@qzsy/dsh-bridge 2.18.0 → 3.0.1

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 (81) hide show
  1. package/CHANGELOG.md +15 -1094
  2. package/LICENSE +21 -21
  3. package/README.md +73 -476
  4. package/client/build.mjs +1 -3
  5. package/client/client.js +381 -9747
  6. package/client/index.js +242 -6828
  7. package/client/locales.mjs +99 -233
  8. package/cordis.patch.yml +5 -3
  9. package/lib/config.js +93 -0
  10. package/lib/data-dir.js +16 -0
  11. package/lib/dsh-cookie.js +111 -0
  12. package/lib/gate.js +414 -0
  13. package/lib/identity.js +23 -0
  14. package/lib/index.js +303 -3496
  15. package/lib/saas/tunnel-client.mjs +529 -467
  16. package/locale/en.json +3 -3
  17. package/locale/zh.json +3 -3
  18. package/package.json +15 -70
  19. package/README.en.md +0 -488
  20. package/client/host-families.js +0 -442
  21. package/client/mobile-styles.js +0 -1299
  22. package/client/mobile-yield.js +0 -219
  23. package/client/panel-locales.mjs +0 -777
  24. package/client/picker-yield.js +0 -77
  25. package/client/resource-url-compat.js +0 -90
  26. package/client/settings-nav-icon.js +0 -105
  27. package/client/theme-tokens.js +0 -49
  28. package/client/tunnel-guard.js +0 -46
  29. package/client/unlock-manager.js +0 -144
  30. package/lib/auth/dsh-native-cookie.js +0 -148
  31. package/lib/auth/login-template.js +0 -516
  32. package/lib/auth/manager.js +0 -596
  33. package/lib/bridge-rpc-constants.js +0 -66
  34. package/lib/bridge-rpc.js +0 -722
  35. package/lib/cloudflared-manager.mjs +0 -1277
  36. package/lib/cloudflared-network-args.js +0 -99
  37. package/lib/cloudflared-process-scan.js +0 -153
  38. package/lib/compat.js +0 -172
  39. package/lib/connection-compat.js +0 -115
  40. package/lib/feishu/comment.js +0 -50
  41. package/lib/feishu/gateway.js +0 -644
  42. package/lib/feishu/index.js +0 -432
  43. package/lib/feishu/lark-bundled.mjs +0 -131929
  44. package/lib/feishu/markdown.js +0 -148
  45. package/lib/feishu/node.js +0 -1152
  46. package/lib/feishu/slash-commands.js +0 -255
  47. package/lib/platform/account-ops.js +0 -248
  48. package/lib/platform/activity.js +0 -169
  49. package/lib/platform/ask-user.js +0 -293
  50. package/lib/platform/base.js +0 -147
  51. package/lib/platform/changes-digest.js +0 -93
  52. package/lib/platform/commands.js +0 -267
  53. package/lib/platform/config-restore.js +0 -78
  54. package/lib/platform/conversation-bridge.js +0 -1544
  55. package/lib/platform/dsh-storage.js +0 -222
  56. package/lib/platform/index.js +0 -10
  57. package/lib/platform/instances.js +0 -458
  58. package/lib/platform/manager.js +0 -130
  59. package/lib/platform/message-split.js +0 -229
  60. package/lib/platform/session-catalog.js +0 -405
  61. package/lib/platform/session-config.js +0 -52
  62. package/lib/platform/stream-slices.js +0 -21
  63. package/lib/qq/gateway.js +0 -1107
  64. package/lib/qq/index.js +0 -561
  65. package/lib/qq/node.js +0 -1153
  66. package/lib/qq/qqbot-bundled.mjs +0 -8273
  67. package/lib/restart-helper.mjs +0 -133
  68. package/lib/security/log-sanitize.js +0 -50
  69. package/lib/security/path-validator.js +0 -195
  70. package/lib/security/rate-limiter.js +0 -93
  71. package/lib/session-strip.js +0 -100
  72. package/lib/sse-keepalive.js +0 -115
  73. package/lib/telegram/gateway.js +0 -643
  74. package/lib/telegram/index.js +0 -255
  75. package/lib/telegram/node.js +0 -410
  76. package/lib/tunnel-client.mjs +0 -537
  77. package/lib/wechat/gateway.js +0 -1000
  78. package/lib/wechat/index.js +0 -282
  79. package/lib/wechat/media.js +0 -302
  80. package/lib/wechat/node.js +0 -360
  81. package/llms.txt +0 -36
