dsh-pocket 2.3.0 → 2.5.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
@@ -133,6 +133,8 @@ Note: in named-tunnel mode the public PIN is **not auto-rotated** (the address i
133
133
  | Nothing changed after install/update | **You must restart `dsh web`**; the running process still loads the old code |
134
134
  | `listen EADDRINUSE ... :3081` | A stale dsh-pocket process holds the port: macOS/Linux `lsof -ti :3081 \| xargs kill -9`; Windows `netstat -ano \| findstr :3081` (find the LISTENING PID) → `taskkill /PID <PID> /F`, then retry |
135
135
  | Want a different port (issue #70) | Plugin mode: write `"proxyPort": 3082` into `$DSH_HOME/dsh-pocket/settings.json` and restart `dsh web`. CLI mode: `dsh-pocket --port 3082`. If the port is taken you'll get `EADDRINUSE` — kill the old process or pick another one |
136
+ | Issue a temporary PIN to a guest (issue #69) | Settings → "Temporary access PINs" → pick public/LAN + duration (1h/24h/7d) + note → Create. Share the 8-char PIN + the access URL with the guest; it auto-expires and can be revoked manually. Temp PINs share the main PIN rate limit |
137
+ | cloudflared install fails on a remote Linux server (issue #45) | If all CDN sources (GitHub / ghproxy / gh.ddlc / gh-proxy) are unreachable on a remote Linux host, install `cloudflared` yourself (e.g. `apt install cloudflared`, `dnf install cloudflared`, or download the tgz and unpack it), then add `"cloudflaredPath": "/path/to/cloudflared"` into `$DSH_HOME/dsh-pocket/settings.json` and restart `dsh web`. The plugin will then use that binary directly and skip the auto-download |
136
138
  | Version stuck below 1.x | `^0.x` ranges never jump to 1.x: update with `--latest` (`dsh plugin --profile web update dsh-pocket --latest -w`) |
137
139
  | Public `error 1033` | See "Public tunnel troubleshooting" below — usually a local proxy/VPN (Clash etc. TUN mode) killing the tunnel |
138
140
  | After "Restart dsh web", the page says the process is running in the background | The new process from in-page self-restart is a detached background process (not attached to your terminal) — that's the standard way to apply updates in-page; stop it: macOS/Linux `lsof -ti :3080 \| xargs kill -9`; Windows `netstat -ano \| findstr :3080` → `taskkill /PID <PID> /F` (logs under `$DSH_HOME` as `dsh-pocket-restart-*.log`) |
@@ -192,7 +194,7 @@ Such tools take over all traffic and often cut cloudflared's tunnel-edge connect
192
194
  ```sh
193
195
  npm install
194
196
  node client/build.mjs # rebuild after editing client/
195
- npm test # proxy / auth / compression / tunnel / service / RPC / settings (93 tests)
197
+ npm test # proxy / auth / compression / tunnel / service / RPC / settings (105 tests)
196
198
  ```
197
199
 
198
200
  **Want to try your changes locally without publishing?** Point the installed plugin at your local checkout with a symlink and restart dsh web. Full steps (including switching back to the npm release) are in [LOCAL-DEV.md](./LOCAL-DEV.md).
package/README.md CHANGED
@@ -133,6 +133,8 @@ npx @deepseek-ai/dsh web
133
133
  | 装完/更新了但界面没变化 | **必须重启 `dsh web`** 才生效;运行中的进程仍加载旧代码 |
134
134
  | `listen EADDRINUSE ... :3081` | 旧 dsh-pocket 进程还占着端口:macOS/Linux `lsof -ti :3081 \| xargs kill -9`;Windows `netstat -ano \| findstr :3081`(找 LISTENING 的 PID)→ `taskkill /PID <PID> /F`,后重试 |
135
135
  | 想换端口(issue #70) | 插件模式:在 `$DSH_HOME/dsh-pocket/settings.json` 写 `"proxyPort": 3082` 后重启 `dsh web`。CLI 模式:`dsh-pocket --port 3082`。端口被占会报 `EADDRINUSE`,杀掉旧进程或换一个端口 |
136
+ | 给访客一个临时 PIN(issue #69) | 设置页「临时访问 PIN」区块 → 选公网/局域网 + 时长(1h/24h/7d) + 备注 → 生成。把 8 位 PIN + 入口 URL 一起发给对方;过期自动作废,也能手动撤销。临时 PIN 与主 PIN 共用速率限制 |
137
+ | Linux 服务器装不上 cloudflared(issue #45) | 远程 Linux 国内/企业网下所有 CDN 源(GitHub/ghproxy/gh.ddlc/gh-proxy)都连不上时:在服务器上手动装 `cloudflared`(如 `apt install cloudflared`、`dnf install cloudflared`、或下载 tgz 解压到任意目录),然后在 `$DSH_HOME/dsh-pocket/settings.json` 加 `"cloudflaredPath": "/path/to/cloudflared"`,重启 `dsh web` 后插件直接调用它,**不再走自动下载** |
136
138
  | 版本停在 0.x 升不上去 | `^0.x` 范围不允许升到 1.x:更新用 `--latest`(`dsh plugin --profile web update dsh-pocket --latest -w`) |
137
139
  | 公网 `error 1033` | 见下方「公网隧道常见问题」——多半是本机代理/VPN(Clash 等 TUN 模式)掐断了隧道 |
138
140
  | 点「重启 dsh web」后页面提示进程在后台运行 | 自重启的新进程是 detached 后台进程(不挂终端),是页内更新的标准做法;停止它:macOS/Linux `lsof -ti :3080 \| xargs kill -9`;Windows `netstat -ano \| findstr :3080` → `taskkill /PID <PID> /F`(日志在 `$DSH_HOME` 下 `dsh-pocket-restart-*.log`) |
@@ -193,7 +195,7 @@ npx @deepseek-ai/dsh web
193
195
  ```sh
194
196
  npm install
195
197
  node client/build.mjs # 改 client/ 后重新打包
196
- npm test # 代理 / 认证 / 压缩 / 隧道 / 服务 / RPC / 设置(93 测试)
198
+ npm test # 代理 / 认证 / 压缩 / 隧道 / 服务 / RPC / 设置(105 测试)
197
199
  ```
198
200
 
199
201
  **改完想在本机先试?** 不用发版:把插件换成指向本地仓库的软链,重启 dsh web 就是本地代码。完整步骤(含怎么换回 npm 官方版本)见 [LOCAL-DEV.md](./LOCAL-DEV.md)。
package/client/api.js CHANGED
@@ -15,6 +15,9 @@ export const POCKET_ENDPOINTS = Object.freeze({
15
15
  lanSetEnabled: 'lan.setEnabled',
16
16
  pinSetCustom: 'pin.setCustom',
17
17
  pocketReset: 'pocket.reset',
18
+ tempPinCreate: 'tempPin.create',
19
+ tempPinList: 'tempPin.list',
20
+ tempPinRevoke: 'tempPin.revoke',
18
21
  });
19
22
 
20
23
  /** 语义化版本比较:a > b 返回正数,相等 0,a < b 负数(数字段 + 预发布后缀)。 */
package/client/client.js CHANGED
@@ -54,7 +54,10 @@ var POCKET_ENDPOINTS = Object.freeze({
54
54
  lanSetOverride: "lan.setOverride",
55
55
  lanSetEnabled: "lan.setEnabled",
56
56
  pinSetCustom: "pin.setCustom",
57
- pocketReset: "pocket.reset"
57
+ pocketReset: "pocket.reset",
58
+ tempPinCreate: "tempPin.create",
59
+ tempPinList: "tempPin.list",
60
+ tempPinRevoke: "tempPin.revoke"
58
61
  });
59
62
  function compareVersions(a, b) {
60
63
  const pa = String(a).replace(/^[vV]/, "").split(".");
@@ -1552,7 +1555,26 @@ var zh2 = {
1552
1555
  "slowHint": " \u2014 \u6709\u70B9\u4E45\uFF1F\u68C0\u67E5\u662F\u5426\u5F00\u7740\u4EE3\u7406/VPN\uFF08Clash TUN \u7B49\uFF09",
1553
1556
  "error": "\u274C \u5F00\u542F\u5931\u8D25\uFF1A{detail}\uFF08\u53EF\u91CD\u8BD5\uFF1B\u82E5\u662F\u4EE3\u7406/VPN \u95EE\u9898\u89C1 README \u6392\u969C\uFF09",
1554
1557
  "unknownError": "\u672A\u77E5\u9519\u8BEF",
1555
- "feedback": "\u6709\u95EE\u9898\uFF1F\u6B22\u8FCE\u5230 GitHub Issues \u53CD\u9988 \u{1F64F}"
1558
+ "feedback": "\u6709\u95EE\u9898\uFF1F\u6B22\u8FCE\u5230 GitHub Issues \u53CD\u9988 \u{1F64F}",
1559
+ // 临时 PIN(issue #69)
1560
+ "tempPinTitle": "\u4E34\u65F6\u8BBF\u95EE PIN\uFF08\u5E26\u8D85\u65F6\uFF09",
1561
+ "tempPinSubtitle": "\u7ED9\u8BBF\u5BA2\u5F00\u4E00\u4E2A\u6709\u5BFF\u547D\u7684 8 \u4F4D PIN\uFF0C\u8FC7\u671F\u81EA\u52A8\u4F5C\u5E9F\uFF1B\u4E0E\u4E3B PIN \u5171\u7528\u901F\u7387\u9650\u5236\u3002",
1562
+ "tempPinKindPublic": "\u516C\u7F51",
1563
+ "tempPinKindLan": "\u5C40\u57DF\u7F51",
1564
+ "tempPinDuration": "\u6709\u6548\u65F6\u957F",
1565
+ "tempPinDuration1h": "1 \u5C0F\u65F6",
1566
+ "tempPinDuration24h": "24 \u5C0F\u65F6",
1567
+ "tempPinDuration7d": "7 \u5929",
1568
+ "tempPinLabel": "\u5907\u6CE8\uFF08\u53EF\u9009\uFF0C\u7ED9\u8C01\u7528\uFF09",
1569
+ "tempPinLabelPh": "\u670B\u53CB\u5C0F\u738B",
1570
+ "tempPinCreate": "\u751F\u6210",
1571
+ "tempPinCreated": "\u5DF2\u751F\u6210\uFF08\u4EC5\u663E\u793A\u4E00\u6B21\uFF09",
1572
+ "tempPinCopied": "\u5DF2\u590D\u5236",
1573
+ "tempPinCopy": "\u590D\u5236",
1574
+ "tempPinEmpty": "\u6682\u65E0\u4E34\u65F6 PIN",
1575
+ "tempPinExpiresIn": "\u8FC7\u671F",
1576
+ "tempPinRevoke": "\u64A4\u9500",
1577
+ "tempPinRevoked": "\u5DF2\u64A4\u9500"
1556
1578
  };
1557
1579
  var en2 = {
1558
1580
  "section": "Phone access",
@@ -1647,7 +1669,26 @@ var en2 = {
1647
1669
  "slowHint": " \u2014 taking long? Check for a proxy/VPN (e.g., Clash TUN)",
1648
1670
  "error": "\u274C Failed to enable: {detail} (you can retry; for proxy/VPN issues see the README)",
1649
1671
  "unknownError": "unknown error",
1650
- "feedback": "\u{1F64F} Questions? Open an issue on GitHub"
1672
+ "feedback": "\u{1F64F} Questions? Open an issue on GitHub",
1673
+ // Temp PIN (issue #69)
1674
+ "tempPinTitle": "Temporary access PINs (auto-expire)",
1675
+ "tempPinSubtitle": "Issue a time-limited 8-char PIN to a guest; expires automatically. Shares the main PIN rate limit.",
1676
+ "tempPinKindPublic": "Public",
1677
+ "tempPinKindLan": "LAN",
1678
+ "tempPinDuration": "Valid for",
1679
+ "tempPinDuration1h": "1 hour",
1680
+ "tempPinDuration24h": "24 hours",
1681
+ "tempPinDuration7d": "7 days",
1682
+ "tempPinLabel": "Note (optional, who is this for)",
1683
+ "tempPinLabelPh": "Friend Alice",
1684
+ "tempPinCreate": "Create",
1685
+ "tempPinCreated": "Created (shown once)",
1686
+ "tempPinCopied": "Copied",
1687
+ "tempPinCopy": "Copy",
1688
+ "tempPinEmpty": "No temporary PINs yet",
1689
+ "tempPinExpiresIn": "expires",
1690
+ "tempPinRevoke": "Revoke",
1691
+ "tempPinRevoked": "Revoked"
1651
1692
  };
1652
1693
 
1653
1694
  // client/index.jsx
@@ -1782,6 +1823,73 @@ function PocketSettingsTab({ rpcCall, t }) {
1782
1823
  setUpdateInfo((u) => ({ ...u, updating: false, result: "fail", output: err.message }));
1783
1824
  }
1784
1825
  };
1826
+ const [tempPinForm, setTempPinForm] = (0, import_react2.useState)({ kind: "lan", expiresInSec: 86400, label: "" });
1827
+ const [tempPinsList, setTempPinsList] = (0, import_react2.useState)([]);
1828
+ const [tempPinLast, setTempPinLast] = (0, import_react2.useState)(null);
1829
+ const [tempPinBusy, setTempPinBusy] = (0, import_react2.useState)(false);
1830
+ const [tempPinError, setTempPinError] = (0, import_react2.useState)(null);
1831
+ const loadTempPins = async () => {
1832
+ try {
1833
+ const r = await call(POCKET_ENDPOINTS.tempPinList, {});
1834
+ setTempPinsList(Array.isArray(r?.tempPins) ? r.tempPins : []);
1835
+ } catch {
1836
+ }
1837
+ };
1838
+ (0, import_react2.useEffect)(() => {
1839
+ loadTempPins();
1840
+ }, []);
1841
+ (0, import_react2.useEffect)(() => {
1842
+ if (tempPinsList.length === 0 && !tempPinLast) return;
1843
+ const t2 = setInterval(() => {
1844
+ loadTempPins();
1845
+ }, 3e4);
1846
+ return () => clearInterval(t2);
1847
+ }, [tempPinsList.length, tempPinLast]);
1848
+ const createTempPinNow = async () => {
1849
+ setTempPinBusy(true);
1850
+ setTempPinError(null);
1851
+ try {
1852
+ const r = await call(POCKET_ENDPOINTS.tempPinCreate, { kind: tempPinForm.kind, expiresInSec: tempPinForm.expiresInSec, label: tempPinForm.label });
1853
+ setTempPinLast(r);
1854
+ setTempPinForm((f) => ({ ...f, label: "" }));
1855
+ await loadTempPins();
1856
+ } catch (err) {
1857
+ setTempPinError(err.message);
1858
+ } finally {
1859
+ setTempPinBusy(false);
1860
+ }
1861
+ };
1862
+ const revokeTempPinNow = async (p) => {
1863
+ try {
1864
+ await call(POCKET_ENDPOINTS.tempPinRevoke, { value: p.value, kind: p.kind });
1865
+ showToast(t("tempPinRevoked"));
1866
+ if (tempPinLast?.value === p.value) setTempPinLast(null);
1867
+ await loadTempPins();
1868
+ } catch (err) {
1869
+ setError(err.message);
1870
+ }
1871
+ };
1872
+ const copyTempPin = async (value) => {
1873
+ try {
1874
+ if (navigator.clipboard?.writeText) {
1875
+ await navigator.clipboard.writeText(value);
1876
+ showToast(t("tempPinCopied"));
1877
+ }
1878
+ } catch {
1879
+ }
1880
+ };
1881
+ const formatRemaining = (expiresAt) => {
1882
+ const ms = expiresAt - Date.now();
1883
+ if (ms <= 0) return "0s";
1884
+ const s = Math.floor(ms / 1e3);
1885
+ if (s < 60) return `${s}s`;
1886
+ const m = Math.floor(s / 60);
1887
+ if (m < 60) return `${m}m`;
1888
+ const h2 = Math.floor(m / 60);
1889
+ if (h2 < 24) return `${h2}h`;
1890
+ const d = Math.floor(h2 / 24);
1891
+ return `${d}d`;
1892
+ };
1785
1893
  const [disclaimerOpen, setDisclaimerOpen] = (0, import_react2.useState)(false);
1786
1894
  const [disclaimerChecked, setDisclaimerChecked] = (0, import_react2.useState)(false);
1787
1895
  const doStartTunnel = async () => {
@@ -2211,6 +2319,77 @@ function PocketSettingsTab({ rpcCall, t }) {
2211
2319
  ) : null
2212
2320
  ),
2213
2321
  error ? (0, import_react2.createElement)("div", { style: { color: "var(--dsw-alias-state-error-primary,#dc2626)", fontSize: 12, marginTop: 8 } }, `\u274C ${errText(error)}`) : null,
2322
+ // 临时 PIN(issue #69):带过期的访问密码,给访客用
2323
+ (0, import_react2.createElement)(
2324
+ "div",
2325
+ { style: styles.block },
2326
+ (0, import_react2.createElement)("div", { style: { fontWeight: 600, fontSize: 13, marginBottom: 4 } }, t("tempPinTitle")),
2327
+ (0, import_react2.createElement)("div", { style: { ...styles.muted, marginBottom: 10 } }, t("tempPinSubtitle")),
2328
+ // 生成表单:分类 + 时长 + 备注 + 生成
2329
+ (0, import_react2.createElement)(
2330
+ "div",
2331
+ { style: { display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" } },
2332
+ (0, import_react2.createElement)(
2333
+ "select",
2334
+ {
2335
+ value: tempPinForm.kind,
2336
+ onChange: (e) => setTempPinForm((f) => ({ ...f, kind: e.target.value })),
2337
+ style: { font: "inherit", height: 30, padding: "0 8px", borderRadius: 8, border: "1px solid var(--dsw-alias-border-l2,#d1d5db)", background: "var(--dsw-alias-bg-layer-1,#fff)", color: "var(--dsw-alias-label-primary,inherit)" }
2338
+ },
2339
+ (0, import_react2.createElement)("option", { value: "public" }, t("tempPinKindPublic")),
2340
+ (0, import_react2.createElement)("option", { value: "lan" }, t("tempPinKindLan"))
2341
+ ),
2342
+ (0, import_react2.createElement)(
2343
+ "select",
2344
+ {
2345
+ value: String(tempPinForm.expiresInSec),
2346
+ onChange: (e) => setTempPinForm((f) => ({ ...f, expiresInSec: Number(e.target.value) })),
2347
+ style: { font: "inherit", height: 30, padding: "0 8px", borderRadius: 8, border: "1px solid var(--dsw-alias-border-l2,#d1d5db)", background: "var(--dsw-alias-bg-layer-1,#fff)", color: "var(--dsw-alias-label-primary,inherit)" }
2348
+ },
2349
+ (0, import_react2.createElement)("option", { value: "3600" }, t("tempPinDuration1h")),
2350
+ (0, import_react2.createElement)("option", { value: "86400" }, t("tempPinDuration24h")),
2351
+ (0, import_react2.createElement)("option", { value: "604800" }, t("tempPinDuration7d"))
2352
+ ),
2353
+ (0, import_react2.createElement)("input", {
2354
+ style: { flex: 1, minWidth: 120, height: 30, padding: "0 10px", borderRadius: 8, border: "1px solid var(--dsw-alias-border-l2,#d1d5db)", fontSize: 13, outline: "none" },
2355
+ placeholder: t("tempPinLabelPh"),
2356
+ value: tempPinForm.label,
2357
+ onChange: (e) => setTempPinForm((f) => ({ ...f, label: e.target.value })),
2358
+ onKeyDown: (e) => {
2359
+ if (e.key === "Enter") createTempPinNow();
2360
+ }
2361
+ }),
2362
+ (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 30 }, onClick: createTempPinNow, disabled: tempPinBusy }, t("tempPinCreate"))
2363
+ ),
2364
+ // 错误展示
2365
+ tempPinError ? (0, import_react2.createElement)("div", { style: { color: "var(--dsw-alias-state-error-primary,#dc2626)", fontSize: 12, marginTop: 6 } }, errText(tempPinError)) : null,
2366
+ // 刚生成的那一个(高亮,下一秒会消失提示用户立刻复制)
2367
+ tempPinLast ? (0, import_react2.createElement)(
2368
+ "div",
2369
+ { style: { marginTop: 10, padding: "8px 10px", borderRadius: 8, background: "var(--dsw-alias-bg-layer-2,#f3f4f6)", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, flexWrap: "wrap" } },
2370
+ (0, import_react2.createElement)(
2371
+ "div",
2372
+ null,
2373
+ (0, import_react2.createElement)("div", { style: { fontSize: 11, color: "var(--dsw-alias-state-success-primary,#16a34a)", fontWeight: 600, marginBottom: 2 } }, `${t("tempPinCreated")} \xB7 ${tempPinLast.kind === "public" ? t("tempPinKindPublic") : t("tempPinKindLan")} \xB7 ${formatRemaining(tempPinLast.expiresAt)}`),
2374
+ (0, import_react2.createElement)("span", { style: { fontFamily: "ui-monospace,Menlo,monospace", fontSize: 16, letterSpacing: 2, fontWeight: 600 } }, tempPinLast.value)
2375
+ ),
2376
+ (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 28, padding: "0 12px", fontSize: 12 }, onClick: () => copyTempPin(tempPinLast.value) }, t("tempPinCopy"))
2377
+ ) : null,
2378
+ // 列表
2379
+ (0, import_react2.createElement)(
2380
+ "div",
2381
+ { style: { marginTop: 12, borderTop: "1px solid var(--dsw-alias-border-l2,#e5e7eb)", paddingTop: 8 } },
2382
+ tempPinsList.length === 0 ? (0, import_react2.createElement)("div", { style: { ...styles.muted, padding: "6px 0" } }, t("tempPinEmpty")) : tempPinsList.map((p) => (0, import_react2.createElement)(
2383
+ "div",
2384
+ { key: p.value, style: { display: "flex", alignItems: "center", gap: 8, padding: "6px 0", borderBottom: "1px solid var(--dsw-alias-border-l2,#e5e7eb)" } },
2385
+ (0, import_react2.createElement)("span", { style: { fontSize: 11, color: "var(--dsw-alias-label-tertiary,#8b93a1)", minWidth: 50 } }, p.kind === "public" ? t("tempPinKindPublic") : t("tempPinKindLan")),
2386
+ (0, import_react2.createElement)("span", { style: { fontFamily: "ui-monospace,Menlo,monospace", fontSize: 13, letterSpacing: 1, flexShrink: 0 } }, p.value),
2387
+ (0, import_react2.createElement)("span", { style: { ...styles.muted, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, p.label || "\u2014"),
2388
+ (0, import_react2.createElement)("span", { style: { fontSize: 11, color: "var(--dsw-alias-label-tertiary,#8b93a1)" } }, `${t("tempPinExpiresIn")} ${formatRemaining(p.expiresAt)}`),
2389
+ (0, import_react2.createElement)("button", { style: { ...styles.btn, height: 24, padding: "0 10px", fontSize: 11 }, onClick: () => revokeTempPinNow(p) }, t("tempPinRevoke"))
2390
+ ))
2391
+ )
2392
+ ),
2214
2393
  // 恢复出厂设置:设置出问题时的临时兜底(最底部,避免误触)
2215
2394
  (0, import_react2.createElement)(
2216
2395
  "div",
package/client/index.jsx CHANGED
@@ -165,6 +165,76 @@ function PocketSettingsTab({ rpcCall, t }) {
165
165
 
166
166
  // 安全免责声明(issue #31):每次开启公网都必须先弹框勾选「我已知情」。
167
167
  // 服务端同样强制(tunnel.start 需 disclaimer: true),防绕过前端直接调 RPC。
168
+ // 临时 PIN(issue #69):表单、刚生成项、列表
169
+ const [tempPinForm, setTempPinForm] = useState({ kind: 'lan', expiresInSec: 86400, label: '' });
170
+ const [tempPinsList, setTempPinsList] = useState([]);
171
+ const [tempPinLast, setTempPinLast] = useState(null); // { value, kind, expiresAt, label }
172
+ const [tempPinBusy, setTempPinBusy] = useState(false);
173
+ const [tempPinError, setTempPinError] = useState(null);
174
+
175
+ const loadTempPins = async () => {
176
+ try {
177
+ const r = await call(POCKET_ENDPOINTS.tempPinList, {});
178
+ setTempPinsList(Array.isArray(r?.tempPins) ? r.tempPins : []);
179
+ } catch { /* 忽略 */ }
180
+ };
181
+ useEffect(() => { loadTempPins(); }, []);
182
+
183
+ // 每 30 秒刷新一次(让剩余时间实时倒计时显示)
184
+ useEffect(() => {
185
+ if (tempPinsList.length === 0 && !tempPinLast) return;
186
+ const t = setInterval(() => {
187
+ // 触发 setNow 已有的 tick 重新渲染即可;这里只重新拉一次(处理过期自动消失)
188
+ loadTempPins();
189
+ }, 30_000);
190
+ return () => clearInterval(t);
191
+ }, [tempPinsList.length, tempPinLast]);
192
+
193
+ const createTempPinNow = async () => {
194
+ setTempPinBusy(true);
195
+ setTempPinError(null);
196
+ try {
197
+ const r = await call(POCKET_ENDPOINTS.tempPinCreate, { kind: tempPinForm.kind, expiresInSec: tempPinForm.expiresInSec, label: tempPinForm.label });
198
+ setTempPinLast(r);
199
+ setTempPinForm((f) => ({ ...f, label: '' }));
200
+ await loadTempPins();
201
+ } catch (err) {
202
+ setTempPinError(err.message);
203
+ } finally {
204
+ setTempPinBusy(false);
205
+ }
206
+ };
207
+ const revokeTempPinNow = async (p) => {
208
+ try {
209
+ await call(POCKET_ENDPOINTS.tempPinRevoke, { value: p.value, kind: p.kind });
210
+ showToast(t('tempPinRevoked'));
211
+ if (tempPinLast?.value === p.value) setTempPinLast(null);
212
+ await loadTempPins();
213
+ } catch (err) {
214
+ setError(err.message);
215
+ }
216
+ };
217
+ const copyTempPin = async (value) => {
218
+ try {
219
+ if (navigator.clipboard?.writeText) {
220
+ await navigator.clipboard.writeText(value);
221
+ showToast(t('tempPinCopied'));
222
+ }
223
+ } catch { /* 忽略 */ }
224
+ };
225
+ const formatRemaining = (expiresAt) => {
226
+ const ms = expiresAt - Date.now();
227
+ if (ms <= 0) return '0s';
228
+ const s = Math.floor(ms / 1000);
229
+ if (s < 60) return `${s}s`;
230
+ const m = Math.floor(s / 60);
231
+ if (m < 60) return `${m}m`;
232
+ const h = Math.floor(m / 60);
233
+ if (h < 24) return `${h}h`;
234
+ const d = Math.floor(h / 24);
235
+ return `${d}d`;
236
+ };
237
+
168
238
  const [disclaimerOpen, setDisclaimerOpen] = useState(false);
169
239
  const [disclaimerChecked, setDisclaimerChecked] = useState(false);
170
240
 
@@ -554,6 +624,62 @@ function PocketSettingsTab({ rpcCall, t }) {
554
624
 
555
625
  error ? h('div', { style: { color: 'var(--dsw-alias-state-error-primary,#dc2626)', fontSize: 12, marginTop: 8 } }, `❌ ${errText(error)}`) : null,
556
626
 
627
+ // 临时 PIN(issue #69):带过期的访问密码,给访客用
628
+ h('div', { style: styles.block },
629
+ h('div', { style: { fontWeight: 600, fontSize: 13, marginBottom: 4 } }, t('tempPinTitle')),
630
+ h('div', { style: { ...styles.muted, marginBottom: 10 } }, t('tempPinSubtitle')),
631
+ // 生成表单:分类 + 时长 + 备注 + 生成
632
+ h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' } },
633
+ h('select', {
634
+ value: tempPinForm.kind,
635
+ onChange: (e) => setTempPinForm((f) => ({ ...f, kind: e.target.value })),
636
+ style: { font: 'inherit', height: 30, padding: '0 8px', borderRadius: 8, border: '1px solid var(--dsw-alias-border-l2,#d1d5db)', background: 'var(--dsw-alias-bg-layer-1,#fff)', color: 'var(--dsw-alias-label-primary,inherit)' },
637
+ },
638
+ h('option', { value: 'public' }, t('tempPinKindPublic')),
639
+ h('option', { value: 'lan' }, t('tempPinKindLan')),
640
+ ),
641
+ h('select', {
642
+ value: String(tempPinForm.expiresInSec),
643
+ onChange: (e) => setTempPinForm((f) => ({ ...f, expiresInSec: Number(e.target.value) })),
644
+ style: { font: 'inherit', height: 30, padding: '0 8px', borderRadius: 8, border: '1px solid var(--dsw-alias-border-l2,#d1d5db)', background: 'var(--dsw-alias-bg-layer-1,#fff)', color: 'var(--dsw-alias-label-primary,inherit)' },
645
+ },
646
+ h('option', { value: '3600' }, t('tempPinDuration1h')),
647
+ h('option', { value: '86400' }, t('tempPinDuration24h')),
648
+ h('option', { value: '604800' }, t('tempPinDuration7d')),
649
+ ),
650
+ h('input', {
651
+ style: { flex: 1, minWidth: 120, height: 30, padding: '0 10px', borderRadius: 8, border: '1px solid var(--dsw-alias-border-l2,#d1d5db)', fontSize: 13, outline: 'none' },
652
+ placeholder: t('tempPinLabelPh'),
653
+ value: tempPinForm.label,
654
+ onChange: (e) => setTempPinForm((f) => ({ ...f, label: e.target.value })),
655
+ onKeyDown: (e) => { if (e.key === 'Enter') createTempPinNow(); },
656
+ }),
657
+ h('button', { style: { ...styles.btn, height: 30 }, onClick: createTempPinNow, disabled: tempPinBusy }, t('tempPinCreate')),
658
+ ),
659
+ // 错误展示
660
+ tempPinError ? h('div', { style: { color: 'var(--dsw-alias-state-error-primary,#dc2626)', fontSize: 12, marginTop: 6 } }, errText(tempPinError)) : null,
661
+ // 刚生成的那一个(高亮,下一秒会消失提示用户立刻复制)
662
+ tempPinLast ? h('div', { style: { marginTop: 10, padding: '8px 10px', borderRadius: 8, background: 'var(--dsw-alias-bg-layer-2,#f3f4f6)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' } },
663
+ h('div', null,
664
+ h('div', { style: { fontSize: 11, color: 'var(--dsw-alias-state-success-primary,#16a34a)', fontWeight: 600, marginBottom: 2 } }, `${t('tempPinCreated')} · ${tempPinLast.kind === 'public' ? t('tempPinKindPublic') : t('tempPinKindLan')} · ${formatRemaining(tempPinLast.expiresAt)}`),
665
+ h('span', { style: { fontFamily: 'ui-monospace,Menlo,monospace', fontSize: 16, letterSpacing: 2, fontWeight: 600 } }, tempPinLast.value),
666
+ ),
667
+ h('button', { style: { ...styles.btn, height: 28, padding: '0 12px', fontSize: 12 }, onClick: () => copyTempPin(tempPinLast.value) }, t('tempPinCopy')),
668
+ ) : null,
669
+ // 列表
670
+ h('div', { style: { marginTop: 12, borderTop: '1px solid var(--dsw-alias-border-l2,#e5e7eb)', paddingTop: 8 } },
671
+ tempPinsList.length === 0
672
+ ? h('div', { style: { ...styles.muted, padding: '6px 0' } }, t('tempPinEmpty'))
673
+ : tempPinsList.map((p) => h('div', { key: p.value, style: { display: 'flex', alignItems: 'center', gap: 8, padding: '6px 0', borderBottom: '1px solid var(--dsw-alias-border-l2,#e5e7eb)' } },
674
+ h('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary,#8b93a1)', minWidth: 50 } }, p.kind === 'public' ? t('tempPinKindPublic') : t('tempPinKindLan')),
675
+ h('span', { style: { fontFamily: 'ui-monospace,Menlo,monospace', fontSize: 13, letterSpacing: 1, flexShrink: 0 } }, p.value),
676
+ h('span', { style: { ...styles.muted, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, p.label || '—'),
677
+ h('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary,#8b93a1)' } }, `${t('tempPinExpiresIn')} ${formatRemaining(p.expiresAt)}`),
678
+ h('button', { style: { ...styles.btn, height: 24, padding: '0 10px', fontSize: 11 }, onClick: () => revokeTempPinNow(p) }, t('tempPinRevoke')),
679
+ )),
680
+ ),
681
+ ),
682
+
557
683
  // 恢复出厂设置:设置出问题时的临时兜底(最底部,避免误触)
558
684
  h('div', { style: styles.block },
559
685
  h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 } },
@@ -97,6 +97,25 @@ export const zh = {
97
97
  'error': '❌ 开启失败:{detail}(可重试;若是代理/VPN 问题见 README 排障)',
98
98
  'unknownError': '未知错误',
99
99
  'feedback': '有问题?欢迎到 GitHub Issues 反馈 🙏',
100
+ // 临时 PIN(issue #69)
101
+ 'tempPinTitle': '临时访问 PIN(带超时)',
102
+ 'tempPinSubtitle': '给访客开一个有寿命的 8 位 PIN,过期自动作废;与主 PIN 共用速率限制。',
103
+ 'tempPinKindPublic': '公网',
104
+ 'tempPinKindLan': '局域网',
105
+ 'tempPinDuration': '有效时长',
106
+ 'tempPinDuration1h': '1 小时',
107
+ 'tempPinDuration24h': '24 小时',
108
+ 'tempPinDuration7d': '7 天',
109
+ 'tempPinLabel': '备注(可选,给谁用)',
110
+ 'tempPinLabelPh': '朋友小王',
111
+ 'tempPinCreate': '生成',
112
+ 'tempPinCreated': '已生成(仅显示一次)',
113
+ 'tempPinCopied': '已复制',
114
+ 'tempPinCopy': '复制',
115
+ 'tempPinEmpty': '暂无临时 PIN',
116
+ 'tempPinExpiresIn': '过期',
117
+ 'tempPinRevoke': '撤销',
118
+ 'tempPinRevoked': '已撤销',
100
119
  }
101
120
 
102
121
  /** English dictionary, key-identical to the Chinese source of truth. */
@@ -194,4 +213,23 @@ export const en = {
194
213
  'error': '❌ Failed to enable: {detail} (you can retry; for proxy/VPN issues see the README)',
195
214
  'unknownError': 'unknown error',
196
215
  'feedback': '🙏 Questions? Open an issue on GitHub',
216
+ // Temp PIN (issue #69)
217
+ 'tempPinTitle': 'Temporary access PINs (auto-expire)',
218
+ 'tempPinSubtitle': 'Issue a time-limited 8-char PIN to a guest; expires automatically. Shares the main PIN rate limit.',
219
+ 'tempPinKindPublic': 'Public',
220
+ 'tempPinKindLan': 'LAN',
221
+ 'tempPinDuration': 'Valid for',
222
+ 'tempPinDuration1h': '1 hour',
223
+ 'tempPinDuration24h': '24 hours',
224
+ 'tempPinDuration7d': '7 days',
225
+ 'tempPinLabel': 'Note (optional, who is this for)',
226
+ 'tempPinLabelPh': 'Friend Alice',
227
+ 'tempPinCreate': 'Create',
228
+ 'tempPinCreated': 'Created (shown once)',
229
+ 'tempPinCopied': 'Copied',
230
+ 'tempPinCopy': 'Copy',
231
+ 'tempPinEmpty': 'No temporary PINs yet',
232
+ 'tempPinExpiresIn': 'expires',
233
+ 'tempPinRevoke': 'Revoke',
234
+ 'tempPinRevoked': 'Revoked',
197
235
  }
package/lib/index.js CHANGED
@@ -21,7 +21,7 @@ import { createPocketService } from './service.mjs';
21
21
  import { installPocketRpc } from './web-rpc.js';
22
22
  import { restartHost } from './restart.js';
23
23
  import { advancedNoticeScript, DEFAULT_INJECT, classifyHost } from './proxy.mjs';
24
- import { lanEnabled, setLanEnabled, lanAuthEnabled, setLanAuthEnabled, lanIpOverride, setLanIpOverride, pinCustom, setPinCustom, tunnelMode, setTunnelMode, tunnelToken, setTunnelToken, tunnelHostname, setTunnelHostname, resetSettings, proxyPort } from './settings.mjs';
24
+ import { lanEnabled, setLanEnabled, lanAuthEnabled, setLanAuthEnabled, lanIpOverride, setLanIpOverride, pinCustom, setPinCustom, tunnelMode, setTunnelMode, tunnelToken, setTunnelToken, tunnelHostname, setTunnelHostname, resetSettings, proxyPort, tempPinValuesFor, tempPins as readTempPins, createTempPin, revokeTempPin, cloudflaredPath } from './settings.mjs';
25
25
 
26
26
  const name = 'dsh-pocket';
27
27
  const inject = ['connection', 'webServer'];
@@ -220,6 +220,15 @@ export function apply(ctx, config = {}, internals = {}) {
220
220
  return () => {};
221
221
  }
222
222
 
223
+ // cloudflared 自定义路径(issue #45):远程 Linux 服务器下载源全失败时,
224
+ // 让用户能在 settings.json 写死一个已经存在的二进制路径,绕过自动下载。
225
+ // 同步到 env 让 lib/tunnel.mjs 的 resolveCloudflared 第一时间读到。
226
+ const cfPath = cloudflaredPath();
227
+ if (cfPath) {
228
+ process.env.DSH_POCKET_CLOUDFLARED = cfPath;
229
+ logger.info(`dsh-pocket: using custom cloudflared at ${cfPath} (skipping auto-download) | 使用自定义 cloudflared 路径(跳过自动下载)`);
230
+ }
231
+
223
232
  // 桌面端环境识别(官方兼容模式,见 desktop 的 plugin-development.md):
224
233
  // desktopProfiles / desktopPnpm 只在 DSH Desktop(Electron)里存在。
225
234
  // 桌面端有自己的更新/进程管理,我们这两项功能在此环境**关闭**(不删除),
@@ -276,6 +285,8 @@ export function apply(ctx, config = {}, internals = {}) {
276
285
  sessionKey: randomBytes(16).toString('hex'),
277
286
  getToken: (host) => tokenForHost(host),
278
287
  isProtected: (host) => (classifyHost(host) === 'public' ? true : lanAuthEnabled()),
288
+ // 临时 PIN(issue #69):与主 PIN 同 host 分类,仅在受保护入口校验
289
+ getAltTokens: (host) => tempPinValuesFor(classifyHost(host) === 'public' ? 'public' : 'lan'),
279
290
  },
280
291
  // dsh web 浏览器会话启动 token(issue #77):新版 dsh(>= 0.1.2-alpha.1)要求根路径
281
292
  // 带一次 `?token=` 换 cookie,否则 /api 与 WebSocket 全 401。token 每次进程启动都变,
@@ -332,6 +343,11 @@ export function apply(ctx, config = {}, internals = {}) {
332
343
  if (token !== undefined) setTunnelToken(token);
333
344
  return { mode: tunnelMode(), hostname: tunnelHostname(), tokenSet: tunnelToken().length > 0 };
334
345
  },
346
+ // 临时 PIN(issue #69):创建/列出/撤销。前端只看到 value 一次(创建时),
347
+ // 列出时也回显 value(用户需要复制给别人),文件 0o600 + 仅本机 loopback RPC 兜底。
348
+ listTempPins: () => readTempPins(),
349
+ createTempPin: ({ kind, expiresInSec, label }) => createTempPin({ kind, expiresInSec, label }),
350
+ revokeTempPin: (value, kind) => revokeTempPin(value, kind),
335
351
  runUpdate: internals.runUpdate ?? { currentVersion, perform: performUpdate, loadedVersion: () => loadedVersion },
336
352
  restart: internals.restart ?? (() => pocketRestart(service)),
337
353
  restartNotice: internals.restartNotice ?? consumeRestartNotice,
package/lib/proxy.mjs CHANGED
@@ -268,14 +268,24 @@ function isHtmlRequest(req) {
268
268
  return accept.includes('text/html') || req.url === '/' || /\.html?$/i.test(String(req.url));
269
269
  }
270
270
 
271
- /** 校验请求是否已认证;返回 true 放行。 */
272
- function authCheck(req, token, sessionKey) {
273
- if (!token) return true; // token(未配置密码)→ 放行
274
- const expected = cookieFor(token, sessionKey);
275
- const cookieTok = parseCookies(req.headers.cookie)[TOKEN_COOKIE];
276
- if (cookieTok === expected) return true;
271
+ /** 校验请求是否已认证;返回 true 放行。`tokens` 允许传多个(主 PIN + 临时 PIN)。 */
272
+ function authCheck(req, tokens, sessionKey) {
273
+ const list = (Array.isArray(tokens) ? tokens : tokens ? [tokens] : []).filter(Boolean);
274
+ if (list.length === 0) return true; // 无 token(未配置密码)→ 放行
275
+ const cookies = parseCookies(req.headers.cookie);
276
+ const cookieTok = cookies[TOKEN_COOKIE];
277
+ if (cookieTok) {
278
+ for (const token of list) {
279
+ if (cookieTok === cookieFor(token, sessionKey)) return true;
280
+ }
281
+ }
277
282
  const qTok = new URL(req.url ?? '/', 'http://x').searchParams.get('token');
278
- return qTok === expected;
283
+ if (qTok) {
284
+ for (const token of list) {
285
+ if (qTok === cookieFor(token, sessionKey)) return true;
286
+ }
287
+ }
288
+ return false;
279
289
  }
280
290
 
281
291
  /** 把浏览器可见的权威改写成 loopback 权威(Host 和 Origin 都改)。 */
@@ -296,6 +306,36 @@ function loopbackAuthority(headers, upstream) {
296
306
  // 只在 `GET /` 且请求还没带 dsh-auth-* cookie 时注入:上游拿到 token 会 303 回干净的根
297
307
  // 路径,若每次都注入就会 303 循环(浏览器很快报"重定向次数过多")。
298
308
  const DSH_AUTH_COOKIE = 'dsh-auth-';
309
+ /**
310
+ * 去掉 URL 上所有 `dsh-desktop-*` query 参数(issue #75)。
311
+ *
312
+ * dsh-pocket 在 ≤ 2.1.1 会用 `history.replaceState` 往页面 URL 上写
313
+ * `dsh-desktop-mode=compatibility` 和 `dsh-desktop-platform=<系统>`
314
+ * (desktopEnvPatchScript,已在 2.1.2 删除)。副作用是:用户当时收藏/保存过
315
+ * 的那个地址**一直带着这两个参数**。升级之后我们不再注入了,但用户打开旧
316
+ * 收藏时 URL 里仍然有 —— 上游 `decideDesktopBrowserAccess` 只要见到
317
+ * `dsh-desktop-` 前缀就认定是渲染器请求,普通浏览器没有渲染器 token,直接
318
+ * 403 forbidden。表现就是「我已经升到最新版了,还是 forbidden」。
319
+ *
320
+ * 脏参数是我们写进去的,就得由我们清掉。所有方法、所有路径都清理(不只是
321
+ * `GET /`)——API 与 WebSocket 握手带上这些参数同样会被拦。
322
+ *
323
+ * @param {string} reqUrl - 原始请求路径(含 query)。
324
+ * @returns {string} 清理后的路径;无该前缀参数或解析失败时原样返回。
325
+ */
326
+ export function stripDesktopMarkers(reqUrl) {
327
+ let u;
328
+ try {
329
+ u = new URL(reqUrl ?? '/', 'http://dsh.invalid');
330
+ } catch {
331
+ return reqUrl;
332
+ }
333
+ const doomed = [...u.searchParams.keys()].filter((k) => k.startsWith('dsh-desktop-'));
334
+ if (doomed.length === 0) return reqUrl;
335
+ for (const key of doomed) u.searchParams.delete(key);
336
+ return `${u.pathname}${u.search}`;
337
+ }
338
+
299
339
  export function upstreamPathWithLaunchToken(reqUrl, method, cookieHeader, launchToken) {
300
340
  if (method !== 'GET') return reqUrl;
301
341
  let u;
@@ -372,7 +412,9 @@ function attachWebSocketHeartbeat(socket, { intervalMs = 30_000, missLimit = 2 }
372
412
  * @param {string} [opts.host] 监听地址(默认 0.0.0.0:LAN 与隧道都能到)
373
413
  * @param {{host:string,port:number}} [opts.upstream] 上游 dsh web(默认 127.0.0.1:3080)
374
414
  * @param {string} [opts.injectHtml] 注入 HTML 的内容(默认 polyfill + 移动端适配;传 '' 关闭)
375
- * @param {object} [opts.auth] 可选访问令牌认证(issue #13):{ getToken, isProtected, sessionKey }
415
+ * @param {object} [opts.auth] 可选访问令牌认证(issue #13 + #69):{ getToken, getAltTokens?, isProtected, sessionKey }
416
+ * - getToken(host) → 主 PIN(公网/局域网各一个,按 host 分类)
417
+ * - getAltTokens?(host) → 临时 PIN 列表(issue #69),校验时与主 PIN 任一命中即放行
376
418
  * @param {object|false} [opts.rateLimit] 登录速率限制参数覆盖(issue #40;测试用短窗口)
377
419
  * @param {object|false} [opts.heartbeat] WebSocket 心跳注入(PR #41):{ intervalMs, missLimit };false 关闭(默认开:30s/容忍 2 个静默周期)
378
420
  * @param {() => boolean} [opts.lanAccessEnabled] 局域网访问是否开启(默认开启)。关闭时拦截经局域网 Host 的请求(公网/loopback 不受影响)。
@@ -396,11 +438,14 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
396
438
  }
397
439
  return;
398
440
  }
399
- // 访问令牌认证(issue #13 + #18 + #33 + #40):局域网与公网按开关/来源要求密码
441
+ // 访问令牌认证(issue #13 + #18 + #33 + #40 + #69):局域网与公网按开关/来源要求密码
400
442
  if (auth) {
401
443
  const protectedHost = isProtectedHost(host, auth.isProtected);
402
444
  const token = protectedHost ? (auth.getToken?.(host) ?? null) : null;
445
+ // 临时 PIN(issue #69):按 host 同源分发(公网临时 PIN 只在公网入口放行,局域网同理)
446
+ const altTokens = protectedHost && token && typeof auth.getAltTokens === 'function' ? (auth.getAltTokens(host) ?? []) : [];
403
447
  const sessionKey = auth.sessionKey ?? null;
448
+ const acceptedTokens = token ? [token, ...altTokens] : [];
404
449
  if (protectedHost && token) {
405
450
  const ip = clientIp(req);
406
451
  // 登录提交:速率限制(issue #40)→ 校验密码 → 种持久 HttpOnly cookie(30 天,绑定进程会话密钥)→ 回首页
@@ -438,7 +483,7 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
438
483
  });
439
484
  return;
440
485
  }
441
- if (!authCheck(req, token, sessionKey)) {
486
+ if (!authCheck(req, acceptedTokens, sessionKey)) {
442
487
  if (isHtmlRequest(req)) {
443
488
  // 锁定期间打开登录页也给提示(HTTP 200 + 锁定文案;429 语义留给 POST 拒绝)
444
489
  const rl = limiter?.status(ip) ?? { locked: false, retryAfter: 0 };
@@ -455,7 +500,13 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
455
500
  const headers = loopbackAuthority({ ...req.headers }, upstream);
456
501
  // dsh web 浏览器会话 token(issue #77):首屏根路径补一次,换回绑定 authority 的 cookie
457
502
  const launchTok = (typeof launchToken === 'function' ? launchToken() : '') || '';
458
- const upstreamPath = upstreamPathWithLaunchToken(req.url, req.method, req.headers.cookie, launchTok);
503
+ // 先清掉历史遗留的 dsh-desktop-* 参数(issue #75),再补 launch token
504
+ const upstreamPath = upstreamPathWithLaunchToken(
505
+ stripDesktopMarkers(req.url),
506
+ req.method,
507
+ req.headers.cookie,
508
+ launchTok,
509
+ );
459
510
  const proxyReq = httpRequest(
460
511
  { host: upstream.host, port: upstream.port, method: req.method, path: upstreamPath, headers, agent: false },
461
512
  (proxyRes) => {
@@ -543,10 +594,12 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
543
594
  socket.destroy();
544
595
  return;
545
596
  }
546
- // WebSocket 同样校验(防止绕过 HTTP 认证从 WS 进入)
597
+ // WebSocket 同样校验(防止绕过 HTTP 认证从 WS 进入;含临时 PIN,issue #69)
547
598
  if (auth) {
548
599
  const token = isProtectedHost(host, auth.isProtected) ? (auth.getToken?.(host) ?? null) : null;
549
- if (token && !authCheck(req, token, auth.sessionKey ?? null)) {
600
+ const altTokens = token && typeof auth.getAltTokens === 'function' ? (auth.getAltTokens(host) ?? []) : [];
601
+ const acceptedTokens = token ? [token, ...altTokens] : [];
602
+ if (token && !authCheck(req, acceptedTokens, auth.sessionKey ?? null)) {
550
603
  socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
551
604
  socket.destroy();
552
605
  return;
@@ -554,7 +607,8 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
554
607
  }
555
608
  const headers = loopbackAuthority({ ...req.headers }, upstream);
556
609
  const proxyReq = httpRequest({
557
- host: upstream.host, port: upstream.port, method: req.method, path: req.url, headers, agent: false,
610
+ // 同样清掉历史遗留的 dsh-desktop-* 参数(issue #75)
611
+ host: upstream.host, port: upstream.port, method: req.method, path: stripDesktopMarkers(req.url), headers, agent: false,
558
612
  });
559
613
  proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
560
614
  socket.write('HTTP/1.1 101 Switching Protocols\r\n');
package/lib/settings.mjs CHANGED
@@ -14,6 +14,7 @@
14
14
  import { readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
15
15
  import { join, dirname } from 'node:path';
16
16
  import { homedir } from 'node:os';
17
+ import { randomBytes } from 'node:crypto';
17
18
  import { isValidIpv4 } from './ip.mjs';
18
19
 
19
20
  const settingsRel = join('dsh-pocket', 'settings.json');
@@ -212,3 +213,101 @@ export function setProxyPort(value) {
212
213
  writeSettings(s);
213
214
  return proxyPort();
214
215
  }
216
+
217
+ // ---------- 临时 PIN(issue #69:带超时的临时访问) ----------
218
+ // 让用户能给访客(朋友/同事/临时设备)开一个**有寿命**的 PIN,过期自动作废——
219
+ //
220
+ // 设计要点:
221
+ // - 临时 PIN 与主 PIN **共用速率限制**(按 IP):攻击者拿到临时 PIN 也不能暴力破解别的;
222
+ // - 临时 PIN **绑 host 分类**:公网临时 PIN 只在公网入口校验,局域网临时 PIN 只在局域网入口校验
223
+ // (与主 PIN 的 host 分发一致;防止公网临时 PIN 被局域网内的人滥用,反之亦然);
224
+ // - 过期**懒清理**:访问时过滤掉过期的,避免每次都写磁盘(频繁写影响响应时间);
225
+ // 设置页列表操作或重启 dsh web 之后才落盘(writeSettings 在 setTempPin / revokeTempPin 时调用);
226
+ // - 不复用主 PIN 的「自定义标记」:临时 PIN 是一次性生成,标记无意义;
227
+ // - 存本机 0o600 settings.json(与 Token 一致)。
228
+ //
229
+ // 临时 PIN 默认长度同主 PIN(8 位字母数字),但由系统生成,用户不设置。
230
+ // 一次性签名不重要:临时 PIN 本身足够长(8 位)且绑超时,被截获也只活到过期。
231
+ //
232
+ // 数据结构:settings.tempPins = [{ value, kind: 'public'|'lan', expiresAt: <epoch_ms>, label: <string> }]
233
+ const TEMP_PIN_KINDS = new Set(['public', 'lan']);
234
+ const TEMP_PIN_RE = /^[a-zA-Z0-9]{8}$/;
235
+ /** 最小允许有效期 5 分钟(防误点立即过期让用户迷惑),最大 30 天。 */
236
+ const TEMP_PIN_MIN_SEC = 5 * 60;
237
+ const TEMP_PIN_MAX_SEC = 30 * 24 * 60 * 60;
238
+ /** 临时 PIN 生成(issue #69):与主 PIN 同长度(8 位字母数字),用密码学随机。 */
239
+ function newTempPin() {
240
+ const bytes = randomBytes(8);
241
+ // 把 8 字节转 base32-ish 字母数字串,再截 8 位;碰撞概率忽略(8 字节熵 ≈ 2^64)
242
+ const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
243
+ let out = '';
244
+ for (let i = 0; i < 8; i++) out += alphabet[bytes[i] % alphabet.length];
245
+ return out;
246
+ }
247
+
248
+ /** 读临时 PIN 列表(自动过滤过期项;不写磁盘)。 */
249
+ export function tempPins() {
250
+ const arr = readSettings().tempPins;
251
+ if (!Array.isArray(arr)) return [];
252
+ const now = Date.now();
253
+ return arr
254
+ .filter((p) => p && typeof p === 'object' && typeof p.value === 'string' && typeof p.expiresAt === 'number' && p.expiresAt > now)
255
+ .map((p) => ({ value: p.value, kind: p.kind, expiresAt: p.expiresAt, label: typeof p.label === 'string' ? p.label : '' }));
256
+ }
257
+
258
+ /** 创建一个临时 PIN(issue #69)。返回 { value, expiresAt, kind, label }。 */
259
+ export function createTempPin({ kind, expiresInSec, label = '' } = {}) {
260
+ if (!TEMP_PIN_KINDS.has(kind)) throw new Error('临时 PIN 类型必须是 public 或 lan | temp pin kind must be public or lan');
261
+ const sec = Number(expiresInSec);
262
+ if (!Number.isInteger(sec) || sec < TEMP_PIN_MIN_SEC || sec > TEMP_PIN_MAX_SEC) {
263
+ throw new Error(`临时 PIN 有效期需在 ${TEMP_PIN_MIN_SEC}-${TEMP_PIN_MAX_SEC} 秒之间 | expiresInSec out of range`);
264
+ }
265
+ const value = newTempPin();
266
+ const expiresAt = Date.now() + sec * 1000;
267
+ const s = readSettings();
268
+ s.tempPins = Array.isArray(s.tempPins) ? s.tempPins.filter((p) => p && p.expiresAt > Date.now()) : [];
269
+ s.tempPins.push({ value, kind, expiresAt, label: String(label).slice(0, 32) });
270
+ writeSettings(s);
271
+ return { value, kind, expiresAt, label: String(label).slice(0, 32) };
272
+ }
273
+
274
+ /** 撤销一个临时 PIN(按 value 匹配,kind 校验防止误删)。不存在也返回 false。 */
275
+ export function revokeTempPin(value, kind) {
276
+ const v = String(value ?? '');
277
+ if (!TEMP_PIN_RE.test(v)) return false;
278
+ const s = readSettings();
279
+ if (!Array.isArray(s.tempPins)) return false;
280
+ const before = s.tempPins.length;
281
+ s.tempPins = s.tempPins.filter((p) => !(p?.value === v && (kind === undefined || p.kind === kind)));
282
+ if (s.tempPins.length === before) return false;
283
+ writeSettings(s);
284
+ return true;
285
+ }
286
+
287
+ /** 给定 kind 返回所有未过期的临时 PIN 集合(value 数组),用于 proxy 校验。 */
288
+ export function tempPinValuesFor(kind) {
289
+ return tempPins().filter((p) => p.kind === kind).map((p) => p.value);
290
+ }
291
+
292
+ // ---------- cloudflared 路径(issue #45:远程 Linux 服务器下载源不可达时手动指定) ----------
293
+ // Linux 服务器在国内/部分企业网下,所有 CDN 源(GitHub / ghproxy / gh.ddlc / gh-proxy)
294
+ // 都连不上时,下载 cloudflared 二进制始终失败。允许用户在 settings.json 里**写死
295
+ // 一个已经存在 / 自己上传的 cloudflared 路径**,跳过下载。`lib/tunnel.mjs` 的
296
+ // `resolveCloudflared` 启动时会优先读 `process.env.DSH_POCKET_CLOUDFLARED`,
297
+ // 找不到再回退到 PATH 探测和下载。`lib/index.js` 在插件 apply 时把 settings
298
+ // 的这个值写入 env,确保 service 走自定义路径。
299
+ // 空字符串 = 清除,回退到默认行为(PATH 探测 + 下载)。
300
+ /** 当前 cloudflared 自定义路径(空 = 用默认)。 */
301
+ export function cloudflaredPath() {
302
+ return readSettings().cloudflaredPath ?? '';
303
+ }
304
+
305
+ /** 设置 cloudflared 自定义路径。空字符串清除(回退到 PATH 探测 + 下载)。 */
306
+ export function setCloudflaredPath(value) {
307
+ const v = String(value ?? '').trim();
308
+ const s = readSettings();
309
+ if (v) s.cloudflaredPath = v;
310
+ else delete s.cloudflaredPath;
311
+ writeSettings(s);
312
+ return v;
313
+ }
package/lib/tunnel.mjs CHANGED
@@ -300,6 +300,20 @@ let downloading = null;
300
300
  export { tsinghuaBottleUrl };
301
301
 
302
302
  export async function resolveCloudflared({ home, onPhase = () => {}, signal } = {}) {
303
+ // 自定义路径(issue #45):用户可能在 settings.json 写了 cloudflaredPath,
304
+ // 或者在外部注入 process.env.DSH_POCKET_CLOUDFLARED;命中就直接用,绕过
305
+ // PATH 探测与下载(远程 Linux 服务器上下载源不可达时的兜底)。
306
+ const explicit = process.env.DSH_POCKET_CLOUDFLARED;
307
+ if (explicit) {
308
+ try {
309
+ await access(explicit);
310
+ return explicit;
311
+ } catch {
312
+ throw new Error(
313
+ `DSH_POCKET_CLOUDFLARED 指向的路径不可执行:${explicit} | cloudflaredPath is set but not accessible: ${explicit}`,
314
+ );
315
+ }
316
+ }
303
317
  if (cloudflaredOnPath()) return 'cloudflared';
304
318
  const dshHome = home ?? process.env.DSH_HOME ?? join(homedir(), '.dsh');
305
319
  const cacheDir = join(dshHome, 'dsh-pocket', 'bin');
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, getTunnelConfig = null, setTunnelConfig = null, resetPocket = 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, resetPocket = null, listTempPins = null, createTempPin = null, revokeTempPin = 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 () => {};
@@ -161,6 +161,25 @@ export function installPocketRpc(ctx, { service, log = console, desktop = false,
161
161
  const dshPort = service.dshPort ?? 3080;
162
162
  return ok({ ...result, hint: `重启后进程在后台运行;如需停止:${killHint(dshPort)}` });
163
163
  }
164
+ // 临时 PIN(issue #69):生成 / 列出 / 撤销。临时 PIN 是带过期的主 PIN 同位替代品,
165
+ // 用于把访问权限临时分给别人(朋友/同事/临时设备),过期自动作废。
166
+ if (endpoint === POCKET_ENDPOINTS.tempPinCreate) {
167
+ if (!createTempPin) return fail('bad-request', '临时 PIN 生成不可用 | temp pin create unavailable');
168
+ try {
169
+ const pin = createTempPin({ kind: payload?.kind, expiresInSec: payload?.expiresInSec, label: payload?.label });
170
+ return ok(pin);
171
+ } catch (err) {
172
+ return fail('bad-request', err?.message ?? String(err));
173
+ }
174
+ }
175
+ if (endpoint === POCKET_ENDPOINTS.tempPinList) {
176
+ return ok({ tempPins: listTempPins?.() ?? [] });
177
+ }
178
+ if (endpoint === POCKET_ENDPOINTS.tempPinRevoke) {
179
+ if (!revokeTempPin) return fail('bad-request', '临时 PIN 撤销不可用 | temp pin revoke unavailable');
180
+ const ok = revokeTempPin(payload?.value, payload?.kind);
181
+ return ok({ revoked: ok });
182
+ }
164
183
  return fail('bad-request', `Unknown endpoint: ${endpoint}`);
165
184
  } catch (err) {
166
185
  log.error?.('dsh-pocket: rpc %s failed | RPC 失败: %s', endpoint, err?.message ?? err);
package/package.json CHANGED
@@ -80,5 +80,5 @@
80
80
  "access": "public",
81
81
  "registry": "https://registry.npmjs.org/"
82
82
  },
83
- "version": "2.3.0"
83
+ "version": "2.5.0"
84
84
  }