Skip to main content

cellos_server/
lib.rs

1//! CellOS HTTP control plane API library surface.
2//!
3//! Exposes the router builder and core types so that integration tests
4//! (and future embedders) can drive the server without spawning a real
5//! TCP listener. See `src/main.rs` for the production wiring.
6
7pub mod auth;
8pub mod error;
9pub mod jetstream;
10pub mod routes;
11pub mod state;
12pub mod ws;
13
14use axum::extract::DefaultBodyLimit;
15use axum::http::{header, Method};
16use axum::{
17    middleware::map_response,
18    routing::{delete, get, post},
19    Router,
20};
21use tower_http::cors::{Any, CorsLayer};
22use tower_http::trace::TraceLayer;
23
24use crate::error::{normalize_problem_response, problem_response, AppErrorKind};
25
26pub use state::{AppState, CellRecord, CellState, FormationRecord, FormationStatus};
27
28/// Per-route ceiling on `POST /v1/formations` body bytes. axum
29/// defaults to 2 MiB; that is ~100x the size of any realistic formation
30/// document we have observed (~12 KiB for a 64-cell formation).
31/// Lowering this surfaces a 413 Payload Too Large rather than burning
32/// CPU on serde parsing a 2 MiB document at the admission gate.
33const FORMATIONS_POST_MAX_BYTES: usize = 64 * 1024;
34
35/// Build the full axum router with all routes mounted at their
36/// canonical paths. `AppState` is cloned per request via axum's
37/// `with_state`.
38///
39/// ADR-0017 §D2: `cellos-server` is API-only. The static bundle moved
40/// to `cellctl` and is served by `cellctl webui`. There is no
41/// `ServeDir` fallback here — unmatched paths return 404.
42///
43/// ADR-0016 (read-only browser boundary): CORS is restricted to
44/// `GET` + `OPTIONS` so a misbehaving browser context (XSS, malicious
45/// extension, or a hostile in-page script that slipped past the
46/// `cellctl webui` proxy) cannot mutate state via a cross-origin
47/// `POST /v1/formations`. The localhost proxy makes browser origins a
48/// non-issue in practice, but we enforce the read-only shape
49/// structurally so the boundary survives a proxy bug.
50pub fn router(state: AppState) -> Router {
51    Router::new()
52        .route(
53            "/v1/formations",
54            post(routes::formations::create_formation)
55                .layer(DefaultBodyLimit::max(FORMATIONS_POST_MAX_BYTES)),
56        )
57        .route("/v1/formations", get(routes::formations::list_formations))
58        // NOTE: axum 0.8 ships matchit 0.8 which uses the brace
59        // `{id}` capture syntax (the old `:id` form was removed). The
60        // `{id}` segment matches a single path segment and is decoded
61        // into the handler's `Path<…>` extractor.
62        .route(
63            "/v1/formations/{id}",
64            get(routes::formations::get_formation),
65        )
66        .route(
67            "/v1/formations/{id}",
68            delete(routes::formations::delete_formation),
69        )
70        // CTL-002 (E2E report): name-addressed counterparts of the
71        // UUID routes above. The literal `/by-name/` segment is matched
72        // before `{id}` because axum's matchit prefers literal segments
73        // over captures at the same depth, so `/v1/formations/by-name/foo`
74        // never tries to parse `by-name` as a UUID.
75        .route(
76            "/v1/formations/by-name/{name}",
77            get(routes::formations::get_formation_by_name),
78        )
79        .route(
80            "/v1/formations/by-name/{name}",
81            delete(routes::formations::delete_formation_by_name),
82        )
83        .route(
84            "/v1/formations/{id}/status",
85            post(routes::formations::update_formation_status),
86        )
87        .route("/v1/cells", get(routes::cells::list_cells))
88        .route("/v1/cells/{id}", get(routes::cells::get_cell))
89        // E2E report SRV-001: `cellctl version` is the day-1 reachability
90        // probe a fresh operator types first. Returns build metadata
91        // (crate version, optional short git SHA, build profile, API
92        // version) behind the same Bearer gate as every other route.
93        .route("/v1/version", get(routes::meta::get_version))
94        // EVT-001: one-shot snapshot of recent events. The `--follow`
95        // path stays on `/ws/events`; this exists for environments
96        // where WebSocket isn't viable (corporate proxies, kubectl-
97        // style scripted pulls). See `routes/events.rs` for the
98        // contract and the WS-envelope wire compatibility note.
99        .route("/v1/events", get(routes::events::list_events))
100        .route("/ws/events", get(ws::ws_events))
101        // FUZZ-WAVE-1 MED-2: unmatched paths used to return 404 with an
102        // empty body and no Content-Type. RFC 9457 says every error
103        // response should carry problem+json; adopters parse `type` to
104        // distinguish a missing route from an authentication failure.
105        .fallback(not_found_handler)
106        // FUZZ-WAVE-1 MED-2: wrong-method-on-existing-path used to
107        // return 405 with empty body. axum's method router still sets
108        // the `Allow` header on its own response; the
109        // `normalize_problem_response` middleware below preserves Allow
110        // when it rewrites the body.
111        .method_not_allowed_fallback(method_not_allowed_handler)
112        .layer(TraceLayer::new_for_http())
113        .layer(
114            CorsLayer::new()
115                // Origin is left open because the only legitimate
116                // browser client is the cellctl localhost proxy — the
117                // method restriction below is the structural gate, not
118                // the origin list.
119                .allow_origin(Any)
120                .allow_methods([Method::GET, Method::OPTIONS])
121                .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]),
122        )
123        // FUZZ-WAVE-1 MED-1: outermost response-mapping layer normalises
124        // every 4xx that isn't already problem+json (axum's built-in
125        // JsonRejection / PathRejection / QueryRejection / BodyLimit
126        // rejections emit text/plain). Placed AFTER CorsLayer so CORS
127        // preflight 200s aren't touched, and outside TraceLayer so
128        // tracing sees the original status.
129        .layer(map_response(normalize_problem_response))
130        .with_state(state)
131}
132
133/// FUZZ-WAVE-1 MED-2: 404 fallback for unmatched paths. Returns
134/// `application/problem+json` so adopters can distinguish a missing
135/// route (`/problems/not-found`) from an authentication failure
136/// (`/problems/unauthorized`) without sniffing the body shape.
137async fn not_found_handler(req: axum::extract::Request) -> axum::response::Response {
138    let path = req.uri().path().to_string();
139    // Keep the detail terse — we don't want to echo a 4 KiB attacker
140    // URL verbatim into the error body. The leading slash plus first
141    // 200 bytes is enough for an operator to debug.
142    let truncated: String = path.chars().take(200).collect();
143    problem_response(
144        AppErrorKind::NotFound,
145        format!("no route matched '{truncated}'"),
146    )
147}
148
149/// FUZZ-WAVE-1 MED-2: 405 fallback for wrong-method-on-known-path.
150/// axum's method router still attaches the `Allow` header to the
151/// underlying response before this handler runs; the
152/// `normalize_problem_response` layer copies it through to the
153/// rewritten body. RFC 9110 §15.5.6 requires Allow on every 405.
154async fn method_not_allowed_handler(req: axum::extract::Request) -> axum::response::Response {
155    let method = req.method().as_str().to_string();
156    let path: String = req.uri().path().chars().take(200).collect();
157    problem_response(
158        AppErrorKind::MethodNotAllowed,
159        format!("method '{method}' not allowed for '{path}' — see Allow header"),
160    )
161}
162
163#[cfg(test)]
164mod cors_tests {
165    use super::*;
166    use axum::body::Body;
167    use axum::http::{header, Method, Request, StatusCode};
168    use tower::ServiceExt;
169
170    /// ADR-0016 structural enforcement: a cross-origin browser MUST
171    /// NOT be able to mutate state. The CORS preflight for
172    /// `POST /v1/formations` is the gate — we assert the server's
173    /// `Access-Control-Allow-Methods` response *omits* `POST`. With
174    /// the strict layer in `router()`, the preflight advertises only
175    /// the safe methods; a compliant browser then refuses to send the
176    /// actual POST.
177    #[tokio::test]
178    async fn cors_preflight_for_post_does_not_allow_post() {
179        let state = AppState::new(None, "test-token");
180        let app = router(state);
181
182        let req = Request::builder()
183            .method(Method::OPTIONS)
184            .uri("/v1/formations")
185            .header(header::ORIGIN, "http://attacker.example")
186            .header(header::ACCESS_CONTROL_REQUEST_METHOD, "POST")
187            .header(header::ACCESS_CONTROL_REQUEST_HEADERS, "authorization")
188            .body(Body::empty())
189            .unwrap();
190
191        let resp = app.oneshot(req).await.expect("router response");
192        // CORS preflight itself succeeds at the HTTP layer; the gate is
193        // in the Access-Control-Allow-Methods header.
194        assert_eq!(resp.status(), StatusCode::OK);
195
196        let allow_methods = resp
197            .headers()
198            .get(header::ACCESS_CONTROL_ALLOW_METHODS)
199            .and_then(|v| v.to_str().ok())
200            .unwrap_or_default()
201            .to_ascii_uppercase();
202
203        assert!(
204            !allow_methods.contains("POST"),
205            "POST must not appear in Access-Control-Allow-Methods (got {allow_methods:?})",
206        );
207        assert!(
208            allow_methods.contains("GET"),
209            "GET must appear in Access-Control-Allow-Methods (got {allow_methods:?})",
210        );
211    }
212
213    /// And the safe preflight (GET) is allowed — sanity check we
214    /// didn't accidentally lock the whole API down.
215    #[tokio::test]
216    async fn cors_preflight_for_get_is_allowed() {
217        let state = AppState::new(None, "test-token");
218        let app = router(state);
219
220        let req = Request::builder()
221            .method(Method::OPTIONS)
222            .uri("/v1/formations")
223            .header(header::ORIGIN, "http://localhost:9999")
224            .header(header::ACCESS_CONTROL_REQUEST_METHOD, "GET")
225            .header(header::ACCESS_CONTROL_REQUEST_HEADERS, "authorization")
226            .body(Body::empty())
227            .unwrap();
228
229        let resp = app.oneshot(req).await.expect("router response");
230        assert_eq!(resp.status(), StatusCode::OK);
231
232        let allow_methods = resp
233            .headers()
234            .get(header::ACCESS_CONTROL_ALLOW_METHODS)
235            .and_then(|v| v.to_str().ok())
236            .unwrap_or_default()
237            .to_ascii_uppercase();
238        assert!(
239            allow_methods.contains("GET"),
240            "GET must be in Access-Control-Allow-Methods (got {allow_methods:?})",
241        );
242    }
243}