refactor(config): preserve explicit flags with FlagsPatch and derive CLI arguments from schema (#2585)

This PR refactors EasyTier's configuration flags system across protobuf definitions, TOML/API persistence, runtime reconciliation, and the CLI.

It introduces a synthesized FlagsPatch protobuf message with optionize and pbjson support to track field presence reliably, replaces manual CLI flag boilerplate with dynamic schema-driven clap argument generation, and
establishes Flags::resolve(patch) as the single source of truth for runtime defaults.

Co-authored-by: Claude Code <noreply@anthropic.com>
This commit is contained in:
Luna YaoandClaude Code authored and GitHub committed 2026-10-10 22:11:08 +08:00
1 parent 018750a785
commit 250c2ad8d6
52 files changed
+1909 -1405

No files matched your search

-6
View File
@@ -30,12 +30,6 @@ pub fn parse_mapped_listener_urls(
.parse_urls(mapped_listeners)
}
pub fn parse_encryption_algorithm(value: &str) -> Result<EncryptionAlgorithm, String> {
value
.parse()
.map_err(|_| format!("'{value}' is not a valid encryption algorithm"))
}
pub fn load_toml_config_from_path(path: &PathBuf) -> Result<TomlConfigLoader, anyhow::Error> {
let config = std::fs::read_to_string(path)
.with_context(|| format!("failed to read config file: {}", path.display()))?;
+260 -440
View File
@@ -2,9 +2,9 @@ use crate::{
ShellType,
common::{
config::{
ConfigFileControl, ConfigLoader, ConsoleLoggerConfig, EncryptionAlgorithm,
FileLoggerConfig, LoggingConfigLoader, NetworkIdentity, PeerConfig, PortForwardConfig,
TomlConfigLoader, VpnPortalClientConfig, VpnPortalConfig, add_proxy_network_to_config,
ConfigFileControl, ConfigLoader, ConsoleLoggerConfig, FileLoggerConfig,
LoggingConfigLoader, NetworkIdentity, PeerConfig, PortForwardConfig, TomlConfigLoader,
VpnPortalClientConfig, VpnPortalConfig, add_proxy_network_to_config,
load_config_from_file, load_toml_config_from_path,
load_toml_config_from_str_with_source, parse_mapped_listener_urls,
},
@@ -12,17 +12,20 @@ use crate::{
log,
},
instance::factory::native_cli_instance_manager,
proto::common::{CompressionAlgoPb, SecureModeConfig},
proto::common::{CompressionAlgoPb, Flags, FlagsPatch, SecureModeConfig},
rpc_service::ApiRpcServer,
utils::panic::setup_panic_handler,
web_client,
};
use anyhow::Context;
use cidr::IpCidr;
use clap::{CommandFactory, Parser};
use easytier_core::config::normalize_secure_mode_config;
use clap::{Arg, ArgMatches, CommandFactory, FromArgMatches, Parser};
use easytier_core::config::{EncryptionAlgorithm, normalize_secure_mode_config};
use guarden::defer;
use prost_types::field_descriptor_proto::Type;
use rust_i18n::t;
use serde_json::{Value, json};
use std::str::{FromStr, ParseBoolError};
use std::{
net::{IpAddr, SocketAddr},
path::PathBuf,
@@ -35,6 +38,7 @@ use tokio::io::AsyncReadExt;
use crate::tunnel::IpScheme;
#[cfg(feature = "jemalloc-prof")]
use jemalloc_ctl::{Access as _, AsName as _, epoch, stats};
use serde::Serialize;
#[cfg(target_os = "windows")]
windows_service::define_windows_service!(ffi_service_main, win_service_main);
@@ -149,6 +153,10 @@ struct Cli {
#[derive(Parser, Debug, Default, PartialEq, Eq)]
struct NetworkOptions {
/// Filled in from the flag arguments the schema implies.
#[arg(skip)]
flags: FlagsPatch,
#[arg(
long,
env = "ET_NETWORK_NAME",
@@ -312,79 +320,6 @@ struct NetworkOptions {
)]
vpn_portal_client_groups: Vec<String>,
#[arg(
long,
env = "ET_DEFAULT_PROTOCOL",
help = t!("core_clap.default_protocol").to_string()
)]
default_protocol: Option<String>,
#[arg(
short = 'u',
long,
env = "ET_DISABLE_ENCRYPTION",
help = t!("core_clap.disable_encryption").to_string(),
num_args = 0..=1,
default_missing_value = "true"
)]
disable_encryption: Option<bool>,
#[arg(
long,
env = "ET_ENCRYPTION_ALGORITHM",
help = t!("core_clap.encryption_algorithm").to_string(),
value_parser = crate::common::config::parse_encryption_algorithm,
)]
encryption_algorithm: Option<EncryptionAlgorithm>,
#[arg(
long,
env = "ET_MULTI_THREAD",
help = t!("core_clap.multi_thread").to_string(),
num_args = 0..=1,
default_missing_value = "true"
)]
multi_thread: Option<bool>,
#[arg(
long,
env = "ET_MULTI_THREAD_COUNT",
help = t!("core_clap.multi_thread_count").to_string(),
)]
multi_thread_count: Option<u32>,
#[arg(
long,
env = "ET_DISABLE_IPV6",
help = t!("core_clap.disable_ipv6").to_string(),
num_args = 0..=1,
default_missing_value = "true"
)]
disable_ipv6: Option<bool>,
#[arg(
long,
env = "ET_DEV_NAME",
help = t!("core_clap.dev_name").to_string()
)]
dev_name: Option<String>,
#[arg(
long,
env = "ET_MTU",
help = t!("core_clap.mtu").to_string()
)]
mtu: Option<u16>,
#[arg(
long,
env = "ET_LATENCY_FIRST",
help = t!("core_clap.latency_first").to_string(),
num_args = 0..=1,
default_missing_value = "true"
)]
latency_first: Option<bool>,
#[arg(
long,
env = "ET_EXIT_NODES",
@@ -394,42 +329,6 @@ struct NetworkOptions {
)]
exit_nodes: Vec<IpAddr>,
#[arg(
long,
env = "ET_ENABLE_EXIT_NODE",
help = t!("core_clap.enable_exit_node").to_string(),
num_args = 0..=1,
default_missing_value = "true"
)]
enable_exit_node: Option<bool>,
#[arg(
long,
env = "ET_PROXY_FORWARD_BY_SYSTEM",
help = t!("core_clap.proxy_forward_by_system").to_string(),
num_args = 0..=1,
default_missing_value = "true"
)]
proxy_forward_by_system: Option<bool>,
#[arg(
long,
env = "ET_NO_TUN",
help = t!("core_clap.no_tun").to_string(),
num_args = 0..=1,
default_missing_value = "true"
)]
no_tun: Option<bool>,
#[arg(
long,
env = "ET_USE_SMOLTCP",
help = t!("core_clap.use_smoltcp").to_string(),
num_args = 0..=1,
default_missing_value = "true"
)]
use_smoltcp: Option<bool>,
#[arg(
long,
env = "ET_MANUAL_ROUTES",
@@ -439,108 +338,6 @@ struct NetworkOptions {
)]
manual_routes: Option<Vec<String>>,
// if not in relay_network_whitelist:
// for foreign virtual network, will refuse the incoming connection
// for local virtual network, will refuse to relay tun packets
#[arg(
long,
env = "ET_RELAY_NETWORK_WHITELIST",
value_delimiter = ',',
help = t!("core_clap.relay_network_whitelist").to_string(),
num_args = 0..
)]
relay_network_whitelist: Option<Vec<String>>,
#[arg(
long,
env = "ET_P2P_ONLY",
help = t!("core_clap.p2p_only").to_string(),
num_args = 0..=1,
default_missing_value = "true"
)]
p2p_only: Option<bool>,
#[arg(
long,
env = "ET_LAZY_P2P",
help = t!("core_clap.lazy_p2p").to_string(),
num_args = 0..=1,
default_missing_value = "true"
)]
lazy_p2p: Option<bool>,
#[arg(
long,
env = "ET_DISABLE_P2P",
help = t!("core_clap.disable_p2p").to_string(),
num_args = 0..=1,
default_missing_value = "true"
)]
disable_p2p: Option<bool>,
#[arg(
long,
env = "ET_DISABLE_UDP_HOLE_PUNCHING",
help = t!("core_clap.disable_udp_hole_punching").to_string(),
num_args = 0..=1,
default_missing_value = "true"
)]
disable_udp_hole_punching: Option<bool>,
#[arg(
long,
env = "ET_DISABLE_TCP_HOLE_PUNCHING",
help = t!("core_clap.disable_tcp_hole_punching").to_string(),
num_args = 0..=1,
default_missing_value = "true"
)]
disable_tcp_hole_punching: Option<bool>,
#[arg(
long,
env = "ET_DISABLE_SYM_HOLE_PUNCHING",
help = t!("core_clap.disable_sym_hole_punching").to_string(),
num_args = 0..=1,
default_missing_value = "true"
)]
disable_sym_hole_punching: Option<bool>,
#[arg(
long,
env = "ET_DISABLE_UPNP",
help = t!("core_clap.disable_upnp").to_string(),
num_args = 0..=1,
default_missing_value = "true"
)]
disable_upnp: Option<bool>,
#[arg(
long,
env = "ET_ENABLE_UDP_BROADCAST_RELAY",
help = t!("core_clap.enable_udp_broadcast_relay").to_string(),
num_args = 0..=1,
default_missing_value = "true"
)]
enable_udp_broadcast_relay: Option<bool>,
#[arg(
long,
env = "ET_RELAY_ALL_PEER_RPC",
help = t!("core_clap.relay_all_peer_rpc").to_string(),
num_args = 0..=1,
default_missing_value = "true"
)]
relay_all_peer_rpc: Option<bool>,
#[arg(
long,
env = "ET_NEED_P2P",
help = t!("core_clap.need_p2p").to_string(),
num_args = 0..=1,
default_missing_value = "true"
)]
need_p2p: Option<bool>,
#[cfg(feature = "socks5")]
#[arg(
long,
@@ -549,67 +346,6 @@ struct NetworkOptions {
)]
socks5: Option<u16>,
#[arg(
long,
env = "ET_COMPRESSION",
help = t!("core_clap.compression").to_string(),
)]
compression: Option<String>,
#[arg(
long,
env = "ET_BIND_DEVICE",
help = t!("core_clap.bind_device").to_string()
)]
bind_device: Option<bool>,
// SO_MARK (fwmark) is a Linux-family kernel feature. Gate the flag out
// entirely on other targets so users on Windows/macOS/BSD don't see a
// `--socket-mark` they can't act on.
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
#[arg(
long,
env = "ET_SOCKET_MARK",
help = t!("core_clap.socket_mark").to_string()
)]
socket_mark: Option<u32>,
#[arg(
long,
env = "ET_ENABLE_KCP_PROXY",
help = t!("core_clap.enable_kcp_proxy").to_string(),
num_args = 0..=1,
default_missing_value = "true"
)]
enable_kcp_proxy: Option<bool>,
#[arg(
long,
env = "ET_DISABLE_KCP_INPUT",
help = t!("core_clap.disable_kcp_input").to_string(),
num_args = 0..=1,
default_missing_value = "true"
)]
disable_kcp_input: Option<bool>,
#[arg(
long,
env = "ET_ENABLE_QUIC_PROXY",
help = t!("core_clap.enable_quic_proxy").to_string(),
num_args = 0..=1,
default_missing_value = "true"
)]
enable_quic_proxy: Option<bool>,
#[arg(
long,
env = "ET_DISABLE_QUIC_INPUT",
help = t!("core_clap.disable_quic_input").to_string(),
num_args = 0..=1,
default_missing_value = "true"
)]
disable_quic_input: Option<bool>,
#[arg(
long,
env = "ET_PORT_FORWARD",
@@ -619,40 +355,6 @@ struct NetworkOptions {
)]
port_forward: Vec<url::Url>,
#[arg(
long,
env = "ET_ACCEPT_DNS",
help = t!("core_clap.accept_dns").to_string(),
)]
accept_dns: Option<bool>,
#[arg(
long = "tld-dns-zone",
env = "ET_TLD_DNS_ZONE",
help = t!("core_clap.tld_dns_zone").to_string())]
tld_dns_zone: Option<String>,
#[arg(
long,
env = "ET_PRIVATE_MODE",
help = t!("core_clap.private_mode").to_string(),
)]
private_mode: Option<bool>,
#[arg(
long,
env = "ET_FOREIGN_RELAY_BPS_LIMIT",
help = t!("core_clap.foreign_relay_bps_limit").to_string(),
)]
foreign_relay_bps_limit: Option<u64>,
#[arg(
long,
env = "ET_INSTANCE_RECV_BPS_LIMIT",
help = t!("core_clap.instance_recv_bps_limit").to_string(),
)]
instance_recv_bps_limit: Option<u64>,
#[arg(
long,
value_delimiter = ',',
@@ -669,42 +371,6 @@ struct NetworkOptions {
)]
udp_whitelist: Vec<String>,
#[arg(
long,
env = "ET_DISABLE_RELAY_KCP",
help = t!("core_clap.disable_relay_kcp").to_string(),
num_args = 0..=1,
default_missing_value = "true"
)]
disable_relay_kcp: Option<bool>,
#[arg(
long,
env = "ET_DISABLE_RELAY_QUIC",
help = t!("core_clap.disable_relay_quic").to_string(),
num_args = 0..=1,
default_missing_value = "true"
)]
disable_relay_quic: Option<bool>,
#[arg(
long,
env = "ET_ENABLE_RELAY_FOREIGN_NETWORK_KCP",
help = t!("core_clap.enable_relay_foreign_network_kcp").to_string(),
num_args = 0..=1,
default_missing_value = "true"
)]
enable_relay_foreign_network_kcp: Option<bool>,
#[arg(
long,
env = "ET_ENABLE_RELAY_FOREIGN_NETWORK_QUIC",
help = t!("core_clap.enable_relay_foreign_network_quic").to_string(),
num_args = 0..=1,
default_missing_value = "true"
)]
enable_relay_foreign_network_quic: Option<bool>,
#[arg(
long,
env = "ET_STUN_SERVERS",
@@ -1237,96 +903,12 @@ impl NetworkOptions {
cfg.set_secure_mode(Some(normalize_secure_mode_config(c)?));
}
let mut f = cfg.get_flags();
if let Some(default_protocol) = &self.default_protocol {
f.default_protocol = default_protocol.clone()
};
if let Some(v) = self.disable_encryption {
f.enable_encryption = !v;
}
if let Some(algorithm) = &self.encryption_algorithm {
f.encryption_algorithm = algorithm.to_string();
}
if let Some(v) = self.disable_ipv6 {
f.enable_ipv6 = !v;
}
f.latency_first = self.latency_first.unwrap_or(f.latency_first);
if let Some(dev_name) = &self.dev_name {
f.dev_name = dev_name.clone()
}
if let Some(mtu) = self.mtu {
f.mtu = mtu as u32;
}
f.enable_exit_node = self.enable_exit_node.unwrap_or(f.enable_exit_node);
f.proxy_forward_by_system = self
.proxy_forward_by_system
.unwrap_or(f.proxy_forward_by_system);
f.no_tun = self.no_tun.unwrap_or(f.no_tun) || cfg!(not(feature = "tun"));
f.use_smoltcp = self.use_smoltcp.unwrap_or(f.use_smoltcp);
if let Some(wl) = self.relay_network_whitelist.as_ref() {
f.relay_network_whitelist = wl.join(" ");
}
f.disable_p2p = self.disable_p2p.unwrap_or(f.disable_p2p);
f.p2p_only = self.p2p_only.unwrap_or(f.p2p_only);
f.lazy_p2p = self.lazy_p2p.unwrap_or(f.lazy_p2p);
f.disable_tcp_hole_punching = self
.disable_tcp_hole_punching
.unwrap_or(f.disable_tcp_hole_punching);
f.disable_udp_hole_punching = self
.disable_udp_hole_punching
.unwrap_or(f.disable_udp_hole_punching);
f.relay_all_peer_rpc = self.relay_all_peer_rpc.unwrap_or(f.relay_all_peer_rpc);
f.need_p2p = self.need_p2p.unwrap_or(f.need_p2p);
f.multi_thread = self.multi_thread.unwrap_or(f.multi_thread);
if let Some(compression) = &self.compression {
f.data_compress_algo = match compression.as_str() {
"none" => CompressionAlgoPb::None,
"zstd" => CompressionAlgoPb::Zstd,
_ => panic!(
"unknown compression algorithm: {}, supported: none, zstd",
compression
),
}
.into();
}
f.bind_device = self.bind_device.unwrap_or(f.bind_device);
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
{
f.socket_mark = self.socket_mark.or(f.socket_mark);
}
f.enable_kcp_proxy = self.enable_kcp_proxy.unwrap_or(f.enable_kcp_proxy);
f.disable_kcp_input = self.disable_kcp_input.unwrap_or(f.disable_kcp_input);
f.enable_quic_proxy = self.enable_quic_proxy.unwrap_or(f.enable_quic_proxy);
f.disable_quic_input = self.disable_quic_input.unwrap_or(f.disable_quic_input);
f.accept_dns = self.accept_dns.unwrap_or(f.accept_dns);
f.private_mode = self.private_mode.unwrap_or(f.private_mode);
f.foreign_relay_bps_limit = self
.foreign_relay_bps_limit
.unwrap_or(f.foreign_relay_bps_limit);
f.instance_recv_bps_limit = self
.instance_recv_bps_limit
.unwrap_or(f.instance_recv_bps_limit);
f.multi_thread_count = self.multi_thread_count.unwrap_or(f.multi_thread_count);
f.disable_relay_kcp = self.disable_relay_kcp.unwrap_or(f.disable_relay_kcp);
f.disable_relay_quic = self.disable_relay_quic.unwrap_or(f.disable_relay_quic);
f.enable_relay_foreign_network_kcp = self
.enable_relay_foreign_network_kcp
.unwrap_or(f.enable_relay_foreign_network_kcp);
f.enable_relay_foreign_network_quic = self
.enable_relay_foreign_network_quic
.unwrap_or(f.enable_relay_foreign_network_quic);
f.disable_sym_hole_punching = self
.disable_sym_hole_punching
.unwrap_or(f.disable_sym_hole_punching);
f.disable_upnp = self.disable_upnp.unwrap_or(f.disable_upnp);
f.enable_udp_broadcast_relay = self
.enable_udp_broadcast_relay
.unwrap_or(f.enable_udp_broadcast_relay);
// Configure tld_dns_zone: use provided value if set
if let Some(tld_dns_zone) = &self.tld_dns_zone {
f.tld_dns_zone = tld_dns_zone.clone();
}
cfg.set_flags(f);
cfg.patch_flags(self.flags.clone());
#[cfg(not(feature = "tun"))]
cfg.patch_flags(FlagsPatch {
no_tun: Some(true),
..Default::default()
});
if !self.exit_nodes.is_empty() {
cfg.set_exit_nodes(self.exit_nodes.clone());
@@ -1471,8 +1053,96 @@ fn win_service_event_loop(
});
}
fn flags_from(matches: &ArgMatches) -> Result<FlagsPatch, clap::Error> {
let given = Flags::flags()
.iter()
.filter_map(|field| {
let name = field.name();
let value = if let Ok(Some(values)) = matches.try_get_many::<String>(name) {
json!(values.map(|s| s.as_str()).collect::<Vec<_>>().join(" "))
} else if let Ok(Some(value)) = matches.try_get_one::<Value>(name) {
value.clone()
} else {
return None;
};
Some((name.to_owned(), value))
})
.collect::<serde_json::Map<_, _>>();
serde_json::from_value(Value::Object(given)).map_err(|error| {
clap::Error::raw(
clap::error::ErrorKind::ValueValidation,
format!("{error:#}"),
)
})
}
fn cli_command() -> clap::Command {
fn arg(flag: &str, spelling: &str) -> Arg {
let name = spelling.to_uppercase();
Arg::new(flag.to_owned())
.long(spelling.replace('_', "-"))
.env(format!("ET_{name}"))
.value_name(name)
.help(t!(format!("core_clap.{spelling}")).to_string())
}
fn parsed<T: FromStr + Serialize>(value: &str) -> Result<Value, T::Err> {
value.parse::<T>().map(|value| json!(value))
}
fn negated(value: &str) -> Result<Value, ParseBoolError> {
value.parse::<bool>().map(|value| json!(!value))
}
Cli::command().args(Flags::flags().iter().map(|field| {
let name = field.name();
match name {
// The two the command line states in the negative, which can be given
// bare like any other boolean flag.
"enable_encryption" => arg(name, "disable_encryption")
.short('u')
.num_args(0..=1)
.default_missing_value("true")
.value_parser(negated),
"enable_ipv6" => arg(name, "disable_ipv6")
.num_args(0..=1)
.default_missing_value("true")
.value_parser(negated),
// The command line names the algorithm instead of spelling the field.
// The values are the schema's own, which the patch reads.
"data_compress_algo" => {
arg(name, "compression").value_parser(parsed::<CompressionAlgoPb>)
}
"encryption_algorithm" => arg(name, name).value_parser(parsed::<EncryptionAlgorithm>),
"mtu" => arg(name, name).value_parser(parsed::<u16>),
"relay_network_whitelist" => arg(name, name)
.value_delimiter(',')
.num_args(0..)
.action(clap::ArgAction::Append),
_ => {
let arg = arg(name, name);
match field.r#type() {
// A boolean flag can be given bare, and bare means enabled.
Type::Bool => arg
.num_args(0..=1)
.default_missing_value("true")
.value_parser(parsed::<bool>),
Type::Uint32 => arg.value_parser(parsed::<u32>),
Type::Uint64 => arg.value_parser(parsed::<u64>),
// An enum flag takes the name the schema spells, and the patch's
// own serde rejects anything the enum does not declare.
Type::String | Type::Enum => arg.value_parser(parsed::<String>),
other => panic!("{name}: no command line for {other:?}"),
}
}
}
}))
}
fn parse_cli() -> Cli {
let mut cli = Cli::parse();
let matches = cli_command().get_matches();
let mut cli = Cli::from_arg_matches(&matches).unwrap_or_else(|error| error.exit());
cli.network_options.flags = flags_from(&matches).unwrap_or_else(|error| error.exit());
// for --stun-servers="", we want vec![], but clap will give vec![""], hack for that
if let Some(stun_servers) = &mut cli.network_options.stun_servers {
stun_servers.retain(|s| !s.trim().is_empty());
@@ -1778,7 +1448,7 @@ pub async fn main() -> ExitCode {
let cli = parse_cli();
if let Some(shell) = cli.gen_autocomplete {
let mut cmd = Cli::command();
let mut cmd = cli_command();
if let Some(shell) = shell.to_shell() {
crate::print_completions(shell, &mut cmd, "easytier-core");
} else {
@@ -1841,6 +1511,12 @@ async fn validate_config(cli: &Cli) -> anyhow::Result<()> {
#[cfg(test)]
mod tests {
use super::*;
use crate::proto::common::CompressionAlgoPb;
/// Parses the flags the way the binary does.
fn parse_flags(argv: &[&str]) -> Result<FlagsPatch, clap::Error> {
flags_from(&cli_command().try_get_matches_from(argv)?)
}
#[test]
fn test_parse_listeners() {
@@ -1954,6 +1630,150 @@ enabled = true
assert_eq!(cfg.get_hostname(), "override-host");
}
#[test]
fn test_cli_flags_from_schema_preserves_presence() {
assert_eq!(parse_flags(&["easytier"]).unwrap(), FlagsPatch::default());
let patch = parse_flags(&[
"easytier",
"--disable-encryption",
"false",
"--latency-first",
"false",
"--mtu",
"0",
"--compression",
"Zstd",
"--socket-mark",
"42",
"--disable-relay-data",
])
.unwrap();
assert_eq!(patch.enable_encryption, Some(true));
assert_eq!(patch.latency_first, Some(false));
assert_eq!(patch.mtu, Some(0));
assert_eq!(
patch.data_compress_algo,
Some(CompressionAlgoPb::Zstd as i32)
);
assert_eq!(patch.socket_mark, Some(42));
assert_eq!(patch.disable_relay_data, Some(true));
assert_eq!(patch.enable_ipv6, None);
// A negated flag can be given bare, and bare means the value it states.
let patch = parse_flags(&["easytier", "--disable-ipv6"]).unwrap();
assert_eq!(patch.enable_ipv6, Some(false));
// Compression tests: case-insensitive, accepts None/none/zstd/Zstd/ZSTD, rejects invalid
let patch = parse_flags(&["easytier", "--compression", "none"]).unwrap();
assert_eq!(
patch.data_compress_algo,
Some(CompressionAlgoPb::None as i32)
);
let patch = parse_flags(&["easytier", "--compression", "zstd"]).unwrap();
assert_eq!(
patch.data_compress_algo,
Some(CompressionAlgoPb::Zstd as i32)
);
let patch = parse_flags(&["easytier", "--compression", "ZSTD"]).unwrap();
assert_eq!(
patch.data_compress_algo,
Some(CompressionAlgoPb::Zstd as i32)
);
assert!(parse_flags(&["easytier", "--compression", "invalid"]).is_err());
assert!(parse_flags(&["easytier", "--compression", "lz4"]).is_err());
// Encryption algorithm tests: case-insensitive, aliases, rejects invalid
let patch = parse_flags(&["easytier", "--encryption-algorithm", "aes-gcm"]).unwrap();
assert_eq!(patch.encryption_algorithm, Some("aes-gcm".to_string()));
let patch = parse_flags(&["easytier", "--encryption-algorithm", "AES-GCM"]).unwrap();
assert_eq!(patch.encryption_algorithm, Some("aes-gcm".to_string()));
let patch =
parse_flags(&["easytier", "--encryption-algorithm", "openssl-aes-gcm"]).unwrap();
assert_eq!(patch.encryption_algorithm, Some("aes-gcm".to_string()));
let patch =
parse_flags(&["easytier", "--encryption-algorithm", "chacha20-poly1305"]).unwrap();
assert_eq!(patch.encryption_algorithm, Some("chacha20".to_string()));
let patch = parse_flags(&["easytier", "--encryption-algorithm", "xor"]).unwrap();
assert_eq!(patch.encryption_algorithm, Some("xor".to_string()));
assert!(parse_flags(&["easytier", "--encryption-algorithm", "rot13"]).is_err());
assert!(parse_flags(&["easytier", "--encryption-algorithm", "des"]).is_err());
// Deprecated flag is not accepted.
assert!(parse_flags(&["easytier", "--quic-listen-port", "1234"]).is_err());
// MTU tests: u16 range validation
let patch = parse_flags(&["easytier", "--mtu", "1400"]).unwrap();
assert_eq!(patch.mtu, Some(1400));
let patch = parse_flags(&["easytier", "--mtu", "65535"]).unwrap();
assert_eq!(patch.mtu, Some(65535));
assert!(parse_flags(&["easytier", "--mtu", "65536"]).is_err());
assert!(parse_flags(&["easytier", "--mtu", "70000"]).is_err());
// Relay network whitelist tests: comma, multiple values, bare clearing
let patch = parse_flags(&["easytier", "--relay-network-whitelist", "net1,net2"]).unwrap();
assert_eq!(patch.relay_network_whitelist, Some("net1 net2".to_string()));
let patch =
parse_flags(&["easytier", "--relay-network-whitelist", "net1", "net2"]).unwrap();
assert_eq!(patch.relay_network_whitelist, Some("net1 net2".to_string()));
let patch = parse_flags(&[
"easytier",
"--relay-network-whitelist",
"net1",
"--relay-network-whitelist",
"net2",
])
.unwrap();
assert_eq!(patch.relay_network_whitelist, Some("net1 net2".to_string()));
let patch = parse_flags(&["easytier", "--relay-network-whitelist"]).unwrap();
assert_eq!(patch.relay_network_whitelist, Some("".to_string()));
}
#[test]
fn test_network_options_patch_preserves_omitted_flags_and_applies_zero_values() {
let cfg = TomlConfigLoader::new_from_str(
r#"[flags]
default_protocol = "udp"
latency_first = true
mtu = 1400
socket_mark = 42
"#,
)
.unwrap();
NetworkOptions::default().merge_into(&cfg).unwrap();
assert_eq!(cfg.get_flags().socket_mark, Some(42));
assert_eq!(cfg.get_flags_patch().enable_encryption, None);
assert_eq!(cfg.get_flags_patch().multi_thread, None);
NetworkOptions {
flags: FlagsPatch {
latency_first: Some(false),
mtu: Some(0),
relay_network_whitelist: Some(String::new()),
enable_encryption: Some(false),
data_compress_algo: Some(CompressionAlgoPb::Zstd.into()),
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
socket_mark: Some(0),
..Default::default()
},
..Default::default()
}
.merge_into(&cfg)
.unwrap();
let flags = cfg.get_flags();
assert_eq!(flags.default_protocol, "udp");
assert!(!flags.latency_first);
assert_eq!(flags.mtu, 0);
assert_eq!(flags.relay_network_whitelist, "");
assert!(!flags.enable_encryption);
assert_eq!(flags.data_compress_algo, CompressionAlgoPb::Zstd as i32);
#[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))]
assert_eq!(flags.socket_mark, Some(0));
#[cfg(not(feature = "tun"))]
assert!(flags.no_tun);
}
#[test]
fn secure_mode_cli_flag_preserves_config_file_keypair() {
use base64::{Engine as _, prelude::BASE64_STANDARD};