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

nordisk/Skidbladnir

crates/installer/plugin-door-wire/src/lib.rs

//! # plugin-door-wire — a product server registers ITS OWN plugin
//!
//! **ONE monetize (owner 2026-10-12):** there is exactly one monetize, the ONE box
//! (monetize.ignalina.dk); no monetize runs on a Gunnar front, so no Gunnar box registers
//! into a front any more — a Gunnar estate enrols at the ONE box ([`attach`]) and its boxes
//! pull from it. The registration below is the plugin door's wire as it was ruled:
//!
//! Owner ruling 2026-09-25: the product server (gunnar-server, holger-server;
//! njord later, the same way) puts its own signed monetize plugin into the
//! front's monetize — once, when it is first elected primary, and again ONLY
//! when the plugin changes. The installer only sets up trust: vm1's plugin
//! door (tcp/[`PORT`], mutual TLS under the estate's installer CA) accepts the
//! primary's OWN box certificate, for its own product id only.
//!
//! This crate is the wire both sides speak — no product type in it (it was
//! `gunnar/crates/plugin-door-wire`; moved here 2026-09-27 when holger followed):
//!
//! | call | who | what |
//! |---|---|---|
//! | `GET /register/<id>` | the product's primary (or the operator) | [`Held`]: the digest of the module vm1 holds for `<id>`, the ticket key and the box it was registered from, vm1's monetize key |
//! | `POST /register/<id>` | the product's primary only | [`SelfRegister`]: the module, its signature, the primary's ticket key → [`Registered`] |
//!
//! What the product server does NOT send: the `[products.<id>]` block. vm1
//! fills it in itself — the endpoint from the authenticated peer (the box the
//! latest feed names as primary, its utility address), the grants from a
//! fixed template on vm1, the trust roots vm1 holds; prices stay the
//! operator's. [`register_if_changed`] is the whole client: ask what vm1
//! holds, send nothing when it is already this module from this box with this
//! ticket key.

/// **Attaching an estate to a central monetize** — f-attach-enrol.
pub mod attach;

use std::net::SocketAddr;

use base64::Engine as _;
use nordisk_pairing::mtls::{self, Issued};

/// The plugin door's port on vm1 (all of its addresses; the product servers
/// reach it on vm1's utility address).
pub const PORT: u16 = 8475;

/// The offer key under which vm1 names where its plugin door is:
/// `<vm1 utility>:8475`. The installer feeds it onward to every box as
/// `offer.<vm1>.plugin_door`.
pub const OFFER_PLUGIN_DOOR: &str = "plugin_door";

/// The path of product `id`'s registration (`GET` and `POST`).
pub fn path(id: &str) -> String {
    format!("/register/{id}")
}

/// **What vm1 holds for a product** — the answer to `GET /register/<id>`.
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Held {
    pub id: String,
    /// blake3 (hex) of the module vm1 keeps for `id`; `None` = none.
    #[serde(default)]
    pub digest: Option<String>,
    /// The ticket key (hex) its block verifies tickets with.
    #[serde(default)]
    pub ticket_key: Option<String>,
    /// The box that registered it (its certificate's name); `None` when the
    /// operator or the installer put it there.
    #[serde(default)]
    pub registrar: Option<String>,
    /// vm1's monetize verifying key (64 hex) — what the product server checks
    /// its entitlement facts with.
    #[serde(default)]
    pub monetize_pubkey: Option<String>,
}

/// **What a product server sends** — `POST /register/<id>`.
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SelfRegister {
    /// The module, base64 (standard alphabet).
    pub module: String,
    /// Its detached CMS signature (`.p7s`), base64 — REQUIRED: a self-registered
    /// plugin is taken only when it chains to a root vm1 trusts.
    pub signature: String,
    /// The primary's ticket-signing public key (hex) — the block's
    /// `appliance_key`. Carried with the push, so it can never race the
    /// installer's offers.
    #[serde(default)]
    pub ticket_key: Option<String>,
}

