dsh-pocket 1.10.0 → 1.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +11 -5
- package/README.md +11 -5
- package/client/api.js +1 -0
- package/client/client.js +64 -6
- package/client/index.jsx +52 -6
- package/client/pocket-locales.js +16 -0
- package/lib/index.js +44 -13
- package/lib/proxy.mjs +113 -17
- package/lib/settings.mjs +26 -1
- package/lib/web-rpc.js +14 -1
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -43,7 +43,9 @@ What it looks like — the phone shows the exact same UI as your computer, live:
|
|
|
43
43
|
|---|---|
|
|
44
44
|
| 📶 LAN QR access | Works out of the box: Settings → Phone access — scan the LAN QR on the same Wi-Fi |
|
|
45
45
|
| 🌐 Public QR (from anywhere) | Click "Enable anywhere" → cloudflared tunnel → scan the public QR over 4G / any network |
|
|
46
|
-
| 🔐 Access PIN | Public links require an **8-digit PIN** (rotated every
|
|
46
|
+
| 🔐 Access PIN | Public links require an **8-digit PIN** (rotated on every tunnel start by default; **customizable to a fixed PIN** — custom PINs are not rotated); LAN has its own separate **8-digit PIN** (on by default; switchable off in Settings — then LAN scans connect directly) |
|
|
47
|
+
| 🔑 Custom PINs | Both the public and LAN PINs can be **set to your own fixed 8-digit number** in Settings (custom PINs are never auto-rotated) |
|
|
48
|
+
| 🧘 Session persistence | Enter the PIN once and you're set for a long time (login is tied to the computer's dsh web process: as long as it stays up, the phone won't ask again; **after a dsh web restart/update, enter it once more**) |
|
|
47
49
|
| ⚡ Real-time sync | Streaming output passes through WebSocket untouched — what the computer renders, the phone renders live; fully interactive both ways |
|
|
48
50
|
| 📱 Mobile-adaptive layout | Narrow screens get a drawer layout automatically (ported from dsh-web-mobile, MIT): sidebar drawer, full-width conversation, safe-area insets, touch optimizations |
|
|
49
51
|
| 🗜️ Transfer compression | Large JSON responses are gzip/brotli'd on the fly (17MB session history → ~1MB; brotli quality 6: fast and bandwidth-friendly) — faster loads, less mobile data |
|
|
@@ -75,23 +77,27 @@ npx @deepseek-ai/dsh web
|
|
|
75
77
|
|
|
76
78
|
### LAN (same Wi-Fi)
|
|
77
79
|
|
|
78
|
-
Settings → **Phone access** → scan the "📶 LAN" QR code → enter the **LAN PIN** (shown in the LAN block
|
|
80
|
+
Settings → **Phone access** → scan the "📶 LAN" QR code → enter the **LAN PIN** (shown in the LAN block; hit **Refresh** to roll a new one, or **Customize** to set your own fixed 8 digits) → the phone opens the exact same DSH, in real time.
|
|
79
81
|
|
|
80
82
|
> The LAN PIN is **on by default** (security-first). If you're the only user and find typing it every time annoying, flip "LAN access PIN" to **Off** in the LAN block — LAN scans then connect directly with no PIN (LAN-only devices; the **public tunnel always requires a PIN**, unaffected).
|
|
83
|
+
>
|
|
84
|
+
> After logging in once, the phone **won't ask again**: as long as the computer's dsh web keeps running, reopening the phone needs no PIN (**a dsh web restart/update asks for it once more**).
|
|
81
85
|
|
|
82
86
|
### Public (from anywhere)
|
|
83
87
|
|
|
84
|
-
On the same page click "**Enable anywhere**" → wait for the tunnel (first run downloads cloudflared; macOS/Linux use the Tsinghua mirror, seconds) → scan the "🌐 Public" QR code → the phone opens the link and **enters the 8-digit PIN** (shown in the settings page's public section
|
|
88
|
+
On the same page click "**Enable anywhere**" → wait for the tunnel (first run downloads cloudflared; macOS/Linux use the Tsinghua mirror, seconds) → scan the "🌐 Public" QR code → the phone opens the link and **enters the 8-digit PIN** (shown in the settings page's public section; **rotated on every tunnel start by default**, or **Customize** it to a fixed PIN that is never rotated) → works from outside (4G / office network).
|
|
85
89
|
|
|
86
90
|
> Upgrading: `dsh plugin --profile web update dsh-pocket --latest -w` (`--latest` is required across major versions — a `^0.x` range won't auto-jump to 1.x).
|
|
87
91
|
|
|
88
92
|
## ⚠️ Security (read first)
|
|
89
93
|
|
|
90
94
|
- **DSH can execute code on your computer.** **LAN** QR/URL plus its own **8-digit PIN** is the key (PIN **on by default**, switchable off — then LAN scans connect directly, same-network devices only) — **never share the LAN QR, URL or PIN**.
|
|
91
|
-
- **Public** access is protected by an **8-digit PIN**: the link is random, the PIN rotates on every tunnel start, and old links die instantly — even a leaked link can't get in.
|
|
95
|
+
- **Public** access is protected by an **8-digit PIN**: the link is random, the PIN rotates on every tunnel start by default, and old links die instantly — even a leaked link can't get in. **A custom PIN is never auto-rotated** (your value stays stable).
|
|
96
|
+
- Phone login state is tied to the computer's dsh web process: **no re-entry while dsh web stays up; one re-entry after a restart/update**.
|
|
97
|
+
- **Login rate limiting** (anti brute-force): **5** consecutive wrong PINs from the same IP lock it for **60s**; a global failure threshold briefly locks everyone (blocks distributed IP-rotation scans); a successful login resets the counter.
|
|
92
98
|
- The public URL is randomly assigned by cloudflared and **changes on every restart** (old links die automatically — a natural key rotation).
|
|
93
99
|
- LAN mode exposes nothing publicly; only devices on the same network can reach it.
|
|
94
|
-
- Built for personal use; the public PIN lives in `$DSH_HOME/dsh-pocket/token` (re-rolled
|
|
100
|
+
- Built for personal use; the public PIN lives in `$DSH_HOME/dsh-pocket/token` (re-rolled per tunnel start unless customized), the LAN PIN in `$DSH_HOME/dsh-pocket/token-lan` (refreshed manually in Settings), and switches/custom flags in `$DSH_HOME/dsh-pocket/settings.json`.
|
|
95
101
|
|
|
96
102
|
## 💻 DSH Desktop
|
|
97
103
|
|
package/README.md
CHANGED
|
@@ -43,7 +43,9 @@ DSH Pocket 就是干这个的:**装上它,手机扫个码,就能实时看
|
|
|
43
43
|
|---|---|
|
|
44
44
|
| 📶 局域网扫码 | 装好即用:设置 → 手机访问,打开就有局域网二维码,手机连同一 WiFi 扫码即开 |
|
|
45
45
|
| 🌐 公网扫码(人在外面) | 点「开启公网访问」→ cloudflared 隧道 → 出公网二维码,4G/任何网络都能访问 |
|
|
46
|
-
| 🔐 访问密码 | 公网链接需输入 **8
|
|
46
|
+
| 🔐 访问密码 | 公网链接需输入 **8 位数字密码**(默认每次开启公网自动换新;**可自定义固定密码**——自定义后不再换新);局域网有独立 **8 位数字密码**(默认开启,设置页可**一键关闭**——关闭后局域网扫码直连) |
|
|
47
|
+
| 🔑 自定义密码 | 公网/局域网密码都可在设置页**设成自己固定的 8 位数字**(自定义后公网不再自动换新) |
|
|
48
|
+
| 🧘 会话保持 | 手机输一次密码后**长期免输**(登录状态绑定电脑上的 dsh web 进程:只要它不重启,手机不用再输;**dsh web 重启/更新后需重新输入一次**) |
|
|
47
49
|
| ⚡ 实时同步 | 流式输出走 WebSocket 全透传——**电脑上在输出,手机上同步在滚**,可双向操作 |
|
|
48
50
|
| 📱 移动端适配 | 窄屏自动变抽屉布局(移植 dsh-web-mobile,MIT):侧栏抽屉、会话全宽、状态栏安全区、触控优化 |
|
|
49
51
|
| 🗜️ 传输压缩 | 大 JSON 响应自动 gzip/brotli(长会话 17MB → ~1MB,brotli 质量 6:快且省流量),手机加载更快、更省流量 |
|
|
@@ -75,23 +77,27 @@ npx @deepseek-ai/dsh web
|
|
|
75
77
|
|
|
76
78
|
### 局域网(同一 WiFi)
|
|
77
79
|
|
|
78
|
-
设置 → **手机访问** → 手机扫「📶 局域网」二维码 →
|
|
80
|
+
设置 → **手机访问** → 手机扫「📶 局域网」二维码 → 打开链接**输入局域网密码**(显示在设置页局域网区块,点「刷新」可换新,或点「自定义」设成自己固定的 8 位数字)→ 打开的就是电脑上的 DSH,实时同步。
|
|
79
81
|
|
|
80
82
|
> 局域网密码**默认开启**(安全优先)。如果只有自己用、嫌每次输密码麻烦,可在设置页局域网区块把「局域网访问密码」切到**关**——之后局域网扫码直连、无需密码(仅同一局域网设备可访问;**公网始终要密码**,不受影响)。
|
|
83
|
+
>
|
|
84
|
+
> 手机登录一次后**长期免输**:只要电脑上的 dsh web 不重启,再次打开手机不用再输入(**dsh web 重启/更新后需重新输入一次**)。
|
|
81
85
|
|
|
82
86
|
### 公网(人在外面)
|
|
83
87
|
|
|
84
|
-
同一页点「**开启公网访问**」→ 等隧道建立(首次会下载 cloudflared,macOS/Linux 走清华镜像秒下)→ 手机扫「🌐 公网」二维码 → 打开链接**输入 8
|
|
88
|
+
同一页点「**开启公网访问**」→ 等隧道建立(首次会下载 cloudflared,macOS/Linux 走清华镜像秒下)→ 手机扫「🌐 公网」二维码 → 打开链接**输入 8 位访问密码**(密码显示在设置页公网区块,默认**每次开启公网变新**,也可点「自定义」设成固定密码——自定义后不再换新)→ 人在外面(4G/公司网)也能访问。
|
|
85
89
|
|
|
86
90
|
> 更新到新版本:`dsh plugin --profile web update dsh-pocket --latest -w`(跨大版本时 `--latest` 是必须的,`^0.x` 范围不会自动升到 1.x)。
|
|
87
91
|
|
|
88
92
|
## ⚠️ 安全(必读)
|
|
89
93
|
|
|
90
94
|
- **DSH 能执行你电脑上的代码**。**局域网**二维码/URL 配上独立 **8 位数字密码**才是钥匙(密码**默认开启**,可关——关闭后局域网扫码直连,仅同一网络设备可访问),**请勿把局域网二维码、URL 或密码发给别人**
|
|
91
|
-
- **公网**有 **8
|
|
95
|
+
- **公网**有 **8 位数字密码**保护:链接随机分配、默认每次开启换新密码、旧链接立即作废——泄露了也进不来,改密码/重开即可作废;**自定义密码后不再自动换新**(你设的值即稳定密码)
|
|
96
|
+
- 手机登录状态与电脑上的 dsh web 进程绑定:**电脑 dsh web 一直开着就不用重复输入;重启/更新后需重新输入一次**
|
|
97
|
+
- **登录限速**(防暴力破解):同一 IP 连续输错 **5 次**锁定 **60 秒**;全局失败超阈值时短暂全锁(防换 IP 分布式扫描);输对密码后计数清零
|
|
92
98
|
- 公网 URL 由 cloudflared 随机分配,**每次重启会变化**(旧链接自动失效,相当于天然轮换)
|
|
93
99
|
- 局域网模式不暴露公网,只有同一网络内的设备能访问
|
|
94
|
-
- 适合个人自用;公网密码存本机 `$DSH_HOME/dsh-pocket/token
|
|
100
|
+
- 适合个人自用;公网密码存本机 `$DSH_HOME/dsh-pocket/token`(默认每次开启公网自动换新,**自定义后不换**),局域网密码存 `$DSH_HOME/dsh-pocket/token-lan`(设置页手动刷新),开关/自定义标记存 `$DSH_HOME/dsh-pocket/settings.json`
|
|
95
101
|
|
|
96
102
|
## 💻 DSH Desktop(桌面版)
|
|
97
103
|
|
package/client/api.js
CHANGED
|
@@ -10,6 +10,7 @@ export const POCKET_ENDPOINTS = Object.freeze({
|
|
|
10
10
|
restart: 'pocket.restart',
|
|
11
11
|
lanTokenRefresh: 'token.lanRefresh',
|
|
12
12
|
lanAuthSetEnabled: 'lanAuth.setEnabled',
|
|
13
|
+
pinSetCustom: 'pin.setCustom',
|
|
13
14
|
});
|
|
14
15
|
|
|
15
16
|
/** 语义化版本比较:a > b 返回正数,相等 0,a < b 负数(数字段 + 预发布后缀)。 */
|
package/client/client.js
CHANGED
|
@@ -49,7 +49,8 @@ var POCKET_ENDPOINTS = Object.freeze({
|
|
|
49
49
|
update: "pocket.update",
|
|
50
50
|
restart: "pocket.restart",
|
|
51
51
|
lanTokenRefresh: "token.lanRefresh",
|
|
52
|
-
lanAuthSetEnabled: "lanAuth.setEnabled"
|
|
52
|
+
lanAuthSetEnabled: "lanAuth.setEnabled",
|
|
53
|
+
pinSetCustom: "pin.setCustom"
|
|
53
54
|
});
|
|
54
55
|
function compareVersions(a, b) {
|
|
55
56
|
const pa = String(a).replace(/^[vV]/, "").split(".");
|
|
@@ -1323,12 +1324,20 @@ var zh2 = {
|
|
|
1323
1324
|
"on": "\u5F00",
|
|
1324
1325
|
"off": "\u5173",
|
|
1325
1326
|
"lanPinValue": "\u{1F510} \u8BBF\u95EE\u5BC6\u7801\uFF1A{pin}\uFF08\u624B\u673A\u6253\u5F00\u9700\u8F93\u5165\uFF1B\u4E0E\u516C\u7F51\u5BC6\u7801\u5206\u5F00\uFF09",
|
|
1327
|
+
"lanPinCustomValue": "\u{1F510} \u8BBF\u95EE\u5BC6\u7801\uFF1A{pin}\uFF08\u81EA\u5B9A\u4E49\uFF1B\u624B\u673A\u6253\u5F00\u9700\u8F93\u5165\uFF09",
|
|
1326
1328
|
"refresh": "\u5237\u65B0",
|
|
1329
|
+
"customize": "\u81EA\u5B9A\u4E49",
|
|
1330
|
+
"customizing": "\u65B0\u5BC6\u7801\uFF088 \u4F4D\u6570\u5B57\uFF09\uFF1A",
|
|
1331
|
+
"save": "\u4FDD\u5B58",
|
|
1332
|
+
"cancel": "\u53D6\u6D88",
|
|
1333
|
+
"pinInvalid": "\u5BC6\u7801\u5FC5\u987B\u662F 8 \u4F4D\u6570\u5B57",
|
|
1334
|
+
"pinCustomHint": "\u81EA\u5B9A\u4E49\u540E\u5F00\u542F\u516C\u7F51\u4E0D\u518D\u81EA\u52A8\u6362\u65B0",
|
|
1327
1335
|
"lanPinOff": "\u{1F513} \u5BC6\u7801\u5DF2\u5173\u95ED\uFF1A\u626B\u7801\u76F4\u8FDE\uFF0C\u65E0\u9700\u5BC6\u7801\uFF08\u4EC5\u540C\u4E00\u5C40\u57DF\u7F51\u8BBE\u5907\u53EF\u8BBF\u95EE\uFF1B\u516C\u7F51\u4ECD\u8981\u5BC6\u7801\uFF09",
|
|
1328
1336
|
"lanStarting": "\u4EE3\u7406\u672A\u5C31\u7EEA\u2026",
|
|
1329
1337
|
"wanTitle": "\u{1F310} \u516C\u7F51\uFF08\u4EBA\u5728\u5916\u9762\uFF09",
|
|
1330
1338
|
"wanHint": "\u4EFB\u4F55\u7F51\u7EDC\u626B\u7801\u5373\u7528\uFF08URL \u6BCF\u6B21\u91CD\u542F\u81EA\u52A8\u6362\u65B0\uFF09",
|
|
1331
1339
|
"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",
|
|
1340
|
+
"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",
|
|
1332
1341
|
"stopTunnel": "\u5173\u95ED\u516C\u7F51",
|
|
1333
1342
|
"enable": "\u5F00\u542F\u516C\u7F51\u8BBF\u95EE",
|
|
1334
1343
|
"opening": "\u5F00\u542F\u4E2D\u2026",
|
|
@@ -1370,12 +1379,20 @@ var en2 = {
|
|
|
1370
1379
|
"on": "On",
|
|
1371
1380
|
"off": "Off",
|
|
1372
1381
|
"lanPinValue": "\u{1F510} PIN: {pin} (required on the phone; separate from the public PIN)",
|
|
1382
|
+
"lanPinCustomValue": "\u{1F510} PIN: {pin} (custom; required on the phone)",
|
|
1373
1383
|
"refresh": "Refresh",
|
|
1384
|
+
"customize": "Customize",
|
|
1385
|
+
"customizing": "New PIN (8 digits): ",
|
|
1386
|
+
"save": "Save",
|
|
1387
|
+
"cancel": "Cancel",
|
|
1388
|
+
"pinInvalid": "PIN must be exactly 8 digits",
|
|
1389
|
+
"pinCustomHint": "custom PINs are not rotated on tunnel start",
|
|
1374
1390
|
"lanPinOff": "\u{1F513} PIN off \u2014 scan & go, no PIN (LAN devices only; public still requires PIN)",
|
|
1375
1391
|
"lanStarting": "Proxy starting\u2026",
|
|
1376
1392
|
"wanTitle": "\u{1F310} Anywhere (public)",
|
|
1377
1393
|
"wanHint": "Scan from any network (the URL changes on every restart)",
|
|
1378
1394
|
"wanPin": "\u{1F510} PIN: {pin} (changes each time the tunnel is enabled; required on the phone)",
|
|
1395
|
+
"wanPinCustom": "\u{1F510} PIN: {pin} (custom \u2014 not rotated on tunnel start)",
|
|
1379
1396
|
"stopTunnel": "Stop",
|
|
1380
1397
|
"enable": "Enable anywhere",
|
|
1381
1398
|
"opening": "Enabling\u2026",
|
|
@@ -1551,6 +1568,44 @@ function PocketSettingsTab({ rpcCall, t }) {
|
|
|
1551
1568
|
} catch {
|
|
1552
1569
|
}
|
|
1553
1570
|
};
|
|
1571
|
+
const [customPin, setCustomPin] = (0, import_react2.useState)(null);
|
|
1572
|
+
const saveCustomPin = async (which) => {
|
|
1573
|
+
try {
|
|
1574
|
+
const r = await call(POCKET_ENDPOINTS.pinSetCustom, { which, value: customPin?.value ?? "" });
|
|
1575
|
+
setStatus((s) => ({
|
|
1576
|
+
...s,
|
|
1577
|
+
accessToken: which === "public" ? r.pin : s.accessToken,
|
|
1578
|
+
lanToken: which === "lan" ? r.pin : s.lanToken,
|
|
1579
|
+
publicPinCustom: which === "public" ? true : s.publicPinCustom,
|
|
1580
|
+
lanPinCustom: which === "lan" ? true : s.lanPinCustom
|
|
1581
|
+
}));
|
|
1582
|
+
setCustomPin(null);
|
|
1583
|
+
} catch (err) {
|
|
1584
|
+
setCustomPin((c) => ({ ...c, err: err.message }));
|
|
1585
|
+
}
|
|
1586
|
+
};
|
|
1587
|
+
const customPinRow = (which) => (0, import_react2.createElement)(
|
|
1588
|
+
"div",
|
|
1589
|
+
{ style: { marginTop: 6, fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)", lineHeight: 1.5 } },
|
|
1590
|
+
t("customizing"),
|
|
1591
|
+
(0, import_react2.createElement)("input", {
|
|
1592
|
+
style: { width: 110, margin: "0 6px", padding: "4px 8px", fontSize: 14, letterSpacing: 2, textAlign: "center", border: "1px solid var(--dsw-alias-border-l2,#d1d5db)", borderRadius: 6, outline: "none" },
|
|
1593
|
+
type: "password",
|
|
1594
|
+
inputMode: "numeric",
|
|
1595
|
+
maxLength: 8,
|
|
1596
|
+
value: customPin?.value ?? "",
|
|
1597
|
+
autoFocus: true,
|
|
1598
|
+
onChange: (e) => setCustomPin((c) => ({ ...c, value: e.target.value.replace(/\D/g, ""), err: null })),
|
|
1599
|
+
onKeyDown: (e) => {
|
|
1600
|
+
if (e.key === "Enter") saveCustomPin(which);
|
|
1601
|
+
if (e.key === "Escape") setCustomPin(null);
|
|
1602
|
+
}
|
|
1603
|
+
}),
|
|
1604
|
+
(0, import_react2.createElement)("button", { style: { ...styles.btn, height: 26, padding: "0 10px", fontSize: 12, marginLeft: 2 }, onClick: () => saveCustomPin(which) }, t("save")),
|
|
1605
|
+
(0, import_react2.createElement)("button", { style: { ...styles.btn, height: 26, padding: "0 10px", fontSize: 12 }, onClick: () => setCustomPin(null) }, t("cancel")),
|
|
1606
|
+
customPin?.err ? (0, import_react2.createElement)("div", { style: { color: "var(--dsw-alias-state-error-primary,#dc2626)", marginTop: 4 } }, customPin.err) : null
|
|
1607
|
+
);
|
|
1608
|
+
const customBtn = (which) => (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 26, padding: "0 10px", fontSize: 12, marginLeft: 8 }, onClick: () => setCustomPin({ which, value: "", err: null }) }, t("customize"));
|
|
1554
1609
|
const lanUrl = status?.lanUrl;
|
|
1555
1610
|
const tunnelUrl = status?.tunnelUrl;
|
|
1556
1611
|
const tunnelPhase = tunnelState?.phase ?? "idle";
|
|
@@ -1640,11 +1695,12 @@ function PocketSettingsTab({ rpcCall, t }) {
|
|
|
1640
1695
|
onClick: () => setLanAuth(false)
|
|
1641
1696
|
}, t("off"))
|
|
1642
1697
|
),
|
|
1643
|
-
status?.lanAuthEnabled !== false ? (0, import_react2.createElement)(
|
|
1698
|
+
status?.lanAuthEnabled !== false ? customPin?.which === "lan" ? customPinRow("lan") : (0, import_react2.createElement)(
|
|
1644
1699
|
"div",
|
|
1645
1700
|
{ style: { marginTop: 6, fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)", lineHeight: 1.5 } },
|
|
1646
|
-
fmt(t, "lanPinValue", { pin: status.lanToken }),
|
|
1647
|
-
(0, import_react2.createElement)("button", { style: { ...styles.btn, height: 26, padding: "0 10px", fontSize: 12, marginLeft: 8 }, onClick: refreshLanPin }, t("refresh"))
|
|
1701
|
+
fmt(t, status?.lanPinCustom ? "lanPinCustomValue" : "lanPinValue", { pin: status.lanToken }),
|
|
1702
|
+
(0, import_react2.createElement)("button", { style: { ...styles.btn, height: 26, padding: "0 10px", fontSize: 12, marginLeft: 8 }, onClick: refreshLanPin }, t("refresh")),
|
|
1703
|
+
customBtn("lan")
|
|
1648
1704
|
) : (0, import_react2.createElement)(
|
|
1649
1705
|
"div",
|
|
1650
1706
|
{ style: { marginTop: 6, fontSize: 12, color: "var(--dsw-alias-state-warn-primary,#b45309)", lineHeight: 1.5 } },
|
|
@@ -1663,10 +1719,12 @@ function PocketSettingsTab({ rpcCall, t }) {
|
|
|
1663
1719
|
(0, import_react2.createElement)("img", { src: status.tunnelQr, alt: "Tunnel QR", style: styles.qr }),
|
|
1664
1720
|
(0, import_react2.createElement)("div", { style: styles.code }, tunnelUrl),
|
|
1665
1721
|
(0, import_react2.createElement)("div", { style: styles.muted }, t("wanHint")),
|
|
1666
|
-
status.accessToken ? (0, import_react2.createElement)(
|
|
1722
|
+
status.accessToken ? customPin?.which === "public" ? customPinRow("public") : (0, import_react2.createElement)(
|
|
1667
1723
|
"div",
|
|
1668
1724
|
{ style: { marginTop: 6, fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)", lineHeight: 1.5 } },
|
|
1669
|
-
fmt(t, "wanPin", { pin: status.accessToken })
|
|
1725
|
+
fmt(t, status?.publicPinCustom ? "wanPinCustom" : "wanPin", { pin: status.accessToken }),
|
|
1726
|
+
customBtn("public"),
|
|
1727
|
+
status?.publicPinCustom ? (0, import_react2.createElement)("div", { style: { marginTop: 2, fontSize: 11, color: "var(--dsw-alias-state-warn-primary,#b45309)" } }, t("pinCustomHint")) : null
|
|
1670
1728
|
) : null,
|
|
1671
1729
|
(0, import_react2.createElement)("button", { style: styles.btn, onClick: stopTunnel }, t("stopTunnel"))
|
|
1672
1730
|
) : (0, import_react2.createElement)(
|
package/client/index.jsx
CHANGED
|
@@ -196,6 +196,44 @@ function PocketSettingsTab({ rpcCall, t }) {
|
|
|
196
196
|
} catch { /* 忽略 */ }
|
|
197
197
|
};
|
|
198
198
|
|
|
199
|
+
// 自定义访问密码(issue #33):公网/局域网各自设固定 8 位数字;自定义后公网不再自动轮换。
|
|
200
|
+
// customPin: { which: 'public'|'lan', value, err } | null —— 正在输入自定义密码的区块
|
|
201
|
+
const [customPin, setCustomPin] = useState(null);
|
|
202
|
+
const saveCustomPin = async (which) => {
|
|
203
|
+
try {
|
|
204
|
+
const r = await call(POCKET_ENDPOINTS.pinSetCustom, { which, value: customPin?.value ?? '' });
|
|
205
|
+
setStatus((s) => ({
|
|
206
|
+
...s,
|
|
207
|
+
accessToken: which === 'public' ? r.pin : s.accessToken,
|
|
208
|
+
lanToken: which === 'lan' ? r.pin : s.lanToken,
|
|
209
|
+
publicPinCustom: which === 'public' ? true : s.publicPinCustom,
|
|
210
|
+
lanPinCustom: which === 'lan' ? true : s.lanPinCustom,
|
|
211
|
+
}));
|
|
212
|
+
setCustomPin(null);
|
|
213
|
+
} catch (err) {
|
|
214
|
+
setCustomPin((c) => ({ ...c, err: err.message }));
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
// 渲染自定义输入行(共用):输入框 + 保存/取消
|
|
218
|
+
const customPinRow = (which) => h('div', { style: { marginTop: 6, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)', lineHeight: 1.5 } },
|
|
219
|
+
t('customizing'),
|
|
220
|
+
h('input', {
|
|
221
|
+
style: { width: 110, margin: '0 6px', padding: '4px 8px', fontSize: 14, letterSpacing: 2, textAlign: 'center', border: '1px solid var(--dsw-alias-border-l2,#d1d5db)', borderRadius: 6, outline: 'none' },
|
|
222
|
+
type: 'password',
|
|
223
|
+
inputMode: 'numeric',
|
|
224
|
+
maxLength: 8,
|
|
225
|
+
value: customPin?.value ?? '',
|
|
226
|
+
autoFocus: true,
|
|
227
|
+
onChange: (e) => setCustomPin((c) => ({ ...c, value: e.target.value.replace(/\D/g, ''), err: null })),
|
|
228
|
+
onKeyDown: (e) => { if (e.key === 'Enter') saveCustomPin(which); if (e.key === 'Escape') setCustomPin(null); },
|
|
229
|
+
}),
|
|
230
|
+
h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12, marginLeft: 2 }, onClick: () => saveCustomPin(which) }, t('save')),
|
|
231
|
+
h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12 }, onClick: () => setCustomPin(null) }, t('cancel')),
|
|
232
|
+
customPin?.err ? h('div', { style: { color: 'var(--dsw-alias-state-error-primary,#dc2626)', marginTop: 4 } }, customPin.err) : null,
|
|
233
|
+
);
|
|
234
|
+
// 「自定义」按钮(非输入态显示在密码行末尾)
|
|
235
|
+
const customBtn = (which) => h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12, marginLeft: 8 }, onClick: () => setCustomPin({ which, value: '', err: null }) }, t('customize'));
|
|
236
|
+
|
|
199
237
|
const lanUrl = status?.lanUrl;
|
|
200
238
|
const tunnelUrl = status?.tunnelUrl;
|
|
201
239
|
const tunnelPhase = tunnelState?.phase ?? 'idle';
|
|
@@ -277,10 +315,13 @@ function PocketSettingsTab({ rpcCall, t }) {
|
|
|
277
315
|
}, t('off')),
|
|
278
316
|
),
|
|
279
317
|
status?.lanAuthEnabled !== false
|
|
280
|
-
?
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
318
|
+
? (customPin?.which === 'lan'
|
|
319
|
+
? customPinRow('lan')
|
|
320
|
+
: h('div', { style: { marginTop: 6, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)', lineHeight: 1.5 } },
|
|
321
|
+
fmt(t, status?.lanPinCustom ? 'lanPinCustomValue' : 'lanPinValue', { pin: status.lanToken }),
|
|
322
|
+
h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12, marginLeft: 8 }, onClick: refreshLanPin }, t('refresh')),
|
|
323
|
+
customBtn('lan'),
|
|
324
|
+
))
|
|
284
325
|
: h('div', { style: { marginTop: 6, fontSize: 12, color: 'var(--dsw-alias-state-warn-primary,#b45309)', lineHeight: 1.5 } },
|
|
285
326
|
t('lanPinOff')),
|
|
286
327
|
)
|
|
@@ -296,8 +337,13 @@ function PocketSettingsTab({ rpcCall, t }) {
|
|
|
296
337
|
h('div', { style: styles.code }, tunnelUrl),
|
|
297
338
|
h('div', { style: styles.muted }, t('wanHint')),
|
|
298
339
|
status.accessToken
|
|
299
|
-
?
|
|
300
|
-
|
|
340
|
+
? (customPin?.which === 'public'
|
|
341
|
+
? customPinRow('public')
|
|
342
|
+
: h('div', { style: { marginTop: 6, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)', lineHeight: 1.5 } },
|
|
343
|
+
fmt(t, status?.publicPinCustom ? 'wanPinCustom' : 'wanPin', { pin: status.accessToken }),
|
|
344
|
+
customBtn('public'),
|
|
345
|
+
status?.publicPinCustom ? h('div', { style: { marginTop: 2, fontSize: 11, color: 'var(--dsw-alias-state-warn-primary,#b45309)' } }, t('pinCustomHint')) : null,
|
|
346
|
+
))
|
|
301
347
|
: null,
|
|
302
348
|
h('button', { style: styles.btn, onClick: stopTunnel }, t('stopTunnel')),
|
|
303
349
|
)
|
package/client/pocket-locales.js
CHANGED
|
@@ -34,12 +34,20 @@ export const zh = {
|
|
|
34
34
|
'on': '开',
|
|
35
35
|
'off': '关',
|
|
36
36
|
'lanPinValue': '🔐 访问密码:{pin}(手机打开需输入;与公网密码分开)',
|
|
37
|
+
'lanPinCustomValue': '🔐 访问密码:{pin}(自定义;手机打开需输入)',
|
|
37
38
|
'refresh': '刷新',
|
|
39
|
+
'customize': '自定义',
|
|
40
|
+
'customizing': '新密码(8 位数字):',
|
|
41
|
+
'save': '保存',
|
|
42
|
+
'cancel': '取消',
|
|
43
|
+
'pinInvalid': '密码必须是 8 位数字',
|
|
44
|
+
'pinCustomHint': '自定义后开启公网不再自动换新',
|
|
38
45
|
'lanPinOff': '🔓 密码已关闭:扫码直连,无需密码(仅同一局域网设备可访问;公网仍要密码)',
|
|
39
46
|
'lanStarting': '代理未就绪…',
|
|
40
47
|
'wanTitle': '🌐 公网(人在外面)',
|
|
41
48
|
'wanHint': '任何网络扫码即用(URL 每次重启自动换新)',
|
|
42
49
|
'wanPin': '🔐 访问密码:{pin}(每次开启公网变新;手机打开链接需输入此密码)',
|
|
50
|
+
'wanPinCustom': '🔐 访问密码:{pin}(自定义,开启公网不再自动换新)',
|
|
43
51
|
'stopTunnel': '关闭公网',
|
|
44
52
|
'enable': '开启公网访问',
|
|
45
53
|
'opening': '开启中…',
|
|
@@ -83,12 +91,20 @@ export const en = {
|
|
|
83
91
|
'on': 'On',
|
|
84
92
|
'off': 'Off',
|
|
85
93
|
'lanPinValue': '🔐 PIN: {pin} (required on the phone; separate from the public PIN)',
|
|
94
|
+
'lanPinCustomValue': '🔐 PIN: {pin} (custom; required on the phone)',
|
|
86
95
|
'refresh': 'Refresh',
|
|
96
|
+
'customize': 'Customize',
|
|
97
|
+
'customizing': 'New PIN (8 digits): ',
|
|
98
|
+
'save': 'Save',
|
|
99
|
+
'cancel': 'Cancel',
|
|
100
|
+
'pinInvalid': 'PIN must be exactly 8 digits',
|
|
101
|
+
'pinCustomHint': 'custom PINs are not rotated on tunnel start',
|
|
87
102
|
'lanPinOff': '🔓 PIN off — scan & go, no PIN (LAN devices only; public still requires PIN)',
|
|
88
103
|
'lanStarting': 'Proxy starting…',
|
|
89
104
|
'wanTitle': '🌐 Anywhere (public)',
|
|
90
105
|
'wanHint': 'Scan from any network (the URL changes on every restart)',
|
|
91
106
|
'wanPin': '🔐 PIN: {pin} (changes each time the tunnel is enabled; required on the phone)',
|
|
107
|
+
'wanPinCustom': '🔐 PIN: {pin} (custom — not rotated on tunnel start)',
|
|
92
108
|
'stopTunnel': 'Stop',
|
|
93
109
|
'enable': 'Enable anywhere',
|
|
94
110
|
'opening': 'Enabling…',
|
package/lib/index.js
CHANGED
|
@@ -15,12 +15,13 @@ 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';
|
|
18
|
+
import { randomBytes } from 'node:crypto';
|
|
18
19
|
|
|
19
20
|
import { createPocketService } from './service.mjs';
|
|
20
21
|
import { installPocketRpc } from './web-rpc.js';
|
|
21
22
|
import { restartHost } from './restart.js';
|
|
22
23
|
import { desktopEnvPatchScript, advancedNoticeScript, DEFAULT_INJECT } from './proxy.mjs';
|
|
23
|
-
import { lanAuthEnabled, setLanAuthEnabled } from './settings.mjs';
|
|
24
|
+
import { lanAuthEnabled, setLanAuthEnabled, pinCustom, setPinCustom } from './settings.mjs';
|
|
24
25
|
|
|
25
26
|
const name = 'dsh-pocket';
|
|
26
27
|
const inject = ['connection', 'webServer'];
|
|
@@ -42,10 +43,13 @@ function currentVersion() {
|
|
|
42
43
|
/** 进程启动时加载的版本(模块加载瞬间固化;用于识别「磁盘已更新但进程还是旧代码」)。 */
|
|
43
44
|
const loadedVersion = currentVersion();
|
|
44
45
|
|
|
45
|
-
// ---------- 访问密码(issue #13 + #18) ----------
|
|
46
|
+
// ---------- 访问密码(issue #13 + #18 + #33) ----------
|
|
46
47
|
// 公网与局域网**分开**:各自 8 位数字,存本机 $DSH_HOME/dsh-pocket/。
|
|
47
|
-
// 公网密码(token
|
|
48
|
-
// 局域网密码(token-lan
|
|
48
|
+
// 公网密码(token):默认每次开启公网时轮换(旧链接作废);**用户自定义后不再轮换**;
|
|
49
|
+
// 局域网密码(token-lan):默认手动刷新(设置页按钮);自定义后刷新会换回随机值。
|
|
50
|
+
// 会话保持(issue #33):登录 cookie 绑定进程级 sessionKey(见 apply)——
|
|
51
|
+
// dsh web 重启/更新后 sessionKey 变化 → 手机需重新输入。
|
|
52
|
+
const PIN_RE = /^\d{8}$/;
|
|
49
53
|
function writePinToFile(p, fresh) {
|
|
50
54
|
try {
|
|
51
55
|
mkdirSync(dirname(p), { recursive: true });
|
|
@@ -56,7 +60,7 @@ function writePinToFile(p, fresh) {
|
|
|
56
60
|
function readPin(p) {
|
|
57
61
|
try {
|
|
58
62
|
const existing = readFileSync(p, 'utf8').trim();
|
|
59
|
-
if (
|
|
63
|
+
if (PIN_RE.test(existing)) return existing;
|
|
60
64
|
} catch { /* 无文件 */ }
|
|
61
65
|
return null;
|
|
62
66
|
}
|
|
@@ -70,11 +74,12 @@ function tokenPath() {
|
|
|
70
74
|
return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), tokenRel);
|
|
71
75
|
}
|
|
72
76
|
/** 当前公网访问密码;文件不存在时生成一个。 */
|
|
73
|
-
function getAccessToken() {
|
|
77
|
+
export function getAccessToken() {
|
|
74
78
|
return readPin(tokenPath()) ?? writePinToFile(tokenPath(), newPin());
|
|
75
79
|
}
|
|
76
|
-
/**
|
|
77
|
-
function rotateAccessToken() {
|
|
80
|
+
/** 轮换公网密码(开启公网时调用)。用户自定义后不再轮换(尊重自定义值)。 */
|
|
81
|
+
export function rotateAccessToken() {
|
|
82
|
+
if (pinCustom('public')) return getAccessToken();
|
|
78
83
|
return writePinToFile(tokenPath(), newPin());
|
|
79
84
|
}
|
|
80
85
|
|
|
@@ -87,14 +92,35 @@ function lanTokenPath() {
|
|
|
87
92
|
function getLanToken() {
|
|
88
93
|
return readPin(lanTokenPath()) ?? writePinToFile(lanTokenPath(), newPin());
|
|
89
94
|
}
|
|
90
|
-
/**
|
|
95
|
+
/** 刷新局域网密码(设置页按钮触发,旧密码立即作废);换新后清除自定义标记。 */
|
|
91
96
|
function refreshLanToken() {
|
|
97
|
+
setPinCustom('lan', false);
|
|
92
98
|
return writePinToFile(lanTokenPath(), newPin());
|
|
93
99
|
}
|
|
94
100
|
/** 按 Host 分发的访问密码:公网隧道用公网密码,局域网用局域网密码。 */
|
|
95
101
|
function tokenForHost(host) {
|
|
96
102
|
return /trycloudflare\.com$/i.test(String(host ?? '')) ? getAccessToken() : getLanToken();
|
|
97
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* 用户自定义访问密码(issue #33):公网/局域网各自设一个固定的 8 位数字。
|
|
106
|
+
* 自定义后公网开启时不再自动轮换(rotateAccessToken 见上)。
|
|
107
|
+
* 非法输入(非 8 位数字)抛错,由 RPC 层转成错误响应。
|
|
108
|
+
*/
|
|
109
|
+
export function setCustomPin(which, value) {
|
|
110
|
+
const v = String(value ?? '').trim();
|
|
111
|
+
if (!PIN_RE.test(v)) throw new Error('密码必须是 8 位数字 | PIN must be exactly 8 digits');
|
|
112
|
+
if (which === 'public') {
|
|
113
|
+
writePinToFile(tokenPath(), v);
|
|
114
|
+
setPinCustom('public', true);
|
|
115
|
+
return v;
|
|
116
|
+
}
|
|
117
|
+
if (which === 'lan') {
|
|
118
|
+
writePinToFile(lanTokenPath(), v);
|
|
119
|
+
setPinCustom('lan', true);
|
|
120
|
+
return v;
|
|
121
|
+
}
|
|
122
|
+
throw new Error('未知密码类型 | unknown PIN kind');
|
|
123
|
+
}
|
|
98
124
|
|
|
99
125
|
const restartNoticeRel = join('dsh-pocket', 'restarted.json');
|
|
100
126
|
function restartNoticePath() {
|
|
@@ -203,16 +229,19 @@ export function apply(ctx, config = {}, internals = {}) {
|
|
|
203
229
|
injectHtml: isDesktop
|
|
204
230
|
? DEFAULT_INJECT + desktopEnvPatchScript(process.platform) + (desktopAdvanced ? advancedNoticeScript() : '')
|
|
205
231
|
: undefined,
|
|
206
|
-
// 访问密码(issue #13 + #18 + #24
|
|
207
|
-
//
|
|
232
|
+
// 访问密码(issue #13 + #18 + #24 + #33):公网永远要密码(默认每次开启变新,
|
|
233
|
+
// 用户自定义后不再轮换);局域网按开关(默认开启;关闭后局域网扫码直连)。
|
|
208
234
|
auth: {
|
|
235
|
+
// sessionKey:进程级随机密钥——登录 cookie 绑定它(会话保持,issue #33):
|
|
236
|
+
// dsh web 重启/更新后 sessionKey 变化 → 手机需重新输入
|
|
237
|
+
sessionKey: randomBytes(16).toString('hex'),
|
|
209
238
|
getToken: (host) => tokenForHost(host),
|
|
210
239
|
isProtected: (host) => /trycloudflare\.com$/i.test(String(host ?? '')) ? true : lanAuthEnabled(),
|
|
211
240
|
},
|
|
212
|
-
// 每次公网隧道就绪 → 轮换 8
|
|
241
|
+
// 每次公网隧道就绪 → 轮换 8 位密码(旧密码/旧链接立即作废;用户自定义后不轮换)
|
|
213
242
|
onTunnelReady: () => {
|
|
214
243
|
const fresh = rotateAccessToken();
|
|
215
|
-
logger.info('dsh-pocket: public access PIN
|
|
244
|
+
logger.info('dsh-pocket: public access PIN refreshed | 公网访问密码已更新(自定义密码不受影响)');
|
|
216
245
|
return fresh;
|
|
217
246
|
},
|
|
218
247
|
});
|
|
@@ -226,6 +255,8 @@ export function apply(ctx, config = {}, internals = {}) {
|
|
|
226
255
|
refreshLanToken: () => refreshLanToken(),
|
|
227
256
|
getLanAuthEnabled: () => lanAuthEnabled(),
|
|
228
257
|
setLanAuthEnabled: (on) => setLanAuthEnabled(on),
|
|
258
|
+
getPinCustom: (which) => pinCustom(which),
|
|
259
|
+
setCustomPin: (which, value) => setCustomPin(which, value),
|
|
229
260
|
runUpdate: internals.runUpdate ?? { currentVersion, perform: performUpdate, loadedVersion: () => loadedVersion },
|
|
230
261
|
restart: internals.restart ?? (() => pocketRestart(service)),
|
|
231
262
|
restartNotice: internals.restartNotice ?? consumeRestartNotice,
|
package/lib/proxy.mjs
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
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
|
+
import { createHash } from 'node:crypto';
|
|
17
18
|
|
|
18
19
|
const DEFAULT_UPSTREAM = { host: '127.0.0.1', port: 3080 };
|
|
19
20
|
|
|
@@ -62,10 +63,82 @@ export function advancedNoticeScript() {
|
|
|
62
63
|
return `<script data-dsh-pocket-advanced-notice="1">!function(){try{var d=document.createElement('div');d.style.cssText='position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.55);color:#fff;font:15px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;text-align:center;padding:24px';d.textContent='DSH 桌面端处于 advanced 模式,手机访问暂不支持。请在桌面端设置中切回 compatibility 模式后重启。| DSH Desktop is in advanced mode — phone access is not supported yet. Switch back to compatibility in the desktop app and restart.';document.documentElement.appendChild(d);}catch(e){}}();</script>`;
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
// ---------- 可选访问令牌认证(issue #13) ----------
|
|
66
|
-
//
|
|
67
|
-
// 登录成功后种 HttpOnly
|
|
66
|
+
// ---------- 可选访问令牌认证(issue #13 + #33) ----------
|
|
67
|
+
// 只对受保护 Host(公网隧道 + 局域网按开关)强制。
|
|
68
|
+
// 登录成功后种 HttpOnly 持久 cookie(Max-Age 30 天)→ SPA 内部 API/WS 自动携带。
|
|
69
|
+
// 会话保持(issue #33):cookie 值 = sha256(PIN:sessionKey)——sessionKey 是 dsh web
|
|
70
|
+
// 进程级随机密钥(lib/index.js 每次启动生成)。于是:
|
|
71
|
+
// - 电脑 dsh web 一直开着 → 手机输一次密码后长期免输(持久 cookie)
|
|
72
|
+
// - dsh web 重启/更新 → sessionKey 变化 → 旧 cookie 失效 → 手机重新输入
|
|
68
73
|
const TOKEN_COOKIE = 'dsh_pocket_token';
|
|
74
|
+
const COOKIE_MAX_AGE = 30 * 24 * 60 * 60; // 30 天(秒)
|
|
75
|
+
|
|
76
|
+
/** cookie 校验值:有 sessionKey 时派生,无则退化为 PIN 本身(向后兼容)。 */
|
|
77
|
+
function cookieFor(token, sessionKey) {
|
|
78
|
+
if (!sessionKey) return token;
|
|
79
|
+
return createHash('sha256').update(`${token}:${sessionKey}`).digest('hex');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------- 登录速率限制(issue #40,改进版方案 A) ----------
|
|
83
|
+
// 8 位数字密码(10^8 组合)本身可接受,真正风险是「无限制重试」让穷举可行。
|
|
84
|
+
// 这里做三层防护(内存态,随进程生命周期,与 sessionKey 一致):
|
|
85
|
+
// 1) 单 IP 滑动窗口:60 秒内失败 ≥5 次 → 锁 60 秒(429)
|
|
86
|
+
// 2) 全局滑动窗口:1 分钟全局失败 > 50 次 → 全局锁 30 秒(防分布式扫描换 IP 绕过)
|
|
87
|
+
// 3) 成功登录清空该 IP 计数
|
|
88
|
+
// IP 识别:优先 cf-connecting-ip(Cloudflare 在隧道入口设置的**真实**客户端 IP,
|
|
89
|
+
// 可信);无则回退 socket remoteAddress。**不信任客户端 x-forwarded-for**(可伪造)。
|
|
90
|
+
export const DEFAULT_RATE_LIMIT = {
|
|
91
|
+
windowMs: 60_000, // 失败计数滑动窗口
|
|
92
|
+
maxFailures: 5, // 窗口内失败阈值 → 触发单 IP 锁
|
|
93
|
+
lockMs: 60_000, // 单 IP 锁定时长
|
|
94
|
+
globalMaxFailures: 50, // 全局失败阈值(同窗口)→ 触发全局锁
|
|
95
|
+
globalLockMs: 30_000, // 全局锁定时长
|
|
96
|
+
};
|
|
97
|
+
function createRateLimiter(cfg = {}) {
|
|
98
|
+
const c = { ...DEFAULT_RATE_LIMIT, ...cfg };
|
|
99
|
+
const failCounts = new Map(); // ip -> { count, windowStart }
|
|
100
|
+
const ipLocks = new Map(); // ip -> lockedUntil
|
|
101
|
+
const global = { count: 0, windowStart: 0, lockedUntil: 0 };
|
|
102
|
+
return {
|
|
103
|
+
/** 该 IP 当前是否被锁;返回 { locked, retryAfter }。 */
|
|
104
|
+
status(ip) {
|
|
105
|
+
const now = Date.now();
|
|
106
|
+
if (global.lockedUntil > now) return { locked: true, retryAfter: Math.ceil((global.lockedUntil - now) / 1000) };
|
|
107
|
+
const until = ipLocks.get(ip) ?? 0;
|
|
108
|
+
if (until > now) return { locked: true, retryAfter: Math.ceil((until - now) / 1000) };
|
|
109
|
+
return { locked: false, retryAfter: 0 };
|
|
110
|
+
},
|
|
111
|
+
/** 记一次失败:维护滑动窗口计数,达阈值触发单 IP / 全局锁。 */
|
|
112
|
+
record(ip) {
|
|
113
|
+
const now = Date.now();
|
|
114
|
+
let rec = failCounts.get(ip);
|
|
115
|
+
if (!rec || now - rec.windowStart > c.windowMs) rec = { count: 0, windowStart: now };
|
|
116
|
+
rec.count++;
|
|
117
|
+
failCounts.set(ip, rec);
|
|
118
|
+
if (now - global.windowStart > c.windowMs) { global.count = 0; global.windowStart = now; }
|
|
119
|
+
global.count++;
|
|
120
|
+
if (rec.count >= c.maxFailures) ipLocks.set(ip, now + c.lockMs);
|
|
121
|
+
if (global.count >= c.globalMaxFailures) global.lockedUntil = now + c.globalLockMs;
|
|
122
|
+
// 防内存膨胀:超过 2000 条记录时清掉已过窗口期的条目
|
|
123
|
+
if (failCounts.size > 2000) {
|
|
124
|
+
for (const [k, v] of failCounts) {
|
|
125
|
+
if (now - v.windowStart > c.windowMs) failCounts.delete(k);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
/** 成功登录:清空该 IP 计数与锁。 */
|
|
130
|
+
clear(ip) {
|
|
131
|
+
failCounts.delete(ip);
|
|
132
|
+
ipLocks.delete(ip);
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/** 客户端真实 IP:cf-connecting-ip(隧道,可信)优先,否则 socket 地址;不信 XFF。 */
|
|
137
|
+
function clientIp(req) {
|
|
138
|
+
const cf = String(req.headers['cf-connecting-ip'] ?? '').trim();
|
|
139
|
+
if (cf) return cf;
|
|
140
|
+
return String(req.socket?.remoteAddress ?? 'unknown');
|
|
141
|
+
}
|
|
69
142
|
|
|
70
143
|
function parseCookies(header) {
|
|
71
144
|
const out = {};
|
|
@@ -76,10 +149,13 @@ function parseCookies(header) {
|
|
|
76
149
|
return out;
|
|
77
150
|
}
|
|
78
151
|
|
|
79
|
-
/** 登录页:按访问来源显示提示(局域网 /
|
|
80
|
-
function loginPageHtml(error, isPublic) {
|
|
152
|
+
/** 登录页:按访问来源显示提示(局域网 / 公网);error: false|true|'locked'(locked 带剩余秒数)。 */
|
|
153
|
+
function loginPageHtml(error, isPublic, retryAfter = 0) {
|
|
81
154
|
const where = isPublic ? '此公网地址' : '此局域网地址';
|
|
82
155
|
const whereEn = isPublic ? 'This public address' : 'This LAN address';
|
|
156
|
+
const errMsg = error === 'locked'
|
|
157
|
+
? `尝试次数过多,请 ${retryAfter} 秒后再试 | Too many attempts — try again in ${retryAfter}s`
|
|
158
|
+
: error ? '密码错误,请重试 | Wrong PIN, try again' : '';
|
|
83
159
|
return `<!doctype html><html lang="zh"><head><meta charset="utf-8">
|
|
84
160
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
85
161
|
<title>DSH Pocket · 访问验证</title>
|
|
@@ -95,7 +171,7 @@ button{width:100%;padding:10px;font-size:15px;background:#4f6ef7;color:#fff;bord
|
|
|
95
171
|
</style></head><body><div class="card">
|
|
96
172
|
<h1>🔐 DSH Pocket</h1>
|
|
97
173
|
<p>${where}受访问密码保护,请输入 8 位数字密码 | ${whereEn} is password-protected — enter the 8-digit PIN</p>
|
|
98
|
-
<div class="err">${
|
|
174
|
+
<div class="err">${errMsg}</div>
|
|
99
175
|
<form method="post" action="/pocket-login">
|
|
100
176
|
<input name="token" type="password" inputmode="numeric" maxlength="8" autocomplete="one-time-code" autofocus required>
|
|
101
177
|
<button type="submit">进入 | Enter</button>
|
|
@@ -115,12 +191,13 @@ function isHtmlRequest(req) {
|
|
|
115
191
|
}
|
|
116
192
|
|
|
117
193
|
/** 校验请求是否已认证;返回 true 放行。 */
|
|
118
|
-
function authCheck(req, token) {
|
|
194
|
+
function authCheck(req, token, sessionKey) {
|
|
119
195
|
if (!token) return true; // 无 token(未配置密码)→ 放行
|
|
196
|
+
const expected = cookieFor(token, sessionKey);
|
|
120
197
|
const cookieTok = parseCookies(req.headers.cookie)[TOKEN_COOKIE];
|
|
121
|
-
if (cookieTok ===
|
|
198
|
+
if (cookieTok === expected) return true;
|
|
122
199
|
const qTok = new URL(req.url ?? '/', 'http://x').searchParams.get('token');
|
|
123
|
-
return qTok ===
|
|
200
|
+
return qTok === expected;
|
|
124
201
|
}
|
|
125
202
|
|
|
126
203
|
/** 把浏览器可见的权威改写成 loopback 权威(Host 和 Origin 都改)。 */
|
|
@@ -139,42 +216,61 @@ function loopbackAuthority(headers, upstream) {
|
|
|
139
216
|
* @param {string} [opts.host] 监听地址(默认 0.0.0.0:LAN 与隧道都能到)
|
|
140
217
|
* @param {{host:string,port:number}} [opts.upstream] 上游 dsh web(默认 127.0.0.1:3080)
|
|
141
218
|
* @param {string} [opts.injectHtml] 注入 HTML 的内容(默认 polyfill + 移动端适配;传 '' 关闭)
|
|
142
|
-
* @param {object} [opts.auth] 可选访问令牌认证(issue #13):{ getToken, isProtected }
|
|
219
|
+
* @param {object} [opts.auth] 可选访问令牌认证(issue #13):{ getToken, isProtected, sessionKey }
|
|
220
|
+
* @param {object} [opts.rateLimit] 登录速率限制参数覆盖(issue #40;测试用短窗口)
|
|
143
221
|
* @returns {Promise<{server:import('node:http').Server, close:()=>Promise<void>}>}
|
|
144
222
|
*/
|
|
145
|
-
export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DEFAULT_UPSTREAM, log = null, injectHtml = DEFAULT_INJECT, auth = null } = {}) {
|
|
223
|
+
export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DEFAULT_UPSTREAM, log = null, injectHtml = DEFAULT_INJECT, auth = null, rateLimit = null } = {}) {
|
|
224
|
+
const limiter = auth ? createRateLimiter(rateLimit ?? {}) : null;
|
|
146
225
|
const server = createServer((req, res) => {
|
|
147
|
-
// 访问令牌认证(issue #13 + #18
|
|
226
|
+
// 访问令牌认证(issue #13 + #18 + #33 + #40):局域网与公网按开关/来源要求密码
|
|
148
227
|
if (auth) {
|
|
149
228
|
const host = String(req.headers.host ?? '');
|
|
150
229
|
const isPublic = /trycloudflare\.com$/i.test(host);
|
|
151
230
|
const protectedHost = isProtectedHost(host, auth.isProtected);
|
|
152
231
|
const token = protectedHost ? (auth.getToken?.(host) ?? null) : null;
|
|
232
|
+
const sessionKey = auth.sessionKey ?? null;
|
|
153
233
|
if (protectedHost && token) {
|
|
154
|
-
|
|
234
|
+
const ip = clientIp(req);
|
|
235
|
+
// 登录提交:速率限制(issue #40)→ 校验密码 → 种持久 HttpOnly cookie(30 天,绑定进程会话密钥)→ 回首页
|
|
155
236
|
if (req.method === 'POST' && req.url?.startsWith('/pocket-login')) {
|
|
237
|
+
const rl = limiter?.status(ip) ?? { locked: false, retryAfter: 0 };
|
|
238
|
+
if (rl.locked) {
|
|
239
|
+
res.writeHead(429, {
|
|
240
|
+
'content-type': 'text/html; charset=utf-8',
|
|
241
|
+
'cache-control': 'no-store',
|
|
242
|
+
'retry-after': String(rl.retryAfter),
|
|
243
|
+
});
|
|
244
|
+
res.end(loginPageHtml('locked', isPublic, rl.retryAfter));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
156
247
|
let body = '';
|
|
157
248
|
req.on('data', (c) => { body += c; if (body.length > 1024) req.destroy(); });
|
|
158
249
|
req.on('end', () => {
|
|
159
250
|
const submitted = String(new URLSearchParams(body).get('token') ?? '');
|
|
160
251
|
if (submitted === token) {
|
|
252
|
+
limiter?.clear(ip);
|
|
161
253
|
res.writeHead(302, {
|
|
162
254
|
location: '/',
|
|
163
|
-
'set-cookie': `${TOKEN_COOKIE}=${token}; HttpOnly; SameSite=Lax; Path
|
|
255
|
+
'set-cookie': `${TOKEN_COOKIE}=${cookieFor(token, sessionKey)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${COOKIE_MAX_AGE}`,
|
|
164
256
|
'cache-control': 'no-store',
|
|
165
257
|
});
|
|
166
258
|
res.end();
|
|
167
259
|
} else {
|
|
260
|
+
limiter?.record(ip);
|
|
261
|
+
log?.(`dsh-pocket: login failed from ${ip} | 登录失败 IP: ${ip}`);
|
|
168
262
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
169
263
|
res.end(loginPageHtml(true, isPublic));
|
|
170
264
|
}
|
|
171
265
|
});
|
|
172
266
|
return;
|
|
173
267
|
}
|
|
174
|
-
if (!authCheck(req, token)) {
|
|
268
|
+
if (!authCheck(req, token, sessionKey)) {
|
|
175
269
|
if (isHtmlRequest(req)) {
|
|
270
|
+
// 锁定期间打开登录页也给提示(HTTP 200 + 锁定文案;429 语义留给 POST 拒绝)
|
|
271
|
+
const rl = limiter?.status(ip) ?? { locked: false, retryAfter: 0 };
|
|
176
272
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
177
|
-
res.end(loginPageHtml(false, isPublic));
|
|
273
|
+
res.end(loginPageHtml(rl.locked ? 'locked' : false, isPublic, rl.retryAfter));
|
|
178
274
|
} else {
|
|
179
275
|
res.writeHead(401, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
180
276
|
res.end('{"error":"unauthorized"}');
|
|
@@ -267,7 +363,7 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
|
|
|
267
363
|
if (auth) {
|
|
268
364
|
const host = String(req.headers.host ?? '');
|
|
269
365
|
const token = isProtectedHost(host, auth.isProtected) ? (auth.getToken?.(host) ?? null) : null;
|
|
270
|
-
if (token && !authCheck(req, token)) {
|
|
366
|
+
if (token && !authCheck(req, token, auth.sessionKey ?? null)) {
|
|
271
367
|
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
|
272
368
|
socket.destroy();
|
|
273
369
|
return;
|
package/lib/settings.mjs
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// dsh-pocket 设置持久化($DSH_HOME/dsh-pocket/settings.json)
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// 当前项:
|
|
4
|
+
// - lanAuthEnabled 局域网访问密码开关(issue #24),默认开启
|
|
5
|
+
// - publicPinCustom 公网密码是否用户自定义(issue #33),自定义后不自动轮换
|
|
6
|
+
// - lanPinCustom 局域网密码是否用户自定义(issue #33)
|
|
4
7
|
// 默认**开启**(安全优先):局域网扫码也要输 8 位密码;
|
|
5
8
|
// 用户可关闭——关闭后局域网扫码直连(仅同一网络内的设备能访问),公网不受影响(永远要密码)。
|
|
6
9
|
|
|
@@ -41,3 +44,25 @@ export function setLanAuthEnabled(on) {
|
|
|
41
44
|
writeSettings(s);
|
|
42
45
|
return s.lanAuthEnabled;
|
|
43
46
|
}
|
|
47
|
+
|
|
48
|
+
// ---------- 访问密码「自定义」标记(issue #33) ----------
|
|
49
|
+
// 用户可把公网/局域网密码设成自己固定的 8 位数字(自定义后不再自动轮换)。
|
|
50
|
+
// 标记存 settings.json:publicPinCustom / lanPinCustom。
|
|
51
|
+
const PIN_CUSTOM_KEYS = { public: 'publicPinCustom', lan: 'lanPinCustom' };
|
|
52
|
+
|
|
53
|
+
/** 该 PIN(public | lan)是否用户自定义过(自定义后不自动轮换)。 */
|
|
54
|
+
export function pinCustom(which) {
|
|
55
|
+
const key = PIN_CUSTOM_KEYS[which];
|
|
56
|
+
if (!key) return false;
|
|
57
|
+
return readSettings()[key] === true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 设置自定义标记,返回新状态。 */
|
|
61
|
+
export function setPinCustom(which, on) {
|
|
62
|
+
const key = PIN_CUSTOM_KEYS[which];
|
|
63
|
+
if (!key) return false;
|
|
64
|
+
const s = readSettings();
|
|
65
|
+
s[key] = !!on;
|
|
66
|
+
writeSettings(s);
|
|
67
|
+
return !!on;
|
|
68
|
+
}
|
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, getToken = null, getLanToken = null, refreshLanToken = null, getLanAuthEnabled = null, setLanAuthEnabled = null }) {
|
|
28
|
+
export function installPocketRpc(ctx, { service, log = console, desktop = false, runUpdate = null, restart = null, restartNotice = null, getToken = null, getLanToken = null, refreshLanToken = null, getLanAuthEnabled = null, setLanAuthEnabled = null, getPinCustom = null, setCustomPin = 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 () => {};
|
|
@@ -46,6 +46,8 @@ export function installPocketRpc(ctx, { service, log = console, desktop = false,
|
|
|
46
46
|
accessToken: getToken?.() ?? null,
|
|
47
47
|
lanToken: getLanToken?.() ?? null,
|
|
48
48
|
lanAuthEnabled: getLanAuthEnabled?.() ?? true,
|
|
49
|
+
publicPinCustom: getPinCustom?.('public') ?? false,
|
|
50
|
+
lanPinCustom: getPinCustom?.('lan') ?? false,
|
|
49
51
|
});
|
|
50
52
|
};
|
|
51
53
|
|
|
@@ -63,6 +65,17 @@ export function installPocketRpc(ctx, { service, log = console, desktop = false,
|
|
|
63
65
|
if (enabled === undefined) return fail('bad-request', '局域网密码开关不可用 | LAN PIN switch unavailable');
|
|
64
66
|
return ok({ lanAuthEnabled: enabled });
|
|
65
67
|
}
|
|
68
|
+
if (endpoint === POCKET_ENDPOINTS.pinSetCustom) {
|
|
69
|
+
const which = payload?.which === 'public' || payload?.which === 'lan' ? payload.which : null;
|
|
70
|
+
if (!which) return fail('bad-request', '未知密码类型 | unknown PIN kind');
|
|
71
|
+
try {
|
|
72
|
+
const pin = setCustomPin?.(which, payload?.value);
|
|
73
|
+
if (pin === undefined) return fail('bad-request', '自定义密码不可用 | custom PIN unavailable');
|
|
74
|
+
return ok({ which, pin, custom: true });
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return fail('bad-request', err?.message ?? String(err));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
66
79
|
if (endpoint === POCKET_ENDPOINTS.tunnelStart) {
|
|
67
80
|
await service.startTunnel();
|
|
68
81
|
return await statusPayload();
|
package/package.json
CHANGED