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