diff --git a/Cargo.lock b/Cargo.lock index 49bdb48..5dfbbb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -143,6 +143,7 @@ dependencies = [ "dashmap 7.0.0-rc2", "elasticsearch", "futures", + "hickory-resolver", "http-body-util", "humantime", "hyper", @@ -762,6 +763,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.16" @@ -1646,6 +1653,76 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hickory-net" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c480823ed7c2c5d0f09c41020cb6b7c28029ce60ec42dc942158dcf22f8e0a4d" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "hickory-proto", + "idna", + "ipnet", + "jni", + "rand 0.10.2", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12b92608f679a6fa515dd1d15c1ff89443026e391200a2c840c7afcba482893d" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "prefix-trie", + "rand 0.10.2", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3da5255c95d5a716857d54b5b8f4e8d67c3484d3beaaaae2ce25063b3ba981" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand 0.10.2", + "resolv-conf", + "smallvec", + "system-configuration", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "hkdf" version = "0.12.4" @@ -2047,11 +2124,27 @@ dependencies = [ "rustversion", ] +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + [[package]] name = "ipnet" version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" +dependencies = [ + "serde", +] [[package]] name = "is_terminal_polyfill" @@ -2617,6 +2710,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "nix" version = "0.30.1" @@ -2809,6 +2908,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -3387,6 +3490,17 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -3660,6 +3774,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + [[package]] name = "rfc6979" version = "0.4.0" @@ -5014,6 +5134,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 756fa47..bb66a22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,4 +68,6 @@ inventory = "0.3.24" elasticsearch = "9.1.0-alpha.1" humantime = "2.4.0" reqwest = "0.12.28" +hickory-resolver = { version = "0.26", features = ["tokio"] } + diff --git a/src/tls/acme/account.rs b/src/tls/acme/account.rs index 11233c5..8b90375 100644 --- a/src/tls/acme/account.rs +++ b/src/tls/acme/account.rs @@ -30,8 +30,8 @@ async fn create_account(file: &str) -> Result<(), Box> { only_return_existing: false, }; let acc_builder = Account::builder()?; - // let (account, credentials) = acc_builder.create(&new_account, LetsEncrypt::Production.url().to_string(), None).await?; - let (account, credentials) = acc_builder.create(&new_account, LetsEncrypt::Staging.url().to_string(), None).await?; + let (account, credentials) = acc_builder.create(&new_account, LetsEncrypt::Production.url().to_string(), None).await?; + // let (account, credentials) = acc_builder.create(&new_account, LetsEncrypt::Staging.url().to_string(), None).await?; info!("Account created: {:?}", account.id()); save_credentials(&credentials, file)?; let _ = ACCOUNT.set(account); diff --git a/src/tls/acme/dns/cloudflare.rs b/src/tls/acme/dns/cloudflare.rs index e3ff395..8bb7bb7 100644 --- a/src/tls/acme/dns/cloudflare.rs +++ b/src/tls/acme/dns/cloudflare.rs @@ -1,4 +1,6 @@ -use crate::tls::acme::types::DnsProvider; +use crate::tls::acme::lookup::lookup_wait; +use crate::tls::acme::types::{DnsBackendPlugin, DnsProvider}; +use log::info; use reqwest::Client; use serde_json::json; @@ -45,11 +47,12 @@ impl DnsProvider for CloudflareProvider { } let record_id = res_json["result"]["id"].as_str().ok_or("missing id")?.to_string(); - println!(" ====> Created DNS Record ID: {}", record_id); + info!("Created TXT record name: {}, id: {}", name, record_id); + lookup_wait(name, "1.1.1.1", 60).await; Ok(record_id) } - async fn delete_txt_record(&self, record_id: &str) -> Result<(), Box> { + async fn delete_txt_record(&self, record_id: &str, record_name: &str) -> Result<(), Box> { let url = format!("https://api.cloudflare.com/client/v4/zones/{}/dns_records/{}", self.zone_id, record_id); let response = self.client.delete(&url).bearer_auth(&self.api_token).send().await?; @@ -58,8 +61,14 @@ impl DnsProvider for CloudflareProvider { if !res_json["success"].as_bool().unwrap_or(false) { return Err(format!("Cloudflare API error deleting record: {:?}", res_json["errors"]).into()); } - - println!(" ====> Successfully deleted DNS record: {}", record_id); + info!("Deleted TXT record name: {}, id: {}", record_name, record_id); Ok(()) } } + +inventory::submit! { + DnsBackendPlugin { + name: "cloudflare", + factory: || Box::new(CloudflareProvider::new()), + } +} diff --git a/src/tls/acme/dns/route53.rs b/src/tls/acme/dns/route53.rs index cfa4e77..ae13339 100644 --- a/src/tls/acme/dns/route53.rs +++ b/src/tls/acme/dns/route53.rs @@ -1,4 +1,4 @@ -use crate::tls::acme::types::DnsProvider; +use crate::tls::acme::types::{DnsBackendPlugin, DnsProvider}; pub struct Route53Provider { pub hosted_zone_id: String, @@ -12,14 +12,22 @@ impl Route53Provider { } } +// Not working, just a placeholder. #[async_trait::async_trait] impl DnsProvider for Route53Provider { - async fn create_txt_record(&self, domain: &str, name: &str, value: &str) -> Result> { - println!(" ====> Route53 Created TXT Record for {}: {}: {}", name, domain, value); + async fn create_txt_record(&self, _domain: &str, _name: &str, _value: &str) -> Result> { + println!(" ====> Route53 Created TXT Record for {}: {}: {}", _name, _domain, _value); Ok("route53_change_id".to_string()) } - async fn delete_txt_record(&self, record_id: &str) -> Result<(), Box> { - println!(" ====> Route53 Deleted TXT Record {}", record_id); + async fn delete_txt_record(&self, _record_id: &str, _record_name: &str) -> Result<(), Box> { + println!(" ====> Route53 Deleted TXT Record for {}: {}", _record_name, _record_id); Ok(()) } } + +inventory::submit! { + DnsBackendPlugin { + name: "route53", + factory: || Box::new(Route53Provider::new()), + } +} diff --git a/src/tls/acme/lookup.rs b/src/tls/acme/lookup.rs new file mode 100644 index 0000000..45160bb --- /dev/null +++ b/src/tls/acme/lookup.rs @@ -0,0 +1,47 @@ +use hickory_resolver::TokioResolver; +use hickory_resolver::config::{NameServerConfig, ResolverConfig, ResolverOpts}; +use hickory_resolver::net::runtime::TokioRuntimeProvider; +use hickory_resolver::proto::rr::RData; +use log::info; +use std::net::IpAddr; +use std::str::FromStr; +use std::time::Duration; + +pub async fn lookup_wait(record_name: &str, dns_server_ip: &str, max_wait_secs: u64) -> bool { + let ip = IpAddr::from_str(dns_server_ip).unwrap_or_else(|_| IpAddr::from([1, 1, 1, 1])); + let name_server = NameServerConfig::udp(ip); + let config = ResolverConfig::from_name_servers(vec![name_server]); + let mut opts = ResolverOpts::default(); + opts.cache_size = 0; + + let resolver = TokioResolver::builder_with_config(config, TokioRuntimeProvider::default()) + .with_options(opts) + .build() + .expect("failed to build DNS resolver"); + + let start = std::time::Instant::now(); + let poll_interval = Duration::from_secs(3); + while start.elapsed().as_secs() < max_wait_secs { + match resolver.txt_lookup(record_name).await { + Ok(txt_lookup) => { + for record in txt_lookup.answers() { + if let RData::TXT(txt) = &record.data { + for data in &txt.txt_data { + if let Ok(txt_str) = std::str::from_utf8(data) { + info!("DNS record propagated successfully for {} : {}", record_name, txt_str); + return true; + } + } + } + } + } + Err(_) => { + info!("Waiting for TXT record {}", record_name); + } + } + + tokio::time::sleep(poll_interval).await; + } + + false +} diff --git a/src/tls/acme/mod.rs b/src/tls/acme/mod.rs index adfabf7..a7e1347 100644 --- a/src/tls/acme/mod.rs +++ b/src/tls/acme/mod.rs @@ -1,6 +1,7 @@ pub mod account; pub mod dns; pub mod http01; +pub mod lookup; pub mod order; pub mod types; diff --git a/src/tls/acme/order.rs b/src/tls/acme/order.rs index 773787f..19e9443 100644 --- a/src/tls/acme/order.rs +++ b/src/tls/acme/order.rs @@ -54,9 +54,6 @@ pub async fn order(domain: &str, credsfile: &str, certs_dir: String, provider: O let mut order = account.new_order(&NewOrder::new(&[Identifier::Dns(domain.to_string())])).await?; let mut authorizations = order.authorizations(); - // let dns_provider = CloudflareProvider::new(); - - let dns_provider = get_provider(); while let Some(auth) = authorizations.next().await { let mut auth = auth?; @@ -68,17 +65,16 @@ pub async fn order(domain: &str, credsfile: &str, certs_dir: String, provider: O let key_auth_str = key_auth.as_str().to_string(); if let Some(prov) = provider.clone() { - println!("============================== {:?}", prov); + let dns_provider = get_provider(prov.clone()); let dns_value = calculate_dns_value(&key_auth_str); let clean_domain = domain.trim_start_matches("*.").to_string(); let record_name = format!("_acme-challenge.{}", clean_domain); - // Works identically for Cloudflare, Route53, or any future provider! + match dns_provider.create_txt_record(&clean_domain, &record_name, &dns_value).await { Ok(record_id) => { - info!("Waiting 30 seconds for DNS propagation: {}", record_name); - tokio::time::sleep(tokio::time::Duration::from_secs(30)).await; - challenge_handle.set_ready().await?; - let _ = dns_provider.delete_txt_record(&record_id).await; + let ready_res = challenge_handle.set_ready().await; + let _ = dns_provider.delete_txt_record(&record_id, &record_name).await; + ready_res?; } Err(e) => { eprintln!("Failed to create DNS record: {}", e); diff --git a/src/tls/acme/types.rs b/src/tls/acme/types.rs index cb6ff57..199abf1 100644 --- a/src/tls/acme/types.rs +++ b/src/tls/acme/types.rs @@ -1,23 +1,32 @@ -use crate::tls::acme::dns::cloudflare::CloudflareProvider; -use crate::tls::acme::dns::route53::Route53Provider; +use async_trait::async_trait; +use std::collections::HashMap; +use std::sync::LazyLock; -#[async_trait::async_trait] +pub struct DnsBackendPlugin { + pub name: &'static str, + pub factory: fn() -> Box, +} + +inventory::collect!(DnsBackendPlugin); + +static PROVIDERS: LazyLock>> = LazyLock::new(|| { + let mut map = HashMap::new(); + for plugin in inventory::iter:: { + map.insert(plugin.name, (plugin.factory)()); + } + map +}); + +#[async_trait] pub trait DnsProvider: Send + Sync { async fn create_txt_record(&self, domain: &str, name: &str, value: &str) -> Result>; - async fn delete_txt_record(&self, record_id: &str) -> Result<(), Box>; + async fn delete_txt_record(&self, record_id: &str, record_name: &str) -> Result<(), Box>; } -// pub enum ProviderType { -// Cloudflare, -// Route53, -// } - -pub fn get_provider() -> Box { - let provider_choice: Option> = match std::env::var("DNS_PROVIDER").as_deref() { - Ok("cloudflare") => Some(Box::new(CloudflareProvider::new())), - Ok("route53") => Some(Box::new(Route53Provider::new())), - _ => None, - }; - provider_choice.expect("No provider choice provided!") +pub fn get_provider(provider: String) -> &'static dyn DnsProvider { + PROVIDERS + .get(provider.as_str()) + .map(|boxed| boxed.as_ref()) + .unwrap_or_else(|| panic!("Unknown DNS provider '{}'. Available providers: {:?}", provider, PROVIDERS.keys().collect::>())) }