Merge pull request #205 from builderz-labs/fix/195-gateway-connect-token
fix(gateway): send stored token during gateway websocket connect
This commit is contained in:
commit
1231897048
|
|
@ -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 |
|
||||
|
|
|
|||
57
openapi.json
57
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": [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue