dsh-tailscale-remote 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ckarefulon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,190 @@
1
- # Temporary Holding Version
1
+ # dsh-tailscale-remote
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 打开 DSH 就把本机 Web GUI 通过 **Tailscale Serve** 暴露到你的 tailnet 上,手机在任何网络下
4
+ 打开一个网址就能用;DSH 退出时把这条路由撤掉。
5
+
6
+ > 给 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)用的插件。
7
+ > 只需要「随时随地用手机连上自己电脑上的 DSH」这一件事,不引入任何第三方中转。
8
+
9
+ ## 先看这个:手机怎么进
10
+
11
+ DSH 的 Web GUI 要求一次**登录令牌**,没令牌只显示一句
12
+ `dsh web authentication required; ...`。DSH Desktop 又**从不打印**那行链接,令牌还每次重启都换 ——
13
+ 所以插件给你一条**存一次就永久用**的入口:
14
+
15
+ ```sh
16
+ # 装好并重启 DSH 后,执行(Windows 用 PowerShell 见下)
17
+ cat ~/.dsh/tailscale-remote.json
18
+ ```
19
+
20
+ 读里面 **`loginUrl`** 那个值,形如:
21
+
22
+ ```
23
+ https://<你的机器>.<tailnet>.ts.net:7259/remote/<持久密钥>
24
+ ```
25
+
26
+ **在手机浏览器打开它一次**(会自动跳转并登录),然后**存成书签/主屏幕图标** ——
27
+ 这条链接跨重启不变,以后点一下就进。
28
+
29
+ Windows PowerShell 读法:
30
+
31
+ ```powershell
32
+ Get-Content "$env:USERPROFILE\.dsh\tailscale-remote.json"
33
+ ```
34
+
35
+ 就这么两步:**读 `loginUrl` → 手机打开一次并存书签**。下面都是细节。
36
+
37
+ ## 安装
38
+
39
+ ```sh
40
+ dsh plugin --profile <你的 profile> add dsh-tailscale-remote
41
+ ```
42
+
43
+ 装完**重启 DSH**(新包需要重启才会加载)。之后每次开 DSH 它都会自己跑起来。
44
+
45
+ 也可以从本地目录装:
46
+
47
+ ```sh
48
+ dsh plugin --profile <profile> add -w /path/to/dsh-tailscale-remote
49
+ ```
50
+
51
+ ## 它做什么
52
+
53
+ 启动时:
54
+
55
+ 1. 确认 `tailscale` 真的在跑(**用 `tailscale status --json` 验证 `BackendState`,
56
+ 不是相信 `tailscale up` 的退出码** —— 见下文)。
57
+ 2. 把 `https://<你的机器>.<tailnet>.ts.net:7259` 代理到 DSH 自己的 Web 端口
58
+ (端口从 `webServer` 服务上读,不写死)。
59
+ 3. 把 `<你的机器>.<tailnet>.ts.net:7259` 加进 DSH `/api` 的 Host 信任列表。
60
+ 4. 打印手机可用的网址,并写一份到 `$DSH_HOME/tailscale-remote.json`。
61
+
62
+ 退出时:只回滚**本次运行自己做过的事**——撤掉 7259 的路由、移除信任项;只有 tailscale
63
+ 本来就是关着、由 DSH 拉起来的情况下才 `tailscale down`。
64
+
65
+ > 例外:如果 7259 上已经有一条**精确指向本实例 DSH 端口**的路由(几乎只可能是上次
66
+ > DSH 崩溃/被强杀留下的),插件会把它**认领**下来,退出时一并撤掉 —— 否则崩溃恢复
67
+ > 之后「关 DSH 就关掉」会永远失效,路由一直留在那儿。
68
+
69
+ ## 为什么必须动信任列表
70
+
71
+ `tailscale serve` **保留客户端的 Host 头**(实测:从 tailnet 地址访问时,后端收到的
72
+ `host` 就是 `xxx.ts.net:7259`)。而 DSH 的 `/api` fence 要求 Host 是回环地址或命中
73
+ `trustedHosts`,否则一律 403 —— 表现为**手机上页面能打开、但一发消息就报错**。
74
+ 插件在运行时把该 authority 推进 `connection.trustedHosts`(这是个可变数组,fence 每次
75
+ 请求都读它),所以不需要改 profile 的 `cordis.yml`。
76
+
77
+ ## 手机上怎么用(重点:怎么拿到登录链接)
78
+
79
+ DSH 的 Web GUI 默认要求一次**登录令牌**才让你进去,没有令牌就只显示一句话:
80
+
81
+ ```
82
+ dsh web authentication required; reopen the URL printed by dsh web.
83
+ ```
84
+
85
+ 麻烦的地方在于:**DSH Desktop 从不打印这行链接**(它走 IPC,不走 `dsh web`),而令牌又是
86
+ **进程级**的、每次重启都换。所以本插件做了两件事,让你**只需要拿一次链接**:
87
+
88
+ ### ① 稳定登录入口(推荐,存一次书签就够)
89
+
90
+ 插件会在 DSH 自己的 webserver 上注册一条路由:
91
+
92
+ ```
93
+ https://<你的机器>.<tailnet>.ts.net:7259/remote/<持久密钥>
94
+ ```
95
+
96
+ 打开它就会 **302 跳转**到带令牌的地址、种下 cookie,然后你就进 GUI 了。
97
+
98
+ 这条链接的密钥存在状态文件里,**跨重启不变** —— 所以手机上存一次书签,以后点一下就进。
99
+
100
+ > 密钥本身就是访问门槛:不知道它的人拿不到会话(和 DSH 自己的令牌同一级别)。
101
+ > 不需要的话可以设 `loginRoute: false` 关掉。
102
+
103
+ ### ② 状态文件(永远可以查到)
104
+
105
+ 任何一次启动后,下面这个文件里都有**当前**可用的链接:
106
+
107
+ ```sh
108
+ # 三个字段:loginUrl(稳定入口)、url(一次性令牌链接)、plainUrl(干净地址)
109
+ cat ~/.dsh/tailscale-remote.json
110
+ ```
111
+
112
+ Windows PowerShell:
113
+
114
+ ```powershell
115
+ Get-Content "$env:USERPROFILE\.dsh\tailscale-remote.json"
116
+ ```
117
+
118
+ 拿 `loginUrl` 那个值,在手机上打开即可。它同时也会打到 DSH 的日志里。
119
+
120
+ ### 登录之后
121
+
122
+ cookie 种下后,直接访问干净地址 `https://<机器>.<tailnet>.ts.net:7259/` 就能进,
123
+ 可以「添加到主屏幕」当 App 用。
124
+
125
+ **cookie 有效期默认 10 年**(见下面的 `cookieMaxAgeDays`)—— DSH 原版是 30 天,对
126
+ 「手机随时连」太短,插件启动时会把 `connection` 上那个活的 `maxAgeMilliseconds`
127
+ 直接改长。所以正常情况下你**只需要登录一次**。
128
+
129
+ > 手机上好不好用:**实测可用**。DSH 0.2.0-rc.2 的 Web 前端确实**没有专门的移动端外壳**
130
+ > (JS 无 `matchMedia`,CSS 里只有 `prefers-reduced-motion`、没有宽度断点),但它的三栏是
131
+ > 弹性布局,在手机宽度下会自然收窄,日常查看会话、发消息、看结果都没问题,不会挤成一条。
132
+ > 想要更像 App 的体验(抽屉侧栏、底部导航)需要另外写客户端插件。
133
+
134
+ ## 前置条件
135
+
136
+ - 本机已安装并登录 Tailscale。
137
+ - 该 tailnet 已启用 HTTPS 证书(Tailscale 管理台 → DNS → HTTPS Certificates)。
138
+ 没启用时 `tailscale serve` 会失败,日志里会提示。
139
+
140
+ ## 配置
141
+
142
+ | 字段 | 默认 | 说明 |
143
+ | --- | --- | --- |
144
+ | `enabled` | `true` | 关掉则插件完全不动作。 |
145
+ | `httpsPort` | `7259` | tailnet 上的 HTTPS 端口。 |
146
+ | `connectOnStart` | `true` | tailscale 没在跑时是否把它拉起来。 |
147
+ | `serveOnStart` | `true` | 是否建立 Serve 路由。 |
148
+ | `teardownOnExit` | `true` | 退出时是否回滚。 |
149
+ | `trustedHosts` | `true` | 是否把该 authority 加进 `/api` 信任列表。 |
150
+ | `loginRoute` | `true` | 是否注册 `/remote/<持久密钥>` 这个稳定登录入口。 |
151
+ | `cookieMaxAgeDays` | `3650` | 登录 cookie 有效期(天)。`0` = 不插手 DSH 的 30 天默认值。 |
152
+ | `binaryPath` | 自动探测 | `tailscale` CLI 可执行文件路径。 |
153
+ | `guiPath` | 自动探测 | Tailscale **桌面客户端**路径(Windows 默认 `tailscale-ipn.exe`)。 |
154
+ | `readyTimeoutMs` | `8000` | 等 `tailscale up` 生效的预算。 |
155
+ | `guiReadyTimeoutMs` | `25000` | 启动桌面客户端后等 backend 就绪的预算。 |
156
+ | `stateFile` | `$DSH_HOME/tailscale-remote.json` | 状态文件位置。 |
157
+
158
+ ### 关于「永久不过期」
159
+
160
+ DSH 把 cookie 有效期上限写死在 `connection` 的 `cookieMaxAgeDays` 里,并且校验时要求
161
+ `expiresAt - issuedAt <= maxAgeMilliseconds` —— 所以**自己造一个超长 cookie 是无效的**,
162
+ 必须改这个上限。本插件启动时会直接改 `connection.browserAuth.maxAgeMilliseconds`,
163
+ 默认给 10 年(`3650` 天),够用且不会溢出 JS 安全整数范围。
164
+
165
+ 想更长就把它调大(比如 `36500` = 100 年);`0` 表示完全不动 DSH 的默认 30 天。
166
+
167
+ ## 为什么不能只调 `tailscale up`
168
+
169
+ 在 Windows 上,如果你从托盘把 Tailscale **完全退出**,tailscaled 服务还在跑,但 backend
170
+ 处于 `NoState`(`tailscale status` 报 `Tailscale is starting. Please wait.`)。此时
171
+ **`tailscale up` 会「立刻 exit 0、不输出任何东西、也什么都不做」**(实测),backend 永远
172
+ 不会变成 `Running` —— 只看退出码就会误判成功,然后因为读不到 DNS 名而静默放弃。
173
+
174
+ 所以插件**每一步都用 `tailscale status --json` 验证 `BackendState`,而不是相信退出码**:
175
+ `tailscale up` 之后 backend 还不是 `Running`,就改为启动**桌面客户端**
176
+ (`tailscale-ipn.exe`),它才会真正初始化 backend(实测启动后立刻 `Running` + `Online`)。
177
+ 桌面客户端以 detached 方式启动,不会随 DSH 退出而被带走。
178
+
179
+ 如果连客户端都拉不起来(例如需要交互式登录),插件会留下明确提示让你手动打开 Tailscale,
180
+ 并且**不会**谎报成功去配一条连不上的路由。
181
+
182
+ ## 安全边界
183
+
184
+ 这条路由**只对你的 tailnet 可见**(Tailscale Serve 的默认范围,不是 Funnel),也就是说
185
+ 只有登录到你这个 tailnet 的设备能连上。DSH 自己的浏览器鉴权(30 天签名 cookie)仍然
186
+ 生效,两层是叠加的。
187
+
188
+ 插件**不会覆盖** 7259 上已有的、指向别处的 Serve 配置:检测到冲突就只记日志、不动手。
189
+ 唯一会接管的情况是发现一条指向**另一个回环端口**的陈旧路由(通常是上次 DSH 换了端口
190
+ 留下的)。
@@ -0,0 +1,9 @@
1
+ # Standalone bundle layer: one Host-only row that owns the Tailscale Serve
2
+ # route in front of this Harness's own Web server. It is inert unless its
3
+ # `enabled` config is true, and it never touches the Web bundle's rows.
4
+ - insert:
5
+ - id: dsh-tailscale-remote
6
+ name: dsh-tailscale-remote
7
+ config:
8
+ enabled: true
9
+ httpsPort: 7259
package/index.js ADDED
@@ -0,0 +1,777 @@
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
+ * @param {import('@deepseek-ai/cordis').Context} ctx - 插件上下文。
441
+ * @param {unknown} rawConfig - 该行声明的 config。
442
+ */
443
+ export function apply(ctx, rawConfig) {
444
+ const config = normalizeConfig(rawConfig)
445
+ if (!config.enabled) {
446
+ ctx.logger.info('tailscale-remote: enabled=false,插件不动作')
447
+ return
448
+ }
449
+
450
+ // connection 是可选依赖:服务晚到也会触发,永不出现则保持静默。
451
+ // 服务只从 scope 参数上取(inject 新建的 fiber 的 store 里才有它)。
452
+ ctx.inject(['connection'], (scope) => {
453
+ if (scope.get('connection') === undefined) return
454
+
455
+ /** 本次运行真正做过的副作用,退出时按这个清单回滚。 */
456
+ const undo = {
457
+ /** 我们 push 进 trustedHosts 的那一项。 */
458
+ trustedHost: undefined,
459
+ /** 7259 路由是不是我们建的(本来就在就留着,不动)。 */
460
+ createdRoute: false,
461
+ /** tailscale 是不是我们拉起来的(本来 Running 就不 down)。 */
462
+ startedTailscale: false,
463
+ /** 启动阶段探测到的可执行文件,退出时直接复用——关停预算里再探一次就超时了。 */
464
+ binary: undefined,
465
+ /** 注册的登录入口路径(fiber 拆除时路由会跟着注销,这里只作记录)。 */
466
+ loginPath: undefined,
467
+ disposed: false,
468
+ }
469
+
470
+ // 退出清理挂在这里:这是唯一可靠的关停挂点,且 disposer 绝不能 reject。
471
+ scope.effect(() => async () => {
472
+ if (undo.disposed) return
473
+ undo.disposed = true
474
+ if (!config.teardownOnExit) return
475
+ // 关停预算很紧:全部吞掉异常,绝不让 disposer reject(会变成 AggregateError)。
476
+ await Promise.allSettled([teardown(scope, config, undo)])
477
+ }, 'tailscale-remote: teardown')
478
+
479
+ // 启动流程异步跑,不阻塞 Loader 装配。
480
+ setup(scope, config, undo).catch((error) => {
481
+ scope.logger.warn(`tailscale-remote: 启动失败:${String(error?.message ?? error)}`)
482
+ })
483
+ })
484
+ }
485
+
486
+ /** 真实依赖;测试通过 `deps` 参数替换它们,生产路径永远用这一份。 */
487
+ const REAL_DEPS = {
488
+ run,
489
+ resolveBinary,
490
+ readStatus,
491
+ readServeStatus,
492
+ waitForPort,
493
+ persistState,
494
+ loadState,
495
+ ensureLoginSecret,
496
+ announce,
497
+ launchGui,
498
+ resolveGui,
499
+ }
500
+
501
+ /** 启动:拉起 tailscale → 建 serve 路由 → 信任该 authority → 打印手机可用的链接。 */
502
+ async function setup(scope, config, undo, deps = REAL_DEPS) {
503
+ const logger = scope.logger
504
+ const binary = await deps.resolveBinary(scope, config.binaryPath)
505
+ if (binary === undefined) {
506
+ logger.warn('tailscale-remote: 找不到 tailscale 可执行文件;请安装 Tailscale 或用 config.binaryPath 指定路径')
507
+ return
508
+ }
509
+ // 记下来给退出阶段复用:关停时只有几秒预算,不能再跑一轮 `tailscale version` 探测。
510
+ undo.binary = binary
511
+
512
+ let status = await deps.readStatus(binary)
513
+ const wasRunning = status?.BackendState === 'Running'
514
+
515
+ // 关键:必须验证 backend 真的 Running,不能只信 `tailscale up` 的退出码
516
+ // (Windows 上客户端被完全退出时,up 会 exit 0 但什么都不做)。
517
+ const running = await ensureRunning(binary, config, deps, logger, undo)
518
+ if (!running) return
519
+ if (!wasRunning) status = await deps.readStatus(binary)
520
+
521
+ const dnsName = dnsNameOf(status)
522
+ if (dnsName === undefined) {
523
+ logger.warn('tailscale-remote: 读不到 tailnet DNS 名称,跳过 Serve 配置')
524
+ return
525
+ }
526
+
527
+ // DSH 自己的 Web 端口从服务上读,绝不写死。
528
+ const port = await deps.waitForPort(scope)
529
+ if (port === undefined) {
530
+ logger.warn('tailscale-remote: 等不到 DSH Web 端口,跳过 Serve 配置')
531
+ return
532
+ }
533
+
534
+ const target = `http://127.0.0.1:${String(port)}`
535
+ const authority = `${dnsName}:${String(config.httpsPort)}`
536
+
537
+ if (config.serveOnStart) {
538
+ const serveStatus = await deps.readServeStatus(binary)
539
+ const state = classifyServe(serveStatus, config.httpsPort, target)
540
+ if (state.kind === 'conflict') {
541
+ logger.warn(`tailscale-remote: ${String(config.httpsPort)} 端口上有冲突的 Serve 配置(${state.reason}),不覆盖`)
542
+ return
543
+ }
544
+ if (state.kind === 'exact') {
545
+ // 已经有一条精确指向本实例 DSH 端口的路由。它只可能是上一次运行留下的
546
+ // (正常退出会撤掉,所以这种情况基本等于「上次 DSH 是崩溃/强杀结束的」)。
547
+ // 既然目标就是我们自己,就把它**认领下来**,退出时一并撤掉 —— 否则用户的
548
+ // 「关 DSH 就关掉」在崩溃恢复后会永远失效,路由会一直留在那儿。
549
+ logger.info(`tailscale-remote: ${authority} 已在代理 ${target},接管它(退出时会撤掉)`)
550
+ undo.createdRoute = true
551
+ } else {
552
+ if (state.kind === 'stale') {
553
+ logger.info(`tailscale-remote: 接管陈旧路由 ${state.authority}(原代理 ${state.previousProxy})`)
554
+ }
555
+ const serve = await deps.run(binary, ['serve', '--bg', `--https=${String(config.httpsPort)}`, target], SETUP_TIMEOUT_MS)
556
+ if (!serve.ok) {
557
+ logger.warn(
558
+ `tailscale-remote: 配置 Serve 失败: ${(serve.stderr || serve.stdout).trim().slice(0, 300)};` +
559
+ '若提示 HTTPS 证书,请先在 Tailscale 管理台为该 tailnet 启用 HTTPS 证书',
560
+ )
561
+ return
562
+ }
563
+ undo.createdRoute = true
564
+ }
565
+ }
566
+
567
+ // 关键一步:让 DSH 的 /api fence 接受这个 authority,否则手机上前端能开、RPC 全 403。
568
+ if (config.trustedHosts) {
569
+ const trusted = connectionTrustedHosts(scope)
570
+ if (trusted === undefined) {
571
+ logger.warn('tailscale-remote: 读不到 connection.trustedHosts,远程 RPC 会被 403 拒绝')
572
+ } else if (!trusted.includes(authority)) {
573
+ trusted.push(authority)
574
+ undo.trustedHost = authority
575
+ logger.info(`tailscale-remote: 已把 ${authority} 加入 /api 信任列表`)
576
+ }
577
+ }
578
+
579
+ const plainUrl = `https://${authority}/`
580
+ const connection = scope.get('connection')
581
+
582
+ // 把登录 cookie 的有效期拉长。DSH 默认 30 天,对「手机随时连」太短。
583
+ // `connection.browserAuth.maxAgeMilliseconds` 是**活的**字段:authorizeIndex 种 cookie 时
584
+ // 用它算 expiresAt / Max-Age,isAuthenticated 校验时也用它,所以在这里改立刻就生效。
585
+ if (config.cookieMaxAgeDays > 0) {
586
+ const maxAgeMilliseconds = config.cookieMaxAgeDays * 86400000
587
+ const auth = connection?.browserAuth
588
+ if (auth !== undefined && Number.isSafeInteger(Date.now() + maxAgeMilliseconds)) {
589
+ auth.maxAgeMilliseconds = maxAgeMilliseconds
590
+ logger.info(`tailscale-remote: 登录 cookie 有效期设为 ${String(config.cookieMaxAgeDays)} 天`)
591
+ } else {
592
+ logger.warn('tailscale-remote: 读不到 connection.browserAuth,cookie 有效期沿用 DSH 默认值')
593
+ }
594
+ }
595
+
596
+ // 稳定的登录入口:/remote/<持久密钥> → 302 到带令牌的地址。
597
+ // 令牌每次都变、用户没法存书签;这条路径的密钥写在状态文件里,**跨重启不变**,
598
+ // 所以手机上存一次就够了。密钥同时充当访问门槛(不知道密钥的人拿不到会话)。
599
+ let loginUrl = plainUrl
600
+ let loginPath
601
+ if (config.loginRoute) {
602
+ const secret = await deps.ensureLoginSecret(scope, config, deps)
603
+ loginPath = `/remote/${secret}`
604
+ loginUrl = `https://${authority}${loginPath}`
605
+ 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
+ }
657
+ // 嵌套 inject:webServer 缺席时只丢登录入口,tailscale / 信任项照常工作。
658
+ scope.inject(['webServer'], (webScope) => registerLoginRoute(webScope))
659
+ }
660
+
661
+ const loginSecret = loginPath === undefined ? undefined : loginPath.slice('/remote/'.length)
662
+ await deps.persistState(scope, {
663
+ url: handoffUrl,
664
+ loginUrl,
665
+ loginPath,
666
+ loginSecret,
667
+ plainUrl,
668
+ authority,
669
+ dnsName,
670
+ httpsPort: config.httpsPort,
671
+ target,
672
+ cookieMaxAgeDays: config.cookieMaxAgeDays,
673
+ updatedAt: new Date().toISOString(),
674
+ stateFile: config.stateFile,
675
+ })
676
+
677
+ logger.info(`tailscale-remote: 手机可用地址 ${plainUrl}`)
678
+ if (loginUrl !== plainUrl) {
679
+ logger.info(`tailscale-remote: 手机登录入口(存一次书签即可,跨重启不变)${loginUrl}`)
680
+ }
681
+ logger.info(`tailscale-remote: 一次性令牌链接(每次重启都会变)${handoffUrl}`)
682
+ // 也打到 stdout:桌面版的日志面板里能直接看到。
683
+ deps.announce?.(plainUrl, loginUrl, handoffUrl)
684
+ }
685
+
686
+ /** 默认的 announce:写 stdout,桌面版日志面板可见。 */
687
+ function announce(plainUrl, loginUrl, handoffUrl) {
688
+ console.log(`dsh tailscale-remote: ${plainUrl}`)
689
+ if (loginUrl !== plainUrl) console.log(`dsh tailscale-remote (手机登录入口): ${loginUrl}`)
690
+ console.log(`dsh tailscale-remote (一次性令牌): ${handoffUrl}`)
691
+ }
692
+
693
+ /**
694
+ * 等 `webServer.port` 就绪。
695
+ * WebServer 在构造函数里就 provide 了服务,但端口要等 `[Service.init]` 的异步 listen
696
+ * 完成才赋值;插件激活时机与它没有顺序保证,所以这里轮询而不是一次性读。
697
+ */
698
+ async function waitForPort(scope, timeoutMs = 8000) {
699
+ const deadline = Date.now() + timeoutMs
700
+ for (;;) {
701
+ const port = scope.get('webServer')?.port
702
+ if (Number.isInteger(port) && port > 0) return port
703
+ if (Date.now() >= deadline) return undefined
704
+ await new Promise((resolve) => setTimeout(resolve, 100))
705
+ }
706
+ }
707
+
708
+ /** 从 connection 服务上取那个可变的 trustedHosts 数组。 */
709
+ function connectionTrustedHosts(scope, strict = true) {
710
+ const connection = scope.get('connection', strict)
711
+ const trusted = connection?.trustedHosts
712
+ return Array.isArray(trusted) ? trusted : undefined
713
+ }
714
+
715
+ /** 退出:按 undo 清单回滚。幂等、并发安全、绝不抛。 */
716
+ async function teardown(scope, config, undo, deps = REAL_DEPS) {
717
+ const logger = scope.logger
718
+ try {
719
+ if (undo.trustedHost !== undefined) {
720
+ // strict=false:关停时 connection 的 fiber 可能已经不再 active,但仍要清掉那一项。
721
+ const trusted = connectionTrustedHosts(scope, false)
722
+ if (trusted !== undefined) {
723
+ const at = trusted.indexOf(undo.trustedHost)
724
+ if (at !== -1) trusted.splice(at, 1)
725
+ }
726
+ undo.trustedHost = undefined
727
+ }
728
+ } catch {
729
+ /* 上下文可能已经在拆除中 */
730
+ }
731
+
732
+ if (!undo.createdRoute && !undo.startedTailscale) return
733
+
734
+ const binary = undo.binary ?? (await deps.resolveBinary(scope, config.binaryPath))
735
+ if (binary === undefined) return
736
+
737
+ // 两条清理各自独立:Serve 路由没建起来不代表 tailscale 不是我们拉起的。
738
+ if (undo.createdRoute) {
739
+ undo.createdRoute = false
740
+ const off = await deps.run(binary, ['serve', `--https=${String(config.httpsPort)}`, 'off'], TEARDOWN_TIMEOUT_MS)
741
+ if (off.ok) logger.info(`tailscale-remote: 已撤销 ${String(config.httpsPort)} 端口上的 Serve 路由`)
742
+ else logger.warn(`tailscale-remote: 撤销 Serve 路由失败: ${(off.stderr || off.stdout).trim().slice(0, 200)}`)
743
+ }
744
+
745
+ if (undo.startedTailscale) {
746
+ undo.startedTailscale = false
747
+ const down = await deps.run(binary, ['down'], TEARDOWN_TIMEOUT_MS)
748
+ if (down.ok) logger.info('tailscale-remote: 已断开 tailscale(本次由 DSH 拉起)')
749
+ else logger.warn(`tailscale-remote: tailscale down 失败: ${(down.stderr || down.stdout).trim().slice(0, 200)}`)
750
+ }
751
+ }
752
+
753
+ /**
754
+ * 纯函数与底层 runner 的测试入口。
755
+ * 生产路径不读这个对象;它只为单元测试而存在(决定"要不要覆盖已有 Serve 配置"的
756
+ * `classifyServe` 是这里风险最高的一段逻辑,必须有牙齿地测)。
757
+ */
758
+ export const internals = {
759
+ normalizeConfig,
760
+ classifyServe,
761
+ dnsNameOf,
762
+ resolveDshHome,
763
+ resolveGui,
764
+ run,
765
+ resolveBinary,
766
+ readServeStatus,
767
+ readStatus,
768
+ waitForPort,
769
+ waitForRunning,
770
+ ensureRunning,
771
+ ensureLoginSecret,
772
+ mintSessionCookie,
773
+ sessionCookieHeader,
774
+ setup,
775
+ teardown,
776
+ REAL_DEPS,
777
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
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."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Tailscale 远程访问",
4
+ "description": "开 DSH 就把 Web GUI 发布到你的 tailnet,手机随处可连;关 DSH 自动撤掉路由。登录链接在 ~/.dsh/tailscale-remote.json 的 loginUrl 字段(形如 https://<机器>.<tailnet>.ts.net:7259/remote/<密钥>),手机上存一次书签即可,跨重启不变。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,55 @@
1
1
  {
2
2
  "name": "dsh-tailscale-remote",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
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.",
6
+ "license": "MIT",
7
+ "author": "Ckarefulon",
8
+ "publishConfig": {
9
+ "access": "public",
10
+ "registry": "https://registry.npmjs.org/"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/Ckarefulon/dsh-tailscale-remote.git"
15
+ },
16
+ "homepage": "https://github.com/Ckarefulon/dsh-tailscale-remote#readme",
17
+ "bugs": {
18
+ "url": "https://github.com/Ckarefulon/dsh-tailscale-remote/issues"
19
+ },
20
+ "keywords": [
21
+ "dsh",
22
+ "dsh-plugin",
23
+ "deepseek-harness",
24
+ "tailscale",
25
+ "tailnet",
26
+ "serve",
27
+ "remote",
28
+ "mobile",
29
+ "phone",
30
+ "remote-access"
31
+ ],
32
+ "exports": {
33
+ ".": "./index.js",
34
+ "./package.json": "./package.json",
35
+ "./locale/*.json": "./locale/*.json"
36
+ },
37
+ "dsh": {
38
+ "bundle": {
39
+ "patch": "./cordis.patch.yml"
40
+ }
41
+ },
42
+ "files": [
43
+ "index.js",
44
+ "cordis.patch.yml",
45
+ "README.md",
46
+ "LICENSE",
47
+ "locale/*.json"
48
+ ],
49
+ "scripts": {
50
+ "test": "node --test test/classify.test.mjs test/teardown.test.mjs"
51
+ },
52
+ "engines": {
53
+ "node": ">=20"
54
+ }
55
+ }