mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-10-11 05:26:14 -08:00
Persist central network intent and compile complete per-device configs with secure credentials, ACL policy, and dedicated Gateway runtimes. Expose network, credential, device registry, and runtime observation APIs. Treat central management and external Console as alternative consumers of the upstream ClientManager. Register central devices through a local webhook handler and publish through existing runtime reconciliation. Serialize public mutations with enrollment, reject direct credential changes to managed instances, and retain REST revision invalidation. Keep Gateway lifecycle publication in the central service and remove obsolete incremental result bookkeeping. Restore persisted networks and retire orphan runtimes through the same serialized publisher. Add Core and protocol support for Gateway and WireGuard management, including GUI bindings and serialized GUI config writes. Bootstrap IPv4 for DHCP-only networks and retain assigned addresses without peer IPv4. Cover intent transactions, complete configuration publication, offline recovery, authentication, revocation, Gateway lifecycle, and DHCP. Validate central candidates before persistence using Core URL, config, and portal-client rules. Reject unsupported peer schemes, unconvertible proxy subnets, and invalid portal clients without changing live intent. Expose existing pure Core validators without changing runtime behavior. Gate API-facing credential validation on API-enabled Core builds so minimal WASM targets do not reference omitted management types. Preserve session-backed device views in external Console mode. Bound Gateway admissions without cancelling transport upgrades, and cancel pending peer handshakes before retiring runtimes. Batch registry reads and bound runtime observation concurrency. Randomize DHCP bootstrap and conflict retries, keep an advertised current subnet, and select fallback subnets deterministically. Cover concurrent startup, Gateway admission lifetime, and external Console regressions. Allow DHCP bootstrap with no remote routes. Count network members through one tenant-scoped query without write transactions. Cover zero- peer allocation and tenant/empty-network counts. Preserve direct Web configurations and disabled states in central device snapshots. Remove obsolete central rows atomically with membership changes. Stop online managed instances before deleting or blocking devices, retaining intent when shutdown or deletion fails. Persist automatic IPv4 allocations separately from manual overrides so subnet changes reallocate only automatic members. Normalize mapped proxy routes to their advertised CIDRs when granting temporary credentials. Cover publication, deletion rollback, subnet migration, and grant updates. Read central intent in deferred transactions so polling does not reserve the SQLite writer lock. Test reads with an active writer and assert the temporary-member secret constraint using a valid device fixture. Persist patched WireGuard clients from the saved Web or GUI candidate without a follow-up RPC. Preserve pending settings and ownership, and cover disconnects and failed patches. Resolve named credential and config mutations against the live Core instance before checking central ownership. Forward authorized mutations by UUID on the same session, including while network renames are pending. Cover all mutation methods and preserve direct Console behavior. * feat(web-ui): add central network console and WireGuard management Add network and device views with central membership, credentials, ACL policy, temporary peers, and per-instance runtime details. Update console navigation, styling, theme handling, and API clients. Extend shared configuration and status components for central networks. Add a WireGuard portal dialog for setup and running-device management, with matching translations and network configuration types. Use secure UUID generation on HTTP, discard stale member configuration responses, and stop node-detail polling when the component unmounts. Update frontend workspace dependencies and include component, dashboard, configuration serialization, and central console end-to-end tests. Add isolated real-Core E2E coverage for central data-plane traffic, ACL, DHCP, recovery, device lifecycle, and native WireGuard clients. Record all 59 functional checks with evidence and confirmed validation/UI defects; keep runtime artifacts and test credentials out of Git. Normalize protobuf logger levels and render translated labels correctly. Cover all six levels across setting, reload, and language changes. Add real configuration-rejection E2E checks for database and Core stability and uninterrupted traffic, and record the resolved audit findings. Gate central navigation and registry actions by console mode. Refresh WireGuard settings on mounted status views and preserve explicit portal listener endpoints. Explain the trusted permanent-member ACL boundary. Cover external Console rendering, portal refresh retries and teardown, and explicit IPv4/IPv6 listener exports. Preserve PublicServer discovery when saving its settings, including when its URL matches the Gateway. Cover renaming and endpoint edits in the browser. Display automatic member addresses without converting them to manual overrides during edits. Derive offline address prefixes from the network subnet. Add browser regression coverage and real-Core checks for direct configuration preservation, temporary proxy mappings, automatic subnet migration, and shutdown before device deletion. Build enrollment commands from the configured API hostname, including IPv6 and relative endpoints. Use the PublicServer connector in temporary credential CLI and TOML exports. Add browser regression coverage. Preserve form credentials across repeated normalization and GUI storage reloads. Keep explicit form values authoritative over backend keys and cover JSON persistence, idempotence, replacement, and clearing.
2170 lines
67 KiB
Rust
2170 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 enabled = if self.vpn_portal.is_some() {
|
|
Some(true)
|
|
} else {
|
|
existing.as_ref().and_then(|portal| portal.enabled)
|
|
};
|
|
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 {
|
|
enabled,
|
|
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")
|
|
);
|
|
}
|
|
}
|