cloudflare_dns_operator/dns/
cloudflare.rs

1use super::util;
2use crate::resources::RecordType;
3use chrono::prelude::*;
4use eyre::{
5    bail,
6    Context as _,
7    Result,
8};
9use reqwest::Method;
10use serde::{
11    de::DeserializeOwned,
12    Deserialize,
13    Serialize,
14};
15use serde_json::Value;
16
17// curl 'https://api.cloudflare.com/client/v4/accounts/{account_id}/pages/projects/{project_name}/deployments' --header 'Authorization: Bearer <API_TOKEN>'
18// c8bba8ee5e5c7b5f8b20bc4d5ca0de58
19
20/// Wraps the cloudflare api response.
21#[derive(Debug, Serialize, Deserialize)]
22struct ApiResult<T> {
23    errors: Value,
24    messages: Value,
25    result: T,
26    result_info: Option<ApiResultInfo>,
27    success: bool,
28}
29
30#[derive(Debug, Serialize, Deserialize)]
31struct ApiResultInfo {
32    count: usize,
33    page: usize,
34    per_page: usize,
35    total_count: usize,
36    total_pages: usize,
37}
38
39// -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
40// account
41
42/// A cloudflare account that represents a zone.
43#[derive(Debug, Serialize, Deserialize)]
44pub struct AccountInfo {
45    account: Account,
46    id: String,
47    name: String,
48    activated_on: DateTime<Utc>,
49    created_on: DateTime<Utc>,
50    modified_on: Option<DateTime<Utc>>,
51    development_mode: i64,
52    meta: Value,
53    name_servers: Vec<String>,
54    original_dnshost: Option<Value>,
55    original_name_servers: Option<Value>,
56    original_registrar: Option<Value>,
57    owner: Owner,
58    paused: bool,
59    permissions: Vec<String>,
60    plan: Plan,
61    status: String,
62    tenant: Value,
63    tenant_unit: Value,
64    r#type: String,
65}
66
67#[derive(Debug, Serialize, Deserialize)]
68pub struct Account {
69    id: String,
70    name: String,
71}
72
73#[derive(Debug, Serialize, Deserialize)]
74pub struct Owner {
75    email: Option<String>,
76    id: Option<String>,
77    r#type: Option<String>,
78}
79
80#[derive(Debug, Serialize, Deserialize)]
81pub struct Plan {
82    can_subscribe: bool,
83    currency: String,
84    externally_managed: bool,
85    frequency: String,
86    id: String,
87    is_subscribed: bool,
88    legacy_discount: bool,
89    legacy_id: String,
90    name: String,
91    price: i64,
92}
93
94// -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-
95
96/// A cloudflare dns record.
97///
98/// See https://developers.cloudflare.com/api/operations/zones-get?schema_url=https%3A%2F%2Fraw.githubusercontent.com%2Fcloudflare%2Fapi-schemas%2Fmain%2Fopenapi.yaml
99#[derive(Debug, Serialize, Deserialize)]
100pub struct DnsRecordInfo {
101    pub comment: Option<String>,
102    pub content: String,
103    pub created_on: DateTime<Utc>,
104    pub id: String,
105    pub meta: DnsRecordMeta,
106    pub modified_on: DateTime<Utc>,
107    pub name: String,
108    pub proxiable: bool,
109    pub proxied: bool,
110    #[serde(default)]
111    pub tags: Vec<String>,
112    pub ttl: i64,
113    #[serde(rename = "type")]
114    pub record_type: String,
115}
116
117#[derive(Debug, Serialize, Deserialize)]
118pub struct DnsRecordMeta {
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub auto_added: Option<bool>,
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub managed_by_apps: Option<bool>,
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub managed_by_argo_tunnel: Option<bool>,
125}
126
127/// Request payload for creating a new dns record.
128///
129/// See https://developers.cloudflare.com/api/operations/dns-records-for-a-zone-create-dns-record.
130#[derive(Debug, Serialize, Deserialize)]
131pub struct DnsRecordModification {
132    /// <= 32 characters
133    pub id: String,
134    pub name: String,
135    #[serde(rename = "type")]
136    pub record_type: RecordType,
137    pub content: String,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub ttl: Option<i64>,
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub proxied: Option<bool>,
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub comment: Option<String>,
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub tags: Option<Vec<String>>,
146}
147
148/// A cloudflare zone. Either the zone name (such as "example.com") or the cloudflare id of it.
149#[derive(Clone, Debug)]
150pub enum Zone {
151    Identifier(String),
152    Name(String),
153}
154
155impl Zone {
156    pub fn id(id: impl ToString) -> Self {
157        Zone::Identifier(id.to_string())
158    }
159
160    pub fn name(name: impl ToString) -> Self {
161        Zone::Name(name.to_string())
162    }
163
164    pub async fn resolve(self, api_token: &str) -> Result<Option<Self>> {
165        self.lookup_id(api_token).await.map(|id| id.map(Zone::Identifier))
166    }
167
168    pub async fn lookup_id(self, api_token: &str) -> Result<Option<String>> {
169        match self {
170            Zone::Identifier(id) => Ok(Some(id)),
171            Zone::Name(name) => {
172                debug!(?name, "looking up zone by name");
173                let accounts = list_zones(api_token).await?;
174                Ok(accounts.into_iter().find(|it| it.name == name).map(|it| it.id))
175            }
176        }
177    }
178}
179
180/// Arguments for [`create_dns_record`].
181pub struct CreateRecordArgs {
182    pub api_token: String,
183    pub zone: Zone,
184    pub name: String,
185    pub record_type: RecordType,
186    pub content: String,
187    pub comment: Option<String>,
188    pub ttl: Option<i64>,
189}
190
191/// List all cloudflare accounts which represent zones.
192pub async fn list_zones(api_token: &str) -> Result<Vec<AccountInfo>, eyre::Error> {
193    let url = "https://api.cloudflare.com/client/v4/zones";
194    cloudflare_api_request::<Vec<AccountInfo>, ()>(url, None, Method::GET, api_token).await
195}
196
197/// Create a new cloudflare dns record
198pub async fn create_dns_record(args: CreateRecordArgs) -> Result<DnsRecordInfo, eyre::Error> {
199    let CreateRecordArgs {
200        api_token,
201        zone,
202        name,
203        record_type,
204        content,
205        comment,
206        ttl,
207    } = args;
208
209    let zone_identifier = zone
210        .lookup_id(&api_token)
211        .await?
212        .ok_or_else(|| eyre::eyre!("zone not found"))?;
213
214    let url = format!("https://api.cloudflare.com/client/v4/zones/{zone_identifier}/dns_records");
215    let id = util::id();
216
217    info!(?id, ?name, r#type = ?record_type, "creating dns record");
218
219    cloudflare_api_request::<DnsRecordInfo, _>(
220        &url,
221        Some(DnsRecordModification {
222            id,
223            name,
224            record_type,
225            content,
226            ttl,
227            proxied: None,
228            comment,
229            tags: None,
230        }),
231        Method::POST,
232        api_token,
233    )
234    .await
235}
236
237/// Updates a cloudflare dns record... currently deletes and recreates... Will wait for the dns record to propagate,
238/// i.e. a dns lookup resolves to the correct ip.
239// TODO: we should use the proper patch api.
240pub async fn update_dns_record_and_wait(args: CreateRecordArgs) -> Result<DnsRecordInfo, eyre::Error> {
241    let Some(zone_id) = args.zone.clone().lookup_id(&args.api_token).await? else {
242        bail!("zone not found");
243    };
244    let api_token = args.api_token.clone();
245    let domain = args.name.clone();
246
247    let dns_records = list_dns_records(&zone_id, &api_token).await?;
248    if let Some(existing) = dns_records.into_iter().find(|record| record.name == domain) {
249        if existing.content == args.content {
250            info!("DNS record for {domain:?} already exists with {:?}", args.content);
251            return Ok(existing);
252        }
253
254        warn!(
255            "Found existing DNS record for web domain {domain:?} with ip {:?}. Deleting.",
256            existing.content
257        );
258        delete_dns_record(&zone_id, &existing.id, &api_token)
259            .await
260            .context("Failed to delete existing DNS record")?;
261    }
262
263    info!("Creating new DNS record for {domain:?} with {:?}", args.content);
264
265    let record = create_dns_record(args).await?;
266
267    debug!("Registered record for {domain:?} with {:?}", record.content);
268
269    Ok(record)
270}
271
272/// Delete a DNS record by its (domain) name using the cloudflare API
273#[allow(dead_code)]
274pub async fn delete_dns_record_by_name(
275    name: impl AsRef<str>,
276    zone_identifier: impl AsRef<str>,
277    api_token: impl AsRef<str>,
278) -> Result<(), eyre::Error> {
279    let name = name.as_ref();
280    let zone_identifier = zone_identifier.as_ref();
281
282    info!(?name, "deleting dns record by name");
283
284    let record = list_dns_records(&zone_identifier, api_token.as_ref())
285        .await?
286        .into_iter()
287        .find(|it| it.name == name);
288
289    let Some(record) = record else {
290        bail!("no record found with name: {name}");
291    };
292
293    delete_dns_record(zone_identifier, record.id, api_token).await?;
294
295    Ok(())
296}
297
298/// Delete a DNS record by its id using the cloudflare API.
299pub async fn delete_dns_record(
300    zone_identifier: impl AsRef<str>,
301    id: impl AsRef<str>,
302    api_token: impl AsRef<str>,
303) -> Result<()> {
304    let zone_identifier = zone_identifier.as_ref();
305    let id = id.as_ref();
306    let url = format!("https://api.cloudflare.com/client/v4/zones/{zone_identifier}/dns_records/{id}");
307
308    cloudflare_api_request::<Value, ()>(&url, None, Method::DELETE, api_token).await?;
309
310    Ok(())
311}
312
313/// List DNS records in a cloudflare zone.
314pub async fn list_dns_records(
315    zone_identifier: impl AsRef<str>,
316    api_token: impl AsRef<str>,
317) -> Result<Vec<DnsRecordInfo>> {
318    let zone_identifier = zone_identifier.as_ref();
319    let url = format!("https://api.cloudflare.com/client/v4/zones/{zone_identifier}/dns_records");
320    cloudflare_api_request::<Vec<DnsRecordInfo>, ()>(&url, None, Method::GET, api_token).await
321}
322
323pub async fn cloudflare_api_request<R, B>(
324    url: &str,
325    body: Option<B>,
326    method: Method,
327    api_token: impl AsRef<str>,
328) -> Result<R>
329where
330    B: Serialize,
331    R: DeserializeOwned,
332{
333    let req = reqwest::Client::new()
334        .request(method, url)
335        .bearer_auth(api_token.as_ref())
336        .header("Content-Type", "application/json");
337
338    let req = if let Some(body) = body { req.json(&body) } else { req };
339
340    let res = req.send().await?;
341
342    if !res.status().is_success() {
343        bail!(
344            "cloudflare api error: status={:?}, body={:?}",
345            res.status(),
346            res.text().await?
347        );
348    }
349
350    #[cfg(debug_assertions)]
351    let body: ApiResult<_> = {
352        let body: Value = res.json().await?;
353        match serde_json::from_value(body.clone()) {
354            Err(err) => bail!(
355                "failed to parse api response: {err:?}: {}",
356                serde_json::to_string_pretty(&body).expect("pretty json")
357            ),
358            Ok(it) => it,
359        }
360    };
361
362    #[cfg(not(debug_assertions))]
363    let body: ApiResult<_> = res.json().await?;
364
365    Ok(body.result)
366}