1311 lines
57 KiB
JavaScript
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
|
|
}
|