zone_edit/gandi/
mod.rs

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