use anyhow::Result;
use std::path::PathBuf;
use tracing::{debug, trace};
use zentinel_common::types::{TlsVersion, TraceIdFormat};
use crate::server::{
default_acme_storage, default_graceful_shutdown_timeout, default_keepalive_timeout,
default_max_concurrent_streams, default_max_connections, default_renewal_days,
default_request_timeout, default_worker_threads, AcmeChallengeType, AcmeConfig,
DnsProviderConfig, DnsProviderType, ListenerConfig, ListenerProtocol, PropagationCheckConfig,
ServerConfig, SniCertificate, TlsConfig,
};
use super::helpers::{get_bool_entry, get_first_arg_string, get_int_entry, get_string_entry};
pub fn parse_server_config(node: &kdl::KdlNode) -> Result<ServerConfig> {
trace!("Parsing server configuration block");
let trace_id_format = get_string_entry(node, "trace-id-format")
.map(|s| TraceIdFormat::from_str_loose(&s))
.unwrap_or_default();
let config = ServerConfig {
worker_threads: get_int_entry(node, "worker-threads")
.map(|v| v as usize)
.unwrap_or_else(default_worker_threads),
max_connections: get_int_entry(node, "max-connections")
.map(|v| v as usize)
.unwrap_or_else(default_max_connections),
graceful_shutdown_timeout_secs: get_int_entry(node, "graceful-shutdown-timeout-secs")
.map(|v| v as u64)
.unwrap_or_else(default_graceful_shutdown_timeout),
daemon: get_bool_entry(node, "daemon").unwrap_or(false),
pid_file: get_string_entry(node, "pid-file").map(PathBuf::from),
user: get_string_entry(node, "user"),
group: get_string_entry(node, "group"),
working_directory: get_string_entry(node, "working-directory").map(PathBuf::from),
trace_id_format,
auto_reload: get_bool_entry(node, "auto-reload").unwrap_or(false),
};
trace!(
worker_threads = config.worker_threads,
max_connections = config.max_connections,
daemon = config.daemon,
auto_reload = config.auto_reload,
"Parsed server configuration"
);
Ok(config)
}
pub fn parse_listeners(node: &kdl::KdlNode) -> Result<Vec<ListenerConfig>> {
trace!("Parsing listeners configuration block");
let mut listeners = Vec::new();
if let Some(children) = node.children() {
for child in children.nodes() {
if child.name().value() == "listener" {
let id = get_first_arg_string(child).ok_or_else(|| {
anyhow::anyhow!(
"Listener requires an ID argument, e.g., listener \"http\" {{ ... }}"
)
})?;
trace!(listener_id = %id, "Parsing listener");
let address = get_string_entry(child, "address").ok_or_else(|| {
anyhow::anyhow!(
"Listener '{}' requires an 'address' field, e.g., address \"0.0.0.0:8080\"",
id
)
})?;
let protocol_str =
get_string_entry(child, "protocol").unwrap_or_else(|| "http".to_string());
let protocol = match protocol_str.to_lowercase().as_str() {
"http" => ListenerProtocol::Http,
"https" => ListenerProtocol::Https,
"h2" => ListenerProtocol::Http2,
"h3" => ListenerProtocol::Http3,
other => {
return Err(anyhow::anyhow!(
"Invalid protocol '{}' for listener '{}'. Valid protocols: http, https, h2, h3",
other,
id
));
}
};
let tls = if let Some(children) = child.children() {
children
.nodes()
.iter()
.find(|n| n.name().value() == "tls")
.map(|tls_node| parse_tls_config(tls_node, &id))
.transpose()?
} else {
None
};
trace!(
listener_id = %id,
address = %address,
protocol = ?protocol,
has_tls = tls.is_some(),
"Parsed listener"
);
listeners.push(ListenerConfig {
id,
address,
protocol,
tls,
default_route: get_string_entry(child, "default-route"),
request_timeout_secs: get_int_entry(child, "request-timeout-secs")
.map(|v| v as u64)
.unwrap_or_else(default_request_timeout),
keepalive_timeout_secs: get_int_entry(child, "keepalive-timeout-secs")
.map(|v| v as u64)
.unwrap_or_else(default_keepalive_timeout),
max_concurrent_streams: get_int_entry(child, "max-concurrent-streams")
.map(|v| v as u32)
.unwrap_or_else(default_max_concurrent_streams),
keepalive_max_requests: get_int_entry(child, "keepalive-max-requests")
.map(|v| v as u32),
});
}
}
}
trace!(
listener_count = listeners.len(),
"Finished parsing listeners"
);
Ok(listeners)
}
pub fn parse_tls_config(node: &kdl::KdlNode, listener_id: &str) -> Result<TlsConfig> {
debug!(listener_id = %listener_id, "Parsing TLS configuration");
let acme = if let Some(children) = node.children() {
children
.nodes()
.iter()
.find(|n| n.name().value() == "acme")
.map(|acme_node| parse_acme_config(acme_node, listener_id))
.transpose()?
} else {
None
};
let cert_file = get_string_entry(node, "cert-file").map(PathBuf::from);
let key_file = get_string_entry(node, "key-file").map(PathBuf::from);
if acme.is_none() && (cert_file.is_none() || key_file.is_none()) {
return Err(anyhow::anyhow!(
"TLS configuration for listener '{}' requires either 'cert-file' and 'key-file', or an 'acme' block",
listener_id
));
}
let ca_file = get_string_entry(node, "ca-file").map(PathBuf::from);
let min_version = get_string_entry(node, "min-version")
.map(|s| parse_tls_version(&s))
.unwrap_or(TlsVersion::Tls12);
let max_version = get_string_entry(node, "max-version").map(|s| parse_tls_version(&s));
let client_auth = get_bool_entry(node, "client-auth").unwrap_or(false);
let ocsp_stapling = get_bool_entry(node, "ocsp-stapling").unwrap_or(true);
let session_resumption = get_bool_entry(node, "session-resumption").unwrap_or(true);
let cipher_suites = if let Some(children) = node.children() {
children
.nodes()
.iter()
.filter(|n| n.name().value() == "cipher-suite")
.filter_map(get_first_arg_string)
.collect()
} else {
Vec::new()
};
let additional_certs = if let Some(children) = node.children() {
children
.nodes()
.iter()
.filter(|n| n.name().value() == "sni")
.map(|sni_node| parse_sni_certificate(sni_node, listener_id))
.collect::<Result<Vec<_>>>()?
} else {
Vec::new()
};
debug!(
listener_id = %listener_id,
has_cert_file = cert_file.is_some(),
has_acme = acme.is_some(),
has_ca = ca_file.is_some(),
client_auth = client_auth,
sni_cert_count = additional_certs.len(),
"Parsed TLS configuration"
);
Ok(TlsConfig {
cert_file,
key_file,
additional_certs,
ca_file,
min_version,
max_version,
cipher_suites,
client_auth,
ocsp_stapling,
session_resumption,
acme,
})
}
fn parse_acme_config(node: &kdl::KdlNode, listener_id: &str) -> Result<AcmeConfig> {
debug!(listener_id = %listener_id, "Parsing ACME configuration");
let email = get_string_entry(node, "email").ok_or_else(|| {
anyhow::anyhow!(
"ACME configuration for listener '{}' requires 'email'",
listener_id
)
})?;
let domains: Vec<String> = if let Some(children) = node.children() {
children
.nodes()
.iter()
.filter(|n| n.name().value() == "domains")
.flat_map(|n| {
n.entries()
.iter()
.filter_map(|e| e.value().as_string().map(|s| s.to_string()))
})
.collect()
} else {
Vec::new()
};
if domains.is_empty() {
return Err(anyhow::anyhow!(
"ACME configuration for listener '{}' requires at least one domain in 'domains'",
listener_id
));
}
let staging = get_bool_entry(node, "staging").unwrap_or(false);
let storage = get_string_entry(node, "storage")
.map(PathBuf::from)
.unwrap_or_else(default_acme_storage);
let renew_before_days = get_int_entry(node, "renew-before-days")
.map(|v| v as u32)
.unwrap_or_else(default_renewal_days);
let challenge_type = get_string_entry(node, "challenge-type")
.map(|s| parse_challenge_type(&s))
.unwrap_or_default();
let dns_provider = if let Some(children) = node.children() {
children
.nodes()
.iter()
.find(|n| n.name().value() == "dns-provider")
.map(|dns_node| parse_dns_provider_config(dns_node, listener_id))
.transpose()?
} else {
None
};
if challenge_type.is_dns01() && dns_provider.is_none() {
return Err(anyhow::anyhow!(
"ACME configuration for listener '{}' uses DNS-01 challenge but no 'dns-provider' is configured",
listener_id
));
}
let has_wildcard = domains.iter().any(|d| d.starts_with("*."));
if has_wildcard && !challenge_type.is_dns01() {
return Err(anyhow::anyhow!(
"ACME configuration for listener '{}' has wildcard domain(s) but uses HTTP-01 challenge. \
Wildcard domains require 'challenge-type \"dns-01\"'",
listener_id
));
}
debug!(
listener_id = %listener_id,
email = %email,
domain_count = domains.len(),
staging = staging,
storage = %storage.display(),
renew_before_days = renew_before_days,
challenge_type = ?challenge_type,
has_dns_provider = dns_provider.is_some(),
"Parsed ACME configuration"
);
Ok(AcmeConfig {
email,
domains,
staging,
storage,
renew_before_days,
challenge_type,
dns_provider,
})
}
fn parse_challenge_type(s: &str) -> AcmeChallengeType {
match s.to_lowercase().as_str() {
"dns-01" | "dns01" | "dns" => AcmeChallengeType::Dns01,
_ => AcmeChallengeType::Http01, }
}
fn parse_dns_provider_config(node: &kdl::KdlNode, listener_id: &str) -> Result<DnsProviderConfig> {
debug!(listener_id = %listener_id, "Parsing DNS provider configuration");
let provider_type = get_string_entry(node, "type").ok_or_else(|| {
anyhow::anyhow!(
"DNS provider configuration for listener '{}' requires 'type'",
listener_id
)
})?;
let provider = parse_dns_provider_type(&provider_type, node, listener_id)?;
let credentials_file = get_string_entry(node, "credentials-file").map(PathBuf::from);
let credentials_env = get_string_entry(node, "credentials-env");
if credentials_file.is_none() && credentials_env.is_none() {
return Err(anyhow::anyhow!(
"DNS provider configuration for listener '{}' requires either 'credentials-file' or 'credentials-env'",
listener_id
));
}
let api_timeout_secs = get_int_entry(node, "api-timeout-secs")
.map(|v| v as u64)
.unwrap_or(30);
let propagation = if let Some(children) = node.children() {
children
.nodes()
.iter()
.find(|n| n.name().value() == "propagation")
.map(parse_propagation_config)
.unwrap_or_default()
} else {
PropagationCheckConfig::default()
};
debug!(
listener_id = %listener_id,
provider_type = %provider_type,
has_credentials_file = credentials_file.is_some(),
has_credentials_env = credentials_env.is_some(),
api_timeout_secs = api_timeout_secs,
"Parsed DNS provider configuration"
);
Ok(DnsProviderConfig {
provider,
credentials_file,
credentials_env,
api_timeout_secs,
propagation,
})
}
fn parse_dns_provider_type(
type_str: &str,
node: &kdl::KdlNode,
listener_id: &str,
) -> Result<DnsProviderType> {
match type_str.to_lowercase().as_str() {
"hetzner" => Ok(DnsProviderType::Hetzner),
"webhook" => {
let url = get_string_entry(node, "url").ok_or_else(|| {
anyhow::anyhow!(
"DNS provider 'webhook' for listener '{}' requires 'url'",
listener_id
)
})?;
let auth_header = get_string_entry(node, "auth-header");
Ok(DnsProviderType::Webhook { url, auth_header })
}
other => Err(anyhow::anyhow!(
"Unknown DNS provider type '{}' for listener '{}'. Valid types: hetzner, webhook",
other,
listener_id
)),
}
}
fn parse_propagation_config(node: &kdl::KdlNode) -> PropagationCheckConfig {
let initial_delay_secs = get_int_entry(node, "initial-delay-secs")
.map(|v| v as u64)
.unwrap_or(10);
let check_interval_secs = get_int_entry(node, "check-interval-secs")
.map(|v| v as u64)
.unwrap_or(5);
let timeout_secs = get_int_entry(node, "timeout-secs")
.map(|v| v as u64)
.unwrap_or(120);
let nameservers: Vec<String> = if let Some(children) = node.children() {
children
.nodes()
.iter()
.filter(|n| n.name().value() == "nameservers")
.flat_map(|n| {
n.entries()
.iter()
.filter_map(|e| e.value().as_string().map(|s| s.to_string()))
})
.collect()
} else {
Vec::new()
};
PropagationCheckConfig {
initial_delay_secs,
check_interval_secs,
timeout_secs,
nameservers,
}
}
fn parse_sni_certificate(node: &kdl::KdlNode, listener_id: &str) -> Result<SniCertificate> {
let children = node.children();
let hostnames: Vec<String> = if let Some(children) = children {
children
.nodes()
.iter()
.filter(|n| n.name().value() == "hostnames")
.flat_map(|n| {
n.entries()
.iter()
.filter_map(|e| e.value().as_string().map(|s| s.to_string()))
})
.collect()
} else {
Vec::new()
};
let priority_hostnames: Vec<String> = if let Some(children) = children {
children
.nodes()
.iter()
.filter(|n| n.name().value() == "priority-hostnames")
.flat_map(|n| {
n.entries()
.iter()
.filter_map(|e| e.value().as_string().map(|s| s.to_string()))
})
.collect()
} else {
Vec::new()
};
if !hostnames.is_empty() && !priority_hostnames.is_empty() {
return Err(anyhow::anyhow!(
"SNI certificate for listener '{}' cannot specify both 'hostnames' and 'priority-hostnames'. \
Use 'hostnames' for an explicit hostname list (no auto-extraction), or \
'priority-hostnames' for priority tie-breaking with full SAN auto-extraction.",
listener_id
));
}
let cert_file = get_string_entry(node, "cert-file")
.map(PathBuf::from)
.ok_or_else(|| {
anyhow::anyhow!(
"SNI certificate for listener '{}' requires 'cert-file'",
listener_id
)
})?;
let key_file = get_string_entry(node, "key-file")
.map(PathBuf::from)
.ok_or_else(|| {
anyhow::anyhow!(
"SNI certificate for listener '{}' requires 'key-file'",
listener_id
)
})?;
if !priority_hostnames.is_empty() {
debug!(
listener_id = %listener_id,
priority_hostnames = ?priority_hostnames,
cert_file = %cert_file.display(),
"Parsed SNI certificate (SAN auto-extraction with priority tie-breaking)"
);
} else if hostnames.is_empty() {
debug!(
listener_id = %listener_id,
cert_file = %cert_file.display(),
"Parsed SNI certificate (hostnames will be auto-extracted from CN/SAN)"
);
} else {
debug!(
listener_id = %listener_id,
hostnames = ?hostnames,
cert_file = %cert_file.display(),
"Parsed SNI certificate"
);
}
Ok(SniCertificate {
hostnames,
priority_hostnames,
cert_file,
key_file,
})
}
fn parse_tls_version(s: &str) -> TlsVersion {
match s.to_lowercase().as_str() {
"1.3" | "tls1.3" | "tlsv1.3" => TlsVersion::Tls13,
_ => TlsVersion::Tls12,
}
}