feat(control-panel): web admin UI for the zero-touch tunnel
Loopback Fastify auth-broker + esbuild SPA. Operator password login (constant-time, signed HttpOnly session cookie, per-forwarded-IP rate-limit) → session-gated proxy that mints a fresh 60s manage capability token per call to the control-plane admin API: list hosts, mint pairing codes (with QR + pair command), revoke hosts. Security headers + CSP, CP_URL pinned loopback (anti-SSRF), hostId dot-segment guard. 55 tests pass; security-reviewed. Deployed behind nginx panel.terminal.yaojia.wang.
This commit is contained in:
99
control-panel/test/api-routes.test.ts
Normal file
99
control-panel/test/api-routes.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildApp } from '../src/app.js'
|
||||
import { CpClientError } from '../src/cp-client.js'
|
||||
import { HostIdSchema } from '../src/routes/api-routes.js'
|
||||
import { makeConfig, fakeCpClient, fakeMinter, authCookieHeader } from './helpers.js'
|
||||
|
||||
const AUTH = { cookie: authCookieHeader() }
|
||||
|
||||
describe('session gating', () => {
|
||||
it('rejects every proxy route without a valid session cookie (401)', async () => {
|
||||
const app = await buildApp({ config: makeConfig(), cpClient: fakeCpClient(), minter: fakeMinter(), staticRoot: null })
|
||||
for (const r of [
|
||||
{ method: 'GET' as const, url: '/api/hosts' },
|
||||
{ method: 'POST' as const, url: '/api/pairing-codes' },
|
||||
{ method: 'DELETE' as const, url: '/api/hosts/abc' },
|
||||
]) {
|
||||
const res = await app.inject(r)
|
||||
expect(res.statusCode).toBe(401)
|
||||
}
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/hosts', () => {
|
||||
it('mints a token and returns the CP host list for the operator account', async () => {
|
||||
const cpClient = fakeCpClient({
|
||||
hosts: [{ hostId: 'h1', subdomain: 'alpha', status: 'online', lastSeen: 'x', createdAt: 'y', revokedAt: null, notAfter: undefined }],
|
||||
})
|
||||
const minter = fakeMinter('MINTED')
|
||||
const app = await buildApp({ config: makeConfig({ operatorAccountId: 'acct-XYZ' }), cpClient, minter, staticRoot: null })
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/api/hosts', headers: AUTH })
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.json()).toEqual({ hosts: [{ hostId: 'h1', subdomain: 'alpha', status: 'online', lastSeen: 'x', createdAt: 'y', revokedAt: null, notAfter: undefined }] })
|
||||
expect(minter.calls()).toBe(1)
|
||||
expect(cpClient.calls[0]).toMatchObject({ method: 'listHosts', accountIdOrHostId: 'acct-XYZ', token: 'MINTED' })
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('maps an upstream CP failure to 502', async () => {
|
||||
const cpClient = fakeCpClient({ throwErr: new CpClientError(403, 'denied') })
|
||||
const app = await buildApp({ config: makeConfig(), cpClient, minter: fakeMinter(), staticRoot: null })
|
||||
const res = await app.inject({ method: 'GET', url: '/api/hosts', headers: AUTH })
|
||||
expect(res.statusCode).toBe(502)
|
||||
expect(res.json()).toEqual({ error: 'upstream_error' })
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/pairing-codes', () => {
|
||||
it('returns the code, pair command, and a QR data URL', async () => {
|
||||
const cpClient = fakeCpClient({ pairing: { code: 'ABCD-EFGH', expiresAt: '2026-03-01T00:00:00.000Z' } })
|
||||
const app = await buildApp({ config: makeConfig({ tunnelZone: 'z.test' }), cpClient, minter: fakeMinter(), staticRoot: null })
|
||||
|
||||
const res = await app.inject({ method: 'POST', url: '/api/pairing-codes', headers: AUTH })
|
||||
expect(res.statusCode).toBe(201)
|
||||
const body = res.json()
|
||||
expect(body.code).toBe('ABCD-EFGH')
|
||||
expect(body.expiresAt).toBe('2026-03-01T00:00:00.000Z')
|
||||
expect(body.pairCommand).toBe('web-terminal-agent pair ABCD-EFGH --install --zone z.test')
|
||||
expect(String(body.qrDataUrl).startsWith('data:image/png;base64,')).toBe(true)
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/hosts/:hostId', () => {
|
||||
it('revokes the host and returns 204', async () => {
|
||||
const cpClient = fakeCpClient()
|
||||
const app = await buildApp({ config: makeConfig(), cpClient, minter: fakeMinter('T'), staticRoot: null })
|
||||
const res = await app.inject({ method: 'DELETE', url: '/api/hosts/host-42', headers: AUTH })
|
||||
expect(res.statusCode).toBe(204)
|
||||
expect(cpClient.calls[0]).toMatchObject({ method: 'deleteHost', accountIdOrHostId: 'host-42', token: 'T' })
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('rejects an invalid host id with 400', async () => {
|
||||
const cpClient = fakeCpClient()
|
||||
const app = await buildApp({ config: makeConfig(), cpClient, minter: fakeMinter(), staticRoot: null })
|
||||
const res = await app.inject({ method: 'DELETE', url: '/api/hosts/' + encodeURIComponent('bad id!'), headers: AUTH })
|
||||
expect(res.statusCode).toBe(400)
|
||||
expect(cpClient.calls).toHaveLength(0)
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('HostIdSchema dot-segment rejection', () => {
|
||||
// The HTTP router already normalizes literal `.`/`..` path segments, but the outbound CP admin URL is
|
||||
// built as `${cpUrl}/hosts/${hostId}` — so the schema itself must refuse dot-segments (defense-in-depth).
|
||||
it('rejects a bare "." and ".."', () => {
|
||||
expect(HostIdSchema.safeParse('.').success).toBe(false)
|
||||
expect(HostIdSchema.safeParse('..').success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a UUID host id, a hyphenated id, and "..." (not a dot-segment)', () => {
|
||||
expect(HostIdSchema.safeParse('550e8400-e29b-41d4-a716-446655440000').success).toBe(true)
|
||||
expect(HostIdSchema.safeParse('host-42').success).toBe(true)
|
||||
expect(HostIdSchema.safeParse('...').success).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user