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.
75 lines
2.7 KiB
TypeScript
75 lines
2.7 KiB
TypeScript
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()
|
|
}
|
|
})
|
|
})
|