//! Persisted listener intent and response controls. Listener changes require restart. use anyhow::{ensure, Result}; use http::{HeaderMap, HeaderValue}; use serde::{Deserialize, Serialize}; use std::{net::SocketAddr, time::Duration}; /// Listener addresses explicitly supplied by the service manager/CLI. These /// are process-level contracts and cannot be removed or rewritten through the /// persisted control plane while the process is started with those values. #[derive(Debug, Clone, Copy, Default)] pub struct StartupLocks { pub http: Option, pub https: Option, pub administration: Option, } impl StartupLocks { pub fn ensure_config(&self, config: &mut crate::config::Config) { if self.administration.is_some() { // Explicit startup administration bind remains authoritative. `None` // means "use startup value" and also preserves the plain-HTTP safety // gate handled by main. config.administration.listen = None; } if let Some(address) = self.http { ensure_locked_listener(config, address, false); } if let Some(address) = self.https { ensure_locked_listener(config, address, true); } } pub fn validate( &self, config: &crate::config::Config, startup_admin: SocketAddr, ) -> Result<()> { if let Some(required) = self.http { ensure!( config.listeners.endpoints.iter().any(|endpoint| { endpoint.enabled && endpoint.bindings().iter().any(|binding| binding.address == required && !binding.tls) }), "HTTP listener {required} is fixed by systemd/startup and cannot be removed, disabled, or changed" ); } if let Some(required) = self.https { ensure!( config.listeners.endpoints.iter().any(|endpoint| { endpoint.enabled && endpoint.bindings().iter().any(|binding| binding.address == required && binding.tls) }), "HTTPS listener {required} is fixed by systemd/startup and cannot be removed, disabled, or changed" ); } if let Some(required) = self.administration { ensure!( config.administration.listen.is_none() && config.administration.resolved_listen(startup_admin) == required, "administration listener {required} is fixed by systemd/startup and cannot be changed" ); } Ok(()) } } fn ensure_locked_listener(config: &mut crate::config::Config, address: SocketAddr, tls: bool) { if let Some(endpoint) = config.listeners.endpoints.iter_mut().find(|endpoint| { endpoint .bindings() .iter() .any(|binding| binding.address == address && binding.tls == tls) }) { endpoint.enabled = true; return; } // An explicit startup HTTP/HTTPS address overrides the canonical default // listener created by earlier starts. Keep separately named/custom listeners // intact, but do not retain the old default :80/:443 binding alongside a // command-line/systemd override. let canonical_id = if tls { "https" } else { "http" }; if let Some(endpoint) = config.listeners.endpoints.iter_mut().find(|endpoint| { endpoint.id == canonical_id && endpoint.binding.tls == tls }) { endpoint.binding.address = address; endpoint.additional_addresses.clear(); endpoint.enabled = true; return; } let base = if tls { "systemd-https" } else { "systemd-http" }; let mut id = base.to_owned(); let mut suffix = 2usize; while config .listeners .endpoints .iter() .any(|endpoint| endpoint.id == id) { id = format!("{base}-{suffix}"); suffix += 1; } config.listeners.endpoints.push(NamedListener { id, name: if tls { "HTTPS (startup locked)".into() } else { "HTTP (startup locked)".into() }, scope: if address.ip().is_loopback() { ListenerScope::Internal } else { ListenerScope::Public }, enabled: true, binding: crate::vhost::Listener { address, tls, http2: tls, http2_settings: tls.then(Http2Connections::default), h2c: false, http3: tls.then(crate::http3::Settings::default), }, additional_addresses: Vec::new(), }); } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(default, deny_unknown_fields)] pub struct Http2Connections { /// Close an otherwise unused HTTP/2 connection after this inactivity period. pub idle_timeout_seconds: u64, /// Absolute connection age limit. Zero disables the lifetime limit. pub max_connection_lifetime_seconds: u64, /// Maximum time after GOAWAY for active streams to finish before TCP is closed. pub drain_timeout_seconds: u64, } impl Default for Http2Connections { fn default() -> Self { Self { idle_timeout_seconds: 300, max_connection_lifetime_seconds: 0, drain_timeout_seconds: 10, } } } impl Http2Connections { pub fn validate(&self) -> Result<()> { ensure!( (1..=86_400).contains(&self.idle_timeout_seconds), "HTTP/2 idle_timeout_seconds must be 1..86400" ); ensure!( self.max_connection_lifetime_seconds == 0 || (1..=2_592_000).contains(&self.max_connection_lifetime_seconds), "HTTP/2 max_connection_lifetime_seconds must be 0..2592000 (0 disables the limit)" ); ensure!( (1..=3_600).contains(&self.drain_timeout_seconds), "HTTP/2 drain_timeout_seconds must be 1..3600" ); Ok(()) } pub fn idle_timeout(self) -> Duration { Duration::from_secs(self.idle_timeout_seconds) } pub fn max_connection_lifetime(self) -> Option { (self.max_connection_lifetime_seconds > 0) .then(|| Duration::from_secs(self.max_connection_lifetime_seconds)) } pub fn drain_timeout(self) -> Duration { Duration::from_secs(self.drain_timeout_seconds) } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default, deny_unknown_fields)] pub struct Administration { /// None keeps the startup/CLI administration address for backward compatibility. /// A persisted address is applied on the next process start. pub listen: Option, pub tls: bool, /// None uses the application-managed default certificate pair. pub certificate_id: Option, /// Optional strict DNS Host/Origin allowlist. Empty HTTPS lists accept valid DNS names. pub allowed_hosts: Vec, /// Absolute browser-session lifetime. Existing sessions are bounded by the current value. pub session_days: u16, } impl Default for Administration { fn default() -> Self { Self { listen: None, tls: false, certificate_id: None, allowed_hosts: Vec::new(), session_days: 30, } } } impl Administration { pub fn resolved_listen(&self, startup: SocketAddr) -> SocketAddr { self.listen.unwrap_or(startup) } pub fn session_seconds(&self) -> i64 { i64::from(self.session_days) * 24 * 60 * 60 } pub fn restart_differs(&self, active: &Self) -> bool { self.listen != active.listen || self.tls != active.tls || self.certificate_id != active.certificate_id || self.allowed_hosts != active.allowed_hosts } pub fn validate(&mut self, tls: &crate::tls::Settings) -> Result<()> { if let Some(addr) = self.listen { ensure!( addr.port() > 0, "administration listener port must be nonzero" ); ensure!( !addr.ip().is_multicast(), "administration listener must use a unicast/wildcard IP" ); } if let Some(id) = &self.certificate_id { ensure!( self.tls, "administration certificate selection requires administration TLS" ); ensure!( tls.certificates .iter() .any(|certificate| &certificate.id == id), "administration certificate references an unknown certificate id" ); } ensure!( (7..=30).contains(&self.session_days), "administration session_days must be 7..30" ); let mut seen = std::collections::HashSet::new(); for host in &mut self.allowed_hosts { if host.parse::().is_err() { *host = crate::config::normalize_host(host)?; } ensure!( seen.insert(host.clone()), "duplicate administration hostname" ); } Ok(()) } } #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Listeners { /// All proxy sockets are configured here and referenced by vhosts by ID. pub endpoints: Vec, } impl Listeners { pub fn from_startup(http: SocketAddr, https: Option) -> Self { let mut endpoints = vec![NamedListener { id: "http".into(), name: "HTTP".into(), scope: ListenerScope::Internal, enabled: true, binding: crate::vhost::Listener { address: http, tls: false, http2: false, http2_settings: None, h2c: false, http3: None, }, additional_addresses: Vec::new(), }]; if let Some(address) = https { endpoints.push(NamedListener { id: "https".into(), name: "HTTPS".into(), scope: ListenerScope::Internal, enabled: true, binding: crate::vhost::Listener { address, tls: true, http2: true, http2_settings: Some(Http2Connections::default()), h2c: false, http3: Some(crate::http3::Settings::default()), }, additional_addresses: Vec::new(), }); } Self { endpoints } } pub fn validate(&self, policy: &crate::config::ValidationPolicy) -> Result<()> { ensure!(self.endpoints.len() <= 64, "at most 64 named listeners"); let mut ids = std::collections::HashSet::new(); for endpoint in &self.endpoints { ensure!( crate::config::valid_id(&endpoint.id) && ids.insert(&endpoint.id), "invalid/duplicate listener id" ); ensure!( endpoint.name.len() <= 96 && !endpoint.name.chars().any(char::is_control), "listener name must be at most 96 bytes without controls" ); ensure!( endpoint.additional_addresses.len() <= 15, "listener allows at most 16 bind addresses" ); let bindings = endpoint.bindings(); let mut addresses = std::collections::HashSet::new(); for binding in &bindings { crate::vhost::validate_listener(*binding)?; ensure!( binding.address.port() != policy.admin_addr.port(), "listener cannot share administration port" ); ensure!( addresses.insert(binding.address), "duplicate address in named listener" ); } if bindings.len() > 1 { ensure!( bindings.iter().all(|binding| !binding.address.ip().is_unspecified()), "multi-address listeners require explicit IPs; do not combine 0.0.0.0/:: with selected addresses" ); } } Ok(()) } } /// Labels describe operator intent, never firewall or access-control enforcement. #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ListenerScope { #[default] Internal, Public, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct NamedListener { pub id: String, #[serde(default)] pub name: String, #[serde(default)] pub scope: ListenerScope, #[serde(default = "enabled")] pub enabled: bool, pub binding: crate::vhost::Listener, /// Extra explicit local addresses that share the same transport/protocol policy. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub additional_addresses: Vec, } impl NamedListener { pub fn bindings(&self) -> Vec { let mut out = Vec::with_capacity(1 + self.additional_addresses.len()); out.push(self.binding); out.extend(self.additional_addresses.iter().map(|address| { let mut binding = self.binding; binding.address = *address; binding })); out } } fn enabled() -> bool { true } #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct DiagnosticHeaders { pub request_id: bool, } impl DiagnosticHeaders { pub fn apply(&self, headers: &mut HeaderMap, request_id: &str) { headers.remove("x-request-id"); if self.request_id { if let Ok(value) = request_id.parse() { headers.insert("x-request-id", value); } } } } #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ServerMode { #[default] Preserve, Hide, Custom, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ServerHeader { #[serde(default)] pub mode: ServerMode, #[serde(default = "legacy_server_header_value")] pub value: String, } fn legacy_server_header_value() -> String { format!("RustProxy/v{}", env!("CARGO_PKG_VERSION")) } impl ServerHeader { pub(crate) fn legacy_compat_default() -> Self { Self { mode: ServerMode::Preserve, value: legacy_server_header_value(), } } } impl Default for ServerHeader { fn default() -> Self { Self { mode: ServerMode::Custom, value: "RustProxy".into(), } } } impl ServerHeader { pub fn validate(&self) -> Result<()> { ensure!( self.value.len() <= 256, "Server header value exceeds 256 bytes" ); if matches!(self.mode, ServerMode::Custom) { ensure!( !self.value.trim().is_empty(), "custom Server header cannot be empty" ); self.value.parse::()?; } Ok(()) } pub fn apply(&self, headers: &mut HeaderMap) { match self.mode { ServerMode::Preserve => {} ServerMode::Hide => { headers.remove("server"); } ServerMode::Custom => { if let Ok(value) = self.value.parse() { headers.insert("server", value); } } } } } #[cfg(test)] include!("settings/tests.rs");