The main code changes were already committed (3f2550f), but test config
TOML strings in cli.rs, admin/socket.rs, shutdown.rs, and
integration_test.rs still needed the new acme_contact field to pass
validation rule 19.
391 lines
10 KiB
Rust
391 lines
10 KiB
Rust
use std::path::Path;
|
|
|
|
use anyhow::{Context, Result};
|
|
use clap::Parser;
|
|
|
|
use crate::config::dynamic_config::{
|
|
BodyConfig, DynamicConfig, RateLimitConfig, SerializableDynamicConfig,
|
|
};
|
|
use crate::config::static_config::StaticConfig;
|
|
use crate::config::validation::validate;
|
|
|
|
const DEFAULT_CONFIG_PATH: &str = "/etc/reverse-proxy/config.toml";
|
|
|
|
#[derive(Debug, Parser)]
|
|
#[command(name = "reverse-proxy", version, about = "Reverse proxy server")]
|
|
pub struct Cli {
|
|
#[arg(long, default_value = DEFAULT_CONFIG_PATH, help = "Path to config file")]
|
|
pub config: String,
|
|
|
|
#[arg(long, help = "Validate config and exit")]
|
|
pub validate: bool,
|
|
|
|
#[arg(
|
|
long,
|
|
help = "Permit 0.0.0.0 as a bind address (for container deployments)"
|
|
)]
|
|
pub allow_wildcard_bind: bool,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct LoadedConfig {
|
|
pub static_config: StaticConfig,
|
|
pub dynamic_config: DynamicConfig,
|
|
pub allow_wildcard_bind: bool,
|
|
}
|
|
|
|
pub fn parse() -> Cli {
|
|
Cli::parse()
|
|
}
|
|
|
|
pub fn parse_from<I, S>(args: I) -> Cli
|
|
where
|
|
I: IntoIterator<Item = S>,
|
|
S: Into<std::ffi::OsString> + Clone,
|
|
{
|
|
Cli::parse_from(args)
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize)]
|
|
struct RawConfig {
|
|
#[serde(default)]
|
|
listeners: Vec<crate::config::static_config::ListenerConfig>,
|
|
#[serde(default)]
|
|
allow_wildcard_bind: bool,
|
|
#[serde(default = "crate::config::static_config::default_health_check_port")]
|
|
health_check_port: u16,
|
|
#[serde(default = "crate::config::static_config::default_admin_socket_path")]
|
|
admin_socket_path: String,
|
|
#[serde(default = "crate::config::static_config::default_shutdown_timeout_secs")]
|
|
shutdown_timeout_secs: u64,
|
|
#[serde(default)]
|
|
logging: crate::config::static_config::LoggingConfig,
|
|
rate_limit: RateLimitConfig,
|
|
body: BodyConfig,
|
|
}
|
|
|
|
pub fn load_config(cli: &Cli) -> Result<LoadedConfig> {
|
|
let config_path = Path::new(&cli.config);
|
|
let config_content = std::fs::read_to_string(config_path)
|
|
.with_context(|| format!("failed to read config file: {}", cli.config))?;
|
|
|
|
let raw: RawConfig = toml::from_str(&config_content)
|
|
.with_context(|| format!("failed to parse config file: {}", cli.config))?;
|
|
|
|
let static_config = StaticConfig {
|
|
listeners: raw.listeners,
|
|
allow_wildcard_bind: raw.allow_wildcard_bind,
|
|
health_check_port: raw.health_check_port,
|
|
admin_socket_path: raw.admin_socket_path,
|
|
shutdown_timeout_secs: raw.shutdown_timeout_secs,
|
|
logging: raw.logging,
|
|
};
|
|
|
|
let serializable_dynamic = SerializableDynamicConfig {
|
|
sites: collect_sites(&static_config),
|
|
rate_limit: raw.rate_limit,
|
|
body: raw.body,
|
|
};
|
|
|
|
let dynamic_config: DynamicConfig = serializable_dynamic.into();
|
|
|
|
let allow_wildcard_bind = static_config.allow_wildcard_bind || cli.allow_wildcard_bind;
|
|
|
|
validate(&static_config, &dynamic_config, cli.allow_wildcard_bind).map_err(|errors| {
|
|
anyhow::anyhow!(
|
|
"config validation failed:\n{}",
|
|
errors
|
|
.iter()
|
|
.map(|e| format!(" - {}", e))
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
)
|
|
})?;
|
|
|
|
Ok(LoadedConfig {
|
|
static_config,
|
|
dynamic_config,
|
|
allow_wildcard_bind,
|
|
})
|
|
}
|
|
|
|
fn collect_sites(static_config: &StaticConfig) -> Vec<crate::config::dynamic_config::SiteConfig> {
|
|
let mut sites = Vec::new();
|
|
for listener in &static_config.listeners {
|
|
sites.extend(listener.sites.clone());
|
|
}
|
|
sites
|
|
}
|
|
|
|
pub fn run_validate(cli: &Cli) -> Result<()> {
|
|
match load_config(cli) {
|
|
Ok(_) => {
|
|
println!("Configuration is valid.");
|
|
Ok(())
|
|
}
|
|
Err(e) => {
|
|
eprintln!("{}", e);
|
|
Err(e)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn cli_default_config_path() {
|
|
let cli = Cli::parse_from(["reverse-proxy"]);
|
|
assert_eq!(cli.config, DEFAULT_CONFIG_PATH);
|
|
assert!(!cli.validate);
|
|
assert!(!cli.allow_wildcard_bind);
|
|
}
|
|
|
|
#[test]
|
|
fn cli_custom_config_path() {
|
|
let cli = Cli::parse_from(["reverse-proxy", "--config", "/tmp/test.toml"]);
|
|
assert_eq!(cli.config, "/tmp/test.toml");
|
|
}
|
|
|
|
#[test]
|
|
fn cli_validate_flag() {
|
|
let cli = Cli::parse_from(["reverse-proxy", "--validate"]);
|
|
assert!(cli.validate);
|
|
}
|
|
|
|
#[test]
|
|
fn cli_allow_wildcard_bind_flag() {
|
|
let cli = Cli::parse_from(["reverse-proxy", "--allow-wildcard-bind"]);
|
|
assert!(cli.allow_wildcard_bind);
|
|
}
|
|
|
|
#[test]
|
|
fn cli_all_flags() {
|
|
let cli = Cli::parse_from([
|
|
"reverse-proxy",
|
|
"--config",
|
|
"/custom/path.toml",
|
|
"--validate",
|
|
"--allow-wildcard-bind",
|
|
]);
|
|
assert_eq!(cli.config, "/custom/path.toml");
|
|
assert!(cli.validate);
|
|
assert!(cli.allow_wildcard_bind);
|
|
}
|
|
|
|
#[test]
|
|
fn cli_version_flag() {
|
|
let result = Cli::try_parse_from(["reverse-proxy", "--version"]);
|
|
assert!(result.is_err());
|
|
if let Err(err) = result {
|
|
use clap::error::ErrorKind;
|
|
assert!(matches!(err.kind(), ErrorKind::DisplayVersion));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn load_config_missing_file() {
|
|
let cli = Cli::parse_from(["reverse-proxy", "--config", "/nonexistent/config.toml"]);
|
|
let result = load_config(&cli);
|
|
assert!(result.is_err());
|
|
let err_msg = result.unwrap_err().to_string();
|
|
assert!(err_msg.contains("failed to read config file") || err_msg.contains("No such file"));
|
|
}
|
|
|
|
#[test]
|
|
fn load_config_invalid_toml() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("bad.toml");
|
|
std::fs::write(&path, "not valid toml {{{{").unwrap();
|
|
let cli = Cli::parse_from(["reverse-proxy", "--config", path.to_str().unwrap()]);
|
|
let result = load_config(&cli);
|
|
assert!(result.is_err());
|
|
let err_msg = result.unwrap_err().to_string();
|
|
assert!(err_msg.contains("failed to parse config file"));
|
|
}
|
|
|
|
#[test]
|
|
fn load_config_valid_file() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let cert_path = dir.path().join("cert.pem");
|
|
let key_path = dir.path().join("key.pem");
|
|
std::fs::write(&cert_path, "cert").unwrap();
|
|
std::fs::write(&key_path, "key").unwrap();
|
|
|
|
let toml = format!(
|
|
r#"
|
|
[rate_limit]
|
|
requests_per_second = 10
|
|
burst = 20
|
|
|
|
[body]
|
|
limit_bytes = 104857600
|
|
|
|
[[listeners]]
|
|
bind_addr = "127.0.0.1"
|
|
http_port = 80
|
|
https_port = 443
|
|
|
|
[listeners.tls]
|
|
mode = "manual"
|
|
cert_path = "{}"
|
|
key_path = "{}"
|
|
|
|
[[listeners.sites]]
|
|
host = "test.local"
|
|
upstream = "127.0.0.1:8080"
|
|
"#,
|
|
cert_path.to_str().unwrap(),
|
|
key_path.to_str().unwrap()
|
|
);
|
|
let config_path = dir.path().join("config.toml");
|
|
std::fs::write(&config_path, toml).unwrap();
|
|
|
|
let cli = Cli::parse_from(["reverse-proxy", "--config", config_path.to_str().unwrap()]);
|
|
let result = load_config(&cli);
|
|
assert!(result.is_ok());
|
|
let loaded = result.unwrap();
|
|
assert!(!loaded.allow_wildcard_bind);
|
|
assert_eq!(loaded.static_config.listeners.len(), 1);
|
|
assert_eq!(loaded.dynamic_config.sites.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn load_config_wildcard_bind_or_logic() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
let toml = r#"
|
|
allow_wildcard_bind = false
|
|
|
|
[rate_limit]
|
|
requests_per_second = 10
|
|
burst = 20
|
|
|
|
[body]
|
|
limit_bytes = 104857600
|
|
|
|
[[listeners]]
|
|
bind_addr = "0.0.0.0"
|
|
http_port = 80
|
|
https_port = 443
|
|
|
|
[listeners.tls]
|
|
mode = "acme"
|
|
acme_domains = ["test.local"]
|
|
acme_cache_dir = "/tmp/acme"
|
|
acme_contact = "mailto:admin@test.local"
|
|
"#;
|
|
let config_path = dir.path().join("config.toml");
|
|
std::fs::write(&config_path, toml).unwrap();
|
|
|
|
let cli = Cli::parse_from([
|
|
"reverse-proxy",
|
|
"--config",
|
|
config_path.to_str().unwrap(),
|
|
"--allow-wildcard-bind",
|
|
]);
|
|
let result = load_config(&cli);
|
|
assert!(result.is_ok());
|
|
let loaded = result.unwrap();
|
|
assert!(loaded.allow_wildcard_bind);
|
|
}
|
|
|
|
#[test]
|
|
fn load_config_wildcard_bind_rejected_without_flag() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
let toml = r#"
|
|
[rate_limit]
|
|
requests_per_second = 10
|
|
burst = 20
|
|
|
|
[body]
|
|
limit_bytes = 104857600
|
|
|
|
[[listeners]]
|
|
bind_addr = "0.0.0.0"
|
|
http_port = 80
|
|
https_port = 443
|
|
|
|
[listeners.tls]
|
|
mode = "acme"
|
|
acme_domains = ["test.local"]
|
|
acme_cache_dir = "/tmp/acme"
|
|
acme_contact = "mailto:admin@test.local"
|
|
"#;
|
|
let config_path = dir.path().join("config.toml");
|
|
std::fs::write(&config_path, toml).unwrap();
|
|
|
|
let cli = Cli::parse_from(["reverse-proxy", "--config", config_path.to_str().unwrap()]);
|
|
let result = load_config(&cli);
|
|
assert!(result.is_err());
|
|
let err_msg = result.unwrap_err().to_string();
|
|
assert!(err_msg.contains("config validation failed"));
|
|
}
|
|
|
|
#[test]
|
|
fn load_config_validation_fails_reports_errors() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
let toml = r#"
|
|
[rate_limit]
|
|
requests_per_second = 0
|
|
burst = 20
|
|
|
|
[body]
|
|
limit_bytes = 0
|
|
"#;
|
|
let config_path = dir.path().join("config.toml");
|
|
std::fs::write(&config_path, toml).unwrap();
|
|
|
|
let cli = Cli::parse_from(["reverse-proxy", "--config", config_path.to_str().unwrap()]);
|
|
let result = load_config(&cli);
|
|
assert!(result.is_err());
|
|
let err_msg = result.unwrap_err().to_string();
|
|
assert!(err_msg.contains("config validation failed"));
|
|
}
|
|
|
|
#[test]
|
|
fn load_config_allow_wildcard_from_config_only() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
let toml = r#"
|
|
allow_wildcard_bind = true
|
|
|
|
[rate_limit]
|
|
requests_per_second = 10
|
|
burst = 20
|
|
|
|
[body]
|
|
limit_bytes = 104857600
|
|
|
|
[[listeners]]
|
|
bind_addr = "0.0.0.0"
|
|
http_port = 80
|
|
https_port = 443
|
|
|
|
[listeners.tls]
|
|
mode = "acme"
|
|
acme_domains = ["test.local"]
|
|
acme_cache_dir = "/tmp/acme"
|
|
acme_contact = "mailto:admin@test.local"
|
|
"#;
|
|
let config_path = dir.path().join("config.toml");
|
|
std::fs::write(&config_path, toml).unwrap();
|
|
|
|
let cli = Cli::parse_from(["reverse-proxy", "--config", config_path.to_str().unwrap()]);
|
|
let result = load_config(&cli);
|
|
assert!(result.is_ok());
|
|
assert!(result.unwrap().allow_wildcard_bind);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_from_vec() {
|
|
let args = vec!["reverse-proxy", "--validate", "--allow-wildcard-bind"];
|
|
let cli = parse_from(args);
|
|
assert!(cli.validate);
|
|
assert!(cli.allow_wildcard_bind);
|
|
}
|
|
}
|