dsh 0.2.0-rc.2: in-container update (experimental channel), path-agnostic compose, upstream patches adapted to 0.1.x/0.2.x
This commit is contained in:
commit
e6dcef9bf5
302 files changed
+75178
No files matched your search
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"id": "gateway",
|
||||
"type": "service",
|
||||
"command": {
|
||||
"executable": "/usr/local/bin/node",
|
||||
"args": ["/run/dsh-platform/views/bootstrap/control-plane/services/gateway/index.mjs"],
|
||||
"timeoutSeconds": 30
|
||||
},
|
||||
"environment": {},
|
||||
"lifecycle": {
|
||||
"prepare": null,
|
||||
"preStart": null,
|
||||
"postStart": null,
|
||||
"preStop": null,
|
||||
"stop": null,
|
||||
"postStop": null
|
||||
},
|
||||
"health": {
|
||||
"type": "http",
|
||||
"host": "127.0.0.1",
|
||||
"port": 3080,
|
||||
"path": "/_dsh_gateway/health",
|
||||
"intervalSeconds": 1,
|
||||
"timeoutSeconds": 30
|
||||
},
|
||||
"logging": {
|
||||
"stdout": true,
|
||||
"stderr": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { loadConfig } from './lib/config.mjs'
|
||||
import { UsageError } from './lib/errors.mjs'
|
||||
import { runGateway } from './lib/lifecycle.mjs'
|
||||
import { JsonlLogManager } from '../../modules/log-manager/index.mjs'
|
||||
import { PlatformPaths } from '../../../platform/lib/paths.mjs'
|
||||
|
||||
const paths = new PlatformPaths(
|
||||
process.env.DSH_PLATFORM_DATA ?? '/data/platform',
|
||||
process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform',
|
||||
)
|
||||
const logs = new JsonlLogManager({
|
||||
root: paths.logsRoot,
|
||||
maxBytes: Number(process.env.DSH_LOG_MAX_BYTES ?? 104857600),
|
||||
retentionDays: Number(process.env.DSH_LOG_RETENTION_DAYS ?? 14),
|
||||
output: { stdout: process.stdout, stderr: process.stderr },
|
||||
})
|
||||
logs.on('error', error => { void logs.diagnostic('log-manager', 'capture.failed', { error }) })
|
||||
const report = (message, fields = {}) => message === 'gateway.access.login-failed'
|
||||
? logs.diagnosticRateLimited(
|
||||
`gateway.access.login-failed:${typeof fields.code === 'string' ? fields.code.toLowerCase() : 'unknown'}`,
|
||||
'gateway', message, fields,
|
||||
)
|
||||
: logs.diagnostic('gateway', message, fields)
|
||||
|
||||
try {
|
||||
const config = await loadConfig()
|
||||
process.exitCode = await runGateway(config, { report, managementPort: config.managementPort })
|
||||
} catch (error) {
|
||||
if (error instanceof UsageError) {
|
||||
await logs.diagnostic('gateway', 'gateway.configuration.failed', { error, level: 'warning' })
|
||||
process.exitCode = error.exitCode
|
||||
} else {
|
||||
await logs.diagnostic('gateway', 'gateway.process.failed', { error })
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { request as httpRequest } from 'node:http'
|
||||
|
||||
// A Bootstrap handoff stops DSH while the updater that initiated it is still
|
||||
// recorded as building the candidate. This state is consulted only after the
|
||||
// DSH upstream becomes unavailable, so ordinary candidate builds stay visible.
|
||||
const SWITCHING_STATES = new Set(['building-candidate', 'snapshotting-data', 'switching', 'probation'])
|
||||
const RECOVERING_STATES = new Set(['restoring-data'])
|
||||
|
||||
const COPY = Object.freeze({
|
||||
en: Object.freeze({
|
||||
starting: 'DeepSeek Harness is starting',
|
||||
stopping: 'DeepSeek Harness is stopping',
|
||||
stopped: 'DeepSeek Harness is stopped',
|
||||
restarting: 'DeepSeek Harness is restarting',
|
||||
switching: 'Switching the DeepSeek Harness runtime',
|
||||
'runtime-recovering': 'Restoring the DeepSeek Harness runtime',
|
||||
recovering: 'DeepSeek Harness stopped unexpectedly and recovery is in progress',
|
||||
failed: 'DeepSeek Harness could not start',
|
||||
'plugin-failed': 'DeepSeek Harness plugins repeatedly failed to load',
|
||||
unavailable: 'DeepSeek Harness is temporarily unavailable and recovery is in progress',
|
||||
}),
|
||||
zh: Object.freeze({
|
||||
starting: 'DeepSeek Harness 正在启动',
|
||||
stopping: 'DeepSeek Harness 正在停止',
|
||||
stopped: 'DeepSeek Harness 已停止',
|
||||
restarting: 'DeepSeek Harness 正在重新启动',
|
||||
switching: '正在切换 DeepSeek Harness 运行版本',
|
||||
'runtime-recovering': '正在恢复 DeepSeek Harness 运行版本',
|
||||
recovering: 'DeepSeek Harness 意外停止,正在尝试恢复',
|
||||
failed: 'DeepSeek Harness 启动失败',
|
||||
'plugin-failed': 'DeepSeek Harness 插件持续加载失败',
|
||||
unavailable: 'DeepSeek Harness 暂时不可用,正在尝试恢复',
|
||||
}),
|
||||
})
|
||||
|
||||
const MANAGEMENT_LINK = Object.freeze({
|
||||
en: 'Open DSH Management Console for diagnostics and recovery',
|
||||
zh: '打开 DSH 管理中心进行检查和恢复',
|
||||
})
|
||||
|
||||
const REFRESH_LINK = Object.freeze({ en: 'Reload page', zh: '刷新页面' })
|
||||
|
||||
export class DshAvailability {
|
||||
constructor({ now = () => Date.now(), failures = 3, failureWindowMs = 1_500 } = {}) {
|
||||
this.now = now
|
||||
this.failures = failures
|
||||
this.failureWindowMs = failureWindowMs
|
||||
this.everReady = false
|
||||
this.consecutiveFailures = 0
|
||||
this.firstFailureAt = null
|
||||
}
|
||||
|
||||
observe(ready) {
|
||||
if (ready) {
|
||||
this.everReady = true
|
||||
this.consecutiveFailures = 0
|
||||
this.firstFailureAt = null
|
||||
return
|
||||
}
|
||||
this.consecutiveFailures += 1
|
||||
this.firstFailureAt ??= this.now()
|
||||
}
|
||||
|
||||
classify(platform = {}) {
|
||||
if (platform.operation === 'restart-failed') return 'failed'
|
||||
if (platform.operation === 'restarting') return 'restarting'
|
||||
if (platform.operation === 'recovering') return 'runtime-recovering'
|
||||
if (platform.operation === 'switching') return 'switching'
|
||||
const updateState = platform.update?.status
|
||||
if (RECOVERING_STATES.has(updateState)) return 'runtime-recovering'
|
||||
if (SWITCHING_STATES.has(updateState)) return 'switching'
|
||||
const lifecycle = platform.dshLifecycle ?? {}
|
||||
if (['starting', 'stopping', 'stopped', 'restarting', 'recovering', 'failed'].includes(lifecycle.state)) {
|
||||
return lifecycle.state
|
||||
}
|
||||
if (platform.recoveryMode !== null && platform.recoveryMode !== undefined) return 'failed'
|
||||
if (!this.everReady) return 'starting'
|
||||
if (
|
||||
this.consecutiveFailures >= this.failures
|
||||
&& this.firstFailureAt !== null
|
||||
&& this.now() - this.firstFailureAt >= this.failureWindowMs
|
||||
) return 'unavailable'
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
export function probeDsh({ host, port, timeoutMs = 750 }) {
|
||||
return new Promise(resolve => {
|
||||
const request = httpRequest({ hostname: host, port, path: '/', method: 'GET' }, response => {
|
||||
response.resume()
|
||||
resolve((response.statusCode ?? 500) < 500)
|
||||
})
|
||||
request.setTimeout(timeoutMs, () => request.destroy())
|
||||
request.once('error', () => resolve(false))
|
||||
request.end()
|
||||
})
|
||||
}
|
||||
|
||||
function cookieLocale(cookie = '') {
|
||||
for (const part of cookie.split(';')) {
|
||||
const [name, value] = part.trim().split('=', 2)
|
||||
if (name === 'dsh_locale' && (value === 'zh' || value === 'en')) return value
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function language(headers) {
|
||||
const saved = cookieLocale(headers.cookie)
|
||||
if (saved !== undefined) return saved
|
||||
for (const item of (headers['accept-language'] ?? '').split(',')) {
|
||||
const primary = item.trim().split(';', 1)[0].split('-', 1)[0].toLowerCase()
|
||||
if (primary === 'zh' || primary === 'en') return primary
|
||||
}
|
||||
return 'en'
|
||||
}
|
||||
|
||||
export function stateMessage(state, headers = {}, lifecycle = {}) {
|
||||
const locale = language(headers)
|
||||
const message = COPY[locale][state] ?? COPY.en.unavailable
|
||||
if (state !== 'recovering' || !(lifecycle.attempt > 0) || !(lifecycle.maxAttempts > 0)) return message
|
||||
return locale === 'zh'
|
||||
? `${message}(第 ${String(lifecycle.attempt)} / ${String(lifecycle.maxAttempts)} 次)`
|
||||
: `${message} (attempt ${String(lifecycle.attempt)} of ${String(lifecycle.maxAttempts)})`
|
||||
}
|
||||
|
||||
export function availabilityPage(state, headers = {}, { lifecycle = {}, returnPath = null, poll = true, allowRefresh = false, managementHref = '/_dsh_platform/console/' } = {}) {
|
||||
const locale = language(headers)
|
||||
const message = stateMessage(state, headers, lifecycle)
|
||||
const managementLink = MANAGEMENT_LINK[locale]
|
||||
const refreshLink = REFRESH_LINK[locale]
|
||||
const target = JSON.stringify(returnPath).replaceAll('<', '\\u003c')
|
||||
return `<!doctype html>
|
||||
<html lang="${locale === 'zh' ? 'zh-CN' : 'en'}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>DeepSeek Harness</title>
|
||||
<style>
|
||||
html{height:100%}body{min-height:100%;margin:0;display:grid;place-items:center;background:#151517;color:#f3f3f4;font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.boot{display:flex;flex-direction:column;align-items:center;gap:16px;padding:24px;text-align:center}.wordmark{font-size:16px;line-height:24px;font-weight:600;letter-spacing:0}.status{max-width:520px;font-size:13px;line-height:20px;color:#a7a7ad}.actions{display:flex;flex-wrap:wrap;justify-content:center;gap:10px}.management{padding:7px 12px;border:1px solid #46464b;border-radius:6px;color:#d7d7db;font-size:13px;line-height:20px;text-decoration:none}.management:hover{background:#232326}.management:focus-visible{outline:2px solid #8ca8ff;outline-offset:2px}@media(prefers-color-scheme:light){body{background:#f9fafb;color:#0f1115}.status{color:#6d7178}.management{border-color:#d6d8dc;color:#34373d}.management:hover{background:#f0f1f3}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="boot"><div class="wordmark">HARNESS</div><div class="status" role="status">${message}</div><div class="actions"><a class="management" href="${String(managementHref).replaceAll('"', '"')}">${managementLink}</a>${allowRefresh ? `<a class="management refresh" href="">${refreshLink}</a>` : ''}</div></main>
|
||||
${poll ? `<script>
|
||||
const returnPath=${target};const status=document.querySelector('.status');const management=document.querySelector('.management:not(.refresh)');
|
||||
async function check(){try{const response=await fetch('/_dsh_gateway/readiness',{cache:'no-store'});const value=await response.json();if(typeof value.managementHref==='string')management.href=value.managementHref;if(value.ready){if(returnPath===null)location.reload();else location.replace(returnPath);return}if(typeof value.message==='string')status.textContent=value.message}catch{}setTimeout(check,1000)}
|
||||
setTimeout(check,600);
|
||||
</script>` : ''}
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,75 @@
|
||||
import { UsageError } from './errors.mjs'
|
||||
|
||||
export function parseBoolean(name, value, fallback) {
|
||||
const resolved = value ?? String(fallback)
|
||||
if (resolved === 'true') return true
|
||||
if (resolved === 'false') return false
|
||||
throw new UsageError(`${name} must be true or false`)
|
||||
}
|
||||
|
||||
function parseUrlAuthority(value) {
|
||||
try {
|
||||
return new URL(`http://${value}`)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalAuthority(value, parsed) {
|
||||
const port = parsed.port !== '' ? parsed.port : new URL(`https://${value}`).port
|
||||
return port === '' ? parsed.hostname : `${parsed.hostname}:${port}`
|
||||
}
|
||||
|
||||
export function parseTrustedAuthority(value) {
|
||||
const parsed = parseUrlAuthority(value)
|
||||
if (
|
||||
parsed === undefined
|
||||
|| parsed.hostname.includes('*')
|
||||
|| canonicalAuthority(value, parsed) !== value.toLowerCase()
|
||||
) {
|
||||
throw new UsageError(
|
||||
`DSH_TRUSTED_HOSTS entry ${JSON.stringify(value)} must be a canonical host or host:port authority`,
|
||||
)
|
||||
}
|
||||
const authority = canonicalAuthority(value, parsed)
|
||||
return Object.freeze({
|
||||
hostname: parsed.hostname,
|
||||
authority,
|
||||
anyPort: authority === parsed.hostname,
|
||||
matchAuthority: parsed.host,
|
||||
})
|
||||
}
|
||||
|
||||
export function parseTrustedHosts(environment = process.env) {
|
||||
const raw = environment.DSH_TRUSTED_HOSTS?.trim() ?? ''
|
||||
if (raw === '') return Object.freeze({ wildcard: false, authorities: Object.freeze([]) })
|
||||
|
||||
const entries = raw.split(',').map(entry => entry.trim())
|
||||
if (entries.some(entry => entry === '')) {
|
||||
throw new UsageError('DSH_TRUSTED_HOSTS cannot contain an empty entry')
|
||||
}
|
||||
if (entries.includes('*')) {
|
||||
if (entries.length !== 1) throw new UsageError('DSH_TRUSTED_HOSTS wildcard must be used alone')
|
||||
return Object.freeze({ wildcard: true, authorities: Object.freeze([]) })
|
||||
}
|
||||
|
||||
const unique = new Map()
|
||||
for (const entry of entries) {
|
||||
const parsed = parseTrustedAuthority(entry)
|
||||
unique.set(`${parsed.authority}|${String(parsed.anyPort)}`, parsed)
|
||||
}
|
||||
return Object.freeze({ wildcard: false, authorities: Object.freeze([...unique.values()]) })
|
||||
}
|
||||
|
||||
export async function loadConfig(environment = process.env) {
|
||||
const managementPort = environment.DSH_MANAGEMENT_PORT === undefined || environment.DSH_MANAGEMENT_PORT.trim() === ''
|
||||
? 3081 : Number(environment.DSH_MANAGEMENT_PORT)
|
||||
if (managementPort !== null && (!Number.isInteger(managementPort) || managementPort < 1 || managementPort > 65535)) {
|
||||
throw new UsageError('DSH_MANAGEMENT_PORT must be an integer between 1 and 65535')
|
||||
}
|
||||
return Object.freeze({
|
||||
trustedHosts: parseTrustedHosts(environment),
|
||||
polyfill: parseBoolean('DSH_PROXY_POLYFILL', environment.DSH_PROXY_POLYFILL, true),
|
||||
managementPort,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { request } from 'node:http'
|
||||
|
||||
function lifecycleReadiness(socketPath) {
|
||||
const body = Buffer.from('{}')
|
||||
return new Promise((resolve, reject) => {
|
||||
const outgoing = request({
|
||||
socketPath,
|
||||
path: '/v1/runtime/readiness',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-length': body.byteLength,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
}, response => {
|
||||
const chunks = []
|
||||
response.on('data', chunk => chunks.push(chunk))
|
||||
response.once('end', () => {
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`DSH lifecycle readiness returned HTTP ${String(response.statusCode)}`))
|
||||
return
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
outgoing.setTimeout(750, () => outgoing.destroy(new Error('DSH lifecycle readiness timed out')))
|
||||
outgoing.once('error', reject)
|
||||
outgoing.end(body)
|
||||
})
|
||||
}
|
||||
|
||||
function exchange(host, port, readyUrl) {
|
||||
const url = new URL(readyUrl)
|
||||
if (url.protocol !== 'http:' || url.hostname !== host || Number(url.port) !== port
|
||||
|| url.pathname !== '/' || url.username !== '' || url.password !== '' || url.hash !== ''
|
||||
|| url.searchParams.getAll('token').length !== 1) {
|
||||
throw new Error('DSH Web readiness URL is invalid')
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const outgoing = request({
|
||||
hostname: host,
|
||||
port,
|
||||
path: `${url.pathname}${url.search}`,
|
||||
method: 'GET',
|
||||
headers: { 'cache-control': 'no-store' },
|
||||
}, response => {
|
||||
response.resume()
|
||||
response.once('end', () => {
|
||||
const cookie = response.headers['set-cookie']?.[0]?.split(';', 1)[0]
|
||||
if (response.statusCode !== 303 || response.headers.location !== '/'
|
||||
|| typeof cookie !== 'string' || cookie === '') {
|
||||
reject(new Error('DSH Web authentication exchange failed'))
|
||||
} else resolve(cookie)
|
||||
})
|
||||
})
|
||||
outgoing.setTimeout(750, () => outgoing.destroy(new Error('DSH Web authentication exchange timed out')))
|
||||
outgoing.once('error', reject)
|
||||
outgoing.end()
|
||||
})
|
||||
}
|
||||
|
||||
export class DshUpstreamAuthentication {
|
||||
constructor({
|
||||
socketPath = '/run/dsh-platform/dsh-lifecycle.sock',
|
||||
host = '127.0.0.1',
|
||||
port = 3079,
|
||||
now = () => Date.now(),
|
||||
legacyProbeIntervalMs = 250,
|
||||
} = {}) {
|
||||
this.socketPath = socketPath
|
||||
this.host = host
|
||||
this.port = port
|
||||
this.now = now
|
||||
this.legacyProbeIntervalMs = legacyProbeIntervalMs
|
||||
this.cachedCookie = undefined
|
||||
this.cachedGeneration = null
|
||||
this.nextProbeAt = 0
|
||||
this.pending = null
|
||||
}
|
||||
|
||||
async publicReady() {
|
||||
const readiness = await lifecycleReadiness(this.socketPath)
|
||||
const ready = readiness?.publicReady === true
|
||||
|| (readiness?.publicReady === undefined && readiness?.ready === true)
|
||||
if (ready) this.nextProbeAt = 0
|
||||
return ready
|
||||
}
|
||||
|
||||
async cookie() {
|
||||
if (this.pending !== null) return this.pending
|
||||
if (this.now() < this.nextProbeAt) return null
|
||||
this.pending = this.refresh().finally(() => { this.pending = null })
|
||||
return this.pending
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
const readiness = await lifecycleReadiness(this.socketPath)
|
||||
const publicReady = readiness?.publicReady === true
|
||||
|| (readiness?.publicReady === undefined && readiness?.ready === true)
|
||||
if (!publicReady || readiness?.ready !== true || typeof readiness.readyUrl !== 'string') {
|
||||
if (!publicReady) {
|
||||
this.cachedCookie = undefined
|
||||
this.cachedGeneration = null
|
||||
}
|
||||
this.nextProbeAt = this.now() + this.legacyProbeIntervalMs
|
||||
return null
|
||||
}
|
||||
if (this.cachedCookie !== undefined && this.cachedGeneration === (readiness.generation ?? null)) {
|
||||
return this.cachedCookie
|
||||
}
|
||||
const cookie = await exchange(this.host, this.port, readiness.readyUrl)
|
||||
this.cachedCookie = cookie
|
||||
this.cachedGeneration = readiness.generation ?? null
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export class UsageError extends Error {
|
||||
constructor(message) {
|
||||
super(message)
|
||||
this.name = 'UsageError'
|
||||
this.exitCode = 64
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { createGatewayServer, closeGatewayServer } from './proxy.mjs'
|
||||
import { createBrowserAuthentication } from './browser-auth.mjs'
|
||||
import { LocalApiClient } from '../../../modules/updater/lib/client.mjs'
|
||||
import { safeReturnPath } from './proxy.mjs'
|
||||
import { DshUpstreamAuthentication } from './dsh-upstream-auth.mjs'
|
||||
|
||||
export const EXTERNAL_HOST = '0.0.0.0'
|
||||
export const EXTERNAL_PORT = 3080
|
||||
|
||||
function listen(server, port, host) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onError = (error) => {
|
||||
server.off('listening', onListening)
|
||||
reject(error)
|
||||
}
|
||||
const onListening = () => {
|
||||
server.off('error', onError)
|
||||
resolve()
|
||||
}
|
||||
server.once('error', onError)
|
||||
server.once('listening', onListening)
|
||||
server.listen(port, host)
|
||||
})
|
||||
}
|
||||
|
||||
export async function runGateway(config, {
|
||||
signalSource = process,
|
||||
gatewayFactory = createGatewayServer,
|
||||
externalHost = EXTERNAL_HOST,
|
||||
externalPort = EXTERNAL_PORT,
|
||||
managementPort = null,
|
||||
managementHost = externalHost,
|
||||
managementSocketPath = process.env.DSH_PLATFORM_MANAGEMENT_SOCKET ?? '/run/dsh-platform/management.sock',
|
||||
managementCliSocketPath = process.env.DSH_PLATFORM_MANAGEMENT_CLI_SOCKET ?? '/run/dsh-platform/management-cli.sock',
|
||||
maintenanceSocketPath = process.env.DSH_PLATFORM_MAINTENANCE_SOCKET ?? '/run/dsh-platform/maintenance.sock',
|
||||
lifecycleSocketPath = process.env.DSH_PLATFORM_LIFECYCLE_SOCKET ?? '/run/dsh-platform/dsh-lifecycle.sock',
|
||||
accessSocketPath = process.env.DSH_PLATFORM_ACCESS_SOCKET ?? '/run/dsh-platform/access/access.sock',
|
||||
accessClient,
|
||||
managementClient,
|
||||
platformStatusPollMs = 250,
|
||||
platformStatusCacheMs = 10_000,
|
||||
now = () => Date.now(),
|
||||
report = async () => {},
|
||||
} = {}) {
|
||||
const record = (message, fields = {}) => Promise.resolve().then(() => report(message, fields)).catch(() => {})
|
||||
const management = managementClient ?? new LocalApiClient(managementCliSocketPath)
|
||||
const access = accessClient ?? new LocalApiClient(accessSocketPath)
|
||||
const accessRequest = access.request.bind(access)
|
||||
const resilientAccess = accessClient === undefined
|
||||
? { request: async (method, path, body, headers) => {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await accessRequest(method, path, body, headers)
|
||||
} catch (error) {
|
||||
const retryable = ['ENOENT', 'ECONNREFUSED', 'ECONNRESET', 'EPIPE'].includes(error?.code)
|
||||
if (!retryable || attempt >= 20) throw error
|
||||
await new Promise(resolve => setTimeout(resolve, Math.min(250, 25 * (attempt + 1))))
|
||||
}
|
||||
}
|
||||
} } : access
|
||||
const browserAuthentication = createBrowserAuthentication({ access: resilientAccess, safeReturnPath, report: record })
|
||||
const dshUpstreamAuthentication = new DshUpstreamAuthentication({ socketPath: lifecycleSocketPath })
|
||||
const isolatedBrowserAuthentication = createBrowserAuthentication({
|
||||
access: resilientAccess,
|
||||
safeReturnPath,
|
||||
report: record,
|
||||
paths: { authPrefix: '/auth/', accessPrefix: '/access/', transitionPrefix: '/transition/', consolePath: '/' },
|
||||
})
|
||||
let lastPlatformStatus
|
||||
let lastPlatformStatusAt = 0
|
||||
const refreshPlatformStatus = async () => {
|
||||
const status = await management.request('GET', '/_dsh_platform/api/v1/status')
|
||||
lastPlatformStatus = status
|
||||
lastPlatformStatusAt = now()
|
||||
return status
|
||||
}
|
||||
const platformStatus = async () => {
|
||||
try {
|
||||
return await refreshPlatformStatus()
|
||||
} catch (error) {
|
||||
if (lastPlatformStatus !== undefined && now() - lastPlatformStatusAt <= platformStatusCacheMs) {
|
||||
return lastPlatformStatus
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
const server = gatewayFactory({
|
||||
polyfill: config.polyfill,
|
||||
trustedHosts: config.trustedHosts,
|
||||
managementSocketPath,
|
||||
maintenanceSocketPath,
|
||||
browserAuthentication,
|
||||
dshUpstreamAuthentication,
|
||||
platformStatus,
|
||||
report: record,
|
||||
surface: 'compat',
|
||||
})
|
||||
const managementServer = managementPort === null || managementPort === undefined
|
||||
? null
|
||||
: gatewayFactory({
|
||||
polyfill: false,
|
||||
trustedHosts: config.trustedHosts,
|
||||
managementSocketPath,
|
||||
maintenanceSocketPath,
|
||||
browserAuthentication: isolatedBrowserAuthentication,
|
||||
dshUpstreamAuthentication,
|
||||
platformStatus,
|
||||
report: record,
|
||||
surface: 'management',
|
||||
})
|
||||
const platformStatusTimer = setInterval(() => {
|
||||
void refreshPlatformStatus().catch(() => {})
|
||||
}, platformStatusPollMs)
|
||||
platformStatusTimer.unref?.()
|
||||
void refreshPlatformStatus().catch(() => {})
|
||||
let resolveSignal
|
||||
const receivedSignal = new Promise(resolve => { resolveSignal = resolve })
|
||||
const onSignal = signal => resolveSignal(signal)
|
||||
const onSigint = () => onSignal('SIGINT')
|
||||
const onSigterm = () => onSignal('SIGTERM')
|
||||
signalSource.once('SIGINT', onSigint)
|
||||
signalSource.once('SIGTERM', onSigterm)
|
||||
try {
|
||||
await record('gateway.starting', {
|
||||
externalHost, externalPort,
|
||||
...(managementServer === null ? {} : { managementHost, managementPort }),
|
||||
})
|
||||
await listen(server, externalPort, externalHost)
|
||||
if (managementServer !== null) await listen(managementServer, managementPort, managementHost)
|
||||
await record('gateway.ready', {
|
||||
externalHost, externalPort,
|
||||
...(managementServer === null ? {} : { managementHost, managementPort }),
|
||||
})
|
||||
const serverFailed = new Promise((_, reject) => server.once('error', reject))
|
||||
const signal = await Promise.race([receivedSignal, serverFailed])
|
||||
await record('gateway.stopping', { signal })
|
||||
await closeGatewayServer(server)
|
||||
if (managementServer !== null) await closeGatewayServer(managementServer)
|
||||
await record('gateway.stopped')
|
||||
return 0
|
||||
} catch (error) {
|
||||
await record('gateway.fatal', { error })
|
||||
if (server.listening || managementServer?.listening) {
|
||||
try {
|
||||
if (server.listening) await closeGatewayServer(server)
|
||||
if (managementServer?.listening) await closeGatewayServer(managementServer)
|
||||
} catch (closeError) {
|
||||
await record('gateway.stop.failed', { error: closeError, cause: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearInterval(platformStatusTimer)
|
||||
signalSource.off('SIGINT', onSigint)
|
||||
signalSource.off('SIGTERM', onSigterm)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
export const RANDOM_UUID_POLYFILL = '<script>(function(){try{var c=globalThis.crypto;if(c&&typeof c.randomUUID!=="function"&&typeof c.getRandomValues==="function"){c.randomUUID=function(){var b=c.getRandomValues(new Uint8Array(16));b[6]=(b[6]&15)|64;b[8]=(b[8]&63)|128;var h="";for(var i=0;i<16;i++){h+=b[i].toString(16).padStart(2,"0")}return h.slice(0,8)+"-"+h.slice(8,12)+"-"+h.slice(12,16)+"-"+h.slice(16,20)+"-"+h.slice(20)}}}catch(e){}})();</script>'
|
||||
|
||||
export const LIFECYCLE_TRANSITION_GUARD = `<script>(function(){
|
||||
try{
|
||||
var api="/_dsh_platform/plugin-api/v1",wait="/_dsh_gateway/wait",navigating=false;
|
||||
function transition(value){return ["restarting","switching","recovering","restart-failed"].includes(value.operation)||["snapshotting-data","switching","probation","restoring-data"].includes(value.update&&value.update.status)||["starting","stopping","stopped","restarting","recovering","failed"].includes(value.dshLifecycle&&value.dshLifecycle.state)}
|
||||
function navigate(){if(navigating||location.pathname===wait)return;navigating=true;var back=location.pathname+location.search+location.hash;location.replace(wait+"?return="+encodeURIComponent(back))}
|
||||
async function inspect(){try{var response=await fetch(api+"/status",{cache:"no-store"}),value=await response.json();if(transition(value))navigate()}catch(e){}}
|
||||
var events=new EventSource(api+"/events");events.addEventListener("state",inspect);inspect();
|
||||
}catch(e){}
|
||||
})();</script>`
|
||||
|
||||
export const SESSION_VALIDITY_GUARD = `<script>(function(){
|
||||
try{
|
||||
var checking=false;
|
||||
async function inspect(){if(checking)return;checking=true;try{var response=await fetch("/_dsh_platform/auth/session-context",{cache:"no-store",credentials:"same-origin"});if(response.status===401)location.reload()}catch(e){}finally{checking=false}}
|
||||
setInterval(inspect,5000);addEventListener("focus",inspect);document.addEventListener("visibilitychange",function(){if(document.visibilityState==="visible")inspect()});
|
||||
}catch(e){}
|
||||
})();</script>`
|
||||
|
||||
export const PLUGIN_RECOVERY_GUARD = `<script>(function(){
|
||||
try{
|
||||
var nativeFetch=globalThis.fetch.bind(globalThis),markerKey="dsh-platform:plugin-load-recovery",waitPath="/_dsh_gateway/wait",failurePath="/_dsh_gateway/plugin-failure",navigating=false;
|
||||
function plugin(url){var match=url.pathname.match(/^\\/plugins\\/(.+)\\/client\\.js$/);if(!match)return null;try{return decodeURIComponent(match[1])}catch(e){return match[1]}}
|
||||
function target(input){try{return new URL(typeof input==="string"?input:input.url,location.href)}catch(e){return null}}
|
||||
function marker(){try{var value=JSON.parse(sessionStorage.getItem(markerKey));return value&&Date.now()-value.createdAt<120000?value:null}catch(e){return null}}
|
||||
function save(value){try{sessionStorage.setItem(markerKey,JSON.stringify(value))}catch(e){}}
|
||||
function report(event,fields){nativeFetch("/_dsh_gateway/client-event",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(Object.assign({event:event,pathname:location.pathname},fields)),keepalive:true}).catch(function(){})}
|
||||
async function json(path){try{var response=await nativeFetch(path,{cache:"no-store"});return await response.json()}catch(e){return {}}}
|
||||
function transition(value){return ["restarting","switching","recovering","restart-failed"].includes(value.operation)||["snapshotting-data","switching","probation","restoring-data"].includes(value.update&&value.update.status)||["starting","stopping","stopped","restarting","recovering","failed"].includes(value.dshLifecycle&&value.dshLifecycle.state)}
|
||||
async function recover(url,pluginId,reason){
|
||||
var values=await Promise.all([json("/_dsh_gateway/readiness"),json("/_dsh_platform/plugin-api/v1/status")]),readiness=values[0],status=values[1],lifecycle=status.dshLifecycle||{},updated=Date.parse(lifecycle.updatedAt),recent=typeof lifecycle.taskId==="string"&&Number.isFinite(updated)&&Date.now()-updated<30000,eligible=transition(status)||recent||readiness.pluginRecoveryEligible===true;
|
||||
var identity=typeof lifecycle.taskId==="string"?lifecycle.taskId:(lifecycle.updatedAt||readiness.state||"unknown"),previous=marker(),attempt=previous&&previous.identity===identity?previous.attempt+1:1,fields={pluginId:pluginId,revision:url.searchParams.get("rev"),lifecycleState:lifecycle.state||readiness.state||null,lifecycleTaskId:lifecycle.taskId||null,recoveryAttempt:attempt,reason:reason};
|
||||
if(attempt>=3){report("browser.plugin-load.recovery.failed",Object.assign({level:"error"},fields));save({identity:identity,taskId:lifecycle.taskId||null,url:url.pathname+url.search,pluginId:pluginId,attempt:attempt,createdAt:Date.now(),completed:false});if(!navigating){navigating=true;location.replace(failurePath)}return true}
|
||||
if(!eligible){report("browser.plugin-load.failed",Object.assign({level:"error"},fields));return false}
|
||||
report("browser.plugin-load.failed",Object.assign({level:"warning"},fields));
|
||||
save({identity:identity,taskId:lifecycle.taskId||null,url:url.pathname+url.search,pluginId:pluginId,attempt:attempt,createdAt:Date.now(),completed:false});
|
||||
report("browser.plugin-load.recovery.started",Object.assign({level:"warning"},fields));
|
||||
if(!navigating){navigating=true;var back=location.pathname+location.search+location.hash;location.replace(waitPath+"?return="+encodeURIComponent(back))}return true
|
||||
}
|
||||
function completed(url,pluginId){var value=marker();if(!value||value.completed||value.url!==url.pathname+url.search)return;value.completed=true;save(value);report("browser.plugin-load.recovery.completed",{level:"info",pluginId:pluginId,revision:url.searchParams.get("rev"),lifecycleTaskId:value.taskId||null,recoveryAttempt:value.attempt})}
|
||||
globalThis.fetch=function(input,init){var url=target(input),pluginId=url&&plugin(url),pending=nativeFetch(input,init);if(!pluginId)return pending;return pending.then(function(response){if(response.ok){completed(url,pluginId);return response}if(response.status!==502&&response.status!==503)return response;return recover(url,pluginId,"HTTP "+response.status).then(function(active){return active?new Promise(function(){}):response})},function(error){return recover(url,pluginId,error&&error.name||"network error").then(function(active){if(active)return new Promise(function(){});throw error})})}
|
||||
function errorUrl(value){
|
||||
var candidates=[value&&value.filename,value&&value.error&&value.error.stack,value&&value.error&&value.error.message,value&&value.reason&&value.reason.stack,value&&value.reason&&value.reason.message];
|
||||
for(var i=0;i<candidates.length;i++){if(typeof candidates[i]!=="string")continue;var match=candidates[i].match(/(?:https?:\\/\\/[^\\s)]+)?(\\/plugins\\/(?:@[^/\\s]+\\/)?[^/\\s]+\\/client\\.js(?:\\?[^\\s)]+)?)/);if(!match)continue;var url=target(match[1]);if(url)return url}
|
||||
return null
|
||||
}
|
||||
function handleError(value,reason){var url=errorUrl(value);if(!url)return;var pluginId=plugin(url);if(!pluginId)return;recover(url,pluginId,reason).catch(function(){})}
|
||||
addEventListener("error",function(event){handleError(event,"plugin module error")},true);
|
||||
addEventListener("unhandledrejection",function(event){handleError({reason:event.reason},"plugin module rejection")},true);
|
||||
}catch(e){}
|
||||
})();</script>`
|
||||
|
||||
export function injectRandomUuidPolyfill(html) {
|
||||
const match = /<head(?:\s[^>]*)?>/i.exec(html)
|
||||
const injected = `${RANDOM_UUID_POLYFILL}${LIFECYCLE_TRANSITION_GUARD}${SESSION_VALIDITY_GUARD}${PLUGIN_RECOVERY_GUARD}`
|
||||
if (match === null) return `${injected}${html}`
|
||||
const end = match.index + match[0].length
|
||||
return `${html.slice(0, end)}${injected}${html.slice(end)}`
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,56 @@
|
||||
function parseRequestAuthority(value) {
|
||||
if (
|
||||
value === ''
|
||||
|| /[\s/@?#]/.test(value)
|
||||
|| value.endsWith(':')
|
||||
) return undefined
|
||||
try {
|
||||
const parsed = new URL(`http://${value}`)
|
||||
return parsed.hostname === '' ? undefined : parsed
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function isLoopbackHostname(hostname) {
|
||||
if (hostname === 'localhost' || hostname === '[::1]') return true
|
||||
const parts = hostname.split('.')
|
||||
return parts.length === 4
|
||||
&& parts[0] === '127'
|
||||
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
||||
}
|
||||
|
||||
function matchesTrustedAuthority(requestUrl, trustedHosts) {
|
||||
if (trustedHosts.wildcard) return true
|
||||
return trustedHosts.authorities.some((entry) => (
|
||||
entry.anyPort ? entry.hostname === requestUrl.hostname : entry.matchAuthority === requestUrl.host
|
||||
))
|
||||
}
|
||||
|
||||
export function inspectExternalRequest(headers, trustedHosts, {
|
||||
allowCrossOrigin = false,
|
||||
allowUntrustedAuthority = false,
|
||||
} = {}) {
|
||||
const host = headers.host
|
||||
if (typeof host !== 'string') return Object.freeze({ accepted: false, reason: 'missing-host' })
|
||||
const hostUrl = parseRequestAuthority(host)
|
||||
if (hostUrl === undefined) return Object.freeze({ accepted: false, reason: 'invalid-host' })
|
||||
if (!allowUntrustedAuthority && !isLoopbackHostname(hostUrl.hostname) && !matchesTrustedAuthority(hostUrl, trustedHosts)) {
|
||||
return Object.freeze({ accepted: false, reason: 'untrusted-host' })
|
||||
}
|
||||
if (!allowCrossOrigin && headers['sec-fetch-site'] === 'cross-site') {
|
||||
return Object.freeze({ accepted: false, reason: 'cross-site' })
|
||||
}
|
||||
const origin = headers.origin
|
||||
if (!allowCrossOrigin && origin !== undefined) {
|
||||
if (typeof origin !== 'string') return Object.freeze({ accepted: false, reason: 'invalid-origin' })
|
||||
try {
|
||||
if (new URL(origin).host !== hostUrl.host) {
|
||||
return Object.freeze({ accepted: false, reason: 'origin-mismatch' })
|
||||
}
|
||||
} catch {
|
||||
return Object.freeze({ accepted: false, reason: 'invalid-origin' })
|
||||
}
|
||||
}
|
||||
return Object.freeze({ accepted: true, externalAuthority: hostUrl.host })
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "dsh-docker-gateway",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node --test"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createServer, request as httpRequest } from 'node:http'
|
||||
import { connect as netConnect } from 'node:net'
|
||||
import { mkdtemp } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { parseTrustedHosts } from '../lib/config.mjs'
|
||||
import { closeGatewayServer, createGatewayServer, HEALTH_PATH } from '../lib/proxy.mjs'
|
||||
|
||||
async function listen(server) {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
return server.address().port
|
||||
}
|
||||
|
||||
async function close(server) {
|
||||
server.closeAllConnections?.()
|
||||
await new Promise(resolve => server.close(resolve))
|
||||
}
|
||||
|
||||
function request(port, { path = '/', method = 'GET', headers = {}, body = '' } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const outgoing = httpRequest({ hostname: '127.0.0.1', port, path, method, headers }, response => {
|
||||
const chunks = []
|
||||
response.on('data', chunk => chunks.push(chunk))
|
||||
response.on('end', () => resolve({
|
||||
status: response.statusCode,
|
||||
headers: response.headers,
|
||||
body: Buffer.concat(chunks).toString('utf8'),
|
||||
}))
|
||||
})
|
||||
outgoing.once('error', reject)
|
||||
outgoing.end(body)
|
||||
})
|
||||
}
|
||||
|
||||
const initialized = Object.freeze({ status: async () => ({ state: 'initialized' }), handle: async () => false })
|
||||
|
||||
test('Gateway fails closed without an authenticated browser session while health remains available', async () => {
|
||||
let upstreamRequests = 0
|
||||
const upstream = createServer((_incoming, response) => { upstreamRequests += 1; response.end('upstream') })
|
||||
const upstreamPort = await listen(upstream)
|
||||
const gateway = createGatewayServer({ trustedHosts: parseTrustedHosts({}), upstreamPort })
|
||||
const port = await listen(gateway)
|
||||
try {
|
||||
assert.equal((await request(port, { path: HEALTH_PATH, headers: { host: '127.0.0.1' } })).status, 200)
|
||||
const navigation = await request(port, { headers: { accept: 'text/html', host: '127.0.0.1' } })
|
||||
assert.equal(navigation.status, 303)
|
||||
assert.match(navigation.headers.location, /^\/_dsh_platform\/auth\//)
|
||||
assert.equal((await request(port, { path: '/api/sessions', headers: { host: '127.0.0.1' } })).status, 401)
|
||||
assert.equal(upstreamRequests, 0)
|
||||
} finally {
|
||||
await Promise.all([closeGatewayServer(gateway), close(upstream)])
|
||||
}
|
||||
})
|
||||
|
||||
test('Gateway reports an unavailable authentication backend as a service failure', async () => {
|
||||
const unavailable = new Error('access socket unavailable')
|
||||
unavailable.browserAuthenticationBackend = true
|
||||
const browserAuthentication = {
|
||||
status: async () => { throw unavailable },
|
||||
handle: async () => false,
|
||||
}
|
||||
const gateway = createGatewayServer({
|
||||
trustedHosts: parseTrustedHosts({}), browserAuthentication, upstreamPort: 1,
|
||||
})
|
||||
const port = await listen(gateway)
|
||||
try {
|
||||
assert.equal((await request(port, {
|
||||
path: HEALTH_PATH, headers: { host: '127.0.0.1' },
|
||||
})).status, 200)
|
||||
const unavailable = await request(port, { headers: { host: '127.0.0.1', accept: 'text/html' } })
|
||||
assert.equal(unavailable.status, 503)
|
||||
assert.match(unavailable.body, /authentication service unavailable/)
|
||||
} finally { await closeGatewayServer(gateway) }
|
||||
})
|
||||
|
||||
test('DSH and Management sessions authorize only their own route surfaces', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-gateway-session-domains-'))
|
||||
const socketPath = join(root, 'management.sock')
|
||||
const management = createServer((incoming, response) => response.end(`management:${incoming.url}`))
|
||||
await new Promise((resolve, reject) => {
|
||||
management.once('error', reject)
|
||||
management.listen(socketPath, resolve)
|
||||
})
|
||||
const upstream = createServer((_incoming, response) => response.end('dsh'))
|
||||
const upstreamPort = await listen(upstream)
|
||||
const browserAuthentication = {
|
||||
...initialized,
|
||||
validateDsh: async request => ({ authenticated: request.headers.cookie === 'dsh=yes' }),
|
||||
authorizePlugin: async request => request.headers.cookie === 'dsh=yes'
|
||||
? { authorized: true, capability: { token: 'plugin-capability' } }
|
||||
: { authorized: false },
|
||||
validateManagement: async request => ({ authenticated: request.headers.cookie === 'management=yes' }),
|
||||
enterManagement: async (_request, response) => { response.writeHead(303, { location: '/_dsh_platform/auth/management' }); response.end() },
|
||||
}
|
||||
const gateway = createGatewayServer({
|
||||
trustedHosts: parseTrustedHosts({}), browserAuthentication,
|
||||
managementSocketPath: socketPath, upstreamPort,
|
||||
})
|
||||
const port = await listen(gateway)
|
||||
try {
|
||||
assert.equal((await request(port, { headers: { host: '127.0.0.1', cookie: 'dsh=yes' } })).body, 'dsh')
|
||||
assert.equal((await request(port, {
|
||||
path: '/_dsh_platform/plugin-api/v1/status', headers: { host: '127.0.0.1', cookie: 'dsh=yes' },
|
||||
})).body, 'management:/_dsh_platform/api/v1/status')
|
||||
assert.equal((await request(port, {
|
||||
path: '/_dsh_platform/api/v1/status', headers: { host: '127.0.0.1', cookie: 'dsh=yes' },
|
||||
})).status, 401)
|
||||
assert.equal((await request(port, {
|
||||
path: '/_dsh_platform/api/v1/status', headers: { host: '127.0.0.1', cookie: 'management=yes' },
|
||||
})).body, 'management:/_dsh_platform/api/v1/status')
|
||||
assert.equal((await request(port, {
|
||||
path: '/', headers: { host: '127.0.0.1', cookie: 'management=yes' },
|
||||
})).status, 401)
|
||||
} finally {
|
||||
await Promise.all([closeGatewayServer(gateway), close(management), close(upstream)])
|
||||
}
|
||||
})
|
||||
|
||||
test('terminal WebSocket requires Management rather than DSH authentication', async () => {
|
||||
const browserAuthentication = {
|
||||
...initialized,
|
||||
validateDsh: async () => ({ authenticated: true }),
|
||||
validateManagement: async () => ({ authenticated: false }),
|
||||
enterManagement: async () => {},
|
||||
}
|
||||
const gateway = createGatewayServer({ trustedHosts: parseTrustedHosts({}), browserAuthentication, upstreamPort: 1 })
|
||||
const port = await listen(gateway)
|
||||
const client = netConnect(port, '127.0.0.1')
|
||||
try {
|
||||
await new Promise((resolve, reject) => { client.once('connect', resolve); client.once('error', reject) })
|
||||
client.write([
|
||||
'GET /_dsh_platform/api/v1/terminal/sessions/123e4567-e89b-42d3-a456-426614174000/stream HTTP/1.1',
|
||||
'Host: 127.0.0.1', 'Connection: Upgrade', 'Upgrade: websocket', '', '',
|
||||
].join('\r\n'))
|
||||
const response = await new Promise((resolve, reject) => {
|
||||
client.once('data', data => resolve(data.toString('utf8')))
|
||||
client.once('error', reject)
|
||||
})
|
||||
assert.match(response, /^HTTP\/1\.1 401 Unauthorized/)
|
||||
} finally {
|
||||
client.destroy()
|
||||
await closeGatewayServer(gateway)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,486 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createServer, request as httpRequest } from 'node:http'
|
||||
import { connect as netConnect } from 'node:net'
|
||||
import test from 'node:test'
|
||||
import { DshAvailability, availabilityPage, language } from '../lib/availability.mjs'
|
||||
import { closeGatewayServer, createGatewayServer as createGatewayServerBase, PLUGIN_FAILURE_PATH, READINESS_PATH, WAIT_PATH, safeReturnPath } from '../lib/proxy.mjs'
|
||||
import { parseTrustedHosts } from '../lib/config.mjs'
|
||||
|
||||
const AUTHENTICATED_BROWSER = Object.freeze({
|
||||
status: async () => ({ state: 'initialized' }),
|
||||
validateDsh: async () => ({ authenticated: true }),
|
||||
validateManagement: async () => ({ authenticated: true }),
|
||||
enterManagement: async () => { throw new Error('unexpected Management login') },
|
||||
handle: async () => false,
|
||||
})
|
||||
|
||||
const UNAUTHENTICATED_BROWSER = Object.freeze({
|
||||
status: async () => ({ state: 'initialized' }),
|
||||
validateDsh: async () => ({ authenticated: false }),
|
||||
validateManagement: async () => ({ authenticated: false }),
|
||||
enterManagement: async () => { throw new Error('unexpected Management login') },
|
||||
handle: async () => false,
|
||||
})
|
||||
|
||||
function createGatewayServer(options) {
|
||||
return createGatewayServerBase({
|
||||
...options,
|
||||
browserAuthentication: options.browserAuthentication ?? AUTHENTICATED_BROWSER,
|
||||
})
|
||||
}
|
||||
|
||||
const ISOLATED_UNAUTHENTICATED_BROWSER = Object.freeze({
|
||||
...UNAUTHENTICATED_BROWSER,
|
||||
status: async () => ({ state: 'initialized', account: {
|
||||
managementAccess: {
|
||||
mode: 'isolated',
|
||||
isolatedEntry: { kind: 'public', managementPublicOrigin: 'https://management.example' },
|
||||
},
|
||||
} }),
|
||||
})
|
||||
|
||||
async function listen(server) {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
return server.address().port
|
||||
}
|
||||
|
||||
function request(port, path = '/', { method = 'GET', accept = 'text/html', headers = {} } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const outgoing = httpRequest({
|
||||
hostname: '127.0.0.1', port, path, method,
|
||||
headers: { host: 'dsh.example', accept, ...headers },
|
||||
}, response => {
|
||||
const chunks = []
|
||||
response.on('data', chunk => chunks.push(chunk))
|
||||
response.on('end', () => resolve({
|
||||
status: response.statusCode,
|
||||
headers: response.headers,
|
||||
body: Buffer.concat(chunks).toString('utf8'),
|
||||
}))
|
||||
})
|
||||
outgoing.once('error', reject)
|
||||
outgoing.end()
|
||||
})
|
||||
}
|
||||
|
||||
async function unavailableGateway({ platform = {}, availability = new DshAvailability(), probe = async () => false, report, browserAuthentication } = {}) {
|
||||
const placeholder = createServer()
|
||||
const unavailablePort = await listen(placeholder)
|
||||
await new Promise(resolve => placeholder.close(resolve))
|
||||
const gateway = createGatewayServer({
|
||||
trustedHosts: parseTrustedHosts({ DSH_TRUSTED_HOSTS: 'dsh.example' }),
|
||||
upstreamPort: unavailablePort,
|
||||
platformStatus: async () => platform,
|
||||
availability,
|
||||
probe,
|
||||
browserAuthentication,
|
||||
report,
|
||||
probeIntervalMs: 60_000,
|
||||
})
|
||||
return { gateway, port: await listen(gateway) }
|
||||
}
|
||||
|
||||
test('authenticated DSH traffic waits for authoritative Bootstrap publication', async t => {
|
||||
let upstreamRequests = 0
|
||||
const upstream = createServer((_request, response) => {
|
||||
upstreamRequests += 1
|
||||
response.writeHead(200, { 'content-type': 'text/plain' })
|
||||
response.end('published')
|
||||
})
|
||||
const upstreamPort = await listen(upstream)
|
||||
t.after(() => upstream.close())
|
||||
let published = false
|
||||
const gateway = createGatewayServer({
|
||||
trustedHosts: parseTrustedHosts({ DSH_TRUSTED_HOSTS: 'dsh.example' }),
|
||||
upstreamPort,
|
||||
platformStatus: async () => ({ dshLifecycle: { state: 'running' } }),
|
||||
probe: async () => true,
|
||||
dshUpstreamAuthentication: {
|
||||
cookie: async () => null,
|
||||
publicReady: async () => published,
|
||||
},
|
||||
probeIntervalMs: 60_000,
|
||||
})
|
||||
const port = await listen(gateway)
|
||||
try {
|
||||
const socket = netConnect(port, '127.0.0.1')
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.once('connect', resolve)
|
||||
socket.once('error', reject)
|
||||
})
|
||||
socket.write('GET /events HTTP/1.1\r\nHost: dsh.example\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n')
|
||||
const upgrade = await new Promise((resolve, reject) => {
|
||||
socket.once('data', data => resolve(data.toString('utf8')))
|
||||
socket.once('error', reject)
|
||||
})
|
||||
assert.match(upgrade, /^HTTP\/1\.1 503 Service Unavailable/)
|
||||
socket.destroy()
|
||||
const waiting = await request(port)
|
||||
assert.equal(waiting.status, 200)
|
||||
assert.match(waiting.body, /DeepSeek Harness is starting/)
|
||||
assert.equal(upstreamRequests, 0)
|
||||
assert.deepEqual(JSON.parse((await request(port, READINESS_PATH, { accept: 'application/json' })).body).ready, false)
|
||||
const dedicatedWaiting = await request(port, `${WAIT_PATH}?return=%2Fsessions%2Fcurrent`)
|
||||
assert.equal(dedicatedWaiting.status, 200)
|
||||
assert.match(dedicatedWaiting.body, /DeepSeek Harness is starting/)
|
||||
assert.match(dedicatedWaiting.body, /\/sessions\/current/)
|
||||
published = true
|
||||
const ready = await request(port)
|
||||
assert.equal(ready.status, 200)
|
||||
assert.equal(ready.body, 'published')
|
||||
assert.equal(upstreamRequests, 1)
|
||||
assert.equal(JSON.parse((await request(port, READINESS_PATH, { accept: 'application/json' })).body).ready, true)
|
||||
} finally {
|
||||
await closeGatewayServer(gateway)
|
||||
}
|
||||
})
|
||||
|
||||
test('an intentional stopped state does not report an upstream proxy failure', async () => {
|
||||
const reports = []
|
||||
const context = await unavailableGateway({
|
||||
platform: { dshLifecycle: { state: 'stopped' } },
|
||||
report: (message, fields) => { reports.push({ message, fields }) },
|
||||
})
|
||||
try {
|
||||
const response = await request(context.port)
|
||||
assert.equal(response.status, 200)
|
||||
assert.match(response.body, /DeepSeek Harness is stopped/)
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
assert.equal(reports.some(entry => entry.message === 'gateway.upstream.failed'), false)
|
||||
} finally {
|
||||
await closeGatewayServer(context.gateway)
|
||||
}
|
||||
})
|
||||
|
||||
test('an unauthenticated top-level navigation sees classified DSH recovery before the login page', async () => {
|
||||
const gateway = createGatewayServer({
|
||||
trustedHosts: parseTrustedHosts({ DSH_TRUSTED_HOSTS: 'dsh.example' }),
|
||||
upstreamPort: 1,
|
||||
platformStatus: async () => ({ dshLifecycle: { state: 'failed', error: 'private runtime detail' } }),
|
||||
probe: async () => false,
|
||||
browserAuthentication: UNAUTHENTICATED_BROWSER,
|
||||
probeIntervalMs: 60_000,
|
||||
})
|
||||
const port = await listen(gateway)
|
||||
try {
|
||||
const page = await request(port)
|
||||
assert.equal(page.status, 200)
|
||||
assert.match(page.body, /DeepSeek Harness could not start/)
|
||||
assert.match(page.body, /href="\/_dsh_platform\/console\/"/)
|
||||
assert.doesNotMatch(page.body, /private runtime detail|autocomplete="current-password"/)
|
||||
assert.equal((await request(port, '/api/sessions', { accept: 'application/json' })).status, 503)
|
||||
} finally {
|
||||
await closeGatewayServer(gateway)
|
||||
}
|
||||
})
|
||||
|
||||
test('official-style holding page is self-contained and replaces the spinner with platform status', () => {
|
||||
const page = availabilityPage('switching', { 'accept-language': 'zh-CN' })
|
||||
assert.match(page, /HARNESS/)
|
||||
assert.match(page, /正在切换 DeepSeek Harness 运行版本/)
|
||||
assert.match(page, /href="\/_dsh_platform\/console\/"/)
|
||||
assert.match(page, /打开 DSH 管理中心进行检查和恢复/)
|
||||
assert.match(page, new RegExp(READINESS_PATH))
|
||||
assert.match(page, /prefers-color-scheme:light/)
|
||||
assert.match(page, /name="viewport"/)
|
||||
assert.match(page, /html\{height:100%\}body\{min-height:100%;margin:0;display:grid;place-items:center;/)
|
||||
assert.doesNotMatch(page, /100(?:d|s|l)?vh/)
|
||||
assert.match(page, /max-width:520px/)
|
||||
assert.match(page, /management\.href=value\.managementHref/)
|
||||
assert.doesNotMatch(page, /spinner|Loading plugins/)
|
||||
assert.doesNotMatch(page, /letter-spacing:-/)
|
||||
})
|
||||
|
||||
test('persistent plugin failure page is terminal, localized, and links to Management', async () => {
|
||||
const page = availabilityPage('plugin-failed', { 'accept-language': 'zh-CN' }, { poll: false, allowRefresh: true })
|
||||
assert.match(page, /DeepSeek Harness 插件持续加载失败/)
|
||||
assert.match(page, /打开 DSH 管理中心进行检查和恢复/)
|
||||
assert.match(page, /刷新页面/)
|
||||
assert.doesNotMatch(page, /setTimeout\(check/)
|
||||
assert.doesNotMatch(availabilityPage('failed', {}, { poll: false }), /Reload page/)
|
||||
|
||||
const context = await unavailableGateway()
|
||||
try {
|
||||
const response = await request(context.port, PLUGIN_FAILURE_PATH, {
|
||||
headers: { 'accept-language': 'zh-CN' },
|
||||
})
|
||||
assert.equal(response.status, 200)
|
||||
assert.match(response.body, /DeepSeek Harness 插件持续加载失败/)
|
||||
assert.equal((await request(context.port, PLUGIN_FAILURE_PATH, { method: 'POST' })).status, 405)
|
||||
} finally {
|
||||
await closeGatewayServer(context.gateway)
|
||||
}
|
||||
})
|
||||
|
||||
test('Gateway temporary DSH pages link to the configured isolated Management entry', async () => {
|
||||
const context = await unavailableGateway({ browserAuthentication: ISOLATED_UNAUTHENTICATED_BROWSER })
|
||||
try {
|
||||
const response = await request(context.port, PLUGIN_FAILURE_PATH)
|
||||
assert.equal(response.status, 200)
|
||||
assert.match(response.body, /href="https:\/\/management\.example\/"/)
|
||||
assert.doesNotMatch(response.body, /href="\/_dsh_platform\/console\/"/)
|
||||
} finally {
|
||||
await closeGatewayServer(context.gateway)
|
||||
}
|
||||
})
|
||||
|
||||
test('Gateway readiness publishes the current Management entry for an already-open temporary page', async () => {
|
||||
let access = { state: 'initialized', account: { managementAccess: { mode: 'compat' } } }
|
||||
const browserAuthentication = {
|
||||
...UNAUTHENTICATED_BROWSER,
|
||||
status: async () => access,
|
||||
}
|
||||
const context = await unavailableGateway({ browserAuthentication })
|
||||
try {
|
||||
const compatible = JSON.parse((await request(context.port, READINESS_PATH)).body)
|
||||
assert.equal(compatible.managementHref, '/_dsh_platform/console/')
|
||||
|
||||
access = await ISOLATED_UNAUTHENTICATED_BROWSER.status()
|
||||
const isolated = JSON.parse((await request(context.port, READINESS_PATH)).body)
|
||||
assert.equal(isolated.managementHref, 'https://management.example/')
|
||||
} finally {
|
||||
await closeGatewayServer(context.gateway)
|
||||
}
|
||||
})
|
||||
|
||||
test('holding pages prefer the last DSH locale cookie over browser language', () => {
|
||||
assert.equal(language({ cookie: 'theme=dark; dsh_locale=en', 'accept-language': 'zh-CN' }), 'en')
|
||||
assert.equal(language({ cookie: 'dsh_locale=zh', 'accept-language': 'en-US' }), 'zh')
|
||||
assert.equal(language({ cookie: 'dsh_locale=fr', 'accept-language': 'en-US' }), 'en')
|
||||
assert.equal(language({ 'accept-language': 'fr-FR, zh-CN;q=0.8, en;q=0.5' }), 'zh')
|
||||
assert.match(availabilityPage('recovering', { cookie: 'dsh_locale=zh', 'accept-language': 'en-US' }), /意外停止/)
|
||||
assert.match(availabilityPage('recovering', { cookie: 'dsh_locale=en', 'accept-language': 'zh-CN' }), /Open DSH Management Console for diagnostics and recovery/)
|
||||
})
|
||||
|
||||
test('every classified outage page links to the standalone Platform Management console', () => {
|
||||
for (const state of ['starting', 'stopping', 'stopped', 'restarting', 'switching', 'runtime-recovering', 'recovering', 'failed', 'unavailable']) {
|
||||
const page = availabilityPage(state, { 'accept-language': 'en-US' })
|
||||
assert.match(page, /href="\/_dsh_platform\/console\/"/)
|
||||
}
|
||||
})
|
||||
|
||||
test('intentional DSH restarts have explicit holding and failure states', () => {
|
||||
const availability = new DshAvailability()
|
||||
assert.equal(availability.classify({ operation: 'restarting' }), 'restarting')
|
||||
assert.equal(availability.classify({ operation: 'restart-failed' }), 'failed')
|
||||
assert.match(availabilityPage('restarting', { 'accept-language': 'zh-CN' }), /正在重新启动/)
|
||||
assert.equal(availability.classify({ dshLifecycle: { state: 'stopping' } }), 'stopping')
|
||||
assert.equal(availability.classify({ dshLifecycle: { state: 'stopped' } }), 'stopped')
|
||||
assert.equal(availability.classify({ dshLifecycle: { state: 'recovering', attempt: 2 } }), 'recovering')
|
||||
assert.match(availabilityPage('recovering', { 'accept-language': 'zh-CN' }, {
|
||||
lifecycle: { attempt: 2, maxAttempts: 3 },
|
||||
}), /第 2 \/ 3 次/)
|
||||
})
|
||||
|
||||
test('holding-page return paths stay same-origin and reject ambiguous inputs', () => {
|
||||
assert.equal(safeReturnPath('/sessions/current?view=chat#latest'), '/sessions/current?view=chat#latest')
|
||||
for (const value of [null, '', '//example.com/path', '/\\example.com', 'https://example.com/', '/bad\npath']) {
|
||||
assert.equal(safeReturnPath(value), '/')
|
||||
}
|
||||
const page = availabilityPage('restarting', {}, { returnPath: '/</script><script>alert(1)</script>' })
|
||||
assert.doesNotMatch(page, /const returnPath="\/<\/script>/)
|
||||
assert.match(page, /\\u003c\/script>/)
|
||||
})
|
||||
|
||||
test('cold start serves a holding page while API and WebSocket-shaped HTTP requests receive 503', async () => {
|
||||
const { gateway, port } = await unavailableGateway()
|
||||
try {
|
||||
const page = await request(port)
|
||||
assert.equal(page.status, 200)
|
||||
assert.match(page.body, /DeepSeek Harness is starting/)
|
||||
assert.equal(page.headers['cache-control'], 'no-store')
|
||||
assert.equal((await request(port, '/api/sessions', { accept: 'application/json' })).status, 503)
|
||||
assert.equal((await request(port, '/', { method: 'POST', accept: 'text/html' })).status, 503)
|
||||
} finally {
|
||||
await closeGatewayServer(gateway)
|
||||
}
|
||||
})
|
||||
|
||||
test('an unresponsive management service does not block the cold-start holding page', async () => {
|
||||
const placeholder = createServer()
|
||||
const unavailablePort = await listen(placeholder)
|
||||
await new Promise(resolve => placeholder.close(resolve))
|
||||
const gateway = createGatewayServer({
|
||||
trustedHosts: parseTrustedHosts({ DSH_TRUSTED_HOSTS: 'dsh.example' }),
|
||||
upstreamPort: unavailablePort,
|
||||
platformStatus: () => new Promise(() => {}),
|
||||
probe: async () => false,
|
||||
probeIntervalMs: 60_000,
|
||||
})
|
||||
const port = await listen(gateway)
|
||||
try {
|
||||
const startedAt = Date.now()
|
||||
const page = await request(port)
|
||||
assert.equal(page.status, 200)
|
||||
assert.match(page.body, /DeepSeek Harness is starting/)
|
||||
assert.ok(Date.now() - startedAt < 1_000)
|
||||
} finally {
|
||||
await closeGatewayServer(gateway)
|
||||
}
|
||||
})
|
||||
|
||||
test('cold-start management status failures remain diagnostic warnings', async () => {
|
||||
const reported = []
|
||||
const gateway = createGatewayServer({
|
||||
trustedHosts: parseTrustedHosts({ DSH_TRUSTED_HOSTS: 'dsh.example' }),
|
||||
upstreamPort: 1,
|
||||
platformStatus: async () => { throw Object.assign(new Error('socket is not ready'), { code: 'ENOENT' }) },
|
||||
probe: async () => false,
|
||||
probeIntervalMs: 60_000,
|
||||
report: async (message, fields) => reported.push({ message, fields }),
|
||||
})
|
||||
const port = await listen(gateway)
|
||||
try {
|
||||
assert.equal((await request(port)).status, 200)
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
assert.equal(reported.length, 1)
|
||||
assert.equal(reported[0].message, 'gateway.platform-status.failed')
|
||||
assert.equal(reported[0].fields.level, 'warning')
|
||||
assert.equal(reported[0].fields.error.code, 'ENOENT')
|
||||
} finally {
|
||||
await closeGatewayServer(gateway)
|
||||
}
|
||||
})
|
||||
|
||||
test('platform switching, recovery, and startup failure select distinct page messages', async () => {
|
||||
for (const [platform, expected] of [
|
||||
[{ operation: 'switching' }, /Switching the DeepSeek Harness runtime/],
|
||||
[{ operation: 'recovering' }, /Restoring the DeepSeek Harness runtime/],
|
||||
[{ recoveryMode: 'candidate failed' }, /DeepSeek Harness could not start/],
|
||||
]) {
|
||||
const { gateway, port } = await unavailableGateway({ platform })
|
||||
try {
|
||||
const page = await request(port)
|
||||
assert.equal(page.status, 200)
|
||||
assert.match(page.body, expected)
|
||||
assert.equal((await request(port, '/api/status', { accept: 'application/json' })).status, 503)
|
||||
} finally {
|
||||
await closeGatewayServer(gateway)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('dedicated holding route preserves a safe return path and redirects when ready', async () => {
|
||||
let ready = false
|
||||
const platform = { dshLifecycle: { state: 'stopping' } }
|
||||
const context = await unavailableGateway({
|
||||
platform,
|
||||
probe: async () => ready,
|
||||
})
|
||||
try {
|
||||
const target = '/sessions/current?view=chat#latest'
|
||||
const waiting = await request(context.port, `${WAIT_PATH}?return=${encodeURIComponent(target)}`)
|
||||
assert.equal(waiting.status, 200)
|
||||
assert.match(waiting.body, /DeepSeek Harness is stopping/)
|
||||
assert.match(waiting.body, /location\.replace\(returnPath\)/)
|
||||
assert.match(waiting.body, /\/sessions\/current\?view=chat#latest/)
|
||||
|
||||
const invalid = await request(context.port, `${WAIT_PATH}?return=${encodeURIComponent('//example.com/')}`)
|
||||
assert.equal(invalid.status, 200)
|
||||
assert.match(invalid.body, /const returnPath="\/"/)
|
||||
|
||||
ready = true
|
||||
const premature = await request(context.port, `${WAIT_PATH}?return=${encodeURIComponent(target)}`)
|
||||
assert.equal(premature.status, 200)
|
||||
assert.match(premature.body, /DeepSeek Harness is stopping/)
|
||||
const prematureReadiness = await request(context.port, READINESS_PATH)
|
||||
assert.equal(prematureReadiness.status, 503)
|
||||
assert.equal(JSON.parse(prematureReadiness.body).state, 'stopping')
|
||||
|
||||
platform.dshLifecycle.state = 'running'
|
||||
const redirected = await request(context.port, `${WAIT_PATH}?return=${encodeURIComponent(target)}`)
|
||||
assert.equal(redirected.status, 302)
|
||||
assert.equal(redirected.headers.location, target)
|
||||
assert.deepEqual(JSON.parse((await request(context.port, READINESS_PATH)).body), {
|
||||
ready: true, state: 'ready', pluginRecoveryEligible: false,
|
||||
managementHref: '/_dsh_platform/console/',
|
||||
})
|
||||
assert.equal((await request(context.port, WAIT_PATH, { method: 'POST' })).status, 405)
|
||||
} finally {
|
||||
await closeGatewayServer(context.gateway)
|
||||
}
|
||||
})
|
||||
|
||||
test('WebSocket receives 503 during a classified DSH outage', async () => {
|
||||
const reports = []
|
||||
const { gateway, port } = await unavailableGateway({
|
||||
platform: { operation: 'switching' },
|
||||
report: (message, fields) => reports.push({ message, fields }),
|
||||
})
|
||||
const socket = netConnect(port, '127.0.0.1')
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.once('connect', resolve)
|
||||
socket.once('error', reject)
|
||||
})
|
||||
socket.write('GET /events HTTP/1.1\r\nHost: dsh.example\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n')
|
||||
const response = await new Promise((resolve, reject) => {
|
||||
socket.once('data', data => resolve(data.toString('utf8')))
|
||||
socket.once('error', reject)
|
||||
})
|
||||
assert.match(response, /^HTTP\/1\.1 503 Service Unavailable/)
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
const failure = reports.find(entry => entry.message === 'gateway.websocket.failed')
|
||||
assert.equal(failure?.fields.level, 'warning')
|
||||
assert.equal(failure?.fields.upstream, 'dsh')
|
||||
} finally {
|
||||
socket.destroy()
|
||||
await closeGatewayServer(gateway)
|
||||
}
|
||||
})
|
||||
|
||||
test('one unexpected local failure remains 502 until repeated probes confirm unavailability', async () => {
|
||||
let now = 0
|
||||
const availability = new DshAvailability({ now: () => now, failures: 3, failureWindowMs: 1_500 })
|
||||
availability.observe(true)
|
||||
const context = await unavailableGateway({ availability })
|
||||
try {
|
||||
assert.equal((await request(context.port)).status, 502)
|
||||
availability.observe(false)
|
||||
now = 1_600
|
||||
availability.observe(false)
|
||||
const recovered = await request(context.port)
|
||||
assert.equal(recovered.status, 200)
|
||||
assert.match(recovered.body, /temporarily unavailable/)
|
||||
} finally {
|
||||
await closeGatewayServer(context.gateway)
|
||||
}
|
||||
})
|
||||
|
||||
test('a Bootstrap candidate handoff serves the switching page instead of a bare 502', async () => {
|
||||
const context = await unavailableGateway({ platform: { update: { status: 'building-candidate' } } })
|
||||
try {
|
||||
const response = await request(context.port)
|
||||
assert.equal(response.status, 200)
|
||||
assert.match(response.body, /Switching the DeepSeek Harness runtime/)
|
||||
} finally {
|
||||
await closeGatewayServer(context.gateway)
|
||||
}
|
||||
})
|
||||
|
||||
test('readiness immediately reports ready and clears confirmed failure state', async () => {
|
||||
let ready = false
|
||||
let now = 0
|
||||
const availability = new DshAvailability({ now: () => now, failures: 1, failureWindowMs: 0 })
|
||||
availability.observe(true)
|
||||
availability.observe(false)
|
||||
const context = await unavailableGateway({ availability, probe: async () => ready })
|
||||
try {
|
||||
const unavailable = await request(context.port, READINESS_PATH, { accept: 'application/json' })
|
||||
assert.equal(unavailable.status, 503)
|
||||
assert.equal(JSON.parse(unavailable.body).state, 'unavailable')
|
||||
ready = true
|
||||
const healthy = await request(context.port, READINESS_PATH, { accept: 'application/json' })
|
||||
assert.equal(healthy.status, 200)
|
||||
assert.deepEqual(JSON.parse(healthy.body), {
|
||||
ready: true, state: 'ready', pluginRecoveryEligible: false,
|
||||
managementHref: '/_dsh_platform/console/',
|
||||
})
|
||||
assert.equal(availability.classify({}), 'unknown')
|
||||
} finally {
|
||||
await closeGatewayServer(context.gateway)
|
||||
}
|
||||
})
|
||||
File diff suppressed because it is too large.
Load diff
+40
@@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { patchBrowserLoopback } from '../../../../environment/resources/patches/browser-loopback.mjs'
|
||||
|
||||
const BEFORE = 'isLoopback: pageLocation === void 0 || isLoopbackHostname(pageLocation.hostname),'
|
||||
const BEFORE_WITH_TRANSPORT = 'isLoopback: transport?.ownsHost === true || pageLocation === void 0 || isLoopbackHostname(pageLocation.hostname),'
|
||||
|
||||
test('browser loopback patch changes exactly one connection classification', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-browser-loopback-patch-'))
|
||||
const target = join(directory, 'client.js')
|
||||
try {
|
||||
await writeFile(target, `before;${BEFORE}after;`)
|
||||
patchBrowserLoopback(target)
|
||||
assert.equal(await readFile(target, 'utf8'), 'before;isLoopback: true,after;')
|
||||
assert.throws(() => patchBrowserLoopback(target), /found 0/)
|
||||
|
||||
await writeFile(target, `${BEFORE}${BEFORE}`)
|
||||
assert.throws(() => patchBrowserLoopback(target), /found 2/)
|
||||
} finally {
|
||||
await rm(directory, { recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('browser loopback patch accepts the transport-owned Host classification', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-browser-loopback-patch-'))
|
||||
const target = join(directory, 'client.js')
|
||||
try {
|
||||
await writeFile(target, `before;${BEFORE_WITH_TRANSPORT}after;`)
|
||||
patchBrowserLoopback(target)
|
||||
assert.equal(await readFile(target, 'utf8'), 'before;isLoopback: true,after;')
|
||||
|
||||
await writeFile(target, `${BEFORE}${BEFORE_WITH_TRANSPORT}`)
|
||||
assert.throws(() => patchBrowserLoopback(target), /found 2/)
|
||||
} finally {
|
||||
await rm(directory, { recursive: true })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import {
|
||||
loadConfig,
|
||||
parseBoolean,
|
||||
parseTrustedAuthority,
|
||||
parseTrustedHosts,
|
||||
} from '../lib/config.mjs'
|
||||
import { UsageError } from '../lib/errors.mjs'
|
||||
|
||||
test('parseBoolean accepts only explicit Compose booleans', () => {
|
||||
assert.equal(parseBoolean('FLAG', undefined, true), true)
|
||||
assert.equal(parseBoolean('FLAG', 'false', true), false)
|
||||
assert.throws(() => parseBoolean('FLAG', '1', true), UsageError)
|
||||
})
|
||||
|
||||
test('parseTrustedAuthority accepts canonical host authorities', () => {
|
||||
assert.deepEqual(parseTrustedAuthority('Example.COM'), {
|
||||
hostname: 'example.com',
|
||||
authority: 'example.com',
|
||||
anyPort: true,
|
||||
matchAuthority: 'example.com',
|
||||
})
|
||||
assert.deepEqual(parseTrustedAuthority('example.com:8443'), {
|
||||
hostname: 'example.com',
|
||||
authority: 'example.com:8443',
|
||||
anyPort: false,
|
||||
matchAuthority: 'example.com:8443',
|
||||
})
|
||||
assert.deepEqual(parseTrustedAuthority('[fd00::1]:3080'), {
|
||||
hostname: '[fd00::1]',
|
||||
authority: '[fd00::1]:3080',
|
||||
anyPort: false,
|
||||
matchAuthority: '[fd00::1]:3080',
|
||||
})
|
||||
})
|
||||
|
||||
test('parseTrustedAuthority rejects URL-like and noncanonical entries', () => {
|
||||
for (const value of [
|
||||
'https://example.com',
|
||||
'example.com/path',
|
||||
'user@example.com',
|
||||
'example.com:',
|
||||
'*.example.com',
|
||||
' example.com',
|
||||
]) {
|
||||
assert.throws(() => parseTrustedAuthority(value), UsageError, value)
|
||||
}
|
||||
})
|
||||
|
||||
test('parseTrustedHosts supports empty, lists, deduplication, and wildcard', () => {
|
||||
assert.deepEqual(parseTrustedHosts({}), { wildcard: false, authorities: [] })
|
||||
const list = parseTrustedHosts({ DSH_TRUSTED_HOSTS: 'a.example, b.example:8443,a.example' })
|
||||
assert.equal(list.wildcard, false)
|
||||
assert.equal(list.authorities.length, 2)
|
||||
assert.deepEqual(parseTrustedHosts({ DSH_TRUSTED_HOSTS: '*' }), {
|
||||
wildcard: true,
|
||||
authorities: [],
|
||||
})
|
||||
})
|
||||
|
||||
test('parseTrustedHosts rejects malformed lists', () => {
|
||||
assert.throws(() => parseTrustedHosts({ DSH_TRUSTED_HOSTS: 'a.example,,b.example' }), UsageError)
|
||||
assert.throws(() => parseTrustedHosts({ DSH_TRUSTED_HOSTS: '*,a.example' }), UsageError)
|
||||
})
|
||||
|
||||
test('loadConfig owns only Gateway settings', async () => {
|
||||
const config = await loadConfig({
|
||||
DSH_DEFAULT_WORKSPACE: 'not-a-gateway-setting',
|
||||
DSH_TELEMETRY_DISABLED: 'not-a-gateway-setting',
|
||||
DSH_PROXY_USERNAME: 'unused',
|
||||
DSH_PROXY_PASSWORD: '',
|
||||
})
|
||||
assert.equal(Object.hasOwn(config, 'password'), false)
|
||||
assert.equal(Object.hasOwn(config, 'username'), false)
|
||||
assert.equal(Object.hasOwn(config, 'platformPassword'), false)
|
||||
assert.equal(Object.hasOwn(config, 'workspace'), false)
|
||||
assert.equal(Object.hasOwn(config, 'telemetryDisabled'), false)
|
||||
assert.equal(config.managementPort, 3081)
|
||||
assert.doesNotReject(() => loadConfig({
|
||||
DSH_PROXY_USERNAME: 'invalid:name',
|
||||
DSH_PROXY_PASSWORD: 'secret',
|
||||
DSH_PLATFORM_PASSWORD: 'legacy-secret',
|
||||
}))
|
||||
})
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { patchDirectoryPicker } from '../../../../environment/resources/patches/directory-picker.mjs'
|
||||
|
||||
test('directory picker patch changes exactly one default without touching explicit paths', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dsh-directory-patch-'))
|
||||
const target = join(directory, 'index.js')
|
||||
try {
|
||||
await writeFile(target, 'before;const target = resolve(path ?? home);after;')
|
||||
patchDirectoryPicker(target)
|
||||
assert.equal(
|
||||
await readFile(target, 'utf8'),
|
||||
'before;const target = resolve(path ?? process.env.DSH_DEFAULT_WORKSPACE ?? home);after;',
|
||||
)
|
||||
assert.throws(() => patchDirectoryPicker(target), /found 0/)
|
||||
|
||||
await writeFile(target, 'const target = resolve(path ?? home);const target = resolve(path ?? home);')
|
||||
assert.throws(() => patchDirectoryPicker(target), /found 2/)
|
||||
} finally {
|
||||
await rm(directory, { recursive: true })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { chmod, mkdtemp } from 'node:fs/promises'
|
||||
import { createServer } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { DshUpstreamAuthentication } from '../lib/dsh-upstream-auth.mjs'
|
||||
|
||||
async function listen(server, target, host) {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(target, host, resolve)
|
||||
})
|
||||
}
|
||||
|
||||
test('exchanges and caches the private DSH launch URL', async t => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-upstream-auth-'))
|
||||
const socketPath = join(root, 'lifecycle.sock')
|
||||
let exchanges = 0
|
||||
const dsh = createServer((request, response) => {
|
||||
exchanges += 1
|
||||
assert.equal(request.url, '/?token=fixture-token')
|
||||
response.writeHead(303, {
|
||||
location: '/',
|
||||
'set-cookie': 'dsh-auth-fixture=signed; Path=/; HttpOnly; SameSite=Strict',
|
||||
})
|
||||
response.end()
|
||||
})
|
||||
await listen(dsh, 0, '127.0.0.1')
|
||||
const port = dsh.address().port
|
||||
let generation = 'generation-one'
|
||||
let publicReady = false
|
||||
const lifecycle = createServer((_request, response) => {
|
||||
response.writeHead(200, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({
|
||||
generation,
|
||||
publicReady,
|
||||
ready: true,
|
||||
readyUrl: `http://127.0.0.1:${String(port)}/?token=fixture-token`,
|
||||
}))
|
||||
})
|
||||
await listen(lifecycle, socketPath)
|
||||
await chmod(socketPath, 0o600)
|
||||
t.after(() => { dsh.close(); lifecycle.close() })
|
||||
const authentication = new DshUpstreamAuthentication({ socketPath, port })
|
||||
assert.equal(await authentication.publicReady(), false)
|
||||
assert.equal(await authentication.cookie(), null)
|
||||
assert.equal(exchanges, 0)
|
||||
publicReady = true
|
||||
assert.equal(await authentication.publicReady(), true)
|
||||
assert.equal(await authentication.cookie(), 'dsh-auth-fixture=signed')
|
||||
assert.equal(await authentication.cookie(), 'dsh-auth-fixture=signed')
|
||||
assert.equal(exchanges, 1)
|
||||
generation = 'generation-two'
|
||||
assert.equal(await authentication.cookie(), 'dsh-auth-fixture=signed')
|
||||
assert.equal(exchanges, 2)
|
||||
publicReady = false
|
||||
assert.equal(await authentication.cookie(), null)
|
||||
publicReady = true
|
||||
assert.equal(await authentication.publicReady(), true)
|
||||
assert.equal(await authentication.cookie(), 'dsh-auth-fixture=signed')
|
||||
assert.equal(exchanges, 3)
|
||||
})
|
||||
|
||||
test('keeps probing legacy DSH readiness without inventing a cookie', async t => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-upstream-auth-legacy-'))
|
||||
const socketPath = join(root, 'lifecycle.sock')
|
||||
let now = 0
|
||||
let probes = 0
|
||||
const lifecycle = createServer((_request, response) => {
|
||||
probes += 1
|
||||
response.writeHead(200, { 'content-type': 'application/json' })
|
||||
response.end(JSON.stringify({ ready: true, readyUrl: null }))
|
||||
})
|
||||
await listen(lifecycle, socketPath)
|
||||
t.after(() => lifecycle.close())
|
||||
const authentication = new DshUpstreamAuthentication({ socketPath, now: () => now })
|
||||
assert.equal(await authentication.cookie(), null)
|
||||
assert.equal(await authentication.cookie(), null)
|
||||
assert.equal(probes, 1)
|
||||
now = 250
|
||||
assert.equal(await authentication.cookie(), null)
|
||||
assert.equal(probes, 2)
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import test from 'node:test'
|
||||
import { runGateway } from '../lib/lifecycle.mjs'
|
||||
|
||||
class FakeServer extends EventEmitter {
|
||||
listening = false
|
||||
|
||||
listen(port, host) {
|
||||
this.listening = true
|
||||
this.bound = { port, host }
|
||||
queueMicrotask(() => this.emit('listening'))
|
||||
}
|
||||
|
||||
close(callback) {
|
||||
this.listening = false
|
||||
queueMicrotask(() => callback())
|
||||
}
|
||||
|
||||
closeAllConnections() {}
|
||||
}
|
||||
|
||||
const config = Object.freeze({
|
||||
polyfill: true,
|
||||
trustedHosts: Object.freeze({ wildcard: false, authorities: Object.freeze([]) }),
|
||||
})
|
||||
|
||||
test('gateway owns only its listener and binds browser authentication to Access Manager', async () => {
|
||||
const signalSource = new EventEmitter()
|
||||
const server = new FakeServer()
|
||||
let options
|
||||
const reports = []
|
||||
const running = runGateway(config, {
|
||||
externalHost: '127.0.0.1',
|
||||
externalPort: 8080,
|
||||
managementSocketPath: '/run/platform.sock',
|
||||
maintenanceSocketPath: '/run/maintenance.sock',
|
||||
accessClient: { request: async () => ({ state: 'recovery-required' }) },
|
||||
signalSource,
|
||||
report: (message, fields) => { reports.push({ message, fields }) },
|
||||
gatewayFactory: value => { options = value; return server },
|
||||
})
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
assert.deepEqual(server.bound, { host: '127.0.0.1', port: 8080 })
|
||||
assert.equal(options.managementSocketPath, '/run/platform.sock')
|
||||
assert.equal(options.maintenanceSocketPath, '/run/maintenance.sock')
|
||||
assert.equal(typeof options.browserAuthentication.handle, 'function')
|
||||
assert.equal(typeof options.platformStatus, 'function')
|
||||
signalSource.emit('SIGTERM')
|
||||
assert.equal(await running, 0)
|
||||
assert.equal(server.listening, false)
|
||||
assert.deepEqual(reports.map(entry => entry.message), [
|
||||
'gateway.starting', 'gateway.ready', 'gateway.stopping', 'gateway.stopped',
|
||||
])
|
||||
assert.equal(reports[2].fields.signal, 'SIGTERM')
|
||||
})
|
||||
|
||||
test('gateway propagates listener failures without creating a DSH process', async () => {
|
||||
const server = new FakeServer()
|
||||
const reports = []
|
||||
server.listen = () => queueMicrotask(() => server.emit('error', new Error('bind failed')))
|
||||
await assert.rejects(runGateway(config, {
|
||||
gatewayFactory: () => server,
|
||||
accessClient: { request: async () => ({ state: 'recovery-required' }) },
|
||||
signalSource: new EventEmitter(),
|
||||
report: (message, fields) => { reports.push({ message, fields }) },
|
||||
}), /bind failed/)
|
||||
assert.equal(reports.some(entry => entry.message === 'gateway.fatal'
|
||||
&& entry.fields.error.message === 'bind failed'), true)
|
||||
})
|
||||
|
||||
test('gateway can expose an isolated Management listener from the same process', async () => {
|
||||
const signalSource = new EventEmitter()
|
||||
const servers = []
|
||||
const running = runGateway(config, {
|
||||
externalHost: '127.0.0.1',
|
||||
externalPort: 8080,
|
||||
managementPort: 8081,
|
||||
managementHost: '127.0.0.1',
|
||||
gatewayFactory: options => {
|
||||
const server = new FakeServer()
|
||||
server.options = options
|
||||
servers.push(server)
|
||||
return server
|
||||
},
|
||||
accessClient: { request: async () => ({ state: 'recovery-required' }) },
|
||||
signalSource,
|
||||
})
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
assert.equal(servers.length, 2)
|
||||
assert.deepEqual(servers[0].bound, { host: '127.0.0.1', port: 8080 })
|
||||
assert.deepEqual(servers[1].bound, { host: '127.0.0.1', port: 8081 })
|
||||
assert.equal(servers[0].options.surface, 'compat')
|
||||
assert.equal(servers[1].options.surface, 'management')
|
||||
signalSource.emit('SIGTERM')
|
||||
assert.equal(await running, 0)
|
||||
assert.equal(servers.every(server => server.listening === false), true)
|
||||
})
|
||||
|
||||
test('gateway retains a recent platform status while Management is handed off', async () => {
|
||||
const signalSource = new EventEmitter()
|
||||
const server = new FakeServer()
|
||||
const expected = { update: { status: 'building-candidate' } }
|
||||
let requests = 0
|
||||
let options
|
||||
const running = runGateway(config, {
|
||||
gatewayFactory: value => { options = value; return server },
|
||||
accessClient: { request: async () => ({ state: 'recovery-required' }) },
|
||||
signalSource,
|
||||
managementClient: {
|
||||
request: async () => {
|
||||
requests += 1
|
||||
if (requests === 1) return expected
|
||||
throw Object.assign(new Error('Management is switching'), { code: 'ECONNREFUSED' })
|
||||
},
|
||||
},
|
||||
platformStatusPollMs: 5,
|
||||
})
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 15))
|
||||
assert.deepEqual(await options.platformStatus(), expected)
|
||||
assert.equal(requests >= 2, true)
|
||||
} finally {
|
||||
signalSource.emit('SIGTERM')
|
||||
assert.equal(await running, 0)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import vm from 'node:vm'
|
||||
import { injectRandomUuidPolyfill, LIFECYCLE_TRANSITION_GUARD, PLUGIN_RECOVERY_GUARD, RANDOM_UUID_POLYFILL, SESSION_VALIDITY_GUARD } from '../lib/polyfill.mjs'
|
||||
|
||||
async function simulatePluginError(bundlePath, { eventType = 'error', storage = new Map() } = {}) {
|
||||
let replacement
|
||||
const events = []
|
||||
const listeners = new Map()
|
||||
const context = {
|
||||
URL,
|
||||
crypto: globalThis.crypto,
|
||||
location: {
|
||||
href: 'http://gateway.local/sessions/current',
|
||||
pathname: '/sessions/current',
|
||||
search: '',
|
||||
hash: '',
|
||||
replace: value => { replacement = value },
|
||||
},
|
||||
sessionStorage: {
|
||||
getItem: key => storage.get(key) ?? null,
|
||||
setItem: (key, value) => storage.set(key, value),
|
||||
},
|
||||
addEventListener: (name, callback) => listeners.set(name, callback),
|
||||
fetch: async (input, init) => {
|
||||
if (input === '/_dsh_gateway/readiness') return { json: async () => ({ state: 'ready', pluginRecoveryEligible: true }) }
|
||||
if (input === '/_dsh_platform/plugin-api/v1/status') return {
|
||||
json: async () => ({
|
||||
dshLifecycle: {
|
||||
state: 'running',
|
||||
taskId: 'lifecycle-task',
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
}
|
||||
if (input === '/_dsh_gateway/client-event') {
|
||||
events.push(JSON.parse(init.body))
|
||||
return { json: async () => ({}) }
|
||||
}
|
||||
return { ok: true, json: async () => ({}) }
|
||||
},
|
||||
}
|
||||
vm.runInNewContext(PLUGIN_RECOVERY_GUARD.slice('<script>'.length, -'</script>'.length), context)
|
||||
if (eventType === 'error') listeners.get('error')?.({ filename: `http://gateway.local${bundlePath}` })
|
||||
else if (eventType === 'unhandledrejection') listeners.get('unhandledrejection')?.({
|
||||
reason: { stack: `TypeError: failed to import http://gateway.local${bundlePath}` },
|
||||
})
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
return { events, replacement, storage }
|
||||
}
|
||||
|
||||
test('polyfill is inserted immediately after the head tag', () => {
|
||||
assert.equal(
|
||||
injectRandomUuidPolyfill('<html><head data-x="1"><title>x</title></head></html>'),
|
||||
`<html><head data-x="1">${RANDOM_UUID_POLYFILL}${LIFECYCLE_TRANSITION_GUARD}${SESSION_VALIDITY_GUARD}${PLUGIN_RECOVERY_GUARD}<title>x</title></head></html>`,
|
||||
)
|
||||
})
|
||||
|
||||
test('polyfill falls back to the start when no head exists', () => {
|
||||
assert.equal(injectRandomUuidPolyfill('<main>x</main>'), `${RANDOM_UUID_POLYFILL}${LIFECYCLE_TRANSITION_GUARD}${SESSION_VALIDITY_GUARD}${PLUGIN_RECOVERY_GUARD}<main>x</main>`)
|
||||
})
|
||||
|
||||
test('gateway lifecycle guard moves an already-open DSH page into the holding flow', async () => {
|
||||
let inspect
|
||||
let replacement
|
||||
const context = {
|
||||
EventSource: class {
|
||||
addEventListener(name, callback) { if (name === 'state') inspect = callback }
|
||||
},
|
||||
URL,
|
||||
fetch: async () => ({ json: async () => ({ dshLifecycle: { state: 'restarting' } }) }),
|
||||
location: {
|
||||
pathname: '/sessions/current', search: '?view=chat', hash: '#latest',
|
||||
replace: value => { replacement = value },
|
||||
},
|
||||
}
|
||||
vm.runInNewContext(LIFECYCLE_TRANSITION_GUARD.slice('<script>'.length, -'</script>'.length), context)
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
assert.equal(replacement, '/_dsh_gateway/wait?return=%2Fsessions%2Fcurrent%3Fview%3Dchat%23latest')
|
||||
replacement = undefined
|
||||
await inspect()
|
||||
assert.equal(replacement, undefined)
|
||||
})
|
||||
|
||||
test('plugin recovery guard runs before DSH modules and bounds lifecycle recovery', () => {
|
||||
assert.doesNotThrow(() => new Function(PLUGIN_RECOVERY_GUARD.slice('<script>'.length, -'</script>'.length)))
|
||||
assert.match(PLUGIN_RECOVERY_GUARD, /globalThis\.fetch=function/)
|
||||
assert.match(PLUGIN_RECOVERY_GUARD, /\/_dsh_gateway\/client-event/)
|
||||
assert.match(PLUGIN_RECOVERY_GUARD, /\/_dsh_gateway\/wait/)
|
||||
assert.match(PLUGIN_RECOVERY_GUARD, /attempt>=3/)
|
||||
assert.match(PLUGIN_RECOVERY_GUARD, /\/_dsh_gateway\/plugin-failure/)
|
||||
assert.match(PLUGIN_RECOVERY_GUARD, /browser\.plugin-load\.recovery\.completed/)
|
||||
assert.match(PLUGIN_RECOVERY_GUARD, /addEventListener\("error"/)
|
||||
assert.match(PLUGIN_RECOVERY_GUARD, /unhandledrejection/)
|
||||
assert.doesNotMatch(PLUGIN_RECOVERY_GUARD, /Failed to load plugins/)
|
||||
assert.doesNotMatch(PLUGIN_RECOVERY_GUARD, /localStorage/)
|
||||
})
|
||||
|
||||
test('Gateway session guard reloads DSH pages only after Access Manager rejects the session', () => {
|
||||
assert.doesNotThrow(() => new Function(SESSION_VALIDITY_GUARD.slice('<script>'.length, -'</script>'.length)))
|
||||
assert.match(SESSION_VALIDITY_GUARD, /auth\/session-context/)
|
||||
assert.match(SESSION_VALIDITY_GUARD, /response\.status===401/)
|
||||
assert.doesNotMatch(SESSION_VALIDITY_GUARD, /innerText|textContent/)
|
||||
})
|
||||
|
||||
test('plugin recovery guard catches official and third-party dynamic import failures', async () => {
|
||||
for (const [path, pluginId] of [
|
||||
['/plugins/@deepseek-ai/dsh-client-ui-input-trigger/client.js?rev=abc123', '@deepseek-ai/dsh-client-ui-input-trigger'],
|
||||
['/plugins/community-plugin/client.js?rev=def456', 'community-plugin'],
|
||||
]) {
|
||||
const result = await simulatePluginError(path)
|
||||
assert.match(result.replacement, /^\/_dsh_gateway\/wait\?return=/)
|
||||
assert.deepEqual(result.events.map(value => [value.event, value.pluginId]), [
|
||||
['browser.plugin-load.failed', pluginId],
|
||||
['browser.plugin-load.recovery.started', pluginId],
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
test('plugin recovery guard retries twice and opens the Gateway failure page after the third failure', async () => {
|
||||
const path = '/plugins/@deepseek-ai/dsh-client-ui-input-trigger/client.js?rev=abc123'
|
||||
const storage = new Map()
|
||||
const first = await simulatePluginError(path, { storage })
|
||||
const second = await simulatePluginError(path, { storage })
|
||||
const third = await simulatePluginError(path, { storage })
|
||||
|
||||
assert.match(first.replacement, /^\/_dsh_gateway\/wait\?return=/)
|
||||
assert.match(second.replacement, /^\/_dsh_gateway\/wait\?return=/)
|
||||
assert.equal(third.replacement, '/_dsh_gateway/plugin-failure')
|
||||
assert.equal(first.events.at(-1).recoveryAttempt, 1)
|
||||
assert.equal(second.events.at(-1).recoveryAttempt, 2)
|
||||
assert.deepEqual(third.events.map(value => [value.event, value.recoveryAttempt]), [
|
||||
['browser.plugin-load.recovery.failed', 3],
|
||||
])
|
||||
})
|
||||
|
||||
test('plugin recovery guard does not inspect page text', async () => {
|
||||
const result = await simulatePluginError('/plugins/example/client.js?rev=abc123', { eventType: 'none' })
|
||||
assert.equal(result.replacement, undefined)
|
||||
assert.deepEqual(result.events, [])
|
||||
})
|
||||
|
||||
test('polyfill is feature guarded and uses no weak random source', () => {
|
||||
assert.match(RANDOM_UUID_POLYFILL, /typeof c\.randomUUID!=="function"/)
|
||||
assert.match(RANDOM_UUID_POLYFILL, /c\.getRandomValues/)
|
||||
assert.doesNotMatch(RANDOM_UUID_POLYFILL, /Math\.random/)
|
||||
})
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,86 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { parseTrustedHosts } from '../lib/config.mjs'
|
||||
import { inspectExternalRequest, isLoopbackHostname } from '../lib/trust.mjs'
|
||||
|
||||
test('isLoopbackHostname follows DSH loopback semantics', () => {
|
||||
for (const hostname of ['localhost', '127.0.0.1', '127.255.255.255', '[::1]']) {
|
||||
assert.equal(isLoopbackHostname(hostname), true, hostname)
|
||||
}
|
||||
for (const hostname of ['example.com', '128.0.0.1', '127.0.0.999', '[::2]']) {
|
||||
assert.equal(isLoopbackHostname(hostname), false, hostname)
|
||||
}
|
||||
})
|
||||
|
||||
test('empty trusted Hosts accepts loopback only', () => {
|
||||
const trusted = parseTrustedHosts({})
|
||||
assert.equal(inspectExternalRequest({ host: '127.0.0.1:3080' }, trusted).accepted, true)
|
||||
assert.equal(inspectExternalRequest({ host: 'localhost:3080' }, trusted).accepted, true)
|
||||
assert.deepEqual(inspectExternalRequest({ host: '192.168.1.10:3080' }, trusted), {
|
||||
accepted: false,
|
||||
reason: 'untrusted-host',
|
||||
})
|
||||
})
|
||||
|
||||
test('trusted Hosts match optional ports and wildcard', () => {
|
||||
const trusted = parseTrustedHosts({
|
||||
DSH_TRUSTED_HOSTS: 'lan.example,exact.example:8443,default.example:80',
|
||||
})
|
||||
assert.equal(inspectExternalRequest({ host: 'lan.example:9999' }, trusted).accepted, true)
|
||||
assert.equal(inspectExternalRequest({ host: 'exact.example:8443' }, trusted).accepted, true)
|
||||
assert.equal(inspectExternalRequest({ host: 'exact.example:3080' }, trusted).accepted, false)
|
||||
assert.equal(inspectExternalRequest({ host: 'default.example' }, trusted).accepted, true)
|
||||
assert.equal(inspectExternalRequest({ host: 'default.example:80' }, trusted).accepted, true)
|
||||
const wildcard = parseTrustedHosts({ DSH_TRUSTED_HOSTS: '*' })
|
||||
assert.equal(inspectExternalRequest({ host: 'anything.example' }, wildcard).accepted, true)
|
||||
})
|
||||
|
||||
test('request inspection rejects cross-site and mismatched origins', () => {
|
||||
const trusted = parseTrustedHosts({ DSH_TRUSTED_HOSTS: 'dsh.example' })
|
||||
assert.deepEqual(inspectExternalRequest({
|
||||
host: 'dsh.example',
|
||||
'sec-fetch-site': 'cross-site',
|
||||
}, trusted), { accepted: false, reason: 'cross-site' })
|
||||
assert.deepEqual(inspectExternalRequest({
|
||||
host: 'dsh.example',
|
||||
origin: 'https://evil.example',
|
||||
}, trusted), { accepted: false, reason: 'origin-mismatch' })
|
||||
assert.equal(inspectExternalRequest({
|
||||
host: 'dsh.example',
|
||||
origin: 'https://dsh.example',
|
||||
'sec-fetch-site': 'same-origin',
|
||||
}, trusted).accepted, true)
|
||||
})
|
||||
|
||||
test('explicit cross-origin inspection still requires a trusted Host', () => {
|
||||
const trusted = parseTrustedHosts({ DSH_TRUSTED_HOSTS: 'management.example' })
|
||||
assert.equal(inspectExternalRequest({
|
||||
host: 'management.example',
|
||||
origin: 'https://dsh.example',
|
||||
'sec-fetch-site': 'cross-site',
|
||||
}, trusted, { allowCrossOrigin: true }).accepted, true)
|
||||
assert.equal(inspectExternalRequest({
|
||||
host: 'untrusted.example',
|
||||
origin: 'https://dsh.example',
|
||||
'sec-fetch-site': 'cross-site',
|
||||
}, trusted, { allowCrossOrigin: true }).accepted, false)
|
||||
})
|
||||
|
||||
test('request inspection rejects missing and malformed browser authorities', () => {
|
||||
const trusted = parseTrustedHosts({ DSH_TRUSTED_HOSTS: '*' })
|
||||
assert.deepEqual(inspectExternalRequest({}, trusted), { accepted: false, reason: 'missing-host' })
|
||||
assert.deepEqual(inspectExternalRequest({ host: 'bad host' }, trusted), {
|
||||
accepted: false,
|
||||
reason: 'invalid-host',
|
||||
})
|
||||
for (const host of ['ok.example/path', 'user@ok.example', 'ok.example?', 'ok.example#x', 'ok.example:']) {
|
||||
assert.deepEqual(inspectExternalRequest({ host }, trusted), {
|
||||
accepted: false,
|
||||
reason: 'invalid-host',
|
||||
}, host)
|
||||
}
|
||||
assert.deepEqual(inspectExternalRequest({ host: 'ok.example', origin: 'null' }, trusted), {
|
||||
accepted: false,
|
||||
reason: 'invalid-origin',
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user