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:
74
control-panel/test/config.test.ts
Normal file
74
control-panel/test/config.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
loadConfig,
|
||||
DEFAULT_CP_URL,
|
||||
DEFAULT_TUNNEL_ZONE,
|
||||
DEFAULT_PANEL_BIND_PORT,
|
||||
DEFAULT_CAPABILITY_SIGN_KEY_PATH,
|
||||
} from '../src/config.js'
|
||||
|
||||
const base = {
|
||||
SESSION_SECRET: 'a-sufficiently-long-secret-value',
|
||||
BASE_DOMAIN: 'terminal.yaojia.wang',
|
||||
OPERATOR_ACCOUNT_ID: 'acct-1',
|
||||
}
|
||||
|
||||
describe('loadConfig', () => {
|
||||
it('applies defaults for optional fields', () => {
|
||||
const cfg = loadConfig({ ...base } as NodeJS.ProcessEnv)
|
||||
expect(cfg.cpUrl).toBe(DEFAULT_CP_URL)
|
||||
expect(cfg.tunnelZone).toBe(DEFAULT_TUNNEL_ZONE)
|
||||
expect(cfg.panelBindPort).toBe(DEFAULT_PANEL_BIND_PORT)
|
||||
expect(cfg.capabilitySignKeyPath).toBe(DEFAULT_CAPABILITY_SIGN_KEY_PATH)
|
||||
expect(cfg.panelPassword).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads all provided values and strips trailing slash from CP_URL', () => {
|
||||
const cfg = loadConfig({
|
||||
...base,
|
||||
PANEL_PASSWORD: 'pw',
|
||||
CP_URL: 'http://127.0.0.1:9000/',
|
||||
TUNNEL_ZONE: 'z.example',
|
||||
PANEL_BIND_PORT: '9999',
|
||||
} as NodeJS.ProcessEnv)
|
||||
expect(cfg.panelPassword).toBe('pw')
|
||||
expect(cfg.cpUrl).toBe('http://127.0.0.1:9000')
|
||||
expect(cfg.tunnelZone).toBe('z.example')
|
||||
expect(cfg.panelBindPort).toBe(9999)
|
||||
})
|
||||
|
||||
it('throws when SESSION_SECRET is missing (fail-closed)', () => {
|
||||
const { SESSION_SECRET: _omit, ...rest } = base
|
||||
expect(() => loadConfig(rest as NodeJS.ProcessEnv)).toThrow()
|
||||
})
|
||||
|
||||
it('throws when SESSION_SECRET is too short', () => {
|
||||
expect(() => loadConfig({ ...base, SESSION_SECRET: 'short' } as NodeJS.ProcessEnv)).toThrow()
|
||||
})
|
||||
|
||||
it('throws when BASE_DOMAIN is missing', () => {
|
||||
const { BASE_DOMAIN: _omit, ...rest } = base
|
||||
expect(() => loadConfig(rest as NodeJS.ProcessEnv)).toThrow()
|
||||
})
|
||||
|
||||
it('throws when OPERATOR_ACCOUNT_ID is missing', () => {
|
||||
const { OPERATOR_ACCOUNT_ID: _omit, ...rest } = base
|
||||
expect(() => loadConfig(rest as NodeJS.ProcessEnv)).toThrow()
|
||||
})
|
||||
|
||||
it('throws when PANEL_BIND_PORT is out of range', () => {
|
||||
expect(() => loadConfig({ ...base, PANEL_BIND_PORT: '70000' } as NodeJS.ProcessEnv)).toThrow()
|
||||
})
|
||||
|
||||
it('accepts loopback CP_URL hosts (127.0.0.0/8, ::1, localhost)', () => {
|
||||
for (const url of ['http://127.0.0.1:8080', 'http://127.9.9.9:1', 'http://[::1]:8080', 'http://localhost:8080']) {
|
||||
expect(loadConfig({ ...base, CP_URL: url } as NodeJS.ProcessEnv).cpUrl).toBe(url)
|
||||
}
|
||||
})
|
||||
|
||||
it('throws when CP_URL host is not loopback (anti-SSRF, fail-closed)', () => {
|
||||
for (const url of ['http://evil.example.com:8080', 'http://169.254.169.254/', 'http://10.0.0.5:8080', 'http://8.8.8.8']) {
|
||||
expect(() => loadConfig({ ...base, CP_URL: url } as NodeJS.ProcessEnv)).toThrow()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user