dsh-remote-hub 0.3.3 → 0.4.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 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 就用二维码:
@@ -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() {
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.3'
19
+ export const version = '0.4.0'
20
20
 
21
21
  /** No host services are required: the plugin only needs Node builtins. */
22
22
  export const inject = []
@@ -22,11 +22,13 @@ export const PAIR_PREFIX = '/_dsh-remote/pair'
22
22
  * fastest one at connect time: `l` = LAN (private address), `u` = public tunnel. Either may be null
23
23
  * when that transport is unavailable that moment.
24
24
  */
25
- export function buildPairPayload({ lan, publicUrl, code, 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.
25
+ export function buildPairPayload({ lan, relay, tunnel, code, expiresAt }) {
26
+ // v3 ships all three transports so one scan can fill every tab:
27
+ // l = LAN addresses (list; only one adapter may be reachable from the phone)
28
+ // r = self-hosted relay (Aliyun/Caddy + reverse SSH)
29
+ // u = auto tunnel (ngrok / cloudflared)
28
30
  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 })
31
+ return JSON.stringify({ v: 3, l: lanList, r: relay ?? null, u: tunnel ?? null, c: code, e: expiresAt })
30
32
  }
31
33
 
32
34
  /**
@@ -76,10 +78,12 @@ export async function handlePairing({ req, res, current, store }) {
76
78
  res.end(
77
79
  JSON.stringify({
78
80
  ok: true,
79
- // `url` keeps the old single-address shape working; new clients read lan/public.
80
- url: snapshot.public ?? (Array.isArray(snapshot.lan) ? snapshot.lan[0] : snapshot.lan) ?? null,
81
+ // `url`/`public` keep the old single-address shape working; new clients read the three.
82
+ url: snapshot.relay ?? snapshot.tunnel ?? null,
81
83
  lan: Array.isArray(snapshot.lan) ? snapshot.lan : snapshot.lan ? [snapshot.lan] : [],
82
- public: snapshot.public ?? null,
84
+ relay: snapshot.relay ?? null,
85
+ tunnel: snapshot.tunnel ?? null,
86
+ public: snapshot.relay ?? snapshot.tunnel ?? null,
83
87
  cookie: result.cookie,
84
88
  issuedAt: result.issuedAt,
85
89
  }),
@@ -89,7 +93,7 @@ export async function handlePairing({ req, res, current, store }) {
89
93
 
90
94
  // Any other GET under the prefix hands out a fresh code.
91
95
  const { code, expiresAt, ttlMs } = store.issue(snapshot.cookie)
92
- const payload = buildPairPayload({ lan: snapshot.lan, publicUrl: snapshot.public, code, expiresAt })
96
+ const payload = buildPairPayload({ lan: snapshot.lan, relay: snapshot.relay, tunnel: snapshot.tunnel, code, expiresAt })
93
97
 
94
98
  if (url === PAIR_PREFIX + '/svg') {
95
99
  const svg = await QRCode.toString(payload, { type: 'svg', margin: 1, width: 320, errorCorrectionLevel: 'M' })
@@ -122,7 +126,7 @@ export async function handlePairing({ req, res, current, store }) {
122
126
  <p>配对码 <code>${code}</code> · ${minutes} 分钟内有效,且只能用一次</p>
123
127
  <div class="qr">${svg}</div>
124
128
  <p class="ok">扫码后自动填入地址并换取登录凭据</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>
129
+ <p class="addr">${(Array.isArray(snapshot.lan) ? snapshot.lan : snapshot.lan ? [snapshot.lan] : []).map((u) => `局域网:${u}`).join('<br>')}${snapshot.relay ? `<br>中转:${snapshot.relay}` : ''}${snapshot.tunnel ? `<br>隧道:${snapshot.tunnel}` : ''}</p>
126
130
  <p class="addr">扫一次会同时存下局域网与公网地址,之后自动优先走局域网</p>
127
131
  </div></body></html>`
128
132
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
package/lib/supervisor.js CHANGED
@@ -11,6 +11,7 @@
11
11
 
12
12
  import http from 'node:http'
13
13
  import https from 'node:https'
14
+ import { execFile } from 'node:child_process'
14
15
  import fs from 'node:fs'
15
16
  import os from 'node:os'
16
17
  import path from 'node:path'
@@ -126,6 +127,21 @@ function writeStatus(status) {
126
127
  fs.writeFileSync(CONNECT_FILE, lines.join(os.EOL), 'utf8')
127
128
  }
128
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
+
129
145
  /** Probe a full URL (used for the configured public relay) and report whether it answers. */
130
146
  function probeHealthUrl(url, timeoutMs = 4000) {
131
147
  return new Promise((resolve) => {
@@ -268,7 +284,8 @@ export async function start(config = {}) {
268
284
  // Read at redeem time so a tunnel URL that appears later is still handed to the phone.
269
285
  current: () => ({
270
286
  lan: lanUrlsFor(actualBridgePort()),
271
- public: lastPublicUrl ?? null,
287
+ relay: relayUrl,
288
+ tunnel: tunnelRef?.publicUrl ?? null,
272
289
  cookie,
273
290
  }),
274
291
  }
@@ -332,6 +349,10 @@ export async function start(config = {}) {
332
349
  log('dsh-remote: port is held by an old bridge WITHOUT pairing support; using another port')
333
350
  } else {
334
351
  log(`dsh-remote: port ${wanted} is busy (not a dsh-remote bridge); using another port`)
352
+ // Say WHO holds it: the usual culprit is a leftover lan-proxy.mjs from an older setup, and
353
+ // knowing the PID is what lets the user stop it instead of guessing.
354
+ const owner = await describePortOwner(wanted)
355
+ if (owner) log(`dsh-remote: port ${wanted} is held by: ${owner}`)
335
356
  }
336
357
  bridge = await startBridge({
337
358
  listenPort: 0,
@@ -365,7 +386,6 @@ export async function start(config = {}) {
365
386
 
366
387
  const tunnel = await startTunnel({
367
388
  kind: tunnelKind,
368
- publicUrl,
369
389
  bridgePort: bridge.port,
370
390
  authority,
371
391
  domain,
@@ -385,14 +405,15 @@ export async function start(config = {}) {
385
405
  },
386
406
  })
387
407
 
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?`)
408
+ // The self-hosted relay (if configured) and the auto tunnel are reported SEPARATELY: a self-use
409
+ // setup wants both (relay for 4G, ngrok as a second path), and the phone decides which to use.
410
+ const relayUrl = String(publicUrl || '').trim().replace(/\/+$/, '') || null
411
+ lastPublicUrl = relayUrl ?? tunnel.publicUrl
412
+ let relayReachable = null
413
+ if (relayUrl) {
414
+ relayReachable = await probeHealthUrl(`${relayUrl}/_dsh-remote/health`)
415
+ if (relayReachable === false) {
416
+ log(`dsh-remote: WARNING ${relayUrl} does not answer — is the relay's SSH tunnel up?`)
396
417
  }
397
418
  }
398
419
  const ip = ipForPairing
@@ -403,12 +424,11 @@ export async function start(config = {}) {
403
424
  authority,
404
425
  upstreamPort: discovery.port || null,
405
426
  lan: { ip, url: ip ? `http://${ip}:${bridge.port}` : null },
406
- public: {
407
- url: tunnel.publicUrl,
408
- kind: tunnel.kind,
409
- ...(publicReachable === null ? {} : { reachable: publicReachable }),
410
- ...(tunnel.note ? { note: tunnel.note } : {}),
411
- },
427
+ public: relayUrl
428
+ ? { url: relayUrl, kind: 'relay', ...(relayReachable === null ? {} : { reachable: relayReachable }) }
429
+ : { url: tunnel.publicUrl, kind: tunnel.kind, ...(tunnel.note ? { note: tunnel.note } : {}) },
430
+ relay: relayUrl ? { url: relayUrl, ...(relayReachable === null ? {} : { reachable: relayReachable }) } : null,
431
+ tunnel: tunnel.publicUrl ? { url: tunnel.publicUrl, kind: tunnel.kind, ...(tunnel.note ? { note: tunnel.note } : {}) } : null,
412
432
  cookie,
413
433
  cookieExpiresAt: expiresAt ?? cookieExpiry(cookie),
414
434
  pairing: {
package/lib/tunnel.js CHANGED
@@ -148,16 +148,7 @@ 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 = '', 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
- }
160
- if (kind === 'off') return { publicUrl: null, kind: 'none', close: () => {}, note: 'tunnel disabled by config' }
151
+ export async function startTunnel({ kind = 'auto', bridgePort, authority, domain = '', publicUrl = '', log = () => {} }) { if (kind === 'off') return { publicUrl: null, kind: 'none', close: () => {}, note: 'tunnel disabled by config' }
161
152
 
162
153
  const wantNgrok = kind === 'auto' || kind === 'ngrok'
163
154
  const wantCloudflared = kind === 'auto' || kind === 'cloudflared'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-hub",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "DSH 插件:把电脑上的 DeepSeek Harness 变成手机可访问的服务——起桥接、自动开隧道(ngrok / cloudflared)、签发 30 天 Cookie,并输出地址与凭据,配合 DSH Remote Android App 使用。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",