fix(companion): pairing QR advertises the LAN address, never a tailnet IP

The QR mirrored the browser origin — an operator browsing over Tailscale
handed the phone a 100.x address it has no route to, so pairing silently
never connected (reported 2026-07-22; the 'random password' alongside it
was the device token working as designed). system.get-hostname now also
returns the default-route LAN IPv4; the QR substitutes it (or the mDNS
name) whenever the origin is localhost or a CGNAT/tailnet 100.64/10 IP.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-22 19:08:17 -04:00
parent 8213b0aee3
commit 197e351880
2 changed files with 32 additions and 6 deletions

View File

@ -139,9 +139,17 @@ impl RpcHandler {
.await
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| "archipelago".to_string());
// LAN IPv4 rides along for the companion pairing QR: when the
// operator's browser reaches this node over Tailscale/VPN or
// localhost, that origin is useless to a phone on the LAN — the QR
// must advertise an address the phone can actually dial
// (2026-07-22: a pairing QR carried a tailnet 100.x IP and the
// companion could never connect).
let lan_ip = crate::host_ip::primary_host_ipv4().await;
Ok(serde_json::json!({
"hostname": hostname,
"mdns_hostname": format!("{hostname}.local"),
"lan_ip": lan_ip,
}))
}

View File

@ -252,21 +252,39 @@ watch(visible, async (isVisible) => {
})
}, { immediate: true, flush: 'post' })
/** Tailscale/CGNAT range 100.64.0.0/10 — reachable only inside the tailnet. */
function isTailnetIp(host: string): boolean {
const m = host.match(/^100\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/)
return !!m && Number(m[1]) >= 64 && Number(m[1]) <= 127
}
/**
* The server URL the companion app should connect to. The demo advertises its
* public https origin; a real node advertises whatever address the browser is
* already using except on the kiosk, where that is localhost and useless to
* a phone, so fall back to the node's mDNS .local name.
* public https origin; a real node advertises the browser's own origin ONLY
* when a phone could plausibly reach it too. Two origins that a phone on the
* LAN can never dial get substituted with the node's real LAN address:
* - localhost/127.0.0.1 (the kiosk browses itself)
* - a tailnet 100.x address (operator browsing over Tailscale a scanned
* QR carried one of these on 2026-07-22 and the companion sat there
* dialing an IP the phone had no route to)
*/
async function resolveServerUrl(): Promise<string> {
if (IS_DEMO) return DEMO_SERVER_URL
const { hostname, origin } = window.location
if (hostname !== 'localhost' && hostname !== '127.0.0.1') return origin
const phoneUnreachable =
hostname === 'localhost' || hostname === '127.0.0.1' || isTailnetIp(hostname)
if (!phoneUnreachable) return origin
try {
const res = await rpcClient.call<{ mdns_hostname?: string }>({ method: 'system.get-hostname' })
const res = await rpcClient.call<{ mdns_hostname?: string; lan_ip?: string | null }>({
method: 'system.get-hostname',
})
// Prefer the LAN IP (phones resolve it without mDNS support Android
// notoriously lacks .local); fall back to the mDNS name.
if (res?.lan_ip) return `http://${res.lan_ip}`
if (res?.mdns_hostname) return `http://${res.mdns_hostname}`
} catch {
// RPC unavailable fall through to the (localhost) origin
// RPC unavailable fall through to the (unreachable) origin; the app
// still lets the user edit the address by hand.
}
return origin
}