zone_update/linode/
mod.rs

1mod types;
2
3use std::{fmt::{Debug, Display}, sync::Mutex};
4
5use serde::{de::DeserializeOwned, Deserialize, Serialize};
6use tracing::{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: &'static 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            .filter(|d| d.domain == self.config.domain)
58            .next()
59            .ok_or(Error::RecordNotFound(self.config.domain.clone()))?;
60
61        Ok(domain)
62    }
63
64    fn get_domain_id(&self) -> Result<u64> {
65        // This is roughly equivalent to OnceLock.get_or_init(), but
66        // is simpler than dealing with closure->Result and is more
67        // portable.
68        let mut id_p = self.domain_id.lock()
69            .map_err(|e| Error::LockingError(e.to_string()))?;
70
71        if let Some(id) = *id_p {
72            return Ok(id);
73        }
74
75        let id = self.get_domain()?.id;
76        *id_p = Some(id);
77
78        Ok(id)
79    }
80
81    fn get_upstream_record<T>(&self, rtype: &RecordType, host: &str) -> Result<Option<Record<T>>>
82        where T: DeserializeOwned
83    {
84        let did = self.get_domain_id()?;
85        let url = format!("{API_BASE}/{did}/records");
86
87        let mut response = http::client().get(url)
88            .with_auth(self.auth.get_header())
89            .with_json_headers()
90            .call()?;
91
92        // Linode returns *all* records, with no ability to filter by
93        // type, resulting in a mixed-type array. To work around this
94        // we filter on the raw json values before deserialising
95        // properly.
96        let body = response.body_mut().read_to_string()?;
97        let srtype = rtype.to_string();
98
99        let values: serde_json::Value = serde_json::from_str(&body)?;
100        let data = values["data"].as_array()
101            .ok_or(Error::ApiError("Data field not found".to_string()))?;
102        let record = data.into_iter()
103            .filter_map(|obj| match &obj["type"] {
104                serde_json::Value::String(t)
105                    if t == &srtype && obj["name"] == host
106                    => Some(serde_json::from_value(obj.clone())),
107                _ => None,
108            })
109            .next()
110            .transpose()?;
111
112        Ok(record)
113    }
114
115    fn get_record_id(&self, rtype: &RecordType, host: &str) -> Result<Option<u64>>
116    {
117        Ok(self.get_upstream_record::<String>(rtype, host)?
118           .map(|r| r.id))
119    }
120}
121
122
123impl DnsProvider for Linode {
124
125    fn get_record<T>(&self, rtype: RecordType, host: &str) -> Result<Option<T>>
126    where
127        T: DeserializeOwned
128    {
129         let rec = match self.get_upstream_record(&rtype, host)? {
130            Some(rec) => rec,
131            None => return Ok(None)
132        };
133
134        Ok(Some(rec.target))
135    }
136
137    fn create_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
138    where
139        T: Serialize + DeserializeOwned + Display + Clone
140    {
141        let did = self.get_domain_id()?;
142        let url = format!("{API_BASE}/{did}/records");
143
144        let create = CreateUpdate {
145            name: host.to_string(),
146            rtype,
147            target: record.to_string(),
148            ttl_sec: 300,
149        };
150        if self.config.dry_run {
151            info!("DRY-RUN: Would have sent {create:?} to {url}");
152            return Ok(())
153        }
154
155        let body = serde_json::to_string(&create)?;
156        let _response = http::client().post(url)
157            .with_auth(self.auth.get_header())
158            .with_json_headers()
159            .send(body)?
160            .check_error()?;
161
162        Ok(())
163    }
164
165    fn update_record<T>(&self, rtype: RecordType, host: &str, urec: &T) -> Result<()>
166    where
167        T: Serialize + DeserializeOwned + Display + Clone
168    {
169        let did = self.get_domain_id()?;
170        let id = self.get_record_id(&rtype, host)?
171            .ok_or(Error::RecordNotFound(host.to_string()))?;
172        let url = format!("{API_BASE}/{did}/records/{id}");
173
174        let update = CreateUpdate {
175            name: host.to_string(),
176            rtype,
177            target: urec.to_string(),
178            ttl_sec: 300,
179        };
180
181        if self.config.dry_run {
182            info!("DRY-RUN: Would have sent {update:?} to {url}");
183            return Ok(())
184        }
185
186        let body = serde_json::to_string(&update)?;
187        let _response = http::client().put(url)
188            .with_auth(self.auth.get_header())
189            .with_json_headers()
190            .send(body)?
191            .check_error()?;
192
193        Ok(())
194    }
195
196    fn delete_record(&self, rtype: RecordType, host: &str) -> Result<()>
197    {
198        let id = match self.get_record_id(&rtype, host)? {
199            Some(id) => id,
200            None => {
201                warn!("No {rtype} record to delete for {host}");
202                return Ok(());
203            }
204        };
205
206        let did = self.get_domain_id()?;
207        let url = format!("{API_BASE}/{did}/records/{id}");
208        if self.config.dry_run {
209            info!("DRY-RUN: Would have sent DELETE to {url}");
210            return Ok(())
211        }
212
213        http::client().delete(url)
214            .with_auth(self.auth.get_header())
215            .with_json_headers()
216            .call()?;
217
218        Ok(())
219    }
220
221    generate_helpers!();
222
223}
224
225
226#[cfg(test)]
227pub(crate) mod tests {
228    use super::*;
229    use crate::{generate_tests, tests::*};
230    use std::env;
231
232    fn get_client() -> Linode {
233        let auth = Auth {
234            key: env::var("LINODE_API_KEY").unwrap(),
235        };
236        let config = Config {
237            domain: env::var("LINODE_TEST_DOMAIN").unwrap(),
238            dry_run: false,
239        };
240        Linode::new(config, auth)
241    }
242
243    generate_tests!("test_linode");
244}