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