mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-09-04 18:15:39 +00:00
* feat(peer): support protocol-agnostic attached peers Add locally attached peers backed by independent, peer-level portable managers and authenticated in-process ring connections. Carry trusted connection provenance through packet admission so attached relay privileges cannot be forged through packet headers. Let every peer manager own ACL loading, sanitized policy updates, route refresh, and runtime cleanup. In Secure Mode, grant attached identities ephemeral credentials instead of sharing administrator and group secrets. * feat(vpn): add reusable attached-peer portal runtime Add a protocol-neutral portal runtime that converts authenticated client sessions into attached EasyTier peers. Own per-client generations, status, packet forwarding, address translation, and peer cleanup without knowing the transport protocol. Add transactional IPv4 source and destination rewriting with correct IPv4, TCP, UDP, ICMP, and quoted-packet checksum updates. Keep the old production portal path temporarily active until the WireGuard adapter is migrated in the next change. * feat(wireguard): attach named clients through peer portal Replace the monolithic WireGuard portal with a native adapter that owns key derivation, UDP demultiplexing, reauthentication, roaming, and bounded per-client packet queues. Hand authenticated sessions to the generic portal runtime for peer lifecycle and IPv4 translation. Move portal configuration into the core instance model, require a dedicated server key, and preserve existing listener, CLI, and runtime configuration behavior. Reject runtime address conflicts before publishing shared configuration. * feat(vpn): expose per-client portal status Project configured clients and their runtime state through the portal RPC, including generated client configuration, listener, peer identity, endpoint, tunnel address, ACL groups, and errors. Keep private client configuration out of the broad instance-info response and expose the explicit RPC through the CLI and Tauri bridge. * feat(vpn): add portal configuration to web clients Expose WireGuard portal listener, key, client, ACL group, and runtime status fields in the shared frontend library, Web dashboard, and Tauri client. Preserve UUID and uint64 values across protobuf JSON boundaries, keep dynamic client editor rows stable, and document the portal workflow. * test(vpn): cover multi-client and roaming WireGuard portals Add two three-node integration tests for the WireGuard VPN portal. The multi-client test connects two kernel WireGuard clients from separate network namespaces, verifies per-client connectivity to mesh nodes, and exercises cross-client traffic that runs the IPv4 source and destination translation in both directions. A TCP echo exchange through the portal additionally covers the TCP pseudo-header checksum rewrite path that ICMP-only ping tests miss, and portal status snapshots must report both clients online with distinct peer ids and correctly learned tunnel addresses. The roaming test swaps the client namespace address (delete the old address, then add the new one) so the kernel WireGuard source cache is invalidated and the client keeps sending under the same session from the new source, exactly like a real network change. The portal must update the client endpoint on the same peer id via the data path (same generation, no re-handshake, no detach/reconnect) while connectivity to mesh nodes is preserved. Supporting changes: run_wireguard_client now takes an interface name, and the shared namespace topology gains net_f (10.1.2.5) on the portal bridge for the second client.
1811 lines
54 KiB
Rust
1811 lines
54 KiB
Rust
//! Complete EasyTier TOML configuration model.
|
|
|
|
use std::{
|
|
net::{IpAddr, SocketAddr},
|
|
path::PathBuf,
|
|
sync::{Arc, Mutex},
|
|
};
|
|
|
|
pub use super::{EncryptionAlgorithm, gateway::PortForwardConfig};
|
|
use anyhow::Context;
|
|
#[cfg(feature = "rich-config-errors")]
|
|
use ariadne::{CharSet, Config as AriadneConfig, IndexType, Label, Report, ReportKind, Source};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::proto::{
|
|
acl::Acl,
|
|
common::{CompressionAlgoPb, SecureModeConfig},
|
|
};
|
|
|
|
pub const DEFAULT_ET_DNS_ZONE: &str = "et.net.";
|
|
|
|
pub type Flags = crate::proto::common::FlagsInConfig;
|
|
|
|
pub(crate) fn default_instance_name() -> String {
|
|
"default".to_owned()
|
|
}
|
|
|
|
pub fn gen_default_flags() -> Flags {
|
|
#[allow(deprecated)]
|
|
Flags {
|
|
default_protocol: "tcp".to_string(),
|
|
dev_name: "".to_string(),
|
|
enable_encryption: true,
|
|
enable_ipv6: true,
|
|
mtu: 1380,
|
|
latency_first: false,
|
|
enable_exit_node: false,
|
|
proxy_forward_by_system: false,
|
|
no_tun: false,
|
|
use_smoltcp: false,
|
|
relay_network_whitelist: "*".to_string(),
|
|
disable_p2p: false,
|
|
p2p_only: false,
|
|
lazy_p2p: false,
|
|
relay_all_peer_rpc: false,
|
|
disable_tcp_hole_punching: false,
|
|
disable_udp_hole_punching: false,
|
|
multi_thread: true,
|
|
data_compress_algo: CompressionAlgoPb::None.into(),
|
|
bind_device: true,
|
|
enable_kcp_proxy: false,
|
|
disable_kcp_input: false,
|
|
disable_relay_kcp: false,
|
|
enable_relay_foreign_network_kcp: false,
|
|
accept_dns: false,
|
|
private_mode: false,
|
|
enable_quic_proxy: false,
|
|
disable_quic_input: false,
|
|
disable_relay_quic: false,
|
|
enable_relay_foreign_network_quic: false,
|
|
foreign_relay_bps_limit: u64::MAX,
|
|
multi_thread_count: 2,
|
|
encryption_algorithm: EncryptionAlgorithm::default().to_string(),
|
|
disable_sym_hole_punching: false,
|
|
tld_dns_zone: DEFAULT_ET_DNS_ZONE.to_string(),
|
|
|
|
quic_listen_port: u32::MAX,
|
|
need_p2p: false,
|
|
instance_recv_bps_limit: u64::MAX,
|
|
disable_upnp: false,
|
|
disable_relay_data: false,
|
|
enable_udp_broadcast_relay: false,
|
|
socket_mark: None,
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "config-write")]
|
|
macro_rules! define_flags_diff {
|
|
(
|
|
fields: [$($field:ident),* $(,)?],
|
|
u64s: [$($u64_field:ident),* $(,)?],
|
|
enums: [$($enum_field:ident),* $(,)?]
|
|
) => {
|
|
#[allow(deprecated)]
|
|
fn flags_diff_from_default(flags: &Flags) -> serde_json::Map<String, serde_json::Value> {
|
|
let defaults = gen_default_flags();
|
|
let mut changed = serde_json::Map::new();
|
|
$(
|
|
if flags.$field != defaults.$field {
|
|
changed.insert(
|
|
stringify!($field).to_owned(),
|
|
serde_json::to_value(&flags.$field)
|
|
.expect("FlagsInConfig field should serialize to JSON"),
|
|
);
|
|
}
|
|
)*
|
|
$(
|
|
if flags.$u64_field != defaults.$u64_field {
|
|
changed.insert(
|
|
stringify!($u64_field).to_owned(),
|
|
serde_json::json!(flags.$u64_field.to_string()),
|
|
);
|
|
}
|
|
)*
|
|
$(
|
|
if flags.$enum_field != defaults.$enum_field {
|
|
let value = CompressionAlgoPb::try_from(flags.$enum_field)
|
|
.map(|value| serde_json::to_value(value).expect("enum should serialize"))
|
|
.unwrap_or_else(|_| serde_json::json!(flags.$enum_field));
|
|
changed.insert(stringify!($enum_field).to_owned(), value);
|
|
}
|
|
)*
|
|
changed
|
|
}
|
|
|
|
#[cfg(all(test, feature = "config-write"))]
|
|
const FLAGS_DIFF_FIELDS: &[&str] = &[
|
|
$(stringify!($field),)*
|
|
$(stringify!($u64_field),)*
|
|
$(stringify!($enum_field),)*
|
|
];
|
|
};
|
|
}
|
|
|
|
#[cfg(feature = "config-write")]
|
|
define_flags_diff! {
|
|
fields: [
|
|
default_protocol,
|
|
dev_name,
|
|
enable_encryption,
|
|
enable_ipv6,
|
|
mtu,
|
|
latency_first,
|
|
enable_exit_node,
|
|
no_tun,
|
|
use_smoltcp,
|
|
relay_network_whitelist,
|
|
disable_p2p,
|
|
relay_all_peer_rpc,
|
|
disable_udp_hole_punching,
|
|
multi_thread,
|
|
bind_device,
|
|
enable_kcp_proxy,
|
|
disable_kcp_input,
|
|
disable_relay_kcp,
|
|
proxy_forward_by_system,
|
|
accept_dns,
|
|
private_mode,
|
|
enable_quic_proxy,
|
|
disable_quic_input,
|
|
disable_relay_quic,
|
|
quic_listen_port,
|
|
multi_thread_count,
|
|
enable_relay_foreign_network_kcp,
|
|
enable_relay_foreign_network_quic,
|
|
encryption_algorithm,
|
|
disable_sym_hole_punching,
|
|
tld_dns_zone,
|
|
p2p_only,
|
|
disable_tcp_hole_punching,
|
|
lazy_p2p,
|
|
need_p2p,
|
|
disable_upnp,
|
|
disable_relay_data,
|
|
enable_udp_broadcast_relay,
|
|
socket_mark,
|
|
],
|
|
u64s: [foreign_relay_bps_limit, instance_recv_bps_limit],
|
|
enums: [data_compress_algo]
|
|
}
|
|
|
|
#[auto_impl::auto_impl(Box, &)]
|
|
pub trait ConfigLoader: Send + Sync {
|
|
fn get_id(&self) -> uuid::Uuid;
|
|
fn set_id(&self, id: uuid::Uuid);
|
|
|
|
fn get_hostname(&self) -> String;
|
|
fn set_hostname(&self, name: Option<String>);
|
|
|
|
fn get_inst_name(&self) -> String;
|
|
fn set_inst_name(&self, name: String);
|
|
|
|
fn get_netns(&self) -> Option<String>;
|
|
fn set_netns(&self, ns: Option<String>);
|
|
|
|
fn get_ipv4(&self) -> Option<cidr::Ipv4Inet>;
|
|
fn set_ipv4(&self, addr: Option<cidr::Ipv4Inet>);
|
|
|
|
fn get_ipv6(&self) -> Option<cidr::Ipv6Inet>;
|
|
fn set_ipv6(&self, addr: Option<cidr::Ipv6Inet>);
|
|
|
|
fn get_ipv6_public_addr_provider(&self) -> bool;
|
|
fn set_ipv6_public_addr_provider(&self, enabled: bool);
|
|
|
|
fn get_ipv6_public_addr_auto(&self) -> bool;
|
|
fn set_ipv6_public_addr_auto(&self, enabled: bool);
|
|
|
|
fn get_ipv6_public_addr_prefix(&self) -> Option<cidr::Ipv6Cidr>;
|
|
fn set_ipv6_public_addr_prefix(&self, prefix: Option<cidr::Ipv6Cidr>);
|
|
|
|
fn get_dhcp(&self) -> bool;
|
|
fn set_dhcp(&self, dhcp: bool);
|
|
|
|
fn add_proxy_cidr(
|
|
&self,
|
|
cidr: cidr::Ipv4Cidr,
|
|
mapped_cidr: Option<cidr::Ipv4Cidr>,
|
|
) -> Result<(), anyhow::Error>;
|
|
fn remove_proxy_cidr(&self, cidr: cidr::Ipv4Cidr);
|
|
fn clear_proxy_cidrs(&self);
|
|
fn get_proxy_cidrs(&self) -> Vec<ProxyNetworkConfig>;
|
|
|
|
fn get_network_identity(&self) -> NetworkIdentity;
|
|
fn set_network_identity(&self, identity: NetworkIdentity);
|
|
|
|
fn get_listener_uris(&self) -> Vec<url::Url>;
|
|
|
|
fn get_peers(&self) -> Vec<PeerConfig>;
|
|
fn set_peers(&self, peers: Vec<PeerConfig>);
|
|
|
|
fn get_listeners(&self) -> Option<Vec<url::Url>>;
|
|
fn set_listeners(&self, listeners: Vec<url::Url>);
|
|
|
|
fn get_mapped_listeners(&self) -> Vec<url::Url>;
|
|
fn set_mapped_listeners(&self, listeners: Option<Vec<url::Url>>);
|
|
|
|
fn get_vpn_portal_config(&self) -> Option<VpnPortalConfig>;
|
|
fn set_vpn_portal_config(&self, config: VpnPortalConfig);
|
|
|
|
fn get_flags(&self) -> Flags;
|
|
fn set_flags(&self, flags: Flags);
|
|
|
|
fn get_exit_nodes(&self) -> Vec<IpAddr>;
|
|
fn set_exit_nodes(&self, nodes: Vec<IpAddr>);
|
|
|
|
fn get_routes(&self) -> Option<Vec<cidr::Ipv4Cidr>>;
|
|
fn set_routes(&self, routes: Option<Vec<cidr::Ipv4Cidr>>);
|
|
|
|
fn get_socks5_portal(&self) -> Option<url::Url>;
|
|
fn set_socks5_portal(&self, addr: Option<url::Url>);
|
|
|
|
fn get_port_forwards(&self) -> Vec<PortForwardConfig>;
|
|
fn set_port_forwards(&self, forwards: Vec<PortForwardConfig>);
|
|
|
|
fn get_acl(&self) -> Option<Acl>;
|
|
fn set_acl(&self, acl: Option<Acl>);
|
|
|
|
fn get_tcp_whitelist(&self) -> Vec<String>;
|
|
fn set_tcp_whitelist(&self, whitelist: Vec<String>);
|
|
|
|
fn get_udp_whitelist(&self) -> Vec<String>;
|
|
fn set_udp_whitelist(&self, whitelist: Vec<String>);
|
|
|
|
fn get_stun_servers(&self) -> Option<Vec<String>>;
|
|
fn set_stun_servers(&self, servers: Option<Vec<String>>);
|
|
|
|
fn get_tcp_stun_servers(&self) -> Option<Vec<String>> {
|
|
None
|
|
}
|
|
fn set_tcp_stun_servers(&self, _servers: Option<Vec<String>>) {}
|
|
|
|
fn get_stun_servers_v6(&self) -> Option<Vec<String>>;
|
|
fn set_stun_servers_v6(&self, servers: Option<Vec<String>>);
|
|
|
|
fn get_secure_mode(&self) -> Option<SecureModeConfig>;
|
|
fn set_secure_mode(&self, secure_mode: Option<SecureModeConfig>);
|
|
|
|
fn get_credential_file(&self) -> Option<std::path::PathBuf> {
|
|
None
|
|
}
|
|
fn set_credential_file(&self, _path: Option<std::path::PathBuf>) {}
|
|
|
|
fn get_network_config_source(&self) -> ConfigSource {
|
|
ConfigSource::User
|
|
}
|
|
fn set_network_config_source(&self, _source: Option<ConfigSource>) {}
|
|
|
|
fn dump(&self) -> String;
|
|
fn dump_redacted(&self) -> String {
|
|
self.dump()
|
|
}
|
|
}
|
|
|
|
pub trait LoggingConfigLoader {
|
|
fn get_file_logger_config(&self) -> FileLoggerConfig;
|
|
|
|
fn get_console_logger_config(&self) -> ConsoleLoggerConfig;
|
|
}
|
|
|
|
use super::NetworkSecretDigest;
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
pub struct NetworkIdentity {
|
|
pub network_name: String,
|
|
pub network_secret: Option<String>,
|
|
#[serde(skip)]
|
|
pub network_secret_digest: Option<NetworkSecretDigest>,
|
|
}
|
|
|
|
impl From<super::NetworkIdentity> for NetworkIdentity {
|
|
fn from(value: super::NetworkIdentity) -> Self {
|
|
Self {
|
|
network_name: value.network_name,
|
|
network_secret: value.network_secret,
|
|
network_secret_digest: value.network_secret_digest,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<&NetworkIdentity> for super::NetworkIdentity {
|
|
fn from(value: &NetworkIdentity) -> Self {
|
|
Self {
|
|
network_name: value.network_name.clone(),
|
|
network_secret: value.network_secret.clone(),
|
|
network_secret_digest: value.network_secret_digest,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<NetworkIdentity> for super::NetworkIdentity {
|
|
fn from(value: NetworkIdentity) -> Self {
|
|
Self {
|
|
network_name: value.network_name,
|
|
network_secret: value.network_secret,
|
|
network_secret_digest: value.network_secret_digest,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ConfigSource {
|
|
#[default]
|
|
User,
|
|
Web,
|
|
}
|
|
|
|
impl ConfigSource {
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::User => "user",
|
|
Self::Web => "web",
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::str::FromStr for ConfigSource {
|
|
type Err = String;
|
|
|
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
match s {
|
|
"user" => Ok(Self::User),
|
|
"web" => Ok(Self::Web),
|
|
other => Err(format!("unknown network config source: {other}")),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
|
struct ConfigSourceConfig {
|
|
source: ConfigSource,
|
|
}
|
|
|
|
impl PartialEq for NetworkIdentity {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
super::NetworkIdentity::from(self) == super::NetworkIdentity::from(other)
|
|
}
|
|
}
|
|
|
|
impl Eq for NetworkIdentity {}
|
|
|
|
impl std::hash::Hash for NetworkIdentity {
|
|
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
|
std::hash::Hash::hash(&super::NetworkIdentity::from(self), state);
|
|
}
|
|
}
|
|
|
|
impl NetworkIdentity {
|
|
pub fn new(network_name: String, network_secret: String) -> Self {
|
|
super::NetworkIdentity::new(network_name, network_secret).into()
|
|
}
|
|
|
|
/// Create a NetworkIdentity for a credential node (no network_secret).
|
|
/// The node identifies by network_name only and authenticates via credential keypair.
|
|
pub fn new_credential(network_name: String) -> Self {
|
|
super::NetworkIdentity::new_credential(network_name).into()
|
|
}
|
|
}
|
|
|
|
impl Default for NetworkIdentity {
|
|
fn default() -> Self {
|
|
super::NetworkIdentity::default().into()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
|
pub struct PeerConfig {
|
|
pub uri: url::Url,
|
|
pub peer_public_key: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
|
pub struct ProxyNetworkConfig {
|
|
pub cidr: cidr::Ipv4Cidr, // the CIDR of the proxy network
|
|
pub mapped_cidr: Option<cidr::Ipv4Cidr>, // allow remap the proxy CIDR to another CIDR
|
|
pub allow: Option<Vec<String>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Default)]
|
|
pub struct FileLoggerConfig {
|
|
pub level: Option<String>,
|
|
pub file: Option<String>,
|
|
pub dir: Option<String>,
|
|
pub size_mb: Option<u64>,
|
|
pub count: Option<usize>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Default)]
|
|
pub struct ConsoleLoggerConfig {
|
|
pub level: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, derive_builder::Builder)]
|
|
pub struct LoggingConfig {
|
|
#[builder(setter(into, strip_option), default = None)]
|
|
pub file_logger: Option<FileLoggerConfig>,
|
|
#[builder(setter(into, strip_option), default = None)]
|
|
pub console_logger: Option<ConsoleLoggerConfig>,
|
|
}
|
|
|
|
impl LoggingConfigLoader for &LoggingConfig {
|
|
fn get_file_logger_config(&self) -> FileLoggerConfig {
|
|
self.file_logger.clone().unwrap_or_default()
|
|
}
|
|
|
|
fn get_console_logger_config(&self) -> ConsoleLoggerConfig {
|
|
self.console_logger.clone().unwrap_or_default()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Deserialize, Serialize, PartialEq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct VpnPortalConfig {
|
|
pub wireguard_listen: SocketAddr,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub wireguard_private_key: Option<String>,
|
|
#[serde(default)]
|
|
pub clients: Vec<VpnPortalClientConfig>,
|
|
}
|
|
|
|
impl std::fmt::Debug for VpnPortalConfig {
|
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
formatter
|
|
.debug_struct("VpnPortalConfig")
|
|
.field("wireguard_listen", &self.wireguard_listen)
|
|
.field(
|
|
"wireguard_private_key",
|
|
&self.wireguard_private_key.as_ref().map(|_| "<redacted>"),
|
|
)
|
|
.field("clients", &self.clients)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct VpnPortalClientConfig {
|
|
pub name: String,
|
|
pub virtual_ip: std::net::Ipv4Addr,
|
|
#[serde(default)]
|
|
pub groups: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
|
#[cfg_attr(feature = "config-write", derive(Serialize))]
|
|
struct Config {
|
|
netns: Option<String>,
|
|
hostname: Option<String>,
|
|
instance_name: Option<String>,
|
|
instance_id: Option<uuid::Uuid>,
|
|
ipv4: Option<String>,
|
|
ipv6: Option<String>,
|
|
ipv6_public_addr_provider: Option<bool>,
|
|
ipv6_public_addr_auto: Option<bool>,
|
|
ipv6_public_addr_prefix: Option<String>,
|
|
dhcp: Option<bool>,
|
|
network_identity: Option<NetworkIdentity>,
|
|
listeners: Option<Vec<url::Url>>,
|
|
mapped_listeners: Option<Vec<url::Url>>,
|
|
exit_nodes: Option<Vec<IpAddr>>,
|
|
|
|
peer: Option<Vec<PeerConfig>>,
|
|
proxy_network: Option<Vec<ProxyNetworkConfig>>,
|
|
|
|
vpn_portal_config: Option<VpnPortalConfig>,
|
|
|
|
routes: Option<Vec<cidr::Ipv4Cidr>>,
|
|
|
|
socks5_proxy: Option<url::Url>,
|
|
|
|
port_forward: Option<Vec<PortForwardConfig>>,
|
|
|
|
secure_mode: Option<SecureModeConfig>,
|
|
|
|
flags: Option<serde_json::Map<String, serde_json::Value>>,
|
|
|
|
#[serde(skip)]
|
|
flags_struct: Option<Flags>,
|
|
|
|
acl: Option<Acl>,
|
|
|
|
tcp_whitelist: Option<Vec<String>>,
|
|
udp_whitelist: Option<Vec<String>>,
|
|
stun_servers: Option<Vec<String>>,
|
|
tcp_stun_servers: Option<Vec<String>>,
|
|
stun_servers_v6: Option<Vec<String>>,
|
|
|
|
credential_file: Option<PathBuf>,
|
|
source: Option<ConfigSourceConfig>,
|
|
}
|
|
|
|
#[cfg(feature = "rich-config-errors")]
|
|
fn format_toml_parse_error(source_name: &str, config_str: &str, error: &toml::de::Error) -> String {
|
|
let message = format!("failed to parse config TOML from {source_name}");
|
|
|
|
let Some(span) = error.span() else {
|
|
return format!("{message}\ndetail: {error}");
|
|
};
|
|
|
|
let mut output = Vec::new();
|
|
let report = Report::build(ReportKind::Error, (source_name, span.clone()))
|
|
.with_config(
|
|
AriadneConfig::default()
|
|
.with_color(false)
|
|
.with_char_set(CharSet::Ascii)
|
|
.with_index_type(IndexType::Byte),
|
|
)
|
|
.with_message(&message)
|
|
.with_label(Label::new((source_name, span)).with_message(error.message()))
|
|
.finish();
|
|
|
|
if report
|
|
.write((source_name, Source::from(config_str)), &mut output)
|
|
.is_ok()
|
|
{
|
|
String::from_utf8_lossy(&output).into_owned()
|
|
} else {
|
|
format!("{message}\ndetail: {error}")
|
|
}
|
|
}
|
|
|
|
#[cfg(not(feature = "rich-config-errors"))]
|
|
fn format_toml_parse_error(
|
|
source_name: &str,
|
|
_config_str: &str,
|
|
error: &toml::de::Error,
|
|
) -> String {
|
|
format!("failed to parse config TOML from {source_name}: {error}")
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct TomlConfig {
|
|
config: Arc<Mutex<Config>>,
|
|
}
|
|
|
|
impl Default for TomlConfig {
|
|
fn default() -> Self {
|
|
TomlConfig::new_from_str("").unwrap()
|
|
}
|
|
}
|
|
|
|
impl TomlConfig {
|
|
fn normalize_config_source(config: &mut Config) {
|
|
if matches!(
|
|
config.source.as_ref().map(|source| source.source),
|
|
Some(ConfigSource::User)
|
|
) {
|
|
config.source = None;
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "config-write")]
|
|
fn config_for_dump(&self) -> Config {
|
|
let mut config = self.config.lock().unwrap().clone();
|
|
Self::normalize_config_source(&mut config);
|
|
config.flags = Some(flags_diff_from_default(&self.get_flags()));
|
|
config
|
|
}
|
|
|
|
#[cfg(feature = "config-write")]
|
|
fn redact_secrets(config: &mut Config) {
|
|
const REDACTED: &str = "<redacted>";
|
|
|
|
if let Some(secret) = config
|
|
.network_identity
|
|
.as_mut()
|
|
.and_then(|identity| identity.network_secret.as_mut())
|
|
&& !secret.is_empty()
|
|
{
|
|
*secret = REDACTED.to_owned();
|
|
}
|
|
if let Some(private_key) = config
|
|
.secure_mode
|
|
.as_mut()
|
|
.and_then(|secure_mode| secure_mode.local_private_key.as_mut())
|
|
&& !private_key.is_empty()
|
|
{
|
|
*private_key = REDACTED.to_owned();
|
|
}
|
|
if let Some(private_key) = config
|
|
.vpn_portal_config
|
|
.as_mut()
|
|
.and_then(|portal| portal.wireguard_private_key.as_mut())
|
|
&& !private_key.is_empty()
|
|
{
|
|
*private_key = REDACTED.to_owned();
|
|
}
|
|
if let Some(declarations) = config
|
|
.acl
|
|
.as_mut()
|
|
.and_then(|acl| acl.acl_v1.as_mut())
|
|
.and_then(|acl| acl.group.as_mut())
|
|
.map(|group| &mut group.declares)
|
|
{
|
|
for declaration in declarations {
|
|
if !declaration.group_secret.is_empty() {
|
|
declaration.group_secret = REDACTED.to_owned();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn new_from_str(config_str: &str) -> Result<Self, anyhow::Error> {
|
|
Self::new_from_str_with_source("inline config", config_str)
|
|
}
|
|
|
|
pub fn new_from_str_with_source(
|
|
source_name: &str,
|
|
config_str: &str,
|
|
) -> Result<Self, anyhow::Error> {
|
|
let mut config = toml::de::from_str::<Config>(config_str).map_err(|err| {
|
|
let message = format_toml_parse_error(source_name, config_str, &err);
|
|
anyhow::Error::new(err).context(message)
|
|
})?;
|
|
|
|
Self::normalize_config_source(&mut config);
|
|
|
|
Self::new_from_config(config).map_err(|err| {
|
|
let message = format!("failed to load config from {source_name}: {err}");
|
|
err.context(message)
|
|
})
|
|
}
|
|
|
|
fn new_from_config(mut config: Config) -> Result<Self, anyhow::Error> {
|
|
config.flags_struct = Some(
|
|
Self::gen_flags(config.flags.clone().unwrap_or_default())
|
|
.context("failed to parse flags")?,
|
|
);
|
|
let has_network_identity = config.network_identity.is_some();
|
|
|
|
let config = TomlConfig {
|
|
config: Arc::new(Mutex::new(config)),
|
|
};
|
|
|
|
let old_ns = config.get_network_identity();
|
|
|
|
// Detect credential mode: secure_mode enabled + no network_secret in TOML
|
|
let is_credential = has_network_identity
|
|
&& config
|
|
.get_secure_mode()
|
|
.map(|sm| sm.enabled)
|
|
.unwrap_or(false)
|
|
&& old_ns
|
|
.network_secret
|
|
.as_deref()
|
|
.is_none_or(|s| s.is_empty());
|
|
|
|
if is_credential {
|
|
config.set_network_identity(NetworkIdentity::new_credential(old_ns.network_name));
|
|
} else {
|
|
config.set_network_identity(NetworkIdentity::new(
|
|
old_ns.network_name,
|
|
old_ns.network_secret.unwrap_or_default(),
|
|
));
|
|
}
|
|
|
|
Ok(config)
|
|
}
|
|
|
|
fn gen_flags(
|
|
flags_hashmap: serde_json::Map<String, serde_json::Value>,
|
|
) -> serde_json::Result<Flags> {
|
|
let mut merged_hashmap = match serde_json::to_value(gen_default_flags()) {
|
|
Ok(serde_json::Value::Object(map)) => map,
|
|
_ => serde_json::Map::new(),
|
|
};
|
|
merged_hashmap.extend(flags_hashmap);
|
|
serde_json::from_value(serde_json::Value::Object(merged_hashmap))
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "web-client")]
|
|
mod snapshot;
|
|
|
|
impl ConfigLoader for TomlConfig {
|
|
fn get_inst_name(&self) -> String {
|
|
self.config
|
|
.lock()
|
|
.unwrap()
|
|
.instance_name
|
|
.clone()
|
|
.unwrap_or_else(default_instance_name)
|
|
}
|
|
|
|
fn set_inst_name(&self, name: String) {
|
|
self.config.lock().unwrap().instance_name = Some(name);
|
|
}
|
|
|
|
fn get_hostname(&self) -> String {
|
|
let hostname = self.config.lock().unwrap().hostname.clone();
|
|
|
|
match hostname {
|
|
Some(hostname) => {
|
|
let hostname = hostname
|
|
.chars()
|
|
.filter(|c| !c.is_control())
|
|
.take(32)
|
|
.collect::<String>();
|
|
|
|
if !hostname.is_empty() {
|
|
self.set_hostname(Some(hostname.clone()));
|
|
hostname
|
|
} else {
|
|
self.set_hostname(None);
|
|
String::new()
|
|
}
|
|
}
|
|
None => String::new(),
|
|
}
|
|
}
|
|
|
|
fn set_hostname(&self, name: Option<String>) {
|
|
self.config.lock().unwrap().hostname = name;
|
|
}
|
|
|
|
fn get_netns(&self) -> Option<String> {
|
|
self.config.lock().unwrap().netns.clone()
|
|
}
|
|
|
|
fn set_netns(&self, ns: Option<String>) {
|
|
self.config.lock().unwrap().netns = ns;
|
|
}
|
|
|
|
fn get_ipv4(&self) -> Option<cidr::Ipv4Inet> {
|
|
let locked_config = self.config.lock().unwrap();
|
|
locked_config
|
|
.ipv4
|
|
.as_ref()
|
|
.and_then(|s| s.parse().ok())
|
|
.map(|c: cidr::Ipv4Inet| {
|
|
if c.network_length() == 32 {
|
|
cidr::Ipv4Inet::new(c.address(), 24).unwrap()
|
|
} else {
|
|
c
|
|
}
|
|
})
|
|
}
|
|
|
|
fn set_ipv4(&self, addr: Option<cidr::Ipv4Inet>) {
|
|
self.config.lock().unwrap().ipv4 = addr.map(|addr| addr.to_string());
|
|
}
|
|
|
|
fn get_ipv6(&self) -> Option<cidr::Ipv6Inet> {
|
|
let locked_config = self.config.lock().unwrap();
|
|
locked_config.ipv6.as_ref().and_then(|s| s.parse().ok())
|
|
}
|
|
|
|
fn set_ipv6(&self, addr: Option<cidr::Ipv6Inet>) {
|
|
self.config.lock().unwrap().ipv6 = addr.map(|addr| addr.to_string());
|
|
}
|
|
|
|
fn get_ipv6_public_addr_provider(&self) -> bool {
|
|
self.config
|
|
.lock()
|
|
.unwrap()
|
|
.ipv6_public_addr_provider
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn set_ipv6_public_addr_provider(&self, enabled: bool) {
|
|
self.config.lock().unwrap().ipv6_public_addr_provider = Some(enabled);
|
|
}
|
|
|
|
fn get_ipv6_public_addr_auto(&self) -> bool {
|
|
self.config
|
|
.lock()
|
|
.unwrap()
|
|
.ipv6_public_addr_auto
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn set_ipv6_public_addr_auto(&self, enabled: bool) {
|
|
self.config.lock().unwrap().ipv6_public_addr_auto = Some(enabled);
|
|
}
|
|
|
|
fn get_ipv6_public_addr_prefix(&self) -> Option<cidr::Ipv6Cidr> {
|
|
let locked_config = self.config.lock().unwrap();
|
|
locked_config
|
|
.ipv6_public_addr_prefix
|
|
.as_ref()
|
|
.and_then(|s| s.parse().ok())
|
|
}
|
|
|
|
fn set_ipv6_public_addr_prefix(&self, prefix: Option<cidr::Ipv6Cidr>) {
|
|
self.config.lock().unwrap().ipv6_public_addr_prefix =
|
|
prefix.map(|prefix| prefix.to_string());
|
|
}
|
|
|
|
fn get_dhcp(&self) -> bool {
|
|
self.config.lock().unwrap().dhcp.unwrap_or_default()
|
|
}
|
|
|
|
fn set_dhcp(&self, dhcp: bool) {
|
|
self.config.lock().unwrap().dhcp = Some(dhcp);
|
|
}
|
|
|
|
fn add_proxy_cidr(
|
|
&self,
|
|
cidr: cidr::Ipv4Cidr,
|
|
mapped_cidr: Option<cidr::Ipv4Cidr>,
|
|
) -> Result<(), anyhow::Error> {
|
|
let mut locked_config = self.config.lock().unwrap();
|
|
if locked_config.proxy_network.is_none() {
|
|
locked_config.proxy_network = Some(vec![]);
|
|
}
|
|
if let Some(mapped_cidr) = mapped_cidr.as_ref()
|
|
&& cidr.network_length() != mapped_cidr.network_length()
|
|
{
|
|
return Err(anyhow::anyhow!(
|
|
"Mapped CIDR must have the same network length as the original CIDR: {} != {}",
|
|
cidr.network_length(),
|
|
mapped_cidr.network_length()
|
|
));
|
|
}
|
|
// insert if no duplicate
|
|
if !locked_config
|
|
.proxy_network
|
|
.as_ref()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|c| c.cidr == cidr && c.mapped_cidr == mapped_cidr)
|
|
{
|
|
locked_config
|
|
.proxy_network
|
|
.as_mut()
|
|
.unwrap()
|
|
.push(ProxyNetworkConfig {
|
|
cidr,
|
|
mapped_cidr,
|
|
allow: None,
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn remove_proxy_cidr(&self, cidr: cidr::Ipv4Cidr) {
|
|
let mut locked_config = self.config.lock().unwrap();
|
|
if let Some(proxy_cidrs) = &mut locked_config.proxy_network {
|
|
proxy_cidrs.retain(|c| c.cidr != cidr);
|
|
}
|
|
}
|
|
|
|
fn clear_proxy_cidrs(&self) {
|
|
let mut locked_config = self.config.lock().unwrap();
|
|
locked_config.proxy_network = None;
|
|
}
|
|
|
|
fn get_proxy_cidrs(&self) -> Vec<ProxyNetworkConfig> {
|
|
self.config
|
|
.lock()
|
|
.unwrap()
|
|
.proxy_network
|
|
.as_ref()
|
|
.cloned()
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn get_id(&self) -> uuid::Uuid {
|
|
let mut locked_config = self.config.lock().unwrap();
|
|
match locked_config.instance_id {
|
|
Some(id) => id,
|
|
None => {
|
|
let id = uuid::Uuid::new_v4();
|
|
locked_config.instance_id = Some(id);
|
|
id
|
|
}
|
|
}
|
|
}
|
|
|
|
fn set_id(&self, id: uuid::Uuid) {
|
|
self.config.lock().unwrap().instance_id = Some(id);
|
|
}
|
|
|
|
fn get_network_identity(&self) -> NetworkIdentity {
|
|
self.config
|
|
.lock()
|
|
.unwrap()
|
|
.network_identity
|
|
.clone()
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn set_network_identity(&self, identity: NetworkIdentity) {
|
|
self.config.lock().unwrap().network_identity = Some(identity);
|
|
}
|
|
|
|
fn get_listener_uris(&self) -> Vec<url::Url> {
|
|
self.config
|
|
.lock()
|
|
.unwrap()
|
|
.listeners
|
|
.clone()
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn get_peers(&self) -> Vec<PeerConfig> {
|
|
self.config.lock().unwrap().peer.clone().unwrap_or_default()
|
|
}
|
|
|
|
fn set_peers(&self, peers: Vec<PeerConfig>) {
|
|
self.config.lock().unwrap().peer = Some(peers);
|
|
}
|
|
|
|
fn get_listeners(&self) -> Option<Vec<url::Url>> {
|
|
self.config.lock().unwrap().listeners.clone()
|
|
}
|
|
|
|
fn set_listeners(&self, listeners: Vec<url::Url>) {
|
|
self.config.lock().unwrap().listeners = Some(listeners);
|
|
}
|
|
|
|
fn get_mapped_listeners(&self) -> Vec<url::Url> {
|
|
self.config
|
|
.lock()
|
|
.unwrap()
|
|
.mapped_listeners
|
|
.clone()
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn set_mapped_listeners(&self, listeners: Option<Vec<url::Url>>) {
|
|
self.config.lock().unwrap().mapped_listeners = listeners;
|
|
}
|
|
|
|
fn get_vpn_portal_config(&self) -> Option<VpnPortalConfig> {
|
|
self.config.lock().unwrap().vpn_portal_config.clone()
|
|
}
|
|
fn set_vpn_portal_config(&self, config: VpnPortalConfig) {
|
|
self.config.lock().unwrap().vpn_portal_config = Some(config);
|
|
}
|
|
|
|
fn get_flags(&self) -> Flags {
|
|
self.config
|
|
.lock()
|
|
.unwrap()
|
|
.flags_struct
|
|
.clone()
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn set_flags(&self, flags: Flags) {
|
|
self.config.lock().unwrap().flags_struct = Some(flags);
|
|
}
|
|
|
|
fn get_exit_nodes(&self) -> Vec<IpAddr> {
|
|
self.config
|
|
.lock()
|
|
.unwrap()
|
|
.exit_nodes
|
|
.clone()
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn set_exit_nodes(&self, nodes: Vec<IpAddr>) {
|
|
self.config.lock().unwrap().exit_nodes = Some(nodes);
|
|
}
|
|
|
|
fn get_routes(&self) -> Option<Vec<cidr::Ipv4Cidr>> {
|
|
self.config.lock().unwrap().routes.clone()
|
|
}
|
|
|
|
fn set_routes(&self, routes: Option<Vec<cidr::Ipv4Cidr>>) {
|
|
self.config.lock().unwrap().routes = routes;
|
|
}
|
|
|
|
fn get_socks5_portal(&self) -> Option<url::Url> {
|
|
self.config.lock().unwrap().socks5_proxy.clone()
|
|
}
|
|
|
|
fn set_socks5_portal(&self, addr: Option<url::Url>) {
|
|
self.config.lock().unwrap().socks5_proxy = addr;
|
|
}
|
|
|
|
fn get_port_forwards(&self) -> Vec<PortForwardConfig> {
|
|
self.config
|
|
.lock()
|
|
.unwrap()
|
|
.port_forward
|
|
.clone()
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn set_port_forwards(&self, forwards: Vec<PortForwardConfig>) {
|
|
self.config.lock().unwrap().port_forward = Some(forwards);
|
|
}
|
|
|
|
fn get_acl(&self) -> Option<Acl> {
|
|
self.config.lock().unwrap().acl.clone()
|
|
}
|
|
|
|
fn set_acl(&self, acl: Option<Acl>) {
|
|
self.config.lock().unwrap().acl = acl;
|
|
}
|
|
|
|
fn get_tcp_whitelist(&self) -> Vec<String> {
|
|
self.config
|
|
.lock()
|
|
.unwrap()
|
|
.tcp_whitelist
|
|
.clone()
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn set_tcp_whitelist(&self, whitelist: Vec<String>) {
|
|
self.config.lock().unwrap().tcp_whitelist = Some(whitelist);
|
|
}
|
|
|
|
fn get_udp_whitelist(&self) -> Vec<String> {
|
|
self.config
|
|
.lock()
|
|
.unwrap()
|
|
.udp_whitelist
|
|
.clone()
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn set_udp_whitelist(&self, whitelist: Vec<String>) {
|
|
self.config.lock().unwrap().udp_whitelist = Some(whitelist);
|
|
}
|
|
|
|
fn get_stun_servers(&self) -> Option<Vec<String>> {
|
|
self.config.lock().unwrap().stun_servers.clone()
|
|
}
|
|
|
|
fn set_stun_servers(&self, servers: Option<Vec<String>>) {
|
|
self.config.lock().unwrap().stun_servers = servers;
|
|
}
|
|
|
|
fn get_tcp_stun_servers(&self) -> Option<Vec<String>> {
|
|
self.config.lock().unwrap().tcp_stun_servers.clone()
|
|
}
|
|
|
|
fn set_tcp_stun_servers(&self, servers: Option<Vec<String>>) {
|
|
self.config.lock().unwrap().tcp_stun_servers = servers;
|
|
}
|
|
|
|
fn get_stun_servers_v6(&self) -> Option<Vec<String>> {
|
|
self.config.lock().unwrap().stun_servers_v6.clone()
|
|
}
|
|
|
|
fn set_stun_servers_v6(&self, servers: Option<Vec<String>>) {
|
|
self.config.lock().unwrap().stun_servers_v6 = servers;
|
|
}
|
|
|
|
fn get_secure_mode(&self) -> Option<SecureModeConfig> {
|
|
self.config.lock().unwrap().secure_mode.clone()
|
|
}
|
|
|
|
fn set_secure_mode(&self, secure_mode: Option<SecureModeConfig>) {
|
|
self.config.lock().unwrap().secure_mode = secure_mode;
|
|
}
|
|
|
|
fn get_credential_file(&self) -> Option<PathBuf> {
|
|
self.config.lock().unwrap().credential_file.clone()
|
|
}
|
|
|
|
fn set_credential_file(&self, path: Option<PathBuf>) {
|
|
self.config.lock().unwrap().credential_file = path;
|
|
}
|
|
|
|
fn get_network_config_source(&self) -> ConfigSource {
|
|
self.config
|
|
.lock()
|
|
.unwrap()
|
|
.source
|
|
.as_ref()
|
|
.map(|source| source.source)
|
|
.unwrap_or(ConfigSource::User)
|
|
}
|
|
|
|
fn set_network_config_source(&self, source: Option<ConfigSource>) {
|
|
self.config.lock().unwrap().source = source.and_then(|source| match source {
|
|
ConfigSource::User => None,
|
|
other => Some(ConfigSourceConfig { source: other }),
|
|
});
|
|
}
|
|
|
|
fn dump(&self) -> String {
|
|
#[cfg(feature = "config-write")]
|
|
{
|
|
toml::to_string_pretty(&self.config_for_dump()).unwrap()
|
|
}
|
|
#[cfg(not(feature = "config-write"))]
|
|
{
|
|
panic!("this build does not include TOML configuration serialization")
|
|
}
|
|
}
|
|
|
|
fn dump_redacted(&self) -> String {
|
|
#[cfg(feature = "config-write")]
|
|
{
|
|
let mut config = self.config_for_dump();
|
|
Self::redact_secrets(&mut config);
|
|
toml::to_string_pretty(&config).unwrap()
|
|
}
|
|
#[cfg(not(feature = "config-write"))]
|
|
{
|
|
panic!("this build does not include TOML configuration serialization")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Transitional name retained while native consumers migrate to [`TomlConfig`].
|
|
pub type TomlConfigLoader = TomlConfig;
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parse_error_preserves_source_and_location() {
|
|
let error =
|
|
TomlConfig::new_from_str_with_source("fixture.toml", "dhcp = \"yes\"").unwrap_err();
|
|
let display = error.to_string();
|
|
|
|
assert!(display.contains("fixture.toml"));
|
|
assert!(display.contains("dhcp = \"yes\""));
|
|
assert!(display.contains("invalid type: string"));
|
|
assert!(
|
|
error
|
|
.chain()
|
|
.any(|cause| cause.downcast_ref::<toml::de::Error>().is_some())
|
|
);
|
|
}
|
|
|
|
#[cfg(feature = "config-write")]
|
|
#[test]
|
|
fn toml_round_trip_preserves_config_and_non_default_flags() {
|
|
let config = TomlConfig::new_from_str(
|
|
r#"
|
|
instance_name = "node-a"
|
|
instance_id = "018f85a8-a9d0-7d4c-b73d-4ab62c048a20"
|
|
hostname = "host-a"
|
|
listeners = ["tcp://0.0.0.0:11010"]
|
|
|
|
[network_identity]
|
|
network_name = "network-a"
|
|
network_secret = "secret-a"
|
|
|
|
[flags]
|
|
mtu = 1420
|
|
socket_mark = 0
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let dumped = config.dump();
|
|
let restored = TomlConfig::new_from_str(&dumped).unwrap();
|
|
|
|
assert_eq!(restored.get_id(), config.get_id());
|
|
assert_eq!(restored.get_hostname(), "host-a");
|
|
assert_eq!(
|
|
restored.get_network_identity(),
|
|
config.get_network_identity()
|
|
);
|
|
assert_eq!(restored.get_listener_uris(), config.get_listener_uris());
|
|
assert_eq!(restored.get_flags().mtu, 1420);
|
|
assert_eq!(restored.get_flags().socket_mark, Some(0));
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_vpn_portal_client_cidr_is_rejected_explicitly() {
|
|
let error = TomlConfig::new_from_str(
|
|
r#"
|
|
[vpn_portal_config]
|
|
client_cidr = "10.14.14.0/24"
|
|
wireguard_listen = "0.0.0.0:51820"
|
|
"#,
|
|
)
|
|
.unwrap_err()
|
|
.to_string();
|
|
|
|
assert!(error.contains("client_cidr"), "{error}");
|
|
}
|
|
|
|
#[cfg(feature = "config-write")]
|
|
#[test]
|
|
fn vpn_portal_round_trip_and_redacted_dump_preserve_dump_semantics() {
|
|
let config = TomlConfig::new_from_str(
|
|
r#"
|
|
[network_identity]
|
|
network_name = "network-a"
|
|
network_secret = "network-secret"
|
|
|
|
[secure_mode]
|
|
enabled = true
|
|
local_private_key = "noise-private-key"
|
|
|
|
[vpn_portal_config]
|
|
wireguard_listen = "0.0.0.0:51820"
|
|
wireguard_private_key = "wireguard-private-key"
|
|
|
|
[[vpn_portal_config.clients]]
|
|
name = "alice"
|
|
virtual_ip = "10.144.144.10"
|
|
groups = ["staff"]
|
|
|
|
[acl.acl_v1.group]
|
|
|
|
[[acl.acl_v1.group.declares]]
|
|
group_name = "staff"
|
|
group_secret = "group-secret"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let dumped = config.dump();
|
|
assert!(dumped.contains("network-secret"));
|
|
assert!(dumped.contains("noise-private-key"));
|
|
assert!(dumped.contains("wireguard-private-key"));
|
|
assert!(dumped.contains("group-secret"));
|
|
assert_eq!(
|
|
TomlConfig::new_from_str(&dumped)
|
|
.unwrap()
|
|
.get_vpn_portal_config(),
|
|
config.get_vpn_portal_config()
|
|
);
|
|
|
|
let redacted = config.dump_redacted();
|
|
assert!(!redacted.contains("network-secret"));
|
|
assert!(!redacted.contains("noise-private-key"));
|
|
assert!(!redacted.contains("wireguard-private-key"));
|
|
assert!(!redacted.contains("group-secret"));
|
|
assert_eq!(redacted.matches("<redacted>").count(), 4);
|
|
}
|
|
|
|
#[test]
|
|
fn hostname_normalization_is_portable_and_has_no_host_fallback() {
|
|
let absent = TomlConfig::default();
|
|
assert_eq!(absent.get_hostname(), "");
|
|
|
|
let configured = TomlConfig::new_from_str("hostname = \"node\\u0007-name\"").unwrap();
|
|
assert_eq!(configured.get_hostname(), "node-name");
|
|
}
|
|
|
|
#[test]
|
|
fn credential_mode_does_not_synthesize_a_network_secret() {
|
|
let config = TomlConfig::new_from_str(
|
|
r#"
|
|
[network_identity]
|
|
network_name = "credential-network"
|
|
|
|
[secure_mode]
|
|
enabled = true
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let identity = config.get_network_identity();
|
|
assert_eq!(identity.network_name, "credential-network");
|
|
assert_eq!(identity.network_secret, None);
|
|
}
|
|
|
|
#[cfg(feature = "config-write")]
|
|
#[test]
|
|
fn user_source_is_implicit_while_web_source_round_trips() {
|
|
let user = TomlConfig::new_from_str(
|
|
r#"
|
|
[source]
|
|
source = "user"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(user.get_network_config_source(), ConfigSource::User);
|
|
assert!(!user.dump().contains("[source]"));
|
|
|
|
let web = TomlConfig::new_from_str(
|
|
r#"
|
|
[source]
|
|
source = "web"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(web.get_network_config_source(), ConfigSource::Web);
|
|
assert!(web.dump().contains("source = \"web\""));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod compatibility_tests {
|
|
use super::*;
|
|
|
|
#[cfg(feature = "config-write")]
|
|
#[test]
|
|
fn flags_diff_covers_every_protobuf_field() {
|
|
use prost::Message as _;
|
|
|
|
let descriptor_set =
|
|
prost_types::FileDescriptorSet::decode(crate::proto::DESCRIPTOR_POOL_BYTES).unwrap();
|
|
let proto_fields = descriptor_set
|
|
.file
|
|
.iter()
|
|
.find(|file| file.package.as_deref() == Some("common"))
|
|
.and_then(|file| {
|
|
file.message_type
|
|
.iter()
|
|
.find(|message| message.name.as_deref() == Some("FlagsInConfig"))
|
|
})
|
|
.unwrap()
|
|
.field
|
|
.iter()
|
|
.map(|field| field.name.as_deref().unwrap())
|
|
.collect::<std::collections::BTreeSet<_>>();
|
|
let diff_fields = FLAGS_DIFF_FIELDS
|
|
.iter()
|
|
.copied()
|
|
.collect::<std::collections::BTreeSet<_>>();
|
|
|
|
assert_eq!(diff_fields, proto_fields);
|
|
}
|
|
|
|
#[test]
|
|
fn socket_mark_config_file_roundtrip_none_some_and_zero() {
|
|
// Omitting the flag leaves socket_mark unset (None) -> SO_MARK untouched.
|
|
let cfg = TomlConfigLoader::new_from_str(
|
|
r#"
|
|
[network_identity]
|
|
network_name = "n"
|
|
network_secret = "s"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(cfg.get_flags().socket_mark, None);
|
|
|
|
// socket_mark = 0 is a legitimate value distinct from "unset".
|
|
let cfg = TomlConfigLoader::new_from_str(
|
|
r#"
|
|
[network_identity]
|
|
network_name = "n"
|
|
network_secret = "s"
|
|
|
|
[flags]
|
|
socket_mark = 0
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(cfg.get_flags().socket_mark, Some(0));
|
|
|
|
// A non-zero mark round-trips as Some(v).
|
|
let cfg = TomlConfigLoader::new_from_str(
|
|
r#"
|
|
[network_identity]
|
|
network_name = "n"
|
|
network_secret = "s"
|
|
|
|
[flags]
|
|
socket_mark = 66
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(cfg.get_flags().socket_mark, Some(66));
|
|
|
|
// set_flags(None) must serialize back through gen_config without
|
|
// resurrecting a value (guards the gen_flags merge against dropping
|
|
// the key when the serialized default is null).
|
|
cfg.set_flags(Flags {
|
|
socket_mark: None,
|
|
..cfg.get_flags()
|
|
});
|
|
assert_eq!(cfg.get_flags().socket_mark, None);
|
|
}
|
|
|
|
#[cfg(feature = "config-write")]
|
|
#[test]
|
|
fn dump_preserves_flags_that_differ_from_easytier_defaults() {
|
|
let cfg = TomlConfigLoader::default();
|
|
let mut flags = gen_default_flags();
|
|
flags.dev_name = "et_test".to_string();
|
|
flags.enable_quic_proxy = true;
|
|
flags.disable_tcp_hole_punching = true;
|
|
flags.disable_sym_hole_punching = true;
|
|
flags.multi_thread = false;
|
|
flags.bind_device = false;
|
|
flags.enable_ipv6 = false;
|
|
flags.relay_network_whitelist = "".to_string();
|
|
flags.mtu = 0;
|
|
flags.foreign_relay_bps_limit = u64::MAX - 1;
|
|
flags.instance_recv_bps_limit = u64::MAX - 2;
|
|
flags.data_compress_algo = CompressionAlgoPb::Zstd.into();
|
|
flags.socket_mark = Some(0);
|
|
cfg.set_flags(flags);
|
|
|
|
let dumped = cfg.dump();
|
|
|
|
assert!(dumped.contains("dev_name = \"et_test\""));
|
|
assert!(dumped.contains("enable_quic_proxy = true"));
|
|
assert!(dumped.contains("disable_tcp_hole_punching = true"));
|
|
assert!(dumped.contains("disable_sym_hole_punching = true"));
|
|
assert!(dumped.contains("multi_thread = false"));
|
|
assert!(dumped.contains("bind_device = false"));
|
|
assert!(dumped.contains("enable_ipv6 = false"));
|
|
assert!(dumped.contains("relay_network_whitelist = \"\""));
|
|
assert!(dumped.contains("mtu = 0"));
|
|
assert!(dumped.contains("foreign_relay_bps_limit = \"18446744073709551614\""));
|
|
assert!(dumped.contains("instance_recv_bps_limit = \"18446744073709551613\""));
|
|
assert!(dumped.contains("data_compress_algo = \"Zstd\""));
|
|
assert!(dumped.contains("socket_mark = 0"));
|
|
|
|
let reloaded = TomlConfigLoader::new_from_str(&dumped).unwrap();
|
|
let reloaded_flags = reloaded.get_flags();
|
|
assert_eq!(reloaded_flags.dev_name, "et_test");
|
|
assert!(reloaded_flags.enable_quic_proxy);
|
|
assert!(reloaded_flags.disable_tcp_hole_punching);
|
|
assert!(reloaded_flags.disable_sym_hole_punching);
|
|
assert!(!reloaded_flags.multi_thread);
|
|
assert!(!reloaded_flags.bind_device);
|
|
assert!(!reloaded_flags.enable_ipv6);
|
|
assert_eq!(reloaded_flags.relay_network_whitelist, "");
|
|
assert_eq!(reloaded_flags.mtu, 0);
|
|
assert_eq!(reloaded_flags.foreign_relay_bps_limit, u64::MAX - 1);
|
|
assert_eq!(reloaded_flags.instance_recv_bps_limit, u64::MAX - 2);
|
|
assert_eq!(
|
|
reloaded_flags.data_compress_algo,
|
|
i32::from(CompressionAlgoPb::Zstd)
|
|
);
|
|
assert_eq!(reloaded_flags.socket_mark, Some(0));
|
|
}
|
|
|
|
#[test]
|
|
fn test_stun_servers_config() {
|
|
let config = TomlConfigLoader::default();
|
|
let stun_servers = config.get_stun_servers();
|
|
assert!(stun_servers.is_none());
|
|
assert!(config.get_tcp_stun_servers().is_none());
|
|
|
|
// Test setting custom stun servers
|
|
let custom_servers = vec!["txt:stun.easytier.cn".to_string()];
|
|
config.set_stun_servers(Some(custom_servers.clone()));
|
|
|
|
let retrieved_servers = config.get_stun_servers();
|
|
assert_eq!(retrieved_servers.unwrap(), custom_servers);
|
|
|
|
let custom_tcp_servers = vec!["tcp-stun.example.com:3478".to_string()];
|
|
config.set_tcp_stun_servers(Some(custom_tcp_servers.clone()));
|
|
|
|
let retrieved_tcp_servers = config.get_tcp_stun_servers();
|
|
assert_eq!(retrieved_tcp_servers.unwrap(), custom_tcp_servers);
|
|
}
|
|
|
|
#[test]
|
|
fn test_stun_servers_toml_parsing() {
|
|
let config_str = r#"
|
|
instance_name = "test"
|
|
stun_servers = [
|
|
"stun.l.google.com:19302",
|
|
"stun1.l.google.com:19302",
|
|
"txt:stun.easytier.cn"
|
|
]
|
|
tcp_stun_servers = [
|
|
"tcp-stun.example.com:3478"
|
|
]"#;
|
|
|
|
let config = TomlConfigLoader::new_from_str(config_str).unwrap();
|
|
let stun_servers = config.get_stun_servers().unwrap();
|
|
let tcp_stun_servers = config.get_tcp_stun_servers().unwrap();
|
|
|
|
assert_eq!(stun_servers.len(), 3);
|
|
assert_eq!(stun_servers[0], "stun.l.google.com:19302");
|
|
assert_eq!(stun_servers[1], "stun1.l.google.com:19302");
|
|
assert_eq!(stun_servers[2], "txt:stun.easytier.cn");
|
|
assert_eq!(tcp_stun_servers, ["tcp-stun.example.com:3478"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_empty_tcp_stun_servers_toml_parsing() {
|
|
let config = TomlConfigLoader::new_from_str(
|
|
r#"
|
|
instance_name = "test"
|
|
tcp_stun_servers = []
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(config.get_tcp_stun_servers(), Some(Vec::new()));
|
|
}
|
|
|
|
#[cfg(feature = "config-write")]
|
|
#[test]
|
|
fn test_network_config_source_toml_roundtrip() {
|
|
let config = TomlConfigLoader::default();
|
|
assert_eq!(config.get_network_config_source(), ConfigSource::User);
|
|
|
|
config.set_network_config_source(Some(ConfigSource::Web));
|
|
let dumped = config.dump();
|
|
|
|
assert!(dumped.contains("[source]"));
|
|
assert!(dumped.contains("source = \"web\""));
|
|
|
|
let loaded = TomlConfigLoader::new_from_str(&dumped).unwrap();
|
|
assert_eq!(loaded.get_network_config_source(), ConfigSource::Web);
|
|
}
|
|
|
|
#[cfg(feature = "config-write")]
|
|
#[test]
|
|
fn test_toml_credential_mode_omits_network_secret() {
|
|
for network_secret in ["", r#"network_secret = """#] {
|
|
let config = TomlConfigLoader::new_from_str(&format!(
|
|
r#"
|
|
[network_identity]
|
|
network_name = "credential-network"
|
|
{network_secret}
|
|
|
|
[secure_mode]
|
|
enabled = true
|
|
"#
|
|
))
|
|
.unwrap();
|
|
|
|
let identity = config.get_network_identity();
|
|
assert_eq!(identity.network_name, "credential-network");
|
|
assert_eq!(identity.network_secret, None);
|
|
assert_eq!(identity.network_secret_digest, None);
|
|
assert!(!config.dump().contains("network_secret"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_toml_secure_mode_without_network_identity_uses_default_secret() {
|
|
let config = TomlConfigLoader::new_from_str(
|
|
r#"
|
|
[secure_mode]
|
|
enabled = true
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let identity = config.get_network_identity();
|
|
assert_eq!(identity.network_name, "default");
|
|
assert_eq!(identity.network_secret.as_deref(), Some(""));
|
|
assert!(identity.network_secret_digest.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_acl_toml_rule_uses_defaults_for_omitted_fields() {
|
|
use crate::proto::acl::{Action, ChainType, Protocol};
|
|
|
|
let config_str = r#"
|
|
[[acl.acl_v1.chains]]
|
|
name = "subnet_proxy_protect"
|
|
chain_type = 3
|
|
enabled = true
|
|
default_action = 2
|
|
|
|
[[acl.acl_v1.chains.rules]]
|
|
name = "allow_my_devices"
|
|
priority = 1000
|
|
action = 1
|
|
source_ips = ["10.172.192.2/32"]
|
|
protocol = 5
|
|
enabled = true
|
|
"#;
|
|
|
|
let config = TomlConfigLoader::new_from_str(config_str).unwrap();
|
|
let acl = config.get_acl().unwrap();
|
|
let acl_v1 = acl.acl_v1.unwrap();
|
|
let chain = &acl_v1.chains[0];
|
|
let rule = &chain.rules[0];
|
|
|
|
assert_eq!(chain.chain_type, ChainType::Forward as i32);
|
|
assert_eq!(chain.default_action, Action::Drop as i32);
|
|
assert_eq!(rule.action, Action::Allow as i32);
|
|
assert_eq!(rule.protocol, Protocol::Any as i32);
|
|
assert_eq!(rule.source_ips, vec!["10.172.192.2/32"]);
|
|
assert!(rule.ports.is_empty());
|
|
assert!(rule.source_ports.is_empty());
|
|
assert!(rule.destination_ips.is_empty());
|
|
assert!(rule.source_groups.is_empty());
|
|
assert!(rule.destination_groups.is_empty());
|
|
assert_eq!(rule.rate_limit, 0);
|
|
assert_eq!(rule.burst_limit, 0);
|
|
assert!(!rule.stateful);
|
|
}
|
|
|
|
#[test]
|
|
fn test_acl_toml_group_can_omit_declares_or_members() {
|
|
let declares_only = r#"
|
|
[acl.acl_v1.group]
|
|
|
|
[[acl.acl_v1.group.declares]]
|
|
group_name = "admin"
|
|
group_secret = "admin-pw"
|
|
"#;
|
|
let config = TomlConfigLoader::new_from_str(declares_only).unwrap();
|
|
let group = config.get_acl().unwrap().acl_v1.unwrap().group.unwrap();
|
|
assert_eq!(group.declares.len(), 1);
|
|
assert!(group.members.is_empty());
|
|
|
|
let members_only = r#"
|
|
[acl.acl_v1.group]
|
|
members = ["admin"]
|
|
"#;
|
|
let config = TomlConfigLoader::new_from_str(members_only).unwrap();
|
|
let group = config.get_acl().unwrap().acl_v1.unwrap().group.unwrap();
|
|
assert!(group.declares.is_empty());
|
|
assert_eq!(group.members, vec!["admin"]);
|
|
}
|
|
|
|
#[cfg(feature = "config-write")]
|
|
#[test]
|
|
fn test_network_config_source_user_is_implicit() {
|
|
let config = TomlConfigLoader::default();
|
|
config.set_network_config_source(Some(ConfigSource::User));
|
|
let dumped = config.dump();
|
|
|
|
assert!(!dumped.contains("[source]"));
|
|
|
|
let loaded = TomlConfigLoader::new_from_str(&dumped).unwrap();
|
|
assert_eq!(loaded.get_network_config_source(), ConfigSource::User);
|
|
|
|
let explicit_user = TomlConfigLoader::new_from_str(
|
|
r#"
|
|
[source]
|
|
source = "user"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
explicit_user.get_network_config_source(),
|
|
ConfigSource::User
|
|
);
|
|
assert!(!explicit_user.dump().contains("[source]"));
|
|
}
|
|
|
|
#[cfg(feature = "config-write")]
|
|
#[test]
|
|
fn test_ipv6_public_addr_config_roundtrip() {
|
|
let config = TomlConfigLoader::default();
|
|
let prefix: cidr::Ipv6Cidr = "2001:db8:100::/64".parse().unwrap();
|
|
|
|
config.set_ipv6_public_addr_provider(true);
|
|
config.set_ipv6_public_addr_auto(true);
|
|
config.set_ipv6_public_addr_prefix(Some(prefix));
|
|
|
|
assert!(config.get_ipv6_public_addr_provider());
|
|
assert!(config.get_ipv6_public_addr_auto());
|
|
assert_eq!(config.get_ipv6_public_addr_prefix(), Some(prefix));
|
|
|
|
let dumped = config.dump();
|
|
let loaded = TomlConfigLoader::new_from_str(&dumped).unwrap();
|
|
assert!(loaded.get_ipv6_public_addr_provider());
|
|
assert!(loaded.get_ipv6_public_addr_auto());
|
|
assert_eq!(loaded.get_ipv6_public_addr_prefix(), Some(prefix));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod full_example_tests {
|
|
use super::*;
|
|
|
|
#[cfg(feature = "config-write")]
|
|
#[test]
|
|
fn full_example_test() {
|
|
let config_str = r#"
|
|
instance_name = "default"
|
|
instance_id = "87ede5a2-9c3d-492d-9bbe-989b9d07e742"
|
|
ipv4 = "10.144.144.10"
|
|
listeners = [ "tcp://0.0.0.0:11010", "udp://0.0.0.0:11010" ]
|
|
routes = [ "192.168.0.0/16" ]
|
|
|
|
[network_identity]
|
|
network_name = "default"
|
|
network_secret = ""
|
|
|
|
[[peer]]
|
|
uri = "tcp://public.kkrainbow.top:11010"
|
|
|
|
[[peer]]
|
|
uri = "udp://192.168.94.33:11010"
|
|
|
|
[[proxy_network]]
|
|
cidr = "10.147.223.0/24"
|
|
allow = ["tcp", "udp", "icmp"]
|
|
|
|
[[proxy_network]]
|
|
cidr = "10.1.1.0/24"
|
|
allow = ["tcp", "icmp"]
|
|
|
|
[file_logger]
|
|
level = "info"
|
|
file = "easytier"
|
|
dir = "/tmp/easytier"
|
|
|
|
[console_logger]
|
|
level = "warn"
|
|
|
|
[[port_forward]]
|
|
bind_addr = "0.0.0.0:11011"
|
|
dst_addr = "192.168.94.33:11011"
|
|
proto = "tcp"
|
|
"#;
|
|
let ret = TomlConfigLoader::new_from_str(config_str);
|
|
if let Err(e) = &ret {
|
|
println!("{}", e);
|
|
} else {
|
|
println!("{:?}", ret.as_ref().unwrap());
|
|
}
|
|
assert!(ret.is_ok());
|
|
|
|
let ret = ret.unwrap();
|
|
assert_eq!("10.144.144.10/24", ret.get_ipv4().unwrap().to_string());
|
|
|
|
assert_eq!(
|
|
vec!["tcp://0.0.0.0:11010", "udp://0.0.0.0:11010"],
|
|
ret.get_listener_uris()
|
|
.iter()
|
|
.map(|u| u.to_string())
|
|
.collect::<Vec<String>>()
|
|
);
|
|
|
|
assert_eq!(
|
|
vec![PortForwardConfig {
|
|
bind_addr: "0.0.0.0:11011".parse().unwrap(),
|
|
dst_addr: "192.168.94.33:11011".parse().unwrap(),
|
|
proto: "tcp".to_string(),
|
|
}],
|
|
ret.get_port_forwards()
|
|
);
|
|
println!("{}", ret.dump());
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod diagnostic_compatibility_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn stdin_source_name_and_caret_are_preserved() {
|
|
let error = TomlConfig::new_from_str_with_source("stdin", "dhcp = \"yes\"")
|
|
.unwrap_err()
|
|
.to_string();
|
|
|
|
assert!(error.contains("stdin"));
|
|
assert!(error.contains("dhcp = \"yes\""));
|
|
assert!(error.contains('^'));
|
|
assert!(!error.contains("<unknown>"));
|
|
}
|
|
|
|
#[test]
|
|
fn non_ascii_before_typed_error_keeps_byte_location() {
|
|
let error = TomlConfig::new_from_str("hostname = \"节点\"\ndhcp = \"yes\"")
|
|
.unwrap_err()
|
|
.to_string();
|
|
|
|
assert!(error.contains("dhcp = \"yes\""));
|
|
assert!(error.contains('^'));
|
|
assert!(error.contains("invalid type: string"));
|
|
}
|
|
|
|
#[cfg(feature = "rich-config-errors")]
|
|
#[test]
|
|
fn non_ascii_on_syntax_error_line_keeps_source_location() {
|
|
let error = TomlConfig::new_from_str("hostname = \"节点\" dhcp = \"yes\"")
|
|
.unwrap_err()
|
|
.to_string();
|
|
|
|
assert!(error.contains("inline config:1:"));
|
|
assert!(error.contains("hostname = \"节点\" dhcp = \"yes\""));
|
|
assert!(error.contains("expected newline"));
|
|
assert!(!error.contains("<unknown>"));
|
|
}
|
|
|
|
#[test]
|
|
fn flags_conversion_error_keeps_source_and_cause_chain() {
|
|
let error = TomlConfig::new_from_str_with_source(
|
|
"flags-fixture.toml",
|
|
"[flags]\nsocket_mark = \"bad\"",
|
|
)
|
|
.unwrap_err();
|
|
let display = error.to_string();
|
|
|
|
assert!(display.contains("flags-fixture.toml"));
|
|
assert!(display.contains("failed to load config"));
|
|
assert!(display.contains("failed to parse flags"));
|
|
assert!(
|
|
error
|
|
.chain()
|
|
.any(|cause| cause.to_string().contains("failed to parse flags"))
|
|
);
|
|
}
|
|
}
|