Compare commits

..
1 Commits
Author SHA1 Message Date
irisandClaude Fable 5.1 d35c880753 enroll: spool a token minted outside the server, adopted on first use
A second process cannot append a token to the config: the server holds
its config in memory and writes it back whole, so the append loses the
race with the next save, silently. spool_pending writes one file per
token, named by the hash, into a private directory; take_pending lets
the running server move it into its own config the first time the phone
presents it, and sweeps anything older than an hour unused. This is what
lets a tool -- Dev Updater -- ask for an enrolment link without being at
the terminal the QR is printed on.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 05:41:55 -04:00
2 changed files with 124 additions and 4 deletions

No files matched your search

+1 -4
View File
@@ -155,10 +155,7 @@ mod tests {
}
fn addresses() -> Vec<IpAddr> {
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]
+123
View File
@@ -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<Option<String>> {
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();
}
}