use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use axum::Router;
use axum::body::Body;
use axum::extract::{Request, State};
use axum::http::{HeaderValue, Method, StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use serde::Serialize;
use tokio::net::TcpListener;
use crate::bus_proxy::{
BUS_HEALTH_ROUTE, BUS_METRICS_ROUTE, BUS_READY_ROUTE, LEGACY_LIMINAL_HEALTH_ROUTE,
LEGACY_LIMINAL_METRICS_ROUTE, LEGACY_LIMINAL_READY_ROUTE, PRODUCTION_DEADLINES,
bus_metrics_body, failure_response, forward_health_request, passthrough_response,
};
use crate::error::HostError;
use crate::truth::AppTruth;
pub const CONFIG_ROUTE: &str = "/frame/config.json";
pub const APP_STATUS_ROUTE: &str = "/frame/app/status.json";
#[derive(Clone, Debug)]
pub struct ShellConfig {
pub bind: SocketAddr,
pub asset_root: PathBuf,
pub bus_endpoint: String,
pub auth_token: String,
pub channel: Option<String>,
pub channels: Vec<String>,
pub bus_health: SocketAddr,
pub document: Option<DocumentAdvert>,
pub truth: Arc<AppTruth>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentAdvert {
pub id: String,
pub component_id: String,
pub feed_channel: String,
pub authoring_channel: String,
pub blink_interval_ms: u64,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ShellRuntimeConfig {
bus_endpoint: String,
liminal_endpoint: String,
auth_token: String,
#[serde(skip_serializing_if = "Option::is_none")]
channel: Option<String>,
channels: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
document: Option<DocumentAdvert>,
}
struct ShellState {
asset_root: PathBuf,
config: ShellRuntimeConfig,
bus_health: SocketAddr,
truth: Arc<AppTruth>,
}
pub struct ShellServer {
listener: TcpListener,
router: Router,
addr: SocketAddr,
}
impl ShellServer {
pub async fn bind(config: ShellConfig) -> Result<Self, HostError> {
if config.bus_endpoint.is_empty() {
return Err(HostError::ConfigContract {
detail: "the bus endpoint is empty: it is derived from the embedded \
WebSocket listener's bound address, which must never be empty — this is \
an internal error, not an operator one"
.to_owned(),
});
}
if let Some(channel) = &config.channel
&& channel.is_empty()
{
return Err(HostError::ConfigContract {
detail: "[frame].channel, when set, must be non-empty: the console refuses an \
empty channel with CONFIG_INVALID; omit the key to use the SDK's default \
channel"
.to_owned(),
});
}
validate_channels_contract(&config.channels, config.channel.as_deref())?;
let asset_root =
config
.asset_root
.canonicalize()
.map_err(|error| HostError::AssetRoot {
path: config.asset_root.clone(),
detail: error.to_string(),
})?;
if !asset_root.is_dir() {
return Err(HostError::AssetRoot {
path: config.asset_root.clone(),
detail: "not a directory".to_owned(),
});
}
if !asset_root.join("index.html").is_file() {
return Err(HostError::MissingIndex {
path: config.asset_root.clone(),
});
}
let state = Arc::new(ShellState {
asset_root,
config: ShellRuntimeConfig {
bus_endpoint: config.bus_endpoint.clone(),
liminal_endpoint: config.bus_endpoint,
auth_token: config.auth_token,
channel: config.channel,
channels: config.channels,
document: config.document,
},
bus_health: config.bus_health,
truth: config.truth,
});
let router = Router::new()
.route(CONFIG_ROUTE, get(serve_config))
.route(APP_STATUS_ROUTE, get(serve_app_status))
.route(BUS_HEALTH_ROUTE, get(proxy_bus_health))
.route(BUS_READY_ROUTE, get(proxy_bus_ready))
.route(BUS_METRICS_ROUTE, get(proxy_bus_metrics))
.route(LEGACY_LIMINAL_HEALTH_ROUTE, get(proxy_legacy_health))
.route(LEGACY_LIMINAL_READY_ROUTE, get(proxy_legacy_ready))
.route(LEGACY_LIMINAL_METRICS_ROUTE, get(proxy_legacy_metrics))
.fallback(serve_asset)
.with_state(state);
let listener = TcpListener::bind(config.bind)
.await
.map_err(|source| HostError::Bind {
addr: config.bind,
source,
})?;
let addr = listener.local_addr().map_err(|source| HostError::Bind {
addr: config.bind,
source,
})?;
Ok(Self {
listener,
router,
addr,
})
}
#[must_use]
pub const fn local_addr(&self) -> SocketAddr {
self.addr
}
pub async fn serve(
self,
shutdown: impl Future<Output = ()> + Send + 'static,
) -> Result<(), HostError> {
axum::serve(self.listener, self.router)
.with_graceful_shutdown(shutdown)
.await
.map_err(|source| HostError::Serve { source })
}
}
fn validate_channels_contract(channels: &[String], channel: Option<&str>) -> Result<(), HostError> {
if channels.is_empty() {
return Err(HostError::ConfigContract {
detail: "the served channels roster is empty: the console subscribes every entry of \
`channels`, so an empty roster leaves it with no feed — it is derived from \
[bus].channels, which must declare at least one channel"
.to_owned(),
});
}
let mut seen = std::collections::HashSet::new();
for entry in channels {
if entry.is_empty() {
return Err(HostError::ConfigContract {
detail: "the served channels roster contains an empty channel name: the console \
refuses an empty channel with CONFIG_INVALID"
.to_owned(),
});
}
if !seen.insert(entry.as_str()) {
return Err(HostError::ConfigContract {
detail: format!(
"the served channels roster names \"{entry}\" more than once: a duplicate \
would open two identical subscriptions, and liminal's own config validation \
refuses duplicate channel names"
),
});
}
}
if let Some(channel) = channel
&& !channels.iter().any(|entry| entry == channel)
{
return Err(HostError::ConfigContract {
detail: format!(
"the served channels roster [{roster}] does not contain the primary channel \
\"{channel}\": the console refuses that pair with CONFIG_INVALID",
roster = channels.join(", ")
),
});
}
Ok(())
}
async fn serve_config(State(state): State<Arc<ShellState>>) -> Response {
tracing::info!(route = CONFIG_ROUTE, "serving shell runtime configuration");
axum::Json(state.config.clone()).into_response()
}
async fn serve_app_status(State(state): State<Arc<ShellState>>) -> Response {
let snapshot = state.truth.snapshot();
tracing::info!(
route = APP_STATUS_ROUTE,
transitions = snapshot.transitions.len(),
facts = snapshot.facts.len(),
"serving application-truth snapshot"
);
axum::Json(snapshot).into_response()
}
async fn proxy_bus_health(State(state): State<Arc<ShellState>>) -> Response {
proxy_bus(&state, BUS_HEALTH_ROUTE, "/health").await
}
async fn proxy_bus_ready(State(state): State<Arc<ShellState>>) -> Response {
proxy_bus(&state, BUS_READY_ROUTE, "/ready").await
}
async fn proxy_bus_metrics(State(state): State<Arc<ShellState>>) -> Response {
proxy_bus(&state, BUS_METRICS_ROUTE, "/metrics").await
}
async fn proxy_legacy_health(State(state): State<Arc<ShellState>>) -> Response {
warn_legacy_route(LEGACY_LIMINAL_HEALTH_ROUTE, BUS_HEALTH_ROUTE);
proxy_bus(&state, LEGACY_LIMINAL_HEALTH_ROUTE, "/health").await
}
async fn proxy_legacy_ready(State(state): State<Arc<ShellState>>) -> Response {
warn_legacy_route(LEGACY_LIMINAL_READY_ROUTE, BUS_READY_ROUTE);
proxy_bus(&state, LEGACY_LIMINAL_READY_ROUTE, "/ready").await
}
async fn proxy_legacy_metrics(State(state): State<Arc<ShellState>>) -> Response {
warn_legacy_route(LEGACY_LIMINAL_METRICS_ROUTE, BUS_METRICS_ROUTE);
proxy_bus(&state, LEGACY_LIMINAL_METRICS_ROUTE, "/metrics").await
}
fn warn_legacy_route(route: &'static str, replacement: &'static str) {
tracing::warn!(
route,
replacement,
"DEPRECATED route served: /frame/liminal/* is the legacy alias of /frame/bus/*; update \
the client to the bus route — the alias is removed after one compatibility window"
);
}
async fn proxy_bus(state: &ShellState, route: &'static str, upstream_path: &str) -> Response {
match forward_health_request(state.bus_health, upstream_path, PRODUCTION_DEADLINES).await {
Ok(mut upstream) => {
if upstream_path == "/metrics" {
upstream.body = bus_metrics_body(&upstream.body);
}
tracing::info!(
route,
upstream_path,
status = upstream.status,
bytes = upstream.body.len(),
"proxied bus health route"
);
passthrough_response(upstream)
}
Err(failure) => {
tracing::error!(
route,
upstream_path,
code = failure.code(),
detail = failure.detail(),
"bus health proxy failed typed"
);
failure_response(upstream_path, &failure)
}
}
}
async fn serve_asset(State(state): State<Arc<ShellState>>, request: Request) -> Response {
let method = request.method().clone();
let raw_path = request.uri().path().to_owned();
if method != Method::GET && method != Method::HEAD {
tracing::warn!(%method, path = %raw_path, "method not allowed on static shell");
return error_page(
StatusCode::METHOD_NOT_ALLOWED,
"METHOD NOT ALLOWED",
&format!("The static shell serves GET and HEAD only; {method} was refused."),
);
}
let relative = match sanitize_request_path(&raw_path) {
Ok(relative) => relative,
Err(refusal) => {
tracing::warn!(path = %raw_path, %refusal, "refusing malformed asset path");
return error_page(StatusCode::BAD_REQUEST, "BAD ASSET PATH", &refusal);
}
};
let candidate = state.asset_root.join(&relative);
let resolved = match candidate.canonicalize() {
Ok(resolved) => resolved,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
tracing::warn!(path = %raw_path, "asset not found");
return not_found_page(&raw_path, &state.asset_root);
}
Err(error) => {
tracing::error!(path = %raw_path, %error, "asset resolution failed");
return error_page(
StatusCode::INTERNAL_SERVER_ERROR,
"ASSET RESOLUTION FAILED",
&format!("Resolving {raw_path} failed: {error}"),
);
}
};
if !resolved.starts_with(&state.asset_root) {
tracing::warn!(path = %raw_path, resolved = %resolved.display(), "asset escapes the asset root");
return error_page(
StatusCode::BAD_REQUEST,
"BAD ASSET PATH",
"The requested path resolves outside the configured asset directory.",
);
}
if !resolved.is_file() {
tracing::warn!(path = %raw_path, "asset path is not a file");
return not_found_page(&raw_path, &state.asset_root);
}
match tokio::fs::read(&resolved).await {
Ok(bytes) => {
let mime = mime_for(&resolved);
tracing::info!(path = %raw_path, bytes = bytes.len(), mime, "served asset");
let mut response = Response::new(Body::from(bytes));
response
.headers_mut()
.insert(header::CONTENT_TYPE, HeaderValue::from_static(mime));
response
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
tracing::warn!(path = %raw_path, "asset vanished between resolution and read");
not_found_page(&raw_path, &state.asset_root)
}
Err(error) => {
tracing::error!(path = %raw_path, %error, "asset read failed");
error_page(
StatusCode::INTERNAL_SERVER_ERROR,
"ASSET READ FAILED",
&format!("Reading {raw_path} failed: {error}"),
)
}
}
}
fn sanitize_request_path(raw: &str) -> Result<PathBuf, String> {
let decoded = percent_decode(raw)?;
let Some(stripped) = decoded.strip_prefix('/') else {
return Err("request path must start with '/'".to_owned());
};
if stripped.is_empty() {
return Ok(PathBuf::from("index.html"));
}
let mut relative = PathBuf::new();
for component in stripped.split('/') {
if component.is_empty() {
return Err("empty path component (doubled or trailing '/')".to_owned());
}
if component == "." || component == ".." {
return Err("relative path components are refused".to_owned());
}
if component.contains('\0') || component.contains('\\') {
return Err("path component contains a refused character".to_owned());
}
relative.push(component);
}
Ok(relative)
}
fn percent_decode(raw: &str) -> Result<String, String> {
if !raw.contains('%') {
return Ok(raw.to_owned());
}
let bytes = raw.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' {
let (Some(high), Some(low)) = (
bytes.get(index + 1).and_then(|byte| hex_value(*byte)),
bytes.get(index + 2).and_then(|byte| hex_value(*byte)),
) else {
return Err("malformed percent-encoding".to_owned());
};
decoded.push(high * 16 + low);
index += 3;
} else {
decoded.push(bytes[index]);
index += 1;
}
}
String::from_utf8(decoded).map_err(|_| "percent-encoding decodes to invalid UTF-8".to_owned())
}
const fn hex_value(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
fn mime_for(path: &Path) -> &'static str {
let extension = path
.extension()
.and_then(|extension| extension.to_str())
.map(str::to_ascii_lowercase);
match extension.as_deref() {
Some("html") => "text/html; charset=utf-8",
Some("js" | "mjs") => "text/javascript; charset=utf-8",
Some("css") => "text/css; charset=utf-8",
Some("wasm") => "application/wasm",
Some("json" | "map") => "application/json; charset=utf-8",
Some("svg") => "image/svg+xml",
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("ico") => "image/x-icon",
Some("txt") => "text/plain; charset=utf-8",
Some("woff") => "font/woff",
Some("woff2") => "font/woff2",
Some("ttf") => "font/ttf",
Some("otf") => "font/otf",
_ => "application/octet-stream",
}
}
fn not_found_page(raw_path: &str, asset_root: &Path) -> Response {
error_page(
StatusCode::NOT_FOUND,
"ASSET NOT FOUND",
&format!(
"No file named {raw_path} exists under the configured asset directory \
{root}. This host never falls back to index.html: a missing asset is \
this loud page, not a silently mis-served shell.",
root = asset_root.display()
),
)
}
fn error_page(status: StatusCode, title: &str, detail: &str) -> Response {
let body = format!(
"<!doctype html>\n<html lang=\"en\">\n<head><meta charset=\"utf-8\">\
<title>frame-host: {status} {title}</title></head>\n<body style=\"font-family: monospace; \
background: #1a0505; color: #ffb4b4; padding: 2rem;\">\n<h1>FRAME HOST — {status} {title}</h1>\n\
<p>{detail}</p>\n</body>\n</html>\n",
status = status.as_u16(),
);
let mut response = Response::new(Body::from(body));
*response.status_mut() = status;
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
);
response
}
#[cfg(test)]
mod tests {
use super::{mime_for, percent_decode, sanitize_request_path, validate_channels_contract};
use crate::error::HostError;
use std::path::{Path, PathBuf};
fn roster(entries: &[&str]) -> Vec<String> {
entries.iter().map(|entry| (*entry).to_owned()).collect()
}
#[test]
fn channels_contract_accepts_valid_rosters() {
assert!(validate_channels_contract(&roster(&["telemetry.stream"]), None).is_ok());
assert!(
validate_channels_contract(
&roster(&["telemetry.stream", "telemetry.east"]),
Some("telemetry.east"),
)
.is_ok()
);
}
#[test]
fn channels_contract_refuses_empty_roster() {
let refusal = validate_channels_contract(&[], None);
assert!(matches!(refusal, Err(HostError::ConfigContract { .. })));
}
#[test]
fn channels_contract_refuses_empty_and_duplicate_entries()
-> Result<(), Box<dyn std::error::Error>> {
let empty_entry = validate_channels_contract(&roster(&["telemetry.stream", ""]), None);
assert!(matches!(empty_entry, Err(HostError::ConfigContract { .. })));
let duplicate = validate_channels_contract(
&roster(&["telemetry.stream", "telemetry.stream"]),
Some("telemetry.stream"),
);
let Err(HostError::ConfigContract { detail }) = duplicate else {
return Err("expected ConfigContract for a duplicate entry".into());
};
assert!(
detail.contains("more than once"),
"duplicate refusal must state the duplication: {detail}"
);
Ok(())
}
#[test]
fn channels_contract_refuses_a_primary_outside_the_roster()
-> Result<(), Box<dyn std::error::Error>> {
let refusal = validate_channels_contract(
&roster(&["telemetry.stream", "telemetry.east"]),
Some("telemetry.west"),
);
let Err(HostError::ConfigContract { detail }) = refusal else {
return Err("expected ConfigContract for a foreign primary".into());
};
assert!(
detail.contains("telemetry.west"),
"refusal must name the missing primary: {detail}"
);
Ok(())
}
#[test]
fn root_maps_to_index_only() {
assert_eq!(
sanitize_request_path("/").ok(),
Some(PathBuf::from("index.html"))
);
assert_eq!(
sanitize_request_path("/assets/app.js").ok(),
Some(PathBuf::from("assets/app.js"))
);
}
#[test]
fn traversal_and_malformed_paths_are_refused() {
assert!(sanitize_request_path("/../secret").is_err());
assert!(sanitize_request_path("/a/../b").is_err());
assert!(sanitize_request_path("/%2e%2e/secret").is_err());
assert!(sanitize_request_path("//double").is_err());
assert!(sanitize_request_path("/a/./b").is_err());
assert!(sanitize_request_path("/a%00b").is_err());
assert!(sanitize_request_path("/a%zz").is_err());
assert!(sanitize_request_path("no-leading-slash").is_err());
}
#[test]
fn percent_decoding_is_strict() {
assert_eq!(percent_decode("/plain").ok().as_deref(), Some("/plain"));
assert_eq!(percent_decode("/a%20b").ok().as_deref(), Some("/a b"));
assert!(percent_decode("/broken%2").is_err());
assert!(percent_decode("/broken%").is_err());
assert!(percent_decode("/bad%ff%fe").is_err());
}
#[test]
fn wasm_and_core_types_map_exactly() {
assert_eq!(mime_for(Path::new("a/app.wasm")), "application/wasm");
assert_eq!(
mime_for(Path::new("index.html")),
"text/html; charset=utf-8"
);
assert_eq!(
mime_for(Path::new("assets/index-abc.js")),
"text/javascript; charset=utf-8"
);
assert_eq!(mime_for(Path::new("noext")), "application/octet-stream");
}
}