holger-fleet 2.0.0

The estate side of the ONE monetize's pull channel for Holger: Holger's order kinds and the executor over holger's own verbs, on the shared pump (monetize-estate-side: the box key, Report/Orders/Ask, order verification, the jera job), and enrolment.
Documentation
//! **Joining the ONE monetize** (f-attach-enrol): the estate's owner hands the operator's
//! one-shot token to `POST /attach/enrol`; the answer carries the product's monetize key
//! (what every order verifies under). Its client certificate is NOT kept: the pull channel
//! presents none, as Gunnar's does not — every call is signed by the box's own key, and the
//! door is verified under the public roots or the fed CA (`crate::grpc::channel`). The wire is
//! Skidbladnir's `plugin-door-wire::attach`; the request is signed by the owner's key on
//! the caller's side ([`EnrolRequest::sign`]).

use std::path::Path;

pub use plugin_door_wire::attach::{EnrolReply, EnrolRequest, InstalledNotice, ENROL_PATH, INSTALLED_PATH};

use crate::config::FleetConfig;

/// `POST <door>/attach/enrol` — the reply, or the central's refusal in its words.
pub fn post(door: &str, req: &EnrolRequest) -> Result<EnrolReply, String> {
    let body = post_json(door, ENROL_PATH, req)?;
    serde_json::from_value(body).map_err(|e| format!("{door}{ENROL_PATH}: not an enrolment reply: {e}"))
}

/// **The owner names the boxes that will call** (`POST /attach/installed`, after the
/// enrolment): `roles`, each box's own fleet key (role → 64 hex) so the central pins exactly
/// those keys, and `ticket_key` — the key the main signs its actor tickets with (its fleet key,
/// `room.rs`), which the central keeps as this estate's appliance key: nobody fills
/// `[products.holger] appliance_key` by hand. Unsigned; [`InstalledNotice::sign`] signs it.
pub fn notice(estate: &str, fleet_keys: &std::collections::BTreeMap<String, String>, ticket_key: &str, nonce: &str, at_unix_ms: i64) -> InstalledNotice {
    InstalledNotice {
        v: 1,
        estate: estate.trim().to_string(),
        product: crate::PRODUCT.into(),
        roles: fleet_keys.keys().cloned().collect(),
        nonce: nonce.to_string(),
        at_unix_ms,
        ticket_key: ticket_key.trim().to_ascii_lowercase(),
        fleet_keys: fleet_keys.iter().map(|(r, k)| (r.clone(), k.trim().to_ascii_lowercase())).collect(),
        owner_sig: String::new(),
    }
}

/// `POST <door>/attach/installed` — the central's answer, or its refusal in its words.
pub fn installed(door: &str, n: &InstalledNotice) -> Result<serde_json::Value, String> {
    post_json(door, INSTALLED_PATH, n)
}

fn post_json<T: serde::Serialize>(door: &str, path: &str, body: &T) -> Result<serde_json::Value, String> {
    let url = format!("{}{path}", door.trim_end_matches('/'));
    let agent: ureq::Agent = ureq::Agent::config_builder().http_status_as_error(false).build().into();
    let mut resp = agent.post(&url).send_json(body).map_err(|e| format!("{url}: {e}"))?;
    let status = resp.status();
    let v: serde_json::Value = resp.body_mut().read_json().map_err(|e| format!("{url} answered {status} with no JSON: {e}"))?;
    if !status.is_success() {
        let clause = v.get("refused").and_then(|x| x.as_str()).unwrap_or("refused");
        let why = v.get("why").and_then(|x| x.as_str()).unwrap_or("");
        return Err(format!("REFUSED [{clause}] {why}"));
    }
    Ok(v)
}

/// **Keep what the enrolment gave**: the door, the estate and the monetize key at
/// [`crate::config::CONFIG_REL`]. The pull channel reads them on its next start.
pub fn remember(root: &Path, door: &str, req: &EnrolRequest, reply: &EnrolReply) -> Result<FleetConfig, String> {
    if crate::key32(&reply.monetize_pubkey).is_none() {
        return Err(format!("the central answered a monetize key that is not 32 bytes ({:?})", reply.monetize_pubkey));
    }
    let cfg = FleetConfig { url: door.trim_end_matches('/').to_string(), instance: req.estate.clone(), monetize_pubkey: reply.monetize_pubkey.clone(), ..Default::default() };
    cfg.save(root).map_err(|e| format!("keeping the fleet config: {e}"))?;
    Ok(cfg)
}

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

    fn req() -> EnrolRequest {
        EnrolRequest {
            v: 1,
            token: "att1.a.b".into(),
            estate: "acme".into(),
            product: "holger".into(),
            box_name: "main".into(),
            csr_pem: String::new(),
            endpoint: "10.0.0.2:50051".into(),
            owner_key: String::new(),
            estate_ca_pem: String::new(),
            nonce: "n".into(),
            at_unix_ms: 1,
            owner_sig: String::new(),
        }
    }

    fn reply(key: &str) -> EnrolReply {
        EnrolReply { cert_pem: "CERT".into(), attach_ca_pem: String::new(), monetize_identity: String::new(), monetize_identity_key: String::new(), monetize_pubkey: key.into(), plugin_door: String::new(), product: "holger".into(), estate_bearer: "b".into() }
    }

    /// ★ After the enrolment the owner names the boxes WITH their keys: the central pins exactly
    /// those (no first-call window to win) and keeps the main's ticket key as the appliance key.
    #[test]
    fn the_installed_notice_names_each_box_with_its_key_and_the_ticket_key() {
        let keys = std::collections::BTreeMap::from([("main".to_string(), "aa".repeat(32)), ("twin".to_string(), "bb".repeat(32))]);
        let n = notice("acme", &keys, &"aa".repeat(32), "n-1", 7);
        assert_eq!((n.v, n.estate.as_str(), n.product.as_str(), n.roles.clone()), (1, "acme", "holger", vec!["main".to_string(), "twin".to_string()]));
        assert_eq!(n.ticket_key, "aa".repeat(32));
        let signed = String::from_utf8(n.signed_bytes()).unwrap();
        assert!(signed.starts_with("monetize-attach-installed v1") && signed.contains(&format!("fleet \"twin\" \"{}\"", "bb".repeat(32))), "{signed}");
        assert!(n.owner_sig.is_empty(), "the owner signs it, not this function");
    }

    #[test]
    fn an_enrolment_keeps_the_door_the_estate_and_the_key_and_refuses_a_bad_key() {
        let dir = tempfile::tempdir().unwrap();
        let cfg = remember(dir.path(), "https://monetize.ignalina.dk/", &req(), &reply(&"ab".repeat(32))).unwrap();
        assert_eq!((cfg.url.as_str(), cfg.instance.as_str(), cfg.key()), ("https://monetize.ignalina.dk", "acme", Some([0xab; 32])));
        assert_eq!(FleetConfig::load(dir.path()).unwrap(), Some(cfg), "the pull channel reads it on its next start");
        assert!(!dir.path().join("fleet/attach-cert.pem").exists(), "no certificate is kept: the channel presents none");
        assert!(remember(dir.path(), "https://x", &req(), &reply("nope")).unwrap_err().contains("not 32 bytes"));
    }
}