Skip to main content

manta_server/server/
mod.rs

1//! Axum HTTP/HTTPS server setup.
2//!
3//! - [`ServerState`] — shared application state passed through every
4//!   handler via Axum's `State<Arc<ServerState>>` extractor. Holds one
5//!   [`SiteBackend`] per configured site so a single server can fan
6//!   out to multiple CSM/OpenCHAMI clusters.
7//! - [`start_server`] — binary entry point. Builds the router (see
8//!   [`routes::build_router`]), installs the request-logging
9//!   middleware, optionally wraps the listener in TLS, and installs a
10//!   SIGTERM/Ctrl+C handler for graceful shutdown.
11//! - Submodules:
12//!   - [`handlers`] — per-resource Axum handlers; converts HTTP
13//!     requests into service-layer calls.
14//!   - [`routes`] — router registration (one entry per `/v2`
15//!     path).
16//!   - [`auth_middleware`] — defensive middleware applied to
17//!     `/v2/auth/*` (per-IP rate limit + body redaction).
18//!   - [`audit`] — audit trail of every mutating request (middleware,
19//!     event schema, Kafka + log sinks).
20//!   - [`common`] — server-only helpers (per-request `InfraContext`,
21//!     JWT claim extractors, Vault client).
22//!   - [`api_doc`] — utoipa OpenAPI document served at
23//!     `GET /openapi.json` + `GET /docs`.
24
25pub mod api_doc;
26pub mod audit;
27pub mod auth_middleware;
28pub mod common;
29pub mod handlers;
30pub mod routes;
31
32use std::collections::HashMap;
33use std::net::SocketAddr;
34use std::sync::Arc;
35
36use axum_server::tls_rustls::RustlsConfig;
37use manta_backend_dispatcher::error::Error;
38use std::time::Duration;
39
40use crate::dispatcher::StaticBackendDispatcher;
41use crate::server::audit::AuditLog;
42use crate::server::common::app_context::InfraContext;
43
44/// All per-site connection data the server needs to talk to backend APIs.
45///
46/// Built once at startup from a `[sites.X]` block in `server.toml`,
47/// then owned by [`ServerState::sites`] inside a `HashMap` keyed by
48/// the site name. The matching `[sites.X]` block is selected per
49/// request from the `X-Manta-Site` header.
50///
51/// Borrowed per request as an [`common::app_context::InfraContext`]
52/// via [`ServerState::infra_context`] so the service layer can pass
53/// the per-site bundle around without taking ownership.
54pub struct SiteBackend {
55  /// Dispatches API calls to the configured CSM or OpenCHAMI backend.
56  pub backend: StaticBackendDispatcher,
57  /// Base URL for the CSM/OpenCHAMI API (e.g. `https://api.cluster/apis`).
58  pub shasta_base_url: String,
59  /// PEM-encoded root CA certificate for the backend; empty vec skips verification.
60  pub shasta_root_cert: Vec<u8>,
61  /// HashiCorp Vault base URL; `None` means features requiring vault return 501.
62  pub vault_base_url: Option<String>,
63  /// Gitea VCS base URL derived from the site base URL.
64  pub gitea_base_url: String,
65  /// Kubernetes API URL; `None` means console and log-streaming endpoints return 501.
66  pub k8s_api_url: Option<String>,
67}
68
69/// Shared state for all HTTP handlers.
70///
71/// Holds one [`SiteBackend`] per configured site so a single server
72/// can serve multiple clusters. Each request supplies the target site
73/// via the `X-Manta-Site` header; handlers call
74/// [`ServerState::infra_context`] (or, via the
75/// [`handlers::RequestCtx`] extractor, the cached
76/// `RequestCtx::infra()` shortcut) to retrieve the per-site data.
77///
78/// Plumbed through Axum's `State<Arc<ServerState>>` extractor. Owned
79/// by [`start_server`] and cloned (cheaply, since it's an `Arc`) into
80/// every spawned task.
81pub struct ServerState {
82  /// Per-site connection data, keyed by site name.
83  pub sites: HashMap<String, SiteBackend>,
84  /// How long a WebSocket console session may be idle before the server
85  /// closes it.  Protects against leaked Kubernetes pod attachments.
86  pub console_inactivity_timeout: Duration,
87  /// Audit trail every mutating request is recorded to (always the
88  /// log; Kafka too when `[auditor.kafka]` is configured).
89  pub audit: AuditLog,
90  /// Per-source-IP rate limit on `/v2/auth/*` (requests/minute).
91  /// `None` disables in-process rate limiting.
92  pub auth_rate_limit_per_minute: Option<u32>,
93  /// Global request timeout applied to every HTTP route (router-level
94  /// `TimeoutLayer`). All long-running work (power transitions, SAT
95  /// dispatch) runs CLI-side, so this is the only request-timeout
96  /// knob the server has.
97  pub request_timeout: Duration,
98  /// Drain window for `axum_server::Handle::graceful_shutdown` on
99  /// SIGTERM / Ctrl+C. Sourced from
100  /// `server.toml`'s `[server] shutdown_grace_period_secs`.
101  pub shutdown_grace_period: Duration,
102  /// Filesystem root that confines `POST /migrate/{backup,restore}`
103  /// file access. `None` disables both endpoints — even admin callers
104  /// must wait for an operator to opt in via `[server]
105  /// migrate_backup_root`. The path is stored already-canonicalised
106  /// so per-request validation is a single `starts_with` against this.
107  pub migrate_backup_root: Option<std::path::PathBuf>,
108}
109
110impl ServerState {
111  /// Build a borrowed [`InfraContext`] for the named site.
112  ///
113  /// Called per-request so the service layer can work with its
114  /// existing `&InfraContext<'_>` API without taking ownership of the
115  /// underlying [`SiteBackend`].
116  ///
117  /// # Errors
118  ///
119  /// Returns [`Error::NotFound`] when `site_name` is not in
120  /// [`Self::sites`].
121  pub fn infra_context<'a>(
122    &'a self,
123    site_name: &'a str,
124  ) -> Result<InfraContext<'a>, Error> {
125    let site = self.sites.get(site_name).ok_or_else(|| {
126      Error::NotFound(format!("site '{site_name}' not found"))
127    })?;
128    Ok(InfraContext {
129      backend: &site.backend,
130      site_name,
131      shasta_base_url: &site.shasta_base_url,
132      shasta_root_cert: &site.shasta_root_cert,
133      vault_base_url: site.vault_base_url.as_deref(),
134      gitea_base_url: &site.gitea_base_url,
135      k8s_api_url: site.k8s_api_url.as_deref(),
136    })
137  }
138}
139
140/// Request-logging middleware. Logs `method uri → status` at INFO
141/// after the inner handler returns, including handler-internal
142/// error responses. Composed once by [`start_server`] around the
143/// router built by [`routes::build_router`].
144async fn log_requests(
145  request: axum::extract::Request,
146  next: axum::middleware::Next,
147) -> axum::response::Response {
148  let method = request.method().clone();
149  let uri = request.uri().clone();
150  let response = next.run(request).await;
151  tracing::info!("{} {} → {}", method, uri, response.status());
152  response
153}
154
155/// How long shutdown waits for queued audit events to reach Kafka.
156pub const AUDIT_FLUSH_TIMEOUT: Duration = Duration::from_secs(5);
157
158/// Start the HTTP or HTTPS server.
159///
160/// Builds the router via [`routes::build_router`], wraps it with the
161/// request-logging middleware, binds the listener at
162/// `<listen_addr>:<port>`, and serves until a SIGTERM or Ctrl+C is
163/// received — at which point the in-process shutdown handler
164/// triggers `axum_server`'s graceful drain with the
165/// [`ServerState::shutdown_grace_period`] window.
166///
167/// When `cert_path` and `key_path` are both `Some`, the server
168/// listens with TLS (`https://`). When both are `None`, it listens
169/// as plain HTTP. Mixing one of the two is rejected.
170///
171/// After the drain, audit events still queued for Kafka get up to
172/// [`AUDIT_FLUSH_TIMEOUT`] to be delivered.
173///
174/// # Errors
175///
176/// - [`Error::BadRequest`] when `listen_addr:port` does not parse as
177///   a `SocketAddr`, or when exactly one of `cert_path` / `key_path`
178///   is supplied (they must be set together).
179/// - Any I/O / TLS load error from `RustlsConfig::from_pem_file` or
180///   the underlying `axum_server::bind*` call surfaces via the
181///   `From<io::Error>` impl on [`Error`].
182pub async fn start_server(
183  state: Arc<ServerState>,
184  listen_addr: &str,
185  port: u16,
186  cert_path: Option<&str>,
187  key_path: Option<&str>,
188) -> Result<(), Error> {
189  // Read shutdown-grace before `state` is moved into the router.
190  let shutdown_grace_period = state.shutdown_grace_period;
191  let audit = state.audit.clone();
192
193  // Both `request_timeout` and `power_timeout` are now applied **inside**
194  // `build_router` so the per-route `/power` override actually wins —
195  // see the comment on `build_router` for why a global outer layer
196  // would silently defeat the override.
197  let app =
198    routes::build_router(state).layer(axum::middleware::from_fn(log_requests));
199
200  let addr: SocketAddr = format!("{listen_addr}:{port}")
201    .parse()
202    .map_err(|e| Error::BadRequest(format!("Invalid listen address: {e}")))?;
203
204  match (cert_path, key_path) {
205    (Some(cert), Some(key)) => {
206      let tls_config = RustlsConfig::from_pem_file(cert, key).await?;
207      let handle = axum_server::Handle::new();
208      let ready_handle = handle.clone();
209      tokio::spawn(async move {
210        ready_handle.listening().await;
211        tracing::info!(
212          "HTTPS server ready, accepting requests on https://{}",
213          addr
214        );
215        eprintln!("HTTPS server ready, accepting requests on https://{addr}");
216      });
217      install_shutdown_handler(handle.clone(), shutdown_grace_period);
218      axum_server::bind_rustls(addr, tls_config)
219        .handle(handle)
220        .serve(app.into_make_service_with_connect_info::<SocketAddr>())
221        .await?;
222    }
223    (None, None) => {
224      let handle = axum_server::Handle::new();
225      let ready_handle = handle.clone();
226      tokio::spawn(async move {
227        ready_handle.listening().await;
228        tracing::info!(
229          "HTTP server ready, accepting requests on http://{}",
230          addr
231        );
232        eprintln!("HTTP server ready, accepting requests on http://{addr}");
233      });
234      install_shutdown_handler(handle.clone(), shutdown_grace_period);
235      axum_server::bind(addr)
236        .handle(handle)
237        .serve(app.into_make_service_with_connect_info::<SocketAddr>())
238        .await?;
239    }
240    _ => {
241      return Err(Error::BadRequest(
242        "--cert and --key must be provided together".to_string(),
243      ));
244    }
245  }
246
247  // The drain is over: deliver audit events still queued for Kafka.
248  // `flush` blocks, so keep it off the async workers.
249  if let Err(e) = tokio::task::spawn_blocking(move || {
250    audit.flush(AUDIT_FLUSH_TIMEOUT);
251  })
252  .await
253  {
254    tracing::warn!("audit flush at shutdown did not complete: {e}");
255  }
256
257  Ok(())
258}
259
260/// Spawn a task that waits for SIGTERM or Ctrl+C and triggers
261/// `axum_server`'s graceful shutdown with a bounded drain window.
262/// Without this, the runtime drops in-flight requests when Tokio is
263/// shut down by the OS — `docker stop` / k8s pod termination would
264/// abandon clients mid-call.
265///
266/// The grace-period comes from `ServerState::shutdown_grace_period`
267/// (sourced from `server.toml`); pods that hit this without
268/// finishing get SIGKILL'd by the kubelet.
269fn install_shutdown_handler(
270  handle: axum_server::Handle<SocketAddr>,
271  grace_period: Duration,
272) {
273  tokio::spawn(async move {
274    let mut sigterm = match tokio::signal::unix::signal(
275      tokio::signal::unix::SignalKind::terminate(),
276    ) {
277      Ok(s) => s,
278      Err(e) => {
279        tracing::warn!(
280          "failed to install SIGTERM handler; falling back to Ctrl+C only: {e}"
281        );
282        let _ = tokio::signal::ctrl_c().await;
283        handle.graceful_shutdown(Some(grace_period));
284        return;
285      }
286    };
287    let grace_secs = grace_period.as_secs();
288    tokio::select! {
289      _ = sigterm.recv() => {
290        tracing::info!("SIGTERM received; draining for up to {grace_secs}s");
291      }
292      _ = tokio::signal::ctrl_c() => {
293        tracing::info!("Ctrl+C received; draining for up to {grace_secs}s");
294      }
295    }
296    handle.graceful_shutdown(Some(grace_period));
297  });
298}
299
300#[cfg(test)]
301mod timeout_layer_tests {
302  //! Behavioural tests for the global + per-route TimeoutLayer
303  //! composition used by `start_server` and
304  //! `routes::build_router::power_router`. These prove the *pattern*
305  //! (outer layer applies to all routes; an inner layer overrides for
306  //! the specific routes it wraps) — the production router relies on
307  //! exactly this composition to give `/power` more headroom than the
308  //! global default without affecting other endpoints.
309  //!
310  //! Pure tower/axum unit tests — no `ServerState`, no real handlers,
311  //! no TCP listener. `tower::ServiceExt::oneshot` drives the router
312  //! in-process.
313  use std::time::Duration;
314
315  use axum::{
316    Router,
317    body::Body,
318    http::{Request, StatusCode},
319    routing::get,
320  };
321  use tower::ServiceExt as _;
322  use tower_http::timeout::TimeoutLayer;
323
324  fn get_req(uri: &str) -> Request<Body> {
325    Request::builder()
326      .method("GET")
327      .uri(uri)
328      .body(Body::empty())
329      .unwrap()
330  }
331
332  /// Handler that sleeps `delay` then returns 200 — used to drive
333  /// the timeout layer past its limit on purpose.
334  async fn sleep_handler(delay: Duration) -> &'static str {
335    tokio::time::sleep(delay).await;
336    "ok"
337  }
338
339  #[tokio::test]
340  async fn global_timeout_returns_408_when_handler_exceeds_limit() {
341    let router = Router::new()
342      .route(
343        "/slow",
344        get(|| async { sleep_handler(Duration::from_millis(400)).await }),
345      )
346      .layer(TimeoutLayer::with_status_code(
347        StatusCode::REQUEST_TIMEOUT,
348        Duration::from_millis(50),
349      ));
350
351    let resp = router.oneshot(get_req("/slow")).await.unwrap();
352    assert_eq!(resp.status(), StatusCode::REQUEST_TIMEOUT);
353  }
354
355  #[tokio::test]
356  async fn fast_handler_finishes_before_timeout_fires() {
357    let router = Router::new()
358      .route(
359        "/fast",
360        get(|| async { sleep_handler(Duration::from_millis(10)).await }),
361      )
362      .layer(TimeoutLayer::with_status_code(
363        StatusCode::REQUEST_TIMEOUT,
364        Duration::from_secs(5),
365      ));
366
367    let resp = router.oneshot(get_req("/fast")).await.unwrap();
368    assert_eq!(resp.status(), StatusCode::OK);
369  }
370}