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