mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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("""
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = """
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user