mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(pairing): offer only addresses the runtime listener answers (#31447)
The runtime WebSocket listener widens to 0.0.0.0, which never accepts IPv6, yet the Connection address picker and the default pairing address offered global IPv6 interfaces, minting links nothing answers. Filter the interface list by the family of the host the listener binds when serving the network. Reported-by: @fongyuan-2011
This commit is contained in:
@@ -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<typeof NodeOs>()),
|
||||
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<string, (...args: unknown[]) => 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<typeof stubServer>): 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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
+22
-46
@@ -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<string, unknown>, 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<string, (...args: unknown[]) => 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',
|
||||
|
||||
+16
-3
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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<NetworkInterface[]> {
|
||||
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<string | null> {
|
||||
return (
|
||||
selectAutoAdvertisedPairingAddress(
|
||||
await getPairingNetworkInterfaces(getDefaultRouteInterfaceNames)
|
||||
await getPairingNetworkInterfaces(listenerHost, getDefaultRouteInterfaceNames)
|
||||
) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user