Files
homelab/k8s/security/iam/key_rotate.rs
T

115 lines
3.7 KiB
Rust
Raw Normal View History

use std::env;
use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::{SystemTime, UNIX_EPOCH};
const SIGNING_CERT_NAME: &str = "homelab-oidc";
const PROVIDERS: &[&str] = &["grafana", "minio"];
fn main() {
let base_url = env::var("AUTHENTIK_BASE_URL")
.unwrap_or_else(|_| "http://localhost:7000".into());
let token = env::var("AUTHENTIK_BOOTSTRAP_TOKEN")
.expect("AUTHENTIK_BOOTSTRAP_TOKEN must be set");
let host = parse_host(&base_url);
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let new_name = format!("{}-{}", SIGNING_CERT_NAME, ts);
let body = format!(
r#"{{"common_name":"{}","validity_days":365,"key_size":4096}}"#,
new_name
);
let resp = http(&host, &token, "POST", "/api/v3/crypto/certificatekeypairs/generate/", Some(&body));
let new_pk = extract_str(&resp, "pk").expect("no pk in generate response");
println!("created '{}' pk={}", new_name, new_pk);
for provider in PROVIDERS {
let list = http(
&host, &token, "GET",
&format!("/api/v3/providers/oauth2/?name={}", provider),
None,
);
let provider_pk = match extract_str(&list, "pk") {
Some(pk) => pk,
None => {
eprintln!("WARN: provider '{}' not found — skipping", provider);
continue;
}
};
let patch = format!(r#"{{"signing_key":"{}"}}"#, new_pk);
http(&host, &token, "PATCH",
&format!("/api/v3/providers/oauth2/{}/", provider_pk),
Some(&patch),
);
println!("rotated '{}' → signing_key={}", provider, new_pk);
}
println!("rotation complete");
}
fn parse_host(base_url: &str) -> String {
let stripped = base_url
.trim_start_matches("http://")
.trim_start_matches("https://");
let host = stripped.split('/').next().unwrap_or(stripped);
if host.contains(':') {
host.to_string()
} else {
format!("{}:80", host)
}
}
fn http(host: &str, token: &str, method: &str, path: &str, body: Option<&str>) -> String {
let mut stream = TcpStream::connect(host)
.unwrap_or_else(|e| panic!("connect {}: {}", host, e));
let body_str = body.unwrap_or("");
let hostname = host.split(':').next().unwrap_or(host);
let req = format!(
"{method} {path} HTTP/1.1\r\n\
Host: {hostname}\r\n\
Authorization: Bearer {token}\r\n\
Content-Type: application/json\r\n\
Content-Length: {len}\r\n\
Connection: close\r\n\
\r\n\
{body_str}",
len = body_str.len(),
);
stream.write_all(req.as_bytes()).unwrap();
let mut raw = String::new();
stream.read_to_string(&mut raw).unwrap();
let (head, resp_body) = raw.split_once("\r\n\r\n").unwrap_or((&raw, ""));
let status: u16 = head.lines().next()
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|s| s.parse().ok())
.unwrap_or(0);
if status >= 400 {
panic!("{} {} → HTTP {}{}", method, path, status, resp_body.trim());
}
resp_body.to_string()
}
// Extracts the value of the first `"key": <value>` match in raw JSON.
// Handles both quoted strings ("pk": "uuid") and bare numbers ("pk": 5).
fn extract_str(json: &str, key: &str) -> Option<String> {
let needle = format!("\"{}\":", key);
let after_colon = json.find(&needle)? + needle.len();
let rest = json[after_colon..].trim_start();
if let Some(inner) = rest.strip_prefix('"') {
Some(inner[..inner.find('"')?].to_string())
} else {
let end = rest.find(|c: char| c == ',' || c == '}' || c.is_ascii_whitespace())?;
Some(rest[..end].to_string())
}
}