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::{strip_origin_from_name, DnsRecord, Error, IntoFqdn};
13use reqwest::Method;
14use serde::Serialize;
15use sha1::{Digest, Sha1};
16use std::time::{Duration, SystemTime, UNIX_EPOCH};
17
18#[derive(Clone)]
19pub struct OvhProvider {
20    application_key: String,
21    application_secret: String,
22    consumer_key: String,
23    pub(crate) endpoint: String,
24    timeout: Duration,
25}
26
27#[derive(Serialize, Debug)]
28pub struct CreateDnsRecordParams {
29    #[serde(rename = "fieldType")]
30    pub field_type: String,
31    #[serde(rename = "subDomain")]
32    pub sub_domain: String,
33    pub target: String,
34    pub ttl: u32,
35}
36
37#[derive(Serialize, Debug)]
38pub struct UpdateDnsRecordParams {
39    pub target: String,
40    pub ttl: u32,
41}
42
43#[derive(Debug)]
44pub struct OvhRecordFormat {
45    pub field_type: String,
46    pub target: String,
47}
48
49#[derive(Debug)]
50pub enum OvhEndpoint {
51    OvhEu,
52    OvhCa,
53    KimsufiEu,
54    KimsufiCa,
55    SoyoustartEu,
56    SoyoustartCa,
57}
58
59impl OvhEndpoint {
60    fn api_url(&self) -> &'static str {
61        match self {
62            OvhEndpoint::OvhEu => "https://eu.api.ovh.com/1.0",
63            OvhEndpoint::OvhCa => "https://ca.api.ovh.com/1.0",
64            OvhEndpoint::KimsufiEu => "https://eu.api.kimsufi.com/1.0",
65            OvhEndpoint::KimsufiCa => "https://ca.api.kimsufi.com/1.0",
66            OvhEndpoint::SoyoustartEu => "https://eu.api.soyoustart.com/1.0",
67            OvhEndpoint::SoyoustartCa => "https://ca.api.soyoustart.com/1.0",
68        }
69    }
70}
71
72impl std::str::FromStr for OvhEndpoint {
73    type Err = Error;
74
75    fn from_str(s: &str) -> Result<Self, Self::Err> {
76        match s {
77            "ovh-eu" => Ok(OvhEndpoint::OvhEu),
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: content.clone(),
102            },
103            DnsRecord::NS { content } => OvhRecordFormat {
104                field_type: "NS".to_string(),
105                target: content.clone(),
106            },
107            DnsRecord::MX { content, priority } => OvhRecordFormat {
108                field_type: "MX".to_string(),
109                target: format!("{} {}", priority, content),
110            },
111            DnsRecord::TXT { content } => OvhRecordFormat {
112                field_type: "TXT".to_string(),
113                target: content.clone(),
114            },
115            DnsRecord::SRV {
116                content,
117                priority,
118                weight,
119                port,
120            } => OvhRecordFormat {
121                field_type: "SRV".to_string(),
122                target: format!("{} {} {} {}", priority, weight, port, content),
123            },
124        }
125    }
126}
127
128impl OvhProvider {
129    pub(crate) fn new(
130        application_key: impl AsRef<str>,
131        application_secret: impl AsRef<str>,
132        consumer_key: impl AsRef<str>,
133        endpoint: OvhEndpoint,
134        timeout: Option<Duration>,
135    ) -> crate::Result<Self> {
136        Ok(Self {
137            application_key: application_key.as_ref().to_string(),
138            application_secret: application_secret.as_ref().to_string(),
139            consumer_key: consumer_key.as_ref().to_string(),
140            endpoint: endpoint.api_url().to_string(),
141            timeout: timeout.unwrap_or(Duration::from_secs(30)),
142        })
143    }
144
145    fn generate_signature(&self, method: &str, url: &str, body: &str, timestamp: u64) -> String {
146        let data = format!(
147            "{}+{}+{}+{}+{}+{}",
148            self.application_secret, self.consumer_key, method, url, body, timestamp
149        );
150
151        let mut hasher = Sha1::new();
152        hasher.update(data.as_bytes());
153        let hash = hasher.finalize();
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);
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);
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}