dsh-pocket 1.10.0 → 1.11.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/README.en.md +10 -5
- package/README.md +10 -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 +25 -11
- 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,26 @@ 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**.
|
|
92
97
|
- The public URL is randomly assigned by cloudflared and **changes on every restart** (old links die automatically — a natural key rotation).
|
|
93
98
|
- 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
|
|
99
|
+
- 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
100
|
|
|
96
101
|
## 💻 DSH Desktop
|
|
97
102
|
|
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,26 @@ 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 一直开着就不用重复输入;重启/更新后需重新输入一次**
|
|
92
97
|
- 公网 URL 由 cloudflared 随机分配,**每次重启会变化**(旧链接自动失效,相当于天然轮换)
|
|
93
98
|
- 局域网模式不暴露公网,只有同一网络内的设备能访问
|
|
94
|
-
- 适合个人自用;公网密码存本机 `$DSH_HOME/dsh-pocket/token
|
|
99
|
+
- 适合个人自用;公网密码存本机 `$DSH_HOME/dsh-pocket/token`(默认每次开启公网自动换新,**自定义后不换**),局域网密码存 `$DSH_HOME/dsh-pocket/token-lan`(设置页手动刷新),开关/自定义标记存 `$DSH_HOME/dsh-pocket/settings.json`
|
|
95
100
|
|
|
96
101
|
## 💻 DSH Desktop(桌面版)
|
|
97
102
|
|
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,21 @@ 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
|
+
}
|
|
69
81
|
|
|
70
82
|
function parseCookies(header) {
|
|
71
83
|
const out = {};
|
|
@@ -115,12 +127,13 @@ function isHtmlRequest(req) {
|
|
|
115
127
|
}
|
|
116
128
|
|
|
117
129
|
/** 校验请求是否已认证;返回 true 放行。 */
|
|
118
|
-
function authCheck(req, token) {
|
|
130
|
+
function authCheck(req, token, sessionKey) {
|
|
119
131
|
if (!token) return true; // 无 token(未配置密码)→ 放行
|
|
132
|
+
const expected = cookieFor(token, sessionKey);
|
|
120
133
|
const cookieTok = parseCookies(req.headers.cookie)[TOKEN_COOKIE];
|
|
121
|
-
if (cookieTok ===
|
|
134
|
+
if (cookieTok === expected) return true;
|
|
122
135
|
const qTok = new URL(req.url ?? '/', 'http://x').searchParams.get('token');
|
|
123
|
-
return qTok ===
|
|
136
|
+
return qTok === expected;
|
|
124
137
|
}
|
|
125
138
|
|
|
126
139
|
/** 把浏览器可见的权威改写成 loopback 权威(Host 和 Origin 都改)。 */
|
|
@@ -144,14 +157,15 @@ function loopbackAuthority(headers, upstream) {
|
|
|
144
157
|
*/
|
|
145
158
|
export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DEFAULT_UPSTREAM, log = null, injectHtml = DEFAULT_INJECT, auth = null } = {}) {
|
|
146
159
|
const server = createServer((req, res) => {
|
|
147
|
-
// 访问令牌认证(issue #13 + #18
|
|
160
|
+
// 访问令牌认证(issue #13 + #18 + #33):局域网与公网按开关/来源要求密码
|
|
148
161
|
if (auth) {
|
|
149
162
|
const host = String(req.headers.host ?? '');
|
|
150
163
|
const isPublic = /trycloudflare\.com$/i.test(host);
|
|
151
164
|
const protectedHost = isProtectedHost(host, auth.isProtected);
|
|
152
165
|
const token = protectedHost ? (auth.getToken?.(host) ?? null) : null;
|
|
166
|
+
const sessionKey = auth.sessionKey ?? null;
|
|
153
167
|
if (protectedHost && token) {
|
|
154
|
-
// 登录提交:校验密码 →
|
|
168
|
+
// 登录提交:校验密码 → 种持久 HttpOnly cookie(30 天,绑定进程会话密钥)→ 回首页
|
|
155
169
|
if (req.method === 'POST' && req.url?.startsWith('/pocket-login')) {
|
|
156
170
|
let body = '';
|
|
157
171
|
req.on('data', (c) => { body += c; if (body.length > 1024) req.destroy(); });
|
|
@@ -160,7 +174,7 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
|
|
|
160
174
|
if (submitted === token) {
|
|
161
175
|
res.writeHead(302, {
|
|
162
176
|
location: '/',
|
|
163
|
-
'set-cookie': `${TOKEN_COOKIE}=${token}; HttpOnly; SameSite=Lax; Path
|
|
177
|
+
'set-cookie': `${TOKEN_COOKIE}=${cookieFor(token, sessionKey)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${COOKIE_MAX_AGE}`,
|
|
164
178
|
'cache-control': 'no-store',
|
|
165
179
|
});
|
|
166
180
|
res.end();
|
|
@@ -171,7 +185,7 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
|
|
|
171
185
|
});
|
|
172
186
|
return;
|
|
173
187
|
}
|
|
174
|
-
if (!authCheck(req, token)) {
|
|
188
|
+
if (!authCheck(req, token, sessionKey)) {
|
|
175
189
|
if (isHtmlRequest(req)) {
|
|
176
190
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
177
191
|
res.end(loginPageHtml(false, isPublic));
|
|
@@ -267,7 +281,7 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
|
|
|
267
281
|
if (auth) {
|
|
268
282
|
const host = String(req.headers.host ?? '');
|
|
269
283
|
const token = isProtectedHost(host, auth.isProtected) ? (auth.getToken?.(host) ?? null) : null;
|
|
270
|
-
if (token && !authCheck(req, token)) {
|
|
284
|
+
if (token && !authCheck(req, token, auth.sessionKey ?? null)) {
|
|
271
285
|
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
|
272
286
|
socket.destroy();
|
|
273
287
|
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