Files
web-terminal/control-panel/src/cp-client.ts
Yaojia Wang 675de771c7 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.
2026-07-19 19:47:51 +02:00

128 lines
4.5 KiB
TypeScript

/**
* Control-plane ADMIN API client. Each method takes a freshly-minted `manage` bearer token and calls
* the loopback CP admin surface. Responses are Zod-validated at the boundary (the CP is trusted for
* auth but its payloads are still external data → validate, never `as`). Non-2xx ⇒ `CpClientError`.
*
* CP contract (control-plane/src/api/provision.ts, mounted at root, `Authorization: Bearer <token>`):
* - GET /accounts/:id/hosts → 200 [HostRecord...] (agentPubkey base64)
* - POST /accounts/:id/pairing-codes → 201 { code, expiresAt }
* - DELETE /hosts/:hostId → 204
*
* We expose only a curated host view to the SPA (never leak agentPubkey/enrollFpr).
*/
import { z } from 'zod'
export interface HostView {
readonly hostId: string
readonly subdomain: string
readonly status: string
readonly lastSeen: string | undefined
readonly createdAt: string | undefined
readonly revokedAt: string | null | undefined
/** Cert expiry, if the CP ever includes one (not in today's HostRecord) — surfaced when present. */
readonly notAfter: string | undefined
}
export interface IssuedPairing {
readonly code: string
readonly expiresAt: string
}
export interface CpClient {
listHosts(accountId: string, token: string): Promise<readonly HostView[]>
createPairingCode(accountId: string, token: string): Promise<IssuedPairing>
deleteHost(hostId: string, token: string): Promise<void>
}
/** Upstream failure — carries an HTTP-ish status for the route layer to map (kept generic; no CP body leaked). */
export class CpClientError extends Error {
readonly status: number
constructor(status: number, message: string) {
super(message)
this.name = 'CpClientError'
this.status = status
}
}
// Lenient: require the fields we render; passthrough-tolerate everything else the CP adds/removes.
const HostRecordSchema = z
.object({
hostId: z.string(),
subdomain: z.string(),
status: z.string(),
lastSeen: z.string().optional(),
createdAt: z.string().optional(),
revokedAt: z.string().nullable().optional(),
notAfter: z.string().optional(),
})
.passthrough()
const HostListSchema = z.array(HostRecordSchema)
const IssuedPairingSchema = z.object({ code: z.string().min(1), expiresAt: z.string().min(1) })
function toHostView(rec: z.infer<typeof HostRecordSchema>): HostView {
return {
hostId: rec.hostId,
subdomain: rec.subdomain,
status: rec.status,
lastSeen: rec.lastSeen,
createdAt: rec.createdAt,
revokedAt: rec.revokedAt,
notAfter: rec.notAfter,
}
}
type FetchFn = typeof fetch
interface CpClientDeps {
readonly cpUrl: string
readonly fetchFn?: FetchFn
}
async function readJson(res: Response): Promise<unknown> {
try {
return await res.json()
} catch {
throw new CpClientError(502, 'control-plane returned a non-JSON response')
}
}
export function createCpClient(deps: CpClientDeps): CpClient {
const doFetch: FetchFn = deps.fetchFn ?? fetch
const authHeaders = (token: string): Record<string, string> => ({ authorization: `Bearer ${token}`, accept: 'application/json' })
const call = async (path: string, init: RequestInit): Promise<Response> => {
try {
return await doFetch(`${deps.cpUrl}${path}`, init)
} catch {
// Network/DNS/connection failure — the CP is unreachable.
throw new CpClientError(502, 'control-plane unreachable')
}
}
return {
async listHosts(accountId, token) {
const res = await call(`/accounts/${encodeURIComponent(accountId)}/hosts`, { headers: authHeaders(token) })
if (!res.ok) throw new CpClientError(res.status, `list hosts failed (${res.status})`)
const parsed = HostListSchema.parse(await readJson(res))
return parsed.map(toHostView)
},
async createPairingCode(accountId, token) {
const res = await call(`/accounts/${encodeURIComponent(accountId)}/pairing-codes`, {
method: 'POST',
headers: { ...authHeaders(token), 'content-type': 'application/json' },
body: '{}',
})
if (!res.ok) throw new CpClientError(res.status, `issue pairing code failed (${res.status})`)
const parsed = IssuedPairingSchema.parse(await readJson(res))
return { code: parsed.code, expiresAt: parsed.expiresAt }
},
async deleteHost(hostId, token) {
const res = await call(`/hosts/${encodeURIComponent(hostId)}`, { method: 'DELETE', headers: authHeaders(token) })
if (!res.ok && res.status !== 204) throw new CpClientError(res.status, `revoke host failed (${res.status})`)
},
}
}