dsh-remote 0.8.42 → 0.8.45
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.en.md +38 -3
- package/README.md +63 -2
- package/lib/client.js +540 -10
- package/lib/forwards.js +45 -6
- package/lib/index.js +247 -31
- package/lib/openssh.js +838 -0
- package/lib/pool.js +177 -57
- package/lib/registry.js +6 -0
- package/lib/sftp-surface.js +76 -0
- package/lib/web-attach.js +384 -17
- package/lib/web-deploy.js +43 -1
- package/package.json +2 -1
package/lib/web-attach.js
CHANGED
|
@@ -222,14 +222,30 @@ export class WebAttach {
|
|
|
222
222
|
* @param {object} [opts.net] - a `node:net`-compatible module (tests).
|
|
223
223
|
* @param {number} [opts.localPortStart] - first local port to try.
|
|
224
224
|
* @param {number} [opts.localPortMax] - how many ports to try before failing.
|
|
225
|
+
* @param {boolean} [opts.trackPool] - follow the pool across reconnects so a
|
|
226
|
+
* dropped SSH connection degrades to "disconnected" and then SELF-HEALS
|
|
227
|
+
* instead of being a permanent dead end (issue #54). `true` in production.
|
|
225
228
|
*/
|
|
226
229
|
constructor(opts = {}) {
|
|
227
230
|
this.net = opts.net || net
|
|
228
231
|
this.localPortStart = opts.localPortStart || 3088
|
|
229
232
|
this.localPortMax = opts.localPortMax || 50
|
|
233
|
+
/** Whether the pool's lifecycle hooks are ours to install. */
|
|
234
|
+
this.trackPool = opts.trackPool !== false
|
|
230
235
|
/** @type {import('node:net').Server|null} */ this.server = null
|
|
231
236
|
/** @type {Set<import('node:net').Socket>} */ this.sockets = new Set()
|
|
232
237
|
/** @type {object|null} */ this.client = null
|
|
238
|
+
/** The pool this session borrowed its connection from (may be null). */
|
|
239
|
+
this.pool = null
|
|
240
|
+
/** Pool hooks we installed, so close() can hand them back untouched. */
|
|
241
|
+
this.prevOnReady = null
|
|
242
|
+
this.prevOnCloseHook = null
|
|
243
|
+
this.hookedPool = null
|
|
244
|
+
this._installedOnReady = null
|
|
245
|
+
this._installedOnClose = null
|
|
246
|
+
this.attachedClient = null
|
|
247
|
+
/** Bound listener, kept so it can be removed from the client. */
|
|
248
|
+
this.onClientClose = null
|
|
233
249
|
this.localPort = 0
|
|
234
250
|
this.remotePort = 0
|
|
235
251
|
this.token = ''
|
|
@@ -239,6 +255,28 @@ export class WebAttach {
|
|
|
239
255
|
this.machineId = ''
|
|
240
256
|
this.command = ''
|
|
241
257
|
this.startedAt = 0
|
|
258
|
+
/** Launch knobs remembered so a restart after a drop uses the same ones. */
|
|
259
|
+
this.waitSeconds = 0
|
|
260
|
+
this.omittedNoOpen = false
|
|
261
|
+
/** True while a reconnect-triggered re-resolve is in flight. */
|
|
262
|
+
this._resolving = false
|
|
263
|
+
/** True once the borrowed connection has been lost (drives recovery). */
|
|
264
|
+
this.connectionLost = false
|
|
265
|
+
/** undefined = "not asked yet"; false = the remote process is provably gone. */
|
|
266
|
+
this._remoteAlive = undefined
|
|
267
|
+
/** Set by close(): nothing may re-arm the tunnel afterwards. */
|
|
268
|
+
this.closed = false
|
|
269
|
+
/** Set once the borrowed connection has gone away (see _clientLost). */
|
|
270
|
+
this.lastError = ''
|
|
271
|
+
/**
|
|
272
|
+
* A listener that throws EARNS an 'error' event on the emitting object, and
|
|
273
|
+
* an 'error' with no listener is itself fatal — the very crash this module
|
|
274
|
+
* exists to prevent. Nothing here is expected to throw, but a lifecycle
|
|
275
|
+
* callback running on someone else's event loop must never be the reason
|
|
276
|
+
* the host process dies, so both hooks are wrapped.
|
|
277
|
+
*/
|
|
278
|
+
this._onReady = (client) => { try { this._attach(client) } catch { /* never fatal */ } }
|
|
279
|
+
this._onPoolClose = () => { try { this._clientLost('ssh connection closed') } catch { /* never fatal */ } }
|
|
242
280
|
}
|
|
243
281
|
|
|
244
282
|
/** Whether the tunnel is currently listening. */
|
|
@@ -268,11 +306,17 @@ export class WebAttach {
|
|
|
268
306
|
if (!pool) throw new Error('web-attach: a machine pool is required')
|
|
269
307
|
this.machineId = String(machineId || '')
|
|
270
308
|
this.command = command
|
|
309
|
+
this.waitSeconds = Number(waitSeconds) || 45
|
|
271
310
|
|
|
272
311
|
const exec = spec.exec || ((cmd, opts) => pool.exec(cmd, opts))
|
|
273
312
|
// Connecting first also surfaces credential/host-key problems BEFORE we
|
|
274
|
-
// start a remote process we would then have to clean up.
|
|
275
|
-
|
|
313
|
+
// start a remote process we would then have to clean up. `_attach` (rather
|
|
314
|
+
// than a bare assignment) is what subscribes to the connection's 'close',
|
|
315
|
+
// so the loss is noticed the moment it happens instead of on the next
|
|
316
|
+
// request — which is when the old code let ssh2 throw 'Not connected' from
|
|
317
|
+
// inside a socket callback and take the host process with it (issue #54).
|
|
318
|
+
this._attach(await pool.connect())
|
|
319
|
+
this._followPool(pool)
|
|
276
320
|
|
|
277
321
|
if (existingUrl) {
|
|
278
322
|
const parsed = parseWebStart(`dsh web: ${existingUrl}`)
|
|
@@ -398,24 +442,334 @@ export class WebAttach {
|
|
|
398
442
|
})
|
|
399
443
|
}
|
|
400
444
|
|
|
445
|
+
/**
|
|
446
|
+
* Borrow the given connection AND subscribe to its lifecycle.
|
|
447
|
+
*
|
|
448
|
+
* Idempotent for the same client (open() calls it once, the pool's `onReady`
|
|
449
|
+
* calls it again on every reconnect), and it always drops the previous
|
|
450
|
+
* client's listener first, so one session never accumulates listeners.
|
|
451
|
+
*
|
|
452
|
+
* @param {object|null} client - the live ssh2 client / OpenSshConnection.
|
|
453
|
+
*/
|
|
454
|
+
_attach(client) {
|
|
455
|
+
if (!client) return
|
|
456
|
+
if (this.attachedClient && this.attachedClient !== client && this.onClientClose) {
|
|
457
|
+
// A different client replaced the one we borrowed (pool reconnect). Stop
|
|
458
|
+
// listening to the old one; its own 'close' has either fired already or
|
|
459
|
+
// belongs to a connection nobody uses any more.
|
|
460
|
+
try { this.attachedClient.removeListener('close', this.onClientClose) } catch { /* already gone */ }
|
|
461
|
+
}
|
|
462
|
+
this.client = client
|
|
463
|
+
if (this.attachedClient === client) return
|
|
464
|
+
this.attachedClient = client
|
|
465
|
+
this.onClientClose = () => { this._clientLost('ssh connection closed') }
|
|
466
|
+
// Without this, the object keeps handing out a connection that is already
|
|
467
|
+
// closed, and `forwardOut` then throws SYNCHRONOUSLY (issue #54).
|
|
468
|
+
try { client.on('close', this.onClientClose) } catch { /* not an emitter: nothing to watch */ }
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** The borrowed connection is gone: keep the session, drop the dead handle. */
|
|
472
|
+
_clientLost(reason) {
|
|
473
|
+
this.client = null
|
|
474
|
+
this.connectionLost = true
|
|
475
|
+
this.lastError = String(reason || 'ssh connection closed')
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Register lifecycle hooks on the chosen pool so this tunnel follows it.
|
|
480
|
+
*
|
|
481
|
+
* The pool is the one place that both NOTICES a dead connection and later
|
|
482
|
+
* obtains a fresh one, and it exposes exactly two hooks for that:
|
|
483
|
+
* • `onCloseHook` — fires from `close()` and `invalidate()` (the latter is
|
|
484
|
+
* the "channel open failed, the pooled connection died server-side"
|
|
485
|
+
* self-heal path, which is the likeliest trigger);
|
|
486
|
+
* • `onReady` — fires after a successful (re)connect.
|
|
487
|
+
*
|
|
488
|
+
* Both are SINGLE-SLOT properties that the plugin already uses for
|
|
489
|
+
* `ForwardManager` (index.js), so this session CHAINS them rather than
|
|
490
|
+
* clobbering the forwards manager, and `_detachPool()` restores exactly what
|
|
491
|
+
* was there — the plugin may legitimately have none, one, or both.
|
|
492
|
+
*
|
|
493
|
+
* A real reconnect is the only way the remote port can move, so once the pool
|
|
494
|
+
* reports a new connection we must re-resolve (token, port) from the remote
|
|
495
|
+
* log. `onReady` itself cannot await: the pool fires it with the client in
|
|
496
|
+
* hand, so the re-resolve is kicked off and its failure is recorded rather
|
|
497
|
+
* than thrown into the pool's callback.
|
|
498
|
+
*
|
|
499
|
+
* @param {object} pool - the pool this session attached through.
|
|
500
|
+
*/
|
|
501
|
+
_followPool(pool) {
|
|
502
|
+
if (!this.trackPool || this.closed || !pool || this.hookedPool === pool) return
|
|
503
|
+
this._detachPool()
|
|
504
|
+
this.pool = pool
|
|
505
|
+
this.hookedPool = pool
|
|
506
|
+
const prevReady = typeof pool.onReady === 'function' ? pool.onReady : null
|
|
507
|
+
const prevClose = typeof pool.onCloseHook === 'function' ? pool.onCloseHook : null
|
|
508
|
+
this.prevOnReady = prevReady
|
|
509
|
+
this.prevOnCloseHook = prevClose
|
|
510
|
+
const installedReady = (client) => {
|
|
511
|
+
this._attach(client)
|
|
512
|
+
if (prevReady) { try { prevReady(client) } catch { /* the forwards manager's problem, not ours */ } }
|
|
513
|
+
// A reconnect lands on a NEW remote process only if the old one died with
|
|
514
|
+
// the machine; re-read the log to find out which. The re-resolve runs off
|
|
515
|
+
// the event loop because the pool cannot await it — a hook that throws
|
|
516
|
+
// would earn an 'error' event on the pool, and an unhandled rejection
|
|
517
|
+
// would be fatal.
|
|
518
|
+
if (!this.closed && this.remotePort && !this._resolving) {
|
|
519
|
+
this._resolving = true
|
|
520
|
+
Promise.resolve()
|
|
521
|
+
.then(() => this._refreshRemotePort())
|
|
522
|
+
.catch((err) => { this.lastError = String((err && err.message) || err) })
|
|
523
|
+
.then(() => { this._resolving = false })
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
const installedClose = () => {
|
|
527
|
+
this._clientLost('ssh connection closed')
|
|
528
|
+
if (prevClose) { try { prevClose() } catch { /* never mask our own report */ } }
|
|
529
|
+
}
|
|
530
|
+
this._installedOnReady = installedReady
|
|
531
|
+
this._installedOnClose = installedClose
|
|
532
|
+
pool.onReady = installedReady
|
|
533
|
+
pool.onCloseHook = installedClose
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Hand the pool's hooks back.
|
|
538
|
+
*
|
|
539
|
+
* Each slot is restored ONLY when it still holds the wrapper this session
|
|
540
|
+
* installed. The pool's hooks are single-slot, so if something else has taken
|
|
541
|
+
* the slot over in the meantime, putting a stale callback back would be worse
|
|
542
|
+
* than leaving it alone — it would silently disconnect a newer owner.
|
|
543
|
+
*/
|
|
544
|
+
_detachPool() {
|
|
545
|
+
const pool = this.hookedPool
|
|
546
|
+
const mineReady = this._installedOnReady
|
|
547
|
+
const mineClose = this._installedOnClose
|
|
548
|
+
this.hookedPool = null
|
|
549
|
+
this._installedOnReady = null
|
|
550
|
+
this._installedOnClose = null
|
|
551
|
+
if (!pool) return
|
|
552
|
+
if (mineReady && pool.onReady === mineReady) pool.onReady = this.prevOnReady
|
|
553
|
+
if (mineClose && pool.onCloseHook === mineClose) pool.onCloseHook = this.prevOnCloseHook
|
|
554
|
+
this.prevOnReady = null
|
|
555
|
+
this.prevOnCloseHook = null
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* True when the borrowed connection was LOST and this is a session we can
|
|
560
|
+
* recover: we launched the remote process ourselves (so we hold its PID and
|
|
561
|
+
* log) and the tunnel is listening (so clients can actually be served).
|
|
562
|
+
*
|
|
563
|
+
* The `connectionLost` flag is deliberately separate from `this.client`: the
|
|
564
|
+
* pool's `onReady` re-attaches the new client BEFORE the re-resolve runs, so a
|
|
565
|
+
* check on `!this.client` would be false by then and recovery would never
|
|
566
|
+
* happen.
|
|
567
|
+
*/
|
|
568
|
+
get _needsReResolve() {
|
|
569
|
+
return !!(this.connectionLost && !this.closed && this.logPath && this.remotePid && this.localPort)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Re-resolve the remote port after a reconnect, restarting the remote `dsh web`
|
|
574
|
+
* when it did not survive.
|
|
575
|
+
*
|
|
576
|
+
* Reaching a remote process needs a live SSH connection, so this reconnects
|
|
577
|
+
* through the pool first — the pool reports `onReady` for that same client in
|
|
578
|
+
* production, and in a test double the callback simply never fires.
|
|
579
|
+
*/
|
|
580
|
+
async _refreshRemotePort() {
|
|
581
|
+
if (!this._needsReResolve) return
|
|
582
|
+
const pool = this.pool
|
|
583
|
+
if (!pool || (typeof pool.connect !== 'function' && typeof pool.exec !== 'function')) return
|
|
584
|
+
const exec = (cmd, opts) => (typeof pool.exec === 'function'
|
|
585
|
+
? pool.exec(cmd, opts)
|
|
586
|
+
: Promise.reject(new Error('web-attach: the pool cannot run commands')))
|
|
587
|
+
if (!this.client) await pool.connect()
|
|
588
|
+
const match = await this._resolveRemotePort(exec)
|
|
589
|
+
this.remotePort = match.port
|
|
590
|
+
if (match.token) this.token = match.token
|
|
591
|
+
this.connectionLost = false
|
|
592
|
+
this.lastError = ''
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Read this session's remote log and decide which port is live.
|
|
597
|
+
*
|
|
598
|
+
* The startup line is the ONLY source of the port (the launch deliberately
|
|
599
|
+
* uses `--port 0`), so after the remote process died and was restarted the log
|
|
600
|
+
* holds BOTH lines — the dead one and the new one — and neither position
|
|
601
|
+
* identifies the live one. Evidence decides:
|
|
602
|
+
* • a process that is still alive on the remote is probed first, and when
|
|
603
|
+
* the port it is LISTENING on is one of the logged ports, that is the
|
|
604
|
+
* answer (the `dsh web: …` line names its own port, so this is a
|
|
605
|
+
* confirmation rather than a second guess);
|
|
606
|
+
* • otherwise the newest logged port wins, which is right in every
|
|
607
|
+
* single-process case and is the best available answer while the remote is
|
|
608
|
+
* still booting (the next reconnect retries).
|
|
609
|
+
*
|
|
610
|
+
* @param {(cmd: string, opts?: object) => Promise<{stdout: string}>} exec - remote exec.
|
|
611
|
+
* @returns {Promise<{port: number, token: string, restarted: boolean}>}
|
|
612
|
+
*/
|
|
613
|
+
async _resolveRemotePort(exec) {
|
|
614
|
+
this._remoteAlive = await this._remoteProcessAlive(exec)
|
|
615
|
+
if (!this._remoteAlive) return this._restartRemote(exec)
|
|
616
|
+
|
|
617
|
+
let out = ''
|
|
618
|
+
try {
|
|
619
|
+
const res = await exec(`cat "$HOME/${this.logPath}" 2>/dev/null`, { timeoutMs: 15000 })
|
|
620
|
+
out = String((res && res.stdout) || '')
|
|
621
|
+
} catch { /* an unreadable log: the listening probe below is the fallback */ }
|
|
622
|
+
const lines = [...out.matchAll(/dsh web:\s*(http:\/\/[^\s]+)/g)]
|
|
623
|
+
.map((m) => parseWebStart(`dsh web: ${m[1]}`))
|
|
624
|
+
.filter(Boolean)
|
|
625
|
+
|
|
626
|
+
// What the recorded process actually listens on beats any guess from the
|
|
627
|
+
// log, because a killed-and-restarted remote leaves BOTH startup lines in
|
|
628
|
+
// the log and neither position says which one is live.
|
|
629
|
+
const listening = await this._listeningRemotePort(exec)
|
|
630
|
+
if (listening) {
|
|
631
|
+
const match = lines.find((l) => l.port === listening)
|
|
632
|
+
return { port: listening, token: (match && match.token) || this.token, restarted: false }
|
|
633
|
+
}
|
|
634
|
+
if (lines.length) {
|
|
635
|
+
const newest = lines[lines.length - 1]
|
|
636
|
+
return { port: newest.port, token: newest.token, restarted: false }
|
|
637
|
+
}
|
|
638
|
+
// Alive but silent and not listening yet: the remote is booting. Keep the
|
|
639
|
+
// last known port and let the next reconnect re-resolve.
|
|
640
|
+
return { port: this.remotePort, token: this.token, restarted: false }
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Whether the remote process THIS session started still exists.
|
|
645
|
+
*
|
|
646
|
+
* Verified through `/proc`, which is Linux-only; a host that cannot answer (or
|
|
647
|
+
* a failed probe) is treated as ALIVE on purpose. Restarting a server that was
|
|
648
|
+
* never dead would spawn a second one and leak a port, whereas assuming life
|
|
649
|
+
* merely retries a port that turns out to be stale.
|
|
650
|
+
*
|
|
651
|
+
* @param {(cmd: string, opts?: object) => Promise<{stdout: string}>} exec - remote exec.
|
|
652
|
+
* @returns {Promise<boolean>}
|
|
653
|
+
*/
|
|
654
|
+
async _remoteProcessAlive(exec) {
|
|
655
|
+
const pid = Number(this.remotePid) || 0
|
|
656
|
+
if (pid < 1) return true
|
|
657
|
+
try {
|
|
658
|
+
const res = await exec(
|
|
659
|
+
`if [ -d /proc ]; then { [ -d /proc/${pid} ] && echo ALIVE; } || echo GONE; else echo ALIVE; fi`,
|
|
660
|
+
{ timeoutMs: 15000 },
|
|
661
|
+
)
|
|
662
|
+
return !/GONE/.test(String((res && res.stdout) || ''))
|
|
663
|
+
} catch { return true }
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Ask the remote which loopback port the recorded PID listens on (0 = none).
|
|
668
|
+
*
|
|
669
|
+
* Two `/proc` reads are needed because a TCP listener appears in a socket
|
|
670
|
+
* table (`/proc/net/tcp{,6}`), not in that process's link list: the fd
|
|
671
|
+
* symlinks give the socket INODE, and the table maps that inode to a port.
|
|
672
|
+
* Matching on the inode rather than the port is what makes this an
|
|
673
|
+
* observation instead of a second guess.
|
|
674
|
+
*/
|
|
675
|
+
async _listeningRemotePort(exec) {
|
|
676
|
+
const pid = Number(this.remotePid) || 0
|
|
677
|
+
if (pid < 1) return 0
|
|
678
|
+
let inodes = ''
|
|
679
|
+
try {
|
|
680
|
+
// `ls -l` on a process's own fds needs no privileges for the same uid.
|
|
681
|
+
const res = await exec(
|
|
682
|
+
`ls -l /proc/${pid}/fd 2>/dev/null | sed -n 's/.*socket:\\[\\([0-9]*\\)\\].*/\\1/p' | sort -u | tr '\\n' ' '`,
|
|
683
|
+
{ timeoutMs: 15000 },
|
|
684
|
+
)
|
|
685
|
+
inodes = String((res && res.stdout) || '').trim()
|
|
686
|
+
} catch { return 0 }
|
|
687
|
+
if (!inodes) return 0
|
|
688
|
+
const want = new Set(inodes.split(/\s+/).filter(Boolean))
|
|
689
|
+
// `/proc/net/tcp` addresses are little-endian hex, so loopback is
|
|
690
|
+
// `0100007F` (v4) and `00000000000000000000000001000000` (v6 `::1`).
|
|
691
|
+
const LOOPBACK = new Set(['0100007F', '00000000000000000000000001000000'])
|
|
692
|
+
for (const file of ['/proc/net/tcp', '/proc/net/tcp6']) {
|
|
693
|
+
let text = ''
|
|
694
|
+
try {
|
|
695
|
+
const res = await exec(`cat ${file} 2>/dev/null`, { timeoutMs: 15000 })
|
|
696
|
+
text = String((res && res.stdout) || '')
|
|
697
|
+
} catch { continue }
|
|
698
|
+
for (const line of text.split('\n').slice(1)) {
|
|
699
|
+
const cols = line.trim().split(/\s+/)
|
|
700
|
+
// sl local_address rem_address st tx:rx tr:tm->when retrnsmt uid timeout inode
|
|
701
|
+
if (cols.length < 10) continue
|
|
702
|
+
if (cols[3] !== '0A') continue // TCP_LISTEN
|
|
703
|
+
if (!want.has(cols[9])) continue
|
|
704
|
+
const [addr, portHex] = String(cols[1]).split(':')
|
|
705
|
+
if (!LOOPBACK.has(String(addr).toUpperCase())) continue
|
|
706
|
+
const port = parseInt(portHex, 16)
|
|
707
|
+
if (Number.isInteger(port) && port > 0 && port < 65536) return port
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return 0
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Relaunch the remote `dsh web` because the previous process is gone.
|
|
715
|
+
*
|
|
716
|
+
* A restart CANNOT change the listening port locally, and a caller holding a
|
|
717
|
+
* stale port silently talks to the wrong place at worst and hangs at best, so
|
|
718
|
+
* the two facts the caller needs are checked: this is a session that launched
|
|
719
|
+
* its own process (an attach onto someone else's DSH stays hands-off and can
|
|
720
|
+
* never restart it), and recovery is possible at all (`_refreshRemotePort`
|
|
721
|
+
* has already returned early when it is not).
|
|
722
|
+
*/
|
|
723
|
+
async _restartRemote(exec) {
|
|
724
|
+
const command = this.command || 'dsh'
|
|
725
|
+
const waitSeconds = this.waitSeconds || 45
|
|
726
|
+
const timeoutMs = Math.max(30000, (waitSeconds + 20) * 1000)
|
|
727
|
+
let out = String(await this._launch(exec, { command, profile: 'web', logPath: this.logPath, waitSeconds, timeoutMs }, this.omittedNoOpen === true))
|
|
728
|
+
if (launchFailed(out) && /unknown option/i.test(out) && /--no-open/.test(out)) {
|
|
729
|
+
out = String(await this._launch(exec, { command, profile: 'web', logPath: this.logPath, waitSeconds, timeoutMs }, true))
|
|
730
|
+
this.omittedNoOpen = true
|
|
731
|
+
}
|
|
732
|
+
this.remotePid = parseRemotePid(out) || this.remotePid
|
|
733
|
+
const parsed = parseWebStart(out)
|
|
734
|
+
if (!parsed) {
|
|
735
|
+
const tail = out.split(TAIL_MARKER).slice(1).join(TAIL_MARKER).trim()
|
|
736
|
+
throw new Error('web-attach: the remote DSH could not be restarted after the SSH'
|
|
737
|
+
+ ' connection dropped.' + explainLaunchFailure(tail || out) + (tail ? ` Remote output: ${tail}` : ''))
|
|
738
|
+
}
|
|
739
|
+
return { port: parsed.port, token: parsed.token, restarted: true }
|
|
740
|
+
}
|
|
741
|
+
|
|
401
742
|
/** Carry one local socket to the remote web port over SSH. */
|
|
402
743
|
_pipe(socket) {
|
|
403
744
|
if (!this.client) { try { socket.destroy() } catch { /* already gone */ } return }
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
745
|
+
// ssh2's forwardOut does NOT report a dead connection through its callback:
|
|
746
|
+
// when the socket is gone it THROWS 'Not connected' synchronously
|
|
747
|
+
// (ssh2/lib/client.js: `if (!this._sock || !isWritable(this._sock)) throw`)
|
|
748
|
+
// and the callback below is never reached — so `if (err)` cannot guard this
|
|
749
|
+
// path. Uncaught, that throw travels net.Server → TCP.onconnection and kills
|
|
750
|
+
// the whole host process (issue #54: every page refresh after the pooled
|
|
751
|
+
// connection dropped took the session, the browser and the remote orphan
|
|
752
|
+
// down with it). A dead tunnel is a UI state, not a reason to exit.
|
|
753
|
+
try {
|
|
754
|
+
this.client.forwardOut('127.0.0.1', 0, '127.0.0.1', this.remotePort, (err, channel) => {
|
|
755
|
+
if (err) { try { socket.destroy() } catch { /* already gone */ } return }
|
|
756
|
+
this.sockets.add(socket)
|
|
757
|
+
socket.pipe(channel)
|
|
758
|
+
channel.pipe(socket)
|
|
759
|
+
const kill = () => {
|
|
760
|
+
try { socket.destroy() } catch { /* already gone */ }
|
|
761
|
+
try { channel.close() } catch { /* already closed */ }
|
|
762
|
+
this.sockets.delete(socket)
|
|
763
|
+
}
|
|
764
|
+
socket.on('error', kill)
|
|
765
|
+
channel.on('error', kill)
|
|
766
|
+
socket.on('close', kill)
|
|
767
|
+
channel.on('close', kill)
|
|
768
|
+
})
|
|
769
|
+
} catch (err) {
|
|
770
|
+
this._clientLost(String((err && err.message) || err))
|
|
771
|
+
try { socket.destroy() } catch { /* already gone */ }
|
|
772
|
+
}
|
|
419
773
|
}
|
|
420
774
|
|
|
421
775
|
/**
|
|
@@ -462,6 +816,15 @@ export class WebAttach {
|
|
|
462
816
|
* @param {(cmd: string) => Promise<unknown>} [opts.exec] - remote exec.
|
|
463
817
|
*/
|
|
464
818
|
async close({ stopRemote = false, exec } = {}) {
|
|
819
|
+
// Stop following the pool BEFORE any await: a reconnect landing mid-teardown
|
|
820
|
+
// must not re-arm the tunnel this call is in the middle of closing.
|
|
821
|
+
this._detachPool()
|
|
822
|
+
if (this.attachedClient && this.onClientClose) {
|
|
823
|
+
try { this.attachedClient.removeListener('close', this.onClientClose) } catch { /* already gone */ }
|
|
824
|
+
}
|
|
825
|
+
this.attachedClient = null
|
|
826
|
+
this.onClientClose = null
|
|
827
|
+
this.pool = null
|
|
465
828
|
for (const socket of [...this.sockets]) {
|
|
466
829
|
try { socket.destroy() } catch { /* already gone */ }
|
|
467
830
|
}
|
|
@@ -475,5 +838,9 @@ export class WebAttach {
|
|
|
475
838
|
await Promise.resolve(exec(buildStopCommand(this.remotePid, this.logPath))).catch(() => {})
|
|
476
839
|
}
|
|
477
840
|
this.client = null
|
|
841
|
+
// The recorded PID and log path are FACTS about this session (the UI shows
|
|
842
|
+
// them, and `stopRemote` addresses them), so they survive; `closed` is what
|
|
843
|
+
// stops recovery work from being re-armed after teardown.
|
|
844
|
+
this.closed = true
|
|
478
845
|
}
|
|
479
846
|
}
|
package/lib/web-deploy.js
CHANGED
|
@@ -351,6 +351,31 @@ export function versionAtLeast(a, b) {
|
|
|
351
351
|
/** The first dsh release whose node-pty ships Linux prebuilds (measured). */
|
|
352
352
|
export const PTY_FIXED_VERSION = '0.1.5-rc.2'
|
|
353
353
|
|
|
354
|
+
/**
|
|
355
|
+
* Minimum Node the remote must run, and the reason it is exactly this number.
|
|
356
|
+
*
|
|
357
|
+
* DSH's boot path imports `parseEnv` from `node:util`, and that export only
|
|
358
|
+
* exists from **20.12.0** (Node 21.7.0 and later have it too). Measured on a
|
|
359
|
+
* real host with two nodes installed:
|
|
360
|
+
*
|
|
361
|
+
* /usr/bin/node (v18.19.1) → parseEnv: undefined
|
|
362
|
+
* ~/.nvm/versions/node/v24.22.0/bin/node (v24.22.0) → parseEnv: function
|
|
363
|
+
*
|
|
364
|
+
* The failure mode is what makes this worth a BLOCKER rather than a warning: npm
|
|
365
|
+
* only prints EBADENGINE (it does not refuse), so all ~535 packages install
|
|
366
|
+
* cleanly and the crash arrives afterwards as
|
|
367
|
+
*
|
|
368
|
+
* SyntaxError: The requested module 'node:util' does not provide an export
|
|
369
|
+
* named 'parseEnv' at .../@deepseek-ai/dsh-app-boot/lib/index.js:4
|
|
370
|
+
*
|
|
371
|
+
* which does not read as "your node is too old". Checking the version BEFORE the
|
|
372
|
+
* install is the only way to turn that into an actionable message.
|
|
373
|
+
*
|
|
374
|
+
* Reference: Node.js 20.12.0 release notes, "Loading and parsing environment
|
|
375
|
+
* variables" — https://nodejs.org/en/blog/release/v20.12.0
|
|
376
|
+
*/
|
|
377
|
+
export const MIN_NODE_VERSION = '20.12.0'
|
|
378
|
+
|
|
354
379
|
/**
|
|
355
380
|
* Turn facts into a verdict: what is wrong, why, and what to do.
|
|
356
381
|
*
|
|
@@ -394,6 +419,17 @@ export function judgeProbe(facts, opts = {}) {
|
|
|
394
419
|
add('NO_NODE', 'blocker', '远端没有 node',
|
|
395
420
|
'自动部署需要 node 与 npm。',
|
|
396
421
|
'先在该机器安装 Node.js(nvm 或系统包管理器),再回来重试。')
|
|
422
|
+
} else if (f.nodeVersion && !versionAtLeast(f.nodeVersion, MIN_NODE_VERSION)) {
|
|
423
|
+
// The install itself SUCCEEDS on this node — npm only warns (EBADENGINE) —
|
|
424
|
+
// and the failure surfaces later as a parseEnv import error that does not
|
|
425
|
+
// read as a version problem. So the check has to happen before the install.
|
|
426
|
+
add('NODE_TOO_OLD', 'blocker',
|
|
427
|
+
`远端 node ${f.nodeVersion} 版本过低(需要 ≥ ${MIN_NODE_VERSION})`,
|
|
428
|
+
`dsh 启动时要 import node:util 的 parseEnv,该导出从 Node ${MIN_NODE_VERSION} 起才有。`
|
|
429
|
+
+ '在这个版本上 npm 只会给 EBADENGINE 警告、仍然把包装完,'
|
|
430
|
+
+ '直到启动时才崩:SyntaxError: The requested module \'node:util\' does not provide an export named \'parseEnv\'。',
|
|
431
|
+
`升级该机器的 node 到 ${MIN_NODE_VERSION} 以上(例如 nvm install 22),`
|
|
432
|
+
+ '并把 `webAttachCommand` 指向新 node 的绝对路径(如 ~/.nvm/versions/node/v22.x/bin/node <前缀>/node_modules/@deepseek-ai/dsh/lib/bin.js)。')
|
|
397
433
|
}
|
|
398
434
|
if (!f.npm) {
|
|
399
435
|
add('NO_NPM', 'blocker', '远端没有 npm',
|
|
@@ -474,7 +510,13 @@ export function judgeProbe(facts, opts = {}) {
|
|
|
474
510
|
const blockers = findings.filter((x) => x.severity === 'blocker')
|
|
475
511
|
// A Windows remote with Git Bash can be installed into: pool.exec pipes every
|
|
476
512
|
// command through `bash -s`, so the POSIX plan works there.
|
|
477
|
-
|
|
513
|
+
//
|
|
514
|
+
// A node older than MIN_NODE_VERSION makes an install POINTLESS as well as
|
|
515
|
+
// failing: the packages land, then the binary cannot boot. Promising
|
|
516
|
+
// "can auto-install: yes" there is what sent the reporter of issue #54 through
|
|
517
|
+
// a 535-package install that ended in a parseEnv SyntaxError.
|
|
518
|
+
const nodeUsable = !!f.node && (!f.nodeVersion || versionAtLeast(f.nodeVersion, MIN_NODE_VERSION))
|
|
519
|
+
const canAutoInstall = nodeUsable && !!f.npm && (!f.windows || !!f.gitBash)
|
|
478
520
|
|
|
479
521
|
// Which command should the attach flow use? A verified deployment wins, then a
|
|
480
522
|
// usable dsh already on PATH.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.45",
|
|
4
4
|
"description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive/proxy jump), pick a remote workspace, operate on it with 22 rw_* tools (incl. rw_edit/rw_stat/rw_mkdir/rw_remove/rw_move/rw_forward), conflict-aware mirror sync, one-click remote dsh deploy + web UI attach, port forwarding, optional side-bar remote file editing, audit log, keychain passwords.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "flymysql <flyphp@outlook.com>",
|
|
@@ -80,6 +80,7 @@
|
|
|
80
80
|
"@deepseek-ai/dsh-session": "^0.1.0-rc.8",
|
|
81
81
|
"@deepseek-ai/dsh-timeout": "^0.1.0-rc.8",
|
|
82
82
|
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
|
|
83
|
+
"react": "^18.3.1",
|
|
83
84
|
"semver": "^7.8.5"
|
|
84
85
|
},
|
|
85
86
|
"scripts": {
|