From c5dd4d35463ef4d8e106046a4a9ad3c4ebea7ebe Mon Sep 17 00:00:00 2001 From: KKRainbow <5665404+KKRainbow@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:43:27 +0800 Subject: [PATCH] Configure one mobile VPN from running TUN instances Use admitted native members to configure VpnService and attach its single fd to the core shared TUN. Wait for attachment completion and record the actual attached members before refreshing VPN state. Report rejected members in the GUI and preserve the VPN for unchanged healthy members. Reconcile ordinary configuration notifications without invalidating an arbitrary member. Invalidate attachments on instance lifecycle changes, including restarts that reuse an instance ID. Forward public IPv6 changes alongside proxy route changes to refresh the platform VPN configuration. Exclude confirmed IPv4-less members from readiness gating while retaining retries for unavailable status and pending addresses. Quick Settings stops all running TUN members and restores that group on the next start, skipping removed configurations and no_tun networks. Keep the FFI set_tun_fd call scoped to one instance and report attachment errors. Cover mobile configuration, member rejection and replacement, attachment completion, collection notifications, IPv4-less members, and tile stop/start restoration in GUI regression tests. Validate with 36 mobile VPN tests, vue-tsc --noEmit, Android GUI cargo check, workspace clippy, and shared TUN network namespace tests. --- easytier-contrib/easytier-ffi/Cargo.toml | 1 + .../easytier-ffi/src/instance_api.rs | 7 +- easytier-gui/src-tauri/src/lib.rs | 142 +++--- easytier-gui/src/auto-imports.d.ts | 12 +- easytier-gui/src/composables/backend.ts | 18 +- easytier-gui/src/composables/config.ts | 16 +- easytier-gui/src/composables/event.ts | 11 +- .../src/composables/mobile_vpn.test.ts | 346 ++++++++++++-- easytier-gui/src/composables/mobile_vpn.ts | 421 +++++++++--------- .../src/composables/mobile_vpn_tile.test.ts | 58 ++- .../src/composables/mobile_vpn_tile.ts | 33 +- easytier-gui/src/pages/index.vue | 12 +- .../android/src/main/java/TauriVpnService.kt | 34 +- .../android/src/main/java/VpnServicePlugin.kt | 6 +- tauri-plugin-vpnservice/guest-js/index.ts | 2 + tauri-plugin-vpnservice/src/models.rs | 2 + 16 files changed, 771 insertions(+), 350 deletions(-) diff --git a/easytier-contrib/easytier-ffi/Cargo.toml b/easytier-contrib/easytier-ffi/Cargo.toml index 56293e45..5d37dee7 100644 --- a/easytier-contrib/easytier-ffi/Cargo.toml +++ b/easytier-contrib/easytier-ffi/Cargo.toml @@ -14,6 +14,7 @@ ffi-dataplane = [ "easytier/ffi-dataplane", "easytier-core/proxy-smoltcp-stack", ] +macos-ne = ["easytier/macos-ne"] [dependencies] easytier = { workspace = true, default-features = true, features = ["tracing-log"] } diff --git a/easytier-contrib/easytier-ffi/src/instance_api.rs b/easytier-contrib/easytier-ffi/src/instance_api.rs index 285d0eaa..9a488ae0 100644 --- a/easytier-contrib/easytier-ffi/src/instance_api.rs +++ b/easytier-contrib/easytier-ffi/src/instance_api.rs @@ -21,7 +21,7 @@ pub(crate) unsafe fn set_tun_fd(inst_name: *const c_char, fd: c_int) -> c_int { let inst_id = match resolve_instance_id_by_name(&inst_name) { Ok(Some(instance_id)) => instance_id, Ok(None) => { - set_error_msg("instance not found"); + set_error_msg(&format!("instance not found: {inst_name}")); return -1; } Err(error) => { @@ -32,7 +32,10 @@ pub(crate) unsafe fn set_tun_fd(inst_name: *const c_char, fd: c_int) -> c_int { match ffi_context().manager.attach_tun_fd(inst_id, fd) { Ok(_) => 0, - Err(_) => -1, + Err(e) => { + set_error_msg(&format!("failed to set tun fd: {}", e)); + -1 + } } } diff --git a/easytier-gui/src-tauri/src/lib.rs b/easytier-gui/src-tauri/src/lib.rs index fd0b106c..e344b7eb 100644 --- a/easytier-gui/src-tauri/src/lib.rs +++ b/easytier-gui/src-tauri/src/lib.rs @@ -6,6 +6,10 @@ mod elevate; use anyhow::Context; #[cfg(target_os = "android")] use easytier::instance::factory::subscribe_native_instance_event; +#[cfg(any(target_os = "android", target_os = "ios"))] +use easytier::instance::factory::{ + attach_mobile_tun_fd, get_mobile_tun_config as native_mobile_tun_config, +}; use easytier::proto::api::config::{ ConfigPatchAction, ConfigRpc, ConfigRpcClientFactory, VpnPortalClientPatch, }; @@ -25,7 +29,7 @@ use easytier::{ config::{ConfigLoader, ConfigSource, FileLoggerConfig, LoggingConfig, TomlConfigLoader}, log, }, - instance::factory::{NativeInstanceManager, native_instance_manager}, + instance::factory::{MobileTunConfig, NativeInstanceManager, native_instance_manager}, proto::rpc::standalone::{runtime_rpc_dialer, runtime_rpc_listener}, rpc_service::ApiRpcServer, utils::panic::setup_panic_handler, @@ -235,19 +239,45 @@ async fn set_logging_level(level: String) -> Result<(), String> { } #[tauri::command] -async fn set_tun_fd(fd: i32) -> Result<(), String> { +async fn set_tun_fd(fd: i32, instance_ids: Option>) -> Result { let Some(instance_manager) = INSTANCE_MANAGER.read().await.clone() else { return Err("set_tun_fd is not supported in remote mode".to_string()); }; - if let Some(uuid) = get_client_manager!()? - .get_enabled_instances_with_tun_ids() - .next() + + #[cfg(any(target_os = "android", target_os = "ios"))] + return attach_mobile_tun_fd(instance_manager.as_ref(), fd, instance_ids.as_deref()) + .await + .map_err(|error| error.to_string()); + + #[cfg(not(any(target_os = "android", target_os = "ios")))] { - instance_manager - .attach_tun_fd(uuid, fd) - .map_err(|e| e.to_string())?; + let _ = instance_ids; + if let Some(instance_id) = get_client_manager!()? + .get_enabled_instances_with_tun_ids() + .next() + { + instance_manager + .attach_tun_fd(instance_id, fd) + .map_err(|error| error.to_string())?; + } + Ok(MobileTunConfig::default()) + } +} + +#[tauri::command] +async fn get_mobile_tun_config(instance_ids: Vec) -> Result { + #[cfg(any(target_os = "android", target_os = "ios"))] + { + let Some(manager) = INSTANCE_MANAGER.read().await.clone() else { + return Err("mobile TUN configuration is not supported in remote mode".to_owned()); + }; + Ok(native_mobile_tun_config(manager.as_ref(), &instance_ids).await) + } + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + let _ = instance_ids; + Err("mobile TUN configuration is unavailable on this platform".to_owned()) } - Ok(()) } #[tauri::command] @@ -783,11 +813,6 @@ mod manager { Self::Web => ConfigSource::Web, } } - - #[cfg(any(test, target_os = "android"))] - fn is_web_like(self) -> bool { - matches!(self, Self::Web) - } } #[derive(Clone)] @@ -1008,44 +1033,21 @@ mod manager { .filter_map(|c| c.config.instance_id().parse::().ok()) } - #[cfg(target_os = "android")] - pub fn get_enabled_instances_with_web_like_tun_ids( - &self, - ) -> impl Iterator + '_ { - self.storage - .network_configs - .iter() - .filter(|v| self.storage.enabled_networks.contains(v.key())) - .filter(|v| !v.config.no_tun()) - .filter(|v| v.source.is_web_like()) - .filter_map(|c| c.config.instance_id().parse::().ok()) - } - - #[cfg(target_os = "android")] - pub(super) async fn disable_instances_with_tun( - &self, - app: &AppHandle, - web_only: bool, - ) -> Result<(), easytier_core::management::remote_client::RemoteClientError> - { - let inst_ids: Vec = if web_only { - self.get_enabled_instances_with_web_like_tun_ids().collect() - } else { - self.get_enabled_instances_with_tun_ids().collect() - }; - for inst_id in inst_ids { - self.handle_update_network_state(app.clone(), inst_id, true) - .await?; - } - Ok(()) - } - pub(super) fn notify_vpn_stop_if_no_tun(&self, app: &AppHandle) -> Result<(), String> { - let has_tun = self.get_enabled_instances_with_tun_ids().any(|_| true); - if !has_tun { - app.emit("vpn_service_stop", "") + #[cfg(target_os = "android")] + if self.get_enabled_instances_with_tun_ids().next().is_some() { + app.emit("vpn_service_config_changed", "") .map_err(|e| e.to_string())?; + return Ok(()); } + + #[cfg(not(target_os = "android"))] + if self.get_enabled_instances_with_tun_ids().next().is_some() { + return Ok(()); + } + + app.emit("vpn_service_stop", "") + .map_err(|e| e.to_string())?; Ok(()) } @@ -1059,28 +1061,6 @@ mod manager { app.emit("pre_run_network_instance", instance_id.to_string()) .map_err(|e| e.to_string())?; - #[cfg(target_os = "android")] - if !cfg.get_flags().no_tun { - match source { - PersistedConfigSource::User | PersistedConfigSource::Legacy => { - self.disable_instances_with_tun(app, false) - .await - .map_err(|e| e.to_string())?; - } - PersistedConfigSource::Web => { - self.disable_instances_with_tun(app, true) - .await - .map_err(|e| e.to_string())?; - if self.get_enabled_instances_with_tun_ids().next().is_some() { - return Err( - "Android only supports one active TUN network; user-managed VPN remains active" - .to_string(), - ); - } - } - } - } - self.storage .save_config( app, @@ -1109,10 +1089,13 @@ mod manager { let instance_id_str = instance_id_clone.to_string(); loop { match event_receiver.recv().await { - Ok(easytier::common::global_ctx::GlobalCtxEvent::DhcpIpv4Changed(_, _)) => { + Ok(easytier::common::global_ctx::GlobalCtxEvent::DhcpIpv4Changed(_, _) + | easytier::common::global_ctx::GlobalCtxEvent::DhcpIpv4Conflicted(_)) => { let _ = app_clone.emit("dhcp_ip_changed", &instance_id_str); } - Ok(easytier::common::global_ctx::GlobalCtxEvent::ProxyCidrsUpdated(_, _)) => { + Ok(easytier::common::global_ctx::GlobalCtxEvent::ProxyCidrsUpdated(_, _) + | easytier::common::global_ctx::GlobalCtxEvent::PublicIpv6Changed(_, _) + | easytier::common::global_ctx::GlobalCtxEvent::PublicIpv6RoutesUpdated(_, _)) => { let _ = app_clone.emit("proxy_cidrs_updated", &instance_id_str); } Ok(_) => {} @@ -1192,10 +1175,11 @@ mod manager { ) -> anyhow::Result<()> { self.storage.network_configs.clear(); for stored in configs { - let instance_id = stored.config.instance_id(); + let config = stored.config; + let instance_id = config.instance_id(); self.storage.network_configs.insert( instance_id.parse()?, - GUIConfig::new(instance_id.to_string(), stored.config, stored.source), + GUIConfig::new(instance_id.to_string(), config, stored.source), ); } @@ -1319,13 +1303,6 @@ mod manager { PersistedConfigSource::Web ); } - - #[test] - fn only_web_configs_are_web_like() { - assert!(!PersistedConfigSource::Legacy.is_web_like()); - assert!(!PersistedConfigSource::User.is_web_like()); - assert!(PersistedConfigSource::Web.is_web_like()); - } } } @@ -1517,6 +1494,7 @@ pub fn run_gui() -> std::process::ExitCode { patch_vpn_portal_clients, set_logging_level, set_tun_fd, + get_mobile_tun_config, easytier_version, set_dock_visibility, list_network_instance_ids, diff --git a/easytier-gui/src/auto-imports.d.ts b/easytier-gui/src/auto-imports.d.ts index 34230ae2..96bc0dff 100644 --- a/easytier-gui/src/auto-imports.d.ts +++ b/easytier-gui/src/auto-imports.d.ts @@ -29,6 +29,7 @@ declare global { const getCurrentInstance: typeof import('vue')['getCurrentInstance'] const getCurrentScope: typeof import('vue')['getCurrentScope'] const getEasytierVersion: typeof import('./composables/backend')['getEasytierVersion'] + const getMobileTunConfig: typeof import('./composables/backend')['getMobileTunConfig'] const getNetworkMetas: typeof import('./composables/backend')['getNetworkMetas'] const getServiceStatus: typeof import('./composables/backend')['getServiceStatus'] const getVpnPortalInfo: typeof import('./composables/backend')['getVpnPortalInfo'] @@ -48,6 +49,7 @@ declare global { const listenGlobalEvents: typeof import('./composables/event')['listenGlobalEvents'] const loadLastNetworkInstanceId: typeof import('./composables/config')['loadLastNetworkInstanceId'] const loadMode: typeof import('./composables/mode')['loadMode'] + const loadVpnTileStoppedInstanceIds: typeof import('./composables/config')['loadVpnTileStoppedInstanceIds'] const mapActions: typeof import('pinia')['mapActions'] const mapGetters: typeof import('pinia')['mapGetters'] const mapState: typeof import('pinia')['mapState'] @@ -67,6 +69,7 @@ declare global { const onMounted: typeof import('vue')['onMounted'] const onNetworkInstanceChange: typeof import('./composables/mobile_vpn')['onNetworkInstanceChange'] const onNetworkInstanceUpdate: typeof import('./composables/mobile_vpn')['onNetworkInstanceUpdate'] + const onNetworkInstancesChange: typeof import('./composables/mobile_vpn')['onNetworkInstancesChange'] const onRenderTracked: typeof import('vue')['onRenderTracked'] const onRenderTriggered: typeof import('vue')['onRenderTriggered'] const onScopeDispose: typeof import('vue')['onScopeDispose'] @@ -85,10 +88,12 @@ declare global { const saveLastNetworkInstanceId: typeof import('./composables/config')['saveLastNetworkInstanceId'] const saveMode: typeof import('./composables/mode')['saveMode'] const saveNetworkConfig: typeof import('./composables/backend')['saveNetworkConfig'] + const saveVpnTileStoppedInstanceIds: typeof import('./composables/config')['saveVpnTileStoppedInstanceIds'] const sendConfigs: typeof import('./composables/backend')['sendConfigs'] const setActivePinia: typeof import('pinia')['setActivePinia'] const setLoggingLevel: typeof import('./composables/backend')['setLoggingLevel'] const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix'] + const setMobileVpnErrorHandler: typeof import('./composables/mobile_vpn')['setMobileVpnErrorHandler'] const setMobileVpnTileActionHandler: typeof import('./composables/mobile_vpn')['setMobileVpnTileActionHandler'] const setServiceStatus: typeof import('./composables/backend')['setServiceStatus'] const setTrayMenu: typeof import('./composables/tray')['setTrayMenu'] @@ -160,6 +165,7 @@ declare module 'vue' { readonly getCurrentInstance: UnwrapRef readonly getCurrentScope: UnwrapRef readonly getEasytierVersion: UnwrapRef + readonly getMobileTunConfig: UnwrapRef readonly getNetworkMetas: UnwrapRef readonly getServiceStatus: UnwrapRef readonly getVpnPortalInfo: UnwrapRef @@ -179,6 +185,7 @@ declare module 'vue' { readonly listenGlobalEvents: UnwrapRef readonly loadLastNetworkInstanceId: UnwrapRef readonly loadMode: UnwrapRef + readonly loadVpnTileStoppedInstanceIds: UnwrapRef readonly mapActions: UnwrapRef readonly mapGetters: UnwrapRef readonly mapState: UnwrapRef @@ -198,6 +205,7 @@ declare module 'vue' { readonly onMounted: UnwrapRef readonly onNetworkInstanceChange: UnwrapRef readonly onNetworkInstanceUpdate: UnwrapRef + readonly onNetworkInstancesChange: UnwrapRef readonly onRenderTracked: UnwrapRef readonly onRenderTriggered: UnwrapRef readonly onScopeDispose: UnwrapRef @@ -216,10 +224,12 @@ declare module 'vue' { readonly saveLastNetworkInstanceId: UnwrapRef readonly saveMode: UnwrapRef readonly saveNetworkConfig: UnwrapRef + readonly saveVpnTileStoppedInstanceIds: UnwrapRef readonly sendConfigs: UnwrapRef readonly setActivePinia: UnwrapRef readonly setLoggingLevel: UnwrapRef readonly setMapStoreSuffix: UnwrapRef + readonly setMobileVpnErrorHandler: UnwrapRef readonly setMobileVpnTileActionHandler: UnwrapRef readonly setServiceStatus: UnwrapRef readonly setTrayMenu: UnwrapRef @@ -256,4 +266,4 @@ declare module 'vue' { readonly watchPostEffect: UnwrapRef readonly watchSyncEffect: UnwrapRef } -} \ No newline at end of file +} diff --git a/easytier-gui/src/composables/backend.ts b/easytier-gui/src/composables/backend.ts index a66ca615..c5a47c76 100644 --- a/easytier-gui/src/composables/backend.ts +++ b/easytier-gui/src/composables/backend.ts @@ -87,8 +87,20 @@ export async function setLoggingLevel(level: string) { return await invoke('set_logging_level', { level }) } -export async function setTunFd(fd: number) { - return await invoke('set_tun_fd', { fd }) +export interface MobileTunConfig { + instance_ids: string[] + addresses: string[] + routes: string[] + dns?: string | null + rejected: { instance_id: string, error: string }[] +} + +export async function getMobileTunConfig(instanceIds: string[]) { + return await invoke('get_mobile_tun_config', { instanceIds }) +} + +export async function setTunFd(fd: number, instanceIds?: string[]) { + return await invoke('set_tun_fd', { fd, instanceIds }) } export async function getEasytierVersion() { @@ -127,7 +139,7 @@ export async function sendConfigs(enabledNetworks: string[]) { config: NetworkTypes.toBackendNetworkConfig(config), source, })), - enabledNetworks + enabledNetworks, }) } diff --git a/easytier-gui/src/composables/config.ts b/easytier-gui/src/composables/config.ts index 72e8cae9..400c094a 100644 --- a/easytier-gui/src/composables/config.ts +++ b/easytier-gui/src/composables/config.ts @@ -17,4 +17,18 @@ export function saveLastNetworkInstanceId(instanceId: string) { */ export function loadLastNetworkInstanceId(): string | null { return localStorage.getItem('last_network_instance_id') -} \ No newline at end of file +} + +export function saveVpnTileStoppedInstanceIds(instanceIds: string[]) { + localStorage.setItem('vpn_tile_stopped_instance_ids', JSON.stringify(instanceIds)) +} + +export function loadVpnTileStoppedInstanceIds(): string[] { + try { + const value: unknown = JSON.parse(localStorage.getItem('vpn_tile_stopped_instance_ids') || '[]') + return Array.isArray(value) ? value.filter((id): id is string => typeof id === 'string') : [] + } + catch { + return [] + } +} diff --git a/easytier-gui/src/composables/event.ts b/easytier-gui/src/composables/event.ts index 2b7a029e..27815dac 100644 --- a/easytier-gui/src/composables/event.ts +++ b/easytier-gui/src/composables/event.ts @@ -3,7 +3,7 @@ import { type } from "@tauri-apps/plugin-os"; import { NetworkTypes } from "easytier-frontend-lib" import { Utils } from "easytier-frontend-lib"; import { normalizeConfigSource } from './config_source' -import { onNetworkInstanceUpdate } from './mobile_vpn' +import { onNetworkInstancesChange, onNetworkInstanceUpdate } from './mobile_vpn' interface StoredGuiConfig { config: NetworkTypes.NetworkConfig @@ -15,6 +15,7 @@ const EVENTS = Object.freeze({ PRE_RUN_NETWORK_INSTANCE: 'pre_run_network_instance', POST_RUN_NETWORK_INSTANCE: 'post_run_network_instance', VPN_SERVICE_STOP: 'vpn_service_stop', + VPN_SERVICE_CONFIG_CHANGED: 'vpn_service_config_changed', DHCP_IP_CHANGED: 'dhcp_ip_changed', PROXY_CIDRS_UPDATED: 'proxy_cidrs_updated', EVENT_LAGGED: 'event_lagged', @@ -77,6 +78,13 @@ async function onVpnServiceStop(event: Event) { await syncMobileVpnService(); } +async function onVpnServiceConfigChanged(event: Event) { + console.log(`Received event '${EVENTS.VPN_SERVICE_CONFIG_CHANGED}'`, event.payload) + if (type() === 'android') { + await onNetworkInstancesChange(); + } +} + async function onDhcpIpChanged(event: Event) { const instanceId = normalizeInstanceIdPayload(event.payload) console.log(`Received event '${EVENTS.DHCP_IP_CHANGED}' for instance: ${instanceId}`); @@ -105,6 +113,7 @@ export async function listenGlobalEvents() { await listen(EVENTS.PRE_RUN_NETWORK_INSTANCE, onPreRunNetworkInstance), await listen(EVENTS.POST_RUN_NETWORK_INSTANCE, onPostRunNetworkInstance), await listen(EVENTS.VPN_SERVICE_STOP, onVpnServiceStop), + await listen(EVENTS.VPN_SERVICE_CONFIG_CHANGED, onVpnServiceConfigChanged), await listen(EVENTS.DHCP_IP_CHANGED, onDhcpIpChanged), await listen(EVENTS.PROXY_CIDRS_UPDATED, onProxyCidrsUpdated), await listen(EVENTS.EVENT_LAGGED, onEventLagged), diff --git a/easytier-gui/src/composables/mobile_vpn.test.ts b/easytier-gui/src/composables/mobile_vpn.test.ts index 19c94abe..9ee8234d 100644 --- a/easytier-gui/src/composables/mobile_vpn.test.ts +++ b/easytier-gui/src/composables/mobile_vpn.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { IPv4CidrRange } from 'ip-num/IPRange' +import type { MobileTunConfig } from './backend' const mocks = vi.hoisted(() => { const listeners = new Map Promise>() @@ -20,7 +22,10 @@ const mocks = vi.hoisted(() => { getVpnStatus: vi.fn<() => Promise>>(async () => ({ running: false })), listNetworkInstanceIds: vi.fn<() => Promise<{ running_inst_ids: unknown[] }>>(async () => ({ running_inst_ids: [] })), prepareVpn: vi.fn(async () => ({ granted: true })), - setTunFd: vi.fn(async () => undefined), + getMobileTunConfig: vi.fn(async (instanceIds: string[]) => makeMobileTunConfig(instanceIds)), + setTunFd: vi.fn(async (fd: number, instanceIds?: string[]) => fd > 0 + ? makeMobileTunConfig(instanceIds ?? [...configs.keys()]) + : { instance_ids: [], addresses: [], routes: [], rejected: [] }), startVpn: vi.fn(async () => { await listeners.get('vpn_service_start')?.({ fd: 1 }) return {} @@ -54,17 +59,46 @@ vi.mock('tauri-plugin-vpnservice-api', () => ({ vi.mock('./backend', () => ({ collectNetworkInfo: mocks.collectNetworkInfo, getConfig: mocks.getConfig, + getMobileTunConfig: mocks.getMobileTunConfig, listNetworkInstanceIds: mocks.listNetworkInstanceIds, setTunFd: mocks.setTunFd, })) -function setConfig(instanceId: string, noTun = false) { +function makeMobileTunConfig(instanceIds: string[]): MobileTunConfig { + const addresses: string[] = [] + const routes = new Set() + let dns: string | undefined + for (const id of instanceIds) { + const config = mocks.configs.get(id)! + const info = mocks.networkInfo.get(id) as any + const ipv4 = info?.my_node_info?.virtual_ipv4 + if (!ipv4?.address?.addr || info.error_msg) + continue + const address = `${ipv4.address.addr}/${ipv4.network_length || 24}` + addresses.push(address) + const range = IPv4CidrRange.fromCidr(address) + routes.add(`${range.getFirst()}/${range.getPrefix()}`) + const configuredRoutes = config.enable_manual_routes + ? config.routes as string[] + : (info.routes ?? []).flatMap((route: any) => route.proxy_cidrs ?? []) + configuredRoutes.forEach((route: string) => routes.add(route.includes('/') ? route : `${route}/32`)) + if (config.enable_magic_dns) { + routes.add('100.100.100.101/32') + dns = '100.100.100.101' + } + } + return { instance_ids: [...instanceIds].sort(), addresses: addresses.sort(), routes: [...routes].sort(), dns, rejected: [] } +} + +function setConfig(instanceId: string, noTun = false, devName?: string) { mocks.configs.set(instanceId, { no_tun: noTun, + dev_name: devName, dhcp: false, enable_magic_dns: false, routes: [], }) + mocks.listNetworkInstanceIds.mockResolvedValue({ running_inst_ids: [...mocks.configs.keys()] }) } function setReady(instanceId: string, ipv4: string) { @@ -87,6 +121,7 @@ async function loadVpnModule() { beforeEach(() => { vi.useFakeTimers() + vi.clearAllTimers() vi.resetModules() mocks.listeners.clear() mocks.configs.clear() @@ -102,34 +137,290 @@ beforeEach(() => { mocks.listNetworkInstanceIds.mockResolvedValue({ running_inst_ids: [] }) mocks.prepareVpn.mockClear() mocks.setTunFd.mockClear() + mocks.setTunFd.mockImplementation(async (fd: number, instanceIds?: string[]) => fd > 0 + ? makeMobileTunConfig(instanceIds ?? [...mocks.configs.keys()]) + : { instance_ids: [], addresses: [], routes: [], rejected: [] }) + mocks.getMobileTunConfig.mockReset() + mocks.getMobileTunConfig.mockImplementation(async instanceIds => makeMobileTunConfig(instanceIds)) mocks.startVpn.mockClear() mocks.stopVpn.mockClear() }) -describe('mobile VPN reconciliation ownership', () => { - it('stops A before retrying an unavailable B, then starts B when it becomes ready', async () => { +describe('mobile VPN reconciliation', () => { + it('preserves healthy members and reports a rejected bootstrap member', async () => { + setConfig('A') + setReady('A', '10.126.126.1') + const vpn = await loadVpnModule() + await vpn.onNetworkInstanceChange('A') + const errorHandler = vi.fn() + vpn.setMobileVpnErrorHandler(errorHandler) + mocks.startVpn.mockClear() + mocks.setTunFd.mockClear() + setConfig('B') + setReady('B', '10.126.126.2') + mocks.getMobileTunConfig.mockResolvedValue({ ...makeMobileTunConfig(['A']), rejected: [{ instance_id: 'B', error: 'overlapping subnet' }] }) + await vpn.onNetworkInstanceChange('B') + await vpn.onNetworkInstanceUpdate('B') + expect(mocks.stopVpn).not.toHaveBeenCalled() + expect(mocks.startVpn).not.toHaveBeenCalled() + expect(mocks.setTunFd).not.toHaveBeenCalled() + expect(errorHandler).toHaveBeenCalledOnce() + expect(errorHandler).toHaveBeenCalledWith('B: overlapping subnet') + }) + + it('does not invalidate healthy attachments on a collection notification', async () => { + setConfig('A') + setReady('A', '10.0.0.1') + const vpn = await loadVpnModule() + await vpn.onNetworkInstanceChange('A') + mocks.setTunFd.mockClear() + await vpn.onNetworkInstancesChange() + expect(mocks.stopVpn).not.toHaveBeenCalled() + expect(mocks.startVpn).toHaveBeenCalledOnce() + expect(mocks.setTunFd).not.toHaveBeenCalled() + }) + + it('corrects OS configuration when attachment admits fewer members', async () => { setConfig('A') setConfig('B') setReady('A', '10.0.0.1') + setReady('B', '10.0.1.1') + const actual = { ...makeMobileTunConfig(['A']), rejected: [{ instance_id: 'B', error: 'claim changed during attachment' }] } + mocks.setTunFd.mockResolvedValue(actual) + const vpn = await loadVpnModule() + await vpn.onNetworkInstanceChange('A') + expect(mocks.stopVpn).not.toHaveBeenCalled() + mocks.getMobileTunConfig.mockResolvedValue(actual) + await vi.advanceTimersByTimeAsync(2000) + expect(mocks.stopVpn).toHaveBeenCalledOnce() + expect(mocks.startVpn).toHaveBeenLastCalledWith(expect.objectContaining({ ipv4Addrs: ['10.0.0.1/24'], routes: ['10.0.0.0/24'] })) + }) + + it('waits for native attachment completion before completing startup', async () => { + setConfig('A') + setReady('A', '10.0.0.1') + let resolve!: (value: ReturnType) => void + mocks.setTunFd.mockImplementationOnce(() => new Promise(r => { resolve = r })) + const vpn = await loadVpnModule() + let completed = false + const start = vpn.onNetworkInstanceChange('A').then(() => { completed = true }) + await vi.waitFor(() => expect(mocks.setTunFd).toHaveBeenCalledWith(1, ['A'])) + expect(completed).toBe(false) + resolve(makeMobileTunConfig(['A'])) + await start + expect(completed).toBe(true) + }) + + it('stops the VPN and reports a physical TUN attachment failure', async () => { + setConfig('A') + setReady('A', '10.0.0.1') + mocks.setTunFd.mockRejectedValueOnce(new Error('physical TUN failed')) + const vpn = await loadVpnModule() + const errorHandler = vi.fn() + vpn.setMobileVpnErrorHandler(errorHandler) + await vpn.onNetworkInstanceChange('A') + expect(mocks.stopVpn).toHaveBeenCalledOnce() + expect(errorHandler).toHaveBeenCalledWith('Error: physical TUN failed') + }) + + it('keeps attached shared members during a temporary status gap', async () => { + setConfig('A', false, 'shared0') + setConfig('B', false, 'shared0') + setReady('A', '10.0.0.1') + setReady('B', '10.0.1.1') + const vpn = await loadVpnModule() + await vpn.onNetworkInstanceChange('A') + mocks.startVpn.mockClear() + + mocks.networkInfo.delete('B') + await vpn.onNetworkInstanceUpdate('B') + expect(mocks.stopVpn).not.toHaveBeenCalled() + expect(mocks.startVpn).not.toHaveBeenCalled() + + setReady('B', '10.0.1.2') + await vpn.onNetworkInstanceUpdate('B') + expect(mocks.stopVpn).toHaveBeenCalledTimes(1) + expect(mocks.startVpn).toHaveBeenLastCalledWith(expect.objectContaining({ + ipv4Addrs: ['10.0.0.1/24', '10.0.1.2/24'], + })) + }) + + it('keeps a ready member active while a new shared member awaits an IP', async () => { + setConfig('A', false, 'shared0') + setReady('A', '10.0.0.1') + const vpn = await loadVpnModule() + await vpn.onNetworkInstanceChange('A') + + setConfig('B', false, 'shared0') + await vpn.onNetworkInstanceChange('B') + expect(mocks.stopVpn).not.toHaveBeenCalled() + expect(mocks.startVpn).toHaveBeenCalledTimes(1) + + setReady('B', '10.0.1.1') + await vpn.onNetworkInstanceUpdate('B') + expect(mocks.stopVpn).toHaveBeenCalledTimes(1) + expect(mocks.startVpn).toHaveBeenLastCalledWith(expect.objectContaining({ + ipv4Addrs: ['10.0.0.1/24', '10.0.1.1/24'], + })) + }) + + it('uses one VPN for different dev_name values and keeps the remaining member', async () => { + setConfig('A', false, 'shared0') + setConfig('B', false, 'other0') + setConfig('C', true) + setReady('A', '10.0.0.1') + setReady('B', '10.0.1.1') const vpn = await loadVpnModule() await vpn.onNetworkInstanceChange('A') - expect(mocks.startVpn).toHaveBeenCalledTimes(1) + expect(mocks.startVpn).toHaveBeenCalledWith(expect.objectContaining({ + ipv4Addrs: ['10.0.0.1/24', '10.0.1.1/24'], + })) + expect(mocks.setTunFd).toHaveBeenCalledWith(1, ['A', 'B']) mocks.startVpn.mockClear() - await vpn.onNetworkInstanceChange('B') + mocks.stopVpn.mockClear() + mocks.listNetworkInstanceIds.mockResolvedValue({ running_inst_ids: ['C', 'B'] }) + await vpn.onNetworkInstanceChange('A') expect(mocks.stopVpn).toHaveBeenCalledTimes(1) - expect(mocks.startVpn).not.toHaveBeenCalled() - - setReady('B', '10.0.0.2') - await vpn.onNetworkInstanceUpdate('B') - - expect(mocks.startVpn).toHaveBeenCalledTimes(1) - expect(mocks.startVpn).toHaveBeenCalledWith(expect.objectContaining({ ipv4Addr: '10.0.0.2/24' })) + expect(mocks.startVpn).toHaveBeenCalledWith(expect.objectContaining({ + ipv4Addrs: ['10.0.1.1/24'], + })) + expect(mocks.setTunFd).toHaveBeenLastCalledWith(1, ['B']) }) - it('stops the previous owner during pre-run even if the new instance never reaches post-run', async () => { + it.each(['A', 'B'])('reattaches a replacement member %s with the same VPN configuration', async (replacementId) => { + setConfig('A') + setReady('A', '10.0.0.1') + const vpn = await loadVpnModule() + await vpn.onNetworkInstanceChange('A') + mocks.setTunFd.mockClear() + + mocks.listNetworkInstanceIds.mockResolvedValue({ running_inst_ids: [] }) + const stopA = vpn.onNetworkInstanceChange('A') + setConfig(replacementId) + setReady(replacementId, '10.0.0.1') + mocks.listNetworkInstanceIds.mockResolvedValue({ running_inst_ids: [replacementId] }) + const startReplacement = vpn.onNetworkInstanceChange(replacementId) + await Promise.all([stopA, startReplacement]) + + expect(mocks.setTunFd).toHaveBeenLastCalledWith(1, [replacementId]) + expect(mocks.startVpn).toHaveBeenCalledTimes(2) + expect(mocks.stopVpn).toHaveBeenCalledTimes(1) + + mocks.setTunFd.mockClear() + await vpn.onNetworkInstanceUpdate(replacementId) + expect(mocks.setTunFd).not.toHaveBeenCalled() + }) + + it('does not reattach unchanged members when their order changes', async () => { + setConfig('A') + setConfig('B') + setReady('A', '10.0.0.1') + setReady('B', '10.0.1.1') + const vpn = await loadVpnModule() + await vpn.onNetworkInstanceChange('A') + mocks.setTunFd.mockClear() + + mocks.listNetworkInstanceIds.mockResolvedValue({ running_inst_ids: ['B', 'A'] }) + await vpn.onNetworkInstanceUpdate('A') + + expect(mocks.setTunFd).not.toHaveBeenCalled() + expect(mocks.stopVpn).not.toHaveBeenCalled() + }) + + it('stops the VPN when only confirmed IPv4-less members remain', async () => { + setConfig('A') + setConfig('B') + setReady('A', '10.0.0.1') + mocks.networkInfo.set('B', { my_node_info: {}, routes: [] }) + const vpn = await loadVpnModule() + await vpn.onNetworkInstanceChange('A') + + mocks.listNetworkInstanceIds.mockResolvedValue({ running_inst_ids: ['B'] }) + await vpn.onNetworkInstanceChange('A') + + expect(mocks.stopVpn).toHaveBeenCalledTimes(1) + expect(mocks.setTunFd).toHaveBeenLastCalledWith(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it('applies DHCP updates while another member has no configured IPv4', async () => { + setConfig('A') + setConfig('B') + mocks.configs.get('A')!.dhcp = true + setReady('A', '10.0.0.1') + mocks.networkInfo.set('B', { my_node_info: {}, routes: [] }) + const vpn = await loadVpnModule() + await vpn.onNetworkInstanceChange('A') + + setReady('A', '10.0.1.1') + await vpn.onNetworkInstanceUpdate('A') + + expect(mocks.stopVpn).toHaveBeenCalledTimes(1) + expect(mocks.startVpn).toHaveBeenLastCalledWith(expect.objectContaining({ + ipv4Addrs: ['10.0.1.1/24'], + routes: ['10.0.1.0/24'], + })) + expect(vi.getTimerCount()).toBe(0) + }) + + it.each([ + { virtualIpv4: '', info: { routes: [] } }, + { virtualIpv4: '10.0.1.1', info: { my_node_info: {}, routes: [] } }, + ])('waits for unconfirmed static IPv4 state: %j', async ({ virtualIpv4, info }) => { + setConfig('A') + setConfig('B') + mocks.configs.get('B')!.virtual_ipv4 = virtualIpv4 + setReady('A', '10.0.0.1') + mocks.networkInfo.set('B', info) + const vpn = await loadVpnModule() + await vpn.onNetworkInstanceChange('A') + + mocks.listNetworkInstanceIds.mockResolvedValue({ running_inst_ids: ['B'] }) + await vpn.onNetworkInstanceChange('A') + + expect(mocks.stopVpn).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(1) + }) + + it('removes a shared member when its DHCP address is withdrawn', async () => { + setConfig('A', false, 'shared0') + setConfig('B', false, 'shared0') + mocks.configs.get('B')!.dhcp = true + setReady('A', '10.0.0.1') + setReady('B', '10.0.1.1') + const vpn = await loadVpnModule() + await vpn.onNetworkInstanceChange('A') + + mocks.networkInfo.set('B', { my_node_info: {}, routes: [] }) + await vpn.onNetworkInstanceUpdate('B') + + expect(mocks.stopVpn).toHaveBeenCalledTimes(1) + expect(mocks.startVpn).toHaveBeenLastCalledWith(expect.objectContaining({ + ipv4Addrs: ['10.0.0.1/24'], + })) + expect(mocks.setTunFd).toHaveBeenLastCalledWith(1, ['A', 'B']) + }) + + it('uses manual routes instead of peer proxy routes', async () => { + setConfig('A') + mocks.configs.get('A')!.enable_manual_routes = true + mocks.configs.get('A')!.routes = ['192.0.2.0/24'] + setReady('A', '10.0.0.1') + const info = mocks.networkInfo.get('A') as { routes: unknown[] } + info.routes = [{ proxy_cidrs: ['10.9.0.0/16'] }] + const vpn = await loadVpnModule() + + await vpn.onNetworkInstanceChange('A') + + expect(mocks.startVpn).toHaveBeenCalledWith(expect.objectContaining({ + routes: ['10.0.0.0/24', '192.0.2.0/24'], + })) + }) + + it('keeps the VPN during pre-run of another instance', async () => { setConfig('A') setConfig('B') setReady('A', '10.0.0.1') @@ -140,7 +431,7 @@ describe('mobile VPN reconciliation ownership', () => { await vpn.prepareVpnService('B') - expect(mocks.stopVpn).toHaveBeenCalledTimes(1) + expect(mocks.stopVpn).not.toHaveBeenCalled() }) it('preserves the VPN while retrying the same instance', async () => { @@ -157,22 +448,25 @@ describe('mobile VPN reconciliation ownership', () => { expect(mocks.stopVpn).not.toHaveBeenCalled() }) - it('ignores an update from an instance that no longer owns the VPN', async () => { + it('reconciles all running members after any member update', async () => { setConfig('A') - setConfig('B') + setConfig('B', false, 'other0') setReady('A', '10.0.0.1') + setReady('B', '10.0.1.1') const vpn = await loadVpnModule() await vpn.onNetworkInstanceChange('A') - await vpn.onNetworkInstanceChange('B') - mocks.collectNetworkInfo.mockClear() + mocks.startVpn.mockClear() + setReady('B', '10.0.1.2') await vpn.onNetworkInstanceUpdate('A') - expect(mocks.collectNetworkInfo).not.toHaveBeenCalled() + expect(mocks.startVpn).toHaveBeenCalledWith(expect.objectContaining({ + ipv4Addrs: ['10.0.0.1/24', '10.0.1.2/24'], + })) }) - it('does not apply an in-flight result after the desired instance changes', async () => { + it('does not apply a stale in-flight network result', async () => { setConfig('A') setConfig('B') setReady('A', '10.0.0.1') @@ -195,7 +489,7 @@ describe('mobile VPN reconciliation ownership', () => { const staleUpdate = vpn.onNetworkInstanceUpdate('A') await collectStarted - const switchToB = vpn.onNetworkInstanceChange('B') + const newerUpdate = vpn.onNetworkInstanceChange('B') resolveNetworkInfo({ info: { map: { @@ -212,13 +506,13 @@ describe('mobile VPN reconciliation ownership', () => { }, }) - await Promise.all([staleUpdate, switchToB]) + await Promise.all([staleUpdate, newerUpdate]) expect(mocks.startVpn).not.toHaveBeenCalled() - expect(mocks.stopVpn).toHaveBeenCalledTimes(1) + expect(mocks.stopVpn).not.toHaveBeenCalled() }) - it('stops a native VPN with unknown ownership before retrying the selected instance', async () => { + it('preserves a native VPN while network info is unavailable', async () => { setConfig('A') mocks.getVpnStatus.mockResolvedValue({ running: true, @@ -230,7 +524,7 @@ describe('mobile VPN reconciliation ownership', () => { await vpn.syncMobileVpnService() - expect(mocks.stopVpn).toHaveBeenCalledTimes(1) + expect(mocks.stopVpn).not.toHaveBeenCalled() expect(mocks.startVpn).not.toHaveBeenCalled() }) }) diff --git a/easytier-gui/src/composables/mobile_vpn.ts b/easytier-gui/src/composables/mobile_vpn.ts index 1cd2adc3..681caab8 100644 --- a/easytier-gui/src/composables/mobile_vpn.ts +++ b/easytier-gui/src/composables/mobile_vpn.ts @@ -1,4 +1,4 @@ -import type { NetworkTypes } from 'easytier-frontend-lib' +import type { MobileTunConfig } from './backend' import { addPluginListener } from '@tauri-apps/api/core' import { Utils } from 'easytier-frontend-lib' import { @@ -9,14 +9,11 @@ import { stop_vpn, type VpnTileAction, } from 'tauri-plugin-vpnservice-api' -import { collectNetworkInfo, getConfig, listNetworkInstanceIds, setTunFd } from './backend' - -type Route = NetworkTypes.Route +import { collectNetworkInfo, getConfig, getMobileTunConfig, listNetworkInstanceIds, setTunFd } from './backend' interface vpnStatus { running: boolean - ipv4Addr: string | null | undefined - ipv4Cidr: number | null | undefined + ipv4Addrs: string[] routes: string[] dns: string | null | undefined } @@ -25,19 +22,32 @@ let vpnReconcileTimer: ReturnType | null = null const VPN_RECONCILE_INTERVAL_MS = 2000 const VPN_RECONCILE_MAX_ATTEMPTS = 60 -let desiredVpnInstanceId: string | undefined -let activeVpnInstanceId: string | undefined let vpnReconcileGeneration = 0 let vpnReconcileAttempts = 0 let vpnReconcileQueue: Promise = Promise.resolve() let vpnPermissionRequest: Promise | null = null let vpnTileActionHandler: ((action: VpnTileAction) => Promise) | undefined let vpnTileActionQueue: Promise = Promise.resolve() +let vpnAttachedInstanceIds: string[] = [] +let vpnStartingInstanceIds: string[] | undefined +let vpnAttachment: Promise | null = null +let vpnErrorHandler: ((error: string) => void) | undefined +let vpnMemberErrors = '' + +export function setMobileVpnErrorHandler(handler?: (error: string) => void) { + vpnErrorHandler = handler +} + +function reportRejectedMembers(config: MobileTunConfig) { + const errors = config.rejected.map(({ instance_id, error }) => `${instance_id}: ${error}`).sort().join('\n') + if (errors && errors !== vpnMemberErrors) + vpnErrorHandler?.(errors) + vpnMemberErrors = errors +} const curVpnStatus: vpnStatus = { running: false, - ipv4Addr: undefined, - ipv4Cidr: undefined, + ipv4Addrs: [], routes: [], dns: undefined, } @@ -108,26 +118,24 @@ function clearVpnReconcileTimer() { } } -function beginVpnReconcile(instanceId?: string) { +function beginVpnReconcile() { clearVpnReconcileTimer() - desiredVpnInstanceId = instanceId vpnReconcileAttempts = 0 vpnReconcileGeneration += 1 return vpnReconcileGeneration } -function isCurrentVpnReconcile(instanceId: string, generation: number) { - return desiredVpnInstanceId === (instanceId || undefined) && vpnReconcileGeneration === generation +function isCurrentVpnReconcile(generation: number) { + return vpnReconcileGeneration === generation } -function scheduleVpnReconcile(instanceId: string, generation: number, reason: string) { - if (!isCurrentVpnReconcile(instanceId, generation)) +function scheduleVpnReconcile(generation: number, reason: string) { + if (!isCurrentVpnReconcile(generation)) return if (vpnReconcileAttempts >= VPN_RECONCILE_MAX_ATTEMPTS) { console.error( 'vpn service reconcile stopped after maximum attempts', - instanceId, VPN_RECONCILE_MAX_ATTEMPTS, reason, ) @@ -139,7 +147,6 @@ function scheduleVpnReconcile(instanceId: string, generation: number, reason: st console.log( 'vpn service is not ready, retrying', JSON.stringify({ - instanceId, attempt: vpnReconcileAttempts, maxAttempts: VPN_RECONCILE_MAX_ATTEMPTS, delayMs: VPN_RECONCILE_INTERVAL_MS, @@ -148,13 +155,13 @@ function scheduleVpnReconcile(instanceId: string, generation: number, reason: st ) vpnReconcileTimer = setTimeout(() => { vpnReconcileTimer = null - void enqueueVpnReconcile(instanceId, generation) + void enqueueVpnReconcile(generation) }, VPN_RECONCILE_INTERVAL_MS) } function resetVpnConfigStatus() { - curVpnStatus.ipv4Addr = undefined - curVpnStatus.ipv4Cidr = undefined + vpnAttachedInstanceIds = [] + curVpnStatus.ipv4Addrs = [] curVpnStatus.routes = [] curVpnStatus.dns = undefined } @@ -162,24 +169,11 @@ function resetVpnConfigStatus() { function syncVpnStatusFromNative(status: Awaited>) { curVpnStatus.running = status?.running ?? false if (!curVpnStatus.running) { - activeVpnInstanceId = undefined resetVpnConfigStatus() return } - const ipv4WithCidr = status?.ipv4Addr - if (ipv4WithCidr?.length) { - const [ipv4Addr, cidr] = ipv4WithCidr.split('/') - curVpnStatus.ipv4Addr = ipv4Addr - - const parsedCidr = Number(cidr) - curVpnStatus.ipv4Cidr = Number.isInteger(parsedCidr) ? parsedCidr : undefined - } - else { - curVpnStatus.ipv4Addr = undefined - curVpnStatus.ipv4Cidr = undefined - } - + curVpnStatus.ipv4Addrs = [...(status?.ipv4Addrs ?? [])] curVpnStatus.routes = [...(status?.routes ?? [])] curVpnStatus.dns = status?.dns ?? undefined } @@ -194,12 +188,21 @@ async function waitVpnStatus(target_status: boolean, timeout_sec: number) { } } +async function detachTunFd() { + try { + await setTunFd(0) + } + catch (e) { + console.error('detach tun fd failed', e) + } +} + async function doStopVpn(force = false) { const wasRunning = curVpnStatus.running if (!force && !wasRunning) { - activeVpnInstanceId = undefined return } + await detachTunFd() console.log('stop vpn') const stop_ret = await stop_vpn() console.log('stop vpn', JSON.stringify((stop_ret))) @@ -207,18 +210,25 @@ async function doStopVpn(force = false) { await waitVpnStatus(false, 3) } - activeVpnInstanceId = undefined resetVpnConfigStatus() } -async function doStartVpn(instanceId: string, ipv4Addr: string, cidr: number, routes: string[], dns?: string) { +async function doStartVpn( + instanceIds: string[], + ipv4Addrs: string[], + routes: string[], + dns: string | undefined, +) { if (curVpnStatus.running) { return } - console.log('start vpn service', ipv4Addr, cidr, routes, dns) + vpnStartingInstanceIds = instanceIds + vpnAttachment = null + + console.log('start vpn service', ipv4Addrs, routes, dns) const request = { - ipv4Addr: `${ipv4Addr}/${cidr}`, + ipv4Addrs, routes, dns, disallowedApplications: ['com.kkrainbow.easytier'], @@ -239,29 +249,50 @@ async function doStartVpn(instanceId: string, ipv4Addr: string, cidr: number, ro if (start_ret?.errorMsg?.length) { throw new Error(start_ret.errorMsg) } - await waitVpnStatus(true, 3) + const actual = await waitVpnAttachment() - curVpnStatus.ipv4Addr = ipv4Addr - curVpnStatus.ipv4Cidr = cidr + curVpnStatus.ipv4Addrs = [...ipv4Addrs] curVpnStatus.routes = routes curVpnStatus.dns = dns - activeVpnInstanceId = instanceId + vpnAttachedInstanceIds = [...actual.instance_ids].sort() + if (JSON.stringify(actual.addresses) !== JSON.stringify(ipv4Addrs) + || JSON.stringify(actual.routes) !== JSON.stringify(routes) + || (actual.dns ?? undefined) !== dns) { + scheduleVpnReconcile(vpnReconcileGeneration, 'attachment_configuration_changed') + } +} + +async function waitVpnAttachment() { + if (!vpnAttachment) + await waitVpnStatus(true, 3) + const actual = await vpnAttachment + if (!actual?.instance_ids.length) + throw new Error('no mobile TUN member was attached') + return actual } async function onVpnServiceStart(payload: any) { console.log('vpn service start', JSON.stringify(payload)) curVpnStatus.running = true if (payload.fd) { - await setTunFd(payload.fd).catch((e) => { + vpnAttachment = setTunFd(payload.fd, vpnStartingInstanceIds) + await vpnAttachment.then(async (actual) => { + reportRejectedMembers(actual) + vpnAttachedInstanceIds = [...actual.instance_ids].sort() + if (!actual.instance_ids.length) + await doStopVpn(true) + }).catch(async (e) => { console.error('set tun fd failed', e) + vpnErrorHandler?.(String(e)) + await doStopVpn(true).catch(stopError => console.error('stop vpn after tun attach failure', stopError)) }) } } async function onVpnServiceStop(payload: any) { console.log('vpn service stop', JSON.stringify(payload)) + await detachTunFd() curVpnStatus.running = false - activeVpnInstanceId = undefined resetVpnConfigStatus() } @@ -290,159 +321,145 @@ async function registerVpnServiceListener() { ) } -function getRoutesForVpn(routes: Route[] | undefined, node_config: NetworkTypes.NetworkConfig): string[] { - const ret = [] - for (const r of routes ?? []) { - for (let cidr of r.proxy_cidrs ?? []) { - if (!cidr.includes('/')) { - cidr += '/32' - } - ret.push(cidr) - } - } - - for (const route of node_config.routes ?? []) { - ret.push(route) - } - - if (node_config.enable_magic_dns) { - ret.push('100.100.100.101/32') - } - - // sort and dedup - return Array.from(new Set(ret)).sort() -} - -async function stopVpnOwnedByOtherInstance(instanceId: string, generation: number) { - if (!isCurrentVpnReconcile(instanceId, generation)) - return false - - if (curVpnStatus.running && activeVpnInstanceId !== instanceId) { - console.warn('vpn service owner changed', activeVpnInstanceId, instanceId) - await doStopVpn() - } - - return isCurrentVpnReconcile(instanceId, generation) -} - -async function reconcileNetworkInstance(instanceId: string, generation: number) { - if (!isCurrentVpnReconcile(instanceId, generation)) +async function reconcileNetworkInstance(generation: number) { + if (!isCurrentVpnReconcile(generation)) return clearVpnReconcileTimer() - if (!instanceId) { - console.warn('vpn service skipped because instance id is empty') - if (curVpnStatus.running) { - await doStopVpn() - } - return - } - const config = await getConfig(instanceId) - if (!isCurrentVpnReconcile(instanceId, generation)) - return - - console.log('vpn service loaded config', instanceId, JSON.stringify({ - no_tun: config.no_tun, - dhcp: config.dhcp, - enable_magic_dns: config.enable_magic_dns, - })) - if (config.no_tun) { - console.log('vpn service skipped because no_tun is enabled', instanceId) - if (activeVpnInstanceId === instanceId) { - await doStopVpn() - } - return - } - - if (!await stopVpnOwnedByOtherInstance(instanceId, generation)) - return - - let curNetworkInfo + let instances: Awaited> try { - curNetworkInfo = (await collectNetworkInfo(instanceId))?.info?.map?.[instanceId] + instances = await findRunningTunInstances() } - catch (e) { - console.warn('vpn service network info query failed', instanceId, e) - scheduleVpnReconcile(instanceId, generation, 'network_info_query_failed') + catch (error) { + console.warn('vpn service instance query failed', error) + scheduleVpnReconcile(generation, 'instance_list_unavailable') return } - if (!isCurrentVpnReconcile(instanceId, generation)) + if (!isCurrentVpnReconcile(generation)) return - if (!curNetworkInfo) { - scheduleVpnReconcile(instanceId, generation, 'network_info_unavailable') + if (!instances.length) { + if (curVpnStatus.running) + await doStopVpn() return } - if (curNetworkInfo.error_msg?.length) { - console.warn('vpn service skipped because network instance failed', instanceId, curNetworkInfo.error_msg) + let retryReason: string | undefined + let networkInfoUnavailable = false + + for (const { instanceId: memberId, config } of instances) { + let curNetworkInfo + try { + curNetworkInfo = (await collectNetworkInfo(memberId)).info.map[memberId] + } + catch (error) { + console.warn('vpn service network info query failed', memberId, error) + retryReason ??= 'network_info_query_failed' + networkInfoUnavailable = true + continue + } + + if (!isCurrentVpnReconcile(generation)) + return + + if (!curNetworkInfo) { + console.warn('vpn service network info unavailable', memberId) + retryReason ??= 'network_info_unavailable' + networkInfoUnavailable = true + continue + } + + if (curNetworkInfo.error_msg?.length) { + console.warn('vpn service network failed', memberId, curNetworkInfo.error_msg) + retryReason ??= 'network_failed' + continue + } + + const virtualIpv4 = curNetworkInfo.my_node_info?.virtual_ipv4 + const virtualIp = virtualIpv4?.address?.addr ? Utils.ipv4ToString(virtualIpv4.address) : undefined + if (!virtualIp) { + if (!config.dhcp && !config.virtual_ipv4 && curNetworkInfo.my_node_info) + continue + retryReason ??= config.dhcp ? 'dhcp_ipv4_unavailable' : 'static_ipv4_unavailable' + if (!config.dhcp) + networkInfoUnavailable = true + continue + } + + } + + if (!isCurrentVpnReconcile(generation)) + return + + if (networkInfoUnavailable && curVpnStatus.running) { + scheduleVpnReconcile(generation, retryReason || 'network_info_unavailable') + return + } + + const preferredIds = [...instances].sort((a, b) => + Number(vpnAttachedInstanceIds.includes(b.instanceId)) - Number(vpnAttachedInstanceIds.includes(a.instanceId)) + || a.instanceId.localeCompare(b.instanceId)).map(({ instanceId }) => instanceId) + let config: MobileTunConfig + try { + config = await getMobileTunConfig(preferredIds) + } + catch (error) { + console.error('mobile TUN configuration query failed', error) + scheduleVpnReconcile(generation, 'tun_configuration_unavailable') + return + } + if (!isCurrentVpnReconcile(generation)) + return + reportRejectedMembers(config) + if (!config.addresses.length) { + if (retryReason) + scheduleVpnReconcile(generation, retryReason) + if (curVpnStatus.running) + await doStopVpn() + return + } + + if (retryReason) + scheduleVpnReconcile(generation, retryReason) + else vpnReconcileAttempts = 0 - await doStopVpn() - return - } + const sortedIpv4Addrs = [...config.addresses].sort() + const sortedRoutes = [...config.routes].sort() + const instanceIds = [...config.instance_ids].sort() + const dns = config.dns ?? undefined + // A replacement runtime needs its own attachment even with identical OS routes. + const membersChanged = JSON.stringify(instanceIds) !== JSON.stringify(vpnAttachedInstanceIds) + const configChanged + = JSON.stringify(sortedIpv4Addrs) !== JSON.stringify(curVpnStatus.ipv4Addrs) + || JSON.stringify(sortedRoutes) !== JSON.stringify(curVpnStatus.routes) + || dns !== curVpnStatus.dns - const virtualIpv4 = curNetworkInfo.my_node_info?.virtual_ipv4 - const virtual_ip = virtualIpv4?.address?.addr ? Utils.ipv4ToString(virtualIpv4.address) : undefined - - if (!virtual_ip || !virtual_ip.length) { - scheduleVpnReconcile( - instanceId, - generation, - config.dhcp ? 'dhcp_ipv4_unavailable' : 'static_ipv4_unavailable', - ) - return - } - - vpnReconcileAttempts = 0 - - let network_length = virtualIpv4?.network_length - if (!network_length) { - network_length = 24 - } - - const routes = getRoutesForVpn(curNetworkInfo?.routes, config) - - const dns = config.enable_magic_dns ? '100.100.100.101' : undefined - - const ipChanged = virtual_ip !== curVpnStatus.ipv4Addr - const cidrChanged = network_length !== curVpnStatus.ipv4Cidr - const routesChanged = JSON.stringify(routes) !== JSON.stringify(curVpnStatus.routes) - const dnsChanged = dns != curVpnStatus.dns - const configChanged = ipChanged || cidrChanged || routesChanged || dnsChanged - const shouldStartVpn = !curVpnStatus.running - - if (shouldStartVpn || configChanged) { - console.info('vpn service virtual ip changed', JSON.stringify(curVpnStatus), virtual_ip) + if (!curVpnStatus.running || configChanged || membersChanged) { if (curVpnStatus.running) { try { await doStopVpn() } - catch (e) { - console.error(e) + catch (error) { + console.error('stop vpn service failed', error) } } + if (!isCurrentVpnReconcile(generation)) + return + try { - if (!isCurrentVpnReconcile(instanceId, generation)) - return - - await doStartVpn(instanceId, virtual_ip, network_length, routes, dns) - if (!isCurrentVpnReconcile(instanceId, generation) && activeVpnInstanceId === instanceId) { + await doStartVpn(instanceIds, sortedIpv4Addrs, sortedRoutes, dns) + if (!isCurrentVpnReconcile(generation)) await doStopVpn() - } } - catch (e) { - if (e instanceof Error && e.message === 'need_prepare') { - console.info('vpn permission is required before starting the Android VPN service') - return - } - if (e instanceof Error && e.message === 'vpn_permission_denied') { + catch (error) { + if (error instanceof Error && error.message === 'vpn_permission_denied') { console.info('vpn permission request was denied or dismissed') return } - console.error('start vpn service failed', e) + console.error('start vpn service failed', error) } } } @@ -459,42 +476,24 @@ function enqueueVpnTask(task: () => Promise) { return run } -function enqueueVpnReconcile(instanceId: string, generation: number) { - return enqueueVpnTask(() => reconcileNetworkInstance(instanceId, generation)) +function enqueueVpnReconcile(generation: number) { + return enqueueVpnTask(() => reconcileNetworkInstance(generation)) } export async function onNetworkInstanceChange(instanceId: string) { - const generation = beginVpnReconcile(instanceId || undefined) + // A restarted instance keeps its ID but loses the previous runtime's TUN attachment. + vpnAttachedInstanceIds = vpnAttachedInstanceIds.filter(id => id !== instanceId) + await onNetworkInstancesChange() +} - if (instanceId && await isNoTunEnabled(instanceId)) { - if (vpnReconcileGeneration !== generation) - return - - if (activeVpnInstanceId === instanceId) { - desiredVpnInstanceId = undefined - await enqueueVpnReconcile('', generation) - return - } - - desiredVpnInstanceId = activeVpnInstanceId - if (activeVpnInstanceId) { - await enqueueVpnReconcile(activeVpnInstanceId, generation) - } - return - } - - if (vpnReconcileGeneration !== generation) - return - - await enqueueVpnReconcile(instanceId, generation) +export async function onNetworkInstancesChange() { + await enqueueVpnReconcile(beginVpnReconcile()) } export async function onNetworkInstanceUpdate(instanceId: string) { - if (!instanceId || instanceId !== desiredVpnInstanceId) + if (!instanceId) return - - const generation = beginVpnReconcile(instanceId) - await enqueueVpnReconcile(instanceId, generation) + await onNetworkInstancesChange() } async function isNoTunEnabled(instanceId: string | undefined) { @@ -504,20 +503,19 @@ async function isNoTunEnabled(instanceId: string | undefined) { return (await getConfig(instanceId)).no_tun ?? false } -async function findRunningTunInstanceId() { +async function findRunningTunInstances() { const instanceIds = await listNetworkInstanceIds() const runningIds = (instanceIds.running_inst_ids ?? []).map(Utils.UuidToStr) - console.log('vpn service sync running instances', JSON.stringify(runningIds)) + const runningTunInstances = [] for (const instanceId of runningIds) { - if (await isNoTunEnabled(instanceId)) { + const config = await getConfig(instanceId) + if (config.no_tun) continue - } - - return instanceId + runningTunInstances.push({ instanceId, config }) } - return undefined + return runningTunInstances } export async function initMobileVpnService() { @@ -529,21 +527,10 @@ export async function prepareVpnService(instanceId: string) { return } - const generation = beginVpnReconcile(instanceId) - const stopPreviousOwner = enqueueVpnTask(async () => { - await stopVpnOwnedByOtherInstance(instanceId, generation) - }) - await Promise.all([requestVpnPermission(), stopPreviousOwner]) + await requestVpnPermission() } export async function syncMobileVpnService() { syncVpnStatusFromNative(await get_vpn_status()) - const instanceId = await findRunningTunInstanceId() - if (instanceId) { - console.log('vpn service sync selected instance', instanceId) - await onNetworkInstanceChange(instanceId) - return - } - await onNetworkInstanceChange('') } diff --git a/easytier-gui/src/composables/mobile_vpn_tile.test.ts b/easytier-gui/src/composables/mobile_vpn_tile.test.ts index 71d8d5ed..8b5376a0 100644 --- a/easytier-gui/src/composables/mobile_vpn_tile.test.ts +++ b/easytier-gui/src/composables/mobile_vpn_tile.test.ts @@ -36,6 +36,45 @@ describe('vpn quick settings tile actions', () => { syncVpnService.mockClear() }) + it('restores the whole stopped group across tile stop and start', async () => { + const running = ['first', 'last', 'headless'] + const disabled: string[] = [] + const { api, configs } = createApi(running, disabled, ['headless']) + let stoppedInstanceIds: string[] = [] + const saveStoppedInstanceIds = (ids: string[]) => { stoppedInstanceIds = ids } + const result = await executeVpnTileAction('stop', api, { lastInstanceId: 'last', syncVpnService, saveStoppedInstanceIds }) + expect(stoppedInstanceIds).toEqual(['first', 'last']) + disabled.push('first', 'last') + running.splice(0, 2) + await executeVpnTileAction('start', api, { lastInstanceId: result.instanceId, stoppedInstanceIds, syncVpnService, saveStoppedInstanceIds }) + expect(api.run_network).toHaveBeenCalledWith(configs.get('first'), true) + expect(api.run_network).toHaveBeenCalledWith(configs.get('last'), true) + expect(api.run_network).toHaveBeenCalledTimes(2) + expect(stoppedInstanceIds).toEqual([]) + }) + + it('skips removed and no_tun members when restoring the stopped group', async () => { + const { api, configs } = createApi(['running'], ['first', 'headless'], ['headless']) + await executeVpnTileAction('start', api, { stoppedInstanceIds: ['removed', 'headless', 'first', 'running', 'first'], syncVpnService }) + expect(api.run_network).toHaveBeenCalledOnce() + expect(api.run_network).toHaveBeenCalledWith(configs.get('first'), true) + }) + + it.each(['removed', 'headless'])('does not start another network when the saved group only contains %s', async (stoppedId) => { + const { api } = createApi([], ['unrelated', 'headless'], ['headless']) + const saveStoppedInstanceIds = vi.fn() + const result = await executeVpnTileAction('start', api, { + lastInstanceId: 'unrelated', + stoppedInstanceIds: [stoppedId], + syncVpnService, + saveStoppedInstanceIds, + }) + expect(api.run_network).not.toHaveBeenCalled() + expect(saveStoppedInstanceIds).toHaveBeenCalledWith([]) + expect(syncVpnService).not.toHaveBeenCalled() + expect(result).toEqual({ action: 'start', changed: false }) + }) + it('starts the last configured network and reconciles the Android VPN', async () => { const { api, configs } = createApi([], ['first', 'last']) @@ -88,7 +127,7 @@ describe('vpn quick settings tile actions', () => { expect(result.changed).toBe(false) }) - it('stops the last running network, falling back to the first running network', async () => { + it('stops all running TUN networks when the last network is disabled', async () => { const { api } = createApi(['first', 'second'], ['disabled']) const result = await executeVpnTileAction('stop', api, { @@ -97,10 +136,27 @@ describe('vpn quick settings tile actions', () => { }) expect(api.update_network_instance_state).toHaveBeenCalledWith('first', true) + expect(api.update_network_instance_state).toHaveBeenCalledWith('second', true) + expect(api.update_network_instance_state).toHaveBeenCalledTimes(2) expect(syncVpnService).toHaveBeenCalledOnce() expect(result).toEqual({ action: 'stop', instanceId: 'first', changed: true }) }) + it('stops all running TUN networks while preserving no_tun networks', async () => { + const { api } = createApi(['headless', 'first', 'last'], ['disabled'], ['headless']) + + const result = await executeVpnTileAction('stop', api, { + lastInstanceId: 'last', + syncVpnService, + }) + + expect(api.update_network_instance_state).toHaveBeenCalledWith('first', true) + expect(api.update_network_instance_state).toHaveBeenCalledWith('last', true) + expect(api.update_network_instance_state).toHaveBeenCalledTimes(2) + expect(syncVpnService).toHaveBeenCalledOnce() + expect(result).toEqual({ action: 'stop', instanceId: 'last', changed: true }) + }) + it('does not select a running no_tun network for stop', async () => { const { api } = createApi(['headless', 'vpn'], [], ['headless']) diff --git a/easytier-gui/src/composables/mobile_vpn_tile.ts b/easytier-gui/src/composables/mobile_vpn_tile.ts index 47ee1b3a..4b68ff5f 100644 --- a/easytier-gui/src/composables/mobile_vpn_tile.ts +++ b/easytier-gui/src/composables/mobile_vpn_tile.ts @@ -10,6 +10,8 @@ export interface VpnTileActionResult { export interface VpnTileActionOptions { lastInstanceId?: string | null + stoppedInstanceIds?: string[] + saveStoppedInstanceIds?: (instanceIds: string[]) => void syncVpnService: () => Promise } @@ -31,22 +33,39 @@ export async function executeVpnTileAction( })), ) const candidates = candidateIds.filter(instanceId => !candidateConfigs.get(instanceId)?.no_tun) - const instanceId = options.lastInstanceId && candidates.includes(options.lastInstanceId) + const stoppedCandidates = [...new Set(options.stoppedInstanceIds ?? [])] + .filter(instanceId => candidates.includes(instanceId)) + const restoringStoppedGroup = action === 'start' && !!options.stoppedInstanceIds?.length + const selectedCandidates = restoringStoppedGroup + ? stoppedCandidates + : candidates + const instanceId = options.lastInstanceId && selectedCandidates.includes(options.lastInstanceId) ? options.lastInstanceId - : candidates[0] + : selectedCandidates[0] if (!instanceId) { + if (restoringStoppedGroup) + options.saveStoppedInstanceIds?.([]) return { action, changed: false } } let changed = false - if (action === 'start' && !runningIds.includes(instanceId)) { - const config = candidateConfigs.get(instanceId)! - await api.run_network(config, true) - changed = true + if (action === 'start') { + const startIds = restoringStoppedGroup ? stoppedCandidates : [instanceId] + for (const candidateId of startIds) { + if (!runningIds.includes(candidateId)) { + await api.run_network(candidateConfigs.get(candidateId)!, true) + changed = true + } + } + options.saveStoppedInstanceIds?.([]) } else if (action === 'stop') { - await api.update_network_instance_state(instanceId, true) + // Save before stopping so a partially completed action can also be resumed. + options.saveStoppedInstanceIds?.([...candidates]) + for (const candidateId of candidates) { + await api.update_network_instance_state(candidateId, true) + } changed = true } diff --git a/easytier-gui/src/pages/index.vue b/easytier-gui/src/pages/index.vue index 2ce5d455..53b98a78 100644 --- a/easytier-gui/src/pages/index.vue +++ b/easytier-gui/src/pages/index.vue @@ -13,6 +13,7 @@ import { consumePendingMobileVpnTileAction, initMobileVpnService, setMobileVpnTileActionHandler, + setMobileVpnErrorHandler, syncMobileVpnService, } from '~/composables/mobile_vpn' import { executeVpnTileAction } from '~/composables/mobile_vpn_tile' @@ -20,7 +21,12 @@ import { GUIRemoteClient } from '~/modules/api' import { useToast, useConfirm } from 'primevue' import { loadMode, saveMode, WebClientConfig, type Mode } from '~/composables/mode' -import { saveLastNetworkInstanceId, loadLastNetworkInstanceId } from '~/composables/config' +import { + saveLastNetworkInstanceId, + loadLastNetworkInstanceId, + loadVpnTileStoppedInstanceIds, + saveVpnTileStoppedInstanceIds, +} from '~/composables/config' import ModeSwitcher from '~/components/ModeSwitcher.vue' import { getEasytierVersion, getServiceStatus } from '~/composables/backend' @@ -227,6 +233,8 @@ onMounted(async () => { const cleanupFns: Array<() => void> = [] if (type() === 'android') { + setMobileVpnErrorHandler(error => toast.add({ severity: 'error', summary: t('error'), detail: error, life: 10000 })) + cleanupFns.push(() => setMobileVpnErrorHandler()) try { await initMobileVpnService() } catch (e: any) { @@ -265,6 +273,8 @@ async function handleMobileVpnTileAction(action: 'start' | 'stop') { try { const result = await executeVpnTileAction(action, remoteClient.value, { lastInstanceId: loadLastNetworkInstanceId(), + stoppedInstanceIds: loadVpnTileStoppedInstanceIds(), + saveStoppedInstanceIds: saveVpnTileStoppedInstanceIds, syncVpnService: syncMobileVpnService, }) diff --git a/tauri-plugin-vpnservice/android/src/main/java/TauriVpnService.kt b/tauri-plugin-vpnservice/android/src/main/java/TauriVpnService.kt index d8c1a5d4..73f7cbdb 100644 --- a/tauri-plugin-vpnservice/android/src/main/java/TauriVpnService.kt +++ b/tauri-plugin-vpnservice/android/src/main/java/TauriVpnService.kt @@ -10,6 +10,7 @@ import android.os.ParcelFileDescriptor import android.os.Bundle import android.content.pm.ServiceInfo import androidx.core.app.NotificationCompat +import android.system.OsConstants.AF_INET6 import java.net.InetAddress import java.util.Arrays @@ -20,10 +21,12 @@ class TauriVpnService : VpnService() { @JvmField var triggerCallback: (String, JSObject) -> Unit = { _, _ -> } @JvmField var self: TauriVpnService? = null @JvmField var ipv4Addr: String? = null + @JvmField var ipv4Addrs: Array = emptyArray() @JvmField var routes: Array = emptyArray() @JvmField var dns: String? = null const val IPV4_ADDR = "IPV4_ADDR" + const val IPV4_ADDRS = "IPV4_ADDRS" const val ROUTES = "ROUTES" const val DNS = "DNS" const val DISALLOWED_APPLICATIONS = "DISALLOWED_APPLICATIONS" @@ -39,7 +42,8 @@ class TauriVpnService : VpnService() { println("vpn on start command ${intent?.getExtras()} $intent") startVpnForegroundService() var args = intent?.getExtras() - ipv4Addr = args?.getString(IPV4_ADDR) + ipv4Addrs = getIpv4Addrs(args) + ipv4Addr = ipv4Addrs.firstOrNull() routes = args?.getStringArray(ROUTES) ?: emptyArray() dns = args?.getString(DNS) @@ -90,6 +94,7 @@ class TauriVpnService : VpnService() { private fun clearStatus() { ipv4Addr = null + ipv4Addrs = emptyArray() routes = emptyArray() dns = null } @@ -159,25 +164,40 @@ class TauriVpnService : VpnService() { } } + private fun getIpv4Addrs(args: Bundle?): Array { + val ipv4Addrs = args + ?.getStringArray(IPV4_ADDRS) + ?.filter { it.isNotBlank() } + ?.toTypedArray() + ?: emptyArray() + if (ipv4Addrs.isNotEmpty()) { + return ipv4Addrs + } + + return arrayOf(args?.getString(IPV4_ADDR) ?: "10.126.126.1/24") + } + private fun createVpnInterface(args: Bundle?): ParcelFileDescriptor { var builder = Builder() .setSession("TauriVpnService") .setBlocking(false) var mtu = args?.getInt(MTU) ?: 1500 - var ipv4Addr = args?.getString(IPV4_ADDR) ?: "10.126.126.1/24" + var ipv4Addrs = getIpv4Addrs(args) var dns: String? = args?.getString(DNS) var routes = args?.getStringArray(ROUTES) ?: emptyArray() var disallowedApplications = args?.getStringArray(DISALLOWED_APPLICATIONS) ?: emptyArray() - println("vpn create vpn interface. mtu: $mtu, ipv4Addr: $ipv4Addr, dns:" + + println("vpn create vpn interface. mtu: $mtu, ipv4Addrs: ${java.util.Arrays.toString(ipv4Addrs)}, dns:" + "$dns, routes: ${java.util.Arrays.toString(routes)}," + "disallowedApplications: ${java.util.Arrays.toString(disallowedApplications)}") - val ipParts = ipv4Addr.split("/") - if (ipParts.size != 2) throw IllegalArgumentException("Invalid IP addr string") - builder.addAddress(ipParts[0], ipParts[1].toInt()) - builder.addAddress("fd00::1", 128) + for (ipv4Addr in ipv4Addrs) { + val ipParts = ipv4Addr.split("/") + if (ipParts.size != 2) throw IllegalArgumentException("Invalid IP addr string") + builder.addAddress(ipParts[0], ipParts[1].toInt()) + } + builder.allowFamily(AF_INET6) builder.setMtu(mtu) dns?.let { builder.addDnsServer(it) } diff --git a/tauri-plugin-vpnservice/android/src/main/java/VpnServicePlugin.kt b/tauri-plugin-vpnservice/android/src/main/java/VpnServicePlugin.kt index 25fda216..f90c91a0 100644 --- a/tauri-plugin-vpnservice/android/src/main/java/VpnServicePlugin.kt +++ b/tauri-plugin-vpnservice/android/src/main/java/VpnServicePlugin.kt @@ -12,6 +12,7 @@ import app.tauri.plugin.Invoke import app.tauri.plugin.JSObject import app.tauri.plugin.Plugin import android.webkit.WebView +import org.json.JSONArray @InvokeArg class PingArgs { @@ -21,6 +22,7 @@ class PingArgs { @InvokeArg class StartVpnArgs { var ipv4Addr: String? = null + var ipv4Addrs: Array = emptyArray() var routes: Array = emptyArray() var dns: String? = null var disallowedApplications: Array = emptyArray() @@ -106,6 +108,7 @@ class VpnServicePlugin(private val activity: Activity) : Plugin(activity) { } else { val intent = Intent(activity, TauriVpnService::class.java) intent.putExtra(TauriVpnService.IPV4_ADDR, args.ipv4Addr) + intent.putExtra(TauriVpnService.IPV4_ADDRS, args.ipv4Addrs) intent.putExtra(TauriVpnService.ROUTES, args.routes) intent.putExtra(TauriVpnService.DNS, args.dns) intent.putExtra(TauriVpnService.DISALLOWED_APPLICATIONS, args.disallowedApplications) @@ -137,7 +140,8 @@ class VpnServicePlugin(private val activity: Activity) : Plugin(activity) { val ret = JSObject() ret.put("running", TauriVpnService.self != null) ret.put("ipv4Addr", TauriVpnService.ipv4Addr) - ret.put("routes", TauriVpnService.routes) + ret.put("ipv4Addrs", JSONArray(TauriVpnService.ipv4Addrs)) + ret.put("routes", JSONArray(TauriVpnService.routes)) ret.put("dns", TauriVpnService.dns) invoke.resolve(ret) } diff --git a/tauri-plugin-vpnservice/guest-js/index.ts b/tauri-plugin-vpnservice/guest-js/index.ts index 2044ed5e..a736cfca 100644 --- a/tauri-plugin-vpnservice/guest-js/index.ts +++ b/tauri-plugin-vpnservice/guest-js/index.ts @@ -15,6 +15,7 @@ export interface InvokeResponse { export interface StartVpnRequest { ipv4Addr?: string; + ipv4Addrs?: string[]; routes?: string[]; dns?: string; disallowedApplications?: string[]; @@ -24,6 +25,7 @@ export interface StartVpnRequest { export interface VpnStatusResponse { running: boolean; ipv4Addr?: string; + ipv4Addrs?: string[]; routes?: string[]; dns?: string; } diff --git a/tauri-plugin-vpnservice/src/models.rs b/tauri-plugin-vpnservice/src/models.rs index a1b8a869..aaf018af 100644 --- a/tauri-plugin-vpnservice/src/models.rs +++ b/tauri-plugin-vpnservice/src/models.rs @@ -22,6 +22,7 @@ pub struct VoidRequest {} #[serde(rename_all = "camelCase")] pub struct StartVpnRequest { pub ipv4_addr: Option, + pub ipv4_addrs: Option>, pub routes: Option>, pub dns: Option, pub disallowed_applications: Option>, @@ -39,6 +40,7 @@ pub struct Status { pub struct VpnStatus { pub running: bool, pub ipv4_addr: Option, + pub ipv4_addrs: Option>, pub routes: Option>, pub dns: Option, }