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 +95 -0
- package/client/index.jsx +5 -0
- package/client/mobile/mobile-apply.tsx +10 -0
- package/client/mobile/sessionGuard.ts +80 -0
- package/client/pocket-locales.js +4 -0
- package/lib/index.js +26 -2
- package/lib/proxy.mjs +98 -14
- package/lib/service.mjs +6 -2
- package/lib/session.mjs +35 -0
- package/lib/tunnel.mjs +29 -9
- package/package.json +1 -1
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
|
+
}
|
package/client/pocket-locales.js
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
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、
|
|
206
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
|
package/lib/session.mjs
ADDED
|
@@ -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
|
-
|
|
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
|
|
479
|
+
const tail = firstMeaningfulErrorLine(buf);
|
|
461
480
|
reject(new Error(
|
|
462
|
-
`cloudflared 退出(code=${code})${tail ? ':' + tail
|
|
463
|
-
+ `tunnel exited (code=${code})${tail ? ': ' + tail
|
|
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
|
-
|
|
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
|
|
549
|
-
// 否则「code=1」用户无从排查(issue #65)
|
|
550
|
-
const tail = buf
|
|
551
|
-
reject(new Error(`cloudflared 退出(code=${code})${tail ? ':' + tail
|
|
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