@tiphareth/dsh-hardssh 0.1.2-alpha
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 +17794 -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 +142 -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 +99 -0
- package/src/client/workspace-gate.ts +232 -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
package/src/ssh/store.ts
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host config store: one JSON file (`~/.dsh/dsh-ssh.json`) holding every
|
|
3
|
+
* SSH host entry, written atomically (tmp + rename). Also parses the user's
|
|
4
|
+
* standard `~/.ssh/config` for one-shot import. Secrets (passwords,
|
|
5
|
+
* passphrases) live in this user-owned file in plaintext — same trust model
|
|
6
|
+
* as ssh-skill's annotated ssh-config comments; document it, never log it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
10
|
+
import { homedir } from 'node:os'
|
|
11
|
+
import { dirname, join, resolve } from 'node:path'
|
|
12
|
+
import type { HostPayload, ImportResult, SshAuthKind, SshHostEntry, SshHostSummary } from './protocol.ts'
|
|
13
|
+
|
|
14
|
+
/** File format version. */
|
|
15
|
+
const FORMAT_VERSION = 1
|
|
16
|
+
|
|
17
|
+
/** Store file location: <home>/.dsh/dsh-ssh.json. */
|
|
18
|
+
export function storePath(): string {
|
|
19
|
+
return join(homedir(), '.dsh', 'dsh-ssh.json')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The user's standard OpenSSH config path. */
|
|
23
|
+
export function sshConfigPath(): string {
|
|
24
|
+
return join(homedir(), '.ssh', 'config')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface StoreFile {
|
|
28
|
+
version: number
|
|
29
|
+
hosts: SshHostEntry[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Payload shape the validator enforces: full create vs partial patch. */
|
|
33
|
+
export type HostPayloadValidationMode = 'create' | 'patch'
|
|
34
|
+
|
|
35
|
+
/** Validate one auth block; returns a message or undefined.
|
|
36
|
+
* With `allowSecretless`, a password-kind block may omit the password (and
|
|
37
|
+
* key-kind may omit the keyPath, meaning "use the ssh-agent") — both shapes
|
|
38
|
+
* are produced by SecureHostStore when secrets live elsewhere (vault ref)
|
|
39
|
+
* or are never persisted ('none' mode, VSCode Remote-SSH style). */
|
|
40
|
+
function validateAuth(auth: unknown, allowSecretless: boolean): string | undefined {
|
|
41
|
+
if (typeof auth !== 'object' || auth === null) return 'auth must be an object'
|
|
42
|
+
const a = auth as Record<string, unknown>
|
|
43
|
+
if (a.kind !== 'key' && a.kind !== 'password') return 'auth.kind must be key or password'
|
|
44
|
+
if (a.kind === 'key' && (typeof a.keyPath !== 'string' || a.keyPath.trim() === '') && !allowSecretless) {
|
|
45
|
+
return 'auth.keyPath is required for key auth'
|
|
46
|
+
}
|
|
47
|
+
if (a.kind === 'password' && typeof a.password !== 'undefined' && typeof a.password !== 'string') {
|
|
48
|
+
return 'auth.password must be a string'
|
|
49
|
+
}
|
|
50
|
+
if (!allowSecretless && a.kind === 'password' && (typeof a.password !== 'string' || a.password === '')) {
|
|
51
|
+
return 'auth.password is required for password auth'
|
|
52
|
+
}
|
|
53
|
+
if (a.secretRef !== undefined && (typeof a.secretRef !== 'string' || a.secretRef === '')) {
|
|
54
|
+
return 'auth.secretRef must be a non-empty string'
|
|
55
|
+
}
|
|
56
|
+
return undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Validate the wire shape of a host payload; returns a message or undefined.
|
|
60
|
+
* `mode` picks the required fields: 'create' demands host/user/auth,
|
|
61
|
+
* 'patch' only validates the fields that are present (same rules).
|
|
62
|
+
* `allowSecretless` relaxes the password requirement so SecureHostStore can
|
|
63
|
+
* persist stripped entries (secrets in vault / never persisted). */
|
|
64
|
+
export function validateHostPayload(
|
|
65
|
+
payload: unknown,
|
|
66
|
+
mode: HostPayloadValidationMode = 'create',
|
|
67
|
+
allowSecretless = false,
|
|
68
|
+
): string | undefined {
|
|
69
|
+
if (typeof payload !== 'object' || payload === null) return 'body must be a JSON object'
|
|
70
|
+
const p = payload as Record<string, unknown>
|
|
71
|
+
if (mode === 'create') {
|
|
72
|
+
if (typeof p.host !== 'string' || p.host.trim() === '') return 'host is required'
|
|
73
|
+
if (typeof p.user !== 'string' || p.user.trim() === '') return 'user is required'
|
|
74
|
+
} else {
|
|
75
|
+
if (p.host !== undefined && (typeof p.host !== 'string' || p.host.trim() === '')) return 'host is required'
|
|
76
|
+
if (p.user !== undefined && (typeof p.user !== 'string' || p.user.trim() === '')) return 'user is required'
|
|
77
|
+
}
|
|
78
|
+
if (p.auth !== undefined) {
|
|
79
|
+
const authError = validateAuth(p.auth, allowSecretless)
|
|
80
|
+
if (authError !== undefined) return authError
|
|
81
|
+
} else if (mode === 'create') {
|
|
82
|
+
return 'auth is required'
|
|
83
|
+
}
|
|
84
|
+
if (p.port !== undefined && (typeof p.port !== 'number' || !Number.isInteger(p.port) || p.port < 1 || p.port > 65535)) {
|
|
85
|
+
return 'port must be an integer in 1..65535'
|
|
86
|
+
}
|
|
87
|
+
if (p.proxyJump !== undefined && (!Array.isArray(p.proxyJump) || p.proxyJump.some(x => typeof x !== 'string' || x === ''))) {
|
|
88
|
+
return 'proxyJump must be an array of alias strings'
|
|
89
|
+
}
|
|
90
|
+
if (p.tags !== undefined && (!Array.isArray(p.tags) || p.tags.some(x => typeof x !== 'string'))) {
|
|
91
|
+
return 'tags must be an array of strings'
|
|
92
|
+
}
|
|
93
|
+
return undefined
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Detect a proxyJump cycle reachable from `startAlias` across the full host
|
|
98
|
+
* list (a hop's own chain is followed transitively). Returns a readable
|
|
99
|
+
* path like 'a -> b -> a', or undefined. Dangling hops (alias with no
|
|
100
|
+
* entry) are terminal edges, not cycles.
|
|
101
|
+
*/
|
|
102
|
+
function findJumpCycle(startAlias: string, entries: Array<{ alias: string; proxyJump: string[] }>): string | undefined {
|
|
103
|
+
const byAlias = new Map(entries.map(entry => [entry.alias, entry]))
|
|
104
|
+
const walked = new Set<string>()
|
|
105
|
+
const walk = (alias: string, path: string[]): string | undefined => {
|
|
106
|
+
const at = path.indexOf(alias)
|
|
107
|
+
if (at >= 0) return [...path.slice(at), alias].join(' -> ')
|
|
108
|
+
if (walked.has(alias)) return undefined
|
|
109
|
+
walked.add(alias)
|
|
110
|
+
const entry = byAlias.get(alias)
|
|
111
|
+
if (entry === undefined) return undefined
|
|
112
|
+
for (const hop of entry.proxyJump) {
|
|
113
|
+
const cycle = walk(hop, [...path, alias])
|
|
114
|
+
if (cycle !== undefined) return cycle
|
|
115
|
+
}
|
|
116
|
+
return undefined
|
|
117
|
+
}
|
|
118
|
+
return walk(startAlias, [])
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The alias grammar (same as the scaffold rule: lowercase, digits, single hyphens). */
|
|
122
|
+
const ALIAS_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/
|
|
123
|
+
|
|
124
|
+
/** Validate an alias for creation. */
|
|
125
|
+
export function validateAlias(alias: string): string | undefined {
|
|
126
|
+
if (!ALIAS_RE.test(alias)) return 'alias must be lowercase letters, digits and single hyphens'
|
|
127
|
+
return undefined
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The host store. Pure file I/O — no cordis dependency, unit-testable.
|
|
132
|
+
*/
|
|
133
|
+
export class HostStore {
|
|
134
|
+
/** The JSON file path. */
|
|
135
|
+
readonly path: string
|
|
136
|
+
/** Optional override of the ~/.ssh/config path (tests). */
|
|
137
|
+
private readonly sshConfigOverride: string | undefined
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @param path - store file path (defaults to the standard location).
|
|
141
|
+
* @param sshConfigOverride - ssh config path override (tests only).
|
|
142
|
+
*/
|
|
143
|
+
constructor(path?: string, sshConfigOverride?: string) {
|
|
144
|
+
this.path = resolve(path ?? storePath())
|
|
145
|
+
this.sshConfigOverride = sshConfigOverride
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Load all entries (empty store when the file is absent). */
|
|
149
|
+
list(): SshHostEntry[] {
|
|
150
|
+
const file = this.load()
|
|
151
|
+
return file.hosts
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Find one entry by alias. */
|
|
155
|
+
find(alias: string): SshHostEntry | undefined {
|
|
156
|
+
return this.list().find(entry => entry.alias === alias)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Secret-free projection for the browser and agent surfaces. */
|
|
160
|
+
summarize(entry: SshHostEntry): SshHostSummary {
|
|
161
|
+
let keyReady = true
|
|
162
|
+
if (entry.auth.kind === 'key' && entry.auth.keyPath) {
|
|
163
|
+
keyReady = existsSync(expandHome(entry.auth.keyPath))
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
alias: entry.alias,
|
|
167
|
+
host: entry.host,
|
|
168
|
+
port: entry.port,
|
|
169
|
+
user: entry.user,
|
|
170
|
+
auth: entry.auth.kind,
|
|
171
|
+
keyReady,
|
|
172
|
+
proxyJump: [...entry.proxyJump],
|
|
173
|
+
// Optional fields are spread conditionally: the tool bridge rejects
|
|
174
|
+
// undefined-valued properties as non-lossless JSON.
|
|
175
|
+
...(entry.description !== undefined ? { description: entry.description } : {}),
|
|
176
|
+
...(entry.environment !== undefined ? { environment: entry.environment } : {}),
|
|
177
|
+
tags: [...entry.tags],
|
|
178
|
+
...(entry.location !== undefined ? { location: entry.location } : {}),
|
|
179
|
+
createdAt: entry.createdAt,
|
|
180
|
+
updatedAt: entry.updatedAt,
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Create one entry. Throws on alias collision or invalid payload. */
|
|
185
|
+
create(payload: HostPayload, allowSecretless = false): SshHostEntry {
|
|
186
|
+
const alias = payload.alias?.trim()
|
|
187
|
+
if (!alias) throw new Error('alias is required')
|
|
188
|
+
const aliasError = validateAlias(alias)
|
|
189
|
+
if (aliasError !== undefined) throw new Error(aliasError)
|
|
190
|
+
const bodyError = validateHostPayload(payload, 'create', allowSecretless)
|
|
191
|
+
if (bodyError !== undefined) throw new Error(bodyError)
|
|
192
|
+
const file = this.load()
|
|
193
|
+
if (file.hosts.some(entry => entry.alias === alias)) throw new Error(`alias '${alias}' already exists`)
|
|
194
|
+
const now = Date.now()
|
|
195
|
+
// validateHostPayload (create mode) above guarantees auth presence; this
|
|
196
|
+
// explicit check narrows the type for the entry construction below.
|
|
197
|
+
const auth = payload.auth
|
|
198
|
+
if (auth === undefined) throw new Error('auth is required')
|
|
199
|
+
const entry: SshHostEntry = {
|
|
200
|
+
alias,
|
|
201
|
+
host: payload.host.trim(),
|
|
202
|
+
port: payload.port ?? 22,
|
|
203
|
+
user: payload.user.trim(),
|
|
204
|
+
auth: {
|
|
205
|
+
kind: auth.kind,
|
|
206
|
+
keyPath: auth.kind === 'key' ? (auth.keyPath?.trim() !== '' ? expandHome(auth.keyPath?.trim() ?? '') : undefined) : undefined,
|
|
207
|
+
passphrase: auth.kind === 'key' ? auth.passphrase ?? undefined : undefined,
|
|
208
|
+
password: auth.kind === 'password' ? auth.password : undefined,
|
|
209
|
+
secretRef: auth.secretRef,
|
|
210
|
+
},
|
|
211
|
+
proxyJump: [...(payload.proxyJump ?? [])],
|
|
212
|
+
description: payload.description?.trim() || undefined,
|
|
213
|
+
environment: payload.environment?.trim() || undefined,
|
|
214
|
+
tags: [...(payload.tags ?? [])].map(tag => tag.trim()).filter(tag => tag !== ''),
|
|
215
|
+
location: payload.location?.trim() || undefined,
|
|
216
|
+
createdAt: now,
|
|
217
|
+
updatedAt: now,
|
|
218
|
+
}
|
|
219
|
+
// ProxyJump cycle guard: the new entry may complete a loop through
|
|
220
|
+
// existing hosts (A -> B, B -> A) — reject before persisting.
|
|
221
|
+
const cycle = findJumpCycle(alias, [...file.hosts, entry])
|
|
222
|
+
if (cycle !== undefined) {
|
|
223
|
+
throw new Error(`proxyJump cycle detected: ${cycle}`)
|
|
224
|
+
}
|
|
225
|
+
file.hosts.push(entry)
|
|
226
|
+
this.save(file)
|
|
227
|
+
return entry
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Update the fields present in `patch`; unknown aliases throw. */
|
|
231
|
+
update(alias: string, patch: Partial<HostPayload>, allowSecretless = false): SshHostEntry {
|
|
232
|
+
const file = this.load()
|
|
233
|
+
const entry = file.hosts.find(candidate => candidate.alias === alias)
|
|
234
|
+
if (entry === undefined) throw new Error(`alias '${alias}' not found`)
|
|
235
|
+
// Partial-patch validation: only the present fields are checked (a
|
|
236
|
+
// caller may update just tags without resending host/user), but every
|
|
237
|
+
// present field follows the same rules as create.
|
|
238
|
+
const patchError = validateHostPayload(patch, 'patch', allowSecretless)
|
|
239
|
+
if (patchError !== undefined) throw new Error(patchError)
|
|
240
|
+
if (patch.host !== undefined) entry.host = patch.host.trim()
|
|
241
|
+
if (patch.port !== undefined) entry.port = patch.port
|
|
242
|
+
if (patch.user !== undefined) entry.user = patch.user.trim()
|
|
243
|
+
if (patch.auth !== undefined) {
|
|
244
|
+
const auth = patch.auth
|
|
245
|
+
// A changed key path with no passphrase means the new key has none;
|
|
246
|
+
// only keep the old passphrase when the key path is unchanged.
|
|
247
|
+
const keyChanged = auth.kind === 'key'
|
|
248
|
+
&& auth.keyPath !== undefined
|
|
249
|
+
&& expandHome(auth.keyPath.trim()) !== entry.auth.keyPath
|
|
250
|
+
entry.auth = {
|
|
251
|
+
kind: auth.kind,
|
|
252
|
+
keyPath: auth.kind === 'key' ? (auth.keyPath?.trim() !== '' ? expandHome(auth.keyPath?.trim() ?? '') : undefined) : undefined,
|
|
253
|
+
passphrase: auth.kind === 'key'
|
|
254
|
+
? (auth.passphrase !== undefined ? auth.passphrase : (keyChanged ? undefined : entry.auth.passphrase))
|
|
255
|
+
: undefined,
|
|
256
|
+
password: auth.kind === 'password' ? auth.password : undefined,
|
|
257
|
+
// Keep the existing vault ref unless the patch supplies a new one
|
|
258
|
+
// or the key changed (a plain auth edit shouldn't silently lose
|
|
259
|
+
// credential linkage, but a new key must not reuse the old secret).
|
|
260
|
+
secretRef: auth.secretRef !== undefined ? auth.secretRef : (keyChanged ? undefined : entry.auth.secretRef),
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (patch.proxyJump !== undefined) {
|
|
264
|
+
entry.proxyJump = [...patch.proxyJump]
|
|
265
|
+
// Guard the full graph with the patched entry in place; a loop through
|
|
266
|
+
// other hosts must reject before the file is written.
|
|
267
|
+
const cycle = findJumpCycle(alias, file.hosts)
|
|
268
|
+
if (cycle !== undefined) {
|
|
269
|
+
throw new Error(`proxyJump cycle detected: ${cycle}`)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (patch.description !== undefined) entry.description = patch.description.trim() || undefined
|
|
273
|
+
if (patch.environment !== undefined) entry.environment = patch.environment.trim() || undefined
|
|
274
|
+
if (patch.tags !== undefined) entry.tags = [...patch.tags].map(tag => tag.trim()).filter(tag => tag !== '')
|
|
275
|
+
if (patch.location !== undefined) entry.location = patch.location.trim() || undefined
|
|
276
|
+
entry.updatedAt = Date.now()
|
|
277
|
+
this.save(file)
|
|
278
|
+
return entry
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Remove one entry. */
|
|
282
|
+
delete(alias: string): void {
|
|
283
|
+
const file = this.load()
|
|
284
|
+
const index = file.hosts.findIndex(candidate => candidate.alias === alias)
|
|
285
|
+
if (index < 0) throw new Error(`alias '${alias}' not found`)
|
|
286
|
+
file.hosts.splice(index, 1)
|
|
287
|
+
this.save(file)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Import hosts from `~/.ssh/config`: Host blocks with a single non-wildcard
|
|
292
|
+
* pattern and a HostName become entries (key auth via IdentityFile, jump
|
|
293
|
+
* hosts via ProxyJump). Existing aliases are skipped.
|
|
294
|
+
* @returns import statistics.
|
|
295
|
+
*/
|
|
296
|
+
importFromSshConfig(): ImportResult {
|
|
297
|
+
// Per-run statistics: a later import must not report earlier runs' skips.
|
|
298
|
+
this.skippedNames = new Set<string>()
|
|
299
|
+
const configPath = this.sshConfigOverride ?? sshConfigPath()
|
|
300
|
+
if (!existsSync(configPath)) return { parsed: 0, added: 0, skipped: 0, skippedNames: [] }
|
|
301
|
+
const lines = readFileSync(configPath, 'utf8').split(/\r?\n/)
|
|
302
|
+
const blocks: { pattern: string; props: Record<string, string> }[] = []
|
|
303
|
+
let current: { pattern: string; props: Record<string, string> } | undefined
|
|
304
|
+
const skip = (name: string, seen: Set<string>): void => {
|
|
305
|
+
if (name !== '' && !seen.has(name)) {
|
|
306
|
+
seen.add(name)
|
|
307
|
+
this.skippedNames.add(name)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
for (const raw of lines) {
|
|
311
|
+
const line = raw.trim()
|
|
312
|
+
if (line === '' || line.startsWith('#')) continue
|
|
313
|
+
const match = /^([A-Za-z0-9_\-]+)\s+(.+)$/.exec(line)
|
|
314
|
+
if (match === null) continue
|
|
315
|
+
const key = match[1].toLowerCase()
|
|
316
|
+
const value = match[2].trim()
|
|
317
|
+
if (key === 'host') {
|
|
318
|
+
current = { pattern: value, props: {} }
|
|
319
|
+
blocks.push(current)
|
|
320
|
+
} else if (current !== undefined) {
|
|
321
|
+
current.props[key] = value
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
let added = 0
|
|
325
|
+
for (const block of blocks) {
|
|
326
|
+
const pattern = block.pattern.split(/\s+/)[0]
|
|
327
|
+
if (pattern.includes('*') || pattern.includes('?')) {
|
|
328
|
+
skip(pattern, this.skippedNames)
|
|
329
|
+
continue
|
|
330
|
+
}
|
|
331
|
+
const hostName = block.props.hostname
|
|
332
|
+
if (hostName === undefined || hostName === '') {
|
|
333
|
+
skip(pattern, this.skippedNames)
|
|
334
|
+
continue
|
|
335
|
+
}
|
|
336
|
+
const existing = this.list().some(entry => entry.alias === pattern)
|
|
337
|
+
if (existing) {
|
|
338
|
+
skip(pattern, this.skippedNames)
|
|
339
|
+
continue
|
|
340
|
+
}
|
|
341
|
+
const payload: HostPayload = {
|
|
342
|
+
alias: pattern,
|
|
343
|
+
host: hostName,
|
|
344
|
+
port: block.props.port !== undefined ? Number.parseInt(block.props.port, 10) : 22,
|
|
345
|
+
user: block.props.user ?? process.env.USER ?? 'root',
|
|
346
|
+
auth: {
|
|
347
|
+
kind: block.props.identityfile !== undefined ? 'key' : 'password',
|
|
348
|
+
keyPath: block.props.identityfile,
|
|
349
|
+
password: block.props.password,
|
|
350
|
+
},
|
|
351
|
+
proxyJump: block.props.proxyjump !== undefined
|
|
352
|
+
? block.props.proxyjump.split(',').map(hop => hop.trim()).filter(hop => hop !== '')
|
|
353
|
+
: [],
|
|
354
|
+
description: block.props.description,
|
|
355
|
+
environment: block.props.environment,
|
|
356
|
+
tags: (block.props.tags ?? '').split(',').map(tag => tag.trim()).filter(tag => tag !== ''),
|
|
357
|
+
location: block.props.location,
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
this.create(payload)
|
|
361
|
+
added += 1
|
|
362
|
+
} catch {
|
|
363
|
+
// Unusable entry (bad alias grammar etc.) — count as skipped.
|
|
364
|
+
skip(pattern, this.skippedNames)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return { parsed: blocks.length, added, skipped: this.skippedNames.size, skippedNames: [...this.skippedNames] }
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private skippedNames = new Set<string>()
|
|
371
|
+
|
|
372
|
+
private load(): StoreFile {
|
|
373
|
+
if (!existsSync(this.path)) return { version: FORMAT_VERSION, hosts: [] }
|
|
374
|
+
try {
|
|
375
|
+
const parsed = JSON.parse(readFileSync(this.path, 'utf8')) as StoreFile
|
|
376
|
+
if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.hosts)) {
|
|
377
|
+
throw new Error('store file shape invalid')
|
|
378
|
+
}
|
|
379
|
+
return parsed
|
|
380
|
+
} catch {
|
|
381
|
+
// A corrupt store must not brick the plugin — and must not be silently
|
|
382
|
+
// overwritten by the next save: rename it aside for manual recovery
|
|
383
|
+
// (the plugin then starts from an empty list).
|
|
384
|
+
try {
|
|
385
|
+
renameSync(this.path, `${this.path}.corrupt-${Date.now()}`)
|
|
386
|
+
} catch { /* best effort */ }
|
|
387
|
+
return { version: FORMAT_VERSION, hosts: [] }
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
private save(file: StoreFile): void {
|
|
392
|
+
const dir = dirname(this.path)
|
|
393
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
394
|
+
const tmp = this.path + '.tmp'
|
|
395
|
+
// Secrets live in this file: keep it readable by the owner only. The
|
|
396
|
+
// tmp file carries the 0600 mode through the rename.
|
|
397
|
+
writeFileSync(tmp, JSON.stringify(file, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
|
|
398
|
+
renameSync(tmp, this.path)
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Expand a leading `~` in a filesystem path. */
|
|
403
|
+
export function expandHome(path: string): string {
|
|
404
|
+
if (path === '~') return homedir()
|
|
405
|
+
if (path.startsWith('~/')) return join(homedir(), path.slice(2))
|
|
406
|
+
return path
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Resolved authentication for one connect (vault-aware). */
|
|
410
|
+
export interface ResolvedAuth {
|
|
411
|
+
kind: SshAuthKind
|
|
412
|
+
keyPath?: string
|
|
413
|
+
password?: string
|
|
414
|
+
passphrase?: string
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Secure host store: composes the plaintext HostStore (host/port/user/… +
|
|
419
|
+
* key paths — non-secrets) with the credential handling chosen by
|
|
420
|
+
* `secretStorage`:
|
|
421
|
+
*
|
|
422
|
+
* - **'vault'**: passwords/passphrases are stored encrypted in the
|
|
423
|
+
* credential vault (AES-256-GCM); the hosts file keeps only a `secretRef`,
|
|
424
|
+
* and resolveAuth reveals on demand. For headless agents running password
|
|
425
|
+
* hosts unattended.
|
|
426
|
+
*
|
|
427
|
+
* - **'none' (default, VSCode Remote-SSH style)**: secrets are NEVER
|
|
428
|
+
* persisted. create/update strip the password/passphrase from the entry
|
|
429
|
+
* (kind + keyPath stay); the engine prompts once per session and holds the
|
|
430
|
+
* credential in-memory (session password table). The plaintext HostStore
|
|
431
|
+
* remains untouched, so existing v1 tests and dual-format reads keep
|
|
432
|
+
* working.
|
|
433
|
+
*/
|
|
434
|
+
export class SecureHostStore {
|
|
435
|
+
private readonly inner: HostStore
|
|
436
|
+
|
|
437
|
+
constructor(
|
|
438
|
+
private readonly vault: import('./vault.ts').Vault | undefined,
|
|
439
|
+
path?: string,
|
|
440
|
+
sshConfigOverride?: string,
|
|
441
|
+
private readonly mode: 'none' | 'vault' = 'none',
|
|
442
|
+
) {
|
|
443
|
+
this.inner = new HostStore(path, sshConfigOverride)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
list(): SshHostEntry[] { return this.inner.list() }
|
|
447
|
+
find(alias: string): SshHostEntry | undefined { return this.inner.find(alias) }
|
|
448
|
+
summarize(entry: SshHostEntry): SshHostSummary { return this.inner.summarize(entry) }
|
|
449
|
+
get path(): string { return this.inner.path }
|
|
450
|
+
/** Legacy ssh-config import (delegates; passwords imported stay inline —
|
|
451
|
+
* a follow-up may route them through the vault). */
|
|
452
|
+
importFromSshConfig(): ImportResult { return this.inner.importFromSshConfig() }
|
|
453
|
+
|
|
454
|
+
/** Create: secrets are stashed per mode; entry only keeps kind (+ keyPath). */
|
|
455
|
+
async create(payload: HostPayload): Promise<SshHostEntry> {
|
|
456
|
+
// Validate the raw wire input first. In 'none' mode a password-kind host
|
|
457
|
+
// may omit the password (typed at connect time, VSCode-style); 'vault'
|
|
458
|
+
// mode keeps the strict rule so every credential gets stashed.
|
|
459
|
+
const rawError = validateHostPayload(payload, 'create', this.mode === 'none')
|
|
460
|
+
if (rawError !== undefined) throw new Error(rawError)
|
|
461
|
+
const ref = await this.stashSecrets(payload.alias ?? '', payload.auth)
|
|
462
|
+
const entry = this.inner.create(stripSecrets(payload, ref, this.mode), true)
|
|
463
|
+
return entry
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Update: auth present -> re-stash (or strip in none mode); absent → keep. */
|
|
467
|
+
async update(alias: string, patch: Partial<HostPayload>): Promise<SshHostEntry> {
|
|
468
|
+
const existing = this.inner.find(alias)
|
|
469
|
+
if (patch.auth === undefined || existing === undefined) {
|
|
470
|
+
return this.inner.update(alias, patch)
|
|
471
|
+
}
|
|
472
|
+
const rawError = validateHostPayload(patch, 'patch', this.mode === 'none')
|
|
473
|
+
if (rawError !== undefined) {
|
|
474
|
+
// 'vault' mode: a password-kind patch without a password is acceptable
|
|
475
|
+
// when the existing entry already holds a secretRef — the user edited
|
|
476
|
+
// other fields without retyping the credential.
|
|
477
|
+
const keepOldRef = this.mode === 'vault'
|
|
478
|
+
&& patch.auth.kind === 'password'
|
|
479
|
+
&& (patch.auth.password === undefined || patch.auth.password === '')
|
|
480
|
+
&& existing.auth.secretRef !== undefined
|
|
481
|
+
if (!keepOldRef) throw new Error(rawError)
|
|
482
|
+
}
|
|
483
|
+
const ref = await this.stashSecrets(alias, patch.auth)
|
|
484
|
+
const effectiveRef = ref ?? (this.mode === 'vault' ? existing.auth.secretRef : undefined)
|
|
485
|
+
return this.inner.update(alias, stripSecrets(patch, effectiveRef, this.mode), true)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Delete: drop the host (and its vault secrets in vault mode, best-effort). */
|
|
489
|
+
async delete(alias: string): Promise<void> {
|
|
490
|
+
const entry = this.inner.find(alias)
|
|
491
|
+
if (entry !== undefined && entry.auth.secretRef !== undefined && this.vault !== undefined) {
|
|
492
|
+
await this.vault.remove(entry.auth.secretRef).catch(() => undefined)
|
|
493
|
+
}
|
|
494
|
+
this.inner.delete(alias)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Resolve the authentication for one entry (vault reveal when needed). */
|
|
498
|
+
async resolveAuth(entry: SshHostEntry): Promise<ResolvedAuth> {
|
|
499
|
+
if (this.mode === 'vault' && entry.auth.secretRef !== undefined && this.vault !== undefined) {
|
|
500
|
+
const secret = await this.vault.reveal(entry.auth.secretRef)
|
|
501
|
+
if (entry.auth.kind === 'password') return { kind: 'password', password: secret }
|
|
502
|
+
return { kind: 'key', keyPath: entry.auth.keyPath, passphrase: secret !== '' ? secret : undefined }
|
|
503
|
+
}
|
|
504
|
+
// Plaintext fallback (v1 store / tests) — reachable in none mode only
|
|
505
|
+
// when an entry still carries inline secrets (legacy v1 file).
|
|
506
|
+
return { kind: entry.auth.kind, keyPath: entry.auth.keyPath, password: entry.auth.password, passphrase: entry.auth.passphrase }
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
private async stashSecrets(alias: string, auth: HostPayload['auth']): Promise<string | undefined> {
|
|
510
|
+
if (this.mode !== 'vault' || this.vault === undefined) return undefined
|
|
511
|
+
if (auth === undefined) return undefined
|
|
512
|
+
if (auth.kind === 'key') {
|
|
513
|
+
if (auth.passphrase !== undefined && auth.passphrase !== '') {
|
|
514
|
+
return this.vault.store('host.passphrase', alias, auth.passphrase)
|
|
515
|
+
}
|
|
516
|
+
return undefined // key without passphrase needs no stash
|
|
517
|
+
}
|
|
518
|
+
if (auth.password !== undefined && auth.password !== '') {
|
|
519
|
+
return this.vault.store('host.password', alias, auth.password)
|
|
520
|
+
}
|
|
521
|
+
return undefined
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Strip secrets from a payload according to mode + stored ref.
|
|
526
|
+
* - vault mode with ref: carry the ref, drop plaintext.
|
|
527
|
+
* - none mode: drop the plaintext (never persist), keep kind + keyPath. */
|
|
528
|
+
function stripSecrets<T extends HostPayload | Partial<HostPayload>>(payload: T, ref: string | undefined, mode: 'none' | 'vault'): T {
|
|
529
|
+
if (payload.auth === undefined) return payload
|
|
530
|
+
const auth = payload.auth
|
|
531
|
+
if (mode === 'vault' && ref !== undefined) {
|
|
532
|
+
return {
|
|
533
|
+
...payload,
|
|
534
|
+
auth: auth.kind === 'key'
|
|
535
|
+
? { kind: 'key' as const, keyPath: auth.keyPath, secretRef: ref }
|
|
536
|
+
: { kind: 'password' as const, secretRef: ref },
|
|
537
|
+
} as T
|
|
538
|
+
}
|
|
539
|
+
// none mode (or no ref available): keep kind + keyPath, drop password/passphrase.
|
|
540
|
+
if (auth.kind === 'key') {
|
|
541
|
+
return { ...payload, auth: { kind: 'key' as const, keyPath: auth.keyPath } } as T
|
|
542
|
+
}
|
|
543
|
+
return { ...payload, auth: { kind: 'password' as const } } as T
|
|
544
|
+
}
|