dsh-remote-hub 0.3.2 → 0.3.4
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 +21 -0
- package/bin/dsh-remote.mjs +43 -2
- package/cordis.patch.yml +3 -0
- package/lib/index.js +1 -1
- package/lib/pairing-routes.js +7 -4
- package/lib/supervisor.js +94 -2
- package/lib/tunnel.js +9 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -267,6 +267,27 @@ curl http://127.0.0.1:<bridgePort>/_dsh-remote/health
|
|
|
267
267
|
> 提示:`cordis.patch.yml` 里的 `port` 只是**首选**端口。若被占用,实际端口会写在 `~/.dsh/dsh-remote/status.json`,
|
|
268
268
|
> 一切链接与二维码都以那里为准,不必手改配置。
|
|
269
269
|
|
|
270
|
+
### 用自己的服务器做中转(阿里云等自建 relay)
|
|
271
|
+
|
|
272
|
+
如果你不用 ngrok,而是用一台云服务器(Caddy/nginx 终止 TLS + SSH 反向隧道)把本机暴露出去,
|
|
273
|
+
就让插件把这个地址写进二维码,否则扫码只会得到 ngrok/cloudflared 的地址:
|
|
274
|
+
|
|
275
|
+
```bash
|
|
276
|
+
# 方式一:临时环境变量
|
|
277
|
+
export DSH_REMOTE_PUBLIC_URL="https://your-relay.example" # Windows: $env:DSH_REMOTE_PUBLIC_URL="https://..."
|
|
278
|
+
dsh-remote start --open
|
|
279
|
+
|
|
280
|
+
# 方式二:写进 profile 配置(推荐,长期有效)
|
|
281
|
+
# ~/.dsh/profiles/<name>/cordis.patch.yml 的 dsh-remote 行:
|
|
282
|
+
# publicUrl: 'https://your-relay.example'
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
配置后插件不再启动 ngrok/cloudflared,并把该地址作为「公网地址」放进配对载荷;
|
|
286
|
+
启动时会探测 `<publicUrl>/_dsh-remote/health`,结果记在 `status.json` 的 `public.reachable`
|
|
287
|
+
(中转在、但反向隧道断了 → `false` 并打印警告)。
|
|
288
|
+
|
|
289
|
+
前提:服务器侧确实把请求转发到**桥接监听的端口**(默认 6300)。
|
|
290
|
+
|
|
270
291
|
### 扫码配对(一次性配对码)
|
|
271
292
|
|
|
272
293
|
不想手抄 Cookie 就用二维码:
|
package/bin/dsh-remote.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
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 [--open] run the supervisor
|
|
8
|
+
* dsh-remote start [--open] [--public-url=URL] run the supervisor; --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
11
|
* dsh-remote qr open the pairing page (QR) in the default browser
|
|
@@ -62,6 +62,21 @@ function printStatus() {
|
|
|
62
62
|
console.log('')
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/** Describe the process listening on a port (Windows only, best effort). */
|
|
66
|
+
function describePortOwner(port) {
|
|
67
|
+
if (process.platform !== 'win32') return Promise.resolve(null)
|
|
68
|
+
const script =
|
|
69
|
+
`$c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1;` +
|
|
70
|
+
'if ($c) { $p = Get-CimInstance Win32_Process -Filter (\"ProcessId=$($c.OwningProcess)\");' +
|
|
71
|
+
' "PID $($c.OwningProcess): $($p.CommandLine)" }'
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
execFile('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], { timeout: 8000, windowsHide: true }, (err, stdout) => {
|
|
74
|
+
const out = String(stdout || '').trim()
|
|
75
|
+
resolve(err || !out ? null : out.slice(0, 180))
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
65
80
|
function openBrowser(url) {
|
|
66
81
|
const cmd = process.platform === 'win32' ? 'cmd' : process.platform === 'darwin' ? 'open' : 'xdg-open'
|
|
67
82
|
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
|
|
@@ -188,6 +203,29 @@ async function doctor() {
|
|
|
188
203
|
}
|
|
189
204
|
console.log(' ' + '-'.repeat(52))
|
|
190
205
|
console.log('')
|
|
206
|
+
// Who owns the bridge port right now, and what public URL is advertised.
|
|
207
|
+
const bridgePort = Number(process.env.DSH_REMOTE_PORT || 6300)
|
|
208
|
+
const health = await bridgeHealthy(bridgePort)
|
|
209
|
+
if (health) {
|
|
210
|
+
console.log(` ✔ 桥接端口 ${bridgePort}(本插件,pairing=${health.pairing})`)
|
|
211
|
+
} else {
|
|
212
|
+
console.log(` · 桥接端口 ${bridgePort} 上没有本插件在跑`)
|
|
213
|
+
const owner = await describePortOwner(bridgePort)
|
|
214
|
+
if (owner) console.log(` 被其它进程占用: ${owner}`)
|
|
215
|
+
}
|
|
216
|
+
const st = readStatus()
|
|
217
|
+
const pub = st?.public
|
|
218
|
+
if (pub?.url) {
|
|
219
|
+
const tag = pub.kind === 'relay' ? '自建中转' : pub.kind
|
|
220
|
+
console.log(` ▲ 公网地址 ${pub.url}(${tag}${pub.reachable === false ? ',当前不可达' : ''})`)
|
|
221
|
+
if (pub.kind !== 'relay' && process.env.DSH_REMOTE_PUBLIC_URL) {
|
|
222
|
+
console.log(' (已设置 DSH_REMOTE_PUBLIC_URL,但当前桥接未采用:需要重启桥接)')
|
|
223
|
+
}
|
|
224
|
+
} else if (process.env.DSH_REMOTE_PUBLIC_URL) {
|
|
225
|
+
console.log(` · 公网地址 未生效(要重启桥接才会用 ${process.env.DSH_REMOTE_PUBLIC_URL})`)
|
|
226
|
+
} else {
|
|
227
|
+
console.log(' · 公网地址 未配置(如需自建中转,设置 DSH_REMOTE_PUBLIC_URL)')
|
|
228
|
+
}
|
|
191
229
|
}
|
|
192
230
|
|
|
193
231
|
async function main() {
|
|
@@ -222,9 +260,12 @@ async function main() {
|
|
|
222
260
|
break
|
|
223
261
|
case 'start': {
|
|
224
262
|
const wantOpen = process.argv.includes('--open')
|
|
263
|
+
// --public-url=https://relay.example advertises a self-hosted relay in the QR payload.
|
|
264
|
+
const publicArg = process.argv.find((a) => a.startsWith('--public-url='))
|
|
265
|
+
const publicUrl = publicArg ? publicArg.slice('--public-url='.length) : process.env.DSH_REMOTE_PUBLIC_URL || ''
|
|
225
266
|
let handle
|
|
226
267
|
try {
|
|
227
|
-
handle = await start({ log: (m) => console.log(m) })
|
|
268
|
+
handle = await start({ publicUrl, log: (m) => console.log(m) })
|
|
228
269
|
} catch (err) {
|
|
229
270
|
// Port already taken: another bridge is serving; just show its pairing page.
|
|
230
271
|
if (/EADDRINUSE/.test(String(err?.message ?? err))) {
|
package/cordis.patch.yml
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
# port local bridge port the phone connects to on the LAN
|
|
10
10
|
# authority Host header the bridge presents; the signed cookie is bound to it
|
|
11
11
|
# tunnel "auto" (ngrok if found, else cloudflared), "off", "ngrok" or "cloudflared"
|
|
12
|
+
# publicUrl explicit public address to advertise (a self-hosted relay, e.g. the Aliyun box).
|
|
13
|
+
# When set, nothing is launched for the tunnel and this URL goes into the QR code.
|
|
12
14
|
# upstreamPort pin the DSH host port; 0 means discover it automatically
|
|
13
15
|
- insert:
|
|
14
16
|
- id: dsh-remote
|
|
@@ -18,4 +20,5 @@
|
|
|
18
20
|
port: 6300
|
|
19
21
|
authority: '127.0.0.1:1815'
|
|
20
22
|
tunnel: 'auto'
|
|
23
|
+
publicUrl: ''
|
|
21
24
|
upstreamPort: 0
|
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.3.
|
|
19
|
+
export const version = '0.3.4'
|
|
20
20
|
|
|
21
21
|
/** No host services are required: the plugin only needs Node builtins. */
|
|
22
22
|
export const inject = []
|
package/lib/pairing-routes.js
CHANGED
|
@@ -23,7 +23,10 @@ export const PAIR_PREFIX = '/_dsh-remote/pair'
|
|
|
23
23
|
* when that transport is unavailable that moment.
|
|
24
24
|
*/
|
|
25
25
|
export function buildPairPayload({ lan, publicUrl, code, expiresAt }) {
|
|
26
|
-
|
|
26
|
+
// `l` is a LIST of LAN URLs: a machine can have several private adapters and only one of them is
|
|
27
|
+
// reachable from the phone, so the client probes them in order instead of trusting our pick.
|
|
28
|
+
const lanList = Array.isArray(lan) ? lan.filter(Boolean) : lan ? [lan] : []
|
|
29
|
+
return JSON.stringify({ v: 2, l: lanList, u: publicUrl ?? null, c: code, e: expiresAt })
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
/**
|
|
@@ -74,8 +77,8 @@ export async function handlePairing({ req, res, current, store }) {
|
|
|
74
77
|
JSON.stringify({
|
|
75
78
|
ok: true,
|
|
76
79
|
// `url` keeps the old single-address shape working; new clients read lan/public.
|
|
77
|
-
url: snapshot.public ?? snapshot.lan ?? null,
|
|
78
|
-
lan: snapshot.lan
|
|
80
|
+
url: snapshot.public ?? (Array.isArray(snapshot.lan) ? snapshot.lan[0] : snapshot.lan) ?? null,
|
|
81
|
+
lan: Array.isArray(snapshot.lan) ? snapshot.lan : snapshot.lan ? [snapshot.lan] : [],
|
|
79
82
|
public: snapshot.public ?? null,
|
|
80
83
|
cookie: result.cookie,
|
|
81
84
|
issuedAt: result.issuedAt,
|
|
@@ -119,7 +122,7 @@ export async function handlePairing({ req, res, current, store }) {
|
|
|
119
122
|
<p>配对码 <code>${code}</code> · ${minutes} 分钟内有效,且只能用一次</p>
|
|
120
123
|
<div class="qr">${svg}</div>
|
|
121
124
|
<p class="ok">扫码后自动填入地址并换取登录凭据</p>
|
|
122
|
-
<p class="addr">${snapshot.lan ?
|
|
125
|
+
<p class="addr">${(Array.isArray(snapshot.lan) ? snapshot.lan : snapshot.lan ? [snapshot.lan] : []).map((u) => `局域网:${u}`).join('<br>')}${snapshot.public ? `<br>公网:${snapshot.public}` : ''}</p>
|
|
123
126
|
<p class="addr">扫一次会同时存下局域网与公网地址,之后自动优先走局域网</p>
|
|
124
127
|
</div></body></html>`
|
|
125
128
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
|
package/lib/supervisor.js
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import http from 'node:http'
|
|
13
|
+
import https from 'node:https'
|
|
14
|
+
import { execFile } from 'node:child_process'
|
|
13
15
|
import fs from 'node:fs'
|
|
14
16
|
import os from 'node:os'
|
|
15
17
|
import path from 'node:path'
|
|
@@ -36,6 +38,29 @@ function appendLog(line) {
|
|
|
36
38
|
} catch {}
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
/** Every private IPv4 address this machine has, best candidate first. */
|
|
42
|
+
export function lanAddresses() {
|
|
43
|
+
const nets = os.networkInterfaces()
|
|
44
|
+
const VIRTUAL = /vEthernet|WSL|Docker|Hyper-V|VMware|VirtualBox|Loopback|Tunnel|Tailscale|ZeroTier/i
|
|
45
|
+
const scored = []
|
|
46
|
+
for (const [name, addrs] of Object.entries(nets)) {
|
|
47
|
+
for (const a of addrs || []) {
|
|
48
|
+
if (a.family !== 'IPv4' || a.internal) continue
|
|
49
|
+
const isPrivate = /^192\.168\.|^10\.|^172\.(1[6-9]|2\d|3[01])\./.test(a.address)
|
|
50
|
+
if (!isPrivate) continue
|
|
51
|
+
let score = 0
|
|
52
|
+
if (/^192\.168\./.test(a.address)) score += 40
|
|
53
|
+
else if (/^10\./.test(a.address)) score += 20
|
|
54
|
+
else score += 5
|
|
55
|
+
if (/wi-?fi|wlan|WLAN|en0|Ethernet|以太网/i.test(name)) score += 10
|
|
56
|
+
if (VIRTUAL.test(name)) score -= 60
|
|
57
|
+
scored.push({ address: a.address, score })
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
scored.sort((a, b) => b.score - a.score)
|
|
61
|
+
return [...new Set(scored.filter((s2) => s2.score > 0).map((s2) => s2.address))]
|
|
62
|
+
}
|
|
63
|
+
|
|
39
64
|
/**
|
|
40
65
|
* LAN address a phone on the same network can reach.
|
|
41
66
|
*
|
|
@@ -102,6 +127,50 @@ function writeStatus(status) {
|
|
|
102
127
|
fs.writeFileSync(CONNECT_FILE, lines.join(os.EOL), 'utf8')
|
|
103
128
|
}
|
|
104
129
|
|
|
130
|
+
/** Best-effort description of the process listening on `port` (Windows), for diagnostics. */
|
|
131
|
+
function describePortOwner(port) {
|
|
132
|
+
if (process.platform !== 'win32') return Promise.resolve(null)
|
|
133
|
+
const script =
|
|
134
|
+
`$c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1;` +
|
|
135
|
+
'if ($c) { $p = Get-CimInstance Win32_Process -Filter (\"ProcessId=$($c.OwningProcess)\");' +
|
|
136
|
+
' "PID $($c.OwningProcess): $($p.CommandLine)" }'
|
|
137
|
+
return new Promise((resolve) => {
|
|
138
|
+
execFile('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], { timeout: 8000, windowsHide: true }, (err, stdout) => {
|
|
139
|
+
const out = String(stdout || '').trim()
|
|
140
|
+
resolve(err || !out ? null : out.slice(0, 200))
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Probe a full URL (used for the configured public relay) and report whether it answers. */
|
|
146
|
+
function probeHealthUrl(url, timeoutMs = 4000) {
|
|
147
|
+
return new Promise((resolve) => {
|
|
148
|
+
let parsed
|
|
149
|
+
try {
|
|
150
|
+
parsed = new URL(url)
|
|
151
|
+
} catch {
|
|
152
|
+
return resolve(false)
|
|
153
|
+
}
|
|
154
|
+
// A relay is usually HTTPS, so pick the transport from the scheme instead of hard-coding http.
|
|
155
|
+
const transport = parsed.protocol === 'https:' ? https : http
|
|
156
|
+
let req
|
|
157
|
+
try {
|
|
158
|
+
req = transport.request(parsed, { method: 'GET', timeout: timeoutMs }, (res) => {
|
|
159
|
+
res.resume()
|
|
160
|
+
resolve(res.statusCode >= 200 && res.statusCode < 500)
|
|
161
|
+
})
|
|
162
|
+
} catch {
|
|
163
|
+
return resolve(false)
|
|
164
|
+
}
|
|
165
|
+
req.on('error', () => resolve(false))
|
|
166
|
+
req.on('timeout', () => {
|
|
167
|
+
req.destroy()
|
|
168
|
+
resolve(false)
|
|
169
|
+
})
|
|
170
|
+
req.end()
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
|
|
105
174
|
/** Ask whatever listens on `port` whether it is a dsh-remote bridge, and what it supports. */
|
|
106
175
|
function probeHealth(port, timeoutMs = 1200) {
|
|
107
176
|
return new Promise((resolve) => {
|
|
@@ -158,6 +227,7 @@ export async function start(config = {}) {
|
|
|
158
227
|
port = 6300,
|
|
159
228
|
authority = '127.0.0.1:1815',
|
|
160
229
|
tunnel: tunnelKind = 'auto',
|
|
230
|
+
publicUrl = '',
|
|
161
231
|
upstreamPort = 0,
|
|
162
232
|
domain = '',
|
|
163
233
|
log = appendLog,
|
|
@@ -201,6 +271,9 @@ export async function start(config = {}) {
|
|
|
201
271
|
let lastPublicUrl = null
|
|
202
272
|
|
|
203
273
|
/** The bridge may have fallen back to an ephemeral port; everything must report the real one. */
|
|
274
|
+
const lanUrlsFor = (bridgePort) =>
|
|
275
|
+
lanAddresses().map((address) => `http://${address}:${bridgePort}`)
|
|
276
|
+
|
|
204
277
|
let bridgeInstance = null
|
|
205
278
|
const actualBridgePort = () => (bridgeInstance ? bridgeInstance.port : Number(port) || 6300)
|
|
206
279
|
|
|
@@ -210,7 +283,7 @@ export async function start(config = {}) {
|
|
|
210
283
|
store: pairingStore,
|
|
211
284
|
// Read at redeem time so a tunnel URL that appears later is still handed to the phone.
|
|
212
285
|
current: () => ({
|
|
213
|
-
lan:
|
|
286
|
+
lan: lanUrlsFor(actualBridgePort()),
|
|
214
287
|
public: lastPublicUrl ?? null,
|
|
215
288
|
cookie,
|
|
216
289
|
}),
|
|
@@ -275,6 +348,10 @@ export async function start(config = {}) {
|
|
|
275
348
|
log('dsh-remote: port is held by an old bridge WITHOUT pairing support; using another port')
|
|
276
349
|
} else {
|
|
277
350
|
log(`dsh-remote: port ${wanted} is busy (not a dsh-remote bridge); using another port`)
|
|
351
|
+
// Say WHO holds it: the usual culprit is a leftover lan-proxy.mjs from an older setup, and
|
|
352
|
+
// knowing the PID is what lets the user stop it instead of guessing.
|
|
353
|
+
const owner = await describePortOwner(wanted)
|
|
354
|
+
if (owner) log(`dsh-remote: port ${wanted} is held by: ${owner}`)
|
|
278
355
|
}
|
|
279
356
|
bridge = await startBridge({
|
|
280
357
|
listenPort: 0,
|
|
@@ -308,6 +385,7 @@ export async function start(config = {}) {
|
|
|
308
385
|
|
|
309
386
|
const tunnel = await startTunnel({
|
|
310
387
|
kind: tunnelKind,
|
|
388
|
+
publicUrl,
|
|
311
389
|
bridgePort: bridge.port,
|
|
312
390
|
authority,
|
|
313
391
|
domain,
|
|
@@ -328,6 +406,15 @@ export async function start(config = {}) {
|
|
|
328
406
|
})
|
|
329
407
|
|
|
330
408
|
lastPublicUrl = tunnel.publicUrl
|
|
409
|
+
// A configured relay can be "up" while its reverse tunnel is down; probe it so status does not
|
|
410
|
+
// advertise a URL that cannot answer.
|
|
411
|
+
let publicReachable = null
|
|
412
|
+
if (publicUrl && tunnel.publicUrl) {
|
|
413
|
+
publicReachable = await probeHealthUrl(`${tunnel.publicUrl}/_dsh-remote/health`)
|
|
414
|
+
if (publicReachable === false) {
|
|
415
|
+
log(`dsh-remote: WARNING ${tunnel.publicUrl} does not answer — is the relay's SSH tunnel up?`)
|
|
416
|
+
}
|
|
417
|
+
}
|
|
331
418
|
const ip = ipForPairing
|
|
332
419
|
const status = {
|
|
333
420
|
updatedAt: Date.now(),
|
|
@@ -336,7 +423,12 @@ export async function start(config = {}) {
|
|
|
336
423
|
authority,
|
|
337
424
|
upstreamPort: discovery.port || null,
|
|
338
425
|
lan: { ip, url: ip ? `http://${ip}:${bridge.port}` : null },
|
|
339
|
-
public: {
|
|
426
|
+
public: {
|
|
427
|
+
url: tunnel.publicUrl,
|
|
428
|
+
kind: tunnel.kind,
|
|
429
|
+
...(publicReachable === null ? {} : { reachable: publicReachable }),
|
|
430
|
+
...(tunnel.note ? { note: tunnel.note } : {}),
|
|
431
|
+
},
|
|
340
432
|
cookie,
|
|
341
433
|
cookieExpiresAt: expiresAt ?? cookieExpiry(cookie),
|
|
342
434
|
pairing: {
|
package/lib/tunnel.js
CHANGED
|
@@ -148,7 +148,15 @@ export async function ensureCloudflared({ log = () => {} } = {}) {
|
|
|
148
148
|
* @param {(msg: string) => void} [options.log]
|
|
149
149
|
* @returns {Promise<{publicUrl: string|null, kind: string, close: () => void, note?: string}>}
|
|
150
150
|
*/
|
|
151
|
-
export async function startTunnel({ kind = 'auto', bridgePort, authority, domain = '', log = () => {} }) {
|
|
151
|
+
export async function startTunnel({ kind = 'auto', bridgePort, authority, domain = '', publicUrl = '', log = () => {} }) {
|
|
152
|
+
// An explicitly configured public URL (a self-hosted relay, e.g. the Aliyun box) wins over any
|
|
153
|
+
// detected tunnel: for that setup there is nothing to launch here, the relay is kept alive by its
|
|
154
|
+
// own reverse SSH tunnel.
|
|
155
|
+
if (publicUrl) {
|
|
156
|
+
const trimmed = String(publicUrl).trim().replace(/\/+$/, '')
|
|
157
|
+
log(`using the configured public URL: ${trimmed}`)
|
|
158
|
+
return { publicUrl: trimmed, kind: 'relay', close: () => {} }
|
|
159
|
+
}
|
|
152
160
|
if (kind === 'off') return { publicUrl: null, kind: 'none', close: () => {}, note: 'tunnel disabled by config' }
|
|
153
161
|
|
|
154
162
|
const wantNgrok = kind === 'auto' || kind === 'ngrok'
|
package/package.json
CHANGED