Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

server/downstream/src/front.rs

//! **The front's report to main** (lane HOL-FLOW-FRONT, Henrik's Q1 of HOL-FLOW-SMART).
//!
//! Main draws the estate's flow, and the front is one of its machines; but main does not know
//! the front's doors, the names it serves or where each name is routed. So the front TELLS it,
//! the way a downstream tells its upstream its RON summary: a small report, with a proof of
//! its own, that an older main ignores safely (its HTTP door answers `404` for a path it does
//! not know, and the front says so and carries on).
//!
//! ```text
//! front ── POST /-/front/report { report, proof } ──► main: check() → <data>/holger-front/report.ron
//!                                                           flow: the front unfolds to its doors and routes
//! ```
//!
//! | piece | what it is |
//! |---|---|
//! | [`FrontReport`] | the front's doors ([`FrontDoor`]), the names it serves and where each goes ([`FrontRoute`]), its console's address; names and ports only, never a key, a token or a certificate |
//! | [`text`] | the bytes the proof is over: one line per field, the same order on both ends |
//! | [`key_of`] | the proof's key, derived from the x25519 secret the front's console key and the box's console key share (the pair the console's bearer comes from), under its OWN context ([`KEY_CONTEXT`]): the key proves this front to this box, and is no bearer |
//! | [`proof`], [`check`] | a keyed blake3 over [`text`]; `check` also refuses a report older or newer than [`FRESH_SECS`], and one too large to be a front's |
//! | [`console_url`] | a console address a page may link to: `https://`, or `http://` on a loopback host only, nothing that is not plainly a URL |
//!
//! Pure: no I/O, no network.

use serde::{Deserialize, Serialize};

/// Main's HTTP door that takes the report (`POST`, JSON [`Sent`]).
pub const PATH: &str = "/-/front/report";
/// The derivation's context (blake3 `derive_key`): never the console bearer's.
pub const KEY_CONTEXT: &str =
    "holger 2026-11-01 the front's report to a box, from the front's and the box's x25519 console keys";
/// The first line of [`text`].
pub const SCHEMA: &str = "holger.front-report/v1";
/// A report signed further than this from the box's clock is refused (seconds).
pub const FRESH_SECS: i64 = 600;
/// How many doors and routes a front reports at most, and how long one field may be.
pub const MAX_DOORS: usize = 32;
pub const MAX_ROUTES: usize = 128;
pub const MAX_FIELD: usize = 256;

/// Where a route goes: main's repository doors (HTTP and the product's gRPC).
pub const TO_MAIN: &str = "main";
/// Where a route goes: the console (holger-ui), whose pass-through reaches main's two doors.
pub const TO_CONSOLE: &str = "console";
/// Where a route goes: the ONE monetize (its control host or a vendor's callbacks).
pub const TO_MONETIZE: &str = "monetize";

/// **What the front serves, as it reports it to main.**
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct FrontReport {
    /// The front's pairing name (`front`, `vm1`, …).
    pub front: String,
    /// When the front made it (Unix seconds, the front's clock: the estate's).
    pub at_unix: i64,
    /// The front's own doors (`https` tcp/443, `http` tcp/80, the downstreams' udp/51830).
    pub doors: Vec<FrontDoor>,
    /// Every name the front serves and where it is routed.
    pub routes: Vec<FrontRoute>,
    /// The console's address (`https://www.<site>`); empty when the front serves none.
    pub console_url: String,
}

/// **One of the front's doors.**
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct FrontDoor {
    /// `https`, `http`, `downstreams`.
    pub name: String,
    /// `tcp/443`, `udp/51830`.
    pub listen: String,
    /// One line: what it is.
    pub what: String,
}

/// **One name the front serves, and where it goes.**
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct FrontRoute {
    /// The served name (`repo.example.com`), or the door's own address for a door no name
    /// reaches (the downstreams' sync door).
    pub host: String,
    /// The door it comes in by ([`FrontDoor::name`]).
    pub door: String,
    /// [`TO_MAIN`], [`TO_CONSOLE`] or [`TO_MONETIZE`] (a later front may name more; main draws
    /// a route to a word it does not know as that word, inside the front).
    pub to: String,
    /// One line: what reaches it there (`main http :8443, gRPC :50051 (holger.*)`).
    pub what: String,
}

