dsh-remote 0.8.41 → 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 +464 -18
- package/lib/deploy-tasks.js +142 -0
- package/lib/forwards.js +22 -0
- package/lib/index.js +339 -93
- 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
|
@@ -40,6 +40,7 @@ import { syncTree, pushTree, loadSyncState, saveSyncState, pushOneFile } from '.
|
|
|
40
40
|
import { searchRemote } from './search.js'
|
|
41
41
|
import { resolveMirror, poolKey, lookupSessionCwd, encodeSegmentSafe, readSessionHeaderCwd, requestSessionHint } from './binding.js'
|
|
42
42
|
import { TaskManager } from './tasks.js'
|
|
43
|
+
import { DeployTasks } from './deploy-tasks.js'
|
|
43
44
|
import { ForwardManager } from './forwards.js'
|
|
44
45
|
import { WebAttach } from './web-attach.js'
|
|
45
46
|
import {
|
|
@@ -51,7 +52,8 @@ import { selfDir, diskVersion, LOADED_VERSION, gtVersion, fetchLatestVersion, ap
|
|
|
51
52
|
import { sendHeartbeat, pseudonym, heartbeatUrl, hasPersistedId, installIdPath } from './telemetry.js'
|
|
52
53
|
import { loadMachines as _loadMachines, saveMachines as _saveMachines, sanitizeMachine as _sanitizeMachine, applyMachine as _applyMachine, machineId as _machineId } from './registry.js'
|
|
53
54
|
import { registerHttpTransports } from './http-transport.js'
|
|
54
|
-
import { SshPool } from './pool.js'
|
|
55
|
+
import { SshPool, ActivePool, createPool } from './pool.js'
|
|
56
|
+
import { isSystemOpenSshBackend, sshBinaryAvailable, resolveSshBinary } from './openssh.js'
|
|
55
57
|
import { createFsRoutes } from './routes-fs.js'
|
|
56
58
|
import { listDirStructured, removeRemoteTree } from './remote-fs.js'
|
|
57
59
|
|
|
@@ -109,6 +111,17 @@ export const Config = z.object({
|
|
|
109
111
|
useAgent: z.boolean().default(false),
|
|
110
112
|
/** Allow keyboard-interactive auth (OTP/MFA chains) using the configured password. */
|
|
111
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(''),
|
|
112
125
|
/** Jump host / bastion: connect through this machine first. All fields have
|
|
113
126
|
* defaults so this works on schemastery versions without `.optional()`; an
|
|
114
127
|
* empty `host` means "no jump host". */
|
|
@@ -315,7 +328,9 @@ function encodeText(s, enc) {
|
|
|
315
328
|
// ── apply ─────────────────────────────────────────────────────────────────
|
|
316
329
|
|
|
317
330
|
export async function apply(ctx, config) {
|
|
318
|
-
|
|
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 })
|
|
319
334
|
ctx.effect(() => () => pool.close(), 'dsh-remote.close')
|
|
320
335
|
|
|
321
336
|
migrateLegacyData()
|
|
@@ -394,6 +409,50 @@ export async function apply(ctx, config) {
|
|
|
394
409
|
}
|
|
395
410
|
}
|
|
396
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
|
+
|
|
397
456
|
/** The alias a machine was saved as ('' for a machine with literal values). */
|
|
398
457
|
const aliasOf = (m) => (m && m.sshAlias) || (m && m.useSshConfig ? String(m.host || '') : '')
|
|
399
458
|
|
|
@@ -481,6 +540,9 @@ export async function apply(ctx, config) {
|
|
|
481
540
|
passphrase: c.passphrase, workspace: c.workspace,
|
|
482
541
|
useAgent: c.useAgent, keyboardInteractive: c.keyboardInteractive,
|
|
483
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,
|
|
484
546
|
})
|
|
485
547
|
|
|
486
548
|
/** Fingerprint of the machine record last applied to the shared pool config.
|
|
@@ -512,6 +574,17 @@ export async function apply(ctx, config) {
|
|
|
512
574
|
// OLD connection alive across a machine switch (issue #25 hazard).
|
|
513
575
|
const previous = targetFields(config)
|
|
514
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
|
+
}
|
|
515
588
|
pool.setTarget(targetFields(config), previous)
|
|
516
589
|
lastAppliedMachine = appliedFingerprint()
|
|
517
590
|
}
|
|
@@ -595,28 +668,36 @@ export async function apply(ctx, config) {
|
|
|
595
668
|
// effect for an existing session instead of dialing the stale address.
|
|
596
669
|
const rec = machineRecordFor(target)
|
|
597
670
|
const eff = rec ? withSshConfig(rec) : null
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
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')
|
|
601
677
|
const existing = machinePools.get(key)
|
|
602
678
|
if (existing) {
|
|
603
679
|
syncTunables(existing.config)
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
//
|
|
610
|
-
// machine in the settings page must reach a pool created earlier, and a
|
|
611
|
-
// 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.
|
|
612
686
|
if (eff) {
|
|
613
687
|
existing.config.password = eff.password || ''
|
|
614
|
-
existing.config.privateKeyPath = eff.privateKeyPath || ''
|
|
688
|
+
existing.config.privateKeyPath = t.aliasNative ? '' : (eff.privateKeyPath || '')
|
|
615
689
|
existing.config.passphrase = eff.passphrase || ''
|
|
616
690
|
existing.config.useAgent = !!eff.useAgent
|
|
617
691
|
existing.config.keyboardInteractive = !!eff.keyboardInteractive
|
|
618
|
-
existing.config.proxy = eff.proxy || undefined
|
|
692
|
+
existing.config.proxy = t.aliasNative ? undefined : (eff.proxy || undefined)
|
|
619
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 })
|
|
620
701
|
}
|
|
621
702
|
return existing
|
|
622
703
|
}
|
|
@@ -626,17 +707,22 @@ export async function apply(ctx, config) {
|
|
|
626
707
|
port,
|
|
627
708
|
username,
|
|
628
709
|
password: (eff && eff.password) || '',
|
|
629
|
-
privateKeyPath: (eff && eff.privateKeyPath) || '',
|
|
710
|
+
privateKeyPath: t.aliasNative ? '' : ((eff && eff.privateKeyPath) || ''),
|
|
630
711
|
passphrase: (eff && eff.passphrase) || '',
|
|
631
712
|
useAgent: eff ? !!eff.useAgent : !!config.useAgent,
|
|
632
713
|
keyboardInteractive: eff ? !!eff.keyboardInteractive : !!config.keyboardInteractive,
|
|
633
|
-
proxy: (eff && eff.proxy) || undefined,
|
|
714
|
+
proxy: t.aliasNative ? undefined : ((eff && eff.proxy) || undefined),
|
|
634
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,
|
|
635
721
|
// Identity-pinned pools never carry the shared active workspace: a
|
|
636
722
|
// session passes its own mirror-resolved remote path explicitly.
|
|
637
723
|
workspace: '',
|
|
638
724
|
})
|
|
639
|
-
const created = new
|
|
725
|
+
const created = new ActivePool(poolConfig, { knownHostsFile })
|
|
640
726
|
// Keychain-backed passwords resolve against THIS pool's machine record.
|
|
641
727
|
created.passwordResolver = async () => machinePassword(eff || { id: '', password: poolConfig.password })
|
|
642
728
|
machinePools.set(key, created)
|
|
@@ -849,6 +935,10 @@ export async function apply(ctx, config) {
|
|
|
849
935
|
// starting a second remote server. Lifecycle is owned by the plugin fiber, so
|
|
850
936
|
// unloading the plugin tears every tunnel down.
|
|
851
937
|
const webAttaches = new Map() // machineId → WebAttach
|
|
938
|
+
// Deploy tasks run on the HOST, detached from any request: closing the browser
|
|
939
|
+
// window mid-install must not lose the progress, and a re-opened page must be
|
|
940
|
+
// able to re-attach and see the live state (high-availability requirement).
|
|
941
|
+
const deployTasks = new DeployTasks()
|
|
852
942
|
ctx.effect(() => () => {
|
|
853
943
|
for (const attach of webAttaches.values()) { try { attach.close() } catch { /* already gone */ } }
|
|
854
944
|
webAttaches.clear()
|
|
@@ -956,6 +1046,69 @@ export async function apply(ctx, config) {
|
|
|
956
1046
|
* without consulting the mutable active-machine state, so a concurrent
|
|
957
1047
|
* session switching machines cannot redirect this session's commands. */
|
|
958
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
|
+
}
|
|
959
1112
|
if (config.autoPush && wsPath()) {
|
|
960
1113
|
startAutoPush(mirrorDirFor(wsPath(), config.host, config.username, config.port))
|
|
961
1114
|
}
|
|
@@ -1345,7 +1498,7 @@ export async function apply(ctx, config) {
|
|
|
1345
1498
|
defineTool({
|
|
1346
1499
|
name: 'rw_connect',
|
|
1347
1500
|
description:
|
|
1348
|
-
'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.',
|
|
1349
1502
|
parameters: {
|
|
1350
1503
|
host: { type: 'string', description: 'Remote host IP or hostname (or a ~/.ssh/config alias with useSshConfig=true). Required unless machineId is given.' },
|
|
1351
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.' },
|
|
@@ -1357,6 +1510,8 @@ export async function apply(ctx, config) {
|
|
|
1357
1510
|
useAgent: { type: 'boolean', description: 'Authenticate via the local SSH agent (SSH_AUTH_SOCK) instead of password/key' },
|
|
1358
1511
|
keyboardInteractive: { type: 'boolean', description: 'Enable keyboard-interactive auth (OTP/动态码 prompts answered with the configured password)' },
|
|
1359
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"' },
|
|
1360
1515
|
name: { type: 'string', description: 'Display name for a newly saved machine (default: the host)' },
|
|
1361
1516
|
useSshConfig: { type: 'boolean', description: 'Treat `host` as a ~/.ssh/config Host alias; resolve host/user/port/key/proxy from that file (issue #38)' },
|
|
1362
1517
|
save: { type: 'boolean', description: 'Save this machine to the registry and make it current (default true)' },
|
|
@@ -1398,10 +1553,17 @@ export async function apply(ctx, config) {
|
|
|
1398
1553
|
if (args.keyboardInteractive !== undefined) rec.keyboardInteractive = !!args.keyboardInteractive
|
|
1399
1554
|
if (args.hostKeyMode) rec.hostKeyMode = String(args.hostKeyMode)
|
|
1400
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)
|
|
1401
1561
|
if (useSshConfig) rec.useSshConfig = true
|
|
1402
1562
|
// An alias connection must dial the RESOLVED identity (~/.ssh/config),
|
|
1403
|
-
// 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.
|
|
1404
1565
|
const eff = useSshConfig ? withSshConfig(rec) : rec
|
|
1566
|
+
const ct = connectTargetFor(eff)
|
|
1405
1567
|
if (args.save !== false) {
|
|
1406
1568
|
// Upsert into the registry and make it the current machine so the
|
|
1407
1569
|
// settings UI and the tools always agree on who is active.
|
|
@@ -1434,15 +1596,19 @@ export async function apply(ctx, config) {
|
|
|
1434
1596
|
// cached client — same reconnect-probe reasoning as /connect).
|
|
1435
1597
|
pool.invalidate()
|
|
1436
1598
|
pool.setTarget({
|
|
1437
|
-
|
|
1438
|
-
|
|
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,
|
|
1439
1603
|
// Explicit (non-undefined) values so a stale passphrase/agent flag
|
|
1440
1604
|
// from a previous target cannot leak into this connection.
|
|
1441
1605
|
passphrase: eff.passphrase || '',
|
|
1442
1606
|
useAgent: !!rec.useAgent,
|
|
1443
1607
|
keyboardInteractive: !!rec.keyboardInteractive,
|
|
1444
1608
|
hostKeyMode: rec.hostKeyMode || config.hostKeyMode,
|
|
1445
|
-
proxy:
|
|
1609
|
+
proxy: ct.proxy,
|
|
1610
|
+
backend: rec.backend || config.backend,
|
|
1611
|
+
sshBinary: rec.sshBinary !== undefined ? rec.sshBinary : config.sshBinary,
|
|
1446
1612
|
workspace: config.workspace,
|
|
1447
1613
|
})
|
|
1448
1614
|
}
|
|
@@ -1487,13 +1653,18 @@ export async function apply(ctx, config) {
|
|
|
1487
1653
|
if (m.privateKeyPath) auth.push(`key:${m.privateKeyPath}${m.passphrase ? ' (passphrase set)' : ''}`)
|
|
1488
1654
|
if (m.useAgent) auth.push('agent')
|
|
1489
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)')
|
|
1490
1661
|
let via = ''
|
|
1491
1662
|
if (m.useSshConfig) {
|
|
1492
1663
|
const eff = withSshConfig(m)
|
|
1493
1664
|
via = ` alias→${eff.username || ''}@${eff.host}:${Number(eff.port) || 22}`
|
|
1494
1665
|
}
|
|
1495
1666
|
const ws = m.workspace ? ` workspace=${m.workspace}` : ''
|
|
1496
|
-
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}`
|
|
1497
1668
|
})
|
|
1498
1669
|
return { text: lines.join('\n') + '\n\nconnect with rw_connect(machineId=<id>) to reuse a machine\'s full stored settings.' }
|
|
1499
1670
|
},
|
|
@@ -2565,6 +2736,27 @@ export async function apply(ctx, config) {
|
|
|
2565
2736
|
}
|
|
2566
2737
|
},
|
|
2567
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
|
+
},
|
|
2568
2760
|
{
|
|
2569
2761
|
kind: 'exact',
|
|
2570
2762
|
path: '/dsh-remote/connect',
|
|
@@ -2781,6 +2973,10 @@ export async function apply(ctx, config) {
|
|
|
2781
2973
|
hostKeyMode: body.hostKeyMode || '',
|
|
2782
2974
|
useAgent: !!body.useAgent,
|
|
2783
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(),
|
|
2784
2980
|
proxy: body.proxy && body.proxy.host ? {
|
|
2785
2981
|
host: String(body.proxy.host),
|
|
2786
2982
|
port: Number(body.proxy.port) || 22,
|
|
@@ -2875,6 +3071,8 @@ export async function apply(ctx, config) {
|
|
|
2875
3071
|
body = parseJsonObject(await readBody(req))
|
|
2876
3072
|
// Issue #38: "test" on an ssh-config ALIAS resolves the alias first —
|
|
2877
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 })
|
|
2878
3076
|
const aliasTarget = body.useSshConfig
|
|
2879
3077
|
? resolveMachineSshConfig({
|
|
2880
3078
|
host: String(body.host || ''),
|
|
@@ -2885,15 +3083,20 @@ export async function apply(ctx, config) {
|
|
|
2885
3083
|
useSshConfig: true,
|
|
2886
3084
|
}, { text: sshConfigText() })
|
|
2887
3085
|
: null
|
|
2888
|
-
|
|
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({
|
|
2889
3091
|
...config,
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
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),
|
|
2893
3096
|
password: String(body.password || ''),
|
|
2894
|
-
privateKeyPath: aliasTarget ? aliasTarget.privateKeyPath : String(body.privateKeyPath || config.privateKeyPath),
|
|
3097
|
+
privateKeyPath: (aliasTarget && !probeIsOpenssh) ? aliasTarget.privateKeyPath : String(body.privateKeyPath || config.privateKeyPath),
|
|
2895
3098
|
passphrase: String(body.passphrase || ''),
|
|
2896
|
-
proxy: aliasTarget && aliasTarget.proxy ? aliasTarget.proxy : (body.proxy && body.proxy.host ? {
|
|
3099
|
+
proxy: (aliasTarget && !probeIsOpenssh && aliasTarget.proxy) ? aliasTarget.proxy : (body.proxy && body.proxy.host ? {
|
|
2897
3100
|
host: String(body.proxy.host),
|
|
2898
3101
|
port: Number(body.proxy.port) || 22,
|
|
2899
3102
|
username: String(body.proxy.username || ''),
|
|
@@ -3076,10 +3279,15 @@ export async function apply(ctx, config) {
|
|
|
3076
3279
|
const q = new URL(req.url, 'http://localhost').searchParams
|
|
3077
3280
|
const id = q.get('machineId') || ''
|
|
3078
3281
|
const attach = id ? webAttaches.get(id) : undefined
|
|
3282
|
+
// Deploy tasks ride along so a REOPENED window sees an in-flight
|
|
3283
|
+
// install immediately, without a separate poll's first tick.
|
|
3284
|
+
const deploy = id ? deployTasks.forMachine(id) : null
|
|
3079
3285
|
return sendJson(res, 200, {
|
|
3080
3286
|
ok: true,
|
|
3081
3287
|
attach: attach ? attach.describe() : null,
|
|
3082
3288
|
attaches: [...webAttaches.values()].map((a) => a.describe()),
|
|
3289
|
+
deploys: deployTasks.list(),
|
|
3290
|
+
deploy: deploy ? deployTasks.describe(deploy) : null,
|
|
3083
3291
|
})
|
|
3084
3292
|
}
|
|
3085
3293
|
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
@@ -3124,75 +3332,113 @@ export async function apply(ctx, config) {
|
|
|
3124
3332
|
})
|
|
3125
3333
|
}
|
|
3126
3334
|
|
|
3127
|
-
// install
|
|
3335
|
+
// install — runs as a HOST-SIDE BACKGROUND TASK.
|
|
3336
|
+
//
|
|
3337
|
+
// It used to be one long HTTP request; npm can legitimately take
|
|
3338
|
+
// minutes, and closing the tab mid-deploy lost the response AND
|
|
3339
|
+
// every trace of the progress — the user could not tell whether it
|
|
3340
|
+
// succeeded, and a second click started a CONCURRENT npm install
|
|
3341
|
+
// into the same prefix. Now the work runs detached from the request
|
|
3342
|
+
// lifecycle, every step's outcome is recorded as it happens, and
|
|
3343
|
+
// the UI re-attaches by polling install-status (or by clicking the
|
|
3344
|
+
// button again, which resumes rather than restarts).
|
|
3128
3345
|
if (body.confirm !== true) {
|
|
3129
3346
|
return sendJson(res, 400, { ok: false, error: '安装需要在请求里显式 confirm: true' })
|
|
3130
3347
|
}
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
return sendJson(res, 400, { ok: false, error: `无法自动安装:${why || '远端缺少前提条件'}`, verdict })
|
|
3348
|
+
// RESUME: a running/queued task for this machine is returned as-is.
|
|
3349
|
+
// One deploy per machine at a time — two concurrent `npm --prefix`
|
|
3350
|
+
// into the same directory corrupt the tree.
|
|
3351
|
+
const already = deployTasks.forMachine(machineId)
|
|
3352
|
+
if (already && (already.status === 'queued' || already.status === 'running') && body.force !== true) {
|
|
3353
|
+
return sendJson(res, 200, { ok: true, resumed: true, task: deployTasks.describe(already) })
|
|
3138
3354
|
}
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
// npm config is left alone, which is what an internal mirror needs.
|
|
3142
|
-
// `--registry` is only added when configured — overriding a working
|
|
3143
|
-
// corporate mirror with the public one would be a regression.
|
|
3144
|
-
const registry = String(body.registry || config.webInstallRegistry || '').trim()
|
|
3145
|
-
// Proxy comes from the remote's own environment; the operator may
|
|
3146
|
-
// override it per-install when the inherited one is wrong.
|
|
3147
|
-
const env = body.proxy && typeof body.proxy === 'object'
|
|
3148
|
-
? Object.fromEntries(Object.entries(body.proxy).filter(([, v]) => typeof v === 'string'))
|
|
3149
|
-
: undefined
|
|
3150
|
-
const plan = buildInstallPlan({
|
|
3151
|
-
facts,
|
|
3152
|
-
prefix: installPrefix,
|
|
3153
|
-
version: String(body.version || config.webInstallVersion || DEFAULT_INSTALL_VERSION),
|
|
3154
|
-
registry,
|
|
3155
|
-
env,
|
|
3156
|
-
})
|
|
3157
|
-
const results = []
|
|
3158
|
-
for (const step of plan.steps) {
|
|
3159
|
-
let res2
|
|
3160
|
-
try {
|
|
3161
|
-
res2 = await target.exec(step.command, {
|
|
3162
|
-
timeoutMs: step.timeoutMs || 120000,
|
|
3163
|
-
// Per-step env exists for the proxy override (P4); pool.exec
|
|
3164
|
-
// passes it through to the remote process.
|
|
3165
|
-
...(step.env ? { env: step.env } : {}),
|
|
3166
|
-
})
|
|
3167
|
-
} catch (err) {
|
|
3168
|
-
results.push({ id: step.id, title: step.title, ok: false, code: -1, output: String((err && err.message) || err) })
|
|
3169
|
-
break
|
|
3170
|
-
}
|
|
3171
|
-
const r = stepResult(step, res2)
|
|
3172
|
-
results.push(r)
|
|
3173
|
-
if (!r.ok) break
|
|
3174
|
-
}
|
|
3175
|
-
const ok = results.length === plan.steps.length && results.every((r) => r.ok)
|
|
3176
|
-
if (ok) {
|
|
3177
|
-
// Persist so the attach flow uses the working build from now on.
|
|
3178
|
-
persistWebAttachCommand(machine, plan.command)
|
|
3179
|
-
audit('web-deploy', `install ${machine.host} → ${plan.command}`, 0, machine)
|
|
3180
|
-
return sendJson(res, 200, { ok: true, steps: results, command: plan.command, prefix: installPrefix })
|
|
3355
|
+
if (already && body.force !== true && already.status !== 'done' && already.status !== 'failed' && already.status !== 'cancelled') {
|
|
3356
|
+
return sendJson(res, 200, { ok: true, resumed: true, task: deployTasks.describe(already) })
|
|
3181
3357
|
}
|
|
3182
|
-
const
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3358
|
+
const { task } = deployTasks.start(machineId, `deploy ${machine.host}`, async (ctx) => {
|
|
3359
|
+
// Everything below is the SAME plan as before, but progress goes
|
|
3360
|
+
// to the task (visible while it runs) instead of one response.
|
|
3361
|
+
const pout = await target.exec(buildProbeCommand({ prefix, installedCommand, defaultPrefixFor: config.webInstallPrefix ? '' : 'home' }), { timeoutMs: 30000 })
|
|
3362
|
+
const facts = parseProbe(pout && pout.stdout)
|
|
3363
|
+
const verdict = judgeProbe(facts, { installedCommand })
|
|
3364
|
+
if (!verdict.canAutoInstall) {
|
|
3365
|
+
const why = verdict.findings.filter((x) => x.severity === 'blocker').map((x) => x.summary).join(';')
|
|
3366
|
+
audit('web-deploy', `install ${machine.host} refused: ${why}`, 1, machine)
|
|
3367
|
+
throw new Error(`无法自动安装:${why || '远端缺少前提条件'}`)
|
|
3368
|
+
}
|
|
3369
|
+
const installPrefix = facts.resolvedPrefix || resolvePrefix(facts, { prefix: config.webInstallPrefix || '' })
|
|
3370
|
+
// A registry the user set explicitly wins; otherwise the remote's
|
|
3371
|
+
// own npm config is left alone (internal mirrors keep working).
|
|
3372
|
+
const registry = String(body.registry || config.webInstallRegistry || '').trim()
|
|
3373
|
+
const env = body.proxy && typeof body.proxy === 'object'
|
|
3374
|
+
? Object.fromEntries(Object.entries(body.proxy).filter(([, v]) => typeof v === 'string'))
|
|
3375
|
+
: undefined
|
|
3376
|
+
const plan = buildInstallPlan({
|
|
3377
|
+
facts,
|
|
3378
|
+
prefix: installPrefix,
|
|
3379
|
+
version: String(body.version || config.webInstallVersion || DEFAULT_INSTALL_VERSION),
|
|
3380
|
+
registry,
|
|
3381
|
+
env,
|
|
3382
|
+
})
|
|
3383
|
+
ctx.progress({ totalSteps: plan.steps.length, prefix: installPrefix })
|
|
3384
|
+
const results = []
|
|
3385
|
+
for (const step of plan.steps) {
|
|
3386
|
+
if (ctx.cancelled) break
|
|
3387
|
+
task.current = step.title
|
|
3388
|
+
ctx.progress({ doneSteps: results.length })
|
|
3389
|
+
let res2
|
|
3390
|
+
try {
|
|
3391
|
+
res2 = await target.exec(step.command, {
|
|
3392
|
+
timeoutMs: step.timeoutMs || 120000,
|
|
3393
|
+
...(step.env ? { env: step.env } : {}),
|
|
3394
|
+
})
|
|
3395
|
+
} catch (err) {
|
|
3396
|
+
results.push({ id: step.id, title: step.title, ok: false, code: -1, output: String((err && err.message) || err) })
|
|
3397
|
+
break
|
|
3398
|
+
}
|
|
3399
|
+
const r = stepResult(step, res2)
|
|
3400
|
+
results.push(r)
|
|
3401
|
+
task.steps = [...results]
|
|
3402
|
+
if (!r.ok) break
|
|
3403
|
+
}
|
|
3404
|
+
task.steps = [...results]
|
|
3405
|
+
const ok = results.length === plan.steps.length && results.every((r) => r.ok)
|
|
3406
|
+
if (ok) {
|
|
3407
|
+
// Persist so the attach flow uses the working build from now on.
|
|
3408
|
+
persistWebAttachCommand(machine, plan.command)
|
|
3409
|
+
audit('web-deploy', `install ${machine.host} → ${plan.command}`, 0, machine)
|
|
3410
|
+
return { ok: true, steps: results, command: plan.command, prefix: installPrefix }
|
|
3411
|
+
}
|
|
3412
|
+
const failed = results.find((r) => !r.ok)
|
|
3413
|
+
audit('web-deploy', `install ${machine.host} failed at ${failed && failed.id}`, 1, machine)
|
|
3414
|
+
const looksNetworky = /ETIMEDOUT|ENOTFOUND|ECONNREFUSED|network|proxy|403|404/i.test(String(failed && failed.output))
|
|
3415
|
+
const err = new Error(`安装失败于「${failed ? failed.title : '未知步骤'}」`)
|
|
3416
|
+
err.detail = {
|
|
3417
|
+
steps: results,
|
|
3418
|
+
prefix: installPrefix,
|
|
3419
|
+
suggestInvestigate: true,
|
|
3420
|
+
...(looksNetworky ? { hint: '看起来是网络或源的问题,可以让 AI 排查。' } : {}),
|
|
3421
|
+
}
|
|
3422
|
+
throw err
|
|
3195
3423
|
})
|
|
3424
|
+
// Answer immediately: the task object IS the handle the UI polls.
|
|
3425
|
+
return sendJson(res, 200, { ok: true, started: true, task: deployTasks.describe(task) })
|
|
3426
|
+
}
|
|
3427
|
+
|
|
3428
|
+
// install-status: re-attach to a deploy at ANY time (window reopened,
|
|
3429
|
+
// page refreshed). Also carried by GET /dsh-remote/web-attach.
|
|
3430
|
+
if (action === 'install-status') {
|
|
3431
|
+
const t = deployTasks.forMachine(machineId)
|
|
3432
|
+
if (!t) return sendJson(res, 200, { ok: true, task: null })
|
|
3433
|
+
return sendJson(res, 200, { ok: true, task: deployTasks.describe(t) })
|
|
3434
|
+
}
|
|
3435
|
+
|
|
3436
|
+
// install-cancel: cooperative cancel between steps.
|
|
3437
|
+
if (action === 'install-cancel') {
|
|
3438
|
+
const t = deployTasks.forMachine(machineId)
|
|
3439
|
+
if (!t) return sendJson(res, 200, { ok: true, cancelled: false })
|
|
3440
|
+
const did = deployTasks.cancel(machineId)
|
|
3441
|
+
return sendJson(res, 200, { ok: true, cancelled: did, task: deployTasks.describe(t) })
|
|
3196
3442
|
}
|
|
3197
3443
|
|
|
3198
3444
|
if (action === 'open') {
|