Skip to main content

backbone_maintenance/
lib.rs

1//! Backbone Maintenance Mode — toggleable 503 gate.
2//!
3//! When enabled, every request is rejected with HTTP 503 unless the request
4//! path begins with one of the configured `allow_paths`.
5//!
6//! ## Consumer-app contract
7//!
8//! On a gated request, the response is:
9//! - Status: `503 Service Unavailable`
10//! - Header: `Retry-After: <seconds>` (RFC 7231)
11//! - Header: `X-Service-Status: maintenance` (machine-readable)
12//! - Body (JSON):
13//!   ```json
14//!   {
15//!     "status": "maintenance",
16//!     "message": "...",
17//!     "retry_after": 300,
18//!     "severity": "warning",
19//!     "started_at": "2026-04-25T12:00:00Z",
20//!     "estimated_end_at": "2026-04-25T13:00:00Z"
21//!   }
22//!   ```
23//!
24//! Consumer apps should treat any 503 with `X-Service-Status: maintenance` as
25//! a maintenance signal and surface a banner or block UI accordingly.
26//! `GET /maintenance/status` is always reachable (200) for proactive polling.
27//!
28//! ## Quick start
29//!
30//! ```no_run
31//! use std::sync::Arc;
32//! use axum::{Router, routing::{get, post}};
33//! use backbone_maintenance::{
34//!     MaintenanceConfig, MaintenanceState,
35//!     maintenance_middleware, status_handler, admin_toggle_handler,
36//! };
37//!
38//! # async fn build() -> Router {
39//! let cfg = MaintenanceConfig::default();
40//! let state = MaintenanceState::from_config(&cfg);
41//!
42//! // Routes for status polling and admin toggle (must remain reachable
43//! // while the gate is on — list `/maintenance` in `allow_paths`).
44//! let maintenance_router = Router::new()
45//!     .route("/maintenance/status", get(status_handler))
46//!     .route("/maintenance", post(admin_toggle_handler))
47//!     .with_state(state.clone());
48//!
49//! // Apply the gate as the OUTERMOST middleware so it can short-circuit
50//! // before any downstream layer pays its cost.
51//! Router::new()
52//!     .merge(maintenance_router)
53//!     // .merge(your_app_routes)
54//!     .layer(axum::middleware::from_fn_with_state(
55//!         state,
56//!         maintenance_middleware,
57//!     ))
58//! # }
59//! ```
60
61#![warn(missing_docs)]
62#![warn(clippy::all)]
63
64use std::sync::atomic::{AtomicBool, Ordering};
65use std::sync::{Arc, RwLock};
66
67use axum::{
68    extract::{Request, State},
69    http::{header, HeaderValue, StatusCode},
70    middleware::Next,
71    response::{IntoResponse, Response},
72    Json,
73};
74use chrono::{DateTime, Utc};
75use serde::{Deserialize, Serialize};
76use serde_json::json;
77
78/// Header set on every gated 503 response. Consumers can key off this header
79/// to distinguish a maintenance 503 from any other source of 503.
80pub const X_SERVICE_STATUS_HEADER: &str = "x-service-status";
81
82/// Header value for the maintenance status header.
83pub const X_SERVICE_STATUS_MAINTENANCE: &str = "maintenance";
84
85/// Environment variable name for the admin toggle bearer token. When unset
86/// or empty, `admin_toggle_handler` returns 501 Not Implemented (closed by
87/// default).
88pub const MAINTENANCE_ADMIN_TOKEN_ENV: &str = "MAINTENANCE_ADMIN_TOKEN";
89
90/// Maintenance-mode configuration. Typically deserialized from the consumer
91/// app's YAML config under a `maintenance:` key.
92#[derive(Debug, Clone, Deserialize, Serialize)]
93pub struct MaintenanceConfig {
94    /// Whether the gate is active at boot. Can be toggled at runtime via
95    /// the admin endpoint.
96    pub enabled: bool,
97
98    /// Human-readable message returned in the 503 body.
99    pub message: String,
100
101    /// Value sent in the `Retry-After` header and in the response body.
102    pub retry_after_seconds: u64,
103
104    /// `"info" | "warning" | "critical"` — surfaced to consumer apps for
105    /// banner styling. Free-form; treat as advisory.
106    pub severity: String,
107
108    /// Optional ISO 8601 timestamp; consumers can render an ETA banner.
109    #[serde(default)]
110    pub estimated_end_at: Option<String>,
111
112    /// Path prefixes that bypass the gate. Health probes, the maintenance
113    /// admin/status endpoints themselves, and any login route MUST be
114    /// included or the system can become unrecoverable.
115    pub allow_paths: Vec<String>,
116}
117
118impl Default for MaintenanceConfig {
119    fn default() -> Self {
120        MaintenanceConfig {
121            enabled: false,
122            message: "Service is undergoing scheduled maintenance. Please try again shortly."
123                .to_string(),
124            retry_after_seconds: 300,
125            severity: "warning".to_string(),
126            estimated_end_at: None,
127            allow_paths: vec![
128                "/health".to_string(),
129                "/readyz".to_string(),
130                "/livez".to_string(),
131                "/metrics".to_string(),
132                "/maintenance".to_string(),
133            ],
134        }
135    }
136}
137
138/// Mutable details surfaced in the 503 body and `/maintenance/status`.
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct MaintenanceDetails {
141    /// Human-readable message displayed to clients.
142    pub message: String,
143    /// `Retry-After` value (seconds) and field in the response body.
144    pub retry_after_seconds: u64,
145    /// Severity classification (e.g. "info", "warning", "critical").
146    pub severity: String,
147    /// When the gate was last toggled on (None when off).
148    pub started_at: Option<DateTime<Utc>>,
149    /// Optional ETA when maintenance is expected to end.
150    pub estimated_end_at: Option<DateTime<Utc>>,
151}
152
153/// Shared maintenance state. Cloned cheaply via `Arc`. Mounted as Axum
154/// router state and as middleware state.
155pub struct MaintenanceState {
156    enabled: AtomicBool,
157    details: RwLock<MaintenanceDetails>,
158    allow_paths: Vec<String>,
159}
160
161impl MaintenanceState {
162    /// Build a fresh state from config. Stamps `started_at = now` if the
163    /// gate is enabled at boot.
164    pub fn from_config(cfg: &MaintenanceConfig) -> Arc<Self> {
165        let estimated_end_at = cfg
166            .estimated_end_at
167            .as_deref()
168            .filter(|s| !s.is_empty())
169            .and_then(|s| {
170                DateTime::parse_from_rfc3339(s)
171                    .ok()
172                    .map(|dt| dt.with_timezone(&Utc))
173            });
174
175        let started_at = if cfg.enabled { Some(Utc::now()) } else { None };
176
177        Arc::new(Self {
178            enabled: AtomicBool::new(cfg.enabled),
179            details: RwLock::new(MaintenanceDetails {
180                message: cfg.message.clone(),
181                retry_after_seconds: cfg.retry_after_seconds,
182                severity: cfg.severity.clone(),
183                started_at,
184                estimated_end_at,
185            }),
186            allow_paths: cfg.allow_paths.clone(),
187        })
188    }
189
190    /// Cheap atomic read of the on/off flag.
191    pub fn is_enabled(&self) -> bool {
192        self.enabled.load(Ordering::Relaxed)
193    }
194
195    /// Snapshot of the current state for serialization (status endpoint and
196    /// 503 body construction).
197    pub fn snapshot(&self) -> MaintenanceSnapshot {
198        let d = self.details.read().expect("maintenance details lock poisoned");
199        MaintenanceSnapshot {
200            enabled: self.is_enabled(),
201            message: d.message.clone(),
202            retry_after_seconds: d.retry_after_seconds,
203            severity: d.severity.clone(),
204            started_at: d.started_at,
205            estimated_end_at: d.estimated_end_at,
206        }
207    }
208
209    /// Apply a partial update atomically. Toggling on stamps `started_at` if
210    /// not already set; toggling off clears it.
211    ///
212    /// All field changes are applied under a single write lock so concurrent
213    /// readers cannot observe a half-updated snapshot.
214    pub fn apply_update(&self, update: MaintenanceUpdate) -> MaintenanceSnapshot {
215        let mut d = self.details.write().expect("maintenance details lock poisoned");
216
217        if let Some(enabled) = update.enabled {
218            let was = self.enabled.swap(enabled, Ordering::Relaxed);
219            match (enabled, was) {
220                (true, false) => d.started_at = Some(Utc::now()),
221                (false, true) => d.started_at = None,
222                _ => {}
223            }
224        }
225        if let Some(message) = update.message {
226            d.message = message;
227        }
228        if let Some(retry) = update.retry_after_seconds {
229            d.retry_after_seconds = retry;
230        }
231        if let Some(severity) = update.severity {
232            d.severity = severity;
233        }
234        if let Some(end) = update.estimated_end_at {
235            // empty string clears the value
236            d.estimated_end_at = if end.is_empty() {
237                None
238            } else {
239                DateTime::parse_from_rfc3339(&end)
240                    .ok()
241                    .map(|dt| dt.with_timezone(&Utc))
242            };
243        }
244
245        let snap = MaintenanceSnapshot {
246            enabled: self.is_enabled(),
247            message: d.message.clone(),
248            retry_after_seconds: d.retry_after_seconds,
249            severity: d.severity.clone(),
250            started_at: d.started_at,
251            estimated_end_at: d.estimated_end_at,
252        };
253        drop(d);
254        snap
255    }
256}
257
258/// Snapshot returned by `/maintenance/status` and used to render 503 bodies.
259#[derive(Debug, Clone, Serialize, Deserialize)]
260pub struct MaintenanceSnapshot {
261    /// Current on/off state.
262    pub enabled: bool,
263    /// Current message.
264    pub message: String,
265    /// Current `Retry-After` value.
266    pub retry_after_seconds: u64,
267    /// Current severity classification.
268    pub severity: String,
269    /// When the gate was last toggled on.
270    pub started_at: Option<DateTime<Utc>>,
271    /// Optional ETA when maintenance is expected to end.
272    pub estimated_end_at: Option<DateTime<Utc>>,
273}
274
275/// Partial update accepted by `POST /maintenance`. Any field omitted is left
276/// unchanged. `estimated_end_at: ""` (empty string) clears the field.
277#[derive(Debug, Default, Deserialize)]
278pub struct MaintenanceUpdate {
279    /// Toggle the gate on or off.
280    pub enabled: Option<bool>,
281    /// Replace the message.
282    pub message: Option<String>,
283    /// Replace the `Retry-After` value.
284    pub retry_after_seconds: Option<u64>,
285    /// Replace the severity classification.
286    pub severity: Option<String>,
287    /// Replace the ETA. Empty string clears.
288    pub estimated_end_at: Option<String>,
289}
290
291/// Axum middleware: gate every request unless maintenance is off or the path
292/// matches an `allow_paths` prefix.
293pub async fn maintenance_middleware(
294    State(state): State<Arc<MaintenanceState>>,
295    request: Request,
296    next: Next,
297) -> Response {
298    if !state.is_enabled() {
299        return next.run(request).await;
300    }
301
302    let path = request.uri().path();
303    if state.allow_paths.iter().any(|p| path.starts_with(p)) {
304        return next.run(request).await;
305    }
306
307    let snap = state.snapshot();
308    build_503(&snap)
309}
310
311fn build_503(snap: &MaintenanceSnapshot) -> Response {
312    let body = Json(json!({
313        "status": "maintenance",
314        "message": snap.message,
315        "retry_after": snap.retry_after_seconds,
316        "severity": snap.severity,
317        "started_at": snap.started_at,
318        "estimated_end_at": snap.estimated_end_at,
319    }));
320
321    let mut response = (StatusCode::SERVICE_UNAVAILABLE, body).into_response();
322    let headers = response.headers_mut();
323    if let Ok(v) = HeaderValue::from_str(&snap.retry_after_seconds.to_string()) {
324        headers.insert(header::RETRY_AFTER, v);
325    }
326    headers.insert(
327        X_SERVICE_STATUS_HEADER,
328        HeaderValue::from_static(X_SERVICE_STATUS_MAINTENANCE),
329    );
330    response
331}
332
333/// `GET /maintenance/status` — always 200. Consumer apps poll this to detect
334/// maintenance proactively without getting 503'd.
335pub async fn status_handler(
336    State(state): State<Arc<MaintenanceState>>,
337) -> Json<MaintenanceSnapshot> {
338    Json(state.snapshot())
339}
340
341/// `POST /maintenance` — auth-gated by the `MAINTENANCE_ADMIN_TOKEN` env var.
342///
343/// - If the env var is unset → 501 Not Implemented (closed by default).
344/// - If `Authorization: Bearer <token>` (or `X-Maintenance-Admin-Token`) does
345///   not match → 401.
346/// - On match → applies the update and returns the new snapshot.
347pub async fn admin_toggle_handler(
348    State(state): State<Arc<MaintenanceState>>,
349    headers: axum::http::HeaderMap,
350    Json(update): Json<MaintenanceUpdate>,
351) -> Response {
352    let expected = match std::env::var(MAINTENANCE_ADMIN_TOKEN_ENV) {
353        Ok(t) if !t.is_empty() => t,
354        _ => {
355            return (
356                StatusCode::NOT_IMPLEMENTED,
357                Json(json!({
358                    "error": "maintenance_admin_disabled",
359                    "message": format!(
360                        "{} env var is not configured",
361                        MAINTENANCE_ADMIN_TOKEN_ENV
362                    ),
363                })),
364            )
365                .into_response();
366        }
367    };
368
369    let presented = headers
370        .get(header::AUTHORIZATION)
371        .and_then(|v| v.to_str().ok())
372        .and_then(|s| s.strip_prefix("Bearer ").map(str::to_string))
373        .or_else(|| {
374            headers
375                .get("x-maintenance-admin-token")
376                .and_then(|v| v.to_str().ok())
377                .map(str::to_string)
378        });
379
380    match presented {
381        Some(t) if constant_time_eq(t.as_bytes(), expected.as_bytes()) => {
382            let snap = state.apply_update(update);
383            tracing::warn!(
384                enabled = snap.enabled,
385                severity = %snap.severity,
386                "Maintenance mode updated via admin endpoint"
387            );
388            (StatusCode::OK, Json(snap)).into_response()
389        }
390        _ => (
391            StatusCode::UNAUTHORIZED,
392            Json(json!({ "error": "unauthorized" })),
393        )
394            .into_response(),
395    }
396}
397
398/// Constant-time byte comparison. Avoids early-exit timing leaks when
399/// comparing bearer tokens. Equivalent to the `subtle` crate but kept
400/// inline so this crate has no extra dep.
401fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
402    if a.len() != b.len() {
403        return false;
404    }
405    let mut acc = 0u8;
406    for (x, y) in a.iter().zip(b.iter()) {
407        acc |= x ^ y;
408    }
409    acc == 0
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    #[test]
417    fn default_config_has_safe_allow_paths() {
418        let cfg = MaintenanceConfig::default();
419        assert!(!cfg.enabled);
420        assert!(cfg.allow_paths.iter().any(|p| p == "/health"));
421        assert!(cfg.allow_paths.iter().any(|p| p == "/maintenance"));
422    }
423
424    #[test]
425    fn from_config_off_has_no_started_at() {
426        let cfg = MaintenanceConfig::default();
427        let state = MaintenanceState::from_config(&cfg);
428        assert!(!state.is_enabled());
429        assert!(state.snapshot().started_at.is_none());
430    }
431
432    #[test]
433    fn from_config_on_stamps_started_at() {
434        let cfg = MaintenanceConfig {
435            enabled: true,
436            ..MaintenanceConfig::default()
437        };
438        let state = MaintenanceState::from_config(&cfg);
439        assert!(state.is_enabled());
440        assert!(state.snapshot().started_at.is_some());
441    }
442
443    #[test]
444    fn apply_update_toggle_on_stamps_started_at() {
445        let cfg = MaintenanceConfig::default();
446        let state = MaintenanceState::from_config(&cfg);
447        let snap = state.apply_update(MaintenanceUpdate {
448            enabled: Some(true),
449            ..Default::default()
450        });
451        assert!(snap.enabled);
452        assert!(snap.started_at.is_some());
453    }
454
455    #[test]
456    fn apply_update_toggle_off_clears_started_at() {
457        let cfg = MaintenanceConfig {
458            enabled: true,
459            ..MaintenanceConfig::default()
460        };
461        let state = MaintenanceState::from_config(&cfg);
462        assert!(state.snapshot().started_at.is_some());
463
464        let snap = state.apply_update(MaintenanceUpdate {
465            enabled: Some(false),
466            ..Default::default()
467        });
468        assert!(!snap.enabled);
469        assert!(snap.started_at.is_none());
470    }
471
472    #[test]
473    fn apply_update_estimated_end_at_empty_string_clears() {
474        let cfg = MaintenanceConfig::default();
475        let state = MaintenanceState::from_config(&cfg);
476        state.apply_update(MaintenanceUpdate {
477            estimated_end_at: Some("2026-04-25T13:00:00Z".to_string()),
478            ..Default::default()
479        });
480        assert!(state.snapshot().estimated_end_at.is_some());
481
482        state.apply_update(MaintenanceUpdate {
483            estimated_end_at: Some(String::new()),
484            ..Default::default()
485        });
486        assert!(state.snapshot().estimated_end_at.is_none());
487    }
488
489    #[test]
490    fn constant_time_eq_works() {
491        assert!(constant_time_eq(b"abc", b"abc"));
492        assert!(!constant_time_eq(b"abc", b"abd"));
493        assert!(!constant_time_eq(b"abc", b"abcd"));
494        assert!(constant_time_eq(b"", b""));
495    }
496}