use std::collections::HashSet;
use crate::error::{ConfigError, ConfigWarning};
use crate::model::{ConfigFile, LeafMatcher, MatchExprConfig};
const VALID_PROTOCOLS: &[&str] = &[
"http",
"socks4",
"socks5",
"shadowsocks",
"trojan",
"h2",
"h3",
"quic",
"websocket",
"ws",
"wss",
"raw",
"echo",
];
const VALID_SCHEDULERS: &[&str] = &[
"first-available",
"round-robin",
"random",
"least-connections",
];
const VALID_FALLBACKS: &[&str] = &["reject", "direct", "use-unhealthy"];
const VALID_AUTH_TYPES: &[&str] = &["password"];
const VALID_REJECT_REASONS: &[&str] = &[
"unsupported-protocol",
"auth-required",
"access-denied",
"blocked",
"internal-error",
];
const VALID_HEALTH_MODES: &[&str] = &["tcp_connect"];
const VALID_HEALTH_INITIAL_STATES: &[&str] = &["unknown", "healthy", "unhealthy", "disabled"];
pub fn validate_config(config: &ConfigFile) -> Result<(), Vec<ConfigError>> {
let mut errors = Vec::new();
if let Some(version) = config.version {
if version != 1 {
errors.push(ConfigError::UnsupportedVersion(version));
}
}
if let Some(ref listeners) = config.listeners {
validate_listeners(listeners, &mut errors);
}
if let Some(ref upstreams) = config.upstreams {
validate_upstreams(upstreams, &mut errors);
}
if let Some(ref groups) = config.upstream_groups {
validate_upstream_groups(groups, config.upstreams.as_deref(), &mut errors);
}
if let Some(ref rules) = config.rules {
validate_rules(rules, config.upstream_groups.as_deref(), &mut errors);
}
if let Some(ref timeouts) = config.timeouts {
validate_timeouts(timeouts, &mut errors);
}
if let Some(ref process) = config.process {
validate_process(process, &mut errors);
}
if let Some(ref admin) = config.admin {
validate_admin(admin, &mut errors);
}
if let Some(ref routing) = config.routing {
if let Some(ref default) = routing.default {
if default != "direct" && default != "reject" {
let group_ids: Vec<&str> = config
.upstream_groups
.as_ref()
.map(|gs| gs.iter().map(|g| g.id.as_str()).collect())
.unwrap_or_default();
if !group_ids.contains(&default.as_str()) {
errors.push(ConfigError::validation(
"routing.default",
&format!("unknown upstream group or action: {}", default),
));
}
}
}
}
if let Some(ref upstreams) = config.upstreams {
if let Some(ref groups) = config.upstream_groups {
validate_upstream_transport(upstreams, groups, config, &mut errors);
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
pub fn validate_config_composition(config: &ConfigFile) -> Vec<ConfigWarning> {
let mut warnings = Vec::new();
let matrix = match load_composition_matrix() {
Some(m) => m,
None => {
warnings.push(ConfigWarning {
path: "composition_matrix".to_string(),
message: "composition matrix not found relative to the working directory; \
protocol composition warnings are suppressed"
.to_string(),
});
return warnings;
}
};
let listener_protocols: Vec<&str> = config
.listeners
.as_ref()
.map(|listeners| {
listeners
.iter()
.flat_map(|l| l.protocols.iter().map(|p| p.as_str()))
.collect()
})
.unwrap_or_default();
if let Some(ref upstreams) = config.upstreams {
let upstream_chains: std::collections::HashMap<&str, eggress_uri::ProxyChainSpec> =
upstreams
.iter()
.filter_map(|u| {
eggress_uri::parse_proxy_chain(&u.uri)
.ok()
.map(|chain| (u.id.as_str(), chain))
})
.collect();
for upstream in upstreams {
if let Some(chain) = upstream_chains.get(upstream.id.as_str()) {
let caps = eggress_core::capability::classify_upstream_chain(chain);
if caps.is_tcp_supported() {
for &proto in &listener_protocols {
if !matrix_cell_supported(&matrix, proto, "listener", "tcp") {
warnings.push(ConfigWarning {
path: format!("upstreams[{}].uri", upstream.id),
message: format!(
"listener protocol '{proto}' has no TCP composition cell; \
upstream '{}' may not work",
upstream.id
),
});
}
}
}
if caps.is_udp_supported() {
for &proto in &listener_protocols {
if !matrix_cell_supported(&matrix, proto, "listener", "udp") {
warnings.push(ConfigWarning {
path: format!("upstreams[{}].uri", upstream.id),
message: format!(
"listener protocol '{proto}' has no UDP composition cell; \
upstream '{}' may not work with UDP relay",
upstream.id
),
});
}
}
}
}
}
}
warnings
}
#[derive(serde::Deserialize)]
struct CompositionCellMinimal {
protocol: String,
role: String,
traffic_kind: String,
tier: String,
}
#[derive(serde::Deserialize)]
struct CompositionMatrixMinimal {
cell: Vec<CompositionCellMinimal>,
}
const EMBEDDED_COMPOSITION_MATRIX: &str = include_str!("../composition_matrix.toml");
fn load_composition_matrix() -> Option<CompositionMatrixMinimal> {
let candidates = [
"docs/parity/composition_matrix.toml",
"../docs/parity/composition_matrix.toml",
"../../docs/parity/composition_matrix.toml",
];
for path in &candidates {
if let Ok(content) = std::fs::read_to_string(path) {
if let Ok(matrix) = toml::from_str::<CompositionMatrixMinimal>(&content) {
return Some(matrix);
}
}
}
if let Ok(matrix) = toml::from_str::<CompositionMatrixMinimal>(EMBEDDED_COMPOSITION_MATRIX) {
return Some(matrix);
}
None
}
fn matrix_cell_supported(
matrix: &CompositionMatrixMinimal,
protocol: &str,
role: &str,
traffic_kind: &str,
) -> bool {
matrix.cell.iter().any(|c| {
c.protocol == protocol
&& c.role == role
&& c.traffic_kind == traffic_kind
&& c.tier != "unsupported"
})
}
fn validate_listeners(listeners: &[crate::model::ListenerConfig], errors: &mut Vec<ConfigError>) {
let mut names = HashSet::new();
for (i, listener) in listeners.iter().enumerate() {
let path = format!("listeners[{}]", i);
if !names.insert(&listener.name) {
errors.push(ConfigError::validation(
&path,
&format!("duplicate listener name: {}", listener.name),
));
}
if listener.protocols.is_empty() {
errors.push(ConfigError::validation(
&path,
"protocols must not be empty",
));
}
for protocol in &listener.protocols {
if !VALID_PROTOCOLS.contains(&protocol.as_str()) {
errors.push(ConfigError::validation(
&path,
&format!("unknown protocol: {}", protocol),
));
}
}
if let Some(ref auth) = listener.auth {
if !VALID_AUTH_TYPES.contains(&auth.auth_type.as_str()) {
errors.push(ConfigError::validation(
&path,
&format!("unknown auth type: {}", auth.auth_type),
));
}
if auth.username.as_deref().unwrap_or("").is_empty() {
errors.push(ConfigError::validation(
&path,
"auth requires a non-empty username",
));
}
if auth.password.is_none() && auth.password_env.is_none() {
errors.push(ConfigError::validation(
&path,
"auth requires at least one of password or password_env",
));
}
if auth.password.as_deref() == Some("") {
errors.push(ConfigError::validation(
&path,
"auth password must not be empty",
));
}
}
if listener.connection_limit == Some(0) {
errors.push(ConfigError::validation(
&path,
"connection_limit must be greater than 0",
));
}
if let Some(ref udp) = listener.udp {
validate_listener_udp(udp, &path, errors);
}
if listener.protocols.contains(&"trojan".to_string()) && listener.tls.is_none() {
errors.push(ConfigError::validation(
&path,
"trojan protocol requires TLS configuration ([listeners.tls])",
));
}
if listener.protocols.contains(&"trojan".to_string()) && listener.trojan.is_none() {
errors.push(ConfigError::validation(
&path,
"trojan protocol requires [listeners.trojan] section with password",
));
}
if let Some(ref trojan) = listener.trojan {
if trojan.password.is_empty() {
errors.push(ConfigError::validation(
&format!("{}.trojan.password", path),
"trojan password must not be empty",
));
}
if let Some(ref fallback) = trojan.fallback {
if fallback.parse::<eggress_core::TargetAddr>().is_err() {
errors.push(ConfigError::validation(
&format!("{}.trojan.fallback", path),
&format!(
"invalid fallback address format: '{fallback}' (expected host:port)"
),
));
}
}
}
}
}
fn validate_upstreams(upstreams: &[crate::model::UpstreamConfig], errors: &mut Vec<ConfigError>) {
let mut ids = HashSet::new();
for (i, upstream) in upstreams.iter().enumerate() {
let path = format!("upstreams[{}]", i);
if !ids.insert(&upstream.id) {
errors.push(ConfigError::validation(
&path,
&format!("duplicate upstream ID: {}", upstream.id),
));
}
if eggress_uri::parse_proxy_chain(&upstream.uri).is_err() {
errors.push(ConfigError::validation(&path, "invalid upstream URI"));
}
if let Some(ref health) = upstream.health {
validate_health_config(health, &path, errors);
}
if let Some(ref h2) = upstream.h2 {
validate_h2_config(h2, &path, errors);
}
}
}
fn validate_health_config(
health: &crate::model::HealthConfigToml,
parent_path: &str,
errors: &mut Vec<ConfigError>,
) {
if let Some(ref mode) = health.mode {
if !VALID_HEALTH_MODES.contains(&mode.as_str()) {
errors.push(ConfigError::validation(
&format!("{}.health.mode", parent_path),
&format!(
"unknown health mode '{}', must be one of: {}",
mode,
VALID_HEALTH_MODES.join(", ")
),
));
}
}
if let Some(ref interval) = health.interval {
if let Ok(d) = parse_duration(interval) {
if d.is_zero() {
errors.push(ConfigError::validation(
&format!("{}.health.interval", parent_path),
&format!("must be greater than 0, got: {}", interval),
));
}
} else {
errors.push(ConfigError::validation(
&format!("{}.health.interval", parent_path),
&format!("invalid duration: {}", interval),
));
}
}
if let Some(ref timeout) = health.timeout {
if let Ok(d) = parse_duration(timeout) {
if d.is_zero() {
errors.push(ConfigError::validation(
&format!("{}.health.timeout", parent_path),
&format!("must be greater than 0, got: {}", timeout),
));
}
} else {
errors.push(ConfigError::validation(
&format!("{}.health.timeout", parent_path),
&format!("invalid duration: {}", timeout),
));
}
}
if let Some(failures) = health.failures_to_unhealthy {
if failures == 0 {
errors.push(ConfigError::validation(
&format!("{}.health.failures_to_unhealthy", parent_path),
"must be greater than 0",
));
}
}
if let Some(successes) = health.successes_to_healthy {
if successes == 0 {
errors.push(ConfigError::validation(
&format!("{}.health.successes_to_healthy", parent_path),
"must be greater than 0",
));
}
}
if let Some(ref initial_state) = health.initial_state {
if !VALID_HEALTH_INITIAL_STATES.contains(&initial_state.as_str()) {
errors.push(ConfigError::validation(
&format!("{}.health.initial_state", parent_path),
&format!(
"unknown state '{}', must be one of: {}",
initial_state,
VALID_HEALTH_INITIAL_STATES.join(", ")
),
));
}
}
}
fn validate_h2_config(
h2: &crate::model::H2UpstreamConfig,
parent_path: &str,
errors: &mut Vec<ConfigError>,
) {
if let Some(max) = h2.max_concurrent_streams {
if max == 0 {
errors.push(ConfigError::validation(
&format!("{}.h2.max_concurrent_streams", parent_path),
"must be greater than 0",
));
}
}
if let Some(pool) = h2.pool_size {
if pool == 0 {
errors.push(ConfigError::validation(
&format!("{}.h2.pool_size", parent_path),
"must be greater than 0",
));
}
}
if let Some(ref idle) = h2.idle_timeout {
if parse_duration(idle).is_err() {
errors.push(ConfigError::validation(
&format!("{}.h2.idle_timeout", parent_path),
&format!("invalid duration: {}", idle),
));
}
}
if let Some(ref interval) = h2.keepalive_interval {
if parse_duration(interval).is_err() {
errors.push(ConfigError::validation(
&format!("{}.h2.keepalive_interval", parent_path),
&format!("invalid duration: {}", interval),
));
}
}
if let Some(ref timeout) = h2.keepalive_timeout {
if parse_duration(timeout).is_err() {
errors.push(ConfigError::validation(
&format!("{}.h2.keepalive_timeout", parent_path),
&format!("invalid duration: {}", timeout),
));
}
}
if let Some(window) = h2.stream_receive_window {
if window == 0 {
errors.push(ConfigError::validation(
&format!("{}.h2.stream_receive_window", parent_path),
"must be greater than 0",
));
}
}
if let Some(window) = h2.connection_receive_window {
if window == 0 {
errors.push(ConfigError::validation(
&format!("{}.h2.connection_receive_window", parent_path),
"must be greater than 0",
));
}
}
if let Some(size) = h2.max_frame_size {
if size == 0 {
errors.push(ConfigError::validation(
&format!("{}.h2.max_frame_size", parent_path),
"must be greater than 0",
));
}
}
if let Some(size) = h2.max_header_list_size {
if size == 0 {
errors.push(ConfigError::validation(
&format!("{}.h2.max_header_list_size", parent_path),
"must be greater than 0",
));
}
}
}
fn validate_upstream_groups(
groups: &[crate::model::UpstreamGroupConfig],
upstreams: Option<&[crate::model::UpstreamConfig]>,
errors: &mut Vec<ConfigError>,
) {
let mut ids = HashSet::new();
let upstream_ids: HashSet<&str> = upstreams
.map(|u| u.iter().map(|u| u.id.as_str()).collect())
.unwrap_or_default();
for (i, group) in groups.iter().enumerate() {
let path = format!("upstream_groups[{}]", i);
if !ids.insert(&group.id) {
errors.push(ConfigError::validation(
&path,
&format!("duplicate group ID: {}", group.id),
));
}
if let Some(ref scheduler) = group.scheduler {
if !VALID_SCHEDULERS.contains(&scheduler.as_str()) {
errors.push(ConfigError::validation(
&path,
&format!("unknown scheduler: {}", scheduler),
));
}
}
if let Some(ref fallback) = group.fallback {
if !VALID_FALLBACKS.contains(&fallback.as_str()) {
errors.push(ConfigError::validation(
&path,
&format!("unknown fallback: {}", fallback),
));
}
}
if group.members.is_empty() {
errors.push(ConfigError::validation(
&path,
"upstream group must have at least one member",
));
}
let mut seen_members = HashSet::new();
for (j, member) in group.members.iter().enumerate() {
if !seen_members.insert(member.as_str()) {
errors.push(ConfigError::validation(
&path,
&format!("duplicate member '{}' at index {}", member, j),
));
}
if !upstream_ids.contains(member.as_str()) {
errors.push(ConfigError::validation(
&path,
&format!("member {} references unknown upstream: {}", j, member),
));
}
}
}
}
fn validate_upstream_transport(
upstreams: &[crate::model::UpstreamConfig],
groups: &[crate::model::UpstreamGroupConfig],
config: &ConfigFile,
errors: &mut Vec<ConfigError>,
) {
let upstream_chains: std::collections::HashMap<&str, eggress_uri::ProxyChainSpec> = upstreams
.iter()
.filter_map(|u| {
eggress_uri::parse_proxy_chain(&u.uri)
.ok()
.map(|chain| (u.id.as_str(), chain))
})
.collect();
let mut group_udp_support: std::collections::HashMap<&str, bool> =
std::collections::HashMap::new();
for group in groups {
let has_udp_upstream = group.members.iter().any(|member_id| {
upstream_chains
.get(member_id.as_str())
.map(|chain| {
eggress_core::capability::classify_upstream_chain(chain).is_udp_supported()
})
.unwrap_or(false)
});
group_udp_support.insert(group.id.as_str(), has_udp_upstream);
}
let udp_listener_exists = config
.listeners
.as_ref()
.map(|listeners| {
listeners.iter().any(|l| {
l.udp_enabled == Some(true)
|| l.udp.as_ref().is_some_and(|u| u.enabled != Some(false))
})
})
.unwrap_or(false);
if let Some(ref rules) = config.rules {
for rule in rules {
if let Some(ref upstream_group) = rule.upstream_group {
let group_id = upstream_group.as_str();
let group_supports_udp = group_udp_support.get(group_id).copied().unwrap_or(false);
let rule_could_match_udp = rule_upstream_group_could_match_udp(rule);
if !group_supports_udp && rule_could_match_udp && udp_listener_exists {
errors.push(ConfigError::validation(
&format!("rules[{}].upstream_group", rule.id),
&format!(
"upstream group '{}' contains no UDP-capable upstreams but is referenced by a rule that could match UDP traffic",
upstream_group
),
));
}
}
}
}
if let Some(ref routing) = config.routing {
if let Some(ref default) = routing.default {
if default != "direct" && default != "reject" {
let group_supports_udp = group_udp_support
.get(default.as_str())
.copied()
.unwrap_or(false);
if !group_supports_udp && udp_listener_exists {
errors.push(ConfigError::validation(
"routing.default",
&format!(
"upstream group '{}' contains no UDP-capable upstreams but is the default route while UDP listeners exist",
default
),
));
}
}
}
}
}
fn rule_upstream_group_could_match_udp(rule: &crate::model::RuleConfig) -> bool {
if let Some(ref match_expr) = rule.match_expr {
return matcher_could_match_udp(match_expr);
}
if rule.host_exact.is_some()
|| rule.host_suffix.is_some()
|| rule.host_regex.is_some()
|| rule.destination_port.is_some()
{
return true;
}
if rule.any.unwrap_or(false) {
return true;
}
true
}
const MAX_MATCH_EXPR_DEPTH: usize = 10;
fn matcher_could_match_udp(matcher: &MatchExprConfig) -> bool {
matcher_could_match_udp_limited(matcher, 0)
}
fn matcher_could_match_udp_limited(matcher: &MatchExprConfig, depth: usize) -> bool {
if depth >= MAX_MATCH_EXPR_DEPTH {
return true;
}
match matcher {
MatchExprConfig::Leaf(leaf) => leaf_could_match_udp(leaf),
MatchExprConfig::Composite(composite) => {
if let Some(ref all) = composite.all {
return all
.iter()
.all(|child| matcher_could_match_udp_limited(child, depth + 1));
}
if let Some(ref any_of) = composite.any_of {
return any_of
.iter()
.any(|child| matcher_could_match_udp_limited(child, depth + 1));
}
if let Some(ref not) = composite.not {
return matcher_could_match_udp_limited(not, depth + 1);
}
true
}
}
}
fn leaf_could_match_udp(leaf: &LeafMatcher) -> bool {
if let Some(ref transport) = leaf.transport {
return transport == "udp";
}
true
}
fn validate_rules(
rules: &[crate::model::RuleConfig],
groups: Option<&[crate::model::UpstreamGroupConfig]>,
errors: &mut Vec<ConfigError>,
) {
let group_ids: HashSet<&str> = groups
.map(|g| g.iter().map(|g| g.id.as_str()).collect())
.unwrap_or_default();
for (i, rule) in rules.iter().enumerate() {
let path = format!("rules[{}]", i);
let matcher_count = [
rule.host_exact.is_some(),
rule.host_suffix.is_some(),
rule.host_regex.is_some(),
rule.destination_port.is_some(),
rule.destination_port_regex.is_some(),
rule.any.unwrap_or(false),
]
.iter()
.filter(|&&b| b)
.count();
if rule.match_expr.is_none() {
if matcher_count > 1 {
errors.push(ConfigError::validation(
&path,
"rule must have exactly one matcher field",
));
}
if let Some(ref host_regex) = rule.host_regex {
if regex::Regex::new(host_regex).is_err() {
errors.push(ConfigError::validation(
&path,
&format!("invalid host regex: {}", host_regex),
));
}
}
} else if let Some(ref match_expr) = rule.match_expr {
if matcher_count > 0 {
errors.push(ConfigError::validation(
&path,
"rule must not combine match with legacy matcher fields",
));
}
validate_match_expr(match_expr, &path, errors, 0);
}
let action_count = [
rule.direct.is_some(),
rule.upstream_group.is_some(),
rule.reject.is_some(),
]
.iter()
.filter(|&&b| b)
.count();
if action_count != 1 {
errors.push(ConfigError::validation(
&path,
"rule must have exactly one action field",
));
}
if let Some(ref upstream_group) = rule.upstream_group {
if !group_ids.contains(upstream_group.as_str()) {
errors.push(ConfigError::validation(
&path,
&format!(
"action references unknown upstream group: {}",
upstream_group
),
));
}
}
if let Some(ref reject) = rule.reject {
if !VALID_REJECT_REASONS.contains(&reject.as_str()) {
errors.push(ConfigError::validation(
&path,
&format!("unknown reject reason: {}", reject),
));
}
}
}
}
fn validate_match_expr(
expr: &crate::model::MatchExprConfig,
path: &str,
errors: &mut Vec<ConfigError>,
depth: usize,
) {
if depth >= MAX_MATCH_EXPR_DEPTH {
errors.push(ConfigError::validation(
path,
&format!(
"expression exceeds maximum depth ({})",
MAX_MATCH_EXPR_DEPTH
),
));
return;
}
match expr {
crate::model::MatchExprConfig::Composite(composite) => {
let has_all = composite.all.is_some();
let has_any = composite.any_of.is_some();
let has_not = composite.not.is_some();
if !has_all && !has_any && !has_not {
errors.push(ConfigError::validation(
&format!("{}.match", path),
"composite must have exactly one of: all, any_of, not",
));
}
if let Some(ref all) = composite.all {
if all.is_empty() {
errors.push(ConfigError::validation(
&format!("{}.match.all", path),
"must not be empty",
));
}
for (j, item) in all.iter().enumerate() {
validate_match_expr(
item,
&format!("{}.match.all[{}]", path, j),
errors,
depth + 1,
);
}
}
if let Some(ref any_of) = composite.any_of {
if any_of.is_empty() {
errors.push(ConfigError::validation(
&format!("{}.match.any_of", path),
"must not be empty",
));
}
for (j, item) in any_of.iter().enumerate() {
validate_match_expr(
item,
&format!("{}.match.any_of[{}]", path, j),
errors,
depth + 1,
);
}
}
if let Some(ref not) = composite.not {
validate_match_expr(not, &format!("{}.match.not", path), errors, depth + 1);
}
}
crate::model::MatchExprConfig::Leaf(leaf) => {
if let Some(ref regex_str) = leaf.host_regex {
if regex::Regex::new(regex_str).is_err() {
errors.push(ConfigError::validation(
&format!("{}.host_regex", path),
&format!("invalid regex: {}", regex_str),
));
}
}
if let Some(ref cidr) = leaf.destination_cidr {
if cidr.parse::<ipnet::IpNet>().is_err() {
errors.push(ConfigError::validation(
&format!("{}.destination_cidr", path),
&format!("invalid CIDR: {}", cidr),
));
}
}
if let Some(ref cidr) = leaf.source_cidr {
if cidr.parse::<ipnet::IpNet>().is_err() {
errors.push(ConfigError::validation(
&format!("{}.source_cidr", path),
&format!("invalid CIDR: {}", cidr),
));
}
}
if let Some(ref range) = leaf.destination_port_range {
if range.len() != 2 {
errors.push(ConfigError::validation(
&format!("{}.destination_port_range", path),
"must have exactly 2 elements [start, end]",
));
} else if range[0] > range[1] {
errors.push(ConfigError::validation(
&format!("{}.destination_port_range", path),
&format!("start ({}) must be <= end ({})", range[0], range[1]),
));
}
}
if let Some(ref ports) = leaf.destination_port_set {
if ports.is_empty() {
errors.push(ConfigError::validation(
&format!("{}.destination_port_set", path),
"must not be empty",
));
}
}
if let Some(ref proto) = leaf.protocol {
if !VALID_PROTOCOLS.contains(&proto.as_str()) && proto != "httponly" {
errors.push(ConfigError::validation(
&format!("{}.protocol", path),
&format!("unknown protocol: {}", proto),
));
}
}
}
}
}
fn parse_duration(s: &str) -> Result<std::time::Duration, String> {
let s = s.trim();
if s.is_empty() {
return Err("empty duration".to_string());
}
let (num_part, unit) = if let Some(pos) = s.find(|c: char| c.is_alphabetic()) {
(&s[..pos], &s[pos..])
} else {
return Err(format!("missing unit in duration: {}", s));
};
let value: u64 = num_part
.parse()
.map_err(|_| format!("invalid duration value: {}", num_part))?;
match unit {
"ns" => Ok(std::time::Duration::from_nanos(value)),
"us" | "μs" => Ok(std::time::Duration::from_micros(value)),
"ms" => Ok(std::time::Duration::from_millis(value)),
"s" => Ok(std::time::Duration::from_secs(value)),
"m" => value
.checked_mul(60)
.map(std::time::Duration::from_secs)
.ok_or_else(|| format!("duration overflow: {}m", value)),
"h" => value
.checked_mul(3600)
.map(std::time::Duration::from_secs)
.ok_or_else(|| format!("duration overflow: {}h", value)),
"d" => value
.checked_mul(86400)
.map(std::time::Duration::from_secs)
.ok_or_else(|| format!("duration overflow: {}d", value)),
_ => Err(format!("unknown duration unit: {}", unit)),
}
}
pub fn validate_duration(s: &str) -> Result<std::time::Duration, ConfigError> {
parse_duration(s).map_err(|msg| ConfigError::validation("duration", &msg))
}
fn validate_timeouts(timeouts: &crate::model::TimeoutConfig, errors: &mut Vec<ConfigError>) {
if let Some(ref handshake) = timeouts.handshake {
if let Ok(d) = parse_duration(handshake) {
if d.is_zero() {
errors.push(ConfigError::validation(
"timeouts.handshake",
&format!("must be greater than 0, got: {}", handshake),
));
}
} else {
errors.push(ConfigError::validation(
"timeouts.handshake",
&format!("invalid duration: {}", handshake),
));
}
}
if let Some(ref connect) = timeouts.connect {
if let Ok(d) = parse_duration(connect) {
if d.is_zero() {
errors.push(ConfigError::validation(
"timeouts.connect",
&format!("must be greater than 0, got: {}", connect),
));
}
} else {
errors.push(ConfigError::validation(
"timeouts.connect",
&format!("invalid duration: {}", connect),
));
}
}
}
fn validate_process(process: &crate::model::ProcessConfig, errors: &mut Vec<ConfigError>) {
if let Some(ref log_level) = process.log_level {
let valid_levels = ["trace", "debug", "info", "warn", "error"];
if !valid_levels.contains(&log_level.as_str()) {
errors.push(ConfigError::validation(
"process.log_level",
&format!("unknown log level: {}", log_level),
));
}
}
if let Some(ref shutdown_grace) = process.shutdown_grace {
if parse_duration(shutdown_grace).is_err() {
errors.push(ConfigError::validation(
"process.shutdown_grace",
&format!("invalid duration: {}", shutdown_grace),
));
}
}
}
fn validate_admin(admin: &crate::model::AdminConfig, errors: &mut Vec<ConfigError>) {
if let Some(ref bind) = admin.bind {
if bind.parse::<std::net::SocketAddr>().is_err()
&& bind.parse::<std::net::SocketAddrV4>().is_err()
&& bind.parse::<std::net::SocketAddrV6>().is_err()
{
errors.push(ConfigError::validation(
"admin.bind",
&format!("invalid bind address: {}", bind),
));
}
}
if admin.enabled.unwrap_or(true)
&& admin
.bind
.as_deref()
.is_some_and(|bind| !is_loopback_bind(bind))
&& admin.auth.is_none()
{
errors.push(ConfigError::validation(
"admin.auth",
"non-loopback admin binds require authentication",
));
}
if let Some(auth) = &admin.auth {
if auth.bearer_token.as_deref().is_some_and(str::is_empty) {
errors.push(ConfigError::validation(
"admin.auth.bearer_token",
"bearer token must not be empty",
));
}
if auth.bearer_token.is_some() && auth.bearer_token_env.is_some() {
errors.push(ConfigError::validation(
"admin.auth",
"configure either bearer_token or bearer_token_env, not both",
));
}
if let Some(basic) = &auth.basic_auth {
if basic.user.is_empty() {
errors.push(ConfigError::validation(
"admin.auth.basic_auth.user",
"basic auth username must not be empty",
));
}
if basic.password.as_deref().is_some_and(str::is_empty) {
errors.push(ConfigError::validation(
"admin.auth.basic_auth.password",
"basic auth password must not be empty",
));
}
if basic.password.is_some() && basic.password_env.is_some() {
errors.push(ConfigError::validation(
"admin.auth.basic_auth",
"configure either password or password_env, not both",
));
}
}
if auth.bearer_token.is_some() && auth.basic_auth.is_some() {
errors.push(ConfigError::validation(
"admin.auth",
"configure either bearer_token or basic_auth, not both",
));
}
}
if let Some(ref pac) = admin.pac {
if let Some(ref path) = pac.path {
if !path.starts_with('/') {
errors.push(ConfigError::validation(
"admin.pac.path",
&format!("PAC path must start with '/': {}", path),
));
}
}
}
if let Some(ref static_content) = admin.static_content {
let reserved_paths = [
"/-/health",
"/-/ready",
"/-/status",
"/-/routes",
"/-/upstreams",
"/-/config",
"/-/route-explain",
"/metrics",
"/pac",
];
let mut seen_paths = HashSet::new();
for (i, entry) in static_content.iter().enumerate() {
let path = format!("admin.static_content[{}]", i);
if !entry.path.starts_with('/') {
errors.push(ConfigError::validation(
&path,
&format!("static path must start with '/': {}", entry.path),
));
}
if !seen_paths.insert(&entry.path) {
errors.push(ConfigError::validation(
&path,
&format!("duplicate static path: {}", entry.path),
));
}
if reserved_paths.contains(&entry.path.as_str()) {
errors.push(ConfigError::validation(
&path,
&format!(
"static path collides with reserved admin endpoint: {}",
entry.path
),
));
}
if let Some(ref body) = entry.body {
if body.is_empty() {
errors.push(ConfigError::validation(
&path,
"static body must be non-empty if provided",
));
}
}
}
}
}
fn validate_listener_udp(
udp: &crate::model::ListenerUdpConfig,
parent_path: &str,
errors: &mut Vec<ConfigError>,
) {
let udp_path = format!("{}.udp", parent_path);
if let Some(ref bind) = udp.bind {
if bind.parse::<std::net::SocketAddr>().is_err() {
errors.push(ConfigError::validation(
&format!("{}.bind", udp_path),
&format!("invalid socket address: {}", bind),
));
}
}
if let Some(ref advertise) = udp.advertise {
if advertise.parse::<std::net::IpAddr>().is_err() {
errors.push(ConfigError::validation(
&format!("{}.advertise", udp_path),
&format!("invalid IP address: {}", advertise),
));
}
}
if let Some(ref idle_timeout) = udp.idle_timeout {
if parse_duration(idle_timeout).is_err() {
errors.push(ConfigError::validation(
&format!("{}.idle_timeout", udp_path),
&format!("invalid duration: {}", idle_timeout),
));
}
}
if let Some(ref target_idle_timeout) = udp.target_idle_timeout {
if parse_duration(target_idle_timeout).is_err() {
errors.push(ConfigError::validation(
&format!("{}.target_idle_timeout", udp_path),
&format!("invalid duration: {}", target_idle_timeout),
));
}
}
if let Some(max_associations) = udp.max_associations {
if max_associations == 0 {
errors.push(ConfigError::validation(
&format!("{}.max_associations", udp_path),
"must be greater than 0",
));
}
}
if let Some(max_targets) = udp.max_targets_per_association {
if max_targets == 0 {
errors.push(ConfigError::validation(
&format!("{}.max_targets_per_association", udp_path),
"must be greater than 0",
));
}
}
if let Some(max_datagram_size) = udp.max_datagram_size {
if !(257..=65535).contains(&max_datagram_size) {
errors.push(ConfigError::validation(
&format!("{}.max_datagram_size", udp_path),
&format!("must be between 257 and 65535, got {}", max_datagram_size),
));
}
}
}
fn is_loopback_bind(addr: &str) -> bool {
if let Ok(socket) = addr.parse::<std::net::SocketAddr>() {
return match socket.ip() {
std::net::IpAddr::V4(v4) => v4.is_loopback(),
std::net::IpAddr::V6(v6) => {
v6.is_loopback() || v6.to_ipv4_mapped().is_some_and(|v4| v4.is_loopback())
}
};
}
if let Ok(ip) = addr.parse::<std::net::IpAddr>() {
return match ip {
std::net::IpAddr::V4(v4) => v4.is_loopback(),
std::net::IpAddr::V6(v6) => {
v6.is_loopback() || v6.to_ipv4_mapped().is_some_and(|v4| v4.is_loopback())
}
};
}
let host_part = if addr.starts_with('[') {
if let Some(end) = addr.find(']') {
let inside = &addr[1..end];
let after = &addr[end + 1..];
if after.is_empty() || (after.starts_with(':') && after[1..].parse::<u16>().is_ok()) {
inside
} else {
addr
}
} else {
addr
}
} else if let Some(colon) = addr.rfind(':') {
let host = &addr[..colon];
let port_part = &addr[colon + 1..];
if port_part.parse::<u16>().is_ok() && !host.is_empty() {
host
} else {
addr
}
} else {
addr
};
if host_part.eq_ignore_ascii_case("localhost") {
return true;
}
if let Ok(ip) = host_part.parse::<std::net::IpAddr>() {
return match ip {
std::net::IpAddr::V4(v4) => v4.is_loopback(),
std::net::IpAddr::V6(v6) => {
v6.is_loopback() || v6.to_ipv4_mapped().is_some_and(|v4| v4.is_loopback())
}
};
}
false
}
pub fn validate_config_security(config: &ConfigFile) -> Vec<ConfigWarning> {
let mut warnings = Vec::new();
if let Some(ref listeners) = config.listeners {
for (i, listener) in listeners.iter().enumerate() {
let path = format!("listeners[{}].bind", i);
if !is_loopback_bind(&listener.bind) {
let has_auth = listener.auth.is_some();
let has_shadowsocks = listener.shadowsocks.is_some();
let has_ssr = listener.ssr.is_some();
let has_trojan = listener.trojan.is_some();
if !has_auth && !has_shadowsocks && !has_ssr && !has_trojan {
warnings.push(ConfigWarning {
path,
message: format!(
"listener '{}' binds to {} without authentication — \
this may expose the proxy to untrusted networks",
listener.name, listener.bind,
),
});
}
}
}
}
if let Some(ref admin) = config.admin {
if let Some(ref bind) = admin.bind {
if !is_loopback_bind(bind) && admin.auth.is_none() {
warnings.push(ConfigWarning {
path: "admin.bind".to_string(),
message: format!(
"admin server binds to {} without authentication — \
this may expose admin endpoints to untrusted networks",
bind,
),
});
}
}
}
if let Some(ref servers) = config.reverse_servers {
for (i, server) in servers.iter().enumerate() {
let path = format!("reverse_servers[{}].control_bind", i);
if !is_loopback_bind(&server.control_bind) {
let has_auth = server.auth_username.is_some()
&& (server.auth_password.is_some() || server.auth_password_env.is_some());
if !has_auth {
warnings.push(ConfigWarning {
path,
message: format!(
"reverse server '{}' control channel binds to {} without authentication — \
any client can connect and request proxying",
server.id, server.control_bind,
),
});
}
}
}
}
warn_protocol_aliases(config, &mut warnings);
warnings
}
fn warn_protocol_aliases(config: &ConfigFile, warnings: &mut Vec<ConfigWarning>) {
if let Some(ref rules) = config.rules {
for (i, rule) in rules.iter().enumerate() {
let base = format!("rules[{i}]");
if let Some(ref expr) = rule.match_expr {
walk_match_expr_for_alias(expr, &format!("{base}.match"), warnings, 0);
}
}
}
}
fn walk_match_expr_for_alias(
expr: &MatchExprConfig,
path: &str,
warnings: &mut Vec<ConfigWarning>,
depth: usize,
) {
if depth >= MAX_MATCH_EXPR_DEPTH {
return;
}
match expr {
MatchExprConfig::Composite(composite) => {
if let Some(ref all) = composite.all {
for (i, child) in all.iter().enumerate() {
walk_match_expr_for_alias(
child,
&format!("{path}.all[{i}]"),
warnings,
depth + 1,
);
}
}
if let Some(ref any) = composite.any_of {
for (i, child) in any.iter().enumerate() {
walk_match_expr_for_alias(
child,
&format!("{path}.any_of[{i}]"),
warnings,
depth + 1,
);
}
}
if let Some(ref not) = composite.not {
walk_match_expr_for_alias(not, &format!("{path}.not"), warnings, depth + 1);
}
}
MatchExprConfig::Leaf(leaf) => {
if leaf.protocol.as_deref() == Some("httponly") {
warnings.push(ConfigWarning {
path: format!("{path}.protocol"),
message: "'httponly' is a pproxy compatibility alias for 'http' \
and does not select distinct protocol semantics"
.to_string(),
});
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vendored_matrix_matches_canonical() {
let canonical_path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../docs/parity/composition_matrix.toml"
);
let Ok(canonical) = std::fs::read_to_string(canonical_path) else {
eprintln!("skipping canonical-matrix sync check (no workspace docs/)");
return;
};
assert_eq!(
EMBEDDED_COMPOSITION_MATRIX, canonical,
"crates/eggress-config/composition_matrix.toml is stale; copy docs/parity/composition_matrix.toml over it"
);
toml::from_str::<CompositionMatrixMinimal>(EMBEDDED_COMPOSITION_MATRIX)
.expect("vendored composition matrix must parse");
}
#[test]
fn zero_durations_rejected_for_timeouts() {
let timeouts = crate::model::TimeoutConfig {
handshake: Some("0s".to_string()),
connect: Some("0ms".to_string()),
};
let mut errors = Vec::new();
validate_timeouts(&timeouts, &mut errors);
assert_eq!(errors.len(), 2, "zero handshake and connect must both fail");
for error in &errors {
let ConfigError::Validation { message, .. } = error else {
panic!("expected validation error, got {error:?}");
};
assert!(message.contains("greater than 0"), "unexpected: {message}");
}
}
#[test]
fn nonzero_and_missing_timeouts_accepted() {
let timeouts = crate::model::TimeoutConfig {
handshake: Some("5s".to_string()),
connect: None,
};
let mut errors = Vec::new();
validate_timeouts(&timeouts, &mut errors);
assert!(errors.is_empty());
}
#[test]
fn zero_health_durations_rejected() {
let health = crate::model::HealthConfigToml {
mode: None,
interval: Some("0s".to_string()),
timeout: Some("0s".to_string()),
failures_to_unhealthy: None,
successes_to_healthy: None,
initial_state: None,
};
let mut errors = Vec::new();
validate_health_config(&health, "upstreams[0]", &mut errors);
assert_eq!(errors.len(), 2, "zero interval and timeout must both fail");
for error in &errors {
let ConfigError::Validation { message, .. } = error else {
panic!("expected validation error, got {error:?}");
};
assert!(message.contains("greater than 0"), "unexpected: {message}");
}
}
#[test]
fn loopback_detection() {
assert!(is_loopback_bind("127.0.0.1:8080"));
assert!(is_loopback_bind("127.0.0.1:0"));
assert!(is_loopback_bind("[::1]:8080"));
assert!(is_loopback_bind("[::ffff:127.0.0.1]:8080"));
assert!(!is_loopback_bind("0.0.0.0:8080"));
assert!(!is_loopback_bind("[::]:8080"));
assert!(!is_loopback_bind("10.0.0.1:8080"));
assert!(!is_loopback_bind("192.168.1.1:8080"));
assert!(!is_loopback_bind("not-an-addr"));
}
#[test]
fn warn_non_loopback_listener_without_auth() {
let config = ConfigFile {
version: Some(1),
listeners: Some(vec![crate::model::ListenerConfig {
name: "public".to_string(),
bind: "0.0.0.0:8080".to_string(),
protocols: vec!["http".to_string()],
reuse_port: None,
connection_limit: None,
auth: None,
udp_enabled: None,
udp: None,
tls: None,
shadowsocks: None,
ssr: None,
trojan: None,
transparent: None,
unix: None,
fixed_target: None,
local_bind: None,
}]),
upstreams: None,
upstream_groups: None,
rules: None,
rules_file: None,
routing: None,
admin: None,
process: None,
timeouts: None,
reverse_servers: None,
reverse_clients: None,
};
let warnings = validate_config_security(&config);
assert!(!warnings.is_empty());
assert!(warnings[0].message.contains("0.0.0.0:8080"));
}
#[test]
fn no_warn_loopback_listener() {
let config = ConfigFile {
version: Some(1),
listeners: Some(vec![crate::model::ListenerConfig {
name: "local".to_string(),
bind: "127.0.0.1:8080".to_string(),
protocols: vec!["http".to_string()],
reuse_port: None,
connection_limit: None,
auth: None,
udp_enabled: None,
udp: None,
tls: None,
shadowsocks: None,
ssr: None,
trojan: None,
transparent: None,
unix: None,
fixed_target: None,
local_bind: None,
}]),
upstreams: None,
upstream_groups: None,
rules: None,
rules_file: None,
routing: None,
admin: None,
process: None,
timeouts: None,
reverse_servers: None,
reverse_clients: None,
};
let warnings = validate_config_security(&config);
assert!(warnings.is_empty());
}
#[test]
fn no_warn_authed_listener() {
let config = ConfigFile {
version: Some(1),
listeners: Some(vec![crate::model::ListenerConfig {
name: "public-ss".to_string(),
bind: "0.0.0.0:8388".to_string(),
protocols: vec!["shadowsocks".to_string()],
reuse_port: None,
connection_limit: None,
auth: None,
udp_enabled: None,
udp: None,
tls: None,
shadowsocks: Some(crate::model::ShadowsocksListenerConfig {
method: "aes-256-gcm".to_string(),
password: "secret".to_string(),
auth_prefix: None,
plugins: Vec::new(),
}),
ssr: None,
trojan: None,
transparent: None,
unix: None,
fixed_target: None,
local_bind: None,
}]),
upstreams: None,
upstream_groups: None,
rules: None,
rules_file: None,
routing: None,
admin: None,
process: None,
timeouts: None,
reverse_servers: None,
reverse_clients: None,
};
let warnings = validate_config_security(&config);
assert!(warnings.is_empty());
}
#[test]
fn warn_non_loopback_admin() {
let config = ConfigFile {
version: Some(1),
listeners: None,
upstreams: None,
upstream_groups: None,
rules: None,
rules_file: None,
routing: None,
admin: Some(crate::model::AdminConfig {
bind: Some("0.0.0.0:9090".to_string()),
enabled: None,
metrics: None,
auth: None,
pac: None,
static_content: None,
}),
process: None,
timeouts: None,
reverse_servers: None,
reverse_clients: None,
};
let warnings = validate_config_security(&config);
assert!(!warnings.is_empty());
assert!(warnings.iter().any(|w| w.path == "admin.bind"));
}
#[test]
fn no_warn_loopback_admin() {
let config = ConfigFile {
version: Some(1),
listeners: None,
upstreams: None,
upstream_groups: None,
rules: None,
rules_file: None,
routing: None,
admin: Some(crate::model::AdminConfig {
bind: Some("127.0.0.1:9090".to_string()),
enabled: None,
metrics: None,
auth: None,
pac: None,
static_content: None,
}),
process: None,
timeouts: None,
reverse_servers: None,
reverse_clients: None,
};
let warnings = validate_config_security(&config);
assert!(warnings.is_empty());
}
#[test]
fn warn_reverse_control_bind_without_auth() {
let config = ConfigFile {
version: Some(1),
listeners: None,
upstreams: None,
upstream_groups: None,
rules: None,
rules_file: None,
routing: None,
admin: None,
process: None,
timeouts: None,
reverse_servers: Some(vec![crate::model::ReverseServerConfig {
id: "rs1".to_string(),
control_bind: "0.0.0.0:8443".to_string(),
external_bind: "0.0.0.0:9000".to_string(),
auth_username: None,
auth_password: None,
auth_password_env: None,
max_streams: None,
heartbeat_interval: None,
pproxy_compat: false,
}]),
reverse_clients: None,
};
let warnings = validate_config_security(&config);
assert!(!warnings.is_empty());
assert!(warnings.iter().any(|w| w.path.contains("control_bind")));
}
#[test]
fn no_warn_reverse_control_bind_with_auth() {
let config = ConfigFile {
version: Some(1),
listeners: None,
upstreams: None,
upstream_groups: None,
rules: None,
rules_file: None,
routing: None,
admin: None,
process: None,
timeouts: None,
reverse_servers: Some(vec![crate::model::ReverseServerConfig {
id: "rs1".to_string(),
control_bind: "0.0.0.0:8443".to_string(),
external_bind: "0.0.0.0:9000".to_string(),
auth_username: Some("user".to_string()),
auth_password: Some("pass".to_string()),
auth_password_env: None,
max_streams: None,
heartbeat_interval: None,
pproxy_compat: false,
}]),
reverse_clients: None,
};
let warnings = validate_config_security(&config);
assert!(warnings.is_empty());
}
#[test]
fn no_warn_reverse_control_bind_with_env_auth() {
let config = ConfigFile {
version: Some(1),
listeners: None,
upstreams: None,
upstream_groups: None,
rules: None,
rules_file: None,
routing: None,
admin: None,
process: None,
timeouts: None,
reverse_servers: Some(vec![crate::model::ReverseServerConfig {
id: "rs1".to_string(),
control_bind: "0.0.0.0:8443".to_string(),
external_bind: "0.0.0.0:9000".to_string(),
auth_username: Some("user".to_string()),
auth_password: None,
auth_password_env: Some("MY_SECRET".to_string()),
max_streams: None,
heartbeat_interval: None,
pproxy_compat: false,
}]),
reverse_clients: None,
};
let warnings = validate_config_security(&config);
assert!(warnings.is_empty());
}
#[test]
fn warn_trojan_listener_without_auth() {
let config = ConfigFile {
version: Some(1),
listeners: Some(vec![crate::model::ListenerConfig {
name: "public-trojan".to_string(),
bind: "0.0.0.0:443".to_string(),
protocols: vec!["trojan".to_string()],
reuse_port: None,
connection_limit: None,
auth: None,
udp_enabled: None,
udp: None,
tls: Some(crate::model::ListenerTlsConfig {
cert: "/path/cert.pem".to_string(),
key: "/path/key.pem".to_string(),
alpn: None,
}),
shadowsocks: None,
ssr: None,
trojan: Some(crate::model::ListenerTrojanConfig {
password: "secret".to_string(),
fallback: None,
}),
transparent: None,
unix: None,
fixed_target: None,
local_bind: None,
}]),
upstreams: None,
upstream_groups: None,
rules: None,
rules_file: None,
routing: None,
admin: None,
process: None,
timeouts: None,
reverse_servers: None,
reverse_clients: None,
};
let warnings = validate_config_security(&config);
assert!(warnings.is_empty());
}
#[test]
fn validate_trojan_requires_tls() {
let config = ConfigFile {
version: Some(1),
listeners: Some(vec![crate::model::ListenerConfig {
name: "trojan-notls".to_string(),
bind: "127.0.0.1:443".to_string(),
protocols: vec!["trojan".to_string()],
reuse_port: None,
connection_limit: None,
auth: None,
udp_enabled: None,
udp: None,
tls: None,
shadowsocks: None,
ssr: None,
trojan: Some(crate::model::ListenerTrojanConfig {
password: "secret".to_string(),
fallback: None,
}),
transparent: None,
unix: None,
fixed_target: None,
local_bind: None,
}]),
upstreams: None,
upstream_groups: None,
rules: None,
rules_file: None,
routing: None,
admin: None,
process: None,
timeouts: None,
reverse_servers: None,
reverse_clients: None,
};
let result = validate_config(&config);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors
.iter()
.any(|e| e.to_string().contains("requires TLS")));
}
#[test]
fn validate_trojan_requires_trojan_section() {
let config = ConfigFile {
version: Some(1),
listeners: Some(vec![crate::model::ListenerConfig {
name: "trojan-nosection".to_string(),
bind: "127.0.0.1:443".to_string(),
protocols: vec!["trojan".to_string()],
reuse_port: None,
connection_limit: None,
auth: None,
udp_enabled: None,
udp: None,
tls: Some(crate::model::ListenerTlsConfig {
cert: "/path/cert.pem".to_string(),
key: "/path/key.pem".to_string(),
alpn: None,
}),
shadowsocks: None,
ssr: None,
trojan: None,
transparent: None,
unix: None,
fixed_target: None,
local_bind: None,
}]),
upstreams: None,
upstream_groups: None,
rules: None,
rules_file: None,
routing: None,
admin: None,
process: None,
timeouts: None,
reverse_servers: None,
reverse_clients: None,
};
let result = validate_config(&config);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors
.iter()
.any(|e| e.to_string().contains("requires [listeners.trojan]")));
}
#[test]
fn validate_trojan_empty_password_rejected() {
let config = ConfigFile {
version: Some(1),
listeners: Some(vec![crate::model::ListenerConfig {
name: "trojan-empty".to_string(),
bind: "127.0.0.1:443".to_string(),
protocols: vec!["trojan".to_string()],
reuse_port: None,
connection_limit: None,
auth: None,
udp_enabled: None,
udp: None,
tls: Some(crate::model::ListenerTlsConfig {
cert: "/path/cert.pem".to_string(),
key: "/path/key.pem".to_string(),
alpn: None,
}),
shadowsocks: None,
ssr: None,
trojan: Some(crate::model::ListenerTrojanConfig {
password: String::new(),
fallback: None,
}),
transparent: None,
unix: None,
fixed_target: None,
local_bind: None,
}]),
upstreams: None,
upstream_groups: None,
rules: None,
rules_file: None,
routing: None,
admin: None,
process: None,
timeouts: None,
reverse_servers: None,
reverse_clients: None,
};
let result = validate_config(&config);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors
.iter()
.any(|e| e.to_string().contains("password must not be empty")));
}
#[test]
fn validate_empty_protocols_rejected() {
let config = ConfigFile {
version: Some(1),
listeners: Some(vec![crate::model::ListenerConfig {
name: "bad".to_string(),
bind: "127.0.0.1:0".to_string(),
protocols: vec![],
reuse_port: None,
connection_limit: None,
auth: None,
udp_enabled: None,
udp: None,
tls: None,
shadowsocks: None,
ssr: None,
trojan: None,
transparent: None,
unix: None,
fixed_target: None,
local_bind: None,
}]),
upstreams: None,
upstream_groups: None,
rules: None,
rules_file: None,
routing: None,
admin: None,
process: None,
timeouts: None,
reverse_servers: None,
reverse_clients: None,
};
let result = validate_config(&config);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors
.iter()
.any(|e| e.to_string().contains("protocols must not be empty")));
}
#[test]
fn validate_trojan_with_tls_and_password_passes() {
let config = ConfigFile {
version: Some(1),
listeners: Some(vec![crate::model::ListenerConfig {
name: "trojan-valid".to_string(),
bind: "127.0.0.1:443".to_string(),
protocols: vec!["trojan".to_string()],
reuse_port: None,
connection_limit: None,
auth: None,
udp_enabled: None,
udp: None,
tls: Some(crate::model::ListenerTlsConfig {
cert: "/path/cert.pem".to_string(),
key: "/path/key.pem".to_string(),
alpn: None,
}),
shadowsocks: None,
ssr: None,
trojan: Some(crate::model::ListenerTrojanConfig {
password: "my-secret".to_string(),
fallback: None,
}),
transparent: None,
unix: None,
fixed_target: None,
local_bind: None,
}]),
upstreams: None,
upstream_groups: None,
rules: None,
rules_file: None,
routing: None,
admin: None,
process: None,
timeouts: None,
reverse_servers: None,
reverse_clients: None,
};
let result = validate_config(&config);
assert!(
result.is_ok(),
"valid trojan config should pass: {:?}",
result.err()
);
}
#[test]
fn validate_trojan_fallback_invalid_address_rejected() {
let config = ConfigFile {
version: Some(1),
listeners: Some(vec![crate::model::ListenerConfig {
name: "trojan-bad-fallback".to_string(),
bind: "127.0.0.1:443".to_string(),
protocols: vec!["trojan".to_string()],
reuse_port: None,
connection_limit: None,
auth: None,
udp_enabled: None,
udp: None,
tls: Some(crate::model::ListenerTlsConfig {
cert: "/path/cert.pem".to_string(),
key: "/path/key.pem".to_string(),
alpn: None,
}),
shadowsocks: None,
ssr: None,
trojan: Some(crate::model::ListenerTrojanConfig {
password: "secret".to_string(),
fallback: Some("not-a-valid-address".to_string()),
}),
transparent: None,
unix: None,
fixed_target: None,
local_bind: None,
}]),
upstreams: None,
upstream_groups: None,
rules: None,
rules_file: None,
routing: None,
admin: None,
process: None,
timeouts: None,
reverse_servers: None,
reverse_clients: None,
};
let result = validate_config(&config);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors
.iter()
.any(|e| e.to_string().contains("invalid fallback address")));
}
#[test]
fn validate_trojan_fallback_valid_address_passes() {
let config = ConfigFile {
version: Some(1),
listeners: Some(vec![crate::model::ListenerConfig {
name: "trojan-good-fallback".to_string(),
bind: "127.0.0.1:443".to_string(),
protocols: vec!["trojan".to_string()],
reuse_port: None,
connection_limit: None,
auth: None,
udp_enabled: None,
udp: None,
tls: Some(crate::model::ListenerTlsConfig {
cert: "/path/cert.pem".to_string(),
key: "/path/key.pem".to_string(),
alpn: None,
}),
shadowsocks: None,
ssr: None,
trojan: Some(crate::model::ListenerTrojanConfig {
password: "secret".to_string(),
fallback: Some("127.0.0.1:443".to_string()),
}),
transparent: None,
unix: None,
fixed_target: None,
local_bind: None,
}]),
upstreams: None,
upstream_groups: None,
rules: None,
rules_file: None,
routing: None,
admin: None,
process: None,
timeouts: None,
reverse_servers: None,
reverse_clients: None,
};
let result = validate_config(&config);
assert!(
result.is_ok(),
"valid trojan config with fallback should pass: {:?}",
result.err()
);
}
}