fix: repair iOS LAN pairing

Fix iOS LAN/setup-code pairing policy for #47887.

- Allow explicit private LAN and .local plaintext ws:// setup/manual connects where policy allows it.
- Keep public hosts, .ts.net, and Tailscale CGNAT plaintext fail-closed.
- Prefer explicit passwords over stale bootstrap tokens in Swift and TypeScript gateway clients.
- Update setup-code/device-pair coverage, docs, and changelog with source credit for #65185.

Verification:
- pnpm install
- git diff --check origin/main..HEAD
- pnpm exec oxfmt --check --threads=1 src/gateway/client.ts src/gateway/client.test.ts src/pairing/setup-code.ts src/pairing/setup-code.test.ts extensions/device-pair/index.ts extensions/device-pair/index.test.ts
- pnpm format:docs:check
- pnpm test src/gateway/client.test.ts src/pairing/setup-code.test.ts extensions/device-pair/index.test.ts
- cd apps/shared/OpenClawKit && swift test --filter 'DeepLinksSecurityTests|GatewayNodeSessionTests'
- pnpm lint:swift passes with the existing TalkModeRuntime.swift type-body-length warning

Blocked locally:
- iOS app-target xcodebuild tests require unavailable watchOS 26.4 runtime here.
- Testbox check:changed previously failed because the image lacks swiftlint; local swiftlint passes.
This commit is contained in:
Val Alexander
2026-05-05 21:07:19 -05:00
committed by GitHub
parent ae7c13e284
commit 36df0d93b9
17 changed files with 277 additions and 98 deletions

View File

@@ -102,6 +102,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Slack: preserve Socket Mode SDK error context and structured Slack API fields in reconnect logs, so startup failures no longer collapse to a bare `unknown error`.
- iOS pairing: allow setup-code and manual `ws://` connects for private LAN and `.local` gateways while keeping Tailscale/public routes on `wss://`, and prefer explicit gateway passwords over stale bootstrap tokens in mixed-auth reconnects. Fixes #47887; carries forward #65185. Thanks @draix and @BunsDev.
- Plugins/diagnostics: make source-only TypeScript package warnings actionable by explaining that missing compiled runtime output is a publisher packaging issue and pointing users to update/reinstall or disable/uninstall the plugin. Fixes #77835. Thanks @googlerest.
- TUI: skip the generic CLI respawn wrapper for interactive launches, exit cleanly on terminal loss, and refuse to restore heartbeat sessions as the remembered chat session, preventing stale heartbeat history and orphaned `openclaw-tui` processes on first boot. Thanks @vincentkoc.
- Doctor/sessions: move heartbeat-poisoned default main session store entries to recovery keys and clear stale TUI restore pointers, so `doctor --fix` can repair instances already stuck on `agent:main:main` heartbeat history. Thanks @vincentkoc.

View File

