dsh-remote 0.8.72 → 0.8.74
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/LICENSE +21 -21
- package/README.en.md +470 -389
- package/README.md +529 -462
- package/cordis.patch.yml +9 -9
- package/lib/binding.js +172 -172
- package/lib/client.js +4039 -4022
- package/lib/credential.js +263 -263
- package/lib/errors.js +141 -141
- package/lib/exec-job.js +235 -235
- package/lib/file-reference.js +457 -457
- package/lib/forwards.js +258 -258
- package/lib/host-api.js +808 -0
- package/lib/hostkey.js +108 -108
- package/lib/http-transport.js +196 -196
- package/lib/ignore.js +202 -202
- package/lib/index.js +4923 -4541
- package/lib/instructions-dynamic.js +415 -415
- package/lib/openssh.js +879 -879
- package/lib/paths.js +252 -252
- package/lib/pool.js +963 -893
- package/lib/registry.js +296 -293
- package/lib/remote-fs.js +173 -173
- package/lib/remote-sandbox.js +183 -183
- package/lib/remote-shell-child.cjs +344 -305
- package/lib/remote-shell.js +542 -518
- package/lib/routes-fs.js +245 -245
- package/lib/routes-term.js +169 -169
- package/lib/search.js +235 -235
- package/lib/sftp-surface.js +119 -119
- package/lib/sshconfig.js +779 -705
- package/lib/sync.js +275 -275
- package/lib/tasks.js +95 -95
- package/lib/telemetry.js +213 -213
- package/lib/terminal.js +205 -205
- package/lib/tui/flow.js +130 -0
- package/lib/tui/format.js +332 -0
- package/lib/tui/i18n.js +344 -0
- package/lib/tui/index.js +322 -0
- package/lib/tui/provider.js +327 -0
- package/lib/tui/scene.js +996 -0
- package/lib/update.js +472 -472
- package/lib/web-attach.js +850 -850
- package/lib/workspace-cleanup.js +337 -0
- package/package.json +100 -99
package/lib/registry.js
CHANGED
|
@@ -1,293 +1,296 @@
|
|
|
1
|
-
// Machine-registry pure logic for dsh-remote.
|
|
2
|
-
//
|
|
3
|
-
// Issue #13: a SAVED machine is only a standby connection — it must never
|
|
4
|
-
// silently become the "active remote context" of every session. The registry
|
|
5
|
-
// therefore keeps `currentId` as an EXPLICIT user choice:
|
|
6
|
-
// • loadMachines never falls back to the first saved machine,
|
|
7
|
-
// • a stale currentId (machine deleted meanwhile) resolves to null,
|
|
8
|
-
// • add/update never auto-activate; only an explicit "set current" does,
|
|
9
|
-
// • deleting the current machine leaves NO current (no auto-promotion).
|
|
10
|
-
// Keeping this logic in one module makes the guarantee unit-testable and
|
|
11
|
-
// shared between the HTTP routes and the tools (rw_connect).
|
|
12
|
-
|
|
13
|
-
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
14
|
-
import path from 'node:path'
|
|
15
|
-
|
|
16
|
-
/** Load { list, currentId, explicitNone } from the registry file. */
|
|
17
|
-
export function loadMachines(file) {
|
|
18
|
-
try {
|
|
19
|
-
const j = JSON.parse(readFileSync(file, 'utf8'))
|
|
20
|
-
if (Array.isArray(j.list)) {
|
|
21
|
-
const list = j.list
|
|
22
|
-
// Explicit choice only — no fallback to the first machine, no activation
|
|
23
|
-
// of a stale/deleted id (issue #13).
|
|
24
|
-
const currentId = j.currentId && list.some((m) => m.id === j.currentId) ? j.currentId : null
|
|
25
|
-
// A persisted `currentId: null` is a deliberate "active remote = none"
|
|
26
|
-
// (the user cleared the current machine). It must survive restarts so a
|
|
27
|
-
// config-level default host cannot silently reactivate a context the user
|
|
28
|
-
// explicitly turned off. `explicitNone` is true when the file carries an
|
|
29
|
-
// explicit null (vs. a fresh file where currentId is simply absent).
|
|
30
|
-
const explicitNone = Object.prototype.hasOwnProperty.call(j, 'currentId') && j.currentId == null
|
|
31
|
-
return { list, currentId, explicitNone }
|
|
32
|
-
}
|
|
33
|
-
} catch {}
|
|
34
|
-
return { list: [], currentId: null, explicitNone: false }
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Persist { list, currentId }. `currentId` may be null → active remote = none.
|
|
38
|
-
* Pass `keepCurrentKey: false` (add/update a machine without touching the
|
|
39
|
-
* current selection) to PRESERVE whatever `currentId` the file already holds
|
|
40
|
-
* — so merely saving a machine never flips a fresh registry into "explicit
|
|
41
|
-
* none", nor clears an explicit choice. When no previous file exists the
|
|
42
|
-
* `currentId` key is omitted entirely (fresh registry = "no choice yet"). */
|
|
43
|
-
export function saveMachines(file, list, currentId, keepCurrentKey = true) {
|
|
44
|
-
try { mkdirSync(path.dirname(file), { recursive: true }) } catch {}
|
|
45
|
-
let stored = currentId
|
|
46
|
-
let hasStored = keepCurrentKey
|
|
47
|
-
if (!keepCurrentKey) {
|
|
48
|
-
try {
|
|
49
|
-
const prev = JSON.parse(readFileSync(file, 'utf8'))
|
|
50
|
-
if (prev && Object.prototype.hasOwnProperty.call(prev, 'currentId')) {
|
|
51
|
-
stored = prev.currentId
|
|
52
|
-
hasStored = true
|
|
53
|
-
}
|
|
54
|
-
} catch { /* no previous file → omit the key */ }
|
|
55
|
-
}
|
|
56
|
-
const payload = { list }
|
|
57
|
-
if (hasStored) payload.currentId = stored
|
|
58
|
-
writeFileSync(file, JSON.stringify(payload, null, 2))
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Does this record actually HAVE a password the plugin can use?
|
|
63
|
-
*
|
|
64
|
-
* `passwordSet` used to be `!!m.password`, i.e. "a PLAINTEXT password exists in
|
|
65
|
-
* machines.json". A machine whose password lives in the OS keychain stores
|
|
66
|
-
* `password: ''` (by design — issue #30), so it reported `passwordSet: false`
|
|
67
|
-
* and the settings page showed an empty password box with no indication that a
|
|
68
|
-
* credential was saved. That is the difference between "the plugin forgot my
|
|
69
|
-
* password" and "the plugin has it and will not echo it".
|
|
70
|
-
*
|
|
71
|
-
* `secretStored` is written by the save route from the keychain result, so new
|
|
72
|
-
* records carry the truth. Records saved before that flag existed only carry the
|
|
73
|
-
* backend name, which is the best evidence available for them (a non-plain
|
|
74
|
-
* backend means a secret was meant to live in the OS store).
|
|
75
|
-
*
|
|
76
|
-
* @param {object} m - a registry record.
|
|
77
|
-
* @returns {boolean}
|
|
78
|
-
*/
|
|
79
|
-
export function hasStoredPassword(m) {
|
|
80
|
-
if (!m) return false
|
|
81
|
-
if (m.password) return true
|
|
82
|
-
if (m.secretStored === true) return true
|
|
83
|
-
if (m.secretStored === undefined && m.credentialBackend && m.credentialBackend !== 'plain') return true
|
|
84
|
-
return false
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Strip secrets before sending a machine to the client.
|
|
88
|
-
*
|
|
89
|
-
* Secrets never round-trip to the browser: a password is replaced by
|
|
90
|
-
* `passwordSet`, and the private-key `passphrase` — which IS stored in the
|
|
91
|
-
* record — is replaced by `passphraseSet`. Both flags exist so the settings form
|
|
92
|
-
* can say "已保存(••••••••),留空 = 不修改" instead of showing an empty box that
|
|
93
|
-
* looks like the plugin lost the value. */
|
|
94
|
-
export function sanitizeMachine(m) {
|
|
95
|
-
if (!m) return m
|
|
96
|
-
const { password, passphrase, proxy, ...rest } = m
|
|
97
|
-
const out = {
|
|
98
|
-
...rest,
|
|
99
|
-
passwordSet: hasStoredPassword(m),
|
|
100
|
-
passphraseSet: !!(passphrase && String(passphrase).length),
|
|
101
|
-
}
|
|
102
|
-
if (proxy) {
|
|
103
|
-
out.proxy = {
|
|
104
|
-
...proxy,
|
|
105
|
-
password: proxy.password ? '' : undefined,
|
|
106
|
-
passwordSet: !!(proxy.password && proxy.password.length),
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
return out
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Resolve the credentials a TEST probe must dial with (issue: "编辑已连接机器 → 测试
|
|
114
|
-
* 连接" always failed with `no credentials`).
|
|
115
|
-
*
|
|
116
|
-
* The settings form never receives stored secrets, so an edit-then-test posted an
|
|
117
|
-
* EMPTY password while a real connect (which resolves the keychain/plain secret
|
|
118
|
-
* through the pool's `passwordResolver`) worked fine. The probe therefore has to
|
|
119
|
-
* be a MERGE: the form is an override layer, and anything it leaves blank falls
|
|
120
|
-
* back to what is stored for that machine.
|
|
121
|
-
*
|
|
122
|
-
* Rules, deliberately one-way (a form value never gets discarded):
|
|
123
|
-
* · a non-empty form string wins;
|
|
124
|
-
* · an empty/absent form string keeps the stored value;
|
|
125
|
-
* · booleans (`useAgent`, `keyboardInteractive`) are taken from the form when
|
|
126
|
-
* the form actually supplied a boolean, otherwise from the record — the
|
|
127
|
-
* probe must exercise the SAME auth method the machine will use;
|
|
128
|
-
* · a jump-host (`proxy`) password the form did not re-supply is carried over
|
|
129
|
-
* from the stored proxy, otherwise a bastion-protected machine "fails to
|
|
130
|
-
* test" for a reason that has nothing to do with the target.
|
|
131
|
-
*
|
|
132
|
-
* @param {object} body - the probe request body (form values).
|
|
133
|
-
* @param {object|null} rec - the saved machine record, if any.
|
|
134
|
-
* @param {string} [storedPassword] - the record's resolvable password (may come
|
|
135
|
-
* from the OS keychain, hence the separate argument).
|
|
136
|
-
* @returns {{password:string,passphrase:string,privateKeyPath:string,useAgent:boolean,
|
|
137
|
-
* keyboardInteractive:boolean,hostKeyMode:string,sshBinary:string,backend:string,
|
|
138
|
-
* proxy:object|undefined,usedSavedCredential:boolean}}
|
|
139
|
-
*/
|
|
140
|
-
export function resolveProbeCredentials(body = {}, rec = null, storedPassword = '') {
|
|
141
|
-
const form = body || {}
|
|
142
|
-
const saved = rec || {}
|
|
143
|
-
const str = (v) => (v === undefined || v === null ? '' : String(v))
|
|
144
|
-
const pick = (formVal, storedVal) => {
|
|
145
|
-
const f = str(formVal).trim()
|
|
146
|
-
return f !== '' ? str(formVal) : str(storedVal)
|
|
147
|
-
}
|
|
148
|
-
const pickBool = (formVal, storedVal) => (typeof formVal === 'boolean' ? formVal : !!storedVal)
|
|
149
|
-
|
|
150
|
-
const formPassword = str(form.password)
|
|
151
|
-
const password = formPassword !== '' ? formPassword : str(storedPassword)
|
|
152
|
-
const formProxy = form.proxy && form.proxy.host ? form.proxy : null
|
|
153
|
-
const savedProxy = saved.proxy && saved.proxy.host ? saved.proxy : null
|
|
154
|
-
const baseProxy = formProxy || savedProxy
|
|
155
|
-
const proxy = baseProxy
|
|
156
|
-
? {
|
|
157
|
-
host: String(baseProxy.host),
|
|
158
|
-
port: Number(baseProxy.port) || 22,
|
|
159
|
-
username: str(baseProxy.username),
|
|
160
|
-
password: formProxy && str(formProxy.password) !== ''
|
|
161
|
-
? str(formProxy.password)
|
|
162
|
-
: str(savedProxy && savedProxy.password),
|
|
163
|
-
privateKeyPath: str(baseProxy.privateKeyPath),
|
|
164
|
-
}
|
|
165
|
-
: undefined
|
|
166
|
-
|
|
167
|
-
return {
|
|
168
|
-
password,
|
|
169
|
-
passphrase: pick(form.passphrase, saved.passphrase),
|
|
170
|
-
privateKeyPath: pick(form.privateKeyPath, saved.privateKeyPath),
|
|
171
|
-
useAgent: pickBool(form.useAgent, saved.useAgent),
|
|
172
|
-
keyboardInteractive: pickBool(form.keyboardInteractive, saved.keyboardInteractive),
|
|
173
|
-
hostKeyMode: pick(form.hostKeyMode, saved.hostKeyMode),
|
|
174
|
-
sshBinary: pick(form.sshBinary, saved.sshBinary),
|
|
175
|
-
backend: pick(form.backend, saved.backend) || 'native',
|
|
176
|
-
proxy,
|
|
177
|
-
// True when the form could not supply the credential and the stored one had
|
|
178
|
-
// to be used — the UI says so, because "测试成功" with an empty password box
|
|
179
|
-
// is otherwise indistinguishable from a form-only probe.
|
|
180
|
-
usedSavedCredential: formPassword === '' && password !== '',
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/** Machine grouping metadata (host organisation).
|
|
185
|
-
*
|
|
186
|
-
* A flat list does not survive contact with reality: once a user has a dozen
|
|
187
|
-
* machines (prod/staging/GPU boxes/laptops) "pick the right one" becomes the
|
|
188
|
-
* dominant failure mode, and picking the WRONG one on a destructive tool is
|
|
189
|
-
* how accidents happen. These four fields are pure LABELS — they change no
|
|
190
|
-
* connection behaviour — so they live beside it and can be edited freely.
|
|
191
|
-
*
|
|
192
|
-
* Normalization is deliberately conservative: a bad value is dropped (not
|
|
193
|
-
* coerced into something surprising), and every field is optional so existing
|
|
194
|
-
* registries keep loading byte-for-byte.
|
|
195
|
-
*
|
|
196
|
-
* @param {object} raw - a (possibly partial) group patch.
|
|
197
|
-
* @returns {{environment?: string, location?: string, description?: string, tags?: string[]}}
|
|
198
|
-
*/
|
|
199
|
-
export function normalizeMachineGroup(raw) {
|
|
200
|
-
const out = {}
|
|
201
|
-
if (!raw || typeof raw !== 'object') return out
|
|
202
|
-
const str = (v, max) => {
|
|
203
|
-
const s = typeof v === 'string' ? v.trim() : ''
|
|
204
|
-
return s ? s.slice(0, max) : ''
|
|
205
|
-
}
|
|
206
|
-
const env = str(raw.environment, 40)
|
|
207
|
-
if (env) out.environment = env
|
|
208
|
-
const loc = str(raw.location, 40)
|
|
209
|
-
if (loc) out.location = loc
|
|
210
|
-
const desc = str(raw.description, 200)
|
|
211
|
-
if (desc) out.description = desc
|
|
212
|
-
// Tags: accept an array or a comma/space separated string (what a UI text box
|
|
213
|
-
// naturally produces), then de-duplicate case-insensitively while KEEPING the
|
|
214
|
-
// first spelling so the display stays stable.
|
|
215
|
-
let tags = raw.tags
|
|
216
|
-
if (typeof tags === 'string') tags = tags.split(/[,\s]+/)
|
|
217
|
-
if (Array.isArray(tags)) {
|
|
218
|
-
const seen = new Set()
|
|
219
|
-
const kept = []
|
|
220
|
-
for (const t of tags) {
|
|
221
|
-
const s = str(t, 32)
|
|
222
|
-
if (!s) continue
|
|
223
|
-
const k = s.toLowerCase()
|
|
224
|
-
if (seen.has(k)) continue
|
|
225
|
-
seen.add(k)
|
|
226
|
-
kept.push(s)
|
|
227
|
-
if (kept.length >= 12) break // a dozen labels is already too many to scan
|
|
228
|
-
}
|
|
229
|
-
if (kept.length) out.tags = kept
|
|
230
|
-
}
|
|
231
|
-
return out
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Select machines by group metadata — the predicate behind "run this on every
|
|
236
|
-
* staging box". All supplied filters must match (AND); tags are OR within
|
|
237
|
-
* themselves (any requested tag qualifies) which is how people read a tag row.
|
|
238
|
-
*
|
|
239
|
-
* @param {Array<object>} machines
|
|
240
|
-
* @param {{environment?: string, location?: string, tags?: string[]|string, query?: string}} filter
|
|
241
|
-
* @returns {Array<object>}
|
|
242
|
-
*/
|
|
243
|
-
export function filterMachinesByGroup(machines, filter = {}) {
|
|
244
|
-
const list = Array.isArray(machines) ? machines : []
|
|
245
|
-
const env = typeof filter.environment === 'string' ? filter.environment.trim().toLowerCase() : ''
|
|
246
|
-
const loc = typeof filter.location === 'string' ? filter.location.trim().toLowerCase() : ''
|
|
247
|
-
let wantTags = filter.tags
|
|
248
|
-
if (typeof wantTags === 'string') wantTags = wantTags.split(/[,\s]+/)
|
|
249
|
-
wantTags = (Array.isArray(wantTags) ? wantTags : []).map((t) => String(t).trim().toLowerCase()).filter(Boolean)
|
|
250
|
-
const query = typeof filter.query === 'string' ? filter.query.trim().toLowerCase() : ''
|
|
251
|
-
return list.filter((m) => {
|
|
252
|
-
if (!m) return false
|
|
253
|
-
if (env && String(m.environment || '').toLowerCase() !== env) return false
|
|
254
|
-
if (loc && String(m.location || '').toLowerCase() !== loc) return false
|
|
255
|
-
if (wantTags.length) {
|
|
256
|
-
const have = (Array.isArray(m.tags) ? m.tags : []).map((t) => String(t).toLowerCase())
|
|
257
|
-
if (!wantTags.some((t) => have.includes(t))) return false
|
|
258
|
-
}
|
|
259
|
-
if (query) {
|
|
260
|
-
const hay = [m.name, m.host, m.username, m.environment, m.location, m.description, ...(Array.isArray(m.tags) ? m.tags : [])]
|
|
261
|
-
.filter(Boolean).join(' ').toLowerCase()
|
|
262
|
-
if (!hay.includes(query)) return false
|
|
263
|
-
}
|
|
264
|
-
return true
|
|
265
|
-
})
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/** Apply a machine's fields onto the live config object (pool + tools read it). */
|
|
269
|
-
export function applyMachine(config, m) {
|
|
270
|
-
if (!m) return
|
|
271
|
-
config.host = m.host
|
|
272
|
-
config.port = Number(m.port) || 22
|
|
273
|
-
config.username = m.username || ''
|
|
274
|
-
config.password = m.password || ''
|
|
275
|
-
config.privateKeyPath = m.privateKeyPath || ''
|
|
276
|
-
config.passphrase = m.passphrase || ''
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
//
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
1
|
+
// Machine-registry pure logic for dsh-remote.
|
|
2
|
+
//
|
|
3
|
+
// Issue #13: a SAVED machine is only a standby connection — it must never
|
|
4
|
+
// silently become the "active remote context" of every session. The registry
|
|
5
|
+
// therefore keeps `currentId` as an EXPLICIT user choice:
|
|
6
|
+
// • loadMachines never falls back to the first saved machine,
|
|
7
|
+
// • a stale currentId (machine deleted meanwhile) resolves to null,
|
|
8
|
+
// • add/update never auto-activate; only an explicit "set current" does,
|
|
9
|
+
// • deleting the current machine leaves NO current (no auto-promotion).
|
|
10
|
+
// Keeping this logic in one module makes the guarantee unit-testable and
|
|
11
|
+
// shared between the HTTP routes and the tools (rw_connect).
|
|
12
|
+
|
|
13
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
|
|
16
|
+
/** Load { list, currentId, explicitNone } from the registry file. */
|
|
17
|
+
export function loadMachines(file) {
|
|
18
|
+
try {
|
|
19
|
+
const j = JSON.parse(readFileSync(file, 'utf8'))
|
|
20
|
+
if (Array.isArray(j.list)) {
|
|
21
|
+
const list = j.list
|
|
22
|
+
// Explicit choice only — no fallback to the first machine, no activation
|
|
23
|
+
// of a stale/deleted id (issue #13).
|
|
24
|
+
const currentId = j.currentId && list.some((m) => m.id === j.currentId) ? j.currentId : null
|
|
25
|
+
// A persisted `currentId: null` is a deliberate "active remote = none"
|
|
26
|
+
// (the user cleared the current machine). It must survive restarts so a
|
|
27
|
+
// config-level default host cannot silently reactivate a context the user
|
|
28
|
+
// explicitly turned off. `explicitNone` is true when the file carries an
|
|
29
|
+
// explicit null (vs. a fresh file where currentId is simply absent).
|
|
30
|
+
const explicitNone = Object.prototype.hasOwnProperty.call(j, 'currentId') && j.currentId == null
|
|
31
|
+
return { list, currentId, explicitNone }
|
|
32
|
+
}
|
|
33
|
+
} catch {}
|
|
34
|
+
return { list: [], currentId: null, explicitNone: false }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Persist { list, currentId }. `currentId` may be null → active remote = none.
|
|
38
|
+
* Pass `keepCurrentKey: false` (add/update a machine without touching the
|
|
39
|
+
* current selection) to PRESERVE whatever `currentId` the file already holds
|
|
40
|
+
* — so merely saving a machine never flips a fresh registry into "explicit
|
|
41
|
+
* none", nor clears an explicit choice. When no previous file exists the
|
|
42
|
+
* `currentId` key is omitted entirely (fresh registry = "no choice yet"). */
|
|
43
|
+
export function saveMachines(file, list, currentId, keepCurrentKey = true) {
|
|
44
|
+
try { mkdirSync(path.dirname(file), { recursive: true }) } catch {}
|
|
45
|
+
let stored = currentId
|
|
46
|
+
let hasStored = keepCurrentKey
|
|
47
|
+
if (!keepCurrentKey) {
|
|
48
|
+
try {
|
|
49
|
+
const prev = JSON.parse(readFileSync(file, 'utf8'))
|
|
50
|
+
if (prev && Object.prototype.hasOwnProperty.call(prev, 'currentId')) {
|
|
51
|
+
stored = prev.currentId
|
|
52
|
+
hasStored = true
|
|
53
|
+
}
|
|
54
|
+
} catch { /* no previous file → omit the key */ }
|
|
55
|
+
}
|
|
56
|
+
const payload = { list }
|
|
57
|
+
if (hasStored) payload.currentId = stored
|
|
58
|
+
writeFileSync(file, JSON.stringify(payload, null, 2))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Does this record actually HAVE a password the plugin can use?
|
|
63
|
+
*
|
|
64
|
+
* `passwordSet` used to be `!!m.password`, i.e. "a PLAINTEXT password exists in
|
|
65
|
+
* machines.json". A machine whose password lives in the OS keychain stores
|
|
66
|
+
* `password: ''` (by design — issue #30), so it reported `passwordSet: false`
|
|
67
|
+
* and the settings page showed an empty password box with no indication that a
|
|
68
|
+
* credential was saved. That is the difference between "the plugin forgot my
|
|
69
|
+
* password" and "the plugin has it and will not echo it".
|
|
70
|
+
*
|
|
71
|
+
* `secretStored` is written by the save route from the keychain result, so new
|
|
72
|
+
* records carry the truth. Records saved before that flag existed only carry the
|
|
73
|
+
* backend name, which is the best evidence available for them (a non-plain
|
|
74
|
+
* backend means a secret was meant to live in the OS store).
|
|
75
|
+
*
|
|
76
|
+
* @param {object} m - a registry record.
|
|
77
|
+
* @returns {boolean}
|
|
78
|
+
*/
|
|
79
|
+
export function hasStoredPassword(m) {
|
|
80
|
+
if (!m) return false
|
|
81
|
+
if (m.password) return true
|
|
82
|
+
if (m.secretStored === true) return true
|
|
83
|
+
if (m.secretStored === undefined && m.credentialBackend && m.credentialBackend !== 'plain') return true
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Strip secrets before sending a machine to the client.
|
|
88
|
+
*
|
|
89
|
+
* Secrets never round-trip to the browser: a password is replaced by
|
|
90
|
+
* `passwordSet`, and the private-key `passphrase` — which IS stored in the
|
|
91
|
+
* record — is replaced by `passphraseSet`. Both flags exist so the settings form
|
|
92
|
+
* can say "已保存(••••••••),留空 = 不修改" instead of showing an empty box that
|
|
93
|
+
* looks like the plugin lost the value. */
|
|
94
|
+
export function sanitizeMachine(m) {
|
|
95
|
+
if (!m) return m
|
|
96
|
+
const { password, passphrase, proxy, ...rest } = m
|
|
97
|
+
const out = {
|
|
98
|
+
...rest,
|
|
99
|
+
passwordSet: hasStoredPassword(m),
|
|
100
|
+
passphraseSet: !!(passphrase && String(passphrase).length),
|
|
101
|
+
}
|
|
102
|
+
if (proxy) {
|
|
103
|
+
out.proxy = {
|
|
104
|
+
...proxy,
|
|
105
|
+
password: proxy.password ? '' : undefined,
|
|
106
|
+
passwordSet: !!(proxy.password && proxy.password.length),
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return out
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolve the credentials a TEST probe must dial with (issue: "编辑已连接机器 → 测试
|
|
114
|
+
* 连接" always failed with `no credentials`).
|
|
115
|
+
*
|
|
116
|
+
* The settings form never receives stored secrets, so an edit-then-test posted an
|
|
117
|
+
* EMPTY password while a real connect (which resolves the keychain/plain secret
|
|
118
|
+
* through the pool's `passwordResolver`) worked fine. The probe therefore has to
|
|
119
|
+
* be a MERGE: the form is an override layer, and anything it leaves blank falls
|
|
120
|
+
* back to what is stored for that machine.
|
|
121
|
+
*
|
|
122
|
+
* Rules, deliberately one-way (a form value never gets discarded):
|
|
123
|
+
* · a non-empty form string wins;
|
|
124
|
+
* · an empty/absent form string keeps the stored value;
|
|
125
|
+
* · booleans (`useAgent`, `keyboardInteractive`) are taken from the form when
|
|
126
|
+
* the form actually supplied a boolean, otherwise from the record — the
|
|
127
|
+
* probe must exercise the SAME auth method the machine will use;
|
|
128
|
+
* · a jump-host (`proxy`) password the form did not re-supply is carried over
|
|
129
|
+
* from the stored proxy, otherwise a bastion-protected machine "fails to
|
|
130
|
+
* test" for a reason that has nothing to do with the target.
|
|
131
|
+
*
|
|
132
|
+
* @param {object} body - the probe request body (form values).
|
|
133
|
+
* @param {object|null} rec - the saved machine record, if any.
|
|
134
|
+
* @param {string} [storedPassword] - the record's resolvable password (may come
|
|
135
|
+
* from the OS keychain, hence the separate argument).
|
|
136
|
+
* @returns {{password:string,passphrase:string,privateKeyPath:string,useAgent:boolean,
|
|
137
|
+
* keyboardInteractive:boolean,hostKeyMode:string,sshBinary:string,backend:string,
|
|
138
|
+
* proxy:object|undefined,usedSavedCredential:boolean}}
|
|
139
|
+
*/
|
|
140
|
+
export function resolveProbeCredentials(body = {}, rec = null, storedPassword = '') {
|
|
141
|
+
const form = body || {}
|
|
142
|
+
const saved = rec || {}
|
|
143
|
+
const str = (v) => (v === undefined || v === null ? '' : String(v))
|
|
144
|
+
const pick = (formVal, storedVal) => {
|
|
145
|
+
const f = str(formVal).trim()
|
|
146
|
+
return f !== '' ? str(formVal) : str(storedVal)
|
|
147
|
+
}
|
|
148
|
+
const pickBool = (formVal, storedVal) => (typeof formVal === 'boolean' ? formVal : !!storedVal)
|
|
149
|
+
|
|
150
|
+
const formPassword = str(form.password)
|
|
151
|
+
const password = formPassword !== '' ? formPassword : str(storedPassword)
|
|
152
|
+
const formProxy = form.proxy && form.proxy.host ? form.proxy : null
|
|
153
|
+
const savedProxy = saved.proxy && saved.proxy.host ? saved.proxy : null
|
|
154
|
+
const baseProxy = formProxy || savedProxy
|
|
155
|
+
const proxy = baseProxy
|
|
156
|
+
? {
|
|
157
|
+
host: String(baseProxy.host),
|
|
158
|
+
port: Number(baseProxy.port) || 22,
|
|
159
|
+
username: str(baseProxy.username),
|
|
160
|
+
password: formProxy && str(formProxy.password) !== ''
|
|
161
|
+
? str(formProxy.password)
|
|
162
|
+
: str(savedProxy && savedProxy.password),
|
|
163
|
+
privateKeyPath: str(baseProxy.privateKeyPath),
|
|
164
|
+
}
|
|
165
|
+
: undefined
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
password,
|
|
169
|
+
passphrase: pick(form.passphrase, saved.passphrase),
|
|
170
|
+
privateKeyPath: pick(form.privateKeyPath, saved.privateKeyPath),
|
|
171
|
+
useAgent: pickBool(form.useAgent, saved.useAgent),
|
|
172
|
+
keyboardInteractive: pickBool(form.keyboardInteractive, saved.keyboardInteractive),
|
|
173
|
+
hostKeyMode: pick(form.hostKeyMode, saved.hostKeyMode),
|
|
174
|
+
sshBinary: pick(form.sshBinary, saved.sshBinary),
|
|
175
|
+
backend: pick(form.backend, saved.backend) || 'native',
|
|
176
|
+
proxy,
|
|
177
|
+
// True when the form could not supply the credential and the stored one had
|
|
178
|
+
// to be used — the UI says so, because "测试成功" with an empty password box
|
|
179
|
+
// is otherwise indistinguishable from a form-only probe.
|
|
180
|
+
usedSavedCredential: formPassword === '' && password !== '',
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Machine grouping metadata (host organisation).
|
|
185
|
+
*
|
|
186
|
+
* A flat list does not survive contact with reality: once a user has a dozen
|
|
187
|
+
* machines (prod/staging/GPU boxes/laptops) "pick the right one" becomes the
|
|
188
|
+
* dominant failure mode, and picking the WRONG one on a destructive tool is
|
|
189
|
+
* how accidents happen. These four fields are pure LABELS — they change no
|
|
190
|
+
* connection behaviour — so they live beside it and can be edited freely.
|
|
191
|
+
*
|
|
192
|
+
* Normalization is deliberately conservative: a bad value is dropped (not
|
|
193
|
+
* coerced into something surprising), and every field is optional so existing
|
|
194
|
+
* registries keep loading byte-for-byte.
|
|
195
|
+
*
|
|
196
|
+
* @param {object} raw - a (possibly partial) group patch.
|
|
197
|
+
* @returns {{environment?: string, location?: string, description?: string, tags?: string[]}}
|
|
198
|
+
*/
|
|
199
|
+
export function normalizeMachineGroup(raw) {
|
|
200
|
+
const out = {}
|
|
201
|
+
if (!raw || typeof raw !== 'object') return out
|
|
202
|
+
const str = (v, max) => {
|
|
203
|
+
const s = typeof v === 'string' ? v.trim() : ''
|
|
204
|
+
return s ? s.slice(0, max) : ''
|
|
205
|
+
}
|
|
206
|
+
const env = str(raw.environment, 40)
|
|
207
|
+
if (env) out.environment = env
|
|
208
|
+
const loc = str(raw.location, 40)
|
|
209
|
+
if (loc) out.location = loc
|
|
210
|
+
const desc = str(raw.description, 200)
|
|
211
|
+
if (desc) out.description = desc
|
|
212
|
+
// Tags: accept an array or a comma/space separated string (what a UI text box
|
|
213
|
+
// naturally produces), then de-duplicate case-insensitively while KEEPING the
|
|
214
|
+
// first spelling so the display stays stable.
|
|
215
|
+
let tags = raw.tags
|
|
216
|
+
if (typeof tags === 'string') tags = tags.split(/[,\s]+/)
|
|
217
|
+
if (Array.isArray(tags)) {
|
|
218
|
+
const seen = new Set()
|
|
219
|
+
const kept = []
|
|
220
|
+
for (const t of tags) {
|
|
221
|
+
const s = str(t, 32)
|
|
222
|
+
if (!s) continue
|
|
223
|
+
const k = s.toLowerCase()
|
|
224
|
+
if (seen.has(k)) continue
|
|
225
|
+
seen.add(k)
|
|
226
|
+
kept.push(s)
|
|
227
|
+
if (kept.length >= 12) break // a dozen labels is already too many to scan
|
|
228
|
+
}
|
|
229
|
+
if (kept.length) out.tags = kept
|
|
230
|
+
}
|
|
231
|
+
return out
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Select machines by group metadata — the predicate behind "run this on every
|
|
236
|
+
* staging box". All supplied filters must match (AND); tags are OR within
|
|
237
|
+
* themselves (any requested tag qualifies) which is how people read a tag row.
|
|
238
|
+
*
|
|
239
|
+
* @param {Array<object>} machines
|
|
240
|
+
* @param {{environment?: string, location?: string, tags?: string[]|string, query?: string}} filter
|
|
241
|
+
* @returns {Array<object>}
|
|
242
|
+
*/
|
|
243
|
+
export function filterMachinesByGroup(machines, filter = {}) {
|
|
244
|
+
const list = Array.isArray(machines) ? machines : []
|
|
245
|
+
const env = typeof filter.environment === 'string' ? filter.environment.trim().toLowerCase() : ''
|
|
246
|
+
const loc = typeof filter.location === 'string' ? filter.location.trim().toLowerCase() : ''
|
|
247
|
+
let wantTags = filter.tags
|
|
248
|
+
if (typeof wantTags === 'string') wantTags = wantTags.split(/[,\s]+/)
|
|
249
|
+
wantTags = (Array.isArray(wantTags) ? wantTags : []).map((t) => String(t).trim().toLowerCase()).filter(Boolean)
|
|
250
|
+
const query = typeof filter.query === 'string' ? filter.query.trim().toLowerCase() : ''
|
|
251
|
+
return list.filter((m) => {
|
|
252
|
+
if (!m) return false
|
|
253
|
+
if (env && String(m.environment || '').toLowerCase() !== env) return false
|
|
254
|
+
if (loc && String(m.location || '').toLowerCase() !== loc) return false
|
|
255
|
+
if (wantTags.length) {
|
|
256
|
+
const have = (Array.isArray(m.tags) ? m.tags : []).map((t) => String(t).toLowerCase())
|
|
257
|
+
if (!wantTags.some((t) => have.includes(t))) return false
|
|
258
|
+
}
|
|
259
|
+
if (query) {
|
|
260
|
+
const hay = [m.name, m.host, m.username, m.environment, m.location, m.description, ...(Array.isArray(m.tags) ? m.tags : [])]
|
|
261
|
+
.filter(Boolean).join(' ').toLowerCase()
|
|
262
|
+
if (!hay.includes(query)) return false
|
|
263
|
+
}
|
|
264
|
+
return true
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Apply a machine's fields onto the live config object (pool + tools read it). */
|
|
269
|
+
export function applyMachine(config, m) {
|
|
270
|
+
if (!m) return
|
|
271
|
+
config.host = m.host
|
|
272
|
+
config.port = Number(m.port) || 22
|
|
273
|
+
config.username = m.username || ''
|
|
274
|
+
config.password = m.password || ''
|
|
275
|
+
config.privateKeyPath = m.privateKeyPath || ''
|
|
276
|
+
config.passphrase = m.passphrase || ''
|
|
277
|
+
// IdentityAgent / IdentitiesOnly from ~/.ssh/config (set by withSshConfig).
|
|
278
|
+
config.agentSocket = m.agentSocket || ''
|
|
279
|
+
config.identitiesOnly = !!m.identitiesOnly
|
|
280
|
+
config.workspace = m.workspace || (config.workspace || '')
|
|
281
|
+
if (m.hostKeyMode) config.hostKeyMode = m.hostKeyMode
|
|
282
|
+
if (typeof m.useAgent === 'boolean') config.useAgent = m.useAgent
|
|
283
|
+
if (typeof m.keyboardInteractive === 'boolean') config.keyboardInteractive = m.keyboardInteractive
|
|
284
|
+
// Issue #51: the transport backend is PER MACHINE. An enterprise box that only
|
|
285
|
+
// accepts Kerberos must go through the system OpenSSH backend while every
|
|
286
|
+
// other machine can stay on the native one, so this cannot be a purely global
|
|
287
|
+
// default.
|
|
288
|
+
if (m.backend) config.backend = m.backend
|
|
289
|
+
if (typeof m.sshBinary === 'string') config.sshBinary = m.sshBinary
|
|
290
|
+
config.proxy = m.proxy && m.proxy.host ? m.proxy : undefined
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Mint a fresh machine id. */
|
|
294
|
+
export function machineId() {
|
|
295
|
+
return 'm-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6)
|
|
296
|
+
}
|