@tiphareth/dsh-hardssh 0.1.2
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.md +50 -0
- package/cordis.patch.yml +26 -0
- package/lib/client.js +17780 -0
- package/lib/environment-BL1jddfB.js +449 -0
- package/lib/fs.js +478 -0
- package/lib/index.js +6758 -0
- package/lib/subprocess.js +600 -0
- package/lib/switch-fs-CAJpFY9C.js +193 -0
- package/lib/switch-fs-RrZtG2gv.js +210 -0
- package/lib/types/backend.d.ts +108 -0
- package/lib/types/base/capability.d.ts +107 -0
- package/lib/types/base/index.d.ts +18 -0
- package/lib/types/base/ledger-router.d.ts +48 -0
- package/lib/types/base/ledger.d.ts +82 -0
- package/lib/types/base/model.d.ts +108 -0
- package/lib/types/base/namespace.d.ts +57 -0
- package/lib/types/base/plugin.d.ts +102 -0
- package/lib/types/base/registry.d.ts +60 -0
- package/lib/types/base/router.d.ts +40 -0
- package/lib/types/client/api.d.ts +85 -0
- package/lib/types/client/directory-flow.d.ts +82 -0
- package/lib/types/client/icons.d.ts +19 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/locales.d.ts +104 -0
- package/lib/types/client/manager-button.d.ts +32 -0
- package/lib/types/client/migrate.d.ts +20 -0
- package/lib/types/client/ssh/api.d.ts +89 -0
- package/lib/types/client/ssh/apply.d.ts +23 -0
- package/lib/types/client/ssh/locales.d.ts +156 -0
- package/lib/types/client/ssh/mount.d.ts +13 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
- package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
- package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/controller.d.ts +23 -0
- package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
- package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
- package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
- package/lib/types/client/state.d.ts +32 -0
- package/lib/types/client/text.d.ts +11 -0
- package/lib/types/client/workspace-badges.d.ts +38 -0
- package/lib/types/client/workspace-gate.d.ts +15 -0
- package/lib/types/client-http.d.ts +15 -0
- package/lib/types/core.d.ts +43 -0
- package/lib/types/fs.d.ts +36 -0
- package/lib/types/host-http.d.ts +25 -0
- package/lib/types/index.d.ts +53 -0
- package/lib/types/ledger.d.ts +132 -0
- package/lib/types/protocol.d.ts +101 -0
- package/lib/types/providers/index.d.ts +19 -0
- package/lib/types/providers/local/provider.d.ts +58 -0
- package/lib/types/providers/ssh/provider.d.ts +112 -0
- package/lib/types/remote/environment.d.ts +29 -0
- package/lib/types/remote/output.d.ts +38 -0
- package/lib/types/remote/remote-fs.d.ts +69 -0
- package/lib/types/remote/remote-process.d.ts +41 -0
- package/lib/types/remote/remote-subprocess.d.ts +31 -0
- package/lib/types/remote/remote-terminal.d.ts +41 -0
- package/lib/types/remote-runner.d.ts +83 -0
- package/lib/types/remote-search.d.ts +50 -0
- package/lib/types/routes.d.ts +24 -0
- package/lib/types/runtime/workspace-core.d.ts +59 -0
- package/lib/types/seam-state.d.ts +69 -0
- package/lib/types/shell.d.ts +8 -0
- package/lib/types/ssh/connection/lease.d.ts +16 -0
- package/lib/types/ssh/connection/pool.d.ts +57 -0
- package/lib/types/ssh/engine.d.ts +434 -0
- package/lib/types/ssh/exec/output.d.ts +31 -0
- package/lib/types/ssh/known-hosts.d.ts +89 -0
- package/lib/types/ssh/plugin.d.ts +57 -0
- package/lib/types/ssh/protocol.d.ts +236 -0
- package/lib/types/ssh/routes.d.ts +44 -0
- package/lib/types/ssh/store.d.ts +107 -0
- package/lib/types/ssh/tools.d.ts +35 -0
- package/lib/types/ssh/transfer/progress.d.ts +14 -0
- package/lib/types/ssh/vault.d.ts +110 -0
- package/lib/types/subprocess.d.ts +32 -0
- package/lib/types/switch/switch-fs.d.ts +88 -0
- package/lib/types/switch/switch-subprocess.d.ts +34 -0
- package/lib/types/tools.d.ts +12 -0
- package/package.json +134 -0
- package/src/backend.ts +624 -0
- package/src/base/capability.ts +86 -0
- package/src/base/index.ts +18 -0
- package/src/base/ledger-router.ts +128 -0
- package/src/base/ledger.ts +299 -0
- package/src/base/model.ts +118 -0
- package/src/base/namespace.ts +102 -0
- package/src/base/plugin.ts +170 -0
- package/src/base/registry.ts +109 -0
- package/src/base/router.ts +43 -0
- package/src/client/api.ts +196 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/directory-flow.tsx +482 -0
- package/src/client/icons.tsx +50 -0
- package/src/client/index.ts +210 -0
- package/src/client/locales.ts +105 -0
- package/src/client/manager-button.tsx +269 -0
- package/src/client/migrate.ts +109 -0
- package/src/client/ssh/api.ts +411 -0
- package/src/client/ssh/apply.ts +50 -0
- package/src/client/ssh/locales.ts +322 -0
- package/src/client/ssh/mount.tsx +83 -0
- package/src/client/ssh/panel/ClusterTab.tsx +123 -0
- package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
- package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
- package/src/client/ssh/panel/HostsTab.tsx +236 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
- package/src/client/ssh/panel/SshPanel.tsx +77 -0
- package/src/client/ssh/panel/TerminalTab.tsx +176 -0
- package/src/client/ssh/panel/TransferTab.tsx +232 -0
- package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
- package/src/client/ssh/panel/controller.ts +48 -0
- package/src/client/ssh/panel/helpers.ts +26 -0
- package/src/client/ssh/panel/panel.module.css +1006 -0
- package/src/client/ssh/panel/xterm.css.ts +2 -0
- package/src/client/ssh/sidebar-entry.ts +123 -0
- package/src/client/state.ts +99 -0
- package/src/client/text.ts +22 -0
- package/src/client/workspace-badges.ts +96 -0
- package/src/client/workspace-gate.ts +225 -0
- package/src/client/workspace.module.css +283 -0
- package/src/client-http.ts +45 -0
- package/src/core.ts +47 -0
- package/src/fs.ts +85 -0
- package/src/host-http.ts +74 -0
- package/src/index.ts +244 -0
- package/src/ledger.ts +416 -0
- package/src/protocol.ts +114 -0
- package/src/providers/index.ts +34 -0
- package/src/providers/local/provider.ts +179 -0
- package/src/providers/ssh/provider.ts +274 -0
- package/src/remote/environment.ts +123 -0
- package/src/remote/output.ts +142 -0
- package/src/remote/remote-fs.ts +532 -0
- package/src/remote/remote-process.ts +203 -0
- package/src/remote/remote-subprocess.ts +159 -0
- package/src/remote/remote-terminal.ts +141 -0
- package/src/remote-runner.ts +201 -0
- package/src/remote-search.ts +163 -0
- package/src/routes.ts +395 -0
- package/src/runtime/workspace-core.ts +154 -0
- package/src/seam-state.ts +185 -0
- package/src/shell.ts +10 -0
- package/src/ssh/connection/lease.ts +17 -0
- package/src/ssh/connection/pool.ts +275 -0
- package/src/ssh/engine.ts +1761 -0
- package/src/ssh/exec/output.ts +70 -0
- package/src/ssh/known-hosts.ts +214 -0
- package/src/ssh/plugin.ts +184 -0
- package/src/ssh/protocol.ts +227 -0
- package/src/ssh/routes.ts +892 -0
- package/src/ssh/store.ts +544 -0
- package/src/ssh/tools.ts +402 -0
- package/src/ssh/transfer/progress.ts +75 -0
- package/src/ssh/vault.ts +477 -0
- package/src/subprocess.ts +71 -0
- package/src/switch/switch-fs.ts +253 -0
- package/src/switch/switch-subprocess.ts +59 -0
- package/src/tools.ts +221 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host form dialog: create/edit one stored host entry. A fixed backdrop with
|
|
3
|
+
* a centered card; Escape and backdrop clicks close it. Secrets (key path,
|
|
4
|
+
* passphrase, password) are never pre-filled when editing (the API only
|
|
5
|
+
* exposes the secret-free summary).
|
|
6
|
+
*/
|
|
7
|
+
import { useEffect, useState } from 'react'
|
|
8
|
+
import type { SshApi } from '../api.ts'
|
|
9
|
+
import type { HostPayload, SshAuthKind, SshHostSummary } from '../../../ssh/protocol.ts'
|
|
10
|
+
import { errorMessage, tt } from './helpers.ts'
|
|
11
|
+
import css from './panel.module.css'
|
|
12
|
+
|
|
13
|
+
/** Host form dialog props. */
|
|
14
|
+
export interface HostFormDialogProps {
|
|
15
|
+
api: SshApi
|
|
16
|
+
/** The host being edited; null/undefined means create. */
|
|
17
|
+
editing?: SshHostSummary | null
|
|
18
|
+
onClose: () => void
|
|
19
|
+
onSaved: (host: SshHostSummary) => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The staged form values (all strings; parsed on save). */
|
|
23
|
+
interface FormState {
|
|
24
|
+
alias: string
|
|
25
|
+
host: string
|
|
26
|
+
port: string
|
|
27
|
+
user: string
|
|
28
|
+
authKind: SshAuthKind
|
|
29
|
+
keyPath: string
|
|
30
|
+
passphrase: string
|
|
31
|
+
password: string
|
|
32
|
+
proxyJump: string
|
|
33
|
+
description: string
|
|
34
|
+
environment: string
|
|
35
|
+
tags: string
|
|
36
|
+
location: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Split a comma-separated input into a trimmed, non-empty string list. */
|
|
40
|
+
function splitList(text: string): string[] {
|
|
41
|
+
return text.split(',').map(part => part.trim()).filter(part => part !== '')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Initial form state: the summary's public fields plus empty secrets. */
|
|
45
|
+
function blankOf(editing: SshHostSummary | null | undefined): FormState {
|
|
46
|
+
return {
|
|
47
|
+
alias: editing?.alias ?? '',
|
|
48
|
+
host: editing?.host ?? '',
|
|
49
|
+
port: String(editing?.port ?? 22),
|
|
50
|
+
user: editing?.user ?? '',
|
|
51
|
+
authKind: editing?.auth ?? 'key',
|
|
52
|
+
keyPath: '',
|
|
53
|
+
passphrase: '',
|
|
54
|
+
password: '',
|
|
55
|
+
proxyJump: (editing?.proxyJump ?? []).join(', '),
|
|
56
|
+
description: editing?.description ?? '',
|
|
57
|
+
environment: editing?.environment ?? '',
|
|
58
|
+
tags: (editing?.tags ?? []).join(', '),
|
|
59
|
+
location: editing?.location ?? '',
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The create/edit host modal. */
|
|
64
|
+
export function HostFormDialog({ api, editing, onClose, onSaved }: HostFormDialogProps) {
|
|
65
|
+
const [form, setForm] = useState<FormState>(() => blankOf(editing))
|
|
66
|
+
const [saving, setSaving] = useState(false)
|
|
67
|
+
const [error, setError] = useState<string | null>(null)
|
|
68
|
+
|
|
69
|
+
// Escape closes the dialog.
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const onKey = (event: KeyboardEvent): void => {
|
|
72
|
+
if (event.key === 'Escape') onClose()
|
|
73
|
+
}
|
|
74
|
+
document.addEventListener('keydown', onKey)
|
|
75
|
+
return () => { document.removeEventListener('keydown', onKey) }
|
|
76
|
+
}, [onClose])
|
|
77
|
+
|
|
78
|
+
const set = <K extends keyof FormState>(key: K, value: FormState[K]): void => {
|
|
79
|
+
setForm(prev => ({ ...prev, [key]: value }))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const save = async (): Promise<void> => {
|
|
83
|
+
const alias = form.alias.trim()
|
|
84
|
+
const host = form.host.trim()
|
|
85
|
+
const user = form.user.trim()
|
|
86
|
+
if (alias === '' || host === '' || user === '') {
|
|
87
|
+
setError(tt('form.required'))
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
const port = Number(form.port)
|
|
91
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
92
|
+
setError(tt('form.portInvalid'))
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
// Secrets are never echoed back by the API: when editing and the secret
|
|
96
|
+
// fields are left empty, the auth block is omitted so the stored
|
|
97
|
+
// authentication is preserved.
|
|
98
|
+
const secretEmpty = form.authKind === 'password'
|
|
99
|
+
? form.password === ''
|
|
100
|
+
: form.keyPath.trim() === ''
|
|
101
|
+
const auth: HostPayload['auth'] = editing != null && secretEmpty
|
|
102
|
+
? undefined
|
|
103
|
+
: form.authKind === 'password'
|
|
104
|
+
? { kind: 'password', password: form.password }
|
|
105
|
+
: { kind: 'key', keyPath: form.keyPath.trim(), passphrase: form.passphrase === '' ? undefined : form.passphrase }
|
|
106
|
+
const payload: HostPayload = {
|
|
107
|
+
host,
|
|
108
|
+
port,
|
|
109
|
+
user,
|
|
110
|
+
auth,
|
|
111
|
+
proxyJump: splitList(form.proxyJump),
|
|
112
|
+
description: form.description.trim() === '' ? undefined : form.description.trim(),
|
|
113
|
+
environment: form.environment.trim() === '' ? undefined : form.environment.trim(),
|
|
114
|
+
tags: splitList(form.tags),
|
|
115
|
+
location: form.location.trim() === '' ? undefined : form.location.trim(),
|
|
116
|
+
}
|
|
117
|
+
setSaving(true)
|
|
118
|
+
setError(null)
|
|
119
|
+
try {
|
|
120
|
+
const saved = editing != null
|
|
121
|
+
? await api.updateHost(editing.alias, payload)
|
|
122
|
+
: await api.createHost({ ...payload, alias })
|
|
123
|
+
onSaved(saved)
|
|
124
|
+
} catch (cause) {
|
|
125
|
+
setError(errorMessage(cause))
|
|
126
|
+
setSaving(false)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div className={css.modalBackdrop} onClick={onClose}>
|
|
132
|
+
<div className={css.modal} role="dialog" aria-modal="true" onClick={event => { event.stopPropagation() }}>
|
|
133
|
+
<div className={css.modalHeader}>
|
|
134
|
+
<h3 className={css.modalTitle}>
|
|
135
|
+
<span className={css.whaleMark} aria-hidden="true" />
|
|
136
|
+
{editing != null ? tt('form.title.edit', { alias: editing.alias }) : tt('form.title.create')}
|
|
137
|
+
</h3>
|
|
138
|
+
</div>
|
|
139
|
+
<div className={css.modalBody}>
|
|
140
|
+
<div className={css.formRow}>
|
|
141
|
+
<label className={css.field}>
|
|
142
|
+
<span className={css.fieldLabel}>{tt('form.alias')}</span>
|
|
143
|
+
<input className={css.input} value={form.alias} disabled={editing != null} onChange={event => { set('alias', event.target.value) }} />
|
|
144
|
+
<span className={css.hint}>{tt('form.aliasHint')}</span>
|
|
145
|
+
</label>
|
|
146
|
+
<label className={css.field}>
|
|
147
|
+
<span className={css.fieldLabel}>{tt('form.host')}</span>
|
|
148
|
+
<input className={css.input} value={form.host} onChange={event => { set('host', event.target.value) }} />
|
|
149
|
+
</label>
|
|
150
|
+
</div>
|
|
151
|
+
<div className={css.formRow}>
|
|
152
|
+
<label className={css.field}>
|
|
153
|
+
<span className={css.fieldLabel}>{tt('form.port')}</span>
|
|
154
|
+
<input className={css.input} type="number" min={1} max={65535} value={form.port} onChange={event => { set('port', event.target.value) }} />
|
|
155
|
+
</label>
|
|
156
|
+
<label className={css.field}>
|
|
157
|
+
<span className={css.fieldLabel}>{tt('form.user')}</span>
|
|
158
|
+
<input className={css.input} value={form.user} onChange={event => { set('user', event.target.value) }} />
|
|
159
|
+
</label>
|
|
160
|
+
</div>
|
|
161
|
+
<div className={css.field}>
|
|
162
|
+
<span className={css.fieldLabel}>{tt('form.auth')}</span>
|
|
163
|
+
<div className={css.radioRow}>
|
|
164
|
+
<label className={css.radioLabel}>
|
|
165
|
+
<input type="radio" name="dsh-ssh-auth" checked={form.authKind === 'key'} onChange={() => { set('authKind', 'key') }} />
|
|
166
|
+
{tt('form.auth.key')}
|
|
167
|
+
</label>
|
|
168
|
+
<label className={css.radioLabel}>
|
|
169
|
+
<input type="radio" name="dsh-ssh-auth" checked={form.authKind === 'password'} onChange={() => { set('authKind', 'password') }} />
|
|
170
|
+
{tt('form.auth.password')}
|
|
171
|
+
</label>
|
|
172
|
+
</div>
|
|
173
|
+
{editing != null && <span className={css.hint}>{tt('form.authKeepHint')}</span>}
|
|
174
|
+
</div>
|
|
175
|
+
{form.authKind === 'key' ? (
|
|
176
|
+
<div className={css.formRow}>
|
|
177
|
+
<label className={css.field}>
|
|
178
|
+
<span className={css.fieldLabel}>{tt('form.keyPath')}</span>
|
|
179
|
+
<input className={css.input} value={form.keyPath} onChange={event => { set('keyPath', event.target.value) }} />
|
|
180
|
+
<span className={css.hint}>{form.keyPath.trim() === '' ? tt('form.agentHint') : tt('form.keyPathHint')}</span>
|
|
181
|
+
</label>
|
|
182
|
+
<label className={css.field}>
|
|
183
|
+
<span className={css.fieldLabel}>{tt('form.passphrase')}</span>
|
|
184
|
+
<input className={css.input} type="password" value={form.passphrase} onChange={event => { set('passphrase', event.target.value) }} />
|
|
185
|
+
</label>
|
|
186
|
+
</div>
|
|
187
|
+
) : (
|
|
188
|
+
<label className={css.field}>
|
|
189
|
+
<span className={css.fieldLabel}>{tt('form.password')}</span>
|
|
190
|
+
<input className={css.input} type="password" value={form.password} onChange={event => { set('password', event.target.value) }} />
|
|
191
|
+
<span className={css.hint}>{tt('form.passwordHint')}</span>
|
|
192
|
+
</label>
|
|
193
|
+
)}
|
|
194
|
+
<label className={css.field}>
|
|
195
|
+
<span className={css.fieldLabel}>{tt('form.proxyJump')}</span>
|
|
196
|
+
<input className={css.input} value={form.proxyJump} onChange={event => { set('proxyJump', event.target.value) }} />
|
|
197
|
+
<span className={css.hint}>{tt('form.proxyJumpHint')}</span>
|
|
198
|
+
</label>
|
|
199
|
+
<div className={css.formRow}>
|
|
200
|
+
<label className={css.field}>
|
|
201
|
+
<span className={css.fieldLabel}>{tt('form.environment')}</span>
|
|
202
|
+
<input className={css.input} value={form.environment} onChange={event => { set('environment', event.target.value) }} />
|
|
203
|
+
<span className={css.hint}>{tt('form.environmentHint')}</span>
|
|
204
|
+
</label>
|
|
205
|
+
<label className={css.field}>
|
|
206
|
+
<span className={css.fieldLabel}>{tt('form.location')}</span>
|
|
207
|
+
<input className={css.input} value={form.location} onChange={event => { set('location', event.target.value) }} />
|
|
208
|
+
</label>
|
|
209
|
+
</div>
|
|
210
|
+
<label className={css.field}>
|
|
211
|
+
<span className={css.fieldLabel}>{tt('form.description')}</span>
|
|
212
|
+
<input className={css.input} value={form.description} onChange={event => { set('description', event.target.value) }} />
|
|
213
|
+
</label>
|
|
214
|
+
<label className={css.field}>
|
|
215
|
+
<span className={css.fieldLabel}>{tt('form.tags')}</span>
|
|
216
|
+
<input className={css.input} value={form.tags} onChange={event => { set('tags', event.target.value) }} />
|
|
217
|
+
<span className={css.hint}>{tt('form.tagsHint')}</span>
|
|
218
|
+
</label>
|
|
219
|
+
{error !== null && <p className={css.formError}>{tt('common.error', { error })}</p>}
|
|
220
|
+
</div>
|
|
221
|
+
<div className={css.modalFooter}>
|
|
222
|
+
<button type="button" className={css.modalGhost} disabled={saving} onClick={onClose}>{tt('form.cancel')}</button>
|
|
223
|
+
<button type="button" className={css.modalPrimary} disabled={saving} onClick={() => { void save() }}>{tt('form.save')}</button>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
)
|
|
228
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hosts tab: the host table with search (debounced through listHosts),
|
|
3
|
+
* add/edit/delete/test actions, ~/.ssh/config import, and a connect action
|
|
4
|
+
* that hands the alias to the terminal tab via onConnect.
|
|
5
|
+
*/
|
|
6
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
7
|
+
import type { SshApi } from '../api.ts'
|
|
8
|
+
import type { SshHostSummary, TestResult } from '../../../ssh/protocol.ts'
|
|
9
|
+
import { errorMessage, tt } from './helpers.ts'
|
|
10
|
+
import { HostFormDialog } from './HostFormDialog.tsx'
|
|
11
|
+
import { HostFingerprintDialog } from './HostFingerprintDialog.tsx'
|
|
12
|
+
import { SessionSecretDialog } from './SessionSecretDialog.tsx'
|
|
13
|
+
import { SshApiError } from '../api.ts'
|
|
14
|
+
import css from './panel.module.css'
|
|
15
|
+
|
|
16
|
+
/** Hosts tab props. */
|
|
17
|
+
export interface HostsTabProps {
|
|
18
|
+
api: SshApi
|
|
19
|
+
/** Connect the given alias in the terminal tab. */
|
|
20
|
+
onConnect: (alias: string) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The host-form dialog invocation. */
|
|
24
|
+
type DialogState = { mode: 'create' } | { mode: 'edit'; host: SshHostSummary }
|
|
25
|
+
|
|
26
|
+
/** A pending host-key confirmation. */
|
|
27
|
+
interface HostKeyConfirmState {
|
|
28
|
+
alias: string
|
|
29
|
+
fingerprintSha256: string
|
|
30
|
+
mismatch: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A pending session-secret prompt (VSCode Remote-SSH style). */
|
|
34
|
+
interface SessionSecretState {
|
|
35
|
+
alias: string
|
|
36
|
+
secret: 'password' | 'passphrase'
|
|
37
|
+
user?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The hosts table plus its toolbar and dialogs. */
|
|
41
|
+
export function HostsTab({ api, onConnect }: HostsTabProps) {
|
|
42
|
+
const [hosts, setHosts] = useState<SshHostSummary[] | null>(null)
|
|
43
|
+
const [error, setError] = useState<string | null>(null)
|
|
44
|
+
const [search, setSearch] = useState('')
|
|
45
|
+
const [testingAlias, setTestingAlias] = useState<string | null>(null)
|
|
46
|
+
const [testResults, setTestResults] = useState<Record<string, TestResult>>({})
|
|
47
|
+
const [importing, setImporting] = useState(false)
|
|
48
|
+
const [notice, setNotice] = useState<string | null>(null)
|
|
49
|
+
const [dialog, setDialog] = useState<DialogState | null>(null)
|
|
50
|
+
const [hostKeyConfirm, setHostKeyConfirm] = useState<HostKeyConfirmState | null>(null)
|
|
51
|
+
const [secretConfirm, setSecretConfirm] = useState<SessionSecretState | null>(null)
|
|
52
|
+
const seqRef = useRef(0)
|
|
53
|
+
|
|
54
|
+
const load = useCallback(async (query?: string): Promise<void> => {
|
|
55
|
+
const seq = ++seqRef.current
|
|
56
|
+
try {
|
|
57
|
+
const list = await api.listHosts(query)
|
|
58
|
+
if (seq !== seqRef.current) return
|
|
59
|
+
setHosts(list)
|
|
60
|
+
setError(null)
|
|
61
|
+
} catch (cause) {
|
|
62
|
+
if (seq !== seqRef.current) return
|
|
63
|
+
setError(errorMessage(cause))
|
|
64
|
+
}
|
|
65
|
+
}, [api])
|
|
66
|
+
|
|
67
|
+
useEffect(() => { void load() }, [load])
|
|
68
|
+
|
|
69
|
+
// Debounced search: every keystroke re-filters through the API.
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const timer = setTimeout(() => {
|
|
72
|
+
const query = search.trim()
|
|
73
|
+
void load(query === '' ? undefined : query)
|
|
74
|
+
}, 300)
|
|
75
|
+
return () => { clearTimeout(timer) }
|
|
76
|
+
}, [search, load])
|
|
77
|
+
|
|
78
|
+
const runTest = async (alias: string): Promise<void> => {
|
|
79
|
+
setTestingAlias(alias)
|
|
80
|
+
try {
|
|
81
|
+
const result = await api.testHost(alias)
|
|
82
|
+
if (result.code === 'NEEDS_PASSWORD' && (result.secret === 'password' || result.secret === 'passphrase')) {
|
|
83
|
+
// VSCode Remote-SSH style: prompt once per session, then retry.
|
|
84
|
+
setSecretConfirm({
|
|
85
|
+
alias,
|
|
86
|
+
secret: result.secret,
|
|
87
|
+
user: hosts?.find(host => host.alias === alias)?.user,
|
|
88
|
+
})
|
|
89
|
+
setTestingAlias(null)
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
setTestResults(prev => ({ ...prev, [alias]: result }))
|
|
93
|
+
} catch (cause) {
|
|
94
|
+
if (cause instanceof SshApiError && (cause.code === 'HOST_KEY_UNKNOWN' || cause.code === 'HOST_KEY_MISMATCH')) {
|
|
95
|
+
setHostKeyConfirm({
|
|
96
|
+
alias,
|
|
97
|
+
fingerprintSha256: cause.hostKeyFingerprint ?? '',
|
|
98
|
+
mismatch: cause.code === 'HOST_KEY_MISMATCH',
|
|
99
|
+
})
|
|
100
|
+
setTestingAlias(null)
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
setTestResults(prev => ({ ...prev, [alias]: { ok: false, error: errorMessage(cause) } }))
|
|
104
|
+
} finally {
|
|
105
|
+
setTestingAlias(null)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** After the fingerprint is confirmed (or reset), retry the test. */
|
|
110
|
+
const retryAfterFingerprint = async (alias: string): Promise<void> => {
|
|
111
|
+
setHostKeyConfirm(null)
|
|
112
|
+
await runTest(alias)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** After the session secret is provided, retry the test. */
|
|
116
|
+
const retryAfterSecret = async (alias: string): Promise<void> => {
|
|
117
|
+
setSecretConfirm(null)
|
|
118
|
+
await runTest(alias)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const deleteHost = async (alias: string): Promise<void> => {
|
|
122
|
+
if (!window.confirm(tt('hosts.deleteConfirm', { alias }))) return
|
|
123
|
+
try {
|
|
124
|
+
await api.deleteHost(alias)
|
|
125
|
+
void load()
|
|
126
|
+
} catch (cause) {
|
|
127
|
+
setError(errorMessage(cause))
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const importConfig = async (): Promise<void> => {
|
|
132
|
+
setImporting(true)
|
|
133
|
+
try {
|
|
134
|
+
const result = await api.importSshConfig()
|
|
135
|
+
setNotice(tt('hosts.imported', { parsed: result.parsed, added: result.added, skipped: result.skipped }))
|
|
136
|
+
void load()
|
|
137
|
+
} catch (cause) {
|
|
138
|
+
setError(errorMessage(cause))
|
|
139
|
+
} finally {
|
|
140
|
+
setImporting(false)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<div className={css.fillBody}>
|
|
146
|
+
<div className={css.toolbar}>
|
|
147
|
+
<input className={css.search} type="search" placeholder={tt('hosts.search')} value={search} onChange={event => { setSearch(event.target.value) }} />
|
|
148
|
+
<div className={css.toolbarSpacer} />
|
|
149
|
+
<button type="button" className={css.primaryButton} onClick={() => { setDialog({ mode: 'create' }) }}>{tt('hosts.add')}</button>
|
|
150
|
+
<button type="button" className={css.ghostButton} disabled={importing} onClick={() => { void importConfig() }}>{importing ? tt('common.loading') : tt('hosts.import')}</button>
|
|
151
|
+
</div>
|
|
152
|
+
{notice !== null && <div className={css.banner} data-kind="ok">{notice}</div>}
|
|
153
|
+
{error !== null && <div className={css.banner} data-kind="error">{tt('common.error', { error })}</div>}
|
|
154
|
+
{hosts === null && error === null && <div className={css.loading}>{tt('common.loading')}</div>}
|
|
155
|
+
{hosts !== null && hosts.length === 0 && <div className={css.empty}>{tt('hosts.empty')}</div>}
|
|
156
|
+
{hosts !== null && hosts.length > 0 && (
|
|
157
|
+
<div className={css.tableWrap}>
|
|
158
|
+
<table className={css.table}>
|
|
159
|
+
<thead>
|
|
160
|
+
<tr>
|
|
161
|
+
<th>{tt('hosts.col.alias')}</th>
|
|
162
|
+
<th>{tt('hosts.col.host')}</th>
|
|
163
|
+
<th>{tt('hosts.col.user')}</th>
|
|
164
|
+
<th>{tt('hosts.col.auth')}</th>
|
|
165
|
+
<th>{tt('hosts.col.environment')}</th>
|
|
166
|
+
<th>{tt('hosts.col.tags')}</th>
|
|
167
|
+
<th>{tt('hosts.col.description')}</th>
|
|
168
|
+
<th>{tt('hosts.col.actions')}</th>
|
|
169
|
+
</tr>
|
|
170
|
+
</thead>
|
|
171
|
+
<tbody>
|
|
172
|
+
{hosts.map(host => {
|
|
173
|
+
const test = testResults[host.alias]
|
|
174
|
+
return (
|
|
175
|
+
<tr key={host.alias}>
|
|
176
|
+
<td className={css.mono}>{host.alias}</td>
|
|
177
|
+
<td className={css.mono}>{host.host}:{host.port}</td>
|
|
178
|
+
<td>{host.user}</td>
|
|
179
|
+
<td><span className={css.badge} data-kind={host.auth}>{host.auth === 'key' ? tt('form.auth.key') : tt('form.auth.password')}</span></td>
|
|
180
|
+
<td className={css.cellMuted}>{host.environment ?? ''}</td>
|
|
181
|
+
<td className={css.cellMuted}>{host.tags.join(', ')}</td>
|
|
182
|
+
<td className={css.cellMuted}>{host.description ?? ''}</td>
|
|
183
|
+
<td>
|
|
184
|
+
<div className={css.actions}>
|
|
185
|
+
<button type="button" className={css.linkButton} disabled={testingAlias === host.alias} onClick={() => { void runTest(host.alias) }}>
|
|
186
|
+
{testingAlias === host.alias ? tt('hosts.testing') : tt('hosts.test')}
|
|
187
|
+
</button>
|
|
188
|
+
{testingAlias === host.alias && <span className={css.spinner} aria-hidden="true" />}
|
|
189
|
+
{test !== undefined && (
|
|
190
|
+
<span className={css.inlineTest} data-status={test.ok ? 'ok' : 'fail'}>
|
|
191
|
+
{test.ok ? tt('hosts.testOk', { latency: test.latencyMs ?? 0 }) : tt('hosts.testFail', { error: test.error ?? '' })}
|
|
192
|
+
</span>
|
|
193
|
+
)}
|
|
194
|
+
<button type="button" className={css.linkButton} onClick={() => { setDialog({ mode: 'edit', host }) }}>{tt('hosts.edit')}</button>
|
|
195
|
+
<button type="button" className={css.linkButton} data-danger onClick={() => { void deleteHost(host.alias) }}>{tt('hosts.delete')}</button>
|
|
196
|
+
<button type="button" className={css.ghostButton} onClick={() => { onConnect(host.alias) }}>{tt('hosts.connected')}</button>
|
|
197
|
+
</div>
|
|
198
|
+
</td>
|
|
199
|
+
</tr>
|
|
200
|
+
)
|
|
201
|
+
})}
|
|
202
|
+
</tbody>
|
|
203
|
+
</table>
|
|
204
|
+
</div>
|
|
205
|
+
)}
|
|
206
|
+
{dialog !== null && (
|
|
207
|
+
<HostFormDialog
|
|
208
|
+
api={api}
|
|
209
|
+
editing={dialog.mode === 'edit' ? dialog.host : null}
|
|
210
|
+
onClose={() => { setDialog(null) }}
|
|
211
|
+
onSaved={() => { setDialog(null); void load() }}
|
|
212
|
+
/>
|
|
213
|
+
)}
|
|
214
|
+
{hostKeyConfirm !== null && (
|
|
215
|
+
<HostFingerprintDialog
|
|
216
|
+
api={api}
|
|
217
|
+
alias={hostKeyConfirm.alias}
|
|
218
|
+
fingerprintSha256={hostKeyConfirm.fingerprintSha256}
|
|
219
|
+
mismatch={hostKeyConfirm.mismatch}
|
|
220
|
+
onClose={() => { setHostKeyConfirm(null) }}
|
|
221
|
+
onTrusted={() => { void retryAfterFingerprint(hostKeyConfirm.alias) }}
|
|
222
|
+
/>
|
|
223
|
+
)}
|
|
224
|
+
{secretConfirm !== null && (
|
|
225
|
+
<SessionSecretDialog
|
|
226
|
+
api={api}
|
|
227
|
+
alias={secretConfirm.alias}
|
|
228
|
+
user={secretConfirm.user}
|
|
229
|
+
secret={secretConfirm.secret}
|
|
230
|
+
onClose={() => { setSecretConfirm(null) }}
|
|
231
|
+
onProvided={() => { void retryAfterSecret(secretConfirm.alias) }}
|
|
232
|
+
/>
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
235
|
+
)
|
|
236
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session secret dialog (VSCode Remote-SSH style): shown when a connection
|
|
3
|
+
* needs a password/passphrase that the user hasn't entered this session
|
|
4
|
+
* (secretStorage='none' → secrets are never persisted). The credential is
|
|
5
|
+
* sent once to the host via /api/dsh-ssh/session-secret, held in the engine's
|
|
6
|
+
* in-memory session table for the connection-pool lifetime, and dropped on
|
|
7
|
+
* process exit. Nothing is written to disk.
|
|
8
|
+
*/
|
|
9
|
+
import { useState } from 'react'
|
|
10
|
+
import type { SshApi } from '../api.ts'
|
|
11
|
+
import { tt } from './helpers.ts'
|
|
12
|
+
import css from './panel.module.css'
|
|
13
|
+
|
|
14
|
+
/** The dialog's props. */
|
|
15
|
+
export interface SessionSecretDialogProps {
|
|
16
|
+
api: SshApi
|
|
17
|
+
alias: string
|
|
18
|
+
/** SSH login name — rendered as user@alias. */
|
|
19
|
+
user?: string
|
|
20
|
+
/** Which secret the connection needs. */
|
|
21
|
+
secret: 'password' | 'passphrase'
|
|
22
|
+
onClose: () => void
|
|
23
|
+
/** Called after the secret was provided (retry the operation). */
|
|
24
|
+
onProvided: () => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Prompt for a session-only credential. */
|
|
28
|
+
export function SessionSecretDialog({ api, alias, user, secret, onClose, onProvided }: SessionSecretDialogProps) {
|
|
29
|
+
const [value, setValue] = useState('')
|
|
30
|
+
const [busy, setBusy] = useState(false)
|
|
31
|
+
const [error, setError] = useState<string | null>(null)
|
|
32
|
+
const account = user !== undefined && user !== '' ? `${user}@${alias}` : alias
|
|
33
|
+
|
|
34
|
+
const submit = async (): Promise<void> => {
|
|
35
|
+
if (value === '') {
|
|
36
|
+
setError(tt('sessionSecret.required'))
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
setBusy(true)
|
|
40
|
+
setError(null)
|
|
41
|
+
try {
|
|
42
|
+
if (secret === 'password') await api.setSessionSecret(alias, { password: value })
|
|
43
|
+
else await api.setSessionSecret(alias, { passphrase: value })
|
|
44
|
+
onProvided()
|
|
45
|
+
} catch (cause) {
|
|
46
|
+
setError(cause instanceof Error ? cause.message : String(cause))
|
|
47
|
+
setBusy(false)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className={css.modalBackdrop} onClick={onClose}>
|
|
53
|
+
<div className={css.modal} role="dialog" aria-modal="true" onClick={event => { event.stopPropagation() }}>
|
|
54
|
+
<div className={css.modalHeader}>
|
|
55
|
+
<h3 className={css.modalTitle}>
|
|
56
|
+
{secret === 'password' ? tt('sessionSecret.title', { account }) : tt('sessionSecret.titlePassphrase', { account })}
|
|
57
|
+
</h3>
|
|
58
|
+
</div>
|
|
59
|
+
<div className={css.modalBody}>
|
|
60
|
+
<input
|
|
61
|
+
className={css.input}
|
|
62
|
+
type="password"
|
|
63
|
+
value={value}
|
|
64
|
+
autoFocus
|
|
65
|
+
placeholder={secret === 'password' ? tt('sessionSecret.placeholder') : tt('sessionSecret.placeholderPassphrase')}
|
|
66
|
+
onChange={event => { setValue(event.target.value) }}
|
|
67
|
+
onKeyDown={event => { if (event.key === 'Enter') void submit() }}
|
|
68
|
+
/>
|
|
69
|
+
{error !== null && <p className={css.formError}>{error}</p>}
|
|
70
|
+
</div>
|
|
71
|
+
<div className={css.modalFooter}>
|
|
72
|
+
<button type="button" className={css.modalGhost} disabled={busy} onClick={onClose}>{tt('sessionSecret.cancel')}</button>
|
|
73
|
+
<button type="button" className={css.modalPrimary} disabled={busy} onClick={() => { void submit() }}>
|
|
74
|
+
{tt('sessionSecret.connect')}
|
|
75
|
+
</button>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SSH operations panel shell: a header with a close control, a five-tab
|
|
3
|
+
* bar, and the active tab's content. Tab state lives here (browser session
|
|
4
|
+
* state); inactive tabs unmount, so each tab fetches its own data on
|
|
5
|
+
* activation. The hosts tab's connect action switches here to the terminal
|
|
6
|
+
* tab with the chosen alias preselected.
|
|
7
|
+
*/
|
|
8
|
+
import { useState } from 'react'
|
|
9
|
+
import type { SshApi } from '../api.ts'
|
|
10
|
+
import type { PanelController } from './controller.ts'
|
|
11
|
+
import { tt } from './helpers.ts'
|
|
12
|
+
import { ClusterTab } from './ClusterTab.tsx'
|
|
13
|
+
import { HostsTab } from './HostsTab.tsx'
|
|
14
|
+
import { TerminalTab } from './TerminalTab.tsx'
|
|
15
|
+
import { TransferTab } from './TransferTab.tsx'
|
|
16
|
+
import { TunnelsTab } from './TunnelsTab.tsx'
|
|
17
|
+
import css from './panel.module.css'
|
|
18
|
+
|
|
19
|
+
/** The panel's tab identifiers. */
|
|
20
|
+
export type SshTab = 'hosts' | 'terminal' | 'transfer' | 'tunnels' | 'cluster'
|
|
21
|
+
|
|
22
|
+
/** Panel shell props. */
|
|
23
|
+
export interface SshPanelProps {
|
|
24
|
+
/** The panel state owner (open/close/toggle). */
|
|
25
|
+
controller: PanelController
|
|
26
|
+
/** The SSH API client every tab operates through. */
|
|
27
|
+
api: SshApi
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The tab bar definition (labels resolved at render time). */
|
|
31
|
+
const TABS: ReadonlyArray<{ id: SshTab; label: () => string }> = [
|
|
32
|
+
{ id: 'hosts', label: () => tt('tab.hosts') },
|
|
33
|
+
{ id: 'terminal', label: () => tt('tab.terminal') },
|
|
34
|
+
{ id: 'transfer', label: () => tt('tab.transfer') },
|
|
35
|
+
{ id: 'tunnels', label: () => tt('tab.tunnels') },
|
|
36
|
+
{ id: 'cluster', label: () => tt('tab.cluster') },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
/** A pending "connect this host" request handed to the terminal tab. */
|
|
40
|
+
interface ConnectRequest {
|
|
41
|
+
alias: string
|
|
42
|
+
nonce: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The tabbed SSH panel. */
|
|
46
|
+
export function SshPanel({ controller, api }: SshPanelProps) {
|
|
47
|
+
const [activeTab, setActiveTab] = useState<SshTab>('hosts')
|
|
48
|
+
const [connectRequest, setConnectRequest] = useState<ConnectRequest | null>(null)
|
|
49
|
+
|
|
50
|
+
const handleConnect = (alias: string): void => {
|
|
51
|
+
setActiveTab('terminal')
|
|
52
|
+
setConnectRequest(prev => ({ alias, nonce: (prev?.nonce ?? 0) + 1 }))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className={css.panel}>
|
|
57
|
+
<div className={css.panelHeader}>
|
|
58
|
+
<h2 className={css.panelTitle}>{tt('panel.title')}</h2>
|
|
59
|
+
<button type="button" className={css.iconButton} title={tt('common.close')} aria-label={tt('common.close')} onClick={() => { controller.close() }}>x</button>
|
|
60
|
+
</div>
|
|
61
|
+
<div className={css.tabBar} role="tablist">
|
|
62
|
+
{TABS.map(tab => (
|
|
63
|
+
<button key={tab.id} type="button" role="tab" aria-selected={activeTab === tab.id} data-active={activeTab === tab.id ? '' : undefined} className={css.tab} onClick={() => { setActiveTab(tab.id) }}>
|
|
64
|
+
{tab.label()}
|
|
65
|
+
</button>
|
|
66
|
+
))}
|
|
67
|
+
</div>
|
|
68
|
+
<div className={css.panelContent}>
|
|
69
|
+
{activeTab === 'hosts' && <HostsTab api={api} onConnect={handleConnect} />}
|
|
70
|
+
{activeTab === 'terminal' && <TerminalTab api={api} presetAlias={connectRequest?.alias} requestId={connectRequest?.nonce} />}
|
|
71
|
+
{activeTab === 'transfer' && <TransferTab api={api} />}
|
|
72
|
+
{activeTab === 'tunnels' && <TunnelsTab api={api} />}
|
|
73
|
+
{activeTab === 'cluster' && <ClusterTab api={api} />}
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
)
|
|
77
|
+
}
|