kczx-user-management 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CUTOVER-RUNBOOK.md +73 -0
- package/MERGED-DEPLOYMENT.md +100 -0
- package/README.md +158 -0
- package/client/bundle.js +1799 -0
- package/client/index.js +1785 -0
- package/client/package.json +3 -0
- package/cordis.patch.yml +13 -0
- package/docs/FAQ.md +95 -0
- package/docs/demo-totp-dark.gif +0 -0
- package/docs/demo-totp-light.gif +0 -0
- package/docs/demo.gif +0 -0
- package/package.json +80 -0
- package/scripts/build-client.mjs +42 -0
- package/scripts/ensure-deps.mjs +50 -0
- package/scripts/import-from-dsh-passwords.mjs +111 -0
- package/src/certs.js +89 -0
- package/src/gate.js +162 -0
- package/src/gateway-core.js +769 -0
- package/src/index.js +1358 -0
- package/src/login-page.js +184 -0
- package/src/permissions.js +477 -0
- package/src/proxy.js +474 -0
- package/src/store.js +1072 -0
- package/src/totp.js +136 -0
- package/src/trusted-identity.js +143 -0
- package/src/vendor/qrcode-generator.js +2289 -0
package/src/proxy.js
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reverse proxy from the gateway's HTTPS listener to the loopback upstream
|
|
5
|
+
* (the dsh webserver): plain HTTP requests and WebSocket upgrades.
|
|
6
|
+
*
|
|
7
|
+
* Ported from dsh-gateway (clarknu/dsh-gateway) lib/proxy.js, adapted to
|
|
8
|
+
* CommonJS. Dependency-free on purpose — node:http request + node:net
|
|
9
|
+
* upgrade socket, streaming both ways so SSE and long responses are never
|
|
10
|
+
* buffered.
|
|
11
|
+
*
|
|
12
|
+
* The crucial twist is loopback-masquerade: the client's Host/Origin are
|
|
13
|
+
* rewritten to the upstream loopback authority so the dsh `/api` browser-trust
|
|
14
|
+
* fence (which accepts loopback hosts and requires Origin.host === Host.host)
|
|
15
|
+
* passes for proxied traffic.
|
|
16
|
+
*
|
|
17
|
+
* dsh 0.1.2-rc.1+ note: dsh-client-connection now gates the `/` index.html
|
|
18
|
+
* behind a launchToken + authority-bound signed cookie (BrowserAuth). The
|
|
19
|
+
* gateway already authenticates the human at its own layer, so for index
|
|
20
|
+
* requests we mint a loopback browser cookie via the host's PUBLIC
|
|
21
|
+
* `connection.authenticatedUrl(baseUrl)` builder (the sanctioned way to obtain
|
|
22
|
+
* a token-carrying URL — BrowserAuth.launchToken itself is TS-private) and
|
|
23
|
+
* inject it upstream, letting authorizeIndex pass. On 0.1.1-rc.2 (no
|
|
24
|
+
* BrowserAuth) getAuthenticatedUrl yields null and we fall through to a plain
|
|
25
|
+
* proxy — fully backward compatible.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import http from 'node:http'
|
|
29
|
+
import net from 'node:net'
|
|
30
|
+
import zlib from 'node:zlib'
|
|
31
|
+
|
|
32
|
+
/** RFC 7230 hop-by-hop headers, never forwarded. */
|
|
33
|
+
const HOP_BY_HOP = new Set([
|
|
34
|
+
'connection',
|
|
35
|
+
'keep-alive',
|
|
36
|
+
'proxy-authenticate',
|
|
37
|
+
'proxy-authorization',
|
|
38
|
+
'proxy-connection',
|
|
39
|
+
'te',
|
|
40
|
+
'trailer',
|
|
41
|
+
'transfer-encoding',
|
|
42
|
+
'upgrade',
|
|
43
|
+
])
|
|
44
|
+
|
|
45
|
+
/** Create a proxy target from an http upstream URL string. */
|
|
46
|
+
/**
|
|
47
|
+
* @param upstream - loopback upstream origin.
|
|
48
|
+
* @param getAuthenticatedUrl - BrowserAuth cookie minter (may yield null).
|
|
49
|
+
* @param hooks - optional enforcement seam:
|
|
50
|
+
* `rewriteResponse(req)` returns null when the response may stream through
|
|
51
|
+
* untouched, or `{ transform, maxBytes }` when it must be rewritten first
|
|
52
|
+
* (the caller — the gateway — owns that decision because only it knows the
|
|
53
|
+
* account's permissions; this module owns the HTTP mechanics).
|
|
54
|
+
*/
|
|
55
|
+
function createProxy(upstream, getAuthenticatedUrl, hooks) {
|
|
56
|
+
const target = new URL(upstream)
|
|
57
|
+
if (target.protocol !== 'http:') {
|
|
58
|
+
throw new Error(`user-management: upstream must be plain http (loopback), got ${target.protocol}`)
|
|
59
|
+
}
|
|
60
|
+
const host = target.hostname
|
|
61
|
+
const port = Number(target.port || 80)
|
|
62
|
+
const agent = new http.Agent({ keepAlive: true, maxSockets: 64 })
|
|
63
|
+
/** Loopback authority we present upstream (rewrites the client's Host). */
|
|
64
|
+
const upstreamAuthority = port === 80 ? host : `${host}:${port}`
|
|
65
|
+
/** Origin header we present upstream, matching the rewritten Host. */
|
|
66
|
+
const upstreamOrigin = `http://${upstreamAuthority}`
|
|
67
|
+
|
|
68
|
+
// dsh-client-connection browser-session cookie for upstreamAuthority.
|
|
69
|
+
// Minted once via the host's authenticatedUrl() token exchange, reused
|
|
70
|
+
// across index requests, refreshed when the upstream answers 401 (cookie
|
|
71
|
+
// expired after its 30-day max age / dsh restarted with a new token).
|
|
72
|
+
let dshCookie = null
|
|
73
|
+
|
|
74
|
+
const forwardHeaders = (req) => {
|
|
75
|
+
const headers = {}
|
|
76
|
+
for (const [name, value] of Object.entries(req.headers || {})) {
|
|
77
|
+
if (value === undefined || HOP_BY_HOP.has(name.toLowerCase())) continue
|
|
78
|
+
headers[name] = value
|
|
79
|
+
}
|
|
80
|
+
// Loopback-masquerade mode: rewrite Host and Origin to the upstream
|
|
81
|
+
// loopback authority. The dsh trust fence accepts loopback hosts and
|
|
82
|
+
// requires Origin.host === Host.host, so both must move together.
|
|
83
|
+
headers.host = upstreamAuthority
|
|
84
|
+
if (headers.origin !== undefined) {
|
|
85
|
+
headers.origin = upstreamOrigin
|
|
86
|
+
}
|
|
87
|
+
const peer = (req.socket && req.socket.remoteAddress) || ''
|
|
88
|
+
const prior = req.headers['x-forwarded-for']
|
|
89
|
+
headers['x-forwarded-for'] = prior ? `${prior}, ${peer}` : peer
|
|
90
|
+
headers['x-forwarded-proto'] = 'https'
|
|
91
|
+
headers['x-forwarded-host'] = req.headers.host || ''
|
|
92
|
+
if (dshCookie) headers.cookie = dshCookie // patched: all paths carry BrowserAuth cookie
|
|
93
|
+
// patched: forward the caller's um_session as a header so same-origin
|
|
94
|
+
// plugins (e.g. dsh-git-server) can resolve the acting user — the cookie
|
|
95
|
+
// itself is replaced above, so this is the only identity channel through.
|
|
96
|
+
const umTok = /(?:^|;\s*)um_session=([^;]+)/.exec(String(req.headers.cookie || ''))
|
|
97
|
+
if (umTok) headers['x-um-session'] = decodeURIComponent(umTok[1])
|
|
98
|
+
return headers
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const stripHop = (headers) => {
|
|
102
|
+
const out = {}
|
|
103
|
+
for (const [name, value] of Object.entries(headers || {})) {
|
|
104
|
+
if (value === undefined || HOP_BY_HOP.has(name.toLowerCase())) continue
|
|
105
|
+
out[name] = value
|
|
106
|
+
}
|
|
107
|
+
return out
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Rewrite upstream absolute Location headers back to the public base. */
|
|
111
|
+
const rewriteLocation = (headers, clientHost) => {
|
|
112
|
+
const loc = headers.location
|
|
113
|
+
if (typeof loc === 'string' && loc.startsWith(`http://${upstreamAuthority}`)) {
|
|
114
|
+
headers.location = `https://${clientHost}${loc.slice(`http://${upstreamAuthority}`.length)}`
|
|
115
|
+
}
|
|
116
|
+
return headers
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Mint a loopback browser cookie from the dsh launch token. The token URL
|
|
121
|
+
* comes from the host's PUBLIC `connection.authenticatedUrl(origin)` — we
|
|
122
|
+
* never touch BrowserAuth's private fields nor hardcode its query key.
|
|
123
|
+
*/
|
|
124
|
+
const refreshDshCookie = () =>
|
|
125
|
+
new Promise((resolve) => {
|
|
126
|
+
let authUrl = null
|
|
127
|
+
try {
|
|
128
|
+
authUrl = typeof getAuthenticatedUrl === 'function' ? getAuthenticatedUrl(`http://${upstreamAuthority}`) : null
|
|
129
|
+
} catch {
|
|
130
|
+
authUrl = null
|
|
131
|
+
}
|
|
132
|
+
if (!authUrl) {
|
|
133
|
+
resolve(false)
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
let path = '/'
|
|
137
|
+
try {
|
|
138
|
+
const parsed = new URL(authUrl)
|
|
139
|
+
path = `${parsed.pathname}${parsed.search}` || '/'
|
|
140
|
+
} catch {
|
|
141
|
+
resolve(false)
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
const authReq = http.request(
|
|
145
|
+
{
|
|
146
|
+
host,
|
|
147
|
+
port,
|
|
148
|
+
method: 'GET',
|
|
149
|
+
path,
|
|
150
|
+
headers: { host: upstreamAuthority, origin: upstreamOrigin },
|
|
151
|
+
agent,
|
|
152
|
+
},
|
|
153
|
+
(authRes) => {
|
|
154
|
+
// authorizeIndex answers 303 + Set-Cookie on a valid token; either way
|
|
155
|
+
// we only care about the cookie header the upstream stamped in.
|
|
156
|
+
const setCookie = authRes.headers['set-cookie']
|
|
157
|
+
if (Array.isArray(setCookie) && setCookie.length) {
|
|
158
|
+
dshCookie = setCookie
|
|
159
|
+
.map((c) => c.split(';')[0].trim())
|
|
160
|
+
.filter(Boolean)
|
|
161
|
+
.join('; ')
|
|
162
|
+
}
|
|
163
|
+
authRes.resume()
|
|
164
|
+
resolve(!!dshCookie)
|
|
165
|
+
},
|
|
166
|
+
)
|
|
167
|
+
authReq.on('error', () => resolve(false))
|
|
168
|
+
authReq.end()
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
const ensureDshCookie = async () => {
|
|
172
|
+
if (dshCookie) return true
|
|
173
|
+
return refreshDshCookie()
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const isIndexPath = (rawUrl) => {
|
|
177
|
+
const url = rawUrl || ''
|
|
178
|
+
const i = url.indexOf('?')
|
|
179
|
+
const path = i === -1 ? url : url.slice(0, i)
|
|
180
|
+
return path === '/' || path === '/index.html'
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Serve an index (`/` or `/index.html`) request: inject the minted dsh
|
|
185
|
+
* browser cookie so authorizeIndex passes, then stream the upstream response
|
|
186
|
+
* back. Refreshes the cookie once on 401 and retries, then gives up (serves
|
|
187
|
+
* the 401 through) so a stuck token never loops.
|
|
188
|
+
*/
|
|
189
|
+
async function handleIndexRequest(req, res, attempts) {
|
|
190
|
+
attempts = attempts || 0
|
|
191
|
+
await ensureDshCookie()
|
|
192
|
+
const headers = forwardHeaders(req)
|
|
193
|
+
if (dshCookie) headers.cookie = dshCookie
|
|
194
|
+
const upstreamReq = http.request(
|
|
195
|
+
{
|
|
196
|
+
host,
|
|
197
|
+
port,
|
|
198
|
+
method: req.method,
|
|
199
|
+
path: req.url,
|
|
200
|
+
headers,
|
|
201
|
+
agent,
|
|
202
|
+
},
|
|
203
|
+
(upstreamRes) => {
|
|
204
|
+
if (upstreamRes.statusCode === 401 && dshCookie && attempts < 1) {
|
|
205
|
+
upstreamRes.resume()
|
|
206
|
+
dshCookie = null
|
|
207
|
+
refreshDshCookie().then(() => handleIndexRequest(req, res, attempts + 1))
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
const outHeaders = stripHop(upstreamRes.headers)
|
|
211
|
+
// The cookie dsh stamps is authority-bound to the loopback upstream and
|
|
212
|
+
// must not leak to the public browser (wrong domain, never storable).
|
|
213
|
+
delete outHeaders['set-cookie']
|
|
214
|
+
if (outHeaders.location !== undefined) rewriteLocation(outHeaders, req.headers.host || '')
|
|
215
|
+
res.writeHead(upstreamRes.statusCode || 502, outHeaders)
|
|
216
|
+
if (req.method === 'HEAD' || upstreamRes.statusCode === 204 || upstreamRes.statusCode === 304) {
|
|
217
|
+
upstreamRes.resume()
|
|
218
|
+
res.end()
|
|
219
|
+
} else {
|
|
220
|
+
upstreamRes.pipe(res)
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
)
|
|
224
|
+
upstreamReq.on('error', (err) => {
|
|
225
|
+
if (res.headersSent) {
|
|
226
|
+
res.destroy()
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
|
|
230
|
+
res.end(`user-management: upstream unreachable (${err.code || err.message})`)
|
|
231
|
+
})
|
|
232
|
+
req.on('error', () => upstreamReq.destroy())
|
|
233
|
+
req.pipe(upstreamReq)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Proxy one plain HTTP request (non-index paths). */
|
|
237
|
+
function proxyPlain(req, res) {
|
|
238
|
+
const upstreamReq = http.request(
|
|
239
|
+
{
|
|
240
|
+
host,
|
|
241
|
+
port,
|
|
242
|
+
method: req.method,
|
|
243
|
+
path: req.url,
|
|
244
|
+
headers: forwardHeaders(req),
|
|
245
|
+
agent,
|
|
246
|
+
},
|
|
247
|
+
(upstreamRes) => {
|
|
248
|
+
const outHeaders = stripHop(upstreamRes.headers)
|
|
249
|
+
delete outHeaders['set-cookie']
|
|
250
|
+
if (outHeaders.location !== undefined) rewriteLocation(outHeaders, req.headers.host || '')
|
|
251
|
+
res.writeHead(upstreamRes.statusCode || 502, outHeaders)
|
|
252
|
+
if (req.method === 'HEAD' || upstreamRes.statusCode === 204 || upstreamRes.statusCode === 304) {
|
|
253
|
+
upstreamRes.resume()
|
|
254
|
+
res.end()
|
|
255
|
+
} else {
|
|
256
|
+
upstreamRes.pipe(res)
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
)
|
|
260
|
+
upstreamReq.on('error', (err) => {
|
|
261
|
+
if (res.headersSent) {
|
|
262
|
+
res.destroy()
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
|
|
266
|
+
res.end(`user-management: upstream unreachable (${err.code || err.message})`)
|
|
267
|
+
})
|
|
268
|
+
req.on('error', () => upstreamReq.destroy())
|
|
269
|
+
sendRequestBody(req, upstreamReq)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Buffered proxy for responses that must be REWRITTEN before they reach the
|
|
274
|
+
* client — today the workspace list, which has to be filtered per account.
|
|
275
|
+
*
|
|
276
|
+
* Streaming is impossible: the body has to be parsed before it can be judged.
|
|
277
|
+
* So this path buffers (with a hard cap) and **fails closed** — a body it
|
|
278
|
+
* cannot parse or rewrite becomes a 502, never an unfiltered pass-through,
|
|
279
|
+
* because passing the full list through would expose every other account's
|
|
280
|
+
* workspaces.
|
|
281
|
+
*/
|
|
282
|
+
function proxyRewriteJson(req, res, transform, options) {
|
|
283
|
+
const maxBytes = Number.isFinite(options && options.maxBytes) ? options.maxBytes : 8 * 1024 * 1024
|
|
284
|
+
let settled = false
|
|
285
|
+
const failClosed = (reason) => {
|
|
286
|
+
if (settled) return
|
|
287
|
+
settled = true
|
|
288
|
+
if (res.headersSent) {
|
|
289
|
+
res.destroy()
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
|
|
293
|
+
res.end(`user-management: upstream response not usable for this account (${reason})`)
|
|
294
|
+
}
|
|
295
|
+
const upstreamReq = http.request(
|
|
296
|
+
{
|
|
297
|
+
host,
|
|
298
|
+
port,
|
|
299
|
+
method: req.method,
|
|
300
|
+
path: req.url,
|
|
301
|
+
headers: forwardHeaders(req),
|
|
302
|
+
agent,
|
|
303
|
+
},
|
|
304
|
+
(upstreamRes) => {
|
|
305
|
+
const chunks = []
|
|
306
|
+
let size = 0
|
|
307
|
+
upstreamRes.on('data', (chunk) => {
|
|
308
|
+
size += chunk.length
|
|
309
|
+
if (size > maxBytes) {
|
|
310
|
+
upstreamRes.destroy()
|
|
311
|
+
failClosed('too large')
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
chunks.push(chunk)
|
|
315
|
+
})
|
|
316
|
+
upstreamRes.on('error', () => failClosed('upstream stream error'))
|
|
317
|
+
upstreamRes.on('end', () => {
|
|
318
|
+
if (settled) return
|
|
319
|
+
try {
|
|
320
|
+
const encoding = String(upstreamRes.headers['content-encoding'] || '')
|
|
321
|
+
let body = Buffer.concat(chunks)
|
|
322
|
+
if (encoding.includes('gzip')) body = zlib.gunzipSync(body)
|
|
323
|
+
const parsed = JSON.parse(body.toString('utf8'))
|
|
324
|
+
const replacement = transform(parsed, { status: upstreamRes.statusCode, headers: upstreamRes.headers })
|
|
325
|
+
let out = Buffer.from(JSON.stringify(replacement === undefined ? parsed : replacement), 'utf8')
|
|
326
|
+
const outHeaders = stripHop(upstreamRes.headers)
|
|
327
|
+
delete outHeaders['set-cookie']
|
|
328
|
+
if (outHeaders.location !== undefined) rewriteLocation(outHeaders, req.headers.host || '')
|
|
329
|
+
const acceptsGzip = /\bgzip\b/.test(String(req.headers['accept-encoding'] || ''))
|
|
330
|
+
if (encoding.includes('gzip') && acceptsGzip) {
|
|
331
|
+
out = zlib.gzipSync(out)
|
|
332
|
+
outHeaders['content-encoding'] = 'gzip'
|
|
333
|
+
} else {
|
|
334
|
+
delete outHeaders['content-encoding']
|
|
335
|
+
}
|
|
336
|
+
delete outHeaders['transfer-encoding']
|
|
337
|
+
outHeaders['content-length'] = String(out.length)
|
|
338
|
+
settled = true
|
|
339
|
+
if (!res.headersSent) res.writeHead(upstreamRes.statusCode || 200, outHeaders)
|
|
340
|
+
if (!res.writableEnded) res.end(out)
|
|
341
|
+
} catch {
|
|
342
|
+
failClosed('unprocessable')
|
|
343
|
+
}
|
|
344
|
+
})
|
|
345
|
+
},
|
|
346
|
+
)
|
|
347
|
+
upstreamReq.on('error', (err) => {
|
|
348
|
+
if (res.headersSent) {
|
|
349
|
+
res.destroy()
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
|
|
353
|
+
res.end(`user-management: upstream unreachable (${err.code || err.message})`)
|
|
354
|
+
})
|
|
355
|
+
req.on('error', () => upstreamReq.destroy())
|
|
356
|
+
sendRequestBody(req, upstreamReq)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Send the request body upstream.
|
|
361
|
+
*
|
|
362
|
+
* A body the gateway already consumed for a permission check is replayed from
|
|
363
|
+
* its buffer: by then the socket stream is spent, and piping it would send an
|
|
364
|
+
* empty body upstream (the request would look bodyless — a silently broken
|
|
365
|
+
* session.create rather than a denial).
|
|
366
|
+
*/
|
|
367
|
+
function sendRequestBody(req, upstreamReq) {
|
|
368
|
+
if (Buffer.isBuffer(req.umReplayBody)) {
|
|
369
|
+
upstreamReq.end(req.umReplayBody)
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
req.pipe(upstreamReq)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Route index paths through the auth-injecting handler, everything else plain. */
|
|
376
|
+
async function handleRequest(req, res) {
|
|
377
|
+
await ensureDshCookie()
|
|
378
|
+
if (isIndexPath(req.url)) {
|
|
379
|
+
handleIndexRequest(req, res)
|
|
380
|
+
return
|
|
381
|
+
}
|
|
382
|
+
if (hooks && typeof hooks.rewriteResponse === 'function') {
|
|
383
|
+
const plan = hooks.rewriteResponse(req)
|
|
384
|
+
if (plan && typeof plan.transform === 'function') {
|
|
385
|
+
proxyRewriteJson(req, res, plan.transform, plan)
|
|
386
|
+
return
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
proxyPlain(req, res)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Build the raw HTTP/1.1 upgrade request line + headers for upstream. */
|
|
393
|
+
const buildUpgradeRequest = (req) => {
|
|
394
|
+
const headers = {}
|
|
395
|
+
for (const [name, value] of Object.entries(forwardHeaders(req))) {
|
|
396
|
+
headers[name] = value
|
|
397
|
+
}
|
|
398
|
+
headers.connection = req.headers.connection || 'Upgrade'
|
|
399
|
+
headers.upgrade = req.headers.upgrade || 'websocket'
|
|
400
|
+
let line = `${req.method} ${req.url} HTTP/1.1\r\n`
|
|
401
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
402
|
+
line += `${name}: ${value}\r\n`
|
|
403
|
+
}
|
|
404
|
+
line += '\r\n'
|
|
405
|
+
return line
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Proxy one WebSocket (or other protocol) upgrade. Ensures the dsh
|
|
410
|
+
* BrowserAuth cookie is minted before forwarding (the HTTP path does
|
|
411
|
+
* this in handleRequest; without it dsh 0.1.2-rc.1+ rejects the upgrade
|
|
412
|
+
* at the BrowserAuth fence — "Remote stream WebSocket closed"). If the
|
|
413
|
+
* upstream rejects the upgrade (non-101, e.g. cookie expired after a
|
|
414
|
+
* dsh restart), refreshes the cookie and retries once — mirrors the
|
|
415
|
+
* HTTP 401-retry in handleIndexRequest.
|
|
416
|
+
*/
|
|
417
|
+
function handleUpgrade(req, socket, head, attempts) {
|
|
418
|
+
attempts = attempts || 0
|
|
419
|
+
ensureDshCookie().then(() => {
|
|
420
|
+
const upstreamSocket = net.connect(port, host, () => {
|
|
421
|
+
upstreamSocket.write(buildUpgradeRequest(req))
|
|
422
|
+
if (head && head.length > 0) upstreamSocket.write(head)
|
|
423
|
+
})
|
|
424
|
+
// Peek at the upstream's first response line: 101 = upgrade accepted,
|
|
425
|
+
// anything else (401/403/500) = rejected, likely an expired cookie.
|
|
426
|
+
// Refresh + retry once; on second failure destroy so the browser
|
|
427
|
+
// reconnects (HTTP traffic will have re-minted by then).
|
|
428
|
+
let upgraded = false
|
|
429
|
+
upstreamSocket.once('data', (chunk) => {
|
|
430
|
+
const statusLine = chunk.toString('utf8', 0, Math.min(chunk.length, 64)).split('\r\n')[0]
|
|
431
|
+
if (statusLine.includes(' 101 ')) {
|
|
432
|
+
upgraded = true
|
|
433
|
+
socket.write(chunk)
|
|
434
|
+
upstreamSocket.pipe(socket)
|
|
435
|
+
socket.pipe(upstreamSocket)
|
|
436
|
+
} else if (attempts < 1) {
|
|
437
|
+
upstreamSocket.destroy()
|
|
438
|
+
dshCookie = null
|
|
439
|
+
refreshDshCookie().then(() => handleUpgrade(req, socket, head, attempts + 1))
|
|
440
|
+
} else {
|
|
441
|
+
upstreamSocket.destroy()
|
|
442
|
+
if (!socket.destroyed) socket.destroy()
|
|
443
|
+
}
|
|
444
|
+
})
|
|
445
|
+
upstreamSocket.on('error', () => {
|
|
446
|
+
if (!upgraded && !socket.destroyed) {
|
|
447
|
+
socket.end('HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n')
|
|
448
|
+
}
|
|
449
|
+
})
|
|
450
|
+
upstreamSocket.on('close', () => {
|
|
451
|
+
if (!upgraded && !socket.destroyed) socket.destroy()
|
|
452
|
+
})
|
|
453
|
+
socket.on('error', () => upstreamSocket.destroy())
|
|
454
|
+
socket.on('close', () => upstreamSocket.destroy())
|
|
455
|
+
}).catch(() => {
|
|
456
|
+
if (!socket.destroyed) socket.destroy()
|
|
457
|
+
})
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return {
|
|
461
|
+
upstream,
|
|
462
|
+
handleRequest,
|
|
463
|
+
handleUpgrade,
|
|
464
|
+
/** Exposed so the gateway (and its tests) can drive the rewrite path. */
|
|
465
|
+
proxyRewriteJson,
|
|
466
|
+
/** @returns {string|null} the minted dsh browser cookie (for tests). */
|
|
467
|
+
getDshCookie: () => dshCookie,
|
|
468
|
+
close() {
|
|
469
|
+
agent.destroy()
|
|
470
|
+
},
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export { createProxy }
|