@qzsy/dsh-bridge 0.0.0-stage → 3.0.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.
@@ -0,0 +1,297 @@
1
+ // DSH 远程隧道云 · 官方客户端(设置页面板)
2
+ //
3
+ // 仅包含 SaaS 隧道需要的最小 UI:状态、网关、设备码绑定/解绑、公网地址复制与二维码、
4
+ // 在线态与最近错误。所有网络操作走 Host 侧 RPC(同一 profile 的本地回环)。
5
+ import { PLUGIN_ID, PACKAGE_NAME, RPC_CHANNEL, RPC_ENDPOINTS } from '../lib/identity.js';
6
+ import { createTranslator } from './locales.mjs';
7
+
8
+ export const name = PLUGIN_ID;
9
+ export const inject = ['slots', 'connection'];
10
+
11
+ const h = (type, props, ...children) => React.createElement(type, props, ...children);
12
+
13
+ const styles = {
14
+ wrap: { padding: '4px 0', maxWidth: 720, fontFamily: 'inherit' },
15
+ card: { border: '1px solid var(--dsw-alias-border-secondary, #e5e7eb)', borderRadius: 12, padding: 16, marginBottom: 12 },
16
+ row: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' },
17
+ label: { fontSize: 12, color: 'var(--dsw-alias-label-secondary, #6b7280)', marginBottom: 6, display: 'block' },
18
+ value: { fontSize: 13, lineHeight: 1.6, wordBreak: 'break-all' },
19
+ input: { flex: '1 1 320px', minWidth: 0, padding: '8px 10px', borderRadius: 8, border: '1px solid var(--dsw-alias-border-secondary, #d1d5db)', font: 'inherit', fontSize: 13 },
20
+ btn: { padding: '7px 14px', borderRadius: 8, border: '1px solid var(--dsw-alias-border-secondary, #d1d5db)', background: 'transparent', color: 'inherit', cursor: 'pointer', fontSize: 13 },
21
+ btnPrimary: { padding: '7px 14px', borderRadius: 8, border: 'none', background: 'var(--dsw-alias-brand-primary, #4f6ef7)', color: '#fff', cursor: 'pointer', fontSize: 13, fontWeight: 600 },
22
+ badge: { display: 'inline-block', fontSize: 12, padding: '2px 8px', borderRadius: 999, border: '1px solid var(--dsw-alias-border-secondary, #d1d5db)' },
23
+ code: { fontSize: 28, fontWeight: 700, letterSpacing: 4, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', margin: '8px 0' },
24
+ error: { color: 'var(--dsw-alias-state-error-primary, #dc2626)', fontSize: 12, lineHeight: 1.6, wordBreak: 'break-all' },
25
+ muted: { color: 'var(--dsw-alias-label-secondary, #6b7280)', fontSize: 12, lineHeight: 1.6 },
26
+ qr: { width: 168, height: 168, borderRadius: 8, background: '#fff', padding: 6 },
27
+ };
28
+
29
+ function CopyButton({ value, t }) {
30
+ const [copied, setCopied] = React.useState(false);
31
+ const copy = React.useCallback(async () => {
32
+ if (!value) return;
33
+ try {
34
+ await navigator.clipboard.writeText(value);
35
+ setCopied(true);
36
+ setTimeout(() => setCopied(false), 1500);
37
+ } catch {}
38
+ }, [value]);
39
+ return h('button', { style: styles.btn, onClick: copy, disabled: !value }, copied ? t('copied') : t('copy'));
40
+ }
41
+
42
+ function UrlRow({ label, url, t }) {
43
+ if (!url) return null;
44
+ return h('div', { style: { marginTop: 10 } },
45
+ h('span', { style: styles.label }, label),
46
+ h('div', { style: styles.row },
47
+ h('input', { style: styles.input, readOnly: true, value: url, onFocus: (e) => e.currentTarget.select() }),
48
+ h(CopyButton, { value: url, t }),
49
+ ),
50
+ );
51
+ }
52
+
53
+ function BridgePanel() {
54
+ const t = createTranslator({});
55
+ // 注意:adminToken 必须先声明——后面 useCallback 的依赖数组会在 Hook 调用时立即求值,
56
+ // 若 state 声明在其后,会在渲染时触发 TDZ(Cannot access before initialization)导致整页白屏。
57
+ const [adminToken, setAdminToken] = React.useState('');
58
+ const rpc = React.useCallback((endpoint, payload, signal) =>
59
+ ctxRpc(endpoint, { ...(payload ?? {}), ...(adminToken ? { adminToken } : {}) }, signal), [adminToken]);
60
+ const [status, setStatus] = React.useState(null);
61
+ const [gatewayDraft, setGatewayDraft] = React.useState('');
62
+ const [codeDraft, setCodeDraft] = React.useState('');
63
+ const [busy, setBusy] = React.useState('');
64
+ const [error, setError] = React.useState('');
65
+ const [notice, setNotice] = React.useState('');
66
+ const [confirmUnbind, setConfirmUnbind] = React.useState(false);
67
+ const [accessUrl, setAccessUrl] = React.useState('');
68
+ const [qrDataUrl, setQrDataUrl] = React.useState('');
69
+ const [passwordDraft, setPasswordDraft] = React.useState('');
70
+
71
+ const loadAdminToken = React.useCallback(async (port) => {
72
+ if (!port) return;
73
+ try {
74
+ const res = await fetch(`http://127.0.0.1:${port}/__qzsy_bridge__/loopback-token`, { cache: 'no-store' });
75
+ const data = await res.json();
76
+ if (data?.adminToken) setAdminToken(data.adminToken);
77
+ } catch { /* 非物理本机(如远端浏览器)无法领取,面板保持只读 */ }
78
+ }, []);
79
+
80
+ const refresh = React.useCallback(async () => {
81
+ const res = await rpc(RPC_ENDPOINTS.getStatus);
82
+ if (res?.ok && res.value) {
83
+ setStatus(res.value);
84
+ setGatewayDraft((current) => current || res.value.gatewayUrl || '');
85
+ setError(res.value.lastError || '');
86
+ if (!adminToken) await loadAdminToken(res.value.proxy?.port);
87
+ } else if (res && res.ok === false) {
88
+ setError(res.error?.message ?? t('loadFailed'));
89
+ }
90
+ }, [t, adminToken, loadAdminToken]);
91
+
92
+ React.useEffect(() => {
93
+ refresh().catch(() => {});
94
+ const timer = setInterval(() => { refresh().catch(() => {}); }, 3000);
95
+ return () => clearInterval(timer);
96
+ }, [refresh]);
97
+
98
+ React.useEffect(() => {
99
+ if (!adminToken || !status?.publicUrl) {
100
+ setAccessUrl('');
101
+ setQrDataUrl('');
102
+ return;
103
+ }
104
+ let cancelled = false;
105
+ rpc(RPC_ENDPOINTS.getAccessUrl).then((res) => {
106
+ if (cancelled || !res?.ok) return;
107
+ setAccessUrl(res.value?.url ?? '');
108
+ setQrDataUrl(res.value?.qrDataUrl ?? '');
109
+ }).catch(() => {});
110
+ return () => { cancelled = true; };
111
+ }, [adminToken, status?.publicUrl, rpc]);
112
+
113
+ const run = React.useCallback(async (key, fn) => {
114
+ setBusy(key);
115
+ setError('');
116
+ setNotice('');
117
+ try {
118
+ const res = await fn();
119
+ if (res && res.ok === false) throw new Error(res.error?.message ?? 'RPC failed');
120
+ await refresh();
121
+ return res?.value;
122
+ } catch (err) {
123
+ setError(err?.message ?? String(err));
124
+ return null;
125
+ } finally {
126
+ setBusy('');
127
+ }
128
+ }, [refresh]);
129
+
130
+ const saveGateway = () => run('gateway', () => rpc(RPC_ENDPOINTS.saveGateway, { gatewayUrl: gatewayDraft })).then(() => setNotice(t('saved')));
131
+ const requestCode = () => run('code', () => rpc(RPC_ENDPOINTS.requestDeviceCode));
132
+ const bindCode = () => run('bind', () => rpc(RPC_ENDPOINTS.bindDevice, { code: codeDraft })).then((value) => { if (value) setCodeDraft(''); });
133
+ const startTunnel = () => run('start', () => rpc(RPC_ENDPOINTS.startTunnel, { autoStart: true }));
134
+ const stopTunnel = () => run('stop', () => rpc(RPC_ENDPOINTS.stopTunnel));
135
+ const unbind = () => run('unbind', () => rpc(RPC_ENDPOINTS.unbindDevice)).then(() => setConfirmUnbind(false));
136
+ const savePassword = () => run('password', () => rpc(RPC_ENDPOINTS.setAccessPassword, { password: passwordDraft })).then((value) => {
137
+ if (value) { setPasswordDraft(''); setNotice(t('passwordSet')); }
138
+ });
139
+
140
+ const bound = Boolean(status?.device?.bound);
141
+ const connected = Boolean(status?.connected);
142
+ const phase = status?.phase ?? 'idle';
143
+ const publicUrl = status?.publicUrl ?? '';
144
+ const pairing = status?.pairing;
145
+ const passwordSet = Boolean(status?.access?.passwordSet);
146
+ const stateText = connected ? t('ready') : (phase === 'connecting' || phase === 'reconnecting' ? t('connecting') : t('offline'));
147
+
148
+ return h('div', { style: styles.wrap },
149
+ h('div', { style: { marginBottom: 12 } },
150
+ h('div', { style: { fontSize: 15, fontWeight: 700 } }, t('subtitle')),
151
+ h('div', { style: styles.muted, marginTop: 4 }, `${PACKAGE_NAME} · v${status?.version ?? '—'}`),
152
+ ),
153
+
154
+ h('div', { style: styles.card },
155
+ h('div', { style: styles.row },
156
+ h('span', { style: styles.badge }, `${t('status')}: ${stateText}`),
157
+ h('span', { style: styles.badge }, bound ? `${t('device')}: ${status?.device?.idMasked ?? ''}` : t('notBound')),
158
+ status?.protocol ? h('span', { style: styles.badge }, `protocol v${status.protocol}`) : null,
159
+ ),
160
+ h('div', { style: { marginTop: 10 } },
161
+ h('span', { style: styles.label }, t('gateway')),
162
+ h('div', { style: styles.row },
163
+ h('input', {
164
+ style: styles.input,
165
+ value: gatewayDraft,
166
+ placeholder: 'wss://dsh.b.nps.qzsyzn.com/_gw/connect',
167
+ onChange: (e) => setGatewayDraft(e.target.value),
168
+ }),
169
+ h('button', { style: styles.btnPrimary, disabled: busy === 'gateway' || !gatewayDraft, onClick: saveGateway }, t('save')),
170
+ ),
171
+ ),
172
+ !adminToken ? h('div', { style: { ...styles.muted, marginTop: 8 } }, t('adminLocalOnly')) : null,
173
+ error ? h('div', { style: { ...styles.error, marginTop: 10 } }, `${t('lastError')}: ${error}`) : null,
174
+ notice ? h('div', { style: { ...styles.muted, marginTop: 8 } }, notice) : null,
175
+ ),
176
+
177
+ h('div', { style: styles.card },
178
+ h('div', { style: styles.row },
179
+ h('div', { style: { flex: 1, minWidth: 260 } },
180
+ h('div', { style: { fontSize: 13, fontWeight: 600 } }, t('device')),
181
+ h('div', { style: styles.muted },
182
+ bound
183
+ ? `${status?.device?.tunnel?.name || 'DSH'} · ${status?.device?.tunnel?.publicId || ''}`
184
+ : t('notBound'),
185
+ ),
186
+ ),
187
+ !bound && h('button', { style: styles.btnPrimary, disabled: Boolean(busy), onClick: requestCode }, t('getCode')),
188
+ bound && h('button', {
189
+ style: styles.btn,
190
+ disabled: Boolean(busy),
191
+ onClick: () => setConfirmUnbind(true),
192
+ }, t('unbind')),
193
+ ),
194
+ pairing && pairing.status === 'pending' ? h('div', { style: { marginTop: 12 } },
195
+ h('div', { style: styles.muted }, t('codeTitle')),
196
+ h('div', { style: styles.code }, pairing.code),
197
+ h('div', { style: styles.muted }, t('codeExpires')),
198
+ pairing.verificationUrl ? h('a', { href: pairing.verificationUrl, target: '_blank', rel: 'noreferrer', style: { fontSize: 12 } }, t('openConsole')) : null,
199
+ ) : null,
200
+ !bound ? h('div', { style: { marginTop: 12 } },
201
+ h('span', { style: styles.label }, t('bindManual')),
202
+ h('div', { style: styles.row },
203
+ h('input', { style: styles.input, value: codeDraft, onChange: (e) => setCodeDraft(e.target.value), placeholder: 'ABCD-1234' }),
204
+ h('button', { style: styles.btn, disabled: busy === 'bind' || !codeDraft, onClick: bindCode }, t('bind')),
205
+ ),
206
+ ) : null,
207
+ confirmUnbind ? h('div', { style: { marginTop: 12 } },
208
+ h('div', { style: { ...styles.muted, marginBottom: 8 } }, t('unbindConfirm')),
209
+ h('div', { style: styles.row },
210
+ h('button', { style: styles.btnPrimary, disabled: busy === 'unbind', onClick: unbind }, t('unbind')),
211
+ h('button', { style: styles.btn, onClick: () => setConfirmUnbind(false) }, t('cancel')),
212
+ ),
213
+ ) : null,
214
+ ),
215
+
216
+ h('div', { style: styles.card },
217
+ h('div', { style: { fontSize: 13, fontWeight: 600 } }, t('password')),
218
+ h('div', { style: styles.muted }, passwordSet ? t('passwordSet') : t('passwordHint')),
219
+ h('div', { style: { ...styles.row, marginTop: 8 } },
220
+ h('input', {
221
+ style: styles.input,
222
+ type: 'password',
223
+ value: passwordDraft,
224
+ onChange: (e) => setPasswordDraft(e.target.value),
225
+ placeholder: t('passwordPlaceholder'),
226
+ }),
227
+ h('button', { style: styles.btnPrimary, disabled: busy === 'password' || passwordDraft.length < 6, onClick: savePassword }, t('savePassword')),
228
+ ),
229
+ ),
230
+
231
+ h('div', { style: styles.card },
232
+ h('div', { style: styles.row },
233
+ h('div', { style: { flex: 1, minWidth: 200 } },
234
+ h('div', { style: { fontSize: 13, fontWeight: 600 } }, t('tunnel')),
235
+ h('div', { style: styles.muted }, connected ? publicUrl : t('noPublicUrl')),
236
+ ),
237
+ connected
238
+ ? h('button', { style: styles.btn, disabled: Boolean(busy), onClick: stopTunnel }, t('stop'))
239
+ : h('button', { style: styles.btnPrimary, disabled: Boolean(busy) || !bound || !passwordSet, onClick: startTunnel }, t('start')),
240
+ ),
241
+ publicUrl ? h('div', { style: { display: 'flex', gap: 16, marginTop: 12, flexWrap: 'wrap', alignItems: 'flex-start' } },
242
+ h('div', { style: { flex: '1 1 320px', minWidth: 260 } },
243
+ h(UrlRow, { label: t('publicUrl'), url: publicUrl, t }),
244
+ h(UrlRow, { label: t('accessUrl'), url: accessUrl, t }),
245
+ ),
246
+ qrDataUrl ? h('div', null,
247
+ h('div', { style: styles.label }, t('qr')),
248
+ h('img', { src: qrDataUrl, alt: t('qr'), style: styles.qr }),
249
+ ) : null,
250
+ ) : null,
251
+ ),
252
+ );
253
+ }
254
+
255
+ let ctxRpc = async () => ({ ok: false, error: { code: 'no-context', message: 'RPC not ready' } });
256
+ let serviceControl = null;
257
+
258
+ export function apply(ctx) {
259
+ ctxRpc = (endpoint, payload, signal) => ctx.connection.rpc.call(RPC_CHANNEL, endpoint, payload, signal);
260
+ const panel = (props = {}) => h(BridgePanel, { ...props, key: 'qzsy-dsh-bridge-panel' });
261
+ let visible = true;
262
+ let registration = null;
263
+ const register = () => {
264
+ if (!visible || registration) return;
265
+ try {
266
+ registration = ctx.slots.register({
267
+ name: 'settings.section',
268
+ id: PLUGIN_ID,
269
+ order: 10,
270
+ label: () => createTranslator(ctx)('title'),
271
+ }, panel);
272
+ } catch {}
273
+ };
274
+ const unregister = () => {
275
+ if (!registration) return;
276
+ try {
277
+ if (typeof registration === 'function') registration();
278
+ else registration.dispose?.();
279
+ } catch {}
280
+ registration = null;
281
+ };
282
+ try { ctx.slots.inject('settings.section', () => { if (visible) register(); }); } catch { register(); }
283
+
284
+ serviceControl = {
285
+ version: 1,
286
+ render: (props = {}) => h(BridgePanel, props),
287
+ setSettingsVisible: (next) => {
288
+ visible = Boolean(next);
289
+ if (visible) register();
290
+ else unregister();
291
+ },
292
+ settingsVisible: () => visible,
293
+ };
294
+ if (typeof ctx.provide === 'function') ctx.provide(PLUGIN_ID, serviceControl);
295
+ }
296
+
297
+ export { BridgePanel };
@@ -0,0 +1,108 @@
1
+ // 面板中英文词典(跟随 DSH <html lang>;ctx.locale 可用时优先用宿主语言服务)
2
+ export const zh = {
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',
43
+ };
44
+
45
+ export const en = {
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',
86
+ };
87
+
88
+ export function detectLang(host = {}) {
89
+ try {
90
+ const fromHost = host?.locale?.current?.();
91
+ if (fromHost === 'en' || fromHost === 'zh') return fromHost;
92
+ } catch {}
93
+ try {
94
+ const documentLang = globalThis.document?.documentElement?.getAttribute?.('lang');
95
+ if (documentLang) return String(documentLang).toLowerCase().startsWith('zh') ? 'zh' : 'en';
96
+ } catch {}
97
+ return 'zh';
98
+ }
99
+
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
+ }
@@ -0,0 +1,6 @@
1
+ # @qzsy/dsh-bridge bundle patch
2
+ # 本包是 DSH 远程隧道云的官方客户端插件(独立实现)。
3
+ # id 使用插件自己的命名空间,与 DSH profile 中其它插件互不覆盖。
4
+ - insert:
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
+ }