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:
hermes committed 2026-10-01 19:21:19 +00:00
commit e6dcef9bf5
302 files changed
+75178

No files matched your search

@@ -0,0 +1,30 @@
{
"schema": 1,
"id": "access-manager",
"type": "service",
"command": {
"executable": "/usr/local/bin/node",
"args": ["/run/dsh-platform/views/bootstrap/control-plane/services/access-manager/supervisor.mjs"],
"timeoutSeconds": 30
},
"environment": {},
"lifecycle": {
"prepare": null,
"preStart": null,
"postStart": null,
"preStop": null,
"stop": null,
"postStop": null
},
"health": {
"type": "exec",
"command": {
"executable": "/usr/local/bin/node",
"args": ["/run/dsh-platform/views/bootstrap/control-plane/services/access-manager/supervisor.mjs", "health"],
"timeoutSeconds": 5
},
"intervalSeconds": 1,
"timeoutSeconds": 30
},
"logging": { "stdout": true, "stderr": true }
}
@@ -0,0 +1,53 @@
#!/usr/bin/env node
import { chmod, mkdir, rm } from 'node:fs/promises'
import { dirname } from 'node:path'
import { PlatformPaths } from '../../../platform/lib/paths.mjs'
import { AccessStateStore } from './lib/store.mjs'
import { AccessService, createAccessHttpServer } from './lib/server.mjs'
const paths = new PlatformPaths(
process.env.DSH_PLATFORM_DATA ?? '/data/platform',
process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform',
)
async function listen(server, path) {
await mkdir(dirname(path), { recursive: true })
await rm(path, { force: true })
await new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(path, resolve)
})
await chmod(path, 0o600)
}
const classificationToken = process.env.DSH_ACCESS_CLASSIFICATION_TOKEN
delete process.env.DSH_ACCESS_CLASSIFICATION_TOKEN
if (typeof classificationToken !== 'string' || classificationToken.length < 32) {
throw new Error('Access classification token is unavailable')
}
const store = new AccessStateStore({ root: paths.accessStateRoot })
await store.prepare()
const service = new AccessService({
store,
classificationToken,
report: (message, fields) => {
process.send?.({ type: 'diagnostic', message, fields })
},
})
await service.reconcileRuntimePolicy()
const access = createAccessHttpServer({ service })
const recovery = createAccessHttpServer({ service, surface: 'recovery' })
await listen(access, paths.accessSocket)
await listen(recovery, paths.accessRecoverySocket)
process.send?.({ type: 'ready', componentReady: true })
let resolveSignal
const signal = new Promise(resolve => { resolveSignal = resolve })
process.once('SIGINT', () => resolveSignal())
process.once('SIGTERM', () => resolveSignal())
await signal
await Promise.all([
new Promise(resolve => access.close(resolve)),
new Promise(resolve => recovery.close(resolve)),
])
@@ -0,0 +1,64 @@
import { createHash, randomBytes } from 'node:crypto'
function digest(value) { return createHash('sha256').update(value).digest('hex') }
function token(random) { return `dshcap_${random(32).toString('base64url')}` }
export function capabilityTarget(path) {
if (typeof path !== 'string' || path.length === 0 || path.length > 8_192 || !path.startsWith('/')) {
throw new TypeError('capability target is invalid')
}
const value = new URL(path, 'http://capability.internal')
if (value.origin !== 'http://capability.internal' || value.hash !== '') throw new TypeError('capability target is invalid')
return `${value.pathname}${value.search}`
}
export class CapabilityStore {
constructor({ now = Date.now, random = randomBytes, ttlMs = 5_000 } = {}) {
this.now = now
this.random = random
this.ttlMs = ttlMs
this.capabilities = new Map()
}
prune() {
const now = this.now()
for (const [key, value] of this.capabilities) if (value.expiresAt <= now) this.capabilities.delete(key)
}
issue(session, account, { audience, method, target }) {
if (!['management', 'maintenance', 'plugin'].includes(audience)) throw new TypeError('capability audience is invalid')
if (!/^(?:GET|HEAD|POST|PUT|DELETE)$/.test(method ?? '')) throw new TypeError('capability method is invalid')
this.prune()
const value = token(this.random)
this.capabilities.set(digest(value), {
audience,
method,
target: capabilityTarget(target),
sessionId: session.sessionId,
accountId: account.accountId,
mainCredentialVersion: account.mainCredential.version,
managementAdditionalCredentialVersion: account.managementAdditionalCredential.enabled
? account.managementAdditionalCredential.version : null,
managementAccessVersion: account.managementAccess.version,
expiresAt: this.now() + this.ttlMs,
})
return Object.freeze({ token: value, expiresAt: new Date(this.now() + this.ttlMs).toISOString() })
}
consume(value, account, { audience, method, target }) {
this.prune()
if (typeof value !== 'string' || value.length > 512) return undefined
const key = digest(value)
const capability = this.capabilities.get(key)
this.capabilities.delete(key)
const additionalVersion = account.managementAdditionalCredential.enabled
? account.managementAdditionalCredential.version : null
if (capability === undefined || capability.accountId !== account.accountId
|| capability.mainCredentialVersion !== account.mainCredential.version
|| capability.managementAdditionalCredentialVersion !== additionalVersion
|| capability.managementAccessVersion !== account.managementAccess.version
|| capability.audience !== audience || capability.method !== method
|| capability.target !== capabilityTarget(target)) return undefined
return Object.freeze({ sessionId: capability.sessionId, audience: capability.audience })
}
}
@@ -0,0 +1,89 @@
import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto'
import { promisify } from 'node:util'
import { AccessError } from './errors.mjs'
const scrypt = promisify(scryptCallback)
const BIDI_CONTROLS = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u
const CONTROL_CHARACTERS = /\p{Cc}/u
export const SCRYPT_POLICY = Object.freeze({ N: 32768, r: 8, p: 1, keyLength: 32, maxmem: 64 * 1024 * 1024 })
function codePoints(value) { return [...value].length }
export function normalizeUsername(input) {
if (typeof input !== 'string') throw new AccessError('USERNAME_INVALID', 'username is invalid')
const value = input.trim().normalize('NFC')
const bytes = Buffer.byteLength(value, 'utf8')
if (value.length === 0 || codePoints(value) > 64 || bytes > 256
|| CONTROL_CHARACTERS.test(value) || BIDI_CONTROLS.test(value)) {
throw new AccessError('USERNAME_INVALID', 'username is invalid')
}
return value
}
export function normalizePassword(input) {
if (typeof input !== 'string') throw new AccessError('PASSWORD_POLICY_VIOLATION', 'password does not meet policy')
const value = input.normalize('NFC')
const length = codePoints(value)
if (length < 8 || length > 1024 || Buffer.byteLength(value, 'utf8') > 1024
|| CONTROL_CHARACTERS.test(value) || BIDI_CONTROLS.test(value)) {
throw new AccessError('PASSWORD_POLICY_VIOLATION', 'password does not meet policy')
}
return value
}
function validParameters(value) {
return value !== null && typeof value === 'object'
&& Number.isInteger(value.N) && value.N >= 2
&& Number.isInteger(value.r) && value.r > 0
&& Number.isInteger(value.p) && value.p > 0
&& Number.isInteger(value.keyLength) && value.keyLength >= 16
&& Number.isInteger(value.maxmem) && value.maxmem >= 1024 * 1024
}
export async function createCredential(password, {
version = 1,
policy = SCRYPT_POLICY,
now = () => new Date(),
random = randomBytes,
derive = scrypt,
} = {}) {
const normalized = normalizePassword(password)
const salt = random(16)
const hash = await derive(normalized, salt, policy.keyLength, {
N: policy.N, r: policy.r, p: policy.p, maxmem: policy.maxmem,
})
return Object.freeze({
version,
algorithm: 'scrypt',
parameters: { ...policy },
salt: Buffer.from(salt).toString('base64url'),
hash: Buffer.from(hash).toString('base64url'),
changedAt: now().toISOString(),
})
}
export function validCredential(value) {
if (value === null || typeof value !== 'object' || value.algorithm !== 'scrypt'
|| !Number.isInteger(value.version) || value.version < 1 || !validParameters(value.parameters)
|| typeof value.salt !== 'string' || typeof value.hash !== 'string'
|| typeof value.changedAt !== 'string') return false
try {
return Buffer.from(value.salt, 'base64url').byteLength >= 16
&& Buffer.from(value.hash, 'base64url').byteLength === value.parameters.keyLength
&& Number.isFinite(Date.parse(value.changedAt))
} catch { return false }
}
export async function verifyCredential(password, credential, { derive = scrypt } = {}) {
if (!validCredential(credential)) return false
let normalized
try { normalized = normalizePassword(password) } catch { return false }
const expected = Buffer.from(credential.hash, 'base64url')
const salt = Buffer.from(credential.salt, 'base64url')
const parameters = credential.parameters
const actual = Buffer.from(await derive(normalized, salt, parameters.keyLength, {
N: parameters.N, r: parameters.r, p: parameters.p, maxmem: parameters.maxmem,
}))
return actual.byteLength === expected.byteLength && timingSafeEqual(actual, expected)
}
@@ -0,0 +1,19 @@
export class AccessError extends Error {
constructor(code, message, statusCode = 400, details = {}) {
super(message)
this.name = 'AccessError'
this.code = code
this.statusCode = statusCode
this.details = details
}
}
export function accessErrorBody(error) {
if (error instanceof AccessError) return {
error: error.message,
code: error.code,
...(Number.isInteger(error.details.retryAfterSeconds)
? { retryAfterSeconds: error.details.retryAfterSeconds } : {}),
}
return { error: 'access operation failed', code: 'ACCESS_INTERNAL_ERROR' }
}
@@ -0,0 +1,97 @@
import { createHash, randomBytes } from 'node:crypto'
function digest(value) { return createHash('sha256').update(value).digest('hex') }
function token(prefix, random) { return `${prefix}_${random(32).toString('base64url')}` }
export class ManagementExchangeStore {
constructor({ now = Date.now, random = randomBytes, handoffTtlMs = 30_000, pendingTtlMs = 5 * 60_000, pendingAttempts = 16 } = {}) {
this.now = now
this.random = random
this.handoffTtlMs = handoffTtlMs
this.pendingTtlMs = pendingTtlMs
this.pendingAttempts = pendingAttempts
this.handoffs = new Map()
this.pending = new Map()
}
prune() {
const now = this.now()
for (const [key, value] of this.handoffs) if (value.expiresAt <= now) this.handoffs.delete(key)
for (const [key, value] of this.pending) if (value.expiresAt <= now) this.pending.delete(key)
}
createHandoff(session, account, targetOrigin) {
this.prune()
const value = token('dshh', this.random)
this.handoffs.set(digest(value), {
accountId: account.accountId,
dshSessionId: session.sessionId,
mainCredentialVersion: account.mainCredential.version,
managementAccessVersion: account.managementAccess.version,
sourceDshOrigin: session.origin,
targetOrigin,
expiresAt: this.now() + this.handoffTtlMs,
})
return Object.freeze({ token: value, expiresAt: new Date(this.now() + this.handoffTtlMs).toISOString() })
}
consumeHandoff(value, account, targetOrigin) {
this.prune()
if (typeof value !== 'string' || value.length > 512) return undefined
const key = digest(value)
const handoff = this.handoffs.get(key)
this.handoffs.delete(key)
if (handoff === undefined || handoff.accountId !== account.accountId
|| handoff.mainCredentialVersion !== account.mainCredential.version
|| handoff.managementAccessVersion !== account.managementAccess.version
|| handoff.targetOrigin !== targetOrigin) return undefined
return handoff
}
createPending(account, { targetOrigin, sourceDshOrigin = null, sourceDshSessionId = null }) {
this.prune()
const value = token('dshmp', this.random)
this.pending.set(digest(value), {
accountId: account.accountId,
mainCredentialVersion: account.mainCredential.version,
managementAdditionalCredentialVersion: account.managementAdditionalCredential.version,
managementAccessVersion: account.managementAccess.version,
targetOrigin,
sourceDshOrigin,
sourceDshSessionId,
attempts: 0,
expiresAt: this.now() + this.pendingTtlMs,
})
return Object.freeze({ token: value, expiresAt: new Date(this.now() + this.pendingTtlMs).toISOString() })
}
peekPendingSource(value, account, targetOrigin) {
this.prune()
if (typeof value !== 'string' || value.length > 512) return undefined
const pending = this.pending.get(digest(value))
if (pending === undefined || pending.accountId !== account.accountId
|| pending.mainCredentialVersion !== account.mainCredential.version
|| pending.managementAccessVersion !== account.managementAccess.version
|| pending.targetOrigin !== targetOrigin) return undefined
return pending.sourceDshSessionId
}
inspectPending(value, account, targetOrigin) {
this.prune()
if (typeof value !== 'string' || value.length > 512) return undefined
const key = digest(value)
const pending = this.pending.get(key)
if (pending === undefined || pending.accountId !== account.accountId
|| pending.mainCredentialVersion !== account.mainCredential.version
|| pending.managementAccessVersion !== account.managementAccess.version
|| pending.targetOrigin !== targetOrigin) return undefined
pending.attempts += 1
if (pending.attempts > this.pendingAttempts) {
this.pending.delete(key)
return undefined
}
return { key, value: pending }
}
consumePending(key) { return this.pending.delete(key) }
}
@@ -0,0 +1,241 @@
import { AccessError } from './errors.mjs'
export class AuthenticationLimiter {
constructor({
globalLimit = 20,
windowMs = 60_000,
maxConcurrent = 2,
backoffThreshold = 5,
initialBackoffMs = 30_000,
maxBackoffMs = 15 * 60_000,
consecutiveResetMs = 24 * 60 * 60_000,
sourceWindows,
globalWindows,
clock = () => Date.now(),
} = {}) {
this.globalLimit = globalLimit
this.windowMs = windowMs
this.maxConcurrent = maxConcurrent
this.backoffThreshold = backoffThreshold
this.initialBackoffMs = initialBackoffMs
this.maxBackoffMs = maxBackoffMs
this.consecutiveResetMs = consecutiveResetMs
this.sourceWindows = sourceWindows ?? [
{ limit: 12, windowMs: 60 * 60_000 },
{ limit: 24, windowMs: 24 * 60 * 60_000 },
]
this.globalWindows = globalWindows ?? [
{ limit: globalLimit, windowMs },
{ limit: 60, windowMs: 60 * 60_000 },
{ limit: 120, windowMs: 24 * 60 * 60_000 },
]
this.maxSourceWindowMs = Math.max(...this.sourceWindows.map(value => value.windowMs))
this.maxGlobalWindowMs = Math.max(...this.globalWindows.map(value => value.windowMs))
this.clock = clock
this.global = []
this.sources = new Map()
this.consecutiveFailures = new Map()
this.active = 0
this.lastPruneAt = Number.NEGATIVE_INFINITY
}
prune(values, now, windowMs = this.windowMs) {
while (values.length > 0 && this.timestamp(values[0]) <= now - windowMs) values.shift()
}
timestamp(value) { return typeof value === 'number' ? value : value.at }
retryAfterSeconds(values, windows, now) {
let retryAfterMs = 0
for (const { limit, windowMs } of windows) {
const withinWindow = values.filter(value => this.timestamp(value) > now - windowMs)
if (withinWindow.length < limit) continue
retryAfterMs = Math.max(
retryAfterMs,
this.timestamp(withinWindow[withinWindow.length - limit]) + windowMs - now,
)
}
return Math.max(0, Math.ceil(retryAfterMs / 1_000))
}
retryKey(accountId, sourceId, credential = 'main') { return `${accountId}\u0000${credential}\u0000${sourceId}` }
retryPrefix(accountId, credential = 'all') {
return credential === 'all' ? `${accountId}\u0000` : `${accountId}\u0000${credential}\u0000`
}
pruneExpiredSources(now) {
if (now - this.lastPruneAt < this.windowMs) return
this.lastPruneAt = now
for (const [key, values] of this.sources) {
this.prune(values, now, this.maxSourceWindowMs)
if (values.length === 0) this.sources.delete(key)
}
for (const [key, retry] of this.consecutiveFailures) {
if (retry.lastFailureAt <= now - this.consecutiveResetMs) {
this.consecutiveFailures.delete(key)
}
}
}
currentRetry(accountId = 'unknown', sourceId = 'unknown', now = this.clock(), credential = 'main') {
const retryKey = this.retryKey(accountId, sourceId, credential)
const retry = this.consecutiveFailures.get(retryKey)
if (retry !== undefined && retry.lastFailureAt <= now - this.consecutiveResetMs) {
this.consecutiveFailures.delete(retryKey)
return undefined
}
return retry
}
checkRetry(accountId = 'unknown', sourceId = 'unknown', credential = 'main') {
const now = this.clock()
const retry = this.currentRetry(accountId, sourceId, now, credential)
if (retry !== undefined && retry.blockedUntil > now) {
const retryAfterSeconds = Math.max(1, Math.ceil((retry.blockedUntil - now) / 1_000))
throw new AccessError(
'AUTHENTICATION_RETRY_REQUIRED',
`authentication retry is available in ${retryAfterSeconds} seconds`,
429,
{ retryAfterSeconds },
)
}
}
enter(accountId = 'unknown', sourceId = 'unknown', credential = 'main') {
this.checkRetry(accountId, sourceId, credential)
const now = this.clock()
this.pruneExpiredSources(now)
const retryKey = this.retryKey(accountId, sourceId, credential)
const source = this.sources.get(retryKey) ?? []
this.prune(source, now, this.maxSourceWindowMs)
this.sources.set(retryKey, source)
this.prune(this.global, now, this.maxGlobalWindowMs)
if (this.active >= this.maxConcurrent) {
throw new AccessError(
'AUTHENTICATION_RATE_LIMITED',
'authentication is temporarily unavailable',
429,
{ retryAfterSeconds: 1 },
)
}
const globalRetryAfterSeconds = this.retryAfterSeconds(this.global, this.globalWindows, now)
const sourceRetryAfterSeconds = this.retryAfterSeconds(source, this.sourceWindows, now)
if (sourceRetryAfterSeconds > 0) {
throw new AccessError(
'AUTHENTICATION_RATE_LIMITED',
`browser authentication is available in ${sourceRetryAfterSeconds} seconds`,
429,
{ retryAfterSeconds: sourceRetryAfterSeconds },
)
}
if (globalRetryAfterSeconds > 0) {
throw new AccessError(
'AUTHENTICATION_RATE_LIMITED',
`administrator authentication is available in ${globalRetryAfterSeconds} seconds`,
429,
{ retryAfterSeconds: globalRetryAfterSeconds },
)
}
this.active += 1
let released = false
const release = success => {
if (released) return this.sourceStatus(accountId, sourceId, credential)
released = true
this.active -= 1
if (success !== true) {
const failedAt = this.clock()
this.global.push({ at: failedAt, credential })
source.push(failedAt)
const previous = this.consecutiveFailures.get(retryKey)
const failures = (previous !== undefined
&& previous.lastFailureAt > failedAt - this.consecutiveResetMs ? previous.failures : 0) + 1
const exponent = Math.max(0, failures - this.backoffThreshold)
const backoffMs = failures < this.backoffThreshold
? 0
: Math.min(this.maxBackoffMs, this.initialBackoffMs * (2 ** exponent))
this.consecutiveFailures.set(retryKey, {
failures,
blockedUntil: backoffMs === 0 ? 0 : failedAt + backoffMs,
lastFailureAt: failedAt,
})
} else {
this.consecutiveFailures.delete(this.retryKey(accountId, sourceId, credential))
this.sources.delete(this.retryKey(accountId, sourceId, credential))
}
return this.sourceStatus(accountId, sourceId, credential)
}
return { release }
}
sourceStatus(accountId = 'unknown', sourceId = 'unknown', credential = 'main') {
const retry = this.currentRetry(accountId, sourceId, this.clock(), credential)
if (retry === undefined) return { consecutiveFailures: 0, retryAfterSeconds: 0 }
return {
consecutiveFailures: retry.failures,
retryAfterSeconds: Math.max(0, Math.ceil((retry.blockedUntil - this.clock()) / 1_000)),
}
}
status(accountId = 'unknown', credential = 'all') {
const now = this.clock()
this.pruneExpiredSources(now)
this.prune(this.global, now, this.maxGlobalWindowMs)
const prefix = this.retryPrefix(accountId, credential)
for (const [key, retry] of this.consecutiveFailures) {
if (key.startsWith(prefix) && retry.lastFailureAt <= now - this.consecutiveResetMs) {
this.consecutiveFailures.delete(key)
}
}
const states = [...this.consecutiveFailures.entries()]
.filter(([key]) => key.startsWith(prefix))
.map(([, retry]) => ({
consecutiveFailures: retry.failures,
retryAfterSeconds: Math.max(0, Math.ceil((retry.blockedUntil - this.clock()) / 1_000)),
}))
const sourcePrefix = prefix
const sourceRetryAfterSeconds = Math.max(0, ...[...this.sources.entries()]
.filter(([key]) => key.startsWith(sourcePrefix))
.map(([, values]) => this.retryAfterSeconds(values, this.sourceWindows, now)))
const global = credential === 'all'
? this.global : this.global.filter(value => value.credential === credential)
return {
activeSources: states.length,
consecutiveFailures: Math.max(0, ...states.map(value => value.consecutiveFailures)),
retryAfterSeconds: Math.max(0, ...states.map(value => value.retryAfterSeconds)),
sourceRetryAfterSeconds,
globalFailures: global.length,
globalRetryAfterSeconds: this.retryAfterSeconds(global, this.globalWindows, now),
}
}
clear(accountId = 'unknown', credential = 'all') {
const previous = this.status(accountId, credential)
const prefix = this.retryPrefix(accountId, credential)
for (const key of this.consecutiveFailures.keys()) {
if (key.startsWith(prefix)) this.consecutiveFailures.delete(key)
}
for (const key of this.sources.keys()) {
if (key.startsWith(prefix)) this.sources.delete(key)
}
this.global = credential === 'all'
? [] : this.global.filter(value => value.credential !== credential)
return { cleared: previous.activeSources > 0 || previous.globalFailures > 0, ...previous }
}
clearGlobal(credential = 'all') {
const now = this.clock()
this.prune(this.global, now, this.maxGlobalWindowMs)
const matching = credential === 'all'
? this.global : this.global.filter(value => value.credential === credential)
const globalFailures = matching.length
const globalRetryAfterSeconds = this.retryAfterSeconds(matching, this.globalWindows, now)
this.global = credential === 'all'
? [] : this.global.filter(value => value.credential !== credential)
return {
cleared: globalFailures > 0,
globalFailures,
globalRetryAfterSeconds,
}
}
}
@@ -0,0 +1,62 @@
import { readFile } from 'node:fs/promises'
function groupId(groupFile, name) {
for (const line of groupFile.split('\n')) {
const fields = line.split(':')
if (fields[0] === name && /^\d+$/.test(fields[2] ?? '')) return Number(fields[2])
}
return null
}
function supplementaryGroups(status) {
const line = status.split('\n').find(value => value.startsWith('Groups:'))
if (line === undefined) return []
return line.slice('Groups:'.length).trim().split(/\s+/).filter(Boolean).map(Number).filter(Number.isInteger)
}
function mountOption(mounts, mountPoint, prefix) {
for (const line of mounts.split('\n')) {
const fields = line.split(' ')
if (fields[1] !== mountPoint) continue
return fields[3]?.split(',').find(value => value.startsWith(prefix)) ?? null
}
return null
}
async function optionalRead(path) {
try { return await readFile(path, 'utf8') } catch { return '' }
}
export async function detectRuntimeCapabilities({
groupPath = '/etc/group',
initStatusPath = '/proc/1/status',
mountsPath = '/proc/mounts',
ptraceScopePath = '/proc/sys/kernel/yama/ptrace_scope',
} = {}) {
const [groupsFile, initStatus, mounts, ptraceScopeValue] = await Promise.all([
optionalRead(groupPath),
optionalRead(initStatusPath),
optionalRead(mountsPath),
optionalRead(ptraceScopePath),
])
const enabledGroup = groupId(groupsFile, 'dsh-sudo-true')
const disabledGroup = groupId(groupsFile, 'dsh-sudo-false')
const initGroups = supplementaryGroups(initStatus)
const dshRootCapabilityEffective = enabledGroup !== null && initGroups.includes(enabledGroup)
const sudoSelection = dshRootCapabilityEffective
? 'enabled'
: (disabledGroup !== null && initGroups.includes(disabledGroup) ? 'disabled' : 'unknown')
const ptraceScope = /^\d+$/.test(ptraceScopeValue.trim()) ? Number(ptraceScopeValue.trim()) : null
const hidepid = mountOption(mounts, '/proc', 'hidepid=')
return {
dshRootCapabilityEffective,
agentIsolationEffective: false,
details: {
sudoSelection,
ptraceScope,
procHidepid: hidepid === null ? null : Number(hidepid.slice('hidepid='.length)),
agentIsolationReason: 'shared-node-process-identity',
},
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,301 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
function digest(value) {
return createHash('sha256').update(value).digest()
}
function identifier(random = randomBytes, bytes = 32) {
return random(bytes).toString('base64url')
}
function sameDigest(value, expected) {
const actual = digest(value ?? '')
return actual.byteLength === expected.byteLength && timingSafeEqual(actual, expected)
}
function sessionClient(value) {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return Object.freeze({ ip: null, userAgent: null })
const bounded = (field, maximum) => typeof field === 'string' && field.length > 0 && field.length <= maximum
? field : null
return Object.freeze({
ip: bounded(value.ip, 128),
userAgent: bounded(value.userAgent, 512),
})
}
export class BrowserSessionStore {
constructor({
now = Date.now,
random = randomBytes,
dshAbsoluteMs = 30 * 24 * 60 * 60_000,
dshIdleMs = 7 * 24 * 60 * 60_000,
managementAbsoluteMs = 24 * 60 * 60_000,
managementIdleMs = 2 * 60 * 60_000,
activityReportMs = 5 * 60_000,
onEvent = () => {},
} = {}) {
this.now = now
this.random = random
this.policy = Object.freeze({
dsh: Object.freeze({ absoluteMs: dshAbsoluteMs, idleMs: dshIdleMs }),
management: Object.freeze({ absoluteMs: managementAbsoluteMs, idleMs: managementIdleMs }),
})
this.activityReportMs = activityReportMs
this.onEvent = onEvent
this.sessions = new Map()
}
setEventSink(onEvent) {
this.onEvent = typeof onEvent === 'function' ? onEvent : () => {}
}
emit(message, session, fields = {}) {
this.onEvent(message, {
accountId: session.accountId,
sessionId: session.sessionId,
kind: session.kind,
...fields,
})
}
prune() {
const now = this.now()
for (const [key, session] of this.sessions) {
const reason = session.expiresAt <= now
? 'absolute'
: session.lastSeenAt + session.idleMs <= now ? 'idle' : null
if (reason === null) continue
this.sessions.delete(key)
this.emit('access.session.expired', session, { reason })
}
for (const [key, session] of this.sessions) {
if (session.kind === 'management' && this.activeDshSession(session.sourceDshSessionId, session.accountId) === undefined) {
this.sessions.delete(key)
this.emit('access.session.invalidated', session, { reason: 'source-dsh-session' })
}
}
}
activeDshSession(sessionId, accountId) {
if (typeof sessionId !== 'string') return undefined
for (const session of this.sessions.values()) {
if (session.kind === 'dsh' && session.sessionId === sessionId && session.accountId === accountId) return session
}
return undefined
}
issue(kind, account, {
origin, sourceDshOrigin = null, sourceDshSessionId = null, client = null, authenticationSource = 'unknown',
} = {}) {
const policy = this.policy[kind]
if (policy === undefined) throw new TypeError('browser session kind is invalid')
if (typeof origin !== 'string' || origin.length === 0 || origin.length > 2_048) {
throw new TypeError('browser session origin is invalid')
}
this.prune()
const sourceDshSession = kind === 'management'
? this.activeDshSession(sourceDshSessionId, account.accountId)
: undefined
if (kind === 'management' && (sourceDshSession === undefined || sourceDshSession.origin !== sourceDshOrigin)) {
throw new TypeError('Management session requires an active DSH session')
}
const token = `${kind === 'dsh' ? 'dshs' : 'dshms'}_${identifier(this.random)}`
const csrfToken = `dshc_${identifier(this.random)}`
const sessionId = identifier(this.random, 16)
const createdAt = this.now()
const sourceClient = sourceDshSession === undefined ? sessionClient(client) : sourceDshSession.client
const expiresAt = Math.min(createdAt + policy.absoluteMs, sourceDshSession?.expiresAt ?? Number.POSITIVE_INFINITY)
this.sessions.set(digest(token).toString('hex'), {
sessionId,
kind,
accountId: account.accountId,
mainCredentialVersion: account.mainCredential.version,
managementAdditionalCredentialVersion: account.managementAdditionalCredential.enabled
? account.managementAdditionalCredential.version : null,
managementAccessVersion: account.managementAccess.version,
origin,
sourceDshOrigin,
sourceDshSessionId,
authenticationSource: sourceDshSession?.authenticationSource ?? authenticationSource,
client: sourceClient,
csrfDigest: digest(csrfToken),
createdAt,
lastSeenAt: createdAt,
activityReportedAt: createdAt,
expiresAt,
idleMs: policy.idleMs,
})
return Object.freeze({
token,
csrfToken,
sessionId,
expiresAt: new Date(expiresAt).toISOString(),
})
}
validate(token, kind, account, { origin, csrfToken, requireCsrf = false, touch = true } = {}) {
this.prune()
if (typeof token !== 'string' || token.length > 512 || this.policy[kind] === undefined) return undefined
const key = digest(token).toString('hex')
const session = this.sessions.get(key)
if (session === undefined || session.kind !== kind || session.accountId !== account.accountId
|| session.origin !== origin || (requireCsrf && !sameDigest(csrfToken, session.csrfDigest))) {
return undefined
}
let invalidationReason = null
if (session.mainCredentialVersion !== account.mainCredential.version) invalidationReason = 'main-credential-version'
else if (session.managementAccessVersion !== account.managementAccess.version) invalidationReason = 'management-access-version'
else if (kind === 'management'
&& session.managementAdditionalCredentialVersion !== (account.managementAdditionalCredential.enabled
? account.managementAdditionalCredential.version : null)) invalidationReason = 'management-credential-version'
if (invalidationReason !== null) {
this.sessions.delete(key)
this.emit('access.session.invalidated', session, { reason: invalidationReason })
return undefined
}
if (kind === 'management') {
const source = this.activeDshSession(session.sourceDshSessionId, account.accountId)
if (source === undefined || source.origin !== session.sourceDshOrigin
|| source.mainCredentialVersion !== account.mainCredential.version
|| source.managementAccessVersion !== account.managementAccess.version) {
this.sessions.delete(key)
this.emit('access.session.invalidated', session, { reason: 'source-dsh-session' })
return undefined
}
}
if (touch) {
const now = this.now()
session.lastSeenAt = now
if (now - session.activityReportedAt >= this.activityReportMs) {
session.activityReportedAt = now
this.emit('access.session.activity', session)
}
}
return Object.freeze({
sessionId: session.sessionId,
kind: session.kind,
accountId: session.accountId,
origin: session.origin,
sourceDshOrigin: session.sourceDshOrigin,
sourceDshSessionId: session.sourceDshSessionId,
createdAt: new Date(session.createdAt).toISOString(),
expiresAt: new Date(session.expiresAt).toISOString(),
})
}
revoke(token) {
if (typeof token !== 'string' || token.length > 512) return false
const key = digest(token).toString('hex')
const session = this.sessions.get(key)
if (!this.sessions.delete(key)) return false
if (session.kind === 'dsh') this.revokeManagementFromDsh(session.sessionId, 'source-dsh-session-revoked')
return true
}
revokeKind(kind) {
const dshSessionIds = []
let revoked = 0
for (const [key, session] of this.sessions) {
if (session.kind !== kind) continue
this.sessions.delete(key)
revoked += 1
if (kind === 'dsh') dshSessionIds.push(session.sessionId)
}
for (const sessionId of dshSessionIds) this.revokeManagementFromDsh(sessionId, 'source-dsh-session-revoked')
return revoked
}
countKind(kind) {
this.prune()
let count = 0
for (const session of this.sessions.values()) {
if (session.kind === kind) count += 1
}
return count
}
list(account, currentSessionId) {
this.prune()
const currentDshSessionId = this.sourceDshSessionId(currentSessionId) ?? currentSessionId
const managementSources = new Set([...this.sessions.values()]
.filter(session => session.kind === 'management')
.map(session => session.sourceDshSessionId))
return [...this.sessions.values()]
.filter(session => session.accountId === account.accountId
&& session.kind === 'dsh'
&& session.mainCredentialVersion === account.mainCredential.version
&& session.managementAccessVersion === account.managementAccess.version)
.map(session => Object.freeze({
sessionId: session.sessionId,
origin: session.origin,
current: session.sessionId === currentDshSessionId,
managementActive: managementSources.has(session.sessionId),
ip: session.client.ip,
userAgent: session.client.userAgent,
createdAt: new Date(session.createdAt).toISOString(),
lastSeenAt: new Date(session.lastSeenAt).toISOString(),
expiresAt: new Date(session.expiresAt).toISOString(),
}))
.sort((left, right) => right.lastSeenAt.localeCompare(left.lastSeenAt))
}
revokeDshSession(sessionId, accountId) {
if (typeof sessionId !== 'string' || typeof accountId !== 'string') return false
for (const [key, session] of this.sessions) {
if (session.kind !== 'dsh' || session.sessionId !== sessionId || session.accountId !== accountId) continue
this.sessions.delete(key)
this.revokeManagementFromDsh(session.sessionId, 'source-dsh-session-revoked')
return true
}
return false
}
revokeManagementFromDsh(sessionId, reason = 'source-dsh-session') {
let revoked = 0
for (const [key, session] of this.sessions) {
if (session.kind !== 'management' || session.sourceDshSessionId !== sessionId) continue
this.sessions.delete(key)
this.emit('access.session.invalidated', session, { reason })
revoked += 1
}
return revoked
}
sourceDshSessionId(sessionId) {
for (const session of this.sessions.values()) {
if (session.kind === 'management' && session.sessionId === sessionId) return session.sourceDshSessionId
}
return null
}
refreshDshSession(sessionId, account) {
const session = this.activeDshSession(sessionId, account.accountId)
if (session === undefined) return false
session.mainCredentialVersion = account.mainCredential.version
session.managementAccessVersion = account.managementAccess.version
this.emit('access.session.refreshed', session, { reason: 'management-access-change' })
return true
}
details(sessionId) {
this.prune()
for (const session of this.sessions.values()) {
if (session.sessionId !== sessionId) continue
return Object.freeze({
sessionId: session.sessionId,
kind: session.kind,
origin: session.origin,
sourceDshOrigin: session.sourceDshOrigin,
sourceDshSessionId: session.sourceDshSessionId,
authenticationSource: session.authenticationSource,
})
}
return undefined
}
revokeAll() {
const revoked = this.sessions.size
this.sessions.clear()
return revoked
}
}
@@ -0,0 +1,249 @@
import { randomBytes, randomUUID } from 'node:crypto'
import { chmod, mkdir, open, readFile, rename, rm } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { AccessError } from './errors.mjs'
import { createCredential, normalizeUsername, validCredential } from './credentials.mjs'
import { validTotpSecret } from './totp.mjs'
const STATES = new Set(['never-initialized', 'migration-required', 'initialized', 'recovery-required'])
function identifier(random = randomBytes) { return random(32).toString('base64url') }
function timestamp(now) { return now().toISOString() }
async function syncDirectory(root) {
const handle = await open(root, 'r')
try { await handle.sync() } finally { await handle.close() }
}
async function atomicJson(path, value, mode = 0o600) {
const temporary = join(dirname(path), `.${path.split('/').at(-1)}.${randomUUID()}.tmp`)
const handle = await open(temporary, 'wx', mode)
try {
await handle.writeFile(`${JSON.stringify(value)}\n`, 'utf8')
await handle.sync()
} finally { await handle.close() }
await chmod(temporary, mode)
await rename(temporary, path)
await syncDirectory(dirname(path))
}
async function createExclusiveJson(path, value, mode = 0o600) {
const handle = await open(path, 'wx', mode)
try {
await handle.writeFile(`${JSON.stringify(value)}\n`, 'utf8')
await handle.sync()
} finally { await handle.close() }
await chmod(path, mode)
await syncDirectory(dirname(path))
}
async function readJson(path) {
try { return { exists: true, value: JSON.parse(await readFile(path, 'utf8')) } }
catch (error) {
if (error?.code === 'ENOENT') return { exists: false, value: undefined }
return { exists: true, error }
}
}
export function validInitialization(value) {
return value !== null && typeof value === 'object' && value.schema === 1
&& typeof value.instanceId === 'string' && Buffer.from(value.instanceId, 'base64url').byteLength === 32
&& STATES.has(value.state) && Number.isFinite(Date.parse(value.createdAt)) && Number.isFinite(Date.parse(value.updatedAt))
}
export function validAccount(value) {
const additional = value?.managementAdditionalCredential
const totp = value?.totp
const access = value?.managementAccess
const validOrigin = origin => {
try {
const parsed = new URL(origin)
return ['http:', 'https:'].includes(parsed.protocol) && parsed.origin !== 'null'
&& parsed.username === '' && parsed.password === '' && parsed.pathname === '/'
&& parsed.search === '' && parsed.hash === '' && origin === parsed.origin
} catch { return false }
}
const additionalValid = additional !== null && typeof additional === 'object'
&& typeof additional.enabled === 'boolean'
&& Number.isInteger(additional.version) && additional.version >= 1
&& Number.isFinite(Date.parse(additional.changedAt))
&& (additional.enabled ? validCredential(additional.verifier) : additional.verifier === null)
const totpValid = totp === undefined || (totp !== null && typeof totp === 'object'
&& typeof totp.enabled === 'boolean'
&& Number.isInteger(totp.version) && totp.version >= 1
&& Number.isFinite(Date.parse(totp.changedAt))
&& (totp.enabled ? validTotpSecret(totp.secret) : totp.secret === null))
const isolatedEntryValid = access?.mode === 'compat'
? access.isolatedEntry === null && access.dshPublicOrigin === null
: access?.mode === 'isolated' && access.isolatedEntry !== null && typeof access.isolatedEntry === 'object'
&& validOrigin(access.dshPublicOrigin)
&& (access.isolatedEntry.kind === 'local-only' && validOrigin(access.isolatedEntry.managementLocalOrigin)
|| (access.isolatedEntry.kind === 'public' && (() => {
try {
const parsed = new URL(access.isolatedEntry.managementPublicOrigin)
return ['http:', 'https:'].includes(parsed.protocol) && parsed.origin !== 'null'
&& parsed.username === '' && parsed.password === '' && parsed.pathname === '/'
&& parsed.search === '' && parsed.hash === ''
&& access.isolatedEntry.managementPublicOrigin === parsed.origin
} catch { return false }
})()))
return value !== null && typeof value === 'object' && value.schema === 1
&& typeof value.revision === 'string' && Buffer.from(value.revision, 'base64url').byteLength === 32
&& typeof value.accountId === 'string' && Buffer.from(value.accountId, 'base64url').byteLength === 32
&& (() => { try { return normalizeUsername(value.username) === value.username } catch { return false } })()
&& validCredential(value.mainCredential)
&& additionalValid && totpValid && isolatedEntryValid
&& Number.isInteger(access?.version) && access.version >= 1
&& Number.isFinite(Date.parse(access.changedAt))
&& Number.isFinite(Date.parse(value.createdAt)) && Number.isFinite(Date.parse(value.updatedAt))
}
function validEvidence(evidence) {
if (evidence === null || typeof evidence !== 'object' || Array.isArray(evidence)) return false
const entries = Object.entries(evidence)
return entries.length > 0 && entries.every(([key, value]) => (
/^[a-z][A-Za-z0-9]{0,63}$/.test(key) && typeof value === 'boolean'
))
}
export class AccessStateStore {
constructor({ root, now = () => new Date(), random = randomBytes, createVerifier = createCredential } = {}) {
if (typeof root !== 'string') throw new TypeError('Access state root is required')
this.root = root
this.initializationPath = join(root, 'initialization.json')
this.accountPath = join(root, 'account.json')
this.now = now
this.random = random
this.createVerifier = createVerifier
}
async prepare() {
await mkdir(this.root, { recursive: true, mode: 0o700 })
await chmod(this.root, 0o700)
}
async inspect() {
const [initialization, account] = await Promise.all([
readJson(this.initializationPath), readJson(this.accountPath),
])
return {
initialization: initialization.error === undefined && validInitialization(initialization.value)
? initialization.value : undefined,
initializationExists: initialization.exists,
initializationDamaged: initialization.error !== undefined || (initialization.exists && !validInitialization(initialization.value)),
account: account.error === undefined && validAccount(account.value) ? account.value : undefined,
accountExists: account.exists,
accountDamaged: account.error !== undefined || (account.exists && !validAccount(account.value)),
}
}
async transition(initialization, state) {
const next = { ...initialization, state, updatedAt: timestamp(this.now) }
await atomicJson(this.initializationPath, next)
return next
}
async classify(evidence) {
if (!validEvidence(evidence)) throw new AccessError('CLASSIFICATION_EVIDENCE_INVALID', 'classification evidence is invalid')
await this.prepare()
const inspected = await this.inspect()
if (inspected.initialization !== undefined) {
if (inspected.initialization.state === 'never-initialized' && inspected.account !== undefined) {
const initialization = await this.transition(inspected.initialization, 'initialized')
return { initialization, account: inspected.account }
}
if (inspected.initialization.state === 'initialized' && inspected.account === undefined) {
const initialization = await this.transition(inspected.initialization, 'recovery-required')
return { initialization, account: undefined }
}
if (inspected.accountDamaged) {
const initialization = inspected.initialization.state === 'recovery-required'
? inspected.initialization : await this.transition(inspected.initialization, 'recovery-required')
return { initialization, account: undefined }
}
return { initialization: inspected.initialization, account: inspected.account }
}
if (inspected.initializationDamaged || inspected.accountExists) {
const initialization = {
schema: 1, instanceId: identifier(this.random), state: 'recovery-required',
createdAt: timestamp(this.now), updatedAt: timestamp(this.now),
}
await atomicJson(this.initializationPath, initialization)
return { initialization, account: undefined }
}
const persistentEvidence = Object.entries(evidence)
.some(([key, value]) => key !== 'legacyAuthenticationConfigured' && value)
const initialization = {
schema: 1,
instanceId: identifier(this.random),
state: persistentEvidence ? 'migration-required' : 'never-initialized',
createdAt: timestamp(this.now),
updatedAt: timestamp(this.now),
}
try { await createExclusiveJson(this.initializationPath, initialization) }
catch (error) {
if (error?.code === 'EEXIST') return this.classify(evidence)
throw error
}
return { initialization, account: undefined }
}
async state() {
const inspected = await this.inspect()
if (inspected.initialization === undefined) {
return { initialization: undefined, account: undefined, state: 'classification-pending' }
}
if (inspected.initialization.state === 'initialized' && inspected.account === undefined) {
return { initialization: inspected.initialization, account: undefined, state: 'recovery-required' }
}
return { initialization: inspected.initialization, account: inspected.account, state: inspected.initialization.state }
}
async createAccount({ username, password }, allowedState) {
const current = await this.state()
if (current.state !== allowedState) {
throw new AccessError('ALREADY_INITIALIZED', 'administrator initialization is unavailable', 409)
}
const normalized = normalizeUsername(username)
const now = timestamp(this.now)
const verifier = await this.createVerifier(password, { now: this.now, random: this.random })
const account = {
schema: 1,
revision: identifier(this.random),
accountId: identifier(this.random),
username: normalized,
mainCredential: verifier,
totp: { enabled: false, version: 1, secret: null, changedAt: now },
managementAdditionalCredential: { enabled: false, version: 1, verifier: null, changedAt: now },
managementAccess: {
mode: 'compat', version: 1, isolatedEntry: null, dshPublicOrigin: null, changedAt: now,
},
createdAt: now,
updatedAt: now,
}
await atomicJson(this.accountPath, account)
await this.transition(current.initialization, 'initialized')
return account
}
initialize(value) { return this.createAccount(value, 'never-initialized') }
migrate(value) { return this.createAccount(value, 'migration-required') }
recover(value) { return this.createAccount(value, 'recovery-required') }
async replaceAccount(account, revision) {
const current = await this.state()
if (current.state !== 'initialized' || current.account === undefined) {
throw new AccessError('ACCESS_NOT_INITIALIZED', 'administrator access is not initialized', 409)
}
if (current.account.revision !== revision) throw new AccessError('REVISION_CONFLICT', 'account revision changed', 409)
if (!validAccount(account)) throw new AccessError('ACCOUNT_INVALID', 'account record is invalid')
await atomicJson(this.accountPath, account)
return account
}
async clear() {
await rm(this.accountPath, { force: true })
}
}
@@ -0,0 +1,293 @@
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
const CODE_PATTERN = /^\d{6}$/
function digest(value) { return createHash('sha256').update(value).digest('hex') }
function token(prefix, random) { return `${prefix}_${random(32).toString('base64url')}` }
function base32Encode(value) {
let bits = 0
let buffer = 0
let output = ''
for (const byte of value) {
buffer = (buffer << 8) | byte
bits += 8
while (bits >= 5) {
output += BASE32_ALPHABET[(buffer >>> (bits - 5)) & 31]
bits -= 5
}
}
if (bits > 0) output += BASE32_ALPHABET[(buffer << (5 - bits)) & 31]
return output
}
function base32Decode(value) {
if (typeof value !== 'string' || value.length === 0 || /[^A-Z2-7]/u.test(value)) return undefined
let bits = 0
let buffer = 0
const output = []
for (const character of value) {
buffer = (buffer << 5) | BASE32_ALPHABET.indexOf(character)
bits += 5
if (bits >= 8) {
output.push((buffer >>> (bits - 8)) & 255)
bits -= 8
}
}
return Buffer.from(output)
}
function counterBuffer(counter) {
const value = Buffer.alloc(8)
value.writeBigUInt64BE(BigInt(counter))
return value
}
export function generateTotpSecret(random = randomBytes) {
return base32Encode(random(20))
}
export function validTotpSecret(secret) {
return base32Decode(secret)?.byteLength === 20
}
export function totpCode(secret, { now = Date.now(), offset = 0 } = {}) {
const key = base32Decode(secret)
if (key?.byteLength !== 20) throw new TypeError('TOTP secret is invalid')
const counter = Math.floor(now / 30_000) + offset
const hmac = createHmac('sha1', key).update(counterBuffer(counter)).digest()
const position = hmac.at(-1) & 15
const value = (hmac.readUInt32BE(position) & 0x7fffffff) % 1_000_000
return String(value).padStart(6, '0')
}
export function verifyTotpCode(code, secret, { now = Date.now(), window = 1 } = {}) {
if (typeof code !== 'string' || !CODE_PATTERN.test(code)) return false
const actual = Buffer.from(code)
for (let offset = -window; offset <= window; offset += 1) {
const expected = Buffer.from(totpCode(secret, { now, offset }))
if (timingSafeEqual(actual, expected)) return true
}
return false
}
export function totpUri({ secret, username, issuer = 'DSH Docker' }) {
if (!validTotpSecret(secret)) throw new TypeError('TOTP secret is invalid')
const label = `${issuer}:${username}`
const query = new URLSearchParams({ secret, issuer, algorithm: 'SHA1', digits: '6', period: '30' })
return `otpauth://totp/${encodeURIComponent(label)}?${query}`
}
export class TotpFlowStore {
constructor({ now = Date.now, random = randomBytes, enrollmentTtlMs = 5 * 60_000, loginTtlMs = 5 * 60_000 } = {}) {
this.now = now
this.random = random
this.enrollmentTtlMs = enrollmentTtlMs
this.loginTtlMs = loginTtlMs
this.enrollments = new Map()
this.disableConfirmations = new Map()
this.logins = new Map()
}
prune() {
const current = this.now()
for (const [key, value] of this.enrollments) if (value.expiresAt <= current) this.enrollments.delete(key)
for (const [key, value] of this.disableConfirmations) if (value.expiresAt <= current) this.disableConfirmations.delete(key)
for (const [key, value] of this.logins) if (value.expiresAt <= current) this.logins.delete(key)
}
createEnrollment(account, sessionId) {
this.prune()
for (const [key, value] of this.enrollments) {
if (value.accountId === account.accountId && value.sessionId === sessionId) this.enrollments.delete(key)
}
const value = token('dshte', this.random)
const secret = generateTotpSecret(this.random)
const expiresAt = this.now() + this.enrollmentTtlMs
this.enrollments.set(digest(value), {
accountId: account.accountId,
accountRevision: account.revision,
sessionId,
secret,
confirmed: false,
expiresAt,
})
return Object.freeze({ token: value, secret, expiresAt: new Date(expiresAt).toISOString() })
}
enrollment(value, account, sessionId) {
this.prune()
if (typeof value !== 'string' || value.length > 512) return undefined
const key = digest(value)
const enrollment = this.enrollments.get(key)
if (enrollment === undefined || enrollment.accountId !== account.accountId
|| enrollment.accountRevision !== account.revision || enrollment.sessionId !== sessionId) return undefined
return { key, value: enrollment }
}
cancelEnrollment(value, account, sessionId) {
const enrollment = this.enrollment(value, account, sessionId)
return enrollment === undefined ? false : this.enrollments.delete(enrollment.key)
}
confirmEnrollment(key) {
const enrollment = this.enrollments.get(key)
if (enrollment === undefined) return false
enrollment.confirmed = true
return true
}
consumeEnrollment(key) { return this.enrollments.delete(key) }
createDisableConfirmation(account, sessionId) {
this.prune()
for (const [key, value] of this.disableConfirmations) {
if (value.accountId === account.accountId && value.sessionId === sessionId) this.disableConfirmations.delete(key)
}
const value = token('dshtd', this.random)
const expiresAt = this.now() + this.enrollmentTtlMs
this.disableConfirmations.set(digest(value), {
accountId: account.accountId,
accountRevision: account.revision,
sessionId,
confirmed: false,
expiresAt,
})
return Object.freeze({ token: value, expiresAt: new Date(expiresAt).toISOString() })
}
disableConfirmation(value, account, sessionId) {
this.prune()
if (typeof value !== 'string' || value.length > 512) return undefined
const key = digest(value)
const confirmation = this.disableConfirmations.get(key)
if (confirmation === undefined || confirmation.accountId !== account.accountId
|| confirmation.accountRevision !== account.revision || confirmation.sessionId !== sessionId) return undefined
return { key, value: confirmation }
}
cancelDisableConfirmation(value, account, sessionId) {
const confirmation = this.disableConfirmation(value, account, sessionId)
return confirmation === undefined ? false : this.disableConfirmations.delete(confirmation.key)
}
confirmDisable(key) {
const confirmation = this.disableConfirmations.get(key)
if (confirmation === undefined) return false
confirmation.confirmed = true
return true
}
consumeDisableConfirmation(key) { return this.disableConfirmations.delete(key) }
createLogin(account, details) {
this.prune()
const value = token('dshtl', this.random)
const expiresAt = this.now() + this.loginTtlMs
this.logins.set(digest(value), {
accountId: account.accountId,
accountRevision: account.revision,
...details,
expiresAt,
})
return Object.freeze({ token: value, expiresAt: new Date(expiresAt).toISOString() })
}
login(value, account) {
this.prune()
if (typeof value !== 'string' || value.length > 512) return undefined
const key = digest(value)
const login = this.logins.get(key)
if (login === undefined || login.accountId !== account.accountId
|| login.accountRevision !== account.revision) return undefined
return { key, value: login }
}
consumeLogin(key) { return this.logins.delete(key) }
clear() {
this.enrollments.clear()
this.disableConfirmations.clear()
this.logins.clear()
}
}
export class TotpRetryLimiter {
constructor({
now = Date.now,
threshold = 3,
retryMs = 10_000,
dailyWindowMs = 24 * 60 * 60_000,
sourceDailyLimit = 25,
globalDailyLimit = 50,
} = {}) {
this.now = now
this.threshold = threshold
this.retryMs = retryMs
this.dailyWindowMs = dailyWindowMs
this.sourceDailyLimit = sourceDailyLimit
this.globalDailyLimit = globalDailyLimit
this.entries = new Map()
this.globalFailures = new Map()
}
key(accountId, source) { return `${accountId}\u0000${source}` }
prune(values) {
const cutoff = this.now() - this.dailyWindowMs
while (values.length > 0 && values[0] <= cutoff) values.shift()
}
retry(accountId, source) {
const entry = this.entries.get(this.key(accountId, source))
const global = this.globalFailures.get(accountId) ?? []
if (entry !== undefined) this.prune(entry.failuresInWindow)
this.prune(global)
const until = values => Math.max(1, Math.ceil((values[0] + this.dailyWindowMs - this.now()) / 1000))
if (global.length >= this.globalDailyLimit) return { kind: 'rate', retryAfterSeconds: until(global) }
if (entry?.failuresInWindow.length >= this.sourceDailyLimit) {
return { kind: 'rate', retryAfterSeconds: until(entry.failuresInWindow) }
}
if (entry !== undefined && entry.blockedUntil > this.now()) {
return { kind: 'retry', retryAfterSeconds: Math.max(1, Math.ceil((entry.blockedUntil - this.now()) / 1000)) }
}
return { kind: null, retryAfterSeconds: 0 }
}
fail(accountId, source) {
const key = this.key(accountId, source)
const entry = this.entries.get(key) ?? { consecutiveFailures: 0, blockedUntil: 0, failuresInWindow: [] }
const global = this.globalFailures.get(accountId) ?? []
this.prune(entry.failuresInWindow)
this.prune(global)
entry.consecutiveFailures += 1
entry.failuresInWindow.push(this.now())
global.push(this.now())
if (entry.consecutiveFailures >= this.threshold) entry.blockedUntil = this.now() + this.retryMs
this.entries.set(key, entry)
this.globalFailures.set(accountId, global)
return this.retry(accountId, source)
}
succeed(accountId, source) {
const entry = this.entries.get(this.key(accountId, source))
if (entry === undefined) return
entry.consecutiveFailures = 0
entry.blockedUntil = 0
}
clearDailyLimits(accountId, { globalOnly = false } = {}) {
let cleared = this.globalFailures.delete(accountId) ? 1 : 0
if (globalOnly) return { cleared }
for (const [key, entry] of this.entries) {
if (!key.startsWith(`${accountId}\u0000`)) continue
if (entry.failuresInWindow.length > 0) {
entry.failuresInWindow = []
cleared += 1
}
}
return { cleared }
}
}
@@ -0,0 +1,120 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
function digest(value) { return createHash('sha256').update(value).digest() }
function token(prefix, random, bytes = 32) { return `${prefix}_${random(bytes).toString('base64url')}` }
function sameDigest(value, expected) {
if (typeof value !== 'string' || expected === null) return false
const actual = digest(value)
return actual.byteLength === expected.byteLength && timingSafeEqual(actual, expected)
}
export class ManagementTransitionStore {
constructor({ now = Date.now, random = randomBytes, ttlMs = 30_000 } = {}) {
this.now = now
this.random = random
this.ttlMs = ttlMs
this.transitions = new Map()
this.continuations = new Map()
}
prune() {
const now = this.now()
for (const [id, value] of this.transitions) if (value.expiresAt <= now) this.transitions.delete(id)
for (const [key, value] of this.continuations) if (value.expiresAt <= now) this.continuations.delete(key)
}
create({ account, instanceId, sessionId, sourceOrigin, sourceDshOrigin, sourceDshSessionId, mode, isolatedEntry, candidateOrigin }) {
this.prune()
const transitionId = token('dshmt', this.random, 16)
const nonce = candidateOrigin === null ? null : token('dshmn', this.random)
const expiresAt = this.now() + this.ttlMs
this.transitions.set(transitionId, {
accountId: account.accountId,
accountRevision: account.revision,
managementAccessVersion: account.managementAccess.version,
instanceId,
sessionId,
sourceOrigin,
sourceDshOrigin,
sourceDshSessionId,
mode,
isolatedEntry,
candidateOrigin,
nonceDigest: nonce === null ? null : digest(nonce),
proofDigest: null,
expiresAt,
})
return Object.freeze({
transitionId,
nonce,
candidateOrigin,
expiresAt: new Date(expiresAt).toISOString(),
})
}
probe({ transitionId, nonce, candidateOrigin, sourceOrigin, instanceId }) {
this.prune()
const transition = this.transitions.get(transitionId)
if (transition === undefined || transition.candidateOrigin !== candidateOrigin
|| transition.sourceOrigin !== sourceOrigin || transition.instanceId !== instanceId
|| !sameDigest(nonce, transition.nonceDigest)) return undefined
transition.nonceDigest = null
const proof = token('dshmpf', this.random)
transition.proofDigest = digest(proof)
return Object.freeze({ proof, expiresAt: new Date(transition.expiresAt).toISOString() })
}
inspect({ transitionId, proof, account, sessionId }) {
this.prune()
const transition = this.transitions.get(transitionId)
if (transition === undefined || transition.accountId !== account.accountId
|| transition.accountRevision !== account.revision
|| transition.managementAccessVersion !== account.managementAccess.version
|| transition.sessionId !== sessionId
|| (transition.candidateOrigin !== null && !sameDigest(proof, transition.proofDigest))) return undefined
return transition
}
consume(value) {
const transition = this.inspect(value)
if (transition === undefined) return undefined
const { transitionId } = value
this.transitions.delete(transitionId)
return transition
}
createContinuation({ account, targetOrigin, sourceDshOrigin, sourceDshSessionId }) {
if (targetOrigin === null) return null
this.prune()
const continuation = token('dshmc', this.random)
const expiresAt = this.now() + this.ttlMs
this.continuations.set(digest(continuation).toString('hex'), {
accountId: account.accountId,
accountRevision: account.revision,
managementAccessVersion: account.managementAccess.version,
targetOrigin,
sourceDshOrigin,
sourceDshSessionId,
expiresAt,
})
return Object.freeze({ token: continuation, expiresAt: new Date(expiresAt).toISOString() })
}
consumeContinuation({ token: value, account, targetOrigin }) {
this.prune()
if (typeof value !== 'string' || value.length > 512) return undefined
const key = digest(value).toString('hex')
const continuation = this.continuations.get(key)
this.continuations.delete(key)
if (continuation === undefined || continuation.accountId !== account.accountId
|| continuation.accountRevision !== account.revision
|| continuation.managementAccessVersion !== account.managementAccess.version
|| continuation.targetOrigin !== targetOrigin) return undefined
return continuation
}
clear() {
this.transitions.clear()
this.continuations.clear()
}
}
@@ -0,0 +1,49 @@
#!/usr/bin/env node
import { LocalApiClient } from '../../modules/updater/lib/client.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 launch = new LocalApiClient(paths.accessLaunchSocket)
if (process.argv[2] === 'health') {
const status = await launch.request('GET', '/v1/status')
process.exit(status.componentReady === true ? 0 : 1)
}
const token = process.env.DSH_ACCESS_LAUNCH_TOKEN
delete process.env.DSH_ACCESS_LAUNCH_TOKEN
if (typeof token !== 'string' || token.length < 32) throw new Error('Access Manager launch token is unavailable')
await launch.request('POST', '/v1/start', { token })
let stopping = false
let resolveStop
const stopped = new Promise(resolve => { resolveStop = resolve })
const stop = () => {
if (stopping) return
stopping = true
void launch.request('POST', '/v1/stop', { token }).catch(() => {}).finally(resolveStop)
}
process.once('SIGINT', stop)
process.once('SIGTERM', stop)
const poll = setInterval(() => {
void launch.request('GET', '/v1/status').then(status => {
if (!stopping && status.running !== true) {
clearInterval(poll)
process.exitCode = 1
resolveStop()
}
}, () => {
if (!stopping) {
clearInterval(poll)
process.exitCode = 1
resolveStop()
}
})
}, 500)
await stopped
clearInterval(poll)
@@ -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('"', '&quot;')}">${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
@@ -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',
}))
})
@@ -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',
})
})
@@ -0,0 +1,442 @@
import { access, rm } from 'node:fs/promises'
import { createInterface } from 'node:readline/promises'
import { stdin, stdout } from 'node:process'
import { LocalApiClient } from '../../modules/updater/lib/client.mjs'
import { PlatformPaths } from '../../../platform/lib/paths.mjs'
const API_PREFIX = '/_dsh_platform/api/v1'
function usage() {
return `usage: dsh-platform <command> [options]
commands:
status
check
update [--wait]
start|stop|restart [--wait]
channel [stable|experimental]
retry|rollback|return-stable
recover
logs [--source NAME] [--since ISO] [--limit N]
access status|reset|set-username|reset-password
access reset-management-password|disable-management-password|disable-two-factor|clear-sessions [--management-only]|generate-key
access clear-retry [--global-only] [--main-password|--management-password|--two-factor]
trust status|reset`
}
export function parseCli(argv) {
const [command, ...rest] = argv
if (['status', 'check', 'retry', 'rollback', 'return-stable', 'recover'].includes(command)) {
if (rest.length !== 0) throw new Error(usage())
return { command }
}
if (command === 'channel' && (rest.length === 0 || (rest.length === 1 && ['stable', 'experimental'].includes(rest[0])))) {
return { command, channel: rest[0] }
}
if (['update', 'start', 'stop', 'restart'].includes(command)) {
if (rest.some(value => value !== '--wait') || rest.filter(value => value === '--wait').length > 1) throw new Error(usage())
return { command, wait: rest.includes('--wait') }
}
if (command === 'logs') {
const options = { command, sources: [] }
for (let index = 0; index < rest.length; index += 2) {
const name = rest[index]
const value = rest[index + 1]
if (value === undefined) throw new Error(usage())
if (name === '--source') options.sources.push(value)
else if (name === '--since') options.since = value
else if (name === '--limit') options.limit = Number(value)
else throw new Error(usage())
}
return options
}
if (command === 'trust' && rest.length === 1 && ['status', 'reset'].includes(rest[0])) {
return { command, operation: rest[0] }
}
if (command === 'access' && rest.length === 1 && ['status', 'reset', 'set-username', 'reset-password', 'reset-management-password', 'disable-management-password', 'disable-two-factor', 'clear-sessions', 'generate-key', 'image-baseline', 'clear-retry'].includes(rest[0])) {
return { command, operation: rest[0] }
}
if (command === 'access' && rest[0] === 'clear-retry') {
const options = rest.slice(1)
const credentialOptions = options.filter(value => ['--main-password', '--management-password', '--two-factor'].includes(value))
if (options.length > 0 && options.length <= 2 && new Set(options).size === options.length
&& credentialOptions.length <= 1
&& options.every(value => ['--global-only', '--main-password', '--management-password', '--two-factor'].includes(value))) {
return {
command,
operation: 'clear-retry',
...(options.includes('--global-only') ? { globalOnly: true } : {}),
...(options.includes('--main-password') ? { credential: 'main' } : {}),
...(options.includes('--management-password') ? { credential: 'management' } : {}),
...(options.includes('--two-factor') ? { credential: 'totp' } : {}),
}
}
}
if (command === 'access' && rest[0] === 'clear-sessions') {
const options = rest.slice(1)
if (options.length <= 1 && (options.length === 0 || options[0] === '--management-only')) {
return { command, operation: 'clear-sessions', ...(options.length === 1 ? { managementOnly: true } : {}) }
}
}
throw new Error(usage())
}
export async function recoverImageBaseline({
recovery,
input = stdin,
output = stdout,
getuid = () => process.getuid?.(),
ask,
} = {}) {
if (getuid() !== 0) throw new Error('image baseline recovery must run as root from the container console')
if (!input.isTTY || !output.isTTY) throw new Error('image baseline recovery requires an interactive container console')
const status = await recovery.request('GET', '/v1/status')
const imageBuildId = status.imageBaseline?.imageBuildId
if (typeof imageBuildId !== 'string') throw new Error('image baseline is unavailable')
const expected = `RECOVER IMAGE BASELINE ${imageBuildId}`
let answer
if (ask !== undefined) answer = await ask(expected, status)
else {
const prompt = createInterface({ input, output })
answer = await prompt.question(
`Current Deployment may be incompatible with image ${status.imageBaseline.dsh}. Type ${expected}: `,
)
prompt.close()
}
if (answer !== expected) throw new Error('image baseline recovery cancelled')
return recovery.request('POST', '/v1/recover-image-baseline', { confirm: imageBuildId })
}
export async function resetTrust({
dataRoot = '/data/platform',
runRoot = '/run/dsh-platform',
input = stdin,
output = stdout,
getuid = () => process.getuid?.(),
} = {}) {
if (getuid() !== 0) throw new Error('trust reset must run as root from the container console')
if (!input.isTTY || !output.isTTY) throw new Error('trust reset requires an interactive container console')
const paths = new PlatformPaths(dataRoot, runRoot)
const socket = paths.trustSocket
if (await access(socket).then(() => true, () => false)) throw new Error('stop Stage-0 before resetting trust')
const prompt = createInterface({ input, output })
const answer = await prompt.question('Type RESET DSH TRUST to clear accepted trust state: ')
prompt.close()
if (answer !== 'RESET DSH TRUST') throw new Error('trust reset cancelled')
await rm(paths.trustStateRoot, { recursive: true, force: true })
return { status: 'reset', recoveryRoot: 'image' }
}
function queryString(options) {
const query = new URLSearchParams()
for (const source of options.sources ?? []) query.append('source', source)
if (options.since !== undefined) query.set('since', options.since)
if (options.limit !== undefined) query.set('limit', String(options.limit))
const value = query.toString()
return value === '' ? '' : `?${value}`
}
function json(value) {
return JSON.stringify(value)
}
function managementPasswordAction(value) {
const normalized = value.trim().toLowerCase()
if (['', 'n'].includes(normalized)) return 'preserve'
if (normalized === 'd') return 'disable'
if (normalized === 'y') return 'reset'
throw new Error('management password choice must be y, n, or d')
}
function affirmative(value, label) {
const normalized = value.trim().toLowerCase()
if (normalized === 'y') return true
if (['', 'n'].includes(normalized)) return false
throw new Error(`${label} choice must be y or n`)
}
export async function readSecret(input, output, prompt) {
output.write(prompt)
if (typeof input.setRawMode !== 'function') throw new Error('password input must be an interactive TTY')
input.setRawMode(true)
input.resume()
return new Promise((resolve, reject) => {
let value = ''
const cleanup = () => {
input.off('data', onData)
input.off('end', onEnd)
input.off('error', onError)
input.setRawMode(false)
input.pause()
}
const finish = (error, result) => {
cleanup()
if (error === undefined) resolve(result)
else reject(error)
}
const onData = chunk => {
for (const character of String(chunk)) {
if (character === '\r' || character === '\n') {
output.write('\n')
finish(undefined, value)
return
}
if (character === '\u0003') {
finish(new Error('access recovery cancelled'))
return
}
if (character === '\u007f') value = value.slice(0, -1)
else if (character >= ' ') value += character
}
}
const onEnd = () => finish(new Error('password input ended before confirmation'))
const onError = error => finish(error)
input.on('data', onData)
input.once('end', onEnd)
input.once('error', onError)
})
}
const RETRYABLE_CONTROL_PLANE_ERRORS = new Set(['ENOENT', 'ECONNREFUSED', 'ECONNRESET', 'EPIPE'])
function isRetryableControlPlaneError(error) {
return RETRYABLE_CONTROL_PLANE_ERRORS.has(error?.code)
}
export async function runCli({
argv = process.argv.slice(2),
management = new LocalApiClient(process.env.DSH_PLATFORM_MANAGEMENT_CLI_SOCKET ?? '/run/dsh-platform/management-cli.sock'),
trust = new LocalApiClient(process.env.DSH_PLATFORM_TRUST_SOCKET ?? '/run/dsh-platform/stage0-trust.sock'),
reset = resetTrust,
recovery = new LocalApiClient(process.env.DSH_PLATFORM_RECOVERY_SOCKET ?? '/run/dsh-platform/recovery.sock'),
access = new LocalApiClient(process.env.DSH_PLATFORM_ACCESS_RECOVERY_SOCKET ?? '/run/dsh-platform/access/recovery.sock'),
recover = recoverImageBaseline,
write = value => process.stdout.write(`${value}\n`),
delay = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)),
input = stdin,
output = stdout,
getuid = () => process.getuid?.(),
readPassword = readSecret,
ask = async question => {
const prompt = createInterface({ input, output })
try { return await prompt.question(question) } finally { prompt.close() }
},
} = {}) {
const parsed = parseCli(argv)
if (parsed.command === 'recover') {
if (getuid() !== 0) throw new Error('image baseline recovery must run as root from the container console')
if (!input.isTTY || !output.isTTY) throw new Error('image baseline recovery requires an interactive container console')
write(json(await recover({ recovery, input, output })))
return 0
}
if (parsed.command === 'access') {
if (getuid() !== 0) throw new Error('access recovery requires root')
if (!input.isTTY || !output.isTTY) throw new Error('access recovery requires an interactive container console')
const current = await access.request('GET', '/v1/recovery/status')
if (parsed.operation === 'status') {
write(json(current))
return 0
}
const confirm = async question => affirmative(await ask(question), 'confirmation')
const cancel = () => {
write(json({ status: 'cancelled' }))
return 0
}
if (parsed.operation === 'generate-key') {
if (!await confirm('Generate an administrator authentication reset key? y/[n]: ')) return cancel()
write(json(await access.request('POST', '/v1/recovery/generate-key')))
return 0
}
if (parsed.operation === 'clear-sessions') {
const question = parsed.managementOnly
? 'Clear all Management console sessions? y/[n]: '
: 'Clear all DSH and Management console sessions? y/[n]: '
if (!await confirm(question)) return cancel()
write(json(await access.request('POST', '/v1/recovery/clear-sessions', {
managementOnly: parsed.managementOnly === true,
})))
return 0
}
if (parsed.operation === 'clear-retry') {
const credentialLabel = parsed.credential === 'main'
? 'main password' : parsed.credential === 'management' ? 'Management console password'
: parsed.credential === 'totp' ? 'two-factor' : 'all administrator'
const question = parsed.credential === 'totp'
? parsed.globalOnly
? 'Clear the instance-wide two-factor daily limit? y/[n]: '
: 'Clear two-factor daily limits? The fixed 10-second retry remains active. y/[n]: '
: parsed.globalOnly
? 'Clear instance-wide administrator login rate limits? y/[n]: '
: `Clear ${credentialLabel} login retry limits? y/[n]: `
if (!await confirm(question)) return cancel()
const authenticationRetry = await access.request('POST', '/v1/recovery/clear-retry', {
scope: parsed.globalOnly ? 'global' : 'all',
...(parsed.credential === undefined ? {} : { credential: parsed.credential }),
})
write(json(authenticationRetry))
return 0
}
if (current.account === null) throw new Error('administrator account is unavailable')
const revision = current.account.revision
if (parsed.operation === 'set-username') {
if (!await confirm('Change administrator username? y/[n]: ')) return cancel()
const username = await ask('New administrator username: ')
write(json(await access.request('POST', '/v1/recovery/set-username', { revision, username })))
return 0
}
if (parsed.operation === 'disable-management-password') {
if (!await confirm('Disable management password? y/[n]: ')) return cancel()
write(json(await access.request('POST', '/v1/recovery/disable-management-password', { revision })))
return 0
}
if (parsed.operation === 'disable-two-factor') {
if (!await confirm('Disable two-factor authentication? y/[n]: ')) return cancel()
write(json(await access.request('POST', '/v1/recovery/reset-access', {
revision,
managementPasswordAction: 'preserve',
totpAction: 'disable',
})))
return 0
}
if (parsed.operation === 'reset') {
let username
let password
if (affirmative(await ask('Change administrator username? y/[n]: '), 'username')) {
username = await ask('New administrator username: ')
}
if (affirmative(await ask('Change administrator password? y/[n]: '), 'password')) {
password = await readPassword(input, output, 'New administrator password: ')
}
let action = 'preserve'
let managementPassword
if (current.account.managementAdditionalCredential.enabled) {
action = managementPasswordAction(await ask(
'Change Management console password? (y)es/[n]o/(d)isable: ',
))
if (action === 'reset') {
managementPassword = await readPassword(input, output, 'New management password: ')
}
}
let totpAction = 'preserve'
if (current.account.totp?.enabled === true) {
if (affirmative(await ask('Disable two-factor authentication? y/[n]: '), 'two-factor')) {
totpAction = 'disable'
}
}
if (username === undefined && password === undefined && action === 'preserve' && totpAction === 'preserve') return cancel()
write(json(await access.request('POST', '/v1/recovery/reset-access', {
revision,
...(username === undefined ? {} : { username }),
...(password === undefined ? {} : { password }),
managementPasswordAction: action,
...(totpAction === 'preserve' ? {} : { totpAction }),
...(managementPassword === undefined ? {} : { managementPassword }),
})))
return 0
}
const confirmed = parsed.operation === 'reset-management-password'
? await confirm('Set or change management password? y/[n]: ')
: await confirm('Change administrator password? y/[n]: ')
if (!confirmed) return cancel()
const password = await readPassword(input, output, parsed.operation === 'reset-management-password'
? 'New management password: ' : 'New administrator password: ')
const route = parsed.operation === 'reset-management-password'
? '/v1/recovery/reset-management-password' : '/v1/recovery/reset-password'
const value = await access.request('POST', route, { revision, password })
write(json(value))
return 0
}
if (parsed.command === 'trust') {
const value = parsed.operation === 'status' ? await trust.status() : await reset()
write(json(value))
return 0
}
if (parsed.command === 'logs') {
const value = await management.request('GET', `${API_PREFIX}/logs${queryString(parsed)}`)
for (const entry of value.entries) write(JSON.stringify(entry))
return 0
}
if (parsed.command === 'update') {
const started = await management.request('POST', `${API_PREFIX}/update`)
write(json(started))
if (!parsed.wait) return 0
let controlPlaneRestarted = false
for (;;) {
let value
try {
value = await management.request('GET', `${API_PREFIX}/status`)
} catch (error) {
if (!isRetryableControlPlaneError(error)) throw error
controlPlaneRestarted = true
await delay(1_000)
continue
}
const terminal = ['success', 'failed'].includes(value.update.status)
const sameTask = value.update.taskId === started.taskId
const resumedTask = controlPlaneRestarted
&& value.update.taskId !== null
&& value.update.operation === 'update'
if (terminal && (sameTask || resumedTask)) {
write(json(value.update))
return value.update.status === 'success' ? 0 : 1
}
await delay(1_000)
}
}
if (['start', 'stop', 'restart'].includes(parsed.command)) {
const started = await management.request('POST', `${API_PREFIX}/${parsed.command}-dsh`)
write(json(started))
if (!parsed.wait) return 0
for (;;) {
const value = await management.request('GET', `${API_PREFIX}/status`)
const lifecycle = value.dshLifecycle
const successState = parsed.command === 'stop' ? 'stopped' : 'running'
if (lifecycle.taskId === started.taskId && [successState, 'failed'].includes(lifecycle.state)) {
write(json(lifecycle))
return lifecycle.state === successState ? 0 : 1
}
await delay(1_000)
}
}
if (parsed.command === 'channel') {
if (parsed.channel === undefined) {
const value = await management.request('GET', `${API_PREFIX}/status`)
write(value.updateChannel)
} else {
const value = await management.request('PUT', `${API_PREFIX}/channel`, { channel: parsed.channel })
write(value.updateChannel)
}
return 0
}
if (parsed.command === 'retry') {
const status = await management.request('GET', `${API_PREFIX}/status`)
const choices = [...status.holds, ...(status.experimentalBlocked === null ? [] : [status.experimentalBlocked])]
const unique = [...new Map(choices.map(value => [value.id, value])).values()]
if (unique.length !== 1) throw new Error('retry requires exactly one active Hold or Blocked combination')
const value = await management.request('POST', `${API_PREFIX}/holds/retry`, { id: unique[0].id })
write(json(value))
return 0
}
if (parsed.command === 'rollback' || parsed.command === 'return-stable') {
const { plan } = await management.request('GET', `${API_PREFIX}/rollback-plan`)
if (plan === null) throw new Error('no complete rollback plan is available')
let confirmDataLoss = false
if (parsed.command === 'return-stable') {
if (!input.isTTY || !output.isTTY) throw new Error('return-stable requires an interactive container console')
const prompt = createInterface({ input, output })
const answer = await prompt.question(`Restore data snapshot from ${plan.snapshot.createdAt}? Type RETURN STABLE AND LOSE NEWER DATA: `)
prompt.close()
if (answer !== 'RETURN STABLE AND LOSE NEWER DATA') throw new Error('return-stable cancelled')
confirmDataLoss = true
}
const value = await management.request('POST', `${API_PREFIX}/${parsed.command}`, {
planId: plan.planId,
...(parsed.command === 'return-stable' ? { confirmDataLoss } : {}),
})
write(json(value))
return 0
}
const method = parsed.command === 'status' ? 'GET' : 'POST'
const value = await management.request(method, `${API_PREFIX}/${parsed.command}`)
write(json(value))
return 0
}
@@ -0,0 +1,30 @@
{
"schema": 1,
"id": "platform-management",
"type": "service",
"command": {
"executable": "/usr/local/bin/node",
"args": ["/run/dsh-platform/views/bootstrap/control-plane/services/management/index.mjs"],
"timeoutSeconds": 30
},
"environment": {},
"lifecycle": {
"prepare": null,
"preStart": null,
"postStart": null,
"preStop": null,
"stop": null,
"postStop": null
},
"health": {
"type": "exec",
"command": {
"executable": "/usr/local/bin/node",
"args": ["/run/dsh-platform/views/bootstrap/control-plane/services/management/healthcheck.mjs"],
"timeoutSeconds": 5
},
"intervalSeconds": 1,
"timeoutSeconds": 30
},
"logging": { "stdout": true, "stderr": true }
}
@@ -0,0 +1,10 @@
#!/usr/bin/env node
import { runCli } from './cli.mjs'
try {
process.exitCode = await runCli()
} catch (error) {
console.error(error instanceof Error ? error.message : 'dsh-platform failed')
process.exitCode = 1
}
@@ -0,0 +1,20 @@
import { request } from 'node:http'
const socketPath = process.env.DSH_PLATFORM_MANAGEMENT_SOCKET ?? '/run/dsh-platform/management.sock'
const status = await new Promise((resolve, reject) => {
const probe = request({
socketPath,
path: '/_dsh_platform/internal/health',
method: 'GET',
timeout: 2_000,
}, response => {
response.resume()
resolve(response.statusCode)
})
probe.once('timeout', () => probe.destroy(new Error('management health check timed out')))
probe.once('error', reject)
probe.end()
})
if (status !== 200) throw new Error(`management health check returned ${String(status)}`)
@@ -0,0 +1,437 @@
#!/usr/bin/env node
import { readFile } from 'node:fs/promises'
import { randomBytes } from 'node:crypto'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { JsonlLogManager } from '../../modules/log-manager/index.mjs'
import { createManagementServer, listenManagement } from './server.mjs'
import { LocalApiClient } from '../../modules/updater/lib/client.mjs'
import { consumeInternalCapability } from '../../../platform/lib/access-capability.mjs'
import { MetadataClient, NpmRegistryClient } from '../../modules/updater/lib/metadata.mjs'
import { TargetPreparer } from '../../modules/updater/lib/preparer.mjs'
import { UpdateCoordinator } from '../../modules/updater/lib/coordinator.mjs'
import { UpdateScheduler } from '../../modules/updater/lib/scheduler.mjs'
import { UpdateStateStore } from '../../modules/updater/lib/state.mjs'
import { AutomaticCheckStateStore } from '../../modules/updater/lib/automatic-check.mjs'
import { PlatformActivator } from '../../modules/updater/lib/activator.mjs'
import { UpdateJournal } from '../../modules/updater/lib/journal.mjs'
import { SnapshotClient } from '../../modules/updater/lib/snapshot-client.mjs'
import { reconcileRecoveredState, resumeInterruptedReconcile } from '../../modules/updater/lib/recovery.mjs'
import { ChannelStateStore } from '../../modules/updater/lib/channel-state.mjs'
import { CompleteStateRecovery } from '../../modules/updater/lib/rollback.mjs'
import { PlatformPaths } from '../../../platform/lib/paths.mjs'
import { readDeploymentStatus } from '../../../platform/lib/deployment-status.mjs'
import { parseImageInventory } from '../../../platform/lib/deployment-contracts.mjs'
import { SettingsDocumentStore } from './settings-document.mjs'
import { markUserPluginRestartState, UserPluginInventory } from '../../modules/plugin-manager/user-inventory.mjs'
import { UserPluginJournal } from '../../modules/plugin-manager/user-journal.mjs'
import { UserPluginSelectionStore } from '../../modules/plugin-manager/user-state.mjs'
import { UserPluginTransactionManager } from '../../modules/plugin-manager/user-transaction.mjs'
import { UserSkillManager } from './user-skills.mjs'
import { createScopedFetch } from './scoped-fetch.mjs'
import { OutboundProxyControlClient } from './outbound-proxy-client.mjs'
import { ProviderInventory } from './provider-inventory.mjs'
import { DshUpstreamAuthentication } from '../gateway/lib/dsh-upstream-auth.mjs'
import { PROXY_SCOPE_CATALOG } from '../outbound-proxy/lib/scope-catalog.mjs'
import { defaultProxyConfiguration } from '../outbound-proxy/lib/contracts.mjs'
import { requestOrigin } from '../gateway/lib/browser-auth.mjs'
import { createRestrictedCliServer, restrictedCliRoute } from './restricted-cli.mjs'
import qrcode from 'qrcode-generator'
const dataRoot = process.env.DSH_PLATFORM_DATA ?? '/data/platform'
const runRoot = process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform'
const paths = new PlatformPaths(dataRoot, runRoot)
const seedRoot = process.env.DSH_PLATFORM_SEED ?? '/opt/dsh-platform/seed'
const imageInventory = parseImageInventory(await readFile(join(seedRoot, 'inventory.json')))
const trust = new LocalApiClient(paths.trustSocket)
const bootstrap = new LocalApiClient(paths.bootstrapSocket)
const access = new LocalApiClient(paths.accessSocket)
const snapshotApi = new LocalApiClient(paths.snapshotSocket)
const userSkillApi = new LocalApiClient(paths.userSkillSocket)
const outboundProxy = new OutboundProxyControlClient(paths.proxyControlSocket)
const dshHome = process.env.DSH_HOME ?? '/data/dsh'
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 },
})
const updateFetch = createScopedFetch('updates')
const dshUpstreamAuthentication = new DshUpstreamAuthentication({ socketPath: paths.dshLifecycleSocket })
const providerInventory = new ProviderInventory({
cachePath: paths.proxyProviderInventoryPath,
authentication: dshUpstreamAuthentication,
})
function qrCodeDataUri(value) {
const code = qrcode(0, 'M')
code.addData(value, 'Byte')
code.make()
const svg = code.createSvgTag({ cellSize: 5, margin: 10, scalable: true })
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
}
let lastProxyConfiguration
function unavailableProxyConfiguration() {
const fallback = lastProxyConfiguration ?? {
...defaultProxyConfiguration(),
revision: null,
routeHealth: {},
lastTest: null,
}
return Object.freeze({
...fallback,
componentReady: false,
configurationAvailable: lastProxyConfiguration !== undefined,
scopeCatalog: PROXY_SCOPE_CATALOG,
})
}
function proxySnapshot(view) {
return Object.freeze({
revision: view.revision,
configuration: Object.freeze({
schema: view.schema,
enabled: view.enabled,
proxy: view.proxy,
scopes: view.scopes,
environment: view.environment,
modelApi: view.modelApi,
noProxy: view.noProxy,
bypass: view.bypass,
}),
})
}
const proxyConfiguration = async () => {
try {
lastProxyConfiguration = await outboundProxy.configuration()
return Object.freeze({
...lastProxyConfiguration,
configurationAvailable: true,
scopeCatalog: PROXY_SCOPE_CATALOG,
})
} catch (error) {
if (error?.code === 'PROXY_MANAGER_UNAVAILABLE') return unavailableProxyConfiguration()
throw error
}
}
void proxyConfiguration().catch(error => logs.diagnostic('platform-management', 'proxy.configuration.cache.failed', { error }))
logs.on('error', error => { void logs.diagnostic('log-manager', 'capture.failed', { error }) })
await logs.diagnostic('platform-management', 'management.starting', {
imageBuildId: imageInventory.imageBuildId,
targetSequence: imageInventory.targetSequence,
})
const metadata = new MetadataClient({
baseUrl: process.env.DSH_UPDATE_METADATA_URL,
trust,
fetchImpl: updateFetch,
retryRequestTimeoutMs: 15_000,
})
const preparer = new TargetPreparer({ untrustedRoot: paths.downloadsRoot, trust, fetchImpl: updateFetch })
const activator = new PlatformActivator({ dataRoot, runRoot, stage0: trust })
const journal = new UpdateJournal(join(paths.updaterStateRoot, 'transaction.json'))
const snapshots = new SnapshotClient(snapshotApi, 'dsh')
const completeRecovery = new CompleteStateRecovery({ journal, snapshots, activator })
const automaticChecks = new AutomaticCheckStateStore(join(paths.updaterStateRoot, 'automatic-check.json'))
const coordinator = new UpdateCoordinator({
metadata,
preparer,
activator,
state: new UpdateStateStore(join(paths.updaterStateRoot, 'status.json')),
npm: new NpmRegistryClient({ fetchImpl: updateFetch, retryRequestTimeoutMs: 15_000 }),
journal,
snapshots,
probationSeconds: Number(process.env.DSH_EXPERIMENTAL_PROBATION_SECONDS ?? 120),
channelState: new ChannelStateStore(join(paths.updaterStateRoot, 'channel.json')),
completeRecovery,
automaticChecks,
allowUnavailableMetadata: imageInventory.authority === 'development',
report: (message, fields) => logs.diagnostic('updater', message, fields),
})
const settingsDocument = new SettingsDocumentStore(dshHome)
const listBundledPlugins = async () => (await bootstrap.request('GET', '/v1/system-plugins')).plugins
const listSystemSkills = async () => (await bootstrap.request('GET', '/v1/system-skills')).skills
const userSkills = new UserSkillManager({
dshHome,
agentsHome: process.env.DSH_AGENTS_HOME ?? '/home/node/.agents',
})
const userPluginInventory = new UserPluginInventory({
dshHome,
selectionPath: paths.userPluginStatePath,
systemPluginNames: async () => (await listBundledPlugins()).map(plugin => `@dsh-docker/${plugin.id}`),
})
const userPluginTransactions = new UserPluginTransactionManager({
inventory: userPluginInventory,
selectionStore: new UserPluginSelectionStore(paths.userPluginStatePath),
snapshots: new SnapshotClient(snapshotApi, 'user-plugin'),
journal: new UserPluginJournal(paths.userPluginJournalPath),
pauseDsh: () => bootstrap.request('POST', '/v1/components/dsh-runtime/pause'),
restartDsh: () => bootstrap.request('POST', '/v1/components/dsh-runtime/restart'),
report: (message, fields) => logs.diagnostic('user-plugin-manager', message, fields),
})
const initialUserPluginTransaction = await userPluginTransactions.recoverBeforeDshStart()
if (initialUserPluginTransaction !== undefined) {
await logs.diagnostic('user-plugin-manager', 'user-plugin.startup-state.reconciled', {
taskId: initialUserPluginTransaction.taskId,
phase: initialUserPluginTransaction.phase,
recoveryResult: initialUserPluginTransaction.recoveryResult,
})
}
let loadedUserPluginInventory
const markUserPluginsLoaded = async () => {
loadedUserPluginInventory = await userPluginInventory.read()
return loadedUserPluginInventory
}
const listUserPlugins = async () => markUserPluginRestartState(
await userPluginInventory.read(),
loadedUserPluginInventory,
)
const waitForBootstrapStartup = async () => {
for (;;) {
try {
const status = await bootstrap.status()
if (status.startupComplete === true) return status
} catch (error) {
await logs.diagnostic('user-plugin-manager', 'bootstrap.startup-status.retrying', {
error,
level: 'warning',
})
}
await new Promise(resolve => setTimeout(resolve, 100))
}
}
const scheduler = new UpdateScheduler({
check: () => coordinator.check('automatic'),
onError: error => logs.diagnostic('updater', 'update.automatic-check.failed', { error }),
})
let server
const restrictedCliToken = randomBytes(32).toString('base64url')
server = createManagementServer({
coordinator,
logs,
authorizeInternal: async request => {
const token = request.headers['x-dsh-internal-capability']
const audience = request.headers['x-dsh-internal-capability-audience'] === 'plugin' ? 'plugin' : 'management'
const restrictedToken = request.headers['x-dsh-restricted-cli']
if (restrictedToken === restrictedCliToken
&& restrictedCliRoute(request.method ?? 'GET', request.url ?? '/')) return true
if (typeof token !== 'string') return false
if (['/_dsh_platform/api/v1/auth-settings', '/_dsh_platform/api/v1/auth-totp/enrollments', '/_dsh_platform/api/v1/auth-totp/enrollments/confirm', '/_dsh_platform/api/v1/auth-totp/enrollments/cancel', '/_dsh_platform/api/v1/auth-totp/disable-confirmations', '/_dsh_platform/api/v1/auth-totp/disable-confirmations/confirm', '/_dsh_platform/api/v1/auth-totp/disable-confirmations/cancel', '/_dsh_platform/api/v1/management-origin/transitions', '/_dsh_platform/api/v1/management-origin/transitions/commit', '/_dsh_platform/api/v1/management-origin/transitions/failure', '/_dsh_platform/api/v1/auth-sessions/revoke'].includes(request.url)) return true
return consumeInternalCapability(access, {
token, audience, method: request.method ?? 'GET', target: request.url ?? '/',
})
},
createManagementOriginTransition: async (value, request) => {
return access.request('POST', '/v1/management/transitions', {
...value,
internalCapability: request.headers['x-dsh-internal-capability'],
method: request.method,
target: request.url,
})
},
commitManagementOriginTransition: async (value, request) => {
return access.request('POST', '/v1/management/transitions/commit', {
...value,
internalCapability: request.headers['x-dsh-internal-capability'],
method: request.method,
target: request.url,
})
},
reportManagementOriginTransitionFailure: async value => {
if (value?.stage !== 'browser-probe' || !['compat', 'isolated'].includes(value?.mode)) {
throw Object.assign(new Error('Management origin transition failure report is invalid'), { statusCode: 400 })
}
await logs.diagnosticRateLimited('management-origin.probe.failed', 'platform-management', 'management-origin.probe.failed', {
level: 'warning', stage: value.stage, mode: value.mode,
})
return { reported: true }
},
getAuthenticationSettings: async request => {
return access.request('POST', '/v1/management/settings', {
internalCapability: request.headers['x-dsh-internal-capability'],
method: request.method,
target: request.url,
origin: requestOrigin(request, { requireHeader: false }),
})
},
updateAuthenticationSettings: async (value, request) => {
return access.request('POST', '/v1/management/auth-settings', {
...value,
internalCapability: request.headers['x-dsh-internal-capability'],
method: request.method,
target: request.url,
})
},
beginTotpEnrollment: async (value, request) => {
const enrollment = await access.request('POST', '/v1/management/totp/enrollments', {
...value,
internalCapability: request.headers['x-dsh-internal-capability'],
method: request.method,
target: request.url,
})
return { ...enrollment, qrCode: qrCodeDataUri(enrollment.uri), uri: undefined }
},
confirmTotpEnrollment: async (value, request) => {
return access.request('POST', '/v1/management/totp/enrollments/confirm', {
...value,
internalCapability: request.headers['x-dsh-internal-capability'],
method: request.method,
target: request.url,
})
},
cancelTotpEnrollment: async (value, request) => {
return access.request('POST', '/v1/management/totp/enrollments/cancel', {
...value,
internalCapability: request.headers['x-dsh-internal-capability'],
method: request.method,
target: request.url,
})
},
beginTotpDisableConfirmation: async (value, request) => {
return access.request('POST', '/v1/management/totp/disable-confirmations', {
...value,
internalCapability: request.headers['x-dsh-internal-capability'],
method: request.method,
target: request.url,
})
},
confirmTotpDisable: async (value, request) => {
return access.request('POST', '/v1/management/totp/disable-confirmations/confirm', {
...value,
internalCapability: request.headers['x-dsh-internal-capability'],
method: request.method,
target: request.url,
})
},
cancelTotpDisableConfirmation: async (value, request) => {
return access.request('POST', '/v1/management/totp/disable-confirmations/cancel', {
...value,
internalCapability: request.headers['x-dsh-internal-capability'],
method: request.method,
target: request.url,
})
},
revokeAuthenticationSessions: async (value, request) => {
return access.request('POST', '/v1/management/sessions/revoke', {
...value,
internalCapability: request.headers['x-dsh-internal-capability'],
method: request.method,
target: request.url,
})
},
platformStatus: async () => ({
...await readDeploymentStatus(paths.deploymentStatusPath),
trust: await trust.status(),
dshLifecycle: (await bootstrap.status()).dshLifecycle,
}),
startDsh: () => bootstrap.request('POST', '/v1/components/dsh-runtime/resume'),
stopDsh: () => bootstrap.request('POST', '/v1/components/dsh-runtime/pause'),
restartDsh: () => bootstrap.request('POST', '/v1/components/dsh-runtime/restart'),
resetRuntime: () => bootstrap.request('POST', '/v1/deployments/runtime/reset'),
listBundledPlugins,
configureBundledPlugin: (id, action) => bootstrap.request('POST', '/v1/system-plugins/action', { id, action }),
recoverBundledPlugin: (id, action) => bootstrap.request('POST', '/v1/system-plugins/recovery-action', { id, action }),
discardBundledPluginChanges: () => bootstrap.request('POST', '/v1/system-plugins/discard'),
listSystemSkills,
configureSystemSkill: (skillId, action) => bootstrap.request('POST', '/v1/system-skills/action', { skillId, action }),
listUserSkills: () => userSkills.list(),
validateUserSkillAction: value => userSkills.validate(value),
configureUserSkill: value => userSkillApi.request('POST', '/v1/action', value),
listUserPlugins,
markUserPluginsLoaded,
validateUserPluginActions: value => userPluginTransactions.validate(value),
applyUserPluginActions: value => userPluginTransactions.apply(value),
initialUserPluginTransaction,
recoverUserPluginTransaction: initialUserPluginTransaction?.phase === 'restarting' ? async () => {
const status = await waitForBootstrapStartup()
try {
const health = await bootstrap.request('GET', '/v1/health')
const dsh = health.components?.find(component => component.id === 'dsh-runtime')
return userPluginTransactions.completeDshStartup({
healthy: status.recoveryMode === null && dsh?.healthy === true,
error: status.recoveryMode,
})
} catch (error) {
return userPluginTransactions.completeDshStartup({
healthy: false,
error: error instanceof Error ? error.message : String(error),
})
}
} : undefined,
settingsDocument,
privilegedMutationActive: () => existsSync(paths.maintenanceLeasePath),
updateAutomaticCheck: async value => {
const state = await automaticChecks.configure(value)
scheduler.configure(state.automaticCheck)
return state.automaticCheck
},
getProxyConfiguration: proxyConfiguration,
updateProxyConfiguration: async value => Object.freeze({
...await outboundProxy.updateConfiguration(value),
scopeCatalog: PROXY_SCOPE_CATALOG,
}),
listProxyProviders: async () => {
const configuration = await proxyConfiguration()
return providerInventory.list(proxySnapshot(configuration))
},
startProxyTest: value => outboundProxy.startTest(value),
getProxyTest: taskId => outboundProxy.test(taskId),
cancelProxyTest: taskId => outboundProxy.cancelTest(taskId),
})
await listenManagement(server, paths.managementSocket)
const restrictedCliServer = createRestrictedCliServer({
managementSocketPath: paths.managementSocket,
token: restrictedCliToken,
})
await listenManagement(restrictedCliServer, paths.managementCliSocket)
void waitForBootstrapStartup()
.then(markUserPluginsLoaded)
.catch(error => logs.diagnostic('user-plugin-manager', 'user-plugin.loaded-state.capture.failed', { error }))
scheduler.configure((await automaticChecks.read()).automaticCheck)
let recovered
try {
recovered = await reconcileRecoveredState({ journal, state: coordinator.state })
} catch (error) {
await logs.diagnostic('updater', 'update.recovery-state.failed', { error })
throw error
}
const { transaction, persisted } = recovered
const journalOwnsState = transaction !== undefined && persisted.taskId === transaction.transactionId
const resumeUpdate = !journalOwnsState && !['idle', 'success', 'failed'].includes(persisted.status)
if (resumeUpdate) {
setImmediate(() => {
try {
resumeInterruptedReconcile({
coordinator,
persisted,
report: (message, fields) => logs.diagnostic('updater', message, fields),
audit: (message, fields) => logs.diagnostic('audit', message, { stream: 'audit', ...fields }),
})
} catch (error) {
void logs.diagnostic('updater', 'update.resume.failed', { error })
}
})
}
await logs.diagnostic('platform-management', 'management.ready', { resumedUpdate: resumeUpdate })
let stopping = false
const stop = signal => {
if (stopping) return
stopping = true
scheduler.stop()
void logs.diagnostic('platform-management', 'management.stopping', { signal }).then(async () => {
await updateFetch.close().catch(error => logs.diagnostic('platform-management', 'proxy.dispatcher.close.failed', { error }))
let closed = 0
const finish = () => {
closed += 1
if (closed === 2) void logs.diagnostic('platform-management', 'management.stopped').finally(() => process.exit(0))
}
restrictedCliServer.close(finish)
server.close(finish)
})
}
process.once('SIGINT', () => stop('SIGINT'))
process.once('SIGTERM', () => stop('SIGTERM'))
@@ -0,0 +1,81 @@
import { request } from 'node:http'
const MAX_RESPONSE_BYTES = 256 * 1024
const UNAVAILABLE_CODES = new Set(['ENOENT', 'ECONNREFUSED', 'ECONNRESET', 'EPIPE', 'ETIMEDOUT', 'ABORT_ERR'])
export class OutboundProxyControlError extends Error {
constructor(error, statusCode, path) {
super(error?.message ?? `Outbound Proxy returned HTTP ${String(statusCode)}`)
this.name = 'OutboundProxyControlError'
this.statusCode = statusCode
this.code = error?.code ?? 'PROXY_MANAGER_UNAVAILABLE'
this.stage = error?.stage ?? 'unknown'
this.retryable = error?.retryable === true
this.localApiPath = path
this.proxyError = Object.freeze({
code: this.code,
message: this.message,
stage: this.stage,
retryable: this.retryable,
})
}
}
export class OutboundProxyControlClient {
constructor(socketPath, { timeoutMs = 10_000 } = {}) {
this.socketPath = socketPath
this.timeoutMs = timeoutMs
}
request(method, path, body) {
return new Promise((resolve, reject) => {
const bytes = body === undefined ? undefined : Buffer.from(JSON.stringify(body))
const outgoing = request({
socketPath: this.socketPath,
method,
path,
headers: bytes === undefined ? {} : {
'content-type': 'application/json',
'content-length': bytes.byteLength,
},
signal: AbortSignal.timeout(this.timeoutMs),
}, response => {
const chunks = []
let size = 0
response.on('data', chunk => {
size += chunk.byteLength
if (size > MAX_RESPONSE_BYTES) outgoing.destroy(new Error('Outbound Proxy response is too large'))
else chunks.push(chunk)
})
response.on('end', () => {
let value
try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch {
reject(new Error('Outbound Proxy response is invalid'))
return
}
const statusCode = response.statusCode ?? 500
if (statusCode >= 400) reject(new OutboundProxyControlError(value.error, statusCode, path))
else resolve(value)
})
})
outgoing.once('error', error => {
if (UNAVAILABLE_CODES.has(error?.code) || error?.name === 'TimeoutError' || error?.name === 'AbortError') {
reject(new OutboundProxyControlError({
code: 'PROXY_MANAGER_UNAVAILABLE',
message: 'Outbound Proxy control service is unavailable',
stage: 'control',
retryable: true,
}, 503, path))
} else reject(error)
})
outgoing.end(bytes)
})
}
configuration() { return this.request('GET', '/v1/configuration') }
updateConfiguration(value) { return this.request('PUT', '/v1/configuration', value) }
startTest(value) { return this.request('POST', '/v1/test', value) }
test(taskId) { return this.request('GET', `/v1/test/tasks/${encodeURIComponent(taskId)}`) }
cancelTest(taskId) { return this.request('DELETE', `/v1/test/tasks/${encodeURIComponent(taskId)}`) }
status() { return this.request('GET', '/v1/status') }
}
@@ -0,0 +1,84 @@
{
"name": "dsh-docker-management",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dsh-docker-management",
"dependencies": {
"@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "6.0.0",
"qrcode-generator": "2.0.4",
"undici": "8.10.0",
"ws": "8.21.3",
"yaml": "2.9.0"
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/qrcode-generator": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz",
"integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==",
"license": "MIT"
},
"node_modules/undici": {
"version": "8.10.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz",
"integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
}
}
}
@@ -0,0 +1,13 @@
{
"name": "dsh-docker-management",
"private": true,
"type": "module",
"dependencies": {
"@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "6.0.0",
"qrcode-generator": "2.0.4",
"undici": "8.10.0",
"ws": "8.21.3",
"yaml": "2.9.0"
}
}
@@ -0,0 +1,230 @@
import { isIP } from 'node:net'
import { readFile } from 'node:fs/promises'
import { durableReplace } from '../../../platform/lib/atomic.mjs'
import { canonicalJson } from '../../../platform/lib/canonical-json.mjs'
import { validProviderId } from '../outbound-proxy/lib/contracts.mjs'
const CACHE_SCHEMA = 1
const DEFAULT_ENDPOINT = 'http://127.0.0.1:3079'
const RPC_TIMEOUT_MS = 5_000
function object(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function rpcValue(value, method) {
if (!object(value) || value.type !== 'server-response' || !object(value.result)) {
throw new Error(`DSH ${method} response is invalid`)
}
if (value.result.ok !== true || !Object.hasOwn(value.result, 'value')) {
throw new Error(`DSH ${method} request failed`)
}
return value.result.value
}
async function rpc(fetchImpl, endpoint, method, signal, headers) {
const rpcId = `dsh-proxy-${method}`
const response = await fetchImpl(`${endpoint}/api/${method}`, {
method: 'POST',
headers: { ...headers, 'content-type': 'application/json' },
body: JSON.stringify({ type: 'client-request', rpcId, method, payload: { args: {} } }),
signal,
})
if (!response.ok) throw new Error(`DSH ${method} returned HTTP ${String(response.status)}`)
return rpcValue(await response.json(), method)
}
function nestedValue(value, path) {
let current = value
for (const part of path) {
if (!object(current) || !(part in current)) return undefined
current = current[part]
}
return current
}
function baseUrlFor(provider, namespaces) {
const namespace = namespaces.get(provider.settingsNs)
if (namespace === undefined) return undefined
const path = Array.isArray(provider.settingsPath) ? provider.settingsPath : []
const configured = nestedValue(namespace.value, path)
const defaults = nestedValue(namespace.base, path)
for (const candidate of [configured, defaults]) {
if (object(candidate) && typeof candidate.baseURL === 'string' && candidate.baseURL.trim() !== '') {
return candidate.baseURL.trim()
}
}
return undefined
}
function configuredProvider(provider, namespaces) {
const namespace = namespaces.get(provider.settingsNs)
if (namespace === undefined) return false
const path = Array.isArray(provider.settingsPath) ? provider.settingsPath : []
return path.length === 0 || nestedValue(namespace.value, path) !== undefined
}
function loopbackUrl(value) {
if (value === undefined) return false
let parsed
try { parsed = new URL(value) } catch { return false }
const host = parsed.hostname.startsWith('[') && parsed.hostname.endsWith(']')
? parsed.hostname.slice(1, -1)
: parsed.hostname.toLowerCase()
if (host === 'localhost' || host.endsWith('.localhost')) return true
if (isIP(host) === 4) return host.startsWith('127.')
if (isIP(host) === 6) return host === '::1' || host === '0:0:0:0:0:0:0:1'
return false
}
function sanitizeProvider(provider, namespaces) {
if (!object(provider) || !validProviderId(provider.provider)) return undefined
if (!configuredProvider(provider, namespaces)) return undefined
const local = loopbackUrl(baseUrlFor(provider, namespaces))
const routingCapability = local
? 'forced-direct'
: provider.routingCapability === 'shared-dsh' || provider.supportsIndependentRouting === false
? 'shared-dsh'
: 'provider'
return Object.freeze({
id: provider.provider,
displayName: typeof provider.displayName === 'string' && provider.displayName.trim() !== ''
? provider.displayName.trim()
: provider.provider,
type: provider.provider,
active: provider.active === true,
declared: provider.declared !== false,
configured: true,
routingCapability,
reason: routingCapability === 'forced-direct'
? 'local-provider'
: routingCapability === 'shared-dsh' ? 'client-uses-shared-dsh-route' : null,
})
}
function decorate(provider, snapshot) {
if (provider.routingCapability !== 'forced-direct') {
const requestedPolicy = snapshot.configuration.modelApi.providers[provider.id]
?? snapshot.configuration.modelApi.default
return Object.freeze({
...provider,
requestedPolicy,
effectivePolicy: requestedPolicy.proxyEnabled
? provider.routingCapability === 'shared-dsh' ? 'shared-dsh' : 'proxy'
: 'direct',
})
}
return Object.freeze({
...provider,
requestedPolicy: null,
effectivePolicy: 'direct',
})
}
function validateCache(value) {
if (!object(value) || value.schema !== CACHE_SCHEMA || !Array.isArray(value.providers) || typeof value.updatedAt !== 'string') {
throw new Error('cached Provider inventory is invalid')
}
const providers = value.providers.map(provider => {
if (!object(provider) || !validProviderId(provider.id) || typeof provider.displayName !== 'string'
|| typeof provider.type !== 'string' || typeof provider.active !== 'boolean' || typeof provider.declared !== 'boolean'
|| provider.configured !== true
|| !['provider', 'shared-dsh', 'forced-direct'].includes(provider.routingCapability)
|| !(provider.reason === null || typeof provider.reason === 'string')) {
throw new Error('cached Provider inventory entry is invalid')
}
return Object.freeze({ ...provider })
})
return Object.freeze({ schema: CACHE_SCHEMA, updatedAt: value.updatedAt, providers: Object.freeze(providers) })
}
export class ProviderInventory {
constructor({
cachePath,
endpoint = DEFAULT_ENDPOINT,
fetchImpl = fetch,
authentication = Object.freeze({ cookie: async () => null }),
timeoutMs = RPC_TIMEOUT_MS,
now = () => new Date(),
}) {
if (typeof cachePath !== 'string' || cachePath === '') throw new TypeError('Provider inventory cache path is required')
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new TypeError('Provider inventory timeout is invalid')
if (!object(authentication) || typeof authentication.cookie !== 'function') {
throw new TypeError('Provider inventory authentication is invalid')
}
this.cachePath = cachePath
this.endpoint = endpoint.replace(/\/$/, '')
this.fetchImpl = fetchImpl
this.authentication = authentication
this.timeoutMs = timeoutMs
this.now = now
}
async refresh() {
const signal = AbortSignal.timeout(this.timeoutMs)
const cookie = await this.authentication.cookie()
if (!(cookie === null || typeof cookie === 'string')) throw new Error('DSH authentication cookie is invalid')
const headers = cookie === null ? {} : { cookie }
const [registeredValue, directoryValue, settingsValue] = await Promise.all([
rpc(this.fetchImpl, this.endpoint, 'llm/listProviders', signal, headers),
rpc(this.fetchImpl, this.endpoint, 'llm/listConfigurableProviders', signal, headers),
rpc(this.fetchImpl, this.endpoint, 'settings/describe', signal, headers),
])
if (!Array.isArray(registeredValue) || !Array.isArray(directoryValue) || !Array.isArray(settingsValue.namespaces)) {
throw new Error('DSH Provider inventory response is invalid')
}
const namespaces = new Map(settingsValue.namespaces
.filter(namespace => object(namespace) && typeof namespace.ns === 'string')
.map(namespace => [namespace.ns, namespace]))
const active = new Set(registeredValue
.filter(provider => object(provider) && validProviderId(provider.id))
.map(provider => provider.id))
const providers = directoryValue
.map(provider => sanitizeProvider({ ...provider, active: active.has(provider?.provider) }, namespaces))
.filter(provider => provider !== undefined)
.sort((left, right) => left.displayName.localeCompare(right.displayName) || left.id.localeCompare(right.id))
const cache = Object.freeze({
schema: CACHE_SCHEMA,
updatedAt: this.now().toISOString(),
providers: Object.freeze(providers),
})
await durableReplace(this.cachePath, canonicalJson(cache))
return cache
}
async cached() {
return validateCache(JSON.parse(await readFile(this.cachePath, 'utf8')))
}
async list(snapshot, { refresh = true } = {}) {
let cache
let source = 'live'
let error = null
if (refresh) {
try { cache = await this.refresh() } catch (failure) {
source = 'cache'
error = failure instanceof Error ? failure.message : String(failure)
}
}
if (cache === undefined) {
try { cache = await this.cached() } catch (failure) {
return Object.freeze({
schema: CACHE_SCHEMA,
source: 'unavailable',
updatedAt: null,
error: error ?? (failure instanceof Error ? failure.message : String(failure)),
providers: Object.freeze([]),
})
}
}
return Object.freeze({
schema: CACHE_SCHEMA,
source,
updatedAt: cache.updatedAt,
error,
providers: Object.freeze(cache.providers.map(provider => decorate(provider, snapshot))),
})
}
}
export const providerInventoryInternals = Object.freeze({ baseUrlFor, configuredProvider, loopbackUrl, validateCache })
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,377 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%23f4f4f5'/%3E%3Ctext x='32' y='39' text-anchor='middle' font-family='Arial,sans-serif' font-size='20' font-weight='700' fill='%2318181b'%3EDSH%3C/text%3E%3C/svg%3E">
<script src="./theme-init.js"></script>
<title>DSH Management Console</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<header class="topbar">
<div class="topbar-inner">
<div class="brand">
<span class="brand-mark" aria-hidden="true">DSH</span>
<div><strong data-i18n="title">DSH Management Console</strong><span data-i18n="consoleLabel">Standalone console</span></div>
</div>
<div class="topbar-actions">
<button id="return-dsh" class="secondary return-dsh" type="button" aria-label="返回 DSH" title="返回 DSH" data-i18n-aria-label="returnDsh" data-i18n-title="returnDsh">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 18l-6-6 6-6"/></svg>
<span class="return-dsh-label" data-i18n="returnDsh">返回 DSH</span>
</button>
<button id="theme-switch" class="theme-switch" type="button" aria-label="Theme" title="Theme">
<span class="theme-icon" data-theme-icon="system" aria-hidden="true"><svg viewBox="0 0 24 24"><rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/></svg></span>
<span class="theme-icon" data-theme-icon="light" aria-hidden="true"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.42 1.42M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.42"/></svg></span>
<span class="theme-icon" data-theme-icon="dark" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg></span>
</button>
<select id="language-switch" class="language-switch" aria-label="Language" title="Language" data-i18n-aria-label="switchLanguage" data-i18n-title="switchLanguage">
<option value="zh">中文</option>
<option value="en">English</option>
</select>
<div class="connection" id="connection" data-state="connecting" role="status">
<span aria-hidden="true"></span><strong data-i18n="connecting">Reconnecting</strong>
</div>
</div>
</div>
</header>
<main>
<div class="management-navigation">
<nav class="tabs" role="tablist" aria-label="Platform management sections" data-i18n-aria-label="managementSections">
<button id="tab-maintenance" type="button" role="tab" aria-selected="true" aria-controls="panel-maintenance" data-tab="maintenance" data-i18n="maintenanceTab">Maintenance</button>
<button id="tab-files" type="button" role="tab" aria-selected="false" aria-controls="panel-files" data-tab="files" data-i18n="filesTab">Files</button>
<button id="tab-terminal" type="button" role="tab" aria-selected="false" aria-controls="panel-terminal" data-tab="terminal" data-i18n="terminalTab">Terminal</button>
<button id="tab-plugins" type="button" role="tab" aria-selected="false" aria-controls="panel-plugins" data-tab="plugins" data-i18n="pluginsTab">System plugins</button>
<button id="tab-skills" type="button" role="tab" aria-selected="false" aria-controls="panel-skills" data-tab="skills" data-i18n="skillsTab">System skills</button>
<button id="tab-user-plugins" type="button" role="tab" aria-selected="false" aria-controls="panel-user-plugins" data-tab="user-plugins" data-i18n="userPluginsTab">User plugins</button>
<button id="tab-user-skills" type="button" role="tab" aria-selected="false" aria-controls="panel-user-skills" data-tab="user-skills" data-i18n="userSkillsTab">User skills</button>
<button id="tab-proxy" type="button" role="tab" aria-selected="false" aria-controls="panel-proxy" data-tab="proxy" data-i18n="proxyTab">Proxy</button>
<button id="tab-updates" type="button" role="tab" aria-selected="false" aria-controls="panel-updates" data-tab="updates" data-i18n="updatesTab">Updates</button>
<button id="tab-auth-settings" type="button" role="tab" aria-selected="false" aria-controls="panel-auth-settings" data-tab="auth-settings" data-i18n="authSettingsTab">Authentication</button>
</nav>
</div>
<p id="error" class="message error" role="alert" hidden></p>
<div id="panel-auth-settings" class="tab-panel" role="tabpanel" aria-labelledby="tab-auth-settings" hidden>
<section aria-labelledby="auth-settings-title">
<div class="section-heading"><div><h2 id="auth-settings-title" data-i18n="authSettingsTitle">Authentication settings</h2><p data-i18n="authSettingsDetail">Manage the local administrator account and Management sessions.</p></div></div>
<form id="auth-settings-form" class="auth-settings-form" method="post" action="" novalidate>
<div class="auth-subsection"><div class="auth-subsection-heading"><h3 data-i18n="authMainPasswordTitle">Administrator account</h3><button id="auth-account-save" type="button" class="primary" data-i18n="authSaveApply">Save and apply</button></div><label><span data-i18n="authUsername">Username</span><input id="auth-username" name="username" autocomplete="username" maxlength="256" pattern="[^\p{Cc}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]{1,64}" required><small id="auth-username-error" class="field-error" data-i18n="authUsernameFormat" role="alert" hidden></small></label><label><span data-i18n="authCurrentPassword">Current main password</span><input id="auth-current-password" type="password" autocomplete="current-password" maxlength="1024" data-i18n-placeholder="authCurrentPasswordPlaceholder"><small id="auth-current-password-error" class="field-error" role="alert" hidden></small></label><div class="auth-password-grid"><label><span data-i18n="authNewPassword">New password</span><input id="auth-password" name="password" type="password" autocomplete="new-password" minlength="8" maxlength="1024" pattern="[^\p{Cc}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]{8,1024}" data-i18n-placeholder="authPasswordPlaceholder"><small id="auth-password-error" class="field-error" data-i18n="authPasswordFormat" role="alert" hidden></small></label><label><span data-i18n="authConfirmPassword">Confirm password</span><input id="auth-password-confirm" type="password" autocomplete="new-password" minlength="8" maxlength="1024" pattern="[^\p{Cc}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]{8,1024}" data-i18n-placeholder="authPasswordPlaceholder"><small id="auth-password-confirm-error" class="field-error" data-i18n="authPasswordFormat" role="alert" hidden></small></label></div><div class="auth-two-factor auth-additional-heading"><h3 data-i18n="authTotpTitle">Two-factor authentication (2FA)</h3><div class="auth-totp-control"><small id="auth-totp-pending" data-i18n="authTotpPending" hidden>Authentication code not verified</small><label class="toggle" data-i18n-aria-label="authTotpEnabled"><input id="auth-totp-enabled" type="checkbox"><span aria-hidden="true"></span></label></div></div><div class="auth-additional"><div class="auth-additional-heading"><h3 data-i18n="authAdditionalTitle">Management console password</h3><label class="toggle" data-i18n-aria-label="authAdditionalEnabled"><input id="auth-additional-enabled" type="checkbox"><span aria-hidden="true"></span></label></div><div id="auth-additional-fields" class="auth-password-grid" hidden><label><span data-i18n="authAdditionalPassword">New password</span><input id="auth-additional-password" type="password" autocomplete="new-password" minlength="8" maxlength="1024" pattern="[^\p{Cc}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]{8,1024}" data-i18n-placeholder="authPasswordPlaceholder"><small id="auth-additional-password-error" class="field-error" data-i18n="authPasswordFormat" role="alert" hidden></small></label><label><span data-i18n="authConfirmAdditionalPassword">Confirm password</span><input id="auth-additional-password-confirm" type="password" autocomplete="new-password" minlength="8" maxlength="1024" pattern="[^\p{Cc}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]{8,1024}" data-i18n-placeholder="authPasswordPlaceholder"><small id="auth-additional-password-confirm-error" class="field-error" data-i18n="authPasswordFormat" role="alert" hidden></small></label></div></div></div>
<div class="auth-subsection">
<div class="auth-subsection-heading"><h3 data-i18n="authOriginTitle">Management origin</h3><div class="auth-subsection-actions"><button id="auth-isolation-compare" type="button" class="secondary" data-i18n="authIsolationCompare">Compare security</button><button id="auth-origin-save" type="button" class="primary" data-i18n="authSaveApply">Save and apply</button></div></div>
<div id="auth-root-warning" class="message warning" hidden></div>
<label><span data-i18n="authMode">Access mode</span><select id="auth-mode"><option value="compat" data-i18n="authModeCompat">Compatibility mode</option><option value="isolated-public" data-i18n="authModeIsolated">Public separate origin</option><option value="isolated-local" data-i18n="authModeLocal">Local separate origin</option></select></label>
<div id="auth-origin-settings" hidden><div class="auth-origin-field"><label id="auth-public-origin-field"><span data-i18n="authPublicOrigin">Management origin</span><input id="auth-origin" type="url" data-i18n-placeholder="authPublicOriginPlaceholder"></label><label id="auth-local-port-field" hidden><span data-i18n="authLocalPort">Host port</span><input id="auth-local-port" type="number" min="1" max="65535" step="1" inputmode="numeric" value="3081" data-i18n-placeholder="authLocalPortPlaceholder"><small id="auth-local-port-error" class="field-error" role="alert" hidden data-i18n="authLocalPortFormat">请输入 1 至 65535 的宿主机端口。</small></label><button id="auth-origin-detect" type="button" class="secondary" data-i18n="authAutoDetect">Use detected address</button></div><p id="auth-origin-detail" class="auth-security-note" data-i18n="authOriginDetail">The candidate origin is verified against this platform instance before the access mode changes.</p></div>
</div>
<div class="auth-subsection"><h3 data-i18n="authSessionsTitle">Browser sessions</h3><p id="auth-session-summary" class="auth-security-note"></p><div id="auth-session-list" class="auth-session-list"></div></div>
<p id="auth-settings-status" class="operation-result" role="status" aria-live="polite" hidden></p>
</form>
</section>
</div>
<div id="panel-proxy" class="tab-panel" role="tabpanel" aria-labelledby="tab-proxy" hidden>
<section aria-labelledby="proxy-master-title">
<div class="proxy-setting-row proxy-master-row"><div><div class="proxy-title-line"><h2 id="proxy-master-title" data-i18n="proxyMaster">Use proxy</h2><button id="proxy-component-alert" class="proxy-component-alert" type="button" data-i18n-aria-label="proxyComponentIssueLabel" data-i18n-title="proxyComponentIssueLabel" hidden><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 6.5v7"/><path d="M12 17.5h.01"/></svg></button></div><small data-i18n="proxyMasterDetail">When off, all managed scopes connect directly.</small></div><label class="toggle" data-i18n-aria-label="proxyMaster"><input id="proxy-enabled" type="checkbox"><span aria-hidden="true"></span></label></div>
</section>
<section aria-labelledby="proxy-title">
<div class="section-heading">
<div><div class="proxy-title-line"><h2 id="proxy-title" data-i18n="proxyTitle">Proxy settings</h2><span id="proxy-save-state" class="proxy-save-state" role="status" aria-live="polite" hidden></span></div><p data-i18n="proxyDetail">Use an existing HTTP or SOCKS5 proxy for selected outbound traffic.</p></div>
<div class="actions"><button id="proxy-test" type="button" class="secondary" data-i18n="proxyTestStart">Test connection</button><button id="proxy-save" type="button" class="primary" data-i18n="proxySave">Save and apply</button></div>
</div>
<div class="proxy-form-grid">
<label><span data-i18n="proxyProtocol">Protocol</span><select id="proxy-protocol"><option value="http">HTTP</option><option value="socks5">SOCKS5</option></select></label>
<label><span data-i18n="proxyHost">Host</span><input id="proxy-host" type="text" maxlength="253" autocomplete="off" spellcheck="false" placeholder="172.17.0.1"></label>
<label><span data-i18n="proxyPort">Port</span><input id="proxy-port" type="number" min="1" max="65535" inputmode="numeric" placeholder="7890"></label>
<label><span data-i18n="proxyUsername">Username</span><input id="proxy-username" type="text" maxlength="255" autocomplete="off" spellcheck="false"></label>
<div class="proxy-password-row"><div class="proxy-password-field"><label><span data-i18n="proxyPassword">Password</span><input id="proxy-password" type="password" maxlength="255" autocomplete="new-password" data-i18n-placeholder="proxyPasswordPlaceholder"></label><button id="proxy-clear-password" type="button" class="secondary proxy-clear-password" aria-pressed="false" data-i18n="proxyClearPassword" hidden>Clear password</button></div><button id="proxy-advanced-toggle" type="button" class="secondary" aria-expanded="false" aria-controls="proxy-advanced" data-i18n="proxyAdvanced">Advanced</button></div>
</div>
<div id="proxy-advanced" class="proxy-advanced" hidden>
<div class="section-heading"><div><h3 data-i18n="proxyRules">Direct rules</h3><p data-i18n="proxyRulesDetail">Destinations listed here bypass the external proxy.</p></div><button id="proxy-system-rules" type="button" class="secondary" data-i18n="proxySystemRules">Built-in rules</button></div>
<label class="proxy-direct-rules-field"><textarea id="proxy-direct-rules" rows="5" spellcheck="false" data-i18n-aria-label="proxyDirectRules" data-i18n-placeholder="proxyDirectRulesPlaceholder"></textarea><small data-i18n="proxyDirectRulesDetail">One host, domain suffix, IP address, or CIDR per line. Use .example.com, not *.example.com.</small></label>
<div class="proxy-setting-row proxy-advanced-setting"><div><h3 data-i18n="proxyAllProxy">Set ALL_PROXY where supported</h3><small data-i18n="proxyAllProxyDetail">Only clients with verified ALL_PROXY support receive it.</small></div><label class="toggle" data-i18n-aria-label="proxyAllProxy"><input id="proxy-all-proxy" type="checkbox"><span aria-hidden="true"></span></label></div>
<div id="proxy-remote-dns-row" class="proxy-setting-row proxy-advanced-setting" hidden><div><h3 data-i18n="proxyRemoteDns">Resolve target DNS through SOCKS5</h3><small data-i18n="proxyRemoteDnsDetail">Avoids resolving target names inside the container.</small></div><label class="toggle" data-i18n-aria-label="proxyRemoteDns"><input id="proxy-remote-dns" type="checkbox"><span aria-hidden="true"></span></label></div>
</div>
<p id="proxy-transport-warning" class="message notice" data-i18n="proxyTransportWarning" hidden>This page is not using HTTPS. Proxy credentials are protected only by the current network boundary.</p>
</section>
<section aria-labelledby="proxy-scopes-title">
<div class="section-heading"><div><h2 id="proxy-scopes-title" data-i18n="proxyScopes">Traffic scopes</h2><p data-i18n="proxyScopesDetail">Select which managed outbound traffic uses this proxy.</p></div><button id="proxy-scope-help" type="button" class="secondary" data-i18n="proxyScopeHelp">Scope guide</button></div>
<div id="proxy-scope-list" class="proxy-scope-grid">
<div class="proxy-scope-card"><span class="proxy-scope-copy"><strong data-i18n="proxyScopeUpdates">Updates</strong><button type="button" class="proxy-scope-description" data-i18n="proxyScopeUpdatesDetail">Metadata checks and remote Artifact downloads.</button></span><label class="toggle" data-i18n-aria-label="proxyScopeUpdates"><input type="checkbox" data-proxy-scope="updates"><span aria-hidden="true"></span></label></div>
<div class="proxy-scope-card"><span class="proxy-scope-copy"><strong data-i18n="proxyScopePlatform">Platform components</strong><button type="button" class="proxy-scope-description" data-i18n="proxyScopePlatformDetail">External requests made by DSH Docker components and System Plugins.</button></span><label class="toggle" data-i18n-aria-label="proxyScopePlatform"><input type="checkbox" data-proxy-scope="platform"><span aria-hidden="true"></span></label></div>
<div class="proxy-scope-card"><span class="proxy-scope-copy"><strong data-i18n="proxyScopeDshCore">DSH core</strong><button type="button" class="proxy-scope-description" data-i18n="proxyScopeDshCoreDetail">DSH core traffic excluding model Provider APIs.</button></span><label class="toggle" data-i18n-aria-label="proxyScopeDshCore"><input type="checkbox" data-proxy-scope="dshCore"><span aria-hidden="true"></span></label></div>
<div class="proxy-scope-card"><span class="proxy-scope-copy"><strong data-i18n="proxyScopeDshPlugins">DSH plugins</strong><button type="button" class="proxy-scope-description" data-i18n="proxyScopeDshPluginsDetail">Official and user-installed DSH plugins.</button></span><label class="toggle" data-i18n-aria-label="proxyScopeDshPlugins"><input type="checkbox" data-proxy-scope="dshPlugins"><span aria-hidden="true"></span></label></div>
<div class="proxy-scope-card"><span class="proxy-scope-copy"><strong data-i18n="proxyScopeAgent">Agent network operations</strong><button type="button" class="proxy-scope-description" data-i18n="proxyScopeAgentDetail">Agent network tools, commands, and their child processes.</button></span><label class="toggle" data-i18n-aria-label="proxyScopeAgent"><input type="checkbox" data-proxy-scope="agentNetwork"><span aria-hidden="true"></span></label></div>
<div class="proxy-scope-card"><span class="proxy-scope-copy"><strong data-i18n="proxyScopeTerminal">Container terminal</strong><button type="button" class="proxy-scope-description" data-i18n="proxyScopeTerminalDetail">Shell sessions created by this Management Console.</button></span><label class="toggle" data-i18n-aria-label="proxyScopeTerminal"><input type="checkbox" data-proxy-scope="managementTerminal"><span aria-hidden="true"></span></label></div>
</div>
</section>
<section aria-labelledby="proxy-providers-title">
<div class="proxy-provider-heading"><h2 id="proxy-providers-title" data-i18n="proxyProviders">Model Providers</h2><div><p data-i18n="proxyProvidersDetail">Identifiable DSH llm/stream Providers can independently select direct access or a dedicated proxy route.</p><input id="proxy-provider-search" type="search" data-i18n-placeholder="proxyProviderSearch" data-i18n-aria-label="proxyProviderSearch" placeholder="Search configured Providers"></div></div>
<div id="proxy-provider-list" class="proxy-provider-list"></div>
<p id="proxy-provider-empty" class="empty-state" data-i18n="proxyNoProviders" hidden>No model Providers were found.</p>
</section>
</div>
<div id="panel-updates" class="tab-panel" role="tabpanel" aria-labelledby="tab-updates" hidden>
<section aria-labelledby="channel-title">
<div class="section-heading">
<div><h2 id="channel-title" data-i18n="channel">Update channel</h2><p data-i18n="channelDetail">Experimental updates DSH only; the platform Environment remains on the supported release.</p></div>
<div class="segmented" role="group" aria-label="Update channel" data-i18n-aria-label="channel">
<button type="button" data-channel="stable" aria-pressed="true" data-i18n="stable">Stable</button>
<button type="button" data-channel="experimental" data-i18n="experimental">Experimental</button>
</div>
</div>
<div class="versions" id="versions">
<div><span data-i18n="current">Current</span><strong id="current-dsh">-</strong><small id="current-env">-</small></div>
<div><span data-i18n="supported">Supported</span><strong id="supported-dsh">-</strong><small id="supported-env">-</small></div>
<div id="upstream-version" hidden><span data-i18n="upstream">Upstream</span><strong id="upstream-dsh">-</strong><small data-i18n="officialNpm">Official npm</small></div>
</div>
<p id="notice" class="message notice" hidden></p>
</section>
<section aria-labelledby="actions-title">
<div class="section-heading action-heading">
<div><h2 id="actions-title" data-i18n="actions">Update actions</h2><p id="checked-at" data-i18n="notChecked">Not checked yet</p></div>
<div class="actions">
<button type="button" id="check" class="secondary"><span id="check-spinner" class="spinner" aria-hidden="true" hidden></span><span id="check-label" data-i18n="check">Check for updates</span></button>
<button type="button" id="update" class="primary" data-i18n="updateSupported" disabled>Update to latest supported</button>
<button type="button" id="rollback" class="secondary" data-i18n="rollback" hidden>Roll back previous</button>
<button type="button" id="return-stable" class="danger-text" data-i18n="returnStable" hidden>Return to Stable now</button>
</div>
</div>
<div class="update-state" aria-live="polite">
<p id="update-result" hidden></p>
<div id="update-progress" class="update-progress" hidden>
<div class="progress-heading"><strong id="progress-title"></strong><span class="progress-heading-actions"><output id="progress-value">0%</output><button type="button" id="progress-dismiss" class="compact" data-i18n="dismissProgress" hidden>Close</button></span></div>
<div id="progress" class="progress" role="progressbar" aria-label="Update progress" data-i18n-aria-label="progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span id="progress-bar"></span></div>
<div id="progress-stage-log" class="progress-stage-log" hidden>
<div id="progress-log-list" class="progress-log-list" role="log" aria-live="polite"></div>
<button type="button" id="progress-full-log" class="link-button" data-i18n="viewFullTransactionLog">View full transaction log</button>
</div>
</div>
</div>
<p id="metadata-notice" class="message notice" data-i18n="metadataUnavailable" hidden>Signed update metadata has not been published yet. Try again later.</p>
<p id="remote-check-notice" class="message notice" data-i18n="remoteCheckFailed" hidden>Remote check failed. Showing the last verified result.</p>
<div id="holds" class="holds" hidden></div>
</section>
<section aria-labelledby="automatic-title">
<div class="section-heading">
<div><h2 id="automatic-title" data-i18n="automaticChecks">Automatic checks</h2><p data-i18n="automaticChecksDetail">Checks for available versions without downloading or updating.</p></div>
<label class="toggle"><input id="automatic-enabled" type="checkbox"><span aria-hidden="true"></span><strong id="automatic-enabled-label">On</strong></label>
</div>
<div class="setting-rows">
<label class="setting-row"><span data-i18n="checkInterval">Check frequency</span><select id="automatic-interval"><option value="3600" data-i18n="interval3600">Every hour</option><option value="10800" data-i18n="interval10800">Every 3 hours</option><option value="21600" selected data-i18n="interval21600">Every 6 hours</option><option value="43200" data-i18n="interval43200">Every 12 hours</option><option value="86400" data-i18n="interval86400">Every 24 hours</option></select></label>
<div class="setting-row"><span><strong data-i18n="updateNotifications">DSH page update notifications</strong><small data-i18n="updateNotificationsDetail">Shown on DSH pages when an automatic check finds a new version.</small></span><label class="toggle" data-i18n-aria-label="updateNotifications"><input id="notifications-enabled" type="checkbox"><span aria-hidden="true"></span></label></div>
</div>
</section>
</div>
<div id="panel-maintenance" class="tab-panel" role="tabpanel" aria-labelledby="tab-maintenance">
<section aria-labelledby="maintenance-title">
<div class="section-heading">
<div><h2 id="maintenance-title" data-i18n="maintenance">DSH lifecycle</h2><p data-i18n="maintenanceDetail">Start, stop, or restart DSH while the container and management console remain running.</p></div>
<div class="lifecycle-actions"><button type="button" id="start-dsh" class="secondary" data-i18n="startDsh">Start DSH</button><button type="button" id="stop-dsh" class="secondary" data-i18n="stopDsh">Stop DSH</button><button type="button" id="restart-dsh" class="secondary" data-i18n="restartDsh">Restart DSH</button></div>
</div>
<p id="restart-state" class="operation-result" hidden></p>
</section>
<section aria-labelledby="runtime-reset-title">
<div class="section-heading">
<div><h2 id="runtime-reset-title" data-i18n="runtimeReset">Reset runtime</h2><p data-i18n="runtimeResetDetail">Rebuild the runtime from verified DSH files and current platform patches without deleting user data.</p></div>
<button type="button" id="runtime-reset" class="secondary" aria-controls="runtime-reset-confirmation" aria-expanded="false" data-i18n="runtimeReset" disabled>Reset runtime</button>
</div>
<div id="runtime-reset-confirmation" class="inline-confirmation" role="alertdialog" aria-labelledby="runtime-reset-confirmation-title" hidden>
<h3 id="runtime-reset-confirmation-title" data-i18n="runtimeResetConfirmTitle">Reset the current runtime?</h3>
<p data-i18n="runtimeResetWarning">DSH stops briefly. The current version and user data remain unchanged.</p>
<div class="dialog-actions"><button type="button" id="cancel-runtime-reset" class="secondary" data-i18n="cancel">Cancel</button><button type="button" id="confirm-runtime-reset" class="primary" data-i18n="confirmRuntimeReset" disabled>Reset and restart DSH</button></div>
</div>
<div id="runtime-reset-progress" class="runtime-reset-progress" hidden>
<div class="status-line"><p id="runtime-reset-state" class="operation-result"></p><output id="runtime-reset-progress-value">0%</output></div>
<div id="runtime-reset-progress-track" class="progress" role="progressbar" aria-label="Runtime reset progress" data-i18n-aria-label="runtimeResetProgress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span id="runtime-reset-progress-bar"></span></div>
</div>
<p id="runtime-reset-result" class="operation-result" hidden></p>
</section>
<section class="log-section" aria-labelledby="logs-title">
<div class="section-heading">
<div><h2 id="logs-title" data-i18n="logs">Live logs</h2><p data-i18n="logsDetail">View runtime logs from DSH and platform modules.</p></div>
<div class="log-tools"><span id="log-connection" class="log-connection" data-state="connecting"><span aria-hidden="true"></span><strong data-i18n="logsConnecting">Connecting</strong></span><button id="refresh-logs" type="button" class="compact" data-i18n="refreshLogs">Refresh logs</button><button id="export-logs" type="button" class="compact" data-i18n="exportLogs" disabled>Export logs</button><button id="clear-logs" type="button" class="compact" data-i18n="clearLogView">Clear view</button></div>
</div>
<div class="log-filters"><input id="log-search" type="search" placeholder="Search logs" aria-label="Search logs" data-i18n-placeholder="searchLogs" data-i18n-aria-label="searchLogs"><select id="log-source" aria-label="Log module" data-i18n-aria-label="logSource"><option value="all" data-i18n="allSources">All modules</option></select><select id="log-level" aria-label="Log level" data-i18n-aria-label="logLevel"><option value="all" data-i18n="levelAll">All levels</option><option value="debug" data-i18n="levelDebug">Debug</option><option value="info" data-i18n="levelInfo">Info</option><option value="warning" data-i18n="levelWarning">Warning</option><option value="error" data-i18n="levelError">Error</option></select><select id="log-limit" aria-label="Entries shown" data-i18n-aria-label="logDisplayLimit"><option value="100" data-log-limit="100">Latest 100</option><option value="250" data-log-limit="250">Latest 250</option><option value="500" data-log-limit="500">Latest 500</option><option value="1000" data-log-limit="1000">Latest 1000</option></select></div>
<div class="log-summary-row"><p id="log-summary" class="log-summary"></p><label class="log-auto-scroll"><input id="auto-scroll" type="checkbox" checked aria-label="Auto-scroll" data-i18n-aria-label="autoScroll"><span aria-hidden="true"></span><strong data-i18n="autoScroll">Auto-scroll</strong></label></div>
<div id="log-resize-frame" class="log-resize-frame">
<div id="log-list" class="log-list" tabindex="0"></div>
<span id="log-resize-handle" class="log-resize-handle" aria-hidden="true"></span>
</div>
<p id="empty-logs" class="empty-state" data-i18n="noLogs">No logs yet</p>
</section>
</div>
<div id="panel-plugins" class="tab-panel resource-panel" role="tabpanel" aria-labelledby="tab-plugins" hidden>
<section aria-labelledby="plugins-title">
<div class="section-heading resource-section-heading"><h2 id="plugins-title" data-i18n="systemPlugins">System plugins</h2><div class="resource-heading-detail"><p data-i18n="systemPluginsConsoleDetail">Manage all bundled System Plugins, including recovery of the Platform Management integration in DSH.</p><input id="plugins-search" class="resource-search" type="search" placeholder="Search System Plugins" aria-label="Search System Plugins" data-i18n-placeholder="searchSystemPlugins" data-i18n-aria-label="searchSystemPlugins"></div></div>
<div id="system-plugin-recovery" class="recovery-summary" role="status" hidden><strong data-i18n="dshUnavailable">DSH is unavailable</strong><p id="system-plugin-recovery-detail"></p></div>
<div id="plugin-restart-required" class="plugin-restart-notice" role="status" hidden><div><strong data-i18n="pluginChangesPending">Changes pending</strong><p id="system-plugin-draft-summary"></p></div><div class="plugin-draft-actions"><button id="cancel-system-plugin-changes" type="button" class="secondary" data-i18n="cancelChanges">Cancel changes</button><button id="apply-system-plugin-changes" type="button" class="primary" data-i18n="applyUserPluginChanges">Apply and restart DSH</button></div></div>
<div id="bundled-plugins" class="plugin-list"></div>
<p id="empty-plugins" class="empty-state" data-i18n="noSystemPlugins" hidden>The current Environment provides no System Plugins.</p>
<div id="plugins-pagination" class="list-pagination" hidden><span id="plugins-page-status"></span><label><select id="plugins-page-size" aria-label="Items per page" data-i18n-aria-label="itemsPerPage"><option value="5">5</option><option value="10">10</option><option value="20">20</option><option value="50">50</option></select><span data-i18n="itemsPerPageSuffix">/ page</span></label><div class="page-navigation"><button id="plugins-page-previous" type="button" class="compact page-arrow" aria-label="Previous" data-i18n-aria-label="previousPage">‹</button><strong id="plugins-page-current">1</strong><button id="plugins-page-next" type="button" class="compact page-arrow" aria-label="Next" data-i18n-aria-label="nextPage">›</button></div><label class="page-jump"><span data-i18n="goToPage">Go to</span><input id="plugins-page-jump" type="number" min="1" value="1" inputmode="numeric"><span data-i18n="pageUnit">page</span></label></div>
<p id="plugin-operation" class="operation-result" hidden></p>
</section>
</div>
<div id="panel-user-plugins" class="tab-panel resource-panel" role="tabpanel" aria-labelledby="tab-user-plugins" hidden>
<section aria-labelledby="user-plugins-title">
<div class="section-heading resource-section-heading"><h2 id="user-plugins-title" data-i18n="userPlugins">User plugins</h2><div class="resource-heading-detail"><p data-i18n="userPluginsDetail">Recover user-installed Web Profile plugins without starting DSH.</p><input id="user-plugins-search" class="resource-search" type="search" placeholder="Search User Plugins" aria-label="Search User Plugins" data-i18n-placeholder="searchUserPlugins" data-i18n-aria-label="searchUserPlugins"></div></div>
<div id="user-plugin-recovery" class="recovery-summary" role="status" hidden><strong data-i18n="dshUnavailable">DSH is unavailable</strong><p id="user-plugin-recovery-detail"></p></div>
<div id="user-plugin-draft-actions" class="plugin-restart-notice" role="status" hidden><div><strong data-i18n="pluginChangesPending">Changes pending</strong><p id="user-plugin-draft-summary"></p></div><div class="plugin-draft-actions"><button id="cancel-user-plugin-changes" type="button" class="secondary" data-i18n="cancelChanges">Cancel changes</button><button id="apply-user-plugin-changes" type="button" class="primary" data-i18n="applyUserPluginChanges">Apply and restart DSH</button></div></div>
<div id="user-plugin-list" class="user-plugin-list"></div>
<p id="empty-user-plugins" class="empty-state" data-i18n="noUserPlugins" hidden>No managed user plugins were found in the Web Profile.</p>
<div id="user-plugins-pagination" class="list-pagination" hidden><span id="user-plugins-page-status"></span><label><select id="user-plugins-page-size" aria-label="Items per page" data-i18n-aria-label="itemsPerPage"><option value="5">5</option><option value="10">10</option><option value="20">20</option><option value="50">50</option></select><span data-i18n="itemsPerPageSuffix">/ page</span></label><div class="page-navigation"><button id="user-plugins-page-previous" type="button" class="compact page-arrow" aria-label="Previous" data-i18n-aria-label="previousPage">‹</button><strong id="user-plugins-page-current">1</strong><button id="user-plugins-page-next" type="button" class="compact page-arrow" aria-label="Next" data-i18n-aria-label="nextPage">›</button></div><label class="page-jump"><span data-i18n="goToPage">Go to</span><input id="user-plugins-page-jump" type="number" min="1" value="1" inputmode="numeric"><span data-i18n="pageUnit">page</span></label></div>
<p id="user-plugin-operation" class="operation-result" role="status" aria-live="polite" hidden></p>
</section>
</div>
<div id="panel-skills" class="tab-panel resource-panel" role="tabpanel" aria-labelledby="tab-skills" hidden>
<section aria-labelledby="skills-title">
<div class="section-heading resource-section-heading"><h2 id="skills-title" data-i18n="systemSkills">System skills</h2><div class="resource-heading-detail"><p data-i18n="systemSkillsConsoleDetail">Manage signed Agent guidance supplied by the current Bootstrap.</p><input id="system-skills-search" class="resource-search" type="search" placeholder="Search System Skills" aria-label="Search System Skills" data-i18n-placeholder="searchSystemSkills" data-i18n-aria-label="searchSystemSkills"></div></div>
<div id="system-skill-recovery" class="recovery-summary" role="status" hidden><strong data-i18n="dshUnavailable">DSH is unavailable</strong><p id="system-skill-recovery-detail"></p></div>
<div id="system-skills" class="plugin-list"></div>
<p id="empty-skills" class="empty-state" data-i18n="noSystemSkills" hidden>The current Bootstrap provides no System Skills.</p>
<div id="system-skills-pagination" class="list-pagination" hidden><span id="system-skills-page-status"></span><label><select id="system-skills-page-size" aria-label="Items per page" data-i18n-aria-label="itemsPerPage"><option value="5">5</option><option value="10">10</option><option value="20">20</option><option value="50">50</option></select><span data-i18n="itemsPerPageSuffix">/ page</span></label><div class="page-navigation"><button id="system-skills-page-previous" type="button" class="compact page-arrow" aria-label="Previous" data-i18n-aria-label="previousPage">‹</button><strong id="system-skills-page-current">1</strong><button id="system-skills-page-next" type="button" class="compact page-arrow" aria-label="Next" data-i18n-aria-label="nextPage">›</button></div><label class="page-jump"><span data-i18n="goToPage">Go to</span><input id="system-skills-page-jump" type="number" min="1" value="1" inputmode="numeric"><span data-i18n="pageUnit">page</span></label></div>
<p id="skill-operation" class="operation-result" role="status" aria-live="polite" hidden></p>
</section>
</div>
<div id="panel-user-skills" class="tab-panel resource-panel" role="tabpanel" aria-labelledby="tab-user-skills" hidden>
<section aria-labelledby="user-skills-title">
<div class="section-heading resource-section-heading"><h2 id="user-skills-title" data-i18n="userSkills">User skills</h2><div class="resource-heading-detail"><p data-i18n="userSkillsDetail">Enable, disable, or permanently delete skills from the DSH user roots.</p><input id="user-skills-search" class="resource-search" type="search" placeholder="Search User Skills" aria-label="Search User Skills" data-i18n-placeholder="searchUserSkills" data-i18n-aria-label="searchUserSkills"></div></div>
<div id="user-skill-recovery" class="recovery-summary" role="status" hidden><strong data-i18n="dshUnavailable">DSH is unavailable</strong><p id="user-skill-recovery-detail"></p></div>
<div id="user-skills" class="plugin-list"></div>
<p id="empty-user-skills" class="empty-state" data-i18n="noUserSkills" hidden>No user skills were found.</p>
<div id="user-skills-pagination" class="list-pagination" hidden><span id="user-skills-page-status"></span><label><select id="user-skills-page-size" aria-label="Items per page" data-i18n-aria-label="itemsPerPage"><option value="5">5</option><option value="10">10</option><option value="20">20</option><option value="50">50</option></select><span data-i18n="itemsPerPageSuffix">/ page</span></label><div class="page-navigation"><button id="user-skills-page-previous" type="button" class="compact page-arrow" aria-label="Previous" data-i18n-aria-label="previousPage">‹</button><strong id="user-skills-page-current">1</strong><button id="user-skills-page-next" type="button" class="compact page-arrow" aria-label="Next" data-i18n-aria-label="nextPage">›</button></div><label class="page-jump"><span data-i18n="goToPage">Go to</span><input id="user-skills-page-jump" type="number" min="1" value="1" inputmode="numeric"><span data-i18n="pageUnit">page</span></label></div>
<p id="user-skill-operation" class="operation-result" role="status" aria-live="polite" hidden></p>
</section>
</div>
<div id="panel-terminal" class="tab-panel" role="tabpanel" aria-labelledby="tab-terminal" hidden>
<section class="terminal-section" aria-labelledby="terminal-title">
<div class="section-heading terminal-heading">
<div><h2 id="terminal-title" data-i18n="terminal">Terminal</h2><p data-i18n="terminalDetail">Open a container shell that remains available while DSH restarts.</p></div>
<div class="terminal-actions">
<button id="new-terminal" type="button" class="primary" data-i18n="newTerminal">New session</button>
<button id="close-terminal" type="button" class="secondary" data-i18n="closeTerminal" disabled>Close session</button>
</div>
</div>
<div class="terminal-status" id="terminal-status" data-state="idle" role="status" aria-live="polite"><span aria-hidden="true"></span><strong data-i18n="terminalIdle">No active session</strong></div>
<div id="terminal-frame" class="terminal-frame">
<div id="terminal-screen" aria-label="Container terminal" data-i18n-aria-label="terminalScreen"></div>
<p id="terminal-placeholder"><span id="terminal-loader" class="terminal-loader" aria-hidden="true" hidden></span><span id="terminal-placeholder-label" data-i18n="terminalPlaceholder">Start a session to open an interactive Bash shell.</span></p>
</div>
</section>
</div>
<div id="panel-files" class="tab-panel" role="tabpanel" aria-labelledby="tab-files" hidden>
<section class="file-section" aria-labelledby="files-title">
<div class="section-heading file-heading">
<div><h2 id="files-title" data-i18n="files">File management</h2><p data-i18n="filesDetail">Manage container files with administrator permissions.</p></div>
<div class="file-heading-actions"><button id="file-new" type="button" class="secondary" aria-controls="file-create-panel" aria-expanded="false" data-i18n="newItem">New</button><button id="file-upload" type="button" class="primary" data-i18n="uploadFiles">Upload files</button><button id="file-upload-directory" type="button" class="secondary" data-i18n="uploadDirectory">Upload folder</button><input id="file-upload-input" type="file" multiple hidden><input id="file-upload-directory-input" type="file" webkitdirectory multiple hidden></div>
</div>
<form id="file-create-panel" class="file-create-panel" hidden>
<div class="segmented file-create-kind" role="group" aria-label="Item type" data-i18n-aria-label="itemType">
<button type="button" data-file-create-kind="touch" aria-pressed="true" data-i18n="newFile">File</button>
<button type="button" data-file-create-kind="mkdir" aria-pressed="false" data-i18n="newDirectory">Directory</button>
</div>
<label class="file-create-name"><span data-i18n="itemName">Name</span><input id="file-create-name" type="text" maxlength="255" autocomplete="off" spellcheck="false" data-i18n-placeholder="enterName" aria-label="Name" data-i18n-aria-label="itemName"></label>
<div class="file-create-actions"><button id="file-create-cancel" type="button" class="secondary" data-i18n="cancel">Cancel</button><button id="file-create-submit" type="submit" class="primary" data-i18n="createItem" disabled>Create</button></div>
<p id="file-create-location" class="file-create-location"></p>
<p id="file-create-error" class="file-create-error" role="alert" hidden></p>
</form>
<div class="file-pathbar">
<button id="file-back" type="button" class="compact file-nav-button" title="Back" data-i18n-title="back" aria-label="Back" data-i18n-aria-label="back" disabled><span class="file-nav-icon back" aria-hidden="true"></span></button>
<button id="file-forward" type="button" class="compact file-nav-button" title="Forward" data-i18n-title="forward" aria-label="Forward" data-i18n-aria-label="forward" disabled><span class="file-nav-icon forward" aria-hidden="true"></span></button>
<button id="file-up" type="button" class="compact file-nav-button" title="Parent directory" data-i18n-title="parentDirectory" aria-label="Parent directory" data-i18n-aria-label="parentDirectory" disabled><span class="file-nav-icon up" aria-hidden="true"></span></button>
<input id="file-path" type="text" value="" spellcheck="false" aria-label="Path" data-i18n-aria-label="path">
<button id="file-refresh" type="button" class="compact" data-i18n="refresh">Refresh</button>
</div>
<div class="file-searchbar">
<input id="file-search" type="search" placeholder="Filter files" data-i18n-placeholder="filterFiles" aria-label="Filter files" data-i18n-aria-label="filterFiles">
<button id="file-search-recursive" type="button" class="compact" data-i18n="searchDirectory">Search this directory</button>
<label><input id="file-hidden" type="checkbox" checked> <span data-i18n="showHidden">Show hidden</span></label>
</div>
<div id="file-managed-warning" class="message notice" data-i18n="managedPathWarning" hidden>This path is platform-managed. Changes may be replaced and can damage the current deployment.</div>
<div class="file-browser">
<nav id="file-shortcuts" class="file-shortcuts" aria-label="Locations" data-i18n-aria-label="locations"></nav>
<div class="file-main">
<div class="file-drop-overlay" aria-hidden="true"><strong data-i18n="dropToUpload">Drop to upload here</strong><span data-i18n="dropUploadDestination">Files and folders will be uploaded to the current directory.</span></div>
<table class="file-table"><thead><tr><th class="file-check"><input id="file-select-all" type="checkbox" aria-label="Select all" data-i18n-aria-label="selectAll"></th><th aria-sort="ascending"><button type="button" data-file-sort="name" data-sort-order="asc"><span data-i18n="fileName">Name</span><span class="file-sort-arrows" aria-hidden="true"></span></button></th><th aria-sort="none"><button type="button" data-file-sort="size"><span data-i18n="fileSize">Size</span><span class="file-sort-arrows" aria-hidden="true"></span></button></th><th data-i18n="fileOwner">User:group</th><th aria-sort="none"><button type="button" data-file-sort="modifiedAt"><span data-i18n="fileModified">Modified</span><span class="file-sort-arrows" aria-hidden="true"></span></button></th><th data-i18n="fileMode">Mode</th></tr></thead><tbody id="file-list"></tbody></table>
<p id="file-empty" class="empty-state" data-i18n="emptyDirectory" hidden>This directory is empty.</p>
<div class="file-pagination"><span id="file-page-status"></span><label><select id="file-page-size" aria-label="Items per page" data-i18n-aria-label="itemsPerPage"><option value="50">50</option><option value="100" selected>100</option><option value="200">200</option></select><span data-i18n="itemsPerPageSuffix">/ page</span></label><div class="page-navigation"><button id="file-page-previous" type="button" class="compact page-arrow" aria-label="Previous" data-i18n-aria-label="previousPage" disabled>‹</button><strong id="file-page-current">1</strong><button id="file-page-next" type="button" class="compact page-arrow" aria-label="Next" data-i18n-aria-label="nextPage" disabled>›</button></div><label class="page-jump"><span data-i18n="goToPage">Go to</span><input id="file-page-jump" type="number" min="1" value="1" inputmode="numeric"><span data-i18n="pageUnit">page</span></label></div>
</div>
</div>
<div class="file-selection"><span id="file-selection-count" data-i18n="noFilesSelected">No files selected</span><div><button id="file-copy" type="button" class="compact" data-i18n="copy" disabled>Copy</button><button id="file-cut" type="button" class="compact" data-i18n="cut" disabled>Cut</button><button id="file-paste" type="button" class="compact" data-i18n="paste" disabled>Paste</button><button id="file-archive" type="button" class="compact" data-i18n="compress" disabled>Compress</button><button id="file-extract" type="button" class="compact" data-i18n="extract" disabled>Extract</button><button id="file-rename" type="button" class="compact" data-i18n="rename" disabled>Rename</button><button id="file-download" type="button" class="compact" data-i18n="download" disabled>Download</button><button id="file-attributes" type="button" class="compact" data-i18n="editPermissions" aria-controls="file-attributes-panel" aria-expanded="false" disabled>Edit permissions</button><button id="file-delete" type="button" class="danger-text compact" data-i18n="deletePermanently" disabled>Delete permanently</button></div></div>
<div id="file-task-state" class="file-task-state" role="status" hidden><div class="file-task-heading"><strong data-i18n="fileOperations">File operations</strong><span id="file-task-label"></span></div><div id="file-task-list" class="file-task-list"></div><button id="file-task-cancel" type="button" class="compact" data-i18n="cancel" hidden>Cancel</button></div>
<form id="file-archive-panel" class="file-archive-panel" hidden>
<div><h3 id="file-archive-title" data-i18n="compressItems">Compress selected items</h3><p id="file-archive-detail"></p></div>
<label><span data-i18n="archiveFormat">Archive format</span><select id="file-archive-format"><option value="zip">ZIP</option><option value="7z">7z</option><option value="tar.gz">tar.gz</option></select></label>
<label id="file-archive-name-row"><span data-i18n="archiveName">Archive name</span><input id="file-archive-name" type="text" maxlength="255" spellcheck="false"></label>
<div class="file-archive-actions"><button id="file-archive-cancel" type="button" class="secondary" data-i18n="cancel">Cancel</button><button id="file-archive-submit" type="submit" class="primary" data-i18n="compress">Compress</button></div>
</form>
<section id="file-attributes-panel" class="file-attributes-panel" aria-labelledby="file-attributes-title" hidden>
<div class="file-attributes-heading"><div><h3 id="file-attributes-title" data-i18n="editPermissions">Edit permissions</h3><p id="file-attributes-path"></p></div><button id="file-attributes-close" type="button" class="compact" data-i18n="close">Close</button></div>
<div class="file-permission-grid" role="group" aria-label="Permissions" data-i18n-aria-label="permissions">
<span></span><strong id="permission-read" data-i18n="permissionRead">Read</strong><strong id="permission-write" data-i18n="permissionWrite">Write</strong><strong id="permission-execute" data-i18n="permissionExecute">Execute</strong>
<span id="permission-owner" data-i18n="permissionOwner">Owner</span><input type="checkbox" data-permission-bit="256" aria-labelledby="permission-owner permission-read"><input type="checkbox" data-permission-bit="128" aria-labelledby="permission-owner permission-write"><input type="checkbox" data-permission-bit="64" aria-labelledby="permission-owner permission-execute">
<span id="permission-group" data-i18n="permissionGroup">Group</span><input type="checkbox" data-permission-bit="32" aria-labelledby="permission-group permission-read"><input type="checkbox" data-permission-bit="16" aria-labelledby="permission-group permission-write"><input type="checkbox" data-permission-bit="8" aria-labelledby="permission-group permission-execute">
<span id="permission-others" data-i18n="permissionOthers">Others</span><input type="checkbox" data-permission-bit="4" aria-labelledby="permission-others permission-read"><input type="checkbox" data-permission-bit="2" aria-labelledby="permission-others permission-write"><input type="checkbox" data-permission-bit="1" aria-labelledby="permission-others permission-execute">
</div>
<div class="file-attribute-fields">
<label><span data-i18n="fileMode">Mode</span><input id="file-attributes-mode" type="text" inputmode="numeric" maxlength="4" spellcheck="false" placeholder="0755"></label>
<label><span data-i18n="fileUser">User</span><input id="file-attributes-user" type="text" maxlength="256" spellcheck="false"></label>
<label><span data-i18n="fileGroup">Group</span><input id="file-attributes-group" type="text" maxlength="256" spellcheck="false"></label>
</div>
<label id="file-attributes-recursive-row" class="file-attributes-recursive"><input id="file-attributes-recursive" type="checkbox"> <span data-i18n="recursiveAttributes">Also change child attributes</span></label>
<div class="file-attributes-actions"><button id="file-attributes-cancel" type="button" class="secondary" data-i18n="cancel">Cancel</button><button id="file-attributes-save" type="button" class="primary" data-i18n="applyPermissions">Apply permissions</button></div>
</section>
<p id="file-operation" class="operation-result" role="status" hidden></p>
</section>
</div>
</main>
<dialog id="stable-dialog" aria-labelledby="stable-dialog-title"><form method="dialog"><h2 id="stable-dialog-title" data-i18n="returnStableTitle">Restore Stable state</h2><p><span data-i18n="returnStableWarning">The following data snapshot will be restored and newer data will be lost:</span> <strong id="stable-snapshot">-</strong></p><label class="confirm"><input type="checkbox" id="confirm-data-loss"><span data-i18n="confirmDataLoss">I understand and confirm the loss of newer data</span></label><div class="dialog-actions"><button value="cancel" class="secondary" data-i18n="cancel">Cancel</button><button type="button" id="confirm-stable" class="danger" data-i18n="confirm" disabled>Restore</button></div></form></dialog>
<dialog id="restart-dialog" aria-labelledby="restart-dialog-title"><form method="dialog"><h2 id="restart-dialog-title" data-i18n="restartTitle">Restart DSH?</h2><p data-i18n="restartWarning">The current DSH connection will be interrupted briefly. This console remains available.</p><div class="dialog-actions"><button value="cancel" class="secondary" data-i18n="cancel">Cancel</button><button type="button" id="confirm-restart" class="primary" data-i18n="confirmRestart">Restart</button></div></form></dialog>
<dialog id="stop-dialog" aria-labelledby="stop-dialog-title"><form method="dialog"><h2 id="stop-dialog-title" data-i18n="stopTitle">Stop DSH?</h2><p data-i18n="stopWarning">DSH remains stopped until it is started here or the container restarts. The management console remains available.</p><div class="dialog-actions"><button value="cancel" class="secondary" data-i18n="cancel">Cancel</button><button type="button" id="confirm-stop" class="danger" data-i18n="confirmStop">Stop DSH</button></div></form></dialog>
<dialog id="file-conflict-dialog" class="file-conflict-dialog" aria-labelledby="file-conflict-title">
<form id="file-conflict-form">
<h2 id="file-conflict-title" data-i18n="fileConflictTitle">Destination already exists</h2>
<p><span data-i18n="fileConflictDetail">Choose how to handle:</span> <strong id="file-conflict-path"></strong></p>
<div class="file-conflict-options">
<label><input type="radio" name="file-conflict-choice" value="overwrite"><span><strong data-i18n="conflictOverwrite">Overwrite</strong><small data-i18n="conflictOverwriteDetail">Replace the existing item.</small></span></label>
<label><input type="radio" name="file-conflict-choice" value="rename"><span><strong data-i18n="conflictRename">Auto rename</strong><small data-i18n="conflictRenameDetail">Keep both items with a new name.</small></span></label>
<label><input type="radio" name="file-conflict-choice" value="skip"><span><strong data-i18n="conflictSkip">Skip</strong><small data-i18n="conflictSkipDetail">Leave the existing item unchanged.</small></span></label>
</div>
<label id="file-conflict-all-row" class="confirm"><input id="file-conflict-all" type="checkbox"><span data-i18n="conflictApplyAll">Apply to all remaining conflicts</span></label>
<div class="dialog-actions"><button id="file-conflict-cancel" type="button" class="secondary" data-i18n="cancel">Cancel</button><button id="file-conflict-confirm" type="submit" class="primary" data-i18n="confirmChoice" disabled>Confirm</button></div>
</form>
</dialog>
<dialog id="file-editor-dialog" class="file-editor-dialog" aria-labelledby="file-editor-title"><form method="dialog"><header><div><h2 id="file-editor-title" data-i18n="editFile">Edit file</h2><p id="file-editor-path"></p></div><button id="file-editor-close" value="cancel" class="secondary" data-i18n="close">Close</button></header><div class="file-editor-frame"><pre id="file-editor-lines" aria-hidden="true">1</pre><textarea id="file-editor-content" spellcheck="false" aria-label="File content" data-i18n-aria-label="fileContent"></textarea></div><p id="file-editor-meta"></p><footer class="file-editor-footer"><span id="file-editor-status" role="status" aria-live="polite"></span><div class="dialog-actions"><button id="file-editor-reload" type="button" class="secondary" data-i18n="reload">Reload</button><button id="file-editor-save-as" type="button" class="secondary" data-i18n="saveAs">Save as</button><button id="file-editor-save" type="button" class="primary" data-i18n="save">Save</button></div></footer></form></dialog>
<dialog id="text-input-dialog" class="page-input-dialog" aria-labelledby="text-input-dialog-title"><form id="text-input-dialog-form"><h2 id="text-input-dialog-title"></h2><p id="text-input-dialog-detail"></p><input id="text-input-dialog-value" type="text" autocomplete="off" spellcheck="false"><p id="text-input-dialog-error" class="dialog-error" role="alert" hidden></p><div class="dialog-actions"><button id="text-input-dialog-cancel" type="button" class="secondary" data-i18n="cancel">Cancel</button><button id="text-input-dialog-confirm" type="submit" class="primary" data-i18n="confirmChoice">Confirm</button></div></form></dialog>
<dialog id="confirmation-dialog" aria-labelledby="confirmation-dialog-title"><form id="confirmation-dialog-form"><h2 id="confirmation-dialog-title"></h2><p id="confirmation-dialog-detail"></p><div class="dialog-actions"><button id="confirmation-dialog-cancel" type="button" class="secondary" data-i18n="cancel">Cancel</button><button id="confirmation-dialog-confirm" type="submit" class="primary" data-i18n="confirmChoice">Confirm</button></div></form></dialog>
<dialog id="proxy-scope-dialog" class="proxy-scope-dialog" aria-labelledby="proxy-scope-dialog-title"><form method="dialog"><header><div><h2 id="proxy-scope-dialog-title" data-i18n="proxyScopeGuideTitle">Proxy scope guide</h2><p data-i18n="proxyScopeGuideDetail">How managed network sources are classified.</p></div><button value="cancel" class="secondary" data-i18n="close">Close</button></header><div id="proxy-scope-catalog" class="proxy-scope-catalog"></div><div id="proxy-scope-summaries" class="proxy-scope-summaries"></div></form></dialog>
<dialog id="proxy-provider-info-dialog" class="proxy-provider-info-dialog" aria-labelledby="proxy-provider-info-title"><form method="dialog"><header><h2 id="proxy-provider-info-title"></h2><button value="cancel" class="secondary" data-i18n="close">Close</button></header><p id="proxy-provider-info-detail"></p></form></dialog>
<dialog id="proxy-component-dialog" class="proxy-provider-info-dialog" aria-labelledby="proxy-component-dialog-title"><form method="dialog"><header><h2 id="proxy-component-dialog-title" data-i18n="proxyComponentIssueTitle">Outbound proxy component unavailable</h2><button value="cancel" class="secondary" data-i18n="close">Close</button></header><p data-i18n="proxyComponentIssueDetail">The DSH Docker outbound proxy component is unavailable. Settings can still be saved, but managed proxy traffic and connection tests may fail. This does not indicate whether the configured external proxy is reachable.</p></form></dialog>
<dialog id="proxy-system-rules-dialog" class="proxy-provider-info-dialog" aria-labelledby="proxy-system-rules-title"><form method="dialog"><header><h2 id="proxy-system-rules-title" data-i18n="proxySystemRulesTitle">Built-in direct rules</h2><button value="cancel" class="secondary" data-i18n="close">Close</button></header><div class="proxy-system-rules-body"><p data-i18n="proxySystemRulesDetail">These platform-managed local destinations are always direct and do not need to be entered again.</p><pre id="proxy-system-rules-list"></pre></div></form></dialog>
<dialog id="proxy-test-dialog" class="proxy-test-dialog" aria-labelledby="proxy-test-dialog-title"><div><header><div><h2 id="proxy-test-dialog-title" data-i18n="proxyTest">Connection test</h2><p data-i18n="proxyTestDetail">Tests the form values without saving or activating them.</p></div></header><ol id="proxy-test-stages" class="proxy-test-stages"></ol><footer><p id="proxy-test-result" class="operation-result" role="status" aria-live="polite" hidden></p><button id="proxy-test-cancel" type="button" class="secondary" hidden>Cancel</button></footer></div></dialog>
<dialog id="auth-totp-dialog" class="auth-totp-dialog" aria-labelledby="auth-totp-dialog-title"><form id="auth-totp-form"><header><h2 id="auth-totp-dialog-title" data-i18n="authTotpSetupTitle">Set up two-factor authentication</h2><button id="auth-totp-close" type="button" class="icon-button" data-i18n-aria-label="close" data-i18n-title="close">×</button></header><p id="auth-totp-dialog-detail" data-i18n="authTotpSetupDetail">Scan the QR code with an authenticator app, then enter its current code.</p><img id="auth-totp-qr" alt="" data-i18n-alt="authTotpQrAlt"><button id="auth-totp-secret-toggle" type="button" class="secondary" aria-expanded="false" data-i18n="authTotpShowSecret">Show 2FA secret</button><div id="auth-totp-secret-row" class="auth-totp-secret" hidden><code id="auth-totp-secret"></code></div><label><span data-i18n="authTotpCode">Authentication code</span><input id="auth-totp-code" type="text" autocomplete="one-time-code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6"></label><small id="auth-totp-error" class="field-error" role="alert" hidden></small><div class="dialog-actions"><button id="auth-totp-confirm" type="submit" class="primary" data-i18n="authTotpConfirm">Confirm code</button></div></form></dialog>
<dialog id="auth-isolation-dialog" class="auth-isolation-dialog" aria-labelledby="auth-isolation-dialog-title"><form method="dialog"><h2 id="auth-isolation-dialog-title" data-i18n="authComparisonTitle">Management isolation comparison</h2><p data-i18n="authComparisonDetail">Both modes require authentication and final-execution authorization. A separate origin additionally isolates the full Management session from DSH client plugins.</p><div class="auth-comparison-table"><div></div><strong data-i18n="authModeCompatShort">Compatibility</strong><strong data-i18n="authModeIsolatedShort">Separate origin</strong><span data-i18n="authComparisonSessions">Separate DSH and Management sessions</span><span data-i18n="authComparisonYes">Yes</span><span data-i18n="authComparisonYes">Yes</span><span data-i18n="authComparisonPlugins">Protect from same-origin DSH client plugins</span><span data-i18n="authComparisonNo">No</span><span data-i18n="authComparisonYes">Yes</span><span data-i18n="authComparisonCapabilities">Final-execution capabilities</span><span data-i18n="authComparisonYes">Yes</span><span data-i18n="authComparisonYes">Yes</span><span data-i18n="authComparisonRoot">Protection after DSH obtains container Root</span><span data-i18n="authComparisonNo">No</span><span data-i18n="authComparisonNo">No</span></div><div class="dialog-actions"><button value="cancel" class="primary" data-i18n="understood">Understood</button></div></form></dialog>
<dialog id="auth-isolation-confirm-dialog" aria-labelledby="auth-isolation-confirm-title"><form method="dialog"><h2 id="auth-isolation-confirm-title" data-i18n="authIsolationConfirmTitle">Enable separate Management origin?</h2><p data-i18n="authIsolationConfirmDetail">Publish container port 3081 or route a separate address to it. The platform verifies the candidate before changing the current mode.</p><div class="dialog-actions"><button value="cancel" class="secondary" data-i18n="cancel">Cancel</button><button value="confirm" class="primary" data-i18n="authIsolationConfirm">I have configured the entry</button></div></form></dialog>
<script type="module" src="./app.js"></script>
</body>
</html>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,4 @@
try {
const theme = localStorage.getItem('dsh-platform:console-theme')
if (theme === 'light' || theme === 'dark') document.documentElement.dataset.theme = theme
} catch {}
@@ -0,0 +1,55 @@
import { createServer, request as httpRequest } from 'node:http'
const ROUTES = new Set([
'GET /_dsh_platform/api/v1/status',
'POST /_dsh_platform/api/v1/check',
'POST /_dsh_platform/api/v1/update',
'GET /_dsh_platform/api/v1/channel',
'PUT /_dsh_platform/api/v1/channel',
'POST /_dsh_platform/api/v1/holds/retry',
'GET /_dsh_platform/api/v1/rollback-plan',
'POST /_dsh_platform/api/v1/rollback',
'POST /_dsh_platform/api/v1/return-stable',
'POST /_dsh_platform/api/v1/start-dsh',
'POST /_dsh_platform/api/v1/stop-dsh',
'POST /_dsh_platform/api/v1/restart-dsh',
'GET /_dsh_platform/api/v1/logs',
])
export function restrictedCliRoute(method, target) {
let pathname
try { pathname = new URL(target, 'http://management-cli.internal').pathname } catch { return false }
return ROUTES.has(`${method} ${pathname}`)
}
export function createRestrictedCliServer({ managementSocketPath, token }) {
if (typeof token !== 'string' || token.length < 32) throw new Error('restricted CLI token is invalid')
return createServer((request, response) => {
if (!restrictedCliRoute(request.method ?? 'GET', request.url ?? '/')) {
response.writeHead(404, { 'content-type': 'application/json' })
response.end(JSON.stringify({ error: 'CLI route is not available', code: 'CLI_ROUTE_NOT_AVAILABLE' }))
return
}
const headers = { 'x-dsh-restricted-cli': token }
for (const name of ['accept', 'content-length', 'content-type']) {
if (request.headers[name] !== undefined) headers[name] = request.headers[name]
}
const upstream = httpRequest({
socketPath: managementSocketPath,
method: request.method,
path: request.url,
headers,
}, upstreamResponse => {
response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers)
upstreamResponse.pipe(response)
})
upstream.once('error', error => {
if (response.headersSent) response.destroy(error)
else {
response.writeHead(503, { 'content-type': 'application/json' })
response.end(JSON.stringify({ error: 'Management service is unavailable', code: 'MANAGEMENT_UNAVAILABLE' }))
}
})
request.pipe(upstream)
})
}
@@ -0,0 +1,55 @@
import { readFileSync } from 'node:fs'
import { Agent, fetch as undiciFetch, ProxyAgent } from 'undici'
import { OUTBOUND_PROXY_PORTS, outboundProxyScopeEnabled } from '../../../platform/lib/outbound-proxy.mjs'
const LOOPBACK = '127.0.0.1'
const DEFAULT_ROUTING_STATE = '/run/dsh-platform/outbound-proxy-routing.json'
export const PROXY_SCOPE_PORTS = Object.freeze({
updates: OUTBOUND_PROXY_PORTS.updates,
platform: OUTBOUND_PROXY_PORTS.platform,
dshCore: OUTBOUND_PROXY_PORTS.dshCore,
dshPlugins: OUTBOUND_PROXY_PORTS.dshPlugins,
agent: OUTBOUND_PROXY_PORTS.agentNetwork,
terminal: OUTBOUND_PROXY_PORTS.managementTerminal,
provider: OUTBOUND_PROXY_PORTS.modelApi,
sharedDsh: OUTBOUND_PROXY_PORTS.sharedDsh,
})
const CONFIGURATION_SCOPE = Object.freeze({
updates: 'updates', platform: 'platform', dshCore: 'dshCore', dshPlugins: 'dshPlugins',
agent: 'agentNetwork', terminal: 'managementTerminal',
})
function proxyEnabled(path, scope) {
try {
const state = JSON.parse(readFileSync(path, 'utf8'))
if (state?.schema !== 1 || typeof state.enabled !== 'boolean') return true
const configurationScope = CONFIGURATION_SCOPE[scope]
return configurationScope === undefined ? true : outboundProxyScopeEnabled(state, configurationScope)
} catch {
return true
}
}
export function createScopedFetch(scope, {
fetchImpl = undiciFetch,
routingStatePath = DEFAULT_ROUTING_STATE,
createDirectDispatcher = () => new Agent(),
createProxyDispatcher = options => new ProxyAgent(options),
} = {}) {
const port = PROXY_SCOPE_PORTS[scope]
if (port === undefined) throw new TypeError(`unknown outbound proxy scope ${JSON.stringify(scope)}`)
const directDispatcher = createDirectDispatcher()
const proxyDispatcher = createProxyDispatcher({ uri: `http://${LOOPBACK}:${String(port)}` })
const scopedFetch = (input, init = {}) => fetchImpl(input, {
...init,
dispatcher: proxyEnabled(routingStatePath, scope) ? proxyDispatcher : directDispatcher,
})
scopedFetch.close = async () => {
await Promise.all([directDispatcher.close(), proxyDispatcher.close()])
}
return scopedFetch
}
export const scopedFetchInternals = Object.freeze({ proxyEnabled })
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,82 @@
import { createHash, randomUUID } from 'node:crypto'
import { chown, lstat, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
export const MAX_SETTINGS_DOCUMENT_BYTES = 1024 * 1024
const UTF8 = new TextDecoder('utf-8', { fatal: true })
function revision(bytes) {
return createHash('sha256').update(bytes).digest('hex')
}
function assertRegularFile(details) {
if (details.isSymbolicLink() || !details.isFile()) {
throw new Error('settings document must be a regular file')
}
}
export class SettingsDocumentConflictError extends Error {}
export class SettingsDocumentStore {
constructor(dshHome, { maxBytes = MAX_SETTINGS_DOCUMENT_BYTES } = {}) {
this.dshHome = resolve(dshHome)
this.path = join(this.dshHome, 'settings.yaml')
this.maxBytes = maxBytes
}
async read() {
let details
try {
details = await lstat(this.path)
} catch (error) {
if (error?.code === 'ENOENT') {
const bytes = Buffer.alloc(0)
return Object.freeze({ content: '', revision: revision(bytes), exists: false })
}
throw error
}
assertRegularFile(details)
if (details.size > this.maxBytes) throw new Error('settings document is too large')
const bytes = await readFile(this.path)
if (bytes.byteLength > this.maxBytes) throw new Error('settings document is too large')
let content
try {
content = UTF8.decode(bytes)
} catch {
throw new Error('settings document is not valid UTF-8')
}
return Object.freeze({ content, revision: revision(bytes), exists: true })
}
async write(content, expectedRevision) {
if (typeof content !== 'string') throw new Error('settings document content must be a string')
if (typeof expectedRevision !== 'string' || !/^[a-f0-9]{64}$/.test(expectedRevision)) {
throw new Error('settings document revision is invalid')
}
const bytes = Buffer.from(content)
if (bytes.byteLength > this.maxBytes) throw new Error('settings document is too large')
const current = await this.read()
if (current.revision !== expectedRevision) {
throw new SettingsDocumentConflictError('settings document changed since it was loaded')
}
await mkdir(this.dshHome, { recursive: true, mode: 0o700 })
const owner = current.exists ? await lstat(this.path) : await stat(this.dshHome)
if (current.exists) assertRegularFile(owner)
const temporary = join(this.dshHome, `.settings.yaml.${randomUUID()}.tmp`)
try {
await writeFile(temporary, bytes, {
flag: 'wx',
mode: current.exists ? owner.mode & 0o777 : 0o600,
})
if (process.getuid?.() === 0) await chown(temporary, owner.uid, owner.gid)
if ((await this.read()).revision !== expectedRevision) {
throw new SettingsDocumentConflictError('settings document changed while it was being saved')
}
await rename(temporary, this.path)
} finally {
await rm(temporary, { force: true })
}
return Object.freeze({ content, revision: revision(bytes), exists: true })
}
}
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
import base64
import fcntl
import json
import os
import pty
import select
import signal
import struct
import sys
import termios
def emit(value):
sys.stdout.write(json.dumps(value, separators=(",", ":")) + "\n")
sys.stdout.flush()
def resize(master, cols, rows):
fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
def write_all(target, data):
remaining = memoryview(data)
while remaining:
written = os.write(target, remaining)
remaining = remaining[written:]
def terminate(pid, requested_signal):
try:
os.killpg(pid, requested_signal)
except ProcessLookupError:
return
except PermissionError:
os.kill(pid, requested_signal)
def main():
if len(sys.argv) != 4:
raise RuntimeError("usage: pty-helper.py <cwd> <cols> <rows>")
cwd = sys.argv[1]
cols = int(sys.argv[2])
rows = int(sys.argv[3])
pid, master = pty.fork()
if pid == 0:
os.chdir(cwd)
os.umask(0o002)
os.execvpe("/bin/bash", ["/bin/bash", "-i"], os.environ)
def stop_child(received_signal, _frame):
terminate(pid, signal.SIGTERM if received_signal in (signal.SIGINT, signal.SIGTERM) else received_signal)
signal.signal(signal.SIGINT, stop_child)
signal.signal(signal.SIGTERM, stop_child)
resize(master, cols, rows)
stdin_fd = sys.stdin.fileno()
stdin_buffer = b""
stdin_open = True
while True:
readable, _, _ = select.select([master] + ([stdin_fd] if stdin_open else []), [], [], 0.25)
if master in readable:
try:
data = os.read(master, 16384)
except OSError:
data = b""
if not data:
break
emit({"type": "output", "data": base64.b64encode(data).decode("ascii")})
if stdin_open and stdin_fd in readable:
data = os.read(stdin_fd, 65536)
if not data:
stdin_open = False
terminate(pid, signal.SIGHUP)
else:
stdin_buffer += data
while b"\n" in stdin_buffer:
line, stdin_buffer = stdin_buffer.split(b"\n", 1)
command = json.loads(line.decode("utf-8"))
command_type = command.get("type")
if command_type == "input":
write_all(master, base64.b64decode(command["data"], validate=True))
elif command_type == "resize":
resize(master, int(command["cols"]), int(command["rows"]))
elif command_type == "signal":
terminate(pid, int(command["signal"]))
else:
raise RuntimeError("unknown PTY command")
finished, status = os.waitpid(pid, os.WNOHANG)
if finished == pid:
if os.WIFEXITED(status):
emit({"type": "exit", "code": os.WEXITSTATUS(status), "signal": None})
else:
emit({"type": "exit", "code": None, "signal": os.WTERMSIG(status)})
os.close(master)
return
_, status = os.waitpid(pid, 0)
if os.WIFEXITED(status):
emit({"type": "exit", "code": os.WEXITSTATUS(status), "signal": None})
else:
emit({"type": "exit", "code": None, "signal": os.WTERMSIG(status)})
os.close(master)
if __name__ == "__main__":
try:
main()
except Exception as error:
emit({"type": "error", "message": str(error)})
sys.exit(1)
@@ -0,0 +1,288 @@
import { spawn } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { EventEmitter } from 'node:events'
import { userInfo } from 'node:os'
import { resolve } from 'node:path'
import { StringDecoder } from 'node:string_decoder'
import { WebSocketServer } from 'ws'
import { OUTBOUND_PROXY_ENVIRONMENT_KEYS } from '../../../../platform/lib/outbound-proxy.mjs'
const SESSION_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
const MAX_INPUT_BYTES = 64 * 1024
const MAX_OUTPUT_BYTES = 256 * 1024
const MAX_SOCKET_BUFFER_BYTES = 1024 * 1024
const MAX_HELPER_INPUT_BUFFER_BYTES = 256 * 1024
const MAX_SESSIONS = 32
const MIN_COLS = 2
const MAX_COLS = 500
const MIN_ROWS = 1
const MAX_ROWS = 200
function dimensions(value = {}) {
if (value === null || typeof value !== 'object' || Array.isArray(value)
|| Object.keys(value).some(key => !['cols', 'rows'].includes(key))) throw new Error('Terminal dimensions are invalid')
const cols = value.cols ?? 80
const rows = value.rows ?? 24
if (!Number.isSafeInteger(cols) || cols < MIN_COLS || cols > MAX_COLS
|| !Number.isSafeInteger(rows) || rows < MIN_ROWS || rows > MAX_ROWS) {
throw new Error('Terminal dimensions are outside the supported range')
}
return { cols, rows }
}
function exactMessage(value, keys) {
return value !== null && typeof value === 'object' && !Array.isArray(value)
&& Object.keys(value).sort().join(',') === [...keys].sort().join(',')
}
class TerminalSession extends EventEmitter {
constructor({ id, helperPath, cwd, env, cols, rows, reconnectMs, report, python }) {
super()
this.id = id
this.createdAt = new Date().toISOString()
this.updatedAt = this.createdAt
this.status = 'running'
this.exit = null
this.output = Buffer.alloc(0)
this.connections = new Set()
this.everConnected = false
this.reconnectMs = reconnectMs
this.report = report
this.decoder = new StringDecoder('utf8')
this.outputDecoder = new StringDecoder('utf8')
this.lineBuffer = ''
this.closed = false
this.terminationTimers = []
this.child = spawn(python, [helperPath, cwd, String(cols), String(rows)], {
cwd,
env: { ...env, TERM: 'xterm-256color' },
stdio: ['pipe', 'pipe', 'pipe'],
})
this.childClosed = new Promise(resolveClosed => this.child.once('close', resolveClosed))
this.child.stdout.on('data', chunk => this.consume(chunk))
this.child.stderr.on('data', chunk => this.report('terminal.helper.stderr', {
sessionId: this.id,
bytes: chunk.byteLength,
level: 'warning',
}))
this.child.once('error', error => this.fail(error))
this.child.once('exit', (code, signal) => {
if (this.status === 'running') this.finish({ code, signal })
})
this.child.once('close', () => {
for (const timer of this.terminationTimers) clearTimeout(timer)
})
this.scheduleExpiry()
}
publicStatus() {
return Object.freeze({
sessionId: this.id,
status: this.status,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
connected: this.connections.size > 0,
exit: this.exit,
})
}
scheduleExpiry() {
clearTimeout(this.expiry)
this.expiry = setTimeout(() => {
if (this.connections.size === 0) this.terminate('reconnect-expired')
}, this.reconnectMs)
this.expiry.unref()
}
sendCommand(value) {
if (this.status !== 'running' || !this.child.stdin.writable) return false
this.child.stdin.write(`${JSON.stringify(value)}\n`)
return true
}
consume(chunk) {
this.lineBuffer += this.decoder.write(chunk)
for (let newline = this.lineBuffer.indexOf('\n'); newline !== -1; newline = this.lineBuffer.indexOf('\n')) {
const line = this.lineBuffer.slice(0, newline)
this.lineBuffer = this.lineBuffer.slice(newline + 1)
try {
const value = JSON.parse(line)
if (value.type === 'output' && typeof value.data === 'string') this.broadcastOutput(Buffer.from(value.data, 'base64'))
else if (value.type === 'exit') this.finish({ code: value.code ?? null, signal: value.signal ?? null })
else if (value.type === 'error') this.fail(new Error(String(value.message)))
else this.fail(new Error('PTY helper emitted an invalid message'))
} catch (error) {
this.fail(error)
}
}
}
broadcastOutput(bytes) {
this.output = Buffer.concat([this.output, bytes])
if (this.output.byteLength > MAX_OUTPUT_BYTES) this.output = this.output.subarray(this.output.byteLength - MAX_OUTPUT_BYTES)
const data = this.outputDecoder.write(bytes)
if (data === '') return
const message = JSON.stringify({ type: 'output', data })
for (const socket of this.connections) if (socket.readyState === 1) {
if (socket.bufferedAmount > MAX_SOCKET_BUFFER_BYTES) socket.close(1013, 'terminal client is too slow')
else socket.send(message)
}
}
attach(socket) {
clearTimeout(this.expiry)
const reconnect = this.everConnected
this.everConnected = true
this.connections.add(socket)
void this.report(reconnect ? 'terminal.session.reconnected' : 'terminal.session.connected', { sessionId: this.id })
if (this.output.byteLength > 0) socket.send(JSON.stringify({ type: 'output', data: this.output.toString('utf8') }))
if (this.exit !== null) socket.send(JSON.stringify({ type: 'exit', ...this.exit }))
socket.on('message', data => this.handleMessage(socket, data))
socket.once('close', () => {
this.connections.delete(socket)
void this.report('terminal.session.disconnected', { sessionId: this.id })
if (!this.closed && this.connections.size === 0) this.scheduleExpiry()
})
socket.once('error', error => this.report('terminal.session.connection.failed', { error, sessionId: this.id }))
}
handleMessage(socket, data) {
try {
if (Buffer.byteLength(data) > MAX_INPUT_BYTES + 1024) throw new Error('Terminal message is too large')
const value = JSON.parse(data.toString())
if (value.type === 'input') {
if (!exactMessage(value, ['type', 'data']) || typeof value.data !== 'string'
|| Buffer.byteLength(value.data) > MAX_INPUT_BYTES) throw new Error('Terminal input is invalid')
if (this.child.stdin.writableLength > MAX_HELPER_INPUT_BUFFER_BYTES) {
socket.close(1013, 'terminal input is too fast')
return
}
this.sendCommand({ type: 'input', data: Buffer.from(value.data).toString('base64') })
} else if (value.type === 'resize') {
if (!exactMessage(value, ['type', 'cols', 'rows'])) throw new Error('Terminal resize is invalid')
this.sendCommand({ type: 'resize', ...dimensions({ cols: value.cols, rows: value.rows }) })
} else throw new Error('Terminal message type is invalid')
} catch {
socket.close(1008, 'invalid terminal message')
}
}
finish(exit) {
if (this.status !== 'running') return
this.status = 'exited'
this.exit = Object.freeze({ code: exit.code ?? null, signal: exit.signal ?? null })
this.updatedAt = new Date().toISOString()
for (const socket of this.connections) if (socket.readyState === 1) {
socket.send(JSON.stringify({ type: 'exit', ...this.exit }))
}
void this.report('terminal.session.exited', { sessionId: this.id, ...this.exit })
if (this.connections.size === 0) this.scheduleExpiry()
this.emit('terminal-exit')
}
fail(error) {
if (this.status !== 'running') return
void this.report('terminal.session.failed', { error, sessionId: this.id })
this.terminate('failed')
}
terminate(reason = 'closed') {
if (this.closed) return
this.closed = true
clearTimeout(this.expiry)
if (this.status === 'running') {
this.sendCommand({ type: 'signal', signal: 15 })
for (const [delay, signal] of [[2_000, 'SIGTERM'], [4_000, 'SIGKILL']]) {
const timer = setTimeout(() => this.child.kill(signal), delay)
timer.unref()
this.terminationTimers.push(timer)
}
}
this.child.stdin.end()
for (const socket of this.connections) socket.close(1001, 'terminal session closed')
this.connections.clear()
void this.report('terminal.session.closed', { reason, sessionId: this.id })
this.emit('terminal-close')
}
async stop(reason) {
this.terminate(reason)
let timeout
await Promise.race([
this.childClosed,
new Promise(resolveTimeout => { timeout = setTimeout(resolveTimeout, 5_000) }),
])
clearTimeout(timeout)
}
}
export class TerminalSessionManager {
constructor({
helperPath = new URL('./pty-helper.py', import.meta.url).pathname,
cwd = '/root',
dshHome = '/data/dsh',
env = process.env,
python = '/usr/bin/python3',
reconnectMs = 30_000,
report = async () => {},
} = {}) {
this.helperPath = resolve(helperPath)
this.cwd = resolve(cwd)
this.env = { ...env, HOME: userInfo().homedir, DSH_HOME: dshHome }
for (const key of OUTBOUND_PROXY_ENVIRONMENT_KEYS) delete this.env[key]
this.python = python
this.reconnectMs = reconnectMs
this.report = (message, fields) => Promise.resolve().then(() => report(message, fields)).catch(() => {})
this.sessions = new Map()
this.webSockets = new WebSocketServer({ noServer: true, perMessageDeflate: false, maxPayload: MAX_INPUT_BYTES + 1024 })
}
create(value = {}, environment = {}) {
if (this.sessions.size >= MAX_SESSIONS) {
const error = new Error('Terminal session limit reached')
error.statusCode = 409
throw error
}
const { cols, rows } = dimensions(value)
const sessionId = randomUUID()
const session = new TerminalSession({
id: sessionId, helperPath: this.helperPath, cwd: this.cwd, env: { ...this.env, ...environment },
cols, rows, reconnectMs: this.reconnectMs, report: this.report, python: this.python,
})
this.sessions.set(sessionId, session)
session.once('terminal-close', () => this.sessions.delete(sessionId))
void this.report('terminal.session.created', { sessionId })
return session.publicStatus()
}
require(id) {
if (!SESSION_PATTERN.test(id) || !this.sessions.has(id)) {
const error = new Error('Terminal session was not found')
error.statusCode = 404
throw error
}
return this.sessions.get(id)
}
status(id) {
return this.require(id).publicStatus()
}
close(id) {
const session = this.require(id)
session.terminate('deleted')
return { sessionId: id, closed: true }
}
upgrade(request, socket, head, id) {
const session = this.require(id)
this.webSockets.handleUpgrade(request, socket, head, webSocket => session.attach(webSocket))
}
async shutdown() {
await Promise.all([...this.sessions.values()].map(session => session.stop('management-stopping')))
this.webSockets.close()
}
}
export const terminalSessionInternals = Object.freeze({ dimensions, SESSION_PATTERN })
@@ -0,0 +1,226 @@
import { createHash, randomUUID } from 'node:crypto'
import { chmod, lchown, lstat, mkdir, readFile, readdir, rename, rm, stat } from 'node:fs/promises'
import { basename, join, resolve } from 'node:path'
import { parseDocument } from 'yaml'
const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
const DISABLED_DIRECTORY = '.disabled'
const MAX_SKILL_BYTES = 2 * 1024 * 1024
async function adoptSkillTree(path, uid, gid) {
const metadata = await lstat(path)
await lchown(path, uid ?? -1, gid ?? -1)
if (!metadata.isDirectory() || metadata.isSymbolicLink()) return
for (const entry of await readdir(path)) await adoptSkillTree(join(path, entry), uid, gid)
}
async function adoptSkillRoot(root, uid, gid, { skipSystem = false } = {}) {
await lchown(root, uid ?? -1, gid ?? -1)
for (const entry of await readdir(root)) {
if (skipSystem && entry === '.system') continue
const path = join(root, entry)
const metadata = await lstat(path)
if ((uid === undefined || metadata.uid === uid) && (gid === undefined || metadata.gid === gid)) continue
await adoptSkillTree(path, uid, gid)
}
}
export async function prepareUserSkillRoots({ dshHome = '/data/dsh', agentsHome = '/home/node/.agents', uid, gid } = {}) {
const roots = [join(resolve(dshHome), 'skills'), join(resolve(agentsHome), 'skills')]
for (const [index, root] of roots.entries()) {
await mkdir(root, { recursive: true })
const metadata = await lstat(root)
if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error(`User Skill root ${root} must be a directory`)
if (uid !== undefined || gid !== undefined) await adoptSkillRoot(root, uid, gid, { skipSystem: index === 0 })
await chmod(root, 0o2775)
}
return Object.freeze(roots)
}
export class UserSkillConflictError extends Error {
constructor(message) {
super(message)
this.statusCode = 409
}
}
function entryId(source, location, entryName) {
return `sha256:${createHash('sha256').update(`${source}\0${location}\0${entryName}`).digest('hex')}`
}
function revisionFor(entries) {
const hash = createHash('sha256')
for (const entry of entries) {
hash.update(`${entry.entryId}\0${entry.fingerprint}\0`)
}
return `sha256:${hash.digest('hex')}`
}
function errorMessage(error) {
return error instanceof Error ? error.message : String(error)
}
async function entryType(path, name) {
const link = await lstat(path)
let target = link
if (link.isSymbolicLink()) target = await stat(path)
if (target.isDirectory()) return { kind: 'directory', documentPath: join(path, 'SKILL.md'), link }
if (target.isFile() && name.endsWith('.md')) return { kind: 'file', documentPath: path, link }
return undefined
}
async function inspectEntry(root, source, location, entryName) {
const path = location === 'active' ? join(root, entryName) : join(root, DISABLED_DIRECTORY, entryName)
let type
let bytes
let damaged = false
let metadataError = null
let name = null
let description = null
try {
type = await entryType(path, entryName)
if (type === undefined) return undefined
const metadata = await stat(type.documentPath)
if (!metadata.isFile() || metadata.size > MAX_SKILL_BYTES) {
throw new Error(metadata.size > MAX_SKILL_BYTES ? 'SKILL.md is too large' : 'SKILL.md is not a regular file')
}
bytes = await readFile(type.documentPath)
const text = bytes.toString('utf8')
if (text.includes('\uFFFD') || !text.startsWith('---\n')) throw new Error('SKILL.md frontmatter is missing')
const close = text.indexOf('\n---', 4)
if (close < 0) throw new Error('SKILL.md frontmatter is not closed')
const document = parseDocument(text.slice(4, close), { prettyErrors: false, strict: true })
if (document.errors.length > 0) throw new Error(document.errors[0].message)
const value = document.toJS()
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error('SKILL.md frontmatter is invalid')
if (typeof value.name !== 'string' || !SKILL_NAME_PATTERN.test(value.name)) throw new Error('Skill name is invalid')
if (typeof value.description !== 'string' || value.description.trim() === '') throw new Error('Skill description is invalid')
name = value.name
description = value.description.trim()
} catch (error) {
if (error?.code === 'ENOENT' && type === undefined) return undefined
damaged = true
metadataError = errorMessage(error)
}
const link = type?.link ?? await lstat(path)
const fingerprint = createHash('sha256')
.update(`${link.dev}:${link.ino}:${link.mode}:${link.size}:${link.mtimeNs ?? BigInt(Math.trunc(link.mtimeMs * 1e6))}:`)
.update(bytes ?? Buffer.alloc(0))
.digest('hex')
return Object.freeze({
entryId: entryId(source, location, entryName),
entryName,
source,
kind: type?.kind ?? (entryName.endsWith('.md') ? 'file' : 'directory'),
name,
description,
enabled: location === 'active',
damaged,
metadataError,
symbolicLink: link.isSymbolicLink(),
path,
root,
fingerprint,
})
}
async function rootEntries(root, source) {
const result = []
const locations = [
{ name: 'active', path: root },
{ name: 'disabled', path: join(root, DISABLED_DIRECTORY) },
]
for (const location of locations) {
let entries
try {
entries = await readdir(location.path, { withFileTypes: true })
} catch (error) {
if (error?.code === 'ENOENT') continue
throw error
}
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
if (location.name === 'active' && (entry.name === DISABLED_DIRECTORY || (source === 'user-dsh' && entry.name === '.system'))) continue
const inspected = await inspectEntry(root, source, location.name, entry.name)
if (inspected !== undefined) result.push(inspected)
}
}
return result
}
async function removeEntry(path) {
const metadata = await lstat(path)
if (metadata.isDirectory() && !metadata.isSymbolicLink()) await rm(path, { recursive: true })
else await rm(path)
}
export class UserSkillManager {
constructor({ dshHome = '/data/dsh', agentsHome = '/home/node/.agents' } = {}) {
this.roots = Object.freeze([
Object.freeze({ source: 'user-dsh', path: join(resolve(dshHome), 'skills') }),
Object.freeze({ source: 'user-agents', path: join(resolve(agentsHome), 'skills') }),
])
}
async inspect() {
const entries = (await Promise.all(this.roots.map(root => rootEntries(root.path, root.source))))
.flat()
.sort((left, right) => left.source.localeCompare(right.source) || left.entryName.localeCompare(right.entryName) || Number(right.enabled) - Number(left.enabled))
return Object.freeze({
schema: 1,
revision: revisionFor(entries),
skills: Object.freeze(entries.map(({ path: _path, root: _root, fingerprint: _fingerprint, ...entry }) => Object.freeze(entry))),
internal: entries,
})
}
async list() {
const { internal: _internal, ...inventory } = await this.inspect()
return inventory
}
async validate({ entryId: requestedId, revision, action }) {
if (typeof requestedId !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(requestedId)) throw new Error('User Skill entry ID is invalid')
if (typeof revision !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(revision)) throw new Error('User Skill revision is invalid')
if (!['enable', 'disable', 'delete'].includes(action)) throw new Error('User Skill action is invalid')
const inventory = await this.inspect()
if (inventory.revision !== revision) throw new UserSkillConflictError('User Skill state changed; reload and try again')
const entry = inventory.internal.find(value => value.entryId === requestedId)
if (entry === undefined) throw new UserSkillConflictError('User Skill entry no longer exists')
if (action === 'enable' && entry.enabled) throw new UserSkillConflictError('User Skill is already enabled')
if (action === 'disable' && !entry.enabled) throw new UserSkillConflictError('User Skill is already disabled')
return Object.freeze({ action, entry, revision })
}
async configure(request) {
const { action, entry } = await this.validate(request)
if (action === 'delete') {
const trash = join(entry.root, `.user-skill-delete-${randomUUID()}`)
await rename(entry.path, trash)
try {
await removeEntry(trash)
} catch (error) {
try {
await rename(trash, entry.path)
} catch (rollbackError) {
throw new AggregateError([error, rollbackError], 'User Skill deletion and rollback failed')
}
throw error
}
} else {
const destination = entry.enabled
? join(entry.root, DISABLED_DIRECTORY, entry.entryName)
: join(entry.root, entry.entryName)
await mkdir(join(entry.root, DISABLED_DIRECTORY), { recursive: true })
try {
await lstat(destination)
throw new UserSkillConflictError(`User Skill destination ${basename(destination)} already exists`)
} catch (error) {
if (error?.code !== 'ENOENT') throw error
}
await rename(entry.path, destination)
}
return this.list()
}
}
export const userSkillInternals = Object.freeze({ DISABLED_DIRECTORY, SKILL_NAME_PATTERN, entryId })
@@ -0,0 +1,30 @@
{
"schema": 1,
"id": "outbound-proxy",
"type": "service",
"command": {
"executable": "/usr/local/bin/node",
"args": ["/run/dsh-platform/views/bootstrap/control-plane/services/outbound-proxy/supervisor.mjs"],
"timeoutSeconds": 30
},
"environment": {},
"lifecycle": {
"prepare": null,
"preStart": null,
"postStart": null,
"preStop": null,
"stop": null,
"postStop": null
},
"health": {
"type": "exec",
"command": {
"executable": "/usr/local/bin/node",
"args": ["/run/dsh-platform/views/bootstrap/control-plane/services/outbound-proxy/supervisor.mjs", "health"],
"timeoutSeconds": 5
},
"intervalSeconds": 1,
"timeoutSeconds": 30
},
"logging": { "stdout": true, "stderr": true }
}
@@ -0,0 +1,131 @@
#!/usr/bin/env node
import { chmod, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
import { dirname } from 'node:path'
import { PlatformPaths } from '../../../platform/lib/paths.mjs'
import { PROXY_PORTS } from './lib/contracts.mjs'
import { createOutboundProxyControl } from './lib/control.mjs'
import { createScopedProxyServer, ProxyAgentPool } from './lib/data-plane.mjs'
import { ProxyDnsCache } from './lib/dns-cache.mjs'
import { probeProxyEntry } from './lib/readiness.mjs'
import { ProxyRouteHealth } from './lib/route-health.mjs'
import { ProxyConfigurationStore } from './lib/store.mjs'
import { ProviderHandleStore } from './lib/provider-handles.mjs'
import { ProxyTestManager } from './lib/test-manager.mjs'
const paths = new PlatformPaths(
process.env.DSH_PLATFORM_DATA ?? '/data/platform',
process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform',
)
const store = new ProxyConfigurationStore(paths.proxyStateRoot)
let snapshot = await store.load()
const listeners = []
const dnsCache = new ProxyDnsCache()
const agentPool = new ProxyAgentPool()
const routeHealth = new ProxyRouteHealth()
const providerHandles = new ProviderHandleStore()
const proxyTests = new ProxyTestManager({
statePath: paths.proxyTestStatePath,
})
await proxyTests.initialize(async path => JSON.parse(await readFile(path, 'utf8')))
const ENTRY_BIND_ATTEMPTS = 21
const ENTRY_BIND_RETRY_MS = 50
async function listenProxyEntry(server, port) {
for (let attempt = 1; attempt <= ENTRY_BIND_ATTEMPTS; attempt += 1) {
try {
await new Promise((resolve, reject) => {
const cleanup = () => {
server.off('error', onError)
server.off('listening', onListening)
}
const onError = error => {
cleanup()
reject(error)
}
const onListening = () => {
cleanup()
resolve()
}
server.once('error', onError)
server.once('listening', onListening)
server.listen(port, '127.0.0.1')
})
return
} catch (error) {
if (error?.code !== 'EADDRINUSE' || attempt === ENTRY_BIND_ATTEMPTS) throw error
await new Promise(resolve => setTimeout(resolve, ENTRY_BIND_RETRY_MS))
}
}
}
async function publishRoutingState(current) {
const staging = `${paths.proxyRoutingStatePath}.${String(process.pid)}.tmp`
const value = {
schema: 1,
revision: current.revision,
enabled: current.configuration.enabled,
scopes: current.configuration.scopes,
modelApi: current.configuration.modelApi,
environment: current.configuration.environment,
noProxy: current.configuration.noProxy,
bypass: current.configuration.bypass,
}
await writeFile(staging, `${JSON.stringify(value)}\n`, { mode: 0o644 })
await chmod(staging, 0o644)
await rename(staging, paths.proxyRoutingStatePath)
}
await publishRoutingState(snapshot)
for (const [scope, port] of Object.entries(PROXY_PORTS)) {
const server = createScopedProxyServer({
scope, getSnapshot: () => snapshot, dnsCache, agentPool, routeHealth, providerHandles,
})
await listenProxyEntry(server, port)
listeners.push(server)
}
await Promise.all(Object.values(PROXY_PORTS).map(port => probeProxyEntry(port)))
const control = createOutboundProxyControl({
getSnapshot: () => snapshot,
routeHealth,
providerHandles,
proxyTests,
getTestState: () => proxyTests.getState(),
commitConfiguration: async request => {
const activated = await store.commit(request)
snapshot = Object.freeze({ ...activated, recovery: 'none' })
await publishRoutingState(snapshot)
return snapshot
},
})
await mkdir(dirname(paths.proxyControlSocket), { recursive: true })
await unlink(paths.proxyControlSocket).catch(error => {
if (error?.code !== 'ENOENT') throw error
})
await new Promise((resolve, reject) => {
control.once('error', reject)
control.listen(paths.proxyControlSocket, resolve)
})
await chmod(paths.proxyControlSocket, 0o600)
process.send?.({
type: 'ready',
componentReady: true,
revision: snapshot.revision,
ports: Object.values(PROXY_PORTS),
})
let resolveSignal
const signal = new Promise(resolve => { resolveSignal = resolve })
process.once('SIGINT', () => resolveSignal())
process.once('SIGTERM', () => resolveSignal())
await signal
agentPool.close()
await Promise.all(listeners.map(server => new Promise(resolve => server.close(resolve))))
await new Promise(resolve => control.close(resolve))
await unlink(paths.proxyControlSocket).catch(error => {
if (error?.code !== 'ENOENT') throw error
})
@@ -0,0 +1,185 @@
import { isIP } from 'node:net'
import { domainToASCII } from 'node:url'
import { ProxyConfigurationError } from './errors.mjs'
import { normalizeProxyRules, PLATFORM_NO_PROXY } from './rules.mjs'
import { OUTBOUND_PROXY_PORTS } from '../../../../platform/lib/outbound-proxy.mjs'
export const PROXY_SCHEMA = 1
export const PROXY_SCOPES = Object.freeze([
'updates', 'platform', 'dshCore', 'dshPlugins', 'agentNetwork', 'managementTerminal',
])
export const PROXY_PORTS = OUTBOUND_PROXY_PORTS
function deepFreeze(value) {
if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
for (const child of Object.values(value)) deepFreeze(child)
Object.freeze(value)
}
return value
}
function object(value, label) {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new ProxyConfigurationError(`${label} must be an object`)
}
return value
}
function allowedKeys(value, allowed, label) {
const extras = Object.keys(value).filter(key => !allowed.includes(key))
if (extras.length > 0) throw new ProxyConfigurationError(`${label} contains unsupported fields: ${extras.sort().join(', ')}`)
}
function requiredKeys(value, required, label) {
const missing = required.filter(key => !(key in value))
if (missing.length > 0) throw new ProxyConfigurationError(`${label} is missing fields: ${missing.join(', ')}`)
}
function normalizeProxyHost(value) {
if (typeof value !== 'string' || value !== value.trim() || /[\u0000-\u0020\u007f\/@?#]/.test(value)) {
throw new ProxyConfigurationError('proxy host is invalid')
}
if (value === '') return ''
if (isIP(value) !== 0) return value.toLowerCase()
const ascii = domainToASCII(value.toLowerCase().replace(/\.$/, ''))
if (ascii === '' || ascii.length > 253 || ascii.split('.').some(part => part === '' || part.length > 63)) {
throw new ProxyConfigurationError('proxy host is invalid')
}
return ascii
}
function normalizeScopes(value) {
object(value, 'proxy scopes')
requiredKeys(value, PROXY_SCOPES, 'proxy scopes')
allowedKeys(value, PROXY_SCOPES, 'proxy scopes')
return Object.fromEntries(PROXY_SCOPES.map(scope => {
if (typeof value[scope] !== 'boolean') throw new ProxyConfigurationError(`proxy scope ${scope} must be boolean`)
return [scope, value[scope]]
}))
}
function normalizeProviderPolicy(value, label) {
object(value, label)
requiredKeys(value, ['proxyEnabled'], label)
allowedKeys(value, ['proxyEnabled'], label)
if (typeof value.proxyEnabled !== 'boolean') throw new ProxyConfigurationError(`${label} proxyEnabled must be boolean`)
return { proxyEnabled: value.proxyEnabled }
}
export function validProviderId(value) {
return typeof value === 'string' && /^[a-z0-9][a-z0-9._-]{0,127}$/.test(value)
}
function normalizeModelApi(value) {
object(value, 'model API proxy policy')
requiredKeys(value, ['default', 'providers'], 'model API proxy policy')
allowedKeys(value, ['default', 'providers'], 'model API proxy policy')
object(value.providers, 'model API provider policies')
const providers = {}
for (const [id, policy] of Object.entries(value.providers)) {
if (!validProviderId(id)) throw new ProxyConfigurationError('model API provider ID is invalid')
providers[id] = normalizeProviderPolicy(policy, `model API provider ${id}`)
}
return { default: normalizeProviderPolicy(value.default, 'model API default'), providers }
}
export function defaultProxyConfiguration() {
return Object.freeze({
schema: PROXY_SCHEMA,
enabled: false,
proxy: Object.freeze({
protocol: 'http', host: '', port: null, username: '', passwordConfigured: false, remoteDns: true,
}),
scopes: Object.freeze(Object.fromEntries(PROXY_SCOPES.map(scope => [scope, false]))),
environment: Object.freeze({ allProxy: null }),
modelApi: Object.freeze({
default: Object.freeze({ proxyEnabled: false }),
providers: Object.freeze({}),
}),
noProxy: Object.freeze({ system: PLATFORM_NO_PROXY, user: Object.freeze([]) }),
bypass: Object.freeze({ additional: Object.freeze([]) }),
})
}
export function validateProxyConfiguration(value, { existingPassword = null } = {}) {
object(value, 'proxy configuration')
requiredKeys(value, ['schema', 'enabled', 'proxy', 'scopes', 'environment', 'modelApi', 'noProxy', 'bypass'], 'proxy configuration')
allowedKeys(value, ['schema', 'enabled', 'proxy', 'scopes', 'environment', 'modelApi', 'noProxy', 'bypass'], 'proxy configuration')
if (value.schema !== PROXY_SCHEMA) throw new ProxyConfigurationError('proxy configuration schema must be 1')
if (typeof value.enabled !== 'boolean') throw new ProxyConfigurationError('proxy enabled must be boolean')
const proxy = object(value.proxy, 'proxy endpoint')
requiredKeys(proxy, ['protocol', 'host', 'port', 'username', 'remoteDns'], 'proxy endpoint')
allowedKeys(proxy, ['protocol', 'host', 'port', 'username', 'password', 'clearPassword', 'passwordConfigured', 'remoteDns'], 'proxy endpoint')
if (!['http', 'socks5'].includes(proxy.protocol)) throw new ProxyConfigurationError('proxy protocol must be http or socks5')
const host = normalizeProxyHost(proxy.host)
const port = proxy.port === null ? null : Number(proxy.port)
if (port !== null && (!Number.isSafeInteger(port) || port < 1 || port > 65535)) throw new ProxyConfigurationError('proxy port is invalid')
if (value.enabled && (host === '' || port === null)) throw new ProxyConfigurationError('enabled proxy requires a host and port')
if ((host === '') !== (port === null)) throw new ProxyConfigurationError('proxy host and port must be configured together')
if (typeof proxy.username !== 'string' || Buffer.byteLength(proxy.username, 'utf8') > 255 || /[\u0000-\u001f\u007f]/.test(proxy.username)) {
throw new ProxyConfigurationError('proxy username is invalid')
}
if (typeof proxy.remoteDns !== 'boolean') throw new ProxyConfigurationError('proxy remoteDns must be boolean')
if (proxy.clearPassword === true && proxy.password !== undefined && proxy.password !== null) {
throw new ProxyConfigurationError('proxy password cannot be set and cleared together')
}
let password = existingPassword
if (proxy.clearPassword === true) password = null
else if (proxy.password !== undefined && proxy.password !== null) {
if (typeof proxy.password !== 'string' || proxy.password === '' || Buffer.byteLength(proxy.password, 'utf8') > 255 || /[\u0000-\u001f\u007f]/.test(proxy.password)) {
throw new ProxyConfigurationError('proxy password is invalid')
}
password = proxy.password
}
const environment = object(value.environment, 'proxy environment')
requiredKeys(environment, ['allProxy'], 'proxy environment')
allowedKeys(environment, ['allProxy'], 'proxy environment')
if (environment.allProxy !== null && environment.allProxy !== 'scope-proxy') {
throw new ProxyConfigurationError('proxy environment allProxy must be null or scope-proxy')
}
const noProxy = object(value.noProxy, 'NO_PROXY')
requiredKeys(noProxy, ['user'], 'NO_PROXY')
allowedKeys(noProxy, ['user', 'system'], 'NO_PROXY')
const bypass = object(value.bypass, 'proxy bypass')
requiredKeys(bypass, ['additional'], 'proxy bypass')
allowedKeys(bypass, ['additional'], 'proxy bypass')
const normalized = {
schema: PROXY_SCHEMA,
enabled: value.enabled,
proxy: {
protocol: proxy.protocol,
host,
port,
username: proxy.username,
passwordConfigured: password !== null,
remoteDns: proxy.remoteDns,
},
scopes: normalizeScopes(value.scopes),
environment: { allProxy: environment.allProxy },
modelApi: normalizeModelApi(value.modelApi),
noProxy: {
system: PLATFORM_NO_PROXY,
user: normalizeProxyRules(noProxy.user, { allowWildcard: true, label: 'NO_PROXY' }).map(rule => rule.value),
},
bypass: {
additional: normalizeProxyRules(bypass.additional, { allowCidr: true, label: 'proxy bypass' }).map(rule => rule.value),
},
}
return Object.freeze({
configuration: deepFreeze(normalized),
credentials: deepFreeze({ username: proxy.username, password }),
})
}
export function sanitizedProxyConfiguration(configuration, revision, state = {}) {
return Object.freeze({
...configuration,
revision,
componentReady: state.componentReady === true,
routeHealth: state.routeHealth ?? Object.fromEntries([...PROXY_SCOPES, 'modelApi', 'sharedDsh'].map(scope => [scope, 'unknown'])),
lastTest: state.lastTest ?? null,
})
}
@@ -0,0 +1,130 @@
import { createServer } from 'node:http'
import { outboundProxyEnvironment, outboundProxyScopeEnabled } from '../../../../platform/lib/outbound-proxy.mjs'
import { sanitizedProxyConfiguration } from './contracts.mjs'
import { ProxyConfigurationError, proxyErrorBody } from './errors.mjs'
const MAX_CONTROL_BODY_BYTES = 64 * 1024
const MAX_PROVIDER_HANDLE_BODY_BYTES = 4096
const TEST_TASK_ROUTE = /^\/v1\/test\/tasks\/([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/
async function jsonBody(request, maximum = MAX_CONTROL_BODY_BYTES) {
const chunks = []
let total = 0
for await (const chunk of request) {
total += chunk.byteLength
if (total > maximum) throw new ProxyConfigurationError('control request body is too large')
chunks.push(chunk)
}
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch {
throw new ProxyConfigurationError('control request body is invalid')
}
}
function send(response, status, value) {
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
response.end(`${JSON.stringify(value)}\n`)
}
function configurationView(snapshot, routeHealth, state = {}) {
return sanitizedProxyConfiguration(snapshot.configuration, snapshot.revision, {
componentReady: true,
routeHealth: routeHealth.status(snapshot),
lastTest: state.lastTest ?? null,
})
}
export function createOutboundProxyControl({
getSnapshot,
routeHealth,
providerHandles,
proxyTests,
commitConfiguration,
getTestState = () => ({ lastTest: null }),
}) {
return createServer(async (request, response) => {
const url = new URL(request.url ?? '/', 'http://outbound-proxy.internal')
const snapshot = getSnapshot()
if (request.method === 'GET' && url.pathname === '/v1/status') {
send(response, 200, {
componentReady: true,
revision: snapshot.revision,
recovery: snapshot.recovery,
routeHealth: routeHealth.status(snapshot),
})
return
}
if (request.method === 'GET' && url.pathname === '/v1/configuration') {
send(response, 200, configurationView(snapshot, routeHealth, getTestState()))
return
}
if (request.method === 'PUT' && url.pathname === '/v1/configuration') {
try {
if (commitConfiguration === undefined) throw new Error('proxy configuration updates are unavailable')
const body = await jsonBody(request)
if (body === null || typeof body !== 'object' || Array.isArray(body)
|| Object.keys(body).sort().join(',') !== 'baseRevision,value') {
throw new ProxyConfigurationError('proxy configuration update is invalid')
}
const activated = await commitConfiguration(body)
providerHandles?.clear()
send(response, 200, configurationView(activated, routeHealth, getTestState()))
} catch (error) {
send(response, error?.statusCode ?? 400, proxyErrorBody(error))
}
return
}
if (request.method === 'POST' && url.pathname === '/v1/test') {
try {
if (proxyTests === undefined) throw new Error('proxy connection tests are unavailable')
send(response, 202, await proxyTests.start(await jsonBody(request), snapshot))
} catch (error) {
send(response, error?.statusCode ?? 400, proxyErrorBody(error))
}
return
}
if (request.method === 'GET' && TEST_TASK_ROUTE.test(url.pathname)) {
try {
if (proxyTests === undefined) throw new Error('proxy connection tests are unavailable')
send(response, 200, proxyTests.get(TEST_TASK_ROUTE.exec(url.pathname)[1]))
} catch (error) {
send(response, error?.statusCode ?? 400, proxyErrorBody(error))
}
return
}
if (request.method === 'DELETE' && TEST_TASK_ROUTE.test(url.pathname)) {
try {
if (proxyTests === undefined) throw new Error('proxy connection tests are unavailable')
send(response, 202, await proxyTests.cancel(TEST_TASK_ROUTE.exec(url.pathname)[1]))
} catch (error) {
send(response, error?.statusCode ?? 400, proxyErrorBody(error))
}
return
}
if (request.method === 'GET' && url.pathname === '/v1/environment') {
try {
const scope = url.searchParams.get('scope')
const environment = outboundProxyEnvironment(scope, {
noProxy: [...snapshot.configuration.noProxy.system, ...snapshot.configuration.noProxy.user],
allProxy: snapshot.configuration.environment.allProxy === 'scope-proxy',
enabled: outboundProxyScopeEnabled(snapshot.configuration, scope),
})
send(response, 200, { scope, revision: snapshot.revision, environment })
} catch (error) {
send(response, 400, { error: error instanceof Error ? error.message : 'invalid scope' })
}
return
}
if (request.method === 'POST' && url.pathname === '/v1/provider-handles') {
try {
if (providerHandles === undefined) throw new Error('provider handles are unavailable')
send(response, 201, providerHandles.issue(await jsonBody(request, MAX_PROVIDER_HANDLE_BODY_BYTES), snapshot))
} catch (error) {
send(response, error?.statusCode ?? 400, proxyErrorBody(error))
}
return
}
send(response, 404, { error: 'not found' })
})
}
export const outboundProxyControlInternals = Object.freeze({ configurationView })
@@ -0,0 +1,459 @@
import { Agent, createServer, request as httpRequest } from 'node:http'
import { ProxyDnsCache } from './dns-cache.mjs'
import { connectTcp, basicProxyAuthorization, connectThroughHttpProxy, connectThroughSocks5, PROXY_TIMEOUTS, ProxyTransportError } from './transport.mjs'
import { selectProxyRoute } from './policy.mjs'
import { ProxyRouteHealth } from './route-health.mjs'
const MAX_HEADER_LINE_BYTES = 64 * 1024
const MAX_CONNECT_AUTHORITY_BYTES = 1024
const HOP_BY_HOP = new Set([
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
'proxy-connection', 'te', 'trailer', 'transfer-encoding', 'upgrade',
])
function proxyError(response, statusCode, code, message) {
if (response.destroyed) return
if (response.headersSent) return response.destroy()
const body = `${message}\n`
response.writeHead(statusCode, {
'content-type': 'text/plain; charset=utf-8',
'content-length': Buffer.byteLength(body),
'x-dsh-proxy-error': code,
connection: 'close',
...statusCode === 407 ? { 'proxy-authenticate': 'DSH-Provider' } : {},
})
response.end(body)
}
function socketError(socket, statusCode, code, message) {
if (socket.destroyed) return
const body = `${message}\n`
socket.end([
`HTTP/1.1 ${statusCode} ${statusCode === 504 ? 'Gateway Timeout' : statusCode === 431 ? 'Request Header Fields Too Large' : statusCode === 407 ? 'Proxy Authentication Required' : statusCode === 400 ? 'Bad Request' : 'Bad Gateway'}`,
'Content-Type: text/plain; charset=utf-8',
`Content-Length: ${Buffer.byteLength(body)}`,
`X-DSH-Proxy-Error: ${code}`,
...statusCode === 407 ? ['Proxy-Authenticate: DSH-Provider'] : [],
'Connection: close',
'',
body,
].join('\r\n'))
}
function errorFields(error) {
if (error instanceof ProxyTransportError) {
return { statusCode: error.statusCode, code: error.code, message: error.message }
}
return { statusCode: 502, code: 'UPSTREAM_CONNECT_FAILED', message: 'upstream connection failed' }
}
function observeRouteHealth(context, snapshot, outcome) {
if (context.getSnapshot().revision === snapshot.revision) {
context.routeHealth.observe(snapshot, context.scope, outcome)
}
}
function authorizeProvider(context, headers, snapshot) {
if (context.scope !== 'modelApi') return undefined
try {
if (context.providerHandles === undefined) throw new Error('provider handles are unavailable')
return context.providerHandles.resolve(headers['proxy-authorization'], snapshot)
} catch {
throw new ProxyTransportError('provider policy handle is invalid', {
code: 'PROVIDER_HANDLE_INVALID', statusCode: 407,
})
}
}
function checkHeaderLimits(request) {
if (request.rawHeaders.length / 2 > 256) return false
for (let index = 0; index < request.rawHeaders.length; index += 2) {
if (Buffer.byteLength(`${request.rawHeaders[index]}: ${request.rawHeaders[index + 1]}`, 'latin1') > MAX_HEADER_LINE_BYTES) return false
}
return true
}
function connectionTokens(headers) {
return new Set(String(headers.connection ?? '').split(',').map(value => value.trim().toLowerCase()).filter(Boolean))
}
function forwardHeaders(headers, { upgrade = false, authorization = null, host }) {
const tokens = connectionTokens(headers)
const result = {}
for (const [name, value] of Object.entries(headers)) {
const lower = name.toLowerCase()
if (HOP_BY_HOP.has(lower) || tokens.has(lower)) continue
if (value !== undefined) result[lower] = value
}
result.host = host
if (upgrade) {
result.connection = 'Upgrade'
result.upgrade = headers.upgrade
}
if (authorization !== null) result['proxy-authorization'] = authorization
return result
}
function responseHeaders(headers) {
const tokens = connectionTokens(headers)
const result = {}
for (const [name, value] of Object.entries(headers)) {
const lower = name.toLowerCase()
if (HOP_BY_HOP.has(lower) || tokens.has(lower)) continue
if (value !== undefined) result[lower] = value
}
return result
}
function parseAbsoluteTarget(raw) {
let target
try { target = new URL(raw) } catch { return null }
if (target.username !== '' || target.password !== '' || target.hostname === '' || target.hash !== '') return null
if (!['http:', 'ws:'].includes(target.protocol)) return Object.freeze({ unsupported: true, url: target })
const port = target.port === '' ? 80 : Number(target.port)
if (!Number.isInteger(port) || port < 1 || port > 65535) return null
const host = target.hostname.startsWith('[') && target.hostname.endsWith(']')
? target.hostname.slice(1, -1)
: target.hostname
return Object.freeze({ url: target, host, port, authority: target.host })
}
function parseAuthority(raw) {
if (Buffer.byteLength(raw) > MAX_CONNECT_AUTHORITY_BYTES || raw.includes('/') || raw.includes('@')) return null
try {
const value = new URL(`tcp://${raw}`)
if (value.hostname === '' || value.port === '') return null
const port = Number(value.port)
if (!Number.isInteger(port) || port < 1 || port > 65535) return null
const host = value.hostname.startsWith('[') && value.hostname.endsWith(']')
? value.hostname.slice(1, -1)
: value.hostname
return Object.freeze({ host, port })
} catch { return null }
}
function requestPath(target) {
return `${target.url.pathname || '/'}${target.url.search}`
}
function idle(socket, timeoutMs) {
socket.setTimeout(timeoutMs, () => socket.destroy(new ProxyTransportError('upstream stream timed out', {
code: 'UPSTREAM_TIMEOUT', statusCode: 504,
})))
}
function bridge(left, right, { leftHead = Buffer.alloc(0), rightHead = Buffer.alloc(0), timeoutMs }) {
const close = () => {
if (!left.destroyed) left.destroy()
if (!right.destroyed) right.destroy()
}
left.on('error', close)
right.on('error', close)
idle(left, timeoutMs)
idle(right, timeoutMs)
if (leftHead.byteLength > 0) right.write(leftHead)
if (rightHead.byteLength > 0) left.write(rightHead)
left.pipe(right)
right.pipe(left)
}
async function tryTargets(targets, connectTarget, { retry } = {}) {
let failure
for (const target of targets) {
try { return await connectTarget(target) } catch (error) {
failure = error
if (error?.code === 'REQUEST_CANCELLED' || retry?.(error) === false) throw error
}
}
throw failure ?? new ProxyTransportError('no usable target addresses', { code: 'TARGET_DNS_FAILED' })
}
async function routeSocket(route, target, signal, timeouts) {
if (route.mode === 'direct') return tryTargets(route.targets ?? [{ address: target.host }], async candidate => Object.freeze({
socket: await connectTcp({ host: candidate.address, port: target.port, timeoutMs: timeouts.connectMs, signal }),
remainder: Buffer.alloc(0),
}))
if (route.mode === 'http') return connectThroughHttpProxy({
endpoint: route.endpoint,
targetHost: target.host,
targetPort: target.port,
connectTimeoutMs: timeouts.connectMs,
handshakeTimeoutMs: timeouts.handshakeMs,
signal,
})
if (route.mode === 'socks5') return tryTargets(route.targets ?? [{ address: target.host }], candidate => connectThroughSocks5({
endpoint: route.endpoint,
targetHost: candidate.address,
targetPort: target.port,
connectTimeoutMs: timeouts.connectMs,
handshakeTimeoutMs: timeouts.handshakeMs,
signal,
}), { retry: error => error?.code === 'UPSTREAM_PROXY_REJECTED' })
throw new ProxyTransportError('configured proxy protocol is not available', {
code: 'PROXY_PROTOCOL_UNAVAILABLE',
})
}
async function httpConnection(route, target, signal, timeouts) {
if (route.mode === 'http') return Object.freeze({
socket: await connectTcp({
host: route.endpoint.host,
port: route.endpoint.port,
timeoutMs: timeouts.connectMs,
signal,
}),
remainder: Buffer.alloc(0),
})
return routeSocket(route, target, signal, timeouts)
}
function agentKey(route) {
const endpoint = route.endpoint === undefined
? ''
: `${route.endpoint.host}:${route.endpoint.port}:${route.endpoint.username}`
return `${route.revision}\u0000${route.mode}\u0000${endpoint}`
}
export class ProxyAgentPool {
constructor() {
this.entries = new Map()
this.currentRevision = null
}
retireOtherRevisions(revision) {
if (this.currentRevision === revision) return
this.currentRevision = revision
for (const [key, entry] of this.entries) {
if (entry.revision === revision) continue
entry.retired = true
for (const sockets of Object.values(entry.agent.freeSockets)) {
for (const socket of sockets) socket.destroy()
}
this.entries.delete(key)
}
}
agentFor(route, timeouts = PROXY_TIMEOUTS) {
this.retireOtherRevisions(route.revision)
const key = agentKey(route)
const existing = this.entries.get(key)
if (existing !== undefined) return existing.agent
const entry = { revision: route.revision, retired: false, agent: null }
const agent = new Agent({ keepAlive: true, maxSockets: 32, maxFreeSockets: 8, timeout: timeouts.streamIdleMs })
entry.agent = agent
agent.createConnection = (options, callback) => {
const target = {
host: options.dshProxyTargetHost,
port: Number(options.dshProxyTargetPort),
}
void httpConnection(route, target, options.dshProxySignal, timeouts).then(connection => {
if (connection.remainder.byteLength > 0) connection.socket.unshift(connection.remainder)
connection.socket.resume()
callback(null, connection.socket)
}, callback)
}
agent.on('free', socket => {
if (entry.retired) socket.destroy()
})
this.entries.set(key, entry)
return agent
}
close() {
for (const entry of this.entries.values()) entry.agent.destroy()
this.entries.clear()
this.currentRevision = null
}
}
async function handleHttp(request, response, context) {
if (!checkHeaderLimits(request)) return proxyError(response, 431, 'REQUEST_HEADERS_TOO_LARGE', 'request headers are too large')
const target = parseAbsoluteTarget(request.url ?? '')
if (target === null || target.unsupported === true || target.url.protocol !== 'http:') {
const invalid = target === null
return proxyError(response, invalid ? 400 : 501, invalid ? 'INVALID_PROXY_REQUEST' : 'UNSUPPORTED_TARGET_SCHEME', invalid ? 'absolute-form HTTP target required' : 'target scheme is not supported')
}
let upstream
let route
let snapshot
const cancellation = new AbortController()
request.once('aborted', () => cancellation.abort())
response.once('close', () => {
if (!response.writableEnded) cancellation.abort()
})
try {
request.pause()
snapshot = context.getSnapshot()
const providerId = authorizeProvider(context, request.headers, snapshot)
route = await selectProxyRoute({
snapshot, scope: context.scope, providerId, host: target.host, port: target.port,
dnsCache: context.dnsCache, signal: cancellation.signal,
})
const proxied = route.mode === 'http'
const agent = context.agentPool.agentFor(route, context.timeouts)
const headers = forwardHeaders(request.headers, {
host: target.authority,
authorization: proxied ? basicProxyAuthorization(route.endpoint.username, route.endpoint.password) : null,
})
upstream = httpRequest({
host: proxied ? route.endpoint.host : target.host,
port: proxied ? route.endpoint.port : target.port,
method: request.method,
path: proxied ? target.url.href : requestPath(target),
headers,
agent,
signal: cancellation.signal,
maxHeaderSize: 256 * 1024,
dshProxyTargetHost: target.host,
dshProxyTargetPort: target.port,
dshProxySignal: cancellation.signal,
})
upstream.setTimeout(context.timeouts.responseHeaderMs, () => upstream.destroy(new ProxyTransportError('upstream response timed out', {
code: 'UPSTREAM_TIMEOUT', statusCode: 504,
})))
request.once('aborted', () => upstream.destroy())
upstream.once('response', incoming => {
if (proxied && incoming.statusCode === 407) {
observeRouteHealth(context, snapshot, 'degraded')
incoming.socket.destroy()
return proxyError(response, 502, 'UPSTREAM_PROXY_AUTH_FAILED', 'upstream proxy authentication failed')
}
if (route.mode !== 'direct') observeRouteHealth(context, snapshot, 'ready')
incoming.socket.setTimeout(context.timeouts.streamIdleMs, () => incoming.destroy(new ProxyTransportError('upstream response timed out', {
code: 'UPSTREAM_TIMEOUT', statusCode: 504,
})))
response.writeHead(incoming.statusCode ?? 502, incoming.statusMessage, responseHeaders(incoming.headers))
incoming.pipe(response, { end: false })
incoming.once('end', () => {
if (Object.keys(incoming.trailers).length > 0) response.addTrailers(incoming.trailers)
response.end()
})
incoming.once('error', error => response.destroy(error))
})
upstream.once('error', error => {
if (route.mode !== 'direct') observeRouteHealth(context, snapshot, 'degraded')
const fields = errorFields(error)
proxyError(response, fields.statusCode, fields.code, fields.message)
})
request.pipe(upstream, { end: false })
request.once('end', () => {
if (Object.keys(request.trailers).length > 0) upstream.addTrailers(request.trailers)
upstream.end()
})
request.resume()
} catch (error) {
upstream?.destroy()
if (route?.mode !== undefined && route.mode !== 'direct') observeRouteHealth(context, snapshot, 'degraded')
const fields = errorFields(error)
proxyError(response, fields.statusCode, fields.code, fields.message)
}
}
function rawRequest(request, target, { absolute, authorization }) {
const headers = forwardHeaders(request.headers, {
upgrade: true,
host: target.authority,
authorization,
})
const start = `${request.method ?? 'GET'} ${absolute ? target.url.href : requestPath(target)} HTTP/${request.httpVersion}`
return Buffer.from(`${[start, ...Object.entries(headers).map(([name, value]) => `${name}: ${Array.isArray(value) ? value.join(', ') : value}`), '', ''].join('\r\n')}`, 'latin1')
}
async function handleUpgrade(request, socket, head, context) {
socket.on('error', () => {})
if (!checkHeaderLimits(request)) return socketError(socket, 431, 'REQUEST_HEADERS_TOO_LARGE', 'request headers are too large')
const target = parseAbsoluteTarget(request.url ?? '')
if (target === null || target.unsupported === true || target.url.protocol !== 'ws:' || String(request.headers.upgrade ?? '').toLowerCase() !== 'websocket') {
const invalid = target === null
return socketError(socket, invalid ? 400 : 501, invalid ? 'INVALID_PROXY_REQUEST' : 'UNSUPPORTED_TARGET_SCHEME', invalid ? 'absolute-form WS target required' : 'target scheme is not supported')
}
const cancellation = new AbortController()
let route
let snapshot
socket.once('close', () => cancellation.abort())
try {
snapshot = context.getSnapshot()
const providerId = authorizeProvider(context, request.headers, snapshot)
route = await selectProxyRoute({
snapshot, scope: context.scope, providerId, host: target.host, port: target.port,
dnsCache: context.dnsCache, signal: cancellation.signal,
})
if (!['direct', 'http', 'socks5'].includes(route.mode)) throw new ProxyTransportError('configured proxy protocol is not available', { code: 'PROXY_PROTOCOL_UNAVAILABLE' })
const connected = await httpConnection(route, target, cancellation.signal, context.timeouts)
if (route.mode !== 'direct') observeRouteHealth(context, snapshot, 'ready')
const authorization = route.mode === 'http' ? basicProxyAuthorization(route.endpoint.username, route.endpoint.password) : null
const preface = rawRequest(request, target, { absolute: route.mode === 'http', authorization })
bridge(socket, connected.socket, {
leftHead: Buffer.concat([preface, head]),
rightHead: connected.remainder,
timeoutMs: context.timeouts.streamIdleMs,
})
} catch (error) {
if (route?.mode !== undefined && route.mode !== 'direct') observeRouteHealth(context, snapshot, 'degraded')
const fields = errorFields(error)
socketError(socket, fields.statusCode, fields.code, fields.message)
}
}
async function handleConnect(request, socket, head, context) {
socket.on('error', () => {})
if (!checkHeaderLimits(request)) return socketError(socket, 431, 'REQUEST_HEADERS_TOO_LARGE', 'request headers are too large')
const target = parseAuthority(request.url ?? '')
if (target === null) return socketError(socket, 400, 'INVALID_CONNECT_AUTHORITY', 'CONNECT authority is invalid')
const cancellation = new AbortController()
let route
let snapshot
socket.once('close', () => cancellation.abort())
try {
snapshot = context.getSnapshot()
const providerId = authorizeProvider(context, request.headers, snapshot)
route = await selectProxyRoute({
snapshot, scope: context.scope, providerId, host: target.host, port: target.port,
dnsCache: context.dnsCache, signal: cancellation.signal,
})
const connected = await routeSocket(route, target, cancellation.signal, context.timeouts)
if (route.mode !== 'direct') observeRouteHealth(context, snapshot, 'ready')
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
bridge(socket, connected.socket, {
leftHead: head,
rightHead: connected.remainder,
timeoutMs: context.timeouts.tunnelIdleMs,
})
} catch (error) {
if (route?.mode !== undefined && route.mode !== 'direct') observeRouteHealth(context, snapshot, 'degraded')
const fields = errorFields(error)
socketError(socket, fields.statusCode, fields.code, fields.message)
}
}
export function createScopedProxyServer({
scope,
getSnapshot,
resolver,
dnsCache = new ProxyDnsCache({ resolver }),
agentPool = new ProxyAgentPool(),
routeHealth = new ProxyRouteHealth(),
providerHandles,
timeouts = PROXY_TIMEOUTS,
}) {
const context = Object.freeze({
scope,
getSnapshot,
dnsCache,
agentPool,
routeHealth,
providerHandles,
timeouts: Object.freeze({ ...PROXY_TIMEOUTS, ...timeouts }),
})
const server = createServer({ maxHeaderSize: 256 * 1024, allowHalfOpen: true }, (request, response) => {
void handleHttp(request, response, context)
})
server.maxHeadersCount = 256
server.on('connect', (request, socket, head) => { void handleConnect(request, socket, head, context) })
server.on('upgrade', (request, socket, head) => { void handleUpgrade(request, socket, head, context) })
server.on('clientError', (error, socket) => {
const overflow = error?.code === 'HPE_HEADER_OVERFLOW'
socketError(socket, overflow ? 431 : 400, overflow ? 'REQUEST_HEADERS_TOO_LARGE' : 'INVALID_PROXY_REQUEST', overflow ? 'request headers are too large' : 'proxy request is invalid')
})
return server
}
@@ -0,0 +1,80 @@
import { lookup } from 'node:dns/promises'
import { isIP } from 'node:net'
import { domainToASCII } from 'node:url'
import { ProxyTransportError } from './transport.mjs'
const POSITIVE_TTL_MS = 60_000
const NEGATIVE_TTL_MS = 10_000
function normalizeHost(value) {
if (isIP(value) !== 0) return value.toLowerCase()
const ascii = domainToASCII(String(value).toLowerCase().replace(/\.+$/, ''))
if (ascii === '') throw new ProxyTransportError('target DNS name is invalid', { code: 'TARGET_DNS_FAILED' })
return ascii
}
function normalizeRecords(value) {
if (!Array.isArray(value)) value = [value]
const records = new Map()
for (const record of value) {
const address = typeof record === 'string' ? record : record?.address
const family = typeof record === 'object' ? Number(record?.family) : isIP(address)
if (typeof address !== 'string' || ![4, 6].includes(family) || isIP(address) !== family) continue
const ttl = typeof record?.ttl === 'number' && Number.isFinite(record.ttl)
? Math.max(0, Math.min(60, record.ttl))
: 60
records.set(`${family}:${address}`, Object.freeze({ address, family, ttl }))
}
return Object.freeze([...records.values()])
}
export class ProxyDnsCache {
constructor({ resolver = lookup, now = Date.now, positiveTtlMs = POSITIVE_TTL_MS, negativeTtlMs = NEGATIVE_TTL_MS } = {}) {
this.resolver = resolver
this.now = now
this.positiveTtlMs = positiveTtlMs
this.negativeTtlMs = negativeTtlMs
this.entries = new Map()
}
async resolve(host, revision, { signal } = {}) {
const normalized = normalizeHost(host)
const type = isIP(normalized)
if (type !== 0) return Object.freeze([{ address: normalized, family: type, ttl: 60 }])
const key = `${revision}\u0000${normalized}`
const cached = this.entries.get(key)
if (cached !== undefined && cached.expiresAt > this.now()) {
if (cached.error !== null) throw cached.error
return cached.records
}
if (signal?.aborted) throw new ProxyTransportError('target DNS resolution was cancelled', { code: 'REQUEST_CANCELLED', statusCode: 499 })
let records
try {
if (signal === undefined) records = await this.resolver(normalized, { all: true, verbatim: true })
else records = await new Promise((resolve, reject) => {
const cancelled = () => reject(new ProxyTransportError('target DNS resolution was cancelled', {
code: 'REQUEST_CANCELLED', statusCode: 499,
}))
signal.addEventListener('abort', cancelled, { once: true })
this.resolver(normalized, { all: true, verbatim: true }).then(resolve, reject).finally(() => {
signal.removeEventListener('abort', cancelled)
})
})
records = normalizeRecords(records)
if (records.length === 0) throw new Error('resolver returned no usable addresses')
} catch (cause) {
if (cause instanceof ProxyTransportError) throw cause
const error = new ProxyTransportError('target DNS resolution failed', { code: 'TARGET_DNS_FAILED', cause })
this.entries.set(key, Object.freeze({ records: null, error, expiresAt: this.now() + this.negativeTtlMs }))
throw error
}
const recordTtlMs = Math.min(...records.map(record => record.ttl * 1000), this.positiveTtlMs)
this.entries.set(key, Object.freeze({ records, error: null, expiresAt: this.now() + recordTtlMs }))
if (this.entries.size > 512) {
const now = this.now()
for (const [entryKey, entry] of this.entries) if (entry.expiresAt <= now) this.entries.delete(entryKey)
while (this.entries.size > 512) this.entries.delete(this.entries.keys().next().value)
}
return records
}
}
@@ -0,0 +1,26 @@
export class ProxyConfigurationError extends Error {
constructor(message, {
code = 'INVALID_CONFIG',
statusCode = 400,
stage = 'validate',
retryable = false,
} = {}) {
super(message)
this.name = 'ProxyConfigurationError'
this.code = code
this.statusCode = statusCode
this.stage = stage
this.retryable = retryable
}
}
export function proxyErrorBody(error) {
return Object.freeze({
error: Object.freeze({
code: error?.code ?? 'PROXY_MANAGER_UNAVAILABLE',
message: error?.message ?? 'proxy manager is unavailable',
stage: error?.stage ?? 'unknown',
retryable: error?.retryable === true,
}),
})
}
@@ -0,0 +1,47 @@
import { isIP } from 'node:net'
import { matchesProxyRules, normalizeProxyRules } from './rules.mjs'
import { outboundProxyScopeEnabled } from '../../../../platform/lib/outbound-proxy.mjs'
const PLATFORM_RULES = normalizeProxyRules(['localhost', '127.0.0.1', '::1'], { label: 'platform NO_PROXY' })
function route(mode, reason, snapshot, fields = {}) {
return Object.freeze({ mode, reason, revision: snapshot.revision, ...fields })
}
export async function selectProxyRoute({ snapshot, scope, providerId, host, port, dnsCache, signal }) {
const configuration = snapshot.configuration
const userNoProxy = normalizeProxyRules(configuration.noProxy.user, {
allowWildcard: true,
label: 'NO_PROXY',
})
const bypass = normalizeProxyRules(configuration.bypass.additional, {
allowCidr: true,
label: 'proxy bypass',
})
const bypassHosts = bypass.filter(rule => rule.type !== 'cidr')
const bypassCidrs = bypass.filter(rule => rule.type === 'cidr')
if (matchesProxyRules(PLATFORM_RULES, host, port)) return route('direct', 'platform-forced-direct', snapshot)
if (matchesProxyRules(userNoProxy, host, port)) return route('direct', 'no-proxy', snapshot)
if (matchesProxyRules(bypassHosts, host, port)) return route('direct', 'bypass', snapshot)
if (isIP(host) !== 0 && matchesProxyRules(bypassCidrs, host, port)) return route('direct', 'bypass', snapshot)
if (!outboundProxyScopeEnabled(configuration, scope, providerId)) {
return route('direct', scope === 'modelApi' ? 'provider-direct' : 'scope-direct', snapshot)
}
const endpoint = Object.freeze({
host: configuration.proxy.host,
port: configuration.proxy.port,
remoteDns: configuration.proxy.remoteDns,
username: snapshot.credentials.username,
password: snapshot.credentials.password,
})
if (configuration.proxy.protocol !== 'socks5' || configuration.proxy.remoteDns || isIP(host) !== 0) {
const reason = scope === 'modelApi' ? 'provider-proxy' : 'scope-proxy'
return route(configuration.proxy.protocol, reason, snapshot, { endpoint })
}
if (dnsCache === undefined) throw new TypeError('SOCKS5 local DNS requires a ProxyDnsCache')
const targets = await dnsCache.resolve(host, snapshot.revision, { signal })
const directTargets = targets.filter(target => matchesProxyRules(bypassCidrs, target.address, port))
if (directTargets.length > 0) return route('direct', 'bypass-cidr', snapshot, { targets: Object.freeze(directTargets) })
const reason = scope === 'modelApi' ? 'provider-proxy' : 'scope-proxy'
return route('socks5', reason, snapshot, { endpoint, targets })
}
@@ -0,0 +1,62 @@
import { randomBytes } from 'node:crypto'
import { ProxyConfigurationError } from './errors.mjs'
import { validProviderId } from './contracts.mjs'
const HANDLE_BYTES = 32
const HANDLE_PATTERN = /^[A-Za-z0-9_-]{43}$/
export const PROVIDER_HANDLE_TTL_MS = 15 * 60 * 1000
function invalidHandle() {
return new ProxyConfigurationError('provider policy handle is invalid', {
code: 'PROVIDER_HANDLE_INVALID', statusCode: 407, stage: 'authorize', retryable: true,
})
}
export class ProviderHandleStore {
constructor({ ttlMs = PROVIDER_HANDLE_TTL_MS, now = () => Date.now(), random = randomBytes } = {}) {
if (!Number.isSafeInteger(ttlMs) || ttlMs < 1) throw new TypeError('provider handle TTL is invalid')
this.ttlMs = ttlMs
this.now = now
this.random = random
this.handles = new Map()
}
issue({ providerId, policyRevision }, snapshot) {
if (!validProviderId(providerId)) throw new ProxyConfigurationError('model API provider ID is invalid')
if (typeof policyRevision !== 'string' || policyRevision !== snapshot.revision) {
throw new ProxyConfigurationError('provider policy revision changed', {
code: 'REVISION_CONFLICT', statusCode: 409, stage: 'authorize', retryable: true,
})
}
this.prune()
const handle = this.random(HANDLE_BYTES).toString('base64url')
if (!HANDLE_PATTERN.test(handle) || this.handles.has(handle)) throw new Error('provider handle generation failed')
const expiresAtMs = this.now() + this.ttlMs
this.handles.set(handle, Object.freeze({ providerId, policyRevision, expiresAtMs }))
return Object.freeze({ handle, policyRevision, expiresAt: new Date(expiresAtMs).toISOString() })
}
resolve(authorization, snapshot) {
const match = typeof authorization === 'string' ? /^DSH-Provider ([A-Za-z0-9_-]{43})$/.exec(authorization) : null
if (match === null) throw invalidHandle()
const entry = this.handles.get(match[1])
if (entry === undefined || entry.expiresAtMs <= this.now() || entry.policyRevision !== snapshot.revision) {
if (entry !== undefined) this.handles.delete(match[1])
throw invalidHandle()
}
return entry.providerId
}
prune() {
const now = this.now()
for (const [handle, entry] of this.handles) {
if (entry.expiresAtMs <= now) this.handles.delete(handle)
}
}
clear() {
this.handles.clear()
}
}
export const providerHandleInternals = Object.freeze({ HANDLE_BYTES, HANDLE_PATTERN })
@@ -0,0 +1,29 @@
import { request } from 'node:http'
import { ProxyTransportError } from './transport.mjs'
export async function probeProxyEntry(port, { timeoutMs = 2_000 } = {}) {
await new Promise((resolve, reject) => {
const probe = request({
host: '127.0.0.1',
port,
method: 'GET',
path: '/__dsh_outbound_proxy_readiness__',
headers: { connection: 'close' },
agent: false,
}, response => {
response.resume()
response.once('end', () => {
if (response.statusCode === 400 && response.headers['x-dsh-proxy-error'] === 'INVALID_PROXY_REQUEST') resolve()
else reject(new ProxyTransportError('outbound proxy readiness response is invalid', {
code: 'PROXY_READINESS_FAILED',
}))
})
})
const timer = setTimeout(() => probe.destroy(new ProxyTransportError('outbound proxy readiness timed out', {
code: 'PROXY_READINESS_FAILED',
})), timeoutMs)
probe.once('close', () => clearTimeout(timer))
probe.once('error', reject)
probe.end()
})
}
@@ -0,0 +1,38 @@
import { PROXY_PORTS } from './contracts.mjs'
function enabled(configuration, scope) {
if (!configuration.enabled) return false
if (scope === 'sharedDsh') return configuration.scopes.dshCore || configuration.scopes.dshPlugins
if (scope === 'modelApi') {
return configuration.modelApi.default.proxyEnabled
|| Object.values(configuration.modelApi.providers).some(policy => policy.proxyEnabled)
}
return configuration.scopes[scope] === true
}
export class ProxyRouteHealth {
constructor() {
this.revision = null
this.routes = new Map()
}
observe(snapshot, scope, outcome) {
if (snapshot.revision !== this.revision) {
this.revision = snapshot.revision
this.routes.clear()
}
if (!['ready', 'degraded'].includes(outcome)) throw new TypeError('proxy route health outcome is invalid')
this.routes.set(scope, outcome)
}
status(snapshot) {
if (snapshot.revision !== this.revision) {
this.revision = snapshot.revision
this.routes.clear()
}
return Object.freeze(Object.fromEntries(Object.keys(PROXY_PORTS).map(scope => [
scope,
enabled(snapshot.configuration, scope) ? (this.routes.get(scope) ?? 'unknown') : 'direct',
])))
}
}
@@ -0,0 +1,121 @@
import { BlockList, isIP } from 'node:net'
import { domainToASCII } from 'node:url'
import { ProxyConfigurationError } from './errors.mjs'
import { PLATFORM_NO_PROXY } from '../../../../platform/lib/outbound-proxy.mjs'
export { PLATFORM_NO_PROXY }
function invalid(label) {
throw new ProxyConfigurationError(`${label} rule is invalid`)
}
function normalizePort(value, label) {
if (!/^[0-9]+$/.test(value)) invalid(label)
const port = Number(value)
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) invalid(label)
return port
}
function splitHostPort(input, label) {
if (input.startsWith('[')) {
const close = input.indexOf(']')
if (close < 0) invalid(label)
const host = input.slice(1, close)
const suffix = input.slice(close + 1)
if (suffix === '') return { host, port: null }
if (!suffix.startsWith(':')) invalid(label)
return { host, port: normalizePort(suffix.slice(1), label) }
}
if (isIP(input) === 6) return { host: input, port: null }
const colon = input.lastIndexOf(':')
if (colon >= 0) {
if (input.indexOf(':') !== colon) invalid(label)
return { host: input.slice(0, colon), port: normalizePort(input.slice(colon + 1), label) }
}
return { host: input, port: null }
}
function normalizeHost(input, label) {
let value = input.toLowerCase()
while (value.endsWith('.')) value = value.slice(0, -1)
if (value === '') invalid(label)
const ipType = isIP(value)
if (ipType !== 0) return { host: value, ipType }
const suffix = value.startsWith('.')
const name = suffix ? value.slice(1) : value
if (name === '' || name.includes('*')) invalid(label)
const ascii = domainToASCII(name)
if (ascii === '' || ascii.length > 253 || ascii.split('.').some(part => part === '' || part.length > 63)) invalid(label)
return { host: suffix ? `.${ascii}` : ascii, ipType: 0 }
}
export function normalizeProxyRule(input, { allowWildcard = false, allowCidr = false, label = 'proxy' } = {}) {
if (
typeof input !== 'string'
|| input === ''
|| input !== input.trim()
|| /[\u0000-\u0020\u007f]/.test(input)
|| /[@?#]/.test(input)
) invalid(label)
if (input === '*') {
if (!allowWildcard) invalid(label)
return Object.freeze({ type: 'wildcard', value: '*' })
}
if (input.includes('/')) {
if (!allowCidr) invalid(label)
if (input.indexOf('/') !== input.lastIndexOf('/')) invalid(label)
const slash = input.lastIndexOf('/')
const address = input.slice(0, slash)
const type = isIP(address)
const prefix = Number(input.slice(slash + 1))
if (type === 0 || !Number.isInteger(prefix) || prefix < 0 || prefix > (type === 4 ? 32 : 128)) invalid(label)
return Object.freeze({ type: 'cidr', value: `${address}/${prefix}`, address, prefix, ipType: type })
}
const { host: rawHost, port } = splitHostPort(input, label)
const { host, ipType } = normalizeHost(rawHost, label)
const bracketed = ipType === 6 && port !== null
const value = port === null ? host : `${bracketed ? `[${host}]` : host}:${String(port)}`
return Object.freeze({ type: host.startsWith('.') ? 'suffix' : 'host', value, host, port, ipType })
}
export function normalizeProxyRules(values, options = {}) {
if (!Array.isArray(values)) throw new ProxyConfigurationError(`${options.label ?? 'proxy'} rules must be an array`)
const result = new Map()
for (const value of values) {
const rule = normalizeProxyRule(value, options)
result.set(rule.value, rule)
}
return Object.freeze([...result.values()].sort((left, right) => left.value.localeCompare(right.value)))
}
function hostMatches(rule, host, port) {
if (rule.port !== null && rule.port !== port) return false
if (rule.type === 'suffix') {
const root = rule.host.slice(1)
return host === root || host.endsWith(`.${root}`)
}
return host === rule.host
}
export function matchesProxyRules(rules, targetHost, targetPort = null) {
const normalized = normalizeHost(String(targetHost), 'target').host
const ipType = isIP(normalized)
for (const rule of rules) {
if (rule.type === 'wildcard') return true
if (rule.type === 'cidr') {
if (ipType === 0) continue
const block = new BlockList()
block.addSubnet(rule.address, rule.prefix, rule.ipType === 4 ? 'ipv4' : 'ipv6')
if (block.check(normalized, ipType === 4 ? 'ipv4' : 'ipv6')) return true
continue
}
if (hostMatches(rule, normalized, targetPort)) return true
}
return false
}
export function noProxyEnvironment(userRules) {
const system = normalizeProxyRules(PLATFORM_NO_PROXY, { label: 'platform NO_PROXY' })
const user = normalizeProxyRules(userRules, { allowWildcard: true, label: 'NO_PROXY' })
return [...new Set([...system, ...user].map(rule => rule.value))].join(',')
}
@@ -0,0 +1,52 @@
const groups = Object.freeze({
updates: { zh: '更新管理', en: 'Updates' },
platform: { zh: '平台组件', en: 'Platform components' },
dsh: { zh: 'DSH 核心与插件', en: 'DSH core and plugins' },
agent: { zh: 'Agent 与终端', en: 'Agent and terminals' },
models: { zh: '模型 API', en: 'Model APIs' },
direct: { zh: '始终直连或不受平台管理', en: 'Always direct or unmanaged' },
})
function entry(id, group, category, sourceZh, sourceEn, detailZh, detailEn) {
return Object.freeze({
id, group, category,
source: Object.freeze({ zh: sourceZh, en: sourceEn }),
detail: Object.freeze({ zh: detailZh, en: detailEn }),
})
}
export const PROXY_SCOPE_CATALOG = Object.freeze({
schema: 1,
groups,
entries: Object.freeze([
entry('stable-metadata', 'updates', 'updates', 'Stable、keyring、Release metadata 检查', 'Stable, keyring, and Release metadata checks', '仅远程获取走代理。', 'Only remote retrieval uses the proxy.'),
entry('update-artifacts', 'updates', 'updates', '更新 Artifact 下载', 'Update Artifact downloads', '包括 DSH、Environment 和 Bootstrap 等下载。', 'Includes DSH, Environment, Bootstrap, and related downloads.'),
entry('experimental-metadata', 'updates', 'updates', 'Experimental 上游版本检查', 'Experimental upstream checks', '包括 npm 和相关 metadata 请求。', 'Includes npm and related metadata requests.'),
entry('update-remote', 'updates', 'updates', '更新、回滚中的远程请求', 'Remote requests during update and rollback', '本地切换、恢复和校验保持直连。', 'Local switching, recovery, and verification remain direct.'),
entry('stage0-verification', 'direct', 'direct', 'Stage-0 签名与 Hash 校验', 'Stage-0 signature and Hash verification', '完全本地执行,不接收代理凭据。', 'Runs locally and never receives proxy credentials.'),
entry('management-external', 'platform', 'platform', 'Management 对外请求', 'Management external requests', '不包括本地 Unix socket 通信。', 'Excludes local Unix socket communication.'),
entry('gateway-external', 'platform', 'platform', 'Gateway 自身对外请求', 'Gateway external requests', '浏览器到 DSH 的本地反向代理不使用外部代理。', 'The local browser-to-DSH reverse proxy does not use the external proxy.'),
entry('updater-platform', 'platform', 'platform', 'Updater 的非更新类外部请求', 'Updater non-update external requests', '更新请求优先归入更新管理。', 'Update requests are classified as Updates first.'),
entry('system-plugins', 'platform', 'platform', 'DSH Docker 系统插件', 'DSH Docker System Plugins', '身份来自签名 Environment Manifest。', 'Identity comes from the signed Environment Manifest.'),
entry('dsh-core', 'dsh', 'dshCore', 'DSH 核心对外请求', 'DSH core external requests', '不包括模型 Provider API。', 'Excludes model Provider APIs.'),
entry('official-plugins', 'dsh', 'dshPlugins', 'DSH 官方插件', 'Official DSH plugins', '当前与第三方插件共用一个总开关。', 'Currently shares one switch with third-party plugins.'),
entry('user-plugins', 'dsh', 'dshPlugins', '用户安装的第三方插件', 'User-installed third-party plugins', '包括插件产生的可识别子进程。', 'Includes identifiable child processes created by plugins.'),
entry('plugin-terminal', 'dsh', 'dshPlugins', '第三方插件提供的终端', 'Third-party plugin terminals', '通过可识别子进程接口创建时归入插件范围;直接创建的 PTY 跟随共享 DSH 策略。', 'Uses the plugin scope when created through an identifiable subprocess interface; directly created PTYs follow the shared DSH policy.'),
entry('agent-tools', 'agent', 'agentNetwork', 'Agent 的 web_search、web_fetch 等联网工具', 'Agent network tools such as web_search and web_fetch', '按 Agent 工具执行上下文注入代理。', 'Proxy settings are injected using the Agent execution context.'),
entry('agent-commands', 'agent', 'agentNetwork', 'Agent 执行的 curl、git、npm 等命令', 'Commands such as curl, git, and npm run by an Agent', '包括 Agent 创建的子进程。', 'Includes child processes created by the Agent.'),
entry('agent-session', 'agent', 'agentNetwork', 'Agent 持久终端会话', 'Persistent Agent terminal sessions', '会话创建时确定代理范围。', 'The proxy scope is selected when the session is created.'),
entry('management-terminal', 'agent', 'managementTerminal', '管理中心容器终端', 'Management container terminal', '独立于 Agent 和插件终端。', 'Independent from Agent and plugin terminals.'),
entry('docker-exec', 'direct', 'unmanaged', '宿主机执行的 docker exec Shell', 'Host-created docker exec Shell', '不受平台管理,用户自行设置代理环境。', 'Not managed by the platform; users configure its environment.'),
entry('provider-routed', 'models', 'modelApi', '可识别的模型 Provider', 'Identifiable model Providers', '可分别选择直连或独立代理。', 'Can independently select direct access or a dedicated proxy route.'),
entry('provider-shared', 'models', 'sharedDsh', '无法独立路由的模型请求', 'Model requests without independent routing', '只能选择直连或跟随 DSH 共享流量策略;DSH 核心或 DSH 插件任一范围启用时共享路由使用代理。', 'Can only connect directly or follow the shared DSH policy; the shared route uses the proxy when either DSH Core or DSH Plugins is enabled.'),
entry('provider-local', 'direct', 'forcedDirect', '本地模型 Provider', 'Local model Providers', '本地地址强制直连。', 'Local addresses are always direct.'),
entry('gateway-dsh', 'direct', 'direct', 'Gateway 到 DSH 的本地转发', 'Local Gateway-to-DSH forwarding', '容器内部 loopback 通信。', 'Container-internal loopback communication.'),
entry('platform-sockets', 'direct', 'direct', 'Management、Bootstrap、Stage-0 的 Unix socket', 'Management, Bootstrap, and Stage-0 Unix sockets', '本地控制协议不使用代理。', 'Local control protocols do not use the proxy.'),
entry('local-files', 'direct', 'direct', '日志、文件、快照、Runtime 构建', 'Logs, files, snapshots, and Runtime builds', '本地文件和进程操作不使用代理。', 'Local file and process operations do not use the proxy.'),
]),
summaries: Object.freeze([
Object.freeze({ zh: '本地平台通信始终直连。', en: 'Local platform communication is always direct.' }),
Object.freeze({ zh: '可识别的模型 Provider 可分别选择直连或独立代理;无法独立路由的请求只能选择直连或跟随 DSH。', en: 'Identifiable model Providers independently select direct access or a dedicated proxy; requests without independent routing can only connect directly or follow DSH.' }),
Object.freeze({ zh: 'docker exec 创建的 Shell 不受平台代理设置管理。', en: 'Shells created with docker exec are not managed by platform proxy settings.' }),
]),
})
@@ -0,0 +1,195 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto'
import { chmod, mkdir, open, readFile, readdir, rename, rm, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { canonicalJson } from '../../../../platform/lib/canonical-json.mjs'
import { durableReplace } from '../../../../platform/lib/atomic.mjs'
import { defaultProxyConfiguration, validateProxyConfiguration } from './contracts.mjs'
import { ProxyConfigurationError } from './errors.mjs'
function hash(bytes) {
return createHash('sha256').update(bytes).digest('hex')
}
async function syncDirectory(path) {
const handle = await open(path, 'r')
try { await handle.sync() } finally { await handle.close() }
}
async function writeSynced(path, bytes, mode) {
const handle = await open(path, 'wx', mode)
try {
await handle.writeFile(bytes)
await handle.sync()
} finally {
await handle.close()
}
}
function revision() {
return randomBytes(32).toString('base64url')
}
function parse(bytes, label) {
try { return JSON.parse(bytes.toString('utf8')) } catch { throw new ProxyConfigurationError(`${label} is corrupt`, { code: 'PROXY_STATE_CORRUPT', stage: 'load' }) }
}
export class ProxyConfigurationStore {
constructor(root, { now = () => new Date() } = {}) {
this.root = root
this.revisionsRoot = join(root, 'revisions')
this.currentPath = join(root, 'current.json')
this.now = now
this.queue = Promise.resolve()
}
exclusive(operation) {
const result = this.queue.then(operation, operation)
this.queue = result.then(() => undefined, () => undefined)
return result
}
async prepare() {
await mkdir(this.revisionsRoot, { recursive: true, mode: 0o700 })
await chmod(this.root, 0o700)
await chmod(this.revisionsRoot, 0o700)
}
async readPointer() {
try {
const pointer = parse(await readFile(this.currentPath), 'proxy current pointer')
if (pointer?.schema !== 1 || typeof pointer.revision !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(pointer.revision)) {
throw new ProxyConfigurationError('proxy current pointer is corrupt', { code: 'PROXY_STATE_CORRUPT', stage: 'load' })
}
return pointer.revision
} catch (error) {
if (error?.code === 'ENOENT') return null
throw error
}
}
async readRevision(id) {
const root = join(this.revisionsRoot, id)
const [configBytes, credentialsBytes, manifestBytes] = await Promise.all([
readFile(join(root, 'config.json')),
readFile(join(root, 'credentials.json')),
readFile(join(root, 'manifest.json')),
])
const manifest = parse(manifestBytes, 'proxy revision manifest')
if (
manifest?.schema !== 1 || manifest.revision !== id
|| manifest.files?.config !== hash(configBytes)
|| manifest.files?.credentials !== hash(credentialsBytes)
) throw new ProxyConfigurationError('proxy revision manifest does not match its files', { code: 'PROXY_STATE_CORRUPT', stage: 'load' })
const configuration = parse(configBytes, 'proxy configuration')
const credentials = parse(credentialsBytes, 'proxy credentials')
if (credentials?.schema !== 1 || credentials.username !== configuration?.proxy?.username || !('password' in credentials)) {
throw new ProxyConfigurationError('proxy credentials do not match configuration', { code: 'PROXY_STATE_CORRUPT', stage: 'load' })
}
const validated = validateProxyConfiguration({
...configuration,
proxy: { ...configuration.proxy, password: credentials.password },
noProxy: { user: configuration.noProxy.user },
})
if (validated.configuration.proxy.passwordConfigured !== configuration.proxy.passwordConfigured) {
throw new ProxyConfigurationError('proxy password state is inconsistent', { code: 'PROXY_STATE_CORRUPT', stage: 'load' })
}
return Object.freeze({ revision: id, configuration: validated.configuration, credentials: validated.credentials, createdAt: manifest.createdAt })
}
async recoveryCandidates() {
const entries = await readdir(this.revisionsRoot, { withFileTypes: true })
const candidates = []
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
try {
const loaded = await this.readRevision(entry.name)
candidates.push(loaded)
} catch {}
}
return candidates.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
}
async load() {
await this.prepare()
let current = null
let corruptPointer = false
try {
current = await this.readPointer()
} catch (error) {
if (error?.code !== 'PROXY_STATE_CORRUPT') throw error
corruptPointer = true
}
if (current !== null) {
try { return Object.freeze({ ...(await this.readRevision(current)), recovery: 'none' }) } catch (error) {
if (error?.code !== 'PROXY_STATE_CORRUPT' && error?.code !== 'ENOENT') throw error
}
}
const recovered = (await this.recoveryCandidates())[0]
if (recovered !== undefined) {
await durableReplace(this.currentPath, canonicalJson({ schema: 1, revision: recovered.revision }), 0o640)
return Object.freeze({ ...recovered, recovery: 'previous' })
}
const entries = await readdir(this.revisionsRoot, { withFileTypes: true })
const hadPublishedState = corruptPointer || entries.some(entry => entry.isDirectory() && !entry.name.startsWith('.'))
if (corruptPointer) {
await rename(this.currentPath, join(this.root, `current.corrupt.${randomUUID()}.json`))
await syncDirectory(this.root)
}
const initialized = await this.commit({ baseRevision: null, value: defaultProxyConfiguration() })
return Object.freeze({ ...initialized, recovery: hadPublishedState ? 'reset-disabled' : 'none' })
}
commit(request) {
return this.exclusive(() => this.commitUnlocked(request))
}
async commitUnlocked({ baseRevision, value }) {
await this.prepare()
let current = null
try { current = await this.loadCurrentWithoutRecovery() } catch (error) {
if (error?.code !== 'ENOENT') throw error
}
if ((current?.revision ?? null) !== baseRevision) {
throw new ProxyConfigurationError('proxy configuration changed', {
code: 'REVISION_CONFLICT', statusCode: 409, stage: 'activate',
})
}
const validated = validateProxyConfiguration(value, { existingPassword: current?.credentials.password ?? null })
const id = revision()
const staging = join(this.revisionsRoot, `.${id}.${randomUUID()}.tmp`)
const published = join(this.revisionsRoot, id)
await mkdir(staging, { mode: 0o700 })
try {
const configBytes = canonicalJson(validated.configuration)
const credentialsBytes = canonicalJson({ schema: 1, ...validated.credentials })
const manifestBytes = canonicalJson({
schema: 1,
revision: id,
createdAt: this.now().toISOString(),
files: { config: hash(configBytes), credentials: hash(credentialsBytes) },
})
await writeSynced(join(staging, 'config.json'), configBytes, 0o640)
await writeSynced(join(staging, 'credentials.json'), credentialsBytes, 0o600)
await writeSynced(join(staging, 'manifest.json'), manifestBytes, 0o640)
await syncDirectory(staging)
await rename(staging, published)
await syncDirectory(this.revisionsRoot)
const candidate = await this.readRevision(id)
await durableReplace(this.currentPath, canonicalJson({ schema: 1, revision: id }), 0o640)
return candidate
} catch (error) {
await rm(staging, { recursive: true, force: true })
throw error
}
}
async loadCurrentWithoutRecovery() {
const current = await this.readPointer()
if (current === null) {
const error = new Error('proxy configuration is not initialized')
error.code = 'ENOENT'
throw error
}
return this.readRevision(current)
}
}
@@ -0,0 +1,455 @@
import { createHash, randomUUID } from 'node:crypto'
import { lookup } from 'node:dns/promises'
import { connect as tlsConnect } from 'node:tls'
import { canonicalJson } from '../../../../platform/lib/canonical-json.mjs'
import { durableReplace } from '../../../../platform/lib/atomic.mjs'
import { validateProxyConfiguration } from './contracts.mjs'
import { ProxyDnsCache } from './dns-cache.mjs'
import { ProxyConfigurationError } from './errors.mjs'
import { selectProxyRoute } from './policy.mjs'
import {
connectTcp,
connectThroughHttpProxy,
connectThroughSocks5,
readHttpHead,
ProxyTransportError,
} from './transport.mjs'
const TASK_SCHEMA = 1
const PHASE_TIMEOUT_MS = 15_000
const TOTAL_TIMEOUT_MS = 60_000
const RETENTION_MS = 10 * 60_000
const STAGES = Object.freeze([
'proxy-address', 'proxy-connect', 'proxy-handshake', 'target-dns', 'target-tls', 'target-http',
])
const TARGETS = Object.freeze([
Object.freeze({ id: 'github-metadata', host: 'api.github.com', port: 443, path: '/repos/yjrszcq/dsh-docker/releases/latest' }),
Object.freeze({ id: 'npm-registry', host: 'registry.npmjs.org', port: 443, path: '/@deepseek-ai%2fdsh/latest' }),
])
function safeError(error) {
return Object.freeze({
errorCode: typeof error?.code === 'string' ? error.code : 'PROXY_TEST_FAILED',
detail: error instanceof Error ? error.message : 'proxy test failed',
})
}
function pendingStage(stage) {
return {
stage,
status: 'pending',
durationMs: null,
errorCode: null,
detail: null,
detailCode: null,
detailData: null,
}
}
function stageDetail(detail, detailCode, detailData = {}) {
return Object.freeze({ detail, detailCode, detailData: Object.freeze(detailData) })
}
function stagesFor(configuration) {
const localTargetDns = configuration.proxy.protocol === 'socks5'
&& configuration.proxy.remoteDns === false
return localTargetDns ? STAGES : STAGES.filter(stage => stage !== 'target-dns')
}
function taskHash(configuration, baseRevision) {
return `sha256:${createHash('sha256').update(canonicalJson({ baseRevision, configuration })).digest('hex')}`
}
function publicTask(task) {
return Object.freeze({
schema: TASK_SCHEMA,
taskId: task.taskId,
status: task.status,
baseRevision: task.baseRevision,
candidateHash: task.candidateHash,
mode: task.mode,
currentStage: task.currentStage,
stages: Object.freeze(task.stages.map(stage => Object.freeze({ ...stage }))),
error: task.error === null ? null : Object.freeze({ ...task.error }),
createdAt: task.createdAt,
updatedAt: task.updatedAt,
})
}
function modeFor(routes) {
const modes = new Set(routes.map(route => route.mode === 'direct' ? 'direct' : 'proxy'))
return modes.size === 1 ? modes.values().next().value : 'mixed'
}
async function firstConnection(route, target, signal) {
if (route.mode === 'direct') {
const candidates = route.targets ?? [{ address: target.host }]
let failure
for (const candidate of candidates) {
try {
return Object.freeze({
socket: await connectTcp({ host: candidate.address, port: target.port, signal }),
remainder: Buffer.alloc(0),
})
} catch (error) { failure = error }
}
throw failure ?? new ProxyTransportError('target connection failed')
}
if (route.mode === 'http') return connectThroughHttpProxy({
endpoint: route.endpoint, targetHost: target.host, targetPort: target.port, signal,
})
if (route.mode === 'socks5') {
const candidates = route.targets ?? [{ address: target.host }]
let failure
for (const candidate of candidates) {
try {
return await connectThroughSocks5({
endpoint: route.endpoint, targetHost: candidate.address, targetPort: target.port, signal,
})
} catch (error) { failure = error }
}
throw failure ?? new ProxyTransportError('SOCKS5 target connection failed')
}
throw new ProxyTransportError('proxy route is invalid', { code: 'PROXY_PROTOCOL_UNAVAILABLE' })
}
async function secureConnection(route, target, signal) {
const connection = await firstConnection(route, target, signal)
if (connection.remainder.byteLength > 0) connection.socket.unshift(connection.remainder)
const socket = tlsConnect({ socket: connection.socket, servername: target.host, rejectUnauthorized: true })
await new Promise((resolve, reject) => {
const cleanup = () => {
socket.off('secureConnect', connected)
socket.off('error', failed)
signal.removeEventListener('abort', cancelled)
}
const connected = () => { cleanup(); resolve() }
const failed = error => { cleanup(); reject(error) }
const cancelled = () => {
cleanup()
socket.destroy()
reject(new ProxyTransportError('TLS connection was cancelled', { code: 'REQUEST_CANCELLED', statusCode: 499 }))
}
socket.once('secureConnect', connected)
socket.once('error', failed)
signal.addEventListener('abort', cancelled, { once: true })
})
return socket
}
async function targetHttp(route, target, signal) {
const socket = await secureConnection(route, target, signal)
try {
socket.write([
`GET ${target.path} HTTP/1.1`,
`Host: ${target.host}`,
'User-Agent: DSH-Docker-Proxy-Test/1',
'Accept: application/json',
'Connection: close',
'',
'',
].join('\r\n'))
const { head } = await readHttpHead(socket, { signal })
const status = Number(/^HTTP\/1\.[01] ([0-9]{3})/.exec(head.toString('latin1'))?.[1])
if (!Number.isInteger(status) || status >= 500) {
throw new ProxyTransportError(`target returned HTTP ${Number.isInteger(status) ? String(status) : 'invalid'}`, {
code: 'TARGET_HTTP_FAILED',
})
}
return status
} finally {
socket.destroy()
}
}
export class ProxyTestManager {
constructor({
statePath,
now = () => new Date(),
resolve = lookup,
phaseTimeoutMs = PHASE_TIMEOUT_MS,
totalTimeoutMs = TOTAL_TIMEOUT_MS,
retentionMs = RETENTION_MS,
targets = TARGETS,
}) {
this.statePath = statePath
this.now = now
this.resolve = resolve
this.phaseTimeoutMs = phaseTimeoutMs
this.totalTimeoutMs = totalTimeoutMs
this.retentionMs = retentionMs
this.targets = targets
this.tasks = new Map()
this.active = null
this.lastTest = null
this.writeQueue = Promise.resolve()
}
async initialize(readState) {
let value
try { value = await readState(this.statePath) } catch (error) {
if (error?.code === 'ENOENT') return
throw error
}
if (value?.schema !== TASK_SCHEMA || !Array.isArray(value.tasks)) return
for (const stored of value.tasks) {
if (stored?.schema !== TASK_SCHEMA || typeof stored.taskId !== 'string') continue
const task = { ...stored, stages: stored.stages.map(stage => ({ ...stage })), controller: undefined }
if (task.status === 'running') {
task.status = 'failed'
task.error = { errorCode: 'PROXY_TEST_INTERRUPTED', detail: 'Proxy Manager restarted during the test' }
task.updatedAt = this.now().toISOString()
for (const stage of task.stages) if (['pending', 'running'].includes(stage.status)) stage.status = 'skipped'
}
this.tasks.set(task.taskId, task)
}
this.prune()
this.lastTest = [...this.tasks.values()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0] ?? null
await this.persist()
}
getState() {
return { lastTest: this.lastTest === null ? null : publicTask(this.lastTest) }
}
prune() {
const cutoff = this.now().getTime() - this.retentionMs
for (const [id, task] of this.tasks) {
if (task.status !== 'running' && Date.parse(task.updatedAt) < cutoff) this.tasks.delete(id)
}
}
async persist() {
this.prune()
const tasks = [...this.tasks.values()].map(task => publicTask(task))
const bytes = canonicalJson({ schema: TASK_SCHEMA, tasks })
const write = this.writeQueue.then(() => durableReplace(this.statePath, bytes, 0o640))
this.writeQueue = write.catch(() => {})
await write
}
async lookup(host, signal) {
if (signal?.aborted) throw signal.reason
return new Promise((resolve, reject) => {
const cancelled = () => reject(signal.reason ?? new ProxyTransportError('DNS resolution was cancelled', {
code: 'REQUEST_CANCELLED', statusCode: 499,
}))
signal?.addEventListener('abort', cancelled, { once: true })
Promise.resolve(this.resolve(host, { all: true, verbatim: true })).then(resolve, reject).finally(() => {
signal?.removeEventListener('abort', cancelled)
})
})
}
async start(request, current) {
if (request === null || typeof request !== 'object' || Array.isArray(request)
|| Object.keys(request).sort().join(',') !== 'baseRevision,value') {
throw new ProxyConfigurationError('proxy test request is invalid', { stage: 'test' })
}
const { baseRevision, value } = request
if (this.active !== null) {
throw new ProxyConfigurationError('another proxy test is already running', {
code: 'PROXY_TEST_BUSY', statusCode: 409, stage: 'test', retryable: true,
})
}
if (baseRevision !== current.revision) {
throw new ProxyConfigurationError('proxy configuration changed', {
code: 'REVISION_CONFLICT', statusCode: 409, stage: 'test', retryable: true,
})
}
const testedValue = {
...value,
enabled: true,
scopes: { ...value.scopes, updates: true },
noProxy: { ...value.noProxy, user: [] },
bypass: { ...value.bypass, additional: [] },
}
const validated = validateProxyConfiguration(testedValue, { existingPassword: current.credentials.password })
const snapshot = Object.freeze({
revision: `test-${randomUUID()}`,
configuration: validated.configuration,
credentials: validated.credentials,
})
const createdAt = this.now().toISOString()
const task = {
schema: TASK_SCHEMA,
taskId: randomUUID(),
status: 'running',
baseRevision,
candidateHash: taskHash(validated.configuration, baseRevision),
mode: null,
currentStage: stagesFor(validated.configuration)[0],
stages: stagesFor(validated.configuration).map(pendingStage),
error: null,
createdAt,
updatedAt: createdAt,
controller: new AbortController(),
}
this.tasks.set(task.taskId, task)
this.active = task
await this.persist()
void this.run(task, snapshot).catch(() => {})
return publicTask(task)
}
async update(task, value) {
Object.assign(task, value, { updatedAt: this.now().toISOString() })
await this.persist()
}
async stage(task, name, operation) {
const entry = task.stages.find(value => value.stage === name)
entry.status = 'running'
task.currentStage = name
await this.update(task, {})
const started = Date.now()
const signal = AbortSignal.any([task.controller.signal, AbortSignal.timeout(this.phaseTimeoutMs)])
try {
const result = await operation(signal)
Object.assign(entry, {
status: 'success', durationMs: Date.now() - started, errorCode: null,
detail: typeof result === 'string' ? result : result?.detail ?? null,
detailCode: typeof result === 'object' ? result?.detailCode ?? null : null,
detailData: typeof result === 'object' ? result?.detailData ?? null : null,
})
await this.update(task, {})
} catch (error) {
const cancelled = task.controller.signal.aborted
&& task.controller.signal.reason?.code !== 'PROXY_TEST_TIMEOUT'
const failure = cancelled
? { errorCode: 'REQUEST_CANCELLED', detail: 'proxy test was cancelled' }
: signal.reason?.name === 'TimeoutError'
? { errorCode: 'PROXY_TEST_STAGE_TIMEOUT', detail: `${name} timed out` }
: safeError(error)
Object.assign(entry, {
status: cancelled ? 'cancelled' : 'failed',
durationMs: Date.now() - started,
...failure,
})
throw error
}
}
async run(task, snapshot) {
const totalTimer = setTimeout(() => task.controller.abort(new ProxyTransportError('proxy test timed out', {
code: 'PROXY_TEST_TIMEOUT', statusCode: 504,
})), this.totalTimeoutMs)
totalTimer.unref?.()
const dnsCache = new ProxyDnsCache({ resolver: this.resolve })
let routes
try {
routes = await Promise.all(this.targets.map(target => selectProxyRoute({
snapshot, scope: 'updates', host: target.host, port: target.port, dnsCache,
signal: task.controller.signal,
})))
task.mode = modeFor(routes)
const proxied = routes.find(route => route.mode !== 'direct')
if (proxied === undefined) {
for (const name of ['proxy-address', 'proxy-connect', 'proxy-handshake']) {
const stage = task.stages.find(value => value.stage === name)
Object.assign(stage, {
status: 'skipped', durationMs: 0, detail: 'candidate route is direct',
detailCode: 'route-direct', detailData: {},
})
}
await this.update(task, {})
} else {
await this.stage(task, 'proxy-address', async () => {
const records = await this.lookup(proxied.endpoint.host, task.controller.signal)
if (!Array.isArray(records) || records.length === 0) throw new ProxyTransportError('proxy address resolution returned no records', { code: 'PROXY_DNS_FAILED' })
return stageDetail(
`resolved ${String(records.length)} proxy address record(s)`,
'proxy-addresses-resolved',
{ count: records.length },
)
})
await this.stage(task, 'proxy-connect', async signal => {
const socket = await connectTcp({ host: proxied.endpoint.host, port: proxied.endpoint.port, signal })
socket.destroy()
return stageDetail('proxy TCP connection succeeded', 'proxy-connected')
})
await this.stage(task, 'proxy-handshake', async signal => {
const target = this.targets[routes.indexOf(proxied)]
const connection = await firstConnection(proxied, target, signal)
connection.socket.destroy()
const protocol = snapshot.configuration.proxy.protocol.toUpperCase()
return stageDetail(`${protocol} proxy authentication succeeded`, 'proxy-handshake-completed', { protocol })
})
}
if (task.stages.some(stage => stage.stage === 'target-dns')) {
await this.stage(task, 'target-dns', async () => {
for (const target of this.targets) {
const records = await this.lookup(target.host, task.controller.signal)
if (!Array.isArray(records) || records.length === 0) throw new ProxyTransportError('target DNS returned no records', { code: 'TARGET_DNS_FAILED' })
}
return stageDetail(
`resolved ${String(this.targets.length)} target(s)`,
'target-addresses-resolved',
{ count: this.targets.length },
)
})
}
await this.stage(task, 'target-tls', async signal => {
for (let index = 0; index < this.targets.length; index += 1) {
const socket = await secureConnection(routes[index], this.targets[index], signal)
socket.destroy()
}
return stageDetail(
`TLS verified for ${String(this.targets.length)} target(s)`,
'target-tls-verified',
{ count: this.targets.length },
)
})
await this.stage(task, 'target-http', async signal => {
const results = []
for (let index = 0; index < this.targets.length; index += 1) {
const status = await targetHttp(routes[index], this.targets[index], signal)
results.push({ id: this.targets[index].id, status })
}
return stageDetail(
results.map(result => `${result.id}=HTTP ${String(result.status)}`).join(', '),
'target-http-completed',
{ results },
)
})
await this.update(task, { status: 'success', currentStage: null, error: null })
} catch (error) {
const timeout = task.controller.signal.reason?.code === 'PROXY_TEST_TIMEOUT'
const cancelled = task.controller.signal.aborted && !timeout
for (const stage of task.stages) {
if (stage.status === 'pending') Object.assign(stage, { status: 'skipped', durationMs: 0 })
}
await this.update(task, {
status: cancelled ? 'cancelled' : 'failed',
currentStage: null,
error: cancelled
? { errorCode: 'REQUEST_CANCELLED', detail: 'proxy test was cancelled' }
: timeout ? safeError(task.controller.signal.reason) : safeError(error),
})
} finally {
clearTimeout(totalTimer)
if (this.active === task) this.active = null
task.controller = undefined
this.lastTest = task
await this.persist()
}
}
get(taskId) {
this.prune()
const task = this.tasks.get(taskId)
if (task === undefined) throw new ProxyConfigurationError('proxy test task was not found', {
code: 'PROXY_TEST_NOT_FOUND', statusCode: 404, stage: 'test',
})
return publicTask(task)
}
async cancel(taskId) {
const task = this.tasks.get(taskId)
if (task === undefined) return this.get(taskId)
if (task.status === 'running') task.controller.abort(new Error('proxy test cancelled'))
return publicTask(task)
}
}
export const proxyTestInternals = Object.freeze({ STAGES, TARGETS, modeFor, publicTask, stageDetail, stagesFor })
@@ -0,0 +1,423 @@
import { lookup } from 'node:dns/promises'
import { connect, isIP } from 'node:net'
import { domainToASCII } from 'node:url'
export const PROXY_TIMEOUTS = Object.freeze({
connectMs: 10_000,
handshakeMs: 15_000,
responseHeaderMs: 60_000,
streamIdleMs: 120_000,
tunnelIdleMs: 30 * 60_000,
})
export class ProxyTransportError extends Error {
constructor(message, { code = 'UPSTREAM_CONNECT_FAILED', statusCode = 502, cause } = {}) {
super(message, { cause })
this.name = 'ProxyTransportError'
this.code = code
this.statusCode = statusCode
}
}
function timeoutError(stage) {
return new ProxyTransportError(`${stage} timed out`, {
code: 'PROXY_CONNECT_TIMEOUT',
statusCode: 504,
})
}
export async function connectTcp({ host, port, timeoutMs = PROXY_TIMEOUTS.connectMs, signal }) {
if (signal?.aborted) {
throw new ProxyTransportError('upstream connection was cancelled', {
code: 'REQUEST_CANCELLED', statusCode: 499,
})
}
const socket = connect({ host, port, allowHalfOpen: true })
// The socket passes through async handshake and Agent/tunnel owners. Keep a
// baseline listener so a peer reset during an ownership handoff cannot exit
// the Proxy Manager; each active owner still installs its own error handler.
socket.on('error', () => {})
try {
await new Promise((resolve, reject) => {
const cleanup = () => {
clearTimeout(timer)
socket.off('connect', connected)
socket.off('error', failed)
signal?.removeEventListener('abort', cancelled)
}
const connected = () => { cleanup(); resolve() }
const failed = error => { cleanup(); reject(error) }
const cancelled = () => {
const error = new ProxyTransportError('upstream connection was cancelled', {
code: 'REQUEST_CANCELLED', statusCode: 499,
})
cleanup()
socket.destroy()
reject(error)
}
const timer = setTimeout(() => { cleanup(); reject(timeoutError('TCP connection')) }, timeoutMs)
socket.once('connect', connected)
socket.once('error', failed)
signal?.addEventListener('abort', cancelled, { once: true })
})
socket.setTimeout(0)
return socket
} catch (error) {
socket.destroy()
if (error instanceof ProxyTransportError) throw error
throw new ProxyTransportError('upstream connection failed', { cause: error })
}
}
export function basicProxyAuthorization(username, password) {
if (username === '' && password === null) return null
return `Basic ${Buffer.from(`${username}:${password ?? ''}`, 'utf8').toString('base64')}`
}
function validateHttpHeadLimits(head) {
const lines = head.toString('latin1').split('\r\n')
if (lines.length - 2 > 256 || lines.some(line => Buffer.byteLength(line, 'latin1') > 64 * 1024)) {
throw new ProxyTransportError('upstream response headers are too large', {
code: 'UPSTREAM_HEADERS_TOO_LARGE',
})
}
}
export async function readHttpHead(socket, {
maxBytes = 256 * 1024,
timeoutMs = PROXY_TIMEOUTS.handshakeMs,
signal,
} = {}) {
const reader = new SocketByteReader(socket)
const cancel = () => socket.destroy(new ProxyTransportError('proxy handshake was cancelled', {
code: 'REQUEST_CANCELLED', statusCode: 499,
}))
signal?.addEventListener('abort', cancel, { once: true })
try {
const head = await reader.readUntil(Buffer.from('\r\n\r\n'), maxBytes, Date.now() + timeoutMs)
return Object.freeze({ head, remainder: reader.release() })
} catch (error) {
if (reader.pending === null) reader.release()
if (error instanceof ProxyTransportError) throw error
throw new ProxyTransportError('upstream proxy handshake failed', { cause: error })
} finally {
signal?.removeEventListener('abort', cancel)
}
}
export async function connectThroughHttpProxy({
endpoint,
targetHost,
targetPort,
connectTimeoutMs = PROXY_TIMEOUTS.connectMs,
handshakeTimeoutMs = PROXY_TIMEOUTS.handshakeMs,
signal,
}) {
const socket = await connectTcp({ ...endpoint, timeoutMs: connectTimeoutMs, signal })
const authorization = basicProxyAuthorization(endpoint.username, endpoint.password)
const authority = targetHost.includes(':') ? `[${targetHost}]:${targetPort}` : `${targetHost}:${targetPort}`
socket.write([
`CONNECT ${authority} HTTP/1.1`,
`Host: ${authority}`,
...(authorization === null ? [] : [`Proxy-Authorization: ${authorization}`]),
'Proxy-Connection: keep-alive',
'',
'',
].join('\r\n'))
try {
const result = await readHttpHead(socket, { timeoutMs: handshakeTimeoutMs, signal })
validateHttpHeadLimits(result.head)
const first = result.head.subarray(0, result.head.indexOf('\r\n')).toString('latin1')
const match = /^HTTP\/1\.[01] ([0-9]{3})(?: |$)/.exec(first)
if (match === null) throw new ProxyTransportError('upstream proxy returned an invalid response')
const status = Number(match[1])
if (status < 200 || status >= 300) {
throw new ProxyTransportError(
status === 407 ? 'upstream proxy authentication failed' : 'upstream proxy rejected the tunnel',
{ code: status === 407 ? 'UPSTREAM_PROXY_AUTH_FAILED' : 'UPSTREAM_PROXY_REJECTED' },
)
}
return Object.freeze({ socket, remainder: result.remainder })
} catch (error) {
socket.destroy()
throw error
}
}
function ipv4Bytes(address) {
const values = address.split('.').map(Number)
if (values.length !== 4 || values.some(value => !Number.isInteger(value) || value < 0 || value > 255)) {
throw new ProxyTransportError('SOCKS5 IPv4 target is invalid', { code: 'SOCKS5_TARGET_INVALID' })
}
return Buffer.from(values)
}
function ipv6Groups(input) {
const expand = values => {
const result = []
for (const value of values) {
if (value.includes('.')) {
const bytes = ipv4Bytes(value)
result.push(bytes.readUInt16BE(0).toString(16), bytes.readUInt16BE(2).toString(16))
} else result.push(value)
}
return result
}
const halves = input.toLowerCase().split('::')
if (halves.length > 2) throw new ProxyTransportError('SOCKS5 IPv6 target is invalid', { code: 'SOCKS5_TARGET_INVALID' })
const left = expand(halves[0] === '' ? [] : halves[0].split(':'))
const right = expand(halves.length === 1 || halves[1] === '' ? [] : halves[1].split(':'))
const missing = 8 - left.length - right.length
if ((halves.length === 1 && missing !== 0) || (halves.length === 2 && missing < 1)) {
throw new ProxyTransportError('SOCKS5 IPv6 target is invalid', { code: 'SOCKS5_TARGET_INVALID' })
}
const groups = halves.length === 1 ? left : [...left, ...Array(missing).fill('0'), ...right]
if (groups.length !== 8 || groups.some(group => !/^[0-9a-f]{1,4}$/.test(group))) {
throw new ProxyTransportError('SOCKS5 IPv6 target is invalid', { code: 'SOCKS5_TARGET_INVALID' })
}
return groups.map(group => Number.parseInt(group, 16))
}
function ipv6Bytes(address) {
const output = Buffer.alloc(16)
ipv6Groups(address).forEach((value, index) => output.writeUInt16BE(value, index * 2))
return output
}
async function socksTarget(host, remoteDns, resolver) {
let value = host
let type = isIP(value)
if (type === 0 && !remoteDns) {
let record
try { record = await resolver(value, { verbatim: true }) } catch (error) {
throw new ProxyTransportError('SOCKS5 target DNS resolution failed', {
code: 'TARGET_DNS_FAILED',
cause: error,
})
}
value = record.address
type = record.family
}
if (type === 4) return Buffer.concat([Buffer.from([0x01]), ipv4Bytes(value)])
if (type === 6) return Buffer.concat([Buffer.from([0x04]), ipv6Bytes(value)])
const ascii = domainToASCII(value)
const bytes = Buffer.from(ascii, 'ascii')
if (ascii === '' || bytes.byteLength > 255) {
throw new ProxyTransportError('SOCKS5 domain target is invalid', { code: 'SOCKS5_TARGET_INVALID' })
}
return Buffer.concat([Buffer.from([0x03, bytes.byteLength]), bytes])
}
class SocketByteReader {
constructor(socket) {
this.socket = socket
this.buffer = Buffer.alloc(0)
this.pending = null
this.ended = false
this.failure = null
this.onData = chunk => {
this.buffer = this.buffer.byteLength === 0 ? chunk : Buffer.concat([this.buffer, chunk])
this.flush()
}
this.onEnd = () => {
this.ended = true
this.flush()
}
this.onError = error => {
this.failure = error
this.flush()
}
socket.on('data', this.onData)
socket.on('end', this.onEnd)
socket.on('error', this.onError)
}
flush() {
if (this.pending === null) return
let size = null
if (this.pending.kind === 'exact' && this.buffer.byteLength >= this.pending.size) size = this.pending.size
if (this.pending.kind === 'until') {
const boundary = this.buffer.indexOf(this.pending.delimiter)
if (boundary >= 0) {
size = boundary + this.pending.delimiter.byteLength
if (size > this.pending.maxBytes) {
const pending = this.pending
this.pending = null
clearTimeout(pending.timer)
pending.reject(new ProxyTransportError('upstream response headers are too large', {
code: 'UPSTREAM_HEADERS_TOO_LARGE',
}))
return
}
}
else if (this.buffer.byteLength > this.pending.maxBytes) {
const pending = this.pending
this.pending = null
clearTimeout(pending.timer)
pending.reject(new ProxyTransportError('upstream response headers are too large', {
code: 'UPSTREAM_HEADERS_TOO_LARGE',
}))
return
}
}
if (size !== null) {
const pending = this.pending
this.pending = null
clearTimeout(pending.timer)
const value = this.buffer.subarray(0, size)
this.buffer = this.buffer.subarray(size)
pending.resolve(value)
return
}
if (this.failure !== null || this.ended) {
const pending = this.pending
this.pending = null
clearTimeout(pending.timer)
pending.reject(this.failure ?? new ProxyTransportError('upstream closed during SOCKS5 handshake'))
}
}
read(size, deadline) {
if (!Number.isSafeInteger(size) || size < 0) {
return Promise.reject(new ProxyTransportError('SOCKS5 response length is invalid'))
}
if (this.pending !== null) {
return Promise.reject(new ProxyTransportError('concurrent SOCKS5 reads are not supported'))
}
if (this.buffer.byteLength >= size) {
const value = this.buffer.subarray(0, size)
this.buffer = this.buffer.subarray(size)
return Promise.resolve(value)
}
if (this.failure !== null) return Promise.reject(this.failure)
if (this.ended) return Promise.reject(new ProxyTransportError('upstream closed during SOCKS5 handshake'))
const remaining = deadline - Date.now()
if (remaining <= 0) return Promise.reject(timeoutError('SOCKS5 handshake'))
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
if (this.pending?.timer !== timer) return
this.pending = null
reject(timeoutError('SOCKS5 handshake'))
}, remaining)
this.pending = { kind: 'exact', size, resolve, reject, timer }
this.flush()
})
}
readUntil(delimiter, maxBytes, deadline) {
if (!Buffer.isBuffer(delimiter) || delimiter.byteLength === 0 || !Number.isSafeInteger(maxBytes) || maxBytes < delimiter.byteLength) {
return Promise.reject(new ProxyTransportError('proxy handshake boundary is invalid'))
}
if (this.pending !== null) return Promise.reject(new ProxyTransportError('concurrent proxy handshake reads are not supported'))
const boundary = this.buffer.indexOf(delimiter)
if (boundary >= 0) {
const size = boundary + delimiter.byteLength
if (size > maxBytes) return Promise.reject(new ProxyTransportError('upstream response headers are too large', { code: 'UPSTREAM_HEADERS_TOO_LARGE' }))
const value = this.buffer.subarray(0, size)
this.buffer = this.buffer.subarray(size)
return Promise.resolve(value)
}
if (this.buffer.byteLength > maxBytes) return Promise.reject(new ProxyTransportError('upstream response headers are too large', { code: 'UPSTREAM_HEADERS_TOO_LARGE' }))
if (this.failure !== null) return Promise.reject(this.failure)
if (this.ended) return Promise.reject(new ProxyTransportError('upstream closed during proxy handshake'))
const remaining = deadline - Date.now()
if (remaining <= 0) return Promise.reject(timeoutError('proxy handshake'))
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
if (this.pending?.timer !== timer) return
this.pending = null
reject(timeoutError('proxy handshake'))
}, remaining)
this.pending = { kind: 'until', delimiter, maxBytes, resolve, reject, timer }
this.flush()
})
}
release() {
if (this.pending !== null) throw new ProxyTransportError('proxy handshake read is still pending')
this.socket.pause()
this.socket.off('data', this.onData)
this.socket.off('end', this.onEnd)
this.socket.off('error', this.onError)
const remainder = this.buffer
this.buffer = Buffer.alloc(0)
return remainder
}
}
function socksReplyError(code) {
const names = new Map([
[0x01, 'general failure'], [0x02, 'connection not allowed'], [0x03, 'network unreachable'],
[0x04, 'host unreachable'], [0x05, 'connection refused'], [0x06, 'TTL expired'],
[0x07, 'command not supported'], [0x08, 'address type not supported'],
])
return new ProxyTransportError(`SOCKS5 proxy rejected the connection (${names.get(code) ?? 'unknown error'})`, {
code: 'UPSTREAM_PROXY_REJECTED',
})
}
export async function connectThroughSocks5({
endpoint,
targetHost,
targetPort,
resolver = lookup,
connectTimeoutMs = PROXY_TIMEOUTS.connectMs,
handshakeTimeoutMs = PROXY_TIMEOUTS.handshakeMs,
timeoutMs,
signal,
}) {
const socket = await connectTcp({ ...endpoint, timeoutMs: connectTimeoutMs, signal })
const cancel = () => socket.destroy(new ProxyTransportError('SOCKS5 handshake was cancelled', {
code: 'REQUEST_CANCELLED', statusCode: 499,
}))
signal?.addEventListener('abort', cancel, { once: true })
const reader = new SocketByteReader(socket)
const deadline = Date.now() + (timeoutMs ?? handshakeTimeoutMs)
try {
const username = Buffer.from(endpoint.username ?? '', 'utf8')
const password = Buffer.from(endpoint.password ?? '', 'utf8')
const authenticated = username.byteLength > 0 || endpoint.password !== null
if (username.byteLength > 255 || password.byteLength > 255) {
throw new ProxyTransportError('SOCKS5 credentials are too long', { code: 'SOCKS5_CREDENTIALS_INVALID' })
}
socket.write(authenticated ? Buffer.from([0x05, 0x02, 0x00, 0x02]) : Buffer.from([0x05, 0x01, 0x00]))
const greeting = await reader.read(2, deadline)
if (greeting[0] !== 0x05) throw new ProxyTransportError('SOCKS5 proxy returned an invalid greeting')
if (greeting[1] === 0xff) throw new ProxyTransportError('SOCKS5 proxy has no acceptable authentication method', { code: 'UPSTREAM_PROXY_AUTH_FAILED' })
if (greeting[1] === 0x02) {
if (!authenticated) throw new ProxyTransportError('SOCKS5 proxy requires authentication', { code: 'UPSTREAM_PROXY_AUTH_FAILED' })
socket.write(Buffer.concat([
Buffer.from([0x01, username.byteLength]), username,
Buffer.from([password.byteLength]), password,
]))
const authentication = await reader.read(2, deadline)
if (authentication[0] !== 0x01 || authentication[1] !== 0x00) {
throw new ProxyTransportError('SOCKS5 proxy authentication failed', { code: 'UPSTREAM_PROXY_AUTH_FAILED' })
}
} else if (greeting[1] !== 0x00) {
throw new ProxyTransportError('SOCKS5 proxy selected an unsupported authentication method', { code: 'UPSTREAM_PROXY_AUTH_FAILED' })
}
const address = await socksTarget(targetHost, endpoint.remoteDns, resolver)
const port = Buffer.alloc(2)
port.writeUInt16BE(targetPort)
socket.write(Buffer.concat([Buffer.from([0x05, 0x01, 0x00]), address, port]))
const response = await reader.read(4, deadline)
if (response[0] !== 0x05 || response[2] !== 0x00) throw new ProxyTransportError('SOCKS5 proxy returned an invalid CONNECT response')
if (response[1] !== 0x00) throw socksReplyError(response[1])
if (response[3] === 0x01) await reader.read(6, deadline)
else if (response[3] === 0x04) await reader.read(18, deadline)
else if (response[3] === 0x03) {
const length = (await reader.read(1, deadline))[0]
await reader.read(length + 2, deadline)
} else throw new ProxyTransportError('SOCKS5 proxy returned an invalid address type')
const remainder = reader.release()
return Object.freeze({ socket, remainder })
} catch (error) {
if (reader.pending === null) reader.release()
socket.destroy()
if (error instanceof ProxyTransportError) throw error
throw new ProxyTransportError('SOCKS5 proxy handshake failed', { cause: error })
} finally {
signal?.removeEventListener('abort', cancel)
}
}
@@ -0,0 +1,49 @@
#!/usr/bin/env node
import { LocalApiClient } from '../../modules/updater/lib/client.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 launch = new LocalApiClient(paths.proxyLaunchSocket)
if (process.argv[2] === 'health') {
const status = await launch.request('GET', '/v1/status')
process.exit(status.componentReady === true ? 0 : 1)
}
const token = process.env.DSH_PROXY_LAUNCH_TOKEN
delete process.env.DSH_PROXY_LAUNCH_TOKEN
if (typeof token !== 'string' || token.length < 32) throw new Error('Proxy launch token is unavailable')
await launch.request('POST', '/v1/start', { token })
let stopping = false
let resolveStop
const stopped = new Promise(resolve => { resolveStop = resolve })
const stop = () => {
if (stopping) return
stopping = true
void launch.request('POST', '/v1/stop', { token }).catch(() => {}).finally(resolveStop)
}
process.once('SIGINT', stop)
process.once('SIGTERM', stop)
const poll = setInterval(() => {
void launch.request('GET', '/v1/status').then(status => {
if (!stopping && status.running !== true) {
clearInterval(poll)
process.exitCode = 1
resolveStop()
}
}, () => {
if (!stopping) {
clearInterval(poll)
process.exitCode = 1
resolveStop()
}
})
}, 500)
await stopped
clearInterval(poll)