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.
162 lines
6.5 KiB
TypeScript
162 lines
6.5 KiB
TypeScript
/**
|
|
* View builders. Every dynamic value (host subdomain/status/dates, pairing code, command) is placed
|
|
* with `textContent` via the `el` helper — NEVER innerHTML — so host data (treated as untrusted for
|
|
* XSS even though it comes from an authenticated CP) can't inject markup. The QR is an <img> whose
|
|
* src is a data: URL our own backend generated with the `qrcode` lib.
|
|
*/
|
|
import { el, clear, type Child } from './dom.js'
|
|
import type { HostView, PairingArtifacts } from './api.js'
|
|
|
|
export interface DashboardHandlers {
|
|
onNewCode: () => void
|
|
onRevoke: (host: HostView) => void
|
|
onLogout: () => void
|
|
onRefresh: () => void
|
|
}
|
|
|
|
/** Login card with a password field; `onSubmit(password)` fires on submit. */
|
|
export function renderLogin(onSubmit: (password: string) => void): HTMLElement {
|
|
const input = el('input', { type: 'password', name: 'password', placeholder: 'Panel password', autocomplete: 'current-password' })
|
|
const error = el('p', { class: 'error', role: 'alert' })
|
|
const form = el(
|
|
'form',
|
|
{
|
|
class: 'card login',
|
|
onSubmit: (e) => {
|
|
e.preventDefault()
|
|
error.textContent = ''
|
|
onSubmit(input.value)
|
|
},
|
|
},
|
|
[
|
|
el('h1', { text: 'Tunnel Control Panel' }),
|
|
el('label', { text: 'Password', class: 'field-label' }),
|
|
input,
|
|
el('button', { type: 'submit', class: 'btn primary', text: 'Sign in' }),
|
|
error,
|
|
],
|
|
)
|
|
const wrap = el('div', { class: 'centered' }, [form])
|
|
// Expose the error node so the app can show login failures.
|
|
;(wrap as HTMLElement & { errorNode?: HTMLElement }).errorNode = error
|
|
queueMicrotask(() => input.focus())
|
|
return wrap
|
|
}
|
|
|
|
function statusPill(status: string): HTMLElement {
|
|
const known = ['online', 'offline', 'revoked', 'suspended', 'active'].includes(status)
|
|
return el('span', { class: `pill pill-${known ? status : 'unknown'}`, text: status })
|
|
}
|
|
|
|
function fmtDate(value: string | undefined | null): string {
|
|
if (value === undefined || value === null || value === '') return '—'
|
|
const t = Date.parse(value)
|
|
return Number.isNaN(t) ? value : new Date(t).toLocaleString()
|
|
}
|
|
|
|
function hostRow(host: HostView, handlers: DashboardHandlers): HTMLElement {
|
|
const revoke = el('button', {
|
|
class: 'btn danger small',
|
|
text: 'Revoke',
|
|
title: `Revoke ${host.subdomain}`,
|
|
onClick: () => handlers.onRevoke(host),
|
|
})
|
|
return el('tr', {}, [
|
|
el('td', { text: host.subdomain, class: 'mono' }),
|
|
el('td', {}, [statusPill(host.status)]),
|
|
el('td', { text: fmtDate(host.notAfter), class: 'muted' }),
|
|
el('td', { text: fmtDate(host.lastSeen), class: 'muted' }),
|
|
el('td', {}, [revoke]),
|
|
])
|
|
}
|
|
|
|
function hostsTable(hosts: readonly HostView[], handlers: DashboardHandlers): HTMLElement {
|
|
if (hosts.length === 0) {
|
|
return el('div', { class: 'empty', text: 'No hosts enrolled yet. Create a pairing code to add one.' })
|
|
}
|
|
const head = el('thead', {}, [
|
|
el('tr', {}, [
|
|
el('th', { text: 'Subdomain' }),
|
|
el('th', { text: 'Status' }),
|
|
el('th', { text: 'Cert expiry' }),
|
|
el('th', { text: 'Last seen' }),
|
|
el('th', { text: '' }),
|
|
]),
|
|
])
|
|
const body = el('tbody', {}, hosts.map((h) => hostRow(h, handlers)))
|
|
return el('div', { class: 'table-scroll' }, [el('table', { class: 'hosts' }, [head, body])])
|
|
}
|
|
|
|
/** Full dashboard: header (refresh / new-code / logout) + hosts table. */
|
|
export function renderDashboard(hosts: readonly HostView[], handlers: DashboardHandlers): HTMLElement {
|
|
const header = el('header', { class: 'topbar' }, [
|
|
el('h1', { text: 'Tunnel Control Panel' }),
|
|
el('div', { class: 'actions' }, [
|
|
el('button', { class: 'btn', text: 'Refresh', onClick: () => handlers.onRefresh() }),
|
|
el('button', { class: 'btn primary', text: 'New pairing code', onClick: () => handlers.onNewCode() }),
|
|
el('button', { class: 'btn ghost', text: 'Log out', onClick: () => handlers.onLogout() }),
|
|
]),
|
|
])
|
|
return el('div', { class: 'dashboard' }, [
|
|
header,
|
|
el('section', { class: 'card' }, [el('h2', { text: 'Hosts' }), hostsTable(hosts, handlers)]),
|
|
])
|
|
}
|
|
|
|
/** Generic modal overlay; returns { overlay, close }. Clicking the backdrop or ✕ closes it. */
|
|
function modal(title: string, children: Child[]): { overlay: HTMLElement; close: () => void } {
|
|
const close = (): void => overlay.remove()
|
|
const dialog = el('div', { class: 'modal', role: 'dialog' }, [
|
|
el('div', { class: 'modal-head' }, [el('h2', { text: title }), el('button', { class: 'icon-btn', text: '✕', ariaLabel: 'Close', onClick: close })]),
|
|
el('div', { class: 'modal-body' }, children),
|
|
])
|
|
const overlay = el('div', { class: 'overlay', onClick: (e) => { if (e.target === overlay) close() } }, [dialog])
|
|
return { overlay, close }
|
|
}
|
|
|
|
/** Pairing-code modal: the code, its QR, the copyable command, and the expiry. */
|
|
export function pairingModal(artifacts: PairingArtifacts): HTMLElement {
|
|
const command = el('code', { class: 'command', text: artifacts.pairCommand })
|
|
const copyBtn = el('button', {
|
|
class: 'btn small',
|
|
text: 'Copy command',
|
|
onClick: () => {
|
|
void navigator.clipboard?.writeText(artifacts.pairCommand).then(
|
|
() => { copyBtn.textContent = 'Copied!' },
|
|
() => { copyBtn.textContent = 'Copy failed' },
|
|
)
|
|
},
|
|
})
|
|
const { overlay } = modal('Pairing code', [
|
|
el('p', { class: 'muted', text: 'Run this on the new host, or scan the QR from the phone client. Single-use.' }),
|
|
el('div', { class: 'code-big mono', text: artifacts.code }),
|
|
el('img', { class: 'qr', src: artifacts.qrDataUrl, alt: 'Pairing code QR' }),
|
|
el('div', { class: 'command-row' }, [command, copyBtn]),
|
|
el('p', { class: 'muted small', text: `Expires: ${fmtDate(artifacts.expiresAt)}` }),
|
|
])
|
|
return overlay
|
|
}
|
|
|
|
/** Confirm dialog → resolves true (confirmed) / false (cancelled). */
|
|
export function confirmDialog(message: string): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
const { overlay, close } = modal('Please confirm', [
|
|
el('p', { text: message }),
|
|
el('div', { class: 'command-row end' }, [
|
|
el('button', { class: 'btn ghost', text: 'Cancel', onClick: () => { close(); resolve(false) } }),
|
|
el('button', { class: 'btn danger', text: 'Revoke', onClick: () => { close(); resolve(true) } }),
|
|
]),
|
|
])
|
|
document.body.append(overlay)
|
|
})
|
|
}
|
|
|
|
/** A transient toast for errors/success (textContent only). */
|
|
export function toast(root: HTMLElement, message: string, kind: 'error' | 'info' = 'info'): void {
|
|
const t = el('div', { class: `toast toast-${kind}`, text: message, role: 'status' })
|
|
root.append(t)
|
|
setTimeout(() => t.remove(), 4000)
|
|
}
|
|
|
|
export { clear }
|