From ab7038af303d8adb1b8cc38e1b055a8c9d1bbcf3 Mon Sep 17 00:00:00 2001 From: Clint Baxley Date: Sat, 14 Mar 2026 03:32:32 -0400 Subject: [PATCH] feat(gateway): detect port-based Tailscale Serve proxies (#330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing Tailscale Serve detection only looked for a /gw path handler, missing configurations where the gateway is proxied on a separate port (e.g. :8443 → localhost:18789). This caused remote browsers to attempt direct wss connections to the gateway port, which fails through Tailscale. Extract Tailscale Serve detection into src/lib/tailscale-serve.ts and extend it to discover port-based proxy mappings from the Tailscale Serve JSON config. Also adds the macOS app bundle path for the Tailscale CLI binary, which is not on PATH when installed via the Mac App Store. --- src/app/api/gateways/connect/route.ts | 71 +++-------- src/lib/__tests__/tailscale-serve.test.ts | 138 ++++++++++++++++++++++ src/lib/tailscale-serve.ts | 127 ++++++++++++++++++++ 3 files changed, 283 insertions(+), 53 deletions(-) create mode 100644 src/lib/__tests__/tailscale-serve.test.ts create mode 100644 src/lib/tailscale-serve.ts diff --git a/src/app/api/gateways/connect/route.ts b/src/app/api/gateways/connect/route.ts index c20e5ad..cb597a0 100644 --- a/src/app/api/gateways/connect/route.ts +++ b/src/app/api/gateways/connect/route.ts @@ -1,9 +1,15 @@ import { NextRequest, NextResponse } from 'next/server' -import { readFileSync } from 'node:fs' import { requireRole } from '@/lib/auth' import { getDatabase } from '@/lib/db' import { buildGatewayWebSocketUrl } from '@/lib/gateway-url' import { getDetectedGatewayToken } from '@/lib/gateway-runtime' +import { + isTailscaleServe, + refreshTailscaleCache, + getCachedTailscaleWeb, + hasGwPathHandler, + findTailscaleServePort, +} from '@/lib/tailscale-serve' interface GatewayEntry { id: number @@ -35,56 +41,6 @@ function inferBrowserProtocol(request: NextRequest): 'http:' | 'https:' { const LOCALHOST_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']) -/** - * Detect whether Tailscale Serve is proxying a `/gw` route to the gateway. - * - * Checks in order: - * 1. `tailscale serve status --json` — look for a /gw handler (authoritative) - * 2. Fallback: `gateway.tailscale.mode === 'serve'` in openclaw.json (legacy) - */ -function detectTailscaleServe(): boolean { - // 1. Check live Tailscale Serve config for a /gw handler - try { - const { execFileSync } = require('node:child_process') - const raw = execFileSync('tailscale', ['serve', 'status', '--json'], { - timeout: 3000, - encoding: 'utf-8', - stdio: ['ignore', 'pipe', 'ignore'], - }) - const config = JSON.parse(raw) - const web = config?.Web - if (web) { - for (const host of Object.values(web) as any[]) { - if ((host as any)?.Handlers?.['/gw']) return true - } - } - } catch { - // tailscale CLI not available or not running — fall through - } - - // 2. Legacy: check openclaw.json config - const configPath = process.env.OPENCLAW_CONFIG_PATH || '' - if (!configPath) return false - try { - const raw = readFileSync(configPath, 'utf-8') - const config = JSON.parse(raw) - return config?.gateway?.tailscale?.mode === 'serve' - } catch { - return false - } -} - -/** Cache Tailscale Serve detection with 60-second TTL. */ -let _tailscaleServeCache: { value: boolean; expiresAt: number } | null = null -const TAILSCALE_CACHE_TTL_MS = 60_000 -function isTailscaleServe(): boolean { - const now = Date.now() - if (!_tailscaleServeCache || now > _tailscaleServeCache.expiresAt) { - _tailscaleServeCache = { value: detectTailscaleServe(), expiresAt: now + TAILSCALE_CACHE_TTL_MS } - } - return _tailscaleServeCache.value -} - /** Extract the browser-facing hostname from the request. */ function getBrowserHostname(request: NextRequest): string { const origin = request.headers.get('origin') || request.headers.get('referer') || '' @@ -114,8 +70,17 @@ function resolveRemoteGatewayUrl( // Browser is remote — determine the correct proxied URL if (isTailscaleServe()) { - // Tailscale Serve proxies /gw → localhost:18789 with TLS - return `wss://${browserHost}/gw` + // Check for a /gw path-based proxy first + refreshTailscaleCache() + const web = getCachedTailscaleWeb() + if (hasGwPathHandler(web)) { + return `wss://${browserHost}/gw` + } + // Port-based proxy: find the Tailscale Serve port that proxies to the gateway port + const tsPort = findTailscaleServePort(web, gateway.port) + if (tsPort) { + return `wss://${browserHost}:${tsPort}` + } } // No Tailscale Serve — try direct connection to dashboard host on gateway port diff --git a/src/lib/__tests__/tailscale-serve.test.ts b/src/lib/__tests__/tailscale-serve.test.ts new file mode 100644 index 0000000..85db65d --- /dev/null +++ b/src/lib/__tests__/tailscale-serve.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import { findTailscaleServePort, detectTailscaleServe, hasGwPathHandler } from '@/lib/tailscale-serve' + +// Realistic Tailscale Serve status JSON matching the structure returned by +// `tailscale serve status --json` +const TYPICAL_WEB_CONFIG = { + 'myhost.tailb5729a.ts.net:8443': { + Handlers: { + '/': { Proxy: 'http://localhost:18789' }, + }, + }, + 'myhost.tailb5729a.ts.net:8444': { + Handlers: { + '/': { Proxy: 'http://127.0.0.1:3000' }, + }, + }, + 'myhost.tailb5729a.ts.net:3001': { + Handlers: { + '/': { Proxy: 'http://127.0.0.1:3002' }, + }, + }, +} + +const GW_PATH_WEB_CONFIG = { + 'myhost.tailb5729a.ts.net:443': { + Handlers: { + '/': { Proxy: 'http://127.0.0.1:3000' }, + '/gw': { Proxy: 'http://127.0.0.1:18789' }, + }, + }, +} + +describe('findTailscaleServePort', () => { + it('finds the external port proxying to a given local port', () => { + expect(findTailscaleServePort(TYPICAL_WEB_CONFIG, 18789)).toBe(8443) + expect(findTailscaleServePort(TYPICAL_WEB_CONFIG, 3000)).toBe(8444) + expect(findTailscaleServePort(TYPICAL_WEB_CONFIG, 3002)).toBe(3001) + }) + + it('returns null when no handler matches the target port', () => { + expect(findTailscaleServePort(TYPICAL_WEB_CONFIG, 9999)).toBeNull() + }) + + it('returns null for null/undefined web config', () => { + expect(findTailscaleServePort(null, 18789)).toBeNull() + expect(findTailscaleServePort(undefined, 18789)).toBeNull() + }) + + it('returns null for empty web config', () => { + expect(findTailscaleServePort({}, 18789)).toBeNull() + }) + + it('handles handlers without Proxy field', () => { + const web = { + 'myhost.ts.net:443': { + Handlers: { + '/': { Path: '/var/www' }, + }, + }, + } + expect(findTailscaleServePort(web, 3000)).toBeNull() + }) + + it('matches proxy URLs with trailing slash', () => { + const web = { + 'myhost.ts.net:9000': { + Handlers: { + '/': { Proxy: 'http://127.0.0.1:4000/' }, + }, + }, + } + expect(findTailscaleServePort(web, 4000)).toBe(9000) + }) + + it('does not match partial port numbers', () => { + const web = { + 'myhost.ts.net:443': { + Handlers: { + '/': { Proxy: 'http://127.0.0.1:18789' }, + }, + }, + } + // Port 1878 should not match a proxy to 18789 + expect(findTailscaleServePort(web, 1878)).toBeNull() + // Port 8789 should not match + expect(findTailscaleServePort(web, 8789)).toBeNull() + }) +}) + +describe('detectTailscaleServe', () => { + it('detects /gw path-based proxy', () => { + expect(detectTailscaleServe(GW_PATH_WEB_CONFIG)).toBe(true) + }) + + it('detects port-based proxy to gateway default port 18789', () => { + expect(detectTailscaleServe(TYPICAL_WEB_CONFIG)).toBe(true) + }) + + it('returns false when no gateway proxy found', () => { + const web = { + 'myhost.ts.net:3000': { + Handlers: { + '/': { Proxy: 'http://127.0.0.1:3000' }, + }, + }, + } + expect(detectTailscaleServe(web)).toBe(false) + }) + + it('returns false for null/undefined web config', () => { + expect(detectTailscaleServe(null)).toBe(false) + expect(detectTailscaleServe(undefined)).toBe(false) + }) + + it('returns false for empty web config with no config path', () => { + expect(detectTailscaleServe({})).toBe(false) + }) + + it('handles host entries with no Handlers', () => { + const web = { 'myhost.ts.net:443': {} } + expect(detectTailscaleServe(web)).toBe(false) + }) +}) + +describe('hasGwPathHandler', () => { + it('returns true when /gw handler exists', () => { + expect(hasGwPathHandler(GW_PATH_WEB_CONFIG)).toBe(true) + }) + + it('returns false when no /gw handler exists', () => { + expect(hasGwPathHandler(TYPICAL_WEB_CONFIG)).toBe(false) + }) + + it('returns false for null/undefined', () => { + expect(hasGwPathHandler(null)).toBe(false) + expect(hasGwPathHandler(undefined)).toBe(false) + }) +}) diff --git a/src/lib/tailscale-serve.ts b/src/lib/tailscale-serve.ts new file mode 100644 index 0000000..02b62c4 --- /dev/null +++ b/src/lib/tailscale-serve.ts @@ -0,0 +1,127 @@ +import { readFileSync } from 'node:fs' + +/** Tailscale CLI binary paths to try (macOS app bundle, then PATH). */ +const TAILSCALE_BINS = [ + '/Applications/Tailscale.app/Contents/MacOS/Tailscale', + 'tailscale', +] + +export function execTailscaleServeJson(): any | null { + const { execFileSync } = require('node:child_process') + for (const bin of TAILSCALE_BINS) { + try { + const raw = execFileSync(bin, ['serve', 'status', '--json'], { + timeout: 3000, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + }) + return JSON.parse(raw) + } catch { + continue + } + } + return null +} + +/** + * Find the Tailscale Serve port that proxies to a given local port. + * + * Looks through the `Web` section of `tailscale serve status --json` for any + * handler whose Proxy target points at localhost:. Returns the + * external Tailscale Serve port (e.g. 8443) or null if not found. + */ +export function findTailscaleServePort(web: Record | null | undefined, targetPort: number): number | null { + if (!web) return null + + const targetSuffixes = [`:${targetPort}`, `:${targetPort}/`] + for (const [hostPort, hostConfig] of Object.entries(web) as [string, any][]) { + const handlers = hostConfig?.Handlers + if (!handlers) continue + for (const handler of Object.values(handlers) as any[]) { + const proxy = handler?.Proxy || '' + if (targetSuffixes.some(s => proxy.endsWith(s) || proxy === `http://127.0.0.1:${targetPort}` || proxy === `http://localhost:${targetPort}`)) { + // hostPort is like "hostname:8443" + const port = parseInt(hostPort.split(':').pop() || '', 10) + if (port > 0) return port + } + } + } + return null +} + +/** + * Detect whether Tailscale Serve is proxying to the gateway. + * + * Checks the Web config for: + * 1. A `/gw` path handler (authoritative) + * 2. Any handler proxying to port 18789 (port-based proxy) + * 3. Fallback: `gateway.tailscale.mode === 'serve'` in openclaw.json (legacy) + */ +export function detectTailscaleServe(web: Record | null | undefined, configPath?: string): boolean { + if (web) { + for (const hostConfig of Object.values(web) as any[]) { + const handlers = (hostConfig as any)?.Handlers + if (!handlers) continue + if ((handlers as any)['/gw']) return true + // Also detect port-based proxy to gateway (e.g. :8443 → localhost:18789) + for (const handler of Object.values(handlers) as any[]) { + const proxy = (handler as any)?.Proxy || '' + if (proxy.includes(':18789')) return true + } + } + } + + // Legacy: check openclaw.json config + const effectivePath = configPath || process.env.OPENCLAW_CONFIG_PATH || '' + if (!effectivePath) return false + try { + const raw = readFileSync(effectivePath, 'utf-8') + const config = JSON.parse(raw) + return config?.gateway?.tailscale?.mode === 'serve' + } catch { + return false + } +} + +/** + * Check whether any Tailscale Serve handler has a `/gw` path. + */ +export function hasGwPathHandler(web: Record | null | undefined): boolean { + if (!web) return false + for (const hostConfig of Object.values(web) as any[]) { + if ((hostConfig as any)?.Handlers?.['/gw']) return true + } + return false +} + +/** Cache Tailscale Serve JSON with 60-second TTL. */ +let _tailscaleServeJsonCache: { value: any; expiresAt: number } | null = null +let _tailscaleServeCache: { value: boolean; expiresAt: number } | null = null +const TAILSCALE_CACHE_TTL_MS = 60_000 + +export function refreshTailscaleCache(): void { + const now = Date.now() + if (!_tailscaleServeJsonCache || now > _tailscaleServeJsonCache.expiresAt) { + _tailscaleServeJsonCache = { value: execTailscaleServeJson(), expiresAt: now + TAILSCALE_CACHE_TTL_MS } + _tailscaleServeCache = null // invalidate derived cache + } +} + +export function getCachedTailscaleWeb(): Record | null { + return _tailscaleServeJsonCache?.value?.Web ?? null +} + +export function isTailscaleServe(): boolean { + refreshTailscaleCache() + const now = Date.now() + if (!_tailscaleServeCache || now > _tailscaleServeCache.expiresAt) { + _tailscaleServeCache = { value: detectTailscaleServe(getCachedTailscaleWeb()), expiresAt: now + TAILSCALE_CACHE_TTL_MS } + } + return _tailscaleServeCache.value +} + +/** Reset caches — for testing only. */ +export function _resetCaches(): void { + _tailscaleServeJsonCache = null + _tailscaleServeCache = null +}