diff --git a/src/main/ipc/mobile-pairing-listener-family.test.ts b/src/main/ipc/mobile-pairing-listener-family.test.ts new file mode 100644 index 00000000000..e9cf311dd6a --- /dev/null +++ b/src/main/ipc/mobile-pairing-listener-family.test.ts @@ -0,0 +1,157 @@ +import '../runtime/rpc/unused-default-rpc-methods.test-fixture' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeOs from 'node:os' + +const { handleMock, networkInterfacesMock } = vi.hoisted(() => ({ + handleMock: vi.fn(), + networkInterfacesMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { isPackaged: false }, + ipcMain: { handle: handleMock }, + shell: { openExternal: vi.fn() } +})) + +vi.mock('os', async (importOriginal) => ({ + ...(await importOriginal()), + networkInterfaces: networkInterfacesMock +})) + +vi.mock('../runtime/windows-default-route-interfaces', () => ({ + getWindowsDefaultRouteInterfaceNames: vi.fn().mockResolvedValue(new Set()) +})) + +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { registerMobileHandlers } from './mobile' +import { createMobileRpcSurfaceRuntime } from '../runtime/runtime-rpc-mobile-method-allowlist-fixtures' +import { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' + +const GLOBAL_IPV6 = '2605:340:cd51:2a01:0:2b13:f279:c096' + +// Why #31161: Node's 0.0.0.0 listener refuses every IPv6 connection, so a v6 entry mints a dead link. +describe('pairing addresses follow the runtime listener family', () => { + const handlers = new Map unknown>() + + beforeEach(() => { + handlers.clear() + handleMock.mockReset() + networkInterfacesMock.mockReset().mockReturnValue({}) + handleMock.mockImplementation((channel: string, handler: (...args: unknown[]) => unknown) => { + handlers.set(channel, handler) + }) + }) + + const registerStub = (stub: ReturnType): void => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements every server member the listing and runtime-link handlers call. + registerMobileHandlers(stub as never) + } + + const stubServer = (bindHost: string) => ({ + getNetworkExposureBindHost: () => bindHost, + createPairingOffer: vi.fn(), + ensureNetworkExposure: vi.fn().mockResolvedValue(undefined) + }) + + it('omits IPv6 addresses while the listener only serves IPv4', async () => { + networkInterfacesMock.mockReturnValue({ + en1: [ + { family: 'IPv6', internal: false, address: GLOBAL_IPV6 }, + { family: 'IPv4', internal: false, address: '192.168.1.24' } + ] + }) + + registerStub(stubServer('0.0.0.0')) + + await expect(handlers.get('mobile:listNetworkInterfaces')?.()).resolves.toEqual({ + interfaces: [{ name: 'en1', address: '192.168.1.24' }] + }) + }) + + it('keeps dual-stack IPv6 ranked after IPv4 and excludes link-local IPv6', async () => { + networkInterfacesMock.mockReturnValue({ + en0: [ + { family: 'IPv4', internal: false, address: '192.168.1.24' }, + { family: 'IPv6', internal: false, address: 'fe80::1' }, + { family: 'IPv6', internal: false, address: GLOBAL_IPV6 } + ], + lo0: [{ family: 'IPv6', internal: true, address: '::1' }] + }) + + registerStub(stubServer('::')) + + await expect(handlers.get('mobile:listNetworkInterfaces')?.()).resolves.toEqual({ + interfaces: [ + { name: 'en0', address: '192.168.1.24' }, + { name: 'en0', address: GLOBAL_IPV6 } + ] + }) + }) + + it('returns an IPv6 interface on an IPv6-only host when the listener serves IPv6', async () => { + networkInterfacesMock.mockReturnValue({ + eth0: [ + { family: 'IPv6', internal: false, address: GLOBAL_IPV6 }, + { family: 'IPv6', internal: false, address: 'fe80::42:acff:fe11:2' } + ] + }) + + registerStub(stubServer('::')) + + await expect(handlers.get('mobile:listNetworkInterfaces')?.()).resolves.toEqual({ + interfaces: [{ name: 'eth0', address: GLOBAL_IPV6 }] + }) + }) + + it('mints no runtime link on an IPv6-only host while the listener only serves IPv4', async () => { + networkInterfacesMock.mockReturnValue({ + eth0: [{ family: 'IPv6', internal: false, address: GLOBAL_IPV6 }] + }) + const server = stubServer('0.0.0.0') + + registerStub(server) + + await expect(handlers.get('mobile:listNetworkInterfaces')?.()).resolves.toEqual({ + interfaces: [] + }) + await expect(handlers.get('mobile:getRuntimePairingUrl')?.(null, {})).resolves.toEqual({ + available: false + }) + expect(server.createPairingOffer).not.toHaveBeenCalled() + }) + + it('offers only addresses the real widened listener answers', async () => { + networkInterfacesMock.mockReturnValue({ + en1: [ + { family: 'IPv4', internal: false, address: '192.168.1.24' }, + { family: 'IPv6', internal: false, address: GLOBAL_IPV6 } + ] + }) + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-mobile-ipc-')) + const server = new OrcaRuntimeRpcServer({ + methods: [], + runtime: createMobileRpcSurfaceRuntime().runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + await server.start() + + try { + registerMobileHandlers(server) + await expect(handlers.get('mobile:getRuntimePairingUrl')?.(null, {})).resolves.toMatchObject({ + available: true, + endpoint: expect.stringMatching(/^ws:\/\/192\.168\.1\.24:/) + }) + expect(server.getWebSocketEndpoint()).toMatch(/^ws:\/\/0\.0\.0\.0:/) + await expect(handlers.get('mobile:listNetworkInterfaces')?.()).resolves.toEqual({ + interfaces: [{ name: 'en1', address: '192.168.1.24' }] + }) + } finally { + await server.stop() + rmSync(userDataPath, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/ipc/mobile.test.ts b/src/main/ipc/mobile.test.ts index e433c23e068..36aed831399 100644 --- a/src/main/ipc/mobile.test.ts +++ b/src/main/ipc/mobile.test.ts @@ -41,6 +41,12 @@ import { createMobileRpcSurfaceRuntime } from '../runtime/runtime-rpc-mobile-met import { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' import { WebSocketTransport } from '../runtime/rpc/ws-transport' +// Why: production widens to 0.0.0.0, an IPv4-only listener, so address-listing stubs report that bind. +function registerWithListener(stub: Record, bindHost = '0.0.0.0'): void { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each stub implements every server member its test's handlers call. + registerMobileHandlers({ getNetworkExposureBindHost: () => bindHost, ...stub } as never) +} + describe('registerMobileHandlers', () => { const handlers = new Map unknown>() @@ -65,7 +71,7 @@ describe('registerMobileHandlers', () => { tailscale0: [{ family: 'IPv4', internal: false, address: '100.64.1.20' }] }) - registerMobileHandlers({} as never) + registerWithListener({}) await expect(handlers.get('mobile:listNetworkInterfaces')?.()).resolves.toEqual({ interfaces: [{ name: 'en0', address: '192.168.1.24' }] @@ -99,7 +105,7 @@ describe('registerMobileHandlers', () => { connectionMode: 'automatic' }) - registerMobileHandlers({ createMobilePairingOffer } as never) + registerWithListener({ createMobilePairingOffer }) await expect(handlers.get('mobile:listNetworkInterfaces')?.()).resolves.toEqual({ interfaces: [ @@ -115,26 +121,6 @@ describe('registerMobileHandlers', () => { ) }) - 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' }, - { family: 'IPv6', internal: false, address: 'fe80::1' }, - { family: 'IPv6', internal: false, address: '2605:340:cd51:2a01:0:2b13:f279:c096' } - ], - lo0: [{ family: 'IPv6', internal: true, address: '::1' }] - }) - - registerMobileHandlers({} as never) - - 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' } - ] - }) - }) - 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. @@ -148,7 +134,7 @@ describe('registerMobileHandlers', () => { ] }) - registerMobileHandlers({} as never) + registerWithListener({}, '::') // Real IPv6 outranks a bridge IPv4: the phone can reach one, never the other. await expect(handlers.get('mobile:listNetworkInterfaces')?.()).resolves.toEqual({ @@ -186,7 +172,7 @@ describe('registerMobileHandlers', () => { connectionMode: 'automatic' }) - registerMobileHandlers({ createMobilePairingOffer } as never) + registerWithListener({ createMobilePairingOffer }) await expect(handlers.get('mobile:listNetworkInterfaces')?.()).resolves.toEqual({ interfaces: [ @@ -222,7 +208,7 @@ describe('registerMobileHandlers', () => { docker0: [{ family: 'IPv4', internal: false, address: '172.17.0.1' }] }) - registerMobileHandlers({} as never) + registerWithListener({}) await expect(handlers.get('mobile:listNetworkInterfaces')?.()).resolves.toEqual({ interfaces: [ @@ -247,7 +233,7 @@ describe('registerMobileHandlers', () => { connectionMode: 'automatic' }) - registerMobileHandlers({ createMobilePairingOffer } as never) + registerWithListener({ createMobilePairingOffer }) await expect(handlers.get('mobile:getPairingQR')?.(null, {})).resolves.toMatchObject({ available: true, @@ -276,7 +262,7 @@ describe('registerMobileHandlers', () => { }) const createMobilePairingOffer = vi.fn() - registerMobileHandlers({ createMobilePairingOffer } as never) + registerWithListener({ createMobilePairingOffer }) await expect( handlers.get('mobile:getPairingQR')?.(null, { connectionMode: 'local-only' }) @@ -313,21 +299,6 @@ describe('registerMobileHandlers', () => { ) }) - 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' }, - { family: 'IPv6', internal: false, address: 'fe80::42:acff:fe11:2' } - ] - }) - - registerMobileHandlers({} as never) - - await expect(handlers.get('mobile:listNetworkInterfaces')?.()).resolves.toEqual({ - interfaces: [{ name: 'eth0', address: '2605:340:cd51:2a01:0:2b13:f279:c096' }] - }) - }) - it('generates mobile pairing urls with the tailnet address by default', async () => { networkInterfacesMock.mockReturnValue({ en0: [{ family: 'IPv4', internal: false, address: '192.168.1.24' }], @@ -342,7 +313,7 @@ describe('registerMobileHandlers', () => { }) const rpcServer = { createMobilePairingOffer } - registerMobileHandlers(rpcServer as never) + registerWithListener(rpcServer) await expect(handlers.get('mobile:getPairingQR')?.(null, {})).resolves.toMatchObject({ available: true, @@ -377,7 +348,7 @@ describe('registerMobileHandlers', () => { relayFailure }) - registerMobileHandlers({ createMobilePairingOffer } as never) + registerWithListener({ createMobilePairingOffer }) await expect(handlers.get('mobile:getPairingQR')?.(null, {})).resolves.toEqual({ available: false, @@ -399,7 +370,7 @@ describe('registerMobileHandlers', () => { connectionMode: 'local-only' }) - registerMobileHandlers({ createMobilePairingOffer } as never) + registerWithListener({ createMobilePairingOffer }) await handlers.get('mobile:getPairingQR')?.(null, { connectionMode: 'local-only' }) expect(createMobilePairingOffer).toHaveBeenCalledWith( @@ -521,7 +492,12 @@ describe('registerMobileHandlers', () => { const stubRuntimePairingServer = ( address: string - ): { createPairingOffer: Mock; ensureNetworkExposure: Mock } => ({ + ): { + getNetworkExposureBindHost: () => string + createPairingOffer: Mock + ensureNetworkExposure: Mock + } => ({ + getNetworkExposureBindHost: () => '0.0.0.0', createPairingOffer: vi.fn().mockReturnValue({ available: true, pairingUrl: 'orca://pair#runtime', diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index ccd3a6742ef..330b3a7c944 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -72,7 +72,10 @@ export function registerMobileHandlers( ipcMain.handle( 'mobile:listNetworkInterfaces', async (): Promise<{ interfaces: NetworkInterface[] }> => ({ - interfaces: await getPairingNetworkInterfaces(getDefaultRouteInterfaceNames) + interfaces: await getPairingNetworkInterfaces( + rpcServer.getNetworkExposureBindHost(), + getDefaultRouteInterfaceNames + ) }) ) @@ -89,7 +92,12 @@ 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 ?? (await getDefaultPairingAddress(getDefaultRouteInterfaceNames)) + const ip = + args?.address ?? + (await getDefaultPairingAddress( + rpcServer.getNetworkExposureBindHost(), + 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 @@ -148,7 +156,12 @@ export function registerMobileHandlers( ipcMain.handle( 'mobile:getRuntimePairingUrl', async (_event, args?: { address?: string; rotate?: boolean; reach?: RuntimePairingReach }) => { - const ip = args?.address ?? (await getDefaultPairingAddress(getDefaultRouteInterfaceNames)) + const ip = + args?.address ?? + (await getDefaultPairingAddress( + rpcServer.getNetworkExposureBindHost(), + getDefaultRouteInterfaceNames + )) if (!ip) { return { available: false as const } } diff --git a/src/main/runtime/pairing-network-interfaces.ts b/src/main/runtime/pairing-network-interfaces.ts index 8c0448ec7e3..e15a4dc3fa9 100644 --- a/src/main/runtime/pairing-network-interfaces.ts +++ b/src/main/runtime/pairing-network-interfaces.ts @@ -1,3 +1,4 @@ +import { isIP } from 'node:net' import { networkInterfaces } from 'node:os' import { isVirtualBridgeInterface, @@ -19,11 +20,23 @@ function isUsableIPv6Address(address: string): boolean { return !/^fe[89ab][0-9a-f]:/i.test(address) } +// Why: Node's `0.0.0.0` listener never accepts IPv6, so a v6 interface would mint a link nothing +// answers (#31161); only an IPv6 bind (`::` is dual-stack by default) serves both families. +function listenerServesFamily(listenerHost: string, family: 'IPv4' | 'IPv6'): boolean { + const listenerFamily = isIP(listenerHost) + if (listenerFamily === 6 && /^[0:]+$/.test(listenerHost)) { + return true + } + return listenerFamily === (family === 'IPv6' ? 6 : 4) +} + function isProxyFakeIpIPv4Address(address: string): boolean { return /^198\.(?:18|19)\./.test(address) } +/** `listenerHost` is the WS listener's network bind; only addresses of a family it accepts are offered. */ export async function getPairingNetworkInterfaces( + listenerHost: string, getDefaultRouteInterfaceNames: DefaultRouteInterfaceLookup = getWindowsDefaultRouteInterfaceNames ): Promise { const result: NetworkInterface[] = [] @@ -49,6 +62,9 @@ export async function getPairingNetworkInterfaces( ) { continue } + if (!listenerServesFamily(listenerHost, addr.family)) { + continue + } result.push({ name, address: addr.address, @@ -70,11 +86,12 @@ function rankInterface({ name, address, hasDefaultRoute }: NetworkInterface): nu } export async function getDefaultPairingAddress( + listenerHost: string, getDefaultRouteInterfaceNames: DefaultRouteInterfaceLookup = getWindowsDefaultRouteInterfaceNames ): Promise { return ( selectAutoAdvertisedPairingAddress( - await getPairingNetworkInterfaces(getDefaultRouteInterfaceNames) + await getPairingNetworkInterfaces(listenerHost, getDefaultRouteInterfaceNames) ) ?? null ) } diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-network-exposure.ts b/src/main/runtime/runtime-rpc/runtime-rpc-network-exposure.ts index ee7e4c08d1a..62697b6ec1a 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-network-exposure.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-network-exposure.ts @@ -4,6 +4,11 @@ import { RuntimeRpcLifecycle } from './runtime-rpc-lifecycle' import { WS_BIND_HOST_ALL_INTERFACES, WS_BIND_HOST_LOOPBACK } from './runtime-rpc-pairing-types' export class RuntimeRpcNetworkExposure extends RuntimeRpcLifecycle { + // Why: pairing offers must advertise addresses this host answers once widened, not the loopback it may sit on now. + getNetworkExposureBindHost(): string { + return this.pinnedBindHost ?? WS_BIND_HOST_ALL_INTERFACES + } + // Why: STA-2370 — widen the loopback listener to all interfaces so a freshly generated pairing // offer's advertised LAN endpoint is reachable. Idempotent, but it is no longer confined to the first // pairing action: a "This computer only" link never widens, so live loopback clients can already be