/// **What the front sends** (the JSON body of [`PATH`]).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Sent {
    pub report: FrontReport,
    /// [`proof`] of `report`, hex.
    pub proof: String,
}

fn one_line(v: &str) -> String {
    v.replace(['\n', '\t', '\r'], " ")
}

/// Pure: **the bytes the proof is over**: [`SCHEMA`], the front and the time, then one
/// `door` line per door, one `route` line per route and the `console` line, in order.
pub fn text(r: &FrontReport) -> String {
    let mut t = format!(
        "{SCHEMA}\nfront\t{}\nat\t{}\n",
        one_line(&r.front),
        r.at_unix
    );
    for d in &r.doors {
        t.push_str(&format!(
            "door\t{}\t{}\t{}\n",
            one_line(&d.name),
            one_line(&d.listen),
            one_line(&d.what)
        ));
    }
    for x in &r.routes {
        t.push_str(&format!(
            "route\t{}\t{}\t{}\t{}\n",
            one_line(&x.host),
            one_line(&x.door),
            one_line(&x.to),
            one_line(&x.what)
        ));
    }
    t.push_str(&format!("console\t{}\n", one_line(&r.console_url)));
    t
}

/// Pure: **the proof's key**, from the x25519 secret the front's and the box's console keys
/// share, under [`KEY_CONTEXT`].
pub fn key_of(shared: &[u8; 32]) -> [u8; 32] {
    blake3::derive_key(KEY_CONTEXT, shared)
}

/// Pure: **the report's proof** under `key`, hex.
pub fn proof(key: &[u8; 32], r: &FrontReport) -> String {
    blake3::keyed_hash(key, text(r).as_bytes())
        .to_hex()
        .to_string()
}

/// Pure: **is this the front's report, now?** The proof under `key` (compared in constant
/// time), signed within [`FRESH_SECS`] of `now_unix`, and no larger than a front's. The
/// refusal says which, in one line.
pub fn check(
    key: &[u8; 32],
    r: &FrontReport,
    proof_hex: &str,
    now_unix: i64,
) -> Result<(), String> {
    if r.doors.len() > MAX_DOORS || r.routes.len() > MAX_ROUTES {
        return Err(format!(
            "the report names {} doors and {} routes; a front has at most {MAX_DOORS} and {MAX_ROUTES}",
            r.doors.len(),
            r.routes.len()
        ));
    }
    let long = std::iter::once(r.front.as_str())
        .chain(std::iter::once(r.console_url.as_str()))
        .chain(
            r.doors
                .iter()
                .flat_map(|d| [d.name.as_str(), d.listen.as_str(), d.what.as_str()]),
        )
        .chain(r.routes.iter().flat_map(|x| {
            [
                x.host.as_str(),
                x.door.as_str(),
                x.to.as_str(),
                x.what.as_str(),
            ]
        }))
        .any(|f| f.len() > MAX_FIELD);
    if long {
        return Err(format!(
            "a field of the report is longer than {MAX_FIELD} bytes"
        ));
    }
    let given = blake3::Hash::from_hex(proof_hex.trim())
        .map_err(|_| "its proof is not 64 hex characters".to_string())?;
    // blake3::Hash compares in constant time.
    if given != blake3::keyed_hash(key, text(r).as_bytes()) {
        return Err("its proof does not hold under this box's key for its front".into());
    }
    if (now_unix - r.at_unix).abs() > FRESH_SECS {
        return Err(format!(
            "it was made at {} and this box's clock says {now_unix}: more than {FRESH_SECS} s apart",
            r.at_unix
        ));
    }
    Ok(())
}

