Files
RustProxy/src/settings.rs
T
2026-10-08 09:42:10 +02:00

456 lines
16 KiB
Rust

//! 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<SocketAddr>,
pub https: Option<SocketAddr>,
pub administration: Option<SocketAddr>,
}
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<Duration> {
(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<SocketAddr>,
pub tls: bool,
/// None uses the application-managed default certificate pair.
pub certificate_id: Option<String>,
/// Optional strict DNS Host/Origin allowlist. Empty HTTPS lists accept valid DNS names.
pub allowed_hosts: Vec<String>,
/// 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::<std::net::IpAddr>().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<NamedListener>,
}
impl Listeners {
pub fn from_startup(http: SocketAddr, https: Option<SocketAddr>) -> 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<SocketAddr>,
}
impl NamedListener {
pub fn bindings(&self) -> Vec<crate::vhost::Listener> {
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::<HeaderValue>()?;
}
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");