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::http::{HttpClient, HttpClientBuilder};
16use crate::utils::{strip_origin_from_name, txt_chunks_to_text};
17use crate::{CAARecord, DnsRecord, DnsRecordType, Error, IntoFqdn, KeyValue, MXRecord, SRVRecord};
18use chrono::Utc;
19use serde::Deserialize;
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    access_key: String,
42    secret_key: String,
43    region: String,
44    host: String,
45    scheme: String,
46    client: HttpClient,
47}
48
49#[derive(Debug, Clone, Deserialize)]
50struct ListedRecord {
51    #[serde(rename = "RecordID")]
52    record_id: String,
53    #[serde(rename = "Host")]
54    host: String,
55    #[serde(rename = "Type")]
56    record_type: String,
57    #[serde(rename = "Value", default)]
58    value: String,
59}
60
61#[derive(Debug, Clone)]
62struct ResolvedZone {
63    id: i64,
64    name: String,
65}
66
67impl VolcengineProvider {
68    pub(crate) fn new(config: VolcengineConfig) -> crate::Result<Self> {
69        if config.access_key.is_empty() || config.secret_key.is_empty() {
70            return Err(Error::Api(
71                "Volcengine credentials are required (access_key and secret_key)".into(),
72            ));
73        }
74
75        let region = config
76            .region
77            .unwrap_or_else(|| VOLCENGINE_DEFAULT_REGION.to_string());
78        let host = config
79            .host
80            .unwrap_or_else(|| VOLCENGINE_DEFAULT_HOST.to_string());
81        let scheme = config.scheme.unwrap_or_else(|| "https".to_string());
82
83        let client = HttpClientBuilder::default()
84            .with_timeout(config.request_timeout)
85            .build();
86        Ok(Self {
87            access_key: config.access_key,
88            secret_key: config.secret_key,
89            region,
90            host,
91            scheme,
92            client,
93        })
94    }
95
96    #[cfg(test)]
97    pub(crate) fn with_endpoint(mut self, endpoint: impl AsRef<str>) -> Self {
98        let endpoint = endpoint.as_ref();
99        if let Some(rest) = endpoint.strip_prefix("https://") {
100            self.scheme = "https".to_string();
101            self.host = rest.trim_end_matches('/').to_string();
102        } else if let Some(rest) = endpoint.strip_prefix("http://") {
103            self.scheme = "http".to_string();
104            self.host = rest.trim_end_matches('/').to_string();
105        } else {
106            self.host = endpoint.trim_end_matches('/').to_string();
107        }
108        self
109    }
110
111    pub(crate) async fn set_rrset(
112        &self,
113        name: impl IntoFqdn<'_>,
114        record_type: DnsRecordType,
115        ttl: u32,
116        records: Vec<DnsRecord>,
117        origin: impl IntoFqdn<'_>,
118    ) -> crate::Result<()> {
119        let type_str = record_type_str(record_type)?;
120        let desired = build_values(record_type, records)?;
121        let name = name.into_name().to_string();
122        let origin = origin.into_name().to_string();
123        let zone = self.get_zone(&origin).await?;
124        let host = strip_origin_from_name(&name, &zone.name, None);
125        let existing = self.list_records(zone.id, &host, type_str).await?;
126
127        let mut pool = existing;
128        let mut to_add: Vec<String> = Vec::new();
129        for value in desired {
130            if let Some(idx) = pool.iter().position(|r| r.value == value) {
131                pool.swap_remove(idx);
132            } else if !to_add.contains(&value) {
133                to_add.push(value);
134            }
135        }
136
137        for stale in pool {
138            self.delete_record(&stale.record_id).await?;
139        }
140        for value in to_add {
141            self.create_record(zone.id, &host, type_str, &value, ttl)
142                .await?;
143        }
144        Ok(())
145    }
146
147    pub(crate) async fn add_to_rrset(
148        &self,
149        name: impl IntoFqdn<'_>,
150        record_type: DnsRecordType,
151        ttl: u32,
152        records: Vec<DnsRecord>,
153        origin: impl IntoFqdn<'_>,
154    ) -> crate::Result<()> {
155        if records.is_empty() {
156            return Ok(());
157        }
158        let type_str = record_type_str(record_type)?;
159        let desired = build_values(record_type, records)?;
160        let name = name.into_name().to_string();
161        let origin = origin.into_name().to_string();
162        let zone = self.get_zone(&origin).await?;
163        let host = strip_origin_from_name(&name, &zone.name, None);
164        let existing = self.list_records(zone.id, &host, type_str).await?;
165
166        let mut queued: Vec<String> = Vec::new();
167        for value in desired {
168            if existing.iter().any(|r| r.value == value) {
169                continue;
170            }
171            if queued.contains(&value) {
172                continue;
173            }
174            self.create_record(zone.id, &host, type_str, &value, ttl)
175                .await?;
176            queued.push(value);
177        }
178        Ok(())
179    }
180
181    pub(crate) async fn remove_from_rrset(
182        &self,
183        name: impl IntoFqdn<'_>,
184        record_type: DnsRecordType,
185        records: Vec<DnsRecord>,
186        origin: impl IntoFqdn<'_>,
187    ) -> crate::Result<()> {
188        if records.is_empty() {
189            return Ok(());
190        }
191        let type_str = record_type_str(record_type)?;
192        let to_remove = build_values(record_type, records)?;
193        let name = name.into_name().to_string();
194        let origin = origin.into_name().to_string();
195        let zone = self.get_zone(&origin).await?;
196        let host = strip_origin_from_name(&name, &zone.name, None);
197        let existing = self.list_records(zone.id, &host, type_str).await?;
198
199        for value in to_remove {
200            if let Some(entry) = existing.iter().find(|r| r.value == value) {
201                self.delete_record(&entry.record_id).await?;
202            }
203        }
204        Ok(())
205    }
206
207    pub(crate) async fn list_rrset(
208        &self,
209        name: impl IntoFqdn<'_>,
210        record_type: DnsRecordType,
211        origin: impl IntoFqdn<'_>,
212    ) -> crate::Result<Vec<DnsRecord>> {
213        let type_str = record_type_str(record_type)?;
214        let name = name.into_name().to_string();
215        let origin = origin.into_name().to_string();
216        let zone = self.get_zone(&origin).await?;
217        let host = strip_origin_from_name(&name, &zone.name, None);
218        let existing = self.list_records(zone.id, &host, type_str).await?;
219        existing
220            .into_iter()
221            .map(|r| value_to_record(record_type, &r.value))
222            .collect()
223    }
224
225    async fn get_zone(&self, origin: &str) -> crate::Result<ResolvedZone> {
226        let trimmed = origin.trim_end_matches('.').to_string();
227        let body = serde_json::json!({
228            "Key": trimmed,
229            "SearchMode": "exact",
230        });
231        let response = self.send_action("ListZones", body).await?;
232        let result = response
233            .get("Result")
234            .ok_or_else(|| Error::Api("Volcengine ListZones response missing Result".into()))?;
235        let zones = result
236            .get("Zones")
237            .and_then(Value::as_array)
238            .ok_or_else(|| Error::Api("Volcengine ListZones response missing Zones".into()))?;
239        let matched = zones
240            .iter()
241            .find(|z| {
242                z.get("ZoneName")
243                    .and_then(Value::as_str)
244                    .map(|n| n.trim_end_matches('.') == trimmed)
245                    .unwrap_or(false)
246            })
247            .ok_or_else(|| Error::Api(format!("No Volcengine zone found for origin {}", origin)))?;
248        let id = matched
249            .get("ZID")
250            .and_then(Value::as_i64)
251            .ok_or_else(|| Error::Api("Volcengine zone missing ZID".into()))?;
252        let name = matched
253            .get("ZoneName")
254            .and_then(Value::as_str)
255            .ok_or_else(|| Error::Api("Volcengine zone missing ZoneName".into()))?
256            .trim_end_matches('.')
257            .to_string();
258        Ok(ResolvedZone { id, name })
259    }
260
261    async fn list_records(
262        &self,
263        zone_id: i64,
264        host: &str,
265        record_type: &str,
266    ) -> crate::Result<Vec<ListedRecord>> {
267        let body = serde_json::json!({
268            "ZID": zone_id,
269            "Host": host,
270            "Type": record_type,
271            "SearchMode": "exact",
272            "PageSize": "100",
273        });
274        let response = self.send_action("ListRecords", body).await?;
275        let records = response
276            .get("Result")
277            .and_then(|r| r.get("Records"))
278            .cloned()
279            .unwrap_or(Value::Array(Vec::new()));
280        let parsed: Vec<ListedRecord> = serde_json::from_value(records).map_err(|e| {
281            Error::Serialize(format!("Failed to parse Volcengine record list: {}", e))
282        })?;
283        Ok(parsed
284            .into_iter()
285            .filter(|r| r.host == host && r.record_type == record_type)
286            .collect())
287    }
288
289    async fn create_record(
290        &self,
291        zone_id: i64,
292        host: &str,
293        record_type: &str,
294        value: &str,
295        ttl: u32,
296    ) -> crate::Result<()> {
297        let body = serde_json::json!({
298            "ZID": zone_id,
299            "Host": host,
300            "Type": record_type,
301            "Value": value,
302            "TTL": ttl,
303        });
304        self.send_action("CreateRecord", body).await.map(|_| ())
305    }
306
307    async fn delete_record(&self, record_id: &str) -> crate::Result<()> {
308        let body = serde_json::json!({ "RecordID": record_id });
309        self.send_action("DeleteRecord", body).await.map(|_| ())
310    }
311
312    async fn send_action(&self, action: &str, body: Value) -> crate::Result<Value> {
313        let body_text = serde_json::to_string(&body)
314            .map_err(|e| Error::Serialize(format!("Failed to serialize request: {}", e)))?;
315        let query = format!("Action={}&Version={}", action, VOLCENGINE_API_VERSION);
316        let canonical_query = canonical_query_string(&query);
317
318        let datetime = Utc::now();
319        let amz_date = datetime.format("%Y%m%dT%H%M%SZ").to_string();
320        let date_stamp = datetime.format("%Y%m%d").to_string();
321        let payload_hash = hex::encode(sha256_digest(body_text.as_bytes()));
322
323        let canonical_headers = format!(
324            "content-type:application/json\nhost:{}\nx-content-sha256:{}\nx-date:{}\n",
325            self.host, payload_hash, amz_date
326        );
327        let signed_headers = "content-type;host;x-content-sha256;x-date";
328
329        let canonical_request = format!(
330            "POST\n/\n{}\n{}\n{}\n{}",
331            canonical_query, canonical_headers, signed_headers, payload_hash
332        );
333
334        let credential_scope = format!(
335            "{}/{}/{}/request",
336            date_stamp, self.region, VOLCENGINE_SERVICE
337        );
338        let string_to_sign = format!(
339            "{}\n{}\n{}\n{}",
340            VOLCENGINE_SIGN_ALGORITHM,
341            amz_date,
342            credential_scope,
343            hex::encode(sha256_digest(canonical_request.as_bytes()))
344        );
345
346        let signing_key = self.derive_signing_key(&date_stamp);
347        let signature = hex::encode(hmac_sha256(&signing_key, string_to_sign.as_bytes()));
348
349        let authorization = format!(
350            "{} Credential={}/{}, SignedHeaders={}, Signature={}",
351            VOLCENGINE_SIGN_ALGORITHM, self.access_key, credential_scope, signed_headers, signature
352        );
353
354        let url = format!("{}://{}/?{}", self.scheme, self.host, query);
355        let text = self
356            .client
357            .post(url)
358            .with_header("Host", &self.host)
359            .with_header("X-Date", &amz_date)
360            .with_header("X-Content-Sha256", &payload_hash)
361            .with_header("Authorization", &authorization)
362            .with_raw_body(body_text)
363            .send_raw()
364            .await?;
365
366        let parsed: Value = if text.is_empty() {
367            Value::Null
368        } else {
369            serde_json::from_str(&text)
370                .map_err(|e| Error::Api(format!("Failed to parse Volcengine response: {}", e)))?
371        };
372
373        if let Some(error) = parsed.get("ResponseMetadata").and_then(|m| m.get("Error")) {
374            let code = error
375                .get("CodeN")
376                .and_then(Value::as_i64)
377                .unwrap_or_default();
378            let message = error
379                .get("Message")
380                .and_then(Value::as_str)
381                .unwrap_or("unknown error");
382            return Err(Error::Api(format!(
383                "Volcengine API error {}: {}",
384                code, message
385            )));
386        }
387
388        Ok(parsed)
389    }
390
391    fn derive_signing_key(&self, date_stamp: &str) -> Vec<u8> {
392        let k_date = hmac_sha256(self.secret_key.as_bytes(), date_stamp.as_bytes());
393        let k_region = hmac_sha256(&k_date, self.region.as_bytes());
394        let k_service = hmac_sha256(&k_region, VOLCENGINE_SERVICE.as_bytes());
395        hmac_sha256(&k_service, b"request")
396    }
397}
398
399fn record_to_value(record: &DnsRecord) -> crate::Result<(&'static str, String)> {
400    Ok(match record {
401        DnsRecord::A(ip) => ("A", ip.to_string()),
402        DnsRecord::AAAA(ip) => ("AAAA", ip.to_string()),
403        DnsRecord::CNAME(target) => ("CNAME", target.trim_end_matches('.').to_string()),
404        DnsRecord::NS(target) => ("NS", target.trim_end_matches('.').to_string()),
405        DnsRecord::MX(mx) => (
406            "MX",
407            format!("{} {}", mx.priority, mx.exchange.trim_end_matches('.')),
408        ),
409        DnsRecord::TXT(txt) => {
410            let mut buf = String::new();
411            txt_chunks_to_text(&mut buf, txt, " ");
412            ("TXT", buf)
413        }
414        DnsRecord::SRV(srv) => (
415            "SRV",
416            format!(
417                "{} {} {} {}",
418                srv.priority,
419                srv.weight,
420                srv.port,
421                srv.target.trim_end_matches('.')
422            ),
423        ),
424        DnsRecord::CAA(caa) => {
425            let (flags, tag, value) = caa.clone().decompose();
426            ("CAA", format!("{} {} \"{}\"", flags, tag, value))
427        }
428        DnsRecord::TLSA(_) => {
429            return Err(Error::Unsupported(
430                "TLSA records are not supported by Volcengine".into(),
431            ));
432        }
433    })
434}
435
436fn build_values(
437    expected_type: DnsRecordType,
438    records: Vec<DnsRecord>,
439) -> crate::Result<Vec<String>> {
440    let mut out = Vec::with_capacity(records.len());
441    for record in records {
442        if record.as_type() != expected_type {
443            return Err(Error::Api(format!(
444                "RRSet record type mismatch: expected {}, got {}",
445                expected_type.as_str(),
446                record.as_type().as_str(),
447            )));
448        }
449        let (_, value) = record_to_value(&record)?;
450        out.push(value);
451    }
452    Ok(out)
453}
454
455fn value_to_record(record_type: DnsRecordType, value: &str) -> crate::Result<DnsRecord> {
456    match record_type {
457        DnsRecordType::A => value
458            .parse()
459            .map(DnsRecord::A)
460            .map_err(|e| Error::Parse(format!("Invalid A value {}: {}", value, e))),
461        DnsRecordType::AAAA => value
462            .parse()
463            .map(DnsRecord::AAAA)
464            .map_err(|e| Error::Parse(format!("Invalid AAAA value {}: {}", value, e))),
465        DnsRecordType::CNAME => Ok(DnsRecord::CNAME(value.to_string())),
466        DnsRecordType::NS => Ok(DnsRecord::NS(value.to_string())),
467        DnsRecordType::MX => {
468            let (priority, exchange) = value
469                .split_once(' ')
470                .ok_or_else(|| Error::Parse(format!("Invalid MX value (no space): {}", value)))?;
471            let priority: u16 = priority
472                .parse()
473                .map_err(|e| Error::Parse(format!("Invalid MX priority {}: {}", priority, e)))?;
474            Ok(DnsRecord::MX(MXRecord {
475                priority,
476                exchange: exchange.to_string(),
477            }))
478        }
479        DnsRecordType::TXT => Ok(DnsRecord::TXT(unquote_txt(value))),
480        DnsRecordType::SRV => {
481            let parts: Vec<&str> = value.splitn(4, ' ').collect();
482            if parts.len() != 4 {
483                return Err(Error::Parse(format!("Invalid SRV value: {}", value)));
484            }
485            Ok(DnsRecord::SRV(SRVRecord {
486                priority: parts[0]
487                    .parse()
488                    .map_err(|e| Error::Parse(format!("Invalid SRV priority: {}", e)))?,
489                weight: parts[1]
490                    .parse()
491                    .map_err(|e| Error::Parse(format!("Invalid SRV weight: {}", e)))?,
492                port: parts[2]
493                    .parse()
494                    .map_err(|e| Error::Parse(format!("Invalid SRV port: {}", e)))?,
495                target: parts[3].to_string(),
496            }))
497        }
498        DnsRecordType::CAA => parse_caa_value(value).map(DnsRecord::CAA),
499        DnsRecordType::TLSA => Err(Error::Unsupported(
500            "TLSA records are not supported by Volcengine".into(),
501        )),
502    }
503}
504
505fn unquote_txt(content: &str) -> String {
506    let mut out = String::with_capacity(content.len());
507    let bytes = content.as_bytes();
508    let mut i = 0;
509    let mut in_quotes = false;
510    let mut any_quotes = false;
511    while i < bytes.len() {
512        let b = bytes[i];
513        if b == b'"' {
514            any_quotes = true;
515            in_quotes = !in_quotes;
516            i += 1;
517            continue;
518        }
519        if in_quotes && b == b'\\' && i + 1 < bytes.len() {
520            let next = bytes[i + 1];
521            if next == b'"' || next == b'\\' {
522                out.push(next as char);
523                i += 2;
524                continue;
525            }
526        }
527        if !any_quotes || in_quotes {
528            out.push(b as char);
529        }
530        i += 1;
531    }
532    if !any_quotes {
533        return content.to_string();
534    }
535    out
536}
537
538fn parse_caa_value(value: &str) -> crate::Result<CAARecord> {
539    let trimmed = value.trim();
540    let (flags_str, rest) = trimmed
541        .split_once(' ')
542        .ok_or_else(|| Error::Parse(format!("Invalid CAA value: {}", value)))?;
543    let flags: u8 = flags_str
544        .parse()
545        .map_err(|e| Error::Parse(format!("Invalid CAA flags {}: {}", flags_str, e)))?;
546    let issuer_critical = flags & 0x80 != 0;
547    let (tag, raw_value) = rest
548        .trim_start()
549        .split_once(' ')
550        .ok_or_else(|| Error::Parse(format!("Invalid CAA tag/value: {}", value)))?;
551    let raw_value = raw_value.trim();
552    let stripped = raw_value
553        .strip_prefix('"')
554        .and_then(|s| s.strip_suffix('"'))
555        .unwrap_or(raw_value);
556    match tag {
557        "issue" => {
558            let (name, options) = split_caa_options(stripped);
559            Ok(CAARecord::Issue {
560                issuer_critical,
561                name,
562                options,
563            })
564        }
565        "issuewild" => {
566            let (name, options) = split_caa_options(stripped);
567            Ok(CAARecord::IssueWild {
568                issuer_critical,
569                name,
570                options,
571            })
572        }
573        "iodef" => Ok(CAARecord::Iodef {
574            issuer_critical,
575            url: stripped.to_string(),
576        }),
577        other => Err(Error::Parse(format!("Unknown CAA tag: {}", other))),
578    }
579}
580
581fn split_caa_options(value: &str) -> (Option<String>, Vec<KeyValue>) {
582    let mut parts = value.split(';').map(str::trim);
583    let name_part = parts.next().unwrap_or("").trim().to_string();
584    let name = if name_part.is_empty() {
585        None
586    } else {
587        Some(name_part)
588    };
589    let options = parts
590        .filter(|p| !p.is_empty())
591        .map(|p| match p.split_once('=') {
592            Some((k, v)) => KeyValue {
593                key: k.trim().to_string(),
594                value: v.trim().to_string(),
595            },
596            None => KeyValue {
597                key: p.trim().to_string(),
598                value: String::new(),
599            },
600        })
601        .collect();
602    (name, options)
603}
604
605fn record_type_str(record_type: DnsRecordType) -> crate::Result<&'static str> {
606    Ok(match record_type {
607        DnsRecordType::A => "A",
608        DnsRecordType::AAAA => "AAAA",
609        DnsRecordType::CNAME => "CNAME",
610        DnsRecordType::NS => "NS",
611        DnsRecordType::MX => "MX",
612        DnsRecordType::TXT => "TXT",
613        DnsRecordType::SRV => "SRV",
614        DnsRecordType::CAA => "CAA",
615        DnsRecordType::TLSA => {
616            return Err(Error::Unsupported(
617                "TLSA records are not supported by Volcengine".into(),
618            ));
619        }
620    })
621}
622
623fn canonical_query_string(query: &str) -> String {
624    let mut pairs: Vec<(String, String)> = query
625        .split('&')
626        .filter(|s| !s.is_empty())
627        .map(|p| {
628            let mut iter = p.splitn(2, '=');
629            let k = iter.next().unwrap_or("");
630            let v = iter.next().unwrap_or("");
631            (volc_uri_encode(k, true), volc_uri_encode(v, true))
632        })
633        .collect();
634    pairs.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
635    pairs
636        .into_iter()
637        .map(|(k, v)| format!("{}={}", k, v))
638        .collect::<Vec<_>>()
639        .join("&")
640}
641
642fn volc_uri_encode(input: &str, encode_slash: bool) -> String {
643    let mut out = String::with_capacity(input.len());
644    for &b in input.as_bytes() {
645        let ch = b as char;
646        let unreserved = ch.is_ascii_alphanumeric()
647            || ch == '-'
648            || ch == '_'
649            || ch == '.'
650            || ch == '~'
651            || (!encode_slash && ch == '/');
652        if unreserved {
653            out.push(ch);
654        } else {
655            out.push_str(&format!("%{:02X}", b));
656        }
657    }
658    out
659}