zone_edit/gandi/
mod.rs

1#![allow(unused)]
2
3mod types;
4
5use std::{fmt::Display};
6use serde::{de::DeserializeOwned, Serialize};
7use tracing::{error, info, warn};
8
9use types::{Record, RecordUpdate};
10use crate::{errors::{Error, Result}, http, Config, DnsProvider, RecordType};
11
12const API_BASE: &str = "https://api.gandi.net/v5/livedns";
13
14pub enum Auth {
15    ApiKey(String),
16    PatKey(String),
17}
18
19impl Auth {
20    fn get_header(&self) -> String {
21        match self {
22            Auth::ApiKey(key) => format!("Apikey {key}"),
23            Auth::PatKey(key) => format!("Bearer {key}"),
24        }
25    }
26}
27
28
29pub struct Gandi {
30    pub config: Config,
31    pub auth: Auth,
32}
33
34impl Gandi {
35    pub fn new(config: Config, auth: Auth) -> Self {
36        Gandi {
37            config,
38            auth,
39        }
40    }
41}
42
43impl DnsProvider for Gandi {
44
45    async fn get_record<T>(&self, rtype: RecordType, host: &str) -> Result<Option<T>>
46    where
47        T: DeserializeOwned
48    {
49        let url = format!("{API_BASE}/domains/{}/records/{host}/{rtype}", self.config.domain)
50            .parse()
51            .map_err(|e| Error::UrlError(format!("Error: {e}")))?;
52        let auth = self.auth.get_header();
53        let mut rec: Record<T> = match http::get(url, Some(auth)).await? {
54            Some(rec) => rec,
55            None => return Ok(None)
56        };
57
58        let nr = rec.rrset_values.len();
59
60        // FIXME: Assumes no or single address (which probably makes sense
61        // for DDNS, but may cause issues with malformed zones.
62        if nr > 1 {
63            error!("Returned number of IPs is {}, should be 1", nr);
64            return Err(Error::UnexpectedRecord(format!("Returned number of IPs is {nr}, should be 1")));
65        } else if nr == 0 {
66            warn!("No IP returned for {host}, continuing");
67            return Ok(None);
68        }
69
70        Ok(Some(rec.rrset_values.remove(0)))
71
72    }
73
74    async fn create_record<T>(&self, rtype: RecordType, host: &str, rec: &T) -> Result<()>
75    where
76        T: Serialize + DeserializeOwned + Display + Clone + Send + Sync
77    {
78        // PUT works for both operations
79        self.update_record(rtype, host, rec).await
80    }
81
82    async fn update_record<T>(&self, rtype: RecordType, host: &str, ip: &T) -> Result<()>
83    where
84        T: Serialize + DeserializeOwned + Display + Clone + Send + Sync
85    {
86        let url = format!("{API_BASE}/domains/{}/records/{host}/{rtype}", self.config.domain)
87            .parse()
88            .map_err(|e| Error::UrlError(format!("Error: {e}")))?;
89        let auth = self.auth.get_header();
90
91        let update = RecordUpdate {
92            rrset_values: vec![(*ip).clone()],
93            rrset_ttl: Some(300),
94        };
95        if self.config.dry_run {
96            info!("DRY-RUN: Would have sent PUT to {url}");
97            return Ok(())
98        }
99        http::put::<RecordUpdate<T>>(url, &update, Some(auth)).await?;
100        Ok(())
101    }
102
103    async  fn delete_record(&self, rtype: RecordType, host: &str) -> Result<()> {
104        let url = format!("{API_BASE}/domains/{}/records/{host}/{rtype}", self.config.domain)
105            .parse()
106            .map_err(|e| Error::UrlError(format!("Error: {e}")))?;
107        let auth = self.auth.get_header();
108
109        if self.config.dry_run {
110            info!("DRY-RUN: Would have sent DELETE to {url}");
111            return Ok(())
112        }
113        http::delete(url, Some(auth)).await?;
114
115        Ok(())
116    }
117
118}
119
120#[cfg(test)]
121mod tests {
122    use crate::strip_quotes;
123
124    use super::*;
125    use std::{env, net::Ipv4Addr};
126    use macro_rules_attribute::apply;
127    use random_string::charsets::ALPHANUMERIC;
128    use tracing_test::traced_test;
129
130    fn get_client() -> Gandi {
131        let auth = if let Some(key) = env::var("GANDI_APIKEY").ok() {
132            Auth::ApiKey(key)
133        } else if let Some(key) = env::var("GANDI_PATKEY").ok() {
134            Auth::PatKey(key)
135        } else {
136            panic!("No Gandi auth key set");
137        };
138
139        let config = Config {
140            domain: env::var("GANDI_TEST_DOMAIN").unwrap(),
141            dry_run: false,
142        };
143
144        Gandi {
145            config,
146            auth,
147        }
148    }
149
150
151    // TODO: This is generic, we could move it up to top-level testing.
152    async fn test_create_update_delete_ipv4() -> Result<()> {
153        let client = get_client();
154
155        let host = random_string::generate(16, ALPHANUMERIC);
156
157        // Create
158        let ip: Ipv4Addr = "1.1.1.1".parse()?;
159        client.create_record(RecordType::A, &host, &ip).await?;
160        let cur = client.get_record(RecordType::A, &host).await?;
161        assert_eq!(Some(ip), cur);
162
163
164        // Update
165        let ip: Ipv4Addr = "2.2.2.2".parse()?;
166        client.update_record(RecordType::A, &host, &ip).await?;
167        let cur = client.get_record(RecordType::A, &host).await?;
168        assert_eq!(Some(ip), cur);
169
170
171        // Delete
172        client.delete_record(RecordType::A, &host).await?;
173        let del: Option<Ipv4Addr> = client.get_record(RecordType::A, &host).await?;
174        assert!(del.is_none());
175
176        Ok(())
177    }
178
179    async fn test_create_update_delete_txt() -> Result<()> {
180        let client = get_client();
181
182        let host = random_string::generate(16, ALPHANUMERIC);
183
184        // Create
185        let txt = "a text reference".to_string();
186        client.create_record(RecordType::TXT, &host, &txt).await?;
187        let cur: Option<String> = client.get_record(RecordType::TXT, &host).await?;
188        assert_eq!(txt, strip_quotes(&cur.unwrap()));
189
190
191        // Update
192        let txt = "another text reference".to_string();
193        client.update_record(RecordType::TXT, &host, &txt).await?;
194        let cur: Option<String> = client.get_record(RecordType::TXT, &host).await?;
195        assert_eq!(txt, strip_quotes(&cur.unwrap()));
196
197
198        // Delete
199        client.delete_record(RecordType::TXT, &host).await?;
200        let del: Option<String> = client.get_record(RecordType::TXT, &host).await?;
201        assert!(del.is_none());
202
203        Ok(())
204    }
205
206
207    #[cfg(feature = "smol")]
208    mod smol_tests {
209        use super::*;
210        use macro_rules_attribute::apply;
211        use smol_macros::test;
212
213        #[apply(test!)]
214        #[traced_test]
215        #[cfg_attr(not(feature = "test_gandi"), ignore = "Gandi API test")]
216        async fn smol_create_update_a() -> Result<()> {
217            test_create_update_delete_ipv4().await?;
218            Ok(())
219        }
220
221        #[apply(test!)]
222        #[traced_test]
223        #[cfg_attr(not(feature = "test_gandi"), ignore = "Gandi API test")]
224        async fn smol_create_update_txt() -> Result<()> {
225            test_create_update_delete_ipv4().await?;
226            Ok(())
227        }
228    }
229
230    #[cfg(feature = "tokio")]
231    mod tokio_tests {
232        use super::*;
233
234        #[tokio::test]
235        #[traced_test]
236        #[cfg_attr(not(feature = "test_dnsimple"), ignore = "DnSimple API test")]
237        async fn tokio_create_update() -> Result<()> {
238            test_create_update_delete_ipv4().await?;
239            test_create_update_delete_txt().await?;
240            Ok(())
241        }
242    }
243
244}