dsh-push-host 0.3.1 → 0.4.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.md CHANGED
@@ -53,6 +53,18 @@ ask_user_question (工具, 进程内)
53
53
  时调用它返回 `false`。此时**不 ack**、留在队列里,等前台等待结束(timed 的 timeout / UI 作答)
54
54
  下一轮再投。
55
55
 
56
+ ## 发布后从 npm 装回来时踩的坑(2026-10-27,第六个)
57
+
58
+ 1. **`priority` 只能是整数 1–5,字符串 `'default'` 会被 relay 判成非法 JSON。**
59
+ `RelayClient.push()` 的默认值原来是字符串 `'default'`(那是 ntfy **请求头**写法里的取值),
60
+ 而 ntfy 的 **JSON body** 只接受数字 ⇒ 服务端回
61
+ `400 {"code":40024,"error":"invalid request: request body must be valid JSON"}`。
62
+ 这条报错把人往"body 不是合法 JSON"上引,实际 body 完全合法,只是 `priority` 类型不对。
63
+ 插件内部三个调用点都显式传了 `4 / 1 / 3`,所以线上一直没暴露;**只有"没传 priority"的外部
64
+ 调用方(自己写脚本验证、第三方集成)会踩**。0.3.2 起默认值改成整数 `3`。
65
+ 同一条规则也解释了为什么 `priority: 'high'`、`priority: '4'` 都 400,而 `3`、`4`、`"4"` 之外
66
+ 的数字全 200 —— **别把请求头的取值抄进 JSON body。**
67
+
56
68
  ## 生产切换踩过的坑(2026-10-12/13,五个,都在这份代码里修掉了)
57
69
 
58
70
  1. **schema 校验后的空默认值会盖掉凭据文件。** `apply()` 拿到的 `config` 已经过 schema 校验,
@@ -158,14 +170,17 @@ timed 路径行为不变。插件侧修不了(`firstOf` 赢后够不着那条
158
170
 
159
171
  本包按可发布的 npm 包组织(`package.json` 带 `repository` / `homepage` / `bugs` / `author` /
160
172
  `publishConfig.access: public` / `engines` / `files` 白名单,MIT `LICENSE` 随包)。
161
- 发布由仓库维护者做(`npm publish`,本机没有 npm,见下),用户侧只需 `dsh plugin add`:
173
+ 用户侧只需 `dsh plugin add`:
174
+
175
+ **已发布到 npm:`dsh-push-host@0.3.2`(2026-10-27)** — https://registry.npmjs.org/dsh-push-host
176
+ (`latest` 即最新版;`repository.directory = host-plugin`,市场据此自动关联 npm 包)
162
177
 
163
178
  ```powershell
164
- # 从 npm 装(发布后)
179
+ # 从 npm 装(推荐,免 allowBuilds 构建授权)
165
180
  & $dsh plugin --profile <profile> add dsh-push-host
166
181
 
167
182
  # 从 tarball 装(离线/内网,等价路径,已实测)
168
- & $dsh plugin --profile <profile> add ./dsh-push-host-0.2.1.tgz
183
+ & $dsh plugin --profile <profile> add ./dsh-push-host-0.3.2.tgz
169
184
 
170
185
  # 从本地目录装(开发期,改完立即生效)
171
186
  & $dsh plugin --profile <profile> add "link:<仓库>/host-plugin"
@@ -174,12 +189,12 @@ timed 路径行为不变。插件侧修不了(`firstOf` 赢后够不着那条
174
189
  三种方式在 profile 里的落点完全相同:`package.json` 的 `dependencies` 多一项、`dsh.profile.bundles`
175
190
  多一项(DSH 读到本包的 `dsh.bundle.patch` 自动登记 bundle 层)。
176
191
 
177
- **打包**:有 npm 的机器直接 `npm pack`(`files` 白名单已配好);本机没装 npm(DSH 只带 pnpm),
178
- 所以仓库里配了等价工具,输出同样的 `npm notice` 清单与 sha1:
192
+ **打包**:有 npm 的机器直接 `npm pack`(`files` 白名单已配好)。
193
+ DSH 运行时只带 pnpm 的机器上,仓库里配了等价工具,输出同样的 `npm notice` 清单与 sha1:
179
194
 
180
195
  ```powershell
181
196
  & $node tools/pack-npm-tarball.mjs host-plugin --dry-run # 只看清单,不落盘
182
- & $node tools/pack-npm-tarball.mjs host-plugin # 落盘 host-plugin/dsh-push-host-0.2.1.tgz
197
+ & $node tools/pack-npm-tarball.mjs host-plugin # 落盘 host-plugin/dsh-push-host-0.3.2.tgz
183
198
  ```
184
199
 
185
200
  实测产物 9 个文件:`LICENSE`、`README.md`、`cordis.patch.yml`、`package.json`、
@@ -202,7 +217,7 @@ $dsh = '<DSH 安装目录>\resources\runtime\cli\bin\dsh.cmd' # 不在 PATH
202
217
  & $dsh --profile pushdist --from-default-profile headless --dump-config
203
218
 
204
219
  # 2. 把包(或 tarball)装进去
205
- & $dsh plugin --profile pushdist add ./dsh-push-host-0.2.1.tgz
220
+ & $dsh plugin --profile pushdist add ./dsh-push-host-0.3.2.tgz
206
221
 
207
222
  # 3. 验收挂载:看 dump-config 尾部
208
223
  & $dsh --profile pushdist --dump-config | Select-Object -Last 3
@@ -224,7 +239,7 @@ $dsh = '<DSH 安装目录>\resources\runtime\cli\bin\dsh.cmd' # 不在 PATH
224
239
  ```powershell
225
240
  & powershell -NoProfile -ExecutionPolicy Bypass -File deploy/windows/install-user.ps1 `
226
241
  -Profile <profile> -DshCli "<DSH 安装目录>\resources\runtime\cli\bin\dsh.cmd" `
227
- -Source .\dsh-push-host-0.2.1.tgz
242
+ -Source .\dsh-push-host-0.3.2.tgz
228
243
  # 成功后照打印的 next steps 走:重启 DSH(工具注册是冷的)→ 用新会话(变体钉在会话创建时)
229
244
  ```
230
245
 
@@ -333,6 +348,51 @@ harness 自己的 `~\.dsh\dsh-runtimes\...` 那个 node 同为 v24.21.0、`os.tm
333
348
  只粘一串 `tk_…` 的老通行证在 App 里仍按默认 topic `dsh` 处理,所以现有手机不会被这次改动破坏。
334
349
  - 发号失败(401 无密钥 / 409 名字占用 / 服务不可达)都在面板上显示中文原因,**不动本机凭据文件、不影响已工作的推送链路**。
335
350
 
351
+ ## 远程访问:没装 Tailscale 也能从外网打开(v0.4.0)
352
+
353
+ 前面所有能力都假设手机和电脑在同一个 tailnet 里。真实用户经常没有 —— 这一版给一条不依赖
354
+ Tailscale 的公网入口,而且**重启后不用重新扫码**。
355
+
356
+ 设置 → 常规 → 远程访问 → 打开,系统会:
357
+
358
+ 1. 在 `127.0.0.1:<随机端口>` 起一个**内层改写网关**;
359
+ 2. 用 `cloudflared tunnel` 把它挂到 Cloudflare 的快速隧道,拿到一个 `https://xxx.trycloudflare.com`;
360
+ 3. 把 `https://xxx.trycloudflare.com/?k=<口令>` 经 ntfy 推到你手机。
361
+
362
+ 点开这个链接:第一次访问会种一个 **30 天有效**的 cookie,然后 303 把 `?k=` 从地址栏抹掉。
363
+ 之后 30 天内换电脑重启、换 DSH token 都不用再扫。
364
+
365
+ ### 为什么不能直接把 3081 暴露出去
366
+
367
+ DSH 有两道栅栏,都是 2026-10-28 实测的:
368
+
369
+ | 请求 | Host | Origin | 结果 |
370
+ |---|---|---|---|
371
+ | `GET /api/session/list` | `abc.trycloudflare.com` | — | **403 host-not-trusted** |
372
+ | `GET /api/session/list` | `127.0.0.1:3081` | `https://abc.trycloudflare.com` | **403 origin-not-trusted** |
373
+
374
+ 所以只改 `--trusted-host` 不够,Origin 也要改。网关把 Host/Origin/Referer 全程改写成回环,
375
+ 再替客户端注入 DSH 自己的登录 cookie(从 `~/.dsh/web.log` 现取,随 DSH 重启自动刷新)——
376
+ **这就是「用户不用重新扫码」的实现方式**:会变的 token 藏在内层,对外只发自己的 30 天口令。
377
+
378
+ ### 几个实现上的硬点(改代码前先读)
379
+
380
+ - **口令存凭据文件,不进 `cordis.patch.yml`**,且 cookie 里存的是 `HMAC-SHA256(secret)` 而不是口令原文。
381
+ - **`.volatile()` 字段在 `resolvePluginConfig` 里是 cosmokit ref 对象**,既不是 `''` 也不是 `undefined`,
382
+ 会被当成"用户显式配置";`String(ref)` 得到 `[object Object]` 把凭据文件的值盖掉。2026-10-28 就因此
383
+ 每次开机重新生成口令、前一天推的链接当天失效。**必须先用 `volatileValue()` 解开 ref 再判空**。
384
+ - 开关的落盘键是 `remoteEnabled: 'on'|'off'`,**不复用 schema 里的 `remoteAccess` 布尔**(布尔默认值
385
+ `false` 会反压文件里的 `true`)。
386
+ - 起隧道后 Cloudflare 边缘还要 **3~8 秒**才挂上域名,这期间访问回 **530**。`waitPublic()` 会先自打
387
+ `/__dshpush/status` 到「不是 5xx」再推链接,否则用户点开就是 Cloudflare 报错页。
388
+ - WebSocket 升级时要**补回 `Connection`/`Upgrade`**(`HOP_BY_HOP` 会剔掉它们,缺了上游不触发 upgrade)。
389
+ - 隧道换了域名会自动重推;`remotePhase` 是给人看的一句话(未开启 / 正在连接 / 隧道建立中 / 已就绪)。
390
+
391
+ ### 局限
392
+
393
+ 快速隧道的域名每次重启都变(Cloudflare 免费策略),**但口令不变,所以旧链接仍然可用到过期**;
394
+ 域名变了会重推一条新的。想要固定地址需要自己的域名 + 命名隧道,那是下一版的事。
395
+
336
396
  ## 给一个真实用户开通(凭据文件 + 配对)
337
397
 
338
398
  **目标**:开通一个用户,操作者要做的只有两件事 —— 在 relay 上跑一条开号命令、把打印出来的
package/lib/client.js CHANGED
@@ -71,6 +71,13 @@ window.__ModuleLoader__.load({
71
71
  order: 130,
72
72
  label: "通行证",
73
73
  }, () => PassPanelRow(scope, listeners, notify)))), "dsh-push-host: pass panel row");
