mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-09-02 01:03:54 +00:00
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:
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user