diff --git a/docs/example-config.yaml b/docs/example-config.yaml deleted file mode 100644 index 5ffa673..0000000 --- a/docs/example-config.yaml +++ /dev/null @@ -1,75 +0,0 @@ -resolver: - ipv4: - url: https://ip.cancom.io - type: Raw - ipv6: - url: https://ipv6.cancom.io - type: Raw -providers: -- provider: !Nitrado - name: Nitrado1 - api_key: your_api_key - api_base_url: https://api.nitrado.net -dns: -- dns: !Nitrado - provider_name: Nitrado1 - domains: - - domain: example.com - records: - - !Manual - domain: ipv4 - value: !A 127.0.0.1 - ttl: 3600 - - !Manual - domain: ipv6 - value: !AAAA ::1 - ttl: 3600 - - !Manual - domain: forward - value: !CNAME example.com - ttl: 3600 - - !Manual - domain: '@' - value: !TXT v=spf1 include:example.com ~all - ttl: 3600 - - !Manual - domain: '@' - value: !SPF v=spf1 include:example.com ~all - ttl: 3600 - - !Manual - domain: '@' - value: !MX - priority: 10 - target: mail.example.com - ttl: 3600 - - !Manual - domain: '@' - value: !SRV - - 10 - - 10 - - 10 - - example.com - ttl: 3600 - - !Manual - domain: _443._tcp - value: !TLSA - - 3 - - 1 - - 1 - - abcdef0123456789 - ttl: 3600 - - !Manual - domain: '@' - value: !CAA - - 0 - - issue - - letsencrypt.org - ttl: 3600 - - !Automatic - domain: auto-ipv4 - ttl: 300 - resolve_type: IPv4 - - !Automatic - domain: auto-ipv6 - ttl: 300 - resolve_type: IPv6 diff --git a/docs/example-config/dns/hetzner-domains.yaml b/docs/example-config/dns/hetzner-domains.yaml new file mode 100644 index 0000000..0db854b --- /dev/null +++ b/docs/example-config/dns/hetzner-domains.yaml @@ -0,0 +1,2 @@ +provider_name: Hetzner1 +domains: [] diff --git a/docs/example-config/dns/nitrado-domains.yaml b/docs/example-config/dns/nitrado-domains.yaml new file mode 100644 index 0000000..ad27350 --- /dev/null +++ b/docs/example-config/dns/nitrado-domains.yaml @@ -0,0 +1,2 @@ +provider_name: Nitrado1 +domains: [] diff --git a/docs/example-config/providers/hetzner.yaml b/docs/example-config/providers/hetzner.yaml new file mode 100644 index 0000000..f2cec8d --- /dev/null +++ b/docs/example-config/providers/hetzner.yaml @@ -0,0 +1,3 @@ +name: Hetzner1 +api_key: your_api_key +api_base_url: https://dns.hetzner.com/api/v1 diff --git a/docs/example-config/providers/nitrado.yaml b/docs/example-config/providers/nitrado.yaml new file mode 100644 index 0000000..0abe472 --- /dev/null +++ b/docs/example-config/providers/nitrado.yaml @@ -0,0 +1,3 @@ +name: Nitrado1 +api_key: your_api_key +api_base_url: https://api.nitrado.net diff --git a/docs/example-config/resolver.yaml b/docs/example-config/resolver.yaml new file mode 100644 index 0000000..f55f511 --- /dev/null +++ b/docs/example-config/resolver.yaml @@ -0,0 +1,6 @@ +ipv4: + url: https://ip.cancom.io + type: Raw +ipv6: + url: https://ipv6.cancom.io + type: Raw diff --git a/src/cli.rs b/src/cli.rs index 987bca7..7c06786 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,5 +1,6 @@ pub mod auto; pub mod command; +pub mod generate_config; pub mod get; use std::future::Future; diff --git a/src/cli/command.rs b/src/cli/command.rs index 40fbd81..b6a0cd2 100644 --- a/src/cli/command.rs +++ b/src/cli/command.rs @@ -5,7 +5,7 @@ use thiserror::Error; use crate::{ Config, - cli::{ExecutableCommand, auto, get}, + cli::{ExecutableCommand, auto, generate_config, get}, }; #[derive(Debug, ClapSubcommand)] @@ -13,6 +13,7 @@ use crate::{ pub enum Subcommand<'a> { Auto(auto::Command<'a>), Get(get::Command<'a>), + GenerateConfig(generate_config::Command<'a>), } #[derive(Debug)] @@ -27,6 +28,9 @@ pub enum Error { #[error("Failed to execute get subcommand: {0}")] Get(#[from] get::Error), + + #[error("Failed to execute generate-config subcommand: {0}")] + GenerateConfig(#[from] generate_config::Error), } /// dnrs @@ -62,6 +66,10 @@ impl<'command> ExecutableCommand<'command> for Command<'command> { let input = get::Input { config, reqwest }; subcommand.execute(&input).await?; } + Subcommand::GenerateConfig(subcommand) => { + let input = generate_config::Input { config }; + subcommand.execute(&input).await?; + } } Ok(()) diff --git a/src/cli/generate_config.rs b/src/cli/generate_config.rs new file mode 100644 index 0000000..2a9b829 --- /dev/null +++ b/src/cli/generate_config.rs @@ -0,0 +1,63 @@ +use std::{io, marker::PhantomData}; + +use clap::Parser; +use lum_log::info; +use thiserror::Error; + +use crate::{Config, cli::ExecutableCommand}; + +#[derive(Debug)] +pub struct Input<'config> { + pub config: &'config Config, +} + +#[derive(Debug, Error)] +pub enum Error { + #[error("IO error: {0}")] + Io(#[from] io::Error), + + #[error("YAML serialization error: {0}")] + Yaml(#[from] serde_yaml_ng::Error), + + #[error("Config error: {0}")] + Config(#[from] anyhow::Error), +} + +/// Generate configuration directory structure +#[derive(Debug, Parser)] +#[command(version, about, long_about = None, propagate_version = true)] +pub struct Command<'command> { + #[clap(skip)] + _phantom: PhantomData<&'command ()>, + + /// Output directory path (defaults to ./config) + #[clap(short, long, default_value = "config")] + pub output: String, + + /// Force overwrite existing files + #[clap(short, long, default_value = "false")] + pub force: bool, +} + +impl<'command> ExecutableCommand<'command> for Command<'command> { + type I = Input<'command>; + type R = Result<(), Error>; + + async fn execute(&self, _input: &'command Self::I) -> Self::R { + let config_dir = std::path::Path::new(&self.output); + + if config_dir.exists() && !self.force { + info!( + "Configuration directory {:?} already exists. Use --force to overwrite.", + config_dir + ); + return Ok(()); + } + + Config::create_example_structure(config_dir)?; + + info!("Configuration structure created in {:?}", config_dir); + + Ok(()) + } +} diff --git a/src/cli/get.rs b/src/cli/get.rs index 48c6e2c..00ae30d 100644 --- a/src/cli/get.rs +++ b/src/cli/get.rs @@ -1,13 +1,17 @@ use std::marker::PhantomData; use clap::{Args, Parser}; +use lum_log::{error, info}; use thiserror::Error; use crate::{ Config, cli::ExecutableCommand, config::provider::Provider as ProviderConfig, - provider::{GetAllRecordsInput, GetRecordsInput, Provider, nitrado::NitradoProvider}, + provider::{ + GetAllRecordsInput, GetRecordsInput, Provider, hetzner::HetznerProvider, + netcup::NetcupProvider, nitrado::NitradoProvider, + }, }; #[derive(Debug)] @@ -25,15 +29,14 @@ pub enum Error { ProviderError(#[from] anyhow::Error), } -//TODO: Fix order of usage message (provider should come first) #[derive(Debug, Args)] -#[group(required = true, multiple = false)] pub struct SubdomainArgs { /// Subdomains to get records for + #[clap(display_order = 3)] subdomains: Vec, /// Get all records - #[clap(short, long, default_value = "false")] + #[clap(short, long, default_value = "false", display_order = 3)] pub all: bool, } @@ -45,9 +48,11 @@ pub struct Command<'command> { _phantom: PhantomData<&'command ()>, /// Name of the provider to get records from + #[clap(display_order = 1)] provider: String, /// Domain to get records for + #[clap(display_order = 2)] domain: String, #[command(flatten)] @@ -59,13 +64,23 @@ fn get_provider<'config>( name: &str, config: &'config Config, ) -> Option> { - for provider_file_config in config.providers.iter() { - match &provider_file_config.provider { + for provider in config.providers.iter() { + match provider { ProviderConfig::Nitrado(nitrado_config) => { if name == nitrado_config.name { return Some(Box::new(NitradoProvider::new(nitrado_config))); } } + ProviderConfig::Hetzner(hetzner_config) => { + if name == hetzner_config.name { + return Some(Box::new(HetznerProvider::new(hetzner_config))); + } + } + ProviderConfig::Netcup(netcup_config) => { + if name == netcup_config.name { + return Some(Box::new(NetcupProvider::new(netcup_config))); + } + } } } @@ -77,6 +92,20 @@ impl<'command> ExecutableCommand<'command> for Command<'command> { type R = Result<(), Error>; async fn execute(&self, input: &'command Self::I) -> Self::R { + if self.subdomain_args.all && !self.subdomain_args.subdomains.is_empty() { + error!("Cannot specify both --all and specific subdomains"); + return Err(Error::ProviderError(anyhow::anyhow!( + "Cannot specify both --all and specific subdomains" + ))); + } + + if !self.subdomain_args.all && self.subdomain_args.subdomains.is_empty() { + error!("Must specify either --all or specific subdomains"); + return Err(Error::ProviderError(anyhow::anyhow!( + "Must specify either --all or specific subdomains" + ))); + } + let config = input.config; let provider_name = self.provider.as_str(); @@ -109,13 +138,13 @@ impl<'command> ExecutableCommand<'command> for Command<'command> { let records = match results { Err(e) => { - eprintln!("Error: {}", e); + error!("Error: {}", e); return Err(e.into()); } Ok(records) => records, }; - println!("Records: {:#?}", records); + info!("Records: {:#?}", records); Ok(()) } } diff --git a/src/config.rs b/src/config.rs index e8354ea..d52ae42 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,8 @@ +use anyhow::Result; use lum_config::MergeFrom; use lum_libs::serde::{Deserialize, Serialize}; +use lum_log::{debug, error, info}; +use std::path::Path; pub mod dns; pub mod provider; @@ -10,16 +13,221 @@ pub mod resolver; #[serde(default)] pub struct Config { pub resolver: resolver::Config, - pub providers: Vec, - pub dns: Vec, + pub providers: Vec, + pub dns: Vec, +} + +impl Config { + pub fn load_from_directory>(config_dir: P) -> Result { + let config_dir = config_dir.as_ref(); + let resolver = Self::load_resolver_config(config_dir)?; + let providers = Self::load_provider_configs(&config_dir.join("providers"))?; + let dns = Self::load_dns_configs(&config_dir.join("dns"))?; + + let loaded_config = Config { + resolver, + providers, + dns, + }; + + let default_config = Config::default(); + Ok(default_config.merge_from(loaded_config)) + } + + fn load_resolver_config>(config_dir: P) -> Result { + let resolver_path = config_dir.as_ref().join("resolver.yaml"); + if resolver_path.exists() { + let content = std::fs::read_to_string(resolver_path)?; + Ok(serde_yaml_ng::from_str(&content)?) + } else { + Ok(resolver::Config::default()) + } + } + + fn load_provider_configs>(providers_dir: P) -> Result> { + let mut configs = Vec::new(); + let providers_dir = providers_dir.as_ref(); + + if !providers_dir.exists() { + info!( + "Providers directory {:?} does not exist, using defaults", + providers_dir + ); + return Ok(vec![ + provider::Provider::Nitrado(crate::provider::nitrado::Config::default()), + provider::Provider::Hetzner(crate::provider::hetzner::Config::default()), + provider::Provider::Netcup(crate::provider::netcup::Config::default()), + ]); + } + + for entry in std::fs::read_dir(providers_dir)? { + let entry = entry?; + let path = entry.path(); + + if path + .extension() + .map_or(false, |ext| ext == "yaml" || ext == "yml") + { + let content = std::fs::read_to_string(&path)?; + + let file_stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown"); + + match file_stem { + "hetzner" => { + let config: crate::provider::hetzner::Config = + serde_yaml_ng::from_str(&content)?; + configs.push(provider::Provider::Hetzner(config)); + debug!("Loaded Hetzner provider config from {:?}", path); + } + "nitrado" => { + let config: crate::provider::nitrado::Config = + serde_yaml_ng::from_str(&content)?; + configs.push(provider::Provider::Nitrado(config)); + debug!("Loaded Nitrado provider config from {:?}", path); + } + "netcup" => { + let config: crate::provider::netcup::Config = + serde_yaml_ng::from_str(&content)?; + configs.push(provider::Provider::Netcup(config)); + debug!("Loaded Netcup provider config from {:?}", path); + } + _ => { + error!("Unknown provider config file: {}", path.display()); + } + } + } + } + + if configs.is_empty() { + info!("No provider configs found, using defaults"); + configs.push(provider::Provider::Nitrado( + crate::provider::nitrado::Config::default(), + )); + configs.push(provider::Provider::Hetzner( + crate::provider::hetzner::Config::default(), + )); + } + + Ok(configs) + } + + fn load_dns_configs>(dns_dir: P) -> Result> { + let mut configs = Vec::new(); + let dns_dir = dns_dir.as_ref(); + + if !dns_dir.exists() { + info!( + "DNS directory {:?} does not exist, using empty configs", + dns_dir + ); + return Ok(vec![]); + } + + for entry in std::fs::read_dir(dns_dir)? { + let entry = entry?; + let path = entry.path(); + + if path + .extension() + .map_or(false, |ext| ext == "yaml" || ext == "yml") + { + let content = std::fs::read_to_string(&path)?; + + let file_stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown"); + + if file_stem.contains("hetzner") { + let config: crate::provider::hetzner::DnsConfig = + serde_yaml_ng::from_str(&content)?; + configs.push(dns::Type::Hetzner(config)); + debug!("Loaded Hetzner DNS config from {:?}", path); + } else if file_stem.contains("nitrado") { + let config: crate::provider::nitrado::DnsConfig = + serde_yaml_ng::from_str(&content)?; + configs.push(dns::Type::Nitrado(config)); + debug!("Loaded Nitrado DNS config from {:?}", path); + } else if file_stem.contains("netcup") { + let config: crate::provider::netcup::DnsConfig = + serde_yaml_ng::from_str(&content)?; + configs.push(dns::Type::Netcup(config)); + debug!("Loaded Netcup DNS config from {:?}", path); + } else { + error!( + "Cannot determine DNS config type for file: {}", + path.display() + ); + } + } + } + + debug!("Loaded {} DNS configurations", configs.len()); + Ok(configs) + } + + pub fn create_example_structure>(config_dir: P) -> Result<()> { + let config_dir = config_dir.as_ref(); + + std::fs::create_dir_all(config_dir.join("providers"))?; + std::fs::create_dir_all(config_dir.join("dns"))?; + + let resolver_config = resolver::Config::default(); + let resolver_yaml = serde_yaml_ng::to_string(&resolver_config)?; + std::fs::write(config_dir.join("resolver.yaml"), resolver_yaml)?; + + let hetzner_config = crate::provider::hetzner::Config::default(); + let hetzner_yaml = serde_yaml_ng::to_string(&hetzner_config)?; + std::fs::write(config_dir.join("providers/hetzner.yaml"), hetzner_yaml)?; + + let nitrado_config = crate::provider::nitrado::Config::default(); + let nitrado_yaml = serde_yaml_ng::to_string(&nitrado_config)?; + std::fs::write(config_dir.join("providers/nitrado.yaml"), nitrado_yaml)?; + + let netcup_config = crate::provider::netcup::Config::default(); + let netcup_yaml = serde_yaml_ng::to_string(&netcup_config)?; + std::fs::write(config_dir.join("providers/netcup.yaml"), netcup_yaml)?; + + let hetzner_dns_config = crate::provider::hetzner::DnsConfig::default(); + let hetzner_dns_yaml = serde_yaml_ng::to_string(&hetzner_dns_config)?; + std::fs::write( + config_dir.join("dns/hetzner-domains.yaml"), + hetzner_dns_yaml, + )?; + + let nitrado_dns_config = crate::provider::nitrado::DnsConfig::default(); + let nitrado_dns_yaml = serde_yaml_ng::to_string(&nitrado_dns_config)?; + std::fs::write( + config_dir.join("dns/nitrado-domains.yaml"), + nitrado_dns_yaml, + )?; + + let netcup_dns_config = crate::provider::netcup::DnsConfig::default(); + let netcup_dns_yaml = serde_yaml_ng::to_string(&netcup_dns_config)?; + std::fs::write(config_dir.join("dns/netcup-domains.yaml"), netcup_dns_yaml)?; + + info!("Created example config structure in {:?}", config_dir); + Ok(()) + } } impl Default for Config { fn default() -> Self { Config { resolver: resolver::Config::default(), - providers: vec![provider::Config::default()], - dns: vec![dns::Config::default()], + providers: vec![ + provider::Provider::Nitrado(crate::provider::nitrado::Config::default()), + provider::Provider::Hetzner(crate::provider::hetzner::Config::default()), + provider::Provider::Netcup(crate::provider::netcup::Config::default()), + ], + dns: vec![ + dns::Type::Nitrado(crate::provider::nitrado::DnsConfig::default()), + dns::Type::Hetzner(crate::provider::hetzner::DnsConfig::default()), + dns::Type::Netcup(crate::provider::netcup::DnsConfig::default()), + ], } } } @@ -28,8 +236,16 @@ impl MergeFrom for Config { fn merge_from(self, other: Self) -> Self { Self { resolver: other.resolver, - providers: other.providers, - dns: other.dns, + providers: if !other.providers.is_empty() { + other.providers + } else { + self.providers + }, + dns: if !other.dns.is_empty() { + other.dns + } else { + self.dns + }, } } } diff --git a/src/config/dns.rs b/src/config/dns.rs index aa35814..f3d5587 100644 --- a/src/config/dns.rs +++ b/src/config/dns.rs @@ -1,11 +1,14 @@ use lum_libs::serde::{Deserialize, Serialize}; -use crate::{provider::nitrado, types}; +use crate::provider::{hetzner, netcup, nitrado}; +use crate::types; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(crate = "lum_libs::serde")] pub enum Type { Nitrado(nitrado::DnsConfig), + Hetzner(hetzner::DnsConfig), + Netcup(netcup::DnsConfig), } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -35,13 +38,16 @@ pub enum ResolveType { pub struct Config { // https://github.com/acatton/serde-yaml-ng/issues/14 //#[serde(flatten)] - pub dns: Type, + pub dns: Vec, } impl Default for Config { fn default() -> Self { Config { - dns: Type::Nitrado(nitrado::DnsConfig::default()), + dns: vec![ + Type::Nitrado(nitrado::DnsConfig::default()), + Type::Hetzner(hetzner::DnsConfig::default()), + ], } } } diff --git a/src/config/provider.rs b/src/config/provider.rs index 27b1a62..3a10db7 100644 --- a/src/config/provider.rs +++ b/src/config/provider.rs @@ -1,25 +1,11 @@ use lum_libs::serde::{Deserialize, Serialize}; -use crate::provider::nitrado; +use crate::provider::{hetzner, netcup, nitrado}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(crate = "lum_libs::serde")] pub enum Provider { Nitrado(nitrado::Config), -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(crate = "lum_libs::serde")] -pub struct Config { - // https://github.com/acatton/serde-yaml-ng/issues/14 - //#[serde(flatten)] - pub provider: Provider, -} - -impl Default for Config { - fn default() -> Self { - Config { - provider: Provider::Nitrado(nitrado::Config::default()), - } - } + Hetzner(hetzner::Config), + Netcup(netcup::Config), } diff --git a/src/main.rs b/src/main.rs index 10d6c47..e965dcd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,9 @@ use std::fmt::{self, Debug}; -use std::fs::{self, File}; +use std::fs; use dnrs::{Config, RuntimeError, run, setup_logger}; -use lum_config::{ConfigPathError, EnvironmentConfigParseError, FileConfigParseError, merge}; -use lum_log::info; -use lum_log::log::SetLoggerError; +use lum_config::{ConfigPathError, EnvironmentConfigParseError, FileConfigParseError}; +use lum_log::{info, log::SetLoggerError}; use thiserror::Error; /* @@ -34,6 +33,7 @@ use thiserror::Error; - CLI options can override config file options - Add command: Always accept multiple domains, use batch if provider supports it - Models (Responses, Errors) + - Add conditional Config files (per provider configs) */ const APP_NAME: &str = "dnrs"; @@ -58,6 +58,9 @@ enum Error { #[error("IO error: {0}")] Io(#[from] std::io::Error), + #[error("Config error: {0}")] + Config(#[from] anyhow::Error), + #[error("Unable to determine config directory")] NoConfigDirectory, @@ -72,79 +75,30 @@ impl Debug for Error { } } -fn read_config() -> Result { - let config_path = dirs::config_dir() - .ok_or(Error::NoConfigDirectory)? - .join(APP_NAME) - .join("config.yaml"); - - let mut loaded_config: Option = None; - if config_path.exists() { - let file = File::open(&config_path)?; - loaded_config = Some(serde_yaml_ng::from_reader(file)?); - } - let config_existed = loaded_config.is_some(); - - let default_config = Config::default(); - let config = match loaded_config { - Some(loaded_config) => merge(default_config, loaded_config), - None => default_config, - }; - - if let Some(parent) = config_path.parent() { - fs::create_dir_all(parent)?; - } - let file = File::create(&config_path)?; - serde_yaml_ng::to_writer(file, &config)?; - - if !config_existed { - info!("Created default config file at: {}", config_path.display()); - } - - Ok(config) -} - #[tokio::main] async fn main() -> Result<(), Error> { setup_logger()?; - let config = read_config()?; - run(config).await?; - - Ok(()) -} - -/* -let reqwest = reqwest::Client::new(); - - let auth = format!("Bearer {}", dnrs::TOKEN); - - let mut headers = HeaderMap::new(); - headers.insert(header::AUTHORIZATION, HeaderValue::from_str(&auth).unwrap()); - headers.insert("Content-Type", HeaderValue::from_static("application/json")); - - let response = reqwest - .get("https://api.nitrado.net/domain//records") - .headers(headers) - .send() - .await; - - let response = match response { - Ok(response) => response, - Err(error) => { - error!("Error sending request: {}", error); - return Err(1); - } - }; - - let text = match response.text().await { - Ok(text) => text, - Err(error) => { - error!("Error reading response text: {}", error); - return Err(1); - } + let config_dir = dirs::config_dir() + .ok_or(Error::NoConfigDirectory)? + .join(APP_NAME); + + let config = if config_dir.exists() { + Config::load_from_directory(&config_dir)? + } else { + info!("Config directory does not exist, creating default structure..."); + fs::create_dir_all(&config_dir)?; + + Config::create_example_structure(&config_dir)?; + info!( + "Created default config structure at: {}", + config_dir.display() + ); + info!("Please configure your providers and DNS settings, then run again."); + + Config::default() }; - info!("Result: {}", text); + run(config).await?; Ok(()) - */ +} diff --git a/src/provider.rs b/src/provider.rs index 83e74cf..7002b03 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -3,6 +3,8 @@ use async_trait::async_trait; use crate::types::dns::Record; +pub mod hetzner; +pub mod netcup; pub mod nitrado; #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/src/provider/hetzner.rs b/src/provider/hetzner.rs new file mode 100644 index 0000000..737fa10 --- /dev/null +++ b/src/provider/hetzner.rs @@ -0,0 +1,156 @@ +use anyhow::Result; +use async_trait::async_trait; +use lum_libs::serde_json; +use reqwest::header::HeaderMap; +use thiserror::Error; + +use crate::{ + provider::{Feature, GetAllRecordsInput, GetRecordsInput, Provider}, + types::dns::{self}, +}; + +pub mod config; +pub mod model; + +pub use config::{Config, DnsConfig, DomainConfig}; +pub use model::{GetRecordsResponse, Record, TryFromRecordError}; + +pub struct HetznerProvider<'provider_config> { + pub provider_config: &'provider_config Config, +} + +impl<'provider_config> HetznerProvider<'provider_config> { + pub fn new(provider_config: &'provider_config Config) -> HetznerProvider<'provider_config> { + HetznerProvider { provider_config } + } + + async fn get_zone_id(&self, reqwest: reqwest::Client, domain: &str) -> Result { + let mut headers = HeaderMap::new(); + headers.insert( + "Auth-API-Token", + self.provider_config.api_key.parse().expect("Invalid Hetzner API key: contains characters that are not allowed in HTTP headers"), + ); + + let url = format!("{}/zones", self.provider_config.api_base_url); + let response = reqwest.get(&url).headers(headers).send().await?; + + if !response.status().is_success() { + return Err(Error::Unsuccessful(response.status().as_u16(), response).into()); + } + + let text = response.text().await?; + let json_value: serde_json::Value = serde_json::from_str(&text)?; + + match json_value + .get("zones") + .and_then(|zones| zones.as_array()) + .and_then(|zones_array| { + zones_array.iter().find_map(|zone| { + let zone_name = zone.get("name")?.as_str()?; + let zone_id = zone.get("id")?.as_str()?; + if zone_name == domain { + Some(zone_id.to_string()) + } else { + None + } + }) + }) { + Some(zone_id) => Ok(zone_id), + None => Err(Error::DomainNotFound(domain.to_string()).into()), + } + } +} + +#[derive(Debug, Error)] +pub enum Error { + #[error("HTTP request failed: {0}")] + Reqwest(#[from] reqwest::Error), + + #[error("HTTP response is not successful: {0}")] + Unsuccessful(u16, reqwest::Response), + + #[error("JSON parsing error: {0}")] + Json(#[from] serde_json::Error), + + #[error("Domain '{0}' not found in Hetzner zones")] + DomainNotFound(String), +} + +#[async_trait] +impl Provider for HetznerProvider<'_> { + fn get_provider_name(&self) -> &'static str { + "Hetzner" + } + + fn get_supported_features(&self) -> Vec { + vec![ + Feature::GetRecords, + Feature::GetAllRecords, + Feature::AddRecord, + Feature::UpdateRecord, + Feature::DeleteRecord, + ] + } + + async fn get_records( + &self, + reqwest: reqwest::Client, + input: &GetRecordsInput, + ) -> Result> { + let get_all_records_input = GetAllRecordsInput::from(input); + let records = self + .get_all_records(reqwest, &get_all_records_input) + .await?; + + let filtered_records = records + .into_iter() + .filter(|record| input.subdomains.contains(&record.domain.as_str())) + .collect::>(); + + Ok(filtered_records) + } + + async fn get_all_records( + &self, + reqwest: reqwest::Client, + input: &GetAllRecordsInput, + ) -> Result> { + let mut headers = HeaderMap::new(); + headers.insert( + "Auth-API-Token", + self.provider_config.api_key.parse().expect("Invalid Hetzner API key: contains characters that are not allowed in HTTP headers"), + ); + + let domain = &input.domain; + let zone_id = self.get_zone_id(reqwest.clone(), domain).await?; + + let url = format!( + "{}/records?zone_id={}", + self.provider_config.api_base_url, zone_id + ); + + let response = reqwest.get(&url).headers(headers).send().await?; + + if !response.status().is_success() { + return Err(Error::Unsuccessful(response.status().as_u16(), response).into()); + } + + let text = response.text().await?; + let response: GetRecordsResponse = serde_json::from_str(&text)?; + let records: Vec = response.try_into()?; + + Ok(records) + } + + async fn add_record(&self, _reqwest: reqwest::Client, _input: &dns::Record) -> Result<()> { + unimplemented!("Hetzner add_record not yet implemented") + } + + async fn update_record(&self, _reqwest: reqwest::Client, _input: &dns::Record) -> Result<()> { + unimplemented!("Hetzner update_record not yet implemented") + } + + async fn delete_record(&self, _reqwest: reqwest::Client, _input: &dns::Record) -> Result<()> { + unimplemented!("Hetzner delete_record not yet implemented") + } +} diff --git a/src/provider/hetzner/config.rs b/src/provider/hetzner/config.rs new file mode 100644 index 0000000..08ce6e2 --- /dev/null +++ b/src/provider/hetzner/config.rs @@ -0,0 +1,44 @@ +use lum_libs::serde::{Deserialize, Serialize}; + +use crate::config::dns::RecordConfig; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(crate = "lum_libs::serde")] +pub struct Config { + pub name: String, + pub api_key: String, + pub api_base_url: String, +} + +impl Default for Config { + fn default() -> Self { + Config { + name: "Hetzner1".to_string(), + api_key: "your_api_key".to_string(), + api_base_url: "https://dns.hetzner.com/api/v1".to_string(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(crate = "lum_libs::serde")] +pub struct DomainConfig { + pub domain: String, + pub records: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(crate = "lum_libs::serde")] +pub struct DnsConfig { + pub provider_name: String, + pub domains: Vec, +} + +impl Default for DnsConfig { + fn default() -> Self { + DnsConfig { + provider_name: "Hetzner1".to_string(), + domains: vec![], + } + } +} diff --git a/src/provider/hetzner/model.rs b/src/provider/hetzner/model.rs new file mode 100644 index 0000000..cdaf0ba --- /dev/null +++ b/src/provider/hetzner/model.rs @@ -0,0 +1,168 @@ +use core::num; +use std::{ + net::{self, Ipv4Addr, Ipv6Addr}, + str::FromStr, +}; + +use lum_libs::serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::types::dns::{self, MxRecord, RecordType, RecordValue}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(crate = "lum_libs::serde")] +pub struct Record { + pub r#type: RecordType, + pub id: String, + pub created: String, + pub modified: String, + pub zone_id: String, + pub name: String, + pub value: String, + pub ttl: Option, +} + +#[derive(Debug, Clone, Error)] +pub enum TryFromRecordError { + #[error("Invalid IP address: {0}")] + InvalidIp(#[from] net::AddrParseError), + + #[error("Invalid MX record format: {0}")] + InvalidMxFormat(String), + + #[error("Invalid priority in MX record: {0}")] + InvalidMxPriority(num::ParseIntError), + + #[error("Invalid SRV record format: {0}")] + InvalidSrvFormat(String), + + #[error("Invalid SRV record priority/weight/port: {0}")] + InvalidSrvValue(num::ParseIntError), + + #[error("Invalid TLSA record format: {0}")] + InvalidTlsaFormat(String), + + #[error("Invalid TLSA record usage/selector/matching type: {0}")] + InvalidTlsaValue(num::ParseIntError), + + #[error("Invalid CAA record format: {0}")] + InvalidCaaFormat(String), + + #[error("Invalid CAA record flag: {0}")] + InvalidCaaFlag(num::ParseIntError), +} + +impl TryFrom for dns::Record { + type Error = TryFromRecordError; + + fn try_from(api_record: Record) -> std::result::Result { + let value = match api_record.r#type { + RecordType::A => { + let ip = Ipv4Addr::from_str(&api_record.value)?; + RecordValue::A(ip) + } + RecordType::AAAA => { + let ip = Ipv6Addr::from_str(&api_record.value)?; + RecordValue::AAAA(ip) + } + RecordType::CNAME => RecordValue::CNAME(api_record.value), + RecordType::TXT => RecordValue::TXT(api_record.value), + RecordType::SPF => RecordValue::SPF(api_record.value), + RecordType::NS => RecordValue::NS(api_record.value), + RecordType::SOA => RecordValue::SOA(api_record.value), + RecordType::MX => { + let content = api_record.value; + let parts: Vec<&str> = content.split_whitespace().collect(); + if parts.len() != 2 { + return Err(TryFromRecordError::InvalidMxFormat(content)); + } + + let priority = parts[0] + .parse::() + .map_err(TryFromRecordError::InvalidMxPriority)?; + + let target = parts[1].to_string(); + RecordValue::MX(MxRecord { priority, target }) + } + RecordType::SRV => { + let content = api_record.value; + let parts: Vec<&str> = content.split_whitespace().collect(); + if parts.len() != 4 { + return Err(TryFromRecordError::InvalidSrvFormat(content)); + } + + let priority = parts[0] + .parse::() + .map_err(TryFromRecordError::InvalidSrvValue)?; + let weight = parts[1] + .parse::() + .map_err(TryFromRecordError::InvalidSrvValue)?; + let port = parts[2] + .parse::() + .map_err(TryFromRecordError::InvalidSrvValue)?; + + let target = parts[3].to_string(); + RecordValue::SRV(priority, weight, port, target) + } + RecordType::TLSA => { + let content = api_record.value; + let parts: Vec<&str> = content.split_whitespace().collect(); + if parts.len() != 4 { + return Err(TryFromRecordError::InvalidTlsaFormat(content)); + } + + let usage = parts[0] + .parse::() + .map_err(TryFromRecordError::InvalidTlsaValue)?; + let selector = parts[1] + .parse::() + .map_err(TryFromRecordError::InvalidTlsaValue)?; + let matching_type = parts[2] + .parse::() + .map_err(TryFromRecordError::InvalidTlsaValue)?; + + let cert_data = parts[3].to_string(); + RecordValue::TLSA(usage, selector, matching_type, cert_data) + } + RecordType::CAA => { + let content = api_record.value; + let parts: Vec<&str> = content.split_whitespace().collect(); + if parts.len() != 3 { + return Err(TryFromRecordError::InvalidCaaFormat(content)); + } + + let flag = parts[0] + .parse::() + .map_err(TryFromRecordError::InvalidCaaFlag)?; + + let tag = parts[1].to_string(); + let value = parts[2].to_string(); + RecordValue::CAA(flag, tag, value) + } + }; + + Ok(dns::Record { + domain: api_record.name, + value, + ttl: api_record.ttl, + }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(crate = "lum_libs::serde")] +pub struct GetRecordsResponse { + pub records: Vec, +} + +impl TryFrom for Vec { + type Error = TryFromRecordError; + + fn try_from(response: GetRecordsResponse) -> std::result::Result { + response + .records + .into_iter() + .map(dns::Record::try_from) + .collect() + } +} diff --git a/src/provider/netcup.rs b/src/provider/netcup.rs new file mode 100644 index 0000000..434c21a --- /dev/null +++ b/src/provider/netcup.rs @@ -0,0 +1,95 @@ +use anyhow::Result; +use async_trait::async_trait; +use lum_libs::serde_json; +use thiserror::Error; + +use crate::{ + provider::{Feature, GetAllRecordsInput, GetRecordsInput, Provider}, + types::dns::{self}, +}; + +pub mod config; +pub mod model; + +pub use config::{Config, DnsConfig, DomainConfig}; +pub use model::{GetRecordsResponse, Record, TryFromRecordError}; + +pub struct NetcupProvider<'provider_config> { + pub provider_config: &'provider_config Config, +} + +impl<'provider_config> NetcupProvider<'provider_config> { + pub fn new(provider_config: &'provider_config Config) -> NetcupProvider<'provider_config> { + NetcupProvider { provider_config } + } +} + +#[derive(Debug, Error)] +pub enum Error { + #[error("HTTP request failed: {0}")] + Reqwest(#[from] reqwest::Error), + + #[error("HTTP response is not successful: {0}")] + Unsuccessful(u16, reqwest::Response), + + #[error("JSON parsing error: {0}")] + Json(#[from] serde_json::Error), + + #[error("Domain '{0}' not found in Netcup zones")] + DomainNotFound(String), +} + +#[async_trait] +impl Provider for NetcupProvider<'_> { + fn get_provider_name(&self) -> &'static str { + "Netcup" + } + + fn get_supported_features(&self) -> Vec { + vec![ + Feature::GetRecords, + Feature::GetAllRecords, + Feature::AddRecord, + Feature::UpdateRecord, + Feature::DeleteRecord, + ] + } + + async fn get_records( + &self, + reqwest: reqwest::Client, + input: &GetRecordsInput, + ) -> Result> { + let get_all_records_input = GetAllRecordsInput::from(input); + let records = self + .get_all_records(reqwest, &get_all_records_input) + .await?; + + let filtered_records = records + .into_iter() + .filter(|record| input.subdomains.contains(&record.domain.as_str())) + .collect::>(); + + Ok(filtered_records) + } + + async fn get_all_records( + &self, + _reqwest: reqwest::Client, + _input: &GetAllRecordsInput, + ) -> Result> { + unimplemented!("Netcup get_all_records not yet implemented") + } + + async fn add_record(&self, _reqwest: reqwest::Client, _input: &dns::Record) -> Result<()> { + unimplemented!("Netcup add_record not yet implemented") + } + + async fn update_record(&self, _reqwest: reqwest::Client, _input: &dns::Record) -> Result<()> { + unimplemented!("Netcup update_record not yet implemented") + } + + async fn delete_record(&self, _reqwest: reqwest::Client, _input: &dns::Record) -> Result<()> { + unimplemented!("Netcup delete_record not yet implemented") + } +} diff --git a/src/provider/netcup/config.rs b/src/provider/netcup/config.rs new file mode 100644 index 0000000..f196f64 --- /dev/null +++ b/src/provider/netcup/config.rs @@ -0,0 +1,48 @@ +use lum_libs::serde::{Deserialize, Serialize}; + +use crate::config::dns::RecordConfig; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(crate = "lum_libs::serde")] +pub struct Config { + pub name: String, + pub customer_number: u32, + pub api_key: String, + pub api_password: String, + pub api_base_url: String, +} + +impl Default for Config { + fn default() -> Self { + Config { + name: "Netcup1".to_string(), + customer_number: 0, + api_key: "your_api_key".to_string(), + api_password: "your_api_password".to_string(), + api_base_url: "https://ccp.netcup.net/run/webservice/servers/endpoint.php".to_string(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(crate = "lum_libs::serde")] +pub struct DomainConfig { + pub domain: String, + pub records: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(crate = "lum_libs::serde")] +pub struct DnsConfig { + pub provider_name: String, + pub domains: Vec, +} + +impl Default for DnsConfig { + fn default() -> Self { + DnsConfig { + provider_name: "Netcup1".to_string(), + domains: vec![], + } + } +} diff --git a/src/provider/netcup/model.rs b/src/provider/netcup/model.rs new file mode 100644 index 0000000..1778c6e --- /dev/null +++ b/src/provider/netcup/model.rs @@ -0,0 +1,177 @@ +use core::num; +use std::{ + net::{self, Ipv4Addr, Ipv6Addr}, + str::FromStr, +}; + +use lum_libs::serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::types::dns::{self, MxRecord, RecordType, RecordValue}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(crate = "lum_libs::serde")] +pub struct Record { + pub id: Option, + pub hostname: String, + pub r#type: RecordType, + pub priority: Option, + pub destination: String, + pub deleterecord: Option, + pub state: Option, +} + +#[derive(Debug, Clone, Error)] +pub enum TryFromRecordError { + #[error("Invalid IP address: {0}")] + InvalidIp(#[from] net::AddrParseError), + + #[error("Invalid MX record format: {0}")] + InvalidMxFormat(String), + + #[error("Invalid priority in MX record: {0}")] + InvalidMxPriority(num::ParseIntError), + + #[error("Invalid SRV record format: {0}")] + InvalidSrvFormat(String), + + #[error("Invalid SRV record priority/weight/port: {0}")] + InvalidSrvValue(num::ParseIntError), + + #[error("Invalid TLSA record format: {0}")] + InvalidTlsaFormat(String), + + #[error("Invalid TLSA record usage/selector/matching type: {0}")] + InvalidTlsaValue(num::ParseIntError), + + #[error("Invalid CAA record format: {0}")] + InvalidCaaFormat(String), + + #[error("Invalid CAA record flag: {0}")] + InvalidCaaFlag(num::ParseIntError), +} + +impl TryFrom for dns::Record { + type Error = TryFromRecordError; + + fn try_from(api_record: Record) -> Result { + let value = match api_record.r#type { + RecordType::A => { + let ip = Ipv4Addr::from_str(&api_record.destination)?; + RecordValue::A(ip) + } + RecordType::AAAA => { + let ip = Ipv6Addr::from_str(&api_record.destination)?; + RecordValue::AAAA(ip) + } + RecordType::CNAME => RecordValue::CNAME(api_record.destination), + RecordType::TXT => RecordValue::TXT(api_record.destination), + RecordType::SPF => RecordValue::SPF(api_record.destination), + RecordType::NS => RecordValue::NS(api_record.destination), + RecordType::SOA => RecordValue::SOA(api_record.destination), + RecordType::MX => { + let priority = api_record + .priority + .ok_or_else(|| { + TryFromRecordError::InvalidMxFormat( + "MX record missing priority".to_string(), + ) + })? + .parse::() + .map_err(TryFromRecordError::InvalidMxPriority)?; + + RecordValue::MX(MxRecord { + priority, + target: api_record.destination, + }) + } + RecordType::SRV => { + let content = api_record.destination; + let parts: Vec<&str> = content.split_whitespace().collect(); + + if parts.len() == 3 { + let priority = api_record + .priority + .ok_or_else(|| { + TryFromRecordError::InvalidSrvFormat( + "SRV record missing priority".to_string(), + ) + })? + .parse::() + .map_err(TryFromRecordError::InvalidSrvValue)?; + + let weight = parts[0] + .parse::() + .map_err(TryFromRecordError::InvalidSrvValue)?; + let port = parts[1] + .parse::() + .map_err(TryFromRecordError::InvalidSrvValue)?; + let target = parts[2].to_string(); + + RecordValue::SRV(priority, weight, port, target) + } else { + return Err(TryFromRecordError::InvalidSrvFormat(content)); + } + } + RecordType::TLSA => { + let content = api_record.destination; + let parts: Vec<&str> = content.split_whitespace().collect(); + if parts.len() != 4 { + return Err(TryFromRecordError::InvalidTlsaFormat(content)); + } + + let usage = parts[0] + .parse::() + .map_err(TryFromRecordError::InvalidTlsaValue)?; + let selector = parts[1] + .parse::() + .map_err(TryFromRecordError::InvalidTlsaValue)?; + let matching_type = parts[2] + .parse::() + .map_err(TryFromRecordError::InvalidTlsaValue)?; + let cert_data = parts[3].to_string(); + + RecordValue::TLSA(usage, selector, matching_type, cert_data) + } + RecordType::CAA => { + let content = api_record.destination; + let parts: Vec<&str> = content.split_whitespace().collect(); + if parts.len() != 3 { + return Err(TryFromRecordError::InvalidCaaFormat(content)); + } + + let flag = parts[0] + .parse::() + .map_err(TryFromRecordError::InvalidCaaFlag)?; + let tag = parts[1].to_string(); + let value = parts[2].to_string(); + + RecordValue::CAA(flag, tag, value) + } + }; + + Ok(dns::Record { + domain: api_record.hostname, + value, + ttl: None, + }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(crate = "lum_libs::serde")] +pub struct GetRecordsResponse { + pub records: Vec, +} + +impl TryFrom for Vec { + type Error = TryFromRecordError; + + fn try_from(response: GetRecordsResponse) -> Result { + response + .records + .into_iter() + .map(dns::Record::try_from) + .collect() + } +} diff --git a/src/provider/nitrado/config.rs b/src/provider/nitrado/config.rs index f3fd7f1..a5a2ec1 100644 --- a/src/provider/nitrado/config.rs +++ b/src/provider/nitrado/config.rs @@ -1,14 +1,6 @@ -use std::{ - net::{Ipv4Addr, Ipv6Addr}, - str::FromStr, -}; - use lum_libs::serde::{Deserialize, Serialize}; -use crate::{ - config::dns::{AutomaticRecordConfig, RecordConfig, ResolveType}, - types::dns::{self, MxRecord, RecordValue}, -}; +use crate::config::dns::RecordConfig; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(crate = "lum_libs::serde")] @@ -46,73 +38,7 @@ impl Default for DnsConfig { fn default() -> Self { DnsConfig { provider_name: "Nitrado1".to_string(), - domains: vec![DomainConfig { - domain: "example.com".to_string(), - records: vec![ - RecordConfig::Manual(dns::Record { - domain: "ipv4".to_string(), - value: RecordValue::A(Ipv4Addr::from_str("127.0.0.1").unwrap()), - ttl: Some(3600), - }), - RecordConfig::Manual(dns::Record { - domain: "ipv6".to_string(), - value: RecordValue::AAAA(Ipv6Addr::from_str("::1").unwrap()), - ttl: Some(3600), - }), - RecordConfig::Manual(dns::Record { - domain: "forward".to_string(), - value: RecordValue::CNAME("example.com".to_string()), - ttl: Some(3600), - }), - RecordConfig::Manual(dns::Record { - domain: "@".to_string(), - value: RecordValue::TXT("v=spf1 include:example.com ~all".to_string()), - ttl: Some(3600), - }), - RecordConfig::Manual(dns::Record { - domain: "@".to_string(), - value: RecordValue::SPF("v=spf1 include:example.com ~all".to_string()), - ttl: Some(3600), - }), - RecordConfig::Manual(dns::Record { - domain: "@".to_string(), - value: RecordValue::MX(MxRecord { - priority: 10, - target: "mail.example.com".to_string(), - }), - ttl: Some(3600), - }), - RecordConfig::Manual(dns::Record { - domain: "@".to_string(), - value: RecordValue::SRV(10, 10, 10, "example.com".to_string()), - ttl: Some(3600), - }), - RecordConfig::Manual(dns::Record { - domain: "_443._tcp".to_string(), - value: RecordValue::TLSA(3, 1, 1, "abcdef0123456789".to_string()), - ttl: Some(3600), - }), - RecordConfig::Manual(dns::Record { - domain: "@".to_string(), - value: RecordValue::CAA( - 0, - "issue".to_string(), - "letsencrypt.org".to_string(), - ), - ttl: Some(3600), - }), - RecordConfig::Automatic(AutomaticRecordConfig { - domain: "auto-ipv4".to_string(), - ttl: Some(300), - resolve_type: ResolveType::IPv4, - }), - RecordConfig::Automatic(AutomaticRecordConfig { - domain: "auto-ipv6".to_string(), - ttl: Some(300), - resolve_type: ResolveType::IPv6, - }), - ], - }], + domains: vec![], } } } diff --git a/src/provider/nitrado/model.rs b/src/provider/nitrado/model.rs index d8e61b7..9ac4a2e 100644 --- a/src/provider/nitrado/model.rs +++ b/src/provider/nitrado/model.rs @@ -56,6 +56,9 @@ pub enum TryFromRecordError { #[error("Invalid CAA record flag: {0}")] InvalidCaaFlag(num::ParseIntError), + + #[error("Record type {0:?} is not supported by Nitrado provider")] + UnsupportedRecordType(RecordType), } impl TryFrom for dns::Record { @@ -74,6 +77,9 @@ impl TryFrom for dns::Record { RecordType::CNAME => RecordValue::CNAME(api_record.content), RecordType::TXT => RecordValue::TXT(api_record.content), RecordType::SPF => RecordValue::SPF(api_record.content), + RecordType::NS | RecordType::SOA => { + return Err(TryFromRecordError::UnsupportedRecordType(api_record.r#type)); + } RecordType::MX => { let content = api_record.content; let parts: Vec<&str> = content.split_whitespace().collect(); diff --git a/src/types/dns.rs b/src/types/dns.rs index aa74e4e..5e371ba 100644 --- a/src/types/dns.rs +++ b/src/types/dns.rs @@ -18,6 +18,8 @@ pub enum RecordValue { TXT(String), SPF(String), MX(MxRecord), + NS(String), + SOA(String), SRV(u16, u16, u16, String), TLSA(u16, u16, u16, String), CAA(u8, String, String), @@ -40,6 +42,8 @@ pub enum RecordType { TXT, SPF, MX, + NS, + SOA, SRV, TLSA, CAA,