mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-03 17:00:25 +00:00
fix(pairing): allow private lan mobile ws
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user