dsh-remote 0.8.49 → 0.8.50
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.md +1 -0
- package/lib/client.js +11 -3
- package/lib/index.js +97 -26
- package/lib/sshconfig.js +256 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -53,6 +53,7 @@ DSH 的 Web 界面刻意只监听 `127.0.0.1`(CLI 为安全拒绝 `--host 0.0.
|
|
|
53
53
|
- **Windows 主机** —— 自动探测平台并定位 Git Bash,命令经 `bash -s` 走 stdin 执行,不受引号/反斜杠转义困扰(`config.shell` 可指定或设 `native` 关闭);`C:\Users\dev` 与 `/c/Users/dev` 两种写法都接受。
|
|
54
54
|
- **长任务异步化** —— `rw_sync`/`rw_push` 传 `async: true` 返回 `taskId`,可查询进度/结果/取消。
|
|
55
55
|
- **数据跟随 Harness 根目录** —— 机器清单与镜像在 `$DSH_HOME/remote-workspaces`;0.6 之前的数据首次启动自动迁移。
|
|
56
|
+
- **`~/.ssh/config` 是连接四字段的唯一来源**(`0.8.50+`,issue #50)—— 保存机器时把 主机/用户/端口/私钥 写入 `~/.ssh/config` 的受管块(`# dsh-remote: managed` 标记,只动自己的块;你手写的同名 Host 块绝不覆盖,会提示冲突)。之后**改这个文件立即生效**:任何 Host 块点名的主机(含字面 IP),连接都按文件解析(裸 `Host *` 是通用默认,不算点名);机器记录里的副本只做兜底,插件自己的状态(最近工作区/延迟等)仍存注册表。
|
|
56
57
|
- **不改动 `dsh-workspace` 官方代码** —— 全部作为普通插件实现。
|
|
57
58
|
|
|
58
59
|
## 安装
|
package/lib/client.js
CHANGED
|
@@ -73,6 +73,7 @@ window.__ModuleLoader__.load({
|
|
|
73
73
|
'settings.sshAliasHint': '别名模式:机器只记住「{alias}」这个名字,主机/用户/端口/私钥/跳板每次连接都从 ~/.ssh/config 实时解析(改配置立刻生效)。',
|
|
74
74
|
'settings.useSshConfig': '使用 ~/.ssh/config 别名(只存别名,不存副本)',
|
|
75
75
|
'settings.useSshConfigHint': '留空或保持默认的字段(用户 root、端口 22、私钥、跳板)将从 ~/.ssh/config 实时解析;填了非默认值则覆盖。',
|
|
76
|
+
'settings.saveWritesSshConfig': '保存时会把 主机/用户/端口/私钥 写入 ~/.ssh/config 的受管块(# dsh-remote: managed 标记);此后该文件优先——直接改它立即生效。',
|
|
76
77
|
'settings.sshResolvedTo': '→ {user}@{host}:{port}',
|
|
77
78
|
'settings.sshResolvedNone': '~/.ssh/config 中没有匹配「{alias}」的 Host 条目',
|
|
78
79
|
'settings.sshResolvedWarn': '注意:{msg}',
|
|
@@ -360,6 +361,7 @@ window.__ModuleLoader__.load({
|
|
|
360
361
|
'settings.sshAliasHint': 'Alias mode: the machine remembers only the name "{alias}"; host/user/port/key/jump host are resolved from ~/.ssh/config at every connect, so editing that file takes effect immediately.',
|
|
361
362
|
'settings.useSshConfig': 'Use a ~/.ssh/config alias (keep the alias, not a copy)',
|
|
362
363
|
'settings.useSshConfigHint': 'Fields left empty or at their default (user root, port 22, key, jump host) come from ~/.ssh/config at connect time; a non-default value overrides it.',
|
|
364
|
+
'settings.saveWritesSshConfig': 'Saving writes host/user/port/key into a managed block in ~/.ssh/config (marked # dsh-remote: managed); the file then takes precedence — edit it directly and it takes effect at once.',
|
|
363
365
|
'settings.sshResolvedTo': '→ {user}@{host}:{port}',
|
|
364
366
|
'settings.sshResolvedNone': 'no ~/.ssh/config block matches "{alias}"',
|
|
365
367
|
'settings.sshResolvedWarn': 'Heads-up: {msg}',
|
|
@@ -1269,9 +1271,12 @@ window.__ModuleLoader__.load({
|
|
|
1269
1271
|
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' } },
|
|
1270
1272
|
React.createElement('span', { style: { fontWeight: 600 } }, m.name),
|
|
1271
1273
|
React.createElement('code', { style: { fontSize: 12, opacity: 0.8 } },
|
|
1272
|
-
|
|
1273
|
-
:
|
|
1274
|
-
|
|
1274
|
+
(m.sshConfigResolved && m.sshConfigResolved.matched)
|
|
1275
|
+
// Issue #50: shown for literal machines too — a Host
|
|
1276
|
+
// block naming this machine's host now drives it.
|
|
1277
|
+
? (m.host + ' ' + tr('settings.sshResolvedTo', { user: m.sshConfigResolved.username || m.username || '?', host: m.sshConfigResolved.host, port: m.sshConfigResolved.port || m.port }))
|
|
1278
|
+
: (m.useSshConfig ? m.host : (m.username + '@' + m.host + ':' + m.port))),
|
|
1279
|
+
(m.useSshConfig || (m.sshConfigResolved && m.sshConfigResolved.matched)) ? pill('', tr('settings.badgeSshConfig')) : null,
|
|
1275
1280
|
// Issue #51: a machine on the system-OpenSSH backend behaves
|
|
1276
1281
|
// differently enough (auth from ~/.ssh/config, GSSAPI) that
|
|
1277
1282
|
// the row must say so.
|
|
@@ -1314,6 +1319,9 @@ window.__ModuleLoader__.load({
|
|
|
1314
1319
|
row(tr('settings.host'), React.createElement('input', { className: 'dsh-rw-input', value: form.host, onChange: setF('host'), placeholder: form.useSshConfig ? tr('settings.sshImport') + ' Host 别名' : 'IP or hostname', style: inputS }), 'h'),
|
|
1315
1320
|
row(tr('settings.useSshConfig'), React.createElement('input', { type: 'checkbox', checked: !!form.useSshConfig, onChange: setFb('useSshConfig') }), 'sshc'),
|
|
1316
1321
|
form.useSshConfig ? React.createElement('div', { style: { fontSize: 11, opacity: 0.8, marginBottom: 8, lineHeight: 1.5 } }, tr('settings.useSshConfigHint')) : null,
|
|
1322
|
+
// Issue #50: a literal save also touches ~/.ssh/config (managed
|
|
1323
|
+
// block) — say so where the choice is made.
|
|
1324
|
+
!form.useSshConfig ? React.createElement('div', { style: { fontSize: 11, opacity: 0.8, marginBottom: 8, lineHeight: 1.5 } }, tr('settings.saveWritesSshConfig')) : null,
|
|
1317
1325
|
form.useSshConfig ? (() => {
|
|
1318
1326
|
// Preview where the alias points, from the same resolution the
|
|
1319
1327
|
// connect path uses (the host list carries it — issue #38).
|
package/lib/index.js
CHANGED
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
import { createHostKeyGuard, isHostKeyKnown as _isHostKeyKnown } from './hostkey.js'
|
|
35
35
|
import { compileIgnore, ignorePatternsFor, DEFAULT_IGNORE } from './ignore.js'
|
|
36
36
|
import { friendlyMessage } from './errors.js'
|
|
37
|
-
import { importableEntries, sshConfigPath, readSshConfigText, resolveMachineSshConfig } from './sshconfig.js'
|
|
37
|
+
import { importableEntries, sshConfigPath, readSshConfigText, resolveMachineSshConfig, resolveLiteralSshConfig, readRawSshConfig, upsertManagedHostBlock, removeManagedHostBlock, writeSshConfig } from './sshconfig.js'
|
|
38
38
|
import { createRemoteFileReference, installFileReferenceOverlay, DEFAULT_EXCLUDED_DIRECTORIES } from './file-reference.js'
|
|
39
39
|
import { getSecret, deleteSecret, platformBackend, persistPassword } from './credential.js'
|
|
40
40
|
import { syncTree, pushTree, loadSyncState, saveSyncState, pushOneFile } from './sync.js'
|
|
@@ -431,25 +431,49 @@ export async function apply(ctx, config) {
|
|
|
431
431
|
* keychain lookups and the settings UI keep working.
|
|
432
432
|
*/
|
|
433
433
|
const loggedSshWarnings = new Map()
|
|
434
|
-
const
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
// Warn once per distinct warning set: this runs on every tool call, and an
|
|
442
|
-
// unmatched alias would otherwise fill the log with the same line.
|
|
443
|
-
const signature = resolved.warnings.join(' | ')
|
|
444
|
-
if (signature && loggedSshWarnings.get(resolved.alias) !== signature) {
|
|
445
|
-
loggedSshWarnings.set(resolved.alias, signature)
|
|
434
|
+
const warnSshOnce = (alias, warnings) => {
|
|
435
|
+
// Warn once per distinct warning set: withSshConfig runs on every tool
|
|
436
|
+
// call, and an unmatched alias would otherwise fill the log with the same
|
|
437
|
+
// line.
|
|
438
|
+
const signature = warnings.join(' | ')
|
|
439
|
+
if (signature && loggedSshWarnings.get(alias) !== signature) {
|
|
440
|
+
loggedSshWarnings.set(alias, signature)
|
|
446
441
|
try {
|
|
447
442
|
const logger = typeof ctx.get === 'function' ? ctx.get('logger') : undefined
|
|
448
443
|
if (logger && typeof logger.warn === 'function') {
|
|
449
|
-
logger.warn(`[dsh-remote] ssh-config alias "${
|
|
444
|
+
logger.warn(`[dsh-remote] ssh-config alias "${alias}": ${warnings.join(' ')}`)
|
|
450
445
|
}
|
|
451
446
|
} catch { /* logging is best-effort */ }
|
|
452
447
|
}
|
|
448
|
+
}
|
|
449
|
+
const withSshConfig = (m) => {
|
|
450
|
+
if (!m) return m
|
|
451
|
+
// Issue #50: ~/.ssh/config wins for ANY host it names, not only alias
|
|
452
|
+
// machines. A literal machine's stored host/user/port/key is a COPY that
|
|
453
|
+
// goes stale the moment the file is edited; when a Host block names the
|
|
454
|
+
// machine's host (a bare `Host *` does not name it), the file's values
|
|
455
|
+
// take over and the record fills the gaps.
|
|
456
|
+
if (!m.useSshConfig) {
|
|
457
|
+
const lit = resolveLiteralSshConfig(m, { text: sshConfigText() })
|
|
458
|
+
if (!lit) return m
|
|
459
|
+
warnSshOnce(lit.alias, lit.warnings)
|
|
460
|
+
return {
|
|
461
|
+
...m,
|
|
462
|
+
sshConfigMatched: true,
|
|
463
|
+
sshConfigWarnings: lit.warnings,
|
|
464
|
+
// The name the user typed, kept for the system-OpenSSH backend: ssh
|
|
465
|
+
// must match its Host blocks against exactly this (connectTargetFor).
|
|
466
|
+
sshConfigName: m.host,
|
|
467
|
+
host: lit.host || m.host,
|
|
468
|
+
port: lit.port || m.port,
|
|
469
|
+
username: lit.username || m.username,
|
|
470
|
+
privateKeyPath: lit.privateKeyPath || m.privateKeyPath,
|
|
471
|
+
proxy: lit.proxy || m.proxy,
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
const resolved = resolveMachineSshConfig(m, { text: sshConfigText() })
|
|
475
|
+
if (!resolved) return m
|
|
476
|
+
warnSshOnce(resolved.alias, resolved.warnings)
|
|
453
477
|
return {
|
|
454
478
|
...m,
|
|
455
479
|
sshAlias: resolved.alias,
|
|
@@ -483,7 +507,10 @@ export async function apply(ctx, config) {
|
|
|
483
507
|
* @returns {{host: string, port: number, username: string, privateKeyPath: string, proxy: object|undefined, aliasNative: boolean}}
|
|
484
508
|
*/
|
|
485
509
|
const connectTargetFor = (eff) => {
|
|
486
|
-
|
|
510
|
+
// Issue #50: a LITERAL machine whose host a Host block names is in the same
|
|
511
|
+
// position as an alias machine on the OpenSSH backend — ssh must match its
|
|
512
|
+
// blocks against the ORIGINAL name, so it is handed over verbatim too.
|
|
513
|
+
const aliasNative = !!(eff && isSystemOpenSshBackend(eff) && (eff.useSshConfig || eff.sshConfigMatched))
|
|
487
514
|
if (!aliasNative) {
|
|
488
515
|
return {
|
|
489
516
|
host: String((eff && eff.host) || ''),
|
|
@@ -494,7 +521,7 @@ export async function apply(ctx, config) {
|
|
|
494
521
|
aliasNative: false,
|
|
495
522
|
}
|
|
496
523
|
}
|
|
497
|
-
const alias = String((eff && (eff.sshAlias || eff.host)) || '')
|
|
524
|
+
const alias = String((eff && (eff.sshAlias || eff.sshConfigName || eff.host)) || '')
|
|
498
525
|
return {
|
|
499
526
|
// The alias itself; ssh expands HostName/User/Port/IdentityFile and every
|
|
500
527
|
// other directive while matching that Host block.
|
|
@@ -511,13 +538,14 @@ export async function apply(ctx, config) {
|
|
|
511
538
|
const aliasOf = (m) => (m && m.sshAlias) || (m && m.useSshConfig ? String(m.host || '') : '')
|
|
512
539
|
|
|
513
540
|
/** Machines as the settings UI sees them: secrets stripped, plus the
|
|
514
|
-
* ~/.ssh/config resolution of an alias machine
|
|
515
|
-
*
|
|
516
|
-
*
|
|
541
|
+
* ~/.ssh/config resolution of an alias machine (issue #38) — and, since
|
|
542
|
+
* issue #50, of a LITERAL machine whose host a Host block names, so the row
|
|
543
|
+
* can show that the file now drives it. */
|
|
517
544
|
const machinesForClient = () => machines.map((m) => ({
|
|
518
545
|
...sanitizeMachine(m),
|
|
519
|
-
|
|
520
|
-
|
|
546
|
+
sshConfigResolved: m.useSshConfig
|
|
547
|
+
? resolveMachineSshConfig(m, { text: sshConfigText() })
|
|
548
|
+
: resolveLiteralSshConfig(m, { text: sshConfigText() }),
|
|
521
549
|
}))
|
|
522
550
|
|
|
523
551
|
/**
|
|
@@ -3090,12 +3118,49 @@ export async function apply(ctx, config) {
|
|
|
3090
3118
|
// alone, so a fresh registry stays "no choice yet" (not an
|
|
3091
3119
|
// explicit none) and a previously-cleared registry stays cleared.
|
|
3092
3120
|
saveMachines(machines, store.currentId, false)
|
|
3121
|
+
// Issue #50: host/user/port/key have ONE home — ~/.ssh/config.
|
|
3122
|
+
// Saving a literal machine upserts a marker-tagged Host block,
|
|
3123
|
+
// so the file and the registry can never drift; the record's
|
|
3124
|
+
// copy remains only as a fallback for a missing/renamed block.
|
|
3125
|
+
// A Host block the USER wrote for the same name is never
|
|
3126
|
+
// touched — the conflict is reported instead. Failures here are
|
|
3127
|
+
// best-effort: the registry copy is already saved.
|
|
3128
|
+
let sshConfigWarning = ''
|
|
3129
|
+
if (!rec.useSshConfig) {
|
|
3130
|
+
try {
|
|
3131
|
+
const raw = readRawSshConfig(sshConfigPath())
|
|
3132
|
+
let next = upsertManagedHostBlock(raw, {
|
|
3133
|
+
alias: rec.host,
|
|
3134
|
+
hostName: rec.host,
|
|
3135
|
+
user: rec.username,
|
|
3136
|
+
port: rec.port,
|
|
3137
|
+
identityFile: rec.privateKeyPath,
|
|
3138
|
+
})
|
|
3139
|
+
// A RENAMED machine leaves the old managed block behind, and
|
|
3140
|
+
// that block is the plugin's own artifact of the edit —
|
|
3141
|
+
// retire it. (Deleting a machine removes nothing: the Host
|
|
3142
|
+
// block is the user's ssh access, not plugin state.)
|
|
3143
|
+
if (prev && prev.host && prev.host !== rec.host && !prev.useSshConfig) {
|
|
3144
|
+
const rm = removeManagedHostBlock(next.text, prev.host)
|
|
3145
|
+
next = { status: rm.status === 'removed' && next.status === 'unchanged' ? 'updated' : next.status, text: rm.text }
|
|
3146
|
+
}
|
|
3147
|
+
if (next.status === 'conflict') {
|
|
3148
|
+
sshConfigWarning = `~/.ssh/config already names "${rec.host}" in a Host block that is not managed by dsh-remote — it was left untouched, and it now wins over the saved copy.`
|
|
3149
|
+
} else if (next.status === 'appended' || next.status === 'updated') {
|
|
3150
|
+
writeSshConfig(next.text, sshConfigPath())
|
|
3151
|
+
sshConfigCache = { text: '', at: 0 }
|
|
3152
|
+
}
|
|
3153
|
+
} catch (sshErr) {
|
|
3154
|
+
sshConfigWarning = `could not update ~/.ssh/config (${String((sshErr && sshErr.message) || sshErr)}); the registry copy was saved.`
|
|
3155
|
+
}
|
|
3156
|
+
}
|
|
3093
3157
|
return sendJson(res, 200, {
|
|
3094
3158
|
ok: true,
|
|
3095
3159
|
machine: sanitizeMachine(rec),
|
|
3096
3160
|
machines: machinesForClient(),
|
|
3097
3161
|
currentId: store.currentId,
|
|
3098
3162
|
...(warning ? { warning, warningDetail } : {}),
|
|
3163
|
+
...(sshConfigWarning ? { sshConfigWarning } : {}),
|
|
3099
3164
|
})
|
|
3100
3165
|
}
|
|
3101
3166
|
if (action === 'delete') {
|
|
@@ -3154,6 +3219,12 @@ export async function apply(ctx, config) {
|
|
|
3154
3219
|
useSshConfig: true,
|
|
3155
3220
|
}, { text: sshConfigText() })
|
|
3156
3221
|
: null
|
|
3222
|
+
// Issue #50: a LITERAL host named by a Host block resolves through
|
|
3223
|
+
// the file as well — the probe must dial what a real connect would.
|
|
3224
|
+
const literalTarget = !body.useSshConfig
|
|
3225
|
+
? resolveLiteralSshConfig({ host: String(body.host || '') }, { text: sshConfigText() })
|
|
3226
|
+
: null
|
|
3227
|
+
const probeTarget = aliasTarget || literalTarget
|
|
3157
3228
|
// Issue #51: on the system-OpenSSH backend an alias is handed to `ssh`
|
|
3158
3229
|
// VERBATIM (see connectTargetFor) — resolving it here would stop the
|
|
3159
3230
|
// `Host` block from matching, so the ~/.ssh/config settings the probe
|
|
@@ -3161,13 +3232,13 @@ export async function apply(ctx, config) {
|
|
|
3161
3232
|
const probe = createPool({
|
|
3162
3233
|
...config,
|
|
3163
3234
|
backend: probeBackend,
|
|
3164
|
-
host:
|
|
3165
|
-
port:
|
|
3166
|
-
username:
|
|
3235
|
+
host: probeTarget && !probeIsOpenssh ? probeTarget.host : String(body.host || config.host),
|
|
3236
|
+
port: (probeTarget && !probeIsOpenssh && probeTarget.port) ? probeTarget.port : (Number(body.port) || config.port),
|
|
3237
|
+
username: (probeTarget && !probeIsOpenssh && probeTarget.username) ? probeTarget.username : String(body.username || config.username),
|
|
3167
3238
|
password: String(body.password || ''),
|
|
3168
|
-
privateKeyPath: (
|
|
3239
|
+
privateKeyPath: (probeTarget && !probeIsOpenssh && probeTarget.privateKeyPath) ? probeTarget.privateKeyPath : String(body.privateKeyPath || config.privateKeyPath),
|
|
3169
3240
|
passphrase: String(body.passphrase || ''),
|
|
3170
|
-
proxy: (
|
|
3241
|
+
proxy: (probeTarget && !probeIsOpenssh && probeTarget.proxy) ? probeTarget.proxy : (body.proxy && body.proxy.host ? {
|
|
3171
3242
|
host: String(body.proxy.host),
|
|
3172
3243
|
port: Number(body.proxy.port) || 22,
|
|
3173
3244
|
username: String(body.proxy.username || ''),
|
package/lib/sshconfig.js
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
// The module is pure (I/O is injectable), so it is unit-testable without a real
|
|
29
29
|
// home directory — see test/sshconfig.test.js.
|
|
30
30
|
|
|
31
|
-
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
31
|
+
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync, chmodSync } from 'node:fs'
|
|
32
32
|
import { homedir } from 'node:os'
|
|
33
33
|
import path from 'node:path'
|
|
34
34
|
|
|
@@ -362,23 +362,14 @@ export function parseProxyJump(value) {
|
|
|
362
362
|
}
|
|
363
363
|
|
|
364
364
|
/**
|
|
365
|
-
*
|
|
365
|
+
* Shared proxy/warning shaping for alias and literal resolutions (issue #50).
|
|
366
366
|
*
|
|
367
367
|
* `ProxyJump` maps onto the plugin's single jump host. A multi-hop chain cannot
|
|
368
368
|
* be represented by one `proxy`, so the FIRST hop is used and `warnings` says so
|
|
369
369
|
* — silently connecting through the wrong path would be worse. `ProxyCommand`
|
|
370
370
|
* (an arbitrary local command) is reported but never executed.
|
|
371
|
-
*
|
|
372
|
-
* @param {string} alias
|
|
373
|
-
* @param {object} [opts]
|
|
374
|
-
* @param {string} [opts.text] - config text (else the real file is read).
|
|
375
|
-
* @param {string} [opts.file]
|
|
376
|
-
* @param {object} [opts.io]
|
|
377
|
-
* @returns {{alias: string, matched: boolean, host: string, port: number, username: string, privateKeyPath: string, proxy: object|undefined, proxyJump: string, warnings: string[]}}
|
|
378
371
|
*/
|
|
379
|
-
|
|
380
|
-
const text = opts.text !== undefined ? String(opts.text) : loadSshConfigText(opts.file || sshConfigPath(), opts.io || {})
|
|
381
|
-
const resolved = resolveSshHost(alias, text)
|
|
372
|
+
function targetFromResolved(resolved, text, { defaultPort }) {
|
|
382
373
|
const warnings = []
|
|
383
374
|
let proxy
|
|
384
375
|
const hops = parseProxyJump(resolved.proxyJump)
|
|
@@ -400,10 +391,10 @@ export function sshAliasTarget(alias, opts = {}) {
|
|
|
400
391
|
warnings.push('ProxyCommand is not supported by dsh-remote (no jump host was applied).')
|
|
401
392
|
}
|
|
402
393
|
return {
|
|
403
|
-
alias,
|
|
394
|
+
alias: resolved.alias,
|
|
404
395
|
matched: resolved.matched,
|
|
405
|
-
host: resolved.hostName || alias,
|
|
406
|
-
port: resolved.port ||
|
|
396
|
+
host: resolved.hostName || resolved.alias,
|
|
397
|
+
port: resolved.port || defaultPort,
|
|
407
398
|
username: resolved.user || '',
|
|
408
399
|
privateKeyPath: resolved.identityFile ? expandHome(resolved.identityFile) : '',
|
|
409
400
|
proxy,
|
|
@@ -412,6 +403,72 @@ export function sshAliasTarget(alias, opts = {}) {
|
|
|
412
403
|
}
|
|
413
404
|
}
|
|
414
405
|
|
|
406
|
+
/**
|
|
407
|
+
* Connect target for an ssh-config alias: the shape the plugin's pool expects.
|
|
408
|
+
*
|
|
409
|
+
* @param {string} alias
|
|
410
|
+
* @param {object} [opts]
|
|
411
|
+
* @param {string} [opts.text] - config text (else the real file is read).
|
|
412
|
+
* @param {string} [opts.file]
|
|
413
|
+
* @param {object} [opts.io]
|
|
414
|
+
* @returns {{alias: string, matched: boolean, host: string, port: number, username: string, privateKeyPath: string, proxy: object|undefined, proxyJump: string, warnings: string[]}}
|
|
415
|
+
*/
|
|
416
|
+
export function sshAliasTarget(alias, opts = {}) {
|
|
417
|
+
const text = opts.text !== undefined ? String(opts.text) : loadSshConfigText(opts.file || sshConfigPath(), opts.io || {})
|
|
418
|
+
return targetFromResolved(resolveSshHost(alias, text), text, { defaultPort: 22 })
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Effective connect identity for a LITERAL machine (issue #50).
|
|
423
|
+
*
|
|
424
|
+
* A machine saved with literal host/user/port/key holds a COPY of what may also
|
|
425
|
+
* be written in ~/.ssh/config, and the copy goes stale the moment the file is
|
|
426
|
+
* edited. The file is the single source for any host it NAMES: when a Host
|
|
427
|
+
* block matches the machine's literal host — with at least one positive pattern
|
|
428
|
+
* that is not the bare `*` — the resolved values win per field and the record
|
|
429
|
+
* fills the gaps. A bare `Host *` block deliberately does NOT name a host:
|
|
430
|
+
* treating universal defaults as an override would hijack every saved machine
|
|
431
|
+
* at once. Group patterns (`*.corp`, `web-?`) DO name hosts, exactly as ssh
|
|
432
|
+
* itself applies them to a literal dial.
|
|
433
|
+
*
|
|
434
|
+
* `port` is 0 (not 22) when the file says nothing, so the caller falls back to
|
|
435
|
+
* the record instead of the default.
|
|
436
|
+
*
|
|
437
|
+
* @param {object} machine - a registry record with a literal `host`.
|
|
438
|
+
* @param {{text?: string, file?: string, io?: object}} [opts]
|
|
439
|
+
* @returns {null | {alias: string, matched: true, host: string, port: number, username: string, privateKeyPath: string, proxy: object|undefined, proxyJump: string, warnings: string[]}}
|
|
440
|
+
* null when no Host block names the machine's host.
|
|
441
|
+
*/
|
|
442
|
+
export function resolveLiteralSshConfig(machine, opts = {}) {
|
|
443
|
+
if (!machine || machine.useSshConfig) return null
|
|
444
|
+
const name = String(machine.host || '').trim()
|
|
445
|
+
if (!name) return null
|
|
446
|
+
const text = opts.text !== undefined ? String(opts.text) : loadSshConfigText(opts.file || sshConfigPath(), opts.io || {})
|
|
447
|
+
const resolved = {
|
|
448
|
+
alias: name, matched: false, hostName: '', user: '', port: 0,
|
|
449
|
+
identityFile: '', proxyJump: '', proxyCommand: '', identitiesOnly: '',
|
|
450
|
+
}
|
|
451
|
+
for (const block of parseHostBlocks(text)) {
|
|
452
|
+
if (!hostBlockMatches(block.patterns, name)) continue
|
|
453
|
+
const positive = block.patterns.filter((p) => !p.startsWith('!'))
|
|
454
|
+
// A block whose only positive pattern is the bare `*` is a defaults block,
|
|
455
|
+
// not a block that names this host.
|
|
456
|
+
if (positive.length && positive.every((p) => p === '*')) continue
|
|
457
|
+
resolved.matched = true
|
|
458
|
+
for (const [key, value] of Object.entries(block.values)) {
|
|
459
|
+
if (key === 'hostname') { if (!resolved.hostName) resolved.hostName = String(value) }
|
|
460
|
+
else if (key === 'user') { if (!resolved.user) resolved.user = String(value) }
|
|
461
|
+
else if (key === 'port') { if (!resolved.port) resolved.port = Number(value) || 0 }
|
|
462
|
+
else if (key === 'identityfile') { if (!resolved.identityFile) resolved.identityFile = String(value) }
|
|
463
|
+
else if (key === 'proxyjump') { if (!resolved.proxyJump) resolved.proxyJump = String(value) }
|
|
464
|
+
else if (key === 'proxycommand') { if (!resolved.proxyCommand) resolved.proxyCommand = String(value) }
|
|
465
|
+
else if (key === 'identitiesonly') { if (!resolved.identitiesOnly) resolved.identitiesOnly = String(value) }
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (!resolved.matched) return null
|
|
469
|
+
return targetFromResolved(resolved, text, { defaultPort: 0 })
|
|
470
|
+
}
|
|
471
|
+
|
|
415
472
|
/**
|
|
416
473
|
* Effective connect identity for a saved machine.
|
|
417
474
|
*
|
|
@@ -452,3 +509,187 @@ export function resolveMachineSshConfig(machine, opts = {}) {
|
|
|
452
509
|
function compareText(left, right) {
|
|
453
510
|
return left < right ? -1 : left > right ? 1 : 0
|
|
454
511
|
}
|
|
512
|
+
|
|
513
|
+
// ── Writing: the managed Host block (issue #50) ─────────────────────────────
|
|
514
|
+
//
|
|
515
|
+
// The four connection fields (host/user/port/key) have ONE home: ~/.ssh/config.
|
|
516
|
+
// When the plugin saves a literal machine it upserts a Host block tagged with
|
|
517
|
+
// MANAGED_MARK, so the file and the registry can never drift — and so the
|
|
518
|
+
// plugin can tell ITS OWN block apart from one the user wrote. Two rules keep
|
|
519
|
+
// the file safe:
|
|
520
|
+
// • a Host block without the marker is never touched — a same-named user
|
|
521
|
+
// block is a CONFLICT, reported to the caller instead of overwritten;
|
|
522
|
+
// • inside a managed block, lines the plugin does not own (a user-added
|
|
523
|
+
// `ForwardAgent yes`, a comment) survive an update — only the four known
|
|
524
|
+
// parameters are rewritten.
|
|
525
|
+
|
|
526
|
+
/** Marker comment that tags a Host block as owned by dsh-remote. */
|
|
527
|
+
export const MANAGED_MARK = '# dsh-remote: managed'
|
|
528
|
+
|
|
529
|
+
/** Parameters the managed block rewrites (lowercase). */
|
|
530
|
+
const MANAGED_PARAMS = new Set(['hostname', 'user', 'port', 'identityfile'])
|
|
531
|
+
|
|
532
|
+
/** Read the RAW config text — no Include expansion (a write targets ONE file). */
|
|
533
|
+
export function readRawSshConfig(filePath = sshConfigPath()) {
|
|
534
|
+
try {
|
|
535
|
+
const text = readFileSync(filePath, 'utf8')
|
|
536
|
+
return typeof text === 'string' ? text : ''
|
|
537
|
+
} catch {
|
|
538
|
+
return ''
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Quote one value for a config line when it contains whitespace. */
|
|
543
|
+
function quoteCfg(value) {
|
|
544
|
+
const s = String(value)
|
|
545
|
+
return /[\s#]/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** The values a managed block must carry for these fields ('' = omit). */
|
|
549
|
+
function desiredValues(fields) {
|
|
550
|
+
const alias = String(fields.alias || '').trim()
|
|
551
|
+
const hostName = String(fields.hostName || '').trim()
|
|
552
|
+
const port = Number(fields.port) || 0
|
|
553
|
+
return {
|
|
554
|
+
hostname: hostName && hostName !== alias ? hostName : '',
|
|
555
|
+
user: String(fields.user || '').trim(),
|
|
556
|
+
port: port && port !== 22 ? port : '',
|
|
557
|
+
identityfile: String(fields.identityFile || '').trim(),
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Render the owned body lines of a managed block. */
|
|
562
|
+
function desiredLines(fields) {
|
|
563
|
+
const want = desiredValues(fields)
|
|
564
|
+
const out = []
|
|
565
|
+
if (want.hostname) out.push(` HostName ${quoteCfg(want.hostname)}`)
|
|
566
|
+
if (want.user) out.push(` User ${quoteCfg(want.user)}`)
|
|
567
|
+
if (want.port) out.push(` Port ${want.port}`)
|
|
568
|
+
if (want.identityfile) out.push(` IdentityFile ${quoteCfg(want.identityfile)}`)
|
|
569
|
+
return out
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Locate the MANAGED Host block for `alias` in raw config lines.
|
|
574
|
+
* The marker must be the nearest non-blank line above the Host line; a block
|
|
575
|
+
* that names the alias without the marker is the user's, not ours.
|
|
576
|
+
*/
|
|
577
|
+
function locateManagedBlock(lines, alias) {
|
|
578
|
+
const wanted = alias.toLowerCase()
|
|
579
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
580
|
+
const m = lines[i].match(/^\s*host\s+(.+?)\s*$/i)
|
|
581
|
+
if (!m) continue
|
|
582
|
+
const names = splitValue(m[1]).map((p) => p.toLowerCase())
|
|
583
|
+
if (!names.includes(wanted)) continue
|
|
584
|
+
let j = i - 1
|
|
585
|
+
while (j >= 0 && lines[j].trim() === '') j -= 1
|
|
586
|
+
if (j < 0 || lines[j].trim().toLowerCase() !== MANAGED_MARK) continue
|
|
587
|
+
let end = lines.length
|
|
588
|
+
for (let k = i + 1; k < lines.length; k += 1) {
|
|
589
|
+
if (/^\s*(host|match)\s/i.test(lines[k])) { end = k; break }
|
|
590
|
+
}
|
|
591
|
+
return { markerLine: j, hostLine: i, end }
|
|
592
|
+
}
|
|
593
|
+
return null
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Whether an UNMANAGED Host block names `alias` (exact token, case-insensitive). */
|
|
597
|
+
function hasUnmanagedConflict(lines, alias) {
|
|
598
|
+
const wanted = alias.toLowerCase()
|
|
599
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
600
|
+
const m = lines[i].match(/^\s*host\s+(.+?)\s*$/i)
|
|
601
|
+
if (!m) continue
|
|
602
|
+
const names = splitValue(m[1]).map((p) => p.toLowerCase())
|
|
603
|
+
if (!names.includes(wanted)) continue
|
|
604
|
+
let j = i - 1
|
|
605
|
+
while (j >= 0 && lines[j].trim() === '') j -= 1
|
|
606
|
+
if (j >= 0 && lines[j].trim().toLowerCase() === MANAGED_MARK) continue // ours
|
|
607
|
+
return true
|
|
608
|
+
}
|
|
609
|
+
return false
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Insert or refresh the managed Host block for a saved machine.
|
|
614
|
+
*
|
|
615
|
+
* @param {string} rawText - the RAW ~/.ssh/config text ('' when absent).
|
|
616
|
+
* @param {{alias: string, hostName?: string, user?: string, port?: number|string, identityFile?: string}} fields
|
|
617
|
+
* @returns {{status: 'appended'|'updated'|'unchanged'|'conflict'|'invalid', text: string}}
|
|
618
|
+
* 'conflict' — an unmanaged block already names the alias (text untouched);
|
|
619
|
+
* 'invalid' — the alias can never be a safe Host token (space/wildcard).
|
|
620
|
+
*/
|
|
621
|
+
export function upsertManagedHostBlock(rawText, fields) {
|
|
622
|
+
const text = String(rawText || '')
|
|
623
|
+
const alias = String((fields && fields.alias) || '').trim()
|
|
624
|
+
if (!alias || /[\s*?!,]/.test(alias)) return { status: 'invalid', text }
|
|
625
|
+
const lines = text.split('\n')
|
|
626
|
+
if (hasUnmanagedConflict(lines, alias)) return { status: 'conflict', text }
|
|
627
|
+
|
|
628
|
+
const want = desiredValues({ ...fields, alias })
|
|
629
|
+
const found = locateManagedBlock(lines, alias)
|
|
630
|
+
if (!found) {
|
|
631
|
+
const block = [MANAGED_MARK, `Host ${alias}`, ...desiredLines({ ...fields, alias })]
|
|
632
|
+
const trimmedEnd = text.replace(/\s+$/, '')
|
|
633
|
+
const prefix = trimmedEnd === '' ? '' : trimmedEnd + '\n\n'
|
|
634
|
+
return { status: 'appended', text: prefix + block.join('\n') + '\n' }
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Compare what the block HAS with what it MUST have; a rewrite that changes
|
|
638
|
+
// nothing would only cost the file's mtime.
|
|
639
|
+
const current = parseHostBlocks(lines.slice(found.hostLine, found.end).join('\n'))[0]
|
|
640
|
+
const cur = (current && current.values) || {}
|
|
641
|
+
const same = ['hostname', 'user', 'port', 'identityfile']
|
|
642
|
+
.every((k) => String(cur[k] ?? '') === String(want[k] ?? ''))
|
|
643
|
+
if (same) return { status: 'unchanged', text }
|
|
644
|
+
|
|
645
|
+
// Rewrite only the four owned parameters; user lines inside OUR block stay.
|
|
646
|
+
const body = lines.slice(found.hostLine + 1, found.end)
|
|
647
|
+
const kept = body.filter((l) => {
|
|
648
|
+
const m = l.match(/^\s*(\S+)\s/)
|
|
649
|
+
return !m || !MANAGED_PARAMS.has(m[1].toLowerCase())
|
|
650
|
+
})
|
|
651
|
+
const next = [
|
|
652
|
+
...lines.slice(0, found.hostLine + 1),
|
|
653
|
+
...desiredLines({ ...fields, alias }),
|
|
654
|
+
...kept,
|
|
655
|
+
...lines.slice(found.end),
|
|
656
|
+
]
|
|
657
|
+
return { status: 'updated', text: next.join('\n') }
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Remove the managed block for `alias` (used when a machine is RENAMED away:
|
|
662
|
+
* the old block is the plugin's own artifact of that edit). Deleting a machine
|
|
663
|
+
* deliberately does NOT call this — the Host block is the user's ssh access,
|
|
664
|
+
* not plugin state. Unmanaged blocks are never removed.
|
|
665
|
+
*
|
|
666
|
+
* @returns {{status: 'removed'|'absent', text: string}}
|
|
667
|
+
*/
|
|
668
|
+
export function removeManagedHostBlock(rawText, alias) {
|
|
669
|
+
const text = String(rawText || '')
|
|
670
|
+
const name = String(alias || '').trim()
|
|
671
|
+
if (!name) return { status: 'absent', text }
|
|
672
|
+
const lines = text.split('\n')
|
|
673
|
+
const found = locateManagedBlock(lines, name)
|
|
674
|
+
if (!found) return { status: 'absent', text }
|
|
675
|
+
let start = found.markerLine
|
|
676
|
+
while (start > 0 && lines[start - 1].trim() === '') start -= 1
|
|
677
|
+
const next = [...lines.slice(0, start), ...lines.slice(found.end)]
|
|
678
|
+
return { status: 'removed', text: next.join('\n') }
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Persist a config text, creating ~/.ssh first. Permissions follow OpenSSH's
|
|
683
|
+
* expectations on POSIX (dir 0700, file 0600) and are a no-op elsewhere.
|
|
684
|
+
*/
|
|
685
|
+
export function writeSshConfig(text, filePath = sshConfigPath(), io = {}) {
|
|
686
|
+
const mkdir = io.mkdir || ((p) => mkdirSync(p, { recursive: true }))
|
|
687
|
+
const write = io.write || ((p, data) => writeFileSync(p, data, 'utf8'))
|
|
688
|
+
const chmod = io.chmod || ((p, mode) => { try { chmodSync(p, mode) } catch { /* no modes here */ } })
|
|
689
|
+
mkdir(path.dirname(filePath))
|
|
690
|
+
write(filePath, String(text))
|
|
691
|
+
if (process.platform !== 'win32') {
|
|
692
|
+
chmod(path.dirname(filePath), 0o700)
|
|
693
|
+
chmod(filePath, 0o600)
|
|
694
|
+
}
|
|
695
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.50",
|
|
4
4
|
"description": "Remote-work assistant for DeepSeek Harness: connect SSH (password/key/agent/keyboard-interactive/proxy jump), pick a remote workspace, operate on it with 22 rw_* tools (incl. rw_edit/rw_stat/rw_mkdir/rw_remove/rw_move/rw_forward), conflict-aware mirror sync, one-click remote dsh deploy + web UI attach, port forwarding, optional side-bar remote file editing, audit log, keychain passwords.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "flymysql <flyphp@outlook.com>",
|