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:
parent
bd45d705bf
commit
c03e8f6f64
|
|
@ -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' }],
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue