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:
114
control-panel/public/app.ts
Normal file
114
control-panel/public/app.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* SPA bootstrap + state machine. On load: check the session → render login or dashboard. The
|
||||
* dashboard polls the host list on an interval and refreshes after mutations. A 401 from any proxy
|
||||
* call bounces the operator back to the login screen (session expired).
|
||||
*/
|
||||
import { mount } from './dom.js'
|
||||
import * as api from './api.js'
|
||||
import { ApiError } from './api.js'
|
||||
import { renderLogin, renderDashboard, pairingModal, confirmDialog, toast, type DashboardHandlers } from './views.js'
|
||||
import type { HostView } from './api.js'
|
||||
|
||||
const POLL_INTERVAL_MS = 15_000
|
||||
|
||||
function rootEl(): HTMLElement {
|
||||
const root = document.getElementById('app')
|
||||
if (root === null) throw new Error('#app root not found')
|
||||
return root
|
||||
}
|
||||
|
||||
let pollTimer: number | undefined
|
||||
|
||||
function stopPolling(): void {
|
||||
if (pollTimer !== undefined) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** True if an error is an auth failure that should bounce to login. */
|
||||
function isUnauthorized(err: unknown): boolean {
|
||||
return err instanceof ApiError && err.status === 401
|
||||
}
|
||||
|
||||
async function showLogin(): Promise<void> {
|
||||
stopPolling()
|
||||
const root = rootEl()
|
||||
const view = renderLogin(async (password) => {
|
||||
const errorNode = (view as HTMLElement & { errorNode?: HTMLElement }).errorNode
|
||||
try {
|
||||
await api.login(password)
|
||||
await showDashboard()
|
||||
} catch (err) {
|
||||
const msg = err instanceof ApiError && err.status === 429 ? 'Too many attempts. Wait and retry.' : err instanceof ApiError && err.status === 503 ? 'Panel not configured (no password set).' : 'Incorrect password.'
|
||||
if (errorNode) errorNode.textContent = msg
|
||||
}
|
||||
})
|
||||
mount(root, view)
|
||||
}
|
||||
|
||||
async function loadHosts(): Promise<readonly HostView[]> {
|
||||
return api.getHosts()
|
||||
}
|
||||
|
||||
async function refreshDashboard(root: HTMLElement, handlers: DashboardHandlers): Promise<void> {
|
||||
try {
|
||||
const hosts = await loadHosts()
|
||||
mount(root, renderDashboard(hosts, handlers))
|
||||
} catch (err) {
|
||||
if (isUnauthorized(err)) {
|
||||
await showLogin()
|
||||
return
|
||||
}
|
||||
toast(root, 'Failed to load hosts.', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function showDashboard(): Promise<void> {
|
||||
const root = rootEl()
|
||||
|
||||
const handlers: DashboardHandlers = {
|
||||
onRefresh: () => void refreshDashboard(root, handlers),
|
||||
onLogout: async () => {
|
||||
stopPolling()
|
||||
await api.logout()
|
||||
await showLogin()
|
||||
},
|
||||
onNewCode: async () => {
|
||||
try {
|
||||
const artifacts = await api.createPairingCode()
|
||||
document.body.append(pairingModal(artifacts))
|
||||
} catch (err) {
|
||||
if (isUnauthorized(err)) return void showLogin()
|
||||
toast(root, 'Failed to create pairing code.', 'error')
|
||||
}
|
||||
},
|
||||
onRevoke: async (host) => {
|
||||
const ok = await confirmDialog(`Revoke host "${host.subdomain}"? This removes its tunnel access.`)
|
||||
if (!ok) return
|
||||
try {
|
||||
await api.revokeHost(host.hostId)
|
||||
toast(root, `Revoked ${host.subdomain}.`, 'info')
|
||||
await refreshDashboard(root, handlers)
|
||||
} catch (err) {
|
||||
if (isUnauthorized(err)) return void showLogin()
|
||||
toast(root, 'Failed to revoke host.', 'error')
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
await refreshDashboard(root, handlers)
|
||||
stopPolling()
|
||||
pollTimer = window.setInterval(() => void refreshDashboard(root, handlers), POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
try {
|
||||
const authed = await api.getSession()
|
||||
await (authed ? showDashboard() : showLogin())
|
||||
} catch {
|
||||
await showLogin()
|
||||
}
|
||||
}
|
||||
|
||||
void boot()
|
||||
Reference in New Issue
Block a user