1use 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#[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, }
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}