mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-10-09 11:56:14 -08:00
* fix(service): restart windows service indefinitely on failure The windows service was installed with only the AutoStart start type and no SCM failure actions configured. When the service failed during boot (e.g. network not yet ready, config load error), it reported SERVICE_STOPPED with a non-zero exit code and stayed stopped until started manually, which is reported as no auto-start after reboot in issue #1771. Configure failure actions on install and update when restart is not disabled: - three Restart actions (1s/5s/10s delay) with reset period Never; the SCM repeats the last action once the failure count exceeds the actions array, so restarts are retried indefinitely - set fFailureActionsOnNonCrashFailures so exits that report SERVICE_STOPPED with a non-zero exit code (the error path in win_service_event_loop) are also treated as failures; manual stops still exit with 0 and do not trigger a restart Also exit the service process with a non-zero code after reporting the error status. Without it the process stayed alive after reporting SERVICE_STOPPED and was only counted as failed after the SCM force-killed it, adding roughly 30s of dead time to every retry cycle. This matches the systemd path, which already generates Restart=always with StartLimitIntervalSec=0. The --disable-restart-on-failure option now also clears previously configured failure actions on windows. Verified on a real windows host: crash failures and reported-error failures both restart with the configured 1s/5s/10s cadence indefinitely, manual stops are not restarted, and --disable-restart-on-failure clears the actions. * feat(gui): add start-on-boot menu entry pointing to service mode Issue #1771 reports that users cannot find how to make EasyTier start on boot. Auto-start is provided by service mode, but the GUI offered no entry named after it, so the connection was hard to discover. Add a "Start on Boot" item to the settings menu. It opens the mode dialog with service mode preselected and shows an info message explaining that enabling service mode registers EasyTier as a system service that starts automatically at boot and keeps running in the background. When the dialog is opened with service mode preselected, the mode watcher in ModeSwitcher can run before the default config/log dirs have been resolved, leaving the fields empty and failing validation on save. Fill them from the resolved defaults after mount in that case. Add mode.autostart / mode.autostart_hint strings to the cn/en locales in frontend-lib. * fix(cli): stop collecting the --core-args flag into the service args InstallArgs.core_args was declared without an explicit `long`, so clap treated it as a trailing positional argument instead of a named option. Passing `service install --core-args --daemon ...` therefore collected the literal "--core-args" token into the value, and the installed service was registered with an invalid command line that failed on every start (observed on a real windows host: the binPath contained `easytier-core.exe --core-args --daemon ...`). Declare it as a real option (`long` + `num_args = 1..`) while keeping allow_hyphen_values and the trailing semantics: --core-args must be the last option of install and consumes everything after it. The bare-positional spelling (`service install --daemon`), the only correctly-working form before, now fails with a clear "unexpected argument" error; scripts written against it need to add the --core-args prefix. Add unit tests covering the flag, `=` and mixed forms.
2164 lines
67 KiB
Rust
2164 lines
67 KiB
Rust
use crate::{
|
|
ShellType,
|
|
common::{
|
|
config::{
|
|
ConfigFileControl, ConfigLoader, ConsoleLoggerConfig, EncryptionAlgorithm,
|
|
FileLoggerConfig, LoggingConfigLoader, NetworkIdentity, PeerConfig, PortForwardConfig,
|
|
TomlConfigLoader, VpnPortalClientConfig, VpnPortalConfig, add_proxy_network_to_config,
|
|
load_config_from_file, load_toml_config_from_path, parse_mapped_listener_urls,
|
|
},
|
|
constants::EASYTIER_VERSION,
|
|
log,
|
|
},
|
|
instance::factory::native_cli_instance_manager,
|
|
proto::common::{CompressionAlgoPb, 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 guarden::defer;
|
|
use rust_i18n::t;
|
|
use std::{
|
|
net::{IpAddr, SocketAddr},
|
|
path::PathBuf,
|
|
process::ExitCode,
|
|
sync::{Arc, atomic::AtomicBool},
|
|
};
|
|
use strum::VariantArray;
|
|
use tokio::io::AsyncReadExt;
|
|
|
|
use crate::tunnel::IpScheme;
|
|
#[cfg(feature = "jemalloc-prof")]
|
|
use jemalloc_ctl::{Access as _, AsName as _, epoch, stats};
|
|
|
|
#[cfg(target_os = "windows")]
|
|
windows_service::define_windows_service!(ffi_service_main, win_service_main);
|
|
|
|
fn set_prof_active(_active: bool) {
|
|
#[cfg(feature = "jemalloc-prof")]
|
|
{
|
|
const PROF_ACTIVE: &[u8] = b"prof.active\0";
|
|
let name = PROF_ACTIVE.name();
|
|
name.write(_active).expect("Should succeed to set prof");
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "jemalloc-prof")]
|
|
fn get_dump_profile_path(cur_allocated: usize, suffix: &str) -> String {
|
|
format!(
|
|
"profile-{}-{}.{}",
|
|
cur_allocated,
|
|
chrono::Local::now().format("%Y-%m-%d-%H-%M-%S"),
|
|
suffix
|
|
)
|
|
}
|
|
|
|
fn dump_profile(_cur_allocated: usize) {
|
|
#[cfg(feature = "jemalloc-prof")]
|
|
{
|
|
const PROF_DUMP: &[u8] = b"prof.dump\0";
|
|
static mut PROF_DUMP_FILE_NAME: [u8; 128] = [0; 128];
|
|
let file_name_str = get_dump_profile_path(_cur_allocated, "out");
|
|
// copy file name to PROF_DUMP
|
|
let file_name = file_name_str.as_bytes();
|
|
let len = file_name.len();
|
|
if len > 127 {
|
|
panic!("file name too long");
|
|
}
|
|
unsafe {
|
|
PROF_DUMP_FILE_NAME[..len].copy_from_slice(file_name);
|
|
// set the last byte to 0
|
|
PROF_DUMP_FILE_NAME[len] = 0;
|
|
|
|
let name = PROF_DUMP.name();
|
|
name.write(&PROF_DUMP_FILE_NAME[..len + 1])
|
|
.expect("Should succeed to dump profile");
|
|
println!("dump profile to: {}", file_name_str);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Parser, Debug)]
|
|
#[command(name = "easytier-core", author, version = EASYTIER_VERSION , about, long_about = None)]
|
|
struct Cli {
|
|
#[arg(
|
|
short = 'w',
|
|
long,
|
|
env = "ET_CONFIG_SERVER",
|
|
help = t!("core_clap.config_server").to_string()
|
|
)]
|
|
config_server: Option<String>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "ET_MACHINE_ID",
|
|
help = t!("core_clap.machine_id").to_string()
|
|
)]
|
|
machine_id: Option<String>,
|
|
|
|
#[arg(
|
|
short,
|
|
long,
|
|
env = "ET_CONFIG_FILE",
|
|
value_delimiter = ',',
|
|
help = t!("core_clap.config_file").to_string(),
|
|
num_args = 1..,
|
|
)]
|
|
config_file: Option<Vec<PathBuf>>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "ET_CONFIG_DIR",
|
|
help = t!("core_clap.config_dir").to_string()
|
|
)]
|
|
config_dir: Option<PathBuf>,
|
|
|
|
#[command(flatten)]
|
|
network_options: NetworkOptions,
|
|
|
|
#[command(flatten)]
|
|
logging_options: LoggingOptions,
|
|
|
|
#[command(flatten)]
|
|
rpc_portal_options: RpcPortalOptions,
|
|
|
|
#[clap(long, help = t!("core_clap.generate_completions").to_string())]
|
|
gen_autocomplete: Option<ShellType>,
|
|
|
|
#[clap(long, help = t!("core_clap.check_config").to_string())]
|
|
check_config: bool,
|
|
|
|
#[clap(long, help = t!("core_clap.daemon").to_string())]
|
|
daemon: bool,
|
|
|
|
#[clap(long, help = t!("core_clap.disable_env_parsing").to_string())]
|
|
disable_env_parsing: bool,
|
|
}
|
|
|
|
#[derive(Parser, Debug, Default, PartialEq, Eq)]
|
|
struct NetworkOptions {
|
|
#[arg(
|
|
long,
|
|
env = "ET_NETWORK_NAME",
|
|
help = t!("core_clap.network_name").to_string(),
|
|
)]
|
|
network_name: Option<String>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "ET_NETWORK_SECRET",
|
|
help = t!("core_clap.network_secret").to_string(),
|
|
)]
|
|
network_secret: Option<String>,
|
|
|
|
#[arg(
|
|
short,
|
|
long,
|
|
env = "ET_IPV4",
|
|
help = t!("core_clap.ipv4").to_string()
|
|
)]
|
|
ipv4: Option<String>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "ET_IPV6",
|
|
help = t!("core_clap.ipv6").to_string()
|
|
)]
|
|
ipv6: Option<String>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "ET_IPV6_PUBLIC_ADDR_PROVIDER",
|
|
help = t!("core_clap.ipv6_public_addr_provider").to_string(),
|
|
num_args = 0..=1,
|
|
default_missing_value = "true"
|
|
)]
|
|
ipv6_public_addr_provider: Option<bool>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "ET_IPV6_PUBLIC_ADDR_AUTO",
|
|
help = t!("core_clap.ipv6_public_addr_auto").to_string(),
|
|
num_args = 0..=1,
|
|
default_missing_value = "true"
|
|
)]
|
|
ipv6_public_addr_auto: Option<bool>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "ET_IPV6_PUBLIC_ADDR_PREFIX",
|
|
help = t!("core_clap.ipv6_public_addr_prefix").to_string()
|
|
)]
|
|
ipv6_public_addr_prefix: Option<String>,
|
|
|
|
#[arg(
|
|
short,
|
|
long,
|
|
env = "ET_DHCP",
|
|
help = t!("core_clap.dhcp").to_string(),
|
|
num_args = 0..=1,
|
|
default_missing_value = "true"
|
|
)]
|
|
dhcp: Option<bool>,
|
|
|
|
#[arg(
|
|
short,
|
|
long,
|
|
env = "ET_PEERS",
|
|
value_delimiter = ',',
|
|
help = t!("core_clap.peers").to_string(),
|
|
num_args = 0..
|
|
)]
|
|
peers: Vec<String>,
|
|
|
|
#[arg(
|
|
short,
|
|
long,
|
|
env = "ET_EXTERNAL_NODE",
|
|
help = t!("core_clap.external_node").to_string()
|
|
)]
|
|
external_node: Option<String>,
|
|
|
|
#[arg(
|
|
short = 'n',
|
|
long,
|
|
env = "ET_PROXY_NETWORKS",
|
|
value_delimiter = ',',
|
|
help = t!("core_clap.proxy_networks").to_string()
|
|
)]
|
|
proxy_networks: Vec<String>,
|
|
|
|
#[arg(
|
|
short,
|
|
long,
|
|
env = "ET_LISTENERS",
|
|
value_delimiter = ',',
|
|
help = t!("core_clap.listeners").to_string(),
|
|
num_args = 0..
|
|
)]
|
|
listeners: Vec<String>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "ET_MAPPED_LISTENERS",
|
|
value_delimiter = ',',
|
|
help = t!("core_clap.mapped_listeners").to_string(),
|
|
num_args = 0..
|
|
)]
|
|
mapped_listeners: Vec<String>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "ET_NO_LISTENER",
|
|
help = t!("core_clap.no_listener").to_string(),
|
|
default_value = "false",
|
|
)]
|
|
no_listener: bool,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "ET_HOSTNAME",
|
|
help = t!("core_clap.hostname").to_string()
|
|
)]
|
|
hostname: Option<String>,
|
|
|
|
#[arg(
|
|
short = 'm',
|
|
long,
|
|
env = "ET_INSTANCE_NAME",
|
|
help = t!("core_clap.instance_name").to_string(),
|
|
)]
|
|
instance_name: Option<String>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "ET_VPN_PORTAL",
|
|
help = t!("core_clap.vpn_portal").to_string()
|
|
)]
|
|
vpn_portal: Option<String>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "ET_VPN_PORTAL_PRIVATE_KEY",
|
|
help = t!("core_clap.vpn_portal_private_key").to_string()
|
|
)]
|
|
vpn_portal_private_key: Option<String>,
|
|
|
|
#[arg(
|
|
long = "vpn-portal-client",
|
|
env = "ET_VPN_PORTAL_CLIENT",
|
|
value_delimiter = ',',
|
|
help = t!("core_clap.vpn_portal_client").to_string()
|
|
)]
|
|
vpn_portal_clients: Vec<String>,
|
|
|
|
#[arg(
|
|
long = "vpn-portal-client-group",
|
|
env = "ET_VPN_PORTAL_CLIENT_GROUP",
|
|
value_delimiter = ',',
|
|
help = t!("core_clap.vpn_portal_client_group").to_string()
|
|
)]
|
|
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",
|
|
value_delimiter = ',',
|
|
help = t!("core_clap.exit_nodes").to_string(),
|
|
num_args = 0..
|
|
)]
|
|
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",
|
|
value_delimiter = ',',
|
|
help = t!("core_clap.manual_routes").to_string(),
|
|
num_args = 0..
|
|
)]
|
|
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,
|
|
env = "ET_SOCKS5",
|
|
help = t!("core_clap.socks5").to_string()
|
|
)]
|
|
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",
|
|
value_delimiter = ',',
|
|
help = t!("core_clap.port_forward").to_string(),
|
|
num_args = 1..
|
|
)]
|
|
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 = ',',
|
|
help = t!("core_clap.tcp_whitelist").to_string(),
|
|
num_args = 0..
|
|
)]
|
|
tcp_whitelist: Vec<String>,
|
|
|
|
#[arg(
|
|
long,
|
|
value_delimiter = ',',
|
|
help = t!("core_clap.udp_whitelist").to_string(),
|
|
num_args = 0..
|
|
)]
|
|
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",
|
|
value_delimiter = ',',
|
|
help = t!("core_clap.stun_servers").to_string(),
|
|
num_args = 0..
|
|
)]
|
|
stun_servers: Option<Vec<String>>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "ET_STUN_SERVERS_V6",
|
|
value_delimiter = ',',
|
|
help = t!("core_clap.stun_servers_v6").to_string(),
|
|
num_args = 0..
|
|
)]
|
|
stun_servers_v6: Option<Vec<String>>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "ET_TCP_STUN_SERVERS",
|
|
value_delimiter = ',',
|
|
help = t!("core_clap.tcp_stun_servers").to_string(),
|
|
num_args = 0..
|
|
)]
|
|
tcp_stun_servers: Option<Vec<String>>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "ET_SECURE_MODE",
|
|
help = t!("core_clap.secure_mode").to_string(),
|
|
num_args = 0..=1,
|
|
default_missing_value = "true"
|
|
)]
|
|
secure_mode: Option<bool>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "ET_LOCAL_PRIVATE_KEY",
|
|
help = t!("core_clap.local_private_key").to_string()
|
|
)]
|
|
local_private_key: Option<String>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "ET_LOCAL_PUBLIC_KEY",
|
|
help = t!("core_clap.local_public_key").to_string()
|
|
)]
|
|
local_public_key: Option<String>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "ET_CREDENTIAL",
|
|
help = t!("core_clap.credential").to_string()
|
|
)]
|
|
credential: Option<String>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "ET_CREDENTIAL_FILE",
|
|
help = t!("core_clap.credential_file").to_string()
|
|
)]
|
|
credential_file: Option<PathBuf>,
|
|
}
|
|
|
|
#[derive(Parser, Debug)]
|
|
struct LoggingOptions {
|
|
#[arg(
|
|
long,
|
|
env = "ET_CONSOLE_LOG_LEVEL",
|
|
help = t!("core_clap.console_log_level").to_string()
|
|
)]
|
|
console_log_level: Option<String>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "ET_FILE_LOG_LEVEL",
|
|
help = t!("core_clap.file_log_level").to_string()
|
|
)]
|
|
file_log_level: Option<String>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "ET_FILE_LOG_DIR",
|
|
help = t!("core_clap.file_log_dir").to_string()
|
|
)]
|
|
file_log_dir: Option<String>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "ET_FILE_LOG_SIZE",
|
|
help = t!("core_clap.file_log_size_mb").to_string()
|
|
)]
|
|
file_log_size: Option<u64>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "ET_FILE_LOG_COUNT",
|
|
help = t!("core_clap.file_log_count").to_string()
|
|
)]
|
|
file_log_count: Option<usize>,
|
|
}
|
|
|
|
#[derive(Parser, Debug)]
|
|
struct RpcPortalOptions {
|
|
#[arg(
|
|
short,
|
|
long,
|
|
env = "ET_RPC_PORTAL",
|
|
help = t!("core_clap.rpc_portal").to_string(),
|
|
)]
|
|
rpc_portal: Option<String>,
|
|
|
|
#[arg(
|
|
long,
|
|
env = "ET_RPC_PORTAL_WHITELIST",
|
|
value_delimiter = ',',
|
|
help = t!("core_clap.rpc_portal_whitelist").to_string(),
|
|
)]
|
|
rpc_portal_whitelist: Option<Vec<IpCidr>>,
|
|
}
|
|
|
|
impl Cli {
|
|
fn gen_listeners(addr: SocketAddr) -> impl Iterator<Item = String> {
|
|
let dynamic = addr.port() == 0;
|
|
IpScheme::VARIANTS.iter().map(move |proto| {
|
|
let mut addr = addr;
|
|
if !dynamic {
|
|
addr.set_port(addr.port() + proto.port_offset());
|
|
}
|
|
format!("{}://{}", proto, addr)
|
|
})
|
|
}
|
|
|
|
fn parse_listeners(no_listener: bool, listeners: Vec<String>) -> anyhow::Result<Vec<String>> {
|
|
if no_listener || listeners.is_empty() {
|
|
return Ok(vec![]);
|
|
}
|
|
|
|
let mut parsed = vec![];
|
|
|
|
for l in listeners.into_iter() {
|
|
if let Ok(port) = l.parse::<u16>() {
|
|
parsed.extend(Self::gen_listeners(SocketAddr::new(
|
|
"0.0.0.0".parse()?,
|
|
port,
|
|
)));
|
|
continue;
|
|
}
|
|
|
|
if let Ok(ip) = l.trim_matches(|c| c == '[' || c == ']').parse::<IpAddr>() {
|
|
parsed.extend(Self::gen_listeners(SocketAddr::new(ip, 11010)));
|
|
continue;
|
|
}
|
|
|
|
if let Ok(addr) = l.parse::<SocketAddr>() {
|
|
parsed.extend(Self::gen_listeners(addr));
|
|
continue;
|
|
}
|
|
|
|
let (scheme, rest) = l.split_once(':').unwrap_or((&l, ""));
|
|
let Ok(scheme) = scheme.parse::<IpScheme>() else {
|
|
anyhow::bail!("invalid listener: {}", l);
|
|
};
|
|
|
|
if rest.is_empty() {
|
|
parsed.push(format!(
|
|
"{}://0.0.0.0:{}",
|
|
scheme,
|
|
11010 + scheme.port_offset()
|
|
));
|
|
continue;
|
|
}
|
|
|
|
if let Ok(port) = rest.parse::<u16>() {
|
|
parsed.push(format!("{}://0.0.0.0:{}", scheme, port));
|
|
continue;
|
|
}
|
|
|
|
if !l.parse::<url::Url>()?.has_authority() {
|
|
anyhow::bail!("invalid listener: {}", l);
|
|
}
|
|
parsed.push(l);
|
|
}
|
|
|
|
Ok(parsed)
|
|
}
|
|
}
|
|
|
|
impl NetworkOptions {
|
|
fn parse_vpn_portal_listener(value: &str) -> anyhow::Result<SocketAddr> {
|
|
let url: url::Url = value
|
|
.parse()
|
|
.with_context(|| format!("failed to parse vpn portal url: {value}"))?;
|
|
if url.scheme() != "wg" {
|
|
anyhow::bail!("vpn portal URL must use the wg scheme: {value}");
|
|
}
|
|
if !url.path().is_empty() {
|
|
anyhow::bail!(
|
|
"legacy VPN portal CIDR paths are no longer supported; use wg://host:port and configure --vpn-portal-client NAME=CIDR"
|
|
);
|
|
}
|
|
if !url.username().is_empty()
|
|
|| url.password().is_some()
|
|
|| url.query().is_some()
|
|
|| url.fragment().is_some()
|
|
{
|
|
anyhow::bail!("vpn portal URL must have the form wg://host:port");
|
|
}
|
|
|
|
let host: IpAddr = url
|
|
.host_str()
|
|
.ok_or_else(|| anyhow::anyhow!("vpn portal url missing host"))?
|
|
.parse()
|
|
.with_context(|| "vpn portal listener host must be an IP address")?;
|
|
let port = url
|
|
.port()
|
|
.ok_or_else(|| anyhow::anyhow!("vpn portal url missing port"))?;
|
|
Ok(SocketAddr::new(host, port))
|
|
}
|
|
|
|
fn parse_vpn_portal_clients(&self) -> anyhow::Result<Vec<VpnPortalClientConfig>> {
|
|
let mut clients = self
|
|
.vpn_portal_clients
|
|
.iter()
|
|
.map(|value| {
|
|
let (name, virtual_ip) = value.split_once('=').ok_or_else(|| {
|
|
anyhow::anyhow!("invalid vpn portal client {value:?}; expected NAME=CIDR")
|
|
})?;
|
|
if name.is_empty() {
|
|
anyhow::bail!("vpn portal client name cannot be empty");
|
|
}
|
|
if !virtual_ip.contains('/') {
|
|
anyhow::bail!(
|
|
"invalid vpn portal client {value:?}; expected NAME=CIDR, for example alice=10.144.0.5/16"
|
|
);
|
|
}
|
|
Ok(VpnPortalClientConfig {
|
|
name: name.to_owned(),
|
|
virtual_ip: virtual_ip.parse().with_context(|| {
|
|
format!("invalid virtual CIDR for vpn portal client {name}: {virtual_ip}")
|
|
})?,
|
|
groups: Vec::new(),
|
|
})
|
|
})
|
|
.collect::<anyhow::Result<Vec<_>>>()?;
|
|
|
|
for value in &self.vpn_portal_client_groups {
|
|
let (name, group) = value.split_once('=').ok_or_else(|| {
|
|
anyhow::anyhow!("invalid vpn portal client group {value:?}; expected NAME=GROUP")
|
|
})?;
|
|
if name.is_empty() || group.is_empty() {
|
|
anyhow::bail!("vpn portal client group name and group cannot be empty");
|
|
}
|
|
let client = clients
|
|
.iter_mut()
|
|
.find(|client| client.name == name)
|
|
.ok_or_else(|| {
|
|
anyhow::anyhow!("vpn portal client group references unknown CLI client: {name}")
|
|
})?;
|
|
client.groups.push(group.to_owned());
|
|
}
|
|
|
|
Ok(clients)
|
|
}
|
|
|
|
fn can_merge(
|
|
&self,
|
|
cfg: &TomlConfigLoader,
|
|
source: ConfigFileSource,
|
|
explicit_config_file_count: usize,
|
|
config_dir_file_count: usize,
|
|
) -> bool {
|
|
if (*self) == NetworkOptions::default() {
|
|
return false;
|
|
}
|
|
|
|
if source == ConfigFileSource::CliConfigFile
|
|
&& explicit_config_file_count == 1
|
|
&& config_dir_file_count == 0
|
|
{
|
|
return true;
|
|
}
|
|
|
|
let Some(network_name) = &self.network_name else {
|
|
return false;
|
|
};
|
|
|
|
if source == ConfigFileSource::ConfigDir {
|
|
return cfg.get_network_identity().network_name == *network_name;
|
|
}
|
|
|
|
cfg.get_network_identity().network_name == *network_name
|
|
}
|
|
|
|
fn merge_into(&self, cfg: &TomlConfigLoader) -> anyhow::Result<()> {
|
|
if self.hostname.is_some() {
|
|
cfg.set_hostname(self.hostname.clone());
|
|
}
|
|
|
|
let old_ns = cfg.get_network_identity();
|
|
let network_name = self
|
|
.network_name
|
|
.clone()
|
|
.unwrap_or_else(|| old_ns.network_name.clone());
|
|
|
|
if self.credential.is_some() {
|
|
// Credential mode: no network_secret, authenticate via credential keypair
|
|
cfg.set_network_identity(NetworkIdentity::new_credential(network_name));
|
|
} else if let Some(network_secret) = &self.network_secret {
|
|
cfg.set_network_identity(NetworkIdentity::new(network_name, network_secret.clone()));
|
|
} else if let Some(network_secret) = old_ns.network_secret {
|
|
cfg.set_network_identity(NetworkIdentity::new(network_name, network_secret));
|
|
} else {
|
|
cfg.set_network_identity(NetworkIdentity::new_credential(network_name));
|
|
}
|
|
|
|
if let Some(dhcp) = self.dhcp {
|
|
cfg.set_dhcp(dhcp);
|
|
}
|
|
|
|
if let Some(ipv4) = &self.ipv4 {
|
|
cfg.set_ipv4(Some(ipv4.parse().with_context(|| {
|
|
format!("failed to parse ipv4 address: {}", ipv4)
|
|
})?))
|
|
}
|
|
|
|
if let Some(ipv6) = &self.ipv6 {
|
|
cfg.set_ipv6(Some(ipv6.parse().with_context(|| {
|
|
format!("failed to parse ipv6 address: {}", ipv6)
|
|
})?))
|
|
}
|
|
|
|
if let Some(enabled) = self.ipv6_public_addr_provider {
|
|
cfg.set_ipv6_public_addr_provider(enabled);
|
|
}
|
|
|
|
if let Some(enabled) = self.ipv6_public_addr_auto {
|
|
cfg.set_ipv6_public_addr_auto(enabled);
|
|
}
|
|
|
|
if let Some(prefix) = &self.ipv6_public_addr_prefix {
|
|
cfg.set_ipv6_public_addr_prefix(Some(prefix.parse().with_context(|| {
|
|
format!("failed to parse ipv6 public address prefix: {}", prefix)
|
|
})?));
|
|
}
|
|
|
|
if !self.peers.is_empty() {
|
|
let mut peers = cfg.get_peers();
|
|
peers.reserve(peers.len() + self.peers.len());
|
|
for p in &self.peers {
|
|
peers.push(PeerConfig {
|
|
uri: p
|
|
.parse()
|
|
.with_context(|| format!("failed to parse peer uri: {}", p))?,
|
|
peer_public_key: None,
|
|
});
|
|
}
|
|
cfg.set_peers(peers);
|
|
}
|
|
|
|
if self.no_listener || !self.listeners.is_empty() {
|
|
cfg.set_listeners(
|
|
Cli::parse_listeners(self.no_listener, self.listeners.clone())
|
|
.with_context(|| format!("failed to parse listeners: {:?}", self.listeners))?
|
|
.into_iter()
|
|
.map(|s| s.parse().unwrap())
|
|
.collect(),
|
|
);
|
|
} else if cfg.get_listeners().is_none() {
|
|
cfg.set_listeners(
|
|
Cli::parse_listeners(false, vec!["11010".to_string()])?
|
|
.into_iter()
|
|
.map(|s| s.parse().unwrap())
|
|
.collect(),
|
|
);
|
|
}
|
|
|
|
if !self.mapped_listeners.is_empty() {
|
|
cfg.set_mapped_listeners(Some(parse_mapped_listener_urls(&self.mapped_listeners)?));
|
|
}
|
|
|
|
for n in self.proxy_networks.iter() {
|
|
add_proxy_network_to_config(n, cfg)?;
|
|
}
|
|
|
|
if let Some(external_nodes) = self.external_node.as_ref() {
|
|
let mut old_peers = cfg.get_peers();
|
|
old_peers.push(PeerConfig {
|
|
uri: external_nodes.parse().with_context(|| {
|
|
format!("failed to parse external node uri: {}", external_nodes)
|
|
})?,
|
|
peer_public_key: None,
|
|
});
|
|
cfg.set_peers(old_peers);
|
|
}
|
|
|
|
if let Some(inst_name) = &self.instance_name {
|
|
cfg.set_inst_name(inst_name.clone());
|
|
}
|
|
|
|
let has_vpn_portal_overrides = self.vpn_portal.is_some()
|
|
|| self.vpn_portal_private_key.is_some()
|
|
|| !self.vpn_portal_clients.is_empty()
|
|
|| !self.vpn_portal_client_groups.is_empty();
|
|
if has_vpn_portal_overrides {
|
|
if self.vpn_portal_clients.is_empty() && !self.vpn_portal_client_groups.is_empty() {
|
|
anyhow::bail!(
|
|
"--vpn-portal-client-group requires at least one --vpn-portal-client"
|
|
);
|
|
}
|
|
|
|
let existing = cfg.get_vpn_portal_config();
|
|
let wireguard_listen = match self.vpn_portal.as_deref() {
|
|
Some(value) => Self::parse_vpn_portal_listener(value)?,
|
|
None => existing
|
|
.as_ref()
|
|
.map(|portal| portal.wireguard_listen)
|
|
.ok_or_else(|| {
|
|
anyhow::anyhow!("--vpn-portal is required when no vpn_portal_config exists")
|
|
})?,
|
|
};
|
|
let wireguard_private_key = self
|
|
.vpn_portal_private_key
|
|
.clone()
|
|
.or_else(|| existing.as_ref()?.wireguard_private_key.clone());
|
|
let clients = if self.vpn_portal_clients.is_empty() {
|
|
existing.map_or_else(Vec::new, |portal| portal.clients)
|
|
} else {
|
|
self.parse_vpn_portal_clients()?
|
|
};
|
|
|
|
cfg.set_vpn_portal_config(VpnPortalConfig {
|
|
wireguard_listen,
|
|
wireguard_private_key,
|
|
clients,
|
|
});
|
|
}
|
|
|
|
if let Some(manual_routes) = self.manual_routes.as_ref() {
|
|
let mut routes = Vec::<cidr::Ipv4Cidr>::with_capacity(manual_routes.len());
|
|
for r in manual_routes {
|
|
routes.push(
|
|
r.parse()
|
|
.with_context(|| format!("failed to parse route: {}", r))?,
|
|
);
|
|
}
|
|
cfg.set_routes(Some(routes));
|
|
}
|
|
|
|
#[cfg(feature = "socks5")]
|
|
if let Some(socks5_proxy) = self.socks5 {
|
|
cfg.set_socks5_portal(Some(
|
|
format!("socks5://0.0.0.0:{}", socks5_proxy)
|
|
.parse()
|
|
.unwrap(),
|
|
));
|
|
}
|
|
|
|
for port_forward in self.port_forward.iter() {
|
|
let example_str = ", example: udp://0.0.0.0:12345/10.126.126.1:12345";
|
|
|
|
let bind_addr = format!(
|
|
"{}:{}",
|
|
port_forward.host_str().expect("local bind host is missing"),
|
|
port_forward.port().expect("local bind port is missing")
|
|
)
|
|
.parse()
|
|
.unwrap_or_else(|_| panic!("failed to parse local bind addr {}", example_str));
|
|
|
|
let dst_addr = port_forward
|
|
.path_segments()
|
|
.unwrap_or_else(|| panic!("remote destination addr is missing {}", example_str))
|
|
.next()
|
|
.unwrap_or_else(|| panic!("remote destination addr is missing {}", example_str))
|
|
.to_string()
|
|
.parse()
|
|
.unwrap_or_else(|_| {
|
|
panic!("failed to parse remote destination addr {}", example_str)
|
|
});
|
|
|
|
let port_forward_item = PortForwardConfig {
|
|
bind_addr,
|
|
dst_addr,
|
|
proto: port_forward.scheme().to_string(),
|
|
};
|
|
|
|
let mut old = cfg.get_port_forwards();
|
|
old.push(port_forward_item);
|
|
cfg.set_port_forwards(old);
|
|
}
|
|
|
|
if let Some(ref credential_file) = self.credential_file {
|
|
cfg.set_credential_file(Some(credential_file.clone()));
|
|
}
|
|
|
|
if let Some(ref credential_secret) = self.credential {
|
|
// --credential implies --secure-mode and sets the credential private key
|
|
let c = SecureModeConfig {
|
|
enabled: true,
|
|
local_private_key: Some(credential_secret.clone()),
|
|
local_public_key: None,
|
|
};
|
|
cfg.set_secure_mode(Some(normalize_secure_mode_config(c)?));
|
|
} else if let Some(secure_mode) = self.secure_mode
|
|
&& secure_mode
|
|
{
|
|
// CLI key options replace the file's [secure_mode] keypair as a unit;
|
|
// without them the keys already loaded from the config file win.
|
|
let cli_private_key = self.local_private_key.clone().filter(|k| !k.is_empty());
|
|
let cli_public_key = self.local_public_key.clone().filter(|k| !k.is_empty());
|
|
let (local_private_key, local_public_key) =
|
|
if cli_private_key.is_some() || cli_public_key.is_some() {
|
|
(cli_private_key, cli_public_key)
|
|
} else {
|
|
cfg.get_secure_mode()
|
|
.map_or((None, None), |c| (c.local_private_key, c.local_public_key))
|
|
};
|
|
let c = SecureModeConfig {
|
|
enabled: secure_mode,
|
|
local_private_key,
|
|
local_public_key,
|
|
};
|
|
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);
|
|
|
|
if !self.exit_nodes.is_empty() {
|
|
cfg.set_exit_nodes(self.exit_nodes.clone());
|
|
}
|
|
|
|
let mut old_tcp_whitelist = cfg.get_tcp_whitelist();
|
|
old_tcp_whitelist.extend(self.tcp_whitelist.clone());
|
|
cfg.set_tcp_whitelist(old_tcp_whitelist);
|
|
|
|
let mut old_udp_whitelist = cfg.get_udp_whitelist();
|
|
old_udp_whitelist.extend(self.udp_whitelist.clone());
|
|
cfg.set_udp_whitelist(old_udp_whitelist);
|
|
|
|
if let Some(stun_servers) = &self.stun_servers {
|
|
if stun_servers.is_empty() {
|
|
cfg.set_stun_servers(Some(Vec::new()));
|
|
} else {
|
|
let mut old_stun_servers = cfg.get_stun_servers().unwrap_or_default();
|
|
old_stun_servers.extend(stun_servers.iter().cloned());
|
|
cfg.set_stun_servers(Some(old_stun_servers));
|
|
}
|
|
}
|
|
|
|
if let Some(stun_servers_v6) = &self.stun_servers_v6 {
|
|
if stun_servers_v6.is_empty() {
|
|
cfg.set_stun_servers_v6(Some(Vec::new()));
|
|
} else {
|
|
let mut old_stun_servers_v6 = cfg.get_stun_servers_v6().unwrap_or_default();
|
|
old_stun_servers_v6.extend(stun_servers_v6.iter().cloned());
|
|
cfg.set_stun_servers_v6(Some(old_stun_servers_v6));
|
|
}
|
|
}
|
|
|
|
if let Some(tcp_stun_servers) = &self.tcp_stun_servers {
|
|
if tcp_stun_servers.is_empty() {
|
|
cfg.set_tcp_stun_servers(Some(Vec::new()));
|
|
} else {
|
|
let mut old_tcp_stun_servers = cfg.get_tcp_stun_servers().unwrap_or_default();
|
|
old_tcp_stun_servers.extend(tcp_stun_servers.iter().cloned());
|
|
cfg.set_tcp_stun_servers(Some(old_tcp_stun_servers));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum ConfigFileSource {
|
|
CliConfigFile,
|
|
ConfigDir,
|
|
}
|
|
|
|
impl LoggingConfigLoader for &LoggingOptions {
|
|
fn get_console_logger_config(&self) -> ConsoleLoggerConfig {
|
|
ConsoleLoggerConfig {
|
|
level: self.console_log_level.clone(),
|
|
}
|
|
}
|
|
|
|
fn get_file_logger_config(&self) -> FileLoggerConfig {
|
|
FileLoggerConfig {
|
|
level: self.file_log_level.clone(),
|
|
dir: self.file_log_dir.clone(),
|
|
file: None,
|
|
size_mb: self.file_log_size,
|
|
count: self.file_log_count,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn win_service_set_work_dir(service_name: &std::ffi::OsString) -> anyhow::Result<()> {
|
|
use crate::common::constants::WIN_SERVICE_WORK_DIR_REG_KEY;
|
|
use winreg::{RegKey, enums::*};
|
|
|
|
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
|
|
let key = hklm.open_subkey_with_flags(WIN_SERVICE_WORK_DIR_REG_KEY, KEY_READ)?;
|
|
let dir_pat_str = key.get_value::<std::ffi::OsString, _>(service_name)?;
|
|
let dir_path = std::fs::canonicalize(dir_pat_str)?;
|
|
|
|
std::env::set_current_dir(dir_path)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn win_service_event_loop(
|
|
stop_notify: std::sync::Arc<tokio::sync::Notify>,
|
|
cli: Cli,
|
|
status_handle: windows_service::service_control_handler::ServiceStatusHandle,
|
|
) {
|
|
use std::time::Duration;
|
|
use tokio::runtime::Runtime;
|
|
use windows_service::service::*;
|
|
|
|
let normal_status = ServiceStatus {
|
|
service_type: ServiceType::OWN_PROCESS,
|
|
current_state: ServiceState::Running,
|
|
controls_accepted: ServiceControlAccept::STOP,
|
|
exit_code: ServiceExitCode::Win32(0),
|
|
checkpoint: 0,
|
|
wait_hint: Duration::default(),
|
|
process_id: None,
|
|
};
|
|
let error_status = ServiceStatus {
|
|
service_type: ServiceType::OWN_PROCESS,
|
|
current_state: ServiceState::Stopped,
|
|
controls_accepted: ServiceControlAccept::empty(),
|
|
exit_code: ServiceExitCode::ServiceSpecific(1u32),
|
|
checkpoint: 0,
|
|
wait_hint: Duration::default(),
|
|
process_id: None,
|
|
};
|
|
|
|
std::thread::spawn(move || {
|
|
let rt = Runtime::new().unwrap();
|
|
rt.block_on(async move {
|
|
tokio::select! {
|
|
res = run_main(cli) => {
|
|
match res {
|
|
Ok(_) => {
|
|
status_handle.set_service_status(normal_status).unwrap();
|
|
std::process::exit(0);
|
|
}
|
|
Err(error) => {
|
|
status_handle.set_service_status(error_status).unwrap();
|
|
log::error!(?error);
|
|
// exit with non-zero code so the SCM treats this as
|
|
// a non-crash failure and applies the configured
|
|
// failure actions; staying alive would leave a
|
|
// zombie process until the SCM force-kills it.
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
},
|
|
_ = stop_notify.notified() => {
|
|
_ = status_handle.set_service_status(normal_status);
|
|
std::process::exit(0);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
fn parse_cli() -> Cli {
|
|
let mut cli = Cli::parse();
|
|
// 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());
|
|
}
|
|
if let Some(stun_servers_v6) = &mut cli.network_options.stun_servers_v6 {
|
|
stun_servers_v6.retain(|s| !s.trim().is_empty());
|
|
}
|
|
if let Some(tcp_stun_servers) = &mut cli.network_options.tcp_stun_servers {
|
|
tcp_stun_servers.retain(|s| !s.trim().is_empty());
|
|
}
|
|
cli
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn win_service_main(arg: Vec<std::ffi::OsString>) {
|
|
use std::{sync::Arc, time::Duration};
|
|
use tokio::sync::Notify;
|
|
use windows_service::{service::*, service_control_handler::*};
|
|
|
|
_ = win_service_set_work_dir(&arg[0]);
|
|
|
|
let cli = parse_cli();
|
|
|
|
let stop_notify_send = Arc::new(Notify::new());
|
|
let stop_notify_recv = Arc::clone(&stop_notify_send);
|
|
let event_handler = move |control_event| -> ServiceControlHandlerResult {
|
|
match control_event {
|
|
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
|
|
ServiceControl::Stop => {
|
|
stop_notify_send.notify_one();
|
|
ServiceControlHandlerResult::NoError
|
|
}
|
|
_ => ServiceControlHandlerResult::NotImplemented,
|
|
}
|
|
};
|
|
let status_handle = register(String::new(), event_handler).expect("register service fail");
|
|
let next_status = ServiceStatus {
|
|
service_type: ServiceType::OWN_PROCESS,
|
|
current_state: ServiceState::Running,
|
|
controls_accepted: ServiceControlAccept::STOP,
|
|
exit_code: ServiceExitCode::Win32(0),
|
|
checkpoint: 0,
|
|
wait_hint: Duration::default(),
|
|
process_id: None,
|
|
};
|
|
status_handle
|
|
.set_service_status(next_status)
|
|
.expect("set service status fail");
|
|
|
|
win_service_event_loop(stop_notify_recv, cli, status_handle);
|
|
}
|
|
|
|
async fn run_main(cli: Cli) -> anyhow::Result<()> {
|
|
defer!(dump_profile(0););
|
|
log::init(&cli.logging_options, true)?;
|
|
|
|
let manager = Arc::new(native_cli_instance_manager().with_config_path(cli.config_dir.clone()));
|
|
|
|
let _rpc_server = ApiRpcServer::new(
|
|
cli.rpc_portal_options.rpc_portal,
|
|
cli.rpc_portal_options.rpc_portal_whitelist,
|
|
manager.clone(),
|
|
)?
|
|
.serve()
|
|
.await?;
|
|
|
|
let _web_client = if let Some(config_server_url_s) = cli.config_server.as_ref() {
|
|
let wc = web_client::run_web_client(
|
|
config_server_url_s,
|
|
crate::common::MachineIdOptions {
|
|
explicit_machine_id: cli.machine_id.clone(),
|
|
state_dir: None,
|
|
},
|
|
cli.network_options.hostname.clone(),
|
|
cli.network_options.secure_mode.unwrap_or(false),
|
|
manager.clone(),
|
|
None,
|
|
)
|
|
.await
|
|
.inspect(|_| {
|
|
log::info!(
|
|
server = config_server_url_s,
|
|
"Web client started successfully...",
|
|
);
|
|
|
|
log::info!("Official config website: https://easytier.cn/web");
|
|
})?;
|
|
|
|
Some(wc)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let _daemon_guard = if cli.daemon {
|
|
Some(manager.register_daemon())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let explicit_config_file_count = cli.config_file.as_ref().map_or(0, |files| files.len());
|
|
let mut config_dir_file_count = 0;
|
|
let mut config_files = if let Some(v) = cli.config_file {
|
|
v.iter()
|
|
.cloned()
|
|
.map(|path| (path, ConfigFileSource::CliConfigFile))
|
|
.collect()
|
|
} else {
|
|
vec![]
|
|
};
|
|
if let Some(config_dir) = cli.config_dir.as_ref() {
|
|
if !config_dir.is_dir() {
|
|
anyhow::bail!("config_dir {} is not a directory", config_dir.display());
|
|
}
|
|
|
|
for entry in std::fs::read_dir(config_dir)? {
|
|
let entry = entry?;
|
|
let path = entry.path();
|
|
if !path.is_file() {
|
|
continue;
|
|
}
|
|
let Some(ext) = path.extension() else {
|
|
continue;
|
|
};
|
|
if ext != "toml" {
|
|
continue;
|
|
}
|
|
config_dir_file_count += 1;
|
|
config_files.push((path, ConfigFileSource::ConfigDir));
|
|
}
|
|
}
|
|
let config_file_count = config_files.len();
|
|
let mut crate_cli_network = {
|
|
if cli.daemon {
|
|
false
|
|
} else if config_file_count == 0 && cli.config_server.is_none() {
|
|
true
|
|
} else {
|
|
cli.network_options.network_name.is_some()
|
|
}
|
|
};
|
|
for (config_file, source) in config_files {
|
|
let (cfg, mut control) = load_config_from_file(
|
|
&config_file,
|
|
cli.config_dir.as_ref(),
|
|
cli.disable_env_parsing,
|
|
)
|
|
.await?;
|
|
|
|
if cli.network_options.can_merge(
|
|
&cfg,
|
|
source,
|
|
explicit_config_file_count,
|
|
config_dir_file_count,
|
|
) {
|
|
cli.network_options
|
|
.merge_into(&cfg)
|
|
.with_context(|| format!("failed to merge config from cli: {:?}", config_file))?;
|
|
crate_cli_network = false;
|
|
control.set_read_only(true);
|
|
control.set_no_delete(true);
|
|
}
|
|
|
|
log::info!(
|
|
"\
|
|
Starting easytier from config file {:?}({:?}) with config:\n\
|
|
############### TOML ###############\n\
|
|
{}\n\
|
|
-----------------------------------\n\
|
|
",
|
|
config_file,
|
|
control.permission,
|
|
cfg.dump_redacted()
|
|
);
|
|
manager.run_network_instance(cfg, control)?;
|
|
}
|
|
|
|
if crate_cli_network {
|
|
let cfg = TomlConfigLoader::default();
|
|
cli.network_options
|
|
.merge_into(&cfg)
|
|
.with_context(|| "failed to create config from cli".to_string())?;
|
|
log::info!(
|
|
"\
|
|
Starting easytier from cli with config:\n\
|
|
############### TOML ###############\n\
|
|
{}\n\
|
|
-----------------------------------\n\
|
|
",
|
|
cfg.dump_redacted()
|
|
);
|
|
manager.run_network_instance(cfg, ConfigFileControl::STATIC_CONFIG)?;
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
|
|
#[cfg(unix)]
|
|
let sigterm = sigterm.recv();
|
|
#[cfg(not(unix))]
|
|
let sigterm = std::future::pending::<()>();
|
|
|
|
tokio::select! {
|
|
_ = manager.wait() => {
|
|
let infos = manager.collect_network_infos().await?;
|
|
if infos
|
|
.into_values()
|
|
.filter_map(|info| info.error_msg).next().is_some() {
|
|
return Err(anyhow::anyhow!("some instances stopped with errors"));
|
|
}
|
|
}
|
|
_ = tokio::signal::ctrl_c() => {
|
|
log::info!("ctrl-c received, exiting...");
|
|
}
|
|
|
|
_ = sigterm, if cfg!(unix) => {
|
|
log::warn!("terminate signal received, exiting...");
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn memory_monitor(_force_dump: Arc<AtomicBool>) {
|
|
#[cfg(feature = "jemalloc-prof")]
|
|
{
|
|
let mut last_peak_size = 0;
|
|
let e = epoch::mib().unwrap();
|
|
let allocated_stats = stats::allocated::mib().unwrap();
|
|
|
|
loop {
|
|
e.advance().unwrap();
|
|
let new_heap_size = allocated_stats.read().unwrap();
|
|
|
|
log::debug!("heap size: {} bytes", new_heap_size);
|
|
|
|
// dump every 75MB
|
|
if (last_peak_size > 0
|
|
&& new_heap_size > last_peak_size
|
|
&& new_heap_size - last_peak_size > 10 * 1024 * 1024)
|
|
|| _force_dump.load(std::sync::atomic::Ordering::Relaxed)
|
|
{
|
|
log::debug!(
|
|
"heap size increased: {} bytes",
|
|
new_heap_size - last_peak_size,
|
|
);
|
|
dump_profile(new_heap_size);
|
|
last_peak_size = new_heap_size;
|
|
if _force_dump.load(std::sync::atomic::Ordering::Relaxed) {
|
|
// also dump whole jemalloc stats
|
|
use jemalloc_ctl::stats_print::stats_print;
|
|
let tmp_file = get_dump_profile_path(new_heap_size, "stats");
|
|
let mut file = std::fs::File::create(tmp_file).unwrap();
|
|
let _ = stats_print(&mut file, Default::default());
|
|
_force_dump.store(false, std::sync::atomic::Ordering::Relaxed);
|
|
}
|
|
}
|
|
|
|
if last_peak_size == 0 {
|
|
last_peak_size = new_heap_size;
|
|
}
|
|
|
|
std::thread::sleep(std::time::Duration::from_secs(5));
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn main() -> ExitCode {
|
|
let locale = sys_locale::get_locale().unwrap_or_else(|| String::from("en-US"));
|
|
rust_i18n::set_locale(&locale);
|
|
setup_panic_handler();
|
|
|
|
#[cfg(target_os = "windows")]
|
|
match windows_service::service_dispatcher::start(String::new(), ffi_service_main) {
|
|
Ok(_) => std::thread::park(),
|
|
Err(e) => {
|
|
let should_panic = if let windows_service::Error::Winapi(ref io_error) = e {
|
|
io_error.raw_os_error() != Some(0x427) // ERROR_FAILED_SERVICE_CONTROLLER_CONNECT
|
|
} else {
|
|
true
|
|
};
|
|
|
|
if should_panic {
|
|
panic!("SCM start an error: {}", e);
|
|
}
|
|
}
|
|
};
|
|
|
|
set_prof_active(true);
|
|
// register a signal handler to set force dump when signal usr1 is received
|
|
let force_dump = Arc::new(AtomicBool::new(false));
|
|
#[cfg(all(feature = "jemalloc-prof", not(target_os = "windows")))]
|
|
{
|
|
let force_dump_clone = force_dump.clone();
|
|
let mut sigusr1 =
|
|
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::user_defined1()).unwrap();
|
|
tokio::task::spawn(async move {
|
|
while sigusr1.recv().await.is_some() {
|
|
force_dump_clone.store(true, std::sync::atomic::Ordering::Relaxed);
|
|
}
|
|
});
|
|
}
|
|
let _monitor = std::thread::spawn(move || memory_monitor(force_dump));
|
|
|
|
let cli = parse_cli();
|
|
|
|
if let Some(shell) = cli.gen_autocomplete {
|
|
let mut cmd = Cli::command();
|
|
if let Some(shell) = shell.to_shell() {
|
|
crate::print_completions(shell, &mut cmd, "easytier-core");
|
|
} else {
|
|
// Handle Nushell
|
|
crate::print_nushell_completions(&mut cmd, "easytier-core");
|
|
}
|
|
return ExitCode::SUCCESS;
|
|
}
|
|
|
|
// Verify configurations
|
|
if cli.check_config {
|
|
if let Err(error) = validate_config(&cli).await {
|
|
log::error!(%error, "Config validation failed");
|
|
return ExitCode::FAILURE;
|
|
} else {
|
|
return ExitCode::SUCCESS;
|
|
}
|
|
}
|
|
|
|
let mut ret_code = 0;
|
|
|
|
if let Err(error) = run_main(cli).await {
|
|
log::error!(%error);
|
|
ret_code = 1;
|
|
}
|
|
|
|
log::info!("Stopping easytier...");
|
|
set_prof_active(false);
|
|
|
|
ExitCode::from(ret_code)
|
|
}
|
|
|
|
async fn validate_config(cli: &Cli) -> anyhow::Result<()> {
|
|
// Check if a config file is provided
|
|
let config_files = cli
|
|
.config_file
|
|
.as_ref()
|
|
.ok_or_else(|| anyhow::anyhow!("--config-file is required when using --check-config"))?;
|
|
|
|
for config_file in config_files {
|
|
if config_file == &PathBuf::from("-") {
|
|
let mut stdin = String::new();
|
|
_ = tokio::io::stdin()
|
|
.read_to_string(&mut stdin)
|
|
.await
|
|
.context("failed to read config from stdin")?;
|
|
TomlConfigLoader::new_from_str_with_source("stdin", stdin.as_str())?;
|
|
} else {
|
|
load_toml_config_from_path(config_file)?;
|
|
};
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_parse_listeners() {
|
|
type IpSchemeMap = fn(&IpScheme) -> String;
|
|
|
|
let cases: [(&str, IpSchemeMap); _] = [
|
|
("0", |s| format!("{}://0.0.0.0:0", s)),
|
|
("11010", |s| {
|
|
format!("{}://0.0.0.0:{}", s, 11010 + s.port_offset())
|
|
}),
|
|
("1.1.1.1", |s| {
|
|
format!("{}://1.1.1.1:{}", s, 11010 + s.port_offset())
|
|
}),
|
|
("1.1.1.1:50000", |s| {
|
|
format!("{}://1.1.1.1:{}", s, 50000 + s.port_offset())
|
|
}),
|
|
("[::1]", |s| {
|
|
format!("{}://[::1]:{}", s, 11010 + s.port_offset())
|
|
}),
|
|
("[::1]:50000", |s| {
|
|
format!("{}://[::1]:{}", s, 50000 + s.port_offset())
|
|
}),
|
|
];
|
|
|
|
for (input, output) in cases {
|
|
assert_eq!(
|
|
Cli::parse_listeners(false, vec![input.to_string()]).unwrap(),
|
|
IpScheme::VARIANTS.iter().map(output).collect::<Vec<_>>()
|
|
);
|
|
}
|
|
|
|
let input = cases.iter().map(|(i, _)| i.to_string()).collect::<Vec<_>>();
|
|
let output = cases
|
|
.iter()
|
|
.flat_map(|(_, o)| IpScheme::VARIANTS.iter().map(o))
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(Cli::parse_listeners(false, input).unwrap(), output);
|
|
|
|
let cases: [(IpSchemeMap, IpSchemeMap); _] = [
|
|
(
|
|
|s| format!("{}", s),
|
|
|s| format!("{}://0.0.0.0:{}", s, 11010 + s.port_offset()),
|
|
),
|
|
(
|
|
|s| format!("{}:50000", s),
|
|
|s| format!("{}://0.0.0.0:50000", s),
|
|
),
|
|
(
|
|
|s| format!("{}://1.1.1.1:50000", s),
|
|
|s| format!("{}://1.1.1.1:50000", s),
|
|
),
|
|
];
|
|
|
|
for (input, output) in cases {
|
|
assert_eq!(
|
|
Cli::parse_listeners(
|
|
false,
|
|
IpScheme::VARIANTS.iter().map(input).collect::<Vec<_>>(),
|
|
)
|
|
.unwrap(),
|
|
IpScheme::VARIANTS.iter().map(output).collect::<Vec<_>>()
|
|
);
|
|
}
|
|
|
|
let input = cases
|
|
.iter()
|
|
.flat_map(|(i, _)| IpScheme::VARIANTS.iter().map(i))
|
|
.collect::<Vec<_>>();
|
|
let output = cases
|
|
.iter()
|
|
.flat_map(|(_, o)| IpScheme::VARIANTS.iter().map(o))
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(Cli::parse_listeners(false, input).unwrap(), output);
|
|
|
|
let cases = ["tcp://[::1", "xxx", "tcp:/abc", "tcp:abc"];
|
|
for input in cases {
|
|
assert!(
|
|
Cli::parse_listeners(false, vec![input.to_string()]).is_err(),
|
|
"input: {}",
|
|
input
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_network_options_merge_preserves_credential_identity() {
|
|
let cfg = TomlConfigLoader::new_from_str(
|
|
r#"
|
|
[network_identity]
|
|
network_name = "credential-network"
|
|
network_secret = ""
|
|
|
|
[secure_mode]
|
|
enabled = true
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(cfg.get_network_identity().network_secret, None);
|
|
|
|
NetworkOptions {
|
|
hostname: Some("override-host".to_string()),
|
|
..Default::default()
|
|
}
|
|
.merge_into(&cfg)
|
|
.unwrap();
|
|
|
|
let identity = cfg.get_network_identity();
|
|
assert_eq!(identity.network_name, "credential-network");
|
|
assert_eq!(identity.network_secret, None);
|
|
assert_eq!(identity.network_secret_digest, None);
|
|
assert_eq!(cfg.get_hostname(), "override-host");
|
|
}
|
|
|
|
#[test]
|
|
fn secure_mode_cli_flag_preserves_config_file_keypair() {
|
|
use base64::{Engine as _, prelude::BASE64_STANDARD};
|
|
let private = x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng);
|
|
let cfg = TomlConfigLoader::new_from_str(&format!(
|
|
r#"
|
|
[secure_mode]
|
|
enabled = true
|
|
local_private_key = "{}"
|
|
"#,
|
|
BASE64_STANDARD.encode(private.as_bytes())
|
|
))
|
|
.unwrap();
|
|
let file_keypair = cfg.get_secure_mode().unwrap();
|
|
|
|
NetworkOptions {
|
|
secure_mode: Some(true),
|
|
..Default::default()
|
|
}
|
|
.merge_into(&cfg)
|
|
.unwrap();
|
|
|
|
let merged = cfg.get_secure_mode().unwrap();
|
|
assert!(merged.enabled);
|
|
assert_eq!(merged.local_private_key, file_keypair.local_private_key);
|
|
assert_eq!(merged.local_public_key, file_keypair.local_public_key);
|
|
assert_eq!(merged.private_key().unwrap().as_bytes(), private.as_bytes());
|
|
}
|
|
|
|
#[test]
|
|
fn secure_mode_cli_key_replaces_config_file_keypair() {
|
|
use base64::{Engine as _, prelude::BASE64_STANDARD};
|
|
let cfg = TomlConfigLoader::new_from_str(
|
|
r#"
|
|
[secure_mode]
|
|
enabled = true
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let cli_private = x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng);
|
|
|
|
NetworkOptions {
|
|
secure_mode: Some(true),
|
|
local_private_key: Some(BASE64_STANDARD.encode(cli_private.as_bytes())),
|
|
..Default::default()
|
|
}
|
|
.merge_into(&cfg)
|
|
.unwrap();
|
|
|
|
let merged = cfg.get_secure_mode().unwrap();
|
|
assert_eq!(
|
|
merged.private_key().unwrap().as_bytes(),
|
|
cli_private.as_bytes()
|
|
);
|
|
assert_eq!(
|
|
merged.public_key().unwrap().as_bytes(),
|
|
x25519_dalek::PublicKey::from(&cli_private).as_bytes()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_stun_server_options_clear_existing_config() {
|
|
let cfg = TomlConfigLoader::new_from_str(
|
|
r#"
|
|
stun_servers = ["udp.example.com:3478"]
|
|
stun_servers_v6 = ["v6.example.com:3478"]
|
|
tcp_stun_servers = ["tcp.example.com:3478"]
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
NetworkOptions {
|
|
stun_servers: Some(Vec::new()),
|
|
stun_servers_v6: Some(Vec::new()),
|
|
tcp_stun_servers: Some(Vec::new()),
|
|
..Default::default()
|
|
}
|
|
.merge_into(&cfg)
|
|
.unwrap();
|
|
|
|
assert_eq!(cfg.get_stun_servers(), Some(Vec::new()));
|
|
assert_eq!(cfg.get_stun_servers_v6(), Some(Vec::new()));
|
|
assert_eq!(cfg.get_tcp_stun_servers(), Some(Vec::new()));
|
|
}
|
|
|
|
#[test]
|
|
fn vpn_portal_cli_uses_named_clients_and_preserves_unset_fields() {
|
|
let cfg = TomlConfigLoader::new_from_str(
|
|
r#"
|
|
[vpn_portal_config]
|
|
wireguard_listen = "127.0.0.1:51820"
|
|
wireguard_private_key = "existing-key"
|
|
|
|
[[vpn_portal_config.clients]]
|
|
name = "existing"
|
|
virtual_ip = "10.144.144.9/24"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
NetworkOptions {
|
|
vpn_portal: Some("wg://0.0.0.0:51821".to_owned()),
|
|
..Default::default()
|
|
}
|
|
.merge_into(&cfg)
|
|
.unwrap();
|
|
let preserved = cfg.get_vpn_portal_config().unwrap();
|
|
assert_eq!(preserved.wireguard_listen, "0.0.0.0:51821".parse().unwrap());
|
|
assert_eq!(
|
|
preserved.wireguard_private_key.as_deref(),
|
|
Some("existing-key")
|
|
);
|
|
assert_eq!(preserved.clients[0].name, "existing");
|
|
|
|
NetworkOptions {
|
|
vpn_portal_private_key: Some("replacement-key".to_owned()),
|
|
vpn_portal_clients: vec![
|
|
"alice=10.144.144.10/24".to_owned(),
|
|
"bob=10.144.144.11/24".to_owned(),
|
|
],
|
|
vpn_portal_client_groups: vec!["alice=staff".to_owned(), "alice=dev".to_owned()],
|
|
..Default::default()
|
|
}
|
|
.merge_into(&cfg)
|
|
.unwrap();
|
|
|
|
let replaced = cfg.get_vpn_portal_config().unwrap();
|
|
assert_eq!(replaced.wireguard_listen, "0.0.0.0:51821".parse().unwrap());
|
|
assert_eq!(
|
|
replaced.wireguard_private_key.as_deref(),
|
|
Some("replacement-key")
|
|
);
|
|
assert_eq!(
|
|
replaced
|
|
.clients
|
|
.iter()
|
|
.map(|client| client.name.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec!["alice", "bob"]
|
|
);
|
|
assert_eq!(
|
|
replaced.clients[0].groups,
|
|
vec!["staff".to_owned(), "dev".to_owned()]
|
|
);
|
|
assert!(replaced.clients[1].groups.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn vpn_portal_cli_rejects_legacy_path_and_invalid_group_mapping() {
|
|
let error = NetworkOptions::parse_vpn_portal_listener("wg://0.0.0.0:51820/10.14.14.0/24")
|
|
.unwrap_err()
|
|
.to_string();
|
|
assert!(error.contains("legacy VPN portal CIDR"), "{error}");
|
|
|
|
let cfg = TomlConfigLoader::default();
|
|
let missing_clients = NetworkOptions {
|
|
vpn_portal: Some("wg://0.0.0.0:51820".to_owned()),
|
|
vpn_portal_client_groups: vec!["alice=staff".to_owned()],
|
|
..Default::default()
|
|
}
|
|
.merge_into(&cfg)
|
|
.unwrap_err()
|
|
.to_string();
|
|
assert!(
|
|
missing_clients.contains("requires at least one --vpn-portal-client"),
|
|
"{missing_clients}"
|
|
);
|
|
|
|
let unknown_client = NetworkOptions {
|
|
vpn_portal: Some("wg://0.0.0.0:51820".to_owned()),
|
|
vpn_portal_clients: vec!["alice=10.144.144.10/24".to_owned()],
|
|
vpn_portal_client_groups: vec!["bob=staff".to_owned()],
|
|
..Default::default()
|
|
}
|
|
.merge_into(&TomlConfigLoader::default())
|
|
.unwrap_err()
|
|
.to_string();
|
|
assert!(
|
|
unknown_client.contains("unknown CLI client: bob"),
|
|
"{unknown_client}"
|
|
);
|
|
|
|
let bare_ip = NetworkOptions {
|
|
vpn_portal_clients: vec!["alice=10.144.144.10".to_owned()],
|
|
..Default::default()
|
|
}
|
|
.parse_vpn_portal_clients()
|
|
.unwrap_err()
|
|
.to_string();
|
|
assert!(bare_ip.contains("expected NAME=CIDR"), "{bare_ip}");
|
|
}
|
|
|
|
#[test]
|
|
fn vpn_portal_cli_repeat_flags_use_singular_names() {
|
|
let cli = Cli::try_parse_from([
|
|
"easytier-core",
|
|
"--vpn-portal",
|
|
"wg://0.0.0.0:51820",
|
|
"--vpn-portal-private-key",
|
|
"private-key",
|
|
"--vpn-portal-client",
|
|
"alice=10.144.144.10",
|
|
"--vpn-portal-client",
|
|
"bob=10.144.144.11",
|
|
"--vpn-portal-client-group",
|
|
"alice=staff",
|
|
])
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
cli.network_options.vpn_portal_clients,
|
|
vec![
|
|
"alice=10.144.144.10".to_owned(),
|
|
"bob=10.144.144.11".to_owned()
|
|
]
|
|
);
|
|
assert_eq!(
|
|
cli.network_options.vpn_portal_client_groups,
|
|
vec!["alice=staff".to_owned()]
|
|
);
|
|
assert_eq!(
|
|
cli.network_options.vpn_portal_private_key.as_deref(),
|
|
Some("private-key")
|
|
);
|
|
}
|
|
}
|