dsh-pocket 2.8.0 → 2.9.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
@@ -503,6 +503,85 @@ function startFileGuard(readFile) {
503
503
  };
504
504
  }
505
505
 
506
+ // client/mobile/sessionGuard.ts
507
+ var SESSION_META = "dsh-pocket-session";
508
+ function readFingerprint() {
509
+ const meta = document.querySelector(`meta[name="${SESSION_META}"]`);
510
+ const fp = meta?.content?.trim();
511
+ return fp && fp.length > 0 ? fp : null;
512
+ }
513
+ function mountBadge(fp) {
514
+ const badge = document.createElement("div");
515
+ badge.setAttribute("data-mobile-nav", "session-fp");
516
+ badge.textContent = `\u{1F512} \u4F1A\u8BDD\u6307\u7EB9 ${fp}`;
517
+ badge.style.cssText = [
518
+ "position:fixed",
519
+ "left:50%",
520
+ "bottom:8px",
521
+ "transform:translateX(-50%)",
522
+ "z-index:2147483646",
523
+ "padding:4px 10px",
524
+ "border-radius:999px",
525
+ "background:rgba(17,24,39,.82)",
526
+ "color:#fff",
527
+ 'font:12px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
528
+ "letter-spacing:.5px",
529
+ "pointer-events:none",
530
+ "user-select:none",
531
+ "white-space:nowrap"
532
+ ].join(";");
533
+ badge.title = "\u672C\u9875\u4F1A\u8BDD\u6307\u7EB9\uFF0C\u5E94\u4E0E\u7535\u8111\u300C\u624B\u673A\u8BBF\u95EE\u300D\u8BBE\u7F6E\u9875\u663E\u793A\u7684\u4E00\u81F4\uFF1B\u4E0D\u4E00\u81F4\u8BF4\u660E\u94FE\u63A5\u5DF2\u88AB\u4ED6\u4EBA\u590D\u7528";
534
+ document.body.appendChild(badge);
535
+ return () => badge.remove();
536
+ }
537
+ function mountWarning() {
538
+ const overlay = document.createElement("div");
539
+ overlay.setAttribute("data-mobile-nav", "session-warn");
540
+ overlay.style.cssText = [
541
+ "position:fixed",
542
+ "inset:0",
543
+ "z-index:2147483647",
544
+ "display:flex",
545
+ "align-items:center",
546
+ "justify-content:center",
547
+ "padding:24px",
548
+ "background:rgba(0,0,0,.72)",
549
+ "color:#fff",
550
+ 'font:15px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
551
+ "text-align:center"
552
+ ].join(";");
553
+ overlay.style.whiteSpace = "pre-line";
554
+ overlay.textContent = "\u26A0\uFE0F \u6B64\u9875\u9762\u4E0D\u662F\u4F60\u7684 DSH\uFF1A\u94FE\u63A5\u53EF\u80FD\u5DF2\u88AB\u4ED6\u4EBA\u590D\u7528\u3002\n\u8BF7\u52FF\u5728\u6B64\u8F93\u5165\u4EFB\u4F55\u5BC6\u7801\u3002\n\u8BF7\u91CD\u65B0\u5728\u7535\u8111\u300C\u624B\u673A\u8BBF\u95EE\u300D\u8BBE\u7F6E\u9875\u626B\u5F53\u524D\u4E8C\u7EF4\u7801\u3002";
555
+ document.documentElement.appendChild(overlay);
556
+ return () => overlay.remove();
557
+ }
558
+ function startSessionGuard() {
559
+ let cleanupBadge = null;
560
+ let cleanupWarn = null;
561
+ const evaluate = () => {
562
+ const fp = readFingerprint();
563
+ if (fp) {
564
+ cleanupWarn?.();
565
+ cleanupWarn = null;
566
+ if (!cleanupBadge) cleanupBadge = mountBadge(fp);
567
+ } else {
568
+ cleanupBadge?.();
569
+ cleanupBadge = null;
570
+ if (!cleanupWarn) cleanupWarn = mountWarning();
571
+ }
572
+ };
573
+ evaluate();
574
+ const timer = window.setTimeout(evaluate, 300);
575
+ const observer = new MutationObserver(evaluate);
576
+ observer.observe(document.documentElement, { childList: true, subtree: true });
577
+ return () => {
578
+ window.clearTimeout(timer);
579
+ observer.disconnect();
580
+ cleanupBadge?.();
581
+ cleanupWarn?.();
582
+ };
583
+ }
584
+
506
585
  // client/mobile/mobile.css.ts
