dsh-remote 0.8.42 → 0.8.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +5 -0
- package/README.md +5 -0
- package/lib/client.js +334 -10
- package/lib/forwards.js +22 -0
- package/lib/index.js +228 -30
- package/lib/openssh.js +838 -0
- package/lib/pool.js +139 -54
- package/lib/registry.js +6 -0
- package/lib/sftp-surface.js +76 -0
- package/package.json +2 -1
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
|
-
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
const
|
|
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
|
-
//
|
|
606
|
-
//
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
//
|
|
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
|
|
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,17 @@ 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)
|
|
1406
1561
|
if (useSshConfig) rec.useSshConfig = true
|
|
1407
1562
|
// An alias connection must dial the RESOLVED identity (~/.ssh/config),
|
|
1408
|
-
// while the registry keeps the alias itself
|
|
1563
|
+
// while the registry keeps the alias itself — EXCEPT on the
|
|
1564
|
+
// system-OpenSSH backend, where ssh resolves the alias itself.
|
|
1409
1565
|
const eff = useSshConfig ? withSshConfig(rec) : rec
|
|
1566
|
+
const ct = connectTargetFor(eff)
|
|
1410
1567
|
if (args.save !== false) {
|
|
1411
1568
|
// Upsert into the registry and make it the current machine so the
|
|
1412
1569
|
// settings UI and the tools always agree on who is active.
|
|
@@ -1439,15 +1596,19 @@ export async function apply(ctx, config) {
|
|
|
1439
1596
|
// cached client — same reconnect-probe reasoning as /connect).
|
|
1440
1597
|
pool.invalidate()
|
|
1441
1598
|
pool.setTarget({
|
|
1442
|
-
|
|
1443
|
-
|
|
1599
|
+
// connectTargetFor: on the system-OpenSSH backend an alias machine
|
|
1600
|
+
// dials the alias, not the values it expands to.
|
|
1601
|
+
host: ct.host, port: ct.aliasNative ? 22 : (Number(eff.port) || port || 22), username: ct.aliasNative ? '' : (eff.username || user),
|
|
1602
|
+
password: rec.password, privateKeyPath: ct.privateKeyPath,
|
|
1444
1603
|
// Explicit (non-undefined) values so a stale passphrase/agent flag
|
|
1445
1604
|
// from a previous target cannot leak into this connection.
|
|
1446
1605
|
passphrase: eff.passphrase || '',
|
|
1447
1606
|
useAgent: !!rec.useAgent,
|
|
1448
1607
|
keyboardInteractive: !!rec.keyboardInteractive,
|
|
1449
1608
|
hostKeyMode: rec.hostKeyMode || config.hostKeyMode,
|
|
1450
|
-
proxy:
|
|
1609
|
+
proxy: ct.proxy,
|
|
1610
|
+
backend: rec.backend || config.backend,
|
|
1611
|
+
sshBinary: rec.sshBinary !== undefined ? rec.sshBinary : config.sshBinary,
|
|
1451
1612
|
workspace: config.workspace,
|
|
1452
1613
|
})
|
|
1453
1614
|
}
|
|
@@ -1492,13 +1653,18 @@ export async function apply(ctx, config) {
|
|
|
1492
1653
|
if (m.privateKeyPath) auth.push(`key:${m.privateKeyPath}${m.passphrase ? ' (passphrase set)' : ''}`)
|
|
1493
1654
|
if (m.useAgent) auth.push('agent')
|
|
1494
1655
|
if (m.keyboardInteractive) auth.push('keyboard-interactive')
|
|
1656
|
+
// Issue #51: say which transport will be used — on `openssh` the
|
|
1657
|
+
// credentials may come entirely from ~/.ssh/config (GSSAPI/kinit), so
|
|
1658
|
+
// "auth: NONE" would be actively misleading there.
|
|
1659
|
+
const backend = m.backend === 'openssh' ? 'openssh' : 'native'
|
|
1660
|
+
if (backend === 'openssh') auth.push('openssh backend (~/.ssh/config: GSSAPI/key/agent)')
|
|
1495
1661
|
let via = ''
|
|
1496
1662
|
if (m.useSshConfig) {
|
|
1497
1663
|
const eff = withSshConfig(m)
|
|
1498
1664
|
via = ` alias→${eff.username || ''}@${eff.host}:${Number(eff.port) || 22}`
|
|
1499
1665
|
}
|
|
1500
1666
|
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}`
|
|
1667
|
+
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
1668
|
})
|
|
1503
1669
|
return { text: lines.join('\n') + '\n\nconnect with rw_connect(machineId=<id>) to reuse a machine\'s full stored settings.' }
|
|
1504
1670
|
},
|
|
@@ -2570,6 +2736,27 @@ export async function apply(ctx, config) {
|
|
|
2570
2736
|
}
|
|
2571
2737
|
},
|
|
2572
2738
|
},
|
|
2739
|
+
{
|
|
2740
|
+
// Every local mirror with its recorded remote origin (issue #49).
|
|
2741
|
+
//
|
|
2742
|
+
// The sidebar paints a "this session is remote" mark on a Session row, so
|
|
2743
|
+
// it needs the same mapping for every visible row. One directory listing
|
|
2744
|
+
// answers all of them: the client matches each row's own cwd (which it
|
|
2745
|
+
// already has in the sessions snapshot) against these mirror dirs, and a
|
|
2746
|
+
// row whose cwd is inside a mirror is a remote session bound to that
|
|
2747
|
+
// mirror's host. Never consults the mutable active machine — a saved
|
|
2748
|
+
// machine that happens to be "current" must not label a local session.
|
|
2749
|
+
kind: 'exact',
|
|
2750
|
+
path: '/dsh-remote/bindings',
|
|
2751
|
+
handler: async (req, res) => {
|
|
2752
|
+
if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed' })
|
|
2753
|
+
try {
|
|
2754
|
+
return sendJson(res, 200, { mirrors: mirrorRegistry() })
|
|
2755
|
+
} catch (err) {
|
|
2756
|
+
return sendJson(res, 500, { error: String((err && err.message) || err) })
|
|
2757
|
+
}
|
|
2758
|
+
},
|
|
2759
|
+
},
|
|
2573
2760
|
{
|
|
2574
2761
|
kind: 'exact',
|
|
2575
2762
|
path: '/dsh-remote/connect',
|
|
@@ -2786,6 +2973,10 @@ export async function apply(ctx, config) {
|
|
|
2786
2973
|
hostKeyMode: body.hostKeyMode || '',
|
|
2787
2974
|
useAgent: !!body.useAgent,
|
|
2788
2975
|
keyboardInteractive: !!body.keyboardInteractive,
|
|
2976
|
+
// Issue #51: the transport backend is per machine. `native` is
|
|
2977
|
+
// the default, so an unknown value is not persisted as-is.
|
|
2978
|
+
backend: body.backend === 'openssh' ? 'openssh' : 'native',
|
|
2979
|
+
sshBinary: String(body.sshBinary || '').trim(),
|
|
2789
2980
|
proxy: body.proxy && body.proxy.host ? {
|
|
2790
2981
|
host: String(body.proxy.host),
|
|
2791
2982
|
port: Number(body.proxy.port) || 22,
|
|
@@ -2880,6 +3071,8 @@ export async function apply(ctx, config) {
|
|
|
2880
3071
|
body = parseJsonObject(await readBody(req))
|
|
2881
3072
|
// Issue #38: "test" on an ssh-config ALIAS resolves the alias first —
|
|
2882
3073
|
// the form holds the alias name, not the host it points at.
|
|
3074
|
+
const probeBackend = String(body.backend || config.backend || 'native')
|
|
3075
|
+
const probeIsOpenssh = isSystemOpenSshBackend({ backend: probeBackend })
|
|
2883
3076
|
const aliasTarget = body.useSshConfig
|
|
2884
3077
|
? resolveMachineSshConfig({
|
|
2885
3078
|
host: String(body.host || ''),
|
|
@@ -2890,15 +3083,20 @@ export async function apply(ctx, config) {
|
|
|
2890
3083
|
useSshConfig: true,
|
|
2891
3084
|
}, { text: sshConfigText() })
|
|
2892
3085
|
: null
|
|
2893
|
-
|
|
3086
|
+
// Issue #51: on the system-OpenSSH backend an alias is handed to `ssh`
|
|
3087
|
+
// VERBATIM (see connectTargetFor) — resolving it here would stop the
|
|
3088
|
+
// `Host` block from matching, so the ~/.ssh/config settings the probe
|
|
3089
|
+
// is supposed to validate would be silently skipped.
|
|
3090
|
+
const probe = createPool({
|
|
2894
3091
|
...config,
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
3092
|
+
backend: probeBackend,
|
|
3093
|
+
host: aliasTarget && !probeIsOpenssh ? aliasTarget.host : String(body.host || config.host),
|
|
3094
|
+
port: aliasTarget && !probeIsOpenssh ? aliasTarget.port : (Number(body.port) || config.port),
|
|
3095
|
+
username: aliasTarget && !probeIsOpenssh ? aliasTarget.username : String(body.username || config.username),
|
|
2898
3096
|
password: String(body.password || ''),
|
|
2899
|
-
privateKeyPath: aliasTarget ? aliasTarget.privateKeyPath : String(body.privateKeyPath || config.privateKeyPath),
|
|
3097
|
+
privateKeyPath: (aliasTarget && !probeIsOpenssh) ? aliasTarget.privateKeyPath : String(body.privateKeyPath || config.privateKeyPath),
|
|
2900
3098
|
passphrase: String(body.passphrase || ''),
|
|
2901
|
-
proxy: aliasTarget && aliasTarget.proxy ? aliasTarget.proxy : (body.proxy && body.proxy.host ? {
|
|
3099
|
+
proxy: (aliasTarget && !probeIsOpenssh && aliasTarget.proxy) ? aliasTarget.proxy : (body.proxy && body.proxy.host ? {
|
|
2902
3100
|
host: String(body.proxy.host),
|
|
2903
3101
|
port: Number(body.proxy.port) || 22,
|
|
2904
3102
|
username: String(body.proxy.username || ''),
|