feat(secret-rotator): netbird-key mode #334
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user