dsh-remote 0.8.53 → 0.8.60
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.en.md +37 -0
- package/README.md +65 -0
- package/lib/client.js +3853 -3730
- package/lib/credential.js +102 -16
- package/lib/errors.js +34 -3
- package/lib/index.js +340 -35
- package/lib/instructions.js +288 -288
- package/lib/pool.js +95 -2
- package/lib/registry.js +202 -4
- package/lib/remote-sandbox.js +183 -0
- package/lib/routes-fs.js +14 -2
- package/lib/routes-term.js +169 -0
- package/lib/terminal.js +205 -0
- package/lib/web-deploy.js +7 -1
- package/package.json +7 -3
package/lib/credential.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// $DSH_HOME/remote-workspaces/.secrets/
|
|
13
13
|
// linux → `secret-tool` (libsecret / gnome-keyring), optional
|
|
14
14
|
// else → unsupported (plain)
|
|
15
|
-
import {
|
|
15
|
+
import { spawn } from 'node:child_process'
|
|
16
16
|
import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs'
|
|
17
17
|
import { join as joinPath } from 'node:path'
|
|
18
18
|
|
|
@@ -26,12 +26,60 @@ const TIMEOUT = 8000
|
|
|
26
26
|
// harmless on PowerShell 7, where the type is already available.
|
|
27
27
|
const PS_SECURITY_ASSEMBLY = 'Add-Type -AssemblyName System.Security -ErrorAction SilentlyContinue;'
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Run a helper and resolve with its trimmed stdout.
|
|
31
|
+
*
|
|
32
|
+
* WHY `spawn` and not `execFile(..., { input })`: measured on a real Ubuntu box
|
|
33
|
+
* with a real gnome-keyring, `secret-tool store` given its password through
|
|
34
|
+
* `execFile`'s `input` option **never saw EOF**, blocked on the read, and was
|
|
35
|
+
* killed by this timeout — so on Linux "encrypt the password" always failed after
|
|
36
|
+
* 8 seconds with the useless message "Command failed: secret-tool store …" (empty
|
|
37
|
+
* stderr) and the credential was dropped. The same command with an explicitly
|
|
38
|
+
* ended stdin (`spawn` + `stdin.end()`, or `sh -c '… < file'`) finishes in ~20ms.
|
|
39
|
+
* Nothing in this module needs the extra `execFile` machinery, so stdin is now
|
|
40
|
+
* always closed explicitly and the child's stderr is carried on the error (the old
|
|
41
|
+
* path dropped it, which is why the failure had no reason attached).
|
|
42
|
+
*/
|
|
29
43
|
function run(bin, args, input) {
|
|
30
44
|
return new Promise((resolve, reject) => {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
45
|
+
let child
|
|
46
|
+
try {
|
|
47
|
+
child = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
48
|
+
} catch (err) {
|
|
49
|
+
reject(err)
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
let out = ''
|
|
53
|
+
let errOut = ''
|
|
54
|
+
let settled = false
|
|
55
|
+
const timer = setTimeout(() => {
|
|
56
|
+
if (settled) return
|
|
57
|
+
settled = true
|
|
58
|
+
try { child.kill('SIGTERM') } catch { /* already gone */ }
|
|
59
|
+
const err = new Error(`${bin} timed out after ${TIMEOUT}ms`)
|
|
60
|
+
err.stderr = errOut
|
|
61
|
+
err.code = 'ETIMEDOUT'
|
|
62
|
+
reject(err)
|
|
63
|
+
}, TIMEOUT)
|
|
64
|
+
const done = (err, value) => {
|
|
65
|
+
if (settled) return
|
|
66
|
+
settled = true
|
|
67
|
+
clearTimeout(timer)
|
|
68
|
+
if (err) reject(err); else resolve(value)
|
|
69
|
+
}
|
|
70
|
+
child.on('error', (err) => done(err))
|
|
71
|
+
child.on('close', (code) => {
|
|
72
|
+
if (code === 0) return done(null, out.trim())
|
|
73
|
+
const err = new Error(`${bin} exited with ${code}${errOut.trim() ? ': ' + errOut.trim().slice(0, 300) : ''}`)
|
|
74
|
+
err.code = code
|
|
75
|
+
err.stderr = errOut
|
|
76
|
+
done(err)
|
|
34
77
|
})
|
|
78
|
+
child.stdout.on('data', (d) => { out += d })
|
|
79
|
+
child.stderr.on('data', (d) => { errOut += d })
|
|
80
|
+
// Always end stdin: a helper that reads a secret from stdin needs EOF, and one
|
|
81
|
+
// that does not must not inherit an open pipe.
|
|
82
|
+
try { child.stdin.end(input == null ? '' : String(input)) } catch { /* closed early */ }
|
|
35
83
|
})
|
|
36
84
|
}
|
|
37
85
|
|
|
@@ -105,17 +153,43 @@ export async function saveSecret(machineId, password, secretsDir) {
|
|
|
105
153
|
|
|
106
154
|
/** Decide where a machine password is persisted (issue #30).
|
|
107
155
|
*
|
|
108
|
-
* The contract the settings UI relies on: a password is NEVER dropped.
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
156
|
+
* The contract the settings UI relies on: a password is NEVER silently dropped.
|
|
157
|
+
* Historically that guarantee was upheld by falling back to PLAINTEXT in
|
|
158
|
+
* machines.json, which is a bad trade in one specific way: the user asked for
|
|
159
|
+
* encryption, the store failed, and the secret ended up less protected than if
|
|
160
|
+
* they had never asked. Worse, a locked keychain / missing secret-tool is
|
|
161
|
+
* exactly the moment when quietly writing a password to a world-readable file
|
|
162
|
+
* on disk is most likely to go unnoticed.
|
|
113
163
|
*
|
|
114
|
-
*
|
|
164
|
+
* So the fallback is a POLICY, not a fixed behaviour (`onStoreFailure`):
|
|
115
165
|
*
|
|
166
|
+
* `refuse` (default) — do not store the password at all. The machine is
|
|
167
|
+
* saved WITHOUT a credential and the caller reports how to fix the
|
|
168
|
+
* store; the user retypes (or connects) and nothing lands in
|
|
169
|
+
* plaintext by accident. `stored: 'none'` makes that explicit.
|
|
170
|
+
* `plain` — the legacy behaviour: write it to machines.json and raise
|
|
171
|
+
* `warning: 'secret-store-failed'`. Opt-in only, for users who
|
|
172
|
+
* genuinely prefer a working machine over the storage guarantee.
|
|
173
|
+
*
|
|
174
|
+
* An explicit `backend: 'plain'` still means plain by REQUEST (the user turned
|
|
175
|
+
* encryption off), and is unaffected by this policy.
|
|
176
|
+
*
|
|
177
|
+
* `save` is injectable so both paths are unit-testable on any platform.
|
|
178
|
+
*
|
|
179
|
+
* @param {object} arg
|
|
180
|
+
* @param {string} arg.backend - the platform backend name ('keychain'|'windows'|'secret'|'plain').
|
|
181
|
+
* @param {string} arg.machineId
|
|
182
|
+
* @param {string} arg.password
|
|
183
|
+
* @param {string} [arg.secretsDir]
|
|
184
|
+
* @param {'refuse'|'plain'} [arg.onStoreFailure] - see above.
|
|
185
|
+
* @param {Function} [arg.save] - injectable secure-store writer.
|
|
116
186
|
* @returns {Promise<{stored:'secret'|'plain'|'none', credentialBackend:string, password:string, warning:string, error:string}>}
|
|
117
187
|
*/
|
|
118
|
-
export async function persistPassword({
|
|
188
|
+
export async function persistPassword({
|
|
189
|
+
backend, machineId, password, secretsDir,
|
|
190
|
+
onStoreFailure = 'refuse',
|
|
191
|
+
save = saveSecret,
|
|
192
|
+
}) {
|
|
119
193
|
const pw = password == null ? '' : String(password)
|
|
120
194
|
const want = backend && backend !== 'plain' ? backend : 'plain'
|
|
121
195
|
if (!pw) return { stored: 'none', credentialBackend: want, password: '', warning: '', error: '' }
|
|
@@ -129,12 +203,24 @@ export async function persistPassword({ backend, machineId, password, secretsDir
|
|
|
129
203
|
if (res && res.ok) {
|
|
130
204
|
return { stored: 'secret', credentialBackend: res.backend || want, password: '', warning: '', error: '' }
|
|
131
205
|
}
|
|
206
|
+
const error = String((res && res.error) || '') || 'secret store write failed'
|
|
207
|
+
if (onStoreFailure === 'plain') {
|
|
208
|
+
return {
|
|
209
|
+
stored: 'plain',
|
|
210
|
+
credentialBackend: 'plain',
|
|
211
|
+
password: pw,
|
|
212
|
+
warning: 'secret-store-failed',
|
|
213
|
+
error,
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// Default: refuse. Nothing is written, and the caller surfaces the store
|
|
217
|
+
// failure plus the platform-specific repair hint instead of a silent downgrade.
|
|
132
218
|
return {
|
|
133
|
-
stored: '
|
|
134
|
-
credentialBackend:
|
|
135
|
-
password:
|
|
136
|
-
warning: 'secret-store-
|
|
137
|
-
error
|
|
219
|
+
stored: 'none',
|
|
220
|
+
credentialBackend: want,
|
|
221
|
+
password: '',
|
|
222
|
+
warning: 'secret-store-refused',
|
|
223
|
+
error,
|
|
138
224
|
}
|
|
139
225
|
}
|
|
140
226
|
|
package/lib/errors.js
CHANGED
|
@@ -24,10 +24,22 @@ export function classifyError(err, opts = {}) {
|
|
|
24
24
|
raw: msg,
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
|
+
// Checked BEFORE the generic `no credentials` branch: the actionable failure
|
|
28
|
+
// there is "this process has no ssh-agent", not "you forgot to configure a
|
|
29
|
+
// credential". Telling a Windows user to "set a password or a privateKeyPath"
|
|
30
|
+
// when they already ticked 使用 SSH agent is how a config page gets a
|
|
31
|
+
// reputation for lying.
|
|
32
|
+
if (/no SSH agent available|SSH_AUTH_SOCK/i.test(msg)) {
|
|
33
|
+
return {
|
|
34
|
+
category: 'credentials',
|
|
35
|
+
hint: `SSH agent 不可用${where}:这台机器勾选了「使用 SSH agent」,但 DSH 宿主进程里没有 SSH_AUTH_SOCK(Windows 默认没有 agent 服务)。要么在启动 DSH 之前把 agent 跑起来并导出 SSH_AUTH_SOCK(或用 Pageant),要么取消勾选 agent 并给这台机器保存密码/私钥。`,
|
|
36
|
+
raw: msg,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
27
39
|
if (/no credentials/i.test(msg)) {
|
|
28
40
|
return {
|
|
29
41
|
category: 'credentials',
|
|
30
|
-
hint:
|
|
42
|
+
hint: `缺少凭据${where}:这台机器没有可用密码、私钥或 agent。请在「远程工作区」设置里填密码(或用私钥路径 / 勾选 agent)后保存;若你确信已经填过,说明保存时密码没有真正落盘(看保存后是否弹出密钥库失败警告),重新填写并保存即可,可用「测试连接」当场验证。`,
|
|
31
43
|
raw: msg,
|
|
32
44
|
}
|
|
33
45
|
}
|
|
@@ -38,6 +50,18 @@ export function classifyError(err, opts = {}) {
|
|
|
38
50
|
raw: msg,
|
|
39
51
|
}
|
|
40
52
|
}
|
|
53
|
+
// ssh2 reports a socket that dies DURING the handshake as the bare string
|
|
54
|
+
// "Connection lost before handshake" — no host, no cause, no advice. That is
|
|
55
|
+
// the message a user sees when the port is open but nothing speaks SSH on it,
|
|
56
|
+
// or when a firewall/security group cuts the connection after the TCP accept;
|
|
57
|
+
// it is indistinguishable from "wrong credentials" unless it is classified.
|
|
58
|
+
if (/connection lost before handshake|handshake failed|no matching (key exchange|host key|cipher)/i.test(msg)) {
|
|
59
|
+
return {
|
|
60
|
+
category: 'network',
|
|
61
|
+
hint: `SSH 握手未完成${where}:TCP 连上了但 SSH 握手没走完——端口上没有跑 sshd、连接被防火墙/安全组拒绝、或远端只支持不兼容的加密算法。确认 host/port 指向 sshd,必要时换端口或让运维放行。`,
|
|
62
|
+
raw: msg,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
41
65
|
if (/ECONNREFUSED|connection refused/i.test(msg)) {
|
|
42
66
|
return {
|
|
43
67
|
category: 'network',
|
|
@@ -78,10 +102,17 @@ export function classifyError(err, opts = {}) {
|
|
|
78
102
|
return { category: 'other', hint: msg, raw: msg }
|
|
79
103
|
}
|
|
80
104
|
|
|
81
|
-
/** One-line friendly message: hint (+ raw when it adds information).
|
|
105
|
+
/** One-line friendly message: hint (+ raw when it adds information).
|
|
106
|
+
*
|
|
107
|
+
* An UNCLASSIFIED failure still gets the host appended: "Connection lost before
|
|
108
|
+
* handshake" or a raw errno on its own leaves the user unable to tell WHICH
|
|
109
|
+
* machine failed, which is the one thing a multi-machine plugin must never hide. */
|
|
82
110
|
export function friendlyMessage(err, opts = {}) {
|
|
83
111
|
const c = classifyError(err, opts)
|
|
84
|
-
|
|
112
|
+
const where = opts.host ? ` (${opts.host}:${opts.port || 22})` : ''
|
|
113
|
+
if (c.category === 'other') {
|
|
114
|
+
return where && !c.raw.includes(opts.host) ? `${c.raw}${where}` : c.raw
|
|
115
|
+
}
|
|
85
116
|
if (c.raw && c.raw !== c.hint) return `${c.hint}(原始错误:${c.raw})`
|
|
86
117
|
return c.hint
|
|
87
118
|
}
|