dsh-pocket 1.15.0 → 1.16.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 CHANGED
@@ -44,6 +44,7 @@ What it looks like — the phone shows the exact same UI as your computer, live:
44
44
  | 📶 LAN QR access | Works out of the box: Settings → Phone access — scan the LAN QR on the same Wi-Fi (auto-detects the LAN IP; **under WSL it picks the Windows host's physical NIC IP**) |
45
45
  | 🚪 LAN switch | **Turn LAN access off/on with one click** in Settings (a confirmation dialog shows each time): off kills the LAN QR code and link instantly; public access is unaffected |
46
46
  | 🌐 Public QR (from anywhere) | Click "Enable anywhere" → cloudflared tunnel → scan the public QR over 4G / any network |
47
+ | 🏷️ Fixed public hostname | Optional "**Named tunnel**" mode: paste a Cloudflare Tunnel Token + your own domain — the public address stays **fixed across restarts** (see below) |
47
48
  | 🔐 Access PIN | Public links require an **8-character PIN** (rotated on every tunnel start by default; **customizable to a fixed PIN** — custom PINs are not rotated); LAN has its own separate **8-character PIN** (on by default; switchable off in Settings — then LAN scans connect directly) |
48
49
  | 🔑 Custom PINs | Both the public and LAN PINs can be **set to your own fixed 8-character PIN (letters and digits) in Settings** (custom PINs are never auto-rotated) |
49
50
  | 🧘 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**) |
@@ -95,6 +96,17 @@ On the same page click "**Enable anywhere**" → **a security disclaimer pops up
95
96
 
96
97
  > 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).
97
98
 
99
+ ### Fixed public hostname (named tunnel, optional)
100
+
101
+ The default "quick tunnel" gets a new random URL on every restart. For a **fixed public address**, use a Cloudflare **named tunnel** (requires a Cloudflare account + your own domain):
102
+
103
+ 1. In [Cloudflare Zero Trust](https://one.dash.cloudflare.com/) → **Networks → Tunnels**, create a Tunnel and copy the **Tunnel Token**
104
+ 2. In that Tunnel's **Public Hostname**, point your domain (e.g. `pocket.example.com`) to `http://127.0.0.1:3081`
105
+ 3. Back in the settings page's public section: switch the mode to "**Named tunnel**", paste the Tunnel Token, enter the fixed hostname, and save
106
+ 4. Click "Enable anywhere" → the public address is now your own hostname and **survives restarts**
107
+
108
+ Note: in named-tunnel mode the public PIN is **not auto-rotated** (the address is fixed, so the PIN stays the same across restarts) — manage it proactively with a custom PIN. The Tunnel Token is stored locally only (`$DSH_HOME/dsh-pocket/settings.json`, readable by your user only) and is never echoed back in the UI.
109
+
98
110
  ## ⚠️ Security (read first)
99
111
 
100
112
  - **DSH can execute code on your computer.** **LAN** QR/URL plus its own **8-character 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**.
@@ -102,7 +114,8 @@ On the same page click "**Enable anywhere**" → **a security disclaimer pops up
102
114
  - **Public** access is protected by an **8-character 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; custom PINs may use letters and digits).
103
115
  - 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**.
104
116
  - **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.
