dsh-remote 0.8.54 → 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 +15 -0
- package/README.md +13 -0
- package/lib/client.js +3853 -3733
- package/lib/credential.js +52 -4
- package/lib/errors.js +34 -3
- package/lib/index.js +156 -28
- package/lib/instructions.js +288 -288
- package/lib/pool.js +10 -1
- package/lib/registry.js +118 -4
- package/lib/routes-fs.js +14 -2
- 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
|
|
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
|
}
|
package/lib/index.js
CHANGED
|
@@ -58,7 +58,7 @@ import {
|
|
|
58
58
|
import { registerDeploySkill, buildInvestigatePrompt, SKILL_NAME } from './web-deploy-skill.js'
|
|
59
59
|
import { selfDir, diskVersion, LOADED_VERSION, gtVersion, fetchLatestVersion, applyUpdate, persistUpdateMode, readUpdateMode, reloadSelf, scheduleSelfReload, lastSelfReload, isInstalledCopy } from './update.js'
|
|
60
60
|
import { sendHeartbeat, pseudonym, heartbeatUrl, hasPersistedId, installIdPath } from './telemetry.js'
|
|
61
|
-
import { loadMachines as _loadMachines, saveMachines as _saveMachines, sanitizeMachine as _sanitizeMachine, applyMachine as _applyMachine, machineId as _machineId, normalizeMachineGroup, filterMachinesByGroup } from './registry.js'
|
|
61
|
+
import { loadMachines as _loadMachines, saveMachines as _saveMachines, sanitizeMachine as _sanitizeMachine, applyMachine as _applyMachine, machineId as _machineId, normalizeMachineGroup, filterMachinesByGroup, resolveProbeCredentials } from './registry.js'
|
|
62
62
|
import { registerHttpTransports } from './http-transport.js'
|
|
63
63
|
import { SshPool, ActivePool, createPool } from './pool.js'
|
|
64
64
|
import { isSystemOpenSshBackend, sshBinaryAvailable, resolveSshBinary } from './openssh.js'
|
|
@@ -1495,6 +1495,29 @@ export async function apply(ctx, config) {
|
|
|
1495
1495
|
err.httpStatus = 403
|
|
1496
1496
|
throw err
|
|
1497
1497
|
}
|
|
1498
|
+
// No session context: fall back to the ACTIVE machine. Boot restore runs on
|
|
1499
|
+
// `setImmediate`, so a request that arrives first would see `config.host`
|
|
1500
|
+
// still empty even though the registry already names a current machine —
|
|
1501
|
+
// apply it here instead of answering "no machine" to a user who HAS one.
|
|
1502
|
+
if (!String(config.host || '').trim()) {
|
|
1503
|
+
const m = activeMachine()
|
|
1504
|
+
if (m && m.host) {
|
|
1505
|
+
try { await applyActiveMachine() } catch { /* reported below */ }
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
// Still nothing: answer with the REAL reason. Falling through used to hand the
|
|
1509
|
+
// caller the un-configured pool, whose first command failed with
|
|
1510
|
+
// "no credentials: set a password, a privateKeyPath…" — i.e. a missing
|
|
1511
|
+
// *selection* was reported as a missing *credential*, which sends people to the
|
|
1512
|
+
// wrong settings page.
|
|
1513
|
+
if (!String(config.host || '').trim()) {
|
|
1514
|
+
const err = new Error(
|
|
1515
|
+
'当前没有活动远程机:请先在「远程工作区」设置里选择或连接一台机器。'
|
|
1516
|
+
+ '(保存的机器只是备用连接,不会自动成为当前机器。)',
|
|
1517
|
+
)
|
|
1518
|
+
err.httpStatus = 409
|
|
1519
|
+
throw err
|
|
1520
|
+
}
|
|
1498
1521
|
return {
|
|
1499
1522
|
pool,
|
|
1500
1523
|
ws: wsPath(),
|
|
@@ -3199,6 +3222,13 @@ export async function apply(ctx, config) {
|
|
|
3199
3222
|
})
|
|
3200
3223
|
const pickLocalNative = async () => {
|
|
3201
3224
|
const platform = process.platform
|
|
3225
|
+
// Escape hatch for non-interactive hosts and for the test suite: opening a
|
|
3226
|
+
// real OS folder dialog from a request handler means the REQUEST waits for a
|
|
3227
|
+
// human. In a headless host (or CI) that is a hang nobody can see or cancel —
|
|
3228
|
+
// the route then falls back to its browse capability / "type the path" hint.
|
|
3229
|
+
if (String(process.env.DSH_REMOTE_NO_NATIVE_DIALOG || '') && process.env.DSH_REMOTE_NO_NATIVE_DIALOG !== '0') {
|
|
3230
|
+
throw new Error('已禁用本机目录选择器对话框(DSH_REMOTE_NO_NATIVE_DIALOG)—— 请直接在输入框填本地路径')
|
|
3231
|
+
}
|
|
3202
3232
|
if (platform === 'darwin') {
|
|
3203
3233
|
return runPick('osascript', ['-e', 'set selectedFolder to choose folder with prompt "Select Workspace Directory"', '-e', 'POSIX path of selectedFolder'])
|
|
3204
3234
|
}
|
|
@@ -3622,8 +3652,20 @@ export async function apply(ctx, config) {
|
|
|
3622
3652
|
port: Number(body.port) || 22,
|
|
3623
3653
|
username: String(body.username || '').trim() || 'root',
|
|
3624
3654
|
password: '',
|
|
3625
|
-
|
|
3626
|
-
|
|
3655
|
+
// An OMITTED `privateKeyPath` keeps the stored one; an explicit
|
|
3656
|
+
// empty string clears it (that is what the form sends when the user
|
|
3657
|
+
// empties the box). A partial update from an API/agent caller used
|
|
3658
|
+
// to silently wipe the key path, leaving a machine that can only
|
|
3659
|
+
// fail to authenticate — the same data-loss class as the passphrase.
|
|
3660
|
+
privateKeyPath: body.privateKeyPath === undefined
|
|
3661
|
+
? String((prev && prev.privateKeyPath) || '')
|
|
3662
|
+
: String(body.privateKeyPath || '').trim(),
|
|
3663
|
+
// An EMPTY passphrase from the form means "unchanged", not
|
|
3664
|
+
// "delete it": the value is never echoed back to the browser, so
|
|
3665
|
+
// an edit that touched any other field used to silently wipe a
|
|
3666
|
+
// saved private-key passphrase (and the next connect then failed
|
|
3667
|
+
// with "no passphrase given" for a key that had been working).
|
|
3668
|
+
passphrase: String(body.passphrase || (prev && prev.passphrase) || ''),
|
|
3627
3669
|
workspace: String(body.workspace || '').trim(),
|
|
3628
3670
|
hostKeyMode: body.hostKeyMode || '',
|
|
3629
3671
|
useAgent: !!body.useAgent,
|
|
@@ -3649,13 +3691,20 @@ export async function apply(ctx, config) {
|
|
|
3649
3691
|
description: body.description === undefined ? (prev && prev.description) : body.description,
|
|
3650
3692
|
tags: body.tags === undefined ? (prev && prev.tags) : body.tags,
|
|
3651
3693
|
}),
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
|
|
3694
|
+
// A jump host the form did not mention is KEPT (omitted ≠
|
|
3695
|
+
// removed), and a jump PASSWORD the form did not re-supply is
|
|
3696
|
+
// kept too: the settings page never receives it back, so an edit
|
|
3697
|
+
// used to strip the bastion credential and the machine could
|
|
3698
|
+
// only fail at connect time.
|
|
3699
|
+
proxy: body.proxy === undefined
|
|
3700
|
+
? (prev && prev.proxy && prev.proxy.host ? prev.proxy : undefined)
|
|
3701
|
+
: (body.proxy && body.proxy.host ? {
|
|
3702
|
+
host: String(body.proxy.host),
|
|
3703
|
+
port: Number(body.proxy.port) || 22,
|
|
3704
|
+
username: String(body.proxy.username || '').trim(),
|
|
3705
|
+
password: String(body.proxy.password || (prev && prev.proxy && prev.proxy.password) || ''),
|
|
3706
|
+
privateKeyPath: String(body.proxy.privateKeyPath || '').trim(),
|
|
3707
|
+
} : undefined),
|
|
3659
3708
|
credentialBackend: credBackend,
|
|
3660
3709
|
recentWorkspaces: prev && prev.recentWorkspaces ? prev.recentWorkspaces : [],
|
|
3661
3710
|
lastConnectedAt: prev && prev.lastConnectedAt ? prev.lastConnectedAt : null,
|
|
@@ -3677,12 +3726,33 @@ export async function apply(ctx, config) {
|
|
|
3677
3726
|
})
|
|
3678
3727
|
rec.credentialBackend = persisted.credentialBackend
|
|
3679
3728
|
rec.password = persisted.password
|
|
3729
|
+
// Record WHERE the secret ended up, so the settings page can say
|
|
3730
|
+
// "已保存" for a keychain machine too (its record carries an
|
|
3731
|
+
// empty `password` by design).
|
|
3732
|
+
rec.secretStored = persisted.stored === 'secret'
|
|
3680
3733
|
if (persisted.warning) {
|
|
3681
3734
|
warning = persisted.warning
|
|
3682
3735
|
warningDetail = persisted.error
|
|
3683
3736
|
}
|
|
3684
|
-
} else if (prev
|
|
3685
|
-
|
|
3737
|
+
} else if (prev) {
|
|
3738
|
+
// No password typed: keep whatever the machine already had —
|
|
3739
|
+
// the plaintext copy AND the keychain flag. Turning the
|
|
3740
|
+
// "加密保存密码" checkbox OFF is handled next: it must actually
|
|
3741
|
+
// MOVE the secret, or the checkbox is a lie.
|
|
3742
|
+
rec.password = prev.password || ''
|
|
3743
|
+
rec.secretStored = prev.secretStored
|
|
3744
|
+
const prevKeychain = prev.credentialBackend && prev.credentialBackend !== 'plain'
|
|
3745
|
+
if (credBackend === 'plain' && prevKeychain) {
|
|
3746
|
+
try {
|
|
3747
|
+
const existing = await machinePassword(prev)
|
|
3748
|
+
if (existing) {
|
|
3749
|
+
rec.password = existing
|
|
3750
|
+
rec.secretStored = false
|
|
3751
|
+
rec.credentialBackend = 'plain'
|
|
3752
|
+
await deleteSecret(id, secretsDir()).catch(() => {})
|
|
3753
|
+
}
|
|
3754
|
+
} catch { /* keep the keychain copy; the next connect still works */ }
|
|
3755
|
+
}
|
|
3686
3756
|
}
|
|
3687
3757
|
if (i >= 0) machines[i] = rec; else machines.push(rec)
|
|
3688
3758
|
// Issue #13: saving a machine must NOT make it the active
|
|
@@ -3788,17 +3858,46 @@ export async function apply(ctx, config) {
|
|
|
3788
3858
|
let body = {}
|
|
3789
3859
|
try {
|
|
3790
3860
|
body = parseJsonObject(await readBody(req))
|
|
3861
|
+
// ── The probe must dial with the credentials a REAL connect would use.
|
|
3862
|
+
//
|
|
3863
|
+
// Stored secrets never travel back to the browser (see
|
|
3864
|
+
// sanitizeMachine), so "编辑一台已经连接成功的机器 → 点测试连接" posted an
|
|
3865
|
+
// EMPTY password while the machine itself connected fine through the
|
|
3866
|
+
// pool's keychain/plaintext resolver. Every such probe died with
|
|
3867
|
+
// `no credentials: set a password, a privateKeyPath, or enable
|
|
3868
|
+
// useAgent to connect` — the error said "you configured nothing" about
|
|
3869
|
+
// a machine that was visibly working.
|
|
3870
|
+
//
|
|
3871
|
+
// So: resolve the saved record (by id when the form is editing one,
|
|
3872
|
+
// else by identity) and merge — form values override, blanks fall back
|
|
3873
|
+
// to the stored record. The resolved password is then ALSO exposed as a
|
|
3874
|
+
// lazy resolver so an OS-keychain machine behaves exactly like a real
|
|
3875
|
+
// connect (read on demand, never logged).
|
|
3876
|
+
const savedRec = (() => {
|
|
3877
|
+
const byId = body.id ? machines[machineIndex(String(body.id))] : null
|
|
3878
|
+
if (byId) return byId
|
|
3879
|
+
// No id (a fresh form that happens to match a saved machine): match on
|
|
3880
|
+
// the identity the form is about to dial.
|
|
3881
|
+
const hit = machineRecordFor({
|
|
3882
|
+
host: String(body.host || ''),
|
|
3883
|
+
port: Number(body.port) || 22,
|
|
3884
|
+
username: String(body.username || ''),
|
|
3885
|
+
})
|
|
3886
|
+
return hit || null
|
|
3887
|
+
})()
|
|
3888
|
+
const savedPassword = savedRec ? await machinePassword(savedRec) : ''
|
|
3889
|
+
const cred = resolveProbeCredentials(body, savedRec, savedPassword)
|
|
3791
3890
|
// Issue #38: "test" on an ssh-config ALIAS resolves the alias first —
|
|
3792
3891
|
// the form holds the alias name, not the host it points at.
|
|
3793
|
-
const probeBackend = String(
|
|
3892
|
+
const probeBackend = String(cred.backend || config.backend || 'native')
|
|
3794
3893
|
const probeIsOpenssh = isSystemOpenSshBackend({ backend: probeBackend })
|
|
3795
3894
|
const aliasTarget = body.useSshConfig
|
|
3796
3895
|
? resolveMachineSshConfig({
|
|
3797
3896
|
host: String(body.host || ''),
|
|
3798
3897
|
port: Number(body.port) || 22,
|
|
3799
3898
|
username: String(body.username || ''),
|
|
3800
|
-
privateKeyPath: String(
|
|
3801
|
-
proxy:
|
|
3899
|
+
privateKeyPath: String(cred.privateKeyPath || ''),
|
|
3900
|
+
proxy: cred.proxy,
|
|
3802
3901
|
useSshConfig: true,
|
|
3803
3902
|
}, { text: sshConfigText() })
|
|
3804
3903
|
: null
|
|
@@ -3818,19 +3917,24 @@ export async function apply(ctx, config) {
|
|
|
3818
3917
|
host: probeTarget && !probeIsOpenssh ? probeTarget.host : String(body.host || config.host),
|
|
3819
3918
|
port: (probeTarget && !probeIsOpenssh && probeTarget.port) ? probeTarget.port : (Number(body.port) || config.port),
|
|
3820
3919
|
username: (probeTarget && !probeIsOpenssh && probeTarget.username) ? probeTarget.username : String(body.username || config.username),
|
|
3821
|
-
password:
|
|
3822
|
-
privateKeyPath: (probeTarget && !probeIsOpenssh && probeTarget.privateKeyPath) ? probeTarget.privateKeyPath : String(
|
|
3823
|
-
passphrase:
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
} :
|
|
3920
|
+
password: cred.password,
|
|
3921
|
+
privateKeyPath: (probeTarget && !probeIsOpenssh && probeTarget.privateKeyPath) ? probeTarget.privateKeyPath : String(cred.privateKeyPath || config.privateKeyPath),
|
|
3922
|
+
passphrase: cred.passphrase,
|
|
3923
|
+
// Auth METHOD flags come from the merge too: a machine that
|
|
3924
|
+
// authenticates through an agent or keyboard-interactive used to be
|
|
3925
|
+
// probed with `useAgent: false`, i.e. with no auth method at all.
|
|
3926
|
+
useAgent: cred.useAgent,
|
|
3927
|
+
keyboardInteractive: cred.keyboardInteractive,
|
|
3928
|
+
...(cred.hostKeyMode ? { hostKeyMode: cred.hostKeyMode } : {}),
|
|
3929
|
+
...(cred.sshBinary ? { sshBinary: cred.sshBinary } : {}),
|
|
3930
|
+
proxy: (probeTarget && !probeIsOpenssh && probeTarget.proxy) ? probeTarget.proxy : cred.proxy,
|
|
3831
3931
|
connectTimeoutMs: Math.min(Math.max(Number(body.connectTimeoutMs) || config.connectTimeoutMs, 2000), 30000),
|
|
3832
3932
|
commandTimeoutMs: 10000,
|
|
3833
3933
|
}, { knownHostsFile })
|
|
3934
|
+
// Same lazy resolution the active pool uses: if the merged password was
|
|
3935
|
+
// empty because the secret lives in the OS keychain, the pool asks again
|
|
3936
|
+
// at dial time instead of failing with "no credentials".
|
|
3937
|
+
probe.passwordResolver = async () => machinePassword(savedRec || {})
|
|
3834
3938
|
const started = Date.now()
|
|
3835
3939
|
await probe.connect()
|
|
3836
3940
|
await probe.exec('true', { timeoutMs: 10000 })
|
|
@@ -3853,6 +3957,9 @@ export async function apply(ctx, config) {
|
|
|
3853
3957
|
platform: probe.platform,
|
|
3854
3958
|
shell: probe.shellMode,
|
|
3855
3959
|
gitBash: probe.gitBashPath || '',
|
|
3960
|
+
// Tell the UI whether the probe leaned on the SAVED credential: with
|
|
3961
|
+
// an empty password box, "测试成功" otherwise looks impossible.
|
|
3962
|
+
usedSavedCredential: cred.usedSavedCredential,
|
|
3856
3963
|
// Issue #38: tell the settings page where an alias resolved to (and
|
|
3857
3964
|
// anything the plugin could not honour, e.g. a multi-hop ProxyJump).
|
|
3858
3965
|
alias: aliasTarget ? { name: aliasTarget.alias, host: aliasTarget.host, port: aliasTarget.port, user: aliasTarget.username, warnings: aliasTarget.warnings } : null,
|
|
@@ -3903,11 +4010,23 @@ export async function apply(ctx, config) {
|
|
|
3903
4010
|
const body = JSON.parse((await readBody(req)) || '{}')
|
|
3904
4011
|
const action = String(body.action || '')
|
|
3905
4012
|
if (action === 'define') {
|
|
4013
|
+
// Validate the port HERE, not at start time: `listen(0)` would silently
|
|
4014
|
+
// pick an ephemeral port and the user would be looking at a forward
|
|
4015
|
+
// that listens nowhere they asked for. The settings UI cannot produce
|
|
4016
|
+
// this, but the HTTP API (and therefore an agent) can.
|
|
4017
|
+
const listenPort = Number(body.listenPort)
|
|
4018
|
+
if (!Number.isInteger(listenPort) || listenPort < 1 || listenPort > 65535) {
|
|
4019
|
+
return sendJson(res, 400, { ok: false, error: 'listenPort 必须是 1–65535 的整数' })
|
|
4020
|
+
}
|
|
4021
|
+
const targetPort = Number(body.targetPort) || listenPort
|
|
4022
|
+
if (!Number.isInteger(targetPort) || targetPort < 1 || targetPort > 65535) {
|
|
4023
|
+
return sendJson(res, 400, { ok: false, error: 'targetPort 必须是 1–65535 的整数' })
|
|
4024
|
+
}
|
|
3906
4025
|
const d = forwards.define({
|
|
3907
4026
|
direction: body.direction === 'reverse' ? 'reverse' : 'local',
|
|
3908
|
-
listenPort
|
|
4027
|
+
listenPort,
|
|
3909
4028
|
targetHost: body.targetHost || '127.0.0.1',
|
|
3910
|
-
targetPort
|
|
4029
|
+
targetPort,
|
|
3911
4030
|
autoStart: !!body.autoStart,
|
|
3912
4031
|
machineId: store.currentId,
|
|
3913
4032
|
})
|
|
@@ -4286,7 +4405,16 @@ export async function apply(ctx, config) {
|
|
|
4286
4405
|
if (!home) return sendJson(res, 200, { ok: true, home: null, hint: 'Windows 远程暂不支持 ~ 解析,请直接输入绝对路径' })
|
|
4287
4406
|
return sendJson(res, 200, { ok: true, home })
|
|
4288
4407
|
} catch (err) {
|
|
4289
|
-
|
|
4408
|
+
// `ok:false` — reporting `ok:true` WITH an `error` is a contradiction:
|
|
4409
|
+
// any caller that trusts `ok` treats "could not resolve the home
|
|
4410
|
+
// directory" as success. The picker only reads `home`, so it still falls
|
|
4411
|
+
// back to `hint`/`error` exactly as before.
|
|
4412
|
+
return sendJson(res, 200, {
|
|
4413
|
+
ok: false,
|
|
4414
|
+
home: null,
|
|
4415
|
+
hint: 'Windows 远程暂不支持 ~ 解析,请直接输入绝对路径',
|
|
4416
|
+
error: String((err && err.message) || err),
|
|
4417
|
+
})
|
|
4290
4418
|
}
|
|
4291
4419
|
},
|
|
4292
4420
|
},
|