dsh-in-hand 1.7.1 → 1.7.3
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 +58 -11
- package/lib/index.js +103 -20
- package/lib/ticket.js +194 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -124,7 +124,7 @@ DSH 的 Web GUI 没有内置鉴权,而默认 preset 往往是 `danger-full-acc
|
|
|
124
124
|
| `/taskwatch/chat/create` | `{}` 或 `{workspace}` | 新建会话;`workspace` 必须命中 `/taskwatch/chat/workspaces` 列出的路径 |
|
|
125
125
|
| `/taskwatch/chat/model` | `{sessionId,provider,model}` | 切换该会话使用的模型 |
|
|
126
126
|
| `/taskwatch/chat/queue/reorder` | `{sessionId,ids:[...]}` | 重排下一轮队列;`ids` 必须是当前队列的完整 id 列表 |
|
|
127
|
-
| `/taskwatch/ticket` | `{to:'remote'\|'lan'}` | 签发 60
|
|
127
|
+
| `/taskwatch/ticket` | `{to:'remote'\|'lan'}` | 签发 60 秒的 **v2 HMAC 换路票据**(`v2.<exp>.<nonce>.<hmac>`),宿主与中继用同一密钥互认;只在内存里记已用的 nonce,不碰会话 / 文件 / 配置。没配 `publicUrl` 时远端请求回 `409 no-public-entry` |
|
|
128
128
|
|
|
129
129
|
全部注册在 `ctx.webServer` 上,不代理 DSH 自身的 `/api`,也不转发任意路径。
|
|
130
130
|
写入口就只有上面 6 条对话入口 + 1 条换路票据 —— 契约测试会**正面清点**路由表,
|
|
@@ -464,19 +464,33 @@ PWA 外壳由中继本地提供(而不是转发上游),这样它不随上
|
|
|
464
464
|
|
|
465
465
|
所以页面点切换时,先向当前这条路的**宿主**(中继,或插件宿主本身)要一张凭据:
|
|
466
466
|
|
|
467
|
-
1. `POST /taskwatch/ticket`(中继上要带令牌)→ 返回另一条路的 URL
|
|
468
|
-
2.
|
|
469
|
-
|
|
470
|
-
|
|
467
|
+
1. `POST /taskwatch/ticket`(中继上要带令牌)→ 返回另一条路的 URL,末尾带 `?t=…`;
|
|
468
|
+
2. 手机跳过去,对面兑换这张票并给那条路种下 cookie,于是不用重新输那 6 位配对码;
|
|
469
|
+
3. 中继与插件宿主**跑在同一台机器、同一个用户下**,用同一个密钥验签,所以宿主签的票
|
|
470
|
+
打到公网(域名后面是中继)也能被中继认,反之亦然。
|
|
471
471
|
|
|
472
472
|
**v1.7.1 起,前两条插件宿主也自己实现了**(同一套语义):`GET /taskwatch/entries`
|
|
473
|
-
回答「现在有哪几条路」,`POST /taskwatch/ticket`
|
|
473
|
+
回答「现在有哪几条路」,`POST /taskwatch/ticket` 给一张换路票据。
|
|
474
474
|
所以从宿主页面(例如 `http://127.0.0.1:52676/taskwatch`)点「去远端」不再拿到 405 / 404。
|
|
475
475
|
`GET /taskwatch/upstream-health` 仍只有中继实现;宿主页面拿到它的 404 时按「未知」处理,
|
|
476
476
|
并停掉探测 —— **不会**把「没有体检接口」误判成「这条路断了」。
|
|
477
477
|
|
|
478
|
+
#### 票据格式(v1.7.2)
|
|
479
|
+
|
|
480
|
+
```text
|
|
481
|
+
t = 'v2.' + <expMs 十进制> + '.' + <nonce base64url> + '.' + <hmac base64url>
|
|
482
|
+
HMAC = HMAC-SHA256(secret, 'v2|' + expMs + '|' + nonce)
|
|
483
|
+
nonce = crypto.randomBytes(16) # base64url 后 22 个字符
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
校验顺序:格式 → `timingSafeEqual` 比 HMAC → `expMs > now` → `expMs - now <= 180000`
|
|
487
|
+
(60 秒 TTL + 时钟偏差)。签名不对 / 过期 / 格式不符一律当作「不是本票」;只有**不是**
|
|
488
|
+
`v2.` 形状的字符串才回落到原来的进程内 Map(老页面、老票仍然能用)。票据有效期内
|
|
489
|
+
两边各自维护一个已用 nonce 的 Set(带 TTL 清理)做**进程内 best-effort 去重** ——
|
|
490
|
+
这不是严格一次性,详见下面「安全边界」。
|
|
491
|
+
|
|
478
492
|
公网地址只来自插件配置项 `publicUrl`(也支持环境变量 `DSH_TASKWATCH_PUBLIC_URL`),
|
|
479
|
-
|
|
493
|
+
局域网入口可以选配 `lanUrl`(也支持 `DSH_TASKWATCH_LAN_URL`),代码里不写死任何域名:
|
|
480
494
|
|
|
481
495
|
```yaml
|
|
482
496
|
- insert:
|
|
@@ -484,17 +498,33 @@ PWA 外壳由中继本地提供(而不是转发上游),这样它不随上
|
|
|
484
498
|
name: dsh-in-hand
|
|
485
499
|
config:
|
|
486
500
|
publicUrl: https://dsh.example.com
|
|
501
|
+
lanUrl: http://192.168.1.5:3099 # 指向中继的局域网入口(有令牌门)
|
|
487
502
|
```
|
|
488
503
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
`
|
|
504
|
+
配了 `lanUrl` 就把 `entries` 的局域网条目换成它,不再自动探测内网 IP;没配保持现状。
|
|
505
|
+
`lanUrl` 接受 `http://192.168.1.5:3099`,也接受入口的完整地址
|
|
506
|
+
`http://192.168.1.5:3099/taskwatch`(只吃掉这一个后缀,entries / ticket 还会自己再拼
|
|
507
|
+
`/taskwatch`);其它路径一律按未配置处理,**并在启动时打一条告警**(v1.7.3 之前是静默丢弃)。
|
|
508
|
+
**插件自己的路由没有令牌门**(授权由中继那层负责),所以建议把 `lanUrl` 指到有鉴权的
|
|
509
|
+
中继,而不是直接指回插件宿主。
|
|
510
|
+
|
|
511
|
+
票据接口的请求体字段就是 `to`:页面发 `{"to":"remote"}` / `{"to":"lan"}`,
|
|
512
|
+
服务端读 `body.to`(缺省 / 未知值都按 `lan` 处理,不会误签远端票)。这条契约由
|
|
513
|
+
`scripts/check-contract.mjs` 钉住,改页面或改服务端时必须同步。
|
|
514
|
+
|
|
515
|
+
没配 `publicUrl` 时只报局域网入口;`ticket` 的远端请求明确回 `409 no-public-entry`。
|
|
516
|
+
宿主这层仍然不做令牌校验(401 由中继负责),但票据是写路由,所以和中继的写路由同一套
|
|
517
|
+
门禁:`POST` + `application/json` + 同源(`Origin` / `Sec-Fetch-Site`)校验。
|
|
492
518
|
|
|
493
519
|
页面侧遇到这两条接口的 **404 / 405** 会进入**静态降级**:不重试、不弹错,胶囊按
|
|
494
520
|
「只有一个入口」隐藏;设置 / 任务详情里留一行说明与一个手动「去远端」链接
|
|
495
521
|
(上次成功的地址记在 `localStorage['tw-remote-url']`)。500 / 502 / 网络错误只提示一次
|
|
496
522
|
(同一句 30 秒内不重复弹),不会变成重试风暴。
|
|
497
523
|
|
|
524
|
+
> **升级后公网换路仍是 401?** 先看 3099 上的中继进程是不是新代码:它在**启动时**
|
|
525
|
+
> 一次性加载路由与票据密钥,不会热更新。带令牌调一次 `POST /taskwatch/ticket`,
|
|
526
|
+
> 返回的 `t` 不是 `v2.` 形状就说明还在跑旧进程 —— 重启中继即可,插件这侧无需改动。
|
|
527
|
+
|
|
498
528
|
> 中继那边另有独立实现,见中继仓库 `dsh-in-hand-relay`(该仓库尚未创建,此处只写仓库名)。
|
|
499
529
|
> 中继没升级时,页面在宿主上仍能正常用,只是没有远端切换。
|
|
500
530
|
|
|
@@ -523,6 +553,17 @@ PWA 外壳由中继本地提供(而不是转发上游),这样它不随上
|
|
|
523
553
|
- 手机上做的每一步都会落进该会话的日志,桌面上打开同一个会话就能看到全部经过 ——
|
|
524
554
|
这是"远程操作可追溯"的那一半。
|
|
525
555
|
- 插件的路由只在**回环**上监听,从不对外。
|
|
556
|
+
- **跨进程票据密钥**放在 `<用户主目录>/.dsh/taskwatch-ticket.key`(UTF-8、`trim` 后
|
|
557
|
+
至少 32 字符)。两边的解析顺序完全一致:环境变量 `TASKWATCH_TICKET_SECRET` → 该文件
|
|
558
|
+
→ 都没有时由**先用到的一方生成**(`crypto.randomBytes(32).toString('base64url')`)。
|
|
559
|
+
Windows 下生成后用 `icacls <file> /inheritance:r /grant:r <当前用户>:F` 去掉继承、
|
|
560
|
+
只留当前用户;Linux/macOS 用 `chmod 600`。该文件**绝不能进任何 git 仓库**:插件仓库的
|
|
561
|
+
`.gitignore` 已加 `taskwatch-ticket.key` 与 `*.key`,真实文件在 `~/.dsh/` 下、
|
|
562
|
+
本来就不在仓库里。
|
|
563
|
+
- **票据不是严格一次性**。签名 + 60 秒时效 + 两边各自的进程内 nonce 去重,把重放窗口
|
|
564
|
+
压得很小,但跨进程没有共享的「已用」状态 —— 一张从未被这两个进程见过的票,理论上
|
|
565
|
+
在有效期内仍可能被重放一次。要严格一次性就得引入共享存储(Redis / SQLite),
|
|
566
|
+
当前「换路跳转」的场景不值得为此多一个依赖与故障点。
|
|
526
567
|
- 页面带 `X-Robots-Tag: noindex, nofollow`,响应带 `nosniff`。除 `/taskwatch/data`
|
|
527
568
|
(见下)外一律 `no-store`。
|
|
528
569
|
- service worker 的作用域**限定为 `/taskwatch`**(注册时即指定,脚本也放在
|
|
@@ -569,7 +610,7 @@ PWA 外壳由中继本地提供(而不是转发上游),这样它不随上
|
|
|
569
610
|
## 开发
|
|
570
611
|
|
|
571
612
|
```sh
|
|
572
|
-
npm run check # bundle
|
|
613
|
+
npm run check # bundle、文件边界、上传路由、页面契约与票据 HMAC 互认测试
|
|
573
614
|
node scripts/make-icons.mjs # 重新生成图标(自实现 PNG 编码,零依赖)
|
|
574
615
|
node scripts/make-demo.mjs # 生成 docs/demo.html(假数据,供截图与预览)
|
|
575
616
|
```
|
|
@@ -584,6 +625,12 @@ node scripts/make-demo.mjs # 生成 docs/demo.html(假数据,供截图
|
|
|
584
625
|
失败重试、数量 / 总大小限制、错误的请求方法与类型,以及宿主缺少上传服务时的错误提示;
|
|
585
626
|
测试只使用内存 fake 服务,不会连接真实模型。
|
|
586
627
|
|
|
628
|
+
`check-ticket-hmac.mjs` 覆盖跨进程票据:宿主签 → 中继实例验、中继签 → 宿主验、篡改签名、
|
|
629
|
+
过期 / 超长跨度、老格式随机票仍走原 Map、以及密钥文件自动生成(只断言存在与长度,
|
|
630
|
+
从不打印内容)。中继那一侧在本机存在时是**真的 spawn 一个临时端口实例**(临时令牌、
|
|
631
|
+
不碰 3099 生产进程,测完杀掉);中继目录不在本机时这两条明确 SKIP,公开仓库的 CI 不会
|
|
632
|
+
因此变红。
|
|
633
|
+
|
|
587
634
|
为什么宿主也要钉住:Cordis 的 `inject` 决定框架等哪些服务就绪后才调用 `apply`。
|
|
588
635
|
`webServer` 的服务是在启动阶段之后才发布的,而 `apply` 在组合期就会被调用——
|
|
589
636
|
只写 `ctx.get('webServer')` 软读会在那一刻拿到 `undefined`,于是整段路由注册被
|
package/lib/index.js
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
* 子代理树、目标轮次与阻塞、工作流阶段、等待审批/提问。
|
|
10
10
|
*
|
|
11
11
|
* 只读为主:除对话接口外不写宿主状态、不代理 DSH 自身的 /api。唯一的例外是
|
|
12
|
-
* 换路票据(POST /taskwatch/ticket)——
|
|
13
|
-
*
|
|
12
|
+
* 换路票据(POST /taskwatch/ticket)—— v1.7.2 起签的是 60 秒、HMAC 签名的 v2 票,
|
|
13
|
+
* 与中继共用同一个本机密钥,所以跨进程能互认;进程内只记已用的 nonce,不碰会话、
|
|
14
|
+
* 文件或配置,见下方「双入口」一节与 lib/ticket.js。
|
|
14
15
|
*
|
|
15
16
|
* 静态资源(PWA)都挂在 /taskwatch/ 之下,service worker 的作用域也限定为
|
|
16
17
|
* /taskwatch —— 绝不允许它有机会拦截主 GUI 的请求。
|
|
@@ -23,6 +24,9 @@ import { dirname, join } from 'node:path'
|
|
|
23
24
|
// 交付文件的解析与安全边界都在 files.js —— 白名单、穿越防护、类型白名单需要能被
|
|
24
25
|
// 测试直接调用,放在路由闭包里就证明不了。
|
|
25
26
|
import { MAX_BYTES, dispositionFor, infoOf, resolveTarget, rootsFrom } from './files.js'
|
|
27
|
+
// 跨进程换路票据:宿主与中继共用同一套 v2 HMAC 格式与密钥解析顺序。
|
|
28
|
+
// 单独成模块是为了纯函数能被测试直接钉住,也避免两边各写一份实现后悄悄漂移。
|
|
29
|
+
import { createTicketGate, loadTicketSecret } from './ticket.js'
|
|
26
30
|
|
|
27
31
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
28
32
|
|
|
@@ -161,6 +165,10 @@ const ENV_ASSIGN = /^\$env:[A-Za-z_][A-Za-z0-9_]*\s*=/i
|
|
|
161
165
|
// 现在宿主按中继的语义补齐这两条;公网地址只从配置项 publicUrl 来,**绝不写死域名**。
|
|
162
166
|
// 没配就只报价局域网入口,ticket 明确回 409 no-public-entry。
|
|
163
167
|
//
|
|
168
|
+
// v1.7.2:票据从"进程内随机串"升级成 v2 HMAC。根因是宿主签的票打到公网(域名后面是
|
|
169
|
+
// 中继)→ 中继只认自己 Map 里的串 → 401。两边共用 <home>/.dsh/taskwatch-ticket.key,
|
|
170
|
+
// 所以现在互认;老格式随机票仍回落到进程内 Map。
|
|
171
|
+
//
|
|
164
172
|
// 鉴权边界不变:宿主这层不做令牌校验(和中继的分工见 README),但 ticket 是**写**路由,
|
|
165
173
|
// 所以和中继的写路由一样要求 POST + application/json(跨站表单发不出这个 content-type,
|
|
166
174
|
// 天然需要 preflight,而宿主不应答 CORS 预检),另加一道 Origin / Sec-Fetch-Site 校验。
|
|
@@ -201,8 +209,26 @@ export function lanAddresses(ifaces, includeVirtual = false) {
|
|
|
201
209
|
return out
|
|
202
210
|
}
|
|
203
211
|
|
|
212
|
+
/** 局域网入口也可以指向别处(通常是中继的局域网入口)。
|
|
213
|
+
*
|
|
214
|
+
* v1.7.3 之前这里和 publicUrl 共用同一个"只收纯 origin"的归一化函数,结果很自然
|
|
215
|
+
* 会写出来的 `http://10.0.51.18:3099/taskwatch`(入口的完整 URL)被**静默丢弃**,
|
|
216
|
+
* 再回落到自动探测内网 IP —— 用户看到的就是"配了 lanUrl 却没生效"。
|
|
217
|
+
* 现在允许且只允许入口自己的那个 `/taskwatch` 后缀:entries / ticket 还会再拼一次,
|
|
218
|
+
* 不把后缀吃掉就会拼成 /taskwatch/taskwatch。其它路径仍然当作没配(返回 ''),
|
|
219
|
+
* 调用方会打一条告警,不再无声无息。 */
|
|
220
|
+
export function normalizeLanUrl(raw) {
|
|
221
|
+
const text = typeof raw === 'string' ? raw.trim() : ''
|
|
222
|
+
if (!text) return ''
|
|
223
|
+
const trimmed = text.replace(/\/+$/, '').replace(/\/taskwatch$/, '')
|
|
224
|
+
return normalizePublicUrl(trimmed)
|
|
225
|
+
}
|
|
226
|
+
|
|
204
227
|
/** 票据存储:60 秒、一次性。单独抽出来的原因是"过期"要能被测试钉住 ——
|
|
205
|
-
* 真等 60 秒的测试不会有人跑,而"只删不校验过期"是这类实现最常见的漏洞。
|
|
228
|
+
* 真等 60 秒的测试不会有人跑,而"只删不校验过期"是这类实现最常见的漏洞。
|
|
229
|
+
*
|
|
230
|
+
* v1.7.2 起它只承担**向后兼容**:老页面的随机串仍在这个 Map 里兑换;新签发的
|
|
231
|
+
* 票是 v2 HMAC(见 lib/ticket.js),跨进程可用。 */
|
|
206
232
|
export function createTicketStore({ ttlMs = TICKET_TTL_MS, now = Date.now } = {}) {
|
|
207
233
|
const tickets = new Map()
|
|
208
234
|
return {
|
|
@@ -1858,10 +1884,43 @@ export function apply(ctx, config) {
|
|
|
1858
1884
|
const settings = config && typeof config === 'object' ? config : {}
|
|
1859
1885
|
// 公网地址只从配置来:插件配置项 publicUrl,其次环境变量(与 DSH_TASKWATCH_ROOTS
|
|
1860
1886
|
// 同一习惯)。没有配置就只报局域网入口 —— 代码里绝不写死任何域名。
|
|
1861
|
-
const
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
const
|
|
1887
|
+
const rawPublicUrl = asText(settings.publicUrl) || asText(process.env.DSH_TASKWATCH_PUBLIC_URL)
|
|
1888
|
+
const publicUrl = normalizePublicUrl(rawPublicUrl)
|
|
1889
|
+
// 局域网入口可以指到别处(通常是有令牌门的中继)。没配就沿用自动探测内网 IP。
|
|
1890
|
+
const rawLanUrl = asText(settings.lanUrl) || asText(process.env.DSH_TASKWATCH_LAN_URL)
|
|
1891
|
+
const lanUrl = normalizeLanUrl(rawLanUrl)
|
|
1892
|
+
// 配了却归一化成空,以前是**静默**回落 —— 这正是"lanUrl 配了不生效"最难查的地方。
|
|
1893
|
+
// 只写键名与预期形态,绝不回显值(URL 里可能带凭据)。
|
|
1894
|
+
if (rawPublicUrl && !publicUrl) {
|
|
1895
|
+
console.error('dsh-in-hand: publicUrl 已配置但无效,按未配置处理'
|
|
1896
|
+
+ '(需要纯 origin,例如 https://dsh.example.com,不能带路径)')
|
|
1897
|
+
}
|
|
1898
|
+
if (rawLanUrl && !lanUrl) {
|
|
1899
|
+
console.error('dsh-in-hand: lanUrl 已配置但无效,按未配置处理'
|
|
1900
|
+
+ '(需要 origin,例如 http://192.168.1.5:3099;只允许带 /taskwatch 后缀)')
|
|
1901
|
+
}
|
|
1902
|
+
// 票据 v2:密钥解析顺序在 lib/ticket.js 里与中继保持一致;文件权限也在那里收紧。
|
|
1903
|
+
// **按需初始化**(第一次签发/兑换时才解析)—— 只读监控、不换路的会话不该在启动时
|
|
1904
|
+
// 就去碰用户主目录,测试里也能避免无意写入真实 ~/.dsh。
|
|
1905
|
+
// 注意日志只写来源与路径,绝不打印密钥本身。
|
|
1906
|
+
let ticketGate = null
|
|
1907
|
+
function tickets() {
|
|
1908
|
+
if (ticketGate) return ticketGate
|
|
1909
|
+
const resolved = loadTicketSecret()
|
|
1910
|
+
if (resolved.source === 'generated') {
|
|
1911
|
+
console.log('dsh-in-hand: 已生成跨进程票据密钥 ' + resolved.file
|
|
1912
|
+
+ '(权限:' + resolved.permission + ')')
|
|
1913
|
+
} else if (resolved.source === 'memory') {
|
|
1914
|
+
console.error('dsh-in-hand: 票据密钥无法写入 ' + resolved.file
|
|
1915
|
+
+ ',只能在本进程内使用(跨进程互认不可用)')
|
|
1916
|
+
}
|
|
1917
|
+
ticketGate = createTicketGate({
|
|
1918
|
+
secret: resolved.secret,
|
|
1919
|
+
// 老格式随机票的向后兼容仍然走这个进程内 Map。
|
|
1920
|
+
store: createTicketStore(),
|
|
1921
|
+
})
|
|
1922
|
+
return ticketGate
|
|
1923
|
+
}
|
|
1865
1924
|
const routeJson = (res, status, obj) =>
|
|
1866
1925
|
send(res, status, 'application/json; charset=utf-8', JSON.stringify(obj))
|
|
1867
1926
|
|
|
@@ -1872,10 +1931,8 @@ export function apply(ctx, config) {
|
|
|
1872
1931
|
const port = requestPort(req)
|
|
1873
1932
|
const remoteHost = publicUrl ? hostOnly(new URL(publicUrl).host) : ''
|
|
1874
1933
|
const isRemote = Boolean(remoteHost) && host === remoteHost
|
|
1875
|
-
const lan = lanAddresses()
|
|
1876
|
-
const lanUrls = lan.map((a) => 'http://' + a.ip + (port ? ':' + port : '') + '/taskwatch')
|
|
1877
|
-
const lanCurrent = lan.findIndex((a) => a.ip === host)
|
|
1878
1934
|
const entries = []
|
|
1935
|
+
let lanUrls = []
|
|
1879
1936
|
if (publicUrl) {
|
|
1880
1937
|
entries.push({
|
|
1881
1938
|
kind: 'remote',
|
|
@@ -1884,12 +1941,27 @@ export function apply(ctx, config) {
|
|
|
1884
1941
|
current: isRemote,
|
|
1885
1942
|
})
|
|
1886
1943
|
}
|
|
1887
|
-
|
|
1888
|
-
//
|
|
1889
|
-
//
|
|
1890
|
-
|
|
1891
|
-
entries.push({
|
|
1892
|
-
|
|
1944
|
+
if (lanUrl) {
|
|
1945
|
+
// 配了 lanUrl 就不再探测内网 IP:那通常是中继的局域网入口,比自动探测更准,
|
|
1946
|
+
// 也更可能是"有鉴权"的那一条(插件自己的路由没有令牌门)。
|
|
1947
|
+
lanUrls = [lanUrl + '/taskwatch']
|
|
1948
|
+
entries.push({
|
|
1949
|
+
kind: 'lan',
|
|
1950
|
+
label: '局域网 · 中继',
|
|
1951
|
+
url: lanUrls[0],
|
|
1952
|
+
current: !isRemote,
|
|
1953
|
+
})
|
|
1954
|
+
} else {
|
|
1955
|
+
const lan = lanAddresses()
|
|
1956
|
+
lanUrls = lan.map((a) => 'http://' + a.ip + (port ? ':' + port : '') + '/taskwatch')
|
|
1957
|
+
const lanCurrent = lan.findIndex((a) => a.ip === host)
|
|
1958
|
+
lanUrls.forEach((url, i) => {
|
|
1959
|
+
// 本机回环访问时没有哪条局域网地址"等于"当前地址,但它们连的是同一个服务,
|
|
1960
|
+
// 所以把第一条标成当前 —— 只影响胶囊文案,不影响任何跳转。
|
|
1961
|
+
const current = !isRemote && (lanCurrent >= 0 ? i === lanCurrent : i === 0)
|
|
1962
|
+
entries.push({ kind: 'lan', label: '局域网 · ' + lan[i].ip, url, current })
|
|
1963
|
+
})
|
|
1964
|
+
}
|
|
1893
1965
|
return { entries, remote: publicUrl, lan: lanUrls, current: isRemote ? 'remote' : 'lan' }
|
|
1894
1966
|
}
|
|
1895
1967
|
|
|
@@ -1976,7 +2048,7 @@ export function apply(ctx, config) {
|
|
|
1976
2048
|
})
|
|
1977
2049
|
return
|
|
1978
2050
|
}
|
|
1979
|
-
const ticket = tickets.mint()
|
|
2051
|
+
const ticket = tickets().mint()
|
|
1980
2052
|
routeJson(res, 200, {
|
|
1981
2053
|
ok: true,
|
|
1982
2054
|
to: 'remote',
|
|
@@ -1985,6 +2057,17 @@ export function apply(ctx, config) {
|
|
|
1985
2057
|
})
|
|
1986
2058
|
return
|
|
1987
2059
|
}
|
|
2060
|
+
if (lanUrl) {
|
|
2061
|
+
// 指到中继的局域网入口:票据是同一套 v2 HMAC,中继能直接验签兑换。
|
|
2062
|
+
routeJson(res, 200, {
|
|
2063
|
+
ok: true,
|
|
2064
|
+
to: 'lan',
|
|
2065
|
+
address: hostOnly(new URL(lanUrl).host),
|
|
2066
|
+
url: lanUrl + '/taskwatch?t=' + tickets().mint(),
|
|
2067
|
+
expiresInMs: TICKET_TTL_MS,
|
|
2068
|
+
})
|
|
2069
|
+
return
|
|
2070
|
+
}
|
|
1988
2071
|
const lan = lanAddresses()
|
|
1989
2072
|
if (!lan.length) {
|
|
1990
2073
|
routeJson(res, 409, {
|
|
@@ -1999,7 +2082,7 @@ export function apply(ctx, config) {
|
|
|
1999
2082
|
const net = (ip) => ip.split('.').slice(0, 3).join('.')
|
|
2000
2083
|
const pick = lan.find((a) => net(a.ip) === net(peer)) || lan[0]
|
|
2001
2084
|
const port = requestPort(req)
|
|
2002
|
-
const ticket = tickets.mint()
|
|
2085
|
+
const ticket = tickets().mint()
|
|
2003
2086
|
routeJson(res, 200, {
|
|
2004
2087
|
ok: true,
|
|
2005
2088
|
to: 'lan',
|
|
@@ -2013,7 +2096,7 @@ export function apply(ctx, config) {
|
|
|
2013
2096
|
|
|
2014
2097
|
// 页面外壳。带 ?t= 时先兑换票据:命中 → 302 到 /taskwatch 并种一张换路标记 cookie
|
|
2015
2098
|
// (与中继同形);未知/过期 → 401,和中继在换路凭据无效时的选择保持一致。
|
|
2016
|
-
//
|
|
2099
|
+
// v1.7.2:v2 票靠签名 + 60 秒时效 + 进程内 nonce 去重;老格式仍走 Map(用后即焚)。
|
|
2017
2100
|
ctx.effect(() => webServer.register({
|
|
2018
2101
|
kind: 'exact',
|
|
2019
2102
|
path: '/taskwatch',
|
|
@@ -2021,7 +2104,7 @@ export function apply(ctx, config) {
|
|
|
2021
2104
|
let ticket = ''
|
|
2022
2105
|
try { ticket = new URL(req.url || '/', 'http://127.0.0.1').searchParams.get('t') || '' } catch { ticket = '' }
|
|
2023
2106
|
if (ticket) {
|
|
2024
|
-
if (!tickets.redeem(ticket)) {
|
|
2107
|
+
if (!tickets().redeem(ticket)) {
|
|
2025
2108
|
send(res, 401, 'text/plain; charset=utf-8', '换路凭据无效或已过期,请再点一次切换。\n')
|
|
2026
2109
|
return
|
|
2027
2110
|
}
|
package/lib/ticket.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 跨进程换路票据(v2):HMAC 签名,让插件宿主与中继互认同一张票。
|
|
3
|
+
*
|
|
4
|
+
* 背景(2026-10-21 的真实故障):宿主签的票打到公网(域名后面是中继)→ 中继只认
|
|
5
|
+
* 自己进程内 Map 里的随机串 → 401。两边跑在同一台机器、同一个用户下,所以只要
|
|
6
|
+
* **共用同一个密钥**,就能互相验签而不必共享内存。
|
|
7
|
+
*
|
|
8
|
+
* 票格式(两边必须一致):
|
|
9
|
+
* t = 'v2.' + <expMs 十进制> + '.' + <nonce base64url> + '.' + <hmac base64url>
|
|
10
|
+
* HMAC = HMAC-SHA256(secret, 'v2|' + expMs + '|' + nonce)
|
|
11
|
+
*
|
|
12
|
+
* 安全边界(诚实写清,别把"签名"说成"严格一次性"):
|
|
13
|
+
* - 签名保证票是本机签的、内容没被改;短时效(60 秒 TTL)压缩重放窗口;
|
|
14
|
+
* - 跨进程无法共享"已用过"的状态,所以只在**各自进程内**做 best-effort 去重
|
|
15
|
+
* (nonce Set + TTL 清理)。两个进程都还没见过的票在窗口内理论上可重放一次。
|
|
16
|
+
* - 这不是"严格一次性"。要严格一次性得引入共享存储(Redis/SQLite),代价与
|
|
17
|
+
* 可用性影响都不小,当前场景(换路跳转,60 秒)不值得。
|
|
18
|
+
*
|
|
19
|
+
* 这个模块不碰 HTTP、不碰宿主服务,只做纯函数与密钥解析,方便直接测。
|
|
20
|
+
*/
|
|
21
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
22
|
+
import { spawnSync } from 'node:child_process'
|
|
23
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
24
|
+
import { homedir } from 'node:os'
|
|
25
|
+
import { dirname, join } from 'node:path'
|
|
26
|
+
|
|
27
|
+
export const TICKET_TTL_MS = 60 * 1000
|
|
28
|
+
/** 允许 60 秒 TTL + 时钟偏差;超过这个跨度的一律当伪造/过期,不给无限期票。 */
|
|
29
|
+
export const TICKET_MAX_AGE_MS = 180 * 1000
|
|
30
|
+
export const TICKET_RE = /^v2\.\d{13}\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{20,}$/
|
|
31
|
+
export const SECRET_MIN_LENGTH = 32
|
|
32
|
+
export const SECRET_FILE_NAME = 'taskwatch-ticket.key'
|
|
33
|
+
|
|
34
|
+
/** 任何端口上的这个插件/中继都读同一个文件:同一台机器、同一个用户。 */
|
|
35
|
+
export function secretPath(home = homedir()) {
|
|
36
|
+
return join(home, '.dsh', SECRET_FILE_NAME)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeSecret(raw) {
|
|
40
|
+
const text = typeof raw === 'string' ? raw.trim() : ''
|
|
41
|
+
return text.length >= SECRET_MIN_LENGTH ? text : ''
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 签发一张 v2 票。nonce 必须是 16 字节(base64url 后 22 字符)。 */
|
|
45
|
+
export function signTicket(secret, { now = Date.now, ttlMs = TICKET_TTL_MS } = {}) {
|
|
46
|
+
if (!normalizeSecret(secret)) throw new Error('票据密钥至少需要 ' + SECRET_MIN_LENGTH + ' 个字符')
|
|
47
|
+
const expMs = now() + ttlMs
|
|
48
|
+
const nonce = randomBytes(16).toString('base64url')
|
|
49
|
+
const hmac = createHmac('sha256', secret).update('v2|' + expMs + '|' + nonce).digest('base64url')
|
|
50
|
+
return 'v2.' + expMs + '.' + nonce + '.' + hmac
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 校验 v2 票。格式不符 / 签名不对 / 过期 / 超出最大跨度 → false(当作"不是本票")。 */
|
|
54
|
+
export function verifyTicket(secret, ticket, { now = Date.now } = {}) {
|
|
55
|
+
if (!normalizeSecret(secret) || typeof ticket !== 'string' || !TICKET_RE.test(ticket)) return false
|
|
56
|
+
const parts = ticket.split('.')
|
|
57
|
+
const expMs = Number(parts[1])
|
|
58
|
+
if (!Number.isSafeInteger(expMs)) return false
|
|
59
|
+
const expected = createHmac('sha256', secret).update('v2|' + expMs + '|' + parts[2]).digest()
|
|
60
|
+
let given
|
|
61
|
+
try {
|
|
62
|
+
given = Buffer.from(parts[3], 'base64url')
|
|
63
|
+
} catch {
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
// timingSafeEqual 要求等长;长度不同本身就不是本票。
|
|
67
|
+
if (given.length !== expected.length) return false
|
|
68
|
+
if (!timingSafeEqual(given, expected)) return false
|
|
69
|
+
const at = now()
|
|
70
|
+
if (!(expMs > at)) return false
|
|
71
|
+
if (expMs - at > TICKET_MAX_AGE_MS) return false
|
|
72
|
+
return true
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function nonceOf(ticket) {
|
|
76
|
+
return typeof ticket === 'string' ? String(ticket.split('.')[2] || '') : ''
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function expOf(ticket) {
|
|
80
|
+
const n = Number(typeof ticket === 'string' ? ticket.split('.')[1] : NaN)
|
|
81
|
+
return Number.isSafeInteger(n) ? n : 0
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 进程内 best-effort 去重:记下见过的 nonce,过期就清。不是跨进程的严格一次性。 */
|
|
85
|
+
export function createReplayGuard({ now = Date.now, max = 1024 } = {}) {
|
|
86
|
+
const seen = new Map()
|
|
87
|
+
const prune = (at) => {
|
|
88
|
+
for (const [nonce, exp] of seen) if (exp <= at) seen.delete(nonce)
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
/** 第一次见到返回 false;重复的 nonce 返回 true。 */
|
|
92
|
+
replayed(nonce, expMs) {
|
|
93
|
+
const at = now()
|
|
94
|
+
if (seen.size >= max) prune(at)
|
|
95
|
+
if (seen.has(nonce)) return true
|
|
96
|
+
seen.set(nonce, Math.max(expMs || 0, at + 1000))
|
|
97
|
+
return false
|
|
98
|
+
},
|
|
99
|
+
get size() { return seen.size },
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 票据闸门:统一签发 v2、统一兑换。
|
|
105
|
+
*
|
|
106
|
+
* redeem 的回落规则是刻意写成"只认格式"的:非 v2 形状(老页面/老票)才走原来的
|
|
107
|
+
* 进程内 Map;一旦长得像 v2 就只按签名/时效判断,签名不对直接拒绝,**不**回落 ——
|
|
108
|
+
* 否则伪造一张 `v2.…` 反而比随机串更容易被误放行。
|
|
109
|
+
*/
|
|
110
|
+
export function createTicketGate({ secret, store, now = Date.now, ttlMs = TICKET_TTL_MS, replay } = {}) {
|
|
111
|
+
const guard = replay || createReplayGuard({ now })
|
|
112
|
+
return {
|
|
113
|
+
mint() {
|
|
114
|
+
return signTicket(secret, { now, ttlMs })
|
|
115
|
+
},
|
|
116
|
+
redeem(ticket) {
|
|
117
|
+
const text = typeof ticket === 'string' ? ticket.trim() : ''
|
|
118
|
+
if (!text) return false
|
|
119
|
+
if (TICKET_RE.test(text)) {
|
|
120
|
+
if (!verifyTicket(secret, text, { now })) return false
|
|
121
|
+
return !guard.replayed(nonceOf(text), expOf(text))
|
|
122
|
+
}
|
|
123
|
+
// 向后兼容:老格式随机票仍只在本进程 Map 里有效。
|
|
124
|
+
return store ? store.redeem(text) : false
|
|
125
|
+
},
|
|
126
|
+
get replaySize() { return guard.size },
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 两边同一套解析顺序:
|
|
132
|
+
* 1. 环境变量 TASKWATCH_TICKET_SECRET(trim 后至少 32 字符)
|
|
133
|
+
* 2. <home>/.dsh/taskwatch-ticket.key(UTF-8、trim、至少 32 字符)
|
|
134
|
+
* 3. 都没有 → 先生成 32 字节随机值写进该文件,**收紧权限**后使用。
|
|
135
|
+
*
|
|
136
|
+
* 返回 { secret, source, file, permission };任何失败都退回内存密钥,
|
|
137
|
+
* 绝不因为一个密钥文件写不了就让插件/中继启动失败。调用方不得把 secret 打日志。
|
|
138
|
+
*/
|
|
139
|
+
export function loadTicketSecret({
|
|
140
|
+
env = process.env,
|
|
141
|
+
home = homedir(),
|
|
142
|
+
platform = process.platform,
|
|
143
|
+
fs = { mkdirSync, writeFileSync, readFileSync, chmodSync },
|
|
144
|
+
run = spawnSync,
|
|
145
|
+
} = {}) {
|
|
146
|
+
const fromEnv = normalizeSecret(env && env.TASKWATCH_TICKET_SECRET)
|
|
147
|
+
if (fromEnv) return { secret: fromEnv, source: 'env', file: '', permission: 'n/a' }
|
|
148
|
+
|
|
149
|
+
const file = secretPath(home)
|
|
150
|
+
let existing = ''
|
|
151
|
+
try {
|
|
152
|
+
existing = normalizeSecret(fs.readFileSync(file, 'utf8'))
|
|
153
|
+
} catch { /* 没有文件就走生成 */ }
|
|
154
|
+
if (existing) return { secret: existing, source: 'file', file, permission: 'existing' }
|
|
155
|
+
|
|
156
|
+
const secret = randomBytes(32).toString('base64url')
|
|
157
|
+
let permission = 'unknown'
|
|
158
|
+
try {
|
|
159
|
+
fs.mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
|
|
160
|
+
// mode 只在创建时生效;已存在的文件由下面的 chmod / icacls 收紧。
|
|
161
|
+
fs.writeFileSync(file, secret + '\n', { mode: 0o600 })
|
|
162
|
+
if (platform === 'win32') {
|
|
163
|
+
// Windows 没有 POSIX 权限位。用 icacls 去掉继承、只给当前用户完全控制。
|
|
164
|
+
// 这一步失败时文件仍在(用户目录下),但权限状态要如实标成 failed。
|
|
165
|
+
const user = String((env && (env.USERNAME || env.USER)) || '').trim()
|
|
166
|
+
if (!user) {
|
|
167
|
+
permission = 'icacls-no-user'
|
|
168
|
+
} else {
|
|
169
|
+
const result = run('icacls', [file, '/inheritance:r', '/grant:r', user + ':F'], {
|
|
170
|
+
windowsHide: true,
|
|
171
|
+
timeout: 5000,
|
|
172
|
+
})
|
|
173
|
+
permission = result && result.status === 0 ? 'icacls-owner' : 'icacls-failed'
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
try {
|
|
177
|
+
fs.chmodSync(file, 0o600)
|
|
178
|
+
permission = 'chmod-600'
|
|
179
|
+
} catch {
|
|
180
|
+
permission = 'chmod-failed'
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return { secret, source: 'generated', file, permission }
|
|
184
|
+
} catch (error) {
|
|
185
|
+
// 目录只读 / 磁盘满:仍然能签发与校验(本进程内),只是无法跨进程互认。
|
|
186
|
+
return {
|
|
187
|
+
secret,
|
|
188
|
+
source: 'memory',
|
|
189
|
+
file,
|
|
190
|
+
permission: 'write-failed',
|
|
191
|
+
error: String((error && error.message) || error),
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-in-hand",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.3",
|
|
4
4
|
"description": "掌上 DSH:DeepSeek Harness 的手机端,不用装 App——会话、实时对话、交付文件、后台任务与待批准。 / DSH in hand: the phone client for DeepSeek Harness with no app to install.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"node": ">=20"
|
|
34
34
|
},
|
|
35
35
|
"scripts": {
|
|
36
|
-
"check": "node scripts/check-bundle.mjs && node scripts/check-files.mjs && node scripts/check-uploads.mjs && node scripts/check-queue.mjs && node scripts/check-entries.mjs",
|
|
36
|
+
"check": "node scripts/check-bundle.mjs && node scripts/check-files.mjs && node scripts/check-uploads.mjs && node scripts/check-queue.mjs && node scripts/check-entries.mjs && node scripts/check-ticket-hmac.mjs && node scripts/check-contract.mjs",
|
|
37
37
|
"demo": "node scripts/make-demo.mjs",
|
|
38
38
|
"icons": "node scripts/make-icons.mjs"
|
|
39
39
|
},
|