@@ -1,242 +1,108 @@
1
- // dsh-bridge 客户端国际化:跟随 DSH 主体语言(LocaleFace)。
2
- //
3
- // DSH 0.1.7 的 client 提供 locale 服务(`dsh-client-locale` 的 LocaleFace):
4
- // - ctx.locale.register(namespace, { zh: {...}, en: {...} }) 注册词典;
5
- // - ctx.locale.translate(map) 按当前语言取译文(纯字符串原样返回、map 走词典,
6
- // 未声明语言回退到 fallback 链,最终到 en);
7
- // - ctx.locale.subscribe(fn) 在语言切换时通知。
8
- // 插件把 "locale" 加进 inject 后即可拿到 ctx.locale,从而自动跟随 DSH 主体语言
9
- // (含用户手动切换、浏览器语言回退、<html lang> 同步),无需自建语言开关。
10
- //
11
- // 本文件有两类词条,分属两层:
12
- // ① 语义化键词典(下面的 `zh` / `en`):移动端自绘 UI、DOM 注入、aria/title 等
13
- // 「拿不到原文上下文」的调用点,用 localeT('menu.open') 这类键;
14
- // ② 「中文原文 → 英文」渲染层词典(panel-locales.mjs 的 panelEn / serverEn):
15
- // client/index.js 的 h() 包装器在渲染时按原文精确命中翻译,动态拼接与服务端
16
- // 运行时文案另有 `${var}` 占位符与正则模式。
17
- // 内部字符串(DOM/CSS 类名匹配、注释、日志)保持中文原样——它们不是 UI 文案,
18
- // 翻译会破坏匹配(见 readHostSessionTitle 教训);这类字面量由
19
- // test/panel-i18n.test.mjs 的内部白名单显式登记。
20
-
21
- import { panelEn, serverEn, serverPatterns } from './panel-locales.mjs';
22
-
23
- export const LOCALE_NAMESPACE = '@wenbin_wb/dsh-bridge';
24
-
25
- /** 词典:每个命名空间至少要有 en(最终 fallback);zh 为默认语言。 */
1
+ // 面板中英文词典(跟随 DSH <html lang>;ctx.locale 可用时优先用宿主语言服务)
26
2
  export const zh = {
27
- // 移动端顶栏
28
- 'menu.open': '打开菜单',
29
- 'menu.newSession': '新建会话',
30
- 'title.fallback': '新会话',
31
- // 移动端输入区
32
- 'composer.placeholder': '✍️ 点击输入消息…',
33
- 'composer.expand': '展开输入框',
34
- 'composer.collapse': '收起输入框,最大化对话阅读区',
35
- 'panel.backToChat': '返回对话',
36
- // 宿主浮层(智能体团队 / 子智能体下拉)在移动端由本插件统一补收起按钮:动作文案,兼作 aria-label
37
- 'popover.collapse': '收起',
38
- // 移动端设置面板 / 抽屉(dsh-bridge 自绘部分)
39
- 'workspace.choose': '选择电脑工作区',
40
- 'workspace.reading': '正在读取目录内容…',
41
- 'workspace.currentDir': '当前目录:',
42
- 'workspace.registeredHint': '已注册工作区 (${count} 个,点击直接切换):',
43
- 'workspace.enterHint': '点击进入文件夹,或点击「+ 选为工作区」直接添加并切换',
44
- 'workspace.unlockHint': '远程访问时浏览/添加工作区需输入后台管理密码解锁(与访问密码不同)。',
45
- 'workspace.chooseFolder': '选择工作区',
46
- 'workspace.addFolder': '添加',
47
- 'workspace.refresh': '刷新目录',
48
- 'workspace.back': '返回上一级',
49
- 'workspace.filter': '过滤子文件夹…',
50
- 'workspace.more': '还有更多',
51
- 'workspace.addSubfolder': '直接添加此子文件夹为工作区并进入',
52
- 'workspace.onlyAdmin': '仅限本机管理',
53
- 'workspace.isCurrent': '当前主入口',
54
- 'workspace.isFallback': '主入口',
55
- // 远程访问面板
56
- 'access.copyLink': '复制链接',
57
- 'access.copied': '✓ 已复制',
58
- 'access.hideQr': '隐藏二维码',
59
- 'access.showQr': '显示二维码',
60
- 'access.privateEnv': '请在私密环境下使用',
61
- 'access.switching': '切换中…',
62
- 'access.publicEntry': '公网访问入口',
63
- 'access.copy': '复制',
64
- 'access.stopReconnect': '停止重连',
65
- 'access.close': '关闭',
66
- 'access.connecting': '连接中…',
67
- 'access.downloading': '下载中…',
68
- 'access.enable': '开启公网隧道',
69
- 'access.scanHint': '请在私密环境下扫码使用',
70
- 'access.autoStart': '随 DSH 启动自动开启',
71
- 'access.tunnelConfig': '隧道配置',
72
- 'access.expand': '展开 ▾',
73
- 'access.collapse': '收起 ▴',
74
- 'access.saveError': '保存配置失败',
75
- 'access.serverConfig': '隧道服务器配置',
76
- 'access.saving': '保存中…',
77
- 'access.saved': '✓ 已保存',
78
- 'access.saveConfig': '保存配置',
79
- 'access.clear': '清除',
80
- 'access.externalTunnel': '外部已部署隧道',
81
- 'access.saveFixed': '保存固定域名配置',
82
- 'access.updateFailed': '更新失败',
83
- 'access.securityHint': '设置外部访客访问密码',
84
- 'access.goSetup': '去开启 ➔',
85
- 'access.gotoSettings': '设置 ➔',
86
- 'access.tutorial': '查看自建隧道服务器搭建教程',
3
+ title: '远程访问',
4
+ subtitle: 'DSH 远程隧道云 · 官方客户端',
5
+ gateway: 'SaaS 网关地址',
6
+ save: '保存网关',
7
+ saved: '已保存',
8
+ device: '设备',
9
+ notBound: '未绑定',
10
+ bound: '已绑定',
11
+ getCode: '获取设备码',
12
+ codeTitle: '在控制台添加设备并输入此设备码',
13
+ codeExpires: '设备码 10 分钟内有效,绑定后自动消失。',
14
+ openConsole: '打开控制台',
15
+ bindManual: '已有设备码?直接绑定',
16
+ bind: '绑定',
17
+ unbind: '解绑设备',
18
+ unbindConfirm: '确认解绑?',
19
+ cancel: '取消',
20
+ tunnel: '隧道',
21
+ start: '开启隧道',
22
+ stop: '关闭隧道',
23
+ connecting: '连接中…',
24
+ ready: '在线',
25
+ offline: '离线',
26
+ publicUrl: '公网地址',
27
+ copy: '复制',
28
+ copied: '已复制',
29
+ accessUrl: '带访问令牌的链接(首次打开后写入会话)',
30
+ qr: '扫码打开',
31
+ lastError: '最近错误',
32
+ password: '访问口令',
33
+ passwordHint: '首次使用必须先设置访问口令;远程/局域网访客需凭此口令通过本地门禁。',
34
+ passwordSet: '已设置访问口令(可在此修改)。',
35
+ passwordPlaceholder: '至少 6 位',
36
+ savePassword: '保存口令',
37
+ adminLocalOnly: '当前不是物理本机打开:面板只读,请在运行 DSH 的电脑上操作。',
38
+ noPublicUrl: '尚未获取公网地址(先开启隧道)',
39
+ autoStart: '随 DSH 启动自动开启',
40
+ status: '状态',
41
+ version: '版本',
42
+ loadFailed: '无法连接插件 RPC',
87
43
  };
88
44
 
89
45
  export const en = {
90
- 'menu.open': 'Open menu',
91
- 'menu.newSession': 'New session',
92
- 'title.fallback': 'New Session',
93
- 'composer.placeholder': '✍️ Tap to type a message…',
94
- 'composer.expand': 'Expand input',
95
- 'composer.collapse': 'Collapse input to maximize chat view',
96
- 'panel.backToChat': 'Back to chat',
97
- // Host popovers (Agent Team / subagent dropdown) get a collapse control from this plugin on mobile
98
- 'popover.collapse': 'Collapse',
99
- 'workspace.choose': 'Choose a workspace',
100
- 'workspace.reading': 'Reading directory…',
101
- 'workspace.currentDir': 'Current directory:',
102
- 'workspace.registeredHint': 'Registered workspaces (${count}, click to switch):',
103
- 'workspace.enterHint': 'Enter a folder, or tap “+ Select as workspace” to add & switch',
104
- 'workspace.unlockHint': 'Browsing/adding workspaces remotely requires the admin password to unlock (different from the access password).',
105
- 'workspace.chooseFolder': 'Choose workspace',
106
- 'workspace.addFolder': 'Add',
107
- 'workspace.refresh': 'Refresh',
108
- 'workspace.back': 'Back',
109
- 'workspace.filter': 'Filter subfolders…',
110
- 'workspace.more': 'More',
111
- 'workspace.addSubfolder': 'Add this subfolder as a workspace and enter',
112
- 'workspace.onlyAdmin': 'Local admin only',
113
- 'workspace.isCurrent': 'Current entry',
114
- 'workspace.isFallback': 'Main entry',
115
- 'access.copyLink': 'Copy link',
116
- 'access.copied': '✓ Copied',
117
- 'access.hideQr': 'Hide QR',
118
- 'access.showQr': 'Show QR',
119
- 'access.privateEnv': 'Use in a private environment',
120
- 'access.switching': 'Switching…',
121
- 'access.publicEntry': 'Public access entry',
122
- 'access.copy': 'Copy',
123
- 'access.stopReconnect': 'Stop reconnecting',
124
- 'access.close': 'Close',
125
- 'access.connecting': 'Connecting…',
126
- 'access.downloading': 'Downloading…',
127
- 'access.enable': 'Enable public tunnel',
128
- 'access.scanHint': 'Scan in a private environment',
129
- 'access.autoStart': 'Start automatically with DSH',
130
- 'access.tunnelConfig': 'Tunnel configuration',
131
- 'access.expand': 'Expand ▾',
132
- 'access.collapse': 'Collapse ▴',
133
- 'access.saveError': 'Failed to save configuration',
134
- 'access.serverConfig': 'Tunnel server configuration',
135
- 'access.saving': 'Saving…',
136
- 'access.saved': '✓ Saved',
137
- 'access.saveConfig': 'Save configuration',
138
- 'access.clear': 'Clear',
139
- 'access.externalTunnel': 'External tunnel',
140
- 'access.saveFixed': 'Save fixed-domain configuration',
141
- 'access.updateFailed': 'Update failed',
142
- 'access.securityHint': 'Set an external visitor access password',
143
- 'access.goSetup': 'Set up ➔',
144
- 'access.gotoSettings': 'Settings ➔',
145
- 'access.tutorial': 'View the self-hosted tunnel server tutorial',
46
+ title: 'Remote Access',
47
+ subtitle: 'DSH Remote Tunnel Cloud · Official Client',
48
+ gateway: 'SaaS gateway URL',
49
+ save: 'Save gateway',
50
+ saved: 'Saved',
51
+ device: 'Device',
52
+ notBound: 'Not bound',
53
+ bound: 'Bound',
54
+ getCode: 'Get device code',
55
+ codeTitle: 'Add the device in the console and enter this code',
56
+ codeExpires: 'The code is valid for 10 minutes and disappears after binding.',
57
+ openConsole: 'Open console',
58
+ bindManual: 'Already have a code? Bind it',
59
+ bind: 'Bind',
60
+ unbind: 'Unbind device',
61
+ unbindConfirm: 'Confirm unbind?',
62
+ cancel: 'Cancel',
63
+ tunnel: 'Tunnel',
64
+ start: 'Start tunnel',
65
+ stop: 'Stop tunnel',
66
+ connecting: 'Connecting…',
67
+ ready: 'Online',
68
+ offline: 'Offline',
69
+ publicUrl: 'Public URL',
70
+ copy: 'Copy',
71
+ copied: 'Copied',
72
+ accessUrl: 'Access-token URL (writes a session cookie on first open)',
73
+ qr: 'Scan to open',
74
+ lastError: 'Last error',
75
+ password: 'Access password',
76
+ passwordHint: 'Set an access password before first use; remote/LAN visitors must pass the local gate with it.',
77
+ passwordSet: 'Access password is set (you can change it here).',
78
+ passwordPlaceholder: 'At least 6 characters',
79
+ savePassword: 'Save password',
80
+ adminLocalOnly: 'Opened outside the physical host: panel is read-only. Use the DSH machine to manage.',
81
+ noPublicUrl: 'No public URL yet (start the tunnel first)',
82
+ autoStart: 'Auto-start with DSH',
83
+ status: 'Status',
84
+ version: 'Version',
85
+ loadFailed: 'Cannot reach plugin RPC',
146
86
  };
