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