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 +}