dsh-ssh-manage 0.3.5 → 0.3.6
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 +5 -3
- package/README.md +4 -3
- package/client.js +265 -0
- package/index.js +10 -0
- package/lib/transfer.js +202 -0
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
- **Host management** — scan the config, groups, tags, favorites, notes, latency and host inspection;
|
|
26
26
|
- **Interactive terminal** — ssh2 allocates a PTY, xterm.js renders it, with colors, arrow keys, full-screen programs such as `vim`/`top`, and window resizing;
|
|
27
|
+
- **Backup and migration** — export/import hosts, groups, tags, notes, favorites and passwords from Settings; exports are re-encrypted with a passphrase (scrypt + AES-256-GCM) and imports merge by alias;
|
|
27
28
|
- **Day-to-day operations** — batch commands, SFTP browse/mkdir/rename/delete/upload/download, connection tests;
|
|
28
29
|
- **Credentials** — one AES-256-GCM encrypted password per host, never written to `~/.ssh/config`; passwords are not returned to the browser by default, but Show password decrypts one on demand in the current page and auto-hides it after 30 seconds;
|
|
29
30
|
- **Agent tool** — `ssh_manager` exposes the same capabilities to the model.
|
|
@@ -96,16 +97,16 @@ Creating a host requires alias, hostname and password. The password is not writt
|
|
|
96
97
|
|
|
97
98
|
### From npm (recommended)
|
|
98
99
|
|
|
99
|
-
npm package: [`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage) (current version `0.3.
|
|
100
|
+
npm package: [`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage) (current version `0.3.6`).
|
|
100
101
|
|
|
101
102
|
```text
|
|
102
|
-
plugin_manager action=install_bundle target=dsh-ssh-manage@0.3.
|
|
103
|
+
plugin_manager action=install_bundle target=dsh-ssh-manage@0.3.6
|
|
103
104
|
```
|
|
104
105
|
|
|
105
106
|
Or with the DSH CLI:
|
|
106
107
|
|
|
107
108
|
```bash
|
|
108
|
-
dsh plugin add dsh-ssh-manage@0.3.
|
|
109
|
+
dsh plugin add dsh-ssh-manage@0.3.6
|
|
109
110
|
```
|
|
110
111
|
|
|
111
112
|
After installation, refresh the Web page; the sidebar shows an "SSH Hosts" entry with id `dsh-ssh-manage`.
|
|
@@ -221,6 +222,7 @@ The terminal uses vendored xterm.js 6.0.0 (MIT, see `assets/LICENSE.xterm`), @xt
|
|
|
221
222
|
- **Config editing scope**: the editor only changes `Host`, `HostName`, `User`, `Port` and plugin metadata; maintain other SSH directives in `~/.ssh/config`.
|
|
222
223
|
- **Batch commands** run one command; they are not an interactive terminal. Per-host output is capped at 1 MB and marked `truncated` when exceeded.
|
|
223
224
|
- **File size**: SFTP files are capped at 10 MB.
|
|
225
|
+
- **Export safety**: encrypted exports contain all passwords; a lost passphrase is unrecoverable. Import only merges and never deletes existing hosts or passwords.
|
|
224
226
|
- **SFTP rename/delete**: rename refuses to overwrite an existing destination by default (`sftp_rename` can pass `overwrite: true` explicitly); deleting a directory requires it to be empty, and root paths, `.`/`..` and drive roots are refused; symlinks are removed as links, not followed.
|
|
225
227
|
- **xterm assets**: the first terminal open loads about 500 KB of xterm.js basics; the search addon loads on demand, about 556 KiB total from the same origin (no CDN).
|
|
226
228
|
|
package/README.md
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
- **主机管理**:扫描配置、分组、标签、收藏、备注、连接延迟、主机巡检;
|
|
26
26
|
- **交互式终端**:ssh2 分配 PTY,xterm.js 渲染,支持彩色输出、方向键、`vim`/`top` 等全屏程序和窗口自适应;
|
|
27
|
+
- **备份迁移**:设置页可导出/导入主机、分组、标签、备注、收藏和密码;导出文件用自定义口令二次加密(scrypt + AES-256-GCM),导入按别名合并;
|
|
27
28
|
- **日常运维**:批量命令、SFTP 目录浏览 / 新建 / 重命名 / 删除 / 上传 / 下载、测试连接;
|
|
28
29
|
- **凭据管理**:每台主机保存一份 AES-256-GCM 加密密码,不写进 `~/.ssh/config`;默认不返回浏览器,在主机详情点“显示密码”时才按需解密到当前页面,30 秒后自动隐藏;
|
|
29
30
|
- **Agent 工具**:`ssh_manager` 把同一套能力开放给模型。
|
|
@@ -98,16 +99,16 @@
|
|
|
98
99
|
|
|
99
100
|
### 从 npm 安装(推荐)
|
|
100
101
|
|
|
101
|
-
npm 包:[`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage)(当前版本 `0.3.
|
|
102
|
+
npm 包:[`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage)(当前版本 `0.3.6`)。
|
|
102
103
|
|
|
103
104
|
```text
|
|
104
|
-
plugin_manager action=install_bundle target=dsh-ssh-manage@0.3.
|
|
105
|
+
plugin_manager action=install_bundle target=dsh-ssh-manage@0.3.6
|
|
105
106
|
```
|
|
106
107
|
|
|
107
108
|
或使用 DSH CLI:
|
|
108
109
|
|
|
109
110
|
```bash
|
|
110
|
-
dsh plugin add dsh-ssh-manage@0.3.
|
|
111
|
+
dsh plugin add dsh-ssh-manage@0.3.6
|
|
111
112
|
```
|
|
112
113
|
|
|
113
114
|
安装后刷新 Web 页面,左侧栏会出现「SSH 主机」入口,入口 id 为 `dsh-ssh-manage`。
|
package/client.js
CHANGED
|
@@ -261,6 +261,32 @@ window.__ModuleLoader__.load({
|
|
|
261
261
|
'settings.stateDir': ['状态目录', 'State directory'],
|
|
262
262
|
'settings.vault': ['凭据仓库', 'Credential vault'],
|
|
263
263
|
'settings.vaultDesc': ['密码使用 AES-256-GCM 加密保存在本机状态目录,插件启动时自动解锁,不需要主密码。', 'Passwords are AES-256-GCM encrypted in the local state directory and unlocked automatically; no master password.'],
|
|
264
|
+
'settings.transfer': ['备份与迁移', 'Backup and migration'],
|
|
265
|
+
'settings.transferBadge': ['加密导出', 'Encrypted export'],
|
|
266
|
+
'settings.transferDesc': ['导出主机、分组、标签、备注、收藏和密码,密码用你设置的口令二次加密;导入时按别名合并,不会删除现有主机。', 'Export hosts, groups, tags, notes, favorites and passwords. Passwords are encrypted again with a passphrase you choose; import merges by alias and never deletes existing hosts.'],
|
|
267
|
+
'settings.export': ['导出', 'Export'],
|
|
268
|
+
'settings.import': ['导入', 'Import'],
|
|
269
|
+
'transfer.exportTitle': ['导出主机数据', 'Export host data'],
|
|
270
|
+
'transfer.importTitle': ['导入主机数据', 'Import host data'],
|
|
271
|
+
'transfer.exportDesc': ['将导出 {count} 台主机及其分组、标签、备注、收藏和全部密码。密码部分会用口令再次加密。', 'Exports {count} host(s) with groups, tags, notes, favorites and all passwords. Password data is encrypted again with a passphrase.'],
|
|
272
|
+
'transfer.importDesc': ['选择由 dsh-ssh-manage 导出的 JSON 文件,输入导出时设置的口令。按主机别名合并:相同别名更新,新别名创建,不删除现有主机。', 'Choose a JSON file exported by dsh-ssh-manage and enter its passphrase. Hosts merge by alias: existing aliases update, new ones are created, nothing is deleted.'],
|
|
273
|
+
'transfer.passphrase': ['导出/导入口令(至少 8 位)', 'Export/import passphrase (at least 8 characters)'],
|
|
274
|
+
'transfer.confirmPassphrase': ['再次输入口令', 'Repeat the passphrase'],
|
|
275
|
+
'transfer.passphraseHint': ['口令只用于加密导出文件,不会保存。忘记口令将无法恢复导出的密码。', 'The passphrase only encrypts the export file and is not stored. A lost passphrase cannot be recovered.'],
|
|
276
|
+
'transfer.chooseFile': ['选择导出 JSON 文件', 'Choose export JSON file'],
|
|
277
|
+
'transfer.fileReady': ['已选择:{count} 台主机', 'Selected: {count} host(s)'],
|
|
278
|
+
'transfer.fileTooLarge': ['文件超过 8 MB,拒绝读取。', 'File is larger than 8 MB and was rejected.'],
|
|
279
|
+
'transfer.invalidFile': ['不是有效的 dsh-ssh-manage 导出文件。', 'Not a valid dsh-ssh-manage export file.'],
|
|
280
|
+
'transfer.chooseFileFirst': ['请先选择导出文件。', 'Choose an export file first.'],
|
|
281
|
+
'transfer.passphraseShort': ['口令至少需要 8 位。', 'Passphrase must be at least 8 characters.'],
|
|
282
|
+
'transfer.passphraseMismatch': ['两次输入的口令不一致。', 'The passphrases do not match.'],
|
|
283
|
+
'transfer.exportWarning': ['导出文件包含加密后的密码,请妥善保管文件与口令,不要上传到公共位置。', 'The export contains encrypted passwords; keep both the file and passphrase safe and do not upload them publicly.'],
|
|
284
|
+
'transfer.importMergeNote': ['导入采用合并模式,不会删除现有主机或密码。', 'Import uses merge mode and never deletes existing hosts or passwords.'],
|
|
285
|
+
'transfer.doExport': ['加密导出', 'Export encrypted'],
|
|
286
|
+
'transfer.doImport': ['导入并合并', 'Import and merge'],
|
|
287
|
+
'transfer.exported': ['已导出 {count} 台主机', 'Exported {count} host(s)'],
|
|
288
|
+
'transfer.imported': ['导入完成:新建 {created},更新 {updated},恢复密码 {passwords}', 'Import complete: {created} created, {updated} updated, {passwords} password(s) restored'],
|
|
289
|
+
'transfer.importWarnings': ['{count} 条记录未能导入,请查看提示。', '{count} record(s) could not be imported.'],
|
|
264
290
|
'settings.vaultCount': ['{count} 台主机已保存密码', '{count} hosts have a stored password'],
|
|
265
291
|
'settings.vaultNone': ['还没有保存任何密码', 'No passwords stored yet'],
|
|
266
292
|
'settings.appearance': ['外观', 'Appearance'],
|
|
@@ -479,6 +505,16 @@ window.__ModuleLoader__.load({
|
|
|
479
505
|
setTimeout(() => URL.revokeObjectURL(url), 1500)
|
|
480
506
|
}
|
|
481
507
|
|
|
508
|
+
function downloadTextFile(name, text, type = 'application/json') {
|
|
509
|
+
const blob = new Blob([text], { type })
|
|
510
|
+
const url = URL.createObjectURL(blob)
|
|
511
|
+
const anchor = document.createElement('a')
|
|
512
|
+
anchor.href = url
|
|
513
|
+
anchor.download = name || 'dsh-ssh-manage-export.json'
|
|
514
|
+
anchor.click()
|
|
515
|
+
setTimeout(() => URL.revokeObjectURL(url), 1500)
|
|
516
|
+
}
|
|
517
|
+
|
|
482
518
|
function readFileBase64(file) {
|
|
483
519
|
return new Promise((resolve, reject) => {
|
|
484
520
|
const reader = new FileReader()
|
|
@@ -5287,8 +5323,201 @@ window.__ModuleLoader__.load({
|
|
|
5287
5323
|
)
|
|
5288
5324
|
}
|
|
5289
5325
|
|
|
5326
|
+
function TransferDialog({ model, mode, onClose }) {
|
|
5327
|
+
const { t, notify, load, hosts } = model
|
|
5328
|
+
const [passphrase, setPassphrase] = React.useState('')
|
|
5329
|
+
const [confirmation, setConfirmation] = React.useState('')
|
|
5330
|
+
const [file, setFile] = React.useState(null)
|
|
5331
|
+
const [reading, setReading] = React.useState(false)
|
|
5332
|
+
const [busy, setBusy] = React.useState(false)
|
|
5333
|
+
const [error, setError] = React.useState('')
|
|
5334
|
+
const busyRef = React.useRef(false)
|
|
5335
|
+
const fileRef = React.useRef(null)
|
|
5336
|
+
|
|
5337
|
+
React.useEffect(() => {
|
|
5338
|
+
const onKey = (event) => {
|
|
5339
|
+
if (event.key !== 'Escape' || busyRef.current) return
|
|
5340
|
+
event.preventDefault()
|
|
5341
|
+
event.stopPropagation()
|
|
5342
|
+
onClose()
|
|
5343
|
+
}
|
|
5344
|
+
document.addEventListener('keydown', onKey, true)
|
|
5345
|
+
return () => document.removeEventListener('keydown', onKey, true)
|
|
5346
|
+
}, [onClose])
|
|
5347
|
+
|
|
5348
|
+
async function pickFile(event) {
|
|
5349
|
+
const picked = event.target.files?.[0]
|
|
5350
|
+
event.target.value = ''
|
|
5351
|
+
if (!picked) return
|
|
5352
|
+
if (picked.size > 8 * 1024 * 1024) {
|
|
5353
|
+
setError(t('transfer.fileTooLarge'))
|
|
5354
|
+
setFile(null)
|
|
5355
|
+
return
|
|
5356
|
+
}
|
|
5357
|
+
setReading(true)
|
|
5358
|
+
setError('')
|
|
5359
|
+
try {
|
|
5360
|
+
const text = typeof picked.text === 'function'
|
|
5361
|
+
? await picked.text()
|
|
5362
|
+
: await new Promise((resolve, reject) => {
|
|
5363
|
+
const reader = new FileReader()
|
|
5364
|
+
reader.onload = () => resolve(String(reader.result ?? ''))
|
|
5365
|
+
reader.onerror = () => reject(reader.error ?? new Error('Cannot read file'))
|
|
5366
|
+
reader.readAsText(picked)
|
|
5367
|
+
})
|
|
5368
|
+
const parsed = JSON.parse(text)
|
|
5369
|
+
if (!parsed || parsed.format !== 'dsh-ssh-manage-export' || !Array.isArray(parsed.hosts)) {
|
|
5370
|
+
throw new Error(t('transfer.invalidFile'))
|
|
5371
|
+
}
|
|
5372
|
+
setFile({ name: picked.name, data: parsed })
|
|
5373
|
+
} catch (failure) {
|
|
5374
|
+
setFile(null)
|
|
5375
|
+
setError(errorMessage(failure))
|
|
5376
|
+
} finally {
|
|
5377
|
+
setReading(false)
|
|
5378
|
+
}
|
|
5379
|
+
}
|
|
5380
|
+
|
|
5381
|
+
async function submit() {
|
|
5382
|
+
if (busyRef.current) return
|
|
5383
|
+
if (passphrase.length < 8) {
|
|
5384
|
+
setError(t('transfer.passphraseShort'))
|
|
5385
|
+
return
|
|
5386
|
+
}
|
|
5387
|
+
if (mode === 'export' && passphrase !== confirmation) {
|
|
5388
|
+
setError(t('transfer.passphraseMismatch'))
|
|
5389
|
+
return
|
|
5390
|
+
}
|
|
5391
|
+
if (mode === 'import' && !file) {
|
|
5392
|
+
setError(t('transfer.chooseFileFirst'))
|
|
5393
|
+
return
|
|
5394
|
+
}
|
|
5395
|
+
busyRef.current = true
|
|
5396
|
+
setBusy(true)
|
|
5397
|
+
setError('')
|
|
5398
|
+
try {
|
|
5399
|
+
if (mode === 'export') {
|
|
5400
|
+
const value = await callAction('data/export', { passphrase })
|
|
5401
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, '').slice(0, 15)
|
|
5402
|
+
downloadTextFile(`dsh-ssh-manage-export-${stamp}.json`, JSON.stringify(value, null, 2))
|
|
5403
|
+
notify(t('transfer.exported', { count: value?.hosts?.length ?? hosts.length }), 'success')
|
|
5404
|
+
} else {
|
|
5405
|
+
const value = await callAction('data/import', { export: file.data, passphrase })
|
|
5406
|
+
const summary = t('transfer.imported', {
|
|
5407
|
+
created: value?.created ?? 0,
|
|
5408
|
+
updated: value?.updated ?? 0,
|
|
5409
|
+
passwords: value?.passwords ?? 0,
|
|
5410
|
+
})
|
|
5411
|
+
if (Array.isArray(value?.errors) && value.errors.length > 0) {
|
|
5412
|
+
notify(`${summary};${t('transfer.importWarnings', { count: value.errors.length })}`, 'error')
|
|
5413
|
+
} else {
|
|
5414
|
+
notify(summary, 'success')
|
|
5415
|
+
}
|
|
5416
|
+
await load({ quiet: true })
|
|
5417
|
+
}
|
|
5418
|
+
onClose()
|
|
5419
|
+
} catch (failure) {
|
|
5420
|
+
setError(errorMessage(failure))
|
|
5421
|
+
} finally {
|
|
5422
|
+
busyRef.current = false
|
|
5423
|
+
setBusy(false)
|
|
5424
|
+
}
|
|
5425
|
+
}
|
|
5426
|
+
|
|
5427
|
+
return h(
|
|
5428
|
+
'div',
|
|
5429
|
+
{
|
|
5430
|
+
className: 'ssh-fileDialogScrim',
|
|
5431
|
+
onMouseDown: () => {
|
|
5432
|
+
if (!busyRef.current) onClose()
|
|
5433
|
+
},
|
|
5434
|
+
},
|
|
5435
|
+
h(
|
|
5436
|
+
'div',
|
|
5437
|
+
{
|
|
5438
|
+
className: 'ssh-fileDialog ssh-transferDialog',
|
|
5439
|
+
role: 'dialog',
|
|
5440
|
+
'aria-modal': 'true',
|
|
5441
|
+
'aria-label': mode === 'export' ? t('transfer.exportTitle') : t('transfer.importTitle'),
|
|
5442
|
+
onMouseDown: (event) => event.stopPropagation(),
|
|
5443
|
+
},
|
|
5444
|
+
h('div', { className: 'ssh-fileDialogTitle' }, mode === 'export' ? t('transfer.exportTitle') : t('transfer.importTitle')),
|
|
5445
|
+
h(
|
|
5446
|
+
'div',
|
|
5447
|
+
{ className: 'ssh-fileDialogBody' },
|
|
5448
|
+
mode === 'export'
|
|
5449
|
+
? t('transfer.exportDesc', { count: hosts.length })
|
|
5450
|
+
: t('transfer.importDesc'),
|
|
5451
|
+
),
|
|
5452
|
+
mode === 'import'
|
|
5453
|
+
? h(
|
|
5454
|
+
'div',
|
|
5455
|
+
{ className: 'ssh-transferFile' },
|
|
5456
|
+
h('input', {
|
|
5457
|
+
ref: fileRef,
|
|
5458
|
+
type: 'file',
|
|
5459
|
+
accept: '.json,application/json',
|
|
5460
|
+
className: 'ssh-srOnly',
|
|
5461
|
+
onChange: (event) => void pickFile(event),
|
|
5462
|
+
}),
|
|
5463
|
+
h(
|
|
5464
|
+
Button,
|
|
5465
|
+
{ size: 'sm', variant: 'ghost', onClick: () => fileRef.current?.click(), disabled: reading || busy },
|
|
5466
|
+
reading ? h(Spinner, { size: 12 }) : h(IconUpload, { size: 13 }),
|
|
5467
|
+
file ? file.name : t('transfer.chooseFile'),
|
|
5468
|
+
),
|
|
5469
|
+
file ? h(Badge, { tone: 'ok' }, t('transfer.fileReady', { count: file.data.hosts.length })) : null,
|
|
5470
|
+
)
|
|
5471
|
+
: null,
|
|
5472
|
+
h(TextInput, {
|
|
5473
|
+
type: 'password',
|
|
5474
|
+
value: passphrase,
|
|
5475
|
+
onChange: setPassphrase,
|
|
5476
|
+
placeholder: t('transfer.passphrase'),
|
|
5477
|
+
autoFocus: true,
|
|
5478
|
+
disabled: busy,
|
|
5479
|
+
onKeyDown: (event) => {
|
|
5480
|
+
if (event.key === 'Enter') void submit()
|
|
5481
|
+
},
|
|
5482
|
+
}),
|
|
5483
|
+
mode === 'export'
|
|
5484
|
+
? h(TextInput, {
|
|
5485
|
+
type: 'password',
|
|
5486
|
+
value: confirmation,
|
|
5487
|
+
onChange: setConfirmation,
|
|
5488
|
+
placeholder: t('transfer.confirmPassphrase'),
|
|
5489
|
+
disabled: busy,
|
|
5490
|
+
onKeyDown: (event) => {
|
|
5491
|
+
if (event.key === 'Enter') void submit()
|
|
5492
|
+
},
|
|
5493
|
+
})
|
|
5494
|
+
: null,
|
|
5495
|
+
h('div', { className: 'ssh-sectionHint' }, t('transfer.passphraseHint')),
|
|
5496
|
+
error ? h('div', { className: 'ssh-formError' }, h(IconAlert, { size: 13 }), h('span', null, error)) : null,
|
|
5497
|
+
h(
|
|
5498
|
+
'div',
|
|
5499
|
+
{ className: 'ssh-sectionHint ssh-revealWarning' },
|
|
5500
|
+
h(IconAlert, { size: 12 }),
|
|
5501
|
+
h('span', null, mode === 'export' ? t('transfer.exportWarning') : t('transfer.importMergeNote')),
|
|
5502
|
+
),
|
|
5503
|
+
h(
|
|
5504
|
+
'div',
|
|
5505
|
+
{ className: 'ssh-fileDialogActions' },
|
|
5506
|
+
h(Button, { variant: 'ghost', size: 'sm', onClick: onClose, disabled: busy }, t('common.cancel')),
|
|
5507
|
+
h(
|
|
5508
|
+
Button,
|
|
5509
|
+
{ variant: 'primary', size: 'sm', onClick: () => void submit(), disabled: busy || reading },
|
|
5510
|
+
busy ? h(Spinner, { size: 12 }) : null,
|
|
5511
|
+
mode === 'export' ? t('transfer.doExport') : t('transfer.doImport'),
|
|
5512
|
+
),
|
|
5513
|
+
),
|
|
5514
|
+
),
|
|
5515
|
+
)
|
|
5516
|
+
}
|
|
5517
|
+
|
|
5290
5518
|
function SettingsTab({ model }) {
|
|
5291
5519
|
const { t, snapshot, hosts, columns, setColumns, copyValue, load, refreshing } = model
|
|
5520
|
+
const [transferMode, setTransferMode] = React.useState(null)
|
|
5292
5521
|
const passwordCount = hosts.filter((host) => host.passwordConfigured).length
|
|
5293
5522
|
const exists = snapshot?.exists === true
|
|
5294
5523
|
return h(
|
|
@@ -5359,6 +5588,33 @@ window.__ModuleLoader__.load({
|
|
|
5359
5588
|
h('span', null, `${passwordCount} / ${hosts.length}`),
|
|
5360
5589
|
),
|
|
5361
5590
|
),
|
|
5591
|
+
h(
|
|
5592
|
+
'section',
|
|
5593
|
+
{ className: 'ssh-card' },
|
|
5594
|
+
h(
|
|
5595
|
+
'div',
|
|
5596
|
+
{ className: 'ssh-cardHead' },
|
|
5597
|
+
h('h3', null, t('settings.transfer')),
|
|
5598
|
+
h(Badge, { tone: 'muted' }, t('settings.transferBadge')),
|
|
5599
|
+
),
|
|
5600
|
+
h('p', { className: 'ssh-cardDesc' }, t('settings.transferDesc')),
|
|
5601
|
+
h(
|
|
5602
|
+
'div',
|
|
5603
|
+
{ className: 'ssh-cardActions' },
|
|
5604
|
+
h(
|
|
5605
|
+
Button,
|
|
5606
|
+
{ size: 'sm', variant: 'ghost', onClick: () => setTransferMode('export') },
|
|
5607
|
+
h(IconDownload, { size: 13 }),
|
|
5608
|
+
t('settings.export'),
|
|
5609
|
+
),
|
|
5610
|
+
h(
|
|
5611
|
+
Button,
|
|
5612
|
+
{ size: 'sm', variant: 'ghost', onClick: () => setTransferMode('import') },
|
|
5613
|
+
h(IconUpload, { size: 13 }),
|
|
5614
|
+
t('settings.import'),
|
|
5615
|
+
),
|
|
5616
|
+
),
|
|
5617
|
+
),
|
|
5362
5618
|
h(
|
|
5363
5619
|
'section',
|
|
5364
5620
|
{ className: 'ssh-card' },
|
|
@@ -5408,6 +5664,13 @@ window.__ModuleLoader__.load({
|
|
|
5408
5664
|
h('span', null, t('settings.aboutTag')),
|
|
5409
5665
|
),
|
|
5410
5666
|
),
|
|
5667
|
+
transferMode
|
|
5668
|
+
? h(TransferDialog, {
|
|
5669
|
+
model,
|
|
5670
|
+
mode: transferMode,
|
|
5671
|
+
onClose: () => setTransferMode(null),
|
|
5672
|
+
})
|
|
5673
|
+
: null,
|
|
5411
5674
|
),
|
|
5412
5675
|
)
|
|
5413
5676
|
}
|
|
@@ -5705,6 +5968,8 @@ body.ssh-col-resizing,body.ssh-col-resizing *{cursor:col-resize!important;user-s
|
|
|
5705
5968
|
.ssh-fileRow{display:grid;grid-template-columns:22px minmax(0,1fr) 90px auto;align-items:center;gap:8px;padding:0 10px;height:34px;border-bottom:.5px solid var(--ssh-border);font-size:12px}
|
|
5706
5969
|
.ssh-fileActions{display:flex;align-items:center;gap:2px}
|
|
5707
5970
|
.ssh-fileDialogScrim{position:absolute;inset:0;z-index:20;display:flex;align-items:center;justify-content:center;padding:20px;background:var(--dsw-alias-bg-mask-1,color-mix(in srgb,var(--ssh-text) 18%,transparent))}
|
|
5971
|
+
.ssh-transferDialog{width:min(460px,100%)}
|
|
5972
|
+
.ssh-transferFile{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
|
5708
5973
|
.ssh-fileDialog{width:min(360px,100%);padding:14px;border:.5px solid var(--ssh-border-strong);border-radius:12px;background:var(--ssh-layer-1);box-shadow:var(--ssh-shadow);display:flex;flex-direction:column;gap:10px}
|
|
5709
5974
|
.ssh-fileDialogTitle{font-size:13px;font-weight:600;color:var(--ssh-text)}
|
|
5710
5975
|
.ssh-fileDialogBody{font-size:12px;line-height:1.5;color:var(--ssh-text-2)}
|
package/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { resolveSshPaths, listSshHosts, saveSshHost, deleteSshHost } from './lib
|
|
|
6
6
|
import { deleteMetadata, loadMetadata, updateMetadata } from './lib/metadata.js'
|
|
7
7
|
import { batchExec, deleteRemotePath, downloadFile, execCommand, inspectHost, listRemoteFiles, makeRemoteDirectory, openPty, renameRemotePath, testConnection, uploadFile } from './lib/ssh-client.js'
|
|
8
8
|
import { storagePaths } from './lib/storage.js'
|
|
9
|
+
import { createExport, importExport } from './lib/transfer.js'
|
|
9
10
|
import { getPassword, listPasswordAliases, removePassword, setPassword } from './lib/vault.js'
|
|
10
11
|
|
|
11
12
|
const STATE_ROUTE = '/dsh-ssh-manage/state'
|
|
@@ -173,6 +174,15 @@ export async function runAction(paths, action, params = {}) {
|
|
|
173
174
|
if (!password) throw new Error('No password is stored for this host')
|
|
174
175
|
return { password }
|
|
175
176
|
}
|
|
177
|
+
case 'data/export': {
|
|
178
|
+
if (typeof params.passphrase !== 'string' || params.passphrase.length === 0) throw new Error('Passphrase is required')
|
|
179
|
+
return await createExport(paths, params.passphrase)
|
|
180
|
+
}
|
|
181
|
+
case 'data/import': {
|
|
182
|
+
if (typeof params.passphrase !== 'string' || params.passphrase.length === 0) throw new Error('Passphrase is required')
|
|
183
|
+
const result = await importExport(paths, params.export, params.passphrase)
|
|
184
|
+
return { ...result, state: await state(paths) }
|
|
185
|
+
}
|
|
176
186
|
case 'host/metadata': {
|
|
177
187
|
const alias = requireString(params, 'host')
|
|
178
188
|
await findHost(paths, alias)
|
package/lib/transfer.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto'
|
|
2
|
+
import { listSshHosts, saveSshHost } from './ssh-config.js'
|
|
3
|
+
import { loadMetadata, updateMetadata } from './metadata.js'
|
|
4
|
+
import { loadVault, saveVault } from './vault.js'
|
|
5
|
+
|
|
6
|
+
const FORMAT = 'dsh-ssh-manage-export'
|
|
7
|
+
const VERSION = 1
|
|
8
|
+
const ALGORITHM = 'aes-256-gcm'
|
|
9
|
+
const KDF = 'scrypt'
|
|
10
|
+
const KDF_N = 16384
|
|
11
|
+
const KDF_R = 8
|
|
12
|
+
const KDF_P = 1
|
|
13
|
+
const KEY_BYTES = 32
|
|
14
|
+
const SALT_BYTES = 16
|
|
15
|
+
const IV_BYTES = 12
|
|
16
|
+
const MAX_HOSTS = 500
|
|
17
|
+
const MAX_METADATA = 1000
|
|
18
|
+
const MAX_PASSWORDS = 1000
|
|
19
|
+
const ALIAS_RE = /^[A-Za-z0-9][A-Za-z0-9_.@-]{0,63}$/
|
|
20
|
+
|
|
21
|
+
function assertPassphrase(passphrase) {
|
|
22
|
+
if (typeof passphrase !== 'string' || passphrase.length < 8) {
|
|
23
|
+
throw new Error('Passphrase must be at least 8 characters')
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function deriveKey(passphrase, salt) {
|
|
28
|
+
return scryptSync(Buffer.from(passphrase, 'utf8'), salt, KEY_BYTES, {
|
|
29
|
+
N: KDF_N,
|
|
30
|
+
r: KDF_R,
|
|
31
|
+
p: KDF_P,
|
|
32
|
+
maxmem: 64 * 1024 * 1024,
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function encryptSecrets(passwords, passphrase) {
|
|
37
|
+
const salt = randomBytes(SALT_BYTES)
|
|
38
|
+
const iv = randomBytes(IV_BYTES)
|
|
39
|
+
const key = deriveKey(passphrase, salt)
|
|
40
|
+
const cipher = createCipheriv(ALGORITHM, key, iv)
|
|
41
|
+
const ciphertext = Buffer.concat([
|
|
42
|
+
cipher.update(JSON.stringify({ passwords }), 'utf8'),
|
|
43
|
+
cipher.final(),
|
|
44
|
+
])
|
|
45
|
+
return {
|
|
46
|
+
version: VERSION,
|
|
47
|
+
algorithm: ALGORITHM,
|
|
48
|
+
kdf: KDF,
|
|
49
|
+
kdfN: KDF_N,
|
|
50
|
+
kdfR: KDF_R,
|
|
51
|
+
kdfP: KDF_P,
|
|
52
|
+
salt: salt.toString('base64'),
|
|
53
|
+
iv: iv.toString('base64'),
|
|
54
|
+
tag: cipher.getAuthTag().toString('base64'),
|
|
55
|
+
ciphertext: ciphertext.toString('base64'),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function decryptSecrets(payload, passphrase) {
|
|
60
|
+
if (!payload || typeof payload !== 'object' || payload.algorithm !== ALGORITHM || payload.kdf !== KDF) {
|
|
61
|
+
throw new Error('Unsupported export secrets format')
|
|
62
|
+
}
|
|
63
|
+
const n = Number(payload.kdfN)
|
|
64
|
+
const r = Number(payload.kdfR)
|
|
65
|
+
const p = Number(payload.kdfP)
|
|
66
|
+
if (!Number.isInteger(n) || !Number.isInteger(r) || !Number.isInteger(p) || n < 1024 || n > 65536 || r < 1 || r > 16 || p < 1 || p > 8) {
|
|
67
|
+
throw new Error('Unsupported export KDF parameters')
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const salt = Buffer.from(String(payload.salt ?? ''), 'base64')
|
|
71
|
+
const iv = Buffer.from(String(payload.iv ?? ''), 'base64')
|
|
72
|
+
const tag = Buffer.from(String(payload.tag ?? ''), 'base64')
|
|
73
|
+
if (salt.length !== SALT_BYTES || iv.length !== IV_BYTES || tag.length === 0) throw new Error('bad envelope')
|
|
74
|
+
const key = scryptSync(Buffer.from(passphrase, 'utf8'), salt, KEY_BYTES, {
|
|
75
|
+
N: n,
|
|
76
|
+
r,
|
|
77
|
+
p,
|
|
78
|
+
maxmem: 64 * 1024 * 1024,
|
|
79
|
+
})
|
|
80
|
+
const decipher = createDecipheriv(ALGORITHM, key, iv)
|
|
81
|
+
decipher.setAuthTag(tag)
|
|
82
|
+
const plaintext = Buffer.concat([
|
|
83
|
+
decipher.update(Buffer.from(String(payload.ciphertext ?? ''), 'base64')),
|
|
84
|
+
decipher.final(),
|
|
85
|
+
]).toString('utf8')
|
|
86
|
+
const parsed = JSON.parse(plaintext)
|
|
87
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.passwords || typeof parsed.passwords !== 'object' || Array.isArray(parsed.passwords)) {
|
|
88
|
+
throw new Error('bad payload')
|
|
89
|
+
}
|
|
90
|
+
return parsed.passwords
|
|
91
|
+
} catch {
|
|
92
|
+
throw new Error('Cannot decrypt export: wrong passphrase or corrupted file')
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function hostRecord(host) {
|
|
97
|
+
return {
|
|
98
|
+
host: String(host?.host ?? ''),
|
|
99
|
+
aliases: Array.isArray(host?.aliases) ? host.aliases.map(String) : [],
|
|
100
|
+
hostname: String(host?.hostname ?? ''),
|
|
101
|
+
user: String(host?.user ?? ''),
|
|
102
|
+
port: String(host?.port ?? ''),
|
|
103
|
+
identityFile: String(host?.identityFile ?? ''),
|
|
104
|
+
proxyJump: String(host?.proxyJump ?? ''),
|
|
105
|
+
forwardAgent: host?.forwardAgent === true,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function createExport(paths, passphrase) {
|
|
110
|
+
assertPassphrase(passphrase)
|
|
111
|
+
const { hosts } = await listSshHosts(paths)
|
|
112
|
+
const metadata = await loadMetadata(paths)
|
|
113
|
+
const vault = await loadVault(paths)
|
|
114
|
+
const passwords = {}
|
|
115
|
+
for (const [alias, password] of Object.entries(vault.records)) {
|
|
116
|
+
if (ALIAS_RE.test(alias) && typeof password === 'string' && password.length > 0) passwords[alias] = password
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
format: FORMAT,
|
|
120
|
+
version: VERSION,
|
|
121
|
+
exportedAt: new Date().toISOString(),
|
|
122
|
+
hosts: hosts.map(hostRecord),
|
|
123
|
+
metadata: metadata.entries,
|
|
124
|
+
secrets: encryptSecrets(passwords, passphrase),
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function validateEnvelope(exported) {
|
|
129
|
+
if (!exported || typeof exported !== 'object' || Array.isArray(exported)) throw new Error('Invalid export file')
|
|
130
|
+
if (exported.format !== FORMAT) throw new Error('Not a dsh-ssh-manage export file')
|
|
131
|
+
if (exported.version !== VERSION) throw new Error(`Unsupported export version: ${String(exported.version)}`)
|
|
132
|
+
if (!Array.isArray(exported.hosts) || exported.hosts.length > MAX_HOSTS) throw new Error('Invalid or too many hosts in export')
|
|
133
|
+
if (!exported.metadata || typeof exported.metadata !== 'object' || Array.isArray(exported.metadata) || Object.keys(exported.metadata).length > MAX_METADATA) {
|
|
134
|
+
throw new Error('Invalid metadata in export')
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
hosts: exported.hosts,
|
|
138
|
+
metadata: exported.metadata,
|
|
139
|
+
secrets: exported.secrets,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function readableError(error) {
|
|
144
|
+
return error instanceof Error ? error.message : String(error)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Merge an export into the local config/metadata/vault. Existing hosts with the
|
|
149
|
+
* same alias are updated in place; nothing is deleted. Per-host failures are
|
|
150
|
+
* returned so a single malformed entry cannot abort the whole restore.
|
|
151
|
+
*/
|
|
152
|
+
export async function importExport(paths, exported, passphrase) {
|
|
153
|
+
assertPassphrase(passphrase)
|
|
154
|
+
const envelope = validateEnvelope(exported)
|
|
155
|
+
if (!envelope.secrets) throw new Error('This export does not contain encrypted passwords')
|
|
156
|
+
const passwords = decryptSecrets(envelope.secrets, passphrase)
|
|
157
|
+
if (Object.keys(passwords).length > MAX_PASSWORDS) throw new Error('Too many passwords in export')
|
|
158
|
+
|
|
159
|
+
const before = await listSshHosts(paths)
|
|
160
|
+
const existing = new Set(before.hosts.map((host) => host.host))
|
|
161
|
+
let created = 0
|
|
162
|
+
let updated = 0
|
|
163
|
+
const errors = []
|
|
164
|
+
|
|
165
|
+
for (const raw of envelope.hosts) {
|
|
166
|
+
try {
|
|
167
|
+
const host = hostRecord(raw)
|
|
168
|
+
if (!ALIAS_RE.test(host.host)) throw new Error('Invalid SSH host alias')
|
|
169
|
+
const exists = existing.has(host.host)
|
|
170
|
+
await saveSshHost({ paths, host, originalHost: exists ? host.host : null })
|
|
171
|
+
if (exists) updated += 1
|
|
172
|
+
else created += 1
|
|
173
|
+
} catch (error) {
|
|
174
|
+
errors.push(`${String(raw?.host ?? '?')}: ${readableError(error)}`)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let metadataCount = 0
|
|
179
|
+
for (const [alias, data] of Object.entries(envelope.metadata)) {
|
|
180
|
+
if (!ALIAS_RE.test(alias)) continue
|
|
181
|
+
try {
|
|
182
|
+
await updateMetadata(paths, alias, data)
|
|
183
|
+
metadataCount += 1
|
|
184
|
+
} catch (error) {
|
|
185
|
+
errors.push(`metadata ${alias}: ${readableError(error)}`)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let passwordCount = 0
|
|
190
|
+
const currentVault = await loadVault(paths)
|
|
191
|
+
const merged = { ...currentVault.records }
|
|
192
|
+
for (const [alias, password] of Object.entries(passwords)) {
|
|
193
|
+
if (!ALIAS_RE.test(alias) || typeof password !== 'string' || password.length === 0) continue
|
|
194
|
+
merged[alias] = password
|
|
195
|
+
passwordCount += 1
|
|
196
|
+
}
|
|
197
|
+
if (passwordCount > 0) await saveVault(paths, merged)
|
|
198
|
+
|
|
199
|
+
return { created, updated, metadata: metadataCount, passwords: passwordCount, errors }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export const __test = { encryptSecrets, decryptSecrets, hostRecord }
|