@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.
- package/README.i18n.yaml +2 -2
- package/README.md +10 -4
- package/README.zh.md +10 -4
- package/lib/client.js +252 -38
- package/lib/client.js.map +1 -1
- package/lib/index.js +504 -52
- package/lib/types/client/body-mutations.d.ts +45 -0
- package/lib/types/client/locales.d.ts +9 -1
- package/lib/types/client/panel/TunnelsTab.d.ts +4 -2
- package/lib/types/client/panel-mount-core.d.ts +1 -1
- package/lib/types/client/sidebar-entry-core.d.ts +0 -15
- package/lib/types/engine/connection-pool.d.ts +20 -0
- package/lib/types/engine/proxy-command.d.ts +36 -0
- package/lib/types/index.d.ts +1 -1
- package/lib/types/protocol.d.ts +24 -2
- package/lib/types/ssh-config.d.ts +34 -0
- package/lib/types/store.d.ts +11 -3
- package/lib/types/tools.d.ts +24 -0
- package/package.json +1 -1
- package/src/client/body-mutations.ts +133 -0
- package/src/client/locales.ts +18 -2
- package/src/client/panel/HostFormDialog.tsx +15 -0
- package/src/client/panel/HostsTab.tsx +37 -2
- package/src/client/panel/SshPanel.tsx +6 -2
- package/src/client/panel/TunnelsTab.tsx +50 -10
- package/src/client/panel/panel.module.css +27 -0
- package/src/client/panel-mount-core.ts +21 -13
- package/src/client/sidebar-entry-core.ts +10 -7
- package/src/engine/connection-pool.ts +122 -10
- package/src/engine/proxy-command.ts +248 -0
- package/src/index.ts +1 -1
- package/src/protocol.ts +26 -2
- package/src/routes.ts +1 -1
- package/src/ssh-config.ts +153 -0
- package/src/store.ts +64 -40
- package/src/tools.ts +35 -5
|
@@ -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}>
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
|
192
|
-
//
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
|
188
|
-
const hop = engine
|
|
189
|
-
if (hop
|
|
190
|
-
throw new Error('proxyJump
|
|
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
|
|
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
|
|
210
|
-
//
|
|
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.
|