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:
parent
b5d44df705
commit
ab7038af30
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue