Skip to main content

runnerd_web/
auth.rs

1//! Who may use the dashboard. The port is open to every local user, so every request but the login and the static
2//! assets needs the session cookie, and every write also needs an `Origin` of the dashboard itself.
3//!
4//! The session token is random per daemon run. A browser gets it by opening the one-time login URL, which the
5//! daemon keeps in a file only its user can read (`login_file`); each use replaces the URL with a new one. This
6//! module owns the token, the login code and that file; it never logs either.
7
8use std::fmt::Write as _;
9use std::io::{self, Read as _, Write as _};
10use std::net::SocketAddr;
11use std::os::unix::fs::OpenOptionsExt;
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, Mutex};
14
15use axum::extract::{Query, Request, State};
16use axum::http::{HeaderMap, Method, StatusCode, header};
17use axum::middleware::Next;
18use axum::response::{IntoResponse, Redirect, Response};
19use serde::Deserialize;
20use tracing::warn;
21
22use crate::views::{self, SignedOut};
23
24/// Where the daemon serving its API on `socket` keeps the dashboard's login URL: `runnerd.sock` → `runnerd.web`.
25/// `runnerctl web` reads it.
26pub fn login_file(socket: &Path) -> PathBuf {
27    socket.with_extension("web")
28}
29
30/// 32 random bytes as hex.
31fn random_token() -> io::Result<String> {
32    let mut bytes = [0u8; 32];
33    std::fs::File::open("/dev/urandom")?.read_exact(&mut bytes)?;
34    Ok(bytes
35        .iter()
36        .fold(String::with_capacity(64), |mut hex, byte| {
37            let _ = write!(hex, "{byte:02x}");
38            hex
39        }))
40}
41
42/// Compares in time independent of where the strings differ.
43fn same(a: &str, b: &str) -> bool {
44    a.len() == b.len()
45        && a.bytes()
46            .zip(b.bytes())
47            .fold(0u8, |diff, (x, y)| diff | (x ^ y))
48            == 0
49}
50
51/// The session token, the current login code and where its URL is written.
52#[derive(Debug)]
53pub(crate) struct Auth {
54    token: String,
55    code: Mutex<String>,
56    file: PathBuf,
57    cookie: String,
58    origins: Vec<String>,
59    base: String,
60}
61
62impl Auth {
63    /// A fresh token and login code for a dashboard on `addr`; writes the login URL to `file`.
64    pub(crate) fn new(addr: SocketAddr, file: PathBuf) -> io::Result<Arc<Auth>> {
65        let base = format!("http://{addr}");
66        let mut origins = vec![base.clone()];
67        if addr.ip().is_loopback() {
68            origins.push(format!("http://localhost:{}", addr.port()));
69        }
70        let auth = Auth {
71            token: random_token()?,
72            code: Mutex::new(random_token()?),
73            file,
74            cookie: format!("runnerd-{}", addr.port()),
75            origins,
76            base,
77        };
78        auth.write_login_file(&auth.current_code())?;
79        Ok(Arc::new(auth))
80    }
81
82    fn current_code(&self) -> String {
83        self.code.lock().map_or_else(
84            |poisoned| poisoned.into_inner().clone(),
85            |code| code.clone(),
86        )
87    }
88
89    /// Writes the login URL with `code` to the file, readable by this user only, replacing it atomically.
90    fn write_login_file(&self, code: &str) -> io::Result<()> {
91        let temporary = self.file.with_extension("web.new");
92        let _ = std::fs::remove_file(&temporary);
93        let mut file = std::fs::OpenOptions::new()
94            .write(true)
95            .create_new(true)
96            .mode(0o600)
97            .open(&temporary)?;
98        writeln!(file, "{}/login?code={code}", self.base)?;
99        file.sync_all()?;
100        std::fs::rename(&temporary, &self.file)
101    }
102
103    /// Takes the login code if it is the current one, and writes a new one.
104    fn redeem(&self, code: &str) -> bool {
105        let Ok(mut current) = self.code.lock() else {
106            return false;
107        };
108        if !same(code, &current) {
109            return false;
110        }
111        match random_token() {
112            Ok(next) => {
113                if let Err(error) = self.write_login_file(&next) {
114                    warn!(
115                        "cannot write the dashboard's login URL to {}: {error}",
116                        self.file.display()
117                    );
118                }
119                *current = next;
120            }
121            Err(error) => warn!("cannot make a new dashboard login code: {error}"),
122        }
123        true
124    }
125
126    /// Removes the login file, when the dashboard stops.
127    pub(crate) fn remove_login_file(&self) {
128        let _ = std::fs::remove_file(&self.file);
129    }
130
131    fn has_session(&self, headers: &HeaderMap) -> bool {
132        headers
133            .get_all(header::COOKIE)
134            .iter()
135            .filter_map(|value| value.to_str().ok())
136            .flat_map(|value| value.split(';'))
137            .filter_map(|pair| pair.trim().split_once('='))
138            .any(|(name, value)| name == self.cookie && same(value, &self.token))
139    }
140
141    fn same_origin(&self, headers: &HeaderMap) -> bool {
142        headers
143            .get(header::ORIGIN)
144            .and_then(|origin| origin.to_str().ok())
145            .is_some_and(|origin| self.origins.iter().any(|allowed| allowed == origin))
146    }
147}
148
149#[derive(Deserialize)]
150pub(crate) struct Login {
151    code: String,
152}
153
154/// `GET /login?code=…`: a valid code sets the session cookie and goes to the overview.
155pub(crate) async fn login(State(auth): State<Arc<Auth>>, Query(login): Query<Login>) -> Response {
156    if !auth.redeem(&login.code) {
157        return (
158            StatusCode::UNAUTHORIZED,
159            views::signed_out(SignedOut::CodeUsed),
160        )
161            .into_response();
162    }
163    let cookie = format!(
164        "{}={}; Path=/; HttpOnly; SameSite=Strict",
165        auth.cookie, auth.token
166    );
167    ([(header::SET_COOKIE, cookie)], Redirect::to("/")).into_response()
168}
169
170/// Lets through the login, the assets, and requests with the session cookie; writes also need the dashboard's own
171/// `Origin`. A missing or wrong cookie is 401, a foreign origin 403.
172pub(crate) async fn require_session(
173    State(auth): State<Arc<Auth>>,
174    request: Request,
175    next: Next,
176) -> Response {
177    let path = request.uri().path();
178    if path == "/login" || path.starts_with("/assets/") {
179        return next.run(request).await;
180    }
181    if !auth.has_session(request.headers()) {
182        return (
183            StatusCode::UNAUTHORIZED,
184            views::signed_out(SignedOut::NoSession),
185        )
186            .into_response();
187    }
188    let safe = matches!(*request.method(), Method::GET | Method::HEAD);
189    if !safe && !auth.same_origin(request.headers()) {
190        return (
191            StatusCode::FORBIDDEN,
192            "writes must come from the dashboard's own pages",
193        )
194            .into_response();
195    }
196    next.run(request).await
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn a_login_code_works_once_and_the_file_then_holds_a_new_one() {
205        let dir = crate::tests::TempDir::new();
206        let file = dir.0.join("runnerd.web");
207        let auth = Auth::new("127.0.0.1:7477".parse().unwrap(), file.clone()).unwrap();
208        let url = std::fs::read_to_string(&file).unwrap();
209        let code = url.trim().split_once("code=").unwrap().1.to_owned();
210        assert!(url.starts_with("http://127.0.0.1:7477/login?code="));
211        assert!(auth.redeem(&code));
212        assert!(!auth.redeem(&code));
213        let next = std::fs::read_to_string(&file).unwrap();
214        assert_ne!(url, next);
215        let mode = std::os::unix::fs::PermissionsExt::mode(
216            &std::fs::metadata(&file).unwrap().permissions(),
217        );
218        assert_eq!(mode & 0o777, 0o600);
219    }
220}