use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::path::{Path, PathBuf};
use std::time::Duration;
use clap::Parser;
use ipnetwork::{IpNetwork, Ipv6Network};
use serde::{Deserialize, Serialize};
use crate::crypto::Cipher;
use crate::mesh::{RouteApproval, MAX_ROUTES};
use crate::policy::{Mode, PolicyConfig};
use crate::protocol::DEFAULT_TUN_MTU;
pub const DEFAULT_CIPHER: &str = "chacha20-poly1305";
pub const DEFAULT_NETMASK: Ipv4Addr = Ipv4Addr::new(255, 255, 255, 0);
pub const DEFAULT_DNS_LISTEN: &str = "127.0.0.1:53";
pub const DEFAULT_DNS_LOCAL: &str = "114.114.114.114:53";
pub const DEFAULT_DNS_REMOTE: &str = "8.8.8.8:53";
pub const DEFAULT_GEOIP_COUNTRY: &str = "CN";
pub const DEFAULT_CACHE_FILE_NAME: &str = "dns-cache.json";
pub const DEFAULT_GEOIP_DB_NAME: &str = "GeoLite2-Country.mmdb";
pub const DEFAULT_GFWLIST_NAME: &str = "gfwlist.txt";
pub const DEFAULT_DNS_TIMEOUT_MS: u64 = 3000;
pub const DEFAULT_LEASE_TTL_SECS: u64 = 120;
pub const DEFAULT_KEEPALIVE_SECS: u64 = 15;
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("failed to read config file {path}: {source}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse config file {path}: {source}")]
Parse {
path: PathBuf,
#[source]
source: serde_json::Error,
},
#[error("missing required configuration field: {0}")]
Missing(&'static str),
#[error(transparent)]
Cipher(#[from] crate::crypto::CryptoError),
#[error(transparent)]
Policy(#[from] crate::policy::PolicyError),
#[error("invalid value for {field}: {message}")]
Invalid {
field: &'static str,
message: String,
},
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FileConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub server: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub password: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cipher: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tun_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tun_ip: Option<Ipv4Addr>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tun_netmask: Option<Ipv4Addr>,
#[serde(skip_serializing_if = "Option::is_none")]
pub peer_ip: Option<Ipv4Addr>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tun_ip6: Option<Ipv6Network>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mtu: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub obfs: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nat: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lease_ttl_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub keepalive_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub advertise_routes: Option<Vec<IpNetwork>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub accept_routes: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub approve_routes: Option<Vec<IpNetwork>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_approve_routes: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dns_listen: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dns_local: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dns_remote: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gfwlist: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub chnroute: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub geoip: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub geoip_country: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub set_dns: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prewarm: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_file: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dns_timeout_ms: Option<u64>,
}
impl FileConfig {
pub fn load(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
let path = path.as_ref();
let bytes = std::fs::read(path).map_err(|source| ConfigError::Read {
path: path.to_path_buf(),
source,
})?;
serde_json::from_slice(&bytes).map_err(|source| ConfigError::Parse {
path: path.to_path_buf(),
source,
})
}
}
#[derive(Debug, Clone)]
pub struct TunConfig {
pub name: Option<String>,
pub ip: Ipv4Addr,
pub netmask: Ipv4Addr,
pub peer_ip: Ipv4Addr,
pub ip6: Option<Ipv6Network>,
pub mtu: u16,
}
#[derive(Debug, Clone)]
pub struct ServerConfig {
pub listen: String,
pub cipher: Cipher,
pub master_key: Vec<u8>,
pub tun: TunConfig,
pub obfs: Option<String>,
pub nat: bool,
pub lease_ttl: Duration,
pub route_approval: RouteApproval,
}
#[derive(Debug, Clone)]
pub struct ClientConfig {
pub server: String,
pub cipher: Cipher,
pub master_key: Vec<u8>,
pub tun: TunConfig,
pub policy: PolicyConfig,
pub obfs: Option<String>,
pub keepalive: Duration,
pub advertise_routes: Vec<IpNetwork>,
pub accept_routes: bool,
}
#[derive(Debug, Clone, Parser)]
#[command(
name = "shadowvpn-server",
about = "ShadowVPN server: terminates the encrypted UDP tunnel onto a TUN device."
)]
pub struct ServerArgs {
#[arg(short = 'c', long = "config")]
pub config: Option<PathBuf>,
#[arg(short = 'l', long = "listen")]
pub listen: Option<String>,
#[arg(short = 'k', long = "password")]
pub password: Option<String>,
#[arg(short = 'm', long = "cipher")]
pub cipher: Option<String>,
#[arg(long = "tun-name")]
pub tun_name: Option<String>,
#[arg(long = "tun-ip")]
pub tun_ip: Option<Ipv4Addr>,
#[arg(long = "tun-netmask")]
pub tun_netmask: Option<Ipv4Addr>,
#[arg(long = "peer-ip")]
pub peer_ip: Option<Ipv4Addr>,
#[arg(long = "tun-ip6")]
pub tun_ip6: Option<Ipv6Network>,
#[arg(long = "mtu")]
pub mtu: Option<u16>,
#[arg(long = "nat")]
pub nat: bool,
#[arg(long = "lease-ttl-secs")]
pub lease_ttl_secs: Option<u64>,
#[arg(long = "approve-routes", value_delimiter = ',')]
pub approve_routes: Option<Vec<IpNetwork>>,
#[arg(long = "auto-approve-routes")]
pub auto_approve_routes: bool,
}
#[derive(Debug, Clone, Parser)]
#[command(
name = "shadowvpn-client",
about = "ShadowVPN client: tunnels TUN traffic to the server over encrypted UDP."
)]
pub struct ClientArgs {
#[arg(short = 'c', long = "config")]
pub config: Option<PathBuf>,
#[arg(short = 's', long = "server")]
pub server: Option<String>,
#[arg(short = 'k', long = "password")]
pub password: Option<String>,
#[arg(short = 'm', long = "cipher")]
pub cipher: Option<String>,
#[arg(long = "tun-name")]
pub tun_name: Option<String>,
#[arg(long = "tun-ip")]
pub tun_ip: Option<Ipv4Addr>,
#[arg(long = "tun-netmask")]
pub tun_netmask: Option<Ipv4Addr>,
#[arg(long = "peer-ip")]
pub peer_ip: Option<Ipv4Addr>,
#[arg(long = "tun-ip6")]
pub tun_ip6: Option<Ipv6Network>,
#[arg(long = "mtu")]
pub mtu: Option<u16>,
#[arg(long = "advertise-routes", value_delimiter = ',')]
pub advertise_routes: Option<Vec<IpNetwork>>,
#[arg(long = "accept-routes")]
pub accept_routes: bool,
#[arg(long = "mode")]
pub mode: Option<String>,
#[arg(long = "dns-listen")]
pub dns_listen: Option<String>,
#[arg(long = "dns-local")]
pub dns_local: Option<String>,
#[arg(long = "dns-remote")]
pub dns_remote: Option<String>,
#[arg(long = "gfwlist")]
pub gfwlist: Option<PathBuf>,
#[arg(long = "chnroute")]
pub chnroute: Option<PathBuf>,
#[arg(long = "geoip")]
pub geoip: Option<PathBuf>,
#[arg(long = "geoip-country")]
pub geoip_country: Option<String>,
#[arg(long = "set-dns")]
pub set_dns: bool,
#[arg(long = "no-set-dns")]
pub no_set_dns: bool,
#[arg(long = "restore-dns")]
pub restore_dns: bool,
#[arg(long = "no-prewarm")]
pub no_prewarm: bool,
#[arg(long = "cache-file")]
pub cache_file: Option<String>,
#[arg(long = "no-cache-persist")]
pub no_cache_persist: bool,
#[arg(long = "keepalive-secs")]
pub keepalive_secs: Option<u64>,
}
fn load_file(config: &Option<PathBuf>) -> Result<FileConfig, ConfigError> {
match config {
Some(path) => FileConfig::load(path),
None => Ok(FileConfig::default()),
}
}
fn resolve_crypto(
cipher_name: Option<String>,
password: Option<String>,
) -> Result<(Cipher, Vec<u8>), ConfigError> {
let cipher_name = cipher_name.unwrap_or_else(|| DEFAULT_CIPHER.to_string());
let cipher = Cipher::from_name(&cipher_name)?;
let password = password.ok_or(ConfigError::Missing("password"))?;
let master_key = crate::crypto::evp_bytes_to_key(password.as_bytes(), cipher.key_len());
Ok((cipher, master_key))
}
fn default_cache_file() -> PathBuf {
std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(Path::to_path_buf))
.unwrap_or_else(|| PathBuf::from("."))
.join(DEFAULT_CACHE_FILE_NAME)
}
fn exe_dir() -> Option<PathBuf> {
std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(Path::to_path_buf))
}
fn data_file_in_dir(dir: &Path, name: &str) -> Option<PathBuf> {
let path = dir.join(name);
path.is_file().then_some(path)
}
fn bundled_gfwlist(mode: Mode, dir: &Path) -> Option<PathBuf> {
if matches!(mode, Mode::GfwList | Mode::ChinaDns) {
data_file_in_dir(dir, DEFAULT_GFWLIST_NAME)
} else {
None
}
}
fn bundled_geoip(mode: Mode, chnroute_set: bool, dir: &Path) -> Option<PathBuf> {
if matches!(mode, Mode::ChinaDns) && !chnroute_set {
data_file_in_dir(dir, DEFAULT_GEOIP_DB_NAME)
} else {
None
}
}
fn parse_dns_addr(
field: &'static str,
value: &str,
default_port: u16,
) -> Result<SocketAddr, ConfigError> {
if let Ok(addr) = value.parse::<SocketAddr>() {
return Ok(addr);
}
if let Ok(ip) = value.parse::<IpAddr>() {
return Ok(SocketAddr::new(ip, default_port));
}
Err(ConfigError::Invalid {
field,
message: format!("`{value}` is not an `ip` or `ip:port` address"),
})
}
fn resolve_policy(args: &ClientArgs, file: &FileConfig) -> Result<PolicyConfig, ConfigError> {
let mode = match args.mode.clone().or_else(|| file.mode.clone()) {
Some(name) => Mode::from_name(&name)?,
None => Mode::Full,
};
let pick = |a: &Option<String>, f: &Option<String>, default: &str| -> String {
a.clone()
.or_else(|| f.clone())
.unwrap_or_else(|| default.to_string())
};
let dns_listen = parse_dns_addr(
"dns_listen",
&pick(&args.dns_listen, &file.dns_listen, DEFAULT_DNS_LISTEN),
53,
)?;
let dns_local = parse_dns_addr(
"dns_local",
&pick(&args.dns_local, &file.dns_local, DEFAULT_DNS_LOCAL),
53,
)?;
let dns_remote = parse_dns_addr(
"dns_remote",
&pick(&args.dns_remote, &file.dns_remote, DEFAULT_DNS_REMOTE),
53,
)?;
let bundle_dir = exe_dir();
let gfwlist = args
.gfwlist
.clone()
.or_else(|| file.gfwlist.clone())
.or_else(|| bundle_dir.as_deref().and_then(|d| bundled_gfwlist(mode, d)));
let chnroute = args.chnroute.clone().or_else(|| file.chnroute.clone());
let geoip = args
.geoip
.clone()
.or_else(|| file.geoip.clone())
.or_else(|| {
bundle_dir
.as_deref()
.and_then(|d| bundled_geoip(mode, chnroute.is_some(), d))
});
let set_dns = if args.no_set_dns {
false
} else if args.set_dns {
true
} else {
file.set_dns.unwrap_or(true)
};
let prewarm = if args.no_prewarm {
Vec::new()
} else {
file.prewarm.clone().unwrap_or_else(|| {
crate::policy::DEFAULT_PREWARM
.iter()
.map(|s| s.to_string())
.collect()
})
};
let cache_file = if args.no_cache_persist {
None
} else {
Some(
args.cache_file
.clone()
.or_else(|| file.cache_file.clone())
.map(PathBuf::from)
.unwrap_or_else(default_cache_file),
)
};
if matches!(mode, Mode::GfwList) && gfwlist.is_none() {
return Err(ConfigError::Missing("gfwlist (required by gfwlist mode)"));
}
if matches!(mode, Mode::ChinaDns) && chnroute.is_none() && geoip.is_none() {
return Err(ConfigError::Missing(
"chnroute or geoip (required by chinadns mode)",
));
}
Ok(PolicyConfig {
mode,
dns_listen,
dns_local,
dns_remote,
gfwlist,
chnroute,
geoip,
geoip_country: args
.geoip_country
.clone()
.or_else(|| file.geoip_country.clone())
.unwrap_or_else(|| DEFAULT_GEOIP_COUNTRY.to_string()),
set_dns,
prewarm,
cache_file,
dns_timeout: Duration::from_millis(file.dns_timeout_ms.unwrap_or(DEFAULT_DNS_TIMEOUT_MS)),
})
}
#[allow(clippy::too_many_arguments)]
fn resolve_tun(
name: Option<String>,
ip: Option<Ipv4Addr>,
netmask: Option<Ipv4Addr>,
peer_ip: Option<Ipv4Addr>,
ip6: Option<Ipv6Network>,
mtu: Option<u16>,
) -> Result<TunConfig, ConfigError> {
Ok(TunConfig {
name,
ip: ip.ok_or(ConfigError::Missing("tun_ip"))?,
netmask: netmask.unwrap_or(DEFAULT_NETMASK),
peer_ip: peer_ip.ok_or(ConfigError::Missing("peer_ip"))?,
ip6,
mtu: mtu.unwrap_or(DEFAULT_TUN_MTU),
})
}
fn validate_advertised(routes: &[IpNetwork]) -> Result<(), ConfigError> {
if routes.len() > MAX_ROUTES {
return Err(ConfigError::Invalid {
field: "advertise_routes",
message: format!("at most {MAX_ROUTES} routes may be advertised"),
});
}
if let Some(net) = routes.iter().find(|net| net.prefix() == 0) {
return Err(ConfigError::Invalid {
field: "advertise_routes",
message: format!("`{net}` is a default route; advertise specific subnets instead"),
});
}
Ok(())
}
impl ServerArgs {
pub fn resolve(self) -> Result<ServerConfig, ConfigError> {
let file = load_file(&self.config)?;
let listen = self
.listen
.or(file.server)
.ok_or(ConfigError::Missing("listen"))?;
let (cipher, master_key) =
resolve_crypto(self.cipher.or(file.cipher), self.password.or(file.password))?;
let tun = resolve_tun(
self.tun_name.or(file.tun_name),
self.tun_ip.or(file.tun_ip),
self.tun_netmask.or(file.tun_netmask),
self.peer_ip.or(file.peer_ip),
self.tun_ip6.or(file.tun_ip6),
self.mtu.or(file.mtu),
)?;
let obfs = file.obfs.filter(|s| !s.is_empty() && s != "none");
let nat = self.nat || file.nat.unwrap_or(false);
let lease_ttl = Duration::from_secs(
self.lease_ttl_secs
.or(file.lease_ttl_secs)
.unwrap_or(DEFAULT_LEASE_TTL_SECS),
);
let route_approval = RouteApproval {
auto: self.auto_approve_routes || file.auto_approve_routes.unwrap_or(false),
allowlist: self
.approve_routes
.or(file.approve_routes)
.unwrap_or_default(),
};
if nat && (route_approval.auto || !route_approval.allowlist.is_empty() || tun.ip6.is_some())
{
return Err(ConfigError::Invalid {
field: "nat",
message: "mesh subnet routing (approve_routes / auto_approve_routes / tun_ip6) \
requires learning mode; remove --nat"
.to_string(),
});
}
Ok(ServerConfig {
listen,
cipher,
master_key,
tun,
obfs,
nat,
lease_ttl,
route_approval,
})
}
}
impl ClientArgs {
pub fn resolve(self) -> Result<ClientConfig, ConfigError> {
let file = load_file(&self.config)?;
let policy = resolve_policy(&self, &file)?;
let server = self
.server
.or(file.server)
.ok_or(ConfigError::Missing("server"))?;
let (cipher, master_key) =
resolve_crypto(self.cipher.or(file.cipher), self.password.or(file.password))?;
let tun = resolve_tun(
self.tun_name.or(file.tun_name),
self.tun_ip.or(file.tun_ip),
self.tun_netmask.or(file.tun_netmask),
self.peer_ip.or(file.peer_ip),
self.tun_ip6.or(file.tun_ip6),
self.mtu.or(file.mtu),
)?;
let obfs = file.obfs.filter(|s| !s.is_empty() && s != "none");
let keepalive_secs = self
.keepalive_secs
.or(file.keepalive_secs)
.unwrap_or(DEFAULT_KEEPALIVE_SECS);
if keepalive_secs == 0 {
return Err(ConfigError::Invalid {
field: "keepalive_secs",
message: "must be at least 1 second".to_string(),
});
}
let advertise_routes = self
.advertise_routes
.or(file.advertise_routes)
.unwrap_or_default();
validate_advertised(&advertise_routes)?;
let accept_routes = self.accept_routes || file.accept_routes.unwrap_or(false);
Ok(ClientConfig {
server,
cipher,
master_key,
tun,
policy,
obfs,
keepalive: Duration::from_secs(keepalive_secs),
advertise_routes,
accept_routes,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
impl ClientArgs {
fn empty() -> Self {
ClientArgs {
config: None,
server: None,
password: None,
cipher: None,
tun_name: None,
tun_ip: None,
tun_netmask: None,
peer_ip: None,
tun_ip6: None,
mtu: None,
advertise_routes: None,
accept_routes: false,
mode: None,
dns_listen: None,
dns_local: None,
dns_remote: None,
gfwlist: None,
chnroute: None,
geoip: None,
geoip_country: None,
set_dns: false,
no_set_dns: false,
restore_dns: false,
no_prewarm: false,
cache_file: None,
no_cache_persist: false,
keepalive_secs: None,
}
}
}
#[test]
fn cli_overrides_file_and_resolves() {
let args = ServerArgs {
config: None,
listen: Some("0.0.0.0:9000".to_string()),
password: Some("test".to_string()),
cipher: Some("aes-128-gcm".to_string()),
tun_name: Some("utun9".to_string()),
tun_ip: Some(Ipv4Addr::new(10, 9, 0, 1)),
tun_netmask: None,
peer_ip: Some(Ipv4Addr::new(10, 9, 0, 2)),
tun_ip6: None,
mtu: None,
nat: false,
lease_ttl_secs: None,
approve_routes: None,
auto_approve_routes: false,
};
let cfg = args.resolve().expect("resolve");
assert_eq!(cfg.listen, "0.0.0.0:9000");
assert_eq!(cfg.cipher, Cipher::Aes128Gcm);
assert_eq!(cfg.master_key.len(), 16);
assert_eq!(cfg.tun.netmask, DEFAULT_NETMASK);
assert_eq!(cfg.tun.mtu, DEFAULT_TUN_MTU);
assert_eq!(cfg.tun.name.as_deref(), Some("utun9"));
}
#[test]
fn missing_password_is_an_error() {
let args = ClientArgs {
config: None,
server: Some("host:1".to_string()),
password: None,
cipher: None,
tun_name: None,
tun_ip: Some(Ipv4Addr::new(10, 0, 0, 2)),
tun_netmask: None,
peer_ip: Some(Ipv4Addr::new(10, 0, 0, 1)),
mtu: None,
..ClientArgs::empty()
};
assert!(matches!(
args.resolve(),
Err(ConfigError::Missing("password"))
));
}
#[test]
fn policy_defaults_to_full_and_validates() {
let base = ClientArgs {
config: None,
server: Some("host:1".to_string()),
password: Some("pw".to_string()),
tun_ip: Some(Ipv4Addr::new(10, 0, 0, 2)),
peer_ip: Some(Ipv4Addr::new(10, 0, 0, 1)),
..ClientArgs::empty()
};
let cfg = base.clone().resolve().expect("resolve full");
assert_eq!(cfg.policy.mode, Mode::Full);
assert_eq!(cfg.policy.dns_listen.to_string(), "127.0.0.1:53");
assert!(cfg.policy.set_dns, "set_dns defaults to on");
let mut nd = base.clone();
nd.no_set_dns = true;
assert!(!nd.resolve().unwrap().policy.set_dns);
let mut sd = base.clone();
sd.set_dns = true;
assert!(sd.resolve().unwrap().policy.set_dns);
assert!(!cfg.policy.prewarm.is_empty());
let mut np = base.clone();
np.no_prewarm = true;
assert!(np.resolve().unwrap().policy.prewarm.is_empty());
assert!(cfg.policy.cache_file.is_some());
let mut nc = base.clone();
nc.no_cache_persist = true;
assert!(nc.resolve().unwrap().policy.cache_file.is_none());
let mut g = base.clone();
g.mode = Some("gfwlist".to_string());
assert!(matches!(g.resolve(), Err(ConfigError::Missing(_))));
let mut c = base.clone();
c.mode = Some("chinadns".to_string());
assert!(matches!(c.resolve(), Err(ConfigError::Missing(_))));
let mut cg = base.clone();
cg.mode = Some("chinadns".to_string());
cg.geoip = Some(PathBuf::from("/tmp/GeoLite2-Country.mmdb"));
let cfg = cg.resolve().expect("resolve chinadns+geoip");
assert_eq!(cfg.policy.mode, Mode::ChinaDns);
assert_eq!(cfg.policy.geoip_country, "CN");
let mut d = base.clone();
d.dns_local = Some("1.2.3.4".to_string());
assert_eq!(
d.resolve().unwrap().policy.dns_local.to_string(),
"1.2.3.4:53"
);
let mut m = base;
m.mode = Some("bogus".to_string());
assert!(matches!(m.resolve(), Err(ConfigError::Policy(_))));
}
#[test]
fn keepalive_defaults_overrides_and_validates() {
let base = ClientArgs {
config: None,
server: Some("host:1".to_string()),
password: Some("pw".to_string()),
tun_ip: Some(Ipv4Addr::new(10, 0, 0, 2)),
peer_ip: Some(Ipv4Addr::new(10, 0, 0, 1)),
..ClientArgs::empty()
};
let cfg = base.clone().resolve().expect("resolve default");
assert_eq!(cfg.keepalive, Duration::from_secs(DEFAULT_KEEPALIVE_SECS));
let mut k = base.clone();
k.keepalive_secs = Some(10);
assert_eq!(
k.resolve().unwrap().keepalive,
Duration::from_secs(10),
"CLI override wins"
);
let mut z = base;
z.keepalive_secs = Some(0);
assert!(matches!(
z.resolve(),
Err(ConfigError::Invalid {
field: "keepalive_secs",
..
})
));
}
#[test]
fn bundled_data_fallbacks_match_mode() {
let dir = std::env::temp_dir().join(format!(
"svpn-bundle-test-{}-{:p}",
std::process::id(),
&0u8 as *const u8
));
std::fs::create_dir_all(&dir).expect("create scratch dir");
let gfw = dir.join(DEFAULT_GFWLIST_NAME);
let db = dir.join(DEFAULT_GEOIP_DB_NAME);
std::fs::write(&gfw, b"example.com\n").expect("write dummy gfwlist");
std::fs::write(&db, b"not a real mmdb").expect("write dummy db");
assert_eq!(
bundled_gfwlist(Mode::GfwList, &dir).as_deref(),
Some(gfw.as_path())
);
assert_eq!(
bundled_gfwlist(Mode::ChinaDns, &dir).as_deref(),
Some(gfw.as_path()),
"chinadns must auto-apply a bundled gfwlist (iOS-aligned force-tunnel override)"
);
assert!(bundled_gfwlist(Mode::Full, &dir).is_none());
assert_eq!(
bundled_geoip(Mode::ChinaDns, false, &dir).as_deref(),
Some(db.as_path())
);
assert!(bundled_geoip(Mode::ChinaDns, true, &dir).is_none());
assert!(bundled_geoip(Mode::GfwList, false, &dir).is_none());
assert!(bundled_geoip(Mode::Full, false, &dir).is_none());
let empty = dir.join("empty");
std::fs::create_dir_all(&empty).expect("create empty subdir");
assert!(bundled_gfwlist(Mode::ChinaDns, &empty).is_none());
assert!(bundled_geoip(Mode::ChinaDns, false, &empty).is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn file_config_parses() {
let json = r#"{
"server": "1.2.3.4:8388",
"password": "pw",
"cipher": "aes-256-gcm",
"tun_ip": "10.1.0.2",
"peer_ip": "10.1.0.1"
}"#;
let fc: FileConfig = serde_json::from_str(json).expect("parse");
assert_eq!(fc.server.as_deref(), Some("1.2.3.4:8388"));
assert_eq!(fc.cipher.as_deref(), Some("aes-256-gcm"));
assert_eq!(fc.tun_ip, Some(Ipv4Addr::new(10, 1, 0, 2)));
}
#[test]
fn server_nat_flag_and_default_ttl() {
let args = ServerArgs {
config: None,
listen: Some("0.0.0.0:1".to_string()),
password: Some("pw".to_string()),
cipher: None,
tun_name: None,
tun_ip: Some(Ipv4Addr::new(10, 9, 0, 1)),
tun_netmask: None,
peer_ip: Some(Ipv4Addr::new(10, 9, 0, 2)),
tun_ip6: None,
mtu: None,
nat: true,
lease_ttl_secs: None,
approve_routes: None,
auto_approve_routes: false,
};
let cfg = args.resolve().expect("resolve");
assert!(cfg.nat);
assert_eq!(cfg.lease_ttl, Duration::from_secs(DEFAULT_LEASE_TTL_SECS));
}
#[test]
fn mesh_config_resolves_and_validates() {
let base = ClientArgs {
config: None,
server: Some("host:1".to_string()),
password: Some("pw".to_string()),
tun_ip: Some(Ipv4Addr::new(10, 77, 0, 2)),
peer_ip: Some(Ipv4Addr::new(10, 77, 0, 1)),
..ClientArgs::empty()
};
let cfg = base.clone().resolve().expect("resolve");
assert!(cfg.advertise_routes.is_empty());
assert!(!cfg.accept_routes);
assert!(cfg.tun.ip6.is_none());
let mut m = base.clone();
m.advertise_routes = Some(vec![
"192.168.200.0/24".parse().unwrap(),
"fd42:cafe::/64".parse().unwrap(),
]);
m.accept_routes = true;
m.tun_ip6 = Some("fd07:7::2/64".parse().unwrap());
let cfg = m.resolve().expect("resolve mesh");
assert_eq!(cfg.advertise_routes.len(), 2);
assert!(cfg.accept_routes);
assert_eq!(cfg.tun.ip6.unwrap().to_string(), "fd07:7::2/64");
let mut d = base.clone();
d.advertise_routes = Some(vec!["0.0.0.0/0".parse().unwrap()]);
assert!(matches!(
d.resolve(),
Err(ConfigError::Invalid {
field: "advertise_routes",
..
})
));
let mut o = base;
o.advertise_routes = Some(
(0..=MAX_ROUTES)
.map(|i| format!("10.{}.{}.0/24", i / 256, i % 256).parse().unwrap())
.collect(),
);
assert!(matches!(
o.resolve(),
Err(ConfigError::Invalid {
field: "advertise_routes",
..
})
));
}
#[test]
fn server_mesh_approval_resolves_and_rejects_nat_combo() {
let base = ServerArgs {
config: None,
listen: Some("0.0.0.0:1".to_string()),
password: Some("pw".to_string()),
cipher: None,
tun_name: None,
tun_ip: Some(Ipv4Addr::new(10, 77, 0, 1)),
tun_netmask: None,
peer_ip: Some(Ipv4Addr::new(10, 77, 0, 2)),
tun_ip6: None,
mtu: None,
nat: false,
lease_ttl_secs: None,
approve_routes: None,
auto_approve_routes: false,
};
let cfg = base.clone().resolve().expect("resolve");
assert!(!cfg.route_approval.auto);
assert!(cfg.route_approval.allowlist.is_empty());
let mut a = base.clone();
a.approve_routes = Some(vec!["192.168.0.0/16".parse().unwrap()]);
a.tun_ip6 = Some("fd07:7::1/64".parse().unwrap());
let cfg = a.resolve().expect("resolve approval");
assert_eq!(cfg.route_approval.allowlist.len(), 1);
assert_eq!(cfg.tun.ip6.unwrap().prefix(), 64);
let mut n = base;
n.nat = true;
n.auto_approve_routes = true;
assert!(matches!(
n.resolve(),
Err(ConfigError::Invalid { field: "nat", .. })
));
}
}