@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
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
|
+
}
|