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    DnsRecord, DnsRecordType, Error, IntoFqdn, crypto::hmac_sha1, http::HttpClientBuilder,
14    utils::strip_origin_from_name,
15};
16use base64::{Engine, engine::general_purpose::STANDARD as BASE64_STANDARD};
17use chrono::{SecondsFormat, Utc};
18use reqwest::Method;
19use serde::{Deserialize, Serialize};
20use std::time::Duration;
21
22const DEFAULT_ENDPOINT: &str = "https://rest.websupport.sk";
23
24#[derive(Clone)]
25pub struct WebSupportProvider {
26    client: HttpClientBuilder,
27    api_key: String,
28    secret: String,
29    endpoint: String,
30}
31
32#[derive(Serialize, Debug)]
33struct CreateRecord<'a> {
34    #[serde(rename = "type")]
35    record_type: &'static str,
36    name: &'a str,
37    content: String,
38    ttl: u32,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    priority: Option<u16>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    port: Option<u16>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    weight: Option<u16>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    flags: Option<u8>,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    tag: Option<String>,
49}
50
51#[derive(Deserialize, Debug)]
52struct WebSupportRecord {
53    id: i64,
54    #[serde(rename = "type")]
55    record_type: String,
56    name: String,
57}
58
59#[derive(Deserialize, Debug)]
60struct RecordResponse {
61    data: Vec<WebSupportRecord>,
62}
63
64#[derive(Deserialize, Debug)]
65struct Service {
66    id: i64,
67    #[serde(rename = "serviceName", default)]
68    service_name: String,
69    #[serde(default)]
70    name: String,
71}
72
73#[derive(Deserialize, Debug)]
74struct ServicesResponse {
75    items: Vec<Service>,
76}
77
78impl WebSupportProvider {
79    pub(crate) fn new(
80        api_key: impl AsRef<str>,
81        secret: impl AsRef<str>,
82        timeout: Option<Duration>,
83    ) -> crate::Result<Self> {
84        let api_key = api_key.as_ref();
85        let secret = secret.as_ref();
86        if api_key.is_empty() || secret.is_empty() {
87            return Err(Error::Api("WebSupport credentials missing".into()));
88        }
89        let client = HttpClientBuilder::default()
90            .with_header("Accept", "application/json")
91            .with_header("Accept-Language", "en_us")
92            .with_timeout(timeout);
93        Ok(Self {
94            client,
95            api_key: api_key.to_string(),
96            secret: secret.to_string(),
97            endpoint: DEFAULT_ENDPOINT.to_string(),
98        })
99    }
100
101    #[cfg(test)]
102    pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
103        Self {
104            endpoint: endpoint.as_ref().to_string(),
105            ..self
106        }
107    }
108
109    fn signed(
110        &self,
111        request: crate::http::HttpClient,
112        method: Method,
113        path: &str,
114    ) -> crate::http::HttpClient {
115        let now = Utc::now();
116        let timestamp = now.timestamp();
117        let date = now.to_rfc3339_opts(SecondsFormat::Secs, true);
118        let canonical = format!("{} {} {}", method.as_str(), path, timestamp);
119        let signature = hex::encode(hmac_sha1(self.secret.as_bytes(), canonical.as_bytes()));
120        let basic = BASE64_STANDARD.encode(format!("{}:{}", self.api_key, signature));
121        request
122            .with_header("Authorization", format!("Basic {}", basic))
123            .with_header("Date", date)
124    }
125
126    pub(crate) async fn create(
127        &self,
128        name: impl IntoFqdn<'_>,
129        record: DnsRecord,
130        ttl: u32,
131        origin: impl IntoFqdn<'_>,
132    ) -> crate::Result<()> {
133        let name = name.into_name();
134        let domain = origin.into_name();
135        let subdomain = strip_origin_from_name(&name, &domain, Some(""));
136        let service_id = self.obtain_service_id(&domain).await?;
137        let path = format!("/v2/service/{}/dns/record", service_id);
138        let body = build_create_record(&subdomain, &record, ttl)?;
139        let url = format!("{}{}", self.endpoint, path);
140        self.signed(self.client.post(url).with_body(body)?, Method::POST, &path)
141            .send_raw()
142            .await
143            .map(|_| ())
144    }
145
146    pub(crate) async fn update(
147        &self,
148        name: impl IntoFqdn<'_>,
149        record: DnsRecord,
150        ttl: u32,
151        origin: impl IntoFqdn<'_>,
152    ) -> crate::Result<()> {
153        let name = name.into_name();
154        let domain = origin.into_name();
155        let subdomain = strip_origin_from_name(&name, &domain, Some(""));
156        let service_id = self.obtain_service_id(&domain).await?;
157        let record_type = record.as_type();
158        let record_ids = self
159            .find_record_ids(service_id, &subdomain, record_type)
160            .await?;
161        if record_ids.is_empty() {
162            return Err(Error::Api(format!(
163                "WebSupport record {} of type {} not found",
164                subdomain,
165                record_type.as_str()
166            )));
167        }
168        for id in record_ids {
169            let path = format!("/v2/service/{}/dns/record/{}", service_id, id);
170            let url = format!("{}{}", self.endpoint, path);
171            self.signed(self.client.delete(url), Method::DELETE, &path)
172                .send_raw()
173                .await?;
174        }
175        let path = format!("/v2/service/{}/dns/record", service_id);
176        let body = build_create_record(&subdomain, &record, ttl)?;
177        let url = format!("{}{}", self.endpoint, path);
178        self.signed(self.client.post(url).with_body(body)?, Method::POST, &path)
179            .send_raw()
180            .await
181            .map(|_| ())
182    }
183
184    pub(crate) async fn delete(
185        &self,
186        name: impl IntoFqdn<'_>,
187        origin: impl IntoFqdn<'_>,
188        record_type: DnsRecordType,
189    ) -> crate::Result<()> {
190        let name = name.into_name();
191        let domain = origin.into_name();
192        let subdomain = strip_origin_from_name(&name, &domain, Some(""));
193        let service_id = self.obtain_service_id(&domain).await?;
194        let record_ids = self
195            .find_record_ids(service_id, &subdomain, record_type)
196            .await?;
197        if record_ids.is_empty() {
198            return Err(Error::NotFound);
199        }
200        for id in record_ids {
201            let path = format!("/v2/service/{}/dns/record/{}", service_id, id);
202            let url = format!("{}{}", self.endpoint, path);
203            self.signed(self.client.delete(url), Method::DELETE, &path)
204                .send_raw()
205                .await?;
206        }
207        Ok(())
208    }
209
210    async fn obtain_service_id(&self, domain: &str) -> crate::Result<i64> {
211        let path = "/v1/user/self/service";
212        let url = format!("{}{}", self.endpoint, path);
213        let response: ServicesResponse = self
214            .signed(self.client.get(url), Method::GET, path)
215            .send()
216            .await?;
217        response
218            .items
219            .into_iter()
220            .find(|s| s.service_name == "domain" && s.name == domain)
221            .map(|s| s.id)
222            .ok_or_else(|| Error::Api(format!("WebSupport domain service {} not found", domain)))
223    }
224
225    async fn find_record_ids(
226        &self,
227        service_id: i64,
228        subdomain: &str,
229        record_type: DnsRecordType,
230    ) -> crate::Result<Vec<i64>> {
231        let path = format!("/v2/service/{}/dns/record", service_id);
232        let url = format!("{}{}", self.endpoint, path);
233        let response: RecordResponse = self
234            .signed(self.client.get(url), Method::GET, &path)
235            .send()
236            .await?;
237        let type_str = record_type.as_str();
238        Ok(response
239            .data
240            .into_iter()
241            .filter(|r| r.name == subdomain && r.record_type == type_str)
242            .map(|r| r.id)
243            .collect())
244    }
245}
246
247fn build_create_record<'a>(
248    name: &'a str,
249    record: &DnsRecord,
250    ttl: u32,
251) -> crate::Result<CreateRecord<'a>> {
252    let mut req = CreateRecord {
253        record_type: dns_type(record)?,
254        name,
255        content: String::new(),
256        ttl,
257        priority: None,
258        port: None,
259        weight: None,
260        flags: None,
261        tag: None,
262    };
263    match record {
264        DnsRecord::A(addr) => req.content = addr.to_string(),
265        DnsRecord::AAAA(addr) => req.content = addr.to_string(),
266        DnsRecord::CNAME(target) => req.content = target.clone(),
267        DnsRecord::NS(target) => req.content = target.clone(),
268        DnsRecord::MX(mx) => {
269            req.content = mx.exchange.clone();
270            req.priority = Some(mx.priority);
271        }
272        DnsRecord::TXT(text) => req.content = text.clone(),
273        DnsRecord::SRV(srv) => {
274            req.content = srv.target.clone();
275            req.priority = Some(srv.priority);
276            req.port = Some(srv.port);
277            req.weight = Some(srv.weight);
278        }
279        DnsRecord::TLSA(_) => {
280            return Err(Error::Api(
281                "TLSA records are not supported by WebSupport".into(),
282            ));
283        }
284        DnsRecord::CAA(caa) => {
285            let (flags, tag, value) = caa.clone().decompose();
286            req.content = value;
287            req.flags = Some(flags);
288            req.tag = Some(tag);
289        }
290    }
291    Ok(req)
292}
293
294fn dns_type(record: &DnsRecord) -> crate::Result<&'static str> {
295    match record {
296        DnsRecord::A(_) => Ok("A"),
297        DnsRecord::AAAA(_) => Ok("AAAA"),
298        DnsRecord::CNAME(_) => Ok("CNAME"),
299        DnsRecord::NS(_) => Ok("NS"),
300        DnsRecord::MX(_) => Ok("MX"),
301        DnsRecord::TXT(_) => Ok("TXT"),
302        DnsRecord::SRV(_) => Ok("SRV"),
303        DnsRecord::CAA(_) => Ok("CAA"),
304        DnsRecord::TLSA(_) => Err(Error::Api(
305            "TLSA records are not supported by WebSupport".into(),
306        )),
307    }
308}