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:
77
control-panel/public/api.ts
Normal file
77
control-panel/public/api.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Typed fetch client for the panel backend. Same-origin; the session cookie rides automatically
|
||||
* (HttpOnly — JS never reads it). Every call narrows the response and throws `ApiError` on failure
|
||||
* so the UI can react (e.g. bounce to the login screen on 401).
|
||||
*/
|
||||
export interface HostView {
|
||||
readonly hostId: string
|
||||
readonly subdomain: string
|
||||
readonly status: string
|
||||
readonly lastSeen?: string
|
||||
readonly createdAt?: string
|
||||
readonly revokedAt?: string | null
|
||||
readonly notAfter?: string
|
||||
}
|
||||
|
||||
export interface PairingArtifacts {
|
||||
readonly code: string
|
||||
readonly expiresAt: string
|
||||
readonly pairCommand: string
|
||||
readonly qrDataUrl: string
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
async function request(method: string, url: string, body?: unknown): Promise<Response> {
|
||||
const init: RequestInit = { method, credentials: 'same-origin', headers: {} }
|
||||
if (body !== undefined) {
|
||||
init.headers = { 'content-type': 'application/json' }
|
||||
init.body = JSON.stringify(body)
|
||||
}
|
||||
return fetch(url, init)
|
||||
}
|
||||
|
||||
async function json<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) throw new ApiError(res.status, `request failed (${res.status})`)
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
export async function getSession(): Promise<boolean> {
|
||||
const res = await request('GET', '/api/session')
|
||||
const data = await json<{ authenticated: boolean }>(res)
|
||||
return data.authenticated === true
|
||||
}
|
||||
|
||||
/** Attempt login. Returns true on success; throws ApiError (status) otherwise so callers can message. */
|
||||
export async function login(password: string): Promise<boolean> {
|
||||
const res = await request('POST', '/login', { password })
|
||||
if (res.ok) return true
|
||||
throw new ApiError(res.status, `login failed (${res.status})`)
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await request('POST', '/logout')
|
||||
}
|
||||
|
||||
export async function getHosts(): Promise<readonly HostView[]> {
|
||||
const res = await request('GET', '/api/hosts')
|
||||
const data = await json<{ hosts: HostView[] }>(res)
|
||||
return data.hosts ?? []
|
||||
}
|
||||
|
||||
export async function createPairingCode(): Promise<PairingArtifacts> {
|
||||
const res = await request('POST', '/api/pairing-codes')
|
||||
return json<PairingArtifacts>(res)
|
||||
}
|
||||
|
||||
export async function revokeHost(hostId: string): Promise<void> {
|
||||
const res = await request('DELETE', `/api/hosts/${encodeURIComponent(hostId)}`)
|
||||
if (!res.ok && res.status !== 204) throw new ApiError(res.status, `revoke failed (${res.status})`)
|
||||
}
|
||||
Reference in New Issue
Block a user