mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-09-02 09:09:17 +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:
@@ -58,6 +58,9 @@ export interface RemoteClient {
|
||||
run_network(config: NetworkConfig, save: boolean): Promise<undefined>;
|
||||
get_network_info(inst_id: string): Promise<NetworkInstanceRunningInfo | undefined>;
|
||||
get_vpn_portal_info(inst_id: string): Promise<VpnPortalInfo | undefined>;
|
||||
add_vpn_portal_client(inst_id: string, client: { name: string, virtual_ip: string, groups: string[] }): Promise<undefined>;
|
||||
remove_vpn_portal_client(inst_id: string, name: string): Promise<undefined>;
|
||||
clear_vpn_portal_clients(inst_id: string): Promise<undefined>;
|
||||
list_network_instance_ids(): Promise<ListNetworkInstanceIdResponse>;
|
||||
delete_network(inst_id: string): Promise<undefined>;
|
||||
update_network_instance_state(inst_id: string, disabled: boolean): Promise<undefined>;
|
||||
|
||||
@@ -237,6 +237,38 @@ class WebRemoteClient implements Api.RemoteClient {
|
||||
? NetworkTypes.normalizeVpnPortalInfo(response.vpn_portal_info)
|
||||
: undefined;
|
||||
}
|
||||
async patch_vpn_portal_clients(inst_id: string, patches: Array<Record<string, any>>): Promise<undefined> {
|
||||
await this.client.post(
|
||||
`/machines/${this.machine_id}/proxy-rpc`,
|
||||
{
|
||||
service_name: 'api.config.ConfigRpcService',
|
||||
method_name: 'patch_config',
|
||||
payload: {
|
||||
instance: {
|
||||
id: Utils.StrToUuid(inst_id),
|
||||
},
|
||||
patch: {
|
||||
vpn_portal_clients: patches,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
async add_vpn_portal_client(inst_id: string, client: { name: string, virtual_ip: string, groups: string[] }): Promise<undefined> {
|
||||
await this.patch_vpn_portal_clients(inst_id, [{
|
||||
action: 'ADD',
|
||||
client,
|
||||
}]);
|
||||
}
|
||||
async remove_vpn_portal_client(inst_id: string, name: string): Promise<undefined> {
|
||||
await this.patch_vpn_portal_clients(inst_id, [{
|
||||
action: 'REMOVE',
|
||||
client: { name, virtual_ip: '', groups: [] },
|
||||
}]);
|
||||
}
|
||||
async clear_vpn_portal_clients(inst_id: string): Promise<undefined> {
|
||||
await this.patch_vpn_portal_clients(inst_id, [{ action: 'CLEAR' }]);
|
||||
}
|
||||
async list_network_instance_ids(): Promise<Api.ListNetworkInstanceIdResponse> {
|
||||
const response = await this.client.get<any, ListNetworkInstanceIdResponse>('/machines/' + this.machine_id + '/networks');
|
||||
return response;
|
||||
|
||||
@@ -3,13 +3,15 @@ use easytier::{
|
||||
common::config::{
|
||||
ConfigLoader, EncryptionAlgorithm, NetworkConfigExt,
|
||||
PortForwardConfig as RuntimePortForwardConfig,
|
||||
VpnPortalClientConfig as RuntimeVpnPortalClientConfig,
|
||||
VpnPortalConfig as RuntimeVpnPortalConfig,
|
||||
},
|
||||
proto::{
|
||||
acl::Acl,
|
||||
api::{
|
||||
config::{
|
||||
AclPatch, ConfigPatchAction, InstanceConfigPatch, PatchConfigRequest,
|
||||
PortForwardPatch, ProxyNetworkPatch,
|
||||
PortForwardPatch, ProxyNetworkPatch, VpnPortalClientPatch,
|
||||
},
|
||||
instance::{InstanceIdentifier, instance_identifier},
|
||||
manage::{
|
||||
@@ -61,6 +63,9 @@ fn hot_patch_base(config: &NetworkConfig) -> anyhow::Result<NetworkConfig> {
|
||||
config.port_forwards.clear();
|
||||
config.proxy_cidrs.clear();
|
||||
config.disable_relay_data = None;
|
||||
// VPN portal clients are diffed separately; the listener identity
|
||||
// (address and private key) decides between patch and recreate.
|
||||
config.vpn_portal_config = None;
|
||||
if config.dhcp.unwrap_or_default() {
|
||||
config.virtual_ipv4 = None;
|
||||
config.network_length = None;
|
||||
@@ -204,6 +209,53 @@ fn normalized_disable_relay_data(config: &NetworkConfig) -> anyhow::Result<bool>
|
||||
Ok(config.gen_config()?.get_flags().disable_relay_data)
|
||||
}
|
||||
|
||||
fn normalized_vpn_portal(config: &NetworkConfig) -> anyhow::Result<Option<RuntimeVpnPortalConfig>> {
|
||||
Ok(config.gen_config()?.get_vpn_portal_config())
|
||||
}
|
||||
|
||||
fn diff_vpn_portal_clients(
|
||||
current: &[RuntimeVpnPortalClientConfig],
|
||||
desired: &[RuntimeVpnPortalClientConfig],
|
||||
) -> Vec<VpnPortalClientPatch> {
|
||||
let mut patches = Vec::new();
|
||||
// Removals first so a virtual IP moved between clients never exists
|
||||
// twice inside one patch request.
|
||||
for client in current {
|
||||
match desired.iter().find(|desired| desired.name == client.name) {
|
||||
Some(matching) if matching == client => {}
|
||||
_ => patches.push(VpnPortalClientPatch {
|
||||
action: ConfigPatchAction::Remove as i32,
|
||||
client: Some(client_name_only(&client.name)),
|
||||
}),
|
||||
}
|
||||
}
|
||||
for client in desired {
|
||||
if current
|
||||
.iter()
|
||||
.find(|existing| existing.name == client.name)
|
||||
.is_none_or(|existing| existing != client)
|
||||
{
|
||||
patches.push(VpnPortalClientPatch {
|
||||
action: ConfigPatchAction::Add as i32,
|
||||
client: Some(easytier::proto::api::manage::VpnPortalClientConfig {
|
||||
name: client.name.clone(),
|
||||
virtual_ip: client.virtual_ip.to_string(),
|
||||
groups: client.groups.clone(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
patches
|
||||
}
|
||||
|
||||
fn client_name_only(name: &str) -> easytier::proto::api::manage::VpnPortalClientConfig {
|
||||
easytier::proto::api::manage::VpnPortalClientConfig {
|
||||
name: name.to_owned(),
|
||||
virtual_ip: String::new(),
|
||||
groups: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn web_source_runtime_patch(
|
||||
current: &NetworkConfig,
|
||||
desired: &NetworkConfig,
|
||||
@@ -256,6 +308,27 @@ fn web_source_runtime_patch(
|
||||
patch.disable_relay_data = Some(desired_disable_relay_data);
|
||||
}
|
||||
|
||||
match (
|
||||
normalized_vpn_portal(current)?,
|
||||
normalized_vpn_portal(desired)?,
|
||||
) {
|
||||
(Some(current_portal), Some(desired_portal)) => {
|
||||
if current_portal.wireguard_listen != desired_portal.wireguard_listen
|
||||
|| current_portal.wireguard_private_key != desired_portal.wireguard_private_key
|
||||
{
|
||||
// The listener identity changed; the portal must be rebuilt.
|
||||
return Ok(None);
|
||||
}
|
||||
if current_portal.clients != desired_portal.clients {
|
||||
patch.vpn_portal_clients =
|
||||
diff_vpn_portal_clients(¤t_portal.clients, &desired_portal.clients);
|
||||
}
|
||||
}
|
||||
// Enabling or disabling the portal changes the listener lifecycle.
|
||||
(Some(_), None) | (None, Some(_)) => return Ok(None),
|
||||
(None, None) => {}
|
||||
}
|
||||
|
||||
Ok(Some(patch))
|
||||
}
|
||||
|
||||
@@ -424,6 +497,151 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
fn portal_client(name: &str, ip: &str) -> easytier::proto::api::manage::VpnPortalClientConfig {
|
||||
easytier::proto::api::manage::VpnPortalClientConfig {
|
||||
name: name.to_owned(),
|
||||
virtual_ip: ip.to_owned(),
|
||||
groups: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn config_with_vpn_portal(
|
||||
clients: Vec<easytier::proto::api::manage::VpnPortalClientConfig>,
|
||||
listen: &str,
|
||||
) -> NetworkConfig {
|
||||
let mut config = config_with_port_forwards(Vec::new());
|
||||
config.dhcp = Some(false);
|
||||
config.virtual_ipv4 = Some("10.144.0.1".to_string());
|
||||
config.network_length = Some(24);
|
||||
config.vpn_portal_config = Some(easytier::proto::api::manage::VpnPortalConfig {
|
||||
wireguard_listen: listen.to_owned(),
|
||||
wireguard_private_key: Some("dGVzdC1rZXk=".to_owned()),
|
||||
clients,
|
||||
});
|
||||
config
|
||||
}
|
||||
|
||||
fn patch_vpn_portal_actions(patch: &InstanceConfigPatch) -> Vec<(i32, String)> {
|
||||
patch
|
||||
.vpn_portal_clients
|
||||
.iter()
|
||||
.map(|client_patch| {
|
||||
(
|
||||
client_patch.action,
|
||||
client_patch
|
||||
.client
|
||||
.as_ref()
|
||||
.map(|client| client.name.clone())
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vpn_portal_client_changes_produce_hot_patches() {
|
||||
let current = config_with_vpn_portal(
|
||||
vec![
|
||||
portal_client("alice", "10.144.144.4"),
|
||||
portal_client("carol", "10.144.144.6"),
|
||||
],
|
||||
"0.0.0.0:22121",
|
||||
);
|
||||
let desired = config_with_vpn_portal(
|
||||
vec![
|
||||
portal_client("bob", "10.144.144.5"),
|
||||
portal_client("carol", "10.144.144.7"),
|
||||
],
|
||||
"0.0.0.0:22121",
|
||||
);
|
||||
|
||||
let patch = web_source_runtime_patch(¤t, &desired)
|
||||
.unwrap()
|
||||
.expect("client-only changes must be hot-patchable");
|
||||
|
||||
assert_eq!(
|
||||
patch_vpn_portal_actions(&patch),
|
||||
vec![
|
||||
(ConfigPatchAction::Remove as i32, "alice".to_owned()),
|
||||
(ConfigPatchAction::Remove as i32, "carol".to_owned()),
|
||||
(ConfigPatchAction::Add as i32, "bob".to_owned()),
|
||||
(ConfigPatchAction::Add as i32, "carol".to_owned()),
|
||||
],
|
||||
"removals must precede additions; changed clients are remove+add"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vpn_portal_client_no_op_produces_empty_patch_section() {
|
||||
let current = config_with_vpn_portal(
|
||||
vec![portal_client("alice", "10.144.144.4")],
|
||||
"0.0.0.0:22121",
|
||||
);
|
||||
let desired = config_with_vpn_portal(
|
||||
vec![portal_client("alice", "10.144.144.4")],
|
||||
"0.0.0.0:22121",
|
||||
);
|
||||
|
||||
let patch = web_source_runtime_patch(¤t, &desired)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(patch.vpn_portal_clients.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vpn_portal_listener_identity_change_requires_recreate() {
|
||||
let current = config_with_vpn_portal(
|
||||
vec![portal_client("alice", "10.144.144.4")],
|
||||
"0.0.0.0:22121",
|
||||
);
|
||||
let desired = config_with_vpn_portal(
|
||||
vec![portal_client("alice", "10.144.144.4")],
|
||||
"0.0.0.0:22122",
|
||||
);
|
||||
assert!(
|
||||
web_source_runtime_patch(¤t, &desired)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let mut different_key = desired.clone();
|
||||
different_key
|
||||
.vpn_portal_config
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.wireguard_listen = "0.0.0.0:22121".to_owned();
|
||||
different_key
|
||||
.vpn_portal_config
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.wireguard_private_key = Some("bm90LXRoZS1zYW1lLWtleQ==".to_owned());
|
||||
assert!(
|
||||
web_source_runtime_patch(¤t, &different_key)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vpn_portal_enable_or_disable_requires_recreate() {
|
||||
let without_portal = config_with_port_forwards(Vec::new());
|
||||
let with_portal = config_with_vpn_portal(
|
||||
vec![portal_client("alice", "10.144.144.4")],
|
||||
"0.0.0.0:22121",
|
||||
);
|
||||
|
||||
assert!(
|
||||
web_source_runtime_patch(&without_portal, &with_portal)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
web_source_runtime_patch(&with_portal, &without_portal)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_patch_ignores_runtime_defaults_and_adds_port_forward() {
|
||||
let mut current = config_with_port_forwards(vec![port_forward(23000, 5174)]);
|
||||
|
||||
Reference in New Issue
Block a user