nordisk-pairing 0.5.28

The boxes of an estate find each other: a seed each box is installed with, a one-time join against the front, a tunnel (WireGuard by default) they meet inside, roles decided once, a pairing loop each box runs itself, and the signal it reports. Shared by gunnar and holger; the laptop's xtask only lays machines, writes seeds and reads the signal. Design: .nornir/pairing-design.md (2026-09-24).
Documentation
//! **A release medium's seed is the provider's metadata** (0.5.26, P4a; A28 L12): the ONE seed
//! reader every product box and front runs, moved here from Holger's `metadata_seed.rs` (holger
//! server/cli, compiled into its front by `#[path]`) and Gunnar's `gunnar-server` `pairing.rs`
//! (`seed_with`, `seed_of_user_data`, `metadata_user_data`), which were the same code.
//!
//! A medium the ONE box lays from (one per kind, uploaded once, cloned per machine) carries no
//! seed: its staged `pairing/seed.toml` holds only the marker line
//! [`super::SEED_FROM_METADATA`]. On its first boot the box reads the seed the shop minted for it
//! from UpCloud's metadata (`user_data`) and keeps it in place of the marker (0600, staged,
//! fsynced and renamed: edda `appliance_pid1::durable`), as a medium's own seed would be kept.
//!
//! Std, the marker's one parser ([`super::is_metadata_marker`]) and the durable writer only.

use std::path::Path;
use std::time::Duration;

use super::Seed;

/// UpCloud's metadata service, as every server with `metadata = yes` reaches it.
pub const METADATA_AT: &str = "169.254.169.254:80";
/// The server's `user_data` there.
pub const METADATA_USER_DATA: &str = "/metadata/v1/user_data";
/// The first boot asks this many times, this far apart (the network may come up a little after
/// the process): three minutes in all.
pub const METADATA_TRIES: u32 = 36;
pub const METADATA_EVERY: Duration = Duration::from_secs(5);

/// Pure: **the seed in a server's `user_data`** — the seed TOML itself, nothing else (an empty
/// `user_data` is a server laid without one). Refused by name.
pub fn of_user_data(body: &str) -> Result<Seed, String> {
    if body.trim().is_empty() {
        return Err("the server's user_data is empty: it was laid without a seed".into());
    }
    Seed::parse(body).map_err(|e| format!("the server's user_data is not a pairing seed: {e}"))
}

/// **The staged seed's text**: `None` when no seed file is staged; the file's own text when it is
/// a seed; for the metadata marker, the metadata's `user_data` once `check` takes it — then
/// written over the marker (0600) so every later boot reads it as a medium's own. Asks `fetch` up
/// to `tries` times, `every` apart; refused by name (`[pairing-seed-metadata]`) when the metadata
/// gives none.
pub fn staged_text(path: &Path, fetch: &dyn Fn() -> Result<String, String>, check: &dyn Fn(&str) -> Result<(), String>, tries: u32, every: Duration) -> Result<Option<String>, String> {
    let text = match std::fs::read_to_string(path) {
        Ok(t) => t,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(e) => return Err(format!("{}: {e}", path.display())),
    };
    if !super::is_metadata_marker(&text) {
        return Ok(Some(text));
    }
    let mut last = String::new();
    for n in 0..tries.max(1) {
        match fetch().and_then(|body| {
            if body.trim().is_empty() {
                return Err("the server's user_data is empty: it was laid without a seed".to_string());
            }
            check(&body).map_err(|e| format!("the server's user_data is not a pairing seed: {e}"))?;
            Ok(body)
        }) {
            Ok(body) => {
                appliance_pid1::durable::write_atomic(path, body.as_bytes(), Some(0o600)).map_err(|e| format!("{}: {e}", path.display()))?;
                return Ok(Some(body));
            }
            Err(why) => last = why,
        }
        if n + 1 < tries {
            std::thread::sleep(every);
        }
    }
    Err(format!("REFUSED [pairing-seed-metadata] {}: a release medium's seed is the metadata's, and the metadata gave none after {tries} tries: {last}", path.display()))
}

/// **The staged seed, parsed** ([`staged_text`] with [`Seed::parse`] as its check): `None` when
/// none is staged; a staged seed that does not parse is refused by name (`[pairing-seed]`) — a
/// declaration that lies is never quietly ignored.
pub fn staged(path: &Path, fetch: &dyn Fn() -> Result<String, String>, tries: u32, every: Duration) -> Result<Option<Seed>, String> {
    let check = |t: &str| Seed::parse(t).map(|_| ()).map_err(|e| e.to_string());
    let Some(text) = staged_text(path, fetch, &check, tries, every)? else { return Ok(None) };
    Seed::parse(&text).map(Some).map_err(|e| format!("REFUSED [pairing-seed] {}: {e}", path.display()))
}

