dsh-bridge-gateway 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.
Files changed (68) hide show
  1. package/CHANGELOG.md +250 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +408 -0
  4. package/README.md +430 -0
  5. package/client/build.mjs +43 -0
  6. package/client/client.js +5209 -0
  7. package/client/index.js +4967 -0
  8. package/cordis.patch.yml +4 -0
  9. package/docs/banner.jpg +0 -0
  10. package/docs/custom-tunnel.md +220 -0
  11. package/docs/feishu-usage.md +116 -0
  12. package/docs/fix-tunnel-sse-and-session-list.md +134 -0
  13. package/docs/platform-abstraction-design.md +473 -0
  14. package/docs/qq-usage.md +387 -0
  15. package/docs/screenshots/admin-lock-screen.jpg +0 -0
  16. package/docs/screenshots/feishu-bot-config.jpg +0 -0
  17. package/docs/screenshots/feishu-chat.jpg +0 -0
  18. package/docs/screenshots/lan-access.jpg +0 -0
  19. package/docs/screenshots/mobile-chat.jpg +0 -0
  20. package/docs/screenshots/mobile-drawer.jpg +0 -0
  21. package/docs/screenshots/mobile-remote-settings.jpg +0 -0
  22. package/docs/screenshots/mobile-settings-im.jpg +0 -0
  23. package/docs/screenshots/mobile-settings-lan.jpg +0 -0
  24. package/docs/screenshots/mobile-settings-security.jpg +0 -0
  25. package/docs/screenshots/mobile-settings-tunnel.jpg +0 -0
  26. package/docs/screenshots/mobile-workspace-picker.jpg +0 -0
  27. package/docs/screenshots/qq-bot-config.jpg +0 -0
  28. package/docs/screenshots/qq-chat.jpg +0 -0
  29. package/docs/screenshots/qq-group.jpg +0 -0
  30. package/docs/screenshots/qr-scan.jpg +0 -0
  31. package/docs/screenshots/remote-auth-login.jpg +0 -0
  32. package/docs/screenshots/remote-web-mobile.jpg +0 -0
  33. package/docs/screenshots/security-auth-config.jpg +0 -0
  34. package/docs/screenshots/telegram-bot-config.jpg +0 -0
  35. package/docs/screenshots/tunnel-access.jpg +0 -0
  36. package/docs/screenshots/wechat-bot-config.jpg +0 -0
  37. package/docs/screenshots/wechat-chat.jpg +0 -0
  38. package/docs/telegram-usage.md +93 -0
  39. package/docs/wechat-usage.md +104 -0
  40. package/lib/auth/login-template.js +382 -0
  41. package/lib/auth/manager.js +470 -0
  42. package/lib/bridge-rpc-constants.js +55 -0
  43. package/lib/bridge-rpc.js +553 -0
  44. package/lib/cloudflared-manager.mjs +345 -0
  45. package/lib/feishu/gateway.js +550 -0
  46. package/lib/feishu/index.js +222 -0
  47. package/lib/feishu/lark-bundled.mjs +125547 -0
  48. package/lib/feishu/node.js +409 -0
  49. package/lib/gateway/cert.js +214 -0
  50. package/lib/index.js +1963 -0
  51. package/lib/platform/base.js +156 -0
  52. package/lib/platform/conversation-bridge.js +1570 -0
  53. package/lib/platform/index.js +10 -0
  54. package/lib/platform/manager.js +74 -0
  55. package/lib/qq/gateway.js +616 -0
  56. package/lib/qq/index.js +309 -0
  57. package/lib/qq/node.js +533 -0
  58. package/lib/security/path-validator.js +208 -0
  59. package/lib/security/rate-limiter.js +93 -0
  60. package/lib/telegram/gateway.js +630 -0
  61. package/lib/telegram/index.js +213 -0
  62. package/lib/telegram/node.js +351 -0
  63. package/lib/tunnel-client.mjs +402 -0
  64. package/lib/wechat/gateway.js +960 -0
  65. package/lib/wechat/index.js +241 -0
  66. package/lib/wechat/media.js +281 -0
  67. package/lib/wechat/node.js +350 -0
  68. package/package.json +102 -0
