1use std::collections::HashSet;
24use std::fmt;
25use std::net::SocketAddr;
26use std::path::{Path, PathBuf};
27use std::time::Duration;
28
29use autumn_web::config::Env;
30use serde::{Deserialize, Serialize};
31
32pub const DEFAULT_PORT: u16 = 8080;
34
35pub const DEFAULT_SECTION: &str = "pingora";
37
38#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
40#[serde(rename_all = "lowercase")]
41#[non_exhaustive]
42pub enum Fallback {
43 #[default]
45 App,
46 #[serde(rename = "none")]
48 NotFound,
49}
50
51impl Fallback {
52 #[must_use]
54 pub const fn as_str(self) -> &'static str {
55 match self {
56 Self::App => "app",
57 Self::NotFound => "none",
58 }
59 }
60}
61
62impl fmt::Display for Fallback {
63 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64 f.write_str(self.as_str())
65 }
66}
67
68#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71#[non_exhaustive]
72pub enum Selection {
73 #[default]
75 RoundRobin,
76 Consistent,
78}
79
80impl fmt::Display for Selection {
81 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82 f.write_str(match self {
83 Self::RoundRobin => "round_robin",
84 Self::Consistent => "consistent",
85 })
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(default, deny_unknown_fields)]
92#[non_exhaustive]
93pub struct Route {
94 pub name: String,
97 pub host: String,
100 pub path_prefix: String,
103 pub upstreams: Vec<String>,
105 pub strip_prefix: bool,
108 pub upstream_host: String,
110 pub selection: Selection,
112}
113
114impl Default for Route {
115 fn default() -> Self {
116 Self {
117 name: String::new(),
118 host: String::new(),
119 path_prefix: "/".to_owned(),
120 upstreams: Vec::new(),
121 strip_prefix: false,
122 upstream_host: String::new(),
123 selection: Selection::RoundRobin,
124 }
125 }
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(default, deny_unknown_fields)]
133#[non_exhaustive]
134pub struct PingoraConfig {
135 pub enabled: bool,
137 pub bind: String,
141 pub fallback: Fallback,
144 pub trusted_proxies: Vec<String>,
148 pub connect_timeout_ms: u64,
150 pub read_timeout_ms: u64,
152 pub write_timeout_ms: u64,
154 pub max_retries: usize,
158 pub health_check_interval_ms: u64,
160 pub health_check_timeout_ms: u64,
163 pub max_request_body_bytes: u64,
166 pub max_connections: usize,
170 pub max_connections_per_ip: usize,
175 pub shutdown_grace_ms: u64,
179 pub metrics: bool,
181 pub routes: Vec<Route>,
184}
185
186impl Default for PingoraConfig {
187 fn default() -> Self {
188 Self {
189 enabled: true,
190 bind: String::new(),
191 fallback: Fallback::App,
192 trusted_proxies: Vec::new(),
193 connect_timeout_ms: 5_000,
194 read_timeout_ms: 60_000,
195 write_timeout_ms: 60_000,
196 max_retries: 1,
197 health_check_interval_ms: 5_000,
198 health_check_timeout_ms: 1_000,
199 max_request_body_bytes: 10 * 1024 * 1024,
200 max_connections: 10_000,
201 max_connections_per_ip: 0,
202 shutdown_grace_ms: 10_000,
203 metrics: true,
204 routes: Vec::new(),
205 }
206 }
207}
208
209#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
211#[error("invalid pingora configuration: {0}")]
212pub struct ConfigError(String);
213
214impl ConfigError {
215 #[must_use]
217 pub fn message(&self) -> &str {
218 &self.0
219 }
220}
221
222#[derive(Debug, Clone)]
224pub struct Resolved {
225 pub(crate) config: PingoraConfig,
226 pub(crate) profile: String,
227}
228
229impl Resolved {
230 #[must_use]
232 pub const fn config(&self) -> &PingoraConfig {
233 &self.config
234 }
235
236 #[must_use]
238 pub fn profile(&self) -> &str {
239 &self.profile
240 }
241
242 #[must_use]
244 pub fn is_development(&self) -> bool {
245 matches!(self.profile.as_str(), "dev" | "test")
246 }
247
248 pub(crate) fn explicit(config: PingoraConfig) -> Self {
251 let profile = autumn_web::dotenv::os_env_with_dotenv().map_or_else(
252 |_| resolve_active_profile(&autumn_web::config::OsEnv).1,
253 |env| resolve_active_profile(&env).1,
254 );
255 Self { config, profile }
256 }
257}
258
259const fn millis(ms: u64) -> Option<Duration> {
260 if ms == 0 {
261 None
262 } else {
263 Some(Duration::from_millis(ms))
264 }
265}
266
267pub const MAX_CONNECTIONS_LIMIT: usize = 1_000_000;
269
270pub const RESERVED_NAMES: [&str; 2] = ["fallback", "unmatched"];
272
273impl PingoraConfig {
274 pub fn bind_addr(&self, development: bool) -> Result<SocketAddr, ConfigError> {
281 let bind = self.bind.trim();
282 if bind.is_empty() {
283 let host = if development {
284 [127, 0, 0, 1]
285 } else {
286 [0, 0, 0, 0]
287 };
288 return Ok(SocketAddr::from((host, DEFAULT_PORT)));
289 }
290 bind.parse().map_err(|_| {
291 ConfigError(format!(
292 "`bind` must be IP:port (for example \"0.0.0.0:8080\"), found \"{}\"",
293 self.bind
294 ))
295 })
296 }
297
298 #[must_use]
300 pub const fn connect_timeout(&self) -> Duration {
301 Duration::from_millis(self.connect_timeout_ms)
302 }
303
304 #[must_use]
306 pub const fn read_timeout(&self) -> Option<Duration> {
307 millis(self.read_timeout_ms)
308 }
309
310 #[must_use]
312 pub const fn write_timeout(&self) -> Option<Duration> {
313 millis(self.write_timeout_ms)
314 }
315
316 #[must_use]
318 pub const fn health_check_interval(&self) -> Option<Duration> {
319 millis(self.health_check_interval_ms)
320 }
321
322 #[must_use]
324 pub const fn health_check_timeout(&self) -> Duration {
325 Duration::from_millis(self.health_check_timeout_ms)
326 }
327
328 #[must_use]
330 pub const fn shutdown_grace(&self) -> Duration {
331 Duration::from_millis(self.shutdown_grace_ms)
332 }
333
334 pub fn from_toml_str(text: &str, section: &str) -> Result<Self, ConfigError> {
342 let document: toml::Table = toml::from_str(text).map_err(|e| ConfigError(e.to_string()))?;
343 let config = Self::from_section(document.get(section))?;
344 config.validate()?;
345 Ok(config)
346 }
347
348 fn from_section(section: Option<&toml::Value>) -> Result<Self, ConfigError> {
349 section.map_or_else(
350 || Ok(Self::default()),
351 |value| {
352 value
353 .clone()
354 .try_into()
355 .map_err(|e: toml::de::Error| ConfigError(e.to_string()))
356 },
357 )
358 }
359
360 pub fn resolve(section: &str) -> Result<Resolved, ConfigError> {
367 let env = autumn_web::dotenv::os_env_with_dotenv()
368 .map_err(|e| ConfigError(format!("cannot read .env files: {e}")))?;
369 Self::resolve_with_env(section, &env)
370 }
371
372 pub fn resolve_with_env(section: &str, env: &dyn Env) -> Result<Resolved, ConfigError> {
378 let (selected, canonical) = resolve_active_profile(env);
379 let mut merged = toml::Value::Table(toml::map::Map::new());
380
381 if let Some(base) = read_optional_toml(&find_config_file("autumn.toml", env))? {
382 deep_merge(&mut merged, base.clone());
383 for name in profile_inline_lookup_names(&canonical) {
384 if let Some(profile) = profile_section(&base, name) {
385 deep_merge(&mut merged, profile);
386 }
387 }
388 }
389 for name in autumn_web::config::profile_override_file_lookup_names(&canonical, &selected) {
390 let path = find_config_file(&format!("autumn-{name}.toml"), env);
391 if let Some(overlay) = read_optional_toml(&path)? {
392 deep_merge(&mut merged, overlay);
393 break;
394 }
395 }
396
397 let mut section_value = merged
398 .get(section)
399 .cloned()
400 .unwrap_or_else(|| toml::Value::Table(toml::map::Map::new()));
401 if !section_value.is_table() {
402 return Err(ConfigError(format!("`{section}` must be a table")));
403 }
404 let mut config = Self::from_section(Some(§ion_value))?;
405 apply_env_overrides(section, &mut section_value, &mut config, env)?;
406 config.validate()?;
407 Ok(Resolved {
408 config,
409 profile: canonical,
410 })
411 }
412
413 pub fn validate(&self) -> Result<(), ConfigError> {
419 self.bind_addr(true)?;
420 for (key, value) in [
421 ("shutdown_grace_ms", self.shutdown_grace_ms),
422 ("connect_timeout_ms", self.connect_timeout_ms),
423 ("health_check_timeout_ms", self.health_check_timeout_ms),
424 ] {
425 if value == 0 {
426 return Err(ConfigError(format!("`{key}` must be more than 0")));
427 }
428 }
429 for range in &self.trusted_proxies {
430 if crate::forwarded::IpRange::parse(range).is_none() {
431 return Err(ConfigError(format!(
432 "`trusted_proxies` entry \"{range}\" must be an IP or a CIDR range"
433 )));
434 }
435 }
436 if self.max_connections > MAX_CONNECTIONS_LIMIT {
437 return Err(ConfigError(format!(
438 "`max_connections` must be at most {MAX_CONNECTIONS_LIMIT}"
439 )));
440 }
441 let mut names = HashSet::new();
442 let mut rules = HashSet::new();
443 for route in &self.routes {
444 route.validate()?;
445 if !names.insert(route.name.as_str()) {
446 return Err(ConfigError(format!(
447 "two routes have the name `{}`",
448 route.name
449 )));
450 }
451 let rule = (
452 route.host.to_ascii_lowercase(),
453 normalize_prefix(&route.path_prefix),
454 );
455 if !rules.insert(rule) {
456 return Err(ConfigError(format!(
457 "route `{}` has the same host and path_prefix as an earlier route",
458 route.name
459 )));
460 }
461 }
462 Ok(())
463 }
464}
465
466pub fn normalize_prefix(prefix: &str) -> String {
468 let trimmed = prefix.trim();
469 let trimmed = if trimmed.is_empty() { "/" } else { trimmed };
470 trimmed.trim_end_matches('/').to_owned()
471}
472
473impl Route {
474 #[must_use]
476 pub fn new(name: impl Into<String>) -> Self {
477 Self {
478 name: name.into(),
479 ..Self::default()
480 }
481 }
482
483 #[must_use]
485 pub fn host(mut self, host: impl Into<String>) -> Self {
486 self.host = host.into();
487 self
488 }
489
490 #[must_use]
492 pub fn path_prefix(mut self, prefix: impl Into<String>) -> Self {
493 self.path_prefix = prefix.into();
494 self
495 }
496
497 #[must_use]
499 pub fn upstream(mut self, upstream: impl Into<String>) -> Self {
500 self.upstreams.push(upstream.into());
501 self
502 }
503
504 #[must_use]
506 pub fn upstreams<I, S>(mut self, upstreams: I) -> Self
507 where
508 I: IntoIterator<Item = S>,
509 S: Into<String>,
510 {
511 self.upstreams.extend(upstreams.into_iter().map(Into::into));
512 self
513 }
514
515 #[must_use]
517 pub const fn strip_prefix(mut self, strip: bool) -> Self {
518 self.strip_prefix = strip;
519 self
520 }
521
522 #[must_use]
524 pub fn upstream_host(mut self, host: impl Into<String>) -> Self {
525 self.upstream_host = host.into();
526 self
527 }
528
529 #[must_use]
531 pub const fn selection(mut self, selection: Selection) -> Self {
532 self.selection = selection;
533 self
534 }
535
536 pub fn validate(&self) -> Result<(), ConfigError> {
542 let fail = |message: String| Err(ConfigError(format!("route `{}`: {message}", self.name)));
543 let name_ok = !self.name.is_empty()
544 && self
545 .name
546 .chars()
547 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'));
548 if !name_ok {
549 return fail(
550 "`name` must be letters, digits, `_`, `-` or `.`, and not empty".to_owned(),
551 );
552 }
553 if RESERVED_NAMES.contains(&self.name.as_str()) {
554 return fail(format!("the name `{}` is reserved", self.name));
555 }
556 if !valid_host_pattern(&self.host) {
557 return fail(format!(
558 "`host` must be a host name or `*.domain`, without a port, found \"{}\"",
559 self.host
560 ));
561 }
562 let prefix = self.path_prefix.trim();
563 if !prefix.starts_with('/')
564 || prefix.contains(['?', '#'])
565 || prefix.chars().any(|c| c.is_whitespace() || c.is_control())
566 || crate::router::unsafe_path(prefix)
567 {
568 return fail(format!(
569 "`path_prefix` must start with `/` and have no query, fragment or dot segment, found \"{}\"",
570 self.path_prefix
571 ));
572 }
573 if self.upstreams.is_empty() {
574 return fail("`upstreams` must have at least one address".to_owned());
575 }
576 let mut seen = HashSet::new();
577 for upstream in &self.upstreams {
578 if !valid_upstream(upstream) {
579 return fail(format!(
580 "upstream \"{upstream}\" must be host:port (for example \"10.0.0.7:8080\")"
581 ));
582 }
583 if !seen.insert(upstream.to_ascii_lowercase()) {
584 return fail(format!("upstream \"{upstream}\" is in the list two times"));
585 }
586 }
587 if http::HeaderValue::from_str(&self.upstream_host).is_err()
588 || self.upstream_host.chars().any(char::is_whitespace)
589 {
590 return fail("`upstream_host` must be a valid Host header value".to_owned());
591 }
592 Ok(())
593 }
594}
595
596fn valid_upstream(upstream: &str) -> bool {
598 if upstream.parse::<SocketAddr>().is_ok() {
599 return true;
600 }
601 let Some((host, port)) = upstream.rsplit_once(':') else {
602 return false;
603 };
604 port.parse::<u16>().is_ok_and(|p| p > 0) && valid_host_name(host)
605}
606
607fn valid_host_name(host: &str) -> bool {
609 !host.is_empty()
610 && host.len() <= 253
611 && host.split('.').all(|label| {
612 !label.is_empty()
613 && label.len() <= 63
614 && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
615 })
616}
617
618fn valid_host_pattern(pattern: &str) -> bool {
620 if pattern.is_empty() {
621 return true;
622 }
623 if let Some(inner) = pattern.strip_prefix('[').and_then(|p| p.strip_suffix(']')) {
624 return inner.parse::<std::net::Ipv6Addr>().is_ok();
625 }
626 let name = pattern.strip_prefix("*.").unwrap_or(pattern);
627 valid_host_name(name)
628}
629
630fn env_prefix(section: &str) -> String {
632 format!("AUTUMN_{}__", section.to_ascii_uppercase())
633}
634
635fn apply_env_overrides(
641 section_name: &str,
642 section: &mut toml::Value,
643 config: &mut PingoraConfig,
644 env: &dyn Env,
645) -> Result<(), ConfigError> {
646 let defaults =
647 toml::Value::try_from(PingoraConfig::default()).map_err(|e| ConfigError(e.to_string()))?;
648 let prefix = env_prefix(section_name);
649 let Some(keys) = defaults.as_table() else {
650 return Ok(());
651 };
652 for name in keys.keys() {
653 let key = format!("{prefix}{}", name.to_ascii_uppercase());
654 let Some(raw) = env_trimmed(env, &key) else {
655 continue;
656 };
657 let mut value = parse_env_value(&raw);
658 if name == "fallback"
660 && let toml::Value::String(text) = &value
661 {
662 value = toml::Value::String(text.to_ascii_lowercase());
663 }
664 let mut candidate = section.clone();
665 if let Some(table) = candidate.as_table_mut() {
666 table.insert(name.clone(), value);
667 }
668 *config = PingoraConfig::from_section(Some(&candidate))
669 .map_err(|error| ConfigError(format!("{key}: {}", error.message())))?;
670 *section = candidate;
671 }
672 Ok(())
673}
674
675fn parse_env_value(raw: &str) -> toml::Value {
676 toml::from_str::<toml::Table>(&format!("v = {raw}"))
677 .ok()
678 .and_then(|mut table| table.remove("v"))
679 .unwrap_or_else(|| toml::Value::String(raw.to_owned()))
680}
681
682fn env_trimmed(env: &dyn Env, key: &str) -> Option<String> {
684 env.var(key)
685 .ok()
686 .map(|value| value.trim().to_owned())
687 .filter(|value| !value.is_empty())
688}
689
690fn resolve_active_profile(env: &dyn Env) -> (String, String) {
695 let selected = resolve_profile_input(env);
696 let canonical =
697 autumn_web::config::normalize_profile_name(&selected).unwrap_or_else(|| "dev".to_owned());
698 (selected, canonical)
699}
700
701fn resolve_profile_input(env: &dyn Env) -> String {
702 if let Some(value) = env_trimmed(env, "AUTUMN_ENV") {
703 return value;
704 }
705 if let Some(value) = env_trimmed(env, "AUTUMN_PROFILE") {
706 return value;
707 }
708 let args: Vec<String> = std::env::args().collect();
709 for (index, arg) in args.iter().enumerate() {
710 if arg == "--profile"
711 && let Some(profile) = args.get(index.saturating_add(1))
712 && !profile.trim().is_empty()
713 {
714 return profile.trim().to_owned();
715 }
716 if let Some(profile) = arg.strip_prefix("--profile=")
717 && !profile.trim().is_empty()
718 {
719 return profile.trim().to_owned();
720 }
721 }
722 if env_trimmed(env, "AUTUMN_IS_DEBUG").as_deref() == Some("0") {
723 return "prod".to_owned();
724 }
725 "dev".to_owned()
726}
727
728fn find_config_file(filename: &str, env: &dyn Env) -> PathBuf {
731 if let Some(dir) = env_trimmed(env, "AUTUMN_MANIFEST_DIR") {
732 let candidate = PathBuf::from(dir).join(filename);
733 if candidate.exists() {
734 return candidate;
735 }
736 }
737 PathBuf::from(filename)
738}
739
740fn read_optional_toml(path: &Path) -> Result<Option<toml::Value>, ConfigError> {
741 match std::fs::read_to_string(path) {
742 Ok(contents) => {
743 let table = toml::from_str::<toml::Table>(&contents)
744 .map_err(|e| ConfigError(format!("cannot parse {}: {e}", path.display())))?;
745 Ok(Some(toml::Value::Table(table)))
746 }
747 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
748 Err(error) => Err(ConfigError(format!(
749 "cannot read {}: {error}",
750 path.display()
751 ))),
752 }
753}
754
755fn profile_inline_lookup_names(canonical: &str) -> Vec<&str> {
756 match canonical {
757 "prod" => vec!["production", "prod"],
758 "dev" => vec!["development", "dev"],
759 other => vec![other],
760 }
761}
762
763fn profile_section(base: &toml::Value, profile: &str) -> Option<toml::Value> {
764 base.get("profile")
765 .and_then(toml::Value::as_table)
766 .and_then(|profiles| profiles.get(profile))
767 .and_then(toml::Value::as_table)
768 .map(|table| toml::Value::Table(table.clone()))
769}
770
771fn deep_merge(base: &mut toml::Value, overlay: toml::Value) {
773 deep_merge_at(base, overlay, 0);
774}
775
776fn deep_merge_at(base: &mut toml::Value, overlay: toml::Value, depth: usize) {
777 const MAX_MERGE_DEPTH: usize = 16;
778 if depth > MAX_MERGE_DEPTH {
779 return;
780 }
781 let toml::Value::Table(overlay_table) = overlay else {
782 return;
783 };
784 let Some(base_table) = base.as_table_mut() else {
785 return;
786 };
787 for (key, overlay_value) in overlay_table {
788 let recurse =
789 overlay_value.is_table() && base_table.get(&key).is_some_and(toml::Value::is_table);
790 if recurse {
791 if let Some(base_value) = base_table.get_mut(&key) {
792 deep_merge_at(base_value, overlay_value, depth.saturating_add(1));
793 }
794 } else {
795 base_table.insert(key, overlay_value);
796 }
797 }
798}