feat(gateway): detect port-based Tailscale Serve proxies (#330)

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.
This commit is contained in:
Clint Baxley 2026-03-14 03:32:32 -04:00 committed by GitHub
parent b5d44df705
commit ab7038af30
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 283 additions and 53 deletions

View File

@ -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

View File

@ -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)
})
})

127
src/lib/tailscale-serve.ts Normal file
View File

@ -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:<targetPort>. Returns the
* external Tailscale Serve port (e.g. 8443) or null if not found.
*/
export function findTailscaleServePort(web: Record<string, any> | 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<string, any> | 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<string, any> | 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<string, any> | 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
}