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 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 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}