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> {
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
521fn 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
534pub(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 #[serde(default)]
563 pairing: String,
564}
565
566pub(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
592pub(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 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 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 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}