feat: stabilize mobile runtime and VPN portal (#2536)

This commit is contained in:
KKRainbow
2026-08-29 13:40:56 +08:00
committed by GitHub
parent 4a10d1c2b9
commit 25f6e2dc5e
31 changed files with 1415 additions and 1302 deletions
@@ -27,8 +27,8 @@ vpn_portal_add_client: 添加客户端
vpn_portal_no_clients: 尚未配置客户端
vpn_portal_client_name: 客户端名称
vpn_portal_client_name_placeholder: 例如:alice-phone
vpn_portal_client_virtual_ip: 虚拟网地址
vpn_portal_client_virtual_ip_placeholder: 例如:10.126.126.10
vpn_portal_client_virtual_ip: 虚拟网 CIDR
vpn_portal_client_virtual_ip_placeholder: 例如:10.126.126.10/24
vpn_portal_client_groups: ACL 组
vpn_portal_client_groups_placeholder: 选择 ACL 组
vpn_portal_remove_client: 删除客户端
@@ -27,8 +27,8 @@ vpn_portal_add_client: Add Client
vpn_portal_no_clients: No clients configured
vpn_portal_client_name: Client Name
vpn_portal_client_name_placeholder: "Example: alice-phone"
vpn_portal_client_virtual_ip: Virtual Network Address
vpn_portal_client_virtual_ip_placeholder: "Example: 10.126.126.10"
vpn_portal_client_virtual_ip: Virtual Network CIDR
vpn_portal_client_virtual_ip_placeholder: "Example: 10.126.126.10/24"
vpn_portal_client_groups: ACL Groups
vpn_portal_client_groups_placeholder: Select ACL groups
vpn_portal_remove_client: Remove Client
@@ -331,7 +331,7 @@ function makeConfig(): NetworkConfig {
wireguard_private_key: 'portal-private-key',
clients: [{
name: 'phone-a',
virtual_ip: '10.1.2.10',
virtual_ip: '10.1.2.10/24',
groups: ['ops'],
}],
},
@@ -421,7 +421,7 @@ describe('Config.vue network config projection', () => {
expect(input(wrapper, '#vpn_portal_wireguard_listen').value).toBe('0.0.0.0:22023')
expect(input(wrapper, '#vpn_portal_wireguard_private_key').value).toBe('portal-private-key')
expect(input(wrapper, '#vpn_portal_client_name_0').value).toBe('phone-a')
expect(input(wrapper, '#vpn_portal_client_virtual_ip_0').value).toBe('10.1.2.10')
expect(input(wrapper, '#vpn_portal_client_virtual_ip_0').value).toBe('10.1.2.10/24')
expect(input(wrapper, '#vpn_portal_client_groups_0').value).toBe('ops')
expect(input(wrapper, '#dev_name').value).toBe('tun-test')
expect(input(wrapper, '#mtu').value).toBe('1280')
@@ -455,7 +455,7 @@ describe('Config.vue network config projection', () => {
await setInput(wrapper, '#vpn_portal_wireguard_listen', '[::]:23000')
await setInput(wrapper, '#vpn_portal_wireguard_private_key', 'edited-private-key')
await setInput(wrapper, '#vpn_portal_client_name_0', 'laptop-a')
await setInput(wrapper, '#vpn_portal_client_virtual_ip_0', '10.1.2.20')
await setInput(wrapper, '#vpn_portal_client_virtual_ip_0', '10.1.2.20/24')
await setInput(wrapper, '#vpn_portal_client_groups_0', 'ops,admin')
await setInput(wrapper, 'input[data-add-label="add_listener_url"]', 'tcp://0.0.0.0:13010')
await setInput(wrapper, '#dev_name', 'tun-edited')
@@ -489,7 +489,7 @@ describe('Config.vue network config projection', () => {
wireguard_private_key: 'edited-private-key',
clients: [{
name: 'laptop-a',
virtual_ip: '10.1.2.20',
virtual_ip: '10.1.2.20/24',
groups: ['ops', 'admin'],
}],
},
@@ -525,7 +525,7 @@ describe('Config.vue network config projection', () => {
wireguard_private_key: 'edited-private-key',
clients: [{
name: 'laptop-a',
virtual_ip: '10.1.2.20',
virtual_ip: '10.1.2.20/24',
groups: ['ops', 'admin'],
}],
},
@@ -568,15 +568,15 @@ mod tests {
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"),
portal_client("alice", "10.144.144.4/24"),
portal_client("carol", "10.144.144.6/24"),
],
"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"),
portal_client("bob", "10.144.144.5/24"),
portal_client("carol", "10.144.144.7/24"),
],
"0.0.0.0:22121",
);
@@ -595,16 +595,24 @@ mod tests {
],
"removals must precede additions; changed clients are remove+add"
);
let added = patch
.vpn_portal_clients
.iter()
.filter(|item| item.action == ConfigPatchAction::Add as i32)
.filter_map(|item| item.client.as_ref())
.map(|client| client.virtual_ip.as_str())
.collect::<Vec<_>>();
assert_eq!(added, ["10.144.144.5/24", "10.144.144.7/24"]);
}
#[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")],
vec![portal_client("alice", "10.144.144.4/24")],
"0.0.0.0:22121",
);
let desired = config_with_vpn_portal(
vec![portal_client("alice", "10.144.144.4")],
vec![portal_client("alice", "10.144.144.4/24")],
"0.0.0.0:22121",
);
@@ -617,11 +625,11 @@ mod tests {
#[test]
fn vpn_portal_listener_identity_change_requires_recreate() {
let current = config_with_vpn_portal(
vec![portal_client("alice", "10.144.144.4")],
vec![portal_client("alice", "10.144.144.4/24")],
"0.0.0.0:22121",
);
let desired = config_with_vpn_portal(
vec![portal_client("alice", "10.144.144.4")],
vec![portal_client("alice", "10.144.144.4/24")],
"0.0.0.0:22122",
);
assert!(
@@ -652,7 +660,7 @@ mod tests {
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")],
vec![portal_client("alice", "10.144.144.4/24")],
"0.0.0.0:22121",
);
@@ -1087,19 +1087,33 @@ async fn mark_config_revision_applied_if_current(
let Some(data) = session_data.upgrade() else {
return RoundStatus::Stop;
};
let mut data = data.write().await;
if !SessionRpcService::runtime_heartbeat_is_current_locked(&data, &round.req) {
return RoundStatus::Ready(());
let notify = {
let mut data = data.write().await;
if !SessionRpcService::runtime_heartbeat_is_current_locked(&data, &round.req) {
return RoundStatus::Ready(());
}
if data.runtime_config_epoch != round.runtime_config_epoch {
return RoundStatus::Ready(());
}
record_applied_config_revision(&mut data, round.target_config_revision.clone())
};
if let Some(notify) = notify {
notify.notify_one();
}
if data.runtime_config_epoch != round.runtime_config_epoch {
return RoundStatus::Ready(());
}
data.applied_config_revision = round.target_config_revision.clone();
data.pending_managed_config_delta = None;
RoundStatus::Ready(())
}
fn record_applied_config_revision(
data: &mut SessionData,
revision: Option<String>,
) -> Option<std::sync::Arc<tokio::sync::Notify>> {
let changed = data.applied_config_revision != revision;
data.applied_config_revision = revision;
data.pending_managed_config_delta = None;
changed.then(|| SessionRpcService::mark_webhook_validation_dirty_locked(data))
}
#[cfg(test)]
mod tests {
use easytier::proto::api::manage::{NetworkingMethod, PortForwardConfig};
@@ -1128,6 +1142,52 @@ mod tests {
}
}
#[tokio::test]
async fn newly_applied_revision_wakes_webhook_validation() {
let storage =
crate::client_manager::storage::Storage::new(crate::db::Db::memory_db().await);
let mut data = SessionData::new(
storage.weak_ref(),
url::Url::parse("http://127.0.0.1").unwrap(),
None,
std::sync::Arc::new(crate::FeatureFlags::default()),
std::sync::Arc::new(crate::webhook::WebhookConfig::new(
None, None, None, None, None,
)),
);
let notify = record_applied_config_revision(&mut data, Some("rev-applied".to_string()))
.expect("new applied revision should wake validation");
assert_eq!(data.applied_config_revision.as_deref(), Some("rev-applied"));
assert!(data.webhook_validation_dirty);
notify.notify_one();
tokio::time::timeout(std::time::Duration::from_millis(100), notify.notified())
.await
.expect("validation worker was not notified");
}
#[tokio::test]
async fn unchanged_applied_revision_does_not_add_validation_work() {
let storage =
crate::client_manager::storage::Storage::new(crate::db::Db::memory_db().await);
let mut data = SessionData::new(
storage.weak_ref(),
url::Url::parse("http://127.0.0.1").unwrap(),
None,
std::sync::Arc::new(crate::FeatureFlags::default()),
std::sync::Arc::new(crate::webhook::WebhookConfig::new(
None, None, None, None, None,
)),
);
data.applied_config_revision = Some("rev-applied".to_string());
assert!(
record_applied_config_revision(&mut data, Some("rev-applied".to_string())).is_none()
);
assert!(!data.webhook_validation_dirty);
}
#[test]
fn patch_delete_requires_runtime_to_remove_every_requested_instance() {
let deleted_id = uuid::Uuid::new_v4();