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
+129 -6
View File
@@ -37,6 +37,7 @@ use easytier::{
config::{
AclPatch, ConfigPatchAction, ConfigRpc, ConfigRpcClientFactory,
InstanceConfigPatch, PatchConfigRequest, PortForwardPatch, StringPatch, UrlPatch,
VpnPortalClientPatch,
},
instance::{
AclManageRpc, AclManageRpcClientFactory, Connector, ConnectorManageRpc,
@@ -64,7 +65,8 @@ use easytier::{
SetLoggerConfigRequest,
},
manage::{
ListNetworkInstanceMetaRequest, ListNetworkInstanceRequest, WebClientService,
ListNetworkInstanceMetaRequest, ListNetworkInstanceRequest,
VpnPortalClientConfig as ManageVpnPortalClientConfig, WebClientService,
WebClientServiceClientFactory,
},
},
@@ -130,8 +132,8 @@ enum SubCommand {
Route(RouteArgs),
#[command(about = "show global peers info")]
PeerCenter,
#[command(about = "show vpn portal (wireguard) info")]
VpnPortal,
#[command(about = "manage vpn portal (wireguard) clients")]
VpnPortal(VpnPortalArgs),
#[command(about = "inspect self easytier-core status")]
Node(NodeArgs),
#[command(about = "manage easytier-core as a system service")]
@@ -265,6 +267,32 @@ enum MappedListenerSubCommand {
List,
}
#[derive(Args, Debug)]
struct VpnPortalArgs {
#[command(subcommand)]
sub_command: Option<VpnPortalSubCommand>,
}
#[derive(Subcommand, Debug)]
enum VpnPortalSubCommand {
/// Add a WireGuard portal client
AddClient {
#[arg(help = "client name")]
name: String,
#[arg(long, help = "client virtual IPv4 address inside the mesh network")]
virtual_ip: String,
#[arg(long, help = "ACL groups assigned to the client")]
groups: Vec<String>,
},
/// Remove a WireGuard portal client
RemoveClient {
#[arg(help = "client name")]
name: String,
},
/// Remove all WireGuard portal clients
ClearClients,
}
#[derive(Subcommand, Debug)]
enum NodeSubCommand {
#[command(about = "show node info")]
@@ -2502,6 +2530,86 @@ impl<'a> CommandHandler<'a> {
})
}
async fn apply_vpn_portal_client_patch(
&self,
patch: VpnPortalClientPatch,
) -> Result<(), Error> {
let client = self.get_config_client().await?;
let request = PatchConfigRequest {
instance: Some(self.instance_selector.clone()),
patch: Some(InstanceConfigPatch {
vpn_portal_clients: vec![patch],
..Default::default()
}),
};
let _response = client
.patch_config(BaseController::default(), request)
.await?;
Ok(())
}
async fn handle_vpn_portal_add_client(
&self,
name: String,
virtual_ip: String,
groups: Vec<String>,
) -> Result<(), Error> {
virtual_ip
.parse::<std::net::Ipv4Addr>()
.map_err(|e| anyhow::anyhow!("invalid virtual ip ({virtual_ip}): {e}"))?;
self.apply_to_instances(|handler| {
let name = name.clone();
let virtual_ip = virtual_ip.clone();
let groups = groups.clone();
Box::pin(async move {
handler
.apply_vpn_portal_client_patch(VpnPortalClientPatch {
action: ConfigPatchAction::Add as i32,
client: Some(ManageVpnPortalClientConfig {
name,
virtual_ip,
groups,
}),
})
.await
})
})
.await
}
async fn handle_vpn_portal_remove_client(&self, name: String) -> Result<(), Error> {
self.apply_to_instances(|handler| {
let name = name.clone();
Box::pin(async move {
handler
.apply_vpn_portal_client_patch(VpnPortalClientPatch {
action: ConfigPatchAction::Remove as i32,
client: Some(ManageVpnPortalClientConfig {
name,
virtual_ip: String::new(),
groups: Vec::new(),
}),
})
.await
})
})
.await
}
async fn handle_vpn_portal_clear_clients(&self) -> Result<(), Error> {
self.apply_to_instances(|handler| {
Box::pin(async move {
handler
.apply_vpn_portal_client_patch(VpnPortalClientPatch {
action: ConfigPatchAction::Clear as i32,
client: None,
})
.await
})
})
.await
}
async fn handle_vpn_portal(&self) -> Result<(), Error> {
let results = self
.collect_instance_results(|handler| Box::pin(handler.fetch_vpn_portal_info()))
@@ -3053,9 +3161,24 @@ async fn main() -> Result<(), Error> {
SubCommand::PeerCenter => {
handler.handle_peer_center().await?;
}
SubCommand::VpnPortal => {
handler.handle_vpn_portal().await?;
}
SubCommand::VpnPortal(args) => match args.sub_command {
None => handler.handle_vpn_portal().await?,
Some(VpnPortalSubCommand::AddClient {
name,
virtual_ip,
groups,
}) => {
handler
.handle_vpn_portal_add_client(name, virtual_ip, groups)
.await?;
}
Some(VpnPortalSubCommand::RemoveClient { name }) => {
handler.handle_vpn_portal_remove_client(name).await?;
}
Some(VpnPortalSubCommand::ClearClients) => {
handler.handle_vpn_portal_clear_clients().await?;
}
},
SubCommand::Node(sub_cmd) => {
handler.handle_node(sub_cmd.sub_command.as_ref()).await?;
}
+198
View File
@@ -108,6 +108,11 @@ use crate::{
#[cfg(feature = "wireguard")]
use easytier_core::gateway::vpn_portal::PortalClientState;
#[cfg(feature = "wireguard")]
use easytier_proto::api::{
config::{ConfigPatchAction, InstanceConfigPatch, VpnPortalClientPatch},
manage::VpnPortalClientConfig as VpnPortalClientConfigPb,
};
pub fn prepare_linux_namespaces() {
del_netns("net_a");
@@ -2133,6 +2138,199 @@ pub async fn wireguard_vpn_portal_client_roaming() {
drop_insts(insts).await;
}
#[cfg(feature = "wireguard")]
#[tokio::test]
#[serial_test::serial]
pub async fn wireguard_vpn_portal_dynamic_clients() {
let insts = init_three_node_ex(
"tcp",
|config| {
let identity = config.get_network_identity();
config.set_network_identity(NetworkIdentity::new(
identity.network_name,
"wireguard-portal-dynamic-clients-test".to_owned(),
));
if config.get_inst_name() == "inst3" {
config.set_vpn_portal_config(VpnPortalConfig {
wireguard_listen: "0.0.0.0:22121".parse().unwrap(),
wireguard_private_key: Some(BASE64_STANDARD.encode([42u8; 32])),
clients: vec![VpnPortalClientConfig {
name: "client-a".to_owned(),
virtual_ip: "10.144.144.4".parse().unwrap(),
groups: Vec::new(),
}],
});
}
config
},
false,
)
.await;
let core = insts[2].get_core_instance();
let portal_config = insts[2]
.get_global_ctx()
.config
.get_vpn_portal_config()
.unwrap();
// 初始客户端上线
{
let net_ns = NetNS::new(Some("net_d".into()));
let _g = net_ns.guard();
let (server_public, client_private) =
test_wireguard_keys(&portal_config, "client-a").unwrap();
run_wireguard_client(
&wireguard_ifname("wg0"),
"10.1.2.3:22121".parse().unwrap(),
Key::try_from(server_public.as_slice()).unwrap(),
Key::try_from(client_private.as_slice()).unwrap(),
vec!["10.144.144.0/24".to_string()],
"192.0.2.42".to_string(),
)
.unwrap();
}
wait_for_condition(
|| async { ping_test("net_d", "10.144.144.1", None).await },
Duration::from_secs(10),
)
.await;
// 不重启实例,通过配置补丁动态添加第二个客户端
easytier_core::management::apply_config_patch(
&core,
InstanceConfigPatch {
vpn_portal_clients: vec![VpnPortalClientPatch {
action: ConfigPatchAction::Add as i32,
client: Some(VpnPortalClientConfigPb {
name: "client-b".to_owned(),
virtual_ip: "10.144.144.5".to_owned(),
groups: Vec::new(),
}),
}],
..Default::default()
},
)
.await
.unwrap();
assert_eq!(
insts[2]
.get_global_ctx()
.config
.get_vpn_portal_config()
.unwrap()
.clients
.len(),
2,
"shared TOML model must reflect the runtime update"
);
// 拒绝的补丁不能污染共享 TOML 模型:重复添加 client-b 必须整体失败
let error = easytier_core::management::apply_config_patch(
&core,
InstanceConfigPatch {
vpn_portal_clients: vec![VpnPortalClientPatch {
action: ConfigPatchAction::Add as i32,
client: Some(VpnPortalClientConfigPb {
name: "client-b".to_owned(),
virtual_ip: "10.144.144.9".to_owned(),
groups: Vec::new(),
}),
}],
..Default::default()
},
)
.await
.unwrap_err();
assert!(
error
.to_string()
.contains("duplicate VPN portal client name"),
"unexpected rejection reason: {error:#}"
);
assert_eq!(
insts[2]
.get_global_ctx()
.config
.get_vpn_portal_config()
.unwrap()
.clients
.len(),
2,
"rejected patch must leave the shared TOML model unchanged"
);
// 新客户端立即可以握手上线,原客户端不受影响
{
let net_ns = NetNS::new(Some("net_f".into()));
let _g = net_ns.guard();
let (server_public, client_private) =
test_wireguard_keys(&portal_config, "client-b").unwrap();
run_wireguard_client(
&wireguard_ifname("wg0"),
"10.1.2.3:22121".parse().unwrap(),
Key::try_from(server_public.as_slice()).unwrap(),
Key::try_from(client_private.as_slice()).unwrap(),
vec!["10.144.144.0/24".to_string()],
"192.0.2.43".to_string(),
)
.unwrap();
}
wait_for_condition(
|| async { ping_test("net_f", "10.144.144.1", None).await },
Duration::from_secs(10),
)
.await;
wait_for_condition(
|| async { ping_test("net_d", "10.144.144.1", None).await },
Duration::from_secs(10),
)
.await;
// 动态移除 client-a:其会话被拆除,client-b 保持在线
easytier_core::management::apply_config_patch(
&core,
InstanceConfigPatch {
vpn_portal_clients: vec![VpnPortalClientPatch {
action: ConfigPatchAction::Remove as i32,
client: Some(VpnPortalClientConfigPb {
name: "client-a".to_owned(),
virtual_ip: String::new(),
groups: Vec::new(),
}),
}],
..Default::default()
},
)
.await
.unwrap();
wait_for_condition(
|| async { !ping_test("net_d", "10.144.144.1", None).await },
Duration::from_secs(20),
)
.await;
wait_for_condition(
|| async { ping_test("net_f", "10.144.144.1", None).await },
Duration::from_secs(10),
)
.await;
let info = core.vpn_portal_info().await;
assert_eq!(info.clients.len(), 1);
assert_eq!(info.clients[0].name, "client-b");
assert_eq!(info.clients[0].state, PortalClientState::Online);
assert_eq!(
info.clients[0].tunnel_ip,
Some("192.0.2.43".parse().unwrap())
);
// Release the held CoreInstance Arc so drop_insts can observe a clean
// drop instead of swallowing its debug assertion.
drop(core);
drop_insts(insts).await;
}
#[cfg(feature = "wireguard")]
#[rstest::rstest]
#[tokio::test]
+98 -30
View File
@@ -8,9 +8,10 @@
mod engine;
use std::{
collections::{BTreeMap, BTreeSet},
fmt,
net::{Ipv6Addr, SocketAddr, SocketAddrV6},
sync::Arc,
sync::{Arc, Mutex as StdMutex, RwLock, Weak},
};
use anyhow::Context as _;
@@ -18,7 +19,9 @@ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use boringtun::x25519::{PublicKey, StaticSecret};
use easytier_core::{
config::toml::VpnPortalConfig,
gateway::vpn_portal::{PortalClientConfigPlan, PortalHost, PortalListener, PortalSession},
gateway::vpn_portal::{
PortalClientConfig, PortalClientConfigPlan, PortalHost, PortalListener, PortalSession,
},
socket::{
ListenerConnectionCounter, NetNamespace, SocketContext, SocketListener,
udp::{UdpBindOptions, VirtualUdpSocket, VirtualUdpSocketFactory},
@@ -147,36 +150,34 @@ pub struct WireGuardPortalHost {
global_ctx: ArcGlobalCtx,
config: VpnPortalConfig,
setup: Result<WireGuardPortalSetup, String>,
engine: StdMutex<Option<Weak<PortalEngine>>>,
}
struct WireGuardPortalSetup {
server_private: [u8; 32],
server_public: PublicKey,
clients: Vec<DerivedClient>,
clients: RwLock<BTreeMap<String, DerivedClient>>,
}
impl WireGuardPortalHost {
pub fn new(global_ctx: ArcGlobalCtx, config: VpnPortalConfig) -> Arc<Self> {
let setup = (|| -> anyhow::Result<_> {
let (master, server_private) = portal_master_and_server_key(&config)?;
// The derivation master equals the server private key.
let (_, server_private) = portal_master_and_server_key(&config)?;
let server_public = PublicKey::from(&StaticSecret::from(server_private));
let mut clients = Vec::with_capacity(config.clients.len());
let mut clients = BTreeMap::new();
for client in &config.clients {
let wireguard_private =
derive_named_key(&master, b"wireguard-client", &client.name)?;
let identity_private_key =
derive_named_key(&master, b"attached-noise", &client.name)?;
clients.push(DerivedClient {
config: client.clone(),
wireguard_private,
wireguard_public: PublicKey::from(&StaticSecret::from(wireguard_private)),
identity_private_key,
});
let client = PortalClientConfig {
name: client.name.clone(),
virtual_ip: client.virtual_ip,
groups: client.groups.clone(),
};
clients.insert(client.name.clone(), derive_client(server_private, &client)?);
}
Ok(WireGuardPortalSetup {
server_private,
server_public,
clients,
clients: RwLock::new(clients),
})
})()
.map_err(|error| error.to_string());
@@ -184,6 +185,7 @@ impl WireGuardPortalHost {
global_ctx,
config,
setup,
engine: StdMutex::new(None),
})
}
@@ -204,6 +206,69 @@ impl WireGuardPortalHost {
)
.await
}
async fn apply_client_updates(&self, clients: &[PortalClientConfig]) -> anyhow::Result<()> {
let setup = self
.setup
.as_ref()
.map_err(|error| anyhow::anyhow!(error.clone()))?;
let engine = self
.engine
.lock()
.unwrap()
.clone()
.and_then(|engine| engine.upgrade())
.ok_or_else(|| anyhow::anyhow!("WireGuard VPN portal is not running"))?;
let desired: BTreeSet<&str> = clients.iter().map(|client| client.name.as_str()).collect();
let stale: Vec<String> = setup
.clients
.read()
.unwrap()
.keys()
.filter(|name| !desired.contains(name.as_str()))
.cloned()
.collect();
for name in &stale {
engine.remove_client(name).await;
setup.clients.write().unwrap().remove(name);
}
for client in clients {
if setup
.clients
.read()
.unwrap()
.get(&client.name)
.is_some_and(|existing| existing.config == *client)
{
continue;
}
// A changed client keeps its WireGuard identity (keys derive from
// the name), but its attached peer must be rebuilt with the new
// virtual IP or groups. Expiring the session makes the client
// re-handshake into a fresh Core generation.
engine.remove_client(&client.name).await;
let derived = derive_client(setup.server_private, client)?;
engine.add_client(derived.clone())?;
setup
.clients
.write()
.unwrap()
.insert(client.name.clone(), derived);
}
Ok(())
}
}
fn derive_client(master: [u8; 32], client: &PortalClientConfig) -> anyhow::Result<DerivedClient> {
let wireguard_private = derive_named_key(&master, b"wireguard-client", &client.name)?;
let identity_private_key = derive_named_key(&master, b"attached-noise", &client.name)?;
Ok(DerivedClient {
config: client.clone(),
wireguard_private,
wireguard_public: PublicKey::from(&StaticSecret::from(wireguard_private)),
identity_private_key,
})
}
fn secondary_ipv6_bind_address(address: SocketAddr, primary_port: u16) -> Option<SocketAddr> {
let SocketAddr::V4(address) = address else {
@@ -237,7 +302,12 @@ impl PortalHost for WireGuardPortalHost {
}
let url = url::Url::parse(&format!("wg://{local}"))?;
let (accepted, receiver) = mpsc::unbounded_channel();
let engine = PortalEngine::new(setup.server_private, setup.clients.clone(), accepted);
let engine = PortalEngine::new(
setup.server_private,
setup.clients.read().unwrap().values().cloned().collect(),
accepted,
);
*self.engine.lock().unwrap() = Some(Arc::downgrade(&engine));
Ok(vec![Box::new(WireGuardPortalListener {
url,
sockets,
@@ -252,27 +322,25 @@ impl PortalHost for WireGuardPortalHost {
"wireguard".to_owned()
}
async fn update_clients(&self, clients: &[PortalClientConfig]) -> anyhow::Result<()> {
self.apply_client_updates(clients).await
}
fn render_client_config(&self, plan: &PortalClientConfigPlan) -> String {
let client = self
let setup = self
.setup
.as_ref()
.expect("client config is rendered only after successful startup")
.clients
.iter()
.find(|client| client.config.name == plan.name)
.expect("Core only renders configured clients");
.expect("client config is rendered only after successful startup");
let clients = setup.clients.read().unwrap();
let Some(client) = clients.get(&plan.name) else {
return String::new();
};
let endpoint = &plan.listener_url[url::Position::BeforeHost..url::Position::AfterPort];
format!(
"[Interface]\nPrivateKey = {}\nAddress = {}/32\n\n[Peer]\nPublicKey = {}\nAllowedIPs = {}\nEndpoint = {} # replace wildcard with the public address\nPersistentKeepalive = 25\n",
BASE64_STANDARD.encode(client.wireguard_private),
plan.address,
BASE64_STANDARD.encode(
self.setup
.as_ref()
.expect("client config is rendered only after successful startup")
.server_public
.as_bytes()
),
BASE64_STANDARD.encode(setup.server_public.as_bytes()),
plan.allowed_ips.join(", "),
endpoint,
)
+171 -18
View File
@@ -2,9 +2,12 @@
use atomic_shim::AtomicU64;
use std::{
collections::HashMap,
collections::{BTreeSet, HashMap},
net::SocketAddr,
sync::{Arc, atomic::Ordering},
sync::{
Arc, RwLock,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};
@@ -16,7 +19,7 @@ use boringtun::{
x25519::{PublicKey, StaticSecret},
};
use easytier_core::{
config::toml::VpnPortalClientConfig, gateway::vpn_portal::PortalSession,
gateway::vpn_portal::{PortalClientConfig, PortalSession},
socket::udp::VirtualUdpSocket,
};
use tokio::{
@@ -35,7 +38,7 @@ const TIMER_INTERVAL: Duration = Duration::from_millis(250);
const PORTAL_PACKET_CAPACITY: usize = 128;
#[derive(Clone)]
pub(super) struct DerivedClient {
pub(super) config: VpnPortalClientConfig,
pub(super) config: PortalClientConfig,
pub(super) wireguard_private: [u8; 32],
pub(super) wireguard_public: PublicKey,
pub(super) identity_private_key: [u8; 32],
@@ -63,6 +66,30 @@ struct ClientSlot {
index: u32,
next_generation: AtomicU64,
session: Mutex<Option<ClientSession>>,
retired: AtomicBool,
}
#[derive(Default)]
struct EngineSlots {
by_name: HashMap<String, Arc<ClientSlot>>,
by_public_key: HashMap<[u8; 32], Arc<ClientSlot>>,
by_index: HashMap<u32, Arc<ClientSlot>>,
free_indices: BTreeSet<u32>,
highest_index: u32,
}
impl EngineSlots {
fn allocate_index(&mut self) -> anyhow::Result<u32> {
if let Some(index) = self.free_indices.pop_first() {
return Ok(index);
}
let next = self
.highest_index
.checked_add(1)
.ok_or_else(|| anyhow::anyhow!("WireGuard portal client index space is exhausted"))?;
self.highest_index = next;
Ok(next)
}
}
#[derive(Clone)]
@@ -88,8 +115,7 @@ pub(super) struct PortalEngine {
server_private: StaticSecret,
server_public: PublicKey,
rate_limiter: Arc<RateLimiter>,
by_public_key: HashMap<[u8; 32], Arc<ClientSlot>>,
by_index: HashMap<u32, Arc<ClientSlot>>,
slots: RwLock<EngineSlots>,
accepted: mpsc::UnboundedSender<PortalSession>,
cancel: CancellationToken,
}
@@ -102,19 +128,24 @@ impl PortalEngine {
) -> Arc<Self> {
let server_private = StaticSecret::from(server_private);
let server_public = PublicKey::from(&server_private);
let mut by_public_key = HashMap::with_capacity(clients.len());
let mut by_index = HashMap::with_capacity(clients.len());
for (offset, client) in clients.into_iter().enumerate() {
let index = u32::try_from(offset + 1).expect("client limit is below u32");
let mut slots = EngineSlots::default();
for client in clients {
let public = *client.wireguard_public.as_bytes();
let index = slots
.allocate_index()
.expect("initial portal clients fit the index space");
let slot = Arc::new(ClientSlot {
client,
index,
next_generation: AtomicU64::new(1),
session: Mutex::new(None),
retired: AtomicBool::new(false),
});
by_public_key.insert(public, slot.clone());
by_index.insert(index, slot);
slots
.by_name
.insert(slot.client.config.name.clone(), slot.clone());
slots.by_public_key.insert(public, slot.clone());
slots.by_index.insert(index, slot);
}
Arc::new(Self {
server_private,
@@ -123,18 +154,61 @@ impl PortalEngine {
&server_public,
DOUBLE_VERIFY_HANDSHAKE_LIMIT,
)),
by_public_key,
by_index,
slots: RwLock::new(slots),
accepted,
cancel: CancellationToken::new(),
})
}
pub(super) fn add_client(&self, client: DerivedClient) -> anyhow::Result<()> {
let public = *client.wireguard_public.as_bytes();
let name = client.config.name.clone();
let mut slots = self.slots.write().unwrap();
if slots.by_name.contains_key(&name) || slots.by_public_key.contains_key(&public) {
anyhow::bail!("WireGuard portal client {name} already exists");
}
let index = slots.allocate_index()?;
let slot = Arc::new(ClientSlot {
client,
index,
next_generation: AtomicU64::new(1),
session: Mutex::new(None),
retired: AtomicBool::new(false),
});
slots.by_name.insert(name, slot.clone());
slots.by_public_key.insert(public, slot.clone());
slots.by_index.insert(index, slot);
Ok(())
}
/// Removes a client by name. Any active session is expired so Core tears
/// down the attached peer through its regular channel-close path.
pub(super) async fn remove_client(&self, name: &str) -> bool {
let slot = {
let mut slots = self.slots.write().unwrap();
slots.by_name.remove(name).inspect(|slot| {
slot.retired.store(true, Ordering::Relaxed);
let public = *slot.client.wireguard_public.as_bytes();
slots.by_public_key.remove(&public);
slots.by_index.remove(&slot.index);
slots.free_indices.insert(slot.index);
})
};
let Some(slot) = slot else {
return false;
};
let expired = slot.session.lock().await.take();
Self::retire_session(expired);
true
}
pub(super) fn cancel(&self) {
self.cancel.cancel();
}
pub(super) fn connection_count(&self) -> u32 {
self.by_index
let slots = self.slots.read().unwrap();
slots
.by_index
.values()
.filter(|slot| {
slot.session.try_lock().is_ok_and(|guard| {
@@ -169,7 +243,10 @@ impl PortalEngine {
parse_handshake_anon(&self.server_private, &self.server_public, init)
.ok()
.and_then(|handshake| {
self.by_public_key
self.slots
.read()
.unwrap()
.by_public_key
.get(&handshake.peer_static_public)
.cloned()
})
@@ -179,8 +256,17 @@ impl PortalEngine {
Packet::PacketData(data) => self.slot_by_receiver(data.receiver_idx),
};
let Some(slot) = slot else { return };
if slot.retired.load(Ordering::Relaxed) {
return;
}
let mut session = slot.session.lock().await;
// Re-check after acquiring the lock: remove_client retires the slot
// and drains the session under this same lock, so a datagram that
// raced with removal cannot resurrect a session here.
if slot.retired.load(Ordering::Relaxed) {
return;
}
if session.is_none() {
if !matches!(parsed, Packet::HandshakeInit(_)) {
return;
@@ -277,7 +363,12 @@ impl PortalEngine {
}
fn slot_by_receiver(&self, receiver: u32) -> Option<Arc<ClientSlot>> {
self.by_index.get(&(receiver >> 8)).cloned()
self.slots
.read()
.unwrap()
.by_index
.get(&(receiver >> 8))
.cloned()
}
fn new_session(
@@ -399,7 +490,15 @@ impl PortalEngine {
_ = interval.tick() => {}
}
self.rate_limiter.reset_count();
for slot in self.by_index.values() {
let slots = self
.slots
.read()
.unwrap()
.by_index
.values()
.cloned()
.collect::<Vec<_>>();
for slot in slots {
let mut output = [0u8; 148];
let mut guard = slot.session.lock().await;
let Some(session) = guard.as_mut() else {
@@ -433,3 +532,57 @@ fn is_handshake_response_packet(packet: &[u8]) -> bool {
fn is_transport_data_packet(packet: &[u8]) -> bool {
packet.len() >= 32 && packet.get(..4) == Some(&4u32.to_le_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
fn derived(name: &str, seed: u8) -> DerivedClient {
let secret = StaticSecret::from([seed; 32]);
DerivedClient {
config: PortalClientConfig {
name: name.to_owned(),
virtual_ip: "192.0.2.1".parse().unwrap(),
groups: Vec::new(),
},
wireguard_private: secret.to_bytes(),
wireguard_public: PublicKey::from(&secret),
identity_private_key: [seed.wrapping_add(1); 32],
}
}
fn slot_index(engine: &PortalEngine, name: &str) -> Option<u32> {
engine
.slots
.read()
.unwrap()
.by_name
.get(name)
.map(|slot| slot.index)
}
#[tokio::test]
async fn remove_client_drops_slot_and_recycles_index() {
let (accepted, _receiver) = mpsc::unbounded_channel();
let engine = PortalEngine::new([1; 32], vec![derived("a", 10), derived("b", 11)], accepted);
assert_eq!(slot_index(&engine, "a"), Some(1));
assert_eq!(slot_index(&engine, "b"), Some(2));
assert!(engine.remove_client("a").await);
assert!(!engine.remove_client("a").await);
engine.add_client(derived("c", 12)).unwrap();
assert_eq!(slot_index(&engine, "c"), Some(1), "freed index is reused");
assert!(
engine.add_client(derived("c", 13)).is_err(),
"duplicate client name is rejected"
);
assert!(
engine.add_client(derived("d", 11)).is_err(),
"duplicate client public key is rejected"
);
engine.add_client(derived("d", 14)).unwrap();
assert_eq!(slot_index(&engine, "d"), Some(3));
}
}