zone_update/desec/
mod.rs

1mod types;
2
3use std::fmt::Display;
4
5use serde::{de::DeserializeOwned, Deserialize, Serialize};
6use tracing::{error, info, warn};
7
8use crate::{
9    Config, DnsProvider, RecordType,
10    desec::types::{CreateUpdateRRSet, RRSet},
11    errors::{Error, Result},
12    generate_helpers,
13    http::{self, ResponseToOption, WithHeaders},
14};
15
16const API_BASE: &'static str = "https://desec.io/api/v1";
17
18/// Authentication credentials for the deSEC API.
19///
20/// Contains the API key and secret required for requests.
21#[derive(Clone, Debug, Deserialize)]
22pub struct Auth {
23    pub key: String,
24}
25
26impl Auth {
27    fn get_header(&self) -> String {
28        format!("Token {}", self.key)
29    }
30}
31
32/// Synchronous deSEC DNS provider implementation.
33///
34/// Holds configuration and authentication state for performing API calls.
35pub struct DeSec {
36    config: Config,
37    auth: Auth,
38}
39
40impl DeSec {
41    /// Create a new `deSEC` provider instance.
42    pub fn new(config: Config, auth: Auth) -> Self {
43        Self {
44            config,
45            auth,
46        }
47    }
48
49}
50
51impl DnsProvider for DeSec {
52
53    fn get_record<T>(&self, rtype: RecordType, host: &str) -> Result<Option<T>>
54    where
55        T: DeserializeOwned
56    {
57
58        let url = format!("{API_BASE}/domains/{}/rrsets/{host}/{rtype}/", self.config.domain);
59        let response = http::client().get(url)
60            .with_json_headers()
61            .with_auth(self.auth.get_header())
62            .call()?
63            .to_option::<RRSet<T>>()?;
64
65        let mut rec: RRSet<T> = match response {
66            Some(rec) => rec,
67            None => return Ok(None)
68        };
69
70        let nr = rec.records.len();
71
72        // FIXME: Assumes no or single address (which probably makes sense
73        // for DDNS, but may cause issues with malformed zones.
74        if nr > 1 {
75            error!("Returned number of IPs is {}, should be 1", nr);
76            return Err(Error::UnexpectedRecord(format!("Returned number of IPs is {nr}, should be 1")));
77        } else if nr == 0 {
78            warn!("No IP returned for {host}, continuing");
79            return Ok(None);
80        }
81
82        Ok(Some(rec.records.remove(0)))
83
84    }
85
86    fn create_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
87    where
88        T: Serialize + DeserializeOwned + Display + Clone
89    {
90        let url = format!("{API_BASE}/domains/{}/rrsets/", self.config.domain);
91
92        let record = CreateUpdateRRSet {
93            subname: host.to_string(),
94            rtype,
95            records: vec![record.to_string()],
96            ttl: 3600, // Minimum
97        };
98        if self.config.dry_run {
99            info!("DRY-RUN: Would have sent {record:?} to {url}");
100            return Ok(())
101        }
102
103        let body = serde_json::to_string(&record)?;
104        let _response = http::client().post(url)
105            .with_json_headers()
106            .with_auth(self.auth.get_header())
107            .send(body)?
108            .check_error()?;
109
110        Ok(())
111    }
112
113    fn update_record<T>(&self, rtype: RecordType, host: &str, urec: &T) -> Result<()>
114    where
115        T: Serialize + DeserializeOwned + Display + Clone
116    {
117        let url = format!("{API_BASE}/domains/{}/rrsets/{host}/{rtype}/", self.config.domain);
118
119        let record = CreateUpdateRRSet {
120            subname: host.to_string(),
121            rtype,
122            records: vec![urec.to_string()],
123            ttl: 3600, // Minimum
124        };
125
126        if self.config.dry_run {
127            info!("DRY-RUN: Would have sent {record:?} to {url}");
128            return Ok(())
129        }
130
131        let body = serde_json::to_string(&record)?;
132        let _response = http::client().put(url)
133            .with_json_headers()
134            .with_auth(self.auth.get_header())
135            .send(body)?
136            .check_error()?;
137
138        Ok(())
139    }
140
141    fn delete_record(&self, rtype: RecordType, host: &str) -> Result<()>
142    {
143        let url = format!("{API_BASE}/domains/{}/rrsets/{host}/{rtype}/", self.config.domain);
144        if self.config.dry_run {
145            info!("DRY-RUN: Would have sent DELETE to {url}");
146            return Ok(())
147        }
148
149        http::client().delete(url)
150            .with_json_headers()
151            .with_auth(self.auth.get_header())
152            .call()?;
153
154        Ok(())
155    }
156
157    generate_helpers!();
158
159}
160
161
162#[cfg(test)]
163pub(crate) mod tests {
164    use super::*;
165    use crate::{generate_tests, tests::*};
166    use std::env;
167
168    fn get_client() -> DeSec{
169        let auth = Auth {
170            key: env::var("DESEC_API_KEY").unwrap(),
171        };
172        let config = Config {
173            domain: env::var("DESEC_TEST_DOMAIN").unwrap(),
174            dry_run: false,
175        };
176        DeSec::new(config, auth)
177    }
178
179    generate_tests!("test_desec");
180}