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

nordisk/holger

server/lib/src/twin_tls.rs

//! **The twin over mutual TLS 1.3 — holger's side of `nordisk-twin`** (stinky 14
//! §P3, 2026-09-27; gunnar's model, gunnar's code moved into the shared crate).
//!
//! Until this module a twin followed its primary over PLAIN http with an
//! optional bearer, and bound every door to loopback — so a twin on another
//! machine could follow nothing, and a remote recharge had nothing to dial
//! (stinky 14 §2 corrections 1 and 2). Now:
//!
//! * **the primary is its own replication issuer** — `nordisk_twin::pki`, key
//!   `0600` under `<data_root>/`[`ISSUER_DIR`] (holger has no sealed secret
//!   store; gunnar keeps its issuer key sealed under its KEK — when holger grows
//!   one, the key moves there and nothing else changes). Never the installer CA:
//!   the medium's bootstrap certificate chains to that one.
//! * **the replication door** — a second gRPC listener at the gRPC port + 1
//!   ([`ReplicationDoorConfig`]), TLS 1.3 only, a client certificate chained to
//!   the issuer REQUIRED at the handshake, serving only what a reader needs:
//!   `RepositoryService` (the mirror's list + fetch) and `holger.estate.v1`, on a
//!   read-only `HolgerGrpc` whose auth knows exactly two CNs ([`door_auth`]).
//!   Every refused handshake is named (`holger-twin-12`) on the log and on
//!   `GET /-/twin`. A config with no `replication_door` opens nothing, mints
//!   nothing, and the box behaves exactly as before.
//! * **certify, driven by the front** — `BecomeTwin` → `TwinCsr` (the twin's key
//!   is born on the twin) → `IssueTwinCertificate` (the primary's issuer, CN
//!   forced, clientAuth, 365 d) → `InstallTwinCertificate` (our key? the CA
//!   signed it? the CA pinned — re-pointing refused, renewal taken).
//! * **the follow dials mTLS** — [`channel`]: the first recorded door that
//!   answers a 3 s TCP probe, the pinned CA, the twin's own certificate, the
//!   door's fixed name [`DOOR_NAME`]. Plain http (+ `HOLGER_PRIMARY_BEARER`)
//!   remains ONLY for an uncertified twin of a LOOPBACK primary — the in-process
//!   tests; a twin of a remote primary with no credential refuses to follow in
//!   the clear (`holger-twin-13`).
//! * **recharge from a remote twin** — the same three steps with purpose
//!   `recharge`: a fresh main mints the request, the TWIN's door issuer signs it
//!   (principal [`RECHARGE_PRINCIPAL`]), the main installs it and `Recharge`
//!   pulls over the twin's door. The twin keeps that door on its configured
//!   address whatever its role: it is the one door a twin must keep open.
//!
//! **The estate cut** (H2, HCUT 2026-10-26): the half that needs `nordisk-twin` (the
//! issuer, certify, the credential, the dialler and the door) is `twin_tls/mtls.rs`,
//! built with `estate`. Without it (the upstream and the downstream) this module is
//! the names, the config block and the pure rules, and `twin_tls/mtls_off.rs` answers:
//! no credential can be installed or read, and a declared door refuses the start.
//!
//! Nothing here holds a holger type in its core — the neutral half is in
//! `Skidbladnir/crates/installer/nordisk-twin`; this is directories, principal
//! names, the error codes and the auth map.

use std::net::SocketAddr;
use std::path::{Path, PathBuf};

use holger_errcode as ec;
use serde::{Deserialize, Serialize};

