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