rrdnsd/providers/
dynv6.rs1use 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
58async 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#[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}