rrdnsd/providers/
dynu.rs

1// rrdnsd - Dynu API client
2// Copyright 2025 Federico Ceratto <federico@debian.org>
3// Released under AGPLv3
4
5/// Dynu API
6/// See https://www.dynu.com/Support/API
7//
8use anyhow::{bail, Result};
9use log::{debug, info};
10use serde::{Deserialize, Serialize};
11use std::net::IpAddr;
12
13use super::{new_reqw, record_type, SetProvider};
14
15const API_BASEURL: &str = "https://api.dynu.com";
16
17#[derive(Deserialize, Debug)]
18#[serde(rename_all = "camelCase")]
19pub struct DynuRecord {
20    id: u32,
21    domain_id: u32,
22    ipv4_address: IpAddr,
23}
24
25#[derive(Deserialize, Debug)]
26#[serde(rename_all = "camelCase")]
27struct DynuRecordsResp {
28    dns_records: Vec<DynuRecord>,
29}
30
31#[derive(Deserialize, Debug)]
32struct DynuGetRoot {
33    id: u32,
34}
35
36#[derive(Serialize, Debug)]
37struct DynuAddPayload {
38    group: String,
39    #[serde(rename = "ipv4Address")]
40    ipv4_address: String,
41    #[serde(rename = "nodeName")]
42    node_name: String,
43    #[serde(rename = "recordType")]
44    record_type: String,
45    state: bool,
46    ttl: u32,
47}
48
49fn extract_record_name(fqdn: &str, zone: &str) -> String {
50    fqdn[..(fqdn.len() - zone.len() - 1)].to_owned()
51}
52
53async fn dynu_request(req: reqwest::RequestBuilder, token: &str) -> Result<reqwest::Response> {
54    let res = req
55        .header("accept", "application/json")
56        .header("API-Key", token)
57        .send()
58        .await?;
59    let status = res.status();
60    if !status.is_success() {
61        let error_body = res.text().await?;
62        bail!("[dynu] API error {}: {}", status, error_body);
63    }
64    Ok(res)
65}
66pub struct Dynu;
67
68impl SetProvider for Dynu {
69    const DEDUPLICATES_RECORDS: bool = true;
70
71    async fn add_record(token: &str, fqdn: &str, zone: &str, ipaddr: IpAddr) -> Result<()> {
72        let reqw = new_reqw()?;
73
74        debug!("[dynu] Fetching domain ID for {fqdn}");
75        let domain_id = {
76            let url = format!("{API_BASEURL}/v2/dns/getroot/{fqdn}");
77            info!("Fetching {}", url);
78            let res = dynu_request(reqw.get(url), token).await?;
79            let r = res.json::<DynuGetRoot>().await?;
80            r.id
81        };
82        debug!("[dynu] Received domain ID {domain_id}");
83
84        let url = format!("{API_BASEURL}/v2/dns/{domain_id}/record");
85        let payload = DynuAddPayload {
86            group: String::new(),
87            ipv4_address: ipaddr.to_string(),
88            node_name: extract_record_name(fqdn, zone),
89            record_type: record_type(ipaddr).to_string(),
90            state: true,
91            ttl: 30,
92        };
93        debug!("[dynu] Adding record {fqdn} to domain ID {domain_id}");
94        dynu_request(reqw.post(url).json(&payload), token).await?;
95        info!("[dynu] Adding record completed");
96        Ok(())
97    }
98
99    async fn delete_record(token: &str, fqdn: &str, _zone: &str, ipaddr: IpAddr) -> Result<()> {
100        let reqw = new_reqw()?;
101        let existing_record: DynuRecord = 'found: {
102            let rtype = record_type(ipaddr);
103            let url = format!("{API_BASEURL}/v2/dns/record/{fqdn}?recordType={rtype}");
104            let res = dynu_request(reqw.get(url), token).await?;
105            let r = res.json::<DynuRecordsResp>().await?;
106            for rdata in r.dns_records {
107                if rdata.ipv4_address == ipaddr {
108                    break 'found rdata;
109                }
110            }
111            bail!("not found");
112        };
113        debug!("Found record {existing_record:?}");
114        let url = format!(
115            "{API_BASEURL}/v2/dns/{}/record/{}",
116            existing_record.domain_id, existing_record.id
117        );
118        dynu_request(reqw.delete(url), token).await?;
119        info!("[dynu] Deleting record {fqdn} completed");
120        Ok(())
121    }
122}
123
124/// cargo nextest run test_integ_dynu_add_delete  --run-ignored only
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::providers::{Dynu, SetProvider};
129    use anyhow::bail;
130    use log::info;
131    use std::net::IpAddr;
132    use tokio::process::Command;
133
134    async fn lookup(fqdn: &str, resolver: &str) -> Result<Vec<IpAddr>> {
135        let ns = format!("@{resolver}");
136        let p = Command::new("dig")
137            .args(["+short", fqdn, &ns])
138            .output()
139            .await?;
140        if !p.status.success() {
141            bail!("Unable to run dig");
142        }
143        let stdout = String::from_utf8_lossy(&p.stdout);
144        let addrs: Vec<IpAddr> = stdout
145            .lines()
146            .filter_map(|line| line.trim().parse().ok())
147            .collect();
148
149        info!("dig {fqdn} -> {addrs:?}");
150        Ok(addrs)
151    }
152    #[derive(serde::Deserialize)]
153    struct Conf {
154        token: String,
155        zone: String,
156        fqdn: String,
157        ns: String, // nameserver
158    }
159
160    #[test]
161    fn test_extract_record_name() {
162        assert_eq!(extract_record_name("a.b.c.d", "b.c.d"), "a");
163        assert_eq!(extract_record_name("aaa.b.c.d", "b.c.d"), "aaa");
164    }
165
166    #[test_log::test(tokio::test)]
167    #[ignore]
168    async fn test_integ_dynu_add_delete() -> Result<()> {
169        let c: Conf = serde_json::from_str(include_str!("testdata/dynu.json"))?;
170        let ipa = "1.2.3.4".parse()?;
171        let orig_ipa = "1.1.1.1".parse()?;
172        let addrs = lookup(&c.fqdn, &c.ns).await?;
173        assert!(!addrs.contains(&ipa), "ipaddr not deleted {:?}", addrs);
174        assert!(addrs.contains(&orig_ipa), "old ipaddr missing {:?}", addrs);
175
176        info!("adding {ipa}");
177        Dynu::add_record(&c.token, &c.fqdn, &c.zone, ipa).await?;
178
179        tokio::time::sleep(tokio::time::Duration::from_secs(32)).await;
180        let addrs = lookup(&c.fqdn, &c.ns).await?;
181        assert!(addrs.contains(&ipa), "ipaddr not found {:?}", addrs);
182        assert!(addrs.contains(&orig_ipa), "old ipaddr missing {:?}", addrs);
183
184        info!("deleting {ipa}");
185        Dynu::delete_record(&c.token, &c.fqdn, &c.zone, ipa).await?;
186
187        tokio::time::sleep(tokio::time::Duration::from_secs(32)).await;
188        let addrs = lookup(&c.fqdn, &c.ns).await?;
189        assert!(!addrs.contains(&ipa), "ipaddr not deleted {:?}", addrs);
190        assert!(addrs.contains(&orig_ipa), "old ipaddr missing {:?}", addrs);
191
192        Ok(())
193    }
194}