Skip to main content

renox_core/
maintenance.rs

1//! Maintenance mode: `my-app down` makes the site answer 503 (with
2//! `errors/503.html` or the built-in error page) until `my-app up`.
3//!
4//! `my-app down --secret s3cr3t` lets you in anyway: visiting `/s3cr3t` sets a
5//! cookie that bypasses the 503. `/health` and Renox's assets keep working.
6//! The state lives in `storage/framework/down`, so every process sees it.
7
8use std::path::{Path, PathBuf};
9
10use axum::extract::{Request, State};
11use axum::http::HeaderValue;
12use axum::http::header::{COOKIE, SET_COOKIE};
13use axum::middleware::Next;
14use axum::response::{IntoResponse, Redirect, Response};
15use serde::{Deserialize, Serialize};
16
17use crate::crypto::constant_time_eq;
18use crate::queue::unix_now;
19use crate::{AppState, Error, Result};
20
21const BYPASS_COOKIE: &str = "renox_maintenance";
22
23/// The maintenance state `down` writes to `storage/framework/down`.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25#[non_exhaustive]
26pub struct Down {
27    /// When the app went down (stored as unix seconds).
28    #[serde(with = "chrono::serde::ts_seconds")]
29    pub since: crate::db::DateTime,
30    /// Seconds to suggest in `Retry-After`.
31    pub retry: Option<u64>,
32    /// The path that sets the bypass cookie (`/{secret}`), if any.
33    pub secret: Option<String>,
34}
35
36pub(crate) fn file(storage: &Path) -> PathBuf {
37    storage.join("framework").join("down")
38}
39
40/// How [`down`] takes the app down, as `my-app down --secret … --retry …`.
41///
42/// ```no_run
43/// use renox::maintenance::{self, DownOptions};
44/// # fn demo(storage: &std::path::Path) -> renox::Result {
45/// maintenance::down(storage, DownOptions::new().secret("let-me-in").retry(120))?;
46/// # Ok(()) }
47/// ```
48#[derive(Debug, Clone, Default)]
49#[non_exhaustive]
50pub struct DownOptions {
51    secret: Option<String>,
52    retry: Option<u64>,
53}
54
55impl DownOptions {
56    /// No bypass secret, no `Retry-After`.
57    pub fn new() -> Self {
58        Self::default()
59    }
60
61    /// `/{secret}` sets a cookie that lets its browser through.
62    pub fn secret(mut self, secret: impl Into<String>) -> Self {
63        self.secret = Some(secret.into());
64        self
65    }
66
67    /// Seconds to suggest in `Retry-After`.
68    pub fn retry(mut self, seconds: u64) -> Self {
69        self.retry = Some(seconds);
70        self
71    }
72}
73
74/// Takes the app down (every process that shares `storage`).
75pub fn down(storage: &Path, options: DownOptions) -> Result {
76    let path = file(storage);
77    if let Some(dir) = path.parent() {
78        std::fs::create_dir_all(dir)?;
79    }
80    let state = Down {
81        since: crate::db::from_unix(unix_now()),
82        retry: options.retry,
83        secret: options.secret,
84    };
85    std::fs::write(path, serde_json::to_string(&state)?)?;
86    Ok(())
87}
88
89/// Brings the app back; returns whether it was down.
90pub fn up(storage: &Path) -> Result<bool> {
91    match std::fs::remove_file(file(storage)) {
92        Ok(()) => Ok(true),
93        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
94        Err(err) => Err(err.into()),
95    }
96}
97
98/// The maintenance state, or `None` while the app is up.
99pub fn status(storage: &Path) -> Option<Down> {
100    let text = std::fs::read_to_string(file(storage)).ok()?;
101    serde_json::from_str(&text).ok()
102}
103
104/// The bypass cookie's value: an HMAC of the secret, so the cookie can't be
105/// read back into the secret (which also opens `/{secret}`).
106fn bypass_token(state: &AppState, secret: &str) -> String {
107    crate::signed::signature(state, &format!("maintenance-bypass:{secret}"))
108}
109
110fn has_bypass(req: &Request, token: &str) -> bool {
111    req.headers()
112        .get_all(COOKIE)
113        .iter()
114        .filter_map(|v| v.to_str().ok())
115        .flat_map(|v| v.split(';'))
116        .filter_map(|pair| pair.trim().split_once('='))
117        .any(|(name, value)| name == BYPASS_COOKIE && constant_time_eq(value, token))
118}
119
120pub(crate) async fn middleware(
121    State(state): State<AppState>,
122    req: Request,
123    next: Next,
124) -> Response {
125    let Some(down) = status(&state.config.storage_path) else {
126        return next.run(req).await;
127    };
128    // Providers would retry, but may give up; store the calls instead.
129    if state
130        .security
131        .is_webhook(req.extensions().get::<axum::extract::MatchedPath>())
132    {
133        return next.run(req).await;
134    }
135    if let Some(secret) = &down.secret {
136        if has_bypass(&req, &bypass_token(&state, secret)) {
137            return next.run(req).await;
138        }
139        if req.uri().path().trim_start_matches('/') == secret {
140            let mut res = Redirect::to("/").into_response();
141            let secure = if state.config.url.starts_with("https://") {
142                "; Secure"
143            } else {
144                ""
145            };
146            let token = bypass_token(&state, secret);
147            let cookie = format!("{BYPASS_COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax{secure}");
148            if let Ok(value) = HeaderValue::from_str(&cookie) {
149                res.headers_mut().append(SET_COOKIE, value);
150            }
151            return res;
152        }
153    }
154    let mut res = Error::ServiceUnavailable.into_response();
155    if let Some(retry) = down.retry {
156        res.headers_mut().insert("retry-after", retry.into());
157    }
158    res
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    /// `up` that can't remove the marker (here a folder in its place) says
166    /// so, instead of reporting the app as up.
167    #[test]
168    fn up_reports_a_marker_it_cant_remove() {
169        let dir = tempfile::tempdir().unwrap();
170        assert!(!up(dir.path()).unwrap(), "nothing to bring up");
171        std::fs::create_dir_all(file(dir.path())).unwrap();
172        assert!(up(dir.path()).is_err());
173    }
174}