v0.29.2
This commit is contained in:
1 parent
76cb4acef0
commit
68678e98be
84 files changed
+1519
-325
No files matched your search
@@ -0,0 +1,50 @@
|
||||
pub const ERROR_FALLBACK_STATUSES: [u16; 6] = [403, 404, 500, 502, 503, 504];
|
||||
|
||||
fn default_error_fallback_statuses() -> Vec<u16> {
|
||||
vec![404]
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct ErrorFallback {
|
||||
/// Stable ID of an internal Custom Location within the same vhost.
|
||||
pub target: String,
|
||||
/// HTTP statuses intercepted before normal response finalization/cache publication.
|
||||
#[serde(default = "default_error_fallback_statuses")]
|
||||
pub statuses: Vec<u16>,
|
||||
}
|
||||
|
||||
impl Default for ErrorFallback {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
target: String::new(),
|
||||
statuses: default_error_fallback_statuses(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ErrorFallback {
|
||||
pub fn validate(&mut self) -> Result<()> {
|
||||
self.target = self.target.trim().to_owned();
|
||||
ensure!(crate::config::valid_id(&self.target), "invalid error fallback target");
|
||||
ensure!(
|
||||
!self.statuses.is_empty() && self.statuses.len() <= ERROR_FALLBACK_STATUSES.len(),
|
||||
"error fallback requires 1..={} status codes",
|
||||
ERROR_FALLBACK_STATUSES.len()
|
||||
);
|
||||
self.statuses.sort_unstable();
|
||||
self.statuses.dedup();
|
||||
ensure!(
|
||||
self.statuses
|
||||
.iter()
|
||||
.all(|status| ERROR_FALLBACK_STATUSES.contains(status)),
|
||||
"error fallback status must be one of 403, 404, 500, 502, 503, 504"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn matches(&self, status: http::StatusCode) -> bool {
|
||||
self.statuses.contains(&status.as_u16())
|
||||
}
|
||||
}
|
||||
|
||||
+11
-9
@@ -7,7 +7,6 @@ use crate::{
|
||||
use anyhow::{ensure, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -17,12 +16,10 @@ fn root() -> String {
|
||||
fn weight() -> u16 {
|
||||
1
|
||||
}
|
||||
|
||||
pub const DEFAULT_PROXY_CONNECT_TIMEOUT_SECONDS: u64 = 10;
|
||||
pub const DEFAULT_PROXY_RESPONSE_HEADER_TIMEOUT_SECONDS: u64 = 120;
|
||||
pub const DEFAULT_PROXY_READ_TIMEOUT_SECONDS: u64 = 60;
|
||||
pub const DEFAULT_PROXY_SEND_TIMEOUT_SECONDS: u64 = 60;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct ProxyTimeouts {
|
||||
@@ -53,7 +50,6 @@ pub enum MethodPolicyMode {
|
||||
Allow,
|
||||
Block,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct MethodPolicy {
|
||||
@@ -78,7 +74,6 @@ impl MethodPolicy {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn allows(&self, method: &http::Method) -> bool {
|
||||
let listed = self
|
||||
.methods
|
||||
@@ -90,14 +85,12 @@ impl MethodPolicy {
|
||||
MethodPolicyMode::Block => !listed,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn allow_header(&self) -> Option<http::HeaderValue> {
|
||||
(self.mode == MethodPolicyMode::Allow)
|
||||
.then(|| self.methods.join(", ").parse::<http::HeaderValue>().ok())
|
||||
.flatten()
|
||||
}
|
||||
}
|
||||
|
||||
impl ProxyTimeouts {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
for (name, value, max) in [
|
||||
@@ -136,7 +129,6 @@ impl ProxyTimeouts {
|
||||
.map(std::time::Duration::from_secs)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PathMatch {
|
||||
@@ -148,7 +140,6 @@ pub enum PathMatch {
|
||||
Regex,
|
||||
RegexInsensitive,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RewriteMode {
|
||||
@@ -301,6 +292,9 @@ pub struct Vhost {
|
||||
/// Main source for the vhost fallback. Missing in older configs means Reverse Proxy.
|
||||
#[serde(default)]
|
||||
pub source: Source,
|
||||
/// Nginx-style internal error_page/proxy_intercept_errors behavior for the main source.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error_fallback: Option<ErrorFallback>,
|
||||
#[serde(default)]
|
||||
pub backends: Vec<PoolMember>,
|
||||
#[serde(default)]
|
||||
@@ -461,6 +455,10 @@ pub struct PathRoute {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub backend_id: Option<String>,
|
||||
pub id: String,
|
||||
/// Internal named location. It is never selected by the public URI matcher and
|
||||
/// can only be reached through an error fallback from this vhost.
|
||||
#[serde(default)]
|
||||
pub internal: bool,
|
||||
/// Prefix/exact path or a Rust-regex pattern, depending on `match_type`.
|
||||
#[serde(default = "root")]
|
||||
pub path_prefix: String,
|
||||
@@ -492,4 +490,8 @@ pub struct PathRoute {
|
||||
/// Local HTTP redirect; mutually exclusive with `response`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub redirect: Option<LocationRedirect>,
|
||||
/// Intercept selected upstream/Web Server error statuses and internally dispatch
|
||||
/// the original request to another internal Custom Location.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error_fallback: Option<ErrorFallback>,
|
||||
}
|
||||
@@ -147,6 +147,44 @@ mod default_listener_tests {
|
||||
.unwrap();
|
||||
assert!(vhost.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_fallback_requires_internal_target_and_rejects_cycles() {
|
||||
let base = json!({
|
||||
"id": "fallback-test",
|
||||
"frontend": {"hosts": ["example.test"], "listener_ids": ["http"]},
|
||||
"backends": [{"id": "origin", "url": "http://127.0.0.1:9001", "allow_private": true}],
|
||||
"error_fallback": {"target": "redirect-check", "statuses": [503, 404, 404]},
|
||||
"routes": [{
|
||||
"id": "redirect-check",
|
||||
"internal": true,
|
||||
"path_prefix": "/",
|
||||
"source": {"type": "reverse_proxy"}
|
||||
}]
|
||||
});
|
||||
let mut valid: Vhost = serde_json::from_value(base.clone()).unwrap();
|
||||
valid.validate().unwrap();
|
||||
assert_eq!(valid.error_fallback.as_ref().unwrap().statuses, vec![404, 503]);
|
||||
|
||||
let mut public_target: Vhost = serde_json::from_value(base.clone()).unwrap();
|
||||
public_target.routes[0].internal = false;
|
||||
assert!(public_target
|
||||
.validate()
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("internal location"));
|
||||
|
||||
let mut cyclic: Vhost = serde_json::from_value(base).unwrap();
|
||||
cyclic.routes[0].error_fallback = Some(ErrorFallback {
|
||||
target: "redirect-check".into(),
|
||||
statuses: vec![404],
|
||||
});
|
||||
assert!(cyclic
|
||||
.validate()
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("recursive error fallback"));
|
||||
}
|
||||
#[test]
|
||||
fn collision_explains_addresses_owners_and_protocol_limits() {
|
||||
let mut config = Config::default();
|
||||
|
||||
@@ -4,6 +4,7 @@ impl Default for PathRoute {
|
||||
source: Source::default(),
|
||||
backend_id: None,
|
||||
id: "root".into(),
|
||||
internal: false,
|
||||
path_prefix: root(),
|
||||
match_type: PathMatch::Prefix,
|
||||
enabled: true,
|
||||
@@ -16,6 +17,7 @@ impl Default for PathRoute {
|
||||
rewrite: PathRewrite::default(),
|
||||
response: None,
|
||||
redirect: None,
|
||||
error_fallback: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +33,7 @@ impl PathRoute {
|
||||
&& self.enabled
|
||||
&& self.source == Source::ReverseProxy
|
||||
&& self.backend_id.is_none()
|
||||
&& !self.internal
|
||||
&& self.cache.is_none()
|
||||
&& self.acl_profile_id.is_none()
|
||||
&& self.acl.is_none()
|
||||
@@ -42,6 +45,7 @@ impl PathRoute {
|
||||
&& self.rewrite.value.is_empty()
|
||||
&& self.response.is_none()
|
||||
&& self.redirect.is_none()
|
||||
&& self.error_fallback.is_none()
|
||||
}
|
||||
}
|
||||
impl Vhost {
|
||||
@@ -140,7 +144,15 @@ impl Vhost {
|
||||
// Normalize the untouched pre-0.16 synthetic root route out of the custom
|
||||
// location list so adding/removing Custom locations cannot delete fallback.
|
||||
self.routes.retain(|route| !route.is_legacy_main_fallback());
|
||||
if let Some(fallback) = &mut self.error_fallback {
|
||||
fallback.validate()?;
|
||||
}
|
||||
let mut route_ids = std::collections::HashSet::new();
|
||||
for route in &mut self.routes {
|
||||
ensure!(
|
||||
crate::config::valid_id(&route.id) && route_ids.insert(route.id.clone()),
|
||||
"invalid/duplicate vhost path id"
|
||||
);
|
||||
route.source.validate()?;
|
||||
ensure!(
|
||||
route.response.is_none() || route.redirect.is_none(),
|
||||
@@ -155,11 +167,55 @@ impl Vhost {
|
||||
route.source.is_reverse_proxy() || route.backend_id.is_none(),
|
||||
"Web Server location cannot pin a reverse-proxy backend"
|
||||
);
|
||||
if let Some(fallback) = &mut route.error_fallback {
|
||||
fallback.validate()?;
|
||||
}
|
||||
}
|
||||
ensure!(
|
||||
self.routes.len() <= 64,
|
||||
"at most 64 custom locations per vhost"
|
||||
);
|
||||
let resolve_target = |target: &str| {
|
||||
self.routes
|
||||
.iter()
|
||||
.find(|route| route.id == target && route.enabled && route.internal)
|
||||
};
|
||||
if let Some(fallback) = &self.error_fallback {
|
||||
ensure!(
|
||||
resolve_target(&fallback.target).is_some(),
|
||||
"vhost error fallback target must reference an enabled internal location"
|
||||
);
|
||||
}
|
||||
for route in &self.routes {
|
||||
if let Some(fallback) = &route.error_fallback {
|
||||
ensure!(
|
||||
resolve_target(&fallback.target).is_some(),
|
||||
"location error fallback target must reference an enabled internal location"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Reject cycles at configuration time; a runtime depth guard remains as a
|
||||
// second line of defense for hot-reload/state inconsistencies.
|
||||
let mut starts = vec![None];
|
||||
starts.extend(self.routes.iter().filter(|route| route.enabled).map(|route| Some(route.id.as_str())));
|
||||
for start in starts {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut current = start;
|
||||
loop {
|
||||
let key = current.unwrap_or("<main>");
|
||||
ensure!(seen.insert(key.to_owned()), "recursive error fallback loop detected");
|
||||
let fallback = match current {
|
||||
None => self.error_fallback.as_ref(),
|
||||
Some(id) => self
|
||||
.routes
|
||||
.iter()
|
||||
.find(|route| route.id == id)
|
||||
.and_then(|route| route.error_fallback.as_ref()),
|
||||
};
|
||||
let Some(fallback) = fallback else { break };
|
||||
current = Some(fallback.target.as_str());
|
||||
}
|
||||
}
|
||||
self.load_balancing.validate()?;
|
||||
self.proxy_timeouts.validate()?;
|
||||
self.rate_limit.validate()?;
|
||||
|
||||
Reference in new issue
Block a user