fix(gateway): use stored token for websocket connect payload

This commit is contained in:
Nyk 2026-03-05 13:31:37 +07:00
parent a7f904be89
commit c0cf678c4d
7 changed files with 218 additions and 8 deletions

View File

@ -289,6 +289,7 @@ All endpoints require authentication unless noted. Full reference below.
| `GET` | `/api/webhooks/deliveries` | admin | Delivery history |
| `GET/POST/PUT/DELETE` | `/api/alerts` | admin | Alert rules |
| `GET/POST/PUT/DELETE` | `/api/gateways` | admin | Gateway connections |
| `POST` | `/api/gateways/connect` | operator | Resolve websocket URL + token for selected gateway |
| `GET/PUT/DELETE/POST` | `/api/integrations` | admin | Integration management |
| `POST` | `/api/github` | admin | Trigger GitHub Issues sync |
@ -381,7 +382,7 @@ See [`.env.example`](.env.example) for the complete list. Key variables:
| `OPENCLAW_GATEWAY_TOKEN` | No | Server-side gateway auth token |
| `OPENCLAW_TOOLS_PROFILE` | No | Tools profile for `sessions_spawn` (recommended: `coding`) |
| `NEXT_PUBLIC_GATEWAY_TOKEN` | No | Browser-side gateway auth token (must use `NEXT_PUBLIC_` prefix) |
| `NEXT_PUBLIC_GATEWAY_CLIENT_ID` | No | Gateway UI client ID for websocket handshake (default: `control-ui`) |
| `NEXT_PUBLIC_GATEWAY_CLIENT_ID` | No | Gateway UI client ID for websocket handshake (default: `openclaw-control-ui`) |
| `OPENCLAW_MEMORY_DIR` | No | Memory browser root (see note below) |
| `MC_CLAUDE_HOME` | No | Path to `~/.claude` directory (default: `~/.claude`) |
| `MC_TRUSTED_PROXIES` | No | Comma-separated trusted proxy IPs for XFF parsing |

View File

@ -3408,6 +3408,63 @@
}
}
},
"/api/gateways/connect": {
"post": {
"tags": [
"Admin"
],
"summary": "Resolve websocket connect payload for a gateway",
"operationId": "connectGateway",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"id"
],
"properties": {
"id": {
"type": "integer"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Resolved websocket URL and token metadata",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"ws_url": { "type": "string" },
"token": { "type": "string" },
"token_set": { "type": "boolean" }
}
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
},
"404": {
"$ref": "#/components/responses/NotFound"
}
}
}
},
"/api/github": {
"get": {
"tags": [

View File

@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireRole } from '@/lib/auth'
import { getDatabase } from '@/lib/db'
import { buildGatewayWebSocketUrl } from '@/lib/gateway-url'
interface GatewayEntry {
id: number
host: string
port: number
token: string
}
function ensureTable(db: ReturnType<typeof getDatabase>) {
db.exec(`
CREATE TABLE IF NOT EXISTS gateways (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
host TEXT NOT NULL DEFAULT '127.0.0.1',
port INTEGER NOT NULL DEFAULT 18789,
token TEXT NOT NULL DEFAULT '',
is_primary INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'unknown',
last_seen INTEGER,
latency INTEGER,
sessions_count INTEGER NOT NULL DEFAULT 0,
agents_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
)
`)
}
/**
* POST /api/gateways/connect
* Resolves websocket URL and token for a selected gateway without exposing tokens in list payloads.
*/
export async function POST(request: NextRequest) {
const auth = requireRole(request, 'operator')
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
const db = getDatabase()
ensureTable(db)
let id: number | null = null
try {
const body = await request.json()
id = Number(body?.id)
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
if (!id || !Number.isInteger(id) || id < 1) {
return NextResponse.json({ error: 'id is required' }, { status: 400 })
}
const gateway = db.prepare('SELECT id, host, port, token FROM gateways WHERE id = ?').get(id) as GatewayEntry | undefined
if (!gateway) {
return NextResponse.json({ error: 'Gateway not found' }, { status: 404 })
}
const ws_url = buildGatewayWebSocketUrl({
host: gateway.host,
port: gateway.port,
browserProtocol: request.nextUrl.protocol,
})
const envToken = (process.env.NEXT_PUBLIC_GATEWAY_TOKEN || process.env.NEXT_PUBLIC_WS_TOKEN || '').trim()
const token = (gateway.token || '').trim() || envToken
return NextResponse.json({
id: gateway.id,
ws_url,
token,
token_set: token.length > 0,
})
}

View File

@ -93,13 +93,26 @@ export function MultiGatewayPanel() {
fetchGateways()
}
const connectTo = (gw: Gateway) => {
const wsUrl = buildGatewayWebSocketUrl({
host: gw.host,
port: gw.port,
browserProtocol: window.location.protocol,
})
connect(wsUrl, '') // token is handled by the gateway entry, not passed to frontend
const connectTo = async (gw: Gateway) => {
try {
const res = await fetch('/api/gateways/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: gw.id }),
})
if (!res.ok) return
const payload = await res.json()
const wsUrl = String(payload?.ws_url || buildGatewayWebSocketUrl({
host: gw.host,
port: gw.port,
browserProtocol: window.location.protocol,
}))
const token = String(payload?.token || '')
connect(wsUrl, token)
} catch {
// ignore: connection status will remain disconnected
}
}
const probeAll = async () => {

View File

@ -33,4 +33,12 @@ describe('buildGatewayWebSocketUrl', () => {
browserProtocol: 'https:',
})).toBe('wss://gateway.example.com:8443')
})
it('drops path/query/hash when full dashboard URL is pasted', () => {
expect(buildGatewayWebSocketUrl({
host: 'https://bill.tail8b4599.ts.net:4443/sessions?foo=bar#frag',
port: 18789,
browserProtocol: 'https:',
})).toBe('wss://bill.tail8b4599.ts.net:4443')
})
})

