dsh-remote 0.8.72 → 0.8.74

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.
Files changed (44) hide show
  1. package/LICENSE +21 -21
  2. package/README.en.md +470 -389
  3. package/README.md +529 -462
  4. package/cordis.patch.yml +9 -9
  5. package/lib/binding.js +172 -172
  6. package/lib/client.js +4039 -4022
  7. package/lib/credential.js +263 -263
  8. package/lib/errors.js +141 -141
  9. package/lib/exec-job.js +235 -235
  10. package/lib/file-reference.js +457 -457
  11. package/lib/forwards.js +258 -258
  12. package/lib/host-api.js +808 -0
  13. package/lib/hostkey.js +108 -108
  14. package/lib/http-transport.js +196 -196
  15. package/lib/ignore.js +202 -202
  16. package/lib/index.js +4923 -4541
  17. package/lib/instructions-dynamic.js +415 -415
  18. package/lib/openssh.js +879 -879
  19. package/lib/paths.js +252 -252
  20. package/lib/pool.js +963 -893
  21. package/lib/registry.js +296 -293
  22. package/lib/remote-fs.js +173 -173
  23. package/lib/remote-sandbox.js +183 -183
  24. package/lib/remote-shell-child.cjs +344 -305
  25. package/lib/remote-shell.js +542 -518
  26. package/lib/routes-fs.js +245 -245
  27. package/lib/routes-term.js +169 -169
  28. package/lib/search.js +235 -235
  29. package/lib/sftp-surface.js +119 -119
  30. package/lib/sshconfig.js +779 -705
  31. package/lib/sync.js +275 -275
  32. package/lib/tasks.js +95 -95
  33. package/lib/telemetry.js +213 -213
  34. package/lib/terminal.js +205 -205
  35. package/lib/tui/flow.js +130 -0
  36. package/lib/tui/format.js +332 -0
  37. package/lib/tui/i18n.js +344 -0
  38. package/lib/tui/index.js +322 -0
  39. package/lib/tui/provider.js +327 -0
  40. package/lib/tui/scene.js +996 -0
  41. package/lib/update.js +472 -472
  42. package/lib/web-attach.js +850 -850
  43. package/lib/workspace-cleanup.js +337 -0
  44. package/package.json +100 -99
