rrdnsd/
api_clients.rs

1// rrdnsd - DNS API clients
2// Copyright 2024 Federico Ceratto <federico@debian.org>
3// Released under AGPLv3
4
5use log::{debug, info};
6use reqwest::Client as Reqw;
7use std::net::IpAddr;
8
9pub struct Dynu {
10    token: String,
11    client: Reqw,
12}
13
14use serde::{Deserialize, Serialize};
15
16// // Dynu // //
17
18#[derive(Deserialize, Debug)]
19#[serde(rename_all = "camelCase")]
20pub struct DynuRecord {
21    id: u32,
22    domain_id: u32,
23    //domain_name: String,
24    //node_name: String,
25    //record_type: String,
26    ipv4_address: IpAddr,
27}
28
29#[derive(Deserialize, Debug)]
30#[serde(rename_all = "camelCase")]
31struct DynuRecordsResp {
32    //status_code: u32,
33    dns_records: Vec<DynuRecord>,
34}
35
36#[derive(Deserialize, Debug)]
37struct DynuGetRoot {
38    //statusCode: u32,
39    id: u32,
40    //hostname: String,
41    //domain_name: String,
42    //node: String,
43}
44
45#[derive(Serialize, Debug)]
46#[serde(rename_all = "camelCase")]
47struct DynuAddPayload {
48    group: String,
49    ipv4_address: IpAddr,
50    node_name: String,
51    record_type: String,
52    state: bool,
53    ttl: u32,
54}
55
56fn extract_record_name(fqdn: &str, zone: &str) -> String {
57    fqdn[..(fqdn.len() - zone.len() - 1)].to_owned()
58}
59
60impl Dynu {
61    pub fn new(token: String) -> Self {
62        let client = Reqw::builder()
63            .user_agent("rrdnsd")
64            .pool_max_idle_per_host(0)
65            .build()
66            .unwrap();
67        Dynu { token, client }
68    }
69
70    pub async fn add_record(&self, fqdn: &str, zone: &str, ipaddr: IpAddr) {
71        debug!("[dynu] Fetching domain ID for {fqdn}");
72        let domain_id = {
73            let url = format!("https://api.dynu.com/v2/dns/getroot/{fqdn}");
74            let res = self
75                .client
76                .get(url)
77                .header("API-Key", self.token.clone())
78                .send()
79                .await
80                .unwrap();
81            let r = res.json::<DynuGetRoot>().await.unwrap();
82            r.id
83        };
84        debug!("[dynu] Received domain ID {domain_id}");
85
86        let url = format!("https://api.dynu.com/v2/dns/{domain_id}/record");
87        let payload = DynuAddPayload {
88            group: String::new(),
89            ipv4_address: ipaddr,
90            node_name: extract_record_name(fqdn, zone),
91            record_type: "A".to_owned(),
92            state: true,
93            ttl: 30,
94        };
95        debug!("[dynu] Adding record domain ID {domain_id}");
96        let res = self
97            .client
98            .post(url)
99            .header("API-Key", self.token.clone())
100            .json(&payload)
101            .send()
102            .await
103            .unwrap();
104        let qq = res.text().await.unwrap();
105        let v: serde_json::Value = serde_json::from_str(&qq).unwrap();
106        info!("{v:?}");
107    }
108
109    pub async fn delete_record(&self, fqdn: &str, _zone: &str, ipaddr: IpAddr) {
110        let existing_record: DynuRecord = 'found: {
111            let url = format!("https://api.dynu.com/v2/dns/record/{fqdn}?recordType=A");
112            let res = self
113                .client
114                .get(url)
115                .header("API-Key", self.token.clone())
116                .send()
117                .await
118                .unwrap();
119            let j = res.text().await.unwrap();
120            let r: DynuRecordsResp = serde_json::from_str(&j).unwrap();
121            for rdata in r.dns_records {
122                if rdata.ipv4_address == ipaddr {
123                    break 'found rdata;
124                }
125            }
126            info!("not found");
127            return;
128        };
129        debug!("Found record {existing_record:?}");
130        let url = format!(
131            "https://api.dynu.com/v2/dns/{}/record/{}",
132            existing_record.domain_id, existing_record.id
133        );
134        let res = self
135            .client
136            .delete(url.clone())
137            .header("API-Key", self.token.clone())
138            .header("accept", "application/json")
139            .header("Content-Type", "application/json")
140            .send()
141            .await
142            .unwrap();
143        // FIXME unwrap: retry?
144        let qq = res.text().await.unwrap();
145        let v: serde_json::Value = serde_json::from_str(&qq).unwrap();
146        info!("{v:?}");
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    #[test]
154    fn test_extract_record_name() {
155        assert_eq!(extract_record_name("a.b.c.d", "b.c.d"), "a");
156        assert_eq!(extract_record_name("aaa.b.c.d", "b.c.d"), "aaa");
157    }
158}