dsh-remote-hub 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -3
- package/bin/dsh-remote.mjs +67 -3
- package/lib/bridge.js +14 -1
- package/lib/index.js +1 -1
- package/lib/pairing-routes.js +119 -0
- package/lib/pairing.js +85 -0
- package/lib/supervisor.js +22 -1
- package/lib/tunnel.js +40 -4
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# DSH Remote — Android 远程控制器
|
|
2
2
|
|
|
3
|
+
[](https://dsh-plugin.org/plugins/liaoguogang-hub/dsh-remote-public)
|
|
4
|
+
|
|
3
5
|
手机原生 App,直接控制电脑上的 **DeepSeek Harness (DSH)**。不是 WebView 套壳:
|
|
4
6
|
Kotlin/Compose 直连 DSH 自带的 HTTP API + WebSocket 网关。
|
|
5
7
|
|
|
@@ -15,6 +17,29 @@ Kotlin/Compose 直连 DSH 自带的 HTTP API + WebSocket 网关。
|
|
|
15
17
|
|
|
16
18
|
功能与 UI 与自用版 4.12.2 完全一致。
|
|
17
19
|
|
|
20
|
+
## 下载手机端 App
|
|
21
|
+
|
|
22
|
+
**DSH Remote v4.19.1** · Android 8.0 (API 26) 及以上 · 2.8 MB
|
|
23
|
+
|
|
24
|
+
[⬇️ 下载 APK](https://github.com/liaoguogang-hub/dsh-remote-public/releases/download/v4.19.1/DSH-Remote-v4.19.1.apk) ·
|
|
25
|
+
[全部版本](https://github.com/liaoguogang-hub/dsh-remote-public/releases)
|
|
26
|
+
|
|
27
|
+
安装后应用名「DSH Remote」,包名 `com.dsh.remote.ngrok`(可与作者的 `DSH` 自用版共存)。
|
|
28
|
+
手机端不通过 npm 分发——npm 包 `dsh-remote-hub` 是电脑侧的 DSH 插件,两者配合使用:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
电脑:dsh plugin --profile desktop add dsh-remote-hub → 起桥接 / 开隧道 / 签 Cookie
|
|
32
|
+
手机:装上面这个 APK → 填电脑给出的地址与 Cookie → 连接
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## 扫码配对(手机端)
|
|
36
|
+
|
|
37
|
+
电脑上 `dsh-remote qr` 打开配对页 → 手机 App「设置 → 扫码配对」扫一下就自动填好地址与凭据,
|
|
38
|
+
不用手抄 Cookie。
|
|
39
|
+
|
|
40
|
+
二维码里**不含 Cookie**:只含一个 **10 分钟有效、用一次就作废**的配对码。
|
|
41
|
+
相机不可用时可以点「手动输入」,填地址 + 配对码同样能连上。
|
|
42
|
+
|
|
18
43
|
## 功能
|
|
19
44
|
|
|
20
45
|
| 能力 | 状态 |
|
|
@@ -157,10 +182,12 @@ node mint.mjs "127.0.0.1:1815" 30 # 签发 30 天 Cookie
|
|
|
157
182
|
并把地址与 Cookie 写进 `~/.dsh/dsh-remote/`,手机 App 直接抄。
|
|
158
183
|
|
|
159
184
|
```bash
|
|
160
|
-
# 从 NPM
|
|
161
|
-
|
|
185
|
+
# 从 NPM 安装(推荐,会出现在 DSH 内的「插件中心」)
|
|
186
|
+
# https://www.npmjs.com/package/dsh-remote-hub
|
|
187
|
+
dsh plugin --profile desktop add dsh-remote-hub
|
|
188
|
+
dsh plugin --profile web add dsh-remote-hub # web profile 同样可用
|
|
162
189
|
|
|
163
|
-
#
|
|
190
|
+
# 或直接从本仓库源码安装(不经过 NPM)
|
|
164
191
|
dsh plugin --profile web add github:liaoguogang-hub/dsh-remote-public
|
|
165
192
|
```
|
|
166
193
|
|
|
@@ -174,6 +201,8 @@ dsh plugin --profile web add github:liaoguogang-hub/dsh-remote-public
|
|
|
174
201
|
查看结果:
|
|
175
202
|
|
|
176
203
|
```bash
|
|
204
|
+
dsh-remote qr # 打开配对页(二维码),手机扫一下就自动填好
|
|
205
|
+
dsh-remote start --open # 起桥接并打印地址/Cookie,同时打开二维码页(connect.cmd 用的就是它)
|
|
177
206
|
dsh-remote status # 局域网地址、公网地址、Cookie、上游端口
|
|
178
207
|
dsh-remote connect # 直接打印要抄进手机的两行
|
|
179
208
|
dsh-remote doctor # 逐项自检:凭据、ngrok、cloudflared、宿主端口
|
|
@@ -192,3 +221,29 @@ dsh-remote start # 不开 DSH 也能单独前台跑(Ctrl+C 退出)
|
|
|
192
221
|
|
|
193
222
|
> 公网隧道受网络影响:`api.trycloudflare.com` 不可达时插件会跳过公网、只给局域网地址,
|
|
194
223
|
> 并在 `status.json` 里写明原因,不会假装成功。
|
|
224
|
+
|
|
225
|
+
### 扫码配对(一次性配对码)
|
|
226
|
+
|
|
227
|
+
不想手抄 Cookie 就用二维码:
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
dsh-remote qr
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
浏览器会打开 `http://127.0.0.1:6300/_dsh-remote/pair`,显示一个二维码。手机 DSH Remote 里点
|
|
234
|
+
「扫码配对」,扫完自动填入地址并换取登录凭据,直接连上。
|
|
235
|
+
|
|
236
|
+
扫码后会自动判断:返回的是私有地址就填进「局域网」,公网地址填进「自定义」。
|
|
237
|
+
|
|
238
|
+
安全设计:
|
|
239
|
+
- 二维码里**不含 Cookie**,只含一个 **10 分钟有效的一次性配对码**(`{"v":1,"u":"<地址>","c":"<配对码>","e":<过期时间>}`);
|
|
240
|
+
- 兑换成功即作废,截图/偷拍泄漏的是一个立刻失效的码,而不是 30 天访问权;
|
|
241
|
+
- 配对码只存在内存里,插件重启即全部失效(失效安全)。
|
|
242
|
+
|
|
243
|
+
端点(都在桥接上,不经 DSH):
|
|
244
|
+
|
|
245
|
+
| 方法 | 路径 | 作用 |
|
|
246
|
+
|---|---|---|
|
|
247
|
+
| GET | `/_dsh-remote/pair` | 配对页(二维码 HTML) |
|
|
248
|
+
| GET | `/_dsh-remote/pair.svg` | 只要二维码 SVG |
|
|
249
|
+
| POST | `/_dsh-remote/pair/redeem` | `{code}` → `{url, cookie}`;用过即失效 |
|
package/bin/dsh-remote.mjs
CHANGED
|
@@ -5,14 +5,16 @@
|
|
|
5
5
|
* dsh-remote status show LAN URL, public URL, cookie and upstream port
|
|
6
6
|
* dsh-remote connect print the two lines to paste into the phone
|
|
7
7
|
* dsh-remote cookie print only the cookie
|
|
8
|
-
* dsh-remote start
|
|
8
|
+
* dsh-remote start [--open] run the supervisor in the foreground; --open 打印地址并打开配对页
|
|
9
9
|
* dsh-remote stop ask a foreground `start` to stop (best effort, via status file)
|
|
10
10
|
* dsh-remote doctor check every prerequisite in one pass
|
|
11
|
+
* dsh-remote qr open the pairing page (QR) in the default browser
|
|
11
12
|
*
|
|
12
13
|
* @module bin/dsh-remote
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
import fs from 'node:fs'
|
|
17
|
+
import { execFile } from 'node:child_process'
|
|
16
18
|
import os from 'node:os'
|
|
17
19
|
import path from 'node:path'
|
|
18
20
|
|
|
@@ -45,6 +47,7 @@ function printStatus() {
|
|
|
45
47
|
console.log(` 桥接端口 ${status.bridgePort}`)
|
|
46
48
|
console.log(` DSH 上游 ${status.upstreamPort ? '127.0.0.1:' + status.upstreamPort : '未找到'}`)
|
|
47
49
|
console.log(` 更新时间 ${new Date(status.updatedAt).toLocaleString()}`)
|
|
50
|
+
if (status.bridgePort) console.log(` 扫码配对 dsh-remote qr (或 http://127.0.0.1:${status.bridgePort}/_dsh-remote/pair)`)
|
|
48
51
|
if (status.cookie) {
|
|
49
52
|
console.log(` Cookie 到期 ${fmtTime(status.cookieExpiresAt)}${remaining !== null ? `(还剩 ${remaining} 天)` : ''}`)
|
|
50
53
|
console.log('')
|
|
@@ -58,6 +61,39 @@ function printStatus() {
|
|
|
58
61
|
console.log('')
|
|
59
62
|
}
|
|
60
63
|
|
|
64
|
+
function openBrowser(url) {
|
|
65
|
+
const cmd = process.platform === 'win32' ? 'cmd' : process.platform === 'darwin' ? 'open' : 'xdg-open'
|
|
66
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
execFile(cmd, args, { windowsHide: true }, () => resolve())
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Point the browser at the running bridge's pairing page (port comes from status, not a constant). */
|
|
73
|
+
async function openPairPage(bridgePort) {
|
|
74
|
+
const page = `http://127.0.0.1:${bridgePort}/_dsh-remote/pair`
|
|
75
|
+
console.log('')
|
|
76
|
+
console.log(' 配对页:', page)
|
|
77
|
+
console.log(' 手机点右上角扫码按钮,扫这个二维码即可(10 分钟内有效,用一次作废)')
|
|
78
|
+
console.log('')
|
|
79
|
+
await openBrowser(page)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function qrCommand() {
|
|
83
|
+
const status = readStatus()
|
|
84
|
+
const port = status?.bridgePort ?? Number(process.env.DSH_REMOTE_PORT || 6300)
|
|
85
|
+
const page = `http://127.0.0.1:${port}/_dsh-remote/pair`
|
|
86
|
+
console.log('')
|
|
87
|
+
console.log(' 配对页:', page)
|
|
88
|
+
console.log(' 用手机 DSH Remote 的「扫码配对」扫屏幕上的二维码(10 分钟内有效,只能用一次)')
|
|
89
|
+
console.log('')
|
|
90
|
+
if (process.argv.includes('--no-open')) {
|
|
91
|
+
console.log(' (--no-open:未自动打开浏览器)')
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
await openBrowser(page)
|
|
95
|
+
}
|
|
96
|
+
|
|
61
97
|
async function doctor() {
|
|
62
98
|
console.log('')
|
|
63
99
|
console.log(' dsh-remote 自检')
|
|
@@ -117,8 +153,36 @@ async function main() {
|
|
|
117
153
|
case 'doctor':
|
|
118
154
|
await doctor()
|
|
119
155
|
break
|
|
156
|
+
case 'qr':
|
|
157
|
+
case 'pair':
|
|
158
|
+
await qrCommand()
|
|
159
|
+
break
|
|
120
160
|
case 'start': {
|
|
121
|
-
const
|
|
161
|
+
const wantOpen = process.argv.includes('--open')
|
|
162
|
+
let handle
|
|
163
|
+
try {
|
|
164
|
+
handle = await start({ log: (m) => console.log(m) })
|
|
165
|
+
} catch (err) {
|
|
166
|
+
// Port already taken: another bridge is serving; just show its pairing page.
|
|
167
|
+
if (/EADDRINUSE/.test(String(err?.message ?? err))) {
|
|
168
|
+
const running = readStatus()
|
|
169
|
+
const port = running?.bridgePort
|
|
170
|
+
if (port) {
|
|
171
|
+
console.log(`已有桥接在 127.0.0.1:${port} 运行,直接复用它`)
|
|
172
|
+
if (wantOpen) await openPairPage(port)
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
throw err
|
|
177
|
+
}
|
|
178
|
+
const status = handle.status
|
|
179
|
+
if (status?.cookie) {
|
|
180
|
+
console.log('')
|
|
181
|
+
console.log(' DSH 地址(局域网):', status.lan?.url ?? '(未检测到局域网 IP)')
|
|
182
|
+
console.log(' DSH 地址(公网) :', status.public?.url ?? `无(${status.public?.note ?? '未开启'})`)
|
|
183
|
+
console.log(' Cookie :', status.cookie)
|
|
184
|
+
}
|
|
185
|
+
if (wantOpen) await openPairPage(status?.bridgePort ?? Number(process.env.DSH_REMOTE_PORT || 6300))
|
|
122
186
|
process.on('SIGINT', async () => {
|
|
123
187
|
await handle.close()
|
|
124
188
|
process.exit(0)
|
|
@@ -130,7 +194,7 @@ async function main() {
|
|
|
130
194
|
break
|
|
131
195
|
default:
|
|
132
196
|
console.log(`未知命令:${command}`)
|
|
133
|
-
console.log('可用:status | connect | cookie | doctor | start | stop')
|
|
197
|
+
console.log('可用:status | connect | cookie | qr | doctor | start | stop')
|
|
134
198
|
process.exitCode = 1
|
|
135
199
|
}
|
|
136
200
|
}
|
package/lib/bridge.js
CHANGED
|
@@ -20,6 +20,8 @@ import http from 'node:http'
|
|
|
20
20
|
import net from 'node:net'
|
|
21
21
|
import zlib from 'node:zlib'
|
|
22
22
|
|
|
23
|
+
import { handlePairing } from './pairing-routes.js'
|
|
24
|
+
|
|
23
25
|
const LIST_PATH = '/api/session/list'
|
|
24
26
|
|
|
25
27
|
function rewriteHeaders(headers, authority) {
|
|
@@ -66,7 +68,7 @@ function trimSessionList(body) {
|
|
|
66
68
|
* @param {(msg: string) => void} [options.log]
|
|
67
69
|
* @returns {Promise<{close: () => Promise<void>, port: number}>}
|
|
68
70
|
*/
|
|
69
|
-
export function startBridge({ listenPort, authority, upstream, log = () => {} }) {
|
|
71
|
+
export function startBridge({ listenPort, authority, upstream, pairing = null, log = () => {} }) {
|
|
70
72
|
const forward = (req, res, attempt = 0) =>
|
|
71
73
|
new Promise((resolve) => {
|
|
72
74
|
upstream.resolve(attempt > 0).then((port) => {
|
|
@@ -147,6 +149,17 @@ export function startBridge({ listenPort, authority, upstream, log = () => {} })
|
|
|
147
149
|
})
|
|
148
150
|
|
|
149
151
|
const server = http.createServer((req, res) => {
|
|
152
|
+
// Pairing is served by the bridge itself: it must work before the phone has any credential,
|
|
153
|
+
// and it must NOT be proxied to DSH.
|
|
154
|
+
if (pairing && String(req.url || '').startsWith('/_dsh-remote/pair')) {
|
|
155
|
+
handlePairing({ req, res, current: pairing.current, store: pairing.store }).catch(() => {
|
|
156
|
+
try {
|
|
157
|
+
res.writeHead(500)
|
|
158
|
+
res.end('dsh-remote: pairing error')
|
|
159
|
+
} catch {}
|
|
160
|
+
})
|
|
161
|
+
return
|
|
162
|
+
}
|
|
150
163
|
forward(req, res).catch(() => {
|
|
151
164
|
try {
|
|
152
165
|
res.writeHead(502)
|
package/lib/index.js
CHANGED
|
@@ -16,7 +16,7 @@ import { start } from './supervisor.js'
|
|
|
16
16
|
|
|
17
17
|
export const name = 'dsh-remote-hub'
|
|
18
18
|
|
|
19
|
-
export const version = '0.1
|
|
19
|
+
export const version = '0.2.1'
|
|
20
20
|
|
|
21
21
|
/** No host services are required: the plugin only needs Node builtins. */
|
|
22
22
|
export const inject = []
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pairing endpoints served by the bridge itself (never proxied to DSH).
|
|
3
|
+
*
|
|
4
|
+
* GET /_dsh-remote/pair → HTML page with a QR code and the address text
|
|
5
|
+
* GET /_dsh-remote/pair.svg → the same QR as a standalone SVG (for embedding)
|
|
6
|
+
* POST /_dsh-remote/pair/redeem → {code} → {publicUrl?, lanUrl, cookie, expiresAt}
|
|
7
|
+
*
|
|
8
|
+
* The QR payload is deliberately small and secret-free:
|
|
9
|
+
* {"v":1,"u":"https://xxx.trycloudflare.com","c":"AB12CD34EF","e":1767000000000}
|
|
10
|
+
*
|
|
11
|
+
* @module lib/pairing-routes
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import QRCode from 'qrcode'
|
|
15
|
+
|
|
16
|
+
export const PAIR_PREFIX = '/_dsh-remote/pair'
|
|
17
|
+
|
|
18
|
+
/** Build the payload the phone parses. `u` is the URL the phone should talk to. */
|
|
19
|
+
export function buildPairPayload({ url, code, expiresAt }) {
|
|
20
|
+
return JSON.stringify({ v: 1, u: url, c: code, e: expiresAt })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Handle a pairing request.
|
|
25
|
+
*
|
|
26
|
+
* @param {object} deps
|
|
27
|
+
* @param {import('node:http').IncomingMessage} deps.req
|
|
28
|
+
* @param {import('node:http').ServerResponse} deps.res
|
|
29
|
+
* @param {() => {url: string, cookie: string} | null} deps.current latest URL + cookie
|
|
30
|
+
* @param {ReturnType<typeof import('./pairing').createPairingStore>} deps.store
|
|
31
|
+
* @returns {Promise<boolean>} true when the request was handled here
|
|
32
|
+
*/
|
|
33
|
+
export async function handlePairing({ req, res, current, store }) {
|
|
34
|
+
const url = String(req.url || '')
|
|
35
|
+
if (!url.startsWith(PAIR_PREFIX)) return false
|
|
36
|
+
|
|
37
|
+
const snapshot = current()
|
|
38
|
+
if (!snapshot) {
|
|
39
|
+
res.writeHead(503, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
40
|
+
res.end(JSON.stringify({ ok: false, reason: 'not-ready' }))
|
|
41
|
+
return true
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (url === PAIR_PREFIX + '/redeem' && req.method === 'POST') {
|
|
45
|
+
let body = ''
|
|
46
|
+
await new Promise((resolve) => {
|
|
47
|
+
req.on('data', (chunk) => {
|
|
48
|
+
body += chunk
|
|
49
|
+
if (body.length > 4096) req.destroy()
|
|
50
|
+
})
|
|
51
|
+
req.on('end', resolve)
|
|
52
|
+
req.on('error', resolve)
|
|
53
|
+
})
|
|
54
|
+
let code = ''
|
|
55
|
+
try {
|
|
56
|
+
code = JSON.parse(body || '{}').code ?? ''
|
|
57
|
+
} catch {
|
|
58
|
+
/* fall through to the unknown-code branch */
|
|
59
|
+
}
|
|
60
|
+
const result = store.redeem(code)
|
|
61
|
+
if (!result.ok) {
|
|
62
|
+
res.writeHead(410, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
63
|
+
res.end(JSON.stringify({ ok: false, reason: result.reason }))
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
67
|
+
res.end(
|
|
68
|
+
JSON.stringify({
|
|
69
|
+
ok: true,
|
|
70
|
+
url: snapshot.url,
|
|
71
|
+
cookie: result.cookie,
|
|
72
|
+
issuedAt: result.issuedAt,
|
|
73
|
+
}),
|
|
74
|
+
)
|
|
75
|
+
return true
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Any other GET under the prefix hands out a fresh code.
|
|
79
|
+
const { code, expiresAt, ttlMs } = store.issue(snapshot.cookie)
|
|
80
|
+
const payload = buildPairPayload({ url: snapshot.url, code, expiresAt })
|
|
81
|
+
|
|
82
|
+
if (url === PAIR_PREFIX + '/svg') {
|
|
83
|
+
const svg = await QRCode.toString(payload, { type: 'svg', margin: 1, width: 320, errorCorrectionLevel: 'M' })
|
|
84
|
+
res.writeHead(200, { 'content-type': 'image/svg+xml; charset=utf-8', 'cache-control': 'no-store' })
|
|
85
|
+
res.end(svg)
|
|
86
|
+
return true
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const svg = await QRCode.toString(payload, { type: 'svg', margin: 2, width: 360, errorCorrectionLevel: 'M' })
|
|
90
|
+
const minutes = Math.round(ttlMs / 60000)
|
|
91
|
+
const html = `<!doctype html>
|
|
92
|
+
<html lang="zh"><head><meta charset="utf-8">
|
|
93
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
94
|
+
<title>DSH Remote 配对</title>
|
|
95
|
+
<style>
|
|
96
|
+
:root { color-scheme: light dark }
|
|
97
|
+
body { font: 15px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif; margin: 0;
|
|
98
|
+
display: flex; min-height: 100vh; align-items: center; justify-content: center;
|
|
99
|
+
background: #0e1116; color: #e8eaed }
|
|
100
|
+
.card { background: #171b22; border: 1px solid #262c36; border-radius: 16px; padding: 28px 32px; text-align: center; max-width: 460px }
|
|
101
|
+
h1 { font-size: 18px; margin: 0 0 4px }
|
|
102
|
+
p { margin: 6px 0; color: #9aa4b2; font-size: 13px }
|
|
103
|
+
.qr { background: #fff; padding: 12px; border-radius: 12px; display: inline-block; margin: 14px 0 10px }
|
|
104
|
+
code { background: #222835; padding: 2px 6px; border-radius: 5px; font-size: 13px; word-break: break-all }
|
|
105
|
+
.addr { font-size: 12px; color: #8b93a1; word-break: break-all; margin-top: 10px }
|
|
106
|
+
.ok { color: #6ee7a8 }
|
|
107
|
+
</style></head>
|
|
108
|
+
<body><div class="card">
|
|
109
|
+
<h1>用 DSH Remote 扫这个码</h1>
|
|
110
|
+
<p>配对码 <code>${code}</code> · ${minutes} 分钟内有效,且只能用一次</p>
|
|
111
|
+
<div class="qr">${svg}</div>
|
|
112
|
+
<p class="ok">扫码后自动填入地址并换取登录凭据</p>
|
|
113
|
+
<p class="addr">本页地址:<br>${snapshot.url}</p>
|
|
114
|
+
<p class="addr">手输备用:打开 App → 设置 → 扫码配对 → 输入配对码</p>
|
|
115
|
+
</div></body></html>`
|
|
116
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
|
|
117
|
+
res.end(html)
|
|
118
|
+
return true
|
|
119
|
+
}
|
package/lib/pairing.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time pairing codes.
|
|
3
|
+
*
|
|
4
|
+
* The QR code the phone scans must never contain the 30-day cookie itself: a photo of the screen
|
|
5
|
+
* would hand out permanent access. Instead the QR carries a short random code with a 10 minute
|
|
6
|
+
* TTL, the bridge keeps code → cookie in memory, and redeeming burns the code.
|
|
7
|
+
*
|
|
8
|
+
* In-memory on purpose: a restart invalidates outstanding codes (fail safe), and nothing
|
|
9
|
+
* sensitive lands on disk.
|
|
10
|
+
*
|
|
11
|
+
* @module lib/pairing
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { randomBytes } from 'node:crypto'
|
|
15
|
+
|
|
16
|
+
const DEFAULT_TTL_MS = 10 * 60 * 1000
|
|
17
|
+
const MAX_ACTIVE = 20
|
|
18
|
+
|
|
19
|
+
/** Crockford-ish alphabet: no look-alike characters, so codes can be read aloud or typed. */
|
|
20
|
+
const ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'
|
|
21
|
+
|
|
22
|
+
function randomCode(length = 10) {
|
|
23
|
+
const bytes = randomBytes(length)
|
|
24
|
+
let out = ''
|
|
25
|
+
for (let i = 0; i < length; i++) out += ALPHABET[bytes[i] % ALPHABET.length]
|
|
26
|
+
return out
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createPairingStore({ ttlMs = DEFAULT_TTL_MS, max = MAX_ACTIVE } = {}) {
|
|
30
|
+
/** @type {Map<string, {cookie: string, expiresAt: number, createdAt: number}>} */
|
|
31
|
+
const codes = new Map()
|
|
32
|
+
|
|
33
|
+
function gc(now = Date.now()) {
|
|
34
|
+
for (const [code, entry] of codes) {
|
|
35
|
+
if (entry.expiresAt <= now) codes.delete(code)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Create a code bound to the current cookie.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} cookie
|
|
43
|
+
* @returns {{code: string, expiresAt: number, ttlMs: number}}
|
|
44
|
+
*/
|
|
45
|
+
function issue(cookie) {
|
|
46
|
+
gc()
|
|
47
|
+
// Drop the oldest entries when the store grows: a phone that scanned an old QR should not
|
|
48
|
+
// keep working forever just because we never expired it.
|
|
49
|
+
while (codes.size >= max) {
|
|
50
|
+
const oldest = codes.keys().next().value
|
|
51
|
+
codes.delete(oldest)
|
|
52
|
+
}
|
|
53
|
+
const now = Date.now()
|
|
54
|
+
const code = randomCode()
|
|
55
|
+
const expiresAt = now + ttlMs
|
|
56
|
+
codes.set(code, { cookie, expiresAt, createdAt: now })
|
|
57
|
+
return { code, expiresAt, ttlMs }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Exchange a code for its cookie. The code is consumed even when it has expired, so a leaked
|
|
62
|
+
* code cannot be retried after the first attempt.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} code
|
|
65
|
+
* @returns {{ok: true, cookie: string, issuedAt: number} | {ok: false, reason: 'unknown'|'expired'}}
|
|
66
|
+
*/
|
|
67
|
+
function redeem(code) {
|
|
68
|
+
if (typeof code !== 'string' || code.length === 0) return { ok: false, reason: 'unknown' }
|
|
69
|
+
const normalized = code.trim().toUpperCase()
|
|
70
|
+
const entry = codes.get(normalized)
|
|
71
|
+
if (!entry) return { ok: false, reason: 'unknown' }
|
|
72
|
+
codes.delete(normalized)
|
|
73
|
+
if (entry.expiresAt <= Date.now()) return { ok: false, reason: 'expired' }
|
|
74
|
+
return { ok: true, cookie: entry.cookie, issuedAt: entry.createdAt }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
issue,
|
|
79
|
+
redeem,
|
|
80
|
+
gc,
|
|
81
|
+
get size() {
|
|
82
|
+
return codes.size
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
}
|
package/lib/supervisor.js
CHANGED
|
@@ -16,6 +16,7 @@ import path from 'node:path'
|
|
|
16
16
|
import { startBridge } from './bridge.js'
|
|
17
17
|
import { cookieExpiry, mintCookie } from './cookie.js'
|
|
18
18
|
import { createDiscovery } from './discover.js'
|
|
19
|
+
import { createPairingStore } from './pairing.js'
|
|
19
20
|
import { startTunnel } from './tunnel.js'
|
|
20
21
|
|
|
21
22
|
export const DATA_DIR = path.join(os.homedir(), '.dsh', 'dsh-remote')
|
|
@@ -159,10 +160,24 @@ export async function start(config = {}) {
|
|
|
159
160
|
if (initialPort) log(`dsh-remote: upstream host on 127.0.0.1:${initialPort}`)
|
|
160
161
|
else log('dsh-remote: no upstream host found yet; the bridge will keep retrying')
|
|
161
162
|
|
|
163
|
+
// Pairing: the QR carries a one-time code, not the cookie; redeeming burns it.
|
|
164
|
+
const pairingStore = createPairingStore()
|
|
165
|
+
const pairing = {
|
|
166
|
+
store: pairingStore,
|
|
167
|
+
// `current()` is read at redeem time so a URL that appears later (tunnel is still coming up)
|
|
168
|
+
// is still handed to the phone.
|
|
169
|
+
current: () => ({ url: lastPublicUrl ?? (ipForPairing ? `http://${ipForPairing}:${Number(port) || 6300}` : ''), cookie }),
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const ipForPairing = lanAddress()
|
|
173
|
+
/** Public URL is filled in after the tunnel starts; pairing reads it lazily. */
|
|
174
|
+
let lastPublicUrl = null
|
|
175
|
+
|
|
162
176
|
const bridge = await startBridge({
|
|
163
177
|
listenPort: Number(port) || 6300,
|
|
164
178
|
authority,
|
|
165
179
|
upstream: discovery,
|
|
180
|
+
pairing,
|
|
166
181
|
log,
|
|
167
182
|
})
|
|
168
183
|
|
|
@@ -174,7 +189,8 @@ export async function start(config = {}) {
|
|
|
174
189
|
log,
|
|
175
190
|
})
|
|
176
191
|
|
|
177
|
-
|
|
192
|
+
lastPublicUrl = tunnel.publicUrl
|
|
193
|
+
const ip = ipForPairing
|
|
178
194
|
const status = {
|
|
179
195
|
updatedAt: Date.now(),
|
|
180
196
|
bridgePort: bridge.port,
|
|
@@ -184,6 +200,11 @@ export async function start(config = {}) {
|
|
|
184
200
|
public: { url: tunnel.publicUrl, kind: tunnel.kind, ...(tunnel.note ? { note: tunnel.note } : {}) },
|
|
185
201
|
cookie,
|
|
186
202
|
cookieExpiresAt: expiresAt ?? cookieExpiry(cookie),
|
|
203
|
+
pairing: {
|
|
204
|
+
// Prefer the public URL: a phone on 4G cannot reach the LAN address.
|
|
205
|
+
url: tunnel.publicUrl ?? (ip ? `http://${ip}:${bridge.port}` : null),
|
|
206
|
+
page: `/_dsh-remote/pair`,
|
|
207
|
+
},
|
|
187
208
|
}
|
|
188
209
|
writeStatus(status)
|
|
189
210
|
|
package/lib/tunnel.js
CHANGED
|
@@ -56,10 +56,14 @@ function which(cmd) {
|
|
|
56
56
|
|
|
57
57
|
/** Locate an installed tunel binary, or null. */
|
|
58
58
|
export function findNgrok() {
|
|
59
|
+
const exe = process.platform === 'win32' ? 'ngrok.exe' : 'ngrok'
|
|
59
60
|
const candidates = [
|
|
60
|
-
process.platform === 'win32' ? path.join(process.env.LOCALAPPDATA || '', 'ngrok',
|
|
61
|
-
path.join(DATA_DIR,
|
|
62
|
-
process.
|
|
61
|
+
process.platform === 'win32' ? path.join(process.env.LOCALAPPDATA || '', 'ngrok', exe) : null,
|
|
62
|
+
path.join(DATA_DIR, exe),
|
|
63
|
+
path.join(process.cwd(), 'tools', exe),
|
|
64
|
+
path.join(os.homedir(), 'Documents', 'DSH', 'DshRemote', 'tools', exe),
|
|
65
|
+
path.join(os.homedir(), 'Documents', 'DSH', '.dsh-apk-research', 'tools', exe),
|
|
66
|
+
exe,
|
|
63
67
|
].filter(Boolean)
|
|
64
68
|
for (const c of candidates) {
|
|
65
69
|
if (exists(c)) return c
|
|
@@ -69,7 +73,14 @@ export function findNgrok() {
|
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
export function findCloudflared() {
|
|
72
|
-
const
|
|
76
|
+
const exe = process.platform === 'win32' ? 'cloudflared.exe' : 'cloudflared'
|
|
77
|
+
const candidates = [
|
|
78
|
+
CLOUDFLARED_EXE,
|
|
79
|
+
path.join(DATA_DIR, exe),
|
|
80
|
+
path.join(process.cwd(), 'tools', exe),
|
|
81
|
+
path.join(os.homedir(), 'Documents', 'DSH', 'DshRemote', 'tools', exe),
|
|
82
|
+
path.join(os.homedir(), 'Documents', 'DSH', '.dsh-apk-research', 'tools', exe),
|
|
83
|
+
]
|
|
73
84
|
for (const c of candidates) {
|
|
74
85
|
if (exists(c)) return c
|
|
75
86
|
}
|
|
@@ -144,6 +155,13 @@ export async function startTunnel({ kind = 'auto', bridgePort, authority, domain
|
|
|
144
155
|
const wantCloudflared = kind === 'auto' || kind === 'cloudflared'
|
|
145
156
|
|
|
146
157
|
if (wantNgrok) {
|
|
158
|
+
// A tunnel that is already pointing at this bridge (the usual case for a long-running setup
|
|
159
|
+
// with a static ngrok domain) is reused instead of spawning a second agent.
|
|
160
|
+
const reused = await existingNgrokUrl(bridgePort)
|
|
161
|
+
if (reused) {
|
|
162
|
+
log(`reusing the running ngrok tunnel: ${reused}`)
|
|
163
|
+
return { publicUrl: reused, kind: 'ngrok', close: () => {} }
|
|
164
|
+
}
|
|
147
165
|
const ngrok = findNgrok()
|
|
148
166
|
if (ngrok) {
|
|
149
167
|
const args = ['http', String(bridgePort), '--host-header', authority, '--log', 'stdout', '--log-format', 'json']
|
|
@@ -178,6 +196,24 @@ export async function startTunnel({ kind = 'auto', bridgePort, authority, domain
|
|
|
178
196
|
return { publicUrl: null, kind: 'none', close: () => {} }
|
|
179
197
|
}
|
|
180
198
|
|
|
199
|
+
/** Ask a running ngrok agent what it already exposes for `bridgePort`. */
|
|
200
|
+
async function existingNgrokUrl(bridgePort) {
|
|
201
|
+
try {
|
|
202
|
+
const res = await fetch('http://127.0.0.1:4040/api/tunnels', { signal: AbortSignal.timeout(2000) })
|
|
203
|
+
if (!res.ok) return null
|
|
204
|
+
const doc = await res.json()
|
|
205
|
+
const tunnels = Array.isArray(doc?.tunnels) ? doc.tunnels : []
|
|
206
|
+
for (const t of tunnels) {
|
|
207
|
+
const addr = String(t?.config?.addr ?? '')
|
|
208
|
+
// ngrok reports the upstream as `http://localhost:6300` / `http://127.0.0.1:6300`.
|
|
209
|
+
if (new RegExp(`:${bridgePort}(/|$)`).test(addr)) return t.public_url || null
|
|
210
|
+
}
|
|
211
|
+
return null
|
|
212
|
+
} catch {
|
|
213
|
+
return null
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
181
217
|
function waitForUrl(child, timeoutMs) {
|
|
182
218
|
return new Promise((resolve) => {
|
|
183
219
|
let done = false
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-hub",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "DSH 插件:把电脑上的 DeepSeek Harness 变成手机可访问的服务——起桥接、自动开隧道(ngrok / cloudflared)、签发 30 天 Cookie,并输出地址与凭据,配合 DSH Remote Android App 使用。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -50,5 +50,8 @@
|
|
|
50
50
|
"publishConfig": {
|
|
51
51
|
"registry": "https://registry.npmjs.org",
|
|
52
52
|
"access": "public"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"qrcode": "^1.5.4"
|
|
53
56
|
}
|
|
54
57
|
}
|