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:
91
control-panel/test/helpers.ts
Normal file
91
control-panel/test/helpers.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Shared test helpers: a valid PanelConfig factory, an authed session cookie, an Ed25519 PKCS#8 PEM
|
||||
* generator (for the manage-token test), and simple fakes for the CP client + token minter.
|
||||
*/
|
||||
import { SESSION_COOKIE_NAME } from '../src/security/cookies.js'
|
||||
import { createSessionToken } from '../src/security/session.js'
|
||||
import type { PanelConfig } from '../src/config.js'
|
||||
import type { CpClient, HostView, IssuedPairing } from '../src/cp-client.js'
|
||||
import type { ManageTokenMinter } from '../src/manage-token.js'
|
||||
|
||||
export const TEST_SESSION_SECRET = 'test-session-secret-0123456789'
|
||||
|
||||
export function makeConfig(overrides: Partial<PanelConfig> = {}): PanelConfig {
|
||||
return {
|
||||
panelPassword: 'hunter2-correct-horse',
|
||||
sessionSecret: TEST_SESSION_SECRET,
|
||||
cpUrl: 'http://127.0.0.1:8080',
|
||||
baseDomain: 'terminal.yaojia.wang',
|
||||
operatorAccountId: 'acct-operator-001',
|
||||
capabilitySignKeyPath: '/nonexistent/key.pem',
|
||||
tunnelZone: 'terminal.yaojia.wang',
|
||||
panelBindPort: 8090,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** A valid session cookie header value for injection (`cookie` header). */
|
||||
export function authCookieHeader(secret: string = TEST_SESSION_SECRET, nowMs: number = Date.now()): string {
|
||||
return `${SESSION_COOKIE_NAME}=${createSessionToken(secret, nowMs)}`
|
||||
}
|
||||
|
||||
/** A minter that returns a fixed opaque token and records how many times it was called. */
|
||||
export function fakeMinter(token = 'fake.manage.token'): ManageTokenMinter & { readonly calls: () => number } {
|
||||
let n = 0
|
||||
return {
|
||||
async mint() {
|
||||
n += 1
|
||||
return token
|
||||
},
|
||||
calls: () => n,
|
||||
}
|
||||
}
|
||||
|
||||
export interface FakeCpCall {
|
||||
readonly method: string
|
||||
readonly accountIdOrHostId: string
|
||||
readonly token: string
|
||||
}
|
||||
|
||||
/** A CP client fake that records calls and returns canned data (or throws an injected error). */
|
||||
export function fakeCpClient(opts: {
|
||||
hosts?: readonly HostView[]
|
||||
pairing?: IssuedPairing
|
||||
throwErr?: Error
|
||||
} = {}): CpClient & { readonly calls: readonly FakeCpCall[] } {
|
||||
const calls: FakeCpCall[] = []
|
||||
const guard = (): void => {
|
||||
if (opts.throwErr) throw opts.throwErr
|
||||
}
|
||||
return {
|
||||
calls,
|
||||
async listHosts(accountId, token) {
|
||||
calls.push({ method: 'listHosts', accountIdOrHostId: accountId, token })
|
||||
guard()
|
||||
return opts.hosts ?? []
|
||||
},
|
||||
async createPairingCode(accountId, token) {
|
||||
calls.push({ method: 'createPairingCode', accountIdOrHostId: accountId, token })
|
||||
guard()
|
||||
return opts.pairing ?? { code: 'ABCD-EFGH', expiresAt: '2026-01-01T00:00:00.000Z' }
|
||||
},
|
||||
async deleteHost(hostId, token) {
|
||||
calls.push({ method: 'deleteHost', accountIdOrHostId: hostId, token })
|
||||
guard()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate an Ed25519 PKCS#8 PEM (private key) for signing-key tests. */
|
||||
export async function generatePkcs8Pem(): Promise<{ pem: string; publicRaw: Uint8Array }> {
|
||||
const pair = (await globalThis.crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as unknown as {
|
||||
publicKey: CryptoKey
|
||||
privateKey: CryptoKey
|
||||
}
|
||||
const pkcs8 = new Uint8Array(await globalThis.crypto.subtle.exportKey('pkcs8', pair.privateKey))
|
||||
const raw = new Uint8Array(await globalThis.crypto.subtle.exportKey('raw', pair.publicKey))
|
||||
const b64 = Buffer.from(pkcs8).toString('base64')
|
||||
const lines = b64.match(/.{1,64}/g) ?? [b64]
|
||||
const pem = `-----BEGIN PRIVATE KEY-----\n${lines.join('\n')}\n-----END PRIVATE KEY-----\n`
|
||||
return { pem, publicRaw: raw }
|
||||
}
|
||||
Reference in New Issue
Block a user