dsh-remote 0.8.71 → 0.8.73

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/lib/sshconfig.js CHANGED
@@ -1,705 +1,779 @@
1
- // dsh-remote — ~/.ssh/config reading and OpenSSH alias resolution (issue #38).
2
- //
3
- // ## Why this module exists
4
- //
5
- // A saved machine can be an ALIAS from the user's ssh config: the registry keeps
6
- // `{ host: '<alias>', useSshConfig: true }` and NOTHING else — no HostName, no
7
- // user, no port, no key path. Every connect resolves that alias against
8
- // `~/.ssh/config` as it is RIGHT NOW, which is what VSCode Remote-SSH does with
9
- // the same file: editing the config (a new jump host, a rotated IdentityFile, a
10
- // different port) is picked up without re-importing anything into the plugin.
11
- //
12
- // ## What is read
13
- //
14
- // Host / HostName / User / Port / IdentityFile / ProxyJump / ProxyCommand.
15
- // `IdentityFile` is imported as a PATH reference only — the plugin never opens
16
- // key material (v0.5.5 policy).
17
- //
18
- // ## OpenSSH semantics implemented
19
- //
20
- // • `Host a b` / `Host *.example.com` / `Host !x *.y` pattern lists, evaluated
21
- // with `*` and `?` wildcards, case-insensitively (see hostBlockMatches).
22
- // • first-obtained-value-wins ACROSS all matching blocks, not first-block-wins:
23
- // ssh_config(5) — "for each parameter, the first obtained value will be used".
24
- // • `Include` directives, globbed, expanded in place, relative to ~/.ssh
25
- // (falling back to the including file's directory), depth- and cycle-limited.
26
- // • backslash line continuations.
27
- //
28
- // The module is pure (I/O is injectable), so it is unit-testable without a real
29
- // home directory — see test/sshconfig.test.js.
30
-
31
- import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync, chmodSync } from 'node:fs'
32
- import { homedir } from 'node:os'
33
- import path from 'node:path'
34
-
35
- /** Default location of the user's ssh config. An env override exists so tests
36
- * (and any deployment that isolates its home directory) can point the plugin
37
- * at a substitute file instead of the real ~/.ssh/config. */
38
- export const sshConfigPath = () => {
39
- const env = process.env.DSH_REMOTE_SSH_CONFIG
40
- if (env && String(env).trim()) return path.resolve(String(env).trim())
41
- return path.join(homedir(), '.ssh', 'config')
42
- }
43
-
44
- /** Directory `Include` paths are resolved against (ssh_config(5)). */
45
- export const sshConfigDir = () => {
46
- const env = process.env.DSH_REMOTE_SSH_CONFIG
47
- if (env && String(env).trim()) return path.dirname(path.resolve(String(env).trim()))
48
- return path.join(homedir(), '.ssh')
49
- }
50
-
51
- /** Parameters the resolver cares about, lowercase (as parsed). */
52
- const KNOWN_PARAMS = new Set([
53
- 'hostname', 'user', 'port', 'identityfile', 'proxyjump', 'proxycommand', 'identitiesonly',
54
- ])
55
-
56
- /** Read the user's ~/.ssh/config text, Includes expanded ('' when absent). */
57
- export function readSshConfigText(filePath = sshConfigPath()) {
58
- return loadSshConfigText(filePath)
59
- }
60
-
61
- /**
62
- * Read an ssh config file and expand its `Include` directives in place.
63
- *
64
- * Relative includes resolve against `~/.ssh` first (ssh_config(5)) and then
65
- * against the including file's directory, because real-world configs use both.
66
- * A missing or unreadable include is skipped: one broken line must not hide the
67
- * rest of the config.
68
- *
69
- * @param {string} [filePath] - the config file (default ~/.ssh/config).
70
- * @param {object} [io] - injectable filesystem hooks (tests).
71
- * @param {(p: string) => string} [io.readFile]
72
- * @param {(p: string) => boolean} [io.exists]
73
- * @param {(p: string) => string[]} [io.listDir]
74
- * @param {number} [io.maxDepth]
75
- * @returns {string} the expanded config text ('' when nothing could be read).
76
- */
77
- export function loadSshConfigText(filePath = sshConfigPath(), io = {}) {
78
- const readFile = io.readFile || ((p) => readFileSync(p, 'utf8'))
79
- const listDir = io.listDir || ((p) => readdirSync(p))
80
- return expandFile(filePath, { readFile, listDir, maxDepth: io.maxDepth ?? 3, depth: 0, seen: new Set() })
81
- }
82
-
83
- function expandFile(file, state) {
84
- let text
85
- try {
86
- text = state.readFile(file)
87
- } catch {
88
- return ''
89
- }
90
- if (typeof text !== 'string') return ''
91
- if (state.depth >= state.maxDepth) return text
92
- const out = []
93
- for (const line of joinContinuations(text)) {
94
- const patterns = matchInclude(line)
95
- if (!patterns) { out.push(line); continue }
96
- for (const pattern of patterns) {
97
- for (const included of expandIncludePath(pattern, path.dirname(file), state.listDir)) {
98
- const key = path.resolve(included)
99
- if (state.seen.has(key)) continue
100
- state.seen.add(key)
101
- const child = expandFile(included, { ...state, depth: state.depth + 1 })
102
- if (child.trim()) out.push(child)
103
- }
104
- }
105
- }
106
- return out.join('\n')
107
- }
108
-
109
- /** The value tokens of `Include <pattern> [pattern...]`, or null. */
110
- function matchInclude(line) {
111
- const m = String(line).match(/^\s*include\s+(.+?)\s*$/i)
112
- if (!m) return null
113
- return splitValue(m[1])
114
- }
115
-
116
- /** Expand one include pattern.
117
- *
118
- * ssh_config(5): a relative path is relative to `~/.ssh`. The including file's
119
- * own directory is tried only when that yields nothing — which is what makes
120
- * the plugin able to read a config the user pointed it at elsewhere, without
121
- * double-resolving (and double-reading) the normal `~/.ssh` case.
122
- */
123
- function expandIncludePath(pattern, callerDir, listDir) {
124
- const raw = stripQuotes(pattern)
125
- if (!raw) return []
126
- if (path.isAbsolute(raw)) return globFiles(raw, listDir).sort()
127
- if (raw.startsWith('~')) return globFiles(path.join(homedir(), raw.replace(/^~[\\/]?/, '')), listDir).sort()
128
- const primary = globFiles(path.join(sshConfigDir(), raw), listDir)
129
- if (primary.length) return primary.sort()
130
- if (callerDir && path.resolve(callerDir) !== path.resolve(sshConfigDir())) {
131
- return globFiles(path.join(callerDir, raw), listDir).sort()
132
- }
133
- return []
134
- }
135
-
136
- /** Files matching a path whose basename may contain `*` / `?` (one level). */
137
- function globFiles(target, listDir) {
138
- const base = path.basename(target)
139
- if (!/[*?]/.test(base)) return [target]
140
- const dir = path.dirname(target)
141
- let entries
142
- try {
143
- entries = listDir(dir)
144
- } catch {
145
- return []
146
- }
147
- const re = globRegex(base)
148
- return entries
149
- .filter((name) => re.test(name))
150
- .map((name) => path.join(dir, name))
151
- }
152
-
153
- /** Join lines ending in a backslash (ssh config line continuation). */
154
- function joinContinuations(text) {
155
- const lines = []
156
- let pending = ''
157
- for (const raw of String(text).split('\n')) {
158
- const line = pending + raw
159
- if (/\\\s*$/.test(line) && !/\\\\\s*$/.test(line)) {
160
- pending = line.replace(/\\\s*$/, ' ') + ' '
161
- continue
162
- }
163
- pending = ''
164
- lines.push(line)
165
- }
166
- if (pending) lines.push(pending)
167
- return lines
168
- }
169
-
170
- /** Config lines with continuations joined — the form every parser consumes. */
171
- const configLines = (text) => joinContinuations(text)
172
-
173
- /** Split a parameter value into whitespace-separated tokens, honoring quotes. */
174
- export function splitValue(value) {
175
- return (String(value || '').match(/"[^"]*"|'[^']*'|\S+/g) || [])
176
- .map(stripQuotes)
177
- .filter((token) => token !== '')
178
- }
179
-
180
- const stripQuotes = (s) => String(s || '').replace(/^"(.*)"$/s, '$1').replace(/^'(.*)'$/s, '$1')
181
-
182
- /**
183
- * Parse OpenSSH config text into Host BLOCKS, preserving pattern lists.
184
- * @param {string} text
185
- * @returns {Array<{patterns: string[], values: Record<string, string|number>, line: number}>}
186
- */
187
- export function parseHostBlocks(text) {
188
- const blocks = []
189
- let current = null
190
- const lines = configLines(text)
191
- for (let i = 0; i < lines.length; i += 1) {
192
- const line = lines[i].trim()
193
- if (!line || line.startsWith('#')) continue
194
- const m = line.match(/^(\S+)\s+(.*)$/)
195
- if (!m) continue
196
- const key = m[1].toLowerCase()
197
- const value = m[2].trim()
198
- if (key === 'host') {
199
- current = { patterns: splitValue(value), values: {}, line: i + 1 }
200
- blocks.push(current)
201
- continue
202
- }
203
- if (!current || !KNOWN_PARAMS.has(key)) continue
204
- // First value wins inside a block too (a repeated parameter is ignored).
205
- if (Object.prototype.hasOwnProperty.call(current.values, key)) continue
206
- current.values[key] = key === 'port' ? (Number(value) || value) : stripQuotes(value)
207
- }
208
- return blocks
209
- }
210
-
211
- /**
212
- * Legacy block list: one entry per `Host` line, flattened to the fields the
213
- * import UI shows. Kept stable for the existing importer + tests.
214
- * @returns {Array<{host: string, hostName: string, user: string, port: number, identityFile: string, proxyJump: string}>}
215
- */
216
- export function parseSshConfig(text) {
217
- const entries = []
218
- let cur = null
219
- for (const raw of configLines(text)) {
220
- const line = raw.trim()
221
- if (!line || line.startsWith('#')) continue
222
- const m = line.match(/^(\S+)\s+(.*)$/)
223
- if (!m) continue
224
- const key = m[1].toLowerCase()
225
- const val = m[2].trim()
226
- if (key === 'host') {
227
- cur = { host: stripQuotes(val), hostName: '', user: '', port: 22, identityFile: '', proxyJump: '' }
228
- entries.push(cur)
229
- continue
230
- }
231
- if (!cur) continue
232
- if (key === 'hostname' && !cur.hostName) cur.hostName = stripQuotes(val)
233
- else if (key === 'user' && !cur.user) cur.user = stripQuotes(val)
234
- else if (key === 'port' && cur.port === 22) cur.port = Number(val) || 22
235
- else if (key === 'identityfile' && !cur.identityFile) cur.identityFile = stripQuotes(val)
236
- else if (key === 'proxyjump' && !cur.proxyJump) cur.proxyJump = stripQuotes(val)
237
- }
238
- return entries
239
- }
240
-
241
- /** Entries a user can actually import: concrete aliases (skip wildcards). */
242
- export function importableEntries(text) {
243
- return parseSshConfig(text).filter((e) => {
244
- if (!e.host || e.host.includes('*') || e.host.includes('!') || e.host.includes(',')) return false
245
- return e.hostName || e.user || e.port !== 22 || e.identityFile || e.proxyJump
246
- })
247
- }
248
-
249
- /**
250
- * Every concrete (non-wildcard) alias the config defines, with the effective
251
- * values after OpenSSH first-wins resolution — the list the import UI offers.
252
- * @param {string} text
253
- * @returns {Array<{alias: string, hostName: string, user: string, port: number, identityFile: string, proxyJump: string, proxyCommand: string}>}
254
- */
255
- export function listAliases(text) {
256
- const seen = new Set()
257
- const out = []
258
- for (const block of parseHostBlocks(text)) {
259
- for (const pattern of block.patterns) {
260
- if (!pattern || /[*?!]/.test(pattern) || seen.has(pattern)) continue
261
- seen.add(pattern)
262
- out.push(resolveSshHost(pattern, text))
263
- }
264
- }
265
- return out.sort((a, b) => compareText(a.alias, b.alias))
266
- }
267
-
268
- /** Glob match for ssh host patterns (`*`, `?`), case-insensitive. */
269
- export function globMatch(pattern, value) {
270
- return globRegex(pattern).test(String(value || ''))
271
- }
272
-
273
- function globRegex(pattern) {
274
- let re = ''
275
- for (const ch of String(pattern || '')) {
276
- if (ch === '*') re += '.*'
277
- else if (ch === '?') re += '.'
278
- else re += ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
279
- }
280
- return new RegExp(`^${re}$`, 'i')
281
- }
282
-
283
- /**
284
- * Whether a `Host` pattern list applies to a name: any positive pattern matches
285
- * AND no negated (`!`) pattern does.
286
- * @param {string[]|string} patterns
287
- * @param {string} name
288
- */
289
- export function hostBlockMatches(patterns, name) {
290
- const list = Array.isArray(patterns) ? patterns : splitValue(patterns)
291
- const negative = list.filter((p) => p.startsWith('!')).map((p) => p.slice(1))
292
- const positive = list.filter((p) => !p.startsWith('!'))
293
- if (negative.some((p) => globMatch(p, name))) return false
294
- return positive.some((p) => globMatch(p, name))
295
- }
296
-
297
- /**
298
- * Effective configuration for one alias, with OpenSSH first-wins semantics.
299
- *
300
- * `hostName` falls back to the alias itself (ssh's own behaviour), so a block
301
- * that only sets `User`/`IdentityFile` still yields a connectable host.
302
- *
303
- * @param {string} alias
304
- * @param {string} text - FULL config text (Include-expanded).
305
- * @returns {{alias: string, matched: boolean, hostName: string, user: string, port: number, identityFile: string, proxyJump: string, proxyCommand: string, identitiesOnly: string}}
306
- */
307
- export function resolveSshHost(alias, text) {
308
- const name = String(alias || '').trim()
309
- const out = {
310
- alias: name,
311
- matched: false,
312
- hostName: '',
313
- user: '',
314
- port: 0,
315
- identityFile: '',
316
- proxyJump: '',
317
- proxyCommand: '',
318
- identitiesOnly: '',
319
- }
320
- if (!name) return out
321
- for (const block of parseHostBlocks(text)) {
322
- if (!hostBlockMatches(block.patterns, name)) continue
323
- out.matched = true
324
- // first obtained value wins, ACROSS blocks in file order
325
- for (const [key, value] of Object.entries(block.values)) {
326
- if (key === 'hostname') { if (!out.hostName) out.hostName = String(value) }
327
- else if (key === 'user') { if (!out.user) out.user = String(value) }
328
- else if (key === 'port') { if (!out.port) out.port = Number(value) || 0 }
329
- else if (key === 'identityfile') { if (!out.identityFile) out.identityFile = String(value) }
330
- else if (key === 'proxyjump') { if (!out.proxyJump) out.proxyJump = String(value) }
331
- else if (key === 'proxycommand') { if (!out.proxyCommand) out.proxyCommand = String(value) }
332
- else if (key === 'identitiesonly') { if (!out.identitiesOnly) out.identitiesOnly = String(value) }
333
- }
334
- }
335
- if (!out.hostName) out.hostName = name
336
- return out
337
- }
338
-
339
- /**
340
- * Resolve an alias from a config FILE (Include-expanded) or from given text.
341
- * @param {string} alias
342
- * @param {{file?: string, text?: string, io?: object}} [opts]
343
- */
344
- export function resolveSshAlias(alias, opts = {}) {
345
- const text = opts.text !== undefined ? String(opts.text) : loadSshConfigText(opts.file || sshConfigPath(), opts.io || {})
346
- return resolveSshHost(alias, text)
347
- }
348
-
349
- /** Expand a leading `~` in an ssh config path (IdentityFile). */
350
- export function expandHome(p) {
351
- const s = String(p || '')
352
- if (!s.startsWith('~')) return s
353
- if (s === '~') return homedir()
354
- if (s.startsWith('~/') || s.startsWith('~\\')) return path.join(homedir(), s.slice(2))
355
- return s
356
- }
357
-
358
- /**
359
- * Split a ProxyJump value into hops: `[user@]host[:port]`, comma-separated, and
360
- * `none` meaning "no jump".
361
- * @param {string} value
362
- * @returns {Array<{user: string, host: string, port: number}>}
363
- */
364
- export function parseProxyJump(value) {
365
- const raw = String(value || '').trim()
366
- if (!raw || /^none$/i.test(raw)) return []
367
- return raw.split(',').map((hop) => hop.trim()).filter(Boolean).map((hop) => {
368
- const m = hop.match(/^(?:([^@]+)@)?([^:[\] ]+)(?::(\d+))?$/)
369
- if (!m) return { user: '', host: hop, port: 0 }
370
- return { user: m[1] || '', host: m[2], port: Number(m[3]) || 0 }
371
- })
372
- }
373
-
374
- /**
375
- * Shared proxy/warning shaping for alias and literal resolutions (issue #50).
376
- *
377
- * `ProxyJump` maps onto the plugin's single jump host. A multi-hop chain cannot
378
- * be represented by one `proxy`, so the FIRST hop is used and `warnings` says so
379
- * — silently connecting through the wrong path would be worse. `ProxyCommand`
380
- * (an arbitrary local command) is reported but never executed.
381
- */
382
- function targetFromResolved(resolved, text, { defaultPort }) {
383
- const warnings = []
384
- let proxy
385
- const hops = parseProxyJump(resolved.proxyJump)
386
- if (hops.length > 1) {
387
- warnings.push(`ProxyJump has ${hops.length} hops; dsh-remote supports one jump host — using the first (${hops[0].host}).`)
388
- }
389
- if (hops.length) {
390
- const hop = hops[0]
391
- const hopResolved = resolveSshHost(hop.host, text)
392
- proxy = {
393
- host: hopResolved.hostName || hop.host,
394
- port: hop.port || hopResolved.port || 22,
395
- username: hop.user || hopResolved.user || '',
396
- privateKeyPath: hopResolved.identityFile ? expandHome(hopResolved.identityFile) : '',
397
- passphrase: '',
398
- password: '',
399
- }
400
- } else if (resolved.proxyCommand) {
401
- warnings.push('ProxyCommand is not supported by dsh-remote (no jump host was applied).')
402
- }
403
- return {
404
- alias: resolved.alias,
405
- matched: resolved.matched,
406
- host: resolved.hostName || resolved.alias,
407
- port: resolved.port || defaultPort,
408
- username: resolved.user || '',
409
- privateKeyPath: resolved.identityFile ? expandHome(resolved.identityFile) : '',
410
- proxy,
411
- proxyJump: resolved.proxyJump,
412
- warnings,
413
- }
414
- }
415
-
416
- /**
417
- * Connect target for an ssh-config alias: the shape the plugin's pool expects.
418
- *
419
- * @param {string} alias
420
- * @param {object} [opts]
421
- * @param {string} [opts.text] - config text (else the real file is read).
422
- * @param {string} [opts.file]
423
- * @param {object} [opts.io]
424
- * @returns {{alias: string, matched: boolean, host: string, port: number, username: string, privateKeyPath: string, proxy: object|undefined, proxyJump: string, warnings: string[]}}
425
- */
426
- export function sshAliasTarget(alias, opts = {}) {
427
- const text = opts.text !== undefined ? String(opts.text) : loadSshConfigText(opts.file || sshConfigPath(), opts.io || {})
428
- return targetFromResolved(resolveSshHost(alias, text), text, { defaultPort: 22 })
429
- }
430
-
431
- /**
432
- * Effective connect identity for a LITERAL machine (issue #50).
433
- *
434
- * A machine saved with literal host/user/port/key holds a COPY of what may also
435
- * be written in ~/.ssh/config, and the copy goes stale the moment the file is
436
- * edited. The file is the single source for any host it NAMES: when a Host
437
- * block matches the machine's literal host — with at least one positive pattern
438
- * that is not the bare `*` — the resolved values win per field and the record
439
- * fills the gaps. A bare `Host *` block deliberately does NOT name a host:
440
- * treating universal defaults as an override would hijack every saved machine
441
- * at once. Group patterns (`*.corp`, `web-?`) DO name hosts, exactly as ssh
442
- * itself applies them to a literal dial.
443
- *
444
- * `port` is 0 (not 22) when the file says nothing, so the caller falls back to
445
- * the record instead of the default.
446
- *
447
- * @param {object} machine - a registry record with a literal `host`.
448
- * @param {{text?: string, file?: string, io?: object}} [opts]
449
- * @returns {null | {alias: string, matched: true, host: string, port: number, username: string, privateKeyPath: string, proxy: object|undefined, proxyJump: string, warnings: string[]}}
450
- * null when no Host block names the machine's host.
451
- */
452
- export function resolveLiteralSshConfig(machine, opts = {}) {
453
- if (!machine || machine.useSshConfig) return null
454
- const name = String(machine.host || '').trim()
455
- if (!name) return null
456
- const text = opts.text !== undefined ? String(opts.text) : loadSshConfigText(opts.file || sshConfigPath(), opts.io || {})
457
- const resolved = {
458
- alias: name, matched: false, hostName: '', user: '', port: 0,
459
- identityFile: '', proxyJump: '', proxyCommand: '', identitiesOnly: '',
460
- }
461
- for (const block of parseHostBlocks(text)) {
462
- if (!hostBlockMatches(block.patterns, name)) continue
463
- const positive = block.patterns.filter((p) => !p.startsWith('!'))
464
- // A block whose only positive pattern is the bare `*` is a defaults block,
465
- // not a block that names this host.
466
- if (positive.length && positive.every((p) => p === '*')) continue
467
- resolved.matched = true
468
- for (const [key, value] of Object.entries(block.values)) {
469
- if (key === 'hostname') { if (!resolved.hostName) resolved.hostName = String(value) }
470
- else if (key === 'user') { if (!resolved.user) resolved.user = String(value) }
471
- else if (key === 'port') { if (!resolved.port) resolved.port = Number(value) || 0 }
472
- else if (key === 'identityfile') { if (!resolved.identityFile) resolved.identityFile = String(value) }
473
- else if (key === 'proxyjump') { if (!resolved.proxyJump) resolved.proxyJump = String(value) }
474
- else if (key === 'proxycommand') { if (!resolved.proxyCommand) resolved.proxyCommand = String(value) }
475
- else if (key === 'identitiesonly') { if (!resolved.identitiesOnly) resolved.identitiesOnly = String(value) }
476
- }
477
- }
478
- if (!resolved.matched) return null
479
- return targetFromResolved(resolved, text, { defaultPort: 0 })
480
- }
481
-
482
- /**
483
- * Effective connect identity for a saved machine.
484
- *
485
- * A machine in ALIAS mode (`useSshConfig: true`, `host` = the alias) inherits
486
- * everything from ~/.ssh/config; a field the user filled in explicitly still
487
- * wins. "Explicitly" cannot be distinguished from "form default" for the two
488
- * fields that have defaults (port 22, user `root`), so those inherit unless they
489
- * differ from the default — stated in the settings UI.
490
- *
491
- * @param {object} machine
492
- * @param {{text?: string, file?: string, io?: object}} [opts]
493
- * @returns {{alias: string, matched: boolean, host: string, port: number, username: string, privateKeyPath: string, proxy: object|undefined, warnings: string[]}|null}
494
- * null when the machine is not in alias mode.
495
- */
496
- export function resolveMachineSshConfig(machine, opts = {}) {
497
- if (!machine || !machine.useSshConfig) return null
498
- const alias = String(machine.host || '').trim()
499
- if (!alias) return null
500
- const target = sshAliasTarget(alias, opts)
501
- const warnings = [...target.warnings]
502
- if (!target.matched) warnings.push(`no ~/.ssh/config block matches "${alias}" — using the name itself.`)
503
- const port = machine.port && Number(machine.port) !== 22 ? Number(machine.port) : target.port
504
- const username = machine.username && machine.username !== 'root' ? String(machine.username) : (target.username || machine.username || '')
505
- const privateKeyPath = machine.privateKeyPath ? String(machine.privateKeyPath) : target.privateKeyPath
506
- const proxy = machine.proxy && machine.proxy.host ? machine.proxy : target.proxy
507
- return {
508
- alias,
509
- matched: target.matched,
510
- host: target.host,
511
- port: Number(port) || 22,
512
- username: String(username || ''),
513
- privateKeyPath,
514
- proxy,
515
- warnings,
516
- }
517
- }
518
-
519
- function compareText(left, right) {
520
- return left < right ? -1 : left > right ? 1 : 0
521
- }
522
-
523
- // ── Writing: the managed Host block (issue #50) ─────────────────────────────
524
- //
525
- // The four connection fields (host/user/port/key) have ONE home: ~/.ssh/config.
526
- // When the plugin saves a literal machine it upserts a Host block tagged with
527
- // MANAGED_MARK, so the file and the registry can never drift — and so the
528
- // plugin can tell ITS OWN block apart from one the user wrote. Two rules keep
529
- // the file safe:
530
- // • a Host block without the marker is never touched — a same-named user
531
- // block is a CONFLICT, reported to the caller instead of overwritten;
532
- // • inside a managed block, lines the plugin does not own (a user-added
533
- // `ForwardAgent yes`, a comment) survive an update — only the four known
534
- // parameters are rewritten.
535
-
536
- /** Marker comment that tags a Host block as owned by dsh-remote. */
537
- export const MANAGED_MARK = '# dsh-remote: managed'
538
-
539
- /** Parameters the managed block rewrites (lowercase). */
540
- const MANAGED_PARAMS = new Set(['hostname', 'user', 'port', 'identityfile'])
541
-
542
- /** Read the RAW config text — no Include expansion (a write targets ONE file). */
543
- export function readRawSshConfig(filePath = sshConfigPath()) {
544
- try {
545
- const text = readFileSync(filePath, 'utf8')
546
- return typeof text === 'string' ? text : ''
547
- } catch {
548
- return ''
549
- }
550
- }
551
-
552
- /** Quote one value for a config line when it contains whitespace. */
553
- function quoteCfg(value) {
554
- const s = String(value)
555
- return /[\s#]/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s
556
- }
557
-
558
- /** The values a managed block must carry for these fields ('' = omit). */
559
- function desiredValues(fields) {
560
- const alias = String(fields.alias || '').trim()
561
- const hostName = String(fields.hostName || '').trim()
562
- const port = Number(fields.port) || 0
563
- return {
564
- hostname: hostName && hostName !== alias ? hostName : '',
565
- user: String(fields.user || '').trim(),
566
- port: port && port !== 22 ? port : '',
567
- identityfile: String(fields.identityFile || '').trim(),
568
- }
569
- }
570
-
571
- /** Render the owned body lines of a managed block. */
572
- function desiredLines(fields) {
573
- const want = desiredValues(fields)
574
- const out = []
575
- if (want.hostname) out.push(` HostName ${quoteCfg(want.hostname)}`)
576
- if (want.user) out.push(` User ${quoteCfg(want.user)}`)
577
- if (want.port) out.push(` Port ${want.port}`)
578
- if (want.identityfile) out.push(` IdentityFile ${quoteCfg(want.identityfile)}`)
579
- return out
580
- }
581
-
582
- /**
583
- * Locate the MANAGED Host block for `alias` in raw config lines.
584
- * The marker must be the nearest non-blank line above the Host line; a block
585
- * that names the alias without the marker is the user's, not ours.
586
- */
587
- function locateManagedBlock(lines, alias) {
588
- const wanted = alias.toLowerCase()
589
- for (let i = 0; i < lines.length; i += 1) {
590
- const m = lines[i].match(/^\s*host\s+(.+?)\s*$/i)
591
- if (!m) continue
592
- const names = splitValue(m[1]).map((p) => p.toLowerCase())
593
- if (!names.includes(wanted)) continue
594
- let j = i - 1
595
- while (j >= 0 && lines[j].trim() === '') j -= 1
596
- if (j < 0 || lines[j].trim().toLowerCase() !== MANAGED_MARK) continue
597
- let end = lines.length
598
- for (let k = i + 1; k < lines.length; k += 1) {
599
- if (/^\s*(host|match)\s/i.test(lines[k])) { end = k; break }
600
- }
601
- return { markerLine: j, hostLine: i, end }
602
- }
603
- return null
604
- }
605
-
606
- /** Whether an UNMANAGED Host block names `alias` (exact token, case-insensitive). */
607
- function hasUnmanagedConflict(lines, alias) {
608
- const wanted = alias.toLowerCase()
609
- for (let i = 0; i < lines.length; i += 1) {
610
- const m = lines[i].match(/^\s*host\s+(.+?)\s*$/i)
611
- if (!m) continue
612
- const names = splitValue(m[1]).map((p) => p.toLowerCase())
613
- if (!names.includes(wanted)) continue
614
- let j = i - 1
615
- while (j >= 0 && lines[j].trim() === '') j -= 1
616
- if (j >= 0 && lines[j].trim().toLowerCase() === MANAGED_MARK) continue // ours
617
- return true
618
- }
619
- return false
620
- }
621
-
622
- /**
623
- * Insert or refresh the managed Host block for a saved machine.
624
- *
625
- * @param {string} rawText - the RAW ~/.ssh/config text ('' when absent).
626
- * @param {{alias: string, hostName?: string, user?: string, port?: number|string, identityFile?: string}} fields
627
- * @returns {{status: 'appended'|'updated'|'unchanged'|'conflict'|'invalid', text: string}}
628
- * 'conflict' — an unmanaged block already names the alias (text untouched);
629
- * 'invalid' — the alias can never be a safe Host token (space/wildcard).
630
- */
631
- export function upsertManagedHostBlock(rawText, fields) {
632
- const text = String(rawText || '')
633
- const alias = String((fields && fields.alias) || '').trim()
634
- if (!alias || /[\s*?!,]/.test(alias)) return { status: 'invalid', text }
635
- const lines = text.split('\n')
636
- if (hasUnmanagedConflict(lines, alias)) return { status: 'conflict', text }
637
-
638
- const want = desiredValues({ ...fields, alias })
639
- const found = locateManagedBlock(lines, alias)
640
- if (!found) {
641
- const block = [MANAGED_MARK, `Host ${alias}`, ...desiredLines({ ...fields, alias })]
642
- const trimmedEnd = text.replace(/\s+$/, '')
643
- const prefix = trimmedEnd === '' ? '' : trimmedEnd + '\n\n'
644
- return { status: 'appended', text: prefix + block.join('\n') + '\n' }
645
- }
646
-
647
- // Compare what the block HAS with what it MUST have; a rewrite that changes
648
- // nothing would only cost the file's mtime.
649
- const current = parseHostBlocks(lines.slice(found.hostLine, found.end).join('\n'))[0]
650
- const cur = (current && current.values) || {}
651
- const same = ['hostname', 'user', 'port', 'identityfile']
652
- .every((k) => String(cur[k] ?? '') === String(want[k] ?? ''))
653
- if (same) return { status: 'unchanged', text }
654
-
655
- // Rewrite only the four owned parameters; user lines inside OUR block stay.
656
- const body = lines.slice(found.hostLine + 1, found.end)
657
- const kept = body.filter((l) => {
658
- const m = l.match(/^\s*(\S+)\s/)
659
- return !m || !MANAGED_PARAMS.has(m[1].toLowerCase())
660
- })
661
- const next = [
662
- ...lines.slice(0, found.hostLine + 1),
663
- ...desiredLines({ ...fields, alias }),
664
- ...kept,
665
- ...lines.slice(found.end),
666
- ]
667
- return { status: 'updated', text: next.join('\n') }
668
- }
669
-
670
- /**
671
- * Remove the managed block for `alias` (used when a machine is RENAMED away:
672
- * the old block is the plugin's own artifact of that edit). Deleting a machine
673
- * deliberately does NOT call this — the Host block is the user's ssh access,
674
- * not plugin state. Unmanaged blocks are never removed.
675
- *
676
- * @returns {{status: 'removed'|'absent', text: string}}
677
- */
678
- export function removeManagedHostBlock(rawText, alias) {
679
- const text = String(rawText || '')
680
- const name = String(alias || '').trim()
681
- if (!name) return { status: 'absent', text }
682
- const lines = text.split('\n')
683
- const found = locateManagedBlock(lines, name)
684
- if (!found) return { status: 'absent', text }
685
- let start = found.markerLine
686
- while (start > 0 && lines[start - 1].trim() === '') start -= 1
687
- const next = [...lines.slice(0, start), ...lines.slice(found.end)]
688
- return { status: 'removed', text: next.join('\n') }
689
- }
690
-
691
- /**
692
- * Persist a config text, creating ~/.ssh first. Permissions follow OpenSSH's
693
- * expectations on POSIX (dir 0700, file 0600) and are a no-op elsewhere.
694
- */
695
- export function writeSshConfig(text, filePath = sshConfigPath(), io = {}) {
696
- const mkdir = io.mkdir || ((p) => mkdirSync(p, { recursive: true }))
697
- const write = io.write || ((p, data) => writeFileSync(p, data, 'utf8'))
698
- const chmod = io.chmod || ((p, mode) => { try { chmodSync(p, mode) } catch { /* no modes here */ } })
699
- mkdir(path.dirname(filePath))
700
- write(filePath, String(text))
701
- if (process.platform !== 'win32') {
702
- chmod(path.dirname(filePath), 0o700)
703
- chmod(filePath, 0o600)
704
- }
705
- }
1
+ // dsh-remote — ~/.ssh/config reading and OpenSSH alias resolution (issue #38).
2
+ //
3
+ // ## Why this module exists
4
+ //
5
+ // A saved machine can be an ALIAS from the user's ssh config: the registry keeps
6
+ // `{ host: '<alias>', useSshConfig: true }` and NOTHING else — no HostName, no
7
+ // user, no port, no key path. Every connect resolves that alias against
8
+ // `~/.ssh/config` as it is RIGHT NOW, which is what VSCode Remote-SSH does with
9
+ // the same file: editing the config (a new jump host, a rotated IdentityFile, a
10
+ // different port) is picked up without re-importing anything into the plugin.
11
+ //
12
+ // ## What is read
13
+ //
14
+ // Host / HostName / User / Port / IdentityFile / ProxyJump / ProxyCommand /
15
+ // IdentityAgent / IdentitiesOnly. `IdentityFile` is imported as a PATH
16
+ // reference only — the plugin never opens key material (v0.5.5 policy); its
17
+ // `%n`-style tokens are expanded (expandSshTokens). A `.pub` IdentityFile is
18
+ // OpenSSH's "use THIS agent key" idiom, which the pool honours.
19
+ //
20
+ // ## OpenSSH semantics implemented
21
+ //
22
+ // • `Host a b` / `Host *.example.com` / `Host !x *.y` pattern lists, evaluated
23
+ // with `*` and `?` wildcards, case-insensitively (see hostBlockMatches).
24
+ // • first-obtained-value-wins ACROSS all matching blocks, not first-block-wins:
25
+ // ssh_config(5) — "for each parameter, the first obtained value will be used".
26
+ // • `Include` directives, globbed, expanded in place, relative to ~/.ssh
27
+ // (falling back to the including file's directory), depth- and cycle-limited.
28
+ // • backslash line continuations.
29
+ //
30
+ // The module is pure (I/O is injectable), so it is unit-testable without a real
31
+ // home directory — see test/sshconfig.test.js.
32
+
33
+ import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync, chmodSync } from 'node:fs'
34
+ import { homedir, hostname as osHostname, userInfo } from 'node:os'
35
+ import path from 'node:path'
36
+
37
+ /** Default location of the user's ssh config. An env override exists so tests
38
+ * (and any deployment that isolates its home directory) can point the plugin
39
+ * at a substitute file instead of the real ~/.ssh/config. */
40
+ export const sshConfigPath = () => {
41
+ const env = process.env.DSH_REMOTE_SSH_CONFIG
42
+ if (env && String(env).trim()) return path.resolve(String(env).trim())
43
+ return path.join(homedir(), '.ssh', 'config')
44
+ }
45
+
46
+ /** Directory `Include` paths are resolved against (ssh_config(5)). */
47
+ export const sshConfigDir = () => {
48
+ const env = process.env.DSH_REMOTE_SSH_CONFIG
49
+ if (env && String(env).trim()) return path.dirname(path.resolve(String(env).trim()))
50
+ return path.join(homedir(), '.ssh')
51
+ }
52
+
53
+ /** Parameters the resolver cares about, lowercase (as parsed). */
54
+ const KNOWN_PARAMS = new Set([
55
+ 'hostname', 'user', 'port', 'identityfile', 'proxyjump', 'proxycommand', 'identitiesonly',
56
+ 'identityagent',
57
+ ])
58
+
59
+ /** Read the user's ~/.ssh/config text, Includes expanded ('' when absent). */
60
+ export function readSshConfigText(filePath = sshConfigPath()) {
61
+ return loadSshConfigText(filePath)
62
+ }
63
+
64
+ /**
65
+ * Read an ssh config file and expand its `Include` directives in place.
66
+ *
67
+ * Relative includes resolve against `~/.ssh` first (ssh_config(5)) and then
68
+ * against the including file's directory, because real-world configs use both.
69
+ * A missing or unreadable include is skipped: one broken line must not hide the
70
+ * rest of the config.
71
+ *
72
+ * @param {string} [filePath] - the config file (default ~/.ssh/config).
73
+ * @param {object} [io] - injectable filesystem hooks (tests).
74
+ * @param {(p: string) => string} [io.readFile]
75
+ * @param {(p: string) => boolean} [io.exists]
76
+ * @param {(p: string) => string[]} [io.listDir]
77
+ * @param {number} [io.maxDepth]
78
+ * @returns {string} the expanded config text ('' when nothing could be read).
79
+ */
80
+ export function loadSshConfigText(filePath = sshConfigPath(), io = {}) {
81
+ const readFile = io.readFile || ((p) => readFileSync(p, 'utf8'))
82
+ const listDir = io.listDir || ((p) => readdirSync(p))
83
+ return expandFile(filePath, { readFile, listDir, maxDepth: io.maxDepth ?? 3, depth: 0, seen: new Set() })
84
+ }
85
+
86
+ function expandFile(file, state) {
87
+ let text
88
+ try {
89
+ text = state.readFile(file)
90
+ } catch {
91
+ return ''
92
+ }
93
+ if (typeof text !== 'string') return ''
94
+ if (state.depth >= state.maxDepth) return text
95
+ const out = []
96
+ for (const line of joinContinuations(text)) {
97
+ const patterns = matchInclude(line)
98
+ if (!patterns) { out.push(line); continue }
99
+ for (const pattern of patterns) {
100
+ for (const included of expandIncludePath(pattern, path.dirname(file), state.listDir)) {
101
+ const key = path.resolve(included)
102
+ if (state.seen.has(key)) continue
103
+ state.seen.add(key)
104
+ const child = expandFile(included, { ...state, depth: state.depth + 1 })
105
+ if (child.trim()) out.push(child)
106
+ }
107
+ }
108
+ }
109
+ return out.join('\n')
110
+ }
111
+
112
+ /** The value tokens of `Include <pattern> [pattern...]`, or null. */
113
+ function matchInclude(line) {
114
+ const m = String(line).match(/^\s*include\s+(.+?)\s*$/i)
115
+ if (!m) return null
116
+ return splitValue(m[1])
117
+ }
118
+
119
+ /** Expand one include pattern.
120
+ *
121
+ * ssh_config(5): a relative path is relative to `~/.ssh`. The including file's
122
+ * own directory is tried only when that yields nothing — which is what makes
123
+ * the plugin able to read a config the user pointed it at elsewhere, without
124
+ * double-resolving (and double-reading) the normal `~/.ssh` case.
125
+ */
126
+ function expandIncludePath(pattern, callerDir, listDir) {
127
+ const raw = stripQuotes(pattern)
128
+ if (!raw) return []
129
+ if (path.isAbsolute(raw)) return globFiles(raw, listDir).sort()
130
+ if (raw.startsWith('~')) return globFiles(path.join(homedir(), raw.replace(/^~[\\/]?/, '')), listDir).sort()
131
+ const primary = globFiles(path.join(sshConfigDir(), raw), listDir)
132
+ if (primary.length) return primary.sort()
133
+ if (callerDir && path.resolve(callerDir) !== path.resolve(sshConfigDir())) {
134
+ return globFiles(path.join(callerDir, raw), listDir).sort()
135
+ }
136
+ return []
137
+ }
138
+
139
+ /** Files matching a path whose basename may contain `*` / `?` (one level). */
140
+ function globFiles(target, listDir) {
141
+ const base = path.basename(target)
142
+ if (!/[*?]/.test(base)) return [target]
143
+ const dir = path.dirname(target)
144
+ let entries
145
+ try {
146
+ entries = listDir(dir)
147
+ } catch {
148
+ return []
149
+ }
150
+ const re = globRegex(base)
151
+ return entries
152
+ .filter((name) => re.test(name))
153
+ .map((name) => path.join(dir, name))
154
+ }
155
+
156
+ /** Join lines ending in a backslash (ssh config line continuation). */
157
+ function joinContinuations(text) {
158
+ const lines = []
159
+ let pending = ''
160
+ for (const raw of String(text).split('\n')) {
161
+ const line = pending + raw
162
+ if (/\\\s*$/.test(line) && !/\\\\\s*$/.test(line)) {
163
+ pending = line.replace(/\\\s*$/, ' ') + ' '
164
+ continue
165
+ }
166
+ pending = ''
167
+ lines.push(line)
168
+ }
169
+ if (pending) lines.push(pending)
170
+ return lines
171
+ }
172
+
173
+ /** Config lines with continuations joined — the form every parser consumes. */
174
+ const configLines = (text) => joinContinuations(text)
175
+
176
+ /** Split a parameter value into whitespace-separated tokens, honoring quotes. */
177
+ export function splitValue(value) {
178
+ return (String(value || '').match(/"[^"]*"|'[^']*'|\S+/g) || [])
179
+ .map(stripQuotes)
180
+ .filter((token) => token !== '')
181
+ }
182
+
183
+ const stripQuotes = (s) => String(s || '').replace(/^"(.*)"$/s, '$1').replace(/^'(.*)'$/s, '$1')
184
+
185
+ /**
186
+ * Parse OpenSSH config text into Host BLOCKS, preserving pattern lists.
187
+ * @param {string} text
188
+ * @returns {Array<{patterns: string[], values: Record<string, string|number>, line: number}>}
189
+ */
190
+ export function parseHostBlocks(text) {
191
+ const blocks = []
192
+ let current = null
193
+ const lines = configLines(text)
194
+ for (let i = 0; i < lines.length; i += 1) {
195
+ const line = lines[i].trim()
196
+ if (!line || line.startsWith('#')) continue
197
+ const m = line.match(/^(\S+)\s+(.*)$/)
198
+ if (!m) continue
199
+ const key = m[1].toLowerCase()
200
+ const value = m[2].trim()
201
+ if (key === 'host') {
202
+ current = { patterns: splitValue(value), values: {}, line: i + 1 }
203
+ blocks.push(current)
204
+ continue
205
+ }
206
+ if (!current || !KNOWN_PARAMS.has(key)) continue
207
+ // First value wins inside a block too (a repeated parameter is ignored).
208
+ if (Object.prototype.hasOwnProperty.call(current.values, key)) continue
209
+ current.values[key] = key === 'port' ? (Number(value) || value) : stripQuotes(value)
210
+ }
211
+ return blocks
212
+ }
213
+
214
+ /**
215
+ * Legacy block list: one entry per `Host` line, flattened to the fields the
216
+ * import UI shows. Kept stable for the existing importer + tests.
217
+ * @returns {Array<{host: string, hostName: string, user: string, port: number, identityFile: string, proxyJump: string}>}
218
+ */
219
+ export function parseSshConfig(text) {
220
+ const entries = []
221
+ let cur = null
222
+ for (const raw of configLines(text)) {
223
+ const line = raw.trim()
224
+ if (!line || line.startsWith('#')) continue
225
+ const m = line.match(/^(\S+)\s+(.*)$/)
226
+ if (!m) continue
227
+ const key = m[1].toLowerCase()
228
+ const val = m[2].trim()
229
+ if (key === 'host') {
230
+ cur = { host: stripQuotes(val), hostName: '', user: '', port: 22, identityFile: '', proxyJump: '' }
231
+ entries.push(cur)
232
+ continue
233
+ }
234
+ if (!cur) continue
235
+ if (key === 'hostname' && !cur.hostName) cur.hostName = stripQuotes(val)
236
+ else if (key === 'user' && !cur.user) cur.user = stripQuotes(val)
237
+ else if (key === 'port' && cur.port === 22) cur.port = Number(val) || 22
238
+ else if (key === 'identityfile' && !cur.identityFile) cur.identityFile = stripQuotes(val)
239
+ else if (key === 'proxyjump' && !cur.proxyJump) cur.proxyJump = stripQuotes(val)
240
+ }
241
+ return entries
242
+ }
243
+
244
+ /** Entries a user can actually import: concrete aliases (skip wildcards). */
245
+ export function importableEntries(text) {
246
+ return parseSshConfig(text).filter((e) => {
247
+ if (!e.host || e.host.includes('*') || e.host.includes('!') || e.host.includes(',')) return false
248
+ return e.hostName || e.user || e.port !== 22 || e.identityFile || e.proxyJump
249
+ })
250
+ }
251
+
252
+ /**
253
+ * Every concrete (non-wildcard) alias the config defines, with the effective
254
+ * values after OpenSSH first-wins resolution — the list the import UI offers.
255
+ * @param {string} text
256
+ * @returns {Array<{alias: string, hostName: string, user: string, port: number, identityFile: string, proxyJump: string, proxyCommand: string}>}
257
+ */
258
+ export function listAliases(text) {
259
+ const seen = new Set()
260
+ const out = []
261
+ for (const block of parseHostBlocks(text)) {
262
+ for (const pattern of block.patterns) {
263
+ if (!pattern || /[*?!]/.test(pattern) || seen.has(pattern)) continue
264
+ seen.add(pattern)
265
+ out.push(resolveSshHost(pattern, text))
266
+ }
267
+ }
268
+ return out.sort((a, b) => compareText(a.alias, b.alias))
269
+ }
270
+
271
+ /** Glob match for ssh host patterns (`*`, `?`), case-insensitive. */
272
+ export function globMatch(pattern, value) {
273
+ return globRegex(pattern).test(String(value || ''))
274
+ }
275
+
276
+ function globRegex(pattern) {
277
+ let re = ''
278
+ for (const ch of String(pattern || '')) {
279
+ if (ch === '*') re += '.*'
280
+ else if (ch === '?') re += '.'
281
+ else re += ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
282
+ }
283
+ return new RegExp(`^${re}$`, 'i')
284
+ }
285
+
286
+ /**
287
+ * Whether a `Host` pattern list applies to a name: any positive pattern matches
288
+ * AND no negated (`!`) pattern does.
289
+ * @param {string[]|string} patterns
290
+ * @param {string} name
291
+ */
292
+ export function hostBlockMatches(patterns, name) {
293
+ const list = Array.isArray(patterns) ? patterns : splitValue(patterns)
294
+ const negative = list.filter((p) => p.startsWith('!')).map((p) => p.slice(1))
295
+ const positive = list.filter((p) => !p.startsWith('!'))
296
+ if (negative.some((p) => globMatch(p, name))) return false
297
+ return positive.some((p) => globMatch(p, name))
298
+ }
299
+
300
+ /**
301
+ * Effective configuration for one alias, with OpenSSH first-wins semantics.
302
+ *
303
+ * `hostName` falls back to the alias itself (ssh's own behaviour), so a block
304
+ * that only sets `User`/`IdentityFile` still yields a connectable host.
305
+ *
306
+ * @param {string} alias
307
+ * @param {string} text - FULL config text (Include-expanded).
308
+ * @returns {{alias: string, matched: boolean, hostName: string, user: string, port: number, identityFile: string, proxyJump: string, proxyCommand: string, identitiesOnly: string, identityAgent: string}}
309
+ */
310
+ export function resolveSshHost(alias, text) {
311
+ const name = String(alias || '').trim()
312
+ const out = {
313
+ alias: name,
314
+ matched: false,
315
+ hostName: '',
316
+ user: '',
317
+ port: 0,
318
+ identityFile: '',
319
+ proxyJump: '',
320
+ proxyCommand: '',
321
+ identitiesOnly: '',
322
+ identityAgent: '',
323
+ }
324
+ if (!name) return out
325
+ for (const block of parseHostBlocks(text)) {
326
+ if (!hostBlockMatches(block.patterns, name)) continue
327
+ out.matched = true
328
+ // first obtained value wins, ACROSS blocks in file order
329
+ for (const [key, value] of Object.entries(block.values)) {
330
+ if (key === 'hostname') { if (!out.hostName) out.hostName = String(value) }
331
+ else if (key === 'user') { if (!out.user) out.user = String(value) }
332
+ else if (key === 'port') { if (!out.port) out.port = Number(value) || 0 }
333
+ else if (key === 'identityfile') { if (!out.identityFile) out.identityFile = String(value) }
334
+ else if (key === 'proxyjump') { if (!out.proxyJump) out.proxyJump = String(value) }
335
+ else if (key === 'proxycommand') { if (!out.proxyCommand) out.proxyCommand = String(value) }
336
+ else if (key === 'identitiesonly') { if (!out.identitiesOnly) out.identitiesOnly = String(value) }
337
+ else if (key === 'identityagent') { if (!out.identityAgent) out.identityAgent = String(value) }
338
+ }
339
+ }
340
+ if (!out.hostName) out.hostName = name
341
+ return out
342
+ }
343
+
344
+ /**
345
+ * Resolve an alias from a config FILE (Include-expanded) or from given text.
346
+ * @param {string} alias
347
+ * @param {{file?: string, text?: string, io?: object}} [opts]
348
+ */
349
+ export function resolveSshAlias(alias, opts = {}) {
350
+ const text = opts.text !== undefined ? String(opts.text) : loadSshConfigText(opts.file || sshConfigPath(), opts.io || {})
351
+ return resolveSshHost(alias, text)
352
+ }
353
+
354
+ /** Expand a leading `~` in an ssh config path (IdentityFile). */
355
+ export function expandHome(p) {
356
+ const s = String(p || '')
357
+ if (!s.startsWith('~')) return s
358
+ if (s === '~') return homedir()
359
+ if (s.startsWith('~/') || s.startsWith('~\\')) return path.join(homedir(), s.slice(2))
360
+ return s
361
+ }
362
+
363
+ /**
364
+ * Expand ssh_config(5) TOKENS and `${VAR}` environment references in an
365
+ * IdentityFile / IdentityAgent value: `%%` `%d` `%h` `%n` `%p` `%r` `%u` `%i`
366
+ * `%l` `%L`. A config like `IdentityFile ~/.ssh/%n.pub` (one key per alias)
367
+ * used to be read as a file literally named `%n.pub`. Unknown tokens are left
368
+ * as-is, so the resulting "file not found" names what was not understood.
369
+ *
370
+ * @param {string} value
371
+ * @param {{alias?: string, host?: string, port?: number, user?: string}} ctx
372
+ */
373
+ export function expandSshTokens(value, ctx = {}) {
374
+ const s = String(value || '')
375
+ let localUser = ''
376
+ try { localUser = userInfo().username } catch {}
377
+ const longHost = (() => { try { return osHostname() } catch { return '' } })()
378
+ const map = {
379
+ '%': '%',
380
+ d: homedir(),
381
+ h: String(ctx.host || ctx.alias || ''),
382
+ n: String(ctx.alias || ctx.host || ''),
383
+ p: String(Number(ctx.port) || 22),
384
+ r: String(ctx.user || localUser),
385
+ u: localUser,
386
+ i: (() => { try { return String(process.getuid ? process.getuid() : '') } catch { return '' } })(),
387
+ l: longHost.split('.')[0],
388
+ L: longHost.split('.')[0],
389
+ }
390
+ return s
391
+ .replace(/%([%a-zA-Z])/g, (m, t) => (Object.prototype.hasOwnProperty.call(map, t) ? map[t] : m))
392
+ .replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (m, v) => (process.env[v] !== undefined ? process.env[v] : m))
393
+ }
394
+
395
+ /** An IdentityFile path, tokens and `~` expanded. */
396
+ function identityPath(value, ctx) {
397
+ return value ? expandHome(expandSshTokens(value, ctx)) : ''
398
+ }
399
+
400
+ /**
401
+ * The agent socket an `IdentityAgent` value selects. `''` means "the process
402
+ * SSH_AUTH_SOCK" (also what `SSH_AUTH_SOCK` itself and an unset value mean);
403
+ * `none` disables the agent and is returned as the literal `'none'`.
404
+ */
405
+ function identityAgentPath(value, ctx) {
406
+ const v = String(value || '').trim()
407
+ if (!v || v === 'SSH_AUTH_SOCK') return ''
408
+ if (/^none$/i.test(v)) return 'none'
409
+ // `$VAR` (no braces) names an environment variable holding the socket path.
410
+ if (/^\$[A-Za-z_][A-Za-z0-9_]*$/.test(v)) return process.env[v.slice(1)] || ''
411
+ return expandHome(expandSshTokens(v, ctx))
412
+ }
413
+
414
+ /**
415
+ * Split a ProxyJump value into hops: `[user@]host[:port]`, comma-separated, and
416
+ * `none` meaning "no jump".
417
+ * @param {string} value
418
+ * @returns {Array<{user: string, host: string, port: number}>}
419
+ */
420
+ export function parseProxyJump(value) {
421
+ const raw = String(value || '').trim()
422
+ if (!raw || /^none$/i.test(raw)) return []
423
+ return raw.split(',').map((hop) => hop.trim()).filter(Boolean).map((hop) => {
424
+ const m = hop.match(/^(?:([^@]+)@)?([^:[\] ]+)(?::(\d+))?$/)
425
+ if (!m) return { user: '', host: hop, port: 0 }
426
+ return { user: m[1] || '', host: m[2], port: Number(m[3]) || 0 }
427
+ })
428
+ }
429
+
430
+ /**
431
+ * Shared proxy/warning shaping for alias and literal resolutions (issue #50).
432
+ *
433
+ * `ProxyJump` maps onto the plugin's single jump host. A multi-hop chain cannot
434
+ * be represented by one `proxy`, so the FIRST hop is used and `warnings` says so
435
+ * — silently connecting through the wrong path would be worse. `ProxyCommand`
436
+ * (an arbitrary local command) is reported but never executed.
437
+ */
438
+ function targetFromResolved(resolved, text, { defaultPort }) {
439
+ const warnings = []
440
+ const ctx = {
441
+ alias: resolved.alias,
442
+ host: resolved.hostName || resolved.alias,
443
+ port: resolved.port || defaultPort || 22,
444
+ user: resolved.user,
445
+ }
446
+ let proxy
447
+ const hops = parseProxyJump(resolved.proxyJump)
448
+ if (hops.length > 1) {
449
+ warnings.push(`ProxyJump has ${hops.length} hops; dsh-remote supports one jump host — using the first (${hops[0].host}).`)
450
+ }
451
+ if (hops.length) {
452
+ const hop = hops[0]
453
+ const hopResolved = resolveSshHost(hop.host, text)
454
+ proxy = {
455
+ host: hopResolved.hostName || hop.host,
456
+ port: hop.port || hopResolved.port || 22,
457
+ username: hop.user || hopResolved.user || '',
458
+ privateKeyPath: identityPath(hopResolved.identityFile, {
459
+ alias: hop.host, host: hopResolved.hostName || hop.host,
460
+ port: hop.port || hopResolved.port, user: hop.user || hopResolved.user,
461
+ }),
462
+ passphrase: '',
463
+ password: '',
464
+ }
465
+ // Only when set, so a hop without them keeps its established shape.
466
+ const hopAgent = identityAgentPath(hopResolved.identityAgent, { alias: hop.host, host: hopResolved.hostName || hop.host })
467
+ if (hopAgent) proxy.agentSocket = hopAgent
468
+ if (/^yes$/i.test(hopResolved.identitiesOnly || '')) proxy.identitiesOnly = true
469
+ } else if (resolved.proxyCommand) {
470
+ warnings.push('ProxyCommand is not supported by dsh-remote (no jump host was applied).')
471
+ }
472
+ return {
473
+ alias: resolved.alias,
474
+ matched: resolved.matched,
475
+ host: resolved.hostName || resolved.alias,
476
+ port: resolved.port || defaultPort,
477
+ username: resolved.user || '',
478
+ privateKeyPath: identityPath(resolved.identityFile, ctx),
479
+ agentSocket: identityAgentPath(resolved.identityAgent, ctx),
480
+ identitiesOnly: /^yes$/i.test(resolved.identitiesOnly || ''),
481
+ proxy,
482
+ proxyJump: resolved.proxyJump,
483
+ warnings,
484
+ }
485
+ }
486
+
487
+ /**
488
+ * Connect target for an ssh-config alias: the shape the plugin's pool expects.
489
+ *
490
+ * @param {string} alias
491
+ * @param {object} [opts]
492
+ * @param {string} [opts.text] - config text (else the real file is read).
493
+ * @param {string} [opts.file]
494
+ * @param {object} [opts.io]
495
+ * @returns {{alias: string, matched: boolean, host: string, port: number, username: string, privateKeyPath: string, proxy: object|undefined, proxyJump: string, warnings: string[]}}
496
+ */
497
+ export function sshAliasTarget(alias, opts = {}) {
498
+ const text = opts.text !== undefined ? String(opts.text) : loadSshConfigText(opts.file || sshConfigPath(), opts.io || {})
499
+ return targetFromResolved(resolveSshHost(alias, text), text, { defaultPort: 22 })
500
+ }
501
+
502
+ /**
503
+ * Effective connect identity for a LITERAL machine (issue #50).
504
+ *
505
+ * A machine saved with literal host/user/port/key holds a COPY of what may also
506
+ * be written in ~/.ssh/config, and the copy goes stale the moment the file is
507
+ * edited. The file is the single source for any host it NAMES: when a Host
508
+ * block matches the machine's literal host — with at least one positive pattern
509
+ * that is not the bare `*` — the resolved values win per field and the record
510
+ * fills the gaps. A bare `Host *` block deliberately does NOT name a host:
511
+ * treating universal defaults as an override would hijack every saved machine
512
+ * at once. Group patterns (`*.corp`, `web-?`) DO name hosts, exactly as ssh
513
+ * itself applies them to a literal dial.
514
+ *
515
+ * `port` is 0 (not 22) when the file says nothing, so the caller falls back to
516
+ * the record instead of the default.
517
+ *
518
+ * @param {object} machine - a registry record with a literal `host`.
519
+ * @param {{text?: string, file?: string, io?: object}} [opts]
520
+ * @returns {null | {alias: string, matched: true, host: string, port: number, username: string, privateKeyPath: string, proxy: object|undefined, proxyJump: string, warnings: string[]}}
521
+ * null when no Host block names the machine's host.
522
+ */
523
+ export function resolveLiteralSshConfig(machine, opts = {}) {
524
+ if (!machine || machine.useSshConfig) return null
525
+ const name = String(machine.host || '').trim()
526
+ if (!name) return null
527
+ const text = opts.text !== undefined ? String(opts.text) : loadSshConfigText(opts.file || sshConfigPath(), opts.io || {})
528
+ const resolved = {
529
+ alias: name, matched: false, hostName: '', user: '', port: 0,
530
+ identityFile: '', proxyJump: '', proxyCommand: '', identitiesOnly: '', identityAgent: '',
531
+ }
532
+ for (const block of parseHostBlocks(text)) {
533
+ if (!hostBlockMatches(block.patterns, name)) continue
534
+ const positive = block.patterns.filter((p) => !p.startsWith('!'))
535
+ // A block whose only positive pattern is the bare `*` is a defaults block,
536
+ // not a block that names this host.
537
+ if (positive.length && positive.every((p) => p === '*')) continue
538
+ resolved.matched = true
539
+ for (const [key, value] of Object.entries(block.values)) {
540
+ if (key === 'hostname') { if (!resolved.hostName) resolved.hostName = String(value) }
541
+ else if (key === 'user') { if (!resolved.user) resolved.user = String(value) }
542
+ else if (key === 'port') { if (!resolved.port) resolved.port = Number(value) || 0 }
543
+ else if (key === 'identityfile') { if (!resolved.identityFile) resolved.identityFile = String(value) }
544
+ else if (key === 'proxyjump') { if (!resolved.proxyJump) resolved.proxyJump = String(value) }
545
+ else if (key === 'proxycommand') { if (!resolved.proxyCommand) resolved.proxyCommand = String(value) }
546
+ else if (key === 'identitiesonly') { if (!resolved.identitiesOnly) resolved.identitiesOnly = String(value) }
547
+ else if (key === 'identityagent') { if (!resolved.identityAgent) resolved.identityAgent = String(value) }
548
+ }
549
+ }
550
+ if (!resolved.matched) return null
551
+ return targetFromResolved(resolved, text, { defaultPort: 0 })
552
+ }
553
+
554
+ /**
555
+ * Effective connect identity for a saved machine.
556
+ *
557
+ * A machine in ALIAS mode (`useSshConfig: true`, `host` = the alias) inherits
558
+ * everything from ~/.ssh/config; a field the user filled in explicitly still
559
+ * wins. "Explicitly" cannot be distinguished from "form default" for the two
560
+ * fields that have defaults (port 22, user `root`), so those inherit unless they
561
+ * differ from the default — stated in the settings UI.
562
+ *
563
+ * @param {object} machine
564
+ * @param {{text?: string, file?: string, io?: object}} [opts]
565
+ * @returns {{alias: string, matched: boolean, host: string, port: number, username: string, privateKeyPath: string, proxy: object|undefined, warnings: string[]}|null}
566
+ * null when the machine is not in alias mode.
567
+ */
568
+ export function resolveMachineSshConfig(machine, opts = {}) {
569
+ if (!machine || !machine.useSshConfig) return null
570
+ const alias = String(machine.host || '').trim()
571
+ if (!alias) return null
572
+ const target = sshAliasTarget(alias, opts)
573
+ const warnings = [...target.warnings]
574
+ if (!target.matched) warnings.push(`no ~/.ssh/config block matches "${alias}" — using the name itself.`)
575
+ const port = machine.port && Number(machine.port) !== 22 ? Number(machine.port) : target.port
576
+ const username = machine.username && machine.username !== 'root' ? String(machine.username) : (target.username || machine.username || '')
577
+ const privateKeyPath = machine.privateKeyPath ? String(machine.privateKeyPath) : target.privateKeyPath
578
+ const proxy = machine.proxy && machine.proxy.host ? machine.proxy : target.proxy
579
+ return {
580
+ alias,
581
+ matched: target.matched,
582
+ host: target.host,
583
+ port: Number(port) || 22,
584
+ username: String(username || ''),
585
+ privateKeyPath,
586
+ agentSocket: target.agentSocket,
587
+ identitiesOnly: target.identitiesOnly,
588
+ proxy,
589
+ warnings,
590
+ }
591
+ }
592
+
593
+ function compareText(left, right) {
594
+ return left < right ? -1 : left > right ? 1 : 0
595
+ }
596
+
597
+ // ── Writing: the managed Host block (issue #50) ─────────────────────────────
598
+ //
599
+ // The four connection fields (host/user/port/key) have ONE home: ~/.ssh/config.
600
+ // When the plugin saves a literal machine it upserts a Host block tagged with
601
+ // MANAGED_MARK, so the file and the registry can never drift — and so the
602
+ // plugin can tell ITS OWN block apart from one the user wrote. Two rules keep
603
+ // the file safe:
604
+ // • a Host block without the marker is never touched — a same-named user
605
+ // block is a CONFLICT, reported to the caller instead of overwritten;
606
+ // • inside a managed block, lines the plugin does not own (a user-added
607
+ // `ForwardAgent yes`, a comment) survive an update — only the four known
608
+ // parameters are rewritten.
609
+
610
+ /** Marker comment that tags a Host block as owned by dsh-remote. */
611
+ export const MANAGED_MARK = '# dsh-remote: managed'
612
+
613
+ /** Parameters the managed block rewrites (lowercase). */
614
+ const MANAGED_PARAMS = new Set(['hostname', 'user', 'port', 'identityfile'])
615
+
616
+ /** Read the RAW config text — no Include expansion (a write targets ONE file). */
617
+ export function readRawSshConfig(filePath = sshConfigPath()) {
618
+ try {
619
+ const text = readFileSync(filePath, 'utf8')
620
+ return typeof text === 'string' ? text : ''
621
+ } catch {
622
+ return ''
623
+ }
624
+ }
625
+
626
+ /** Quote one value for a config line when it contains whitespace. */
627
+ function quoteCfg(value) {
628
+ const s = String(value)
629
+ return /[\s#]/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s
630
+ }
631
+
632
+ /** The values a managed block must carry for these fields ('' = omit). */
633
+ function desiredValues(fields) {
634
+ const alias = String(fields.alias || '').trim()
635
+ const hostName = String(fields.hostName || '').trim()
636
+ const port = Number(fields.port) || 0
637
+ return {
638
+ hostname: hostName && hostName !== alias ? hostName : '',
639
+ user: String(fields.user || '').trim(),
640
+ port: port && port !== 22 ? port : '',
641
+ identityfile: String(fields.identityFile || '').trim(),
642
+ }
643
+ }
644
+
645
+ /** Render the owned body lines of a managed block. */
646
+ function desiredLines(fields) {
647
+ const want = desiredValues(fields)
648
+ const out = []
649
+ if (want.hostname) out.push(` HostName ${quoteCfg(want.hostname)}`)
650
+ if (want.user) out.push(` User ${quoteCfg(want.user)}`)
651
+ if (want.port) out.push(` Port ${want.port}`)
652
+ if (want.identityfile) out.push(` IdentityFile ${quoteCfg(want.identityfile)}`)
653
+ return out
654
+ }
655
+
656
+ /**
657
+ * Locate the MANAGED Host block for `alias` in raw config lines.
658
+ * The marker must be the nearest non-blank line above the Host line; a block
659
+ * that names the alias without the marker is the user's, not ours.
660
+ */
661
+ function locateManagedBlock(lines, alias) {
662
+ const wanted = alias.toLowerCase()
663
+ for (let i = 0; i < lines.length; i += 1) {
664
+ const m = lines[i].match(/^\s*host\s+(.+?)\s*$/i)
665
+ if (!m) continue
666
+ const names = splitValue(m[1]).map((p) => p.toLowerCase())
667
+ if (!names.includes(wanted)) continue
668
+ let j = i - 1
669
+ while (j >= 0 && lines[j].trim() === '') j -= 1
670
+ if (j < 0 || lines[j].trim().toLowerCase() !== MANAGED_MARK) continue
671
+ let end = lines.length
672
+ for (let k = i + 1; k < lines.length; k += 1) {
673
+ if (/^\s*(host|match)\s/i.test(lines[k])) { end = k; break }
674
+ }
675
+ return { markerLine: j, hostLine: i, end }
676
+ }
677
+ return null
678
+ }
679
+
680
+ /** Whether an UNMANAGED Host block names `alias` (exact token, case-insensitive). */
681
+ function hasUnmanagedConflict(lines, alias) {
682
+ const wanted = alias.toLowerCase()
683
+ for (let i = 0; i < lines.length; i += 1) {
684
+ const m = lines[i].match(/^\s*host\s+(.+?)\s*$/i)
685
+ if (!m) continue
686
+ const names = splitValue(m[1]).map((p) => p.toLowerCase())
687
+ if (!names.includes(wanted)) continue
688
+ let j = i - 1
689
+ while (j >= 0 && lines[j].trim() === '') j -= 1
690
+ if (j >= 0 && lines[j].trim().toLowerCase() === MANAGED_MARK) continue // ours
691
+ return true
692
+ }
693
+ return false
694
+ }
695
+
696
+ /**
697
+ * Insert or refresh the managed Host block for a saved machine.
698
+ *
699
+ * @param {string} rawText - the RAW ~/.ssh/config text ('' when absent).
700
+ * @param {{alias: string, hostName?: string, user?: string, port?: number|string, identityFile?: string}} fields
701
+ * @returns {{status: 'appended'|'updated'|'unchanged'|'conflict'|'invalid', text: string}}
702
+ * 'conflict' — an unmanaged block already names the alias (text untouched);
703
+ * 'invalid' — the alias can never be a safe Host token (space/wildcard).
704
+ */
705
+ export function upsertManagedHostBlock(rawText, fields) {
706
+ const text = String(rawText || '')
707
+ const alias = String((fields && fields.alias) || '').trim()
708
+ if (!alias || /[\s*?!,]/.test(alias)) return { status: 'invalid', text }
709
+ const lines = text.split('\n')
710
+ if (hasUnmanagedConflict(lines, alias)) return { status: 'conflict', text }
711
+
712
+ const want = desiredValues({ ...fields, alias })
713
+ const found = locateManagedBlock(lines, alias)
714
+ if (!found) {
715
+ const block = [MANAGED_MARK, `Host ${alias}`, ...desiredLines({ ...fields, alias })]
716
+ const trimmedEnd = text.replace(/\s+$/, '')
717
+ const prefix = trimmedEnd === '' ? '' : trimmedEnd + '\n\n'
718
+ return { status: 'appended', text: prefix + block.join('\n') + '\n' }
719
+ }
720
+
721
+ // Compare what the block HAS with what it MUST have; a rewrite that changes
722
+ // nothing would only cost the file's mtime.
723
+ const current = parseHostBlocks(lines.slice(found.hostLine, found.end).join('\n'))[0]
724
+ const cur = (current && current.values) || {}
725
+ const same = ['hostname', 'user', 'port', 'identityfile']
726
+ .every((k) => String(cur[k] ?? '') === String(want[k] ?? ''))
727
+ if (same) return { status: 'unchanged', text }
728
+
729
+ // Rewrite only the four owned parameters; user lines inside OUR block stay.
730
+ const body = lines.slice(found.hostLine + 1, found.end)
731
+ const kept = body.filter((l) => {
732
+ const m = l.match(/^\s*(\S+)\s/)
733
+ return !m || !MANAGED_PARAMS.has(m[1].toLowerCase())
734
+ })
735
+ const next = [
736
+ ...lines.slice(0, found.hostLine + 1),
737
+ ...desiredLines({ ...fields, alias }),
738
+ ...kept,
739
+ ...lines.slice(found.end),
740
+ ]
741
+ return { status: 'updated', text: next.join('\n') }
742
+ }
743
+
744
+ /**
745
+ * Remove the managed block for `alias` (used when a machine is RENAMED away:
746
+ * the old block is the plugin's own artifact of that edit). Deleting a machine
747
+ * deliberately does NOT call this — the Host block is the user's ssh access,
748
+ * not plugin state. Unmanaged blocks are never removed.
749
+ *
750
+ * @returns {{status: 'removed'|'absent', text: string}}
751
+ */
752
+ export function removeManagedHostBlock(rawText, alias) {
753
+ const text = String(rawText || '')
754
+ const name = String(alias || '').trim()
755
+ if (!name) return { status: 'absent', text }
756
+ const lines = text.split('\n')
757
+ const found = locateManagedBlock(lines, name)
758
+ if (!found) return { status: 'absent', text }
759
+ let start = found.markerLine
760
+ while (start > 0 && lines[start - 1].trim() === '') start -= 1
761
+ const next = [...lines.slice(0, start), ...lines.slice(found.end)]
762
+ return { status: 'removed', text: next.join('\n') }
763
+ }
764
+
765
+ /**
766
+ * Persist a config text, creating ~/.ssh first. Permissions follow OpenSSH's
767
+ * expectations on POSIX (dir 0700, file 0600) and are a no-op elsewhere.
768
+ */
769
+ export function writeSshConfig(text, filePath = sshConfigPath(), io = {}) {
770
+ const mkdir = io.mkdir || ((p) => mkdirSync(p, { recursive: true }))
771
+ const write = io.write || ((p, data) => writeFileSync(p, data, 'utf8'))
772
+ const chmod = io.chmod || ((p, mode) => { try { chmodSync(p, mode) } catch { /* no modes here */ } })
773
+ mkdir(path.dirname(filePath))
774
+ write(filePath, String(text))
775
+ if (process.platform !== 'win32') {
776
+ chmod(path.dirname(filePath), 0o700)
777
+ chmod(filePath, 0o600)
778
+ }
779
+ }