@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/src/index.js ADDED
@@ -0,0 +1,695 @@
1
+ // dsh-tunnel Host 半区: /p/<name> 前缀路由 strip 转发与 <name>.* 子域名全路径
2
+ // 透传(任意 WS 路径)双入口, tunnels.json 持久化与激活恢复; ai 工具三件
3
+ // (tunnel_open/list/close)经 tools 服务注册, 注册面随「允许 ai 使用」开关同步,
4
+ // 关闭即注销。子域名分流用 wrap+shadow, 与 dsh-auto-trust-all 同构; 规格:
5
+ // docs/调研-路径穿透插件.md「方案设计」两节; 干净禁用由 inject ['webServer']
6
+ // 门控, 无任何自有认证层(仓库安全边界约定)。
7
+
8
+ import http from 'node:http'
9
+ import net from 'node:net'
10
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
11
+ import { homedir } from 'node:os'
12
+ import { join } from 'node:path'
13
+ import z from '@deepseek-ai/schemastery'
14
+ import { defineTool } from '@deepseek-ai/dsh-tools'
15
+
16
+ import { createApi, MESSAGES, ROUTE_PREFIX as API_PREFIX } from './api.mjs'
17
+
18
+ export const name = 'dsh-tunnel'
19
+
20
+ export const inject = ['webServer']
21
+
22
+ const PATH_PREFIX = '/p/'
23
+ const TUNNEL_NAME_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
24
+ const WS_PATH_CHARS_RE = /^\/[A-Za-z0-9/._~-]*$/
25
+ const PORT_MIN = 1
26
+ const PORT_MAX = 65535
27
+ const DEFAULT_CONNECT_TIMEOUT = 10 * 1000
28
+ const FORWARDED_PROTO = 'http'
29
+ const REDIRECT_MIN = 300
30
+ const REDIRECT_MAX = 399
31
+ const STORE_FILE = 'tunnels.json'
32
+ const STORE_TMP_SUFFIX = '.tmp'
33
+ const WARN_TAG = 'dsh-tunnel: '
34
+
35
+ const ENTRY_PATH = 'path'
36
+ const ENTRY_SUBDOMAIN = 'subdomain'
37
+ const VALID_ENTRIES = new Set([ENTRY_PATH, ENTRY_SUBDOMAIN])
38
+ // 设置命名空间与 UI 偏好: 设置页独立配置节 + 侧边栏注入开关(设计: docs/设计-隧道GUI.md)
39
+ const SETTINGS_NS = 'tunnel'
40
+ const SETTINGS_UNAVAILABLE = '设置服务不可用'
41
+ // 分流面标记: 防包装叠加/防重复 shadow/防 upgrades 表二次拦截
42
+ const WRAPPED_PROP = 'dshTunnelWrapped'
43
+ const SHADOWED_PROP = 'dshTunnelShadowed'
44
+ const UPGRADE_TABLE_PROP = 'dshTunnelUpgradeTable'
45
+ // 分发载体: 包装器调用期动态读取, 插件重载后旧包装器自动路由到新代
46
+ const DISPATCH_PROP = 'dshTunnelDispatch'
47
+ // 超时载体: 同代际切换, config 变更后旧包装器读到新值
48
+ const DISPATCH_TIMEOUT_PROP = 'dshTunnelConnectTimeoutMs'
49
+ const TIMED_OUT_MARK = Symbol.for('dsh-tunnel.timedOut')
50
+
51
+ const renderJson = (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }]
52
+
53
+ // 逐跳头不透传: 连接管理由两端的 node http 栈各自负责
54
+ const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'transfer-encoding', 'upgrade'])
55
+ // 信任链头剥离: 入站伪造的代理链声明一律替换为本插件视角的真实值
56
+ const FORWARDED_LIE_HEADERS = ['forwarded', 'via', 'x-real-ip']
57
+
58
+ // 超时与偏好字段 Config 与 SETTINGS_SCHEMA 同名同约束双写, 单一工厂防漂移
59
+ const TIMEOUT_MIN_MS = 1
60
+ const TIMEOUT_MAX_MS = 60 * 1000
61
+ const timeoutField = () => z.number().step(1).min(TIMEOUT_MIN_MS).max(TIMEOUT_MAX_MS).default(DEFAULT_CONNECT_TIMEOUT)
62
+ .description('等待目标响应头的超时, 头到即解除(SSE 等长流不受影响)')
63
+ const aiToolsField = () => z.boolean().default(true)
64
+ .description('允许 ai 调用隧道工具(tunnel_open/list/close), 关闭即向 ai 注销; 看板与 REST 管理不受影响')
65
+ const sidebarTabField = () => z.boolean().default(false)
66
+ .description('看板移入 better-sidebar 侧边栏(需已安装; 关闭时始终使用主界面)')
67
+
68
+ // 字段级 volatile 标记: 0.1.7+ 该字段进自动配置节并支持 loader 原地热更;
69
+ // 旧宿主 schemastery 无 volatile 方法, 特性检测原样返回(dsh-maintain 同构)
70
+ const volatileField = (schema) => (typeof schema.volatile === 'function' ? schema.volatile() : schema)
71
+
72
+ export const Config = z.object({
73
+ connectTimeoutMs: volatileField(timeoutField()),
74
+ dataDir: z.string().default(join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'dsh-tunnel'))
75
+ .description('隧道表持久化目录'),
76
+ aiTools: volatileField(aiToolsField()),
77
+ sidebarTab: volatileField(sidebarTabField()),
78
+ })
79
+
80
+ // 设置页独立配置节(legacy 注册面): 偏好字段与 Config 单一工厂同源,
81
+ // dataDir 属环境路径不进 UI
82
+ export const SETTINGS_SCHEMA = z.object({
83
+ connectTimeoutMs: timeoutField(),
84
+ aiTools: aiToolsField(),
85
+ sidebarTab: sidebarTabField(),
86
+ })
87
+
88
+ const normalizeWsPath = (value) => value.replace(/\/+$/, '')
89
+
90
+ // 校验作用于归一前的原始值; '/' 为合法输入
91
+ function normalizeWsPaths(raw) {
92
+ if (raw === undefined) return []
93
+ if (!Array.isArray(raw) || raw.some((item) => typeof item !== 'string' || !WS_PATH_CHARS_RE.test(item))) return undefined
94
+ return [...new Set(raw.map(normalizeWsPath))]
95
+ }
96
+
97
+ function validateName(name) {
98
+ return typeof name === 'string' && TUNNEL_NAME_RE.test(name)
99
+ }
100
+
101
+ function validatePort(port) {
102
+ return Number.isInteger(port) && port >= PORT_MIN && port <= PORT_MAX
103
+ }
104
+
105
+ // strip 隧道前缀: 前缀本身映射 '/', 其余保留前导 '/' 携带查询串
106
+ function targetPathOf(reqUrl, prefix) {
107
+ const url = new URL(reqUrl, 'http://x')
108
+ const rest = url.pathname === prefix ? '/' : url.pathname.slice(prefix.length)
109
+ return rest + url.search
110
+ }
111
+
112
+ // 上游路径: 子域名全路径透传, 路径模式 strip 隧道前缀
113
+ function upstreamPathOf(req, record) {
114
+ if (record.entry === ENTRY_SUBDOMAIN) return req.url
115
+ return targetPathOf(req.url, PATH_PREFIX + record.name)
116
+ }
117
+
118
+ // 子域名匹配: hostname 首标签 === 隧道名且含点(多级名由名称正则单标签保证不误吸)
119
+ // host 解析单槽 memo: 分发在宿主全量请求热路径上, 同 Host 连发时免重复 URL 解析
120
+ let hostMemoRaw
121
+ let hostMemoValue
122
+
123
+ function hostOf(req) {
124
+ const raw = req.headers.host
125
+ if (typeof raw !== 'string') return ''
126
+ if (raw === hostMemoRaw) return hostMemoValue
127
+ hostMemoRaw = raw
128
+ try {
129
+ hostMemoValue = new URL('http://' + raw).hostname.toLowerCase()
130
+ } catch {
131
+ hostMemoValue = ''
132
+ }
133
+ return hostMemoValue
134
+ }
135
+
136
+ function matchSubdomain(req, subdomains) {
137
+ const hostname = hostOf(req)
138
+ if (!hostname.includes('.')) return undefined
139
+ for (const record of subdomains.values()) {
140
+ if (hostname.startsWith(record.name + '.')) return record
141
+ }
142
+ return undefined
143
+ }
144
+
145
+ function forwardedHeaders(req, { keepHopByHop = false } = {}) {
146
+ const headers = {}
147
+ for (const [key, value] of Object.entries(req.headers)) {
148
+ if (key.startsWith('x-forwarded-') || FORWARDED_LIE_HEADERS.includes(key)) continue
149
+ if (!keepHopByHop && HOP_BY_HOP.has(key)) continue
150
+ headers[key] = value
151
+ }
152
+ // 无 Host 头的请求(HTTP/1.0 等)缺源值时跳过, undefined 头会使 http.request 抛错
153
+ if (req.headers.host !== undefined) headers['x-forwarded-host'] = req.headers.host
154
+ if (req.socket?.remoteAddress !== undefined) headers['x-forwarded-for'] = req.socket.remoteAddress
155
+ headers['x-forwarded-proto'] = FORWARDED_PROTO
156
+ return headers
157
+ }
158
+
159
+ // 唯一响应体改写: 同源根绝对路径补前缀(排除协议相对 //), 其余原样
160
+ function rewriteLocation(proxyRes, prefix) {
161
+ const location = proxyRes.headers.location
162
+ if (
163
+ proxyRes.statusCode >= REDIRECT_MIN && proxyRes.statusCode <= REDIRECT_MAX
164
+ && typeof location === 'string' && location.startsWith('/')
165
+ && !location.startsWith('//') && !location.startsWith(prefix)
166
+ ) {
167
+ proxyRes.headers.location = prefix + location
168
+ }
169
+ }
170
+
171
+ function respondJson(res, status, payload) {
172
+ res.writeHead(status, { 'content-type': 'application/json' })
173
+ res.end(JSON.stringify(payload))
174
+ }
175
+
176
+ function forwardHttp(req, res, record, connectTimeoutMs) {
177
+ const proxyReq = http.request({
178
+ host: '127.0.0.1',
179
+ port: record.targetPort,
180
+ method: req.method,
181
+ path: upstreamPathOf(req, record),
182
+ headers: forwardedHeaders(req),
183
+ })
184
+ proxyReq.on('socket', (socket) => {
185
+ socket.setTimeout(connectTimeoutMs)
186
+ proxyReq.once('response', () => socket.setTimeout(0))
187
+ })
188
+ proxyReq.once('timeout', () => {
189
+ markTimedOut(proxyReq)
190
+ proxyReq.destroy()
191
+ })
192
+ proxyReq.on('response', (proxyRes) => {
193
+ // 仅路径模式改写 Location: 子域名根部署下同源根绝对路径本就正确
194
+ if (record.entry === ENTRY_PATH) rewriteLocation(proxyRes, PATH_PREFIX + record.name)
195
+ const headers = {}
196
+ for (const [key, value] of Object.entries(proxyRes.headers)) {
197
+ if (!HOP_BY_HOP.has(key)) headers[key] = value
198
+ }
199
+ res.writeHead(proxyRes.statusCode, headers)
200
+ proxyRes.pipe(res)
201
+ })
202
+ proxyReq.on('error', (error) => {
203
+ if (res.headersSent) return res.destroy()
204
+ const status = socketTimedOut(proxyReq) ? 504 : 502
205
+ respondJson(res, status, { ok: false, error: `目标不可达: ${error.message}` })
206
+ })
207
+ // 客户端在响应任意阶段断开都回收上游连接(aborted 只覆盖请求体阶段, SSE/GET 不触发)
208
+ res.on('close', () => {
209
+ if (!res.writableEnded) proxyReq.destroy()
210
+ })
211
+ req.pipe(proxyReq)
212
+ }
213
+
214
+ // node 不带 code 的 timeout 销毁只能靠标记: socket timeout 事件打点
215
+ function socketTimedOut(proxyReq) {
216
+ return proxyReq[TIMED_OUT_MARK] === true
217
+ }
218
+
219
+ function markTimedOut(proxyReq) {
220
+ proxyReq[TIMED_OUT_MARK] = true
221
+ }
222
+
223
+ // 升级透传: 重建请求头直写目标 socket(握手头 connection/upgrade/sec-websocket-* 必须原样保留), 之后双向管道
224
+ function forwardUpgrade(req, socket, head, record, connectTimeoutMs) {
225
+ const headerLines = Object.entries(forwardedHeaders(req, { keepHopByHop: true }))
226
+ .map(([key, value]) => `${key}: ${value}`)
227
+ .join('\r\n')
228
+ const headText = `${req.method} ${upstreamPathOf(req, record)} HTTP/1.1\r\n${headerLines}\r\n\r\n`
229
+ const upstream = net.connect({ host: '127.0.0.1', port: record.targetPort })
230
+ const destroyAll = () => {
231
+ upstream.destroy()
232
+ socket.destroy()
233
+ }
234
+ upstream.setTimeout(connectTimeoutMs, () => destroyAll())
235
+ // 目标首字节(101 握手)到达即解除空闲计时, 建立后的长连接不受连接超时误杀
236
+ upstream.once('data', () => upstream.setTimeout(0))
237
+ upstream.on('error', destroyAll)
238
+ socket.on('error', destroyAll)
239
+ upstream.on('connect', () => {
240
+ upstream.write(headText)
241
+ if (head.length > 0) upstream.write(head)
242
+ socket.pipe(upstream)
243
+ upstream.pipe(socket)
244
+ })
245
+ }
246
+
247
+ function storePath(dataDir) {
248
+ return join(dataDir, STORE_FILE)
249
+ }
250
+
251
+ // 损坏文件按空表处理并告警, 不阻塞激活(禁止加载崩溃)
252
+ function loadStore(dataDir) {
253
+ let raw
254
+ try {
255
+ raw = readFileSync(storePath(dataDir), 'utf8')
256
+ } catch (error) {
257
+ if (error.code !== 'ENOENT') console.warn(`${WARN_TAG}隧道表读取失败, 按空表处理: ${error.message}`)
258
+ return []
259
+ }
260
+ try {
261
+ const rows = JSON.parse(raw)
262
+ return Array.isArray(rows) ? rows : []
263
+ } catch {
264
+ console.warn(`${WARN_TAG}隧道表损坏, 按空表处理: ${storePath(dataDir)}`)
265
+ return []
266
+ }
267
+ }
268
+
269
+ // 写盘失败告警: 隧道本次会话可用, 重启后消失, open 仍返回成功
270
+ function saveStore(dataDir, rows) {
271
+ try {
272
+ mkdirSync(dataDir, { recursive: true })
273
+ const path = storePath(dataDir)
274
+ writeFileSync(path + STORE_TMP_SUFFIX, JSON.stringify(rows, null, 2))
275
+ renameSync(path + STORE_TMP_SUFFIX, path)
276
+ } catch (error) {
277
+ console.warn(`${WARN_TAG}隧道表写盘失败: ${error.message}`)
278
+ }
279
+ }
280
+
281
+ export function apply(ctx, config) {
282
+ const webServer = ctx.webServer
283
+
284
+ // 形状守卫: 宿主形态漂移时干净禁用并告警, 绝不破坏宿主
285
+ const shapeOk = webServer.exact instanceof Map
286
+ && webServer.prefixes instanceof Map
287
+ && webServer.upgrades instanceof Map
288
+ && typeof webServer.register === 'function'
289
+ && typeof webServer.registerUpgrade === 'function'
290
+ && typeof webServer.registerFallback === 'function'
291
+ && 'fallback' in webServer
292
+ if (!shapeOk) {
293
+ console.warn(`${WARN_TAG}webServer 形状不匹配, 插件停用`)
294
+ return () => {}
295
+ }
296
+
297
+ // 可变绑定: 偏好改动经 legacy watch / 0.1.7+ volatile-update 回写, 载体与各
298
+ // handler 按请求期读到新值
299
+ let connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT
300
+ const dataDir = config.dataDir
301
+
302
+ // 偏好面读值: legacy 设置节优先(register 时已合并持久值), 0.1.7+ 走 Config
303
+ // volatile ref(loader 原地热更, get 协议动态解包); 设置服务缺席回退 config 初值
304
+ const settingsRef = { current: null }
305
+ const unwrapRef = (value) => (typeof value?.get === 'function' ? value.get() : value)
306
+ const readPref = (key) => {
307
+ const fromSettings = settingsRef.current?.get(SETTINGS_NS)?.[key]
308
+ return fromSettings !== undefined ? fromSettings : unwrapRef(config?.[key])
309
+ }
310
+ const aiToolsNow = () => readPref('aiTools') !== false
311
+
312
+ // ai 工具三件注册开关: 关闭即注销(干净禁用), GUI/REST 同表不受影响;
313
+ // 注入等待期开关可能已翻转, 回调内以当下值复核, 防迟到注册
314
+ let disposeTools = null
315
+ const syncToolsRegistration = () => {
316
+ if (aiToolsNow() && disposeTools === null) {
317
+ ctx.inject(['tools'], (tctx) => {
318
+ if (disposeTools !== null || !aiToolsNow()) return
319
+ const disposers = tools.map((tool) => tctx.tools.register(tool))
320
+ disposeTools = () => { for (const dispose of disposers) dispose() }
321
+ })
322
+ } else if (!aiToolsNow() && disposeTools !== null) {
323
+ disposeTools()
324
+ disposeTools = null
325
+ }
326
+ }
327
+
328
+ // 偏好重读: 超时越界保持旧值, 工具注册面随开关同步; legacy watch 与
329
+ // 0.1.7+ volatile-update 两条变更通道共用
330
+ const applyPreferences = () => {
331
+ const timeout = Number(readPref('connectTimeoutMs'))
332
+ if (Number.isInteger(timeout) && timeout >= TIMEOUT_MIN_MS && timeout <= TIMEOUT_MAX_MS) connectTimeoutMs = timeout
333
+ syncToolsRegistration()
334
+ }
335
+
336
+ // 活动隧道: name → 记录 + 本次激活注册的路由回收器(路径模式才有路由可回收)
337
+ const tunnels = new Map()
338
+ // 子域名活跃表: 分发器活读, open/close 只需增删此表
339
+ const subdomains = new Map()
340
+
341
+ // 单隧道事务: 任一注册失败回滚本次已注册的全部路由
342
+ function registerRoutes(record) {
343
+ const prefix = PATH_PREFIX + record.name
344
+ const undo = []
345
+ const run = (fn) => {
346
+ try {
347
+ undo.push(fn())
348
+ } catch (error) {
349
+ for (const dispose of undo.reverse()) dispose()
350
+ throw error
351
+ }
352
+ }
353
+ run(() => webServer.register({
354
+ kind: 'prefix',
355
+ path: prefix,
356
+ handler: (req, res) => forwardHttp(req, res, record, connectTimeoutMs),
357
+ }))
358
+ for (const value of record.wsPaths) {
359
+ for (const suffix of [value, value + '/']) {
360
+ run(() => webServer.registerUpgrade({
361
+ path: prefix + suffix,
362
+ handler: (req, socket, head) => forwardUpgrade(req, socket, head, record, connectTimeoutMs),
363
+ }))
364
+ }
365
+ }
366
+ return () => {
367
+ for (const dispose of undo.reverse()) dispose()
368
+ }
369
+ }
370
+
371
+ const persist = () => saveStore(dataDir, [...tunnels.values()].map(({ createdAt, entry, name, targetPort, wsPaths }) => ({ createdAt, entry, name, targetPort, wsPaths })))
372
+
373
+ const api = {
374
+ open(args) {
375
+ if (!validateName(args.name) || !validatePort(args.targetPort)) {
376
+ return { ok: false, error: '参数不合法: name 须为小写字母/数字/连字符(≤32 位), targetPort 须为 1..65535 整数' }
377
+ }
378
+ const entry = args.entry ?? ENTRY_PATH
379
+ if (!VALID_ENTRIES.has(entry)) {
380
+ return { ok: false, error: `entry 不合法: 须为 ${ENTRY_PATH} 或 ${ENTRY_SUBDOMAIN}` }
381
+ }
382
+ // 子域名全路径全协议透传, wsPaths 无意义
383
+ const wsPaths = entry === ENTRY_SUBDOMAIN ? [] : normalizeWsPaths(args.wsPaths)
384
+ if (wsPaths === undefined) {
385
+ return { ok: false, error: 'wsPaths 不合法: 每项须为白名单字符 [A-Za-z0-9/._~-] 的 / 开头非空路径' }
386
+ }
387
+ if (tunnels.has(args.name)) {
388
+ return { ok: false, error: `隧道名已占用: ${args.name}` }
389
+ }
390
+ const record = { name: args.name, targetPort: args.targetPort, entry, wsPaths, createdAt: new Date().toISOString() }
391
+ if (entry === ENTRY_SUBDOMAIN) {
392
+ subdomains.set(record.name, record)
393
+ tunnels.set(record.name, record)
394
+ persist()
395
+ return { ok: true, name: record.name, entry, host: `${record.name}.localhost`, port: webServer.port }
396
+ }
397
+ let disposeRoutes
398
+ try {
399
+ disposeRoutes = registerRoutes(record)
400
+ } catch (error) {
401
+ return { ok: false, error: `路由注册失败: ${error.message}` }
402
+ }
403
+ tunnels.set(record.name, { ...record, disposeRoutes })
404
+ persist()
405
+ return { ok: true, name: record.name, entry, path: PATH_PREFIX + record.name, port: webServer.port }
406
+ },
407
+ list() {
408
+ return {
409
+ ok: true,
410
+ tunnels: [...tunnels.values()].map(({ createdAt, entry, name, targetPort, wsPaths }) => ({
411
+ name,
412
+ entry,
413
+ access: entry === ENTRY_SUBDOMAIN ? `${name}.*` : PATH_PREFIX + name,
414
+ targetPort,
415
+ wsPaths,
416
+ createdAt,
417
+ })),
418
+ }
419
+ },
420
+ close(args) {
421
+ const record = tunnels.get(args.name)
422
+ if (record === undefined) return { ok: true, removed: false }
423
+ record.disposeRoutes?.()
424
+ subdomains.delete(args.name)
425
+ tunnels.delete(args.name)
426
+ persist()
427
+ return { ok: true, removed: true }
428
+ },
429
+ disposeAll() {
430
+ for (const record of tunnels.values()) record.disposeRoutes?.()
431
+ subdomains.clear()
432
+ tunnels.clear()
433
+ },
434
+ }
435
+
436
+ // 分流载体先行: 之后安装的包装器全部经它分发, 插件重载时整体切到新代
437
+ webServer[DISPATCH_PROP] = (req) => matchSubdomain(req, subdomains)
438
+ webServer[DISPATCH_TIMEOUT_PROP] = () => connectTimeoutMs
439
+
440
+ // 包装现存与未来路由: 隧道 Host 命中即转发, 否则原 handler;
441
+ // 超时经载体动态读, 旧代包装器在 config 变更后同样取新值
442
+ const timeoutNow = () => webServer[DISPATCH_TIMEOUT_PROP]?.() ?? connectTimeoutMs
443
+ const wrapHttp = (handler) => {
444
+ if (typeof handler !== 'function' || handler[WRAPPED_PROP]) return handler
445
+ const wrapped = (req, res) => {
446
+ const record = webServer[DISPATCH_PROP]?.(req)
447
+ if (record) return forwardHttp(req, res, record, timeoutNow())
448
+ return handler(req, res)
449
+ }
450
+ wrapped[WRAPPED_PROP] = true
451
+ return wrapped
452
+ }
453
+ const wrapUpgrade = (handler) => {
454
+ if (typeof handler !== 'function' || handler[WRAPPED_PROP]) return handler
455
+ const wrapped = (req, socket, head) => {
456
+ const record = webServer[DISPATCH_PROP]?.(req)
457
+ if (record) return forwardUpgrade(req, socket, head, record, timeoutNow())
458
+ return handler(req, socket, head)
459
+ }
460
+ wrapped[WRAPPED_PROP] = true
461
+ return wrapped
462
+ }
463
+ for (const route of webServer.exact.values()) route.handler = wrapHttp(route.handler)
464
+ for (const route of webServer.prefixes.values()) route.handler = wrapHttp(route.handler)
465
+ for (const route of webServer.upgrades.values()) route.handler = wrapUpgrade(route.handler)
466
+ if (typeof webServer.fallback === 'function') webServer.fallback = wrapHttp(webServer.fallback)
467
+ const shadow = (method, wrapRoute) => {
468
+ const previous = webServer[method]
469
+ if (typeof previous !== 'function') {
470
+ console.warn(`${WARN_TAG}webServer.${method} 缺失, 该入口不做未来路由包装`)
471
+ return
472
+ }
473
+ if (previous[SHADOWED_PROP]) return
474
+ const shadowed = (arg) => previous.call(webServer, wrapRoute(arg))
475
+ shadowed[SHADOWED_PROP] = true
476
+ webServer[method] = shadowed
477
+ }
478
+ shadow('register', (route) => {
479
+ route.handler = wrapHttp(route.handler)
480
+ return route
481
+ })
482
+ shadow('registerUpgrade', (route) => {
483
+ route.handler = wrapUpgrade(route.handler)
484
+ return route
485
+ })
486
+ shadow('registerFallback', (handler) => wrapHttp(handler))
487
+
488
+ // upgrades 表拦截: 官方分发仅 exact 且 miss 即销毁 socket, 动态 WS 路径
489
+ // (如 jupyter 内核通道)只能在 miss 层合成隧道分发路由; 命中路由仍走原表
490
+ if (!webServer.upgrades[UPGRADE_TABLE_PROP]) {
491
+ const table = new Map(webServer.upgrades)
492
+ const originalGet = table.get.bind(table)
493
+ table[UPGRADE_TABLE_PROP] = true
494
+ table.get = (pathname) => {
495
+ const route = originalGet(pathname)
496
+ if (route !== undefined) return route
497
+ return {
498
+ handler: (req, socket, head) => {
499
+ const record = webServer[DISPATCH_PROP]?.(req)
500
+ if (record) return forwardUpgrade(req, socket, head, record, timeoutNow())
501
+ socket.destroy()
502
+ },
503
+ }
504
+ }
505
+ webServer.upgrades = table
506
+ }
507
+
508
+ // 激活恢复: 路径模式沿用单隧道事务(冲突回滚并告警, 绝不阻塞激活),
509
+ // 子域名模式仅入表(无路由注册故无失败面); 旧记录缺 entry 视为路径模式
510
+ for (const row of loadStore(dataDir)) {
511
+ if (!validateName(row?.name) || !validatePort(row?.targetPort)) {
512
+ console.warn(`${WARN_TAG}隧道表存在非法行, 已跳过: ${JSON.stringify(row)}`)
513
+ continue
514
+ }
515
+ if (tunnels.has(row.name)) {
516
+ console.warn(`${WARN_TAG}隧道表存在重复名 ${row.name}, 仅恢复首行`)
517
+ continue
518
+ }
519
+ if (row.entry !== undefined && row.entry !== ENTRY_PATH && row.entry !== ENTRY_SUBDOMAIN) {
520
+ console.warn(`${WARN_TAG}隧道 ${row.name} 的 entry 非法, 已跳过: ${String(row.entry)}`)
521
+ continue
522
+ }
523
+ if (row.entry === ENTRY_SUBDOMAIN) {
524
+ const record = { name: row.name, targetPort: row.targetPort, entry: ENTRY_SUBDOMAIN, wsPaths: [], createdAt: row.createdAt }
525
+ subdomains.set(record.name, record)
526
+ tunnels.set(record.name, record)
527
+ continue
528
+ }
529
+ const wsPaths = normalizeWsPaths(row.wsPaths)
530
+ if (wsPaths === undefined) console.warn(`${WARN_TAG}隧道 ${row.name} 的 wsPaths 非法, 本次按无 WS 恢复`)
531
+ const record = { ...row, entry: ENTRY_PATH, wsPaths: wsPaths ?? [] }
532
+ try {
533
+ const disposeRoutes = registerRoutes(record)
534
+ tunnels.set(record.name, { ...record, disposeRoutes })
535
+ } catch (error) {
536
+ console.warn(`${WARN_TAG}隧道 ${record.name} 激活失败, 已回滚待下次激活重试: ${error.message}`)
537
+ }
538
+ }
539
+
540
+ const tools = [
541
+ defineTool({
542
+ name: 'tunnel_open',
543
+ // 描述尾部附冲突提示: 隧道名撞入口域名首标签(含纯 IP Host 首段)会劫持入口
544
+ description: '打开穿透隧道。entry=path(默认): 把 http://<host>:<port>/p/<name>/ 反向代理到本机 targetPort, 应用需支持子路径部署(vite --base / jupyter base_url / gradio root_path / streamlit baseUrlPath), 由你以正确 base 参数启动目标应用; WebSocket 应用传 wsPaths 相对路径(如 ["/"])。entry=subdomain: 子域名 <name>. 开头且解析到本机的任意域名全路径透传(本机浏览器用 <name>.localhost:<port>, 泛解析域名可用于局域网/公网), 应用按根路径正常启动, WebSocket 任意路径可用, 无需 base 参数与 wsPaths; 需 dsh-auto-trust-all 放行子域名 Host。隧道名避开 dsh 入口域名的首标签(纯 IP 访问时避开 IP 首段数字, 如隧道名 127/192 会劫持对应 IP 访问)。',
545
+ parameters: {
546
+ name: { type: 'string', required: true, description: '隧道名: 小写字母/数字/连字符, ≤32 位' },
547
+ targetPort: { type: 'number', required: true, description: '目标本地端口 1..65535' },
548
+ entry: { type: 'string', description: "入口形态: 'path'(默认, /p/<name> 子路径)或 'subdomain'(<name>. 子域名全路径透传)" },
549
+ wsPaths: { type: 'array', items: { type: 'string' }, description: "仅 entry=path: 相对隧道根的 WebSocket 路径, 如 [\"/\"]; 缺省 = 纯 HTTP" },
550
+ },
551
+ output: {
552
+ schema: {
553
+ type: 'object',
554
+ additionalProperties: true,
555
+ properties: {
556
+ ok: { type: 'boolean', required: true },
557
+ name: { type: 'string' },
558
+ entry: { type: 'string' },
559
+ path: { type: 'string' },
560
+ host: { type: 'string' },
561
+ port: { type: 'number' },
562
+ error: { type: 'string' },
563
+ },
564
+ },
565
+ render: renderJson,
566
+ },
567
+ async execute(args) {
568
+ return api.open(args ?? {})
569
+ },
570
+ }),
571
+ defineTool({
572
+ name: 'tunnel_list',
573
+ description: '列出当前活动的穿透隧道(名称/入口形态/访问地址/目标端口/WS 路径)。',
574
+ parameters: {},
575
+ output: {
576
+ schema: {
577
+ type: 'object',
578
+ additionalProperties: true,
579
+ properties: {
580
+ ok: { type: 'boolean', required: true },
581
+ tunnels: { type: 'array', items: { type: 'object', additionalProperties: true } },
582
+ },
583
+ },
584
+ render: renderJson,
585
+ },
586
+ async execute() {
587
+ return api.list()
588
+ },
589
+ }),
590
+ defineTool({
591
+ name: 'tunnel_close',
592
+ description: '关闭穿透隧道并删除持久化条目; 幂等, 不存在的名同样返回成功。',
593
+ parameters: {
594
+ name: { type: 'string', required: true, description: '要关闭的隧道名' },
595
+ },
596
+ output: {
597
+ schema: {
598
+ type: 'object',
599
+ additionalProperties: true,
600
+ properties: {
601
+ ok: { type: 'boolean', required: true },
602
+ removed: { type: 'boolean' },
603
+ },
604
+ },
605
+ render: renderJson,
606
+ },
607
+ async execute(args) {
608
+ return api.close(args ?? {})
609
+ },
610
+ }),
611
+ ]
612
+ // 初次偏好同步: ai 工具注册面随开关落位(0.1.7+ volatile ref 即持久值;
613
+ // legacy 待 settings 注入后以节值重同步)
614
+ applyPreferences()
615
+
616
+ // 设置: schema 注册(legacy 独立配置节) + 偏好 watch 回写; 服务缺失时绑定保持
617
+ // config 初值, 行为与无设置版一致
618
+ ctx.inject(['settings'], (sctx) => {
619
+ const svc = sctx.settings
620
+ if (typeof svc?.register !== 'function') return
621
+ settingsRef.current = svc
622
+ const scope = svc.register(SETTINGS_NS, SETTINGS_SCHEMA, { base: config })
623
+ // 激活期即同步一次: 宿主 register 已合并持久化值, watch 仅在后续改动时触发;
624
+ // 缺这一行, 重启后绑定回退 Config 初值而设置页仍显示持久化值
625
+ applyPreferences()
626
+ if (typeof scope?.watch === 'function') scope.watch(applyPreferences)
627
+ })
628
+ // 0.1.7+ volatile 原地热更广播: 设置页原生表单或 REST 写后 ref 已换值, 事件驱动重读
629
+ ctx.on('loader/volatile-update', applyPreferences)
630
+
631
+ // GUI 偏好读写: PATCH 语义按字段校验布尔; 写路径双形态(照 dsh-maintain persistPatch,
632
+ // 特性检测延迟到使用点): legacy 设置节 update, 0.1.7+ volatile 字段落 profile 条目
633
+ // (loader 仅 volatile 变化时原地热更)。服务缺失降级 ok:false, 面板与形态仲裁照常工作
634
+ const UI_PREF_KEYS = ['aiTools', 'sidebarTab']
635
+ const uiBooleanError = (keys) => keys.join('/') + ' 须为布尔'
636
+ const readUi = () => ({ aiTools: aiToolsNow(), sidebarTab: readPref('sidebarTab') === true })
637
+ const persistPatch = async (patch) => {
638
+ const svc = settingsRef.current
639
+ if (svc) {
640
+ svc.update(SETTINGS_NS, patch)
641
+ return true
642
+ }
643
+ const editor = ctx.get('configEditor')
644
+ const entry = ctx.fiber?.entry
645
+ if (!editor || typeof editor.edit !== 'function' || !entry) return false
646
+ await editor.edit(entry, (current) => ({ ...current, ...patch }))
647
+ return true
648
+ }
649
+ const updateUi = async (body) => {
650
+ const patch = {}
651
+ for (const key of UI_PREF_KEYS) {
652
+ if (body?.[key] === undefined) continue
653
+ if (typeof body[key] !== 'boolean') throw new Error(uiBooleanError([key]))
654
+ patch[key] = body[key]
655
+ }
656
+ if (Object.keys(patch).length === 0) throw new Error(uiBooleanError(UI_PREF_KEYS))
657
+ if (!(await persistPatch(patch))) return { ok: false, error: SETTINGS_UNAVAILABLE }
658
+ applyPreferences()
659
+ return { ok: true, ui: readUi() }
660
+ }
661
+
662
+ const restApi = createApi({
663
+ tunnelsApi: api,
664
+ readUi,
665
+ updateUi,
666
+ logSystem: (line) => console.warn(`${WARN_TAG}${line}`),
667
+ })
668
+ ctx.effect(() => {
669
+ const disposeApiRoute = webServer.register({
670
+ kind: 'prefix',
671
+ path: API_PREFIX,
672
+ handler: async (req, res) => {
673
+ try {
674
+ await restApi.handle(req, res)
675
+ } catch (error) {
676
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
677
+ res.end(JSON.stringify({ error: MESSAGES.systemError }))
678
+ console.warn(`${WARN_TAG}${String(error && error.stack || error)}`)
679
+ }
680
+ },
681
+ })
682
+ return disposeApiRoute
683
+ }, 'dsh-tunnel api')
684
+
685
+ return () => {
686
+ if (disposeTools) {
687
+ disposeTools()
688
+ disposeTools = null
689
+ }
690
+ api.disposeAll()
691
+ // 载体置空穿透: 包装器常驻(与 auto-trust-all 同款生命周期), 禁用后全体原路放行
692
+ webServer[DISPATCH_PROP] = () => undefined
693
+ webServer[DISPATCH_TIMEOUT_PROP] = () => connectTimeoutMs
694
+ }
695
+ }