From dd72f152a671d1a7b737a0bd5e83cab1a05fdd1a Mon Sep 17 00:00:00 2001 From: awfufu Date: Mon, 19 Jan 2026 10:30:29 +0800 Subject: [PATCH] feat(config): implement strict validation for unknown fields and semantic rules --- Cargo.lock | 11 +++++ Cargo.toml | 1 + config_example.yaml | 3 -- src/config/mod.rs | 68 ++++++++++++++++++++++++++- tests/config_test.rs | 107 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 185 insertions(+), 5 deletions(-) create mode 100644 tests/config_test.rs diff --git a/Cargo.lock b/Cargo.lock index 7013f76..01e8775 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2010,6 +2010,16 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "serde_ignored" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -2491,6 +2501,7 @@ dependencies = [ "openssl", "pingora", "serde", + "serde_ignored", "serde_repr", "serde_yaml 0.9.34+deprecated", "socket2", diff --git a/Cargo.toml b/Cargo.toml index 2f60a18..a5f0275 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ pingora = { version = "0.6", features = ["lb", "openssl"] } ipnet = { version = "2.11.0", features = ["serde"] } httparse = "1.10.1" openssl = { version = "0.10", optional = true } +serde_ignored = "0.1.14" [features] default = [] diff --git a/config_example.yaml b/config_example.yaml index eb68ae3..1cae2e2 100644 --- a/config_example.yaml +++ b/config_example.yaml @@ -13,9 +13,6 @@ services: - name: "ssh" forward_to: "127.0.0.1:22" type: "tcp" - real_ip: - from: "proxy_protocol" - trust_private_ranges: true binds: # Entry 1: Public traffic from FRP - addr: "0.0.0.0:2223" diff --git a/src/config/mod.rs b/src/config/mod.rs index 800a2f8..105f605 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -163,10 +163,74 @@ where impl Config { pub async fn load>(path: P) -> Result { - let content = fs::read_to_string(path).await?; - let config: Config = serde_yaml::from_str(&content)?; + let content = fs::read_to_string(&path).await?; + let deserializer = serde_yaml::Deserializer::from_str(&content); + + // Track unknown fields + let mut unused = Vec::new(); + let config: Config = serde_ignored::deserialize(deserializer, |path| { + unused.push(path.to_string()); + }) + .map_err(|e| anyhow::anyhow!("failed to parse config: {}", e))?; + + if !unused.is_empty() { + let fields = unused.join(", "); + anyhow::bail!( + "configuration contains unknown or misplaced fields: [{}] in {}", + fields, + path.as_ref().display() + ); + } + + // Semantic validation + config.validate()?; + Ok(config) } + + pub fn validate(&self) -> anyhow::Result<()> { + for service in &self.services { + // Rule 1 check: TCP cannot use XFF + // Check default/fallback real_ip logic if we had it, but currently real_ip is per-bind mostly? + // Actually ServiceConfig has strictly forward_to/binds. But wait, checking definition... + // Binds have real_ip. Service does NOT have real_ip in the struct definiton in this file, + // but Config struct shows "ServiceConfig" has "binds". + // User request said: "In tcp service, user wrote real_ip.from: xff". + // Let's check where real_ip is defined. + // Ah, checking the struct definition above... BindEntry has real_ip. ServiceConfig does NOT have real_ip field shown in previous view. + // Wait, let me re-verify the struct definition from the file content I have in context. + + // Lines 34-43: ServiceConfig has name, type, binds, forward_to. NO real_ip. + // If user puts "real_ip" in service block, the "unused fields" check handles it (My Rule #3). + // So verification logic only needs to check BindEntry's real_ip. + + for bind in &service.binds { + if let Some(real_ip) = &bind.real_ip { + // Rule 1: TCP + XFF + if service.service_type == "tcp" && real_ip.source == RealIpSource::Xff { + // Exception: If it's a TCP service but strictly doing HTTP analysis (unlikely in pure tcp mode unless using http parser?) + // User explicitly said "tcp service ... does not support xff". + // Assuming "type: tcp" implies no HTTP parsing layer is active to extract headers. + anyhow::bail!( + "Service '{}' is type 'tcp', but bind '{}' is configured to use 'xff' for real_ip. TCP services cannot parse HTTP headers.", + service.name, + bind.addr + ); + } + + // Rule 2: No Proxy + ProxyProtocol + if bind.proxy.is_none() && real_ip.source == RealIpSource::ProxyProtocol { + anyhow::bail!( + "Service '{}' bind '{}' requests real_ip from 'proxy_protocol', but proxy protocol support is not enabled (missing 'proxy: v1/v2').", + service.name, + bind.addr + ); + } + } + } + } + Ok(()) + } } #[cfg(test)] diff --git a/tests/config_test.rs b/tests/config_test.rs new file mode 100644 index 0000000..6ca518b --- /dev/null +++ b/tests/config_test.rs @@ -0,0 +1,107 @@ +use std::io::Write; +use traudit::config::{Config, RealIpConfig}; + +#[tokio::test] +async fn test_error_on_unknown_fields() { + let config_str = r#" +database: + type: clickhouse + dsn: "http://127.0.0.1:8123" + unknown_db_field: "should_error" +services: [] +unknown_root_field: "should_also_error" +"#; + let mut file = tempfile::NamedTempFile::new().unwrap(); + write!(file, "{}", config_str).unwrap(); + let path = file.path().to_path_buf(); + + // Init tracing optional + let _ = tracing_subscriber::fmt::try_init(); + + // Expect ERROR + let res = Config::load(&path).await; + assert!(res.is_err()); + let err = res.err().unwrap().to_string(); + assert!(err.contains("unknown or misplaced fields")); + assert!(err.contains("unknown_db_field")); + assert!(err.contains("unknown_root_field")); +} + +#[tokio::test] +async fn test_error_tcp_xff() { + let config_str = r#" +database: + type: clickhouse + dsn: "http://127.0.0.1:8123" +services: + - name: "bad-service" + type: "tcp" + forward_to: "127.0.0.1:22" + binds: + - addr: "0.0.0.0:8000" + real_ip: + from: "xff" +"#; + let mut file = tempfile::NamedTempFile::new().unwrap(); + write!(file, "{}", config_str).unwrap(); + let path = file.path().to_path_buf(); + + let res = Config::load(&path).await; + assert!(res.is_err()); + let err = res.err().unwrap().to_string(); + assert!(err.contains("TCP services cannot parse HTTP headers")); +} + +#[tokio::test] +async fn test_error_proxy_mismatch() { + let config_str = r#" +database: + type: clickhouse + dsn: "http://127.0.0.1:8123" +services: + - name: "bad-proxy" + type: "tcp" + forward_to: "127.0.0.1:22" + binds: + - addr: "0.0.0.0:8000" + # proxy: v2 IS MISSING + real_ip: + from: "proxy_protocol" +"#; + let mut file = tempfile::NamedTempFile::new().unwrap(); + write!(file, "{}", config_str).unwrap(); + let path = file.path().to_path_buf(); + + let res = Config::load(&path).await; + assert!(res.is_err()); + let err = res.err().unwrap().to_string(); + assert!(err.contains("proxy protocol support is not enabled")); +} + +#[test] +fn test_trusted_proxies_mixed_formats() { + let yaml = r#" + from: "xff" + trusted_proxies: + - "1.2.3.4" + - "10.0.0.0/24" + - "2001:db8::/32" + "#; + + let config: RealIpConfig = serde_yaml::from_str(yaml).expect("Failed to parse config"); + + // 1. Exact IP match + assert!(config.is_trusted("1.2.3.4".parse().unwrap())); + + // 2. CIDR Range match (10.0.0.1 is in 10.0.0.0/24) + assert!(config.is_trusted("10.0.0.1".parse().unwrap())); + assert!(config.is_trusted("10.0.0.254".parse().unwrap())); + + // 3. IPv6 CIDR match + assert!(config.is_trusted("2001:db8::1".parse().unwrap())); + + // 4. Negative cases + assert!(!config.is_trusted("1.2.3.5".parse().unwrap())); // Wrong IP + assert!(!config.is_trusted("10.0.1.1".parse().unwrap())); // Outside /24 + assert!(!config.is_trusted("2001:db9::1".parse().unwrap())); // Outside /32 +}