1use 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}