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

nordisk/holger

estate/holger-fleet/src/enrol.rs

//! **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)
}

/// How long a call to the attach door may take as a whole (P4c, lane mon-shared): the agent
/// had no timeout, so a door that took the connection and never answered held the
/// enrolment (and the box's install) forever.
pub const DOOR_WITHIN: std::time::Duration = std::time::Duration::from_secs(60);
/// How long the connect alone may take.
const CONNECT_WITHIN: std::time::Duration = std::time::Duration::from_secs(10);

fn post_json<T: serde::Serialize>(
    door: &str,
    path: &str,
    body: &T,
) -> Result<serde_json::Value, String> {
    post_json_within(door, path, body, DOOR_WITHIN)
}

fn post_json_within<T: serde::Serialize>(
    door: &str,
    path: &str,
    body: &T,
    within: std::time::Duration,
) -> 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)
        .timeout_global(Some(within))
        .timeout_connect(Some(CONNECT_WITHIN.min(within)))
        .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"
        );
    }

    /// ★ P4c (lane mon-shared) RED->GREEN: a door that takes the connection and never
    /// answers is given up on within the bound, by name (it held the enrolment forever).
    #[test]
    fn a_silent_door_is_given_up_on_within_the_bound() {
        let silent = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
        let door = format!("http://{}", silent.local_addr().unwrap());
        let started = std::time::Instant::now();
        let e = post_json_within(
            &door,
            ENROL_PATH,
            &req(),
            std::time::Duration::from_millis(300),
        )
        .expect_err("a silent door answers nothing");
        assert!(e.contains(ENROL_PATH), "{e}");
        assert!(
            started.elapsed() < std::time::Duration::from_secs(10),
            "bounded: {:?}",
            started.elapsed()
        );
        drop(silent);
    }

    #[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"));
    }
}