147
87
 
148
- /**
149
- * 构造一个翻译函数:优先用宿主的 ctx.locale.translate(跟随主体语言),
150
- * 宿主不支持时退化为「取当前语言词典(en/zh)+ 互为兜底」。
151
- * @param {object} ctx - Cordis 客户端上下文(含可选 locale 服务)
152
- * @returns {{ t: Function, active: () => string, subscribe: Function }}
153
- */
154
- /**
155
- * 构造翻译器:读 DSH 主体语言(`<html lang>`,宿主 LocaleFace 实时同步),
156
- * 中英词典按当前语言选词。`ctx.locale` 只作可选的词典注册(拿到则注册,
157
- * 拿不到或未声明 inject 也不抛错、不影响翻译——动态插件无法把 locale 加进
158
- * inject,宿主会对未声明服务抛 denyRead,详见 index.js 注释)。
159
- * @param {object} ctx - Cordis 客户端上下文(可选)
160
- * @returns {{ t: Function, active: () => string }}
161
- */
162
- export function createTranslator(ctx) {
163
- // 当前语言:宿主把 active 语言同步到 <html lang>(zh→zh-CN,其余原样)。
164
- // 读它即可跟随 DSH 主体语言且无需声明 locale 服务。
165
- // 读 <html lang>。document 可能不存在(Node 测试)或只有残缺 stub(宿主外壳/测试
166
- // 替身没有 documentElement.getAttribute)——整体容错:翻译函数在渲染/初始化链路上
167
- // 被高频调用,任何一个残缺 document 都不该把面板初始化打断。
168
- const readLang = () => {
169
- try {
170
- const root = typeof document !== 'undefined' ? document.documentElement : null;
171
- return (root && typeof root.getAttribute === 'function' ? root.getAttribute('lang') : '') || '';
172
- } catch {
173
- return '';
174
- }
175
- };
176
- const active = () => (readLang().toLowerCase().startsWith('zh') ? 'zh' : 'en');
177
- // 词典查询必须用「自有属性」判定:词典是普通对象,直接 dict[key] 会让
178
- // 'constructor' / 'toString' / '__proto__' 这类原型属性名返回函数或对象。
179
- // 渲染层 h() 会把**所有**字符串子节点送进来(含用户数据、路径、会话标题),
180
- // 一旦命中就会把函数当 React child 渲染(React 直接抛错)。见
181
- // test/panel-i18n.test.mjs 的「h() 包装器」用例。
182
- const own = (dict, key) => (typeof key === 'string' && Object.hasOwn(dict, key) ? dict[key] : undefined);
183
- // 先查语义化键词典(locales.mjs),未命中再查「中文原文 → 译文」词典
184
- // (panel-locales.mjs,渲染层兜底翻译用)。中文模式下 panel 词典不生效——
185
- // 中文就是原文本身(键),直接返回 undefined 让 t() 回退键。
186
- const lookup = (key, lang, vars, depth = 0) => {
187
- if (typeof key !== 'string') return undefined;
188
- // `$1t` 允许捕获组再翻译一次;深度设上限,防止超长嵌套前缀把调用栈打爆
189
- if (depth > 8) return undefined;
190
- const semantic = lang === 'zh' ? own(zh, key) : (own(en, key) ?? own(zh, key));
191
- if (semantic !== undefined) return semantic;
192
- if (lang === 'zh') return undefined;
193
- const panel = own(panelEn, key);
194
- if (panel !== undefined) {
195
- if (Array.isArray(panel)) return vars && vars.count === 1 ? panel[0] : panel[1];
196
- return panel;
197
- }
198
- // 服务端运行时文案(RPC 错误 / 隧道状态 / 网络诊断):精确匹配优先,其次正则模式。
199
- const server = own(serverEn, key);
200
- if (server !== undefined) return server;
201
- for (const [pattern, english] of serverPatterns) {
202
- const matched = pattern.exec(key);
203
- if (!matched) continue;
204
- // `$1` 原样插入捕获组;`$1t` 表示「该捕获组本身也要过一遍词典」
205
- // (服务端常把中文原因/状态嵌在英文模板里,如『连接异常: 连接超时 (3.5s)』)。
206
- return english.replace(/\$(\d)(t?)/g, (_, group, translate) => {
207
- const captured = matched[Number(group)] ?? '';
208
- return translate ? (lookup(captured, 'en', undefined, depth + 1) ?? captured) : captured;
209
- });
210
- }
211
- return undefined;
212
- };
213
- const t = (key, vars) => {
214
- let text = lookup(key, active(), vars);
215
- // 英文缺失译文时回退原文(宁可见中文,也不显示键名/空白)
216
- if (text === undefined) text = key;
217
- if (vars && text) {
218
- for (const [k, v] of Object.entries(vars)) text = text.replaceAll(`\${${k}}`, String(v));
219
- }
220
- return text;
221
- };
222
- // 可选:向宿主注册词典(仅当其有 register 且可访问时;失败无妨)
88
+ export function detectLang(host = {}) {
89
+ try {
90
+ const fromHost = host?.locale?.current?.();
91
+ if (fromHost === 'en' || fromHost === 'zh') return fromHost;
92
+ } catch {}
223
93
  try {
224
- const locale = ctx && (typeof ctx.get === 'function' ? ctx.get('locale') : ctx.locale);
225
- if (locale && typeof locale.register === 'function') {
226
- locale.register(LOCALE_NAMESPACE, { zh, en });
227
- }
228
- } catch { /* 动态插件的 locale 服务被宿主门控时忽略 */ }
229
- return { t, active };
94
+ const documentLang = globalThis.document?.documentElement?.getAttribute?.('lang');
95
+ if (documentLang) return String(documentLang).toLowerCase().startsWith('zh') ? 'zh' : 'en';
96
+ } catch {}
97
+ return 'zh';
230
98
  }
