mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 08:03:33 +00:00
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:
committed by
Jinwoo-H
co-authored by
OrcaWin
parent
94c9050ff9
commit
b18200dec4
+73
-14
@@ -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
@@ -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')
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user