manta_server/server/
routes.rs

1//! Axum router registration: maps every `/v2/` 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 `/v2/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, put},
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, 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/// - `/v2/*` — 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/// - `/v2/auth/*` — separate sub-router with two layered
36///   defences: per-IP rate limit (see [`AuthRateLimiter`]) and body
37///   redaction from any log span (see [`strip_body_for_logs`]). No
38///   Bearer-token extractor (these endpoints issue the token).
39/// - `/docs` + `/openapi.json` — Swagger UI and the spec from
40///   [`ApiDoc`].
41/// - HSTS header injected on every response by an
42///   `add_hsts_header` middleware (private to this module).
43pub fn build_router(state: Arc<ServerState>) -> Router {
44  let api = Router::new()
45    // --- GET endpoints ---
46    .route("/sessions", get(handlers::get_sessions))
47    .route("/analysis/images", get(handlers::get_image_analysis))
48    .route("/configurations", get(handlers::get_configurations))
49    .route("/nodes", get(handlers::get_nodes))
50    .route("/groups", get(handlers::get_groups))
51    .route("/groups/available", get(handlers::get_available_groups))
52    .route("/images", get(handlers::get_images))
53    .route("/templates", get(handlers::get_templates))
54    .route("/boot-parameters", get(handlers::get_boot_parameters))
55    .route("/kernel-parameters", get(handlers::get_kernel_parameters))
56    .route("/redfish-endpoints", get(handlers::get_redfish_endpoints))
57    // Canonical (group-centric) read endpoints
58    .route("/groups/nodes", get(handlers::get_groups_nodes))
59    .route("/groups/hardware", get(handlers::get_groups_hardware))
60    // Deprecated aliases retained for one release. Each handler logs
61    // a server-side warning and forwards to the canonical impl.
62    .route("/clusters", get(handlers::get_clusters_deprecated))
63    .route(
64      "/hardware-clusters",
65      get(handlers::get_hardware_clusters_deprecated),
66    )
67    .route(
68      "/hardware-nodes-list",
69      get(handlers::get_hardware_nodes_list),
70    )
71    // --- Write endpoints ---
72    // Nodes
73    .route("/nodes", post(handlers::add_node))
74    .route("/nodes/{id}", delete(handlers::delete_node))
75    // Groups
76    .route("/groups", post(handlers::create_group))
77    .route("/groups/{label}", delete(handlers::delete_group))
78    .route(
79      "/groups/{name}/members",
80      post(handlers::add_nodes_to_group).delete(handlers::delete_group_members),
81    )
82    // Boot parameters
83    .route(
84      "/boot-parameters",
85      post(handlers::add_boot_parameters)
86        .put(handlers::update_boot_parameters)
87        .delete(handlers::delete_boot_parameters),
88    )
89    // Redfish endpoints
90    .route(
91      "/redfish-endpoints",
92      post(handlers::add_redfish_endpoint)
93        .put(handlers::update_redfish_endpoint),
94    )
95    .route(
96      "/redfish-endpoints/{id}",
97      delete(handlers::delete_redfish_endpoint),
98    )
99    // Sessions (delete with dry_run)
100    .route("/sessions/{name}", delete(handlers::delete_session))
101    // Sessions (create)
102    .route("/sessions", post(handlers::create_session))
103    // Images (delete with dry_run)
104    .route("/images", delete(handlers::delete_images))
105    // Configurations (delete with dry_run)
106    .route("/configurations", delete(handlers::delete_configurations))
107    // Boot config (apply with dry_run)
108    .route("/boot-config", post(handlers::apply_boot_config))
109    // Runtime configuration (PUT — set CFS desired_configuration + enabled
110    // on the components resolved from a hosts expression)
111    .route(
112      "/runtime-configuration",
113      put(handlers::apply_runtime_configuration),
114    )
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    // Health check
169    .route("/health", get(handlers::health))
170    // Hardware cluster member management
171    .route(
172      "/hardware-clusters/{target}/members",
173      post(handlers::add_hw_component).delete(handlers::delete_hw_component),
174    )
175    // Hardware cluster configuration (pin/unpin)
176    .route(
177      "/hardware-clusters/{target}/configuration",
178      post(handlers::apply_hw_configuration),
179    )
180    .merge(build_ws_routes())
181    // Apply the global request timeout to every route in the api
182    // sub-router.
183    .layer(TimeoutLayer::with_status_code(
184      StatusCode::REQUEST_TIMEOUT,
185      state.request_timeout,
186    ));
187
188  // /v2/auth/* — credential-handling sub-router. No Bearer
189  // extractor (chicken-and-egg). Two layered defences applied:
190  // (1) per-IP rate limit, (2) body redaction from any log span.
191  let limiter = AuthRateLimiter::new();
192  let auth = Router::new()
193    .route("/token", post(handlers::auth_token))
194    .route("/validate", post(handlers::auth_validate))
195    .layer(middleware::from_fn(strip_body_for_logs))
196    .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
197    .layer(Extension(limiter));
198
199  Router::new()
200    .nest("/v2", api)
201    .nest("/v2/auth", auth)
202    .merge(SwaggerUi::new("/docs").url("/openapi.json", ApiDoc::openapi()))
203    // HSTS on every response. Browsers ignore HSTS over plain HTTP
204    // per RFC 6797, so this is a no-op when `allow_http = true`
205    // and active otherwise. Conservative one-year max-age; bump to
206    // include `preload` only after confirming the deployment can
207    // sustain it.
208    .layer(middleware::from_fn(add_hsts_header))
209    .with_state(state)
210}
211
212/// Inject `Strict-Transport-Security: max-age=31536000; includeSubDomains`
213/// on every outgoing response. Cheap; the header is constant.
214async fn add_hsts_header(
215  request: axum::extract::Request,
216  next: middleware::Next,
217) -> axum::response::Response {
218  let mut response = next.run(request).await;
219  response.headers_mut().insert(
220    axum::http::header::STRICT_TRANSPORT_SECURITY,
221    axum::http::HeaderValue::from_static("max-age=31536000; includeSubDomains"),
222  );
223  response
224}
225
226/// WebSocket upgrade routes — kept separate so they're easy to identify
227/// and so the upgrade protocol is not mixed with plain HTTP routes.
228fn build_ws_routes() -> Router<Arc<ServerState>> {
229  Router::new()
230    .route("/nodes/{xname}/console", get(handlers::console_node_ws))
231    .route(
232      "/sessions/{name}/console",
233      get(handlers::console_session_ws),
234    )
235}