/// The door's name — the SAN every twin checks, whatever address it dialled.
pub const DOOR_NAME: &str = "holger-replication";
/// The CN of a twin following its primary.
pub const TWIN_PRINCIPAL: &str = "holger-twin";
/// The CN of a fresh main recharging from its twin.
pub const RECHARGE_PRINCIPAL: &str = "holger-recharge";
/// `<data_root>/` — this box's replication issuer and its door record.
pub const ISSUER_DIR: &str = "holger-replication-issuer";
/// `<data_root>/` — the twin's credential (following its primary).
pub const TWIN_DIR: &str = "holger-twin-tls";
/// `<data_root>/` — a fresh main's credential (reading its twin).
pub const RECHARGE_DIR: &str = "holger-recharge-tls";
/// In [`ISSUER_DIR`]: the door's bound port, written at every start, removed
/// when the door is off — what `IssueTwinCertificate` asks before it certifies.
pub const DOOR_PORT: &str = "door.port";
/// In [`ISSUER_DIR`]: the addresses the door answers on, in order.
pub const DOOR_HOSTS: &str = "door.hosts";

/// **Directories that are this BOX's identity** — never replicated (a twin
/// carrying its primary's issuer key could mint the primary's twins), never
/// "uncovered" in an estate report, and never what makes a box un-fresh (a box
/// opens its door, and so mints its issuer, on its FIRST start — before any
/// election could find it fresh). With them, the recharge markers: whether a
/// box was recharged is ITS fact — a twin carrying `holger-recharged` onward
/// would make the next main it recharges skip its own recharge.
///
/// The boot hold ([`crate::twin_follow::RECHARGE_HOLD`]) is the box's own the same way, and so
/// is its pull-channel enrolment (`fleet/`: the box key, the orders it took, its own jobs): a
/// re-laid main enrols at the ONE box before the recharge order it pulls can fill it, so that
/// enrolment must not make it un-fresh, and a twin must never carry the main's box key.
pub const BOX_OWN: &[&str] = &[
    ISSUER_DIR,
    TWIN_DIR,
    RECHARGE_DIR,
    crate::twin_follow::RECHARGED,
    crate::twin_follow::RECHARGING,
    crate::twin_follow::RECHARGE_HOLD,
    FLEET_DIR,
];

/// The pull channel's directory under the data root (holger-fleet's `fleet/…`).
pub const FLEET_DIR: &str = "fleet";

/// The shared crate, for a caller that drives certify or dials a door with
/// holger's types in hand (the front, the tests).
#[cfg(feature = "estate")]
pub use nordisk_twin;
#[cfg(feature = "estate")]
mod mtls;
#[cfg(feature = "estate")]
pub use mtls::*;
#[cfg(not(feature = "estate"))]
mod mtls_off;
#[cfg(not(feature = "estate"))]
pub use mtls_off::*;

/// **The `replication_door:` block.** Absent ⇒ no door.
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ReplicationDoorConfig {
    /// `host:port` to bind. Absent ⇒ the first exposed endpoint's CONFIGURED
    /// gRPC host (never the twin's loopback rewrite) and its port + 1.
    #[serde(default)]
    pub ron_bind: Option<String>,
    /// Addresses the door is reached at besides the bound one (the utility IP
    /// when the door binds `0.0.0.0`, a tunnel address), in the reader's order
    /// of preference; each goes into the door's SAN and to the reader.
    #[serde(default)]
    pub ron_advertise: Vec<String>,
}

/// Which credential slot a pairing step is about.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Purpose {
    /// A twin following its primary.
    Twin,
    /// A fresh main reading its twin.
    Recharge,
}

impl Purpose {
    /// `"twin"` (or empty) / `"recharge"`; anything else refused by name.
    pub fn parse(s: &str) -> Result<Purpose, String> {
        match s.trim() {
            "" | "twin" => Ok(Purpose::Twin),
            "recharge" => Ok(Purpose::Recharge),
            other => Err(ec::TWIN_PAIRING_REFUSED.tag(format!(
                "purpose `{other}` is neither `twin` nor `recharge`"
            ))),
        }
    }
    pub fn dir(self, root: &Path) -> PathBuf {
        root.join(match self {
            Purpose::Twin => TWIN_DIR,
            Purpose::Recharge => RECHARGE_DIR,
        })
    }
    pub fn principal(self) -> &'static str {
        match self {
            Purpose::Twin => TWIN_PRINCIPAL,
            Purpose::Recharge => RECHARGE_PRINCIPAL,
        }
    }
}

