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