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