View File

@ -38,6 +38,10 @@ export function buildGatewayWebSocketUrl(input: {
try {
const parsed = new URL(prefixed)
parsed.protocol = normalizeProtocol(parsed.protocol)
// Users often paste dashboard/session URLs; websocket connect should target gateway root.
parsed.pathname = '/'
parsed.search = ''
parsed.hash = ''
return parsed.toString().replace(/\/$/, '')
} catch {
return prefixed

View File

@ -0,0 +1,51 @@
import { expect, test } from '@playwright/test'
import { API_KEY_HEADER } from './helpers'
test.describe('Gateway Connect API', () => {
const cleanup: number[] = []
test.afterEach(async ({ request }) => {
for (const id of cleanup) {
await request.delete('/api/gateways', {
headers: API_KEY_HEADER,
data: { id },
}).catch(() => {})
}
cleanup.length = 0
})
test('returns ws_url and token for selected gateway', async ({ request }) => {
const createRes = await request.post('/api/gateways', {
headers: API_KEY_HEADER,
data: {
name: `e2e-gw-${Date.now()}`,
host: 'https://example.tailnet.ts.net:4443/sessions',
port: 18789,
token: 'gw-token-123',
},
})
expect(createRes.status()).toBe(201)
const createBody = await createRes.json()
const gatewayId = createBody.gateway?.id as number
cleanup.push(gatewayId)
const connectRes = await request.post('/api/gateways/connect', {
headers: API_KEY_HEADER,
data: { id: gatewayId },
})
expect(connectRes.status()).toBe(200)
const connectBody = await connectRes.json()
expect(connectBody.ws_url).toBe('wss://example.tailnet.ts.net:4443')
expect(connectBody.token).toBe('gw-token-123')
expect(connectBody.token_set).toBe(true)
})
test('returns 404 for unknown gateway', async ({ request }) => {
const res = await request.post('/api/gateways/connect', {
headers: API_KEY_HEADER,
data: { id: 999999 },
})
expect(res.status()).toBe(404)
})
})