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