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());
}
}