manta_server/server/
routes.rs

1//! Axum router registration: maps every `/api/v1/` path to its handler.
2//!
3//! The OpenAPI JSON spec is served at `GET /openapi.json` and the
4//! Swagger UI is served at `GET /docs`. The `/api/v1/auth/*`
5//! sub-router carries its own defensive layers (rate limit, body
6//! redaction) — see [`crate::server::auth_middleware`].
7
8use std::sync::Arc;
9
10use axum::{
11  Extension, Router,
12  http::StatusCode,
13  middleware,
14  routing::{delete, get, post},
15};
16use tower_http::timeout::TimeoutLayer;
17use utoipa::OpenApi as _;
18use utoipa_swagger_ui::SwaggerUi;
19
20use super::ServerState;
21use super::api_doc::ApiDoc;
22use super::auth_middleware::{
23  AuthRateLimiter, rate_limit, read_only_guard, strip_body_for_logs,
24};
25use super::handlers;
26
27/// Build the axum router with all API endpoints and OpenAPI doc routes.
28///
29/// Structure:
30/// - `/api/v1/*` — the main resource router, with the global
31///   [`ServerState::request_timeout`] applied as an outer
32///   `TimeoutLayer`. `POST /power` now returns immediately with a
33///   PCS transition id (the polling loop runs CLI-side), so it fits
34///   well under the default timeout — no per-route override is needed.
35///   A second outer layer ([`read_only_guard`]) refuses
36///   mutating methods (POST/PUT/PATCH/DELETE) when the caller's
37///   JWT carries the [`READ_ONLY_ROLE`] role.
38///
39/// [`read_only_guard`]: super::auth_middleware::read_only_guard
40/// [`READ_ONLY_ROLE`]: super::common::jwt_ops::READ_ONLY_ROLE
41/// - `/api/v1/auth/*` — separate sub-router with two layered
42///   defences: per-IP rate limit (see [`AuthRateLimiter`]) and body
43///   redaction from any log span (see [`strip_body_for_logs`]). No
44///   Bearer-token extractor (these endpoints issue the token).
45/// - `/docs` + `/openapi.json` — Swagger UI and the spec from
46///   [`ApiDoc`].
47/// - HSTS header injected on every response by an
48///   `add_hsts_header` middleware (private to this module).
49pub fn build_router(state: Arc<ServerState>) -> Router {
50  let api = Router::new()
51    // --- GET endpoints ---
52    .route("/sessions", get(handlers::get_sessions))
53    .route("/analysis/images", get(handlers::get_image_analysis))
54    .route("/configurations", get(handlers::get_configurations))
55    .route("/nodes", get(handlers::get_nodes))
56    .route("/groups", get(handlers::get_groups))
57    .route("/groups/available", get(handlers::get_available_groups))
58    .route("/images", get(handlers::get_images))
59    .route("/templates", get(handlers::get_templates))
60    .route("/boot-parameters", get(handlers::get_boot_parameters))
61    .route("/kernel-parameters", get(handlers::get_kernel_parameters))
62    .route("/redfish-endpoints", get(handlers::get_redfish_endpoints))
63    // Canonical (group-centric) read endpoints
64    .route("/groups/nodes", get(handlers::get_groups_nodes))
65    .route("/groups/hardware", get(handlers::get_groups_hardware))
66    // Deprecated aliases retained for one release. Each handler logs
67    // a server-side warning and forwards to the canonical impl.
68    .route("/clusters", get(handlers::get_clusters_deprecated))
69    .route(
70      "/hardware-clusters",
71      get(handlers::get_hardware_clusters_deprecated),
72    )
73    .route(
74      "/hardware-nodes-list",
75      get(handlers::get_hardware_nodes_list),
76    )
77    // --- Write endpoints ---
78    // Nodes
79    .route("/nodes", post(handlers::add_node))
80    .route("/nodes/{id}", delete(handlers::delete_node))
81    // Groups
82    .route("/groups", post(handlers::create_group))
83    .route("/groups/{label}", delete(handlers::delete_group))
84    .route(
85      "/groups/{name}/members",
86      post(handlers::add_nodes_to_group).delete(handlers::delete_group_members),
87    )
88    // Boot parameters
89    .route(
90      "/boot-parameters",
91      post(handlers::add_boot_parameters)
92        .put(handlers::update_boot_parameters)
93        .delete(handlers::delete_boot_parameters),
94    )
95    // Redfish endpoints
96    .route(
97      "/redfish-endpoints",
98      post(handlers::add_redfish_endpoint)
99        .put(handlers::update_redfish_endpoint),
100    )
101    .route(
102      "/redfish-endpoints/{id}",
103      delete(handlers::delete_redfish_endpoint),
104    )
105    // Sessions (delete with dry_run)
106    .route("/sessions/{name}", delete(handlers::delete_session))
107    // Sessions (create)
108    .route("/sessions", post(handlers::create_session))
109    // Images (delete with dry_run)
110    .route("/images", delete(handlers::delete_images))
111    // Configurations (delete with dry_run)
112    .route("/configurations", delete(handlers::delete_configurations))
113    // Boot config (apply with dry_run)
114    .route("/boot-config", post(handlers::apply_boot_config))
115    // Kernel parameters (apply, add, delete)
116    .route(
117      "/kernel-parameters/apply",
118      post(handlers::apply_kernel_parameters),
119    )
120    .route(
121      "/kernel-parameters/add",
122      post(handlers::add_kernel_parameters),
123    )
124    .route(
125      "/kernel-parameters",
126      delete(handlers::delete_kernel_parameters),
127    )
128    // Migrate
129    .route("/migrate/nodes", post(handlers::migrate_nodes))
130    .route("/migrate/backup", post(handlers::migrate_backup))
131    .route("/migrate/restore", post(handlers::migrate_restore))
132    // Ephemeral environment
133    .route("/ephemeral-env", post(handlers::create_ephemeral_env))
134    // Power management — POST starts a PCS transition and returns
135    // immediately; GET snapshots the transition for the CLI poll loop.
136    .route("/power", post(handlers::post_power))
137    .route(
138      "/power/transitions/{id}",
139      get(handlers::get_power_transition),
140    )
141    // BOS session from template
142    .route(
143      "/templates/{name}/sessions",
144      post(handlers::post_template_session),
145    )
146    // CFS session logs (SSE)
147    .route("/sessions/{name}/logs", get(handlers::get_session_logs))
148    // SAT file apply — per-element endpoints. The CLI's `build_plan`
149    // walks the SAT file and dispatches one POST per artifact;
150    // `images[]` further splits into the three-step
151    // cfs-session/monitor/stamp pipeline that the CLI orchestrates.
152    .route(
153      "/sat-file/configurations",
154      post(handlers::post_sat_configuration),
155    )
156    .route(
157      "/sat-file/images/cfs-session",
158      post(handlers::post_sat_image_cfs_session),
159    )
160    .route(
161      "/sat-file/images/stamp",
162      post(handlers::post_sat_image_stamp),
163    )
164    .route(
165      "/sat-file/session-templates",
166      post(handlers::post_sat_session_template),
167    )
168    .route("/sat-file/validate", post(handlers::post_sat_validate))
169    // Health check
170    .route("/health", get(handlers::health))
171    // Hardware cluster member management
172    .route(
173      "/hardware-clusters/{target}/members",
174      post(handlers::add_hw_component).delete(handlers::delete_hw_component),
175    )
176    // Hardware cluster configuration (pin/unpin)
177    .route(
178      "/hardware-clusters/{target}/configuration",
179      post(handlers::apply_hw_configuration),
180    )
181    .merge(build_ws_routes())
182    // Apply the global request timeout to every route in the api
183    // sub-router.
184    .layer(TimeoutLayer::with_status_code(
185      StatusCode::REQUEST_TIMEOUT,
186      state.request_timeout,
187    ))
188    // Reject mutating requests when the caller's JWT carries the
189    // `manta-read-only` role. Only on `/api/v1/*` — `/api/v1/auth/*`
190    // (login) and `/docs` (Swagger) are unaffected.
191    .layer(middleware::from_fn(read_only_guard));
192
193  // /api/v1/auth/* — credential-handling sub-router. No Bearer
194  // extractor (chicken-and-egg). Two layered defences applied:
195  // (1) per-IP rate limit, (2) body redaction from any log span.
196  let limiter = AuthRateLimiter::new();
197  let auth = Router::new()
198    .route("/token", post(handlers::auth_token))
199    .route("/validate", post(handlers::auth_validate))
200    .layer(middleware::from_fn(strip_body_for_logs))
201    .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
202    .layer(Extension(limiter));
203
204  Router::new()
205    .nest("/api/v1", api)
206    .nest("/api/v1/auth", auth)
207    .merge(SwaggerUi::new("/docs").url("/openapi.json", ApiDoc::openapi()))
208    // HSTS on every response. Browsers ignore HSTS over plain HTTP
209    // per RFC 6797, so this is a no-op when `allow_http = true`
210    // and active otherwise. Conservative one-year max-age; bump to
211    // include `preload` only after confirming the deployment can
212    // sustain it.
213    .layer(middleware::from_fn(add_hsts_header))
214    .with_state(state)
215}
216
217/// Inject `Strict-Transport-Security: max-age=31536000; includeSubDomains`
218/// on every outgoing response. Cheap; the header is constant.
219async fn add_hsts_header(
220  request: axum::extract::Request,
221  next: middleware::Next,
222) -> axum::response::Response {
223  let mut response = next.run(request).await;
224  response.headers_mut().insert(
225    axum::http::header::STRICT_TRANSPORT_SECURITY,
226    axum::http::HeaderValue::from_static("max-age=31536000; includeSubDomains"),
227  );
228  response
229}
230
231/// WebSocket upgrade routes — kept separate so they're easy to identify
232/// and so the upgrade protocol is not mixed with plain HTTP routes.
233fn build_ws_routes() -> Router<Arc<ServerState>> {
234  Router::new()
235    .route("/nodes/{xname}/console", get(handlers::console_node_ws))
236    .route(
237      "/sessions/{name}/console",
238      get(handlers::console_session_ws),
239    )
240}