v0.24.5
This commit is contained in:
commit
5e23e9af02
240 files changed
+99957
No files matched your search
+593
@@ -0,0 +1,593 @@
|
||||
//! 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rule() -> Rule {
|
||||
Rule {
|
||||
target: "https://new.example/base?fixed=1#section".into(),
|
||||
..Rule::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redirect_location_supports_path_and_query_modes() {
|
||||
let host = Host {
|
||||
id: "legacy".into(),
|
||||
name: "Legacy".into(),
|
||||
enabled: true,
|
||||
http2_enabled: true,
|
||||
http3_enabled: true,
|
||||
frontend: crate::vhost::Frontend {
|
||||
hosts: vec!["old.example".into()],
|
||||
listener_ids: vec!["http".into()],
|
||||
},
|
||||
certificate_id: None,
|
||||
rules: vec![rule()],
|
||||
};
|
||||
let listeners =
|
||||
crate::settings::Listeners::from_startup("127.0.0.1:8080".parse().unwrap(), None);
|
||||
let compiled = compile(&host, &listeners).unwrap();
|
||||
let uri: Uri = "/products/1?src=old".parse().unwrap();
|
||||
assert_eq!(
|
||||
compiled.rules[0]
|
||||
.location("old.example", false, &uri)
|
||||
.unwrap(),
|
||||
"https://new.example/base/products/1?fixed=1&src=old#section"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_prefix_and_host_target_are_deterministic() {
|
||||
let mut value = rule();
|
||||
value.path_prefix = "/old".into();
|
||||
value.target_type = TargetType::Host;
|
||||
value.target = "app.example:8443".into();
|
||||
value.target_scheme = TargetScheme::Https;
|
||||
value.path_mode = PathMode::StripPrefix;
|
||||
value.query_mode = QueryMode::Drop;
|
||||
let host = Host {
|
||||
id: "legacy".into(),
|
||||
name: "Legacy".into(),
|
||||
enabled: true,
|
||||
http2_enabled: true,
|
||||
http3_enabled: true,
|
||||
frontend: crate::vhost::Frontend {
|
||||
hosts: vec!["old.example".into()],
|
||||
listener_ids: vec!["http".into()],
|
||||
},
|
||||
certificate_id: None,
|
||||
rules: vec![value],
|
||||
};
|
||||
let listeners =
|
||||
crate::settings::Listeners::from_startup("127.0.0.1:8080".parse().unwrap(), None);
|
||||
let compiled = compile(&host, &listeners).unwrap();
|
||||
let uri: Uri = "/old/path?x=1".parse().unwrap();
|
||||
assert_eq!(
|
||||
compiled.rules[0]
|
||||
.location("old.example", false, &uri)
|
||||
.unwrap(),
|
||||
"https://app.example:8443/path"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_rejects_overlaps_and_unconditional_path_loop() {
|
||||
let mut host = Host {
|
||||
id: "legacy".into(),
|
||||
name: "Legacy".into(),
|
||||
enabled: true,
|
||||
http2_enabled: true,
|
||||
http3_enabled: true,
|
||||
frontend: crate::vhost::Frontend {
|
||||
hosts: vec!["OLD.EXAMPLE.".into()],
|
||||
listener_ids: vec!["http".into()],
|
||||
},
|
||||
certificate_id: None,
|
||||
rules: vec![Rule {
|
||||
target_type: TargetType::Path,
|
||||
target: "/".into(),
|
||||
path_mode: PathMode::Replace,
|
||||
..Rule::default()
|
||||
}],
|
||||
};
|
||||
assert!(host.validate().is_err());
|
||||
host.rules = vec![
|
||||
Rule {
|
||||
target: "https://new.example/".into(),
|
||||
..Rule::default()
|
||||
},
|
||||
Rule {
|
||||
id: "other".into(),
|
||||
target: "https://other.example/".into(),
|
||||
..Rule::default()
|
||||
},
|
||||
];
|
||||
assert!(host.validate().is_err());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user