feat(secret-rotator): cloudflare-roll mode #331
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user