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//! `Sessions` owns every credential, under one lock: the session secret the cookie holds, which the daemon keeps in
6//! a file only its user can read, so a browser stays signed in across restarts; the one-time login links of the
7//! running dashboard, one per address in `login_file`, each replaced once used; and pairing codes, which sign one
8//! device in on the one origin they were made for, within `PAIRING_LIFETIME`. Signing every browser out replaces
9//! the secret and every code at once. This module never logs any of them.
10
11use std::fmt::{self, Write as _};
12use std::io::{self, Read as _, Write as _};
13use std::net::SocketAddr;
14use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _};
15use std::path::{Path, PathBuf};
16use std::sync::{Arc, Mutex, MutexGuard};
17use std::time::{Duration, Instant};
18
19use axum::extract::{Query, Request, State};
20use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
21use axum::middleware::Next;
22use axum::response::{IntoResponse, Redirect, Response};
23use runnerd_core::config::Origin;
24use rustix::fs::{Mode, OFlags};
25use serde::Deserialize;
26use tracing::warn;
27
28use crate::views::{self, SignedOut};
29
30/// How long a signed-in browser stays signed in without using the dashboard. Each authenticated response sets the
31/// cookie again, so a phone opened once a month never signs out; one lost in a drawer does after this.
32const SESSION_LIFETIME: Duration = Duration::from_hours(30 * 24);
33
34/// How long a pairing code works: enough to pick up a phone and scan it.
35pub const PAIRING_LIFETIME: Duration = Duration::from_mins(5);
36
37/// The most pairing codes alive at once; making another drops the oldest.
38pub const PAIRING_MAX: usize = 8;
39
40/// Where the daemon serving its API on `socket` keeps the dashboard's login links, one per line: its loopback
41/// address first, then each of `[web] origins`. `runnerd.sock` → `runnerd.web`. `runnerctl web` reads it.
42pub fn login_file(socket: &Path) -> PathBuf {
43    socket.with_extension("web")
44}
45
46/// Where the daemon keeps the session secret: beside its job history, `database`.
47pub fn session_file(database: &Path) -> PathBuf {
48    database.with_file_name("dashboard-session")
49}
50
51/// 32 random bytes as hex.
52fn random_token() -> io::Result<String> {
53    let mut bytes = [0u8; 32];
54    std::fs::File::open("/dev/urandom")?.read_exact(&mut bytes)?;
55    Ok(bytes
56        .iter()
57        .fold(String::with_capacity(64), |mut hex, byte| {
58            let _ = write!(hex, "{byte:02x}");
59            hex
60        }))
61}
62
63/// Compares in time independent of where the strings differ.
64fn same(a: &str, b: &str) -> bool {
65    a.len() == b.len()
66        && a.bytes()
67            .zip(b.bytes())
68            .fold(0u8, |diff, (x, y)| diff | (x ^ y))
69            == 0
70}
71
72/// Writes `text` to `file`, readable by this user only, replacing it atomically.
73fn write_private(file: &Path, text: &str) -> io::Result<()> {
74    let mut temporary = file.as_os_str().to_owned();
75    temporary.push(".new");
76    let temporary = PathBuf::from(temporary);
77    let _ = std::fs::remove_file(&temporary);
78    let mut written = std::fs::OpenOptions::new()
79        .write(true)
80        .create_new(true)
81        .mode(0o600)
82        .open(&temporary)?;
83    written.write_all(text.as_bytes())?;
84    written.sync_all()?;
85    std::fs::rename(&temporary, file)
86}
87
88fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
89    mutex
90        .lock()
91        .unwrap_or_else(std::sync::PoisonError::into_inner)
92}
93
94/// Fails unless `dir` is owned by this user or root and no other user can write to it: another user could
95/// otherwise swap the secret's file for one they know.
96fn check_private_dir(dir: &Path) -> io::Result<()> {
97    let metadata = std::fs::metadata(dir)?;
98    let owner = metadata.uid();
99    let writable_by_others = metadata.mode() & 0o022;
100    if (owner != rustix::process::geteuid().as_raw() && owner != 0) || writable_by_others != 0 {
101        return Err(io::Error::new(
102            io::ErrorKind::PermissionDenied,
103            format!(
104                "{} can be written by other users; keep the job history (history.database) in a directory only \
105                 you can write",
106                dir.display()
107            ),
108        ));
109    }
110    Ok(())
111}
112
113/// The secret in `file`, or none to make a new one: missing, malformed, or readable or owned by another user. A
114/// symbolic link or anything but a regular file is an error. The checks and the read use one descriptor, opened
115/// without following a link, so the file cannot be swapped between them.
116fn read_secret(file: &Path) -> io::Result<Option<String>> {
117    let flags = OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC;
118    let descriptor = match rustix::fs::open(file, flags, Mode::empty()) {
119        Ok(descriptor) => descriptor,
120        Err(rustix::io::Errno::NOENT) => return Ok(None),
121        Err(rustix::io::Errno::LOOP) => {
122            return Err(io::Error::new(
123                io::ErrorKind::InvalidData,
124                format!("{} is a symbolic link; remove it", file.display()),
125            ));
126        }
127        Err(error) => return Err(error.into()),
128    };
129    let opened = std::fs::File::from(descriptor);
130    let metadata = opened.metadata()?;
131    if !metadata.is_file() {
132        return Err(io::Error::new(
133            io::ErrorKind::InvalidData,
134            format!("{} is not a regular file; remove it", file.display()),
135        ));
136    }
137    let shared = metadata.mode() & 0o077;
138    if metadata.uid() != rustix::process::geteuid().as_raw() || shared != 0 {
139        warn!(
140            "{} is readable by or belongs to another user; signing every browser out",
141            file.display()
142        );
143        return Ok(None);
144    }
145    let mut text = String::new();
146    opened.take(256).read_to_string(&mut text)?;
147    let secret = text.trim();
148    Ok(
149        (secret.len() == 64 && secret.bytes().all(|byte| byte.is_ascii_hexdigit()))
150            .then(|| secret.to_owned()),
151    )
152}
153
154/// The origin a request came to: `X-Forwarded-Proto` and `X-Forwarded-Host` from a proxy, or `http` and `Host`,
155/// as a browser would serialize it; none when they do not make an origin.
156fn request_origin(headers: &HeaderMap) -> Option<Origin> {
157    let header = |name: &str| headers.get(name).and_then(|value| value.to_str().ok());
158    let scheme = header("x-forwarded-proto").unwrap_or("http");
159    let host = header("x-forwarded-host").or_else(|| header(header::HOST.as_str()))?;
160    format!("{scheme}://{host}").parse().ok()
161}
162
163/// A pairing code: it works once, on `origin`, until `expires`.
164struct Pairing {
165    code: String,
166    origin: String,
167    expires: Instant,
168}
169
170/// A running dashboard's login links: the file they are written to, and an address and a code per link, the
171/// loopback address first.
172struct Links {
173    file: PathBuf,
174    bases: Vec<String>,
175    codes: Vec<String>,
176}
177
178impl Links {
179    fn write(&self) -> io::Result<()> {
180        let text =
181            self.bases
182                .iter()
183                .zip(&self.codes)
184                .fold(String::new(), |mut text, (base, code)| {
185                    let _ = writeln!(text, "{base}/login?code={code}");
186                    text
187                });
188        write_private(&self.file, &text)
189    }
190
191    /// New codes for every link, written to the file.
192    fn renew(&mut self) {
193        match self.bases.iter().map(|_| random_token()).collect() {
194            Ok(codes) => self.codes = codes,
195            // The old codes go anyway: an empty one matches nothing.
196            Err(error) => {
197                self.codes = vec![String::new(); self.bases.len()];
198                warn!("cannot make new dashboard login codes: {error}");
199            }
200        }
201        if let Err(error) = self.write() {
202            warn!(
203                "cannot write the dashboard's login links to {}: {error}",
204                self.file.display()
205            );
206        }
207    }
208
209    /// `[web] origins`: the addresses after the loopback one.
210    fn proxies(&self) -> &[String] {
211        self.bases.get(1..).unwrap_or_default()
212    }
213}
214
215/// The secret and the codes, changed together.
216struct Credentials {
217    secret: String,
218    /// None while no dashboard runs.
219    links: Option<Links>,
220    pairings: Vec<Pairing>,
221}
222
223/// Why no pairing link was made.
224#[derive(Debug)]
225pub enum PairError {
226    /// The daemon serves no dashboard (`--web off`).
227    DashboardOff,
228    /// `[web] origins` is empty: a phone cannot reach loopback.
229    NoOrigins,
230    /// The origin asked for is not one of `[web] origins`.
231    NotListed(String),
232    /// The session that asked was signed out before the code was made.
233    SignedOut,
234    /// No random code could be read.
235    Io(io::Error),
236}
237
238impl fmt::Display for PairError {
239    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
240        match self {
241            PairError::DashboardOff => formatter.write_str("runnerd serves no dashboard (--web off)"),
242            PairError::NoOrigins => formatter.write_str(
243                "no address another device reaches the dashboard on: list it in `[web] origins` in runnerd.toml \
244                 and restart runnerd",
245            ),
246            PairError::NotListed(origin) => write!(formatter, "{origin} is not one of [web] origins"),
247            PairError::SignedOut => formatter.write_str("this browser was signed out"),
248            PairError::Io(error) => write!(formatter, "cannot make a pairing code: {error}"),
249        }
250    }
251}
252
253impl std::error::Error for PairError {}
254
255/// Every credential of the dashboard: the session secret, kept in a file only the daemon's user can read, which
256/// every signed-in browser holds in its cookie; the login links of a running dashboard; and the pairing codes.
257pub struct Sessions {
258    file: PathBuf,
259    credentials: Mutex<Credentials>,
260}
261
262impl fmt::Debug for Sessions {
263    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
264        formatter
265            .debug_struct("Sessions")
266            .field("file", &self.file)
267            .finish_non_exhaustive()
268    }
269}
270
271impl Sessions {
272    /// The secret in `file`, or a new one written there when it is missing, malformed, or readable by other users.
273    /// Fails when other users can write to its directory, or `file` is a symbolic link or not a regular file.
274    pub fn open(file: PathBuf) -> io::Result<Arc<Sessions>> {
275        let dir = match file.parent() {
276            Some(dir) if !dir.as_os_str().is_empty() => dir,
277            _ => Path::new("."),
278        };
279        check_private_dir(dir)?;
280        let secret = if let Some(secret) = read_secret(&file)? {
281            secret
282        } else {
283            let secret = random_token()?;
284            write_private(&file, &format!("{secret}\n"))?;
285            secret
286        };
287        Ok(Arc::new(Sessions {
288            file,
289            credentials: Mutex::new(Credentials {
290                secret,
291                links: None,
292                pairings: Vec::new(),
293            }),
294        }))
295    }
296
297    /// Replaces the secret, so every browser signed in is signed out, and every login link and pairing code made
298    /// before, so none signs one in again; the login file gets new links.
299    pub fn sign_out_all(&self) -> io::Result<()> {
300        let mut credentials = lock(&self.credentials);
301        let next = random_token()?;
302        write_private(&self.file, &format!("{next}\n"))?;
303        credentials.secret = next;
304        credentials.pairings.clear();
305        if let Some(links) = &mut credentials.links {
306            links.renew();
307        }
308        Ok(())
309    }
310
311    /// A link on `origin`, one of `[web] origins`, that signs one device in there within `PAIRING_LIFETIME` of
312    /// `now`, for the daemon's owner.
313    pub fn pair(&self, origin: &str, now: Instant) -> Result<String, PairError> {
314        Self::mint(&mut lock(&self.credentials), origin, now)
315    }
316
317    /// `pair` for a browser signed in with `session`, the secret its request was checked against: none once every
318    /// device was signed out since, checked under the lock that makes the code.
319    fn pair_for(
320        &self,
321        session: &SignedIn,
322        origin: &str,
323        now: Instant,
324    ) -> Result<String, PairError> {
325        let mut credentials = lock(&self.credentials);
326        if !same(&session.0, &credentials.secret) {
327            return Err(PairError::SignedOut);
328        }
329        Self::mint(&mut credentials, origin, now)
330    }
331
332    fn mint(
333        credentials: &mut Credentials,
334        origin: &str,
335        now: Instant,
336    ) -> Result<String, PairError> {
337        let links = credentials.links.as_ref().ok_or(PairError::DashboardOff)?;
338        if links.proxies().is_empty() {
339            return Err(PairError::NoOrigins);
340        }
341        if !links.proxies().iter().any(|proxy| proxy == origin) {
342            return Err(PairError::NotListed(origin.to_owned()));
343        }
344        let code = random_token().map_err(PairError::Io)?;
345        credentials.pairings.retain(|pairing| pairing.expires > now);
346        if credentials.pairings.len() >= PAIRING_MAX {
347            credentials.pairings.remove(0);
348        }
349        credentials.pairings.push(Pairing {
350            code: code.clone(),
351            origin: origin.to_owned(),
352            expires: now + PAIRING_LIFETIME,
353        });
354        Ok(format!("{origin}/login?code={code}"))
355    }
356
357    /// `[web] origins` of the running dashboard; none when it does not run.
358    pub fn origins(&self) -> Option<Vec<String>> {
359        lock(&self.credentials)
360            .links
361            .as_ref()
362            .map(|links| links.proxies().to_vec())
363    }
364
365    /// Makes a login link for each of `bases`, the loopback address first, and writes them to `file`.
366    fn publish(&self, file: PathBuf, bases: Vec<String>) -> io::Result<()> {
367        let codes = bases
368            .iter()
369            .map(|_| random_token())
370            .collect::<io::Result<_>>()?;
371        let links = Links { file, bases, codes };
372        links.write()?;
373        lock(&self.credentials).links = Some(links);
374        Ok(())
375    }
376
377    /// Drops the login links and their file, when the dashboard stops.
378    fn withdraw(&self) {
379        if let Some(links) = lock(&self.credentials).links.take() {
380            let _ = std::fs::remove_file(&links.file);
381        }
382    }
383
384    /// The secret, if `value` is it: what a renewed cookie carries, so a request that passed with a secret since
385    /// replaced never gets the new one.
386    fn authenticate(&self, value: &str) -> Option<String> {
387        let credentials = lock(&self.credentials);
388        same(value, &credentials.secret).then(|| credentials.secret.clone())
389    }
390
391    /// Takes `code`, a login link's on any address, or a pairing code alive at `now` on `origin`, the one it was
392    /// made for, and returns the secret to sign in with. A used link's code is replaced in the login file.
393    fn redeem(&self, code: &str, origin: Option<&Origin>, now: Instant) -> Option<String> {
394        let mut credentials = lock(&self.credentials);
395        credentials.pairings.retain(|pairing| pairing.expires > now);
396        if let Some(index) = credentials
397            .pairings
398            .iter()
399            .position(|pairing| same(code, &pairing.code))
400        {
401            if origin.is_none_or(|origin| origin.as_str() != credentials.pairings[index].origin) {
402                return None;
403            }
404            credentials.pairings.remove(index);
405            return Some(credentials.secret.clone());
406        }
407        let links = credentials.links.as_mut()?;
408        let index = links.codes.iter().position(|link| same(code, link))?;
409        match random_token() {
410            Ok(next) => {
411                links.codes[index] = next;
412                if let Err(error) = links.write() {
413                    warn!(
414                        "cannot write the dashboard's login links to {}: {error}",
415                        links.file.display()
416                    );
417                }
418            }
419            // The used code goes anyway: a code works once even when no new one can be made.
420            Err(error) => {
421                links.codes[index] = String::new();
422                warn!("cannot make a new dashboard login code: {error}");
423            }
424        }
425        Some(credentials.secret.clone())
426    }
427}
428
429/// The secret a request's session cookie was checked against, which the session check hands its handler.
430#[derive(Clone)]
431pub(crate) struct SignedIn(String);
432
433/// The checks of a running dashboard: its cookie's name and the origins its pages may write from.
434#[derive(Debug)]
435pub(crate) struct Auth {
436    sessions: Arc<Sessions>,
437    cookie: String,
438    /// The origins whose pages may write: the loopback address, `localhost` on its port, and the proxies.
439    origins: Vec<String>,
440    proxies: Vec<String>,
441}
442
443impl Auth {
444    /// The checks for a dashboard on `addr`, which also takes writes from `proxies`; writes a login link for each
445    /// to `file`.
446    pub(crate) fn new(
447        addr: SocketAddr,
448        proxies: &[Origin],
449        sessions: Arc<Sessions>,
450        file: PathBuf,
451    ) -> io::Result<Arc<Auth>> {
452        let base = format!("http://{addr}");
453        let proxies: Vec<String> = proxies
454            .iter()
455            .map(|origin| origin.as_str().to_owned())
456            .collect();
457        let mut origins = vec![base.clone()];
458        if addr.ip().is_loopback() {
459            origins.push(format!("http://localhost:{}", addr.port()));
460        }
461        origins.extend(proxies.iter().cloned());
462        sessions.publish(
463            file,
464            std::iter::once(base)
465                .chain(proxies.iter().cloned())
466                .collect(),
467        )?;
468        Ok(Arc::new(Auth {
469            sessions,
470            cookie: format!("runnerd-{}", addr.port()),
471            origins,
472            proxies,
473        }))
474    }
475
476    /// `[web] origins`, the addresses a QR code can sign a phone in on.
477    pub(crate) fn proxies(&self) -> &[String] {
478        &self.proxies
479    }
480
481    /// Removes the login file, when the dashboard stops.
482    pub(crate) fn remove_login_file(&self) {
483        self.sessions.withdraw();
484    }
485
486    /// The secret the request's session cookie holds, if it is the current one.
487    fn authenticate(&self, headers: &HeaderMap) -> Option<String> {
488        headers
489            .get_all(header::COOKIE)
490            .iter()
491            .filter_map(|value| value.to_str().ok())
492            .flat_map(|value| value.split(';'))
493            .filter_map(|pair| pair.trim().split_once('='))
494            .filter(|(name, _)| *name == self.cookie)
495            .find_map(|(_, value)| self.sessions.authenticate(value))
496    }
497
498    fn same_origin(&self, headers: &HeaderMap) -> bool {
499        headers
500            .get(header::ORIGIN)
501            .and_then(|origin| origin.to_str().ok())
502            .is_some_and(|origin| self.origins.iter().any(|allowed| allowed == origin))
503    }
504
505    /// The session cookie holding `secret`. `SameSite=Lax`: a link from another site (GitHub, a notification, the
506    /// browser's history) opens the page signed in, while a write from another site still lacks the dashboard's
507    /// `Origin` and is refused. Through a proxy that says it took the request over https
508    /// (`X-Forwarded-Proto`, as `tailscale serve` sends), it is `Secure`.
509    fn cookie(&self, headers: &HeaderMap, secret: &str) -> Option<HeaderValue> {
510        let https = headers
511            .get("x-forwarded-proto")
512            .is_some_and(|proto| proto.as_bytes().eq_ignore_ascii_case(b"https"));
513        HeaderValue::try_from(format!(
514            "{}={secret}; Path=/; Max-Age={}; HttpOnly; SameSite=Lax{}",
515            self.cookie,
516            SESSION_LIFETIME.as_secs(),
517            if https { "; Secure" } else { "" }
518        ))
519        .ok()
520    }
521}
522
523/// A response carrying a credential, which no cache may keep.
524fn no_store(mut response: Response) -> Response {
525    response
526        .headers_mut()
527        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
528    response
529}
530
531#[derive(Deserialize)]
532pub(crate) struct Login {
533    code: String,
534}
535
536/// `GET /login?code=…`: a live code sets the session cookie and goes to the overview. A pairing code works only on
537/// the origin it was made for, as `X-Forwarded-Proto` and `X-Forwarded-Host` or `Host` name it.
538pub(crate) async fn login(
539    State(auth): State<Arc<Auth>>,
540    Query(login): Query<Login>,
541    headers: HeaderMap,
542) -> Response {
543    let origin = request_origin(&headers);
544    let Some(secret) = auth
545        .sessions
546        .redeem(&login.code, origin.as_ref(), Instant::now())
547    else {
548        // A browser that opens a link twice, as a phone's QR scanner can, is signed in by the first open.
549        if auth.authenticate(&headers).is_some() {
550            return no_store(Redirect::to("/").into_response());
551        }
552        return (
553            StatusCode::UNAUTHORIZED,
554            views::signed_out(SignedOut::CodeUsed),
555        )
556            .into_response();
557    };
558    let mut response = Redirect::to("/").into_response();
559    if let Some(cookie) = auth.cookie(&headers, &secret) {
560        response.headers_mut().insert(header::SET_COOKIE, cookie);
561    }
562    no_store(response)
563}
564
565#[derive(Deserialize)]
566pub(crate) struct PairRequest {
567    /// The proxy to sign in on; the first when the page sends none.
568    #[serde(default)]
569    pairing: String,
570}
571
572/// `POST /pair`: a fresh one-time login link on one of `[web] origins`, as a QR code the page shows.
573pub(crate) async fn pair(
574    State(auth): State<Arc<Auth>>,
575    axum::Extension(session): axum::Extension<SignedIn>,
576    request: Option<axum::Json<PairRequest>>,
577) -> Response {
578    let wanted = request.map(|request| request.0.pairing).unwrap_or_default();
579    let origin = if wanted.is_empty() {
580        auth.proxies().first().cloned().unwrap_or_default()
581    } else {
582        wanted
583    };
584    let markup = match auth.sessions.pair_for(&session, &origin, Instant::now()) {
585        Ok(url) => views::config::pairing_code(&url, &origin),
586        Err(PairError::SignedOut) => {
587            return (
588                StatusCode::UNAUTHORIZED,
589                views::signed_out(SignedOut::NoSession),
590            )
591                .into_response();
592        }
593        Err(error) => views::notice(&error.to_string(), views::Tone::Failed),
594    };
595    no_store(axum::response::Html(markup.into_string()).into_response())
596}
597
598/// Lets through the login, the assets, and requests with the session cookie, which they get again with its
599/// lifetime renewed and hand their handler as `SignedIn`; writes also need the dashboard's own `Origin` or a proxy's. A missing or wrong cookie is 401,
600/// a foreign origin 403.
601pub(crate) async fn require_session(
602    State(auth): State<Arc<Auth>>,
603    request: Request,
604    next: Next,
605) -> Response {
606    let path = request.uri().path();
607    if path == "/login" || path.starts_with("/assets/") {
608        return next.run(request).await;
609    }
610    let Some(secret) = auth.authenticate(request.headers()) else {
611        return (
612            StatusCode::UNAUTHORIZED,
613            views::signed_out(SignedOut::NoSession),
614        )
615            .into_response();
616    };
617    let safe = matches!(*request.method(), Method::GET | Method::HEAD);
618    if !safe && !auth.same_origin(request.headers()) {
619        return (
620            StatusCode::FORBIDDEN,
621            "writes must come from the dashboard's own pages",
622        )
623            .into_response();
624    }
625    let cookie = auth.cookie(request.headers(), &secret);
626    let mut request = request;
627    request.extensions_mut().insert(SignedIn(secret));
628    let mut response = next.run(request).await;
629    if let Some(cookie) = cookie {
630        response.headers_mut().append(header::SET_COOKIE, cookie);
631    }
632    response
633}
634
635#[cfg(test)]
636mod tests {
637    use std::os::unix::fs::PermissionsExt as _;
638
639    use super::*;
640
641    const PROXY: &str = "https://forge.example.ts.net:8443";
642
643    fn links(file: &Path) -> Vec<String> {
644        std::fs::read_to_string(file)
645            .unwrap()
646            .lines()
647            .map(str::to_owned)
648            .collect()
649    }
650
651    fn code(link: &str) -> &str {
652        link.split_once("code=").unwrap().1
653    }
654
655    fn mode(file: &Path) -> u32 {
656        std::fs::symlink_metadata(file)
657            .unwrap()
658            .permissions()
659            .mode()
660            & 0o777
661    }
662
663    fn origin(text: &str) -> Origin {
664        text.parse().unwrap()
665    }
666
667    /// The secret in the session file.
668    fn stored(dir: &Path) -> String {
669        std::fs::read_to_string(dir.join("dashboard-session"))
670            .unwrap()
671            .trim()
672            .to_owned()
673    }
674
675    fn dashboard(dir: &Path, proxies: &[&str]) -> Arc<Auth> {
676        let proxies: Vec<Origin> = proxies.iter().map(|p| p.parse().unwrap()).collect();
677        let sessions = Sessions::open(dir.join("dashboard-session")).unwrap();
678        Auth::new(
679            "127.0.0.1:7477".parse().unwrap(),
680            &proxies,
681            sessions,
682            dir.join("runnerd.web"),
683        )
684        .unwrap()
685    }
686
687    #[test]
688    fn a_login_code_works_once_and_the_file_then_holds_a_new_one() {
689        let dir = crate::tests::TempDir::new();
690        let auth = dashboard(&dir.0, &[]);
691        let file = dir.0.join("runnerd.web");
692        let url = links(&file).remove(0);
693        assert!(url.starts_with("http://127.0.0.1:7477/login?code="));
694        let now = Instant::now();
695        assert!(
696            auth.sessions.redeem(code(&url), None, now).is_some(),
697            "the code works"
698        );
699        assert!(
700            auth.sessions.redeem(code(&url), None, now).is_none(),
701            "the code works only once"
702        );
703        assert_ne!(links(&file)[0], url);
704        assert_eq!(mode(&file), 0o600);
705    }
706
707    #[test]
708    fn each_address_gets_its_own_link_and_using_one_leaves_the_others_working() {
709        let dir = crate::tests::TempDir::new();
710        let auth = dashboard(&dir.0, &[PROXY, "https://forge.lan"]);
711        let before = links(&dir.0.join("runnerd.web"));
712        let bases: Vec<&str> = before
713            .iter()
714            .map(|link| link.split_once("/login?").unwrap().0)
715            .collect();
716        assert_eq!(bases, ["http://127.0.0.1:7477", PROXY, "https://forge.lan"]);
717        let now = Instant::now();
718        assert!(auth.sessions.redeem(code(&before[0]), None, now).is_some());
719        let after = links(&dir.0.join("runnerd.web"));
720        assert_ne!(after[0], before[0], "the used link is replaced");
721        assert_eq!(after[1..], before[1..], "the others stay as printed");
722        assert!(
723            auth.sessions.redeem(code(&before[1]), None, now).is_some(),
724            "the proxy's link still works"
725        );
726    }
727
728    #[test]
729    fn a_pairing_code_works_once_and_not_after_it_expires() {
730        let dir = crate::tests::TempDir::new();
731        let auth = dashboard(&dir.0, &[PROXY]);
732        let now = Instant::now();
733        let url = auth.sessions.pair(PROXY, now).unwrap();
734        assert!(url.starts_with("https://forge.example.ts.net:8443/login?code="));
735        let on_proxy = Some(origin(PROXY));
736        assert!(
737            auth.sessions
738                .redeem(code(&url), on_proxy.as_ref(), now)
739                .is_some(),
740            "a fresh code works"
741        );
742        assert!(
743            auth.sessions
744                .redeem(code(&url), on_proxy.as_ref(), now)
745                .is_none(),
746            "a used code does not"
747        );
748        let late = auth.sessions.pair(PROXY, now).unwrap();
749        let after = now + PAIRING_LIFETIME + Duration::from_secs(1);
750        assert!(
751            auth.sessions
752                .redeem(code(&late), on_proxy.as_ref(), after)
753                .is_none(),
754            "an expired code does not"
755        );
756    }
757
758    #[test]
759    fn a_pairing_code_works_only_on_the_origin_it_was_made_for() {
760        let dir = crate::tests::TempDir::new();
761        let auth = dashboard(&dir.0, &[PROXY, "https://forge.lan"]);
762        let now = Instant::now();
763        let url = auth.sessions.pair(PROXY, now).unwrap();
764        for elsewhere in [
765            Some(origin("http://127.0.0.1:7477")),
766            Some(origin("https://forge.lan")),
767            Some(origin("https://unlisted.example")),
768            None,
769        ] {
770            assert!(
771                auth.sessions
772                    .redeem(code(&url), elsewhere.as_ref(), now)
773                    .is_none(),
774                "the code does not work on {elsewhere:?}"
775            );
776        }
777        assert!(
778            auth.sessions
779                .redeem(code(&url), Some(&origin(PROXY)), now)
780                .is_some(),
781            "nor is it spent there"
782        );
783    }
784
785    #[test]
786    fn a_pairing_code_is_only_for_a_configured_proxy() {
787        let dir = crate::tests::TempDir::new();
788        let auth = dashboard(&dir.0, &[PROXY]);
789        assert!(
790            matches!(
791                auth.sessions.pair("https://evil.example", Instant::now()),
792                Err(PairError::NotListed(_))
793            ),
794            "no link on an address the owner did not list"
795        );
796        let dir = crate::tests::TempDir::new();
797        let auth = dashboard(&dir.0, &[]);
798        assert!(
799            matches!(
800                auth.sessions.pair(PROXY, Instant::now()),
801                Err(PairError::NoOrigins)
802            ),
803            "none without [web] origins"
804        );
805        let sessions = Sessions::open(dir.0.join("other-session")).unwrap();
806        assert!(
807            matches!(
808                sessions.pair(PROXY, Instant::now()),
809                Err(PairError::DashboardOff)
810            ),
811            "none while no dashboard runs"
812        );
813    }
814
815    #[test]
816    fn at_most_eight_pairing_codes_live_at_once() {
817        let dir = crate::tests::TempDir::new();
818        let auth = dashboard(&dir.0, &[PROXY]);
819        let now = Instant::now();
820        let urls: Vec<String> = (0..9)
821            .map(|_| auth.sessions.pair(PROXY, now).unwrap())
822            .collect();
823        let on_proxy = Some(origin(PROXY));
824        assert!(
825            auth.sessions
826                .redeem(code(&urls[0]), on_proxy.as_ref(), now)
827                .is_none(),
828            "the oldest is dropped"
829        );
830        assert!(
831            auth.sessions
832                .redeem(code(&urls[1]), on_proxy.as_ref(), now)
833                .is_some(),
834            "the next still works"
835        );
836    }
837
838    #[test]
839    fn the_session_survives_a_restart_until_every_device_is_signed_out() {
840        let dir = crate::tests::TempDir::new();
841        let file = dir.0.join("dashboard-session");
842        Sessions::open(file.clone()).unwrap();
843        let cookie = stored(&dir.0);
844        assert_eq!(mode(&file), 0o600);
845        let restarted = Sessions::open(file.clone()).unwrap();
846        assert!(
847            restarted.authenticate(&cookie).is_some(),
848            "the cookie works after a restart"
849        );
850        restarted.sign_out_all().unwrap();
851        assert!(
852            restarted.authenticate(&cookie).is_none(),
853            "signing out ends it"
854        );
855        assert!(
856            Sessions::open(file.clone())
857                .unwrap()
858                .authenticate(&cookie)
859                .is_none(),
860            "and it stays ended after the next restart"
861        );
862        assert_eq!(mode(&file), 0o600);
863    }
864
865    #[test]
866    fn signing_every_device_out_ends_every_code_made_before_and_writes_new_links() {
867        let dir = crate::tests::TempDir::new();
868        let auth = dashboard(&dir.0, &[PROXY]);
869        let now = Instant::now();
870        let pairing = auth.sessions.pair(PROXY, now).unwrap();
871        let printed = links(&dir.0.join("runnerd.web"));
872        auth.sessions.sign_out_all().unwrap();
873        let on_proxy = Some(origin(PROXY));
874        assert!(
875            auth.sessions
876                .redeem(code(&pairing), on_proxy.as_ref(), now)
877                .is_none(),
878            "a QR code shown before no longer signs in"
879        );
880        for link in &printed {
881            assert!(
882                auth.sessions.redeem(code(link), None, now).is_none(),
883                "a link printed before no longer signs in"
884            );
885        }
886        let fresh = links(&dir.0.join("runnerd.web"));
887        assert_ne!(fresh, printed);
888        let secret = auth.sessions.redeem(code(&fresh[0]), None, now).unwrap();
889        assert!(
890            auth.sessions.authenticate(&secret).is_some(),
891            "a new link signs in with the new secret"
892        );
893    }
894
895    #[test]
896    fn a_request_that_passed_before_a_sign_out_is_renewed_with_its_old_secret_only() {
897        let dir = crate::tests::TempDir::new();
898        let auth = dashboard(&dir.0, &[]);
899        let old = stored(&dir.0);
900        let mut headers = HeaderMap::new();
901        headers.insert(
902            header::COOKIE,
903            format!("{}={old}", auth.cookie).parse().unwrap(),
904        );
905        // The middleware's order: authenticate, the handler runs while every device is signed out, renew.
906        let passed = auth.authenticate(&headers).unwrap();
907        auth.sessions.sign_out_all().unwrap();
908        let renewed = auth.cookie(&headers, &passed).unwrap();
909        let renewed = renewed.to_str().unwrap();
910        let new = stored(&dir.0);
911        assert!(
912            renewed.contains(&old) && !renewed.contains(&new),
913            "the renewed cookie carries the secret the request came with, not its replacement"
914        );
915        assert!(
916            auth.authenticate(&headers).is_none(),
917            "and the next request is signed out"
918        );
919    }
920
921    #[test]
922    fn a_session_file_other_users_can_read_is_replaced() {
923        let dir = crate::tests::TempDir::new();
924        let file = dir.0.join("dashboard-session");
925        Sessions::open(file.clone()).unwrap();
926        let cookie = stored(&dir.0);
927        std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
928        assert!(
929            Sessions::open(file.clone())
930                .unwrap()
931                .authenticate(&cookie)
932                .is_none(),
933            "a secret others could read no longer signs in"
934        );
935        assert_eq!(mode(&file), 0o600);
936    }
937
938    #[test]
939    fn a_session_file_that_is_a_symbolic_link_or_a_pipe_is_refused() {
940        let dir = crate::tests::TempDir::new();
941        let known = dir.0.join("known");
942        std::fs::write(&known, format!("{}\n", "ab".repeat(32))).unwrap();
943        std::fs::set_permissions(&known, std::fs::Permissions::from_mode(0o600)).unwrap();
944        let file = dir.0.join("dashboard-session");
945        std::os::unix::fs::symlink(&known, &file).unwrap();
946        let error = Sessions::open(file).unwrap_err();
947        assert!(
948            error.to_string().contains("symbolic link"),
949            "a known secret behind a link is not taken"
950        );
951        let fifo = dir.0.join("fifo-session");
952        rustix::fs::mknodat(
953            rustix::fs::CWD,
954            &fifo,
955            rustix::fs::FileType::Fifo,
956            Mode::RUSR | Mode::WUSR,
957            0,
958        )
959        .unwrap();
960        let error = Sessions::open(fifo).unwrap_err();
961        assert!(
962            error.to_string().contains("not a regular file"),
963            "a pipe is refused without blocking"
964        );
965    }
966
967    #[test]
968    fn a_session_file_in_a_directory_others_can_write_is_refused() {
969        let dir = crate::tests::TempDir::new();
970        let shared = dir.0.join("shared");
971        std::fs::create_dir(&shared).unwrap();
972        for unsafe_mode in [0o777, 0o1777, 0o770] {
973            std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(unsafe_mode))
974                .unwrap();
975            let error = Sessions::open(shared.join("dashboard-session")).unwrap_err();
976            assert!(
977                error.to_string().contains("can be written by other users"),
978                "mode {unsafe_mode:o} is refused"
979            );
980        }
981        assert!(
982            !shared.join("dashboard-session").exists(),
983            "nothing is written there"
984        );
985    }
986
987    #[test]
988    fn the_log_shows_no_secret_or_code_even_when_a_new_link_cannot_be_written() {
989        let dir = crate::tests::TempDir::new();
990        let log = Arc::new(Mutex::new(Vec::new()));
991        let writer = log.clone();
992        let subscriber = tracing_subscriber::fmt()
993            .with_writer(move || Writer(writer.clone()))
994            .finish();
995        let mut secrets = Vec::new();
996        tracing::subscriber::with_default(subscriber, || {
997            let auth = dashboard(&dir.0, &[PROXY]);
998            let first = links(&dir.0.join("runnerd.web")).remove(0);
999            secrets.push(code(&first).to_owned());
1000            // A directory in place of the login file's temporary: the new link cannot be written, and that is
1001            // logged.
1002            std::fs::create_dir(dir.0.join("runnerd.web.new")).unwrap();
1003            std::fs::create_dir(dir.0.join("runnerd.web.new/x")).unwrap();
1004            let secret = auth
1005                .sessions
1006                .redeem(code(&first), None, Instant::now())
1007                .unwrap();
1008            secrets.push(secret);
1009            let pairing = auth.sessions.pair(PROXY, Instant::now()).unwrap();
1010            secrets.push(code(&pairing).to_owned());
1011            auth.sessions.sign_out_all().unwrap();
1012            let credentials = lock(&auth.sessions.credentials);
1013            secrets.push(credentials.secret.clone());
1014            secrets.extend(credentials.links.as_ref().unwrap().codes.iter().cloned());
1015        });
1016        let log = String::from_utf8(lock(&log).clone()).unwrap();
1017        assert!(log.contains("cannot write"), "the failure is logged: {log}");
1018        for secret in secrets {
1019            assert!(!log.contains(&secret), "the log shows a secret");
1020        }
1021    }
1022
1023    struct Writer(Arc<Mutex<Vec<u8>>>);
1024
1025    impl io::Write for Writer {
1026        fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
1027            lock(&self.0).extend_from_slice(bytes);
1028            Ok(bytes.len())
1029        }
1030
1031        fn flush(&mut self) -> io::Result<()> {
1032            Ok(())
1033        }
1034    }
1035
1036    #[test]
1037    fn debug_output_shows_no_secret_or_code() {
1038        let dir = crate::tests::TempDir::new();
1039        let auth = dashboard(&dir.0, &[PROXY]);
1040        let pairing = auth.sessions.pair(PROXY, Instant::now()).unwrap();
1041        let debug = format!("{auth:?} {:?}", auth.sessions);
1042        let mut secrets = vec![stored(&dir.0), code(&pairing).to_owned()];
1043        secrets.extend(
1044            links(&dir.0.join("runnerd.web"))
1045                .iter()
1046                .map(|link| code(link).to_owned()),
1047        );
1048        for secret in secrets {
1049            assert!(!debug.contains(&secret), "Debug shows a secret");
1050        }
1051    }
1052}