Add ConfigReloadHandle with Arc<ArcSwap<DynamicConfig>> for lock-free reads on the request hot path and tokio::sync::Mutex-serialized reload. Add static config change detection via diff_static_config(). Add DynamicConfig validation (rate_limit, body_limit, site checks). Add PartialEq derives to config types. Include unit tests for ArcSwap swap visibility, invalid config rejection, and concurrent reload serialization.
419 lines
11 KiB
Rust
419 lines
11 KiB
Rust
use serde::Deserialize;
|
|
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
|
pub struct StaticConfig {
|
|
pub listeners: Vec<ListenerConfig>,
|
|
#[serde(default)]
|
|
pub allow_wildcard_bind: bool,
|
|
#[serde(default = "default_health_check_port")]
|
|
pub health_check_port: u16,
|
|
#[serde(default = "default_admin_socket_path")]
|
|
pub admin_socket_path: String,
|
|
#[serde(default = "default_shutdown_timeout_secs")]
|
|
pub shutdown_timeout_secs: u64,
|
|
#[serde(default)]
|
|
pub logging: LoggingConfig,
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
fn default_health_check_port() -> u16 {
|
|
9900
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
fn default_admin_socket_path() -> String {
|
|
"/run/reverse-proxy/admin.sock".to_string()
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
fn default_shutdown_timeout_secs() -> u64 {
|
|
30
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
|
pub struct ListenerConfig {
|
|
pub bind_addr: String,
|
|
#[serde(default = "default_http_port")]
|
|
pub http_port: u16,
|
|
#[serde(default = "default_https_port")]
|
|
pub https_port: u16,
|
|
pub tls: TlsConfig,
|
|
#[serde(default)]
|
|
pub sites: Vec<crate::config::dynamic_config::SiteConfig>,
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
fn default_http_port() -> u16 {
|
|
80
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
fn default_https_port() -> u16 {
|
|
443
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
|
pub struct TlsConfig {
|
|
pub mode: String,
|
|
#[serde(default)]
|
|
pub acme_domains: Vec<String>,
|
|
#[serde(default)]
|
|
pub acme_cache_dir: String,
|
|
#[serde(default = "default_acme_directory")]
|
|
pub acme_directory: String,
|
|
#[serde(default)]
|
|
pub cert_path: String,
|
|
#[serde(default)]
|
|
pub key_path: String,
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
fn default_acme_directory() -> String {
|
|
"production".to_string()
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
|
pub struct LoggingConfig {
|
|
#[serde(default = "default_log_level")]
|
|
pub level: String,
|
|
#[serde(default = "default_log_format")]
|
|
pub format: String,
|
|
#[serde(default)]
|
|
pub log_file_path: Option<String>,
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
fn default_log_level() -> String {
|
|
"info".to_string()
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
fn default_log_format() -> String {
|
|
"text".to_string()
|
|
}
|
|
|
|
impl Default for LoggingConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
level: default_log_level(),
|
|
format: default_log_format(),
|
|
log_file_path: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn multi_config_toml() -> &'static str {
|
|
r#"
|
|
health_check_port = 9900
|
|
admin_socket_path = "/run/reverse-proxy/admin.sock"
|
|
|
|
[logging]
|
|
level = "info"
|
|
format = "text"
|
|
|
|
[rate_limit]
|
|
requests_per_second = 10
|
|
burst = 20
|
|
|
|
[body]
|
|
limit_bytes = 104857600
|
|
|
|
[[listeners]]
|
|
bind_addr = "203.0.113.10"
|
|
http_port = 80
|
|
https_port = 443
|
|
|
|
[listeners.tls]
|
|
mode = "acme"
|
|
acme_domains = ["git.alk.dev"]
|
|
acme_cache_dir = "/var/lib/reverse-proxy/acme-cache-git"
|
|
acme_directory = "production"
|
|
|
|
[[listeners.sites]]
|
|
host = "git.alk.dev"
|
|
upstream = "127.0.0.1:3000"
|
|
upstream_scheme = "http"
|
|
|
|
[[listeners]]
|
|
bind_addr = "203.0.113.11"
|
|
http_port = 80
|
|
https_port = 443
|
|
|
|
[listeners.tls]
|
|
mode = "manual"
|
|
cert_path = "/etc/ssl/alk.dev/fullchain.pem"
|
|
key_path = "/etc/ssl/alk.dev/privkey.pem"
|
|
|
|
[[listeners.sites]]
|
|
host = "alk.dev"
|
|
upstream = "127.0.0.1:8080"
|
|
upstream_scheme = "http"
|
|
"#
|
|
}
|
|
|
|
fn shared_ip_san_toml() -> &'static str {
|
|
r#"
|
|
health_check_port = 9900
|
|
admin_socket_path = "/run/reverse-proxy/admin.sock"
|
|
|
|
[logging]
|
|
level = "info"
|
|
format = "text"
|
|
|
|
[rate_limit]
|
|
requests_per_second = 10
|
|
burst = 20
|
|
|
|
[body]
|
|
limit_bytes = 104857600
|
|
|
|
[[listeners]]
|
|
bind_addr = "203.0.113.10"
|
|
http_port = 80
|
|
https_port = 443
|
|
|
|
[listeners.tls]
|
|
mode = "acme"
|
|
acme_domains = ["git.alk.dev", "alk.dev"]
|
|
acme_cache_dir = "/var/lib/reverse-proxy/acme-cache"
|
|
acme_directory = "production"
|
|
|
|
[[listeners.sites]]
|
|
host = "git.alk.dev"
|
|
upstream = "127.0.0.1:3000"
|
|
|
|
[[listeners.sites]]
|
|
host = "alk.dev"
|
|
upstream = "127.0.0.1:8080"
|
|
"#
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, serde::Deserialize)]
|
|
struct FullConfig {
|
|
#[serde(default)]
|
|
listeners: Vec<ListenerConfig>,
|
|
#[serde(default)]
|
|
allow_wildcard_bind: bool,
|
|
#[serde(default = "default_health_check_port")]
|
|
health_check_port: u16,
|
|
#[serde(default = "default_admin_socket_path")]
|
|
admin_socket_path: String,
|
|
#[serde(default = "default_shutdown_timeout_secs")]
|
|
shutdown_timeout_secs: u64,
|
|
#[serde(default)]
|
|
logging: LoggingConfig,
|
|
rate_limit: crate::config::dynamic_config::RateLimitConfig,
|
|
body: crate::config::dynamic_config::BodyConfig,
|
|
}
|
|
|
|
#[test]
|
|
fn deserialize_multi_config() {
|
|
let config: FullConfig =
|
|
toml::from_str(multi_config_toml()).expect("multi-config TOML should parse");
|
|
|
|
assert_eq!(config.listeners.len(), 2);
|
|
assert!(!config.allow_wildcard_bind);
|
|
assert_eq!(config.health_check_port, 9900);
|
|
assert_eq!(config.admin_socket_path, "/run/reverse-proxy/admin.sock");
|
|
assert_eq!(config.shutdown_timeout_secs, 30);
|
|
assert_eq!(config.logging.level, "info");
|
|
assert_eq!(config.logging.format, "text");
|
|
assert!(config.logging.log_file_path.is_none());
|
|
|
|
let listener1 = &config.listeners[0];
|
|
assert_eq!(listener1.bind_addr, "203.0.113.10");
|
|
assert_eq!(listener1.http_port, 80);
|
|
assert_eq!(listener1.https_port, 443);
|
|
assert_eq!(listener1.tls.mode, "acme");
|
|
assert_eq!(listener1.tls.acme_domains, vec!["git.alk.dev"]);
|
|
assert_eq!(
|
|
listener1.tls.acme_cache_dir,
|
|
"/var/lib/reverse-proxy/acme-cache-git"
|
|
);
|
|
assert_eq!(listener1.tls.acme_directory, "production");
|
|
assert_eq!(listener1.sites.len(), 1);
|
|
assert_eq!(listener1.sites[0].host, "git.alk.dev");
|
|
assert_eq!(listener1.sites[0].upstream, "127.0.0.1:3000");
|
|
assert_eq!(listener1.sites[0].upstream_scheme, "http");
|
|
|
|
let listener2 = &config.listeners[1];
|
|
assert_eq!(listener2.bind_addr, "203.0.113.11");
|
|
assert_eq!(listener2.tls.mode, "manual");
|
|
assert_eq!(listener2.tls.cert_path, "/etc/ssl/alk.dev/fullchain.pem");
|
|
assert_eq!(listener2.tls.key_path, "/etc/ssl/alk.dev/privkey.pem");
|
|
assert_eq!(listener2.sites.len(), 1);
|
|
assert_eq!(listener2.sites[0].host, "alk.dev");
|
|
}
|
|
|
|
#[test]
|
|
fn deserialize_shared_ip_san() {
|
|
let config: FullConfig =
|
|
toml::from_str(shared_ip_san_toml()).expect("shared-IP SAN TOML should parse");
|
|
|
|
assert_eq!(config.listeners.len(), 1);
|
|
let listener = &config.listeners[0];
|
|
assert_eq!(listener.bind_addr, "203.0.113.10");
|
|
assert_eq!(listener.tls.mode, "acme");
|
|
assert_eq!(listener.tls.acme_domains, vec!["git.alk.dev", "alk.dev"]);
|
|
assert_eq!(
|
|
listener.tls.acme_cache_dir,
|
|
"/var/lib/reverse-proxy/acme-cache"
|
|
);
|
|
assert_eq!(listener.sites.len(), 2);
|
|
assert_eq!(listener.sites[0].host, "git.alk.dev");
|
|
assert_eq!(listener.sites[1].host, "alk.dev");
|
|
}
|
|
|
|
#[test]
|
|
fn defaults_applied_when_omitted() {
|
|
let minimal = r#"
|
|
[rate_limit]
|
|
requests_per_second = 10
|
|
burst = 20
|
|
|
|
[body]
|
|
limit_bytes = 104857600
|
|
|
|
[[listeners]]
|
|
bind_addr = "192.168.1.1"
|
|
|
|
[listeners.tls]
|
|
mode = "acme"
|
|
acme_domains = ["example.com"]
|
|
acme_cache_dir = "/tmp/cache"
|
|
"#;
|
|
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, serde::Deserialize)]
|
|
struct MinimalConfig {
|
|
#[serde(default)]
|
|
listeners: Vec<ListenerConfig>,
|
|
#[serde(default)]
|
|
allow_wildcard_bind: bool,
|
|
#[serde(default = "default_health_check_port")]
|
|
health_check_port: u16,
|
|
#[serde(default = "default_admin_socket_path")]
|
|
admin_socket_path: String,
|
|
#[serde(default = "default_shutdown_timeout_secs")]
|
|
shutdown_timeout_secs: u64,
|
|
#[serde(default)]
|
|
logging: LoggingConfig,
|
|
rate_limit: crate::config::dynamic_config::RateLimitConfig,
|
|
body: crate::config::dynamic_config::BodyConfig,
|
|
}
|
|
|
|
let config: MinimalConfig = toml::from_str(minimal).expect("minimal config should parse");
|
|
|
|
assert!(!config.allow_wildcard_bind);
|
|
assert_eq!(config.health_check_port, 9900);
|
|
assert_eq!(config.admin_socket_path, "/run/reverse-proxy/admin.sock");
|
|
assert_eq!(config.shutdown_timeout_secs, 30);
|
|
assert_eq!(config.logging.level, "info");
|
|
assert_eq!(config.logging.format, "text");
|
|
assert!(config.logging.log_file_path.is_none());
|
|
|
|
let listener = &config.listeners[0];
|
|
assert_eq!(listener.http_port, 80);
|
|
assert_eq!(listener.https_port, 443);
|
|
assert_eq!(listener.tls.acme_directory, "production");
|
|
assert!(listener.sites.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn logging_with_file_path() {
|
|
let toml = r#"
|
|
[rate_limit]
|
|
requests_per_second = 10
|
|
burst = 20
|
|
|
|
[body]
|
|
limit_bytes = 104857600
|
|
|
|
[logging]
|
|
level = "debug"
|
|
format = "json"
|
|
log_file_path = "/var/log/reverse-proxy/access.log"
|
|
|
|
[[listeners]]
|
|
bind_addr = "203.0.113.10"
|
|
|
|
[listeners.tls]
|
|
mode = "manual"
|
|
cert_path = "/etc/ssl/cert.pem"
|
|
key_path = "/etc/ssl/key.pem"
|
|
"#;
|
|
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, serde::Deserialize)]
|
|
struct TestConfig {
|
|
#[serde(default)]
|
|
listeners: Vec<ListenerConfig>,
|
|
#[serde(default)]
|
|
logging: LoggingConfig,
|
|
rate_limit: crate::config::dynamic_config::RateLimitConfig,
|
|
body: crate::config::dynamic_config::BodyConfig,
|
|
}
|
|
|
|
let config: TestConfig =
|
|
toml::from_str(toml).expect("config with log_file_path should parse");
|
|
|
|
assert_eq!(config.logging.level, "debug");
|
|
assert_eq!(config.logging.format, "json");
|
|
assert_eq!(
|
|
config.logging.log_file_path,
|
|
Some("/var/log/reverse-proxy/access.log".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn site_defaults() {
|
|
let toml = r#"
|
|
[rate_limit]
|
|
requests_per_second = 10
|
|
burst = 20
|
|
|
|
[body]
|
|
limit_bytes = 104857600
|
|
|
|
[[listeners]]
|
|
bind_addr = "203.0.113.10"
|
|
|
|
[listeners.tls]
|
|
mode = "acme"
|
|
acme_domains = ["test.example"]
|
|
acme_cache_dir = "/tmp/acme"
|
|
|
|
[[listeners.sites]]
|
|
host = "test.example"
|
|
upstream = "127.0.0.1:8080"
|
|
"#;
|
|
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, serde::Deserialize)]
|
|
struct TestConfig {
|
|
#[serde(default)]
|
|
listeners: Vec<ListenerConfig>,
|
|
rate_limit: crate::config::dynamic_config::RateLimitConfig,
|
|
body: crate::config::dynamic_config::BodyConfig,
|
|
}
|
|
|
|
let config: TestConfig = toml::from_str(toml).expect("config should parse");
|
|
|
|
let site = &config.listeners[0].sites[0];
|
|
assert_eq!(site.host, "test.example");
|
|
assert_eq!(site.upstream, "127.0.0.1:8080");
|
|
assert_eq!(site.upstream_scheme, "http");
|
|
assert_eq!(site.upstream_connect_timeout_secs, 5);
|
|
assert_eq!(site.upstream_request_timeout_secs, 60);
|
|
}
|
|
}
|