diff --git a/secret-rotator/src/main.rs b/secret-rotator/src/main.rs index deb014ad..9bfa857e 100644 --- a/secret-rotator/src/main.rs +++ b/secret-rotator/src/main.rs @@ -9,6 +9,7 @@ mod bao; mod cloudflare; +mod netbird; mod secretgen; mod ha; @@ -29,7 +30,8 @@ fn main() -> ExitCode { "random" => rotate_random(), "ha-token" => rotate_ha_token(), "cloudflare-roll" => rotate_cloudflare_roll(), - "" => Err("usage: secret-rotator ".into()), + "netbird-key" => rotate_netbird_key(), + "" => Err("usage: secret-rotator ".into()), other => Err(format!("unknown mode '{other}'").into()), }; match result { @@ -144,6 +146,50 @@ fn rotate_cloudflare_roll() -> Result> { )) } +/// Class B: mint a fresh NetBird reusable setup key and store it. +/// +/// Create-new, not roll-in-place: POST /api/setup-keys issues a new key and the +/// old one is left to expire (keys are ephemeral with a bounded expiry). +/// Nothing is deleted, so there is no destructive step and no window. +fn rotate_netbird_key() -> Result> { + let bao_addr = env_or("BAO_ADDR", "http://openbao.openbao.svc.cluster.local:8200"); + let bao_role = env("BAO_ROLE")?; + let bootstrap_path = env("BOOTSTRAP_PATH")?; + let bootstrap_field = env_or("BOOTSTRAP_FIELD", "pat"); + let target_path = env("BAO_PATH")?; + let target_field = env_or("BAO_FIELD", "setup-key"); + + let mgmt_url = env("NB_MANAGEMENT_URL")?; + let key_name = env_or("NB_KEY_NAME", "ingress-rotated"); + let req = netbird::SetupKeyRequest { + name: &key_name, + expires_in: env_or("NB_EXPIRES_IN", "31536000").parse()?, + ephemeral: env_or("NB_EPHEMERAL", "true").parse()?, + allow_extra_dns_labels: env_or("NB_ALLOW_EXTRA_DNS_LABELS", "true").parse()?, + auto_groups: env("NB_AUTO_GROUPS")? + .split(',') + .map(str::trim) + .filter(|g| !g.is_empty()) + .map(str::to_string) + .collect(), + }; + if req.auto_groups.is_empty() { + return Err("NB_AUTO_GROUPS listed no group ids".into()); + } + + let bao = bao::Bao::login(&bao_addr, &bao_role)?; + let pat = bao.read_field(&bootstrap_path, &bootstrap_field)?; + bao.assert_fields_exist(&target_path, &[&target_field])?; + + let key = netbird::create_setup_key(&bao::agent(), &mgmt_url, &pat, &req)?; + bao.patch_field(&target_path, &target_field, &key)?; + + Ok(format!( + "minted NetBird setup key '{}' -> {target_path}#{target_field} (value not logged)", + req.name + )) +} + /// Rotate a Home Assistant long-lived access token, make-before-break. /// /// Ordering is deliberate and must not be reshuffled: diff --git a/secret-rotator/src/netbird.rs b/secret-rotator/src/netbird.rs new file mode 100644 index 00000000..b010f0bd --- /dev/null +++ b/secret-rotator/src/netbird.rs @@ -0,0 +1,82 @@ +//! NetBird management API: mint a reusable setup key. +//! +//! Rotation here is create-new, not roll-in-place: `POST /api/setup-keys` +//! issues a fresh key and the old one is left to expire naturally (the keys are +//! ephemeral with a bounded expiry). Nothing is deleted, so there is no +//! destructive step. + +use std::error::Error; +use std::fmt; + +#[derive(Debug)] +pub struct NbError(String); +impl fmt::Display for NbError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "netbird: {}", self.0) + } +} +impl Error for NbError {} + +fn err(msg: impl Into) -> Result> { + Err(Box::new(NbError(msg.into()))) +} + +/// Parameters for a reusable setup key. `type` and `usage_limit` are fixed +/// (reusable, unlimited) — the semantics of the ingress key — while the rest +/// come from the environment so the one mode can serve future NetBird keys. +pub struct SetupKeyRequest<'a> { + pub name: &'a str, + pub expires_in: u64, + pub ephemeral: bool, + pub allow_extra_dns_labels: bool, + pub auto_groups: Vec, +} + +/// Mint a setup key and return its secret value. +/// +/// NetBird authenticates with `Authorization: Token ` — NOT `Bearer`. +/// The returned key is used verbatim (serde_json gives the exact string, no +/// trailing newline). +pub fn create_setup_key( + agent: &ureq::Agent, + mgmt_url: &str, + pat: &str, + req: &SetupKeyRequest, +) -> Result> { + let url = format!("{}/api/setup-keys", mgmt_url.trim_end_matches('/')); + let body = serde_json::json!({ + "name": req.name, + "type": "reusable", + "expires_in": req.expires_in, + "usage_limit": 0, + "ephemeral": req.ephemeral, + "allow_extra_dns_labels": req.allow_extra_dns_labels, + "auto_groups": req.auto_groups, + }); + let mut resp = agent + .post(&url) + .header("Authorization", &format!("Token {pat}")) + .send_json(&body)?; + let v: serde_json::Value = resp.body_mut().read_json()?; + match v["key"].as_str() { + Some(s) if !s.is_empty() => Ok(s.to_string()), + _ => err(format!( + "setup-key response had no 'key' field (got: {})", + // keep the shape visible for debugging, but the key itself is never + // present in an error path since it was missing/empty + serde_json::to_string(&redact(&v)).unwrap_or_default() + )), + } +} + +/// Blank the `key` field if present, so an error that echoes the response can +/// never leak a value. +fn redact(v: &serde_json::Value) -> serde_json::Value { + let mut out = v.clone(); + if let Some(obj) = out.as_object_mut() + && obj.contains_key("key") + { + obj.insert("key".into(), serde_json::Value::String("".into())); + } + out +}