1use std::net::SocketAddr;
36use std::path::{Path, PathBuf};
37use std::sync::Arc;
38
39use axum::Router;
40use axum::body::Body;
41use axum::extract::{Request, State};
42use axum::http::{HeaderValue, Method, StatusCode, header};
43use axum::response::{IntoResponse, Response};
44use axum::routing::get;
45use serde::Serialize;
46use tokio::net::TcpListener;
47
48use crate::bus_proxy::{
49 BUS_HEALTH_ROUTE, BUS_METRICS_ROUTE, BUS_READY_ROUTE, LEGACY_LIMINAL_HEALTH_ROUTE,
50 LEGACY_LIMINAL_METRICS_ROUTE, LEGACY_LIMINAL_READY_ROUTE, PRODUCTION_DEADLINES,
51 bus_metrics_body, failure_response, forward_health_request, passthrough_response,
52};
53use crate::error::HostError;
54use crate::truth::AppTruth;
55
56pub const CONFIG_ROUTE: &str = "/frame/config.json";
58
59pub const APP_STATUS_ROUTE: &str = "/frame/app/status.json";
65
66#[derive(Clone, Debug)]
68pub struct ShellConfig {
69 pub bind: SocketAddr,
71 pub asset_root: PathBuf,
73 pub bus_endpoint: String,
75 pub auth_token: String,
77 pub channel: Option<String>,
81 pub channels: Vec<String>,
86 pub bus_health: SocketAddr,
89 pub document: Option<DocumentAdvert>,
92 pub truth: Arc<AppTruth>,
95}
96
97#[derive(Clone, Debug, Serialize)]
101#[serde(rename_all = "camelCase")]
102pub struct DocumentAdvert {
103 pub id: String,
105 pub component_id: String,
107 pub feed_channel: String,
109 pub authoring_channel: String,
111 pub blink_interval_ms: u64,
113}
114
115#[derive(Clone, Debug, Serialize)]
121#[serde(rename_all = "camelCase")]
122struct ShellRuntimeConfig {
123 bus_endpoint: String,
125 liminal_endpoint: String,
128 auth_token: String,
129 #[serde(skip_serializing_if = "Option::is_none")]
130 channel: Option<String>,
131 channels: Vec<String>,
132 #[serde(skip_serializing_if = "Option::is_none")]
135 document: Option<DocumentAdvert>,
136}
137
138struct ShellState {
139 asset_root: PathBuf,
140 config: ShellRuntimeConfig,
141 bus_health: SocketAddr,
142 truth: Arc<AppTruth>,
143}
144
145pub struct ShellServer {
147 listener: TcpListener,
148 router: Router,
149 addr: SocketAddr,
150}
151
152impl ShellServer {
153 pub async fn bind(config: ShellConfig) -> Result<Self, HostError> {
164 let bind_addr = config.bind;
165 let router = Self::prepare(config)?;
166 let listener = TcpListener::bind(bind_addr)
167 .await
168 .map_err(|source| HostError::Bind {
169 addr: bind_addr,
170 source,
171 })?;
172 let addr = listener.local_addr().map_err(|source| HostError::Bind {
173 addr: bind_addr,
174 source,
175 })?;
176 Ok(Self {
177 listener,
178 router,
179 addr,
180 })
181 }
182
183 pub fn adopt(listener: std::net::TcpListener, config: ShellConfig) -> Result<Self, HostError> {
201 let router = Self::prepare(config)?;
202 listener.set_nonblocking(true).map_err(|source| {
203 let addr = listener
204 .local_addr()
205 .unwrap_or_else(|_| std::net::SocketAddr::from(([127, 0, 0, 1], 0)));
206 HostError::Bind { addr, source }
207 })?;
208 let addr = listener.local_addr().map_err(|source| HostError::Bind {
209 addr: std::net::SocketAddr::from(([127, 0, 0, 1], 0)),
210 source,
211 })?;
212 let listener =
213 TcpListener::from_std(listener).map_err(|source| HostError::Bind { addr, source })?;
214 Ok(Self {
215 listener,
216 router,
217 addr,
218 })
219 }
220
221 fn prepare(config: ShellConfig) -> Result<Router, HostError> {
225 if config.bus_endpoint.is_empty() {
226 return Err(HostError::ConfigContract {
227 detail: "the bus endpoint is empty: it is derived from the embedded \
228 WebSocket listener's bound address, which must never be empty — this is \
229 an internal error, not an operator one"
230 .to_owned(),
231 });
232 }
233 if let Some(channel) = &config.channel
234 && channel.is_empty()
235 {
236 return Err(HostError::ConfigContract {
237 detail: "[frame].channel, when set, must be non-empty: the console refuses an \
238 empty channel with CONFIG_INVALID; omit the key to use the SDK's default \
239 channel"
240 .to_owned(),
241 });
242 }
243 validate_channels_contract(&config.channels, config.channel.as_deref())?;
244 let asset_root =
245 config
246 .asset_root
247 .canonicalize()
248 .map_err(|error| HostError::AssetRoot {
249 path: config.asset_root.clone(),
250 detail: error.to_string(),
251 })?;
252 if !asset_root.is_dir() {
253 return Err(HostError::AssetRoot {
254 path: config.asset_root.clone(),
255 detail: "not a directory".to_owned(),
256 });
257 }
258 if !asset_root.join("index.html").is_file() {
259 return Err(HostError::MissingIndex {
260 path: config.asset_root.clone(),
261 });
262 }
263 let state = Arc::new(ShellState {
264 asset_root,
265 config: ShellRuntimeConfig {
266 bus_endpoint: config.bus_endpoint.clone(),
267 liminal_endpoint: config.bus_endpoint,
269 auth_token: config.auth_token,
270 channel: config.channel,
271 channels: config.channels,
272 document: config.document,
273 },
274 bus_health: config.bus_health,
275 truth: config.truth,
276 });
277 Ok(Router::new()
278 .route(CONFIG_ROUTE, get(serve_config))
279 .route(APP_STATUS_ROUTE, get(serve_app_status))
280 .route(BUS_HEALTH_ROUTE, get(proxy_bus_health))
281 .route(BUS_READY_ROUTE, get(proxy_bus_ready))
282 .route(BUS_METRICS_ROUTE, get(proxy_bus_metrics))
283 .route(LEGACY_LIMINAL_HEALTH_ROUTE, get(proxy_legacy_health))
286 .route(LEGACY_LIMINAL_READY_ROUTE, get(proxy_legacy_ready))
287 .route(LEGACY_LIMINAL_METRICS_ROUTE, get(proxy_legacy_metrics))
288 .fallback(serve_asset)
289 .with_state(state))
290 }
291
292 #[must_use]
294 pub const fn local_addr(&self) -> SocketAddr {
295 self.addr
296 }
297
298 pub async fn serve(
304 self,
305 shutdown: impl Future<Output = ()> + Send + 'static,
306 ) -> Result<(), HostError> {
307 axum::serve(self.listener, self.router)
308 .with_graceful_shutdown(shutdown)
309 .await
310 .map_err(|source| HostError::Serve { source })
311 }
312}
313
314fn validate_channels_contract(channels: &[String], channel: Option<&str>) -> Result<(), HostError> {
321 if channels.is_empty() {
322 return Err(HostError::ConfigContract {
323 detail: "the served channels roster is empty: the console subscribes every entry of \
324 `channels`, so an empty roster leaves it with no feed — it is derived from \
325 [bus].channels, which must declare at least one channel"
326 .to_owned(),
327 });
328 }
329 let mut seen = std::collections::HashSet::new();
330 for entry in channels {
331 if entry.is_empty() {
332 return Err(HostError::ConfigContract {
333 detail: "the served channels roster contains an empty channel name: the console \
334 refuses an empty channel with CONFIG_INVALID"
335 .to_owned(),
336 });
337 }
338 if !seen.insert(entry.as_str()) {
339 return Err(HostError::ConfigContract {
340 detail: format!(
341 "the served channels roster names \"{entry}\" more than once: a duplicate \
342 would open two identical subscriptions, and liminal's own config validation \
343 refuses duplicate channel names"
344 ),
345 });
346 }
347 }
348 if let Some(channel) = channel
349 && !channels.iter().any(|entry| entry == channel)
350 {
351 return Err(HostError::ConfigContract {
352 detail: format!(
353 "the served channels roster [{roster}] does not contain the primary channel \
354 \"{channel}\": the console refuses that pair with CONFIG_INVALID",
355 roster = channels.join(", ")
356 ),
357 });
358 }
359 Ok(())
360}
361
362async fn serve_config(State(state): State<Arc<ShellState>>) -> Response {
363 tracing::info!(route = CONFIG_ROUTE, "serving shell runtime configuration");
364 axum::Json(state.config.clone()).into_response()
365}
366
367async fn serve_app_status(State(state): State<Arc<ShellState>>) -> Response {
374 let snapshot = state.truth.snapshot();
375 tracing::info!(
376 route = APP_STATUS_ROUTE,
377 transitions = snapshot.transitions.len(),
378 facts = snapshot.facts.len(),
379 "serving application-truth snapshot"
380 );
381 axum::Json(snapshot).into_response()
382}
383
384async fn proxy_bus_health(State(state): State<Arc<ShellState>>) -> Response {
385 proxy_bus(&state, BUS_HEALTH_ROUTE, "/health").await
386}
387
388async fn proxy_bus_ready(State(state): State<Arc<ShellState>>) -> Response {
389 proxy_bus(&state, BUS_READY_ROUTE, "/ready").await
390}
391
392async fn proxy_bus_metrics(State(state): State<Arc<ShellState>>) -> Response {
393 proxy_bus(&state, BUS_METRICS_ROUTE, "/metrics").await
394}
395
396async fn proxy_legacy_health(State(state): State<Arc<ShellState>>) -> Response {
397 warn_legacy_route(LEGACY_LIMINAL_HEALTH_ROUTE, BUS_HEALTH_ROUTE);
398 proxy_bus(&state, LEGACY_LIMINAL_HEALTH_ROUTE, "/health").await
399}
400
401async fn proxy_legacy_ready(State(state): State<Arc<ShellState>>) -> Response {
402 warn_legacy_route(LEGACY_LIMINAL_READY_ROUTE, BUS_READY_ROUTE);
403 proxy_bus(&state, LEGACY_LIMINAL_READY_ROUTE, "/ready").await
404}
405
406async fn proxy_legacy_metrics(State(state): State<Arc<ShellState>>) -> Response {
407 warn_legacy_route(LEGACY_LIMINAL_METRICS_ROUTE, BUS_METRICS_ROUTE);
408 proxy_bus(&state, LEGACY_LIMINAL_METRICS_ROUTE, "/metrics").await
409}
410
411fn warn_legacy_route(route: &'static str, replacement: &'static str) {
414 tracing::warn!(
415 route,
416 replacement,
417 "DEPRECATED route served: /frame/liminal/* is the legacy alias of /frame/bus/*; update \
418 the client to the bus route — the alias is removed after one compatibility window"
419 );
420}
421
422async fn proxy_bus(state: &ShellState, route: &'static str, upstream_path: &str) -> Response {
427 match forward_health_request(state.bus_health, upstream_path, PRODUCTION_DEADLINES).await {
428 Ok(mut upstream) => {
429 if upstream_path == "/metrics" {
430 upstream.body = bus_metrics_body(&upstream.body);
431 }
432 tracing::info!(
433 route,
434 upstream_path,
435 status = upstream.status,
436 bytes = upstream.body.len(),
437 "proxied bus health route"
438 );
439 passthrough_response(upstream)
440 }
441 Err(failure) => {
442 tracing::error!(
443 route,
444 upstream_path,
445 code = failure.code(),
446 detail = failure.detail(),
447 "bus health proxy failed typed"
448 );
449 failure_response(upstream_path, &failure)
450 }
451 }
452}
453
454async fn serve_asset(State(state): State<Arc<ShellState>>, request: Request) -> Response {
455 let method = request.method().clone();
456 let raw_path = request.uri().path().to_owned();
457 if method != Method::GET && method != Method::HEAD {
458 tracing::warn!(%method, path = %raw_path, "method not allowed on static shell");
459 return error_page(
460 StatusCode::METHOD_NOT_ALLOWED,
461 "METHOD NOT ALLOWED",
462 &format!("The static shell serves GET and HEAD only; {method} was refused."),
463 );
464 }
465 let relative = match sanitize_request_path(&raw_path) {
466 Ok(relative) => relative,
467 Err(refusal) => {
468 tracing::warn!(path = %raw_path, %refusal, "refusing malformed asset path");
469 return error_page(StatusCode::BAD_REQUEST, "BAD ASSET PATH", &refusal);
470 }
471 };
472 let candidate = state.asset_root.join(&relative);
473 let resolved = match candidate.canonicalize() {
474 Ok(resolved) => resolved,
475 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
476 tracing::warn!(path = %raw_path, "asset not found");
477 return not_found_page(&raw_path, &state.asset_root);
478 }
479 Err(error) => {
480 tracing::error!(path = %raw_path, %error, "asset resolution failed");
481 return error_page(
482 StatusCode::INTERNAL_SERVER_ERROR,
483 "ASSET RESOLUTION FAILED",
484 &format!("Resolving {raw_path} failed: {error}"),
485 );
486 }
487 };
488 if !resolved.starts_with(&state.asset_root) {
489 tracing::warn!(path = %raw_path, resolved = %resolved.display(), "asset escapes the asset root");
490 return error_page(
491 StatusCode::BAD_REQUEST,
492 "BAD ASSET PATH",
493 "The requested path resolves outside the configured asset directory.",
494 );
495 }
496 if !resolved.is_file() {
497 tracing::warn!(path = %raw_path, "asset path is not a file");
498 return not_found_page(&raw_path, &state.asset_root);
499 }
500 match tokio::fs::read(&resolved).await {
501 Ok(bytes) => {
502 let mime = mime_for(&resolved);
503 tracing::info!(path = %raw_path, bytes = bytes.len(), mime, "served asset");
504 let mut response = Response::new(Body::from(bytes));
505 response
506 .headers_mut()
507 .insert(header::CONTENT_TYPE, HeaderValue::from_static(mime));
508 response
509 }
510 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
511 tracing::warn!(path = %raw_path, "asset vanished between resolution and read");
512 not_found_page(&raw_path, &state.asset_root)
513 }
514 Err(error) => {
515 tracing::error!(path = %raw_path, %error, "asset read failed");
516 error_page(
517 StatusCode::INTERNAL_SERVER_ERROR,
518 "ASSET READ FAILED",
519 &format!("Reading {raw_path} failed: {error}"),
520 )
521 }
522 }
523}
524
525fn sanitize_request_path(raw: &str) -> Result<PathBuf, String> {
531 let decoded = percent_decode(raw)?;
532 let Some(stripped) = decoded.strip_prefix('/') else {
533 return Err("request path must start with '/'".to_owned());
534 };
535 if stripped.is_empty() {
536 return Ok(PathBuf::from("index.html"));
537 }
538 let mut relative = PathBuf::new();
539 for component in stripped.split('/') {
540 if component.is_empty() {
541 return Err("empty path component (doubled or trailing '/')".to_owned());
542 }
543 if component == "." || component == ".." {
544 return Err("relative path components are refused".to_owned());
545 }
546 if component.contains('\0') || component.contains('\\') {
547 return Err("path component contains a refused character".to_owned());
548 }
549 relative.push(component);
550 }
551 Ok(relative)
552}
553
554fn percent_decode(raw: &str) -> Result<String, String> {
557 if !raw.contains('%') {
558 return Ok(raw.to_owned());
559 }
560 let bytes = raw.as_bytes();
561 let mut decoded = Vec::with_capacity(bytes.len());
562 let mut index = 0;
563 while index < bytes.len() {
564 if bytes[index] == b'%' {
565 let (Some(high), Some(low)) = (
566 bytes.get(index + 1).and_then(|byte| hex_value(*byte)),
567 bytes.get(index + 2).and_then(|byte| hex_value(*byte)),
568 ) else {
569 return Err("malformed percent-encoding".to_owned());
570 };
571 decoded.push(high * 16 + low);
572 index += 3;
573 } else {
574 decoded.push(bytes[index]);
575 index += 1;
576 }
577 }
578 String::from_utf8(decoded).map_err(|_| "percent-encoding decodes to invalid UTF-8".to_owned())
579}
580
581const fn hex_value(byte: u8) -> Option<u8> {
582 match byte {
583 b'0'..=b'9' => Some(byte - b'0'),
584 b'a'..=b'f' => Some(byte - b'a' + 10),
585 b'A'..=b'F' => Some(byte - b'A' + 10),
586 _ => None,
587 }
588}
589
590fn mime_for(path: &Path) -> &'static str {
593 let extension = path
594 .extension()
595 .and_then(|extension| extension.to_str())
596 .map(str::to_ascii_lowercase);
597 match extension.as_deref() {
598 Some("html") => "text/html; charset=utf-8",
599 Some("js" | "mjs") => "text/javascript; charset=utf-8",
600 Some("css") => "text/css; charset=utf-8",
601 Some("wasm") => "application/wasm",
602 Some("json" | "map") => "application/json; charset=utf-8",
603 Some("svg") => "image/svg+xml",
604 Some("png") => "image/png",
605 Some("jpg" | "jpeg") => "image/jpeg",
606 Some("gif") => "image/gif",
607 Some("webp") => "image/webp",
608 Some("ico") => "image/x-icon",
609 Some("txt") => "text/plain; charset=utf-8",
610 Some("woff") => "font/woff",
611 Some("woff2") => "font/woff2",
612 Some("ttf") => "font/ttf",
613 Some("otf") => "font/otf",
614 _ => "application/octet-stream",
615 }
616}
617
618fn not_found_page(raw_path: &str, asset_root: &Path) -> Response {
619 error_page(
620 StatusCode::NOT_FOUND,
621 "ASSET NOT FOUND",
622 &format!(
623 "No file named {raw_path} exists under the configured asset directory \
624 {root}. This host never falls back to index.html: a missing asset is \
625 this loud page, not a silently mis-served shell.",
626 root = asset_root.display()
627 ),
628 )
629}
630
631fn error_page(status: StatusCode, title: &str, detail: &str) -> Response {
632 let body = format!(
633 "<!doctype html>\n<html lang=\"en\">\n<head><meta charset=\"utf-8\">\
634 <title>frame-host: {status} {title}</title></head>\n<body style=\"font-family: monospace; \
635 background: #1a0505; color: #ffb4b4; padding: 2rem;\">\n<h1>FRAME HOST — {status} {title}</h1>\n\
636 <p>{detail}</p>\n</body>\n</html>\n",
637 status = status.as_u16(),
638 );
639 let mut response = Response::new(Body::from(body));
640 *response.status_mut() = status;
641 response.headers_mut().insert(
642 header::CONTENT_TYPE,
643 HeaderValue::from_static("text/html; charset=utf-8"),
644 );
645 response
646}
647
648#[cfg(test)]
649mod tests {
650 use super::{mime_for, percent_decode, sanitize_request_path, validate_channels_contract};
651 use crate::error::HostError;
652 use std::path::{Path, PathBuf};
653
654 fn roster(entries: &[&str]) -> Vec<String> {
655 entries.iter().map(|entry| (*entry).to_owned()).collect()
656 }
657
658 #[test]
661 fn channels_contract_accepts_valid_rosters() {
662 assert!(validate_channels_contract(&roster(&["telemetry.stream"]), None).is_ok());
663 assert!(
664 validate_channels_contract(
665 &roster(&["telemetry.stream", "telemetry.east"]),
666 Some("telemetry.east"),
667 )
668 .is_ok()
669 );
670 }
671
672 #[test]
673 fn channels_contract_refuses_empty_roster() {
674 let refusal = validate_channels_contract(&[], None);
675 assert!(matches!(refusal, Err(HostError::ConfigContract { .. })));
676 }
677
678 #[test]
679 fn channels_contract_refuses_empty_and_duplicate_entries()
680 -> Result<(), Box<dyn std::error::Error>> {
681 let empty_entry = validate_channels_contract(&roster(&["telemetry.stream", ""]), None);
682 assert!(matches!(empty_entry, Err(HostError::ConfigContract { .. })));
683
684 let duplicate = validate_channels_contract(
685 &roster(&["telemetry.stream", "telemetry.stream"]),
686 Some("telemetry.stream"),
687 );
688 let Err(HostError::ConfigContract { detail }) = duplicate else {
689 return Err("expected ConfigContract for a duplicate entry".into());
690 };
691 assert!(
692 detail.contains("more than once"),
693 "duplicate refusal must state the duplication: {detail}"
694 );
695 Ok(())
696 }
697
698 #[test]
699 fn channels_contract_refuses_a_primary_outside_the_roster()
700 -> Result<(), Box<dyn std::error::Error>> {
701 let refusal = validate_channels_contract(
702 &roster(&["telemetry.stream", "telemetry.east"]),
703 Some("telemetry.west"),
704 );
705 let Err(HostError::ConfigContract { detail }) = refusal else {
706 return Err("expected ConfigContract for a foreign primary".into());
707 };
708 assert!(
709 detail.contains("telemetry.west"),
710 "refusal must name the missing primary: {detail}"
711 );
712 Ok(())
713 }
714
715 #[test]
716 fn root_maps_to_index_only() {
717 assert_eq!(
718 sanitize_request_path("/").ok(),
719 Some(PathBuf::from("index.html"))
720 );
721 assert_eq!(
722 sanitize_request_path("/assets/app.js").ok(),
723 Some(PathBuf::from("assets/app.js"))
724 );
725 }
726
727 #[test]
728 fn traversal_and_malformed_paths_are_refused() {
729 assert!(sanitize_request_path("/../secret").is_err());
730 assert!(sanitize_request_path("/a/../b").is_err());
731 assert!(sanitize_request_path("/%2e%2e/secret").is_err());
732 assert!(sanitize_request_path("//double").is_err());
733 assert!(sanitize_request_path("/a/./b").is_err());
734 assert!(sanitize_request_path("/a%00b").is_err());
735 assert!(sanitize_request_path("/a%zz").is_err());
736 assert!(sanitize_request_path("no-leading-slash").is_err());
737 }
738
739 #[test]
740 fn percent_decoding_is_strict() {
741 assert_eq!(percent_decode("/plain").ok().as_deref(), Some("/plain"));
742 assert_eq!(percent_decode("/a%20b").ok().as_deref(), Some("/a b"));
743 assert!(percent_decode("/broken%2").is_err());
744 assert!(percent_decode("/broken%").is_err());
745 assert!(percent_decode("/bad%ff%fe").is_err());
746 }
747
748 #[test]
749 fn wasm_and_core_types_map_exactly() {
750 assert_eq!(mime_for(Path::new("a/app.wasm")), "application/wasm");
751 assert_eq!(
752 mime_for(Path::new("index.html")),
753 "text/html; charset=utf-8"
754 );
755 assert_eq!(
756 mime_for(Path::new("assets/index-abc.js")),
757 "text/javascript; charset=utf-8"
758 );
759 assert_eq!(mime_for(Path::new("noext")), "application/octet-stream");
760 }
761}