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::{
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}