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