/// **What vm1 answers a registration.**
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Registered {
    /// `ADDED`, `REPLACED`, `UNCHANGED`, or `REFUSED [<clause>]`.
    pub outcome: String,
    /// The line it is read from (monetize's watcher's, or the door's refusal).
    pub line: String,
    /// vm1's monetize verifying key (64 hex).
    #[serde(default)]
    pub monetize_pubkey: Option<String>,
}

impl Registered {
    /// Taken: the module stands on vm1 now.
    pub fn taken(&self) -> bool {
        matches!(self.outcome.as_str(), "ADDED" | "REPLACED" | "UNCHANGED")
    }

    /// A refusal, by name.
    pub fn refused(clause: &str, why: impl Into<String>) -> Registered {
        let why = why.into();
        Registered { outcome: format!("REFUSED [{clause}]"), line: format!("REFUSED [{clause}] {why}"), monetize_pubkey: None }
    }
}

/// The digest vm1 compares by: blake3 of the module bytes, hex.
pub fn digest(module: &[u8]) -> String {
    blake3::hash(module).to_hex().to_string()
}

/// **A custom section of a WebAssembly module**, by name — pure. `None` when
/// the bytes are not a module or carry no such section. A product stamps its
/// build reference into one (gunnar: `gunnar_build_ref`), so its server can
/// refuse to register a plugin that is not its own build.
pub fn custom_section<'a>(module: &'a [u8], name: &str) -> Option<&'a [u8]> {
    fn leb(b: &[u8], at: &mut usize) -> Option<usize> {
        let (mut n, mut shift) = (0usize, 0u32);
        loop {
            let x = *b.get(*at)?;
            *at += 1;
            n |= ((x & 0x7f) as usize).checked_shl(shift)?;
            if x & 0x80 == 0 {
                return Some(n);
            }
            shift += 7;
            if shift > 35 {
                return None;
            }
        }
    }
    if module.len() < 8 || &module[..4] != b"\0asm" {
        return None;
    }
    let mut at = 8;
    while at < module.len() {
        let id = module[at];
        at += 1;
        let len = leb(module, &mut at)?;
        let end = at.checked_add(len).filter(|e| *e <= module.len())?;
        if id == 0 {
            let mut p = at;
            let n = leb(module, &mut p)?;
            let name_end = p.checked_add(n).filter(|e| *e <= end)?;
            if &module[p..name_end] == name.as_bytes() {
                return Some(&module[name_end..end]);
            }
        }
        at = end;
    }
    None
}

/// Base64 (standard), as the door decodes it.
pub fn b64(bytes: &[u8]) -> String {
    base64::engine::general_purpose::STANDARD.encode(bytes)
}

/// **The plugin a product server carries**: the module and its signature.
#[derive(Debug, Clone, Copy)]
pub struct Plugin<'a> {
    pub module: &'a [u8],
    pub signature: &'a [u8],
}

/// Who is registering, and with which key.
#[derive(Debug, Clone)]
pub struct Me<'a> {
    /// The box's name — the CN of its own certificate, the name the feed gives it.
    pub name: &'a str,
    /// Its own certificate and key (issued by the installer CA).
    pub identity: &'a Issued,
    /// The installer CA (the door's certificate chains to it; name `bootstrap.installer`).
    pub ca_pem: &'a str,
    /// Its ticket key (hex), carried with the push.
    pub ticket_key: Option<&'a str>,
}

/// What [`register_if_changed`] did.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Done {
    /// vm1 already holds this module, from this box, with this ticket key:
    /// nothing was sent.
    Unchanged(Held),
    /// It was sent; vm1's HTTP status and answer.
    Sent { status: u16, answer: Registered },
}

impl Done {
    /// vm1's monetize key, from whichever answer carried it.
    pub fn monetize_pubkey(&self) -> Option<&str> {
        match self {
            Done::Unchanged(h) => h.monetize_pubkey.as_deref(),
            Done::Sent { answer, .. } => answer.monetize_pubkey.as_deref(),
        }
    }
}

/// Pure: whether vm1's [`Held`] is already exactly what `me` would register.
pub fn already_held(held: &Held, me: &Me<'_>, module_digest: &str) -> bool {
    held.digest.as_deref() == Some(module_digest) && held.registrar.as_deref() == Some(me.name) && held.ticket_key.as_deref() == me.ticket_key
}

