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/lib/forwards.js CHANGED
@@ -14,11 +14,17 @@
14
14
  import net from 'node:net'
15
15
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
16
16
  import path from 'node:path'
17
+ import { forwardOutChannel } from './pool.js'
17
18
 
18
19
  export class ForwardManager {
19
- /** @param {object} pool SshPool (needs .connect() returning the ssh2 client). */
20
- constructor(pool, { file } = {}) {
20
+ /** @param {object} pool SshPool (needs .connect() returning the ssh2 client).
21
+ * @param {object} [opts] - `file` (persistence) and `net` (a `node:net`
22
+ * compatible module, so the listener's connection handler — the path the
23
+ * issue #54 crash travelled — can be driven from a unit test without
24
+ * binding a real port). */
25
+ constructor(pool, { file, net: netModule } = {}) {
21
26
  this.pool = pool
27
+ this.net = netModule || net
22
28
  this.file = file || null
23
29
  this.defs = this._load()
24
30
  this.servers = new Map() // id → { server | remoteListen, sockets:Set, client }
@@ -134,9 +140,17 @@ export class ForwardManager {
134
140
 
135
141
  _startLocal(d, client) {
136
142
  return new Promise((resolve, reject) => {
137
- const server = net.createServer((socket) => {
138
- client.forwardOut('127.0.0.1', 0, d.targetHost, d.targetPort, (err, channel) => {
139
- if (err) { socket.destroy(); return }
143
+ const server = this.net.createServer((socket) => {
144
+ // ssh2's forwardOut reports a dead connection by THROWING
145
+ // (`if (!this._sock || !isWritable(this._sock)) throw new Error('Not
146
+ // connected')`) and never calls the callback, so a callback-only error
147
+ // path cannot guard this call. Uncaught, the throw escapes net.Server's
148
+ // connection handler and kills the whole host process — the exact fatal
149
+ // stack of issue #54, found in WebAttach and reachable here too for a
150
+ // user-defined local forward. `attach()` does register a `close`
151
+ // listener that calls stopAll(), but a request can arrive in the window
152
+ // between the connection dying and that event.
153
+ forwardOutChannel(client, { dstHost: d.targetHost, dstPort: d.targetPort }).then((channel) => {
140
154
  socket.pipe(channel)
141
155
  channel.pipe(socket)
142
156
  const kill = () => { try { socket.destroy() } catch {}; try { channel.close() } catch {} }
@@ -144,6 +158,9 @@ export class ForwardManager {
144
158
  channel.on('error', kill)
145
159
  socket.on('close', kill)
146
160
  channel.on('close', kill)
161
+ }).catch(() => {
162
+ // A dead tunnel is a UI state, not a reason to exit.
163
+ try { socket.destroy() } catch { /* already gone */ }
147
164
  })
148
165
  })
149
166
  server.on('error', (e) => reject(new Error(`本地转发 ${d.listenPort} 启动失败: ${e.message}`)))
@@ -155,6 +172,22 @@ export class ForwardManager {
155
172
  }
156
173
 
157
174
  _startReverse(d, client) {
175
+ // The system-OpenSSH backend (issue #51) cannot do ssh2's two-phase
176
+ // `forwardIn` + `tcpip`: the `ssh -R` CLI needs the final target as an
177
+ // argument, so it exposes `reverseForward()` instead. Prefer it when
178
+ // present — otherwise every reverse tunnel would fail on that backend.
179
+ if (typeof client.reverseForward === 'function') {
180
+ return client.reverseForward({
181
+ bindHost: '127.0.0.1',
182
+ listenPort: d.listenPort,
183
+ targetHost: d.targetHost,
184
+ targetPort: d.targetPort,
185
+ }).then((handle) => {
186
+ this.servers.set(d.id, { kind: 'reverse-cli', handle, client })
187
+ }).catch((err) => {
188
+ throw new Error(`远端监听 ${d.listenPort} 失败: ${(err && err.message) || err}`)
189
+ })
190
+ }
158
191
  return new Promise((resolve, reject) => {
159
192
  // ssh2 calls the remote TCP listen API `forwardIn` (NOT openssh_forwardIn,
160
193
  // which in ssh2 >= 1.16 is stream-local only).
@@ -170,10 +203,13 @@ export class ForwardManager {
170
203
  }
171
204
 
172
205
  _handleTcpip(info, accept, reject) {
206
+ // Only the ssh2 backend reaches here: `ssh -R` handles the remote listener
207
+ // and its targets entirely inside the CLI, so a 'reverse-cli' entry never
208
+ // produces a 'tcpip' event.
173
209
  for (const entry of this.servers.values()) {
174
210
  if (entry.kind === 'reverse' && Number(info.destPort) === Number(this._defFor(entry).listenPort)) {
175
211
  const channel = accept()
176
- const socket = net.connect({ host: this._defFor(entry).targetHost, port: this._defFor(entry).targetPort })
212
+ const socket = this.net.connect({ host: this._defFor(entry).targetHost, port: this._defFor(entry).targetPort })
177
213
  socket.pipe(channel)
178
214
  channel.pipe(socket)
179
215
  entry.sockets.add(socket)
@@ -205,6 +241,9 @@ export class ForwardManager {
205
241
  if (entry.kind === 'local') {
206
242
  entry.server.close()
207
243
  for (const s of entry.sockets) { try { s.destroy() } catch {} }
244
+ } else if (entry.kind === 'reverse-cli') {
245
+ // The `ssh -R` child IS the tunnel: killing it closes the remote listener.
246
+ if (entry.handle && typeof entry.handle.close === 'function') entry.handle.close()
208
247
  } else if (entry.client && typeof entry.client.unforwardIn === 'function') {
209
248
  const d = this._defFor(entry)
210
249
  entry.client.unforwardIn('127.0.0.1', d.listenPort, () => {})
package/lib/index.js CHANGED
@@ -52,7 +52,8 @@ import { selfDir, diskVersion, LOADED_VERSION, gtVersion, fetchLatestVersion, ap
52
52
  import { sendHeartbeat, pseudonym, heartbeatUrl, hasPersistedId, installIdPath } from './telemetry.js'
53
53
  import { loadMachines as _loadMachines, saveMachines as _saveMachines, sanitizeMachine as _sanitizeMachine, applyMachine as _applyMachine, machineId as _machineId } from './registry.js'
54
54
  import { registerHttpTransports } from './http-transport.js'
55
- import { SshPool } from './pool.js'
55
+ import { SshPool, ActivePool, createPool } from './pool.js'
56
+ import { isSystemOpenSshBackend, sshBinaryAvailable, resolveSshBinary } from './openssh.js'
56
57
  import { createFsRoutes } from './routes-fs.js'
57
58
  import { listDirStructured, removeRemoteTree } from './remote-fs.js'
58
59
 
@@ -110,6 +111,17 @@ export const Config = z.object({
110
111
  useAgent: z.boolean().default(false),
111
112
  /** Allow keyboard-interactive auth (OTP/MFA chains) using the configured password. */
112
113
  keyboardInteractive: z.boolean().default(false),
114
+ /** SSH transport backend (issue #51):
115
+ * `native` (default) speaks SSH through ssh2 — password / private key /
116
+ * ssh-agent / keyboard-interactive;
117
+ * `openssh` delegates to the LOCAL `ssh` executable, which is the only way to
118
+ * reach hosts that allow GSSAPI/Kerberos (kinit) or that rely on other
119
+ * OpenSSH-only `~/.ssh/config` features (ProxyCommand, PKCS11Provider,
120
+ * ControlMaster, Match blocks). See lib/openssh.js for the trade-offs. */
121
+ backend: z.string().default('native'),
122
+ /** The `ssh` executable the `openssh` backend drives (PATH name or a full
123
+ * path). Ignored by the native backend. */
124
+ sshBinary: z.string().default(''),
113
125
  /** Jump host / bastion: connect through this machine first. All fields have
114
126
  * defaults so this works on schemastery versions without `.optional()`; an
115
127
  * empty `host` means "no jump host". */
@@ -316,7 +328,9 @@ function encodeText(s, enc) {
316
328
  // ── apply ─────────────────────────────────────────────────────────────────
317
329
 
318
330
  export async function apply(ctx, config) {
319
- const pool = new SshPool(config, { knownHostsFile })
331
+ // The ACTIVE pool: a delegating facade, because `backend` is per machine and
332
+ // the plugin must keep ONE long-lived object to hang its hooks on (issue #51).
333
+ const pool = new ActivePool(config, { knownHostsFile })
320
334
  ctx.effect(() => () => pool.close(), 'dsh-remote.close')
321
335
 
322
336
  migrateLegacyData()
@@ -395,6 +409,50 @@ export async function apply(ctx, config) {
395
409
  }
396
410
  }
397
411
 
412
+ /**
413
+ * The CONNECT target for a pool, given a machine's RESOLVED connect identity
414
+ * (i.e. a record that already passed through withSshConfig()).
415
+ *
416
+ * Issue #38 resolves an alias machine into concrete host/user/port/key values
417
+ * because the native backend has to dial them itself. Issue #51's
418
+ * system-OpenSSH backend must NOT do that: it hands the name to `ssh`, and ssh
419
+ * matches its `Host` blocks against exactly the name it was given. Passing the
420
+ * resolved HostName instead would stop `Host myalias` blocks from matching and
421
+ * silently drop the very settings (GSSAPIAuthentication=yes, ProxyCommand,
422
+ * PKCS11Provider, Match blocks) that make this backend worth choosing. For an
423
+ * alias machine on that backend the ALIAS is therefore the connect target, no
424
+ * -l/-p/-i of our own is emitted, and ~/.ssh/config stays the single source of
425
+ * truth for that machine (which is also exactly what issue #50 asks for).
426
+ *
427
+ * @param {object} eff - a record after withSshConfig() (`sshAlias` present for
428
+ * alias machines).
429
+ * @returns {{host: string, port: number, username: string, privateKeyPath: string, proxy: object|undefined, aliasNative: boolean}}
430
+ */
431
+ const connectTargetFor = (eff) => {
432
+ const aliasNative = !!(eff && eff.useSshConfig && isSystemOpenSshBackend(eff))
433
+ if (!aliasNative) {
434
+ return {
435
+ host: String((eff && eff.host) || ''),
436
+ port: Number((eff && eff.port) || 0) || 22,
437
+ username: String((eff && eff.username) || ''),
438
+ privateKeyPath: String((eff && eff.privateKeyPath) || ''),
439
+ proxy: eff && eff.proxy,
440
+ aliasNative: false,
441
+ }
442
+ }
443
+ const alias = String((eff && (eff.sshAlias || eff.host)) || '')
444
+ return {
445
+ // The alias itself; ssh expands HostName/User/Port/IdentityFile and every
446
+ // other directive while matching that Host block.
447
+ host: alias,
448
+ port: 0, // 0 → no -p is emitted, so Port comes from the config
449
+ username: '', // '' → no -l is emitted, so User comes from the config
450
+ privateKeyPath: '', // '' → no -i is emitted, so IdentityFile comes from it
451
+ proxy: undefined, // a ProxyJump written in the config is honoured by ssh
452
+ aliasNative: true,
453
+ }
454
+ }
455
+
398
456
  /** The alias a machine was saved as ('' for a machine with literal values). */
399
457
  const aliasOf = (m) => (m && m.sshAlias) || (m && m.useSshConfig ? String(m.host || '') : '')
400
458
 
@@ -482,6 +540,9 @@ export async function apply(ctx, config) {
482
540
  passphrase: c.passphrase, workspace: c.workspace,
483
541
  useAgent: c.useAgent, keyboardInteractive: c.keyboardInteractive,
484
542
  proxy: c.proxy, hostKeyMode: c.hostKeyMode,
543
+ // Issue #51: the backend is part of the target identity, so a machine switch
544
+ // between backends must be seen as a change by setTarget.
545
+ backend: c.backend, sshBinary: c.sshBinary,
485
546
  })
486
547
 
487
548
  /** Fingerprint of the machine record last applied to the shared pool config.
@@ -513,6 +574,17 @@ export async function apply(ctx, config) {
513
574
  // OLD connection alive across a machine switch (issue #25 hazard).
514
575
  const previous = targetFields(config)
515
576
  applyMachine(config, { ...m, password: pw || m.password || '' })
577
+ // Issue #51: an ssh-config alias on the system-OpenSSH backend connects AS
578
+ // the alias, so ssh — not the plugin — expands HostName/User/Port/
579
+ // IdentityFile/ProxyJump, and the `Host` block keeps matching.
580
+ const t = connectTargetFor(m)
581
+ if (t.aliasNative) {
582
+ config.host = t.host
583
+ config.port = 22
584
+ config.username = ''
585
+ config.privateKeyPath = ''
586
+ config.proxy = undefined
587
+ }
516
588
  pool.setTarget(targetFields(config), previous)
517
589
  lastAppliedMachine = appliedFingerprint()
518
590
  }
@@ -596,28 +668,36 @@ export async function apply(ctx, config) {
596
668
  // effect for an existing session instead of dialing the stale address.
597
669
  const rec = machineRecordFor(target)
598
670
  const eff = rec ? withSshConfig(rec) : null
599
- const host = (eff && eff.host) || target.host
600
- const port = Number((eff && eff.port) || target.port) || 22
601
- const username = (eff && eff.username) || target.username || 'root'
671
+ // Issue #51: on the system-OpenSSH backend an alias machine connects AS its
672
+ // alias so ~/.ssh/config (not the registry) supplies host/user/port/key.
673
+ const t = connectTargetFor(eff || target)
674
+ const host = t.aliasNative ? t.host : ((eff && eff.host) || target.host)
675
+ const port = t.aliasNative ? 0 : (Number((eff && eff.port) || target.port) || 22)
676
+ const username = t.aliasNative ? '' : ((eff && eff.username) || target.username || 'root')
602
677
  const existing = machinePools.get(key)
603
678
  if (existing) {
604
679
  syncTunables(existing.config)
605
- // Re-point a live pool whose resolved identity moved (setTarget closes the
606
- // connection, so only do it when something actually changed).
607
- if (existing.config.host !== host || Number(existing.config.port) !== port || (existing.config.username || '') !== username) {
608
- existing.setTarget({ host, port, username })
609
- }
610
- // Re-read credentials and per-machine policy from the registry: editing a
611
- // machine in the settings page must reach a pool created earlier, and a
612
- // pool holding a stale password would keep failing to reconnect.
680
+ // Credentials and per-machine policy are re-read from the registry BEFORE
681
+ // re-pointing: editing a machine in the settings page must reach a pool
682
+ // created earlier, and a pool holding a stale password would keep failing
683
+ // to reconnect. The backend belongs in this group too — ActivePool picks
684
+ // its delegate class from `config.backend` during setTarget/_sync, so
685
+ // assigning it afterwards would hand the OLD backend the new target.
613
686
  if (eff) {
614
687
  existing.config.password = eff.password || ''
615
- existing.config.privateKeyPath = eff.privateKeyPath || ''
688
+ existing.config.privateKeyPath = t.aliasNative ? '' : (eff.privateKeyPath || '')
616
689
  existing.config.passphrase = eff.passphrase || ''
617
690
  existing.config.useAgent = !!eff.useAgent
618
691
  existing.config.keyboardInteractive = !!eff.keyboardInteractive
619
- existing.config.proxy = eff.proxy || undefined
692
+ existing.config.proxy = t.aliasNative ? undefined : (eff.proxy || undefined)
620
693
  existing.config.hostKeyMode = eff.hostKeyMode || config.hostKeyMode
694
+ existing.config.backend = eff.backend || config.backend
695
+ existing.config.sshBinary = (typeof eff.sshBinary === 'string') ? eff.sshBinary : config.sshBinary
696
+ }
697
+ // Re-point a live pool whose resolved identity moved (setTarget closes the
698
+ // connection, so only do it when something actually changed).
699
+ if (existing.config.host !== host || Number(existing.config.port) !== port || (existing.config.username || '') !== username) {
700
+ existing.setTarget({ host, port, username })
621
701
  }
622
702
  return existing
623
703
  }
@@ -627,17 +707,22 @@ export async function apply(ctx, config) {
627
707
  port,
628
708
  username,
629
709
  password: (eff && eff.password) || '',
630
- privateKeyPath: (eff && eff.privateKeyPath) || '',
710
+ privateKeyPath: t.aliasNative ? '' : ((eff && eff.privateKeyPath) || ''),
631
711
  passphrase: (eff && eff.passphrase) || '',
632
712
  useAgent: eff ? !!eff.useAgent : !!config.useAgent,
633
713
  keyboardInteractive: eff ? !!eff.keyboardInteractive : !!config.keyboardInteractive,
634
- proxy: (eff && eff.proxy) || undefined,
714
+ proxy: t.aliasNative ? undefined : ((eff && eff.proxy) || undefined),
635
715
  hostKeyMode: (eff && eff.hostKeyMode) || config.hostKeyMode,
716
+ // Issue #51: the transport backend is a per-machine choice — an
717
+ // enterprise Kerberos host needs `openssh` while its neighbours stay on
718
+ // the native backend.
719
+ backend: (eff && eff.backend) || config.backend,
720
+ sshBinary: (eff && typeof eff.sshBinary === 'string') ? eff.sshBinary : config.sshBinary,
636
721
  // Identity-pinned pools never carry the shared active workspace: a
637
722
  // session passes its own mirror-resolved remote path explicitly.
638
723
  workspace: '',
639
724
  })
640
- const created = new SshPool(poolConfig, { knownHostsFile })
725
+ const created = new ActivePool(poolConfig, { knownHostsFile })
641
726
  // Keychain-backed passwords resolve against THIS pool's machine record.
642
727
  created.passwordResolver = async () => machinePassword(eff || { id: '', password: poolConfig.password })
643
728
  machinePools.set(key, created)
@@ -961,6 +1046,69 @@ export async function apply(ctx, config) {
961
1046
  * without consulting the mutable active-machine state, so a concurrent
962
1047
  * session switching machines cannot redirect this session's commands. */
963
1048
  const resolveMirrorForLocal = (local) => resolveMirror(local, remoteWorkspacesRoot())
1049
+
1050
+ /**
1051
+ * The whole local-mirror registry with each mirror's recorded remote origin
1052
+ * (issue #49).
1053
+ *
1054
+ * The sidebar's Session rows need to know, for EVERY visible row, whether that
1055
+ * session is a remote one and which host it is bound to. Asking
1056
+ * `/resolve-mirror` per row would issue one HTTP request per row and would
1057
+ * also depend on the host still knowing that session id; instead the client
1058
+ * already holds each row's cwd (the sessions snapshot) and matches it against
1059
+ * this directory list locally, so the whole sidebar costs ONE request.
1060
+ *
1061
+ * Mirrors whose meta is missing/unparsable (or has no `host`) are SKIPPED
1062
+ * rather than guessed at — the same rule `resolveMirror()` applies, and for
1063
+ * the same reason: labelling a session with a host it is not bound to is
1064
+ * worse than showing no badge at all.
1065
+ *
1066
+ * @returns {Array<{dir: string, host: string, port: number, username: string,
1067
+ * remotePath: string, alias: string, name: string}>} one entry per mirror.
1068
+ */
1069
+ const mirrorRegistry = () => {
1070
+ const out = []
1071
+ const root = remoteWorkspacesRoot()
1072
+ if (!existsSync(root)) return out
1073
+ let hostDirs = []
1074
+ try { hostDirs = readdirSync(root) } catch { return out }
1075
+ for (const hostDir of hostDirs) {
1076
+ const hostPath = path.join(root, hostDir)
1077
+ if (!statSync(hostPath, { throwIfNoEntry: false })?.isDirectory?.()) continue
1078
+ let entries = []
1079
+ try { entries = readdirSync(hostPath) } catch { continue }
1080
+ for (const entry of entries) {
1081
+ const dir = path.join(hostPath, entry)
1082
+ const metaPath = path.join(dir, '.dsh-remote-meta.json')
1083
+ if (!existsSync(metaPath)) continue
1084
+ try {
1085
+ const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
1086
+ if (!meta || !meta.host) continue
1087
+ const origin = {
1088
+ host: String(meta.host),
1089
+ port: Number(meta.port) || 22,
1090
+ username: String(meta.username || ''),
1091
+ alias: String(meta.alias || ''),
1092
+ }
1093
+ const rec = machineRecordFor(origin)
1094
+ out.push({
1095
+ dir: path.resolve(dir),
1096
+ host: origin.host,
1097
+ port: origin.port,
1098
+ username: origin.username,
1099
+ remotePath: String(meta.remotePath || ''),
1100
+ alias: origin.alias,
1101
+ // A saved machine's display name, when this mirror resolves to one:
1102
+ // the UI prefers the friendly name over the raw address in its
1103
+ // tooltip (the row itself shows the address, which is what the
1104
+ // reader asked for).
1105
+ name: rec && rec.name ? String(rec.name) : '',
1106
+ })
1107
+ } catch { /* unparsable meta → not a usable binding */ }
1108
+ }
1109
+ }
1110
+ return out
1111
+ }
964
1112
  if (config.autoPush && wsPath()) {
965
1113
  startAutoPush(mirrorDirFor(wsPath(), config.host, config.username, config.port))
966
1114
  }
@@ -1350,7 +1498,7 @@ export async function apply(ctx, config) {
1350
1498
  defineTool({
1351
1499
  name: 'rw_connect',
1352
1500
  description:
1353
- 'Connect SSH to a remote host for remote workspace work. Two ways (issue #48): (1) pass machineId to connect with a machine ALREADY SAVED in the registry (list them with rw_machines) — every stored setting applies: password/keychain password, privateKeyPath + passphrase, useAgent, keyboardInteractive, proxy, hostKeyMode, and ~/.ssh/config aliases; or (2) pass host plus any of username/port/password/privateKeyPath/passphrase/useAgent/keyboardInteractive/hostKeyMode/name. Defaults to saving the machine to the registry (save=false keeps it as a temporary connection). Once connected, call rw_pick_workspace to pick the workspace directory this session should work in. With useSshConfig=true, `host` is an ALIAS from ~/.ssh/config and every value (HostName/user/port/IdentityFile/ProxyJump) is read from that file at connect time. A failed connection is reported as an error message — it never takes the plugin down.',
1501
+ 'Connect SSH to a remote host for remote workspace work. Two ways (issue #48): (1) pass machineId to connect with a machine ALREADY SAVED in the registry (list them with rw_machines) — every stored setting applies: password/keychain password, privateKeyPath + passphrase, useAgent, keyboardInteractive, proxy, hostKeyMode, backend, and ~/.ssh/config aliases; or (2) pass host plus any of username/port/password/privateKeyPath/passphrase/useAgent/keyboardInteractive/hostKeyMode/backend/name. Defaults to saving the machine to the registry (save=false keeps it as a temporary connection). Once connected, call rw_pick_workspace to pick the workspace directory this session should work in. With useSshConfig=true, `host` is an ALIAS from ~/.ssh/config and every value (HostName/user/port/IdentityFile/ProxyJump) is read from that file at connect time. For a host that only accepts GSSAPI/Kerberos (issue #51), pass backend="openssh" (with useSshConfig=true) so the local ssh — which holds your kinit ticket — performs the login. A failed connection is reported as an error message — it never takes the plugin down.',
1354
1502
  parameters: {
1355
1503
  host: { type: 'string', description: 'Remote host IP or hostname (or a ~/.ssh/config alias with useSshConfig=true). Required unless machineId is given.' },
1356
1504
  machineId: { type: 'string', description: 'Connect with a saved machine from the registry (see rw_machines): all its stored settings apply and it becomes the current machine. host/other fields are not needed.' },
@@ -1362,6 +1510,8 @@ export async function apply(ctx, config) {
1362
1510
  useAgent: { type: 'boolean', description: 'Authenticate via the local SSH agent (SSH_AUTH_SOCK) instead of password/key' },
1363
1511
  keyboardInteractive: { type: 'boolean', description: 'Enable keyboard-interactive auth (OTP/动态码 prompts answered with the configured password)' },
1364
1512
  hostKeyMode: { type: 'string', description: 'Host-key policy for this machine: ask | strict | off (default from config)' },
1513
+ backend: { type: 'string', description: 'SSH transport backend (issue #51): "native" (default) uses the built-in ssh2 client (password / private key / agent / keyboard-interactive); "openssh" drives the LOCAL ssh command, which is the only way to reach hosts that require GSSAPI/Kerberos (log in once with kinit) and which honours the whole of ~/.ssh/config (ProxyCommand, PKCS11Provider, Match blocks). Prefer "openssh" together with useSshConfig=true so the alias and its settings stay the single source of truth.' },
1514
+ sshBinary: { type: 'string', description: 'Path (or PATH name) of the ssh executable the "openssh" backend drives — default "ssh"' },
1365
1515
  name: { type: 'string', description: 'Display name for a newly saved machine (default: the host)' },
1366
1516
  useSshConfig: { type: 'boolean', description: 'Treat `host` as a ~/.ssh/config Host alias; resolve host/user/port/key/proxy from that file (issue #38)' },
1367
1517
  save: { type: 'boolean', description: 'Save this machine to the registry and make it current (default true)' },
@@ -1403,10 +1553,21 @@ export async function apply(ctx, config) {
1403
1553
  if (args.keyboardInteractive !== undefined) rec.keyboardInteractive = !!args.keyboardInteractive
1404
1554
  if (args.hostKeyMode) rec.hostKeyMode = String(args.hostKeyMode)
1405
1555
  if (args.name) rec.name = String(args.name)
1556
+ // Issue #51: pick the transport backend. `openssh` is what reaches
1557
+ // GSSAPI/Kerberos-only hosts, and with useSshConfig=true the alias is
1558
+ // handed to ssh verbatim so ~/.ssh/config stays the single source.
1559
+ if (args.backend) rec.backend = args.backend === 'openssh' ? 'openssh' : 'native'
1560
+ if (args.sshBinary !== undefined) rec.sshBinary = String(args.sshBinary)
1561
+ // Issue #54: the per-machine `dsh web` command is settable by hand too,
1562
+ // not only by the deploy flow (the README told users to set it and the
1563
+ // settings UI offered nowhere to type it).
1564
+ if (args.webAttachCommand !== undefined) rec.webAttachCommand = String(args.webAttachCommand)
1406
1565
  if (useSshConfig) rec.useSshConfig = true
1407
1566
  // An alias connection must dial the RESOLVED identity (~/.ssh/config),
1408
- // while the registry keeps the alias itself.
1567
+ // while the registry keeps the alias itself — EXCEPT on the
1568
+ // system-OpenSSH backend, where ssh resolves the alias itself.
1409
1569
  const eff = useSshConfig ? withSshConfig(rec) : rec
1570
+ const ct = connectTargetFor(eff)
1410
1571
  if (args.save !== false) {
1411
1572
  // Upsert into the registry and make it the current machine so the
1412
1573
  // settings UI and the tools always agree on who is active.
@@ -1439,15 +1600,19 @@ export async function apply(ctx, config) {
1439
1600
  // cached client — same reconnect-probe reasoning as /connect).
1440
1601
  pool.invalidate()
1441
1602
  pool.setTarget({
1442
- host: eff.host, port: Number(eff.port) || port || 22, username: eff.username || user,
1443
- password: rec.password, privateKeyPath: eff.privateKeyPath || '',
1603
+ // connectTargetFor: on the system-OpenSSH backend an alias machine
1604
+ // dials the alias, not the values it expands to.
1605
+ host: ct.host, port: ct.aliasNative ? 22 : (Number(eff.port) || port || 22), username: ct.aliasNative ? '' : (eff.username || user),
1606
+ password: rec.password, privateKeyPath: ct.privateKeyPath,
1444
1607
  // Explicit (non-undefined) values so a stale passphrase/agent flag
1445
1608
  // from a previous target cannot leak into this connection.
1446
1609
  passphrase: eff.passphrase || '',
1447
1610
  useAgent: !!rec.useAgent,
1448
1611
  keyboardInteractive: !!rec.keyboardInteractive,
1449
1612
  hostKeyMode: rec.hostKeyMode || config.hostKeyMode,
1450
- proxy: eff.proxy,
1613
+ proxy: ct.proxy,
1614
+ backend: rec.backend || config.backend,
1615
+ sshBinary: rec.sshBinary !== undefined ? rec.sshBinary : config.sshBinary,
1451
1616
  workspace: config.workspace,
1452
1617
  })
1453
1618
  }
@@ -1492,13 +1657,18 @@ export async function apply(ctx, config) {
1492
1657
  if (m.privateKeyPath) auth.push(`key:${m.privateKeyPath}${m.passphrase ? ' (passphrase set)' : ''}`)
1493
1658
  if (m.useAgent) auth.push('agent')
1494
1659
  if (m.keyboardInteractive) auth.push('keyboard-interactive')
1660
+ // Issue #51: say which transport will be used — on `openssh` the
1661
+ // credentials may come entirely from ~/.ssh/config (GSSAPI/kinit), so
1662
+ // "auth: NONE" would be actively misleading there.
1663
+ const backend = m.backend === 'openssh' ? 'openssh' : 'native'
1664
+ if (backend === 'openssh') auth.push('openssh backend (~/.ssh/config: GSSAPI/key/agent)')
1495
1665
  let via = ''
1496
1666
  if (m.useSshConfig) {
1497
1667
  const eff = withSshConfig(m)
1498
1668
  via = ` alias→${eff.username || ''}@${eff.host}:${Number(eff.port) || 22}`
1499
1669
  }
1500
1670
  const ws = m.workspace ? ` workspace=${m.workspace}` : ''
1501
- return `- ${m.id} "${m.name || m.host}" ${m.username || ''}@${m.host}:${Number(m.port) || 22}${via}${cur}\n auth: ${auth.join(' + ') || 'NONE (connect will fail — add credentials)'}${ws}`
1671
+ return `- ${m.id} "${m.name || m.host}" [${backend}] ${m.username || ''}@${m.host}:${Number(m.port) || 22}${via}${cur}\n auth: ${auth.join(' + ') || 'NONE (connect will fail — add credentials)'}${ws}`
1502
1672
  })
1503
1673
  return { text: lines.join('\n') + '\n\nconnect with rw_connect(machineId=<id>) to reuse a machine\'s full stored settings.' }
1504
1674
  },
@@ -2570,6 +2740,27 @@ export async function apply(ctx, config) {
2570
2740
  }
2571
2741
  },
2572
2742
  },
2743
+ {
2744
+ // Every local mirror with its recorded remote origin (issue #49).
2745
+ //
2746
+ // The sidebar paints a "this session is remote" mark on a Session row, so
2747
+ // it needs the same mapping for every visible row. One directory listing
2748
+ // answers all of them: the client matches each row's own cwd (which it
2749
+ // already has in the sessions snapshot) against these mirror dirs, and a
2750
+ // row whose cwd is inside a mirror is a remote session bound to that
2751
+ // mirror's host. Never consults the mutable active machine — a saved
2752
+ // machine that happens to be "current" must not label a local session.
2753
+ kind: 'exact',
2754
+ path: '/dsh-remote/bindings',
2755
+ handler: async (req, res) => {
2756
+ if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed' })
2757
+ try {
2758
+ return sendJson(res, 200, { mirrors: mirrorRegistry() })
2759
+ } catch (err) {
2760
+ return sendJson(res, 500, { error: String((err && err.message) || err) })
2761
+ }
2762
+ },
2763
+ },
2573
2764
  {
2574
2765
  kind: 'exact',
2575
2766
  path: '/dsh-remote/connect',
@@ -2786,6 +2977,19 @@ export async function apply(ctx, config) {
2786
2977
  hostKeyMode: body.hostKeyMode || '',
2787
2978
  useAgent: !!body.useAgent,
2788
2979
  keyboardInteractive: !!body.keyboardInteractive,
2980
+ // Issue #51: the transport backend is per machine. `native` is
2981
+ // the default, so an unknown value is not persisted as-is.
2982
+ backend: body.backend === 'openssh' ? 'openssh' : 'native',
2983
+ sshBinary: String(body.sshBinary || '').trim(),
2984
+ // Issue #54: the per-machine `dsh web` launch command was only
2985
+ // ever writable by the deploy flow or by hand-editing
2986
+ // machines.json — the README told users to set it and the
2987
+ // settings UI offered nowhere to type it. It is an ordinary
2988
+ // machine field now; empty means "fall back to the global
2989
+ // webAttachCommand".
2990
+ webAttachCommand: body.webAttachCommand === undefined
2991
+ ? String((prev && prev.webAttachCommand) || '')
2992
+ : String(body.webAttachCommand || '').trim(),
2789
2993
  proxy: body.proxy && body.proxy.host ? {
2790
2994
  host: String(body.proxy.host),
2791
2995
  port: Number(body.proxy.port) || 22,
@@ -2880,6 +3084,8 @@ export async function apply(ctx, config) {
2880
3084
  body = parseJsonObject(await readBody(req))
2881
3085
  // Issue #38: "test" on an ssh-config ALIAS resolves the alias first —
2882
3086
  // the form holds the alias name, not the host it points at.
3087
+ const probeBackend = String(body.backend || config.backend || 'native')
3088
+ const probeIsOpenssh = isSystemOpenSshBackend({ backend: probeBackend })
2883
3089
  const aliasTarget = body.useSshConfig
2884
3090
  ? resolveMachineSshConfig({
2885
3091
  host: String(body.host || ''),
@@ -2890,15 +3096,20 @@ export async function apply(ctx, config) {
2890
3096
  useSshConfig: true,
2891
3097
  }, { text: sshConfigText() })
2892
3098
  : null
2893
- const probe = new SshPool({
3099
+ // Issue #51: on the system-OpenSSH backend an alias is handed to `ssh`
3100
+ // VERBATIM (see connectTargetFor) — resolving it here would stop the
3101
+ // `Host` block from matching, so the ~/.ssh/config settings the probe
3102
+ // is supposed to validate would be silently skipped.
3103
+ const probe = createPool({
2894
3104
  ...config,
2895
- host: aliasTarget ? aliasTarget.host : String(body.host || config.host),
2896
- port: aliasTarget ? aliasTarget.port : (Number(body.port) || config.port),
2897
- username: aliasTarget ? aliasTarget.username : String(body.username || config.username),
3105
+ backend: probeBackend,
3106
+ host: aliasTarget && !probeIsOpenssh ? aliasTarget.host : String(body.host || config.host),
3107
+ port: aliasTarget && !probeIsOpenssh ? aliasTarget.port : (Number(body.port) || config.port),
3108
+ username: aliasTarget && !probeIsOpenssh ? aliasTarget.username : String(body.username || config.username),
2898
3109
  password: String(body.password || ''),
2899
- privateKeyPath: aliasTarget ? aliasTarget.privateKeyPath : String(body.privateKeyPath || config.privateKeyPath),
3110
+ privateKeyPath: (aliasTarget && !probeIsOpenssh) ? aliasTarget.privateKeyPath : String(body.privateKeyPath || config.privateKeyPath),
2900
3111
  passphrase: String(body.passphrase || ''),
2901
- proxy: aliasTarget && aliasTarget.proxy ? aliasTarget.proxy : (body.proxy && body.proxy.host ? {
3112
+ proxy: (aliasTarget && !probeIsOpenssh && aliasTarget.proxy) ? aliasTarget.proxy : (body.proxy && body.proxy.host ? {
2902
3113
  host: String(body.proxy.host),
2903
3114
  port: Number(body.proxy.port) || 22,
2904
3115
  username: String(body.proxy.username || ''),
@@ -3254,12 +3465,17 @@ export async function apply(ctx, config) {
3254
3465
  if (live) { try { await live.close() } catch { /* already torn down */ } }
3255
3466
  const machine = machines.find((m) => m.id === machineId)
3256
3467
  if (!machine) return sendJson(res, 404, { ok: false, error: `机器不存在:${machineId}` })
3468
+ const attachPool = poolForMachine(identityOf(machine))
3257
3469
  const attach = new WebAttach({
3258
3470
  localPortStart: config.webAttachPortStart,
3471
+ // Issue #54: follow the chosen pool's lifecycle so a dropped SSH
3472
+ // connection degrades to "disconnected" and then self-heals,
3473
+ // instead of leaving a dead client that crashes the host process.
3474
+ trackPool: true,
3259
3475
  })
3260
3476
  try {
3261
3477
  await attach.open({
3262
- pool: poolForMachine(identityOf(machine)),
3478
+ pool: attachPool,
3263
3479
  machineId,
3264
3480
  command: String(body.command || '').trim() || webAttachCommandFor(machine),
3265
3481
  profile: 'web',