489 lines
15 KiB
Rust
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");
|