/// Pure: **a console address a page may link to**, without a trailing `/`: `https://` and a
/// plain host (letters, digits, `.`, `-`, an optional `:port`) and an optional plain path, or
/// `http://` to a loopback host (`localhost`, `127.0.0.1`, `[::1]`: a console on the same
/// machine). Anything else (`javascript:`, quotes, spaces, a user part) is `None`.
pub fn console_url(raw: &str) -> Option<String> {
    let raw = raw.trim().trim_end_matches('/');
    let (https, rest) = if let Some(r) = raw.strip_prefix("https://") {
        (true, r)
    } else {
        (false, raw.strip_prefix("http://")?)
    };
    let (authority, path) = match rest.find('/') {
        Some(i) => (&rest[..i], &rest[i..]),
        None => (rest, ""),
    };
    let (host, port) = if let Some(h) = authority.strip_prefix("[::1]") {
        ("[::1]", h)
    } else {
        match authority.rfind(':') {
            Some(i) => (&authority[..i], &authority[i..]),
            None => (authority, ""),
        }
    };
    let host_ok = host == "[::1]"
        || (!host.is_empty()
            && host.len() <= 253
            && host
                .bytes()
                .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-'));
    let port_ok = port.is_empty()
        || (port.len() > 1 && port.len() <= 6 && port[1..].bytes().all(|b| b.is_ascii_digit()));
    let path_ok = path
        .bytes()
        .all(|b| b.is_ascii_alphanumeric() || b"/-._~".contains(&b));
    let loopback = matches!(host, "localhost" | "127.0.0.1" | "[::1]");
    (host_ok && port_ok && path_ok && (https || loopback)).then(|| raw.to_string())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn report(at: i64) -> FrontReport {
        FrontReport {
            front: "front".into(),
            at_unix: at,
            doors: vec![FrontDoor {
                name: "https".into(),
                listen: "tcp/443".into(),
                what: "the web face".into(),
            }],
            routes: vec![FrontRoute {
                host: "repo.example.com".into(),
                door: "https".into(),
                to: TO_MAIN.into(),
                what: "main http :8443".into(),
            }],
            console_url: "https://www.example.com".into(),
        }
    }

    /// ★ The front's report is proven by the key its console key and the box's share, near
    /// now; a changed report, another key, an old report or a proof that is no proof is
    /// refused by name.
    #[test]
    fn a_front_report_holds_under_its_key_near_now_and_nothing_else_does() {
        let key = key_of(&[7; 32]);
        let r = report(1_000_000);
        let p = proof(&key, &r);
        assert_eq!(check(&key, &r, &p, 1_000_100), Ok(()));
        let mut changed = r.clone();
        changed.routes[0].to = TO_CONSOLE.into();
        assert!(check(&key, &changed, &p, 1_000_100)
            .unwrap_err()
            .contains("does not hold"));
        assert!(check(&key_of(&[8; 32]), &r, &p, 1_000_100)
            .unwrap_err()
            .contains("does not hold"));
        assert!(check(&key, &r, &p, 1_000_000 + FRESH_SECS + 1)
            .unwrap_err()
            .contains("apart"));
        assert!(check(&key, &r, "not hex", 1_000_100)
            .unwrap_err()
            .contains("hex"));
        let mut big = r.clone();
        big.doors = vec![FrontDoor::default(); MAX_DOORS + 1];
        let bp = proof(&key, &big);
        assert!(check(&key, &big, &bp, 1_000_100)
            .unwrap_err()
            .contains("at most"));
        assert_ne!(
            key,
            *blake3::hash(&[7; 32]).as_bytes(),
            "its own context, never a plain hash"
        );
    }

    #[test]
    fn the_text_is_one_line_per_field_in_order_and_a_newline_cannot_forge_a_line() {
        let mut r = report(5);
        r.routes[0].what = "a\nroute\tforged".into();
        assert_eq!(
            text(&r),
            "holger.front-report/v1\nfront\tfront\nat\t5\ndoor\thttps\ttcp/443\tthe web face\nroute\trepo.example.com\thttps\tmain\ta route forged\nconsole\thttps://www.example.com\n"
        );
    }

    #[test]
    fn a_console_url_is_https_or_a_loopback_and_nothing_else() {
        assert_eq!(
            console_url("https://www.example.com/"),
            Some("https://www.example.com".into())
        );
        assert_eq!(
            console_url("https://lab-1.example.com:8443/console"),
            Some("https://lab-1.example.com:8443/console".into())
        );
        assert_eq!(
            console_url("http://localhost:4000"),
            Some("http://localhost:4000".into())
        );
        assert_eq!(
            console_url("http://127.0.0.1:9"),
            Some("http://127.0.0.1:9".into())
        );
        for bad in [
            "",
            "http://lab-1.example.com",
            "javascript:alert(1)",
            "https://x.example.com/\"onmouseover=1",
            "https://user@x.example.com",
            "https://x.example.com/a b",
            "https://x.example.com:port",
            "ftp://x.example.com",
            "https://",
        ] {
            assert_eq!(console_url(bad), None, "{bad}");
        }
    }
}