use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Config {
pub global: GlobalSettings,
pub http: Vec<HttpBlock>,
#[serde(default)]
pub stream: Vec<StreamBlock>,
#[serde(default)]
pub mail: Option<MailBlock>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct GlobalSettings {
#[serde(default = "default_worker_processes")]
pub worker_processes: WorkerProcesses,
#[serde(default = "default_error_log")]
pub error_log: String,
#[serde(default)]
pub pid_file: Option<String>,
#[serde(default)]
pub user: Option<String>,
#[serde(default)]
pub timeouts: Option<TimeoutSettings>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(untagged)]
pub enum WorkerProcesses {
Auto(String),
Count(usize),
}
impl Default for WorkerProcesses {
fn default() -> Self {
WorkerProcesses::Auto("auto".to_string())
}
}
fn default_worker_processes() -> WorkerProcesses {
WorkerProcesses::Auto("auto".to_string())
}
fn default_error_log() -> String {
"./logs/error.log".to_string()
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TimeoutSettings {
#[serde(default = "default_idle_timeout")]
pub idle: u64,
#[serde(default = "default_request_timeout")]
pub request: u64,
#[serde(default = "default_keepalive_timeout")]
pub keepalive: u64,
#[serde(default = "default_proxy_read_timeout")]
pub proxy_read: u64,
}
fn default_idle_timeout() -> u64 { 75 }
fn default_request_timeout() -> u64 { 30 }
fn default_keepalive_timeout() -> u64 { 75 }
fn default_proxy_read_timeout() -> u64 { 60 }
impl Default for TimeoutSettings {
fn default() -> Self {
Self {
idle: default_idle_timeout(),
request: default_request_timeout(),
keepalive: default_keepalive_timeout(),
proxy_read: default_proxy_read_timeout(),
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct HttpBlock {
pub listen: Vec<ListenDirective>,
#[serde(default)]
pub server_name: Vec<String>,
#[serde(default)]
pub root: Option<String>,
#[serde(default)]
pub ssl: Option<SslSettings>,
#[serde(default = "default_true")]
pub http2: bool,
#[serde(default)]
pub http3: bool,
#[serde(default)]
pub locations: Vec<Location>,
#[serde(default)]
pub rewrites: Vec<RewriteRule>,
#[serde(default)]
pub geoip_db: Option<String>,
#[serde(default)]
pub ab_splits: Vec<AbSplit>,
#[serde(default = "default_access_log")]
pub access_log: String,
#[serde(default)]
pub max_header_size: Option<usize>,
#[serde(default)]
pub max_body_size: Option<usize>,
#[serde(default = "default_true")]
pub reject_ambiguous_framing: bool,
#[serde(default = "default_true")]
pub normalize_headers_before_proxy: bool,
#[serde(default)]
pub http2_max_reset_rate: Option<ResetRateLimit>,
#[serde(default)]
pub http3_max_dynamic_table_size: Option<usize>,
#[serde(default = "default_strict")]
pub host_header_policy: String,
#[serde(default)]
pub allowed_hosts: Option<Vec<String>>,
#[serde(default = "default_true")]
pub reject_headers_with_control_chars: bool,
#[serde(default)]
pub allow_connect: bool,
#[serde(default)]
pub connect_upstream: Option<UpstreamRef>,
#[serde(default)]
pub connect_allowed_targets: Option<Vec<String>>,
#[serde(default)]
pub trusted_proxies: Option<Vec<String>>,
#[serde(default)]
pub upstream_allowed_networks: Option<Vec<String>>,
#[serde(default)]
pub max_header_count: Option<usize>,
#[serde(default)]
pub max_decompression_ratio: Option<usize>,
#[serde(default)]
pub max_decompression_size: Option<usize>,
#[serde(default)]
pub upstreams: Vec<UpstreamRef>,
#[serde(default)]
pub dns_cache_ttl_seconds: Option<u64>,
#[serde(default)]
pub hsts_max_age: Option<u64>,
#[serde(default)]
pub max_upstream_response_size: Option<usize>,
}
fn default_true() -> bool { true }
fn default_strict() -> String { "strict".to_string() }
fn default_access_log() -> String { "./logs/access.log".to_string() }
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ListenDirective {
pub port: u16,
#[serde(default = "default_tcp")]
pub protocol: String,
#[serde(default)]
pub address: Option<String>,
}
fn default_tcp() -> String { "tcp".to_string() }
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SslSettings {
#[serde(default)]
pub certificate: Option<String>,
#[serde(default)]
pub certificate_key: Option<String>,
#[serde(default)]
pub acme: Option<AcmeConfig>,
#[serde(default = "default_tls_protocols")]
pub protocols: Vec<String>,
}
fn default_tls_protocols() -> Vec<String> {
vec!["TLSv1.2".to_string(), "TLSv1.3".to_string()]
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AcmeConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub email: String,
#[serde(default = "default_acme_endpoint")]
pub ca_endpoint: String,
#[serde(default)]
pub staging: bool,
#[serde(default)]
pub domains: Vec<String>,
#[serde(default = "default_challenge_types")]
pub challenge_types: Vec<String>,
#[serde(default)]
pub dns_webhook: Option<WebhookConfig>,
#[serde(default)]
pub eab: Option<EabConfig>,
#[serde(default = "default_renewal_window")]
pub renewal_window: u32,
#[serde(default)]
pub http_challenge_port: Option<u16>,
#[serde(default)]
pub agree_tos: bool,
#[serde(default)]
pub ca_root: Option<String>,
#[serde(default = "default_acme_cert_dir")]
pub cert_dir: String,
#[serde(default)]
pub renewal_check_interval_secs: Option<u64>,
}
fn default_acme_cert_dir() -> String { "./acme".to_string() }
fn default_acme_endpoint() -> String {
"https://acme-v02.api.letsencrypt.org/directory".to_string()
}
fn default_challenge_types() -> Vec<String> {
vec!["http-01".to_string(), "tls-alpn-01".to_string()]
}
fn default_renewal_window() -> u32 { 30 }
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct WebhookConfig {
pub url: String,
#[serde(default = "default_webhook_timeout")]
pub timeout: u64,
#[serde(default)]
pub auth_header: Option<String>,
}
fn default_webhook_timeout() -> u64 { 30 }
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct EabConfig {
pub kid: String,
pub hmac_key: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Location {
pub pattern: String,
#[serde(default = "default_prefix")]
pub match_type: String,
pub handler: Handler,
}
fn default_prefix() -> String { "prefix".to_string() }
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Handler {
#[serde(rename = "type")]
pub handler_type: String,
#[serde(default)]
pub root: Option<String>,
#[serde(default)]
pub upstream: Option<UpstreamRef>,
#[serde(default)]
pub target: Option<String>,
#[serde(default)]
pub status: Option<u16>,
#[serde(default)]
pub ab_split: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RewriteRule {
pub pattern: String,
pub replacement: String,
#[serde(default = "default_break")]
pub flag: String,
}
fn default_break() -> String { "break".to_string() }
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UpstreamRef {
pub name: String,
#[serde(default)]
pub servers: Vec<UpstreamServer>,
#[serde(default = "default_round_robin")]
pub strategy: String,
}
fn default_round_robin() -> String { "round-robin".to_string() }
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UpstreamServer {
pub address: String,
#[serde(default = "default_weight")]
pub weight: u32,
}
fn default_weight() -> u32 { 1 }
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AbSplit {
pub name: String,
#[serde(default)]
pub sticky: bool,
pub groups: Vec<AbGroup>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AbGroup {
pub name: String,
pub upstream: String,
pub weight: u32,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct StreamBlock {
pub listen: ListenDirective,
pub upstream: UpstreamRef,
#[serde(default)]
pub ssl: Option<SslSettings>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct MailBlock {
pub listen: Vec<ListenDirective>,
pub protocol: String,
pub upstream: UpstreamRef,
#[serde(default)]
pub ssl: Option<SslSettings>,
#[serde(default)]
pub auth_required: bool,
#[serde(default)]
pub max_line_length: Option<usize>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ResetRateLimit {
pub count: usize,
pub window_seconds: u64,
}
impl Config {
pub fn load(path: &Path) -> Result<Self> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
let trimmed = content.trim();
if trimmed.starts_with('{') {
let config: Config = serde_json::from_str(trimmed)
.with_context(|| "Failed to parse JSON config")?;
config.validate()?;
return Ok(config);
}
let config: Config = toml::from_str(&content)
.with_context(|| "Failed to parse config TOML")?;
config.validate()?;
Ok(config)
}
pub fn validate(&self) -> Result<()> {
for http_block in self.http.iter() {
for ab_split in &http_block.ab_splits {
let total_weight: u32 = ab_split.groups.iter().map(|g| g.weight).sum();
if total_weight != 100 {
anyhow::bail!(
"A/B split '{}' weights sum to {}, must be 100",
ab_split.name,
total_weight
);
}
for group in &ab_split.groups {
if !http_block.upstreams.iter().any(|u| u.name == group.upstream) {
anyhow::bail!(
"A/B split '{}' group '{}' references unknown upstream '{}'",
ab_split.name,
group.name,
group.upstream
);
}
}
}
for listen in &http_block.listen {
if let Some(addr) = &listen.address {
addr.parse::<std::net::IpAddr>()
.with_context(|| format!("Invalid listen address: {}", addr))?;
}
}
let check_upstream = |upstream: &UpstreamRef| -> Result<()> {
match upstream.strategy.as_str() {
"round-robin" | "least-conn" | "ip-hash" => {}
other => anyhow::bail!(
"Upstream '{}' has unknown strategy '{}' (round-robin, least-conn, ip-hash)",
upstream.name,
other
),
}
for server in &upstream.servers {
if server.weight == 0 {
anyhow::bail!(
"Upstream '{}' server '{}' has weight 0; weights must be >= 1",
upstream.name,
server.address
);
}
}
Ok(())
};
for upstream in &http_block.upstreams {
check_upstream(upstream)?;
}
for location in &http_block.locations {
if let Some(upstream) = &location.handler.upstream {
check_upstream(upstream)?;
}
}
if let Some(upstream) = &http_block.connect_upstream {
check_upstream(upstream)?;
}
for location in &http_block.locations {
match location.match_type.as_str() {
"exact" | "prefix" => {}
"regex" => {
regex::Regex::new(&location.pattern).with_context(|| {
format!("Invalid location regex: {}", location.pattern)
})?;
}
other => anyhow::bail!("Unknown location match_type: {}", other),
}
let h = &location.handler;
if matches!(h.handler_type.as_str(), "proxy" | "reverse_proxy") {
if let Some(split_name) = &h.ab_split {
if !http_block.ab_splits.iter().any(|s| &s.name == split_name) {
anyhow::bail!(
"Location '{}' references unknown ab_split '{}'",
location.pattern,
split_name
);
}
} else {
let has_inline =
h.upstream.as_ref().is_some_and(|u| !u.servers.is_empty());
let has_shared = http_block
.upstreams
.first()
.is_some_and(|u| !u.servers.is_empty());
if !has_inline && !has_shared {
anyhow::bail!(
"Proxy location '{}' has no upstream servers configured",
location.pattern
);
}
}
}
}
for rule in &http_block.rewrites {
regex::Regex::new(&rule.pattern)
.with_context(|| format!("Invalid rewrite pattern: {}", rule.pattern))?;
match rule.flag.as_str() {
"break" | "last" | "redirect" | "permanent" => {}
other => anyhow::bail!(
"Rewrite '{}' has unknown flag '{}' (break, last, redirect, permanent)",
rule.pattern,
other
),
}
}
if let Some(proxies) = &http_block.trusted_proxies {
for cidr in proxies {
cidr.parse::<ipnetwork::IpNetwork>()
.with_context(|| format!("Invalid CIDR in trusted_proxies: {}", cidr))?;
}
}
if let Some(targets) = &http_block.connect_allowed_targets {
for target in targets {
if target.trim().is_empty() {
anyhow::bail!("connect_allowed_targets contains an empty entry");
}
}
}
match http_block.host_header_policy.as_str() {
"strict" | "any" | "list" => {}
other => anyhow::bail!("Invalid host_header_policy: {}", other),
}
if http_block.host_header_policy == "list"
&& http_block.allowed_hosts.as_ref().is_none_or(|h| h.is_empty())
&& http_block.server_name.is_empty()
{
anyhow::bail!("host_header_policy 'list' requires allowed_hosts or server_name");
}
if let Some(networks) = &http_block.upstream_allowed_networks {
for net in networks {
net.parse::<ipnetwork::IpNetwork>()
.with_context(|| format!("Invalid CIDR in upstream_allowed_networks: {}", net))?;
}
}
for upstream in &http_block.upstreams {
for server in &upstream.servers {
Self::validate_upstream_address(&server.address, &http_block.upstream_allowed_networks)
.with_context(|| format!("Upstream '{}' server '{}'", upstream.name, server.address))?;
}
}
if let Some(ssl) = &http_block.ssl {
for proto in &ssl.protocols {
let p = proto.to_uppercase();
match p.as_str() {
"TLSV1.2" | "TLSV1.3" => {}
"SSLV3" | "TLSV1.0" | "TLSV1.1" => {
anyhow::bail!("Prohibited TLS protocol: {} (minimum TLSv1.2)", proto);
}
_ => anyhow::bail!("Unsupported SSL protocol: {}", proto),
}
}
if let Some(acme) = &ssl.acme {
if acme.enabled {
if acme.email.is_empty() {
anyhow::bail!("ACME enabled but email is empty");
}
if !acme.agree_tos {
anyhow::bail!("ACME enabled but agree_tos is false");
}
for domain in &acme.domains {
if !http_block.server_name.iter().any(|s| s == domain || Self::matches_wildcard(s, domain)) {
anyhow::bail!("ACME domain '{}' not in server_name list", domain);
}
}
if acme.staging
&& acme.ca_endpoint.contains("acme-v02.api.letsencrypt.org")
&& !acme.ca_endpoint.contains("staging")
{
anyhow::bail!(
"ACME staging=true but production CA endpoint specified (Invariant 70)"
);
}
let challenge_port = acme.http_challenge_port.unwrap_or(80);
if challenge_port < 1024 && !Self::is_effective_root() {
anyhow::bail!(
"ACME http_challenge_port {} is privileged (<1024) but the daemon \
is not running as root (Invariant 72)",
challenge_port
);
}
if let Some(webhook) = &acme.dns_webhook {
if let Some(auth_path) = &webhook.auth_header {
Self::check_secret_file(auth_path, "dns_webhook.auth_header")?;
}
}
if let Some(eab) = &acme.eab {
if eab.kid.is_empty() {
anyhow::bail!("ACME eab.kid is empty");
}
Self::check_secret_file(&eab.hmac_key, "eab.hmac_key")?;
}
if let Some(ca_root) = &acme.ca_root {
if !Path::new(ca_root).exists() {
anyhow::bail!("ACME ca_root file not found: {}", ca_root);
}
}
if acme.challenge_types.iter().any(|c| c == "dns-01")
&& acme.dns_webhook.is_none()
{
anyhow::bail!(
"ACME challenge_types includes 'dns-01' but no dns_webhook configured"
);
}
}
}
if let Some(cert) = &ssl.certificate {
if !Path::new(&cert).exists() {
anyhow::bail!("TLS certificate file not found: {}", cert);
}
}
if let Some(key) = &ssl.certificate_key {
if !Path::new(&key).exists() {
anyhow::bail!("TLS certificate key file not found: {}", key);
}
}
}
if http_block.allow_connect && http_block.connect_upstream.is_none() {
anyhow::bail!("allow_connect=true but no connect_upstream specified");
}
}
for (i, sb) in self.stream.iter().enumerate() {
if sb.listen.port < 1 {
anyhow::bail!("stream[{}]: listen port {} out of range", i, sb.listen.port);
}
if sb.upstream.name.is_empty() {
anyhow::bail!("stream[{}]: upstream name is empty", i);
}
if sb.upstream.servers.is_empty() {
anyhow::bail!("stream[{}]: upstream '{}' has no servers", i, sb.upstream.name);
}
if let Some(addr) = &sb.listen.address {
addr.parse::<std::net::IpAddr>()
.with_context(|| format!("stream[{}]: invalid listen address {}", i, addr))?;
}
}
if let Some(mail) = &self.mail {
for ld in &mail.listen {
if ld.port < 1 {
anyhow::bail!("mail: listen port {} out of range", ld.port);
}
}
if mail.upstream.name.is_empty() {
anyhow::bail!("mail: upstream name is empty");
}
}
Ok(())
}
fn validate_upstream_address(addr: &str, allowed_networks: &Option<Vec<String>>) -> Result<()> {
let (host, _) = addr.rsplit_once(':').unwrap_or((addr, ""));
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
let is_private = match ip {
std::net::IpAddr::V4(v4) => v4.is_private() || v4.is_loopback() || v4.is_link_local() || v4.is_multicast(),
std::net::IpAddr::V6(v6) => v6.is_loopback() || v6.is_multicast(),
};
if is_private {
if let Some(networks) = &allowed_networks {
let allowed = networks.iter().any(|cidr| {
if let Ok(net) = cidr.parse::<ipnetwork::IpNetwork>() {
net.contains(ip)
} else {
false
}
});
if !allowed {
anyhow::bail!(
"Address {} is private/loopback and not in allowed networks",
addr
);
}
} else {
anyhow::bail!(
"Address {} is in a private/loopback range and no upstream_allowed_networks is configured",
addr
);
}
}
}
Ok(())
}
fn matches_wildcard(pattern: &str, domain: &str) -> bool {
if pattern.starts_with("*.") {
let suffix = &pattern[1..];
domain.ends_with(suffix) || domain == &pattern[2..]
} else {
pattern == domain
}
}
fn is_effective_root() -> bool {
#[cfg(unix)]
{
nix::unistd::Uid::effective().is_root()
}
#[cfg(not(unix))]
{
false
}
}
fn check_secret_file(path: &str, what: &str) -> Result<()> {
let meta = fs::metadata(path)
.with_context(|| format!("{} secret file not found: {}", what, path))?;
if !meta.is_file() {
anyhow::bail!("{} path {} is not a regular file", what, path);
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let perms = meta.mode() & 0o777;
if perms & 0o077 != 0 {
anyhow::bail!(
"{} file {} has insecure permissions {:#o}; must be 0600 or stricter \
(no group/other access)",
what,
path,
perms
);
}
}
Ok(())
}
}
pub fn resolve_upstream<'a>(hb: &'a HttpBlock, name: &'a str) -> Option<&'a UpstreamRef> {
hb.upstreams.iter().find(|u| u.name == name)
}