/// **Register once, and again only when something changed.** Asks vm1 what it
/// holds for `id`; sends the plugin only when vm1 does not already hold this
/// module from this box with this ticket key.
pub async fn register_if_changed(door: SocketAddr, me: &Me<'_>, id: &str, plugin: Plugin<'_>) -> Result<Done, String> {
    let (status, held) = held_remote(door, me.ca_pem, me.identity, id).await?;
    if status != 200 {
        return Err(format!("the plugin door answered {status} to `GET {}`", path(id)));
    }
    if already_held(&held, me, &digest(plugin.module)) {
        return Ok(Done::Unchanged(held));
    }
    let req = SelfRegister { module: b64(plugin.module), signature: b64(plugin.signature), ticket_key: me.ticket_key.map(str::to_string) };
    let (status, answer) = register_remote(door, me.ca_pem, me.identity, id, &req).await?;
    Ok(Done::Sent { status, answer })
}

/// `GET /register/<id>` — what vm1 holds.
pub async fn held_remote(door: SocketAddr, ca_pem: &str, me: &Issued, id: &str) -> Result<(u16, Held), String> {
    let (status, body) = exchange(door, ca_pem, me, "GET", &path(id), None).await?;
    if status != 200 {
        return Ok((status, Held { id: id.to_string(), ..Held::default() }));
    }
    Ok((status, json(&body)?))
}

/// `POST /register/<id>` — send the plugin.
pub async fn register_remote(door: SocketAddr, ca_pem: &str, me: &Issued, id: &str, req: &SelfRegister) -> Result<(u16, Registered), String> {
    let body = serde_json::to_vec(req).map_err(|e| e.to_string())?;
    let (status, answer) = exchange(door, ca_pem, me, "POST", &path(id), Some(&body)).await?;
    Ok((status, json(&answer)?))
}

fn json<T: serde::de::DeserializeOwned>(body: &str) -> Result<T, String> {
    let (s, e) = (body.find('{'), body.rfind('}'));
    match (s, e) {
        (Some(s), Some(e)) if s <= e => serde_json::from_str(&body[s..=e]).map_err(|err| format!("the door's answer does not parse: {err}: {body:?}")),
        _ => Err(format!("the door answered with no JSON body: {body:?}")),
    }
}

/// How long a client waits for the door's answer — longer than any door waits
/// for monetize's watcher (gunnar-front `plugin_door::ANSWER_WITHIN`, 120 s:
/// a loaded front's watcher compiles a guest in ~50 s; gunnar recharge bc26e94).
pub const READ_WITHIN: std::time::Duration = std::time::Duration::from_secs(180);

