1use 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(¶ms)
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(¶ms)
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}