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.
115 lines
3.4 KiB
TypeScript
115 lines
3.4 KiB
TypeScript
/**
|
|
* 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()
|