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:
parent
8213b0aee3
commit
197e351880
@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user