507
586
  var MOBILE_CSS = `
508
587
  /* ---------- base control styles (rendered at any width, hidden where unused) ---------- */
@@ -1661,6 +1740,11 @@ function mobileApply(ctx) {
1661
1740
  );
1662
1741
  return startFileGuard(readFile);
1663
1742
  }, "dsh-mobile-nav: file open guard + copy button + hide add-workspace (issue #17)");
1743
+ ctx.effect(() => {
1744
+ if (!narrow.matches) return () => {
1745
+ };
1746
+ return startSessionGuard();
1747
+ }, "dsh-mobile-nav: session fingerprint guard (issue #82)");
1664
1748
  ctx.slots.inject("conversation.session.header.actions", () => ctx.slots.register({
1665
1749
  name: "conversation.session.header.actions",
1666
1750
  id: "mobile-nav-toggle",
@@ -1760,6 +1844,8 @@ var zh2 = {
1760
1844
  "wanHint": "\u4EFB\u4F55\u7F51\u7EDC\u626B\u7801\u5373\u7528\uFF08URL \u6BCF\u6B21\u91CD\u542F\u81EA\u52A8\u6362\u65B0\uFF09",
1761
1845
  "wanPin": "\u{1F510} \u8BBF\u95EE\u5BC6\u7801\uFF1A{pin}\uFF08\u6BCF\u6B21\u5F00\u542F\u516C\u7F51\u53D8\u65B0\uFF1B\u624B\u673A\u6253\u5F00\u94FE\u63A5\u9700\u8F93\u5165\u6B64\u5BC6\u7801\uFF09",
1762
1846
  "wanPinCustom": "\u{1F510} \u8BBF\u95EE\u5BC6\u7801\uFF1A{pin}\uFF08\u81EA\u5B9A\u4E49\uFF0C\u5F00\u542F\u516C\u7F51\u4E0D\u518D\u81EA\u52A8\u6362\u65B0\uFF09",
1847
+ "wanEphemeralWarn": "\u26A0\uFE0F \u516C\u7F51\u94FE\u63A5\u4EC5\u5728\u672C\u6B21\u5F00\u542F\u671F\u95F4\u6709\u6548\uFF1A\u5173\u95ED\u6216\u91CD\u542F\u540E\u5931\u6548\uFF0C\u5E76\u53EF\u80FD\u88AB\u4ED6\u4EBA\u590D\u7528\u4E3A\u964C\u751F\u7F51\u7AD9\u3002\u8BF7\u52FF\u6536\u85CF\uFF0C\u6BCF\u6B21\u4ECE\u672C\u9875\u626B\u300C\u5F53\u524D\u300D\u4E8C\u7EF4\u7801\u3002\u9700\u8981\u56FA\u5B9A\u4E0D\u53D8\u7684\u5730\u5740\u8BF7\u7528\u4E0B\u65B9\u300C\u56FA\u5B9A\u57DF\u540D\u300D\u3002",
1848
+ "wanSessionFp": "\u672C\u9875\u4F1A\u8BDD\u6307\u7EB9\uFF1A{fp}\uFF08\u624B\u673A\u767B\u5F55\u9875\u4F1A\u663E\u793A\u540C\u6837\u7684\uFF1B\u4E0D\u4E00\u81F4\u8BF4\u660E\u94FE\u63A5\u5DF2\u88AB\u4ED6\u4EBA\u590D\u7528\uFF0C\u8BF7\u52FF\u8F93\u5165\u5BC6\u7801\uFF09",
1763
1849
  "stopTunnel": "\u5173\u95ED\u516C\u7F51",
1764
1850
  "enable": "\u5F00\u542F\u516C\u7F51\u8BBF\u95EE",
1765
1851
  "opening": "\u5F00\u542F\u4E2D\u2026",
@@ -1855,6 +1941,8 @@ var en2 = {
1855
1941
  "wanHint": "Scan from any network (the URL changes on every restart)",
1856
1942
  "wanPin": "\u{1F510} PIN: {pin} (changes each time the tunnel is enabled; required on the phone)",
1857
1943
  "wanPinCustom": "\u{1F510} PIN: {pin} (custom \u2014 not rotated on tunnel start)",
1944
+ "wanEphemeralWarn": '\u26A0\uFE0F The public link is valid only for this session: it stops working after you close or restart, and may be reused by someone else for an unrelated site. Do not bookmark it \u2014 scan the CURRENT QR code from this page each time. For a permanent address use "Fixed domain" below.',
1945
+ "wanSessionFp": "Session fingerprint: {fp} (the phone login page shows the same; if it differs, the link has been reused \u2014 do not enter your PIN)",
1858
1946
  "stopTunnel": "Stop",
1859
1947
  "enable": "Enable anywhere",
1860
1948
  "opening": "Enabling\u2026",
@@ -2355,6 +2443,13 @@ function PocketSettingsTab({ rpcCall, t }) {
2355
2443
  "div",
2356
2444
  null,
2357
2445
  qrArea(status.tunnelQr, tunnelUrl, namedMode ? t("namedRunningHint") : t("wanHint")),
2446
+ // 防钓鱼 / 别收藏(issue #82):链接 ephemeral 提示 + 本次会话指纹交叉核对
2447
+ (0, import_react2.createElement)("div", { style: { marginTop: 8, fontSize: 12, lineHeight: 1.5, borderLeft: "4px solid var(--dsw-alias-state-warn-primary,#b45309)", background: "var(--dsw-alias-bg-layer-2,#f3f4f6)", borderRadius: 8, padding: "8px 10px" } }, t("wanEphemeralWarn")),
2448
+ status?.sessionFingerprint ? (0, import_react2.createElement)(
2449
+ "div",
2450
+ { style: { marginTop: 8, fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)", lineHeight: 1.5 } },
2451
+ fmt(t, "wanSessionFp", { fp: status.sessionFingerprint })
2452
+ ) : null,
2358
2453
  // 地址模式行(随机/固定;固定域名选中或编辑时高亮)
2359
2454
  row(
2360
2455
  t("modeLabel"),
package/client/index.jsx CHANGED
@@ -487,6 +487,11 @@ function PocketSettingsTab({ rpcCall, t }) {
487
487
  tunnelUrl
488
488
  ? h('div', null,
489
489
  qrArea(status.tunnelQr, tunnelUrl, namedMode ? t('namedRunningHint') : t('wanHint')),
490
+ // 防钓鱼 / 别收藏(issue #82):链接 ephemeral 提示 + 本次会话指纹交叉核对
491
+ h('div', { style: { marginTop: 8, fontSize: 12, lineHeight: 1.5, borderLeft: '4px solid var(--dsw-alias-state-warn-primary,#b45309)', background: 'var(--dsw-alias-bg-layer-2,#f3f4f6)', borderRadius: 8, padding: '8px 10px' } }, t('wanEphemeralWarn')),
492
+ status?.sessionFingerprint ? h('div', { style: { marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)', lineHeight: 1.5 } },
493
+ fmt(t, 'wanSessionFp', { fp: status.sessionFingerprint }),
494
+ ) : null,
490
495
  // 地址模式行(随机/固定;固定域名选中或编辑时高亮)
491
496
  row(t('modeLabel'),
492
497
  h('span', { style: { display: 'inline-flex', gap: 6 } },
@@ -4,6 +4,7 @@ import { MobileNavToggle } from './MobileNavToggle.tsx'
4
4
  import { MobileNavOverlay } from './MobileNavOverlay.tsx'
5
5
  import { MobileDrawerFooter } from './MobileDrawerFooter.tsx'
6
6
  import { startFileGuard } from './fileGuard.ts'
7
+ import { startSessionGuard } from './sessionGuard.ts'
7
8
  import { MOBILE_CSS } from './mobile.css.ts'
8
9
  import { POCKET_RPC_CHANNEL, POCKET_ENDPOINTS } from '../api.js'
9
10
  import { NS, en, zh } from './locales.ts'
@@ -303,6 +304,15 @@ export function mobileApply(ctx): void {
303
304
  return startFileGuard(readFile)
304
305
  }, 'dsh-mobile-nav: file open guard + copy button + hide add-workspace (issue #17)')
305
306
 
307
+ // 公网会话指纹校验(issue #82,防密码被钓):页面带指纹 meta 即我们的页面、
308
+ // 显示交叉核对徽标;不带则极可能不是我们的页面(链接被复用/克隆登录页钓鱼),
309
+ // 弹红屏警告并拦截交互。只挂窄屏(登录页是 server-render、无本脚本,主要靠
310
+ // 登录页与设置页的可见指纹做人工交叉核对;此层是应用页的兜底)。
311
+ ctx.effect(() => {
312
+ if (!narrow.matches) return () => {}
313
+ return startSessionGuard()
314
+ }, 'dsh-mobile-nav: session fingerprint guard (issue #82)')
315
+
306
316
  ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({
307
317
  name: 'conversation.session.header.actions',
308
318
  id: 'mobile-nav-toggle',
@@ -0,0 +1,80 @@
1
+ // 公网会话指纹校验(issue #82,防密码被钓)。
2
+ //
3
+ // 本代理会给每个经它伺服的 HTML(应用页 + 登录页)注入
4
+ // <meta name="dsh-pocket-session" content="<本次进程随机指纹>">。该指纹只在本机
5
+ // 代理内生成、只出现在我们伺服的页面,陌生人复用被回收的快速隧道子域时(其站点
6
+ // 不经我们的代理)拿不到它、也猜不出。
7
+ //
8
+ // 校验逻辑(纯前端、不依赖任何外部请求,避免被钓鱼站点误导):
9
+ // - 页面带指纹 meta → 这是我们的页面,显示一个小徽标供用户与电脑设置页交叉核对
10
+ // (不一致即说明链接被复用);
11
+ // - 页面不带指纹 meta → 很可能不是我们的页面(链接已被他人复用,或克隆登录页钓鱼),
12
+ // 弹红色全屏警告并拦截交互,防止用户在被钓鱼页输入 8 位密码。
13
+
14
+ const SESSION_META = 'dsh-pocket-session'
15
+
16
+ function readFingerprint(): string | null {
17
+ const meta = document.querySelector<HTMLMetaElement>(`meta[name="${SESSION_META}"]`)
18
+ const fp = meta?.content?.trim()
19
+ return fp && fp.length > 0 ? fp : null
20
+ }
21
+
22
+ function mountBadge(fp: string): () => void {
23
+ const badge = document.createElement('div')
24
+ badge.setAttribute('data-mobile-nav', 'session-fp')
25
+ badge.textContent = `🔒 会话指纹 ${fp}`
26
+ // 固定在底部居中、低存在感、仅供交叉核对
27
+ badge.style.cssText = [
28
+ 'position:fixed', 'left:50%', 'bottom:8px', 'transform:translateX(-50%)',
29
+ 'z-index:2147483646', 'padding:4px 10px', 'border-radius:999px',
30
+ 'background:rgba(17,24,39,.82)', 'color:#fff',
31
+ 'font:12px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
32
+ 'letter-spacing:.5px', 'pointer-events:none', 'user-select:none', 'white-space:nowrap',
33
+ ].join(';')
34
+ badge.title = '本页会话指纹,应与电脑「手机访问」设置页显示的一致;不一致说明链接已被他人复用'
35
+ document.body.appendChild(badge)
36
+ return () => badge.remove()
37
+ }
38
+
39
+ function mountWarning(): () => void {
40
+ const overlay = document.createElement('div')
41
+ overlay.setAttribute('data-mobile-nav', 'session-warn')
42
+ overlay.style.cssText = [
43
+ 'position:fixed', 'inset:0', 'z-index:2147483647',
44
+ 'display:flex', 'align-items:center', 'justify-content:center', 'padding:24px',
45
+ 'background:rgba(0,0,0,.72)', 'color:#fff',
46
+ 'font:15px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif', 'text-align:center',
47
+ ].join(';')
48
+ overlay.style.whiteSpace = 'pre-line'
49
+ overlay.textContent = '⚠️ 此页面不是你的 DSH:链接可能已被他人复用。\n请勿在此输入任何密码。\n请重新在电脑「手机访问」设置页扫当前二维码。'
50
+ document.documentElement.appendChild(overlay)
51
+ return () => overlay.remove()
52
+ }
53
+
54
+ export function startSessionGuard(): () => void {
55
+ let cleanupBadge: (() => void) | null = null
56
+ let cleanupWarn: (() => void) | null = null
57
+ const evaluate = (): void => {
58
+ const fp = readFingerprint()
59
+ if (fp) {
60
+ cleanupWarn?.()
61
+ cleanupWarn = null
62
+ if (!cleanupBadge) cleanupBadge = mountBadge(fp)
63
+ } else {
64
+ cleanupBadge?.()
65
+ cleanupBadge = null
66
+ if (!cleanupWarn) cleanupWarn = mountWarning()
67
+ }
68
+ }
69
+ evaluate()
70
+ // 容错:极少数情况下 meta 在 effect 运行时尚未解析,稍后复查一次
71
+ const timer = window.setTimeout(evaluate, 300)
72
+ const observer = new MutationObserver(evaluate)
73
+ observer.observe(document.documentElement, { childList: true, subtree: true })
74
+ return () => {
75
+ window.clearTimeout(timer)
76
+ observer.disconnect()
77
+ cleanupBadge?.()
78
+ cleanupWarn?.()
79
+ }
80
+ }
@@ -70,6 +70,8 @@ export const zh = {
70
70
  'wanHint': '任何网络扫码即用(URL 每次重启自动换新)',
71
71
  'wanPin': '🔐 访问密码:{pin}(每次开启公网变新;手机打开链接需输入此密码)',
72
72
  'wanPinCustom': '🔐 访问密码:{pin}(自定义,开启公网不再自动换新)',
73
+ 'wanEphemeralWarn': '⚠️ 公网链接仅在本次开启期间有效:关闭或重启后失效,并可能被他人复用为陌生网站。请勿收藏,每次从本页扫「当前」二维码。需要固定不变的地址请用下方「固定域名」。',
74
+ 'wanSessionFp': '本页会话指纹:{fp}(手机登录页会显示同样的;不一致说明链接已被他人复用,请勿输入密码)',
73
75
  'stopTunnel': '关闭公网',
74
76
  'enable': '开启公网访问',
75
77
  'opening': '开启中…',
@@ -167,6 +169,8 @@ export const en = {
167
169
  'wanHint': 'Scan from any network (the URL changes on every restart)',
168
170
  'wanPin': '🔐 PIN: {pin} (changes each time the tunnel is enabled; required on the phone)',
169
171
  'wanPinCustom': '🔐 PIN: {pin} (custom — not rotated on tunnel start)',
172
+ 'wanEphemeralWarn': '⚠️ The public link is valid only for this session: it stops working after you close or restart, and may be reused by someone else for an unrelated site. Do not bookmark it — scan the CURRENT QR code from this page each time. For a permanent address use "Fixed domain" below.',
173
+ 'wanSessionFp': 'Session fingerprint: {fp} (the phone login page shows the same; if it differs, the link has been reused — do not enter your PIN)',
170
174
  'stopTunnel': 'Stop',
171
175
  'enable': 'Enable anywhere',
172
176
  'opening': 'Enabling…',
package/lib/index.js CHANGED
@@ -104,9 +104,30 @@ function refreshLanToken() {
104
104
  * 一律校验公网密码;loopback/局域网(私网 IP、.local 等)才走局域网密码——
105
105
  * 自建隧道 + 关闭局域网密码不再出现公网裸奔。
106
106
  */
107
- function tokenForHost(host) {
107
+ export function tokenForHost(host) {
108
+ if (isLanOverrideHost(host)) return getLanToken();
108
109
  return classifyHost(host) === 'public' ? getAccessToken() : getLanToken();
109
110
  }
111
+ /** 去掉 Host 的端口(与 classifyHost 一致),用于和手动设置的局域网地址比较。 */
112
+ function hostNameOnly(host) {
113
+ let name = String(host ?? '').trim().toLowerCase();
114
+ if (name.startsWith('[')) {
115
+ const end = name.indexOf(']');
116
+ if (end >= 0) name = name.slice(1, end);
117
+ } else {
118
+ name = name.replace(/:\d+$/, '');
119
+ }
120
+ return name;
121
+ }
122
+ /**
123
+ * 用户手动指定的「局域网地址」覆盖(issue #79):无论什么网段(含 Radmin 26.x 等任意 overlay),
124
+ * 一律按局域网密码/开关裁决。优先级高于 classifyHost——避免某 overlay 地址落在公网判定里、
125
+ * 导致局域网入口被错判成公网、比对成公网密码而永远「密码错误」。
126
+ */
127
+ export function isLanOverrideHost(host) {
128
+ const override = lanIpOverride().trim().toLowerCase();
129
+ return override.length > 0 && hostNameOnly(host) === override;
130
+ }
110
131
  /**
111
132
  * 用户自定义访问密码(issue #33):公网/局域网各自设一个固定的 8 位密码(英文字母大小写或数字)。
112
133
  * 自定义后公网开启时不再自动轮换(rotateAccessToken 见上)。
@@ -284,7 +305,10 @@ export function apply(ctx, config = {}, internals = {}) {
284
305
  // dsh web 重启/更新后 sessionKey 变化 → 手机需重新输入
285
306
  sessionKey: randomBytes(16).toString('hex'),
286
307
  getToken: (host) => tokenForHost(host),
287
- isProtected: (host) => (classifyHost(host) === 'public' ? true : lanAuthEnabled()),
308
+ isProtected: (host) => {
309
+ if (isLanOverrideHost(host)) return lanAuthEnabled();
310
+ return classifyHost(host) === 'public' ? true : lanAuthEnabled();
311
+ },
288
312
  },
289
313
  // dsh web 浏览器会话启动 token(issue #77):新版 dsh(>= 0.1.2-alpha.1)要求根路径
290
314
  // 带一次 `?token=` 换 cookie,否则 /api 与 WebSocket 全 401。token 每次进程启动都变,
package/lib/proxy.mjs CHANGED
@@ -15,6 +15,7 @@ import { createServer } from 'node:http';
15
15
  import { request as httpRequest } from 'node:http';
16
16
  import { createGzip, createBrotliCompress, constants as zlibConstants } from 'node:zlib';
17
17
  import { createHash } from 'node:crypto';
18
+ import { ensureSessionFingerprint } from './session.mjs';
18
19
 
19
20
  const DEFAULT_UPSTREAM = { host: '127.0.0.1', port: 3080 };
20
21
 
@@ -36,6 +37,8 @@ export const RANDOM_UUID_POLYFILL = `<script data-dsh-pocket-polyfill="1">!funct
36
37
  已回退;该问题属 DSH 客户端限制(location.hostname 是 unforgeable 属性)无法安全绕过。*/</script>`;
37
38
 
38
39
  const INJECT_MARK = 'data-dsh-pocket-polyfill="1"';
40
+ /** 防密码被钓(issue #82):本代理注入到每个 HTML 的会话指纹标记。 */
41
+ const SESSION_MARK = 'name="dsh-pocket-session"';
39
42
 
40
43
  /**
41
44
  * DSH Desktop(桌面版)渲染进程兼容补丁(issue #3/#4,已于 issue #76 停用)。
@@ -165,15 +168,20 @@ function parseCookies(header) {
165
168
  return out;
166
169
  }
167
170
 
168
- /** 登录页:按访问来源显示提示(局域网 / 公网);error: false|true|'locked'(locked 带剩余秒数)。 */
169
- function loginPageHtml(error, isPublic, retryAfter = 0) {
171
+ /** 登录页:按访问来源显示提示(局域网 / 公网);error: false|true|'locked'(locked 带剩余秒数)。
172
+ * fp: 本次公网会话指纹(issue #82 防钓鱼)——与电脑「手机访问」设置页一致才是你的 DSH。 */
173
+ function loginPageHtml(error, isPublic, retryAfter = 0, fp = '') {
170
174
  const where = isPublic ? '此公网地址' : '此局域网地址';
171
175
  const whereEn = isPublic ? 'This public address' : 'This LAN address';
172
176
  const errMsg = error === 'locked'
173
177
  ? `尝试次数过多,请 ${retryAfter} 秒后再试 | Too many attempts — try again in ${retryAfter}s`
174
178
  : error ? '密码错误,请重试 | Wrong PIN, try again' : '';
179
+ const fpLine = fp
180
+ ? `<p style="margin:14px 0 0;font-size:12px;line-height:1.6;color:#6b7280">本页会话指纹:<b style="font-family:ui-monospace,Menlo,monospace;letter-spacing:1px;color:#111827">${fp}</b><br>与电脑「手机访问」设置页显示的<b>一致</b>,才是你的 DSH;不一致请勿输入密码。<br><span style="color:#9ca3af">Session fingerprint — only enter your PIN if it matches the one shown on your computer's "Phone access" settings.</span></p>`
181
+ : '';
175
182
  return `<!doctype html><html lang="zh"><head><meta charset="utf-8">
176
183
  <meta name="viewport" content="width=device-width,initial-scale=1">
184
+ <meta name="dsh-pocket-session" content="${fp}">
177
185
  <title>DSH Pocket · 访问验证</title>
178
186
  <style>
179
187
  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}
@@ -191,7 +199,7 @@ button{width:100%;padding:10px;font-size:15px;background:#4f6ef7;color:#fff;bord
191
199
  <form method="post" action="/pocket-login">
192
200
  <input name="token" type="password" maxlength="8" autocomplete="one-time-code" autofocus required>
193
201
  <button type="submit">进入 | Enter</button>
194
- </form>
202
+ </form>${fpLine}
195
203
  </div></body></html>`;
