Files
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

78 lines
2.5 KiB
TypeScript

/**
* 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})`)
}