diff --git a/secret-rotator/src/cloudflare.rs b/secret-rotator/src/cloudflare.rs new file mode 100644 index 00000000..f8cfb9e5 --- /dev/null +++ b/secret-rotator/src/cloudflare.rs @@ -0,0 +1,63 @@ +//! Cloudflare API: roll an API token's secret value in place. +//! +//! `PUT /user/tokens/{id}/value` issues a NEW secret for the same token — same +//! id, same scope, same policies — and invalidates the old one immediately. +//! There is no make-before-break and no destructive window: the token keeps +//! existing, only its value changes. + +use std::error::Error; +use std::fmt; + +const API: &str = "https://api.cloudflare.com/client/v4"; + +#[derive(Debug)] +pub struct CfError(String); +impl fmt::Display for CfError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "cloudflare: {}", self.0) + } +} +impl Error for CfError {} + +fn err(msg: impl Into) -> Result> { + Err(Box::new(CfError(msg.into()))) +} + +/// Roll the value of token `token_id` using an admin token that holds +/// "Edit User API Tokens". Returns the new secret value. +/// +/// `admin` must carry no surrounding whitespace and the returned value is used +/// verbatim — a stray newline here surfaces downstream as +/// "invalid header field value for Authorization" and crash-loops the consumer +/// (the bug that hit external-dns once). serde_json gives us the exact string +/// the API returned, with no added newline. +pub fn roll_token_value( + agent: &ureq::Agent, + admin: &str, + token_id: &str, +) -> Result> { + let url = format!("{API}/user/tokens/{token_id}/value"); + // Body is an empty JSON object: the endpoint takes no parameters, it just + // regenerates the value. + let mut resp = agent + .put(&url) + .header("Authorization", &format!("Bearer {admin}")) + .send_json(serde_json::json!({}))?; + let v: serde_json::Value = resp.body_mut().read_json()?; + + if v["success"].as_bool() != Some(true) { + let errs = v["errors"].to_string(); + return err(format!("roll of token {token_id} failed: {errs}")); + } + // `result` is the new value as a bare string on success. Some Cloudflare + // endpoints wrap it as { "value": "..." }; accept either so a future API + // shape change fails loud rather than storing "null". + match &v["result"] { + serde_json::Value::String(s) if !s.is_empty() => Ok(s.clone()), + serde_json::Value::Object(o) => match o.get("value").and_then(|x| x.as_str()) { + Some(s) if !s.is_empty() => Ok(s.to_string()), + _ => err("roll succeeded but result.value was missing or empty"), + }, + _ => err("roll succeeded but result was not a token value"), + } +} diff --git a/secret-rotator/src/main.rs b/secret-rotator/src/main.rs index 7d98ae67..deb014ad 100644 --- a/secret-rotator/src/main.rs +++ b/secret-rotator/src/main.rs @@ -8,6 +8,7 @@ //! NEVER logs a secret value. mod bao; +mod cloudflare; mod secretgen; mod ha; @@ -27,7 +28,8 @@ fn main() -> ExitCode { let result = match mode.as_str() { "random" => rotate_random(), "ha-token" => rotate_ha_token(), - "" => Err("usage: secret-rotator ".into()), + "cloudflare-roll" => rotate_cloudflare_roll(), + "" => Err("usage: secret-rotator ".into()), other => Err(format!("unknown mode '{other}'").into()), }; match result { @@ -99,6 +101,49 @@ fn rotate_random() -> Result> { )) } +/// Class B: roll a Cloudflare API token's value in place and store it. +/// +/// Replaces the two identical python rotators — cert-manager's token and +/// external-dns's — which differ only in which bootstrap field holds the token +/// id and which OpenBao path receives the new value: +/// +/// cloudflare bootstrap#token-id -> infrastructure/cloudflare#api-token +/// external-dns bootstrap#external-dns-token-id -> apps/external-dns#api-token +/// +/// Both read the admin token from the SAME shared bootstrap +/// (infrastructure/secret-rotators-bootstrap/cloudflare#admin-token), so the +/// admin credential lives in one place; only the token-id pin differs, which +/// keeps the two tokens rolling independently. +/// +/// Roll-in-place has no destructive window: the token id and scope are +/// unchanged, only the value. cert-manager re-reads its Secret per DNS-01 +/// challenge (no restart); external-dns reads at startup and carries a Reloader +/// annotation. +fn rotate_cloudflare_roll() -> 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 admin_field = env_or("BOOTSTRAP_ADMIN_FIELD", "admin-token"); + let tokenid_field = env("BOOTSTRAP_TOKENID_FIELD")?; + let target_path = env("BAO_PATH")?; + let target_field = env_or("BAO_FIELD", "api-token"); + + let bao = bao::Bao::login(&bao_addr, &bao_role)?; + let admin = bao.read_field(&bootstrap_path, &admin_field)?; + let token_id = bao.read_field(&bootstrap_path, &tokenid_field)?; + + // Refuse to write a field that isn't already there — same guard as random + // mode; a target typo would add a stray field and strand the real token. + bao.assert_fields_exist(&target_path, &[&target_field])?; + + let new = cloudflare::roll_token_value(&bao::agent(), &admin, &token_id)?; + bao.patch_field(&target_path, &target_field, &new)?; + + Ok(format!( + "rolled Cloudflare token {token_id} -> {target_path}#{target_field} (value not logged)" + )) +} + /// Rotate a Home Assistant long-lived access token, make-before-break. /// /// Ordering is deliberate and must not be reshuffled: