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.
128 lines
4.5 KiB
TypeScript
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})`)
|
|
},
|
|
}
|
|
}
|