1use crate::{strip_origin_from_name, DnsRecord, Error, IntoFqdn};
13use reqwest::Method;
14use serde::Serialize;
15use sha1::{Digest, Sha1};
16use std::time::{Duration, SystemTime, UNIX_EPOCH};
17
18#[derive(Clone)]
19pub struct OvhProvider {
20 application_key: String,
21 application_secret: String,
22 consumer_key: String,
23 pub(crate) endpoint: String,
24 timeout: Duration,
25}
26
27#[derive(Serialize, Debug)]
28pub struct CreateDnsRecordParams {
29 #[serde(rename = "fieldType")]
30 pub field_type: String,
31 #[serde(rename = "subDomain")]
32 pub sub_domain: String,
33 pub target: String,
34 pub ttl: u32,
35}
36
37#[derive(Serialize, Debug)]
38pub struct UpdateDnsRecordParams {
39 pub target: String,
40 pub ttl: u32,
41}
42
43#[derive(Debug)]
44pub struct OvhRecordFormat {
45 pub field_type: String,
46 pub target: String,
47}
48
49#[derive(Debug)]
50pub enum OvhEndpoint {
51 OvhEu,
52 OvhCa,
53 KimsufiEu,
54 KimsufiCa,
55 SoyoustartEu,
56 SoyoustartCa,
57}
58
59impl OvhEndpoint {
60 fn api_url(&self) -> &'static str {
61 match self {
62 OvhEndpoint::OvhEu => "https://eu.api.ovh.com/1.0",
63 OvhEndpoint::OvhCa => "https://ca.api.ovh.com/1.0",
64 OvhEndpoint::KimsufiEu => "https://eu.api.kimsufi.com/1.0",
65 OvhEndpoint::KimsufiCa => "https://ca.api.kimsufi.com/1.0",
66 OvhEndpoint::SoyoustartEu => "https://eu.api.soyoustart.com/1.0",
67 OvhEndpoint::SoyoustartCa => "https://ca.api.soyoustart.com/1.0",
68 }
69 }
70}
71
72impl std::str::FromStr for OvhEndpoint {
73 type Err = Error;
74
75 fn from_str(s: &str) -> Result<Self, Self::Err> {
76 match s {
77 "ovh-eu" => Ok(OvhEndpoint::OvhEu),
78 "ovh-ca" => Ok(OvhEndpoint::OvhCa),
79 "kimsufi-eu" => Ok(OvhEndpoint::KimsufiEu),
80 "kimsufi-ca" => Ok(OvhEndpoint::KimsufiCa),
81 "soyoustart-eu" => Ok(OvhEndpoint::SoyoustartEu),
82 "soyoustart-ca" => Ok(OvhEndpoint::SoyoustartCa),
83 _ => Err(Error::Parse(format!("Invalid OVH endpoint: {}", s))),
84 }
85 }
86}
87
88impl From<&DnsRecord> for OvhRecordFormat {
89 fn from(record: &DnsRecord) -> Self {
90 match record {
91 DnsRecord::A { content } => OvhRecordFormat {
92 field_type: "A".to_string(),
93 target: content.to_string(),
94 },
95 DnsRecord::AAAA { content } => OvhRecordFormat {
96 field_type: "AAAA".to_string(),
97 target: content.to_string(),
98 },
99 DnsRecord::CNAME { content } => OvhRecordFormat {
100 field_type: "CNAME".to_string(),
101 target: content.clone(),
102 },
103 DnsRecord::NS { content } => OvhRecordFormat {
104 field_type: "NS".to_string(),
105 target: content.clone(),
106 },
107 DnsRecord::MX { content, priority } => OvhRecordFormat {
108 field_type: "MX".to_string(),
109 target: format!("{} {}", priority, content),
110 },
111 DnsRecord::TXT { content } => OvhRecordFormat {
112 field_type: "TXT".to_string(),
113 target: content.clone(),
114 },
115 DnsRecord::SRV {
116 content,
117 priority,
118 weight,
119 port,
120 } => OvhRecordFormat {
121 field_type: "SRV".to_string(),
122 target: format!("{} {} {} {}", priority, weight, port, content),
123 },
124 }
125 }
126}
127
128impl OvhProvider {
129 pub(crate) fn new(
130 application_key: impl AsRef<str>,
131 application_secret: impl AsRef<str>,
132 consumer_key: impl AsRef<str>,
133 endpoint: OvhEndpoint,
134 timeout: Option<Duration>,
135 ) -> crate::Result<Self> {
136 Ok(Self {
137 application_key: application_key.as_ref().to_string(),
138 application_secret: application_secret.as_ref().to_string(),
139 consumer_key: consumer_key.as_ref().to_string(),
140 endpoint: endpoint.api_url().to_string(),
141 timeout: timeout.unwrap_or(Duration::from_secs(30)),
142 })
143 }
144
145 fn generate_signature(&self, method: &str, url: &str, body: &str, timestamp: u64) -> String {
146 let data = format!(
147 "{}+{}+{}+{}+{}+{}",
148 self.application_secret, self.consumer_key, method, url, body, timestamp
149 );
150
151 let mut hasher = Sha1::new();
152 hasher.update(data.as_bytes());
153 let hash = hasher.finalize();
154 let hex_string = hash
155 .iter()
156 .map(|b| format!("{:02x}", b))
157 .collect::<String>();
158 format!("$1${}", hex_string)
159 }
160
161 async fn send_authenticated_request(
162 &self,
163 method: Method,
164 url: &str,
165 body: &str,
166 ) -> crate::Result<reqwest::Response> {
167 let timestamp = SystemTime::now()
168 .duration_since(UNIX_EPOCH)
169 .map_err(|e| Error::Client(format!("Failed to get timestamp: {}", e)))?
170 .as_secs();
171
172 let signature = self.generate_signature(method.as_str(), url, body, timestamp);
173
174 let client = reqwest::Client::builder()
175 .timeout(self.timeout)
176 .build()
177 .map_err(|e| Error::Client(format!("Failed to create HTTP client: {}", e)))?;
178 let mut request = client
179 .request(method, url)
180 .header("X-Ovh-Application", &self.application_key)
181 .header("X-Ovh-Consumer", &self.consumer_key)
182 .header("X-Ovh-Signature", signature)
183 .header("X-Ovh-Timestamp", timestamp.to_string())
184 .header("Content-Type", "application/json");
185
186 if !body.is_empty() {
187 request = request.body(body.to_string());
188 }
189
190 request
191 .send()
192 .await
193 .map_err(|e| Error::Api(format!("Failed to send request: {}", e)))
194 }
195
196 async fn get_zone_name(&self, origin: impl IntoFqdn<'_>) -> crate::Result<String> {
197 let domain = origin.into_name();
198 let domain_name = domain.trim_end_matches('.');
199
200 let url = format!("{}/domain/zone/{}", self.endpoint, domain_name);
201 let response = self
202 .send_authenticated_request(Method::GET, &url, "")
203 .await?;
204
205 if response.status().is_success() {
206 Ok(domain_name.to_string())
207 } else {
208 Err(Error::Api(format!(
209 "Zone {} not found or not accessible",
210 domain_name
211 )))
212 }
213 }
214
215 async fn get_record_id(
216 &self,
217 zone: &str,
218 name: impl IntoFqdn<'_>,
219 record_type: &str,
220 ) -> crate::Result<u64> {
221 let name = name.into_name();
222 let subdomain = strip_origin_from_name(&name, zone);
223 let subdomain = if subdomain == "@" { "" } else { &subdomain };
224
225 let url = format!(
226 "{}/domain/zone/{}/record?fieldType={}&subDomain={}",
227 self.endpoint, zone, record_type, subdomain
228 );
229
230 let response = self
231 .send_authenticated_request(Method::GET, &url, "")
232 .await?;
233
234 if !response.status().is_success() {
235 return Err(Error::Api(format!(
236 "Failed to list records: HTTP {}",
237 response.status()
238 )));
239 }
240
241 let record_ids: Vec<u64> = serde_json::from_slice(
242 response
243 .bytes()
244 .await
245 .map_err(|e| Error::Api(format!("Failed to fetch record list: {}", e)))?
246 .as_ref(),
247 )
248 .map_err(|e| Error::Api(format!("Failed to parse record list: {}", e)))?;
249
250 record_ids.into_iter().next().ok_or(Error::NotFound)
251 }
252
253 pub(crate) async fn create(
254 &self,
255 name: impl IntoFqdn<'_>,
256 record: DnsRecord,
257 ttl: u32,
258 origin: impl IntoFqdn<'_>,
259 ) -> crate::Result<()> {
260 let zone = self.get_zone_name(origin).await?;
261 let name = name.into_name();
262 let subdomain = strip_origin_from_name(&name, &zone);
263 let subdomain = if subdomain == "@" {
264 String::new()
265 } else {
266 subdomain
267 };
268
269 let ovh_record: OvhRecordFormat = (&record).into();
270 let (field_type, target) = (ovh_record.field_type, ovh_record.target);
271
272 let params = CreateDnsRecordParams {
273 field_type,
274 sub_domain: subdomain,
275 target,
276 ttl,
277 };
278
279 let body = serde_json::to_string(¶ms)
280 .map_err(|e| Error::Serialize(format!("Failed to serialize record: {}", e)))?;
281
282 let url = format!("{}/domain/zone/{}/record", self.endpoint, zone);
283 let response = self
284 .send_authenticated_request(Method::POST, &url, &body)
285 .await?;
286
287 if !response.status().is_success() {
288 let status = response.status();
289 let error_text = response
290 .text()
291 .await
292 .unwrap_or_else(|_| "Unknown error".to_string());
293 return Err(Error::Api(format!(
294 "Failed to create record: HTTP {} - {}",
295 status, error_text
296 )));
297 }
298
299 let url = format!("{}/domain/zone/{}/refresh", self.endpoint, zone);
300 let _response = self
301 .send_authenticated_request(Method::POST, &url, "")
302 .await
303 .map_err(|e| {
304 Error::Api(format!(
305 "Failed to refresh zone (record created but zone not refreshed): {:?}",
306 e
307 ))
308 })?;
309
310 Ok(())
311 }
312
313 pub(crate) async fn update(
314 &self,
315 name: impl IntoFqdn<'_>,
316 record: DnsRecord,
317 ttl: u32,
318 origin: impl IntoFqdn<'_>,
319 ) -> crate::Result<()> {
320 let zone = self.get_zone_name(origin).await?;
321 let name = name.into_name();
322
323 let ovh_record: OvhRecordFormat = (&record).into();
324 let (field_type, target) = (ovh_record.field_type, ovh_record.target);
325
326 let record_id = self
327 .get_record_id(&zone, name.as_ref(), &field_type)
328 .await?;
329
330 let params = UpdateDnsRecordParams { target, ttl };
331
332 let body = serde_json::to_string(¶ms)
333 .map_err(|e| Error::Serialize(format!("Failed to serialize record: {}", e)))?;
334
335 let url = format!(
336 "{}/domain/zone/{}/record/{}",
337 self.endpoint, zone, record_id
338 );
339 let response = self
340 .send_authenticated_request(Method::PUT, &url, &body)
341 .await?;
342
343 if !response.status().is_success() {
344 let status = response.status();
345 let error_text = response
346 .text()
347 .await
348 .unwrap_or_else(|_| "Unknown error".to_string());
349 return Err(Error::Api(format!(
350 "Failed to update record: HTTP {} - {}",
351 status, error_text
352 )));
353 }
354
355 let url = format!("{}/domain/zone/{}/refresh", self.endpoint, zone);
356 let _response = self
357 .send_authenticated_request(Method::POST, &url, "")
358 .await
359 .map_err(|e| {
360 Error::Api(format!(
361 "Failed to refresh zone (record updated but zone not refreshed): {:?}",
362 e
363 ))
364 })?;
365
366 Ok(())
367 }
368
369 pub(crate) async fn delete(
370 &self,
371 name: impl IntoFqdn<'_>,
372 origin: impl IntoFqdn<'_>,
373 record_type: crate::DnsRecordType,
374 ) -> crate::Result<()> {
375 let zone = self.get_zone_name(origin).await?;
376 let record_id = self
377 .get_record_id(&zone, name, &record_type.to_string())
378 .await?;
379
380 let url = format!(
381 "{}/domain/zone/{}/record/{}",
382 self.endpoint, zone, record_id
383 );
384 let response = self
385 .send_authenticated_request(Method::DELETE, &url, "")
386 .await?;
387
388 if !response.status().is_success() {
389 let status = response.status();
390 let error_text = response
391 .text()
392 .await
393 .unwrap_or_else(|_| "Unknown error".to_string());
394 return Err(Error::Api(format!(
395 "Failed to delete record: HTTP {} - {}",
396 status, error_text
397 )));
398 }
399
400 let url = format!("{}/domain/zone/{}/refresh", self.endpoint, zone);
401 let _response = self
402 .send_authenticated_request(Method::POST, &url, "")
403 .await
404 .map_err(|e| {
405 Error::Api(format!(
406 "Failed to refresh zone (record deleted but zone not refreshed): {:?}",
407 e
408 ))
409 })?;
410
411 Ok(())
412 }
413}