Skip to main content

zone_update/linode/
mod.rs

1mod types;
2
3use std::{fmt::{Debug, Display}, sync::Mutex};
4
5use serde::{de::DeserializeOwned, Deserialize, Serialize};
6use tracing::{error, info, warn};
7
8use crate::{
9    errors::{Error, Result}, generate_helpers, http::{self, ResponseToOption, WithHeaders}, linode::types::{CreateUpdate, Domain, List, Record}, Config, DnsProvider, RecordType
10};
11
12const API_BASE: &str = "https://api.linode.com/v4/domains";
13
14/// Authentication credentials for the Linode API.
15///
16/// Contains the API key and secret required for requests.
17#[derive(Clone, Debug, Deserialize)]
18pub struct Auth {
19    pub key: String,
20}
21
22impl Auth {
23    fn get_header(&self) -> String {
24        format!("Bearer {}", self.key)
25    }
26}
27
28
29/// Synchronous Linode DNS provider implementation.
30///
31/// Holds configuration and authentication state for performing API calls.
32pub struct Linode {
33    config: Config,
34    auth: Auth,
35    domain_id: Mutex<Option<u64>>,
36}
37
38impl Linode {
39    /// Create a new `Linode` provider instance.
40    pub fn new(config: Config, auth: Auth) -> Self {
41        Self {
42            config,
43            auth,
44            domain_id: Mutex::new(None),
45        }
46    }
47
48    fn get_domain(&self) -> Result<Domain> {
49        let list = http::client().get(API_BASE)
50            .with_auth(self.auth.get_header())
51            .with_json_headers()
52            .call()?
53            .to_option::<List<Domain>>()?
54            .ok_or(Error::ApiError("No domains returned from upstream".to_string()))?;
55
56        let domain = list.data.into_iter()
57            .find(|d| d.domain == self.config.domain)
58            .ok_or(Error::RecordNotFound(self.config.domain.clone()))?;
59
60        Ok(domain)
61    }
62
63    fn get_domain_id(&self) -> Result<u64> {
64        // This is roughly equivalent to OnceLock.get_or_init(), but
65        // is simpler than dealing with closure->Result and is more
66        // portable.
67        let mut id_p = self.domain_id.lock()
68            .map_err(|e| Error::LockingError(e.to_string()))?;
69
70        if let Some(id) = *id_p {
71            return Ok(id);
72        }
73
74        let id = self.get_domain()?.id;
75        *id_p = Some(id);
76
77        Ok(id)
78    }
79
80    fn get_upstream_records<T>(&self, rtype: &RecordType, host: &str) -> Result<Vec<Record<T>>>
81        where T: DeserializeOwned
82    {
83        let did = self.get_domain_id()?;
84        let url = format!("{API_BASE}/{did}/records");
85
86        let mut response = http::client().get(url)
87            .with_auth(self.auth.get_header())
88            .with_json_headers()
89            .call()?;
90
91        // Linode returns *all* records, with no ability to filter by
92        // type, resulting in a mixed-type array. To work around this
93        // we filter on the raw json values before deserialising
94        // properly.
95        let body = response.body_mut().read_to_string()?;
96        let srtype = rtype.to_string();
97
98        let values: serde_json::Value = serde_json::from_str(&body)?;
99        let data = values["data"].as_array()
100            .ok_or(Error::ApiError("Data field not found".to_string()))?;
101        let records = data.iter()
102            .filter_map(|obj| match &obj["type"] {
103                serde_json::Value::String(t)
104                    if t == &srtype && obj["name"] == host
105                    => Some(serde_json::from_value(obj.clone())),
106                _ => None,
107            })
108            .collect::<std::result::Result<Vec<Record<T>>, _>>()?;
109
110        Ok(records)
111    }
112
113    fn get_upstream_record<T>(&self, rtype: &RecordType, host: &str) -> Result<Option<Record<T>>>
114        where T: DeserializeOwned
115    {
116        let mut recs = self.get_upstream_records(rtype, host)?;
117
118        // FIXME: Assumes no or single address (which probably makes
119        // sense for DDNS and DNS-01, but may cause issues with
120        // malformed zones).
121        let nr = recs.len();
122        if nr > 1 {
123            error!("Returned number of records is {}, should be 1", nr);
124            return Err(Error::UnexpectedRecord(format!("Returned number of records is {nr}, should be 1")));
125        } else if nr == 0 {
126            warn!("No record returned for {host}, continuing");
127            return Ok(None);
128        }
129
130        Ok(Some(recs.remove(0)))
131    }
132
133    fn do_delete(&self, rec: Record<String>) -> Result<()> {
134        let did = self.get_domain_id()?;
135        let url = format!("{API_BASE}/{did}/records/{}", rec.id);
136        if self.config.dry_run {
137            info!("DRY-RUN: Would have sent DELETE to {url}");
138            return Ok(())
139        }
140
141        info!("Deleting DNS {} record {}", rec.rtype, rec.name);
142        http::client().delete(url)
143            .with_auth(self.auth.get_header())
144            .with_json_headers()
145            .call()?;
146
147        Ok(())
148    }
149}
150
151
152impl DnsProvider for Linode {
153
154    fn get_record<T>(&self, rtype: RecordType, host: &str) -> Result<Option<T>>
155    where
156        T: DeserializeOwned
157    {
158         let rec = match self.get_upstream_record(&rtype, host)? {
159            Some(rec) => rec,
160            None => return Ok(None)
161        };
162
163        Ok(Some(rec.target))
164    }
165
166    fn create_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
167    where
168        T: Serialize + DeserializeOwned + Display + Clone
169    {
170        let did = self.get_domain_id()?;
171        let url = format!("{API_BASE}/{did}/records");
172
173        let create = CreateUpdate {
174            name: host.to_string(),
175            rtype,
176            target: record.to_string(),
177            ttl_sec: 300,
178        };
179        if self.config.dry_run {
180            info!("DRY-RUN: Would have sent {create:?} to {url}");
181            return Ok(())
182        }
183
184        let body = serde_json::to_string(&create)?;
185        let _response = http::client().post(url)
186            .with_auth(self.auth.get_header())
187            .with_json_headers()
188            .send(body)?
189            .check_error()?;
190
191        Ok(())
192    }
193
194    fn update_record<T>(&self, rtype: RecordType, host: &str, urec: &T) -> Result<()>
195    where
196        T: Serialize + DeserializeOwned + Display + Clone
197    {
198        let did = self.get_domain_id()?;
199        let rec: Record<T> = self.get_upstream_record(&rtype, host)?
200            .ok_or(Error::RecordNotFound(host.to_string()))?;
201        let url = format!("{API_BASE}/{did}/records/{}", rec.id);
202
203        let update = CreateUpdate {
204            name: host.to_string(),
205            rtype,
206            target: urec.to_string(),
207            ttl_sec: 300,
208        };
209
210        if self.config.dry_run {
211            info!("DRY-RUN: Would have sent {update:?} to {url}");
212            return Ok(())
213        }
214
215        let body = serde_json::to_string(&update)?;
216        let _response = http::client().put(url)
217            .with_auth(self.auth.get_header())
218            .with_json_headers()
219            .send(body)?
220            .check_error()?;
221
222        Ok(())
223    }
224
225    fn delete_record(&self, rtype: RecordType, host: &str) -> Result<()>
226    {
227        let rec = match self.get_upstream_record(&rtype, host)? {
228            Some(rec) => rec,
229            None => {
230                warn!("No {rtype} record to delete for {host}");
231                return Ok(());
232            }
233        };
234
235        self.do_delete(rec)
236    }
237
238    fn delete_all_records(&self, rtype: RecordType, host: &str) -> Result<()>
239    {
240        let recs: Vec<Record<String>> = self.get_upstream_records(&rtype, host)?;
241        for rec in recs {
242            self.do_delete(rec)?;
243        }
244
245        Ok(())
246    }
247
248    generate_helpers!();
249
250}
251
252
253#[cfg(test)]
254pub(crate) mod tests {
255    use super::*;
256    use crate::{generate_tests, tests::*};
257    use std::env;
258
259    fn get_client() -> Linode {
260        let auth = Auth {
261            key: env::var("LINODE_API_KEY").unwrap(),
262        };
263        let config = Config {
264            domain: env::var("LINODE_TEST_DOMAIN").unwrap(),
265            dry_run: false,
266        };
267        Linode::new(config, auth)
268    }
269
270    generate_tests!("test_linode");
271}