#![doc = include_str!("../README.md")]
pub mod errors;
mod http;
#[cfg(feature = "async")]
pub mod async_impl;
#[cfg(feature = "bunny")]
pub mod bunny;
#[cfg(feature = "cloudflare")]
pub mod cloudflare;
#[cfg(feature = "desec")]
pub mod desec;
#[cfg(feature = "digitalocean")]
pub mod digitalocean;
#[cfg(feature = "dnsimple")]
pub mod dnsimple;
#[cfg(feature = "dnsmadeeasy")]
pub mod dnsmadeeasy;
#[cfg(feature = "gandi")]
pub mod gandi;
#[cfg(feature = "linode")]
pub mod linode;
#[cfg(feature = "porkbun")]
pub mod porkbun;
use std::{fmt::{self, Debug, Display, Formatter}, net::Ipv4Addr};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use tracing::warn;
use crate::errors::Result;
pub struct Config {
pub domain: String,
pub dry_run: bool,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "lowercase", tag = "name")]
#[non_exhaustive]
pub enum Provider {
Bunny(bunny::Auth),
Cloudflare(cloudflare::Auth),
DeSec(desec::Auth),
DigitalOcean(digitalocean::Auth),
DnsMadeEasy(dnsmadeeasy::Auth),
Dnsimple(dnsimple::Auth),
Gandi(gandi::Auth),
Linode(linode::Auth),
PorkBun(porkbun::Auth),
}
impl Provider {
pub fn blocking_impl(&self, dns_conf: Config) -> Box<dyn DnsProvider> {
match self {
#[cfg(feature = "bunny")]
Provider::Bunny(auth) => Box::new(bunny::Bunny::new(dns_conf, auth.clone())),
#[cfg(feature = "cloudflare")]
Provider::Cloudflare(auth) => Box::new(cloudflare::Cloudflare::new(dns_conf, auth.clone())),
#[cfg(feature = "desec")]
Provider::DeSec(auth) => Box::new(desec::DeSec::new(dns_conf, auth.clone())),
#[cfg(feature = "digitalocean")]
Provider::DigitalOcean(auth) => Box::new(digitalocean::DigitalOcean::new(dns_conf, auth.clone())),
#[cfg(feature = "gandi")]
Provider::Gandi(auth) => Box::new(gandi::Gandi::new(dns_conf, auth.clone())),
#[cfg(feature = "dnsimple")]
Provider::Dnsimple(auth) => Box::new(dnsimple::Dnsimple::new(dns_conf, auth.clone(), None)),
#[cfg(feature = "dnsmadeeasy")]
Provider::DnsMadeEasy(auth) => Box::new(dnsmadeeasy::DnsMadeEasy::new(dns_conf, auth.clone())),
#[cfg(feature = "porkbun")]
Provider::PorkBun(auth) => Box::new(porkbun::Porkbun::new(dns_conf, auth.clone())),
#[cfg(feature = "linode")]
Provider::Linode(auth) => Box::new(linode::Linode::new(dns_conf, auth.clone())),
}
}
#[cfg(feature = "async")]
pub fn async_impl(&self, dns_conf: Config) -> Box<dyn async_impl::AsyncDnsProvider> {
match self {
#[cfg(feature = "bunny")]
Provider::Bunny(auth) => Box::new(async_impl::bunny::Bunny::new(dns_conf, auth.clone())),
#[cfg(feature = "cloudflare")]
Provider::Cloudflare(auth) => Box::new(async_impl::cloudflare::Cloudflare::new(dns_conf, auth.clone())),
#[cfg(feature = "desec")]
Provider::DeSec(auth) => Box::new(async_impl::desec::DeSec::new(dns_conf, auth.clone())),
#[cfg(feature = "digitalocean")]
Provider::DigitalOcean(auth) => Box::new(async_impl::digitalocean::DigitalOcean::new(dns_conf, auth.clone())),
#[cfg(feature = "gandi")]
Provider::Gandi(auth) => Box::new(async_impl::gandi::Gandi::new(dns_conf, auth.clone())),
#[cfg(feature = "dnsimple")]
Provider::Dnsimple(auth) => Box::new(async_impl::dnsimple::Dnsimple::new(dns_conf, auth.clone(), None)),
#[cfg(feature = "dnsmadeeasy")]
Provider::DnsMadeEasy(auth) => Box::new(async_impl::dnsmadeeasy::DnsMadeEasy::new(dns_conf, auth.clone())),
#[cfg(feature = "porkbun")]
Provider::PorkBun(auth) => Box::new(async_impl::porkbun::Porkbun::new(dns_conf, auth.clone())),
#[cfg(feature = "linode")]
Provider::Linode(auth) => Box::new(async_impl::linode::Linode::new(dns_conf, auth.clone())),
}
}
}
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq)]
#[non_exhaustive]
pub enum RecordType {
A,
AAAA,
CAA,
CNAME,
MX,
NS,
PTR,
SRV,
TXT,
SVCB,
HTTPS,
}
impl Display for RecordType {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(f, "{:?}", self)
}
}
pub trait DnsProvider {
fn get_record<T>(&self, rtype: RecordType, host: &str) -> Result<Option<T>>
where T: DeserializeOwned,
Self: Sized;
fn create_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
where T: Serialize + DeserializeOwned + Display + Clone,
Self: Sized;
fn update_record<T>(&self, rtype: RecordType, host: &str, record: &T) -> Result<()>
where T: Serialize + DeserializeOwned + Display + Clone,
Self: Sized;
fn delete_record(&self, rtype: RecordType, host: &str) -> Result<()>
where Self: Sized;
fn get_txt_record(&self, host: &str) -> Result<Option<String>>;
fn create_txt_record(&self, host: &str, record: &String) -> Result<()>;
fn update_txt_record(&self, host: &str, record: &String) -> Result<()>;
fn delete_txt_record(&self, host: &str) -> Result<()>;
fn get_a_record(&self, host: &str) -> Result<Option<Ipv4Addr>>;
fn create_a_record(&self, host: &str, record: &Ipv4Addr) -> Result<()>;
fn update_a_record(&self, host: &str, record: &Ipv4Addr) -> Result<()>;
fn delete_a_record(&self, host: &str) -> Result<()>;
}
#[macro_export]
macro_rules! generate_helpers {
() => {
fn get_txt_record(&self, host: &str) -> Result<Option<String>> {
self.get_record::<String>(RecordType::TXT, host)
.map(|opt| opt.map(|s| crate::strip_quotes(&s)))
}
fn create_txt_record(&self, host: &str, record: &String) -> Result<()> {
self.create_record(RecordType::TXT, host, &crate::ensure_quotes(record))
}
fn update_txt_record(&self, host: &str, record: &String) -> Result<()> {
self.update_record(RecordType::TXT, host, &crate::ensure_quotes(record))
}
fn delete_txt_record(&self, host: &str) -> Result<()> {
self.delete_record(RecordType::TXT, host)
}
fn get_a_record(&self, host: &str) -> Result<Option<std::net::Ipv4Addr>> {
self.get_record(RecordType::A, host)
}
fn create_a_record(&self, host: &str, record: &std::net::Ipv4Addr) -> Result<()> {
self.create_record(RecordType::A, host, record)
}
fn update_a_record(&self, host: &str, record: &std::net::Ipv4Addr) -> Result<()> {
self.update_record(RecordType::A, host, record)
}
fn delete_a_record(&self, host: &str) -> Result<()> {
self.delete_record(RecordType::A, host)
}
}
}
fn ensure_quotes(record: &String) -> String {
let starts = record.starts_with('"');
let ends = record.ends_with('"');
match (starts, ends) {
(true, true) => record.clone(),
(true, false) => format!("{}\"", record),
(false, true) => format!("\"{}", record),
(false, false) => format!("\"{}\"", record),
}
}
fn strip_quotes(record: &str) -> String {
let chars = record.chars();
let mut check = chars.clone();
let first = check.next();
let last = check.last();
if let Some('"') = first && let Some('"') = last {
chars.skip(1)
.take(record.len() - 2)
.collect()
} else {
warn!("Double quotes not found in record string, using whole record.");
record.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::Ipv4Addr;
use random_string::charsets::ALPHA_LOWER;
use tracing::info;
#[test]
fn test_strip_quotes() {
assert_eq!("abc123".to_string(), strip_quotes("\"abc123\""));
assert_eq!("abc123\"", strip_quotes("abc123\""));
assert_eq!("\"abc123", strip_quotes("\"abc123"));
assert_eq!("abc123", strip_quotes("abc123"));
}
#[test]
fn test_already_quoted() {
assert_eq!(ensure_quotes(&"\"hello\"".to_string()), "\"hello\"");
assert_eq!(ensure_quotes(&"\"\"".to_string()), "\"\"");
assert_eq!(ensure_quotes(&"\"a\"".to_string()), "\"a\"");
assert_eq!(ensure_quotes(&"\"quoted \" string\"".to_string()), "\"quoted \" string\"");
}
#[test]
fn test_no_quotes() {
assert_eq!(ensure_quotes(&"hello".to_string()), "\"hello\"");
assert_eq!(ensure_quotes(&"".to_string()), "\"\"");
assert_eq!(ensure_quotes(&"a".to_string()), "\"a\"");
assert_eq!(ensure_quotes(&"hello world".to_string()), "\"hello world\"");
}
#[test]
fn test_only_starting_quote() {
assert_eq!(ensure_quotes(&"\"hello".to_string()), "\"hello\"");
assert_eq!(ensure_quotes(&"\"test case".to_string()), "\"test case\"");
}
#[test]
fn test_only_ending_quote() {
assert_eq!(ensure_quotes(&"hello\"".to_string()), "\"hello\"");
assert_eq!(ensure_quotes(&"test case\"".to_string()), "\"test case\"");
}
#[test]
fn test_whitespace_handling() {
assert_eq!(ensure_quotes(&"".to_string()), "\"\"");
assert_eq!(ensure_quotes(&" ".to_string()), "\" \"");
assert_eq!(ensure_quotes(&"\t\n".to_string()), "\"\t\n\"");
assert_eq!(ensure_quotes(&" hello ".to_string()), "\" hello \"");
assert_eq!(ensure_quotes(&"\" hello ".to_string()), "\" hello \"");
assert_eq!(ensure_quotes(&" hello \"".to_string()), "\" hello \"");
}
#[test]
fn test_special_characters() {
assert_eq!(ensure_quotes(&"hello\nworld".to_string()), "\"hello\nworld\"");
assert_eq!(ensure_quotes(&"hello\tworld".to_string()), "\"hello\tworld\"");
assert_eq!(ensure_quotes(&"123!@#$%^&*()".to_string()), "\"123!@#$%^&*()\"");
}
pub(crate) fn test_create_update_delete_ipv4(client: impl DnsProvider) -> Result<()> {
let host = random_string::generate(16, ALPHA_LOWER);
info!("Creating IPv4 {host}");
let ip: Ipv4Addr = "10.9.8.7".parse()?;
client.create_record(RecordType::A, &host, &ip)?;
info!("Checking IPv4 {host}");
let cur = client.get_record(RecordType::A, &host)?;
assert_eq!(Some(ip), cur);
info!("Updating IPv4 {host}");
let ip: Ipv4Addr = "10.10.9.8".parse()?;
client.update_record(RecordType::A, &host, &ip)?;
info!("Checking IPv4 {host}");
let cur = client.get_record(RecordType::A, &host)?;
assert_eq!(Some(ip), cur);
info!("Deleting IPv4 {host}");
client.delete_record(RecordType::A, &host)?;
let del: Option<Ipv4Addr> = client.get_record(RecordType::A, &host)?;
assert!(del.is_none());
Ok(())
}
pub(crate) fn test_create_update_delete_txt(client: impl DnsProvider) -> Result<()> {
let host = random_string::generate(16, ALPHA_LOWER);
let txt = "\"a text reference\"".to_string();
client.create_record(RecordType::TXT, &host, &txt)?;
let cur: Option<String> = client.get_record(RecordType::TXT, &host)?;
assert_eq!(txt, cur.unwrap());
let txt = "\"another text reference\"".to_string();
client.update_record(RecordType::TXT, &host, &txt)?;
let cur: Option<String> = client.get_record(RecordType::TXT, &host)?;
assert_eq!(txt, cur.unwrap());
client.delete_record(RecordType::TXT, &host)?;
let del: Option<String> = client.get_record(RecordType::TXT, &host)?;
assert!(del.is_none());
Ok(())
}
pub(crate) fn test_create_update_delete_txt_default(client: impl DnsProvider) -> Result<()> {
let host = random_string::generate(16, ALPHA_LOWER);
let txt = "a text reference".to_string();
client.create_txt_record(&host, &txt)?;
let cur = client.get_txt_record(&host)?;
assert_eq!(txt, strip_quotes(&cur.unwrap()));
let txt = "another text reference".to_string();
client.update_txt_record(&host, &txt)?;
let cur = client.get_txt_record(&host)?;
assert_eq!(txt, strip_quotes(&cur.unwrap()));
client.delete_txt_record(&host)?;
let del = client.get_txt_record(&host)?;
assert!(del.is_none());
Ok(())
}
#[macro_export]
macro_rules! generate_tests {
($feat:literal) => {
use serial_test::serial;
#[test_log::test]
#[serial]
#[cfg_attr(not(feature = $feat), ignore = "API test")]
fn create_update_v4() -> Result<()> {
test_create_update_delete_ipv4(get_client())?;
Ok(())
}
#[test_log::test]
#[serial]
#[cfg_attr(not(feature = $feat), ignore = "API test")]
fn create_update_txt() -> Result<()> {
test_create_update_delete_txt(get_client())?;
Ok(())
}
#[test_log::test]
#[serial]
#[cfg_attr(not(feature = $feat), ignore = "API test")]
fn create_update_default() -> Result<()> {
test_create_update_delete_txt_default(get_client())?;
Ok(())
}
}
}
}