mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-09-01 16:59:21 +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:
@@ -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?;
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user