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
|
.await
|
||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
.unwrap_or_else(|_| "archipelago".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!({
|
Ok(serde_json::json!({
|
||||||
"hostname": hostname,
|
"hostname": hostname,
|
||||||
"mdns_hostname": format!("{hostname}.local"),
|
"mdns_hostname": format!("{hostname}.local"),
|
||||||
|
"lan_ip": lan_ip,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -252,21 +252,39 @@ watch(visible, async (isVisible) => {
|
|||||||
})
|
})
|
||||||
}, { immediate: true, flush: 'post' })
|
}, { 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
|
* 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
|
* public https origin; a real node advertises the browser's own origin ONLY
|
||||||
* already using — except on the kiosk, where that is localhost and useless to
|
* when a phone could plausibly reach it too. Two origins that a phone on the
|
||||||
* a phone, so fall back to the node's mDNS .local name.
|
* 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> {
|
async function resolveServerUrl(): Promise<string> {
|
||||||
if (IS_DEMO) return DEMO_SERVER_URL
|
if (IS_DEMO) return DEMO_SERVER_URL
|
||||||
const { hostname, origin } = window.location
|
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 {
|
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}`
|
if (res?.mdns_hostname) return `http://${res.mdns_hostname}`
|
||||||
} catch {
|
} 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
|
return origin
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user