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}