1use crate::utils::split_caa_value;
13use crate::{
14 CAARecord, DnsRecord, DnsRecordType, Error, IntoFqdn, MXRecord, SRVRecord,
15 crypto::hmac_sha1,
16 http::{HttpClient, HttpClientBuilder},
17 utils::strip_origin_from_name,
18};
19use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
20use chrono::{SecondsFormat, Utc};
21use reqwest::Method;
22use serde::{Deserialize, Serialize};
23use std::time::Duration;
24
25const DEFAULT_ENDPOINT: &str = "https://rest.websupport.sk";
26const SERVICES_PAGE_SIZE: u32 = 500;
27
28#[derive(Clone)]
29pub struct WebSupportProvider {
30 client: HttpClient,
31 api_key: String,
32 secret: String,
33 endpoint: String,
34}
35
36#[derive(Serialize, Debug)]
37struct CreateRecord<'a> {
38 #[serde(rename = "type")]
39 record_type: &'static str,
40 name: &'a str,
41 content: String,
42 ttl: u32,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 priority: Option<u16>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 port: Option<u16>,
47 #[serde(skip_serializing_if = "Option::is_none")]
48 weight: Option<u16>,
49}
50
51#[derive(Deserialize, Debug)]
52struct WebSupportRecord {
53 id: i64,
54 #[serde(rename = "type")]
55 record_type: String,
56 name: String,
57 #[serde(default)]
58 content: String,
59 #[serde(default)]
60 priority: Option<u16>,
61 #[serde(default)]
62 port: Option<u16>,
63 #[serde(default)]
64 weight: Option<u16>,
65}
66
67#[derive(Deserialize, Debug)]
68struct RecordResponse {
69 data: Vec<WebSupportRecord>,
70}
71
72#[derive(Deserialize, Debug)]
73struct Service {
74 id: i64,
75 #[serde(rename = "serviceName", default)]
76 service_name: String,
77 #[serde(default)]
78 name: String,
79}
80
81#[derive(Deserialize, Debug)]
82struct ServicesResponse {
83 items: Vec<Service>,
84 #[serde(default)]
85 pager: Option<Pager>,
86}
87
88#[derive(Deserialize, Debug)]
89struct Pager {
90 #[serde(default)]
91 pagesize: Option<u32>,
92 #[serde(default)]
93 items: u32,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
97struct RecordContent {
98 content: String,
99 priority: Option<u16>,
100 port: Option<u16>,
101 weight: Option<u16>,
102}
103
104#[derive(Debug, Clone)]
105struct ListedRecord {
106 id: i64,
107 content: RecordContent,
108}
109
110impl WebSupportProvider {
111 pub(crate) fn new(
112 api_key: impl AsRef<str>,
113 secret: impl AsRef<str>,
114 timeout: Option<Duration>,
115 ) -> crate::Result<Self> {
116 let api_key = api_key.as_ref();
117 let secret = secret.as_ref();
118 if api_key.is_empty() || secret.is_empty() {
119 return Err(Error::Api("WebSupport credentials missing".into()));
120 }
121 let client = HttpClientBuilder::default()
122 .with_header("Accept", "application/json")
123 .with_header("Accept-Language", "en_us")
124 .with_timeout(timeout)
125 .build();
126 Ok(Self {
127 client,
128 api_key: api_key.to_string(),
129 secret: secret.to_string(),
130 endpoint: DEFAULT_ENDPOINT.to_string(),
131 })
132 }
133
134 #[cfg(test)]
135 pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
136 Self {
137 endpoint: endpoint.as_ref().to_string(),
138 ..self
139 }
140 }
141
142 fn signed(
143 &self,
144 request: crate::http::HttpRequest,
145 method: Method,
146 path: &str,
147 ) -> crate::http::HttpRequest {
148 let now = Utc::now();
149 let timestamp = now.timestamp();
150 let date = now.to_rfc3339_opts(SecondsFormat::Secs, true);
151 let canonical = format!("{} {} {}", method.as_str(), path, timestamp);
152 let signature = hex::encode(hmac_sha1(self.secret.as_bytes(), canonical.as_bytes()));
153 let basic = BASE64_STANDARD.encode(format!("{}:{}", self.api_key, signature));
154 request
155 .with_header("Authorization", format!("Basic {}", basic))
156 .with_header("Date", date)
157 }
158
159 pub(crate) async fn set_rrset(
160 &self,
161 name: impl IntoFqdn<'_>,
162 record_type: DnsRecordType,
163 ttl: u32,
164 records: Vec<DnsRecord>,
165 origin: impl IntoFqdn<'_>,
166 ) -> crate::Result<()> {
167 check_record_types(record_type, &records)?;
168 reject_unsupported_type(record_type)?;
169 let name = name.into_name();
170 let domain = origin.into_name();
171 let subdomain = strip_origin_from_name(&name, &domain, None);
172 let service_id = self.obtain_service_id(&domain).await?;
173 let desired = build_contents(record_type, records)?;
174 let existing = self.list_at(service_id, &subdomain, record_type).await?;
175
176 let mut existing_pool: Vec<ListedRecord> = existing;
177 let mut to_add: Vec<RecordContent> = Vec::new();
178
179 for content in desired {
180 if let Some(idx) = existing_pool.iter().position(|r| r.content == content) {
181 existing_pool.swap_remove(idx);
182 } else {
183 to_add.push(content);
184 }
185 }
186
187 for entry in existing_pool {
188 self.delete_record(service_id, entry.id).await?;
189 }
190 for content in to_add {
191 let body = content_to_create(record_type, &subdomain, ttl, content);
192 self.create_record(service_id, body).await?;
193 }
194 Ok(())
195 }
196
197 pub(crate) async fn add_to_rrset(
198 &self,
199 name: impl IntoFqdn<'_>,
200 record_type: DnsRecordType,
201 ttl: u32,
202 records: Vec<DnsRecord>,
203 origin: impl IntoFqdn<'_>,
204 ) -> crate::Result<()> {
205 if records.is_empty() {
206 return Ok(());
207 }
208 check_record_types(record_type, &records)?;
209 reject_unsupported_type(record_type)?;
210 let name = name.into_name();
211 let domain = origin.into_name();
212 let subdomain = strip_origin_from_name(&name, &domain, None);
213 let service_id = self.obtain_service_id(&domain).await?;
214 let desired = build_contents(record_type, records)?;
215 let existing = self.list_at(service_id, &subdomain, record_type).await?;
216
217 for content in desired {
218 if existing.iter().any(|r| r.content == content) {
219 continue;
220 }
221 let body = content_to_create(record_type, &subdomain, ttl, content);
222 self.create_record(service_id, body).await?;
223 }
224 Ok(())
225 }
226
227 pub(crate) async fn remove_from_rrset(
228 &self,
229 name: impl IntoFqdn<'_>,
230 record_type: DnsRecordType,
231 records: Vec<DnsRecord>,
232 origin: impl IntoFqdn<'_>,
233 ) -> crate::Result<()> {
234 if records.is_empty() {
235 return Ok(());
236 }
237 check_record_types(record_type, &records)?;
238 reject_unsupported_type(record_type)?;
239 let name = name.into_name();
240 let domain = origin.into_name();
241 let subdomain = strip_origin_from_name(&name, &domain, None);
242 let service_id = self.obtain_service_id(&domain).await?;
243 let to_remove = build_contents(record_type, records)?;
244 let existing = self.list_at(service_id, &subdomain, record_type).await?;
245
246 for content in to_remove {
247 if let Some(entry) = existing.iter().find(|r| r.content == content) {
248 self.delete_record(service_id, entry.id).await?;
249 }
250 }
251 Ok(())
252 }
253
254 pub(crate) async fn list_rrset(
255 &self,
256 name: impl IntoFqdn<'_>,
257 record_type: DnsRecordType,
258 origin: impl IntoFqdn<'_>,
259 ) -> crate::Result<Vec<DnsRecord>> {
260 reject_unsupported_type(record_type)?;
261 let name = name.into_name();
262 let domain = origin.into_name();
263 let subdomain = strip_origin_from_name(&name, &domain, None);
264 let service_id = self.obtain_service_id(&domain).await?;
265 let existing = self.list_at(service_id, &subdomain, record_type).await?;
266 existing
267 .into_iter()
268 .map(|r| record_from_listed(record_type, r.content))
269 .collect()
270 }
271
272 async fn obtain_service_id(&self, domain: &str) -> crate::Result<i64> {
273 const SERVICES_PATH: &str = "/v1/user/self/service";
274 let mut page: u32 = 1;
275 loop {
276 let url = format!(
277 "{}{}?page={}&pagesize={}",
278 self.endpoint, SERVICES_PATH, page, SERVICES_PAGE_SIZE
279 );
280 let response: ServicesResponse = self
281 .signed(self.client.get(url), Method::GET, SERVICES_PATH)
282 .send()
283 .await?;
284 let returned = response.items.len() as u32;
285 if let Some(found) = response
286 .items
287 .into_iter()
288 .find(|s| s.service_name == "domain" && s.name == domain)
289 {
290 return Ok(found.id);
291 }
292 let total = response.pager.as_ref().map(|p| p.items).unwrap_or(0);
293 let pagesize = response
294 .pager
295 .as_ref()
296 .and_then(|p| p.pagesize)
297 .unwrap_or(SERVICES_PAGE_SIZE);
298 let seen = page.saturating_mul(pagesize.max(1));
299 if returned == 0 || total == 0 || seen >= total {
300 return Err(Error::Api(format!(
301 "WebSupport domain service {} not found",
302 domain
303 )));
304 }
305 page += 1;
306 }
307 }
308
309 async fn list_at(
310 &self,
311 service_id: i64,
312 subdomain: &str,
313 record_type: DnsRecordType,
314 ) -> crate::Result<Vec<ListedRecord>> {
315 let path = format!("/v2/service/{}/dns/record", service_id);
316 let url = format!("{}{}", self.endpoint, path);
317 let response: RecordResponse = self
318 .signed(self.client.get(url), Method::GET, &path)
319 .send()
320 .await?;
321 let type_str = record_type.as_str();
322 let mut out = Vec::new();
323 for r in response.data {
324 if r.name != subdomain || r.record_type != type_str {
325 continue;
326 }
327 out.push(ListedRecord {
328 id: r.id,
329 content: RecordContent {
330 content: r.content,
331 priority: r.priority,
332 port: r.port,
333 weight: r.weight,
334 },
335 });
336 }
337 Ok(out)
338 }
339
340 async fn create_record<'a>(
341 &self,
342 service_id: i64,
343 body: CreateRecord<'a>,
344 ) -> crate::Result<()> {
345 let path = format!("/v2/service/{}/dns/record", service_id);
346 let url = format!("{}{}", self.endpoint, path);
347 self.signed(self.client.post(url).with_body(body)?, Method::POST, &path)
348 .send_with_retry::<serde_json::Value>(3)
349 .await
350 .map(|_| ())
351 }
352
353 async fn delete_record(&self, service_id: i64, record_id: i64) -> crate::Result<()> {
354 let path = format!("/v2/service/{}/dns/record/{}", service_id, record_id);
355 let url = format!("{}{}", self.endpoint, path);
356 self.signed(self.client.delete(url), Method::DELETE, &path)
357 .send_with_retry::<serde_json::Value>(3)
358 .await
359 .map(|_| ())
360 }
361}
362
363fn check_record_types(expected: DnsRecordType, records: &[DnsRecord]) -> crate::Result<()> {
364 for r in records {
365 if r.as_type() != expected {
366 return Err(Error::Api(format!(
367 "RRSet record type mismatch: expected {}, got {}",
368 expected.as_str(),
369 r.as_type().as_str(),
370 )));
371 }
372 }
373 Ok(())
374}
375
376fn reject_unsupported_type(record_type: DnsRecordType) -> crate::Result<()> {
377 if matches!(record_type, DnsRecordType::TLSA) {
378 return Err(Error::Unsupported(
379 "TLSA records are not supported by WebSupport".into(),
380 ));
381 }
382 Ok(())
383}
384
385fn build_contents(
386 expected_type: DnsRecordType,
387 records: Vec<DnsRecord>,
388) -> crate::Result<Vec<RecordContent>> {
389 let mut out = Vec::with_capacity(records.len());
390 for record in records {
391 if record.as_type() != expected_type {
392 return Err(Error::Api(format!(
393 "RRSet record type mismatch: expected {}, got {}",
394 expected_type.as_str(),
395 record.as_type().as_str(),
396 )));
397 }
398 out.push(record_to_content(record)?);
399 }
400 Ok(out)
401}
402
403fn record_to_content(record: DnsRecord) -> crate::Result<RecordContent> {
404 Ok(match record {
405 DnsRecord::A(addr) => RecordContent {
406 content: addr.to_string(),
407 priority: None,
408 port: None,
409 weight: None,
410 },
411 DnsRecord::AAAA(addr) => RecordContent {
412 content: addr.to_string(),
413 priority: None,
414 port: None,
415 weight: None,
416 },
417 DnsRecord::CNAME(target) => RecordContent {
418 content: target,
419 priority: None,
420 port: None,
421 weight: None,
422 },
423 DnsRecord::NS(target) => RecordContent {
424 content: target,
425 priority: None,
426 port: None,
427 weight: None,
428 },
429 DnsRecord::MX(mx) => RecordContent {
430 content: mx.exchange,
431 priority: Some(mx.priority),
432 port: None,
433 weight: None,
434 },
435 DnsRecord::TXT(text) => RecordContent {
436 content: text,
437 priority: None,
438 port: None,
439 weight: None,
440 },
441 DnsRecord::SRV(srv) => RecordContent {
442 content: srv.target,
443 priority: Some(srv.priority),
444 port: Some(srv.port),
445 weight: Some(srv.weight),
446 },
447 DnsRecord::CAA(caa) => RecordContent {
448 content: format!("{}", caa),
449 priority: None,
450 port: None,
451 weight: None,
452 },
453 DnsRecord::TLSA(_) => {
454 return Err(Error::Unsupported(
455 "TLSA records are not supported by WebSupport".into(),
456 ));
457 }
458 })
459}
460
461fn content_to_create<'a>(
462 record_type: DnsRecordType,
463 name: &'a str,
464 ttl: u32,
465 content: RecordContent,
466) -> CreateRecord<'a> {
467 CreateRecord {
468 record_type: record_type.as_str(),
469 name,
470 content: content.content,
471 ttl,
472 priority: content.priority,
473 port: content.port,
474 weight: content.weight,
475 }
476}
477
478fn record_from_listed(
479 record_type: DnsRecordType,
480 content: RecordContent,
481) -> crate::Result<DnsRecord> {
482 Ok(match record_type {
483 DnsRecordType::A => {
484 DnsRecord::A(content.content.parse().map_err(|e| {
485 Error::Parse(format!("invalid A address {}: {}", content.content, e))
486 })?)
487 }
488 DnsRecordType::AAAA => DnsRecord::AAAA(content.content.parse().map_err(|e| {
489 Error::Parse(format!("invalid AAAA address {}: {}", content.content, e))
490 })?),
491 DnsRecordType::CNAME => DnsRecord::CNAME(content.content),
492 DnsRecordType::NS => DnsRecord::NS(content.content),
493 DnsRecordType::MX => DnsRecord::MX(MXRecord {
494 exchange: content.content,
495 priority: content.priority.unwrap_or(0),
496 }),
497 DnsRecordType::TXT => DnsRecord::TXT(content.content),
498 DnsRecordType::SRV => DnsRecord::SRV(SRVRecord {
499 target: content.content,
500 priority: content.priority.unwrap_or(0),
501 weight: content.weight.unwrap_or(0),
502 port: content.port.unwrap_or(0),
503 }),
504 DnsRecordType::CAA => DnsRecord::CAA(parse_caa(&content.content)?),
505 DnsRecordType::TLSA => {
506 return Err(Error::Unsupported(
507 "TLSA records are not supported by WebSupport".into(),
508 ));
509 }
510 })
511}
512
513fn parse_caa(raw: &str) -> crate::Result<CAARecord> {
514 let trimmed = raw.trim();
515 let mut parts = trimmed.splitn(3, char::is_whitespace);
516 let flags_str = parts
517 .next()
518 .ok_or_else(|| Error::Parse(format!("invalid CAA record: {raw}")))?;
519 let tag = parts
520 .next()
521 .ok_or_else(|| Error::Parse(format!("invalid CAA record: {raw}")))?;
522 let value = parts
523 .next()
524 .ok_or_else(|| Error::Parse(format!("invalid CAA record: {raw}")))?;
525 let flags: u8 = flags_str
526 .parse()
527 .map_err(|e| Error::Parse(format!("invalid CAA flags {flags_str}: {e}")))?;
528 let issuer_critical = flags & 0x80 != 0;
529 let value_unquoted = value
530 .trim()
531 .strip_prefix('"')
532 .and_then(|s| s.strip_suffix('"'))
533 .unwrap_or(value.trim())
534 .to_string();
535 match tag {
536 "issue" => {
537 let (name, options) = split_caa_value(&value_unquoted);
538 Ok(CAARecord::Issue {
539 issuer_critical,
540 name,
541 options,
542 })
543 }
544 "issuewild" => {
545 let (name, options) = split_caa_value(&value_unquoted);
546 Ok(CAARecord::IssueWild {
547 issuer_critical,
548 name,
549 options,
550 })
551 }
552 "iodef" => Ok(CAARecord::Iodef {
553 issuer_critical,
554 url: value_unquoted,
555 }),
556 other => Err(Error::Parse(format!("unknown CAA tag: {other}"))),
557 }
558}