dsh-remote-hub 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 liaoguogang-hub
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,194 @@
1
- # Temporary Holding Version
1
+ # DSH Remote — Android 远程控制器
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
+ 手机原生 App,直接控制电脑上的 **DeepSeek Harness (DSH)**。不是 WebView 套壳:
4
+ Kotlin/Compose 直连 DSH 自带的 HTTP API + WebSocket 网关。
5
+
6
+ 这是**公共版**,与作者自用版的唯一差别:
7
+
8
+ | | 自用版 | 本仓库(公共版) |
9
+ |---|---|---|
10
+ | 连接方式 | 局域网 / 自建中继 / 自定义 | 局域网 / **ngrok 隧道** / 自定义 |
11
+ | 内置服务器地址 | 有 | **无** |
12
+ | 内置登录凭证 | 有(30 天 cookie) | **无** |
13
+ | 包名 | `com.dsh.remote` | `com.dsh.remote.ngrok`(可与自用版共存) |
14
+ | 应用名 / APK | `DSH` / `DSH-Remote-v*.apk` | `DSH Remote` / `DSH Remote-v*.apk` |
15
+
16
+ 功能与 UI 与自用版 4.12.2 完全一致。
17
+
18
+ ## 功能
19
+
20
+ | 能力 | 状态 |
21
+ |---|---|
22
+ | 三种连接方式(局域网 / ngrok / 自定义),地址与凭证首次填写后本地保存 | ✅ |
23
+ | 会话列表:搜索、排序(最近更新 / 手动)、归档筛选(隐藏 / 全部 / 仅归档) | ✅ |
24
+ | 会话列表默认显示最近 10 条,「展开其余 N 个会话」每次 +5 | ✅ |
25
+ | 长按会话:重命名 / 归档会话(或取消归档) | ✅ |
26
+ | 发消息、逐字流式回复、随时中断 | ✅ |
27
+ | 历史分页(「加载更早的消息」)与本地缓存(先显示上次内容再同步) | ✅ |
28
+ | 模型选择(分组目录 + 切换) | ✅ |
29
+ | 权限模式 | ⚠️ 只能读取当前值(DSH 未开放远程切换权限的接口) |
30
+ | 自定义地址历史(点选填入 / 删除) | ✅ |
31
+
32
+ ## 三步连上(推荐:一键脚本)
33
+
34
+ 1. **电脑上双击 `connect.cmd`**(和 App 一起发布的文件)。
35
+ 它会自动做四件事:起桥接 → 签发 30 天 Cookie → 尽量开公网隧道 → 打印结果。
36
+ 2. 等黑窗口打印出这两行:
37
+ - `DSH 地址: http://192.168.x.x:6300`(同一 WiFi,最快)
38
+ - `Cookie: dsh-auth-…`(已自动复制到剪贴板)
39
+ 如果网络允许,还会多给一行公网地址 `https://xxx.trycloudflare.com`(人在外面时用)。
40
+ 3. **App 里选「自定义」档** → 把地址粘进「DSH 地址」框 → Cookie 粘进「Cookie」框 → 点连接。
41
+
42
+ > 电脑端那个黑窗口要保持开着;关掉 = 断开手机。下次用重新双击一次,抄新的地址和 Cookie 即可。
43
+ > 结果同时会写到同目录的 `connect-info.txt`。
44
+
45
+ ### 公网隧道说明
46
+
47
+ 脚本按这个顺序尝试,**局域网地址永远可用**,公网则尽力而为:
48
+
49
+ | 情况 | 结果 |
50
+ |---|---|
51
+ | 装了 `ngrok` 且在 PATH 里 | 用 ngrok 开隧道 |
52
+ | 有 `cloudflared`(脚本会自动找/下载)且 `api.trycloudflare.com` 可达 | 免费 trycloudflare 地址,免注册 |
53
+ | 两者都不行(常见于受限网络) | 只给局域网地址,并提示需要自备内网穿透(cpolar / natapp / 花生壳 / 自建 frp) |
54
+
55
+ ### 用 ngrok 拿一个永久固定域名(可选,需注册)
56
+
57
+ cloudflared 的免费 quick tunnel **每次重启地址都会变**;想要固定域名就用 ngrok 免费账号。
58
+
59
+
60
+ ![ngrok 注册要点:左栏三处](docs/ngrok-dashboard-annotated.png)
61
+
62
+ 上图红框就是全部要点:① Share Localhost ② Your Authtoken ③ Domains。
63
+
64
+ **注册(约 2 分钟)**
65
+
66
+ 1. 打开 <https://dashboard.ngrok.com/get-started/setup>,在首页选 **Share Localhost**
67
+ (「Put your local app on a public URL」——它明确写着 *stable, auth-protected URL*);
68
+ 2. 跟着 **Get started** 走完,进 dashboard 后取两样东西:
69
+ - **Authtoken**:左侧 `Getting Started` → `Your Authtoken`
70
+ <https://dashboard.ngrok.com/get-started/your-authtoken>
71
+ - **免费固定域名**:左侧 `Universal Gateway` → `Domains`,免费账号自带一个
72
+ `xxxx-yyyy.ngrok-free.app` <https://dashboard.ngrok.com/domains>
73
+ 3. 下载 ngrok 单文件(同样免安装、不需要管理员权限),放进 `tools\ngrok.exe`,或装到 PATH。
74
+
75
+ **配置(只需一次)**
76
+
77
+ ```powershell
78
+ tools\ngrok.exe config add-authtoken <你的 Authtoken>
79
+ ```
80
+
81
+ 然后双击 `connect.cmd`:它会自己发现 ngrok、问你一次固定域名(记在
82
+ `%LOCALAPPDATA%\dsh-remote\ngrok-domain.txt`),之后每次都复用同一个地址。
83
+
84
+ > - **Host 必须改写**:脚本用 `--host-header=127.0.0.1:1815`,否则 DSH 的信任栅栏会 403,
85
+ > 而且 cookie 的 authority 对不上。
86
+ > - **不要加 ngrok 的 Basic Auth**:App 是用 DSH 的签名 cookie 认证的,Basic Auth 会让每个
87
+ > API 调用先被 ngrok 拦下(401),App 连不上。安全性由 30 天签名的 cookie 保证。
88
+ > - **免费额度**:约 40 连接/分钟、1 GB/月。App 是长连接 + 流式输出,日常够用;
89
+ > 偶发断连多半是额度或连接数超限,可换付费或 cloudflared。
90
+
91
+ ### 自己下载 cloudflared(可选,单文件免安装、不需要管理员权限)
92
+
93
+ 脚本会优先在 `connect.cmd` 同目录、`tools\`、`%LOCALAPPDATA%\dsh-remote\` 等位置找
94
+ `cloudflared.exe`;找不到才下载。想自己放一份,按下面来:
95
+
96
+ ```powershell
97
+ # 1) 镜像优先:国内直连 GitHub 下 52 MB 基本必超时
98
+ $url = "https://ghfast.top/https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe"
99
+ # 官方直连(网络允许时):
100
+ # $url = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe"
101
+
102
+ $dest = "tools\cloudflared.exe"
103
+ New-Item -ItemType Directory -Force -Path (Split-Path $dest) | Out-Null
104
+ Invoke-WebRequest -Uri $url -OutFile $dest -TimeoutSec 300 -UseBasicParsing
105
+
106
+ # 2) 一定要验签:镜像可能给错文件,但签名伪造不了
107
+ Get-AuthenticodeSignature $dest | Format-List Status, SignerCertificate
108
+ ```
109
+
110
+ 合格标准:`Status = Valid`,`Subject` 里含 `Cloudflare, Inc.`。
111
+ 参考值(2026.10.5 版):`55,343,328` 字节,
112
+ SHA256 `DB4E4C413612A0916F3E1679D3CAAF376F94911952D163028FB726FB2E0516A9`。
113
+
114
+ > 脚本自己下载时也会做同样的验签(`Get-AuthenticodeSignature` + 主体含 Cloudflare),
115
+ > 校验不过就丢弃并换下一个源 —— **不要用哈希对比代替验签**,镜像可以把哈希一起改掉。
116
+
117
+ ### 手工方式(想自己控制时)
118
+
119
+ ```bash
120
+ node lan-proxy.mjs 6300 127.0.0.1:1815 # 桥接:把 Host 改写成 DSH 认可的 127.0.0.1:1815
121
+ ngrok http 6300 --host-header=127.0.0.1:1815 # 或 cloudflared tunnel --url http://127.0.0.1:6300
122
+ node mint.mjs "127.0.0.1:1815" 30 # 签发 30 天 Cookie
123
+ ```
124
+
125
+ ```text
126
+ 浏览器打开 DSH 页面 → F12 → Application → Cookies → 复制会话 cookie
127
+ (cookie 只对签发时的 authority 有效,所以桥接必须改写 Host 到同一个值)
128
+ ```
129
+
130
+ ## 安全说明
131
+
132
+ - **本仓库不包含任何地址、Token 或 Cookie**:`DshDefaults.kt` 里全是空字符串,首次连接在 App 里填。
133
+ - **Cookie 由你自己的电脑签发**:脚本用 `~/.dsh/.credentials.yaml` 里的密钥签一个 30 天有效的凭据,
134
+ 只在你自己机器和手机之间流动,不要提交到任何仓库。
135
+ - **局域网默认是明文 HTTP**:`lan-proxy.mjs` 监听 `0.0.0.0:6300`,同一 WiFi 内 Anyone 能访问它,
136
+ 所以请只在可信网络里用局域网模式;要跨网请用 ngrok / cloudflared 这类带 TLS 的隧道。
137
+ - **App 允许明文与用户证书**(`network_security_config.xml`):DSH 通常跑在明文 HTTP 上,
138
+ 自签 CA 的中转也要能信任;代价是它不会阻止你连明文地址,所以地址来源要自己把关。
139
+ - **别把 ngrok 的 Basic Auth 和 DSH 的 Cookie 混用**:App 只带 DSH 的 Cookie,
140
+ Basic Auth 会让每个请求先被隧道 401 拦掉。
141
+
142
+ ## 构建
143
+
144
+ ```bash
145
+ ./gradlew assembleRelease # 产物:dist/DSH Remote-v<version>.apk
146
+ ```
147
+
148
+ 需要 JDK 17 与 Android SDK(`local.properties` 指向本机 SDK)。
149
+
150
+ ## 版本
151
+
152
+ 当前 `4.12.2`(versionCode 58),与自用版 4.12.2 功能一致。
153
+
154
+ ## 作为 DSH 插件安装(推荐)
155
+
156
+ 本仓库根目录同时是一个 **DSH plugin**(Node 侧),装进 DSH 后由宿主负责起桥接、开隧道、签 Cookie,
157
+ 并把地址与 Cookie 写进 `~/.dsh/dsh-remote/`,手机 App 直接抄。
158
+
159
+ ```bash
160
+ # 从 NPM 安装(发布后可用,会出现在 DSH 内的「插件中心」)
161
+ dsh plugin --profile web add dsh-remote-hub
162
+
163
+ # 或直接从本仓库源码安装(不需要 NPM 发布)
164
+ dsh plugin --profile web add github:liaoguogang-hub/dsh-remote-public
165
+ ```
166
+
167
+ 装完重启 `dsh web`(或桌面端),插件会自动:
168
+
169
+ 1. 起桥接:`0.0.0.0:6300`,把 Host 改写成 `127.0.0.1:1815`(Cookie 因此不会因端口变化失效);
170
+ 2. 自动发现 DSH 宿主端口(宿主每次重启都换端口,插件每次失败会重新扫一遍);
171
+ 3. 开隧道:**有 ngrok 就用 ngrok**(固定域名),否则自动下载并验签 cloudflared 开免注册 quick tunnel;
172
+ 4. 签发 30 天 Cookie,写到 `~/.dsh/dsh-remote/status.json` 与 `connect.txt`。
173
+
174
+ 查看结果:
175
+
176
+ ```bash
177
+ dsh-remote status # 局域网地址、公网地址、Cookie、上游端口
178
+ dsh-remote connect # 直接打印要抄进手机的两行
179
+ dsh-remote doctor # 逐项自检:凭据、ngrok、cloudflared、宿主端口
180
+ dsh-remote start # 不开 DSH 也能单独前台跑(Ctrl+C 退出)
181
+ ```
182
+
183
+ 插件配置(profile 行里覆盖即可):
184
+
185
+ | 键 | 默认 | 作用 |
186
+ |---|---|---|
187
+ | `enabled` | `true` | 关掉插件但不卸载 |
188
+ | `port` | `6300` | 桥接监听端口(手机连这个) |
189
+ | `authority` | `127.0.0.1:1815` | 桥接呈现的 Host,Cookie 绑定它 |
190
+ | `tunnel` | `auto` | `auto` / `off` / `ngrok` / `cloudflared` |
191
+ | `upstreamPort` | `0` | 固定 DSH 宿主端口,`0` 表示自动发现 |
192
+
193
+ > 公网隧道受网络影响:`api.trycloudflare.com` 不可达时插件会跳过公网、只给局域网地址,
194
+ > 并在 `status.json` 里写明原因,不会假装成功。
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dsh-remote CLI — inspect or drive the bridge/tunnel without the DSH UI.
4
+ *
5
+ * dsh-remote status show LAN URL, public URL, cookie and upstream port
6
+ * dsh-remote connect print the two lines to paste into the phone
7
+ * dsh-remote cookie print only the cookie
8
+ * dsh-remote start run the supervisor in the foreground (Ctrl+C stops it)
9
+ * dsh-remote stop ask a foreground `start` to stop (best effort, via status file)
10
+ * dsh-remote doctor check every prerequisite in one pass
11
+ *
12
+ * @module bin/dsh-remote
13
+ */
14
+
15
+ import fs from 'node:fs'
16
+ import os from 'node:os'
17
+ import path from 'node:path'
18
+
19
+ import { credentialsPath, mintCookie, readSigningSecret } from '../lib/cookie.js'
20
+ import { listeningPorts, probePort } from '../lib/discover.js'
21
+ import { findCloudflared, findNgrok } from '../lib/tunnel.js'
22
+ import { CONNECT_FILE, STATUS_FILE, readStatus, start } from '../lib/supervisor.js'
23
+
24
+ const args = process.argv.slice(2)
25
+ const command = args[0] || 'status'
26
+
27
+ function fmtTime(ms) {
28
+ if (!ms) return '?'
29
+ const d = new Date(ms)
30
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
31
+ }
32
+
33
+ function printStatus() {
34
+ const status = readStatus()
35
+ if (!status) {
36
+ console.log('还没有运行记录。先运行: dsh-remote start')
37
+ return
38
+ }
39
+ const remaining = status.cookieExpiresAt ? Math.round((status.cookieExpiresAt - Date.now()) / 86400000) : null
40
+ console.log('')
41
+ console.log(' DSH Remote 状态')
42
+ console.log(' ' + '-'.repeat(52))
43
+ console.log(` 局域网地址 ${status.lan?.url ?? '(未检测到局域网 IP)'}`)
44
+ console.log(` 公网地址 ${status.public?.url ?? `无(${status.public?.note ?? '未开启'})`}`)
45
+ console.log(` 桥接端口 ${status.bridgePort}`)
46
+ console.log(` DSH 上游 ${status.upstreamPort ? '127.0.0.1:' + status.upstreamPort : '未找到'}`)
47
+ console.log(` 更新时间 ${new Date(status.updatedAt).toLocaleString()}`)
48
+ if (status.cookie) {
49
+ console.log(` Cookie 到期 ${fmtTime(status.cookieExpiresAt)}${remaining !== null ? `(还剩 ${remaining} 天)` : ''}`)
50
+ console.log('')
51
+ console.log(' 把下面两样填进手机 App:')
52
+ console.log(` 地址 ${status.public?.url ?? status.lan?.url ?? ''}`)
53
+ console.log(` Cookie ${status.cookie}`)
54
+ } else if (status.error) {
55
+ console.log(` 错误 ${status.error}`)
56
+ }
57
+ console.log(' ' + '-'.repeat(52))
58
+ console.log('')
59
+ }
60
+
61
+ async function doctor() {
62
+ console.log('')
63
+ console.log(' dsh-remote 自检')
64
+ console.log(' ' + '-'.repeat(52))
65
+ const cred = credentialsPath()
66
+ let secret = null
67
+ try {
68
+ secret = readSigningSecret(cred)
69
+ console.log(` ✔ 凭据文件 ${cred}`)
70
+ } catch (err) {
71
+ console.log(` ✘ 凭据文件 ${err.message}`)
72
+ }
73
+ const ngrok = findNgrok()
74
+ console.log(` ${ngrok ? '✔' : '·'} ngrok ${ngrok ?? '未安装(可选,有则优先用固定域名)'}`)
75
+ const cf = findCloudflared()
76
+ console.log(` ${cf ? '✔' : '·'} cloudflared ${cf ?? '未安装(首次运行会自动下载并验签)'}`)
77
+ if (secret) {
78
+ try {
79
+ const { cookie } = mintCookie('127.0.0.1:1815', { secret, days: 30 })
80
+ const bridgePort = Number(process.env.DSH_REMOTE_PORT || 6300)
81
+ const ports = (await listeningPorts()).filter((p) => p !== bridgePort)
82
+ let found = null
83
+ for (const p of ports) {
84
+ // eslint-disable-next-line no-await-in-loop
85
+ if (await probePort(p, { authority: '127.0.0.1:1815', cookie })) {
86
+ found = p
87
+ break
88
+ }
89
+ }
90
+ console.log(` ${found ? '✔' : '✘'} DSH 宿主端口 ${found ? '127.0.0.1:' + found : '未找到(DSH 开着吗?)'}`)
91
+ } catch (err) {
92
+ console.log(` ✘ 探测失败 ${err.message}`)
93
+ }
94
+ }
95
+ console.log(' ' + '-'.repeat(52))
96
+ console.log('')
97
+ }
98
+
99
+ async function main() {
100
+ switch (command) {
101
+ case 'status':
102
+ printStatus()
103
+ break
104
+ case 'connect':
105
+ if (fs.existsSync(CONNECT_FILE)) console.log(fs.readFileSync(CONNECT_FILE, 'utf8'))
106
+ else printStatus()
107
+ break
108
+ case 'cookie': {
109
+ const status = readStatus()
110
+ if (status?.cookie) console.log(status.cookie)
111
+ else {
112
+ const { cookie } = mintCookie('127.0.0.1:1815')
113
+ console.log(cookie)
114
+ }
115
+ break
116
+ }
117
+ case 'doctor':
118
+ await doctor()
119
+ break
120
+ case 'start': {
121
+ const handle = await start({ log: (m) => console.log(m) })
122
+ process.on('SIGINT', async () => {
123
+ await handle.close()
124
+ process.exit(0)
125
+ })
126
+ break
127
+ }
128
+ case 'stop':
129
+ console.log('前台运行的 dsh-remote 用 Ctrl+C 停止;插件模式下请在 profile 设置里关闭该插件。')
130
+ break
131
+ default:
132
+ console.log(`未知命令:${command}`)
133
+ console.log('可用:status | connect | cookie | doctor | start | stop')
134
+ process.exitCode = 1
135
+ }
136
+ }
137
+
138
+ main().catch((err) => {
139
+ console.error(err?.stack ?? String(err))
140
+ process.exitCode = 1
141
+ })
@@ -0,0 +1,21 @@
1
+ # Bundle patch layer for the dsh-remote plugin.
2
+ #
3
+ # One insert row mounts the Host half only: the plugin runs inside the DSH host process, starts
4
+ # the Host-rewriting bridge and the tunnel from there, and writes the resulting URL + cookie to
5
+ # ~/.dsh/dsh-remote/. There is no browser half — the phone talks to the bridge, not to the web UI.
6
+ #
7
+ # Every key below is overridable from a saved profile row:
8
+ # enabled false disables the whole plugin without uninstalling it
9
+ # port local bridge port the phone connects to on the LAN
10
+ # authority Host header the bridge presents; the signed cookie is bound to it
11
+ # tunnel "auto" (ngrok if found, else cloudflared), "off", "ngrok" or "cloudflared"
12
+ # upstreamPort pin the DSH host port; 0 means discover it automatically
13
+ - insert:
14
+ - id: dsh-remote
15
+ name: 'dsh-remote-hub'
16
+ config:
17
+ enabled: true
18
+ port: 6300
19
+ authority: '127.0.0.1:1815'
20
+ tunnel: 'auto'
21
+ upstreamPort: 0
package/lib/bridge.js ADDED
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Host-rewriting reverse proxy between the phone and the DSH host.
3
+ *
4
+ * Two invariants:
5
+ *
6
+ * 1. The cookie's audience is whatever Host DSH receives, so this proxy always presents a fixed
7
+ * authority (`127.0.0.1:1815` by default). One signed cookie therefore keeps working across
8
+ * host restarts and across every transport.
9
+ * 2. The upstream port moves, so forwarding never pins it: a failed request or a 5xx triggers a
10
+ * re-resolve before the error reaches the phone (up to one retry).
11
+ *
12
+ * It also trims `/api/session/list`, which is the single heaviest payload the app pulls (every
13
+ * Session with its full projection set). The phone only renders title / permissions /
14
+ * modelSelection, so projecting it here cuts the wire body by roughly 8x.
15
+ *
16
+ * @module lib/bridge
17
+ */
18
+
19
+ import http from 'node:http'
20
+ import net from 'node:net'
21
+ import zlib from 'node:zlib'
22
+
23
+ const LIST_PATH = '/api/session/list'
24
+
25
+ function rewriteHeaders(headers, authority) {
26
+ const next = { ...headers }
27
+ delete next.origin
28
+ delete next.referer
29
+ next.host = authority
30
+ return next
31
+ }
32
+
33
+ /** Keep only the fields the Android app reads, from a `session/list` response body. */
34
+ function trimSessionList(body) {
35
+ const doc = JSON.parse(body.toString('utf8'))
36
+ const items = doc?.result?.value?.items
37
+ if (!Array.isArray(items)) return null
38
+ doc.result.value.items = items.map((row) => {
39
+ const v = row?.projections?.values ?? row?.projections ?? {}
40
+ return {
41
+ sessionId: row.sessionId,
42
+ updatedAt: row.updatedAt,
43
+ running: row.running,
44
+ blank: row.blank,
45
+ origin: row.origin,
46
+ cwd: row.cwd,
47
+ projections: {
48
+ values: {
49
+ title: v.title,
50
+ permissions: v.permissions,
51
+ modelSelection: v.modelSelection,
52
+ },
53
+ },
54
+ }
55
+ })
56
+ return Buffer.from(JSON.stringify(doc), 'utf8')
57
+ }
58
+
59
+ /**
60
+ * Start the bridge.
61
+ *
62
+ * @param {object} options
63
+ * @param {number} options.listenPort
64
+ * @param {string} options.authority
65
+ * @param {{resolve: (force?: boolean) => Promise<number>, invalidate: () => void}} options.upstream
66
+ * @param {(msg: string) => void} [options.log]
67
+ * @returns {Promise<{close: () => Promise<void>, port: number}>}
68
+ */
69
+ export function startBridge({ listenPort, authority, upstream, log = () => {} }) {
70
+ const forward = (req, res, attempt = 0) =>
71
+ new Promise((resolve) => {
72
+ upstream.resolve(attempt > 0).then((port) => {
73
+ if (!port) {
74
+ res.writeHead(502)
75
+ res.end('dsh-remote: no upstream found (is the DSH host running?)')
76
+ return resolve()
77
+ }
78
+ const headers = rewriteHeaders(req.headers, authority)
79
+ // DSH gzips on its own when it sees Accept-Encoding; the list needs plain JSON so it can
80
+ // be trimmed and re-compressed here.
81
+ const isList = req.url === LIST_PATH
82
+ if (isList) delete headers['accept-encoding']
83
+
84
+ const up = http.request({ host: '127.0.0.1', port, path: req.url, method: req.method, headers }, (r) => {
85
+ if (r.statusCode >= 500 && attempt === 0) {
86
+ r.resume()
87
+ upstream.invalidate()
88
+ return forward(req, res, attempt + 1).then(resolve)
89
+ }
90
+ const contentType = String(r.headers['content-type'] || '')
91
+ const clientWantsGzip = /gzip/i.test(String(req.headers['accept-encoding'] || ''))
92
+ const canRewrite = isList && r.statusCode === 200 && !r.headers['content-encoding']
93
+ if (!canRewrite || !/json/i.test(contentType)) {
94
+ res.writeHead(r.statusCode, { ...r.headers, 'x-dsh-remote-bridge': '1' })
95
+ r.pipe(res)
96
+ r.on('end', resolve)
97
+ return
98
+ }
99
+ const chunks = []
100
+ r.on('data', (c) => chunks.push(c))
101
+ r.on('end', () => {
102
+ const headersOut = { ...r.headers }
103
+ let body = Buffer.concat(chunks)
104
+ try {
105
+ const trimmed = trimSessionList(body)
106
+ if (trimmed) {
107
+ body = trimmed
108
+ delete headersOut['content-length']
109
+ }
110
+ } catch {
111
+ /* unexpected shape: pass the original body through */
112
+ }
113
+ headersOut['x-dsh-remote-bridge'] = '1'
114
+ if (clientWantsGzip && body.length >= 2048) {
115
+ const gz = zlib.gzipSync(body, { level: 6 })
116
+ headersOut['content-encoding'] = 'gzip'
117
+ headersOut['content-length'] = String(gz.length)
118
+ delete headersOut['transfer-encoding']
119
+ res.writeHead(r.statusCode, headersOut)
120
+ res.end(gz)
121
+ } else {
122
+ res.writeHead(r.statusCode, headersOut)
123
+ res.end(body)
124
+ }
125
+ resolve()
126
+ })
127
+ r.on('error', () => {
128
+ try {
129
+ res.destroy()
130
+ } catch {}
131
+ resolve()
132
+ })
133
+ })
134
+ up.on('error', () => {
135
+ if (attempt === 0) {
136
+ upstream.invalidate()
137
+ return forward(req, res, attempt + 1).then(resolve)
138
+ }
139
+ try {
140
+ res.writeHead(502)
141
+ res.end('dsh-remote: upstream unavailable')
142
+ } catch {}
143
+ resolve()
144
+ })
145
+ req.pipe(up)
146
+ })
147
+ })
148
+
149
+ const server = http.createServer((req, res) => {
150
+ forward(req, res).catch(() => {
151
+ try {
152
+ res.writeHead(502)
153
+ res.end('dsh-remote: internal error')
154
+ } catch {}
155
+ })
156
+ })
157
+
158
+ // WebSocket upgrades (session/follow) need the same Host rewrite, done by hand: a raw socket
159
+ // tunnel cannot reuse `http.request`.
160
+ server.on('upgrade', (req, socket, head) => {
161
+ upstream.resolve().then((port) => {
162
+ if (!port) {
163
+ socket.destroy()
164
+ return
165
+ }
166
+ const up = net.connect(port, '127.0.0.1', () => {
167
+ const headers = rewriteHeaders({ ...req.headers, connection: 'Upgrade', upgrade: req.headers.upgrade }, authority)
168
+ const lines = [
169
+ `GET ${req.url} HTTP/1.1`,
170
+ ...Object.entries(headers).map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(', ') : v}`),
171
+ '',
172
+ '',
173
+ ]
174
+ up.write(lines.join('\r\n'))
175
+ if (head?.length) up.write(head)
176
+ up.pipe(socket)
177
+ socket.pipe(up)
178
+ })
179
+ up.on('error', () => socket.destroy())
180
+ socket.on('error', () => up.destroy())
181
+ })
182
+ })
183
+
184
+ return new Promise((resolve, reject) => {
185
+ server.once('error', reject)
186
+ server.listen(listenPort, '0.0.0.0', () => {
187
+ server.removeListener('error', reject)
188
+ log(`bridge listening on 0.0.0.0:${listenPort} (Host: ${authority})`)
189
+ resolve({
190
+ port: listenPort,
191
+ close: () =>
192
+ new Promise((done) => {
193
+ server.close(() => done())
194
+ // `close` waits for keep-alive sockets; drop them so shutdown is prompt.
195
+ server.closeAllConnections?.()
196
+ }),
197
+ })
198
+ })
199
+ })
200
+ }
package/lib/cookie.js ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Cookie minting: sign a `dsh-auth-*` v1 cookie for an authority using the DSH install's own
3
+ * browser-session secret.
4
+ *
5
+ * Why this works without the launch token: DSH verifies the HMAC and `expiresAt`, and the
6
+ * authority is part of the signed body. Since the bridge always presents a fixed Host, one
7
+ * cookie keeps working across host restarts and across every transport (LAN / tunnel / proxy).
8
+ *
9
+ * @module lib/cookie
10
+ */
11
+
12
+ import fs from 'node:fs'
13
+ import os from 'node:os'
14
+ import path from 'node:path'
15
+ import { createHash, createHmac } from 'node:crypto'
16
+
17
+ /** Path of the credentials file that holds the signing secret. */
18
+ export function credentialsPath() {
19
+ return path.join(os.homedir(), '.dsh', '.credentials.yaml')
20
+ }
21
+
22
+ /**
23
+ * Read `client-connection/browser-session: secret` out of the credentials YAML.
24
+ *
25
+ * The file is small and machine-generated, so a targeted line scan beats pulling a YAML
26
+ * dependency into the plugin.
27
+ */
28
+ export function readSigningSecret(file = credentialsPath()) {
29
+ let text
30
+ try {
31
+ text = fs.readFileSync(file, 'utf8')
32
+ } catch {
33
+ throw new Error(`cannot read DSH credentials at ${file}`)
34
+ }
35
+ const lines = text.split(/\r?\n/)
36
+ for (let i = 0; i < lines.length; i++) {
37
+ if (!/client-connection\/browser-session:/.test(lines[i])) continue
38
+ for (let j = i; j < Math.min(i + 8, lines.length); j++) {
39
+ const m = lines[j].match(/^\s*secret:\s*(\S+)\s*$/)
40
+ if (m) return m[1]
41
+ }
42
+ }
43
+ throw new Error(`no browser-session signing secret in ${file} (open DSH once, then retry)`)
44
+ }
45
+
46
+ const b64 = (buf) => Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
47
+
48
+ /**
49
+ * Mint a signed cookie for `authority`.
50
+ *
51
+ * @param {string} authority Host authority the bridge presents, e.g. `127.0.0.1:1815`
52
+ * @param {object} [options]
53
+ * @param {number} [options.days=30] validity window
54
+ * @param {string} [options.secret] pre-read secret (avoids re-reading the file)
55
+ * @returns {{cookie: string, issuedAt: number, expiresAt: number}}
56
+ */
57
+ export function mintCookie(authority, options = {}) {
58
+ if (!authority) throw new Error('authority is required')
59
+ const days = Number(options.days ?? 30)
60
+ if (!Number.isFinite(days) || days <= 0) throw new Error('days must be a positive number')
61
+ const secret = options.secret ?? readSigningSecret()
62
+ const now = Date.now()
63
+ const expiresAt = now + days * 24 * 3600 * 1000
64
+ const body = b64(JSON.stringify({ version: 1, authority, issuedAt: now, expiresAt }))
65
+ const sig = b64(createHmac('sha256', Buffer.from(secret, 'base64url')).update(body).digest())
66
+ const name = 'dsh-auth-' + b64(createHash('sha256').update(authority).digest())
67
+ return { cookie: `${name}=v1.${body}.${sig}`, issuedAt: now, expiresAt }
68
+ }
69
+
70
+ /** Decode the `expiresAt` carried inside a minted cookie, for status reporting. */
71
+ export function cookieExpiry(cookie) {
72
+ const m = /\.v1\.([^.]+)\./.exec(String(cookie))
73
+ if (!m) return null
74
+ try {
75
+ const json = Buffer.from(m[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')
76
+ const parsed = JSON.parse(json)
77
+ return Number.isFinite(parsed?.expiresAt) ? parsed.expiresAt : null
78
+ } catch {
79
+ return null
80
+ }
81
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Find the DSH host's HTTP port.
3
+ *
4
+ * The desktop host listens on a **different port every restart** (observed 1815, 5894, 14299,
5
+ * 8312, 12077), so the port cannot be configured once and forgotten. It also shares the machine
6
+ * with the inspector and other loopback services, so "the port is listening" is not enough —
7
+ * only the real web server answers our authenticated RPC.
8
+ *
9
+ * Strategy: enumerate local listening ports, then probe each with the signed cookie and the
10
+ * authority the bridge presents. First 200 wins. Results are cached with a TTL so a host restart
11
+ * is picked up on the next failure instead of pinning a dead port forever.
12
+ *
13
+ * @module lib/discover
14
+ */
15
+
16
+ import http from 'node:http'
17
+ import { execFile } from 'node:child_process'
18
+
19
+ const PROBE_TIMEOUT_MS = 1500
20
+ const PROBE_PATH = '/api/session/list'
21
+ const PROBE_BODY = JSON.stringify({
22
+ type: 'client-request',
23
+ rpcId: 'probe',
24
+ method: 'session/list',
25
+ payload: { args: { _request: {} } },
26
+ })
27
+
28
+ function run(cmd, args, timeout = 8000) {
29
+ return new Promise((resolve) => {
30
+ execFile(cmd, args, { timeout, windowsHide: true }, (err, stdout) => {
31
+ resolve(err ? '' : String(stdout))
32
+ })
33
+ })
34
+ }
35
+
36
+ /** Local TCP ports currently in LISTEN state. */
37
+ export async function listeningPorts() {
38
+ const ports = new Set()
39
+ if (process.platform === 'win32') {
40
+ const out = await run('netstat', ['-ano', '-p', 'tcp'])
41
+ for (const line of out.split(/\r?\n/)) {
42
+ const m = /^\s*TCP\s+(\S+):(\d+)\s+\S+\s+LISTENING\s+(\d+)/i.exec(line)
43
+ if (!m) continue
44
+ const addr = m[1]
45
+ const port = Number(m[2])
46
+ if (!Number.isInteger(port) || port <= 0) continue
47
+ if (addr !== '127.0.0.1' && addr !== '0.0.0.0' && addr !== '[::]' && addr !== '[::1]') continue
48
+ ports.add(port)
49
+ }
50
+ return [...ports]
51
+ }
52
+ // macOS / Linux
53
+ const ss = await run('ss', ['-ltn'])
54
+ if (ss) {
55
+ for (const line of ss.split(/\r?\n/)) {
56
+ const m = /\s(?:\S*):(\d+)\s/.exec(` ${line} `)
57
+ if (m) ports.add(Number(m[1]))
58
+ }
59
+ }
60
+ if (ports.size === 0) {
61
+ const lsof = await run('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN'])
62
+ for (const line of lsof.split(/\r?\n/)) {
63
+ const m = /:(\d+)\s+\(LISTEN\)/.exec(line)
64
+ if (m) ports.add(Number(m[1]))
65
+ }
66
+ }
67
+ return [...ports].filter((p) => Number.isInteger(p) && p > 0)
68
+ }
69
+
70
+ /** Does `port` answer the authenticated DSH RPC? */
71
+ export function probePort(port, { authority, cookie }) {
72
+ return new Promise((resolve) => {
73
+ const req = http.request(
74
+ {
75
+ host: '127.0.0.1',
76
+ port,
77
+ path: PROBE_PATH,
78
+ method: 'POST',
79
+ timeout: PROBE_TIMEOUT_MS,
80
+ headers: {
81
+ 'content-type': 'application/json',
82
+ 'content-length': Buffer.byteLength(PROBE_BODY),
83
+ host: authority,
84
+ cookie,
85
+ },
86
+ },
87
+ (res) => {
88
+ // Another dsh-remote bridge also answers our RPC (it forwards to the real host); treating
89
+ // it as upstream would chain bridges, so tagged responses are rejected.
90
+ const isBridge = res.headers['x-dsh-remote-bridge'] === '1'
91
+ res.resume()
92
+ resolve(res.statusCode === 200 && !isBridge)
93
+ },
94
+ )
95
+ req.on('error', () => resolve(false))
96
+ req.on('timeout', () => {
97
+ req.destroy()
98
+ resolve(false)
99
+ })
100
+ req.write(PROBE_BODY)
101
+ req.end()
102
+ })
103
+ }
104
+
105
+ /**
106
+ * A resolver that remembers the last known-good port.
107
+ *
108
+ * @param {object} options
109
+ * @param {string} options.authority
110
+ * @param {() => Promise<string>|string} options.cookie current cookie (may be re-minted)
111
+ * @param {number} [options.pinned=0] skip discovery when > 0
112
+ * @param {number} [options.ttlMs=30000]
113
+ */
114
+ export function createDiscovery({ authority, cookie, pinned = 0, ttlMs = 30_000, exclude = [] }) {
115
+ let port = pinned || 0
116
+ let resolvedAt = 0
117
+ let inFlight = null
118
+
119
+ async function resolveCookie() {
120
+ try {
121
+ return await cookie()
122
+ } catch {
123
+ return ''
124
+ }
125
+ }
126
+
127
+ async function resolve(force = false) {
128
+ if (pinned) return pinned
129
+ if (!force && port && Date.now() - resolvedAt < ttlMs) return port
130
+ if (inFlight) return inFlight
131
+ inFlight = (async () => {
132
+ const ck = await resolveCookie()
133
+ if (!ck) return port
134
+ const skip = new Set(exclude.filter((p) => Number.isInteger(p) && p > 0))
135
+ const ports = (await listeningPorts()).filter((p) => !skip.has(p))
136
+ // Probe the remembered port first: it is usually still right and saves a full sweep.
137
+ const ordered = port && ports.includes(port) ? [port, ...ports.filter((p) => p !== port)] : ports
138
+ for (const candidate of ordered) {
139
+ // eslint-disable-next-line no-await-in-loop
140
+ if (await probePort(candidate, { authority, cookie: ck })) {
141
+ port = candidate
142
+ resolvedAt = Date.now()
143
+ return port
144
+ }
145
+ }
146
+ return port
147
+ })()
148
+ try {
149
+ return await inFlight
150
+ } finally {
151
+ inFlight = null
152
+ }
153
+ }
154
+
155
+ return {
156
+ resolve,
157
+ get port() {
158
+ return port
159
+ },
160
+ /** Forget the cached port so the next `resolve()` sweeps again. */
161
+ invalidate() {
162
+ if (pinned) return
163
+ resolvedAt = 0
164
+ },
165
+ }
166
+ }
package/lib/index.js ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * dsh-remote — Host-side plugin that makes the DSH host reachable from the DSH Remote app.
3
+ *
4
+ * `apply()` starts three long-lived things inside the host process:
5
+ * 1. a bridge that rewrites Host to a fixed authority (so one signed cookie stays valid),
6
+ * 2. a tunnel (ngrok if present, otherwise an auto-downloaded cloudflared quick tunnel),
7
+ * 3. a status file with the LAN URL, public URL and a freshly minted 30-day cookie.
8
+ *
9
+ * Everything is torn down when the plugin is disposed, so disabling it in the profile leaves no
10
+ * orphan listeners or child processes behind.
11
+ *
12
+ * @module lib/index
13
+ */
14
+
15
+ import { start } from './supervisor.js'
16
+
17
+ export const name = 'dsh-remote-hub'
18
+
19
+ export const version = '0.1.0'
20
+
21
+ /** No host services are required: the plugin only needs Node builtins. */
22
+ export const inject = []
23
+
24
+ /**
25
+ * @param {object} ctx cordis plugin context
26
+ * @param {object} [config] see cordis.patch.yml
27
+ */
28
+ export function apply(ctx, config = {}) {
29
+ const log = (msg) => {
30
+ try {
31
+ if (typeof ctx?.logger?.info === 'function') ctx.logger.info(msg)
32
+ else console.log(msg)
33
+ } catch {
34
+ /* logging must never break startup */
35
+ }
36
+ }
37
+
38
+ let closed = false
39
+ /** @type {{close: () => Promise<void>} | null} */
40
+ let handle = null
41
+
42
+ const dispose = async () => {
43
+ if (closed) return
44
+ closed = true
45
+ try {
46
+ await handle?.close()
47
+ } catch (err) {
48
+ log(`dsh-remote: shutdown failed: ${err?.message ?? err}`)
49
+ }
50
+ }
51
+
52
+ start({ ...config, log })
53
+ .then((started) => {
54
+ handle = started
55
+ if (closed) return handle.close()
56
+ return undefined
57
+ })
58
+ .catch((err) => log(`dsh-remote: startup failed: ${err?.message ?? err}`))
59
+
60
+ // cordis accepts a disposer as the return value of apply().
61
+ return dispose
62
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Supervisor: cookie + discovery + bridge + tunnel, and the status file the app/CLI reads.
3
+ *
4
+ * Everything it produces lives under `~/.dsh/dsh-remote/`:
5
+ * status.json machine-readable (the CLI and the Android app's "pair" flow read this)
6
+ * connect.txt human-readable, the two lines you copy into the phone
7
+ * dsh-remote.log rolling log
8
+ *
9
+ * @module lib/supervisor
10
+ */
11
+
12
+ import fs from 'node:fs'
13
+ import os from 'node:os'
14
+ import path from 'node:path'
15
+
16
+ import { startBridge } from './bridge.js'
17
+ import { cookieExpiry, mintCookie } from './cookie.js'
18
+ import { createDiscovery } from './discover.js'
19
+ import { startTunnel } from './tunnel.js'
20
+
21
+ export const DATA_DIR = path.join(os.homedir(), '.dsh', 'dsh-remote')
22
+ export const STATUS_FILE = path.join(DATA_DIR, 'status.json')
23
+ export const CONNECT_FILE = path.join(DATA_DIR, 'connect.txt')
24
+ export const LOG_FILE = path.join(DATA_DIR, 'dsh-remote.log')
25
+
26
+ function ensureDir() {
27
+ fs.mkdirSync(DATA_DIR, { recursive: true })
28
+ }
29
+
30
+ function appendLog(line) {
31
+ try {
32
+ ensureDir()
33
+ fs.appendFileSync(LOG_FILE, `${new Date().toISOString()} ${line}\n`, 'utf8')
34
+ } catch {}
35
+ }
36
+
37
+ /**
38
+ * LAN address a phone on the same network can reach.
39
+ *
40
+ * VMs and container runtimes add private adapters (WSL/vEthernet 172.x, Docker, VMware, ...) that
41
+ * a phone on the Wi-Fi cannot route to, so real 192.168/10.x interfaces are ranked first and the
42
+ * virtual ones are penalised by name.
43
+ */
44
+ export function lanAddress() {
45
+ const nets = os.networkInterfaces()
46
+ const VIRTUAL = /vEthernet|WSL|Docker|Hyper-V|VMware|VirtualBox|Loopback|Tunnel|Tailscale|ZeroTier/i
47
+ const scored = []
48
+ for (const [name, addrs] of Object.entries(nets)) {
49
+ for (const a of addrs || []) {
50
+ if (a.family !== 'IPv4' || a.internal) continue
51
+ const isPrivate = /^192\.168\.|^10\.|^172\.(1[6-9]|2\d|3[01])\./.test(a.address)
52
+ if (!isPrivate) continue
53
+ let score = 0
54
+ if (/^192\.168\./.test(a.address)) score += 40
55
+ else if (/^10\./.test(a.address)) score += 20
56
+ else score += 5 // 172.16/12 is dominated by virtual adapters in practice
57
+ if (/wi-?fi|wlan|WLAN|en0|Ethernet|以太网/i.test(name)) score += 10
58
+ if (VIRTUAL.test(name)) score -= 60
59
+ scored.push({ name, address: a.address, score })
60
+ }
61
+ }
62
+ scored.sort((a, b) => b.score - a.score)
63
+ const best = scored[0]
64
+ return best && best.score > 0 ? best.address : (best?.address ?? null)
65
+ }
66
+
67
+ /** Read the status file written by a previous run, or null. */
68
+ export function readStatus() {
69
+ try {
70
+ return JSON.parse(fs.readFileSync(STATUS_FILE, 'utf8'))
71
+ } catch {
72
+ return null
73
+ }
74
+ }
75
+
76
+ function writeStatus(status) {
77
+ ensureDir()
78
+ fs.writeFileSync(STATUS_FILE, JSON.stringify(status, null, 2), 'utf8')
79
+ const lines = [
80
+ '',
81
+ '======================= 填进手机 App =======================',
82
+ '',
83
+ '【同一 WiFi】DSH 地址:',
84
+ ` ${status.lan.url ?? '(未检测到局域网 IP)'}`,
85
+ '',
86
+ ]
87
+ if (status.public?.url) {
88
+ lines.push('【人在外面】DSH 地址:', ` ${status.public.url}`, '')
89
+ } else if (status.public?.note) {
90
+ lines.push('【人在外面】不可用:', ` ${status.public.note}`, '')
91
+ }
92
+ lines.push(
93
+ '【Cookie】粘到 App 的 Cookie 框(30 天有效):',
94
+ ` ${status.cookie ?? '(未生成)'}`,
95
+ '',
96
+ '电脑端保持 DSH 运行;关闭 = 手机断开。',
97
+ '==============================================================',
98
+ '',
99
+ )
100
+ fs.writeFileSync(CONNECT_FILE, lines.join(os.EOL), 'utf8')
101
+ }
102
+
103
+ /**
104
+ * Start everything.
105
+ *
106
+ * @param {object} config
107
+ * @param {boolean} [config.enabled=true]
108
+ * @param {number} [config.port=6300]
109
+ * @param {string} [config.authority='127.0.0.1:1815']
110
+ * @param {'auto'|'off'|'ngrok'|'cloudflared'} [config.tunnel='auto']
111
+ * @param {number} [config.upstreamPort=0]
112
+ * @param {string} [config.domain='']
113
+ * @param {(msg: string) => void} [config.log]
114
+ */
115
+ export async function start(config = {}) {
116
+ const {
117
+ enabled = true,
118
+ port = 6300,
119
+ authority = '127.0.0.1:1815',
120
+ tunnel: tunnelKind = 'auto',
121
+ upstreamPort = 0,
122
+ domain = '',
123
+ log = appendLog,
124
+ } = config
125
+
126
+ if (!enabled) {
127
+ log('dsh-remote: disabled by config, nothing started')
128
+ return { status: null, close: async () => {} }
129
+ }
130
+
131
+ let cookie
132
+ let expiresAt
133
+ try {
134
+ const minted = mintCookie(authority, { days: 30 })
135
+ cookie = minted.cookie
136
+ expiresAt = minted.expiresAt
137
+ } catch (err) {
138
+ log(`dsh-remote: cannot mint cookie: ${err.message}`)
139
+ const status = {
140
+ updatedAt: Date.now(),
141
+ error: `cookie: ${err.message}`,
142
+ lan: { url: null, ip: lanAddress() },
143
+ public: { url: null, kind: 'none', note: 'no cookie — open DSH once, then restart' },
144
+ cookie: null,
145
+ }
146
+ writeStatus(status)
147
+ return { status, close: async () => {} }
148
+ }
149
+
150
+ // The bridge listens on `port`; probing it would forward to itself, so it must be excluded
151
+ // from upstream discovery.
152
+ const discovery = createDiscovery({
153
+ authority,
154
+ cookie: () => cookie,
155
+ pinned: Number(upstreamPort) || 0,
156
+ exclude: [Number(port) || 6300],
157
+ })
158
+ const initialPort = await discovery.resolve(true)
159
+ if (initialPort) log(`dsh-remote: upstream host on 127.0.0.1:${initialPort}`)
160
+ else log('dsh-remote: no upstream host found yet; the bridge will keep retrying')
161
+
162
+ const bridge = await startBridge({
163
+ listenPort: Number(port) || 6300,
164
+ authority,
165
+ upstream: discovery,
166
+ log,
167
+ })
168
+
169
+ const tunnel = await startTunnel({
170
+ kind: tunnelKind,
171
+ bridgePort: bridge.port,
172
+ authority,
173
+ domain,
174
+ log,
175
+ })
176
+
177
+ const ip = lanAddress()
178
+ const status = {
179
+ updatedAt: Date.now(),
180
+ bridgePort: bridge.port,
181
+ authority,
182
+ upstreamPort: discovery.port || null,
183
+ lan: { ip, url: ip ? `http://${ip}:${bridge.port}` : null },
184
+ public: { url: tunnel.publicUrl, kind: tunnel.kind, ...(tunnel.note ? { note: tunnel.note } : {}) },
185
+ cookie,
186
+ cookieExpiresAt: expiresAt ?? cookieExpiry(cookie),
187
+ }
188
+ writeStatus(status)
189
+
190
+ log(`dsh-remote: LAN ${status.lan.url ?? '(no private IPv4)'}`)
191
+ log(`dsh-remote: WAN ${status.public.url ?? `(none — ${status.public.note ?? tunnelKind})`}`)
192
+ log(`dsh-remote: status written to ${STATUS_FILE}`)
193
+
194
+ return {
195
+ status,
196
+ close: async () => {
197
+ try {
198
+ tunnel.close()
199
+ } catch {}
200
+ try {
201
+ await bridge.close()
202
+ } catch {}
203
+ log('dsh-remote: stopped')
204
+ },
205
+ }
206
+ }
package/lib/tunnel.js ADDED
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Tunnel management: put the local bridge on a public URL.
3
+ *
4
+ * Order of preference:
5
+ * 1. `ngrok` — a stable, account-backed domain. Detected on PATH and in the usual install dirs;
6
+ * the authtoken must already be configured (`ngrok config add-authtoken`).
7
+ * 2. `cloudflared` — account-less quick tunnel. Auto-downloaded from a mirror chain when missing,
8
+ * and every download is verified by Authenticode (`Cloudflare, Inc.`) before it is used:
9
+ * a mirror can hand back anything, but it cannot forge Cloudflare's signature.
10
+ * 3. No tunnel — the LAN URL still works, so this is a warning, never a hard failure.
11
+ *
12
+ * @module lib/tunnel
13
+ */
14
+
15
+ import fs from 'node:fs'
16
+ import os from 'node:os'
17
+ import path from 'node:path'
18
+ import { spawn, execFile, execFileSync } from 'node:child_process'
19
+
20
+ const DATA_DIR = path.join(os.homedir(), '.dsh', 'dsh-remote')
21
+ const CLOUDFLARED_EXE = path.join(DATA_DIR, process.platform === 'win32' ? 'cloudflared.exe' : 'cloudflared')
22
+ const URL_RE = /https:\/\/[a-z0-9][a-z0-9-]*\.(?:trycloudflare\.com|ngrok-free\.(?:dev|app)|ngrok\.(?:io|app))/i
23
+
24
+ const CLOUDFLARED_URLS = [
25
+ 'https://ghfast.top/https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe',
26
+ 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe',
27
+ 'https://gh-proxy.com/https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe',
28
+ ]
29
+
30
+ function exists(p) {
31
+ try {
32
+ return fs.existsSync(p)
33
+ } catch {
34
+ return false
35
+ }
36
+ }
37
+
38
+ /** Resolve a command name to an absolute path, or null. */
39
+ function which(cmd) {
40
+ try {
41
+ const out = execFileSync(process.platform === 'win32' ? 'where' : 'which', [cmd], {
42
+ timeout: 5000,
43
+ windowsHide: true,
44
+ encoding: 'utf8',
45
+ stdio: ['ignore', 'pipe', 'ignore'],
46
+ })
47
+ const first = String(out)
48
+ .split(String.fromCharCode(10))
49
+ .map((l) => l.trim())
50
+ .filter(Boolean)[0]
51
+ return first || null
52
+ } catch {
53
+ return null
54
+ }
55
+ }
56
+
57
+ /** Locate an installed tunel binary, or null. */
58
+ export function findNgrok() {
59
+ const candidates = [
60
+ process.platform === 'win32' ? path.join(process.env.LOCALAPPDATA || '', 'ngrok', 'ngrok.exe') : null,
61
+ path.join(DATA_DIR, process.platform === 'win32' ? 'ngrok.exe' : 'ngrok'),
62
+ process.platform === 'win32' ? 'ngrok.exe' : 'ngrok',
63
+ ].filter(Boolean)
64
+ for (const c of candidates) {
65
+ if (exists(c)) return c
66
+ }
67
+ const onPath = which('ngrok')
68
+ return onPath && exists(onPath) ? onPath : null
69
+ }
70
+
71
+ export function findCloudflared() {
72
+ const candidates = [CLOUDFLARED_EXE, path.join(DATA_DIR, 'cloudflared.exe')]
73
+ for (const c of candidates) {
74
+ if (exists(c)) return c
75
+ }
76
+ const onPath = which('cloudflared')
77
+ return onPath && exists(onPath) ? onPath : null
78
+ }
79
+
80
+ /** Does the binary carry a valid Authenticode signature from Cloudflare, Inc.? */
81
+ export async function verifyCloudflaredSignature(exePath) {
82
+ if (process.platform !== 'win32') return true // signed differently (or not at all) off Windows
83
+ const script =
84
+ `$s = Get-AuthenticodeSignature -FilePath '${exePath.replace(/'/g, "''")}';` +
85
+ `if ($s.Status -ne 'Valid') { 'INVALID' } else { $s.SignerCertificate.Subject }`
86
+ const out = await new Promise((resolve) => {
87
+ execFile('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], { timeout: 30_000, windowsHide: true }, (err, stdout) => {
88
+ resolve(err ? '' : String(stdout).trim())
89
+ })
90
+ })
91
+ if (!out || out === 'INVALID') return false
92
+ return /Cloudflare/i.test(out) && /Inc/i.test(out)
93
+ }
94
+
95
+ async function downloadFile(url, dest) {
96
+ const res = await fetch(url, { redirect: 'follow' })
97
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
98
+ const buf = Buffer.from(await res.arrayBuffer())
99
+ if (buf.length < 20 * 1024 * 1024) throw new Error(`suspiciously small (${buf.length} bytes)`)
100
+ fs.writeFileSync(dest, buf)
101
+ return buf.length
102
+ }
103
+
104
+ /** Download cloudflared (mirror chain) and verify its signature. Returns the path or null. */
105
+ export async function ensureCloudflared({ log = () => {} } = {}) {
106
+ const existing = findCloudflared()
107
+ if (existing) return existing
108
+ fs.mkdirSync(DATA_DIR, { recursive: true })
109
+ for (const url of CLOUDFLARED_URLS) {
110
+ log(`downloading cloudflared from ${url}`)
111
+ try {
112
+ // eslint-disable-next-line no-await-in-loop
113
+ await downloadFile(url, CLOUDFLARED_EXE)
114
+ // eslint-disable-next-line no-await-in-loop
115
+ if (await verifyCloudflaredSignature(CLOUDFLARED_EXE)) {
116
+ log('cloudflared downloaded and signature verified (Cloudflare, Inc.)')
117
+ return CLOUDFLARED_EXE
118
+ }
119
+ log('cloudflared signature check failed, discarding and trying the next source')
120
+ fs.rmSync(CLOUDFLARED_EXE, { force: true })
121
+ } catch (err) {
122
+ log(`cloudflared download failed: ${err.message}`)
123
+ fs.rmSync(CLOUDFLARED_EXE, { force: true })
124
+ }
125
+ }
126
+ return null
127
+ }
128
+
129
+ /**
130
+ * Spawn a tunnel and resolve its public URL.
131
+ *
132
+ * @param {object} options
133
+ * @param {'auto'|'off'|'ngrok'|'cloudflared'} [options.kind='auto']
134
+ * @param {number} options.bridgePort
135
+ * @param {string} options.authority
136
+ * @param {string} [options.domain] pinned ngrok domain (e.g. xxxx.ngrok-free.app)
137
+ * @param {(msg: string) => void} [options.log]
138
+ * @returns {Promise<{publicUrl: string|null, kind: string, close: () => void, note?: string}>}
139
+ */
140
+ export async function startTunnel({ kind = 'auto', bridgePort, authority, domain = '', log = () => {} }) {
141
+ if (kind === 'off') return { publicUrl: null, kind: 'none', close: () => {}, note: 'tunnel disabled by config' }
142
+
143
+ const wantNgrok = kind === 'auto' || kind === 'ngrok'
144
+ const wantCloudflared = kind === 'auto' || kind === 'cloudflared'
145
+
146
+ if (wantNgrok) {
147
+ const ngrok = findNgrok()
148
+ if (ngrok) {
149
+ const args = ['http', String(bridgePort), '--host-header', authority, '--log', 'stdout', '--log-format', 'json']
150
+ if (domain) args.push('--url', domain)
151
+ const child = spawn(ngrok, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
152
+ const url = await waitForUrl(child, 45_000)
153
+ if (url) return { publicUrl: url, kind: 'ngrok', close: () => child.kill() }
154
+ child.kill()
155
+ log('ngrok started but produced no URL (authtoken configured?)')
156
+ if (kind === 'ngrok') return { publicUrl: null, kind: 'none', close: () => {}, note: 'ngrok produced no URL' }
157
+ } else if (kind === 'ngrok') {
158
+ return { publicUrl: null, kind: 'none', close: () => {}, note: 'ngrok not installed' }
159
+ }
160
+ }
161
+
162
+ if (wantCloudflared) {
163
+ const cf = await ensureCloudflared({ log })
164
+ if (cf) {
165
+ const child = spawn(cf, ['tunnel', '--url', `http://127.0.0.1:${bridgePort}`, '--no-autoupdate'], {
166
+ windowsHide: true,
167
+ stdio: ['ignore', 'pipe', 'pipe'],
168
+ })
169
+ const url = await waitForUrl(child, 60_000)
170
+ if (url) return { publicUrl: url, kind: 'cloudflared', close: () => child.kill() }
171
+ child.kill()
172
+ log('cloudflared started but produced no URL (api.trycloudflare.com reachable?)')
173
+ return { publicUrl: null, kind: 'none', close: () => {}, note: 'cloudflared produced no URL (network may block it)' }
174
+ }
175
+ return { publicUrl: null, kind: 'none', close: () => {}, note: 'could not obtain cloudflared' }
176
+ }
177
+
178
+ return { publicUrl: null, kind: 'none', close: () => {} }
179
+ }
180
+
181
+ function waitForUrl(child, timeoutMs) {
182
+ return new Promise((resolve) => {
183
+ let done = false
184
+ const finish = (url) => {
185
+ if (done) return
186
+ done = true
187
+ clearTimeout(timer)
188
+ resolve(url)
189
+ }
190
+ const scan = (buf) => {
191
+ const m = URL_RE.exec(String(buf))
192
+ if (m) finish(m[0])
193
+ }
194
+ child.stdout?.on('data', scan)
195
+ child.stderr?.on('data', scan)
196
+ child.on('exit', () => finish(null))
197
+ const timer = setTimeout(() => finish(null), timeoutMs)
198
+ })
199
+ }
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "dsh-remote-hub",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "DSH 插件:把电脑上的 DeepSeek Harness 变成手机可访问的服务——起桥接、自动开隧道(ngrok / cloudflared)、签发 30 天 Cookie,并输出地址与凭据,配合 DSH Remote Android App 使用。",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "bin": {
8
+ "dsh-remote": "bin/dsh-remote.mjs"
9
+ },
10
+ "exports": {
11
+ ".": "./lib/index.js",
12
+ "./package.json": "./package.json"
13
+ },
14
+ "files": [
15
+ "lib",
16
+ "bin",
17
+ "cordis.patch.yml",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "keywords": [
22
+ "deepseek-harness",
23
+ "dsh",
24
+ "dsh-plugin",
25
+ "mobile",
26
+ "remote",
27
+ "tunnel",
28
+ "ngrok",
29
+ "cloudflared",
30
+ "android"
31
+ ],
32
+ "engines": {
33
+ "node": "^22.19.0 || >=24.0.0"
34
+ },
35
+ "license": "MIT",
36
+ "author": "liaoguogang-hub",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/liaoguogang-hub/dsh-remote-public.git"
40
+ },
41
+ "homepage": "https://github.com/liaoguogang-hub/dsh-remote-public",
42
+ "bugs": {
43
+ "url": "https://github.com/liaoguogang-hub/dsh-remote-public/issues"
44
+ },
45
+ "dsh": {
46
+ "bundle": {
47
+ "patch": "./cordis.patch.yml"
48
+ }
49
+ },
50
+ "publishConfig": {
51
+ "registry": "https://registry.npmjs.org",
52
+ "access": "public"
53
+ }
54
+ }