Files

1311 lines
57 KiB
JavaScript

import { createServer, request as httpRequest } from 'node:http'
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { connect as netConnect } from 'node:net'
import { join, resolve } from 'node:path'
import { inspectExternalRequest } from './trust.mjs'
import { injectRandomUuidPolyfill } from './polyfill.mjs'
import { isReservedCookieName, rejectDshAuthentication, stripReservedCookies } from './browser-auth.mjs'
import { availabilityPage, DshAvailability, probeDsh, stateMessage } from './availability.mjs'
export const INTERNAL_HOST = '127.0.0.1'
export const INTERNAL_PORT = 3079
export const INTERNAL_AUTHORITY = `${INTERNAL_HOST}:${String(INTERNAL_PORT)}`
export const HEALTH_PATH = '/_dsh_gateway/health'
export const READINESS_PATH = '/_dsh_gateway/readiness'
export const WAIT_PATH = '/_dsh_gateway/wait'
export const PLUGIN_FAILURE_PATH = '/_dsh_gateway/plugin-failure'
export const CLIENT_EVENT_PATH = '/_dsh_gateway/client-event'
export const MANAGEMENT_PREFIX = '/_dsh_platform/api/v1/'
export const MANAGEMENT_PLUGIN_PREFIX = '/_dsh_platform/plugin-api/v1/'
export const MANAGEMENT_UI_PREFIX = '/_dsh_platform/console/'
const EXTERNAL_MANAGEMENT_ROUTES = new Map([
['GET', new Set(['status', 'events', 'logs', 'logs/stream', 'rollback-plan', 'auth-settings', 'bundled-plugins', 'system-skills', 'user-skills', 'settings-document', 'user-plugins', 'proxy', 'proxy/provider-inventory', 'files/config', 'files/list', 'files/stat', 'files/content', 'files/download', 'files/tasks'])],
['POST', new Set(['check', 'update', 'holds/retry', 'rollback', 'return-stable', 'start-dsh', 'stop-dsh', 'restart-dsh', 'runtime/reset', 'auth-sessions/revoke', 'auth-totp/enrollments', 'auth-totp/enrollments/confirm', 'auth-totp/enrollments/cancel', 'auth-totp/disable-confirmations', 'auth-totp/disable-confirmations/confirm', 'auth-totp/disable-confirmations/cancel', 'management-origin/transitions', 'management-origin/transitions/commit', 'management-origin/transitions/failure', 'bundled-plugins/action', 'bundled-plugins/toggle', 'bundled-plugins/recovery-action', 'bundled-plugins/discard', 'system-skills/action', 'user-skills/action', 'user-plugins/apply', 'proxy/test', 'terminal/sessions', 'files/upload', 'files/tasks'])],
['PUT', new Set(['channel', 'automatic-check', 'auth-settings', 'proxy', 'settings-document', 'files/content'])],
])
const TERMINAL_SESSION_ROUTE = /^terminal\/sessions\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
const TERMINAL_STREAM_ROUTE = /^\/_dsh_platform\/api\/v1\/terminal\/sessions\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\/stream$/
const FILE_TASK_ROUTE = /^files\/tasks\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
const PROXY_TEST_TASK_ROUTE = /^proxy\/test\/tasks\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
const PLUGIN_MANAGEMENT_ROUTES = new Map([
['GET', new Set(['status', 'events', 'logs', 'logs/stream', 'rollback-plan', 'bundled-plugins', 'system-skills', 'settings-document', 'proxy', 'proxy/provider-inventory'])],
['POST', new Set(['check', 'update', 'holds/retry', 'rollback', 'return-stable', 'restart-dsh', 'bundled-plugins/action', 'bundled-plugins/toggle', 'bundled-plugins/recovery-action', 'bundled-plugins/discard', 'system-skills/action', 'proxy/test'])],
['PUT', new Set(['channel', 'automatic-check', 'proxy', 'settings-document'])],
])
function isMaintenanceRoute(pathname) {
if (!pathname.startsWith(MANAGEMENT_PREFIX)) return false
const route = pathname.slice(MANAGEMENT_PREFIX.length)
return route.startsWith('files/') || route === 'terminal/sessions' || TERMINAL_SESSION_ROUTE.test(route)
}
function managementTrustedHosts(access) {
const entry = access.account?.managementAccess?.isolatedEntry
if (access.account?.managementAccess?.mode !== 'isolated' || entry?.kind !== 'public') {
return Object.freeze({ wildcard: false, authorities: Object.freeze([]) })
}
const parsed = new URL(entry.managementPublicOrigin)
return Object.freeze({
wildcard: false,
authorities: Object.freeze([Object.freeze({
anyPort: false,
hostname: parsed.hostname,
matchAuthority: parsed.host,
})]),
})
}
const MAX_HTML_BYTES = 5 * 1024 * 1024
const DEFAULT_PLUGIN_BUNDLE_MAX_BYTES = 16 * 1024 * 1024
const DEFAULT_PLUGIN_BUNDLE_CACHE_MAX_BYTES = 64 * 1024 * 1024
const SYSTEM_PLUGIN_BUNDLE = /^\/plugins\/@dsh-docker\/([a-z0-9][a-z0-9._-]{0,127})\/client\.js$/
const DSH_PLUGIN_BUNDLE = /^\/plugins\/(?:@[^/]+\/)?[^/]+\/client\.js$/
const MODULE_HOLD_STATES = new Set(['starting', 'restarting', 'switching', 'runtime-recovering', 'recovering'])
const MODULE_FAILED_STATES = new Set(['stopping', 'stopped', 'failed'])
const REGISTERED_OPERATIONS = new Set(['restarting', 'switching', 'recovering', 'restart-failed'])
const REGISTERED_UPDATE_STATES = new Set(['snapshotting-data', 'switching', 'probation', 'restoring-data'])
const REGISTERED_LIFECYCLE_STATES = new Set(['starting', 'stopping', 'stopped', 'restarting', 'recovering', 'failed'])
const CLIENT_EVENTS = new Set([
'browser.plugin-load.failed',
'browser.plugin-load.recovery.started',
'browser.plugin-load.recovery.completed',
'browser.plugin-load.recovery.failed',
])
const upgradedSocketsByServer = new WeakMap()
const probeTimersByServer = new WeakMap()
const HOP_BY_HOP_HEADERS = new Set([
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'proxy-connection',
'te',
'trailer',
'transfer-encoding',
'upgrade',
])
const INTERNAL_CAPABILITY_HEADER = 'x-dsh-internal-capability'
const INTERNAL_CAPABILITY_AUDIENCE_HEADER = 'x-dsh-internal-capability-audience'
function excludedHeaderNames(headers) {
const excluded = new Set(HOP_BY_HOP_HEADERS)
if (typeof headers.connection === 'string') {
for (const name of headers.connection.split(',')) excluded.add(name.trim().toLowerCase())
}
return excluded
}
function copyEndToEndHeaders(headers) {
const excluded = excludedHeaderNames(headers)
const copied = {}
for (const [name, value] of Object.entries(headers)) {
if (!excluded.has(name.toLowerCase()) && value !== undefined) copied[name] = value
}
return copied
}
async function clientEvent(request) {
const chunks = []
let bytes = 0
for await (const chunk of request) {
bytes += chunk.byteLength
if (bytes > 8_192) throw new Error('client event exceeds size limit')
chunks.push(chunk)
}
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'))
if (value === null || typeof value !== 'object' || Array.isArray(value) || !CLIENT_EVENTS.has(value.event)) {
throw new Error('client event is invalid')
}
const text = (name, maximum, pattern) => {
const field = value[name]
if (field === null || field === undefined) return null
if (typeof field !== 'string' || field.length > maximum || !pattern.test(field)) throw new Error(`client event ${name} is invalid`)
return field
}
const attempt = Number(value.recoveryAttempt)
if (!Number.isSafeInteger(attempt) || attempt < 0 || attempt > 3) throw new Error('client event recoveryAttempt is invalid')
return {
event: value.event,
level: ['info', 'warning', 'error'].includes(value.level) ? value.level : 'warning',
pluginId: text('pluginId', 256, /^[^\u0000-\u001f\u007f]+$/u),
revision: text('revision', 128, /^[A-Za-z0-9._-]+$/),
pathname: text('pathname', 1_024, /^\/[^\u0000-\u001f\u007f]*$/u),
lifecycleState: text('lifecycleState', 64, /^[a-z-]+$/),
lifecycleTaskId: text('lifecycleTaskId', 128, /^[A-Za-z0-9._:-]+$/),
recoveryAttempt: attempt,
reason: text('reason', 128, /^[^\u0000-\u001f\u007f]+$/u),
}
}
export function upstreamRequestHeaders(headers, { dsh = true } = {}) {
const excluded = excludedHeaderNames(headers)
const rewritten = copyEndToEndHeaders(headers)
if (dsh) {
for (const name of Object.keys(rewritten)) {
const normalized = name.toLowerCase()
if (normalized === 'forwarded' || normalized === 'x-real-ip' || normalized.startsWith('x-forwarded-')) {
delete rewritten[name]
}
}
}
rewritten.host = INTERNAL_AUTHORITY
rewritten['accept-encoding'] = 'identity'
if (!excluded.has('origin') && typeof headers.origin === 'string') {
rewritten.origin = `http://${INTERNAL_AUTHORITY}`
}
delete rewritten.authorization
delete rewritten[INTERNAL_CAPABILITY_HEADER]
delete rewritten[INTERNAL_CAPABILITY_AUDIENCE_HEADER]
const cookies = stripReservedCookies(rewritten.cookie)
if (cookies === undefined) delete rewritten.cookie
else rewritten.cookie = cookies
return rewritten
}
export function applyDshUpstreamCookie(headers, cookie) {
if (typeof cookie !== 'string' || cookie === '') return headers
const current = typeof headers.cookie === 'string'
? headers.cookie.split(';').map(value => value.trim()).filter(value => {
const separator = value.indexOf('=')
return separator > 0 && !value.slice(0, separator).startsWith('dsh-auth-')
})
: []
headers.cookie = [...current, cookie].join('; ')
return headers
}
function rejectHttp(response, status, message) {
if (response.headersSent) {
response.destroy()
return
}
response.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' })
response.end(`${message}\n`)
}
function sendJson(response, status, value) {
const body = Buffer.from(`${JSON.stringify(value)}\n`)
response.writeHead(status, {
'cache-control': 'no-store',
'content-length': String(body.byteLength),
'content-type': 'application/json; charset=utf-8',
})
response.end(body)
}
function isPageNavigation(request) {
const pathname = new URL(request.url ?? '/', 'http://gateway.internal').pathname
return request.method === 'GET'
&& !pathname.startsWith('/api/')
&& typeof request.headers.accept === 'string'
&& request.headers.accept.toLowerCase().includes('text/html')
}
function sendAvailabilityPage(request, response, state, options = {}) {
const body = Buffer.from(availabilityPage(state, request.headers, options))
response.writeHead(200, {
'cache-control': 'no-store',
'content-length': String(body.byteLength),
'content-security-policy': "default-src 'none'; connect-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'",
'content-type': 'text/html; charset=utf-8',
'referrer-policy': 'no-referrer',
})
response.end(body)
}
function managementAvailabilityHref(access) {
const entry = access?.account?.managementAccess?.isolatedEntry
if (access?.account?.managementAccess?.mode !== 'isolated') return '/_dsh_platform/console/'
const origin = entry?.kind === 'public' ? entry.managementPublicOrigin : entry?.managementLocalOrigin
return typeof origin === 'string' ? `${origin}/` : '/_dsh_platform/console/'
}
async function currentManagementAvailabilityHref(options) {
try {
return managementAvailabilityHref(await options.browserAuthentication.status())
} catch {
return '/_dsh_platform/console/'
}
}
export function safeReturnPath(value) {
if (typeof value !== 'string' || value.length === 0 || value.length > 4_096
|| !value.startsWith('/') || value.startsWith('//') || value.includes('\\')
|| /[\u0000-\u001f\u007f]/u.test(value)) return '/'
try {
const parsed = new URL(value, 'http://gateway.internal')
return parsed.origin === 'http://gateway.internal'
? `${parsed.pathname}${parsed.search}${parsed.hash}`
: '/'
} catch {
return '/'
}
}
async function serveSystemPluginBundle(request, response, root, pathname, searchParams) {
if (!['GET', 'HEAD'].includes(request.method ?? 'GET')) return false
const match = SYSTEM_PLUGIN_BUNDLE.exec(pathname)
if (match === null) return false
const packageRoot = join(resolve(root), match[1])
try {
const metadata = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8'))
if (metadata.name !== `@dsh-docker/${match[1]}`) return false
const client = metadata.exports?.['./client']
const relative = typeof client === 'string' ? client : client?.default
if (typeof relative !== 'string' || relative.startsWith('/') || relative.includes('\\') || relative.split('/').includes('..')) return false
const body = await readFile(join(packageRoot, relative))
const requestedRev = searchParams.get('rev')
const actualRev = createHash('sha1').update(body).digest('hex').slice(0, 12)
if (requestedRev !== null && requestedRev !== actualRev) return false
response.writeHead(200, {
'cache-control': 'no-cache',
'content-length': String(body.byteLength),
'content-type': 'text/javascript; charset=utf-8',
etag: `"${actualRev}"`,
})
response.end(request.method === 'HEAD' ? undefined : body)
return true
} catch (error) {
if (error?.code === 'ENOENT') return false
throw error
}
}
function rejectUpgrade(socket, status, reason, headers = {}) {
if (!socket.destroyed) {
const lines = [`HTTP/1.1 ${String(status)} ${reason}`, 'Connection: close', 'Content-Length: 0']
for (const [name, value] of Object.entries(headers)) lines.push(`${name}: ${value}`)
socket.end(`${lines.join('\r\n')}\r\n\r\n`)
}
}
function proxyResponseHeaders(headers) {
const copied = copyEndToEndHeaders(headers)
const cookies = copied['set-cookie']
if (cookies !== undefined) {
const values = Array.isArray(cookies) ? cookies : [cookies]
const kept = values.filter(value => {
const separator = value.indexOf('=')
return separator > 0 && !isReservedCookieName(value.slice(0, separator).trim())
})
if (kept.length === 0) delete copied['set-cookie']
else copied['set-cookie'] = kept
}
return copied
}
function createPluginBundleCache(maxBytes) {
const entries = new Map()
let bytes = 0
return {
get(key) {
const value = entries.get(key)
if (value === undefined) return undefined
entries.delete(key)
entries.set(key, value)
return value
},
set(key, value) {
if (value.body.byteLength > maxBytes) return
const previous = entries.get(key)
if (previous !== undefined) bytes -= previous.body.byteLength
entries.delete(key)
entries.set(key, value)
bytes += value.body.byteLength
while (bytes > maxBytes) {
const oldest = entries.entries().next().value
if (oldest === undefined) break
entries.delete(oldest[0])
bytes -= oldest[1].body.byteLength
}
},
}
}
function deploymentRecordId(platform) {
return typeof platform.current?.recordId === 'string' && platform.current.recordId.length > 0
? platform.current.recordId
: null
}
function recentLifecycleTransition(platform, now, windowMs) {
const lifecycle = platform.dshLifecycle ?? {}
const updatedAt = Date.parse(lifecycle.updatedAt ?? '')
return typeof lifecycle.taskId === 'string' && lifecycle.taskId.length > 0
&& Number.isFinite(updatedAt) && now() - updatedAt >= 0 && now() - updatedAt < windowMs
}
function bufferedPluginHeaders(headers, body) {
const result = proxyResponseHeaders(headers)
result['content-length'] = String(body.byteLength)
delete result['transfer-encoding']
return result
}
function sendBufferedPluginBundle(request, response, value) {
response.writeHead(value.statusCode, value.statusMessage, bufferedPluginHeaders(value.headers, value.body))
response.end(request.method === 'HEAD' ? undefined : value.body)
}
function pluginCacheValue(value) {
const headers = {}
for (const name of ['cache-control', 'content-language', 'content-type', 'etag', 'last-modified']) {
if (value.headers[name] !== undefined) headers[name] = value.headers[name]
}
return Object.freeze({ statusCode: 200, statusMessage: value.statusMessage, headers, body: value.body })
}
function isJavaScriptBundle(value) {
const contentType = value.headers['content-type']
return value.statusCode === 200 && typeof contentType === 'string'
&& /(?:java|ecma)script/i.test(contentType)
}
async function fetchPluginBundle(request, options) {
const headers = upstreamRequestHeaders(request.headers)
applyDshUpstreamCookie(headers, await options.dshUpstreamAuthentication.cookie())
return new Promise((resolvePromise, rejectPromise) => {
const upstream = httpRequest({
hostname: options.upstreamHost,
port: options.upstreamPort,
method: request.method,
path: request.url,
headers,
})
upstream.once('response', (upstreamResponse) => {
void (async () => {
const chunks = []
let bytes = 0
for await (const chunk of upstreamResponse) {
bytes += chunk.byteLength
if (bytes > options.pluginBundleMaxBytes) {
throw new Error('upstream plugin bundle exceeds gateway limit')
}
chunks.push(chunk)
}
resolvePromise({
statusCode: upstreamResponse.statusCode ?? 502,
statusMessage: upstreamResponse.statusMessage,
headers: upstreamResponse.headers,
body: Buffer.concat(chunks),
})
})().catch(rejectPromise)
})
upstream.once('error', rejectPromise)
upstream.end()
})
}
function pluginReturnPath(request) {
if (typeof request.headers.referer !== 'string') return '/'
try {
const value = new URL(request.headers.referer)
return safeReturnPath(`${value.pathname}${value.search}${value.hash}`)
} catch {
return '/'
}
}
function sendPluginHoldingModule(request, response) {
const returnPath = pluginReturnPath(request)
const waitPath = `${WAIT_PATH}?return=${encodeURIComponent(returnPath)}`
const body = Buffer.from(`globalThis.location.replace(${JSON.stringify(waitPath)});\nawait new Promise(function(){});\n`)
response.writeHead(200, {
'cache-control': 'no-store',
'content-length': String(body.byteLength),
'content-type': 'text/javascript; charset=utf-8',
})
response.end(request.method === 'HEAD' ? undefined : body)
}
function isInjectableHtml(request, response) {
if (request.method === 'HEAD') return false
const contentType = response.headers['content-type']
const encoding = response.headers['content-encoding']
return typeof contentType === 'string'
&& contentType.toLowerCase().includes('text/html')
&& encoding === undefined
}
async function writeInjectedHtml(upstream, response) {
const chunks = []
let bytes = 0
for await (const chunk of upstream) {
bytes += chunk.byteLength
if (bytes > MAX_HTML_BYTES) throw new Error('upstream HTML exceeds gateway injection limit')
chunks.push(chunk)
}
const body = Buffer.from(injectRandomUuidPolyfill(Buffer.concat(chunks).toString('utf8')))
const headers = proxyResponseHeaders(upstream.headers)
delete headers.etag
delete headers['last-modified']
headers['cache-control'] = 'no-cache'
headers['content-length'] = String(body.byteLength)
response.writeHead(upstream.statusCode ?? 502, upstream.statusMessage, headers)
response.end(body)
}
function pipeHttpResponse(upstream, response, onError = () => {}) {
response.writeHead(
upstream.statusCode ?? 502,
upstream.statusMessage,
proxyResponseHeaders(upstream.headers),
)
upstream.on('error', error => {
if (!response.destroyed) onError(error)
response.destroy()
})
upstream.pipe(response)
}
function requestContext(request) {
let pathname = 'invalid-url'
try { pathname = new URL(request.url ?? '/', 'http://gateway.internal').pathname } catch {}
return { method: request.method ?? null, pathname }
}
async function boundedPlatformStatus(platformStatus, options, timeoutMs = 250) {
let timer
try {
return await Promise.race([
Promise.resolve().then(() => platformStatus()).then(value => {
options.reportRecovered('platform-status', 'gateway.platform-status.recovered', { upstream: 'management' })
return value
}, error => {
options.reportFailure('platform-status', 'gateway.platform-status.failed', {
error, level: 'warning', upstream: 'management',
})
return {}
}),
new Promise(resolve => {
timer = setTimeout(() => {
options.reportFailure('platform-status', 'gateway.platform-status.timed-out', {
timeoutMs, level: 'warning', upstream: 'management',
})
resolve({})
}, timeoutMs)
timer.unref()
}),
])
} finally {
clearTimeout(timer)
}
}
async function unavailableState(options) {
const platform = await boundedPlatformStatus(options.platformStatus, options)
return options.availability.classify(platform)
}
async function publicationReady(options) {
try {
const ready = await options.dshUpstreamAuthentication.publicReady()
if (ready) options.reportRecovered('dsh-publication', 'gateway.dsh-publication.recovered', { upstream: 'bootstrap' })
return ready
} catch (error) {
options.reportFailure('dsh-publication', 'gateway.dsh-publication.failed', {
error, level: 'warning', upstream: 'bootstrap',
})
return false
}
}
function registeredAvailabilityState(platform, availability) {
if (!REGISTERED_OPERATIONS.has(platform.operation)
&& !REGISTERED_UPDATE_STATES.has(platform.update?.status)
&& !REGISTERED_LIFECYCLE_STATES.has(platform.dshLifecycle?.state)
&& (platform.recoveryMode === null || platform.recoveryMode === undefined)) return null
return availability.classify(platform)
}
async function readiness(options, published = undefined) {
const publicReady = published ?? await publicationReady(options)
const upstreamReady = await options.probe()
options.availability.observe(upstreamReady)
const platform = await boundedPlatformStatus(options.platformStatus, options)
const registeredState = registeredAvailabilityState(platform, options.availability)
return {
ready: publicReady && upstreamReady && registeredState === null,
state: registeredState ?? (publicReady ? options.availability.classify(platform) : 'starting'),
platform,
pluginRecoveryEligible: options.pluginRecoveryAvailable?.(platform) === true,
}
}
async function holdPluginBundleDuringTransition(request, response, options, pathname) {
if (!['GET', 'HEAD'].includes(request.method ?? 'GET')
|| !DSH_PLUGIN_BUNDLE.test(pathname)) return false
let platform = await boundedPlatformStatus(options.platformStatus, options)
let state = registeredAvailabilityState(platform, options.availability)
if (!MODULE_HOLD_STATES.has(state) && !MODULE_FAILED_STATES.has(state)) return false
if (MODULE_FAILED_STATES.has(state)) {
rejectHttp(response, 503, stateMessage(state, request.headers, platform.dshLifecycle))
return true
}
const deadline = Date.now() + options.pluginBundleHoldTimeoutMs
while (!request.destroyed && !response.destroyed && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, options.pluginBundlePollIntervalMs))
const ready = await options.probe()
options.availability.observe(ready)
platform = await boundedPlatformStatus(options.platformStatus, options)
state = registeredAvailabilityState(platform, options.availability)
if (ready && !MODULE_HOLD_STATES.has(state) && !MODULE_FAILED_STATES.has(state)) return false
if (MODULE_FAILED_STATES.has(state)) {
rejectHttp(response, 503, stateMessage(state, request.headers, platform.dshLifecycle))
return true
}
}
if (!request.destroyed && !response.destroyed) rejectHttp(response, 503, 'DeepSeek Harness restart timed out')
return true
}
async function proxyPluginBundle(request, response, options, pathname) {
if (!['GET', 'HEAD'].includes(request.method ?? 'GET') || !DSH_PLUGIN_BUNDLE.test(pathname)) return false
const deadline = Date.now() + options.pluginBundleHoldTimeoutMs
let attempts = 0
let lastResponse
let lastError
let recoveryEligible = false
while (!request.destroyed && !response.destroyed && Date.now() < deadline) {
attempts += 1
const before = await boundedPlatformStatus(options.platformStatus, options)
const beforeRecord = deploymentRecordId(before)
const cacheKey = beforeRecord === null ? null : `${beforeRecord}\u0000${request.url}`
const cached = cacheKey === null ? undefined : options.pluginBundleCache.get(cacheKey)
try {
const value = await fetchPluginBundle(request, options)
lastResponse = value
lastError = undefined
const ready = await options.probe()
options.availability.observe(ready)
const after = await boundedPlatformStatus(options.platformStatus, options)
const state = registeredAvailabilityState(after, options.availability)
const sameDeployment = beforeRecord === deploymentRecordId(after)
recoveryEligible = recoveryEligible || MODULE_HOLD_STATES.has(state)
|| recentLifecycleTransition(after, options.now, options.pluginBundleRecentWindowMs)
|| options.pluginRecoveryAvailable?.(after) === true
if (ready && sameDeployment && !MODULE_HOLD_STATES.has(state) && !MODULE_FAILED_STATES.has(state)) {
if (isJavaScriptBundle(value)) {
if (cacheKey !== null && request.method === 'GET') options.pluginBundleCache.set(cacheKey, pluginCacheValue(value))
sendBufferedPluginBundle(request, response, value)
return true
}
if (cached !== undefined && value.statusCode >= 400) {
sendBufferedPluginBundle(request, response, cached)
return true
}
if (!recoveryEligible || (value.statusCode < 500 && value.statusCode !== 404)) {
sendBufferedPluginBundle(request, response, value)
return true
}
}
} catch (error) {
lastError = error
if (cached !== undefined) {
sendBufferedPluginBundle(request, response, cached)
return true
}
const after = await boundedPlatformStatus(options.platformStatus, options)
const state = registeredAvailabilityState(after, options.availability)
recoveryEligible = recoveryEligible || MODULE_HOLD_STATES.has(state)
|| recentLifecycleTransition(after, options.now, options.pluginBundleRecentWindowMs)
|| options.pluginRecoveryAvailable?.(after) === true
options.reportFailure('dsh-plugin-bundle', 'gateway.plugin-bundle.failed', {
...requestContext(request), error, level: 'warning', attempt: attempts,
})
}
if (!recoveryEligible && attempts >= 2) break
await new Promise(resolvePromise => setTimeout(resolvePromise, options.pluginBundlePollIntervalMs))
}
if (request.destroyed || response.destroyed) return true
if (recoveryEligible) {
options.consumePluginRecovery?.()
sendPluginHoldingModule(request, response)
} else if (lastResponse !== undefined) {
sendBufferedPluginBundle(request, response, lastResponse)
} else {
rejectHttp(response, 502, lastError?.message ?? 'bad gateway')
}
return true
}
async function rejectDshFailure(request, response, options) {
options.availability.observe(false)
const platform = await boundedPlatformStatus(options.platformStatus, options)
const state = options.availability.classify(platform)
if (state === 'unknown') {
rejectHttp(response, 502, 'bad gateway')
} else if (isPageNavigation(request)) {
sendAvailabilityPage(request, response, state, {
lifecycle: platform.dshLifecycle,
managementHref: await currentManagementAvailabilityHref(options),
})
} else {
rejectHttp(response, 503, stateMessage(state, request.headers))
}
return state
}
async function proxyHttp(request, response, options) {
const upstreamType = options.socketPath === undefined ? 'dsh' : 'management'
const context = requestContext(request)
const headers = upstreamRequestHeaders(request.headers, { dsh: upstreamType === 'dsh' })
if (upstreamType === 'dsh') {
applyDshUpstreamCookie(headers, await options.dshUpstreamAuthentication.cookie())
}
if (typeof options.internalCapability === 'string') delete headers.cookie
if (options.preserveAuthorization === true && typeof request.headers.authorization === 'string') {
headers.authorization = request.headers.authorization
}
if (typeof options.internalCapability === 'string') headers[INTERNAL_CAPABILITY_HEADER] = options.internalCapability
if (typeof options.internalCapabilityAudience === 'string') {
headers[INTERNAL_CAPABILITY_AUDIENCE_HEADER] = options.internalCapabilityAudience
}
const upstream = httpRequest({
...(options.socketPath === undefined
? { hostname: options.upstreamHost, port: options.upstreamPort }
: { socketPath: options.socketPath }),
method: request.method,
path: options.upstreamPath ?? request.url,
headers,
})
upstream.on('response', (upstreamResponse) => {
options.reportRecovered(`${upstreamType}-http`, 'gateway.upstream.recovered', { upstream: upstreamType })
if (options.trackDsh === true) options.availability.observe(true)
if (options.polyfill && isInjectableHtml(request, upstreamResponse)) {
void writeInjectedHtml(upstreamResponse, response).catch(error => {
options.reportFailure('dsh-injection', 'gateway.response-injection.failed', {
...context,
error,
upstream: 'dsh',
})
upstreamResponse.destroy()
rejectHttp(response, 502, 'bad gateway')
})
return
}
pipeHttpResponse(upstreamResponse, response, error => options.reportFailure(`${upstreamType}-response`, 'gateway.upstream-response.failed', {
...context,
error,
level: 'warning',
upstream: upstreamType,
}))
})
upstream.on('error', error => {
if (request.aborted || response.destroyed) return
const reportUpstreamFailure = () => options.reportFailure(`${upstreamType}-http`, 'gateway.upstream.failed', {
...context, error, upstream: upstreamType,
})
if (options.trackDsh === true) {
void rejectDshFailure(request, response, options).then(state => {
if (state === 'unknown') reportUpstreamFailure()
})
} else {
reportUpstreamFailure()
rejectHttp(response, 502, 'bad gateway')
}
})
request.on('aborted', () => upstream.destroy())
response.on('close', () => upstream.destroy())
request.pipe(upstream)
}
function isExternalManagementRoute(method, pathname) {
if (!pathname.startsWith(MANAGEMENT_PREFIX)) return false
const route = pathname.slice(MANAGEMENT_PREFIX.length)
if (method === 'GET' && /^bundled-plugins\/task\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(route)) return true
if (method === 'GET' && /^user-plugins\/task\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(route)) return true
if (['GET', 'DELETE'].includes(method ?? 'GET') && PROXY_TEST_TASK_ROUTE.test(route)) return true
if (['GET', 'DELETE'].includes(method ?? 'GET') && TERMINAL_SESSION_ROUTE.test(route)) return true
if (['GET', 'DELETE'].includes(method ?? 'GET') && FILE_TASK_ROUTE.test(route)) return true
return EXTERNAL_MANAGEMENT_ROUTES.get(method ?? 'GET')?.has(route) ?? false
}
function pluginManagementUpstreamPath(method, url) {
if (!url.pathname.startsWith(MANAGEMENT_PLUGIN_PREFIX)) return undefined
const route = url.pathname.slice(MANAGEMENT_PLUGIN_PREFIX.length)
if (method === 'GET' && /^bundled-plugins\/task\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(route)) {
return `${MANAGEMENT_PREFIX}${route}${url.search}`
}
if (['GET', 'DELETE'].includes(method ?? 'GET') && PROXY_TEST_TASK_ROUTE.test(route)) {
return `${MANAGEMENT_PREFIX}${route}${url.search}`
}
if (!(PLUGIN_MANAGEMENT_ROUTES.get(method ?? 'GET')?.has(route) ?? false)) return null
return `${MANAGEMENT_PREFIX}${route}${url.search}`
}
function isExternalConsoleRoute(method, pathname) {
return ['GET', 'HEAD'].includes(method ?? 'GET')
&& (pathname === MANAGEMENT_UI_PREFIX.slice(0, -1) || pathname.startsWith(MANAGEMENT_UI_PREFIX))
}
function managementSurfaceUpstream(url) {
const pathname = url.pathname
if (pathname === '/') return `${MANAGEMENT_UI_PREFIX}${url.search}`
if (pathname === '/app.js' || pathname === '/style.css' || pathname === '/theme-init.js'
|| pathname.startsWith('/vendor/')) {
return `${MANAGEMENT_UI_PREFIX}${pathname.slice(1)}${url.search}`
}
if (pathname === '/api/v1' || pathname.startsWith('/api/v1/')) {
return `${MANAGEMENT_PREFIX}${pathname.slice('/api/v1/'.length)}${url.search}`
}
return null
}
function isCompatibilityManagementPath(pathname) {
return pathname === MANAGEMENT_UI_PREFIX.slice(0, -1)
|| pathname.startsWith(MANAGEMENT_UI_PREFIX)
|| pathname.startsWith(MANAGEMENT_PREFIX)
}
function serializeUpgradeRequest(request, headers, target = request.url ?? '/') {
const lines = [`${request.method ?? 'GET'} ${target} HTTP/${request.httpVersion}`]
for (const [name, value] of Object.entries(headers)) {
if (Array.isArray(value)) {
for (const item of value) lines.push(`${name}: ${item}`)
} else if (value !== undefined) {
lines.push(`${name}: ${value}`)
}
}
return `${lines.join('\r\n')}\r\n\r\n`
}
async function proxyUpgrade(request, clientSocket, head, options) {
const context = requestContext(request)
const upstreamType = options.socketPath === undefined ? 'dsh' : 'management'
const failureKey = `${upstreamType}-websocket`
const headers = upstreamRequestHeaders(request.headers, { dsh: upstreamType === 'dsh' })
if (upstreamType === 'dsh') {
applyDshUpstreamCookie(headers, await options.dshUpstreamAuthentication.cookie())
}
if (typeof options.internalCapability === 'string') delete headers.cookie
if (options.preserveAuthorization === true && typeof request.headers.authorization === 'string') {
headers.authorization = request.headers.authorization
}
if (typeof options.internalCapability === 'string') headers[INTERNAL_CAPABILITY_HEADER] = options.internalCapability
headers.connection = 'Upgrade'
headers.upgrade = request.headers.upgrade ?? 'websocket'
const upstreamSocket = options.socketPath === undefined
? netConnect(options.upstreamPort, options.upstreamHost)
: netConnect(options.socketPath)
let connected = false
upstreamSocket.once('connect', () => {
connected = true
options.reportRecovered(failureKey, 'gateway.websocket.recovered', { upstream: upstreamType })
if (upstreamType === 'dsh') options.availability.observe(true)
upstreamSocket.write(serializeUpgradeRequest(request, headers, options.upstreamPath))
if (head.length > 0) upstreamSocket.write(head)
clientSocket.pipe(upstreamSocket).pipe(clientSocket)
})
upstreamSocket.once('error', error => {
if (connected) {
options.reportFailure(failureKey, 'gateway.websocket.disconnected', {
...context, error, level: 'warning', upstream: upstreamType,
})
clientSocket.destroy()
} else if (upstreamType === 'dsh') {
options.availability.observe(false)
void unavailableState(options).then(state => {
options.reportFailure(failureKey, 'gateway.websocket.failed', {
...context, error, level: state === 'unknown' ? 'error' : 'warning', upstream: upstreamType,
})
if (state === 'unknown') rejectUpgrade(clientSocket, 502, 'Bad Gateway')
else rejectUpgrade(clientSocket, 503, 'Service Unavailable')
})
} else {
options.reportFailure(failureKey, 'gateway.websocket.failed', {
...context, error, level: 'error', upstream: upstreamType,
})
rejectUpgrade(clientSocket, 502, 'Bad Gateway')
}
})
clientSocket.once('error', () => upstreamSocket.destroy())
clientSocket.once('close', () => upstreamSocket.destroy())
}
export function createGatewayServer({
trustedHosts,
polyfill = true,
upstreamHost = INTERNAL_HOST,
upstreamPort = INTERNAL_PORT,
managementSocketPath = '/run/dsh-platform/management.sock',
maintenanceSocketPath = '/run/dsh-platform/maintenance.sock',
systemPluginRoot = '/run/dsh-platform/deployment/system-plugins/packages',
platformStatus = async () => ({}),
availability = new DshAvailability(),
probe = () => probeDsh({ host: upstreamHost, port: upstreamPort }),
probeIntervalMs = 750,
isReady = () => true,
browserAuthentication = Object.freeze({
status: async () => ({ state: 'recovery-required' }),
validateDsh: async () => ({ authenticated: false }),
validateManagement: async () => ({ authenticated: false }),
authorizeManagement: async () => ({ authorized: false }),
authorizePlugin: async () => ({ authorized: false }),
enterManagement: async (request, response) => rejectDshAuthentication(request, response, safeReturnPath),
handle: async () => false,
}),
dshUpstreamAuthentication = Object.freeze({ cookie: async () => null, publicReady: async () => true }),
report = async () => {},
now = () => Date.now(),
failureLogIntervalMs = 30_000,
pluginBundleHoldTimeoutMs = 60_000,
pluginBundlePollIntervalMs = 100,
pluginBundleMaxBytes = DEFAULT_PLUGIN_BUNDLE_MAX_BYTES,
pluginBundleCacheMaxBytes = DEFAULT_PLUGIN_BUNDLE_CACHE_MAX_BYTES,
pluginBundleRecentWindowMs = 30_000,
surface = 'compat',
}) {
const failures = new Map()
// Permit two reloads for each running Deployment. A third structured browser
// failure is terminal and moves the page to the dedicated diagnostics view.
let pluginRecoveryIdentity
let consumedPluginRecoveries = 0
const currentPluginRecoveryIdentity = platform => {
const lifecycle = platform?.dshLifecycle ?? {}
const identity = deploymentRecordId(platform) ?? lifecycle.updatedAt
if (typeof identity !== 'string' || identity.length === 0) return null
if (pluginRecoveryIdentity !== identity) consumedPluginRecoveries = 0
pluginRecoveryIdentity = identity
return identity
}
const pluginRecoveryAvailable = platform => {
if ((platform?.dshLifecycle?.state ?? null) !== 'running' || platform?.recoveryMode != null) return false
const identity = currentPluginRecoveryIdentity(platform)
return identity !== null && consumedPluginRecoveries < 2
}
const consumePluginRecovery = () => {
if (pluginRecoveryIdentity !== undefined && consumedPluginRecoveries < 2) consumedPluginRecoveries += 1
}
const record = (message, fields) => Promise.resolve().then(() => report(message, fields)).catch(() => {})
const reportFailure = (key, message, fields) => {
const timestamp = now()
const previous = failures.get(key)
if (previous !== undefined && timestamp - previous.lastReportedAt < failureLogIntervalMs) {
previous.suppressedCount += 1
return
}
const suppressedCount = previous?.suppressedCount ?? 0
failures.set(key, { firstFailureAt: previous?.firstFailureAt ?? timestamp, lastReportedAt: timestamp, suppressedCount: 0 })
void record(message, { ...fields, ...(suppressedCount === 0 ? {} : { suppressedCount }) })
}
const reportRecovered = (key, message, fields) => {
const failure = failures.get(key)
if (failure === undefined) return
failures.delete(key)
void record(message, {
...fields,
outageMs: Math.max(0, now() - failure.firstFailureAt),
suppressedCount: failure.suppressedCount,
})
}
const options = {
trustedHosts, polyfill, upstreamHost, upstreamPort, managementSocketPath, maintenanceSocketPath, systemPluginRoot, platformStatus,
availability, probe, isReady, browserAuthentication, dshUpstreamAuthentication, reportFailure, reportRecovered,
now, pluginBundleHoldTimeoutMs, pluginBundlePollIntervalMs, pluginBundleMaxBytes,
pluginBundleRecentWindowMs, pluginBundleCache: createPluginBundleCache(pluginBundleCacheMaxBytes),
pluginRecoveryAvailable, consumePluginRecovery,
}
const upgradedSockets = new Set()
const server = createServer((request, response) => {
void handleRequest(request, response)
})
async function handleRequest(request, response) {
try {
const url = new URL(request.url ?? '/', 'http://gateway.internal')
const pathname = url.pathname
const transitionProbe = surface === 'management'
&& request.method === 'GET' && pathname === '/transition/probe'
const topLevelNavigation = request.method === 'GET'
&& (request.headers['sec-fetch-mode'] === undefined
|| (request.headers['sec-fetch-mode'] === 'navigate' && request.headers['sec-fetch-dest'] === 'document'))
const transitionContinuation = topLevelNavigation
&& pathname === (surface === 'management' ? '/transition/continue' : '/_dsh_platform/transition/continue')
const isolatedAuthenticationEntry = surface === 'management' && topLevelNavigation
&& ['/auth/management', '/auth/management/handoff'].includes(pathname)
const dshAuthenticationEntry = surface === 'compat' && topLevelNavigation
&& ['/_dsh_platform/auth', '/_dsh_platform/auth/'].includes(pathname)
const access = surface === 'management'
? await options.browserAuthentication.status()
: null
const isolatedRootNavigation = topLevelNavigation && pathname === '/'
&& (surface === 'compat' || access?.account?.managementAccess?.mode === 'isolated')
const trust = inspectExternalRequest(request.headers,
surface === 'management' ? managementTrustedHosts(access) : options.trustedHosts, {
allowCrossOrigin: transitionProbe || transitionContinuation || isolatedAuthenticationEntry || dshAuthenticationEntry || isolatedRootNavigation,
allowUntrustedAuthority: transitionProbe,
})
if (!trust.accepted) {
reportFailure(`gateway-http-trust-${trust.reason}`, 'gateway.request.rejected', {
...requestContext(request),
reason: trust.reason,
level: 'warning',
})
rejectHttp(response, 403, 'forbidden')
return
}
if (pathname === HEALTH_PATH) {
if (options.isReady()) {
response.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' })
response.end('ok\n')
} else {
rejectHttp(response, 503, 'unavailable')
}
return
}
const currentAccess = access ?? await options.browserAuthentication.status()
const managementMode = currentAccess.account?.managementAccess?.mode ?? 'compat'
if ((surface === 'management' && managementMode !== 'isolated' && !transitionProbe)
|| (surface === 'compat' && managementMode === 'isolated'
&& isCompatibilityManagementPath(pathname))) {
rejectHttp(response, 404, 'not found')
return
}
if (await options.browserAuthentication.handle(request, response, pathname, url.searchParams)) return
const authPrefix = surface === 'management' ? '/auth/' : '/_dsh_platform/auth/'
const accessPrefix = surface === 'management' ? '/access/' : '/_dsh_platform/access/'
if (pathname.startsWith(authPrefix) || pathname.startsWith(accessPrefix)) {
rejectHttp(response, 404, 'not found')
return
}
if (currentAccess.state !== 'initialized') {
rejectDshAuthentication(request, response, safeReturnPath)
return
}
const requireDsh = async () => {
const published = await publicationReady(options)
if (!published) {
const result = await readiness(options, false)
if (isPageNavigation(request)) {
sendAvailabilityPage(request, response, result.state, {
lifecycle: result.platform.dshLifecycle,
returnPath: safeReturnPath(url.searchParams.get('return')),
managementHref: managementAvailabilityHref(currentAccess),
})
} else rejectHttp(response, 503, stateMessage(result.state, request.headers, result.platform.dshLifecycle))
return false
}
const session = await options.browserAuthentication.validateDsh(request)
if (session.authenticated) return true
const result = await readiness(options)
if (!result.ready && result.state !== 'unknown') {
if (isPageNavigation(request)) {
sendAvailabilityPage(request, response, result.state, {
lifecycle: result.platform.dshLifecycle,
returnPath: safeReturnPath(url.searchParams.get('return')),
managementHref: managementAvailabilityHref(currentAccess),
})
} else rejectHttp(response, 503, stateMessage(result.state, request.headers, result.platform.dshLifecycle))
return false
}
rejectDshAuthentication(request, response, safeReturnPath)
return false
}
const requireManagement = async (audience, target = request.url ?? pathname) => {
const mutation = !['GET', 'HEAD', 'OPTIONS'].includes(request.method ?? 'GET')
const authorization = typeof options.browserAuthentication.authorizeManagement === 'function'
? await options.browserAuthentication.authorizeManagement(request, {
audience,
method: request.method ?? 'GET',
target,
requireCsrf: mutation,
})
: { authorized: (await options.browserAuthentication.validateManagement(request)).authenticated === true }
if (authorization.authorized) {
if (Array.isArray(authorization.headers?.['set-cookie'])) {
response.setHeader('set-cookie', authorization.headers['set-cookie'])
}
return authorization.capability?.token ?? null
}
if (isPageNavigation(request)) await options.browserAuthentication.enterManagement(request, response)
else sendJson(response, 401, { error: 'management authentication required', code: 'MANAGEMENT_AUTHENTICATION_REQUIRED' })
return undefined
}
if (surface === 'management') {
const upstreamPath = managementSurfaceUpstream(url)
if (upstreamPath === null) {
rejectHttp(response, 404, 'not found')
return
}
const upstreamUrl = new URL(upstreamPath, 'http://gateway.internal')
if (upstreamUrl.pathname.startsWith(MANAGEMENT_UI_PREFIX)) {
if (!isExternalConsoleRoute(request.method, upstreamUrl.pathname)) {
rejectHttp(response, 404, 'not found')
return
}
const capability = await requireManagement('management', upstreamPath)
if (capability === undefined) return
await proxyHttp(request, response, {
...options, socketPath: options.managementSocketPath, polyfill: false, upstreamPath,
...(typeof capability === 'string' ? { internalCapability: capability } : {}),
})
return
}
if (!isExternalManagementRoute(request.method, upstreamUrl.pathname)) {
rejectHttp(response, 404, 'not found')
return
}
const socketPath = isMaintenanceRoute(upstreamUrl.pathname) ? options.maintenanceSocketPath : options.managementSocketPath
const audience = socketPath === options.maintenanceSocketPath ? 'maintenance' : 'management'
const capability = await requireManagement(audience, upstreamPath)
if (capability === undefined) return
await proxyHttp(request, response, {
...options, socketPath, polyfill: false, upstreamPath,
...(typeof capability === 'string' ? { internalCapability: capability } : {}),
})
return
}
if (pathname === MANAGEMENT_UI_PREFIX.slice(0, -1) || pathname.startsWith(MANAGEMENT_UI_PREFIX)) {
if (!isExternalConsoleRoute(request.method, pathname)) {
rejectHttp(response, 404, 'not found')
return
}
const capability = await requireManagement('management')
if (capability === undefined) return
await proxyHttp(request, response, {
...options, socketPath: options.managementSocketPath, polyfill: false,
...(typeof capability === 'string' ? { internalCapability: capability } : {}),
})
return
}
if (pathname.startsWith(MANAGEMENT_PLUGIN_PREFIX)) {
const mutation = !['GET', 'HEAD', 'OPTIONS'].includes(request.method ?? 'GET')
const upstreamPath = pluginManagementUpstreamPath(request.method, url)
if (upstreamPath === null) {
rejectHttp(response, 404, 'not found')
return
}
const authorization = await options.browserAuthentication.authorizePlugin(request, {
method: request.method ?? 'GET', target: upstreamPath, requireCsrf: mutation,
})
if (!authorization.authorized) {
rejectDshAuthentication(request, response, safeReturnPath)
return
}
await proxyHttp(request, response, {
...options, socketPath: options.managementSocketPath, polyfill: false, upstreamPath,
internalCapability: authorization.capability.token,
internalCapabilityAudience: 'plugin',
})
return
}
if (pathname.startsWith(MANAGEMENT_PREFIX)) {
if (!isExternalManagementRoute(request.method, pathname)) {
rejectHttp(response, 404, 'not found')
return
}
const socketPath = isMaintenanceRoute(pathname) ? options.maintenanceSocketPath : options.managementSocketPath
const audience = socketPath === options.maintenanceSocketPath ? 'maintenance' : 'management'
const capability = await requireManagement(audience)
if (capability === undefined) return
await proxyHttp(request, response, {
...options, socketPath, polyfill: false,
...(typeof capability === 'string' ? { internalCapability: capability } : {}),
})
return
}
if (pathname === PLUGIN_FAILURE_PATH) {
if (!['GET', 'HEAD'].includes(request.method ?? 'GET')) {
rejectHttp(response, 405, 'method not allowed')
return
}
sendAvailabilityPage(request, response, 'plugin-failed', {
poll: false,
allowRefresh: true,
managementHref: managementAvailabilityHref(currentAccess),
})
return
}
if (pathname === READINESS_PATH) {
const result = await readiness(options)
const managementHref = managementAvailabilityHref(currentAccess)
if (result.ready) sendJson(response, 200, {
ready: true,
state: 'ready',
pluginRecoveryEligible: result.pluginRecoveryEligible,
managementHref,
})
else {
sendJson(response, result.state === 'unknown' ? 502 : 503, {
ready: false,
state: result.state,
message: stateMessage(result.state, request.headers, result.platform.dshLifecycle),
managementHref,
})
}
return
}
if (pathname === WAIT_PATH) {
if (!['GET', 'HEAD'].includes(request.method ?? 'GET')) {
rejectHttp(response, 405, 'method not allowed')
return
}
const returnPath = safeReturnPath(url.searchParams.get('return'))
const result = await readiness(options)
if (result.ready) {
response.writeHead(302, { 'cache-control': 'no-store', location: returnPath })
response.end()
return
}
sendAvailabilityPage(request, response, result.state === 'unknown' ? 'unavailable' : result.state, {
lifecycle: result.platform.dshLifecycle,
returnPath,
managementHref: managementAvailabilityHref(currentAccess),
})
return
}
if (!await requireDsh()) return
if (await serveSystemPluginBundle(request, response, options.systemPluginRoot, pathname, url.searchParams)) return
if (await holdPluginBundleDuringTransition(request, response, options, pathname)) return
if (await proxyPluginBundle(request, response, options, pathname)) return
if (pathname === CLIENT_EVENT_PATH) {
if (request.method !== 'POST') {
rejectHttp(response, 405, 'method not allowed')
return
}
const value = await clientEvent(request)
if (value.event === 'browser.plugin-load.recovery.started') options.consumePluginRecovery?.()
await record(value.event, {
level: value.level,
pluginId: value.pluginId,
revision: value.revision,
pathname: value.pathname,
lifecycleState: value.lifecycleState,
lifecycleTaskId: value.lifecycleTaskId,
recoveryAttempt: value.recoveryAttempt,
reason: value.reason,
})
response.writeHead(204, { 'cache-control': 'no-store' })
response.end()
return
}
await proxyHttp(request, response, { ...options, trackDsh: true })
} catch (error) {
reportFailure('gateway-request', 'gateway.request.failed', { ...requestContext(request), error })
if (error?.browserAuthenticationBackend === true) rejectHttp(response, 503, 'authentication service unavailable')
else rejectHttp(response, error?.statusCode ?? 400, 'bad request')
}
}
server.on('upgrade', (request, socket, head) => {
try {
const url = new URL(request.url ?? '/', 'http://gateway.internal')
const pathname = url.pathname
void (async () => {
const access = await options.browserAuthentication.status()
const trust = inspectExternalRequest(request.headers,
surface === 'management' ? managementTrustedHosts(access) : options.trustedHosts)
if (!trust.accepted) {
reportFailure(`gateway-upgrade-trust-${trust.reason}`, 'gateway.upgrade-request.rejected', {
...requestContext(request),
reason: trust.reason,
level: 'warning',
})
rejectUpgrade(socket, 403, 'Forbidden')
return
}
if (access.state !== 'initialized') {
rejectUpgrade(socket, 401, 'Unauthorized')
return
}
const managementMode = access.account?.managementAccess?.mode ?? 'compat'
if ((surface === 'management' && managementMode !== 'isolated')
|| (surface === 'compat' && managementMode === 'isolated'
&& isCompatibilityManagementPath(pathname))) {
rejectUpgrade(socket, 404, 'Not Found')
return
}
const upstreamPath = surface === 'management' ? managementSurfaceUpstream(url) : request.url ?? pathname
const upstreamPathname = upstreamPath === null
? null : new URL(upstreamPath, 'http://gateway.internal').pathname
if (surface === 'management' && upstreamPathname === null) {
rejectUpgrade(socket, 404, 'Not Found')
return
}
if (request.method === 'GET' && TERMINAL_STREAM_ROUTE.test(upstreamPathname ?? pathname)) {
const authorization = typeof options.browserAuthentication.authorizeManagement === 'function'
? await options.browserAuthentication.authorizeManagement(request, {
audience: 'maintenance', method: request.method ?? 'GET', target: upstreamPath,
})
: { authorized: (await options.browserAuthentication.validateManagement(request)).authenticated === true }
if (!authorization.authorized) {
rejectUpgrade(socket, 401, 'Unauthorized')
return
}
upgradedSockets.add(socket)
socket.once('close', () => upgradedSockets.delete(socket))
await proxyUpgrade(request, socket, head, {
...options, socketPath: options.maintenanceSocketPath,
upstreamPath,
...(typeof authorization.capability?.token === 'string'
? { internalCapability: authorization.capability.token } : {}),
})
return
}
if ((upstreamPathname ?? pathname).startsWith(MANAGEMENT_PREFIX)) {
rejectUpgrade(socket, 400, 'Bad Request')
return
}
const dshSession = await options.browserAuthentication.validateDsh(request)
if (!dshSession.authenticated) {
rejectUpgrade(socket, 401, 'Unauthorized')
return
}
if (!await publicationReady(options)) {
rejectUpgrade(socket, 503, 'Service Unavailable')
return
}
upgradedSockets.add(socket)
socket.once('close', () => upgradedSockets.delete(socket))
await proxyUpgrade(request, socket, head, options)
})().catch(error => {
reportFailure('gateway-upgrade-authentication', 'gateway.upgrade-authentication.failed', {
...requestContext(request), error,
})
rejectUpgrade(socket, 503, 'Service Unavailable')
})
} catch (error) {
reportFailure('gateway-upgrade', 'gateway.upgrade-request.failed', { ...requestContext(request), error })
rejectUpgrade(socket, 400, 'Bad Request')
}
})
upgradedSocketsByServer.set(server, upgradedSockets)
let probing = false
const probeTimer = setInterval(() => {
if (probing) return
probing = true
void options.probe().then(ready => {
const wasReady = options.availability.everReady
options.availability.observe(ready)
if (ready) reportRecovered('dsh-probe', 'gateway.dsh.recovered', { upstream: 'dsh' })
else if (wasReady && options.availability.consecutiveFailures >= options.availability.failures) {
reportFailure('dsh-probe', 'gateway.dsh.unavailable', { level: 'warning', upstream: 'dsh' })
}
}, error => {
options.availability.observe(false)
reportFailure('dsh-probe', 'gateway.dsh.probe.failed', { error, level: 'warning', upstream: 'dsh' })
})
.finally(() => { probing = false })
}, probeIntervalMs)
probeTimer.unref()
server.once('close', () => clearInterval(probeTimer))
probeTimersByServer.set(server, probeTimer)
return server
}
export async function closeGatewayServer(server) {
clearInterval(probeTimersByServer.get(server))
const closed = new Promise((resolve, reject) => {
server.close(error => error === undefined ? resolve() : reject(error))
})
server.closeAllConnections()
for (const socket of upgradedSocketsByServer.get(server) ?? []) socket.destroy()
await closed
}