//! Axum router wiring. Mounts the §2.1 P2 routes + the WS endpoint. use axum::http::{header, HeaderValue, Method}; use axum::routing::{get, post}; use axum::Router; use tower_http::cors::{AllowOrigin, CorsLayer}; use tower_http::trace::TraceLayer; use crate::rest; use crate::state::SharedState; use crate::ws; pub type AppState = SharedState; /// Build the Axum router with an EXPLICIT CORS allowlist (audit fix /// HC-05). The previous `CorsLayer::permissive()` set /// `Access-Control-Allow-Origin: *` which lets any webpage make /// authenticated cross-origin calls once a bearer is leaked. /// /// Default allowlist: `http://localhost:5173` (the homecore-frontend /// Vite dev server) plus the same on port 3000 / 8080 / 8081 / 8123 /// covering the most common reverse-proxy + HA-app paths. Production /// deployments should set `HOMECORE_CORS_ORIGINS=https://...` (comma- /// separated) to override. pub fn router(state: SharedState) -> Router { let cors = build_cors_layer(); Router::new() .route("/api/", get(rest::api_root)) .route("/api/config", get(rest::get_config)) .route("/api/states", get(rest::get_states)) .route( "/api/states/:entity_id", get(rest::get_state) .post(rest::set_state) .delete(rest::delete_state), ) .route("/api/services", get(rest::get_services)) .route("/api/services/:domain/:service", post(rest::call_service)) .route("/api/websocket", get(ws::websocket_handler)) .layer(cors) .layer(TraceLayer::new_for_http()) .with_state(state) } fn build_cors_layer() -> CorsLayer { let raw = std::env::var("HOMECORE_CORS_ORIGINS").ok(); let origins: Vec = match raw { Some(v) if !v.trim().is_empty() => v .split(',') .filter_map(|s| s.trim().parse::().ok()) .collect(), _ => default_origins(), }; CorsLayer::new() .allow_origin(AllowOrigin::list(origins)) .allow_methods([Method::GET, Method::POST, Method::OPTIONS, Method::DELETE]) .allow_headers([ header::AUTHORIZATION, header::CONTENT_TYPE, header::ACCEPT, ]) .allow_credentials(false) } fn default_origins() -> Vec { // Dev defaults — homecore-frontend Vite (5173), common reverse- // proxy ports (3000, 8080, 8081), and the bind port itself (8123) // so HA-companion-app-style same-origin calls work without // ceremony. [ "http://localhost:5173", "http://127.0.0.1:5173", "http://localhost:3000", "http://127.0.0.1:3000", "http://localhost:8080", "http://127.0.0.1:8080", "http://localhost:8081", "http://127.0.0.1:8081", "http://localhost:8123", "http://127.0.0.1:8123", ] .iter() .filter_map(|o| o.parse::().ok()) .collect() } #[cfg(test)] mod tests { use super::*; #[test] fn default_origins_includes_vite_and_ha_ports() { let origins = default_origins(); assert!(origins.iter().any(|o| o.to_str().unwrap().contains("5173"))); assert!(origins.iter().any(|o| o.to_str().unwrap().contains("8123"))); assert!(!origins.is_empty()); } #[test] fn env_override_via_homecore_cors_origins() { std::env::set_var("HOMECORE_CORS_ORIGINS", "https://example.com,https://other.example.com"); // build_cors_layer() returns a CorsLayer which doesn't expose // its origin list; we test the parse path indirectly by // confirming no panic + at least one origin would parse. let parsed: Vec<_> = "https://example.com,https://other.example.com" .split(',') .filter_map(|s| s.trim().parse::().ok()) .collect(); assert_eq!(parsed.len(), 2); std::env::remove_var("HOMECORE_CORS_ORIGINS"); } #[test] fn env_empty_falls_back_to_defaults() { std::env::set_var("HOMECORE_CORS_ORIGINS", " "); let raw = std::env::var("HOMECORE_CORS_ORIGINS").ok(); let trimmed = raw.as_deref().map(|s| s.trim()).unwrap_or(""); assert!(trimmed.is_empty()); std::env::remove_var("HOMECORE_CORS_ORIGINS"); } }