dsh-tailscale-remote 0.1.0 → 0.3.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 +107 -9
- package/client/client.js +465 -0
- package/index.js +410 -63
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +11 -3
package/README.md
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
# dsh-tailscale-remote
|
|
2
2
|
|
|
3
3
|
打开 DSH 就把本机 Web GUI 通过 **Tailscale Serve** 暴露到你的 tailnet 上,手机在任何网络下
|
|
4
|
-
打开一个网址就能用;DSH
|
|
4
|
+
打开一个网址就能用;DSH 退出时把这条路由撤掉。手机链接直接显示在
|
|
5
|
+
**「设置 → 通用 → 手机连接」**里,点一下就能复制。
|
|
5
6
|
|
|
6
7
|
> 给 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)用的插件。
|
|
7
8
|
> 只需要「随时随地用手机连上自己电脑上的 DSH」这一件事,不引入任何第三方中转。
|
|
8
9
|
|
|
9
10
|
## 先看这个:手机怎么进
|
|
10
11
|
|
|
11
|
-
DSH
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
**最省事的办法:DSH 里打开「设置 → 通用」,往下找到「手机连接」那一行,里面就有链接,
|
|
13
|
+
点一下「复制」,发到手机上打开一次即可。** 那一行还能随时「更换密钥」——万一链接泄露,
|
|
14
|
+
换了之后旧链接和所有旧登录一起失效。
|
|
15
|
+
|
|
16
|
+
不想开 DSH 界面时,读状态文件也一样(字段是 `loginUrl`):
|
|
14
17
|
|
|
15
18
|
```sh
|
|
16
19
|
# 装好并重启 DSH 后,执行(Windows 用 PowerShell 见下)
|
|
@@ -32,7 +35,11 @@ Windows PowerShell 读法:
|
|
|
32
35
|
Get-Content "$env:USERPROFILE\.dsh\tailscale-remote.json"
|
|
33
36
|
```
|
|
34
37
|
|
|
35
|
-
|
|
38
|
+
就这么两步:**拿到 `loginUrl` → 手机打开一次并存书签**。下面都是细节。
|
|
39
|
+
|
|
40
|
+
> DSH 的 Web GUI 要求一次**登录令牌**,没令牌只显示一句
|
|
41
|
+
> `dsh web authentication required; ...`。DSH Desktop 又**从不打印**那行链接,令牌还每次重启都换 ——
|
|
42
|
+
> 所以插件给你一条**存一次就永久用**的入口。
|
|
36
43
|
|
|
37
44
|
## 安装
|
|
38
45
|
|
|
@@ -83,9 +90,28 @@ dsh web authentication required; reopen the URL printed by dsh web.
|
|
|
83
90
|
```
|
|
84
91
|
|
|
85
92
|
麻烦的地方在于:**DSH Desktop 从不打印这行链接**(它走 IPC,不走 `dsh web`),而令牌又是
|
|
86
|
-
|
|
93
|
+
**进程级**的、每次重启都换。所以本插件给你三个拿链接的地方,任选其一。
|
|
94
|
+
|
|
95
|
+
### ① 设置页里的「手机连接」行(推荐)
|
|
96
|
+
|
|
97
|
+
**设置 → 通用**,滚到下面有「手机连接」这一行:
|
|
87
98
|
|
|
88
|
-
|
|
99
|
+
- 里面是**手机登录链接**,一个只读输入框 + 「复制」按钮,点一下就能发到手机上。
|
|
100
|
+
- 旁边有「更换密钥」:点一下会先让你确认,确认后**旧密钥与所有旧登录一起失效**。
|
|
101
|
+
- **换的到底是什么**:登录链接(`/remote/<密钥>`)只负责**换取**一张会话 cookie。
|
|
102
|
+
密钥泄露后,如果对方已经用它登录过,他手里那张 cookie 是另一把签名密钥签的、
|
|
103
|
+
有效期 10 年 —— 只换登录密钥完全不影响它。所以这里的「更换密钥」会**同时**换掉
|
|
104
|
+
登录密钥**和**会话签名密钥,让泄露的密钥真正失效。
|
|
105
|
+
- **代价**:所有设备(包括你自己的手机)都会被登出,需要用新链接重新登录一次。
|
|
106
|
+
**点按钮的这台设备会被自动补发一张新 cookie、不掉线**,所以在电脑上操作最顺手。
|
|
107
|
+
- 想只换链接、不踢设备?设 `revokeSessionsOnRotate: false`(此时泄露的旧 cookie
|
|
108
|
+
仍然有效,只适合「我确定没人用过那条链接」的情况)。
|
|
109
|
+
- 还没就绪时会直接告诉你卡在哪一步(比如「Tailscale 没在运行」),而不是给你一个空框。
|
|
110
|
+
|
|
111
|
+
> 这一行只在你**打开设置页**时才请求一次状态(外加窗口重新获得焦点时刷新一次),
|
|
112
|
+
> 不轮询、不挂定时器。它注册在 DSH 自己的 `/api` 上,所以**只有已登录的浏览器**能读。
|
|
113
|
+
|
|
114
|
+
### ② 稳定登录入口(存一次书签就够)
|
|
89
115
|
|
|
90
116
|
插件会在 DSH 自己的 webserver 上注册一条路由:
|
|
91
117
|
|
|
@@ -100,7 +126,7 @@ https://<你的机器>.<tailnet>.ts.net:7259/remote/<持久密钥>
|
|
|
100
126
|
> 密钥本身就是访问门槛:不知道它的人拿不到会话(和 DSH 自己的令牌同一级别)。
|
|
101
127
|
> 不需要的话可以设 `loginRoute: false` 关掉。
|
|
102
128
|
|
|
103
|
-
###
|
|
129
|
+
### ③ 状态文件(永远可以查到)
|
|
104
130
|
|
|
105
131
|
任何一次启动后,下面这个文件里都有**当前**可用的链接:
|
|
106
132
|
|
|
@@ -147,7 +173,8 @@ cookie 种下后,直接访问干净地址 `https://<机器>.<tailnet>.ts.net:7
|
|
|
147
173
|
| `serveOnStart` | `true` | 是否建立 Serve 路由。 |
|
|
148
174
|
| `teardownOnExit` | `true` | 退出时是否回滚。 |
|
|
149
175
|
| `trustedHosts` | `true` | 是否把该 authority 加进 `/api` 信任列表。 |
|
|
150
|
-
| `loginRoute` | `true` | 是否注册 `/remote/<持久密钥>`
|
|
176
|
+
| `loginRoute` | `true` | 是否注册 `/remote/<持久密钥>` 这个稳定登录入口(设置页那一行的「更换密钥」也依赖它)。 |
|
|
177
|
+
| `revokeSessionsOnRotate` | `true` | 换密钥时是否**同时吊销所有已登录的会话**。默认开:密钥泄露时对方可能已用旧密钥换到一张 10 年 cookie,只换登录密钥拦不住他。关掉则只换链接、所有设备继续登录。 |
|
|
151
178
|
| `cookieMaxAgeDays` | `3650` | 登录 cookie 有效期(天)。`0` = 不插手 DSH 的 30 天默认值。 |
|
|
152
179
|
| `binaryPath` | 自动探测 | `tailscale` CLI 可执行文件路径。 |
|
|
153
180
|
| `guiPath` | 自动探测 | Tailscale **桌面客户端**路径(Windows 默认 `tailscale-ipn.exe`)。 |
|
|
@@ -179,12 +206,83 @@ DSH 把 cookie 有效期上限写死在 `connection` 的 `cookieMaxAgeDays` 里
|
|
|
179
206
|
如果连客户端都拉不起来(例如需要交互式登录),插件会留下明确提示让你手动打开 Tailscale,
|
|
180
207
|
并且**不会**谎报成功去配一条连不上的路由。
|
|
181
208
|
|
|
209
|
+
## 发布(维护者)
|
|
210
|
+
|
|
211
|
+
包名 `dsh-tailscale-remote` 已发布到 npm。`package.json` 里写了 `publishConfig`
|
|
212
|
+
(`access: public` + `registry: https://registry.npmjs.org/`),所以**直接 `npm publish`
|
|
213
|
+
不带任何参数**就会发到正确的地方——即使本机 `~/.npmrc` 指向的是镜像站。
|
|
214
|
+
|
|
215
|
+
后续版本**自动发布**:推送到 `main` 时 `.github/workflows/publish.yml` 会先跑测试再
|
|
216
|
+
`npm publish`——但**只有 `package.json` 里的 `version` 是 registry 上还没有的版本时才发**。
|
|
217
|
+
所以发版流程就是两步:
|
|
218
|
+
|
|
219
|
+
1. 改 `package.json` 的 `version`。
|
|
220
|
+
2. 推送到 `main`。
|
|
221
|
+
|
|
222
|
+
这次运行要么发出这个版本,要么报告「该版本已在线上」并以成功结束。
|
|
223
|
+
**没改版本号的推送是 no-op,不是失败**——所以每个要发出去的改动都必须自己带一次版本号
|
|
224
|
+
变更;只改文档的提交同样会触发工作流,同样 no-op。
|
|
225
|
+
|
|
226
|
+
认证用 npm **Trusted Publishing(OIDC)**:GitHub 给这个 job 签一个短时 token,npm 拿它换
|
|
227
|
+
一个只对这一个包有效的发布 token。**仓库里没有任何 `NPM_TOKEN` secret。** 工作流刻意
|
|
228
|
+
**不用** `actions/setup-node` 的 `registry-url` 输入——它会往 `~/.npmrc` 写
|
|
229
|
+
`//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}`,而该变量没定义时这个占位符会
|
|
230
|
+
原样留下、被 npm 当作 token 发出去。registry 交给 `publishConfig` 就够了。
|
|
231
|
+
|
|
232
|
+
<details>
|
|
233
|
+
<summary>一次性配置:绑定 Trusted Publisher</summary>
|
|
234
|
+
|
|
235
|
+
Trusted Publisher **只能绑到 registry 上已存在的包**,所以**首次发布是手工做的**
|
|
236
|
+
(在检出目录里跑了一次 `npm publish`),从第二个版本起才轮到工作流接管。在 npmjs.com 上:
|
|
237
|
+
包 → Settings → Trusted Publisher → GitHub Actions,填
|
|
238
|
+
|
|
239
|
+
| 字段 | 值 |
|
|
240
|
+
|---|---|
|
|
241
|
+
| Repository | `Ckarefulon/dsh-tailscale-remote` |
|
|
242
|
+
| Workflow filename | `publish.yml` |
|
|
243
|
+
| Environment | *留空* |
|
|
244
|
+
|
|
245
|
+
文件名必须**逐字一致**——npm 把授权绑在它上面,写错会表现为发布报 `ENEEDAUTH`。
|
|
246
|
+
|
|
247
|
+
</details>
|
|
248
|
+
|
|
182
249
|
## 安全边界
|
|
183
250
|
|
|
184
251
|
这条路由**只对你的 tailnet 可见**(Tailscale Serve 的默认范围,不是 Funnel),也就是说
|
|
185
252
|
只有登录到你这个 tailnet 的设备能连上。DSH 自己的浏览器鉴权(30 天签名 cookie)仍然
|
|
186
253
|
生效,两层是叠加的。
|
|
187
254
|
|
|
255
|
+
设置页那一行背后的两个端点注册在 DSH 自己的 `/api` 前缀路由上
|
|
256
|
+
(`connection.fetch`),因此**直接白拿 DSH 的 Host/Origin fence 与浏览器 cookie 鉴权**:
|
|
257
|
+
未登录的请求在到达插件之前就被 401 挡掉了。它们也刻意**不用**
|
|
258
|
+
`webServer.register({ kind: 'exact', path: '/api/...' })` —— `webserver` 的 `match()`
|
|
259
|
+
先查 exact 表,那样会**抢先命中**并遮掉整条 `/api` 路由,把 fence 和鉴权一起绕过去。
|
|
260
|
+
|
|
261
|
+
| 端点 | 作用 |
|
|
262
|
+
| --- | --- |
|
|
263
|
+
| `GET /api/dsh-tailscale-remote/state` | 当前阶段、手机链接、能否换密钥、换密钥会不会踢设备。不缓存。 |
|
|
264
|
+
| `POST /api/dsh-tailscale-remote/rotate` | 换一把新登录密钥,并(默认)吊销所有已登录的会话;给发起方补发一张新 cookie。 |
|
|
265
|
+
|
|
266
|
+
### 「更换密钥」到底吊销了什么
|
|
267
|
+
|
|
268
|
+
DSH 的浏览器鉴权有两把独立的密钥,别把它们混为一谈:
|
|
269
|
+
|
|
270
|
+
| | 作用 | 换了它会怎样 |
|
|
271
|
+
| --- | --- | --- |
|
|
272
|
+
| **登录密钥**(本插件的 `/remote/<密钥>`) | 换取会话 cookie 的入口 | 旧链接立刻 404 |
|
|
273
|
+
| **会话签名密钥**(`connection.browserAuth.secret`,存在 credentials 的 `client-connection/browser-session` 记录里) | 给会话 cookie 签名 | 所有已发出的 cookie 立刻验不过 |
|
|
274
|
+
|
|
275
|
+
**只换登录密钥是不够的**:泄露的链接如果已经被用过,对方手里那张 cookie 由第二把密钥
|
|
276
|
+
签名、有效期 10 年,换第一把对它毫无影响。所以默认两把一起换(`revokeSessionsOnRotate`)。
|
|
277
|
+
|
|
278
|
+
实现上两处都要改,缺一不可:credentials 记录(**持久**,否则 DSH 一重启就把旧密钥读回来、
|
|
279
|
+
旧 cookie 复活)和活着的 `browserAuth.secret`(**立即生效**,它按引用逐字比较签名)。
|
|
280
|
+
写入后会**回读确认**:写错记录键或被只读来源遮蔽时 `modifyRecord` 会「成功返回但记录没变」,
|
|
281
|
+
不校验的话就是「报成功、实则没换」。
|
|
282
|
+
|
|
283
|
+
想彻底踢掉已登录设备也可以直接去改 DSH 自己的凭据 —— 那等于把第二把密钥换掉,效果一样,
|
|
284
|
+
但会一并影响其它依赖它的东西;用本插件这一行更精确,而且会顺手把新链接给你。
|
|
285
|
+
|
|
188
286
|
插件**不会覆盖** 7259 上已有的、指向别处的 Serve 配置:检测到冲突就只记日志、不动手。
|
|
189
287
|
唯一会接管的情况是发现一条指向**另一个回环端口**的陈旧路由(通常是上次 DSH 换了端口
|
|
190
288
|
留下的)。
|
package/client/client.js
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
// dsh-tailscale-remote 的浏览器半边:在「设置 → 通用」里加一行「手机连接」,
|
|
2
|
+
// 显示手机可用的登录链接,并允许更换密钥。
|
|
3
|
+
//
|
|
4
|
+
// 手写,没有构建步骤:元素一律用 React.createElement 构造,样式全部内联,
|
|
5
|
+
// 颜色走 DSH 主题变量(真名前缀是 --dsw-alias-,深浅色自动跟随)并各带一个
|
|
6
|
+
// 浅色兜底值,变量缺失时界面仍然可读。
|
|
7
|
+
//
|
|
8
|
+
// 数据来自同源的两个 /api 路由(host 侧 index.js 注册在 connection.fetch 上,
|
|
9
|
+
// 因此白拿 DSH 自己的 Host/Origin fence 与 cookie 鉴权):
|
|
10
|
+
// GET /api/dsh-tailscale-remote/state → { ok, phase, error, plainUrl, loginUrl, ... }
|
|
11
|
+
// POST /api/dsh-tailscale-remote/rotate → { ok, loginUrl } | { ok:false, error }
|
|
12
|
+
// 注意失败时状态码不是 200 但响应体仍是 JSON,所以先解析 body 再看 body.ok,
|
|
13
|
+
// 不拿状态码覆盖原文。
|
|
14
|
+
//
|
|
15
|
+
// 这一行**不轮询、不挂后台定时器**:只在挂载时取一次,另有窗口重新获得焦点时
|
|
16
|
+
// 刷新一次。设置页关掉就什么都不剩。
|
|
17
|
+
|
|
18
|
+
window.__ModuleLoader__.load({
|
|
19
|
+
id: 'dsh-tailscale-remote',
|
|
20
|
+
factory: (require) => {
|
|
21
|
+
// loader 只传 require、不传 module,所以每个 bundle 自带这一对,
|
|
22
|
+
// 和宿主自己的 bundle 写法一致。
|
|
23
|
+
const module = { exports: {} }
|
|
24
|
+
const exports = module.exports
|
|
25
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
26
|
+
|
|
27
|
+
const React = require('react')
|
|
28
|
+
const h = React.createElement
|
|
29
|
+
const useState = React.useState
|
|
30
|
+
const useEffect = React.useEffect
|
|
31
|
+
const useCallback = React.useCallback
|
|
32
|
+
const useRef = React.useRef
|
|
33
|
+
|
|
34
|
+
/** 这一行的插槽 id;同时是设置页里这一行的定位键。 */
|
|
35
|
+
const ROW_ID = 'dsh-tailscale-remote'
|
|
36
|
+
|
|
37
|
+
const STATE_PATH = '/api/dsh-tailscale-remote/state'
|
|
38
|
+
const ROTATE_PATH = '/api/dsh-tailscale-remote/rotate'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 行内字典。
|
|
42
|
+
*
|
|
43
|
+
* 宿主有 locale 服务时优先用它(见 pickLanguage),这里这份是兜底:
|
|
44
|
+
* 拿不到 locale 服务时(浏览器 harness、精简组合)界面仍然是中文/英文,
|
|
45
|
+
* 而不是一堆字面量 key。
|
|
46
|
+
*/
|
|
47
|
+
const ZH = {
|
|
48
|
+
title: '手机连接',
|
|
49
|
+
description: '开 DSH 时把网页发布到你的 Tailscale 网络(tailnet),手机在任何网络下打开下面的链接就能连上;关掉 DSH 会自动撤掉这条路由。',
|
|
50
|
+
loading: '正在读取…',
|
|
51
|
+
urlLabel: '手机登录链接',
|
|
52
|
+
plainLabel: '直连地址',
|
|
53
|
+
copy: '复制',
|
|
54
|
+
copied: '已复制',
|
|
55
|
+
copyFailed: '复制失败,请手动选中输入框里的内容复制',
|
|
56
|
+
rotate: '更换密钥',
|
|
57
|
+
rotateConfirm: '确认更换',
|
|
58
|
+
rotateCancel: '取消',
|
|
59
|
+
rotateWarningRevoke: '换完之后旧链接立刻失效,所有已登录的设备都会退出(包括你的手机),需要用新链接重新登录一次。这台设备会被自动保留登录状态。',
|
|
60
|
+
rotateWarningLinkOnly: '换完之后旧链接立刻失效,需要把新链接重新存一次书签。已经登录过的设备不受影响。',
|
|
61
|
+
rotateDoneRevoke: '已更换。旧密钥与所有旧登录都已失效,请把新链接重新存一次书签。',
|
|
62
|
+
rotateDoneLinkOnly: '已更换。请重新保存下面的链接。',
|
|
63
|
+
rotateFailed: '更换失败',
|
|
64
|
+
noLogin: '登录入口已关闭(loginRoute=false),下面的直连地址需要先在本机用一次性令牌登录。',
|
|
65
|
+
keepBookmark: '存一次书签即可,跨重启不变。',
|
|
66
|
+
cookieHint: (days) => `登录状态有效期 ${days} 天。`,
|
|
67
|
+
retry: '重试',
|
|
68
|
+
phaseStarting: '还在启动中(拉起 Tailscale / 建立 Serve 路由)。',
|
|
69
|
+
phaseUnavailable: 'Tailscale 没在运行,远程访问暂时不可用。',
|
|
70
|
+
phaseError: '远程访问没能建起来。',
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const EN = {
|
|
74
|
+
title: 'Phone access',
|
|
75
|
+
description: 'While DSH runs, its Web GUI is published to your Tailscale network (tailnet), so a phone anywhere can open the link below. Quitting DSH removes the route.',
|
|
76
|
+
loading: 'Loading…',
|
|
77
|
+
urlLabel: 'Phone login link',
|
|
78
|
+
plainLabel: 'Direct address',
|
|
79
|
+
copy: 'Copy',
|
|
80
|
+
copied: 'Copied',
|
|
81
|
+
copyFailed: 'Copy failed — select the field and copy manually',
|
|
82
|
+
rotate: 'Change key',
|
|
83
|
+
rotateConfirm: 'Confirm change',
|
|
84
|
+
rotateCancel: 'Cancel',
|
|
85
|
+
rotateWarningRevoke: 'The old link stops working immediately and every signed-in device is signed out, including your phone — sign in again with the new link. This device stays signed in.',
|
|
86
|
+
rotateWarningLinkOnly: 'The old link stops working immediately; bookmark the new one. Devices already signed in are unaffected.',
|
|
87
|
+
rotateDoneRevoke: 'Changed. The old key and every old session are now invalid; save the new link again.',
|
|
88
|
+
rotateDoneLinkOnly: 'Changed. Save the link below again.',
|
|
89
|
+
rotateFailed: 'Change failed',
|
|
90
|
+
noLogin: 'The login entry is off (loginRoute=false); the direct address needs a one-time token login from this machine first.',
|
|
91
|
+
keepBookmark: 'Bookmark it once — it survives restarts.',
|
|
92
|
+
cookieHint: (days) => `Sign-in stays valid for ${days} days.`,
|
|
93
|
+
retry: 'Retry',
|
|
94
|
+
phaseStarting: 'Still starting up (bringing up Tailscale / the Serve route).',
|
|
95
|
+
phaseUnavailable: 'Tailscale is not running, so remote access is unavailable.',
|
|
96
|
+
phaseError: 'Remote access could not be established.',
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 当前该说中文还是英文。
|
|
101
|
+
*
|
|
102
|
+
* 只读 `ctx.get('locale')`(可选服务,**不写进 inject**:写进去会在
|
|
103
|
+
* locale 缺席时把整个包 park 掉)。拿不到就退回浏览器语言。
|
|
104
|
+
* @param ctx - 客户端根上下文。
|
|
105
|
+
* @returns 'zh' 或 'en'。
|
|
106
|
+
*/
|
|
107
|
+
function pickLanguage(ctx) {
|
|
108
|
+
let active
|
|
109
|
+
try {
|
|
110
|
+
active = ctx.get('locale')?.getSnapshot?.().active
|
|
111
|
+
} catch {
|
|
112
|
+
active = undefined
|
|
113
|
+
}
|
|
114
|
+
if (typeof active === 'string' && active !== '') {
|
|
115
|
+
return active.toLowerCase().startsWith('zh') ? 'zh' : 'en'
|
|
116
|
+
}
|
|
117
|
+
const nav = typeof navigator === 'undefined' ? '' : navigator.language
|
|
118
|
+
return String(nav).toLowerCase().startsWith('zh') ? 'zh' : 'en'
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 行内样式。token 名照抄宿主,括号里是浅色兜底。 */
|
|
122
|
+
const styles = {
|
|
123
|
+
row: {
|
|
124
|
+
display: 'flex',
|
|
125
|
+
flexDirection: 'column',
|
|
126
|
+
gap: '10px',
|
|
127
|
+
padding: '16px 0',
|
|
128
|
+
borderBottom: '.5px solid var(--dsw-alias-border-l2,#e5e7eb)',
|
|
129
|
+
},
|
|
130
|
+
head: { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '24px' },
|
|
131
|
+
title: { fontSize: 14, lineHeight: '20px', color: 'var(--dsw-alias-label-primary,#111827)' },
|
|
132
|
+
description: {
|
|
133
|
+
marginTop: 4,
|
|
134
|
+
fontSize: 12,
|
|
135
|
+
lineHeight: '18px',
|
|
136
|
+
color: 'var(--dsw-alias-label-secondary,#6b7280)',
|
|
137
|
+
},
|
|
138
|
+
actions: { display: 'flex', alignItems: 'center', gap: 8, flex: 'none' },
|
|
139
|
+
field: { display: 'flex', alignItems: 'center', gap: 8 },
|
|
140
|
+
label: {
|
|
141
|
+
fontSize: 12,
|
|
142
|
+
lineHeight: '18px',
|
|
143
|
+
color: 'var(--dsw-alias-label-secondary,#6b7280)',
|
|
144
|
+
flex: 'none',
|
|
145
|
+
},
|
|
146
|
+
input: {
|
|
147
|
+
flex: '1 1 auto',
|
|
148
|
+
minWidth: 0,
|
|
149
|
+
font: 'inherit',
|
|
150
|
+
fontSize: 12,
|
|
151
|
+
lineHeight: '18px',
|
|
152
|
+
padding: '5px 8px',
|
|
153
|
+
borderRadius: 'var(--dsw-radius-sm,8px)',
|
|
154
|
+
border: '1px solid var(--dsw-alias-border-l2,#e5e7eb)',
|
|
155
|
+
background: 'var(--dsw-alias-bg-base,#fff)',
|
|
156
|
+
color: 'var(--dsw-alias-label-primary,#111827)',
|
|
157
|
+
},
|
|
158
|
+
button: {
|
|
159
|
+
font: 'inherit',
|
|
160
|
+
fontSize: 12,
|
|
161
|
+
lineHeight: '18px',
|
|
162
|
+
padding: '5px 10px',
|
|
163
|
+
borderRadius: 'var(--dsw-radius-sm,8px)',
|
|
164
|
+
border: '1px solid var(--dsw-alias-border-l2,#e5e7eb)',
|
|
165
|
+
background: 'transparent',
|
|
166
|
+
color: 'var(--dsw-alias-label-primary,#111827)',
|
|
167
|
+
cursor: 'pointer',
|
|
168
|
+
whiteSpace: 'nowrap',
|
|
169
|
+
flex: 'none',
|
|
170
|
+
},
|
|
171
|
+
buttonPrimary: {
|
|
172
|
+
borderColor: 'transparent',
|
|
173
|
+
background: 'var(--dsw-alias-state-warn-primary,#b45309)',
|
|
174
|
+
color: '#fff',
|
|
175
|
+
},
|
|
176
|
+
hint: { fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-secondary,#6b7280)' },
|
|
177
|
+
warn: { fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-state-warn-primary,#b45309)' },
|
|
178
|
+
error: { fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-state-error-primary,#dc2626)' },
|
|
179
|
+
ok: { fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-state-success-primary,#16a34a)' },
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 读一个 JSON 端点。
|
|
184
|
+
*
|
|
185
|
+
* 失败时状态码不是 200,但 body 仍是 JSON 且带着真正的原因,所以这里
|
|
186
|
+
* 先解析、再看 `ok`,不拿状态码覆盖原文(照抄 dsh-mini-remote 的经验)。
|
|
187
|
+
* @param path - 同源路径。
|
|
188
|
+
* @param options - fetch 选项。
|
|
189
|
+
* @returns `{ ok: true, data }` 或 `{ ok: false, error }`。
|
|
190
|
+
*/
|
|
191
|
+
async function readJson(path, options) {
|
|
192
|
+
let response
|
|
193
|
+
try {
|
|
194
|
+
response = await fetch(path, { credentials: 'same-origin', cache: 'no-store', ...options })
|
|
195
|
+
} catch (error) {
|
|
196
|
+
return { ok: false, error: String((error && error.message) || error) }
|
|
197
|
+
}
|
|
198
|
+
let body
|
|
199
|
+
try {
|
|
200
|
+
body = await response.json()
|
|
201
|
+
} catch {
|
|
202
|
+
return { ok: false, error: `HTTP ${response.status}` }
|
|
203
|
+
}
|
|
204
|
+
if (body === null || typeof body !== 'object') return { ok: false, error: `HTTP ${response.status}` }
|
|
205
|
+
if (body.ok !== true) {
|
|
206
|
+
return { ok: false, error: typeof body.error === 'string' ? body.error : `HTTP ${response.status}` }
|
|
207
|
+
}
|
|
208
|
+
return { ok: true, data: body }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 把文案里的阶段/错误翻成人话;认不出来就把原文附在后面。 */
|
|
212
|
+
function describePhase(t, state) {
|
|
213
|
+
if (state === null) return t.loading
|
|
214
|
+
if (state.phase === 'starting') return t.phaseStarting
|
|
215
|
+
if (state.phase === 'unavailable') return t.phaseUnavailable
|
|
216
|
+
if (state.phase === 'error') return t.phaseError
|
|
217
|
+
return undefined
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 「设置 → 通用」里的这一行。
|
|
222
|
+
*
|
|
223
|
+
* 只在挂载时取一次状态;另有 window focus 时刷新一次(桌面 App 里
|
|
224
|
+
* 用户切走再切回来很常见,这时 Tailscale 可能刚好起来了)。
|
|
225
|
+
* @param props - 注入面给的 `ctx`(见 apply 里的 `inject`)。
|
|
226
|
+
*/
|
|
227
|
+
function RemoteRow(props) {
|
|
228
|
+
const ctx = props.ctx
|
|
229
|
+
// 语言变化时强制重渲染;字典本身是行内常量,不需要订阅字典。
|
|
230
|
+
const [, setTick] = useState(0)
|
|
231
|
+
const [state, setState] = useState(null)
|
|
232
|
+
const [error, setError] = useState(null)
|
|
233
|
+
const [busy, setBusy] = useState(false)
|
|
234
|
+
const [copied, setCopied] = useState(false)
|
|
235
|
+
const [confirming, setConfirming] = useState(false)
|
|
236
|
+
const [notice, setNotice] = useState(null)
|
|
237
|
+
const inputRef = useRef(null)
|
|
238
|
+
|
|
239
|
+
const t = pickLanguage(ctx) === 'zh' ? ZH : EN
|
|
240
|
+
|
|
241
|
+
const load = useCallback(async () => {
|
|
242
|
+
const result = await readJson(STATE_PATH)
|
|
243
|
+
if (result.ok) {
|
|
244
|
+
setState(result.data)
|
|
245
|
+
setError(null)
|
|
246
|
+
} else {
|
|
247
|
+
setError(result.error)
|
|
248
|
+
}
|
|
249
|
+
}, [])
|
|
250
|
+
|
|
251
|
+
useEffect(() => {
|
|
252
|
+
void load()
|
|
253
|
+
}, [load])
|
|
254
|
+
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
const locale = (() => {
|
|
257
|
+
try {
|
|
258
|
+
return ctx.get('locale')
|
|
259
|
+
} catch {
|
|
260
|
+
return undefined
|
|
261
|
+
}
|
|
262
|
+
})()
|
|
263
|
+
if (locale === undefined || typeof locale.subscribe !== 'function') return undefined
|
|
264
|
+
return locale.subscribe(() => setTick((n) => n + 1))
|
|
265
|
+
}, [ctx])
|
|
266
|
+
|
|
267
|
+
useEffect(() => {
|
|
268
|
+
const onFocus = () => {
|
|
269
|
+
void load()
|
|
270
|
+
}
|
|
271
|
+
window.addEventListener('focus', onFocus)
|
|
272
|
+
return () => window.removeEventListener('focus', onFocus)
|
|
273
|
+
}, [load])
|
|
274
|
+
|
|
275
|
+
const url = state === null ? null : state.loginUrl || state.plainUrl
|
|
276
|
+
const usesLoginUrl = state !== null && state.loginUrl !== null && state.loginUrl !== undefined
|
|
277
|
+
|
|
278
|
+
const copy = useCallback(async () => {
|
|
279
|
+
if (url === null || url === undefined) return
|
|
280
|
+
let done = false
|
|
281
|
+
try {
|
|
282
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard !== undefined) {
|
|
283
|
+
await navigator.clipboard.writeText(url)
|
|
284
|
+
done = true
|
|
285
|
+
}
|
|
286
|
+
} catch {
|
|
287
|
+
done = false
|
|
288
|
+
}
|
|
289
|
+
if (!done) {
|
|
290
|
+
// 非安全上下文里 clipboard 不可用(tailnet 的 https 和 localhost 都算安全,
|
|
291
|
+
// 但用户可能从 http 的局域网地址进来)。退回选中输入框内容,让人手动复制。
|
|
292
|
+
try {
|
|
293
|
+
inputRef.current?.select?.()
|
|
294
|
+
done = document.execCommand('copy')
|
|
295
|
+
} catch {
|
|
296
|
+
done = false
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
setNotice(done ? null : t.copyFailed)
|
|
300
|
+
setCopied(done)
|
|
301
|
+
if (done) setTimeout(() => setCopied(false), 1600)
|
|
302
|
+
}, [url, t])
|
|
303
|
+
|
|
304
|
+
const rotate = useCallback(async () => {
|
|
305
|
+
setBusy(true)
|
|
306
|
+
setNotice(null)
|
|
307
|
+
const result = await readJson(ROTATE_PATH, { method: 'POST' })
|
|
308
|
+
setBusy(false)
|
|
309
|
+
setConfirming(false)
|
|
310
|
+
if (!result.ok) {
|
|
311
|
+
setNotice(`${t.rotateFailed}:${result.error}`)
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
setState((previous) => ({ ...(previous ?? {}), ...result.data }))
|
|
315
|
+
setNotice(result.data.revokesSessions === false ? t.rotateDoneLinkOnly : t.rotateDoneRevoke)
|
|
316
|
+
}, [t])
|
|
317
|
+
|
|
318
|
+
const canRotate = state !== null && state.canRotate === true && !busy
|
|
319
|
+
// 文案必须跟着实际行为走:吊销会话时警告要说清「所有设备都会退出」,
|
|
320
|
+
// 否则用户以为只是换个链接,结果手机被踢下线、还不知道为什么。
|
|
321
|
+
const revokes = state !== null && state.revokesSessions === true
|
|
322
|
+
const warningText = revokes ? t.rotateWarningRevoke : t.rotateWarningLinkOnly
|
|
323
|
+
|
|
324
|
+
// 首帧:还没拿到状态,也没出错。这时不要摆一个空输入框配一个会误导的标签,
|
|
325
|
+
// 直接说「正在读取」。
|
|
326
|
+
if (state === null && error === null) {
|
|
327
|
+
return h(
|
|
328
|
+
'div',
|
|
329
|
+
{ style: styles.row, 'data-dsh-tailscale-remote-row': '' },
|
|
330
|
+
h('div', { style: styles.title }, t.title),
|
|
331
|
+
h('div', { style: styles.description }, t.description),
|
|
332
|
+
h('div', { style: styles.hint }, t.loading),
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const hint = describePhase(t, state)
|
|
337
|
+
|
|
338
|
+
return h(
|
|
339
|
+
'div',
|
|
340
|
+
{ style: styles.row, 'data-dsh-tailscale-remote-row': '' },
|
|
341
|
+
h(
|
|
342
|
+
'div',
|
|
343
|
+
{ style: styles.head },
|
|
344
|
+
h(
|
|
345
|
+
'div',
|
|
346
|
+
null,
|
|
347
|
+
h('div', { style: styles.title }, t.title),
|
|
348
|
+
h('div', { style: styles.description }, t.description),
|
|
349
|
+
),
|
|
350
|
+
h(
|
|
351
|
+
'div',
|
|
352
|
+
{ style: styles.actions },
|
|
353
|
+
confirming
|
|
354
|
+
? [
|
|
355
|
+
h(
|
|
356
|
+
'button',
|
|
357
|
+
{
|
|
358
|
+
key: 'go',
|
|
359
|
+
type: 'button',
|
|
360
|
+
style: { ...styles.button, ...styles.buttonPrimary },
|
|
361
|
+
disabled: busy,
|
|
362
|
+
onClick: () => void rotate(),
|
|
363
|
+
},
|
|
364
|
+
t.rotateConfirm,
|
|
365
|
+
),
|
|
366
|
+
h(
|
|
367
|
+
'button',
|
|
368
|
+
{ key: 'no', type: 'button', style: styles.button, disabled: busy, onClick: () => setConfirming(false) },
|
|
369
|
+
t.rotateCancel,
|
|
370
|
+
),
|
|
371
|
+
]
|
|
372
|
+
: h(
|
|
373
|
+
'button',
|
|
374
|
+
{
|
|
375
|
+
type: 'button',
|
|
376
|
+
style: styles.button,
|
|
377
|
+
disabled: !canRotate,
|
|
378
|
+
onClick: () => {
|
|
379
|
+
setNotice(null)
|
|
380
|
+
setConfirming(true)
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
t.rotate,
|
|
384
|
+
),
|
|
385
|
+
),
|
|
386
|
+
),
|
|
387
|
+
h(
|
|
388
|
+
'div',
|
|
389
|
+
{ style: styles.field },
|
|
390
|
+
h('span', { style: styles.label }, usesLoginUrl ? t.urlLabel : t.plainLabel),
|
|
391
|
+
h('input', {
|
|
392
|
+
ref: inputRef,
|
|
393
|
+
readOnly: true,
|
|
394
|
+
spellCheck: false,
|
|
395
|
+
value: url ?? '',
|
|
396
|
+
'aria-label': usesLoginUrl ? t.urlLabel : t.plainLabel,
|
|
397
|
+
style: styles.input,
|
|
398
|
+
onFocus: (event) => {
|
|
399
|
+
try {
|
|
400
|
+
event.target.select()
|
|
401
|
+
} catch {
|
|
402
|
+
/* 选中失败无所谓 */
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
}),
|
|
406
|
+
h(
|
|
407
|
+
'button',
|
|
408
|
+
{ type: 'button', style: styles.button, disabled: url === null || url === undefined, onClick: () => void copy() },
|
|
409
|
+
copied ? t.copied : t.copy,
|
|
410
|
+
),
|
|
411
|
+
),
|
|
412
|
+
confirming ? h('div', { style: styles.warn }, warningText) : null,
|
|
413
|
+
notice === null ? null : h('div', { style: styles.warn }, notice),
|
|
414
|
+
error !== null
|
|
415
|
+
? h(
|
|
416
|
+
'div',
|
|
417
|
+
{ style: styles.error, role: 'alert' },
|
|
418
|
+
`${error} `,
|
|
419
|
+
h(
|
|
420
|
+
'button',
|
|
421
|
+
{ type: 'button', style: styles.button, onClick: () => void load() },
|
|
422
|
+
t.retry,
|
|
423
|
+
),
|
|
424
|
+
)
|
|
425
|
+
: null,
|
|
426
|
+
state === null || state.phase === 'ready'
|
|
427
|
+
? h(
|
|
428
|
+
'div',
|
|
429
|
+
{ style: styles.hint },
|
|
430
|
+
usesLoginUrl ? `${t.keepBookmark}${t.cookieHint(state?.cookieMaxAgeDays ?? 0)}` : t.noLogin,
|
|
431
|
+
)
|
|
432
|
+
: h('div', { style: styles.hint }, hint),
|
|
433
|
+
)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* 注册这一行。
|
|
438
|
+
*
|
|
439
|
+
* `slots.inject` 而不是 `slots.register` 直调:`settings.general.item` 由
|
|
440
|
+
* ui-settings-general 声明,它和本包的激活顺序没有保证。
|
|
441
|
+
* @param ctx - 客户端根上下文。
|
|
442
|
+
*/
|
|
443
|
+
function apply(ctx) {
|
|
444
|
+
ctx.slots.inject('settings.general.item', () =>
|
|
445
|
+
ctx.slots.register(
|
|
446
|
+
{
|
|
447
|
+
name: 'settings.general.item',
|
|
448
|
+
id: ROW_ID,
|
|
449
|
+
// 排在 performance-usage(30) 之后、current-version(100) 之前。
|
|
450
|
+
order: 95,
|
|
451
|
+
// 组件从注入面上拿 ctx(插槽的 standard props 里没有 ctx)。
|
|
452
|
+
inject: () => ({ ctx }),
|
|
453
|
+
},
|
|
454
|
+
RemoteRow,
|
|
455
|
+
),
|
|
456
|
+
)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
exports.name = 'dsh-tailscale-remote'
|
|
460
|
+
// 只要 slots;locale 走 ctx.get 可选读取(写进 inject 会在它缺席时 park 掉整包)。
|
|
461
|
+
exports.inject = ['slots']
|
|
462
|
+
exports.apply = apply
|
|
463
|
+
return module.exports
|
|
464
|
+
},
|
|
465
|
+
})
|
package/index.js
CHANGED
|
@@ -107,6 +107,12 @@ function normalizeConfig(raw) {
|
|
|
107
107
|
Number.isFinite(source.cookieMaxAgeDays) && source.cookieMaxAgeDays >= 0 ? source.cookieMaxAgeDays : 3650,
|
|
108
108
|
// 是否注册一个「稳定登录入口」路由(/remote/<持久密钥>),让手机不用去翻令牌。
|
|
109
109
|
loginRoute: source.loginRoute !== false,
|
|
110
|
+
// 换密钥时是否**同时吊销所有已登录的会话**。
|
|
111
|
+
//
|
|
112
|
+
// 默认 true,因为换密钥的动机通常就是「密钥泄露了」:只换登录密钥的话,
|
|
113
|
+
// 已经用泄露密钥登录过的人手里那张 10 年 cookie 依然有效。代价是所有设备
|
|
114
|
+
// (含自己的手机)都要重新登录一次;点按钮的那台会被补发 cookie、不掉线。
|
|
115
|
+
revokeSessionsOnRotate: source.revokeSessionsOnRotate !== false,
|
|
110
116
|
}
|
|
111
117
|
}
|
|
112
118
|
|
|
@@ -435,6 +441,340 @@ function sessionCookieHeader(minted) {
|
|
|
435
441
|
return `${minted.name}=${minted.value}; Max-Age=${String(minted.maxAgeSeconds)}; Path=/; Expires=${new Date(minted.expiresAt).toUTCString()}; HttpOnly; SameSite=Lax`
|
|
436
442
|
}
|
|
437
443
|
|
|
444
|
+
/**
|
|
445
|
+
* 设置页那一行读的两个管理端点。
|
|
446
|
+
*
|
|
447
|
+
* 注册在 `connection.fetch` 上而**不是** `webServer.register`:`/api` 那条前缀路由
|
|
448
|
+
* 上挂着 DSH 自己的 Host/Origin fence 与浏览器 cookie 鉴权(`connection.admit`),
|
|
449
|
+
* 注册到 `connection.fetch` 等于白拿这套策略。反过来,用 `webServer.register` 建一条
|
|
450
|
+
* exact 的 `/api/...` 会**抢先命中** `match()` 的 exact 表,把整条 `/api` 前缀路由遮掉。
|
|
451
|
+
*/
|
|
452
|
+
const STATE_PATH = '/api/dsh-tailscale-remote/state'
|
|
453
|
+
const ROTATE_PATH = '/api/dsh-tailscale-remote/rotate'
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* DSH 浏览器会话 cookie 的签名密钥在 credentials 里的记录键。
|
|
457
|
+
*
|
|
458
|
+
* 值是 `credentialKey('client-connection', 'browser-session')` —— 那个 helper 只是
|
|
459
|
+
* `brandString(`${scope}/${id}`)`(`@deepseek-ai/dsh-credentials` 的 types),所以
|
|
460
|
+
* 运行时就是这条普通字符串。**必须逐字一致**:写错键会静默写到另一条记录上,
|
|
461
|
+
* 于是重启后旧密钥照旧生效、旧 cookie 继续能用 —— 换了个寂寞而且毫无提示。
|
|
462
|
+
*/
|
|
463
|
+
const AUTH_RECORD_KEY = 'client-connection/browser-session'
|
|
464
|
+
/** 会话密钥长度(字节),与 client-connection 的 `SECRET_BYTES` 一致,否则它拒绝加载。 */
|
|
465
|
+
const SESSION_SECRET_BYTES = 32
|
|
466
|
+
/** 记录格式版本,与 `STORED_SECRET_VERSION` 一致。 */
|
|
467
|
+
const STORED_SECRET_VERSION = 1
|
|
468
|
+
|
|
469
|
+
/** 从请求头取规范化的 authority(cookie 名与签名受众都用它)。 */
|
|
470
|
+
function hostAuthority(headers) {
|
|
471
|
+
const host = headers?.get?.('host')
|
|
472
|
+
if (typeof host !== 'string' || host === '') return undefined
|
|
473
|
+
try {
|
|
474
|
+
return new URL(`http://${host}`).host
|
|
475
|
+
} catch {
|
|
476
|
+
return undefined
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* 换掉 DSH 浏览器会话 cookie 的签名密钥,从而**立刻吊销所有已登录的会话**。
|
|
482
|
+
*
|
|
483
|
+
* 为什么换密钥必须连这一步一起做:登录链接(`/remote/<密钥>`)只负责**换取**一张
|
|
484
|
+
* 会话 cookie。密钥泄露后,如果对方已经用它登录过,他手里那张 cookie 是用
|
|
485
|
+
* `browserAuth.secret` 签名的、**有效期 10 年**,换登录密钥完全不影响它。
|
|
486
|
+
* 要让泄露的密钥真正失效,签名密钥也得换。
|
|
487
|
+
*
|
|
488
|
+
* 两处都要改,缺一不可:
|
|
489
|
+
* 1. credentials 记录(**持久**)—— 否则 DSH 一重启就把旧密钥读回来,旧 cookie 复活。
|
|
490
|
+
* 2. 活着的 `browserAuth.secret`(**立即生效**)—— 它按引用比较签名,改了就当场失效。
|
|
491
|
+
* @param scope - 带 `connection` / `credentials` 的上下文。
|
|
492
|
+
* @returns 新的密钥字节。
|
|
493
|
+
* @throws 服务缺席、或写入没有真正落到那条记录上时(绝不假装成功)。
|
|
494
|
+
*/
|
|
495
|
+
async function rotateSessionSecret(scope) {
|
|
496
|
+
const credentials = scope.get('credentials', false)
|
|
497
|
+
if (credentials === undefined || typeof credentials.modifyRecord !== 'function') {
|
|
498
|
+
throw new Error('读不到 credentials 服务,无法吊销已登录的会话')
|
|
499
|
+
}
|
|
500
|
+
const connection = scope.get('connection', false)
|
|
501
|
+
if (connection?.browserAuth === undefined) {
|
|
502
|
+
throw new Error('读不到 connection.browserAuth,无法吊销已登录的会话')
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const secret = randomBytes(SESSION_SECRET_BYTES).toString('base64url')
|
|
506
|
+
const written = await credentials.modifyRecord(AUTH_RECORD_KEY, () =>
|
|
507
|
+
Promise.resolve({
|
|
508
|
+
kind: 'grant',
|
|
509
|
+
payload: { version: STORED_SECRET_VERSION, secret },
|
|
510
|
+
}),
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
// 回读确认。写错键、或被只读来源遮蔽时,`modifyRecord` 会**成功返回**但记录没变,
|
|
514
|
+
// 于是这里不校验的话就是「报成功、实则没换」——重启后旧 cookie 复活。
|
|
515
|
+
if (written?.payload?.secret !== secret) {
|
|
516
|
+
throw new Error('会话密钥没有写入成功(credentials 记录未更新)')
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
connection.browserAuth.secret = Buffer.from(secret, 'base64url')
|
|
520
|
+
return connection.browserAuth.secret
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* 组装管理端点要返回的状态。
|
|
525
|
+
*
|
|
526
|
+
* 刻意**不抛**:「还在启动」「起不来」都是设置页要看的正常答案,不是错误响应。
|
|
527
|
+
* @param config - 归一化后的配置。
|
|
528
|
+
* @param runtime - 本次运行的活状态(见 `apply`)。
|
|
529
|
+
*/
|
|
530
|
+
function statePayload(config, runtime) {
|
|
531
|
+
const state = runtime.state
|
|
532
|
+
return {
|
|
533
|
+
ok: true,
|
|
534
|
+
enabled: config.enabled,
|
|
535
|
+
phase: runtime.phase,
|
|
536
|
+
error: runtime.error ?? null,
|
|
537
|
+
// 只有「已就绪」才谈得上换密钥:换密钥要写回状态文件并重建登录路由。
|
|
538
|
+
canRotate: config.enabled && config.loginRoute && runtime.phase === 'ready',
|
|
539
|
+
// 换密钥会不会把所有设备踢下线(客户端据此决定警告文案的强度)。
|
|
540
|
+
revokesSessions: config.enabled && config.loginRoute && config.revokeSessionsOnRotate,
|
|
541
|
+
plainUrl: state?.plainUrl ?? null,
|
|
542
|
+
loginUrl: state?.loginUrl ?? null,
|
|
543
|
+
authority: state?.authority ?? null,
|
|
544
|
+
dnsName: state?.dnsName ?? null,
|
|
545
|
+
httpsPort: state?.httpsPort ?? config.httpsPort,
|
|
546
|
+
cookieMaxAgeDays: state?.cookieMaxAgeDays ?? config.cookieMaxAgeDays,
|
|
547
|
+
updatedAt: state?.updatedAt ?? null,
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* 注册(或换成)登录入口路由。
|
|
553
|
+
*
|
|
554
|
+
* 换密钥时**必须先撤掉旧的**:路由表按 `(kind, path)` 去重,路径变了不会报冲突,
|
|
555
|
+
* 于是旧链接会继续可用 —— 那会让「换完之后旧链接立刻失效」变成谎话。
|
|
556
|
+
* @param runtime - 活状态;`webScope` 缺席时什么都不做。
|
|
557
|
+
* @param connection - `connection` 服务(取签名密钥用)。
|
|
558
|
+
* @param config - 归一化后的配置。
|
|
559
|
+
* @param authority - `<dnsName>:<httpsPort>`。
|
|
560
|
+
* @param secret - 要用的密钥。
|
|
561
|
+
* @returns 新路径;装不上时返回 undefined。
|
|
562
|
+
*/
|
|
563
|
+
function installLoginRoute(runtime, connection, config, authority, secret) {
|
|
564
|
+
const webScope = runtime.webScope
|
|
565
|
+
if (webScope === undefined) return undefined
|
|
566
|
+
const webServer = webScope.get('webServer')
|
|
567
|
+
if (webServer === undefined) return undefined
|
|
568
|
+
|
|
569
|
+
runtime.login?.dispose?.()
|
|
570
|
+
runtime.login = undefined
|
|
571
|
+
|
|
572
|
+
const path = `/remote/${secret}`
|
|
573
|
+
const dispose = webScope.effect(
|
|
574
|
+
() =>
|
|
575
|
+
webServer.register({
|
|
576
|
+
kind: 'exact',
|
|
577
|
+
path,
|
|
578
|
+
handler: (req, res) => {
|
|
579
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
580
|
+
res.writeHead(405)
|
|
581
|
+
res.end()
|
|
582
|
+
return
|
|
583
|
+
}
|
|
584
|
+
// 首选:自己铸 cookie,一跳到位(见 mintSessionCookie 的说明)。
|
|
585
|
+
const minted = mintSessionCookie(connection, authority, config.cookieMaxAgeDays * 86400000)
|
|
586
|
+
if (minted !== undefined) {
|
|
587
|
+
res.writeHead(302, {
|
|
588
|
+
location: `https://${authority}/`,
|
|
589
|
+
'set-cookie': sessionCookieHeader(minted),
|
|
590
|
+
'cache-control': 'no-store',
|
|
591
|
+
'referrer-policy': 'no-referrer',
|
|
592
|
+
})
|
|
593
|
+
res.end()
|
|
594
|
+
return
|
|
595
|
+
}
|
|
596
|
+
// 回落:拿不到签名密钥时走 DSH 自己的令牌链。
|
|
597
|
+
res.writeHead(302, {
|
|
598
|
+
location: runtime.handoffUrl ?? `https://${authority}/`,
|
|
599
|
+
'cache-control': 'no-store',
|
|
600
|
+
'referrer-policy': 'no-referrer',
|
|
601
|
+
})
|
|
602
|
+
res.end()
|
|
603
|
+
},
|
|
604
|
+
}),
|
|
605
|
+
'tailscale-remote: login route',
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
runtime.login = { secret, path, dispose }
|
|
609
|
+
return path
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* 换一个新密钥:换登录密钥 → 吊销所有会话 → 装上新路由 → 写回状态文件 → 更新活状态。
|
|
614
|
+
*
|
|
615
|
+
* 吊销会话是**有意的**:登录密钥泄露后,对方可能已经用它换到一张 10 年有效的会话
|
|
616
|
+
* cookie;只换登录密钥的话他照样在里面。代价是**所有设备都要重新登录一次**,
|
|
617
|
+
* 包括你自己的手机 —— 这正是「密钥泄露」时该有的行为。
|
|
618
|
+
*
|
|
619
|
+
* 例外:**发起这次更换的那个浏览器会被补发一张新 cookie**,所以它自己不会掉线。
|
|
620
|
+
* 也就是说「在你电脑上点更换」= 电脑留着、其它设备全部踢掉。
|
|
621
|
+
* @param scope - 带 `connection` 的上下文。
|
|
622
|
+
* @param config - 归一化后的配置。
|
|
623
|
+
* @param runtime - 活状态。
|
|
624
|
+
* @param deps - 依赖注入(测试替换用)。
|
|
625
|
+
* @param requestingAuthority - 发起请求的那个 authority(用它给当前浏览器补发 cookie)。
|
|
626
|
+
* @returns `{ loginUrl, loginPath, updatedAt, revokesSessions, stayedSignedIn }`。
|
|
627
|
+
* @throws 未就绪 / 登录入口已关闭 / webServer 或 credentials 不可用时。
|
|
628
|
+
*/
|
|
629
|
+
async function rotateLoginSecret(scope, config, runtime, deps = REAL_DEPS, requestingAuthority) {
|
|
630
|
+
if (!config.loginRoute) throw new Error('登录入口已关闭(loginRoute=false),没有可更换的密钥')
|
|
631
|
+
const state = runtime.state
|
|
632
|
+
if (state === undefined) throw new Error('远程访问还没就绪,稍后再试')
|
|
633
|
+
|
|
634
|
+
const connection = scope.get('connection', false)
|
|
635
|
+
|
|
636
|
+
// 先吊销会话,再装新路由:反过来的话,若吊销失败就已经把新密钥写出去了,
|
|
637
|
+
// 用户会以为换成功了,而泄露的那张 cookie 还活着。
|
|
638
|
+
if (config.revokeSessionsOnRotate) await rotateSessionSecret(scope)
|
|
639
|
+
|
|
640
|
+
const secret = randomBytes(24).toString('base64url')
|
|
641
|
+
const loginPath = installLoginRoute(runtime, connection, config, state.authority, secret)
|
|
642
|
+
if (loginPath === undefined) throw new Error('webServer 不可用,换不了密钥')
|
|
643
|
+
|
|
644
|
+
const loginUrl = `https://${state.authority}${loginPath}`
|
|
645
|
+
let handoffUrl = state.plainUrl
|
|
646
|
+
try {
|
|
647
|
+
handoffUrl = connection?.authenticatedUrl?.(state.plainUrl) ?? state.plainUrl
|
|
648
|
+
} catch {
|
|
649
|
+
handoffUrl = state.plainUrl
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const next = {
|
|
653
|
+
...state,
|
|
654
|
+
url: handoffUrl,
|
|
655
|
+
loginUrl,
|
|
656
|
+
loginPath,
|
|
657
|
+
loginSecret: secret,
|
|
658
|
+
updatedAt: new Date().toISOString(),
|
|
659
|
+
}
|
|
660
|
+
await deps.persistState(scope, next)
|
|
661
|
+
runtime.state = next
|
|
662
|
+
runtime.handoffUrl = handoffUrl
|
|
663
|
+
if (runtime.undo !== undefined) runtime.undo.loginPath = loginPath
|
|
664
|
+
|
|
665
|
+
// 给**发起请求的那个 authority** 补一张新 cookie,让点按钮的这台设备不掉线。
|
|
666
|
+
// 只能补这一个:cookie 按 authority 命名并签名,给别的 authority 种的 cookie
|
|
667
|
+
// 浏览器会直接忽略。
|
|
668
|
+
let stayedSignedIn = false
|
|
669
|
+
if (config.revokeSessionsOnRotate && typeof requestingAuthority === 'string' && requestingAuthority !== '') {
|
|
670
|
+
const minted = mintSessionCookie(connection, requestingAuthority, config.cookieMaxAgeDays * 86400000)
|
|
671
|
+
if (minted !== undefined) stayedSignedIn = true
|
|
672
|
+
return {
|
|
673
|
+
loginUrl,
|
|
674
|
+
loginPath,
|
|
675
|
+
updatedAt: next.updatedAt,
|
|
676
|
+
revokesSessions: true,
|
|
677
|
+
stayedSignedIn,
|
|
678
|
+
cookie: minted === undefined ? null : sessionCookieHeader(minted),
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
return { loginUrl, loginPath, updatedAt: next.updatedAt, revokesSessions: config.revokeSessionsOnRotate, stayedSignedIn }
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* 注册管理端点。
|
|
687
|
+
*
|
|
688
|
+
* 即便 `enabled=false` 也注册:设置页那一行要能回答「为什么没起来」,
|
|
689
|
+
* 而不是只能看到一个 404。
|
|
690
|
+
* @param scope - 带 `connection` 的上下文。
|
|
691
|
+
* @param config - 归一化后的配置。
|
|
692
|
+
* @param runtime - 活状态。
|
|
693
|
+
*/
|
|
694
|
+
function registerAdminRoutes(scope, config, runtime) {
|
|
695
|
+
const connection = scope.get('connection', false)
|
|
696
|
+
const routes = connection?.fetch
|
|
697
|
+
if (routes === undefined) return
|
|
698
|
+
|
|
699
|
+
const json = (payload, status = 200, extraHeaders) =>
|
|
700
|
+
new Response(JSON.stringify(payload), {
|
|
701
|
+
status,
|
|
702
|
+
headers: {
|
|
703
|
+
'content-type': 'application/json; charset=utf-8',
|
|
704
|
+
'cache-control': 'no-store',
|
|
705
|
+
...(extraHeaders ?? {}),
|
|
706
|
+
},
|
|
707
|
+
})
|
|
708
|
+
|
|
709
|
+
// 每条路由都显式挂在自己的 effect 上。
|
|
710
|
+
//
|
|
711
|
+
// `connection.fetch.register` 内部**确实**会用「读服务的那个 ctx」调 `owner.effect`,
|
|
712
|
+
// 但那个 ctx 来自 cordis 的服务代理在调用点绑定 —— 与其依赖这条链路,不如自己
|
|
713
|
+
// 明确声明生命周期:`scope.effect(() => routes.register(...))` 拿返回的 disposer
|
|
714
|
+
// 作为清理函数,插件卸载时路由一定跟着走,不多不少。
|
|
715
|
+
scope.effect(
|
|
716
|
+
() =>
|
|
717
|
+
routes.register({
|
|
718
|
+
path: STATE_PATH,
|
|
719
|
+
methods: ['GET'],
|
|
720
|
+
requestBody: 'buffered',
|
|
721
|
+
fetch: () => Promise.resolve(json(statePayload(config, runtime))),
|
|
722
|
+
}),
|
|
723
|
+
'tailscale-remote: state route',
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
scope.effect(
|
|
727
|
+
() =>
|
|
728
|
+
routes.register({
|
|
729
|
+
path: ROTATE_PATH,
|
|
730
|
+
methods: ['POST'],
|
|
731
|
+
requestBody: 'buffered',
|
|
732
|
+
fetch: async (request) => {
|
|
733
|
+
try {
|
|
734
|
+
const result = await rotateLoginSecret(
|
|
735
|
+
scope,
|
|
736
|
+
config,
|
|
737
|
+
runtime,
|
|
738
|
+
REAL_DEPS,
|
|
739
|
+
hostAuthority(request?.headers),
|
|
740
|
+
)
|
|
741
|
+
// 补发的 cookie 必须由 HTTP 响应带走;放在 body 里没用。
|
|
742
|
+
const cookie = typeof result.cookie === 'string' ? { 'set-cookie': result.cookie } : undefined
|
|
743
|
+
const { cookie: _omitted, ...payload } = result
|
|
744
|
+
return json({ ok: true, ...payload }, 200, cookie)
|
|
745
|
+
} catch (error) {
|
|
746
|
+
return json({ ok: false, error: String(error?.message ?? error) }, 409)
|
|
747
|
+
}
|
|
748
|
+
},
|
|
749
|
+
}),
|
|
750
|
+
'tailscale-remote: rotate route',
|
|
751
|
+
)
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* 本次运行的活状态。
|
|
756
|
+
*
|
|
757
|
+
* 设置页那一行要能回答「现在到哪一步了」,而 `setup` 的返回值只够写日志;
|
|
758
|
+
* 这里把阶段、错误、状态快照和已装的登录路由都挂在一个对象上,`apply` 建一次、
|
|
759
|
+
* `setup` 往里写、管理端点读。
|
|
760
|
+
* @param undo - 本次运行的回滚清单(换密钥时要更新它的 loginPath)。
|
|
761
|
+
*/
|
|
762
|
+
function freshRuntime(undo) {
|
|
763
|
+
return {
|
|
764
|
+
phase: 'starting',
|
|
765
|
+
error: undefined,
|
|
766
|
+
/** 最近一次写盘的状态快照(见 `setup` 末尾)。 */
|
|
767
|
+
state: undefined,
|
|
768
|
+
/** 登录入口落地的那个 webServer 作用域;换密钥时复用它。 */
|
|
769
|
+
webScope: undefined,
|
|
770
|
+
/** 当前登录路由 `{ secret, path, dispose }`;换密钥时先撤掉它。 */
|
|
771
|
+
login: undefined,
|
|
772
|
+
/** 回落跳转用的带令牌地址。 */
|
|
773
|
+
handoffUrl: undefined,
|
|
774
|
+
undo,
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
438
778
|
/**
|
|
439
779
|
* 插件入口。
|
|
440
780
|
* @param {import('@deepseek-ai/cordis').Context} ctx - 插件上下文。
|
|
@@ -442,10 +782,6 @@ function sessionCookieHeader(minted) {
|
|
|
442
782
|
*/
|
|
443
783
|
export function apply(ctx, rawConfig) {
|
|
444
784
|
const config = normalizeConfig(rawConfig)
|
|
445
|
-
if (!config.enabled) {
|
|
446
|
-
ctx.logger.info('tailscale-remote: enabled=false,插件不动作')
|
|
447
|
-
return
|
|
448
|
-
}
|
|
449
785
|
|
|
450
786
|
// connection 是可选依赖:服务晚到也会触发,永不出现则保持静默。
|
|
451
787
|
// 服务只从 scope 参数上取(inject 新建的 fiber 的 store 里才有它)。
|
|
@@ -466,6 +802,18 @@ export function apply(ctx, rawConfig) {
|
|
|
466
802
|
loginPath: undefined,
|
|
467
803
|
disposed: false,
|
|
468
804
|
}
|
|
805
|
+
const runtime = freshRuntime(undo)
|
|
806
|
+
|
|
807
|
+
// 管理端点**即便 enabled=false 也注册**:设置页那一行要能回答「为什么没起来」,
|
|
808
|
+
// 而不是只能看到一个 404。客户端半边是独立声明的,禁用时照样会加载并渲染这一行。
|
|
809
|
+
if (!config.enabled) {
|
|
810
|
+
runtime.phase = 'disabled'
|
|
811
|
+
ctx.logger.info('tailscale-remote: enabled=false,只提供设置页状态,不动作')
|
|
812
|
+
registerAdminRoutes(scope, config, runtime)
|
|
813
|
+
return
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
registerAdminRoutes(scope, config, runtime)
|
|
469
817
|
|
|
470
818
|
// 退出清理挂在这里:这是唯一可靠的关停挂点,且 disposer 绝不能 reject。
|
|
471
819
|
scope.effect(() => async () => {
|
|
@@ -477,8 +825,10 @@ export function apply(ctx, rawConfig) {
|
|
|
477
825
|
}, 'tailscale-remote: teardown')
|
|
478
826
|
|
|
479
827
|
// 启动流程异步跑,不阻塞 Loader 装配。
|
|
480
|
-
setup(scope, config, undo).catch((error) => {
|
|
481
|
-
|
|
828
|
+
setup(scope, config, undo, REAL_DEPS, runtime).catch((error) => {
|
|
829
|
+
runtime.phase = 'error'
|
|
830
|
+
runtime.error = String(error?.message ?? error)
|
|
831
|
+
scope.logger.warn(`tailscale-remote: 启动失败:${runtime.error}`)
|
|
482
832
|
})
|
|
483
833
|
})
|
|
484
834
|
}
|
|
@@ -499,10 +849,15 @@ const REAL_DEPS = {
|
|
|
499
849
|
}
|
|
500
850
|
|
|
501
851
|
/** 启动:拉起 tailscale → 建 serve 路由 → 信任该 authority → 打印手机可用的链接。 */
|
|
502
|
-
async function setup(scope, config, undo, deps = REAL_DEPS) {
|
|
852
|
+
async function setup(scope, config, undo, deps = REAL_DEPS, runtime = freshRuntime(undo)) {
|
|
503
853
|
const logger = scope.logger
|
|
854
|
+
runtime.undo = undo
|
|
855
|
+
runtime.phase = 'starting'
|
|
856
|
+
runtime.error = undefined
|
|
504
857
|
const binary = await deps.resolveBinary(scope, config.binaryPath)
|
|
505
858
|
if (binary === undefined) {
|
|
859
|
+
runtime.phase = 'unavailable'
|
|
860
|
+
runtime.error = '找不到 tailscale 可执行文件'
|
|
506
861
|
logger.warn('tailscale-remote: 找不到 tailscale 可执行文件;请安装 Tailscale 或用 config.binaryPath 指定路径')
|
|
507
862
|
return
|
|
508
863
|
}
|
|
@@ -515,11 +870,17 @@ async function setup(scope, config, undo, deps = REAL_DEPS) {
|
|
|
515
870
|
// 关键:必须验证 backend 真的 Running,不能只信 `tailscale up` 的退出码
|
|
516
871
|
// (Windows 上客户端被完全退出时,up 会 exit 0 但什么都不做)。
|
|
517
872
|
const running = await ensureRunning(binary, config, deps, logger, undo)
|
|
518
|
-
if (!running)
|
|
873
|
+
if (!running) {
|
|
874
|
+
runtime.phase = 'unavailable'
|
|
875
|
+
runtime.error = 'tailscale 没在运行'
|
|
876
|
+
return
|
|
877
|
+
}
|
|
519
878
|
if (!wasRunning) status = await deps.readStatus(binary)
|
|
520
879
|
|
|
521
880
|
const dnsName = dnsNameOf(status)
|
|
522
881
|
if (dnsName === undefined) {
|
|
882
|
+
runtime.phase = 'unavailable'
|
|
883
|
+
runtime.error = '读不到 tailnet DNS 名称'
|
|
523
884
|
logger.warn('tailscale-remote: 读不到 tailnet DNS 名称,跳过 Serve 配置')
|
|
524
885
|
return
|
|
525
886
|
}
|
|
@@ -527,6 +888,8 @@ async function setup(scope, config, undo, deps = REAL_DEPS) {
|
|
|
527
888
|
// DSH 自己的 Web 端口从服务上读,绝不写死。
|
|
528
889
|
const port = await deps.waitForPort(scope)
|
|
529
890
|
if (port === undefined) {
|
|
891
|
+
runtime.phase = 'unavailable'
|
|
892
|
+
runtime.error = '等不到 DSH Web 端口'
|
|
530
893
|
logger.warn('tailscale-remote: 等不到 DSH Web 端口,跳过 Serve 配置')
|
|
531
894
|
return
|
|
532
895
|
}
|
|
@@ -538,6 +901,8 @@ async function setup(scope, config, undo, deps = REAL_DEPS) {
|
|
|
538
901
|
const serveStatus = await deps.readServeStatus(binary)
|
|
539
902
|
const state = classifyServe(serveStatus, config.httpsPort, target)
|
|
540
903
|
if (state.kind === 'conflict') {
|
|
904
|
+
runtime.phase = 'error'
|
|
905
|
+
runtime.error = `${String(config.httpsPort)} 端口上有冲突的 Serve 配置(${state.reason})`
|
|
541
906
|
logger.warn(`tailscale-remote: ${String(config.httpsPort)} 端口上有冲突的 Serve 配置(${state.reason}),不覆盖`)
|
|
542
907
|
return
|
|
543
908
|
}
|
|
@@ -554,8 +919,11 @@ async function setup(scope, config, undo, deps = REAL_DEPS) {
|
|
|
554
919
|
}
|
|
555
920
|
const serve = await deps.run(binary, ['serve', '--bg', `--https=${String(config.httpsPort)}`, target], SETUP_TIMEOUT_MS)
|
|
556
921
|
if (!serve.ok) {
|
|
922
|
+
const detail = (serve.stderr || serve.stdout).trim().slice(0, 300)
|
|
923
|
+
runtime.phase = 'error'
|
|
924
|
+
runtime.error = detail === '' ? '配置 Serve 失败' : detail
|
|
557
925
|
logger.warn(
|
|
558
|
-
`tailscale-remote: 配置 Serve 失败: ${
|
|
926
|
+
`tailscale-remote: 配置 Serve 失败: ${detail};` +
|
|
559
927
|
'若提示 HTTPS 证书,请先在 Tailscale 管理台为该 tailnet 启用 HTTPS 证书',
|
|
560
928
|
)
|
|
561
929
|
return
|
|
@@ -593,6 +961,15 @@ async function setup(scope, config, undo, deps = REAL_DEPS) {
|
|
|
593
961
|
}
|
|
594
962
|
}
|
|
595
963
|
|
|
964
|
+
let handoffUrl = plainUrl
|
|
965
|
+
try {
|
|
966
|
+
// authenticatedUrl 会把本进程的启动令牌加到链接上;首次打开会种下 cookie。
|
|
967
|
+
handoffUrl = connection?.authenticatedUrl?.(plainUrl) ?? plainUrl
|
|
968
|
+
} catch {
|
|
969
|
+
handoffUrl = plainUrl
|
|
970
|
+
}
|
|
971
|
+
runtime.handoffUrl = handoffUrl
|
|
972
|
+
|
|
596
973
|
// 稳定的登录入口:/remote/<持久密钥> → 302 到带令牌的地址。
|
|
597
974
|
// 令牌每次都变、用户没法存书签;这条路径的密钥写在状态文件里,**跨重启不变**,
|
|
598
975
|
// 所以手机上存一次就够了。密钥同时充当访问门槛(不知道密钥的人拿不到会话)。
|
|
@@ -600,66 +977,22 @@ async function setup(scope, config, undo, deps = REAL_DEPS) {
|
|
|
600
977
|
let loginPath
|
|
601
978
|
if (config.loginRoute) {
|
|
602
979
|
const secret = await deps.ensureLoginSecret(scope, config, deps)
|
|
980
|
+
// 路径在这里**同步**算出来:`scope.inject` 的服务晚到时回调是异步的,
|
|
981
|
+
// 若等回调再算,webServer 迟到就会让状态文件里丢掉 loginUrl。
|
|
603
982
|
loginPath = `/remote/${secret}`
|
|
604
983
|
loginUrl = `https://${authority}${loginPath}`
|
|
605
984
|
undo.loginPath = loginPath
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
let handoffUrl = plainUrl
|
|
609
|
-
try {
|
|
610
|
-
// authenticatedUrl 会把本进程的启动令牌加到链接上;首次打开会种下 cookie。
|
|
611
|
-
handoffUrl = connection?.authenticatedUrl?.(plainUrl) ?? plainUrl
|
|
612
|
-
} catch {
|
|
613
|
-
handoffUrl = plainUrl
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
// 登录入口的跳转目标就是上面这个带令牌的地址;注册在 DSH 自己的 webserver 上
|
|
617
|
-
// (所以不需要第二个端口、也不需要额外的 tailscale serve 路由)。
|
|
618
|
-
if (loginPath !== undefined) {
|
|
619
|
-
const registerLoginRoute = (webScope) => {
|
|
620
|
-
const webServer = webScope.get('webServer')
|
|
621
|
-
if (webServer === undefined) return
|
|
622
|
-
webScope.effect(
|
|
623
|
-
() =>
|
|
624
|
-
webServer.register({
|
|
625
|
-
kind: 'exact',
|
|
626
|
-
path: loginPath,
|
|
627
|
-
handler: (req, res) => {
|
|
628
|
-
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
629
|
-
res.writeHead(405)
|
|
630
|
-
res.end()
|
|
631
|
-
return
|
|
632
|
-
}
|
|
633
|
-
// 首选:自己铸 cookie,一跳到位(见 mintSessionCookie 的说明)。
|
|
634
|
-
const minted = mintSessionCookie(connection, authority, config.cookieMaxAgeDays * 86400000)
|
|
635
|
-
if (minted !== undefined) {
|
|
636
|
-
res.writeHead(302, {
|
|
637
|
-
location: `https://${authority}/`,
|
|
638
|
-
'set-cookie': sessionCookieHeader(minted),
|
|
639
|
-
'cache-control': 'no-store',
|
|
640
|
-
'referrer-policy': 'no-referrer',
|
|
641
|
-
})
|
|
642
|
-
res.end()
|
|
643
|
-
return
|
|
644
|
-
}
|
|
645
|
-
// 回落:拿不到签名密钥时走 DSH 自己的令牌链。
|
|
646
|
-
res.writeHead(302, {
|
|
647
|
-
location: handoffUrl,
|
|
648
|
-
'cache-control': 'no-store',
|
|
649
|
-
'referrer-policy': 'no-referrer',
|
|
650
|
-
})
|
|
651
|
-
res.end()
|
|
652
|
-
},
|
|
653
|
-
}),
|
|
654
|
-
'tailscale-remote: login route',
|
|
655
|
-
)
|
|
656
|
-
}
|
|
985
|
+
// 登录入口的跳转目标就是上面这个带令牌的地址;注册在 DSH 自己的 webserver 上
|
|
986
|
+
// (所以不需要第二个端口、也不需要额外的 tailscale serve 路由)。
|
|
657
987
|
// 嵌套 inject:webServer 缺席时只丢登录入口,tailscale / 信任项照常工作。
|
|
658
|
-
scope.inject(['webServer'], (webScope) =>
|
|
988
|
+
scope.inject(['webServer'], (webScope) => {
|
|
989
|
+
runtime.webScope = webScope
|
|
990
|
+
installLoginRoute(runtime, connection, config, authority, secret)
|
|
991
|
+
})
|
|
659
992
|
}
|
|
660
993
|
|
|
661
994
|
const loginSecret = loginPath === undefined ? undefined : loginPath.slice('/remote/'.length)
|
|
662
|
-
|
|
995
|
+
const snapshot = {
|
|
663
996
|
url: handoffUrl,
|
|
664
997
|
loginUrl,
|
|
665
998
|
loginPath,
|
|
@@ -672,7 +1005,11 @@ async function setup(scope, config, undo, deps = REAL_DEPS) {
|
|
|
672
1005
|
cookieMaxAgeDays: config.cookieMaxAgeDays,
|
|
673
1006
|
updatedAt: new Date().toISOString(),
|
|
674
1007
|
stateFile: config.stateFile,
|
|
675
|
-
}
|
|
1008
|
+
}
|
|
1009
|
+
await deps.persistState(scope, snapshot)
|
|
1010
|
+
runtime.state = snapshot
|
|
1011
|
+
runtime.phase = 'ready'
|
|
1012
|
+
runtime.error = undefined
|
|
676
1013
|
|
|
677
1014
|
logger.info(`tailscale-remote: 手机可用地址 ${plainUrl}`)
|
|
678
1015
|
if (loginUrl !== plainUrl) {
|
|
@@ -771,7 +1108,17 @@ export const internals = {
|
|
|
771
1108
|
ensureLoginSecret,
|
|
772
1109
|
mintSessionCookie,
|
|
773
1110
|
sessionCookieHeader,
|
|
1111
|
+
statePayload,
|
|
1112
|
+
installLoginRoute,
|
|
1113
|
+
rotateLoginSecret,
|
|
1114
|
+
rotateSessionSecret,
|
|
1115
|
+
hostAuthority,
|
|
1116
|
+
registerAdminRoutes,
|
|
1117
|
+
freshRuntime,
|
|
774
1118
|
setup,
|
|
775
1119
|
teardown,
|
|
776
1120
|
REAL_DEPS,
|
|
1121
|
+
STATE_PATH,
|
|
1122
|
+
ROTATE_PATH,
|
|
1123
|
+
AUTH_RECORD_KEY,
|
|
777
1124
|
}
|
package/locale/en.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Tailscale remote access",
|
|
4
|
-
"description": "Publishes the DSH Web GUI on your tailnet whenever DSH runs, so a phone anywhere can connect, and removes the route on exit.
|
|
4
|
+
"description": "Publishes the DSH Web GUI on your tailnet whenever DSH runs, so a phone anywhere can connect, and removes the route on exit. The phone link is in Settings -> General -> Phone access; copy it with one click, and if it ever leaks, rotate the key there so the old link and every old session stop working."
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/zh.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Tailscale 远程访问",
|
|
4
|
-
"description": "开 DSH 就把 Web GUI 发布到你的 tailnet,手机随处可连;关 DSH
|
|
4
|
+
"description": "开 DSH 就把 Web GUI 发布到你的 tailnet,手机随处可连;关 DSH 自动撤掉路由。手机链接就在「设置 → 通用 → 手机连接」那一行里,点一下即可复制;链接泄露时可在那里更换密钥,旧链接与所有旧登录会一起失效。"
|
|
5
5
|
}
|
|
6
6
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-tailscale-remote",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Publishes the DSH Web GUI on your tailnet whenever DSH runs, so a phone anywhere can connect, and removes the route on exit. The
|
|
5
|
+
"description": "Publishes the DSH Web GUI on your tailnet whenever DSH runs, so a phone anywhere can connect, and removes the route on exit. The phone link lives in Settings -> General -> Phone access with one-click copy and key rotation: rotating revokes the old link and every session signed with it. The same link is in the loginUrl field of ~/.dsh/tailscale-remote.json.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Ckarefulon",
|
|
8
8
|
"publishConfig": {
|
|
@@ -31,23 +31,31 @@
|
|
|
31
31
|
],
|
|
32
32
|
"exports": {
|
|
33
33
|
".": "./index.js",
|
|
34
|
+
"./client": "./client/client.js",
|
|
34
35
|
"./package.json": "./package.json",
|
|
35
36
|
"./locale/*.json": "./locale/*.json"
|
|
36
37
|
},
|
|
37
38
|
"dsh": {
|
|
38
39
|
"bundle": {
|
|
39
40
|
"patch": "./cordis.patch.yml"
|
|
41
|
+
},
|
|
42
|
+
"client": {
|
|
43
|
+
"platform": "web",
|
|
44
|
+
"inject": [
|
|
45
|
+
"@deepseek-ai/dsh-client-ui-settings-general"
|
|
46
|
+
]
|
|
40
47
|
}
|
|
41
48
|
},
|
|
42
49
|
"files": [
|
|
43
50
|
"index.js",
|
|
51
|
+
"client/client.js",
|
|
44
52
|
"cordis.patch.yml",
|
|
45
53
|
"README.md",
|
|
46
54
|
"LICENSE",
|
|
47
55
|
"locale/*.json"
|
|
48
56
|
],
|
|
49
57
|
"scripts": {
|
|
50
|
-
"test": "node --test test/classify.test.mjs test/teardown.test.mjs"
|
|
58
|
+
"test": "node --test test/classify.test.mjs test/teardown.test.mjs test/routes.test.mjs test/client.test.mjs"
|
|
51
59
|
},
|
|
52
60
|
"engines": {
|
|
53
61
|
"node": ">=20"
|