shared-tun: preserve member ownership on mobile

Introduce shared NIC source ownership and dispatcher handling so a
single dev_name can be shared by multiple tun-enabled instances while
keeping per-member IP and route claims distinct.

Pass Android VpnService fd registration with per-instance source and
route claims. Keep the VPN address list limited to real member
addresses and allow AF_INET6 without installing hidden fd00::1.

Invalidate dispatcher flow and NAT state when source ownership changes
or a member unregisters. Avoid rewriting non-first IPv4 fragment
payloads, and adjust fragmented TCP/UDP checksums without recomputing
over partial fragment bodies.

Preserve source-owner routing for equal-prefix route conflicts, keep
ICMP echo NAT entries distinct by echo id, and retry stale flow-owner
send failures from the original packet. Only record NAT state after a
translated packet is accepted by its member.

Apply Linux IPv4 route preferred-source hints for shared routes and keep
route repair paths source-aware. Keep Darwin ifcfg access scoped to
cleanup-only paths where netns is not available.
This commit is contained in:
sijie.sun
2026-06-16 02:16:25 +08:00
parent ca17e856a6
commit 800c840bb5
13 changed files with 2423 additions and 175 deletions
+93 -5
View File
@@ -66,6 +66,9 @@ static RPC_SERVER: once_cell::sync::Lazy<Mutex<Option<RpcServer>>> =
static WEB_CLIENT: once_cell::sync::Lazy<RwLock<Option<WebClient>>> =
once_cell::sync::Lazy::new(|| RwLock::new(None));
#[cfg(target_os = "android")]
const ANDROID_SHARED_TUN_DEV_NAME: &str = "easytier-shared";
macro_rules! get_client_manager {
() => {{
let guard = CLIENT_MANAGER
@@ -76,6 +79,17 @@ macro_rules! get_client_manager {
}};
}
fn normalize_network_config_for_runtime(cfg: &mut NetworkConfig) {
#[cfg(target_os = "android")]
{
if !cfg.no_tun() && cfg.dev_name.as_deref().map(str::is_empty).unwrap_or(true) {
cfg.dev_name = Some(ANDROID_SHARED_TUN_DEV_NAME.to_owned());
}
}
#[cfg(not(target_os = "android"))]
let _ = cfg;
}
#[tauri::command]
fn easytier_version() -> Result<String, String> {
Ok(easytier::VERSION.to_string())
@@ -100,6 +114,8 @@ fn set_dock_visibility(app: tauri::AppHandle, visible: bool) -> Result<(), Strin
#[tauri::command]
fn parse_network_config(cfg: NetworkConfig) -> Result<String, String> {
let mut cfg = cfg;
normalize_network_config_for_runtime(&mut cfg);
let toml = cfg.gen_config().map_err(|e| e.to_string())?;
Ok(toml.dump())
}
@@ -117,6 +133,8 @@ async fn run_network_instance(
cfg: NetworkConfig,
save: bool,
) -> Result<(), String> {
let mut cfg = cfg;
normalize_network_config_for_runtime(&mut cfg);
let client_manager = get_client_manager!()?;
let toml_config = cfg.gen_config().map_err(|e| e.to_string())?;
client_manager
@@ -155,16 +173,84 @@ async fn set_logging_level(level: String) -> Result<(), String> {
Ok(())
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct TunFdInstanceSources {
instance_id: String,
ipv4_addrs: Vec<String>,
#[serde(default)]
ipv6_addrs: Vec<String>,
#[serde(default)]
ipv4_routes: Vec<String>,
#[serde(default)]
ipv6_routes: Vec<String>,
}
#[cfg(mobile)]
fn parse_tun_fd_instance_sources(
instance_sources: Vec<TunFdInstanceSources>,
) -> Result<
std::collections::HashMap<uuid::Uuid, easytier::instance::virtual_nic::MobileTunSources>,
String,
> {
instance_sources
.into_iter()
.map(|source| {
let instance_id = source
.instance_id
.parse::<uuid::Uuid>()
.map_err(|err| format!("invalid instance id {}: {err}", source.instance_id))?;
let sources = easytier::instance::virtual_nic::MobileTunSources::parse(
source.ipv4_addrs,
source.ipv6_addrs,
source.ipv4_routes,
source.ipv6_routes,
)
.map_err(|err| err.to_string())?;
Ok((instance_id, sources))
})
.collect()
}
#[tauri::command]
async fn set_tun_fd(fd: i32) -> Result<(), String> {
async fn set_tun_fd(
fd: i32,
instance_ids: Option<Vec<String>>,
instance_sources: Option<Vec<TunFdInstanceSources>>,
) -> 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());
};
let target_ids = match instance_ids {
Some(instance_ids) if !instance_ids.is_empty() => instance_ids
.into_iter()
.map(|id| {
id.parse::<uuid::Uuid>()
.map_err(|err| format!("invalid instance id {id}: {err}"))
})
.collect::<Result<Vec<_>, _>>()?,
_ => get_client_manager!()?.get_enabled_instances_for_tun_fd(),
};
#[cfg(mobile)]
let mut source_map = parse_tun_fd_instance_sources(instance_sources.unwrap_or_default())?;
#[cfg(not(mobile))]
let _ = instance_sources;
let mut success_count = 0;
let mut errors = Vec::new();
for uuid in get_client_manager!()?.get_enabled_instances_for_tun_fd() {
match instance_manager.set_tun_fd(&uuid, fd) {
for uuid in target_ids {
#[cfg(mobile)]
let set_result = match source_map.remove(&uuid) {
Some(sources) => instance_manager.set_tun_fd(&uuid, fd, sources),
None => Err(anyhow::anyhow!("missing tun sources for instance {uuid}")),
};
#[cfg(not(mobile))]
let set_result = instance_manager.set_tun_fd(&uuid, fd);
match set_result {
Ok(()) => {
success_count += 1;
}
@@ -1210,10 +1296,12 @@ mod manager {
) -> anyhow::Result<()> {
self.storage.network_configs.clear();
for stored in configs {
let instance_id = stored.config.instance_id();
let mut config = stored.config;
normalize_network_config_for_runtime(&mut 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),
);
}
+18 -3
View File
@@ -70,8 +70,23 @@ 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 TunFdInstanceSources {
instanceId: string
ipv4Addrs: string[]
ipv6Addrs?: string[]
ipv4Routes?: string[]
ipv6Routes?: string[]
}
export async function setTunFd(fd: number, instanceIds?: string[], instanceSources?: TunFdInstanceSources[]) {
const args: { fd: number, instanceIds?: string[], instanceSources?: TunFdInstanceSources[] } = { fd }
if (instanceIds?.length) {
args.instanceIds = instanceIds
}
if (instanceSources?.length) {
args.instanceSources = instanceSources
}
return await invoke('set_tun_fd', args)
}
export async function getEasytierVersion() {
@@ -110,7 +125,7 @@ export async function sendConfigs(enabledNetworks: string[]) {
config: NetworkTypes.toBackendNetworkConfig(config),
source,
})),
enabledNetworks
enabledNetworks,
})
}
+113 -11
View File
@@ -2,6 +2,7 @@ import type { NetworkTypes } from 'easytier-frontend-lib'
import { addPluginListener } from '@tauri-apps/api/core'
import { Utils } from 'easytier-frontend-lib'
import { get_vpn_status, prepare_vpn, start_vpn, stop_vpn } from 'tauri-plugin-vpnservice-api'
import type { TunFdInstanceSources } from './backend'
type Route = NetworkTypes.Route
@@ -12,6 +13,8 @@ interface vpnStatus {
ipv4Cidr: number | null | undefined
routes: string[]
dns: string | null | undefined
instanceIds: string[]
instanceSources: TunFdInstanceSources[]
}
let dhcpPollingTimer: NodeJS.Timeout | null = null
@@ -26,6 +29,8 @@ const curVpnStatus: vpnStatus = {
ipv4Cidr: undefined,
routes: [],
dns: undefined,
instanceIds: [],
instanceSources: [],
}
async function requestVpnPermission() {
@@ -50,6 +55,8 @@ function resetVpnConfigStatus() {
curVpnStatus.ipv4Cidr = undefined
curVpnStatus.routes = []
curVpnStatus.dns = undefined
curVpnStatus.instanceIds = []
curVpnStatus.instanceSources = []
}
function syncVpnStatusFromNative(status: Awaited<ReturnType<typeof get_vpn_status>>) {
@@ -59,13 +66,14 @@ function syncVpnStatusFromNative(status: Awaited<ReturnType<typeof get_vpn_statu
return
}
curVpnStatus.ipv4Addrs = status?.ipv4Addrs?.length
const nativeIpv4Addrs = status?.ipv4Addrs?.length
? [...status.ipv4Addrs]
: status?.ipv4Addr
? [status.ipv4Addr]
: []
curVpnStatus.ipv4Addrs = nativeIpv4Addrs
const ipv4WithCidr = status?.ipv4Addr
const ipv4WithCidr = curVpnStatus.ipv4Addrs[0]
if (ipv4WithCidr?.length) {
const [ipv4Addr, cidr] = ipv4WithCidr.split('/')
curVpnStatus.ipv4Addr = ipv4Addr
@@ -107,14 +115,22 @@ async function doStopVpn(force = false) {
resetVpnConfigStatus()
}
async function doStartVpn(ipv4Addrs: string[], routes: string[], dns?: string) {
async function doStartVpn(
ipv4Addrs: string[],
routes: string[],
dns: string | undefined,
instanceIds: string[],
instanceSources: TunFdInstanceSources[],
) {
if (curVpnStatus.running) {
return
}
const primaryIpv4 = ipv4Addrs[0]
const [ipv4Addr, cidr] = primaryIpv4.split('/')
console.log('start vpn service', ipv4Addrs, routes, dns)
const [ipv4Addr, cidr] = ipv4Addrs[0].split('/')
curVpnStatus.instanceIds = [...instanceIds]
curVpnStatus.instanceSources = [...instanceSources]
console.log('start vpn service', ipv4Addrs, routes, dns, instanceIds)
const request = {
ipv4Addr: primaryIpv4,
ipv4Addrs,
@@ -145,6 +161,8 @@ async function doStartVpn(ipv4Addrs: string[], routes: string[], dns?: string) {
curVpnStatus.ipv4Cidr = Number(cidr)
curVpnStatus.routes = routes
curVpnStatus.dns = dns
curVpnStatus.instanceIds = [...instanceIds]
curVpnStatus.instanceSources = [...instanceSources]
}
function scheduleVpnConfigRetry(instanceId: string) {
@@ -164,7 +182,7 @@ 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, curVpnStatus.instanceIds, curVpnStatus.instanceSources).catch((e) => {
console.error('set tun fd failed', e)
})
}
@@ -218,12 +236,73 @@ function getRoutesForVpn(routes: Route[], node_config: NetworkTypes.NetworkConfi
return Array.from(new Set(ret)).sort()
}
function ipv4CidrToRoute(cidr: string): string | undefined {
const [address, prefixText] = cidr.split('/')
const prefix = Number(prefixText)
const octets = address?.split('.').map(octet => Number(octet))
if (
octets?.length !== 4
|| !Number.isInteger(prefix)
|| prefix < 0
|| prefix > 32
|| octets.some((octet) => {
return !Number.isInteger(octet) || octet < 0 || octet > 255
})
) {
return undefined
}
const ip = (
octets[0] * 0x1000000
+ octets[1] * 0x10000
+ octets[2] * 0x100
+ octets[3]
) >>> 0
const mask = prefix === 0 ? 0 : (0xFFFFFFFF << (32 - prefix)) >>> 0
const network = (ip & mask) >>> 0
const route = [
(network >>> 24) & 0xFF,
(network >>> 16) & 0xFF,
(network >>> 8) & 0xFF,
network & 0xFF,
].join('.')
return `${route}/${prefix}`
}
function getCollectedNetworkInfo(response: Awaited<ReturnType<typeof collectNetworkInfo>>, instanceId: string) {
const info = response.info as any
const map = info?.map ?? info
return map?.[instanceId]
}
function sortInstanceSources(sources: TunFdInstanceSources[]): TunFdInstanceSources[] {
return sources
.map(source => ({
instanceId: source.instanceId,
ipv4Addrs: [...source.ipv4Addrs].sort(),
ipv6Addrs: [...(source.ipv6Addrs ?? [])].sort(),
ipv4Routes: [...(source.ipv4Routes ?? [])].sort(),
ipv6Routes: [...(source.ipv6Routes ?? [])].sort(),
}))
.sort((a, b) => a.instanceId.localeCompare(b.instanceId))
}
function splitRoutesByFamily(routes: string[]) {
const ipv4Routes: string[] = []
const ipv6Routes: string[] = []
routes.forEach((route) => {
if (route.includes(':')) {
ipv6Routes.push(route)
}
else {
ipv4Routes.push(route)
}
})
return { ipv4Routes, ipv6Routes }
}
export async function onNetworkInstanceChange(instanceId: string) {
pendingVpnConfigInstanceId = instanceId
if (!vpnConfigSyncTask) {
@@ -273,6 +352,7 @@ async function applyNetworkInstanceChange(instanceId: string) {
}
const ipv4Addrs: string[] = []
const instanceSources: TunFdInstanceSources[] = []
const routes = new Set<string>()
let dns: string | undefined
const retryInstanceIds: string[] = []
@@ -308,8 +388,26 @@ async function applyNetworkInstanceChange(instanceId: string) {
network_length = 24
}
ipv4Addrs.push(`${virtual_ip}/${network_length}`)
getRoutesForVpn(curNetworkInfo?.routes, config).forEach(route => routes.add(route))
const sourceIpv4 = `${virtual_ip}/${network_length}`
ipv4Addrs.push(sourceIpv4)
const instanceRoutes = new Set<string>()
const localRoute = ipv4CidrToRoute(sourceIpv4)
if (localRoute) {
routes.add(localRoute)
instanceRoutes.add(localRoute)
}
getRoutesForVpn(curNetworkInfo?.routes, config).forEach((route) => {
routes.add(route)
instanceRoutes.add(route)
})
const { ipv4Routes, ipv6Routes } = splitRoutesByFamily([...instanceRoutes])
instanceSources.push({
instanceId,
ipv4Addrs: [sourceIpv4],
ipv6Addrs: [],
ipv4Routes,
ipv6Routes,
})
if (config.enable_magic_dns) {
dns = '100.100.100.101'
}
@@ -330,10 +428,14 @@ async function applyNetworkInstanceChange(instanceId: string) {
const sortedIpv4Addrs = [...ipv4Addrs].sort()
const sortedRoutes = Array.from(routes).sort()
const sortedInstanceIds = group.map(({ instanceId }) => instanceId).sort()
const sortedInstanceSources = sortInstanceSources(instanceSources)
const ipChanged = JSON.stringify(sortedIpv4Addrs) !== JSON.stringify(curVpnStatus.ipv4Addrs)
const routesChanged = JSON.stringify(sortedRoutes) !== JSON.stringify(curVpnStatus.routes)
const dnsChanged = dns != curVpnStatus.dns
const configChanged = ipChanged || routesChanged || dnsChanged
const dnsChanged = dns !== curVpnStatus.dns
const instanceIdsChanged = JSON.stringify(sortedInstanceIds) !== JSON.stringify(curVpnStatus.instanceIds)
const instanceSourcesChanged = JSON.stringify(sortedInstanceSources) !== JSON.stringify(sortInstanceSources(curVpnStatus.instanceSources))
const configChanged = ipChanged || routesChanged || dnsChanged || instanceIdsChanged || instanceSourcesChanged
const shouldStartVpn = !curVpnStatus.running
if (shouldStartVpn || configChanged) {
@@ -357,7 +459,7 @@ async function applyNetworkInstanceChange(instanceId: string) {
}
try {
await doStartVpn(sortedIpv4Addrs, sortedRoutes, dns)
await doStartVpn(sortedIpv4Addrs, sortedRoutes, dns, sortedInstanceIds, sortedInstanceSources)
}
catch (e) {
if (e instanceof Error && e.message === 'need_prepare') {