renox_core/
maintenance.rs1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
25#[non_exhaustive]
26pub struct Down {
27 #[serde(with = "chrono::serde::ts_seconds")]
29 pub since: crate::db::DateTime,
30 pub retry: Option<u64>,
32 pub secret: Option<String>,
34}
35
36pub(crate) fn file(storage: &Path) -> PathBuf {
37 storage.join("framework").join("down")
38}
39
40#[derive(Debug, Clone, Default)]
49#[non_exhaustive]
50pub struct DownOptions {
51 secret: Option<String>,
52 retry: Option<u64>,
53}
54
55impl DownOptions {
56 pub fn new() -> Self {
58 Self::default()
59 }
60
61 pub fn secret(mut self, secret: impl Into<String>) -> Self {
63 self.secret = Some(secret.into());
64 self
65 }
66
67 pub fn retry(mut self, seconds: u64) -> Self {
69 self.retry = Some(seconds);
70 self
71 }
72}
73
74pub 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
89pub 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
98pub 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
104fn 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 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 #[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}