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": ` match in raw JSON. // Handles both quoted strings ("pk": "uuid") and bare numbers ("pk": 5). fn extract_str(json: &str, key: &str) -> Option { 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()) } }