use std::collections::HashSet;
use std::fmt;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::time::Duration;
use autumn_web::config::Env;
use serde::{Deserialize, Serialize};
pub const DEFAULT_PORT: u16 = 8080;
pub const DEFAULT_SECTION: &str = "pingora";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum Fallback {
#[default]
App,
#[serde(rename = "none")]
NotFound,
}
impl Fallback {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::App => "app",
Self::NotFound => "none",
}
}
}
impl fmt::Display for Fallback {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Selection {
#[default]
RoundRobin,
Consistent,
}
impl fmt::Display for Selection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::RoundRobin => "round_robin",
Self::Consistent => "consistent",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
#[non_exhaustive]
pub struct Route {
pub name: String,
pub host: String,
pub path_prefix: String,
pub upstreams: Vec<String>,
pub strip_prefix: bool,
pub upstream_host: String,
pub selection: Selection,
}
impl Default for Route {
fn default() -> Self {
Self {
name: String::new(),
host: String::new(),
path_prefix: "/".to_owned(),
upstreams: Vec::new(),
strip_prefix: false,
upstream_host: String::new(),
selection: Selection::RoundRobin,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
#[non_exhaustive]
pub struct PingoraConfig {
pub enabled: bool,
pub bind: String,
pub fallback: Fallback,
pub trusted_proxies: Vec<String>,
pub connect_timeout_ms: u64,
pub read_timeout_ms: u64,
pub write_timeout_ms: u64,
pub max_retries: usize,
pub health_check_interval_ms: u64,
pub health_check_timeout_ms: u64,
pub max_request_body_bytes: u64,
pub max_connections: usize,
pub max_connections_per_ip: usize,
pub shutdown_grace_ms: u64,
pub metrics: bool,
pub routes: Vec<Route>,
}
impl Default for PingoraConfig {
fn default() -> Self {
Self {
enabled: true,
bind: String::new(),
fallback: Fallback::App,
trusted_proxies: Vec::new(),
connect_timeout_ms: 5_000,
read_timeout_ms: 60_000,
write_timeout_ms: 60_000,
max_retries: 1,
health_check_interval_ms: 5_000,
health_check_timeout_ms: 1_000,
max_request_body_bytes: 10 * 1024 * 1024,
max_connections: 10_000,
max_connections_per_ip: 0,
shutdown_grace_ms: 10_000,
metrics: true,
routes: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("invalid pingora configuration: {0}")]
pub struct ConfigError(String);
impl ConfigError {
#[must_use]
pub fn message(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone)]
pub struct Resolved {
pub(crate) config: PingoraConfig,
pub(crate) profile: String,
}
impl Resolved {
#[must_use]
pub const fn config(&self) -> &PingoraConfig {
&self.config
}
#[must_use]
pub fn profile(&self) -> &str {
&self.profile
}
#[must_use]
pub fn is_development(&self) -> bool {
matches!(self.profile.as_str(), "dev" | "test")
}
pub(crate) fn explicit(config: PingoraConfig) -> Self {
let profile = autumn_web::dotenv::os_env_with_dotenv().map_or_else(
|_| resolve_active_profile(&autumn_web::config::OsEnv).1,
|env| resolve_active_profile(&env).1,
);
Self { config, profile }
}
}
const fn millis(ms: u64) -> Option<Duration> {
if ms == 0 {
None
} else {
Some(Duration::from_millis(ms))
}
}
pub const MAX_CONNECTIONS_LIMIT: usize = 1_000_000;
pub const RESERVED_NAMES: [&str; 2] = ["fallback", "unmatched"];
impl PingoraConfig {
pub fn bind_addr(&self, development: bool) -> Result<SocketAddr, ConfigError> {
let bind = self.bind.trim();
if bind.is_empty() {
let host = if development {
[127, 0, 0, 1]
} else {
[0, 0, 0, 0]
};
return Ok(SocketAddr::from((host, DEFAULT_PORT)));
}
bind.parse().map_err(|_| {
ConfigError(format!(
"`bind` must be IP:port (for example \"0.0.0.0:8080\"), found \"{}\"",
self.bind
))
})
}
#[must_use]
pub const fn connect_timeout(&self) -> Duration {
Duration::from_millis(self.connect_timeout_ms)
}
#[must_use]
pub const fn read_timeout(&self) -> Option<Duration> {
millis(self.read_timeout_ms)
}
#[must_use]
pub const fn write_timeout(&self) -> Option<Duration> {
millis(self.write_timeout_ms)
}
#[must_use]
pub const fn health_check_interval(&self) -> Option<Duration> {
millis(self.health_check_interval_ms)
}
#[must_use]
pub const fn health_check_timeout(&self) -> Duration {
Duration::from_millis(self.health_check_timeout_ms)
}
#[must_use]
pub const fn shutdown_grace(&self) -> Duration {
Duration::from_millis(self.shutdown_grace_ms)
}
pub fn from_toml_str(text: &str, section: &str) -> Result<Self, ConfigError> {
let document: toml::Table = toml::from_str(text).map_err(|e| ConfigError(e.to_string()))?;
let config = Self::from_section(document.get(section))?;
config.validate()?;
Ok(config)
}
fn from_section(section: Option<&toml::Value>) -> Result<Self, ConfigError> {
section.map_or_else(
|| Ok(Self::default()),
|value| {
value
.clone()
.try_into()
.map_err(|e: toml::de::Error| ConfigError(e.to_string()))
},
)
}
pub fn resolve(section: &str) -> Result<Resolved, ConfigError> {
let env = autumn_web::dotenv::os_env_with_dotenv()
.map_err(|e| ConfigError(format!("cannot read .env files: {e}")))?;
Self::resolve_with_env(section, &env)
}
pub fn resolve_with_env(section: &str, env: &dyn Env) -> Result<Resolved, ConfigError> {
let (selected, canonical) = resolve_active_profile(env);
let mut merged = toml::Value::Table(toml::map::Map::new());
if let Some(base) = read_optional_toml(&find_config_file("autumn.toml", env))? {
deep_merge(&mut merged, base.clone());
for name in profile_inline_lookup_names(&canonical) {
if let Some(profile) = profile_section(&base, name) {
deep_merge(&mut merged, profile);
}
}
}
for name in autumn_web::config::profile_override_file_lookup_names(&canonical, &selected) {
let path = find_config_file(&format!("autumn-{name}.toml"), env);
if let Some(overlay) = read_optional_toml(&path)? {
deep_merge(&mut merged, overlay);
break;
}
}
let mut section_value = merged
.get(section)
.cloned()
.unwrap_or_else(|| toml::Value::Table(toml::map::Map::new()));
if !section_value.is_table() {
return Err(ConfigError(format!("`{section}` must be a table")));
}
let mut config = Self::from_section(Some(§ion_value))?;
apply_env_overrides(section, &mut section_value, &mut config, env)?;
config.validate()?;
Ok(Resolved {
config,
profile: canonical,
})
}
pub fn validate(&self) -> Result<(), ConfigError> {
self.bind_addr(true)?;
for (key, value) in [
("shutdown_grace_ms", self.shutdown_grace_ms),
("connect_timeout_ms", self.connect_timeout_ms),
("health_check_timeout_ms", self.health_check_timeout_ms),
] {
if value == 0 {
return Err(ConfigError(format!("`{key}` must be more than 0")));
}
}
for range in &self.trusted_proxies {
if crate::forwarded::IpRange::parse(range).is_none() {
return Err(ConfigError(format!(
"`trusted_proxies` entry \"{range}\" must be an IP or a CIDR range"
)));
}
}
if self.max_connections > MAX_CONNECTIONS_LIMIT {
return Err(ConfigError(format!(
"`max_connections` must be at most {MAX_CONNECTIONS_LIMIT}"
)));
}
let mut names = HashSet::new();
let mut rules = HashSet::new();
for route in &self.routes {
route.validate()?;
if !names.insert(route.name.as_str()) {
return Err(ConfigError(format!(
"two routes have the name `{}`",
route.name
)));
}
let rule = (
route.host.to_ascii_lowercase(),
normalize_prefix(&route.path_prefix),
);
if !rules.insert(rule) {
return Err(ConfigError(format!(
"route `{}` has the same host and path_prefix as an earlier route",
route.name
)));
}
}
Ok(())
}
}
pub fn normalize_prefix(prefix: &str) -> String {
let trimmed = prefix.trim();
let trimmed = if trimmed.is_empty() { "/" } else { trimmed };
trimmed.trim_end_matches('/').to_owned()
}
impl Route {
#[must_use]
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
..Self::default()
}
}
#[must_use]
pub fn host(mut self, host: impl Into<String>) -> Self {
self.host = host.into();
self
}
#[must_use]
pub fn path_prefix(mut self, prefix: impl Into<String>) -> Self {
self.path_prefix = prefix.into();
self
}
#[must_use]
pub fn upstream(mut self, upstream: impl Into<String>) -> Self {
self.upstreams.push(upstream.into());
self
}
#[must_use]
pub fn upstreams<I, S>(mut self, upstreams: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.upstreams.extend(upstreams.into_iter().map(Into::into));
self
}
#[must_use]
pub const fn strip_prefix(mut self, strip: bool) -> Self {
self.strip_prefix = strip;
self
}
#[must_use]
pub fn upstream_host(mut self, host: impl Into<String>) -> Self {
self.upstream_host = host.into();
self
}
#[must_use]
pub const fn selection(mut self, selection: Selection) -> Self {
self.selection = selection;
self
}
pub fn validate(&self) -> Result<(), ConfigError> {
let fail = |message: String| Err(ConfigError(format!("route `{}`: {message}", self.name)));
let name_ok = !self.name.is_empty()
&& self
.name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'));
if !name_ok {
return fail(
"`name` must be letters, digits, `_`, `-` or `.`, and not empty".to_owned(),
);
}
if RESERVED_NAMES.contains(&self.name.as_str()) {
return fail(format!("the name `{}` is reserved", self.name));
}
if !valid_host_pattern(&self.host) {
return fail(format!(
"`host` must be a host name or `*.domain`, without a port, found \"{}\"",
self.host
));
}
let prefix = self.path_prefix.trim();
if !prefix.starts_with('/')
|| prefix.contains(['?', '#'])
|| prefix.chars().any(|c| c.is_whitespace() || c.is_control())
|| crate::router::unsafe_path(prefix)
{
return fail(format!(
"`path_prefix` must start with `/` and have no query, fragment or dot segment, found \"{}\"",
self.path_prefix
));
}
if self.upstreams.is_empty() {
return fail("`upstreams` must have at least one address".to_owned());
}
let mut seen = HashSet::new();
for upstream in &self.upstreams {
if !valid_upstream(upstream) {
return fail(format!(
"upstream \"{upstream}\" must be host:port (for example \"10.0.0.7:8080\")"
));
}
if !seen.insert(upstream.to_ascii_lowercase()) {
return fail(format!("upstream \"{upstream}\" is in the list two times"));
}
}
if http::HeaderValue::from_str(&self.upstream_host).is_err()
|| self.upstream_host.chars().any(char::is_whitespace)
{
return fail("`upstream_host` must be a valid Host header value".to_owned());
}
Ok(())
}
}
fn valid_upstream(upstream: &str) -> bool {
if upstream.parse::<SocketAddr>().is_ok() {
return true;
}
let Some((host, port)) = upstream.rsplit_once(':') else {
return false;
};
port.parse::<u16>().is_ok_and(|p| p > 0) && valid_host_name(host)
}
fn valid_host_name(host: &str) -> bool {
!host.is_empty()
&& host.len() <= 253
&& host.split('.').all(|label| {
!label.is_empty()
&& label.len() <= 63
&& label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
})
}
fn valid_host_pattern(pattern: &str) -> bool {
if pattern.is_empty() {
return true;
}
if let Some(inner) = pattern.strip_prefix('[').and_then(|p| p.strip_suffix(']')) {
return inner.parse::<std::net::Ipv6Addr>().is_ok();
}
let name = pattern.strip_prefix("*.").unwrap_or(pattern);
valid_host_name(name)
}
fn env_prefix(section: &str) -> String {
format!("AUTUMN_{}__", section.to_ascii_uppercase())
}
fn apply_env_overrides(
section_name: &str,
section: &mut toml::Value,
config: &mut PingoraConfig,
env: &dyn Env,
) -> Result<(), ConfigError> {
let defaults =
toml::Value::try_from(PingoraConfig::default()).map_err(|e| ConfigError(e.to_string()))?;
let prefix = env_prefix(section_name);
let Some(keys) = defaults.as_table() else {
return Ok(());
};
for name in keys.keys() {
let key = format!("{prefix}{}", name.to_ascii_uppercase());
let Some(raw) = env_trimmed(env, &key) else {
continue;
};
let mut value = parse_env_value(&raw);
if name == "fallback"
&& let toml::Value::String(text) = &value
{
value = toml::Value::String(text.to_ascii_lowercase());
}
let mut candidate = section.clone();
if let Some(table) = candidate.as_table_mut() {
table.insert(name.clone(), value);
}
*config = PingoraConfig::from_section(Some(&candidate))
.map_err(|error| ConfigError(format!("{key}: {}", error.message())))?;
*section = candidate;
}
Ok(())
}
fn parse_env_value(raw: &str) -> toml::Value {
toml::from_str::<toml::Table>(&format!("v = {raw}"))
.ok()
.and_then(|mut table| table.remove("v"))
.unwrap_or_else(|| toml::Value::String(raw.to_owned()))
}
fn env_trimmed(env: &dyn Env, key: &str) -> Option<String> {
env.var(key)
.ok()
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
}
fn resolve_active_profile(env: &dyn Env) -> (String, String) {
let selected = resolve_profile_input(env);
let canonical =
autumn_web::config::normalize_profile_name(&selected).unwrap_or_else(|| "dev".to_owned());
(selected, canonical)
}
fn resolve_profile_input(env: &dyn Env) -> String {
if let Some(value) = env_trimmed(env, "AUTUMN_ENV") {
return value;
}
if let Some(value) = env_trimmed(env, "AUTUMN_PROFILE") {
return value;
}
let args: Vec<String> = std::env::args().collect();
for (index, arg) in args.iter().enumerate() {
if arg == "--profile"
&& let Some(profile) = args.get(index.saturating_add(1))
&& !profile.trim().is_empty()
{
return profile.trim().to_owned();
}
if let Some(profile) = arg.strip_prefix("--profile=")
&& !profile.trim().is_empty()
{
return profile.trim().to_owned();
}
}
if env_trimmed(env, "AUTUMN_IS_DEBUG").as_deref() == Some("0") {
return "prod".to_owned();
}
"dev".to_owned()
}
fn find_config_file(filename: &str, env: &dyn Env) -> PathBuf {
if let Some(dir) = env_trimmed(env, "AUTUMN_MANIFEST_DIR") {
let candidate = PathBuf::from(dir).join(filename);
if candidate.exists() {
return candidate;
}
}
PathBuf::from(filename)
}
fn read_optional_toml(path: &Path) -> Result<Option<toml::Value>, ConfigError> {
match std::fs::read_to_string(path) {
Ok(contents) => {
let table = toml::from_str::<toml::Table>(&contents)
.map_err(|e| ConfigError(format!("cannot parse {}: {e}", path.display())))?;
Ok(Some(toml::Value::Table(table)))
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(ConfigError(format!(
"cannot read {}: {error}",
path.display()
))),
}
}
fn profile_inline_lookup_names(canonical: &str) -> Vec<&str> {
match canonical {
"prod" => vec!["production", "prod"],
"dev" => vec!["development", "dev"],
other => vec![other],
}
}
fn profile_section(base: &toml::Value, profile: &str) -> Option<toml::Value> {
base.get("profile")
.and_then(toml::Value::as_table)
.and_then(|profiles| profiles.get(profile))
.and_then(toml::Value::as_table)
.map(|table| toml::Value::Table(table.clone()))
}
fn deep_merge(base: &mut toml::Value, overlay: toml::Value) {
deep_merge_at(base, overlay, 0);
}
fn deep_merge_at(base: &mut toml::Value, overlay: toml::Value, depth: usize) {
const MAX_MERGE_DEPTH: usize = 16;
if depth > MAX_MERGE_DEPTH {
return;
}
let toml::Value::Table(overlay_table) = overlay else {
return;
};
let Some(base_table) = base.as_table_mut() else {
return;
};
for (key, overlay_value) in overlay_table {
let recurse =
overlay_value.is_table() && base_table.get(&key).is_some_and(toml::Value::is_table);
if recurse {
if let Some(base_value) = base_table.get_mut(&key) {
deep_merge_at(base_value, overlay_value, depth.saturating_add(1));
}
} else {
base_table.insert(key, overlay_value);
}
}
}