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/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 { execFile } from 'node:child_process'
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
- execFile(bin, args, { timeout: TIMEOUT, maxBuffer: 1 << 20, ...(input != null ? { input } : {}) }, (err, stdout, stderr) => {
32
- if (err) return reject(err)
33
- resolve(String(stdout || '').trim())
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. When the
109
- * requested OS store fails (missing DPAPI assembly, locked keychain, absent
110
- * secret-tool), this falls back to plaintext in machines.json and reports
111
- * `warning: 'secret-store-failed'` so the caller can tell the user instead of
112
- * silently saving a credential-less machine.
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
- * `save` is injectable so the fallback is unit-testable on any platform.
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({ backend, machineId, password, secretsDir, save = saveSecret }) {
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: 'plain',
134
- credentialBackend: 'plain',
135
- password: pw,
136
- warning: 'secret-store-failed',
137
- error: String((res && res.error) || '') || 'secret store write failed',
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: `缺少凭据:请设置 password 或 privateKeyPath(私钥只在显式提供时使用),或为该机器启用 SSH agent。`,
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
- if (c.category === 'other') return c.raw
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
  }