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, }