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    async fn add_record(token: &str, fqdn: &str, zone: &str, ipaddr: IpAddr) -> Result<()> {
70        let reqw = new_reqw()?;
71
72        debug!("[dynu] Fetching domain ID for {fqdn}");
73        let domain_id = {
74            let url = format!("{API_BASEURL}/v2/dns/getroot/{fqdn}");
75            info!("Fetching {}", url);
76            let res = dynu_request(reqw.get(url), token).await?;
77            let r = res.json::<DynuGetRoot>().await?;
78            r.id
79        };
80        debug!("[dynu] Received domain ID {domain_id}");
81
82        let url = format!("{API_BASEURL}/v2/dns/{domain_id}/record");
83        let payload = DynuAddPayload {
84            group: String::new(),
85            ipv4_address: ipaddr.to_string(),
86            node_name: extract_record_name(fqdn, zone),
87            record_type: record_type(ipaddr).to_string(),
88            state: true,
89            ttl: 30,
90        };
91        debug!("[dynu] Adding record {fqdn} to domain ID {domain_id}");
92        dynu_request(reqw.post(url).json(&payload), token).await?;
93        info!("[dynu] Adding record completed");
94        Ok(())
95    }
96
97    async fn delete_record(token: &str, fqdn: &str, _zone: &str, ipaddr: IpAddr) -> Result<()> {
98        let reqw = new_reqw()?;
99        let existing_record: DynuRecord = 'found: {
100            let rtype = record_type(ipaddr);
101            let url = format!("{API_BASEURL}/v2/dns/record/{fqdn}?recordType={rtype}");
102            let res = dynu_request(reqw.get(url), token).await?;
103            let r = res.json::<DynuRecordsResp>().await?;
104            for rdata in r.dns_records {
105                if rdata.ipv4_address == ipaddr {
106                    break 'found rdata;
107                }
108            }
109            bail!("not found");
110        };
111        debug!("Found record {existing_record:?}");
112        let url = format!(
113            "{API_BASEURL}/v2/dns/{}/record/{}",
114            existing_record.domain_id, existing_record.id
115        );
116        dynu_request(reqw.delete(url), token).await?;
117        info!("[dynu] Deleting record {fqdn} completed");
118        Ok(())
119    }
120}
121
122/// cargo nextest run test_integ_dynu_add_delete  --run-ignored only
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::providers::{Dynu, SetProvider};
127    use anyhow::bail;
128    use log::info;
129    use std::net::IpAddr;
130    use tokio::process::Command;
131
132    async fn lookup(fqdn: &str, resolver: &str) -> Result<Vec<IpAddr>> {
133        let ns = format!("@{resolver}");
134        let p = Command::new("dig")
135            .args(["+short", fqdn, &ns])
136            .output()
137            .await?;
138        if !p.status.success() {
139            bail!("Unable to run dig");
140        }
141        let stdout = String::from_utf8_lossy(&p.stdout);
142        let addrs: Vec<IpAddr> = stdout
143            .lines()
144            .filter_map(|line| line.trim().parse().ok())
145            .collect();
146
147        info!("dig {fqdn} -> {addrs:?}");
148        Ok(addrs)
149    }
150    #[derive(serde::Deserialize)]
151    struct Conf {
152        token: String,
153        zone: String,
154        fqdn: String,
155        ns: String, // nameserver
156    }
157
158    #[test]
159    fn test_extract_record_name() {
160        assert_eq!(extract_record_name("a.b.c.d", "b.c.d"), "a");
161        assert_eq!(extract_record_name("aaa.b.c.d", "b.c.d"), "aaa");
162    }
163
164    #[test_log::test(tokio::test)]
165    #[ignore]
166    async fn test_integ_dynu_add_delete() -> Result<()> {
167        let c: Conf = serde_json::from_str(include_str!("testdata/dynu.json"))?;
168        let ipa = "1.2.3.4".parse()?;
169        let orig_ipa = "1.1.1.1".parse()?;
170        let addrs = lookup(&c.fqdn, &c.ns).await?;
171        assert!(!addrs.contains(&ipa), "ipaddr not deleted {:?}", addrs);
172        assert!(addrs.contains(&orig_ipa), "old ipaddr missing {:?}", addrs);
173
174        info!("adding {ipa}");
175        Dynu::add_record(&c.token, &c.fqdn, &c.zone, ipa).await?;
176
177        tokio::time::sleep(tokio::time::Duration::from_secs(32)).await;
178        let addrs = lookup(&c.fqdn, &c.ns).await?;
179        assert!(addrs.contains(&ipa), "ipaddr not found {:?}", addrs);
180        assert!(addrs.contains(&orig_ipa), "old ipaddr missing {:?}", addrs);
181
182        info!("deleting {ipa}");
183        Dynu::delete_record(&c.token, &c.fqdn, &c.zone, ipa).await?;
184
185        tokio::time::sleep(tokio::time::Duration::from_secs(32)).await;
186        let addrs = lookup(&c.fqdn, &c.ns).await?;
187        assert!(!addrs.contains(&ipa), "ipaddr not deleted {:?}", addrs);
188        assert!(addrs.contains(&orig_ipa), "old ipaddr missing {:?}", addrs);
189
190        Ok(())
191    }
192}