feat(secret-rotator): cloudflare-roll mode #331

Merged
k.renner merged 1 commits from feat/cloudflare-roll-mode into master 2026-07-21 14:59:16 +00:00
2 changed files with 109 additions and 1 deletions
+63
View File
@@ -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<T>(msg: impl Into<String>) -> Result<T, Box<dyn Error>> {
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<String, Box<dyn Error>> {
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"),
}
}
+46 -1
View File
@@ -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 <random|ha-token>".into()),
"cloudflare-roll" => rotate_cloudflare_roll(),
"" => Err("usage: secret-rotator <random|ha-token|cloudflare-roll>".into()),
other => Err(format!("unknown mode '{other}'").into()),
};
match result {
@@ -99,6 +101,49 @@ fn rotate_random() -> Result<String, Box<dyn Error>> {
))
}
/// 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<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 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: