fix(pairing): allow private lan mobile ws

This commit is contained in:
Ayaan Zaidi
2026-04-03 13:21:39 +05:30
parent 84add47525
commit a2b53522eb
16 changed files with 274 additions and 52 deletions

View File

@@ -217,7 +217,7 @@ describe("registerQrCli", () => {
loadConfig.mockReturnValue({
gateway: {
bind: "custom",
customBindHost: "gateway.local",
customBindHost: "gateway.example",
auth: { mode: "token", token: "tok" },
},
});
@@ -225,10 +225,24 @@ describe("registerQrCli", () => {
await expectQrExit(["--setup-code-only"]);
const output = runtime.error.mock.calls.map((call) => readRuntimeCallText(call)).join("\n");
expect(output).toContain("Mobile pairing requires a secure remote gateway URL");
expect(output).toContain("Tailscale and public mobile pairing require a secure gateway URL");
expect(output).toContain("gateway.tailscale.mode=serve");
});
it("allows lan mdns cleartext setup urls", async () => {
loadConfig.mockReturnValue({
gateway: {
bind: "custom",
customBindHost: "gateway.local",
auth: { mode: "token", token: "tok" },
},
});
await runQr(["--setup-code-only"]);
expectLoggedSetupCode("ws://gateway.local:18789");
});
it("allows android emulator cleartext override urls", async () => {
loadConfig.mockReturnValue({
gateway: {

View File

@@ -414,6 +414,36 @@ describe("pairing setup code", () => {
urlSource: "gateway.bind=custom",
},
},
{
name: "allows lan ip cleartext setup urls",
config: {
gateway: {
bind: "custom",
customBindHost: "192.168.1.20",
auth: { mode: "token", token: "tok_123" },
},
} satisfies ResolveSetupConfig,
expected: {
authLabel: "token",
url: "ws://192.168.1.20:18789",
urlSource: "gateway.bind=custom",
},
},
{
name: "allows mdns hostname cleartext setup urls",
config: {
gateway: {
bind: "custom",
customBindHost: "gateway.local",
auth: { mode: "token", token: "tok_123" },
},
} satisfies ResolveSetupConfig,
expected: {
authLabel: "token",
url: "ws://gateway.local:18789",
urlSource: "gateway.bind=custom",
},
},
] as const)("$name", async ({ config, options, expected }) => {
await expectResolvedSetupSuccessCase({
config,
@@ -424,15 +454,15 @@ describe("pairing setup code", () => {
it.each([
{
name: "rejects custom bind remote ws setup urls for mobile pairing",
name: "rejects custom bind public ws setup urls for mobile pairing",
config: {
gateway: {
bind: "custom",
customBindHost: "gateway.local",
customBindHost: "gateway.example",
auth: { mode: "token", token: "tok_123" },
},
} satisfies ResolveSetupConfig,
expectedError: "Mobile pairing requires a secure remote gateway URL",
expectedError: "Tailscale and public mobile pairing require a secure gateway URL",
},
{
name: "rejects tailnet bind remote ws setup urls for mobile pairing",
@@ -447,8 +477,16 @@ describe("pairing setup code", () => {
} satisfies ResolveSetupOptions,
expectedError: "prefer gateway.tailscale.mode=serve",
},
{
name: "rejects lan bind remote ws setup urls for mobile pairing",
] as const)("$name", async ({ config, options, expectedError }) => {
await expectResolvedSetupFailureCase({
config,
options,
expectedError,
});
});
it("allows lan bind cleartext setup urls for mobile pairing", async () => {
await expectResolvedSetupSuccessCase({
config: {
gateway: {
bind: "lan",
@@ -458,13 +496,11 @@ describe("pairing setup code", () => {
options: {
networkInterfaces: () => createIpv4NetworkInterfaces("192.168.1.20"),
} satisfies ResolveSetupOptions,
expectedError: "ws:// is only valid for localhost or the Android emulator",
},
] as const)("$name", async ({ config, options, expectedError }) => {
await expectResolvedSetupFailureCase({
config,
options,
expectedError,
expected: {
authLabel: "password",
url: "ws://192.168.1.20:18789",
urlSource: "gateway.bind=lan",
},
});
});

View File

@@ -16,7 +16,13 @@ import {
} from "../infra/network-interfaces.js";
import { PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-profile.js";
import { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js";
import { isCarrierGradeNatIpv4Address, isRfc1918Ipv4Address } from "../shared/net/ip.js";
import {
isCarrierGradeNatIpv4Address,
isIpv4Address,
isIpv6Address,
isRfc1918Ipv4Address,
parseCanonicalIpAddress,
} from "../shared/net/ip.js";
import { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js";
export type PairingSetupPayload = {
@@ -66,15 +72,50 @@ type ResolveUrlResult = {
function describeSecureMobilePairingFix(source?: string): string {
const sourceNote = source ? ` Resolved source: ${source}.` : "";
return (
"Mobile pairing requires a secure remote gateway URL (wss://) or Tailscale Serve/Funnel." +
"Tailscale and public mobile pairing require a secure gateway URL (wss://) or Tailscale Serve/Funnel." +
sourceNote +
" Fix: prefer gateway.tailscale.mode=serve, or set gateway.remote.url / " +
"plugins.entries.device-pair.config.publicUrl to a wss:// URL. ws:// is only valid for localhost or the Android emulator."
" Fix: use a private LAN host/address, prefer gateway.tailscale.mode=serve, or set " +
"gateway.remote.url / plugins.entries.device-pair.config.publicUrl to a wss:// URL. " +
"ws:// is only valid for localhost, private LAN, or the Android emulator."
);
}
function isPrivateLanHostname(host: string): boolean {
const normalized = host.trim().toLowerCase().replace(/\.+$/, "");
if (!normalized) {
return false;
}
return normalized.endsWith(".local") || (!normalized.includes(".") && !normalized.includes(":"));
}
function isPrivateLanIpHost(host: string): boolean {
if (isRfc1918Ipv4Address(host)) {
return true;
}
const parsed = parseCanonicalIpAddress(host);
if (!parsed) {
return false;
}
if (isIpv4Address(parsed)) {
const normalized = parsed.toString();
return normalized.startsWith("169.254.") && !isCarrierGradeNatIpv4Address(normalized);
}
if (!isIpv6Address(parsed)) {
return false;
}
const normalized = parsed.toString().toLowerCase();
return (
normalized.startsWith("fe80:") || normalized.startsWith("fc") || normalized.startsWith("fd")
);
}
function isMobilePairingCleartextAllowedHost(host: string): boolean {
return isLoopbackHost(host) || host === "10.0.2.2";
return (
isLoopbackHost(host) ||
host === "10.0.2.2" ||
isPrivateLanIpHost(host) ||
isPrivateLanHostname(host)
);
}
function validateMobilePairingUrl(url: string, source?: string): string | null {