From 3bc398610b7e5154d2ff516b9d2b2065147afc1c Mon Sep 17 00:00:00 2001 From: Ara Sadoyan Date: Sat, 3 Oct 2026 10:56:47 +0200 Subject: [PATCH] Intermediate commit fo DNS Challenge --- Cargo.lock | 53 +++++++++++++++++++++++++-- Cargo.toml | 1 + src/core/bgservice.rs | 4 ++- src/core/webserver.rs | 10 +++--- src/ingress/discovery.rs | 1 + src/tls/acme/account.rs | 4 +-- src/tls/acme/dns/cloudflare.rs | 65 ++++++++++++++++++++++++++++++++++ src/tls/acme/dns/mod.rs | 2 ++ src/tls/acme/dns/route53.rs | 25 +++++++++++++ src/tls/acme/http01.rs | 10 +++--- src/tls/acme/mod.rs | 2 ++ src/tls/acme/order.rs | 56 ++++++++++++++++++++++++----- src/tls/acme/types.rs | 23 ++++++++++++ src/utils/lazylock.rs | 3 +- src/utils/parceyaml.rs | 6 ++-- src/utils/types.rs | 1 + 16 files changed, 238 insertions(+), 28 deletions(-) create mode 100644 src/tls/acme/dns/cloudflare.rs create mode 100644 src/tls/acme/dns/mod.rs create mode 100644 src/tls/acme/dns/route53.rs create mode 100644 src/tls/acme/types.rs diff --git a/Cargo.lock b/Cargo.lock index 2497e93..49bdb48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -170,6 +170,7 @@ dependencies = [ "prometheus", "rand 0.10.2", "rcgen", + "reqwest", "rustls", "rustls-pemfile", "sd-notify", @@ -708,6 +709,16 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -1817,9 +1828,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -3615,15 +3628,19 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-core", + "h2", "http", "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-tls", "hyper-util", "js-sys", "log", + "mime", "native-tls", "percent-encoding", "pin-project-lite", @@ -3805,7 +3822,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "jni", "log", @@ -3950,7 +3967,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.13.0", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -4385,6 +4402,27 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tagptr" version = "0.2.0" @@ -5048,6 +5086,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" diff --git a/Cargo.toml b/Cargo.toml index 44d07f3..756fa47 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,4 +67,5 @@ anyhow = "1.0.103" inventory = "0.3.24" elasticsearch = "9.1.0-alpha.1" humantime = "2.4.0" +reqwest = "0.12.28" diff --git a/src/core/bgservice.rs b/src/core/bgservice.rs index bce0787..4e4bd23 100644 --- a/src/core/bgservice.rs +++ b/src/core/bgservice.rs @@ -72,6 +72,7 @@ impl BackgroundService for LB { file_server_folder: self.config.file_server_folder.clone(), current_upstreams: self.ump_upst.clone(), full_upstreams: self.ump_full.clone(), + acme_dns_provider: self.config.acme_dns_provider.clone(), }; tokio::spawn(async move { api_load.start(tx_api).await }); let uu = self.ump_upst.clone(); @@ -80,7 +81,8 @@ impl BackgroundService for LB { let (hc_method, hc_interval) = (self.config.hc_method.clone(), self.config.hc_interval); tokio::spawn(async move { healthcheck::hc2(uu, ff, im, (&*hc_method.to_string(), hc_interval.to_string().parse().unwrap())).await }); tokio::spawn(async move { calc_cache_metrics().await }); - tokio::spawn(async move { refresh_order(certdir, confdir).await }); + let acme_provider = self.config.acme_dns_provider.clone(); + tokio::spawn(async move { refresh_order(certdir, confdir, acme_provider).await }); init_access_logging(self.config.access_log.clone()).await; loop { diff --git a/src/core/webserver.rs b/src/core/webserver.rs index 7f63ac4..0a6ad5a 100644 --- a/src/core/webserver.rs +++ b/src/core/webserver.rs @@ -1,7 +1,7 @@ use crate::auth::jwt::Claims; use crate::ingress::APIUpstreamProvider; use crate::tls::acme::http01::{acme_create, acme_order, http01_challenge}; -use crate::utils::metrics::{get_memory_usage, get_open_files, MEMORY_USAGE, OPEN_FILES}; +use crate::utils::metrics::{MEMORY_USAGE, OPEN_FILES, get_memory_usage, get_open_files}; use crate::utils::tools::{upstreams_liveness_json, upstreams_to_json}; use crate::utils::types::{Config, Configuration, UpstreamsDashMap}; use axum::body::Body; @@ -10,9 +10,9 @@ use axum::http::{Response, StatusCode}; use axum::response::IntoResponse; use axum::routing::{any, get, post}; use axum::{Json, Router}; -use jsonwebtoken::{encode, EncodingKey, Header}; +use jsonwebtoken::{EncodingKey, Header, encode}; use log::{debug, error, info, warn}; -use prometheus::{gather, Encoder, TextEncoder}; +use prometheus::{Encoder, TextEncoder, gather}; use serde::Serialize; use signal_hook::{consts::SIGQUIT, iterator::Signals}; use std::collections::HashMap; @@ -32,6 +32,7 @@ struct OutToken { #[derive(Clone)] pub struct AppState { master_key: Option, + pub acme_dns_provider: Option, pub cert_creds: String, pub certs_dir: String, upstreams_file: String, @@ -53,6 +54,7 @@ pub async fn run_server(config: &APIUpstreamProvider, mut to_return: mpsc::Sende config_api_enabled: config.config_api_enabled, current_upstreams: upstreams_curr, full_upstreams: upstreams_full, + acme_dns_provider: config.acme_dns_provider.clone(), }; let app = Router::new() // .route("/{*wildcard}", get(senderror)) @@ -214,7 +216,7 @@ async fn status(State(st): State, Query(params): Query { return Response::builder() diff --git a/src/ingress/discovery.rs b/src/ingress/discovery.rs index 7629a35..b5daf32 100644 --- a/src/ingress/discovery.rs +++ b/src/ingress/discovery.rs @@ -12,6 +12,7 @@ pub struct APIUpstreamProvider { pub config_api_enabled: bool, pub address: String, pub masterkey: Option, + pub acme_dns_provider: Option, pub certs_dir: String, pub config_dir: String, pub upstreams_file: String, diff --git a/src/tls/acme/account.rs b/src/tls/acme/account.rs index 8b90375..11233c5 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 new file mode 100644 index 0000000..e3ff395 --- /dev/null +++ b/src/tls/acme/dns/cloudflare.rs @@ -0,0 +1,65 @@ +use crate::tls::acme::types::DnsProvider; +use reqwest::Client; +use serde_json::json; + +pub struct CloudflareProvider { + pub client: Client, + pub api_token: String, + pub zone_id: String, +} + +impl CloudflareProvider { + pub fn new() -> Self { + let api_token = std::env::var("CLOUDFLARE_API_TOKEN").expect("Environment variable CLOUDFLARE_API_TOKEN is required"); + let zone_id = std::env::var("CLOUDFLARE_ZONE_ID").expect("Environment variable CLOUDFLARE_ZONE_ID is required"); + + Self { + client: Client::new(), + api_token, + zone_id, + } + } +} + +#[async_trait::async_trait] +impl DnsProvider for CloudflareProvider { + async fn create_txt_record(&self, _domain: &str, name: &str, value: &str) -> Result> { + let url = format!("https://api.cloudflare.com/client/v4/zones/{}/dns_records", self.zone_id); + + let response = self + .client + .post(&url) + .bearer_auth(&self.api_token) + .json(&json!({ + "type": "TXT", + "name": name, + "content": value, + "ttl": 120, + })) + .send() + .await?; + + let res_json: serde_json::Value = response.json().await?; + if !res_json["success"].as_bool().unwrap_or(false) { + return Err(format!("Cloudflare API error: {:?}", res_json["errors"]).into()); + } + + let record_id = res_json["result"]["id"].as_str().ok_or("missing id")?.to_string(); + println!(" ====> Created DNS Record ID: {}", record_id); + Ok(record_id) + } + + async fn delete_txt_record(&self, record_id: &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?; + + let res_json: serde_json::Value = response.json().await?; + 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); + Ok(()) + } +} diff --git a/src/tls/acme/dns/mod.rs b/src/tls/acme/dns/mod.rs new file mode 100644 index 0000000..4dfee2b --- /dev/null +++ b/src/tls/acme/dns/mod.rs @@ -0,0 +1,2 @@ +pub mod cloudflare; +pub mod route53; diff --git a/src/tls/acme/dns/route53.rs b/src/tls/acme/dns/route53.rs new file mode 100644 index 0000000..cfa4e77 --- /dev/null +++ b/src/tls/acme/dns/route53.rs @@ -0,0 +1,25 @@ +use crate::tls::acme::types::DnsProvider; + +pub struct Route53Provider { + pub hosted_zone_id: String, +} + +impl Route53Provider { + pub fn new() -> Self { + Self { + hosted_zone_id: std::env::var("AWS_HOSTED_ZONE_ID").unwrap_or_default(), + } + } +} + +#[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); + Ok("route53_change_id".to_string()) + } + async fn delete_txt_record(&self, record_id: &str) -> Result<(), Box> { + println!(" ====> Route53 Deleted TXT Record {}", record_id); + Ok(()) + } +} diff --git a/src/tls/acme/http01.rs b/src/tls/acme/http01.rs index 6d8db8b..26f482c 100644 --- a/src/tls/acme/http01.rs +++ b/src/tls/acme/http01.rs @@ -14,32 +14,32 @@ pub async fn acme_create(State(state): State) -> impl IntoR .status(StatusCode::OK) .header("Content-Type", "text/plain") .body(Body::from(txt)) - .unwrap() + .unwrap(); } Err(e) => { return Response::builder() .status(StatusCode::INTERNAL_SERVER_ERROR) .body(Body::from(format!("Failed to create account: {}", e))) - .unwrap() + .unwrap(); } }; } #[allow(clippy::needless_return)] pub async fn acme_order(State(state): State, axum::extract::Path(domain): axum::extract::Path) -> impl IntoResponse { let domain_clean = domain.trim_matches('/'); - match order::order(domain_clean, state.cert_creds.as_str(), state.certs_dir).await { + match order::order(domain_clean, state.cert_creds.as_str(), state.certs_dir, state.acme_dns_provider).await { Ok(txt) => { return Response::builder() .status(StatusCode::OK) .header("Content-Type", "text/plain") .body(Body::from(txt)) - .unwrap() + .unwrap(); } Err(e) => { return Response::builder() .status(StatusCode::INTERNAL_SERVER_ERROR) .body(Body::from(format!("Failed to order a certificate: {}", e))) - .unwrap() + .unwrap(); } }; } diff --git a/src/tls/acme/mod.rs b/src/tls/acme/mod.rs index 523e3b7..adfabf7 100644 --- a/src/tls/acme/mod.rs +++ b/src/tls/acme/mod.rs @@ -1,6 +1,8 @@ pub mod account; +pub mod dns; pub mod http01; pub mod order; +pub mod types; pub use account::*; pub use order::*; diff --git a/src/tls/acme/order.rs b/src/tls/acme/order.rs index 90b8ab8..773787f 100644 --- a/src/tls/acme/order.rs +++ b/src/tls/acme/order.rs @@ -1,24 +1,30 @@ use crate::tls::acme::account::get_account; +use crate::tls::acme::types::get_provider; use crate::utils::lazylock::CHALLENGES; -use crate::utils::parceyaml::DOMAINS; +use crate::utils::lazylock::DOMAINS; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; use instant_acme::{ChallengeType, Identifier, NewOrder, RetryPolicy}; use log::{error, info}; use pingora::prelude::sleep; use rcgen::{CertificateParams, DistinguishedName, KeyPair}; +use sha2::{Digest, Sha256}; use std::fs; use std::time::Duration; use x509_parser::prelude::*; -pub async fn refresh_order(certs_dir: String, autoconf_dir: String) { +static DNS: bool = true; + +pub async fn refresh_order(certs_dir: String, autoconf_dir: String, provider: Option) { let credsfile = autoconf_dir + "/acme_credentials.json"; loop { for item in DOMAINS.iter() { - let _what = order(item.key(), credsfile.as_str(), certs_dir.clone()).await; + let _what = order(item.key(), credsfile.as_str(), certs_dir.clone(), provider.clone()).await; } sleep(Duration::from_secs(12 * 3600)).await; } } -pub async fn order(domain: &str, credsfile: &str, certs_dir: String) -> Result> { +pub async fn order(domain: &str, credsfile: &str, certs_dir: String, provider: Option) -> Result> { let crt = certs_dir.clone() + "/" + domain + ".crt"; let key = certs_dir.clone() + "/" + domain + ".key"; @@ -36,7 +42,6 @@ pub async fn order(domain: &str, credsfile: &str, certs_dir: String) -> Result now + 30 * 24 * 3600 { @@ -49,14 +54,42 @@ pub async fn order(domain: &str, credsfile: &str, certs_dir: String) -> Result { + 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; + } + Err(e) => { + eprintln!("Failed to create DNS record: {}", e); + return Err(e); + } + } + } else { + let token = key_auth_str.split('.').next().ok_or("invalid key authorization")?.to_string(); + CHALLENGES.write().unwrap().insert(token, key_auth_str); + challenge_handle.set_ready().await?; + } } let status = order.poll_ready(&RetryPolicy::default()).await?; @@ -86,3 +119,8 @@ fn cert_expiry(path: &str) -> Result> { let expiry = cert.validity().not_after.timestamp() as u64; Ok(expiry) } + +fn calculate_dns_value(key_auth: &str) -> String { + let hash = Sha256::digest(key_auth.as_bytes()); + URL_SAFE_NO_PAD.encode(hash) +} diff --git a/src/tls/acme/types.rs b/src/tls/acme/types.rs new file mode 100644 index 0000000..cb6ff57 --- /dev/null +++ b/src/tls/acme/types.rs @@ -0,0 +1,23 @@ +use crate::tls::acme::dns::cloudflare::CloudflareProvider; +use crate::tls::acme::dns::route53::Route53Provider; + +#[async_trait::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>; +} + +// 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!") +} diff --git a/src/utils/lazylock.rs b/src/utils/lazylock.rs index 44f55ae..fa2838f 100644 --- a/src/utils/lazylock.rs +++ b/src/utils/lazylock.rs @@ -1,8 +1,8 @@ use dashmap::DashMap; use moka::sync::Cache; +use pingora_cache::MemCache; use pingora_cache::eviction::simple_lru::Manager; use pingora_cache::lock::CacheLock; -use pingora_cache::MemCache; use pingora_limits::rate::Rate; use std::collections::HashMap; use std::net::IpAddr; @@ -19,3 +19,4 @@ pub static CACHE_LOCK: LazyLock = LazyLock::new(|| CacheLock::new(Dur pub static EVICTION: OnceLock = OnceLock::new(); pub static CACHE_TTL: OnceLock = OnceLock::new(); pub static CHALLENGES: LazyLock>> = LazyLock::new(|| RwLock::new(HashMap::new())); +pub static DOMAINS: LazyLock> = LazyLock::new(DashMap::new); diff --git a/src/utils/parceyaml.rs b/src/utils/parceyaml.rs index ef215e8..8fb1b89 100644 --- a/src/utils/parceyaml.rs +++ b/src/utils/parceyaml.rs @@ -1,5 +1,5 @@ use crate::utils::healthcheck; -use crate::utils::lazylock::REVERSE_STORE; +use crate::utils::lazylock::{DOMAINS, REVERSE_STORE}; use crate::utils::state::{is_first_run, mark_not_first_run}; use crate::utils::tools::{clone_dashmap, clone_dashmap_into, print_upstreams}; use crate::utils::types::*; @@ -7,12 +7,10 @@ use dashmap::DashMap; use log::{error, info, warn}; use std::collections::HashMap; use std::path::Path; +use std::sync::Arc; use std::sync::atomic::AtomicUsize; -use std::sync::{Arc, LazyLock}; use std::{env, fs}; -pub static DOMAINS: LazyLock> = LazyLock::new(DashMap::new); - pub async fn load_configuration(d: &str, kind: &str) -> (Option, String) { let mut conf_files = Vec::new(); let yaml_data = match kind { diff --git a/src/utils/types.rs b/src/utils/types.rs index 46733d3..901a72b 100644 --- a/src/utils/types.rs +++ b/src/utils/types.rs @@ -134,6 +134,7 @@ pub struct AppConfig { pub log_file: Option, pub log_pattern: Option, pub log_structured: Option, + pub acme_dns_provider: Option, pub tcp_keepalive_idle: Option, pub tcp_keepalive_interval: Option, pub tcp_keepalive_count: Option,