Files
RustProxy/src/vhost/model.rs
T
2026-10-08 15:59:37 +02:00

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>,
}