Skip to main content

edda_server/
admin.rs

1//! The admin API: what whoever runs the services asks of them over HTTP,
2//! such as creating and deleting campaigns, so a hosting's control plane
3//! can do it without a shell. Served on `ADMIN_ADDR`, an address only it
4//! can reach, and only with `ADMIN_TOKEN` too, which every request must
5//! bear; without both there's none.
6
7use axum::{
8    Router,
9    extract::Request,
10    http::{StatusCode, header},
11    middleware::{self, Next},
12    response::Response,
13};
14
15/// Where to serve the admin API, and the token it wants.
16#[derive(Clone)]
17pub struct Admin {
18    pub address: String,
19    token: String,
20}
21
22/// Leaves the token out.
23impl std::fmt::Debug for Admin {
24    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
25        f.debug_struct("Admin")
26            .field("address", &self.address)
27            .finish_non_exhaustive()
28    }
29}
30
31/// The shortest token accepted: guessing it must be hopeless.
32const MIN_TOKEN: usize = 32;
33
34impl Admin {
35    /// From `ADMIN_ADDR` and `ADMIN_TOKEN`: `None` with neither, an error
36    /// with only one, or a token too short to be safe.
37    pub fn from_env() -> Result<Option<Self>, String> {
38        let var = |name| std::env::var(name).ok().filter(|v: &String| !v.is_empty());
39        Self::from(var("ADMIN_ADDR"), var("ADMIN_TOKEN"))
40    }
41
42    fn from(address: Option<String>, token: Option<String>) -> Result<Option<Self>, String> {
43        match (address, token) {
44            (None, None) => Ok(None),
45            (Some(_), None) => Err("ADMIN_ADDR needs ADMIN_TOKEN".into()),
46            (None, Some(_)) => Err("ADMIN_TOKEN needs ADMIN_ADDR".into()),
47            (Some(address), Some(token)) => Self::new(address, token).map(Some),
48        }
49    }
50
51    /// Serving on `address`, wanting `token`: at least 32 characters, of
52    /// printable ASCII (as in a header).
53    pub fn new(address: impl Into<String>, token: impl Into<String>) -> Result<Self, String> {
54        let token = token.into();
55        if token.len() < MIN_TOKEN || !token.bytes().all(|b| b.is_ascii_graphic()) {
56            return Err(format!(
57                "ADMIN_TOKEN must be at least {MIN_TOKEN} printable ASCII characters"
58            ));
59        }
60        Ok(Self {
61            address: address.into(),
62            token,
63        })
64    }
65
66    /// `router`, answering only requests that bear the token.
67    pub fn guard(&self, router: Router) -> Router {
68        let token = self.token.clone();
69        router.layer(middleware::from_fn(move |request, next| {
70            let token = token.clone();
71            async move { check(&token, request, next).await }
72        }))
73    }
74}
75
76async fn check(token: &str, request: Request, next: Next) -> Response {
77    let bearer = request
78        .headers()
79        .get(header::AUTHORIZATION)
80        .and_then(|v| v.to_str().ok())
81        .and_then(|v| v.strip_prefix("Bearer "));
82    match bearer {
83        Some(given) if same(given.trim().as_bytes(), token.as_bytes()) => next.run(request).await,
84        _ => crate::error_response(StatusCode::UNAUTHORIZED, &"the admin token is required"),
85    }
86}
87
88/// Whether `a` and `b` are the same, taking as long however much of them
89/// matches, so the time taken says nothing about the token.
90fn same(a: &[u8], b: &[u8]) -> bool {
91    a.len() == b.len() && a.iter().zip(b).fold(0, |differ, (x, y)| differ | (x ^ y)) == 0
92}
93
94#[cfg(test)]
95mod tests {
96    use axum::{body::Body, routing::get};
97    use tower::ServiceExt;
98
99    use super::*;
100
101    const TOKEN: &str = "a-long-enough-admin-token-for-the-tests";
102
103    #[test]
104    fn it_takes_both_settings_or_neither() {
105        let some = |s: &str| Some(s.to_owned());
106        assert!(Admin::from(None, None).unwrap().is_none());
107        assert!(Admin::from(some("0.0.0.0:9200"), None).is_err());
108        assert!(Admin::from(None, some(TOKEN)).is_err());
109        assert!(Admin::from(some("0.0.0.0:9200"), some("short")).is_err());
110        let spaced = format!("{TOKEN} with spaces");
111        assert!(
112            Admin::new("0.0.0.0:9200", spaced).is_err(),
113            "it goes in a header"
114        );
115        let admin = Admin::from(some("0.0.0.0:9200"), some(TOKEN))
116            .unwrap()
117            .unwrap();
118        assert_eq!(admin.address, "0.0.0.0:9200");
119        assert!(
120            !format!("{admin:?}").contains(TOKEN),
121            "the token stays out of logs"
122        );
123    }
124
125    #[tokio::test]
126    async fn only_requests_bearing_the_token_get_through() {
127        let admin = Admin::from(Some("unused".into()), Some(TOKEN.into()))
128            .unwrap()
129            .unwrap();
130        let app = admin.guard(Router::new().route("/admin/x", get(|| async { "done" })));
131        let status = |authorization: Option<String>| {
132            let app = app.clone();
133            async move {
134                let mut request = Request::get("/admin/x");
135                if let Some(value) = authorization {
136                    request = request.header(header::AUTHORIZATION, value);
137                }
138                app.oneshot(request.body(Body::empty()).unwrap())
139                    .await
140                    .unwrap()
141                    .status()
142            }
143        };
144        assert_eq!(status(None).await, StatusCode::UNAUTHORIZED);
145        assert_eq!(
146            status(Some("Bearer wrong".into())).await,
147            StatusCode::UNAUTHORIZED
148        );
149        assert_eq!(
150            status(Some(format!("Bearer {TOKEN}x"))).await,
151            StatusCode::UNAUTHORIZED
152        );
153        assert_eq!(
154            status(Some(format!("Bearer {TOKEN}"))).await,
155            StatusCode::OK
156        );
157    }
158
159    #[test]
160    fn comparing_is_exact() {
161        assert!(same(b"abc", b"abc"));
162        assert!(!same(b"abc", b"abd"));
163        assert!(!same(b"abc", b"ab"));
164    }
165}