/// **Where the door binds by default**: the configured gRPC host, its port + 1.
pub fn default_bind(configured_grpc: &str) -> Result<String, String> {
    let addr: SocketAddr = configured_grpc.parse().map_err(|e| format!("the gRPC door `{configured_grpc}` is not host:port ({e}); declare replication_door.ron_bind"))?;
    let port = addr
        .port()
        .checked_add(1)
        .ok_or_else(|| format!("the gRPC port {} has no port + 1", addr.port()))?;
    Ok(SocketAddr::new(addr.ip(), port).to_string())
}

/// The addresses the door answers on, in order: the advertised ones, then the
/// bound IP when it is a specific one.
pub fn door_hosts(bound: &SocketAddr, advertise: &[String]) -> Vec<String> {
    let mut v: Vec<String> = advertise
        .iter()
        .map(|a| a.trim().to_string())
        .filter(|a| !a.is_empty())
        .collect();
    if !bound.ip().is_unspecified() && !v.contains(&bound.ip().to_string()) {
        v.push(bound.ip().to_string());
    }
    v
}

/// **The hosts a door is dialled at**: what its source reported, or — a door
/// bound on 0.0.0.0 reports none — the host of the control address the source
/// was asked at. The same rule as vm1's certify (holger-estate-front
/// `door_hosts_or_source`).
pub fn hosts_or_source(reported: Vec<String>, source_ctl: &str) -> Vec<String> {
    if reported.iter().any(|h| !h.trim().is_empty()) {
        return reported;
    }
    let host = source_ctl
        .rsplit_once(':')
        .map_or(source_ctl, |(h, _)| h)
        .trim_matches(['[', ']']);
    if host.is_empty() {
        Vec::new()
    } else {
        vec![host.to_string()]
    }
}

/// Write the door record (the listener is up).
pub fn record_door(root: &Path, port: u16, hosts: &[String]) -> Result<(), String> {
    let dir = root.join(ISSUER_DIR);
    std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
    std::fs::write(dir.join(DOOR_PORT), port.to_string())
        .map_err(|e| format!("{}: {e}", dir.join(DOOR_PORT).display()))?;
    std::fs::write(dir.join(DOOR_HOSTS), hosts.join(","))
        .map_err(|e| format!("{}: {e}", dir.join(DOOR_HOSTS).display()))
}

/// Remove the door record (no door this start) — so no twin is certified for a
/// door that is not there.
pub fn clear_door(root: &Path) {
    let _ = std::fs::remove_file(root.join(ISSUER_DIR).join(DOOR_PORT));
}

/// The recorded door, when this box serves one: `(port, hosts)`.
pub fn recorded_door(root: &Path) -> Option<(u16, Vec<String>)> {
    let dir = root.join(ISSUER_DIR);
    let port = std::fs::read_to_string(dir.join(DOOR_PORT))
        .ok()?
        .trim()
        .parse()
        .ok()?;
    let hosts = std::fs::read_to_string(dir.join(DOOR_HOSTS))
        .ok()
        .unwrap_or_default()
        .split(',')
        .map(str::trim)
        .filter(|h| !h.is_empty())
        .map(str::to_string)
        .collect();
    Some((port, hosts))
}

/// **The door's auth**: mTLS only, and exactly two principals — the twin and
/// the recharging main — each an admin-READ reader on a door that is read-only
/// by construction (`OperationMode::ReadOnly`, and only the read services are
/// mounted); every other CN `Denied`, every read authenticated. No bearer is
/// accepted here: the handshake already proved the issuer signed the peer.
pub fn door_auth() -> crate::auth::AuthConfig {
    let mut roles = std::collections::HashMap::new();
    roles.insert(TWIN_PRINCIPAL.to_string(), crate::auth::Role::Admin);
    roles.insert(RECHARGE_PRINCIPAL.to_string(), crate::auth::Role::Admin);
    crate::auth::AuthConfig {
        methods: vec![crate::auth::AuthMethodConfig::Mtls {
            ca_cert: String::new(),
        }],
        roles,
        default_role: Some(crate::auth::Role::Denied),
        require_auth_for_reads: true,
        ..Default::default()
    }
}