/// **The box's seed as a release medium reads it**: [`staged`] over UpCloud's metadata
/// ([`user_data`]), [`METADATA_TRIES`] × [`METADATA_EVERY`].
pub fn read(path: &Path) -> Result<Option<Seed>, String> {
    staged(path, &user_data, METADATA_TRIES, METADATA_EVERY)
}

/// `GET http://169.254.169.254/metadata/v1/user_data` — the body of a 200.
pub fn user_data() -> Result<String, String> {
    let at: std::net::SocketAddr = METADATA_AT.parse().map_err(|e| format!("{METADATA_AT}: {e}"))?;
    match http_get(at, "169.254.169.254", METADATA_USER_DATA)? {
        (200, body) => Ok(body),
        (st, body) => Err(format!("the metadata answered HTTP {st}: {}", body.chars().take(200).collect::<String>())),
    }
}

/// One plain HTTP/1.0 GET (the metadata service speaks no TLS): the status and the body.
pub fn http_get(at: std::net::SocketAddr, host: &str, path: &str) -> Result<(u16, String), String> {
    use std::io::{Read as _, Write as _};
    let mut tcp = std::net::TcpStream::connect_timeout(&at, Duration::from_secs(3)).map_err(|e| format!("{at}: {e}"))?;
    tcp.set_read_timeout(Some(Duration::from_secs(10))).map_err(|e| e.to_string())?;
    tcp.write_all(format!("GET {path} HTTP/1.0\r\nHost: {host}\r\nAccept: text/plain\r\nConnection: close\r\n\r\n").as_bytes()).map_err(|e| format!("{at}: {e}"))?;
    let mut raw = Vec::new();
    tcp.read_to_end(&mut raw).map_err(|e| format!("{at}: {e}"))?;
    let text = String::from_utf8_lossy(&raw);
    let (head, body) = text.split_once("\r\n\r\n").ok_or_else(|| format!("{at} answered no HTTP"))?;
    let status = head.split_whitespace().nth(1).and_then(|s| s.parse().ok()).ok_or_else(|| format!("{at} answered no status: {}", head.lines().next().unwrap_or("")))?;
    Ok((status, body.to_string()))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::seed::{Kind, SEED_FROM_METADATA};
    use std::cell::Cell;

    fn dir(tag: &str) -> std::path::PathBuf {
        let d = std::env::temp_dir().join(format!("nordisk-pairing-metadata-seed-{tag}-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&d);
        std::fs::create_dir_all(&d).unwrap();
        d
    }

    fn ok(_: &str) -> Result<(), String> {
        Ok(())
    }

    #[test]
    fn no_staged_file_is_no_seed_and_a_real_seed_is_read_as_it_is() {
        let d = dir("plain");
        let p = d.join("seed.toml");
        let never = || -> Result<String, String> { panic!("a staged seed never asks the metadata") };
        assert_eq!(staged_text(&p, &never, &ok, 3, Duration::ZERO).unwrap(), None);
        std::fs::write(&p, "estate = \"acme\"\n").unwrap();
        assert_eq!(staged_text(&p, &never, &ok, 3, Duration::ZERO).unwrap().as_deref(), Some("estate = \"acme\"\n"));
    }

    #[test]
    fn a_release_medium_takes_its_seed_from_the_metadata_and_keeps_it() {
        let d = dir("meta");
        let p = d.join("seed.toml");
        std::fs::write(&p, format!("{SEED_FROM_METADATA}\n")).unwrap();
        let asked = Cell::new(0);
        let fetch = || {
            asked.set(asked.get() + 1);
            if asked.get() < 3 { Err("not yet".to_string()) } else { Ok("estate = \"acme\"\n".to_string()) }
        };
        assert_eq!(staged_text(&p, &fetch, &ok, 5, Duration::ZERO).unwrap().as_deref(), Some("estate = \"acme\"\n"));
        assert_eq!(asked.get(), 3, "asked until the metadata answered");
        assert_eq!(std::fs::read_to_string(&p).unwrap(), "estate = \"acme\"\n", "the seed is kept over the marker");
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt as _;
            assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o600);
        }
        let never = || -> Result<String, String> { panic!("a kept seed never asks again") };
        assert_eq!(staged_text(&p, &never, &ok, 1, Duration::ZERO).unwrap().as_deref(), Some("estate = \"acme\"\n"));
    }

    #[test]
    fn an_empty_or_bad_user_data_is_refused_by_name_and_the_marker_stays() {
        let d = dir("bad");
        let p = d.join("seed.toml");
        std::fs::write(&p, format!("{SEED_FROM_METADATA}\n")).unwrap();
        let empty = || Ok(String::new());
        let e = staged_text(&p, &empty, &ok, 2, Duration::ZERO).unwrap_err();
        assert!(e.contains("[pairing-seed-metadata]") && e.contains("empty"), "{e}");
        let junk = || Ok("not a seed".to_string());
        let no = |_: &str| Err("bad toml".to_string());
        let e = staged_text(&p, &junk, &no, 2, Duration::ZERO).unwrap_err();
        assert!(e.contains("not a pairing seed") && e.contains("bad toml"), "{e}");
        assert!(crate::seed::is_metadata_marker(&std::fs::read_to_string(&p).unwrap()), "nothing is written over the marker");
    }

    /// ★ The typed reader (Gunnar's `seed_with`): the metadata's seed, parsed, kept over the
    /// marker; a staged seed that does not parse is refused by name, never taken as none.
    #[test]
    fn the_typed_reader_parses_the_metadata_seed_and_refuses_a_staged_seed_that_lies() {
        let d = dir("typed");
        let p = d.join("seed.toml");
        let never = || -> Result<String, String> { panic!("never asked") };
        assert_eq!(staged(&p, &never, 1, Duration::ZERO).unwrap(), None, "none staged: none");
        let (seed, _) = crate::seed::tests::a_seed(Kind::Holger);
        let text = seed.render().unwrap();
        std::fs::write(&p, format!("{SEED_FROM_METADATA}\n")).unwrap();
        let junk_then_seed = Cell::new(0);
        let fetch = || {
            junk_then_seed.set(junk_then_seed.get() + 1);
            Ok(if junk_then_seed.get() == 1 { "estate = \"acme\"\n".to_string() } else { text.clone() })
        };
        assert_eq!(staged(&p, &fetch, 3, Duration::ZERO).unwrap(), Some(seed.clone()), "a user_data that is no seed is asked again");
        assert_eq!(junk_then_seed.get(), 2);
        assert_eq!(Seed::parse(&std::fs::read_to_string(&p).unwrap()).unwrap(), seed, "kept over the marker");
        std::fs::write(&p, "estate = \"acme\"\n").unwrap();
        let e = staged(&p, &never, 1, Duration::ZERO).unwrap_err();
        assert!(e.starts_with("REFUSED [pairing-seed]"), "{e}");
        assert!(of_user_data(" \n").unwrap_err().contains("empty"));
        assert_eq!(of_user_data(&text).unwrap(), seed);
    }

    /// ★ The metadata GET over a real socket: a 200's body, any other status by name.
    #[test]
    fn the_metadata_get_reads_a_200s_body_and_names_any_other_status() {
        use std::io::{Read as _, Write as _};
        let serve = |answer: &'static str| {
            let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
            let at = l.local_addr().unwrap();
            let t = std::thread::spawn(move || {
                let (mut s, _) = l.accept().unwrap();
                let mut req = [0u8; 512];
                let n = s.read(&mut req).unwrap();
                s.write_all(answer.as_bytes()).unwrap();
                String::from_utf8_lossy(&req[..n]).to_string()
            });
            (at, t)
        };
        let (at, t) = serve("HTTP/1.0 200 OK\r\nContent-Type: text/plain\r\n\r\nestate = \"acme\"\n");
        assert_eq!(http_get(at, "169.254.169.254", METADATA_USER_DATA).unwrap(), (200, "estate = \"acme\"\n".to_string()));
        let req = t.join().unwrap();
        assert!(req.starts_with("GET /metadata/v1/user_data HTTP/1.0\r\n") && req.contains("Host: 169.254.169.254\r\n"), "{req}");
        let (at, t) = serve("HTTP/1.0 404 Not Found\r\n\r\nno user_data");
        assert_eq!(http_get(at, "h", "/x").unwrap().0, 404);
        t.join().unwrap();
        let (at, t) = serve("garbage");
        assert!(http_get(at, "h", "/x").unwrap_err().contains("answered no HTTP"));
        t.join().unwrap();
        assert_eq!((METADATA_AT, METADATA_TRIES, METADATA_EVERY), ("169.254.169.254:80", 36, Duration::from_secs(5)), "three minutes, as both products asked");
    }
}