1#![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
78pub const X_SERVICE_STATUS_HEADER: &str = "x-service-status";
81
82pub const X_SERVICE_STATUS_MAINTENANCE: &str = "maintenance";
84
85pub const MAINTENANCE_ADMIN_TOKEN_ENV: &str = "MAINTENANCE_ADMIN_TOKEN";
89
90#[derive(Debug, Clone, Deserialize, Serialize)]
93pub struct MaintenanceConfig {
94 pub enabled: bool,
97
98 pub message: String,
100
101 pub retry_after_seconds: u64,
103
104 pub severity: String,
107
108 #[serde(default)]
110 pub estimated_end_at: Option<String>,
111
112 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#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct MaintenanceDetails {
141 pub message: String,
143 pub retry_after_seconds: u64,
145 pub severity: String,
147 pub started_at: Option<DateTime<Utc>>,
149 pub estimated_end_at: Option<DateTime<Utc>>,
151}
152
153pub struct MaintenanceState {
156 enabled: AtomicBool,
157 details: RwLock<MaintenanceDetails>,
158 allow_paths: Vec<String>,
159}
160
161impl MaintenanceState {
162 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 pub fn is_enabled(&self) -> bool {
192 self.enabled.load(Ordering::Relaxed)
193 }
194
195 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
260pub struct MaintenanceSnapshot {
261 pub enabled: bool,
263 pub message: String,
265 pub retry_after_seconds: u64,
267 pub severity: String,
269 pub started_at: Option<DateTime<Utc>>,
271 pub estimated_end_at: Option<DateTime<Utc>>,
273}
274
275#[derive(Debug, Default, Deserialize)]
278pub struct MaintenanceUpdate {
279 pub enabled: Option<bool>,
281 pub message: Option<String>,
283 pub retry_after_seconds: Option<u64>,
285 pub severity: Option<String>,
287 pub estimated_end_at: Option<String>,
289}
290
291pub 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
333pub async fn status_handler(
336 State(state): State<Arc<MaintenanceState>>,
337) -> Json<MaintenanceSnapshot> {
338 Json(state.snapshot())
339}
340
341pub 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
398fn 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}