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)]
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
33pub struct Gandi {
37 config: Config,
38 auth: Auth,
39}
40
41impl Gandi {
42 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 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 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}