Skip to main content

bhtune_server/routes/
capabilities.rs

1use axum::http::{HeaderMap, header};
2use axum::{Json, Router, extract::State};
3use bhtune_cli::config::{
4    DEMO_COOKIE_NAME, DEMO_CYCLES_COUNT_DEFAULT, DEMO_CYCLES_COUNT_MAX, DEMO_CYCLES_COUNT_MIN,
5    DEMO_CYCLES_SKIP_DEFAULT, DEMO_CYCLES_SKIP_MAX, DEMO_CYCLES_SKIP_MIN,
6    DEMO_NOISE_PROTECTION_SECS_DEFAULT, DEMO_NOISE_PROTECTION_SECS_MAX,
7    DEMO_NOISE_PROTECTION_SECS_MIN, DEMO_POLL_INTERVAL_MS, DEMO_RANGE_ENDPOINT_MAX,
8    DEMO_RANGE_ENDPOINT_MIN, DEMO_RANGE_HIGH, DEMO_RANGE_LOW, DEMO_RANGE_SPAN_MAX,
9    DEMO_RANGE_SPAN_MIN, DEMO_RELAY_AMP_DEFAULT, DEMO_RELAY_AMP_MAX, DEMO_RELAY_AMP_MIN,
10    DEMO_RUN_TIMEOUT_SECS, DEMO_SIM_DEAD_TIME_DEFAULT, DEMO_SIM_DEAD_TIME_MAX,
11    DEMO_SIM_DEAD_TIME_MIN, DEMO_SIM_GAIN_ABS_MIN, DEMO_SIM_GAIN_DEFAULT, DEMO_SIM_GAIN_MAX,
12    DEMO_SIM_INITIAL_VALUE_DEFAULT, DEMO_SIM_NOISE_DEFAULT, DEMO_SIM_NOISE_MAX_PV_SPAN_FRACTION,
13    DEMO_SIM_SEED_DEFAULT, DEMO_SIM_SEED_MAX, DEMO_SIM_TAU_DEFAULT, DEMO_SIM_TAU_MAX,
14    DEMO_SIM_TAU_MIN, DEMO_TAG_NAME, DEMO_TEMPLATE_NAME, DemoPolicy, ServerMode,
15};
16use bhtune_core::{ControllerDirection, ControllerType, ProcessType, built_in_templates};
17use serde::Serialize;
18use utoipa::ToSchema;
19
20use crate::error::ApiError;
21use crate::state::AppState;
22
23#[derive(Debug, Serialize, ToSchema, PartialEq, Eq)]
24pub struct CapabilityActions {
25    pub start_simulator_tune: bool,
26    pub start_opcda_tune: bool,
27    pub cancel_run: bool,
28    pub stream_run: bool,
29    pub list_history: bool,
30    pub export_run: bool,
31    pub delete_run: bool,
32    pub edit_notes: bool,
33    pub write_pid: bool,
34    pub revert_pid: bool,
35    pub manage_templates: bool,
36    pub manage_config: bool,
37    pub browse_opc: bool,
38}
39
40impl CapabilityActions {
41    fn for_mode(mode: ServerMode) -> Self {
42        let demo = mode == ServerMode::Demo;
43        Self {
44            start_simulator_tune: true,
45            start_opcda_tune: !demo,
46            cancel_run: true,
47            stream_run: true,
48            list_history: true,
49            export_run: true,
50            delete_run: true,
51            edit_notes: !demo,
52            write_pid: !demo,
53            revert_pid: !demo,
54            manage_templates: !demo,
55            manage_config: !demo,
56            browse_opc: !demo,
57        }
58    }
59}
60
61#[derive(Debug, Serialize, ToSchema, PartialEq)]
62pub struct FloatBounds {
63    pub min: f32,
64    pub max: f32,
65    /// When present, values strictly between `-absolute_min` and `absolute_min` are invalid.
66    pub absolute_min: Option<f32>,
67}
68
69#[derive(Debug, Serialize, ToSchema, PartialEq, Eq)]
70pub struct IntegerBounds {
71    pub min: u64,
72    pub max: u64,
73}
74
75#[derive(Debug, Serialize, ToSchema, PartialEq, Eq)]
76pub struct ProcessControllerCompatibility {
77    pub process_type: ProcessType,
78    pub controller_types: Vec<ControllerType>,
79}
80
81#[derive(Debug, Serialize, ToSchema, PartialEq)]
82pub struct DemoSimulatorDefaults {
83    pub tag_name: String,
84    pub template: String,
85    pub controller_type: ControllerType,
86    pub direction: ControllerDirection,
87    pub pv_range: FloatBounds,
88    pub mv_range: FloatBounds,
89    pub poll_interval_ms: u64,
90    pub run_timeout_secs: u64,
91    pub relay_amp: f32,
92    pub cycles_skip: u32,
93    pub cycles_count: u32,
94    pub noise_protection_secs: u32,
95    pub sim_gain: f32,
96    pub sim_tau: f32,
97    pub sim_dead_time: f32,
98    pub sim_noise: f32,
99    pub sim_seed: u64,
100    pub sim_initial_pv: f32,
101    pub sim_initial_mv: f32,
102}
103
104#[derive(Debug, Serialize, ToSchema, PartialEq)]
105pub struct DemoSimulatorLimits {
106    pub relay_amp: FloatBounds,
107    pub cycles_skip: IntegerBounds,
108    pub cycles_count: IntegerBounds,
109    pub noise_protection_secs: IntegerBounds,
110    pub sim_gain: FloatBounds,
111    pub sim_tau: FloatBounds,
112    pub sim_dead_time: FloatBounds,
113    pub sim_seed: IntegerBounds,
114    pub range_endpoint: FloatBounds,
115    pub range_span: FloatBounds,
116    pub max_noise_fraction_of_pv_span: f32,
117}
118
119#[derive(Debug, Serialize, ToSchema, PartialEq)]
120pub struct DemoSimulatorCapabilities {
121    pub template: String,
122    pub templates: Vec<String>,
123    pub tag_name: String,
124    pub process_types: Vec<ProcessType>,
125    pub controller_types: Vec<ControllerType>,
126    pub compatibility: Vec<ProcessControllerCompatibility>,
127    pub defaults: DemoSimulatorDefaults,
128    pub limits: DemoSimulatorLimits,
129}
130
131#[derive(Debug, Serialize, ToSchema, PartialEq, Eq)]
132pub struct DemoRestrictions {
133    pub simulator_only: bool,
134    pub built_in_templates_only: bool,
135    pub fixed_tag_name: bool,
136    pub direction_must_match_process_gain: bool,
137    pub custom_tag_mappings_allowed: bool,
138    pub notes_allowed: bool,
139    pub automatic_pid_write_allowed: bool,
140    pub post_run_pid_write_allowed: bool,
141}
142
143#[derive(Debug, Serialize, ToSchema, PartialEq, Eq)]
144pub struct DemoQuotas {
145    pub max_active_runs_global: u32,
146    pub max_active_runs_per_visitor: u32,
147    pub max_runs_per_session: u32,
148    pub accepted_starts_per_token: u32,
149    pub accepted_starts_per_client_ip: u32,
150    pub accepted_start_window_secs: u64,
151    pub retained_runs_per_visitor: u32,
152    pub max_tune_run_rows_global: u32,
153    pub max_json_body_bytes: u64,
154    pub max_sse_per_visitor: u32,
155    pub max_sse_global: u32,
156    pub sse_lifetime_secs: u64,
157    pub ordinary_request_concurrency: u32,
158    pub ordinary_request_timeout_secs: u64,
159}
160
161impl From<DemoPolicy> for DemoQuotas {
162    fn from(policy: DemoPolicy) -> Self {
163        Self {
164            max_active_runs_global: policy.max_active_runs_global,
165            max_active_runs_per_visitor: policy.max_active_runs_per_visitor,
166            max_runs_per_session: policy.max_runs_per_session,
167            accepted_starts_per_token: policy.accepted_starts_per_token,
168            accepted_starts_per_client_ip: policy.accepted_starts_per_client_ip,
169            accepted_start_window_secs: policy.accepted_start_window_secs,
170            retained_runs_per_visitor: policy.retained_runs_per_visitor,
171            max_tune_run_rows_global: policy.max_tune_run_rows_global,
172            max_json_body_bytes: policy.max_json_body_bytes,
173            max_sse_per_visitor: policy.max_sse_per_visitor,
174            max_sse_global: policy.max_sse_global,
175            sse_lifetime_secs: policy.sse_lifetime_secs,
176            ordinary_request_concurrency: policy.ordinary_request_concurrency,
177            ordinary_request_timeout_secs: policy.ordinary_request_timeout_secs,
178        }
179    }
180}
181
182#[derive(Debug, Serialize, ToSchema, PartialEq, Eq)]
183pub struct CookieCapabilities {
184    pub name: String,
185    pub path: String,
186    pub max_age_secs: u64,
187    pub http_only: bool,
188    pub secure: bool,
189    pub same_site: String,
190}
191
192#[derive(Debug, Serialize, ToSchema, PartialEq, Eq)]
193pub struct SecurityCapabilities {
194    pub allowed_origin: String,
195    pub exact_origin_required_for_mutations: bool,
196    pub https_required: bool,
197    pub loopback_http_allowed: bool,
198    pub trusted_proxy_configured: bool,
199    pub forwarded_client_ip_header: Option<String>,
200    pub cookie: Option<CookieCapabilities>,
201}
202
203#[derive(Debug, Serialize, ToSchema)]
204pub struct CapabilitiesResponse {
205    pub mode: ServerMode,
206    /// Driver identifiers accepted by the mode's tune-start surface.
207    pub drivers: Vec<String>,
208    pub actions: CapabilityActions,
209    pub demo: bool,
210    pub demo_policy: Option<DemoPolicy>,
211    pub simulator: Option<DemoSimulatorCapabilities>,
212    pub restrictions: Option<DemoRestrictions>,
213    pub quotas: Option<DemoQuotas>,
214    pub security: SecurityCapabilities,
215}
216
217fn compatibility() -> Vec<ProcessControllerCompatibility> {
218    ProcessType::ALL
219        .into_iter()
220        .map(|process_type| ProcessControllerCompatibility {
221            process_type,
222            controller_types: ControllerType::ALL
223                .into_iter()
224                .filter(|controller_type| controller_type.is_allowed_for(process_type))
225                .collect(),
226        })
227        .collect()
228}
229
230fn demo_simulator() -> DemoSimulatorCapabilities {
231    let process_types = ProcessType::ALL.to_vec();
232    DemoSimulatorCapabilities {
233        template: DEMO_TEMPLATE_NAME.to_owned(),
234        tag_name: DEMO_TAG_NAME.to_owned(),
235        controller_types: ControllerType::ALL.to_vec(),
236        compatibility: compatibility(),
237        defaults: DemoSimulatorDefaults {
238            tag_name: DEMO_TAG_NAME.to_owned(),
239            template: DEMO_TEMPLATE_NAME.to_owned(),
240            controller_type: ControllerType::Pi,
241            direction: ControllerDirection::Reverse,
242            pv_range: FloatBounds {
243                min: DEMO_RANGE_LOW,
244                max: DEMO_RANGE_HIGH,
245                absolute_min: None,
246            },
247            mv_range: FloatBounds {
248                min: DEMO_RANGE_LOW,
249                max: DEMO_RANGE_HIGH,
250                absolute_min: None,
251            },
252            poll_interval_ms: DEMO_POLL_INTERVAL_MS,
253            run_timeout_secs: DEMO_RUN_TIMEOUT_SECS,
254            relay_amp: DEMO_RELAY_AMP_DEFAULT,
255            cycles_skip: DEMO_CYCLES_SKIP_DEFAULT,
256            cycles_count: DEMO_CYCLES_COUNT_DEFAULT,
257            noise_protection_secs: DEMO_NOISE_PROTECTION_SECS_DEFAULT,
258            sim_gain: DEMO_SIM_GAIN_DEFAULT,
259            sim_tau: DEMO_SIM_TAU_DEFAULT,
260            sim_dead_time: DEMO_SIM_DEAD_TIME_DEFAULT,
261            sim_noise: DEMO_SIM_NOISE_DEFAULT,
262            sim_seed: DEMO_SIM_SEED_DEFAULT,
263            sim_initial_pv: DEMO_SIM_INITIAL_VALUE_DEFAULT,
264            sim_initial_mv: DEMO_SIM_INITIAL_VALUE_DEFAULT,
265        },
266        limits: DemoSimulatorLimits {
267            relay_amp: FloatBounds {
268                min: DEMO_RELAY_AMP_MIN,
269                max: DEMO_RELAY_AMP_MAX,
270                absolute_min: None,
271            },
272            cycles_skip: IntegerBounds {
273                min: u64::from(DEMO_CYCLES_SKIP_MIN),
274                max: u64::from(DEMO_CYCLES_SKIP_MAX),
275            },
276            cycles_count: IntegerBounds {
277                min: u64::from(DEMO_CYCLES_COUNT_MIN),
278                max: u64::from(DEMO_CYCLES_COUNT_MAX),
279            },
280            noise_protection_secs: IntegerBounds {
281                min: u64::from(DEMO_NOISE_PROTECTION_SECS_MIN),
282                max: u64::from(DEMO_NOISE_PROTECTION_SECS_MAX),
283            },
284            sim_gain: FloatBounds {
285                min: -DEMO_SIM_GAIN_MAX,
286                max: DEMO_SIM_GAIN_MAX,
287                absolute_min: Some(DEMO_SIM_GAIN_ABS_MIN),
288            },
289            sim_tau: FloatBounds {
290                min: DEMO_SIM_TAU_MIN,
291                max: DEMO_SIM_TAU_MAX,
292                absolute_min: None,
293            },
294            sim_dead_time: FloatBounds {
295                min: DEMO_SIM_DEAD_TIME_MIN,
296                max: DEMO_SIM_DEAD_TIME_MAX,
297                absolute_min: None,
298            },
299            sim_seed: IntegerBounds {
300                min: 0,
301                max: DEMO_SIM_SEED_MAX,
302            },
303            range_endpoint: FloatBounds {
304                min: DEMO_RANGE_ENDPOINT_MIN,
305                max: DEMO_RANGE_ENDPOINT_MAX,
306                absolute_min: None,
307            },
308            range_span: FloatBounds {
309                min: DEMO_RANGE_SPAN_MIN,
310                max: DEMO_RANGE_SPAN_MAX,
311                absolute_min: None,
312            },
313            max_noise_fraction_of_pv_span: DEMO_SIM_NOISE_MAX_PV_SPAN_FRACTION,
314        },
315        templates: built_in_templates()
316            .into_iter()
317            .map(|template| template.name)
318            .collect(),
319        process_types,
320    }
321}
322
323#[utoipa::path(
324    get,
325    path = "/api/capabilities",
326    tag = "health",
327    responses((status = 200, body = CapabilitiesResponse))
328)]
329pub(crate) async fn capabilities(
330    State(state): State<AppState>,
331    request_headers: HeaderMap,
332) -> Result<(HeaderMap, Json<CapabilitiesResponse>), ApiError> {
333    let demo = state.mode == ServerMode::Demo;
334    let _request_permit = if demo {
335        Some(crate::routes::demo::ordinary_request_permit(&state)?)
336    } else {
337        None
338    };
339    let actions = CapabilityActions::for_mode(state.mode);
340    let mut response_headers = HeaderMap::new();
341    if demo
342        && let Some(cookie) =
343            crate::routes::demo::session_cookie_header(&request_headers, state.demo_policy)?
344    {
345        response_headers.insert(header::SET_COOKIE, cookie);
346    }
347    Ok((
348        response_headers,
349        Json(CapabilitiesResponse {
350            mode: state.mode,
351            drivers: if demo {
352                vec!["simulator".to_owned()]
353            } else {
354                vec!["opcda".to_owned(), "simulator".to_owned()]
355            },
356            actions,
357            demo,
358            demo_policy: demo.then_some(state.demo_policy),
359            simulator: demo.then(demo_simulator),
360            restrictions: demo.then_some(DemoRestrictions {
361                simulator_only: true,
362                built_in_templates_only: true,
363                fixed_tag_name: true,
364                direction_must_match_process_gain: true,
365                custom_tag_mappings_allowed: false,
366                notes_allowed: false,
367                automatic_pid_write_allowed: false,
368                post_run_pid_write_allowed: false,
369            }),
370            quotas: demo.then(|| state.demo_policy.into()),
371            security: SecurityCapabilities {
372                allowed_origin: state.allowed_origin.clone().unwrap_or_default(),
373                exact_origin_required_for_mutations: demo,
374                https_required: demo,
375                loopback_http_allowed: demo,
376                trusted_proxy_configured: state.trusted_proxy.is_some(),
377                forwarded_client_ip_header: state
378                    .trusted_proxy
379                    .is_some()
380                    .then(|| "X-BHTune-Client-IP".to_owned()),
381                cookie: demo.then_some(CookieCapabilities {
382                    name: DEMO_COOKIE_NAME.to_owned(),
383                    path: "/".to_owned(),
384                    max_age_secs: state.demo_policy.session_ttl_secs,
385                    http_only: true,
386                    secure: true,
387                    same_site: "Strict".to_owned(),
388                }),
389            },
390        }),
391    ))
392}
393
394pub fn router() -> Router<AppState> {
395    Router::new().route("/api/capabilities", axum::routing::get(capabilities))
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[tokio::test]
403    async fn demo_capabilities_publish_the_authoritative_contract() {
404        let mut state = crate::test_support::in_memory_state().await;
405        state.mode = ServerMode::Demo;
406        state.allowed_origin = Some("https://demo.test".to_owned());
407        state.trusted_proxy = Some("127.0.0.1".to_owned());
408
409        let (headers, Json(response)) = capabilities(State(state.clone()), HeaderMap::new())
410            .await
411            .unwrap();
412
413        assert!(response.demo);
414        assert_eq!(response.drivers, ["simulator"]);
415        assert_eq!(response.demo_policy, Some(DemoPolicy::default()));
416        assert!(response.actions.start_simulator_tune);
417        assert!(!response.actions.start_opcda_tune);
418        assert!(!response.actions.write_pid);
419        assert_eq!(
420            response
421                .simulator
422                .as_ref()
423                .unwrap()
424                .compatibility
425                .iter()
426                .find(|item| item.process_type == ProcessType::Flow)
427                .unwrap()
428                .controller_types,
429            [ControllerType::P, ControllerType::Pi]
430        );
431        assert_eq!(
432            response
433                .simulator
434                .as_ref()
435                .unwrap()
436                .defaults
437                .controller_type,
438            ControllerType::Pi
439        );
440        assert_eq!(
441            response.simulator.as_ref().unwrap().limits.cycles_count,
442            IntegerBounds { min: 1, max: 3 }
443        );
444        assert_eq!(
445            response.simulator.as_ref().unwrap().limits.cycles_skip,
446            IntegerBounds { min: 0, max: 2 }
447        );
448        assert_eq!(
449            response.simulator.as_ref().unwrap().limits.sim_gain,
450            FloatBounds {
451                min: -5.0,
452                max: 5.0,
453                absolute_min: Some(0.1),
454            }
455        );
456        assert_eq!(
457            response.simulator.as_ref().unwrap().defaults.relay_amp,
458            10.0
459        );
460        assert_eq!(
461            response.simulator.as_ref().unwrap().defaults.cycles_count,
462            2
463        );
464        assert_eq!(response.simulator.as_ref().unwrap().defaults.sim_tau, 0.5);
465        assert_eq!(
466            response.simulator.as_ref().unwrap().defaults.sim_dead_time,
467            1.0
468        );
469        assert_eq!(
470            response.simulator.as_ref().unwrap().defaults.direction,
471            ControllerDirection::Reverse
472        );
473        assert_eq!(
474            response.simulator.as_ref().unwrap().tag_name,
475            "Simulator demo"
476        );
477        assert_eq!(
478            response.simulator.as_ref().unwrap().templates.len(),
479            built_in_templates().len()
480        );
481        assert_eq!(
482            response.security.cookie.as_ref().unwrap().name,
483            DEMO_COOKIE_NAME
484        );
485        assert_eq!(response.security.allowed_origin, "https://demo.test");
486        assert!(response.security.trusted_proxy_configured);
487        assert_eq!(
488            response.security.forwarded_client_ip_header.as_deref(),
489            Some("X-BHTune-Client-IP")
490        );
491        assert_eq!(
492            response.quotas.as_ref().unwrap().retained_runs_per_visitor,
493            DemoPolicy::default().retained_runs_per_visitor
494        );
495        let cookie = headers[header::SET_COOKIE].to_str().unwrap();
496        assert!(cookie.starts_with("__Host-bhtune_demo_session="));
497        assert!(cookie.contains("; Path=/; Max-Age=86400; HttpOnly; SameSite=Strict; Secure"));
498        assert_eq!(
499            sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM demo_sessions")
500                .fetch_one(&state.pool)
501                .await
502                .unwrap(),
503            0
504        );
505    }
506
507    #[tokio::test]
508    async fn full_capabilities_preserve_the_complete_action_surface() {
509        let state = crate::test_support::in_memory_state().await;
510
511        let (headers, Json(response)) = capabilities(State(state), HeaderMap::new()).await.unwrap();
512
513        assert!(!response.demo);
514        assert_eq!(response.drivers, ["opcda", "simulator"]);
515        assert!(response.actions.start_opcda_tune);
516        assert!(response.actions.write_pid);
517        assert!(response.actions.manage_config);
518        assert_eq!(response.demo_policy, None);
519        assert!(response.simulator.is_none());
520        assert!(response.restrictions.is_none());
521        assert!(response.quotas.is_none());
522        assert!(response.security.cookie.is_none());
523        assert!(!response.security.exact_origin_required_for_mutations);
524        assert!(!headers.contains_key(header::SET_COOKIE));
525    }
526
527    #[tokio::test]
528    async fn demo_capabilities_preserve_an_existing_valid_cookie() {
529        let mut state = crate::test_support::in_memory_state().await;
530        state.mode = ServerMode::Demo;
531        let mut headers = HeaderMap::new();
532        headers.insert(
533            header::COOKIE,
534            format!("other=value; {DEMO_COOKIE_NAME}={}", "ab".repeat(32))
535                .parse()
536                .unwrap(),
537        );
538
539        let (response_headers, _) = capabilities(State(state), headers).await.unwrap();
540
541        assert!(!response_headers.contains_key(header::SET_COOKIE));
542    }
543
544    #[tokio::test]
545    async fn demo_capabilities_replace_a_malformed_cookie() {
546        let mut state = crate::test_support::in_memory_state().await;
547        state.mode = ServerMode::Demo;
548        let mut headers = HeaderMap::new();
549        headers.insert(
550            header::COOKIE,
551            format!("{DEMO_COOKIE_NAME}=invalid").parse().unwrap(),
552        );
553
554        let (response_headers, _) = capabilities(State(state), headers).await.unwrap();
555
556        assert!(response_headers.contains_key(header::SET_COOKIE));
557    }
558}