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