@@ -0,0 +1,409 @@
1
+ // dsh-bridge Feishu / Lark conversation node
2
+ // 把飞书 OpenAPI / WebSocket 长连接入站事件解析后交给平台无关的
3
+ // ConversationBridge 处理,出站通过 FeishuGateway 发送文本 / 卡片。
4
+
5
+ import fs from 'node:fs'
6
+ import path from 'node:path'
7
+ import { ConversationBridge, conversationBridgeHelpers } from '../platform/conversation-bridge.js'
8
+
9
+ function makePlatform(gateway) {
10
+ return {
11
+ id: 'feishu',
12
+ name: 'Feishu / Lark',
13
+ capabilities: { supportsGroup: true, group: true, media: true, approvals: true },
14
+ async sendText(peerId, text, opts = {}) {
15
+ return gateway?.sendMarkdownCard(peerId, text, opts)
16
+ },
17
+ async sendMediaFile(peerId, filePath, opts = {}) {
18
+ return gateway?.sendMediaFile(peerId, filePath, opts)
19
+ },
20
+ async sendTyping() {},
21
+ dispose() {},
22
+ }
23
+ }
24
+
25
+ function sleep(ms) {
26
+ return new Promise((resolve) => setTimeout(resolve, ms))
27
+ }
28
+
29
+ function splitIntoIncremental(content, maxChunk = 150) {
30
+ const chunks = []
31
+ let start = 0
32
+ while (start < content.length) {
33
+ if (content.length - start <= maxChunk) {
34
+ chunks.push(content.slice(start))
35
+ break
36
+ }
37
+ const windowStart = start + Math.floor(maxChunk * 0.6)
38
+ const windowEnd = start + maxChunk
39
+ let cut = content.lastIndexOf('\n', windowEnd)
40
+ if (cut <= windowStart || cut === -1) cut = windowEnd
41
+ chunks.push(content.slice(start, cut))
42
+ start = cut
43
+ }
44
+ if (chunks.length <= 1 && content.length > 30) {
45
+ const step = Math.max(20, Math.floor(content.length / 4))
46
+ chunks.length = 0
47
+ for (let i = 0; i < content.length; i += step) {
48
+ chunks.push(content.slice(i, Math.min(i + step, content.length)))
49
+ }
50
+ }
51
+ const slices = []
52
+ let acc = ''
53
+ for (const s of chunks) {
54
+ acc += s
55
+ slices.push(acc)
56
+ }
57
+ return slices
58
+ }
59
+
60
+ export class FeishuConversationNode extends ConversationBridge {
61
+ constructor(ctx, config, logger, { onFirstSender, onActiveSessionChange } = {}) {
62
+ super({
63
+ ctx,
64
+ logger,
65
+ config,
66
+ platform: makePlatform(ctx.feishu),
67
+ onFirstSender,
68
+ onActiveSessionChange,
69
+ })
70
+ this.gateway = ctx.feishu
71
+ this._lastPeer = null // { peerId, senderId, isGroup }
72
+ this._streamCardId = null // 当前轮次的流式卡片 message_id
73
+ this._streamContent = '' // 当前轮次流式卡片的内容
74
+ this._patchTimer = null // 节流定时器
75
+ this._inTurn = false
76
+
77
+ // 订阅网关入站消息事件
78
+ this.disposers.push(this.ctx.on('feishu/message', (event) => this._handleInbound(event)))
79
+
80
+ // 订阅卡片交互事件(审批按钮点击)
81
+ this.disposers.push(this.ctx.on('feishu/action', (event) => this._handleAction(event)))
82
+
83
+ // 监听轮次事件:turn/start 开启流式会话,turn/end 最终刷新并重置
84
+ this.disposers.push(this.ctx.on('session/event', (session, event) => {
85
+ if (session.id !== this.activeSessionId) return
86
+ if (event.type === 'turn/start') {
87
+ this._inTurn = true
88
+ this._streamCardId = null
89
+ this._streamContent = ''
90
+ if (this._patchTimer) {
91
+ clearTimeout(this._patchTimer)
92
+ this._patchTimer = null
93
+ }
94
+ } else if (event.type === 'turn/end') {
95
+ this._inTurn = false
96
+ if (this._patchTimer) {
97
+ clearTimeout(this._patchTimer)
98
+ this._patchTimer = null
99
+ }
100
+ if (this._streamCardId && this._streamContent) {
101
+ void this.gateway.patchCard(this._streamCardId, this._streamContent).catch(() => {})
102
+ }
103
+ this._streamCardId = null
104
+ this._streamContent = ''
105
+ }
106
+ }))
107
+ }
108
+
109
+ async _handleInbound(event) {
110
+ if (this.gateway?._closing || this.gateway?.status === 'offline') return
111
+ const { peerId, senderId, isGroup, text, messageId, messageType, contentObj } = event
112
+ this._lastPeer = { peerId, senderId, isGroup }
113
+
114
+ let mediaFiles = []
115
+ if (messageType && messageType !== 'text') {
116
+ mediaFiles = await this._processMedia(messageId, messageType, contentObj, this.config.cwd)
117
+ }
118
+
119
+ if (!text && mediaFiles.length === 0) {
120
+ this.logger.debug?.(`[dsh-bridge feishu] ignore empty message from ${peerId}`)
121
+ return
122
+ }
123
+
124
+ let fullText = text || ''
125
+ if (mediaFiles.length > 0) {
126
+ const mediaDesc = mediaFiles.map((f) => `[文件: ${f.path}]`).join('\n')
127
+ fullText = fullText ? `${fullText}\n\n${mediaDesc}` : mediaDesc
128
+ }
129
+
130
+ this.logger.debug?.(`[dsh-bridge feishu] handling inbound message from ${peerId} (group=${isGroup}): ${fullText}`)
131
+
132
+ // 如果还没有活动会话,且是首条消息,自动尝试选择最新已有会话
133
+ if (!this.activeSessionId) {
134
+ await this._pickDefaultSession().catch(() => {})
135
+ }
136
+
137
+ // 转交通用 ConversationBridge 路由
138
+ return this.handleInbound({
139
+ senderId: isGroup ? peerId : senderId,
140
+ peerId,
141
+ isGroup,
142
+ text: fullText,
143
+ })
144
+ }
145
+
146
+ async _processMedia(messageId, messageType, contentObj = {}, sessionCwd) {
147
+ if (!messageId || !messageType) return []
148
+ const mediaDir = path.join(sessionCwd || this.config.cwd || process.cwd(), '.feishu-media')
149
+ try { await fs.promises.mkdir(mediaDir, { recursive: true }) } catch {}
150
+
151
+ const downloaded = []
152
+ const fileKey = contentObj.image_key || contentObj.file_key
153
+ const fileName = contentObj.file_name
154
+
155
+ if (fileKey) {
156
+ try {
157
+ const type = messageType === 'image' ? 'image' : 'file'
158
+ const buf = await this.gateway.downloadMessageResource({ messageId, fileKey, type })
159
+ if (buf && buf.length > 0) {
160
+ const ext = fileName ? path.extname(fileName) : (type === 'image' ? '.png' : '.bin')
161
+ const safeName = fileName ? path.basename(fileName) : `feishu_${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}`
162
+ const filePath = path.join(mediaDir, safeName)
163
+ await fs.promises.writeFile(filePath, buf)
164
+ downloaded.push({ filename: safeName, path: filePath, size: buf.length })
165
+ this.logger.info?.(`[dsh-bridge feishu] downloaded media ${safeName} (${buf.length} bytes)`)
166
+ }
167
+ } catch (err) {
168
+ this.logger.warn?.(`[dsh-bridge feishu] download media failed: ${err.message}`)
169
+ }
170
+ }
171
+ return downloaded
172
+ }
173
+
174
+ async _handleAction(event) {
175
+ const { operatorId, value } = event
176
+ const approvalId = Number(value.approvalId)
177
+ const action = value.action
178
+
179
+ if (approvalId && (action === 'approve' || action === 'reject')) {
180
+ const outcome = action === 'approve' ? 'allowed-once' : 'rejected'
181
+ const pending = this.pending.get(approvalId)
182
+ if (pending) {
183
+ this.clearApproval(approvalId)
184
+ pending.resolve(outcome)
185
+ }
186
+ }
187
+ }
188
+
189
+ async sendText(text) {
190
+ const peerId = this._lastPeer?.peerId || this.peerId
191
+ if (!peerId) return
192
+ const isGroup = this._lastPeer?.isGroup ?? (peerId.startsWith('oc_') || peerId.startsWith('chat_'))
193
+ const content = String(text || '').trim()
194
+ if (!content) return
195
+
196
+ // 如果处于 Agent 生成轮次中(turn 期间):流式打字机逐段增量更新
197
+ if (this._inTurn) {
198
+ const maxChunk = 150
199
+ const slices = splitIntoIncremental(content, maxChunk)
200
+ const delayMs = Math.min(Math.max(this.config.sendChunkDelayMs ?? 200, 100), 1000)
201
+
202
+ if (!this._streamCardId) {
203
+ // 首片:创建卡片并记录 message_id
204
+ const firstSlice = slices[0] || content
205
+ try {
206
+ const res = await this.gateway.sendMarkdownCard(peerId, firstSlice, { isGroup })
207
+ this._streamCardId = res?.message_id || null
208
+ this._streamContent = firstSlice
209
+ } catch (err) {
210
+ this.logger.warn?.('[dsh-bridge feishu] sendMarkdownCard failed, fallback to sendText:', err?.message ?? err)
211
+ await this.gateway.sendText(peerId, content, { isGroup })
212
+ return
213
+ }
214
+
215
+ // 后续片依次 patchCard(间隔 delayMs,呈现真实打字机流式效果)
216
+ for (let i = 1; i < slices.length; i++) {
217
+ await sleep(delayMs)
218
+ this._streamContent = slices[i]
219
+ if (this._streamCardId) {
220
+ await this.gateway.patchCard(this._streamCardId, this._streamContent).catch(() => {})
221
+ }
222
+ }
223
+ } else {
224
+ // 如果卡片已存在(同轮次后续输出):更新到最终内容
225
+ this._streamContent = content
226
+ await this.gateway.patchCard(this._streamCardId, this._streamContent).catch(() => {})
227
+ }
228
+ return
229
+ }
230
+
231
+ // 非 turn 期间(指令响应、系统提示、单条通知等):以完整单张卡片直接发送,杜绝碎片拆分
232
+ try {
233
+ if (content.length <= 25000) {
234
+ await this.gateway.sendMarkdownCard(peerId, content, { isGroup })
235
+ } else {
236
+ const chunks = conversationBridgeHelpers.splitForIM(content, 20000)
237
+ for (const chunk of chunks) {
238
+ await this.gateway.sendMarkdownCard(peerId, chunk, { isGroup })
239
+ }
240
+ }
241
+ } catch (err) {
242
+ this.logger.warn?.('[dsh-bridge feishu] sendMarkdownCard failed, fallback to plain text:', err?.message ?? err)
243
+ await this.gateway.sendText(peerId, content, { isGroup })
244
+ }
245
+ }
246
+
247
+ // ---- 飞书专属卡片审批桥 ----
248
+
249
+ _attachApprovalBridge() {
250
+ const listener = async (req, next) => {
251
+ if (!this.ownsAgent(req.agent)) return next?.()
252
+ const peer = this._lastPeer
253
+ if (!peer?.peerId) return next?.()
254
+
255
+ const number = this.nextApprovalNumber()
256
+ const timeoutSec = this.config.approvalTimeoutSec || 600
257
+ const timeoutMin = Math.max(1, Math.round(timeoutSec / 60))
258
+
259
+ // 构建飞书原生交互卡片 (JSON 2.0)
260
+ const card = {
261
+ schema: '2.0',
262
+ header: {
263
+ title: {
264
+ tag: 'plain_text',
265
+ content: `⚠️ 操作权限确认 (#${number})`,
266
+ },
267
+ template: 'orange',
268
+ },
269
+ body: {
270
+ elements: [
271
+ {
272
+ tag: 'markdown',
273
+ content: [
274
+ `| 项目 | 详情 |`,
275
+ `| :--- | :--- |`,
276
+ `| **调用工具** | \`${req.toolName}\` |`,
277
+ ...(req.reason ? [`| **申请原因** | ${String(req.reason).replace(/\|/g, '|')} |`] : []),
278
+ `| **等待超时** | ${timeoutMin} 分钟 (超时自动拒绝) |`,
279
+ ].join('\n'),
280
+ },
281
+ {
282
+ tag: 'column_set',
283
+ flex_mode: 'flow',
284
+ columns: [
285
+ {
286
+ tag: 'column',
287
+ width: 'auto',
288
+ elements: [
289
+ {
290
+ tag: 'button',
291
+ text: {
292
+ tag: 'plain_text',
293
+ content: '✓ 批准执行',
294
+ },
295
+ type: 'primary',
296
+ value: {
297
+ action: 'approve',
298
+ approvalId: number,
299
+ },
300
+ },
301
+ ],
302
+ },
303
+ {
304
+ tag: 'column',
305
+ width: 'auto',
306
+ elements: [
307
+ {
308
+ tag: 'button',
309
+ text: {
310
+ tag: 'plain_text',
311
+ content: '✕ 拒绝执行',
312
+ },
313
+ type: 'danger',
314
+ value: {
315
+ action: 'reject',
316
+ approvalId: number,
317
+ },
318
+ },
319
+ ],
320
+ },
321
+ ],
322
+ },
323
+ ],
324
+ },
325
+ }
326
+
327
+ // 发送飞书卡片
328
+ try {
329
+ await this.gateway.sendCard(peer.peerId, card, { isGroup: peer.isGroup })
330
+ } catch (err) {
331
+ // 卡片发送失败降级发送 Markdown
332
+ const textFallback = [
333
+ `## ⚠️ 操作权限确认 (#${number})`,
334
+ '',
335
+ `| 项目 | 详情 |`,
336
+ `| :--- | :--- |`,
337
+ `| **调用工具** | \`${req.toolName}\` |`,
338
+ ...(req.reason ? [`| **申请原因** | ${String(req.reason).replace(/\|/g, '|')} |`] : []),
339
+ `| **等待超时** | ${timeoutMin} 分钟 (超时自动拒绝) |`,
340
+ '',
341
+ `> 回复 \`/yes\` (或 \`1\`) 批准执行`,
342
+ `> 回复 \`/no\` (或 \`2\`) 拒绝执行`,
343
+ ].join('\n')
344
+ void this.gateway.sendMarkdownCard(peer.peerId, textFallback, { isGroup: peer.isGroup })
345
+ }
346
+
347
+ // 同时调用 downstream next(),使 Web UI 原生弹窗也能同步显示并支持直接操作
348
+ let nextPromise = null
349
+ if (typeof next === 'function') {
350
+ try {
351
+ const res = next()
352
+ if (res && typeof res.then === 'function') {
353
+ nextPromise = res
354
+ }
355
+ } catch {}
356
+ }
357
+
358
+ let timeoutFired = false
359
+ let winner = 'feishu'
360
+ const feishuPromise = new Promise((resolve) => {
361
+ const timer = setTimeout(() => {
362
+ timeoutFired = true
363
+ this.clearApproval(number)
364
+ resolve('rejected')
365
+ }, timeoutSec * 1000)
366
+ if (typeof timer.unref === 'function') timer.unref()
367
+ this.registerApproval(number, { number, request: req, resolve, timer })
368
+ })
369
+
370
+ const outcome = await (nextPromise
371
+ ? Promise.race([
372
+ feishuPromise.then(res => { winner = 'feishu'; return res }),
373
+ nextPromise.then(res => { winner = 'web'; return res }),
374
+ ])
375
+ : feishuPromise)
376
+
377
+ // 如果 Web 端先决议,清除飞书端 pending 记录
378
+ if (winner === 'web') {
379
+ this.clearApproval(number)
380
+ }
381
+
382
+ if (!timeoutFired) {
383
+ const sourceHint = winner === 'web' ? '(Web 端操作)' : ''
384
+ const label = outcome === 'allowed-once' ? `✓ **已批准执行**${sourceHint}` : outcome === 'rejected' ? `❌ **已拒绝执行**${sourceHint}` : `**[${outcome}]**`
385
+ void this.gateway.sendMarkdownCard(peer.peerId, `${label}(#${number})`, { isGroup: peer.isGroup })
386
+ }
387
+ return outcome
388
+ }
389
+
390
+ const disposer = this.ctx.on('approval/request', listener)
391
+ this.disposers.push(disposer)
392
+ }
393
+
394
+ dispose() {
395
+ if (this._patchTimer) {
396
+ clearTimeout(this._patchTimer)
397
+ this._patchTimer = null
398
+ }
399
+ this._inTurn = false
400
+ this._streamCardId = null
401
+ this._streamContent = ''
402
+ this._lastPeer = null
403
+ super.dispose()
404
+ }
405
+ }
406
+
407
+ export const feishuNodeHelpers = {
408
+ makePlatform,
409
+ }
@@ -0,0 +1,214 @@
1
+ // 零依赖自签证书生成器(IP 直连场景适用)
2
+ // 仅用 Node 内置 crypto:RSA 密钥对 + DER 编码一张最基本的自签 X.509 v3 证书。
3
+ // 无需 openssl 命令行、无需额外 npm 依赖,可在 macOS / Linux / Windows 开箱生成 TLS 证书。
4
+ //
5
+ // 注意:个人远程访问(IP 直连)使用本证书时,浏览器首次会提示"证书无效/不安全",
6
+ // 用户需选择"继续前往"。若要彻底消除告警,请在网关配置区上传域名/权威证书。
7
+
8
+ import { dirname } from 'node:path';
9
+ import {
10
+ generateKeyPairSync,
11
+ sign,
12
+ randomBytes,
13
+ } from 'node:crypto';
14
+
15
+ // --- 最小 ASN.1 DER 编码助手 ---
16
+
17
+ function len(n) {
18
+ if (n < 0x80) return [n];
19
+ const bytes = [];
20
+ while (n > 0) {
21
+ bytes.unshift(n & 0xff);
22
+ n >>>= 8;
23
+ }
24
+ return [0x80 | bytes.length, ...bytes];
25
+ }
26
+
27
+ function tlv(tag, content) {
28
+ return [tag, ...len(content.length), ...content];
29
+ }
30
+
31
+ function integerBytes(bytes) {
32
+ if (bytes.length && (bytes[0] & 0x80)) bytes = [0, ...bytes];
33
+ return tlv(0x02, bytes);
34
+ }
35
+
36
+ function oid(oidArr) {
37
+ let body = [40 * oidArr[0] + oidArr[1]];
38
+ for (let i = 2; i < oidArr.length; i++) {
39
+ let v = oidArr[i];
40
+ let sub = [];
41
+ if (v === 0) {
42
+ sub = [0];
43
+ } else {
44
+ while (v > 0) {
45
+ sub.unshift(v & 0x7f);
46
+ v >>>= 7;
47
+ }
48
+ for (let j = 0; j < sub.length - 1; j++) sub[j] |= 0x80;
49
+ }
50
+ body.push(...sub);
51
+ }
52
+ return tlv(0x06, body);
53
+ }
54
+
55
+ function nullValue() {
56
+ return tlv(0x05, []);
57
+ }
58
+
59
+ function printableString(str) {
60
+ return tlv(0x13, [...Buffer.from(str, 'utf8')]);
61
+ }
62
+
63
+ function bitString(bytes) {
64
+ return tlv(0x03, [0, ...bytes]);
65
+ }
66
+
67
+ function octetString(...bytes) {
68
+ return tlv(0x04, bytes.flat());
69
+ }
70
+
71
+ function sequence(...inner) {
72
+ return tlv(0x30, inner.flat());
73
+ }
74
+
75
+ function setOf(...inner) {
76
+ return tlv(0x31, inner.flat());
77
+ }
78
+
79
+ function utcTimeStr(d) {
80
+ const p = (n) => String(n).padStart(2, '0');
81
+ return (d.getUTCFullYear() % 100).toString().padStart(2, '0') +
82
+ p(d.getUTCMonth() + 1) + p(d.getUTCDate()) +
83
+ p(d.getUTCHours()) + p(d.getUTCMinutes()) + p(d.getUTCSeconds()) + 'Z';
84
+ }
85
+
86
+ function toPem(der, type) {
87
+ const b64 = Buffer.from(der).toString('base64').match(/.{1,64}/g).join('\n');
88
+ return `-----BEGIN ${type}-----\n${b64}\n-----END ${type}-----\n`;
89
+ }
90
+
91
+ /**
92
+ * 生成单张可被浏览器接受的 IP / 域名自签证书。
93
+ * 关键点:subjectAltName 必须包含浏览器访问所用的宿主(IP 或域名)。
94
+ */
95
+ export function generateSelfSignedCert({
96
+ commonName = 'dsh-bridge-gateway',
97
+ subjectAltNames = ['localhost', '127.0.0.1', '::1'],
98
+ days = 825,
99
+ } = {}) {
100
+ const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
101
+ const keyObj = privateKey; // generateKeyPairSync 返回的已是 PrivateKeyObject,可直接用于签名
102
+ const pubObject = publicKey; // 同理:已是公钥 KeyObject
103
+ const derSpki = pubObject.export({ type: 'spki', format: 'der' });
104
+
105
+ // 从 SPKI DER 提取公钥 BIT STRING 内容
106
+ const spki = parseTlvs(derSpki);
107
+ // spki: [0]=SEQUENCE(AlgorithmIdentifier, BIT STRING)
108
+ const spkiSeq = parseTlvs(spki[0].content);
109
+ const pubBitString = spkiSeq[1].content.subarray(1); // 跳过第一个 0x00
110
+
111
+ const now = Date.now();
112
+ const notBefore = new Date(now - 60 * 60 * 1000);
113
+ const notAfter = new Date(now + days * 24 * 3600 * 1000);
114
+
115
+ // subjectAltName (GeneralNames):dNSName [2] IMPLICIT IA5String / iPAddress [7] IMPLICIT OCTET STRING
116
+ // 每个 GeneralName 都是 context-specific 标签,必须带长度前缀(首次手写漏了长度导致 X509 "wrong tag")。
117
+ const sanValues = [];
118
+ for (const name of subjectAltNames) {
119
+ if (isIpv4(name)) {
120
+ sanValues.push(tlv(0x87, name.split('.').map((n) => parseInt(n, 10))));
121
+ } else if (name === '::1') {
122
+ const raw = Buffer.alloc(16);
123
+ raw[15] = 1;
124
+ sanValues.push(tlv(0x87, [...raw]));
125
+ } else {
126
+ sanValues.push(tlv(0x82, [...Buffer.from(name, 'utf8')]));
127
+ }
128
+ }
129
+
130
+ const extensions = sequence(
131
+ // basicConstraints: critical CA:FALSE —— 值为空 SEQUENCE `30 00`
132
+ sequence(oid([2, 5, 29, 19]), octetString(...sequence(...[]))),
133
+ // subjectAltName
134
+ sequence(oid([2, 5, 29, 17]), octetString(...sequence(...sanValues))),
135
+ );
136
+
137
+ const tbs = sequence(
138
+ tlv(0xa0, integerTlv(2)), // version [0] EXPLICIT Version = v3
139
+ integerBytes([...randomBytes(16)]), // serial
140
+ sequence(oid([1, 2, 840, 113549, 1, 1, 11]), nullValue()), // sha256WithRSA 算法
141
+ sequence(setOf(sequence(oid([2, 5, 4, 3]), printableString(commonName)))), // issuer
142
+ sequence(
143
+ tlv(0x17, [...Buffer.from(utcTimeStr(notBefore), 'ascii')]),
144
+ tlv(0x17, [...Buffer.from(utcTimeStr(notAfter), 'ascii')]),
145
+ ),
146
+ sequence(setOf(sequence(oid([2, 5, 4, 3]), printableString(commonName)))), // subject
147
+ sequence(
148
+ // SubjectPublicKeyInfo
149
+ sequence(oid([1, 2, 840, 113549, 1, 1, 1]), nullValue()),
150
+ bitString([...pubBitString]),
151
+ ),
152
+ tlv(0xa3, extensions), // [3] extensions
153
+ );
154
+
155
+ const algorithm = sequence(oid([1, 2, 840, 113549, 1, 1, 11]), nullValue());
156
+ const sig = sign('sha256', Buffer.from(tbs), keyObj);
157
+ const certDer = sequence(tbs, algorithm, bitString([...sig]));
158
+
159
+ return {
160
+ key: privateKey.export({ type: 'pkcs8', format: 'pem' }),
161
+ cert: toPem(certDer, 'CERTIFICATE'),
162
+ };
163
+ }
164
+
165
+ // --- DER 解析(用于从 SPKI 提取公钥;返回元素数组,元素 {content: Buffer},忽略不用的 tag) ---
166
+ function parseTlvs(buf) {
167
+ const out = [];
168
+ let i = 0;
169
+ while (i < buf.length) {
170
+ const tag = buf[i++];
171
+ let n = buf[i++];
172
+ if (n & 0x80) {
173
+ const c = n & 0x7f;
174
+ n = 0;
175
+ for (let k = 0; k < c; k++) n = (n << 8) | buf[i++];
176
+ }
177
+ out.push({ tag, content: buf.subarray(i, i + n) });
178
+ i += n;
179
+ }
180
+ return out;
181
+ }
182
+
183
+ function integerTlv(n) {
184
+ return tlv(0x02, [n]);
185
+ }
186
+
187
+ function isIpv4(str) {
188
+ return /^\d{1,3}(\.\d{1,3}){3}$/.test(str);
189
+ }
190
+
191
+ /**
192
+ * 读取已缓存自签证书,否则生成并持久化到 DSH_HOME/dsh-bridge/certs/。
193
+ */
194
+ export async function getOrCreateSelfSignedCert({ certFile, keyFile, logger }) {
195
+ const { readFile, writeFile, mkdir } = await import('node:fs/promises');
196
+ try {
197
+ const [cert, key] = await Promise.all([
198
+ readFile(certFile, 'utf8'),
199
+ readFile(keyFile, 'utf8'),
200
+ ]);
201
+ if (cert && key) return { cert, key };
202
+ } catch {}
203
+ const { cert, key } = generateSelfSignedCert();
204
+ try {
205
+ await mkdir(dirname(certFile), { recursive: true });
206
+ await Promise.all([
207
+ writeFile(certFile, cert, 'utf8'),
208
+ writeFile(keyFile, key, 'utf8'),
209
+ ]);
210
+ } catch (err) {
211
+ logger?.warn?.('dsh-bridge: 无法持久化自签证书: %s', err?.message ?? err);
212
+ }
213
+ return { cert, key };
214
+ }