From c0cf678c4da901bcbca10c5e637c73a8d5033a5f Mon Sep 17 00:00:00 2001 From: Nyk <0xnykcd@googlemail.com> Date: Thu, 5 Mar 2026 13:31:37 +0700 Subject: [PATCH] fix(gateway): use stored token for websocket connect payload --- README.md | 3 +- openapi.json | 57 ++++++++++++++ src/app/api/gateways/connect/route.ts | 76 +++++++++++++++++++ src/components/panels/multi-gateway-panel.tsx | 27 +++++-- src/lib/__tests__/gateway-url.test.ts | 8 ++ src/lib/gateway-url.ts | 4 + tests/gateway-connect.spec.ts | 51 +++++++++++++ 7 files changed, 218 insertions(+), 8 deletions(-) create mode 100644 src/app/api/gateways/connect/route.ts create mode 100644 tests/gateway-connect.spec.ts diff --git a/README.md b/README.md index 46db90d..7354f9a 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/openapi.json b/openapi.json index 26b9c74..1b8e4d5 100644 --- a/openapi.json +++ b/openapi.json @@ -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": [ diff --git a/src/app/api/gateways/connect/route.ts b/src/app/api/gateways/connect/route.ts new file mode 100644 index 0000000..6fdad8f --- /dev/null +++ b/src/app/api/gateways/connect/route.ts @@ -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) { + 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, + }) +} diff --git a/src/components/panels/multi-gateway-panel.tsx b/src/components/panels/multi-gateway-panel.tsx index 9b06c9e..e284d1c 100644 --- a/src/components/panels/multi-gateway-panel.tsx +++ b/src/components/panels/multi-gateway-panel.tsx @@ -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 () => { diff --git a/src/lib/__tests__/gateway-url.test.ts b/src/lib/__tests__/gateway-url.test.ts index f6f1a43..e3219c2 100644 --- a/src/lib/__tests__/gateway-url.test.ts +++ b/src/lib/__tests__/gateway-url.test.ts @@ -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') + }) }) diff --git a/src/lib/gateway-url.ts b/src/lib/gateway-url.ts index 60440d5..e767b9d 100644 --- a/src/lib/gateway-url.ts +++ b/src/lib/gateway-url.ts @@ -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 diff --git a/tests/gateway-connect.spec.ts b/tests/gateway-connect.spec.ts new file mode 100644 index 0000000..f6f5841 --- /dev/null +++ b/tests/gateway-connect.spec.ts @@ -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) + }) +})