1use 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
28const 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 #[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 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
125fn 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
141fn 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#[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 }
167
168fn is_local_ipv6(ip: Ipv6Addr) -> bool {
169 if ip.is_loopback() || ip.is_unicast_link_local() {
170 return true;
171 }
172 let octets = ip.octets();
174 (octets[0] & 0xfe) == 0xfc
175 || ip
177 .to_ipv4_mapped()
178 .is_some_and(is_local_ipv4)
179}
180
181fn 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#[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#[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}