@linxin666/dsh-ssh 0.3.20 → 0.3.22

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.
@@ -5,7 +5,8 @@
5
5
  */
6
6
  import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
7
7
  import type { SshApi } from '../api.ts'
8
- import type { SshHostSummary, TestResult } from '../../protocol.ts'
8
+ import type { ImportSkipBlock, ImportSkipReason, SshHostSummary, TestResult } from '../../protocol.ts'
9
+ import type { SshKey } from '../locales.ts'
9
10
  import { errorMessage, tt } from './helpers.ts'
10
11
  import { HostFormDialog } from './HostFormDialog.tsx'
11
12
  import css from './panel.module.css'
@@ -20,6 +21,17 @@ export interface HostsTabProps {
20
21
  /** The host-form dialog invocation. */
21
22
  type DialogState = { mode: 'create' } | { mode: 'edit'; host: SshHostSummary }
22
23
 
24
+ /** Skipped-block rows rendered before the "and N more" summary. */
25
+ const IMPORT_SKIP_LIMIT = 8
26
+
27
+ /** Locale key per import skip reason (no dynamic key concatenation). */
28
+ const IMPORT_REASON_KEY: Record<ImportSkipReason, SshKey> = {
29
+ wildcard: 'import.reason.wildcard',
30
+ existing: 'import.reason.existing',
31
+ match: 'import.reason.match',
32
+ invalid: 'import.reason.invalid',
33
+ }
34
+
23
35
  /** Host list grouping modes (#379). */
24
36
  export type HostGroupBy = 'none' | 'environment' | 'tags'
25
37
 
@@ -67,6 +79,8 @@ export function HostsTab({ api, onConnect }: HostsTabProps) {
67
79
  const [testResults, setTestResults] = useState<Record<string, TestResult>>({})
68
80
  const [importing, setImporting] = useState(false)
69
81
  const [notice, setNotice] = useState<string | null>(null)
82
+ /** Blocks the last import skipped, with the reason each one was left out. */
83
+ const [importSkips, setImportSkips] = useState<ImportSkipBlock[]>([])
70
84
  const [dialog, setDialog] = useState<DialogState | null>(null)
71
85
  const [groupBy, setGroupBy] = useState<HostGroupBy>('none')
72
86
  const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
@@ -153,6 +167,7 @@ export function HostsTab({ api, onConnect }: HostsTabProps) {
153
167
  const result = await api.importSshConfig()
154
168
  if (!mountedRef.current) return
155
169
  setNotice(tt('hosts.imported', { parsed: result.parsed, added: result.added, skipped: result.skipped }))
170
+ setImportSkips(result.skippedBlocks)
156
171
  void load()
157
172
  } catch (cause) {
158
173
  if (!mountedRef.current) return
@@ -167,7 +182,12 @@ export function HostsTab({ api, onConnect }: HostsTabProps) {
167
182
  return (
168
183
  <tr key={host.alias}>
169
184
  <td className={css.mono}>{host.alias}</td>
170
- <td className={css.mono}>{host.host}:{host.port}</td>
185
+ <td className={css.mono}>
186
+ {host.host}:{host.port}
187
+ {host.proxyCommand !== undefined && (
188
+ <span className={css.badge} data-kind="proxy" title={host.proxyCommand}>{tt('hosts.proxyBadge')}</span>
189
+ )}
190
+ </td>
171
191
  <td>{host.user}</td>
172
192
  <td><span className={css.badge} data-kind={host.auth}>{host.auth === 'key' ? tt('form.auth.key') : host.auth === 'password' ? tt('form.auth.password') : tt('form.auth.agent')}</span></td>
173
193
  <td className={css.cellMuted}>{host.environment ?? ''}</td>
@@ -234,6 +254,21 @@ export function HostsTab({ api, onConnect }: HostsTabProps) {
234
254
  <button type="button" className={css.ghostButton} disabled={importing} onClick={() => { void importConfig() }}>{importing ? tt('common.loading') : tt('hosts.import')}</button>
235
255
  </div>
236
256
  {notice !== null && <div className={css.banner} data-kind="ok">{notice}</div>}
257
+ {notice !== null && importSkips.length > 0 && (
258
+ <ul className={css.importSkips}>
259
+ {importSkips.slice(0, IMPORT_SKIP_LIMIT).map(block => (
260
+ <li key={block.name} className={css.importSkipRow}>
261
+ <span className={css.mono}>{block.name}</span>
262
+ <span className={css.cellMuted}>{tt(IMPORT_REASON_KEY[block.reason])}</span>
263
+ </li>
264
+ ))}
265
+ {importSkips.length > IMPORT_SKIP_LIMIT && (
266
+ <li className={css.importSkipRow}>
267
+ <span className={css.cellMuted}>{tt('import.more', { count: importSkips.length - IMPORT_SKIP_LIMIT })}</span>
268
+ </li>
269
+ )}
270
+ </ul>
271
+ )}
237
272
  {error !== null && <div className={css.banner} data-kind="error">{tt('common.error', { error })}</div>}
238
273
  {hosts === null && error === null && <div className={css.loading}>{tt('common.loading')}</div>}
239
274
  {hosts !== null && hosts.length === 0 && <div className={css.empty}>{tt('hosts.empty')}</div>}
@@ -5,7 +5,7 @@
5
5
  * activation. The hosts tab's connect action switches here to the terminal
6
6
  * tab with the chosen alias preselected.
7
7
  */
8
- import { useState } from 'react'
8
+ import { useCallback, useState, useSyncExternalStore } from 'react'
9
9
  import type { SshApi } from '../api.ts'
10
10
  import type { PanelController } from './controller.ts'
11
11
  import { tt, type TerminalFontSource } from './helpers.ts'
@@ -46,6 +46,10 @@ interface ConnectRequest {
46
46
 
47
47
  /** The tabbed SSH panel. */
48
48
  export function SshPanel({ controller, api, terminalFont }: SshPanelProps) {
49
+ const panelOpen = useSyncExternalStore(
50
+ useCallback(listener => controller.subscribe(listener), [controller]),
51
+ useCallback(() => controller.getSnapshot().panelOpen, [controller]),
52
+ )
49
53
  const [activeTab, setActiveTab] = useState<SshTab>('hosts')
50
54
  const [connectRequest, setConnectRequest] = useState<ConnectRequest | null>(null)
51
55
 
@@ -81,7 +85,7 @@ export function SshPanel({ controller, api, terminalFont }: SshPanelProps) {
81
85
  {activeTab === 'hosts' && <HostsTab api={api} onConnect={handleConnect} />}
82
86
  {activeTab === 'terminal' && <TerminalTab api={api} presetAlias={connectRequest?.alias} requestId={connectRequest?.nonce} terminalFont={terminalFont} />}
83
87
  {activeTab === 'transfer' && <TransferTab api={api} />}
84
- {activeTab === 'tunnels' && <TunnelsTab api={api} />}
88
+ {activeTab === 'tunnels' && <TunnelsTab api={api} active={panelOpen} />}
85
89
  {activeTab === 'cluster' && <ClusterTab api={api} />}
86
90
  </div>
87
91
  </div>
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Tunnels tab: the live local port-forward list (auto-refresh every 5s while
3
- * mounted) with per-row stop, a stop-all action scoped to the selected alias,
3
+ * visible) with per-row stop, a stop-all action scoped to the selected alias,
4
4
  * and a new-tunnel form.
5
5
  */
6
6
  import { useEffect, useRef, useState } from 'react'
@@ -9,7 +9,7 @@ import type { SshHostSummary, TunnelInfo } from '../../protocol.ts'
9
9
  import { errorMessage, tt } from './helpers.ts'
10
10
  import css from './panel.module.css'
11
11
 
12
- /** Live-tunnel polling interval while the tab is mounted (ms). */
12
+ /** Live-tunnel polling interval while the tab and page are visible (ms). */
13
13
  export const TUNNEL_POLL_MS = 5000
14
14
 
15
15
  /**
@@ -37,10 +37,12 @@ export function diffTunnels(prev: TunnelInfo[] | null, next: TunnelInfo[]): Tunn
37
37
  /** Tunnels tab props. */
38
38
  export interface TunnelsTabProps {
39
39
  api: SshApi
40
+ /** Pause automatic reads while the owning panel is closed, preserving form state. */
41
+ active?: boolean
40
42
  }
41
43
 
42
44
  /** The tunnels tab. */
43
- export function TunnelsTab({ api }: TunnelsTabProps) {
45
+ export function TunnelsTab({ api, active = true }: TunnelsTabProps) {
44
46
  const [hosts, setHosts] = useState<SshHostSummary[]>([])
45
47
  const [tunnels, setTunnels] = useState<TunnelInfo[] | null>(null)
46
48
  const [error, setError] = useState<string | null>(null)
@@ -65,28 +67,66 @@ export function TunnelsTab({ api }: TunnelsTabProps) {
65
67
  return () => { disposed = true }
66
68
  }, [api])
67
69
 
68
- // Live list with a TUNNEL_POLL_MS heartbeat while mounted. Every load
70
+ // Live list with a TUNNEL_POLL_MS heartbeat while visible. Every load
69
71
  // carries a sequence number so stale responses never overwrite newer
70
72
  // state, and the list is diff-set so an unchanged poll tick keeps the
71
73
  // previous state reference (no wasted re-render).
72
74
  const seqRef = useRef(0)
75
+ const automaticRead = useRef<{ running: boolean; resume?: () => void }>({ running: false })
73
76
  useEffect(() => {
77
+ if (!active) return
78
+ let disposed = false
79
+ const read = automaticRead.current
80
+ let timer: ReturnType<typeof setInterval> | undefined
74
81
  const load = async (): Promise<void> => {
82
+ if (disposed || read.running || document.visibilityState === 'hidden') return
83
+ read.running = true
75
84
  const seq = ++seqRef.current
76
85
  try {
77
86
  const list = await api.listTunnels()
78
- if (seq !== seqRef.current) return
87
+ if (disposed || seq !== seqRef.current) return
79
88
  setTunnels(prev => diffTunnels(prev, list) ?? prev)
80
89
  setError(null)
81
90
  } catch (cause) {
82
- if (seq !== seqRef.current) return
91
+ if (disposed || seq !== seqRef.current) return
83
92
  setError(errorMessage(cause))
93
+ } finally {
94
+ read.running = false
95
+ const resume = read.resume
96
+ read.resume = undefined
97
+ resume?.()
84
98
  }
85
99
  }
86
- void load()
87
- const timer = setInterval(() => { void load() }, TUNNEL_POLL_MS)
88
- return () => { clearInterval(timer) }
89
- }, [api])
100
+ const resume = (): void => {
101
+ if (disposed || document.visibilityState === 'hidden') return
102
+ // Rapid close/reopen must not start more reads while the old effect's
103
+ // request is pending. Keep only the latest visible refresh.
104
+ if (read.running) read.resume = resume
105
+ else void load()
106
+ }
107
+ const stop = (): void => {
108
+ if (timer !== undefined) clearInterval(timer)
109
+ timer = undefined
110
+ }
111
+ const onVisibility = (): void => {
112
+ if (document.visibilityState === 'hidden') {
113
+ stop()
114
+ if (read.resume === resume) read.resume = undefined
115
+ } else if (timer === undefined) {
116
+ resume()
117
+ timer = setInterval(() => { void load() }, TUNNEL_POLL_MS)
118
+ }
119
+ }
120
+ document.addEventListener('visibilitychange', onVisibility)
121
+ onVisibility()
122
+ return () => {
123
+ disposed = true
124
+ seqRef.current += 1
125
+ if (read.resume === resume) read.resume = undefined
126
+ stop()
127
+ document.removeEventListener('visibilitychange', onVisibility)
128
+ }
129
+ }, [api, active])
90
130
 
91
131
  const refresh = async (): Promise<void> => {
92
132
  const seq = ++seqRef.current
@@ -433,6 +433,12 @@ html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [class*='centerCol']
433
433
  border-color: var(--dsw-alias-state-success-primary);
434
434
  }
435
435
 
436
+ .badge[data-kind='proxy'] {
437
+ margin-left: 6px;
438
+ color: var(--dsw-alias-state-business-primary);
439
+ border-color: var(--dsw-alias-state-business-primary);
440
+ }
441
+
436
442
  .badge[data-status='ok'] {
437
443
  color: var(--dsw-alias-state-success-primary);
438
444
  border-color: var(--dsw-alias-state-success-primary);
@@ -562,6 +568,27 @@ html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [class*='centerCol']
562
568
  overflow-wrap: anywhere;
563
569
  }
564
570
 
571
+ /* Import skip detail: what an import left out and why (#1448). */
572
+ .importSkips {
573
+ list-style: none;
574
+ margin: 6px 0 0;
575
+ padding: 8px 12px;
576
+ display: flex;
577
+ flex-direction: column;
578
+ gap: 4px;
579
+ border: 1px solid var(--dsw-alias-border-l1);
580
+ border-radius: 8px;
581
+ background: var(--dsw-alias-bg-layer-2);
582
+ }
583
+
584
+ .importSkipRow {
585
+ display: flex;
586
+ justify-content: space-between;
587
+ align-items: baseline;
588
+ gap: 12px;
589
+ font-size: 12px;
590
+ }
591
+
565
592
  .banner[data-kind='ok'] {
566
593
  color: var(--dsw-alias-state-success-primary);
567
594
  border-color: var(--dsw-alias-state-success-primary);
@@ -20,10 +20,11 @@
20
20
  * (shared/client/sidebar-entry-core.ts, synced copy).
21
21
  */
22
22
  import { createRoot, type Root } from 'react-dom/client'
23
+ import { subscribeBodyInvalidations } from './body-mutations.ts'
23
24
 
24
25
  /** Options for mountCenterPanel; dsh-ssh mount.tsx and dsh-task-board board-mount.tsx are the canonical consumers. */
25
26
  export interface CenterPanelMountOptions {
26
- /** Render the panel React tree into a root (initial mount, remount, locale refresh). */
27
+ /** Render the panel tree (first open, remount while open, locale refresh). */
27
28
  render: (root: Root) => void
28
29
  /** dataset key of the injected container's view attribute, e.g. `dshSshView` for `data-dsh-ssh-view`. */
29
30
  viewDatasetKey: string
@@ -79,31 +80,38 @@ export function mountCenterPanel(options: CenterPanelMountOptions): () => void {
79
80
  } catch { /* locale service absent: the panel follows its next natural re-render */ }
80
81
 
81
82
  const ensure = (): void => {
82
- if (container !== undefined) {
83
- if (container.isConnected) return
83
+ if (container !== undefined && !container.isConnected) {
84
84
  // The conversation pane was replaced; drop the stale tree and remount.
85
85
  root?.unmount()
86
86
  root = undefined
87
87
  container.remove()
88
88
  container = undefined
89
89
  }
90
- const column = conversationColumn()
91
- if (column === undefined) return
92
- container = document.createElement('div')
93
- container.dataset[options.viewDatasetKey] = ''
94
- container.dataset.dshPlugin = options.pluginName
95
- container.className = options.viewClassName
96
- column.appendChild(container)
90
+ if (container === undefined) {
91
+ const column = conversationColumn()
92
+ if (column === undefined) return
93
+ container = document.createElement('div')
94
+ container.dataset[options.viewDatasetKey] = ''
95
+ container.dataset.dshPlugin = options.pluginName
96
+ container.className = options.viewClassName
97
+ column.appendChild(container)
98
+ }
99
+ // Keep a visited tree mounted so drafts and terminal sessions survive
100
+ // close/reopen; an unused panel needs neither a React root nor effects.
101
+ if (root !== undefined || !options.isOpen()) return
97
102
  root = createRoot(container)
98
103
  options.render(root)
99
104
  }
100
105
 
101
106
  // The frame mounts after boot settlement; watch for the column's arrival.
102
- const waitObserver = new MutationObserver(() => { ensure() })
103
- waitObserver.observe(document.body, { childList: true, subtree: true })
107
+ // The observation is the page-wide hub (shared/client/body-mutations.ts):
108
+ // every family panel used to hold its own document.body subtree observer, so
109
+ // the per-mutation cost grew with the number of installed plugins.
110
+ const unsubscribeBody = subscribeBodyInvalidations(() => { ensure() })
104
111
 
105
112
  const applyActive = (): void => {
106
113
  if (options.isOpen()) {
114
+ ensure()
107
115
  // Single-occupant center column: opening this panel must evict the
108
116
  // sibling panel, both its html attribute and its controller state,
109
117
  // otherwise the two panels' visibility rules fight and the second
@@ -135,7 +143,7 @@ export function mountCenterPanel(options: CenterPanelMountOptions): () => void {
135
143
  return () => {
136
144
  document.removeEventListener('click', onClickSidebarRow, true)
137
145
  document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
138
- waitObserver.disconnect()
146
+ unsubscribeBody()
139
147
  unsubscribe()
140
148
  unsubscribeLocale?.()
141
149
  document.documentElement.removeAttribute(options.activeAttribute)
@@ -14,6 +14,7 @@
14
14
  * Packages receive this file as a generated copy via scripts/sync-shared.mjs;
15
15
  * edit the shared source and re-run the sync instead of editing a copy.
16
16
  */
17
+ import { subscribeBodyInvalidations } from './body-mutations.ts'
17
18
 
18
19
  /** Per-package configuration for one sidebar entry row. */
19
20
  export interface SidebarEntryOptions {
@@ -188,13 +189,15 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
188
189
 
189
190
  // Body-level watcher retained as the "whole rebuild" fallback: when the shell
190
191
  // tears down the whole sidebar pane, the root observer is gone with it and
191
- // only this body observation can notice the new pane mounting. It is no
192
- // longer disconnected after placement; the placed-and-still-mounted case
192
+ // only this body observation can notice the new pane mounting. It stays
193
+ // subscribed after placement; the placed-and-still-mounted case
193
194
  // short-circuits through the cheap document.body.contains(entry) check, so
194
- // unrelated app mutations (e.g. chat streaming) cost one contains check
195
- // instead of churning the full re-query.
196
- const waitObserver = new MutationObserver(() => { tryPlace() })
197
- waitObserver.observe(document.body, { childList: true, subtree: true })
195
+ // unrelated app mutations (e.g. chat streaming) cost one contains check per
196
+ // frame instead of churning the full re-query. The body observation itself is
197
+ // the page-wide hub (shared/client/body-mutations.ts): every family plugin
198
+ // used to hold its own body subtree observer, so the per-mutation cost grew
199
+ // with the number of installed plugins; the hub keeps exactly one.
200
+ const unsubscribeBody = subscribeBodyInvalidations(() => { tryPlace() })
198
201
 
199
202
  // Self-heal: if a React re-render displaces the row, re-insert it in the
200
203
  // same frame (microtask before paint -> no visible flicker).
@@ -225,7 +228,7 @@ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
225
228
  tryPlace()
226
229
 
227
230
  return () => {
228
- waitObserver.disconnect()
231
+ unsubscribeBody()
229
232
  rootObserver.disconnect()
230
233
  unsubscribeRefresh?.()
231
234
  unsubscribeActive?.()
@@ -4,9 +4,11 @@
4
4
  */
5
5
 
6
6
  import { existsSync, readFileSync } from 'node:fs'
7
+ import type { Duplex } from 'node:stream'
7
8
  import { Client, type ConnectConfig } from 'ssh2'
8
9
  import type { ExecResult, SshHostEntry } from '../protocol.ts'
9
10
  import { expandHome, normalizeAgentPath, type HostStore } from '../store.ts'
11
+ import { startProxyCommand, type ProxyTarget } from './proxy-command.ts'
10
12
 
11
13
  /** Default engine knobs. */
12
14
  export interface EngineOptions {
@@ -121,6 +123,13 @@ export function connectClient(config: ConnectConfig, onKeyboardInteractive?: Key
121
123
  const fail = (error: unknown): void => {
122
124
  if (settled) return
123
125
  settled = true
126
+ // A caller-supplied transport (a jump forwardOut stream or a
127
+ // ProxyCommand) must not outlive the client that failed to use it, or
128
+ // its child process leaks.
129
+ const sock = config.sock
130
+ if (sock !== undefined && typeof (sock as { destroy?: unknown }).destroy === 'function') {
131
+ try { (sock as { destroy: () => void }).destroy() } catch { /* already closed */ }
132
+ }
124
133
  try { client.destroy() } catch { /* already closed */ }
125
134
  reject(error instanceof Error ? error : new Error(String(error)))
126
135
  }
@@ -169,10 +178,86 @@ export function appendOutput(target: { text: string; truncated: boolean }, chunk
169
178
  target.text += chunk.toString('utf8')
170
179
  }
171
180
 
181
+ /** The ProxyCommand token source for one entry. */
182
+ function proxyTargetOf(entry: SshHostEntry): ProxyTarget {
183
+ return { host: entry.host, port: entry.port, user: entry.user, alias: entry.alias }
184
+ }
185
+
186
+ /**
187
+ * Parse an OpenSSH jump hop written as an address: `[user@]host[:port]`
188
+ * (an IPv6 literal must be bracketed). Returns undefined when the value is not
189
+ * an address at all.
190
+ */
191
+ export function parseJumpSpec(spec: string): { host: string; port?: number; user?: string } | undefined {
192
+ const trimmed = spec.trim()
193
+ if (trimmed === '') return undefined
194
+ const at = trimmed.lastIndexOf('@')
195
+ const user = at >= 0 ? trimmed.slice(0, at) : undefined
196
+ const rest = at >= 0 ? trimmed.slice(at + 1) : trimmed
197
+ if (rest === '') return undefined
198
+ let host = rest
199
+ let portText: string | undefined
200
+ if (rest.startsWith('[')) {
201
+ const end = rest.indexOf(']')
202
+ if (end < 0) return undefined
203
+ host = rest.slice(1, end)
204
+ const tail = rest.slice(end + 1)
205
+ if (tail.startsWith(':')) portText = tail.slice(1)
206
+ else if (tail !== '') return undefined
207
+ } else {
208
+ const colon = rest.indexOf(':')
209
+ if (colon >= 0) {
210
+ host = rest.slice(0, colon)
211
+ portText = rest.slice(colon + 1)
212
+ }
213
+ }
214
+ if (host === '') return undefined
215
+ let port: number | undefined
216
+ if (portText !== undefined) {
217
+ port = Number(portText)
218
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return undefined
219
+ }
220
+ return {
221
+ host,
222
+ ...(port !== undefined ? { port } : {}),
223
+ ...(user !== undefined && user !== '' ? { user } : {}),
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Resolve one ProxyJump spec: an alias configured in this plugin wins, and any
229
+ * other value is read as an OpenSSH address whose credentials come from the
230
+ * target entry (an ad-hoc hop has no stored auth of its own).
231
+ */
232
+ export function resolveHop(engine: PoolEngine, entry: SshHostEntry, spec: string): SshHostEntry {
233
+ const configured = engine.store.find(spec)
234
+ if (configured !== undefined) return configured
235
+ const parsed = parseJumpSpec(spec)
236
+ if (parsed === undefined) {
237
+ throw new Error('proxyJump \'' + spec + '\' is neither a configured alias nor a [user@]host[:port] address')
238
+ }
239
+ const now = Date.now()
240
+ return {
241
+ alias: spec,
242
+ host: parsed.host,
243
+ port: parsed.port ?? 22,
244
+ user: parsed.user ?? process.env.USER ?? entry.user,
245
+ auth: entry.auth,
246
+ proxyJump: [],
247
+ tags: [],
248
+ createdAt: now,
249
+ updatedAt: now,
250
+ }
251
+ }
252
+
172
253
  /**
173
254
  * Build one full jump chain for an entry: hop clients connected through in
174
255
  * order, each forwarding a stream to the next destination, ending with the
175
256
  * target client. Shared by the pool and standalone shell sessions.
257
+ *
258
+ * A ProxyCommand is the transport that reaches its own host, so the entry's
259
+ * own command seeds the chain when there is no jump chain, and the first hop's
260
+ * command is used when the hop carries one.
176
261
  */
177
262
  export async function connectChain(
178
263
  engine: PoolEngine,
@@ -180,24 +265,45 @@ export async function connectChain(
180
265
  onKeyboardInteractive?: KeyboardInteractiveHandler,
181
266
  ): Promise<{ client: Client; hops: Client[] }> {
182
267
  const hops: Client[] = []
183
- let sock: ConnectConfig['sock']
184
268
  const chain = entry.proxyJump
269
+ // The store rejects both transports on one entry; a hand-edited store file
270
+ // can still carry both, and silently picking one would hide the mistake.
271
+ if (chain.length > 0 && entry.proxyCommand !== undefined) {
272
+ throw new Error('entry \'' + entry.alias + '\' declares both proxyCommand and proxyJump — pick one transport per host')
273
+ }
274
+ /** Transports this call started; destroyed on every failure path. */
275
+ const transports: Duplex[] = []
276
+ const startTransport = (owner: SshHostEntry): ConnectConfig['sock'] => {
277
+ const { stream } = startProxyCommand(owner.proxyCommand as string, proxyTargetOf(owner))
278
+ transports.push(stream)
279
+ return stream
280
+ }
281
+ let sock: ConnectConfig['sock'] = entry.proxyCommand !== undefined ? startTransport(entry) : undefined
185
282
  try {
186
283
  for (let index = 0; index < chain.length; index += 1) {
187
- const hopAlias = chain[index]
188
- const hop = engine.store.find(hopAlias)
189
- if (hop === undefined) {
190
- throw new Error('proxyJump alias \'' + hopAlias + '\' not found — create it first')
284
+ const spec = chain[index]
285
+ const hop = resolveHop(engine, entry, spec)
286
+ if (index > 0 && hop.proxyCommand !== undefined) {
287
+ throw new Error('proxyJump hop \'' + spec + '\' declares a proxyCommand, which is only supported on the first hop')
288
+ }
289
+ const hopSock = index === 0 && hop.proxyCommand !== undefined ? startTransport(hop) : sock
290
+ let hopClient: Client
291
+ try {
292
+ hopClient = await connectClient(buildConnectConfig(hop, hopSock, engine.opts), onKeyboardInteractive)
293
+ } catch (error) {
294
+ // Name the hop: an address hop (or a typo'd alias) must stay
295
+ // diagnosable from the error alone.
296
+ throw new Error('proxyJump hop \'' + spec + '\' (' + hop.user + '@' + hop.host + ':' + hop.port + '): '
297
+ + (error instanceof Error ? error.message : String(error)))
191
298
  }
192
- const hopClient = await connectClient(buildConnectConfig(hop, sock, engine.opts), onKeyboardInteractive)
193
299
  hops.push(hopClient)
194
- const next = index + 1 < chain.length ? engine.store.find(chain[index + 1]) : undefined
300
+ const next = index + 1 < chain.length ? resolveHop(engine, entry, chain[index + 1]) : undefined
195
301
  const nextHost = next !== undefined ? next.host : entry.host
196
302
  const nextPort = next !== undefined ? next.port : entry.port
197
303
  sock = await new Promise<ConnectConfig['sock']>((resolve, reject) => {
198
304
  hopClient.forwardOut('127.0.0.1', 0, nextHost, nextPort, (error, stream) => {
199
305
  if (error !== undefined) {
200
- reject(error)
306
+ reject(new Error('proxyJump hop \'' + spec + '\' could not forward to ' + nextHost + ':' + nextPort + ': ' + error.message))
201
307
  } else {
202
308
  resolve(stream)
203
309
  }
@@ -206,9 +312,12 @@ export async function connectChain(
206
312
  }
207
313
  } catch (error) {
208
314
  // A missing alias, a failed hop connect, or a failed forwardOut must all
209
- // close the hops already connected, so a failed ProxyJump never leaks a
210
- // middle-hop connection.
315
+ // close the hops already connected — and the proxy commands behind them —
316
+ // so a failed ProxyJump never leaks either.
211
317
  for (const client of hops) client.end()
318
+ for (const stream of transports) {
319
+ try { stream.destroy() } catch { /* already closed */ }
320
+ }
212
321
  throw error
213
322
  }
214
323
  let target: Client | undefined
@@ -217,6 +326,9 @@ export async function connectChain(
217
326
  return { client: target, hops }
218
327
  } catch (error) {
219
328
  for (const client of hops) client.end()
329
+ for (const stream of transports) {
330
+ try { stream.destroy() } catch { /* already closed */ }
331
+ }
220
332
  // connectClient already destroys the failed target on its own failure
221
333
  // path; destroy defensively only when a reference leaked through, and
222
334
  // guard it so a second destroy is never an issue.