1use 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
30const SESSION_LIFETIME: Duration = Duration::from_hours(30 * 24);
33
34pub const PAIRING_LIFETIME: Duration = Duration::from_mins(5);
36
37pub const PAIRING_MAX: usize = 8;
39
40pub fn login_file(socket: &Path) -> PathBuf {
43 socket.with_extension("web")
44}
45
46pub fn session_file(database: &Path) -> PathBuf {
48 database.with_file_name("dashboard-session")
49}
50
51fn 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
63fn 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
72fn 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
94fn 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
113fn 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
154fn 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
163struct Pairing {
165 code: String,
166 origin: String,
167 expires: Instant,
168}
169
170struct 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 fn renew(&mut self) {
193 match self.bases.iter().map(|_| random_token()).collect() {
194 Ok(codes) => self.codes = codes,
195 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 fn proxies(&self) -> &[String] {
211 self.bases.get(1..).unwrap_or_default()
212 }
213}
214
215struct Credentials {
217 secret: String,
218 links: Option<Links>,
220 pairings: Vec<Pairing>,
221}
222
223#[derive(Debug)]
225pub enum PairError {
226 DashboardOff,
228 NoOrigins,
230 NotListed(String),
232 SignedOut,
234 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
255pub 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 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 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 pub fn pair(&self, origin: &str, now: Instant) -> Result<String, PairError> {
314 Self::mint(&mut lock(&self.credentials), origin, now)
315 }
316
317 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 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 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 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 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 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 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#[derive(Clone)]
431pub(crate) struct SignedIn(String);
432
433#[derive(Debug)]
435pub(crate) struct Auth {
436 sessions: Arc<Sessions>,
437 cookie: String,
438 origins: Vec<String>,
440 proxies: Vec<String>,
441}
442
443impl Auth {
444 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 pub(crate) fn proxies(&self) -> &[String] {
478 &self.proxies
479 }
480
481 pub(crate) fn remove_login_file(&self) {
483 self.sessions.withdraw();
484 }
485
486 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 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
523fn 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
536pub(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 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 #[serde(default)]
569 pairing: String,
570}
571
572pub(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
598pub(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 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 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 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}