Compare commits

..
4 Commits
Author SHA1 Message Date
Ara Sadoyan a8dc98f636 cargo version 2026-10-05 19:07:32 +02:00
Ara Sadoyan dad9539b9c Fix provider separation 2026-10-05 19:06:44 +02:00
Ara Sadoyan ab5252ee52 Fixed bug with HTTP-01 Challenge 2026-10-05 18:41:39 +02:00
Ara Sadoyan 0f7f7b723b Added Route53 support. 2026-10-05 15:40:07 +02:00
5 changed files with 818 additions and 102 deletions
Generated
+695 -66
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "aralez" name = "aralez"
version = "0.94.2" version = "0.94.4"
edition = "2024" edition = "2024"
license = "Apache-2.0" license = "Apache-2.0"
description = "Reverse proxy built on top of Cloudflare's Pingora" description = "Reverse proxy built on top of Cloudflare's Pingora"
@@ -69,5 +69,5 @@ elasticsearch = "9.1.0-alpha.1"
humantime = "2.4.0" humantime = "2.4.0"
reqwest = "0.12.28" reqwest = "0.12.28"
hickory-resolver = { version = "0.26", features = ["tokio"] } hickory-resolver = { version = "0.26", features = ["tokio"] }
aws-sdk-route53 = "1.127.0"
aws-config = { version = "1.12.0", features = ["behavior-version-latest"] }
+99 -9
View File
@@ -1,26 +1,116 @@
// ------------------------ //
// This file is vibecoded !
// ------------------------ //
use crate::tls::acme::lookup::lookup_wait;
use crate::tls::acme::types::{DnsBackendPlugin, DnsProvider}; use crate::tls::acme::types::{DnsBackendPlugin, DnsProvider};
use aws_config::SdkConfig;
use aws_sdk_route53::Client;
use aws_sdk_route53::types::{Change, ChangeAction, ChangeBatch, ResourceRecord, ResourceRecordSet, RrType};
use log::info;
use tokio::runtime::Handle;
pub struct Route53Provider { pub struct Route53Provider {
pub client: Client,
pub hosted_zone_id: String, pub hosted_zone_id: String,
} }
impl Route53Provider { impl Route53Provider {
pub fn new() -> Self { pub fn new() -> Self {
Self { let hosted_zone_id = std::env::var("AWS_HOSTED_ZONE_ID").expect("Environment variable AWS_HOSTED_ZONE_ID is required");
hosted_zone_id: std::env::var("AWS_HOSTED_ZONE_ID").unwrap_or_default(), // Load AWS SDK config synchronously using Tokio runtime handle to fit factory construction
} let config: SdkConfig = if let Ok(handle) = Handle::try_current() {
tokio::task::block_in_place(|| handle.block_on(aws_config::load_from_env()))
} else {
tokio::runtime::Runtime::new().unwrap().block_on(aws_config::load_from_env())
};
let client = Client::new(&config);
Self { client, hosted_zone_id }
}
pub async fn get_authoritative_ns_ip(&self) -> Option<String> {
// Query Route53 for the zone's details (includes assigned Name Servers)
let response = self.client.get_hosted_zone().id(&self.hosted_zone_id).send().await.ok()?;
// Pick the first name server assigned to this zone (e.g., "ns-1289.awsdns-29.org")
let ns_domain = response.delegation_set()?.name_servers().first()?;
// Resolve that nameserver's domain name to an IP address
let ips = tokio::net::lookup_host(format!("{}:53", ns_domain)).await.ok()?;
let ip = ips.into_iter().next()?.ip().to_string();
info!("Targeting Route53 authoritative NS {} ({}) for polling", ns_domain, ip);
Some(ip)
} }
} }
// Not working, just a placeholder.
#[async_trait::async_trait] #[async_trait::async_trait]
impl DnsProvider for Route53Provider { impl DnsProvider for Route53Provider {
async fn create_txt_record(&self, _domain: &str, _name: &str, _value: &str) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { async fn create_txt_record(&self, _domain: &str, name: &str, value: &str) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
println!(" ====> Route53 Created TXT Record for {}: {}: {}", _name, _domain, _value); // AWS Route53 requires TXT strings to be wrapped in double quotes
Ok("route53_change_id".to_string()) let formatted_value = format!("\"{}\"", value);
let change = Change::builder()
.action(ChangeAction::Upsert)
.resource_record_set(
ResourceRecordSet::builder()
.name(name)
.r#type(RrType::Txt)
.ttl(60)
.resource_records(ResourceRecord::builder().value(formatted_value).build()?)
.build()?,
)
.build()?;
let change_batch = ChangeBatch::builder().changes(change).build()?;
let response = self
.client
.change_resource_record_sets()
.hosted_zone_id(&self.hosted_zone_id)
.change_batch(change_batch)
.send()
.await?;
let record_id = response.change_info().ok_or("Missing ChangeInfo in Route53 response")?.id().to_string();
info!("Created Route53 TXT record name: {}, change_id: {}", name, record_id);
let target_dns_ip = self.get_authoritative_ns_ip().await;
lookup_wait(name, target_dns_ip.as_deref(), 60, value).await;
Ok(record_id)
} }
async fn delete_txt_record(&self, _record_id: &str, _record_name: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
println!(" ====> Route53 Deleted TXT Record for {}: {}", _record_name, _record_id); async fn delete_txt_record(&self, _record_id: &str, record_name: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Since Route53 requires record value context to delete, we look up or pass existing TXT record values.
// For ACME cleanup, issuing a DELETE with wildcards/upsert strategy or direct deletion:
let list_res = self
.client
.list_resource_record_sets()
.hosted_zone_id(&self.hosted_zone_id)
.start_record_name(record_name)
.start_record_type(RrType::Txt)
.max_items(1)
.send()
.await?;
if let Some(record_set) = list_res.resource_record_sets().first() {
if record_set.name().trim_end_matches('.') == record_name.trim_end_matches('.') {
let change = Change::builder().action(ChangeAction::Delete).resource_record_set(record_set.clone()).build()?;
let change_batch = ChangeBatch::builder().changes(change).build()?;
self.client
.change_resource_record_sets()
.hosted_zone_id(&self.hosted_zone_id)
.change_batch(change_batch)
.send()
.await?;
info!("Deleted Route53 TXT record name: {}", record_name);
return Ok(());
}
}
info!("Route53 TXT record name: {} already deleted or not found", record_name);
Ok(()) Ok(())
} }
} }
+15 -18
View File
@@ -13,8 +13,6 @@ use std::fs;
use std::time::Duration; use std::time::Duration;
use x509_parser::prelude::*; use x509_parser::prelude::*;
static DNS: bool = true;
pub async fn refresh_order(certs_dir: String, autoconf_dir: String, provider: Option<String>) { pub async fn refresh_order(certs_dir: String, autoconf_dir: String, provider: Option<String>) {
let credsfile = autoconf_dir + "/acme_credentials.json"; let credsfile = autoconf_dir + "/acme_credentials.json";
loop { loop {
@@ -25,15 +23,12 @@ pub async fn refresh_order(certs_dir: String, autoconf_dir: String, provider: Op
} }
} }
pub async fn order(domain: &str, credsfile: &str, certs_dir: String, provider: Option<String>) -> Result<String, Box<dyn std::error::Error>> { pub async fn order(domain: &str, credsfile: &str, certs_dir: String, provider: Option<String>) -> Result<String, Box<dyn std::error::Error>> {
let crt = certs_dir.clone() + "/" + domain + ".crt"; let crt = format!("{}/{}.crt", certs_dir, domain);
let key = certs_dir.clone() + "/" + domain + ".key"; let key = format!("{}/{}.key", certs_dir, domain);
if DOMAINS.get(domain).is_none() { if DOMAINS.get(domain).is_none() {
DOMAINS.insert(domain.to_string(), true); DOMAINS.insert(domain.to_string(), true);
let mut newlist: Vec<String> = Vec::new(); let newlist: Vec<String> = DOMAINS.iter().map(|item| item.key().to_string()).collect();
for item in DOMAINS.iter() {
newlist.push(item.key().to_string());
}
if let Ok(json_content) = serde_json::to_string_pretty(&newlist) { if let Ok(json_content) = serde_json::to_string_pretty(&newlist) {
let autocfg_file = credsfile.replace("/acme_credentials.json", "/domains.json"); let autocfg_file = credsfile.replace("/acme_credentials.json", "/domains.json");
if let Err(err) = std::fs::write(&autocfg_file, json_content) { if let Err(err) = std::fs::write(&autocfg_file, json_content) {
@@ -42,10 +37,10 @@ pub async fn order(domain: &str, credsfile: &str, certs_dir: String, provider: O
} }
} }
} }
if let Ok(expiry) = cert_expiry(crt.as_str()) { if let Ok(expiry) = cert_expiry(crt.as_str()) {
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs(); let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs();
if expiry > now + 30 * 24 * 3600 { if expiry > now + 30 * 24 * 3600 {
// println!("Fresh certificate exists. Not renewing !");
return Ok("Fresh certificate exists. Not renewing ! \n".to_string()); return Ok("Fresh certificate exists. Not renewing ! \n".to_string());
} }
}; };
@@ -57,17 +52,21 @@ pub async fn order(domain: &str, credsfile: &str, certs_dir: String, provider: O
while let Some(auth) = authorizations.next().await { while let Some(auth) = authorizations.next().await {
let mut auth = auth?; let mut auth = auth?;
let domain = auth.identifier().to_string(); let domain_name = auth.identifier().to_string();
let challenge_type = if DNS { ChallengeType::Dns01 } else { ChallengeType::Http01 }; let (challenge_type, is_dns) = match &provider {
Some(_) => (ChallengeType::Dns01, true),
None => (ChallengeType::Http01, false),
};
let mut challenge_handle = auth.challenge(challenge_type).ok_or("No challenge found")?; let mut challenge_handle = auth.challenge(challenge_type).ok_or("Requested challenge type not offered by ACME server")?;
let key_auth = challenge_handle.key_authorization(); let key_auth = challenge_handle.key_authorization();
let key_auth_str = key_auth.as_str().to_string(); let key_auth_str = key_auth.as_str().to_string();
if let Some(prov) = provider.clone() { if is_dns {
let prov = provider.as_ref().unwrap();
let dns_provider = get_provider(prov.clone()); let dns_provider = get_provider(prov.clone());
let dns_value = calculate_dns_value(&key_auth_str); let dns_value = calculate_dns_value(&key_auth_str);
let clean_domain = domain.trim_start_matches("*.").to_string(); let clean_domain = domain_name.trim_start_matches("*.").to_string();
let record_name = format!("_acme-challenge.{}", clean_domain); let record_name = format!("_acme-challenge.{}", clean_domain);
match dns_provider.create_txt_record(&clean_domain, &record_name, &dns_value).await { match dns_provider.create_txt_record(&clean_domain, &record_name, &dns_value).await {
@@ -82,8 +81,9 @@ pub async fn order(domain: &str, credsfile: &str, certs_dir: String, provider: O
} }
} }
} else { } else {
let token = key_auth_str.split('.').next().ok_or("invalid key authorization")?.to_string(); let token = key_auth_str.split('.').next().ok_or("Invalid key authorization")?.to_string();
CHALLENGES.write().unwrap().insert(token, key_auth_str); CHALLENGES.write().unwrap().insert(token, key_auth_str);
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
challenge_handle.set_ready().await?; challenge_handle.set_ready().await?;
} }
} }
@@ -97,12 +97,9 @@ pub async fn order(domain: &str, credsfile: &str, certs_dir: String, provider: O
let signing_request = params.serialize_request(&private_key)?; let signing_request = params.serialize_request(&private_key)?;
let csr_der = signing_request.der(); let csr_der = signing_request.der();
order.finalize_csr(csr_der).await?; order.finalize_csr(csr_der).await?;
// poll for certificate
let cert_chain_pem = order.poll_certificate(&RetryPolicy::default()).await?; let cert_chain_pem = order.poll_certificate(&RetryPolicy::default()).await?;
CHALLENGES.write().unwrap().clear(); CHALLENGES.write().unwrap().clear();
let private_key_pem = private_key.serialize_pem(); let private_key_pem = private_key.serialize_pem();
fs::write(crt, cert_chain_pem)?; fs::write(crt, cert_chain_pem)?;
fs::write(key, private_key_pem)?; fs::write(key, private_key_pem)?;
Ok("Certificate is successfully generated \n".to_string()) Ok("Certificate is successfully generated \n".to_string())
+6 -6
View File
@@ -9,10 +9,10 @@ pub struct DnsBackendPlugin {
inventory::collect!(DnsBackendPlugin); inventory::collect!(DnsBackendPlugin);
static PROVIDERS: LazyLock<HashMap<&'static str, Box<dyn DnsProvider>>> = LazyLock::new(|| { static FACTORIES: LazyLock<HashMap<&'static str, fn() -> Box<dyn DnsProvider>>> = LazyLock::new(|| {
let mut map = HashMap::new(); let mut map = HashMap::new();
for plugin in inventory::iter::<DnsBackendPlugin> { for plugin in inventory::iter::<DnsBackendPlugin> {
map.insert(plugin.name, (plugin.factory)()); map.insert(plugin.name, plugin.factory);
} }
map map
}); });
@@ -24,9 +24,9 @@ pub trait DnsProvider: Send + Sync {
async fn delete_txt_record(&self, record_id: &str, record_name: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>>; async fn delete_txt_record(&self, record_id: &str, record_name: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>>;
} }
pub fn get_provider(provider: String) -> &'static dyn DnsProvider { pub fn get_provider(provider: String) -> Box<dyn DnsProvider> {
PROVIDERS FACTORIES
.get(provider.as_str()) .get(provider.as_str())
.map(|boxed| boxed.as_ref()) .map(|factory| factory())
.unwrap_or_else(|| panic!("Unknown DNS provider '{}'. Available providers: {:?}", provider, PROVIDERS.keys().collect::<Vec<_>>())) .unwrap_or_else(|| panic!("Unknown DNS provider '{}'. Available providers: {:?}", provider, FACTORIES.keys().collect::<Vec<_>>()))
} }