74
+ // v0.4.0:远程访问——给没装 Tailscale 的人一条 30 天免登录的公网入口(宿主侧跑 cloudflared)。
75
+ ctx.effect(() => ctx.configForms.whileServed([NS], () => ctx.slots.inject("settings.general.item", () => ctx.slots.register({
76
+ name: "settings.general.item",
77
+ id: "push-host-remote",
78
+ order: 140,
79
+ label: "远程访问",
80
+ }, () => RemoteRow(scope, listeners, notify)))), "dsh-push-host: remote access row");
74
81
  // v0.2.2:把推送链接 hash 里的来源会话切到主视图(与设置行互不相干,各自独立生效)。
75
82
  revealFromHash(ctx);
76
83
  }
@@ -235,6 +242,102 @@ window.__ModuleLoader__.load({
235
242
  btn("吊销", () => ask("revoke", row.name), !writable)))) : null);
236
243
  }
237
244
 
245
+ /**
246
+ * 远程访问(v0.4.0):给没装 Tailscale 的人一条公网入口。
247
+ *
248
+ * 为什么不直接暴露 3081:DSH 自己有两道栅栏(Host 不在白名单 403 host-not-trusted、
249
+ * Origin 不在白名单 403 origin-not-trusted,2026-10-28 实测),而且它的 token 每次重启
250
+ * 都变 —— 用户「重启后还得再扫码」正是要避免的事。所以宿主侧起一个内层改写网关
251
+ * (把 Host/Origin 改回回环)再挂 cloudflared,对外只发一条 30 天有效的口令链接。
252
+ *
253
+ * 这一行只负责:开关、口令(可换)、看状态、把链接再推一次。真正的隧道在
254
+ * lib/index.js:RemoteAccessor + lib/remote.js。
255
+ * @param scope - 绑定了 push-host 的配置表单。
256
+ * @param listeners - 订阅回调集合。
257
+ * @param notify - 唤醒全部订阅者。
258
+ */
259
+ function RemoteRow(scope, listeners, notify) {
260
+ const [snapshot, setSnapshot] = react.useState(() => scope.getSnapshot());
261
+ const [busy, setBusy] = react.useState(false);
262
+ const [copied, setCopied] = react.useState(false);
263
+ react.useEffect(() => {
264
+ const update = () => setSnapshot(scope.getSnapshot());
265
+ listeners.add(update);
266
+ update();
267
+ return () => {
268
+ listeners.delete(update);
269
+ };
270
+ }, []);
271
+ const ready = snapshot.status === "ready";
272
+ const value = snapshot.value ?? {};
273
+ const text = (key) => (typeof value[key] === "string" ? value[key] : "");
274
+ const on = value.remoteAccess === true;
275
+ const phase = text("remotePhase");
276
+ const url = text("remoteUrl");
277
+ const status = text("remoteStatus");
278
+ const pushResult = text("remotePushResult");
279
+ const secret = text("remoteSecret");
280
+ const done = text("remoteDone");
281
+ const seq = text("remoteSeq");
282
+ react.useEffect(() => {
283
+ if (busy && done !== "" && done === seq) setBusy(false);
284
+ }, [done, seq, busy]);
285
+ const writable = ready && snapshot.writable === true && !busy;
286
+ // 30 天口令链接:宿主 pushLink 发的是同一条(remoteMessage),这里现拼一条方便复制。
287
+ const link = url !== "" && secret !== "" ? (url + "/?k=" + secret) : "";
288
+ const write = (ops) => {
289
+ if (!writable) return;
290
+ const nextSeq = String(Date.now()) + "-" + Math.floor(Math.random() * 1e6).toString(36);
291
+ setBusy(true);
292
+ setCopied(false);
293
+ setTimeout(() => setBusy(false), 30000);
294
+ Promise.resolve()
295
+ .then(() => scope.mutate(ops.concat([{ op: "set", path: ["remoteSeq"], value: nextSeq }]), snapshot.revision))
296
+ .catch(() => setBusy(false))
297
+ .then(() => notify());
298
+ };
299
+ const act = (action) => write([{ op: "set", path: ["remoteAction"], value: action }]);
300
+ const toggle = () => write([{ op: "set", path: ["remoteAccess"], value: !on }]);
301
+ const copy = () => {
302
+ if (link === "") return;
303
+ try {
304
+ navigator.clipboard.writeText(link);
305
+ setCopied(true);
306
+ } catch {
307
+ setCopied(false);
308
+ }
309
+ };
310
+ const btn = (label, onClick, disabled) => react.createElement("button", {
311
+ type: "button", disabled: disabled, onClick: onClick,
312
+ style: { padding: "6px 12px", borderRadius: 8, border: "1px solid currentColor", opacity: disabled ? 0.5 : 1 },
313
+ }, label);
314
+ return react.createElement("div", { style: { display: "flex", flexDirection: "column", gap: 6, padding: "8px 0" } },
315
+ react.createElement("div", { style: { fontWeight: 600 } }, "远程访问"),
316
+ react.createElement("div", { style: { fontSize: 13, opacity: 0.75 } },
317
+ "没装 Tailscale 也能用:给这台机器开一条公网入口,手机打开一次就记住 30 天,重启不用重新扫码。"),
318
+ react.createElement("div", { style: { display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" } },
319
+ btn(on ? "关闭远程访问" : "开启远程访问", toggle, !writable),
320
+ on ? btn("重新推送链接", () => act("push"), !writable) : null,
321
+ on ? btn("换一个口令", () => act("rotate"), !writable) : null,
322
+ on ? btn("重建隧道", () => act("reconnect"), !writable) : null),
323
+ react.createElement("div", { style: { fontSize: 12, opacity: 0.6 } },
324
+ on
325
+ ? ("状态:" + (phase || "…") + (status !== "" ? ("(" + status + ")") : ""))
326
+ : "未开启。开启后需要本机有 cloudflared(放 ~/.dsh/bin/cloudflared.exe,或在 PATH 里);没有也能开,只是会提示找不到程序。"),
327
+ link !== ""
328
+ ? react.createElement("div", { style: { display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" } },
329
+ react.createElement("textarea", {
330
+ readOnly: true, value: link, rows: 2,
331
+ onFocus: (event) => { try { event.target.select(); } catch {} },
332
+ style: { flex: "1 1 240px", padding: "6px 8px", borderRadius: 8, border: "1px solid currentColor", background: "transparent", color: "inherit", fontFamily: "monospace", fontSize: 12 },
333
+ }),
334
+ btn(copied ? "已复制" : "复制", copy, false))
335
+ : null,
336
+ pushResult !== "" ? react.createElement("div", { style: { fontSize: 12, opacity: 0.7 } }, pushResult) : null,
337
+ react.createElement("div", { style: { fontSize: 12, opacity: 0.55 } },
338
+ "这条链接里的口令就是开关机钥匙:谁拿到谁能进。泄露了就在这里点「换一个口令」,旧链接立刻作废。地址是 Cloudflare 快速隧道,每次重建都会换域名,换了宿主会自动再推一次到手机。"));
339
+ }
340
+
238
341
 
239
342
  /**
240
343
  * v0.2.2:推送里的 `#web` 链接把来源会话放在 **URL hash 片段**(`…#session-<uuid>`,服务端
@@ -91,10 +91,16 @@ export const CREDENTIAL_KEYS = {
91
91
  provisionToken: ['provisiontoken'],
92
92
  provisionBase: ['provisionbase'],
93
93
  webLog: ['weblog'],
94
+ // v0.4.0 远程访问。三处都要有:index.js 的 CREDENTIAL_CONFIG_KEYS(允许从文件读)、
95
+ // 这张表(真的去读),以及 selftest 的两条清单对账断言(漏一个就 FAIL,2026-10-17 踩过)。
96
+ // remoteEnabled 是 'on'/'off' 字符串而不是布尔:布尔开关的 schema 默认值会反压文件值。
97
+ remoteSecret: ['remotesecret'],
98
+ remoteBin: ['remotebin'],
99
+ remoteEnabled: ['remoteenabled'],
94
100
  }
95
101
 
96
102
  /** Keys that hold secrets: they are never echoed back in logs or diagnostic output. */
97
- export const SECRET_KEYS = ['hostToken', 'ntfyToken', 'phoneToken', 'regToken', 'provisionToken']
103
+ export const SECRET_KEYS = ['hostToken', 'ntfyToken', 'phoneToken', 'regToken', 'provisionToken', 'remoteSecret']
98
104
 
99
105
  /** `~` / `~/x` / `~\x` → the real home path; everything else resolves against `base`. */
100
106
  export function expandHome(input, home = homedir()) {
package/lib/index.js CHANGED
@@ -47,6 +47,7 @@ import { join } from 'node:path'
47
47
  import { randomUUID } from 'node:crypto'
48
48
  import z from '@deepseek-ai/schemastery'
49
49
  import { RelayClient } from './relay.js'
50
+ import { RemoteAccess, newRemoteSecret, remoteMessage } from './remote.js'
50
51
  import { credentialsPath, loadCredentials } from './credentials.mjs'
51
52
 
52
53
  /** Cordis plugin name used by loader diagnostics. */
@@ -90,6 +91,15 @@ const DEFAULTS = {
90
91
  echoAnswers: true,
91
92
  claimForeground: false,
92
93
  log: true,
94
+ // 远程访问(v0.4.0):默认关闭,关着的时候整条链路是惰性的(不装 cloudflared 也不报错)。
95
+ // remoteSecret 就是用户手里那条 30 天链接里的口令;remotePort=0 表示让系统随便挑个空闲端口。
96
+ // remoteEnabled 不是 schema 键:它是凭据文件里的「上次开关状态」('on'/'off'),
97
+ // 因为布尔开关的 schema 默认值 false 会反压凭据文件里的 true(见 CREDENTIAL_CONFIG_KEYS 注释)。
98
+ remoteAccess: false,
99
+ remoteSecret: '',
100
+ remoteBin: '',
101
+ remotePort: 0,
102
+ remoteEnabled: '',
93
103
  }
94
104
 
95
105
  /** Config keys (schema/overlay vocabulary) = the defaults above. */
@@ -178,6 +188,30 @@ export const Config = z.object({
178
188
  provisionDefault: z.string().default('').volatile(),
179
189
  /** 宿主处理完的请求号回写。 */
180
190
  provisionDone: z.string().default('').volatile(),
191
+
192
+ // ── 远程访问(v0.4.0)────────────────────────────────────────────────────────────
193
+ /** 总开关:只有 true 才起网关 + 隧道。 */
194
+ remoteAccess: z.boolean().default(false).volatile(),
195
+ /** 长期口令(30 天链接里的 `?k=`)。空着且打开开关 = 让宿主自动生成一串。 */
196
+ remoteSecret: z.string().default('').volatile(),
197
+ /** cloudflared 可执行文件;空 = 先找 `~/.dsh/bin/cloudflared[.exe]`,再找 PATH。 */
198
+ remoteBin: z.string().default('').volatile(),
199
+ /** 网关端口;0 = 挑空闲端口(推荐,避免与他人自跑的 cloudflared 抢端口)。 */
200
+ remotePort: z.number().step(1).min(0).max(65535).default(0).volatile(),
201
+ /** 当前公网地址(宿主写,设置行展示 + 复制;隧道重启换了域名会更新)。 */
202
+ remoteUrl: z.string().default('').volatile(),
203
+ /** 最近一次错误('' = 正常),设置行直接显示给人看。 */
204
+ remoteStatus: z.string().default('').volatile(),
205
+ /** 一句人话状态:未开启 / 正在连接 / 已就绪。 */
206
+ remotePhase: z.string().default('').volatile(),
207
+ /** 上一次「推送链接到手机」的结果('' = 没点过)。 */
208
+ remotePushResult: z.string().default('').volatile(),
209
+ /** 请求号:设置行按一次「重新连接」写一个新值,宿主据此重启隧道。 */
210
+ remoteSeq: z.string().default('').volatile(),
211
+ /** 动作:'' = 无;push = 把当前链接推到手机;reconnect = 重起隧道;rotate = 换口令。 */
212
+ remoteAction: z.union(['', 'push', 'reconnect', 'rotate']).default('').volatile(),
213
+ /** 宿主处理完的请求号回写(设置行据此清 busy)。 */
214
+ remoteDone: z.string().default('').volatile(),
181
215
  })
182
216
 
183
217
  /** `[{label, description?}]` (service vocabulary) → `[label]` (what the relay/phone render). */
@@ -798,7 +832,15 @@ export function resolvePluginConfig(config = {}, { env = process.env, readFile =
798
832
  // as "not set", so the file can fill it in. `false` stays meaningful (never dropped).
799
833
  const fromCredentials = pickConfigKeys(file.value)
800
834
  const explicit = {}
801
- for (const [key, value] of Object.entries(config)) {
835
+ for (const [key, rawValue] of Object.entries(config)) {
836
+ // 2026-10-28 fix: a `.volatile()` field arrives here as a cosmokit ref OBJECT, so it is neither
837
+ // '' nor undefined/null and used to count as "explicitly configured"; `String(ref)` then became
838
+ // "[object Object]" (15 chars) and overrode the real value from the credentials file. remoteSecret
839
+ // is validated by length >= 16, so the plugin regenerated the passphrase on every boot and the
840
+ // 30-day link pushed the day before died. Unwrap the ref before deciding.
841
+ const value = (rawValue !== null && typeof rawValue === 'object' && typeof rawValue.get === 'function')
842
+ ? volatileValue(rawValue, '')
843
+ : rawValue
802
844
  if (value !== '' && value !== undefined && value !== null) explicit[key] = value
803
845
  }
804
846
  const resolved = resolveConfig({ ...fromCredentials, ...explicit })
@@ -843,6 +885,14 @@ export const CREDENTIAL_CONFIG_KEYS = [
843
885
  'regToken',
844
886
  'provisionBase',
845
887
  'provisionToken',
888
+ // v0.4.0 远程访问:口令与 cloudflared 路径可以写进凭据文件(用户级、与其余凭据同生死)。
889
+ // 开关单独用 `remoteEnabled: 'on'` 表达,**不复用 schema 里的 `remoteAccess` 布尔**:那个键的
890
+ // schema 默认值是 `false`,而 resolvePluginConfig 的 explicit 只丢掉 ''/undefined/null(false 是
891
+ // 显式值),所以凭据文件里的 true 会被默认 false 反压回去;`remoteEnabled` 不是 DEFAULTS 的键,
892
+ // resolveConfig 会原样带过来,RemoteAccessor 拿它判断「上次是开着还是关着」。
893
+ 'remoteSecret',
894
+ 'remoteBin',
895
+ 'remoteEnabled',
846
896
  ]
847
897
 
848
898
  function pickConfigKeys(values) {
@@ -1394,6 +1444,312 @@ export class PassProvisioner {
1394
1444
  }
1395
1445
  }
1396
1446
 
1447
+ /**
1448
+ * 远程访问(v0.4.0)的宿主侧壳:把 `RemoteAccess`(lib/remote.js)挂到插件生命周期上,
1449
+ * 并把运行态镜像回 `remote*` 那几个 volatile 字段给网页设置行看。
1450
+ *
1451
+ * 三条设计约束(都是踩过的坑换来的):
1452
+ *
1453
+ * 1. **口令存凭据文件,不存 volatile**。浏览器写 volatile 会被持久化进 profile 的
1454
+ * `cordis.patch.yml`(0.3.0 的通行证面板实测过),把长期口令写进用户的补丁文件既难看
1455
+ * 又难撤。所以 `remoteSecret` 只做「显示/输入」通道,真正的落盘走
1456
+ * `writeCredentialsPatch(remoteSecret / remoteAccess)`,下次开机由 `resolvePluginConfig`
1457
+ * 从凭据文件读回来。
1458
+ * 2. **改 volatile 会热重启本插件 fiber** ⇒ 任何持久化都不能在同一次 `run()` 里做完就指望
1459
+ * 内存还活着。做法与 PassProvisioner 相同:结果挂到进程级 store,新 fiber 启动时回灌。
1460
+ * 3. **失败绝不致命**:起不来隧道只是 `remoteStatus` 有值,推送/作答链路完全不受影响。
1461
+ * 宿主会把未处理的 rejection 当致命加载错误并退出进程,所以所有入口都吞掉异常。
1462
+ */
1463
+ export class RemoteAccessor {
1464
+ constructor(ctx, config, resolved, host, log, deps = {}) {
1465
+ this.ctx = ctx
1466
+ this.config = config
1467
+ this.resolved = resolved
1468
+ this.host = host
1469
+ this.log = log
1470
+ this.writeCreds = deps.writeCredentialsPatch || writeCredentialsPatch
1471
+ this.credentialsPath = deps.credentialsPath || ''
1472
+ this.engine = null
1473
+ this.busy = false
1474
+ this.scheduled = false
1475
+ this.lastSeq = ''
1476
+ /** 上一次推给手机的地址,避免隧道抖动时反复推送。 */
1477
+ this.pushedUrl = ''
1478
+ /** 正在把凭据文件里的开关状态回写进 volatile(此时的 volatile-update 不是用户操作)。 */
1479
+ this.mirroring = false
1480
+ }
1481
+
1482
+ read(key) { return volatileValue(this.config[key], '') }
1483
+
1484
+ /**
1485
+ * 读一个「既可能在 volatile 里(本次会话刚改)、也可能只在凭据文件里」的配置。
1486
+ *
1487
+ * 2026-10-28 修的缺陷:`read()` 只看 `this.config[key]`,而 `Config` schema 的默认值是空串,
1488
+ * 凭据文件里的值落在 `this.resolved` 上 ⇒ 只读 config 就等于永远读不到存盘的口令,**每次开机
1489
+ * 都会重新生成一串新口令**,昨天推给手机的 30 天链接当天就失效。凡是持久化的键都必须走这里。
1490
+ */
1491
+ readAny(key) {
1492
+ const live = this.read(key)
1493
+ if (live !== '' && live !== undefined && live !== null && live !== false) return live
1494
+ const saved = this.resolved ? this.resolved[key] : undefined
1495
+ return saved === undefined || saved === null ? '' : saved
1496
+ }
1497
+
1498
+ write(key, value) { setVolatileValue(this.config, key, value) }
1499
+
1500
+ storeKey() {
1501
+ return 'remote|' + String(this.resolved.host || '') + '|' + String(this.credentialsPath || '')
1502
+ }
1503
+
1504
+ /** 把最近状态回灌进当前 fiber(fiber 刚被热重启时设置行不该是空的)。 */
1505
+ applyStore() {
1506
+ const saved = remoteStore()[this.storeKey()]
1507
+ if (!saved) return
1508
+ this.write('remoteUrl', String(saved.url || ''))
1509
+ this.write('remoteStatus', String(saved.status || ''))
1510
+ this.write('remotePhase', String(saved.phase || ''))
1511
+ this.write('remotePushResult', String(saved.pushResult || ''))
1512
+ this.write('remoteDone', String(saved.done || ''))
1513
+ }
1514
+
1515
+ saveStore() {
1516
+ remoteStore()[this.storeKey()] = {
1517
+ url: String(this.read('remoteUrl') || ''),
1518
+ status: String(this.read('remoteStatus') || ''),
1519
+ phase: String(this.read('remotePhase') || ''),
1520
+ pushResult: String(this.read('remotePushResult') || ''),
1521
+ done: String(this.read('remoteDone') || ''),
1522
+ }
1523
+ }
1524
+
1525
+ /**
1526
+ * 把值写进 volatile,但**不让自己的 volatile-update 监听把它当成用户操作**(否则会再跑一遍
1527
+ * persistEnabled/up,无限自激)。设置行读的是 volatile,开机时必须回灌一次,否则口令框是空的、
1528
+ * 复制链接也复制不出东西。
1529
+ */
1530
+ mirror(key, value) {
1531
+ if (this.read(key) === value) return
1532
+ this.mirroring = true
1533
+ try { this.write(key, value) } finally { this.mirroring = false }
1534
+ }
1535
+
1536
+ /** 口令:配置里的(凭据文件/补丁)优先,都没有就生成一串并存盘。 */
1537
+ ensureSecret() {
1538
+ const given = String(this.readAny('remoteSecret') || '').trim()
1539
+ if (given.length >= 16) return given
1540
+ const generated = newRemoteSecret()
1541
+ this.write('remoteSecret', generated)
1542
+ this.persist({ remoteSecret: generated })
1543
+ this.log('已生成远程访问口令(32 字节随机;存进凭据文件,不在补丁里)')
1544
+ return generated
1545
+ }
1546
+
1547
+ /** 落盘(凭据文件);用于口令与开关,避免它们进 cordis.patch.yml。 */
1548
+ persist(patch) {
1549
+ if (!this.credentialsPath) return
1550
+ try {
1551
+ const saved = this.writeCreds(this.credentialsPath, patch)
1552
+ this.log('远程访问设置已写回凭据文件 ' + saved.path)
1553
+ } catch (error) {
1554
+ this.log('写凭据文件失败:' + String(error?.message ?? error))
1555
+ }
1556
+ }
1557
+
1558
+ /** 起引擎(幂等)。@returns {Promise<void>} */
1559
+ async up() {
1560
+ const secret = this.ensureSecret()
1561
+ this.mirror('remoteSecret', secret)
1562
+ if (!this.engine) {
1563
+ this.engine = new RemoteAccess({
1564
+ log: this.log,
1565
+ secret,
1566
+ binPath: String(this.readAny('remoteBin') || ''),
1567
+ webLog: this.resolved.webLog,
1568
+ port: Number(this.readAny('remotePort') || 0) || 0,
1569
+ onUrl: (url) => this.onUrl(url),
1570
+ })
1571
+ } else {
1572
+ this.engine.setSecret(secret)
1573
+ }
1574
+ this.write('remotePhase', '正在连接…')
1575
+ this.write('remoteStatus', '')
1576
+ try {
1577
+ const state = await this.engine.start()
1578
+ if (state.running === false) {
1579
+ this.write('remotePhase', '未运行')
1580
+ this.write('remoteStatus', state.error || '启动失败')
1581
+ return
1582
+ }
1583
+ // 地址可能比 server.listen 晚几秒才拿到(等 cloudflared),先给个中间态。
1584
+ if (!state.url) this.write('remotePhase', '隧道建立中…')
1585
+ } catch (error) {
1586
+ this.write('remotePhase', '未运行')
1587
+ this.write('remoteStatus', '启动失败:' + String(error?.message ?? error))
1588
+ this.log('远程访问启动失败:' + String(error?.message ?? error))
1589
+ }
1590
+ }
1591
+
1592
+ /** 引擎拿到公网地址(或换了地址)时:镜像 + 首次/变更时推手机。 */
1593
+ onUrl(url) {
1594
+ this.write('remoteUrl', url)
1595
+ this.write('remotePhase', '隧道建立中…')
1596
+ this.write('remoteStatus', '')
1597
+ this.saveStore()
1598
+ const secret = String(this.readAny('remoteSecret') || '')
1599
+ if (this.pushedUrl === url) return
1600
+ this.pushedUrl = url
1601
+ // 先等 Cloudflare 边缘真的挂上再推(否则用户点开是 530 报错页,实测 3~8 秒)。
1602
+ Promise.resolve()
1603
+ .then(() => this.engine?.waitPublic?.())
1604
+ .then((ok) => {
1605
+ this.write('remotePhase', ok === false ? '已就绪(边缘可能还要几秒)' : '已就绪')
1606
+ this.saveStore()
1607
+ return this.pushLink(secret)
1608
+ })
1609
+ .catch(() => {})
1610
+ }
1611
+
1612
+ /** 把 30 天链接经 ntfy 送到手机(复用 RelayClient,不新增通道)。 */
1613
+ async pushLink(secret) {
1614
+ const url = String(this.read('remoteUrl') || '')
1615
+ if (!url || !secret) return { ok: false, error: '还没有可用地址' }
1616
+ if (!this.host || this.host.enabled !== true) return { ok: false, error: '推送通道未配置(先配通行证)' }
1617
+ const link = remoteMessage(url, secret)
1618
+ try {
1619
+ const result = await this.host.relay.push({
1620
+ title: 'DSH 远程访问地址',
1621
+ body: '手机/异地浏览器打开这个链接即可(30 天免登录):\n' + link,
1622
+ priority: 3,
1623
+ tags: ['globe_with_meridians'],
1624
+ })
1625
+ const ok = result?.status === 200
1626
+ this.write('remotePushResult', ok ? '已推送到手机' : ('推送失败:HTTP ' + String(result?.status)))
1627
+ this.log('远程链接推送 -> HTTP ' + String(result?.status))
1628
+ this.saveStore()
1629
+ return { ok }
1630
+ } catch (error) {
1631
+ this.write('remotePushResult', '推送异常:' + String(error?.message ?? error))
1632
+ this.saveStore()
1633
+ return { ok: false, error: String(error?.message ?? error) }
1634
+ }
1635
+ }
1636
+
1637
+ /** volatile-update 发生在 settings RPC 事务里 ⇒ 挪到下一个宏任务(同 PassProvisioner)。 */
1638
+ schedule() {
1639
+ if (this.scheduled) return
1640
+ this.scheduled = true
1641
+ setTimeout(() => {
1642
+ this.scheduled = false
1643
+ Promise.resolve(this.run()).catch(() => {})
1644
+ }, 0)
1645
+ }
1646
+
1647
+ /** 处理设置行的动作:push / reconnect / rotate。 */
1648
+ async run() {
1649
+ const seq = String(this.read('remoteSeq') || '')
1650
+ const action = String(this.read('remoteAction') || '')
1651
+ if (this.busy || seq === '' || seq === this.lastSeq) return
1652
+ if (action !== 'push' && action !== 'reconnect' && action !== 'rotate') return
1653
+ this.lastSeq = seq
1654
+ this.busy = true
1655
+ try {
1656
+ if (action === 'push') {
1657
+ await this.pushLink(String(this.readAny('remoteSecret') || ''))
1658
+ } else if (action === 'reconnect') {
1659
+ if (this.engine) this.engine.dispose()
1660
+ this.engine = null
1661
+ this.pushedUrl = ''
1662
+ await this.up()
1663
+ } else if (action === 'rotate') {
1664
+ const fresh = newRemoteSecret()
1665
+ this.write('remoteSecret', fresh)
1666
+ this.persist({ remoteSecret: fresh })
1667
+ if (this.engine) this.engine.setSecret(fresh)
1668
+ this.pushedUrl = ''
1669
+ this.write('remotePushResult', '')
1670
+ this.write('remoteStatus', '')
1671
+ this.log('远程访问口令已更换')
1672
+ await this.pushLink(fresh)
1673
+ }
1674
+ } catch (error) {
1675
+ this.write('remoteStatus', String(error?.message ?? error))
1676
+ } finally {
1677
+ this.write('remoteDone', seq)
1678
+ this.busy = false
1679
+ this.saveStore()
1680
+ }
1681
+ }
1682
+
1683
+ /** 开关的权威值:volatile(本次会话刚点过)优先,否则看凭据文件里的 `remoteEnabled`。 */
1684
+ isEnabled() {
1685
+ if (this.read('remoteAccess') === true) return true
1686
+ return String(this.resolved?.remoteEnabled || '') === 'on'
1687
+ }
1688
+
1689
+ /** 开关落盘(凭据文件;`off` 而不是删键——writeCredentialsPatch 只并非空字符串)。 */
1690
+ persistEnabled(on) {
1691
+ this.persist({ remoteEnabled: on ? 'on' : 'off' })
1692
+ }
1693
+
1694
+ start() {
1695
+ this.applyStore()
1696
+ const enabled = this.isEnabled()
1697
+ // 设置行的开关读的是 volatile 的 `remoteAccess`;开机时它总是默认 false,而真值在凭据文件里。
1698
+ // 不把真值写回去,界面就会显示「开启远程访问」而隧道其实正在跑(用户会以为开关坏了)。
1699
+ if (enabled && this.read('remoteAccess') !== true) {
1700
+ // 自己写自己监听:不设这个闸,下面那个 volatile-update 分支会把它当成「用户刚点开启」
1701
+ // 再跑一遍 persistEnabled + up()(会多写一次凭据文件、多启一次引擎)。
1702
+ this.mirroring = true
1703
+ try { this.write('remoteAccess', true) } finally { this.mirroring = false }
1704
+ }
1705
+ const mine = ['remoteSeq', 'remoteAction', 'remoteAccess', 'remoteSecret']
1706
+ this.ctx.on('loader/volatile-update', (paths) => {
1707
+ if (this.mirroring) return
1708
+ const touched = Array.isArray(paths) && paths.some((path) =>
1709
+ Array.isArray(path) && mine.includes(path[0]))
1710
+ if (!touched) return
1711
+ // 开关/口令是「配置」类变化,不需要请求号;直接按新配置重启引擎。
1712
+ const switched = Array.isArray(paths) && paths.some((path) =>
1713
+ Array.isArray(path) && (path[0] === 'remoteAccess' || path[0] === 'remoteSecret'))
1714
+ if (switched) {
1715
+ setTimeout(() => {
1716
+ const on = this.read('remoteAccess') === true
1717
+ this.persistEnabled(on)
1718
+ if (on) { Promise.resolve(this.up()).catch(() => {}) } else if (this.engine) {
1719
+ this.engine.dispose()
1720
+ this.engine = null
1721
+ this.write('remotePhase', '未开启')
1722
+ this.write('remoteUrl', '')
1723
+ this.saveStore()
1724
+ }
1725
+ }, 0)
1726
+ return
1727
+ }
1728
+ this.schedule()
1729
+ })
1730
+ if (!enabled) {
1731
+ this.write('remotePhase', '未开启')
1732
+ this.log('远程访问未开启(设置 → 常规 → 远程访问 里可打开)')
1733
+ return
1734
+ }
1735
+ // 本次 volatile 说开着、但凭据文件里还不是 on ⇒ 补齐。否则「第一次点开启」之后只要没
1736
+ // 走到 volatile-update 那条分支(例如开关值随 config 一起来),下次开机就又变回关着。
1737
+ if (this.read('remoteAccess') === true && String(this.resolved?.remoteEnabled || '') !== 'on') {
1738
+ this.persistEnabled(true)
1739
+ }
1740
+ Promise.resolve(this.up()).catch(() => {})
1741
+ }
1742
+ }
1743
+
1744
+ /** 远程访问镜像的进程级暂存(同 panelStore 的理由:volatile 写入会热重启 fiber)。 */
1745
+ export const REMOTE_STORE_KEY = '__DSHPUSH_REMOTE__'
1746
+
1747
+ export function remoteStore() {
1748
+ const g = globalThis
1749
+ if (!g[REMOTE_STORE_KEY]) g[REMOTE_STORE_KEY] = {}
1750
+ return g[REMOTE_STORE_KEY]
1751
+ }
1752
+
1397
1753
  export function apply(ctx, config = {}) {
1398
1754
  const { resolved, file } = resolvePluginConfig(config)
1399
1755
  const host = new PushHost(ctx, resolved)
@@ -1417,6 +1773,11 @@ export function apply(ctx, config = {}) {
1417
1773
  new PassProvisioner(ctx, config, resolved, (message) => host.log(message), {
1418
1774
  credentialsPath: file.path,
1419
1775
  }).start()
1776
+ // 远程访问(v0.4.0):默认关。开着就走「内层改写网关 + cloudflared 快速隧道」,
1777
+ // 把公网域名换成 30 天口令链接;隧道换了域名会自动重推给手机。
1778
+ new RemoteAccessor(ctx, config, resolved, host, (message) => host.log(message), {
1779
+ credentialsPath: file.path,
1780
+ }).start()
1420
1781
  }
1421
1782
 
1422
1783
  export { PushHost, resolveConfig, CONFIG_KEYS }
package/lib/relay.js CHANGED
@@ -27,8 +27,16 @@ export class RelayClient {
27
27
  try { return JSON.parse(text); } catch { return { ok: false, raw: text.slice(0, 200) }; }
28
28
  }
29
29
 
30
- /** Publish a notification to the phone through ntfy (same JSON body shape the app expects). */
31
- async push({ title, body, click, priority = 'default', tags = [] }) {
30
+ /**
31
+ * Publish a notification to the phone through ntfy (same JSON body shape the app expects).
32
+ *
33
+ * `priority` MUST be an integer 1-5. ntfy's JSON body only accepts numbers here: passing the
34
+ * string 'default' (which is what ntfy's *header* form uses) makes the server answer
35
+ * 400 `invalid request: request body must be valid JSON` — a message that points nowhere near
36
+ * the real cause. Callers inside this plugin always pass 1/3/4; the default used to be the
37
+ * string 'default', which broke every caller that omitted it.
38
+ */
39
+ async push({ title, body, click, priority = 3, tags = [] }) {
32
40
  const r = await this.fetchImpl(this.base + '/', {
33
41
  method: 'POST',
34
42
  headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + this.ntfyToken },
package/lib/remote.js ADDED
@@ -0,0 +1,596 @@
1
+ /**
2
+ * remote.js — 「不带 Tailscale 也能远程访问」的宿主侧实现(v0.4.0)。
3
+ *
4
+ * 目标:让**没装 Tailscale、没有公网 IP** 的人也能在手机/异地浏览器里打开这台机器的 DSH Web,
5
+ * 而且**不用重新登录/扫码**(凭据约 30 天有效)。
6
+ *
7
+ * 链路(全部进程内,不需要用户改 DSH 配置、不需要重启 DSH):
8
+ *
9
+ * 手机 ──https──▶ Cloudflare 快速隧道 ──▶ 本模块的【网关】(127.0.0.1:<port>)
10
+ * │ ① 自己的 30 天口令门
11
+ * │ ② 自动注入 DSH 的登录 cookie
12
+ * │ ③ Host/Origin/Referer 改写成回环
13
+ * ▼
14
+ * DSH Web (127.0.0.1:3081)
15
+ *
16
+ * 为什么必须有 ① 和 ②(2026-10-28 实测,缺一条都不行):
17
+ * - DSH 有两道栅栏:Host 不在白名单 → 403 `host-not-trusted`;Origin 不在白名单 →
18
+ * 403 `origin-not-trusted`。所以**光起隧道不行**,Host 和 Origin 都得改写成回环;
19
+ * - 但把 Host 改成回环以后,DSH 认的是它自己发的 `dsh-auth-<hash>` cookie,而那个 cookie
20
+ * 的签发 token 每次 `dsh web` 重启都会换(用户说的「不希望重启后还得再扫码」就是它)。
21
+ * 所以本模块**在内部**用 web.log 里的 token 自取 cookie 并随请求注入,对外只发自己的
22
+ * 长期口令 —— 会变的东西被藏在网关后面,用户手里的链接不用变。
23
+ *
24
+ * 为什么口令放在**链接里**(`?k=`)而不是只放 cookie:
25
+ * 快速隧道的域名每次重启都会变(`https://xxx.trycloudflare.com`),而 cookie 是绑域名的,
26
+ * 换域名就丢。把口令嵌进链接,域名怎么变都能一链直达;同时再种一个 30 天 cookie,
27
+ * 之后在站内点来点去就不用带参数了。
28
+ *
29
+ * @module dsh-push-host/remote
30
+ */
31
+ import http from 'node:http'
32
+ import { spawn } from 'node:child_process'
33
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
34
+ import { existsSync, mkdirSync, readFileSync, appendFileSync } from 'node:fs'
35
+ import { homedir } from 'node:os'
36
+ import { join } from 'node:path'
37
+
38
+ /** cookie 名(本模块自己发的,与 DSH 的 dsh-auth-* 互不相干)。 */
39
+ const COOKIE = 'dshpush-remote'
40
+ /** 30 天:用户明确要求「重启后不用再扫码」。 */
41
+ const COOKIE_MAX_AGE = 30 * 24 * 60 * 60
42
+ /** 逐跳头,不能转发。 */
43
+ const HOP_BY_HOP = new Set([
44
+ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
45
+ 'te', 'trailer', 'transfer-encoding', 'upgrade',
46
+ ])
47
+ /** 快速隧道地址出现在 cloudflared 日志里的形态。 */
48
+ const TUNNEL_URL_RE = /https:\/\/[a-z0-9][a-z0-9-]*\.trycloudflare\.com/
49
+ /** web.log 里 DSH 打印的访问链接。 */
50
+ const WEB_URL_RE = /dsh web: (https?:\/\/\S+)/
51
+
52
+ /** 定长比较,避免把口令比较写成可计时侧信道。 */
53
+ function safeEq(a, b) {
54
+ const x = Buffer.from(String(a))
55
+ const y = Buffer.from(String(b))
56
+ if (x.length !== y.length) return false
57
+ return timingSafeEqual(x, y)
58
+ }
59
+
60
+ /** 从 cookie 头里取一个键(不引第三方依赖)。 */
61
+ function cookieValue(header, name) {
62
+ if (typeof header !== 'string') return ''
63
+ for (const part of header.split(';')) {
64
+ const at = part.indexOf('=')
65
+ if (at < 0) continue
66
+ if (part.slice(0, at).trim() === name) return part.slice(at + 1).trim()
67
+ }
68
+ return ''
69
+ }
70
+
71
+ /** 去掉指定的 cookie 键,其余原样保留(拼回 Cookie 头)。 */
72
+ function dropCookie(header, name) {
73
+ if (typeof header !== 'string') return ''
74
+ return header
75
+ .split(';')
76
+ .map((part) => part.trim())
77
+ .filter((part) => part.length > 0 && !part.startsWith(name + '='))
78
+ .join('; ')
79
+ }
80
+
81
+ /** 32 字节随机口令,base64url 之后 43 字符(够短可贴,够长不可猜)。 */
82
+ export function newRemoteSecret() {
83
+ return randomBytes(32).toString('base64url')
84
+ }
85
+
86
+ /** cookie 里存的不是口令原文,而是它的 HMAC(cookie 泄露也不等于口令泄露)。 */
87
+ export function remoteCookieToken(secret) {
88
+ return createHmac('sha256', String(secret)).update('dshpush-remote-cookie-v1').digest('base64url')
89
+ }
90
+
91
+ /**
92
+ * 远程访问服务:管一条 cloudflared 子进程 + 一个改写网关。
93
+ *
94
+ * 生命周期由宿主插件的 fiber 管(unload 时 `dispose()`),失败不致命:
95
+ * 任何一步起不来都只是 `state.lastError` 有值,推送/作答链路照常。
96
+ */
97
+ export class RemoteAccess {
98
+ /**
99
+ * @param {object} options
100
+ * @param {(message: string) => void} options.log 日志出口(宿主 logger)。
101
+ * @param {string} options.secret 长期口令(空 = 未启用)。
102
+ * @param {string} [options.binPath] cloudflared 可执行文件(空 = 按默认路径找)。
103
+ * @param {string} [options.webLog] DSH 的 web.log 路径(取 token / 端口)。
104
+ * @param {number} [options.port] 网关监听端口(0 = 随机挑一个空闲端口)。
105
+ * @param {(url: string) => void} [options.onUrl] 公网地址变化时回调(用于推送手机)。
106
+ */
107
+ constructor(options) {
108
+ this.log = options.log ?? (() => {})
109
+ this.secret = String(options.secret ?? '')
110
+ this.binPath = String(options.binPath ?? '')
111
+ this.webLog = String(options.webLog ?? join(homedir(), '.dsh', 'web.log'))
112
+ this.port = Number(options.port ?? 0) || 0
113
+ this.onUrl = options.onUrl ?? (() => {})
114
+
115
+ /** 网关实际端口(start 后才有值)。 */
116
+ this.listenPort = 0
117
+ /** 当前公网地址(空 = 还没起来)。 */
118
+ this.url = ''
119
+ /** 最近一次错误('' = 正常)。 */
120
+ this.lastError = ''
121
+ this.startedAt = 0
122
+ this.tunnelStarts = 0
123
+
124
+ this.server = null
125
+ this.child = null
126
+ this.disposed = false
127
+ this.restartTimer = null
128
+ /** DSH 自己的登录 cookie 缓存:`dsh-auth-xxx=v1.…`。 */
129
+ this.dshCookie = ''
130
+ this.dshCookieAt = 0
131
+ }
132
+
133
+ /** 给设置行/诊断用的状态快照。 */
134
+ state() {
135
+ return {
136
+ running: this.server !== null,
137
+ url: this.url,
138
+ port: this.listenPort,
139
+ error: this.lastError,
140
+ since: this.startedAt,
141
+ restarts: this.tunnelStarts,
142
+ hasSecret: this.secret.length > 0,
143
+ }
144
+ }
145
+
146
+ /** 找 cloudflared:显式配置 > ~/.dsh/bin > PATH。 */
147
+ resolveBin() {
148
+ if (this.binPath) return this.binPath
149
+ const local = join(homedir(), '.dsh', 'bin', process.platform === 'win32' ? 'cloudflared.exe' : 'cloudflared')
150
+ if (existsSync(local)) return local
151
+ return process.platform === 'win32' ? 'cloudflared.exe' : 'cloudflared'
152
+ }
153
+
154
+ /** 起网关 + 隧道。重复调用安全(已起就返回)。 */
155
+ async start() {
156
+ if (this.disposed) return this.state()
157
+ if (this.secret.length === 0) {
158
+ this.lastError = '未设置远程口令'
159
+ return this.state()
160
+ }
161
+ if (this.server) return this.state()
162
+
163
+ // 先把 DSH 的 cookie 拿到,网关一起来就能直接放行;拿不到不算致命(请求时会再试)。
164
+ await this.refreshDshCookie().catch(() => {})
165
+
166
+ await new Promise((resolve, reject) => {
167
+ const server = http.createServer((req, res) => {
168
+ this.handle(req, res).catch((error) => {
169
+ try { res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' }) } catch { /* already sent */ }
170
+ try { res.end('dsh-push remote: ' + String(error?.message ?? error)) } catch { /* gone */ }
171
+ })
172
+ })
173
+ server.on('upgrade', (req, socket, head) => this.handleUpgrade(req, socket, head))
174
+ server.on('error', (error) => {
175
+ this.lastError = '网关监听失败:' + String(error?.message ?? error)
176
+ reject(error)
177
+ })
178
+ server.listen(this.port, '127.0.0.1', () => {
179
+ this.server = server
180
+ this.listenPort = server.address().port
181
+ this.startedAt = Date.now()
182
+ this.lastError = ''
183
+ this.log('远程网关已监听 127.0.0.1:' + String(this.listenPort))
184
+ resolve()
185
+ })
186
+ })
187
+
188
+ this.spawnTunnel()
189
+ return this.state()
190
+ }
191
+
192
+ /** 停网关 + 隧道(可重复调用)。 */
193
+ dispose() {
194
+ this.disposed = true
195
+ if (this.restartTimer !== null) {
196
+ clearTimeout(this.restartTimer)
197
+ this.restartTimer = null
198
+ }
199
+ this.killTunnel()
200
+ if (this.server) {
201
+ try { this.server.close() } catch { /* already closed */ }
202
+ this.server = null
203
+ }
204
+ this.url = ''
205
+ }
206
+
207
+ /** 换口令:清掉旧 cookie 缓存,网关不用重起。 */
208
+ setSecret(secret) {
209
+ this.secret = String(secret ?? '')
210
+ }
211
+
212
+ /**
213
+ * 等公网地址真的可用再交出去(2026-10-28 实测:cloudflared 打印出域名后,Cloudflare 边缘
214
+ * 还要 3~8 秒才把这个主机名挂上,这期间访问回 **530**)。如果这时就把链接推给手机,用户点
215
+ * 进去看到的是 Cloudflare 的报错页,会以为功能坏了。所以推送前先自打一遍:
216
+ * 只要不是 5xx 就算就绪(401 是口令门,说明链路已通)。
217
+ * @param {number} [timeoutMs] 最多等多久(默认 60 秒)。
218
+ * @returns {Promise<boolean>} 是否就绪。
219
+ */
220
+ async waitPublic(timeoutMs = 60000) {
221
+ const url = this.url
222
+ if (!url) return false
223
+ const deadline = Date.now() + timeoutMs
224
+ while (Date.now() < deadline) {
225
+ if (this.disposed) return false
226
+ try {
227
+ const res = await fetch(url + '/__dshpush/status', { redirect: 'manual' })
228
+ if (res.status < 500) {
229
+ this.log('公网链路已就绪(status=' + String(res.status) + ')')
230
+ return true
231
+ }
232
+ } catch { /* 还没就绪,继续等 */ }
233
+ await new Promise((resolve) => setTimeout(resolve, 1500))
234
+ }
235
+ this.log('公网链路 ' + String(Math.round(timeoutMs / 1000)) + ' 秒内未就绪(链接可能点开会先看到 Cloudflare 的 530)')
236
+ return false
237
+ }
238
+
239
+ killTunnel() {
240
+ if (!this.child) return
241
+ const child = this.child
242
+ this.child = null
243
+ try { child.kill() } catch { /* already gone */ }
244
+ }
245
+
246
+ /** 起 cloudflared 快速隧道,从它自己的日志文件里读出公网地址。 */
247
+ spawnTunnel() {
248
+ if (this.disposed) return
249
+ const bin = this.resolveBin()
250
+ const logDir = join(homedir(), '.dsh', 'logs')
251
+ try { mkdirSync(logDir, { recursive: true }) } catch { /* best effort */ }
252
+ const logFile = join(logDir, 'cloudflared-remote.log')
253
+ try { appendFileSync(logFile, '\n--- start ' + new Date().toISOString() + ' ---\n') } catch { /* best effort */ }
254
+
255
+ // 每次起隧道前把日志读到末尾位置,只认本次新增的行,避免重启后误读上一轮那个已失效的域名。
256
+ let offset = 0
257
+ try { offset = readFileSync(logFile, 'utf8').length } catch { offset = 0 }
258
+ let settled = false
259
+
260
+ const args = [
261
+ 'tunnel',
262
+ '--url', 'http://127.0.0.1:' + String(this.listenPort),
263
+ '--no-autoupdate',
264
+ '--logfile', logFile,
265
+ '--loglevel', 'info',
266
+ // 指标端口随机,避免和用户自己跑的 cloudflared 撞 20241。
267
+ '--metrics', '127.0.0.1:0',
268
+ ]
269
+ let child
270
+ try {
271
+ child = spawn(bin, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
272
+ } catch (error) {
273
+ this.lastError = '起不了 cloudflared:' + String(error?.message ?? error)
274
+ this.log(this.lastError)
275
+ this.scheduleRestart()
276
+ return
277
+ }
278
+ this.child = child
279
+ this.tunnelStarts += 1
280
+ this.log('cloudflared 已启动(第 ' + String(this.tunnelStarts) + ' 次):' + bin)
281
+
282
+ const tail = () => {
283
+ if (settled) return
284
+ let text = ''
285
+ try {
286
+ text = readFileSync(logFile, 'utf8').slice(offset)
287
+ } catch {
288
+ return
289
+ }
290
+ const hit = TUNNEL_URL_RE.exec(text)
291
+ if (!hit) return
292
+ settled = true
293
+ this.url = hit[0]
294
+ this.lastError = ''
295
+ this.log('公网地址:' + this.url)
296
+ try { this.onUrl(this.url) } catch { /* caller's problem */ }
297
+ }
298
+
299
+ const timer = setInterval(tail, 400)
300
+ // 60 秒还没拿到地址就判失败(实测正常 3~6 秒内就有)。
301
+ const giveUp = setTimeout(() => {
302
+ if (settled) return
303
+ settled = true
304
+ this.lastError = 'cloudflared 60 秒内没有给出公网地址'
305
+ this.log(this.lastError)
306
+ }, 60000)
307
+
308
+ child.on('exit', (code) => {
309
+ clearInterval(timer)
310
+ clearTimeout(giveUp)
311
+ if (this.child === child) this.child = null
312
+ if (this.disposed) return
313
+ this.url = ''
314
+ if (!settled) this.lastError = 'cloudflared 退出,code=' + String(code)
315
+ this.log('cloudflared 退出 code=' + String(code) + ',准备重连')
316
+ this.scheduleRestart()
317
+ })
318
+ child.on('error', (error) => {
319
+ clearInterval(timer)
320
+ clearTimeout(giveUp)
321
+ this.lastError = 'cloudflared 启动失败:' + String(error?.message ?? error)
322
+ this.log(this.lastError)
323
+ this.scheduleRestart()
324
+ })
325
+ }
326
+
327
+ /** 隧道断了就退避重连(10s → 20s → 40s,封顶 60s);地址变了会重新推送。 */
328
+ scheduleRestart() {
329
+ if (this.disposed || this.restartTimer !== null) return
330
+ const delay = Math.min(60000, 10000 * Math.pow(2, Math.min(3, this.tunnelStarts - 1)))
331
+ this.restartTimer = setTimeout(() => {
332
+ this.restartTimer = null
333
+ this.spawnTunnel()
334
+ }, delay)
335
+ }
336
+
337
+ /** 从 web.log 取当前 token,换成 DSH 自己的登录 cookie。 */
338
+ async refreshDshCookie() {
339
+ let text = ''
340
+ try {
341
+ text = readFileSync(this.webLog, 'utf8')
342
+ } catch {
343
+ this.log('读不到 web.log:' + this.webLog + '(远程访问需要它来取 DSH token)')
344
+ return ''
345
+ }
346
+ const matches = [...text.matchAll(new RegExp(WEB_URL_RE, 'g'))]
347
+ if (matches.length === 0) return ''
348
+ const url = matches[matches.length - 1][1]
349
+ let target
350
+ try {
351
+ target = new URL(url)
352
+ } catch {
353
+ return ''
354
+ }
355
+ // 只在回环上取 cookie:绝不用隧道域名去自打(那会再撞一次 Host/Origin 栅栏)。
356
+ target.hostname = '127.0.0.1'
357
+ const res = await fetch(target.toString(), { redirect: 'manual', headers: { 'cache-control': 'no-store' } })
358
+ const list = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : []
359
+ const auth = list.find((line) => line.startsWith('dsh-auth-'))
360
+ if (!auth) {
361
+ this.log('DSH 没有下发 dsh-auth-* cookie(status=' + String(res.status) + ')')
362
+ return this.dshCookie
363
+ }
364
+ this.dshCookie = auth.split(';')[0].trim()
365
+ this.dshCookieAt = Date.now()
366
+ this.log('已取得 DSH 登录 cookie(有效期由 DSH 决定,本模块会自动刷新)')
367
+ return this.dshCookie
368
+ }
369
+
370
+ /** 口令校验:链接里的 ?k= 或 30 天 cookie。 */
371
+ authorize(req) {
372
+ let query = ''
373
+ try {
374
+ query = new URL(req.url ?? '/', 'http://placeholder').searchParams.get('k') ?? ''
375
+ } catch {
376
+ query = ''
377
+ }
378
+ if (query.length > 0 && safeEq(query, this.secret)) return { ok: true, viaQuery: true }
379
+ const cookie = cookieValue(req.headers.cookie, COOKIE)
380
+ if (cookie.length > 0 && safeEq(cookie, remoteCookieToken(this.secret))) return { ok: true }
381
+ return { ok: false }
382
+ }
383
+
384
+ /** 登录页(用户手输口令,或从设置行复制链接)。 */
385
+ loginPage(message = '') {
386
+ return '<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">' +
387
+ '<title>DSH 远程访问</title>' +
388
+ '<style>body{font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;margin:0;' +
389
+ 'display:flex;min-height:100vh;align-items:center;justify-content:center;background:#f6f7fb;color:#1d2430}' +
390
+ 'form{background:#fff;padding:28px 24px;border-radius:16px;box-shadow:0 8px 30px rgba(20,30,60,.12);width:min(92vw,380px)}' +
391
+ 'h1{font-size:18px;margin:0 0 6px}p{font-size:13px;opacity:.7;margin:0 0 18px}' +
392
+ 'input{width:100%;box-sizing:border-box;padding:12px 14px;border:1px solid #d8dce6;border-radius:10px;font-size:15px}' +
393
+ 'button{margin-top:14px;width:100%;padding:12px;border:0;border-radius:10px;background:#2f6df6;color:#fff;font-size:15px}' +
394
+ '.err{margin-top:12px;color:#c0392b;font-size:13px}</style>' +
395
+ '<form method="post" action="/__dshpush/login">' +
396
+ '<h1>DSH 远程访问</h1><p>请输入这台机器的访问口令(在设置里生成,可用 30 天)。</p>' +
397
+ '<input name="k" type="password" placeholder="访问口令" autofocus autocomplete="current-password">' +
398
+ '<button type="submit">进入</button>' +
399
+ (message ? '<div class="err">' + message + '</div>' : '') +
400
+ '</form>'
401
+ }
402
+
403
+ /** 网关主处理:口令门 → 注入 DSH cookie → 改写 Host/Origin → 转发。 */
404
+ async handle(req, res) {
405
+ const path = (req.url ?? '/').split('?')[0]
406
+
407
+ // 登录表单提交(POST /__dshpush/login,body 是 k=…)
408
+ if (path === '/__dshpush/login' && req.method === 'POST') {
409
+ const body = await readBody(req, 4096)
410
+ const typed = new URLSearchParams(body).get('k') ?? ''
411
+ if (!safeEq(typed, this.secret)) {
412
+ res.writeHead(401, { 'content-type': 'text/html; charset=utf-8' })
413
+ res.end(this.loginPage('口令不对。'))
414
+ return
415
+ }
416
+ res.writeHead(303, {
417
+ location: '/',
418
+ 'set-cookie': COOKIE + '=' + remoteCookieToken(this.secret) + '; Max-Age=' + String(COOKIE_MAX_AGE) +
419
+ '; Path=/; HttpOnly; SameSite=Lax' + (this.isHttps(req) ? '; Secure' : ''),
420
+ 'cache-control': 'no-store',
421
+ })
422
+ res.end()
423
+ return
424
+ }
425
+
426
+ // 诊断口(要口令):给设置行/排障看状态,不含任何秘密。
427
+ if (path === '/__dshpush/status') {
428
+ if (!this.authorize(req).ok) {
429
+ res.writeHead(401, { 'content-type': 'application/json' })
430
+ res.end(JSON.stringify({ ok: false }))
431
+ return
432
+ }
433
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
434
+ res.end(JSON.stringify({ ok: true, ...this.state() }))
435
+ return
436
+ }
437
+
438
+ const auth = this.authorize(req)
439
+ if (!auth.ok) {
440
+ res.writeHead(401, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
441
+ res.end(this.loginPage())
442
+ return
443
+ }
444
+ // 链接里带口令进来的:立刻换成 cookie,并把 ?k= 从地址栏抹掉(避免被截图/转发泄露)。
445
+ if (auth.viaQuery) {
446
+ const query = new URL(req.url, 'http://placeholder')
447
+ query.searchParams.delete('k')
448
+ const rest = query.searchParams.toString()
449
+ res.writeHead(303, {
450
+ location: path + (rest ? '?' + rest : ''),
451
+ 'set-cookie': COOKIE + '=' + remoteCookieToken(this.secret) + '; Max-Age=' + String(COOKIE_MAX_AGE) +
452
+ '; Path=/; HttpOnly; SameSite=Lax' + (this.isHttps(req) ? '; Secure' : ''),
453
+ 'cache-control': 'no-store',
454
+ })
455
+ res.end()
456
+ return
457
+ }
458
+
459
+ this.forward(req, res, (status) => status === 401 || status === 403)
460
+ }
461
+
462
+ /** WebSocket 升级:同样先过口令门,再改写头转发。 */
463
+ handleUpgrade(req, socket, head) {
464
+ if (!this.authorize(req).ok) {
465
+ try { socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n') } catch { /* gone */ }
466
+ try { socket.destroy() } catch { /* gone */ }
467
+ return
468
+ }
469
+ const headers = this.upstreamHeaders(req)
470
+ // ★ 2026-10-28 实测:`upstreamHeaders` 会剔掉逐跳头(含 Connection/Upgrade),这对普通请求是
471
+ // 对的,但升级请求**必须**带这两个头,否则上游回的是普通响应、`upgrade` 事件永不触发——浏览器
472
+ // 那头表现成「WebSocket 挂着不连,最后 closed before established」。这里补回来。
473
+ headers.connection = 'Upgrade'
474
+ headers.upgrade = String(req.headers.upgrade || 'websocket')
475
+ const up = http.request({
476
+ host: '127.0.0.1',
477
+ port: this.targetPort(),
478
+ method: req.method,
479
+ path: req.url,
480
+ headers,
481
+ })
482
+ up.on('upgrade', (upRes, upSocket, upHead) => {
483
+ const lines = ['HTTP/1.1 101 Switching Protocols']
484
+ for (let i = 0; i < upRes.rawHeaders.length; i += 2) {
485
+ lines.push(upRes.rawHeaders[i] + ': ' + upRes.rawHeaders[i + 1])
486
+ }
487
+ try { socket.write(lines.join('\r\n') + '\r\n\r\n') } catch { /* gone */ }
488
+ if (upHead && upHead.length > 0) socket.write(upHead)
489
+ if (head && head.length > 0) upSocket.write(head)
490
+ upSocket.pipe(socket)
491
+ socket.pipe(upSocket)
492
+ })
493
+ up.on('error', () => {
494
+ try { socket.destroy() } catch { /* gone */ }
495
+ })
496
+ up.end()
497
+ }
498
+
499
+ /** 从 web.log 现取目标端口(DSH 可能换端口,每次现读更稳)。 */
500
+ targetPort() {
501
+ try {
502
+ const text = readFileSync(this.webLog, 'utf8')
503
+ const matches = [...text.matchAll(new RegExp(WEB_URL_RE, 'g'))]
504
+ if (matches.length > 0) return Number(new URL(matches[matches.length - 1][1]).port) || 3081
505
+ } catch { /* fall through */ }
506
+ return 3081
507
+ }
508
+
509
+ isHttps(req) {
510
+ const proto = String(req.headers['x-forwarded-proto'] ?? '').toLowerCase()
511
+ if (proto === 'https') return true
512
+ const host = String(req.headers.host ?? '')
513
+ return !/^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/.test(host) && !host.startsWith('127.0.0.1:')
514
+ }
515
+
516
+ /** 转发用的头:抹掉逐跳头、抹掉自己的 cookie、注入 DSH cookie、改回环。 */
517
+ upstreamHeaders(req) {
518
+ const headers = {}
519
+ for (const [key, value] of Object.entries(req.headers)) {
520
+ if (HOP_BY_HOP.has(key.toLowerCase())) continue
521
+ headers[key] = value
522
+ }
523
+ const target = '127.0.0.1:' + String(this.targetPort())
524
+ headers.host = target
525
+ if (headers.origin) headers.origin = 'http://' + target
526
+ if (headers.referer) {
527
+ try {
528
+ const ref = new URL(headers.referer)
529
+ headers.referer = 'http://' + target + ref.pathname + ref.search
530
+ } catch {
531
+ headers.referer = 'http://' + target + '/'
532
+ }
533
+ }
534
+ const kept = dropCookie(req.headers.cookie, COOKIE)
535
+ headers.cookie = [kept, this.dshCookie].filter((part) => part.length > 0).join('; ')
536
+ if (headers.cookie.length === 0) delete headers.cookie
537
+ return headers
538
+ }
539
+
540
+ /**
541
+ * 真正转发。`isRetry` 时先刷一次 DSH cookie(token 可能刚换过)再重发一次。
542
+ */
543
+ forward(req, res, retryOn, isRetry = false) {
544
+ const up = http.request({
545
+ host: '127.0.0.1',
546
+ port: this.targetPort(),
547
+ method: req.method,
548
+ path: req.url,
549
+ headers: this.upstreamHeaders(req),
550
+ }, (upRes) => {
551
+ const status = upRes.statusCode ?? 502
552
+ if (!isRetry && retryOn(status)) {
553
+ upRes.resume()
554
+ this.refreshDshCookie()
555
+ .then(() => this.forward(req, res, retryOn, true))
556
+ .catch(() => {
557
+ try { res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' }) } catch { /* sent */ }
558
+ try { res.end('dsh-push remote: DSH 认证失败且刷新 cookie 也失败') } catch { /* gone */ }
559
+ })
560
+ return
561
+ }
562
+ const headers = {}
563
+ for (const [key, value] of Object.entries(upRes.headers)) {
564
+ if (HOP_BY_HOP.has(key.toLowerCase())) continue
565
+ headers[key] = value
566
+ }
567
+ try { res.writeHead(status, headers) } catch { /* headers already sent */ }
568
+ upRes.pipe(res)
569
+ })
570
+ up.on('error', (error) => {
571
+ try { res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' }) } catch { /* sent */ }
572
+ try { res.end('dsh-push remote: ' + String(error?.message ?? error)) } catch { /* gone */ }
573
+ })
574
+ req.pipe(up)
575
+ }
576
+ }
577
+
578
+ /** 读一小段请求体(登录表单用),超过上限直接截断。 */
579
+ function readBody(req, limit) {
580
+ return new Promise((resolve) => {
581
+ let data = ''
582
+ req.setEncoding('utf8')
583
+ req.on('data', (chunk) => {
584
+ if (data.length < limit) data += chunk
585
+ })
586
+ req.on('end', () => resolve(data.slice(0, limit)))
587
+ req.on('error', () => resolve(data.slice(0, limit)))
588
+ })
589
+ }
590
+
591
+ /** 拼一条给手机看的文本:地址 + 30 天口令链接。 */
592
+ export function remoteMessage(url, secret) {
593
+ if (!url) return ''
594
+ const link = url + '/?k=' + encodeURIComponent(secret)
595
+ return link
596
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-push-host",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "DSH host-side phone push: question events to ntfy + in-process answering via relay polling (replaces the external notifier + answer bridge)",
5
5
  "keywords": [
6
6
  "dsh",
@@ -30,6 +30,7 @@
30
30
  "./pairing": "./lib/pairing.mjs",
31
31
  "./pairing-lib": "./lib/pairing-lib.mjs",
32
32
  "./relay": "./lib/relay.js",
33
+ "./remote": "./lib/remote.js",
33
34
  "./cordis.patch.yml": "./cordis.patch.yml",
34
35
  "./package.json": "./package.json"
35
36
  },