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