cfdns 0.1.0

A Cloudflare Dynamic DNS update tool
// Copyright 2025 Matthew Lyon
// SPDX-License-Identifier: Apache-2.0
use std::sync::Arc;
use cloudflare::framework::{
    self, Environment,
    auth::Credentials,
    client::{ClientConfig, async_api::Client},
};

pub fn make_client(token: String) -> Result<Arc<Client>, framework::Error> {
    let auth = Credentials::UserAuthToken { token };
    let c = ClientConfig::default();
    let e = Environment::Production;
    Ok(Arc::new(Client::new(auth, c, e)?))
}

pub mod zone {
    use addr::parse_domain_name;
    use cloudflare::{
        endpoints::zones::zone::{ListZones, ListZonesParams},
        framework::{client::async_api::Client, response::ApiFailure},
    };
    use miette::{Diagnostic, Result};
    use reqwest::StatusCode;
    use thiserror::Error;

    pub async fn fetch_zone_id(client: &Client, zone_name: &str) -> Result<String, ZoneError> {
        let req = ListZones {
            params: ListZonesParams {
                name: Some(zone_name.to_string()),
                ..Default::default()
            },
        };
        let mut res = client
            .request(&req)
            .await
            .map_err(|e| from_api(zone_name.to_string(), e))?;

        if res.result.len() < 1 {
            return Err(ZoneError::NotFound(zone_name.to_string()));
        }
        let zone = res.result.swap_remove(0);

        Ok(zone.id)
    }

    pub fn guess_zone_from_domain<'a>(domain: &'a str) -> Option<&'a str> {
        let Ok(name) = parse_domain_name(&domain) else {
            return None;
        };
        name.root()
    }

    #[derive(Debug, Error, Diagnostic)]
    pub enum ZoneError {
        #[error("zone `{0}` was not found")]
        NotFound(String),

        #[error("permission denied while accessing zone `{0}`")]
        AccessDenied(String),

        #[error("Cloudflare API request failed for zone `{0}` with status code `{1}`")]
        Api(String, u16),

        #[error("Cloudflare API request failed for zone `{0}`")]
        Invalid(String, #[source] reqwest::Error),
    }

    fn from_api(zone_name: String, value: ApiFailure) -> ZoneError {
        match value {
            ApiFailure::Error(code, _) => {
                if code == StatusCode::NOT_FOUND {
                    ZoneError::NotFound(zone_name)
                } else if code == StatusCode::FORBIDDEN {
                    ZoneError::AccessDenied(zone_name)
                } else {
                    ZoneError::Api(zone_name, code.as_u16())
                }
            }
            ApiFailure::Invalid(e) => ZoneError::Invalid(zone_name, e),
        }
    }
}

pub mod dns {
    use std::net::IpAddr;

    use cloudflare::{
        endpoints::dns::dns::{
            CreateDnsRecord, CreateDnsRecordParams, DnsContent, DnsRecord, ListDnsRecords,
            ListDnsRecordsParams, UpdateDnsRecord, UpdateDnsRecordParams,
        },
        framework::{client::async_api::Client, response::ApiFailure},
    };
    use miette::Diagnostic;
    use thiserror::Error;
    use tracing::info;

    pub async fn fetch_ip_records(
        client: &Client,
        zone_id: &str,
        domain: &str,
    ) -> Result<(Option<DnsRecord>, Option<DnsRecord>), ApiFailure> {
        let req = ListDnsRecords {
            zone_identifier: zone_id,
            params: ListDnsRecordsParams {
                name: Some(domain.to_string()),
                ..Default::default()
            },
        };
        let res = client.request(&req).await?;

        let mut v4 = None;
        let mut v6 = None;

        for record in res.result {
            match record.content {
                DnsContent::A { content: _ } => v4 = Some(record),
                DnsContent::AAAA { content: _ } => v6 = Some(record),
                _ => {}
            };
        }

        Ok((v4, v6))
    }

