zone_update/gandi/
mod.rs

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