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 }