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.
- package/LICENSE +21 -0
- package/README.md +161 -2
- package/README.zh-CN.md +164 -0
- package/client/index.js +541 -0
- package/config/cordis.patch.example.yml +43 -0
- package/cordis.patch.yml +18 -0
- package/icon.svg +4 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +66 -4
- package/plugin/dsh-remote-access-proxy.mjs +1224 -0
- package/test-remote-access-proxy.mjs +993 -0
|
@@ -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
|
+
})
|