dsh-remote 0.8.42 → 0.8.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +38 -3
- package/README.md +63 -2
- package/lib/client.js +540 -10
- package/lib/forwards.js +45 -6
- package/lib/index.js +247 -31
- package/lib/openssh.js +838 -0
- package/lib/pool.js +177 -57
- package/lib/registry.js +6 -0
- package/lib/sftp-surface.js +76 -0
- package/lib/web-attach.js +384 -17
- package/lib/web-deploy.js +43 -1
- package/package.json +2 -1
package/lib/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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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,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
|
-
|
|
1443
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
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:
|
|
3478
|
+
pool: attachPool,
|
|
3263
3479
|
machineId,
|
|
3264
3480
|
command: String(body.command || '').trim() || webAttachCommandFor(machine),
|
|
3265
3481
|
profile: 'web',
|