dsh-remote-hub 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -2
- package/bin/dsh-remote.mjs +5 -0
- package/lib/bridge.js +15 -0
- package/lib/discover.js +43 -48
- package/lib/index.js +9 -1
- package/lib/supervisor.js +56 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,9 +19,9 @@ Kotlin/Compose 直连 DSH 自带的 HTTP API + WebSocket 网关。
|
|
|
19
19
|
|
|
20
20
|
## 下载手机端 App
|
|
21
21
|
|
|
22
|
-
**DSH Remote v4.20.
|
|
22
|
+
**DSH Remote v4.20.1** · Android 8.0 (API 26) 及以上 · 2.8 MB
|
|
23
23
|
|
|
24
|
-
[⬇️ 下载 APK](https://github.com/liaoguogang-hub/dsh-remote-public/releases/download/v4.20.
|
|
24
|
+
[⬇️ 下载 APK](https://github.com/liaoguogang-hub/dsh-remote-public/releases/download/v4.20.1/DSH-Remote-v4.20.1.apk) ·
|
|
25
25
|
[全部版本](https://github.com/liaoguogang-hub/dsh-remote-public/releases)
|
|
26
26
|
|
|
27
27
|
安装后应用名「DSH Remote」,包名 `com.dsh.remote.ngrok`(可与作者的 `DSH` 自用版共存)。
|
|
@@ -222,6 +222,19 @@ dsh-remote start # 不开 DSH 也能单独前台跑(Ctrl+C 退出)
|
|
|
222
222
|
> 公网隧道受网络影响:`api.trycloudflare.com` 不可达时插件会跳过公网、只给局域网地址,
|
|
223
223
|
> 并在 `status.json` 里写明原因,不会假装成功。
|
|
224
224
|
|
|
225
|
+
### 装完之后
|
|
226
|
+
|
|
227
|
+
插件随 DSH 启动自动工作,**不需要手动跑 connect.cmd**;装的时候没有提示是正常的,日志里会出现:
|
|
228
|
+
|
|
229
|
+
```
|
|
230
|
+
dsh-remote: ready — 手机端扫码:dsh-remote qr(或浏览器打开 http://127.0.0.1:<端口>/_dsh-remote/pair)
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
想手动确认/手动起,就在仓库目录双击 `connect.cmd`(它和插件是同一份代码,会打印地址与 Cookie 并自动打开二维码)。
|
|
234
|
+
|
|
235
|
+
**Cookie 跨重启有效**:桥接始终向 DSH 呈现固定 authority(默认 `127.0.0.1:1815`),
|
|
236
|
+
所以 DSH 换端口、桥接重启都不会让手机上的凭据失效;只有签名密钥变化或 Cookie 到期(30 天)才需要重新扫码。
|
|
237
|
+
|
|
225
238
|
### 扫码配对(一次性配对码)
|
|
226
239
|
|
|
227
240
|
不想手抄 Cookie 就用二维码:
|
package/bin/dsh-remote.mjs
CHANGED
|
@@ -136,6 +136,11 @@ async function main() {
|
|
|
136
136
|
switch (command) {
|
|
137
137
|
case 'status':
|
|
138
138
|
printStatus()
|
|
139
|
+
if (!readStatus()) {
|
|
140
|
+
console.log(' 下一步:dsh-remote start --open (起桥接并打开配对二维码)')
|
|
141
|
+
console.log(' 或双击仓库里的 connect.cmd')
|
|
142
|
+
console.log('')
|
|
143
|
+
}
|
|
139
144
|
break
|
|
140
145
|
case 'connect':
|
|
141
146
|
if (fs.existsSync(CONNECT_FILE)) console.log(fs.readFileSync(CONNECT_FILE, 'utf8'))
|
package/lib/bridge.js
CHANGED
|
@@ -149,6 +149,21 @@ export function startBridge({ listenPort, authority, upstream, pairing = null, l
|
|
|
149
149
|
})
|
|
150
150
|
|
|
151
151
|
const server = http.createServer((req, res) => {
|
|
152
|
+
// Health probe: lets the CLI and a second instance tell "my bridge is already running"
|
|
153
|
+
// apart from "some other program holds the port".
|
|
154
|
+
if (String(req.url || '') === '/_dsh-remote/health') {
|
|
155
|
+
const body = JSON.stringify({
|
|
156
|
+
ok: true,
|
|
157
|
+
service: 'dsh-remote-bridge',
|
|
158
|
+
bridgePort: listenPort,
|
|
159
|
+
authority,
|
|
160
|
+
upstreamPort: upstream.port || null,
|
|
161
|
+
})
|
|
162
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
163
|
+
res.end(body)
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
|
|
152
167
|
// Pairing is served by the bridge itself: it must work before the phone has any credential,
|
|
153
168
|
// and it must NOT be proxied to DSH.
|
|
154
169
|
if (pairing && String(req.url || '').startsWith('/_dsh-remote/pair')) {
|
package/lib/discover.js
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
* Find the DSH host's HTTP port.
|
|
3
3
|
*
|
|
4
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.
|
|
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.
|
|
5
|
+
* 8312, 12077), so the port cannot be configured once and forgotten.
|
|
8
6
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* Detection is a fingerprint, not a status code: a **cookieless** `GET /` addressed with the port
|
|
8
|
+
* as Host answers `401 dsh web authentication required`. That sentence is DSH's own trust fence,
|
|
9
|
+
* and no other local service produces it. A plain 200 is worthless here — plenty of tools answer
|
|
10
|
+
* 200 to anything (a real false-positive source on other machines), and an authenticated probe
|
|
11
|
+
* cannot run before the credential exists.
|
|
12
12
|
*
|
|
13
13
|
* @module lib/discover
|
|
14
14
|
*/
|
|
@@ -17,13 +17,14 @@ import http from 'node:http'
|
|
|
17
17
|
import { execFile } from 'node:child_process'
|
|
18
18
|
|
|
19
19
|
const PROBE_TIMEOUT_MS = 1500
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
20
|
+
const NEWLINE = String.fromCharCode(10)
|
|
21
|
+
const CARRIAGE = String.fromCharCode(13)
|
|
22
|
+
|
|
23
|
+
function splitLines(text) {
|
|
24
|
+
return String(text)
|
|
25
|
+
.split(NEWLINE)
|
|
26
|
+
.map((line) => line.split(CARRIAGE).join(''))
|
|
27
|
+
}
|
|
27
28
|
|
|
28
29
|
function run(cmd, args, timeout = 8000) {
|
|
29
30
|
return new Promise((resolve) => {
|
|
@@ -38,7 +39,7 @@ export async function listeningPorts() {
|
|
|
38
39
|
const ports = new Set()
|
|
39
40
|
if (process.platform === 'win32') {
|
|
40
41
|
const out = await run('netstat', ['-ano', '-p', 'tcp'])
|
|
41
|
-
for (const line of out
|
|
42
|
+
for (const line of splitLines(out)) {
|
|
42
43
|
const m = /^\s*TCP\s+(\S+):(\d+)\s+\S+\s+LISTENING\s+(\d+)/i.exec(line)
|
|
43
44
|
if (!m) continue
|
|
44
45
|
const addr = m[1]
|
|
@@ -49,17 +50,16 @@ export async function listeningPorts() {
|
|
|
49
50
|
}
|
|
50
51
|
return [...ports]
|
|
51
52
|
}
|
|
52
|
-
// macOS / Linux
|
|
53
53
|
const ss = await run('ss', ['-ltn'])
|
|
54
54
|
if (ss) {
|
|
55
|
-
for (const line of ss
|
|
55
|
+
for (const line of splitLines(ss)) {
|
|
56
56
|
const m = /\s(?:\S*):(\d+)\s/.exec(` ${line} `)
|
|
57
57
|
if (m) ports.add(Number(m[1]))
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
60
|
if (ports.size === 0) {
|
|
61
61
|
const lsof = await run('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN'])
|
|
62
|
-
for (const line of lsof
|
|
62
|
+
for (const line of splitLines(lsof)) {
|
|
63
63
|
const m = /:(\d+)\s+\(LISTEN\)/.exec(line)
|
|
64
64
|
if (m) ports.add(Number(m[1]))
|
|
65
65
|
}
|
|
@@ -67,29 +67,36 @@ export async function listeningPorts() {
|
|
|
67
67
|
return [...ports].filter((p) => Number.isInteger(p) && p > 0)
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
/**
|
|
71
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Is `port` the DSH web server?
|
|
72
|
+
*
|
|
73
|
+
* Resolves true only for `401` + `dsh web authentication required`, and false for anything that
|
|
74
|
+
* carries our own bridge tag (a bridge would forward the same response).
|
|
75
|
+
*/
|
|
76
|
+
export function probePort(port) {
|
|
72
77
|
return new Promise((resolve) => {
|
|
73
78
|
const req = http.request(
|
|
74
79
|
{
|
|
75
80
|
host: '127.0.0.1',
|
|
76
81
|
port,
|
|
77
|
-
path:
|
|
78
|
-
method: '
|
|
82
|
+
path: '/',
|
|
83
|
+
method: 'GET',
|
|
79
84
|
timeout: PROBE_TIMEOUT_MS,
|
|
80
|
-
headers: {
|
|
81
|
-
'content-type': 'application/json',
|
|
82
|
-
'content-length': Buffer.byteLength(PROBE_BODY),
|
|
83
|
-
host: authority,
|
|
84
|
-
cookie,
|
|
85
|
-
},
|
|
85
|
+
headers: { host: `127.0.0.1:${port}` },
|
|
86
86
|
},
|
|
87
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
88
|
const isBridge = res.headers['x-dsh-remote-bridge'] === '1'
|
|
91
|
-
|
|
92
|
-
|
|
89
|
+
const chunks = []
|
|
90
|
+
let seen = 0
|
|
91
|
+
res.on('data', (chunk) => {
|
|
92
|
+
if (seen < 8192) chunks.push(chunk)
|
|
93
|
+
seen += chunk.length
|
|
94
|
+
})
|
|
95
|
+
res.on('end', () => {
|
|
96
|
+
if (isBridge) return resolve(false)
|
|
97
|
+
const head = Buffer.concat(chunks).toString('utf8')
|
|
98
|
+
resolve(res.statusCode === 401 && /dsh web authentication required/i.test(head))
|
|
99
|
+
})
|
|
93
100
|
},
|
|
94
101
|
)
|
|
95
102
|
req.on('error', () => resolve(false))
|
|
@@ -97,7 +104,6 @@ export function probePort(port, { authority, cookie }) {
|
|
|
97
104
|
req.destroy()
|
|
98
105
|
resolve(false)
|
|
99
106
|
})
|
|
100
|
-
req.write(PROBE_BODY)
|
|
101
107
|
req.end()
|
|
102
108
|
})
|
|
103
109
|
}
|
|
@@ -105,39 +111,28 @@ export function probePort(port, { authority, cookie }) {
|
|
|
105
111
|
/**
|
|
106
112
|
* A resolver that remembers the last known-good port.
|
|
107
113
|
*
|
|
108
|
-
* @param {object} options
|
|
109
|
-
* @param {
|
|
110
|
-
* @param {() => Promise<string>|string} options.cookie current cookie (may be re-minted)
|
|
111
|
-
* @param {number} [options.pinned=0] skip discovery when > 0
|
|
114
|
+
* @param {object} [options]
|
|
115
|
+
* @param {number} [options.pinned=0] skip discovery entirely when > 0
|
|
112
116
|
* @param {number} [options.ttlMs=30000]
|
|
117
|
+
* @param {number[]} [options.exclude=[]] ports that must never be treated as upstream (our bridge)
|
|
113
118
|
*/
|
|
114
|
-
export function createDiscovery({
|
|
119
|
+
export function createDiscovery({ pinned = 0, ttlMs = 30_000, exclude = [] } = {}) {
|
|
115
120
|
let port = pinned || 0
|
|
116
121
|
let resolvedAt = 0
|
|
117
122
|
let inFlight = null
|
|
118
123
|
|
|
119
|
-
async function resolveCookie() {
|
|
120
|
-
try {
|
|
121
|
-
return await cookie()
|
|
122
|
-
} catch {
|
|
123
|
-
return ''
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
124
|
async function resolve(force = false) {
|
|
128
125
|
if (pinned) return pinned
|
|
129
126
|
if (!force && port && Date.now() - resolvedAt < ttlMs) return port
|
|
130
127
|
if (inFlight) return inFlight
|
|
131
128
|
inFlight = (async () => {
|
|
132
|
-
const ck = await resolveCookie()
|
|
133
|
-
if (!ck) return port
|
|
134
129
|
const skip = new Set(exclude.filter((p) => Number.isInteger(p) && p > 0))
|
|
135
130
|
const ports = (await listeningPorts()).filter((p) => !skip.has(p))
|
|
136
131
|
// Probe the remembered port first: it is usually still right and saves a full sweep.
|
|
137
132
|
const ordered = port && ports.includes(port) ? [port, ...ports.filter((p) => p !== port)] : ports
|
|
138
133
|
for (const candidate of ordered) {
|
|
139
134
|
// eslint-disable-next-line no-await-in-loop
|
|
140
|
-
if (await probePort(candidate
|
|
135
|
+
if (await probePort(candidate)) {
|
|
141
136
|
port = candidate
|
|
142
137
|
resolvedAt = Date.now()
|
|
143
138
|
return port
|
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.
|
|
19
|
+
export const version = '0.3.0'
|
|
20
20
|
|
|
21
21
|
/** No host services are required: the plugin only needs Node builtins. */
|
|
22
22
|
export const inject = []
|
|
@@ -52,6 +52,14 @@ export function apply(ctx, config = {}) {
|
|
|
52
52
|
start({ ...config, log })
|
|
53
53
|
.then((started) => {
|
|
54
54
|
handle = started
|
|
55
|
+
if (started?.status?.bridgePort) {
|
|
56
|
+
// Make the next step discoverable from the log: after installing the plugin there is no
|
|
57
|
+
// other place telling the user how to get the phone connected.
|
|
58
|
+
log(
|
|
59
|
+
`dsh-remote: ready — 手机端扫码:dsh-remote qr(或浏览器打开 http://127.0.0.1:${started.status.bridgePort}/_dsh-remote/pair);` +
|
|
60
|
+
'不想装插件时也可以直接双击仓库里的 connect.cmd',
|
|
61
|
+
)
|
|
62
|
+
}
|
|
55
63
|
if (closed) return handle.close()
|
|
56
64
|
return undefined
|
|
57
65
|
})
|
package/lib/supervisor.js
CHANGED
|
@@ -107,7 +107,7 @@ function writeStatus(status) {
|
|
|
107
107
|
* @param {object} config
|
|
108
108
|
* @param {boolean} [config.enabled=true]
|
|
109
109
|
* @param {number} [config.port=6300]
|
|
110
|
-
* @param {string} [config.authority='127.0.0.1:1815']
|
|
110
|
+
* @param {string} [config.authority='127.0.0.1:1815'] fixed on purpose; 'auto' follows the host port
|
|
111
111
|
* @param {'auto'|'off'|'ngrok'|'cloudflared'} [config.tunnel='auto']
|
|
112
112
|
* @param {number} [config.upstreamPort=0]
|
|
113
113
|
* @param {string} [config.domain='']
|
|
@@ -149,16 +149,21 @@ export async function start(config = {}) {
|
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
// The bridge listens on `port`; probing it would forward to itself, so it must be excluded
|
|
152
|
-
// from upstream discovery.
|
|
152
|
+
// from upstream discovery. Discovery never blocks startup: the bridge must come up even when the
|
|
153
|
+
// DSH web server has not bound its port yet (plugin loads early in host boot), otherwise the
|
|
154
|
+
// pairing page is unreachable and the failure looks like "the plugin did nothing".
|
|
153
155
|
const discovery = createDiscovery({
|
|
154
|
-
authority,
|
|
155
|
-
cookie: () => cookie,
|
|
156
156
|
pinned: Number(upstreamPort) || 0,
|
|
157
157
|
exclude: [Number(port) || 6300],
|
|
158
158
|
})
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
159
|
+
|
|
160
|
+
const ipForPairing = lanAddress()
|
|
161
|
+
/** Public URL is filled in after the tunnel starts; pairing reads it lazily. */
|
|
162
|
+
let lastPublicUrl = null
|
|
163
|
+
|
|
164
|
+
/** The bridge may have fallen back to an ephemeral port; everything must report the real one. */
|
|
165
|
+
let bridgeInstance = null
|
|
166
|
+
const actualBridgePort = () => (bridgeInstance ? bridgeInstance.port : Number(port) || 6300)
|
|
162
167
|
|
|
163
168
|
// Pairing: the QR carries a one-time code, not the cookie; redeeming burns it.
|
|
164
169
|
const pairingStore = createPairingStore()
|
|
@@ -166,24 +171,56 @@ export async function start(config = {}) {
|
|
|
166
171
|
store: pairingStore,
|
|
167
172
|
// Read at redeem time so a tunnel URL that appears later is still handed to the phone.
|
|
168
173
|
current: () => ({
|
|
169
|
-
lan: ipForPairing ? `http://${ipForPairing}:${
|
|
174
|
+
lan: ipForPairing ? `http://${ipForPairing}:${actualBridgePort()}` : null,
|
|
170
175
|
public: lastPublicUrl ?? null,
|
|
171
176
|
cookie,
|
|
172
177
|
}),
|
|
173
178
|
}
|
|
174
179
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
180
|
+
let bridge
|
|
181
|
+
try {
|
|
182
|
+
bridge = await startBridge({
|
|
183
|
+
listenPort: Number(port) || 6300,
|
|
184
|
+
authority,
|
|
185
|
+
upstream: discovery,
|
|
186
|
+
pairing,
|
|
187
|
+
log,
|
|
188
|
+
})
|
|
189
|
+
} catch (err) {
|
|
190
|
+
// The usual cause is an older bridge (or a watchdog) still holding the port. Falling back to
|
|
191
|
+
// an ephemeral port keeps the pairing page reachable; the real port is reported everywhere.
|
|
192
|
+
if (!/EADDRINUSE/.test(String(err?.message ?? err))) throw err
|
|
193
|
+
log(`dsh-remote: port ${port} is busy, falling back to an ephemeral port`)
|
|
194
|
+
bridge = await startBridge({
|
|
195
|
+
listenPort: 0,
|
|
196
|
+
authority,
|
|
197
|
+
upstream: discovery,
|
|
198
|
+
pairing,
|
|
199
|
+
log,
|
|
200
|
+
})
|
|
201
|
+
}
|
|
178
202
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
203
|
+
// Wait for the host's web server in the background: plugin mode runs before DSH binds its port.
|
|
204
|
+
const waitDeadline = Date.now() + 120_000
|
|
205
|
+
const waitForHost = async () => {
|
|
206
|
+
let attempts = 0
|
|
207
|
+
while (Date.now() < waitDeadline) {
|
|
208
|
+
attempts += 1
|
|
209
|
+
// eslint-disable-next-line no-await-in-loop
|
|
210
|
+
const found = await discovery.resolve(true)
|
|
211
|
+
if (found) {
|
|
212
|
+
log(`dsh-remote: upstream host on 127.0.0.1:${found} (after ${attempts} probe(s))`)
|
|
213
|
+
return found
|
|
214
|
+
}
|
|
215
|
+
// eslint-disable-next-line no-await-in-loop
|
|
216
|
+
await new Promise((r) => setTimeout(r, 1500))
|
|
217
|
+
}
|
|
218
|
+
log('dsh-remote: no DSH web server found after 2 minutes — is DSH running?')
|
|
219
|
+
return 0
|
|
220
|
+
}
|
|
221
|
+
const upstreamReady = waitForHost()
|
|
186
222
|
|
|
223
|
+
bridgeInstance = bridge
|
|
187
224
|
const tunnel = await startTunnel({
|
|
188
225
|
kind: tunnelKind,
|
|
189
226
|
bridgePort: bridge.port,
|
|
@@ -196,6 +233,7 @@ export async function start(config = {}) {
|
|
|
196
233
|
const ip = ipForPairing
|
|
197
234
|
const status = {
|
|
198
235
|
updatedAt: Date.now(),
|
|
236
|
+
phase: 'ready',
|
|
199
237
|
bridgePort: bridge.port,
|
|
200
238
|
authority,
|
|
201
239
|
upstreamPort: discovery.port || null,
|
package/package.json
CHANGED