1use crate::http::{HttpClient, HttpClientBuilder};
2use crate::utils::split_caa_value;
3use crate::utils::{
4 decode_hex, tlsa_cert_usage_from_u8, tlsa_matching_from_u8, tlsa_selector_from_u8,
5};
6use crate::{
7 CAARecord, DnsRecord, DnsRecordType, Error, IntoFqdn, MXRecord, SRVRecord, TLSARecord, crypto,
8 utils::strip_origin_from_name,
9};
10use http::Method;
11use serde::{Deserialize, Serialize};
12use std::borrow::Cow;
13use std::time::{Duration, SystemTime, UNIX_EPOCH};
14
15#[derive(Clone)]
16pub struct OvhProvider {
17 application_key: String,
18 application_secret: String,
19 consumer_key: String,
20 pub(crate) endpoint: String,
21 client: HttpClient,
22}
23
24#[derive(Serialize, Debug)]
25pub struct CreateDnsRecordParams {
26 #[serde(rename = "fieldType")]
27 pub field_type: String,
28 #[serde(rename = "subDomain")]
29 pub sub_domain: String,
30 pub target: String,
31 pub ttl: u32,
32}
33
34#[derive(Serialize, Debug)]
35pub struct UpdateDnsRecordParams {
36 pub target: String,
37 pub ttl: u32,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct OvhRecordFormat {
42 pub field_type: String,
43 pub target: String,
44}
45
46#[derive(Deserialize, Debug)]
47struct OvhRecordBody {
48 id: u64,
49 #[serde(rename = "fieldType")]
50 field_type: String,
51 target: String,
52}
53
54#[derive(Debug)]
55pub enum OvhEndpoint {
56 OvhEu,
57 OvhUs,
58 OvhCa,
59 KimsufiEu,
60 KimsufiCa,
61 SoyoustartEu,
62 SoyoustartCa,
63}
64
65impl OvhEndpoint {
66 fn api_url(&self) -> Cow<'static, str> {
67 crate::config::ovh_endpoint(self)
68 }
69}
70
71impl std::str::FromStr for OvhEndpoint {
72 type Err = Error;
73
74 fn from_str(s: &str) -> Result<Self, Self::Err> {
75 match s {
76 "ovh-eu" => Ok(OvhEndpoint::OvhEu),
77 "ovh-us" => Ok(OvhEndpoint::OvhUs),
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: format!("{}.", content.trim_end_matches('.')),
102 },
103 DnsRecord::NS(content) => OvhRecordFormat {
104 field_type: "NS".to_string(),
105 target: format!("{}.", content.trim_end_matches('.')),
106 },
107 DnsRecord::MX(mx) => OvhRecordFormat {
108 field_type: "MX".to_string(),
109 target: format!("{} {}.", mx.priority, mx.exchange.trim_end_matches('.')),
110 },
111 DnsRecord::TXT(content) => OvhRecordFormat {
112 field_type: "TXT".to_string(),
113 target: content.clone(),
114 },
115 DnsRecord::SRV(srv) => OvhRecordFormat {
116 field_type: "SRV".to_string(),
117 target: format!(
118 "{} {} {} {}.",
119 srv.priority,
120 srv.weight,
121 srv.port,
122 srv.target.trim_end_matches('.')
123 ),
124 },
125 DnsRecord::TLSA(tlsa) => OvhRecordFormat {
126 field_type: "TLSA".to_string(),
127 target: tlsa.to_string(),
128 },
129 DnsRecord::CAA(caa) => OvhRecordFormat {
130 field_type: "CAA".to_string(),
131 target: caa.to_string(),
132 },
133 DnsRecord::HTTPS(https) => OvhRecordFormat {
134 field_type: "HTTPS".to_string(),
135 target: https.to_string(),
136 },
137 }
138 }
139}
140
141impl OvhProvider {
142 pub(crate) fn new(
143 application_key: impl AsRef<str>,
144 application_secret: impl AsRef<str>,
145 consumer_key: impl AsRef<str>,
146 endpoint: OvhEndpoint,
147 timeout: Option<Duration>,
148 ) -> crate::Result<Self> {
149 let client = HttpClientBuilder::default()
150 .with_timeout(timeout.or(Some(Duration::from_secs(30))))
151 .build();
152 Ok(Self {
153 application_key: application_key.as_ref().to_string(),
154 application_secret: application_secret.as_ref().to_string(),
155 consumer_key: consumer_key.as_ref().to_string(),
156 endpoint: endpoint.api_url().to_string(),
157 client,
158 })
159 }
160
161 fn generate_signature(&self, method: &str, url: &str, body: &str, timestamp: u64) -> String {
162 let data = format!(
163 "{}+{}+{}+{}+{}+{}",
164 self.application_secret, self.consumer_key, method, url, body, timestamp
165 );
166
167 let hash = crypto::sha1_digest(data.as_bytes());
168 let hex_string = hash
169 .iter()
170 .map(|b| format!("{:02x}", b))
171 .collect::<String>();
172 format!("$1${}", hex_string)
173 }
174
175 async fn send_authenticated_request(
176 &self,
177 method: Method,
178 url: &str,
179 body: &str,
180 ) -> crate::Result<String> {
181 let timestamp = SystemTime::now()
182 .duration_since(UNIX_EPOCH)
183 .map_err(|e| Error::Client(format!("Failed to get timestamp: {}", e)))?
184 .as_secs();
185
186 let signature = self.generate_signature(method.as_str(), url, body, timestamp);
187
188 let mut request = match method {
189 Method::GET => self.client.get(url),
190 Method::POST => self.client.post(url),
191 Method::PUT => self.client.put(url),
192 Method::DELETE => self.client.delete(url),
193 Method::PATCH => self.client.patch(url),
194 other => {
195 return Err(Error::Unsupported(format!(
196 "OVH unsupported method: {other}"
197 )));
198 }
199 };
200 request = request
201 .with_header("X-Ovh-Application", &self.application_key)
202 .with_header("X-Ovh-Consumer", &self.consumer_key)
203 .with_header("X-Ovh-Signature", signature)
204 .with_header("X-Ovh-Timestamp", timestamp.to_string());
205
206 if !body.is_empty() {
207 request = request.with_raw_body(body.to_string());
208 }
209
210 request.send_raw().await
211 }
212
213 async fn get_zone_name(&self, origin: impl IntoFqdn<'_>) -> crate::Result<String> {
214 let domain = origin.into_name();
215 let domain_name = domain.trim_end_matches('.');
216
217 let url = format!("{}/domain/zone/{}", self.endpoint, domain_name);
218 self.send_authenticated_request(Method::GET, &url, "")
219 .await
220 .map(|_| domain_name.to_string())
221 .map_err(|_| Error::Api(format!("Zone {} not found or not accessible", domain_name)))
222 }
223
224 async fn list_record_ids(
225 &self,
226 zone: &str,
227 subdomain: &str,
228 record_type: DnsRecordType,
229 ) -> crate::Result<Vec<u64>> {
230 let url = format!(
231 "{}/domain/zone/{}/record?fieldType={}&subDomain={}",
232 self.endpoint,
233 zone,
234 record_type.as_str(),
235 subdomain
236 );
237 let body = self
238 .send_authenticated_request(Method::GET, &url, "")
239 .await?;
240 serde_json::from_str(&body)
241 .map_err(|e| Error::Api(format!("Failed to parse record list: {}", e)))
242 }
243
244 async fn fetch_record(&self, zone: &str, id: u64) -> crate::Result<OvhRecordBody> {
245 let url = format!("{}/domain/zone/{}/record/{}", self.endpoint, zone, id);
246 let body = self
247 .send_authenticated_request(Method::GET, &url, "")
248 .await?;
249 serde_json::from_str(&body)
250 .map_err(|e| Error::Api(format!("Failed to parse record {}: {}", id, e)))
251 }
252
253 async fn list_at(
254 &self,
255 zone: &str,
256 subdomain: &str,
257 record_type: DnsRecordType,
258 ) -> crate::Result<Vec<OvhRecordBody>> {
259 let ids = self.list_record_ids(zone, subdomain, record_type).await?;
260 let mut out = Vec::with_capacity(ids.len());
261 for id in ids {
262 let body = self.fetch_record(zone, id).await?;
263 if body.field_type == record_type.as_str() {
264 out.push(body);
265 }
266 }
267 Ok(out)
268 }
269
270 async fn refresh_zone(&self, zone: &str) -> crate::Result<()> {
271 let url = format!("{}/domain/zone/{}/refresh", self.endpoint, zone);
272 self.send_authenticated_request(Method::POST, &url, "")
273 .await
274 .map(|_| ())
275 }
276
277 async fn post_record(
278 &self,
279 zone: &str,
280 subdomain: &str,
281 ttl: u32,
282 wire: &OvhRecordFormat,
283 ) -> crate::Result<()> {
284 let params = CreateDnsRecordParams {
285 field_type: wire.field_type.clone(),
286 sub_domain: subdomain.to_string(),
287 target: wire.target.clone(),
288 ttl,
289 };
290 let body = serde_json::to_string(¶ms)
291 .map_err(|e| Error::Serialize(format!("Failed to serialize record: {}", e)))?;
292
293 let url = format!("{}/domain/zone/{}/record", self.endpoint, zone);
294 self.send_authenticated_request(Method::POST, &url, &body)
295 .await
296 .map(|_| ())
297 }
298
299 async fn delete_record_id(&self, zone: &str, id: u64) -> crate::Result<()> {
300 let url = format!("{}/domain/zone/{}/record/{}", self.endpoint, zone, id);
301 self.send_authenticated_request(Method::DELETE, &url, "")
302 .await
303 .map(|_| ())
304 }
305
306 fn subdomain_for<'a>(&self, zone: &str, name: impl IntoFqdn<'a>) -> String {
307 let name = name.into_name();
308 let subdomain = strip_origin_from_name(&name, zone, Some(""));
309 if subdomain == "@" {
310 String::new()
311 } else {
312 subdomain
313 }
314 }
315
316 pub(crate) async fn set_rrset(
317 &self,
318 name: impl IntoFqdn<'_>,
319 record_type: DnsRecordType,
320 ttl: u32,
321 records: Vec<DnsRecord>,
322 origin: impl IntoFqdn<'_>,
323 ) -> crate::Result<()> {
324 let desired = build_wire(record_type, &records)?;
325 let zone = self.get_zone_name(origin).await?;
326 let subdomain = self.subdomain_for(&zone, name);
327
328 let existing = self.list_at(&zone, &subdomain, record_type).await?;
329
330 let mut to_add: Vec<OvhRecordFormat> = Vec::new();
331 let mut leftovers: Vec<&OvhRecordBody> = existing.iter().collect();
332
333 for wanted in &desired {
334 if let Some(pos) = leftovers.iter().position(|e| target_equivalent(e, wanted)) {
335 leftovers.swap_remove(pos);
336 } else {
337 to_add.push(wanted.clone());
338 }
339 }
340
341 let mut mutated = false;
342 for stale in leftovers {
343 self.delete_record_id(&zone, stale.id).await?;
344 mutated = true;
345 }
346 for wire in to_add {
347 self.post_record(&zone, &subdomain, ttl, &wire).await?;
348 mutated = true;
349 }
350
351 if mutated {
352 self.refresh_zone(&zone).await?;
353 }
354 Ok(())
355 }
356
357 pub(crate) async fn add_to_rrset(
358 &self,
359 name: impl IntoFqdn<'_>,
360 record_type: DnsRecordType,
361 ttl: u32,
362 records: Vec<DnsRecord>,
363 origin: impl IntoFqdn<'_>,
364 ) -> crate::Result<()> {
365 if records.is_empty() {
366 return Ok(());
367 }
368 let desired = build_wire(record_type, &records)?;
369 let zone = self.get_zone_name(origin).await?;
370 let subdomain = self.subdomain_for(&zone, name);
371
372 let existing = self.list_at(&zone, &subdomain, record_type).await?;
373
374 let mut mutated = false;
375 for wire in desired {
376 if existing.iter().any(|e| target_equivalent(e, &wire)) {
377 continue;
378 }
379 self.post_record(&zone, &subdomain, ttl, &wire).await?;
380 mutated = true;
381 }
382
383 if mutated {
384 self.refresh_zone(&zone).await?;
385 }
386 Ok(())
387 }
388
389 pub(crate) async fn remove_from_rrset(
390 &self,
391 name: impl IntoFqdn<'_>,
392 record_type: DnsRecordType,
393 records: Vec<DnsRecord>,
394 origin: impl IntoFqdn<'_>,
395 ) -> crate::Result<()> {
396 if records.is_empty() {
397 return Ok(());
398 }
399 let to_remove = build_wire(record_type, &records)?;
400 let zone = self.get_zone_name(origin).await?;
401 let subdomain = self.subdomain_for(&zone, name);
402
403 let existing = self.list_at(&zone, &subdomain, record_type).await?;
404
405 let mut mutated = false;
406 for wire in to_remove {
407 if let Some(entry) = existing.iter().find(|e| target_equivalent(e, &wire)) {
408 self.delete_record_id(&zone, entry.id).await?;
409 mutated = true;
410 }
411 }
412
413 if mutated {
414 self.refresh_zone(&zone).await?;
415 }
416 Ok(())
417 }
418
419 pub(crate) async fn list_rrset(
420 &self,
421 name: impl IntoFqdn<'_>,
422 record_type: DnsRecordType,
423 origin: impl IntoFqdn<'_>,
424 ) -> crate::Result<Vec<DnsRecord>> {
425 let zone = self.get_zone_name(origin).await?;
426 let subdomain = self.subdomain_for(&zone, name);
427 let existing = self.list_at(&zone, &subdomain, record_type).await?;
428 existing
429 .into_iter()
430 .map(|e| parse_ovh_target(record_type, &e.target))
431 .collect()
432 }
433}
434
435fn build_wire(
436 expected_type: DnsRecordType,
437 records: &[DnsRecord],
438) -> crate::Result<Vec<OvhRecordFormat>> {
439 let mut out = Vec::with_capacity(records.len());
440 for record in records {
441 if record.as_type() != expected_type {
442 return Err(Error::Api(format!(
443 "RRSet record type mismatch: expected {}, got {}",
444 expected_type.as_str(),
445 record.as_type().as_str(),
446 )));
447 }
448 out.push(record.into());
449 }
450 Ok(out)
451}
452
453fn target_equivalent(existing: &OvhRecordBody, wanted: &OvhRecordFormat) -> bool {
454 if existing.field_type != wanted.field_type {
455 return false;
456 }
457 if existing.target == wanted.target {
458 return true;
459 }
460 match wanted.field_type.as_str() {
461 "CNAME" | "NS" => existing
462 .target
463 .trim_end_matches('.')
464 .eq_ignore_ascii_case(wanted.target.trim_end_matches('.')),
465 "MX" | "SRV" => {
466 normalize_priority_target(&existing.target) == normalize_priority_target(&wanted.target)
467 }
468 "TLSA" => existing.target.eq_ignore_ascii_case(&wanted.target),
469 _ => false,
470 }
471}
472
473fn normalize_priority_target(value: &str) -> String {
474 let trimmed = value.trim();
475 let last_space = trimmed.rfind(char::is_whitespace);
476 match last_space {
477 Some(idx) => {
478 let (prefix, tail) = trimmed.split_at(idx);
479 let tail_trimmed = tail.trim().trim_end_matches('.').to_ascii_lowercase();
480 format!("{} {}", prefix.trim(), tail_trimmed)
481 }
482 None => trimmed.trim_end_matches('.').to_ascii_lowercase(),
483 }
484}
485
486fn parse_ovh_target(record_type: DnsRecordType, target: &str) -> crate::Result<DnsRecord> {
487 match record_type {
488 DnsRecordType::A => target
489 .parse()
490 .map(DnsRecord::A)
491 .map_err(|e| Error::Parse(format!("invalid A target {}: {}", target, e))),
492 DnsRecordType::AAAA => target
493 .parse()
494 .map(DnsRecord::AAAA)
495 .map_err(|e| Error::Parse(format!("invalid AAAA target {}: {}", target, e))),
496 DnsRecordType::CNAME => Ok(DnsRecord::CNAME(target.to_string())),
497 DnsRecordType::NS => Ok(DnsRecord::NS(target.to_string())),
498 DnsRecordType::TXT => Ok(DnsRecord::TXT(target.to_string())),
499 DnsRecordType::MX => {
500 let mut parts = target.splitn(2, char::is_whitespace);
501 let priority = parts
502 .next()
503 .ok_or_else(|| Error::Parse(format!("invalid MX target: {}", target)))?
504 .parse::<u16>()
505 .map_err(|e| Error::Parse(format!("invalid MX priority in {}: {}", target, e)))?;
506 let exchange = parts
507 .next()
508 .ok_or_else(|| Error::Parse(format!("MX target missing exchange: {}", target)))?
509 .trim()
510 .to_string();
511 Ok(DnsRecord::MX(MXRecord { exchange, priority }))
512 }
513 DnsRecordType::SRV => {
514 let mut parts = target.split_whitespace();
515 let priority = parts
516 .next()
517 .ok_or_else(|| Error::Parse(format!("invalid SRV target: {}", target)))?
518 .parse::<u16>()
519 .map_err(|e| Error::Parse(format!("invalid SRV priority in {}: {}", target, e)))?;
520 let weight = parts
521 .next()
522 .ok_or_else(|| Error::Parse(format!("invalid SRV target: {}", target)))?
523 .parse::<u16>()
524 .map_err(|e| Error::Parse(format!("invalid SRV weight in {}: {}", target, e)))?;
525 let port = parts
526 .next()
527 .ok_or_else(|| Error::Parse(format!("invalid SRV target: {}", target)))?
528 .parse::<u16>()
529 .map_err(|e| Error::Parse(format!("invalid SRV port in {}: {}", target, e)))?;
530 let srv_target = parts
531 .next()
532 .ok_or_else(|| Error::Parse(format!("SRV target missing host: {}", target)))?
533 .to_string();
534 Ok(DnsRecord::SRV(SRVRecord {
535 priority,
536 weight,
537 port,
538 target: srv_target,
539 }))
540 }
541 DnsRecordType::TLSA => {
542 let mut parts = target.split_whitespace();
543 let usage = parts
544 .next()
545 .ok_or_else(|| Error::Parse(format!("invalid TLSA target: {}", target)))?
546 .parse::<u8>()
547 .map_err(|e| Error::Parse(format!("invalid TLSA usage in {}: {}", target, e)))?;
548 let selector = parts
549 .next()
550 .ok_or_else(|| Error::Parse(format!("invalid TLSA target: {}", target)))?
551 .parse::<u8>()
552 .map_err(|e| Error::Parse(format!("invalid TLSA selector in {}: {}", target, e)))?;
553 let matching = parts
554 .next()
555 .ok_or_else(|| Error::Parse(format!("invalid TLSA target: {}", target)))?
556 .parse::<u8>()
557 .map_err(|e| Error::Parse(format!("invalid TLSA matching in {}: {}", target, e)))?;
558 let cert_hex = parts
559 .next()
560 .ok_or_else(|| Error::Parse(format!("TLSA target missing data: {}", target)))?;
561 Ok(DnsRecord::TLSA(TLSARecord {
562 cert_usage: tlsa_cert_usage_from_u8(usage)?,
563 selector: tlsa_selector_from_u8(selector)?,
564 matching: tlsa_matching_from_u8(matching)?,
565 cert_data: decode_hex(cert_hex)?,
566 }))
567 }
568 DnsRecordType::CAA => parse_caa(target),
569 DnsRecordType::HTTPS => parse_https(target),
570 }
571}
572
573fn parse_https(content: &str) -> crate::Result<DnsRecord> {
574 let mut parts = content.split(' ');
575 let svc_priority: u16 = parts
576 .next()
577 .ok_or_else(|| Error::Parse(format!("invalid HTTPS record: {content}")))?
578 .parse()
579 .map_err(|e| Error::Parse(format!("invalid HTTPS priority: {e}")))?;
580 let target_name = parts
581 .next()
582 .ok_or_else(|| Error::Parse(format!("invalid HTTPS record: {content}")))?
583 .to_string();
584 let svc_params: Vec<crate::KeyValue> = parts
585 .map(|v| {
586 let mut parts = v.splitn(2, '=');
587 crate::KeyValue {
588 key: parts.next().unwrap_or_default().to_string(),
589 value: parts
590 .next()
591 .unwrap_or_default()
592 .trim_matches('"')
593 .to_string(),
594 }
595 })
596 .collect();
597 Ok(DnsRecord::HTTPS(crate::HTTPSRecord {
598 svc_priority,
599 target_name,
600 svc_params,
601 }))
602}
603
604fn parse_caa(target: &str) -> crate::Result<DnsRecord> {
605 let mut parts = target.splitn(3, char::is_whitespace);
606 let flags = parts
607 .next()
608 .ok_or_else(|| Error::Parse(format!("invalid CAA target: {}", target)))?
609 .parse::<u8>()
610 .map_err(|e| Error::Parse(format!("invalid CAA flags in {}: {}", target, e)))?;
611 let tag = parts
612 .next()
613 .ok_or_else(|| Error::Parse(format!("CAA target missing tag: {}", target)))?
614 .to_string();
615 let raw_value = parts
616 .next()
617 .ok_or_else(|| Error::Parse(format!("CAA target missing value: {}", target)))?
618 .trim();
619 let unquoted_full = strip_caa_quotes(raw_value);
620
621 let issuer_critical = flags & 0x80 != 0;
622 match tag.as_str() {
623 "issue" => {
624 let (name, options) = split_caa_value(&unquoted_full);
625 Ok(DnsRecord::CAA(CAARecord::Issue {
626 issuer_critical,
627 name,
628 options,
629 }))
630 }
631 "issuewild" => {
632 let (name, options) = split_caa_value(&unquoted_full);
633 Ok(DnsRecord::CAA(CAARecord::IssueWild {
634 issuer_critical,
635 name,
636 options,
637 }))
638 }
639 "iodef" => Ok(DnsRecord::CAA(CAARecord::Iodef {
640 issuer_critical,
641 url: unquoted_full,
642 })),
643 other => Err(Error::Parse(format!("unknown CAA tag: {}", other))),
644 }
645}
646
647fn strip_caa_quotes(s: &str) -> String {
648 let s = s.trim();
649 if let Some(inner) = s.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
650 inner.to_string()
651 } else {
652 s.to_string()
653 }
654}