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