dsh-tailscale-remote 0.0.0-stage → 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/index.js ADDED
@@ -0,0 +1,1003 @@
1
+ /**
2
+ * dsh-tailscale-remote — 把本机 DSH Web GUI 通过 Tailscale Serve 暴露到 tailnet 上,
3
+ * 让手机在任何网络下都能打开;DSH 退出时把这条路由撤掉。
4
+ *
5
+ * 设计要点(都是实测结论,别凭直觉改):
6
+ *
7
+ * 1. `tailscale serve` **保留客户端的 Host 头**(实测:请求 tailnet 地址时,后端收到
8
+ * 的 `host` 就是 `xxx.ts.net:7259`)。而 DSH 的 `/api` fence(`isTrustedApiRequest`)
9
+ * 要求 Host 是回环地址或命中 `trustedHosts`,否则一律 403。所以插件必须把
10
+ * `<dnsName>:<port>` 塞进 `connection.trustedHosts`,否则手机上页面能开、RPC 全 403。
11
+ *
12
+ * 2. `connection.trustedHosts` 是服务上一个**公开的可变数组**,fence 每次请求都读它,
13
+ * 所以运行时 push 立刻生效,不需要改 profile 的 cordis.yml。
14
+ *
15
+ * 3. 手机上要"打开网页就能连",靠的是 `connection.authenticatedUrl(url)` 生成的一次性
16
+ * 令牌链接:带 `?token=` 打开会种下 30 天的 HttpOnly cookie,之后书签直接访问即可。
17
+ *
18
+ * 4. 退出清理挂在 `ctx.effect(() => () => ...)` 上:这是唯一可靠的关停挂点(没有
19
+ * `app-boot/shutdown` 事件,Host 侧也没有 Electron 的 `before-quit`)。清理必须
20
+ * 幂等、绝不 reject(reject 的 disposer 会让 runProfile 抛 AggregateError)。
21
+ *
22
+ * @module dsh-tailscale-remote
23
+ */
24
+
25
+ import { spawn } from 'node:child_process'
26
+ import { createHash, createHmac, randomBytes } from 'node:crypto'
27
+ import { existsSync } from 'node:fs'
28
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
29
+ import { homedir } from 'node:os'
30
+ import { dirname, join } from 'node:path'
31
+
32
+ /** 稳定插件名。 */
33
+ export const name = 'dsh-tailscale-remote'
34
+
35
+ /** 清理阶段单条命令的超时(毫秒)。进程关停预算很紧,宁可放弃也不要拖住。 */
36
+ const TEARDOWN_TIMEOUT_MS = 3500
37
+
38
+ /** 启动阶段单条命令的超时(毫秒)。`tailscale up` 可能要等网络。 */
39
+ const SETUP_TIMEOUT_MS = 12000
40
+
41
+ /** 各平台 tailscale 可执行文件的候选路径,按顺序探测。 */
42
+ const BINARY_CANDIDATES = {
43
+ win32: [
44
+ process.env.TAILSCALE_PATH,
45
+ 'C:\\Program Files\\Tailscale\\tailscale.exe',
46
+ 'C:\\Program Files (x86)\\Tailscale\\tailscale.exe',
47
+ 'tailscale',
48
+ ],
49
+ darwin: [
50
+ process.env.TAILSCALE_PATH,
51
+ '/Applications/Tailscale.app/Contents/MacOS/Tailscale',
52
+ '/usr/local/bin/tailscale',
53
+ '/opt/homebrew/bin/tailscale',
54
+ '/usr/bin/tailscale',
55
+ 'tailscale',
56
+ ],
57
+ linux: [
58
+ process.env.TAILSCALE_PATH,
59
+ '/usr/bin/tailscale',
60
+ '/usr/local/bin/tailscale',
61
+ '/usr/sbin/tailscale',
62
+ 'tailscale',
63
+ ],
64
+ }
65
+
66
+ /**
67
+ * 各平台的 Tailscale **桌面客户端**——也就是用户手动「打开 Tailscale」的那一下。
68
+ *
69
+ * 为什么需要它:在 Windows(以及 macOS 的 App 形态)上,tailscaled 由客户端负责
70
+ * 初始化 backend。用户从托盘「完全退出」之后,服务还在跑、`tailscale status` 报
71
+ * `BackendState: NoState` + `Health: ["Tailscale is starting. Please wait."]`,
72
+ * 此时 **`tailscale up` 会「立刻 exit 0、不输出任何东西、也什么都不做」**(实测)。
73
+ * 只有把客户端拉起来,backend 才会真正连上(实测:启动后立刻变 Running + Online)。
74
+ */
75
+ const GUI_CANDIDATES = {
76
+ win32: [
77
+ process.env.TAILSCALE_GUI_PATH,
78
+ 'C:\\Program Files\\Tailscale\\tailscale-ipn.exe',
79
+ 'C:\\Program Files (x86)\\Tailscale\\tailscale-ipn.exe',
80
+ ],
81
+ darwin: [process.env.TAILSCALE_GUI_PATH, '/Applications/Tailscale.app'],
82
+ linux: [process.env.TAILSCALE_GUI_PATH],
83
+ }
84
+
85
+ /** 把行内注释里提到的"配置"收敛成带默认值的普通对象(本包刻意不依赖 schemastery)。 */
86
+ function normalizeConfig(raw) {
87
+ const source = raw !== null && typeof raw === 'object' ? raw : {}
88
+ const port = Number(source.httpsPort)
89
+ return {
90
+ enabled: source.enabled !== false,
91
+ httpsPort: Number.isInteger(port) && port > 0 && port <= 65535 ? port : 7259,
92
+ connectOnStart: source.connectOnStart !== false,
93
+ serveOnStart: source.serveOnStart !== false,
94
+ teardownOnExit: source.teardownOnExit !== false,
95
+ trustedHosts: source.trustedHosts !== false,
96
+ binaryPath: typeof source.binaryPath === 'string' && source.binaryPath !== '' ? source.binaryPath : undefined,
97
+ guiPath: typeof source.guiPath === 'string' && source.guiPath !== '' ? source.guiPath : undefined,
98
+ // 等 backend 就绪的两段预算:先等 `tailscale up`,再等桌面客户端。
99
+ readyTimeoutMs: Number.isFinite(source.readyTimeoutMs) && source.readyTimeoutMs >= 0 ? source.readyTimeoutMs : 8000,
100
+ guiReadyTimeoutMs:
101
+ Number.isFinite(source.guiReadyTimeoutMs) && source.guiReadyTimeoutMs >= 0 ? source.guiReadyTimeoutMs : 25000,
102
+ stateFile: typeof source.stateFile === 'string' && source.stateFile !== '' ? source.stateFile : undefined,
103
+ // 登录 cookie 的有效期(天)。DSH 默认 30 天,对「手机随时连」太短;
104
+ // 这里直接改 connection 上那个活的 maxAgeMilliseconds,让种下的 cookie 长期有效。
105
+ // 0 表示不动 DSH 的默认值。
106
+ cookieMaxAgeDays:
107
+ Number.isFinite(source.cookieMaxAgeDays) && source.cookieMaxAgeDays >= 0 ? source.cookieMaxAgeDays : 3650,
108
+ // 是否注册一个「稳定登录入口」路由(/remote/<持久密钥>),让手机不用去翻令牌。
109
+ loginRoute: source.loginRoute !== false,
110
+ }
111
+ }
112
+
113
+ /**
114
+ * 跑一条命令,收集 stdout/stderr,超时就杀掉。
115
+ * 永不 reject —— 调用方永远拿到一个结果对象。
116
+ */
117
+ function run(file, args, timeoutMs) {
118
+ return new Promise((resolve) => {
119
+ let child
120
+ try {
121
+ child = spawn(file, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
122
+ } catch (error) {
123
+ resolve({ ok: false, code: -1, stdout: '', stderr: String(error?.message ?? error) })
124
+ return
125
+ }
126
+ let stdout = ''
127
+ let stderr = ''
128
+ let settled = false
129
+ const finish = (result) => {
130
+ if (settled) return
131
+ settled = true
132
+ clearTimeout(timer)
133
+ resolve(result)
134
+ }
135
+ // 刻意不用 AbortSignal.timeout():它的定时器是 unref 的,关停阶段可能永远不触发。
136
+ const timer = setTimeout(() => {
137
+ try {
138
+ child.kill()
139
+ } catch {
140
+ /* 进程可能已经没了 */
141
+ }
142
+ finish({ ok: false, code: -1, stdout, stderr: `${stderr}\n[超时 ${String(timeoutMs)}ms]`.trim() })
143
+ }, timeoutMs)
144
+ child.stdout?.setEncoding('utf8')
145
+ child.stderr?.setEncoding('utf8')
146
+ child.stdout?.on('data', (chunk) => {
147
+ stdout += chunk
148
+ })
149
+ child.stderr?.on('data', (chunk) => {
150
+ stderr += chunk
151
+ })
152
+ child.on('error', (error) => finish({ ok: false, code: -1, stdout, stderr: String(error?.message ?? error) }))
153
+ child.on('close', (code) => finish({ ok: code === 0, code: code ?? -1, stdout, stderr }))
154
+ })
155
+ }
156
+
157
+ /** 探测可用的 tailscale 可执行文件;找不到返回 undefined。 */
158
+ async function resolveBinary(ctx, configured) {
159
+ const candidates = [...(BINARY_CANDIDATES[process.platform] ?? ['tailscale'])]
160
+ if (configured !== undefined) candidates.unshift(configured)
161
+ for (const candidate of candidates) {
162
+ if (candidate === undefined || candidate === '') continue
163
+ // 带路径分隔符的候选先做存在性检查,省一次进程启动。
164
+ if (/[\\/]/.test(candidate) && !existsSync(candidate)) continue
165
+ const result = await run(candidate, ['version'], 5000)
166
+ if (result.ok) return candidate
167
+ }
168
+ return undefined
169
+ }
170
+
171
+ /** 解析 `tailscale status --json`;失败返回 undefined。 */
172
+ async function readStatus(binary) {
173
+ const result = await run(binary, ['status', '--json'], 6000)
174
+ if (!result.ok) return undefined
175
+ try {
176
+ return JSON.parse(result.stdout)
177
+ } catch {
178
+ return undefined
179
+ }
180
+ }
181
+
182
+ /** 解析 `tailscale serve status --json`;失败返回 undefined(视为"无配置")。 */
183
+ async function readServeStatus(binary) {
184
+ const result = await run(binary, ['serve', 'status', '--json'], 6000)
185
+ if (result.ok && result.stdout.trim() !== '') {
186
+ try {
187
+ return JSON.parse(result.stdout)
188
+ } catch {
189
+ return undefined
190
+ }
191
+ }
192
+ // 没有 serve 配置时该命令可能以非零码退出,这里统一当作空配置。
193
+ return undefined
194
+ }
195
+
196
+ /** `Self.DNSName` 带结尾点,去掉它才是可用的主机名。 */
197
+ function dnsNameOf(status) {
198
+ const raw = status?.Self?.DNSName
199
+ if (typeof raw !== 'string' || raw === '') return undefined
200
+ return raw.replace(/\.$/, '').toLowerCase()
201
+ }
202
+
203
+ /** 探测桌面客户端路径;找不到返回 undefined。 */
204
+ function resolveGui(configured) {
205
+ const candidates = [...(GUI_CANDIDATES[process.platform] ?? [])]
206
+ if (typeof configured === 'string' && configured !== '') candidates.unshift(configured)
207
+ for (const candidate of candidates) {
208
+ if (typeof candidate !== 'string' || candidate === '') continue
209
+ if (existsSync(candidate)) return candidate
210
+ }
211
+ return undefined
212
+ }
213
+
214
+ /**
215
+ * 启动桌面客户端。**刻意 detached + unref**:它是常驻托盘程序,不能因为 DSH
216
+ * 退出就被带走(也不能让 DSH 因为它的 stdio 一直挂着不退出)。
217
+ * @returns 是否成功发起启动(不代表 backend 已就绪)。
218
+ */
219
+ function launchGui(target) {
220
+ return new Promise((resolve) => {
221
+ try {
222
+ const child = spawn(target, [], { detached: true, stdio: 'ignore', windowsHide: false })
223
+ child.on('error', () => resolve(false))
224
+ child.unref()
225
+ // 只要没有立刻报错就当作已发起;backend 是否起来由 waitForRunning 判定。
226
+ setTimeout(() => resolve(true), 300)
227
+ } catch {
228
+ resolve(false)
229
+ }
230
+ })
231
+ }
232
+
233
+ /** backend 是否已经 Running。 */
234
+ async function isRunning(deps, binary) {
235
+ const status = await deps.readStatus(binary)
236
+ return status?.BackendState === 'Running'
237
+ }
238
+
239
+ /** 轮询等待 backend 变 Running;超时返回 false(绝不抛)。 */
240
+ async function waitForRunning(deps, binary, timeoutMs) {
241
+ const deadline = Date.now() + timeoutMs
242
+ for (;;) {
243
+ if (await isRunning(deps, binary)) return true
244
+ if (Date.now() >= deadline) return false
245
+ await new Promise((resolve) => setTimeout(resolve, 1000))
246
+ }
247
+ }
248
+
249
+ /**
250
+ * 确保 tailscale backend 真的在 Running —— **光看 `tailscale up` 的退出码是不够的**。
251
+ *
252
+ * 顺序:已经 Running 就直接成功 → `tailscale up` → 还不 Running 就拉起桌面客户端。
253
+ * 每一步都用 `readStatus` 验证,而不是相信命令的退出码。
254
+ * @returns true 表示 backend 确实 Running(此时才值得去配 Serve 路由)。
255
+ */
256
+ async function ensureRunning(binary, config, deps, logger, undo) {
257
+ if (await isRunning(deps, binary)) {
258
+ logger.info('tailscale-remote: tailscale 本来就在运行,退出时不会把它断开')
259
+ return true
260
+ }
261
+ if (!config.connectOnStart) {
262
+ logger.warn('tailscale-remote: tailscale 未连接,且 connectOnStart=false,跳过')
263
+ return false
264
+ }
265
+
266
+ logger.info('tailscale-remote: tailscale 未连接,执行 tailscale up')
267
+ const up = await deps.run(binary, ['up'], SETUP_TIMEOUT_MS)
268
+ if (!up.ok) {
269
+ logger.warn(`tailscale-remote: tailscale up 失败: ${(up.stderr || up.stdout).trim().slice(0, 300)}`)
270
+ }
271
+ if (await waitForRunning(deps, binary, config.readyTimeoutMs)) {
272
+ undo.startedTailscale = true
273
+ return true
274
+ }
275
+
276
+ // Windows/macOS:`tailscale up` 在客户端被完全退出后会「exit 0 但什么都不做」,
277
+ // 必须把桌面客户端拉起来才会真正初始化 backend。
278
+ const gui = deps.resolveGui(config.guiPath)
279
+ if (gui === undefined) {
280
+ logger.warn(
281
+ 'tailscale-remote: tailscale up 没有生效(backend 仍不是 Running)。' +
282
+ '请手动打开 Tailscale 客户端登录/连接,或设置 config.guiPath 指向它',
283
+ )
284
+ return false
285
+ }
286
+ logger.info(`tailscale-remote: tailscale up 未生效,改为启动桌面客户端 ${gui}`)
287
+ const launched = await deps.launchGui(gui)
288
+ if (!launched) {
289
+ logger.warn(`tailscale-remote: 启动 ${gui} 失败`)
290
+ return false
291
+ }
292
+ if (await waitForRunning(deps, binary, config.guiReadyTimeoutMs)) {
293
+ logger.info('tailscale-remote: tailscale 已由桌面客户端拉起')
294
+ undo.startedTailscale = true
295
+ return true
296
+ }
297
+ logger.warn('tailscale-remote: 等不到 tailscale 进入 Running;请检查客户端是否需要登录')
298
+ return false
299
+ }
300
+
301
+ /**
302
+ * 判定 7259 端口上现有的 serve 路由与我们的目标是什么关系。
303
+ * 只有"空着"或"本来就是指向我们的回环代理"才动手;其余一律视为冲突,不覆盖。
304
+ */
305
+ function classifyServe(status, port, target) {
306
+ const web = status?.Web
307
+ const tcp = status?.TCP
308
+ const authority = Object.keys(web ?? {}).find((key) => {
309
+ try {
310
+ return Number(new URL(`https://${key}`).port) === port
311
+ } catch {
312
+ return false
313
+ }
314
+ })
315
+ if (authority === undefined) {
316
+ const claimed = tcp !== undefined && Object.prototype.hasOwnProperty.call(tcp, String(port))
317
+ if (claimed) return { kind: 'conflict', reason: `TCP ${String(port)} 已被其它 Tailscale Serve 配置占用` }
318
+ return { kind: 'absent' }
319
+ }
320
+ const handlers = web?.[authority]?.Handlers
321
+ if (handlers === null || typeof handlers !== 'object') {
322
+ return { kind: 'conflict', reason: `${authority} 的 Serve 处理程序无法识别` }
323
+ }
324
+ const paths = Object.keys(handlers)
325
+ if (paths.length !== 1 || paths[0] !== '/') {
326
+ return { kind: 'conflict', reason: `${authority} 上还有其它路径处理程序(${paths.join(', ')})` }
327
+ }
328
+ const proxy = handlers['/']?.Proxy
329
+ if (typeof proxy !== 'string') return { kind: 'conflict', reason: `${authority} 的根处理程序不是反向代理` }
330
+ // tailscale 写回的形态可能带尾斜杠;归一化后比较,否则会把自己的路由误判成陈旧路由,
331
+ // 进而把它记成"本次新建",退出时撤掉一条其实不是自己建的路由。
332
+ const normalized = proxy.replace(/\/+$/, '')
333
+ if (normalized === target) return { kind: 'exact', authority }
334
+ // 指向另一个回环地址 = 上一次 DSH 换端口后留下的陈旧路由,可以接管。
335
+ if (/^https?:\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?::\d+)?$/i.test(normalized)) {
336
+ return { kind: 'stale', authority, previousProxy: proxy }
337
+ }
338
+ return { kind: 'conflict', reason: `${authority} 已代理到 ${proxy}` }
339
+ }
340
+
341
+ /**
342
+ * 解析 DSH home,与 `@deepseek-ai/dsh-home-paths` 的 `resolveDshHome` 同一套优先级:
343
+ * 显式配置 > `$DSH_HOME`(空/纯空白视为未设) > `~/.dsh`。
344
+ *
345
+ * 注意:**Host 进程的环境里通常没有 `DSH_HOME`**(那个变量是 shell 调用时才注入的),
346
+ * 所以只读 `process.env.DSH_HOME` 会让状态文件永远写不出来。这里必须回落到 `~/.dsh`。
347
+ */
348
+ function resolveDshHome(configured) {
349
+ const fromConfig = typeof configured === 'string' && configured.trim() !== '' ? configured : undefined
350
+ if (fromConfig !== undefined) return fromConfig
351
+ const fromEnv = process.env.DSH_HOME
352
+ if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return fromEnv
353
+ return join(homedir(), '.dsh')
354
+ }
355
+
356
+ /** 把当前远程地址写一份到磁盘,方便用户随时取用;失败只记日志。 */
357
+ async function persistState(ctx, state) {
358
+ const home = resolveDshHome(process.env.DSH_HOME)
359
+ const path = state.stateFile ?? join(home, 'tailscale-remote.json')
360
+ try {
361
+ await mkdir(dirname(path), { recursive: true })
362
+ await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
363
+ } catch (error) {
364
+ ctx.logger.warn(`tailscale-remote: 写入 ${path} 失败:${String(error?.message ?? error)}`)
365
+ }
366
+ }
367
+
368
+ /** 读回上一次的状态文件;不存在或坏了都返回 undefined(绝不抛)。 */
369
+ async function loadState(scope, config) {
370
+ const home = resolveDshHome(process.env.DSH_HOME)
371
+ const path = config.stateFile ?? join(home, 'tailscale-remote.json')
372
+ try {
373
+ const raw = await readFile(path, 'utf8')
374
+ const parsed = JSON.parse(raw)
375
+ return parsed !== null && typeof parsed === 'object' ? parsed : undefined
376
+ } catch {
377
+ return undefined
378
+ }
379
+ }
380
+
381
+ /**
382
+ * 取得(或首次生成)**跨重启稳定**的登录密钥。
383
+ *
384
+ * 为什么要它:DSH 的登录令牌是**进程级**的(`PROCESS_LAUNCH_TOKENS` 以 root ctx 为键),
385
+ * 每次重启 DSH 都会换,用户根本没法记、也没法存成书签。这里自己生成一个持久随机密钥,
386
+ * 存进状态文件,让「手机上的登录链接」永远不变。
387
+ */
388
+ async function ensureLoginSecret(scope, config, deps) {
389
+ const previous = await deps.loadState(scope, config)
390
+ const existing = previous?.loginSecret
391
+ if (typeof existing === 'string' && /^[A-Za-z0-9_-]{22,}$/.test(existing)) return existing
392
+ return randomBytes(24).toString('base64url')
393
+ }
394
+
395
+ /** DSH 的浏览器会话 cookie 前缀(与 client-connection 的 `COOKIE_PREFIX` 一致)。 */
396
+ const COOKIE_PREFIX = 'dsh-auth-'
397
+
398
+ /** base64url 编码(与 DSH 内部实现逐字一致:+→-、/→_、去掉尾部 =)。 */
399
+ function base64url(buffer) {
400
+ return buffer.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, '')
401
+ }
402
+
403
+ /**
404
+ * 直接**铸造** DSH 的浏览器会话 cookie。
405
+ *
406
+ * 为什么要自己铸:DSH 正常流程是「带 `?token=` 访问 → 303 + Set-Cookie → 跳回 `/`」。
407
+ * 这条链在手机浏览器上很脆(跨站跳转 + `SameSite=Strict`:从别的 App 点进来时,
408
+ * 后续请求可能不带 cookie,于是又看到那句未授权提示)。自己铸就只要一跳:
409
+ * `/remote/<密钥>` → 302 到 `/` **同时把 cookie 种下去**,而且可以用 `SameSite=Lax`。
410
+ *
411
+ * 格式必须和 `client-connection` 完全一致,否则 `isAuthenticated` 验不过:
412
+ * cookie 名 = `dsh-auth-` + base64url(sha256(authority))
413
+ * 值 = `v1.` + base64url(JSON) + `.` + base64url(HMAC-SHA256(secret, base64url(JSON)))
414
+ * JSON = { version: 1, authority, issuedAt, expiresAt }
415
+ * @returns 铸好的 cookie;拿不到签名密钥时返回 undefined(调用方回落到令牌跳转)。
416
+ */
417
+ function mintSessionCookie(connection, authority, maxAgeMilliseconds) {
418
+ const secret = connection?.browserAuth?.secret
419
+ if (!Buffer.isBuffer(secret) || secret.length === 0) return undefined
420
+ const issuedAt = Date.now()
421
+ const expiresAt = issuedAt + maxAgeMilliseconds
422
+ if (!Number.isSafeInteger(expiresAt)) return undefined
423
+ const body = base64url(Buffer.from(JSON.stringify({ version: 1, authority, issuedAt, expiresAt }), 'utf8'))
424
+ const signature = base64url(createHmac('sha256', secret).update(body).digest())
425
+ return {
426
+ name: COOKIE_PREFIX + base64url(createHash('sha256').update(authority).digest()),
427
+ value: `v1.${body}.${signature}`,
428
+ maxAgeSeconds: Math.floor(maxAgeMilliseconds / 1000),
429
+ expiresAt,
430
+ }
431
+ }
432
+
433
+ /** 把铸好的 cookie 序列化成 `Set-Cookie` 头值。`SameSite=Lax` 是刻意的(见上)。 */
434
+ function sessionCookieHeader(minted) {
435
+ return `${minted.name}=${minted.value}; Max-Age=${String(minted.maxAgeSeconds)}; Path=/; Expires=${new Date(minted.expiresAt).toUTCString()}; HttpOnly; SameSite=Lax`
436
+ }
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
+
660
+ /**
661
+ * 插件入口。
662
+ * @param {import('@deepseek-ai/cordis').Context} ctx - 插件上下文。
663
+ * @param {unknown} rawConfig - 该行声明的 config。
664
+ */
665
+ export function apply(ctx, rawConfig) {
666
+ const config = normalizeConfig(rawConfig)
667
+
668
+ // connection 是可选依赖:服务晚到也会触发,永不出现则保持静默。
669
+ // 服务只从 scope 参数上取(inject 新建的 fiber 的 store 里才有它)。
670
+ ctx.inject(['connection'], (scope) => {
671
+ if (scope.get('connection') === undefined) return
672
+
673
+ /** 本次运行真正做过的副作用,退出时按这个清单回滚。 */
674
+ const undo = {
675
+ /** 我们 push 进 trustedHosts 的那一项。 */
676
+ trustedHost: undefined,
677
+ /** 7259 路由是不是我们建的(本来就在就留着,不动)。 */
678
+ createdRoute: false,
679
+ /** tailscale 是不是我们拉起来的(本来 Running 就不 down)。 */
680
+ startedTailscale: false,
681
+ /** 启动阶段探测到的可执行文件,退出时直接复用——关停预算里再探一次就超时了。 */
682
+ binary: undefined,
683
+ /** 注册的登录入口路径(fiber 拆除时路由会跟着注销,这里只作记录)。 */
684
+ loginPath: undefined,
685
+ disposed: false,
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)
699
+
700
+ // 退出清理挂在这里:这是唯一可靠的关停挂点,且 disposer 绝不能 reject。
701
+ scope.effect(() => async () => {
702
+ if (undo.disposed) return
703
+ undo.disposed = true
704
+ if (!config.teardownOnExit) return
705
+ // 关停预算很紧:全部吞掉异常,绝不让 disposer reject(会变成 AggregateError)。
706
+ await Promise.allSettled([teardown(scope, config, undo)])
707
+ }, 'tailscale-remote: teardown')
708
+
709
+ // 启动流程异步跑,不阻塞 Loader 装配。
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}`)
714
+ })
715
+ })
716
+ }
717
+
718
+ /** 真实依赖;测试通过 `deps` 参数替换它们,生产路径永远用这一份。 */
719
+ const REAL_DEPS = {
720
+ run,
721
+ resolveBinary,
722
+ readStatus,
723
+ readServeStatus,
724
+ waitForPort,
725
+ persistState,
726
+ loadState,
727
+ ensureLoginSecret,
728
+ announce,
729
+ launchGui,
730
+ resolveGui,
731
+ }
732
+
733
+ /** 启动:拉起 tailscale → 建 serve 路由 → 信任该 authority → 打印手机可用的链接。 */
734
+ async function setup(scope, config, undo, deps = REAL_DEPS, runtime = freshRuntime(undo)) {
735
+ const logger = scope.logger
736
+ runtime.undo = undo
737
+ runtime.phase = 'starting'
738
+ runtime.error = undefined
739
+ const binary = await deps.resolveBinary(scope, config.binaryPath)
740
+ if (binary === undefined) {
741
+ runtime.phase = 'unavailable'
742
+ runtime.error = '找不到 tailscale 可执行文件'
743
+ logger.warn('tailscale-remote: 找不到 tailscale 可执行文件;请安装 Tailscale 或用 config.binaryPath 指定路径')
744
+ return
745
+ }
746
+ // 记下来给退出阶段复用:关停时只有几秒预算,不能再跑一轮 `tailscale version` 探测。
747
+ undo.binary = binary
748
+
749
+ let status = await deps.readStatus(binary)
750
+ const wasRunning = status?.BackendState === 'Running'
751
+
752
+ // 关键:必须验证 backend 真的 Running,不能只信 `tailscale up` 的退出码
753
+ // (Windows 上客户端被完全退出时,up 会 exit 0 但什么都不做)。
754
+ const running = await ensureRunning(binary, config, deps, logger, undo)
755
+ if (!running) {
756
+ runtime.phase = 'unavailable'
757
+ runtime.error = 'tailscale 没在运行'
758
+ return
759
+ }
760
+ if (!wasRunning) status = await deps.readStatus(binary)
761
+
762
+ const dnsName = dnsNameOf(status)
763
+ if (dnsName === undefined) {
764
+ runtime.phase = 'unavailable'
765
+ runtime.error = '读不到 tailnet DNS 名称'
766
+ logger.warn('tailscale-remote: 读不到 tailnet DNS 名称,跳过 Serve 配置')
767
+ return
768
+ }
769
+
770
+ // DSH 自己的 Web 端口从服务上读,绝不写死。
771
+ const port = await deps.waitForPort(scope)
772
+ if (port === undefined) {
773
+ runtime.phase = 'unavailable'
774
+ runtime.error = '等不到 DSH Web 端口'
775
+ logger.warn('tailscale-remote: 等不到 DSH Web 端口,跳过 Serve 配置')
776
+ return
777
+ }
778
+
779
+ const target = `http://127.0.0.1:${String(port)}`
780
+ const authority = `${dnsName}:${String(config.httpsPort)}`
781
+
782
+ if (config.serveOnStart) {
783
+ const serveStatus = await deps.readServeStatus(binary)
784
+ const state = classifyServe(serveStatus, config.httpsPort, target)
785
+ if (state.kind === 'conflict') {
786
+ runtime.phase = 'error'
787
+ runtime.error = `${String(config.httpsPort)} 端口上有冲突的 Serve 配置(${state.reason})`
788
+ logger.warn(`tailscale-remote: ${String(config.httpsPort)} 端口上有冲突的 Serve 配置(${state.reason}),不覆盖`)
789
+ return
790
+ }
791
+ if (state.kind === 'exact') {
792
+ // 已经有一条精确指向本实例 DSH 端口的路由。它只可能是上一次运行留下的
793
+ // (正常退出会撤掉,所以这种情况基本等于「上次 DSH 是崩溃/强杀结束的」)。
794
+ // 既然目标就是我们自己,就把它**认领下来**,退出时一并撤掉 —— 否则用户的
795
+ // 「关 DSH 就关掉」在崩溃恢复后会永远失效,路由会一直留在那儿。
796
+ logger.info(`tailscale-remote: ${authority} 已在代理 ${target},接管它(退出时会撤掉)`)
797
+ undo.createdRoute = true
798
+ } else {
799
+ if (state.kind === 'stale') {
800
+ logger.info(`tailscale-remote: 接管陈旧路由 ${state.authority}(原代理 ${state.previousProxy})`)
801
+ }
802
+ const serve = await deps.run(binary, ['serve', '--bg', `--https=${String(config.httpsPort)}`, target], SETUP_TIMEOUT_MS)
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
807
+ logger.warn(
808
+ `tailscale-remote: 配置 Serve 失败: ${detail};` +
809
+ '若提示 HTTPS 证书,请先在 Tailscale 管理台为该 tailnet 启用 HTTPS 证书',
810
+ )
811
+ return
812
+ }
813
+ undo.createdRoute = true
814
+ }
815
+ }
816
+
817
+ // 关键一步:让 DSH 的 /api fence 接受这个 authority,否则手机上前端能开、RPC 全 403。
818
+ if (config.trustedHosts) {
819
+ const trusted = connectionTrustedHosts(scope)
820
+ if (trusted === undefined) {
821
+ logger.warn('tailscale-remote: 读不到 connection.trustedHosts,远程 RPC 会被 403 拒绝')
822
+ } else if (!trusted.includes(authority)) {
823
+ trusted.push(authority)
824
+ undo.trustedHost = authority
825
+ logger.info(`tailscale-remote: 已把 ${authority} 加入 /api 信任列表`)
826
+ }
827
+ }
828
+
829
+ const plainUrl = `https://${authority}/`
830
+ const connection = scope.get('connection')
831
+
832
+ // 把登录 cookie 的有效期拉长。DSH 默认 30 天,对「手机随时连」太短。
833
+ // `connection.browserAuth.maxAgeMilliseconds` 是**活的**字段:authorizeIndex 种 cookie 时
834
+ // 用它算 expiresAt / Max-Age,isAuthenticated 校验时也用它,所以在这里改立刻就生效。
835
+ if (config.cookieMaxAgeDays > 0) {
836
+ const maxAgeMilliseconds = config.cookieMaxAgeDays * 86400000
837
+ const auth = connection?.browserAuth
838
+ if (auth !== undefined && Number.isSafeInteger(Date.now() + maxAgeMilliseconds)) {
839
+ auth.maxAgeMilliseconds = maxAgeMilliseconds
840
+ logger.info(`tailscale-remote: 登录 cookie 有效期设为 ${String(config.cookieMaxAgeDays)} 天`)
841
+ } else {
842
+ logger.warn('tailscale-remote: 读不到 connection.browserAuth,cookie 有效期沿用 DSH 默认值')
843
+ }
844
+ }
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
+
855
+ // 稳定的登录入口:/remote/<持久密钥> → 302 到带令牌的地址。
856
+ // 令牌每次都变、用户没法存书签;这条路径的密钥写在状态文件里,**跨重启不变**,
857
+ // 所以手机上存一次就够了。密钥同时充当访问门槛(不知道密钥的人拿不到会话)。
858
+ let loginUrl = plainUrl
859
+ let loginPath
860
+ if (config.loginRoute) {
861
+ const secret = await deps.ensureLoginSecret(scope, config, deps)
862
+ // 路径在这里**同步**算出来:`scope.inject` 的服务晚到时回调是异步的,
863
+ // 若等回调再算,webServer 迟到就会让状态文件里丢掉 loginUrl。
864
+ loginPath = `/remote/${secret}`
865
+ loginUrl = `https://${authority}${loginPath}`
866
+ undo.loginPath = loginPath
867
+ // 登录入口的跳转目标就是上面这个带令牌的地址;注册在 DSH 自己的 webserver 上
868
+ // (所以不需要第二个端口、也不需要额外的 tailscale serve 路由)。
869
+ // 嵌套 inject:webServer 缺席时只丢登录入口,tailscale / 信任项照常工作。
870
+ scope.inject(['webServer'], (webScope) => {
871
+ runtime.webScope = webScope
872
+ installLoginRoute(runtime, connection, config, authority, secret)
873
+ })
874
+ }
875
+
876
+ const loginSecret = loginPath === undefined ? undefined : loginPath.slice('/remote/'.length)
877
+ const snapshot = {
878
+ url: handoffUrl,
879
+ loginUrl,
880
+ loginPath,
881
+ loginSecret,
882
+ plainUrl,
883
+ authority,
884
+ dnsName,
885
+ httpsPort: config.httpsPort,
886
+ target,
887
+ cookieMaxAgeDays: config.cookieMaxAgeDays,
888
+ updatedAt: new Date().toISOString(),
889
+ stateFile: config.stateFile,
890
+ }
891
+ await deps.persistState(scope, snapshot)
892
+ runtime.state = snapshot
893
+ runtime.phase = 'ready'
894
+ runtime.error = undefined
895
+
896
+ logger.info(`tailscale-remote: 手机可用地址 ${plainUrl}`)
897
+ if (loginUrl !== plainUrl) {
898
+ logger.info(`tailscale-remote: 手机登录入口(存一次书签即可,跨重启不变)${loginUrl}`)
899
+ }
900
+ logger.info(`tailscale-remote: 一次性令牌链接(每次重启都会变)${handoffUrl}`)
901
+ // 也打到 stdout:桌面版的日志面板里能直接看到。
902
+ deps.announce?.(plainUrl, loginUrl, handoffUrl)
903
+ }
904
+
905
+ /** 默认的 announce:写 stdout,桌面版日志面板可见。 */
906
+ function announce(plainUrl, loginUrl, handoffUrl) {
907
+ console.log(`dsh tailscale-remote: ${plainUrl}`)
908
+ if (loginUrl !== plainUrl) console.log(`dsh tailscale-remote (手机登录入口): ${loginUrl}`)
909
+ console.log(`dsh tailscale-remote (一次性令牌): ${handoffUrl}`)
910
+ }
911
+
912
+ /**
913
+ * 等 `webServer.port` 就绪。
914
+ * WebServer 在构造函数里就 provide 了服务,但端口要等 `[Service.init]` 的异步 listen
915
+ * 完成才赋值;插件激活时机与它没有顺序保证,所以这里轮询而不是一次性读。
916
+ */
917
+ async function waitForPort(scope, timeoutMs = 8000) {
918
+ const deadline = Date.now() + timeoutMs
919
+ for (;;) {
920
+ const port = scope.get('webServer')?.port
921
+ if (Number.isInteger(port) && port > 0) return port
922
+ if (Date.now() >= deadline) return undefined
923
+ await new Promise((resolve) => setTimeout(resolve, 100))
924
+ }
925
+ }
926
+
927
+ /** 从 connection 服务上取那个可变的 trustedHosts 数组。 */
928
+ function connectionTrustedHosts(scope, strict = true) {
929
+ const connection = scope.get('connection', strict)
930
+ const trusted = connection?.trustedHosts
931
+ return Array.isArray(trusted) ? trusted : undefined
932
+ }
933
+
934
+ /** 退出:按 undo 清单回滚。幂等、并发安全、绝不抛。 */
935
+ async function teardown(scope, config, undo, deps = REAL_DEPS) {
936
+ const logger = scope.logger
937
+ try {
938
+ if (undo.trustedHost !== undefined) {
939
+ // strict=false:关停时 connection 的 fiber 可能已经不再 active,但仍要清掉那一项。
940
+ const trusted = connectionTrustedHosts(scope, false)
941
+ if (trusted !== undefined) {
942
+ const at = trusted.indexOf(undo.trustedHost)
943
+ if (at !== -1) trusted.splice(at, 1)
944
+ }
945
+ undo.trustedHost = undefined
946
+ }
947
+ } catch {
948
+ /* 上下文可能已经在拆除中 */
949
+ }
950
+
951
+ if (!undo.createdRoute && !undo.startedTailscale) return
952
+
953
+ const binary = undo.binary ?? (await deps.resolveBinary(scope, config.binaryPath))
954
+ if (binary === undefined) return
955
+
956
+ // 两条清理各自独立:Serve 路由没建起来不代表 tailscale 不是我们拉起的。
957
+ if (undo.createdRoute) {
958
+ undo.createdRoute = false
959
+ const off = await deps.run(binary, ['serve', `--https=${String(config.httpsPort)}`, 'off'], TEARDOWN_TIMEOUT_MS)
960
+ if (off.ok) logger.info(`tailscale-remote: 已撤销 ${String(config.httpsPort)} 端口上的 Serve 路由`)
961
+ else logger.warn(`tailscale-remote: 撤销 Serve 路由失败: ${(off.stderr || off.stdout).trim().slice(0, 200)}`)
962
+ }
963
+
964
+ if (undo.startedTailscale) {
965
+ undo.startedTailscale = false
966
+ const down = await deps.run(binary, ['down'], TEARDOWN_TIMEOUT_MS)
967
+ if (down.ok) logger.info('tailscale-remote: 已断开 tailscale(本次由 DSH 拉起)')
968
+ else logger.warn(`tailscale-remote: tailscale down 失败: ${(down.stderr || down.stdout).trim().slice(0, 200)}`)
969
+ }
970
+ }
971
+
972
+ /**
973
+ * 纯函数与底层 runner 的测试入口。
974
+ * 生产路径不读这个对象;它只为单元测试而存在(决定"要不要覆盖已有 Serve 配置"的
975
+ * `classifyServe` 是这里风险最高的一段逻辑,必须有牙齿地测)。
976
+ */
977
+ export const internals = {
978
+ normalizeConfig,
979
+ classifyServe,
980
+ dnsNameOf,
981
+ resolveDshHome,
982
+ resolveGui,
983
+ run,
984
+ resolveBinary,
985
+ readServeStatus,
986
+ readStatus,
987
+ waitForPort,
988
+ waitForRunning,
989
+ ensureRunning,
990
+ ensureLoginSecret,
991
+ mintSessionCookie,
992
+ sessionCookieHeader,
993
+ statePayload,
994
+ installLoginRoute,
995
+ rotateLoginSecret,
996
+ registerAdminRoutes,
997
+ freshRuntime,
998
+ setup,
999
+ teardown,
1000
+ REAL_DEPS,
1001
+ STATE_PATH,
1002
+ ROTATE_PATH,
1003
+ }