Skip to main content

dns_update/providers/
websupport.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::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}