/// What `IssueTwinCertificate` answers.
#[derive(Debug, Clone)]
pub struct Issued {
    pub cert_pem: String,
    pub issuer_pem: String,
    pub port: u16,
    pub hosts: Vec<String>,
    pub not_after: i64,
}

/// Pure: **the new primary as this twin dials it.** A main reports the address
/// its control door is BOUND on (`RechargeStatus.control`, `0.0.0.0:50051`),
/// which names no host; the door this twin was just certified for names it
/// (`hosts[0]`), and the port stays the primary's. MEASURED by the
/// private-holger-ops estate-box gate (2026-10-04, U1): after a recharge the twin
/// was re-pointed at `0.0.0.0:50051`, and its next re-admission waited for ever on
/// "the pairing loop says twin of 10.6.0.14, and this box's role record says
/// `twin` of `0.0.0.0:50051`". A primary that names its host is kept as it is.
pub fn dialable(primary: &str, hosts: &[String]) -> String {
    let p = primary.trim();
    let (host, port) = match p.rsplit_once(':') {
        Some((h, pt)) if pt.chars().all(|c| c.is_ascii_digit()) => (h.trim_matches(['[', ']']), pt),
        _ => (p, ""),
    };
    let unspecified = host.is_empty()
        || host
            .parse::<std::net::IpAddr>()
            .map(|ip| ip.is_unspecified())
            .unwrap_or(false);
    match (
        unspecified,
        hosts.iter().map(|h| h.trim()).find(|h| !h.is_empty()),
    ) {
        (true, Some(h)) if !port.is_empty() => format!("{h}:{port}"),
        (true, Some(h)) => h.to_string(),
        _ => p.to_string(),
    }
}

/// True when `endpoint` (`http://host:port`, `host:port`) names a loopback host
/// — the one case a twin with no credential may follow in the clear.
pub fn is_loopback(endpoint: &str) -> bool {
    let rest = endpoint
        .split_once("://")
        .map(|(_, r)| r)
        .unwrap_or(endpoint);
    let hostport = rest.split('/').next().unwrap_or(rest);
    if let Ok(sa) = hostport.parse::<SocketAddr>() {
        return sa.ip().is_loopback();
    }
    let host = hostport
        .rsplit_once(':')
        .map(|(h, _)| h)
        .unwrap_or(hostport);
    host == "localhost"
        || host
            .parse::<std::net::IpAddr>()
            .map(|ip| ip.is_loopback())
            .unwrap_or(false)
}

/// Pure: **a peer that hung up before the handshake was whole** — a TCP probe, not a
/// refusal: nothing was presented, nothing was refused. nordisk-twin names it
/// `closed` only for a bare EOF; tokio-rustls wraps the same EOF as `tls handshake
/// eof`, so it arrived here as a `handshake` refusal: the twin's follow cycle put
/// `[holger-twin-12] handshake: … tls handshake eof` on its primary's log eight
/// times a minute, for ever — WARN spam by the estate's own machine (MEASURED by the
/// private-holger-ops estate-box gate, 2026-10-04). A client that refuses the door's
/// certificate sends an alert, and one without a certificate is refused by rustls by
/// name: both stay refusals.
pub fn is_probe(name: &str, why: &str) -> bool {
    name == "closed" || (name == "handshake" && why.trim() == "tls handshake eof")
}

