@mzzsfy/dsh-tunnel 0.1.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/README.md +39 -0
- package/cordis.patch.yml +9 -0
- package/package.json +42 -0
- package/src/api.mjs +116 -0
- package/src/client.js +548 -0
- package/src/index.js +695 -0
- package/test/ai-switch.test.mjs +272 -0
- package/test/api.test.mjs +145 -0
- package/test/client-id.test.mjs +25 -0
- package/test/switch-guard.test.mjs +81 -0
- package/test/tunnel.test.mjs +921 -0
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
// dsh-tunnel 行为测试: mock webServer(三 Map + fallback + 官方注册语义)与 mock ctx,
|
|
2
|
+
// 转发/升级用真实 http/net 端到端; 持久化落在临时目录。
|
|
3
|
+
// 规格源 docs/调研-路径穿透插件.md「方案设计 (v1 路径模式)」节。
|
|
4
|
+
import test from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import http from 'node:http'
|
|
7
|
+
import net from 'node:net'
|
|
8
|
+
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs'
|
|
9
|
+
import { tmpdir } from 'node:os'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { once } from 'node:events'
|
|
12
|
+
|
|
13
|
+
const DEFAULT_CONNECT_TIMEOUT = 10 * 1000
|
|
14
|
+
const PORT_MAX = 65535
|
|
15
|
+
|
|
16
|
+
class MockWebServer {
|
|
17
|
+
exact = new Map()
|
|
18
|
+
prefixes = new Map()
|
|
19
|
+
upgrades = new Map()
|
|
20
|
+
fallback = undefined
|
|
21
|
+
|
|
22
|
+
host = '127.0.0.1'
|
|
23
|
+
port = 3080
|
|
24
|
+
|
|
25
|
+
register(route) {
|
|
26
|
+
const table = route.kind === 'exact' ? this.exact : this.prefixes
|
|
27
|
+
if (table.has(route.path)) throw new Error(`webserver: duplicate ${route.kind} route "${route.path}"`)
|
|
28
|
+
table.set(route.path, route)
|
|
29
|
+
return () => table.delete(route.path)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
registerUpgrade(route) {
|
|
33
|
+
if (this.upgrades.has(route.path)) throw new Error(`webserver: duplicate upgrade route "${route.path}"`)
|
|
34
|
+
this.upgrades.set(route.path, route)
|
|
35
|
+
return () => this.upgrades.delete(route.path)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
registerFallback(handler) {
|
|
39
|
+
if (this.fallback !== undefined) throw new Error('webserver: fallback already registered')
|
|
40
|
+
this.fallback = handler
|
|
41
|
+
return () => { this.fallback = undefined }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function createCtx(webServer, tools = undefined, settings = undefined) {
|
|
46
|
+
const toolsService = tools ?? { registered: [], register(tool) { this.registered.push(tool); return () => {} } }
|
|
47
|
+
// effect 清理函数登记: 模拟宿主 fiber 回收语义, 停用回调统一触发
|
|
48
|
+
const effectDisposers = []
|
|
49
|
+
const runEffect = (fn) => {
|
|
50
|
+
const disposer = fn()
|
|
51
|
+
if (typeof disposer === 'function') effectDisposers.push(disposer)
|
|
52
|
+
return disposer
|
|
53
|
+
}
|
|
54
|
+
const ctx = {
|
|
55
|
+
webServer,
|
|
56
|
+
get: () => undefined,
|
|
57
|
+
on: () => () => {},
|
|
58
|
+
effect: runEffect,
|
|
59
|
+
inject(names, cb) {
|
|
60
|
+
cb({ tools: toolsService, settings, effect: runEffect })
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
return { ctx, toolsService, effectDisposers }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// mock settings 服务: 内存存储 + watch 回调登记, 测试手动触发模拟设置页写入
|
|
67
|
+
function createSettingsService(initial = {}) {
|
|
68
|
+
const store = { tunnel: { ...initial } }
|
|
69
|
+
const watchers = []
|
|
70
|
+
return {
|
|
71
|
+
store,
|
|
72
|
+
watchers,
|
|
73
|
+
setTunnel(patch) { store.tunnel = { ...store.tunnel, ...patch }; for (const cb of watchers) cb() },
|
|
74
|
+
service: {
|
|
75
|
+
get: (ns) => store[ns],
|
|
76
|
+
update: (ns, patch) => { store[ns] = { ...(store[ns] ?? {}), ...patch } },
|
|
77
|
+
register: (ns) => ({ watch: (cb) => watchers.push(cb) }),
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 缺省 dataDir 逐场景独立, 统一登记, 进程退出一次清理(避免跨场景污染与目录泄漏)
|
|
83
|
+
const tempDataDirs = new Set()
|
|
84
|
+
const defaultDataDir = () => {
|
|
85
|
+
const dir = mkdtempSync(join(tmpdir(), 'dsh-tunnel-test-'))
|
|
86
|
+
tempDataDirs.add(dir)
|
|
87
|
+
return dir
|
|
88
|
+
}
|
|
89
|
+
process.on('exit', () => {
|
|
90
|
+
for (const dir of tempDataDirs) rmSync(dir, { recursive: true, force: true })
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
async function applyPlugin(webServer, options = {}) {
|
|
94
|
+
const { apply } = await import('../src/index.js')
|
|
95
|
+
const { ctx, toolsService, effectDisposers } = createCtx(webServer, undefined, options.settings)
|
|
96
|
+
const dataDir = options.dataDir ?? defaultDataDir()
|
|
97
|
+
const config = { connectTimeoutMs: options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT, dataDir }
|
|
98
|
+
const warnings = []
|
|
99
|
+
const origWarn = console.warn
|
|
100
|
+
console.warn = (...args) => warnings.push(args.join(' '))
|
|
101
|
+
let dispose
|
|
102
|
+
try {
|
|
103
|
+
dispose = apply(ctx, config)
|
|
104
|
+
} finally {
|
|
105
|
+
console.warn = origWarn
|
|
106
|
+
}
|
|
107
|
+
const tools = Object.fromEntries(toolsService.registered.map((tool) => [tool.name, tool]))
|
|
108
|
+
// 宿主语义: 停用 = apply disposer + 各 effect 清理(逆序, fiber 回收)
|
|
109
|
+
const disposeAll = () => {
|
|
110
|
+
dispose()
|
|
111
|
+
for (const disposer of effectDisposers.reverse()) disposer()
|
|
112
|
+
}
|
|
113
|
+
return { webServer, tools, dataDir, warnings, dispose: disposeAll, ctx }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function toolOf(plugin, name) {
|
|
117
|
+
const tool = plugin.tools[name]
|
|
118
|
+
assert.ok(tool, `工具未注册: ${name}`)
|
|
119
|
+
return tool
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function open(plugin, args) {
|
|
123
|
+
return toolOf(plugin, 'tunnel_open').execute(args)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function list(plugin) {
|
|
127
|
+
return toolOf(plugin, 'tunnel_list').execute({})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function close(plugin, name) {
|
|
131
|
+
return toolOf(plugin, 'tunnel_close').execute({ name })
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 隧道路由计数: /api/tunnel REST 面常驻注册, 计数语义只看 /p/ 前缀
|
|
135
|
+
function tunnelPrefixCount(webServer) {
|
|
136
|
+
return [...webServer.prefixes.keys()].filter((path) => path.startsWith('/p/')).length
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 真实端到端: 外层服务器把请求交给本插件的路由 handler, 模拟官方 webServer 分发;
|
|
140
|
+
// 用 http.request 而非 fetch(可控 Host 头, 不自动跟随重定向)
|
|
141
|
+
async function requestVia(webServer, path, options = {}) {
|
|
142
|
+
const prefixRoute = [...webServer.prefixes.values()].find((route) => path === route.path || path.startsWith(route.path + '/'))
|
|
143
|
+
const outer = http.createServer((req, res) => {
|
|
144
|
+
if (prefixRoute) return prefixRoute.handler(req, res)
|
|
145
|
+
res.writeHead(404)
|
|
146
|
+
res.end()
|
|
147
|
+
})
|
|
148
|
+
await once(outer.listen(0), 'listening')
|
|
149
|
+
try {
|
|
150
|
+
const port = outer.address().port
|
|
151
|
+
const res = await new Promise((resolve, reject) => {
|
|
152
|
+
const req = http.request({ host: '127.0.0.1', port, path, method: options.method ?? 'GET', headers: options.headers }, resolve)
|
|
153
|
+
req.on('error', reject)
|
|
154
|
+
if (options.body !== undefined) req.write(options.body)
|
|
155
|
+
req.end()
|
|
156
|
+
})
|
|
157
|
+
const chunks = []
|
|
158
|
+
for await (const chunk of res) chunks.push(chunk)
|
|
159
|
+
return { status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks).toString() }
|
|
160
|
+
} finally {
|
|
161
|
+
outer.closeAllConnections()
|
|
162
|
+
await once(outer.close(), 'close')
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function startTarget(handler) {
|
|
167
|
+
const server = http.createServer(handler)
|
|
168
|
+
return { server, ready: once(server.listen(0), 'listening'), get port() { return server.address().port } }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function stopTarget(target) {
|
|
172
|
+
const server = target.server ?? target
|
|
173
|
+
server.closeAllConnections()
|
|
174
|
+
await once(server.close(), 'close')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 官方分发全镜像: exact → 最长前缀 → fallback; upgrade 仅 exact。子域名场景用
|
|
178
|
+
// (Host 可控, 全路由面参与分发)
|
|
179
|
+
async function dispatchVia(webServer, { host, path, headers = {}, upgrade = false }) {
|
|
180
|
+
const outer = http.createServer((req, res) => {
|
|
181
|
+
const exact = webServer.exact.get(req.url.split('?')[0])
|
|
182
|
+
if (exact) return exact.handler(req, res)
|
|
183
|
+
let best
|
|
184
|
+
for (const route of webServer.prefixes.values()) {
|
|
185
|
+
if ((req.url === route.path || req.url.startsWith(route.path + '/')) && (!best || route.path.length > best.path.length)) best = route
|
|
186
|
+
}
|
|
187
|
+
if (best) return best.handler(req, res)
|
|
188
|
+
if (typeof webServer.fallback === 'function') return webServer.fallback(req, res)
|
|
189
|
+
res.writeHead(404)
|
|
190
|
+
res.end()
|
|
191
|
+
})
|
|
192
|
+
outer.on('upgrade', (req, socket, head) => {
|
|
193
|
+
const route = webServer.upgrades.get(new URL(req.url ?? '/', 'http://x').pathname)
|
|
194
|
+
if (route) return route.handler(req, socket, head)
|
|
195
|
+
socket.destroy()
|
|
196
|
+
})
|
|
197
|
+
await once(outer.listen(0), 'listening')
|
|
198
|
+
try {
|
|
199
|
+
const port = outer.address().port
|
|
200
|
+
if (upgrade) {
|
|
201
|
+
const request = http.request({
|
|
202
|
+
host: '127.0.0.1',
|
|
203
|
+
port,
|
|
204
|
+
path,
|
|
205
|
+
headers: { host, connection: 'Upgrade', upgrade: 'websocket', 'sec-websocket-key': 'dGhlIHNhbXBsZSBub25jZQ==', 'sec-websocket-version': '13', ...headers },
|
|
206
|
+
})
|
|
207
|
+
request.end()
|
|
208
|
+
const result = await new Promise((resolve) => {
|
|
209
|
+
const timer = setTimeout(() => resolve({ ok: false, error: 'upgrade 超时' }), 2000)
|
|
210
|
+
request.once('upgrade', (res, socket) => {
|
|
211
|
+
socket.write('probe-frame')
|
|
212
|
+
socket.once('data', (chunk) => {
|
|
213
|
+
clearTimeout(timer)
|
|
214
|
+
socket.destroy()
|
|
215
|
+
resolve({ ok: res.statusCode === 101 && chunk.toString() === 'probe-frame', status: res.statusCode })
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
request.once('error', (error) => {
|
|
219
|
+
clearTimeout(timer)
|
|
220
|
+
resolve({ ok: false, error: error.message })
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
return result
|
|
224
|
+
}
|
|
225
|
+
const res = await new Promise((resolve, reject) => {
|
|
226
|
+
const req = http.request({ host: '127.0.0.1', port, path, headers: { host, ...headers } }, resolve)
|
|
227
|
+
req.on('error', reject)
|
|
228
|
+
req.end()
|
|
229
|
+
})
|
|
230
|
+
const chunks = []
|
|
231
|
+
for await (const chunk of res) chunks.push(chunk)
|
|
232
|
+
return { status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks).toString() }
|
|
233
|
+
} finally {
|
|
234
|
+
outer.closeAllConnections()
|
|
235
|
+
await once(outer.close(), 'close')
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 隧道表行数; 文件不存在/损坏按 0
|
|
240
|
+
function storeCount(dataDir) {
|
|
241
|
+
try {
|
|
242
|
+
return JSON.parse(readFileSync(join(dataDir, 'tunnels.json'), 'utf8')).length
|
|
243
|
+
} catch {
|
|
244
|
+
return 0
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
test('场景1 工具注册: Given apply Then 注册 tunnel_open/list/close 三件', async () => {
|
|
249
|
+
const plugin = await applyPlugin(new MockWebServer())
|
|
250
|
+
assert.deepEqual(Object.keys(plugin.tools).sort(), ['tunnel_close', 'tunnel_list', 'tunnel_open'])
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
test('场景2 open: Given 合法参数与 wsPaths Then prefix 路由与两形态 upgrade 注册并持久化', async (t) => {
|
|
254
|
+
const plugin = await applyPlugin(new MockWebServer())
|
|
255
|
+
const outcome = await open(plugin, { name: 'app', targetPort: PORT_MAX - 1, wsPaths: ['/'] })
|
|
256
|
+
assert.equal(outcome.ok, true)
|
|
257
|
+
assert.equal(outcome.path, '/p/app')
|
|
258
|
+
assert.deepEqual(plugin.webServer.prefixes.get('/p/app').path, '/p/app')
|
|
259
|
+
assert.ok(plugin.webServer.upgrades.has('/p/app'))
|
|
260
|
+
assert.ok(plugin.webServer.upgrades.has('/p/app/'))
|
|
261
|
+
const persisted = JSON.parse(readFileSync(join(plugin.dataDir, 'tunnels.json'), 'utf8'))
|
|
262
|
+
assert.deepEqual(persisted.map((row) => row.name), ['app'])
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
test('场景3 校验: Given 非法名或越界端口 Then 拒收且不注册不落盘', async (t) => {
|
|
266
|
+
const plugin = await applyPlugin(new MockWebServer())
|
|
267
|
+
for (const args of [{ name: 'App', targetPort: 80 }, { name: 'a.b', targetPort: 80 }, { name: 'ok', targetPort: 0 }, { name: 'ok', targetPort: PORT_MAX + 1 }]) {
|
|
268
|
+
const outcome = await open(plugin, args)
|
|
269
|
+
assert.equal(outcome.ok, false, JSON.stringify(args))
|
|
270
|
+
assert.equal(typeof outcome.error, 'string')
|
|
271
|
+
}
|
|
272
|
+
assert.equal(tunnelPrefixCount(plugin.webServer), 0)
|
|
273
|
+
assert.equal(storeCount(plugin.dataDir), 0)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
test('场景4 wsPaths: Given 白名单外项或非 / 开头项 Then 整体拒收; Given 重复尾斜杠项 Then 归一去重', async (t) => {
|
|
277
|
+
const plugin = await applyPlugin(new MockWebServer())
|
|
278
|
+
for (const wsPaths of [['foo'], ['/x?'], ['/a', '/b ']]) {
|
|
279
|
+
const outcome = await open(plugin, { name: 'app', targetPort: 80, wsPaths })
|
|
280
|
+
assert.equal(outcome.ok, false, JSON.stringify(wsPaths))
|
|
281
|
+
assert.equal(tunnelPrefixCount(plugin.webServer), 0)
|
|
282
|
+
}
|
|
283
|
+
// 数组项类型违规由工具框架参数校验先行拦截
|
|
284
|
+
await assert.rejects(open(plugin, { name: 'app', targetPort: 80, wsPaths: [5] }), /wsPaths/)
|
|
285
|
+
assert.equal(tunnelPrefixCount(plugin.webServer), 0)
|
|
286
|
+
const outcome = await open(plugin, { name: 'app', targetPort: 80, wsPaths: ['/x/', '/x', '/y'] })
|
|
287
|
+
assert.equal(outcome.ok, true)
|
|
288
|
+
assert.deepEqual([...plugin.webServer.upgrades.keys()].sort(), ['/p/app/x', '/p/app/x/', '/p/app/y', '/p/app/y/'])
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
test('场景5 转发: Given 目标服务 When GET /p/app/a?b=c Then strip 转发且保留 Host 附 x-forwarded 三件套', async (t) => {
|
|
292
|
+
const plugin = await applyPlugin(new MockWebServer())
|
|
293
|
+
const seen = []
|
|
294
|
+
const target = startTarget((req, res) => {
|
|
295
|
+
seen.push({ url: req.url, host: req.headers.host, fwdHost: req.headers['x-forwarded-host'], fwdFor: Boolean(req.headers['x-forwarded-for']), fwdProto: req.headers['x-forwarded-proto'] })
|
|
296
|
+
res.writeHead(200, { 'content-type': 'text/plain' })
|
|
297
|
+
res.end('target-body')
|
|
298
|
+
})
|
|
299
|
+
await target.ready
|
|
300
|
+
try {
|
|
301
|
+
await open(plugin, { name: 'app', targetPort: target.port })
|
|
302
|
+
const response = await requestVia(plugin.webServer, '/p/app/a?b=c', { headers: { host: 'entry.example:3080', 'x-custom': '1' } })
|
|
303
|
+
assert.equal(response.status, 200)
|
|
304
|
+
assert.equal(response.body, 'target-body')
|
|
305
|
+
assert.equal(seen.length, 1)
|
|
306
|
+
assert.equal(seen[0].url, '/a?b=c')
|
|
307
|
+
assert.equal(seen[0].host, 'entry.example:3080')
|
|
308
|
+
assert.equal(seen[0].fwdHost, 'entry.example:3080')
|
|
309
|
+
assert.ok(seen[0].fwdFor)
|
|
310
|
+
assert.equal(seen[0].fwdProto, 'http')
|
|
311
|
+
} finally {
|
|
312
|
+
await stopTarget(target)
|
|
313
|
+
}
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
test('场景6 拒连: Given 无监听端口 Then 502 JSON 且 ok 为 false', async (t) => {
|
|
317
|
+
const plugin = await applyPlugin(new MockWebServer())
|
|
318
|
+
await open(plugin, { name: 'app', targetPort: 1 })
|
|
319
|
+
const response = await requestVia(plugin.webServer, '/p/app/x')
|
|
320
|
+
assert.equal(response.status, 502)
|
|
321
|
+
const body = JSON.parse(response.body)
|
|
322
|
+
assert.equal(body.ok, false)
|
|
323
|
+
assert.equal(typeof body.error, 'string')
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
test('场景7 响应头等待超时: Given 目标只接受不响应与短超时 Then 快速 504 不悬挂', async (t) => {
|
|
327
|
+
const plugin = await applyPlugin(new MockWebServer(), { connectTimeoutMs: 100 })
|
|
328
|
+
const sink = net.createServer(() => {})
|
|
329
|
+
await once(sink.listen(0), 'listening')
|
|
330
|
+
try {
|
|
331
|
+
await open(plugin, { name: 'app', targetPort: sink.address().port })
|
|
332
|
+
const started = Date.now()
|
|
333
|
+
const response = await requestVia(plugin.webServer, '/p/app/x')
|
|
334
|
+
assert.equal(response.status, 504)
|
|
335
|
+
assert.ok(Date.now() - started < 5 * 1000, '应在注入超时内返回')
|
|
336
|
+
} finally {
|
|
337
|
+
sink.close()
|
|
338
|
+
}
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
test('场景8 Location 改写: Given 3xx 同源根绝对路径 Then 补前缀; 协议相对与跨源与已带前缀则不动', async (t) => {
|
|
342
|
+
const plugin = await applyPlugin(new MockWebServer())
|
|
343
|
+
const target = startTarget((req, res) => {
|
|
344
|
+
res.writeHead(302, { location: req.headers['x-case'] })
|
|
345
|
+
res.end()
|
|
346
|
+
})
|
|
347
|
+
await target.ready
|
|
348
|
+
try {
|
|
349
|
+
await open(plugin, { name: 'app', targetPort: target.port })
|
|
350
|
+
const cases = [
|
|
351
|
+
['/login', '/p/app/login'],
|
|
352
|
+
['//evil.example/x', '//evil.example/x'],
|
|
353
|
+
['http://other.example/y', 'http://other.example/y'],
|
|
354
|
+
['/p/app/x', '/p/app/x'],
|
|
355
|
+
]
|
|
356
|
+
for (const [location, expected] of cases) {
|
|
357
|
+
const response = await requestVia(plugin.webServer, '/p/app/a', { headers: { 'x-case': location } })
|
|
358
|
+
assert.equal(response.headers.location, expected, location)
|
|
359
|
+
}
|
|
360
|
+
} finally {
|
|
361
|
+
await stopTarget(target)
|
|
362
|
+
}
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
test('场景9 close: Given 已开隧道 Then 路由摘除条目删除且幂等', async (t) => {
|
|
366
|
+
const plugin = await applyPlugin(new MockWebServer())
|
|
367
|
+
await open(plugin, { name: 'app', targetPort: 80 })
|
|
368
|
+
const outcome = await close(plugin, 'app')
|
|
369
|
+
assert.deepEqual(outcome, { ok: true, removed: true })
|
|
370
|
+
assert.equal(tunnelPrefixCount(plugin.webServer), 0)
|
|
371
|
+
assert.equal(plugin.webServer.upgrades.size, 0)
|
|
372
|
+
assert.equal(storeCount(plugin.dataDir), 0)
|
|
373
|
+
const again = await close(plugin, 'app')
|
|
374
|
+
assert.deepEqual(again, { ok: true, removed: false })
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
test('场景10 重启恢复: Given 已持久化隧道与新 apply Then 路由重注册且 list 可见', async (t) => {
|
|
378
|
+
const dataDir = mkdtempSync(join(tmpdir(), 'dsh-tunnel-test-'))
|
|
379
|
+
t.after(() => rmSync(dataDir, { recursive: true, force: true }))
|
|
380
|
+
const first = await applyPlugin(new MockWebServer(), { dataDir })
|
|
381
|
+
await open(first, { name: 'app', targetPort: 80, wsPaths: ['/ws'] })
|
|
382
|
+
|
|
383
|
+
const second = await applyPlugin(new MockWebServer(), { dataDir })
|
|
384
|
+
assert.ok(second.webServer.prefixes.has('/p/app'))
|
|
385
|
+
assert.ok(second.webServer.upgrades.has('/p/app/ws'), 'wsPaths 的 upgrade 路由应随恢复重注册')
|
|
386
|
+
const outcome = await list(second)
|
|
387
|
+
assert.deepEqual(outcome.tunnels.map((row) => row.name), ['app'])
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
test('场景11 损坏文件: Given 非法 JSON Then 按空表处理告警且激活不抛', async (t) => {
|
|
391
|
+
const dataDir = mkdtempSync(join(tmpdir(), 'dsh-tunnel-test-'))
|
|
392
|
+
t.after(() => rmSync(dataDir, { recursive: true, force: true }))
|
|
393
|
+
writeFileSync(join(dataDir, 'tunnels.json'), 'not-json')
|
|
394
|
+
const plugin = await applyPlugin(new MockWebServer(), { dataDir })
|
|
395
|
+
assert.equal(tunnelPrefixCount(plugin.webServer), 0)
|
|
396
|
+
assert.ok(plugin.warnings.some((line) => line.includes('dsh-tunnel')))
|
|
397
|
+
const outcome = await list(plugin)
|
|
398
|
+
assert.deepEqual(outcome.tunnels, [])
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
test('场景12 激活冲突回滚: Given 持久化隧道与已占用前缀 Then 冲突隧道回滚其余照常且激活不失败', async (t) => {
|
|
402
|
+
const dataDir = mkdtempSync(join(tmpdir(), 'dsh-tunnel-test-'))
|
|
403
|
+
t.after(() => rmSync(dataDir, { recursive: true, force: true }))
|
|
404
|
+
writeFileSync(join(dataDir, 'tunnels.json'), JSON.stringify([
|
|
405
|
+
{ name: 'app', targetPort: 80, wsPaths: [], createdAt: 't' },
|
|
406
|
+
{ name: 'good', targetPort: 80, wsPaths: [], createdAt: 't' },
|
|
407
|
+
]))
|
|
408
|
+
const webServer = new MockWebServer()
|
|
409
|
+
webServer.register({ kind: 'prefix', path: '/p/app', handler: () => {} })
|
|
410
|
+
const plugin = await applyPlugin(webServer, { dataDir })
|
|
411
|
+
assert.ok(webServer.prefixes.has('/p/app'), '预注册路由保持原样')
|
|
412
|
+
assert.ok(webServer.prefixes.has('/p/good'), '无冲突隧道照常注册')
|
|
413
|
+
assert.ok(plugin.warnings.some((line) => line.includes('app')))
|
|
414
|
+
// 冲突隧道已回滚不入活动表; 持久化条目仍在(由场景10/16覆盖恢复与保留语义)
|
|
415
|
+
const outcome = await list(plugin)
|
|
416
|
+
assert.deepEqual(outcome.tunnels.map((row) => row.name), ['good'])
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
test('场景13 upgrade 转发: Given 声明 wsPaths 的隧道 Then 两形态注册且握手字节双向透传', { timeout: 10 * 1000 }, async (t) => {
|
|
420
|
+
const plugin = await applyPlugin(new MockWebServer())
|
|
421
|
+
const target = http.createServer(() => {})
|
|
422
|
+
target.on('upgrade', (req, socket) => {
|
|
423
|
+
socket.write('HTTP/1.1 101 Switching Protocols\r\nconnection: Upgrade\r\nupgrade: websocket\r\n\r\n')
|
|
424
|
+
socket.pipe(socket)
|
|
425
|
+
})
|
|
426
|
+
await once(target.listen(0), 'listening')
|
|
427
|
+
try {
|
|
428
|
+
await open(plugin, { name: 'app', targetPort: target.address().port, wsPaths: ['/ws'] })
|
|
429
|
+
assert.ok(plugin.webServer.upgrades.has('/p/app/ws'), '无尾斜杠形态应注册')
|
|
430
|
+
assert.ok(plugin.webServer.upgrades.has('/p/app/ws/'), '带尾斜杠形态应注册')
|
|
431
|
+
|
|
432
|
+
// 外层 http server 的 upgrade 分发与官方 webServer 同构: exact 匹配则交本插件 handler
|
|
433
|
+
const outer = http.createServer(() => {})
|
|
434
|
+
outer.on('upgrade', (req, socket, head) => {
|
|
435
|
+
const route = plugin.webServer.upgrades.get(new URL(req.url ?? '/', 'http://x').pathname)
|
|
436
|
+
if (route) return route.handler(req, socket, head)
|
|
437
|
+
socket.destroy()
|
|
438
|
+
})
|
|
439
|
+
await once(outer.listen(0), 'listening')
|
|
440
|
+
try {
|
|
441
|
+
const request = http.request({
|
|
442
|
+
host: '127.0.0.1',
|
|
443
|
+
port: outer.address().port,
|
|
444
|
+
path: '/p/app/ws',
|
|
445
|
+
headers: { host: 'entry.example', connection: 'Upgrade', upgrade: 'websocket', 'sec-websocket-key': 'dGhlIHNhbXBsZSBub25jZQ==', 'sec-websocket-version': '13' },
|
|
446
|
+
})
|
|
447
|
+
request.end()
|
|
448
|
+
const res = await once(request, 'upgrade')
|
|
449
|
+
const [proxyRes, proxySocket] = res
|
|
450
|
+
assert.equal(proxyRes.statusCode, 101, '应透传目标 101 握手')
|
|
451
|
+
proxySocket.write('frame-1')
|
|
452
|
+
const echoed = await once(proxySocket, 'data')
|
|
453
|
+
assert.equal(echoed[0].toString(), 'frame-1', '应回环透传帧')
|
|
454
|
+
proxySocket.destroy()
|
|
455
|
+
} finally {
|
|
456
|
+
outer.closeAllConnections()
|
|
457
|
+
await once(outer.close(), 'close')
|
|
458
|
+
}
|
|
459
|
+
} finally {
|
|
460
|
+
target.closeAllConnections()
|
|
461
|
+
await once(target.close(), 'close')
|
|
462
|
+
}
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
test('场景14 缺省 wsPaths: Given 纯 HTTP 隧道 Then 不注册任何 upgrade', async (t) => {
|
|
466
|
+
const plugin = await applyPlugin(new MockWebServer())
|
|
467
|
+
await open(plugin, { name: 'app', targetPort: 80 })
|
|
468
|
+
assert.equal(plugin.webServer.upgrades.size, 0)
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
test('场景15 patch 形态: Given bundle patch Then insert 行指向本包', async (t) => {
|
|
472
|
+
const { readFileSync } = await import('node:fs')
|
|
473
|
+
const { fileURLToPath } = await import('node:url')
|
|
474
|
+
const { dirname } = await import('node:path')
|
|
475
|
+
const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
476
|
+
const patchText = readFileSync(join(pkgRoot, 'cordis.patch.yml'), 'utf8')
|
|
477
|
+
assert.match(patchText, /- insert:\n - id: dsh-tunnel\n name: '@mzzsfy\/dsh-tunnel'/)
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
test('场景17 SSE 断开回收: Given 目标持续推流 When 客户端断开 Then 上游连接被回收', { timeout: 10 * 1000 }, async (t) => {
|
|
481
|
+
const plugin = await applyPlugin(new MockWebServer())
|
|
482
|
+
let targetSockets = new Set()
|
|
483
|
+
const target = startTarget((req, res) => {
|
|
484
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
|
485
|
+
res.write('data: hello\n\n')
|
|
486
|
+
res.socket.on('close', () => targetSockets.delete(res.socket))
|
|
487
|
+
})
|
|
488
|
+
target.server.on('connection', (socket) => targetSockets.add(socket))
|
|
489
|
+
await target.ready
|
|
490
|
+
try {
|
|
491
|
+
await open(plugin, { name: 'app', targetPort: target.port })
|
|
492
|
+
// 通过外层服务器拿真实 res, 手动断开模拟客户端离开
|
|
493
|
+
const prefixRoute = plugin.webServer.prefixes.get('/p/app')
|
|
494
|
+
const outer = http.createServer((req, res) => prefixRoute.handler(req, res))
|
|
495
|
+
await once(outer.listen(0), 'listening')
|
|
496
|
+
const clientReq = http.request({ host: '127.0.0.1', port: outer.address().port, path: '/p/app/stream' }, (res) => {
|
|
497
|
+
res.once('data', () => clientReq.destroy())
|
|
498
|
+
})
|
|
499
|
+
clientReq.end()
|
|
500
|
+
await once(clientReq, 'close')
|
|
501
|
+
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
502
|
+
assert.equal(targetSockets.size, 0, '客户端断开后上游连接应被回收')
|
|
503
|
+
outer.closeAllConnections()
|
|
504
|
+
await once(outer.close(), 'close')
|
|
505
|
+
} finally {
|
|
506
|
+
await stopTarget(target)
|
|
507
|
+
}
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
test('场景18 WS 空闲存活: Given 握手完成后的长静默 When 超过连接超时 Then 连接不断开', { timeout: 10 * 1000 }, async (t) => {
|
|
511
|
+
const plugin = await applyPlugin(new MockWebServer(), { connectTimeoutMs: 100 })
|
|
512
|
+
const target = startTarget(() => {})
|
|
513
|
+
target.server.on('upgrade', (req, socket) => {
|
|
514
|
+
socket.write('HTTP/1.1 101 Switching Protocols\r\nconnection: Upgrade\r\nupgrade: websocket\r\n\r\n')
|
|
515
|
+
socket.pipe(socket)
|
|
516
|
+
})
|
|
517
|
+
await target.ready
|
|
518
|
+
const outer = http.createServer(() => {})
|
|
519
|
+
outer.on('upgrade', (req, socket, head) => {
|
|
520
|
+
const route = plugin.webServer.upgrades.get(new URL(req.url ?? '/', 'http://x').pathname)
|
|
521
|
+
if (route) return route.handler(req, socket, head)
|
|
522
|
+
socket.destroy()
|
|
523
|
+
})
|
|
524
|
+
await once(outer.listen(0), 'listening')
|
|
525
|
+
try {
|
|
526
|
+
await open(plugin, { name: 'app', targetPort: target.port, wsPaths: ['/'] })
|
|
527
|
+
const request = http.request({
|
|
528
|
+
host: '127.0.0.1', port: outer.address().port, path: '/p/app',
|
|
529
|
+
headers: { host: 'entry.example', connection: 'Upgrade', upgrade: 'websocket', 'sec-websocket-key': 'dGhlIHNhbXBsZSBub25jZQ==', 'sec-websocket-version': '13' },
|
|
530
|
+
})
|
|
531
|
+
request.end()
|
|
532
|
+
const [res, socket] = await once(request, 'upgrade')
|
|
533
|
+
assert.equal(res.statusCode, 101)
|
|
534
|
+
// 先经历超过注入超时(100ms)的静默, 再验证连接存活——否则测试对空闲误杀无证明力
|
|
535
|
+
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
536
|
+
socket.write('still-alive')
|
|
537
|
+
const echoed = await once(socket, 'data')
|
|
538
|
+
assert.equal(echoed[0].toString(), 'still-alive', '静默超过注入超时后连接应仍存活')
|
|
539
|
+
socket.destroy()
|
|
540
|
+
} finally {
|
|
541
|
+
outer.closeAllConnections()
|
|
542
|
+
await once(outer.close(), 'close')
|
|
543
|
+
await stopTarget(target)
|
|
544
|
+
}
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
test('场景19 同名拒收与 open 期回滚: Given 已占用名或注册冲突 Then 拒收且不留半注册路由', async (t) => {
|
|
548
|
+
const plugin = await applyPlugin(new MockWebServer())
|
|
549
|
+
await open(plugin, { name: 'app', targetPort: 80 })
|
|
550
|
+
const again = await open(plugin, { name: 'app', targetPort: 81 })
|
|
551
|
+
assert.equal(again.ok, false)
|
|
552
|
+
assert.match(again.error, /已占用/)
|
|
553
|
+
assert.equal(plugin.webServer.prefixes.get('/p/app')?.route, undefined)
|
|
554
|
+
assert.ok(plugin.webServer.prefixes.has('/p/app'))
|
|
555
|
+
|
|
556
|
+
// open 期事务回滚: 预占一个 upgrade 路径, open 携同路径应整体失败且 prefix 不残留
|
|
557
|
+
plugin.webServer.registerUpgrade({ path: '/p/dup/ws', handler: () => {} })
|
|
558
|
+
const conflict = await open(plugin, { name: 'dup', targetPort: 80, wsPaths: ['/ws'] })
|
|
559
|
+
assert.equal(conflict.ok, false)
|
|
560
|
+
assert.ok(plugin.webServer.prefixes.has('/p/app'))
|
|
561
|
+
assert.ok(!plugin.webServer.prefixes.has('/p/dup'), '失败隧道的 prefix 不应残留')
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
test('场景20 x-forwarded 剥离与无 Host 请求: Given 入站伪造 forwarded 头或缺 Host Then 剥离伪造且不抛错', async (t) => {
|
|
565
|
+
const plugin = await applyPlugin(new MockWebServer())
|
|
566
|
+
const seen = []
|
|
567
|
+
const target = startTarget((req, res) => {
|
|
568
|
+
seen.push({ fwdHost: req.headers['x-forwarded-host'], fwdFor: req.headers['x-forwarded-for'] })
|
|
569
|
+
res.writeHead(200)
|
|
570
|
+
res.end('ok')
|
|
571
|
+
})
|
|
572
|
+
await target.ready
|
|
573
|
+
try {
|
|
574
|
+
await open(plugin, { name: 'app', targetPort: target.port })
|
|
575
|
+
await requestVia(plugin.webServer, '/p/app/a', { headers: { 'x-forwarded-host': 'evil', 'x-forwarded-for': '1.2.3.4' } })
|
|
576
|
+
assert.notEqual(seen[0].fwdHost, 'evil', '入站伪造头应被剥离重写')
|
|
577
|
+
assert.match(seen[0].fwdHost, /^127\.0\.0\.1/, '重写为真实入口 Host')
|
|
578
|
+
assert.notEqual(seen[0].fwdFor, '1.2.3.4')
|
|
579
|
+
|
|
580
|
+
// 无 Host 头(HTTP/1.0 形态)不抛错, 转发照常
|
|
581
|
+
const prefixRoute = plugin.webServer.prefixes.get('/p/app')
|
|
582
|
+
const outer = http.createServer((req, res) => prefixRoute.handler(req, res))
|
|
583
|
+
await once(outer.listen(0), 'listening')
|
|
584
|
+
const client = net.connect(outer.address().port, '127.0.0.1')
|
|
585
|
+
await once(client, 'connect')
|
|
586
|
+
client.write('GET /p/app/b HTTP/1.0\r\n\r\n')
|
|
587
|
+
const raw = await once(client, 'data')
|
|
588
|
+
assert.ok(raw[0].toString().startsWith('HTTP/1.1 200'), '无 Host 请求应照常转发')
|
|
589
|
+
client.destroy()
|
|
590
|
+
outer.closeAllConnections()
|
|
591
|
+
await once(outer.close(), 'close')
|
|
592
|
+
} finally {
|
|
593
|
+
await stopTarget(target)
|
|
594
|
+
}
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
test('场景16 dispose: Given 已开隧道 Then dispose 摘除全部路由且持久化文件保留', async (t) => {
|
|
598
|
+
const dataDir = mkdtempSync(join(tmpdir(), 'dsh-tunnel-test-'))
|
|
599
|
+
t.after(() => rmSync(dataDir, { recursive: true, force: true }))
|
|
600
|
+
const plugin = await applyPlugin(new MockWebServer(), { dataDir })
|
|
601
|
+
await open(plugin, { name: 'app', targetPort: 80 })
|
|
602
|
+
plugin.dispose()
|
|
603
|
+
assert.equal(tunnelPrefixCount(plugin.webServer), 0)
|
|
604
|
+
assert.equal(storeCount(plugin.dataDir), 1)
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
test('场景21 包装安装: Given apply Then 现存路由加标记且非隧道 Host 穿透原 handler', async (t) => {
|
|
608
|
+
const webServer = new MockWebServer()
|
|
609
|
+
webServer.register({ kind: 'exact', path: '/api/x', handler: (req, res) => { res.writeHead(200); res.end('exact') } })
|
|
610
|
+
webServer.registerFallback((req, res) => { res.writeHead(200); res.end('fb') })
|
|
611
|
+
const plugin = await applyPlugin(webServer)
|
|
612
|
+
assert.ok(webServer.exact.get('/api/x').handler.dshTunnelWrapped, '现存 exact 应被包装')
|
|
613
|
+
assert.ok(webServer.fallback.dshTunnelWrapped, '现存 fallback 应被包装')
|
|
614
|
+
webServer.register({ kind: 'exact', path: '/api/new', handler: () => {} })
|
|
615
|
+
assert.ok(webServer.exact.get('/api/new').handler.dshTunnelWrapped, 'shadow 后新注册应被包装')
|
|
616
|
+
|
|
617
|
+
const viaExact = await dispatchVia(webServer, { host: 'gui.example.com', path: '/api/x' })
|
|
618
|
+
assert.equal(viaExact.body, 'exact', '非隧道 Host 走原 exact')
|
|
619
|
+
const viaFallback = await dispatchVia(webServer, { host: 'gui.example.com', path: '/other' })
|
|
620
|
+
assert.equal(viaFallback.body, 'fb', '非隧道 Host 走原 fallback')
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
test('场景22 子域名 HTTP: Given entry=subdomain Then 无路由注册且任意路径无 strip 透传', async (t) => {
|
|
624
|
+
const webServer = new MockWebServer()
|
|
625
|
+
webServer.registerFallback((req, res) => { res.writeHead(200); res.end('fb') })
|
|
626
|
+
const plugin = await applyPlugin(webServer)
|
|
627
|
+
const seen = []
|
|
628
|
+
const target = startTarget((req, res) => {
|
|
629
|
+
seen.push({ url: req.url, host: req.headers.host, fwdHost: req.headers['x-forwarded-host'] })
|
|
630
|
+
res.writeHead(200)
|
|
631
|
+
res.end('target:' + req.url)
|
|
632
|
+
})
|
|
633
|
+
await target.ready
|
|
634
|
+
try {
|
|
635
|
+
const outcome = await open(plugin, { name: 'app', targetPort: target.port, entry: 'subdomain' })
|
|
636
|
+
assert.equal(outcome.ok, true)
|
|
637
|
+
assert.equal(outcome.entry, 'subdomain')
|
|
638
|
+
assert.equal(outcome.host, 'app.localhost')
|
|
639
|
+
assert.equal(tunnelPrefixCount(plugin.webServer), 0, '子域名不注册 prefix 路由')
|
|
640
|
+
assert.equal(plugin.webServer.upgrades.size, 0, '子域名不注册 upgrade 路由')
|
|
641
|
+
|
|
642
|
+
const deep = await dispatchVia(plugin.webServer, { host: 'app.localhost:3080', path: '/deep/path?q=1' })
|
|
643
|
+
assert.equal(deep.body, 'target:/deep/path?q=1', '全路径透传不 strip(miss 经 fallback 座包装进入分发)')
|
|
644
|
+
assert.equal(seen[0].host, 'app.localhost:3080', '原 Host 头保留')
|
|
645
|
+
assert.equal(seen[0].fwdHost, 'app.localhost:3080', 'x-forwarded-host 为入口 Host')
|
|
646
|
+
|
|
647
|
+
const listed = await list(plugin)
|
|
648
|
+
assert.equal(listed.tunnels.length, 1)
|
|
649
|
+
const [row] = listed.tunnels
|
|
650
|
+
assert.deepEqual(
|
|
651
|
+
{ name: row.name, entry: row.entry, access: row.access, targetPort: row.targetPort, wsPaths: row.wsPaths },
|
|
652
|
+
{ name: 'app', entry: 'subdomain', access: 'app.*', targetPort: target.port, wsPaths: [] })
|
|
653
|
+
} finally {
|
|
654
|
+
await stopTarget(target)
|
|
655
|
+
}
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
test('场景23 子域名 WS: Given 动态随机路径 upgrade Then 无需 wsPaths 即 101 透传', async (t) => {
|
|
659
|
+
const plugin = await applyPlugin(new MockWebServer())
|
|
660
|
+
const target = startTarget(() => {})
|
|
661
|
+
target.server.on('upgrade', (req, socket) => {
|
|
662
|
+
socket.write('HTTP/1.1 101 Switching Protocols\r\nconnection: Upgrade\r\nupgrade: websocket\r\n\r\n')
|
|
663
|
+
socket.pipe(socket)
|
|
664
|
+
})
|
|
665
|
+
await target.ready
|
|
666
|
+
try {
|
|
667
|
+
await open(plugin, { name: 'app', targetPort: target.port, entry: 'subdomain' })
|
|
668
|
+
const result = await dispatchVia(plugin.webServer, {
|
|
669
|
+
host: 'app.localhost',
|
|
670
|
+
path: '/api/kernels/9f8e7d6c-1234-4321-abcd-001122334455/channels',
|
|
671
|
+
upgrade: true,
|
|
672
|
+
})
|
|
673
|
+
assert.ok(result.ok, `动态内核 WS 路径应全透传: ${result.error ?? ''}`)
|
|
674
|
+
assert.equal(result.status, 101)
|
|
675
|
+
// 表替换后宿主 miss 语义保持: 非隧道 Host 的无路由 upgrade 仍销毁
|
|
676
|
+
const miss = await dispatchVia(plugin.webServer, { host: 'other.localhost', path: '/random/ws', upgrade: true })
|
|
677
|
+
assert.equal(miss.ok, false, '非隧道 Host 无路由 upgrade 应被销毁')
|
|
678
|
+
} finally {
|
|
679
|
+
await stopTarget(target)
|
|
680
|
+
}
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
test('场景24 隔离优先级: Given 隧道域名命中 Then /api 等宿主路由全进隧道', async (t) => {
|
|
684
|
+
const webServer = new MockWebServer()
|
|
685
|
+
webServer.register({ kind: 'exact', path: '/api/gw', handler: (req, res) => { res.writeHead(200); res.end('dsh-gw') } })
|
|
686
|
+
const plugin = await applyPlugin(webServer)
|
|
687
|
+
const target = startTarget((req, res) => { res.writeHead(200); res.end('target-gw') })
|
|
688
|
+
await target.ready
|
|
689
|
+
try {
|
|
690
|
+
await open(plugin, { name: 'app', targetPort: target.port, entry: 'subdomain' })
|
|
691
|
+
const tunneled = await dispatchVia(webServer, { host: 'app.localhost', path: '/api/gw' })
|
|
692
|
+
assert.equal(tunneled.body, 'target-gw', '隧道域名上宿主 exact 被绕过')
|
|
693
|
+
const normal = await dispatchVia(webServer, { host: 'gui.example.com', path: '/api/gw' })
|
|
694
|
+
assert.equal(normal.body, 'dsh-gw', '非隧道域名宿主路由不受影响')
|
|
695
|
+
} finally {
|
|
696
|
+
await stopTarget(target)
|
|
697
|
+
}
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
test('场景25 关闭恢复: Given close 子域名隧道 Then Host 回落原路由且持久化行删除', async (t) => {
|
|
701
|
+
const webServer = new MockWebServer()
|
|
702
|
+
webServer.registerFallback((req, res) => { res.writeHead(200); res.end('fb') })
|
|
703
|
+
const plugin = await applyPlugin(webServer)
|
|
704
|
+
const target = startTarget((req, res) => { res.writeHead(200); res.end('t') })
|
|
705
|
+
await target.ready
|
|
706
|
+
try {
|
|
707
|
+
await open(plugin, { name: 'app', targetPort: target.port, entry: 'subdomain' })
|
|
708
|
+
assert.equal(storeCount(plugin.dataDir), 1)
|
|
709
|
+
const closed = await close(plugin, 'app')
|
|
710
|
+
assert.deepEqual(closed, { ok: true, removed: true })
|
|
711
|
+
const after = await dispatchVia(webServer, { host: 'app.localhost', path: '/x' })
|
|
712
|
+
assert.equal(after.body, 'fb', '关闭后子域名应回落 fallback')
|
|
713
|
+
assert.equal(storeCount(plugin.dataDir), 0)
|
|
714
|
+
} finally {
|
|
715
|
+
await stopTarget(target)
|
|
716
|
+
}
|
|
717
|
+
})
|
|
718
|
+
|
|
719
|
+
test('场景26 恢复: Given 混合 entry 持久化 Then 子域名进分发且路径模式照常注册路由', async (t) => {
|
|
720
|
+
const dataDir = mkdtempSync(join(tmpdir(), 'dsh-tunnel-test-'))
|
|
721
|
+
const firstServer = new MockWebServer()
|
|
722
|
+
const plugin = await applyPlugin(firstServer, { dataDir })
|
|
723
|
+
await open(plugin, { name: 'sub1', targetPort: 81, entry: 'subdomain' })
|
|
724
|
+
await open(plugin, { name: 'pat1', targetPort: 82 })
|
|
725
|
+
plugin.dispose()
|
|
726
|
+
const secondServer = new MockWebServer()
|
|
727
|
+
secondServer.registerFallback((req, res) => { res.writeHead(200); res.end('fb') })
|
|
728
|
+
const second = await applyPlugin(secondServer, { dataDir })
|
|
729
|
+
const hit = await dispatchVia(secondServer, { host: 'sub1.localhost', path: '/any' })
|
|
730
|
+
assert.equal(hit.status, 502, '子域名恢复后命中分发(目标未启动按 502 错误路径)')
|
|
731
|
+
assert.ok(secondServer.prefixes.has('/p/pat1'), '路径模式照常注册路由')
|
|
732
|
+
const listed = await list(second)
|
|
733
|
+
assert.deepEqual(listed.tunnels.map((row) => [row.name, row.entry]).sort(), [['pat1', 'path'], ['sub1', 'subdomain']])
|
|
734
|
+
rmSync(dataDir, { recursive: true, force: true })
|
|
735
|
+
})
|
|
736
|
+
|
|
737
|
+
test('场景27 entry 校验: Given 非法或缺省 entry 与 subdomain 带 wsPaths Then 各按语义处理', async (t) => {
|
|
738
|
+
const plugin = await applyPlugin(new MockWebServer())
|
|
739
|
+
const bad = await open(plugin, { name: 'app', targetPort: 80, entry: 'host' })
|
|
740
|
+
assert.equal(bad.ok, false)
|
|
741
|
+
assert.match(bad.error, /entry/)
|
|
742
|
+
assert.equal(tunnelPrefixCount(plugin.webServer), 0)
|
|
743
|
+
const byDefault = await open(plugin, { name: 'app', targetPort: 80 })
|
|
744
|
+
assert.equal(byDefault.entry, 'path')
|
|
745
|
+
assert.ok(plugin.webServer.prefixes.has('/p/app'))
|
|
746
|
+
await close(plugin, 'app')
|
|
747
|
+
const sub = await open(plugin, { name: 'web', targetPort: 80, entry: 'subdomain', wsPaths: ['/_x'] })
|
|
748
|
+
assert.equal(sub.ok, true)
|
|
749
|
+
const rows = JSON.parse(readFileSync(join(plugin.dataDir, 'tunnels.json'), 'utf8'))
|
|
750
|
+
assert.deepEqual(rows.find((row) => row.name === 'web'), { name: 'web', targetPort: 80, entry: 'subdomain', wsPaths: [], createdAt: rows.find((row) => row.name === 'web').createdAt })
|
|
751
|
+
})
|
|
752
|
+
|
|
753
|
+
test('场景28 名字跨模式唯一: Given path 模式占用 Then subdomain 同名拒收', async (t) => {
|
|
754
|
+
const plugin = await applyPlugin(new MockWebServer())
|
|
755
|
+
await open(plugin, { name: 'app', targetPort: 80 })
|
|
756
|
+
const clash = await open(plugin, { name: 'app', targetPort: 81, entry: 'subdomain' })
|
|
757
|
+
assert.equal(clash.ok, false)
|
|
758
|
+
assert.match(clash.error, /已占用/)
|
|
759
|
+
})
|
|
760
|
+
|
|
761
|
+
test('场景29 代际安全: Given dispose 后重挂载 Then 分发载体被新代接管且旧包装不叠加', async (t) => {
|
|
762
|
+
const webServer = new MockWebServer()
|
|
763
|
+
webServer.registerFallback((req, res) => { res.writeHead(200); res.end('fb') })
|
|
764
|
+
const first = await applyPlugin(webServer)
|
|
765
|
+
const target = startTarget((req, res) => { res.writeHead(200); res.end('t') })
|
|
766
|
+
await target.ready
|
|
767
|
+
try {
|
|
768
|
+
await open(first, { name: 'app', targetPort: target.port, entry: 'subdomain' })
|
|
769
|
+
first.dispose()
|
|
770
|
+
const disposed = await dispatchVia(webServer, { host: 'app.localhost', path: '/x' })
|
|
771
|
+
assert.equal(disposed.body, 'fb', 'dispose 后载体穿透')
|
|
772
|
+
|
|
773
|
+
// 捕获点在二代 apply 之前: 引用相同才能证明标记防叠加生效
|
|
774
|
+
const fbBefore = webServer.fallback
|
|
775
|
+
const second = await applyPlugin(webServer)
|
|
776
|
+
await open(second, { name: 'app', targetPort: target.port, entry: 'subdomain' })
|
|
777
|
+
const revived = await dispatchVia(webServer, { host: 'app.localhost', path: '/x' })
|
|
778
|
+
assert.equal(revived.body, 't', '重挂载后新代分发接管')
|
|
779
|
+
assert.ok(webServer.fallback === fbBefore, '标记防叠加: 第二次 apply 不再包装同一 handler')
|
|
780
|
+
} finally {
|
|
781
|
+
await stopTarget(target)
|
|
782
|
+
}
|
|
783
|
+
})
|
|
784
|
+
|
|
785
|
+
test('场景31 恢复容错: Given 非法 entry 或重复名行 Then 告警并仅恢复首行', async (t) => {
|
|
786
|
+
const dataDir = mkdtempSync(join(tmpdir(), 'dsh-tunnel-test-'))
|
|
787
|
+
t.after(() => rmSync(dataDir, { recursive: true, force: true }))
|
|
788
|
+
mkdirSync(dataDir, { recursive: true })
|
|
789
|
+
writeFileSync(join(dataDir, 'tunnels.json'), JSON.stringify([
|
|
790
|
+
{ name: 'dup', targetPort: 80, entry: 'path', wsPaths: [], createdAt: 't1' },
|
|
791
|
+
{ name: 'dup', targetPort: 81, entry: 'subdomain', wsPaths: [], createdAt: 't2' },
|
|
792
|
+
{ name: 'bad', targetPort: 80, entry: 'host', wsPaths: [], createdAt: 't3' },
|
|
793
|
+
]), 'utf8')
|
|
794
|
+
const plugin = await applyPlugin(new MockWebServer(), { dataDir })
|
|
795
|
+
assert.ok(plugin.webServer.prefixes.has('/p/dup'), '重复名仅恢复首行')
|
|
796
|
+
const listed = await list(plugin)
|
|
797
|
+
assert.deepEqual(listed.tunnels.map((row) => row.name), ['dup'])
|
|
798
|
+
assert.match(plugin.warnings.join(''), /重复名/)
|
|
799
|
+
assert.match(plugin.warnings.join(''), /entry 非法/)
|
|
800
|
+
})
|
|
801
|
+
|
|
802
|
+
test('场景32 超时载体换代: Given 二代更短超时 Then 一代包装器按新值判 504', { timeout: 10 * 1000 }, async (t) => {
|
|
803
|
+
const webServer = new MockWebServer()
|
|
804
|
+
webServer.registerFallback((req, res) => { res.writeHead(200); res.end('fb') })
|
|
805
|
+
// 不响应的 sink: 连接接受但永不应答, 触发响应头等待超时
|
|
806
|
+
const sink = net.createServer(() => {})
|
|
807
|
+
const sinkConnections = new Set()
|
|
808
|
+
sink.on('connection', (conn) => {
|
|
809
|
+
sinkConnections.add(conn)
|
|
810
|
+
conn.on('close', () => sinkConnections.delete(conn))
|
|
811
|
+
})
|
|
812
|
+
await once(sink.listen(0), 'listening')
|
|
813
|
+
try {
|
|
814
|
+
const first = await applyPlugin(webServer)
|
|
815
|
+
await open(first, { name: 'app', targetPort: sink.address().port, entry: 'subdomain' })
|
|
816
|
+
first.dispose()
|
|
817
|
+
const second = await applyPlugin(webServer, { connectTimeoutMs: 100 })
|
|
818
|
+
await open(second, { name: 'app', targetPort: sink.address().port, entry: 'subdomain' })
|
|
819
|
+
const started = Date.now()
|
|
820
|
+
const res = await dispatchVia(webServer, { host: 'app.localhost', path: '/x' })
|
|
821
|
+
const elapsed = Date.now() - started
|
|
822
|
+
assert.equal(res.status, 504)
|
|
823
|
+
assert.ok(elapsed < 2000, `应按二代 100ms 超时判 504(实际 ${elapsed}ms)`)
|
|
824
|
+
} finally {
|
|
825
|
+
for (const conn of sinkConnections) conn.destroy()
|
|
826
|
+
sink.close()
|
|
827
|
+
}
|
|
828
|
+
})
|
|
829
|
+
|
|
830
|
+
test('场景30 形状守卫: Given webServer 缺少分发面 Then 干净停用且不注册工具', async (t) => {
|
|
831
|
+
const plugin = await applyPlugin({ port: 3080 })
|
|
832
|
+
assert.deepEqual(plugin.tools, {})
|
|
833
|
+
assert.equal(typeof plugin.dispose, 'function')
|
|
834
|
+
assert.match(plugin.warnings.join(''), /形状不匹配/)
|
|
835
|
+
})
|
|
836
|
+
|
|
837
|
+
test('场景33 超时热生效: Given 设置页改短超时 Then 既有两类隧道新请求按新值判 504', { timeout: 10 * 1000 }, async (t) => {
|
|
838
|
+
const webServer = new MockWebServer()
|
|
839
|
+
webServer.registerFallback((req, res) => { res.writeHead(200); res.end('fb') })
|
|
840
|
+
const settings = createSettingsService()
|
|
841
|
+
const sink = net.createServer(() => {})
|
|
842
|
+
const sinkConnections = new Set()
|
|
843
|
+
sink.on('connection', (conn) => {
|
|
844
|
+
sinkConnections.add(conn)
|
|
845
|
+
conn.on('close', () => sinkConnections.delete(conn))
|
|
846
|
+
})
|
|
847
|
+
await once(sink.listen(0), 'listening')
|
|
848
|
+
try {
|
|
849
|
+
const plugin = await applyPlugin(webServer, { settings: settings.service })
|
|
850
|
+
const sinkPort = sink.address().port
|
|
851
|
+
await open(plugin, { name: 'path-app', targetPort: sinkPort })
|
|
852
|
+
await open(plugin, { name: 'sub-app', targetPort: sinkPort, entry: 'subdomain' })
|
|
853
|
+
settings.setTunnel({ connectTimeoutMs: 100 })
|
|
854
|
+
const started = Date.now()
|
|
855
|
+
const viaPath = await requestVia(webServer, '/p/path-app/x')
|
|
856
|
+
const viaSub = await dispatchVia(webServer, { host: 'sub-app.localhost', path: '/x' })
|
|
857
|
+
const elapsed = Date.now() - started
|
|
858
|
+
assert.equal(viaPath.status, 504, '路径模式按新值判 504')
|
|
859
|
+
assert.equal(viaSub.status, 504, '子域名模式按新值判 504')
|
|
860
|
+
assert.ok(elapsed < 2000, `两类请求合计应在 2s 内超时(实际 ${elapsed}ms)`)
|
|
861
|
+
} finally {
|
|
862
|
+
for (const conn of sinkConnections) conn.destroy()
|
|
863
|
+
sink.close()
|
|
864
|
+
}
|
|
865
|
+
})
|
|
866
|
+
|
|
867
|
+
test('场景34 创建时间透出: Given 任意入口建隧道 Then list 行携带 createdAt', async (t) => {
|
|
868
|
+
const plugin = await applyPlugin(new MockWebServer())
|
|
869
|
+
await open(plugin, { name: 'app', targetPort: 80 })
|
|
870
|
+
const listed = await list(plugin)
|
|
871
|
+
const row = listed.tunnels[0]
|
|
872
|
+
assert.equal(typeof row.createdAt, 'string', 'createdAt 随行透出(ISO 形态, 与持久化同源)')
|
|
873
|
+
assert.ok(!Number.isNaN(Date.parse(row.createdAt)), 'createdAt 可解析')
|
|
874
|
+
})
|
|
875
|
+
|
|
876
|
+
test('场景35 超时激活同步: Given settings 预置持久化值 Then apply 后新请求按该值判 504', { timeout: 10 * 1000 }, async (t) => {
|
|
877
|
+
const webServer = new MockWebServer()
|
|
878
|
+
webServer.registerFallback((req, res) => { res.writeHead(200); res.end('fb') })
|
|
879
|
+
// register 合并持久化值的宿主语义: get 即返回预置值, watch 不触发(激活非 commit)
|
|
880
|
+
const settings = createSettingsService({ connectTimeoutMs: 100, sidebarTab: false })
|
|
881
|
+
const sink = net.createServer(() => {})
|
|
882
|
+
const sinkConnections = new Set()
|
|
883
|
+
sink.on('connection', (conn) => {
|
|
884
|
+
sinkConnections.add(conn)
|
|
885
|
+
conn.on('close', () => sinkConnections.delete(conn))
|
|
886
|
+
})
|
|
887
|
+
await once(sink.listen(0), 'listening')
|
|
888
|
+
try {
|
|
889
|
+
const plugin = await applyPlugin(webServer, { settings: settings.service })
|
|
890
|
+
await open(plugin, { name: 'app', targetPort: sink.address().port })
|
|
891
|
+
const started = Date.now()
|
|
892
|
+
const res = await requestVia(webServer, '/p/app/x')
|
|
893
|
+
const elapsed = Date.now() - started
|
|
894
|
+
assert.equal(res.status, 504, '激活期按 settings 持久化值判 504')
|
|
895
|
+
assert.ok(elapsed < 2000, `应按预置 100ms 而非 Config 默认判超时(实际 ${elapsed}ms)`)
|
|
896
|
+
} finally {
|
|
897
|
+
for (const conn of sinkConnections) conn.destroy()
|
|
898
|
+
sink.close()
|
|
899
|
+
}
|
|
900
|
+
})
|
|
901
|
+
|
|
902
|
+
test('场景36 REST 打穿: Given wrapHttp 包装后的 /api/tunnel Then GUI 通道与 ai 同表', async (t) => {
|
|
903
|
+
const plugin = await applyPlugin(new MockWebServer())
|
|
904
|
+
await open(plugin, { name: 'pre', targetPort: 80 })
|
|
905
|
+
const listed = await requestVia(plugin.webServer, '/api/tunnel/tunnels')
|
|
906
|
+
assert.equal(listed.status, 200)
|
|
907
|
+
const items = JSON.parse(listed.body).items
|
|
908
|
+
assert.ok(items.some((row) => row.name === 'pre'), 'REST 列表可见 ai 打开的隧道')
|
|
909
|
+
const created = await requestVia(plugin.webServer, '/api/tunnel/tunnels', {
|
|
910
|
+
method: 'POST',
|
|
911
|
+
headers: { 'content-type': 'application/json' },
|
|
912
|
+
body: JSON.stringify({ name: 'via-rest', targetPort: 81, entry: 'subdomain' }),
|
|
913
|
+
})
|
|
914
|
+
assert.equal(created.status, 200)
|
|
915
|
+
assert.equal(JSON.parse(created.body).ok, true)
|
|
916
|
+
const listed2 = await list(plugin)
|
|
917
|
+
assert.ok(listed2.tunnels.some((row) => row.name === 'via-rest'), 'ai 列表可见 REST 创建的隧道')
|
|
918
|
+
const removed = await requestVia(plugin.webServer, '/api/tunnel/tunnels/via-rest', { method: 'DELETE' })
|
|
919
|
+
assert.equal(removed.status, 200)
|
|
920
|
+
assert.deepEqual(JSON.parse(removed.body), { ok: true, removed: true })
|
|
921
|
+
})
|