/// One HTTP/1.1 exchange over TLS 1.3, the installer CA pinned and the door
/// checked against `bootstrap.installer`, `me` presented as the client.
async fn exchange(door: SocketAddr, ca_pem: &str, me: &Issued, method: &str, path: &str, body: Option<&[u8]>) -> Result<(u16, String), String> {
    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
    let cfg = mtls::client_config(ca_pem, me).map_err(|e| e.to_string())?;
    let t0 = std::time::Instant::now();
    let tcp = tokio::time::timeout(std::time::Duration::from_secs(10), tokio::net::TcpStream::connect(door))
        .await
        .map_err(|_| format!("{door}: no answer within 10 s"))?
        .map_err(|e| format!("{door}: {e}"))?;
    let t_tcp = t0.elapsed();
    let mut tls = tokio_rustls::TlsConnector::from(cfg).connect(mtls::vm_name(), tcp).await.map_err(|e| format!("{door}: TLS: {e}"))?;
    let t_tls = t0.elapsed();
    let body = body.unwrap_or_default();
    let head = format!(
        "{method} {path} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
        mtls::VM_SERVER_NAME,
        body.len()
    );
    tls.write_all(head.as_bytes()).await.map_err(|e| e.to_string())?;
    tls.write_all(body).await.map_err(|e| e.to_string())?;
    // R1: `write_all` returns once rustls TOOK the bytes; what the socket would not take yet
    // stays sealed in rustls until somebody writes it out — and the read below never does.
    // Unflushed, the door waited for the rest of the body and this client for the answer
    // (gunnar estate-box: no HTTP answer for 180 s, both kernel queues empty).
    tls.flush().await.map_err(|e| format!("{door}: sending the request: {e}"))?;
    let mut out = Vec::new();
    // The door answers within its own deadline (it waits for monetize's watcher).
    let t_wrote = t0.elapsed();
    let read = tokio::time::timeout(READ_WITHIN, tls.read_to_end(&mut out)).await;
    let text = String::from_utf8_lossy(&out).to_string();
    let how = match &read {
        Err(_) => format!("nothing within {} s", READ_WITHIN.as_secs()),
        Ok(Err(e)) => format!("the read failed: {e}"),
        Ok(Ok(n)) => format!("{n} byte(s) then the end"),
    };
    let status: u16 = text.split_whitespace().nth(1).and_then(|s| s.parse().ok()).ok_or_else(|| {
        format!(
            "{door}: no HTTP answer to {method} {path}: {text:?} ({how}; tcp {} ms, tls {} ms, {} request byte(s) written at {} ms)",
            t_tcp.as_millis(),
            t_tls.as_millis(),
            head.len() + body.len(),
            t_wrote.as_millis()
        )
    })?;
    let rest = text.split_once("\r\n\r\n").map(|(_, b)| b.to_string()).unwrap_or_default();
    Ok((status, rest))
}

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

    fn me<'a>(identity: &'a Issued, ticket: Option<&'a str>) -> Me<'a> {
        Me { name: "vm2", identity, ca_pem: "", ticket_key: ticket }
    }

    #[test]
    fn a_held_module_is_the_same_only_when_digest_registrar_and_ticket_key_all_agree() {
        let id = Issued { cert_pem: String::new(), key_pem: String::new() };
        let d = digest(b"\0asm module");
        let held = Held { id: "gunnar".into(), digest: Some(d.clone()), ticket_key: Some("ab".into()), registrar: Some("vm2".into()), monetize_pubkey: None };
        assert!(already_held(&held, &me(&id, Some("ab")), &d));
        assert!(!already_held(&held, &me(&id, Some("cd")), &d), "a new ticket key is a change");
        assert!(!already_held(&held, &me(&id, Some("ab")), &digest(b"\0asm other")), "a new module is a change");
        let by_operator = Held { registrar: None, ..held.clone() };
        assert!(!already_held(&by_operator, &me(&id, Some("ab")), &d), "put there by someone else: the primary registers it as its own");
        assert!(!already_held(&Held::default(), &me(&id, Some("ab")), &d), "nothing held");
    }

    #[test]
    fn the_answer_says_whether_the_module_stands() {
        for o in ["ADDED", "REPLACED", "UNCHANGED"] {
            assert!(Registered { outcome: o.into(), ..Registered::default() }.taken());
        }
        let r = Registered::refused("self-register-not-primary", "vm3 is the twin");
        assert!(!r.taken() && r.line.starts_with("REFUSED [self-register-not-primary] "));
    }

    #[test]
    fn a_custom_section_is_read_by_name_and_anything_else_is_none() {
        // magic + version, a type section (id 1, empty), then custom "stamp" = "v1".
        let mut m = b"\0asm\x01\0\0\0".to_vec();
        m.extend([1, 1, 0]);
        m.extend([0, 8, 5]);
        m.extend(b"stampv1");
        assert_eq!(custom_section(&m, "stamp"), Some(&b"v1"[..]));
        assert_eq!(custom_section(&m, "other"), None);
        assert_eq!(custom_section(b"MZ\0\0\0\0\0\0", "stamp"), None, "not a module");
        let mut cut = m.clone();
        cut.truncate(m.len() - 1);
        assert_eq!(custom_section(&cut, "stamp"), None, "a section that runs past the end");
    }

    #[test]
    fn a_door_answer_is_read_from_its_json_body() {
        let h: Held = json("5a\r\n{\"id\":\"gunnar\",\"digest\":\"ab\"}\r\n0\r\n\r\n").unwrap();
        assert_eq!((h.id.as_str(), h.digest.as_deref()), ("gunnar", Some("ab")));
        assert!(json::<Held>("").is_err());
    }
}