dsh-remote-hub 0.3.2 → 0.3.3

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.
@@ -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 in the foreground; --open 打印地址并打开配对页
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
@@ -222,9 +222,12 @@ async function main() {
222
222
  break
223
223
  case 'start': {
224
224
  const wantOpen = process.argv.includes('--open')
225
+ // --public-url=https://relay.example advertises a self-hosted relay in the QR payload.
226
+ const publicArg = process.argv.find((a) => a.startsWith('--public-url='))
227
+ const publicUrl = publicArg ? publicArg.slice('--public-url='.length) : process.env.DSH_REMOTE_PUBLIC_URL || ''
225
228
  let handle
226
229
  try {
227
- handle = await start({ log: (m) => console.log(m) })
230
+ handle = await start({ publicUrl, log: (m) => console.log(m) })
228
231
  } catch (err) {
229
232
  // Port already taken: another bridge is serving; just show its pairing page.
230
233
  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.2'
19
+ export const version = '0.3.3'
20
20
 
21
21
  /** No host services are required: the plugin only needs Node builtins. */
22
22
  export const inject = []
@@ -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
- return JSON.stringify({ v: 1, l: lan ?? null, u: publicUrl ?? null, c: code, e: expiresAt })
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 ?? null,
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 ? `局域网:${snapshot.lan}<br>` : ''}${snapshot.public ? `公网:${snapshot.public}` : ''}</p>
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,7 @@
10
10
  */
11
11
 
12
12
  import http from 'node:http'
13
+ import https from 'node:https'
13
14
  import fs from 'node:fs'
14
15
  import os from 'node:os'
15
16
  import path from 'node:path'
@@ -36,6 +37,29 @@ function appendLog(line) {
36
37
  } catch {}
37
38
  }
38
39
 
40
+ /** Every private IPv4 address this machine has, best candidate first. */
41
+ export function lanAddresses() {
42
+ const nets = os.networkInterfaces()
43
+ const VIRTUAL = /vEthernet|WSL|Docker|Hyper-V|VMware|VirtualBox|Loopback|Tunnel|Tailscale|ZeroTier/i
44
+ const scored = []
45
+ for (const [name, addrs] of Object.entries(nets)) {
46
+ for (const a of addrs || []) {
47
+ if (a.family !== 'IPv4' || a.internal) continue
48
+ const isPrivate = /^192\.168\.|^10\.|^172\.(1[6-9]|2\d|3[01])\./.test(a.address)
49
+ if (!isPrivate) continue
50
+ let score = 0
51
+ if (/^192\.168\./.test(a.address)) score += 40
52
+ else if (/^10\./.test(a.address)) score += 20
53
+ else score += 5
54
+ if (/wi-?fi|wlan|WLAN|en0|Ethernet|以太网/i.test(name)) score += 10
55
+ if (VIRTUAL.test(name)) score -= 60
56
+ scored.push({ address: a.address, score })
57
+ }
58
+ }
59
+ scored.sort((a, b) => b.score - a.score)
60
+ return [...new Set(scored.filter((s2) => s2.score > 0).map((s2) => s2.address))]
61
+ }
62
+
39
63
  /**
40
64
  * LAN address a phone on the same network can reach.
41
65
  *
@@ -102,6 +126,35 @@ function writeStatus(status) {
102
126
  fs.writeFileSync(CONNECT_FILE, lines.join(os.EOL), 'utf8')
103
127
  }
104
128
 
129
+ /** Probe a full URL (used for the configured public relay) and report whether it answers. */
130
+ function probeHealthUrl(url, timeoutMs = 4000) {
131
+ return new Promise((resolve) => {
132
+ let parsed
133
+ try {
134
+ parsed = new URL(url)
135
+ } catch {
136
+ return resolve(false)
137
+ }
138
+ // A relay is usually HTTPS, so pick the transport from the scheme instead of hard-coding http.
139
+ const transport = parsed.protocol === 'https:' ? https : http
140
+ let req
141
+ try {
142
+ req = transport.request(parsed, { method: 'GET', timeout: timeoutMs }, (res) => {
143
+ res.resume()
144
+ resolve(res.statusCode >= 200 && res.statusCode < 500)
145
+ })
146
+ } catch {
147
+ return resolve(false)
148
+ }
149
+ req.on('error', () => resolve(false))
150
+ req.on('timeout', () => {
151
+ req.destroy()
152
+ resolve(false)
153
+ })
154
+ req.end()
155
+ })
156
+ }
157
+
105
158
  /** Ask whatever listens on `port` whether it is a dsh-remote bridge, and what it supports. */
106
159
  function probeHealth(port, timeoutMs = 1200) {
107
160
  return new Promise((resolve) => {
@@ -158,6 +211,7 @@ export async function start(config = {}) {
158
211
  port = 6300,
159
212
  authority = '127.0.0.1:1815',
160
213
  tunnel: tunnelKind = 'auto',
214
+ publicUrl = '',
161
215
  upstreamPort = 0,
162
216
  domain = '',
163
217
  log = appendLog,
@@ -201,6 +255,9 @@ export async function start(config = {}) {
201
255
  let lastPublicUrl = null
202
256
 
203
257
  /** The bridge may have fallen back to an ephemeral port; everything must report the real one. */
258
+ const lanUrlsFor = (bridgePort) =>
259
+ lanAddresses().map((address) => `http://${address}:${bridgePort}`)
260
+
204
261
  let bridgeInstance = null
205
262
  const actualBridgePort = () => (bridgeInstance ? bridgeInstance.port : Number(port) || 6300)
206
263
 
@@ -210,7 +267,7 @@ export async function start(config = {}) {
210
267
  store: pairingStore,
211
268
  // Read at redeem time so a tunnel URL that appears later is still handed to the phone.
212
269
  current: () => ({
213
- lan: ipForPairing ? `http://${ipForPairing}:${actualBridgePort()}` : null,
270
+ lan: lanUrlsFor(actualBridgePort()),
214
271
  public: lastPublicUrl ?? null,
215
272
  cookie,
216
273
  }),
@@ -308,6 +365,7 @@ export async function start(config = {}) {
308
365
 
309
366
  const tunnel = await startTunnel({
310
367
  kind: tunnelKind,
368
+ publicUrl,
311
369
  bridgePort: bridge.port,
312
370
  authority,
313
371
  domain,
@@ -328,6 +386,15 @@ export async function start(config = {}) {
328
386
  })
329
387
 
330
388
  lastPublicUrl = tunnel.publicUrl
389
+ // A configured relay can be "up" while its reverse tunnel is down; probe it so status does not
390
+ // advertise a URL that cannot answer.
391
+ let publicReachable = null
392
+ if (publicUrl && tunnel.publicUrl) {
393
+ publicReachable = await probeHealthUrl(`${tunnel.publicUrl}/_dsh-remote/health`)
394
+ if (publicReachable === false) {
395
+ log(`dsh-remote: WARNING ${tunnel.publicUrl} does not answer — is the relay's SSH tunnel up?`)
396
+ }
397
+ }
331
398
  const ip = ipForPairing
332
399
  const status = {
333
400
  updatedAt: Date.now(),
@@ -336,7 +403,12 @@ export async function start(config = {}) {
336
403
  authority,
337
404
  upstreamPort: discovery.port || null,
338
405
  lan: { ip, url: ip ? `http://${ip}:${bridge.port}` : null },
339
- public: { url: tunnel.publicUrl, kind: tunnel.kind, ...(tunnel.note ? { note: tunnel.note } : {}) },
406
+ public: {
407
+ url: tunnel.publicUrl,
408
+ kind: tunnel.kind,
409
+ ...(publicReachable === null ? {} : { reachable: publicReachable }),
410
+ ...(tunnel.note ? { note: tunnel.note } : {}),
411
+ },
340
412
  cookie,
341
413
  cookieExpiresAt: expiresAt ?? cookieExpiry(cookie),
342
414
  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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-hub",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "DSH 插件:把电脑上的 DeepSeek Harness 变成手机可访问的服务——起桥接、自动开隧道(ngrok / cloudflared)、签发 30 天 Cookie,并输出地址与凭据,配合 DSH Remote Android App 使用。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",