package/lib/web-attach.js CHANGED
@@ -1,850 +1,850 @@
1
- // dsh-remote — "remote DSH web UI" attach sessions (issue #46).
2
- //
3
- // The user's intent behind that issue is "use the local DSH as a CLIENT for a
4
- // DSH running on another machine": they do NOT want the remote to expose an
5
- // inbound listener. DSH makes that impossible anyway — `dsh-web-app` hard-
6
- // rejects `--host 0.0.0.0` (startup.js) because it would put remote code
7
- // execution on the network, and `dsh-host-webserver` only accepts the
8
- // 127.0.0.1/0.0.0.0 literals.
9
- //
10
- // So this module inverts the direction, exactly like the rest of the plugin:
11
- // we SSH OUT, start (or reuse) a loopback-bound `dsh web` on the remote, and
12
- // carry its socket back over our own SSH connection with a local TCP forward.
13
- // The browser then talks to 127.0.0.1:<localPort> on this machine.
14
- //
15
- // Verified end-to-end on a real Linux host (dsh 0.1.5-rc.2): a bare TCP forward
16
- // is sufficient — the SPA, its assets, AND the `/api/remote.mux` WebSocket
17
- // upgrade all transit it (HTTP/1.1 101), with no reverse proxy and no host
18
- // rewriting. That works because DSH binds its auth cookie to the request
19
- // authority, so a cookie minted against `127.0.0.1:<localPort>` is exactly what
20
- // the browser then sends back to that same authority.
21
- //
22
- // Three details drive the design and are worth stating once:
23
- //
24
- // 1. The launch token CANNOT be read from anywhere but stdout. It is a
25
- // `randomBytes` value held in a process-local WeakMap and printed on one
26
- // line (`dsh web: http://127.0.0.1:<port>/?token=…`). No file, no env var.
27
- // Therefore the token is captured by redirecting the remote process's
28
- // output to a log file and parsing that line out of it.
29
- // 2. `--port 0` lets the remote OS pick a free port, and the same printed line
30
- // reports which port it chose — so we never have to guess or probe for a
31
- // free port on the remote.
32
- // 3. The pairing that must survive is (token, port) from ONE launch. Reading
33
- // them from one line is what keeps them consistent.
34
- //
35
- // Everything here is read-only with respect to the remote filesystem except
36
- // the log file it creates under the remote `$HOME`.
37
- import net from 'node:net'
38
-
39
- /** Remote log file per attach session; `$HOME` is expanded by the remote shell. */
40
- const LOG_NAME = '.dsh-remote-web'
41
-
42
- /**
43
- * Parse the `dsh web:` startup line out of a remote log.
44
- *
45
- * The line looks like:
46
- * dsh web: http://127.0.0.1:43129/?token=Iy7FKuPSI5qJKJUFDyah4B6i8Ul6_hvspiJhiWufYt8
47
- * and may be followed by a LAN suffix when the remote bound 0.0.0.0 (it never
48
- * will here — we never pass `--host` — but tolerate it rather than break).
49
- *
50
- * @param {string} text - the remote log contents.
51
- * @returns {{url: string, port: number, token: string}|null} parsed facts, or
52
- * null when the line has not appeared yet (still booting) or is malformed.
53
- */
54
- export function parseWebStart(text) {
55
- if (typeof text !== 'string' || text === '') return null
56
- // Take the LAST match: a reused log file may hold an earlier session's line,
57
- // and the newest one is the one this launch produced.
58
- const matches = [...text.matchAll(/dsh web:\s*(http:\/\/[^\s]+)/g)]
59
- if (matches.length === 0) return null
60
- const url = matches[matches.length - 1][1]
61
- let parsed
62
- try {
63
- parsed = new URL(url)
64
- } catch {
65
- return null
66
- }
67
- const port = Number(parsed.port)
68
- const token = parsed.searchParams.get('token') || ''
69
- if (!Number.isInteger(port) || port < 1 || port > 65535) return null
70
- if (!token) return null
71
- return { url, port, token }
72
- }
73
-
74
- /** True when the log proves the remote process died instead of booting. */
75
- export function launchFailed(text) {
76
- return typeof text === 'string' && text.includes(DEAD_MARKER)
77
- }
78
-
79
- /** Marker the remote launcher echoes when the process exits before printing. */
80
- const DEAD_MARKER = '__DSH_REMOTE_DIED__'
81
- /** Marker separating the parsed line from the raw log tail for diagnostics. */
82
- export const TAIL_MARKER = '__DSH_TAIL__'
83
- /** Marker carrying the remote PID so the session can stop its own process. */
84
- export const PID_MARKER = '__DSH_PID__='
85
- /** Random ASCII tag so two attach sessions never share a remote log file. */
86
- function randomTag() {
87
- return Math.random().toString(36).slice(2, 8) + Date.now().toString(36).slice(-4)
88
- }
89
-
90
- /**
91
- * Build the one-shot remote command that launches `dsh web` detached and waits
92
- * for its startup line.
93
- *
94
- * `setsid` + `nohup` + redirected stdio are all required: the SSH channel closes
95
- * as soon as this shell exits, and without detaching the SIGHUP would kill the
96
- * server we just started. The loop is bounded so a wedged remote cannot hang the
97
- * call past the caller's own timeout, and it reports either the parsed line or
98
- * the log tail — never silence.
99
- *
100
- * @param {object} spec - launch parameters.
101
- * @param {boolean} [spec.omitNoOpen] - drop `--no-open`. Older DSH (measured on
102
- * 0.1.0-rc.6) has no such flag and exits with "unknown option '--no-open'";
103
- * the caller retries with this set rather than assuming a flag exists.
104
- * @returns {string} a POSIX shell command line (no newlines, ASCII only).
105
- */
106
- export function buildLaunchCommand({ command, profile, logPath, remotePort = 0, dshHome = '', waitSeconds = 45, omitNoOpen = false }) {
107
- const env = dshHome ? `DSH_HOME="${dshHome}" ` : ''
108
- const noOpen = omitNoOpen ? '' : ' --no-open'
109
- const args = `--profile ${profile} --port ${Number(remotePort) || 0}${noOpen}`
110
- const limit = Number(waitSeconds) || 45
111
- // The loop is ONE shell element: `do` is a keyword and `do;` is a syntax error
112
- // in POSIX sh, so it must be glued to the first command of the body rather
113
- // than separated like the other statements.
114
- const loop = `while [ $i -lt ${limit} ]; do grep -q 'token=' "$LOG" 2>/dev/null && break`
115
- + `; kill -0 $P 2>/dev/null || { echo ${DEAD_MARKER}; break; }`
116
- + `; sleep 1; i=$((i+1)); done`
117
- return [
118
- `LOG="$HOME/${logPath}"`,
119
- `: > "$LOG"`,
120
- // `command -v setsid` guards hosts without util-linux (macOS); plain nohup
121
- // still detaches from the channel's HUP there.
122
- `command -v setsid >/dev/null 2>&1 && SET=setsid || SET=`,
123
- `${env}$SET nohup ${command} ${args} >"$LOG" 2>&1 </dev/null & P=$!`,
124
- `i=0`,
125
- loop,
126
- `echo "${PID_MARKER}$P"`,
127
- `grep -m1 'dsh web:' "$LOG" 2>/dev/null`,
128
- `echo ${TAIL_MARKER}`,
129
- `tail -20 "$LOG" 2>/dev/null`,
130
- ].join('; ')
131
- }
132
-
133
- /** Extract the remote PID from launcher output (0 when absent). */
134
- export function parseRemotePid(output) {
135
- const m = /__DSH_PID__=(\d+)/.exec(String(output || ''))
136
- return m ? Number(m[1]) : 0
137
- }
138
-
139
- /**
140
- * Recognize a known remote-side reason the web surface could not start, and
141
- * return an actionable sentence for it.
142
- *
143
- * The point is to keep an operator from reading "the remote DSH did not report a
144
- * startup token" and concluding the *plugin* is broken. Measured cause: dsh
145
- * ≤ 0.1.0-rc.6 depends on `node-pty@1.1.0`, whose published tarball ships
146
- * darwin/win32 prebuilds but **no `linux-x64`**, so `dsh web` cannot boot on
147
- * Linux at all — it dies loading the native module. 0.1.5-rc.2 moved to
148
- * `node-pty@1.2.0-beta.15`, which does ship Linux builds.
149
- *
150
- * @param {string} output - the remote log tail.
151
- * @returns {string} a hint, or '' when nothing is recognized.
152
- */
153
- export function explainLaunchFailure(output) {
154
- const text = String(output || '')
155
- if (/pty\.node|Failed to load native module/i.test(text)) {
156
- return ' The remote could not load node-pty\'s native module. Known cause: dsh <= 0.1.0-rc.6'
157
- + ' depends on node-pty@1.1.0, which publishes no linux-x64 prebuild, so `dsh web` cannot start'
158
- + ' on Linux at all. Upgrade the remote dsh to 0.1.5-rc.2 or newer (node-pty@1.2.0-beta.15 ships'
159
- + ' the Linux build), or point `webAttachCommand` at a newer dsh installed elsewhere.'
160
- }
161
- if (/is intentionally not supported|--host 0\.0\.0\.0/i.test(text)) {
162
- return ' The remote dsh refused an all-interfaces bind. Attach deliberately never passes `--host`;'
163
- + ' check `webAttachCommand` does not add one.'
164
- }
165
- if (/command not found|No such file or directory|not found/i.test(text)) {
166
- return ' The remote shell could not run the dsh command. If the remote dsh is not on the SSH login'
167
- + ' PATH (a version manager or private prefix), set `webAttachCommand` to its absolute path.'
168
- }
169
- return ''
170
- }
171
-
172
- /**
173
- * Build the command that stops the remote process THIS session started.
174
- *
175
- * Two things make this less obvious than it looks, both found by running it
176
- * against a real host:
177
- * 1. The log path cannot identify the process — it appears only as a shell
178
- * redirect, so it is absent from argv and `pkill -f <logfile>` matches
179
- * nothing (the process survived, verified live).
180
- * 2. The PORT cannot identify it either, because we deliberately launch with
181
- * `--port 0` (the remote picks a free port, and the chosen number is only
182
- * known afterwards). A `--port 0` substring match therefore never fires.
183
- *
184
- * So the recorded PID is the handle, and the kill targets its process GROUP
185
- * (`kill -- -PID`): the launch goes through `setsid`, making the child a
186
- * session/group leader whose PGID equals its PID, so the group contains exactly
187
- * the process tree we started — never a sibling attach session and never the
188
- * user's own DSH. Before signalling, the command re-verifies that the PID still
189
- * exists and still looks like a `dsh` process, so a recycled PID belonging to an
190
- * unrelated program is not killed.
191
- *
192
- * @param {number} pid - the PID reported by the launch.
193
- * @param {string} [logPath] - this session's log file, removed after the kill so
194
- * repeated attaches do not accumulate files in the remote home.
195
- * @returns {string} a POSIX shell command (always exits 0, never throws).
196
- */
197
- export function buildStopCommand(pid, logPath = '') {
198
- const p = Number(pid) || 0
199
- const cleanup = logPath ? `; rm -f "$HOME/${logPath}" 2>/dev/null` : ''
200
- if (p < 1) return `true${cleanup}`
201
- return `P=${p}`
202
- + `; if [ -d "/proc/$P" ]; then`
203
- + ` C=$(tr '\\0' ' ' < "/proc/$P/cmdline" 2>/dev/null);`
204
- + ` case "$C" in *dsh*) kill -- -"$P" 2>/dev/null || kill "$P" 2>/dev/null;; esac;`
205
- + ` fi${cleanup}; true`
206
- }
207
-
208
- /**
209
- * One attached remote DSH Web UI: a local listener whose sockets are carried
210
- * over SSH to the remote's loopback web port.
211
- *
212
- * The listener is a plain `net.Server` + `client.forwardOut` pair — the same
213
- * mechanism `ForwardManager` uses for user-defined forwards. It is implemented
214
- * here rather than through that manager because this tunnel is bound to a
215
- * CHOSEN pool (the machine the user picked), whereas `ForwardManager` is wired
216
- * to the single "active machine" pool; routing an attach through it would send
217
- * the tunnel to whichever machine happened to be active.
218
- */
219
- export class WebAttach {
220
- /**
221
- * @param {object} opts - injected collaborators (all optional in production).
222
- * @param {object} [opts.net] - a `node:net`-compatible module (tests).
223
- * @param {number} [opts.localPortStart] - first local port to try.
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.
228
- */
229
- constructor(opts = {}) {
230
- this.net = opts.net || net
231
- // `??`, not `||`: port 0 is a MEANINGFUL value here (let the OS pick), and
232
- // `0 || 3088` silently turned "let the OS choose" into a bind on a fixed
233
- // port. An explicit 0 now survives, which is also what lets a test ask for a
234
- // port without risking a collision with something really listening.
235
- this.localPortStart = opts.localPortStart ?? 3088
236
- this.localPortMax = opts.localPortMax ?? 50
237
- /** Whether the pool's lifecycle hooks are ours to install. */
238
- this.trackPool = opts.trackPool !== false
239
- /** @type {import('node:net').Server|null} */ this.server = null
240
- /** @type {Set<import('node:net').Socket>} */ this.sockets = new Set()
241
- /** @type {object|null} */ this.client = null
242
- /** The pool this session borrowed its connection from (may be null). */
243
- this.pool = null
244
- /** Pool hooks we installed, so close() can hand them back untouched. */
245
- this.prevOnReady = null
246
- this.prevOnCloseHook = null
247
- this.hookedPool = null
248
- this._installedOnReady = null
249
- this._installedOnClose = null
250
- this.attachedClient = null
251
- /** Bound listener, kept so it can be removed from the client. */
252
- this.onClientClose = null
253
- this.localPort = 0
254
- this.remotePort = 0
255
- this.token = ''
256
- this.tag = ''
257
- this.logPath = ''
258
- this.remotePid = 0
259
- this.machineId = ''
260
- this.command = ''
261
- this.startedAt = 0
262
- /** Launch knobs remembered so a restart after a drop uses the same ones. */
263
- this.waitSeconds = 0
264
- this.omittedNoOpen = false
265
- /** True while a reconnect-triggered re-resolve is in flight. */
266
- this._resolving = false
267
- /** True once the borrowed connection has been lost (drives recovery). */
268
- this.connectionLost = false
269
- /** undefined = "not asked yet"; false = the remote process is provably gone. */
270
- this._remoteAlive = undefined
271
- /** Set by close(): nothing may re-arm the tunnel afterwards. */
272
- this.closed = false
273
- /** Set once the borrowed connection has gone away (see _clientLost). */
274
- this.lastError = ''
275
- /**
276
- * A listener that throws EARNS an 'error' event on the emitting object, and
277
- * an 'error' with no listener is itself fatal — the very crash this module
278
- * exists to prevent. Nothing here is expected to throw, but a lifecycle
279
- * callback running on someone else's event loop must never be the reason
280
- * the host process dies, so both hooks are wrapped.
281
- */
282
- this._onReady = (client) => { try { this._attach(client) } catch { /* never fatal */ } }
283
- this._onPoolClose = () => { try { this._clientLost('ssh connection closed') } catch { /* never fatal */ } }
284
- }
285
-
286
- /** Whether the tunnel is currently listening. */
287
- get active() {
288
- return this.server !== null
289
- }
290
-
291
- /**
292
- * Attach to a remote DSH Web UI.
293
- *
294
- * @param {object} spec - the attach request.
295
- * @param {object} spec.pool - the SshPool for the chosen machine.
296
- * @param {string} spec.machineId - machine id, for bookkeeping.
297
- * @param {string} [spec.command] - remote dsh executable (default `dsh`).
298
- * @param {string} [spec.profile] - DSH profile to boot (default `web`).
299
- * @param {string} [spec.existingUrl] - attach to an ALREADY running remote
300
- * web UI instead of starting one: its `http://127.0.0.1:<port>/?token=…`.
301
- * @param {(cmd: string, opts?: object) => Promise<{stdout: string, code: number}>} [spec.exec]
302
- * - remote exec, defaulting to `pool.exec`.
303
- * @returns {Promise<{url: string, localPort: number, remotePort: number}>}
304
- */
305
- async open(spec) {
306
- const {
307
- pool, machineId = '', command = 'dsh', profile = 'web',
308
- existingUrl = '', dshHome = '', waitSeconds = 45,
309
- } = spec || {}
310
- if (!pool) throw new Error('web-attach: a machine pool is required')
311
- this.machineId = String(machineId || '')
312
- this.command = command
313
- this.waitSeconds = Number(waitSeconds) || 45
314
-
315
- const exec = spec.exec || ((cmd, opts) => pool.exec(cmd, opts))
316
- // Connecting first also surfaces credential/host-key problems BEFORE we
317
- // start a remote process we would then have to clean up. `_attach` (rather
318
- // than a bare assignment) is what subscribes to the connection's 'close',
319
- // so the loss is noticed the moment it happens instead of on the next
320
- // request — which is when the old code let ssh2 throw 'Not connected' from
321
- // inside a socket callback and take the host process with it (issue #54).
322
- this._attach(await pool.connect())
323
- this._followPool(pool)
324
-
325
- if (existingUrl) {
326
- const parsed = parseWebStart(`dsh web: ${existingUrl}`)
327
- if (!parsed) {
328
- throw new Error('web-attach: existing URL must look like http://127.0.0.1:<port>/?token=<token>')
329
- }
330
- this.remotePort = parsed.port
331
- this.token = parsed.token
332
- } else {
333
- this.tag = randomTag()
334
- const logPath = `${LOG_NAME}-${this.tag}.log`
335
- this.logPath = logPath
336
- const timeoutMs = Math.max(30000, (waitSeconds + 20) * 1000)
337
- let out = String(await this._launch(exec, { command, profile, logPath, dshHome, waitSeconds, timeoutMs }, false))
338
- // Older DSH (0.1.0-rc.6) has no `--no-open` and exits on the unknown flag
339
- // before doing anything. Retry once without it: `--no-open` is a nicety
340
- // (it stops the REMOTE from opening its own browser), not a requirement,
341
- // and a bounded single retry is safe because the first attempt provably
342
- // never started a server.
343
- if (launchFailed(out) && /unknown option/i.test(out) && /--no-open/.test(out)) {
344
- out = String(await this._launch(exec, { command, profile, logPath, dshHome, waitSeconds, timeoutMs }, true))
345
- this.omittedNoOpen = true
346
- }
347
- this.remotePid = parseRemotePid(out)
348
- const tail = out.split(TAIL_MARKER).slice(1).join(TAIL_MARKER).trim()
349
- // Both failure shapes carry the same diagnostics: the process either died
350
- // (its output is the whole story) or is still booting after the deadline.
351
- if (launchFailed(out)) {
352
- // The remote process is already gone — nothing to clean up.
353
- throw new Error('web-attach: the remote DSH exited during startup.'
354
- + explainLaunchFailure(tail || out)
355
- + ` ${tail || 'no output'}`)
356
- }
357
- const parsed = parseWebStart(out)
358
- if (!parsed) {
359
- // A timed-out launch may STILL be booting: the deadline expiring is not
360
- // evidence the process died. Kill it before throwing, or every retry
361
- // leaks another remote server (measured: three leftover `dsh --port 0`
362
- // processes after three failed attaches). A PID of 0 means the launcher
363
- // could not report one — nothing to target.
364
- if (this.remotePid > 0) {
365
- try {
366
- await exec(buildStopCommand(this.remotePid, logPath), { timeoutMs: 20000 })
367
- this.remotePid = 0
368
- } catch { /* the process may already be gone; a leftover is worse than a failed kill attempt only if it repeats, and the next attach replaces this session */ }
369
- }
370
- throw new Error(
371
- `web-attach: the remote DSH did not report a startup token within ${waitSeconds}s.`
372
- + ` Check that \`${command}\` exists on the remote and can boot a web profile.`
373
- + explainLaunchFailure(tail || out)
374
- + (tail ? ` Remote output: ${tail}` : ''),
375
- )
376
- }
377
- this.remotePort = parsed.port
378
- this.token = parsed.token
379
- }
380
-
381
- await this._listen()
382
- this.startedAt = Date.now()
383
- return this.describe()
384
- }
385
-
386
- /**
387
- * Run one launch attempt and return its raw output.
388
- *
389
- * @param {(cmd: string, opts?: object) => Promise<{stdout: string}>} exec - remote exec.
390
- * @param {object} d - launch parameters plus the exec timeout.
391
- * @param {boolean} omitNoOpen - drop `--no-open` for old DSH.
392
- * @returns {Promise<string>} the launcher's stdout.
393
- */
394
- async _launch(exec, d, omitNoOpen) {
395
- const cmd = buildLaunchCommand({ ...d, omitNoOpen })
396
- const res = await exec(cmd, { timeoutMs: d.timeoutMs })
397
- return String(res && res.stdout ? res.stdout : '')
398
- }
399
-
400
- /** Bind the local listener, advancing past ports already in use. */
401
- async _listen() {
402
- let lastError = null
403
- for (let i = 0; i < this.localPortMax; i++) {
404
- const port = this.localPortStart + i
405
- try {
406
- await this._listenOn(port)
407
- this.localPort = port
408
- return
409
- } catch (err) {
410
- lastError = err
411
- // Only a taken port is worth retrying; anything else (permissions,
412
- // an unusable interface) will not be fixed by the next number.
413
- if (err && err.code !== 'EADDRINUSE') break
414
- }
415
- }
416
- throw new Error(
417
- `web-attach: no free local port in ${this.localPortStart}..${this.localPortStart + this.localPortMax - 1}`
418
- + (lastError ? ` (${lastError.code || lastError.message})` : ''),
419
- )
420
- }
421
-
422
- _listenOn(port) {
423
- return new Promise((resolve, reject) => {
424
- const server = this.net.createServer((socket) => this._pipe(socket))
425
- const onError = (err) => {
426
- server.removeListener('listening', onListening)
427
- try { server.close() } catch { /* never listened */ }
428
- reject(err)
429
- }
430
- const onListening = () => {
431
- server.removeListener('error', onError)
432
- // A post-bind error must not crash the host process: report and drop
433
- // the tunnel so the UI can show "disconnected" instead of dying.
434
- server.on('error', (err) => {
435
- this.server = null
436
- this.lastError = String((err && err.message) || err)
437
- })
438
- this.server = server
439
- resolve()
440
- }
441
- server.once('error', onError)
442
- server.once('listening', onListening)
443
- // Loopback only: this endpoint reaches a full remote harness, so it must
444
- // never be reachable from the network even on a LAN-facing machine.
445
- server.listen(port, '127.0.0.1')
446
- })
447
- }
448
-
449
- /**
450
- * Borrow the given connection AND subscribe to its lifecycle.
451
- *
452
- * Idempotent for the same client (open() calls it once, the pool's `onReady`
453
- * calls it again on every reconnect), and it always drops the previous
454
- * client's listener first, so one session never accumulates listeners.
455
- *
456
- * @param {object|null} client - the live ssh2 client / OpenSshConnection.
457
- */
458
- _attach(client) {
459
- if (!client) return
460
- if (this.attachedClient && this.attachedClient !== client && this.onClientClose) {
461
- // A different client replaced the one we borrowed (pool reconnect). Stop
462
- // listening to the old one; its own 'close' has either fired already or
463
- // belongs to a connection nobody uses any more.
464
- try { this.attachedClient.removeListener('close', this.onClientClose) } catch { /* already gone */ }
465
- }
466
- this.client = client
467
- if (this.attachedClient === client) return
468
- this.attachedClient = client
469
- this.onClientClose = () => { this._clientLost('ssh connection closed') }
470
- // Without this, the object keeps handing out a connection that is already
471
- // closed, and `forwardOut` then throws SYNCHRONOUSLY (issue #54).
472
- try { client.on('close', this.onClientClose) } catch { /* not an emitter: nothing to watch */ }
473
- }
474
-
475
- /** The borrowed connection is gone: keep the session, drop the dead handle. */
476
- _clientLost(reason) {
477
- this.client = null
478
- this.connectionLost = true
479
- this.lastError = String(reason || 'ssh connection closed')
480
- }
481
-
482
- /**
483
- * Register lifecycle hooks on the chosen pool so this tunnel follows it.
484
- *
485
- * The pool is the one place that both NOTICES a dead connection and later
486
- * obtains a fresh one, and it exposes exactly two hooks for that:
487
- * • `onCloseHook` — fires from `close()` and `invalidate()` (the latter is
488
- * the "channel open failed, the pooled connection died server-side"
489
- * self-heal path, which is the likeliest trigger);
490
- * • `onReady` — fires after a successful (re)connect.
491
- *
492
- * Both are SINGLE-SLOT properties that the plugin already uses for
493
- * `ForwardManager` (index.js), so this session CHAINS them rather than
494
- * clobbering the forwards manager, and `_detachPool()` restores exactly what
495
- * was there — the plugin may legitimately have none, one, or both.
496
- *
497
- * A real reconnect is the only way the remote port can move, so once the pool
498
- * reports a new connection we must re-resolve (token, port) from the remote
499
- * log. `onReady` itself cannot await: the pool fires it with the client in
500
- * hand, so the re-resolve is kicked off and its failure is recorded rather
501
- * than thrown into the pool's callback.
502
- *
503
- * @param {object} pool - the pool this session attached through.
504
- */
505
- _followPool(pool) {
506
- if (!this.trackPool || this.closed || !pool || this.hookedPool === pool) return
507
- this._detachPool()
508
- this.pool = pool
509
- this.hookedPool = pool
510
- const prevReady = typeof pool.onReady === 'function' ? pool.onReady : null
511
- const prevClose = typeof pool.onCloseHook === 'function' ? pool.onCloseHook : null
512
- this.prevOnReady = prevReady
513
- this.prevOnCloseHook = prevClose
514
- const installedReady = (client) => {
515
- this._attach(client)
516
- if (prevReady) { try { prevReady(client) } catch { /* the forwards manager's problem, not ours */ } }
517
- // A reconnect lands on a NEW remote process only if the old one died with
518
- // the machine; re-read the log to find out which. The re-resolve runs off
519
- // the event loop because the pool cannot await it — a hook that throws
520
- // would earn an 'error' event on the pool, and an unhandled rejection
521
- // would be fatal.
522
- if (!this.closed && this.remotePort && !this._resolving) {
523
- this._resolving = true
524
- Promise.resolve()
525
- .then(() => this._refreshRemotePort())
526
- .catch((err) => { this.lastError = String((err && err.message) || err) })
527
- .then(() => { this._resolving = false })
528
- }
529
- }
530
- const installedClose = () => {
531
- this._clientLost('ssh connection closed')
532
- if (prevClose) { try { prevClose() } catch { /* never mask our own report */ } }
533
- }
534
- this._installedOnReady = installedReady
535
- this._installedOnClose = installedClose
536
- pool.onReady = installedReady
537
- pool.onCloseHook = installedClose
538
- }
539
-
540
- /**
541
- * Hand the pool's hooks back.
542
- *
543
- * Each slot is restored ONLY when it still holds the wrapper this session
544
- * installed. The pool's hooks are single-slot, so if something else has taken
545
- * the slot over in the meantime, putting a stale callback back would be worse
546
- * than leaving it alone — it would silently disconnect a newer owner.
547
- */
548
- _detachPool() {
549
- const pool = this.hookedPool
550
- const mineReady = this._installedOnReady
551
- const mineClose = this._installedOnClose
552
- this.hookedPool = null
553
- this._installedOnReady = null
554
- this._installedOnClose = null
555
- if (!pool) return
556
- if (mineReady && pool.onReady === mineReady) pool.onReady = this.prevOnReady
557
- if (mineClose && pool.onCloseHook === mineClose) pool.onCloseHook = this.prevOnCloseHook
558
- this.prevOnReady = null
559
- this.prevOnCloseHook = null
560
- }
561
-
562
- /**
563
- * True when the borrowed connection was LOST and this is a session we can
564
- * recover: we launched the remote process ourselves (so we hold its PID and
565
- * log) and the tunnel is listening (so clients can actually be served).
566
- *
567
- * The `connectionLost` flag is deliberately separate from `this.client`: the
568
- * pool's `onReady` re-attaches the new client BEFORE the re-resolve runs, so a
569
- * check on `!this.client` would be false by then and recovery would never
570
- * happen.
571
- */
572
- get _needsReResolve() {
573
- return !!(this.connectionLost && !this.closed && this.logPath && this.remotePid && this.localPort)
574
- }
575
-
576
- /**
577
- * Re-resolve the remote port after a reconnect, restarting the remote `dsh web`
578
- * when it did not survive.
579
- *
580
- * Reaching a remote process needs a live SSH connection, so this reconnects
581
- * through the pool first — the pool reports `onReady` for that same client in
582
- * production, and in a test double the callback simply never fires.
583
- */
584
- async _refreshRemotePort() {
585
- if (!this._needsReResolve) return
586
- const pool = this.pool
587
- if (!pool || (typeof pool.connect !== 'function' && typeof pool.exec !== 'function')) return
588
- const exec = (cmd, opts) => (typeof pool.exec === 'function'
589
- ? pool.exec(cmd, opts)
590
- : Promise.reject(new Error('web-attach: the pool cannot run commands')))
591
- if (!this.client) await pool.connect()
592
- const match = await this._resolveRemotePort(exec)
593
- this.remotePort = match.port
594
- if (match.token) this.token = match.token
595
- this.connectionLost = false
596
- this.lastError = ''
597
- }
598
-
599
- /**
600
- * Read this session's remote log and decide which port is live.
601
- *
602
- * The startup line is the ONLY source of the port (the launch deliberately
603
- * uses `--port 0`), so after the remote process died and was restarted the log
604
- * holds BOTH lines — the dead one and the new one — and neither position
605
- * identifies the live one. Evidence decides:
606
- * • a process that is still alive on the remote is probed first, and when
607
- * the port it is LISTENING on is one of the logged ports, that is the
608
- * answer (the `dsh web: …` line names its own port, so this is a
609
- * confirmation rather than a second guess);
610
- * • otherwise the newest logged port wins, which is right in every
611
- * single-process case and is the best available answer while the remote is
612
- * still booting (the next reconnect retries).
613
- *
614
- * @param {(cmd: string, opts?: object) => Promise<{stdout: string}>} exec - remote exec.
615
- * @returns {Promise<{port: number, token: string, restarted: boolean}>}
616
- */
617
- async _resolveRemotePort(exec) {
618
- this._remoteAlive = await this._remoteProcessAlive(exec)
619
- if (!this._remoteAlive) return this._restartRemote(exec)
620
-
621
- let out = ''
622
- try {
623
- const res = await exec(`cat "$HOME/${this.logPath}" 2>/dev/null`, { timeoutMs: 15000 })
624
- out = String((res && res.stdout) || '')
625
- } catch { /* an unreadable log: the listening probe below is the fallback */ }
626
- const lines = [...out.matchAll(/dsh web:\s*(http:\/\/[^\s]+)/g)]
627
- .map((m) => parseWebStart(`dsh web: ${m[1]}`))
628
- .filter(Boolean)
629
-
630
- // What the recorded process actually listens on beats any guess from the
631
- // log, because a killed-and-restarted remote leaves BOTH startup lines in
632
- // the log and neither position says which one is live.
633
- const listening = await this._listeningRemotePort(exec)
634
- if (listening) {
635
- const match = lines.find((l) => l.port === listening)
636
- return { port: listening, token: (match && match.token) || this.token, restarted: false }
637
- }
638
- if (lines.length) {
639
- const newest = lines[lines.length - 1]
640
- return { port: newest.port, token: newest.token, restarted: false }
641
- }
642
- // Alive but silent and not listening yet: the remote is booting. Keep the
643
- // last known port and let the next reconnect re-resolve.
644
- return { port: this.remotePort, token: this.token, restarted: false }
645
- }
646
-
647
- /**
648
- * Whether the remote process THIS session started still exists.
649
- *
650
- * Verified through `/proc`, which is Linux-only; a host that cannot answer (or
651
- * a failed probe) is treated as ALIVE on purpose. Restarting a server that was
652
- * never dead would spawn a second one and leak a port, whereas assuming life
653
- * merely retries a port that turns out to be stale.
654
- *
655
- * @param {(cmd: string, opts?: object) => Promise<{stdout: string}>} exec - remote exec.
656
- * @returns {Promise<boolean>}
657
- */
658
- async _remoteProcessAlive(exec) {
659
- const pid = Number(this.remotePid) || 0
660
- if (pid < 1) return true
661
- try {
662
- const res = await exec(
663
- `if [ -d /proc ]; then { [ -d /proc/${pid} ] && echo ALIVE; } || echo GONE; else echo ALIVE; fi`,
664
- { timeoutMs: 15000 },
665
- )
666
- return !/GONE/.test(String((res && res.stdout) || ''))
667
- } catch { return true }
668
- }
669
-
670
- /**
671
- * Ask the remote which loopback port the recorded PID listens on (0 = none).
672
- *
673
- * Two `/proc` reads are needed because a TCP listener appears in a socket
674
- * table (`/proc/net/tcp{,6}`), not in that process's link list: the fd
675
- * symlinks give the socket INODE, and the table maps that inode to a port.
676
- * Matching on the inode rather than the port is what makes this an
677
- * observation instead of a second guess.
678
- */
679
- async _listeningRemotePort(exec) {
680
- const pid = Number(this.remotePid) || 0
681
- if (pid < 1) return 0
682
- let inodes = ''
683
- try {
684
- // `ls -l` on a process's own fds needs no privileges for the same uid.
685
- const res = await exec(
686
- `ls -l /proc/${pid}/fd 2>/dev/null | sed -n 's/.*socket:\\[\\([0-9]*\\)\\].*/\\1/p' | sort -u | tr '\\n' ' '`,
687
- { timeoutMs: 15000 },
688
- )
689
- inodes = String((res && res.stdout) || '').trim()
690
- } catch { return 0 }
691
- if (!inodes) return 0
692
- const want = new Set(inodes.split(/\s+/).filter(Boolean))
693
- // `/proc/net/tcp` addresses are little-endian hex, so loopback is
694
- // `0100007F` (v4) and `00000000000000000000000001000000` (v6 `::1`).
695
- const LOOPBACK = new Set(['0100007F', '00000000000000000000000001000000'])
696
- for (const file of ['/proc/net/tcp', '/proc/net/tcp6']) {
697
- let text = ''
698
- try {
699
- const res = await exec(`cat ${file} 2>/dev/null`, { timeoutMs: 15000 })
700
- text = String((res && res.stdout) || '')
701
- } catch { continue }
702
- for (const line of text.split('\n').slice(1)) {
703
- const cols = line.trim().split(/\s+/)
704
- // sl local_address rem_address st tx:rx tr:tm->when retrnsmt uid timeout inode
705
- if (cols.length < 10) continue
706
- if (cols[3] !== '0A') continue // TCP_LISTEN
707
- if (!want.has(cols[9])) continue
708
- const [addr, portHex] = String(cols[1]).split(':')
709
- if (!LOOPBACK.has(String(addr).toUpperCase())) continue
710
- const port = parseInt(portHex, 16)
711
- if (Number.isInteger(port) && port > 0 && port < 65536) return port
712
- }
713
- }
714
- return 0
715
- }
716
-
717
- /**
718
- * Relaunch the remote `dsh web` because the previous process is gone.
719
- *
720
- * A restart CANNOT change the listening port locally, and a caller holding a
721
- * stale port silently talks to the wrong place at worst and hangs at best, so
722
- * the two facts the caller needs are checked: this is a session that launched
723
- * its own process (an attach onto someone else's DSH stays hands-off and can
724
- * never restart it), and recovery is possible at all (`_refreshRemotePort`
725
- * has already returned early when it is not).
726
- */
727
- async _restartRemote(exec) {
728
- const command = this.command || 'dsh'
729
- const waitSeconds = this.waitSeconds || 45
730
- const timeoutMs = Math.max(30000, (waitSeconds + 20) * 1000)
731
- let out = String(await this._launch(exec, { command, profile: 'web', logPath: this.logPath, waitSeconds, timeoutMs }, this.omittedNoOpen === true))
732
- if (launchFailed(out) && /unknown option/i.test(out) && /--no-open/.test(out)) {
733
- out = String(await this._launch(exec, { command, profile: 'web', logPath: this.logPath, waitSeconds, timeoutMs }, true))
734
- this.omittedNoOpen = true
735
- }
736
- this.remotePid = parseRemotePid(out) || this.remotePid
737
- const parsed = parseWebStart(out)
738
- if (!parsed) {
739
- const tail = out.split(TAIL_MARKER).slice(1).join(TAIL_MARKER).trim()
740
- throw new Error('web-attach: the remote DSH could not be restarted after the SSH'
741
- + ' connection dropped.' + explainLaunchFailure(tail || out) + (tail ? ` Remote output: ${tail}` : ''))
742
- }
743
- return { port: parsed.port, token: parsed.token, restarted: true }
744
- }
745
-
746
- /** Carry one local socket to the remote web port over SSH. */
747
- _pipe(socket) {
748
- if (!this.client) { try { socket.destroy() } catch { /* already gone */ } return }
749
- // ssh2's forwardOut does NOT report a dead connection through its callback:
750
- // when the socket is gone it THROWS 'Not connected' synchronously
751
- // (ssh2/lib/client.js: `if (!this._sock || !isWritable(this._sock)) throw`)
752
- // and the callback below is never reached — so `if (err)` cannot guard this
753
- // path. Uncaught, that throw travels net.Server → TCP.onconnection and kills
754
- // the whole host process (issue #54: every page refresh after the pooled
755
- // connection dropped took the session, the browser and the remote orphan
756
- // down with it). A dead tunnel is a UI state, not a reason to exit.
757
- try {
758
- this.client.forwardOut('127.0.0.1', 0, '127.0.0.1', this.remotePort, (err, channel) => {
759
- if (err) { try { socket.destroy() } catch { /* already gone */ } return }
760
- this.sockets.add(socket)
761
- socket.pipe(channel)
762
- channel.pipe(socket)
763
- const kill = () => {
764
- try { socket.destroy() } catch { /* already gone */ }
765
- try { channel.close() } catch { /* already closed */ }
766
- this.sockets.delete(socket)
767
- }
768
- socket.on('error', kill)
769
- channel.on('error', kill)
770
- socket.on('close', kill)
771
- channel.on('close', kill)
772
- })
773
- } catch (err) {
774
- this._clientLost(String((err && err.message) || err))
775
- try { socket.destroy() } catch { /* already gone */ }
776
- }
777
- }
778
-
779
- /**
780
- * The URL to open, carrying the one-time launch token.
781
- *
782
- * The token is a credential, so it stays on loopback and is never sent
783
- * anywhere; DSH exchanges it for a 30-day signed cookie on the first request
784
- * and redirects to a clean `/`.
785
- */
786
- get url() {
787
- return `http://127.0.0.1:${this.localPort}/?token=${this.token}`
788
- }
789
-
790
- /** The clean URL (no token) — what the address bar shows after the exchange. */
791
- get cleanUrl() {
792
- return `http://127.0.0.1:${this.localPort}/`
793
- }
794
-
795
- /** Serializable status for the settings UI. */
796
- describe() {
797
- return {
798
- machineId: this.machineId,
799
- url: this.url,
800
- cleanUrl: this.cleanUrl,
801
- token: this.token,
802
- localPort: this.localPort,
803
- remotePort: this.remotePort,
804
- active: this.active,
805
- startedAt: this.startedAt,
806
- // Only a process we launched can be stopped by us; an attach onto the
807
- // user's own running instance is deliberately hands-off.
808
- managed: this.remotePid > 0,
809
- error: this.lastError || '',
810
- }
811
- }
812
-
813
- /**
814
- * Tear the tunnel down. The remote process is left running by default: the
815
- * user may be using that DSH elsewhere, and silently killing a process we did
816
- * not start is worse than a leftover one. `stopRemote` opts in.
817
- *
818
- * @param {object} [opts] - close options.
819
- * @param {boolean} [opts.stopRemote] - also terminate the remote process.
820
- * @param {(cmd: string) => Promise<unknown>} [opts.exec] - remote exec.
821
- */
822
- async close({ stopRemote = false, exec } = {}) {
823
- // Stop following the pool BEFORE any await: a reconnect landing mid-teardown
824
- // must not re-arm the tunnel this call is in the middle of closing.
825
- this._detachPool()
826
- if (this.attachedClient && this.onClientClose) {
827
- try { this.attachedClient.removeListener('close', this.onClientClose) } catch { /* already gone */ }
828
- }
829
- this.attachedClient = null
830
- this.onClientClose = null
831
- this.pool = null
832
- for (const socket of [...this.sockets]) {
833
- try { socket.destroy() } catch { /* already gone */ }
834
- }
835
- this.sockets.clear()
836
- const server = this.server
837
- this.server = null
838
- if (server) await new Promise((resolve) => server.close(() => resolve()))
839
- // Only a process we started has a recorded PID; an attach to the user's own
840
- // running instance must never signal anything.
841
- if (stopRemote && exec && this.remotePid) {
842
- await Promise.resolve(exec(buildStopCommand(this.remotePid, this.logPath))).catch(() => {})
843
- }
844
- this.client = null
845
- // The recorded PID and log path are FACTS about this session (the UI shows
846
- // them, and `stopRemote` addresses them), so they survive; `closed` is what
847
- // stops recovery work from being re-armed after teardown.
848
- this.closed = true
849
- }
850
- }
1
+ // dsh-remote — "remote DSH web UI" attach sessions (issue #46).
2
+ //
3
+ // The user's intent behind that issue is "use the local DSH as a CLIENT for a
4
+ // DSH running on another machine": they do NOT want the remote to expose an
5
+ // inbound listener. DSH makes that impossible anyway — `dsh-web-app` hard-
6
+ // rejects `--host 0.0.0.0` (startup.js) because it would put remote code
7
+ // execution on the network, and `dsh-host-webserver` only accepts the
8
+ // 127.0.0.1/0.0.0.0 literals.
9
+ //
10
+ // So this module inverts the direction, exactly like the rest of the plugin:
11
+ // we SSH OUT, start (or reuse) a loopback-bound `dsh web` on the remote, and
12
+ // carry its socket back over our own SSH connection with a local TCP forward.
13
+ // The browser then talks to 127.0.0.1:<localPort> on this machine.
14
+ //
15
+ // Verified end-to-end on a real Linux host (dsh 0.1.5-rc.2): a bare TCP forward
16
+ // is sufficient — the SPA, its assets, AND the `/api/remote.mux` WebSocket
17
+ // upgrade all transit it (HTTP/1.1 101), with no reverse proxy and no host
18
+ // rewriting. That works because DSH binds its auth cookie to the request
19
+ // authority, so a cookie minted against `127.0.0.1:<localPort>` is exactly what
20
+ // the browser then sends back to that same authority.
21
+ //
22
+ // Three details drive the design and are worth stating once:
23
+ //
24
+ // 1. The launch token CANNOT be read from anywhere but stdout. It is a
25
+ // `randomBytes` value held in a process-local WeakMap and printed on one
26
+ // line (`dsh web: http://127.0.0.1:<port>/?token=…`). No file, no env var.
27
+ // Therefore the token is captured by redirecting the remote process's
28
+ // output to a log file and parsing that line out of it.
29
+ // 2. `--port 0` lets the remote OS pick a free port, and the same printed line
30
+ // reports which port it chose — so we never have to guess or probe for a
31
+ // free port on the remote.
32
+ // 3. The pairing that must survive is (token, port) from ONE launch. Reading
33
+ // them from one line is what keeps them consistent.
34
+ //
35
+ // Everything here is read-only with respect to the remote filesystem except
36
+ // the log file it creates under the remote `$HOME`.
37
+ import net from 'node:net'
38
+
39
+ /** Remote log file per attach session; `$HOME` is expanded by the remote shell. */
40
+ const LOG_NAME = '.dsh-remote-web'
41
+
42
+ /**
43
+ * Parse the `dsh web:` startup line out of a remote log.
44
+ *
45
+ * The line looks like:
46
+ * dsh web: http://127.0.0.1:43129/?token=Iy7FKuPSI5qJKJUFDyah4B6i8Ul6_hvspiJhiWufYt8
47
+ * and may be followed by a LAN suffix when the remote bound 0.0.0.0 (it never
48
+ * will here — we never pass `--host` — but tolerate it rather than break).
49
+ *
50
+ * @param {string} text - the remote log contents.
51
+ * @returns {{url: string, port: number, token: string}|null} parsed facts, or
52
+ * null when the line has not appeared yet (still booting) or is malformed.
53
+ */
54
+ export function parseWebStart(text) {
55
+ if (typeof text !== 'string' || text === '') return null
56
+ // Take the LAST match: a reused log file may hold an earlier session's line,
57
+ // and the newest one is the one this launch produced.
58
+ const matches = [...text.matchAll(/dsh web:\s*(http:\/\/[^\s]+)/g)]
59
+ if (matches.length === 0) return null
60
+ const url = matches[matches.length - 1][1]
61
+ let parsed
62
+ try {
63
+ parsed = new URL(url)
64
+ } catch {
65
+ return null
66
+ }
67
+ const port = Number(parsed.port)
68
+ const token = parsed.searchParams.get('token') || ''
69
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return null
70
+ if (!token) return null
71
+ return { url, port, token }
72
+ }
73
+
74
+ /** True when the log proves the remote process died instead of booting. */
75
+ export function launchFailed(text) {
76
+ return typeof text === 'string' && text.includes(DEAD_MARKER)
77
+ }
78
+
79
+ /** Marker the remote launcher echoes when the process exits before printing. */
80
+ const DEAD_MARKER = '__DSH_REMOTE_DIED__'
81
+ /** Marker separating the parsed line from the raw log tail for diagnostics. */
82
+ export const TAIL_MARKER = '__DSH_TAIL__'
83
+ /** Marker carrying the remote PID so the session can stop its own process. */
84
+ export const PID_MARKER = '__DSH_PID__='
85
+ /** Random ASCII tag so two attach sessions never share a remote log file. */
86
+ function randomTag() {
87
+ return Math.random().toString(36).slice(2, 8) + Date.now().toString(36).slice(-4)
88
+ }
89
+
90
+ /**
91
+ * Build the one-shot remote command that launches `dsh web` detached and waits
92
+ * for its startup line.
93
+ *
94
+ * `setsid` + `nohup` + redirected stdio are all required: the SSH channel closes
95
+ * as soon as this shell exits, and without detaching the SIGHUP would kill the
96
+ * server we just started. The loop is bounded so a wedged remote cannot hang the
97
+ * call past the caller's own timeout, and it reports either the parsed line or
98
+ * the log tail — never silence.
99
+ *
100
+ * @param {object} spec - launch parameters.
101
+ * @param {boolean} [spec.omitNoOpen] - drop `--no-open`. Older DSH (measured on
102
+ * 0.1.0-rc.6) has no such flag and exits with "unknown option '--no-open'";
103
+ * the caller retries with this set rather than assuming a flag exists.
104
+ * @returns {string} a POSIX shell command line (no newlines, ASCII only).
105
+ */
106
+ export function buildLaunchCommand({ command, profile, logPath, remotePort = 0, dshHome = '', waitSeconds = 45, omitNoOpen = false }) {
107
+ const env = dshHome ? `DSH_HOME="${dshHome}" ` : ''
108
+ const noOpen = omitNoOpen ? '' : ' --no-open'
109
+ const args = `--profile ${profile} --port ${Number(remotePort) || 0}${noOpen}`
110
+ const limit = Number(waitSeconds) || 45
111
+ // The loop is ONE shell element: `do` is a keyword and `do;` is a syntax error
112
+ // in POSIX sh, so it must be glued to the first command of the body rather
113
+ // than separated like the other statements.
114
+ const loop = `while [ $i -lt ${limit} ]; do grep -q 'token=' "$LOG" 2>/dev/null && break`
115
+ + `; kill -0 $P 2>/dev/null || { echo ${DEAD_MARKER}; break; }`
116
+ + `; sleep 1; i=$((i+1)); done`
117
+ return [
118
+ `LOG="$HOME/${logPath}"`,
119
+ `: > "$LOG"`,
120
+ // `command -v setsid` guards hosts without util-linux (macOS); plain nohup
121
+ // still detaches from the channel's HUP there.
122
+ `command -v setsid >/dev/null 2>&1 && SET=setsid || SET=`,
123
+ `${env}$SET nohup ${command} ${args} >"$LOG" 2>&1 </dev/null & P=$!`,
124
+ `i=0`,
125
+ loop,
126
+ `echo "${PID_MARKER}$P"`,
127
+ `grep -m1 'dsh web:' "$LOG" 2>/dev/null`,
128
+ `echo ${TAIL_MARKER}`,
129
+ `tail -20 "$LOG" 2>/dev/null`,
130
+ ].join('; ')
131
+ }
132
+
133
+ /** Extract the remote PID from launcher output (0 when absent). */
134
+ export function parseRemotePid(output) {
135
+ const m = /__DSH_PID__=(\d+)/.exec(String(output || ''))
136
+ return m ? Number(m[1]) : 0
137
+ }
138
+
139
+ /**
140
+ * Recognize a known remote-side reason the web surface could not start, and
141
+ * return an actionable sentence for it.
142
+ *
143
+ * The point is to keep an operator from reading "the remote DSH did not report a
144
+ * startup token" and concluding the *plugin* is broken. Measured cause: dsh
145
+ * ≤ 0.1.0-rc.6 depends on `node-pty@1.1.0`, whose published tarball ships
146
+ * darwin/win32 prebuilds but **no `linux-x64`**, so `dsh web` cannot boot on
147
+ * Linux at all — it dies loading the native module. 0.1.5-rc.2 moved to
148
+ * `node-pty@1.2.0-beta.15`, which does ship Linux builds.
149
+ *
150
+ * @param {string} output - the remote log tail.
151
+ * @returns {string} a hint, or '' when nothing is recognized.
152
+ */
153
+ export function explainLaunchFailure(output) {
154
+ const text = String(output || '')
155
+ if (/pty\.node|Failed to load native module/i.test(text)) {
156
+ return ' The remote could not load node-pty\'s native module. Known cause: dsh <= 0.1.0-rc.6'
157
+ + ' depends on node-pty@1.1.0, which publishes no linux-x64 prebuild, so `dsh web` cannot start'
158
+ + ' on Linux at all. Upgrade the remote dsh to 0.1.5-rc.2 or newer (node-pty@1.2.0-beta.15 ships'
159
+ + ' the Linux build), or point `webAttachCommand` at a newer dsh installed elsewhere.'
160
+ }
161
+ if (/is intentionally not supported|--host 0\.0\.0\.0/i.test(text)) {
162
+ return ' The remote dsh refused an all-interfaces bind. Attach deliberately never passes `--host`;'
163
+ + ' check `webAttachCommand` does not add one.'
164
+ }
165
+ if (/command not found|No such file or directory|not found/i.test(text)) {
166
+ return ' The remote shell could not run the dsh command. If the remote dsh is not on the SSH login'
167
+ + ' PATH (a version manager or private prefix), set `webAttachCommand` to its absolute path.'
168
+ }
169
+ return ''
170
+ }
171
+
172
+ /**
173
+ * Build the command that stops the remote process THIS session started.
174
+ *
175
+ * Two things make this less obvious than it looks, both found by running it
176
+ * against a real host:
177
+ * 1. The log path cannot identify the process — it appears only as a shell
178
+ * redirect, so it is absent from argv and `pkill -f <logfile>` matches
179
+ * nothing (the process survived, verified live).
180
+ * 2. The PORT cannot identify it either, because we deliberately launch with
181
+ * `--port 0` (the remote picks a free port, and the chosen number is only
182
+ * known afterwards). A `--port 0` substring match therefore never fires.
183
+ *
184
+ * So the recorded PID is the handle, and the kill targets its process GROUP
185
+ * (`kill -- -PID`): the launch goes through `setsid`, making the child a
186
+ * session/group leader whose PGID equals its PID, so the group contains exactly
187
+ * the process tree we started — never a sibling attach session and never the
188
+ * user's own DSH. Before signalling, the command re-verifies that the PID still
189
+ * exists and still looks like a `dsh` process, so a recycled PID belonging to an
190
+ * unrelated program is not killed.
191
+ *
192
+ * @param {number} pid - the PID reported by the launch.
193
+ * @param {string} [logPath] - this session's log file, removed after the kill so
194
+ * repeated attaches do not accumulate files in the remote home.
195
+ * @returns {string} a POSIX shell command (always exits 0, never throws).
196
+ */
197
+ export function buildStopCommand(pid, logPath = '') {
198
+ const p = Number(pid) || 0
199
+ const cleanup = logPath ? `; rm -f "$HOME/${logPath}" 2>/dev/null` : ''
200
+ if (p < 1) return `true${cleanup}`
201
+ return `P=${p}`
202
+ + `; if [ -d "/proc/$P" ]; then`
203
+ + ` C=$(tr '\\0' ' ' < "/proc/$P/cmdline" 2>/dev/null);`
204
+ + ` case "$C" in *dsh*) kill -- -"$P" 2>/dev/null || kill "$P" 2>/dev/null;; esac;`
205
+ + ` fi${cleanup}; true`
206
+ }
207
+
208
+ /**
209
+ * One attached remote DSH Web UI: a local listener whose sockets are carried
210
+ * over SSH to the remote's loopback web port.
211
+ *
212
+ * The listener is a plain `net.Server` + `client.forwardOut` pair — the same
213
+ * mechanism `ForwardManager` uses for user-defined forwards. It is implemented
214
+ * here rather than through that manager because this tunnel is bound to a
215
+ * CHOSEN pool (the machine the user picked), whereas `ForwardManager` is wired
216
+ * to the single "active machine" pool; routing an attach through it would send
217
+ * the tunnel to whichever machine happened to be active.
218
+ */
219
+ export class WebAttach {
220
+ /**
221
+ * @param {object} opts - injected collaborators (all optional in production).
222
+ * @param {object} [opts.net] - a `node:net`-compatible module (tests).
223
+ * @param {number} [opts.localPortStart] - first local port to try.
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.
228
+ */
229
+ constructor(opts = {}) {
230
+ this.net = opts.net || net
231
+ // `??`, not `||`: port 0 is a MEANINGFUL value here (let the OS pick), and
232
+ // `0 || 3088` silently turned "let the OS choose" into a bind on a fixed
233
+ // port. An explicit 0 now survives, which is also what lets a test ask for a
234
+ // port without risking a collision with something really listening.
235
+ this.localPortStart = opts.localPortStart ?? 3088
236
+ this.localPortMax = opts.localPortMax ?? 50
237
+ /** Whether the pool's lifecycle hooks are ours to install. */
238
+ this.trackPool = opts.trackPool !== false
239
+ /** @type {import('node:net').Server|null} */ this.server = null
240
+ /** @type {Set<import('node:net').Socket>} */ this.sockets = new Set()
241
+ /** @type {object|null} */ this.client = null
242
+ /** The pool this session borrowed its connection from (may be null). */
243
+ this.pool = null
244
+ /** Pool hooks we installed, so close() can hand them back untouched. */
245
+ this.prevOnReady = null
246
+ this.prevOnCloseHook = null
247
+ this.hookedPool = null
248
+ this._installedOnReady = null
249
+ this._installedOnClose = null
250
+ this.attachedClient = null
251
+ /** Bound listener, kept so it can be removed from the client. */
252
+ this.onClientClose = null
253
+ this.localPort = 0
254
+ this.remotePort = 0
255
+ this.token = ''
256
+ this.tag = ''
257
+ this.logPath = ''
258
+ this.remotePid = 0
259
+ this.machineId = ''
260
+ this.command = ''
261
+ this.startedAt = 0
262
+ /** Launch knobs remembered so a restart after a drop uses the same ones. */
263
+ this.waitSeconds = 0
264
+ this.omittedNoOpen = false
265
+ /** True while a reconnect-triggered re-resolve is in flight. */
266
+ this._resolving = false
267
+ /** True once the borrowed connection has been lost (drives recovery). */
268
+ this.connectionLost = false
269
+ /** undefined = "not asked yet"; false = the remote process is provably gone. */
270
+ this._remoteAlive = undefined
271
+ /** Set by close(): nothing may re-arm the tunnel afterwards. */
272
+ this.closed = false
273
+ /** Set once the borrowed connection has gone away (see _clientLost). */
274
+ this.lastError = ''
275
+ /**
276
+ * A listener that throws EARNS an 'error' event on the emitting object, and
277
+ * an 'error' with no listener is itself fatal — the very crash this module
278
+ * exists to prevent. Nothing here is expected to throw, but a lifecycle
279
+ * callback running on someone else's event loop must never be the reason
280
+ * the host process dies, so both hooks are wrapped.
281
+ */
282
+ this._onReady = (client) => { try { this._attach(client) } catch { /* never fatal */ } }
283
+ this._onPoolClose = () => { try { this._clientLost('ssh connection closed') } catch { /* never fatal */ } }
284
+ }
285
+
286
+ /** Whether the tunnel is currently listening. */
287
+ get active() {
288
+ return this.server !== null
289
+ }
290
+
291
+ /**
292
+ * Attach to a remote DSH Web UI.
293
+ *
294
+ * @param {object} spec - the attach request.
295
+ * @param {object} spec.pool - the SshPool for the chosen machine.
296
+ * @param {string} spec.machineId - machine id, for bookkeeping.
297
+ * @param {string} [spec.command] - remote dsh executable (default `dsh`).
298
+ * @param {string} [spec.profile] - DSH profile to boot (default `web`).
299
+ * @param {string} [spec.existingUrl] - attach to an ALREADY running remote
300
+ * web UI instead of starting one: its `http://127.0.0.1:<port>/?token=…`.
301
+ * @param {(cmd: string, opts?: object) => Promise<{stdout: string, code: number}>} [spec.exec]
302
+ * - remote exec, defaulting to `pool.exec`.
303
+ * @returns {Promise<{url: string, localPort: number, remotePort: number}>}
304
+ */
305
+ async open(spec) {
306
+ const {
307
+ pool, machineId = '', command = 'dsh', profile = 'web',
308
+ existingUrl = '', dshHome = '', waitSeconds = 45,
309
+ } = spec || {}
310
+ if (!pool) throw new Error('web-attach: a machine pool is required')
311
+ this.machineId = String(machineId || '')
312
+ this.command = command
313
+ this.waitSeconds = Number(waitSeconds) || 45
314
+
315
+ const exec = spec.exec || ((cmd, opts) => pool.exec(cmd, opts))
316
+ // Connecting first also surfaces credential/host-key problems BEFORE we
317
+ // start a remote process we would then have to clean up. `_attach` (rather
318
+ // than a bare assignment) is what subscribes to the connection's 'close',
319
+ // so the loss is noticed the moment it happens instead of on the next
320
+ // request — which is when the old code let ssh2 throw 'Not connected' from
321
+ // inside a socket callback and take the host process with it (issue #54).
322
+ this._attach(await pool.connect())
323
+ this._followPool(pool)
324
+
325
+ if (existingUrl) {
326
+ const parsed = parseWebStart(`dsh web: ${existingUrl}`)
327
+ if (!parsed) {
328
+ throw new Error('web-attach: existing URL must look like http://127.0.0.1:<port>/?token=<token>')
329
+ }
330
+ this.remotePort = parsed.port
331
+ this.token = parsed.token
332
+ } else {
333
+ this.tag = randomTag()
334
+ const logPath = `${LOG_NAME}-${this.tag}.log`
335
+ this.logPath = logPath
336
+ const timeoutMs = Math.max(30000, (waitSeconds + 20) * 1000)
337
+ let out = String(await this._launch(exec, { command, profile, logPath, dshHome, waitSeconds, timeoutMs }, false))
338
+ // Older DSH (0.1.0-rc.6) has no `--no-open` and exits on the unknown flag
339
+ // before doing anything. Retry once without it: `--no-open` is a nicety
340
+ // (it stops the REMOTE from opening its own browser), not a requirement,
341
+ // and a bounded single retry is safe because the first attempt provably
342
+ // never started a server.
343
+ if (launchFailed(out) && /unknown option/i.test(out) && /--no-open/.test(out)) {
344
+ out = String(await this._launch(exec, { command, profile, logPath, dshHome, waitSeconds, timeoutMs }, true))
345
+ this.omittedNoOpen = true
346
+ }
347
+ this.remotePid = parseRemotePid(out)
348
+ const tail = out.split(TAIL_MARKER).slice(1).join(TAIL_MARKER).trim()
349
+ // Both failure shapes carry the same diagnostics: the process either died
350
+ // (its output is the whole story) or is still booting after the deadline.
351
+ if (launchFailed(out)) {
352
+ // The remote process is already gone — nothing to clean up.
353
+ throw new Error('web-attach: the remote DSH exited during startup.'
354
+ + explainLaunchFailure(tail || out)
355
+ + ` ${tail || 'no output'}`)
356
+ }
357
+ const parsed = parseWebStart(out)
358
+ if (!parsed) {
359
+ // A timed-out launch may STILL be booting: the deadline expiring is not
360
+ // evidence the process died. Kill it before throwing, or every retry
361
+ // leaks another remote server (measured: three leftover `dsh --port 0`
362
+ // processes after three failed attaches). A PID of 0 means the launcher
363
+ // could not report one — nothing to target.
364
+ if (this.remotePid > 0) {
365
+ try {
366
+ await exec(buildStopCommand(this.remotePid, logPath), { timeoutMs: 20000 })
367
+ this.remotePid = 0
368
+ } catch { /* the process may already be gone; a leftover is worse than a failed kill attempt only if it repeats, and the next attach replaces this session */ }
369
+ }
370
+ throw new Error(
371
+ `web-attach: the remote DSH did not report a startup token within ${waitSeconds}s.`
372
+ + ` Check that \`${command}\` exists on the remote and can boot a web profile.`
373
+ + explainLaunchFailure(tail || out)
374
+ + (tail ? ` Remote output: ${tail}` : ''),
375
+ )
376
+ }
377
+ this.remotePort = parsed.port
378
+ this.token = parsed.token
379
+ }
380
+
381
+ await this._listen()
382
+ this.startedAt = Date.now()
383
+ return this.describe()
384
+ }
385
+
386
+ /**
387
+ * Run one launch attempt and return its raw output.
388
+ *
389
+ * @param {(cmd: string, opts?: object) => Promise<{stdout: string}>} exec - remote exec.
390
+ * @param {object} d - launch parameters plus the exec timeout.
391
+ * @param {boolean} omitNoOpen - drop `--no-open` for old DSH.
392
+ * @returns {Promise<string>} the launcher's stdout.
393
+ */
394
+ async _launch(exec, d, omitNoOpen) {
395
+ const cmd = buildLaunchCommand({ ...d, omitNoOpen })
396
+ const res = await exec(cmd, { timeoutMs: d.timeoutMs })
397
+ return String(res && res.stdout ? res.stdout : '')
398
+ }
399
+
400
+ /** Bind the local listener, advancing past ports already in use. */
401
+ async _listen() {
402
+ let lastError = null
403
+ for (let i = 0; i < this.localPortMax; i++) {
404
+ const port = this.localPortStart + i
405
+ try {
406
+ await this._listenOn(port)
407
+ this.localPort = port
408
+ return
409
+ } catch (err) {
410
+ lastError = err
411
+ // Only a taken port is worth retrying; anything else (permissions,
412
+ // an unusable interface) will not be fixed by the next number.
413
+ if (err && err.code !== 'EADDRINUSE') break
414
+ }
415
+ }
416
+ throw new Error(
417
+ `web-attach: no free local port in ${this.localPortStart}..${this.localPortStart + this.localPortMax - 1}`
418
+ + (lastError ? ` (${lastError.code || lastError.message})` : ''),
419
+ )
420
+ }
421
+
422
+ _listenOn(port) {
423
+ return new Promise((resolve, reject) => {
424
+ const server = this.net.createServer((socket) => this._pipe(socket))
425
+ const onError = (err) => {
426
+ server.removeListener('listening', onListening)
427
+ try { server.close() } catch { /* never listened */ }
428
+ reject(err)
429
+ }
430
+ const onListening = () => {
431
+ server.removeListener('error', onError)
432
+ // A post-bind error must not crash the host process: report and drop
433
+ // the tunnel so the UI can show "disconnected" instead of dying.
434
+ server.on('error', (err) => {
435
+ this.server = null
436
+ this.lastError = String((err && err.message) || err)
437
+ })
438
+ this.server = server
439
+ resolve()
440
+ }
441
+ server.once('error', onError)
442
+ server.once('listening', onListening)
443
+ // Loopback only: this endpoint reaches a full remote harness, so it must
444
+ // never be reachable from the network even on a LAN-facing machine.
445
+ server.listen(port, '127.0.0.1')
446
+ })
447
+ }
448
+
449
+ /**
450
+ * Borrow the given connection AND subscribe to its lifecycle.
451
+ *
452
+ * Idempotent for the same client (open() calls it once, the pool's `onReady`
453
+ * calls it again on every reconnect), and it always drops the previous
454
+ * client's listener first, so one session never accumulates listeners.
455
+ *
456
+ * @param {object|null} client - the live ssh2 client / OpenSshConnection.
457
+ */
458
+ _attach(client) {
459
+ if (!client) return
460
+ if (this.attachedClient && this.attachedClient !== client && this.onClientClose) {
461
+ // A different client replaced the one we borrowed (pool reconnect). Stop
462
+ // listening to the old one; its own 'close' has either fired already or
463
+ // belongs to a connection nobody uses any more.
464
+ try { this.attachedClient.removeListener('close', this.onClientClose) } catch { /* already gone */ }
465
+ }
466
+ this.client = client
467
+ if (this.attachedClient === client) return
468
+ this.attachedClient = client
469
+ this.onClientClose = () => { this._clientLost('ssh connection closed') }
470
+ // Without this, the object keeps handing out a connection that is already
471
+ // closed, and `forwardOut` then throws SYNCHRONOUSLY (issue #54).
472
+ try { client.on('close', this.onClientClose) } catch { /* not an emitter: nothing to watch */ }
473
+ }
474
+
475
+ /** The borrowed connection is gone: keep the session, drop the dead handle. */
476
+ _clientLost(reason) {
477
+ this.client = null
478
+ this.connectionLost = true
479
+ this.lastError = String(reason || 'ssh connection closed')
480
+ }
481
+
482
+ /**
483
+ * Register lifecycle hooks on the chosen pool so this tunnel follows it.
484
+ *
485
+ * The pool is the one place that both NOTICES a dead connection and later
486
+ * obtains a fresh one, and it exposes exactly two hooks for that:
487
+ * • `onCloseHook` — fires from `close()` and `invalidate()` (the latter is
488
+ * the "channel open failed, the pooled connection died server-side"
489
+ * self-heal path, which is the likeliest trigger);
490
+ * • `onReady` — fires after a successful (re)connect.
491
+ *
492
+ * Both are SINGLE-SLOT properties that the plugin already uses for
493
+ * `ForwardManager` (index.js), so this session CHAINS them rather than
494
+ * clobbering the forwards manager, and `_detachPool()` restores exactly what
495
+ * was there — the plugin may legitimately have none, one, or both.
496
+ *
497
+ * A real reconnect is the only way the remote port can move, so once the pool
498
+ * reports a new connection we must re-resolve (token, port) from the remote
499
+ * log. `onReady` itself cannot await: the pool fires it with the client in
500
+ * hand, so the re-resolve is kicked off and its failure is recorded rather
501
+ * than thrown into the pool's callback.
502
+ *
503
+ * @param {object} pool - the pool this session attached through.
504
+ */
505
+ _followPool(pool) {
506
+ if (!this.trackPool || this.closed || !pool || this.hookedPool === pool) return
507
+ this._detachPool()
508
+ this.pool = pool
509
+ this.hookedPool = pool
510
+ const prevReady = typeof pool.onReady === 'function' ? pool.onReady : null
511
+ const prevClose = typeof pool.onCloseHook === 'function' ? pool.onCloseHook : null
512
+ this.prevOnReady = prevReady
513
+ this.prevOnCloseHook = prevClose
514
+ const installedReady = (client) => {
515
+ this._attach(client)
516
+ if (prevReady) { try { prevReady(client) } catch { /* the forwards manager's problem, not ours */ } }
517
+ // A reconnect lands on a NEW remote process only if the old one died with
518
+ // the machine; re-read the log to find out which. The re-resolve runs off
519
+ // the event loop because the pool cannot await it — a hook that throws
520
+ // would earn an 'error' event on the pool, and an unhandled rejection
521
+ // would be fatal.
522
+ if (!this.closed && this.remotePort && !this._resolving) {
523
+ this._resolving = true
524
+ Promise.resolve()
525
+ .then(() => this._refreshRemotePort())
526
+ .catch((err) => { this.lastError = String((err && err.message) || err) })
527
+ .then(() => { this._resolving = false })
528
+ }
529
+ }
530
+ const installedClose = () => {
531
+ this._clientLost('ssh connection closed')
532
+ if (prevClose) { try { prevClose() } catch { /* never mask our own report */ } }
533
+ }
534
+ this._installedOnReady = installedReady
535
+ this._installedOnClose = installedClose
536
+ pool.onReady = installedReady
537
+ pool.onCloseHook = installedClose
538
+ }
539
+
540
+ /**
541
+ * Hand the pool's hooks back.
542
+ *
543
+ * Each slot is restored ONLY when it still holds the wrapper this session
544
+ * installed. The pool's hooks are single-slot, so if something else has taken
545
+ * the slot over in the meantime, putting a stale callback back would be worse
546
+ * than leaving it alone — it would silently disconnect a newer owner.
547
+ */
548
+ _detachPool() {
549
+ const pool = this.hookedPool
550
+ const mineReady = this._installedOnReady
551
+ const mineClose = this._installedOnClose
552
+ this.hookedPool = null
553
+ this._installedOnReady = null
554
+ this._installedOnClose = null
555
+ if (!pool) return
556
+ if (mineReady && pool.onReady === mineReady) pool.onReady = this.prevOnReady
557
+ if (mineClose && pool.onCloseHook === mineClose) pool.onCloseHook = this.prevOnCloseHook
558
+ this.prevOnReady = null
559
+ this.prevOnCloseHook = null
560
+ }
561
+
562
+ /**
563
+ * True when the borrowed connection was LOST and this is a session we can
564
+ * recover: we launched the remote process ourselves (so we hold its PID and
565
+ * log) and the tunnel is listening (so clients can actually be served).
566
+ *
567
+ * The `connectionLost` flag is deliberately separate from `this.client`: the
568
+ * pool's `onReady` re-attaches the new client BEFORE the re-resolve runs, so a
569
+ * check on `!this.client` would be false by then and recovery would never
570
+ * happen.
571
+ */
572
+ get _needsReResolve() {
573
+ return !!(this.connectionLost && !this.closed && this.logPath && this.remotePid && this.localPort)
574
+ }
575
+
576
+ /**
577
+ * Re-resolve the remote port after a reconnect, restarting the remote `dsh web`
578
+ * when it did not survive.
579
+ *
580
+ * Reaching a remote process needs a live SSH connection, so this reconnects
581
+ * through the pool first — the pool reports `onReady` for that same client in
582
+ * production, and in a test double the callback simply never fires.
583
+ */
584
+ async _refreshRemotePort() {
585
+ if (!this._needsReResolve) return
586
+ const pool = this.pool
587
+ if (!pool || (typeof pool.connect !== 'function' && typeof pool.exec !== 'function')) return
588
+ const exec = (cmd, opts) => (typeof pool.exec === 'function'
589
+ ? pool.exec(cmd, opts)
590
+ : Promise.reject(new Error('web-attach: the pool cannot run commands')))
591
+ if (!this.client) await pool.connect()
592
+ const match = await this._resolveRemotePort(exec)
593
+ this.remotePort = match.port
594
+ if (match.token) this.token = match.token
595
+ this.connectionLost = false
596
+ this.lastError = ''
597
+ }
598
+
599
+ /**
600
+ * Read this session's remote log and decide which port is live.
601
+ *
602
+ * The startup line is the ONLY source of the port (the launch deliberately
603
+ * uses `--port 0`), so after the remote process died and was restarted the log
604
+ * holds BOTH lines — the dead one and the new one — and neither position
605
+ * identifies the live one. Evidence decides:
606
+ * • a process that is still alive on the remote is probed first, and when
607
+ * the port it is LISTENING on is one of the logged ports, that is the
608
+ * answer (the `dsh web: …` line names its own port, so this is a
609
+ * confirmation rather than a second guess);
610
+ * • otherwise the newest logged port wins, which is right in every
611
+ * single-process case and is the best available answer while the remote is
612
+ * still booting (the next reconnect retries).
613
+ *
614
+ * @param {(cmd: string, opts?: object) => Promise<{stdout: string}>} exec - remote exec.
615
+ * @returns {Promise<{port: number, token: string, restarted: boolean}>}
616
+ */
617
+ async _resolveRemotePort(exec) {
618
+ this._remoteAlive = await this._remoteProcessAlive(exec)
619
+ if (!this._remoteAlive) return this._restartRemote(exec)
620
+
621
+ let out = ''
622
+ try {
623
+ const res = await exec(`cat "$HOME/${this.logPath}" 2>/dev/null`, { timeoutMs: 15000 })
624
+ out = String((res && res.stdout) || '')
625
+ } catch { /* an unreadable log: the listening probe below is the fallback */ }
626
+ const lines = [...out.matchAll(/dsh web:\s*(http:\/\/[^\s]+)/g)]
627
+ .map((m) => parseWebStart(`dsh web: ${m[1]}`))
628
+ .filter(Boolean)
629
+
630
+ // What the recorded process actually listens on beats any guess from the
631
+ // log, because a killed-and-restarted remote leaves BOTH startup lines in
632
+ // the log and neither position says which one is live.
633
+ const listening = await this._listeningRemotePort(exec)
634
+ if (listening) {
635
+ const match = lines.find((l) => l.port === listening)
636
+ return { port: listening, token: (match && match.token) || this.token, restarted: false }
637
+ }
638
+ if (lines.length) {
639
+ const newest = lines[lines.length - 1]
640
+ return { port: newest.port, token: newest.token, restarted: false }
641
+ }
642
+ // Alive but silent and not listening yet: the remote is booting. Keep the
643
+ // last known port and let the next reconnect re-resolve.
644
+ return { port: this.remotePort, token: this.token, restarted: false }
645
+ }
646
+
647
+ /**
648
+ * Whether the remote process THIS session started still exists.
649
+ *
650
+ * Verified through `/proc`, which is Linux-only; a host that cannot answer (or
651
+ * a failed probe) is treated as ALIVE on purpose. Restarting a server that was
652
+ * never dead would spawn a second one and leak a port, whereas assuming life
653
+ * merely retries a port that turns out to be stale.
654
+ *
655
+ * @param {(cmd: string, opts?: object) => Promise<{stdout: string}>} exec - remote exec.
656
+ * @returns {Promise<boolean>}
657
+ */
658
+ async _remoteProcessAlive(exec) {
659
+ const pid = Number(this.remotePid) || 0
660
+ if (pid < 1) return true
661
+ try {
662
+ const res = await exec(
663
+ `if [ -d /proc ]; then { [ -d /proc/${pid} ] && echo ALIVE; } || echo GONE; else echo ALIVE; fi`,
664
+ { timeoutMs: 15000 },
665
+ )
666
+ return !/GONE/.test(String((res && res.stdout) || ''))
667
+ } catch { return true }
668
+ }
669
+
670
+ /**
671
+ * Ask the remote which loopback port the recorded PID listens on (0 = none).
672
+ *
673
+ * Two `/proc` reads are needed because a TCP listener appears in a socket
674
+ * table (`/proc/net/tcp{,6}`), not in that process's link list: the fd
675
+ * symlinks give the socket INODE, and the table maps that inode to a port.
676
+ * Matching on the inode rather than the port is what makes this an
677
+ * observation instead of a second guess.
678
+ */
679
+ async _listeningRemotePort(exec) {
680
+ const pid = Number(this.remotePid) || 0
681
+ if (pid < 1) return 0
682
+ let inodes = ''
683
+ try {
684
+ // `ls -l` on a process's own fds needs no privileges for the same uid.
685
+ const res = await exec(
686
+ `ls -l /proc/${pid}/fd 2>/dev/null | sed -n 's/.*socket:\\[\\([0-9]*\\)\\].*/\\1/p' | sort -u | tr '\\n' ' '`,
687
+ { timeoutMs: 15000 },
688
+ )
689
+ inodes = String((res && res.stdout) || '').trim()
690
+ } catch { return 0 }
691
+ if (!inodes) return 0
692
+ const want = new Set(inodes.split(/\s+/).filter(Boolean))
693
+ // `/proc/net/tcp` addresses are little-endian hex, so loopback is
694
+ // `0100007F` (v4) and `00000000000000000000000001000000` (v6 `::1`).
695
+ const LOOPBACK = new Set(['0100007F', '00000000000000000000000001000000'])
696
+ for (const file of ['/proc/net/tcp', '/proc/net/tcp6']) {
697
+ let text = ''
698
+ try {
699
+ const res = await exec(`cat ${file} 2>/dev/null`, { timeoutMs: 15000 })
700
+ text = String((res && res.stdout) || '')
701
+ } catch { continue }
702
+ for (const line of text.split('\n').slice(1)) {
703
+ const cols = line.trim().split(/\s+/)
704
+ // sl local_address rem_address st tx:rx tr:tm->when retrnsmt uid timeout inode
705
+ if (cols.length < 10) continue
706
+ if (cols[3] !== '0A') continue // TCP_LISTEN
707
+ if (!want.has(cols[9])) continue
708
+ const [addr, portHex] = String(cols[1]).split(':')
709
+ if (!LOOPBACK.has(String(addr).toUpperCase())) continue
710
+ const port = parseInt(portHex, 16)
711
+ if (Number.isInteger(port) && port > 0 && port < 65536) return port
712
+ }
713
+ }
714
+ return 0
715
+ }
716
+
717
+ /**
718
+ * Relaunch the remote `dsh web` because the previous process is gone.
719
+ *
720
+ * A restart CANNOT change the listening port locally, and a caller holding a
721
+ * stale port silently talks to the wrong place at worst and hangs at best, so
722
+ * the two facts the caller needs are checked: this is a session that launched
723
+ * its own process (an attach onto someone else's DSH stays hands-off and can
724
+ * never restart it), and recovery is possible at all (`_refreshRemotePort`
725
+ * has already returned early when it is not).
726
+ */
727
+ async _restartRemote(exec) {
728
+ const command = this.command || 'dsh'
729
+ const waitSeconds = this.waitSeconds || 45
730
+ const timeoutMs = Math.max(30000, (waitSeconds + 20) * 1000)
731
+ let out = String(await this._launch(exec, { command, profile: 'web', logPath: this.logPath, waitSeconds, timeoutMs }, this.omittedNoOpen === true))
732
+ if (launchFailed(out) && /unknown option/i.test(out) && /--no-open/.test(out)) {
733
+ out = String(await this._launch(exec, { command, profile: 'web', logPath: this.logPath, waitSeconds, timeoutMs }, true))
734
+ this.omittedNoOpen = true
735
+ }
736
+ this.remotePid = parseRemotePid(out) || this.remotePid
737
+ const parsed = parseWebStart(out)
738
+ if (!parsed) {
739
+ const tail = out.split(TAIL_MARKER).slice(1).join(TAIL_MARKER).trim()
740
+ throw new Error('web-attach: the remote DSH could not be restarted after the SSH'
741
+ + ' connection dropped.' + explainLaunchFailure(tail || out) + (tail ? ` Remote output: ${tail}` : ''))
742
+ }
743
+ return { port: parsed.port, token: parsed.token, restarted: true }
744
+ }
745
+
746
+ /** Carry one local socket to the remote web port over SSH. */
747
+ _pipe(socket) {
748
+ if (!this.client) { try { socket.destroy() } catch { /* already gone */ } return }
749
+ // ssh2's forwardOut does NOT report a dead connection through its callback:
750
+ // when the socket is gone it THROWS 'Not connected' synchronously
751
+ // (ssh2/lib/client.js: `if (!this._sock || !isWritable(this._sock)) throw`)
752
+ // and the callback below is never reached — so `if (err)` cannot guard this
753
+ // path. Uncaught, that throw travels net.Server → TCP.onconnection and kills
754
+ // the whole host process (issue #54: every page refresh after the pooled
755
+ // connection dropped took the session, the browser and the remote orphan
756
+ // down with it). A dead tunnel is a UI state, not a reason to exit.
757
+ try {
758
+ this.client.forwardOut('127.0.0.1', 0, '127.0.0.1', this.remotePort, (err, channel) => {
759
+ if (err) { try { socket.destroy() } catch { /* already gone */ } return }
760
+ this.sockets.add(socket)
761
+ socket.pipe(channel)
762
+ channel.pipe(socket)
763
+ const kill = () => {
764
+ try { socket.destroy() } catch { /* already gone */ }
765
+ try { channel.close() } catch { /* already closed */ }
766
+ this.sockets.delete(socket)
767
+ }
768
+ socket.on('error', kill)
769
+ channel.on('error', kill)
770
+ socket.on('close', kill)
771
+ channel.on('close', kill)
772
+ })
773
+ } catch (err) {
774
+ this._clientLost(String((err && err.message) || err))
775
+ try { socket.destroy() } catch { /* already gone */ }
776
+ }
777
+ }
778
+
779
+ /**
780
+ * The URL to open, carrying the one-time launch token.
781
+ *
782
+ * The token is a credential, so it stays on loopback and is never sent
783
+ * anywhere; DSH exchanges it for a 30-day signed cookie on the first request
784
+ * and redirects to a clean `/`.
785
+ */
786
+ get url() {
787
+ return `http://127.0.0.1:${this.localPort}/?token=${this.token}`
788
+ }
789
+
790
+ /** The clean URL (no token) — what the address bar shows after the exchange. */
791
+ get cleanUrl() {
792
+ return `http://127.0.0.1:${this.localPort}/`
793
+ }
794
+
795
+ /** Serializable status for the settings UI. */
796
+ describe() {
797
+ return {
798
+ machineId: this.machineId,
799
+ url: this.url,
800
+ cleanUrl: this.cleanUrl,
801
+ token: this.token,
802
+ localPort: this.localPort,
803
+ remotePort: this.remotePort,
804
+ active: this.active,
805
+ startedAt: this.startedAt,
806
+ // Only a process we launched can be stopped by us; an attach onto the
807
+ // user's own running instance is deliberately hands-off.
808
+ managed: this.remotePid > 0,
809
+ error: this.lastError || '',
810
+ }
811
+ }
812
+
813
+ /**
814
+ * Tear the tunnel down. The remote process is left running by default: the
815
+ * user may be using that DSH elsewhere, and silently killing a process we did
816
+ * not start is worse than a leftover one. `stopRemote` opts in.
817
+ *
818
+ * @param {object} [opts] - close options.
819
+ * @param {boolean} [opts.stopRemote] - also terminate the remote process.
820
+ * @param {(cmd: string) => Promise<unknown>} [opts.exec] - remote exec.
821
+ */
822
+ async close({ stopRemote = false, exec } = {}) {
823
+ // Stop following the pool BEFORE any await: a reconnect landing mid-teardown
824
+ // must not re-arm the tunnel this call is in the middle of closing.
825
+ this._detachPool()
826
+ if (this.attachedClient && this.onClientClose) {
827
+ try { this.attachedClient.removeListener('close', this.onClientClose) } catch { /* already gone */ }
828
+ }
829
+ this.attachedClient = null
830
+ this.onClientClose = null
831
+ this.pool = null
832
+ for (const socket of [...this.sockets]) {
833
+ try { socket.destroy() } catch { /* already gone */ }
834
+ }
835
+ this.sockets.clear()
836
+ const server = this.server
837
+ this.server = null
838
+ if (server) await new Promise((resolve) => server.close(() => resolve()))
839
+ // Only a process we started has a recorded PID; an attach to the user's own
840
+ // running instance must never signal anything.
841
+ if (stopRemote && exec && this.remotePid) {
842
+ await Promise.resolve(exec(buildStopCommand(this.remotePid, this.logPath))).catch(() => {})
843
+ }
844
+ this.client = null
845
+ // The recorded PID and log path are FACTS about this session (the UI shows
846
+ // them, and `stopRemote` addresses them), so they survive; `closed` is what
847
+ // stops recovery work from being re-armed after teardown.
848
+ this.closed = true
849
+ }
850
+ }