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`. Through a proxy that says it took the request over https
506    /// (`X-Forwarded-Proto`, as `tailscale serve` sends), it is `Secure`.
507    fn cookie(&self, headers: &HeaderMap, secret: &str) -> Option<HeaderValue> {
508        let https = headers
509            .get("x-forwarded-proto")
510            .is_some_and(|proto| proto.as_bytes().eq_ignore_ascii_case(b"https"));
511        HeaderValue::try_from(format!(
512            "{}={secret}; Path=/; Max-Age={}; HttpOnly; SameSite=Strict{}",
513            self.cookie,
514            SESSION_LIFETIME.as_secs(),
515            if https { "; Secure" } else { "" }
516        ))
517        .ok()
518    }
519}
520
521/// A response carrying a credential, which no cache may keep.
522fn no_store(mut response: Response) -> Response {
523    response
524        .headers_mut()
525        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
526    response
527}
528
529#[derive(Deserialize)]
530pub(crate) struct Login {
531    code: String,
532}
533
534/// `GET /login?code=…`: a live code sets the session cookie and goes to the overview. A pairing code works only on
535/// the origin it was made for, as `X-Forwarded-Proto` and `X-Forwarded-Host` or `Host` name it.
536pub(crate) async fn login(
537    State(auth): State<Arc<Auth>>,
538    Query(login): Query<Login>,
539    headers: HeaderMap,
540) -> Response {
541    let origin = request_origin(&headers);
542    let Some(secret) = auth
543        .sessions
544        .redeem(&login.code, origin.as_ref(), Instant::now())
545    else {
546        return (
547            StatusCode::UNAUTHORIZED,
548            views::signed_out(SignedOut::CodeUsed),
549        )
550            .into_response();
551    };
552    let mut response = Redirect::to("/").into_response();
553    if let Some(cookie) = auth.cookie(&headers, &secret) {
554        response.headers_mut().insert(header::SET_COOKIE, cookie);
555    }
556    no_store(response)
557}
558
559#[derive(Deserialize)]
560pub(crate) struct PairRequest {
561    /// The proxy to sign in on; the first when the page sends none.
562    #[serde(default)]
563    pairing: String,
564}
565
566/// `POST /pair`: a fresh one-time login link on one of `[web] origins`, as a QR code the page shows.
567pub(crate) async fn pair(
568    State(auth): State<Arc<Auth>>,
569    axum::Extension(session): axum::Extension<SignedIn>,
570    request: Option<axum::Json<PairRequest>>,
571) -> Response {
572    let wanted = request.map(|request| request.0.pairing).unwrap_or_default();
573    let origin = if wanted.is_empty() {
574        auth.proxies().first().cloned().unwrap_or_default()
575    } else {
576        wanted
577    };
578    let markup = match auth.sessions.pair_for(&session, &origin, Instant::now()) {
579        Ok(url) => views::config::pairing_code(&url, &origin),
580        Err(PairError::SignedOut) => {
581            return (
582                StatusCode::UNAUTHORIZED,
583                views::signed_out(SignedOut::NoSession),
584            )
585                .into_response();
586        }
587        Err(error) => views::notice(&error.to_string(), views::Tone::Failed),
588    };
589    no_store(axum::response::Html(markup.into_string()).into_response())
590}
591
592/// Lets through the login, the assets, and requests with the session cookie, which they get again with its
593/// 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,
594/// a foreign origin 403.
595pub(crate) async fn require_session(
596    State(auth): State<Arc<Auth>>,
597    request: Request,
598    next: Next,
599) -> Response {
600    let path = request.uri().path();
601    if path == "/login" || path.starts_with("/assets/") {
602        return next.run(request).await;
603    }
604    let Some(secret) = auth.authenticate(request.headers()) else {
605        return (
606            StatusCode::UNAUTHORIZED,
607            views::signed_out(SignedOut::NoSession),
608        )
609            .into_response();
610    };
611    let safe = matches!(*request.method(), Method::GET | Method::HEAD);
612    if !safe && !auth.same_origin(request.headers()) {
613        return (
614            StatusCode::FORBIDDEN,
615            "writes must come from the dashboard's own pages",
616        )
617            .into_response();
618    }
619    let cookie = auth.cookie(request.headers(), &secret);
620    let mut request = request;
621    request.extensions_mut().insert(SignedIn(secret));
622    let mut response = next.run(request).await;
623    if let Some(cookie) = cookie {
624        response.headers_mut().append(header::SET_COOKIE, cookie);
625    }
626    response
627}
628
629#[cfg(test)]
630mod tests {
631    use std::os::unix::fs::PermissionsExt as _;
632
633    use super::*;
634
635    const PROXY: &str = "https://forge.example.ts.net:8443";
636
637    fn links(file: &Path) -> Vec<String> {
638        std::fs::read_to_string(file)
639            .unwrap()
640            .lines()
641            .map(str::to_owned)
642            .collect()
643    }
644
645    fn code(link: &str) -> &str {
646        link.split_once("code=").unwrap().1
647    }
648
649    fn mode(file: &Path) -> u32 {
650        std::fs::symlink_metadata(file)
651            .unwrap()
652            .permissions()
653            .mode()
654            & 0o777
655    }
656
657    fn origin(text: &str) -> Origin {
658        text.parse().unwrap()
659    }
660
661    /// The secret in the session file.
662    fn stored(dir: &Path) -> String {
663        std::fs::read_to_string(dir.join("dashboard-session"))
664            .unwrap()
665            .trim()
666            .to_owned()
667    }
668
669    fn dashboard(dir: &Path, proxies: &[&str]) -> Arc<Auth> {
670        let proxies: Vec<Origin> = proxies.iter().map(|p| p.parse().unwrap()).collect();
671        let sessions = Sessions::open(dir.join("dashboard-session")).unwrap();
672        Auth::new(
673            "127.0.0.1:7477".parse().unwrap(),
674            &proxies,
675            sessions,
676            dir.join("runnerd.web"),
677        )
678        .unwrap()
679    }
680
681    #[test]
682    fn a_login_code_works_once_and_the_file_then_holds_a_new_one() {
683        let dir = crate::tests::TempDir::new();
684        let auth = dashboard(&dir.0, &[]);
685        let file = dir.0.join("runnerd.web");
686        let url = links(&file).remove(0);
687        assert!(url.starts_with("http://127.0.0.1:7477/login?code="));
688        let now = Instant::now();
689        assert!(
690            auth.sessions.redeem(code(&url), None, now).is_some(),
691            "the code works"
692        );
693        assert!(
694            auth.sessions.redeem(code(&url), None, now).is_none(),
695            "the code works only once"
696        );
697        assert_ne!(links(&file)[0], url);
698        assert_eq!(mode(&file), 0o600);
699    }
700
701    #[test]
702    fn each_address_gets_its_own_link_and_using_one_leaves_the_others_working() {
703        let dir = crate::tests::TempDir::new();
704        let auth = dashboard(&dir.0, &[PROXY, "https://forge.lan"]);
705        let before = links(&dir.0.join("runnerd.web"));
706        let bases: Vec<&str> = before
707            .iter()
708            .map(|link| link.split_once("/login?").unwrap().0)
709            .collect();
710        assert_eq!(bases, ["http://127.0.0.1:7477", PROXY, "https://forge.lan"]);
711        let now = Instant::now();
712        assert!(auth.sessions.redeem(code(&before[0]), None, now).is_some());
713        let after = links(&dir.0.join("runnerd.web"));
714        assert_ne!(after[0], before[0], "the used link is replaced");
715        assert_eq!(after[1..], before[1..], "the others stay as printed");
716        assert!(
717            auth.sessions.redeem(code(&before[1]), None, now).is_some(),
718            "the proxy's link still works"
719        );
720    }
721
722    #[test]
723    fn a_pairing_code_works_once_and_not_after_it_expires() {
724        let dir = crate::tests::TempDir::new();
725        let auth = dashboard(&dir.0, &[PROXY]);
726        let now = Instant::now();
727        let url = auth.sessions.pair(PROXY, now).unwrap();
728        assert!(url.starts_with("https://forge.example.ts.net:8443/login?code="));
729        let on_proxy = Some(origin(PROXY));
730        assert!(
731            auth.sessions
732                .redeem(code(&url), on_proxy.as_ref(), now)
733                .is_some(),
734            "a fresh code works"
735        );
736        assert!(
737            auth.sessions
738                .redeem(code(&url), on_proxy.as_ref(), now)
739                .is_none(),
740            "a used code does not"
741        );
742        let late = auth.sessions.pair(PROXY, now).unwrap();
743        let after = now + PAIRING_LIFETIME + Duration::from_secs(1);
744        assert!(
745            auth.sessions
746                .redeem(code(&late), on_proxy.as_ref(), after)
747                .is_none(),
748            "an expired code does not"
749        );
750    }
751
752    #[test]
753    fn a_pairing_code_works_only_on_the_origin_it_was_made_for() {
754        let dir = crate::tests::TempDir::new();
755        let auth = dashboard(&dir.0, &[PROXY, "https://forge.lan"]);
756        let now = Instant::now();
757        let url = auth.sessions.pair(PROXY, now).unwrap();
758        for elsewhere in [
759            Some(origin("http://127.0.0.1:7477")),
760            Some(origin("https://forge.lan")),
761            Some(origin("https://unlisted.example")),
762            None,
763        ] {
764            assert!(
765                auth.sessions
766                    .redeem(code(&url), elsewhere.as_ref(), now)
767                    .is_none(),
768                "the code does not work on {elsewhere:?}"
769            );
770        }
771        assert!(
772            auth.sessions
773                .redeem(code(&url), Some(&origin(PROXY)), now)
774                .is_some(),
775            "nor is it spent there"
776        );
777    }
778
779    #[test]
780    fn a_pairing_code_is_only_for_a_configured_proxy() {
781        let dir = crate::tests::TempDir::new();
782        let auth = dashboard(&dir.0, &[PROXY]);
783        assert!(
784            matches!(
785                auth.sessions.pair("https://evil.example", Instant::now()),
786                Err(PairError::NotListed(_))
787            ),
788            "no link on an address the owner did not list"
789        );
790        let dir = crate::tests::TempDir::new();
791        let auth = dashboard(&dir.0, &[]);
792        assert!(
793            matches!(
794                auth.sessions.pair(PROXY, Instant::now()),
795                Err(PairError::NoOrigins)
796            ),
797            "none without [web] origins"
798        );
799        let sessions = Sessions::open(dir.0.join("other-session")).unwrap();
800        assert!(
801            matches!(
802                sessions.pair(PROXY, Instant::now()),
803                Err(PairError::DashboardOff)
804            ),
805            "none while no dashboard runs"
806        );
807    }
808
809    #[test]
810    fn at_most_eight_pairing_codes_live_at_once() {
811        let dir = crate::tests::TempDir::new();
812        let auth = dashboard(&dir.0, &[PROXY]);
813        let now = Instant::now();
814        let urls: Vec<String> = (0..9)
815            .map(|_| auth.sessions.pair(PROXY, now).unwrap())
816            .collect();
817        let on_proxy = Some(origin(PROXY));
818        assert!(
819            auth.sessions
820                .redeem(code(&urls[0]), on_proxy.as_ref(), now)
821                .is_none(),
822            "the oldest is dropped"
823        );
824        assert!(
825            auth.sessions
826                .redeem(code(&urls[1]), on_proxy.as_ref(), now)
827                .is_some(),
828            "the next still works"
829        );
830    }
831
832    #[test]
833    fn the_session_survives_a_restart_until_every_device_is_signed_out() {
834        let dir = crate::tests::TempDir::new();
835        let file = dir.0.join("dashboard-session");
836        Sessions::open(file.clone()).unwrap();
837        let cookie = stored(&dir.0);
838        assert_eq!(mode(&file), 0o600);
839        let restarted = Sessions::open(file.clone()).unwrap();
840        assert!(
841            restarted.authenticate(&cookie).is_some(),
842            "the cookie works after a restart"
843        );
844        restarted.sign_out_all().unwrap();
845        assert!(
846            restarted.authenticate(&cookie).is_none(),
847            "signing out ends it"
848        );
849        assert!(
850            Sessions::open(file.clone())
851                .unwrap()
852                .authenticate(&cookie)
853                .is_none(),
854            "and it stays ended after the next restart"
855        );
856        assert_eq!(mode(&file), 0o600);
857    }
858
859    #[test]
860    fn signing_every_device_out_ends_every_code_made_before_and_writes_new_links() {
861        let dir = crate::tests::TempDir::new();
862        let auth = dashboard(&dir.0, &[PROXY]);
863        let now = Instant::now();
864        let pairing = auth.sessions.pair(PROXY, now).unwrap();
865        let printed = links(&dir.0.join("runnerd.web"));
866        auth.sessions.sign_out_all().unwrap();
867        let on_proxy = Some(origin(PROXY));
868        assert!(
869            auth.sessions
870                .redeem(code(&pairing), on_proxy.as_ref(), now)
871                .is_none(),
872            "a QR code shown before no longer signs in"
873        );
874        for link in &printed {
875            assert!(
876                auth.sessions.redeem(code(link), None, now).is_none(),
877                "a link printed before no longer signs in"
878            );
879        }
880        let fresh = links(&dir.0.join("runnerd.web"));
881        assert_ne!(fresh, printed);
882        let secret = auth.sessions.redeem(code(&fresh[0]), None, now).unwrap();
883        assert!(
884            auth.sessions.authenticate(&secret).is_some(),
885            "a new link signs in with the new secret"
886        );
887    }
888
889    #[test]
890    fn a_request_that_passed_before_a_sign_out_is_renewed_with_its_old_secret_only() {
891        let dir = crate::tests::TempDir::new();
892        let auth = dashboard(&dir.0, &[]);
893        let old = stored(&dir.0);
894        let mut headers = HeaderMap::new();
895        headers.insert(
896            header::COOKIE,
897            format!("{}={old}", auth.cookie).parse().unwrap(),
898        );
899        // The middleware's order: authenticate, the handler runs while every device is signed out, renew.
900        let passed = auth.authenticate(&headers).unwrap();
901        auth.sessions.sign_out_all().unwrap();
902        let renewed = auth.cookie(&headers, &passed).unwrap();
903        let renewed = renewed.to_str().unwrap();
904        let new = stored(&dir.0);
905        assert!(
906            renewed.contains(&old) && !renewed.contains(&new),
907            "the renewed cookie carries the secret the request came with, not its replacement"
908        );
909        assert!(
910            auth.authenticate(&headers).is_none(),
911            "and the next request is signed out"
912        );
913    }
914
915    #[test]
916    fn a_session_file_other_users_can_read_is_replaced() {
917        let dir = crate::tests::TempDir::new();
918        let file = dir.0.join("dashboard-session");
919        Sessions::open(file.clone()).unwrap();
920        let cookie = stored(&dir.0);
921        std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
922        assert!(
923            Sessions::open(file.clone())
924                .unwrap()
925                .authenticate(&cookie)
926                .is_none(),
927            "a secret others could read no longer signs in"
928        );
929        assert_eq!(mode(&file), 0o600);
930    }
931
932    #[test]
933    fn a_session_file_that_is_a_symbolic_link_or_a_pipe_is_refused() {
934        let dir = crate::tests::TempDir::new();
935        let known = dir.0.join("known");
936        std::fs::write(&known, format!("{}\n", "ab".repeat(32))).unwrap();
937        std::fs::set_permissions(&known, std::fs::Permissions::from_mode(0o600)).unwrap();
938        let file = dir.0.join("dashboard-session");
939        std::os::unix::fs::symlink(&known, &file).unwrap();
940        let error = Sessions::open(file).unwrap_err();
941        assert!(
942            error.to_string().contains("symbolic link"),
943            "a known secret behind a link is not taken"
944        );
945        let fifo = dir.0.join("fifo-session");
946        rustix::fs::mknodat(
947            rustix::fs::CWD,
948            &fifo,
949            rustix::fs::FileType::Fifo,
950            Mode::RUSR | Mode::WUSR,
951            0,
952        )
953        .unwrap();
954        let error = Sessions::open(fifo).unwrap_err();
955        assert!(
956            error.to_string().contains("not a regular file"),
957            "a pipe is refused without blocking"
958        );
959    }
960
961    #[test]
962    fn a_session_file_in_a_directory_others_can_write_is_refused() {
963        let dir = crate::tests::TempDir::new();
964        let shared = dir.0.join("shared");
965        std::fs::create_dir(&shared).unwrap();
966        for unsafe_mode in [0o777, 0o1777, 0o770] {
967            std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(unsafe_mode))
968                .unwrap();
969            let error = Sessions::open(shared.join("dashboard-session")).unwrap_err();
970            assert!(
971                error.to_string().contains("can be written by other users"),
972                "mode {unsafe_mode:o} is refused"
973            );
974        }
975        assert!(
976            !shared.join("dashboard-session").exists(),
977            "nothing is written there"
978        );
979    }
980
981    #[test]
982    fn the_log_shows_no_secret_or_code_even_when_a_new_link_cannot_be_written() {
983        let dir = crate::tests::TempDir::new();
984        let log = Arc::new(Mutex::new(Vec::new()));
985        let writer = log.clone();
986        let subscriber = tracing_subscriber::fmt()
987            .with_writer(move || Writer(writer.clone()))
988            .finish();
989        let mut secrets = Vec::new();
990        tracing::subscriber::with_default(subscriber, || {
991            let auth = dashboard(&dir.0, &[PROXY]);
992            let first = links(&dir.0.join("runnerd.web")).remove(0);
993            secrets.push(code(&first).to_owned());
994            // A directory in place of the login file's temporary: the new link cannot be written, and that is
995            // logged.
996            std::fs::create_dir(dir.0.join("runnerd.web.new")).unwrap();
997            std::fs::create_dir(dir.0.join("runnerd.web.new/x")).unwrap();
998            let secret = auth
999                .sessions
1000                .redeem(code(&first), None, Instant::now())
1001                .unwrap();
1002            secrets.push(secret);
1003            let pairing = auth.sessions.pair(PROXY, Instant::now()).unwrap();
1004            secrets.push(code(&pairing).to_owned());
1005            auth.sessions.sign_out_all().unwrap();
1006            let credentials = lock(&auth.sessions.credentials);
1007            secrets.push(credentials.secret.clone());
1008            secrets.extend(credentials.links.as_ref().unwrap().codes.iter().cloned());
1009        });
1010        let log = String::from_utf8(lock(&log).clone()).unwrap();
1011        assert!(log.contains("cannot write"), "the failure is logged: {log}");
1012        for secret in secrets {
1013            assert!(!log.contains(&secret), "the log shows a secret");
1014        }
1015    }
1016
1017    struct Writer(Arc<Mutex<Vec<u8>>>);
1018
1019    impl io::Write for Writer {
1020        fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
1021            lock(&self.0).extend_from_slice(bytes);
1022            Ok(bytes.len())
1023        }
1024
1025        fn flush(&mut self) -> io::Result<()> {
1026            Ok(())
1027        }
1028    }
1029
1030    #[test]
1031    fn debug_output_shows_no_secret_or_code() {
1032        let dir = crate::tests::TempDir::new();
1033        let auth = dashboard(&dir.0, &[PROXY]);
1034        let pairing = auth.sessions.pair(PROXY, Instant::now()).unwrap();
1035        let debug = format!("{auth:?} {:?}", auth.sessions);
1036        let mut secrets = vec![stored(&dir.0), code(&pairing).to_owned()];
1037        secrets.extend(
1038            links(&dir.0.join("runnerd.web"))
1039                .iter()
1040                .map(|link| code(link).to_owned()),
1041        );
1042        for secret in secrets {
1043            assert!(!debug.contains(&secret), "Debug shows a secret");
1044        }
1045    }
1046}