105
- - The public URL is randomly assigned by cloudflared and **changes on every restart** (old links die automatically — a natural key rotation).
117
+ - The public URL is randomly assigned by cloudflared and **changes on every restart** (old links die automatically — a natural key rotation); in **named-tunnel fixed-hostname** mode the address stays and the PIN is not auto-rotated — manage it with a custom PIN.
118
+ - **Public detection is fail-closed** (issue #66): everything except loopback and private LAN addresses is treated as **public and PIN-gated** — including any self-hosted tunnel / reverse proxy pointing at the local port with its own hostname. There is no "change the domain to bypass the PIN" hole.
106
119
  - LAN mode exposes nothing publicly; only devices on the same network can reach it.
107
120
  - 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`.
108
121
 
package/README.md CHANGED
@@ -44,6 +44,7 @@ DSH Pocket 就是干这个的:**装上它,手机扫个码,就能实时看
44
44
  | 📶 局域网扫码 | 装好即用:设置 → 手机访问,打开就有局域网二维码,手机连同一 WiFi 扫码即开(自动识别本机局域网 IP,**WSL 环境自动取 Windows 物理网卡 IP**) |
45
45
  | 🚪 局域网开关 | 设置页可**一键关闭/开启局域网访问**(切换时弹窗提醒):关闭后局域网二维码/链接立即失效,仅公网可用 |
46
46
  | 🌐 公网扫码(人在外面) | 点「开启公网访问」→ cloudflared 隧道 → 出公网二维码,4G/任何网络都能访问 |
47
+ | 🏷️ 公网固定域名 | 可选「**命名隧道**」模式:填 Cloudflare Tunnel Token + 自己的域名,公网地址**固定不变**(重启不再变;见下方说明) |
47
48
  | 🔐 访问密码 | 公网链接需输入 **8 位密码**(默认每次开启公网自动换新;**可自定义固定密码**——自定义后不再换新);局域网有独立 **8 位密码**(默认开启,设置页可**一键关闭**——关闭后局域网扫码直连) |
48
49
  | 🔑 自定义密码 | 公网/局域网密码都可在设置页**设成自己固定的 8 位密码(英文字母大小写或数字)**(自定义后公网不再自动换新) |
49
50
  | 🧘 会话保持 | 手机输一次密码后**长期免输**(登录状态绑定电脑上的 dsh web 进程:只要它不重启,手机不用再输;**dsh web 重启/更新后需重新输入一次**) |
@@ -95,6 +96,17 @@ npx @deepseek-ai/dsh web
95
96
 
96
97
  > 更新到新版本:`dsh plugin --profile web update dsh-pocket --latest -w`(跨大版本时 `--latest` 是必须的,`^0.x` 范围不会自动升到 1.x)。
97
98
 
99
+ ### 公网固定域名(命名隧道,可选)
100
+
101
+ 默认「快速隧道」的公网地址每次重启都会变(前缀随机)。想要**固定公网地址**,可用 Cloudflare **命名隧道**(需要 Cloudflare 账号 + 自己的域名):
102
+
103
+ 1. 在 [Cloudflare Zero Trust](https://one.dash.cloudflare.com/) → **Networks → Tunnels** 创建一条 Tunnel,复制 **Tunnel Token**
104
+ 2. 在该 Tunnel 的 **Public Hostname** 里把你的域名(如 `pocket.example.com`)的 Service 指向 `http://127.0.0.1:3081`
105
+ 3. 回到设置页公网区块:模式切到「**命名隧道**」,粘贴 Tunnel Token、填写固定域名,保存
106
+ 4. 点「开启公网访问」→ 公网地址固定为你的域名,**重启不再变化**
107
+
108
+ 注意:命名隧道模式下公网密码**不自动轮换**(地址固定,重启后密码不变),建议配合「自定义密码」主动管理;Tunnel Token 只存本机(`$DSH_HOME/dsh-pocket/settings.json`,仅本机可读),设置页不回显。
109
+
98
110
  ## ⚠️ 安全(必读)
99
111
 
100
112
  - **DSH 能执行你电脑上的代码**。**局域网**二维码/URL 配上独立 **8 位密码**才是钥匙(密码**默认开启**,可关——关闭后局域网扫码直连,仅同一网络设备可访问),**请勿把局域网二维码、URL 或密码发给别人**
@@ -102,7 +114,8 @@ npx @deepseek-ai/dsh web
102
114
  - **公网**有 **8 位密码**保护:链接随机分配、默认每次开启换新密码、旧链接立即作废——泄露了也进不来,改密码/重开即可作废;**自定义密码后不再自动换新**(你设的值即稳定密码,可为英文字母大小写或数字)
103
115
  - 手机登录状态与电脑上的 dsh web 进程绑定:**电脑 dsh web 一直开着就不用重复输入;重启/更新后需重新输入一次**
104
116
  - **登录限速**(防暴力破解):同一 IP 连续输错 **5 次**锁定 **60 秒**;全局失败超阈值时短暂全锁(防换 IP 分布式扫描);输对密码后计数清零
105
- - 公网 URL 由 cloudflared 随机分配,**每次重启会变化**(旧链接自动失效,相当于天然轮换)
117
+ - 公网 URL 由 cloudflared 随机分配,**每次重启会变化**(旧链接自动失效,相当于天然轮换);**命名隧道固定域名**模式下地址不变、密码不自动轮换,请配合自定义密码管理
118
+ - **公网判定是 fail closed**(issue #66):除本机(loopback)和局域网私网地址外,**一切陌生域名(包括你自建隧道/反向代理指向本机端口的固定域名)一律按公网处理、强制公网密码**——不存在「换域名绕过密码」的口子
106
119
  - 局域网模式不暴露公网,只有同一网络内的设备能访问
107
120
  - 适合个人自用;公网密码存本机 `$DSH_HOME/dsh-pocket/token`(默认每次开启公网自动换新,**自定义后不换**),局域网密码存 `$DSH_HOME/dsh-pocket/token-lan`(设置页手动刷新),开关/自定义标记存 `$DSH_HOME/dsh-pocket/settings.json`
108
121
 
package/client/api.js CHANGED
@@ -5,6 +5,7 @@ export const POCKET_ENDPOINTS = Object.freeze({
5
5
  status: 'pocket.status',
6
6
  tunnelStart: 'tunnel.start',
7
7
  tunnelStop: 'tunnel.stop',
8
+ tunnelSetConfig: 'tunnel.setConfig',
8
9
  version: 'pocket.version',
9
10
  update: 'pocket.update',
10
11
  restart: 'pocket.restart',
@@ -62,6 +63,7 @@ export function redactStatus(s) {
62
63
  tunnelUrl: s?.tunnelUrl ?? null,
63
64
  tunnelQr: s?.tunnelQr ?? null,
64
65
  tunnelState: s?.tunnelState ?? { phase: 'idle' },
66
+ tunnelConfig: s?.tunnelConfig ?? { mode: 'quick', hostname: '', tokenSet: false },
65
67
  dshPort: s?.dshPort ?? null,
66
68
  };
67
69
  }
package/client/client.js CHANGED
@@ -45,6 +45,7 @@ var POCKET_ENDPOINTS = Object.freeze({
45
45
  status: "pocket.status",
46
46
  tunnelStart: "tunnel.start",
47
47
  tunnelStop: "tunnel.stop",
48
+ tunnelSetConfig: "tunnel.setConfig",
48
49
  version: "pocket.version",
49
50
  update: "pocket.update",
50
51
  restart: "pocket.restart",
@@ -95,6 +96,7 @@ function redactStatus(s) {
95
96
  tunnelUrl: s?.tunnelUrl ?? null,
96
97
  tunnelQr: s?.tunnelQr ?? null,
97
98
  tunnelState: s?.tunnelState ?? { phase: "idle" },
99
+ tunnelConfig: s?.tunnelConfig ?? { mode: "quick", hostname: "", tokenSet: false },
98
100
  dshPort: s?.dshPort ?? null
99
101
  };
100
102
  }
@@ -1396,6 +1398,19 @@ var zh2 = {
1396
1398
  "stopTunnel": "\u5173\u95ED\u516C\u7F51",
1397
1399
  "enable": "\u5F00\u542F\u516C\u7F51\u8BBF\u95EE",
1398
1400
  "opening": "\u5F00\u542F\u4E2D\u2026",
1401
+ "tunnelMode": "\u516C\u7F51\u6A21\u5F0F\uFF1A",
1402
+ "modeQuick": "\u968F\u673A\u57DF\u540D\uFF08\u9ED8\u8BA4\uFF09",
1403
+ "modeNamed": "\u56FA\u5B9A\u57DF\u540D",
1404
+ "namedSummary": "\u56FA\u5B9A\u57DF\u540D\uFF1A{host} \xB7 Token {token}",
1405
+ "namedTokenSet": "\u5DF2\u914D\u7F6E",
1406
+ "namedTokenMissing": "\u672A\u914D\u7F6E",
1407
+ "namedEdit": "\u4FEE\u6539",
1408
+ "namedHostnameLabel": "\u56FA\u5B9A\u57DF\u540D\uFF1A",
1409
+ "namedTokenLabel": "Tunnel Token\uFF08\u7559\u7A7A = \u4FDD\u6301\u4E0D\u53D8\uFF09\uFF1A",
1410
+ "namedHow": "\u5728 Cloudflare Zero Trust \u2192 Networks \u2192 Tunnels \u521B\u5EFA\u96A7\u9053\u5E76\u590D\u5236 Token\uFF1B\u628A\u57DF\u540D\u7684 Service \u6307\u5411 http://127.0.0.1:3081\uFF08\u4EE3\u7406\u7AEF\u53E3\uFF09\u3002\u5730\u5740\u56FA\u5B9A\uFF0C\u91CD\u542F\u4E0D\u518D\u53D8\u5316\u3002",
1411
+ "namedSecurity": "\u56FA\u5B9A\u57DF\u540D\u957F\u671F\u66B4\u9732\u5728\u516C\u7F51\u3001\u66F4\u6613\u88AB\u626B\u63CF\uFF0C\u5EFA\u8BAE\u540C\u65F6\u8BBE\u7F6E\u81EA\u5B9A\u4E49\u5F3A\u5BC6\u7801\uFF08\u672C\u6A21\u5F0F\u516C\u7F51\u5BC6\u7801\u9ED8\u8BA4\u4E0D\u968F\u91CD\u542F\u8F6E\u6362\uFF09\u3002",
1412
+ "namedNeedCfg": "\u8BF7\u5148\u586B\u5199\u56FA\u5B9A\u57DF\u540D\u4E0E Tunnel Token",
1413
+ "namedRunningHint": "\u56FA\u5B9A\u57DF\u540D\uFF08Cloudflare \u547D\u540D\u96A7\u9053\uFF09\u2014\u2014\u5730\u5740\u4E0D\u968F\u91CD\u542F\u53D8\u5316",
1399
1414
  "disclaimerTitle": "\u26A0\uFE0F \u5B89\u5168\u514D\u8D23\u58F0\u660E",
1400
1415
  "disclaimerBody": "\u5F00\u542F\u516C\u7F51 = \u628A\u672C\u673A DSH\uFF08\u80FD\u6267\u884C\u4EE3\u7801\uFF09\u66B4\u9732\u5230\u4E92\u8054\u7F51\u3002\u4EFB\u4F55\u4EBA\u62FF\u5230\u516C\u7F51\u94FE\u63A5\u548C\u5BC6\u7801\uFF0C\u90FD\u80FD\u8BBF\u95EE\u751A\u81F3\u64CD\u4F5C\u4F60\u7684\u7535\u8111\u3002\u8BF7\u786E\u8BA4\uFF1A\u2460 \u4F7F\u7528\u81EA\u5B9A\u4E49\u5F3A\u5BC6\u7801\u6216\u59A5\u5584\u4FDD\u7BA1\u81EA\u52A8\u5BC6\u7801\uFF1B\u2461 \u7528\u5B8C\u7ACB\u5373\u300C\u5173\u95ED\u516C\u7F51\u300D\uFF1B\u2462 \u516C\u53F8/\u6D89\u5BC6\u7F51\u7EDC\u8BF7\u5148\u786E\u8BA4\u5408\u89C4\u3002",
1401
1416
  "disclaimerAgree": "\u6211\u5DF2\u77E5\u60C5\uFF0C\u540C\u610F\u5F00\u542F",
@@ -1465,6 +1480,19 @@ var en2 = {
1465
1480
  "stopTunnel": "Stop",
1466
1481
  "enable": "Enable anywhere",
1467
1482
  "opening": "Enabling\u2026",
1483
+ "tunnelMode": "Mode:",
1484
+ "modeQuick": "Random URL (default)",
1485
+ "modeNamed": "Fixed domain",
1486
+ "namedSummary": "Fixed domain: {host} \xB7 Token {token}",
1487
+ "namedTokenSet": "configured",
1488
+ "namedTokenMissing": "not set",
1489
+ "namedEdit": "Edit",
1490
+ "namedHostnameLabel": "Fixed domain:",
1491
+ "namedTokenLabel": "Tunnel Token (blank = keep current):",
1492
+ "namedHow": "Create a tunnel in Cloudflare Zero Trust \u2192 Networks \u2192 Tunnels and copy the token; point the hostname's Service at http://127.0.0.1:3081 (the proxy port). The URL stays fixed across restarts.",
1493
+ "namedSecurity": "A fixed domain is long-lived and easier to scan \u2014 set a strong custom PIN too (the public PIN is not rotated on restart in this mode).",
1494
+ "namedNeedCfg": "Set the fixed domain and Tunnel Token first",
1495
+ "namedRunningHint": "Fixed domain (Cloudflare named tunnel) \u2014 the URL no longer changes on restart",
1468
1496
  "disclaimerTitle": "\u26A0\uFE0F Security disclaimer",
1469
1497
  "disclaimerBody": "Enabling public access exposes this computer\u2019s DSH (which can execute code) to the internet. Anyone with the public link and PIN can reach \u2014 and operate \u2014 your computer. Please confirm: \u2460 use a strong custom PIN or keep the auto-generated one safe; \u2461 turn public access OFF as soon as you\u2019re done; \u2462 on a corporate/classified network, confirm compliance first.",
1470
1498
  "disclaimerAgree": "I understand and agree",
@@ -1612,6 +1640,11 @@ function PocketSettingsTab({ rpcCall, t }) {
1612
1640
  const [disclaimerOpen, setDisclaimerOpen] = (0, import_react2.useState)(false);
1613
1641
  const [disclaimerChecked, setDisclaimerChecked] = (0, import_react2.useState)(false);
1614
1642
  const doStartTunnel = async () => {
1643
+ const cfg = status?.tunnelConfig;
1644
+ if (cfg?.mode === "named" && (!cfg.hostname || !cfg.tokenSet)) {
1645
+ setError(t("namedNeedCfg"));
1646
+ return;
1647
+ }
1615
1648
  setBusy(true);
1616
1649
  setError(null);
1617
1650
  setTunnelState({ phase: "starting", detail: "\u6B63\u5728\u5F00\u542F\u2026", startedAt: Date.now() });
@@ -1638,6 +1671,27 @@ function PocketSettingsTab({ rpcCall, t }) {
1638
1671
  } catch {
1639
1672
  }
1640
1673
  };
1674
+ const [tunnelCfg, setTunnelCfg] = (0, import_react2.useState)(null);
1675
+ const switchToQuick = async () => {
1676
+ try {
1677
+ setStatus(await call(POCKET_ENDPOINTS.tunnelSetConfig, { mode: "quick" }));
1678
+ } catch (err) {
1679
+ setError(err.message);
1680
+ }
1681
+ };
1682
+ const saveNamedTunnel = async () => {
1683
+ try {
1684
+ setStatus(await call(POCKET_ENDPOINTS.tunnelSetConfig, {
1685
+ mode: "named",
1686
+ hostname: tunnelCfg?.hostname ?? "",
1687
+ token: tunnelCfg?.token || void 0
1688
+ // 留空不覆盖已存 Token
1689
+ }));
1690
+ setTunnelCfg(null);
1691
+ } catch (err) {
1692
+ setTunnelCfg((c) => ({ ...c, err: err.message }));
1693
+ }
1694
+ };
1641
1695
  const refreshLanPin = async () => {
1642
1696
  try {
1643
1697
  const r = await call(POCKET_ENDPOINTS.lanTokenRefresh, {});
@@ -1715,6 +1769,8 @@ function PocketSettingsTab({ rpcCall, t }) {
1715
1769
  const tunnelStarting = ["downloading", "starting", "registering"].includes(tunnelPhase);
1716
1770
  const tunnelStateDetail = tunnelState?.detail ?? "";
1717
1771
  const tunnelStateStarted = tunnelState?.startedAt ?? null;
1772
+ const tunnelModeView = status?.tunnelConfig ?? { mode: "quick", hostname: "", tokenSet: false };
1773
+ const namedMode = tunnelModeView.mode === "named";
1718
1774
  return (0, import_react2.createElement)(
1719
1775
  "div",
1720
1776
  { style: styles.card },
@@ -1851,18 +1907,87 @@ function PocketSettingsTab({ rpcCall, t }) {
1851
1907
  null,
1852
1908
  (0, import_react2.createElement)("img", { src: status.tunnelQr, alt: "Tunnel QR", style: styles.qr }),
1853
1909
  (0, import_react2.createElement)("div", { style: styles.code }, tunnelUrl),
1854
- (0, import_react2.createElement)("div", { style: styles.muted }, t("wanHint")),
1910
+ (0, import_react2.createElement)("div", { style: styles.muted }, namedMode ? t("namedRunningHint") : t("wanHint")),
1855
1911
  status.accessToken ? customPin?.which === "public" ? customPinRow("public") : (0, import_react2.createElement)(
1856
1912
  "div",
1857
1913
  { style: { marginTop: 6, fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)", lineHeight: 1.5 } },
1858
1914
  fmt(t, status?.publicPinCustom ? "wanPinCustom" : "wanPin", { pin: status.accessToken }),
1859
1915
  customBtn("public"),
1860
- status?.publicPinCustom ? (0, import_react2.createElement)("div", { style: { marginTop: 2, fontSize: 11, color: "var(--dsw-alias-state-warn-primary,#b45309)" } }, t("pinCustomHint")) : null
1916
+ status?.publicPinCustom ? (0, import_react2.createElement)("div", { style: { marginTop: 2, fontSize: 11, color: "var(--dsw-alias-state-warn-primary,#b45309)" } }, t("pinCustomHint")) : null,
1917
+ namedMode ? (0, import_react2.createElement)("div", { style: { marginTop: 2, fontSize: 11, color: "var(--dsw-alias-state-warn-primary,#b45309)" } }, t("namedSecurity")) : null
1861
1918
  ) : null,
1862
1919
  (0, import_react2.createElement)("button", { style: styles.btn, onClick: stopTunnel }, t("stopTunnel"))
1863
1920
  ) : (0, import_react2.createElement)(
1864
1921
  "div",
1865
1922
  null,
1923
+ // 公网模式(issue #66):随机域名(默认)/ 固定域名(命名隧道)
1924
+ (0, import_react2.createElement)(
1925
+ "div",
1926
+ { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 8, flexWrap: "wrap" } },
1927
+ (0, import_react2.createElement)("span", { style: { fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)" } }, t("tunnelMode")),
1928
+ (0, import_react2.createElement)("button", {
1929
+ style: { ...styles.btn, height: 28, padding: "0 12px", fontSize: 12, fontWeight: !namedMode ? 600 : 400, background: !namedMode ? "var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary,#4f6ef7))" : "var(--dsw-alias-bg-layer-1,#fff)", color: !namedMode ? "var(--dsw-alias-label-primary-foreground, #fff)" : "var(--dsw-alias-label-primary,inherit)" },
1930
+ onClick: namedMode ? switchToQuick : void 0
1931
+ }, t("modeQuick")),
1932
+ (0, import_react2.createElement)("button", {
1933
+ style: { ...styles.btn, height: 28, padding: "0 12px", fontSize: 12, fontWeight: namedMode ? 600 : 400, background: namedMode ? "var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary,#4f6ef7))" : "var(--dsw-alias-bg-layer-1,#fff)", color: namedMode ? "var(--dsw-alias-label-primary-foreground, #fff)" : "var(--dsw-alias-label-primary,inherit)" },
1934
+ onClick: () => setTunnelCfg(tunnelCfg ? null : { hostname: tunnelModeView.hostname ?? "", token: "", err: null })
1935
+ }, t("modeNamed"))
1936
+ ),
1937
+ // 固定域名:已保存的摘要 + 「修改」入口(非编辑态)
1938
+ namedMode && !tunnelCfg ? (0, import_react2.createElement)(
1939
+ "div",
1940
+ { style: { marginTop: 6, fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)", lineHeight: 1.5 } },
1941
+ fmt(t, "namedSummary", { host: tunnelModeView.hostname || "\u2014", token: tunnelModeView.tokenSet ? t("namedTokenSet") : t("namedTokenMissing") }),
1942
+ (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 26, padding: "0 10px", fontSize: 12, marginLeft: 8 }, onClick: () => setTunnelCfg({ hostname: tunnelModeView.hostname ?? "", token: "", err: null }) }, t("namedEdit")),
1943
+ (0, import_react2.createElement)("div", { style: { ...styles.muted, marginTop: 4 } }, t("namedHow")),
1944
+ !tunnelModeView.tokenSet || !tunnelModeView.hostname ? (0, import_react2.createElement)("div", { style: { marginTop: 2, fontSize: 12, color: "var(--dsw-alias-state-error-primary,#dc2626)" } }, t("namedNeedCfg")) : null
1945
+ ) : null,
1946
+ // 固定域名:编辑表单(域名 + Tunnel Token,Token 留空保持不变)
1947
+ tunnelCfg ? (0, import_react2.createElement)(
1948
+ "div",
1949
+ { style: { marginTop: 8, fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)", lineHeight: 1.6 } },
1950
+ (0, import_react2.createElement)(
1951
+ "div",
1952
+ null,
1953
+ t("namedHostnameLabel"),
1954
+ (0, import_react2.createElement)("input", {
1955
+ style: { margin: "4px 0 0 6px", padding: "4px 8px", fontSize: 13, border: "1px solid var(--dsw-alias-border-l2,#d1d5db)", borderRadius: 6, outline: "none", width: 200 },
1956
+ placeholder: "pocket.example.com",
1957
+ value: tunnelCfg.hostname ?? "",
1958
+ autoFocus: true,
1959
+ onChange: (e) => setTunnelCfg((c) => ({ ...c, hostname: e.target.value.trim(), err: null })),
1960
+ onKeyDown: (e) => {
1961
+ if (e.key === "Enter") saveNamedTunnel();
1962
+ if (e.key === "Escape") setTunnelCfg(null);
1963
+ }
1964
+ })
1965
+ ),
1966
+ (0, import_react2.createElement)(
1967
+ "div",
1968
+ { style: { marginTop: 6 } },
1969
+ t("namedTokenLabel"),
1970
+ (0, import_react2.createElement)("input", {
1971
+ style: { margin: "4px 0 0 6px", padding: "4px 8px", fontSize: 13, border: "1px solid var(--dsw-alias-border-l2,#d1d5db)", borderRadius: 6, outline: "none", width: 240, fontFamily: "ui-monospace,Menlo,monospace" },
1972
+ type: "password",
1973
+ value: tunnelCfg.token ?? "",
1974
+ onChange: (e) => setTunnelCfg((c) => ({ ...c, token: e.target.value.trim(), err: null })),
1975
+ onKeyDown: (e) => {
1976
+ if (e.key === "Enter") saveNamedTunnel();
1977
+ if (e.key === "Escape") setTunnelCfg(null);
1978
+ }
1979
+ })
1980
+ ),
1981
+ (0, import_react2.createElement)(
1982
+ "div",
1983
+ { style: { marginTop: 6, display: "flex", gap: 8 } },
1984
+ (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 26, padding: "0 10px", fontSize: 12 }, onClick: saveNamedTunnel }, t("save")),
1985
+ (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 26, padding: "0 10px", fontSize: 12 }, onClick: () => setTunnelCfg(null) }, t("cancel"))
1986
+ ),
1987
+ (0, import_react2.createElement)("div", { style: { ...styles.muted, marginTop: 6 } }, t("namedHow")),
1988
+ (0, import_react2.createElement)("div", { style: { marginTop: 2, fontSize: 11, color: "var(--dsw-alias-state-warn-primary,#b45309)", lineHeight: 1.5 } }, t("namedSecurity")),
1989
+ tunnelCfg.err ? (0, import_react2.createElement)("div", { style: { color: "var(--dsw-alias-state-error-primary,#dc2626)", marginTop: 4 } }, tunnelCfg.err) : null
1990
+ ) : null,
1866
1991
  (0, import_react2.createElement)("button", { style: { ...styles.primary, margin: "8px 0" }, onClick: startTunnel, disabled: busy || tunnelStarting }, busy ? t("opening") : t("enable")),
1867
1992
  tunnelStarting ? (0, import_react2.createElement)(
1868
1993
  "div",
package/client/index.jsx CHANGED
@@ -169,6 +169,12 @@ function PocketSettingsTab({ rpcCall, t }) {
169
169
  const [disclaimerChecked, setDisclaimerChecked] = useState(false);
170
170
 
171
171
  const doStartTunnel = async () => {
172
+ // 命名隧道模式:Token/域名没配齐就不发起(服务端同样会拒绝)
173
+ const cfg = status?.tunnelConfig;
174
+ if (cfg?.mode === 'named' && (!cfg.hostname || !cfg.tokenSet)) {
175
+ setError(t('namedNeedCfg'));
176
+ return;
177
+ }
172
178
  setBusy(true);
173
179
  setError(null);
174
180
  setTunnelState({ phase: 'starting', detail: '正在开启…', startedAt: Date.now() });
@@ -195,6 +201,25 @@ function PocketSettingsTab({ rpcCall, t }) {
195
201
  try { setStatus(await call(POCKET_ENDPOINTS.tunnelStop, {})); } catch { /* 忽略 */ }
196
202
  };
197
203
 
204
+ // 公网模式(issue #66):随机域名(默认零配置)/ 固定域名(Cloudflare 命名隧道 + Tunnel Token)
205
+ // tunnelCfg:编辑态 { hostname, token, err } | null;token 输入留空 = 保持已存的 Token 不变
206
+ const [tunnelCfg, setTunnelCfg] = useState(null);
207
+ const switchToQuick = async () => {
208
+ try { setStatus(await call(POCKET_ENDPOINTS.tunnelSetConfig, { mode: 'quick' })); } catch (err) { setError(err.message); }
209
+ };
210
+ const saveNamedTunnel = async () => {
211
+ try {
212
+ setStatus(await call(POCKET_ENDPOINTS.tunnelSetConfig, {
213
+ mode: 'named',
214
+ hostname: tunnelCfg?.hostname ?? '',
215
+ token: tunnelCfg?.token || undefined, // 留空不覆盖已存 Token
216
+ }));
217
+ setTunnelCfg(null);
218
+ } catch (err) {
219
+ setTunnelCfg((c) => ({ ...c, err: err.message }));
220
+ }
221
+ };
222
+
198
223
  // 刷新局域网访问密码(旧密码立即作废)
199
224
  const refreshLanPin = async () => {
200
225
  try {
@@ -279,6 +304,9 @@ function PocketSettingsTab({ rpcCall, t }) {
279
304
  const tunnelStarting = ['downloading', 'starting', 'registering'].includes(tunnelPhase);
280
305
  const tunnelStateDetail = tunnelState?.detail ?? '';
281
306
  const tunnelStateStarted = tunnelState?.startedAt ?? null;
307
+ // 公网模式视图(issue #66):{ mode, hostname, tokenSet }
308
+ const tunnelModeView = status?.tunnelConfig ?? { mode: 'quick', hostname: '', tokenSet: false };
309
+ const namedMode = tunnelModeView.mode === 'named';
282
310
 
283
311
  return h('div', { style: styles.card },
284
312
  h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 } },
@@ -400,7 +428,7 @@ function PocketSettingsTab({ rpcCall, t }) {
400
428
  ? h('div', null,
401
429
  h('img', { src: status.tunnelQr, alt: 'Tunnel QR', style: styles.qr }),
402
430
  h('div', { style: styles.code }, tunnelUrl),
403
- h('div', { style: styles.muted }, t('wanHint')),
431
+ h('div', { style: styles.muted }, namedMode ? t('namedRunningHint') : t('wanHint')),
404
432
  status.accessToken
405
433
  ? (customPin?.which === 'public'
406
434
  ? customPinRow('public')
@@ -408,11 +436,62 @@ function PocketSettingsTab({ rpcCall, t }) {
408
436
  fmt(t, status?.publicPinCustom ? 'wanPinCustom' : 'wanPin', { pin: status.accessToken }),
409
437
  customBtn('public'),
410
438
  status?.publicPinCustom ? h('div', { style: { marginTop: 2, fontSize: 11, color: 'var(--dsw-alias-state-warn-primary,#b45309)' } }, t('pinCustomHint')) : null,
439
+ namedMode ? h('div', { style: { marginTop: 2, fontSize: 11, color: 'var(--dsw-alias-state-warn-primary,#b45309)' } }, t('namedSecurity')) : null,
411
440
  ))
412
441
  : null,
413
442
  h('button', { style: styles.btn, onClick: stopTunnel }, t('stopTunnel')),
414
443
  )
415
444
  : h('div', null,
445
+ // 公网模式(issue #66):随机域名(默认)/ 固定域名(命名隧道)
446
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 8, flexWrap: 'wrap' } },
447
+ h('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)' } }, t('tunnelMode')),
448
+ h('button', {
449
+ style: { ...styles.btn, height: 28, padding: '0 12px', fontSize: 12, fontWeight: !namedMode ? 600 : 400, background: !namedMode ? 'var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary,#4f6ef7))' : 'var(--dsw-alias-bg-layer-1,#fff)', color: !namedMode ? 'var(--dsw-alias-label-primary-foreground, #fff)' : 'var(--dsw-alias-label-primary,inherit)' },
450
+ onClick: namedMode ? switchToQuick : undefined,
451
+ }, t('modeQuick')),
452
+ h('button', {
453
+ style: { ...styles.btn, height: 28, padding: '0 12px', fontSize: 12, fontWeight: namedMode ? 600 : 400, background: namedMode ? 'var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary,#4f6ef7))' : 'var(--dsw-alias-bg-layer-1,#fff)', color: namedMode ? 'var(--dsw-alias-label-primary-foreground, #fff)' : 'var(--dsw-alias-label-primary,inherit)' },
454
+ onClick: () => setTunnelCfg(tunnelCfg ? null : { hostname: tunnelModeView.hostname ?? '', token: '', err: null }),
455
+ }, t('modeNamed')),
456
+ ),
457
+ // 固定域名:已保存的摘要 + 「修改」入口(非编辑态)
458
+ namedMode && !tunnelCfg ? h('div', { style: { marginTop: 6, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)', lineHeight: 1.5 } },
459
+ fmt(t, 'namedSummary', { host: tunnelModeView.hostname || '—', token: tunnelModeView.tokenSet ? t('namedTokenSet') : t('namedTokenMissing') }),
460
+ h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12, marginLeft: 8 }, onClick: () => setTunnelCfg({ hostname: tunnelModeView.hostname ?? '', token: '', err: null }) }, t('namedEdit')),
461
+ h('div', { style: { ...styles.muted, marginTop: 4 } }, t('namedHow')),
462
+ !tunnelModeView.tokenSet || !tunnelModeView.hostname ? h('div', { style: { marginTop: 2, fontSize: 12, color: 'var(--dsw-alias-state-error-primary,#dc2626)' } }, t('namedNeedCfg')) : null,
463
+ ) : null,
464
+ // 固定域名:编辑表单(域名 + Tunnel Token,Token 留空保持不变)
465
+ tunnelCfg ? h('div', { style: { marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)', lineHeight: 1.6 } },
466
+ h('div', null,
467
+ t('namedHostnameLabel'),
468
+ h('input', {
469
+ style: { margin: '4px 0 0 6px', padding: '4px 8px', fontSize: 13, border: '1px solid var(--dsw-alias-border-l2,#d1d5db)', borderRadius: 6, outline: 'none', width: 200 },
470
+ placeholder: 'pocket.example.com',
471
+ value: tunnelCfg.hostname ?? '',
472
+ autoFocus: true,
473
+ onChange: (e) => setTunnelCfg((c) => ({ ...c, hostname: e.target.value.trim(), err: null })),
474
+ onKeyDown: (e) => { if (e.key === 'Enter') saveNamedTunnel(); if (e.key === 'Escape') setTunnelCfg(null); },
475
+ }),
476
+ ),
477
+ h('div', { style: { marginTop: 6 } },
478
+ t('namedTokenLabel'),
479
+ h('input', {
480
+ style: { margin: '4px 0 0 6px', padding: '4px 8px', fontSize: 13, border: '1px solid var(--dsw-alias-border-l2,#d1d5db)', borderRadius: 6, outline: 'none', width: 240, fontFamily: 'ui-monospace,Menlo,monospace' },
481
+ type: 'password',
482
+ value: tunnelCfg.token ?? '',
483
+ onChange: (e) => setTunnelCfg((c) => ({ ...c, token: e.target.value.trim(), err: null })),
484
+ onKeyDown: (e) => { if (e.key === 'Enter') saveNamedTunnel(); if (e.key === 'Escape') setTunnelCfg(null); },
485
+ }),
486
+ ),
487
+ h('div', { style: { marginTop: 6, display: 'flex', gap: 8 } },
488
+ h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12 }, onClick: saveNamedTunnel }, t('save')),
489
+ h('button', { style: { ...styles.btn, height: 26, padding: '0 10px', fontSize: 12 }, onClick: () => setTunnelCfg(null) }, t('cancel')),
490
+ ),
491
+ h('div', { style: { ...styles.muted, marginTop: 6 } }, t('namedHow')),
492
+ h('div', { style: { marginTop: 2, fontSize: 11, color: 'var(--dsw-alias-state-warn-primary,#b45309)', lineHeight: 1.5 } }, t('namedSecurity')),
493
+ tunnelCfg.err ? h('div', { style: { color: 'var(--dsw-alias-state-error-primary,#dc2626)', marginTop: 4 } }, tunnelCfg.err) : null,
494
+ ) : null,
416
495
  h('button', { style: { ...styles.primary, margin: '8px 0' }, onClick: startTunnel, disabled: busy || tunnelStarting }, busy ? t('opening') : t('enable')),
417
496
  tunnelStarting
418
497
  ? h('div', { style: { marginTop: 4, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)' } },
@@ -61,6 +61,19 @@ export const zh = {
61
61
  'stopTunnel': '关闭公网',
62
62
  'enable': '开启公网访问',
63
63
  'opening': '开启中…',
64
+ 'tunnelMode': '公网模式:',
65
+ 'modeQuick': '随机域名(默认)',
66
+ 'modeNamed': '固定域名',
67
+ 'namedSummary': '固定域名:{host} · Token {token}',
68
+ 'namedTokenSet': '已配置',
69
+ 'namedTokenMissing': '未配置',
70
+ 'namedEdit': '修改',
71
+ 'namedHostnameLabel': '固定域名:',
72
+ 'namedTokenLabel': 'Tunnel Token(留空 = 保持不变):',
73
+ 'namedHow': '在 Cloudflare Zero Trust → Networks → Tunnels 创建隧道并复制 Token;把域名的 Service 指向 http://127.0.0.1:3081(代理端口)。地址固定,重启不再变化。',
74
+ 'namedSecurity': '固定域名长期暴露在公网、更易被扫描,建议同时设置自定义强密码(本模式公网密码默认不随重启轮换)。',
75
+ 'namedNeedCfg': '请先填写固定域名与 Tunnel Token',
76
+ 'namedRunningHint': '固定域名(Cloudflare 命名隧道)——地址不随重启变化',
64
77
  'disclaimerTitle': '⚠️ 安全免责声明',
65
78
  'disclaimerBody': '开启公网 = 把本机 DSH(能执行代码)暴露到互联网。任何人拿到公网链接和密码,都能访问甚至操作你的电脑。请确认:① 使用自定义强密码或妥善保管自动密码;② 用完立即「关闭公网」;③ 公司/涉密网络请先确认合规。',
66
79
  'disclaimerAgree': '我已知情,同意开启',
@@ -132,6 +145,19 @@ export const en = {
132
145
  'stopTunnel': 'Stop',
133
146
  'enable': 'Enable anywhere',
134
147
  'opening': 'Enabling…',
148
+ 'tunnelMode': 'Mode:',
149
+ 'modeQuick': 'Random URL (default)',
150
+ 'modeNamed': 'Fixed domain',
151
+ 'namedSummary': 'Fixed domain: {host} · Token {token}',
152
+ 'namedTokenSet': 'configured',
153
+ 'namedTokenMissing': 'not set',
154
+ 'namedEdit': 'Edit',
155
+ 'namedHostnameLabel': 'Fixed domain:',
156
+ 'namedTokenLabel': 'Tunnel Token (blank = keep current):',
157
+ 'namedHow': 'Create a tunnel in Cloudflare Zero Trust → Networks → Tunnels and copy the token; point the hostname\'s Service at http://127.0.0.1:3081 (the proxy port). The URL stays fixed across restarts.',
158
+ 'namedSecurity': 'A fixed domain is long-lived and easier to scan — set a strong custom PIN too (the public PIN is not rotated on restart in this mode).',
159
+ 'namedNeedCfg': 'Set the fixed domain and Tunnel Token first',
160
+ 'namedRunningHint': 'Fixed domain (Cloudflare named tunnel) — the URL no longer changes on restart',
135
161
  'disclaimerTitle': '⚠️ Security disclaimer',
136
162
  'disclaimerBody': 'Enabling public access exposes this computer\u2019s DSH (which can execute code) to the internet. Anyone with the public link and PIN can reach — and operate — your computer. Please confirm: ① use a strong custom PIN or keep the auto-generated one safe; ② turn public access OFF as soon as you\u2019re done; ③ on a corporate/classified network, confirm compliance first.',
137
163
  'disclaimerAgree': 'I understand and agree',
package/lib/index.js CHANGED
@@ -20,8 +20,8 @@ import { randomBytes } from 'node:crypto';
20
20
  import { createPocketService } from './service.mjs';
21
21
  import { installPocketRpc } from './web-rpc.js';
22
22
  import { restartHost } from './restart.js';
23
- import { desktopEnvPatchScript, advancedNoticeScript, DEFAULT_INJECT } from './proxy.mjs';
24
- import { lanEnabled, setLanEnabled, lanAuthEnabled, setLanAuthEnabled, lanIpOverride, setLanIpOverride, pinCustom, setPinCustom } from './settings.mjs';
23
+ import { desktopEnvPatchScript, advancedNoticeScript, DEFAULT_INJECT, classifyHost } from './proxy.mjs';
24
+ import { lanEnabled, setLanEnabled, lanAuthEnabled, setLanAuthEnabled, lanIpOverride, setLanIpOverride, pinCustom, setPinCustom, tunnelMode, setTunnelMode, tunnelToken, setTunnelToken, tunnelHostname, setTunnelHostname } from './settings.mjs';
25
25
 
26
26
  const name = 'dsh-pocket';
27
27
  const inject = ['connection', 'webServer'];
@@ -98,9 +98,14 @@ function refreshLanToken() {
98
98
  setPinCustom('lan', false);
99
99
  return writePinToFile(lanTokenPath(), newPin());
100
100
  }
101
- /** 按 Host 分发的访问密码:公网隧道用公网密码,局域网用局域网密码。 */
101
+ /**
102
+ * 按 Host 分发的访问密码(issue #66:fail closed)。
103
+ * 公网 = classifyHost 判为 public 的 Host(trycloudflare **或用户自建命名隧道的任意固定域名**),
104
+ * 一律校验公网密码;loopback/局域网(私网 IP、.local 等)才走局域网密码——
105
+ * 自建隧道 + 关闭局域网密码不再出现公网裸奔。
106
+ */
102
107
  function tokenForHost(host) {
103
- return /trycloudflare\.com$/i.test(String(host ?? '')) ? getAccessToken() : getLanToken();
108
+ return classifyHost(host) === 'public' ? getAccessToken() : getLanToken();
104
109
  }
105
110
  /**
106
111
  * 用户自定义访问密码(issue #33):公网/局域网各自设一个固定的 8 位密码(英文字母大小写或数字)。
@@ -235,17 +240,26 @@ export function apply(ctx, config = {}, internals = {}) {
235
240
  injectHtml: isDesktop
236
241
  ? DEFAULT_INJECT + desktopEnvPatchScript(process.platform) + (desktopAdvanced ? advancedNoticeScript() : '')
237
242
  : undefined,
238
- // 访问密码(issue #13 + #18 + #24 + #33):公网永远要密码(默认每次开启变新,
243
+ // 访问密码(issue #13 + #18 + #24 + #33 + #66):公网永远要密码(默认每次开启变新,
239
244
  // 用户自定义后不再轮换);局域网按开关(默认开启;关闭后局域网扫码直连)。
245
+ // 公网的判定是 fail closed:除了 loopback/私网 Host,一切陌生域名(含自建
246
+ // 命名隧道的固定域名)都按公网处理、强制公网密码。
240
247
  auth: {
241
248
  // sessionKey:进程级随机密钥——登录 cookie 绑定它(会话保持,issue #33):
242
249
  // dsh web 重启/更新后 sessionKey 变化 → 手机需重新输入
243
250
  sessionKey: randomBytes(16).toString('hex'),
244
251
  getToken: (host) => tokenForHost(host),
245
- isProtected: (host) => /trycloudflare\.com$/i.test(String(host ?? '')) ? true : lanAuthEnabled(),
252
+ isProtected: (host) => (classifyHost(host) === 'public' ? true : lanAuthEnabled()),
246
253
  },
247
- // 每次公网隧道就绪 轮换 8 位密码(旧密码/旧链接立即作废;用户自定义后不轮换)
248
- onTunnelReady: () => {
254
+ // 隧道模式(issue #66):'quick'(默认,随机 trycloudflare.com)| 'named'(固定域名)
255
+ getTunnelConfig: () => ({ mode: tunnelMode(), token: tunnelToken(), hostname: tunnelHostname() }),
256
+ // 隧道就绪回调:快速模式每次开启轮换 8 位密码(旧链接作废);命名模式地址固定,
257
+ // 不自动轮换(重启后密码不变,用户可用「自定义密码」主动更换)。
258
+ onTunnelReady: (mode) => {
259
+ if (mode === 'named') {
260
+ logger.info('dsh-pocket: named tunnel ready — PIN not rotated | 命名隧道就绪(公网密码不自动轮换)');
261
+ return getAccessToken();
262
+ }
249
263
  const fresh = rotateAccessToken();
250
264
  logger.info('dsh-pocket: public access PIN refreshed | 公网访问密码已更新(自定义密码不受影响)');
251
265
  return fresh;
@@ -267,6 +281,16 @@ export function apply(ctx, config = {}, internals = {}) {
267
281
  setLanIpOverride: (ip) => setLanIpOverride(ip),
268
282
  getPinCustom: (which) => pinCustom(which),
269
283
  setCustomPin: (which, value) => setCustomPin(which, value),
284
+ // 命名隧道配置(issue #66):RPC 只回显脱敏视图(token 只写不读)
285
+ getTunnelConfig: () => ({ mode: tunnelMode(), hostname: tunnelHostname(), tokenSet: tunnelToken().length > 0 }),
286
+ setTunnelConfig: ({ mode, hostname, token } = {}) => {
287
+ const m = mode === undefined ? tunnelMode() : mode;
288
+ if (m !== 'quick' && m !== 'named') throw new Error('未知隧道模式 | unknown tunnel mode');
289
+ setTunnelMode(m);
290
+ if (hostname !== undefined) setTunnelHostname(hostname);
291
+ if (token !== undefined) setTunnelToken(token);
292
+ return { mode: tunnelMode(), hostname: tunnelHostname(), tokenSet: tunnelToken().length > 0 };
293
+ },
270
294
  runUpdate: internals.runUpdate ?? { currentVersion, perform: performUpdate, loadedVersion: () => loadedVersion },
271
295
  restart: internals.restart ?? (() => pocketRestart(service)),
272
296
  restartNotice: internals.restartNotice ?? consumeRestartNotice,
package/lib/proxy.mjs CHANGED
@@ -187,9 +187,38 @@ button{width:100%;padding:10px;font-size:15px;background:#4f6ef7;color:#fff;bord
187
187
  </div></body></html>`;
188
188
  }
189
189
 
190
- /** 该 Host 是否受访问密码保护(公网隧道;局域网 IP 直连免密码)。 */
190
+ /**
191
+ * Host 信任边界分类(issue #66:fail closed)。
192
+ *
193
+ * 旧逻辑只认 `*.trycloudflare.com` 后缀为公网,其余一律当局域网——用户自建
194
+ * 命名隧道/反向代理指向本机端口时,固定域名被误判成局域网;若局域网密码又
195
+ * 关着,公网入口就无密码裸奔。现在反转为 fail closed:
196
+ * - loopback:localhost / 127.x / ::1 / 0.0.0.0(本机与 cloudflared 回连)
197
+ * - lan:RFC1918 私网 IPv4、IPv6 ULA/link-local、`.local`(mDNS)、
198
+ * 无点单标签名(NetBIOS 计算机名等——公网 DNS 不可能解析它们)
199
+ * - public:其余一切 Host(trycloudflare 或任何陌生域名)→ 强制公网密码
200
+ *
201
+ * @returns {'loopback'|'lan'|'public'}
202
+ */
203
+ export function classifyHost(host) {
204
+ let name = String(host ?? '').trim().toLowerCase();
205
+ if (name.startsWith('[')) {
206
+ const end = name.indexOf(']');
207
+ if (end >= 0) name = name.slice(1, end); // [::1]:3081 → ::1
208
+ } else {
209
+ name = name.replace(/:\d+$/, ''); // hostname:3081 / 127.0.0.1:3081 → 去掉端口
210
+ }
211
+ if (name === 'localhost' || name === '0.0.0.0' || name === '::1' || /^127\./.test(name)) return 'loopback';
212
+ if (/^(?:10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.)/.test(name)) return 'lan'; // RFC1918
213
+ if (/^(?:fe80:|f[cd][0-9a-f]{2}:)/.test(name) && name.includes(':')) return 'lan'; // IPv6 link-local / ULA
214
+ if (name === '' || name.includes(':')) return 'loopback'; // 裸 IPv6 / 无 Host → 当本机
215
+ if (name.endsWith('.local') || !name.includes('.')) return 'lan'; // mDNS / NetBIOS 单标签名
216
+ return 'public';
217
+ }
218
+
219
+ /** 该 Host 是否受访问密码保护(公网一律要密码;局域网/本机按设置页开关)。 */
191
220
  function isProtectedHost(host, isProtected) {
192
- return isProtected ? isProtected(host) : /trycloudflare\.com$/i.test(String(host ?? ''));
221
+ return isProtected ? isProtected(host) : classifyHost(host) === 'public';
193
222
  }
194
223
 
195
224
  /**
@@ -323,9 +352,10 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
323
352
  const limiter = auth ? createRateLimiter(rateLimit ?? {}) : null;
324
353
  const server = createServer((req, res) => {
325
354
  const host = String(req.headers.host ?? '');
326
- const isPublic = /trycloudflare\.com$/i.test(host);
355
+ const isPublic = classifyHost(host) === 'public';
327
356
  // 局域网访问关闭(issue #54):拦截经局域网 IP/主机名访问的请求;
328
- // 公网(trycloudflare)与 loopback(本机/cloudflared 回连)放行。
357
+ // 公网(含任意非内网 Host——issue #66 fail closed)与 loopback(本机/cloudflared 回连)放行,
358
+ // 公网流量随后照常走访问密码认证。
329
359
  if (!isPublic && !isLoopbackHost(host) && !lanAccessEnabled()) {
330
360
  if (isHtmlRequest(req)) {
331
361
  res.writeHead(403, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
@@ -471,8 +501,8 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
471
501
  // WebSocket upgrade(DSH 的 /api/events.mux + events.host 流式通道)原样透传
472
502
  server.on('upgrade', (req, socket, head) => {
473
503
  const host = String(req.headers.host ?? '');
474
- const isPublic = /trycloudflare\.com$/i.test(host);
475
- // 局域网访问关闭:拦截经局域网 Host 的 WS 握手(公网/loopback 放行)
504
+ const isPublic = classifyHost(host) === 'public';
505
+ // 局域网访问关闭:拦截经局域网 Host 的 WS 握手(公网/loopback 放行——issue #66 fail closed)
476
506
  if (!isPublic && !isLoopbackHost(host) && !lanAccessEnabled()) {
477
507
  socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
478
508
  socket.destroy();
package/lib/service.mjs CHANGED
@@ -13,7 +13,7 @@ import { execFile } from 'node:child_process';
13
13
  import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
14
14
  import { join, dirname } from 'node:path';
15
15
  import { createPocketProxy } from './proxy.mjs';
16
- import { startQuickTunnel } from './tunnel.mjs';
16
+ import { startQuickTunnel, startNamedTunnel } from './tunnel.mjs';
17
17
  import { isValidIpv4 } from './ip.mjs';
18
18
 
19
19
  const require = createRequire(import.meta.url);
@@ -180,11 +180,14 @@ export function createPocketService({
180
180
  injectHtml,
181
181
  /** 访问令牌认证配置(issue #13):{ getToken, isProtected },传给代理 */
182
182
  auth,
183
- /** 隧道就绪回调(lib/index.js 用它轮换公网密码) */
183
+ /** 隧道配置(issue #66):() => ({ mode:'quick'|'named', token, hostname });named 用固定域名 */
184
+ getTunnelConfig,
185
+ /** 隧道就绪回调(lib/index.js 用它轮换公网密码;参数为 'quick'|'named') */
184
186
  onTunnelReady,
185
187
  } = {}) {
186
188
  const createProxy = internals.createProxy ?? createPocketProxy;
187
189
  const startTunnel = internals.startTunnel ?? startQuickTunnel;
190
+ const startNamed = internals.startNamedTunnel ?? startNamedTunnel;
188
191
  const getLanOverride = () => {
189
192
  const value = String(getLanIpOverride?.() ?? '').trim();
190
193
  return isValidIpv4(value) ? value : '';
@@ -285,9 +288,22 @@ export function createPocketService({
285
288
  };
286
289
  tunnelPromise = (async () => {
287
290
  try {
288
- const result = await startTunnel({ port: proxy.port, home, signal: controller.signal, onPhase });
289
- // 归一化:startTunnel 契约返回 {url, kill}(字符串也兼容)
290
- tunnel = typeof result === 'string' ? { url: result, kill: () => {} } : result;
291
+ // 命名隧道(issue #66):固定域名模式——URL 由设置里的域名拼出(cloudflared 不打印)
292
+ const cfg = typeof getTunnelConfig === 'function' ? (getTunnelConfig() ?? {}) : {};
293
+ if (cfg?.mode === 'named') {
294
+ if (!cfg.token || !cfg.hostname) {
295
+ throw new Error(
296
+ '命名隧道未配置完整:需要 Tunnel Token 和固定域名(设置页「固定域名」里填写) | '
297
+ + 'named tunnel is incomplete — set the Tunnel Token and the fixed hostname in Settings',
298
+ );
299
+ }
300
+ const result = await startNamed({ token: cfg.token, home, signal: controller.signal, onPhase });
301
+ tunnel = { url: `https://${cfg.hostname}`, kill: result.kill, onExit: result.onExit };
302
+ } else {
303
+ const result = await startTunnel({ port: proxy.port, home, signal: controller.signal, onPhase });
304
+ // 归一化:startTunnel 契约返回 {url, kill}(字符串也兼容)
305
+ tunnel = typeof result === 'string' ? { url: result, kill: () => {} } : result;
306
+ }
291
307
  tunnelState.phase = 'ready';
292
308
  // M1:隧道进程运行中死亡(崩溃/被杀)→ 状态打回,别让 UI 永远显示"可用"
293
309
  tunnel.onExit?.((code) => {
@@ -297,8 +313,8 @@ export function createPocketService({
297
313
  });
298
314
  // 记录「隧道开启中」,供重启后自动恢复(issue #11)
299
315
  void persistAutoTunnel();
300
- // 公网隧道就绪 → 轮换访问密码(issue #13:每次开启变新,旧链接作废)
301
- try { onTunnelReady?.(); } catch { /* 忽略 */ }
316
+ // 公网隧道就绪 → 回调(快速模式轮换访问密码;命名模式不轮换,见 lib/index.js)
317
+ try { onTunnelReady?.(cfg?.mode === 'named' ? 'named' : 'quick'); } catch { /* 忽略 */ }
302
318
  return tunnel.url;
303
319
  } catch (err) {
304
320
  // stopTunnel 触发的 abort 不算错误:保持 idle,别把状态刷成 error
@@ -368,6 +384,15 @@ export function createPocketService({
368
384
  tunnelUrl: tunnel?.url ?? null,
369
385
  tunnelQr: await qrCached(tunnel?.url ?? null),
370
386
  tunnelState: { ...tunnelState },
387
+ // 隧道配置脱敏视图(issue #66):token 永不回显,只有 tokenSet 布尔值
388
+ tunnelConfig: (() => {
389
+ const cfg = typeof getTunnelConfig === 'function' ? (getTunnelConfig() ?? {}) : {};
390
+ return {
391
+ mode: cfg?.mode === 'named' ? 'named' : 'quick',
392
+ hostname: typeof cfg?.hostname === 'string' ? cfg.hostname : '',
393
+ tokenSet: Boolean(cfg?.token),
394
+ };
395
+ })(),
371
396
  dshPort,
372
397
  };
373
398
  },
package/lib/settings.mjs CHANGED
@@ -5,6 +5,9 @@
5
5
  // - lanAuthEnabled 局域网访问密码开关(issue #24),默认开启
6
6
  // - publicPinCustom 公网密码是否用户自定义(issue #33),自定义后不自动轮换
7
7
  // - lanPinCustom 局域网密码是否用户自定义(issue #33)
8
+ // - tunnelMode 公网隧道模式(issue #66):'quick'(默认,随机 trycloudflare.com)| 'named'(固定域名)
9
+ // - tunnelToken Cloudflare 命名隧道 Token(issue #66,秘密;文件 0o600,RPC 不回显)
10
+ // - tunnelHostname 命名隧道绑定的固定域名(issue #66,如 pocket.example.com)
8
11
  // 默认**开启**(安全优先):局域网扫码也要输 8 位密码;
9
12
  // 用户可关闭——关闭后局域网扫码直连(仅同一网络内的设备能访问),公网不受影响(永远要密码)。
10
13
 
@@ -99,3 +102,78 @@ export function setPinCustom(which, on) {
99
102
  writeSettings(s);
100
103
  return !!on;
101
104
  }
105
+
106
+ // ---------- 命名隧道配置(issue #66:固定公网域名) ----------
107
+ // 用户在 Cloudflare Zero Trust(Networks → Tunnels)创建命名隧道并复制 Tunnel Token,
108
+ // 把自己域名的 ingress Service 指向 http://127.0.0.1:<代理端口>;填到这里后,
109
+ // 开启公网改用 `cloudflared tunnel run`,公网地址固定为该域名(重启不再变化)。
110
+ // token 是长期凭据:只存本机 0o600 文件,RPC 只写不读(回显仅 tokenSet 布尔值)。
111
+
112
+ /** 隧道模式:'quick'(默认,随机地址)| 'named'(固定域名)。 */
113
+ export function tunnelMode() {
114
+ return readSettings().tunnelMode === 'named' ? 'named' : 'quick';
115
+ }
116
+
117
+ /** 设置隧道模式(持久化);非法值抛错。 */
118
+ export function setTunnelMode(mode) {
119
+ if (mode !== 'quick' && mode !== 'named') {
120
+ throw new Error('隧道模式必须是 quick 或 named | tunnel mode must be quick or named');
121
+ }
122
+ const s = readSettings();
123
+ if (mode === 'quick') delete s.tunnelMode;
124
+ else s.tunnelMode = mode;
125
+ writeSettings(s);
126
+ return mode;
127
+ }
128
+
129
+ /** 命名隧道 Token(未配置返回空字符串)。 */
130
+ export function tunnelToken() {
131
+ const v = readSettings().tunnelToken;
132
+ return typeof v === 'string' ? v : '';
133
+ }
134
+
135
+ /**
136
+ * 设置命名隧道 Token(持久化)。空字符串清除;非空要求至少 20 个
137
+ * base64url 字符(Cloudflare Token 是长 base64 串,过短/含空白视为无效)。
138
+ */
139
+ export function setTunnelToken(value) {
140
+ const v = String(value ?? '').trim();
141
+ if (v) {
142
+ if (v.length < 20 || !/^[A-Za-z0-9+/_=-]+$/.test(v)) {
143
+ throw new Error('Tunnel Token 格式不对(应为 Cloudflare 后台复制的完整 Token) | invalid tunnel token');
144
+ }
145
+ }
146
+ const s = readSettings();
147
+ if (v) s.tunnelToken = v;
148
+ else delete s.tunnelToken;
149
+ writeSettings(s);
150
+ return v;
151
+ }
152
+
153
+ /** 命名隧道绑定的固定域名(未配置返回空字符串)。 */
154
+ export function tunnelHostname() {
155
+ const v = readSettings().tunnelHostname;
156
+ return typeof v === 'string' ? v : '';
157
+ }
158
+
159
+ /**
160
+ * 设置固定域名(持久化)。接受 `https://host/path` 粘贴并归一化为裸域名;
161
+ * 空字符串清除。要求是带点的合法公网域名(局域网主机名/裸 IP 不允许——
162
+ * 那不该走公网密码边界之外的东西)。
163
+ */
164
+ export function setTunnelHostname(value) {
165
+ let v = String(value ?? '').trim().toLowerCase();
166
+ v = v.replace(/^[a-z][a-z0-9+.-]*:\/\//, '').split(/[/?#\s]/)[0].replace(/:\d+$/, '').replace(/\.$/, '');
167
+ if (v) {
168
+ const HOSTNAME_RE = /^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/;
169
+ const IS_IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/;
170
+ if (IS_IPV4.test(v) || !v.includes('.') || !HOSTNAME_RE.test(v)) {
171
+ throw new Error('固定域名格式不对(如 pocket.example.com) | invalid tunnel hostname');
172
+ }
173
+ }
174
+ const s = readSettings();
175
+ if (v) s.tunnelHostname = v;
176
+ else delete s.tunnelHostname;
177
+ writeSettings(s);
178
+ return v;
179
+ }
package/lib/tunnel.mjs CHANGED
@@ -1,7 +1,10 @@
1
- // cloudflared 快速隧道:把本机代理暴露成公网 https URL
1
+ // cloudflared 隧道:把本机代理暴露成公网 https URL
2
2
  //
3
- // 手机在任何网络都能访问;URL 由 cloudflared 随机分配(每次重启会变)。
4
- // 无密码模式:URL 即钥匙(dsh web 能执行代码,请勿把二维码/URL 发给别人)。
3
+ // 两条路径(issue #66):
4
+ // - 快速隧道 startQuickTunnel:URL cloudflared 随机分配(每次重启会变),零配置;
5
+ // - 命名隧道 startNamedTunnel:用户自带 Cloudflare Tunnel Token + 固定域名,重启地址不变。
6
+ //
7
+ // 手机在任何网络都能访问。公网一律要求访问密码(dsh web 能执行代码,请勿泄露二维码/URL)。
5
8
 
6
9
  import { spawn, execSync } from 'node:child_process';
7
10
  import { mkdir, access, chmod, rm, stat, rename, cp, open } from 'node:fs/promises';
@@ -347,6 +350,110 @@ export async function resolveCloudflared({ home, onPhase = () => {}, signal } =
347
350
  * @param {(phase:string)=>void} [opts.onPhase] 进度回调:downloading→starting→registering→ready
348
351
  * @returns {Promise<{url:string, kill:()=>void}>}
349
352
  */
353
+ /**
354
+ * 启动 cloudflared 命名隧道(issue #66:固定公网域名)。
355
+ *
356
+ * 用户在 Cloudflare Zero Trust 后台创建 Tunnel、把域名 ingress 的 Service 指向
357
+ * `http://127.0.0.1:<port>`,复制 Tunnel Token 填进设置页。与快速隧道的区别:
358
+ * - Token 走 `TUNNEL_TOKEN` **环境变量**(不进 argv——长期凭据不该出现在
359
+ * 进程列表/崩溃日志里,issue #66 讨论中的一致意见);
360
+ * - URL 固定为用户绑定的域名(cloudflared 输出不打印它),由调用方拼 `https://<域名>`,
361
+ * 所以这里返回 `url: null`;
362
+ * - 就绪判据是 stderr 出现 `Registered tunnel connection`(边缘连接注册成功即开始服务)。
363
+ * @param {object} opts
364
+ * @param {string} opts.token Cloudflare Tunnel Token
365
+ * @param {string} [opts.home] $DSH_HOME(cloudflared 持久缓存)
366
+ * @param {AbortSignal} [opts.signal]
367
+ * @param {(phase:string)=>void} [opts.onPhase] 进度回调:downloading→starting→registering→ready
368
+ * @returns {Promise<{url:null, kill:()=>void, onExit:(cb)=>()=>void}>}
369
+ */
370
+ export async function startNamedTunnel({ token, home, signal, onPhase = () => {} }) {
371
+ const bin = await resolveCloudflared({ home, onPhase, signal });
372
+ onPhase('starting');
373
+ // 与快速隧道一致强制 HTTP/2(国内/企业网常屏蔽 UDP 7844 → error 1033)
374
+ const child = spawn(bin, ['tunnel', 'run', '--protocol', 'http2', '--no-autoupdate'], {
375
+ stdio: ['ignore', 'pipe', 'pipe'],
376
+ env: { ...process.env, TUNNEL_TOKEN: String(token ?? '') },
377
+ });
378
+ let cleanup = null;
379
+ let rejectErr = null;
380
+ // H1:spawn 失败(缓存二进制损坏等)必须接住,否则 uncaughtException 崩宿主
381
+ child.on('error', (err) => {
382
+ cleanup?.();
383
+ onPhase?.('error');
384
+ rejectErr?.(new Error(`cloudflared 启动失败:${err?.message ?? err}(可删除 $DSH_HOME/dsh-pocket/bin 缓存后重试)`));
385
+ });
386
+ onPhase('registering');
387
+
388
+ await new Promise((resolve, reject) => {
389
+ let buf = '';
390
+ const onData = (chunk) => {
391
+ buf += String(chunk);
392
+ // 边缘连接注册成功即开始服务(每条连接一行;等第一条就够)
393
+ if (/Registered tunnel connection/i.test(buf)) {
394
+ cleanup();
395
+ onPhase('ready');
396
+ resolve();
397
+ }
398
+ };
399
+ const onExit = (code) => {
400
+ cleanup();
401
+ const tail = buf.trim().split(/\r?\n/).slice(-4).join('\n').trim();
402
+ reject(new Error(
403
+ `cloudflared 退出(code=${code})${tail ? ':' + tail.slice(0, 500) : ''}`
404
+ + '——请检查 Tunnel Token 是否有效、域名 Service 是否指向本机代理端口 | tunnel exited — check the token and the ingress hostname',
405
+ ));
406
+ };
407
+ cleanup = () => {
408
+ child.stdout.off('data', onData);
409
+ child.stderr.off('data', onData);
410
+ child.off('exit', onExit);
411
+ clearTimeout(timer);
412
+ signal?.removeEventListener('abort', onAbort);
413
+ // M4:摘掉监听后管道不再消费 → 64KB 缓冲填满会阻塞 cloudflared → 继续吞掉输出
414
+ child.stdout.resume();
415
+ child.stderr.resume();
416
+ };
417
+ const onAbort = () => {
418
+ cleanup();
419
+ child.kill();
420
+ reject(new Error('已取消 | cancelled'));
421
+ };
422
+ const timer = setTimeout(() => {
423
+ cleanup();
424
+ child.kill();
425
+ reject(new Error(
426
+ 'cloudflared 启动超时(30s)——请检查 Tunnel Token 是否有效、域名 Service 是否指向本机代理端口,'
427
+ + '以及是否开着代理/VPN(Clash 等 TUN 模式会掐断隧道连接) | timeout — check the token, the ingress hostname, and quit any proxy/VPN (TUN mode)',
428
+ ));
429
+ }, 30_000);
430
+
431
+ child.stdout.on('data', onData);
432
+ child.stderr.on('data', onData);
433
+ child.once('exit', onExit);
434
+ signal?.addEventListener('abort', onAbort, { once: true });
435
+ rejectErr = reject;
436
+ });
437
+
438
+ // M1:隧道进程运行中死亡(崩溃/被杀)→ 通知监听方(service 据此把状态从 ready 打回)
439
+ const exitListeners = new Set();
440
+ child.on('exit', (code) => {
441
+ for (const cb of exitListeners) cb(code);
442
+ });
443
+
444
+ return {
445
+ url: null, // 固定域名由调用方(service)按设置拼 https://<hostname>
446
+ kill: () => {
447
+ try { child.kill(); } catch { /* 忽略 */ }
448
+ },
449
+ /** 注册「进程已退出」回调,返回取消函数。 */
450
+ onExit: (cb) => {
451
+ exitListeners.add(cb);
452
+ return () => exitListeners.delete(cb);
453
+ },
454
+ };
455
+ }
456
+
350
457
  export async function startQuickTunnel({ port, home, signal, onPhase = () => {} }) {
351
458
  const bin = await resolveCloudflared({ home, onPhase, signal });
352
459
  onPhase('starting');
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, getLanEnabled = null, setLanEnabled = null, getLanIpOverride = null, setLanIpOverride = null, getPinCustom = null, setCustomPin = 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, getLanEnabled = null, setLanEnabled = null, getLanIpOverride = null, setLanIpOverride = null, getPinCustom = null, setCustomPin = null, getTunnelConfig = null, setTunnelConfig = 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 () => {};
@@ -49,6 +49,7 @@ export function installPocketRpc(ctx, { service, log = console, desktop = false,
49
49
  lanEnabled: getLanEnabled?.() ?? true,
50
50
  publicPinCustom: getPinCustom?.('public') ?? false,
51
51
  lanPinCustom: getPinCustom?.('lan') ?? false,
52
+ tunnelConfig: getTunnelConfig?.() ?? { mode: 'quick', hostname: '', tokenSet: false },
52
53
  });
53
54
  };
54
55
 
@@ -94,6 +95,17 @@ export function installPocketRpc(ctx, { service, log = console, desktop = false,
94
95
  return fail('bad-request', err?.message ?? String(err));
95
96
  }
96
97
  }
98
+ if (endpoint === POCKET_ENDPOINTS.tunnelSetConfig) {
99
+ // 命名隧道配置(issue #66):{ mode, hostname, token }——token 只写不读,
100
+ // 留空表示保持不变(undefined 不覆盖);返回完整 status 供前端直接替换。
101
+ if (!setTunnelConfig) return fail('bad-request', '隧道配置不可用 | tunnel config unavailable');
102
+ try {
103
+ setTunnelConfig({ mode: payload?.mode, hostname: payload?.hostname, token: payload?.token });
104
+ return await statusPayload();
105
+ } catch (err) {
106
+ return fail('bad-request', err?.message ?? String(err));
107
+ }
108
+ }
97
109
  if (endpoint === POCKET_ENDPOINTS.tunnelStart) {
98
110
  // 安全免责声明(issue #31):每次开启公网都必须先确认(前端弹框勾选)。
99
111
  // 服务端强制校验,防止绕过前端直接调 RPC。
package/package.json CHANGED
@@ -80,5 +80,5 @@
80
80
  "access": "public",
81
81
  "registry": "https://registry.npmjs.org/"
82
82
  },
83
- "version": "1.15.0"
83
+ "version": "1.16.0"
84
84
  }