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