1use std::io::Write;
12use std::path::{Path, PathBuf};
13
14pub const LOCAL_BROKER_TOKEN_FILE: &str = "auth-broker.token";
16
17pub fn local_broker_token_path() -> PathBuf {
19 crate::config::base_dir().join(LOCAL_BROKER_TOKEN_FILE)
20}
21
22pub fn is_loopback_endpoint(endpoint: &str) -> bool {
25 let Ok(url) = url::Url::parse(endpoint.trim()) else {
26 return false;
27 };
28 if !matches!(url.scheme(), "http" | "https") {
29 return false;
30 }
31 match url.host() {
32 Some(url::Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"),
33 Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
34 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
35 None => false,
36 }
37}
38
39pub fn read_local_broker_token() -> Option<String> {
41 read_token_at(&local_broker_token_path()).ok().flatten()
42}
43
44const MAX_CLIENT_TOKEN_FILE: u64 = 4096;
46
47fn read_token_at(path: &Path) -> std::io::Result<Option<String>> {
51 use std::io::Read;
52 let meta = match std::fs::symlink_metadata(path) {
53 Ok(m) => m,
54 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
55 Err(e) => return Err(e),
56 };
57 if meta.file_type().is_symlink() {
58 return Err(invalid(path, "is a symlink; refusing to follow it"));
59 }
60 if !meta.is_file() {
61 return Err(invalid(path, "is not a regular file"));
62 }
63 if meta.len() > MAX_CLIENT_TOKEN_FILE {
64 return Err(invalid(path, "is larger than 4 KiB; not a machine token"));
65 }
66 let mut opts = std::fs::OpenOptions::new();
67 opts.read(true);
68 #[cfg(unix)]
69 {
70 use std::os::unix::fs::OpenOptionsExt;
71 opts.custom_flags(libc::O_NOFOLLOW);
72 }
73 let mut s = String::new();
74 opts.open(path)?
75 .take(MAX_CLIENT_TOKEN_FILE)
76 .read_to_string(&mut s)?;
77 Ok(Some(s.trim().to_string()).filter(|t| !t.is_empty()))
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum BrokerTokenOrigin {
83 Loaded,
85 Created,
87 Rotated,
90}
91
92fn invalid(path: &Path, why: &str) -> std::io::Error {
93 std::io::Error::new(
94 std::io::ErrorKind::InvalidData,
95 format!("broker token file {}: {why}", path.display()),
96 )
97}
98
99pub fn load_or_create_broker_token(path: &Path) -> std::io::Result<(String, BrokerTokenOrigin)> {
111 if let Some(parent) = path.parent() {
112 create_private_dir(parent)?;
113 }
114 sweep_stale_temp_files(path, STALE_TEMP_AGE);
115 for _ in 0..3 {
116 match std::fs::symlink_metadata(path) {
117 Ok(meta) => {
118 if meta.file_type().is_symlink() {
119 return Err(invalid(path, "is a symlink; refusing to follow it"));
120 }
121 if !meta.is_file() {
122 return Err(invalid(path, "is not a regular file"));
123 }
124 check_owner(path, &meta)?;
125 if loose_mode(&meta) {
126 let token = generate_token();
127 write_atomic_replace(path, &token)?;
128 return Ok((token, BrokerTokenOrigin::Rotated));
129 }
130 let token = read_no_follow(path)?;
131 if token.is_empty() {
132 return Err(invalid(path, "is empty; delete it to generate a new one"));
133 }
134 return Ok((token, BrokerTokenOrigin::Loaded));
135 }
136 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
137 let token = generate_token();
138 match write_new_exclusive(path, &token) {
139 Ok(()) => return Ok((token, BrokerTokenOrigin::Created)),
140 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
142 Err(e) => return Err(e),
143 }
144 }
145 Err(e) => return Err(e),
146 }
147 }
148 Err(std::io::Error::other(format!(
149 "could not create or read broker token file {}",
150 path.display()
151 )))
152}
153
154#[cfg(unix)]
155fn check_owner(path: &Path, meta: &std::fs::Metadata) -> std::io::Result<()> {
156 use std::os::unix::fs::MetadataExt;
157 if meta.uid() != unsafe { libc::geteuid() } {
159 return Err(invalid(path, "is owned by another user; refusing to use it"));
160 }
161 Ok(())
162}
163#[cfg(not(unix))]
164fn check_owner(_: &Path, _: &std::fs::Metadata) -> std::io::Result<()> {
165 Ok(())
166}
167
168#[cfg(unix)]
169fn loose_mode(meta: &std::fs::Metadata) -> bool {
170 use std::os::unix::fs::PermissionsExt;
171 meta.permissions().mode() & 0o077 != 0
172}
173#[cfg(not(unix))]
174fn loose_mode(_: &std::fs::Metadata) -> bool {
175 false
176}
177
178fn read_no_follow(path: &Path) -> std::io::Result<String> {
179 use std::io::Read;
180 let mut opts = std::fs::OpenOptions::new();
181 opts.read(true);
182 #[cfg(unix)]
183 {
184 use std::os::unix::fs::OpenOptionsExt;
185 opts.custom_flags(libc::O_NOFOLLOW);
186 }
187 let mut s = String::new();
188 opts.open(path)?.read_to_string(&mut s)?;
189 Ok(s.trim().to_string())
190}
191
192fn write_temp(path: &Path, token: &str) -> std::io::Result<PathBuf> {
194 let name = path
195 .file_name()
196 .map(|n| n.to_string_lossy().into_owned())
197 .unwrap_or_else(|| LOCAL_BROKER_TOKEN_FILE.to_string());
198 let tmp = path.with_file_name(format!(
199 ".{name}.tmp.{}.{:08x}",
200 std::process::id(),
201 rand::random::<u32>()
202 ));
203 let mut opts = std::fs::OpenOptions::new();
204 opts.write(true).create_new(true);
205 #[cfg(unix)]
206 {
207 use std::os::unix::fs::OpenOptionsExt;
208 opts.mode(0o600);
209 }
210 let mut f = opts.open(&tmp)?;
211 let written = f
212 .write_all(token.as_bytes())
213 .and_then(|_| f.write_all(b"\n"))
214 .and_then(|_| f.sync_all());
215 if let Err(e) = written {
216 let _ = std::fs::remove_file(&tmp);
217 return Err(e);
218 }
219 Ok(tmp)
220}
221
222const STALE_TEMP_AGE: std::time::Duration = std::time::Duration::from_secs(60);
225
226fn sweep_stale_temp_files(path: &Path, age: std::time::Duration) {
229 let (Some(dir), Some(name)) = (path.parent(), path.file_name()) else {
230 return;
231 };
232 let prefix = format!(".{}.tmp.", name.to_string_lossy());
233 let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir };
234 let Ok(entries) = std::fs::read_dir(dir) else {
235 return;
236 };
237 for entry in entries.flatten() {
238 if !entry.file_name().to_string_lossy().starts_with(&prefix) {
239 continue;
240 }
241 let Ok(meta) = std::fs::symlink_metadata(entry.path()) else {
242 continue;
243 };
244 let old = meta
245 .modified()
246 .ok()
247 .and_then(|m| m.elapsed().ok())
248 .is_some_and(|e| e >= age);
249 if meta.is_file() && old {
250 let _ = std::fs::remove_file(entry.path());
251 }
252 }
253}
254
255fn write_new_exclusive(path: &Path, token: &str) -> std::io::Result<()> {
261 let tmp = write_temp(path, token)?;
262 let linked = std::fs::hard_link(&tmp, path);
263 let _ = std::fs::remove_file(&tmp);
264 match linked {
265 Err(e) if e.kind() != std::io::ErrorKind::AlreadyExists => {
266 let mut opts = std::fs::OpenOptions::new();
267 opts.write(true).create_new(true);
268 #[cfg(unix)]
269 {
270 use std::os::unix::fs::OpenOptionsExt;
271 opts.mode(0o600);
272 }
273 let mut f = opts.open(path)?;
274 f.write_all(token.as_bytes())?;
275 f.write_all(b"\n")?;
276 f.sync_all()
277 }
278 other => other,
279 }
280}
281
282fn write_atomic_replace(path: &Path, token: &str) -> std::io::Result<()> {
284 let tmp = write_temp(path, token)?;
285 std::fs::rename(&tmp, path).inspect_err(|_| {
286 let _ = std::fs::remove_file(&tmp);
287 })
288}
289
290fn generate_token() -> String {
291 let bytes: [u8; 32] = rand::random();
292 bytes.iter().map(|b| format!("{b:02x}")).collect()
293}
294
295fn create_private_dir(dir: &Path) -> std::io::Result<()> {
296 if dir.as_os_str().is_empty() || dir.exists() {
297 return Ok(());
298 }
299 let mut builder = std::fs::DirBuilder::new();
300 builder.recursive(true);
301 #[cfg(unix)]
302 {
303 use std::os::unix::fs::DirBuilderExt;
304 builder.mode(0o700);
305 }
306 builder.create(dir)
307}
308
309#[derive(Clone)]
323pub struct MachineToken {
324 current: std::sync::Arc<std::sync::RwLock<String>>,
325 reload_from: Option<PathBuf>,
326}
327
328impl MachineToken {
329 pub fn for_endpoint(endpoint: &str, token: impl Into<String>) -> Self {
331 let token = token.into().trim().to_string();
332 if !token.is_empty() || !is_loopback_endpoint(endpoint) {
333 return Self::fixed(token);
334 }
335 let path = local_broker_token_path();
336 let initial = read_token_at(&path).ok().flatten().unwrap_or_default();
337 Self {
338 current: std::sync::Arc::new(std::sync::RwLock::new(initial)),
339 reload_from: Some(path),
340 }
341 }
342
343 pub fn is_from_local_file(&self) -> bool {
345 self.reload_from.is_some()
346 }
347
348 pub fn fixed(token: impl Into<String>) -> Self {
350 Self {
351 current: std::sync::Arc::new(std::sync::RwLock::new(token.into())),
352 reload_from: None,
353 }
354 }
355
356 pub fn get(&self) -> String {
357 self.current
358 .read()
359 .unwrap_or_else(|e| e.into_inner())
360 .clone()
361 }
362
363 pub fn reload_after_unauthorized(&self) -> bool {
366 let Some(path) = &self.reload_from else {
367 return false;
368 };
369 let Ok(Some(fresh)) = read_token_at(path) else {
370 return false;
371 };
372 let mut cur = self.current.write().unwrap_or_else(|e| e.into_inner());
373 if *cur == fresh {
374 return false;
375 }
376 *cur = fresh;
377 true
378 }
379
380 pub async fn send(
383 &self,
384 make: impl Fn(&str) -> reqwest::RequestBuilder,
385 ) -> reqwest::Result<reqwest::Response> {
386 let resp = make(&self.get()).send().await?;
387 if resp.status() == reqwest::StatusCode::UNAUTHORIZED && self.reload_after_unauthorized() {
388 drop(resp);
389 return make(&self.get()).send().await;
390 }
391 Ok(resp)
392 }
393}
394
395pub fn unauthorized_hint() -> String {
397 format!(
398 "broker rejected machine auth (401). Set auth.machine_token or \
399 SYNAPS_MACHINE_TOKEN to the broker's token; a loopback broker started \
400 without one writes it to {} on the broker host (same user and base dir \
401 are picked up automatically). If the broker regenerated it, restart or \
402 reload this client.",
403 local_broker_token_path().display()
404 )
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 #[test]
412 fn loopback_endpoints_are_recognised() {
413 for e in [
414 "http://127.0.0.1:8181",
415 "http://localhost:8181/",
416 "https://LOCALHOST:9",
417 "http://[::1]:8181",
418 "http://127.1.2.3:1",
419 ] {
420 assert!(is_loopback_endpoint(e), "{e}");
421 }
422 for e in [
423 "https://jade.jade:8181",
424 "http://10.0.0.1:8181",
425 "http://localhost.evil.com:8181",
426 "ftp://127.0.0.1",
427 "not a url",
428 "",
429 ] {
430 assert!(!is_loopback_endpoint(e), "{e}");
431 }
432 }
433
434 #[test]
435 fn creates_a_private_random_token_once_and_reuses_it() {
436 let dir = tempfile::tempdir().unwrap();
437 let path = dir.path().join("nested").join(LOCAL_BROKER_TOKEN_FILE);
438 let (token, origin) = load_or_create_broker_token(&path).unwrap();
439 assert_eq!(origin, BrokerTokenOrigin::Created);
440 assert_eq!(token.len(), 64, "256-bit hex");
441 assert!(token.chars().all(|c| c.is_ascii_hexdigit()));
442 #[cfg(unix)]
443 {
444 use std::os::unix::fs::PermissionsExt;
445 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
446 assert_eq!(mode & 0o777, 0o600, "token file must be 0600");
447 let dir_mode = std::fs::metadata(path.parent().unwrap())
448 .unwrap()
449 .permissions()
450 .mode();
451 assert_eq!(dir_mode & 0o777, 0o700, "created dir must be 0700");
452 }
453 let (again, origin) = load_or_create_broker_token(&path).unwrap();
454 assert_eq!(origin, BrokerTokenOrigin::Loaded);
455 assert_eq!(again, token, "restart keeps the same token");
456 let leftovers: Vec<_> = std::fs::read_dir(path.parent().unwrap())
457 .unwrap()
458 .filter_map(|e| e.ok())
459 .filter(|e| e.file_name().to_string_lossy().contains(".tmp."))
460 .collect();
461 assert!(leftovers.is_empty(), "no temp files left behind");
462 let (other, _) = load_or_create_broker_token(&dir.path().join("other.token")).unwrap();
463 assert_ne!(other, token, "tokens are random");
464 }
465
466 #[cfg(unix)]
469 #[test]
470 fn loose_token_file_is_rotated_not_reused() {
471 use std::os::unix::fs::PermissionsExt;
472 let dir = tempfile::tempdir().unwrap();
473 let path = dir.path().join(LOCAL_BROKER_TOKEN_FILE);
474 std::fs::write(&path, "leaked-token\n").unwrap();
475 for mode in [0o644, 0o640, 0o604] {
476 std::fs::write(&path, "leaked-token\n").unwrap();
477 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap();
478 let (token, origin) = load_or_create_broker_token(&path).unwrap();
479 assert_eq!(origin, BrokerTokenOrigin::Rotated, "{mode:o}");
480 assert_ne!(token, "leaked-token", "{mode:o}");
481 assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), token);
482 let now = std::fs::metadata(&path).unwrap().permissions().mode();
483 assert_eq!(now & 0o777, 0o600, "{mode:o}");
484 }
485 std::fs::write(&path, " \n").unwrap();
486 assert!(load_or_create_broker_token(&path).is_err(), "empty file refused");
487 }
488
489 #[cfg(unix)]
490 #[test]
491 fn symlinked_token_file_is_refused() {
492 let dir = tempfile::tempdir().unwrap();
493 let target = dir.path().join("attacker-controlled");
494 std::fs::write(&target, "attacker-token\n").unwrap();
495 let path = dir.path().join(LOCAL_BROKER_TOKEN_FILE);
496 std::os::unix::fs::symlink(&target, &path).unwrap();
497 let err = load_or_create_broker_token(&path).unwrap_err();
498 assert!(err.to_string().contains("symlink"), "{err}");
499 std::fs::remove_file(&target).unwrap();
501 assert!(load_or_create_broker_token(&path).is_err());
502 assert!(!target.exists(), "never written through the link");
503 }
504
505 #[test]
506 fn non_regular_token_path_is_refused() {
507 let dir = tempfile::tempdir().unwrap();
508 let path = dir.path().join(LOCAL_BROKER_TOKEN_FILE);
509 std::fs::create_dir(&path).unwrap();
510 assert!(load_or_create_broker_token(&path).is_err());
511 }
512
513 #[cfg(unix)]
516 #[test]
517 fn foreign_owned_token_file_is_refused_when_testable() {
518 if unsafe { libc::geteuid() } != 0 {
520 return;
521 }
522 use std::os::unix::fs::PermissionsExt;
523 let dir = tempfile::tempdir().unwrap();
524 let path = dir.path().join(LOCAL_BROKER_TOKEN_FILE);
525 std::fs::write(&path, "t\n").unwrap();
526 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
527 std::os::unix::fs::chown(&path, Some(65534), None).unwrap();
528 let err = load_or_create_broker_token(&path).unwrap_err();
529 assert!(err.to_string().contains("another user"), "{err}");
530 }
531
532 #[test]
533 fn stale_temp_files_are_swept_fresh_ones_kept() {
534 let dir = tempfile::tempdir().unwrap();
535 let path = dir.path().join(LOCAL_BROKER_TOKEN_FILE);
536 let stale = dir.path().join(".auth-broker.token.tmp.1.deadbeef");
537 let fresh = dir.path().join(".auth-broker.token.tmp.2.cafebabe");
538 let other = dir.path().join("unrelated.tmp.1");
539 for f in [&stale, &fresh, &other] {
540 std::fs::write(f, "token\n").unwrap();
541 }
542 let old = std::time::SystemTime::now() - std::time::Duration::from_secs(3600);
543 for f in [&stale, &other] {
544 std::fs::File::options()
545 .write(true)
546 .open(f)
547 .unwrap()
548 .set_modified(old)
549 .unwrap();
550 }
551 let (_, origin) = load_or_create_broker_token(&path).unwrap();
552 assert_eq!(origin, BrokerTokenOrigin::Created);
553 assert!(!stale.exists(), "crashed writer's temp file is removed");
554 assert!(fresh.exists(), "a live writer's temp file is left alone");
555 assert!(other.exists(), "unrelated files untouched");
556 }
557
558 #[cfg(unix)]
559 #[test]
560 fn client_read_refuses_symlinks_and_oversized_files() {
561 let dir = tempfile::tempdir().unwrap();
562 let target = dir.path().join("elsewhere");
563 std::fs::write(&target, "secret-elsewhere\n").unwrap();
564 let link = dir.path().join(LOCAL_BROKER_TOKEN_FILE);
565 std::os::unix::fs::symlink(&target, &link).unwrap();
566 assert!(read_token_at(&link).is_err(), "symlink never followed");
567 let big = dir.path().join("big.token");
568 std::fs::write(&big, "a".repeat(5000)).unwrap();
569 assert!(read_token_at(&big).is_err(), "over 4 KiB refused");
570 let ok = dir.path().join("ok.token");
571 std::fs::write(&ok, "tok\n").unwrap();
572 assert_eq!(read_token_at(&ok).unwrap().as_deref(), Some("tok"));
573 assert_eq!(read_token_at(&dir.path().join("missing")).unwrap(), None);
574 }
575
576 #[test]
578 fn machine_token_reloads_only_for_loopback_and_only_when_changed() {
579 let dir = tempfile::tempdir().unwrap();
580 let path = dir.path().join(LOCAL_BROKER_TOKEN_FILE);
581 let tok = MachineToken {
582 current: std::sync::Arc::new(std::sync::RwLock::new(String::new())),
583 reload_from: Some(path.clone()),
584 };
585 assert!(!tok.reload_after_unauthorized(), "no file yet");
586 std::fs::write(&path, "fresh\n").unwrap();
587 let shared = tok.clone();
588 assert!(tok.reload_after_unauthorized());
589 assert_eq!(shared.get(), "fresh", "clones share the reloaded token");
590 assert!(!tok.reload_after_unauthorized(), "unchanged → no retry");
591 let remote = MachineToken::for_endpoint("https://jade.jade:8181", "");
592 assert!(!remote.reload_after_unauthorized());
593 let explicit = MachineToken::for_endpoint("http://127.0.0.1:8181", "x");
595 assert!(!explicit.is_from_local_file());
596 assert!(!explicit.reload_after_unauthorized());
597 assert_eq!(explicit.get(), "x");
598 assert!(MachineToken::for_endpoint("http://127.0.0.1:8181", "").is_from_local_file());
599 }
600}