feat(vpn): hot add/remove WireGuard portal clients without restart (#2514)

* feat(vpn): hot add/remove WireGuard portal clients without restart

WireGuard portal clients were frozen at instance construction: the
engine slot maps, host key table, and PortalModule state were all
immutable after startup, so any client change required recreating the
whole instance and dropping every established session.

Wire dynamic client management through the existing config-patch
channel (ConfigRpc.patch_config -> apply_config_patch), following the
same pattern as connectors, port forwards, and proxy networks:

- proto: InstanceConfigPatch gains repeated VpnPortalClientPatch
  (Add/Remove/Clear by client name)
- engine: slot maps move under an RwLock with a free-index allocator;
  add_client/remove_client recycle indices, mark removed slots retired,
  and expire active sessions so Core tears down the attached peer via
  the regular channel-close path (credential revocation and disconnect
  events included); untouched clients keep their sessions intact. The
  retired flag is re-checked under the session lock so a datagram that
  races with removal cannot resurrect a session
- host: WireGuardPortalHost derives keys deterministically per name
  (HKDF), keeps a mutable client table for render_client_config, and
  forwards updates to the live engine; changed clients are re-added so
  they re-handshake into a fresh generation with the new virtual IP or
  groups
- PortalModule: client set, statuses, and session locks become shared
  mutable state; run_session resolves clients from the shared map at
  accept time; update_clients() validates against a caller-supplied
  runtime snapshot. An empty client set is legal in every lifecycle
  stage, so clearing all clients never produces a configuration that
  fails instance recreation
- config_patch: apply_vpn_portal_client_patches mutates the candidate
  TOML; the sub-patch runs last and is deep-validated and hot-applied
  before the candidate commits, so a rejected client set leaves neither
  the shared model nor the live portal changed, and validation sees the
  fully patched state including routes and node IPv4 from the same
  request. Rejects patches when no portal is configured or a removed
  client does not exist
- cli: vpn-portal add-client/remove-client/clear-clients subcommands

Tests: engine index recycling, module update validation/state/host
notification, TOML patch application, and a three-node integration test
that adds a second WireGuard client live, removes the first while the
second stays online, and asserts rejected patches leave the shared
model unchanged.

* feat(web): reconcile WireGuard portal client edits as hot patches

The web console reconciles desired network config against the running
instance and patches it in place when possible. VPN portal changes were
not part of that: any client edit made the base configs differ, so every
save recreated the instance and dropped all established sessions.

Exclude vpn_portal_config from the base comparison and diff its clients
by name instead. Client add/remove/change now produces
VpnPortalClientPatch entries (removals first, changed clients as
remove+add) applied through the existing PatchConfig channel. Listener
identity changes (address or private key) and enabling or disabling the
portal still fall back to a full instance recreate, since those change
the listener lifecycle.

* feat(web/gui): map portal client patches to frontend RPC backends

Extend the RemoteClient seam with add/remove/clear VPN portal client
operations so frontend hosts can drive the same PatchConfig channel as
the CLI. There is deliberately no dedicated editing UI: the config form
stays the single editing surface (aligned with port forwards), and
these methods exist for programmatic and future use.

- web console: JSON proxy-rpc to ConfigRpcService.patch_config with
  VpnPortalClientPatch entries (pbjson string enum actions)
- desktop GUI: patch_vpn_portal_clients tauri command forwarding the
  same patch through the typed ConfigRpc client
This commit is contained in:
KKRainbow
2026-08-22 01:18:42 +08:00
committed by GitHub
parent 62e4fd15e9
commit 8794e12a26
14 changed files with 1416 additions and 119 deletions
+266 -59
View File
@@ -7,7 +7,7 @@
use std::{
collections::{BTreeMap, BTreeSet},
net::{IpAddr, Ipv4Addr},
sync::Arc,
sync::{Arc, RwLock as StdRwLock},
};
use async_trait::async_trait;
@@ -119,6 +119,15 @@ pub trait PortalHost: Send + Sync + 'static {
fn name(&self) -> String;
fn render_client_config(&self, plan: &PortalClientConfigPlan) -> String;
/// Replaces the configured client set at runtime without restarting the
/// listeners. Established sessions of untouched clients must stay intact;
/// sessions of removed or changed clients are torn down through the
/// regular channel-close cleanup path.
async fn update_clients(&self, clients: &[PortalClientConfig]) -> anyhow::Result<()> {
let _ = clients;
anyhow::bail!("portal host does not support runtime client updates")
}
}
#[derive(Debug, Clone)]
@@ -154,11 +163,11 @@ pub struct PortalModule {
operation: Mutex<()>,
peer_manager: Arc<PeerManagerCore>,
runtime_config: CoreRuntimeConfigStore,
config: Option<PortalRuntimeConfig>,
config: Option<Arc<StdRwLock<PortalRuntimeConfig>>>,
host: Option<Arc<dyn PortalHost>>,
events: Arc<dyn CoreEventSink>,
statuses: Arc<RwLock<BTreeMap<String, ClientStatus>>>,
session_locks: Arc<BTreeMap<String, Arc<Mutex<()>>>>,
session_locks: Arc<RwLock<BTreeMap<String, Arc<Mutex<()>>>>>,
runtime: Mutex<Option<PortalRuntime>>,
}
@@ -171,37 +180,17 @@ impl PortalModule {
events: Arc<dyn CoreEventSink>,
) -> anyhow::Result<Arc<Self>> {
if let Some(config) = config.as_ref() {
validate_config(config, runtime_config.snapshot().as_ref())?;
validate_clients(config, runtime_config.snapshot().as_ref())?;
}
let statuses = config
.as_ref()
.map(|config| {
config
.clients
.iter()
.map(|client| (client.name.clone(), ClientStatus::default()))
.collect()
})
.unwrap_or_default();
let session_locks = config
.as_ref()
.map(|config| {
config
.clients
.iter()
.map(|client| (client.name.clone(), Arc::new(Mutex::new(()))))
.collect()
})
.unwrap_or_default();
Ok(Arc::new(Self {
operation: Mutex::new(()),
peer_manager,
runtime_config,
config,
config: config.map(|config| Arc::new(StdRwLock::new(config))),
host,
events,
statuses: Arc::new(RwLock::new(statuses)),
session_locks: Arc::new(session_locks),
statuses: Arc::new(RwLock::new(BTreeMap::new())),
session_locks: Arc::new(RwLock::new(BTreeMap::new())),
runtime: Mutex::new(None),
}))
}
@@ -213,7 +202,77 @@ impl PortalModule {
let Some(config) = self.config.as_ref() else {
return Ok(());
};
validate_runtime_compatibility(config, runtime_config)
let config = config.read().unwrap();
validate_runtime_compatibility(&config, runtime_config)
}
/// Replaces the configured client set at runtime. Untouched clients keep
/// their established sessions; removed and changed clients are torn down
/// through the regular cleanup path and may re-handshake afterwards.
///
/// The caller supplies the runtime snapshot the new set must be validated
/// against, so a combined configuration patch is judged by its final
/// state rather than the currently running one.
pub async fn update_clients(
&self,
clients: Vec<PortalClientConfig>,
runtime: &CoreInstanceRuntimeConfig,
) -> anyhow::Result<Vec<PortalClientConfig>> {
let _operation = self.operation.lock().await;
let Some(config) = self.config.as_ref() else {
anyhow::bail!("VPN portal is not configured");
};
if self.host.is_none() {
anyhow::bail!("VPN portal has no host adapter");
}
let candidate = PortalRuntimeConfig { clients };
validate_clients(&candidate, runtime)?;
self.host
.as_ref()
.expect("checked above")
.update_clients(&candidate.clients)
.await?;
let applied: BTreeSet<String> = candidate
.clients
.iter()
.map(|client| client.name.clone())
.collect();
let removed: Vec<String> = {
let mut current = config.write().unwrap();
let removed: Vec<String> = current
.clients
.iter()
.map(|client| client.name.clone())
.filter(|name| !applied.contains(name))
.collect();
current.clients = candidate.clients.clone();
removed
};
let applied_clients = candidate.clients;
{
let mut statuses = self.statuses.write().await;
statuses.retain(|name, _| applied.contains(name));
for name in applied {
statuses.entry(name).or_default();
}
}
{
let mut locks = self.session_locks.write().await;
for name in removed {
// Entries still held by a live session are left alone; the
// session drops its reference during its regular cleanup.
if locks
.get(&name)
.is_none_or(|lock| Arc::strong_count(lock) == 1)
{
locks.remove(&name);
}
}
}
Ok(applied_clients)
}
pub async fn start(&self) -> anyhow::Result<()> {
@@ -288,9 +347,9 @@ impl PortalModule {
listener_url: url::Url,
peer_manager: Arc<PeerManagerCore>,
runtime_config: CoreRuntimeConfigStore,
config: PortalRuntimeConfig,
config: Arc<StdRwLock<PortalRuntimeConfig>>,
statuses: Arc<RwLock<BTreeMap<String, ClientStatus>>>,
session_locks: Arc<BTreeMap<String, Arc<Mutex<()>>>>,
session_locks: Arc<RwLock<BTreeMap<String, Arc<Mutex<()>>>>>,
events: Arc<dyn CoreEventSink>,
cancel: CancellationToken,
start_signal: CancellationToken,
@@ -338,13 +397,15 @@ impl PortalModule {
listener_url: url::Url,
peer_manager: Arc<PeerManagerCore>,
runtime_config: CoreRuntimeConfigStore,
config: PortalRuntimeConfig,
config: Arc<StdRwLock<PortalRuntimeConfig>>,
statuses: Arc<RwLock<BTreeMap<String, ClientStatus>>>,
session_locks: Arc<BTreeMap<String, Arc<Mutex<()>>>>,
session_locks: Arc<RwLock<BTreeMap<String, Arc<Mutex<()>>>>>,
events: Arc<dyn CoreEventSink>,
cancel: CancellationToken,
) {
let Some(client) = config
.read()
.unwrap()
.clients
.iter()
.find(|client| client.name == session.client_name)
@@ -353,18 +414,17 @@ impl PortalModule {
tracing::warn!(client = %session.client_name, "unknown VPN portal client session");
return;
};
let session_lock = session_locks
.get(&client.name)
.expect("validated client session lock exists");
let session_lock = {
let mut locks = session_locks.write().await;
locks.entry(client.name.clone()).or_default().clone()
};
let _session_guard = tokio::select! {
_ = cancel.cancelled() => return,
guard = session_lock.lock() => guard,
};
let generation = {
let mut statuses = statuses.write().await;
let status = statuses
.get_mut(&client.name)
.expect("validated client status exists");
let status = statuses.entry(client.name.clone()).or_default();
status.generation = status.generation.wrapping_add(1);
status.state = PortalClientState::Connecting;
status.endpoint = Some(session.endpoint.borrow_and_update().clone());
@@ -400,9 +460,11 @@ impl PortalModule {
{
let mut statuses = statuses.write().await;
let status = statuses
.get_mut(&client.name)
.expect("validated client status exists");
let Some(status) = statuses.get_mut(&client.name) else {
drop(statuses);
attached.close().await;
return;
};
if status.generation != generation {
drop(statuses);
attached.close().await;
@@ -617,7 +679,11 @@ impl PortalModule {
}
pub async fn info_snapshot(&self) -> PortalInfoSnapshot {
let Some(config) = self.config.as_ref() else {
let Some(config) = self
.config
.as_ref()
.map(|config| config.read().unwrap().clone())
else {
return PortalInfoSnapshot {
vpn_type: "null".to_owned(),
clients: Vec::new(),
@@ -692,13 +758,14 @@ impl PortalModule {
}
}
fn validate_config(
/// Validates a client set. The empty set is legal in every lifecycle stage:
/// a portal with zero clients keeps listening and accepts nothing, so
/// clearing all clients never produces a configuration that fails a later
/// instance recreation.
fn validate_clients(
config: &PortalRuntimeConfig,
runtime_config: &CoreInstanceRuntimeConfig,
) -> anyhow::Result<()> {
if config.clients.is_empty() {
anyhow::bail!("VPN portal requires at least one configured client");
}
if config.clients.len() > MAX_VPN_PORTAL_CLIENTS {
anyhow::bail!("VPN portal supports at most {MAX_VPN_PORTAL_CLIENTS} clients");
}
@@ -1102,7 +1169,7 @@ mod tests {
client("alice", Ipv4Addr::new(10, 82, 0, 3), &["ops"]),
],
};
let error = validate_config(&duplicate_name, snapshot.as_ref())
let error = validate_clients(&duplicate_name, snapshot.as_ref())
.unwrap_err()
.to_string();
assert!(error.contains("duplicate VPN portal client name"));
@@ -1110,7 +1177,7 @@ mod tests {
let duplicate_ip = PortalRuntimeConfig {
clients: vec![alice, client("bob", Ipv4Addr::new(10, 82, 0, 2), &["ops"])],
};
let error = validate_config(&duplicate_ip, snapshot.as_ref())
let error = validate_clients(&duplicate_ip, snapshot.as_ref())
.unwrap_err()
.to_string();
assert!(error.contains("duplicate VPN portal virtual IP"));
@@ -1122,7 +1189,7 @@ mod tests {
let config = PortalRuntimeConfig {
clients: vec![client("alice", Ipv4Addr::new(10, 82, 0, 2), &["unknown"])],
};
let error = validate_config(&config, runtime_config.snapshot().as_ref())
let error = validate_clients(&config, runtime_config.snapshot().as_ref())
.unwrap_err()
.to_string();
assert!(error.contains("unknown ACL group"));
@@ -1138,7 +1205,7 @@ mod tests {
clients: vec![client("alice", Ipv4Addr::new(10, 82, 0, 2), &["ops"])],
};
let error = validate_config(&config, runtime_config.snapshot().as_ref())
let error = validate_clients(&config, runtime_config.snapshot().as_ref())
.unwrap_err()
.to_string();
@@ -1175,10 +1242,10 @@ mod tests {
"alice".to_owned(),
ClientStatus::default(),
)])));
let session_locks = Arc::new(BTreeMap::from([(
let session_locks = Arc::new(RwLock::new(BTreeMap::from([(
"alice".to_owned(),
Arc::new(Mutex::new(())),
)]));
)])));
let (to_runtime, from_client) = mpsc::channel(1);
let (to_client, _from_runtime) = mpsc::channel(1);
let (endpoint_sender, endpoint) = tokio::sync::watch::channel("portal://alice".to_owned());
@@ -1195,7 +1262,7 @@ mod tests {
"portal://listener".parse().unwrap(),
peer_manager.clone(),
runtime_config,
config,
Arc::new(StdRwLock::new(config)),
statuses.clone(),
session_locks,
Arc::new(()),
@@ -1263,10 +1330,10 @@ mod tests {
"alice".to_owned(),
ClientStatus::default(),
)])));
let session_locks = Arc::new(BTreeMap::from([(
let session_locks = Arc::new(RwLock::new(BTreeMap::from([(
"alice".to_owned(),
Arc::new(Mutex::new(())),
)]));
)])));
let (_to_runtime, from_client) = mpsc::channel(1);
let (to_client, _from_runtime) = mpsc::channel(1);
let (_endpoint_sender, endpoint) = tokio::sync::watch::channel("portal://alice".to_owned());
@@ -1287,7 +1354,7 @@ mod tests {
"portal://listener".parse().unwrap(),
peer_manager.clone(),
runtime_config,
config,
Arc::new(StdRwLock::new(config)),
statuses.clone(),
session_locks,
events.clone(),
@@ -1373,10 +1440,10 @@ mod tests {
"alice".to_owned(),
ClientStatus::default(),
)])));
let session_locks = Arc::new(BTreeMap::from([(
let session_locks = Arc::new(RwLock::new(BTreeMap::from([(
"alice".to_owned(),
Arc::new(Mutex::new(())),
)]));
)])));
let (to_runtime, from_client) = mpsc::channel(1);
let (to_client, from_runtime) = mpsc::channel(1);
let (_endpoint_sender, endpoint) = tokio::sync::watch::channel("portal://alice".to_owned());
@@ -1393,7 +1460,7 @@ mod tests {
"portal://listener".parse().unwrap(),
peer_manager.clone(),
runtime_config,
config,
Arc::new(StdRwLock::new(config)),
statuses.clone(),
session_locks,
events.clone(),
@@ -1652,4 +1719,144 @@ mod tests {
assert!(validate_client_name("laptop_1").is_err());
assert!(validate_client_name("").is_err());
}
#[derive(Default)]
struct RecordingPortalHost {
updates: StdMutex<Vec<Vec<PortalClientConfig>>>,
}
impl RecordingPortalHost {
fn recorded(&self) -> Vec<Vec<PortalClientConfig>> {
self.updates.lock().unwrap().clone()
}
}
#[async_trait]
impl PortalHost for RecordingPortalHost {
async fn start_listeners(&self) -> anyhow::Result<Vec<PortalListener>> {
anyhow::bail!("recording host never starts listeners")
}
fn name(&self) -> String {
"recording".to_owned()
}
fn render_client_config(&self, plan: &PortalClientConfigPlan) -> String {
format!("config:{}", plan.name)
}
async fn update_clients(&self, clients: &[PortalClientConfig]) -> anyhow::Result<()> {
self.updates.lock().unwrap().push(clients.to_vec());
Ok(())
}
}
fn portal_module_with_recording_host(
initial: PortalRuntimeConfig,
) -> (
Arc<PortalModule>,
Arc<RecordingPortalHost>,
CoreRuntimeConfigStore,
) {
let (peer_manager, runtime_config) = network_runtime();
let host = Arc::new(RecordingPortalHost::default());
let module = PortalModule::new(
peer_manager,
runtime_config.clone(),
Some(initial),
Some(host.clone()),
Arc::new(()),
)
.unwrap();
(module, host, runtime_config)
}
#[tokio::test]
async fn portal_module_update_clients_rejects_invalid_sets() {
let (module, _host, runtime_config) =
portal_module_with_recording_host(PortalRuntimeConfig {
clients: vec![client("alice", Ipv4Addr::new(10, 82, 0, 2), &["ops"])],
});
let runtime = runtime_config.snapshot();
let duplicate = module
.update_clients(
vec![
client("bob", Ipv4Addr::new(10, 82, 0, 3), &["ops"]),
client("bob", Ipv4Addr::new(10, 82, 0, 4), &["ops"]),
],
runtime.as_ref(),
)
.await
.unwrap_err();
assert!(
duplicate
.to_string()
.contains("duplicate VPN portal client name")
);
let unknown_group = module
.update_clients(
vec![client("bob", Ipv4Addr::new(10, 82, 0, 3), &["missing"])],
runtime.as_ref(),
)
.await
.unwrap_err();
assert!(unknown_group.to_string().contains("unknown ACL group"));
// Runtime updates may drain the portal to zero clients.
let applied = module
.update_clients(Vec::new(), runtime.as_ref())
.await
.unwrap();
assert!(applied.is_empty());
}
#[tokio::test]
async fn portal_module_update_clients_replaces_shared_state_and_notifies_host() {
let (module, host, runtime_config) =
portal_module_with_recording_host(PortalRuntimeConfig {
clients: vec![client("alice", Ipv4Addr::new(10, 82, 0, 2), &["ops"])],
});
let applied = module
.update_clients(
vec![client("bob", Ipv4Addr::new(10, 82, 0, 3), &["ops"])],
runtime_config.snapshot().as_ref(),
)
.await
.unwrap();
assert_eq!(applied.len(), 1);
assert_eq!(applied[0].name, "bob");
let recorded = host.recorded();
assert_eq!(recorded.len(), 1);
assert_eq!(recorded[0].len(), 1);
assert_eq!(recorded[0][0].name, "bob");
let snapshot = module.info_snapshot().await;
assert_eq!(snapshot.clients.len(), 1);
assert_eq!(snapshot.clients[0].name, "bob");
}
#[tokio::test]
async fn portal_module_update_clients_requires_configured_portal() {
let (peer_manager, runtime_config) = network_runtime();
let module = PortalModule::new(
peer_manager,
runtime_config.clone(),
None,
None,
Arc::new(()),
)
.unwrap();
let error = module
.update_clients(
vec![client("alice", Ipv4Addr::new(10, 82, 0, 2), &["ops"])],
runtime_config.snapshot().as_ref(),
)
.await
.unwrap_err();
assert!(error.to_string().contains("not configured"));
}
}
@@ -1,5 +1,6 @@
use crate::{
gateway::vpn_portal::PortalInfoSnapshot,
config::runtime::CoreInstanceRuntimeConfig,
gateway::vpn_portal::{PortalClientConfig, PortalInfoSnapshot},
instance::{CoreInstance, CoreInstanceHost},
};
@@ -10,4 +11,19 @@ where
pub async fn vpn_portal_info(&self) -> PortalInfoSnapshot {
self.vpn_portal.info_snapshot().await
}
/// Replaces the VPN portal client set at runtime without restarting the
/// instance. Untouched clients keep their established sessions.
///
/// The caller supplies the runtime snapshot the new set is validated
/// against, so a combined configuration patch is judged by its final
/// state rather than the currently running one.
#[cfg(feature = "vpn-portal")]
pub(crate) async fn update_vpn_portal_clients(
&self,
clients: Vec<PortalClientConfig>,
runtime: &CoreInstanceRuntimeConfig,
) -> anyhow::Result<Vec<PortalClientConfig>> {
self.vpn_portal.update_clients(clients, runtime).await
}
}
@@ -3,7 +3,7 @@ use std::{fmt::Debug, sync::Arc};
use anyhow::Context as _;
use easytier_proto::api::config::{
self, AclPatch, ConfigPatchAction, ExitNodePatch, InstanceConfigPatch, Patchable,
PortForwardPatch, ProxyNetworkPatch, RoutePatch, UrlPatch,
PortForwardPatch, ProxyNetworkPatch, RoutePatch, UrlPatch, VpnPortalClientPatch,
};
use crate::{
@@ -95,6 +95,35 @@ where
candidate.set_ipv6_public_addr_prefix(prefix);
provider_config_changed = true;
}
// Runs last so client validation sees the fully patched candidate,
// including routes and the node IPv4 set earlier in this request.
if !patch.vpn_portal_clients.is_empty() {
apply_vpn_portal_client_patches(&candidate, patch.vpn_portal_clients)?;
// Deep-validate and hot-apply before committing, so a rejected
// client set leaves neither the shared TOML model nor the live
// portal changed.
let normalized = validate_candidate(instance, &candidate)?;
#[cfg(feature = "vpn-portal")]
{
let portal = normalized
.vpn_portal
.clone()
.ok_or_else(|| anyhow::anyhow!("VPN portal is not configured"))?;
instance
.update_vpn_portal_clients(
portal.clients,
&runtime_config_from_normalized(&normalized),
)
.await?;
}
#[cfg(not(feature = "vpn-portal"))]
{
let _ = normalized;
}
validate_and_commit_candidate(instance, &config, &candidate)?;
}
let normalized = validate_and_commit_candidate(instance, &config, &candidate)?;
let runtime = runtime_config_from_normalized(&normalized);
instance
@@ -120,9 +149,8 @@ where
Ok(())
}
fn validate_and_commit_candidate<H>(
fn validate_candidate<H>(
instance: &CoreInstance<H>,
shared: &TomlConfig,
candidate: &TomlConfig,
) -> anyhow::Result<CoreInstanceConfig>
where
@@ -132,6 +160,18 @@ where
let runtime = runtime_config_from_normalized(&normalized);
runtime.services.public_ipv6_provider.validate()?;
instance.validate_runtime_config_capabilities(&runtime)?;
Ok(normalized)
}
fn validate_and_commit_candidate<H>(
instance: &CoreInstance<H>,
shared: &TomlConfig,
candidate: &TomlConfig,
) -> anyhow::Result<CoreInstanceConfig>
where
H: CoreInstanceHost,
{
let normalized = validate_candidate(instance, candidate)?;
shared.replace_from_snapshot(candidate);
Ok(normalized)
}
@@ -312,6 +352,67 @@ fn patch_mapped_listeners(config: &TomlConfig, patches: Vec<UrlPatch>) -> anyhow
Ok(())
}
/// Applies VPN portal client patches to the candidate TOML model. The live
/// portal is updated by the caller after the candidate commits, so deep
/// validation runs against the final configuration state.
fn apply_vpn_portal_client_patches(
config: &TomlConfig,
patches: Vec<VpnPortalClientPatch>,
) -> anyhow::Result<()> {
if patches.is_empty() {
return Ok(());
}
let mut portal = config
.get_vpn_portal_config()
.ok_or_else(|| anyhow::anyhow!("VPN portal is not configured; cannot patch its clients"))?;
for patch in patches {
match ConfigPatchAction::try_from(patch.action) {
Ok(ConfigPatchAction::Add) => {
let Some(client) = patch.client else {
tracing::warn!("ignored VPN portal client add without client");
continue;
};
let virtual_ip = client
.virtual_ip
.parse::<std::net::Ipv4Addr>()
.with_context(|| {
format!(
"invalid VPN portal client virtual IP: {}",
client.virtual_ip
)
})?;
portal
.clients
.push(crate::config::toml::VpnPortalClientConfig {
name: client.name,
virtual_ip,
groups: client.groups,
});
}
Ok(ConfigPatchAction::Remove) => {
let Some(client) = patch.client else {
tracing::warn!("ignored VPN portal client remove without client");
continue;
};
let before = portal.clients.len();
portal
.clients
.retain(|existing| existing.name != client.name);
if portal.clients.len() == before {
anyhow::bail!("VPN portal client not found: {}", client.name);
}
}
Ok(ConfigPatchAction::Clear) => portal.clients.clear(),
Err(_) => tracing::warn!(
action = patch.action,
"ignored invalid VPN portal client action"
),
}
}
config.set_vpn_portal_config(portal);
Ok(())
}
fn patch_connectors<H>(instance: &CoreInstance<H>, patches: Vec<UrlPatch>) -> anyhow::Result<()>
where
H: CoreInstanceHost,
@@ -346,3 +447,97 @@ where
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::toml::{VpnPortalClientConfig, VpnPortalConfig};
use easytier_proto::api::manage::VpnPortalClientConfig as ClientPb;
fn portal_config() -> TomlConfig {
let config = TomlConfig::default();
config.set_vpn_portal_config(VpnPortalConfig {
wireguard_listen: "0.0.0.0:51820".parse().unwrap(),
wireguard_private_key: None,
clients: vec![VpnPortalClientConfig {
name: "alice".to_owned(),
virtual_ip: "10.0.0.2".parse().unwrap(),
groups: Vec::new(),
}],
});
config
}
fn configured_names(config: &TomlConfig) -> Vec<String> {
config
.get_vpn_portal_config()
.unwrap()
.clients
.into_iter()
.map(|client| client.name)
.collect()
}
fn add(name: &str, ip: &str) -> VpnPortalClientPatch {
VpnPortalClientPatch {
action: ConfigPatchAction::Add as i32,
client: Some(ClientPb {
name: name.to_owned(),
virtual_ip: ip.to_owned(),
groups: Vec::new(),
}),
}
}
fn remove(name: &str) -> VpnPortalClientPatch {
VpnPortalClientPatch {
action: ConfigPatchAction::Remove as i32,
client: Some(ClientPb {
name: name.to_owned(),
virtual_ip: String::new(),
groups: Vec::new(),
}),
}
}
#[test]
fn vpn_portal_client_patches_add_remove_and_clear() {
let config = portal_config();
apply_vpn_portal_client_patches(&config, vec![add("bob", "10.0.0.3")]).unwrap();
assert_eq!(configured_names(&config), ["alice", "bob"]);
apply_vpn_portal_client_patches(&config, vec![remove("alice")]).unwrap();
assert_eq!(configured_names(&config), ["bob"]);
apply_vpn_portal_client_patches(
&config,
vec![VpnPortalClientPatch {
action: ConfigPatchAction::Clear as i32,
client: None,
}],
)
.unwrap();
assert!(configured_names(&config).is_empty());
}
#[test]
fn vpn_portal_client_patches_reject_missing_prerequisites() {
let bare = TomlConfig::default();
let error =
apply_vpn_portal_client_patches(&bare, vec![add("alice", "10.0.0.2")]).unwrap_err();
assert!(error.to_string().contains("not configured"));
let config = portal_config();
let error = apply_vpn_portal_client_patches(&config, vec![remove("ghost")]).unwrap_err();
assert!(error.to_string().contains("not found"));
let error =
apply_vpn_portal_client_patches(&config, vec![add("bob", "not-an-ip")]).unwrap_err();
assert!(
error
.to_string()
.contains("invalid VPN portal client virtual IP")
);
}
}