Skip to main content

horto_os_ui_status_api/
main.rs

1//! Horto OS UI status API: box-local HTTP `/health` and `/v1/status`.
2//!
3//! Day-2 `/etc` backup mutate routes require a confirm header. Bind and token
4//! come from CLI flags / env; logging uses shared `init_tracing`.
5
6use anyhow::{bail, Context, Result};
7use axum::{
8    extract::{ConnectInfo, State},
9    http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode},
10    middleware::{self, Next},
11    response::{IntoResponse, Response},
12    routing::{get, post},
13    Json, Router,
14};
15use clap::Parser;
16use horto_os_ui_shared::{
17    backup_etc_timestamped, box_status, footer_line, init_tracing, ApplyMode, HostContext,
18    SetupKind, LONG_VERSION,
19};
20use serde::Serialize;
21use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs};
22use std::sync::Arc;
23use subtle::ConstantTimeEq;
24use tower_http::cors::{AllowOrigin, CorsLayer};
25use tower_http::trace::TraceLayer;
26use tracing::{info, warn};
27
28/// Exact confirm value required on mutating backup routes.
29const CONFIRM_BACKUP_ETC: &str = "backup-etc";
30const CONFIRM_HEADER: &str = "x-horto-confirm";
31
32#[derive(Parser, Debug)]
33#[command(
34    name = "horto-os-ui-status-api",
35    about = "Horto OS UI box-local status API",
36    version,
37    long_version = LONG_VERSION
38)]
39struct Cli {
40    /// Bind host:port. Default `0.0.0.0:8787` listens on all IPv4 interfaces so
41    /// the box hostname / LAN IP can reach the API (not only loopback).
42    /// Peer addresses are still restricted to loopback / private / link-local.
43    #[arg(long, default_value = "0.0.0.0:8787", env = "HORTO_API_BIND")]
44    bind: String,
45    #[arg(long, env = "HORTO_API_TOKEN")]
46    token: Option<String>,
47}
48
49#[derive(Clone)]
50struct AppState {
51    token: Option<String>,
52}
53
54#[derive(Serialize)]
55struct Health {
56    ok: bool,
57}
58
59#[tokio::main]
60async fn main() -> Result<()> {
61    init_tracing("horto_os_ui_status_api=info,tower_http=info");
62
63    let cli = Cli::parse();
64    info!("{}", footer_line());
65    if cli.token.is_none() {
66        warn!(
67            "HORTO_API_TOKEN unset; GET /v1/status accepts unauthenticated local-network clients; \
68             POST mutate routes are disabled (503)"
69        );
70    } else {
71        info!("HORTO_API_TOKEN set; bearer required for /v1/status and mutate routes");
72    }
73    info!("rejecting non-local client IPs (loopback / RFC1918 / ULA / link-local only)");
74
75    let state = Arc::new(AppState { token: cli.token });
76    // Browser / Tauri webview origins differ from the API origin, so a CORS
77    // allowlist is required for `fetch`. Real auth is HORTO_API_TOKEN; peer IPs
78    // must still be local-network (see local_net_middleware).
79    let cors = local_desktop_cors();
80
81    let app = Router::new()
82        .route("/health", get(health))
83        .route("/v1/status", get(status))
84        .route("/v1/backup/etc", post(backup_etc))
85        .layer(middleware::from_fn_with_state(
86            state.clone(),
87            auth_middleware,
88        ))
89        .layer(middleware::from_fn(local_net_middleware))
90        .layer(cors)
91        .layer(TraceLayer::new_for_http())
92        .with_state(state);
93
94    let addrs = resolve_bind(&cli.bind)?;
95    let mut addrs = addrs.into_iter();
96    let first = addrs
97        .next()
98        .ok_or_else(|| anyhow::anyhow!("no bind addresses for {}", cli.bind))?;
99    let first_listener = tokio::net::TcpListener::bind(first)
100        .await
101        .with_context(|| format!("bind {first}"))?;
102    info!("listening on {}", listen_url(first));
103
104    for addr in addrs {
105        match tokio::net::TcpListener::bind(addr).await {
106            Ok(listener) => {
107                info!("listening on {}", listen_url(addr));
108                let app = app.clone();
109                tokio::spawn(async move {
110                    let svc = app.into_make_service_with_connect_info::<SocketAddr>();
111                    if let Err(e) = axum::serve(listener, svc).await {
112                        warn!("listener exited: {e}");
113                    }
114                });
115            }
116            Err(e) => warn!("skip bind {addr}: {e}"),
117        }
118    }
119
120    let svc = app.into_make_service_with_connect_info::<SocketAddr>();
121    axum::serve(first_listener, svc).await?;
122    Ok(())
123}
124
125/// CORS limited to local desktop / loopback UI origins (not `*` / Any).
126fn local_desktop_cors() -> CorsLayer {
127    CorsLayer::new()
128        .allow_origin(AllowOrigin::predicate(|origin: &HeaderValue, _request| {
129            is_local_desktop_origin(origin)
130        }))
131        .allow_methods([Method::GET, Method::POST, Method::OPTIONS])
132        .allow_headers([
133            header::AUTHORIZATION,
134            header::CONTENT_TYPE,
135            header::ACCEPT,
136            header::HeaderName::from_static(CONFIRM_HEADER),
137        ])
138        .max_age(std::time::Duration::from_secs(600))
139}
140
141/// True for Tauri webview and localhost pages talking to this API.
142fn is_local_desktop_origin(origin: &HeaderValue) -> bool {
143    let Ok(origin) = origin.to_str() else {
144        return false;
145    };
146    origin == "null"
147        || origin.starts_with("http://localhost")
148        || origin.starts_with("https://localhost")
149        || origin.starts_with("http://tauri.localhost")
150        || origin.starts_with("https://tauri.localhost")
151        || origin.starts_with("tauri://localhost")
152}
153
154/// True for loopback, RFC1918, IPv6 ULA, and link-local peers.
155#[must_use]
156fn is_local_network_ip(ip: IpAddr) -> bool {
157    match ip {
158        IpAddr::V4(v4) => is_local_ipv4(v4),
159        IpAddr::V6(v6) => is_local_ipv6(v6),
160    }
161}
162
163const fn is_local_ipv4(ip: Ipv4Addr) -> bool {
164    ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_unspecified()
165    // rare; treat as local peer quirk
166}
167
168fn is_local_ipv6(ip: Ipv6Addr) -> bool {
169    if ip.is_loopback() || ip.is_unicast_link_local() {
170        return true;
171    }
172    // Unique local addresses fc00::/7 (includes fd00::/8).
173    let octets = ip.octets();
174    (octets[0] & 0xfe) == 0xfc
175        // IPv4-mapped ::ffff:a.b.c.d → judge the embedded v4.
176        || ip
177            .to_ipv4_mapped()
178            .is_some_and(is_local_ipv4)
179}
180
181/// Resolve a clap bind string to one or more listen addresses.
182fn resolve_bind(bind: &str) -> Result<Vec<SocketAddr>> {
183    if let Ok(addr) = bind.parse::<SocketAddr>() {
184        return Ok(vec![addr]);
185    }
186    let mut addrs: Vec<SocketAddr> = bind
187        .to_socket_addrs()
188        .with_context(|| format!("resolve bind {bind}"))?
189        .collect();
190    addrs.sort_unstable();
191    addrs.dedup();
192    if addrs.is_empty() {
193        bail!("bind {bind} resolved to no addresses");
194    }
195    Ok(addrs)
196}
197
198fn listen_url(addr: SocketAddr) -> String {
199    if addr.ip().is_unspecified() {
200        format!("http://0.0.0.0:{} (all interfaces)", addr.port())
201    } else if addr.ip().is_loopback() {
202        format!("http://localhost:{}", addr.port())
203    } else {
204        format!("http://{addr}")
205    }
206}
207
208/// Constant-time bearer compare. Length mismatches fail without comparing bytes.
209#[must_use]
210fn bearer_authorized(expected: &str, header: Option<&str>) -> bool {
211    let Some(provided) = header.and_then(|v| v.strip_prefix("Bearer ")) else {
212        return false;
213    };
214    if provided.len() != expected.len() {
215        return false;
216    }
217    bool::from(provided.as_bytes().ct_eq(expected.as_bytes()))
218}
219
220#[must_use]
221fn is_mutate_path(path: &str) -> bool {
222    path.starts_with("/v1/backup/")
223}
224
225/// Auth decision for a path given configured token and Authorization header.
226#[must_use]
227fn auth_gate(path: &str, configured: Option<&str>, authorization: Option<&str>) -> AuthDecision {
228    if path == "/health" {
229        return AuthDecision::Allow;
230    }
231    if is_mutate_path(path) {
232        let Some(expected) = configured.filter(|t| !t.is_empty()) else {
233            return AuthDecision::MutateDisabled;
234        };
235        if bearer_authorized(expected, authorization) {
236            AuthDecision::Allow
237        } else {
238            AuthDecision::Unauthorized
239        }
240    } else {
241        match configured.filter(|t| !t.is_empty()) {
242            None => AuthDecision::Allow,
243            Some(expected) if bearer_authorized(expected, authorization) => AuthDecision::Allow,
244            Some(_) => AuthDecision::Unauthorized,
245        }
246    }
247}
248
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250enum AuthDecision {
251    Allow,
252    Unauthorized,
253    MutateDisabled,
254}
255
256#[must_use]
257fn confirm_backup_etc(headers: &HeaderMap) -> bool {
258    headers
259        .get(CONFIRM_HEADER)
260        .and_then(|v| v.to_str().ok())
261        .is_some_and(|v| v == CONFIRM_BACKUP_ETC)
262}
263
264async fn health() -> Json<Health> {
265    Json(Health { ok: true })
266}
267
268async fn status() -> impl IntoResponse {
269    let ctx = HostContext::new(ApplyMode::DryRun, SetupKind::Full);
270    let kind = if ctx.paths.minimal_env_file().exists() && !ctx.paths.full_env_file().exists() {
271        SetupKind::Minimal
272    } else {
273        SetupKind::Full
274    };
275    let report = box_status(&ctx, kind);
276    Json(report)
277}
278
279async fn backup_etc(headers: HeaderMap) -> Response {
280    if !confirm_backup_etc(&headers) {
281        return (
282            StatusCode::BAD_REQUEST,
283            format!("missing or invalid {CONFIRM_HEADER}: {CONFIRM_BACKUP_ETC}"),
284        )
285            .into_response();
286    }
287    let mut ctx = HostContext::new(ApplyMode::Apply, SetupKind::Full);
288    match backup_etc_timestamped(&mut ctx) {
289        Ok(report) => (StatusCode::OK, Json(report)).into_response(),
290        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
291    }
292}
293
294async fn local_net_middleware(
295    ConnectInfo(addr): ConnectInfo<SocketAddr>,
296    req: Request<axum::body::Body>,
297    next: Next,
298) -> Response {
299    if is_local_network_ip(addr.ip()) {
300        next.run(req).await
301    } else {
302        (
303            StatusCode::FORBIDDEN,
304            "client address is not on a local network",
305        )
306            .into_response()
307    }
308}
309
310async fn auth_middleware(
311    State(state): State<Arc<AppState>>,
312    req: Request<axum::body::Body>,
313    next: Next,
314) -> Response {
315    let path = req.uri().path().to_owned();
316    let header = req
317        .headers()
318        .get(header::AUTHORIZATION)
319        .and_then(|v| v.to_str().ok());
320    match auth_gate(&path, state.token.as_deref(), header) {
321        AuthDecision::Allow => next.run(req).await,
322        AuthDecision::Unauthorized => {
323            (StatusCode::UNAUTHORIZED, "missing or invalid bearer token").into_response()
324        }
325        AuthDecision::MutateDisabled => (
326            StatusCode::SERVICE_UNAVAILABLE,
327            "mutate disabled: set HORTO_API_TOKEN on the box",
328        )
329            .into_response(),
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use clap::CommandFactory;
337    use clap::Parser;
338    use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
339
340    #[test]
341    fn cli_debug_assert() {
342        Cli::command().debug_assert();
343    }
344
345    #[test]
346    fn parses_bind_and_token() {
347        let cli = Cli::try_parse_from([
348            "horto-os-ui-status-api",
349            "--bind",
350            "localhost:9999",
351            "--token",
352            "secret",
353        ])
354        .unwrap();
355        assert_eq!(cli.bind, "localhost:9999");
356        assert_eq!(cli.token.as_deref(), Some("secret"));
357    }
358
359    #[test]
360    fn resolve_localhost_yields_loopback() {
361        let addrs = resolve_bind("localhost:8787").unwrap();
362        assert_ne!(addrs, [] as [std::net::SocketAddr; 0]);
363        assert!(addrs.iter().all(|a| a.ip().is_loopback()));
364        assert!(addrs.iter().all(|a| a.port() == 8787));
365    }
366
367    #[test]
368    fn resolve_ipv4_literal() {
369        let addrs = resolve_bind("127.0.0.1:8787").unwrap();
370        assert_eq!(addrs.len(), 1);
371        assert_eq!(addrs[0].ip(), IpAddr::V4(Ipv4Addr::LOCALHOST));
372    }
373
374    #[test]
375    fn listen_url_loopback_and_lan() {
376        let loopback = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8787);
377        assert_eq!(listen_url(loopback), "http://localhost:8787");
378        let any = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 8787);
379        assert_eq!(listen_url(any), "http://0.0.0.0:8787 (all interfaces)");
380        let lan = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)), 8787);
381        assert_eq!(listen_url(lan), "http://192.168.1.10:8787");
382    }
383
384    #[test]
385    fn bearer_auth_helper() {
386        assert!(bearer_authorized("sec", Some("Bearer sec")));
387        assert!(!bearer_authorized("sec", Some("Bearer other")));
388        assert!(!bearer_authorized("sec", Some("Basic sec")));
389        assert!(!bearer_authorized("sec", None));
390        assert!(!bearer_authorized("secret", Some("Bearer sec")));
391    }
392
393    #[test]
394    fn auth_gate_health_and_status() {
395        assert_eq!(auth_gate("/health", None, None), AuthDecision::Allow);
396        assert_eq!(auth_gate("/v1/status", None, None), AuthDecision::Allow);
397        assert_eq!(
398            auth_gate("/v1/status", Some("tok"), None),
399            AuthDecision::Unauthorized
400        );
401        assert_eq!(
402            auth_gate("/v1/status", Some("tok"), Some("Bearer tok")),
403            AuthDecision::Allow
404        );
405    }
406
407    #[test]
408    fn auth_gate_mutate_requires_token() {
409        assert_eq!(
410            auth_gate("/v1/backup/etc", None, Some("Bearer x")),
411            AuthDecision::MutateDisabled
412        );
413        assert_eq!(
414            auth_gate("/v1/backup/etc", Some(""), Some("Bearer x")),
415            AuthDecision::MutateDisabled
416        );
417        assert_eq!(
418            auth_gate("/v1/backup/etc", Some("tok"), None),
419            AuthDecision::Unauthorized
420        );
421        assert_eq!(
422            auth_gate("/v1/backup/etc", Some("tok"), Some("Bearer wrong")),
423            AuthDecision::Unauthorized
424        );
425        assert_eq!(
426            auth_gate("/v1/backup/etc", Some("tok"), Some("Bearer tok")),
427            AuthDecision::Allow
428        );
429    }
430
431    #[test]
432    fn confirm_header_exact() {
433        let mut headers = HeaderMap::new();
434        assert!(!confirm_backup_etc(&headers));
435        headers.insert(CONFIRM_HEADER, HeaderValue::from_static("nope"));
436        assert!(!confirm_backup_etc(&headers));
437        headers.insert(CONFIRM_HEADER, HeaderValue::from_static(CONFIRM_BACKUP_ETC));
438        assert!(confirm_backup_etc(&headers));
439    }
440
441    #[test]
442    fn local_origin_allowlist() {
443        assert!(is_local_desktop_origin(&HeaderValue::from_static(
444            "http://tauri.localhost"
445        )));
446        assert!(is_local_desktop_origin(&HeaderValue::from_static(
447            "http://localhost:4187"
448        )));
449        assert!(!is_local_desktop_origin(&HeaderValue::from_static(
450            "https://evil.example"
451        )));
452        let _ = local_desktop_cors();
453    }
454
455    #[test]
456    fn local_network_ip_allowlist() {
457        assert!(is_local_network_ip(IpAddr::V4(Ipv4Addr::LOCALHOST)));
458        assert!(is_local_network_ip(IpAddr::V4(Ipv4Addr::new(
459            192, 168, 1, 50
460        ))));
461        assert!(is_local_network_ip(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2))));
462        assert!(is_local_network_ip(IpAddr::V4(Ipv4Addr::new(
463            172, 16, 5, 1
464        ))));
465        assert!(is_local_network_ip(IpAddr::V4(Ipv4Addr::new(
466            169, 254, 1, 1
467        ))));
468        assert!(!is_local_network_ip(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
469        assert!(!is_local_network_ip(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1))));
470
471        assert!(is_local_network_ip(IpAddr::V6(Ipv6Addr::LOCALHOST)));
472        let ula: Ipv6Addr = "fd12:3456:789a::1".parse().unwrap();
473        assert!(is_local_network_ip(IpAddr::V6(ula)));
474        let link_local: Ipv6Addr = "fe80::1".parse().unwrap();
475        assert!(is_local_network_ip(IpAddr::V6(link_local)));
476        let global: Ipv6Addr = "2001:db8::1".parse().unwrap();
477        assert!(!is_local_network_ip(IpAddr::V6(global)));
478
479        let mapped_private =
480            Ipv6Addr::from_octets([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 192, 168, 0, 1]);
481        assert!(is_local_network_ip(IpAddr::V6(mapped_private)));
482        let mapped_public =
483            Ipv6Addr::from_octets([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 8, 8, 8, 8]);
484        assert!(!is_local_network_ip(IpAddr::V6(mapped_public)));
485    }
486}