    pub async fn try_update_record(
        client: &Client,
        zone_id: &str,
        domain: &str,
        existing: Option<DnsRecord>,
        ip: IpAddr
    ) -> Result<Option<DnsRecord>, UpdateError> {
        if let Some(existing) = existing {
            let existing_ip = match existing.content {
                DnsContent::A { content } => IpAddr::V4(content),
                DnsContent::AAAA { content } => IpAddr::V6(content),
                _ => return Err(UpdateError::NotAnIpRecord),
            };
            if ip != existing_ip {
                info!(domain, %ip, old_ip=%existing_ip, "Updating DNS record");
                let updated_record = update_dns_record(client, zone_id, &existing, ip)
                    .await
                    .map_err(|source| UpdateError::Cloudflare {
                        domain: domain.to_string(),
                        source,
                    })?;
                return Ok(Some(updated_record));
            } else {
                info!(domain, %ip, "Skipping up-to-date record");
                return Ok(None);
            }
        } else {
            info!(domain, %ip, "Creating new DNS record");
            let created_record = create_dns_record(client, zone_id, domain, ip)
                .await
                .map_err(|source| UpdateError::Cloudflare {
                    domain: domain.to_string(),
                    source,
                })?;
            return Ok(Some(created_record));
        }
    }

    pub async fn try_update_record_dry_run(
        domain: &str,
        existing: Option<DnsRecord>,
        ip: IpAddr
    ) -> Result<Option<()>, UpdateError> {
        if let Some(existing) = existing {
            let existing_ip = match existing.content {
                DnsContent::A { content } => IpAddr::V4(content),
                DnsContent::AAAA { content } => IpAddr::V6(content),
                _ => return Err(UpdateError::NotAnIpRecord),
            };
            if ip != existing_ip {
                info!(domain, %ip, old_ip=%existing_ip, "Updating DNS record (dry-run)");
                return Ok(Some(()));
            } else {
                info!(domain, %ip, "Skipping up-to-date record (dry-run)");
                return Ok(None);
            }
        } else {
            info!(domain, %ip, "Creating new DNS record (dry-run)");
            return Ok(Some(()));
        }
    }

    async fn create_dns_record(
        client: &Client,
        zone_id: &str,
        domain: &str,
        ip: IpAddr,
    ) -> Result<DnsRecord, ApiFailure> {
        let content = match ip {
            IpAddr::V4(ip) => DnsContent::A { content: ip },
            IpAddr::V6(ip) => DnsContent::AAAA { content: ip },
        };
        let req = CreateDnsRecord {
            zone_identifier: zone_id,
            params: CreateDnsRecordParams {
                name: domain,
                content,
                ttl: None,
                priority: None,
                proxied: None,
            },
        };
        let res = client.request(&req).await?;
        Ok(res.result)
    }

    async fn update_dns_record(
        client: &Client,
        zone_id: &str,
        record: &DnsRecord,
        new_ip: IpAddr,
    ) -> Result<DnsRecord, ApiFailure> {
        let content = match new_ip {
            IpAddr::V4(ip) => DnsContent::A { content: ip },
            IpAddr::V6(ip) => DnsContent::AAAA { content: ip },
        };
        let req = UpdateDnsRecord {
            zone_identifier: zone_id,
            identifier: &record.id,
            params: UpdateDnsRecordParams {
                name: &record.name,
                content,
                ttl: None,
                proxied: None,
            },
        };
        let res = client.request(&req).await?;
        Ok(res.result)
    }

    #[derive(Debug, Error, Diagnostic)]
    pub enum UpdateError {
        #[error("the record returned was not an A or AAAA record")]
        #[diagnostic(help("this is not your fault. Please report this error and try again later"))]
        NotAnIpRecord,
        #[error("the DNS update to `{domain}` failed")]
        #[help("check your permissions on your Cloudflare API token")]
        Cloudflare { domain: String, source: ApiFailure },
    }
}