diff --git a/server/src/certs.rs b/server/src/certs.rs index 719f27e..1151eba 100644 --- a/server/src/certs.rs +++ b/server/src/certs.rs @@ -155,10 +155,7 @@ mod tests { } fn addresses() -> Vec { - vec![ - "192.168.1.5".parse().unwrap(), - "127.0.0.1".parse().unwrap(), - ] + vec!["192.168.1.5".parse().unwrap(), "127.0.0.1".parse().unwrap()] } #[test] diff --git a/server/src/enroll.rs b/server/src/enroll.rs index d6f94cd..028aa0c 100644 --- a/server/src/enroll.rs +++ b/server/src/enroll.rs @@ -24,7 +24,10 @@ //! middleware, which stays in each project because it is generic over //! that project's state. +use std::fs; use std::net::IpAddr; +use std::path::Path; +use std::time::Duration; use anyhow::{Context, Result}; use base64::Engine; @@ -97,6 +100,74 @@ pub fn print_enrollment(scheme: &str, host: IpAddr, port: u16, token: &str) -> R Ok(()) } +/// How long a spooled enrolment stays valid unused. The link is meant to be +/// opened straight away, by the tool that asked for it; one that was never +/// opened should not stay a valid credential on disk. +pub const PENDING_TTL: Duration = Duration::from_secs(60 * 60); + +/// Records a token minted by another process for the running server to +/// adopt on first use -- see [`take_pending`]. +/// +/// Why a spool rather than writing the config: the server holds its config +/// in memory and writes it back whole, so a second process appending a +/// token to the file loses the race with the next save, silently. Here the +/// other process writes only into `dir` (created private to the user), one +/// file per token, named by the hash and holding the device name; the +/// server owns the config as before and moves the entry across itself. +/// Only the hash touches disk, as with every stored token. +pub fn spool_pending(dir: &Path, name: &str, token: &str) -> Result<()> { + fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(dir, fs::Permissions::from_mode(0o700)) + .with_context(|| format!("restrict {}", dir.display()))?; + } + let path = dir.join(token_hash_hex(token)); + fs::write(&path, name).with_context(|| format!("write {}", path.display()))?; + Ok(()) +} + +/// Adopts a spooled token if `presented` is one: returns the device name +/// it was spooled under and removes the entry, so a spooled token is +/// consumed exactly once and belongs to the config from then on. Anything +/// older than [`PENDING_TTL`] is removed rather than honoured. +/// +/// A missing directory is the common case -- nothing has ever been +/// spooled -- and answers `None` like an empty one. +pub fn take_pending(dir: &Path, presented: &str) -> Result> { + let entries = match fs::read_dir(dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error).with_context(|| format!("read {}", dir.display())), + }; + let wanted = token_hash_hex(presented); + let mut found = None; + for entry in entries { + let entry = entry.with_context(|| format!("read {}", dir.display()))?; + let path = entry.path(); + let fresh = entry + .metadata() + .and_then(|meta| meta.modified()) + .ok() + .and_then(|modified| modified.elapsed().ok()) + .is_some_and(|age| age < PENDING_TTL); + if !fresh { + let _ = fs::remove_file(&path); + continue; + } + let name = entry.file_name(); + let name = name.to_string_lossy(); + if bool::from(name.as_bytes().ct_eq(wanted.as_bytes())) { + found = Some(path); + } + } + let Some(path) = found else { return Ok(None) }; + let device = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?; + Ok(Some(device)) +} + #[cfg(test)] mod tests { use super::*; @@ -143,4 +214,56 @@ mod tests { let other = enrollment_uri("aiapp", "10.66.0.1".parse().unwrap(), 8443, "tok"); assert!(other.starts_with("aiapp://enroll?")); } + + fn scratch_dir() -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("wg-app-link-enroll-{}", generate_token())); + fs::create_dir_all(&dir).unwrap(); + dir + } + + /// A spooled token is adopted once, under the name it was spooled with, + /// and by nothing but that token. + #[test] + fn a_spooled_token_is_taken_exactly_once() { + let dir = scratch_dir(); + let token = generate_token(); + spool_pending(&dir, "tablet", &token).unwrap(); + assert_eq!(take_pending(&dir, "wrong").unwrap(), None); + assert_eq!( + take_pending(&dir, &token).unwrap().as_deref(), + Some("tablet") + ); + assert_eq!(take_pending(&dir, &token).unwrap(), None, "consumed"); + assert!( + fs::read_dir(&dir).unwrap().next().is_none(), + "nothing left behind" + ); + fs::remove_dir_all(dir).unwrap(); + } + + /// Nothing spooled -- not even the directory -- is an ordinary miss. + #[test] + fn no_spool_is_a_miss() { + let dir = scratch_dir().join("never-made"); + assert_eq!(take_pending(&dir, "anything").unwrap(), None); + } + + /// An entry past its age is swept rather than honoured. + #[test] + fn a_stale_entry_is_swept_not_honoured() { + let dir = scratch_dir(); + let token = generate_token(); + spool_pending(&dir, "old", &token).unwrap(); + let path = dir.join(token_hash_hex(&token)); + let past = std::time::SystemTime::now() - PENDING_TTL - Duration::from_secs(1); + fs::File::options() + .write(true) + .open(&path) + .unwrap() + .set_modified(past) + .unwrap(); + assert_eq!(take_pending(&dir, &token).unwrap(), None); + assert!(!path.exists(), "swept"); + fs::remove_dir_all(dir).unwrap(); + } }