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 async fn add_record(token: &str, fqdn: &str, zone: &str, ipaddr: IpAddr) -> Result<()> {
99 let trim = format!(".{zone}");
100 let record = fqdn.strip_suffix(&trim).context(format!(
101 "Unable to extract record from FQDN {fqdn} and zone {zone}"
102 ))?;
103 let reqw = new_reqw(token)?;
104 let zone_id = fetch_zone_id(&reqw, zone).await?;
105
106 let rtype = record_type(ipaddr);
107 debug!("[dynv6] Adding {rtype} record {record} -> {ipaddr}");
108
109 let payload = RecordPayload {
110 name: record.to_owned(),
111 data: ipaddr.to_string(),
112 record_type: rtype.to_string(),
113 };
114 let url = format!("{}/zones/{}/records", API_BASEURL, zone_id);
115 let res = reqw
116 .post(&url)
117 .header("Content-Type", "application/json")
118 .json(&payload)
119 .send()
120 .await?;
121
122 if !res.status().is_success() {
123 let status = res.status();
124 let body = res.text().await.unwrap_or_default();
125 bail!("Failed to add record: HTTP {status}, body: {body}");
126 }
127
128 let response: serde_json::Value = res.json().await?;
129 info!("[dynv6] Add record response: {response:?}");
130
131 Ok(())
132 }
133
134 async fn delete_record(token: &str, fqdn: &str, zone: &str, ipaddr: IpAddr) -> Result<()> {
135 let trim = format!(".{zone}");
136 let record = fqdn.strip_suffix(&trim).context(format!(
137 "Unable to extract record from FQDN {fqdn} and zone {zone}"
138 ))?;
139 let reqw = new_reqw(token)?;
140
141 let zone_id = fetch_zone_id(&reqw, zone).await?;
142
143 let record_id = fetch_record_id(&reqw, zone_id, record, ipaddr).await?;
144
145 debug!("[dynv6] Found record ID {record_id}");
146 let url = format!("{API_BASEURL}/zones/{zone_id}/records/{record_id}");
147 let res = reqw.delete(&url).send().await?;
148
149 if !res.status().is_success() {
150 let status = res.status();
151 let body = res.text().await.unwrap_or_default();
152 bail!("Failed to delete record: HTTP {status}, body: {body}");
153 }
154
155 info!("[dynv6] Deleted record {record} in {zone}");
156
157 Ok(())
158 }
159}
160
161#[cfg(test)]
164mod tests {
165 use crate::providers::{Dynv6, SetProvider};
166 use anyhow::{bail, Result};
167 use log::info;
168 use std::net::IpAddr;
169 use tokio::process::Command;
170
171 async fn lookup(fqdn: &str) -> Result<Vec<IpAddr>> {
172 let p = Command::new("dig")
173 .args(["+short", fqdn, "@ns1.dynv6.com"])
174 .output()
175 .await?;
176 if !p.status.success() {
177 bail!("Unable to run dig");
178 }
179 let stdout = String::from_utf8_lossy(&p.stdout);
180 let addrs: Vec<IpAddr> = stdout
181 .lines()
182 .filter_map(|line| line.trim().parse().ok())
183 .collect();
184
185 info!("dig {fqdn} -> {addrs:?}");
186 Ok(addrs)
187 }
188 #[derive(serde::Deserialize)]
189 struct Conf {
190 token: String,
191 zone: String,
192 fqdn: String,
193 }
194
195 #[test_log::test(tokio::test)]
196 #[ignore]
197 async fn test_integ_dynv6_add_delete() -> Result<()> {
198 let c: Conf = serde_json::from_str(include_str!("testdata/dynv6.json"))?;
199 let ipa = "1.2.3.4".parse()?;
200 let orig_ipa = "1.1.1.1".parse()?;
201
202 let addrs = lookup(&c.fqdn).await?;
203 assert!(!addrs.contains(&ipa), "ipaddr not deleted {:?}", addrs);
204 assert!(addrs.contains(&orig_ipa), "old ipaddr missing {:?}", addrs);
205
206 info!("adding {ipa}");
207 Dynv6::add_record(&c.token, &c.fqdn, &c.zone, ipa).await?;
208
209 tokio::time::sleep(tokio::time::Duration::from_secs(60)).await;
210 let addrs = lookup(&c.fqdn).await?;
211 assert!(addrs.contains(&ipa), "ipaddr not found {:?}", addrs);
212 assert!(addrs.contains(&orig_ipa), "old ipaddr missing {:?}", addrs);
213
214 info!("deleting {ipa}");
215 Dynv6::delete_record(&c.token, &c.fqdn, &c.zone, ipa).await?;
216
217 tokio::time::sleep(tokio::time::Duration::from_secs(60)).await;
218 let addrs = lookup(&c.fqdn).await?;
219 assert!(!addrs.contains(&ipa), "ipaddr not deleted {:?}", addrs);
220 assert!(addrs.contains(&orig_ipa), "old ipaddr missing {:?}", addrs);
221
222 Ok(())
223 }
224}