Skip to main content

dns_update/providers/
volcengine.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
12#![cfg(any(feature = "ring", feature = "aws-lc-rs"))]
13
14use crate::crypto::{hmac_sha256, sha256_digest};
15use crate::utils::txt_chunks_to_text;
16use crate::{DnsRecord, DnsRecordType, Error, IntoFqdn};
17use chrono::Utc;
18use reqwest::Client;
19use serde::{Deserialize, Serialize};
20use serde_json::Value;
21use std::time::Duration;
22
23const VOLCENGINE_DEFAULT_HOST: &str = "open.volcengineapi.com";
24const VOLCENGINE_DEFAULT_REGION: &str = "cn-north-1";
25const VOLCENGINE_SERVICE: &str = "DNS";
26const VOLCENGINE_API_VERSION: &str = "2018-08-01";
27const VOLCENGINE_SIGN_ALGORITHM: &str = "HMAC-SHA256";
28
29#[derive(Debug, Clone)]
30pub struct VolcengineConfig {
31    pub access_key: String,
32    pub secret_key: String,
33    pub region: Option<String>,
34    pub host: Option<String>,
35    pub scheme: Option<String>,
36    pub request_timeout: Option<Duration>,
37}
38
39#[derive(Clone)]
40pub struct VolcengineProvider {
41    client: Client,
42    config: VolcengineConfig,
43    region: String,
44    host: String,
45    scheme: String,
46}
47
48impl VolcengineProvider {
49    pub(crate) fn new(config: VolcengineConfig) -> crate::Result<Self> {
50        if config.access_key.is_empty() || config.secret_key.is_empty() {
51            return Err(Error::Api(
52                "Volcengine credentials are required (access_key and secret_key)".into(),
53            ));
54        }
55
56        let region = config
57            .region
58            .clone()
59            .unwrap_or_else(|| VOLCENGINE_DEFAULT_REGION.to_string());
60        let host = config
61            .host
62            .clone()
63            .unwrap_or_else(|| VOLCENGINE_DEFAULT_HOST.to_string());
64        let scheme = config.scheme.clone().unwrap_or_else(|| "https".to_string());
65
66        let mut builder = Client::builder();
67        if let Some(timeout) = config.request_timeout {
68            builder = builder.timeout(timeout);
69        }
70        let client = builder
71            .build()
72            .map_err(|e| Error::Client(format!("Failed to build reqwest client: {}", e)))?;
73
74        Ok(Self {
75            client,
76            config,
77            region,
78            host,
79            scheme,
80        })
81    }
82
83    #[cfg(test)]
84    pub(crate) fn with_endpoint(mut self, endpoint: impl AsRef<str>) -> Self {
85        let endpoint = endpoint.as_ref();
86        if let Some(rest) = endpoint.strip_prefix("https://") {
87            self.scheme = "https".to_string();
88            self.host = rest.trim_end_matches('/').to_string();
89        } else if let Some(rest) = endpoint.strip_prefix("http://") {
90            self.scheme = "http".to_string();
91            self.host = rest.trim_end_matches('/').to_string();
92        } else {
93            self.host = endpoint.trim_end_matches('/').to_string();
94        }
95        self
96    }
97
98    pub(crate) async fn create(
99        &self,
100        name: impl IntoFqdn<'_>,
101        record: DnsRecord,
102        ttl: u32,
103        origin: impl IntoFqdn<'_>,
104    ) -> crate::Result<()> {
105        let name = name.into_name().to_string();
106        let origin = origin.into_name().to_string();
107        let zone = self.get_zone(&origin).await?;
108        let host = subdomain_for(&name, &zone.name);
109        let entry = record_to_entry(&record)?;
110
111        let body = serde_json::json!({
112            "ZID": zone.id,
113            "Host": host,
114            "Type": entry.record_type,
115            "Value": entry.value,
116            "TTL": ttl,
117        });
118
119        let final_body = if let Some(priority) = entry.priority {
120            let mut value = body;
121            value["Weight"] = priority.into();
122            value
123        } else {
124            body
125        };
126
127        self.send_action("CreateRecord", final_body).await.map(|_| ())
128    }
129
130    pub(crate) async fn update(
131        &self,
132        name: impl IntoFqdn<'_>,
133        record: DnsRecord,
134        ttl: u32,
135        origin: impl IntoFqdn<'_>,
136    ) -> crate::Result<()> {
137        let name = name.into_name().to_string();
138        let origin = origin.into_name().to_string();
139        let zone = self.get_zone(&origin).await?;
140        let host = subdomain_for(&name, &zone.name);
141        let entry = record_to_entry(&record)?;
142        let record_id = self
143            .find_record_id(&zone.id, &host, &entry.record_type)
144            .await?;
145
146        let body = serde_json::json!({
147            "RecordID": record_id,
148            "Host": host,
149            "Type": entry.record_type,
150            "Value": entry.value,
151            "TTL": ttl,
152        });
153
154        let final_body = if let Some(priority) = entry.priority {
155            let mut value = body;
156            value["Weight"] = priority.into();
157            value
158        } else {
159            body
160        };
161
162        self.send_action("UpdateRecord", final_body).await.map(|_| ())
163    }
164
165    pub(crate) async fn delete(
166        &self,
167        name: impl IntoFqdn<'_>,
168        origin: impl IntoFqdn<'_>,
169        record_type: DnsRecordType,
170    ) -> crate::Result<()> {
171        let name = name.into_name().to_string();
172        let origin = origin.into_name().to_string();
173        let zone = self.get_zone(&origin).await?;
174        let host = subdomain_for(&name, &zone.name);
175        let type_str = record_type_str(record_type)?;
176        let record_id = self.find_record_id(&zone.id, &host, type_str).await?;
177
178        let body = serde_json::json!({ "RecordID": record_id });
179        self.send_action("DeleteRecord", body).await.map(|_| ())
180    }
181
182    async fn get_zone(&self, origin: &str) -> crate::Result<ResolvedZone> {
183        let trimmed = origin.trim_end_matches('.').to_string();
184        let body = serde_json::json!({
185            "Key": trimmed,
186            "SearchMode": "exact",
187        });
188        let response = self.send_action("ListZones", body).await?;
189        let result = response
190            .get("Result")
191            .ok_or_else(|| Error::Api("Volcengine ListZones response missing Result".into()))?;
192        let total = result
193            .get("Total")
194            .and_then(Value::as_u64)
195            .unwrap_or(0);
196        if total == 0 {
197            return Err(Error::Api(format!(
198                "No Volcengine zone found for origin {}",
199                origin
200            )));
201        }
202        if total > 1 {
203            return Err(Error::Api(format!(
204                "Multiple Volcengine zones matched origin {}",
205                origin
206            )));
207        }
208        let zones = result
209            .get("Zones")
210            .and_then(Value::as_array)
211            .ok_or_else(|| Error::Api("Volcengine ListZones response missing Zones".into()))?;
212        let zone = zones.first().ok_or_else(|| {
213            Error::Api(format!("Volcengine zone list empty for origin {}", origin))
214        })?;
215        let id = zone
216            .get("ZID")
217            .and_then(Value::as_i64)
218            .ok_or_else(|| Error::Api("Volcengine zone missing ZID".into()))?;
219        let name = zone
220            .get("ZoneName")
221            .and_then(Value::as_str)
222            .ok_or_else(|| Error::Api("Volcengine zone missing ZoneName".into()))?
223            .trim_end_matches('.')
224            .to_string();
225        Ok(ResolvedZone { id, name })
226    }
227
228    async fn find_record_id(
229        &self,
230        zone_id: &i64,
231        host: &str,
232        record_type: &str,
233    ) -> crate::Result<String> {
234        let body = serde_json::json!({
235            "ZID": zone_id,
236            "Host": host,
237            "Type": record_type,
238            "PageSize": 100,
239        });
240        let response = self.send_action("ListRecords", body).await?;
241        let result = response
242            .get("Result")
243            .ok_or_else(|| Error::Api("Volcengine ListRecords response missing Result".into()))?;
244        let records = result
245            .get("Records")
246            .and_then(Value::as_array)
247            .ok_or_else(|| Error::Api("Volcengine ListRecords response missing Records".into()))?;
248        let record = records
249            .iter()
250            .find(|r| {
251                let h = r.get("Host").and_then(Value::as_str).unwrap_or("");
252                let t = r.get("Type").and_then(Value::as_str).unwrap_or("");
253                h == host && t == record_type
254            })
255            .ok_or_else(|| {
256                Error::Api(format!(
257                    "Volcengine record {} of type {} not found",
258                    host, record_type
259                ))
260            })?;
261        record
262            .get("RecordID")
263            .and_then(Value::as_str)
264            .map(ToString::to_string)
265            .ok_or_else(|| Error::Api("Volcengine record missing RecordID".into()))
266    }
267
268    async fn send_action(&self, action: &str, body: Value) -> crate::Result<Value> {
269        let body_text = serde_json::to_string(&body)
270            .map_err(|e| Error::Serialize(format!("Failed to serialize request: {}", e)))?;
271        let query = format!("Action={}&Version={}", action, VOLCENGINE_API_VERSION);
272        let canonical_query = canonical_query_string(&query);
273
274        let datetime = Utc::now();
275        let amz_date = datetime.format("%Y%m%dT%H%M%SZ").to_string();
276        let date_stamp = datetime.format("%Y%m%d").to_string();
277        let payload_hash = hex::encode(sha256_digest(body_text.as_bytes()));
278
279        let canonical_headers = format!(
280            "content-type:application/json\nhost:{}\nx-content-sha256:{}\nx-date:{}\n",
281            self.host, payload_hash, amz_date
282        );
283        let signed_headers = "content-type;host;x-content-sha256;x-date";
284
285        let canonical_request = format!(
286            "POST\n/\n{}\n{}\n{}\n{}",
287            canonical_query, canonical_headers, signed_headers, payload_hash
288        );
289
290        let credential_scope = format!(
291            "{}/{}/{}/request",
292            date_stamp, self.region, VOLCENGINE_SERVICE
293        );
294        let string_to_sign = format!(
295            "{}\n{}\n{}\n{}",
296            VOLCENGINE_SIGN_ALGORITHM,
297            amz_date,
298            credential_scope,
299            hex::encode(sha256_digest(canonical_request.as_bytes()))
300        );
301
302        let signing_key = self.derive_signing_key(&date_stamp);
303        let signature = hex::encode(hmac_sha256(&signing_key, string_to_sign.as_bytes()));
304
305        let authorization = format!(
306            "{} Credential={}/{}, SignedHeaders={}, Signature={}",
307            VOLCENGINE_SIGN_ALGORITHM,
308            self.config.access_key,
309            credential_scope,
310            signed_headers,
311            signature
312        );
313
314        let url = format!("{}://{}/?{}", self.scheme, self.host, query);
315        let response = self
316            .client
317            .post(&url)
318            .header("Content-Type", "application/json")
319            .header("Host", &self.host)
320            .header("X-Date", &amz_date)
321            .header("X-Content-Sha256", &payload_hash)
322            .header("Authorization", &authorization)
323            .body(body_text)
324            .send()
325            .await
326            .map_err(|e| Error::Api(format!("Volcengine request failed: {}", e)))?;
327
328        let status = response.status();
329        let text = response
330            .text()
331            .await
332            .map_err(|e| Error::Api(format!("Failed to read Volcengine response: {}", e)))?;
333
334        if !status.is_success() {
335            return Err(match status.as_u16() {
336                400 => Error::Api(format!("BadRequest {}", text)),
337                401 | 403 => Error::Unauthorized,
338                404 => Error::NotFound,
339                _ => Error::Api(format!("Volcengine API error {}: {}", status, text)),
340            });
341        }
342
343        let parsed: Value = if text.is_empty() {
344            Value::Null
345        } else {
346            serde_json::from_str(&text)
347                .map_err(|e| Error::Api(format!("Failed to parse Volcengine response: {}", e)))?
348        };
349
350        if let Some(error) = parsed.get("ResponseMetadata").and_then(|m| m.get("Error")) {
351            let code = error
352                .get("CodeN")
353                .and_then(Value::as_i64)
354                .unwrap_or_default();
355            let message = error
356                .get("Message")
357                .and_then(Value::as_str)
358                .unwrap_or("unknown error");
359            return Err(Error::Api(format!(
360                "Volcengine API error {}: {}",
361                code, message
362            )));
363        }
364
365        Ok(parsed)
366    }
367
368    fn derive_signing_key(&self, date_stamp: &str) -> Vec<u8> {
369        let k_date = hmac_sha256(self.config.secret_key.as_bytes(), date_stamp.as_bytes());
370        let k_region = hmac_sha256(&k_date, self.region.as_bytes());
371        let k_service = hmac_sha256(&k_region, VOLCENGINE_SERVICE.as_bytes());
372        hmac_sha256(&k_service, b"request")
373    }
374}
375
376#[derive(Debug, Clone)]
377struct ResolvedZone {
378    id: i64,
379    name: String,
380}
381
382#[derive(Debug, Serialize, Deserialize)]
383struct RecordEntry {
384    record_type: String,
385    value: String,
386    priority: Option<u16>,
387}
388
389fn record_to_entry(record: &DnsRecord) -> crate::Result<RecordEntry> {
390    let entry = match record {
391        DnsRecord::A(ip) => RecordEntry {
392            record_type: "A".into(),
393            value: ip.to_string(),
394            priority: None,
395        },
396        DnsRecord::AAAA(ip) => RecordEntry {
397            record_type: "AAAA".into(),
398            value: ip.to_string(),
399            priority: None,
400        },
401        DnsRecord::CNAME(target) => RecordEntry {
402            record_type: "CNAME".into(),
403            value: target.trim_end_matches('.').to_string(),
404            priority: None,
405        },
406        DnsRecord::NS(target) => RecordEntry {
407            record_type: "NS".into(),
408            value: target.trim_end_matches('.').to_string(),
409            priority: None,
410        },
411        DnsRecord::MX(mx) => RecordEntry {
412            record_type: "MX".into(),
413            value: mx.exchange.trim_end_matches('.').to_string(),
414            priority: Some(mx.priority),
415        },
416        DnsRecord::TXT(txt) => {
417            let mut buf = String::new();
418            txt_chunks_to_text(&mut buf, txt, " ");
419            RecordEntry {
420                record_type: "TXT".into(),
421                value: buf,
422                priority: None,
423            }
424        }
425        DnsRecord::SRV(srv) => RecordEntry {
426            record_type: "SRV".into(),
427            value: format!(
428                "{} {} {} {}",
429                srv.priority,
430                srv.weight,
431                srv.port,
432                srv.target.trim_end_matches('.')
433            ),
434            priority: None,
435        },
436        DnsRecord::CAA(caa) => {
437            let (flags, tag, value) = caa.clone().decompose();
438            RecordEntry {
439                record_type: "CAA".into(),
440                value: format!("{} {} \"{}\"", flags, tag, value),
441                priority: None,
442            }
443        }
444        DnsRecord::TLSA(_) => {
445            return Err(Error::Api(
446                "TLSA records are not supported by Volcengine".into(),
447            ));
448        }
449    };
450    Ok(entry)
451}
452
453fn record_type_str(record_type: DnsRecordType) -> crate::Result<&'static str> {
454    Ok(match record_type {
455        DnsRecordType::A => "A",
456        DnsRecordType::AAAA => "AAAA",
457        DnsRecordType::CNAME => "CNAME",
458        DnsRecordType::NS => "NS",
459        DnsRecordType::MX => "MX",
460        DnsRecordType::TXT => "TXT",
461        DnsRecordType::SRV => "SRV",
462        DnsRecordType::CAA => "CAA",
463        DnsRecordType::TLSA => {
464            return Err(Error::Api(
465                "TLSA records are not supported by Volcengine".into(),
466            ));
467        }
468    })
469}
470
471fn subdomain_for(name: &str, zone_name: &str) -> String {
472    let name = name.trim_end_matches('.');
473    let zone = zone_name.trim_end_matches('.');
474    if name == zone {
475        "@".to_string()
476    } else if let Some(stripped) = name.strip_suffix(&format!(".{}", zone)) {
477        stripped.to_string()
478    } else {
479        name.to_string()
480    }
481}
482
483fn canonical_query_string(query: &str) -> String {
484    let mut pairs: Vec<(String, String)> = query
485        .split('&')
486        .filter(|s| !s.is_empty())
487        .map(|p| {
488            let mut iter = p.splitn(2, '=');
489            let k = iter.next().unwrap_or("");
490            let v = iter.next().unwrap_or("");
491            (
492                volc_uri_encode(k, true),
493                volc_uri_encode(v, true),
494            )
495        })
496        .collect();
497    pairs.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
498    pairs
499        .into_iter()
500        .map(|(k, v)| format!("{}={}", k, v))
501        .collect::<Vec<_>>()
502        .join("&")
503}
504
505fn volc_uri_encode(input: &str, encode_slash: bool) -> String {
506    let mut out = String::with_capacity(input.len());
507    for &b in input.as_bytes() {
508        let ch = b as char;
509        let unreserved = ch.is_ascii_alphanumeric()
510            || ch == '-'
511            || ch == '_'
512            || ch == '.'
513            || ch == '~'
514            || (!encode_slash && ch == '/');
515        if unreserved {
516            out.push(ch);
517        } else {
518            out.push_str(&format!("%{:02X}", b));
519        }
520    }
521    out
522}