Files
RustProxy/src/redirect.rs
T
2026-10-04 11:15:58 +02:00

489 lines
15 KiB
Rust

//! 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<String>,
#[serde(default)]
pub rules: Vec<Rule>,
}
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<String>,
fragment: Option<String>,
},
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<crate::vhost::Listener>,
pub http2_enabled: bool,
pub http3_enabled: bool,
pub rules: Vec<CompiledRule>,
}
pub fn compile(host: &Host, listeners: &crate::settings::Listeners) -> Result<CompiledHost> {
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<axum::response::Response> {
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<HeaderValue> {
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");