fix(mobile): allow reachable Hyper-V pairing addresses (#13107)

* fix(mobile): allow reachable Hyper-V pairing addresses

* fix(mobile): keep host-local Hyper-V addresses filtered

* fix(mobile): preserve explicit address on empty refresh

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-08-07 23:38:26 -04:00
committed by Jinwoo-H
co-authored by OrcaWin
parent 94c9050ff9
commit b18200dec4
13 changed files with 528 additions and 114 deletions
+73 -14
View File
@@ -1,7 +1,8 @@
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import type * as NodeOs from 'node:os'
const { handleMock, networkInterfacesMock } = vi.hoisted(() => ({
const { defaultRouteInterfaceNamesMock, handleMock, networkInterfacesMock } = vi.hoisted(() => ({
defaultRouteInterfaceNamesMock: vi.fn(),
handleMock: vi.fn(),
networkInterfacesMock: vi.fn()
}))
@@ -25,6 +26,10 @@ vi.mock('os', async (importOriginal) => ({
networkInterfaces: networkInterfacesMock
}))
vi.mock('../runtime/windows-default-route-interfaces', () => ({
getWindowsDefaultRouteInterfaceNames: defaultRouteInterfaceNamesMock
}))
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -42,12 +47,13 @@ describe('registerMobileHandlers', () => {
handleMock.mockReset()
networkInterfacesMock.mockReset()
networkInterfacesMock.mockReturnValue({})
defaultRouteInterfaceNamesMock.mockReset().mockResolvedValue(new Set())
handleMock.mockImplementation((channel: string, handler: (...args: unknown[]) => unknown) => {
handlers.set(channel, handler)
})
})
it('re-reads system network interfaces on each request and prefers tailnet addresses', () => {
it('re-reads system network interfaces on each request and prefers tailnet addresses', async () => {
networkInterfacesMock
.mockReturnValueOnce({
en0: [{ family: 'IPv4', internal: false, address: '192.168.1.24' }]
@@ -59,15 +65,16 @@ describe('registerMobileHandlers', () => {
registerMobileHandlers({} as never)
expect(handlers.get('mobile:listNetworkInterfaces')?.()).toEqual({
await expect(handlers.get('mobile:listNetworkInterfaces')?.()).resolves.toEqual({
interfaces: [{ name: 'en0', address: '192.168.1.24' }]
})
expect(handlers.get('mobile:listNetworkInterfaces')?.()).toEqual({
await expect(handlers.get('mobile:listNetworkInterfaces')?.()).resolves.toEqual({
interfaces: [
{ name: 'tailscale0', address: '100.64.1.20' },
{ name: 'en0', address: '192.168.1.24' }
]
})
expect(defaultRouteInterfaceNamesMock).not.toHaveBeenCalled()
})
it('excludes proxy fake-ip addresses so pairing defaults to LAN (#10404)', async () => {
@@ -92,7 +99,7 @@ describe('registerMobileHandlers', () => {
registerMobileHandlers({ createMobilePairingOffer } as never)
expect(handlers.get('mobile:listNetworkInterfaces')?.()).toEqual({
await expect(handlers.get('mobile:listNetworkInterfaces')?.()).resolves.toEqual({
interfaces: [
{ name: 'en0', address: '192.168.50.238' },
{ name: 'en0', address: '198.17.255.254' },
@@ -106,7 +113,7 @@ describe('registerMobileHandlers', () => {
)
})
it('includes IPv6 addresses (ranked after IPv4) and excludes link-local IPv6', () => {
it('includes IPv6 addresses (ranked after IPv4) and excludes link-local IPv6', async () => {
networkInterfacesMock.mockReturnValue({
en0: [
{ family: 'IPv4', internal: false, address: '192.168.1.24' },
@@ -118,7 +125,7 @@ describe('registerMobileHandlers', () => {
registerMobileHandlers({} as never)
expect(handlers.get('mobile:listNetworkInterfaces')?.()).toEqual({
await expect(handlers.get('mobile:listNetworkInterfaces')?.()).resolves.toEqual({
interfaces: [
{ name: 'en0', address: '192.168.1.24' },
{ name: 'en0', address: '2605:340:cd51:2a01:0:2b13:f279:c096' }
@@ -126,7 +133,7 @@ describe('registerMobileHandlers', () => {
})
})
it('ranks container and VM bridges below every reachable address', () => {
it('ranks container and VM bridges below every reachable address', async () => {
// Why: a phone can never reach docker0, so advertising it makes the direct
// path lose the pairing race and silently relays every session.
networkInterfacesMock.mockReturnValue({
@@ -142,7 +149,7 @@ describe('registerMobileHandlers', () => {
registerMobileHandlers({} as never)
// Real IPv6 outranks a bridge IPv4: the phone can reach one, never the other.
expect(handlers.get('mobile:listNetworkInterfaces')?.()).toEqual({
await expect(handlers.get('mobile:listNetworkInterfaces')?.()).resolves.toEqual({
interfaces: [
{ name: 'en0', address: '192.168.1.24' },
{ name: 'en0', address: '2605:340:cd51:2a01:0:2b13:f279:c096' },
@@ -153,7 +160,59 @@ describe('registerMobileHandlers', () => {
})
})
it('keeps a real LAN address that merely overlaps a container subnet', () => {
it('auto-advertises a route-backed Hyper-V external-switch management address', async () => {
networkInterfacesMock.mockReturnValue({
'vEthernet (Production LAN)': [{ family: 'IPv4', internal: false, address: '192.168.50.24' }],
'vEthernet (Default Switch)': [{ family: 'IPv4', internal: false, address: '172.28.80.1' }],
'vEthernet (WSL (Hyper-V firewall))': [
{ family: 'IPv4', internal: false, address: '172.20.96.1' }
],
Ethernet: [{ family: 'IPv4', internal: false, address: '192.168.50.25' }]
})
defaultRouteInterfaceNamesMock.mockResolvedValue(
new Set([
'vEthernet (Production LAN)',
'vEthernet (Default Switch)',
'vEthernet (WSL (Hyper-V firewall))'
])
)
const createMobilePairingOffer = vi.fn().mockResolvedValue({
available: true,
pairingUrl: 'orca://pair#external-switch',
endpoint: 'ws://192.168.50.24:6768',
deviceId: 'mobile-external-switch',
connectionMode: 'automatic'
})
registerMobileHandlers({ createMobilePairingOffer } as never)
await expect(handlers.get('mobile:listNetworkInterfaces')?.()).resolves.toEqual({
interfaces: [
{
name: 'vEthernet (Production LAN)',
address: '192.168.50.24',
hasDefaultRoute: true
},
{ name: 'Ethernet', address: '192.168.50.25' },
{
name: 'vEthernet (Default Switch)',
address: '172.28.80.1',
hasDefaultRoute: true
},
{
name: 'vEthernet (WSL (Hyper-V firewall))',
address: '172.20.96.1',
hasDefaultRoute: true
}
]
})
await handlers.get('mobile:getPairingQR')?.(null, {})
expect(createMobilePairingOffer).toHaveBeenCalledWith(
expect.objectContaining({ address: '192.168.50.24' })
)
})
it('keeps a real LAN address that merely overlaps a container subnet', async () => {
// Why: Docker's 172.16/12 pool overlaps genuine corporate LANs, so the
// bridge check keys on interface name — a subnet test would demote this.
networkInterfacesMock.mockReturnValue({
@@ -163,7 +222,7 @@ describe('registerMobileHandlers', () => {
registerMobileHandlers({} as never)
expect(handlers.get('mobile:listNetworkInterfaces')?.()).toEqual({
await expect(handlers.get('mobile:listNetworkInterfaces')?.()).resolves.toEqual({
interfaces: [
{ name: 'eth0', address: '172.17.4.9' },
{ name: 'docker0', address: '172.17.0.1' }
@@ -199,7 +258,7 @@ describe('registerMobileHandlers', () => {
expect.objectContaining({ address: null })
)
// The bridges stay pickable, just never automatically.
expect(handlers.get('mobile:listNetworkInterfaces')?.()).toEqual({
await expect(handlers.get('mobile:listNetworkInterfaces')?.()).resolves.toEqual({
interfaces: [
{ name: 'docker0', address: '172.17.0.1' },
{ name: 'vEthernet (WSL)', address: '172.28.80.1' }
@@ -252,7 +311,7 @@ describe('registerMobileHandlers', () => {
)
})
it('returns an IPv6 interface on an IPv6-only host (regression: was empty, breaking mobile pairing)', () => {
it('returns an IPv6 interface on an IPv6-only host (regression: was empty, breaking mobile pairing)', async () => {
networkInterfacesMock.mockReturnValue({
eth0: [
{ family: 'IPv6', internal: false, address: '2605:340:cd51:2a01:0:2b13:f279:c096' },
@@ -262,7 +321,7 @@ describe('registerMobileHandlers', () => {
registerMobileHandlers({} as never)
expect(handlers.get('mobile:listNetworkInterfaces')?.()).toEqual({
await expect(handlers.get('mobile:listNetworkInterfaces')?.()).resolves.toEqual({
interfaces: [{ name: 'eth0', address: '2605:340:cd51:2a01:0:2b13:f279:c096' }]
})
})
+18 -77
View File
@@ -1,20 +1,21 @@
import { app, ipcMain, shell, type IpcMainInvokeEvent } from 'electron'
import { networkInterfaces } from 'node:os'
import type { RuntimeAccessGrant } from '../../shared/runtime-access-grants'
import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode'
import {
isVirtualBridgeInterface,
selectAutoAdvertisedPairingAddress
} from '../../shared/pairing-address-auto-selection'
import { classifyRemotePairingHostname } from '../../shared/remote-pairing-address'
import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach'
import { isTailnetIPv4Address } from '../../shared/tailnet-address'
import type { DeviceEntry } from '../runtime/device-registry'
import { NETWORK_EXPOSURE_FAILED_GUIDANCE } from '../runtime/network-exposure-guidance'
import {
getDefaultPairingAddress,
getPairingNetworkInterfaces,
type DefaultRouteInterfaceLookup,
type NetworkInterface
} from '../runtime/pairing-network-interfaces'
import { resolveAdvertisedPairingHostname } from '../runtime/pairing-endpoint'
import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc'
import type { RelayBrokerStatus } from '../runtime/relay/relay-session-broker'
import { encodeMobilePairingQr, type MobilePairingQrResult } from '../runtime/mobile-pairing-qr'
import { getWindowsDefaultRouteInterfaceNames } from '../runtime/windows-default-route-interfaces'
import {
getWebSocketPort,
inspectWindowsMobileFirewall,
@@ -22,72 +23,6 @@ import {
type WindowsMobileFirewallEnvironment
} from '../runtime/windows-mobile-firewall'
export type NetworkInterface = {
name: string
address: string
}
// Why: link-local IPv6 addresses (fe80::/10) require a scope/zone id to be
// connectable and never work as a QR-advertised pairing host, so they are
// excluded from the pickable list. The regex covers the full /10 range
// (fe80: through febf:), not just the fe80: prefix the OS usually assigns.
function isUsableIPv6Address(address: string): boolean {
return !/^fe[89ab][0-9a-f]:/i.test(address)
}
function isProxyFakeIpIPv4Address(address: string): boolean {
return /^198\.(?:18|19)\./.test(address)
}
// Why: the WebSocket transport advertises 0.0.0.0 as its endpoint, which isn't
// connectable from a mobile device. We enumerate all non-internal IPv4 and
// (non-link-local) IPv6 addresses so the user can choose which one to advertise
// in the QR code (e.g. LAN vs Tailscale). IPv6 must be included so pairing works
// on IPv6-only hosts (e.g. a headless `orca serve` reachable only over IPv6),
// where an IPv4-only scan returns nothing and the UI reports "no interfaces".
function getNetworkInterfaces(): NetworkInterface[] {
const result: NetworkInterface[] = []
const interfaces = networkInterfaces()
for (const [name, addrs] of Object.entries(interfaces)) {
if (!addrs) {
continue
}
for (const addr of addrs) {
if (addr.internal) {
continue
}
if (addr.family === 'IPv4') {
// 198.18.0.0/15 proxy fake IPs are only routable inside the desktop proxy.
if (isProxyFakeIpIPv4Address(addr.address)) {
continue
}
result.push({ name, address: addr.address })
} else if (addr.family === 'IPv6' && isUsableIPv6Address(addr.address)) {
result.push({ name, address: addr.address })
}
}
}
// Why: prefer tailnet IPv4 first (most portable across networks), then other
// IPv4, then IPv6 as a fallback for IPv6-only environments. Virtual bridges
// sort below all of them and stay in the list so they remain explicitly
// pickable; the automatic default skips them entirely.
return result.sort((a, b) => rankInterface(a) - rankInterface(b))
}
function rankInterface({ name, address }: NetworkInterface): number {
if (isTailnetIPv4Address(address)) {
return 0
}
const bridgePenalty = isVirtualBridgeInterface(name) ? 2 : 0
return (address.includes(':') ? 2 : 1) + bridgePenalty
}
// Why: null when every enumerated interface is a host-local bridge, so a docker0-only host
// advertises no direct address instead of one the phone provably cannot reach.
function getDefaultPairingAddress(): string | null {
return selectAutoAdvertisedPairingAddress(getNetworkInterfaces()) ?? null
}
// Why: only an explicit "This computer only" pick skips the one-way widen, and only when the address it
// advertises really is loopback — a mismatch (a LAN address under a this-computer reach) would otherwise
// mint a link with no listener behind it. Every other reach, including a loopback-looking Custom address
@@ -119,6 +54,7 @@ export type MobileHandlerDependencies = {
getRelayStatus?: () => RelayBrokerStatus
consumePendingUnpairedDeviceAuthFailure?: (webContentsId: number) => boolean
encodePairingQr?: (pairingUrl: string) => Promise<MobilePairingQrResult>
getDefaultRouteInterfaceNames?: DefaultRouteInterfaceLookup
}
export function registerMobileHandlers(
@@ -131,9 +67,14 @@ export function registerMobileHandlers(
executablePath: process.execPath,
systemRoot: process.env.SystemRoot
}
ipcMain.handle('mobile:listNetworkInterfaces', (): { interfaces: NetworkInterface[] } => ({
interfaces: getNetworkInterfaces()
}))
const getDefaultRouteInterfaceNames =
dependencies.getDefaultRouteInterfaceNames ?? getWindowsDefaultRouteInterfaceNames
ipcMain.handle(
'mobile:listNetworkInterfaces',
async (): Promise<{ interfaces: NetworkInterface[] }> => ({
interfaces: await getPairingNetworkInterfaces(getDefaultRouteInterfaceNames)
})
)
ipcMain.handle(
'mobile:getPairingQR',
@@ -148,7 +89,7 @@ export function registerMobileHandlers(
// Why: allow the caller to specify which network interface address to
// embed in the QR code. This supports overlay networks (Tailscale,
// ZeroTier) where the default LAN IP isn't reachable from the phone.
const ip = args?.address ?? getDefaultPairingAddress()
const ip = args?.address ?? (await getDefaultPairingAddress(getDefaultRouteInterfaceNames))
// Why: the local address is optional under Relay — the QR carries the relay invite, so a host
// with nothing auto-advertisable (only container bridges, or no interface at all) still pairs.
// The offer's endpoint then falls back to loopback, which is the phone's own device: the direct
@@ -206,7 +147,7 @@ export function registerMobileHandlers(
ipcMain.handle(
'mobile:getRuntimePairingUrl',
async (_event, args?: { address?: string; rotate?: boolean; reach?: RuntimePairingReach }) => {
const ip = args?.address ?? getDefaultPairingAddress()
const ip = args?.address ?? (await getDefaultPairingAddress(getDefaultRouteInterfaceNames))
if (!ip) {
return { available: false as const }
}
@@ -0,0 +1,80 @@
import { networkInterfaces } from 'node:os'
import {
isVirtualBridgeInterface,
selectAutoAdvertisedPairingAddress
} from '../../shared/pairing-address-auto-selection'
import { isTailnetIPv4Address } from '../../shared/tailnet-address'
import { getWindowsDefaultRouteInterfaceNames } from './windows-default-route-interfaces'
export type NetworkInterface = {
name: string
address: string
hasDefaultRoute?: boolean
}
export type DefaultRouteInterfaceLookup = () => Promise<ReadonlySet<string> | null>
function isUsableIPv6Address(address: string): boolean {
// Why: link-local IPv6 needs an unadvertised scope id, so a QR hostname cannot connect to it.
return !/^fe[89ab][0-9a-f]:/i.test(address)
}
function isProxyFakeIpIPv4Address(address: string): boolean {
return /^198\.(?:18|19)\./.test(address)
}
export async function getPairingNetworkInterfaces(
getDefaultRouteInterfaceNames: DefaultRouteInterfaceLookup = getWindowsDefaultRouteInterfaceNames
): Promise<NetworkInterface[]> {
const result: NetworkInterface[] = []
const interfaces = networkInterfaces()
const hasHyperVInterface = Object.keys(interfaces).some((name) => /^vEthernet /i.test(name))
// Why: an unavailable route query is unknown, so no vEthernet adapter receives reachability proof.
const defaultRouteInterfaceNames = hasHyperVInterface
? await getDefaultRouteInterfaceNames()
: new Set<string>()
const normalizedDefaultRouteInterfaceNames =
defaultRouteInterfaceNames === null
? null
: new Set([...defaultRouteInterfaceNames].map((name) => name.toLowerCase()))
for (const [name, addrs] of Object.entries(interfaces)) {
for (const addr of addrs ?? []) {
if (addr.internal || (addr.family === 'IPv4' && isProxyFakeIpIPv4Address(addr.address))) {
continue
}
if (
addr.family !== 'IPv4' &&
!(addr.family === 'IPv6' && isUsableIPv6Address(addr.address))
) {
continue
}
result.push({
name,
address: addr.address,
...(normalizedDefaultRouteInterfaceNames?.has(name.toLowerCase())
? { hasDefaultRoute: true }
: {})
})
}
}
return result.sort((a, b) => rankInterface(a) - rankInterface(b))
}
function rankInterface({ name, address, hasDefaultRoute }: NetworkInterface): number {
if (isTailnetIPv4Address(address)) {
return 0
}
const bridgePenalty = isVirtualBridgeInterface(name, hasDefaultRoute) ? 2 : 0
return (address.includes(':') ? 2 : 1) + bridgePenalty
}
export async function getDefaultPairingAddress(
getDefaultRouteInterfaceNames: DefaultRouteInterfaceLookup = getWindowsDefaultRouteInterfaceNames
): Promise<string | null> {
return (
selectAutoAdvertisedPairingAddress(
await getPairingNetworkInterfaces(getDefaultRouteInterfaceNames)
) ?? null
)
}
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from 'vitest'
import {
getWindowsDefaultRouteInterfaceNames,
type WindowsDefaultRouteEnvironment
} from './windows-default-route-interfaces'
function environment(
runPowerShell: WindowsDefaultRouteEnvironment['runPowerShell'],
platform: NodeJS.Platform = 'win32'
): WindowsDefaultRouteEnvironment {
return { platform, systemRoot: 'C:\\Windows', runPowerShell }
}
describe('getWindowsDefaultRouteInterfaceNames', () => {
it('returns active IPv4 and IPv6 default-route interface aliases', async () => {
const runPowerShell = vi.fn().mockResolvedValue('["vEthernet (Production LAN)","Ethernet"]')
await expect(getWindowsDefaultRouteInterfaceNames(environment(runPowerShell))).resolves.toEqual(
new Set(['vEthernet (Production LAN)', 'Ethernet'])
)
const script = runPowerShell.mock.calls[0]![0] as string
expect(runPowerShell.mock.calls[0]![1]).toBe(3_000)
expect(script).toContain("$ProgressPreference = 'SilentlyContinue'")
expect(script).toContain("$_.State -eq 'Alive'")
expect(script).toContain("$_.DestinationPrefix -eq '0.0.0.0/0'")
expect(script).toContain("$_.DestinationPrefix -eq '::/0'")
})
it('returns unknown when route inspection fails or emits malformed data', async () => {
await expect(
getWindowsDefaultRouteInterfaceNames(
environment(vi.fn().mockRejectedValue(new Error('managed host')))
)
).resolves.toBeNull()
await expect(
getWindowsDefaultRouteInterfaceNames(environment(vi.fn().mockResolvedValue('"Ethernet"')))
).resolves.toBeNull()
})
it('does not inspect routes outside Windows', async () => {
const runPowerShell = vi.fn()
await expect(
getWindowsDefaultRouteInterfaceNames(environment(runPowerShell, 'linux'))
).resolves.toEqual(new Set())
expect(runPowerShell).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,72 @@
import { execFile } from 'node:child_process'
import { win32 } from 'node:path'
const POWERSHELL_TIMEOUT_MS = 3_000
type PowerShellRunner = (script: string, timeoutMs: number) => Promise<string>
export type WindowsDefaultRouteEnvironment = {
platform: NodeJS.Platform
systemRoot?: string
runPowerShell?: PowerShellRunner
}
export async function getWindowsDefaultRouteInterfaceNames(
environment: WindowsDefaultRouteEnvironment = {
platform: process.platform,
systemRoot: process.env.SystemRoot
}
): Promise<ReadonlySet<string> | null> {
if (environment.platform !== 'win32') {
return new Set()
}
try {
const stdout = await (
environment.runPowerShell ?? createPowerShellRunner(environment.systemRoot)
)(buildDefaultRouteScript(), POWERSHELL_TIMEOUT_MS)
const parsed = JSON.parse(stdout.trim()) as unknown
if (!Array.isArray(parsed) || !parsed.every((name) => typeof name === 'string')) {
return null
}
return new Set(parsed)
} catch {
return null
}
}
function buildDefaultRouteScript(): string {
return `$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$names = @(Get-NetRoute -ErrorAction Stop | Where-Object { $_.State -eq 'Alive' -and ($_.DestinationPrefix -eq '0.0.0.0/0' -or $_.DestinationPrefix -eq '::/0') } | Select-Object -ExpandProperty InterfaceAlias -Unique)
ConvertTo-Json -InputObject $names -Compress`
}
function createPowerShellRunner(systemRoot = 'C:\\Windows'): PowerShellRunner {
const powershellPath = win32.join(
systemRoot,
'System32',
'WindowsPowerShell',
'v1.0',
'powershell.exe'
)
return (script, timeoutMs) =>
new Promise((resolve, reject) => {
execFile(
powershellPath,
['-NoProfile', '-NonInteractive', '-EncodedCommand', encodePowerShell(script)],
{ encoding: 'utf8', timeout: timeoutMs, windowsHide: true },
(error, stdout) => {
if (error) {
reject(error)
} else {
resolve(stdout)
}
}
)
})
}
function encodePowerShell(script: string): string {
return Buffer.from(script, 'utf16le').toString('base64')
}
+1 -1
View File
@@ -3660,7 +3660,7 @@ export type PreloadApi = {
}
mobile: {
listNetworkInterfaces: () => Promise<{
interfaces: { name: string; address: string }[]
interfaces: { name: string; address: string; hasDefaultRoute?: boolean }[]
}>
getPairingQR: (args?: {
address?: string
+1 -1
View File
@@ -4671,7 +4671,7 @@ const api = {
mobile: {
listNetworkInterfaces: (): Promise<{
interfaces: { name: string; address: string }[]
interfaces: { name: string; address: string; hasDefaultRoute?: boolean }[]
}> => ipcRenderer.invoke('mobile:listNetworkInterfaces'),
getPairingQR: (args?: {
@@ -26,8 +26,14 @@ import { useMobilePairingAddressPreference } from './use-mobile-pairing-address-
const LAN: MobileNetworkInterface = { name: 'en0', address: '192.168.1.24' }
const OTHER: MobileNetworkInterface = { name: 'en1', address: '10.0.0.5' }
const EXTERNAL_SWITCH: MobileNetworkInterface = {
name: 'vEthernet (Lab)',
address: '192.168.1.30',
hasDefaultRoute: true
}
const BRIDGE: MobileNetworkInterface = { name: 'docker0', address: '172.17.0.1' }
function renderPreference() {
function renderPreference(networkInterfaces: readonly MobileNetworkInterface[] = []) {
mocks.holder.state = {
settings: {},
updateSettings: vi.fn().mockResolvedValue(undefined)
@@ -35,7 +41,7 @@ function renderPreference() {
const onSelectionInvalidated = vi.fn()
const { result } = renderHook(() =>
useMobilePairingAddressPreference({
networkInterfaces: [],
networkInterfaces,
onSelectionInvalidated
})
)
@@ -81,4 +87,35 @@ describe('useMobilePairingAddressPreference', () => {
source: 'refresh'
})
})
it('invalidates an automatic vEthernet selection when route evidence becomes ambiguous', () => {
const { result, onSelectionInvalidated } = renderPreference()
act(() => result.current.selectAddressAfterRefresh([EXTERNAL_SWITCH]))
act(() =>
result.current.selectAddressAfterRefresh([{ ...EXTERNAL_SWITCH, hasDefaultRoute: undefined }])
)
expect(result.current.selectedAddress).toBeUndefined()
expect(onSelectionInvalidated).toHaveBeenCalledExactlyOnceWith({
address: undefined,
source: 'refresh'
})
})
it('keeps an explicitly selected bridge across refreshes', () => {
const { result, onSelectionInvalidated } = renderPreference([LAN, BRIDGE])
act(() => result.current.selectAddressAfterRefresh([LAN, BRIDGE]))
act(() => result.current.selectAddress(BRIDGE.address))
act(() => result.current.selectAddressAfterRefresh([LAN, BRIDGE]))
act(() => result.current.selectAddressAfterRefresh([]))
expect(result.current.selectedAddress).toBe(BRIDGE.address)
expect(result.current.selectedAddressIsCustom).toBe(false)
expect(onSelectionInvalidated).toHaveBeenCalledExactlyOnceWith({
address: BRIDGE.address,
source: 'user'
})
})
})
@@ -45,6 +45,7 @@ export function useMobilePairingAddressPreference(args: {
const [customAddresses, setCustomAddresses] = useState(savedCustomAddresses)
const selectedAddressRef = useRef(selectedAddress)
const selectedAddressIsManualRef = useRef(savedCustomAddress !== undefined)
const selectedAddressWasExplicitlySelectedRef = useRef(savedCustomAddress !== undefined)
const customAddressesRef = useRef(savedCustomAddresses)
const observedCustomAddressRef = useRef(savedCustomAddress)
const pendingCustomAddressWritesRef = useRef<(string | undefined)[]>([])
@@ -54,7 +55,8 @@ export function useMobilePairingAddressPreference(args: {
const nextAddress = selectRefreshedNetworkAddress(
selectedAddressRef.current,
interfaces,
selectedAddressIsManualRef.current
selectedAddressIsManualRef.current,
selectedAddressWasExplicitlySelectedRef.current
)
if (nextAddress === selectedAddressRef.current) {
return
@@ -64,6 +66,7 @@ export function useMobilePairingAddressPreference(args: {
const hadSelection = selectedAddressRef.current !== undefined
selectedAddressRef.current = nextAddress
selectedAddressIsManualRef.current = false
selectedAddressWasExplicitlySelectedRef.current = false
setSelectedAddress(nextAddress)
setSelectedAddressIsCustom(false)
if (hadSelection) {
@@ -76,11 +79,16 @@ export function useMobilePairingAddressPreference(args: {
const commitAddress = useCallback(
(address: string, isManual: boolean): void => {
const addressChanged = selectedAddressRef.current !== address
if (!addressChanged && selectedAddressIsManualRef.current === isManual) {
if (
!addressChanged &&
selectedAddressIsManualRef.current === isManual &&
selectedAddressWasExplicitlySelectedRef.current
) {
return
}
selectedAddressRef.current = address
selectedAddressIsManualRef.current = isManual
selectedAddressWasExplicitlySelectedRef.current = true
setSelectedAddress(address)
setSelectedAddressIsCustom(isManual)
const customAddress = isManual ? address : undefined
@@ -144,6 +152,7 @@ export function useMobilePairingAddressPreference(args: {
const addressChanged = selectedAddressRef.current !== nextAddress
selectedAddressRef.current = nextAddress
selectedAddressIsManualRef.current = false
selectedAddressWasExplicitlySelectedRef.current = false
setSelectedAddress(nextAddress)
setSelectedAddressIsCustom(false)
pendingCustomAddressWritesRef.current.push(undefined)
@@ -175,6 +184,7 @@ export function useMobilePairingAddressPreference(args: {
const addressChanged = selectedAddressRef.current !== nextAddress
selectedAddressRef.current = nextAddress
selectedAddressIsManualRef.current = savedCustomAddress !== undefined
selectedAddressWasExplicitlySelectedRef.current = savedCustomAddress !== undefined
setSelectedAddress(nextAddress)
setSelectedAddressIsCustom(savedCustomAddress !== undefined)
if (addressChanged) {
@@ -7,6 +7,21 @@ import {
const LAN: MobileNetworkInterface = { name: 'en0', address: '192.168.1.24' }
const TAILNET: MobileNetworkInterface = { name: 'tailscale0', address: '100.64.1.20' }
const BRIDGE: MobileNetworkInterface = { name: 'docker0', address: '172.17.0.1' }
const DEFAULT_SWITCH: MobileNetworkInterface = {
name: 'vEthernet (Default Switch)',
address: '172.28.80.1',
hasDefaultRoute: true
}
const WSL_SWITCH: MobileNetworkInterface = {
name: 'vEthernet (WSL (Hyper-V firewall))',
address: '172.20.96.1',
hasDefaultRoute: true
}
const EXTERNAL_SWITCH: MobileNetworkInterface = {
name: 'vEthernet (Lab)',
address: '192.168.1.30',
hasDefaultRoute: true
}
describe('selectRefreshedNetworkAddress', () => {
// Why: regression for the manual-address branch the PR adds — a
@@ -40,10 +55,43 @@ describe('selectRefreshedNetworkAddress', () => {
expect(selectRefreshedNetworkAddress(LAN.address, [])).toBeUndefined()
})
it.each([BRIDGE, DEFAULT_SWITCH])(
'keeps the explicitly selected $name adapter when refresh is empty',
(networkInterface) => {
expect(selectRefreshedNetworkAddress(networkInterface.address, [], false, true)).toBe(
networkInterface.address
)
}
)
it('skips a container bridge in favor of a reachable address', () => {
expect(selectRefreshedNetworkAddress(undefined, [BRIDGE, LAN])).toBe(LAN.address)
})
it('skips route-positive Default Switch and WSL addresses in favor of LAN', () => {
expect(selectRefreshedNetworkAddress(undefined, [DEFAULT_SWITCH, WSL_SWITCH, LAN])).toBe(
LAN.address
)
})
it('reclassifies a stale auto-selected Default Switch address', () => {
expect(selectRefreshedNetworkAddress(DEFAULT_SWITCH.address, [DEFAULT_SWITCH, LAN])).toBe(
LAN.address
)
})
it('reclassifies a stale auto-selected WSL address', () => {
expect(selectRefreshedNetworkAddress(WSL_SWITCH.address, [WSL_SWITCH, LAN])).toBe(LAN.address)
})
it('drops an auto-selected vEthernet address when route evidence becomes ambiguous', () => {
expect(
selectRefreshedNetworkAddress(EXTERNAL_SWITCH.address, [
{ ...EXTERNAL_SWITCH, hasDefaultRoute: undefined }
])
).toBeUndefined()
})
// Why: matches main's default — advertising nothing beats advertising docker0, and Relay
// pairs without a direct address. A divergence here would show an address the QR never carried.
it('selects no address when every interface is a container bridge', () => {
@@ -51,6 +99,22 @@ describe('selectRefreshedNetworkAddress', () => {
})
it('keeps a bridge the user picked explicitly', () => {
expect(selectRefreshedNetworkAddress(BRIDGE.address, [BRIDGE, LAN])).toBe(BRIDGE.address)
expect(selectRefreshedNetworkAddress(BRIDGE.address, [BRIDGE, LAN], false, true)).toBe(
BRIDGE.address
)
})
it.each([DEFAULT_SWITCH, WSL_SWITCH])(
'keeps the explicitly selected known host-local adapter $name',
(networkInterface) => {
expect(
selectRefreshedNetworkAddress(
networkInterface.address,
[networkInterface, LAN],
false,
true
)
).toBe(networkInterface.address)
}
)
})
@@ -1,8 +1,12 @@
import { selectAutoAdvertisedPairingAddress } from '../../../../shared/pairing-address-auto-selection'
import {
isVirtualBridgeInterface,
selectAutoAdvertisedPairingAddress
} from '../../../../shared/pairing-address-auto-selection'
export type MobileNetworkInterface = {
name: string
address: string
hasDefaultRoute?: boolean
}
export function selectRefreshedNetworkAddress(
@@ -11,17 +15,23 @@ export function selectRefreshedNetworkAddress(
// Why: callers that explicitly know the user picked a manual address
// (not an OS-enumerated one) pass this so the refresh path keeps their
// selection instead of snapping back to a tailnet/LAN fallback.
currentAddressIsManual: boolean = false
currentAddressIsManual: boolean = false,
currentAddressWasExplicitlySelected: boolean = false
): string | undefined {
// Why: an empty refresh result usually means discovery is transiently
// unavailable, not that the user wants to drop their selection. Keep the
// manual address so a recovering discovery doesn't clobber it.
// Why: transient discovery failure must not clobber a deliberate user choice.
if (interfaces.length === 0) {
return currentAddressIsManual ? currentAddress : undefined
return currentAddressIsManual || currentAddressWasExplicitlySelected
? currentAddress
: undefined
}
if (currentAddressIsManual) {
return currentAddress
}
const currentInterface = interfaces.find((iface) => iface.address === currentAddress)
if (
currentAddress &&
(currentAddressIsManual || interfaces.some((iface) => iface.address === currentAddress))
currentInterface &&
(currentAddressWasExplicitlySelected ||
!isVirtualBridgeInterface(currentInterface.name, currentInterface.hasDefaultRoute))
) {
return currentAddress
}
@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest'
import {
isVirtualBridgeInterface,
selectAutoAdvertisedPairingAddress,
type PairingNetworkInterface
} from './pairing-address-auto-selection'
const EXTERNAL_SWITCH = {
name: 'vEthernet (Production LAN)',
address: '192.168.50.24',
hasDefaultRoute: true
} satisfies PairingNetworkInterface & { hasDefaultRoute: boolean }
const DEFAULT_SWITCH: PairingNetworkInterface = {
name: 'vEthernet (Default Switch)',
address: '172.28.80.1',
hasDefaultRoute: true
}
const WSL_SWITCH: PairingNetworkInterface = {
name: 'vEthernet (WSL (Hyper-V firewall))',
address: '172.20.96.1',
hasDefaultRoute: true
}
const PHYSICAL_LAN: PairingNetworkInterface = {
name: 'Ethernet',
address: '192.168.50.25'
}
const HOST_LOCAL_BRIDGE: PairingNetworkInterface = {
name: 'docker0',
address: '172.17.0.1'
}
const AMBIGUOUS_SWITCH: PairingNetworkInterface = {
name: 'vEthernet (Lab)',
address: '10.40.0.1'
}
describe('selectAutoAdvertisedPairingAddress', () => {
it('allows a reachable Hyper-V external-switch management address', () => {
expect(selectAutoAdvertisedPairingAddress([EXTERNAL_SWITCH])).toBe(EXTERNAL_SWITCH.address)
})
it('selects a physical address while filtering Default Switch, WSL, and host-local bridges', () => {
expect(
selectAutoAdvertisedPairingAddress([
DEFAULT_SWITCH,
WSL_SWITCH,
HOST_LOCAL_BRIDGE,
PHYSICAL_LAN
])
).toBe(PHYSICAL_LAN.address)
})
it('filters a vEthernet adapter when route reachability is ambiguous', () => {
expect(selectAutoAdvertisedPairingAddress([AMBIGUOUS_SWITCH])).toBeUndefined()
})
it.each([
'vEthernet (Default Switch)',
'vEthernet (default switch)',
'vEthernet (WSL)',
'vEthernet (WSL (Hyper-V firewall))'
])('keeps the known host-local %s label filtered with a default route', (name) => {
expect(isVirtualBridgeInterface(name, true)).toBe(true)
})
it.each([
'vEthernet (Default Switchboard)',
'vEthernet (WSL-LAN)',
'vEthernet (WSL LAN)',
'vEthernet (WSL External)'
])('does not treat the route-backed near-match %s as a known host-local label', (name) => {
expect(isVirtualBridgeInterface(name, true)).toBe(false)
})
it.each([
['external', EXTERNAL_SWITCH, false],
['Default Switch', DEFAULT_SWITCH, true],
['WSL', WSL_SWITCH, true],
['physical', PHYSICAL_LAN, false],
['host-local', HOST_LOCAL_BRIDGE, true],
['ambiguous', AMBIGUOUS_SWITCH, true]
] as const)('classifies the %s fixture', (_label, fixture, expected) => {
expect(isVirtualBridgeInterface(fixture.name, fixture.hasDefaultRoute)).toBe(expected)
})
})
+17 -8
View File
@@ -3,17 +3,24 @@ import { isTailnetIPv4Address } from './tailnet-address'
export type PairingNetworkInterface = {
name: string
address: string
hasDefaultRoute?: boolean
}
// Why: container/VM bridges are host-local — a phone can never reach docker0 or
// vmnet8 — but they enumerate as ordinary non-internal IPv4, so advertising one
// makes the direct path silently lose the pairing race and every session relay.
// Keyed on interface name, not subnet: Docker's 172.16/12 pool overlaps real
// corporate LANs, so an address test would demote genuine addresses.
// Why: known bridge labels are host-local, while vEthernet needs positive route evidence because
// External Switch management adapters are reachable; subnets overlap real corporate LANs.
const VIRTUAL_BRIDGE_INTERFACE_PATTERN =
/^(?:docker|br-|virbr|vmnet|vboxnet|veth|lxcbr|cni|flannel|cali|bridge)|^vEthernet |VMware Network Adapter|VirtualBox Host-Only/i
/^(?:docker|br-|virbr|vmnet|vboxnet|veth|lxcbr|cni|flannel|cali|bridge)|VMware Network Adapter|VirtualBox Host-Only/i
const HYPER_V_INTERFACE_PATTERN = /^vEthernet /i
const HOST_LOCAL_HYPER_V_INTERFACE_PATTERN =
/^vEthernet \((?:Default Switch|WSL(?: \(Hyper-V firewall\))?)\)$/i
export function isVirtualBridgeInterface(name: string): boolean {
export function isVirtualBridgeInterface(name: string, hasDefaultRoute?: boolean): boolean {
if (HOST_LOCAL_HYPER_V_INTERFACE_PATTERN.test(name)) {
return true
}
if (HYPER_V_INTERFACE_PATTERN.test(name)) {
return hasDefaultRoute !== true
}
return VIRTUAL_BRIDGE_INTERFACE_PATTERN.test(name)
}
@@ -24,7 +31,9 @@ export function isVirtualBridgeInterface(name: string): boolean {
export function selectAutoAdvertisedPairingAddress(
interfaces: readonly PairingNetworkInterface[]
): string | undefined {
const advertisable = interfaces.filter((iface) => !isVirtualBridgeInterface(iface.name))
const advertisable = interfaces.filter(
(iface) => !isVirtualBridgeInterface(iface.name, iface.hasDefaultRoute)
)
return (
advertisable.find((iface) => isTailnetIPv4Address(iface.address))?.address ??
advertisable[0]?.address