use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::path::{Path, PathBuf};
use std::time::Duration;
use clap::Parser;
use ipnetwork::{IpNetwork, Ipv4Network, Ipv6Network};
use serde::{Deserialize, Serialize};
use crate::assign::DEFAULT_ASSIGN_TTL_SECS;
use crate::crypto::Cipher;
use crate::magic::{self, DEFAULT_SUFFIX};
use crate::mesh::{
canonical, AssignReq, NameAdvert, RouteAdvert, RouteApproval, FLAG_WANT_IP6, MAX_ROUTES,
};
use crate::policy::{Mode, PolicyConfig};
use crate::pool::host_range;
use crate::protocol::DEFAULT_TUN_MTU;
use crate::state::default_client_state_path;
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 assign_pool: Option<IpNetwork>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reserved_ips: Option<Vec<Ipv4Addr>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub assign_ttl_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lease_file: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub keepalive_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state_file: Option<String>,
#[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>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hostname: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub magic_dns: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub magic_dns_suffix: Option<String>,
}
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,
pub assign_pool: Option<Ipv4Network>,
pub reserved_ips: Vec<Ipv4Addr>,
pub assign_ttl: Duration,
pub lease_file: Option<PathBuf>,
pub hostname: String,
}
#[derive(Debug, Clone)]
pub struct ClientConfig {
pub server: String,
pub cipher: Cipher,
pub master_key: Vec<u8>,
pub tun: TunConfig,
pub auto_tun: bool,
pub want_ip6: bool,
pub state_file: Option<PathBuf>,
pub policy: PolicyConfig,
pub obfs: Option<String>,
pub keepalive: Duration,
pub advertise_routes: Vec<IpNetwork>,
pub accept_routes: bool,
pub hostname: String,
pub magic_dns: bool,
pub magic_dns_suffix: String,
}
#[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,
#[arg(long = "assign-pool")]
pub assign_pool: Option<IpNetwork>,
#[arg(long = "reserved-ips", value_delimiter = ',')]
pub reserved_ips: Option<Vec<Ipv4Addr>>,
#[arg(long = "assign-ttl-secs")]
pub assign_ttl_secs: Option<u64>,
#[arg(long = "lease-file")]
pub lease_file: Option<String>,
#[arg(long = "hostname")]
pub hostname: Option<String>,
}
#[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>,
#[arg(long = "state-file")]
pub state_file: Option<PathBuf>,
#[arg(long = "hostname")]
pub hostname: Option<String>,
#[arg(long = "magic-dns")]
pub magic_dns: bool,
#[arg(long = "no-magic-dns")]
pub no_magic_dns: bool,
#[arg(long = "magic-dns-suffix")]
pub magic_dns_suffix: Option<String>,
}
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)),
magic_dns: resolve_magic_dns(args, file),
magic_dns_suffix: resolve_magic_suffix(args, file)?,
})
}
fn resolve_magic_dns(args: &ClientArgs, file: &FileConfig) -> bool {
if args.no_magic_dns {
false
} else if args.magic_dns {
true
} else {
file.magic_dns.unwrap_or(true)
}
}
fn resolve_magic_suffix(args: &ClientArgs, file: &FileConfig) -> Result<String, ConfigError> {
let raw = args
.magic_dns_suffix
.clone()
.or_else(|| file.magic_dns_suffix.clone())
.unwrap_or_else(|| DEFAULT_SUFFIX.to_string());
magic::sanitize_suffix(&raw).ok_or_else(|| ConfigError::Invalid {
field: "magic_dns_suffix",
message: "must be a non-empty DNS label".to_string(),
})
}
fn resolve_hostname(cli: Option<String>, file: Option<String>) -> String {
let raw = cli.or(file).unwrap_or_else(magic::os_hostname);
magic::sanitize_hostname(&raw)
}
#[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),
})
}
#[allow(clippy::too_many_arguments)]
fn resolve_client_tun(
name: Option<String>,
ip: Option<Ipv4Addr>,
netmask: Option<Ipv4Addr>,
peer_ip: Option<Ipv4Addr>,
ip6: Option<Ipv6Network>,
mtu: Option<u16>,
) -> Result<(TunConfig, bool, bool), ConfigError> {
let auto_tun = match (ip, peer_ip) {
(None, None) => true,
(Some(_), Some(_)) => false,
_ => {
return Err(ConfigError::Invalid {
field: "tun_ip",
message: "tun_ip and peer_ip must both be set, or both omitted for auto-assign"
.to_string(),
});
}
};
let want_ip6 = auto_tun && ip6.is_none();
Ok((
TunConfig {
name,
ip: ip.unwrap_or(Ipv4Addr::UNSPECIFIED),
netmask: netmask.unwrap_or(DEFAULT_NETMASK),
peer_ip: peer_ip.unwrap_or(Ipv4Addr::UNSPECIFIED),
ip6,
mtu: mtu.unwrap_or(DEFAULT_TUN_MTU),
},
auto_tun,
want_ip6,
))
}
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(())
}
fn default_lease_file(config_path: Option<&Path>) -> PathBuf {
if let Some(cfg) = config_path {
let mut s = cfg.as_os_str().to_os_string();
s.push(".leases.json");
return PathBuf::from(s);
}
#[cfg(windows)]
{
std::env::var_os("PROGRAMDATA")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(r"C:\ProgramData"))
.join("shadowvpn")
.join("leases.json")
}
#[cfg(not(windows))]
{
PathBuf::from("/var/lib/shadowvpn/leases.json")
}
}
fn tun_v4_network(ip: Ipv4Addr, netmask: Ipv4Addr) -> Result<Ipv4Network, ConfigError> {
let mask = u32::from(netmask);
if mask.leading_ones() + mask.trailing_zeros() != 32 {
return Err(ConfigError::Invalid {
field: "tun_netmask",
message: format!("{netmask} is not a contiguous IPv4 netmask"),
});
}
let prefix = mask.leading_ones() as u8;
let network = Ipv4Addr::from(u32::from(ip) & mask);
Ipv4Network::new(network, prefix).map_err(|e| ConfigError::Invalid {
field: "tun_ip",
message: e.to_string(),
})
}
fn validate_assign_pool(
pool: IpNetwork,
tun: Ipv4Network,
server_ip: Ipv4Addr,
reserved: &[Ipv4Addr],
) -> Result<Ipv4Network, ConfigError> {
let IpNetwork::V4(pool) = canonical(pool) else {
return Err(ConfigError::Invalid {
field: "assign_pool",
message: "must be an IPv4 CIDR".to_string(),
});
};
if pool.prefix() < tun.prefix() || !tun.contains(pool.ip()) {
return Err(ConfigError::Invalid {
field: "assign_pool",
message: format!("{pool} is not a subset of the TUN network {tun}"),
});
}
let (start, end) = host_range(pool.network(), pool.mask());
let mut usable = 0usize;
if start <= end {
for host in start..=end {
let addr = Ipv4Addr::from(host);
if addr != server_ip && !reserved.contains(&addr) {
usable += 1;
}
}
}
if usable == 0 {
return Err(ConfigError::Invalid {
field: "assign_pool",
message: format!(
"{pool} has no assignable hosts after excluding the network, \
broadcast, server IP, and reserved addresses"
),
});
}
Ok(pool)
}
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(),
});
}
let extra_reserved = self.reserved_ips.or(file.reserved_ips).unwrap_or_default();
let mut reserved_ips = Vec::with_capacity(extra_reserved.len() + 1);
reserved_ips.push(tun.peer_ip);
for ip in extra_reserved {
if !reserved_ips.contains(&ip) {
reserved_ips.push(ip);
}
}
let assign_pool = match self.assign_pool.or(file.assign_pool) {
Some(pool) => {
let tun_net = tun_v4_network(tun.ip, tun.netmask)?;
Some(validate_assign_pool(pool, tun_net, tun.ip, &reserved_ips)?)
}
None => None,
};
let assign_ttl = Duration::from_secs(
self.assign_ttl_secs
.or(file.assign_ttl_secs)
.unwrap_or(DEFAULT_ASSIGN_TTL_SECS),
);
let lease_file = match self.lease_file.as_deref().or(file.lease_file.as_deref()) {
Some("-") => None,
Some(path) => Some(PathBuf::from(path)),
None => Some(default_lease_file(self.config.as_deref())),
};
let hostname = resolve_hostname(self.hostname, file.hostname);
Ok(ServerConfig {
listen,
cipher,
master_key,
tun,
obfs,
nat,
lease_ttl,
route_approval,
assign_pool,
reserved_ips,
assign_ttl,
lease_file,
hostname,
})
}
}
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, auto_tun, want_ip6) = resolve_client_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);
let state_file = Some(
self.state_file
.or_else(|| file.state_file.map(PathBuf::from))
.unwrap_or_else(|| default_client_state_path(self.config.as_deref(), &server)),
);
let hostname = resolve_hostname(self.hostname, file.hostname);
let magic_dns = policy.magic_dns;
let magic_dns_suffix = policy.magic_dns_suffix.clone();
Ok(ClientConfig {
server,
cipher,
master_key,
tun,
auto_tun,
want_ip6,
state_file,
policy,
obfs,
keepalive: Duration::from_secs(keepalive_secs),
advertise_routes,
accept_routes,
hostname,
magic_dns,
magic_dns_suffix,
})
}
}
impl ClientConfig {
pub fn overlay_cached_assignment(
&mut self,
tun_ip: Ipv4Addr,
netmask: Ipv4Addr,
peer_ip: Ipv4Addr,
tun_ip6: Option<Ipv6Network>,
) {
self.tun.ip = tun_ip;
self.tun.netmask = netmask;
self.tun.peer_ip = peer_ip;
if self.want_ip6 {
self.tun.ip6 = tun_ip6;
}
}
pub fn assign_request(&self, node_id: [u8; 16]) -> Vec<u8> {
AssignReq {
flags: if self.want_ip6 { FLAG_WANT_IP6 } else { 0 },
node_id,
hint_ip4: self.tun.ip,
hint_ip6: self.tun.ip6.map(|n| n.ip()),
}
.encode()
}
pub fn mesh_active(&self) -> bool {
self.accept_routes || !self.advertise_routes.is_empty()
}
pub fn route_advert(&self) -> RouteAdvert {
RouteAdvert {
tunnel_ip: self.tun.ip,
tunnel_ip6: self.tun.ip6.map(|n| n.ip()),
accept_routes: self.accept_routes,
routes: self.advertise_routes.clone(),
}
}
pub fn auto_tick_payloads(&self, node_id: [u8; 16]) -> Vec<Vec<u8>> {
let mut out = vec![self.assign_request(node_id)];
if self.mesh_active() {
out.push(self.route_advert().encode());
}
if self.magic_dns {
out.push(self.name_advert().encode());
}
out
}
pub fn name_advert(&self) -> NameAdvert {
magic::name_advert(
&self.hostname,
self.tun.ip,
self.tun.ip6.map(|n| n.ip()),
self.magic_dns,
)
}
pub fn static_tick_payloads(&self) -> Vec<Vec<u8>> {
let mut out = Vec::new();
if self.mesh_active() {
out.push(self.route_advert().encode());
}
if self.magic_dns {
out.push(self.name_advert().encode());
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
impl ServerArgs {
fn empty() -> Self {
ServerArgs {
config: None,
listen: None,
password: None,
cipher: None,
tun_name: None,
tun_ip: None,
tun_netmask: None,
peer_ip: None,
tun_ip6: None,
mtu: None,
nat: false,
lease_ttl_secs: None,
approve_routes: None,
auto_approve_routes: false,
assign_pool: None,
reserved_ips: None,
assign_ttl_secs: None,
lease_file: None,
hostname: None,
}
}
fn test_base() -> Self {
ServerArgs {
listen: Some("0.0.0.0:1".to_string()),
password: Some("pw".to_string()),
tun_ip: Some(Ipv4Addr::new(10, 9, 0, 1)),
peer_ip: Some(Ipv4Addr::new(10, 9, 0, 2)),
..Self::empty()
}
}
}
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,
state_file: None,
hostname: None,
magic_dns: false,
no_magic_dns: false,
magic_dns_suffix: 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,
assign_pool: None,
reserved_ips: None,
assign_ttl_secs: None,
lease_file: None,
hostname: None,
};
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,
assign_pool: None,
reserved_ips: None,
assign_ttl_secs: None,
lease_file: None,
hostname: None,
};
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,
assign_pool: None,
reserved_ips: None,
assign_ttl_secs: None,
lease_file: None,
hostname: None,
};
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", .. })
));
}
#[test]
fn assign_pool_ipv4_subset_and_defaults() {
let cfg = ServerArgs::test_base().resolve().expect("resolve");
assert!(cfg.assign_pool.is_none());
assert_eq!(cfg.reserved_ips, vec![Ipv4Addr::new(10, 9, 0, 2)]);
assert_eq!(cfg.assign_ttl, Duration::from_secs(DEFAULT_ASSIGN_TTL_SECS));
#[cfg(not(windows))]
assert_eq!(
cfg.lease_file.as_deref(),
Some(Path::new("/var/lib/shadowvpn/leases.json"))
);
#[cfg(windows)]
{
let p = cfg.lease_file.expect("default lease file");
assert_eq!(p.file_name().unwrap(), "leases.json");
assert!(p.to_string_lossy().contains("shadowvpn"));
}
let mut a = ServerArgs::test_base();
a.assign_pool = Some("10.9.0.128/25".parse().unwrap());
a.reserved_ips = Some(vec![Ipv4Addr::new(10, 9, 0, 10)]);
a.assign_ttl_secs = Some(3600);
a.lease_file = Some("/tmp/leases.json".into());
let cfg = a.resolve().expect("valid subset");
assert_eq!(cfg.assign_pool.unwrap().to_string(), "10.9.0.128/25");
assert_eq!(
cfg.reserved_ips,
vec![Ipv4Addr::new(10, 9, 0, 2), Ipv4Addr::new(10, 9, 0, 10)]
);
assert_eq!(cfg.assign_ttl, Duration::from_secs(3600));
assert_eq!(
cfg.lease_file.as_deref(),
Some(Path::new("/tmp/leases.json"))
);
let mut d = ServerArgs::test_base();
d.lease_file = Some("-".into());
assert!(d.resolve().unwrap().lease_file.is_none());
assert_eq!(
default_lease_file(Some(Path::new("/etc/shadowvpn/server.json"))),
PathBuf::from("/etc/shadowvpn/server.json.leases.json")
);
}
#[test]
fn assign_pool_rejects_ipv6_non_subset_and_degenerate() {
let mut v6 = ServerArgs::test_base();
v6.assign_pool = Some("fd07:7::/64".parse().unwrap());
assert!(matches!(
v6.resolve(),
Err(ConfigError::Invalid {
field: "assign_pool",
..
})
));
let mut outside = ServerArgs::test_base();
outside.assign_pool = Some("10.8.0.0/24".parse().unwrap());
assert!(matches!(
outside.resolve(),
Err(ConfigError::Invalid {
field: "assign_pool",
..
})
));
let mut supernet = ServerArgs::test_base();
supernet.assign_pool = Some("10.9.0.0/16".parse().unwrap());
assert!(matches!(
supernet.resolve(),
Err(ConfigError::Invalid {
field: "assign_pool",
..
})
));
let mut empty = ServerArgs::test_base();
empty.assign_pool = Some("10.9.0.0/30".parse().unwrap());
assert!(matches!(
empty.resolve(),
Err(ConfigError::Invalid {
field: "assign_pool",
..
})
));
}
#[test]
fn tun_ip6_prefix_over_96_still_resolves() {
let mut args = ServerArgs::test_base();
args.tun_ip6 = Some("fd07:7::1/128".parse().unwrap());
let cfg = args
.resolve()
.expect("static-only /128 hub must still start");
assert_eq!(cfg.tun.ip6.unwrap().prefix(), 128);
}
#[test]
fn file_config_parses_assign_keys() {
let json = r#"{
"server": "0.0.0.0:8388",
"assign_pool": "10.9.0.128/25",
"reserved_ips": ["10.9.0.10"],
"assign_ttl_secs": 3600,
"lease_file": "-"
}"#;
let fc: FileConfig = serde_json::from_str(json).expect("parse");
assert_eq!(fc.assign_pool.unwrap().to_string(), "10.9.0.128/25");
assert_eq!(fc.reserved_ips.unwrap(), vec![Ipv4Addr::new(10, 9, 0, 10)]);
assert_eq!(fc.assign_ttl_secs, Some(3600));
assert_eq!(fc.lease_file.as_deref(), Some("-"));
}
fn auto_client_base() -> ClientArgs {
ClientArgs {
server: Some("vpn.example.com:8388".to_string()),
password: Some("pw".to_string()),
..ClientArgs::empty()
}
}
#[test]
fn tun_ip_and_peer_ip_both_omitted_is_auto_tun() {
let cfg = auto_client_base().resolve().expect("resolve auto");
assert!(cfg.auto_tun);
assert!(cfg.want_ip6);
assert_eq!(cfg.tun.ip, Ipv4Addr::UNSPECIFIED);
assert_eq!(cfg.tun.peer_ip, Ipv4Addr::UNSPECIFIED);
assert_eq!(cfg.tun.netmask, DEFAULT_NETMASK);
assert!(cfg.tun.ip6.is_none());
assert!(cfg.state_file.is_some());
}
#[test]
fn tun_ip_or_peer_ip_alone_is_an_error() {
let mut ip_only = auto_client_base();
ip_only.tun_ip = Some(Ipv4Addr::new(10, 9, 0, 2));
assert!(matches!(
ip_only.resolve(),
Err(ConfigError::Invalid {
field: "tun_ip",
..
})
));
let mut peer_only = auto_client_base();
peer_only.peer_ip = Some(Ipv4Addr::new(10, 9, 0, 1));
assert!(matches!(
peer_only.resolve(),
Err(ConfigError::Invalid {
field: "tun_ip",
..
})
));
}
#[test]
fn auto_with_static_tun_ip6_clears_want_ip6() {
let mut args = auto_client_base();
args.tun_ip6 = Some("fd07:7::2/64".parse().unwrap());
let cfg = args.resolve().expect("auto + static v6");
assert!(cfg.auto_tun);
assert!(!cfg.want_ip6);
assert_eq!(cfg.tun.ip6.unwrap().to_string(), "fd07:7::2/64");
}
#[test]
fn both_tun_addresses_stay_static() {
let mut args = auto_client_base();
args.tun_ip = Some(Ipv4Addr::new(10, 9, 0, 2));
args.peer_ip = Some(Ipv4Addr::new(10, 9, 0, 1));
let cfg = args.resolve().expect("static");
assert!(!cfg.auto_tun);
assert!(!cfg.want_ip6);
assert_eq!(cfg.tun.ip, Ipv4Addr::new(10, 9, 0, 2));
assert_eq!(cfg.tun.peer_ip, Ipv4Addr::new(10, 9, 0, 1));
}
#[test]
fn cache_overlay_still_sends_flag_want_ip6() {
let mut cfg = auto_client_base().resolve().expect("resolve auto");
assert!(cfg.want_ip6);
cfg.overlay_cached_assignment(
Ipv4Addr::new(10, 9, 0, 37),
DEFAULT_NETMASK,
Ipv4Addr::new(10, 9, 0, 1),
Some("fd07:7::a09:25/64".parse().unwrap()),
);
assert!(cfg.want_ip6);
assert!(cfg.tun.ip6.is_some());
let node_id = [
0xc0, 0xff, 0xee, 0x00, 0x00, 0x00, 0x40, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x01,
];
let bytes = cfg.assign_request(node_id);
assert_eq!(bytes[0], 0x00);
assert_eq!(bytes[1], 0x03);
assert_eq!(bytes[2] & FLAG_WANT_IP6, FLAG_WANT_IP6);
assert_eq!(&bytes[3..19], &node_id);
}
#[test]
fn auto_tick_is_assign_request_not_five_byte_keepalive() {
let mut cfg = auto_client_base().resolve().expect("resolve auto");
cfg.overlay_cached_assignment(
Ipv4Addr::new(10, 9, 0, 37),
DEFAULT_NETMASK,
Ipv4Addr::new(10, 9, 0, 1),
None,
);
let node_id = [0x11u8; 16];
let ticks = cfg.auto_tick_payloads(node_id);
assert_eq!(ticks.len(), 2, "AssignRequest + NameAdvert");
assert!(ticks[0].starts_with(&[0x00, 0x03]));
assert_eq!(&ticks[0][3..19], &node_id);
assert!(ticks[1].starts_with(&[0x00, 0x05]));
assert!(
ticks.iter().all(|p| p.len() != 5),
"auto mode must not send a 5-byte keepalive"
);
cfg.magic_dns = false;
let ticks = cfg.auto_tick_payloads(node_id);
assert_eq!(ticks.len(), 1, "no mesh, no magic → AssignRequest only");
assert!(ticks[0].starts_with(&[0x00, 0x03]));
cfg.accept_routes = true;
cfg.magic_dns = true;
let mesh_ticks = cfg.auto_tick_payloads(node_id);
assert_eq!(mesh_ticks.len(), 3);
assert!(mesh_ticks[0].starts_with(&[0x00, 0x03]));
assert_eq!(&mesh_ticks[0][3..19], &node_id);
assert!(mesh_ticks.iter().all(|p| p.len() != 5));
}
#[test]
fn magic_dns_defaults_and_hostname_sanitize() {
let cfg = auto_client_base().resolve().expect("resolve");
assert!(cfg.magic_dns);
assert_eq!(cfg.magic_dns_suffix, "svpn");
assert!(!cfg.hostname.is_empty());
let mut args = auto_client_base();
args.hostname = Some("My-Laptop.local".into());
args.no_magic_dns = true;
args.magic_dns_suffix = Some("SVPN".into());
let cfg = args.resolve().expect("resolve");
assert_eq!(cfg.hostname, "my-laptop");
assert!(!cfg.magic_dns);
assert_eq!(cfg.magic_dns_suffix, "svpn");
let mut bad = auto_client_base();
bad.magic_dns_suffix = Some(" ".into());
assert!(matches!(
bad.resolve(),
Err(ConfigError::Invalid {
field: "magic_dns_suffix",
..
})
));
}
#[test]
fn state_file_override_and_default() {
let mut args = auto_client_base();
args.state_file = Some(PathBuf::from("/tmp/node.state"));
let cfg = args.resolve().expect("override");
assert_eq!(
cfg.state_file.as_deref(),
Some(Path::new("/tmp/node.state"))
);
let dir = std::env::temp_dir().join(format!(
"svpn-state-cfg-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).expect("scratch dir");
let cfg_path = dir.join("client.json");
std::fs::write(&cfg_path, b"{}").expect("write empty config");
let mut with_cfg = auto_client_base();
with_cfg.config = Some(cfg_path.clone());
let cfg = with_cfg.resolve().expect("default next to config");
let mut expect = cfg_path.into_os_string();
expect.push(".state");
assert_eq!(cfg.state_file.as_deref(), Some(Path::new(&expect)));
let _ = std::fs::remove_dir_all(&dir);
}
}