From 1e80911b10f2098806842edd3bc6fea4403e33a5 Mon Sep 17 00:00:00 2001 From: Guillaume De Saint Martin Date: Tue, 28 Jul 2026 18:01:30 +0200 Subject: [PATCH] [Node] improve QR code IP selector --- .../src/lib/__tests__/pairing.test.ts | 57 ++++++++++++++ .../node_web_interface/src/lib/pairing.ts | 74 +++++++++++++++---- 2 files changed, 117 insertions(+), 14 deletions(-) create mode 100644 packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/pairing.test.ts diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/pairing.test.ts b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/pairing.test.ts new file mode 100644 index 000000000..c4f95d8e8 --- /dev/null +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/pairing.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest" + +import { + buildOriginWithHostname, + resolveExternalHostUrl, + resolvePairingNodeHostname, +} from "@/lib/pairing" + +describe("buildOriginWithHostname", () => { + it("replaces hostname while preserving protocol and port", () => { + expect( + buildOriginWithHostname("http://localhost:8000", "192.168.0.10"), + ).toBe("http://192.168.0.10:8000") + }) + + it("replaces hostname when origin has no explicit port", () => { + expect( + buildOriginWithHostname("https://localhost", "100.64.0.1"), + ).toBe("https://100.64.0.1") + }) +}) + +describe("resolveExternalHostUrl", () => { + it("returns full URLs unchanged", () => { + expect( + resolveExternalHostUrl("https://node.example.com", "http:"), + ).toBe("https://node.example.com") + }) + + it("prefixes host-only values with the browser protocol", () => { + expect( + resolveExternalHostUrl("node.example.com:443", "https:"), + ).toBe("https://node.example.com:443") + }) +}) + +describe("resolvePairingNodeHostname", () => { + const browserOrigin = "http://localhost:8000" + + it("prefers Tailscale IP when both VPN and LAN IPs are available", () => { + expect( + resolvePairingNodeHostname("100.64.0.1", "192.168.0.10", browserOrigin), + ).toBe("http://100.64.0.1:8000") + }) + + it("uses LAN IP when VPN IP is unavailable", () => { + expect( + resolvePairingNodeHostname(null, "192.168.0.10", browserOrigin), + ).toBe("http://192.168.0.10:8000") + }) + + it("falls back to browser origin when both IPs are unavailable", () => { + expect(resolvePairingNodeHostname(null, null, browserOrigin)).toBe( + browserOrigin, + ) + }) +}) diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/pairing.ts b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/pairing.ts index 299d7124e..ba9869793 100644 --- a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/pairing.ts +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/pairing.ts @@ -1,6 +1,65 @@ +import { SetupService } from "@/client" import { loadPassword } from "@/lib/device-key" import { fetchNodeConfig } from "@/lib/node-config" +export function buildOriginWithHostname(origin: string, hostname: string): string { + const url = new URL(origin) + url.hostname = hostname + return url.origin +} + +export function resolveExternalHostUrl( + externalHost: string, + protocol: string, +): string { + return /^https?:\/\//i.test(externalHost) + ? externalHost + : `${protocol}//${externalHost}` +} + +export function resolvePairingNodeHostname( + vpnIp: string | null | undefined, + localIp: string | null | undefined, + browserOrigin: string, +): string { + if (vpnIp) { + return buildOriginWithHostname(browserOrigin, vpnIp) + } + if (localIp) { + return buildOriginWithHostname(browserOrigin, localIp) + } + return browserOrigin +} + +export async function resolvePairingNodeUrl(): Promise { + // P0: admin-configured external host (reverse proxy / tailscale serve). + try { + const config = await fetchNodeConfig() + if (config?.external_host) { + return resolveExternalHostUrl(config.external_host, window.location.protocol) + } + } catch (error) { + console.error("resolvePairingNodeUrl: failed to fetch node config", error) + } + + // P1: Tailscale IP, P2: LAN IP, P3: browser origin fallback. + const [vpnResult, localResult] = await Promise.allSettled([ + SetupService.getVpnNetworkAddress(), + SetupService.getLocalNetworkAddress(), + ]) + + const vpnIp = + vpnResult.status === "fulfilled" + ? (vpnResult.value.vpn_network_ip ?? null) + : null + const localIp = + localResult.status === "fulfilled" + ? (localResult.value.local_network_ip ?? null) + : null + + return resolvePairingNodeHostname(vpnIp, localIp, window.location.origin) +} + export async function buildPairingQrValue() { const address = localStorage.getItem("auth_username") || "" const passphrase = (await loadPassword()) ?? "" @@ -9,20 +68,7 @@ export async function buildPairingQrValue() { "No active wallet session — log out and back in to refresh device key.", ) } - // Prefer the admin-configured external host (e.g. behind a reverse proxy - // like tailscale serve) over the browser's own origin, which may be a LAN - // IP or localhost that the pairing mobile device can't reach. - let url = window.location.origin - try { - const config = await fetchNodeConfig() - if (config?.external_host) { - url = /^https?:\/\//i.test(config.external_host) - ? config.external_host - : `${window.location.protocol}//${config.external_host}` - } - } catch (e) { - console.error("buildPairingQrValue: failed to fetch node config", e) - } + const url = await resolvePairingNodeUrl() return JSON.stringify({ url, address,