Skip to main content

homecore_api/
app.rs

1//! Axum router wiring. Mounts the §2.1 P2 routes + the WS endpoint.
2
3use axum::http::{header, HeaderValue, Method};
4use axum::routing::{get, post};
5use axum::Router;
6use tower_http::cors::{AllowOrigin, CorsLayer};
7use tower_http::trace::TraceLayer;
8
9use crate::rest;
10use crate::state::SharedState;
11use crate::ws;
12
13pub type AppState = SharedState;
14
15/// Build the Axum router with an EXPLICIT CORS allowlist (audit fix
16/// HC-05). The previous `CorsLayer::permissive()` set
17/// `Access-Control-Allow-Origin: *` which lets any webpage make
18/// authenticated cross-origin calls once a bearer is leaked.
19///
20/// Default allowlist: `http://localhost:5173` (the homecore-frontend
21/// Vite dev server) plus the same on port 3000 / 8080 / 8081 / 8123
22/// covering the most common reverse-proxy + HA-app paths. Production
23/// deployments should set `HOMECORE_CORS_ORIGINS=https://...` (comma-
24/// separated) to override.
25pub fn router(state: SharedState) -> Router {
26    let cors = build_cors_layer();
27    Router::new()
28        .route("/api/", get(rest::api_root))
29        .route("/api/config", get(rest::get_config))
30        .route("/api/components", get(rest::get_components))
31        .route("/api/states", get(rest::get_states))
32        .route(
33            "/api/states/:entity_id",
34            get(rest::get_state)
35                .post(rest::set_state)
36                .delete(rest::delete_state),
37        )
38        .route("/api/services", get(rest::get_services))
39        .route("/api/services/:domain/:service", post(rest::call_service))
40        .route("/api/events", get(rest::get_events))
41        .route("/api/events/:event_type", post(rest::fire_event))
42        .route("/api/template", post(rest::render_template))
43        .route("/api/config/core/check_config", post(rest::check_config))
44        .route("/api/error_log", get(rest::error_log))
45        .route("/api/history/period", get(rest::get_history))
46        .route(
47            "/api/history/period/:start_time",
48            get(rest::get_history_period),
49        )
50        .route("/api/logbook", get(rest::get_logbook))
51        .route("/api/logbook/:start_time", get(rest::get_logbook_period))
52        .route("/api/calendars", get(rest::get_calendars))
53        .route("/api/calendars/:entity_id", get(rest::get_calendar_events))
54        .route("/api/camera_proxy/:entity_id", get(rest::get_camera_proxy))
55        .route("/api/homecore/compatibility", get(rest::compatibility))
56        .route("/api/websocket", get(ws::websocket_handler))
57        .layer(cors)
58        .layer(TraceLayer::new_for_http())
59        .with_state(state)
60}
61
62/// Build the audited CORS allowlist layer (HC-05). Exposed so the
63/// integration binary can apply the SAME allowlist to routes merged in
64/// outside `router()` (e.g. the ADR-131 BFF gateway), instead of leaving
65/// `/api/homecore/*` and `/api/cal/*` with no CORS coverage at all.
66pub fn build_cors_layer() -> CorsLayer {
67    let raw = std::env::var("HOMECORE_CORS_ORIGINS").ok();
68    let origins: Vec<HeaderValue> = match raw {
69        Some(v) if !v.trim().is_empty() => v
70            .split(',')
71            .filter_map(|s| s.trim().parse::<HeaderValue>().ok())
72            .collect(),
73        _ => default_origins(),
74    };
75    CorsLayer::new()
76        .allow_origin(AllowOrigin::list(origins))
77        .allow_methods([Method::GET, Method::POST, Method::OPTIONS, Method::DELETE])
78        .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE, header::ACCEPT])
79        .allow_credentials(false)
80}
81
82fn default_origins() -> Vec<HeaderValue> {
83    // Dev defaults — homecore-frontend Vite (5173), common reverse-
84    // proxy ports (3000, 8080, 8081), and the bind port itself (8123)
85    // so HA-companion-app-style same-origin calls work without
86    // ceremony.
87    [
88        "http://localhost:5173",
89        "http://127.0.0.1:5173",
90        "http://localhost:3000",
91        "http://127.0.0.1:3000",
92        "http://localhost:8080",
93        "http://127.0.0.1:8080",
94        "http://localhost:8081",
95        "http://127.0.0.1:8081",
96        "http://localhost:8123",
97        "http://127.0.0.1:8123",
98    ]
99    .iter()
100    .filter_map(|o| o.parse::<HeaderValue>().ok())
101    .collect()
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    // `set_var`/`remove_var` mutate process-global state; serialize every test
109    // that touches HOMECORE_CORS_ORIGINS so they cannot race in parallel.
110    // Poison-tolerant: a panicking test must not cascade-fail the others.
111    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
112
113    #[test]
114    fn default_origins_includes_vite_and_ha_ports() {
115        let origins = default_origins();
116        assert!(origins.iter().any(|o| o.to_str().unwrap().contains("5173")));
117        assert!(origins.iter().any(|o| o.to_str().unwrap().contains("8123")));
118        assert!(!origins.is_empty());
119    }
120
121    #[test]
122    fn env_override_via_homecore_cors_origins() {
123        let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
124        std::env::set_var(
125            "HOMECORE_CORS_ORIGINS",
126            "https://example.com,https://other.example.com",
127        );
128        // build_cors_layer() returns a CorsLayer which doesn't expose
129        // its origin list; we test the parse path indirectly by
130        // confirming no panic + at least one origin would parse.
131        let parsed: Vec<_> = "https://example.com,https://other.example.com"
132            .split(',')
133            .filter_map(|s| s.trim().parse::<HeaderValue>().ok())
134            .collect();
135        assert_eq!(parsed.len(), 2);
136        std::env::remove_var("HOMECORE_CORS_ORIGINS");
137    }
138
139    #[test]
140    fn env_empty_falls_back_to_defaults() {
141        let _env = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
142        std::env::set_var("HOMECORE_CORS_ORIGINS", "   ");
143        let raw = std::env::var("HOMECORE_CORS_ORIGINS").ok();
144        let trimmed = raw.as_deref().map(|s| s.trim()).unwrap_or("");
145        assert!(trimmed.is_empty());
146        std::env::remove_var("HOMECORE_CORS_ORIGINS");
147    }
148}