zone_edit/gandi/
mod.rs

1mod types;
2
3use std::{fmt::Display};
4use serde::{de::DeserializeOwned, Serialize};
5use tracing::{error, info, warn};
6
7use types::{Record, RecordUpdate};
8use ureq::http::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE};
9use crate::{errors::{Error, Result}, http::{self, ResponseToOption, WithHeaders}, Config, DnsProvider, RecordType};
10
11pub(crate) const 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
27pub struct Gandi {
28    config: Config,
29    auth: Auth,
30}
31
32impl Gandi {
33    pub fn new(config: Config, auth: Auth) -> Self {
34        Gandi {
35            config,
36            auth,
37        }
38    }
39}
40
41impl DnsProvider for Gandi {
42
43    fn get_record<T>(&self, rtype: RecordType, host: &str) -> Result<Option<T>>
44    where
45        T: DeserializeOwned
46    {
47
48        let url = format!("{API_BASE}/domains/{}/records/{host}/{rtype}", self.config.domain);
49        let response = http::client().get(url)
50            .with_json_headers()
51            .with_auth(self.auth.get_header())
52            .call()?
53            .to_option::<Record<T>>()?;
54
55        let mut rec: Record<T> = match response {
56            Some(rec) => rec,
57            None => return Ok(None)
58        };
59
60        let nr = rec.rrset_values.len();
61
62        // FIXME: Assumes no or single address (which probably makes sense
63        // for DDNS, but may cause issues with malformed zones.
64        if nr > 1 {
65            error!("Returned number of IPs is {}, should be 1", nr);
66            return Err(Error::UnexpectedRecord(format!("Returned number of IPs is {nr}, should be 1")));
67        } else if nr == 0 {
68            warn!("No IP returned for {host}, continuing");
69            return Ok(None);
70        }
71
72        Ok(Some(rec.rrset_values.remove(0)))
73
74    }
75
76    fn create_record<T>(&self, rtype: RecordType, host: &str, rec: &T) -> Result<()>
77    where
78        T: Serialize + DeserializeOwned + Display + Clone
79    {
80        // PUT works for both operations
81        self.update_record(rtype, host, rec)
82    }
83
84    fn update_record<T>(&self, rtype: RecordType, host: &str, ip: &T) -> Result<()>
85    where
86        T: Serialize + DeserializeOwned + Display + Clone
87    {
88        let url = format!("{API_BASE}/domains/{}/records/{host}/{rtype}", self.config.domain);
89        if self.config.dry_run {
90            info!("DRY-RUN: Would have sent PUT to {url}");
91            return Ok(())
92        }
93
94        let update = RecordUpdate {
95            rrset_values: vec![(*ip).clone()],
96            rrset_ttl: Some(300),
97        };
98
99        let body = serde_json::to_string(&update)?;
100        http::client().put(url)
101            .with_json_headers()
102            .with_auth(self.auth.get_header())
103            .send(body)?;
104
105        Ok(())
106    }
107
108     fn delete_record(&self, rtype: RecordType, host: &str) -> Result<()> {
109        let url = format!("{API_BASE}/domains/{}/records/{host}/{rtype}", self.config.domain);
110
111        if self.config.dry_run {
112            info!("DRY-RUN: Would have sent DELETE to {url}");
113            return Ok(())
114        }
115
116        http::client().delete(url)
117            .with_json_headers()
118            .with_auth(self.auth.get_header())
119            .call()?;
120
121        Ok(())
122    }
123
124}
125
126#[cfg(test)]
127mod tests {
128    use crate::{generate_tests, strip_quotes};
129
130    use super::*;
131    use crate::tests::*;
132    use std::{env, net::Ipv4Addr};
133    use random_string::charsets::ALPHANUMERIC;
134
135    fn get_client() -> Gandi {
136        let auth = if let Some(key) = env::var("GANDI_APIKEY").ok() {
137            Auth::ApiKey(key)
138        } else if let Some(key) = env::var("GANDI_PATKEY").ok() {
139            Auth::PatKey(key)
140        } else {
141            panic!("No Gandi auth key set");
142        };
143
144        let config = Config {
145            domain: env::var("GANDI_TEST_DOMAIN").unwrap(),
146            dry_run: false,
147        };
148
149        Gandi {
150            config,
151            auth,
152        }
153    }
154
155
156    generate_tests!("test_gandi");
157
158}