231
99
 
232
- const globalTranslator = createTranslator(null);
233
-
234
- /**
235
- * 无 ctx 的全局翻译函数:给「不在 React 渲染层、也拿不到 ctx」的模块用
236
- * (例:client/unlock-manager.js 的错误文案、client/settings-nav-icon.js 的兜底标签)。
237
- * 语言每次调用实时读 <html lang>,与面板翻译器同源。
238
- * @param {string} key 语义化键或中文原文
239
- * @param {Record<string, unknown>} [vars] 占位符/单复数变量
240
- * @returns {string}
241
- */
242
- export const t = (key, vars) => globalTranslator.t(key, vars);
100
+ export function createTranslator(host = {}) {
101
+ const lang = detectLang(host);
102
+ const dict = lang === 'en' ? en : zh;
103
+ return (key, vars = {}) => {
104
+ let text = dict[key] ?? zh[key] ?? key;
105
+ for (const [name, value] of Object.entries(vars ?? {})) text = text.replaceAll(`\${${name}}`, String(value));
106
+ return text;
107
+ };
108
+ }
package/cordis.patch.yml CHANGED
@@ -1,4 +1,6 @@
1
- # dsh-bridge bundle patch
1
+ # @qzsy/dsh-bridge bundle patch
2
+ # 本包是 DSH 远程隧道云的官方客户端插件(独立实现)。
3
+ # id 使用插件自己的命名空间,与 DSH profile 中其它插件互不覆盖。
2
4
  - insert:
