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 f966c155..2306215a 100644 --- a/easytier-gui/src-tauri/src/lib.rs +++ b/easytier-gui/src-tauri/src/lib.rs @@ -4,6 +4,8 @@ mod elevate; use anyhow::Context; +#[cfg(any(target_os = "android", target_os = "ios"))] +use easytier::instance::factory::attach_mobile_tun_fd; #[cfg(target_os = "android")] use easytier::instance::factory::subscribe_native_instance_event; use easytier::proto::api::config::{ @@ -248,15 +250,24 @@ async fn set_tun_fd(fd: i32) -> Result<(), String> { 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) + .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())?; + 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(()) } - Ok(()) } #[tauri::command] @@ -786,11 +797,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)] @@ -1009,44 +1015,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 let Some(instance_id) = self.get_enabled_instances_with_tun_ids().next() { + app.emit("vpn_service_config_changed", instance_id.to_string()) .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(()) } @@ -1060,28 +1043,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, @@ -1110,7 +1071,8 @@ 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(_, _)) => { @@ -1193,10 +1155,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), ); } @@ -1309,13 +1272,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()); - } } } diff --git a/easytier-gui/src/composables/backend.ts b/easytier-gui/src/composables/backend.ts index a66ca615..750cec64 100644 --- a/easytier-gui/src/composables/backend.ts +++ b/easytier-gui/src/composables/backend.ts @@ -127,7 +127,7 @@ export async function sendConfigs(enabledNetworks: string[]) { config: NetworkTypes.toBackendNetworkConfig(config), source, })), - enabledNetworks + enabledNetworks, }) } diff --git a/easytier-gui/src/composables/event.ts b/easytier-gui/src/composables/event.ts index 2b7a029e..47c609a9 100644 --- a/easytier-gui/src/composables/event.ts +++ b/easytier-gui/src/composables/event.ts @@ -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,14 @@ async function onVpnServiceStop(event: Event) { await syncMobileVpnService(); } +async function onVpnServiceConfigChanged(event: Event) { + const instanceId = normalizeInstanceIdPayload(event.payload) + console.log(`Received event '${EVENTS.VPN_SERVICE_CONFIG_CHANGED}' for instance: ${instanceId}`) + if (type() === 'android') { + await onNetworkInstanceChange(instanceId); + } +} + async function onDhcpIpChanged(event: Event) { const instanceId = normalizeInstanceIdPayload(event.payload) console.log(`Received event '${EVENTS.DHCP_IP_CHANGED}' for instance: ${instanceId}`); @@ -105,6 +114,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..0a157e0b 100644 --- a/easytier-gui/src/composables/mobile_vpn.test.ts +++ b/easytier-gui/src/composables/mobile_vpn.test.ts @@ -58,13 +58,15 @@ vi.mock('./backend', () => ({ setTunFd: mocks.setTunFd, })) -function setConfig(instanceId: string, noTun = false) { +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) { @@ -106,30 +108,110 @@ beforeEach(() => { mocks.stopVpn.mockClear() }) -describe('mobile VPN reconciliation ownership', () => { - it('stops A before retrying an unavailable B, then starts B when it becomes ready', async () => { - setConfig('A') - setConfig('B') +describe('mobile VPN reconciliation', () => { + 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) 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) }) - it('stops the previous owner during pre-run even if the new instance never reaches post-run', async () => { + 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) + }) + + 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 +222,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 +239,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 +280,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 +297,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 +315,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..b0a50034 100644 --- a/easytier-gui/src/composables/mobile_vpn.ts +++ b/easytier-gui/src/composables/mobile_vpn.ts @@ -1,6 +1,7 @@ import type { NetworkTypes } from 'easytier-frontend-lib' import { addPluginListener } from '@tauri-apps/api/core' import { Utils } from 'easytier-frontend-lib' +import { IPv4CidrRange } from 'ip-num/IPRange' import { consume_vpn_tile_action, get_vpn_status, @@ -15,8 +16,7 @@ type Route = NetworkTypes.Route interface vpnStatus { running: boolean - ipv4Addr: string | null | undefined - ipv4Cidr: number | null | undefined + ipv4Addrs: string[] routes: string[] dns: string | null | undefined } @@ -25,8 +25,6 @@ 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() @@ -36,8 +34,7 @@ let vpnTileActionQueue: Promise = Promise.resolve() const curVpnStatus: vpnStatus = { running: false, - ipv4Addr: undefined, - ipv4Cidr: undefined, + ipv4Addrs: [], routes: [], dns: undefined, } @@ -108,26 +105,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 +134,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 +142,12 @@ 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 + curVpnStatus.ipv4Addrs = [] curVpnStatus.routes = [] curVpnStatus.dns = undefined } @@ -162,24 +155,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 +174,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 +196,21 @@ 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( + ipv4Addrs: string[], + routes: string[], + dns: string | undefined, +) { if (curVpnStatus.running) { return } - console.log('start vpn service', ipv4Addr, cidr, routes, dns) + console.log('start vpn service', ipv4Addrs, routes, dns) const request = { - ipv4Addr: `${ipv4Addr}/${cidr}`, + ipv4Addrs, routes, dns, disallowedApplications: ['com.kkrainbow.easytier'], @@ -241,27 +233,26 @@ async function doStartVpn(instanceId: string, ipv4Addr: string, cidr: number, ro } await waitVpnStatus(true, 3) - curVpnStatus.ipv4Addr = ipv4Addr - curVpnStatus.ipv4Cidr = cidr + curVpnStatus.ipv4Addrs = [...ipv4Addrs] curVpnStatus.routes = routes curVpnStatus.dns = dns - activeVpnInstanceId = instanceId } 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) => { + await setTunFd(payload.fd).catch(async (e) => { console.error('set tun fd failed', 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() } @@ -292,157 +283,169 @@ 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) - } + if (node_config.enable_manual_routes) { + ret.push(...(node_config.routes ?? [])) } - - for (const route of node_config.routes ?? []) { - ret.push(route) + else { + for (const r of routes ?? []) { + for (let cidr of r.proxy_cidrs ?? []) { + if (!cidr.includes('/')) { + cidr += '/32' + } + ret.push(cidr) + } + } } if (node_config.enable_magic_dns) { ret.push('100.100.100.101/32') } - // sort and dedup - return Array.from(new Set(ret)).sort() + return ret } -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() +function ipv4CidrToRoute(cidr: string): string | undefined { + try { + const range = IPv4CidrRange.fromCidr(cidr) + return `${range.getFirst()}/${range.getPrefix()}` + } + catch { + return undefined } - - 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) + const ipv4Addrs: string[] = [] + const routes = new Set() + let dns: string | undefined + 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) { + retryReason ??= config.dhcp ? 'dhcp_ipv4_unavailable' : 'static_ipv4_unavailable' + if (!config.dhcp) + networkInfoUnavailable = true + continue + } + + const networkLength = virtualIpv4?.network_length || 24 + const sourceIpv4 = virtualIp + '/' + networkLength + ipv4Addrs.push(sourceIpv4) + const localRoute = ipv4CidrToRoute(sourceIpv4) + if (localRoute) + routes.add(localRoute) + getRoutesForVpn(curNetworkInfo.routes, config).forEach((route) => { + routes.add(route) + }) + if (config.enable_magic_dns) + dns = '100.100.100.101' + } + + if (!isCurrentVpnReconcile(generation)) + return + + if (networkInfoUnavailable && curVpnStatus.running) { + scheduleVpnReconcile(generation, retryReason || 'network_info_unavailable') + return + } + + if (!ipv4Addrs.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 = [...ipv4Addrs].sort() + const sortedRoutes = [...routes].sort() + 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) { 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(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 +462,18 @@ 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) - - 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 onNetworkInstanceChange(_instanceId: string) { + 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 onNetworkInstanceChange(instanceId) } async function isNoTunEnabled(instanceId: string | undefined) { @@ -504,20 +483,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 +507,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/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, }