use std::{env, net::IpAddr, sync::Arc, time::Duration}; use anyhow::{Context, Result, bail}; use reqwest::RequestBuilder; use serde::{Deserialize, de::DeserializeOwned}; use serde_json::json; use super::{DnsProvider, DnsRecord}; const API: &str = "https://api.cloudflare.com/client/v4"; const COMMENT: &str = "managed by dns-monitor"; pub enum Auth { Token(String), GlobalKey { email: String, key: String }, } impl Auth { /// CF_API_TOKEN if set, otherwise CF_API_EMAIL + CF_API_KEY (Global API Key). pub fn from_env() -> Result { if let Ok(token) = env::var("CF_API_TOKEN") { return Ok(Self::Token(token)); } match (env::var("CF_API_EMAIL"), env::var("CF_API_KEY")) { (Ok(email), Ok(key)) => Ok(Self::GlobalKey { email, key }), _ => bail!("set CF_API_TOKEN, or CF_API_EMAIL and CF_API_KEY"), } } } #[derive(Deserialize)] struct Envelope { success: bool, #[serde(default)] errors: Vec, result: Option, } #[derive(Deserialize)] struct ApiError { code: i64, message: String, } #[derive(Deserialize)] struct ZoneInfo { id: String, } pub struct Client { http: reqwest::Client, auth: Auth, } impl Client { pub fn new(auth: Auth) -> Result> { let http = reqwest::Client::builder() .timeout(Duration::from_secs(15)) .user_agent("dns-monitor") .build()?; Ok(Arc::new(Self { http, auth })) } async fn call(&self, req: RequestBuilder) -> Result { let req = match &self.auth { Auth::Token(token) => req.bearer_auth(token), Auth::GlobalKey { email, key } => req.header("X-Auth-Email", email).header("X-Auth-Key", key), }; let resp = req.send().await?; let status = resp.status(); let body: Envelope = resp .json() .await .with_context(|| format!("unexpected Cloudflare response (HTTP {status})"))?; if !body.success { let errors: Vec = body.errors.iter().map(|e| format!("{} {}", e.code, e.message)).collect(); bail!("Cloudflare API error (HTTP {status}): {}", errors.join("; ")); } body.result.context("Cloudflare response has no result") } } /// One Cloudflare zone, as seen by the monitor. pub struct Zone { client: Arc, id: String, } impl Zone { pub async fn lookup(client: Arc, zone: &str) -> Result { let req = client.http.get(format!("{API}/zones")).query(&[("name", zone)]); let zones: Vec = client.call(req).await?; match zones.into_iter().next() { Some(z) => Ok(Self { client, id: z.id }), None => bail!("zone {zone} not found in this Cloudflare account"), } } } impl DnsProvider for Zone { async fn records(&self, name: &str) -> Result> { let req = self .client .http .get(format!("{API}/zones/{}/dns_records", self.id)) .query(&[("name", name), ("per_page", "100")]); self.client.call(req).await.with_context(|| format!("listing records of {name}")) } async fn create(&self, name: &str, ip: IpAddr, ttl: u32) -> Result<()> { let kind = if ip.is_ipv4() { "A" } else { "AAAA" }; let req = self .client .http .post(format!("{API}/zones/{}/dns_records", self.id)) .json(&json!({ "type": kind, "name": name, "content": ip.to_string(), "ttl": ttl, "proxied": false, "comment": COMMENT, })); let _: serde_json::Value = self.client.call(req).await.with_context(|| format!("creating {kind} {name} -> {ip}"))?; Ok(()) } async fn delete(&self, record_id: &str) -> Result<()> { let req = self.client.http.delete(format!("{API}/zones/{}/dns_records/{record_id}", self.id)); let _: serde_json::Value = self.client.call(req).await.with_context(|| format!("deleting record {record_id}"))?; Ok(()) } }