dsh-remote-access-proxy 0.0.0-stage → 1.5.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.
@@ -0,0 +1,993 @@
1
+ /**
2
+ * Standalone end-to-end test for dsh-remote-access-proxy.
3
+ *
4
+ * Mocks the DSH upstream (launch-token exchange + dsh-auth cookie gate) and the
5
+ * DSH 0.1.7 host contract around the plugin — a Cordis Config of volatile
6
+ * references, `loader/volatile-update`, the profile configuration editor, the
7
+ * page-policy call, and the connection service — then exercises the proxy
8
+ * server itself.
9
+ *
10
+ * Run: node test-remote-access-proxy.mjs
11
+ *
12
+ * It loads the plugin beside this file — the copy a pnpm install of this bundle
13
+ * deploys — so module resolution matches production when run from an installed
14
+ * bundle, or after a local install. DSH_PLUGIN points at any other copy. The
15
+ * plugin appends to its access.log; the harness snapshots that file (and clears
16
+ * the rotated access.log.1…) and restores it on exit, so test activity leaves no
17
+ * trace.
18
+ */
19
+
20
+ import http from "node:http"
21
+ import { randomBytes } from "node:crypto"
22
+ import { dirname, join } from "node:path"
23
+ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
24
+ import { networkInterfaces, tmpdir } from "node:os"
25
+ import { fileURLToPath, pathToFileURL } from "node:url"
26
+ import vm from "node:vm"
27
+
28
+ const PLUGIN_URL = process.env.DSH_PLUGIN
29
+ ? pathToFileURL(process.env.DSH_PLUGIN).href
30
+ : new URL("./plugin/dsh-remote-access-proxy.mjs", import.meta.url).href
31
+ const REAL_LOG = join(dirname(fileURLToPath(PLUGIN_URL)), "access.log")
32
+ /** Rotated files the plugin can create; the harness restores the whole ring. */
33
+ const LOG_RING = 5
34
+ let LOG_BEFORE = ""
35
+ try {
36
+ LOG_BEFORE = readFileSync(REAL_LOG, "utf8")
37
+ } catch {
38
+ /* no log yet — the plugin creates it on first write */
39
+ }
40
+ function restoreLog() {
41
+ try {
42
+ writeFileSync(REAL_LOG, LOG_BEFORE)
43
+ } catch {
44
+ /* best effort */
45
+ }
46
+ for (let i = 1; i <= LOG_RING; i += 1) {
47
+ try {
48
+ rmSync(`${REAL_LOG}.${i}`, { force: true })
49
+ } catch {
50
+ /* not there */
51
+ }
52
+ }
53
+ }
54
+ function logSize(suffix = "") {
55
+ try {
56
+ return statSync(REAL_LOG + suffix).size
57
+ } catch {
58
+ return -1
59
+ }
60
+ }
61
+ const LAUNCH_TOKEN = "TEST_LAUNCH_TOKEN_0123456789abcdef0123456789abcdef"
62
+ /** Profile entry id the bundle mounts the host plugin as; also its settings namespace. */
63
+ const ROW_ID = "remote-access-proxy"
64
+ /** Bundle package name (the row page key is `<package>#<row id>`). */
65
+ const BUNDLE = "dsh-remote-access-proxy"
66
+
67
+ let passed = 0
68
+ let failed = 0
69
+ function check(name, cond, extra = "") {
70
+ if (cond) {
71
+ passed += 1
72
+ console.log(` PASS ${name}`)
73
+ } else {
74
+ failed += 1
75
+ console.log(` FAIL ${name} ${extra}`)
76
+ }
77
+ }
78
+ function sleep(ms) {
79
+ return new Promise((r) => setTimeout(r, ms))
80
+ }
81
+
82
+ /** Mock DSH upstream: token exchange + authority-bound cookie gate. */
83
+ function createMockDsh() {
84
+ let validCookie = null // cookie value the mock currently accepts
85
+ let rejectHeld = false // test knob: reject whatever cookie the client holds
86
+ let exchanges = 0 // launch-token exchanges served (proves who was forwarded to)
87
+ const cookieName = "dsh-auth-mock"
88
+
89
+ const server = http.createServer((req, res) => {
90
+ const url = new URL(req.url, "http://dsh.invalid")
91
+ const cookie = (req.headers.cookie || "").split(";").find((c) => c.trim().startsWith(cookieName + "="))
92
+
93
+ // launch-token exchange on GET /?token=...
94
+ if (url.pathname === "/" && url.searchParams.get("token") === LAUNCH_TOKEN && !cookie) {
95
+ exchanges += 1
96
+ validCookie = "v1.mockbody.sig-" + randomBytes(8).toString("hex")
97
+ res.writeHead(303, {
98
+ location: "./",
99
+ "set-cookie": `${cookieName}=${validCookie}; Max-Age=2592000; Path=/; HttpOnly; SameSite=Strict`,
100
+ })
101
+ res.end()
102
+ return
103
+ }
104
+
105
+ const authed = cookie !== undefined && cookie.split("=")[1].trim() === validCookie && !rejectHeld
106
+
107
+ // index: serve only when the cookie validates
108
+ if (url.pathname === "/") {
109
+ if (authed) {
110
+ res.writeHead(200, { "content-type": "text/html" })
111
+ res.end("<html>index ok</html>")
112
+ } else {
113
+ res.writeHead(401, { "content-type": "text/plain" })
114
+ res.end("dsh web authentication required\n")
115
+ }
116
+ return
117
+ }
118
+
119
+ // /api: requires the browser-auth cookie
120
+ if (url.pathname.startsWith("/api/")) {
121
+ if (authed) {
122
+ res.writeHead(200, { "content-type": "application/json" })
123
+ res.end(JSON.stringify({ ok: true, path: url.pathname, q: Object.fromEntries(url.searchParams) }))
124
+ } else {
125
+ res.writeHead(401, { "content-type": "text/plain" })
126
+ res.end("unauthorized\n")
127
+ }
128
+ return
129
+ }
130
+
131
+ res.writeHead(200, { "content-type": "text/plain" })
132
+ res.end("asset " + url.pathname)
133
+ })
134
+
135
+ server.on("upgrade", (req, socket) => {
136
+ const cookie = (req.headers.cookie || "").split(";").find((c) => c.trim().startsWith(cookieName + "="))
137
+ const authed = cookie !== undefined && cookie.split("=")[1].trim() === validCookie && !rejectHeld
138
+ if (!authed) {
139
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n")
140
+ socket.destroy()
141
+ return
142
+ }
143
+ socket.write("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n")
144
+ socket.pipe(socket)
145
+ })
146
+
147
+ return new Promise((resolve) => {
148
+ server.listen(0, "127.0.0.1", () => resolve({
149
+ port: server.address().port,
150
+ server,
151
+ get rejectHeld() {
152
+ return rejectHeld
153
+ },
154
+ set rejectHeld(v) {
155
+ rejectHeld = v
156
+ },
157
+ get exchanges() {
158
+ return exchanges
159
+ },
160
+ }))
161
+ })
162
+ }
163
+
164
+ /**
165
+ * The DSH 0.1.7 host contract around one plugin instance: a Config of volatile
166
+ * `{ get() }` references, the configuration editor that persists edits into the
167
+ * profile patch, the loader notification a volatile-only edit lands as, and the
168
+ * optional settings service this plugin only asks for a page policy.
169
+ *
170
+ * `_commit(patch)` is the Loader half: it moves the references and re-fires
171
+ * `loader/volatile-update`, exactly as `Entry._commitVolatile` does.
172
+ *
173
+ * `webServer` models the host's `webserver` row: `port` is the port the OS
174
+ * actually bound (undefined until `listen` returns), and `active: false` mimics
175
+ * a fiber that has not reached ACTIVE, where the strict `ctx.get` sees nothing.
176
+ */
177
+ function createCtx(initial, connection, { webStartup, webServer, documentPath } = {}) {
178
+ const state = { ...initial }
179
+ const refs = {}
180
+ for (const field of Object.keys(state)) refs[field] = { get: () => state[field] }
181
+ const handlers = new Map()
182
+ const writes = []
183
+ const pagePolicies = []
184
+ const injected = []
185
+ const stats = { escapes: 0, nestedRejections: 0 }
186
+ // Mirrors `dsh-hmr`: an AsyncLocalStorage marking the caller as inside a
187
+ // hot-reload transaction, the `exit()` escape hatch the Loader itself uses
188
+ // (`watchConfig`), and the guard that rejects re-entrant edits.
189
+ let inTransaction = false
190
+ const executing = {
191
+ getStore: () => (inTransaction ? true : undefined),
192
+ run(_value, fn) {
193
+ const before = inTransaction
194
+ inTransaction = true
195
+ try {
196
+ return fn()
197
+ } finally {
198
+ inTransaction = before
199
+ }
200
+ },
201
+ exit(fn) {
202
+ const before = inTransaction
203
+ inTransaction = false
204
+ stats.escapes += 1
205
+ try {
206
+ return fn()
207
+ } finally {
208
+ inTransaction = before
209
+ }
210
+ },
211
+ }
212
+ const hmr = { executing }
213
+ const settingsStub = {
214
+ configure(presentation, owner) {
215
+ pagePolicies.push({ presentation, owner })
216
+ return () => {}
217
+ },
218
+ }
219
+ const ctx = {
220
+ connection,
221
+ fiber: { entry: { options: { id: ROW_ID, name: BUNDLE, config: undefined } } },
222
+ root: { loader: { await: () => Promise.resolve() } },
223
+ get(name, strict = true) {
224
+ if (name === "configEditor") return editor
225
+ if (name === "hmr") return hmr
226
+ if (name === "webStartup") return webStartup
227
+ if (name === "webServer") {
228
+ if (webServer === undefined) return undefined
229
+ return strict && webServer.active === false ? undefined : webServer
230
+ }
231
+ return undefined
232
+ },
233
+ inject(deps, callback) {
234
+ injected.push([...deps])
235
+ if (deps.includes("settings")) callback({ settings: settingsStub, effect: (fn) => { fn() } })
236
+ },
237
+ on(event, fn) {
238
+ const list = handlers.get(event) ?? []
239
+ list.push(fn)
240
+ handlers.set(event, list)
241
+ },
242
+ // Cordis runs an effect at once and keeps the disposer it returns.
243
+ effect(fn) {
244
+ const dispose = fn()
245
+ if (typeof dispose === "function") disposers.push(dispose)
246
+ },
247
+ _refs: refs,
248
+ _state: state,
249
+ /** The live `webserver` row, poked by tests to model the OS binding a port late. */
250
+ _webServer: webServer,
251
+ _writes: writes,
252
+ _pagePolicies: pagePolicies,
253
+ _injected: injected,
254
+ _stats: stats,
255
+ /** Put the caller inside a hot-reload transaction (as the Loader mounts us). */
256
+ _setTransaction(value) {
257
+ inTransaction = value
258
+ },
259
+ /** Loader's volatile commit: move the references, then notify the owner. */
260
+ _commit(patch) {
261
+ Object.assign(state, patch)
262
+ for (const fn of handlers.get("loader/volatile-update") ?? []) fn([["config"]])
263
+ },
264
+ _disposeAll() {
265
+ while (disposers.length > 0) disposers.pop()()
266
+ },
267
+ }
268
+ const disposers = []
269
+ const editor = {
270
+ documentPath,
271
+ async edit(target, change) {
272
+ if (executing.getStore()) {
273
+ stats.nestedRejections += 1
274
+ throw new Error("HMR transactions cannot be nested")
275
+ }
276
+ const next = change({ ...(target.options.config ?? {}) }, {})
277
+ target.options.config = next
278
+ writes.push(next)
279
+ },
280
+ }
281
+ return ctx
282
+ }
283
+
284
+ function httpReq(port, { method = "GET", path = "/", headers = {}, body, hostname = "127.0.0.1" } = {}) {
285
+ return new Promise((resolve, reject) => {
286
+ const req = http.request({ hostname, port, method, path, headers }, (res) => {
287
+ let data = ""
288
+ res.on("data", (c) => (data += c))
289
+ res.on("end", () => resolve({ status: res.statusCode, headers: res.headers, body: data }))
290
+ })
291
+ req.on("error", reject)
292
+ if (body) req.write(body)
293
+ req.end()
294
+ })
295
+ }
296
+
297
+ /** Read the package's own layout around the loaded plugin: bundle patch + metadata. */
298
+ function readPackage(root, pluginDir) {
299
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"))
300
+ const patchRel = pkg.dsh?.bundle?.patch
301
+ const patchPath = typeof patchRel === "string" ? join(root, patchRel) : undefined
302
+ const patch = patchPath !== undefined && existsSync(patchPath) ? readFileSync(patchPath, "utf8") : ""
303
+ const rows = [...patch.matchAll(/id:\s*(\S+)\s*\n\s*name:\s*(\S+)/g)].map((m) => ({ id: m[1], name: m[2] }))
304
+ const clientRel = pkg.exports?.["./client"]
305
+ const clientPath = typeof clientRel === "string" ? join(root, clientRel) : ""
306
+ const clientSource = existsSync(clientPath) ? readFileSync(clientPath, "utf8") : ""
307
+ return { pkg, root, patchPath, patch, rows, clientPath, clientSource }
308
+ }
309
+
310
+ /** A free loopback port, closed again so a listener can bind it deterministically. */
311
+ function freePort() {
312
+ return new Promise((resolve) => {
313
+ const s = http.createServer()
314
+ s.listen(0, "127.0.0.1", () => {
315
+ const p = s.address().port
316
+ s.close(() => resolve(p))
317
+ })
318
+ })
319
+ }
320
+
321
+ async function main() {
322
+ const mock = await createMockDsh()
323
+ console.log(`mock DSH upstream on 127.0.0.1:${mock.port}, launch token present`)
324
+
325
+ const plugin = await import(PLUGIN_URL)
326
+ const pluginDir = dirname(fileURLToPath(PLUGIN_URL))
327
+ const root = dirname(pluginDir)
328
+ const layout = readPackage(root, pluginDir)
329
+
330
+ // Reserve an explicit free port so the proxy's bound port is deterministic.
331
+ const proxyPort = await freePort()
332
+
333
+ // The 0.2 connection also carries the authenticated /api Fetch registry the
334
+ // status route rides; keep every registration so the test can call it.
335
+ const routes = new Map()
336
+ const connection = {
337
+ authenticatedUrl(base) {
338
+ return `${base}/?token=${LAUNCH_TOKEN}`
339
+ },
340
+ fetch: {
341
+ register(route) {
342
+ routes.set(route.path, route)
343
+ return async () => {
344
+ if (routes.get(route.path) === route) routes.delete(route.path)
345
+ }
346
+ },
347
+ },
348
+ }
349
+ /** Read the status document the configuration page polls. */
350
+ async function readStatus() {
351
+ const route = routes.get(plugin.STATUS_ROUTE)
352
+ if (route === undefined) return undefined
353
+ const response = await route.fetch(new Request("http://127.0.0.1" + plugin.STATUS_ROUTE))
354
+ return await response.json()
355
+ }
356
+ // secretPath / cookieValue empty on purpose: the plugin must generate both and
357
+ // persist them into the profile configuration.
358
+ const ctx2 = createCtx(
359
+ {
360
+ enabled: true,
361
+ allowFrom: "",
362
+ password: "",
363
+ listenPort: proxyPort,
364
+ secretPath: "",
365
+ cookieValue: "",
366
+ upstreamHost: "127.0.0.1",
367
+ upstreamPort: mock.port,
368
+ tlsEnabled: false,
369
+ tlsPfxPath: "",
370
+ tlsPassphrase: "",
371
+ logMaxBytes: 1024 * 1024,
372
+ logKeep: 3,
373
+ },
374
+ connection,
375
+ )
376
+ // The Loader mounts this plugin inside an HMR transaction, and every volatile
377
+ // commit arrives the same way; `configEditor.edit` refuses to run re-entrantly
378
+ // ("HMR transactions cannot be nested"), so the write-back has to leave that
379
+ // context first. Stay inside a transaction for the whole run to prove it.
380
+ ctx2._setTransaction(true)
381
+ plugin.apply(ctx2, ctx2._refs)
382
+ console.log(`proxy listening on 127.0.0.1:${proxyPort}`)
383
+ await sleep(150)
384
+
385
+ // The generated values must be exactly what the gate accepts, and exactly what
386
+ // went into the profile configuration.
387
+ const writeBack = ctx2._writes[0] ?? {}
388
+ const generatedPath = ctx2._state.secretPath || writeBack.secretPath
389
+ const generatedCookie = ctx2._state.cookieValue || writeBack.cookieValue
390
+ check("generates a gate path and persists it to the profile", typeof writeBack.secretPath === "string" && writeBack.secretPath.length === 12, JSON.stringify(writeBack))
391
+ check("generates a gate cookie value and persists it", typeof writeBack.cookieValue === "string" && writeBack.cookieValue.length === 24, JSON.stringify(writeBack))
392
+ check(
393
+ "escapes the hot-reload transaction before editing the profile",
394
+ ctx2._stats.escapes >= 1 && ctx2._stats.nestedRejections === 0,
395
+ JSON.stringify(ctx2._stats),
396
+ )
397
+ // Emulate Loader reconciling the profile patch: commit what was written.
398
+ ctx2._commit(writeBack)
399
+ await sleep(150)
400
+
401
+ // 1) ENTRY without any cookie -> 200 index + gate cookie set, DSH session warmed.
402
+ let r = await httpReq(proxyPort, { path: `/${generatedPath}/` })
403
+ check("entry through the generated path -> 200 index", r.status === 200 && r.body.includes("index ok"), `got ${r.status} ${r.body}`)
404
+ const minted = Array.isArray(r.headers["set-cookie"]) ? r.headers["set-cookie"][0] : ""
405
+ check(
406
+ "entry remembers the device with a signed 30-day cookie, not the raw secret",
407
+ /^dsh_et_gate=[0-9a-z]+\.[A-Za-z0-9_-]{43};/.test(minted) && minted.includes("Max-Age=2592000") && minted.includes("HttpOnly") && !minted.includes(generatedCookie),
408
+ minted,
409
+ )
410
+ const GATE = minted.split(";")[0]
411
+ r = await httpReq(proxyPort, { path: "/api/session/me", headers: { cookie: `dsh_et_gate=${generatedCookie}` } })
412
+ check("the raw server secret is not a valid cookie", r.status === 403, `got ${r.status}`)
413
+
414
+ // 1b) the gate path without its trailing slash redirects into it, so the
415
+ // client's `<base href="./">` assets stay under the gate.
416
+ r = await httpReq(proxyPort, { path: `/${generatedPath}` })
417
+ check("gate path without a slash -> 308 to the slash form", r.status === 308 && r.headers.location === `/${generatedPath}/`, `status=${r.status} loc=${r.headers.location}`)
418
+
419
+ // 1c) the status route the page reads: running, with the address to open.
420
+ let status = await readStatus()
421
+ check("status route is registered on the authenticated /api channel", status !== undefined && plugin.STATUS_ROUTE === "/api/remote-access-proxy/status")
422
+ check(
423
+ "status reports listening with the entry address",
424
+ status?.state === "listening" && Array.isArray(status.urls) && status.urls.length > 0 && status.urls.every((url) => url.startsWith("http://") && url.endsWith(`:${proxyPort}/${generatedPath}/`)),
425
+ JSON.stringify(status),
426
+ )
427
+
428
+ // 2) PASS /api with only the gate cookie -> 200, DSH cookie injected upstream.
429
+ r = await httpReq(proxyPort, { path: "/api/session/me", headers: { cookie: GATE } })
430
+ check("pass /api with gate cookie -> 200", r.status === 200 && r.body.includes('"ok":true'), `got ${r.status} ${r.body}`)
431
+
432
+ // 3) no cookie at all -> 403 (gate denies before upstream).
433
+ r = await httpReq(proxyPort, { path: "/api/session/me" })
434
+ check("api without cookie -> 403", r.status === 403, `got ${r.status}`)
435
+
436
+ // 4) entry with token in query -> token stripped upstream, still 200.
437
+ r = await httpReq(proxyPort, { path: `/${generatedPath}/?token=` + LAUNCH_TOKEN, headers: { cookie: GATE } })
438
+ check("entry with ?token= still 200", r.status === 200, `got ${r.status}`)
439
+ r = await httpReq(proxyPort, { path: "/api/tokencode", headers: { cookie: GATE } })
440
+ const q = JSON.parse(r.body)
441
+ check("token query stripped upstream", !("token" in q.q), `q=${JSON.stringify(q.q)}`)
442
+
443
+ // 5) Location rewrite on entry: an upstream 303 Location '/' becomes the gate prefix.
444
+ const mocked303 = await new Promise((resolve) => {
445
+ const orig = http.createServer((req, res) => {
446
+ const url = new URL(req.url, "http://x")
447
+ if (url.pathname === "/") {
448
+ res.writeHead(303, { location: "/" })
449
+ res.end()
450
+ return
451
+ }
452
+ res.writeHead(200)
453
+ res.end("ok")
454
+ })
455
+ orig.listen(0, "127.0.0.1", () => resolve({ port: orig.address().port, server: orig }))
456
+ })
457
+ ctx2._commit({ upstreamPort: mocked303.port })
458
+ await sleep(150)
459
+ r = await httpReq(proxyPort, { path: `/${generatedPath}/?token=` + LAUNCH_TOKEN })
460
+ check("303 location rewritten to the gate prefix", r.status === 303 && r.headers.location === `/${generatedPath}/`, `status=${r.status} loc=${r.headers.location}`)
461
+ ctx2._commit({ upstreamPort: mock.port })
462
+ mocked303.server.close()
463
+ await sleep(150)
464
+
465
+ // 6) self-heal: mock rejects the held cookie -> 401, then the next request re-exchanges -> 200.
466
+ mock.rejectHeld = true
467
+ r = await httpReq(proxyPort, { path: "/api/selfheal", headers: { cookie: GATE } })
468
+ check("stale cookie -> upstream 401 surfaces", r.status === 401, `got ${r.status}`)
469
+ await sleep(200) // background re-exchange
470
+ mock.rejectHeld = false
471
+ r = await httpReq(proxyPort, { path: "/api/selfheal", headers: { cookie: GATE } })
472
+ check("self-heal: next request re-auths -> 200", r.status === 200, `got ${r.status} ${r.body}`)
473
+
474
+ // 7) WS upgrade passes with the proxy-held cookie.
475
+ const wsStatus = await new Promise((resolve) => {
476
+ const req = http.request({ hostname: "127.0.0.1", port: proxyPort, path: "/wsx", headers: { connection: "Upgrade", upgrade: "websocket", cookie: GATE } })
477
+ req.on("upgrade", (res, socket) => {
478
+ socket.destroy()
479
+ resolve(res.statusCode)
480
+ })
481
+ req.on("response", (res) => {
482
+ resolve(res.statusCode)
483
+ })
484
+ req.on("error", () => resolve("error"))
485
+ req.end()
486
+ })
487
+ check("ws upgrade passes with the gate cookie", wsStatus === 101, `got ${wsStatus}`)
488
+
489
+ // 8) a volatile `enabled: false` commit stops the listener.
490
+ ctx2._commit({ enabled: false })
491
+ await sleep(150)
492
+ const afterDisable = await httpReq(proxyPort, { path: `/${generatedPath}/` }).catch(() => ({ status: "conn-error" }))
493
+ check("volatile enabled=false stops the listener", afterDisable.status === "conn-error", `got ${afterDisable.status}`)
494
+ status = await readStatus()
495
+ check("status reports disabled", status?.state === "disabled", JSON.stringify(status))
496
+
497
+ // 8a) problems the page has to explain instead of failing silently.
498
+ const badCtx = createCtx(
499
+ { ...ctx2._state, enabled: true, listenPort: await freePort(), secretPath: "has/slash", cookieValue: "validcookie123" },
500
+ connection,
501
+ )
502
+ routes.clear()
503
+ plugin.apply(badCtx, badCtx._refs)
504
+ await sleep(100)
505
+ status = await readStatus()
506
+ check("an invalid gate path is refused with a reason", status?.state === "error" && status.code === "bad-secret-path", JSON.stringify(status))
507
+ badCtx._disposeAll()
508
+
509
+ // Reserve the same dual-stack wildcard address as the real listener.
510
+ // Windows may allow a wildcard listener alongside an IPv4-only fixture.
511
+ const blocker = http.createServer()
512
+ await new Promise(resolve => blocker.listen(0, "::", resolve))
513
+ const busyPort = blocker.address().port
514
+ const busyCtx = createCtx(
515
+ { ...ctx2._state, enabled: true, listenPort: busyPort, secretPath: "abcd1234", cookieValue: "validcookie123" },
516
+ connection,
517
+ )
518
+ plugin.apply(busyCtx, busyCtx._refs)
519
+ // A busy port is retried for about two seconds (a hot-swapped predecessor may
520
+ // still be releasing it) before it is reported.
521
+ for (let i = 0; i < 40; i += 1) {
522
+ await sleep(100)
523
+ status = await readStatus()
524
+ if (status?.state === "error") break
525
+ }
526
+ check("a busy port is reported, not swallowed", status?.state === "error" && status.code === "port-in-use" && status.listenPort === busyPort, JSON.stringify(status))
527
+ busyCtx._disposeAll()
528
+ await new Promise(resolve => blocker.close(resolve))
529
+
530
+ // 8a') migration from the pre-1.2 row: the user patch names the module by an
531
+ // absolute file URL (copied there by the configuration editor). It must be
532
+ // rewritten to the package name, and the saved gate values must survive —
533
+ // no fresh secrets minted while the loader brings them back.
534
+ const migrationDir = mkdtempSync(join(tmpdir(), "rap-migrate-"))
535
+ const userPatch = join(migrationDir, "cordis.patch.yml")
536
+ writeFileSync(userPatch, [
537
+ "- id: remote-access-proxy",
538
+ " name: file:///C:/old/place/node_modules/dsh-remote-access-proxy/plugin/dsh-remote-access-proxy.mjs",
539
+ " config:",
540
+ " secretPath: keepme123",
541
+ " cookieValue: keepcookie12345",
542
+ "- id: something-else",
543
+ " name: ./plugin/dsh-remote-access-proxy.mjs",
544
+ "",
545
+ ].join("\n"))
546
+ const migratePort = await freePort()
547
+ const migrateCtx = createCtx(
548
+ { ...ctx2._state, enabled: true, listenPort: migratePort, secretPath: "", cookieValue: "" },
549
+ connection,
550
+ { documentPath: userPatch },
551
+ )
552
+ plugin.apply(migrateCtx, migrateCtx._refs)
553
+ await sleep(150)
554
+ const migrated = readFileSync(userPatch, "utf8")
555
+ check("migration rewrites the stale absolute module name", migrated.includes(" name: dsh-remote-access-proxy\n config:") && !migrated.includes("file:///C:/old"), migrated)
556
+ check("migration leaves other rows alone", migrated.includes("- id: something-else\n name: ./plugin/dsh-remote-access-proxy.mjs"), migrated)
557
+ check("no replacement secrets are minted while the saved ones load", migrateCtx._writes.length === 0, JSON.stringify(migrateCtx._writes))
558
+ migrateCtx._commit({ secretPath: "keepme123", cookieValue: "keepcookie12345" })
559
+ await sleep(150)
560
+ r = await httpReq(migratePort, { path: "/keepme123/" })
561
+ check("the saved gate path still works after migration", r.status === 200 && migrateCtx._writes.length === 0, `got ${r.status}`)
562
+ migrateCtx._disposeAll()
563
+ rmSync(migrationDir, { recursive: true, force: true })
564
+
565
+ // 8b) upstream auto-detect: an empty host / port 0 follow the running
566
+ // instance's published `webStartup` address instead of the historical 3080
567
+ // default — this is what makes `dsh web` work without any configuration.
568
+ const autoMock = await createMockDsh()
569
+ const autoPort = await freePort()
570
+ const ctx3 = createCtx(
571
+ {
572
+ enabled: true,
573
+ allowFrom: "",
574
+ password: "",
575
+ listenPort: autoPort,
576
+ secretPath: "",
577
+ cookieValue: "",
578
+ upstreamHost: "",
579
+ upstreamPort: 0,
580
+ tlsEnabled: false,
581
+ tlsPfxPath: "",
582
+ tlsPassphrase: "",
583
+ logMaxBytes: 1024 * 1024,
584
+ logKeep: 3,
585
+ },
586
+ connection,
587
+ { webStartup: { host: "127.0.0.1", port: autoMock.port } },
588
+ )
589
+ plugin.apply(ctx3, ctx3._refs)
590
+ await sleep(150)
591
+ check("auto-detects the upstream from webStartup", autoMock.exchanges > 0, `exchanges=${autoMock.exchanges}`)
592
+ const autoPath = ctx3._writes[0]?.secretPath
593
+ const autoGate = `dsh_et_gate=${ctx3._writes[0]?.cookieValue}`
594
+ r = await httpReq(autoPort, { path: `/${autoPath}/`, headers: { cookie: autoGate } })
595
+ check("the auto-detected proxy serves the entry", r.status === 200 && r.body.includes("index ok"), `got ${r.status} ${r.body}`)
596
+ let autoLogged = true
597
+ try {
598
+ autoLogged = readFileSync(REAL_LOG, "utf8").includes(`upstream 127.0.0.1:${autoMock.port} (auto)`)
599
+ } catch {
600
+ autoLogged = false
601
+ }
602
+ check("logs the auto-detected upstream", autoLogged)
603
+
604
+ // An explicit address always wins over the live webStartup one.
605
+ const explicitPort = await freePort()
606
+ const mockBefore = mock.exchanges
607
+ const autoBefore = autoMock.exchanges
608
+ const ctx4 = createCtx(
609
+ {
610
+ enabled: true,
611
+ allowFrom: "",
612
+ password: "",
613
+ listenPort: explicitPort,
614
+ secretPath: "",
615
+ cookieValue: "",
616
+ upstreamHost: "127.0.0.1",
617
+ upstreamPort: mock.port,
618
+ tlsEnabled: false,
619
+ tlsPfxPath: "",
620
+ tlsPassphrase: "",
621
+ logMaxBytes: 1024 * 1024,
622
+ logKeep: 3,
623
+ },
624
+ connection,
625
+ { webStartup: { host: "127.0.0.1", port: autoMock.port } },
626
+ )
627
+ plugin.apply(ctx4, ctx4._refs)
628
+ await sleep(150)
629
+ check(
630
+ "an explicit upstream wins over webStartup",
631
+ mock.exchanges > mockBefore && autoMock.exchanges === autoBefore,
632
+ `mock=${mock.exchanges} auto=${autoMock.exchanges}`,
633
+ )
634
+ ctx3._disposeAll()
635
+ ctx4._disposeAll()
636
+ autoMock.server.close()
637
+ await sleep(50)
638
+
639
+ // 8c) `--port 0` hosts (the desktop launcher) publish `webStartup.port === 0`:
640
+ // the requested "let the OS pick one", not a connectable port. The bound
641
+ // port lives on `webServer` instead, and a proxy that ignored it forwarded
642
+ // to the 3080 fallback and answered 502 bad gateway on every request.
643
+ const zeroMock = await createMockDsh()
644
+ const zeroPort = await freePort()
645
+ const ctx5 = createCtx(
646
+ {
647
+ enabled: true,
648
+ allowFrom: "",
649
+ password: "",
650
+ listenPort: zeroPort,
651
+ secretPath: "",
652
+ cookieValue: "",
653
+ upstreamHost: "",
654
+ upstreamPort: 0,
655
+ tlsEnabled: false,
656
+ tlsPfxPath: "",
657
+ tlsPassphrase: "",
658
+ logMaxBytes: 1024 * 1024,
659
+ logKeep: 3,
660
+ },
661
+ connection,
662
+ {
663
+ webStartup: { host: "127.0.0.1", port: 0 },
664
+ webServer: { host: "127.0.0.1", port: zeroMock.port },
665
+ },
666
+ )
667
+ plugin.apply(ctx5, ctx5._refs)
668
+ await sleep(150)
669
+ check("port 0 upstream reads the bound port from webServer", zeroMock.exchanges > 0, `exchanges=${zeroMock.exchanges}`)
670
+ const zeroPath = ctx5._writes[0]?.secretPath
671
+ const zeroGate = `dsh_et_gate=${ctx5._writes[0]?.cookieValue}`
672
+ r = await httpReq(zeroPort, { path: `/${zeroPath}/`, headers: { cookie: zeroGate } })
673
+ check(
674
+ "a --port 0 host is reachable through the gate instead of 502",
675
+ r.status === 200 && r.body.includes("index ok"),
676
+ `got ${r.status} ${String(r.body).slice(0, 60)}`,
677
+ )
678
+ let zeroLogged = true
679
+ try {
680
+ zeroLogged = readFileSync(REAL_LOG, "utf8").includes(`upstream 127.0.0.1:${zeroMock.port} (auto)`)
681
+ } catch {
682
+ zeroLogged = false
683
+ }
684
+ check("logs the webServer-bound upstream", zeroLogged)
685
+
686
+ // The window between plugin start and the OS binding a port: the service exists
687
+ // but has no port yet, so the start-time resolution still lands on the 3080
688
+ // fallback. The address is re-resolved per request, so the proxy recovers on its
689
+ // own — no timer, no restart, and no 502 once the port appears.
690
+ const latePort = await freePort()
691
+ const lateCtx = createCtx(
692
+ {
693
+ enabled: true,
694
+ allowFrom: "",
695
+ password: "",
696
+ listenPort: latePort,
697
+ secretPath: "lateretry1234",
698
+ cookieValue: "latesecret12345",
699
+ upstreamHost: "",
700
+ upstreamPort: 0,
701
+ tlsEnabled: false,
702
+ tlsPfxPath: "",
703
+ tlsPassphrase: "",
704
+ logMaxBytes: 1024 * 1024,
705
+ logKeep: 3,
706
+ },
707
+ connection,
708
+ { webStartup: { host: "127.0.0.1", port: 0 }, webServer: { host: "127.0.0.1", active: false } },
709
+ )
710
+ plugin.apply(lateCtx, lateCtx._refs)
711
+ await sleep(100)
712
+ const lateGate = "dsh_et_gate=latesecret12345"
713
+ r = await httpReq(latePort, { path: "/lateretry1234/", headers: { cookie: lateGate } })
714
+ check(
715
+ "an unbound upstream does not serve the entry while the port is unknown",
716
+ !(r.status === 200 && String(r.body).includes("index ok")),
717
+ `got ${r.status}`,
718
+ )
719
+ const earlyMock = await createMockDsh()
720
+ lateCtx._webServer.port = earlyMock.port
721
+ lateCtx._webServer.active = true
722
+ r = await httpReq(latePort, { path: "/lateretry1234/", headers: { cookie: lateGate } })
723
+ check(
724
+ "re-resolves the upstream once the OS-assigned port shows up",
725
+ r.status === 200 && r.body.includes("index ok") && earlyMock.exchanges > 0,
726
+ `got ${r.status} exchanges=${earlyMock.exchanges}`,
727
+ )
728
+ lateCtx._disposeAll()
729
+ earlyMock.server.close()
730
+ ctx5._disposeAll()
731
+ zeroMock.server.close()
732
+ await sleep(50)
733
+
734
+ // 9) access.log rotation: a tiny cap must rotate into access.log.1 and keep the
735
+ // live file under the cap. Each gate miss logs one DENY line.
736
+ ctx2._commit({ enabled: true, logMaxBytes: 300, logKeep: 2 })
737
+ await sleep(150)
738
+ for (let i = 0; i < 12; i += 1) await httpReq(proxyPort, { path: "/logfill" })
739
+ const liveSize = logSize()
740
+ check("rotation keeps the live log under the cap", liveSize > 0 && liveSize <= 300, `size=${liveSize}`)
741
+ check("rotation archived the previous log", logSize(".1") > 0, `size=${logSize(".1")}`)
742
+ check("rotation honours logKeep (no .3 with keep=2)", !existsSync(`${REAL_LOG}.3`))
743
+
744
+ // 10) logMaxBytes=0 is the off switch: the log grows again, nothing rotates.
745
+ rmSync(`${REAL_LOG}.1`, { force: true })
746
+ ctx2._commit({ enabled: true, logMaxBytes: 0, logKeep: 2 })
747
+ await sleep(150)
748
+ for (let i = 0; i < 12; i += 1) await httpReq(proxyPort, { path: "/logfill" })
749
+ check("logMaxBytes=0 disables rotation", logSize() > 300 && !existsSync(`${REAL_LOG}.1`), `size=${logSize()}`)
750
+
751
+ ctx2._disposeAll()
752
+ mock.server.close()
753
+ const pwMock = await createMockDsh()
754
+
755
+ // ---- the 0.1.7 host contract ------------------------------------------------
756
+
757
+ check("inject requires connection and no settings service", Array.isArray(plugin.inject) && plugin.inject.length === 1 && plugin.inject[0] === "connection", JSON.stringify(plugin.inject))
758
+
759
+ const policies = ctx2._pagePolicies
760
+ check(
761
+ "registers its own page policy (settings.configure({auto:false}))",
762
+ policies.length === 1 && policies[0].presentation?.auto === false && policies[0].owner === ctx2.fiber,
763
+ JSON.stringify(policies.map((p) => p.presentation)),
764
+ )
765
+
766
+ const dict = plugin.Config?.dict
767
+ const fields = Object.keys(dict ?? {})
768
+ const ordinary = fields.filter((field) => dict[field]?.meta?.volatile !== true)
769
+ check("Config declares a schema", fields.length > 0, `fields=${fields.length}`)
770
+ check("every Config field is volatile (edits never remount)", fields.length > 0 && ordinary.length === 0, `non-volatile: ${ordinary.join(", ")}`)
771
+
772
+ // The real schemastery must hand back live references, and the serialized
773
+ // schema must survive the form projection the settings service performs.
774
+ const defaults = plugin.Config({})
775
+ check(
776
+ "Config parses into live references carrying their schema defaults",
777
+ typeof defaults.allowFrom?.get === "function" && defaults.allowFrom.get() === "" && defaults.password.get() === "" && defaults.listenHost === undefined
778
+ && typeof defaults.enabled?.get === "function" && defaults.enabled.get() === true
779
+ && defaults.logMaxBytes.get() === 1048576
780
+ && defaults.upstreamHost.get() === "" && defaults.upstreamPort.get() === 0,
781
+ JSON.stringify({ allowFrom: defaults.allowFrom?.get?.(), logMaxBytes: defaults.logMaxBytes?.get?.(), upstreamPort: defaults.upstreamPort?.get?.() }),
782
+ )
783
+ let serializes = true
784
+ try {
785
+ if (typeof plugin.Config.toJSON !== "function") serializes = false
786
+ else plugin.Config.toJSON()
787
+ } catch (_error) {
788
+ serializes = false
789
+ }
790
+ check("Config serializes for the settings form projection", serializes)
791
+
792
+ // ---- 1.3: access password, allow list -------------------------------------
793
+ const pwPort = await freePort()
794
+ const pwCtx = createCtx(
795
+ { ...ctx2._state, enabled: true, listenPort: pwPort, secretPath: "pwgate1234", cookieValue: "pwsecret12345", password: "open sesame", upstreamHost: "127.0.0.1", upstreamPort: pwMock.port },
796
+ connection,
797
+ )
798
+ plugin.apply(pwCtx, pwCtx._refs)
799
+ await sleep(150)
800
+ const html = { accept: "text/html", "accept-language": "zh-CN,zh;q=0.9" }
801
+ r = await httpReq(pwPort, { path: "/pwgate1234/", headers: html })
802
+ check("with a password the entry shows the login page", r.status === 200 && r.body.includes('type="password"') && r.body.includes("输入访问密码") && !r.body.includes("index ok"), `got ${r.status}`)
803
+ check("the login page sets no cookie", r.headers["set-cookie"] === undefined)
804
+ r = await httpReq(pwPort, { path: "/pwgate1234/api/session/me" })
805
+ check("an API call before login is 401", r.status === 401, `got ${r.status}`)
806
+ r = await httpReq(pwPort, { path: "/pwgate1234/session/abc", headers: html })
807
+ check("a deep link keeps where it was going", r.body.includes('name="next" value="/pwgate1234/session/abc"'))
808
+ const form = (pw, next = "/pwgate1234/session/abc") => new URLSearchParams({ username: "dsh", password: pw, next }).toString()
809
+ const post = (body, address) => httpReq(pwPort, { method: "POST", path: "/pwgate1234/__rap/login", headers: { "content-type": "application/x-www-form-urlencoded", ...html }, body })
810
+ r = await post(form("wrong"))
811
+ check("a wrong password is refused on the login page", r.status === 401 && r.body.includes("密码不对") && r.headers["set-cookie"] === undefined, `got ${r.status}`)
812
+ r = await post(form("open sesame"))
813
+ const pwCookie = Array.isArray(r.headers["set-cookie"]) ? r.headers["set-cookie"][0].split(";")[0] : ""
814
+ check("the right password signs in and returns to the deep link", r.status === 303 && r.headers.location === "/pwgate1234/session/abc" && pwCookie.startsWith("dsh_et_gate="), `got ${r.status} ${r.headers.location}`)
815
+ r = await post(form("open sesame", "https://evil.example/"))
816
+ check("login never redirects off the gate path", r.headers.location === "/pwgate1234/", `loc=${r.headers.location}`)
817
+ r = await httpReq(pwPort, { path: "/pwgate1234/", headers: { ...html, cookie: pwCookie } })
818
+ check("a signed-in device opens DSH", r.status === 200 && r.body.includes("index ok"), `got ${r.status}`)
819
+ r = await httpReq(pwPort, { path: "/api/session/me", headers: { cookie: pwCookie } })
820
+ check("a signed-in device passes outside the path too", r.status === 200, `got ${r.status}`)
821
+ const pwWs = await new Promise((resolve) => {
822
+ const req = http.request({ hostname: "127.0.0.1", port: pwPort, path: "/pwgate1234/ws", headers: { connection: "Upgrade", upgrade: "websocket" } })
823
+ req.on("upgrade", (res, socket) => { socket.destroy(); resolve(res.statusCode) })
824
+ req.on("response", (res) => resolve(res.statusCode))
825
+ req.on("error", () => resolve("error"))
826
+ req.end()
827
+ })
828
+ check("a websocket without login is refused even on the gate path", pwWs === 403, `got ${pwWs}`)
829
+ r = await httpReq(pwPort, { path: "/pwgate1234/__rap/logout", headers: { cookie: pwCookie } })
830
+ check("logout clears the cookie", r.status === 303 && String(r.headers["set-cookie"]).includes("Max-Age=0"), `got ${r.status}`)
831
+ pwCtx._commit({ password: "new password" })
832
+ await sleep(150)
833
+ r = await httpReq(pwPort, { path: "/api/session/me", headers: { cookie: pwCookie } })
834
+ check("changing the password signs every device out", r.status === 403, `got ${r.status}`)
835
+ for (let i = 0; i < 5; i += 1) await post(form("guess" + i))
836
+ r = await post(form("new password"))
837
+ check("too many wrong passwords lock the address for a while", r.status === 429 && r.body.includes("尝试次数太多"), `got ${r.status}`)
838
+ status = await readStatus()
839
+ check("status says a password is set", status?.password === true, JSON.stringify(status))
840
+
841
+ // Allow list: loopback always passes; an outside address is dropped.
842
+ const external = Object.values(networkInterfaces()).flat().find((item) => item && (item.family === "IPv4" || item.family === 4) && !item.internal)?.address
843
+ pwCtx._commit({ password: "", allowFrom: "203.0.113.0/24" })
844
+ await sleep(150)
845
+ r = await httpReq(pwPort, { path: "/pwgate1234/" })
846
+ check("loopback is always allowed", r.status === 200, `got ${r.status}`)
847
+ if (external !== undefined) {
848
+ r = await httpReq(pwPort, { path: "/pwgate1234/", hostname: external }).catch((error) => ({ status: error.code }))
849
+ check("an address outside the allow list is dropped", r.status === "ECONNRESET" || r.status === "EPIPE", `got ${r.status} from ${external}`)
850
+ status = await readStatus()
851
+ check("the page learns which address was turned away", status?.lastDenied?.address === external, JSON.stringify(status?.lastDenied))
852
+ pwCtx._commit({ allowFrom: `203.0.113.0/24, ${external}` })
853
+ await sleep(150)
854
+ r = await httpReq(pwPort, { path: "/pwgate1234/", hostname: external })
855
+ check("adding the address lets it in (and the listener covers every interface)", r.status === 200, `got ${r.status}`)
856
+ }
857
+ pwCtx._commit({ allowFrom: "192.168.1.0/24, not-an-ip" })
858
+ await sleep(100)
859
+ status = await readStatus()
860
+ check("a bad allow-list entry is refused with a reason", status?.state === "error" && status.code === "bad-allow" && status.detail === "not-an-ip", JSON.stringify(status))
861
+ const parsed = plugin.parseAllowList("10.0.0.0/8 192.168.1.10-192.168.1.20; fd00::/8,1.2.3.4")
862
+ check(
863
+ "allow list understands IPs, CIDR blocks, ranges and IPv4-mapped clients",
864
+ parsed.bad.length === 0
865
+ && plugin.addressAllowed(parsed.list, "::ffff:10.9.8.7")
866
+ && plugin.addressAllowed(parsed.list, "192.168.1.15")
867
+ && !plugin.addressAllowed(parsed.list, "192.168.1.21")
868
+ && plugin.addressAllowed(parsed.list, "fd12::3")
869
+ && !plugin.addressAllowed(parsed.list, "8.8.8.8")
870
+ && plugin.addressAllowed(plugin.parseAllowList("").list, "8.8.8.8"),
871
+ )
872
+ pwCtx._disposeAll()
873
+ pwMock.server.close()
874
+
875
+ check("generated values are written exactly once", ctx2._writes.length === 1, `writes=${ctx2._writes.length}`)
876
+
877
+ const hostRow = layout.rows.find((row) => row.id === ROW_ID)
878
+ check("bundle patch declares the host row as the settings namespace", hostRow !== undefined && layout.rows.length === 1, JSON.stringify(layout.rows))
879
+ check("the row names the package, never a path (portable installs)", hostRow?.name === BUNDLE, JSON.stringify(layout.rows))
880
+ check(
881
+ "the package root is the host plugin and the client page",
882
+ layout.pkg.name === BUNDLE
883
+ && typeof layout.pkg.exports?.["."] === "string" && join(root, layout.pkg.exports["."]) === fileURLToPath(PLUGIN_URL)
884
+ && existsSync(layout.clientPath)
885
+ && layout.pkg.dsh?.client?.platform === "web",
886
+ JSON.stringify(layout.pkg.exports),
887
+ )
888
+ const tokens = new Set(Array.from({ length: 50 }, () => plugin.genToken(12)))
889
+ check("generated secrets are random lowercase alphanumerics", tokens.size === 50 && [...tokens].every((token) => /^[a-z0-9]{12}$/.test(token)))
890
+ check("package metadata is packaged (icon + locale dictionaries)", [
891
+ layout.pkg.icon,
892
+ "locale/en.json",
893
+ "locale/zh.json",
894
+ ].every((rel) => typeof rel === "string" && rel !== "" && existsSync(join(root, rel))), JSON.stringify({ icon: layout.pkg.icon }))
895
+
896
+ const clientSource = layout.clientSource
897
+ let clientParses = clientSource !== ""
898
+ try {
899
+ new vm.Script(clientSource)
900
+ } catch (error) {
901
+ clientParses = false
902
+ console.log(` client parse error: ${error.message}`)
903
+ }
904
+ check("the browser half is a valid bundle script", clientParses)
905
+ check(
906
+ "the browser half registers the row page on the 0.1.7 slots",
907
+ clientSource.includes('"plugins.row.config"')
908
+ && clientSource.includes('BUNDLE + "#" + NS')
909
+ && clientSource.includes(`"${BUNDLE}"`)
910
+ && clientSource.includes(`"${ROW_ID}"`)
911
+ && clientSource.includes("configForms")
912
+ && clientSource.includes("whileServed"),
913
+ )
914
+ check(
915
+ "the browser half renders booleans as switches and reads the status route",
916
+ clientSource.includes('entry.kind === "boolean"')
917
+ && !clientSource.includes("entry.spec.kind")
918
+ && clientSource.includes('"' + plugin.STATUS_ROUTE.slice(1) + '"'),
919
+ )
920
+ const upMock = await createMockDsh()
921
+ const upRoutes = new Map()
922
+ const upConnection = {
923
+ authenticatedUrl: connection.authenticatedUrl,
924
+ fetch: { register(route) { upRoutes.set(route.path, route); return () => upRoutes.delete(route.path) } },
925
+ }
926
+ const upStatus = async () => {
927
+ const route = upRoutes.get(plugin.STATUS_ROUTE)
928
+ if (route === undefined) return undefined
929
+ return await (await route.fetch(new Request("http://127.0.0.1/x"))).json()
930
+ }
931
+ const upPort = await freePort()
932
+ const upState = { ...ctx2._state, enabled: true, listenPort: upPort, secretPath: "upgate1234", cookieValue: "upsecret12345", password: "", allowFrom: "", upstreamHost: "127.0.0.1", upstreamPort: upMock.port }
933
+ let upCtx = createCtx(upState, upConnection)
934
+ plugin.apply(upCtx, upCtx._refs)
935
+ let up = await upStatus()
936
+ check("status carries the running version without a private updater", up?.version === layout.pkg.version && up.update === undefined && !upRoutes.has("/api/remote-access-proxy/update-check"))
937
+ await sleep(150)
938
+
939
+ // A hot swap disposes this instance and starts the new version on the same
940
+ // port at once: dispose must end the listener and every connection.
941
+ const keepAlive = new http.Agent({ keepAlive: true })
942
+ const kaRes = await new Promise((resolve, reject) => {
943
+ http.get({ hostname: "127.0.0.1", port: upPort, path: "/upgate1234/", agent: keepAlive }, (res) => {
944
+ res.resume()
945
+ res.on("end", () => resolve(res))
946
+ }).on("error", reject)
947
+ })
948
+ const kaSocket = Object.values(keepAlive.freeSockets).flat()[0]
949
+ const wsSocket = await new Promise((resolve) => {
950
+ const req = http.request({ hostname: "127.0.0.1", port: upPort, path: "/upgate1234/ws", headers: { connection: "Upgrade", upgrade: "websocket" } })
951
+ req.on("upgrade", (_res, socket) => resolve(socket))
952
+ req.on("response", () => resolve(undefined))
953
+ req.on("error", () => resolve(undefined))
954
+ req.end()
955
+ })
956
+ const closed = (socket) => new Promise((resolve) => {
957
+ if (socket === undefined) return resolve(false)
958
+ if (socket.destroyed) return resolve(true)
959
+ socket.once("close", () => resolve(true))
960
+ setTimeout(() => resolve(socket.destroyed), 1000)
961
+ })
962
+ const kaClosed = closed(kaSocket)
963
+ const wsClosed = closed(wsSocket)
964
+ upCtx._disposeAll()
965
+ check("dispose ends keep-alive connections at once", kaRes.statusCode === 200 && (await kaClosed) === true)
966
+ check("dispose ends bridged websockets at once", (await wsClosed) === true)
967
+ // The replacement binds the same port immediately.
968
+ upCtx = createCtx(upState, upConnection)
969
+ plugin.apply(upCtx, upCtx._refs)
970
+ await sleep(150)
971
+ up = await upStatus()
972
+ const swapped = await httpReq(upPort, { path: "/upgate1234/" }).catch((error) => ({ status: error.code }))
973
+ check("the replacing instance listens on the same port right away, same gate", up?.state === "listening" && swapped.status === 200, `${up?.state} ${swapped.status}`)
974
+ upCtx._disposeAll()
975
+ keepAlive.destroy()
976
+ upMock.server.close()
977
+
978
+ check(
979
+ "every Config field is editable in the browser half",
980
+ fields.every((field) => clientSource.includes(`"${field}"`)),
981
+ `missing: ${fields.filter((field) => !clientSource.includes(`"${field}"`)).join(", ")}`,
982
+ )
983
+
984
+ restoreLog()
985
+ console.log(`\n${passed} passed, ${failed} failed`)
986
+ process.exit(failed ? 1 : 0)
987
+ }
988
+
989
+ main().catch((err) => {
990
+ restoreLog()
991
+ console.error("harness error:", err)
992
+ process.exit(1)
993
+ })