Skip to main content

dns_update/providers/
transip.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::{
15    CAARecord, DnsRecord, DnsRecordType, Error, IntoFqdn, KeyValue, MXRecord, SRVRecord,
16    TLSARecord, TlsaCertUsage, TlsaMatching, TlsaSelector,
17    http::{HttpClient, HttpClientBuilder, HttpRequest},
18    jwt::rsa_sha512_sign,
19    utils::strip_origin_from_name,
20};
21use base64::{Engine as _, engine::general_purpose::STANDARD};
22use serde::{Deserialize, Serialize};
23use std::sync::{Arc, Mutex};
24use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
25
26const DEFAULT_API_ENDPOINT: &str = "https://api.transip.nl/v6";
27
28#[derive(Clone)]
29pub struct TransipProvider {
30    auth: Arc<Mutex<AuthState>>,
31    login: String,
32    private_key_pem: String,
33    global_key: bool,
34    endpoint: String,
35    client: HttpClient,
36}
37
38struct AuthState {
39    token: Option<(String, Instant)>,
40}
41
42#[derive(Serialize, Debug)]
43struct AuthRequest<'a> {
44    login: &'a str,
45    nonce: String,
46    read_only: bool,
47    expiration_time: &'a str,
48    label: String,
49    global_key: bool,
50}
51
52#[derive(Deserialize, Debug)]
53struct AuthResponse {
54    token: String,
55}
56
57#[derive(Serialize, Deserialize, Debug, Clone)]
58struct DnsEntry {
59    name: String,
60    expire: u32,
61    #[serde(rename = "type")]
62    record_type: String,
63    content: String,
64}
65
66#[derive(Deserialize, Debug)]
67struct DnsEntriesResponse {
68    #[serde(rename = "dnsEntries", default)]
69    dns_entries: Vec<DnsEntry>,
70}
71
72#[derive(Serialize, Debug)]
73struct DnsEntryRequest<'a> {
74    #[serde(rename = "dnsEntry")]
75    dns_entry: &'a DnsEntry,
76}
77
78impl TransipProvider {
79    pub(crate) fn new(
80        login: impl AsRef<str>,
81        private_key_pem: impl AsRef<str>,
82        global_key: bool,
83        timeout: Option<Duration>,
84    ) -> crate::Result<Self> {
85        let login = login.as_ref().to_string();
86        let private_key_pem = private_key_pem.as_ref().to_string();
87        if login.is_empty() || private_key_pem.is_empty() {
88            return Err(Error::Api(
89                "TransIP login and private key must not be empty".to_string(),
90            ));
91        }
92        let client = HttpClientBuilder::default()
93            .with_header("Accept", "application/json")
94            .with_timeout(timeout)
95            .build();
96        Ok(Self {
97            auth: Arc::new(Mutex::new(AuthState { token: None })),
98            login,
99            private_key_pem,
100            global_key,
101            endpoint: DEFAULT_API_ENDPOINT.to_string(),
102            client,
103        })
104    }
105
106    #[cfg(test)]
107    pub(crate) fn with_endpoint(self, endpoint: impl AsRef<str>) -> Self {
108        Self {
109            endpoint: endpoint.as_ref().trim_end_matches('/').to_string(),
110            ..self
111        }
112    }
113
114    #[cfg(test)]
115    pub(crate) fn with_cached_token(self, token: impl Into<String>) -> Self {
116        if let Ok(mut guard) = self.auth.lock() {
117            guard.token = Some((token.into(), Instant::now() + Duration::from_secs(30 * 60)));
118        }
119        self
120    }
121
122    async fn ensure_token(&self) -> crate::Result<String> {
123        {
124            let guard = self
125                .auth
126                .lock()
127                .map_err(|_| Error::Client("TransIP token lock poisoned".to_string()))?;
128            if let Some((token, expiry)) = &guard.token
129                && Instant::now() < *expiry
130            {
131                return Ok(token.clone());
132            }
133        }
134
135        let nonce = generate_nonce();
136        let body = AuthRequest {
137            login: &self.login,
138            nonce: nonce.clone(),
139            read_only: false,
140            expiration_time: "30 minutes",
141            label: format!("dns-update-{nonce}"),
142            global_key: self.global_key,
143        };
144
145        let payload = serde_json::to_string(&body)
146            .map_err(|e| Error::Serialize(format!("Failed to encode TransIP auth body: {e}")))?;
147        let signature = rsa_sha512_sign(&self.private_key_pem, payload.as_bytes())
148            .map_err(|e| Error::Api(format!("Failed to sign TransIP request: {e}")))?;
149        let signature_b64 = STANDARD.encode(&signature);
150
151        let response: AuthResponse = self
152            .client
153            .post(format!("{}/auth", self.endpoint))
154            .with_header("Signature", signature_b64)
155            .with_raw_body(payload)
156            .send()
157            .await?;
158
159        let expiry = Instant::now() + Duration::from_secs(25 * 60);
160        let mut guard = self
161            .auth
162            .lock()
163            .map_err(|_| Error::Client("TransIP token lock poisoned".to_string()))?;
164        guard.token = Some((response.token.clone(), expiry));
165        Ok(response.token)
166    }
167
168    fn authed(&self, request: HttpRequest, token: &str) -> HttpRequest {
169        request.with_header("Authorization", format!("Bearer {token}"))
170    }
171
172    pub(crate) async fn set_rrset(
173        &self,
174        name: impl IntoFqdn<'_>,
175        record_type: DnsRecordType,
176        ttl: u32,
177        records: Vec<DnsRecord>,
178        origin: impl IntoFqdn<'_>,
179    ) -> crate::Result<()> {
180        check_record_types(record_type, &records)?;
181
182        let name = name.into_name();
183        let domain = origin.into_name();
184        let subdomain = strip_origin_from_name(&name, &domain, Some("@"));
185        let token = self.ensure_token().await?;
186
187        let target_type = record_type.as_str();
188        let existing_at_target: Vec<DnsEntry> = self
189            .list_zone(&token, &domain)
190            .await?
191            .into_iter()
192            .filter(|e| e.name == subdomain && e.record_type == target_type)
193            .collect();
194
195        let desired: Vec<DnsEntry> = records
196            .into_iter()
197            .map(|record| {
198                Ok(DnsEntry {
199                    name: subdomain.clone(),
200                    expire: ttl,
201                    record_type: target_type.to_string(),
202                    content: render_value(record)?,
203                })
204            })
205            .collect::<crate::Result<_>>()?;
206
207        let mut existing_pool = existing_at_target;
208        let mut to_add: Vec<DnsEntry> = Vec::new();
209        for entry in desired {
210            if let Some(idx) = existing_pool
211                .iter()
212                .position(|e| e.content == entry.content && e.expire == entry.expire)
213            {
214                existing_pool.swap_remove(idx);
215            } else {
216                to_add.push(entry);
217            }
218        }
219
220        for entry in existing_pool {
221            self.delete_entry(&token, &domain, &entry).await?;
222        }
223        for entry in to_add {
224            self.post_entry(&token, &domain, &entry).await?;
225        }
226        Ok(())
227    }
228
229    pub(crate) async fn add_to_rrset(
230        &self,
231        name: impl IntoFqdn<'_>,
232        record_type: DnsRecordType,
233        ttl: u32,
234        records: Vec<DnsRecord>,
235        origin: impl IntoFqdn<'_>,
236    ) -> crate::Result<()> {
237        check_record_types(record_type, &records)?;
238        if records.is_empty() {
239            return Ok(());
240        }
241
242        let name = name.into_name();
243        let domain = origin.into_name();
244        let subdomain = strip_origin_from_name(&name, &domain, Some("@"));
245        let token = self.ensure_token().await?;
246        let target_type = record_type.as_str();
247
248        let existing: Vec<DnsEntry> = self
249            .list_zone(&token, &domain)
250            .await?
251            .into_iter()
252            .filter(|e| e.name == subdomain && e.record_type == target_type)
253            .collect();
254
255        for record in records {
256            let content = render_value(record)?;
257            if existing.iter().any(|e| e.content == content) {
258                continue;
259            }
260            let entry = DnsEntry {
261                name: subdomain.clone(),
262                expire: ttl,
263                record_type: target_type.to_string(),
264                content,
265            };
266            self.post_entry(&token, &domain, &entry).await?;
267        }
268        Ok(())
269    }
270
271    pub(crate) async fn remove_from_rrset(
272        &self,
273        name: impl IntoFqdn<'_>,
274        record_type: DnsRecordType,
275        records: Vec<DnsRecord>,
276        origin: impl IntoFqdn<'_>,
277    ) -> crate::Result<()> {
278        check_record_types(record_type, &records)?;
279        if records.is_empty() {
280            return Ok(());
281        }
282
283        let name = name.into_name();
284        let domain = origin.into_name();
285        let subdomain = strip_origin_from_name(&name, &domain, Some("@"));
286        let token = self.ensure_token().await?;
287        let target_type = record_type.as_str();
288
289        let existing: Vec<DnsEntry> = self
290            .list_zone(&token, &domain)
291            .await?
292            .into_iter()
293            .filter(|e| e.name == subdomain && e.record_type == target_type)
294            .collect();
295
296        let mut targets: Vec<String> = Vec::with_capacity(records.len());
297        for record in records {
298            targets.push(render_value(record)?);
299        }
300
301        for entry in existing {
302            if targets.iter().any(|t| t == &entry.content) {
303                self.delete_entry(&token, &domain, &entry).await?;
304            }
305        }
306        Ok(())
307    }
308
309    pub(crate) async fn list_rrset(
310        &self,
311        name: impl IntoFqdn<'_>,
312        record_type: DnsRecordType,
313        origin: impl IntoFqdn<'_>,
314    ) -> crate::Result<Vec<DnsRecord>> {
315        let name = name.into_name();
316        let domain = origin.into_name();
317        let subdomain = strip_origin_from_name(&name, &domain, Some("@"));
318        let token = self.ensure_token().await?;
319        let target_type = record_type.as_str();
320
321        let entries = self.list_zone(&token, &domain).await?;
322        let mut out = Vec::new();
323        for entry in entries {
324            if entry.name == subdomain && entry.record_type == target_type {
325                out.push(parse_dns_entry(&entry, record_type)?);
326            }
327        }
328        Ok(out)
329    }
330
331    async fn list_zone(&self, token: &str, domain: &str) -> crate::Result<Vec<DnsEntry>> {
332        let url = format!("{}/domains/{}/dns", self.endpoint, domain);
333        let entries: DnsEntriesResponse = self.authed(self.client.get(url), token).send().await?;
334        Ok(entries.dns_entries)
335    }
336
337    async fn post_entry(&self, token: &str, domain: &str, entry: &DnsEntry) -> crate::Result<()> {
338        let url = format!("{}/domains/{}/dns", self.endpoint, domain);
339        self.authed(self.client.post(url), token)
340            .with_body(DnsEntryRequest { dns_entry: entry })?
341            .send_raw()
342            .await
343            .map(|_| ())
344    }
345
346    async fn delete_entry(&self, token: &str, domain: &str, entry: &DnsEntry) -> crate::Result<()> {
347        let url = format!("{}/domains/{}/dns", self.endpoint, domain);
348        self.authed(self.client.delete(url), token)
349            .with_body(DnsEntryRequest { dns_entry: entry })?
350            .send_raw()
351            .await
352            .map(|_| ())
353    }
354}
355
356pub(crate) fn generate_nonce() -> String {
357    let now = SystemTime::now()
358        .duration_since(UNIX_EPOCH)
359        .map(|d| d.as_nanos())
360        .unwrap_or(0) as u64;
361    format!("dnsu{now:016x}")
362}
363
364fn check_record_types(expected: DnsRecordType, records: &[DnsRecord]) -> crate::Result<()> {
365    for record in records {
366        if record.as_type() != expected {
367            return Err(Error::Api(format!(
368                "RRSet record type mismatch: expected {}, got {}",
369                expected.as_str(),
370                record.as_type().as_str(),
371            )));
372        }
373    }
374    Ok(())
375}
376
377fn render_value(record: DnsRecord) -> crate::Result<String> {
378    Ok(match record {
379        DnsRecord::A(addr) => addr.to_string(),
380        DnsRecord::AAAA(addr) => addr.to_string(),
381        DnsRecord::CNAME(content) => ensure_fqdn(content),
382        DnsRecord::NS(content) => ensure_fqdn(content),
383        DnsRecord::MX(mx) => format!("{} {}", mx.priority, ensure_fqdn(mx.exchange)),
384        DnsRecord::TXT(content) => content,
385        DnsRecord::SRV(srv) => format!(
386            "{} {} {} {}",
387            srv.priority,
388            srv.weight,
389            srv.port,
390            ensure_fqdn(srv.target)
391        ),
392        DnsRecord::TLSA(tlsa) => tlsa.to_string(),
393        DnsRecord::CAA(caa) => {
394            let (flags, tag, value) = caa.decompose();
395            format!("{flags} {tag} \"{value}\"")
396        }
397    })
398}
399
400fn ensure_fqdn(name: String) -> String {
401    if name.ends_with('.') {
402        name
403    } else {
404        format!("{name}.")
405    }
406}
407
408fn strip_trailing_dot(s: &str) -> &str {
409    s.strip_suffix('.').unwrap_or(s)
410}
411
412fn parse_dns_entry(entry: &DnsEntry, record_type: DnsRecordType) -> crate::Result<DnsRecord> {
413    match record_type {
414        DnsRecordType::A => {
415            let addr = entry
416                .content
417                .parse()
418                .map_err(|e| Error::Parse(format!("invalid A address {}: {e}", entry.content)))?;
419            Ok(DnsRecord::A(addr))
420        }
421        DnsRecordType::AAAA => {
422            let addr = entry.content.parse().map_err(|e| {
423                Error::Parse(format!("invalid AAAA address {}: {e}", entry.content))
424            })?;
425            Ok(DnsRecord::AAAA(addr))
426        }
427        DnsRecordType::CNAME => Ok(DnsRecord::CNAME(
428            strip_trailing_dot(&entry.content).to_string(),
429        )),
430        DnsRecordType::NS => Ok(DnsRecord::NS(
431            strip_trailing_dot(&entry.content).to_string(),
432        )),
433        DnsRecordType::TXT => Ok(DnsRecord::TXT(entry.content.clone())),
434        DnsRecordType::MX => {
435            let mut parts = entry.content.splitn(2, char::is_whitespace);
436            let priority_token = parts
437                .next()
438                .ok_or_else(|| Error::Parse(format!("invalid MX content: {}", entry.content)))?;
439            let exchange = strip_trailing_dot(
440                parts
441                    .next()
442                    .ok_or_else(|| Error::Parse(format!("invalid MX content: {}", entry.content)))?
443                    .trim(),
444            )
445            .to_string();
446            let priority: u16 = priority_token
447                .parse()
448                .map_err(|e| Error::Parse(format!("invalid MX priority: {e}")))?;
449            Ok(DnsRecord::MX(MXRecord { priority, exchange }))
450        }
451        DnsRecordType::SRV => {
452            let mut parts = entry.content.split_whitespace();
453            let priority: u16 = parts
454                .next()
455                .ok_or_else(|| Error::Parse(format!("invalid SRV content: {}", entry.content)))?
456                .parse()
457                .map_err(|e| Error::Parse(format!("invalid SRV priority: {e}")))?;
458            let weight: u16 = parts
459                .next()
460                .ok_or_else(|| Error::Parse(format!("invalid SRV content: {}", entry.content)))?
461                .parse()
462                .map_err(|e| Error::Parse(format!("invalid SRV weight: {e}")))?;
463            let port: u16 = parts
464                .next()
465                .ok_or_else(|| Error::Parse(format!("invalid SRV content: {}", entry.content)))?
466                .parse()
467                .map_err(|e| Error::Parse(format!("invalid SRV port: {e}")))?;
468            let target =
469                strip_trailing_dot(parts.next().ok_or_else(|| {
470                    Error::Parse(format!("invalid SRV content: {}", entry.content))
471                })?)
472                .to_string();
473            Ok(DnsRecord::SRV(SRVRecord {
474                priority,
475                weight,
476                port,
477                target,
478            }))
479        }
480        DnsRecordType::TLSA => {
481            let mut parts = entry.content.split_whitespace();
482            let usage: u8 = parts
483                .next()
484                .ok_or_else(|| Error::Parse(format!("invalid TLSA content: {}", entry.content)))?
485                .parse()
486                .map_err(|e| Error::Parse(format!("invalid TLSA usage: {e}")))?;
487            let selector: u8 = parts
488                .next()
489                .ok_or_else(|| Error::Parse(format!("invalid TLSA content: {}", entry.content)))?
490                .parse()
491                .map_err(|e| Error::Parse(format!("invalid TLSA selector: {e}")))?;
492            let matching: u8 = parts
493                .next()
494                .ok_or_else(|| Error::Parse(format!("invalid TLSA content: {}", entry.content)))?
495                .parse()
496                .map_err(|e| Error::Parse(format!("invalid TLSA matching: {e}")))?;
497            let hex = parts
498                .next()
499                .ok_or_else(|| Error::Parse(format!("invalid TLSA content: {}", entry.content)))?;
500            Ok(DnsRecord::TLSA(TLSARecord {
501                cert_usage: tlsa_cert_usage_from_u8(usage)?,
502                selector: tlsa_selector_from_u8(selector)?,
503                matching: tlsa_matching_from_u8(matching)?,
504                cert_data: decode_hex(hex)?,
505            }))
506        }
507        DnsRecordType::CAA => Ok(DnsRecord::CAA(parse_caa_content(&entry.content)?)),
508    }
509}
510
511fn parse_caa_content(content: &str) -> crate::Result<CAARecord> {
512    let trimmed = content.trim();
513    let mut parts = trimmed.splitn(3, char::is_whitespace);
514    let flags_token = parts
515        .next()
516        .ok_or_else(|| Error::Parse(format!("invalid CAA value: {trimmed}")))?;
517    let tag_token = parts
518        .next()
519        .ok_or_else(|| Error::Parse(format!("invalid CAA value: {trimmed}")))?;
520    let raw_value = parts.next().unwrap_or("").trim();
521    let flags: u8 = flags_token
522        .parse()
523        .map_err(|e| Error::Parse(format!("invalid CAA flags: {e}")))?;
524    let issuer_critical = flags & 0x80 != 0;
525    let value = raw_value
526        .strip_prefix('"')
527        .and_then(|s| s.strip_suffix('"'))
528        .unwrap_or(raw_value)
529        .to_string();
530
531    match tag_token {
532        "issue" => {
533            let (name, options) = split_caa_value(&value);
534            Ok(CAARecord::Issue {
535                issuer_critical,
536                name,
537                options,
538            })
539        }
540        "issuewild" => {
541            let (name, options) = split_caa_value(&value);
542            Ok(CAARecord::IssueWild {
543                issuer_critical,
544                name,
545                options,
546            })
547        }
548        "iodef" => Ok(CAARecord::Iodef {
549            issuer_critical,
550            url: value,
551        }),
552        other => Err(Error::Parse(format!("unknown CAA tag: {other}"))),
553    }
554}
555
556fn split_caa_value(value: &str) -> (Option<String>, Vec<KeyValue>) {
557    let mut parts = value.split(';').map(str::trim);
558    let name_part = parts.next().unwrap_or("").trim().to_string();
559    let name = if name_part.is_empty() {
560        None
561    } else {
562        Some(name_part)
563    };
564    let options = parts
565        .filter(|p| !p.is_empty())
566        .map(|p| match p.split_once('=') {
567            Some((k, v)) => KeyValue {
568                key: k.trim().to_string(),
569                value: v.trim().to_string(),
570            },
571            None => KeyValue {
572                key: p.trim().to_string(),
573                value: String::new(),
574            },
575        })
576        .collect();
577    (name, options)
578}
579
580fn decode_hex(hex: &str) -> crate::Result<Vec<u8>> {
581    if !hex.len().is_multiple_of(2) {
582        return Err(Error::Parse(format!("invalid hex string: {hex}")));
583    }
584    (0..hex.len())
585        .step_by(2)
586        .map(|i| {
587            u8::from_str_radix(&hex[i..i + 2], 16)
588                .map_err(|e| Error::Parse(format!("invalid hex byte: {e}")))
589        })
590        .collect()
591}
592
593fn tlsa_cert_usage_from_u8(value: u8) -> crate::Result<TlsaCertUsage> {
594    Ok(match value {
595        0 => TlsaCertUsage::PkixTa,
596        1 => TlsaCertUsage::PkixEe,
597        2 => TlsaCertUsage::DaneTa,
598        3 => TlsaCertUsage::DaneEe,
599        255 => TlsaCertUsage::Private,
600        _ => return Err(Error::Parse(format!("unknown TLSA cert usage: {value}"))),
601    })
602}
603
604fn tlsa_selector_from_u8(value: u8) -> crate::Result<TlsaSelector> {
605    Ok(match value {
606        0 => TlsaSelector::Full,
607        1 => TlsaSelector::Spki,
608        255 => TlsaSelector::Private,
609        _ => return Err(Error::Parse(format!("unknown TLSA selector: {value}"))),
610    })
611}
612
613fn tlsa_matching_from_u8(value: u8) -> crate::Result<TlsaMatching> {
614    Ok(match value {
615        0 => TlsaMatching::Raw,
616        1 => TlsaMatching::Sha256,
617        2 => TlsaMatching::Sha512,
618        255 => TlsaMatching::Private,
619        _ => return Err(Error::Parse(format!("unknown TLSA matching: {value}"))),
620    })
621}