dsh-remote 0.8.9 → 0.8.10

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
@@ -1,107 +1,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 falls back to plaintext (the feature must never block connecting).
6
- //
7
- // Backends:
8
- // darwin → `security` (login keychain, generic password)
9
- // win32 → DPAPI via PowerShell (CurrentUser scope), files under
10
- // $DSH_HOME/remote-workspaces/.secrets/
11
- // linux → `secret-tool` (libsecret / gnome-keyring), optional
12
- // else → unsupported (plain)
13
- import { execFile } from 'node:child_process'
14
- import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs'
15
- import { join as joinPath } from 'node:path'
16
-
17
- const SERVICE = 'dsh-remote'
18
- const TIMEOUT = 8000
19
-
20
- function run(bin, args, input) {
21
- return new Promise((resolve, reject) => {
22
- execFile(bin, args, { timeout: TIMEOUT, maxBuffer: 1 << 20, ...(input != null ? { input } : {}) }, (err, stdout, stderr) => {
23
- if (err) return reject(err)
24
- resolve(String(stdout || '').trim())
25
- })
26
- })
27
- }
28
-
29
- export function platformBackend() {
30
- if (process.platform === 'darwin') return 'keychain'
31
- if (process.platform === 'win32') return 'windows'
32
- return 'secret' // linux (secret-tool may be absent → falls back)
33
- }
34
-
35
- function account(machineId) {
36
- // Only machine ids are stored; sanitize just in case.
37
- return String(machineId || '').replace(/[^a-zA-Z0-9._-]/g, '_')
38
- }
39
-
40
- /** Save a password to the OS store. Resolves {ok, backend} — ok:false on any
41
- * failure (caller falls back to plaintext). */
42
- export async function saveSecret(machineId, password, secretsDir) {
43
- const acc = account(machineId)
44
- try {
45
- if (process.platform === 'darwin') {
46
- await run('security', ['add-generic-password', '-U', '-s', SERVICE, '-a', acc, '-w', String(password)])
47
- return { ok: true, backend: 'keychain' }
48
- }
49
- if (process.platform === 'win32') {
50
- // DPAPI-encrypt and store under the harness home.
51
- const script =
52
- `$p='${String(password).replace(/'/g, "''")}';` +
53
- `$s=[System.Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes($p),$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser);` +
54
- `[Convert]::ToBase64String($s)`
55
- const b64 = await run('powershell', ['-NoProfile', '-STA', '-Command', script])
56
- if (!b64) return { ok: false, backend: 'windows' }
57
- mkdirSync(secretsDir || '.', { recursive: true })
58
- writeFileSync(joinPath(secretsDir || '.', acc + '.bin'), b64, 'utf8')
59
- return { ok: true, backend: 'windows' }
60
- }
61
- if (process.platform === 'linux') {
62
- await run('secret-tool', ['store', '--label=' + SERVICE, 'service', SERVICE, 'account', acc], String(password) + '\n')
63
- return { ok: true, backend: 'secret' }
64
- }
65
- } catch { /* fall through */ }
66
- return { ok: false, backend: platformBackend() }
67
- }
68
-
69
- /** Fetch a stored password. Resolves string|null. */
70
- export async function getSecret(machineId, secretsDir) {
71
- const acc = account(machineId)
72
- try {
73
- if (process.platform === 'darwin') {
74
- return await run('security', ['find-generic-password', '-s', SERVICE, '-a', acc, '-w'])
75
- }
76
- if (process.platform === 'win32') {
77
- const b64 = readFileSync(joinPath(secretsDir || '.', acc + '.bin'), 'utf8').trim()
78
- if (!b64) return null
79
- const script =
80
- `$s=[Convert]::FromBase64String('${b64}');` +
81
- `[Text.Encoding]::UTF8.GetString([System.Security.Cryptography.ProtectedData]::Unprotect($s,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser))`
82
- return await run('powershell', ['-NoProfile', '-STA', '-Command', script])
83
- }
84
- if (process.platform === 'linux') {
85
- return await run('secret-tool', ['lookup', 'service', SERVICE, 'account', acc])
86
- }
87
- } catch { /* fall through */ }
88
- return null
89
- }
90
-
91
- /** Delete a stored password (idempotent). */
92
- export async function deleteSecret(machineId, secretsDir) {
93
- const acc = account(machineId)
94
- try {
95
- if (process.platform === 'darwin') {
96
- await run('security', ['delete-generic-password', '-s', SERVICE, '-a', acc]).catch(() => {})
97
- return
98
- }
99
- if (process.platform === 'win32') {
100
- try { unlinkSync(joinPath(secretsDir || '.', acc + '.bin')) } catch { /* absent */ }
101
- return
102
- }
103
- if (process.platform === 'linux') {
104
- await run('secret-tool', ['clear', 'service', SERVICE, 'account', acc]).catch(() => {})
105
- }
106
- } catch { /* best effort */ }
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 falls back to plaintext (the feature must never block connecting).
6
+ //
7
+ // Backends:
8
+ // darwin → `security` (login keychain, generic password)
9
+ // win32 → DPAPI via PowerShell (CurrentUser scope), files under
10
+ // $DSH_HOME/remote-workspaces/.secrets/
11
+ // linux → `secret-tool` (libsecret / gnome-keyring), optional
12
+ // else → unsupported (plain)
13
+ import { execFile } from 'node:child_process'
14
+ import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs'
15
+ import { join as joinPath } from 'node:path'
16
+
17
+ const SERVICE = 'dsh-remote'
18
+ const TIMEOUT = 8000
19
+
20
+ function run(bin, args, input) {
21
+ return new Promise((resolve, reject) => {
22
+ execFile(bin, args, { timeout: TIMEOUT, maxBuffer: 1 << 20, ...(input != null ? { input } : {}) }, (err, stdout, stderr) => {
23
+ if (err) return reject(err)
24
+ resolve(String(stdout || '').trim())
25
+ })
26
+ })
27
+ }
28
+
29
+ export function platformBackend() {
30
+ if (process.platform === 'darwin') return 'keychain'
31
+ if (process.platform === 'win32') return 'windows'
32
+ return 'secret' // linux (secret-tool may be absent → falls back)
33
+ }
34
+
35
+ function account(machineId) {
36
+ // Only machine ids are stored; sanitize just in case.
37
+ return String(machineId || '').replace(/[^a-zA-Z0-9._-]/g, '_')
38
+ }
39
+
40
+ /** Save a password to the OS store. Resolves {ok, backend} — ok:false on any
41
+ * failure (caller falls back to plaintext). */
42
+ export async function saveSecret(machineId, password, secretsDir) {
43
+ const acc = account(machineId)
44
+ try {
45
+ if (process.platform === 'darwin') {
46
+ await run('security', ['add-generic-password', '-U', '-s', SERVICE, '-a', acc, '-w', String(password)])
47
+ return { ok: true, backend: 'keychain' }
48
+ }
49
+ if (process.platform === 'win32') {
50
+ // DPAPI-encrypt and store under the harness home.
51
+ const script =
52
+ `$p='${String(password).replace(/'/g, "''")}';` +
53
+ `$s=[System.Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes($p),$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser);` +
54
+ `[Convert]::ToBase64String($s)`
55
+ const b64 = await run('powershell', ['-NoProfile', '-STA', '-Command', script])
56
+ if (!b64) return { ok: false, backend: 'windows' }
57
+ mkdirSync(secretsDir || '.', { recursive: true })
58
+ writeFileSync(joinPath(secretsDir || '.', acc + '.bin'), b64, 'utf8')
59
+ return { ok: true, backend: 'windows' }
60
+ }
61
+ if (process.platform === 'linux') {
62
+ await run('secret-tool', ['store', '--label=' + SERVICE, 'service', SERVICE, 'account', acc], String(password) + '\n')
63
+ return { ok: true, backend: 'secret' }
64
+ }
65
+ } catch { /* fall through */ }
66
+ return { ok: false, backend: platformBackend() }
67
+ }
68
+
69
+ /** Fetch a stored password. Resolves string|null. */
70
+ export async function getSecret(machineId, secretsDir) {
71
+ const acc = account(machineId)
72
+ try {
73
+ if (process.platform === 'darwin') {
74
+ return await run('security', ['find-generic-password', '-s', SERVICE, '-a', acc, '-w'])
75
+ }
76
+ if (process.platform === 'win32') {
77
+ const b64 = readFileSync(joinPath(secretsDir || '.', acc + '.bin'), 'utf8').trim()
78
+ if (!b64) return null
79
+ const script =
80
+ `$s=[Convert]::FromBase64String('${b64}');` +
81
+ `[Text.Encoding]::UTF8.GetString([System.Security.Cryptography.ProtectedData]::Unprotect($s,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser))`
82
+ return await run('powershell', ['-NoProfile', '-STA', '-Command', script])
83
+ }
84
+ if (process.platform === 'linux') {
85
+ return await run('secret-tool', ['lookup', 'service', SERVICE, 'account', acc])
86
+ }
87
+ } catch { /* fall through */ }
88
+ return null
89
+ }
90
+
91
+ /** Delete a stored password (idempotent). */
92
+ export async function deleteSecret(machineId, secretsDir) {
93
+ const acc = account(machineId)
94
+ try {
95
+ if (process.platform === 'darwin') {
96
+ await run('security', ['delete-generic-password', '-s', SERVICE, '-a', acc]).catch(() => {})
97
+ return
98
+ }
99
+ if (process.platform === 'win32') {
100
+ try { unlinkSync(joinPath(secretsDir || '.', acc + '.bin')) } catch { /* absent */ }
101
+ return
102
+ }
103
+ if (process.platform === 'linux') {
104
+ await run('secret-tool', ['clear', 'service', SERVICE, 'account', acc]).catch(() => {})
105
+ }
106
+ } catch { /* best effort */ }
107
+ }
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
+ }