/** * Reverse proxy from the orchestrator to a running per-user DSH. * * Two entry points: * - subpath `/u/:slug/dsh/*` (authenticated, legacy), and * - per-user subdomain `.` (HTTP + WebSocket). The DSH's * absolute-path SPA requires the subdomain form: its `/assets/*` and `/api/*` * resolve against the host root, which only works when each DSH owns a host. * @module dshs/supervisor/proxy */ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' import { readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { Agent, request as httpRequest, type IncomingHttpHeaders, type IncomingMessage } from 'node:http' import { connect } from 'node:net' import { hashSessionToken, parseCookie } from '../web/auth.js' import { requireAuth } from '../web/middleware/authn.js' import type { Endpoint } from './spawner.js' // Keep-alive pool for per-user DSH upstreams. Replaced (not just destroyed) on a // connection error, because a Pod rebuild changes its IP and any pooled socket // to the old IP would keep failing (docs/k8s.md §5.4). let upstreamAgent = new Agent({ keepAlive: true, maxSockets: 32 }) // Headers the DSH's browser-trust fence must NOT see from the browser, so the // proxied request looks like a clean loopback client (its Host is overridden to // loopback; a mismatched Origin would otherwise 403). const STRIP_HEADERS = new Set([ 'origin', 'referer', 'sec-fetch-site', 'sec-fetch-mode', 'sec-fetch-dest', 'sec-fetch-user', 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', ]) /** * 档案 50:注入到**实例 HTML**里的「会话过期自愈」脚本。 * * 为什么需要它:实例被空闲回收后**重建**(新端口 + 新 launch token),而**已打开的页面** * 仍带着旧 `dsh-auth-*` cookie → 之后任何 `/api/*` XHR 都会被新实例判 **401**, * dsh 前端只显示 `transport failure … HTTP 401`,用户只能手动刷新。 * 导航路径的 401 已在上面处理(302 到当前实例新 token),但 **XHR 无法靠 302 自愈**, * 所以给页面这段脚本:发现 `/api/*` 返回 401 就显示覆盖层并 reload —— * reload 会走"导航 401 → 302 新 token"这条**已经工作**的链路,从而自动恢复。 * 仅改写响应内容,不落盘、不改官方文件(R2);README/技能已留档。 */ // 注入到实例子域页面的自愈脚本(档案 50 + 档案 59 增强)。 // ① 401 自愈:实例被回收重建后 launch token 轮换,页面内 /api/* 会拿到 401 → 亮覆盖层并整页 reload。 // ② 慢请求提示(档案 59):服务端在实例未就绪时 hold 住请求最长 20 秒等拉起, // 期间浏览器端原本毫无反馈(点了重连也看不出在等什么)→ 挂起 ≥3 秒即显示「实例正在启动」。 // ③ 回到页面自检 + 就地恢复(档案 77):判据从「进程在不在跑」改为「页面还能不能连上实例」, // 并把恢复过程做成**看得见**的(顶部轻提示 → 覆盖层),恢复后原地跳回而不是离开到门户域。 // 保持多行形式便于维护;注入时整段塞进 ' + '' return html.includes('') ? html.replace('', tag + '') : html + tag } function buildUpstreamHeaders(headers: IncomingHttpHeaders, port: number): Record { const out: Record = {} for (const [key, value] of Object.entries(headers)) { if (value === undefined || STRIP_HEADERS.has(key.toLowerCase())) continue out[key] = value as string | string[] } // Keep Host loopback: DSH's /api trust fence requires the Host to be loopback // or a `--trusted-host` authority — a real domain would 403 every /api call. // DSH's absolute URLs are rewritten to the real origin in proxyHttp below. out.host = `127.0.0.1:${port}` return out } /** 档案 51:从 freshAuthUrl 的 `?token=` 中取出当前实例的 launch token。 */ function tokenFromAuthUrl(url: string | undefined): string | undefined { if (url === undefined) return undefined const m = /[?&]token=([^&]+)/.exec(url) return m === null || m[1] === undefined ? undefined : decodeURIComponent(m[1]) } /** * 档案 51:向**当前**实例要一份有效的浏览器凭证 cookie。 * * 实例每次 (重)启动都会换 launch token **和** `dsh-auth-<随机后缀>` 的 cookie 名。 * 已打开的页面还带着旧名字的 cookie → 新实例一律判 401。这里 GET `/?token=<当前>` * (实例回 303 + Set-Cookie),把 set-cookie 原样取出,供代理重放请求与回写浏览器。 */ function fetchInstanceAuthCookies(endpoint: Endpoint, token: string): Promise { return new Promise((resolve) => { let done = false const finish = (v: string[]): void => { if (!done) { done = true resolve(v) } } const req = httpRequest( { host: endpoint.host, port: endpoint.port, path: '/?token=' + encodeURIComponent(token), method: 'GET', headers: { host: `127.0.0.1:${endpoint.port}`, 'user-agent': 'dsh-proxy-auth-refresh' }, }, (res) => { const sc = res.headers['set-cookie'] res.resume() finish(Array.isArray(sc) ? sc : sc === undefined ? [] : [sc]) }, ) req.on('error', () => finish([])) req.setTimeout(5000, () => { req.destroy() finish([]) }) req.end() }) } /** * 档案 51:把浏览器带来的 cookie 与实例的新 cookie 合并 —— 丢掉旧的 `dsh-auth-*` * (新实例只认自己那份;旧名留着也没用),其余(`sid` 等代理不关心)原样保留。 */ function mergeCookieHeader(original: string | undefined, fresh: string[]): string { const keep = (original ?? '') .split(';') .map((x) => x.trim()) .filter((x) => x !== '' && !/^dsh-auth-/.test(x)) const add = fresh .map((c) => (c.split(';')[0] ?? '').trim()) .filter((x) => x !== '') return [...keep, ...add].join('; ') } /** Extract `` from `.`, or null when not a match. */ export function parseSubdomain(host: string | undefined, baseDomain: string): string | null { if (host === undefined || baseDomain === '') return null const name = host.split(':')[0] ?? '' if (name === baseDomain) return null if (name.endsWith('.' + baseDomain)) { const slug = name.slice(0, -(baseDomain.length + 1)) return slug !== '' ? slug.toLowerCase() : null } return null } /** The per-user subdomain for a username, or null when `baseDomain` is unset. */ export function subdomainForUser(baseDomain: string, username: string): string | null { return baseDomain === '' ? null : `${username.toLowerCase()}.${baseDomain}` } /** The browser-facing origin (`://`) this request reached us with, * used to rewrite DSH's loopback absolute URLs back to the real domain. */ function realOrigin(headers: IncomingHttpHeaders): string | undefined { const host = clientHost(headers) if (host === undefined) return undefined const proto = headers['x-forwarded-proto'] const scheme = typeof proto === 'string' && proto !== '' ? proto : 'https' return `${scheme}://${host}` } /** A subdomain resolution: a tunnelable endpoint, an error to return, or null (not a subdomain). */ type SubdomainAccess = { endpoint: Endpoint; userId: string } | { error: string; code: number; userId?: string } | null /** * The client-facing host, preferring `X-Forwarded-Host`. The control plane sits * behind an edge proxy (Tencent nginx) that hides the real Host to sidestep the * cloud provider's ICP check (docs/k8s-deploy.md §7.4); the real domain arrives * here. The subdomain auth check still validates the cookie against the slug, so * a spoofed forwarded host cannot reach another user's DSH. */ function clientHost(headers: IncomingHttpHeaders): string | undefined { const fwd = headers['x-forwarded-host'] if (typeof fwd === 'string' && fwd !== '') return fwd if (Array.isArray(fwd) && fwd[0] !== '') return fwd[0] return headers.host } /** * Resolve a subdomain Host to a DSH port, authenticating the caller: the session * cookie must belong to a non-disabled user whose username matches the subdomain. */ async function resolveSubdomainAccess( app: FastifyInstance, host: string | undefined, cookieHeader: string | undefined, ): Promise { const slug = parseSubdomain(host, app.config.baseDomain) if (slug === null) return null const target = await app.db.findUserBySlug(slug) if (target === undefined) return { error: 'unknown_user', code: 404 } const token = parseCookie(cookieHeader, 'sid') const session = token === undefined ? undefined : await app.db.findSessionWithUser(hashSessionToken(token)) if (session === undefined || session.expiresAt <= Date.now() || session.user.role === 'disabled') { return { error: 'unauthorized', code: 401 } } if (session.user.username.toLowerCase() !== slug) { return { error: 'forbidden', code: 403 } } const endpoint = await app.supervisor.endpointFor(session.user.id) if (endpoint === undefined) return { error: 'not_running', code: 404, userId: session.user.id } // userId 一并返回:实例侧 401(launch token 过期)时用它取当前实例的新 token(档案 24)。 // Real user traffic to their own DSH counts as activity (idle-reap signal). app.supervisor.touch(session.user.id) return { endpoint, userId: session.user.id } } function proxyHttp( request: FastifyRequest, reply: FastifyReply, endpoint: Endpoint, targetPath: string, rewritePrefix?: string, rewriteLoopbackLocation = false, useKeepAlive = false, /** * 实例侧 401 时的恢复入口(档案 24):浏览器导航遇到 dsh 的 "authentication required" * (launch token 过期 —— 实例重启/回收后刷新旧标签页)时调用,返回应 302 到的地址。 * 返回 undefined 则回落到 '/'。 */ freshAuthUrl?: () => Promise, ): void { reply.hijack() // 档案 51:为「401 透明重放」准备请求体。 // 只在 content-length 已知且不大时才缓冲(普通 /api JSON-RPC 请求都很小); // 未知长度(chunked 上传)不缓冲 → 该请求退回旧行为(401 透传),不做重放。 const MAX_REPLAY_BODY = 8 * 1024 * 1024 const reqMethod = (request.raw.method ?? 'GET').toUpperCase() const mayHaveBody = reqMethod !== 'GET' && reqMethod !== 'HEAD' const clNum = typeof request.headers['content-length'] === 'string' ? Number(request.headers['content-length']) : NaN const bufferable = mayHaveBody && Number.isFinite(clNum) && clNum <= MAX_REPLAY_BODY let bodyBuf: Buffer | undefined // ★ 事后修正 1(2026-09-11):重放闸门**不能**用 bufferable —— 它含 mayHaveBody, // 会让 GET/HEAD(含 dsh 的 SSE 会话流)永远无法重放。GET 没有请求体,反而最该能重放。 const canReplay = mayHaveBody ? false : true let authRetryUsed = false let replayCookies: string[] = [] const attempt = (connRetry: boolean, authCookie?: string): void => { // 档案 50 修正(实测定位):**HTML 导航请求必须向上游要 identity**。 // 原因:dsh 实例对带 Accept-Encoding 的请求会 **gzip 压缩 HTML**(浏览器就带), // 于是响应带 content-encoding → 我下面的注入逻辑**主动跳过** → 浏览器拿到的页面 // **没有自愈脚本** → C 方案对真实用户无效(而 curl 不带 Accept-Encoding,故此前验证 // 是假阳性)。改法:只要**客户端接受 HTML**,就覆盖转发的 accept-encoding 为 identity, // 让上游回未压缩 HTML → 注入成功。体积影响可忽略(HTML ~24KB),且边缘 nginx 若开 gzip // 仍会对浏览器压缩,用户侧无感知。静态资源/SSE 的压缩行为不受影响。 const upHeaders = buildUpstreamHeaders(request.headers, endpoint.port) if (String(request.headers.accept ?? '').includes('text/html')) { upHeaders['accept-encoding'] = 'identity' } // 档案 51:重放时用**当前实例的新 cookie** 覆盖浏览器带来的旧 cookie。 if (authCookie !== undefined) upHeaders.cookie = authCookie if (bodyBuf !== undefined) { // 请求体已缓冲 → 显式带上长度并去掉可能存在的 chunked 头,保证重放一致。 delete upHeaders['transfer-encoding'] upHeaders['content-length'] = String(bodyBuf.length) } const upstream = httpRequest( { host: endpoint.host, port: endpoint.port, path: targetPath, method: request.method, // Keep-alive only where it is required (k8s Headless Service DNS // re-resolution on retry — docs/k8s.md §5.4). Local mode uses a fresh // connection per request: the loopback connect cost is negligible and // this avoids the half-open keep-alive pool that hung the proxy after // child-instance restarts (2026-09-09 outage: pooled sockets to a // since-recycled instance port never errored, so requests hung). agent: useKeepAlive && !connRetry ? upstreamAgent : false, // Host header stays loopback for the DSH trust fence; the TCP target host // is endpoint.host above. k8s mode overrides the header port separately. headers: upHeaders, }, (upRes: IncomingMessage) => { const headers = { ...upRes.headers } const isNav = request.raw.method === 'GET' && String(request.headers.accept ?? '').includes('text/html') // 档案 51:**非导航**请求的实例侧 401 = 页面拿着旧实例的 dsh-auth cookie。 // 透明重放:取当前实例新 cookie → 覆盖 cookie 头重发同一请求 → 新 cookie 回写浏览器。 // 只重放一次(authRetryUsed),避免与实例互相刷 401 造成死循环。 const replayReady = mayHaveBody ? bodyBuf !== undefined : canReplay if (upRes.statusCode === 401 && !isNav && !authRetryUsed && freshAuthUrl !== undefined && replayReady) { authRetryUsed = true upRes.resume() void (async () => { let cookieHeader: string | undefined try { const url = await freshAuthUrl() const tk = tokenFromAuthUrl(url) if (tk !== undefined) { const fresh = await fetchInstanceAuthCookies(endpoint, tk) if (fresh.length > 0) { replayCookies = fresh cookieHeader = mergeCookieHeader(request.headers.cookie, fresh) } } } catch { /* 取不到 → 回落到原样 401 */ } if (cookieHeader === undefined) { reply.raw.writeHead(401, headers) reply.raw.end('unauthorized') return } process.stderr.write( `[proxy-auth-replay] ${request.raw.method ?? 'GET'} ${targetPath}(旧 cookie → 实例新 cookie,重放)\n`, ) attempt(false, cookieHeader) })() return } // 重放成功后把新 cookie 交给浏览器:之后(含 SSE 自动重连)不再需要重放。 if (authCookie !== undefined && replayCookies.length > 0) { const prev = headers['set-cookie'] headers['set-cookie'] = [ ...(Array.isArray(prev) ? prev : prev === undefined ? [] : [prev]), ...replayCookies, ] } const location = upRes.headers.location if ( rewritePrefix !== undefined && typeof location === 'string' && location.startsWith('/') && !location.startsWith('//') && !location.startsWith(rewritePrefix) ) { headers.location = rewritePrefix + location } // ── 2026-09-12(平台缓存治理):让「改了 client bundle 却看不到变化」不再发生 ── // // 背景(档案 67 v0.2.6 实测,排查成本极高): // dsh 的客户端模块 URL 形如 `/plugins/??/client.js,…&rev=<内容 sha1>`, // `rev` 由 **dsh 官方**按内容计算(`dsh-client-modules`),**平台不得改(R2)**。 // 平台更新 bundle 后,若 `rev` 未变 → 浏览器认为手上那份缓存仍有效 → **不重新请求** // ⇒ 服务端已是新版、用户却一直看到旧 UI(本次:SQL 层/磁盘/服务端三处都验过是新的)。 // // 处置:对实例的**模块/静态资源路径**统一加 `Cache-Control: no-cache` —— // **允许缓存,但每次必须回源校验**(配合上游 ETag/Last-Modified 走 304,开销极小)。 // ⇒ bundle 一变,浏览器下一次请求就拿到新的,**用户无需清缓存/换无痕**。 // // ⚠️ 勿删:这是 UI 改动能否被验收的前置机制(`06-工作台UI规范 §6.5`)。 if (targetPath.startsWith('/plugins/') || targetPath.startsWith('/assets/')) { headers['cache-control'] = 'no-cache' } // local mode only: DSH builds absolute URLs from the loopback Host we // forward; rewrite any 127.0.0.1 Location to the real origin so the // browser doesn't jump to the user's own machine. k8s mode keeps its // verified behavior (no rewrite). if ( rewriteLoopbackLocation && typeof location === 'string' && realOrigin(request.headers) !== undefined ) { headers.location = location.replace(/^https?:\/\/127\.0\.0\.1(:\d+)?/, realOrigin(request.headers)!) } // 2026-09-11(档案 24):实例侧 401 = launch token 过期。浏览器导航时不要停在 // dsh 的 "dsh web authentication required; reopen the URL printed by dsh web." 死端页, // 而是用当前实例的新 token 302 回同一地址(拿不到则回门户)。 if ( upRes.statusCode === 401 && request.raw.method === 'GET' && String(request.headers.accept ?? '').includes('text/html') && freshAuthUrl !== undefined ) { upRes.resume() void freshAuthUrl() .then((url) => { reply.raw.writeHead(302, { location: url ?? '/' }) reply.raw.end() }) .catch(() => { reply.raw.writeHead(302, { location: '/' }) reply.raw.end() }) return } // 档案 50:HTML 响应缓冲后注入「会话过期自愈」脚本(其余一切原样 pipe,零影响)。 const ctype = String(upRes.headers['content-type'] ?? '') const enc = upRes.headers['content-encoding'] const status = upRes.statusCode ?? 502 const canInject = ctype.includes('text/html') && enc === undefined && status !== 204 && status !== 304 if (!canInject) { if (ctype.includes('text/html') && enc !== undefined) { process.stderr.write(`[inject-recovery] skip: content-encoding=${String(enc)}\n`) } reply.raw.writeHead(status, headers) upRes.pipe(reply.raw) return } const chunks: Buffer[] = [] upRes.on('data', (c: Buffer) => chunks.push(c)) upRes.on('end', () => { const out = injectRecovery(Buffer.concat(chunks).toString('utf8')) delete headers['content-length'] delete headers['transfer-encoding'] headers['content-length'] = String(Buffer.byteLength(out)) reply.raw.writeHead(status, headers) reply.raw.end(out) }) upRes.on('error', () => reply.raw.destroy()) }, ) upstream.on('error', () => { if (connRetry) { reply.raw.destroy() return } // A stale keep-alive socket or a Pod that just restarted: drop the pool, // replace it with a fresh one, and retry once on a fresh connection. upstreamAgent.destroy() upstreamAgent = new Agent({ keepAlive: true, maxSockets: 32 }) request.raw.unpipe(upstream) attempt(true) }) if (bodyBuf !== undefined) upstream.end(bodyBuf) else request.raw.pipe(upstream) } if (!bufferable) { attempt(false) return } const pre: Buffer[] = [] request.raw.on('data', (c: Buffer) => pre.push(c)) request.raw.on('end', () => { bodyBuf = Buffer.concat(pre) attempt(false) }) request.raw.on('error', () => reply.raw.destroy()) } export async function registerDshProxy(app: FastifyInstance): Promise { // Legacy authenticated subpath proxy. app.all('/u/:slug/dsh/*', { preHandler: requireAuth }, async (request, reply) => { const slug = (request.params as { slug: string }).slug if (request.user === null || request.user.id !== slug) { reply.code(403).send({ error: 'forbidden' }) return } const endpoint = await app.supervisor.endpointFor(slug) if (endpoint === undefined) { reply.code(404).send({ error: 'not_running' }) return } app.supervisor.touch(request.user!.id) const prefix = `/u/${slug}/dsh` const rawUrl = request.raw.url ?? '/' const targetPath = rawUrl.startsWith(prefix) ? rawUrl.slice(prefix.length) || '/' : rawUrl const pathScheme = app.config.secureCookies ? 'https' : 'http' proxyHttp( request, reply, endpoint, targetPath, prefix, app.config.deployMode === 'local', app.config.deployMode === 'k8s', async () => { const status = await app.supervisor.status(request.user!.id) const token = status.main?.launchToken return token !== undefined && token !== '' ? `${pathScheme}://${app.config.baseDomain}${prefix}/?token=${encodeURIComponent(token)}` : `${pathScheme}://${app.config.baseDomain}/` }, ) }) // Per-user subdomain: HTTP (intercept before normal routing). app.addHook('onRequest', async (request, reply) => { const access = await resolveSubdomainAccess(app, clientHost(request.headers), request.headers.cookie) if (access === null) return if ('error' in access) { // 浏览器导航(GET + 期待 HTML)的 401(会话失效/退出后刷新实例子域)→ 302 回门户 // 登录页,而不是吐 JSON {"error":"unauthorized"} 停在原地。 if ( access.code === 401 && request.raw.method === 'GET' && (request.headers.accept ?? '').includes('text/html') ) { const scheme = app.config.secureCookies ? 'https' : 'http' reply.redirect(`${scheme}://${app.config.baseDomain}/login.html`) return } // 实例未运行(后台回收/崩溃/停止/熔断后刷新会话页)→ 需要拉起。 // // 2026-09-11(档案 49 · 体验修复):**浏览器导航一律"立刻" 302 到平台自有的过渡页**, // 由过渡页显示加载动画并自己调 /api/dsh/enter 完成「拉起 + 等 token + 跳回」。 // 原因:此前导航请求会在**服务端阻塞等待 launch token(最长 20 秒)**,这期间浏览器 // 只看到白屏、没有任何反馈 → 用户以为卡死,只能手动刷新(那时实例已就绪,看似"刷新才好")。 // 现在导航分支**不阻塞**(0.2s 内返回 302),动画立刻出现,且**不会**在这里触发 launch //(拉起交给过渡页,单点、可重试)。 if (access.code === 404 && access.error === 'not_running' && access.userId !== undefined) { const navScheme = app.config.secureCookies ? 'https' : 'http' const navHost = clientHost(request.headers) ?? app.config.baseDomain const isNavigation = request.raw.method === 'GET' && (request.headers.accept ?? '').includes('text/html') if (isNavigation) { const next = `${navScheme}://${navHost}${request.raw.url ?? '/'}` reply.redirect( `${navScheme}://${app.config.baseDomain}/wake.html?next=${encodeURIComponent(next)}`, ) return } // 非导航(XHR / API)——**这是"页面已打开、再对话没反应"的主场景**。 // 正解不是"回一个错误/动画",而是**等实例就绪后继续转发**:请求最终成功, // dsh 前端自己会保持它的 loading 态 → 用户无感,不需要刷新。 try { const folderAbs = app.userFs.resolvePath(access.userId, '') try { await app.supervisor.launch(access.userId, folderAbs, undefined) } catch { // 并发进场(另一请求正在拉起 → AlreadyRunningError):等它拿到 token await app.supervisor.waitForLaunchTokenForUser(access.userId, 20000) } } catch { /* 拉起失败 → 落到下面的 503 */ } const again = await resolveSubdomainAccess(app, clientHost(request.headers), request.headers.cookie) if (again !== null && !('error' in again)) { proxyHttp( request, reply, again.endpoint, request.raw.url ?? '/', undefined, app.config.deployMode === 'local', app.config.deployMode === 'k8s', async () => { const st = await app.supervisor.status(again.userId) const tk = st.main?.launchToken return tk !== undefined && tk !== '' ? `${navScheme}://${navHost}/?token=${encodeURIComponent(tk)}` : `${navScheme}://${app.config.baseDomain}/` }, ) return } // 真的起不来:给明确的「启动中」+ Retry-After,让前端自行退避重试。 reply.code(503).header('retry-after', '3').send({ error: 'instance_starting' }) return } reply.code(access.code).send({ error: access.error }) return } const navScheme = app.config.secureCookies ? 'https' : 'http' const navHost = clientHost(request.headers) ?? app.config.baseDomain proxyHttp( request, reply, access.endpoint, request.raw.url ?? '/', undefined, app.config.deployMode === 'local', app.config.deployMode === 'k8s', async () => { const status = await app.supervisor.status(access.userId) const token = status.main?.launchToken return token !== undefined && token !== '' ? `${navScheme}://${navHost}/?token=${encodeURIComponent(token)}` : `${navScheme}://${app.config.baseDomain}/` }, ) }) // Per-user subdomain: WebSocket upgrade tunnel. Auth is async (DB lookup), so // the raw `upgrade` callback defers to an async IIFE before deciding to tunnel. app.server.on('upgrade', (req, socket, head) => { void (async () => { const access = await resolveSubdomainAccess(app, clientHost(req.headers), req.headers.cookie) if (access === null || 'error' in access) { socket.destroy() return } const upstream = connect({ host: access.endpoint.host, port: access.endpoint.port }) upstream.on('connect', () => { const lines = [`${req.method} ${req.url} HTTP/${req.httpVersion}`] for (let i = 0; i < req.rawHeaders.length; i += 2) { const name = (req.rawHeaders[i] ?? '').toLowerCase() if (name === 'host' || STRIP_HEADERS.has(name)) continue lines.push(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}`) } lines.push(`Host: 127.0.0.1:${access.endpoint.port}`) upstream.write(lines.join('\r\n') + '\r\n\r\n') if (head !== undefined && head.length > 0) upstream.write(head) socket.pipe(upstream) upstream.pipe(socket) }) upstream.on('error', () => socket.destroy()) socket.on('error', () => upstream.destroy()) })() }) }