496 lines
17 KiB
Rust
496 lines
17 KiB
Rust
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),
|
|
}
|
|
}
|
|
}
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum MethodPolicyMode {
|
|
#[default]
|
|
Off,
|
|
Allow,
|
|
Block,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
|
#[serde(default, deny_unknown_fields)]
|
|
pub struct MethodPolicy {
|
|
pub mode: MethodPolicyMode,
|
|
pub methods: Vec<String>,
|
|
}
|
|
impl MethodPolicy {
|
|
pub fn validate(&mut self) -> Result<()> {
|
|
ensure!(self.methods.len() <= 32, "method policy allows at most 32 methods");
|
|
let mut seen = std::collections::HashSet::new();
|
|
for method in &mut self.methods {
|
|
*method = method.trim().to_ascii_uppercase();
|
|
ensure!(
|
|
!method.is_empty() && method.len() <= 32 && method.parse::<http::Method>().is_ok(),
|
|
"invalid HTTP method in method policy"
|
|
);
|
|
ensure!(seen.insert(method.clone()), "duplicate HTTP method in method policy");
|
|
}
|
|
ensure!(
|
|
self.mode == MethodPolicyMode::Off || !self.methods.is_empty(),
|
|
"method allow/block policy requires at least one method"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn allows(&self, method: &http::Method) -> bool {
|
|
let listed = self
|
|
.methods
|
|
.iter()
|
|
.any(|candidate| candidate.eq_ignore_ascii_case(method.as_str()));
|
|
match self.mode {
|
|
MethodPolicyMode::Off => true,
|
|
MethodPolicyMode::Allow => listed,
|
|
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 [
|
|
("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 Cloudflare client IP/country headers 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,
|
|
/// Optional allow/block policy for HTTP methods. Vhost and location policies stack.
|
|
#[serde(default)]
|
|
pub method_policy: MethodPolicy,
|
|
#[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,
|
|
/// Redirect the www/non-www companion to a selected exact hostname.
|
|
#[serde(default)]
|
|
pub www_redirect: bool,
|
|
/// Canonical hostname for the quick www redirect. None preserves the legacy
|
|
/// first-hostname behavior. Other vhost hostnames remain independent aliases.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub www_redirect_target: Option<String>,
|
|
/// 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 compression: crate::compression::Overrides,
|
|
#[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,
|
|
/// Runtime-only marker for a published Docker vhost whose service currently has no route target.
|
|
#[serde(skip)]
|
|
pub(crate) transient_unavailable: bool,
|
|
}
|
|
#[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>,
|
|
/// Optional reusable ACL profile applied in addition to vhost policy.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub acl_profile_id: Option<String>,
|
|
#[serde(default)]
|
|
pub acl: Option<Acl>,
|
|
/// Location-scoped country allow/block policy, stacked with vhost/profile policy.
|
|
#[serde(default)]
|
|
pub country_access: crate::rate_limit::CountryAccess,
|
|
/// Location-scoped HTTP method allow/block policy, stacked with vhost policy.
|
|
#[serde(default)]
|
|
pub method_policy: MethodPolicy,
|
|
#[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>,
|
|
/// Local HTTP redirect; mutually exclusive with `response`.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub redirect: Option<LocationRedirect>,
|
|
}
|