v0.25.1
This commit is contained in:
1 parent
87e571c170
commit
fa7abc9e53
308 files changed
+49746
-50237
No files matched your search
@@ -0,0 +1,416 @@
|
||||
use crate::{
|
||||
acl::Acl,
|
||||
config::{Backend, Config, UpstreamProtocol, ValidationPolicy},
|
||||
headers::HeaderRules,
|
||||
security::SecurityHeaders,
|
||||
};
|
||||
use anyhow::{ensure, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
fn root() -> String {
|
||||
"/".into()
|
||||
}
|
||||
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 {
|
||||
/// TCP/TLS connect timeout for each backend connection. Explicit null inherits the runtime default.
|
||||
pub connect_timeout_seconds: Option<u64>,
|
||||
/// Deadline until upstream response headers arrive. Explicit null inherits the process startup value.
|
||||
pub response_header_timeout_seconds: Option<u64>,
|
||||
/// Maximum idle gap while reading the upstream response body. Explicit null inherits the process startup value.
|
||||
pub read_timeout_seconds: Option<u64>,
|
||||
/// Maximum idle gap while streaming the client request body upstream. Explicit null inherits the process startup value.
|
||||
pub send_timeout_seconds: Option<u64>,
|
||||
}
|
||||
impl Default for ProxyTimeouts {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
connect_timeout_seconds: Some(DEFAULT_PROXY_CONNECT_TIMEOUT_SECONDS),
|
||||
response_header_timeout_seconds: Some(DEFAULT_PROXY_RESPONSE_HEADER_TIMEOUT_SECONDS),
|
||||
read_timeout_seconds: Some(DEFAULT_PROXY_READ_TIMEOUT_SECONDS),
|
||||
send_timeout_seconds: Some(DEFAULT_PROXY_SEND_TIMEOUT_SECONDS),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl ProxyTimeouts {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
for (name, value, max) in [
|
||||
("proxy connect timeout", self.connect_timeout_seconds, 300),
|
||||
(
|
||||
"proxy response-header timeout",
|
||||
self.response_header_timeout_seconds,
|
||||
86_400,
|
||||
),
|
||||
("proxy read timeout", self.read_timeout_seconds, 86_400),
|
||||
("proxy send timeout", self.send_timeout_seconds, 86_400),
|
||||
] {
|
||||
if let Some(value) = value {
|
||||
ensure!(
|
||||
(1..=max).contains(&value),
|
||||
"{name} must be 1..={max} seconds"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn connect_timeout(&self) -> Option<std::time::Duration> {
|
||||
self.connect_timeout_seconds
|
||||
.map(std::time::Duration::from_secs)
|
||||
}
|
||||
pub fn response_header_timeout(&self) -> Option<std::time::Duration> {
|
||||
self.response_header_timeout_seconds
|
||||
.map(std::time::Duration::from_secs)
|
||||
}
|
||||
pub fn read_timeout(&self) -> Option<std::time::Duration> {
|
||||
self.read_timeout_seconds
|
||||
.map(std::time::Duration::from_secs)
|
||||
}
|
||||
pub fn send_timeout(&self) -> Option<std::time::Duration> {
|
||||
self.send_timeout_seconds
|
||||
.map(std::time::Duration::from_secs)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PathMatch {
|
||||
#[default]
|
||||
Prefix,
|
||||
/// Nginx-style ^~ prefix: an exact/longest prefix match skips regex routes.
|
||||
PrefixPriority,
|
||||
Exact,
|
||||
Regex,
|
||||
RegexInsensitive,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RewriteMode {
|
||||
#[default]
|
||||
Preserve,
|
||||
StripPrefix,
|
||||
ReplacePrefix,
|
||||
Regex,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct PathRewrite {
|
||||
pub mode: RewriteMode,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
fn response_status() -> u16 {
|
||||
200
|
||||
}
|
||||
fn response_content_type() -> String {
|
||||
"text/plain; charset=utf-8".into()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct LocationResponse {
|
||||
#[serde(default = "response_status")]
|
||||
pub status: u16,
|
||||
#[serde(default = "response_content_type")]
|
||||
pub content_type: String,
|
||||
#[serde(default)]
|
||||
pub body: String,
|
||||
}
|
||||
impl Default for LocationResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
status: response_status(),
|
||||
content_type: response_content_type(),
|
||||
body: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Source {
|
||||
ReverseProxy,
|
||||
WebServer { web_service_id: String },
|
||||
}
|
||||
impl Default for Source {
|
||||
fn default() -> Self {
|
||||
Self::ReverseProxy
|
||||
}
|
||||
}
|
||||
impl Source {
|
||||
pub fn is_reverse_proxy(&self) -> bool {
|
||||
matches!(self, Self::ReverseProxy)
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if let Self::WebServer { web_service_id } = self {
|
||||
ensure!(
|
||||
crate::config::valid_id(web_service_id),
|
||||
"invalid Web Service reference"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Vhost {
|
||||
#[serde(default = "yes")]
|
||||
pub websockets: bool,
|
||||
/// Opt-in compatibility for legacy WebSocket origins. This affects only
|
||||
/// WebSocket upstream handshakes and leaves ordinary HTTP unchanged.
|
||||
#[serde(default)]
|
||||
pub websocket_legacy_support: bool,
|
||||
/// RFC 8441 WebSocket Extended CONNECT over HTTP/2.
|
||||
#[serde(default)]
|
||||
pub websocket_rfc8441: bool,
|
||||
/// Experimental RFC 9220 WebSocket Extended CONNECT over HTTP/3.
|
||||
/// Classic HTTP/1.1 Upgrade remains the default WebSocket transport.
|
||||
#[serde(default)]
|
||||
pub websocket_rfc9220: bool,
|
||||
/// Per-vhost HTTP/2 opt-out. TLS listeners may still advertise h2 for other vhosts.
|
||||
#[serde(default = "yes")]
|
||||
pub http2_enabled: bool,
|
||||
/// Per-vhost QUIC opt-out. Shared UDP listeners may remain active for other vhosts.
|
||||
#[serde(default = "yes")]
|
||||
pub http3_enabled: bool,
|
||||
/// Trust CF-Connecting-IP only when the immediate peer is a Cloudflare edge address.
|
||||
#[serde(default)]
|
||||
pub cloudflare_client_ip: bool,
|
||||
/// Emit proxy-owned Server-Timing diagnostics for this vhost.
|
||||
#[serde(default)]
|
||||
pub timing_headers: bool,
|
||||
/// Expose the selected backend ID in X-RustProxy-Backend. Opt-in because it reveals topology.
|
||||
#[serde(default)]
|
||||
pub backend_header: bool,
|
||||
/// Expose upstream response-header latency in X-RustProxy-Backend-Time.
|
||||
#[serde(default)]
|
||||
pub backend_timing_header: bool,
|
||||
#[serde(default)]
|
||||
pub rate_limit: crate::rate_limit::Settings,
|
||||
#[serde(default)]
|
||||
pub basic_auth: crate::basic_auth::Settings,
|
||||
pub id: String,
|
||||
#[serde(default = "yes")]
|
||||
pub enabled: bool,
|
||||
/// Redirect cleartext requests for this vhost to the selected HTTPS frontend.
|
||||
#[serde(default)]
|
||||
pub force_ssl: bool,
|
||||
/// Forward the effective client IP to the application using Forwarded/X-Forwarded-For/X-Real-IP.
|
||||
/// Disable only for applications that must see the RustProxy transport peer instead.
|
||||
#[serde(default = "yes")]
|
||||
pub forward_client_ip: bool,
|
||||
pub frontend: Frontend,
|
||||
#[serde(default)]
|
||||
pub server_header: Option<crate::settings::ServerHeader>,
|
||||
#[serde(default)]
|
||||
pub diagnostic_headers: Option<crate::settings::DiagnosticHeaders>,
|
||||
#[serde(default)]
|
||||
pub tinyauth: crate::tinyauth::Settings,
|
||||
#[serde(default)]
|
||||
pub certificate_id: Option<String>,
|
||||
/// Optional protocol/cipher override. None inherits the global TLS policy.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tls_policy: Option<crate::tls::ProtocolPolicy>,
|
||||
#[serde(default)]
|
||||
pub cache: crate::cache::Overrides,
|
||||
#[serde(default)]
|
||||
pub transform: crate::transform::Overrides,
|
||||
#[serde(default)]
|
||||
pub pages: crate::pages::VhostPages,
|
||||
/// Main source for the vhost fallback. Missing in older configs means Reverse Proxy.
|
||||
#[serde(default)]
|
||||
pub source: Source,
|
||||
#[serde(default)]
|
||||
pub backends: Vec<PoolMember>,
|
||||
#[serde(default)]
|
||||
pub load_balancing: crate::balance::Settings,
|
||||
#[serde(default)]
|
||||
pub routes: Vec<PathRoute>,
|
||||
/// Optional reusable ACL profile. The profile and the inline ACL are both enforced.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub acl_profile_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub acl: Option<Acl>,
|
||||
#[serde(default)]
|
||||
pub headers: HeaderRules,
|
||||
#[serde(default)]
|
||||
pub security_headers: SecurityHeaders,
|
||||
/// Nginx-style upstream timeout controls. Empty values inherit process defaults.
|
||||
#[serde(default)]
|
||||
pub proxy_timeouts: ProxyTimeouts,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Frontend {
|
||||
pub hosts: Vec<String>,
|
||||
pub listener_ids: Vec<String>,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub struct Listener {
|
||||
pub address: SocketAddr,
|
||||
pub tls: bool,
|
||||
/// Enabling HTTP/2 never disables the HTTP/1.1 ALPN fallback.
|
||||
pub http2: bool,
|
||||
/// Per-listener HTTP/2 lifecycle limits. Missing values inherit the legacy
|
||||
/// global defaults for backward-compatible configuration loading.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub http2_settings: Option<crate::settings::Http2Connections>,
|
||||
/// Cleartext HTTP/2 support using the HTTP/2 prior-knowledge preface.
|
||||
#[serde(default)]
|
||||
pub h2c: bool,
|
||||
pub http3: Option<crate::http3::Settings>,
|
||||
}
|
||||
// Missing H3 means the secure default; explicit null remains a persisted opt-out.
|
||||
// Do not skip null during serialization, or a reload would silently re-enable it.
|
||||
impl<'de> Deserialize<'de> for Listener {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> std::result::Result<Self, D::Error> {
|
||||
#[derive(Default)]
|
||||
enum H3 {
|
||||
#[default]
|
||||
Auto,
|
||||
Explicit(Option<crate::http3::Settings>),
|
||||
}
|
||||
impl<'de> Deserialize<'de> for H3 {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(
|
||||
d: D,
|
||||
) -> std::result::Result<Self, D::Error> {
|
||||
Option::<crate::http3::Settings>::deserialize(d).map(H3::Explicit)
|
||||
}
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Wire {
|
||||
address: SocketAddr,
|
||||
#[serde(default)]
|
||||
tls: bool,
|
||||
#[serde(default = "yes")]
|
||||
http2: bool,
|
||||
#[serde(default)]
|
||||
http2_settings: Option<crate::settings::Http2Connections>,
|
||||
#[serde(default)]
|
||||
h2c: bool,
|
||||
#[serde(default)]
|
||||
http3: H3,
|
||||
}
|
||||
let w = Wire::deserialize(deserializer)?;
|
||||
let http3 = match w.http3 {
|
||||
H3::Auto => w.tls.then(crate::http3::Settings::default),
|
||||
H3::Explicit(v) => v,
|
||||
};
|
||||
Ok(Self {
|
||||
address: w.address,
|
||||
tls: w.tls,
|
||||
http2: w.http2,
|
||||
http2_settings: w.http2_settings,
|
||||
h2c: w.h2c,
|
||||
http3,
|
||||
})
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PoolMember {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
#[serde(default = "weight")]
|
||||
pub weight: u16,
|
||||
#[serde(default = "yes")]
|
||||
pub enabled: bool,
|
||||
/// False reserves this origin for path-pinned locations only.
|
||||
#[serde(default = "yes")]
|
||||
pub pool_eligible: bool,
|
||||
#[serde(default)]
|
||||
pub backup: bool,
|
||||
#[serde(default)]
|
||||
pub drain: bool,
|
||||
#[serde(default)]
|
||||
pub allow_private: bool,
|
||||
#[serde(default)]
|
||||
pub accept_local_listener_risk: bool,
|
||||
#[serde(default = "yes")]
|
||||
pub preserve_host: bool,
|
||||
#[serde(default)]
|
||||
pub protocol: UpstreamProtocol,
|
||||
#[serde(default)]
|
||||
pub address_allowlist: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub tls_ca_pem: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tls_server_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tls_verify: bool,
|
||||
/// Maximum number of complete proxy exchanges assigned to this backend.
|
||||
/// Zero keeps the historical unlimited behavior.
|
||||
#[serde(default)]
|
||||
pub max_concurrent_requests: u32,
|
||||
/// Maximum time a request may wait for backend capacity when the limit is
|
||||
/// reached. Zero rejects immediately with 503.
|
||||
#[serde(default)]
|
||||
pub queue_timeout_ms: u64,
|
||||
}
|
||||
impl PoolMember {
|
||||
pub fn backend(&self) -> Backend {
|
||||
Backend {
|
||||
id: self.id.clone(),
|
||||
url: self.url.clone(),
|
||||
protocol: self.protocol,
|
||||
allow_private: self.allow_private,
|
||||
accept_local_listener_risk: self.accept_local_listener_risk,
|
||||
preserve_host: self.preserve_host,
|
||||
address_allowlist: self.address_allowlist.clone(),
|
||||
tls_ca_pem: self.tls_ca_pem.clone(),
|
||||
tls_server_name: self.tls_server_name.clone(),
|
||||
tls_verify: self.tls_verify,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PathRoute {
|
||||
/// Source selected for this location. A local return ignores this field.
|
||||
#[serde(default)]
|
||||
pub source: Source,
|
||||
/// None balances over the whole pool. Some pins this path to one healthy
|
||||
/// member; never fail over a path to an unrelated application.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub backend_id: Option<String>,
|
||||
pub id: String,
|
||||
/// Prefix/exact path or a Rust-regex pattern, depending on `match_type`.
|
||||
#[serde(default = "root")]
|
||||
pub path_prefix: String,
|
||||
#[serde(default)]
|
||||
pub match_type: PathMatch,
|
||||
#[serde(default = "yes")]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub cache: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub acl: Option<Acl>,
|
||||
#[serde(default)]
|
||||
pub headers: HeaderRules,
|
||||
/// Optional origin-path transformation. Query parameters are preserved.
|
||||
#[serde(default)]
|
||||
pub rewrite: PathRewrite,
|
||||
/// Nginx-style local `return`: when present no backend connection is opened.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub response: Option<LocationResponse>,
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
mod default_listener_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn tls_defaults_to_h3_and_explicit_null_survives_a_roundtrip() {
|
||||
let auto: Listener =
|
||||
serde_json::from_value(json!({"address":"127.0.0.1:8443","tls":true})).unwrap();
|
||||
assert_eq!(auto.http3, Some(crate::http3::Settings::default()));
|
||||
assert!(auto.http2);
|
||||
let disabled: Listener =
|
||||
serde_json::from_value(json!({"address":"127.0.0.1:8443","tls":true,"http3":null}))
|
||||
.unwrap();
|
||||
let wire = serde_json::to_value(disabled).unwrap();
|
||||
assert!(wire["http3"].is_null());
|
||||
assert!(serde_json::from_value::<Listener>(wire)
|
||||
.unwrap()
|
||||
.http3
|
||||
.is_none());
|
||||
let plain: Listener = serde_json::from_value(json!({"address":"127.0.0.1:8080"})).unwrap();
|
||||
assert!(plain.http3.is_none());
|
||||
}
|
||||
#[test]
|
||||
fn listener_plan_inherits_legacy_h2_limits_and_prefers_listener_override() {
|
||||
let mut config = Config::default();
|
||||
config.http2 = crate::settings::Http2Connections {
|
||||
idle_timeout_seconds: 111,
|
||||
max_connection_lifetime_seconds: 222,
|
||||
drain_timeout_seconds: 33,
|
||||
};
|
||||
config.listeners.endpoints = serde_json::from_value(json!([
|
||||
{
|
||||
"id": "tls",
|
||||
"name": "TLS",
|
||||
"scope": "public",
|
||||
"enabled": true,
|
||||
"binding": {
|
||||
"address": "127.0.0.1:8443",
|
||||
"tls": true,
|
||||
"http2": true,
|
||||
"http3": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "h2c",
|
||||
"name": "H2C",
|
||||
"scope": "internal",
|
||||
"enabled": true,
|
||||
"binding": {
|
||||
"address": "127.0.0.1:8081",
|
||||
"tls": false,
|
||||
"http2": false,
|
||||
"h2c": true,
|
||||
"http2_settings": {
|
||||
"idle_timeout_seconds": 444,
|
||||
"max_connection_lifetime_seconds": 555,
|
||||
"drain_timeout_seconds": 66
|
||||
},
|
||||
"http3": null
|
||||
}
|
||||
}
|
||||
]))
|
||||
.unwrap();
|
||||
assert!(config.listeners.endpoints[0].binding.http2_settings.is_none());
|
||||
let policy = ValidationPolicy {
|
||||
proxy_addr: "127.0.0.1:18080".parse().unwrap(),
|
||||
admin_addr: "127.0.0.1:19090".parse().unwrap(),
|
||||
tls_addr: None,
|
||||
cert_dir: None,
|
||||
data_dir: None,
|
||||
};
|
||||
let plan = listener_plan(&config, &policy).unwrap();
|
||||
let tls = plan.iter().find(|item| item.address.port() == 8443).unwrap();
|
||||
assert_eq!(tls.http2_settings, Some(config.http2));
|
||||
let h2c = plan.iter().find(|item| item.address.port() == 8081).unwrap();
|
||||
assert_eq!(
|
||||
h2c.http2_settings,
|
||||
Some(crate::settings::Http2Connections {
|
||||
idle_timeout_seconds: 444,
|
||||
max_connection_lifetime_seconds: 555,
|
||||
drain_timeout_seconds: 66,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h2c_is_cleartext_only_and_defaults_off() {
|
||||
let plain: Listener =
|
||||
serde_json::from_value(json!({"address":"127.0.0.1:8080","tls":false})).unwrap();
|
||||
assert!(!plain.h2c);
|
||||
let h2c: Listener = serde_json::from_value(
|
||||
json!({"address":"127.0.0.1:8080","tls":false,"h2c":true}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(validate_listener(h2c).is_ok());
|
||||
let invalid: Listener = serde_json::from_value(
|
||||
json!({"address":"127.0.0.1:8443","tls":true,"h2c":true}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(validate_listener(invalid).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn proxy_timeouts_have_explicit_safe_defaults_but_keep_null_compatibility() {
|
||||
let defaults: ProxyTimeouts = serde_json::from_value(json!({})).unwrap();
|
||||
assert_eq!(defaults.connect_timeout_seconds, Some(10));
|
||||
assert_eq!(defaults.response_header_timeout_seconds, Some(120));
|
||||
assert_eq!(defaults.read_timeout_seconds, Some(60));
|
||||
assert_eq!(defaults.send_timeout_seconds, Some(60));
|
||||
let inherited: ProxyTimeouts =
|
||||
serde_json::from_value(json!({"read_timeout_seconds":null})).unwrap();
|
||||
assert_eq!(inherited.read_timeout_seconds, None);
|
||||
assert_eq!(inherited.connect_timeout_seconds, Some(10));
|
||||
}
|
||||
#[test]
|
||||
fn vhost_hostname_count_is_not_artificially_capped() {
|
||||
let hosts = (0..40)
|
||||
.map(|index| format!("host-{index}.example.test"))
|
||||
.collect::<Vec<_>>();
|
||||
let mut vhost: Vhost = serde_json::from_value(json!({
|
||||
"id": "many-hosts",
|
||||
"frontend": {"hosts": hosts, "listener_ids": ["https"]},
|
||||
"backends": [{"id": "origin", "url": "http://127.0.0.1:9001", "allow_private": true}]
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(vhost.validate().is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn collision_explains_addresses_owners_and_protocol_limits() {
|
||||
let mut config = Config::default();
|
||||
config.listeners.endpoints = serde_json::from_value(json!([
|
||||
{"id":"public","name":"Public TLS","binding":{"address":"0.0.0.0:8443","tls":true}},
|
||||
{"id":"private","name":"Private TLS","binding":{"address":"127.0.0.1:8443","tls":true,"http3":{"max_concurrent_connections":12}}}
|
||||
])).unwrap();
|
||||
let policy = ValidationPolicy {
|
||||
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
|
||||
admin_addr: "127.0.0.1:9090".parse().unwrap(),
|
||||
tls_addr: None,
|
||||
cert_dir: None,
|
||||
data_dir: None,
|
||||
};
|
||||
let message = listener_plan(&config, &policy).unwrap_err().to_string();
|
||||
for expected in [
|
||||
"0.0.0.0:8443",
|
||||
"127.0.0.1:8443",
|
||||
"Public TLS",
|
||||
"Private TLS",
|
||||
"H3 limits",
|
||||
"max_concurrent_connections",
|
||||
] {
|
||||
assert!(message.contains(expected), "{message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
impl Default for PathRoute {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
source: Source::default(),
|
||||
backend_id: None,
|
||||
id: "root".into(),
|
||||
path_prefix: root(),
|
||||
match_type: PathMatch::Prefix,
|
||||
enabled: true,
|
||||
cache: None,
|
||||
acl: None,
|
||||
headers: HeaderRules::default(),
|
||||
rewrite: PathRewrite::default(),
|
||||
response: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl PathRoute {
|
||||
/// Older documents persisted the main vhost fallback as a synthetic `root`
|
||||
/// custom location. 0.16 keeps that fallback separate from user locations;
|
||||
/// only the untouched synthetic shape is normalized away. A deliberate `/`
|
||||
/// location with any custom behavior remains a real override.
|
||||
pub fn is_legacy_main_fallback(&self) -> bool {
|
||||
self.id == "root"
|
||||
&& self.path_prefix == "/"
|
||||
&& self.match_type == PathMatch::Prefix
|
||||
&& self.enabled
|
||||
&& self.source == Source::ReverseProxy
|
||||
&& self.backend_id.is_none()
|
||||
&& self.cache.is_none()
|
||||
&& self.acl.is_none()
|
||||
&& self.headers.is_empty()
|
||||
&& self.rewrite.mode == RewriteMode::Preserve
|
||||
&& self.rewrite.value.is_empty()
|
||||
&& self.response.is_none()
|
||||
}
|
||||
}
|
||||
impl Vhost {
|
||||
pub fn validate(&mut self) -> Result<()> {
|
||||
ensure!(crate::config::valid_id(&self.id), "invalid vhost id");
|
||||
ensure!(
|
||||
!self.websocket_legacy_support || self.websockets,
|
||||
"legacy WebSocket compatibility requires WebSocket support to be enabled"
|
||||
);
|
||||
ensure!(
|
||||
!self.websocket_rfc8441 || self.websockets,
|
||||
"RFC 8441 requires WebSocket support to be enabled"
|
||||
);
|
||||
ensure!(
|
||||
!self.websocket_rfc9220 || self.websockets,
|
||||
"RFC 9220 requires WebSocket support to be enabled"
|
||||
);
|
||||
ensure!(
|
||||
!self.websocket_rfc8441 || self.http2_enabled,
|
||||
"RFC 8441 requires HTTP/2 to be enabled for the vhost"
|
||||
);
|
||||
ensure!(
|
||||
!self.websocket_rfc9220 || self.http3_enabled,
|
||||
"RFC 9220 requires HTTP/3 to be enabled for the vhost"
|
||||
);
|
||||
ensure!(!self.frontend.hosts.is_empty(), "vhost 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 vhost hostname");
|
||||
}
|
||||
if let Some(id) = &self.acl_profile_id {
|
||||
ensure!(crate::config::valid_id(id), "invalid ACL profile id");
|
||||
}
|
||||
ensure!(
|
||||
!self.frontend.listener_ids.is_empty(),
|
||||
"vhost requires at least one named listener"
|
||||
);
|
||||
ensure!(
|
||||
self.frontend.listener_ids.len() <= 16,
|
||||
"vhost allows at most 16 named listeners"
|
||||
);
|
||||
self.source.validate()?;
|
||||
let needs_proxy = self.source.is_reverse_proxy()
|
||||
|| self.routes.iter().any(|route| {
|
||||
route.enabled && route.response.is_none() && route.source.is_reverse_proxy()
|
||||
});
|
||||
ensure!(
|
||||
self.backends.len() <= 32,
|
||||
"vhost allows at most 32 backend members"
|
||||
);
|
||||
ensure!(
|
||||
!needs_proxy || !self.backends.is_empty(),
|
||||
"vhost requires at least one backend member for Reverse Proxy sources"
|
||||
);
|
||||
let mut members = std::collections::HashSet::new();
|
||||
for member in &self.backends {
|
||||
ensure!(
|
||||
crate::config::valid_id(&member.id) && members.insert(&member.id),
|
||||
"invalid/duplicate pool member id"
|
||||
);
|
||||
ensure!(
|
||||
(1..=256).contains(&member.weight),
|
||||
"backend weight must be 1..256"
|
||||
);
|
||||
ensure!(
|
||||
member.max_concurrent_requests <= 1_000_000,
|
||||
"backend max_concurrent_requests must be 0..1000000"
|
||||
);
|
||||
ensure!(
|
||||
member.queue_timeout_ms <= 60_000,
|
||||
"backend queue_timeout_ms must be 0..60000"
|
||||
);
|
||||
}
|
||||
// The main vhost backend is an unconditional internal fallback in 0.16.
|
||||
// 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());
|
||||
for route in &self.routes {
|
||||
route.source.validate()?;
|
||||
ensure!(
|
||||
route.source.is_reverse_proxy() || route.backend_id.is_none(),
|
||||
"Web Server location cannot pin a reverse-proxy backend"
|
||||
);
|
||||
}
|
||||
ensure!(
|
||||
self.routes.len() <= 64,
|
||||
"at most 64 custom locations per vhost"
|
||||
);
|
||||
self.load_balancing.validate()?;
|
||||
self.proxy_timeouts.validate()?;
|
||||
self.rate_limit.validate()?;
|
||||
if let Some(policy) = &self.tls_policy {
|
||||
// Listener-aware HTTP/3 compatibility is checked during full config
|
||||
// compilation. A vhost may keep the H3 feature flag enabled while it
|
||||
// is currently attached only to TCP listeners.
|
||||
policy.validate()?;
|
||||
}
|
||||
if let Some(server) = &self.server_header {
|
||||
server.validate()?;
|
||||
}
|
||||
self.tinyauth.validate()?;
|
||||
self.basic_auth.compile()?;
|
||||
ensure!(
|
||||
!(self.tinyauth.enabled && self.basic_auth.enabled),
|
||||
"choose either Tinyauth or Basic authentication"
|
||||
);
|
||||
self.pages.validate()?;
|
||||
self.transform.resolve(&crate::transform::Settings::default())?;
|
||||
self.security_headers.compile()?;
|
||||
self.headers.compile()?;
|
||||
if let Some(acl) = &self.acl {
|
||||
acl.compile()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
pub fn canonical_ip(ip: IpAddr) -> IpAddr {
|
||||
match ip {
|
||||
IpAddr::V6(v6) => v6.to_ipv4_mapped().map(IpAddr::V4).unwrap_or(ip),
|
||||
_ => ip,
|
||||
}
|
||||
}
|
||||
impl Listener {
|
||||
pub fn matches(&self, local: SocketAddr, tls: bool) -> bool {
|
||||
if self.tls != tls || self.address.port() != local.port() {
|
||||
return false;
|
||||
}
|
||||
if let (SocketAddr::V6(bound), SocketAddr::V6(actual)) = (self.address, local) {
|
||||
if bound.scope_id() != 0 && bound.scope_id() != actual.scope_id() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let bind = canonical_ip(self.address.ip());
|
||||
let local = canonical_ip(local.ip());
|
||||
bind == local
|
||||
|| (bind.is_unspecified() && (bind.is_ipv6() || bind.is_ipv4() == local.is_ipv4()))
|
||||
}
|
||||
}
|
||||
pub fn socket_overlap(a: SocketAddr, b: SocketAddr) -> bool {
|
||||
if a.port() != b.port() {
|
||||
return false;
|
||||
}
|
||||
let a = canonical_ip(a.ip());
|
||||
let b = canonical_ip(b.ip());
|
||||
a == b
|
||||
|| ((a.is_unspecified() || b.is_unspecified())
|
||||
&& (a.is_ipv4() == b.is_ipv4()
|
||||
|| (a.is_ipv6() && a.is_unspecified())
|
||||
|| (b.is_ipv6() && b.is_unspecified())))
|
||||
}
|
||||
pub fn validate_listener(listener: Listener) -> Result<()> {
|
||||
ensure!(
|
||||
!listener.tls || !listener.h2c,
|
||||
"h2c is available only on cleartext listeners"
|
||||
);
|
||||
if let Some(h2) = listener.http2_settings {
|
||||
h2.validate()?;
|
||||
}
|
||||
if let Some(h3) = listener.http3 {
|
||||
ensure!(listener.tls, "HTTP/3 requires TLS on the frontend");
|
||||
h3.validate()?;
|
||||
}
|
||||
let ip = listener.address.ip();
|
||||
ensure!(
|
||||
listener.address.port() > 0 && !ip.is_multicast(),
|
||||
"listener needs a unicast/wildcard IP and port 1..65535"
|
||||
);
|
||||
ensure!(
|
||||
!matches!(ip, IpAddr::V6(v6) if v6.to_ipv4_mapped().is_some()),
|
||||
"use native IPv4 rather than a mapped IPv6 bind address"
|
||||
);
|
||||
ensure!(
|
||||
!matches!(ip, IpAddr::V4(v4) if v4.is_broadcast()),
|
||||
"broadcast address cannot be a frontend"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
impl Frontend {
|
||||
pub fn resolved(&self, settings: &crate::settings::Listeners) -> Result<Vec<Listener>> {
|
||||
let mut result = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for id in &self.listener_ids {
|
||||
ensure!(seen.insert(id), "duplicate named listener reference");
|
||||
let endpoint = settings
|
||||
.endpoints
|
||||
.iter()
|
||||
.find(|item| &item.id == id)
|
||||
.ok_or_else(|| anyhow::anyhow!("unknown named listener: {id}"))?;
|
||||
if endpoint.enabled {
|
||||
result.extend(endpoint.bindings());
|
||||
}
|
||||
}
|
||||
let mut addresses = std::collections::HashSet::new();
|
||||
ensure!(
|
||||
result.iter().all(|l| addresses.insert(l.address)),
|
||||
"duplicate resolved frontend binding"
|
||||
);
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
/// Consolidate identical / same-family wildcard sockets, but never widen routing
|
||||
/// permissions. Vhost selection still checks its own unmerged bindings.
|
||||
pub fn listener_plan(config: &Config, policy: &ValidationPolicy) -> Result<Vec<Listener>> {
|
||||
let mut candidates: Vec<Listener> = config
|
||||
.listeners
|
||||
.endpoints
|
||||
.iter()
|
||||
.filter(|e| e.enabled)
|
||||
.flat_map(|e| e.bindings())
|
||||
.collect();
|
||||
for host in &config.vhosts {
|
||||
if host.enabled {
|
||||
candidates.extend(host.frontend.resolved(&config.listeners)?);
|
||||
}
|
||||
}
|
||||
for redirect in &config.redirects {
|
||||
if redirect.enabled {
|
||||
candidates.extend(redirect.frontend.resolved(&config.listeners)?);
|
||||
}
|
||||
}
|
||||
for host in &config.not_found_hosts {
|
||||
if host.enabled {
|
||||
candidates.extend(host.frontend.resolved(&config.listeners)?);
|
||||
}
|
||||
}
|
||||
let effective = |mut value: Listener| {
|
||||
if !value.tls {
|
||||
value.http2 = false;
|
||||
} else {
|
||||
value.h2c = false;
|
||||
}
|
||||
if value.http2 || value.h2c {
|
||||
if value.http2_settings.is_none() {
|
||||
value.http2_settings = Some(config.http2);
|
||||
}
|
||||
} else {
|
||||
value.http2_settings = None;
|
||||
}
|
||||
value
|
||||
};
|
||||
let describe = |binding: &Listener| -> String {
|
||||
let mut owners = Vec::new();
|
||||
let same = |value: Listener| effective(value) == *binding;
|
||||
for item in config
|
||||
.listeners
|
||||
.endpoints
|
||||
.iter()
|
||||
.filter(|e| e.enabled && e.bindings().into_iter().any(|value| same(value)))
|
||||
{
|
||||
owners.push(format!("listener '{}' ({})", item.id, item.name));
|
||||
}
|
||||
if owners.is_empty() {
|
||||
binding.address.to_string()
|
||||
} else {
|
||||
owners.join(", ")
|
||||
}
|
||||
};
|
||||
let mut result: Vec<Listener> = Vec::new();
|
||||
for listener in candidates {
|
||||
validate_listener(listener)?;
|
||||
let listener = effective(listener);
|
||||
ensure!(
|
||||
listener.address.port() != policy.admin_addr.port(),
|
||||
"frontend cannot share the administration port"
|
||||
);
|
||||
let mut covered = false;
|
||||
for previous in &result {
|
||||
if !socket_overlap(previous.address, listener.address) {
|
||||
continue;
|
||||
}
|
||||
ensure!(previous.address.is_ipv4() == listener.address.is_ipv4(), "Listener conflict: {} [{}] overlaps {} [{}]. IPv4/IPv6 wildcard sockets cannot be merged; use explicit IP addresses or a different port.", previous.address, describe(previous), listener.address, describe(&listener));
|
||||
ensure!(previous.tls == listener.tls && previous.http2 == listener.http2 && previous.http2_settings == listener.http2_settings && previous.h2c == listener.h2c && previous.http3 == listener.http3,
|
||||
"Listener conflict: {} [{}] overlaps {} [{}]. Existing: TLS={}, HTTP/2={}, H2 settings={:?}, h2c={}, HTTP/3={:?}; requested: TLS={}, HTTP/2={}, H2 settings={:?}, h2c={}, HTTP/3={:?}. A shared IP/port (including wildcard bindings) must use the same protocols and H2/H3 limits. Select the same named listener, align these settings, or use another IP/port.",
|
||||
previous.address, describe(previous), listener.address, describe(&listener),
|
||||
previous.tls, previous.http2, previous.http2_settings, previous.h2c, previous.http3, listener.tls, listener.http2, listener.http2_settings, listener.h2c, listener.http3);
|
||||
if previous.address.ip().is_unspecified() || previous.address == listener.address {
|
||||
covered = true;
|
||||
}
|
||||
}
|
||||
if covered {
|
||||
continue;
|
||||
}
|
||||
if listener.address.ip().is_unspecified() {
|
||||
result.retain(|p| !socket_overlap(p.address, listener.address));
|
||||
}
|
||||
result.push(listener);
|
||||
}
|
||||
ensure!(
|
||||
result.len() <= 130,
|
||||
"too many distinct frontend sockets (maximum 130)"
|
||||
);
|
||||
result.sort_by_key(|l| (l.address, l.tls, l.http2, l.h2c));
|
||||
Ok(result)
|
||||
}
|
||||
Reference in new issue
Block a user