/// The refusal a twin with no credential and a remote source says, each cycle.
pub fn not_certified(source: &str) -> String {
    ec::TWIN_NOT_CERTIFIED.tag(format!(
        "no replication credential is installed and `{source}` is not on loopback: this box will not follow it in the clear. The front certifies it (BecomeTwin → TwinCsr → IssueTwinCertificate → InstallTwinCertificate); the next cycle dials the door"
    ))
}

#[cfg(test)]
mod tests {
    /// ★ A twin re-pointed at a main that reports its BIND address follows the
    /// host its new door names; a primary that names its host is kept.
    #[test]
    fn a_twin_is_re_pointed_at_an_address_it_can_dial() {
        let hosts = vec!["10.6.0.14".to_string()];
        assert_eq!(super::dialable("0.0.0.0:50051", &hosts), "10.6.0.14:50051");
        assert_eq!(super::dialable("[::]:50051", &hosts), "10.6.0.14:50051");
        assert_eq!(
            super::dialable("10.6.0.14:50051", &["10.9.9.9".to_string()]),
            "10.6.0.14:50051",
            "a named host is kept"
        );
        assert_eq!(
            super::dialable("0.0.0.0:50051", &[]),
            "0.0.0.0:50051",
            "nothing better to say: unchanged"
        );
    }

    /// ★ A peer that hung up mid-handshake is a probe (no WARN); a real refusal stays one.
    #[test]
    fn an_eof_before_the_handshake_is_a_probe_and_a_refusal_stays_one() {
        assert!(super::is_probe("closed", "early eof"));
        assert!(
            super::is_probe("handshake", "tls handshake eof"),
            "tokio-rustls' spelling of the same EOF"
        );
        assert!(
            !super::is_probe("handshake", "no handshake within 10s"),
            "a peer that holds the handshake open is refused"
        );
        assert!(!super::is_probe("certificate", "peer sent no certificates"));
    }

    /// ★ `holger-server recharge` (GrpcBoxes) certifies against a door bound on
    /// 0.0.0.0 that reports no host (MEASURED on the dwarf mock 2026-09-29,
    /// [twin-bad-host]): the source's address stands in.
    #[test]
    fn a_door_with_no_reported_host_is_dialled_at_its_source() {
        assert_eq!(
            super::hosts_or_source(vec![], "10.13.8.112:50051"),
            vec!["10.13.8.112".to_string()]
        );
        assert_eq!(
            super::hosts_or_source(vec!["10.9.9.9".into()], "10.13.8.112:50051"),
            vec!["10.9.9.9".to_string()]
        );
    }
    use super::*;

    #[test]
    fn the_door_sits_beside_the_configured_grpc_door_and_names_its_hosts() {
        assert_eq!(default_bind("0.0.0.0:50051").unwrap(), "0.0.0.0:50052");
        assert_eq!(default_bind("10.13.8.2:50051").unwrap(), "10.13.8.2:50052");
        assert!(
            default_bind("grpc.holger.rs:50051").is_err(),
            "a name is not a bind"
        );
        let any: SocketAddr = "0.0.0.0:50052".parse().unwrap();
        assert!(
            door_hosts(&any, &[]).is_empty(),
            "0.0.0.0 is not an address a twin can dial"
        );
        assert_eq!(door_hosts(&any, &["10.44.0.2".into()]), ["10.44.0.2"]);
        let one: SocketAddr = "10.13.8.2:50052".parse().unwrap();
        assert_eq!(
            door_hosts(&one, &["10.44.0.2".into()]),
            ["10.44.0.2", "10.13.8.2"]
        );
        for (e, lo) in [
            ("http://127.0.0.1:50051", true),
            ("127.0.0.2:1", true),
            ("http://localhost:1", true),
            ("http://10.13.8.2:50051", false),
            ("grpc.holger.rs:50051", false),
        ] {
            assert_eq!(is_loopback(e), lo, "{e}");
        }
        assert_eq!(Purpose::parse("").unwrap(), Purpose::Twin);
        assert!(Purpose::parse("admin")
            .unwrap_err()
            .contains(ec::TWIN_PAIRING_REFUSED.code));
    }
}