dsh-remote 0.8.21 → 0.8.23
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 +28 -1
- package/README.zh.md +26 -1
- package/lib/binding.js +9 -3
- package/lib/client.js +275 -56
- package/lib/file-reference.js +457 -0
- package/lib/index.js +456 -84
- package/lib/pool.js +49 -18
- package/lib/routes-fs.js +14 -2
- package/lib/sshconfig.js +415 -18
- package/package.json +1 -1
package/lib/pool.js
CHANGED
|
@@ -41,24 +41,48 @@ export class SshPool {
|
|
|
41
41
|
return p
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode }) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
// the
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
44
|
+
setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode }, previous) {
|
|
45
|
+
// Collect the requested changes first: an UNCHANGED target is a no-op. The
|
|
46
|
+
// old behavior closed the live connection and re-ran platform detection
|
|
47
|
+
// even for an identical target, so every picker keystroke
|
|
48
|
+
// (POST /current → applyActiveMachine → setTarget) paid a full SSH
|
|
49
|
+
// reconnect + re-detect — the "slow remote path autocomplete" bug.
|
|
50
|
+
//
|
|
51
|
+
// `previous` is the diff BASELINE. Callers whose shared config was already
|
|
52
|
+
// mutated BEFORE this call (applyActiveMachine: applyMachine rewrites the
|
|
53
|
+
// live config IN PLACE, and pool.config IS that config object) pass a
|
|
54
|
+
// pre-mutation snapshot — otherwise the comparison runs the new values
|
|
55
|
+
// against themselves, is always equal and never closes even for a machine
|
|
56
|
+
// switch (the wrong-host hazard of issue #25). Defaults to the current
|
|
57
|
+
// config for callers that pass fresh values read independently of it.
|
|
58
|
+
const base = previous || this.config
|
|
59
|
+
const wanted = []
|
|
60
|
+
if (host !== undefined) wanted.push(['host', String(host)])
|
|
61
|
+
if (port !== undefined && Number(port)) wanted.push(['port', Number(port)])
|
|
62
|
+
if (username !== undefined) wanted.push(['username', String(username)])
|
|
63
|
+
if (password !== undefined && password !== null) wanted.push(['password', String(password)])
|
|
64
|
+
if (privateKeyPath !== undefined) wanted.push(['privateKeyPath', String(privateKeyPath)])
|
|
65
|
+
if (passphrase !== undefined) wanted.push(['passphrase', String(passphrase)])
|
|
66
|
+
if (workspace !== undefined) wanted.push(['workspace', String(workspace)])
|
|
67
|
+
if (useAgent !== undefined) wanted.push(['useAgent', !!useAgent])
|
|
68
|
+
if (keyboardInteractive !== undefined) wanted.push(['keyboardInteractive', !!keyboardInteractive])
|
|
69
|
+
if (proxy !== undefined) wanted.push(['proxy', proxy])
|
|
70
|
+
if (hostKeyMode !== undefined) wanted.push(['hostKeyMode', String(hostKeyMode)])
|
|
71
|
+
const differs = ([k, v]) => (k === 'proxy'
|
|
72
|
+
? JSON.stringify(base[k] ?? null) !== JSON.stringify(v ?? null)
|
|
73
|
+
: base[k] !== v)
|
|
74
|
+
// Something actually changed → apply it and force a fresh connection.
|
|
75
|
+
// (A caller that WANTS a reconnect for an identical target uses
|
|
76
|
+
// invalidate(); setTarget semantics stay "re-point when moved".)
|
|
77
|
+
if (wanted.some(differs)) {
|
|
78
|
+
for (const [k, v] of wanted) this.config[k] = v
|
|
79
|
+
// the new target may be a different OS — re-detect on the next command
|
|
80
|
+
this.platform = 'unknown'
|
|
81
|
+
this.gitBashPath = ''
|
|
82
|
+
this.shellMode = 'native'
|
|
83
|
+
this._detecting = null
|
|
84
|
+
this.close()
|
|
85
|
+
}
|
|
62
86
|
return this
|
|
63
87
|
}
|
|
64
88
|
|
|
@@ -484,6 +508,13 @@ export class SshPool {
|
|
|
484
508
|
if (client) {
|
|
485
509
|
try { client.end() } catch {}
|
|
486
510
|
}
|
|
511
|
+
// Same teardown as close(): the forwards manager must detach from the
|
|
512
|
+
// dying client, or its stale `close -> stopAll()` listener later tears
|
|
513
|
+
// down forwards that were re-attached to the NEW client (onReady does
|
|
514
|
+
// the re-attach on the next successful connect).
|
|
515
|
+
if (this.onCloseHook) {
|
|
516
|
+
try { this.onCloseHook() } catch {}
|
|
517
|
+
}
|
|
487
518
|
}
|
|
488
519
|
|
|
489
520
|
close() {
|
package/lib/routes-fs.js
CHANGED
|
@@ -40,6 +40,17 @@ export function createFsRoutes({
|
|
|
40
40
|
const hint = requestSessionHint(req)
|
|
41
41
|
const b = await bindOrReply(req, res, hint)
|
|
42
42
|
if (!b) return
|
|
43
|
+
// Identity echo (external-switch defense): name the machine this
|
|
44
|
+
// listing is answered for, straight from the pool's target — the
|
|
45
|
+
// picker validates every listing against the machine it INTENDED and
|
|
46
|
+
// re-switches on mismatch (the settings page can change the active
|
|
47
|
+
// machine under a memoized picker).
|
|
48
|
+
const tgt = (b.pool && b.pool.config) || {}
|
|
49
|
+
const machine = {
|
|
50
|
+
host: String(tgt.host || b.host || ''),
|
|
51
|
+
port: Number(tgt.port) || Number(b.port) || 22,
|
|
52
|
+
username: String(tgt.username || b.username || ''),
|
|
53
|
+
}
|
|
43
54
|
await b.pool.detect()
|
|
44
55
|
const q = new URL(req.url, 'http://localhost').searchParams
|
|
45
56
|
const raw = q.get('path') ? decodeURIComponent(q.get('path')) : (b.ws || '')
|
|
@@ -48,9 +59,9 @@ export function createFsRoutes({
|
|
|
48
59
|
if (canon === '/' || raw === '' || raw === '/') {
|
|
49
60
|
if (win || b.pool.platform !== 'posix') {
|
|
50
61
|
const drives = await listRemoteDrives(b.pool)
|
|
51
|
-
if (drives.length) return sendJson(res, 200, { path: '', platform: 'windows', items: drives, bound: b.bound })
|
|
62
|
+
if (drives.length) return sendJson(res, 200, { path: '', platform: 'windows', items: drives, bound: b.bound, machine })
|
|
52
63
|
}
|
|
53
|
-
if (win) return sendJson(res, 200, { path: '', platform: 'windows', items: [], bound: b.bound })
|
|
64
|
+
if (win) return sendJson(res, 200, { path: '', platform: 'windows', items: [], bound: b.bound, machine })
|
|
54
65
|
}
|
|
55
66
|
const sftp = await b.pool.sftp()
|
|
56
67
|
const out = await listDirStructured(sftp, canon)
|
|
@@ -64,6 +75,7 @@ export function createFsRoutes({
|
|
|
64
75
|
items,
|
|
65
76
|
missing: !!out.missing,
|
|
66
77
|
bound: b.bound,
|
|
78
|
+
machine,
|
|
67
79
|
})
|
|
68
80
|
} catch (err) {
|
|
69
81
|
return sendJson(res, 500, { error: String((err && err.message) || err) })
|
package/lib/sshconfig.js
CHANGED
|
@@ -1,49 +1,229 @@
|
|
|
1
|
-
// dsh-remote — ~/.ssh/config
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
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 } from 'node:fs'
|
|
6
32
|
import { homedir } from 'node:os'
|
|
7
33
|
import path from 'node:path'
|
|
8
34
|
|
|
35
|
+
/** Default location of the user's ssh config. */
|
|
9
36
|
export const sshConfigPath = () => path.join(homedir(), '.ssh', 'config')
|
|
10
37
|
|
|
11
|
-
/**
|
|
38
|
+
/** Directory `Include` paths are resolved against (ssh_config(5)). */
|
|
39
|
+
export const sshConfigDir = () => path.join(homedir(), '.ssh')
|
|
40
|
+
|
|
41
|
+
/** Parameters the resolver cares about, lowercase (as parsed). */
|
|
42
|
+
const KNOWN_PARAMS = new Set([
|
|
43
|
+
'hostname', 'user', 'port', 'identityfile', 'proxyjump', 'proxycommand', 'identitiesonly',
|
|
44
|
+
])
|
|
45
|
+
|
|
46
|
+
/** Read the user's ~/.ssh/config text, Includes expanded ('' when absent). */
|
|
12
47
|
export function readSshConfigText(filePath = sshConfigPath()) {
|
|
48
|
+
return loadSshConfigText(filePath)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Read an ssh config file and expand its `Include` directives in place.
|
|
53
|
+
*
|
|
54
|
+
* Relative includes resolve against `~/.ssh` first (ssh_config(5)) and then
|
|
55
|
+
* against the including file's directory, because real-world configs use both.
|
|
56
|
+
* A missing or unreadable include is skipped: one broken line must not hide the
|
|
57
|
+
* rest of the config.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} [filePath] - the config file (default ~/.ssh/config).
|
|
60
|
+
* @param {object} [io] - injectable filesystem hooks (tests).
|
|
61
|
+
* @param {(p: string) => string} [io.readFile]
|
|
62
|
+
* @param {(p: string) => boolean} [io.exists]
|
|
63
|
+
* @param {(p: string) => string[]} [io.listDir]
|
|
64
|
+
* @param {number} [io.maxDepth]
|
|
65
|
+
* @returns {string} the expanded config text ('' when nothing could be read).
|
|
66
|
+
*/
|
|
67
|
+
export function loadSshConfigText(filePath = sshConfigPath(), io = {}) {
|
|
68
|
+
const readFile = io.readFile || ((p) => readFileSync(p, 'utf8'))
|
|
69
|
+
const listDir = io.listDir || ((p) => readdirSync(p))
|
|
70
|
+
return expandFile(filePath, { readFile, listDir, maxDepth: io.maxDepth ?? 3, depth: 0, seen: new Set() })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function expandFile(file, state) {
|
|
74
|
+
let text
|
|
13
75
|
try {
|
|
14
|
-
|
|
76
|
+
text = state.readFile(file)
|
|
15
77
|
} catch {
|
|
16
78
|
return ''
|
|
17
79
|
}
|
|
80
|
+
if (typeof text !== 'string') return ''
|
|
81
|
+
if (state.depth >= state.maxDepth) return text
|
|
82
|
+
const out = []
|
|
83
|
+
for (const line of joinContinuations(text)) {
|
|
84
|
+
const patterns = matchInclude(line)
|
|
85
|
+
if (!patterns) { out.push(line); continue }
|
|
86
|
+
for (const pattern of patterns) {
|
|
87
|
+
for (const included of expandIncludePath(pattern, path.dirname(file), state.listDir)) {
|
|
88
|
+
const key = path.resolve(included)
|
|
89
|
+
if (state.seen.has(key)) continue
|
|
90
|
+
state.seen.add(key)
|
|
91
|
+
const child = expandFile(included, { ...state, depth: state.depth + 1 })
|
|
92
|
+
if (child.trim()) out.push(child)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out.join('\n')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The value tokens of `Include <pattern> [pattern...]`, or null. */
|
|
100
|
+
function matchInclude(line) {
|
|
101
|
+
const m = String(line).match(/^\s*include\s+(.+?)\s*$/i)
|
|
102
|
+
if (!m) return null
|
|
103
|
+
return splitValue(m[1])
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Expand one include pattern.
|
|
107
|
+
*
|
|
108
|
+
* ssh_config(5): a relative path is relative to `~/.ssh`. The including file's
|
|
109
|
+
* own directory is tried only when that yields nothing — which is what makes
|
|
110
|
+
* the plugin able to read a config the user pointed it at elsewhere, without
|
|
111
|
+
* double-resolving (and double-reading) the normal `~/.ssh` case.
|
|
112
|
+
*/
|
|
113
|
+
function expandIncludePath(pattern, callerDir, listDir) {
|
|
114
|
+
const raw = stripQuotes(pattern)
|
|
115
|
+
if (!raw) return []
|
|
116
|
+
if (path.isAbsolute(raw)) return globFiles(raw, listDir).sort()
|
|
117
|
+
if (raw.startsWith('~')) return globFiles(path.join(homedir(), raw.replace(/^~[\\/]?/, '')), listDir).sort()
|
|
118
|
+
const primary = globFiles(path.join(sshConfigDir(), raw), listDir)
|
|
119
|
+
if (primary.length) return primary.sort()
|
|
120
|
+
if (callerDir && path.resolve(callerDir) !== path.resolve(sshConfigDir())) {
|
|
121
|
+
return globFiles(path.join(callerDir, raw), listDir).sort()
|
|
122
|
+
}
|
|
123
|
+
return []
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Files matching a path whose basename may contain `*` / `?` (one level). */
|
|
127
|
+
function globFiles(target, listDir) {
|
|
128
|
+
const base = path.basename(target)
|
|
129
|
+
if (!/[*?]/.test(base)) return [target]
|
|
130
|
+
const dir = path.dirname(target)
|
|
131
|
+
let entries
|
|
132
|
+
try {
|
|
133
|
+
entries = listDir(dir)
|
|
134
|
+
} catch {
|
|
135
|
+
return []
|
|
136
|
+
}
|
|
137
|
+
const re = globRegex(base)
|
|
138
|
+
return entries
|
|
139
|
+
.filter((name) => re.test(name))
|
|
140
|
+
.map((name) => path.join(dir, name))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Join lines ending in a backslash (ssh config line continuation). */
|
|
144
|
+
function joinContinuations(text) {
|
|
145
|
+
const lines = []
|
|
146
|
+
let pending = ''
|
|
147
|
+
for (const raw of String(text).split('\n')) {
|
|
148
|
+
const line = pending + raw
|
|
149
|
+
if (/\\\s*$/.test(line) && !/\\\\\s*$/.test(line)) {
|
|
150
|
+
pending = line.replace(/\\\s*$/, ' ') + ' '
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
pending = ''
|
|
154
|
+
lines.push(line)
|
|
155
|
+
}
|
|
156
|
+
if (pending) lines.push(pending)
|
|
157
|
+
return lines
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Config lines with continuations joined — the form every parser consumes. */
|
|
161
|
+
const configLines = (text) => joinContinuations(text)
|
|
162
|
+
|
|
163
|
+
/** Split a parameter value into whitespace-separated tokens, honoring quotes. */
|
|
164
|
+
export function splitValue(value) {
|
|
165
|
+
return (String(value || '').match(/"[^"]*"|'[^']*'|\S+/g) || [])
|
|
166
|
+
.map(stripQuotes)
|
|
167
|
+
.filter((token) => token !== '')
|
|
18
168
|
}
|
|
19
169
|
|
|
170
|
+
const stripQuotes = (s) => String(s || '').replace(/^"(.*)"$/s, '$1').replace(/^'(.*)'$/s, '$1')
|
|
171
|
+
|
|
20
172
|
/**
|
|
21
|
-
* Parse OpenSSH config text into
|
|
173
|
+
* Parse OpenSSH config text into Host BLOCKS, preserving pattern lists.
|
|
174
|
+
* @param {string} text
|
|
175
|
+
* @returns {Array<{patterns: string[], values: Record<string, string|number>, line: number}>}
|
|
176
|
+
*/
|
|
177
|
+
export function parseHostBlocks(text) {
|
|
178
|
+
const blocks = []
|
|
179
|
+
let current = null
|
|
180
|
+
const lines = configLines(text)
|
|
181
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
182
|
+
const line = lines[i].trim()
|
|
183
|
+
if (!line || line.startsWith('#')) continue
|
|
184
|
+
const m = line.match(/^(\S+)\s+(.*)$/)
|
|
185
|
+
if (!m) continue
|
|
186
|
+
const key = m[1].toLowerCase()
|
|
187
|
+
const value = m[2].trim()
|
|
188
|
+
if (key === 'host') {
|
|
189
|
+
current = { patterns: splitValue(value), values: {}, line: i + 1 }
|
|
190
|
+
blocks.push(current)
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
if (!current || !KNOWN_PARAMS.has(key)) continue
|
|
194
|
+
// First value wins inside a block too (a repeated parameter is ignored).
|
|
195
|
+
if (Object.prototype.hasOwnProperty.call(current.values, key)) continue
|
|
196
|
+
current.values[key] = key === 'port' ? (Number(value) || value) : stripQuotes(value)
|
|
197
|
+
}
|
|
198
|
+
return blocks
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Legacy block list: one entry per `Host` line, flattened to the fields the
|
|
203
|
+
* import UI shows. Kept stable for the existing importer + tests.
|
|
22
204
|
* @returns {Array<{host: string, hostName: string, user: string, port: number, identityFile: string, proxyJump: string}>}
|
|
23
|
-
* host = alias pattern (may be '*' / '!foo' / comma list — caller filters).
|
|
24
205
|
*/
|
|
25
206
|
export function parseSshConfig(text) {
|
|
26
207
|
const entries = []
|
|
27
208
|
let cur = null
|
|
28
|
-
const
|
|
29
|
-
for (const raw of lines) {
|
|
209
|
+
for (const raw of configLines(text)) {
|
|
30
210
|
const line = raw.trim()
|
|
31
|
-
if (!line || line.startsWith('#')
|
|
211
|
+
if (!line || line.startsWith('#')) continue
|
|
32
212
|
const m = line.match(/^(\S+)\s+(.*)$/)
|
|
33
213
|
if (!m) continue
|
|
34
214
|
const key = m[1].toLowerCase()
|
|
35
215
|
const val = m[2].trim()
|
|
36
216
|
if (key === 'host') {
|
|
37
|
-
cur = { host: val, hostName: '', user: '', port: 22, identityFile: '', proxyJump: '' }
|
|
217
|
+
cur = { host: stripQuotes(val), hostName: '', user: '', port: 22, identityFile: '', proxyJump: '' }
|
|
38
218
|
entries.push(cur)
|
|
39
219
|
continue
|
|
40
220
|
}
|
|
41
221
|
if (!cur) continue
|
|
42
|
-
if (key === 'hostname') cur.hostName = val
|
|
43
|
-
else if (key === 'user') cur.user = val
|
|
44
|
-
else if (key === 'port') cur.port = Number(val) || 22
|
|
45
|
-
else if (key === 'identityfile') cur.identityFile = val
|
|
46
|
-
else if (key === 'proxyjump') cur.proxyJump = val
|
|
222
|
+
if (key === 'hostname' && !cur.hostName) cur.hostName = stripQuotes(val)
|
|
223
|
+
else if (key === 'user' && !cur.user) cur.user = stripQuotes(val)
|
|
224
|
+
else if (key === 'port' && cur.port === 22) cur.port = Number(val) || 22
|
|
225
|
+
else if (key === 'identityfile' && !cur.identityFile) cur.identityFile = stripQuotes(val)
|
|
226
|
+
else if (key === 'proxyjump' && !cur.proxyJump) cur.proxyJump = stripQuotes(val)
|
|
47
227
|
}
|
|
48
228
|
return entries
|
|
49
229
|
}
|
|
@@ -55,3 +235,220 @@ export function importableEntries(text) {
|
|
|
55
235
|
return e.hostName || e.user || e.port !== 22 || e.identityFile || e.proxyJump
|
|
56
236
|
})
|
|
57
237
|
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Every concrete (non-wildcard) alias the config defines, with the effective
|
|
241
|
+
* values after OpenSSH first-wins resolution — the list the import UI offers.
|
|
242
|
+
* @param {string} text
|
|
243
|
+
* @returns {Array<{alias: string, hostName: string, user: string, port: number, identityFile: string, proxyJump: string, proxyCommand: string}>}
|
|
244
|
+
*/
|
|
245
|
+
export function listAliases(text) {
|
|
246
|
+
const seen = new Set()
|
|
247
|
+
const out = []
|
|
248
|
+
for (const block of parseHostBlocks(text)) {
|
|
249
|
+
for (const pattern of block.patterns) {
|
|
250
|
+
if (!pattern || /[*?!]/.test(pattern) || seen.has(pattern)) continue
|
|
251
|
+
seen.add(pattern)
|
|
252
|
+
out.push(resolveSshHost(pattern, text))
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return out.sort((a, b) => compareText(a.alias, b.alias))
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Glob match for ssh host patterns (`*`, `?`), case-insensitive. */
|
|
259
|
+
export function globMatch(pattern, value) {
|
|
260
|
+
return globRegex(pattern).test(String(value || ''))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function globRegex(pattern) {
|
|
264
|
+
let re = ''
|
|
265
|
+
for (const ch of String(pattern || '')) {
|
|
266
|
+
if (ch === '*') re += '.*'
|
|
267
|
+
else if (ch === '?') re += '.'
|
|
268
|
+
else re += ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
269
|
+
}
|
|
270
|
+
return new RegExp(`^${re}$`, 'i')
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Whether a `Host` pattern list applies to a name: any positive pattern matches
|
|
275
|
+
* AND no negated (`!`) pattern does.
|
|
276
|
+
* @param {string[]|string} patterns
|
|
277
|
+
* @param {string} name
|
|
278
|
+
*/
|
|
279
|
+
export function hostBlockMatches(patterns, name) {
|
|
280
|
+
const list = Array.isArray(patterns) ? patterns : splitValue(patterns)
|
|
281
|
+
const negative = list.filter((p) => p.startsWith('!')).map((p) => p.slice(1))
|
|
282
|
+
const positive = list.filter((p) => !p.startsWith('!'))
|
|
283
|
+
if (negative.some((p) => globMatch(p, name))) return false
|
|
284
|
+
return positive.some((p) => globMatch(p, name))
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Effective configuration for one alias, with OpenSSH first-wins semantics.
|
|
289
|
+
*
|
|
290
|
+
* `hostName` falls back to the alias itself (ssh's own behaviour), so a block
|
|
291
|
+
* that only sets `User`/`IdentityFile` still yields a connectable host.
|
|
292
|
+
*
|
|
293
|
+
* @param {string} alias
|
|
294
|
+
* @param {string} text - FULL config text (Include-expanded).
|
|
295
|
+
* @returns {{alias: string, matched: boolean, hostName: string, user: string, port: number, identityFile: string, proxyJump: string, proxyCommand: string, identitiesOnly: string}}
|
|
296
|
+
*/
|
|
297
|
+
export function resolveSshHost(alias, text) {
|
|
298
|
+
const name = String(alias || '').trim()
|
|
299
|
+
const out = {
|
|
300
|
+
alias: name,
|
|
301
|
+
matched: false,
|
|
302
|
+
hostName: '',
|
|
303
|
+
user: '',
|
|
304
|
+
port: 0,
|
|
305
|
+
identityFile: '',
|
|
306
|
+
proxyJump: '',
|
|
307
|
+
proxyCommand: '',
|
|
308
|
+
identitiesOnly: '',
|
|
309
|
+
}
|
|
310
|
+
if (!name) return out
|
|
311
|
+
for (const block of parseHostBlocks(text)) {
|
|
312
|
+
if (!hostBlockMatches(block.patterns, name)) continue
|
|
313
|
+
out.matched = true
|
|
314
|
+
// first obtained value wins, ACROSS blocks in file order
|
|
315
|
+
for (const [key, value] of Object.entries(block.values)) {
|
|
316
|
+
if (key === 'hostname') { if (!out.hostName) out.hostName = String(value) }
|
|
317
|
+
else if (key === 'user') { if (!out.user) out.user = String(value) }
|
|
318
|
+
else if (key === 'port') { if (!out.port) out.port = Number(value) || 0 }
|
|
319
|
+
else if (key === 'identityfile') { if (!out.identityFile) out.identityFile = String(value) }
|
|
320
|
+
else if (key === 'proxyjump') { if (!out.proxyJump) out.proxyJump = String(value) }
|
|
321
|
+
else if (key === 'proxycommand') { if (!out.proxyCommand) out.proxyCommand = String(value) }
|
|
322
|
+
else if (key === 'identitiesonly') { if (!out.identitiesOnly) out.identitiesOnly = String(value) }
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (!out.hostName) out.hostName = name
|
|
326
|
+
return out
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Resolve an alias from a config FILE (Include-expanded) or from given text.
|
|
331
|
+
* @param {string} alias
|
|
332
|
+
* @param {{file?: string, text?: string, io?: object}} [opts]
|
|
333
|
+
*/
|
|
334
|
+
export function resolveSshAlias(alias, opts = {}) {
|
|
335
|
+
const text = opts.text !== undefined ? String(opts.text) : loadSshConfigText(opts.file || sshConfigPath(), opts.io || {})
|
|
336
|
+
return resolveSshHost(alias, text)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Expand a leading `~` in an ssh config path (IdentityFile). */
|
|
340
|
+
export function expandHome(p) {
|
|
341
|
+
const s = String(p || '')
|
|
342
|
+
if (!s.startsWith('~')) return s
|
|
343
|
+
if (s === '~') return homedir()
|
|
344
|
+
if (s.startsWith('~/') || s.startsWith('~\\')) return path.join(homedir(), s.slice(2))
|
|
345
|
+
return s
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Split a ProxyJump value into hops: `[user@]host[:port]`, comma-separated, and
|
|
350
|
+
* `none` meaning "no jump".
|
|
351
|
+
* @param {string} value
|
|
352
|
+
* @returns {Array<{user: string, host: string, port: number}>}
|
|
353
|
+
*/
|
|
354
|
+
export function parseProxyJump(value) {
|
|
355
|
+
const raw = String(value || '').trim()
|
|
356
|
+
if (!raw || /^none$/i.test(raw)) return []
|
|
357
|
+
return raw.split(',').map((hop) => hop.trim()).filter(Boolean).map((hop) => {
|
|
358
|
+
const m = hop.match(/^(?:([^@]+)@)?([^:[\] ]+)(?::(\d+))?$/)
|
|
359
|
+
if (!m) return { user: '', host: hop, port: 0 }
|
|
360
|
+
return { user: m[1] || '', host: m[2], port: Number(m[3]) || 0 }
|
|
361
|
+
})
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Connect target for an ssh-config alias: the shape the plugin's pool expects.
|
|
366
|
+
*
|
|
367
|
+
* `ProxyJump` maps onto the plugin's single jump host. A multi-hop chain cannot
|
|
368
|
+
* be represented by one `proxy`, so the FIRST hop is used and `warnings` says so
|
|
369
|
+
* — silently connecting through the wrong path would be worse. `ProxyCommand`
|
|
370
|
+
* (an arbitrary local command) is reported but never executed.
|
|
371
|
+
*
|
|
372
|
+
* @param {string} alias
|
|
373
|
+
* @param {object} [opts]
|
|
374
|
+
* @param {string} [opts.text] - config text (else the real file is read).
|
|
375
|
+
* @param {string} [opts.file]
|
|
376
|
+
* @param {object} [opts.io]
|
|
377
|
+
* @returns {{alias: string, matched: boolean, host: string, port: number, username: string, privateKeyPath: string, proxy: object|undefined, proxyJump: string, warnings: string[]}}
|
|
378
|
+
*/
|
|
379
|
+
export function sshAliasTarget(alias, opts = {}) {
|
|
380
|
+
const text = opts.text !== undefined ? String(opts.text) : loadSshConfigText(opts.file || sshConfigPath(), opts.io || {})
|
|
381
|
+
const resolved = resolveSshHost(alias, text)
|
|
382
|
+
const warnings = []
|
|
383
|
+
let proxy
|
|
384
|
+
const hops = parseProxyJump(resolved.proxyJump)
|
|
385
|
+
if (hops.length > 1) {
|
|
386
|
+
warnings.push(`ProxyJump has ${hops.length} hops; dsh-remote supports one jump host — using the first (${hops[0].host}).`)
|
|
387
|
+
}
|
|
388
|
+
if (hops.length) {
|
|
389
|
+
const hop = hops[0]
|
|
390
|
+
const hopResolved = resolveSshHost(hop.host, text)
|
|
391
|
+
proxy = {
|
|
392
|
+
host: hopResolved.hostName || hop.host,
|
|
393
|
+
port: hop.port || hopResolved.port || 22,
|
|
394
|
+
username: hop.user || hopResolved.user || '',
|
|
395
|
+
privateKeyPath: hopResolved.identityFile ? expandHome(hopResolved.identityFile) : '',
|
|
396
|
+
passphrase: '',
|
|
397
|
+
password: '',
|
|
398
|
+
}
|
|
399
|
+
} else if (resolved.proxyCommand) {
|
|
400
|
+
warnings.push('ProxyCommand is not supported by dsh-remote (no jump host was applied).')
|
|
401
|
+
}
|
|
402
|
+
return {
|
|
403
|
+
alias,
|
|
404
|
+
matched: resolved.matched,
|
|
405
|
+
host: resolved.hostName || alias,
|
|
406
|
+
port: resolved.port || 22,
|
|
407
|
+
username: resolved.user || '',
|
|
408
|
+
privateKeyPath: resolved.identityFile ? expandHome(resolved.identityFile) : '',
|
|
409
|
+
proxy,
|
|
410
|
+
proxyJump: resolved.proxyJump,
|
|
411
|
+
warnings,
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Effective connect identity for a saved machine.
|
|
417
|
+
*
|
|
418
|
+
* A machine in ALIAS mode (`useSshConfig: true`, `host` = the alias) inherits
|
|
419
|
+
* everything from ~/.ssh/config; a field the user filled in explicitly still
|
|
420
|
+
* wins. "Explicitly" cannot be distinguished from "form default" for the two
|
|
421
|
+
* fields that have defaults (port 22, user `root`), so those inherit unless they
|
|
422
|
+
* differ from the default — stated in the settings UI.
|
|
423
|
+
*
|
|
424
|
+
* @param {object} machine
|
|
425
|
+
* @param {{text?: string, file?: string, io?: object}} [opts]
|
|
426
|
+
* @returns {{alias: string, matched: boolean, host: string, port: number, username: string, privateKeyPath: string, proxy: object|undefined, warnings: string[]}|null}
|
|
427
|
+
* null when the machine is not in alias mode.
|
|
428
|
+
*/
|
|
429
|
+
export function resolveMachineSshConfig(machine, opts = {}) {
|
|
430
|
+
if (!machine || !machine.useSshConfig) return null
|
|
431
|
+
const alias = String(machine.host || '').trim()
|
|
432
|
+
if (!alias) return null
|
|
433
|
+
const target = sshAliasTarget(alias, opts)
|
|
434
|
+
const warnings = [...target.warnings]
|
|
435
|
+
if (!target.matched) warnings.push(`no ~/.ssh/config block matches "${alias}" — using the name itself.`)
|
|
436
|
+
const port = machine.port && Number(machine.port) !== 22 ? Number(machine.port) : target.port
|
|
437
|
+
const username = machine.username && machine.username !== 'root' ? String(machine.username) : (target.username || machine.username || '')
|
|
438
|
+
const privateKeyPath = machine.privateKeyPath ? String(machine.privateKeyPath) : target.privateKeyPath
|
|
439
|
+
const proxy = machine.proxy && machine.proxy.host ? machine.proxy : target.proxy
|
|
440
|
+
return {
|
|
441
|
+
alias,
|
|
442
|
+
matched: target.matched,
|
|
443
|
+
host: target.host,
|
|
444
|
+
port: Number(port) || 22,
|
|
445
|
+
username: String(username || ''),
|
|
446
|
+
privateKeyPath,
|
|
447
|
+
proxy,
|
|
448
|
+
warnings,
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function compareText(left, right) {
|
|
453
|
+
return left < right ? -1 : left > right ? 1 : 0
|
|
454
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.23",
|
|
4
4
|
"description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive/proxy jump), pick a remote workspace, operate on it with 20 rw_* tools (incl. rw_edit/rw_stat/rw_mkdir/rw_remove/rw_move/rw_forward), conflict-aware mirror sync, port forwarding, optional side-bar remote file editing, audit log, keychain passwords.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "flymysql <flyphp@outlook.com>",
|