use crate::access::{PolicyAction, Predicate};
use ::kdl::KdlDocument;
use anyhow::{Context, anyhow, bail};
use hyper::header::HeaderName;
use regex::Regex;
use std::collections::{HashMap, HashSet};
use std::path::Path;
mod kdl;
mod parse;
mod types_socket;
pub use types_socket::{AddrLocation, BoundAddr, SocketKind};
use parse::{
check_misnesting, did_you_mean, line_of_offset, node_line,
parse_certificate, parse_listener, parse_server, parse_vhost,
TOP_LEVEL_ONLY,
};
#[cfg(test)]
mod tests;
fn loc(name: &str, line: usize) -> String {
if name.is_empty() {
format!("line {line}: ")
} else {
format!("{name}:{line}: ")
}
}
#[derive(Debug, Clone)]
pub enum PolicyRuleDef {
Rule {
predicate: Option<Predicate>,
action: PolicyAction,
},
Apply { name: String },
}
#[derive(Debug, Clone)]
pub enum ErrorPageDef {
File(String),
Inline(String),
}
#[derive(Debug, Default)]
pub struct Config {
pub server: ServerConfig,
pub listeners: Vec<ListenerConfig>,
pub vhosts: Vec<VHostConfig>,
pub certificates: Vec<CertificateDef>,
}
#[derive(Debug, Clone)]
pub struct CertificateDef {
pub name: String,
pub source: TlsConfig,
pub line: usize,
}
#[derive(Debug)]
pub struct ServerConfig {
pub state_dir: Option<String>,
pub tls_defaults: TlsOptions,
pub user: Option<String>,
pub group: Option<String>,
pub inherit_supplementary_groups: bool,
pub auth: Option<AuthBackend>,
pub geoip: Option<GeoIpConfig>,
pub health: HealthConfig,
pub policies: HashMap<String, Vec<PolicyRuleDef>>,
pub cache: Option<CacheGlobalConfig>,
pub error_pages: Vec<(u16, ErrorPageDef)>,
pub cert_key_mode: Option<u32>,
pub access_log: Option<AccessLogConfig>,
#[allow(dead_code)] pub graceful_drain_timeout: u32,
#[allow(dead_code)] pub upgrade_startup_timeout: u32,
pub lame_duck_timeout: u32,
}
impl Default for ServerConfig {
fn default() -> Self {
ServerConfig {
state_dir: None,
tls_defaults: Default::default(),
user: None,
group: None,
inherit_supplementary_groups: false,
auth: None,
geoip: None,
health: Default::default(),
policies: HashMap::new(),
cache: None,
error_pages: Vec::new(),
cert_key_mode: None,
access_log: None,
graceful_drain_timeout: 0,
upgrade_startup_timeout: 60,
lame_duck_timeout: 0,
}
}
}
#[derive(Debug, Clone)]
pub struct AccessLogConfig {
pub format: AccessLogFormatConfig,
pub path: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum AccessLogFormatConfig {
#[default]
Tracing,
Json,
Common,
Combined,
}
#[derive(Debug, Clone)]
pub struct HealthConfig {
pub enabled: bool,
pub liveness_paths: Vec<String>,
pub readiness_paths: Vec<String>,
}
impl Default for HealthConfig {
fn default() -> Self {
HealthConfig {
enabled: true,
liveness_paths: crate::handler::health::DEFAULT_LIVENESS_PATHS
.iter()
.map(|s| s.to_string())
.collect(),
readiness_paths: crate::handler::health::DEFAULT_READINESS_PATHS
.iter()
.map(|s| s.to_string())
.collect(),
}
}
}
#[derive(Debug, Clone)]
pub struct GeoIpConfig {
pub db: String,
}
mod types_auth;
pub use types_auth::*;
#[derive(Debug, Clone, Default)]
pub struct Timeouts {
pub request_header_secs: Option<u64>,
pub handler_secs: Option<u64>,
pub keepalive_secs: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ProxyProtocolVersion {
V1,
V2,
}
#[derive(Debug, Clone)]
pub struct ProxyConfig {
pub upstream: BoundAddr,
pub upstream_tls: Option<UpstreamTlsConfig>,
pub upstream_dtls: Option<UpstreamDtlsConfig>,
pub proxy_protocol: Option<ProxyProtocolVersion>,
pub policy: Option<Vec<PolicyRuleDef>>,
pub flow_idle_timeout_secs: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct UpstreamTlsConfig {
pub skip_verify: bool,
}
#[derive(Debug, Clone, Default)]
pub struct UpstreamDtlsConfig {
#[allow(dead_code)] pub skip_verify: bool,
}
#[derive(Debug, Clone)]
pub struct ListenerConfig {
pub bind: BoundAddr,
pub tls: Option<TlsListenerConfig>,
pub proxy: Option<ProxyConfig>,
pub accept_proxy_protocol: Option<ProxyProtocolVersion>,
pub trusted_proxies: Vec<ipnet::IpNet>,
pub vhosts: Vec<String>,
pub reject_unknown_host: bool,
pub health: Option<bool>,
pub timeouts: Timeouts,
pub max_connections: Option<u32>,
pub max_request_body: Option<u64>,
pub auto_alt_svc: Option<String>,
pub alpn: Option<Vec<String>>,
pub quic_transport: Option<QuicTransport>,
pub line: usize,
}
#[derive(Debug, Clone)]
pub struct UpstreamConfig {
pub url: String,
pub weight: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum LbPolicy {
#[default]
RoundRobin,
LeastConn,
Random,
IpHash,
HeaderHash,
}
#[derive(Debug, Clone)]
pub struct ActiveHealthConfig {
pub path: String,
pub interval_secs: u64,
pub timeout_secs: u64,
pub expect_status: u16,
pub unhealthy_after: u32,
pub healthy_after: u32,
}
#[derive(Debug, Clone)]
pub struct PassiveHealthConfig {
pub eject_after: u32,
pub eject_for_secs: u64,
}
impl Default for PassiveHealthConfig {
fn default() -> Self {
PassiveHealthConfig {
eject_after: u32::MAX,
eject_for_secs: 30,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct RetryConfig {
pub max: u32,
pub on_status: Vec<u16>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ProxyUpstreamScheme {
#[default]
Auto,
H3,
H2c,
}
#[derive(Debug, Clone, Default)]
pub struct QuicTransport {
pub max_concurrent_bidi_streams: Option<u64>,
pub max_idle_timeout_secs: Option<u64>,
pub keep_alive_interval_secs: Option<u64>,
pub zero_rtt_enabled: bool,
pub retry_tokens: bool,
pub retry_token_lifetime_secs: Option<u64>,
}
impl ListenerConfig {
pub fn local_name(&self) -> String {
self.bind.to_url()
}
}
mod types_tls;
pub use types_tls::*;
#[derive(Debug, Clone)]
pub struct VHostName {
pub value: String,
pub regex: bool,
}
#[derive(Debug)]
pub struct VHostConfig {
pub name: VHostName,
pub aliases: Vec<VHostName>,
pub locations: Vec<LocationConfig>,
pub ref_name: Option<String>,
pub explicit_only: bool,
pub alpn: Option<Vec<String>>,
pub line: usize,
}
impl VHostConfig {
pub fn handle(&self) -> &str {
self.ref_name.as_deref().unwrap_or(self.name.value.as_str())
}
}
#[derive(Debug, Clone)]
pub enum HeaderOpConfig {
Set { name: String, value: String },
Add { name: String, value: String },
Remove { name: String },
}
impl HeaderOpConfig {
pub fn header_name(&self) -> &str {
match self {
HeaderOpConfig::Set { name, .. }
| HeaderOpConfig::Add { name, .. }
| HeaderOpConfig::Remove { name } => name,
}
}
}
#[derive(Debug)]
pub struct LocationConfig {
pub path: String,
pub handler: HandlerConfig,
pub policy: Option<Vec<PolicyRuleDef>>,
pub auth: Option<BasicAuthConfig>,
pub request_headers: Vec<HeaderOpConfig>,
pub response_headers: Vec<HeaderOpConfig>,
pub rate_limits: Vec<RateLimitConfig>,
pub max_request_body: Option<u64>,
pub matcher: Option<MatcherConfig>,
pub rewrite: Option<RewriteConfig>,
pub cache: Option<CacheConfig>,
pub line: usize,
}
#[derive(Debug, Clone)]
pub struct CacheGlobalConfig {
pub max_size: u64,
}
#[derive(Debug, Clone)]
pub struct CacheConfig {
pub ttl_secs: u64,
pub max_object_size: u64,
pub methods: Vec<String>,
pub key: Option<String>,
pub honor_client_cache_control: bool,
}
#[derive(Debug, Clone)]
pub struct RewriteConfig {
pub from: String,
pub to: String,
}
#[derive(Debug, Clone)]
pub struct MatcherConfig {
pub predicates: Vec<MatchPredicateConfig>,
}
#[derive(Debug, Clone)]
pub enum MatchPredicateConfig {
Method(Vec<String>),
Header { name: String, values: Vec<String> },
HeaderAbsent { name: String },
Query { name: String, values: Vec<String> },
Path(Vec<String>),
Not(Vec<MatchPredicateConfig>),
}
#[derive(Debug, Clone)]
pub struct RateLimitConfig {
pub name: String,
pub rate_per_sec: f64,
pub burst: f64,
pub key: RateLimitKeyConfig,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RateLimitKeyConfig {
ClientIp,
User,
Header(String),
}
#[derive(Debug, Clone)]
pub enum RespondBody {
Empty,
Inline(String),
File(String),
}
#[derive(Debug)]
pub enum HandlerConfig {
Static {
root: Option<String>,
index_files: Vec<String>,
strip_prefix: bool,
try_files: Vec<String>,
directory_listing: bool,
fallback_redirect: Option<String>,
userdir: Option<String>,
userdir_allowlist: Vec<String>,
userdir_min_uid: u32,
},
Proxy {
upstreams: Vec<UpstreamConfig>,
lb_policy: LbPolicy,
lb_hash_header: Option<String>,
active_health: Option<ActiveHealthConfig>,
passive_health: PassiveHealthConfig,
retry: RetryConfig,
strip_prefix: bool,
proxy_protocol: Option<ProxyProtocolVersion>,
scheme: ProxyUpstreamScheme,
pool_idle_timeout_secs: Option<u64>,
pool_max_idle: Option<u32>,
upstream_tls: Option<UpstreamTlsConfig>,
connect_timeout_secs: Option<u64>,
},
Redirect {
to: String,
code: u16,
},
Respond {
status: u16,
body: RespondBody,
content_type: Option<String>,
},
FastCgi {
socket: String,
root: String,
index: Option<String>,
},
Scgi {
socket: String,
root: String,
index: Option<String>,
},
Cgi {
root: String,
},
Status,
AuthRequest,
}
impl Config {
pub fn load(path: &Path) -> anyhow::Result<Self> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading {}", path.display()))?;
let name = path.display().to_string();
Self::parse_named(&text, &name)
}
#[cfg(test)]
pub fn parse(text: &str) -> anyhow::Result<Self> {
Self::parse_named(text, "")
}
fn parse_named(text: &str, name: &str) -> anyhow::Result<Self> {
let doc: KdlDocument = text.parse().map_err(|e: ::kdl::KdlError| {
let mut seen: Vec<(usize, String)> = Vec::new();
let mut parts: Vec<String> = Vec::new();
for diag in &e.diagnostics {
let line = line_of_offset(text, diag.span.offset());
let msg = diag
.message
.clone()
.unwrap_or_else(|| "syntax error".to_string());
if seen.iter().any(|(l, m)| *l == line && *m == msg) {
continue;
}
seen.push((line, msg.clone()));
let snippet = text
.lines()
.nth(line.saturating_sub(1))
.unwrap_or("")
.trim();
parts.push(if name.is_empty() {
format!("line {line}: {msg} -- `{snippet}`")
} else {
format!("{name}:{line}: {msg} -- `{snippet}`")
});
}
if parts.is_empty() {
if name.is_empty() {
anyhow!("syntax error")
} else {
anyhow!("{name}: syntax error")
}
} else {
anyhow!("{}", parts.join("\n"))
}
})?;
check_misnesting(text, name, &doc)?;
let mut config = Config::default();
for node in doc.nodes() {
let line = node_line(text, node);
match node.name().value() {
"server" => {
config.server = parse_server(node, text, name)?;
}
"listener" => {
config
.listeners
.push(parse_listener(node, text, name)?);
}
"vhost" => {
config.vhosts.push(parse_vhost(node, text, name)?);
}
"certificate" => {
config
.certificates
.push(parse_certificate(node, text, name)?);
}
other => {
bail!(
"{name}:{line}: unknown top-level node \
'{other}'{}",
did_you_mean(other, &TOP_LEVEL_ONLY)
)
}
}
}
let udp_ports: std::collections::HashSet<u16> = config
.listeners
.iter()
.filter(|l| {
l.bind.kind == SocketKind::UdpDgram && l.tls.is_some()
})
.filter_map(|l| l.bind.as_inet().map(|sa| sa.port()))
.collect();
if !udp_ports.is_empty() {
for listener in config.listeners.iter_mut() {
if listener.bind.kind != SocketKind::TcpStream
|| listener.tls.is_none()
|| listener.proxy.is_some()
{
continue;
}
if let Some(port) =
listener.bind.as_inet().map(|sa| sa.port())
&& udp_ports.contains(&port)
{
listener.auto_alt_svc =
Some(format!("h3=\":{port}\"; ma=86400"));
}
}
}
config.validate(name)?;
Ok(config)
}
pub fn validate(&self, name: &str) -> anyhow::Result<()> {
if self.listeners.is_empty() {
bail!("config must define at least one listener");
}
let has_http = self.listeners.iter().any(|l| l.proxy.is_none());
if has_http && self.vhosts.is_empty() {
bail!("config must define at least one vhost");
}
{
let h = &self.server.health;
for p in h.liveness_paths.iter().chain(h.readiness_paths.iter()) {
if !p.starts_with('/') {
bail!("health path '{p}' must start with '/'");
}
}
let live: HashSet<&str> =
h.liveness_paths.iter().map(String::as_str).collect();
for p in &h.readiness_paths {
if live.contains(p.as_str()) {
bail!(
"health path '{p}' is listed as both liveness \
and readiness"
);
}
}
}
for l in self.listeners.iter() {
let at = loc(name, l.line);
let kind = l.bind.kind;
let has_tls = l.tls.is_some();
let has_proxy = l.proxy.is_some();
if has_tls
&& !kind.is_byte_stream()
&& kind != SocketKind::UdpDgram
{
bail!(
"{at}listener ({}) carries a `tls {{ }}` block; on a \
datagram listener TLS means HTTP/3 or DTLS, both of \
which are udp:// only. unix-dgram: / \
unix-seqpacket: support only a `proxy {{ }}` block.",
l.bind.to_url()
);
}
if has_tls && has_proxy && kind == SocketKind::UdpDgram {
bail!(
"{at}listener ({}) requests a DTLS-terminating \
datagram proxy (`tls` + `proxy` on udp://) -- DTLS \
is not yet implemented; the config slot is \
reserved.",
l.bind.to_url()
);
}
if kind.is_datagram_stream() && !has_tls && !has_proxy {
bail!(
"{at}listener ({}) has no handler: a datagram-\
stream listener requires either a `tls {{ }}` \
block (HTTP/3 on udp://) or a `proxy {{ }}` block \
(raw datagram forward).",
l.bind.to_url()
);
}
if let Some(p) = &l.proxy {
let upstream_kind = p.upstream.kind;
if kind.is_byte_stream() && !upstream_kind.is_byte_stream() {
bail!(
"{at}listener ({}) is a byte-stream listener \
but its proxy upstream {} is a datagram \
socket; byte-stream listeners must forward \
to a byte-stream upstream (tcp:// or \
unix-stream:).",
l.bind.to_url(),
p.upstream.to_url()
);
}
if kind.is_datagram_stream()
&& !upstream_kind.is_datagram_stream()
{
bail!(
"{at}listener ({}) is a datagram-stream \
listener but its proxy upstream {} is a \
byte-stream socket; datagram listeners must \
forward to a datagram upstream (udp://, \
unix-dgram:, unix-seqpacket:).",
l.bind.to_url(),
p.upstream.to_url()
);
}
if p.upstream_tls.is_some() && !upstream_kind.is_byte_stream()
{
bail!(
"{at}listener ({}) proxy carries an upstream \
`tls` block but the upstream {} is a datagram \
socket; TLS origination is byte-stream only.",
l.bind.to_url(),
p.upstream.to_url()
);
}
if p.upstream_dtls.is_some()
&& upstream_kind != SocketKind::UdpDgram
{
bail!(
"{at}listener ({}) proxy carries an upstream \
`dtls` block but the upstream {} is not \
udp://; DTLS origination is UDP-only.",
l.bind.to_url(),
p.upstream.to_url()
);
}
if p.upstream_dtls.is_some() {
bail!(
"{at}listener ({}) proxy uses `dtls` upstream \
origination -- not yet implemented; the \
config slot is reserved.",
l.bind.to_url()
);
}
if p.proxy_protocol.is_some()
&& !upstream_kind.is_byte_stream()
{
bail!(
"{at}listener ({}) proxy uses `proxy-protocol` \
but the upstream {} is a datagram socket; \
HAProxy PROXY protocol is byte-stream only.",
l.bind.to_url(),
p.upstream.to_url()
);
}
}
}
if matches!(self.server.auth, Some(AuthBackend::Jwt { .. }))
&& self.server.state_dir.is_none()
{
bail!(
"server.state-dir is required when auth jwt is \
configured"
);
}
{
let mut seen: HashSet<&str> = HashSet::new();
for c in &self.certificates {
if !seen.insert(c.name.as_str()) {
bail!(
"{}duplicate certificate name '{}'",
loc(name, c.line),
c.name
);
}
}
}
for l in self.listeners.iter() {
let at = loc(name, l.line);
if let Some(t) = &l.tls
&& let TlsConfig::Ref(name) = &t.cert
&& !self.certificates.iter().any(|c| &c.name == name)
{
bail!(
"{at}listener references unknown certificate \
'{name}'; define it at the top level with \
`certificate \"{name}\" {{ ... }}`"
);
}
}
let uses_acme = self
.listeners
.iter()
.filter_map(|l| l.tls.as_ref())
.any(|t| {
self.resolve_cert(&t.cert)
.is_some_and(|c| matches!(c, TlsConfig::Acme { .. }))
})
|| self
.certificates
.iter()
.any(|c| matches!(c.source, TlsConfig::Acme { .. }));
if uses_acme && self.server.state_dir.is_none() {
bail!(
"server.state-dir is required when any listener \
uses tls mode=acme"
);
}
self.check_cert_identity_conflicts()?;
for v in &self.vhosts {
let names = std::iter::once(&v.name).chain(v.aliases.iter());
for n in names {
if n.regex {
Regex::new(&n.value).with_context(|| {
format!(
"{}invalid regex in vhost name '{}'",
loc(name, v.line),
n.value
)
})?;
}
}
}
let mut by_handle: HashMap<&str, &VHostConfig> = HashMap::new();
for v in &self.vhosts {
let handle = v.handle();
if by_handle.insert(handle, v).is_some() {
bail!(
"{}duplicate vhost handle '{handle}'; give one a \
distinct `name=` to disambiguate",
loc(name, v.line)
);
}
}
for l in self.listeners.iter() {
if l.proxy.is_some() {
continue;
}
let at = loc(name, l.line);
let effective: Vec<&VHostConfig> = if l.vhosts.is_empty() {
self.vhosts.iter().filter(|v| !v.explicit_only).collect()
} else {
let mut out = Vec::with_capacity(l.vhosts.len());
for h in &l.vhosts {
match by_handle.get(h.as_str()) {
Some(v) => out.push(*v),
None => bail!(
"{at}listener 'vhost' references unknown \
vhost '{h}'"
),
}
}
out
};
let mut seen: HashSet<&str> = HashSet::new();
for v in &effective {
let names =
std::iter::once(&v.name).chain(v.aliases.iter());
for n in names {
if !n.regex && !seen.insert(n.value.as_str()) {
bail!(
"{at}host '{}' is served by more than one \
vhost on this listener",
n.value
);
}
}
}
}
for v in &self.vhosts {
for location in &v.locations {
let headers =
[&location.request_headers, &location.response_headers];
for ops in headers {
for op in ops.iter() {
let n = op.header_name();
HeaderName::from_bytes(n.as_bytes()).map_err(|_| {
anyhow!(
"{}invalid header name '{n}' in \
location '{}'",
loc(name, location.line),
location.path
)
})?;
}
}
}
}
let uses_country = {
let mut visited = HashSet::new();
self.vhosts.iter().any(|v| {
v.locations.iter().any(|loc| {
loc.policy.as_ref().is_some_and(|s| {
policy_needs_geoip(
s,
&self.server.policies,
&mut visited,
)
})
})
}) || self.listeners.iter().any(|l| {
l.proxy
.as_ref()
.and_then(|s| s.policy.as_ref())
.is_some_and(|s| {
policy_needs_geoip(
s,
&self.server.policies,
&mut visited,
)
})
}) || self.server.policies.values().any(|s| {
policy_needs_geoip(s, &self.server.policies, &mut visited)
})
};
if uses_country && self.server.geoip.is_none() {
bail!(
"policy 'country' predicates require \
server {{ geoip {{ db \"...\" }} }}"
);
}
Ok(())
}
fn check_cert_identity_conflicts(&self) -> anyhow::Result<()> {
let mut sources: Vec<(String, &TlsConfig)> = Vec::new();
for c in &self.certificates {
sources.push((format!("certificate \"{}\"", c.name), &c.source));
}
for (i, l) in self.listeners.iter().enumerate() {
let Some(t) = &l.tls else { continue };
if matches!(t.cert, TlsConfig::Ref(_)) {
continue;
}
sources.push((format!("listener[{i}] inline tls"), &t.cert));
}
let mut by_acme_name: HashMap<&str, Vec<&str>> = HashMap::new();
let mut by_files: HashMap<(&str, &str), Vec<&str>> = HashMap::new();
for (origin, src) in &sources {
match src {
TlsConfig::Acme { domains, name, .. } => {
let key = name.as_deref().unwrap_or(&domains[0]);
by_acme_name.entry(key).or_default().push(origin);
}
TlsConfig::Files { cert, key } => {
by_files
.entry((cert.as_str(), key.as_str()))
.or_default()
.push(origin);
}
TlsConfig::SelfSigned | TlsConfig::Ref(_) => {}
}
}
for (key, owners) in &by_acme_name {
if owners.len() > 1 {
bail!(
"ACME cert directory '{key}' is claimed by multiple \
sources: {}. Define a single top-level \
`certificate \"{key}\" {{ acme {{ ... }} }}` and \
have each listener reference it via \
`tls cert=\"{key}\"` to share one renewal loop \
and on-disk slot",
owners.join(", ")
);
}
}
for ((cert, key), owners) in &by_files {
if owners.len() > 1 {
bail!(
"file-based cert (cert=\"{cert}\", key=\"{key}\") is \
claimed by multiple sources: {}. Define a single \
top-level `certificate \"...\" {{ files cert=... \
key=... }}` and have each listener reference it",
owners.join(", ")
);
}
}
Ok(())
}
pub fn resolve_cert<'a>(
&'a self,
cfg: &'a TlsConfig,
) -> Option<&'a TlsConfig> {
match cfg {
TlsConfig::Ref(name) => self
.certificates
.iter()
.find(|c| &c.name == name)
.map(|c| &c.source),
other => Some(other),
}
}
}
fn policy_needs_geoip(
stmts: &[PolicyRuleDef],
policies: &HashMap<String, Vec<PolicyRuleDef>>,
visited: &mut HashSet<String>,
) -> bool {
stmts.iter().any(|s| match s {
PolicyRuleDef::Rule { predicate, .. } => {
predicate.as_ref().is_some_and(|p| p.needs_geoip())
}
PolicyRuleDef::Apply { name } => {
if visited.contains(name) {
return false;
}
visited.insert(name.clone());
policies.get(name).is_some_and(|inner| {
policy_needs_geoip(inner, policies, visited)
})
}
})
}