refactor(core): separate portable core from native runtime (#2451)

Create easytier-core as the portable owner of configuration,
connectivity, tunnels, peer and routing state, gateways, management,
the data plane, and instance lifecycle. Keep operating-system
integration, native protocol engines, process startup, and presentation
in easytier behind explicit Host capability adapters.

Create easytier-proto to own schemas, generated RPC types, descriptors,
and feature-scoped protocol slices. Remove runtime protobuf reflection
from core while preserving unknown route-peer fields across forwarding.

Normalize instance construction through CoreInstance, CoreHostAdapters,
CoreProcessRuntime, and InstanceManager. Make the runtime config store
the only authoritative mutable configuration after startup.

Move the portable TCP/UDP data plane into core and extract a generic
OperationBroker for completion, cancellation, disposal, and capacity
accounting. Expose the session-based FFI v2 completion API and keep the
WASI guest ABI, wire schemas, and adapters with core.

Migrate CLI, GUI, web, FFI, Android JNI, OHOS, uptime, and mobile
consumers to the shared manager and core state. Add explicit user/web
config ownership and revision-aware web reconciliation.

Preserve configuration, wire, and management behavior while fixing
regressions discovered by the full platform and integration matrix:

- inherit advertised relay capabilities in foreign networks;
- refresh OSPF peer state immediately after runtime config changes;
- restore CLI GlobalCtx event output without forcing GUI logging;
- retain legacy encryption names and standalone RPC tunnel metadata;
- restore ICMP host composition and fragmented UDP handling;
- use portable 64-bit atomics on 32-bit MIPS targets; and
- retain discarded operations until late cancellation completes.

Validate the refactor across 45 GitHub checks, including Linux, macOS,
Windows, FreeBSD, web, GUI, Android, OHOS, feature profiles, and
three-node and subnet-proxy integration tests.

BREAKING CHANGE: internal Rust module paths are not preserved. Legacy
native data-plane APIs are replaced by the session-based FFI v2 API.
The dedicated Android data-plane wrapper is removed.
This commit is contained in:
KKRainbow
2026-07-26 15:41:55 +08:00
committed by GitHub
parent 346f32d3d0
commit 021f523431
523 changed files with 102785 additions and 67067 deletions
+166
View File
@@ -0,0 +1,166 @@
//! Portable conversion between the shared TOML model and management schema.
use easytier_proto::api::manage::{
self, NetworkConfig, NetworkingMethod, PortForwardConfig as ApiPortForwardConfig,
};
use super::toml::{ConfigLoader as _, TomlConfig};
pub fn network_config_from_toml(config: &TomlConfig) -> NetworkConfig {
let default_config = TomlConfig::default();
let mut result = NetworkConfig {
instance_id: Some(config.get_id().to_string()),
dhcp: Some(config.get_dhcp()),
..Default::default()
};
if config.get_hostname() != default_config.get_hostname() {
result.hostname = Some(config.get_hostname());
}
let network_identity = config.get_network_identity();
result.network_name = Some(network_identity.network_name);
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(|peer| peer.uri.to_string()).collect();
result.peers = peers
.iter()
.map(|peer| manage::NetworkPeerConfig {
uri: peer.uri.to_string(),
peer_public_key: peer.peer_public_key.clone(),
})
.collect();
}
result.listener_urls = config
.get_listeners()
.unwrap_or_default()
.iter()
.map(ToString::to_string)
.collect();
result.proxy_cidrs = config
.get_proxy_cidrs()
.iter()
.map(|proxy| match proxy.mapped_cidr {
Some(mapped) => format!("{}->{}", proxy.cidr, mapped),
None => proxy.cidr.to_string(),
})
.collect();
let port_forwards = config.get_port_forwards();
if !port_forwards.is_empty() {
result.port_forwards = port_forwards
.iter()
.map(|forward| ApiPortForwardConfig {
proto: forward.proto.clone(),
bind_ip: forward.bind_addr.ip().to_string(),
bind_port: forward.bind_addr.port() as u32,
dst_ip: forward.dst_addr.ip().to_string(),
dst_port: forward.dst_addr.port() as u32,
})
.collect();
}
if let Some(vpn_config) = config.get_vpn_portal_config() {
result.enable_vpn_portal = Some(true);
result.vpn_portal_client_network_addr =
Some(vpn_config.client_cidr.first_address().to_string());
result.vpn_portal_client_network_len = Some(vpn_config.client_cidr.network_length() as i32);
result.vpn_portal_listen_port = Some(vpn_config.wireguard_listen.port() as i32);
}
if let Some(routes) = config.get_routes()
&& !routes.is_empty()
{
result.enable_manual_routes = Some(true);
result.routes = routes.iter().map(ToString::to_string).collect();
}
let exit_nodes = config.get_exit_nodes();
if !exit_nodes.is_empty() {
result.exit_nodes = exit_nodes.iter().map(ToString::to_string).collect();
}
if let Some(socks5_portal) = config.get_socks5_portal() {
result.enable_socks5 = Some(true);
result.socks5_port = socks5_portal.port().map(|port| port as i32);
}
let mapped_listeners = config.get_mapped_listeners();
if !mapped_listeners.is_empty() {
result.mapped_listeners = mapped_listeners.iter().map(ToString::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());
let flags = config.get_flags();
let default_flags = default_config.get_flags();
result.latency_first = Some(flags.latency_first);
result.dev_name = Some(flags.dev_name.clone());
result.use_smoltcp = Some(flags.use_smoltcp);
result.disable_ipv6 = Some(!flags.enable_ipv6);
result.enable_kcp_proxy = Some(flags.enable_kcp_proxy);
result.disable_kcp_input = Some(flags.disable_kcp_input);
result.enable_quic_proxy = Some(flags.enable_quic_proxy);
result.disable_quic_input = Some(flags.disable_quic_input);
result.disable_p2p = Some(flags.disable_p2p);
result.p2p_only = Some(flags.p2p_only);
result.lazy_p2p = Some(flags.lazy_p2p);
result.bind_device = Some(flags.bind_device);
result.socket_mark = flags.socket_mark;
result.no_tun = Some(flags.no_tun);
result.enable_exit_node = Some(flags.enable_exit_node);
result.relay_all_peer_rpc = Some(flags.relay_all_peer_rpc);
result.need_p2p = Some(flags.need_p2p);
result.multi_thread = Some(flags.multi_thread);
result.proxy_forward_by_system = Some(flags.proxy_forward_by_system);
result.disable_encryption = Some(!flags.enable_encryption);
result.disable_tcp_hole_punching = Some(flags.disable_tcp_hole_punching);
result.disable_udp_hole_punching = Some(flags.disable_udp_hole_punching);
result.disable_upnp = Some(flags.disable_upnp);
result.disable_relay_data = Some(flags.disable_relay_data);
result.enable_udp_broadcast_relay = Some(flags.enable_udp_broadcast_relay);
result.disable_sym_hole_punching = Some(flags.disable_sym_hole_punching);
result.enable_magic_dns = Some(flags.accept_dns);
result.mtu = Some(flags.mtu as i32);
result.data_compress_algo = (flags.data_compress_algo != default_flags.data_compress_algo)
.then_some(flags.data_compress_algo);
result.encryption_algorithm = (flags.encryption_algorithm
!= default_flags.encryption_algorithm)
.then_some(flags.encryption_algorithm);
result.instance_recv_bps_limit =
(flags.instance_recv_bps_limit != u64::MAX).then_some(flags.instance_recv_bps_limit);
result.enable_private_mode = Some(flags.private_mode);
result.acl = config.get_acl();
if flags.relay_network_whitelist == "*" {
result.enable_relay_network_whitelist = Some(false);
} else {
result.enable_relay_network_whitelist = Some(true);
result.relay_network_whitelist = flags
.relay_network_whitelist
.split_whitespace()
.map(ToOwned::to_owned)
.collect();
}
result
}
+652
View File
@@ -0,0 +1,652 @@
//! Conversion between the management NetworkConfig schema and shared TOML.
use std::net::SocketAddr;
use anyhow::Context;
use easytier_proto::api::manage;
use crate::config::{
MappedListenerPolicy, normalize_secure_mode_config,
toml::{
ConfigLoader, NetworkIdentity, PeerConfig, PortForwardConfig, TomlConfigLoader,
VpnPortalConfig, gen_default_flags,
},
};
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 trait NetworkConfigExt {
fn gen_config(&self) -> Result<TomlConfigLoader, anyhow::Error>;
fn new_from_config(config: impl ConfigLoader) -> Result<NetworkConfig, anyhow::Error>;
}
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 {
fn gen_config(&self) -> Result<TomlConfigLoader, anyhow::Error> {
let cfg = TomlConfigLoader::default();
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_default());
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.unwrap_or_default() {
let cidr = format!(
"{}/{}",
self.vpn_portal_client_network_addr
.clone()
.unwrap_or_default(),
self.vpn_portal_client_network_len.unwrap_or(24)
);
cfg.set_vpn_portal_config(VpnPortalConfig {
client_cidr: cidr
.parse()
.with_context(|| format!("failed to parse vpn portal client cidr: {}", cidr))?,
wireguard_listen: format!(
"0.0.0.0:{}",
self.vpn_portal_listen_port.unwrap_or_default()
)
.parse()
.with_context(|| {
format!(
"failed to parse vpn portal wireguard listen port. {:?}",
self.vpn_portal_listen_port
)
})?,
});
}
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()));
}
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()?,
);
}
let mut flags = gen_default_flags();
if let Some(latency_first) = self.latency_first {
flags.latency_first = latency_first;
}
if let Some(dev_name) = self.dev_name.clone() {
flags.dev_name = dev_name;
}
if let Some(use_smoltcp) = self.use_smoltcp {
flags.use_smoltcp = use_smoltcp;
}
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(disable_ipv6) = self.disable_ipv6 {
flags.enable_ipv6 = !disable_ipv6;
}
if let Some(enable_kcp_proxy) = self.enable_kcp_proxy {
flags.enable_kcp_proxy = enable_kcp_proxy;
}
if let Some(disable_kcp_input) = self.disable_kcp_input {
flags.disable_kcp_input = disable_kcp_input;
}
if let Some(enable_quic_proxy) = self.enable_quic_proxy {
flags.enable_quic_proxy = enable_quic_proxy;
}
if let Some(disable_quic_input) = self.disable_quic_input {
flags.disable_quic_input = disable_quic_input;
}
if let Some(disable_p2p) = self.disable_p2p {
flags.disable_p2p = disable_p2p;
}
if let Some(p2p_only) = self.p2p_only {
flags.p2p_only = p2p_only;
}
if let Some(lazy_p2p) = self.lazy_p2p {
flags.lazy_p2p = lazy_p2p;
}
if let Some(bind_device) = self.bind_device {
flags.bind_device = bind_device;
}
if self.socket_mark.is_some() {
flags.socket_mark = self.socket_mark;
}
if let Some(no_tun) = self.no_tun {
flags.no_tun = no_tun;
}
if let Some(enable_exit_node) = self.enable_exit_node {
flags.enable_exit_node = enable_exit_node;
}
if let Some(relay_all_peer_rpc) = self.relay_all_peer_rpc {
flags.relay_all_peer_rpc = relay_all_peer_rpc;
}
if let Some(need_p2p) = self.need_p2p {
flags.need_p2p = need_p2p;
}
if let Some(multi_thread) = self.multi_thread {
flags.multi_thread = multi_thread;
}
if let Some(proxy_forward_by_system) = self.proxy_forward_by_system {
flags.proxy_forward_by_system = proxy_forward_by_system;
}
if let Some(disable_encryption) = self.disable_encryption {
flags.enable_encryption = !disable_encryption;
}
if self.enable_relay_network_whitelist.unwrap_or_default() {
if !self.relay_network_whitelist.is_empty() {
flags.relay_network_whitelist = self.relay_network_whitelist.join(" ");
} else {
flags.relay_network_whitelist = "".to_string();
}
}
if let Some(disable_tcp_hole_punching) = self.disable_tcp_hole_punching {
flags.disable_tcp_hole_punching = disable_tcp_hole_punching;
}
if let Some(disable_udp_hole_punching) = self.disable_udp_hole_punching {
flags.disable_udp_hole_punching = disable_udp_hole_punching;
}
if let Some(disable_upnp) = self.disable_upnp {
flags.disable_upnp = disable_upnp;
}
if let Some(disable_relay_data) = self.disable_relay_data {
flags.disable_relay_data = disable_relay_data;
}
if let Some(enable_udp_broadcast_relay) = self.enable_udp_broadcast_relay {
flags.enable_udp_broadcast_relay = enable_udp_broadcast_relay;
}
if let Some(disable_sym_hole_punching) = self.disable_sym_hole_punching {
flags.disable_sym_hole_punching = disable_sym_hole_punching;
}
if let Some(enable_magic_dns) = self.enable_magic_dns {
flags.accept_dns = enable_magic_dns;
}
if let Some(mtu) = self.mtu {
flags.mtu = mtu as u32;
}
if let Some(instance_recv_bps_limit) = self.instance_recv_bps_limit {
flags.instance_recv_bps_limit = instance_recv_bps_limit;
}
if let Some(enable_private_mode) = self.enable_private_mode {
flags.private_mode = enable_private_mode;
}
if let Some(encryption_algorithm) = self.encryption_algorithm.clone() {
flags.encryption_algorithm = encryption_algorithm;
}
if let Some(acl) = self.acl.as_ref()
&& !acl.is_empty()
{
cfg.set_acl(Some(acl.clone()));
}
if let Some(data_compress_algo) = self.data_compress_algo {
if data_compress_algo < 1 {
flags.data_compress_algo = 1;
} else {
flags.data_compress_algo = data_compress_algo
}
}
cfg.set_flags(flags);
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.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());
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.enable_vpn_portal = Some(true);
let cidr = vpn_config.client_cidr;
result.vpn_portal_client_network_addr = Some(cidr.first_address().to_string());
result.vpn_portal_client_network_len = Some(cidr.network_length() as i32);
result.vpn_portal_listen_port = Some(vpn_config.wireguard_listen.port() as i32);
}
if let Some(routes) = config.get_routes()
&& !routes.is_empty()
{
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());
let flags = config.get_flags();
let default_flags = default_config.get_flags();
result.latency_first = Some(flags.latency_first);
result.dev_name = Some(flags.dev_name.clone());
result.use_smoltcp = Some(flags.use_smoltcp);
result.disable_ipv6 = Some(!flags.enable_ipv6);
result.enable_kcp_proxy = Some(flags.enable_kcp_proxy);
result.disable_kcp_input = Some(flags.disable_kcp_input);
result.enable_quic_proxy = Some(flags.enable_quic_proxy);
result.disable_quic_input = Some(flags.disable_quic_input);
result.disable_p2p = Some(flags.disable_p2p);
result.p2p_only = Some(flags.p2p_only);
result.lazy_p2p = Some(flags.lazy_p2p);
result.bind_device = Some(flags.bind_device);
result.socket_mark = flags.socket_mark;
result.no_tun = Some(flags.no_tun);
result.enable_exit_node = Some(flags.enable_exit_node);
result.relay_all_peer_rpc = Some(flags.relay_all_peer_rpc);
result.need_p2p = Some(flags.need_p2p);
result.multi_thread = Some(flags.multi_thread);
result.proxy_forward_by_system = Some(flags.proxy_forward_by_system);
result.disable_encryption = Some(!flags.enable_encryption);
result.disable_tcp_hole_punching = Some(flags.disable_tcp_hole_punching);
result.disable_udp_hole_punching = Some(flags.disable_udp_hole_punching);
result.disable_upnp = Some(flags.disable_upnp);
result.disable_relay_data = Some(flags.disable_relay_data);
result.enable_udp_broadcast_relay = Some(flags.enable_udp_broadcast_relay);
result.disable_sym_hole_punching = Some(flags.disable_sym_hole_punching);
result.enable_magic_dns = Some(flags.accept_dns);
result.mtu = Some(flags.mtu as i32);
result.data_compress_algo = (flags.data_compress_algo != default_flags.data_compress_algo)
.then_some(flags.data_compress_algo);
result.encryption_algorithm = (flags.encryption_algorithm
!= default_flags.encryption_algorithm)
.then_some(flags.encryption_algorithm.clone());
result.instance_recv_bps_limit =
(flags.instance_recv_bps_limit != u64::MAX).then_some(flags.instance_recv_bps_limit);
result.enable_private_mode = Some(flags.private_mode);
result.acl = config.get_acl();
if flags.relay_network_whitelist == "*" {
result.enable_relay_network_whitelist = Some(false);
} else {
result.enable_relay_network_whitelist = Some(true);
if flags.relay_network_whitelist.is_empty() {
result.relay_network_whitelist = vec![];
} else {
result.relay_network_whitelist = flags
.relay_network_whitelist
.split_whitespace()
.map(|s| s.to_string())
.collect();
}
}
Ok(result)
}
}
+73
View File
@@ -0,0 +1,73 @@
use std::{fmt, str::FromStr};
use strum::VariantArray;
/// Stable configuration vocabulary for every known encryption algorithm.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, VariantArray)]
pub enum EncryptionAlgorithm {
Xor,
#[default]
AesGcm,
Aes256Gcm,
ChaCha20,
}
impl EncryptionAlgorithm {
pub const fn as_str(self) -> &'static str {
match self {
Self::Xor => "xor",
Self::AesGcm => "aes-gcm",
Self::Aes256Gcm => "aes-256-gcm",
Self::ChaCha20 => "chacha20",
}
}
}
impl fmt::Display for EncryptionAlgorithm {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for EncryptionAlgorithm {
type Err = ();
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.to_ascii_lowercase().as_str() {
"xor" => Ok(Self::Xor),
"aes-gcm" | "openssl-aes-gcm" => Ok(Self::AesGcm),
"aes-256-gcm" | "openssl-aes-256-gcm" => Ok(Self::Aes256Gcm),
"chacha20" | "chacha20-poly1305" | "openssl-chacha20" => Ok(Self::ChaCha20),
_ => Err(()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_algorithm_names_are_stable() {
let cases = [
("xor", EncryptionAlgorithm::Xor),
("aes-gcm", EncryptionAlgorithm::AesGcm),
("aes-256-gcm", EncryptionAlgorithm::Aes256Gcm),
("chacha20", EncryptionAlgorithm::ChaCha20),
("chacha20-poly1305", EncryptionAlgorithm::ChaCha20),
("openssl-aes-gcm", EncryptionAlgorithm::AesGcm),
("openssl-aes-256-gcm", EncryptionAlgorithm::Aes256Gcm),
("openssl-chacha20", EncryptionAlgorithm::ChaCha20),
];
for (name, expected) in cases {
assert_eq!(name.parse(), Ok(expected));
}
assert_eq!(EncryptionAlgorithm::ChaCha20.to_string(), "chacha20");
}
#[test]
fn aes_is_the_stable_default() {
assert_eq!(EncryptionAlgorithm::default(), EncryptionAlgorithm::AesGcm);
}
}
+138
View File
@@ -0,0 +1,138 @@
use std::net::SocketAddr;
use serde::{Deserialize, Serialize};
use easytier_proto::common::{PortForwardConfigPb, SocketType};
/// Runtime configuration for the core-owned SOCKS and port-forward gateway.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct GatewayRuntimeConfig {
pub socks5_bind: Option<SocketAddr>,
pub port_forwards: Vec<PortForwardConfig>,
}
/// One TCP or UDP port-forward rule.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct PortForwardConfig {
pub bind_addr: SocketAddr,
pub dst_addr: SocketAddr,
pub proto: String,
}
impl From<PortForwardConfigPb> for PortForwardConfig {
fn from(config: PortForwardConfigPb) -> Self {
Self {
bind_addr: config.bind_addr.unwrap_or_default().into(),
dst_addr: config.dst_addr.unwrap_or_default().into(),
proto: match SocketType::try_from(config.socket_type) {
Ok(SocketType::Tcp) => "tcp".to_string(),
Ok(SocketType::Udp) => "udp".to_string(),
_ => "tcp".to_string(),
},
}
}
}
impl From<PortForwardConfig> for PortForwardConfigPb {
fn from(config: PortForwardConfig) -> Self {
Self {
bind_addr: Some(config.bind_addr.into()),
dst_addr: Some(config.dst_addr.into()),
socket_type: match config.proto.to_lowercase().as_str() {
"tcp" => SocketType::Tcp as i32,
"udp" => SocketType::Udp as i32,
_ => SocketType::Tcp as i32,
},
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProxyRuntimeConfig {
pub enable_exit_node: bool,
pub no_tun: bool,
pub forward_by_system: bool,
pub force_smoltcp: bool,
pub icmp_failure_is_fatal: bool,
pub udp_response_ipv4_mtu: usize,
}
impl ProxyRuntimeConfig {
pub fn should_start(self, has_proxy_networks: bool) -> bool {
if !has_proxy_networks && !self.enable_exit_node && !self.no_tun {
return false;
}
!self.forward_by_system || self.no_tun
}
}
impl Default for ProxyRuntimeConfig {
fn default() -> Self {
Self {
enable_exit_node: false,
no_tun: false,
forward_by_system: false,
force_smoltcp: false,
icmp_failure_is_fatal: false,
udp_response_ipv4_mtu: 1280,
}
}
}
#[cfg(test)]
mod tests {
use super::ProxyRuntimeConfig;
#[test]
fn proxy_startup_policy_preserves_runtime_modes() {
assert!(!ProxyRuntimeConfig::default().should_start(false));
assert!(ProxyRuntimeConfig::default().should_start(true));
assert!(
ProxyRuntimeConfig {
enable_exit_node: true,
..Default::default()
}
.should_start(false)
);
assert!(
ProxyRuntimeConfig {
no_tun: true,
..Default::default()
}
.should_start(false)
);
}
#[test]
fn proxy_startup_policy_preserves_system_forwarding_rules() {
assert!(
!ProxyRuntimeConfig {
forward_by_system: true,
..Default::default()
}
.should_start(true)
);
assert!(
!ProxyRuntimeConfig {
enable_exit_node: true,
forward_by_system: true,
..Default::default()
}
.should_start(false)
);
assert!(
ProxyRuntimeConfig {
no_tun: true,
forward_by_system: true,
..Default::default()
}
.should_start(false)
);
}
#[test]
fn proxy_runtime_defaults_preserve_udp_mtu() {
assert_eq!(ProxyRuntimeConfig::default().udp_response_ipv4_mtu, 1280);
}
}
+804
View File
@@ -0,0 +1,804 @@
//! Static configuration schema plus the live runtime configuration store.
#[cfg(feature = "management")]
pub mod api;
#[cfg(feature = "management")]
pub mod api_input;
mod encryption;
pub mod gateway;
pub mod peers;
pub mod runtime;
pub mod toml;
pub use encryption::EncryptionAlgorithm;
pub(crate) const DEFAULT_UDP_STUN_SERVERS: &[&str] = &[
"txt:stun.easytier.cn",
"stun.miwifi.com",
"stun.chat.bilibili.com",
"stun.hitv.com",
];
pub(crate) const DEFAULT_TCP_STUN_SERVERS: &[&str] = &[
"stun.hot-chilli.net",
"stun.fitauto.ru",
"fwa.lifesizecloud.com",
"global.turn.twilio.com",
"turn.cloudflare.com",
"stun.voip.blackberry.com",
"stun.radiojar.com",
];
pub(crate) const DEFAULT_UDP_V6_STUN_SERVERS: &[&str] = &["txt:stun-v6.easytier.cn"];
pub(crate) fn default_stun_servers(servers: &[&str]) -> Vec<String> {
servers.iter().map(ToString::to_string).collect()
}
use std::{
collections::{BTreeSet, hash_map::DefaultHasher},
hash::{Hash, Hasher},
net::IpAddr,
};
use anyhow::Context as _;
use base64::{Engine as _, prelude::BASE64_STANDARD};
use easytier_proto::{common as common_pb, core_config as pb};
use serde::{Deserialize, Serialize};
use url::Url;
pub type PeerId = u32;
pub type NetworkSecretDigest = [u8; 32];
/// Host capabilities used by the portable mapped-listener validation rule.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MappedListenerPolicy {
implicit_port_schemes: BTreeSet<String>,
}
impl MappedListenerPolicy {
pub fn new<I, S>(implicit_port_schemes: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
implicit_port_schemes: implicit_port_schemes
.into_iter()
.map(Into::into)
.map(|scheme: String| scheme.to_ascii_lowercase())
.collect(),
}
}
pub fn validate(&self, url: &Url) -> anyhow::Result<()> {
if url.port().is_none() && !self.implicit_port_schemes.contains(url.scheme()) {
anyhow::bail!("mapped listener port is missing: {}", url);
}
Ok(())
}
pub fn parse_urls(&self, mapped_listeners: &[String]) -> anyhow::Result<Vec<Url>> {
mapped_listeners
.iter()
.map(|value| {
let url: Url = value
.parse()
.with_context(|| format!("mapped listener is not a valid url: {}", value))?;
self.validate(&url)?;
Ok(url)
})
.collect()
}
}
/// Completes and validates the portable secure-mode key configuration.
pub fn normalize_secure_mode_config(
mut config: common_pb::SecureModeConfig,
) -> anyhow::Result<common_pb::SecureModeConfig> {
if !config.enabled {
return Ok(config);
}
let private_key = if config.local_private_key.is_none() {
let private = x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng);
config.local_private_key = Some(BASE64_STANDARD.encode(private.as_bytes()));
private
} else {
config.private_key()?
};
let generated_public_key = x25519_dalek::PublicKey::from(&private_key);
let generated_public_key = BASE64_STANDARD.encode(generated_public_key.as_bytes());
match config.local_public_key.as_ref() {
None => config.local_public_key = Some(generated_public_key),
Some(configured_public_key) => {
let public_key = config.public_key()?;
let canonical_public_key = BASE64_STANDARD.encode(public_key.as_bytes());
if configured_public_key != &canonical_public_key {
anyhow::bail!(
"local public key {} does not match generated public key {}",
configured_public_key,
canonical_public_key
);
}
}
}
Ok(config)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkIdentity {
pub network_name: String,
pub network_secret: Option<String>,
pub network_secret_digest: Option<NetworkSecretDigest>,
}
impl NetworkIdentity {
pub fn new(network_name: String, network_secret: String) -> Self {
Self {
network_secret_digest: Some(network_secret_digest(&network_name, &network_secret)),
network_name,
network_secret: Some(network_secret),
}
}
pub fn new_credential(network_name: String) -> Self {
Self {
network_name,
network_secret: None,
network_secret_digest: None,
}
}
pub fn secret_digest(&self) -> Option<NetworkSecretDigest> {
if self.network_secret_digest.is_some() {
self.network_secret_digest
} else if let Some(network_secret) = &self.network_secret {
let mut network_secret_digest = [0u8; 32];
generate_digest_from_str(
&self.network_name,
network_secret,
&mut network_secret_digest,
);
Some(network_secret_digest)
} else {
None
}
}
pub fn with_secret_digest(mut self) -> Self {
self.network_secret_digest = self.secret_digest();
self
}
}
#[derive(Eq, PartialEq, Hash)]
struct NetworkIdentityWithOnlyDigest {
network_name: String,
network_secret_digest: Option<NetworkSecretDigest>,
}
fn generate_digest_from_str(str1: &str, str2: &str, digest: &mut [u8]) {
let mut hasher = DefaultHasher::new();
hasher.write(str1.as_bytes());
hasher.write(str2.as_bytes());
assert_eq!(digest.len() % 8, 0, "digest length must be multiple of 8");
let shard_count = digest.len() / 8;
for i in 0..shard_count {
digest[i * 8..(i + 1) * 8].copy_from_slice(&hasher.finish().to_be_bytes());
hasher.write(&digest[..(i + 1) * 8]);
}
}
fn network_secret_digest(network_name: &str, network_secret: &str) -> NetworkSecretDigest {
let mut digest = [0u8; 32];
generate_digest_from_str(network_name, network_secret, &mut digest);
digest
}
impl From<NetworkIdentity> for NetworkIdentityWithOnlyDigest {
fn from(identity: NetworkIdentity) -> Self {
Self {
network_secret_digest: identity.secret_digest(),
network_name: identity.network_name,
}
}
}
impl PartialEq for NetworkIdentity {
fn eq(&self, other: &Self) -> bool {
let self_with_digest = NetworkIdentityWithOnlyDigest::from(self.clone());
let other_with_digest = NetworkIdentityWithOnlyDigest::from(other.clone());
self_with_digest == other_with_digest
}
}
impl Eq for NetworkIdentity {}
impl Hash for NetworkIdentity {
fn hash<H: Hasher>(&self, state: &mut H) {
let self_with_digest = NetworkIdentityWithOnlyDigest::from(self.clone());
self_with_digest.hash(state);
}
}
impl Default for NetworkIdentity {
fn default() -> Self {
Self::new("default".to_string(), "".to_string())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct CoreConfig {
pub node: NodeConfig,
pub routes: RouteConfig,
pub peer_policy: PeerPolicyConfig,
pub traffic: TrafficConfig,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct NodeConfig {
pub peer_id: Option<PeerId>,
pub instance_id: Option<[u8; 16]>,
pub hostname: Option<String>,
pub network_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct RouteConfig {
pub ipv4: Option<IpPrefix>,
pub ipv6: Option<IpPrefix>,
pub advertised_routes: Vec<IpPrefix>,
pub proxy_networks: Vec<ProxyNetworkConfig>,
pub foreign_networks: Vec<ForeignNetworkConfig>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IpPrefix {
pub address: IpAddr,
pub prefix_len: u8,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProxyNetworkConfig {
pub real: IpPrefix,
pub mapped: Option<IpPrefix>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ForeignNetworkConfig {
pub name: String,
pub cidrs: Vec<IpPrefix>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerPolicyConfig {
pub p2p_enabled: bool,
pub relay_peer_rpc: bool,
pub relay_data: bool,
pub latency_first: bool,
pub encryption_required: bool,
}
impl Default for PeerPolicyConfig {
fn default() -> Self {
Self {
p2p_enabled: true,
relay_peer_rpc: false,
relay_data: true,
latency_first: false,
encryption_required: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct P2pPolicyFlags {
pub disable_udp_hole_punching: bool,
pub disable_sym_hole_punching: bool,
pub disable_upnp: bool,
pub lazy_p2p: bool,
pub disable_p2p: bool,
pub need_p2p: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct TrafficConfig {
pub mtu: Option<u16>,
pub instance_recv_bps_limit: Option<u64>,
pub foreign_relay_bps_limit: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ConfigError {
#[error("missing required field: {0}")]
MissingField(&'static str),
#[error("invalid IPv4 prefix length: {0}")]
InvalidIpv4Prefix(u8),
#[error("invalid IPv6 prefix length: {0}")]
InvalidIpv6Prefix(u8),
#[error("invalid MTU: {0}")]
InvalidMtu(u32),
}
impl IpPrefix {
pub fn new(address: IpAddr, prefix_len: u8) -> Result<Self, ConfigError> {
match address {
IpAddr::V4(_) if prefix_len <= 32 => Ok(Self {
address,
prefix_len,
}),
IpAddr::V4(_) => Err(ConfigError::InvalidIpv4Prefix(prefix_len)),
IpAddr::V6(_) if prefix_len <= 128 => Ok(Self {
address,
prefix_len,
}),
IpAddr::V6(_) => Err(ConfigError::InvalidIpv6Prefix(prefix_len)),
}
}
}
impl TryFrom<pb::CoreConfig> for CoreConfig {
type Error = ConfigError;
fn try_from(value: pb::CoreConfig) -> Result<Self, Self::Error> {
Ok(Self {
node: value
.node
.map(TryInto::try_into)
.transpose()?
.unwrap_or_default(),
routes: value
.routes
.map(TryInto::try_into)
.transpose()?
.unwrap_or_default(),
peer_policy: value.peer_policy.map(Into::into).unwrap_or_default(),
traffic: value
.traffic
.map(TryInto::try_into)
.transpose()?
.unwrap_or_default(),
})
}
}
impl From<CoreConfig> for pb::CoreConfig {
fn from(value: CoreConfig) -> Self {
Self {
node: Some(value.node.into()),
routes: Some(value.routes.into()),
peer_policy: Some(value.peer_policy.into()),
traffic: Some(value.traffic.into()),
}
}
}
impl TryFrom<pb::NodeConfig> for NodeConfig {
type Error = ConfigError;
fn try_from(value: pb::NodeConfig) -> Result<Self, Self::Error> {
Ok(Self {
peer_id: value.peer_id,
instance_id: value.instance_id.map(uuid_to_bytes),
hostname: value.hostname,
network_name: value.network_name,
})
}
}
impl From<NodeConfig> for pb::NodeConfig {
fn from(value: NodeConfig) -> Self {
Self {
peer_id: value.peer_id,
instance_id: value.instance_id.map(uuid_from_bytes),
hostname: value.hostname,
network_name: value.network_name,
}
}
}
impl TryFrom<pb::RouteConfig> for RouteConfig {
type Error = ConfigError;
fn try_from(value: pb::RouteConfig) -> Result<Self, Self::Error> {
Ok(Self {
ipv4: value.ipv4.map(TryInto::try_into).transpose()?,
ipv6: value.ipv6.map(TryInto::try_into).transpose()?,
advertised_routes: value
.advertised_routes
.into_iter()
.map(TryInto::try_into)
.collect::<Result<_, _>>()?,
proxy_networks: value
.proxy_networks
.into_iter()
.map(TryInto::try_into)
.collect::<Result<_, _>>()?,
foreign_networks: value
.foreign_networks
.into_iter()
.map(TryInto::try_into)
.collect::<Result<_, _>>()?,
})
}
}
impl From<RouteConfig> for pb::RouteConfig {
fn from(value: RouteConfig) -> Self {
Self {
ipv4: value.ipv4.map(Into::into),
ipv6: value.ipv6.map(Into::into),
advertised_routes: value
.advertised_routes
.into_iter()
.map(Into::into)
.collect(),
proxy_networks: value.proxy_networks.into_iter().map(Into::into).collect(),
foreign_networks: value.foreign_networks.into_iter().map(Into::into).collect(),
}
}
}
impl TryFrom<pb::IpPrefix> for IpPrefix {
type Error = ConfigError;
fn try_from(value: pb::IpPrefix) -> Result<Self, Self::Error> {
let address = pb_ip_addr_to_std(
value
.address
.ok_or(ConfigError::MissingField("IpPrefix.address"))?,
)?;
let prefix_len = u8::try_from(value.prefix_len)
.map_err(|_| invalid_prefix_for_address(address, value.prefix_len))?;
Self::new(address, prefix_len)
}
}
impl From<IpPrefix> for pb::IpPrefix {
fn from(value: IpPrefix) -> Self {
Self {
address: Some(value.address.into()),
prefix_len: value.prefix_len.into(),
}
}
}
impl TryFrom<pb::ProxyNetworkConfig> for ProxyNetworkConfig {
type Error = ConfigError;
fn try_from(value: pb::ProxyNetworkConfig) -> Result<Self, Self::Error> {
Ok(Self {
real: value
.real
.ok_or(ConfigError::MissingField("ProxyNetworkConfig.real"))?
.try_into()?,
mapped: value.mapped.map(TryInto::try_into).transpose()?,
})
}
}
impl From<ProxyNetworkConfig> for pb::ProxyNetworkConfig {
fn from(value: ProxyNetworkConfig) -> Self {
Self {
real: Some(value.real.into()),
mapped: value.mapped.map(Into::into),
}
}
}
impl TryFrom<pb::ForeignNetworkConfig> for ForeignNetworkConfig {
type Error = ConfigError;
fn try_from(value: pb::ForeignNetworkConfig) -> Result<Self, Self::Error> {
Ok(Self {
name: value.name,
cidrs: value
.cidrs
.into_iter()
.map(TryInto::try_into)
.collect::<Result<_, _>>()?,
})
}
}
impl From<ForeignNetworkConfig> for pb::ForeignNetworkConfig {
fn from(value: ForeignNetworkConfig) -> Self {
Self {
name: value.name,
cidrs: value.cidrs.into_iter().map(Into::into).collect(),
}
}
}
impl From<pb::PeerPolicyConfig> for PeerPolicyConfig {
fn from(value: pb::PeerPolicyConfig) -> Self {
let default = Self::default();
Self {
p2p_enabled: value.p2p_enabled.unwrap_or(default.p2p_enabled),
relay_peer_rpc: value.relay_peer_rpc.unwrap_or(default.relay_peer_rpc),
relay_data: value.relay_data.unwrap_or(default.relay_data),
latency_first: value.latency_first.unwrap_or(default.latency_first),
encryption_required: value
.encryption_required
.unwrap_or(default.encryption_required),
}
}
}
impl From<PeerPolicyConfig> for pb::PeerPolicyConfig {
fn from(value: PeerPolicyConfig) -> Self {
Self {
p2p_enabled: Some(value.p2p_enabled),
relay_peer_rpc: Some(value.relay_peer_rpc),
relay_data: Some(value.relay_data),
latency_first: Some(value.latency_first),
encryption_required: Some(value.encryption_required),
}
}
}
impl TryFrom<pb::TrafficConfig> for TrafficConfig {
type Error = ConfigError;
fn try_from(value: pb::TrafficConfig) -> Result<Self, Self::Error> {
Ok(Self {
mtu: value
.mtu
.map(|mtu| u16::try_from(mtu).map_err(|_| ConfigError::InvalidMtu(mtu)))
.transpose()?,
instance_recv_bps_limit: value.instance_recv_bps_limit,
foreign_relay_bps_limit: value.foreign_relay_bps_limit,
})
}
}
impl From<TrafficConfig> for pb::TrafficConfig {
fn from(value: TrafficConfig) -> Self {
Self {
mtu: value.mtu.map(Into::into),
instance_recv_bps_limit: value.instance_recv_bps_limit,
foreign_relay_bps_limit: value.foreign_relay_bps_limit,
}
}
}
fn pb_ip_addr_to_std(value: common_pb::IpAddr) -> Result<IpAddr, ConfigError> {
match value.ip.ok_or(ConfigError::MissingField("IpAddr.ip"))? {
common_pb::ip_addr::Ip::Ipv4(addr) => Ok(IpAddr::V4(addr.into())),
common_pb::ip_addr::Ip::Ipv6(addr) => Ok(IpAddr::V6(addr.into())),
}
}
fn invalid_prefix_for_address(address: IpAddr, prefix_len: u32) -> ConfigError {
let prefix_len = u8::try_from(prefix_len).unwrap_or(u8::MAX);
match address {
IpAddr::V4(_) => ConfigError::InvalidIpv4Prefix(prefix_len),
IpAddr::V6(_) => ConfigError::InvalidIpv6Prefix(prefix_len),
}
}
fn uuid_to_bytes(value: common_pb::Uuid) -> [u8; 16] {
let mut bytes = [0; 16];
bytes[0..4].copy_from_slice(&value.part1.to_be_bytes());
bytes[4..8].copy_from_slice(&value.part2.to_be_bytes());
bytes[8..12].copy_from_slice(&value.part3.to_be_bytes());
bytes[12..16].copy_from_slice(&value.part4.to_be_bytes());
bytes
}
fn uuid_from_bytes(bytes: [u8; 16]) -> common_pb::Uuid {
common_pb::Uuid {
part1: u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
part2: u32::from_be_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]),
part3: u32::from_be_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]),
part4: u32::from_be_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]),
}
}
#[cfg(test)]
mod tests {
use super::*;
use base64::prelude::BASE64_STANDARD;
use x25519_dalek::{PublicKey, StaticSecret};
fn digest(network_name: &str, network_secret: &str) -> NetworkSecretDigest {
let mut digest = [0u8; 32];
generate_digest_from_str(network_name, network_secret, &mut digest);
digest
}
#[test]
fn network_identity_matches_secret_to_digest_identity() {
let local = NetworkIdentity {
network_name: "net".to_string(),
network_secret: Some("secret".to_string()),
network_secret_digest: None,
};
let remote = NetworkIdentity {
network_name: "net".to_string(),
network_secret: None,
network_secret_digest: Some(digest("net", "secret")),
};
assert_eq!(local, remote);
}
#[test]
fn network_identity_rejects_different_digest() {
let local = NetworkIdentity {
network_name: "net".to_string(),
network_secret: Some("secret".to_string()),
network_secret_digest: None,
};
let remote = NetworkIdentity {
network_name: "net".to_string(),
network_secret: None,
network_secret_digest: Some(digest("net", "other")),
};
assert_ne!(local, remote);
}
#[test]
fn network_identity_equal_values_have_equal_hash() {
let local = NetworkIdentity {
network_name: "net".to_string(),
network_secret: Some("secret".to_string()),
network_secret_digest: None,
};
let remote = NetworkIdentity {
network_name: "net".to_string(),
network_secret: None,
network_secret_digest: Some(digest("net", "secret")),
};
let mut local_hasher = DefaultHasher::new();
let mut remote_hasher = DefaultHasher::new();
local.hash(&mut local_hasher);
remote.hash(&mut remote_hasher);
assert_eq!(local_hasher.finish(), remote_hasher.finish());
}
#[test]
fn network_identity_derives_digest_from_plaintext_secret() {
let identity = NetworkIdentity {
network_name: "net".to_string(),
network_secret: Some("secret".to_string()),
network_secret_digest: None,
};
assert_eq!(identity.secret_digest(), Some(digest("net", "secret")));
}
#[test]
fn network_identity_default_matches_native_default_network() {
assert_eq!(
NetworkIdentity::default(),
NetworkIdentity::new("default".to_string(), "".to_string())
);
}
#[test]
fn mapped_listener_policy_uses_explicit_host_capabilities() {
let policy = MappedListenerPolicy::new(["tcp", "ws", "wss"]);
let parsed = policy
.parse_urls(&[
"tcp://127.0.0.1".to_string(),
"ws://example.com".to_string(),
"wss://example.com/path".to_string(),
"ring://peer-id:1000".to_string(),
])
.unwrap();
assert_eq!(parsed.len(), 4);
assert_eq!(parsed[0].scheme(), "tcp");
assert_eq!(parsed[1].scheme(), "ws");
assert_eq!(parsed[2].scheme(), "wss");
assert_eq!(parsed[3].port(), Some(1000));
let error = policy
.parse_urls(&["ring://peer-id".to_string()])
.unwrap_err();
assert!(
error
.to_string()
.contains("mapped listener port is missing")
);
}
#[test]
fn secure_mode_normalization_generates_missing_key_pair() {
let normalized = normalize_secure_mode_config(common_pb::SecureModeConfig {
enabled: true,
local_private_key: None,
local_public_key: None,
})
.unwrap();
let private_key = normalized.private_key().unwrap();
let public_key = normalized.public_key().unwrap();
assert_eq!(public_key, PublicKey::from(&private_key));
}
#[test]
fn secure_mode_normalization_preserves_existing_key_configuration() {
let private_key = StaticSecret::from([7; 32]);
let public_key = PublicKey::from(&private_key);
let config = common_pb::SecureModeConfig {
enabled: true,
local_private_key: Some(BASE64_STANDARD.encode(private_key.as_bytes())),
local_public_key: Some(BASE64_STANDARD.encode(public_key.as_bytes())),
};
assert_eq!(
normalize_secure_mode_config(config.clone()).unwrap(),
config
);
}
#[test]
fn disabled_secure_mode_does_not_validate_keys() {
let config = common_pb::SecureModeConfig {
enabled: false,
local_private_key: Some("not-base64".to_string()),
local_public_key: Some("not-base64".to_string()),
};
assert_eq!(
normalize_secure_mode_config(config.clone()).unwrap(),
config
);
}
#[test]
fn validates_ip_prefix_lengths() {
assert!(IpPrefix::new("10.0.0.1".parse().unwrap(), 24).is_ok());
assert_eq!(
IpPrefix::new("10.0.0.1".parse().unwrap(), 33),
Err(ConfigError::InvalidIpv4Prefix(33))
);
assert!(IpPrefix::new("2001:db8::1".parse().unwrap(), 64).is_ok());
assert_eq!(
IpPrefix::new("2001:db8::1".parse().unwrap(), 129),
Err(ConfigError::InvalidIpv6Prefix(129))
);
}
#[test]
fn converts_core_config_from_proto_defaults() {
let config = CoreConfig::try_from(pb::CoreConfig {
node: Some(pb::NodeConfig {
peer_id: Some(7),
instance_id: None,
hostname: Some("node-a".to_string()),
network_name: "net".to_string(),
}),
routes: None,
peer_policy: None,
traffic: Some(pb::TrafficConfig {
mtu: Some(1380),
instance_recv_bps_limit: Some(100),
foreign_relay_bps_limit: None,
}),
})
.unwrap();
assert_eq!(config.node.peer_id, Some(7));
assert_eq!(config.node.hostname.as_deref(), Some("node-a"));
assert!(config.peer_policy.p2p_enabled);
assert_eq!(config.traffic.mtu, Some(1380));
}
#[test]
fn converts_ip_prefix_round_trip() {
let prefix = IpPrefix::new("10.1.0.1".parse().unwrap(), 16).unwrap();
let pb: pb::IpPrefix = prefix.clone().into();
assert_eq!(IpPrefix::try_from(pb).unwrap(), prefix);
}
}
+315
View File
@@ -0,0 +1,315 @@
//! Peer-flavored configuration data owned by the config layer.
//!
//! These types are pure serializable configuration snapshots. Normalization
//! and derivation behavior that depends on peer-domain logic stays in
//! `crate::peers`.
use anyhow::Context as _;
use cidr::{Ipv4Cidr, Ipv6Cidr};
use easytier_proto::common::{FlagsInConfig, PeerFeatureFlag, SecureModeConfig, StunInfo};
use serde::{Deserialize, Serialize};
use crate::proto::acl::{Acl, AclV1, Action, Chain, ChainType, GroupInfo, Protocol, Rule};
use super::{CoreConfig, NetworkIdentity};
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct AclRuleConfig {
pub acl: Option<Acl>,
pub tcp_whitelist: Vec<String>,
pub udp_whitelist: Vec<String>,
pub whitelist_priority: Option<u32>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AclWhitelistSnapshot {
pub tcp_ports: Vec<String>,
pub udp_ports: Vec<String>,
}
impl From<&AclRuleConfig> for AclWhitelistSnapshot {
fn from(config: &AclRuleConfig) -> Self {
Self {
tcp_ports: config.tcp_whitelist.clone(),
udp_ports: config.udp_whitelist.clone(),
}
}
}
impl AclRuleConfig {
fn parse_port_list(port_list: &[String]) -> anyhow::Result<Vec<String>> {
let mut ports = Vec::new();
for port_spec in port_list {
if port_spec.contains('-') {
let parts: Vec<&str> = port_spec.split('-').collect();
if parts.len() != 2 {
return Err(anyhow::anyhow!("Invalid port range format: {}", port_spec));
}
let start: u16 = parts[0]
.parse()
.with_context(|| format!("Invalid start port in range: {}", port_spec))?;
let end: u16 = parts[1]
.parse()
.with_context(|| format!("Invalid end port in range: {}", port_spec))?;
if start > end {
return Err(anyhow::anyhow!(
"Start port must be <= end port in range: {}",
port_spec
));
}
ports.push(port_spec.clone());
} else {
let port: u16 = port_spec
.parse()
.with_context(|| format!("Invalid port number: {}", port_spec))?;
ports.push(port.to_string());
}
}
Ok(ports)
}
fn generate_acl_from_whitelists(&mut self) -> anyhow::Result<()> {
if self.tcp_whitelist.is_empty() && self.udp_whitelist.is_empty() {
return Ok(());
}
let mut inbound_chain = Chain {
name: "inbound_whitelist".to_string(),
chain_type: ChainType::Inbound as i32,
description: "Auto-generated inbound whitelist from CLI".to_string(),
enabled: true,
rules: vec![],
default_action: Action::Allow as i32,
};
let mut rule_priority = self.whitelist_priority.unwrap_or(1000u32);
if !self.tcp_whitelist.is_empty() {
let tcp_ports = Self::parse_port_list(&self.tcp_whitelist)?;
inbound_chain.rules.push(Rule {
name: "tcp_whitelist".to_string(),
description: "Auto-generated TCP whitelist rule".to_string(),
priority: rule_priority,
enabled: true,
protocol: Protocol::Tcp as i32,
ports: tcp_ports,
source_ips: vec![],
destination_ips: vec![],
source_ports: vec![],
action: Action::Allow as i32,
rate_limit: 0,
burst_limit: 0,
stateful: true,
source_groups: vec![],
destination_groups: vec![],
});
inbound_chain.rules.push(Rule {
name: "tcp_whitelist_deny_other".to_string(),
description: "Auto-generated TCP whitelist rule to deny other ports".to_string(),
priority: 0,
enabled: true,
protocol: Protocol::Tcp as i32,
ports: vec!["0-65535".to_string()],
source_ips: vec![],
destination_ips: vec![],
source_ports: vec![],
action: Action::Drop as i32,
rate_limit: 0,
burst_limit: 0,
stateful: false,
source_groups: vec![],
destination_groups: vec![],
});
rule_priority -= 1;
}
if !self.udp_whitelist.is_empty() {
let udp_ports = Self::parse_port_list(&self.udp_whitelist)?;
inbound_chain.rules.push(Rule {
name: "udp_whitelist".to_string(),
description: "Auto-generated UDP whitelist rule".to_string(),
priority: rule_priority,
enabled: true,
protocol: Protocol::Udp as i32,
ports: udp_ports,
source_ips: vec![],
destination_ips: vec![],
source_ports: vec![],
action: Action::Allow as i32,
rate_limit: 0,
burst_limit: 0,
stateful: false,
source_groups: vec![],
destination_groups: vec![],
});
inbound_chain.rules.push(Rule {
name: "udp_whitelist_deny_other".to_string(),
description: "Auto-generated UDP whitelist rule to deny other ports".to_string(),
priority: 0,
enabled: true,
protocol: Protocol::Udp as i32,
ports: vec!["0-65535".to_string()],
source_ips: vec![],
destination_ips: vec![],
source_ports: vec![],
action: Action::Drop as i32,
rate_limit: 0,
burst_limit: 0,
stateful: false,
source_groups: vec![],
destination_groups: vec![],
});
}
if self.acl.is_none() {
self.acl = Some(Acl::default());
}
let acl = self.acl.as_mut().expect("ACL was initialized above");
if let Some(acl_v1) = acl.acl_v1.as_mut() {
acl_v1.chains.push(inbound_chain);
} else {
acl.acl_v1 = Some(AclV1 {
chains: vec![inbound_chain],
group: Some(GroupInfo {
declares: vec![],
members: vec![],
}),
});
}
Ok(())
}
pub fn build(&self) -> anyhow::Result<Option<Acl>> {
let mut config = self.clone();
config.generate_acl_from_whitelists()?;
Ok(config.acl)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PublicIpv6ProviderConfig {
pub provider_enabled: bool,
pub configured_prefix: Option<Ipv6Cidr>,
pub provider_supported: bool,
}
impl PublicIpv6ProviderConfig {
pub fn should_run_reconcile(self) -> bool {
self.provider_enabled
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PeerRuntimeConfig {
pub core: CoreConfig,
pub network_identity: NetworkIdentity,
pub stun_info: StunInfo,
pub feature_flags: PeerFeatureFlag,
pub secure_mode: Option<SecureModeConfig>,
pub host_routing: HostRoutingPolicy,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostRoutingPolicy {
/// Route otherwise-unreachable external IPv4 traffic through this node and
/// keep self-delivered packets eligible for the host TUN/proxy path.
pub local_exit_node_fallback: bool,
}
/// One normalized peer configuration version submitted by a host.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PeerRuntimeSnapshot {
pub runtime: PeerRuntimeConfig,
pub easytier_version: String,
pub avoid_relay_data_preference: bool,
pub flags: FlagsInConfig,
pub vpn_portal_cidr: Option<Ipv4Cidr>,
pub pinned_peers: Vec<(url::Url, Option<String>)>,
pub peer_group_memberships: Vec<PeerGroupIdentity>,
pub acl_group_declarations: Vec<PeerGroupIdentity>,
pub ospf_update_my_foreign_network_interval_sec: u64,
pub max_direct_conns_per_peer_in_foreign_network: usize,
pub hmac_secret_digest: bool,
}
impl PeerRuntimeSnapshot {
pub fn new(runtime: PeerRuntimeConfig, flags: FlagsInConfig) -> Self {
let avoid_relay_data_preference = runtime.feature_flags.avoid_relay_data;
Self {
runtime,
easytier_version: env!("CARGO_PKG_VERSION").to_owned(),
avoid_relay_data_preference,
flags,
vpn_portal_cidr: None,
pinned_peers: Vec::new(),
peer_group_memberships: Vec::new(),
acl_group_declarations: Vec::new(),
ospf_update_my_foreign_network_interval_sec: 10,
max_direct_conns_per_peer_in_foreign_network: 3,
hmac_secret_digest: false,
}
}
}
impl Default for PeerRuntimeSnapshot {
fn default() -> Self {
Self::new(
PeerRuntimeConfig {
core: CoreConfig::default(),
network_identity: NetworkIdentity::default(),
stun_info: StunInfo::default(),
feature_flags: PeerFeatureFlag::default(),
secure_mode: None,
host_routing: HostRoutingPolicy::default(),
},
FlagsInConfig::default(),
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PeerGroupIdentity {
pub group_name: String,
pub group_secret: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn whitelist_rules_are_built_in_core() {
let acl = AclRuleConfig {
tcp_whitelist: vec!["80".to_string(), "8000-9000".to_string()],
udp_whitelist: vec!["53".to_string()],
..Default::default()
}
.build()
.unwrap()
.unwrap();
let chain = &acl.acl_v1.unwrap().chains[0];
assert_eq!(chain.name, "inbound_whitelist");
assert_eq!(chain.rules.len(), 4);
assert_eq!(chain.rules[0].ports, ["80", "8000-9000"]);
assert_eq!(chain.rules[2].ports, ["53"]);
}
#[test]
fn invalid_whitelist_range_is_rejected() {
let error = AclRuleConfig {
tcp_whitelist: vec!["9000-8000".to_string()],
..Default::default()
}
.build()
.unwrap_err();
assert!(error.to_string().contains("Start port must be <= end port"));
}
}
+253
View File
@@ -0,0 +1,253 @@
//! Atomic runtime configuration owned by one core instance.
use std::{collections::BTreeSet, sync::Arc};
use arc_swap::ArcSwap;
use cidr::Ipv4Cidr;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use super::{
gateway::{GatewayRuntimeConfig, ProxyRuntimeConfig},
peers::{AclRuleConfig, PeerRuntimeSnapshot, PublicIpv6ProviderConfig},
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoreRuntimeConfig {
pub acl: AclRuleConfig,
pub dhcp_ipv4: bool,
pub gateway: GatewayRuntimeConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manual_routes: Option<BTreeSet<Ipv4Cidr>>,
pub proxy: ProxyRuntimeConfig,
#[serde(default)]
pub public_ipv6_auto: bool,
pub public_ipv6_provider: PublicIpv6ProviderConfig,
}
impl Default for CoreRuntimeConfig {
fn default() -> Self {
Self {
acl: AclRuleConfig::default(),
dhcp_ipv4: false,
gateway: GatewayRuntimeConfig::default(),
manual_routes: None,
proxy: ProxyRuntimeConfig::default(),
public_ipv6_auto: false,
public_ipv6_provider: PublicIpv6ProviderConfig {
provider_enabled: false,
configured_prefix: None,
provider_supported: false,
},
}
}
}
#[derive(Debug, Clone)]
pub struct CoreInstanceRuntimeConfig {
pub services: CoreRuntimeConfig,
pub peer: Arc<PeerRuntimeSnapshot>,
}
struct CoreRuntimeConfigStoreInner {
snapshot: ArcSwap<CoreInstanceRuntimeConfig>,
update: Mutex<()>,
peer_changes: tokio::sync::watch::Sender<u64>,
service_changes: tokio::sync::watch::Sender<u64>,
}
/// Atomic configuration authority shared by one core instance and its peer
/// context. Readers always observe a complete submitted version.
#[derive(Clone)]
pub struct CoreRuntimeConfigStore {
inner: Arc<CoreRuntimeConfigStoreInner>,
}
impl CoreRuntimeConfigStore {
pub fn new(services: CoreRuntimeConfig, peer: Arc<PeerRuntimeSnapshot>) -> Self {
let (peer_changes, _) = tokio::sync::watch::channel(0);
let (service_changes, _) = tokio::sync::watch::channel(0);
Self {
inner: Arc::new(CoreRuntimeConfigStoreInner {
snapshot: ArcSwap::from_pointee(CoreInstanceRuntimeConfig { services, peer }),
update: Mutex::new(()),
peer_changes,
service_changes,
}),
}
}
pub fn snapshot(&self) -> Arc<CoreInstanceRuntimeConfig> {
self.inner.snapshot.load_full()
}
pub fn replace(&self, config: CoreInstanceRuntimeConfig) {
let _update = self.inner.update.lock();
self.inner.snapshot.store(Arc::new(config));
self.inner.peer_changes.send_modify(|version| *version += 1);
self.inner
.service_changes
.send_modify(|version| *version += 1);
}
pub fn update_services(&self, update: impl FnOnce(&mut CoreRuntimeConfig)) {
let _update = self.inner.update.lock();
let mut config = self.inner.snapshot.load_full().as_ref().clone();
update(&mut config.services);
self.inner.snapshot.store(Arc::new(config));
self.inner
.service_changes
.send_modify(|version| *version += 1);
}
pub fn update_peer(&self, peer: Arc<PeerRuntimeSnapshot>) {
let _update = self.inner.update.lock();
let mut config = self.inner.snapshot.load_full().as_ref().clone();
config.peer = peer;
self.inner.snapshot.store(Arc::new(config));
self.inner.peer_changes.send_modify(|version| *version += 1);
}
pub(crate) fn update_peer_with(&self, update: impl FnOnce(&mut PeerRuntimeSnapshot)) {
let _update = self.inner.update.lock();
let mut config = self.inner.snapshot.load_full().as_ref().clone();
update(Arc::make_mut(&mut config.peer));
self.inner.snapshot.store(Arc::new(config));
self.inner.peer_changes.send_modify(|version| *version += 1);
}
pub fn subscribe_peer_runtime_changes(&self) -> tokio::sync::watch::Receiver<u64> {
self.inner.peer_changes.subscribe()
}
pub fn subscribe_service_runtime_changes(&self) -> tokio::sync::watch::Receiver<u64> {
self.inner.service_changes.subscribe()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn replaces_service_and_peer_as_one_version() {
let mut before_peer = PeerRuntimeSnapshot::default();
before_peer.runtime.core.node.hostname = Some("before".to_owned());
let store =
CoreRuntimeConfigStore::new(CoreRuntimeConfig::default(), Arc::new(before_peer));
let before = store.snapshot();
let after_services = CoreRuntimeConfig {
dhcp_ipv4: true,
..Default::default()
};
let mut after_peer = PeerRuntimeSnapshot::default();
after_peer.runtime.core.node.hostname = Some("after".to_owned());
store.replace(CoreInstanceRuntimeConfig {
services: after_services,
peer: Arc::new(after_peer),
});
assert!(!before.services.dhcp_ipv4);
assert_eq!(
before.peer.runtime.core.node.hostname.as_deref(),
Some("before")
);
let after = store.snapshot();
assert!(after.services.dhcp_ipv4);
assert_eq!(
after.peer.runtime.core.node.hostname.as_deref(),
Some("after")
);
}
#[tokio::test]
async fn notifies_peer_snapshot_changes() {
let store = CoreRuntimeConfigStore::new(
CoreRuntimeConfig::default(),
Arc::new(PeerRuntimeSnapshot::default()),
);
let mut changes = store.subscribe_peer_runtime_changes();
let mut peer = PeerRuntimeSnapshot::default();
peer.runtime.core.node.hostname = Some("updated".to_owned());
store.update_peer(Arc::new(peer));
assert!(changes.changed().await.is_ok());
}
#[tokio::test]
async fn notifies_service_snapshot_changes() {
let store = CoreRuntimeConfigStore::new(
CoreRuntimeConfig::default(),
Arc::new(PeerRuntimeSnapshot::default()),
);
let mut changes = store.subscribe_service_runtime_changes();
store.update_services(|services| services.dhcp_ipv4 = true);
assert!(changes.changed().await.is_ok());
assert!(store.snapshot().services.dhcp_ipv4);
}
#[tokio::test]
async fn peer_update_does_not_notify_service_watchers() {
let store = CoreRuntimeConfigStore::new(
CoreRuntimeConfig::default(),
Arc::new(PeerRuntimeSnapshot::default()),
);
let changes = store.subscribe_service_runtime_changes();
let mut peer = PeerRuntimeSnapshot::default();
peer.runtime.core.node.hostname = Some("updated".to_owned());
store.update_peer(Arc::new(peer));
assert!(!changes.has_changed().unwrap());
}
#[test]
fn peer_in_place_update_preserves_the_rest_of_the_atomic_snapshot() {
let services = CoreRuntimeConfig {
dhcp_ipv4: true,
..Default::default()
};
let mut peer = PeerRuntimeSnapshot::default();
peer.runtime.core.node.hostname = Some("preserved".to_owned());
let store = CoreRuntimeConfigStore::new(services, Arc::new(peer));
store.update_peer_with(|peer| {
peer.runtime.core.routes.ipv4 = Some(crate::config::IpPrefix {
address: "10.20.30.7".parse().unwrap(),
prefix_len: 24,
});
});
let snapshot = store.snapshot();
assert!(snapshot.services.dhcp_ipv4);
assert_eq!(
snapshot.peer.runtime.core.node.hostname.as_deref(),
Some("preserved")
);
assert_eq!(
snapshot
.peer
.runtime
.core
.routes
.ipv4
.as_ref()
.unwrap()
.address,
"10.20.30.7".parse::<std::net::IpAddr>().unwrap()
);
}
#[test]
fn missing_manual_routes_preserves_portable_config_compatibility() {
let encoded = serde_json::to_value(CoreRuntimeConfig::default()).unwrap();
assert!(encoded.get("manual_routes").is_none());
let decoded: CoreRuntimeConfig = serde_json::from_value(encoded).unwrap();
assert_eq!(decoded.manual_routes, None);
}
}
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
use super::{Arc, Mutex, TomlConfig};
impl TomlConfig {
pub(crate) fn detached_snapshot(&self) -> Self {
let config = self.config.lock().unwrap().clone();
Self {
config: Arc::new(Mutex::new(config)),
}
}
pub(crate) fn replace_from_snapshot(&self, snapshot: &Self) {
let config = snapshot.config.lock().unwrap().clone();
*self.config.lock().unwrap() = config;
}
}