@@ -689,7 +689,7 @@ final class GatewayConnectionController {
}
private func shouldRequireTLS(host: String) -> Bool {
!Self.isLoopbackHost(host)
!LoopbackHost.isLocalNetworkHost(host)
}
private func shouldForceTLS(host: String) -> Bool {
@@ -698,51 +698,6 @@ final class GatewayConnectionController {
return trimmed.hasSuffix(".ts.net") || trimmed.hasSuffix(".ts.net.")
}
private static func isLoopbackHost(_ rawHost: String) -> Bool {
var host = rawHost.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !host.isEmpty else { return false }
if host.hasPrefix("[") && host.hasSuffix("]") {
host.removeFirst()
host.removeLast()
}
if host.hasSuffix(".") {
host.removeLast()
}
if let zoneIndex = host.firstIndex(of: "%") {
host = String(host[..<zoneIndex])
}
if host.isEmpty { return false }
if host == "localhost" || host == "0.0.0.0" || host == "::" {
return true
}
return Self.isLoopbackIPv4(host) || Self.isLoopbackIPv6(host)
}
private static func isLoopbackIPv4(_ host: String) -> Bool {
var addr = in_addr()
let parsed = host.withCString { inet_pton(AF_INET, $0, &addr) == 1 }
guard parsed else { return false }
let value = UInt32(bigEndian: addr.s_addr)
let firstOctet = UInt8((value >> 24) & 0xFF)
return firstOctet == 127
}
private static func isLoopbackIPv6(_ host: String) -> Bool {
var addr = in6_addr()
let parsed = host.withCString { inet_pton(AF_INET6, $0, &addr) == 1 }
guard parsed else { return false }
return withUnsafeBytes(of: &addr) { rawBytes in
let bytes = rawBytes.bindMemory(to: UInt8.self)
let isV6Loopback = bytes[0..<15].allSatisfy { $0 == 0 } && bytes[15] == 1
if isV6Loopback { return true }
let isMappedV4 = bytes[0..<10].allSatisfy { $0 == 0 } && bytes[10] == 0xFF && bytes[11] == 0xFF
return isMappedV4 && bytes[12] == 127
}
}
private func manualStableID(host: String, port: Int) -> String {
"manual|\(host.lowercased())|\(port)"
}

View File

@@ -101,6 +101,20 @@ private func agentAction(
#expect(DeepLinkParser.parse(url) == nil)
}
@Test func parseGatewayLinkAllowsPrivateLanWs() {
let url = URL(
string: "openclaw://gateway?host=openclaw.local&port=18789&tls=0&token=abc")!
#expect(
DeepLinkParser.parse(url) == .gateway(
.init(
host: "openclaw.local",
port: 18789,
tls: false,
bootstrapToken: nil,
token: "abc",
password: nil)))
}
@Test func parseGatewayLinkRejectsInsecurePrefixBypassHost() {
let url = URL(
string: "openclaw://gateway?host=127.attacker.example&port=18789&tls=0&token=abc")!
@@ -162,6 +176,25 @@ private func agentAction(
password: nil))
}
@Test func parseGatewaySetupCodeAllowsPrivateLanWs() {
let payload = #"{"url":"ws://openclaw.local:18789","bootstrapToken":"tok"}"#
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == .init(
host: "openclaw.local",
port: 18789,
tls: false,
bootstrapToken: "tok",
token: nil,
password: nil))
}
@Test func parseGatewaySetupCodeRejectsTailnetPlaintextWs() {
let payload = #"{"url":"ws://gateway.tailnet.ts.net:18789","bootstrapToken":"tok"}"#
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == nil)
}
@Test func parseGatewaySetupInputParsesFullCopiedSetupMessage() {
let payload = #"{"url":"wss://gateway.example.com","bootstrapToken":"tok"}"#
let link = GatewayConnectDeepLink.fromSetupInput("""

View File

@@ -107,8 +107,9 @@ import Testing
let controller = makeController()
#expect(controller._test_resolveManualUseTLS(host: "gateway.example.com", useTLS: false) == true)
#expect(controller._test_resolveManualUseTLS(host: "openclaw.local", useTLS: false) == true)
#expect(controller._test_resolveManualUseTLS(host: "127.attacker.example", useTLS: false) == true)
#expect(controller._test_resolveManualUseTLS(host: "gateway.ts.net", useTLS: false) == true)
#expect(controller._test_resolveManualUseTLS(host: "100.64.0.9", useTLS: false) == true)
#expect(controller._test_resolveManualUseTLS(host: "localhost", useTLS: false) == false)
#expect(controller._test_resolveManualUseTLS(host: "127.0.0.1", useTLS: false) == false)
@@ -118,6 +119,17 @@ import Testing
#expect(controller._test_resolveManualUseTLS(host: "0.0.0.0", useTLS: false) == false)
}
@Test @MainActor func manualConnectionsAllowPrivateLanPlaintext() async {
let controller = makeController()
#expect(controller._test_resolveManualUseTLS(host: "openclaw.local", useTLS: false) == false)
#expect(controller._test_resolveManualUseTLS(host: "192.168.1.20", useTLS: false) == false)
#expect(controller._test_resolveManualUseTLS(host: "10.0.0.5", useTLS: false) == false)
#expect(controller._test_resolveManualUseTLS(host: "172.16.1.5", useTLS: false) == false)
#expect(controller._test_resolveManualUseTLS(host: "169.254.1.5", useTLS: false) == false)
#expect(controller._test_resolveManualUseTLS(host: "fd00::1", useTLS: false) == false)
}
@Test @MainActor func manualDefaultPortUses443OnlyForTailnetTLSHosts() async {
let controller = makeController()

View File

@@ -116,7 +116,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
return nil
}
let tls = payload.tls ?? true
if !tls, !LoopbackHost.isLoopbackHost(host) {
if !tls, !LoopbackHost.isLocalNetworkHost(host) {
return nil
}
return GatewayConnectDeepLink(
@@ -143,7 +143,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
return nil
}
let tls = scheme == "wss" || scheme == "https"
if !tls, !LoopbackHost.isLoopbackHost(hostname) {
if !tls, !LoopbackHost.isLocalNetworkHost(hostname) {
return nil
}
return GatewayConnectDeepLink(
@@ -254,7 +254,7 @@ public enum DeepLinkParser {
}
let port = query["port"].flatMap { Int($0) } ?? 18789
let tls = (query["tls"] as NSString?)?.boolValue ?? false
if !tls, !LoopbackHost.isLoopbackHost(hostParam) {
if !tls, !LoopbackHost.isLocalNetworkHost(hostParam) {
return nil
}
return .gateway(

View File

@@ -522,7 +522,8 @@ public actor GatewayChannelActor {
(includeDeviceIdentity && explicitPassword == nil && explicitBootstrapToken == nil
? storedToken
: nil)
let authBootstrapToken = authToken == nil ? explicitBootstrapToken : nil
let authBootstrapToken =
authToken == nil && explicitPassword == nil ? explicitBootstrapToken : nil
let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil
let authSource: GatewayAuthSource = if authDeviceToken != nil || (explicitToken == nil && authToken != nil) {
.deviceToken

View File

@@ -41,16 +41,32 @@ public enum LoopbackHost {
}
public static func isLocalNetworkHost(_ rawHost: String) -> Bool {
let host = rawHost.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let host = self.normalizedHost(rawHost)
guard !host.isEmpty else { return false }
if self.isLoopbackHost(host) { return true }
if host.hasSuffix(".local") { return true }
if host.hasSuffix(".ts.net") { return true }
if host.hasSuffix(".tailscale.net") { return true }
// Allow MagicDNS / LAN hostnames like "peters-mac-studio-1".
if !host.contains("."), !host.contains(":") { return true }
guard let ipv4 = self.parseIPv4(host) else { return false }
return self.isLocalNetworkIPv4(ipv4)
if let ipv4 = self.parseIPv4(host) {
return self.isLocalNetworkIPv4(ipv4)
}
guard let ipv6 = IPv6Address(host) else { return false }
let bytes = Array(ipv6.rawValue)
let isUniqueLocal = (bytes[0] & 0xFE) == 0xFC
let isLinkLocal = bytes[0] == 0xFE && (bytes[1] & 0xC0) == 0x80
return isUniqueLocal || isLinkLocal
}
static func normalizedHost(_ rawHost: String) -> String {
var host = rawHost
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
.trimmingCharacters(in: CharacterSet(charactersIn: "[]"))
if host.hasSuffix(".") {
host.removeLast()
}
if let zoneIndex = host.firstIndex(of: "%") {
host = String(host[..<zoneIndex])
}
return host
}
static func parseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? {
@@ -73,8 +89,6 @@ public enum LoopbackHost {
if a == 127 { return true }
// 169.254.0.0/16 (link-local)
if a == 169, b == 254 { return true }
// Tailscale: 100.64.0.0/10
if a == 100, (64...127).contains(Int(b)) { return true }
return false
}
}

View File

@@ -59,6 +59,40 @@ private func setupCode(from payload: String) -> String {
password: nil))
}
@Test func setupCodeAllowsPrivateLanWs() {
let payload = #"{"url":"ws://192.168.1.20:18789","bootstrapToken":"tok"}"#
#expect(
GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == .init(
host: "192.168.1.20",
port: 18789,
tls: false,
bootstrapToken: "tok",
token: nil,
password: nil))
}
@Test func setupCodeAllowsMDNSWs() {
let payload = #"{"url":"ws://openclaw.local:18789","bootstrapToken":"tok"}"#
#expect(
GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == .init(
host: "openclaw.local",
port: 18789,
tls: false,
bootstrapToken: "tok",
token: nil,
password: nil))
}
@Test func setupCodeRejectsTailnetPlaintextWs() {
let payload = #"{"url":"ws://gateway.tailnet.ts.net:18789","bootstrapToken":"tok"}"#
#expect(GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == nil)
}
@Test func setupCodeRejectsCgnatPlaintextWs() {
let payload = #"{"url":"ws://100.64.0.9:18789","bootstrapToken":"tok"}"#
#expect(GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == nil)
}
@Test func setupCodeParsesHostPayload() {
let payload = #"{"host":"gateway.tailnet.ts.net","port":443,"tls":true,"bootstrapToken":"tok"}"#
#expect(
@@ -88,6 +122,18 @@ private func setupCode(from payload: String) -> String {
#expect(GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == nil)
}
@Test func setupCodeAllowsPrivateLanHostPayload() {
let payload = #"{"host":"openclaw.local","port":18789,"tls":false,"bootstrapToken":"tok"}"#
#expect(
GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) == .init(
host: "openclaw.local",
port: 18789,
tls: false,
bootstrapToken: "tok",
token: nil,
password: nil))
}
@Test func setupInputParsesFullCopiedSetupMessage() {
let payload = #"{"url":"wss://gateway.tailnet.ts.net","bootstrapToken":"tok"}"#
let message = """

View File

@@ -249,6 +249,42 @@ struct GatewayNodeSessionTests {
await gateway.disconnect()
}
@Test
func passwordTakesPrecedenceOverBootstrapToken() async throws {
let session = FakeGatewayWebSocketSession()
let gateway = GatewayNodeSession()
let options = GatewayConnectOptions(
role: "operator",
scopes: ["operator.read"],
caps: [],
commands: [],
permissions: [:],
clientId: "openclaw-ios-test",
clientMode: "ui",
clientDisplayName: "iOS Test",
includeDeviceIdentity: false)
try await gateway.connect(
url: URL(string: "ws://example.invalid")!,
token: nil,
bootstrapToken: "stale-bootstrap-token",
password: "shared-password",
connectOptions: options,
sessionBox: WebSocketSessionBox(session: session),
onConnected: {},
onDisconnected: { _ in },
onInvoke: { req in
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
})
let auth = try #require(session.latestTask()?.latestConnectAuth())
#expect(auth["password"] as? String == "shared-password")
#expect(auth["bootstrapToken"] == nil)
#expect(auth["token"] == nil)
await gateway.disconnect()
}
@Test
func bootstrapHelloStoresAdditionalDeviceTokens() async throws {
let tempDir = FileManager.default.temporaryDirectory

View File

@@ -134,12 +134,11 @@ That bootstrap token carries the built-in pairing bootstrap profile:
Treat the setup code like a password while it is valid.
For Tailscale, public, or other non-loopback mobile pairing, use Tailscale
Serve/Funnel or another `wss://` Gateway URL. Direct non-loopback `ws://` setup
URLs are rejected before QR/setup-code issuance. Plaintext `ws://` setup codes
are limited to loopback URLs; private-network `ws://` clients still require the explicit
`OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` break-glass described in the remote
Gateway guide.
For Tailscale, public, or other remote mobile pairing, use Tailscale Serve/Funnel
or another `wss://` Gateway URL. Plaintext `ws://` setup codes are accepted only
for loopback, private LAN addresses, `.local` Bonjour hosts, and the Android
emulator host. Tailnet CGNAT addresses, `.ts.net` names, and public hosts still
fail closed before QR/setup-code issuance.
### Approve a node device

View File

@@ -38,7 +38,7 @@ openclaw qr --url wss://gateway.example/ws
- In the built-in node/operator bootstrap flow, the primary node token still lands with `scopes: []`.
- If bootstrap handoff also issues an operator token, it stays bounded to the bootstrap allowlist: `operator.approvals`, `operator.read`, `operator.talk.secrets`, `operator.write`.
- Bootstrap scope checks are role-prefixed. That operator allowlist only satisfies operator requests; non-operator roles still need scopes under their own role prefix.
- Mobile pairing fails closed for Tailscale/public `ws://` gateway URLs. Private LAN `ws://` remains supported, but Tailscale/public mobile routes should use Tailscale Serve/Funnel or a `wss://` gateway URL.
- Mobile pairing fails closed for Tailscale/public `ws://` gateway URLs. Private LAN addresses and `.local` Bonjour hosts remain supported over `ws://`, but Tailscale/public mobile routes should use Tailscale Serve/Funnel or a `wss://` gateway URL.
- With `--remote`, OpenClaw requires either `gateway.remote.url` or
`gateway.tailscale.mode=serve|funnel`.
- With `--remote`, if effectively active remote credentials are configured as SecretRefs and you do not pass `--token` or `--password`, the command resolves them from the active gateway snapshot. If gateway is unavailable, the command fails fast.

View File

@@ -742,7 +742,7 @@ describe("device-pair /pair default setup code", () => {
expect(text).toContain("Gateway: ws://127.0.0.1:18789");
});
it("rejects private LAN cleartext setup urls before issuing setup codes", async () => {
it("allows private LAN cleartext setup urls", async () => {
const command = registerPairCommand({
pluginConfig: {
publicUrl: "ws://192.168.1.20:18789",
@@ -757,10 +757,27 @@ describe("device-pair /pair default setup code", () => {
}),
);
expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled();
expect(requireText(result)).toContain(
"Mobile pairing over non-loopback networks requires a secure gateway URL",
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1);
expect(requireText(result)).toContain("Gateway: ws://192.168.1.20:18789");
});
it("allows mdns cleartext setup urls", async () => {
const command = registerPairCommand({
pluginConfig: {
publicUrl: "ws://openclaw.local:18789",
},
});
const result = await command.handler(
createCommandContext({
channel: "webchat",
args: "",
commandBody: "/pair",
gatewayClientScopes: ["operator.write", "operator.pairing"],
}),
);
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1);
expect(requireText(result)).toContain("Gateway: ws://openclaw.local:18789");
});
it("rejects public cleartext setup urls before issuing setup codes", async () => {
@@ -780,7 +797,7 @@ describe("device-pair /pair default setup code", () => {
expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled();
expect(requireText(result)).toContain(
"Mobile pairing over non-loopback networks requires a secure gateway URL",
"Tailscale and public mobile pairing require a secure gateway URL",
);
});

View File

@@ -175,11 +175,11 @@ function parseNormalizedGatewayUrl(raw: string): string | null {
function describeSecureMobilePairingFix(source?: string): string {
const sourceNote = source ? ` Resolved source: ${source}.` : "";
return (
"Mobile pairing over non-loopback networks requires a secure 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 " +
" Fix: use a private LAN address, prefer gateway.tailscale.mode=serve, or set " +
"gateway.remote.url / plugins.entries.device-pair.config.publicUrl to a wss:// URL. " +
"ws:// setup codes are only valid for localhost/loopback or the Android emulator."
"ws:// setup codes are only valid for localhost/loopback, private LAN addresses, .local hosts, or the Android emulator."
);
}
@@ -256,6 +256,21 @@ function isPrivateIPv4(address: string): boolean {
return false;
}
function isPrivateLanCleartextHost(host: string): boolean {
const normalized = normalizeHostForIpCheck(host);
if (normalized.endsWith(".local")) {
return true;
}
if (isPrivateIPv4(normalized)) {
return true;
}
const octets = parseIPv4Octets(normalized);
if (!octets) {
return false;
}
return octets[0] === 169 && octets[1] === 254;
}
function isTailnetIPv4(address: string): boolean {
const octets = parseIPv4Octets(address);
if (!octets) {
@@ -267,7 +282,9 @@ function isTailnetIPv4(address: string): boolean {
function isMobilePairingCleartextAllowedHost(host: string): boolean {
const normalized = normalizeHostForIpCheck(host);
return isLoopbackHost(normalized) || normalized === "10.0.2.2";
return (
isLoopbackHost(normalized) || normalized === "10.0.2.2" || isPrivateLanCleartextHost(normalized)
);
}
function validateMobilePairingUrl(url: string, source?: string): string | null {

View File

@@ -800,6 +800,26 @@ describe("GatewayClient connect auth payload", () => {
client.stop();
});
it("prefers explicit shared password over bootstrap token", () => {
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
bootstrapToken: "stale-bootstrap-token",
password: "shared-password", // pragma: allowlist secret
});
client.start();
const ws = getLatestWs();
ws.emitOpen();
emitConnectChallenge(ws);
expect(connectFrameFrom(ws)).toMatchObject({
password: "shared-password", // pragma: allowlist secret
});
expect(connectFrameFrom(ws).bootstrapToken).toBeUndefined();
expect(connectFrameFrom(ws).token).toBeUndefined();
client.stop();
});
it("uses stored device token scopes when shared token is not provided", () => {
loadDeviceAuthTokenMock.mockReturnValue({
token: "stored-device-token",

View File

@@ -799,7 +799,9 @@ export class GatewayClient {
// no explicit shared token is present.
const authToken = explicitGatewayToken ?? resolvedDeviceToken;
const authBootstrapToken =
!explicitGatewayToken && !resolvedDeviceToken ? explicitBootstrapToken : undefined;
!explicitGatewayToken && !resolvedDeviceToken && !authPassword
? explicitBootstrapToken
: undefined;
return {
authToken,
authBootstrapToken,

View File

@@ -467,6 +467,21 @@ describe("pairing setup code", () => {
urlSource: "gateway.bind=custom",
},
},
{
name: "allows mdns 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",
},
},
{
name: "allows lan ip cleartext setup urls",
config: {
@@ -502,17 +517,6 @@ describe("pairing setup code", () => {
} satisfies ResolveSetupConfig,
expectedError: "Tailscale and public mobile pairing require a secure gateway URL",
},
{
name: "rejects mdns hostname cleartext setup urls",
config: {
gateway: {
bind: "custom",
customBindHost: "gateway.local",
auth: { mode: "token", token: "tok_123" },
},
} satisfies ResolveSetupConfig,
expectedError: "private LAN IP address",
},
{
name: "rejects tailnet bind remote ws setup urls for mobile pairing",
config: {

View File

@@ -74,35 +74,57 @@ function describeSecureMobilePairingFix(source?: string): string {
return (
"Tailscale and public mobile pairing require a secure gateway URL (wss://) or Tailscale Serve/Funnel." +
sourceNote +
" Fix: use a private LAN IP address, prefer gateway.tailscale.mode=serve, or set " +
" Fix: use a private LAN 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 IP addresses, or the Android emulator."
"ws:// is only valid for localhost, private LAN addresses, .local hosts, or the Android emulator."
);
}
function isPrivateLanIpHost(host: string): boolean {
if (isRfc1918Ipv4Address(host)) {
function normalizeMobilePairingHost(host: string): string {
let normalized = normalizeLowercaseStringOrEmpty(host);
if (normalized.startsWith("[") && normalized.endsWith("]")) {
normalized = normalized.slice(1, -1);
}
if (normalized.endsWith(".")) {
normalized = normalized.slice(0, -1);
}
const zoneIndex = normalized.indexOf("%");
if (zoneIndex >= 0) {
normalized = normalized.slice(0, zoneIndex);
}
return normalized;
}
function isPrivateLanHost(host: string): boolean {
const normalized = normalizeMobilePairingHost(host);
if (normalized.endsWith(".local")) {
return true;
}
const parsed = parseCanonicalIpAddress(host);
if (isRfc1918Ipv4Address(normalized)) {
return true;
}
const parsed = parseCanonicalIpAddress(normalized);
if (!parsed) {
return false;
}
if (isIpv4Address(parsed)) {
const normalized = parsed.toString();
return normalized.startsWith("169.254.") && !isCarrierGradeNatIpv4Address(normalized);
const normalizedIp = parsed.toString();
return normalizedIp.startsWith("169.254.") && !isCarrierGradeNatIpv4Address(normalizedIp);
}
if (!isIpv6Address(parsed)) {
return false;
}
const normalized = normalizeLowercaseStringOrEmpty(parsed.toString());
const normalizedIp = normalizeLowercaseStringOrEmpty(parsed.toString());
return (
normalized.startsWith("fe80:") || normalized.startsWith("fc") || normalized.startsWith("fd")
normalizedIp.startsWith("fe80:") ||
normalizedIp.startsWith("fc") ||
normalizedIp.startsWith("fd")
);
}
function isMobilePairingCleartextAllowedHost(host: string): boolean {
return isLoopbackHost(host) || host === "10.0.2.2" || isPrivateLanIpHost(host);
const normalized = normalizeMobilePairingHost(host);
return isLoopbackHost(normalized) || normalized === "10.0.2.2" || isPrivateLanHost(normalized);
}
function validateMobilePairingUrl(url: string, source?: string): string | null {