Files
Easytier/easytier-core/src/config/toml.rs
T
KKRainbow 62e4fd15e9 feat(vpn): multi-client WireGuard portal with attached peers (#2502)
* 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.
2026-08-21 10:59:05 +08:00

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"))
);
}
}