rrdnsd/providers/
dynv6.rs

1// rrdnsd - dynv6 API client
2// Copyright 2025 Federico Ceratto <federico@debian.org>
3// Released under AGPLv3
4
5use anyhow::{bail, Context, Result};
6use log::{debug, info};
7use reqwest::Client as Reqw;
8use serde::{Deserialize, Serialize};
9use std::net::IpAddr;
10use std::time::Duration;
11
12use super::{record_type, SetProvider};
13
14const API_BASEURL: &str = "https://dynv6.com/api/v2";
15
16#[derive(Deserialize, Debug)]
17#[serde(rename_all = "camelCase")]
18struct Zone {
19    id: u64,
20}
21
22#[derive(Deserialize, Debug)]
23#[serde(rename_all = "camelCase")]
24struct Record {
25    id: u64,
26    name: String,
27    data: String,
28    #[serde(rename = "type")]
29    record_type: String,
30}
31
32#[derive(Serialize, Debug)]
33struct RecordPayload {
34    name: String,
35    data: String,
36    #[serde(rename = "type")]
37    record_type: String,
38}
39
40async fn fetch_zone_id(reqw: &Reqw, zone: &str) -> Result<u64> {
41    debug!("[dynv6] Fetching zone ID for {zone}");
42
43    let url = format!("{API_BASEURL}/zones/by-name/{zone}");
44
45    let res = reqw.get(&url).send().await?;
46
47    if !res.status().is_success() {
48        let status = res.status();
49        let body = res.text().await.unwrap_or_default();
50        bail!("Failed to fetch zone: HTTP {status}, body: {body}");
51    }
52
53    let zone_info: Zone = res.json().await?;
54    debug!("[dynv6] Received zone ID {}", zone_info.id);
55    Ok(zone_info.id)
56}
57
58/// Find existing record ID by FQDN and ipaddr
59async fn fetch_record_id(reqw: &Reqw, zone_id: u64, fqdn: &str, ipaddr: IpAddr) -> Result<u64> {
60    debug!("[dynv6] Searching for existing record {fqdn} with ipaddr {ipaddr}");
61
62    let url = format!("{API_BASEURL}/zones/{zone_id}/records");
63    let records: Vec<Record> = reqw
64        .get(url)
65        .send()
66        .await?
67        .error_for_status()?
68        .json()
69        .await?;
70
71    let rtype = record_type(ipaddr);
72    let ipaddr = ipaddr.to_string();
73    for r in records {
74        if r.data == ipaddr && r.record_type == rtype && r.name == fqdn {
75            return Ok(r.id);
76        }
77    }
78    bail!("Record not found for {fqdn} with ipaddr {ipaddr}")
79}
80
81fn new_reqw(token: &str) -> Result<Reqw> {
82    let mut headers = reqwest::header::HeaderMap::new();
83    let auth_value = reqwest::header::HeaderValue::from_str(&format!("Bearer {}", token))?;
84    headers.insert("Authorization", auth_value);
85
86    let r = Reqw::builder()
87        .user_agent("rrdnsd")
88        .timeout(Duration::from_secs(30))
89        .pool_max_idle_per_host(0)
90        .default_headers(headers)
91        .build()?;
92    Ok(r)
93}
94
95pub struct Dynv6;
96
97impl SetProvider for Dynv6 {
98    const DEDUPLICATES_RECORDS: bool = false;
99
100    async fn add_record(token: &str, fqdn: &str, zone: &str, ipaddr: IpAddr) -> Result<()> {
101        let trim = format!(".{zone}");
102        let record = fqdn.strip_suffix(&trim).context(format!(
103            "Unable to extract record from FQDN {fqdn} and zone {zone}"
104        ))?;
105        let reqw = new_reqw(token)?;
106        let zone_id = fetch_zone_id(&reqw, zone).await?;
107
108        let rtype = record_type(ipaddr);
109        debug!("[dynv6] Adding {rtype} record {record} -> {ipaddr}");
110
111        let payload = RecordPayload {
112            name: record.to_owned(),
113            data: ipaddr.to_string(),
114            record_type: rtype.to_string(),
115        };
116        let url = format!("{}/zones/{}/records", API_BASEURL, zone_id);
117        let res = reqw
118            .post(&url)
119            .header("Content-Type", "application/json")
120            .json(&payload)
121            .send()
122            .await?;
123
124        if !res.status().is_success() {
125            let status = res.status();
126            let body = res.text().await.unwrap_or_default();
127            bail!("Failed to add record: HTTP {status}, body: {body}");
128        }
129
130        let response: serde_json::Value = res.json().await?;
131        info!("[dynv6] Add record response: {response:?}");
132
133        Ok(())
134    }
135
136    async fn delete_record(token: &str, fqdn: &str, zone: &str, ipaddr: IpAddr) -> Result<()> {
137        let trim = format!(".{zone}");
138        let record = fqdn.strip_suffix(&trim).context(format!(
139            "Unable to extract record from FQDN {fqdn} and zone {zone}"
140        ))?;
141        let reqw = new_reqw(token)?;
142
143        let zone_id = fetch_zone_id(&reqw, zone).await?;
144
145        let record_id = fetch_record_id(&reqw, zone_id, record, ipaddr).await?;
146
147        debug!("[dynv6] Found record ID {record_id}");
148        let url = format!("{API_BASEURL}/zones/{zone_id}/records/{record_id}");
149        let res = reqw.delete(&url).send().await?;
150
151        if !res.status().is_success() {
152            let status = res.status();
153            let body = res.text().await.unwrap_or_default();
154            bail!("Failed to delete record: HTTP {status}, body: {body}");
155        }
156
157        info!("[dynv6] Deleted record {record} in {zone}");
158
159        Ok(())
160    }
161}
162
163/// cargo nextest run test_integ_dynv6_add_delete  --run-ignored only
164// Tested in 2025 using test.rrdnsd.dynv6.net
165#[cfg(test)]
166mod tests {
167    use crate::providers::{Dynv6, SetProvider};
168    use anyhow::{bail, Result};
169    use log::info;
170    use std::net::IpAddr;
171    use tokio::process::Command;
172
173    async fn lookup(fqdn: &str) -> Result<Vec<IpAddr>> {
174        let p = Command::new("dig")
175            .args(["+short", fqdn, "@ns1.dynv6.com"])
176            .output()
177            .await?;
178        if !p.status.success() {
179            bail!("Unable to run dig");
180        }
181        let stdout = String::from_utf8_lossy(&p.stdout);
182        let addrs: Vec<IpAddr> = stdout
183            .lines()
184            .filter_map(|line| line.trim().parse().ok())
185            .collect();
186
187        info!("dig {fqdn} -> {addrs:?}");
188        Ok(addrs)
189    }
190    #[derive(serde::Deserialize)]
191    struct Conf {
192        token: String,
193        zone: String,
194        fqdn: String,
195    }
196
197    #[test_log::test(tokio::test)]
198    #[ignore]
199    async fn test_integ_dynv6_add_delete() -> Result<()> {
200        let c: Conf = serde_json::from_str(include_str!("testdata/dynv6.json"))?;
201        let ipa = "1.2.3.4".parse()?;
202        let orig_ipa = "1.1.1.1".parse()?;
203
204        let addrs = lookup(&c.fqdn).await?;
205        assert!(!addrs.contains(&ipa), "ipaddr not deleted {:?}", addrs);
206        assert!(addrs.contains(&orig_ipa), "old ipaddr missing {:?}", addrs);
207
208        info!("adding {ipa}");
209        Dynv6::add_record(&c.token, &c.fqdn, &c.zone, ipa).await?;
210
211        tokio::time::sleep(tokio::time::Duration::from_secs(60)).await;
212        let addrs = lookup(&c.fqdn).await?;
213        assert!(addrs.contains(&ipa), "ipaddr not found {:?}", addrs);
214        assert!(addrs.contains(&orig_ipa), "old ipaddr missing {:?}", addrs);
215
216        info!("deleting {ipa}");
217        Dynv6::delete_record(&c.token, &c.fqdn, &c.zone, ipa).await?;
218
219        tokio::time::sleep(tokio::time::Duration::from_secs(60)).await;
220        let addrs = lookup(&c.fqdn).await?;
221        assert!(!addrs.contains(&ipa), "ipaddr not deleted {:?}", addrs);
222        assert!(addrs.contains(&orig_ipa), "old ipaddr missing {:?}", addrs);
223
224        Ok(())
225    }
226}