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