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