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: mx.to_string(),
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: srv.to_string(),
117            },
118            DnsRecord::TLSA(tlsa) => OvhRecordFormat {
119                field_type: "TLSA".to_string(),
120                target: tlsa.to_string(),
121            },
122            DnsRecord::CAA(caa) => OvhRecordFormat {
123                field_type: "CAA".to_string(),
124                target: caa.to_string(),
125            },
126        }
127    }
128}
129
130impl OvhProvider {
131    pub(crate) fn new(
132        application_key: impl AsRef<str>,
133        application_secret: impl AsRef<str>,
134        consumer_key: impl AsRef<str>,
135        endpoint: OvhEndpoint,
136        timeout: Option<Duration>,
137    ) -> crate::Result<Self> {
138        Ok(Self {
139            application_key: application_key.as_ref().to_string(),
140            application_secret: application_secret.as_ref().to_string(),
141            consumer_key: consumer_key.as_ref().to_string(),
142            endpoint: endpoint.api_url().to_string(),
143            timeout: timeout.unwrap_or(Duration::from_secs(30)),
144        })
145    }
146
147    fn generate_signature(&self, method: &str, url: &str, body: &str, timestamp: u64) -> String {
148        let data = format!(
149            "{}+{}+{}+{}+{}+{}",
150            self.application_secret, self.consumer_key, method, url, body, timestamp
151        );
152
153        let hash = crypto::sha1_digest(data.as_bytes());
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, None);
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, None);
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(&params)
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(&params)
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}