3
- - id: dsh-bridge
4
- name: '@wenbin_wb/dsh-bridge'
5
+ - id: qzsy-dsh-bridge
6
+ name: '@qzsy/dsh-bridge'
package/lib/config.js ADDED
@@ -0,0 +1,93 @@
1
+ // 配置读写:~/.dsh/qzsy-dsh-bridge/config.json,权限固定 0600。
2
+ // 配置里含设备密钥与会话签名密钥,视同机器凭据:绝不外传日志,绝不放宽权限。
3
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
5
+ import { dirname } from 'node:path';
6
+ import { resolveConfigPath } from './data-dir.js';
7
+ import { DEFAULT_GATEWAY_URL, DEFAULT_PORT } from './identity.js';
8
+
9
+ export const CONFIG_FILE_MODE = 0o600;
10
+ const SCRYPT_KEYLEN = 32;
11
+
12
+ export function createSecret(bytes = 32) {
13
+ return randomBytes(bytes).toString('base64url');
14
+ }
15
+
16
+ export function hashPassword(password, salt = randomBytes(16).toString('hex')) {
17
+ const hash = scryptSync(String(password), salt, SCRYPT_KEYLEN).toString('hex');
18
+ return { passwordHash: hash, passwordSalt: salt };
19
+ }
20
+
21
+ export function verifyPassword(password, { passwordHash = '', passwordSalt = '' } = {}) {
22
+ if (!passwordHash || !passwordSalt || typeof password !== 'string' || password === '') return false;
23
+ try {
24
+ const expected = Buffer.from(passwordHash, 'hex');
25
+ const actual = scryptSync(password, passwordSalt, expected.length);
26
+ return expected.length === actual.length && timingSafeEqual(expected, actual);
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ /** 归一化 + 补默认值;不写盘(由 ensureConfigSecrets 决定是否需要持久化新密钥)。 */
33
+ export function normalizeConfig(raw = {}) {
34
+ const input = raw && typeof raw === 'object' ? raw : {};
35
+ const access = input.access && typeof input.access === 'object' ? input.access : {};
36
+ const port = Number(input.port);
37
+ return {
38
+ port: Number.isInteger(port) && port > 0 && port < 65536 ? port : DEFAULT_PORT,
39
+ gatewayUrl: typeof input.gatewayUrl === 'string' && input.gatewayUrl.trim()
40
+ ? input.gatewayUrl.trim()
41
+ : DEFAULT_GATEWAY_URL,
42
+ autoStart: input.autoStart === true,
43
+ deviceId: typeof input.deviceId === 'string' ? input.deviceId : '',
44
+ deviceSecret: typeof input.deviceSecret === 'string' ? input.deviceSecret : '',
45
+ tunnel: input.tunnel && typeof input.tunnel === 'object'
46
+ ? { publicId: String(input.tunnel.publicId ?? ''), name: String(input.tunnel.name ?? '') }
47
+ : null,
48
+ access: {
49
+ passwordHash: typeof access.passwordHash === 'string' ? access.passwordHash : '',
50
+ passwordSalt: typeof access.passwordSalt === 'string' ? access.passwordSalt : '',
51
+ token: typeof access.token === 'string' && access.token ? access.token : '',
52
+ sessionSecret: typeof access.sessionSecret === 'string' && access.sessionSecret ? access.sessionSecret : '',
53
+ },
54
+ };
55
+ }
56
+
57
+ /** 读取配置;文件缺失/损坏一律降级为默认值(首次安装等价)。 */
58
+ export function loadConfig(filePath = resolveConfigPath()) {
59
+ try {
60
+ return normalizeConfig(JSON.parse(readFileSync(filePath, 'utf8')));
61
+ } catch {
62
+ return normalizeConfig({});
63
+ }
64
+ }
65
+
66
+ export function saveConfig(config, filePath = resolveConfigPath()) {
67
+ const normalized = normalizeConfig(config);
68
+ mkdirSync(dirname(filePath), { recursive: true });
69
+ writeFileSync(filePath, JSON.stringify(normalized, null, 2), { mode: CONFIG_FILE_MODE, encoding: 'utf8' });
70
+ try { chmodSync(filePath, CONFIG_FILE_MODE); } catch { /* Windows 不实现 POSIX 权限位 */ }
71
+ return normalized;
72
+ }
73
+
74
+ /** 首次启动补上访问令牌 / 会话密钥(缺失才补,已有值不变),返回是否发生变更。 */
75
+ export function ensureConfigSecrets(config) {
76
+ let changed = false;
77
+ if (!config.access.token) {
78
+ config.access.token = createSecret(24);
79
+ changed = true;
80
+ }
81
+ if (!config.access.sessionSecret) {
82
+ config.access.sessionSecret = createSecret(32);
83
+ changed = true;
84
+ }
85
+ return changed;
86
+ }
87
+
88
+ export function maskSecret(value, tail = 4) {
89
+ const text = String(value ?? '');
90
+ if (!text) return '';
91
+ if (text.length <= tail) return '•'.repeat(text.length);
92
+ return '•'.repeat(Math.min(8, text.length - tail)) + text.slice(-tail);
93
+ }
@@ -0,0 +1,16 @@
1
+ // 数据目录解析(Host 侧;依赖 node:os / node:path,client 不得 import)
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { DATA_DIR } from './identity.js';
5
+
6
+ export function resolveDshHome(env = process.env) {
7
+ return env?.DSH_HOME || join(homedir(), '.dsh');
8
+ }
9
+
10
+ export function resolveDataDir(env = process.env) {
11
+ return join(resolveDshHome(env), DATA_DIR);
12
+ }
13
+
14
+ export function resolveConfigPath(env = process.env) {
15
+ return join(resolveDataDir(env), 'config.json');
16
+ }
@@ -0,0 +1,111 @@
1
+ // 为 DSH 反转代理生成"本机回环"会话凭据的工具。
2
+ //
3
+ // 背景:DSH 的浏览器会话凭据绑定在 <host>:<port> 这个 authority 上,而本插件
4
+ // 对外监听的端口与 DSH 自身端口不同;转发时必须替浏览器补一枚面向 DSH 端口的
5
+ // 合法凭据,否则 DSH 会把所有经代理的请求当作未登录。
6
+ //
7
+ // 对外契约(由 DSH 宿主的浏览器鉴权实现决定,属外部协议常量):
8
+ // cookie 名 = "dsh-auth-" + urlsafeBase64(sha256("127.0.0.1:<port>"))
9
+ // cookie 值 = "v1." + urlsafeBase64(JSON{v,authority,issuedAt,expiresAt}) + "." + urlsafeBase64(hmacSha256(body))
10
+ // 签名密钥存放于 <DSH_HOME>/.credentials.yaml 的 client-connection/browser-session 记录。
11
+ //
12
+ // 本文件为独立实现,仅依赖 node 内置模块;不包含任何第三方项目代码。
13
+ import { createHash, createHmac } from 'node:crypto';
14
+ import { readFileSync, existsSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { resolveDshHome } from './data-dir.js';
17
+
18
+ const COOKIE_SPEC = { prefix: 'dsh-auth-', version: 1, ttlMs: 2592000000 };
19
+ export const DSH_AUTH_COOKIE_PREFIX = COOKIE_SPEC.prefix;
20
+ export const DSH_AUTH_COOKIE_VERSION = COOKIE_SPEC.version;
21
+ export const DSH_AUTH_COOKIE_MAX_AGE_MS = COOKIE_SPEC.ttlMs;
22
+
23
+ const base64url = (value) => Buffer.from(value).toString('base64url');
24
+ const authorityOf = (host, port) => host + ':' + port;
25
+
26
+ /** 会话 cookie 名由 authority 的哈希决定,不同端口互不覆盖。 */
27
+ export function dshCookieNameFor(targetPort, host = '127.0.0.1') {
28
+ const digest = createHash('sha256').update(authorityOf(host, targetPort)).digest();
29
+ return DSH_AUTH_COOKIE_PREFIX + base64url(digest);
30
+ }
31
+
32
+ /**
33
+ * 从 .credentials.yaml 文本里定位 browser-session 记录的 secret。
34
+ * 逐行扫描而非整文件正则,避免误取模型 API key 等其它 secret 字段。
35
+ */
36
+ export function extractBrowserSessionKey(content) {
37
+ const text = typeof content === 'string' ? content : '';
38
+ if (text.length === 0) return '';
39
+ const target = 'client-connection/browser-session:';
40
+ let recordIndent = -1;
41
+ let reading = false;
42
+ for (const rawLine of text.split(/\r?\n/)) {
43
+ const line = rawLine.replace(/\t/g, ' ');
44
+ const trimmed = line.trim();
45
+ if (trimmed.length === 0 || trimmed.startsWith('#')) continue;
46
+ const indent = line.length - line.replace(/^\s+/, '').length;
47
+ if (trimmed === target) {
48
+ reading = true;
49
+ recordIndent = indent;
50
+ continue;
51
+ }
52
+ if (!reading) continue;
53
+ if (indent <= recordIndent) break;
54
+ const match = /^secret:\s*([A-Za-z0-9_-]+)$/.exec(trimmed);
55
+ if (match) return match[1];
56
+ }
57
+ // 兼容非标准缩进/单行写法:只在记录键之后有限范围内兜底查找。
58
+ const fallback = /client-connection\/browser-session[\s\S]{0,256}?secret:\s*([A-Za-z0-9_-]+)/.exec(text);
59
+ return fallback?.[1] ?? '';
60
+ }
61
+
62
+ /**
63
+ * 生成面向 <host>:<targetPort> 的 DSH 会话 cookie。
64
+ * @returns {string} "name=value";参数不全时返回空串。
65
+ */
66
+ export function mintDshAuthCookie({
67
+ targetPort,
68
+ secret,
69
+ host = '127.0.0.1',
70
+ now = Date.now(),
71
+ ttlMs = DSH_AUTH_COOKIE_MAX_AGE_MS,
72
+ } = {}) {
73
+ if (!targetPort || !secret) return '';
74
+ const payload = {
75
+ version: COOKIE_SPEC.version,
76
+ authority: authorityOf(host, targetPort),
77
+ issuedAt: now - 1000,
78
+ expiresAt: now - 1000 + ttlMs,
79
+ };
80
+ const body = base64url(JSON.stringify(payload));
81
+ const mac = createHmac('sha256', Buffer.from(secret, 'base64url'));
82
+ const signature = base64url(mac.update(body).digest());
83
+ return dshCookieNameFor(targetPort, host) + '=v' + DSH_AUTH_COOKIE_VERSION + '.' + body + '.' + signature;
84
+ }
85
+
86
+ /** 读取本机凭据并生成 cookie;任何失败都返回空串。 */
87
+ export function getDshAuthCookie(targetPort, options = {}) {
88
+ const env = options.env ?? process.env;
89
+ const read = options.readFileSync ?? readFileSync;
90
+ const exists = options.existsSync ?? existsSync;
91
+ try {
92
+ const home = options.dshHome ?? resolveDshHome(env);
93
+ const credentials = join(home, '.credentials.yaml');
94
+ if (!exists(credentials)) return '';
95
+ const secret = extractBrowserSessionKey(read(credentials, 'utf8'));
96
+ if (secret.length === 0) return '';
97
+ return mintDshAuthCookie({ targetPort, secret, now: options.now });
98
+ } catch {
99
+ return '';
100
+ }
101
+ }
102
+
103
+ /** 合并 Cookie 头:移除已有 DSH 会话(同 authority 只保留一枚),再追加新值。 */
104
+ export function mergeCookieHeader(existing, cookie) {
105
+ const parts = String(existing ?? '')
106
+ .split(';')
107
+ .map((part) => part.trim())
108
+ .filter((part) => part.length > 0 && !part.startsWith(DSH_AUTH_COOKIE_PREFIX));
109
+ if (cookie) parts.push(cookie);
110
+ return parts.join('; ');
111
+ }