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 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 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}