feat(secret-rotator): netbird-key mode #334

Merged
k.renner merged 1 commits from feat/netbird-key-mode into master 2026-07-21 15:23:20 +00:00
2 changed files with 129 additions and 1 deletions
+47 -1
View File
@@ -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 <random|ha-token|cloudflare-roll>".into()),
"netbird-key" => rotate_netbird_key(),
"" => Err("usage: secret-rotator <random|ha-token|cloudflare-roll|netbird-key>".into()),
other => Err(format!("unknown mode '{other}'").into()),
};
match result {
@@ -144,6 +146,50 @@ fn rotate_cloudflare_roll() -> Result<String, Box<dyn Error>> {
))
}
/// 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<String, Box<dyn Error>> {
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:
+82
View File
@@ -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<T>(msg: impl Into<String>) -> Result<T, Box<dyn Error>> {
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<String>,
}
/// Mint a setup key and return its secret value.
///
/// NetBird authenticates with `Authorization: Token <pat>` — 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<String, Box<dyn Error>> {
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("<redacted>".into()));
}
out
}