Skip to main content

dns_update/providers/
ovh.rs

1/*
2 * Copyright Stalwart Labs LLC See the COPYING
3 * file at the top-level directory of this distribution.
4 *
5 * Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
6 * https://www.apache.org/licenses/LICENSE-2.0> or the MIT license
7 * <LICENSE-MIT or https://opensource.org/licenses/MIT>, at your
8 * option. This file may not be copied, modified, or distributed
9 * except according to those terms.
10 */
11
12use 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(&params)
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}