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, /// Deadline until upstream response headers arrive. Explicit null inherits the process startup value. pub response_header_timeout_seconds: Option, /// Maximum idle gap while reading the upstream response body. Explicit null inherits the process startup value. pub read_timeout_seconds: Option, /// Maximum idle gap while streaming the client request body upstream. Explicit null inherits the process startup value. pub send_timeout_seconds: Option, } 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, } 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::().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 { (self.mode == MethodPolicyMode::Allow) .then(|| self.methods.join(", ").parse::().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 { self.connect_timeout_seconds .map(std::time::Duration::from_secs) } pub fn response_header_timeout(&self) -> Option { self.response_header_timeout_seconds .map(std::time::Duration::from_secs) } pub fn read_timeout(&self) -> Option { self.read_timeout_seconds .map(std::time::Duration::from_secs) } pub fn send_timeout(&self) -> Option { 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, /// 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, #[serde(default)] pub diagnostic_headers: Option, #[serde(default)] pub tinyauth: crate::tinyauth::Settings, #[serde(default)] pub certificate_id: Option, /// Optional protocol/cipher override. None inherits the global TLS policy. #[serde(default, skip_serializing_if = "Option::is_none")] pub tls_policy: Option, #[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, #[serde(default)] pub load_balancing: crate::balance::Settings, #[serde(default)] pub routes: Vec, /// 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, #[serde(default)] pub acl: Option, #[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, pub listener_ids: Vec, } #[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, /// Cleartext HTTP/2 support using the HTTP/2 prior-knowledge preface. #[serde(default)] pub h2c: bool, pub http3: Option, } // 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>( deserializer: D, ) -> std::result::Result { #[derive(Default)] enum H3 { #[default] Auto, Explicit(Option), } impl<'de> Deserialize<'de> for H3 { fn deserialize>( d: D, ) -> std::result::Result { Option::::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, #[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, #[serde(default)] pub tls_ca_pem: Option, #[serde(default)] pub tls_server_name: Option, #[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, 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, /// Optional reusable ACL profile applied in addition to vhost policy. #[serde(default, skip_serializing_if = "Option::is_none")] pub acl_profile_id: Option, #[serde(default)] pub acl: Option, /// 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, /// Local HTTP redirect; mutually exclusive with `response`. #[serde(default, skip_serializing_if = "Option::is_none")] pub redirect: Option, }