fix: enumerate IPv6 addresses for mobile pairing on IPv6-only hosts (#9130) (#9131)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: PannenetsF <fanyunqian.1@bytedance.com>
This commit is contained in:
Yunqian Fan 2026-07-24 14:31:10 +08:00 committed by GitHub
parent bd45d705bf
commit c03e8f6f64
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 64 additions and 7 deletions

View File

@ -59,6 +59,41 @@ describe('registerMobileHandlers', () => {
})
})
it('includes IPv6 addresses (ranked after IPv4) and excludes link-local IPv6', () => {
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)
expect(handlers.get('mobile:listNetworkInterfaces')?.()).toEqual({
interfaces: [
{ name: 'en0', address: '192.168.1.24' },
{ name: 'en0', address: '2605:340:cd51:2a01:0:2b13:f279:c096' }
]
})
})
it('returns an IPv6 interface on an IPv6-only host (regression: was empty, breaking mobile pairing)', () => {
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)
expect(handlers.get('mobile:listNetworkInterfaces')?.()).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' }],

View File

@ -19,10 +19,20 @@ export type NetworkInterface = {
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)
}
// 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
// addresses so the user can choose which one to advertise in the QR code
// (e.g. LAN vs Tailscale).
// 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()
@ -31,14 +41,26 @@ function getNetworkInterfaces(): NetworkInterface[] {
continue
}
for (const addr of addrs) {
if (addr.family === 'IPv4' && !addr.internal) {
if (addr.internal) {
continue
}
if (addr.family === 'IPv4') {
result.push({ name, address: addr.address })
} else if (addr.family === 'IPv6' && isUsableIPv6Address(addr.address)) {
result.push({ name, address: addr.address })
}
}
}
return result.sort(
(a, b) => Number(isTailnetIPv4Address(b.address)) - Number(isTailnetIPv4Address(a.address))
)
// Why: prefer tailnet IPv4 first (most portable across networks), then other
// IPv4, then IPv6 as a fallback for IPv6-only environments.
return result.sort((a, b) => rankAddress(a.address) - rankAddress(b.address))
}
function rankAddress(address: string): number {
if (isTailnetIPv4Address(address)) {
return 0
}
return address.includes(':') ? 2 : 1
}
function getDefaultPairingAddress(): string | null {