mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-11-10 21:20:56 +08:00
* feat(dns): support local static records * fix(dns): normalize zone names with LowerName, preload static zones, and deprecate tld_dns_zone - Normalize DNS zone names to LowerName across the server catalog and RPC boundaries to prevent uppercase/non-canonical zone suffixes from clearing node records. - Preload all configured static zones during server initialization before registering the resolver and applying system DNS configuration. - Deprecate tld_dns_zone in favor of [dns] domain, keeping tld_dns_zone as a hidden fallback without exposing it in help or schema defaults. - Keep MagicDnsRecordStore using LowerName and restrict dns_route_identity to node hostname and IP.
1320 lines
49 KiB
Rust
1320 lines
49 KiB
Rust
//! Conversion between the management NetworkConfig schema and shared TOML.
|
|
|
|
use std::net::SocketAddr;
|
|
|
|
use anyhow::Context;
|
|
use easytier_proto::{api::manage, common::FlagsPatch};
|
|
|
|
#[cfg(all(
|
|
feature = "browser-config",
|
|
any(test, all(target_arch = "wasm32", target_os = "unknown"))
|
|
))]
|
|
use easytier_proto::common::Flags;
|
|
use optionize::{Optionizable, Optionized};
|
|
|
|
use crate::config::{
|
|
MappedListenerPolicy, normalize_secure_mode_config,
|
|
toml::{
|
|
ConfigLoader, NetworkIdentity, PeerConfig, PortForwardConfig, TomlConfigLoader,
|
|
VpnPortalClientConfig, VpnPortalConfig,
|
|
},
|
|
};
|
|
|
|
fn parse_mapped_listener_urls(mapped_listeners: &[String]) -> Result<Vec<url::Url>, anyhow::Error> {
|
|
MappedListenerPolicy::new(["tcp", "udp", "wg", "quic", "ws", "wss", "faketcp"])
|
|
.parse_urls(mapped_listeners)
|
|
}
|
|
|
|
pub fn add_proxy_network_to_config(
|
|
proxy_network: &str,
|
|
cfg: &TomlConfigLoader,
|
|
) -> Result<(), anyhow::Error> {
|
|
let parts: Vec<&str> = proxy_network.split("->").collect();
|
|
let real_cidr = parts[0]
|
|
.parse()
|
|
.with_context(|| format!("failed to parse proxy network: {}", parts[0]))?;
|
|
|
|
if parts.len() > 2 {
|
|
return Err(anyhow::anyhow!(
|
|
"invalid proxy network format: {}, support format: <real_cidr> or <real_cidr>-><mapped_cidr>, example:
|
|
10.0.0.0/24 or 10.0.0.0/24->192.168.0.0/24",
|
|
proxy_network
|
|
));
|
|
}
|
|
|
|
let mapped_cidr = if parts.len() == 2 {
|
|
Some(
|
|
parts[1]
|
|
.parse()
|
|
.with_context(|| format!("failed to parse mapped network: {}", parts[1]))?,
|
|
)
|
|
} else {
|
|
None
|
|
};
|
|
cfg.add_proxy_cidr(real_cidr, mapped_cidr)?;
|
|
Ok(())
|
|
}
|
|
|
|
pub type NetworkingMethod = easytier_proto::api::manage::NetworkingMethod;
|
|
pub type NetworkConfig = easytier_proto::api::manage::NetworkConfig;
|
|
|
|
pub(crate) fn set_network_flags(result: &mut NetworkConfig, flags: FlagsPatch) {
|
|
result.latency_first = flags.latency_first;
|
|
result.dev_name = flags.dev_name;
|
|
result.use_smoltcp = flags.use_smoltcp;
|
|
result.disable_ipv6 = flags.enable_ipv6.map(|enabled| !enabled);
|
|
result.enable_kcp_proxy = flags.enable_kcp_proxy;
|
|
result.disable_kcp_input = flags.disable_kcp_input;
|
|
result.enable_quic_proxy = flags.enable_quic_proxy;
|
|
result.disable_quic_input = flags.disable_quic_input;
|
|
result.disable_p2p = flags.disable_p2p;
|
|
result.p2p_only = flags.p2p_only;
|
|
result.lazy_p2p = flags.lazy_p2p;
|
|
result.bind_device = flags.bind_device;
|
|
result.socket_mark = flags.socket_mark;
|
|
result.no_tun = flags.no_tun;
|
|
result.enable_exit_node = flags.enable_exit_node;
|
|
result.relay_all_peer_rpc = flags.relay_all_peer_rpc;
|
|
result.need_p2p = flags.need_p2p;
|
|
result.multi_thread = flags.multi_thread;
|
|
result.proxy_forward_by_system = flags.proxy_forward_by_system;
|
|
result.disable_encryption = flags.enable_encryption.map(|enabled| !enabled);
|
|
result.disable_tcp_hole_punching = flags.disable_tcp_hole_punching;
|
|
result.disable_udp_hole_punching = flags.disable_udp_hole_punching;
|
|
result.disable_upnp = flags.disable_upnp;
|
|
result.disable_relay_data = flags.disable_relay_data;
|
|
result.prefer_peer_relay = flags.prefer_peer_relay;
|
|
result.enable_udp_broadcast_relay = flags.enable_udp_broadcast_relay;
|
|
result.disable_sym_hole_punching = flags.disable_sym_hole_punching;
|
|
result.enable_magic_dns = flags.accept_dns;
|
|
#[allow(deprecated)]
|
|
{
|
|
result.tld_dns_zone = flags.tld_dns_zone;
|
|
}
|
|
result.mtu = flags.mtu.map(|mtu| mtu as i32);
|
|
result.data_compress_algo = flags.data_compress_algo;
|
|
result.encryption_algorithm = flags.encryption_algorithm;
|
|
result.instance_recv_bps_limit = flags.instance_recv_bps_limit;
|
|
result.enable_private_mode = flags.private_mode;
|
|
|
|
result.enable_relay_network_whitelist = flags
|
|
.relay_network_whitelist
|
|
.as_ref()
|
|
.map(|list| list != "*");
|
|
result.relay_network_whitelist = flags
|
|
.relay_network_whitelist
|
|
.filter(|list| list != "*")
|
|
.map(|list| list.split_whitespace().map(ToOwned::to_owned).collect())
|
|
.unwrap_or_default();
|
|
}
|
|
|
|
pub trait NetworkConfigExt {
|
|
fn gen_config(&self) -> Result<TomlConfigLoader, anyhow::Error>;
|
|
fn new_from_config(config: impl ConfigLoader) -> Result<NetworkConfig, anyhow::Error>;
|
|
}
|
|
|
|
#[cfg(all(
|
|
feature = "browser-config",
|
|
any(test, all(target_arch = "wasm32", target_os = "unknown"))
|
|
))]
|
|
const FORM_MANAGED_TOML_FIELDS: &[&str] = &[
|
|
"hostname",
|
|
"dns",
|
|
"instance_id",
|
|
"ipv4",
|
|
"ipv6_public_addr_provider",
|
|
"ipv6_public_addr_auto",
|
|
"ipv6_public_addr_prefix",
|
|
"dhcp",
|
|
"dhcpv6",
|
|
"network_identity",
|
|
"listeners",
|
|
"mapped_listeners",
|
|
"exit_nodes",
|
|
"peer",
|
|
"proxy_network",
|
|
"vpn_portal_config",
|
|
"routes",
|
|
"socks5_proxy",
|
|
"port_forward",
|
|
"secure_mode",
|
|
"acl",
|
|
"credential_file",
|
|
"managed_credentials",
|
|
];
|
|
|
|
#[cfg(all(
|
|
feature = "browser-config",
|
|
any(test, all(target_arch = "wasm32", target_os = "unknown"))
|
|
))]
|
|
pub(crate) fn merge_network_config_toml(
|
|
original_toml: &str,
|
|
config: &NetworkConfig,
|
|
) -> Result<String, anyhow::Error> {
|
|
let generated_toml = config.gen_config()?.dump();
|
|
let mut original = toml::from_str::<toml::Table>(original_toml)
|
|
.context("failed to parse the original TOML document")?;
|
|
let mut generated = toml::from_str::<toml::Table>(&generated_toml)
|
|
.context("failed to parse the generated TOML document")?;
|
|
|
|
for (key, value) in original.iter() {
|
|
if key != "flags" && !FORM_MANAGED_TOML_FIELDS.contains(&key.as_str()) {
|
|
generated.insert(key.clone(), value.clone());
|
|
}
|
|
}
|
|
if !original.contains_key("instance_name") {
|
|
generated.remove("instance_name");
|
|
}
|
|
|
|
let mut merged_flags: toml::Table = original
|
|
.remove("flags")
|
|
.and_then(|value| value.try_into().ok())
|
|
.unwrap_or_default();
|
|
let generated_flags: toml::Table = generated
|
|
.remove("flags")
|
|
.and_then(|value| value.try_into().ok())
|
|
.unwrap_or_default();
|
|
|
|
// Normalize any camelCase aliases in original [flags] to their canonical field names
|
|
// so each field appears at most once in the merged table.
|
|
for field in Flags::flags() {
|
|
if let (Some(name), Some(json_name)) = (field.name.as_deref(), field.json_name.as_deref()) {
|
|
if name != json_name
|
|
&& let Some(val) = merged_flags.remove(json_name)
|
|
{
|
|
merged_flags.entry(name.to_owned()).or_insert(val);
|
|
}
|
|
}
|
|
}
|
|
|
|
// The keys a config form owns are the flags it offers a control for, which
|
|
// the schema declares with `(easytier.flag)`.
|
|
for key in Flags::form() {
|
|
if let Some(value) = generated_flags.get(*key) {
|
|
merged_flags.insert((*key).to_owned(), value.clone());
|
|
} else {
|
|
merged_flags.remove(*key);
|
|
}
|
|
}
|
|
if !merged_flags.is_empty() {
|
|
generated.insert("flags".to_owned(), toml::Value::Table(merged_flags));
|
|
}
|
|
|
|
toml::to_string_pretty(&generated).context("failed to serialize the merged TOML document")
|
|
}
|
|
|
|
fn parse_peer(peer: &manage::NetworkPeerConfig) -> Result<Option<PeerConfig>, anyhow::Error> {
|
|
let uri = peer.uri.trim();
|
|
if uri.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
Ok(Some(PeerConfig {
|
|
uri: uri
|
|
.parse()
|
|
.with_context(|| format!("failed to parse peer uri: {}", uri))?,
|
|
peer_public_key: peer.peer_public_key.clone(),
|
|
}))
|
|
}
|
|
|
|
fn parse_peers(peers: &[manage::NetworkPeerConfig]) -> Result<Vec<PeerConfig>, anyhow::Error> {
|
|
let mut ret = Vec::new();
|
|
for peer in peers {
|
|
if let Some(peer) = parse_peer(peer)? {
|
|
ret.push(peer);
|
|
}
|
|
}
|
|
Ok(ret)
|
|
}
|
|
|
|
fn parse_peer_urls(peer_urls: &[String]) -> Result<Vec<PeerConfig>, anyhow::Error> {
|
|
let mut peers = vec![];
|
|
for peer_url in peer_urls.iter() {
|
|
let peer_url = peer_url.trim();
|
|
if peer_url.is_empty() {
|
|
continue;
|
|
}
|
|
peers.push(PeerConfig {
|
|
uri: peer_url
|
|
.parse()
|
|
.with_context(|| format!("failed to parse peer uri: {}", peer_url))?,
|
|
peer_public_key: None,
|
|
});
|
|
}
|
|
Ok(peers)
|
|
}
|
|
|
|
impl NetworkConfigExt for NetworkConfig {
|
|
#[allow(deprecated)]
|
|
fn gen_config(&self) -> Result<TomlConfigLoader, anyhow::Error> {
|
|
let cfg = TomlConfigLoader::default();
|
|
cfg.set_dns(self.dns.clone());
|
|
cfg.set_id(
|
|
self.instance_id
|
|
.clone()
|
|
.unwrap_or(uuid::Uuid::new_v4().to_string())
|
|
.parse()
|
|
.with_context(|| format!("failed to parse instance id: {:?}", self.instance_id))?,
|
|
);
|
|
cfg.set_hostname(self.hostname.clone());
|
|
cfg.set_dhcp(self.dhcp.unwrap_or(self.dhcp_ipv4_cidr.is_some()));
|
|
if cfg.get_dhcp()
|
|
&& let Some(cidr) = &self.dhcp_ipv4_cidr
|
|
{
|
|
cfg.set_dhcp_config(super::DhcpConfig::from_cidr(cidr)?);
|
|
}
|
|
cfg.set_dhcpv6(self.dhcpv6.unwrap_or(self.dhcp_ipv6_cidr.is_some()));
|
|
if cfg.get_dhcpv6()
|
|
&& let Some(cidr) = &self.dhcp_ipv6_cidr
|
|
{
|
|
cfg.set_dhcpv6_config(super::DhcpConfig::from_cidr(cidr)?);
|
|
}
|
|
cfg.set_inst_name(self.network_name.clone().unwrap_or_default());
|
|
|
|
// The web UI does not expose credential inputs directly, but imported/saved
|
|
// NetworkConfig objects still need to preserve credential-mode instances via
|
|
// secure_mode.local_private_key + empty network_secret.
|
|
let credential_secret = if self.network_secret.is_some() {
|
|
None
|
|
} else {
|
|
self.secure_mode
|
|
.as_ref()
|
|
.and_then(|mode| mode.local_private_key.clone())
|
|
.filter(|s| !s.is_empty())
|
|
};
|
|
|
|
if credential_secret.is_some() {
|
|
cfg.set_network_identity(NetworkIdentity::new_credential(
|
|
self.network_name.clone().unwrap_or_default(),
|
|
));
|
|
} else {
|
|
cfg.set_network_identity(NetworkIdentity::new(
|
|
self.network_name.clone().unwrap_or_default(),
|
|
self.network_secret.clone().unwrap_or_default(),
|
|
));
|
|
}
|
|
|
|
if !cfg.get_dhcp() {
|
|
let virtual_ipv4 = self.virtual_ipv4.clone().unwrap_or_default();
|
|
if !virtual_ipv4.is_empty() {
|
|
let ip = format!("{}/{}", virtual_ipv4, self.network_length.unwrap_or(24))
|
|
.parse()
|
|
.with_context(|| {
|
|
format!(
|
|
"failed to parse ipv4 inet address: {}, {:?}",
|
|
virtual_ipv4, self.network_length
|
|
)
|
|
})?;
|
|
cfg.set_ipv4(Some(ip));
|
|
}
|
|
}
|
|
|
|
match NetworkingMethod::try_from(self.networking_method.unwrap_or_default())
|
|
.unwrap_or_default()
|
|
{
|
|
NetworkingMethod::PublicServer => {
|
|
let peers = parse_peers(&self.peers)?;
|
|
if peers.is_empty() {
|
|
let public_server_url = self.public_server_url.clone().unwrap_or_default();
|
|
cfg.set_peers(vec![PeerConfig {
|
|
uri: public_server_url.parse().with_context(|| {
|
|
format!("failed to parse public server uri: {}", public_server_url)
|
|
})?,
|
|
peer_public_key: None,
|
|
}]);
|
|
} else {
|
|
cfg.set_peers(peers);
|
|
}
|
|
}
|
|
NetworkingMethod::Manual => {
|
|
let mut peers = parse_peers(&self.peers)?;
|
|
if peers.is_empty() {
|
|
peers = parse_peer_urls(&self.peer_urls)?;
|
|
}
|
|
if !peers.is_empty() {
|
|
cfg.set_peers(peers);
|
|
}
|
|
}
|
|
NetworkingMethod::Standalone => {}
|
|
}
|
|
|
|
let mut listener_urls = vec![];
|
|
for listener_url in self.listener_urls.iter() {
|
|
if listener_url.is_empty() {
|
|
continue;
|
|
}
|
|
listener_urls.push(
|
|
listener_url
|
|
.parse()
|
|
.with_context(|| format!("failed to parse listener uri: {}", listener_url))?,
|
|
);
|
|
}
|
|
cfg.set_listeners(listener_urls);
|
|
|
|
for n in self.proxy_cidrs.iter() {
|
|
add_proxy_network_to_config(n, &cfg)?;
|
|
}
|
|
|
|
if !self.port_forwards.is_empty() {
|
|
cfg.set_port_forwards(
|
|
self.port_forwards
|
|
.iter()
|
|
.filter(|pf| !pf.bind_ip.is_empty() && !pf.dst_ip.is_empty())
|
|
.filter_map(|pf| {
|
|
let bind_addr =
|
|
format!("{}:{}", pf.bind_ip, pf.bind_port).parse::<SocketAddr>();
|
|
let dst_addr =
|
|
format!("{}:{}", pf.dst_ip, pf.dst_port).parse::<SocketAddr>();
|
|
|
|
match (bind_addr, dst_addr) {
|
|
(Ok(bind_addr), Ok(dst_addr)) => Some(PortForwardConfig {
|
|
bind_addr,
|
|
dst_addr,
|
|
proto: pf.proto.clone(),
|
|
}),
|
|
_ => None,
|
|
}
|
|
})
|
|
.collect::<Vec<_>>(),
|
|
);
|
|
}
|
|
|
|
if self.enable_vpn_portal == Some(true) {
|
|
anyhow::bail!(
|
|
"legacy VPN portal configuration is no longer supported; configure vpn_portal_config with named clients"
|
|
);
|
|
}
|
|
|
|
if let Some(vpn_config) = &self.vpn_portal_config {
|
|
cfg.set_vpn_portal_config(VpnPortalConfig {
|
|
enabled: vpn_config.enabled,
|
|
wireguard_listen: vpn_config.wireguard_listen.parse().with_context(|| {
|
|
format!(
|
|
"failed to parse vpn portal wireguard listen address: {}",
|
|
vpn_config.wireguard_listen
|
|
)
|
|
})?,
|
|
wireguard_private_key: vpn_config.wireguard_private_key.clone(),
|
|
clients: vpn_config
|
|
.clients
|
|
.iter()
|
|
.map(|client| {
|
|
Ok(VpnPortalClientConfig {
|
|
name: client.name.clone(),
|
|
virtual_ip: client.virtual_ip.parse().with_context(|| {
|
|
format!(
|
|
"failed to parse vpn portal virtual IP for client {}: {}",
|
|
client.name, client.virtual_ip
|
|
)
|
|
})?,
|
|
groups: client.groups.clone(),
|
|
})
|
|
})
|
|
.collect::<Result<Vec<_>, anyhow::Error>>()?,
|
|
});
|
|
}
|
|
|
|
if self.enable_manual_routes.unwrap_or_default() {
|
|
let mut routes = Vec::<cidr::Ipv4Cidr>::with_capacity(self.routes.len());
|
|
for route in self.routes.iter() {
|
|
routes.push(
|
|
route
|
|
.parse()
|
|
.with_context(|| format!("failed to parse route: {}", route))?,
|
|
);
|
|
}
|
|
cfg.set_routes(Some(routes));
|
|
}
|
|
|
|
if !self.exit_nodes.is_empty() {
|
|
let mut exit_nodes = Vec::<std::net::IpAddr>::with_capacity(self.exit_nodes.len());
|
|
for node in self.exit_nodes.iter() {
|
|
exit_nodes.push(
|
|
node.parse()
|
|
.with_context(|| format!("failed to parse exit node: {}", node))?,
|
|
);
|
|
}
|
|
cfg.set_exit_nodes(exit_nodes);
|
|
}
|
|
|
|
if self.enable_socks5.unwrap_or_default()
|
|
&& let Some(socks5_port) = self.socks5_port
|
|
{
|
|
cfg.set_socks5_portal(Some(
|
|
format!("socks5://0.0.0.0:{}", socks5_port).parse().unwrap(),
|
|
));
|
|
}
|
|
|
|
if !self.mapped_listeners.is_empty() {
|
|
let mapped_listeners = parse_mapped_listener_urls(&self.mapped_listeners)?;
|
|
cfg.set_mapped_listeners(Some(mapped_listeners));
|
|
}
|
|
|
|
if let Some(credential_file) = self
|
|
.credential_file
|
|
.as_ref()
|
|
.filter(|path| !path.is_empty())
|
|
{
|
|
cfg.set_credential_file(Some(credential_file.into()));
|
|
}
|
|
|
|
cfg.set_managed_credentials(
|
|
self.managed_credentials
|
|
.iter()
|
|
.map(|credential| credential.clone().upgrade())
|
|
.collect::<Result<Vec<_>, _>>()?,
|
|
);
|
|
|
|
if let Some(credential_secret) = credential_secret {
|
|
cfg.set_secure_mode(Some(normalize_secure_mode_config(
|
|
easytier_proto::common::SecureModeConfig {
|
|
enabled: true,
|
|
local_private_key: Some(credential_secret),
|
|
local_public_key: None,
|
|
},
|
|
)?));
|
|
} else {
|
|
cfg.set_secure_mode(
|
|
self.secure_mode
|
|
.clone()
|
|
.map(normalize_secure_mode_config)
|
|
.transpose()?,
|
|
);
|
|
}
|
|
|
|
if let Some(ipv6_public_addr_provider) = self.ipv6_public_addr_provider {
|
|
cfg.set_ipv6_public_addr_provider(ipv6_public_addr_provider);
|
|
}
|
|
|
|
if let Some(ipv6_public_addr_auto) = self.ipv6_public_addr_auto {
|
|
cfg.set_ipv6_public_addr_auto(ipv6_public_addr_auto);
|
|
}
|
|
|
|
if let Some(ipv6_public_addr_prefix) = self
|
|
.ipv6_public_addr_prefix
|
|
.as_ref()
|
|
.filter(|prefix| !prefix.is_empty())
|
|
{
|
|
cfg.set_ipv6_public_addr_prefix(Some(ipv6_public_addr_prefix.parse().with_context(
|
|
|| format!("failed to parse ipv6 public address prefix: {ipv6_public_addr_prefix}"),
|
|
)?));
|
|
}
|
|
|
|
if let Some(acl) = self.acl.as_ref()
|
|
&& !acl.is_empty()
|
|
{
|
|
cfg.set_acl(Some(acl.clone()));
|
|
}
|
|
|
|
cfg.patch_flags(FlagsPatch {
|
|
dev_name: self.dev_name.clone(),
|
|
enable_ipv6: self.disable_ipv6.map(|disabled| !disabled),
|
|
socket_mark: self.socket_mark,
|
|
enable_encryption: self.disable_encryption.map(|disabled| !disabled),
|
|
relay_network_whitelist: self.enable_relay_network_whitelist.map(|enabled| {
|
|
if enabled {
|
|
self.relay_network_whitelist.join(" ")
|
|
} else {
|
|
"*".to_owned()
|
|
}
|
|
}),
|
|
accept_dns: self.enable_magic_dns,
|
|
#[allow(deprecated)]
|
|
tld_dns_zone: self.tld_dns_zone.clone(),
|
|
mtu: self
|
|
.mtu
|
|
.map(u32::try_from)
|
|
.transpose()
|
|
.context("invalid mtu: expected a non-negative integer")?,
|
|
private_mode: self.enable_private_mode,
|
|
encryption_algorithm: self.encryption_algorithm.clone(),
|
|
data_compress_algo: self.data_compress_algo.map(|algorithm| algorithm.max(1)),
|
|
latency_first: self.latency_first,
|
|
use_smoltcp: self.use_smoltcp,
|
|
enable_kcp_proxy: self.enable_kcp_proxy,
|
|
disable_kcp_input: self.disable_kcp_input,
|
|
enable_quic_proxy: self.enable_quic_proxy,
|
|
disable_quic_input: self.disable_quic_input,
|
|
disable_p2p: self.disable_p2p,
|
|
p2p_only: self.p2p_only,
|
|
lazy_p2p: self.lazy_p2p,
|
|
bind_device: self.bind_device,
|
|
no_tun: self.no_tun,
|
|
enable_exit_node: self.enable_exit_node,
|
|
relay_all_peer_rpc: self.relay_all_peer_rpc,
|
|
need_p2p: self.need_p2p,
|
|
multi_thread: self.multi_thread,
|
|
proxy_forward_by_system: self.proxy_forward_by_system,
|
|
disable_tcp_hole_punching: self.disable_tcp_hole_punching,
|
|
disable_udp_hole_punching: self.disable_udp_hole_punching,
|
|
disable_upnp: self.disable_upnp,
|
|
disable_relay_data: self.disable_relay_data,
|
|
prefer_peer_relay: self.prefer_peer_relay,
|
|
enable_udp_broadcast_relay: self.enable_udp_broadcast_relay,
|
|
disable_sym_hole_punching: self.disable_sym_hole_punching,
|
|
instance_recv_bps_limit: self.instance_recv_bps_limit,
|
|
..Default::default()
|
|
});
|
|
Ok(cfg)
|
|
}
|
|
|
|
fn new_from_config(config: impl ConfigLoader) -> Result<Self, anyhow::Error> {
|
|
let default_config = TomlConfigLoader::default();
|
|
|
|
let mut result = Self {
|
|
..Default::default()
|
|
};
|
|
|
|
result.dns = config.get_dns();
|
|
result.instance_id = Some(config.get_id().to_string());
|
|
if config.get_hostname() != default_config.get_hostname() {
|
|
result.hostname = Some(config.get_hostname());
|
|
}
|
|
|
|
result.dhcp = Some(config.get_dhcp());
|
|
result.dhcp_ipv4_cidr = config.get_dhcp_config().cidr().map(|cidr| cidr.to_string());
|
|
result.dhcpv6 = Some(config.get_dhcpv6());
|
|
result.dhcp_ipv6_cidr = config
|
|
.get_dhcpv6_config()
|
|
.cidr()
|
|
.map(|cidr| cidr.to_string());
|
|
|
|
let network_identity = config.get_network_identity();
|
|
result.network_name = Some(network_identity.network_name.clone());
|
|
result.network_secret = network_identity.network_secret;
|
|
|
|
if let Some(ipv4) = config.get_ipv4() {
|
|
result.virtual_ipv4 = Some(ipv4.address().to_string());
|
|
result.network_length = Some(ipv4.network_length() as i32);
|
|
}
|
|
|
|
if config.get_ipv6_public_addr_provider() != default_config.get_ipv6_public_addr_provider()
|
|
{
|
|
result.ipv6_public_addr_provider = Some(config.get_ipv6_public_addr_provider());
|
|
}
|
|
if config.get_ipv6_public_addr_auto() != default_config.get_ipv6_public_addr_auto() {
|
|
result.ipv6_public_addr_auto = Some(config.get_ipv6_public_addr_auto());
|
|
}
|
|
result.ipv6_public_addr_prefix = config
|
|
.get_ipv6_public_addr_prefix()
|
|
.map(|prefix| prefix.to_string());
|
|
|
|
let peers = config.get_peers();
|
|
result.networking_method = Some(NetworkingMethod::Manual as i32);
|
|
if !peers.is_empty() {
|
|
result.peer_urls = peers.iter().map(|p| p.uri.to_string()).collect();
|
|
result.peers = peers
|
|
.iter()
|
|
.map(|p| manage::NetworkPeerConfig {
|
|
uri: p.uri.to_string(),
|
|
peer_public_key: p.peer_public_key.clone(),
|
|
})
|
|
.collect();
|
|
}
|
|
|
|
result.listener_urls = config
|
|
.get_listeners()
|
|
.unwrap_or_default()
|
|
.iter()
|
|
.map(|l| l.to_string())
|
|
.collect();
|
|
|
|
result.proxy_cidrs = config
|
|
.get_proxy_cidrs()
|
|
.iter()
|
|
.map(|c| {
|
|
if let Some(mapped) = c.mapped_cidr {
|
|
format!("{}->{}", c.cidr, mapped)
|
|
} else {
|
|
c.cidr.to_string()
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
let port_forwards = config.get_port_forwards();
|
|
if !port_forwards.is_empty() {
|
|
result.port_forwards = port_forwards
|
|
.iter()
|
|
.map(|f| manage::PortForwardConfig {
|
|
proto: f.proto.clone(),
|
|
bind_ip: f.bind_addr.ip().to_string(),
|
|
bind_port: f.bind_addr.port() as u32,
|
|
dst_ip: f.dst_addr.ip().to_string(),
|
|
dst_port: f.dst_addr.port() as u32,
|
|
})
|
|
.collect();
|
|
}
|
|
|
|
if let Some(vpn_config) = config.get_vpn_portal_config() {
|
|
result.vpn_portal_config = Some(manage::VpnPortalConfig {
|
|
enabled: vpn_config.enabled,
|
|
wireguard_listen: vpn_config.wireguard_listen.to_string(),
|
|
wireguard_private_key: vpn_config.wireguard_private_key,
|
|
clients: vpn_config
|
|
.clients
|
|
.into_iter()
|
|
.map(|client| manage::VpnPortalClientConfig {
|
|
name: client.name,
|
|
virtual_ip: client.virtual_ip.to_string(),
|
|
groups: client.groups,
|
|
})
|
|
.collect(),
|
|
});
|
|
}
|
|
|
|
if let Some(routes) = config.get_routes() {
|
|
result.enable_manual_routes = Some(true);
|
|
result.routes = routes.iter().map(|r| r.to_string()).collect();
|
|
}
|
|
|
|
let exit_nodes = config.get_exit_nodes();
|
|
if !exit_nodes.is_empty() {
|
|
result.exit_nodes = exit_nodes.iter().map(|n| n.to_string()).collect();
|
|
}
|
|
|
|
if let Some(socks5_portal) = config.get_socks5_portal() {
|
|
result.enable_socks5 = Some(true);
|
|
result.socks5_port = socks5_portal.port().map(|p| p as i32);
|
|
}
|
|
|
|
let mapped_listeners = config.get_mapped_listeners();
|
|
if !mapped_listeners.is_empty() {
|
|
result.mapped_listeners = mapped_listeners.iter().map(|l| l.to_string()).collect();
|
|
}
|
|
|
|
result.secure_mode = config.get_secure_mode();
|
|
result.credential_file = config
|
|
.get_credential_file()
|
|
.map(|path| path.to_string_lossy().into_owned());
|
|
result.managed_credentials = config
|
|
.get_managed_credentials()
|
|
.into_iter()
|
|
.map(|credential| credential.downgrade())
|
|
.collect();
|
|
set_network_flags(&mut result, config.get_flags_patch());
|
|
result.acl = config.get_acl();
|
|
|
|
Ok(result)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#![allow(deprecated)]
|
|
|
|
use super::*;
|
|
use easytier_proto::common::Flags;
|
|
|
|
#[test]
|
|
fn dns_records_survive_toml_and_form_edits() {
|
|
for dns in [
|
|
"",
|
|
"[dns]\n",
|
|
"[dns]\ndomain = 'mesh.test.'\n",
|
|
"[[dns.zone]]\norigin = 'example.test.'\nttl = 0\nrecords = ['nas IN A 10.0.0.8', 'nas IN AAAA fd00::8']\n",
|
|
] {
|
|
let config = TomlConfigLoader::new_from_str(dns).unwrap();
|
|
let expected = config.get_dns();
|
|
config.set_hostname(Some("changed".to_owned()));
|
|
assert_eq!(config.get_dns(), expected);
|
|
#[cfg(feature = "config-write")]
|
|
assert_eq!(
|
|
TomlConfigLoader::new_from_str(&config.dump())
|
|
.unwrap()
|
|
.get_dns(),
|
|
expected
|
|
);
|
|
#[cfg(feature = "browser-config")]
|
|
{
|
|
let api = NetworkConfig::new_from_config(&config).unwrap();
|
|
let merged = merge_network_config_toml(dns, &api).unwrap();
|
|
let restored = TomlConfigLoader::new_from_str(&merged).unwrap();
|
|
assert_eq!(restored.get_dns(), expected);
|
|
assert_eq!(restored.get_hostname(), "changed");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "browser-config")]
|
|
#[test]
|
|
fn dns_raw_presence_survives_management_json_and_file_roundtrips() {
|
|
for input in [
|
|
"",
|
|
"[dns]\n",
|
|
"[dns]\ndomain = 'mesh.test.'\n",
|
|
"[dns]\nzone = []\n",
|
|
"[[dns.zone]]\norigin = 'example.test.'\n",
|
|
"[[dns.zone]]\norigin = 'example.test.'\nttl = 0\nrecords = []\n",
|
|
"[[dns.zone]]\norigin = 'example.test.'\nrecords = ['@ IN A 192.0.2.1']\n",
|
|
] {
|
|
let config = TomlConfigLoader::new_from_str(input).unwrap();
|
|
let expected = config.get_dns();
|
|
let network = NetworkConfig::new_from_config(&config).unwrap();
|
|
assert_eq!(network.dns, expected, "{input}");
|
|
let json = serde_json::to_value(&network).unwrap();
|
|
if let Some(records) = expected.as_ref().and_then(|dns| dns.zone.as_ref()) {
|
|
assert_eq!(
|
|
json["dns"]["zone"].as_array().unwrap().len(),
|
|
records.items.len()
|
|
);
|
|
}
|
|
if let Some(domain) = expected.as_ref().and_then(|dns| dns.domain.as_deref()) {
|
|
assert_eq!(json["dns"]["domain"].as_str(), Some(domain));
|
|
}
|
|
let network: NetworkConfig = serde_json::from_value(json).unwrap();
|
|
let restored = network.gen_config().unwrap();
|
|
assert_eq!(restored.get_dns(), expected, "{input}");
|
|
assert_eq!(
|
|
TomlConfigLoader::new_from_str(&restored.dump())
|
|
.unwrap()
|
|
.get_dns(),
|
|
expected
|
|
);
|
|
let merged = merge_network_config_toml(input, &network).unwrap();
|
|
assert_eq!(
|
|
TomlConfigLoader::new_from_str(&merged).unwrap().get_dns(),
|
|
expected
|
|
);
|
|
#[cfg(feature = "web-client")]
|
|
assert_eq!(
|
|
crate::config::api::network_config_from_toml(&config).dns,
|
|
expected
|
|
);
|
|
}
|
|
let missing = TomlConfigLoader::new_from_str("[dns]\n")
|
|
.unwrap()
|
|
.get_dns()
|
|
.unwrap();
|
|
let empty = TomlConfigLoader::new_from_str("[dns]\nzone = []\n")
|
|
.unwrap()
|
|
.get_dns()
|
|
.unwrap();
|
|
assert!(missing.zone.is_none());
|
|
assert!(empty.zone.unwrap().items.is_empty());
|
|
|
|
let input = "[[dns.zone]]\norigin = 'example.test.'\nrecords = []\n";
|
|
let mut network =
|
|
NetworkConfig::new_from_config(TomlConfigLoader::new_from_str(input).unwrap()).unwrap();
|
|
network.dns = None;
|
|
let cleared = merge_network_config_toml(input, &network).unwrap();
|
|
assert!(
|
|
TomlConfigLoader::new_from_str(&cleared)
|
|
.unwrap()
|
|
.get_dns()
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dns_file_input_rejects_unknown_fields_and_invalid_shapes() {
|
|
for input in [
|
|
"[dns]\nzoen = []\n",
|
|
"[[dns.zone]]\norigin = 'example.test.'\nrecrods = []\n",
|
|
"[[dns.zone]]\norigin = 'example.test.'\nrecords = {}\n",
|
|
"[[dns.zone]]\norigin = 'example.test.'\nttl = -1\n",
|
|
] {
|
|
assert!(
|
|
TomlConfigLoader::new_from_str(input).is_err(),
|
|
"accepted {input}"
|
|
);
|
|
}
|
|
}
|
|
|
|
fn api_portal_config() -> manage::VpnPortalConfig {
|
|
manage::VpnPortalConfig {
|
|
enabled: None,
|
|
wireguard_listen: "0.0.0.0:51820".to_owned(),
|
|
wireguard_private_key: Some("server-private-key".to_owned()),
|
|
clients: vec![manage::VpnPortalClientConfig {
|
|
name: "alice".to_owned(),
|
|
virtual_ip: "10.144.144.10/16".to_owned(),
|
|
groups: vec!["staff".to_owned()],
|
|
}],
|
|
}
|
|
}
|
|
|
|
fn standalone_config() -> NetworkConfig {
|
|
NetworkConfig {
|
|
networking_method: Some(NetworkingMethod::Standalone as i32),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn manual_routes_preserve_empty_and_automatic_modes() {
|
|
for routes in [Vec::new(), vec!["10.20.0.0/24".to_owned()]] {
|
|
for enabled in [None, Some(false), Some(true)] {
|
|
let mut input = standalone_config();
|
|
input.enable_manual_routes = enabled;
|
|
input.routes = routes.clone();
|
|
let config = input.gen_config().unwrap();
|
|
let expected = if enabled == Some(true) {
|
|
Some(routes.iter().map(|route| route.parse().unwrap()).collect())
|
|
} else {
|
|
None
|
|
};
|
|
assert_eq!(config.get_routes(), expected);
|
|
let readback = NetworkConfig::new_from_config(config.clone()).unwrap();
|
|
assert_eq!(
|
|
readback.enable_manual_routes,
|
|
enabled.filter(|value| *value)
|
|
);
|
|
assert_eq!(readback.gen_config().unwrap().get_routes(), expected);
|
|
assert_eq!(
|
|
TomlConfigLoader::new_from_str(&config.dump())
|
|
.unwrap()
|
|
.get_routes(),
|
|
expected
|
|
);
|
|
#[cfg(feature = "web-client")]
|
|
{
|
|
let projected = super::super::api::network_config_from_toml(&config);
|
|
assert_eq!(
|
|
projected.enable_manual_routes,
|
|
readback.enable_manual_routes
|
|
);
|
|
assert_eq!(projected.routes, readback.routes);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn dhcp_cidr_survives_api_and_toml_round_trips() {
|
|
let mut input = standalone_config();
|
|
input.dhcp_ipv4_cidr = Some("192.168.100.0/24".to_owned());
|
|
for enabled in [None, Some(true), Some(false)] {
|
|
input.dhcp = enabled;
|
|
let config = input.gen_config().unwrap();
|
|
assert_eq!(config.get_dhcp(), enabled.unwrap_or(true));
|
|
let readback = NetworkConfig::new_from_config(config.clone()).unwrap();
|
|
let expected_cidr = if enabled == Some(false) {
|
|
None
|
|
} else {
|
|
input.dhcp_ipv4_cidr.clone()
|
|
};
|
|
assert_eq!(readback.dhcp_ipv4_cidr, expected_cidr);
|
|
assert_eq!(
|
|
TomlConfigLoader::new_from_str(&config.dump())
|
|
.unwrap()
|
|
.get_dhcp_config(),
|
|
readback.gen_config().unwrap().get_dhcp_config()
|
|
);
|
|
#[cfg(feature = "web-client")]
|
|
assert_eq!(
|
|
super::super::api::network_config_from_toml(&config).dhcp_ipv4_cidr,
|
|
expected_cidr
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn dhcpv6_cidr_survives_management_round_trips() {
|
|
let mut input = NetworkConfig {
|
|
dhcp: Some(true),
|
|
dhcp_ipv6_cidr: Some("fd12::/64".to_owned()),
|
|
..standalone_config()
|
|
};
|
|
for enabled in [None, Some(true), Some(false)] {
|
|
input.dhcpv6 = enabled;
|
|
let config = input.gen_config().unwrap();
|
|
assert_eq!(config.get_dhcpv6(), enabled.unwrap_or(true));
|
|
let readback = NetworkConfig::new_from_config(&config).unwrap();
|
|
assert_eq!(readback.dhcpv6, Some(enabled.unwrap_or(true)));
|
|
assert_eq!(
|
|
readback.dhcp_ipv6_cidr,
|
|
if enabled == Some(false) {
|
|
None
|
|
} else {
|
|
input.dhcp_ipv6_cidr.clone()
|
|
}
|
|
);
|
|
assert_eq!(
|
|
readback.gen_config().unwrap().get_dhcpv6_config(),
|
|
config.get_dhcpv6_config()
|
|
);
|
|
assert_eq!(
|
|
super::super::api::network_config_from_toml(&config).dhcp_ipv6_cidr,
|
|
readback.dhcp_ipv6_cidr
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn dhcp_api_rejects_invalid_or_unusable_subnets() {
|
|
let mut input = standalone_config();
|
|
for value in [
|
|
"true",
|
|
"false",
|
|
"yes",
|
|
"::/64",
|
|
"192.168.100.0/31",
|
|
"192.168.100.1/32",
|
|
] {
|
|
input.dhcp_ipv4_cidr = Some(value.to_owned());
|
|
assert!(input.gen_config().is_err(), "accepted {value}");
|
|
}
|
|
}
|
|
|
|
/// The management API's name for a flag is the schema's to state, and the
|
|
/// projection is what actually names the fields. Turning the flag the
|
|
/// annotation names must turn the field it points at, in the direction the
|
|
/// annotation declares.
|
|
#[test]
|
|
fn declared_api_names_match_the_projection() {
|
|
use prost_reflect::{DescriptorPool, Value};
|
|
|
|
let pool = DescriptorPool::decode(easytier_proto::ALL_DESCRIPTOR_BYTES).unwrap();
|
|
let flags = pool.get_message_by_name("common.Flags").unwrap();
|
|
let extension = pool.get_extension_by_name("easytier.flag").unwrap();
|
|
|
|
// A patch states every field, so the values survive serialization:
|
|
// protobuf JSON omits a field holding its zero value.
|
|
let patch: FlagsPatch = Flags::defaults().downgrade();
|
|
let defaults = serde_json::to_value(&patch).unwrap();
|
|
|
|
for field in flags.fields() {
|
|
let name = field.name();
|
|
let options = field.options();
|
|
let annotation = options.get_extension(&extension).into_owned();
|
|
let Value::Message(meta) = annotation else {
|
|
panic!("{name}: the annotation is not a message");
|
|
};
|
|
if !meta.has_field_by_name("api") {
|
|
continue;
|
|
}
|
|
let Value::Message(spelling) = meta.get_field_by_name("api").unwrap().into_owned()
|
|
else {
|
|
panic!("{name}: the api spelling is not a message");
|
|
};
|
|
let api_field = match spelling.get_field_by_name("field").as_deref() {
|
|
Some(Value::String(api_field)) if !api_field.is_empty() => api_field.clone(),
|
|
_ => continue,
|
|
};
|
|
let negate = matches!(
|
|
spelling.get_field_by_name("negate").as_deref(),
|
|
Some(Value::Bool(true))
|
|
);
|
|
|
|
let declared = defaults[name]
|
|
.as_bool()
|
|
.unwrap_or_else(|| panic!("{name} is not a boolean flag"));
|
|
|
|
let mut flipped = defaults.clone();
|
|
flipped[name] = serde_json::json!(!declared);
|
|
let patch: FlagsPatch = serde_json::from_value(flipped).unwrap();
|
|
|
|
let mut projected = NetworkConfig::default();
|
|
set_network_flags(&mut projected, patch);
|
|
|
|
// Protobuf JSON omits a field holding its zero value.
|
|
let projected = serde_json::to_value(&projected).unwrap();
|
|
let value = projected[&api_field].as_bool().unwrap_or(false);
|
|
assert_eq!(
|
|
value,
|
|
if negate { declared } else { !declared },
|
|
"flipping {name} should leave {api_field} as the annotation declares"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "config-write")]
|
|
#[test]
|
|
fn api_flags_round_trip_preserves_missing_default_and_custom_values() {
|
|
for disabled in [None, Some(false), Some(true)] {
|
|
for whitelist in [None, Some(false), Some(true)] {
|
|
let input = NetworkConfig {
|
|
disable_encryption: disabled,
|
|
enable_relay_network_whitelist: whitelist,
|
|
latency_first: Some(false),
|
|
mtu: Some(0),
|
|
instance_recv_bps_limit: Some(u64::MAX),
|
|
..standalone_config()
|
|
};
|
|
let config = input.gen_config().unwrap();
|
|
let config = TomlConfigLoader::new_from_str(&config.dump()).unwrap();
|
|
let output = NetworkConfig::new_from_config(&config).unwrap();
|
|
assert_eq!(output.disable_encryption, disabled);
|
|
assert_eq!(output.enable_relay_network_whitelist, whitelist);
|
|
assert_eq!(output.latency_first, Some(false));
|
|
assert_eq!(output.mtu, Some(0));
|
|
assert_eq!(output.instance_recv_bps_limit, Some(u64::MAX));
|
|
assert_eq!(output.disable_ipv6, None);
|
|
assert_eq!(output.encryption_algorithm, None);
|
|
}
|
|
}
|
|
assert!(
|
|
NetworkConfig {
|
|
mtu: Some(-1),
|
|
..standalone_config()
|
|
}
|
|
.gen_config()
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn vpn_portal_api_config_round_trips_through_toml_model() {
|
|
let input = NetworkConfig {
|
|
vpn_portal_config: Some(api_portal_config()),
|
|
..standalone_config()
|
|
};
|
|
|
|
let config = input.gen_config().unwrap();
|
|
let portal = config.get_vpn_portal_config().unwrap();
|
|
assert_eq!(portal.wireguard_listen, "0.0.0.0:51820".parse().unwrap());
|
|
assert_eq!(
|
|
portal.wireguard_private_key.as_deref(),
|
|
Some("server-private-key")
|
|
);
|
|
assert_eq!(portal.clients[0].name, "alice");
|
|
assert_eq!(portal.clients[0].virtual_ip.to_string(), "10.144.144.10/16");
|
|
assert_eq!(portal.clients[0].groups, vec!["staff".to_owned()]);
|
|
|
|
let output = NetworkConfig::new_from_config(&config).unwrap();
|
|
assert_eq!(output.vpn_portal_config, input.vpn_portal_config);
|
|
assert_eq!(output.enable_vpn_portal, None);
|
|
}
|
|
|
|
#[test]
|
|
fn disabled_vpn_portal_preserves_clients_and_key_without_a_runtime() {
|
|
let mut portal = api_portal_config();
|
|
portal.enabled = Some(false);
|
|
let input = NetworkConfig {
|
|
vpn_portal_config: Some(portal),
|
|
..standalone_config()
|
|
};
|
|
let config = input.gen_config().unwrap();
|
|
let restored = crate::config::toml::TomlConfig::new_from_str(&config.dump()).unwrap();
|
|
let output = NetworkConfig::new_from_config(&restored).unwrap();
|
|
assert_eq!(output.vpn_portal_config, input.vpn_portal_config);
|
|
assert!(
|
|
crate::instance::CoreInstanceConfig::from_toml(&restored)
|
|
.unwrap()
|
|
.vpn_portal
|
|
.is_none()
|
|
);
|
|
|
|
let mut portal = restored.get_vpn_portal_config().unwrap();
|
|
portal.enabled = Some(true);
|
|
restored.set_vpn_portal_config(portal);
|
|
let runtime = crate::instance::CoreInstanceConfig::from_toml(&restored).unwrap();
|
|
assert_eq!(runtime.vpn_portal.unwrap().clients[0].name, "alice");
|
|
}
|
|
|
|
#[test]
|
|
fn managed_credentials_round_trip_through_toml_model() {
|
|
let input = NetworkConfig {
|
|
managed_credentials: vec![manage::ManagedCredentialConfig {
|
|
credential_id: "managed-a".to_owned(),
|
|
credential_secret: "secret".to_owned(),
|
|
groups: vec!["ops".to_owned()],
|
|
allow_relay: true,
|
|
allowed_proxy_cidrs: vec!["10.0.0.0/24".to_owned()],
|
|
expiry_unix: 2_000_000_000,
|
|
reusable: None,
|
|
}],
|
|
..standalone_config()
|
|
};
|
|
|
|
let config = input.gen_config().unwrap();
|
|
let output = NetworkConfig::new_from_config(&config).unwrap();
|
|
assert_eq!(output.managed_credentials[0].credential_id, "managed-a");
|
|
assert_eq!(output.managed_credentials[0].reusable, Some(true));
|
|
}
|
|
|
|
#[test]
|
|
fn peer_relay_preference_round_trips_independently() {
|
|
let input = NetworkConfig {
|
|
disable_relay_data: Some(false),
|
|
prefer_peer_relay: Some(true),
|
|
..standalone_config()
|
|
};
|
|
|
|
let config = input.gen_config().unwrap();
|
|
let flags = config.get_flags();
|
|
assert!(!flags.disable_relay_data);
|
|
assert!(flags.prefer_peer_relay);
|
|
|
|
let output = NetworkConfig::new_from_config(&config).unwrap();
|
|
assert_eq!(output.disable_relay_data, Some(false));
|
|
assert_eq!(output.prefer_peer_relay, Some(true));
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_enabled_vpn_portal_config_reports_migration_error() {
|
|
let error = NetworkConfig {
|
|
enable_vpn_portal: Some(true),
|
|
..standalone_config()
|
|
}
|
|
.gen_config()
|
|
.unwrap_err()
|
|
.to_string();
|
|
|
|
assert!(error.contains("legacy VPN portal"), "{error}");
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_disabled_vpn_portal_defaults_are_ignored() {
|
|
let config = NetworkConfig {
|
|
enable_vpn_portal: Some(false),
|
|
vpn_portal_listen_port: Some(0),
|
|
vpn_portal_client_network_addr: Some(String::new()),
|
|
vpn_portal_client_network_len: Some(0),
|
|
..standalone_config()
|
|
}
|
|
.gen_config()
|
|
.unwrap();
|
|
|
|
assert!(config.get_vpn_portal_config().is_none());
|
|
}
|
|
|
|
#[cfg(feature = "browser-config")]
|
|
#[test]
|
|
fn browser_merge_reconciles_renamed_flags() {
|
|
let original = r#"
|
|
[flags]
|
|
default_protocol = "udp"
|
|
enable_encryption = false
|
|
enable_ipv6 = false
|
|
accept_dns = false
|
|
private_mode = false
|
|
"#;
|
|
|
|
for value in [Some(false), Some(true), None] {
|
|
let network_config = NetworkConfig {
|
|
disable_encryption: value,
|
|
disable_ipv6: value,
|
|
enable_magic_dns: value,
|
|
enable_private_mode: value,
|
|
..standalone_config()
|
|
};
|
|
let merged = merge_network_config_toml(original, &network_config).unwrap();
|
|
let merged: toml::Table = toml::from_str(&merged).unwrap();
|
|
let flags = merged["flags"].as_table().unwrap();
|
|
|
|
assert_eq!(flags["default_protocol"].as_str(), Some("udp"));
|
|
for (field, expected) in [
|
|
("enable_encryption", value.map(|disabled| !disabled)),
|
|
("enable_ipv6", value.map(|disabled| !disabled)),
|
|
("accept_dns", value),
|
|
("private_mode", value),
|
|
] {
|
|
assert_eq!(
|
|
flags.get(field).and_then(toml::Value::as_bool),
|
|
expected,
|
|
"merging {field} from API value {value:?}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "browser-config")]
|
|
#[test]
|
|
fn browser_merge_preserves_fields_outside_the_shared_form() {
|
|
let original = r#"
|
|
instance_name = "module-instance"
|
|
rpc_portal = "0.0.0.0:15888"
|
|
tcp_whitelist = ["22"]
|
|
stun_servers = ["custom.example.com:3478"]
|
|
|
|
[network_identity]
|
|
network_name = "old-network"
|
|
network_secret = "secret"
|
|
|
|
[flags]
|
|
default_protocol = "udp"
|
|
disable_p2p = true
|
|
"#;
|
|
let parsed = TomlConfigLoader::new_from_str(original).unwrap();
|
|
let mut network_config = NetworkConfig::new_from_config(&parsed).unwrap();
|
|
network_config.network_name = Some("edited-network".to_owned());
|
|
network_config.disable_p2p = Some(false);
|
|
|
|
let merged = merge_network_config_toml(original, &network_config).unwrap();
|
|
let merged: toml::Table = toml::from_str(&merged).unwrap();
|
|
|
|
assert_eq!(merged["instance_name"].as_str(), Some("module-instance"));
|
|
assert_eq!(merged["rpc_portal"].as_str(), Some("0.0.0.0:15888"));
|
|
assert_eq!(merged["tcp_whitelist"][0].as_str(), Some("22"));
|
|
assert_eq!(
|
|
merged["stun_servers"][0].as_str(),
|
|
Some("custom.example.com:3478")
|
|
);
|
|
assert_eq!(
|
|
merged["network_identity"]["network_name"].as_str(),
|
|
Some("edited-network")
|
|
);
|
|
assert_eq!(merged["flags"]["default_protocol"].as_str(), Some("udp"));
|
|
assert_eq!(merged["flags"]["disable_p2p"].as_bool(), Some(false));
|
|
}
|
|
|
|
#[cfg(feature = "browser-config")]
|
|
#[test]
|
|
fn browser_merge_normalizes_camel_case_flag_aliases() {
|
|
let original = r#"
|
|
[flags]
|
|
disableP2p = true
|
|
enableEncryption = false
|
|
foreignRelayBpsLimit = 1234
|
|
"#;
|
|
let parsed_orig = TomlConfigLoader::new_from_str(original).unwrap();
|
|
let mut network_config = NetworkConfig::new_from_config(&parsed_orig).unwrap();
|
|
network_config.disable_p2p = Some(false);
|
|
|
|
let merged = merge_network_config_toml(original, &network_config).unwrap();
|
|
let parsed = TomlConfigLoader::new_from_str(&merged)
|
|
.expect("merged config should be parseable without duplicate-field error");
|
|
assert!(!parsed.get_flags().disable_p2p);
|
|
assert!(!parsed.get_flags().enable_encryption);
|
|
assert_eq!(parsed.get_flags().foreign_relay_bps_limit, 1234);
|
|
|
|
let merged_table: toml::Table = toml::from_str(&merged).unwrap();
|
|
let flags = merged_table["flags"].as_table().unwrap();
|
|
assert_eq!(
|
|
flags.get("disable_p2p").and_then(toml::Value::as_bool),
|
|
Some(false)
|
|
);
|
|
assert!(flags.get("disableP2p").is_none());
|
|
assert_eq!(
|
|
flags
|
|
.get("enable_encryption")
|
|
.and_then(toml::Value::as_bool),
|
|
Some(false)
|
|
);
|
|
assert!(flags.get("enableEncryption").is_none());
|
|
assert_eq!(
|
|
flags
|
|
.get("foreign_relay_bps_limit")
|
|
.and_then(toml::Value::as_integer),
|
|
Some(1234)
|
|
);
|
|
assert!(flags.get("foreignRelayBpsLimit").is_none());
|
|
}
|
|
|
|
#[cfg(feature = "browser-config")]
|
|
#[test]
|
|
fn browser_merge_repro_camel_case_disable_p2p_collision() {
|
|
let original = r#"
|
|
[flags]
|
|
disableP2p = false
|
|
"#;
|
|
let mut network_config = standalone_config();
|
|
network_config.hostname = Some("new-hostname".to_owned());
|
|
network_config.disable_p2p = Some(false);
|
|
|
|
let merged = merge_network_config_toml(original, &network_config).unwrap();
|
|
let parsed = TomlConfigLoader::new_from_str(&merged)
|
|
.expect("saving config with camelCase alias must not fail on reload");
|
|
assert!(!parsed.get_flags().disable_p2p);
|
|
|
|
let merged_table: toml::Table = toml::from_str(&merged).unwrap();
|
|
let flags = merged_table["flags"].as_table().unwrap();
|
|
assert_eq!(
|
|
flags.get("disable_p2p").and_then(toml::Value::as_bool),
|
|
Some(false)
|
|
);
|
|
assert!(flags.get("disableP2p").is_none());
|
|
}
|
|
}
|