├── .gitignore ├── src ├── tests │ ├── mod.rs │ ├── lib_tests.rs │ ├── bunny_test.rs │ ├── desec_tests.rs │ └── ovh_tests.rs ├── providers │ ├── mod.rs │ ├── digitalocean.rs │ ├── desec.rs │ ├── cloudflare.rs │ ├── bunny.rs │ ├── rfc2136.rs │ └── ovh.rs ├── http.rs └── lib.rs ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE-MIT ├── examples └── desec.rs ├── README.md └── LICENSE-APACHE /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | _ignore 3 | Cargo.lock 4 | 5 | .idea/ 6 | 7 | # ignore vscode dir 8 | **/.vscode -------------------------------------------------------------------------------- /src/tests/mod.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 or the MIT license 7 | * , at your 8 | * option. This file may not be copied, modified, or distributed 9 | * except according to those terms. 10 | */ 11 | 12 | pub mod bunny_test; 13 | pub mod desec_tests; 14 | pub mod lib_tests; 15 | pub mod ovh_tests; 16 | -------------------------------------------------------------------------------- /src/tests/lib_tests.rs: -------------------------------------------------------------------------------- 1 | 2 | 3 | #[cfg(test)] 4 | mod tests { 5 | use crate::{strip_origin_from_name}; 6 | 7 | #[test] 8 | fn test_strip_origin_from_name() { 9 | assert_eq!( 10 | strip_origin_from_name("www.example.com", "example.com"), 11 | "www" 12 | ); 13 | assert_eq!( 14 | strip_origin_from_name("example.com", "example.com"), 15 | "@" 16 | ); 17 | assert_eq!( 18 | strip_origin_from_name("api.v1.example.com", "example.com"), 19 | "api.v1" 20 | ); 21 | assert_eq!( 22 | strip_origin_from_name("example.com", "google.com"), 23 | "example.com" 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | dns-update 0.1.6 2 | ================================ 3 | - deSec fixes. 4 | 5 | dns-update 0.1.5 6 | ================================ 7 | - Add OVH provider. 8 | 9 | dns-update 0.1.4 10 | ================================ 11 | - Add desec.io provider. 12 | - Add retry function to http client 13 | - Moved `strip_origin_from_name` form `digitalocean` to `lib` 14 | - Fixed cargo test 15 | 16 | dns-update 0.1.3 17 | ================================ 18 | - Add DigitalOcean provider. 19 | 20 | dns-update 0.1.2 21 | ================================ 22 | - Fixed parsing IPv6 addresses. 23 | 24 | dns-update 0.1.1 25 | ================================ 26 | - Minor fixes. 27 | 28 | dns-update 0.1.0 29 | ================================ 30 | - Initial release. 31 | -------------------------------------------------------------------------------- /src/providers/mod.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 or the MIT license 7 | * , at your 8 | * option. This file may not be copied, modified, or distributed 9 | * except according to those terms. 10 | */ 11 | 12 | use crate::DnsRecord; 13 | 14 | pub mod bunny; 15 | pub mod cloudflare; 16 | pub mod desec; 17 | pub mod digitalocean; 18 | pub mod ovh; 19 | pub mod rfc2136; 20 | 21 | impl DnsRecord { 22 | pub fn priority(&self) -> Option { 23 | match self { 24 | DnsRecord::MX { priority, .. } => Some(*priority), 25 | DnsRecord::SRV { priority, .. } => Some(*priority), 26 | _ => None, 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "dns-update" 3 | description = "Dynamic DNS update (RFC 2136 and cloud) library for Rust" 4 | version = "0.1.6" 5 | edition = "2021" 6 | authors = [ "Stalwart Labs "] 7 | license = "Apache-2.0 OR MIT" 8 | repository = "https://github.com/stalwartlabs/dns-update" 9 | homepage = "https://github.com/stalwartlabs/dns-update" 10 | keywords = ["dns", "update", "rfc2136", "dynamic"] 11 | categories = ["network-programming"] 12 | readme = "README.md" 13 | resolver = "2" 14 | 15 | [dependencies] 16 | tokio = { version = "1", features = ["rt", "net"] } 17 | hickory-client = { version = "0.24", features = ["dns-over-rustls", "dnssec-ring", "dns-over-https-rustls"], default-features = false } 18 | serde = { version = "1.0.197", features = ["derive"] } 19 | serde_json = "1.0.116" 20 | reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-webpki-roots", "http2"]} 21 | serde_urlencoded = "0.7.1" 22 | sha1 = "0.10" 23 | 24 | [dev-dependencies] 25 | tokio = { version = "1", features = ["full"] } 26 | mockito = "1.2" 27 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any 2 | person obtaining a copy of this software and associated 3 | documentation files (the "Software"), to deal in the 4 | Software without restriction, including without 5 | limitation the rights to use, copy, modify, merge, 6 | publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software 8 | is furnished to do so, subject to the following 9 | conditions: 10 | 11 | The above copyright notice and this permission notice 12 | shall be included in all copies or substantial portions 13 | of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 22 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 23 | DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /examples/desec.rs: -------------------------------------------------------------------------------- 1 | use dns_update::{DnsRecord, DnsRecordType, DnsUpdater}; 2 | use std::env; 3 | use std::time::Duration; 4 | 5 | #[tokio::main] 6 | pub async fn main() -> Result<(), std::env::VarError> { 7 | let token = env::var("DESEC_TOKEN").expect("Envvar DESEC_TOKEN should be set with valid token"); 8 | let domain = 9 | env::var("DESEC_DOMAIN").expect("Envvar DESEC_DOMAIN should be set with DNS domain"); 10 | 11 | let client = DnsUpdater::new_desec(token, Some(Duration::from_secs(120))).unwrap(); 12 | 13 | // Create a new TXT record 14 | 15 | let client_result = client 16 | .create( 17 | format!("_domainkey.{}", domain), 18 | DnsRecord::TXT { 19 | content: "\"v=DKIM1; k=rsa; h=sha256; p=test\"".to_string(), 20 | }, 21 | 3600, 22 | format!("{}", domain), 23 | ) 24 | .await; 25 | 26 | println!("client create result={:?}", client_result); 27 | 28 | let client_del_result = client 29 | .delete("_domainkey", format!("{}", domain), DnsRecordType::TXT) 30 | .await; 31 | 32 | println!("client del result={:?}", client_del_result); 33 | 34 | Ok(()) 35 | } 36 | -------------------------------------------------------------------------------- /src/tests/bunny_test.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | mod tests { 3 | use crate::{providers::bunny::BunnyProvider, DnsRecord, DnsUpdater}; 4 | use std::time::Duration; 5 | 6 | #[tokio::test] 7 | #[ignore = "Requires Bunny API keys and domain configuration"] 8 | async fn integration_test() { 9 | let api_key = std::env::var("BUNNY_API_KEY").unwrap_or_default(); 10 | let domain = std::env::var("BUNNY_DOMAIN").unwrap_or_default(); 11 | let origin = std::env::var("BUNNY_ORIGIN").unwrap_or_default(); 12 | 13 | assert!( 14 | !api_key.is_empty(), 15 | "Please configure your Bunny application key in the integration test" 16 | ); 17 | assert!( 18 | !domain.is_empty(), 19 | "Please configure your domain in the integration test" 20 | ); 21 | assert!( 22 | !origin.is_empty(), 23 | "Please configure your origin in the integration test" 24 | ); 25 | 26 | let updater = DnsUpdater::new_bunny(api_key, Some(Duration::from_secs(30))).unwrap(); 27 | 28 | let create_result = updater 29 | .create( 30 | &domain, 31 | DnsRecord::A { 32 | content: [1, 1, 1, 1].into(), 33 | }, 34 | 300, 35 | &origin, 36 | ) 37 | .await; 38 | assert!(create_result.is_ok()); 39 | 40 | let update_result = updater 41 | .update( 42 | &domain, 43 | DnsRecord::A { 44 | content: [8, 8, 8, 8].into(), 45 | }, 46 | 300, 47 | &origin, 48 | ) 49 | .await; 50 | assert!(update_result.is_ok()); 51 | 52 | let delete_result = updater 53 | .delete(&domain, &origin, crate::DnsRecordType::A) 54 | .await; 55 | assert!(delete_result.is_ok()); 56 | } 57 | 58 | #[test] 59 | fn provider_creation() { 60 | let provider = BunnyProvider::new("bunny-mock-api-key", Some(Duration::from_secs(1))); 61 | 62 | assert!(provider.is_ok()); 63 | } 64 | 65 | #[test] 66 | fn dns_updater_creation() { 67 | let updater = DnsUpdater::new_bunny("bunny-mock-api-key", Some(Duration::from_secs(30))); 68 | 69 | assert!( 70 | matches!(updater, Ok(DnsUpdater::Bunny(..))), 71 | "Expected Bunny updater to provide a Bunny provider" 72 | ); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dns-update 2 | 3 | [![crates.io](https://img.shields.io/crates/v/dns-update)](https://crates.io/crates/dns-update) 4 | [![build](https://github.com/stalwartlabs/dns-update/actions/workflows/rust.yml/badge.svg)](https://github.com/stalwartlabs/dns-update/actions/workflows/rust.yml) 5 | [![docs.rs](https://img.shields.io/docsrs/dns-update)](https://docs.rs/dns-update) 6 | [![crates.io](https://img.shields.io/crates/l/dns-update)](http://www.apache.org/licenses/LICENSE-2.0) 7 | 8 | _dns-update_ is an **Dynamic DNS update library** for Rust that supports updating DNS records using the [RFC 2136](https://datatracker.ietf.org/doc/html/rfc2136) protocol 9 | and different cloud provider APIs such as [Cloudflare](https://www.cloudflare.com/). It was designed to be simple and easy to use, while providing a high level of flexibility 10 | and performance. 11 | 12 | ## Limitations 13 | 14 | - Currently the library is `async` only. 15 | - Besides RFC 2136, it only supports a few cloud providers API. 16 | 17 | ## PRs Welcome 18 | 19 | PRs to add more providers are welcome. The goal is to support as many providers as Go's [lego](https://go-acme.github.io/lego/dns/) library. 20 | 21 | ## Usage Example 22 | 23 | Using RFC2136 with TSIG: 24 | 25 | ```rust,ignore 26 | // Create a new RFC2136 client 27 | let client = DnsUpdater::new_rfc2136_tsig("tcp://127.0.0.1:53", "", STANDARD.decode("").unwrap(), TsigAlgorithm::HmacSha512).unwrap(); 28 | 29 | // Create a new TXT record 30 | client.create( 31 | "test._domainkey.example.org", 32 | DnsRecord::TXT { 33 | content: "v=DKIM1; k=rsa; h=sha256; p=test".to_string(), 34 | }, 35 | 300, 36 | "example.org", 37 | ) 38 | .await 39 | .unwrap(); 40 | 41 | // Delete the record 42 | client.delete("test._domainkey.example.org", "example.org").await.unwrap(); 43 | ``` 44 | 45 | Using a cloud provider such as Cloudflare: 46 | 47 | ```rust,ignore 48 | // Create a new Cloudflare client 49 | let client = 50 | DnsUpdater::new_cloudflare("", None::, Some(Duration::from_secs(60))) 51 | .unwrap(); 52 | 53 | // Create a new TXT record 54 | client.create( 55 | "test._domainkey.example.org", 56 | DnsRecord::TXT { 57 | content: "v=DKIM1; k=rsa; h=sha256; p=test".to_string(), 58 | }, 59 | 300, 60 | "example.org", 61 | ) 62 | .await 63 | .unwrap(); 64 | 65 | // Delete the record 66 | client.delete("test._domainkey.example.org", "example.org").await.unwrap(); 67 | ``` 68 | 69 | ## License 70 | 71 | Licensed under either of 72 | 73 | * Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) 74 | * MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) 75 | 76 | at your option. 77 | 78 | ## Copyright 79 | 80 | Copyright (C) 2020, Stalwart Labs LLC 81 | -------------------------------------------------------------------------------- /src/providers/digitalocean.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 or the MIT license 7 | * , at your 8 | * option. This file may not be copied, modified, or distributed 9 | * except according to those terms. 10 | */ 11 | 12 | use std::{ 13 | net::{Ipv4Addr, Ipv6Addr}, 14 | time::Duration, 15 | }; 16 | 17 | use crate::{http::HttpClientBuilder, strip_origin_from_name, DnsRecord, Error, IntoFqdn}; 18 | use serde::{Deserialize, Serialize}; 19 | 20 | #[derive(Clone)] 21 | pub struct DigitalOceanProvider { 22 | client: HttpClientBuilder, 23 | } 24 | 25 | #[derive(Deserialize, Serialize, Clone, Debug)] 26 | pub struct ListDomainRecord { 27 | domain_records: Vec, 28 | } 29 | 30 | #[derive(Deserialize, Serialize, Clone, Debug)] 31 | pub struct UpdateDomainRecord<'a> { 32 | ttl: u32, 33 | name: &'a str, 34 | #[serde(flatten)] 35 | data: RecordData, 36 | } 37 | 38 | #[derive(Deserialize, Serialize, Clone, Debug)] 39 | pub struct DomainRecord { 40 | id: i64, 41 | ttl: u32, 42 | name: String, 43 | #[serde(flatten)] 44 | data: RecordData, 45 | } 46 | 47 | #[derive(Deserialize, Serialize, Clone, Debug)] 48 | #[serde(tag = "type")] 49 | #[allow(clippy::upper_case_acronyms)] 50 | pub enum RecordData { 51 | A { 52 | data: Ipv4Addr, 53 | }, 54 | AAAA { 55 | data: Ipv6Addr, 56 | }, 57 | CNAME { 58 | data: String, 59 | }, 60 | NS { 61 | data: String, 62 | }, 63 | MX { 64 | data: String, 65 | priority: u16, 66 | }, 67 | TXT { 68 | data: String, 69 | }, 70 | SRV { 71 | data: String, 72 | priority: u16, 73 | port: u16, 74 | weight: u16, 75 | }, 76 | } 77 | 78 | #[derive(Serialize, Debug)] 79 | pub struct Query<'a> { 80 | name: &'a str, 81 | } 82 | 83 | impl DigitalOceanProvider { 84 | pub(crate) fn new(auth_token: impl AsRef, timeout: Option) -> Self { 85 | let client = HttpClientBuilder::default() 86 | .with_header("Authorization", format!("Bearer {}", auth_token.as_ref())) 87 | .with_timeout(timeout); 88 | Self { client } 89 | } 90 | 91 | pub(crate) async fn create( 92 | &self, 93 | name: impl IntoFqdn<'_>, 94 | record: DnsRecord, 95 | ttl: u32, 96 | origin: impl IntoFqdn<'_>, 97 | ) -> crate::Result<()> { 98 | let name = name.into_name(); 99 | let domain = origin.into_name(); 100 | let subdomain = strip_origin_from_name(&name, &domain); 101 | 102 | self.client 103 | .post(format!( 104 | "https://api.digitalocean.com/v2/domains/{domain}/records", 105 | )) 106 | .with_body(UpdateDomainRecord { 107 | ttl, 108 | name: &subdomain, 109 | data: record.into(), 110 | })? 111 | .send_raw() 112 | .await 113 | .map(|_| ()) 114 | } 115 | 116 | pub(crate) async fn update( 117 | &self, 118 | name: impl IntoFqdn<'_>, 119 | record: DnsRecord, 120 | ttl: u32, 121 | origin: impl IntoFqdn<'_>, 122 | ) -> crate::Result<()> { 123 | let name = name.into_name(); 124 | let domain = origin.into_name(); 125 | let subdomain = strip_origin_from_name(&name, &domain); 126 | let record_id = self.obtain_record_id(&name, &domain).await?; 127 | 128 | self.client 129 | .put(format!( 130 | "https://api.digitalocean.com/v2/domains/{domain}/records/{record_id}", 131 | )) 132 | .with_body(UpdateDomainRecord { 133 | ttl, 134 | name: &subdomain, 135 | data: record.into(), 136 | })? 137 | .send_raw() 138 | .await 139 | .map(|_| ()) 140 | } 141 | 142 | pub(crate) async fn delete( 143 | &self, 144 | name: impl IntoFqdn<'_>, 145 | origin: impl IntoFqdn<'_>, 146 | ) -> crate::Result<()> { 147 | let name = name.into_name(); 148 | let domain = origin.into_name(); 149 | let record_id = self.obtain_record_id(&name, &domain).await?; 150 | 151 | self.client 152 | .delete(format!( 153 | "https://api.digitalocean.com/v2/domains/{domain}/records/{record_id}", 154 | )) 155 | .send_raw() 156 | .await 157 | .map(|_| ()) 158 | } 159 | 160 | async fn obtain_record_id(&self, name: &str, domain: &str) -> crate::Result { 161 | let subdomain = strip_origin_from_name(name, domain); 162 | self.client 163 | .get(format!( 164 | "https://api.digitalocean.com/v2/domains/{domain}/records?{}", 165 | Query::name(name).serialize() 166 | )) 167 | .send_with_retry::(3) 168 | .await 169 | .and_then(|result| { 170 | result 171 | .domain_records 172 | .into_iter() 173 | .find(|record| record.name == subdomain) 174 | .map(|record| record.id) 175 | .ok_or_else(|| Error::Api(format!("DNS Record {} not found", subdomain))) 176 | }) 177 | } 178 | } 179 | 180 | impl<'a> Query<'a> { 181 | pub fn name(name: impl Into<&'a str>) -> Self { 182 | Self { name: name.into() } 183 | } 184 | 185 | pub fn serialize(&self) -> String { 186 | serde_urlencoded::to_string(self).unwrap() 187 | } 188 | } 189 | 190 | impl From for RecordData { 191 | fn from(record: DnsRecord) -> Self { 192 | match record { 193 | DnsRecord::A { content } => RecordData::A { data: content }, 194 | DnsRecord::AAAA { content } => RecordData::AAAA { data: content }, 195 | DnsRecord::CNAME { content } => RecordData::CNAME { data: content }, 196 | DnsRecord::NS { content } => RecordData::NS { data: content }, 197 | DnsRecord::MX { content, priority } => RecordData::MX { 198 | data: content, 199 | priority, 200 | }, 201 | DnsRecord::TXT { content } => RecordData::TXT { data: content }, 202 | DnsRecord::SRV { 203 | content, 204 | priority, 205 | weight, 206 | port, 207 | } => RecordData::SRV { 208 | data: content, 209 | priority, 210 | weight, 211 | port, 212 | }, 213 | } 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/providers/desec.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 or the MIT license 7 | * , at your 8 | * option. This file may not be copied, modified, or distributed 9 | * except according to those terms. 10 | */ 11 | 12 | use std::time::Duration; 13 | 14 | use serde::{Deserialize, Serialize}; 15 | 16 | use crate::{http::HttpClientBuilder, strip_origin_from_name, DnsRecord, DnsRecordType, IntoFqdn}; 17 | 18 | pub struct DesecDnsRecordRepresentation { 19 | pub record_type: String, 20 | pub content: String, 21 | } 22 | 23 | #[derive(Clone)] 24 | pub struct DesecProvider { 25 | client: HttpClientBuilder, 26 | endpoint: String, 27 | } 28 | 29 | /// The parameters for creation and modification requests of the desec API. 30 | #[derive(Serialize, Clone, Debug)] 31 | pub struct DnsRecordParams<'a> { 32 | pub subname: &'a str, 33 | #[serde(rename = "type")] 34 | pub rr_type: &'a str, 35 | pub ttl: Option, 36 | pub records: Vec, 37 | } 38 | 39 | /// The response for creation and modification requests of the desec API. 40 | #[derive(Deserialize, Debug)] 41 | pub struct DesecApiResponse { 42 | pub created: String, 43 | pub domain: String, 44 | pub subname: String, 45 | pub name: String, 46 | pub records: Vec, 47 | pub ttl: u32, 48 | #[serde(rename = "type")] 49 | pub record_type: String, 50 | pub touched: String, 51 | } 52 | 53 | #[derive(Deserialize)] 54 | struct DesecEmptyResponse {} 55 | 56 | /// The default endpoint for the desec API. 57 | const DEFAULT_API_ENDPOINT: &str = "https://desec.io/api/v1"; 58 | 59 | impl DesecProvider { 60 | pub(crate) fn new(auth_token: impl AsRef, timeout: Option) -> Self { 61 | let client = HttpClientBuilder::default() 62 | .with_header("Authorization", format!("Token {}", auth_token.as_ref())) 63 | .with_timeout(timeout); 64 | 65 | Self { 66 | client, 67 | endpoint: DEFAULT_API_ENDPOINT.to_string(), 68 | } 69 | } 70 | 71 | #[cfg(test)] 72 | pub(crate) fn with_endpoint(self, endpoint: impl AsRef) -> Self { 73 | Self { 74 | endpoint: endpoint.as_ref().to_string(), 75 | ..self 76 | } 77 | } 78 | 79 | pub(crate) async fn create( 80 | &self, 81 | name: impl IntoFqdn<'_>, 82 | record: DnsRecord, 83 | ttl: u32, 84 | origin: impl IntoFqdn<'_>, 85 | ) -> crate::Result<()> { 86 | let name = name.into_name(); 87 | let domain = origin.into_name(); 88 | let subdomain = strip_origin_from_name(&name, &domain); 89 | 90 | let desec_record = DesecDnsRecordRepresentation::from(record); 91 | self.client 92 | .post(format!( 93 | "{endpoint}/domains/{domain}/rrsets/", 94 | endpoint = self.endpoint, 95 | domain = domain 96 | )) 97 | .with_body(DnsRecordParams { 98 | subname: &subdomain, 99 | rr_type: &desec_record.record_type, 100 | ttl: Some(ttl), 101 | records: vec![desec_record.content], 102 | })? 103 | .send_with_retry::(3) 104 | .await 105 | .map(|_| ()) 106 | } 107 | 108 | pub(crate) async fn update( 109 | &self, 110 | name: impl IntoFqdn<'_>, 111 | record: DnsRecord, 112 | ttl: u32, 113 | origin: impl IntoFqdn<'_>, 114 | ) -> crate::Result<()> { 115 | let name = name.into_name(); 116 | let domain = origin.into_name(); 117 | let subdomain = strip_origin_from_name(&name, &domain); 118 | 119 | let desec_record = DesecDnsRecordRepresentation::from(record); 120 | self.client 121 | .put(format!( 122 | "{endpoint}/domains/{domain}/rrsets/{subdomain}/{rr_type}/", 123 | endpoint = self.endpoint, 124 | domain = &domain, 125 | subdomain = &subdomain, 126 | rr_type = &desec_record.record_type, 127 | )) 128 | .with_body(DnsRecordParams { 129 | subname: &subdomain, 130 | rr_type: desec_record.record_type.as_str(), 131 | ttl: Some(ttl), 132 | records: vec![desec_record.content], 133 | })? 134 | .send_with_retry::(3) 135 | .await 136 | .map(|_| ()) 137 | } 138 | 139 | pub(crate) async fn delete( 140 | &self, 141 | name: impl IntoFqdn<'_>, 142 | origin: impl IntoFqdn<'_>, 143 | record_type: DnsRecordType, 144 | ) -> crate::Result<()> { 145 | let name = name.into_name(); 146 | let domain = origin.into_name(); 147 | let subdomain = strip_origin_from_name(&name, &domain); 148 | 149 | let rr_type = &record_type.to_string(); 150 | self.client 151 | .delete(format!( 152 | "{endpoint}/domains/{domain}/rrsets/{subdomain}/{rtype}/", 153 | endpoint = self.endpoint, 154 | domain = &domain, 155 | subdomain = &subdomain, 156 | rtype = &rr_type.to_string(), 157 | )) 158 | .send_with_retry::(3) 159 | .await 160 | .map(|_| ()) 161 | } 162 | } 163 | 164 | /// Converts a DNS record into a representation that can be sent to the desec API. 165 | impl From for DesecDnsRecordRepresentation { 166 | fn from(record: DnsRecord) -> Self { 167 | match record { 168 | DnsRecord::A { content } => DesecDnsRecordRepresentation { 169 | record_type: "A".to_string(), 170 | content: content.to_string(), 171 | }, 172 | DnsRecord::AAAA { content } => DesecDnsRecordRepresentation { 173 | record_type: "AAAA".to_string(), 174 | content: content.to_string(), 175 | }, 176 | DnsRecord::CNAME { content } => DesecDnsRecordRepresentation { 177 | record_type: "CNAME".to_string(), 178 | content, 179 | }, 180 | DnsRecord::NS { content } => DesecDnsRecordRepresentation { 181 | record_type: "NS".to_string(), 182 | content, 183 | }, 184 | DnsRecord::MX { content, priority } => DesecDnsRecordRepresentation { 185 | record_type: "MX".to_string(), 186 | content: format!("{priority} {content}"), 187 | }, 188 | DnsRecord::TXT { content } => DesecDnsRecordRepresentation { 189 | record_type: "TXT".to_string(), 190 | content: format!("\"{content}\""), 191 | }, 192 | DnsRecord::SRV { 193 | content, 194 | priority, 195 | weight, 196 | port, 197 | } => DesecDnsRecordRepresentation { 198 | record_type: "SRV".to_string(), 199 | content: format!("{priority} {weight} {port} {content}"), 200 | }, 201 | } 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/http.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 or the MIT license 7 | * , at your 8 | * option. This file may not be copied, modified, or distributed 9 | * except according to those terms. 10 | */ 11 | 12 | use std::time::Duration; 13 | 14 | use reqwest::{ 15 | header::{HeaderMap, HeaderValue, CONTENT_TYPE}, 16 | Method, 17 | }; 18 | use serde::{de::DeserializeOwned, Serialize}; 19 | 20 | use crate::Error; 21 | 22 | #[derive(Debug, Clone)] 23 | pub struct HttpClientBuilder { 24 | timeout: Duration, 25 | headers: HeaderMap, 26 | } 27 | 28 | #[derive(Debug, Default, Clone)] 29 | pub struct HttpClient { 30 | method: Method, 31 | timeout: Duration, 32 | url: String, 33 | headers: HeaderMap, 34 | body: Option, 35 | } 36 | 37 | impl Default for HttpClientBuilder { 38 | fn default() -> Self { 39 | let mut headers = HeaderMap::new(); 40 | headers.append(CONTENT_TYPE, HeaderValue::from_static("application/json")); 41 | 42 | Self { 43 | timeout: Duration::from_secs(30), 44 | headers, 45 | } 46 | } 47 | } 48 | 49 | impl HttpClientBuilder { 50 | pub fn build(&self, method: Method, url: impl Into) -> HttpClient { 51 | HttpClient { 52 | method, 53 | url: url.into(), 54 | headers: self.headers.clone(), 55 | body: None, 56 | timeout: self.timeout, 57 | } 58 | } 59 | 60 | pub fn get(&self, url: impl Into) -> HttpClient { 61 | self.build(Method::GET, url) 62 | } 63 | 64 | pub fn post(&self, url: impl Into) -> HttpClient { 65 | self.build(Method::POST, url) 66 | } 67 | 68 | pub fn put(&self, url: impl Into) -> HttpClient { 69 | self.build(Method::PUT, url) 70 | } 71 | 72 | pub fn delete(&self, url: impl Into) -> HttpClient { 73 | self.build(Method::DELETE, url) 74 | } 75 | 76 | pub fn patch(&self, url: impl Into) -> HttpClient { 77 | self.build(Method::PATCH, url) 78 | } 79 | 80 | pub fn with_header(mut self, name: &'static str, value: impl AsRef) -> Self { 81 | if let Ok(value) = HeaderValue::from_str(value.as_ref()) { 82 | self.headers.append(name, value); 83 | } 84 | self 85 | } 86 | 87 | pub fn with_timeout(mut self, timeout: Option) -> Self { 88 | if let Some(timeout) = timeout { 89 | self.timeout = timeout; 90 | } 91 | self 92 | } 93 | } 94 | 95 | impl HttpClient { 96 | pub fn with_header(mut self, name: &'static str, value: impl AsRef) -> Self { 97 | if let Ok(value) = HeaderValue::from_str(value.as_ref()) { 98 | self.headers.append(name, value); 99 | } 100 | self 101 | } 102 | 103 | pub fn with_body(mut self, body: B) -> crate::Result { 104 | match serde_json::to_string(&body) { 105 | Ok(body) => { 106 | self.body = Some(body); 107 | Ok(self) 108 | } 109 | Err(err) => Err(Error::Serialize(format!( 110 | "Failed to serialize request: {err}" 111 | ))), 112 | } 113 | } 114 | 115 | pub fn with_raw_body(mut self, body: String) -> Self { 116 | self.body = Some(body); 117 | self 118 | } 119 | 120 | pub async fn send(self) -> crate::Result 121 | where 122 | T: DeserializeOwned, 123 | { 124 | let response = self.send_raw().await?; 125 | serde_json::from_slice::(response.as_bytes()) 126 | .map_err(|err| Error::Serialize(format!("Failed to deserialize response: {err}"))) 127 | } 128 | 129 | pub async fn send_raw(self) -> crate::Result { 130 | let mut request = reqwest::Client::builder() 131 | .timeout(self.timeout) 132 | .build() 133 | .unwrap_or_default() 134 | .request(self.method, &self.url) 135 | .headers(self.headers); 136 | 137 | if let Some(body) = self.body { 138 | request = request.body(body); 139 | } 140 | 141 | let response = request 142 | .send() 143 | .await 144 | .map_err(|err| Error::Api(format!("Failed to send request to {}: {err}", self.url)))?; 145 | 146 | match response.status().as_u16() { 147 | 204 => serde_json::from_str("{}") 148 | .map_err(|err| Error::Serialize(format!("Failed to create empty response: {err}"))), 149 | 200..=299 => response.text().await.map_err(|err| { 150 | Error::Api(format!("Failed to read response from {}: {err}", self.url)) 151 | }), 152 | 400 => { 153 | let text = response.text().await.map_err(|err| { 154 | Error::Api(format!("Failed to read response from {}: {err}", self.url)) 155 | })?; 156 | Err(Error::Api(format!("BadRequest {}", text))) 157 | } 158 | 401 => Err(Error::Unauthorized), 159 | 404 => Err(Error::NotFound), 160 | code => Err(Error::Api(format!( 161 | "Invalid HTTP response code {code}: {:?}", 162 | response.error_for_status() 163 | ))), 164 | } 165 | } 166 | 167 | pub async fn send_with_retry(self, max_retries: u32) -> crate::Result 168 | where 169 | T: DeserializeOwned, 170 | { 171 | let mut attempts = 0; 172 | let body = self.body; 173 | loop { 174 | let mut request = reqwest::Client::builder() 175 | .timeout(self.timeout) 176 | .build() 177 | .unwrap_or_default() 178 | .request(self.method.clone(), &self.url) 179 | .headers(self.headers.clone()); 180 | 181 | if let Some(body) = body.as_ref() { 182 | request = request.body(body.clone()); 183 | } 184 | 185 | let response = request.send().await.map_err(|err| { 186 | Error::Api(format!("Failed to send request to {}: {err}", self.url)) 187 | })?; 188 | 189 | return match response.status().as_u16() { 190 | 204 => serde_json::from_str("{}").map_err(|err| { 191 | Error::Serialize(format!("Failed to create empty response: {err}")) 192 | }), 193 | 200..=299 => { 194 | let text = response.text().await.map_err(|err| { 195 | Error::Api(format!("Failed to read response from {}: {err}", self.url)) 196 | })?; 197 | serde_json::from_str(&text).map_err(|err| { 198 | Error::Serialize(format!("Failed to deserialize response: {err}")) 199 | }) 200 | } 201 | 429 if attempts < max_retries => { 202 | if let Some(retry_after) = response.headers().get("retry-after") { 203 | if let Ok(seconds) = retry_after.to_str().unwrap_or("0").parse::() { 204 | tokio::time::sleep(Duration::from_secs(seconds)).await; 205 | attempts += 1; 206 | continue; 207 | } 208 | } 209 | Err(Error::Api("Rate limit exceeded".to_string())) 210 | } 211 | 400 => { 212 | let text = response.text().await.map_err(|err| { 213 | Error::Api(format!("Failed to read response from {}: {err}", self.url)) 214 | })?; 215 | Err(Error::Api(format!("BadRequest {}", text))) 216 | } 217 | 401 => Err(Error::Unauthorized), 218 | 404 => Err(Error::NotFound), 219 | code => Err(Error::Api(format!( 220 | "Invalid HTTP response code {code}: {:?}", 221 | response.error_for_status() 222 | ))), 223 | }; 224 | } 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/providers/cloudflare.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 or the MIT license 7 | * , at your 8 | * option. This file may not be copied, modified, or distributed 9 | * except according to those terms. 10 | */ 11 | 12 | use std::{ 13 | net::{Ipv4Addr, Ipv6Addr}, 14 | time::Duration, 15 | }; 16 | 17 | use serde::{Deserialize, Serialize}; 18 | use serde_json::Value; 19 | 20 | use crate::{http::HttpClientBuilder, DnsRecord, Error, IntoFqdn}; 21 | 22 | #[derive(Clone)] 23 | pub struct CloudflareProvider { 24 | client: HttpClientBuilder, 25 | } 26 | 27 | #[derive(Deserialize, Debug)] 28 | pub struct IdMap { 29 | pub id: String, 30 | pub name: String, 31 | } 32 | 33 | #[derive(Serialize, Debug)] 34 | pub struct Query { 35 | name: String, 36 | } 37 | 38 | #[derive(Serialize, Clone, Debug)] 39 | pub struct CreateDnsRecordParams<'a> { 40 | #[serde(skip_serializing_if = "Option::is_none")] 41 | pub ttl: Option, 42 | #[serde(skip_serializing_if = "Option::is_none")] 43 | pub priority: Option, 44 | #[serde(skip_serializing_if = "Option::is_none")] 45 | pub proxied: Option, 46 | pub name: &'a str, 47 | #[serde(flatten)] 48 | pub content: DnsContent, 49 | } 50 | 51 | #[derive(Serialize, Clone, Debug)] 52 | pub struct UpdateDnsRecordParams<'a> { 53 | #[serde(skip_serializing_if = "Option::is_none")] 54 | pub ttl: Option, 55 | #[serde(skip_serializing_if = "Option::is_none")] 56 | pub proxied: Option, 57 | pub name: &'a str, 58 | #[serde(flatten)] 59 | pub content: DnsContent, 60 | } 61 | 62 | #[derive(Deserialize, Serialize, Clone, Debug)] 63 | #[serde(tag = "type")] 64 | #[allow(clippy::upper_case_acronyms)] 65 | pub enum DnsContent { 66 | A { content: Ipv4Addr }, 67 | AAAA { content: Ipv6Addr }, 68 | CNAME { content: String }, 69 | NS { content: String }, 70 | MX { content: String, priority: u16 }, 71 | TXT { content: String }, 72 | SRV { content: String }, 73 | } 74 | 75 | #[derive(Deserialize, Serialize, Debug)] 76 | struct ApiResult { 77 | errors: Vec, 78 | success: bool, 79 | result: T, 80 | } 81 | 82 | #[derive(Deserialize, Serialize, Debug)] 83 | pub struct ApiError { 84 | pub code: u16, 85 | pub message: String, 86 | } 87 | 88 | impl CloudflareProvider { 89 | pub(crate) fn new( 90 | secret: impl AsRef, 91 | email: Option>, 92 | timeout: Option, 93 | ) -> crate::Result { 94 | let client = if let Some(email) = email { 95 | HttpClientBuilder::default() 96 | .with_header("X-Auth-Email", email.as_ref()) 97 | .with_header("X-Auth-Key", secret.as_ref()) 98 | } else { 99 | HttpClientBuilder::default() 100 | .with_header("Authorization", format!("Bearer {}", secret.as_ref())) 101 | } 102 | .with_timeout(timeout); 103 | 104 | Ok(Self { client }) 105 | } 106 | 107 | async fn obtain_zone_id(&self, origin: impl IntoFqdn<'_>) -> crate::Result { 108 | let origin = origin.into_name(); 109 | self.client 110 | .get(format!( 111 | "https://api.cloudflare.com/client/v4/zones?{}", 112 | Query::name(origin.as_ref()).serialize() 113 | )) 114 | .send_with_retry::>>(3) 115 | .await 116 | .and_then(|r| r.unwrap_response("list zones")) 117 | .and_then(|result| { 118 | result 119 | .into_iter() 120 | .find(|zone| zone.name == origin.as_ref()) 121 | .map(|zone| zone.id) 122 | .ok_or_else(|| Error::Api(format!("Zone {} not found", origin.as_ref()))) 123 | }) 124 | } 125 | 126 | async fn obtain_record_id( 127 | &self, 128 | zone_id: &str, 129 | name: impl IntoFqdn<'_>, 130 | ) -> crate::Result { 131 | let name = name.into_name(); 132 | self.client 133 | .get(format!( 134 | "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records?{}", 135 | Query::name(name.as_ref()).serialize() 136 | )) 137 | .send_with_retry::>>(3) 138 | .await 139 | .and_then(|r| r.unwrap_response("list DNS records")) 140 | .and_then(|result| { 141 | result 142 | .into_iter() 143 | .find(|record| record.name == name.as_ref()) 144 | .map(|record| record.id) 145 | .ok_or_else(|| Error::Api(format!("DNS Record {} not found", name.as_ref()))) 146 | }) 147 | } 148 | 149 | pub(crate) async fn create( 150 | &self, 151 | name: impl IntoFqdn<'_>, 152 | record: DnsRecord, 153 | ttl: u32, 154 | origin: impl IntoFqdn<'_>, 155 | ) -> crate::Result<()> { 156 | self.client 157 | .post(format!( 158 | "https://api.cloudflare.com/client/v4/zones/{}/dns_records", 159 | self.obtain_zone_id(origin).await? 160 | )) 161 | .with_body(CreateDnsRecordParams { 162 | ttl: ttl.into(), 163 | priority: record.priority(), 164 | proxied: false.into(), 165 | name: name.into_name().as_ref(), 166 | content: record.into(), 167 | })? 168 | .send_with_retry::>(3) 169 | .await 170 | .map(|_| ()) 171 | } 172 | 173 | pub(crate) async fn update( 174 | &self, 175 | name: impl IntoFqdn<'_>, 176 | record: DnsRecord, 177 | ttl: u32, 178 | origin: impl IntoFqdn<'_>, 179 | ) -> crate::Result<()> { 180 | let name = name.into_name(); 181 | self.client 182 | .patch(format!( 183 | "https://api.cloudflare.com/client/v4/zones/{}/dns_records/{}", 184 | self.obtain_zone_id(origin).await?, 185 | name.as_ref() 186 | )) 187 | .with_body(UpdateDnsRecordParams { 188 | ttl: ttl.into(), 189 | proxied: None, 190 | name: name.as_ref(), 191 | content: record.into(), 192 | })? 193 | .send_with_retry::>(3) 194 | .await 195 | .map(|_| ()) 196 | } 197 | 198 | pub(crate) async fn delete( 199 | &self, 200 | name: impl IntoFqdn<'_>, 201 | origin: impl IntoFqdn<'_>, 202 | ) -> crate::Result<()> { 203 | let zone_id = self.obtain_zone_id(origin).await?; 204 | let record_id = self.obtain_record_id(&zone_id, name).await?; 205 | 206 | self.client 207 | .delete(format!( 208 | "https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{record_id}", 209 | )) 210 | .send_with_retry::>(3) 211 | .await 212 | .map(|_| ()) 213 | } 214 | } 215 | 216 | impl ApiResult { 217 | fn unwrap_response(self, action_name: &str) -> crate::Result { 218 | if self.success { 219 | Ok(self.result) 220 | } else { 221 | Err(Error::Api(format!( 222 | "Failed to {action_name}: {:?}", 223 | self.errors 224 | ))) 225 | } 226 | } 227 | } 228 | 229 | impl Query { 230 | pub fn name(name: impl Into) -> Self { 231 | Self { name: name.into() } 232 | } 233 | 234 | pub fn serialize(&self) -> String { 235 | serde_urlencoded::to_string(self).unwrap() 236 | } 237 | } 238 | 239 | impl From for DnsContent { 240 | fn from(record: DnsRecord) -> Self { 241 | match record { 242 | DnsRecord::A { content } => DnsContent::A { content }, 243 | DnsRecord::AAAA { content } => DnsContent::AAAA { content }, 244 | DnsRecord::CNAME { content } => DnsContent::CNAME { content }, 245 | DnsRecord::NS { content } => DnsContent::NS { content }, 246 | DnsRecord::MX { content, priority } => DnsContent::MX { content, priority }, 247 | DnsRecord::TXT { content } => DnsContent::TXT { content }, 248 | DnsRecord::SRV { content, .. } => DnsContent::SRV { content }, 249 | } 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /src/providers/bunny.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | net::{Ipv4Addr, Ipv6Addr}, 3 | time::Duration, 4 | }; 5 | 6 | use serde::{Deserialize, Serialize}; 7 | 8 | use crate::{http::HttpClientBuilder, DnsRecord, DnsRecordType, Error, IntoFqdn}; 9 | 10 | #[derive(Clone)] 11 | pub struct BunnyProvider { 12 | client: HttpClientBuilder, 13 | } 14 | 15 | impl BunnyProvider { 16 | pub(crate) fn new(api_key: impl AsRef, timeout: Option) -> crate::Result { 17 | Ok(Self { 18 | client: HttpClientBuilder::default() 19 | .with_header("AccessKey", api_key.as_ref()) 20 | .with_timeout(timeout), 21 | }) 22 | } 23 | 24 | // --- 25 | // Library functions 26 | 27 | pub(crate) async fn create( 28 | &self, 29 | name: impl IntoFqdn<'_>, 30 | record: DnsRecord, 31 | ttl: u32, 32 | origin: impl IntoFqdn<'_>, 33 | ) -> crate::Result<()> { 34 | let zone_id = self.get_zone_data(origin).await?.id; 35 | let name = name.into_name(); 36 | let body = DnsRecordData { 37 | name: name.into(), 38 | record_type: (&record).into(), 39 | ttl: Some(ttl), 40 | }; 41 | 42 | self.client 43 | .put(format!("https://api.bunny.net/dnszone/{zone_id}/records")) 44 | .with_body(&body)? 45 | .send_with_retry::(3) 46 | .await 47 | .map(|_| ()) 48 | } 49 | 50 | pub(crate) async fn update( 51 | &self, 52 | name: impl IntoFqdn<'_>, 53 | record: DnsRecord, 54 | ttl: u32, 55 | origin: impl IntoFqdn<'_>, 56 | ) -> crate::Result<()> { 57 | let name = name.into_name(); 58 | 59 | let zone_data = self.get_zone_data(origin).await?; 60 | let zone_id = zone_data.id; 61 | let bunny_record = zone_data 62 | .records 63 | .iter() 64 | .find(|r| r.record.name == name && r.record.record_type.eq_type(&record)) 65 | .ok_or(Error::NotFound)?; 66 | 67 | self.client 68 | .post(format!( 69 | "https://api.bunny.net/dnszone/{zone_id}/records/{}", 70 | bunny_record.id 71 | )) 72 | .with_body(BunnyDnsRecord { 73 | id: bunny_record.id, 74 | record: DnsRecordData { 75 | name: bunny_record.record.name.clone(), 76 | record_type: (&record).into(), 77 | ttl: Some(ttl), 78 | }, 79 | })? 80 | .send_with_retry::(3) 81 | .await 82 | .map(|_| ()) 83 | } 84 | 85 | pub(crate) async fn delete( 86 | &self, 87 | name: impl IntoFqdn<'_>, 88 | origin: impl IntoFqdn<'_>, 89 | record: DnsRecordType, 90 | ) -> crate::Result<()> { 91 | let name = name.into_name(); 92 | 93 | let zone_data = self.get_zone_data(origin).await?; 94 | let zone_id = zone_data.id; 95 | let record_id = zone_data 96 | .records 97 | .iter() 98 | .find(|r| r.record.name == name && r.record.record_type == record) 99 | .map(|r| r.id) 100 | .ok_or(Error::NotFound)?; 101 | 102 | self.client 103 | .delete(format!( 104 | "https://api.bunny.net/dnszone/{zone_id}/records/{record_id}", 105 | )) 106 | .send_with_retry::(3) 107 | .await 108 | .map(|_| ()) 109 | } 110 | 111 | // --- 112 | // Utility functions 113 | 114 | async fn get_zone_data(&self, origin: impl IntoFqdn<'_>) -> crate::Result { 115 | let origin = origin.into_name(); 116 | 117 | let query_string = serde_urlencoded::to_string([("search", origin.as_ref())]) 118 | .expect("Unable to convert DNS origin into HTTP query string"); 119 | self.client 120 | .get(format!("https://api.bunny.net/dnszone?{query_string}")) 121 | .send_with_retry::>(3) 122 | .await 123 | .and_then(|r| { 124 | r.items 125 | .into_iter() 126 | .find(|z| z.domain == origin.as_ref()) 127 | .ok_or_else(|| Error::Api(format!("DNS Record {origin} not found"))) 128 | }) 129 | } 130 | } 131 | 132 | // ----------- 133 | // Data types 134 | 135 | #[derive(Debug, Clone, Serialize, Deserialize)] 136 | #[serde(tag = "Type")] 137 | #[repr(u8)] 138 | pub enum BunnyDnsRecordType { 139 | #[serde(rename_all = "PascalCase")] 140 | A { 141 | value: Ipv4Addr, 142 | }, 143 | #[serde(rename_all = "PascalCase")] 144 | AAAA { 145 | value: Ipv6Addr, 146 | }, 147 | #[serde(rename_all = "PascalCase")] 148 | CNAME { 149 | value: String, 150 | }, 151 | #[serde(rename_all = "PascalCase")] 152 | TXT { 153 | value: String, 154 | }, 155 | #[serde(rename_all = "PascalCase")] 156 | MX { 157 | value: String, 158 | priority: u16, 159 | }, 160 | Redirect, 161 | Flatten, 162 | PullZone, 163 | #[serde(rename_all = "PascalCase")] 164 | SRV { 165 | value: String, 166 | priority: u16, 167 | port: u16, 168 | weight: u16, 169 | }, 170 | CAA, 171 | PTR, 172 | Script, 173 | #[serde(rename_all = "PascalCase")] 174 | NS { 175 | value: String, 176 | }, 177 | SVCB, 178 | HTTPS, 179 | } 180 | 181 | impl From<&DnsRecord> for BunnyDnsRecordType { 182 | fn from(record: &DnsRecord) -> Self { 183 | match record { 184 | DnsRecord::A { content } => BunnyDnsRecordType::A { value: (*content) }, 185 | DnsRecord::AAAA { content } => BunnyDnsRecordType::AAAA { value: (*content) }, 186 | DnsRecord::CNAME { content } => BunnyDnsRecordType::CNAME { 187 | value: content.to_string(), 188 | }, 189 | DnsRecord::NS { content } => BunnyDnsRecordType::NS { 190 | value: content.to_string(), 191 | }, 192 | DnsRecord::MX { content, priority } => BunnyDnsRecordType::MX { 193 | value: content.to_string(), 194 | priority: *priority, 195 | }, 196 | DnsRecord::TXT { content } => BunnyDnsRecordType::TXT { 197 | value: content.to_string(), 198 | }, 199 | DnsRecord::SRV { 200 | content, 201 | priority, 202 | weight, 203 | port, 204 | } => BunnyDnsRecordType::SRV { 205 | value: content.to_string(), 206 | priority: *priority, 207 | port: *port, 208 | weight: *weight, 209 | }, 210 | } 211 | } 212 | } 213 | 214 | impl BunnyDnsRecordType { 215 | /// Tests `self` and `other`'s DNS record type to be equal 216 | fn eq_type(&self, other: &DnsRecord) -> bool { 217 | match other { 218 | DnsRecord::A { .. } => matches!(self, BunnyDnsRecordType::A { .. }), 219 | DnsRecord::AAAA { .. } => matches!(self, BunnyDnsRecordType::AAAA { .. }), 220 | DnsRecord::CNAME { .. } => matches!(self, BunnyDnsRecordType::CNAME { .. }), 221 | DnsRecord::NS { .. } => matches!(self, BunnyDnsRecordType::NS { .. }), 222 | DnsRecord::MX { .. } => matches!(self, BunnyDnsRecordType::MX { .. }), 223 | DnsRecord::TXT { .. } => matches!(self, BunnyDnsRecordType::TXT { .. }), 224 | DnsRecord::SRV { .. } => matches!(self, BunnyDnsRecordType::SRV { .. }), 225 | } 226 | } 227 | } 228 | 229 | impl PartialEq for BunnyDnsRecordType { 230 | fn eq(&self, other: &DnsRecordType) -> bool { 231 | match other { 232 | DnsRecordType::A => matches!(self, BunnyDnsRecordType::A { .. }), 233 | DnsRecordType::AAAA => matches!(self, BunnyDnsRecordType::AAAA { .. }), 234 | DnsRecordType::CNAME => matches!(self, BunnyDnsRecordType::CNAME { .. }), 235 | DnsRecordType::NS => matches!(self, BunnyDnsRecordType::NS { .. }), 236 | DnsRecordType::MX => matches!(self, BunnyDnsRecordType::MX { .. }), 237 | DnsRecordType::TXT => matches!(self, BunnyDnsRecordType::TXT { .. }), 238 | DnsRecordType::SRV => matches!(self, BunnyDnsRecordType::SRV { .. }), 239 | } 240 | } 241 | } 242 | 243 | // ----------- 244 | // API Responses 245 | 246 | #[derive(Deserialize, Clone, Debug)] 247 | #[serde(rename_all = "PascalCase")] 248 | pub struct ApiItems { 249 | pub items: Vec, 250 | 251 | pub current_page: u32, 252 | pub total_items: u32, 253 | 254 | pub has_more_items: bool, 255 | } 256 | 257 | #[derive(Serialize, Deserialize, Clone, Debug)] 258 | #[serde(rename_all = "PascalCase")] 259 | pub struct PartialDnsZone { 260 | pub id: u32, 261 | pub domain: String, 262 | pub records: Vec, 263 | } 264 | 265 | #[derive(Serialize, Deserialize, Clone, Debug)] 266 | #[serde(rename_all = "PascalCase")] 267 | pub struct BunnyDnsRecord { 268 | pub id: u32, 269 | #[serde(flatten)] 270 | pub record: DnsRecordData, 271 | } 272 | 273 | #[derive(Serialize, Deserialize, Clone, Debug)] 274 | #[serde(rename_all = "PascalCase")] 275 | pub struct DnsRecordData { 276 | pub name: String, 277 | 278 | #[serde(flatten)] 279 | pub record_type: BunnyDnsRecordType, 280 | 281 | #[serde(skip_serializing_if = "Option::is_none")] 282 | pub ttl: Option, 283 | } 284 | -------------------------------------------------------------------------------- /src/tests/desec_tests.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | mod tests { 3 | use crate::providers::desec::DesecDnsRecordRepresentation; 4 | use crate::{providers::desec::DesecProvider, DnsRecord, DnsRecordType, Error}; 5 | use serde_json::json; 6 | use std::time::Duration; 7 | 8 | fn setup_provider(endpoint: &str) -> DesecProvider { 9 | DesecProvider::new("test_token", Some(Duration::from_secs(1))).with_endpoint(endpoint) 10 | } 11 | 12 | #[tokio::test] 13 | async fn test_create_record_success() { 14 | let mut server = mockito::Server::new_async().await; 15 | let expected_request = json!({ 16 | "subname": "test", 17 | "type": "A", 18 | "ttl": 3600, 19 | "records": ["1.1.1.1"], 20 | }); 21 | 22 | let mock = server 23 | .mock("POST", "/domains/example.com/rrsets/") 24 | .with_status(201) 25 | .with_header("content-type", "application/json") 26 | .match_header("authorization", "Token test_token") 27 | .match_header("content-type", "application/json") 28 | .match_body(mockito::Matcher::Json(expected_request)) 29 | .with_body( 30 | r#"{ 31 | "created": "2025-07-25T19:18:37.286381Z", 32 | "domain": "example.com", 33 | "subname": "test", 34 | "name": "test.example.com.", 35 | "records": ["1.1.1.1"], 36 | "ttl": 3600, 37 | "type": "A", 38 | "touched": "2025-07-25T19:18:37.292390Z" 39 | }"#, 40 | ) 41 | .create(); 42 | 43 | let provider = setup_provider(server.url().as_str()); 44 | let result = provider 45 | .create( 46 | "test.example.com", 47 | DnsRecord::A { 48 | content: "1.1.1.1".parse().unwrap(), 49 | }, 50 | 3600, 51 | "example.com", 52 | ) 53 | .await; 54 | 55 | assert!(result.is_ok()); 56 | mock.assert(); 57 | } 58 | 59 | #[tokio::test] 60 | async fn test_create_mx_record_success() { 61 | let mut server = mockito::Server::new_async().await; 62 | let expected_request = json!({ 63 | "subname": "test", 64 | "type": "MX", 65 | "ttl": 3600, 66 | "records": ["10 mail.example.com"], 67 | }); 68 | 69 | let mock = server 70 | .mock("POST", "/domains/example.com/rrsets/") 71 | .with_status(201) 72 | .with_header("content-type", "application/json") 73 | .match_header("authorization", "Token test_token") 74 | .match_header("content-type", "application/json") 75 | .match_body(mockito::Matcher::Json(expected_request)) 76 | .with_body( 77 | r#"{ 78 | "created": "2025-07-25T19:18:37.286381Z", 79 | "domain": "example.com", 80 | "subname": "test", 81 | "name": "test.example.com.", 82 | "records": ["10 mail.example.com"], 83 | "ttl": 3600, 84 | "type": "MX", 85 | "touched": "2025-07-25T19:18:37.292390Z" 86 | }"#, 87 | ) 88 | .create(); 89 | 90 | let provider = setup_provider(server.url().as_str()); 91 | let result = provider 92 | .create( 93 | "test.example.com", 94 | DnsRecord::MX { 95 | priority: 10, 96 | content: "mail.example.com".to_string(), 97 | }, 98 | 3600, 99 | "example.com", 100 | ) 101 | .await; 102 | 103 | assert!(result.is_ok()); 104 | mock.assert(); 105 | } 106 | 107 | #[tokio::test] 108 | async fn test_create_record_unauthorized() { 109 | let mut server = mockito::Server::new_async().await; 110 | let expected_request = json!({ 111 | "subname": "test", 112 | "type": "A", 113 | "ttl": 3600, 114 | "records": ["1.1.1.1"], 115 | }); 116 | 117 | let mock = server 118 | .mock("POST", "/domains/example.com/rrsets/") 119 | .with_status(401) 120 | .with_header("content-type", "application/json") 121 | .match_header("authorization", "Token test_token") 122 | .match_header("content-type", "application/json") 123 | .match_body(mockito::Matcher::Json(expected_request)) 124 | .with_body(r#"{ "detail": "Invalid token." }"#) 125 | .create(); 126 | 127 | let provider = setup_provider(server.url().as_str()); 128 | let result = provider 129 | .create( 130 | "test.example.com", 131 | DnsRecord::A { 132 | content: "1.1.1.1".parse().unwrap(), 133 | }, 134 | 3600, 135 | "example.com", 136 | ) 137 | .await; 138 | 139 | assert!(matches!(result, Err(Error::Unauthorized))); 140 | mock.assert(); 141 | } 142 | 143 | #[tokio::test] 144 | async fn test_update_record_success() { 145 | let mut server = mockito::Server::new_async().await; 146 | let expected_request = json!({ 147 | "subname": "test", 148 | "type": "AAAA", 149 | "ttl": 3600, 150 | "records": ["2001:db8::1"], 151 | }); 152 | 153 | let mock = server 154 | .mock("PUT", "/domains/example.com/rrsets/test/AAAA/") 155 | .with_status(200) 156 | .match_body(mockito::Matcher::Json(expected_request)) 157 | .match_header("authorization", "Token test_token") 158 | .with_body( 159 | r#"{ 160 | "created": "2025-07-25T19:18:37.286381Z", 161 | "domain": "example.com", 162 | "subname": "test", 163 | "name": "test.example.com.", 164 | "records": ["2001:db8::1"], 165 | "ttl": 3600, 166 | "type": "AAAA", 167 | "touched": "2025-07-25T19:18:37.292390Z" 168 | }"#, 169 | ) 170 | .create(); 171 | 172 | let provider = setup_provider(server.url().as_str()); 173 | let result = provider 174 | .update( 175 | "test", 176 | DnsRecord::AAAA { 177 | content: "2001:db8::1".parse().unwrap(), 178 | }, 179 | 3600, 180 | "example.com", 181 | ) 182 | .await; 183 | 184 | assert!(result.is_ok()); 185 | mock.assert(); 186 | } 187 | 188 | #[tokio::test] 189 | async fn test_delete_record_success() { 190 | let mut server = mockito::Server::new_async().await; 191 | let mock = server 192 | .mock("DELETE", "/domains/example.com/rrsets/test/TXT/") 193 | .with_status(204) 194 | .create(); 195 | 196 | let provider = setup_provider(server.url().as_str()); 197 | let result = provider 198 | .delete("test", "example.com", DnsRecordType::TXT) 199 | .await; 200 | 201 | assert!(result.is_ok()); 202 | mock.assert(); 203 | } 204 | 205 | #[tokio::test] 206 | #[ignore = "Requires desec API Token and domain configuration"] 207 | async fn integration_test() { 208 | let token = ""; // <-- Fill in your deSEC API token here 209 | let origin = ""; // <-- Fill in your domain (e.g., "example.com") 210 | let domain = ""; // <-- Fill in your test subdomain (e.g., "test.example.com") 211 | 212 | assert!( 213 | !token.is_empty(), 214 | "Please configure your deSEC API token in the integration test" 215 | ); 216 | assert!( 217 | !origin.is_empty(), 218 | "Please configure your domain in the integration test" 219 | ); 220 | assert!( 221 | !domain.is_empty(), 222 | "Please configure your test subdomain in the integration test" 223 | ); 224 | 225 | let provider = DesecProvider::new(token, Some(Duration::from_secs(30))); 226 | 227 | // check creation 228 | let creation_result = provider 229 | .create( 230 | domain, 231 | DnsRecord::A { 232 | content: "1.1.1.1".parse().unwrap(), 233 | }, 234 | 3600, 235 | origin, 236 | ) 237 | .await; 238 | 239 | assert!(creation_result.is_ok()); 240 | 241 | // check modification 242 | let update_result = provider 243 | .update( 244 | domain, 245 | DnsRecord::A { 246 | content: "2.2.2.2".parse().unwrap(), 247 | }, 248 | 3600, 249 | origin, 250 | ) 251 | .await; 252 | 253 | assert!(update_result.is_ok()); 254 | 255 | // check deletion 256 | let deletion_result = provider.delete(domain, origin, DnsRecordType::A).await; 257 | 258 | assert!(deletion_result.is_ok()); 259 | } 260 | 261 | #[test] 262 | fn test_into_desec_record() { 263 | let record = DnsRecord::A { 264 | content: "1.1.1.1".parse().unwrap(), 265 | }; 266 | let desec_record: DesecDnsRecordRepresentation = record.into(); 267 | assert_eq!(desec_record.content, "1.1.1.1"); 268 | assert_eq!(desec_record.record_type, "A"); 269 | 270 | let record = DnsRecord::AAAA { 271 | content: "2001:db8::1".parse().unwrap(), 272 | }; 273 | let desec_record: DesecDnsRecordRepresentation = record.into(); 274 | assert_eq!(desec_record.content, "2001:db8::1"); 275 | assert_eq!(desec_record.record_type, "AAAA"); 276 | 277 | let record = DnsRecord::TXT { 278 | content: "test".to_string(), 279 | }; 280 | let desec_record: DesecDnsRecordRepresentation = record.into(); 281 | assert_eq!(desec_record.content, "test"); 282 | assert_eq!(desec_record.record_type, "TXT"); 283 | 284 | let record = DnsRecord::MX { 285 | priority: 10, 286 | content: "mail.example.com".to_string(), 287 | }; 288 | let desec_record: DesecDnsRecordRepresentation = record.into(); 289 | assert_eq!(desec_record.content, "10 mail.example.com"); 290 | assert_eq!(desec_record.record_type, "MX"); 291 | 292 | let record = DnsRecord::SRV { 293 | priority: 10, 294 | weight: 20, 295 | port: 443, 296 | content: "sip.example.com".to_string(), 297 | }; 298 | let desec_record: DesecDnsRecordRepresentation = record.into(); 299 | assert_eq!(desec_record.content, "10 20 443 sip.example.com"); 300 | assert_eq!(desec_record.record_type, "SRV"); 301 | } 302 | } 303 | -------------------------------------------------------------------------------- /src/providers/rfc2136.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 or the MIT license 7 | * , at your 8 | * option. This file may not be copied, modified, or distributed 9 | * except according to those terms. 10 | */ 11 | 12 | use std::net::{AddrParseError, SocketAddr}; 13 | use std::sync::Arc; 14 | 15 | use hickory_client::client::{AsyncClient, ClientConnection, ClientHandle, Signer}; 16 | use hickory_client::error::ClientError; 17 | use hickory_client::op::ResponseCode; 18 | use hickory_client::proto::error::ProtoError; 19 | use hickory_client::proto::rr::dnssec::tsig::TSigner; 20 | use hickory_client::proto::rr::dnssec::{Algorithm, KeyPair, Private, SigSigner}; 21 | use hickory_client::rr::rdata::key::KEY; 22 | use hickory_client::rr::rdata::tsig::TsigAlgorithm; 23 | use hickory_client::rr::rdata::{A, AAAA, CNAME, MX, NS, SRV, TXT}; 24 | use hickory_client::rr::{DNSClass, Name, RData, Record, RecordType}; 25 | use hickory_client::tcp::TcpClientConnection; 26 | use hickory_client::udp::UdpClientConnection; 27 | 28 | use crate::{DnsRecord, Error, IntoFqdn}; 29 | 30 | #[derive(Clone)] 31 | pub struct Rfc2136Provider { 32 | addr: DnsAddress, 33 | signer: Arc, 34 | } 35 | 36 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 37 | pub enum DnsAddress { 38 | Tcp(SocketAddr), 39 | Udp(SocketAddr), 40 | } 41 | 42 | impl Rfc2136Provider { 43 | pub(crate) fn new_tsig( 44 | addr: impl TryInto, 45 | key_name: impl AsRef, 46 | key: impl Into>, 47 | algorithm: TsigAlgorithm, 48 | ) -> crate::Result { 49 | Ok(Rfc2136Provider { 50 | addr: addr 51 | .try_into() 52 | .map_err(|_| Error::Parse("Invalid address".to_string()))?, 53 | signer: Arc::new(Signer::from(TSigner::new( 54 | key.into(), 55 | algorithm, 56 | Name::from_ascii(key_name.as_ref())?, 57 | 60, 58 | )?)), 59 | }) 60 | } 61 | 62 | pub(crate) fn new_sig0( 63 | addr: impl TryInto, 64 | signer_name: impl AsRef, 65 | key: KeyPair, 66 | public_key: impl Into>, 67 | algorithm: Algorithm, 68 | ) -> crate::Result { 69 | let sig0key = KEY::new( 70 | Default::default(), 71 | Default::default(), 72 | Default::default(), 73 | Default::default(), 74 | algorithm, 75 | public_key.into(), 76 | ); 77 | 78 | let signer = SigSigner::sig0(sig0key, key, Name::from_str_relaxed(signer_name.as_ref())?); 79 | 80 | Ok(Rfc2136Provider { 81 | addr: addr 82 | .try_into() 83 | .map_err(|_| Error::Parse("Invalid address".to_string()))?, 84 | signer: Arc::new(Signer::from(signer)), 85 | }) 86 | } 87 | 88 | async fn connect(&self) -> crate::Result { 89 | match &self.addr { 90 | DnsAddress::Udp(addr) => { 91 | let conn = UdpClientConnection::new(*addr)?.new_stream(Some(self.signer.clone())); 92 | let (client, bg) = AsyncClient::connect(conn).await?; 93 | tokio::spawn(bg); 94 | Ok(client) 95 | } 96 | DnsAddress::Tcp(addr) => { 97 | let conn = TcpClientConnection::new(*addr)?.new_stream(Some(self.signer.clone())); 98 | let (client, bg) = AsyncClient::connect(conn).await?; 99 | tokio::spawn(bg); 100 | Ok(client) 101 | } 102 | } 103 | } 104 | 105 | pub(crate) async fn create( 106 | &self, 107 | name: impl IntoFqdn<'_>, 108 | record: DnsRecord, 109 | ttl: u32, 110 | origin: impl IntoFqdn<'_>, 111 | ) -> crate::Result<()> { 112 | let (rr_type, rdata) = convert_record(record)?; 113 | let mut record = Record::with( 114 | Name::from_str_relaxed(name.into_name().as_ref())?, 115 | rr_type, 116 | ttl, 117 | ); 118 | record.set_data(Some(rdata)); 119 | 120 | let mut client = self.connect().await?; 121 | let result = client 122 | .create(record, Name::from_str_relaxed(origin.into_fqdn().as_ref())?) 123 | .await?; 124 | if result.response_code() == ResponseCode::NoError { 125 | Ok(()) 126 | } else { 127 | Err(crate::Error::Response(result.response_code().to_string())) 128 | } 129 | } 130 | 131 | pub(crate) async fn update( 132 | &self, 133 | name: impl IntoFqdn<'_>, 134 | record: DnsRecord, 135 | ttl: u32, 136 | origin: impl IntoFqdn<'_>, 137 | ) -> crate::Result<()> { 138 | let (rr_type, rdata) = convert_record(record)?; 139 | let mut record = Record::with( 140 | Name::from_str_relaxed(name.into_name().as_ref())?, 141 | rr_type, 142 | ttl, 143 | ); 144 | record.set_data(Some(rdata)); 145 | 146 | let mut client = self.connect().await?; 147 | let result = client 148 | .append( 149 | record, 150 | Name::from_str_relaxed(origin.into_fqdn().as_ref())?, 151 | false, 152 | ) 153 | .await?; 154 | if result.response_code() == ResponseCode::NoError { 155 | Ok(()) 156 | } else { 157 | Err(crate::Error::Response(result.response_code().to_string())) 158 | } 159 | } 160 | 161 | pub(crate) async fn delete( 162 | &self, 163 | name: impl IntoFqdn<'_>, 164 | origin: impl IntoFqdn<'_>, 165 | ) -> crate::Result<()> { 166 | let mut client = self.connect().await?; 167 | let result = client 168 | .delete_all( 169 | Name::from_str_relaxed(name.into_name().as_ref())?, 170 | Name::from_str_relaxed(origin.into_fqdn().as_ref())?, 171 | DNSClass::IN, 172 | ) 173 | .await?; 174 | if result.response_code() == ResponseCode::NoError { 175 | Ok(()) 176 | } else { 177 | Err(crate::Error::Response(result.response_code().to_string())) 178 | } 179 | } 180 | } 181 | 182 | fn convert_record(record: DnsRecord) -> crate::Result<(RecordType, RData)> { 183 | Ok(match record { 184 | DnsRecord::A { content } => (RecordType::A, RData::A(A::from(content))), 185 | DnsRecord::AAAA { content } => (RecordType::AAAA, RData::AAAA(AAAA::from(content))), 186 | DnsRecord::CNAME { content } => ( 187 | RecordType::CNAME, 188 | RData::CNAME(CNAME(Name::from_str_relaxed(content)?)), 189 | ), 190 | DnsRecord::NS { content } => ( 191 | RecordType::NS, 192 | RData::NS(NS(Name::from_str_relaxed(content)?)), 193 | ), 194 | DnsRecord::MX { content, priority } => ( 195 | RecordType::MX, 196 | RData::MX(MX::new(priority, Name::from_str_relaxed(content)?)), 197 | ), 198 | DnsRecord::TXT { content } => (RecordType::TXT, RData::TXT(TXT::new(vec![content]))), 199 | DnsRecord::SRV { 200 | content, 201 | priority, 202 | weight, 203 | port, 204 | } => ( 205 | RecordType::SRV, 206 | RData::SRV(SRV::new( 207 | priority, 208 | weight, 209 | port, 210 | Name::from_str_relaxed(content)?, 211 | )), 212 | ), 213 | }) 214 | } 215 | 216 | impl TryFrom<&str> for DnsAddress { 217 | type Error = (); 218 | 219 | fn try_from(url: &str) -> Result { 220 | let (host, is_tcp) = if let Some(host) = url.strip_prefix("udp://") { 221 | (host, false) 222 | } else if let Some(host) = url.strip_prefix("tcp://") { 223 | (host, true) 224 | } else { 225 | (url, false) 226 | }; 227 | let (host, port) = if let Some(host) = host.strip_prefix('[') { 228 | let (host, maybe_port) = host.rsplit_once(']').ok_or(())?; 229 | 230 | ( 231 | host, 232 | maybe_port 233 | .rsplit_once(':') 234 | .map(|(_, port)| port) 235 | .unwrap_or("53"), 236 | ) 237 | } else if let Some((host, port)) = host.rsplit_once(':') { 238 | (host, port) 239 | } else { 240 | (host, "53") 241 | }; 242 | 243 | let addr = SocketAddr::new(host.parse().map_err(|_| ())?, port.parse().map_err(|_| ())?); 244 | 245 | if is_tcp { 246 | Ok(DnsAddress::Tcp(addr)) 247 | } else { 248 | Ok(DnsAddress::Udp(addr)) 249 | } 250 | } 251 | } 252 | 253 | impl TryFrom<&String> for DnsAddress { 254 | type Error = (); 255 | 256 | fn try_from(url: &String) -> Result { 257 | DnsAddress::try_from(url.as_str()) 258 | } 259 | } 260 | 261 | impl TryFrom for DnsAddress { 262 | type Error = (); 263 | 264 | fn try_from(url: String) -> Result { 265 | DnsAddress::try_from(url.as_str()) 266 | } 267 | } 268 | 269 | impl From for TsigAlgorithm { 270 | fn from(alg: crate::TsigAlgorithm) -> Self { 271 | match alg { 272 | crate::TsigAlgorithm::HmacMd5 => TsigAlgorithm::HmacMd5, 273 | crate::TsigAlgorithm::Gss => TsigAlgorithm::Gss, 274 | crate::TsigAlgorithm::HmacSha1 => TsigAlgorithm::HmacSha1, 275 | crate::TsigAlgorithm::HmacSha224 => TsigAlgorithm::HmacSha224, 276 | crate::TsigAlgorithm::HmacSha256 => TsigAlgorithm::HmacSha256, 277 | crate::TsigAlgorithm::HmacSha256_128 => TsigAlgorithm::HmacSha256_128, 278 | crate::TsigAlgorithm::HmacSha384 => TsigAlgorithm::HmacSha384, 279 | crate::TsigAlgorithm::HmacSha384_192 => TsigAlgorithm::HmacSha384_192, 280 | crate::TsigAlgorithm::HmacSha512 => TsigAlgorithm::HmacSha512, 281 | crate::TsigAlgorithm::HmacSha512_256 => TsigAlgorithm::HmacSha512_256, 282 | } 283 | } 284 | } 285 | 286 | impl From for Algorithm { 287 | fn from(alg: crate::Algorithm) -> Self { 288 | match alg { 289 | crate::Algorithm::RSASHA256 => Algorithm::RSASHA256, 290 | crate::Algorithm::RSASHA512 => Algorithm::RSASHA512, 291 | crate::Algorithm::ECDSAP256SHA256 => Algorithm::ECDSAP256SHA256, 292 | crate::Algorithm::ECDSAP384SHA384 => Algorithm::ECDSAP384SHA384, 293 | crate::Algorithm::ED25519 => Algorithm::ED25519, 294 | } 295 | } 296 | } 297 | 298 | impl From for Error { 299 | fn from(e: ProtoError) -> Self { 300 | Error::Protocol(e.to_string()) 301 | } 302 | } 303 | 304 | impl From for Error { 305 | fn from(e: AddrParseError) -> Self { 306 | Error::Parse(e.to_string()) 307 | } 308 | } 309 | 310 | impl From for Error { 311 | fn from(e: ClientError) -> Self { 312 | Error::Client(e.to_string()) 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![doc = include_str!("../README.md")] 2 | use core::fmt; 3 | /* 4 | * Copyright Stalwart Labs LLC See the COPYING 5 | * file at the top-level directory of this distribution. 6 | * 7 | * Licensed under the Apache License, Version 2.0 or the MIT license 9 | * , at your 10 | * option. This file may not be copied, modified, or distributed 11 | * except according to those terms. 12 | */ 13 | use std::{ 14 | borrow::Cow, 15 | fmt::{Display, Formatter}, 16 | net::{Ipv4Addr, Ipv6Addr}, 17 | str::FromStr, 18 | time::Duration, 19 | }; 20 | 21 | use hickory_client::proto::rr::dnssec::{KeyPair, Private}; 22 | 23 | use providers::{ 24 | bunny::BunnyProvider, 25 | cloudflare::CloudflareProvider, 26 | desec::DesecProvider, 27 | digitalocean::DigitalOceanProvider, 28 | ovh::{OvhEndpoint, OvhProvider}, 29 | rfc2136::{DnsAddress, Rfc2136Provider}, 30 | }; 31 | 32 | pub mod http; 33 | pub mod providers; 34 | pub mod tests; 35 | 36 | #[derive(Debug)] 37 | pub enum Error { 38 | Protocol(String), 39 | Parse(String), 40 | Client(String), 41 | Response(String), 42 | Api(String), 43 | Serialize(String), 44 | Unauthorized, 45 | NotFound, 46 | BadRequest, 47 | } 48 | 49 | /// A DNS record type. 50 | #[derive(Debug)] 51 | pub enum DnsRecordType { 52 | A, 53 | AAAA, 54 | CNAME, 55 | NS, 56 | MX, 57 | TXT, 58 | SRV, 59 | } 60 | 61 | /// A DNS record type with a value. 62 | pub enum DnsRecord { 63 | A { 64 | content: Ipv4Addr, 65 | }, 66 | AAAA { 67 | content: Ipv6Addr, 68 | }, 69 | CNAME { 70 | content: String, 71 | }, 72 | NS { 73 | content: String, 74 | }, 75 | MX { 76 | content: String, 77 | priority: u16, 78 | }, 79 | TXT { 80 | content: String, 81 | }, 82 | SRV { 83 | content: String, 84 | priority: u16, 85 | weight: u16, 86 | port: u16, 87 | }, 88 | } 89 | 90 | /// A TSIG algorithm. 91 | pub enum TsigAlgorithm { 92 | HmacMd5, 93 | Gss, 94 | HmacSha1, 95 | HmacSha224, 96 | HmacSha256, 97 | HmacSha256_128, 98 | HmacSha384, 99 | HmacSha384_192, 100 | HmacSha512, 101 | HmacSha512_256, 102 | } 103 | 104 | /// A DNSSEC algorithm. 105 | pub enum Algorithm { 106 | RSASHA256, 107 | RSASHA512, 108 | ECDSAP256SHA256, 109 | ECDSAP384SHA384, 110 | ED25519, 111 | } 112 | 113 | pub type Result = std::result::Result; 114 | 115 | #[derive(Clone)] 116 | #[non_exhaustive] 117 | pub enum DnsUpdater { 118 | Rfc2136(Rfc2136Provider), 119 | Cloudflare(CloudflareProvider), 120 | DigitalOcean(DigitalOceanProvider), 121 | Desec(DesecProvider), 122 | Ovh(OvhProvider), 123 | Bunny(BunnyProvider), 124 | } 125 | 126 | pub trait IntoFqdn<'x> { 127 | fn into_fqdn(self) -> Cow<'x, str>; 128 | fn into_name(self) -> Cow<'x, str>; 129 | } 130 | 131 | impl DnsUpdater { 132 | /// Create a new DNS updater using the RFC 2136 protocol and TSIG authentication. 133 | pub fn new_rfc2136_tsig( 134 | addr: impl TryInto, 135 | key_name: impl AsRef, 136 | key: impl Into>, 137 | algorithm: TsigAlgorithm, 138 | ) -> crate::Result { 139 | Ok(DnsUpdater::Rfc2136(Rfc2136Provider::new_tsig( 140 | addr, 141 | key_name, 142 | key, 143 | algorithm.into(), 144 | )?)) 145 | } 146 | 147 | /// Create a new DNS updater using the RFC 2136 protocol and SIG(0) authentication. 148 | pub fn new_rfc2136_sig0( 149 | addr: impl TryInto, 150 | signer_name: impl AsRef, 151 | key: KeyPair, 152 | public_key: impl Into>, 153 | algorithm: Algorithm, 154 | ) -> crate::Result { 155 | Ok(DnsUpdater::Rfc2136(Rfc2136Provider::new_sig0( 156 | addr, 157 | signer_name, 158 | key, 159 | public_key, 160 | algorithm.into(), 161 | )?)) 162 | } 163 | 164 | /// Create a new DNS updater using the Cloudflare API. 165 | pub fn new_cloudflare( 166 | secret: impl AsRef, 167 | email: Option>, 168 | timeout: Option, 169 | ) -> crate::Result { 170 | Ok(DnsUpdater::Cloudflare(CloudflareProvider::new( 171 | secret, email, timeout, 172 | )?)) 173 | } 174 | 175 | /// Create a new DNS updater using the Cloudflare API. 176 | pub fn new_digitalocean( 177 | auth_token: impl AsRef, 178 | timeout: Option, 179 | ) -> crate::Result { 180 | Ok(DnsUpdater::DigitalOcean(DigitalOceanProvider::new( 181 | auth_token, timeout, 182 | ))) 183 | } 184 | 185 | /// Create a new DNS updater using the Desec.io API. 186 | pub fn new_desec( 187 | auth_token: impl AsRef, 188 | timeout: Option, 189 | ) -> crate::Result { 190 | Ok(DnsUpdater::Desec(DesecProvider::new(auth_token, timeout))) 191 | } 192 | 193 | /// Create a new DNS updater using the OVH API. 194 | pub fn new_ovh( 195 | application_key: impl AsRef, 196 | application_secret: impl AsRef, 197 | consumer_key: impl AsRef, 198 | endpoint: OvhEndpoint, 199 | timeout: Option, 200 | ) -> crate::Result { 201 | Ok(DnsUpdater::Ovh(OvhProvider::new( 202 | application_key, 203 | application_secret, 204 | consumer_key, 205 | endpoint, 206 | timeout, 207 | )?)) 208 | } 209 | 210 | pub fn new_bunny(api_key: impl AsRef, timeout: Option) -> crate::Result { 211 | Ok(DnsUpdater::Bunny(BunnyProvider::new(api_key, timeout)?)) 212 | } 213 | 214 | /// Create a new DNS record. 215 | pub async fn create( 216 | &self, 217 | name: impl IntoFqdn<'_>, 218 | record: DnsRecord, 219 | ttl: u32, 220 | origin: impl IntoFqdn<'_>, 221 | ) -> crate::Result<()> { 222 | match self { 223 | DnsUpdater::Rfc2136(provider) => provider.create(name, record, ttl, origin).await, 224 | DnsUpdater::Cloudflare(provider) => provider.create(name, record, ttl, origin).await, 225 | DnsUpdater::DigitalOcean(provider) => provider.create(name, record, ttl, origin).await, 226 | DnsUpdater::Desec(provider) => provider.create(name, record, ttl, origin).await, 227 | DnsUpdater::Ovh(provider) => provider.create(name, record, ttl, origin).await, 228 | DnsUpdater::Bunny(provider) => provider.create(name, record, ttl, origin).await, 229 | } 230 | } 231 | 232 | /// Update an existing DNS record. 233 | pub async fn update( 234 | &self, 235 | name: impl IntoFqdn<'_>, 236 | record: DnsRecord, 237 | ttl: u32, 238 | origin: impl IntoFqdn<'_>, 239 | ) -> crate::Result<()> { 240 | match self { 241 | DnsUpdater::Rfc2136(provider) => provider.update(name, record, ttl, origin).await, 242 | DnsUpdater::Cloudflare(provider) => provider.update(name, record, ttl, origin).await, 243 | DnsUpdater::DigitalOcean(provider) => provider.update(name, record, ttl, origin).await, 244 | DnsUpdater::Desec(provider) => provider.update(name, record, ttl, origin).await, 245 | DnsUpdater::Ovh(provider) => provider.update(name, record, ttl, origin).await, 246 | DnsUpdater::Bunny(provider) => provider.update(name, record, ttl, origin).await, 247 | } 248 | } 249 | 250 | /// Delete an existing DNS record. 251 | pub async fn delete( 252 | &self, 253 | name: impl IntoFqdn<'_>, 254 | origin: impl IntoFqdn<'_>, 255 | record: DnsRecordType, 256 | ) -> crate::Result<()> { 257 | match self { 258 | DnsUpdater::Rfc2136(provider) => provider.delete(name, origin).await, 259 | DnsUpdater::Cloudflare(provider) => provider.delete(name, origin).await, 260 | DnsUpdater::DigitalOcean(provider) => provider.delete(name, origin).await, 261 | DnsUpdater::Desec(provider) => provider.delete(name, origin, record).await, 262 | DnsUpdater::Ovh(provider) => provider.delete(name, origin, record).await, 263 | DnsUpdater::Bunny(provider) => provider.delete(name, origin, record).await, 264 | } 265 | } 266 | } 267 | 268 | impl<'x> IntoFqdn<'x> for &'x str { 269 | fn into_fqdn(self) -> Cow<'x, str> { 270 | if self.ends_with('.') { 271 | Cow::Borrowed(self) 272 | } else { 273 | Cow::Owned(format!("{}.", self)) 274 | } 275 | } 276 | 277 | fn into_name(self) -> Cow<'x, str> { 278 | if let Some(name) = self.strip_suffix('.') { 279 | Cow::Borrowed(name) 280 | } else { 281 | Cow::Borrowed(self) 282 | } 283 | } 284 | } 285 | 286 | impl<'x> IntoFqdn<'x> for &'x String { 287 | fn into_fqdn(self) -> Cow<'x, str> { 288 | self.as_str().into_fqdn() 289 | } 290 | 291 | fn into_name(self) -> Cow<'x, str> { 292 | self.as_str().into_name() 293 | } 294 | } 295 | 296 | impl<'x> IntoFqdn<'x> for String { 297 | fn into_fqdn(self) -> Cow<'x, str> { 298 | if self.ends_with('.') { 299 | Cow::Owned(self) 300 | } else { 301 | Cow::Owned(format!("{}.", self)) 302 | } 303 | } 304 | 305 | fn into_name(self) -> Cow<'x, str> { 306 | if let Some(name) = self.strip_suffix('.') { 307 | Cow::Owned(name.to_string()) 308 | } else { 309 | Cow::Owned(self) 310 | } 311 | } 312 | } 313 | 314 | pub fn strip_origin_from_name(name: &str, origin: &str) -> String { 315 | let name = name.trim_end_matches('.'); 316 | let origin = origin.trim_end_matches('.'); 317 | 318 | if name == origin { 319 | return "@".to_string(); 320 | } 321 | 322 | if name.ends_with(&format!(".{}", origin)) { 323 | name[..name.len() - origin.len() - 1].to_string() 324 | } else { 325 | name.to_string() 326 | } 327 | } 328 | 329 | impl FromStr for TsigAlgorithm { 330 | type Err = (); 331 | 332 | fn from_str(s: &str) -> std::prelude::v1::Result { 333 | match s { 334 | "hmac-md5" => Ok(TsigAlgorithm::HmacMd5), 335 | "gss" => Ok(TsigAlgorithm::Gss), 336 | "hmac-sha1" => Ok(TsigAlgorithm::HmacSha1), 337 | "hmac-sha224" => Ok(TsigAlgorithm::HmacSha224), 338 | "hmac-sha256" => Ok(TsigAlgorithm::HmacSha256), 339 | "hmac-sha256-128" => Ok(TsigAlgorithm::HmacSha256_128), 340 | "hmac-sha384" => Ok(TsigAlgorithm::HmacSha384), 341 | "hmac-sha384-192" => Ok(TsigAlgorithm::HmacSha384_192), 342 | "hmac-sha512" => Ok(TsigAlgorithm::HmacSha512), 343 | "hmac-sha512-256" => Ok(TsigAlgorithm::HmacSha512_256), 344 | _ => Err(()), 345 | } 346 | } 347 | } 348 | 349 | impl Display for Error { 350 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 351 | match self { 352 | Error::Protocol(e) => write!(f, "Protocol error: {}", e), 353 | Error::Parse(e) => write!(f, "Parse error: {}", e), 354 | Error::Client(e) => write!(f, "Client error: {}", e), 355 | Error::Response(e) => write!(f, "Response error: {}", e), 356 | Error::Api(e) => write!(f, "API error: {}", e), 357 | Error::Serialize(e) => write!(f, "Serialize error: {}", e), 358 | Error::Unauthorized => write!(f, "Unauthorized"), 359 | Error::NotFound => write!(f, "Not found"), 360 | Error::BadRequest => write!(f, "Bad request"), 361 | } 362 | } 363 | } 364 | 365 | impl Display for DnsRecordType { 366 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 367 | write!(f, "{:?}", self) 368 | } 369 | } 370 | -------------------------------------------------------------------------------- /src/providers/ovh.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 or the MIT license 7 | * , at your 8 | * option. This file may not be copied, modified, or distributed 9 | * except according to those terms. 10 | */ 11 | 12 | use crate::{strip_origin_from_name, DnsRecord, Error, IntoFqdn}; 13 | use reqwest::Method; 14 | use serde::Serialize; 15 | use sha1::{Digest, Sha1}; 16 | use std::time::{Duration, SystemTime, UNIX_EPOCH}; 17 | 18 | #[derive(Clone)] 19 | pub struct OvhProvider { 20 | application_key: String, 21 | application_secret: String, 22 | consumer_key: String, 23 | pub(crate) endpoint: String, 24 | timeout: Duration, 25 | } 26 | 27 | #[derive(Serialize, Debug)] 28 | pub struct CreateDnsRecordParams { 29 | #[serde(rename = "fieldType")] 30 | pub field_type: String, 31 | #[serde(rename = "subDomain")] 32 | pub sub_domain: String, 33 | pub target: String, 34 | pub ttl: u32, 35 | } 36 | 37 | #[derive(Serialize, Debug)] 38 | pub struct UpdateDnsRecordParams { 39 | pub target: String, 40 | pub ttl: u32, 41 | } 42 | 43 | #[derive(Debug)] 44 | pub struct OvhRecordFormat { 45 | pub field_type: String, 46 | pub target: String, 47 | } 48 | 49 | #[derive(Debug)] 50 | pub enum OvhEndpoint { 51 | OvhEu, 52 | OvhCa, 53 | KimsufiEu, 54 | KimsufiCa, 55 | SoyoustartEu, 56 | SoyoustartCa, 57 | } 58 | 59 | impl OvhEndpoint { 60 | fn api_url(&self) -> &'static str { 61 | match self { 62 | OvhEndpoint::OvhEu => "https://eu.api.ovh.com/1.0", 63 | OvhEndpoint::OvhCa => "https://ca.api.ovh.com/1.0", 64 | OvhEndpoint::KimsufiEu => "https://eu.api.kimsufi.com/1.0", 65 | OvhEndpoint::KimsufiCa => "https://ca.api.kimsufi.com/1.0", 66 | OvhEndpoint::SoyoustartEu => "https://eu.api.soyoustart.com/1.0", 67 | OvhEndpoint::SoyoustartCa => "https://ca.api.soyoustart.com/1.0", 68 | } 69 | } 70 | } 71 | 72 | impl std::str::FromStr for OvhEndpoint { 73 | type Err = Error; 74 | 75 | fn from_str(s: &str) -> Result { 76 | match s { 77 | "ovh-eu" => Ok(OvhEndpoint::OvhEu), 78 | "ovh-ca" => Ok(OvhEndpoint::OvhCa), 79 | "kimsufi-eu" => Ok(OvhEndpoint::KimsufiEu), 80 | "kimsufi-ca" => Ok(OvhEndpoint::KimsufiCa), 81 | "soyoustart-eu" => Ok(OvhEndpoint::SoyoustartEu), 82 | "soyoustart-ca" => Ok(OvhEndpoint::SoyoustartCa), 83 | _ => Err(Error::Parse(format!("Invalid OVH endpoint: {}", s))), 84 | } 85 | } 86 | } 87 | 88 | impl From<&DnsRecord> for OvhRecordFormat { 89 | fn from(record: &DnsRecord) -> Self { 90 | match record { 91 | DnsRecord::A { content } => OvhRecordFormat { 92 | field_type: "A".to_string(), 93 | target: content.to_string(), 94 | }, 95 | DnsRecord::AAAA { content } => OvhRecordFormat { 96 | field_type: "AAAA".to_string(), 97 | target: content.to_string(), 98 | }, 99 | DnsRecord::CNAME { content } => OvhRecordFormat { 100 | field_type: "CNAME".to_string(), 101 | target: content.clone(), 102 | }, 103 | DnsRecord::NS { content } => OvhRecordFormat { 104 | field_type: "NS".to_string(), 105 | target: content.clone(), 106 | }, 107 | DnsRecord::MX { content, priority } => OvhRecordFormat { 108 | field_type: "MX".to_string(), 109 | target: format!("{} {}", priority, content), 110 | }, 111 | DnsRecord::TXT { content } => OvhRecordFormat { 112 | field_type: "TXT".to_string(), 113 | target: content.clone(), 114 | }, 115 | DnsRecord::SRV { 116 | content, 117 | priority, 118 | weight, 119 | port, 120 | } => OvhRecordFormat { 121 | field_type: "SRV".to_string(), 122 | target: format!("{} {} {} {}", priority, weight, port, content), 123 | }, 124 | } 125 | } 126 | } 127 | 128 | impl OvhProvider { 129 | pub(crate) fn new( 130 | application_key: impl AsRef, 131 | application_secret: impl AsRef, 132 | consumer_key: impl AsRef, 133 | endpoint: OvhEndpoint, 134 | timeout: Option, 135 | ) -> crate::Result { 136 | Ok(Self { 137 | application_key: application_key.as_ref().to_string(), 138 | application_secret: application_secret.as_ref().to_string(), 139 | consumer_key: consumer_key.as_ref().to_string(), 140 | endpoint: endpoint.api_url().to_string(), 141 | timeout: timeout.unwrap_or(Duration::from_secs(30)), 142 | }) 143 | } 144 | 145 | fn generate_signature(&self, method: &str, url: &str, body: &str, timestamp: u64) -> String { 146 | let data = format!( 147 | "{}+{}+{}+{}+{}+{}", 148 | self.application_secret, self.consumer_key, method, url, body, timestamp 149 | ); 150 | 151 | let mut hasher = Sha1::new(); 152 | hasher.update(data.as_bytes()); 153 | let hash = hasher.finalize(); 154 | let hex_string = hash 155 | .iter() 156 | .map(|b| format!("{:02x}", b)) 157 | .collect::(); 158 | format!("$1${}", hex_string) 159 | } 160 | 161 | async fn send_authenticated_request( 162 | &self, 163 | method: Method, 164 | url: &str, 165 | body: &str, 166 | ) -> crate::Result { 167 | let timestamp = SystemTime::now() 168 | .duration_since(UNIX_EPOCH) 169 | .map_err(|e| Error::Client(format!("Failed to get timestamp: {}", e)))? 170 | .as_secs(); 171 | 172 | let signature = self.generate_signature(method.as_str(), url, body, timestamp); 173 | 174 | let client = reqwest::Client::builder() 175 | .timeout(self.timeout) 176 | .build() 177 | .map_err(|e| Error::Client(format!("Failed to create HTTP client: {}", e)))?; 178 | let mut request = client 179 | .request(method, url) 180 | .header("X-Ovh-Application", &self.application_key) 181 | .header("X-Ovh-Consumer", &self.consumer_key) 182 | .header("X-Ovh-Signature", signature) 183 | .header("X-Ovh-Timestamp", timestamp.to_string()) 184 | .header("Content-Type", "application/json"); 185 | 186 | if !body.is_empty() { 187 | request = request.body(body.to_string()); 188 | } 189 | 190 | request 191 | .send() 192 | .await 193 | .map_err(|e| Error::Api(format!("Failed to send request: {}", e))) 194 | } 195 | 196 | async fn get_zone_name(&self, origin: impl IntoFqdn<'_>) -> crate::Result { 197 | let domain = origin.into_name(); 198 | let domain_name = domain.trim_end_matches('.'); 199 | 200 | let url = format!("{}/domain/zone/{}", self.endpoint, domain_name); 201 | let response = self 202 | .send_authenticated_request(Method::GET, &url, "") 203 | .await?; 204 | 205 | if response.status().is_success() { 206 | Ok(domain_name.to_string()) 207 | } else { 208 | Err(Error::Api(format!( 209 | "Zone {} not found or not accessible", 210 | domain_name 211 | ))) 212 | } 213 | } 214 | 215 | async fn get_record_id( 216 | &self, 217 | zone: &str, 218 | name: impl IntoFqdn<'_>, 219 | record_type: &str, 220 | ) -> crate::Result { 221 | let name = name.into_name(); 222 | let subdomain = strip_origin_from_name(&name, zone); 223 | let subdomain = if subdomain == "@" { "" } else { &subdomain }; 224 | 225 | let url = format!( 226 | "{}/domain/zone/{}/record?fieldType={}&subDomain={}", 227 | self.endpoint, zone, record_type, subdomain 228 | ); 229 | 230 | let response = self 231 | .send_authenticated_request(Method::GET, &url, "") 232 | .await?; 233 | 234 | if !response.status().is_success() { 235 | return Err(Error::Api(format!( 236 | "Failed to list records: HTTP {}", 237 | response.status() 238 | ))); 239 | } 240 | 241 | let record_ids: Vec = serde_json::from_slice( 242 | response 243 | .bytes() 244 | .await 245 | .map_err(|e| Error::Api(format!("Failed to fetch record list: {}", e)))? 246 | .as_ref(), 247 | ) 248 | .map_err(|e| Error::Api(format!("Failed to parse record list: {}", e)))?; 249 | 250 | record_ids.into_iter().next().ok_or(Error::NotFound) 251 | } 252 | 253 | pub(crate) async fn create( 254 | &self, 255 | name: impl IntoFqdn<'_>, 256 | record: DnsRecord, 257 | ttl: u32, 258 | origin: impl IntoFqdn<'_>, 259 | ) -> crate::Result<()> { 260 | let zone = self.get_zone_name(origin).await?; 261 | let name = name.into_name(); 262 | let subdomain = strip_origin_from_name(&name, &zone); 263 | let subdomain = if subdomain == "@" { 264 | String::new() 265 | } else { 266 | subdomain 267 | }; 268 | 269 | let ovh_record: OvhRecordFormat = (&record).into(); 270 | let (field_type, target) = (ovh_record.field_type, ovh_record.target); 271 | 272 | let params = CreateDnsRecordParams { 273 | field_type, 274 | sub_domain: subdomain, 275 | target, 276 | ttl, 277 | }; 278 | 279 | let body = serde_json::to_string(¶ms) 280 | .map_err(|e| Error::Serialize(format!("Failed to serialize record: {}", e)))?; 281 | 282 | let url = format!("{}/domain/zone/{}/record", self.endpoint, zone); 283 | let response = self 284 | .send_authenticated_request(Method::POST, &url, &body) 285 | .await?; 286 | 287 | if !response.status().is_success() { 288 | let status = response.status(); 289 | let error_text = response 290 | .text() 291 | .await 292 | .unwrap_or_else(|_| "Unknown error".to_string()); 293 | return Err(Error::Api(format!( 294 | "Failed to create record: HTTP {} - {}", 295 | status, error_text 296 | ))); 297 | } 298 | 299 | let url = format!("{}/domain/zone/{}/refresh", self.endpoint, zone); 300 | let _response = self 301 | .send_authenticated_request(Method::POST, &url, "") 302 | .await 303 | .map_err(|e| { 304 | Error::Api(format!( 305 | "Failed to refresh zone (record created but zone not refreshed): {:?}", 306 | e 307 | )) 308 | })?; 309 | 310 | Ok(()) 311 | } 312 | 313 | pub(crate) async fn update( 314 | &self, 315 | name: impl IntoFqdn<'_>, 316 | record: DnsRecord, 317 | ttl: u32, 318 | origin: impl IntoFqdn<'_>, 319 | ) -> crate::Result<()> { 320 | let zone = self.get_zone_name(origin).await?; 321 | let name = name.into_name(); 322 | 323 | let ovh_record: OvhRecordFormat = (&record).into(); 324 | let (field_type, target) = (ovh_record.field_type, ovh_record.target); 325 | 326 | let record_id = self 327 | .get_record_id(&zone, name.as_ref(), &field_type) 328 | .await?; 329 | 330 | let params = UpdateDnsRecordParams { target, ttl }; 331 | 332 | let body = serde_json::to_string(¶ms) 333 | .map_err(|e| Error::Serialize(format!("Failed to serialize record: {}", e)))?; 334 | 335 | let url = format!( 336 | "{}/domain/zone/{}/record/{}", 337 | self.endpoint, zone, record_id 338 | ); 339 | let response = self 340 | .send_authenticated_request(Method::PUT, &url, &body) 341 | .await?; 342 | 343 | if !response.status().is_success() { 344 | let status = response.status(); 345 | let error_text = response 346 | .text() 347 | .await 348 | .unwrap_or_else(|_| "Unknown error".to_string()); 349 | return Err(Error::Api(format!( 350 | "Failed to update record: HTTP {} - {}", 351 | status, error_text 352 | ))); 353 | } 354 | 355 | let url = format!("{}/domain/zone/{}/refresh", self.endpoint, zone); 356 | let _response = self 357 | .send_authenticated_request(Method::POST, &url, "") 358 | .await 359 | .map_err(|e| { 360 | Error::Api(format!( 361 | "Failed to refresh zone (record updated but zone not refreshed): {:?}", 362 | e 363 | )) 364 | })?; 365 | 366 | Ok(()) 367 | } 368 | 369 | pub(crate) async fn delete( 370 | &self, 371 | name: impl IntoFqdn<'_>, 372 | origin: impl IntoFqdn<'_>, 373 | record_type: crate::DnsRecordType, 374 | ) -> crate::Result<()> { 375 | let zone = self.get_zone_name(origin).await?; 376 | let record_id = self 377 | .get_record_id(&zone, name, &record_type.to_string()) 378 | .await?; 379 | 380 | let url = format!( 381 | "{}/domain/zone/{}/record/{}", 382 | self.endpoint, zone, record_id 383 | ); 384 | let response = self 385 | .send_authenticated_request(Method::DELETE, &url, "") 386 | .await?; 387 | 388 | if !response.status().is_success() { 389 | let status = response.status(); 390 | let error_text = response 391 | .text() 392 | .await 393 | .unwrap_or_else(|_| "Unknown error".to_string()); 394 | return Err(Error::Api(format!( 395 | "Failed to delete record: HTTP {} - {}", 396 | status, error_text 397 | ))); 398 | } 399 | 400 | let url = format!("{}/domain/zone/{}/refresh", self.endpoint, zone); 401 | let _response = self 402 | .send_authenticated_request(Method::POST, &url, "") 403 | .await 404 | .map_err(|e| { 405 | Error::Api(format!( 406 | "Failed to refresh zone (record deleted but zone not refreshed): {:?}", 407 | e 408 | )) 409 | })?; 410 | 411 | Ok(()) 412 | } 413 | } 414 | -------------------------------------------------------------------------------- /src/tests/ovh_tests.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 or the MIT license 7 | * , at your 8 | * option. This file may not be copied, modified, or distributed 9 | * except according to those terms. 10 | */ 11 | 12 | #[cfg(test)] 13 | mod tests { 14 | use crate::{ 15 | providers::ovh::{OvhEndpoint, OvhProvider, OvhRecordFormat}, 16 | DnsRecord, DnsRecordType, DnsUpdater, Error, 17 | }; 18 | use serde_json::json; 19 | use std::time::Duration; 20 | 21 | fn setup_provider() -> OvhProvider { 22 | OvhProvider::new( 23 | "test_app_key", 24 | "test_app_secret", 25 | "test_consumer_key", 26 | OvhEndpoint::OvhEu, 27 | Some(Duration::from_secs(1)), 28 | ) 29 | .unwrap() 30 | } 31 | 32 | #[test] 33 | fn test_ovh_endpoint_parsing() { 34 | assert!(matches!( 35 | "ovh-eu".parse::().unwrap(), 36 | OvhEndpoint::OvhEu 37 | )); 38 | assert!(matches!( 39 | "ovh-ca".parse::().unwrap(), 40 | OvhEndpoint::OvhCa 41 | )); 42 | assert!(matches!( 43 | "kimsufi-eu".parse::().unwrap(), 44 | OvhEndpoint::KimsufiEu 45 | )); 46 | assert!(matches!( 47 | "kimsufi-ca".parse::().unwrap(), 48 | OvhEndpoint::KimsufiCa 49 | )); 50 | assert!(matches!( 51 | "soyoustart-eu".parse::().unwrap(), 52 | OvhEndpoint::SoyoustartEu 53 | )); 54 | assert!(matches!( 55 | "soyoustart-ca".parse::().unwrap(), 56 | OvhEndpoint::SoyoustartCa 57 | )); 58 | 59 | assert!("invalid-endpoint".parse::().is_err()); 60 | } 61 | 62 | #[test] 63 | fn test_ovh_provider_creation() { 64 | let provider = OvhProvider::new( 65 | "test_app_key", 66 | "test_app_secret", 67 | "test_consumer_key", 68 | OvhEndpoint::OvhEu, 69 | Some(Duration::from_secs(30)), 70 | ); 71 | 72 | assert!(provider.is_ok()); 73 | } 74 | 75 | #[test] 76 | fn test_dns_updater_ovh_creation() { 77 | let updater = DnsUpdater::new_ovh( 78 | "test_app_key", 79 | "test_app_secret", 80 | "test_consumer_key", 81 | OvhEndpoint::OvhEu, 82 | Some(Duration::from_secs(30)), 83 | ); 84 | 85 | assert!(updater.is_ok()); 86 | 87 | match updater.unwrap() { 88 | DnsUpdater::Ovh(_) => (), 89 | _ => panic!("Expected OVH provider"), 90 | } 91 | } 92 | 93 | #[test] 94 | fn test_ovh_record_format_from_dns_record() { 95 | let record = DnsRecord::A { 96 | content: "1.1.1.1".parse().unwrap(), 97 | }; 98 | let ovh_record: OvhRecordFormat = (&record).into(); 99 | assert_eq!(ovh_record.field_type, "A"); 100 | assert_eq!(ovh_record.target, "1.1.1.1"); 101 | 102 | let record = DnsRecord::AAAA { 103 | content: "2001:db8::1".parse().unwrap(), 104 | }; 105 | let ovh_record: OvhRecordFormat = (&record).into(); 106 | assert_eq!(ovh_record.field_type, "AAAA"); 107 | assert_eq!(ovh_record.target, "2001:db8::1"); 108 | 109 | let record = DnsRecord::CNAME { 110 | content: "alias.example.com".to_string(), 111 | }; 112 | let ovh_record: OvhRecordFormat = (&record).into(); 113 | assert_eq!(ovh_record.field_type, "CNAME"); 114 | assert_eq!(ovh_record.target, "alias.example.com"); 115 | 116 | let record = DnsRecord::MX { 117 | priority: 10, 118 | content: "mail.example.com".to_string(), 119 | }; 120 | let ovh_record: OvhRecordFormat = (&record).into(); 121 | assert_eq!(ovh_record.field_type, "MX"); 122 | assert_eq!(ovh_record.target, "10 mail.example.com"); 123 | 124 | let record = DnsRecord::TXT { 125 | content: "v=spf1 include:_spf.example.com ~all".to_string(), 126 | }; 127 | let ovh_record: OvhRecordFormat = (&record).into(); 128 | assert_eq!(ovh_record.field_type, "TXT"); 129 | assert_eq!(ovh_record.target, "v=spf1 include:_spf.example.com ~all"); 130 | 131 | let record = DnsRecord::SRV { 132 | priority: 10, 133 | weight: 20, 134 | port: 443, 135 | content: "sip.example.com".to_string(), 136 | }; 137 | let ovh_record: OvhRecordFormat = (&record).into(); 138 | assert_eq!(ovh_record.field_type, "SRV"); 139 | assert_eq!(ovh_record.target, "10 20 443 sip.example.com"); 140 | 141 | let record = DnsRecord::NS { 142 | content: "ns1.example.com".to_string(), 143 | }; 144 | let ovh_record: OvhRecordFormat = (&record).into(); 145 | assert_eq!(ovh_record.field_type, "NS"); 146 | assert_eq!(ovh_record.target, "ns1.example.com"); 147 | } 148 | 149 | #[tokio::test] 150 | async fn test_create_record_success() { 151 | let mut server = mockito::Server::new_async().await; 152 | 153 | let zone_mock = server 154 | .mock("GET", "/domain/zone/example.com") 155 | .with_status(200) 156 | .match_header("x-ovh-application", "test_app_key") 157 | .match_header("x-ovh-consumer", "test_consumer_key") 158 | .with_body(r#"{"name": "example.com"}"#) 159 | .create(); 160 | 161 | let create_mock = server 162 | .mock("POST", "/domain/zone/example.com/record") 163 | .with_status(200) 164 | .match_header("x-ovh-application", "test_app_key") 165 | .match_header("x-ovh-consumer", "test_consumer_key") 166 | .match_header("content-type", "application/json") 167 | .match_body(mockito::Matcher::Json(json!({ 168 | "fieldType": "A", 169 | "subDomain": "test", 170 | "target": "1.1.1.1", 171 | "ttl": 3600 172 | }))) 173 | .with_body(r#"{"id": 123456789}"#) 174 | .create(); 175 | 176 | let refresh_mock = server 177 | .mock("POST", "/domain/zone/example.com/refresh") 178 | .with_status(200) 179 | .match_header("x-ovh-application", "test_app_key") 180 | .match_header("x-ovh-consumer", "test_consumer_key") 181 | .with_body("") 182 | .create(); 183 | 184 | let mut provider = setup_provider(); 185 | provider.endpoint = server.url(); 186 | 187 | let result = provider 188 | .create( 189 | "test.example.com", 190 | DnsRecord::A { 191 | content: "1.1.1.1".parse().unwrap(), 192 | }, 193 | 3600, 194 | "example.com", 195 | ) 196 | .await; 197 | 198 | assert!(result.is_ok()); 199 | zone_mock.assert(); 200 | create_mock.assert(); 201 | refresh_mock.assert(); 202 | } 203 | 204 | #[tokio::test] 205 | async fn test_update_record_success() { 206 | let mut server = mockito::Server::new_async().await; 207 | 208 | let zone_mock = server 209 | .mock("GET", "/domain/zone/example.com") 210 | .with_status(200) 211 | .match_header("x-ovh-application", "test_app_key") 212 | .match_header("x-ovh-consumer", "test_consumer_key") 213 | .with_body(r#"{"name": "example.com"}"#) 214 | .create(); 215 | 216 | let lookup_mock = server 217 | .mock( 218 | "GET", 219 | "/domain/zone/example.com/record?fieldType=A&subDomain=test", 220 | ) 221 | .with_status(200) 222 | .match_header("x-ovh-application", "test_app_key") 223 | .match_header("x-ovh-consumer", "test_consumer_key") 224 | .with_body(r#"[123456789]"#) 225 | .create(); 226 | 227 | let update_mock = server 228 | .mock("PUT", "/domain/zone/example.com/record/123456789") 229 | .with_status(200) 230 | .match_header("x-ovh-application", "test_app_key") 231 | .match_header("x-ovh-consumer", "test_consumer_key") 232 | .match_header("content-type", "application/json") 233 | .match_body(mockito::Matcher::Json(json!({ 234 | "target": "2.2.2.2", 235 | "ttl": 3600 236 | }))) 237 | .with_body("") 238 | .create(); 239 | 240 | let refresh_mock = server 241 | .mock("POST", "/domain/zone/example.com/refresh") 242 | .with_status(200) 243 | .match_header("x-ovh-application", "test_app_key") 244 | .match_header("x-ovh-consumer", "test_consumer_key") 245 | .with_body("") 246 | .create(); 247 | 248 | let mut provider = setup_provider(); 249 | provider.endpoint = server.url(); 250 | 251 | let result = provider 252 | .update( 253 | "test.example.com", 254 | DnsRecord::A { 255 | content: "2.2.2.2".parse().unwrap(), 256 | }, 257 | 3600, 258 | "example.com", 259 | ) 260 | .await; 261 | 262 | assert!(result.is_ok()); 263 | zone_mock.assert(); 264 | lookup_mock.assert(); 265 | update_mock.assert(); 266 | refresh_mock.assert(); 267 | } 268 | 269 | #[tokio::test] 270 | async fn test_delete_record_success() { 271 | let mut server = mockito::Server::new_async().await; 272 | 273 | let zone_mock = server 274 | .mock("GET", "/domain/zone/example.com") 275 | .with_status(200) 276 | .match_header("x-ovh-application", "test_app_key") 277 | .match_header("x-ovh-consumer", "test_consumer_key") 278 | .with_body(r#"{"name": "example.com"}"#) 279 | .create(); 280 | 281 | let lookup_mock = server 282 | .mock( 283 | "GET", 284 | "/domain/zone/example.com/record?fieldType=TXT&subDomain=test", 285 | ) 286 | .with_status(200) 287 | .match_header("x-ovh-application", "test_app_key") 288 | .match_header("x-ovh-consumer", "test_consumer_key") 289 | .with_body(r#"[123456789]"#) 290 | .create(); 291 | 292 | let delete_mock = server 293 | .mock("DELETE", "/domain/zone/example.com/record/123456789") 294 | .with_status(200) 295 | .match_header("x-ovh-application", "test_app_key") 296 | .match_header("x-ovh-consumer", "test_consumer_key") 297 | .with_body("") 298 | .create(); 299 | 300 | let refresh_mock = server 301 | .mock("POST", "/domain/zone/example.com/refresh") 302 | .with_status(200) 303 | .match_header("x-ovh-application", "test_app_key") 304 | .match_header("x-ovh-consumer", "test_consumer_key") 305 | .with_body("") 306 | .create(); 307 | 308 | let mut provider = setup_provider(); 309 | provider.endpoint = server.url(); 310 | 311 | let result = provider 312 | .delete("test.example.com", "example.com", DnsRecordType::TXT) 313 | .await; 314 | 315 | assert!(result.is_ok()); 316 | zone_mock.assert(); 317 | lookup_mock.assert(); 318 | delete_mock.assert(); 319 | refresh_mock.assert(); 320 | } 321 | 322 | #[tokio::test] 323 | async fn test_create_record_unauthorized() { 324 | let mut server = mockito::Server::new_async().await; 325 | 326 | let zone_mock = server 327 | .mock("GET", "/domain/zone/example.com") 328 | .with_status(401) 329 | .match_header("x-ovh-application", "test_app_key") 330 | .match_header("x-ovh-consumer", "test_consumer_key") 331 | .with_body(r#"{"message": "Invalid credentials"}"#) 332 | .create(); 333 | 334 | let mut provider = setup_provider(); 335 | provider.endpoint = server.url(); 336 | 337 | let result = provider 338 | .create( 339 | "test.example.com", 340 | DnsRecord::A { 341 | content: "1.1.1.1".parse().unwrap(), 342 | }, 343 | 3600, 344 | "example.com", 345 | ) 346 | .await; 347 | 348 | assert!(matches!(result, Err(Error::Api(_)))); 349 | zone_mock.assert(); 350 | } 351 | 352 | #[tokio::test] 353 | async fn test_record_not_found() { 354 | let mut server = mockito::Server::new_async().await; 355 | 356 | let zone_mock = server 357 | .mock("GET", "/domain/zone/example.com") 358 | .with_status(200) 359 | .match_header("x-ovh-application", "test_app_key") 360 | .match_header("x-ovh-consumer", "test_consumer_key") 361 | .with_body(r#"{"name": "example.com"}"#) 362 | .create(); 363 | 364 | let lookup_mock = server 365 | .mock( 366 | "GET", 367 | "/domain/zone/example.com/record?fieldType=A&subDomain=nonexistent", 368 | ) 369 | .with_status(200) 370 | .match_header("x-ovh-application", "test_app_key") 371 | .match_header("x-ovh-consumer", "test_consumer_key") 372 | .with_body(r#"[]"#) 373 | .create(); 374 | 375 | let mut provider = setup_provider(); 376 | provider.endpoint = server.url(); 377 | 378 | let result = provider 379 | .update( 380 | "nonexistent.example.com", 381 | DnsRecord::A { 382 | content: "1.1.1.1".parse().unwrap(), 383 | }, 384 | 3600, 385 | "example.com", 386 | ) 387 | .await; 388 | 389 | assert!(matches!(result, Err(Error::NotFound))); 390 | zone_mock.assert(); 391 | lookup_mock.assert(); 392 | } 393 | 394 | #[tokio::test] 395 | #[ignore = "Requires OVH API credentials and domain configuration"] 396 | async fn integration_test() { 397 | let app_key = std::env::var("OVH_APP_KEY").unwrap_or_default(); 398 | let app_secret = std::env::var("OVH_APP_SECRET").unwrap_or_default(); 399 | let consumer_key = std::env::var("OVH_CONSUMER_KEY").unwrap_or_default(); 400 | let endpoint = std::env::var("OVH_ENDPOINT").unwrap_or_default(); 401 | let origin = std::env::var("OVH_ORIGIN").unwrap_or_default(); 402 | let domain = std::env::var("OVH_DOMAIN").unwrap_or_default(); 403 | 404 | assert!( 405 | !app_key.is_empty(), 406 | "Please configure your OVH application key in the integration test" 407 | ); 408 | assert!( 409 | !app_secret.is_empty(), 410 | "Please configure your OVH application secret in the integration test" 411 | ); 412 | assert!( 413 | !consumer_key.is_empty(), 414 | "Please configure your OVH consumer key in the integration test" 415 | ); 416 | assert!( 417 | !endpoint.is_empty(), 418 | "Please configure your endpoint in the integration test" 419 | ); 420 | assert!( 421 | !origin.is_empty(), 422 | "Please configure your domain in the integration test" 423 | ); 424 | assert!( 425 | !domain.is_empty(), 426 | "Please configure your test subdomain in the integration test" 427 | ); 428 | 429 | let updater = DnsUpdater::new_ovh( 430 | app_key, 431 | app_secret, 432 | consumer_key, 433 | endpoint.parse().unwrap(), 434 | Some(Duration::from_secs(30)), 435 | ) 436 | .unwrap(); 437 | 438 | let creation_result = updater 439 | .create( 440 | &domain, 441 | DnsRecord::A { 442 | content: "1.1.1.1".parse().unwrap(), 443 | }, 444 | 3600, 445 | &origin, 446 | ) 447 | .await; 448 | 449 | assert!(creation_result.is_ok()); 450 | 451 | let update_result = updater 452 | .update( 453 | &domain, 454 | DnsRecord::A { 455 | content: "2.2.2.2".parse().unwrap(), 456 | }, 457 | 3600, 458 | &origin, 459 | ) 460 | .await; 461 | 462 | assert!(update_result.is_ok()); 463 | 464 | let deletion_result = updater.delete(domain, origin, DnsRecordType::A).await; 465 | 466 | assert!(deletion_result.is_ok()); 467 | } 468 | } 469 | --------------------------------------------------------------------------------