dsh-pocket 1.5.0 → 1.7.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/client/client.js CHANGED
@@ -1518,6 +1518,11 @@ function PocketSettingsTab({ rpcCall }) {
1518
1518
  (0, import_react2.createElement)("div", { style: styles.code }, tunnelUrl),
1519
1519
  (0, import_react2.createElement)("div", { style: styles.muted }, "\u4EFB\u4F55\u7F51\u7EDC\u626B\u7801\u5373\u7528\uFF08URL \u6BCF\u6B21\u91CD\u542F\u81EA\u52A8\u6362\u65B0\uFF09"),
1520
1520
  (0, import_react2.createElement)("div", { style: styles.warn, marginTop: 4 }, "\u{1F511} \u94FE\u63A5\u5DF2\u6CC4\u9732\uFF1F\u91CD\u542F dsh web\u2014\u2014URL \u7ACB\u5373\u6362\u65B0\uFF0C\u65E7\u94FE\u63A5\u4F5C\u5E9F\uFF0C\u65E0\u5B89\u5168\u98CE\u9669 | URL leaked? Restart dsh web \u2014 the URL rotates and the old one dies"),
1521
+ status.accessToken ? (0, import_react2.createElement)(
1522
+ "div",
1523
+ { style: { marginTop: 6, fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)", lineHeight: 1.5 } },
1524
+ `\u{1F510} \u8BBF\u95EE\u5BC6\u7801\uFF1A${status.accessToken}\uFF08\u6BCF\u6B21\u5F00\u542F\u516C\u7F51\u53D8\u65B0\uFF1B\u624B\u673A\u6253\u5F00\u94FE\u63A5\u9700\u8F93\u5165\u6B64\u5BC6\u7801\uFF09| PIN: ${status.accessToken} \u2014 required on the phone`
1525
+ ) : null,
1521
1526
  (0, import_react2.createElement)("button", { style: styles.btn, onClick: stopTunnel }, "\u5173\u95ED\u516C\u7F51 | Stop")
1522
1527
  ) : (0, import_react2.createElement)(
1523
1528
  "div",
package/client/index.jsx CHANGED
@@ -248,6 +248,10 @@ function PocketSettingsTab({ rpcCall }) {
248
248
  h('div', { style: styles.code }, tunnelUrl),
249
249
  h('div', { style: styles.muted }, '任何网络扫码即用(URL 每次重启自动换新)'),
250
250
  h('div', { style: styles.warn, marginTop: 4 }, '🔑 链接已泄露?重启 dsh web——URL 立即换新,旧链接作废,无安全风险 | URL leaked? Restart dsh web — the URL rotates and the old one dies'),
251
+ status.accessToken
252
+ ? h('div', { style: { marginTop: 6, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)', lineHeight: 1.5 } },
253
+ `🔐 访问密码:${status.accessToken}(每次开启公网变新;手机打开链接需输入此密码)| PIN: ${status.accessToken} — required on the phone`)
254
+ : null,
251
255
  h('button', { style: styles.btn, onClick: stopTunnel }, '关闭公网 | Stop'),
252
256
  )
253
257
  : h('div', null,
package/lib/index.js CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { spawn } from 'node:child_process';
14
- import { readFileSync } from 'node:fs';
14
+ import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
15
15
  import { writeFile, readFile, mkdir, rm } from 'node:fs/promises';
16
16
  import { join, dirname } from 'node:path';
17
17
  import { homedir } from 'node:os';
@@ -41,6 +41,35 @@ function currentVersion() {
41
41
  /** 进程启动时加载的版本(模块加载瞬间固化;用于识别「磁盘已更新但进程还是旧代码」)。 */
42
42
  const loadedVersion = currentVersion();
43
43
 
44
+ // ---------- 公网访问密码(issue #13) ----------
45
+ // 8 位数字,存本机 $DSH_HOME/dsh-pocket/token,每次开启公网时轮换(旧链接作废)。
46
+ // 局域网 IP 直连免密码;仅公网隧道 Host 强制。
47
+ const tokenRel = join('dsh-pocket', 'token');
48
+ function tokenPath() {
49
+ return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), tokenRel);
50
+ }
51
+ /** 生成 8 位数字密码并写文件(同步,保证后续读取一致)。 */
52
+ function writeNewToken() {
53
+ const fresh = String(Math.floor(10000000 + Math.random() * 90000000));
54
+ try {
55
+ mkdirSync(dirname(tokenPath()), { recursive: true });
56
+ writeFileSync(tokenPath(), fresh, { mode: 0o600 });
57
+ } catch { /* 忽略 */ }
58
+ return fresh;
59
+ }
60
+ /** 当前公网访问密码;文件不存在时生成一个。 */
61
+ function getAccessToken() {
62
+ try {
63
+ const existing = readFileSync(tokenPath(), 'utf8').trim();
64
+ if (/^\d{8}$/.test(existing)) return existing;
65
+ } catch { /* 无文件 */ }
66
+ return writeNewToken();
67
+ }
68
+ /** 轮换:生成新的 8 位数字密码(开启公网时调用,旧密码/链接立即作废)。 */
69
+ function rotateAccessToken() {
70
+ return writeNewToken();
71
+ }
72
+
44
73
  const restartNoticeRel = join('dsh-pocket', 'restarted.json');
45
74
  function restartNoticePath() {
46
75
  return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), restartNoticeRel);
@@ -132,12 +161,24 @@ export function apply(ctx, config = {}, internals = {}) {
132
161
  // client 崩溃(issue #3/#4)。给代理注入「桌面参数补丁」(history.replaceState 补齐参数,
133
162
  // 无跳转;compatibility 模式不套桌面布局,避免移动端按钮叠加)。保留默认 polyfill。
134
163
  injectHtml: isDesktop ? DEFAULT_INJECT + desktopEnvPatchScript(process.platform) : undefined,
164
+ // 公网访问密码(issue #13):仅公网隧道 Host 强制;局域网免密码
165
+ auth: {
166
+ getToken: () => getAccessToken(),
167
+ isProtected: (host) => /trycloudflare\.com$/i.test(String(host ?? '')),
168
+ },
169
+ // 每次公网隧道就绪 → 轮换 8 位密码(旧密码/旧链接立即作废)
170
+ onTunnelReady: () => {
171
+ const fresh = rotateAccessToken();
172
+ logger.info('dsh-pocket: public access PIN rotated | 公网访问密码已更新(旧链接作废)');
173
+ return fresh;
174
+ },
135
175
  });
136
176
 
137
177
  const disposers = [];
138
178
  const disposeRpc = installPocketRpc(ctx, {
139
179
  service,
140
180
  desktop: isDesktop,
181
+ getToken: () => getAccessToken(),
141
182
  runUpdate: internals.runUpdate ?? { currentVersion, perform: performUpdate, loadedVersion: () => loadedVersion },
142
183
  restart: internals.restart ?? (() => pocketRestart(service)),
143
184
  restartNotice: internals.restartNotice ?? consumeRestartNotice,
package/lib/proxy.mjs CHANGED
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { createServer } from 'node:http';
15
15
  import { request as httpRequest } from 'node:http';
16
+ import { createGzip, createBrotliCompress } from 'node:zlib';
16
17
 
17
18
  const DEFAULT_UPSTREAM = { host: '127.0.0.1', port: 3080 };
18
19
 
@@ -51,6 +52,64 @@ function isCompressed(headers) {
51
52
  /** 默认注入到经代理的 HTML 文档里:crypto.randomUUID polyfill(非安全上下文必需)。 */
52
53
  export const DEFAULT_INJECT = RANDOM_UUID_POLYFILL;
53
54
 
55
+ // ---------- 可选访问令牌认证(issue #13) ----------
56
+ // 只对公网隧道 Host(trycloudflare.com)强制;局域网免密码。
57
+ // 登录成功后种 HttpOnly 会话 cookie(浏览器关闭失效)→ SPA 内部 API/WS 自动携带。
58
+ const TOKEN_COOKIE = 'dsh_pocket_token';
59
+
60
+ function parseCookies(header) {
61
+ const out = {};
62
+ for (const part of String(header ?? '').split(';')) {
63
+ const eq = part.indexOf('=');
64
+ if (eq > 0) out[part.slice(0, eq).trim()] = part.slice(eq + 1).trim();
65
+ }
66
+ return out;
67
+ }
68
+
69
+ function loginPageHtml(error) {
70
+ return `<!doctype html><html lang="zh"><head><meta charset="utf-8">
71
+ <meta name="viewport" content="width=device-width,initial-scale=1">
72
+ <title>DSH Pocket · 访问验证</title>
73
+ <style>
74
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
75
+ .card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:28px 24px;max-width:320px;width:calc(100% - 48px);text-align:center}
76
+ h1{font-size:16px;margin:0 0 4px;color:#111827}
77
+ p{font-size:13px;color:#6b7280;margin:0 0 16px}
78
+ input{width:100%;box-sizing:border-box;padding:10px 12px;font-size:18px;letter-spacing:6px;text-align:center;border:1px solid #d1d5db;border-radius:8px;outline:none;margin-bottom:12px}
79
+ input:focus{border-color:#4f6ef7}
80
+ button{width:100%;padding:10px;font-size:15px;background:#4f6ef7;color:#fff;border:none;border-radius:8px;cursor:pointer}
81
+ .err{color:#dc2626;font-size:12px;margin-bottom:10px;min-height:16px}
82
+ </style></head><body><div class="card">
83
+ <h1>🔐 DSH Pocket</h1>
84
+ <p>此公网地址受访问密码保护,请输入 8 位数字密码</p>
85
+ <div class="err">${error ? '密码错误,请重试' : ''}</div>
86
+ <form method="post" action="/pocket-login">
87
+ <input name="token" type="password" inputmode="numeric" maxlength="8" autocomplete="one-time-code" autofocus required>
88
+ <button type="submit">进入 | Enter</button>
89
+ </form>
90
+ </div></body></html>`;
91
+ }
92
+
93
+ /** 该 Host 是否受访问密码保护(公网隧道;局域网 IP 直连免密码)。 */
94
+ function isProtectedHost(host, isProtected) {
95
+ return isProtected ? isProtected(host) : /trycloudflare\.com$/i.test(String(host ?? ''));
96
+ }
97
+
98
+ /** 请求是否期望 HTML(浏览器导航 → 返回登录页;API/WS → 401)。 */
99
+ function isHtmlRequest(req) {
100
+ const accept = String(req.headers.accept ?? '');
101
+ return accept.includes('text/html') || req.url === '/' || /\.html?$/i.test(String(req.url));
102
+ }
103
+
104
+ /** 校验请求是否已认证;返回 true 放行。 */
105
+ function authCheck(req, token) {
106
+ if (!token) return true; // 无 token(未配置密码)→ 放行
107
+ const cookieTok = parseCookies(req.headers.cookie)[TOKEN_COOKIE];
108
+ if (cookieTok === token) return true;
109
+ const qTok = new URL(req.url ?? '/', 'http://x').searchParams.get('token');
110
+ return qTok === token;
111
+ }
112
+
54
113
  /** 把浏览器可见的权威改写成 loopback 权威(Host 和 Origin 都改)。 */
55
114
  function loopbackAuthority(headers, upstream) {
56
115
  const authority = `${upstream.host}:${upstream.port}`;
@@ -67,10 +126,49 @@ function loopbackAuthority(headers, upstream) {
67
126
  * @param {string} [opts.host] 监听地址(默认 0.0.0.0:LAN 与隧道都能到)
68
127
  * @param {{host:string,port:number}} [opts.upstream] 上游 dsh web(默认 127.0.0.1:3080)
69
128
  * @param {string} [opts.injectHtml] 注入 HTML 的内容(默认 polyfill + 移动端适配;传 '' 关闭)
129
+ * @param {object} [opts.auth] 可选访问令牌认证(issue #13):{ getToken, isProtected }
70
130
  * @returns {Promise<{server:import('node:http').Server, close:()=>Promise<void>}>}
71
131
  */
72
- export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DEFAULT_UPSTREAM, log = null, injectHtml = DEFAULT_INJECT } = {}) {
132
+ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DEFAULT_UPSTREAM, log = null, injectHtml = DEFAULT_INJECT, auth = null } = {}) {
73
133
  const server = createServer((req, res) => {
134
+ // 访问令牌认证(issue #13):仅公网隧道 Host 强制;局域网 IP 直连免密码
135
+ if (auth) {
136
+ const host = String(req.headers.host ?? '');
137
+ const protectedHost = isProtectedHost(host, auth.isProtected);
138
+ const token = protectedHost ? (auth.getToken?.() ?? null) : null;
139
+ if (protectedHost && token) {
140
+ // 登录提交:校验密码 → 种 HttpOnly 会话 cookie → 回首页
141
+ if (req.method === 'POST' && req.url?.startsWith('/pocket-login')) {
142
+ let body = '';
143
+ req.on('data', (c) => { body += c; if (body.length > 1024) req.destroy(); });
144
+ req.on('end', () => {
145
+ const submitted = String(new URLSearchParams(body).get('token') ?? '');
146
+ if (submitted === token) {
147
+ res.writeHead(302, {
148
+ location: '/',
149
+ 'set-cookie': `${TOKEN_COOKIE}=${token}; HttpOnly; SameSite=Lax; Path=/`,
150
+ 'cache-control': 'no-store',
151
+ });
152
+ res.end();
153
+ } else {
154
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
155
+ res.end(loginPageHtml(true));
156
+ }
157
+ });
158
+ return;
159
+ }
160
+ if (!authCheck(req, token)) {
161
+ if (isHtmlRequest(req)) {
162
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
163
+ res.end(loginPageHtml(false));
164
+ } else {
165
+ res.writeHead(401, { 'content-type': 'application/json', 'cache-control': 'no-store' });
166
+ res.end('{"error":"unauthorized"}');
167
+ }
168
+ return;
169
+ }
170
+ }
171
+ }
74
172
  const headers = loopbackAuthority({ ...req.headers }, upstream);
75
173
  const proxyReq = httpRequest(
76
174
  { host: upstream.host, port: upstream.port, method: req.method, path: req.url, headers, agent: false },
@@ -98,6 +196,37 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
98
196
  proxyRes.on('error', () => res.destroy());
99
197
  return;
100
198
  }
199
+ // 大 JSON/text 响应**流式压缩**(issue #12):长会话历史一次返回 17MB+,
200
+ // 局域网直连与隧道段都吃满带宽;压缩到 ~1.3MB。跳过已压缩、SSE 流
201
+ // (/api/events.* 原样透传)、HTML(走上面的注入分支)。
202
+ const acceptEncoding = String(req.headers['accept-encoding'] ?? '');
203
+ const canGzip = /\bgzip\b/.test(acceptEncoding);
204
+ const canBr = /\bbr\b/.test(acceptEncoding);
205
+ const isEventStream = contentType.includes('text/event-stream');
206
+ const knownLen = Number(proxyRes.headers['content-length'] || 0);
207
+ const shouldCompress = (canGzip || canBr)
208
+ && !isCompressed(proxyRes.headers)
209
+ && !isEventStream
210
+ && (contentType.includes('application/json') || contentType.startsWith('text/'))
211
+ && (knownLen === 0 || knownLen >= 1024);
212
+ if (shouldCompress) {
213
+ const enc = canBr ? 'br' : 'gzip';
214
+ const outHeaders = { ...proxyRes.headers };
215
+ delete outHeaders['content-length'];
216
+ delete outHeaders['transfer-encoding'];
217
+ outHeaders['content-encoding'] = enc;
218
+ res.writeHead(proxyRes.statusCode ?? 200, outHeaders);
219
+ const z = enc === 'br' ? createBrotliCompress() : createGzip();
220
+ proxyRes.pipe(z).pipe(res);
221
+ // 任一端断开都要清理(含压缩流)。注意:不能用 proxyRes 的 'close'
222
+ // 来掐 res——正常结束后 close 也会触发,此时压缩流可能还没写完,
223
+ // 会误杀连接;异常中止用 'aborted'。
224
+ res.on('close', () => { proxyRes.destroy(); z.destroy(); });
225
+ proxyRes.on('error', () => { z.destroy(); res.destroy(); });
226
+ proxyRes.on('aborted', () => { z.destroy(); res.destroy(); });
227
+ z.on('error', () => res.destroy());
228
+ return;
229
+ }
101
230
  res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
102
231
  proxyRes.pipe(res);
103
232
  // 任一端断开都要清理另一端:客户端断连销毁上游流(不留僵尸),
@@ -116,6 +245,16 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
116
245
 
117
246
  // WebSocket upgrade(DSH 的 /api/events.mux + events.host 流式通道)原样透传
118
247
  server.on('upgrade', (req, socket, head) => {
248
+ // WebSocket 同样校验(防止绕过 HTTP 认证从 WS 进入)
249
+ if (auth) {
250
+ const host = String(req.headers.host ?? '');
251
+ const token = isProtectedHost(host, auth.isProtected) ? (auth.getToken?.() ?? null) : null;
252
+ if (token && !authCheck(req, token)) {
253
+ socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
254
+ socket.destroy();
255
+ return;
256
+ }
257
+ }
119
258
  const headers = loopbackAuthority({ ...req.headers }, upstream);
120
259
  const proxyReq = httpRequest({
121
260
  host: upstream.host, port: upstream.port, method: req.method, path: req.url, headers, agent: false,
package/lib/service.mjs CHANGED
@@ -86,6 +86,10 @@ export function createPocketService({
86
86
  internals = {},
87
87
  /** 代理注入 HTML 的内容(桌面端补丁等由 lib/index.js 传入;默认 randomUUID polyfill) */
88
88
  injectHtml,
89
+ /** 访问令牌认证配置(issue #13):{ getToken, isProtected },传给代理 */
90
+ auth,
91
+ /** 隧道就绪回调(lib/index.js 用它轮换公网密码) */
92
+ onTunnelReady,
89
93
  } = {}) {
90
94
  const createProxy = internals.createProxy ?? createPocketProxy;
91
95
  const startTunnel = internals.startTunnel ?? startQuickTunnel;
@@ -142,6 +146,7 @@ export function createPocketService({
142
146
  host: '0.0.0.0',
143
147
  upstream: { host: '127.0.0.1', port: dshPort },
144
148
  ...(injectHtml ? { injectHtml } : {}),
149
+ ...(auth ? { auth } : {}),
145
150
  });
146
151
  if (p !== port) {
147
152
  console.log(`dsh-pocket: port ${port} busy, proxy on ${p} | 端口 ${port} 被占用,代理改用 ${p}`);
@@ -185,6 +190,8 @@ export function createPocketService({
185
190
  });
186
191
  // 记录「隧道开启中」,供重启后自动恢复(issue #11)
187
192
  void persistAutoTunnel();
193
+ // 公网隧道就绪 → 轮换访问密码(issue #13:每次开启变新,旧链接作废)
194
+ try { onTunnelReady?.(); } catch { /* 忽略 */ }
188
195
  return tunnel.url;
189
196
  } catch (err) {
190
197
  // stopTunnel 触发的 abort 不算错误:保持 idle,别把状态刷成 error
package/lib/web-rpc.js CHANGED
@@ -25,7 +25,7 @@ export function killHint(port) {
25
25
  }
26
26
 
27
27
  /** 注册 /dsh-pocket 逻辑通道(仅本机 loopback 可调)。 */
28
- export function installPocketRpc(ctx, { service, log = console, desktop = false, runUpdate = null, restart = null, restartNotice = null }) {
28
+ export function installPocketRpc(ctx, { service, log = console, desktop = false, runUpdate = null, restart = null, restartNotice = null, getToken = null }) {
29
29
  if (!ctx?.connection?.rpc?.handle) {
30
30
  log.warn?.('dsh-pocket: DSH Host Connection RPC unavailable — settings tab disabled | 无 Connection RPC,设置页不可用');
31
31
  return () => {};
@@ -33,12 +33,12 @@ export function installPocketRpc(ctx, { service, log = console, desktop = false,
33
33
  return ctx.connection.rpc.handle(POCKET_RPC_CHANNEL, async (endpoint, payload = {}, signal) => {
34
34
  if (signal?.aborted) return fail('cancelled', 'The request was cancelled.');
35
35
 
36
- // status 响应:redact 后的服务状态 + 重启提示(重启后 30 分钟内有效)+ 停止命令(按平台)+ 桌面端标志
36
+ // status 响应:redact 后的服务状态 + 重启提示(重启后 30 分钟内有效)+ 停止命令(按平台)+ 桌面端标志 + 公网访问密码
37
37
  const statusPayload = async () => {
38
38
  let notice = null;
39
39
  try { notice = (await restartNotice?.()) ?? null; } catch { notice = null; }
40
40
  const s = await service.status();
41
- return ok({ ...redactStatus(s), desktop, restartNotice: notice, killHint: killHint(s.dshPort ?? 3080) });
41
+ return ok({ ...redactStatus(s), desktop, restartNotice: notice, killHint: killHint(s.dshPort ?? 3080), accessToken: getToken?.() ?? null });
42
42
  };
43
43
 
44
44
  try {
package/package.json CHANGED
@@ -76,5 +76,5 @@
76
76
  "access": "public",
77
77
  "registry": "https://registry.npmjs.org/"
78
78
  },
79
- "version": "1.5.0"
79
+ "version": "1.7.0"
80
80
  }