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