Skip to main content

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::{catch_panic::CatchPanicLayer, timeout::TimeoutLayer};
17use utoipa::OpenApi as _;
18use utoipa_swagger_ui::SwaggerUi;
19
20use super::ServerState;
21use super::api_doc::ApiDoc;
22use super::audit::audit_requests;
23use super::auth_middleware::{
24  AuthRateLimiter, rate_limit, strip_body_for_logs,
25};
26use super::handlers;
27
28/// Build the axum router with all API endpoints and OpenAPI doc routes.
29///
30/// Structure:
31/// - `/v2/*` — the main resource router, with the global
32///   [`ServerState::request_timeout`] applied as an outer
33///   `TimeoutLayer`, a `CatchPanicLayer` outside it, and
34///   [`audit_requests`] outside both. `POST /power` returns
35///   immediately with a PCS transition id (the polling loop runs CLI-side), so it fits
36///   well under the default timeout — no per-route override is needed.
37/// - `/v2/auth/*` — separate sub-router with two layered
38///   defences: per-IP rate limit (see [`AuthRateLimiter`]) and body
39///   redaction from any log span (see [`strip_body_for_logs`]). No
40///   Bearer-token extractor (these endpoints issue the token).
41/// - `/docs` + `/openapi.json` — Swagger UI and the spec from
42///   [`ApiDoc`].
43/// - HSTS header injected on every response by an
44///   `add_hsts_header` middleware (private to this module).
45pub fn build_router(state: Arc<ServerState>) -> Router {
46  let api = Router::new()
47    // --- GET endpoints ---
48    .route("/sessions", get(handlers::get_sessions))
49    .route("/analysis/images", get(handlers::get_image_analysis))
50    .route("/configurations", get(handlers::get_configurations))
51    .route("/nodes", get(handlers::get_nodes))
52    .route("/groups", get(handlers::get_groups))
53    .route("/groups/available", get(handlers::get_available_groups))
54    .route("/images", get(handlers::get_images))
55    .route("/templates", get(handlers::get_templates))
56    .route("/boot-parameters", get(handlers::get_boot_parameters))
57    .route("/kernel-parameters", get(handlers::get_kernel_parameters))
58    .route("/redfish-endpoints", get(handlers::get_redfish_endpoints))
59    // Canonical (group-centric) read endpoints
60    .route("/groups/nodes", get(handlers::get_groups_nodes))
61    .route("/groups/hardware", get(handlers::get_groups_hardware))
62    // Deprecated aliases retained for one release. Each handler logs
63    // a server-side warning and forwards to the canonical impl.
64    .route("/clusters", get(handlers::get_clusters_deprecated))
65    .route(
66      "/hardware-clusters",
67      get(handlers::get_hardware_clusters_deprecated),
68    )
69    .route(
70      "/hardware-nodes-list",
71      get(handlers::get_hardware_nodes_list),
72    )
73    // --- Write endpoints ---
74    // Nodes
75    .route("/nodes", post(handlers::add_node))
76    .route("/nodes/{id}", delete(handlers::delete_node))
77    // Groups
78    .route("/groups", post(handlers::create_group))
79    .route("/groups/{label}", delete(handlers::delete_group))
80    .route(
81      "/groups/{name}/members",
82      post(handlers::add_nodes_to_group).delete(handlers::delete_group_members),
83    )
84    // Boot parameters
85    .route(
86      "/boot-parameters",
87      post(handlers::add_boot_parameters)
88        .put(handlers::update_boot_parameters)
89        .delete(handlers::delete_boot_parameters),
90    )
91    // Redfish endpoints
92    .route(
93      "/redfish-endpoints",
94      post(handlers::add_redfish_endpoint)
95        .put(handlers::update_redfish_endpoint),
96    )
97    .route(
98      "/redfish-endpoints/{id}",
99      delete(handlers::delete_redfish_endpoint),
100    )
101    // Sessions (delete with dry_run)
102    .route("/sessions/{name}", delete(handlers::delete_session))
103    // Sessions (create)
104    .route("/sessions", post(handlers::create_session))
105    // Images (delete with dry_run)
106    .route("/images", delete(handlers::delete_images))
107    // Configurations (delete with dry_run)
108    .route("/configurations", delete(handlers::delete_configurations))
109    // Boot config (apply with dry_run)
110    .route("/boot-config", post(handlers::apply_boot_config))
111    // Runtime configuration (PUT — set CFS desired_configuration + enabled
112    // on the components resolved from a hosts expression)
113    .route(
114      "/runtime-configuration",
115      put(handlers::apply_runtime_configuration),
116    )
117    // Kernel parameters (apply, add, delete)
118    .route(
119      "/kernel-parameters/apply",
120      post(handlers::apply_kernel_parameters),
121    )
122    .route(
123      "/kernel-parameters/add",
124      post(handlers::add_kernel_parameters),
125    )
126    .route(
127      "/kernel-parameters",
128      delete(handlers::delete_kernel_parameters),
129    )
130    // Migrate
131    .route("/migrate/nodes", post(handlers::migrate_nodes))
132    .route("/migrate/backup", post(handlers::migrate_backup))
133    .route("/migrate/restore", post(handlers::migrate_restore))
134    // Ephemeral environment
135    .route("/ephemeral-env", post(handlers::create_ephemeral_env))
136    // Power management — POST starts a PCS transition and returns
137    // immediately; GET snapshots the transition for the CLI poll loop.
138    .route("/power", post(handlers::post_power))
139    .route(
140      "/power/transitions/{id}",
141      get(handlers::get_power_transition),
142    )
143    // BOS session from template
144    .route(
145      "/templates/{name}/sessions",
146      post(handlers::post_template_session),
147    )
148    // CFS session logs (SSE)
149    .route("/sessions/{name}/logs", get(handlers::get_session_logs))
150    // SAT file apply — per-element endpoints. The CLI's `build_plan`
151    // walks the SAT file and dispatches one POST per artifact;
152    // `images[]` further splits into the three-step
153    // cfs-session/monitor/stamp pipeline that the CLI orchestrates.
154    .route(
155      "/sat-file/configurations",
156      post(handlers::post_sat_configuration),
157    )
158    .route(
159      "/sat-file/images/cfs-session",
160      post(handlers::post_sat_image_cfs_session),
161    )
162    .route(
163      "/sat-file/images/stamp",
164      post(handlers::post_sat_image_stamp),
165    )
166    .route(
167      "/sat-file/session-templates",
168      post(handlers::post_sat_session_template),
169    )
170    // Health check
171    .route("/health", get(handlers::health))
172    // Hardware cluster member management
173    .route(
174      "/hardware-clusters/{target}/members",
175      post(handlers::add_hw_component).delete(handlers::delete_hw_component),
176    )
177    // Hardware cluster configuration (pin/unpin)
178    .route(
179      "/hardware-clusters/{target}/configuration",
180      post(handlers::apply_hw_configuration),
181    )
182    .merge(build_ws_routes());
183  let api = with_api_layers(api, &state);
184
185  // /v2/auth/* — credential-handling sub-router. No Bearer
186  // extractor (chicken-and-egg). Two layered defences applied:
187  // (1) per-IP rate limit, (2) body redaction from any log span.
188  let limiter = AuthRateLimiter::new();
189  let auth = Router::new()
190    .route("/token", post(handlers::auth_token))
191    .route("/validate", post(handlers::auth_validate))
192    .layer(middleware::from_fn(strip_body_for_logs))
193    .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
194    .layer(Extension(limiter));
195
196  Router::new()
197    .nest("/v2", api)
198    .nest("/v2/auth", auth)
199    .merge(SwaggerUi::new("/docs").url("/openapi.json", ApiDoc::openapi()))
200    // HSTS on every response. Browsers ignore HSTS over plain HTTP
201    // per RFC 6797, so this is a no-op when `allow_http = true`
202    // and active otherwise. Conservative one-year max-age; bump to
203    // include `preload` only after confirming the deployment can
204    // sustain it.
205    .layer(middleware::from_fn(add_hsts_header))
206    .with_state(state)
207}
208
209/// The `/v2` resource router's layers, innermost first.
210fn with_api_layers(
211  api: Router<Arc<ServerState>>,
212  state: &Arc<ServerState>,
213) -> Router<Arc<ServerState>> {
214  api
215    // Apply the global request timeout to every route in the api
216    // sub-router.
217    .layer(TimeoutLayer::with_status_code(
218      StatusCode::REQUEST_TIMEOUT,
219      state.request_timeout,
220    ))
221    // A panicking handler becomes a 500 here instead of tearing down
222    // the request future, so the audit layer below still records it.
223    .layer(CatchPanicLayer::new())
224    // Audit every mutating request and console attach. Layered after
225    // (= outside) the timeout and panic catcher so a timed-out or
226    // panicked request is still recorded.
227    .layer(middleware::from_fn_with_state(
228      state.clone(),
229      audit_requests,
230    ))
231}
232
233/// Inject `Strict-Transport-Security: max-age=31536000; includeSubDomains`
234/// on every outgoing response. Cheap; the header is constant.
235async fn add_hsts_header(
236  request: axum::extract::Request,
237  next: middleware::Next,
238) -> axum::response::Response {
239  let mut response = next.run(request).await;
240  response.headers_mut().insert(
241    axum::http::header::STRICT_TRANSPORT_SECURITY,
242    axum::http::HeaderValue::from_static("max-age=31536000; includeSubDomains"),
243  );
244  response
245}
246
247/// WebSocket upgrade routes — kept separate so they're easy to identify
248/// and so the upgrade protocol is not mixed with plain HTTP routes.
249fn build_ws_routes() -> Router<Arc<ServerState>> {
250  Router::new()
251    .route("/nodes/{xname}/console", get(handlers::console_node_ws))
252    .route(
253      "/sessions/{name}/console",
254      get(handlers::console_session_ws),
255    )
256}
257
258#[cfg(test)]
259mod tests {
260  use std::collections::HashMap;
261  use std::time::Duration;
262
263  use axum::{body::Body, http::Request};
264  use tower::ServiceExt as _;
265
266  use super::*;
267  use crate::server::audit::{AuditLog, CaptureSink, event::Verdict};
268
269  /// A handler panic is turned into a 500 *inside* the audit layer, so
270  /// the request is still recorded.
271  #[tokio::test]
272  async fn panicking_handler_is_audited_as_failed() {
273    let sink = Arc::new(CaptureSink::default());
274    let state = Arc::new(ServerState {
275      sites: HashMap::new(),
276      console_inactivity_timeout: Duration::from_secs(1),
277      audit: AuditLog::with_sinks(vec![sink.clone()]),
278      auth_rate_limit_per_minute: None,
279      request_timeout: Duration::from_secs(60),
280      shutdown_grace_period: Duration::from_secs(1),
281      migrate_backup_root: None,
282    });
283    let api = Router::new().route(
284      "/boom",
285      axum::routing::delete(|| async {
286        panic!("handler bug");
287        #[allow(unreachable_code)]
288        ()
289      }),
290    );
291    let app = Router::new()
292      .nest("/v2", with_api_layers(api, &state))
293      .with_state(state);
294
295    let response = app
296      .oneshot(Request::delete("/v2/boom").body(Body::empty()).unwrap())
297      .await
298      .unwrap();
299
300    assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
301    let events = sink.0.lock().unwrap();
302    assert_eq!(events.len(), 1);
303    assert_eq!(events[0].outcome.result, Verdict::Failed);
304    assert_eq!(events[0].request.route, "/v2/boom");
305  }
306}