mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-09-04 18:15:39 +00:00
feat(vpn): multi-client WireGuard portal with attached peers (#2502)
* feat(peer): support protocol-agnostic attached peers Add locally attached peers backed by independent, peer-level portable managers and authenticated in-process ring connections. Carry trusted connection provenance through packet admission so attached relay privileges cannot be forged through packet headers. Let every peer manager own ACL loading, sanitized policy updates, route refresh, and runtime cleanup. In Secure Mode, grant attached identities ephemeral credentials instead of sharing administrator and group secrets. * feat(vpn): add reusable attached-peer portal runtime Add a protocol-neutral portal runtime that converts authenticated client sessions into attached EasyTier peers. Own per-client generations, status, packet forwarding, address translation, and peer cleanup without knowing the transport protocol. Add transactional IPv4 source and destination rewriting with correct IPv4, TCP, UDP, ICMP, and quoted-packet checksum updates. Keep the old production portal path temporarily active until the WireGuard adapter is migrated in the next change. * feat(wireguard): attach named clients through peer portal Replace the monolithic WireGuard portal with a native adapter that owns key derivation, UDP demultiplexing, reauthentication, roaming, and bounded per-client packet queues. Hand authenticated sessions to the generic portal runtime for peer lifecycle and IPv4 translation. Move portal configuration into the core instance model, require a dedicated server key, and preserve existing listener, CLI, and runtime configuration behavior. Reject runtime address conflicts before publishing shared configuration. * feat(vpn): expose per-client portal status Project configured clients and their runtime state through the portal RPC, including generated client configuration, listener, peer identity, endpoint, tunnel address, ACL groups, and errors. Keep private client configuration out of the broad instance-info response and expose the explicit RPC through the CLI and Tauri bridge. * feat(vpn): add portal configuration to web clients Expose WireGuard portal listener, key, client, ACL group, and runtime status fields in the shared frontend library, Web dashboard, and Tauri client. Preserve UUID and uint64 values across protobuf JSON boundaries, keep dynamic client editor rows stable, and document the portal workflow. * test(vpn): cover multi-client and roaming WireGuard portals Add two three-node integration tests for the WireGuard VPN portal. The multi-client test connects two kernel WireGuard clients from separate network namespaces, verifies per-client connectivity to mesh nodes, and exercises cross-client traffic that runs the IPv4 source and destination translation in both directions. A TCP echo exchange through the portal additionally covers the TCP pseudo-header checksum rewrite path that ICMP-only ping tests miss, and portal status snapshots must report both clients online with distinct peer ids and correctly learned tunnel addresses. The roaming test swaps the client namespace address (delete the old address, then add the new one) so the kernel WireGuard source cache is invalidated and the client keeps sending under the same session from the new source, exactly like a real network change. The portal must update the client endpoint on the same peer id via the data path (same generation, no re-handshake, no detach/reconnect) while connectivity to mesh nodes is preserved. Supporting changes: run_wireguard_client now takes an interface name, and the shared namespace topology gains net_f (10.1.2.5) on the portal bridge for the second client.
This commit is contained in:
@@ -80,11 +80,19 @@ pub fn network_config_from_toml(config: &TomlConfig) -> NetworkConfig {
|
||||
}
|
||||
|
||||
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);
|
||||
result.vpn_portal_config = Some(manage::VpnPortalConfig {
|
||||
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()
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::config::{
|
||||
MappedListenerPolicy, normalize_secure_mode_config,
|
||||
toml::{
|
||||
ConfigLoader, NetworkIdentity, PeerConfig, PortForwardConfig, TomlConfigLoader,
|
||||
VpnPortalConfig, gen_default_flags,
|
||||
VpnPortalClientConfig, VpnPortalConfig, gen_default_flags,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -98,6 +98,7 @@ fn parse_peer_urls(peer_urls: &[String]) -> Result<Vec<PeerConfig>, anyhow::Erro
|
||||
}
|
||||
|
||||
impl NetworkConfigExt for NetworkConfig {
|
||||
#[allow(deprecated)]
|
||||
fn gen_config(&self) -> Result<TomlConfigLoader, anyhow::Error> {
|
||||
let cfg = TomlConfigLoader::default();
|
||||
cfg.set_id(
|
||||
@@ -219,29 +220,37 @@ impl NetworkConfigExt for NetworkConfig {
|
||||
);
|
||||
}
|
||||
|
||||
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)
|
||||
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 {
|
||||
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(|| {
|
||||
wireguard_listen: vpn_config.wireguard_listen.parse().with_context(|| {
|
||||
format!(
|
||||
"failed to parse vpn portal wireguard listen port. {:?}",
|
||||
self.vpn_portal_listen_port
|
||||
"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>>()?,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -556,13 +565,19 @@ impl NetworkConfigExt for NetworkConfig {
|
||||
}
|
||||
|
||||
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);
|
||||
result.vpn_portal_config = Some(manage::VpnPortalConfig {
|
||||
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()
|
||||
@@ -650,3 +665,80 @@ impl NetworkConfigExt for NetworkConfig {
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(deprecated)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn api_portal_config() -> manage::VpnPortalConfig {
|
||||
manage::VpnPortalConfig {
|
||||
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".to_owned(),
|
||||
groups: vec!["staff".to_owned()],
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn standalone_config() -> NetworkConfig {
|
||||
NetworkConfig {
|
||||
networking_method: Some(NetworkingMethod::Standalone as i32),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[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");
|
||||
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 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! `crate::peers`.
|
||||
|
||||
use anyhow::Context as _;
|
||||
use cidr::{Ipv4Cidr, Ipv6Cidr};
|
||||
use cidr::Ipv6Cidr;
|
||||
use easytier_proto::common::{FlagsInConfig, PeerFeatureFlag, SecureModeConfig, StunInfo};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -185,6 +185,14 @@ impl AclRuleConfig {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn for_credential_peer(&self) -> Self {
|
||||
let mut config = self.clone();
|
||||
if let Some(acl) = config.acl.as_mut().and_then(|acl| acl.acl_v1.as_mut()) {
|
||||
acl.group = None;
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
pub fn build(&self) -> anyhow::Result<Option<Acl>> {
|
||||
let mut config = self.clone();
|
||||
config.generate_acl_from_whitelists()?;
|
||||
@@ -229,7 +237,6 @@ pub struct PeerRuntimeSnapshot {
|
||||
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>,
|
||||
@@ -246,7 +253,6 @@ impl PeerRuntimeSnapshot {
|
||||
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(),
|
||||
@@ -312,4 +318,42 @@ mod tests {
|
||||
|
||||
assert!(error.to_string().contains("Start port must be <= end port"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_peer_acl_preserves_chains_without_group_secrets() {
|
||||
let config = AclRuleConfig {
|
||||
acl: Some(Acl {
|
||||
acl_v1: Some(AclV1 {
|
||||
chains: vec![Chain {
|
||||
name: "forward".to_owned(),
|
||||
chain_type: ChainType::Forward as i32,
|
||||
rules: vec![Rule {
|
||||
action: Action::Drop as i32,
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}],
|
||||
group: Some(GroupInfo {
|
||||
declares: vec![crate::proto::acl::GroupIdentity {
|
||||
group_name: "ops".to_owned(),
|
||||
group_secret: "secret".to_owned(),
|
||||
}],
|
||||
members: vec!["ops".to_owned()],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
tcp_whitelist: vec!["22".to_owned()],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let sanitized = config.for_credential_peer();
|
||||
|
||||
let acl = sanitized.acl.unwrap().acl_v1.unwrap();
|
||||
assert_eq!(acl.chains.len(), 1);
|
||||
assert_eq!(acl.chains[0].name, "forward");
|
||||
assert_eq!(acl.chains[0].rules[0].action, Action::Drop as i32);
|
||||
assert!(acl.group.is_none());
|
||||
assert_eq!(sanitized.tcp_whitelist, ["22"]);
|
||||
assert!(config.acl.unwrap().acl_v1.unwrap().group.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +145,15 @@ impl CoreRuntimeConfigStore {
|
||||
pub fn subscribe_service_runtime_changes(&self) -> tokio::sync::watch::Receiver<u64> {
|
||||
self.inner.service_changes.subscribe()
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(crate) fn peer_change_subscriber_count(&self) -> usize {
|
||||
self.inner.peer_changes.receiver_count()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn service_change_subscriber_count(&self) -> usize {
|
||||
self.inner.service_changes.receiver_count()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -276,6 +276,9 @@ pub trait ConfigLoader: Send + Sync {
|
||||
fn set_network_config_source(&self, _source: Option<ConfigSource>) {}
|
||||
|
||||
fn dump(&self) -> String;
|
||||
fn dump_redacted(&self) -> String {
|
||||
self.dump()
|
||||
}
|
||||
}
|
||||
|
||||
pub trait LoggingConfigLoader {
|
||||
@@ -435,10 +438,37 @@ impl LoggingConfigLoader for &LoggingConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[derive(Clone, Deserialize, Serialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct VpnPortalConfig {
|
||||
pub client_cidr: cidr::Ipv4Cidr,
|
||||
pub wireguard_listen: SocketAddr,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub wireguard_private_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub clients: Vec<VpnPortalClientConfig>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for VpnPortalConfig {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("VpnPortalConfig")
|
||||
.field("wireguard_listen", &self.wireguard_listen)
|
||||
.field(
|
||||
"wireguard_private_key",
|
||||
&self.wireguard_private_key.as_ref().map(|_| "<redacted>"),
|
||||
)
|
||||
.field("clients", &self.clients)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct VpnPortalClientConfig {
|
||||
pub name: String,
|
||||
pub virtual_ip: std::net::Ipv4Addr,
|
||||
#[serde(default)]
|
||||
pub groups: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
@@ -549,6 +579,57 @@ impl TomlConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "config-write")]
|
||||
fn config_for_dump(&self) -> Config {
|
||||
let mut config = self.config.lock().unwrap().clone();
|
||||
Self::normalize_config_source(&mut config);
|
||||
config.flags = Some(flags_diff_from_default(&self.get_flags()));
|
||||
config
|
||||
}
|
||||
|
||||
#[cfg(feature = "config-write")]
|
||||
fn redact_secrets(config: &mut Config) {
|
||||
const REDACTED: &str = "<redacted>";
|
||||
|
||||
if let Some(secret) = config
|
||||
.network_identity
|
||||
.as_mut()
|
||||
.and_then(|identity| identity.network_secret.as_mut())
|
||||
&& !secret.is_empty()
|
||||
{
|
||||
*secret = REDACTED.to_owned();
|
||||
}
|
||||
if let Some(private_key) = config
|
||||
.secure_mode
|
||||
.as_mut()
|
||||
.and_then(|secure_mode| secure_mode.local_private_key.as_mut())
|
||||
&& !private_key.is_empty()
|
||||
{
|
||||
*private_key = REDACTED.to_owned();
|
||||
}
|
||||
if let Some(private_key) = config
|
||||
.vpn_portal_config
|
||||
.as_mut()
|
||||
.and_then(|portal| portal.wireguard_private_key.as_mut())
|
||||
&& !private_key.is_empty()
|
||||
{
|
||||
*private_key = REDACTED.to_owned();
|
||||
}
|
||||
if let Some(declarations) = config
|
||||
.acl
|
||||
.as_mut()
|
||||
.and_then(|acl| acl.acl_v1.as_mut())
|
||||
.and_then(|acl| acl.group.as_mut())
|
||||
.map(|group| &mut group.declares)
|
||||
{
|
||||
for declaration in declarations {
|
||||
if !declaration.group_secret.is_empty() {
|
||||
declaration.group_secret = REDACTED.to_owned();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_from_str(config_str: &str) -> Result<Self, anyhow::Error> {
|
||||
Self::new_from_str_with_source("inline config", config_str)
|
||||
}
|
||||
@@ -1027,9 +1108,19 @@ impl ConfigLoader for TomlConfig {
|
||||
fn dump(&self) -> String {
|
||||
#[cfg(feature = "config-write")]
|
||||
{
|
||||
let mut config = self.config.lock().unwrap().clone();
|
||||
Self::normalize_config_source(&mut config);
|
||||
config.flags = Some(flags_diff_from_default(&self.get_flags()));
|
||||
toml::to_string_pretty(&self.config_for_dump()).unwrap()
|
||||
}
|
||||
#[cfg(not(feature = "config-write"))]
|
||||
{
|
||||
panic!("this build does not include TOML configuration serialization")
|
||||
}
|
||||
}
|
||||
|
||||
fn dump_redacted(&self) -> String {
|
||||
#[cfg(feature = "config-write")]
|
||||
{
|
||||
let mut config = self.config_for_dump();
|
||||
Self::redact_secrets(&mut config);
|
||||
toml::to_string_pretty(&config).unwrap()
|
||||
}
|
||||
#[cfg(not(feature = "config-write"))]
|
||||
@@ -1097,6 +1188,72 @@ socket_mark = 0
|
||||
assert_eq!(restored.get_flags().socket_mark, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_vpn_portal_client_cidr_is_rejected_explicitly() {
|
||||
let error = TomlConfig::new_from_str(
|
||||
r#"
|
||||
[vpn_portal_config]
|
||||
client_cidr = "10.14.14.0/24"
|
||||
wireguard_listen = "0.0.0.0:51820"
|
||||
"#,
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert!(error.contains("client_cidr"), "{error}");
|
||||
}
|
||||
|
||||
#[cfg(feature = "config-write")]
|
||||
#[test]
|
||||
fn vpn_portal_round_trip_and_redacted_dump_preserve_dump_semantics() {
|
||||
let config = TomlConfig::new_from_str(
|
||||
r#"
|
||||
[network_identity]
|
||||
network_name = "network-a"
|
||||
network_secret = "network-secret"
|
||||
|
||||
[secure_mode]
|
||||
enabled = true
|
||||
local_private_key = "noise-private-key"
|
||||
|
||||
[vpn_portal_config]
|
||||
wireguard_listen = "0.0.0.0:51820"
|
||||
wireguard_private_key = "wireguard-private-key"
|
||||
|
||||
[[vpn_portal_config.clients]]
|
||||
name = "alice"
|
||||
virtual_ip = "10.144.144.10"
|
||||
groups = ["staff"]
|
||||
|
||||
[acl.acl_v1.group]
|
||||
|
||||
[[acl.acl_v1.group.declares]]
|
||||
group_name = "staff"
|
||||
group_secret = "group-secret"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let dumped = config.dump();
|
||||
assert!(dumped.contains("network-secret"));
|
||||
assert!(dumped.contains("noise-private-key"));
|
||||
assert!(dumped.contains("wireguard-private-key"));
|
||||
assert!(dumped.contains("group-secret"));
|
||||
assert_eq!(
|
||||
TomlConfig::new_from_str(&dumped)
|
||||
.unwrap()
|
||||
.get_vpn_portal_config(),
|
||||
config.get_vpn_portal_config()
|
||||
);
|
||||
|
||||
let redacted = config.dump_redacted();
|
||||
assert!(!redacted.contains("network-secret"));
|
||||
assert!(!redacted.contains("noise-private-key"));
|
||||
assert!(!redacted.contains("wireguard-private-key"));
|
||||
assert!(!redacted.contains("group-secret"));
|
||||
assert_eq!(redacted.matches("<redacted>").count(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostname_normalization_is_portable_and_has_no_host_fallback() {
|
||||
let absent = TomlConfig::default();
|
||||
|
||||
Reference in New Issue
Block a user