mirror of
https://github.com/awfufu/traudit
synced 2026-03-01 05:29:44 +08:00
feat(config): implement strict validation for unknown fields and semantic rules
This commit is contained in:
11
Cargo.lock
generated
11
Cargo.lock
generated
@@ -2010,6 +2010,16 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"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]]
|
[[package]]
|
||||||
name = "serde_repr"
|
name = "serde_repr"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -2491,6 +2501,7 @@ dependencies = [
|
|||||||
"openssl",
|
"openssl",
|
||||||
"pingora",
|
"pingora",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_ignored",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
"serde_yaml 0.9.34+deprecated",
|
"serde_yaml 0.9.34+deprecated",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ pingora = { version = "0.6", features = ["lb", "openssl"] }
|
|||||||
ipnet = { version = "2.11.0", features = ["serde"] }
|
ipnet = { version = "2.11.0", features = ["serde"] }
|
||||||
httparse = "1.10.1"
|
httparse = "1.10.1"
|
||||||
openssl = { version = "0.10", optional = true }
|
openssl = { version = "0.10", optional = true }
|
||||||
|
serde_ignored = "0.1.14"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
|
|||||||
@@ -13,9 +13,6 @@ services:
|
|||||||
- name: "ssh"
|
- name: "ssh"
|
||||||
forward_to: "127.0.0.1:22"
|
forward_to: "127.0.0.1:22"
|
||||||
type: "tcp"
|
type: "tcp"
|
||||||
real_ip:
|
|
||||||
from: "proxy_protocol"
|
|
||||||
trust_private_ranges: true
|
|
||||||
binds:
|
binds:
|
||||||
# Entry 1: Public traffic from FRP
|
# Entry 1: Public traffic from FRP
|
||||||
- addr: "0.0.0.0:2223"
|
- addr: "0.0.0.0:2223"
|
||||||
|
|||||||
@@ -163,10 +163,74 @@ where
|
|||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub async fn load<P: AsRef<Path>>(path: P) -> Result<Self, anyhow::Error> {
|
pub async fn load<P: AsRef<Path>>(path: P) -> Result<Self, anyhow::Error> {
|
||||||
let content = fs::read_to_string(path).await?;
|
let content = fs::read_to_string(&path).await?;
|
||||||
let config: Config = serde_yaml::from_str(&content)?;
|
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)
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
107
tests/config_test.rs
Normal file
107
tests/config_test.rs
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user