196
204
  }
197
205
 
@@ -202,8 +210,8 @@ button{width:100%;padding:10px;font-size:15px;background:#4f6ef7;color:#fff;bord
202
210
  * 命名隧道/反向代理指向本机端口时,固定域名被误判成局域网;若局域网密码又
203
211
  * 关着,公网入口就无密码裸奔。现在反转为 fail closed:
204
212
  * - loopback:localhost / 127.x / ::1 / 0.0.0.0(本机与 cloudflared 回连)
205
- * - lan:RFC1918 私网 IPv4、IPv6 ULA/link-local、`.local`(mDNS)、
206
- * 无点单标签名(NetBIOS 计算机名等——公网 DNS 不可能解析它们)
213
+ * - lan:RFC1918 私网 IPv4、CGNAT 100.64/10(RFC 6598,Tailscale/ZeroTier 默认网段,
214
+ * 公网不可路由)、IPv6 ULA/link-local、`.local`(mDNS)、无点单标签名(NetBIOS 计算机名等)
207
215
  * - public:其余一切 Host(trycloudflare 或任何陌生域名)→ 强制公网密码
208
216
  *
209
217
  * @returns {'loopback'|'lan'|'public'}
@@ -217,7 +225,8 @@ export function classifyHost(host) {
217
225
  name = name.replace(/:\d+$/, ''); // hostname:3081 / 127.0.0.1:3081 → 去掉端口
218
226
  }
219
227
  if (name === 'localhost' || name === '0.0.0.0' || name === '::1' || /^127\./.test(name)) return 'loopback';
220
- if (/^(?:10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.)/.test(name)) return 'lan'; // RFC1918
228
+ // RFC1918 私网 + CGNAT 100.64/10(RFC 6598,Tailscale/ZeroTier 默认网段,公网不可路由)
229
+ if (/^(?:10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.|100\.(?:6[4-9]|[7-9]\d|1(?:0\d|1\d|2[0-7]))\.)/.test(name)) return 'lan';
221
230
  if (/^(?:fe80:|f[cd][0-9a-f]{2}:)/.test(name) && name.includes(':')) return 'lan'; // IPv6 link-local / ULA
222
231
  if (name === '' || name.includes(':')) return 'loopback'; // 裸 IPv6 / 无 Host → 当本机
223
232
  if (name.endsWith('.local') || !name.includes('.')) return 'lan'; // mDNS / NetBIOS 单标签名
@@ -262,6 +271,36 @@ p{font-size:13px;color:#6b7280;margin:0;line-height:1.6}
262
271
  </div></body></html>`;
263
272
  }
264
273
 
274
+ /** 桌面端浏览器访问门禁提示页(issue #81):DSH Desktop 未开启「浏览器访问」时,
275
+ * 上游 desktop-browser-access 门禁对普通浏览器(含经本代理转发的手机)返回 403
276
+ * `forbidden`,且本代理无法携带 Electron renderer secret 绕过。对符合该特征的
277
+ * 浏览器导航请求返回此可操作提示页;API/WS 与其余 403 原样透传。 */
278
+ function desktopAccessBlockedPageHtml() {
279
+ return `<!doctype html><html lang="zh"><head><meta charset="utf-8">
280
+ <meta name="viewport" content="width=device-width,initial-scale=1">
281
+ <title>DSH Pocket · 桌面端未开启浏览器访问</title>
282
+ <style>
283
+ 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}
284
+ .card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:28px 24px;max-width:392px;width:calc(100% - 48px);text-align:center}
285
+ h1{font-size:16px;margin:0 0 8px;color:#111827}
286
+ p{font-size:13px;color:#6b7280;margin:0 0 12px;line-height:1.6}
287
+ code{background:#f3f4f6;padding:2px 6px;border-radius:6px;font-size:12px;color:#374151}
288
+ .step{text-align:left;background:#f9fafb;border:1px solid #eef2f7;border-radius:10px;padding:12px 14px;margin-top:8px;font-size:12px;color:#4b5563;line-height:1.8}
289
+ </style></head><body><div class="card">
290
+ <h1>🖥️ DSH Pocket</h1>
291
+ <p>你已通过访问密码,但页面仍被拦截。<br>因为本机 DSH Desktop 未开启「浏览器访问」,桌面门禁拒绝了普通浏览器(含手机)的页面请求。</p>
292
+ <div class="step">
293
+ <strong>解决方法(任选其一):</strong><br>
294
+ 1. DSH Desktop → 设置 → 窗口 / 模式 → 开启「浏览器访问」(自动切到 compatibility 模式)→ <strong>重启 DSH Desktop</strong>。<br>
295
+ 2. 或在配置文件中设置:<br>
296
+ <code>dsh-desktop: { mode: compatibility, openBrowser: true }</code><br>
297
+ 然后重启 DSH Desktop,再刷新本页。
298
+ </div>
299
+ <p style="margin-top:14px">注意:访问密码登录成功 ≠ 已获得桌面 Web 访问授权。门禁由 DSH Desktop 控制,pocket 无法代为绕过。</p>
300
+ <p style="color:#9ca3af">The host DSH Desktop has "browser access" disabled. Enable it (Settings → window/mode → browser access → restart), or set <code>dsh-desktop.mode: compatibility, openBrowser: true</code>, then refresh.</p>
301
+ </div></body></html>`;
302
+ }
303
+
265
304
  /** 请求是否期望 HTML(浏览器导航 → 返回登录页;API/WS → 401)。 */
266
305
  function isHtmlRequest(req) {
267
306
  const accept = String(req.headers.accept ?? '');
@@ -449,6 +488,9 @@ function attachWebSocketHeartbeat(socket, { intervalMs = 30_000, missLimit = 2 }
449
488
  */
450
489
  export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DEFAULT_UPSTREAM, log = null, injectHtml = DEFAULT_INJECT, auth = null, rateLimit = null, heartbeat = {}, lanAccessEnabled = () => true, launchToken = () => '' } = {}) {
451
490
  const limiter = auth ? createRateLimiter(rateLimit ?? {}) : null;
491
+ // 防密码被钓(issue #82):本次进程会话指纹,注入到所有经代理伺服的 HTML
492
+ const sessionFingerprint = ensureSessionFingerprint();
493
+ const sessionMeta = `<meta name="dsh-pocket-session" content="${sessionFingerprint}">`;
452
494
  const server = createServer((req, res) => {
453
495
  const host = String(req.headers.host ?? '');
454
496
  const isPublic = classifyHost(host) === 'public';
@@ -479,12 +521,12 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
479
521
  if (req.method === 'POST' && req.url?.startsWith('/pocket-login')) {
480
522
  const rl = limiter?.status(ip) ?? { locked: false, retryAfter: 0 };
481
523
  if (rl.locked) {
482
- res.writeHead(429, {
483
- 'content-type': 'text/html; charset=utf-8',
484
- 'cache-control': 'no-store',
485
- 'retry-after': String(rl.retryAfter),
486
- });
487
- res.end(loginPageHtml('locked', isPublic, rl.retryAfter));
524
+ res.writeHead(429, {
525
+ 'content-type': 'text/html; charset=utf-8',
526
+ 'cache-control': 'no-store',
527
+ 'retry-after': String(rl.retryAfter),
528
+ });
529
+ res.end(loginPageHtml('locked', isPublic, rl.retryAfter, sessionFingerprint));
488
530
  return;
489
531
  }
490
532
  let body = '';
@@ -505,7 +547,7 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
505
547
  limiter?.record(ip);
506
548
  log?.(`dsh-pocket: login failed from ${ip} | 登录失败 IP: ${ip}`);
507
549
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
508
- res.end(loginPageHtml(true, isPublic));
550
+ res.end(loginPageHtml(true, isPublic, 0, sessionFingerprint));
509
551
  }
510
552
  });
511
553
  return;
@@ -516,7 +558,7 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
516
558
  // 锁定期间打开登录页也给提示(HTTP 200 + 锁定文案;429 语义留给 POST 拒绝)
517
559
  const rl = limiter?.status(ip) ?? { locked: false, retryAfter: 0 };
518
560
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
519
- res.end(loginPageHtml(rl.locked ? 'locked' : false, isPublic, rl.retryAfter));
561
+ res.end(loginPageHtml(rl.locked ? 'locked' : false, isPublic, rl.retryAfter, sessionFingerprint));
520
562
  } else {
521
563
  res.writeHead(401, { 'content-type': 'application/json', 'cache-control': 'no-store' });
522
564
  res.end('{"error":"unauthorized"}');
@@ -542,6 +584,43 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
542
584
  (proxyRes) => {
543
585
  log?.(`${req.method} ${req.url} -> ${proxyRes.statusCode}`);
544
586
  const contentType = String(proxyRes.headers['content-type'] ?? '');
587
+ // issue #81:上游 desktop-browser-access 门禁(DSH Desktop 未开启「浏览器访问」时)
588
+ // 对普通浏览器(含经本代理转发的手机)返回 403 text/plain "forbidden",且本代理无法
589
+ // 携带 Electron renderer secret 绕过。对符合该特征的**浏览器导航**请求改写为可操作
590
+ // 提示页;API/WS 与其余 403 原样透传(不猜 secret、不把任意 403 都判为桌面门禁)。
591
+ if (proxyRes.statusCode === 403 && contentType.includes('text/plain')) {
592
+ const navReq = isHtmlRequest(req);
593
+ const gateChunks = [];
594
+ let gateOverflow = false;
595
+ const passRaw403 = () => {
596
+ if (res.headersSent) return;
597
+ res.writeHead(403, { ...proxyRes.headers });
598
+ if (gateChunks.length) res.write(Buffer.concat(gateChunks));
599
+ proxyRes.pipe(res);
600
+ };
601
+ proxyRes.on('data', (c) => {
602
+ if (gateOverflow) return;
603
+ gateChunks.push(c);
604
+ if (Buffer.concat(gateChunks).length > 65536) { gateOverflow = true; gateChunks.length = 0; passRaw403(); }
605
+ });
606
+ proxyRes.on('end', () => {
607
+ if (gateOverflow || res.headersSent) return;
608
+ const body = Buffer.concat(gateChunks).toString('utf8').trim();
609
+ if (navReq && body === 'forbidden') {
610
+ res.writeHead(403, {
611
+ 'content-type': 'text/html; charset=utf-8',
612
+ 'cache-control': 'no-store',
613
+ 'x-dsh-pocket-gate': 'desktop-browser-access',
614
+ });
615
+ res.end(desktopAccessBlockedPageHtml());
616
+ } else {
617
+ res.writeHead(403, { ...proxyRes.headers });
618
+ res.end(Buffer.concat(gateChunks));
619
+ }
620
+ });
621
+ proxyRes.on('error', () => res.destroy());
622
+ return;
623
+ }
545
624
  // 只给**未压缩**的 HTML 文档注入(SSE/WS/JS/CSS 原样透传;压缩流注入会损坏页面);
546
625
  // 注入后修正 Content-Length
547
626
  if (injectHtml && contentType.includes('text/html') && !isCompressed(proxyRes.headers)) {
@@ -549,6 +628,11 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
549
628
  proxyRes.on('data', (c) => chunks.push(c));
550
629
  proxyRes.on('end', () => {
551
630
  let html = Buffer.concat(chunks).toString('utf8');
631
+ // 防密码被钓(issue #82):注入会话指纹 meta(与登录页同源)。
632
+ // 仅当页面尚无该标记时注入,避免重复;不存在 <head> 时跳过(dsh web 必有)。
633
+ if (!html.includes(SESSION_MARK)) {
634
+ html = html.replace(/<head[^>]*>/i, (m) => `${m}${sessionMeta}`);
635
+ }
552
636
  if (!html.includes(INJECT_MARK)) {
553
637
  html = html.replace(/<head[^>]*>/i, (m) => `${m}${injectHtml}`);
554
638
  }
package/lib/service.mjs CHANGED
@@ -15,6 +15,7 @@ import { join, dirname } from 'node:path';
15
15
  import { createPocketProxy } from './proxy.mjs';
16
16
  import { startQuickTunnel, startNamedTunnel } from './tunnel.mjs';
17
17
  import { isValidIpv4 } from './ip.mjs';
18
+ import { ensureSessionFingerprint } from './session.mjs';
18
19
 
19
20
  const require = createRequire(import.meta.url);
20
21
 
@@ -24,8 +25,9 @@ export async function qrDataUrl(text, { width = 220, margin = 1 } = {}) {
24
25
  return QRCode.toDataURL(text, { errorCorrectionLevel: 'M', margin, width, type: 'image/png' });
25
26
  }
26
27
 
27
- /** RFC1918 私网地址:手机与电脑连同一局域网时通常可直连。 */
28
- const PRIVATE_IPV4_RE = /^(?:10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.)/;
28
+ // RFC1918 私网地址:手机与电脑连同一局域网时通常可直连。
29
+ // 另含 CGNAT 100.64/10(RFC 6598,Tailscale/ZeroTier 默认网段,公网不可路由),保持一致(issue #79)。
30
+ const PRIVATE_IPV4_RE = /^(?:10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.|100\.(?:6[4-9]|[7-9]\d|1(?:0\d|1\d|2[0-7]))\.)/;
29
31
 
30
32
  /** 名称像真实物理网卡的接口(WLAN / Wi-Fi / Ethernet / 以太网 / 有线 / 无线 / en / eth)。 */
31
33
  const PHYSICAL_IFACE_RE = /^(?:wlan|wi-?fi|wireless|ethernet|eth\d|en\d|wlp\d|以太网|有线|无线|本地连接)/i;
@@ -398,6 +400,8 @@ export function createPocketService({
398
400
  };
399
401
  })(),
400
402
  dshPort,
403
+ // 防密码被钓(issue #82):本次进程的会话指纹,设置页展示供用户与手机登录页交叉核对
404
+ sessionFingerprint: ensureSessionFingerprint(),
401
405
  };
402
406
  },
403
407
 
@@ -0,0 +1,35 @@
1
+ // 公网会话指纹(防密码被钓,issue #82)。
2
+ //
3
+ // 每次 dsh web 进程启动生成一次,随机、不可预测。只经本代理注入到我们伺服的
4
+ // HTML(应用页 + 登录页),并显示在电脑「手机访问」设置页——永不落盘、不进日志、
5
+ // 不出现在任何静态产物里。
6
+ //
7
+ // 快速隧道子域被 Cloudflare 回收、分给陌生人后,陌生人的站点不经我们的代理,
8
+ // 拿不到这个指纹、也猜不出。于是其克隆的登录页要么没有指纹、要么指纹对不上:
9
+ // 用户在手机登录页看到的指纹若与电脑设置页不一致,即说明链接已被他人复用,
10
+ // 不应输入密码。这是"密码不防回收"之外,真正能拦住密码钓鱼的一层。
11
+
12
+ import { randomBytes } from 'node:crypto'
13
+
14
+ let fingerprint = null
15
+
16
+ /**
17
+ * 惰性生成并返回本次进程的会话指纹(多次调用返回同一值)。
18
+ * @returns {string} 6 位大写字母/数字指纹
19
+ */
20
+ export function ensureSessionFingerprint() {
21
+ if (!fingerprint) {
22
+ let s = ''
23
+ while (s.length < 6) {
24
+ // base64 含 + / 等非字母数字字符,只保留 A-Z0-9 并转大写
25
+ s += randomBytes(6).toString('base64').replace(/[^A-Z0-9]/gi, '').toUpperCase()
26
+ }
27
+ fingerprint = s.slice(0, 6)
28
+ }
29
+ return fingerprint
30
+ }
31
+
32
+ /** 读取已生成的指纹(代理尚未启动时返回 null)。 */
33
+ export function getSessionFingerprint() {
34
+ return fingerprint
35
+ }
package/lib/tunnel.mjs CHANGED
@@ -22,6 +22,22 @@ import { createWriteStream } from 'node:fs';
22
22
  // 正是该错误体,与 issue 完全一致。
23
23
  export const QUICK_TUNNEL_URL_RE = /https:\/\/(?!api\.)[a-z0-9-]+\.trycloudflare\.com/i;
24
24
 
25
+ /**
26
+ * 从 cloudflared 输出里提取最有诊断价值的一段(issue #78)。
27
+ *
28
+ * cloudflared 参数错误(如 "Incorrect Usage: flag provided but not defined")的
29
+ * 关键信息在输出**开头**,尾部整段都是 usage 帮助文本(对用户没用);而运行期错误
30
+ * (403 / 协议 / 网络)的关键信息在**尾部**。所以:参数错误取开头片段,其它仍取尾部。
31
+ * 最多 500 字符,与历史上限一致。
32
+ */
33
+ export function firstMeaningfulErrorLine(buf) {
34
+ const text = String(buf ?? '').trim();
35
+ if (/^(Incorrect Usage|flag provided but not defined|unknown flag|unknown command)/im.test(text)) {
36
+ return text.split(/\r?\n/)[0].trim().slice(0, 500);
37
+ }
38
+ return text.split(/\r?\n/).slice(-4).join('\n').trim().slice(0, 500);
39
+ }
40
+
25
41
  function platformBinary() {
26
42
  const archMap = { x64: 'amd64', arm64: 'arm64', ia32: '386', arm: 'arm' };
27
43
  const a = archMap[process.arch] ?? process.arch;
@@ -430,7 +446,10 @@ export async function startNamedTunnel({ token, home, signal, onPhase = () => {}
430
446
  const bin = await resolveCloudflared({ home, onPhase, signal });
431
447
  onPhase('starting');
432
448
  // 与快速隧道一致强制 HTTP/2(国内/企业网常屏蔽 UDP 7844 → error 1033)
433
- const child = spawn(bin, ['tunnel', 'run', '--protocol', 'http2', '--no-autoupdate'], {
449
+ // 与快速隧道一致强制 HTTP/2(国内/企业网常屏蔽 UDP 7844 error 1033)
450
+ // `--no-autoupdate` 必须在全局位置(子命令之前):cloudflared 2026.x 移除了
451
+ // `tunnel run` 子命令层级的该 flag,但全局位置仍有效(issue #78)
452
+ const child = spawn(bin, ['--no-autoupdate', 'tunnel', 'run', '--protocol', 'http2'], {
434
453
  stdio: ['ignore', 'pipe', 'pipe'],
435
454
  env: { ...process.env, TUNNEL_TOKEN: String(token ?? '') },
436
455
  });
@@ -457,10 +476,10 @@ export async function startNamedTunnel({ token, home, signal, onPhase = () => {}
457
476
  };
458
477
  const onExit = (code) => {
459
478
  cleanup();
460
- const tail = buf.trim().split(/\r?\n/).slice(-4).join('\n').trim();
479
+ const tail = firstMeaningfulErrorLine(buf);
461
480
  reject(new Error(
462
- `cloudflared 退出(code=${code})${tail ? ':' + tail.slice(0, 500) : ''}——请检查 Tunnel Token 是否有效、域名 Service 是否指向本机代理端口 | `
463
- + `tunnel exited (code=${code})${tail ? ': ' + tail.slice(0, 500) : ''} — check the Tunnel Token and the ingress hostname`,
481
+ `cloudflared 退出(code=${code})${tail ? ':' + tail : ''}——请检查 Tunnel Token 是否有效、域名 Service 是否指向本机代理端口 | `
482
+ + `tunnel exited (code=${code})${tail ? ': ' + tail : ''} — check the Tunnel Token and the ingress hostname`,
464
483
  ));
465
484
  };
466
485
  cleanup = () => {
@@ -519,7 +538,8 @@ export async function startQuickTunnel({ port, home, signal, onPhase = () => {}
519
538
  // 强制 HTTP/2(TCP 443)而不是默认的 QUIC(UDP 7844):
520
539
  // 国内网络/部分企业网常屏蔽 UDP 7844,导致 tunnel 报 error 1033(Tunnel error);
521
540
  // HTTP/2 走 443 更稳。若平台未来恢复 QUIC 可达,可去掉 --protocol http2。
522
- const child = spawn(bin, ['tunnel', '--url', `http://127.0.0.1:${port}`, '--protocol', 'http2', '--no-autoupdate'], {
541
+ // `--no-autoupdate` 必须在全局位置(子命令之前,见 issue #78 同款修复)
542
+ const child = spawn(bin, ['--no-autoupdate', 'tunnel', '--url', `http://127.0.0.1:${port}`, '--protocol', 'http2'], {
523
543
  stdio: ['ignore', 'pipe', 'pipe'],
524
544
  });
525
545
  // H1:spawn 失败(缓存二进制损坏等)必须接住,否则 uncaughtException 崩宿主
@@ -545,10 +565,10 @@ export async function startQuickTunnel({ port, home, signal, onPhase = () => {}
545
565
  };
546
566
  const onExit = (code) => {
547
567
  cleanup();
548
- // 带上 cloudflared 自己的输出尾段(stderr 常含具体原因:403/协议/网络等),
549
- // 否则「code=1」用户无从排查(issue #65)
550
- const tail = buf.trim().split(/\r?\n/).slice(-3).join('\n').trim();
551
- reject(new Error(`cloudflared 退出(code=${code})${tail ? ':' + tail.slice(0, 500) : ''}`));
568
+ // 带上 cloudflared 自己的输出(参数错误显示开头、运行期错误显示尾部,见 firstMeaningfulErrorLine),
569
+ // 否则「code=1」用户无从排查(issue #65 / #78
570
+ const tail = firstMeaningfulErrorLine(buf);
571
+ reject(new Error(`cloudflared 退出(code=${code})${tail ? ':' + tail : ''}`));
552
572
  };
553
573
  cleanup = () => {
554
574
  child.stdout.off('data', onData);
package/package.json CHANGED
@@ -80,5 +80,5 @@
80
80
  "access": "public",
81
81
  "registry": "https://registry.npmjs.org/"
82
82
  },
83
- "version": "2.8.0"
83
+ "version": "2.9.0"
84
84
  }