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:
127
control-panel/src/cp-client.ts
Normal file
127
control-panel/src/cp-client.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 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})`)
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user