dsh-remote 0.8.13 → 0.8.15
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.md +236 -234
- package/README.zh.md +195 -190
- package/lib/binding.js +97 -97
- package/lib/client.js +28 -26
- package/lib/credential.js +177 -107
- package/lib/errors.js +78 -78
- package/lib/forwards.js +219 -219
- package/lib/hostkey.js +48 -48
- package/lib/ignore.js +123 -123
- package/lib/index.js +3288 -3230
- package/lib/paths.js +217 -217
- package/lib/registry.js +89 -89
- package/lib/search.js +127 -127
- package/lib/sshconfig.js +57 -57
- package/lib/sync.js +270 -270
- package/lib/tasks.js +95 -95
- package/lib/update.js +196 -196
- package/package.json +74 -66
package/lib/client.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
//
|
|
7
7
|
// A unified workspace directory picker fills ui-workspace's two directory-flow
|
|
8
8
|
// holes (sidebar + conversation hero):
|
|
9
|
-
// • 本机 tab → opens the NATIVE OS folder chooser (
|
|
9
|
+
// • 本机 tab → opens the NATIVE OS folder chooser (/dsh-remote/local-pick)
|
|
10
10
|
// and returns the picked local path (works with local workspaces).
|
|
11
11
|
// • 远程 tab → pick a machine (dropdown), list its directories over
|
|
12
12
|
// /dsh-remote/ls, choose or type a remote path → /dsh-remote/mirror builds a
|
|
@@ -109,6 +109,7 @@ window.__ModuleLoader__.load({
|
|
|
109
109
|
'settings.deleted': '已删除',
|
|
110
110
|
'settings.saved': '已保存更新',
|
|
111
111
|
'settings.added': '已添加 — 可设为当前',
|
|
112
|
+
'settings.secretStoreFailed': '密码未能写入系统密钥库({msg}),已回退为明文保存在 machines.json,连接仍可用。',
|
|
112
113
|
'settings.switched': '已切换为当前远程机',
|
|
113
114
|
'settings.cleared': '已取消当前远程机 — 本地会话不再自动使用远程上下文',
|
|
114
115
|
'settings.filledSsh': '已从 ~/.ssh/config 填入(仅引用私钥路径,不读取内容)',
|
|
@@ -308,6 +309,7 @@ window.__ModuleLoader__.load({
|
|
|
308
309
|
'settings.deleted': 'Deleted',
|
|
309
310
|
'settings.saved': 'Saved',
|
|
310
311
|
'settings.added': 'Added — you can set it as current',
|
|
312
|
+
'settings.secretStoreFailed': 'The OS keychain refused the password ({msg}); it was saved as plaintext in machines.json and the machine still connects.',
|
|
311
313
|
'settings.switched': 'Switched to current remote machine',
|
|
312
314
|
'settings.cleared': 'Cleared current remote — local sessions no longer use remote context automatically',
|
|
313
315
|
'settings.filledSsh': 'Filled from ~/.ssh/config (only references the private key path, never reads its content)',
|
|
@@ -598,8 +600,7 @@ window.__ModuleLoader__.load({
|
|
|
598
600
|
}
|
|
599
601
|
}
|
|
600
602
|
|
|
601
|
-
let
|
|
602
|
-
let SESSIONS = null // ctx.get('sessions'), set in apply()
|
|
603
|
+
let SESSIONS = null // optional sessions service, tracked by ctx.inject in apply()
|
|
603
604
|
|
|
604
605
|
// ── Settings → 远程工作区 (machine registry + forwards + audit) ──────────
|
|
605
606
|
function RemoteWorkspacePage() {
|
|
@@ -716,7 +717,13 @@ window.__ModuleLoader__.load({
|
|
|
716
717
|
workspace: form.workspace, hostKeyMode: form.hostKeyMode, useAgent: form.useAgent, keyboardInteractive: form.keyboardInteractive,
|
|
717
718
|
encryptPassword: form.encryptPassword,
|
|
718
719
|
proxy: form.proxyHost ? { host: form.proxyHost, port: Number(form.proxyPort) || 22, username: form.proxyUser || '', password: form.proxyPassword || '', privateKeyPath: form.proxyKey || '' } : undefined,
|
|
719
|
-
}).then((r) => {
|
|
720
|
+
}).then((r) => {
|
|
721
|
+
refresh(); clearForm()
|
|
722
|
+
// Issue #30: "加密保存密码"失败时服务端回退明文并带警告 —— 不能再静默
|
|
723
|
+
// 保存一台没有凭据的机器(那会让之后每次连接都失败且没有原因)。
|
|
724
|
+
if (r && r.warning === 'secret-store-failed') setErr(tr('settings.secretStoreFailed', { msg: r.warningDetail || 'unknown' }))
|
|
725
|
+
else setMsg(form.id ? tr('settings.saved') : tr('settings.added'))
|
|
726
|
+
}).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false))
|
|
720
727
|
}
|
|
721
728
|
const useNow = (id) => { setBusy(true); api('POST', '/dsh-remote/current', { id }).then((r) => { setCurrentId(r.currentId); setMsg(tr('settings.switched')) }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false)) }
|
|
722
729
|
// Issue #13: "active remote = none" — saved machines stay, but no machine
|
|
@@ -1955,36 +1962,31 @@ window.__ModuleLoader__.load({
|
|
|
1955
1962
|
|
|
1956
1963
|
function apply(ctx) {
|
|
1957
1964
|
const slots = ctx.get('slots')
|
|
1958
|
-
if (slots === undefined) return
|
|
1959
1965
|
wireLocale(ctx)
|
|
1960
|
-
//
|
|
1961
|
-
//
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1966
|
+
// Optional integrations must not gate the settings page. In particular,
|
|
1967
|
+
// replacement sidebars may disable the native ui-workspace package.
|
|
1968
|
+
ctx.inject(['sessions'], (inner) => {
|
|
1969
|
+
const sessions = inner.get('sessions')
|
|
1970
|
+
SESSIONS = sessions
|
|
1971
|
+
inner.effect(() => () => {
|
|
1972
|
+
if (SESSIONS === sessions) SESSIONS = null
|
|
1973
|
+
}, 'dsh-remote.sessions')
|
|
1974
|
+
})
|
|
1967
1975
|
slots.inject('settings.section', () =>
|
|
1968
|
-
slots.register({ name: 'settings.section', id: 'dsh-remote',
|
|
1969
|
-
)
|
|
1970
|
-
slots.inject(
|
|
1971
|
-
'conversation.hero.workspace.directoryFlow',
|
|
1972
|
-
() => slots.inject('sidebar.workspaces.directoryFlow',
|
|
1973
|
-
function* () {
|
|
1974
|
-
yield slots.register({ name: 'conversation.hero.workspace.directoryFlow', id: 'dsh-remote', priority: -100 }, DirPicker)
|
|
1975
|
-
yield slots.register({ name: 'sidebar.workspaces.directoryFlow', id: 'dsh-remote', priority: -100 }, DirPicker)
|
|
1976
|
-
},
|
|
1977
|
-
),
|
|
1976
|
+
slots.register({ name: 'settings.section', id: 'dsh-remote', order: 40, label: () => tr('settings.title') }, () => React.createElement(RemoteWorkspacePage, null)),
|
|
1978
1977
|
)
|
|
1978
|
+
for (const slot of ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow']) {
|
|
1979
|
+
slots.inject(slot, () =>
|
|
1980
|
+
slots.register({ name: slot, id: 'dsh-remote', priority: -100 }, DirPicker),
|
|
1981
|
+
)
|
|
1982
|
+
}
|
|
1979
1983
|
ctx.inject(['betterSidebar'], (inner) => {
|
|
1980
|
-
|
|
1981
|
-
if (bsDispose) {
|
|
1982
|
-
inner.effect(() => bsDispose, 'dsh-remote.betterSidebar')
|
|
1983
|
-
}
|
|
1984
|
+
inner.effect(() => registerSidebarIntegration(inner), 'dsh-remote.betterSidebar')
|
|
1984
1985
|
})
|
|
1985
1986
|
}
|
|
1986
1987
|
|
|
1987
1988
|
exports.name = name
|
|
1989
|
+
exports.inject = ['slots', 'locale']
|
|
1988
1990
|
exports.apply = apply
|
|
1989
1991
|
return module.exports
|
|
1990
1992
|
},
|
package/lib/credential.js
CHANGED
|
@@ -1,107 +1,177 @@
|
|
|
1
|
-
// dsh-remote — optional OS-keychain password storage.
|
|
2
|
-
// Machines keep their password in the OS credential store instead of the
|
|
3
|
-
// plaintext machines.json when the user enables "加密保存密码" per machine.
|
|
4
|
-
// Every backend is best-effort: any failure resolves to {ok:false} and the
|
|
5
|
-
// caller falls back to plaintext
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
return
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
1
|
+
// dsh-remote — optional OS-keychain password storage.
|
|
2
|
+
// Machines keep their password in the OS credential store instead of the
|
|
3
|
+
// plaintext machines.json when the user enables "加密保存密码" per machine.
|
|
4
|
+
// Every backend is best-effort: any failure resolves to {ok:false} and the
|
|
5
|
+
// caller (persistPassword) falls back to plaintext + a user-visible warning
|
|
6
|
+
// (the feature must never block connecting, and must never silently drop the
|
|
7
|
+
// credential — issue #30).
|
|
8
|
+
//
|
|
9
|
+
// Backends:
|
|
10
|
+
// darwin → `security` (login keychain, generic password)
|
|
11
|
+
// win32 → DPAPI via PowerShell (CurrentUser scope), files under
|
|
12
|
+
// $DSH_HOME/remote-workspaces/.secrets/
|
|
13
|
+
// linux → `secret-tool` (libsecret / gnome-keyring), optional
|
|
14
|
+
// else → unsupported (plain)
|
|
15
|
+
import { execFile } from 'node:child_process'
|
|
16
|
+
import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs'
|
|
17
|
+
import { join as joinPath } from 'node:path'
|
|
18
|
+
|
|
19
|
+
const SERVICE = 'dsh-remote'
|
|
20
|
+
const TIMEOUT = 8000
|
|
21
|
+
|
|
22
|
+
// Windows PowerShell 5.1 does not preload System.Security, so
|
|
23
|
+
// [System.Security.Cryptography.ProtectedData] is "not found" until the assembly
|
|
24
|
+
// is added (issue #30: without this the whole DPAPI call failed and the caller
|
|
25
|
+
// silently lost the password). `-ErrorAction SilentlyContinue` keeps the line
|
|
26
|
+
// harmless on PowerShell 7, where the type is already available.
|
|
27
|
+
const PS_SECURITY_ASSEMBLY = 'Add-Type -AssemblyName System.Security -ErrorAction SilentlyContinue;'
|
|
28
|
+
|
|
29
|
+
function run(bin, args, input) {
|
|
30
|
+
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())
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function platformBackend() {
|
|
39
|
+
if (process.platform === 'darwin') return 'keychain'
|
|
40
|
+
if (process.platform === 'win32') return 'windows'
|
|
41
|
+
return 'secret' // linux (secret-tool may be absent → falls back)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function account(machineId) {
|
|
45
|
+
// Only machine ids are stored; sanitize just in case.
|
|
46
|
+
return String(machineId || '').replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Redact a failed command's diagnostics: execFile puts the FULL command line
|
|
50
|
+
* (which carries the password as an argv) into `err.message`, and this string is
|
|
51
|
+
* shown in the settings UI — so the secret must never survive it. */
|
|
52
|
+
function safeReason(err, password) {
|
|
53
|
+
const raw = [err && err.stderr, err && err.message, err && err.code]
|
|
54
|
+
.filter(Boolean).map(String).join(' ')
|
|
55
|
+
const secret = String(password == null ? '' : password)
|
|
56
|
+
const scrubbed = secret ? raw.split(secret).join('***') : raw
|
|
57
|
+
return scrubbed.replace(/\s+/g, ' ').trim().slice(0, 300)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** PowerShell one-liner that DPAPI-protects a password (CurrentUser scope).
|
|
61
|
+
* Exported because the issue #30 fix (the mandatory Add-Type preamble) is only
|
|
62
|
+
* observable here — a Windows host cannot be exercised from the test suite. */
|
|
63
|
+
export function dpapiProtectScript(password) {
|
|
64
|
+
return PS_SECURITY_ASSEMBLY +
|
|
65
|
+
`$p='${String(password).replace(/'/g, "''")}';` +
|
|
66
|
+
`$s=[System.Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes($p),$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser);` +
|
|
67
|
+
`[Convert]::ToBase64String($s)`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** PowerShell one-liner that reverses dpapiProtectScript(). */
|
|
71
|
+
export function dpapiUnprotectScript(b64) {
|
|
72
|
+
return PS_SECURITY_ASSEMBLY +
|
|
73
|
+
`$s=[Convert]::FromBase64String('${String(b64).replace(/'/g, "''")}');` +
|
|
74
|
+
`[Text.Encoding]::UTF8.GetString([System.Security.Cryptography.ProtectedData]::Unprotect($s,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser))`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Save a password to the OS store. Resolves {ok, backend, error} — ok:false on
|
|
78
|
+
* any failure (caller falls back to plaintext). `error` is a password-free
|
|
79
|
+
* diagnostic for the UI. */
|
|
80
|
+
export async function saveSecret(machineId, password, secretsDir) {
|
|
81
|
+
const acc = account(machineId)
|
|
82
|
+
try {
|
|
83
|
+
if (process.platform === 'darwin') {
|
|
84
|
+
await run('security', ['add-generic-password', '-U', '-s', SERVICE, '-a', acc, '-w', String(password)])
|
|
85
|
+
return { ok: true, backend: 'keychain', error: '' }
|
|
86
|
+
}
|
|
87
|
+
if (process.platform === 'win32') {
|
|
88
|
+
// DPAPI-encrypt and store under the harness home.
|
|
89
|
+
const script = dpapiProtectScript(password)
|
|
90
|
+
const b64 = await run('powershell', ['-NoProfile', '-STA', '-Command', script])
|
|
91
|
+
if (!b64) return { ok: false, backend: 'windows', error: 'DPAPI returned no ciphertext' }
|
|
92
|
+
mkdirSync(secretsDir || '.', { recursive: true })
|
|
93
|
+
writeFileSync(joinPath(secretsDir || '.', acc + '.bin'), b64, 'utf8')
|
|
94
|
+
return { ok: true, backend: 'windows', error: '' }
|
|
95
|
+
}
|
|
96
|
+
if (process.platform === 'linux') {
|
|
97
|
+
await run('secret-tool', ['store', '--label=' + SERVICE, 'service', SERVICE, 'account', acc], String(password) + '\n')
|
|
98
|
+
return { ok: true, backend: 'secret', error: '' }
|
|
99
|
+
}
|
|
100
|
+
} catch (err) {
|
|
101
|
+
return { ok: false, backend: platformBackend(), error: safeReason(err, password) || 'secret store write failed' }
|
|
102
|
+
}
|
|
103
|
+
return { ok: false, backend: platformBackend(), error: `unsupported platform: ${process.platform}` }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Decide where a machine password is persisted (issue #30).
|
|
107
|
+
*
|
|
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.
|
|
113
|
+
*
|
|
114
|
+
* `save` is injectable so the fallback is unit-testable on any platform.
|
|
115
|
+
*
|
|
116
|
+
* @returns {Promise<{stored:'secret'|'plain'|'none', credentialBackend:string, password:string, warning:string, error:string}>}
|
|
117
|
+
*/
|
|
118
|
+
export async function persistPassword({ backend, machineId, password, secretsDir, save = saveSecret }) {
|
|
119
|
+
const pw = password == null ? '' : String(password)
|
|
120
|
+
const want = backend && backend !== 'plain' ? backend : 'plain'
|
|
121
|
+
if (!pw) return { stored: 'none', credentialBackend: want, password: '', warning: '', error: '' }
|
|
122
|
+
if (want === 'plain') return { stored: 'plain', credentialBackend: 'plain', password: pw, warning: '', error: '' }
|
|
123
|
+
let res
|
|
124
|
+
try {
|
|
125
|
+
res = await save(machineId, pw, secretsDir)
|
|
126
|
+
} catch (err) {
|
|
127
|
+
res = { ok: false, error: safeReason(err, pw) }
|
|
128
|
+
}
|
|
129
|
+
if (res && res.ok) {
|
|
130
|
+
return { stored: 'secret', credentialBackend: res.backend || want, password: '', warning: '', error: '' }
|
|
131
|
+
}
|
|
132
|
+
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',
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Fetch a stored password. Resolves string|null. */
|
|
142
|
+
export async function getSecret(machineId, secretsDir) {
|
|
143
|
+
const acc = account(machineId)
|
|
144
|
+
try {
|
|
145
|
+
if (process.platform === 'darwin') {
|
|
146
|
+
return await run('security', ['find-generic-password', '-s', SERVICE, '-a', acc, '-w'])
|
|
147
|
+
}
|
|
148
|
+
if (process.platform === 'win32') {
|
|
149
|
+
const b64 = readFileSync(joinPath(secretsDir || '.', acc + '.bin'), 'utf8').trim()
|
|
150
|
+
if (!b64) return null
|
|
151
|
+
const script = dpapiUnprotectScript(b64)
|
|
152
|
+
return await run('powershell', ['-NoProfile', '-STA', '-Command', script])
|
|
153
|
+
}
|
|
154
|
+
if (process.platform === 'linux') {
|
|
155
|
+
return await run('secret-tool', ['lookup', 'service', SERVICE, 'account', acc])
|
|
156
|
+
}
|
|
157
|
+
} catch { /* fall through */ }
|
|
158
|
+
return null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Delete a stored password (idempotent). */
|
|
162
|
+
export async function deleteSecret(machineId, secretsDir) {
|
|
163
|
+
const acc = account(machineId)
|
|
164
|
+
try {
|
|
165
|
+
if (process.platform === 'darwin') {
|
|
166
|
+
await run('security', ['delete-generic-password', '-s', SERVICE, '-a', acc]).catch(() => {})
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
if (process.platform === 'win32') {
|
|
170
|
+
try { unlinkSync(joinPath(secretsDir || '.', acc + '.bin')) } catch { /* absent */ }
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
if (process.platform === 'linux') {
|
|
174
|
+
await run('secret-tool', ['clear', 'service', SERVICE, 'account', acc]).catch(() => {})
|
|
175
|
+
}
|
|
176
|
+
} catch { /* best effort */ }
|
|
177
|
+
}
|
package/lib/errors.js
CHANGED
|
@@ -1,78 +1,78 @@
|
|
|
1
|
-
// dsh-remote — SSH/SFTP error classification → actionable hints.
|
|
2
|
-
// Pure function so it is unit-testable and reused by tools, slash commands,
|
|
3
|
-
// the test-connect route and the settings UI.
|
|
4
|
-
|
|
5
|
-
/** Classify an error thrown by the ssh pool / sftp wrapper.
|
|
6
|
-
* @returns {{ category: 'auth'|'network'|'hostkey'|'timeout'|'sftp'|'credentials'|'other', hint: string, raw: string }}
|
|
7
|
-
*/
|
|
8
|
-
export function classifyError(err, opts = {}) {
|
|
9
|
-
const msg = String((err && err.message) || err || '')
|
|
10
|
-
const { host, port } = opts
|
|
11
|
-
const where = host ? ` (${host}:${port || 22})` : ''
|
|
12
|
-
|
|
13
|
-
if (/host key/i.test(msg) && /(change|mismatch|fingerprint|verify|unknown host)/i.test(msg)) {
|
|
14
|
-
return {
|
|
15
|
-
category: 'hostkey',
|
|
16
|
-
hint: `主机指纹校验失败${where}:可能遭遇中间人攻击,或该主机重装/更换过密钥。确认无异常后执行 /remote-forget-key 重新信任。`,
|
|
17
|
-
raw: msg,
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
if (/permission denied|all configured authentication methods failed|authentication failed/i.test(msg)) {
|
|
21
|
-
return {
|
|
22
|
-
category: 'auth',
|
|
23
|
-
hint: `认证失败${where}:检查用户名/密码/私钥路径,私钥是否设了 passphrase;若公司机器要求 OTP/动态码,请确认机器配置了 keyboard-interactive 或对应密码。`,
|
|
24
|
-
raw: msg,
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
if (/no credentials/i.test(msg)) {
|
|
28
|
-
return {
|
|
29
|
-
category: 'credentials',
|
|
30
|
-
hint: `缺少凭据:请设置 password 或 privateKeyPath(私钥只在显式提供时使用),或为该机器启用 SSH agent。`,
|
|
31
|
-
raw: msg,
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
if (/ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(msg)) {
|
|
35
|
-
return {
|
|
36
|
-
category: 'network',
|
|
37
|
-
hint: `主机不可达${where}:检查 host 拼写 / IP / DNS;内网机器可能需要走跳板机(配置 proxy)或 VPN。`,
|
|
38
|
-
raw: msg,
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
if (/ECONNREFUSED|connection refused/i.test(msg)) {
|
|
42
|
-
return {
|
|
43
|
-
category: 'network',
|
|
44
|
-
hint: `连接被拒绝${where}:确认端口 ${port || 22} 正确、远端 sshd 在监听;检查防火墙/安全组是否放行。`,
|
|
45
|
-
raw: msg,
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
if (/sftp operation timed out|sftp failed|readdir|writeFile|fastGet|fastPut/i.test(msg)) {
|
|
49
|
-
return {
|
|
50
|
-
category: 'sftp',
|
|
51
|
-
hint: `SFTP 操作失败:可能是远端磁盘/权限问题或服务器卡死(已自动超时保护)。检查目标路径权限。`,
|
|
52
|
-
raw: msg,
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
if (/ETIMEDOUT|timed out|timeout/i.test(msg)) {
|
|
56
|
-
return {
|
|
57
|
-
category: 'timeout',
|
|
58
|
-
hint: `连接/操作超时${where}:检查端口、防火墙、路由;或调大 connectTimeoutMs / commandTimeoutMs。`,
|
|
59
|
-
raw: msg,
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
if (/cannot read private key/i.test(msg)) {
|
|
63
|
-
return {
|
|
64
|
-
category: 'credentials',
|
|
65
|
-
hint: `无法读取私钥文件:检查 privateKeyPath 是否存在、当前用户是否有读取权限。`,
|
|
66
|
-
raw: msg,
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
return { category: 'other', hint: msg, raw: msg }
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** One-line friendly message: hint (+ raw when it adds information). */
|
|
73
|
-
export function friendlyMessage(err, opts = {}) {
|
|
74
|
-
const c = classifyError(err, opts)
|
|
75
|
-
if (c.category === 'other') return c.raw
|
|
76
|
-
if (c.raw && c.raw !== c.hint) return `${c.hint}(原始错误:${c.raw})`
|
|
77
|
-
return c.hint
|
|
78
|
-
}
|
|
1
|
+
// dsh-remote — SSH/SFTP error classification → actionable hints.
|
|
2
|
+
// Pure function so it is unit-testable and reused by tools, slash commands,
|
|
3
|
+
// the test-connect route and the settings UI.
|
|
4
|
+
|
|
5
|
+
/** Classify an error thrown by the ssh pool / sftp wrapper.
|
|
6
|
+
* @returns {{ category: 'auth'|'network'|'hostkey'|'timeout'|'sftp'|'credentials'|'other', hint: string, raw: string }}
|
|
7
|
+
*/
|
|
8
|
+
export function classifyError(err, opts = {}) {
|
|
9
|
+
const msg = String((err && err.message) || err || '')
|
|
10
|
+
const { host, port } = opts
|
|
11
|
+
const where = host ? ` (${host}:${port || 22})` : ''
|
|
12
|
+
|
|
13
|
+
if (/host key/i.test(msg) && /(change|mismatch|fingerprint|verify|unknown host)/i.test(msg)) {
|
|
14
|
+
return {
|
|
15
|
+
category: 'hostkey',
|
|
16
|
+
hint: `主机指纹校验失败${where}:可能遭遇中间人攻击,或该主机重装/更换过密钥。确认无异常后执行 /remote-forget-key 重新信任。`,
|
|
17
|
+
raw: msg,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (/permission denied|all configured authentication methods failed|authentication failed/i.test(msg)) {
|
|
21
|
+
return {
|
|
22
|
+
category: 'auth',
|
|
23
|
+
hint: `认证失败${where}:检查用户名/密码/私钥路径,私钥是否设了 passphrase;若公司机器要求 OTP/动态码,请确认机器配置了 keyboard-interactive 或对应密码。`,
|
|
24
|
+
raw: msg,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (/no credentials/i.test(msg)) {
|
|
28
|
+
return {
|
|
29
|
+
category: 'credentials',
|
|
30
|
+
hint: `缺少凭据:请设置 password 或 privateKeyPath(私钥只在显式提供时使用),或为该机器启用 SSH agent。`,
|
|
31
|
+
raw: msg,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (/ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(msg)) {
|
|
35
|
+
return {
|
|
36
|
+
category: 'network',
|
|
37
|
+
hint: `主机不可达${where}:检查 host 拼写 / IP / DNS;内网机器可能需要走跳板机(配置 proxy)或 VPN。`,
|
|
38
|
+
raw: msg,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (/ECONNREFUSED|connection refused/i.test(msg)) {
|
|
42
|
+
return {
|
|
43
|
+
category: 'network',
|
|
44
|
+
hint: `连接被拒绝${where}:确认端口 ${port || 22} 正确、远端 sshd 在监听;检查防火墙/安全组是否放行。`,
|
|
45
|
+
raw: msg,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (/sftp operation timed out|sftp failed|readdir|writeFile|fastGet|fastPut/i.test(msg)) {
|
|
49
|
+
return {
|
|
50
|
+
category: 'sftp',
|
|
51
|
+
hint: `SFTP 操作失败:可能是远端磁盘/权限问题或服务器卡死(已自动超时保护)。检查目标路径权限。`,
|
|
52
|
+
raw: msg,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (/ETIMEDOUT|timed out|timeout/i.test(msg)) {
|
|
56
|
+
return {
|
|
57
|
+
category: 'timeout',
|
|
58
|
+
hint: `连接/操作超时${where}:检查端口、防火墙、路由;或调大 connectTimeoutMs / commandTimeoutMs。`,
|
|
59
|
+
raw: msg,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (/cannot read private key/i.test(msg)) {
|
|
63
|
+
return {
|
|
64
|
+
category: 'credentials',
|
|
65
|
+
hint: `无法读取私钥文件:检查 privateKeyPath 是否存在、当前用户是否有读取权限。`,
|
|
66
|
+
raw: msg,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { category: 'other', hint: msg, raw: msg }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One-line friendly message: hint (+ raw when it adds information). */
|
|
73
|
+
export function friendlyMessage(err, opts = {}) {
|
|
74
|
+
const c = classifyError(err, opts)
|
|
75
|
+
if (c.category === 'other') return c.raw
|
|
76
|
+
if (c.raw && c.raw !== c.hint) return `${c.hint}(原始错误:${c.raw})`
|
|
77
|
+
return c.hint
|
|
78
|
+
}
|