//! Declarative HTTP redirects with listener-aware hostname ownership. //! Redirects never open an upstream connection and are resolved before proxy routes. use anyhow::{ensure, Context, Result}; use http::{header::HeaderValue, uri::Authority, StatusCode, Uri}; use serde::{Deserialize, Serialize}; use std::str::FromStr; use url::{Position, Url}; fn yes() -> bool { true } fn root() -> String { "/".into() } fn default_status() -> u16 { 308 } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum SourceScheme { #[default] Any, Http, Https, } impl SourceScheme { fn matches(self, tls: bool) -> bool { matches!(self, Self::Any) || matches!((self, tls), (Self::Http, false) | (Self::Https, true)) } } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum MatchType { #[default] Prefix, Exact, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum TargetType { #[default] Url, Host, Path, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum TargetScheme { #[default] Preserve, Http, Https, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PathMode { #[default] Append, StripPrefix, Replace, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum QueryMode { #[default] Preserve, Drop, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Rule { pub id: String, pub enabled: bool, pub source_scheme: SourceScheme, pub match_type: MatchType, pub path_prefix: String, pub target_type: TargetType, /// Absolute http(s) URL, authority (`example.com[:port]`) or absolute path, /// according to `target_type`. pub target: String, /// Used only by `target_type=host`. pub target_scheme: TargetScheme, pub path_mode: PathMode, pub query_mode: QueryMode, pub status_code: u16, /// 0 emits `Cache-Control: no-store`; otherwise public max-age, up to one year. pub cache_seconds: u32, } impl Default for Rule { fn default() -> Self { Self { id: "root".into(), enabled: true, source_scheme: SourceScheme::Any, match_type: MatchType::Prefix, path_prefix: root(), target_type: TargetType::Url, target: "https://example.com/".into(), target_scheme: TargetScheme::Preserve, path_mode: PathMode::Append, query_mode: QueryMode::Preserve, status_code: default_status(), cache_seconds: 0, } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Host { pub id: String, #[serde(default)] pub name: String, #[serde(default = "yes")] pub enabled: bool, #[serde(default = "yes")] pub http2_enabled: bool, #[serde(default = "yes")] pub http3_enabled: bool, pub frontend: crate::vhost::Frontend, #[serde(default, skip_serializing_if = "Option::is_none")] pub certificate_id: Option, #[serde(default)] pub rules: Vec, } impl Host { pub fn validate(&mut self) -> Result<()> { ensure!(crate::config::valid_id(&self.id), "invalid redirect id"); if self.name.is_empty() { self.name = self.id.clone(); } self.name = self.name.trim().to_owned(); ensure!( !self.name.is_empty() && self.name.len() <= 96 && !self.name.chars().any(char::is_control), "redirect name must be 1..96 bytes without controls" ); ensure!( !self.frontend.hosts.is_empty(), "redirect requires at least one hostname" ); let mut hosts = std::collections::HashSet::new(); for host in &mut self.frontend.hosts { *host = crate::config::normalize_host_pattern(host)?; ensure!(hosts.insert(host.clone()), "duplicate redirect hostname"); } ensure!( !self.frontend.listener_ids.is_empty() && self.frontend.listener_ids.len() <= 16, "redirect requires 1..16 named listeners" ); if self.rules.is_empty() { self.rules.push(Rule::default()); } ensure!(self.rules.len() <= 64, "at most 64 rules per redirect host"); let mut ids = std::collections::HashSet::new(); let mut bindings: Vec<(String, SourceScheme, MatchType)> = Vec::new(); for rule in &mut self.rules { ensure!( crate::config::valid_id(&rule.id) && ids.insert(rule.id.clone()), "invalid/duplicate redirect rule id" ); normalize_prefix(&mut rule.path_prefix)?; ensure!( matches!(rule.status_code, 301 | 302 | 303 | 307 | 308), "redirect status_code must be 301, 302, 303, 307 or 308" ); ensure!( rule.cache_seconds <= 31_536_000, "redirect cache_seconds must be <= 31536000" ); validate_target(rule)?; for (path, scheme, kind) in &bindings { if path == &rule.path_prefix && *kind == rule.match_type && schemes_overlap(*scheme, rule.source_scheme) { anyhow::bail!("overlapping redirect rules for {}", rule.path_prefix); } } bindings.push(( rule.path_prefix.clone(), rule.source_scheme, rule.match_type, )); if rule.target_type == TargetType::Path && rule.path_mode == PathMode::Replace && rule.target == rule.path_prefix { anyhow::bail!( "redirect rule {} is an unconditional self-redirect", rule.id ); } } Ok(()) } } fn schemes_overlap(a: SourceScheme, b: SourceScheme) -> bool { a == SourceScheme::Any || b == SourceScheme::Any || a == b } fn normalize_prefix(prefix: &mut String) -> Result<()> { ensure!( prefix.starts_with('/') && prefix.len() <= 512 && !prefix.contains('?') && !prefix.contains('#') && !prefix.bytes().any(|b| b <= 32 || b == 127), "invalid redirect path prefix" ); if prefix.len() > 1 { *prefix = prefix.trim_end_matches('/').to_owned(); } *prefix = crate::acl::routing_path(prefix)?; Ok(()) } fn validate_path(value: &str) -> Result<()> { ensure!( value.starts_with('/') && value.len() <= 2048 && !value.contains('?') && !value.contains('#') && !value.bytes().any(|b| b <= 32 || b == 127), "redirect path target must be an absolute path without query or fragment" ); crate::acl::routing_path(value)?; Ok(()) } fn validate_target(rule: &Rule) -> Result<()> { ensure!( !rule.target.is_empty() && rule.target.len() <= 2048, "redirect target must be 1..2048 bytes" ); match rule.target_type { TargetType::Url => { let url = Url::parse(&rule.target).context("invalid redirect URL")?; ensure!( matches!(url.scheme(), "http" | "https"), "redirect URL scheme must be http or https" ); ensure!(url.host_str().is_some(), "redirect URL requires a host"); ensure!( url.username().is_empty() && url.password().is_none(), "redirect URL must not contain credentials" ); } TargetType::Host => { ensure!( !rule.target.contains('@'), "redirect host must not contain credentials" ); let authority = Authority::from_str(&rule.target).context("invalid redirect host/port")?; crate::config::request_host(authority.as_str()) .context("invalid redirect host/port")?; } TargetType::Path => validate_path(&rule.target)?, } Ok(()) } #[derive(Debug, Clone)] enum CompiledTarget { Url { prefix: String, base_path: String, query: Option, fragment: Option, }, Host { authority: String, scheme: TargetScheme, }, Path { base_path: String, }, } #[derive(Debug, Clone)] pub struct CompiledRule { pub id: String, pub source_scheme: SourceScheme, pub match_type: MatchType, pub path_prefix: String, target: CompiledTarget, pub path_mode: PathMode, pub query_mode: QueryMode, pub status_code: u16, pub cache_seconds: u32, } #[derive(Debug, Clone)] pub struct CompiledHost { pub id: String, pub bindings: Vec, pub http2_enabled: bool, pub http3_enabled: bool, pub rules: Vec, } pub fn compile(host: &Host, listeners: &crate::settings::Listeners) -> Result { let bindings = host.frontend.resolved(listeners)?; let mut rules = Vec::new(); for rule in host.rules.iter().filter(|rule| rule.enabled) { let target = match rule.target_type { TargetType::Url => { let url = Url::parse(&rule.target)?; CompiledTarget::Url { prefix: url[..Position::BeforePath].to_owned(), base_path: url.path().to_owned(), query: url.query().map(str::to_owned), fragment: url.fragment().map(str::to_owned), } } TargetType::Host => CompiledTarget::Host { authority: Authority::from_str(&rule.target)?.to_string(), scheme: rule.target_scheme, }, TargetType::Path => CompiledTarget::Path { base_path: rule.target.clone(), }, }; rules.push(CompiledRule { id: rule.id.clone(), source_scheme: rule.source_scheme, match_type: rule.match_type, path_prefix: rule.path_prefix.clone(), target, path_mode: rule.path_mode, query_mode: rule.query_mode, status_code: rule.status_code, cache_seconds: rule.cache_seconds, }); } rules.sort_by(|a, b| { b.path_prefix.len().cmp(&a.path_prefix.len()).then_with(|| { match (a.match_type, b.match_type) { (MatchType::Exact, MatchType::Prefix) => std::cmp::Ordering::Less, (MatchType::Prefix, MatchType::Exact) => std::cmp::Ordering::Greater, _ => std::cmp::Ordering::Equal, } }) }); Ok(CompiledHost { id: host.id.clone(), bindings, http2_enabled: host.http2_enabled, http3_enabled: host.http3_enabled, rules, }) } impl CompiledRule { pub fn matches(&self, path: &str, tls: bool) -> bool { self.source_scheme.matches(tls) && match self.match_type { MatchType::Exact => path == self.path_prefix, MatchType::Prefix => crate::config::prefix_matches(&self.path_prefix, path), } } pub fn response( &self, request_host: &str, tls: bool, uri: &Uri, ) -> Option { use axum::response::IntoResponse; let location = self.location(request_host, tls, uri)?; let status = StatusCode::from_u16(self.status_code).ok()?; let mut response = status.into_response(); response .headers_mut() .insert(http::header::LOCATION, location); let cache = if self.cache_seconds == 0 { HeaderValue::from_static("no-store") } else { HeaderValue::from_str(&format!("public, max-age={}", self.cache_seconds)).ok()? }; response .headers_mut() .insert(http::header::CACHE_CONTROL, cache); Some(response) } pub fn location(&self, request_host: &str, tls: bool, uri: &Uri) -> Option { let request_path = uri.path(); let suffix = match self.path_mode { PathMode::Replace => "", PathMode::Append => request_path, PathMode::StripPrefix => request_path.strip_prefix(&self.path_prefix).unwrap_or(""), }; let (prefix, base_path, static_query, fragment) = match &self.target { CompiledTarget::Url { prefix, base_path, query, fragment, } => ( prefix.clone(), base_path.clone(), query.clone(), fragment.clone(), ), CompiledTarget::Host { authority, scheme } => { let scheme = match scheme { TargetScheme::Preserve => { if tls { "https" } else { "http" } } TargetScheme::Http => "http", TargetScheme::Https => "https", }; (format!("{scheme}://{authority}"), "/".into(), None, None) } CompiledTarget::Path { base_path } => { let scheme = if tls { "https" } else { "http" }; ( format!("{scheme}://{request_host}"), base_path.clone(), None, None, ) } }; let path = match self.path_mode { PathMode::Replace => base_path, PathMode::Append | PathMode::StripPrefix => join_paths(&base_path, suffix), }; let mut value = format!("{prefix}{path}"); let source_query = if self.query_mode == QueryMode::Preserve { uri.query() } else { None }; match (static_query.as_deref(), source_query) { (Some(a), Some(b)) if !a.is_empty() && !b.is_empty() => { value.push_str(&format!("?{a}&{b}")) } (Some(a), _) if !a.is_empty() => value.push_str(&format!("?{a}")), (_, Some(b)) if !b.is_empty() => value.push_str(&format!("?{b}")), _ => {} } if let Some(fragment) = fragment.filter(|value| !value.is_empty()) { value.push('#'); value.push_str(&fragment); } HeaderValue::from_str(&value).ok() } } fn join_paths(base: &str, suffix: &str) -> String { if suffix.is_empty() || suffix == "/" { return if base.is_empty() { "/".into() } else { base.into() }; } let base = if base.is_empty() { "/" } else { base }; if base == "/" { format!("/{}", suffix.trim_start_matches('/')) } else { format!( "{}/{}", base.trim_end_matches('/'), suffix.trim_start_matches('/') ) } } #[cfg(test)] include!("redirect/tests.rs");