dsh-ssh-manage 0.2.0 → 0.2.2
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 +4 -4
- package/README.md +4 -4
- package/client.js +205 -28
- package/index.js +70 -15
- package/lib/metadata.js +28 -11
- package/lib/ssh-config.js +15 -2
- package/lib/storage.js +58 -11
- package/lib/vault.js +30 -12
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -96,16 +96,16 @@ Creating a host requires alias, hostname and password. The password is not writt
|
|
|
96
96
|
|
|
97
97
|
### From npm (recommended)
|
|
98
98
|
|
|
99
|
-
npm package: [`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage) (current version `0.2.
|
|
99
|
+
npm package: [`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage) (current version `0.2.2`).
|
|
100
100
|
|
|
101
101
|
```text
|
|
102
|
-
plugin_manager action=install_bundle target=dsh-ssh-manage@0.2.
|
|
102
|
+
plugin_manager action=install_bundle target=dsh-ssh-manage@0.2.2
|
|
103
103
|
```
|
|
104
104
|
|
|
105
105
|
Or with the DSH CLI:
|
|
106
106
|
|
|
107
107
|
```bash
|
|
108
|
-
dsh plugin add dsh-ssh-manage@0.2.
|
|
108
|
+
dsh plugin add dsh-ssh-manage@0.2.2
|
|
109
109
|
```
|
|
110
110
|
|
|
111
111
|
After installation, refresh the Web page; the sidebar shows an "SSH Hosts" entry with id `dsh-ssh-manage`.
|
|
@@ -137,7 +137,7 @@ and make sure the package is resolvable by the profile (for example `link:` to t
|
|
|
137
137
|
|
|
138
138
|
| | Requirement |
|
|
139
139
|
| --- | --- |
|
|
140
|
-
| **DSH** | **`>=0.2.
|
|
140
|
+
| **DSH** | **`>=0.2.2-rc.3 <0.3.0`** (validated on DSH Desktop `0.2.2-rc.3`) |
|
|
141
141
|
| **Node.js** | `>=20` (follows the DSH runtime) |
|
|
142
142
|
| **Platform** | Validated on macOS; the test suite only uses Node built-ins and runs cross-platform |
|
|
143
143
|
| **Network** | Reachable SSH port (22 by default, or the configured port) |
|
package/README.md
CHANGED
|
@@ -98,16 +98,16 @@
|
|
|
98
98
|
|
|
99
99
|
### 从 npm 安装(推荐)
|
|
100
100
|
|
|
101
|
-
npm 包:[`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage)(当前版本 `0.2.
|
|
101
|
+
npm 包:[`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage)(当前版本 `0.2.2`)。
|
|
102
102
|
|
|
103
103
|
```text
|
|
104
|
-
plugin_manager action=install_bundle target=dsh-ssh-manage@0.2.
|
|
104
|
+
plugin_manager action=install_bundle target=dsh-ssh-manage@0.2.2
|
|
105
105
|
```
|
|
106
106
|
|
|
107
107
|
或使用 DSH CLI:
|
|
108
108
|
|
|
109
109
|
```bash
|
|
110
|
-
dsh plugin add dsh-ssh-manage@0.2.
|
|
110
|
+
dsh plugin add dsh-ssh-manage@0.2.2
|
|
111
111
|
```
|
|
112
112
|
|
|
113
113
|
安装后刷新 Web 页面,左侧栏会出现「SSH 主机」入口,入口 id 为 `dsh-ssh-manage`。
|
|
@@ -139,7 +139,7 @@ plugin_manager action=install_bundle target=<仓库绝对路径>/dsh-ssh-manage
|
|
|
139
139
|
|
|
140
140
|
| | 要求 |
|
|
141
141
|
| --- | --- |
|
|
142
|
-
| **DSH** | **`>=0.2.
|
|
142
|
+
| **DSH** | **`>=0.2.2-rc.3 <0.3.0`**(实机验证版本:DSH Desktop `0.2.2-rc.3`) |
|
|
143
143
|
| **Node.js** | `>=20`(跟随 DSH 内置运行时) |
|
|
144
144
|
| **平台** | 实机验证 macOS;测试套件只依赖 Node 内置模块,可跨平台运行 |
|
|
145
145
|
| **网络** | 需要能访问目标 SSH 主机的 22 端口(或配置的端口) |
|
package/client.js
CHANGED
|
@@ -16,6 +16,8 @@ window.__ModuleLoader__.load({
|
|
|
16
16
|
panel: ['SSH 主机', 'SSH Hosts'],
|
|
17
17
|
'tabs.hosts': ['主机', 'Hosts'],
|
|
18
18
|
'tabs.sessions': ['会话', 'Sessions'],
|
|
19
|
+
'session.restoring': ['正在恢复终端会话…', 'Restoring terminal sessions…'],
|
|
20
|
+
'session.tooMany': ['最多同时打开 16 个终端会话,请先关闭一个。', 'At most 16 terminal sessions can be open. Close one first.'],
|
|
19
21
|
'tabs.batch': ['批量命令', 'Batch'],
|
|
20
22
|
'tabs.settings': ['设置', 'Settings'],
|
|
21
23
|
'session.new': ['新建连接', 'New connection'],
|
|
@@ -31,7 +33,7 @@ window.__ModuleLoader__.load({
|
|
|
31
33
|
'toolbar.refresh': ['刷新', 'Refresh'],
|
|
32
34
|
'toolbar.groups': ['分组', 'Groups'],
|
|
33
35
|
'toolbar.new': ['新建主机', 'New host'],
|
|
34
|
-
'toolbar.newShort': ['
|
|
36
|
+
'toolbar.newShort': ['SSH', 'SSH'],
|
|
35
37
|
'toolbar.filter': ['只看收藏', 'Favorites only'],
|
|
36
38
|
'toolbar.info': ['信息', 'Info'],
|
|
37
39
|
'toolbar.settings': ['显示设置', 'View options'],
|
|
@@ -141,6 +143,7 @@ window.__ModuleLoader__.load({
|
|
|
141
143
|
'editor.required': ['请填写别名和服务器地址。', 'Alias and hostname are required.'],
|
|
142
144
|
'editor.invalidAlias': ['别名只能包含字母、数字、点、下划线、@ 和短横线。', 'Alias may only contain letters, digits, dot, underscore, @ and dash.'],
|
|
143
145
|
'editor.delete': ['删除这台主机', 'Delete this host'],
|
|
146
|
+
'editor.discardChanges': ['有未保存的修改,确定放弃?', 'Discard unsaved changes?'],
|
|
144
147
|
'drawer.files': ['文件', 'Files'],
|
|
145
148
|
'drawer.details': ['详情', 'Details'],
|
|
146
149
|
'terminal.loading': ['正在加载终端…', 'Loading terminal…'],
|
|
@@ -280,6 +283,12 @@ window.__ModuleLoader__.load({
|
|
|
280
283
|
return `${user}${target}${port}`
|
|
281
284
|
}
|
|
282
285
|
|
|
286
|
+
// Table / session chrome shows only the hostname (the name column already
|
|
287
|
+
// carries the alias); the full user@host:port stays in titles and copy.
|
|
288
|
+
function hostTarget(host) {
|
|
289
|
+
return host.hostname || host.host
|
|
290
|
+
}
|
|
291
|
+
|
|
283
292
|
function hostGroupKey(host) {
|
|
284
293
|
return host.group || ''
|
|
285
294
|
}
|
|
@@ -1048,7 +1057,7 @@ window.__ModuleLoader__.load({
|
|
|
1048
1057
|
}
|
|
1049
1058
|
let result = 0
|
|
1050
1059
|
if (sort.key === 'system') result = String(a.system ?? '').localeCompare(String(b.system ?? ''))
|
|
1051
|
-
else if (sort.key === 'address') result =
|
|
1060
|
+
else if (sort.key === 'address') result = hostTarget(a).localeCompare(hostTarget(b))
|
|
1052
1061
|
else if (sort.key === 'notes') result = String(a.note ?? '').localeCompare(String(b.note ?? ''))
|
|
1053
1062
|
else result = hostName(a).localeCompare(hostName(b), undefined, { numeric: true, sensitivity: 'base' })
|
|
1054
1063
|
if (result === 0) result = a.host.localeCompare(b.host)
|
|
@@ -1068,10 +1077,10 @@ window.__ModuleLoader__.load({
|
|
|
1068
1077
|
}
|
|
1069
1078
|
|
|
1070
1079
|
function tableTracks(prefs) {
|
|
1071
|
-
const tracks = ['32px', '
|
|
1072
|
-
if (prefs.info) tracks.push('minmax(
|
|
1073
|
-
if (prefs.notes) tracks.push('minmax(
|
|
1074
|
-
tracks.push('
|
|
1080
|
+
const tracks = ['32px', '96px', '80px', 'minmax(90px,1fr)', 'minmax(150px,1.25fr)']
|
|
1081
|
+
if (prefs.info) tracks.push('minmax(210px,1.4fr)')
|
|
1082
|
+
if (prefs.notes) tracks.push('minmax(120px,0.8fr)')
|
|
1083
|
+
tracks.push('150px', '28px')
|
|
1075
1084
|
return tracks.join(' ')
|
|
1076
1085
|
}
|
|
1077
1086
|
|
|
@@ -1174,6 +1183,8 @@ window.__ModuleLoader__.load({
|
|
|
1174
1183
|
const [clearSignals, setClearSignals] = React.useState({})
|
|
1175
1184
|
const toastTimer = React.useRef(null)
|
|
1176
1185
|
const floatingRef = React.useRef(null)
|
|
1186
|
+
const editorCloseGuard = React.useRef(null)
|
|
1187
|
+
const groupsCloseGuard = React.useRef(null)
|
|
1177
1188
|
|
|
1178
1189
|
const notify = React.useCallback((message, tone = 'info') => {
|
|
1179
1190
|
if (toastTimer.current) clearTimeout(toastTimer.current)
|
|
@@ -1369,6 +1380,10 @@ window.__ModuleLoader__.load({
|
|
|
1369
1380
|
if (existing) {
|
|
1370
1381
|
setActiveSessionId(existing.id)
|
|
1371
1382
|
} else {
|
|
1383
|
+
if (sessions.length >= MAX_TERMINAL_SESSIONS) {
|
|
1384
|
+
notify(t('session.tooMany'), 'error')
|
|
1385
|
+
return
|
|
1386
|
+
}
|
|
1372
1387
|
const id = newPtySessionId()
|
|
1373
1388
|
ptyActiveIds.add(id)
|
|
1374
1389
|
setSessions((prev) => [...prev, { id, hostAlias: host.host }])
|
|
@@ -1396,6 +1411,7 @@ window.__ModuleLoader__.load({
|
|
|
1396
1411
|
}
|
|
1397
1412
|
|
|
1398
1413
|
function clearSession(id) {
|
|
1414
|
+
clearPtySession(id)
|
|
1399
1415
|
setClearSignals((prev) => ({ ...prev, [id]: (prev[id] ?? 0) + 1 }))
|
|
1400
1416
|
}
|
|
1401
1417
|
|
|
@@ -1578,11 +1594,15 @@ window.__ModuleLoader__.load({
|
|
|
1578
1594
|
return
|
|
1579
1595
|
}
|
|
1580
1596
|
if (editor) {
|
|
1581
|
-
|
|
1597
|
+
const guard = editorCloseGuard.current
|
|
1598
|
+
if (typeof guard === 'function') guard()
|
|
1599
|
+
else setEditor(null)
|
|
1582
1600
|
return
|
|
1583
1601
|
}
|
|
1584
1602
|
if (groupsOpen) {
|
|
1585
|
-
|
|
1603
|
+
const guard = groupsCloseGuard.current
|
|
1604
|
+
if (typeof guard === 'function') guard()
|
|
1605
|
+
else setGroupsOpen(false)
|
|
1586
1606
|
return
|
|
1587
1607
|
}
|
|
1588
1608
|
if (drawer) setDrawer(null)
|
|
@@ -1674,6 +1694,8 @@ window.__ModuleLoader__.load({
|
|
|
1674
1694
|
setFloating,
|
|
1675
1695
|
setEditor,
|
|
1676
1696
|
setDrawer,
|
|
1697
|
+
editorCloseGuard,
|
|
1698
|
+
groupsCloseGuard,
|
|
1677
1699
|
treeWidth,
|
|
1678
1700
|
setTreeWidth,
|
|
1679
1701
|
sessions,
|
|
@@ -1928,7 +1950,7 @@ window.__ModuleLoader__.load({
|
|
|
1928
1950
|
'div',
|
|
1929
1951
|
{ className: 'ssh-loadingPage' },
|
|
1930
1952
|
h(Spinner, { size: 22 }),
|
|
1931
|
-
h('span', null, t('state.loading')),
|
|
1953
|
+
h('span', null, sessions.length > 0 ? t('session.restoring') : t('state.loading')),
|
|
1932
1954
|
)
|
|
1933
1955
|
: !snapshot && error
|
|
1934
1956
|
? h(
|
|
@@ -1945,7 +1967,7 @@ window.__ModuleLoader__.load({
|
|
|
1945
1967
|
: effectiveTab === 'batch'
|
|
1946
1968
|
? h(BatchTab, { model })
|
|
1947
1969
|
: h(SettingsTab, { model }),
|
|
1948
|
-
sessions.length > 0
|
|
1970
|
+
snapshot && sessions.length > 0
|
|
1949
1971
|
? h(
|
|
1950
1972
|
'div',
|
|
1951
1973
|
{ className: 'ssh-sessionLayer', style: { display: effectiveTab === 'sessions' ? 'flex' : 'none' } },
|
|
@@ -2394,7 +2416,7 @@ window.__ModuleLoader__.load({
|
|
|
2394
2416
|
h(
|
|
2395
2417
|
'div',
|
|
2396
2418
|
{ className: 'ssh-cell ssh-colAddress' },
|
|
2397
|
-
h('span', { className: 'ssh-address', title: hostAddress(host) },
|
|
2419
|
+
h('span', { className: 'ssh-address', title: hostAddress(host) }, hostTarget(host)),
|
|
2398
2420
|
h(
|
|
2399
2421
|
'span',
|
|
2400
2422
|
{ className: 'ssh-inlineActions' },
|
|
@@ -2503,9 +2525,11 @@ window.__ModuleLoader__.load({
|
|
|
2503
2525
|
observer.observe(node)
|
|
2504
2526
|
return () => observer.disconnect()
|
|
2505
2527
|
}, [])
|
|
2528
|
+
const hasInfo = visible.some((host) => Boolean(host.system || host.cpuCores || host.memoryGB || host.diskGB))
|
|
2529
|
+
const hasNotes = visible.some((host) => Boolean(host.note))
|
|
2506
2530
|
const effective = {
|
|
2507
|
-
info: columns.info && tableWidth >=
|
|
2508
|
-
notes: columns.notes && tableWidth >=
|
|
2531
|
+
info: columns.info && hasInfo && tableWidth >= 900,
|
|
2532
|
+
notes: columns.notes && hasNotes && tableWidth >= 1100,
|
|
2509
2533
|
}
|
|
2510
2534
|
const style = { gridTemplateColumns: tableTracks(effective) }
|
|
2511
2535
|
const visibleAliases = visible.map((host) => host.host)
|
|
@@ -2741,7 +2765,7 @@ window.__ModuleLoader__.load({
|
|
|
2741
2765
|
'div',
|
|
2742
2766
|
{ className: 'ssh-sessionActions' },
|
|
2743
2767
|
activeHost
|
|
2744
|
-
? h('span', { className: 'ssh-sessionAddress', title: hostAddress(activeHost) },
|
|
2768
|
+
? h('span', { className: 'ssh-sessionAddress', title: hostAddress(activeHost) }, hostTarget(activeHost))
|
|
2745
2769
|
: null,
|
|
2746
2770
|
activeCredential ? h(Badge, { tone: activeCredential.tone }, activeCredential.label) : null,
|
|
2747
2771
|
h(
|
|
@@ -2853,7 +2877,7 @@ window.__ModuleLoader__.load({
|
|
|
2853
2877
|
'div',
|
|
2854
2878
|
{ className: 'ssh-drawerTitles' },
|
|
2855
2879
|
h('div', { className: 'ssh-drawerTitle', title: hostName(host) }, hostName(host)),
|
|
2856
|
-
h('div', { className: 'ssh-drawerSub' },
|
|
2880
|
+
h('div', { className: 'ssh-drawerSub' }, hostTarget(host)),
|
|
2857
2881
|
),
|
|
2858
2882
|
h(
|
|
2859
2883
|
Badge,
|
|
@@ -3031,11 +3055,73 @@ window.__ModuleLoader__.load({
|
|
|
3031
3055
|
}
|
|
3032
3056
|
}
|
|
3033
3057
|
|
|
3058
|
+
const terminalThemeListeners = new Set()
|
|
3059
|
+
let terminalThemeObserver = null
|
|
3060
|
+
let terminalThemeMedia = null
|
|
3061
|
+
let terminalThemeScheduled = false
|
|
3062
|
+
|
|
3063
|
+
function emitTerminalTheme() {
|
|
3064
|
+
for (const listener of [...terminalThemeListeners]) {
|
|
3065
|
+
try { listener() } catch {}
|
|
3066
|
+
}
|
|
3067
|
+
}
|
|
3068
|
+
|
|
3069
|
+
function scheduleTerminalTheme() {
|
|
3070
|
+
if (terminalThemeScheduled) return
|
|
3071
|
+
terminalThemeScheduled = true
|
|
3072
|
+
const run = () => {
|
|
3073
|
+
terminalThemeScheduled = false
|
|
3074
|
+
emitTerminalTheme()
|
|
3075
|
+
}
|
|
3076
|
+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run)
|
|
3077
|
+
else setTimeout(run, 16)
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
function ensureTerminalThemeWatcher() {
|
|
3081
|
+
if (typeof MutationObserver !== 'undefined' && !terminalThemeObserver) {
|
|
3082
|
+
terminalThemeObserver = new MutationObserver(scheduleTerminalTheme)
|
|
3083
|
+
const options = {
|
|
3084
|
+
attributes: true,
|
|
3085
|
+
attributeFilter: ['class', 'style', 'data-theme', 'data-color-mode', 'data-ds-dark-theme', 'data-ds-theme-source'],
|
|
3086
|
+
}
|
|
3087
|
+
terminalThemeObserver.observe(document.documentElement, options)
|
|
3088
|
+
if (document.body) terminalThemeObserver.observe(document.body, options)
|
|
3089
|
+
}
|
|
3090
|
+
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function' && !terminalThemeMedia) {
|
|
3091
|
+
terminalThemeMedia = window.matchMedia('(prefers-color-scheme: dark)')
|
|
3092
|
+
const onChange = () => scheduleTerminalTheme()
|
|
3093
|
+
if (typeof terminalThemeMedia.addEventListener === 'function') terminalThemeMedia.addEventListener('change', onChange)
|
|
3094
|
+
else if (typeof terminalThemeMedia.addListener === 'function') terminalThemeMedia.addListener(onChange)
|
|
3095
|
+
terminalThemeMedia.__sshThemeChange = onChange
|
|
3096
|
+
}
|
|
3097
|
+
}
|
|
3098
|
+
|
|
3099
|
+
function subscribeTerminalTheme(listener) {
|
|
3100
|
+
terminalThemeListeners.add(listener)
|
|
3101
|
+
ensureTerminalThemeWatcher()
|
|
3102
|
+
return () => {
|
|
3103
|
+
terminalThemeListeners.delete(listener)
|
|
3104
|
+
if (terminalThemeListeners.size > 0) return
|
|
3105
|
+
if (terminalThemeObserver) {
|
|
3106
|
+
terminalThemeObserver.disconnect()
|
|
3107
|
+
terminalThemeObserver = null
|
|
3108
|
+
}
|
|
3109
|
+
if (terminalThemeMedia) {
|
|
3110
|
+
const onChange = terminalThemeMedia.__sshThemeChange
|
|
3111
|
+
if (typeof terminalThemeMedia.removeEventListener === 'function') terminalThemeMedia.removeEventListener('change', onChange)
|
|
3112
|
+
else if (typeof terminalThemeMedia.removeListener === 'function') terminalThemeMedia.removeListener(onChange)
|
|
3113
|
+
terminalThemeMedia = null
|
|
3114
|
+
}
|
|
3115
|
+
}
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3034
3118
|
const PTY_EVENTS_ROUTE = '/dsh-ssh-manage/pty/events'
|
|
3035
3119
|
const PTY_ATTACH_ROUTE = '/dsh-ssh-manage/pty/attach'
|
|
3120
|
+
const PTY_CLEAR_ROUTE = '/dsh-ssh-manage/pty/clear'
|
|
3036
3121
|
const ptyStream = { source: null, ready: false, promise: null, listeners: new Map() }
|
|
3037
3122
|
const ptyActiveIds = new Set()
|
|
3038
3123
|
const ptyClosedIds = new Set()
|
|
3124
|
+
const MAX_TERMINAL_SESSIONS = 16
|
|
3039
3125
|
const ptyStreamLostHandlers = new Set()
|
|
3040
3126
|
|
|
3041
3127
|
function closePtySession(id) {
|
|
@@ -3057,13 +3143,23 @@ window.__ModuleLoader__.load({
|
|
|
3057
3143
|
return () => ptyStreamLostHandlers.delete(handler)
|
|
3058
3144
|
}
|
|
3059
3145
|
|
|
3060
|
-
function resetPtyStream() {
|
|
3146
|
+
function resetPtyStream(reason = new Error('Terminal event stream closed')) {
|
|
3147
|
+
const wasReady = ptyStream.ready === true
|
|
3148
|
+
const rejectPending = ptyStream.reject
|
|
3149
|
+
ptyStream.reject = null
|
|
3061
3150
|
if (ptyStream.source) {
|
|
3062
3151
|
try { ptyStream.source.close() } catch {}
|
|
3063
3152
|
}
|
|
3064
3153
|
ptyStream.source = null
|
|
3065
3154
|
ptyStream.ready = false
|
|
3066
3155
|
ptyStream.promise = null
|
|
3156
|
+
if (rejectPending) {
|
|
3157
|
+
try { rejectPending(reason) } catch {}
|
|
3158
|
+
}
|
|
3159
|
+
// A stream that never finished connecting is not a lost terminal yet;
|
|
3160
|
+
// its waiting panes get the rejected ensure promise and keep their
|
|
3161
|
+
// normal connecting/error overlay.
|
|
3162
|
+
if (!wasReady) return
|
|
3067
3163
|
for (const handler of [...ptyStreamLostHandlers]) {
|
|
3068
3164
|
try { handler() } catch {}
|
|
3069
3165
|
}
|
|
@@ -3077,8 +3173,18 @@ window.__ModuleLoader__.load({
|
|
|
3077
3173
|
}
|
|
3078
3174
|
|
|
3079
3175
|
function ensurePtyStream() {
|
|
3080
|
-
if (ptyStream.
|
|
3081
|
-
|
|
3176
|
+
if (ptyStream.source) {
|
|
3177
|
+
const state = ptyStream.source.readyState
|
|
3178
|
+
if (state === 1 && ptyStream.ready) return Promise.resolve()
|
|
3179
|
+
// CONNECTING: all callers must share the one in-flight promise; never
|
|
3180
|
+
// tear it down. This is the multi-tab restore / rapid-second-connect
|
|
3181
|
+
// race that used to leave N-1 panes in a permanent error overlay.
|
|
3182
|
+
if (state === 0) {
|
|
3183
|
+
if (ptyStream.promise) return ptyStream.promise
|
|
3184
|
+
try { ptyStream.source.close() } catch {}
|
|
3185
|
+
ptyStream.source = null
|
|
3186
|
+
} else if (state === 2) resetPtyStream()
|
|
3187
|
+
}
|
|
3082
3188
|
if (ptyStream.promise) return ptyStream.promise
|
|
3083
3189
|
ptyStream.promise = new Promise((resolve, reject) => {
|
|
3084
3190
|
let settled = false
|
|
@@ -3092,14 +3198,17 @@ window.__ModuleLoader__.load({
|
|
|
3092
3198
|
ptyStream.source = null
|
|
3093
3199
|
ptyStream.ready = false
|
|
3094
3200
|
ptyStream.promise = null
|
|
3201
|
+
ptyStream.reject = null
|
|
3095
3202
|
reject(error)
|
|
3096
3203
|
}
|
|
3097
3204
|
ptyStream.source = source
|
|
3205
|
+
ptyStream.reject = reject
|
|
3098
3206
|
timer = setTimeout(() => fail(new Error('Terminal event stream timed out')), 10_000)
|
|
3099
3207
|
source.addEventListener('open', () => {
|
|
3100
3208
|
if (settled) return
|
|
3101
3209
|
settled = true
|
|
3102
3210
|
clearTimeout(timer)
|
|
3211
|
+
ptyStream.reject = null
|
|
3103
3212
|
ptyStream.ready = true
|
|
3104
3213
|
resolve()
|
|
3105
3214
|
})
|
|
@@ -3153,6 +3262,11 @@ window.__ModuleLoader__.load({
|
|
|
3153
3262
|
}
|
|
3154
3263
|
}
|
|
3155
3264
|
|
|
3265
|
+
function clearPtySession(id) {
|
|
3266
|
+
if (!id) return
|
|
3267
|
+
void ptyPost(PTY_CLEAR_ROUTE, { id }, 15_000).catch(() => {})
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3156
3270
|
function decodeBase64(text) {
|
|
3157
3271
|
const binary = atob(String(text ?? ''))
|
|
3158
3272
|
const bytes = new Uint8Array(binary.length)
|
|
@@ -3171,6 +3285,8 @@ window.__ModuleLoader__.load({
|
|
|
3171
3285
|
const [status, setStatus] = React.useState('loading')
|
|
3172
3286
|
const [message, setMessage] = React.useState('')
|
|
3173
3287
|
const [retryToken, setRetryToken] = React.useState(0)
|
|
3288
|
+
const clearRequested = React.useRef(false)
|
|
3289
|
+
const syncedRef = React.useRef(false)
|
|
3174
3290
|
|
|
3175
3291
|
React.useEffect(() => {
|
|
3176
3292
|
activeRef.current = active === true
|
|
@@ -3186,7 +3302,12 @@ window.__ModuleLoader__.load({
|
|
|
3186
3302
|
}, [active])
|
|
3187
3303
|
|
|
3188
3304
|
React.useEffect(() => {
|
|
3189
|
-
if (!clearSignal
|
|
3305
|
+
if (!clearSignal) return
|
|
3306
|
+
// Only a clear issued before attach needs to survive into the attach
|
|
3307
|
+
// replay. A clear on a live, synced terminal is handled by the server
|
|
3308
|
+
// route directly and must not truncate the next attach replay.
|
|
3309
|
+
clearRequested.current = syncedRef.current !== true
|
|
3310
|
+
if (!termRef.current) return
|
|
3190
3311
|
try {
|
|
3191
3312
|
termRef.current.clear()
|
|
3192
3313
|
termRef.current.write('\x1b[H\x1b[2J')
|
|
@@ -3209,6 +3330,8 @@ window.__ModuleLoader__.load({
|
|
|
3209
3330
|
let opened = false
|
|
3210
3331
|
let inputChain = Promise.resolve()
|
|
3211
3332
|
let streamLost = null
|
|
3333
|
+
let themeUnsub = null
|
|
3334
|
+
syncedRef.current = false
|
|
3212
3335
|
setStatus('loading')
|
|
3213
3336
|
setMessage('')
|
|
3214
3337
|
|
|
@@ -3270,6 +3393,10 @@ window.__ModuleLoader__.load({
|
|
|
3270
3393
|
termRef.current = term
|
|
3271
3394
|
fitRef.current = fit
|
|
3272
3395
|
try { fit.fit() } catch {}
|
|
3396
|
+
themeUnsub = subscribeTerminalTheme(() => {
|
|
3397
|
+
if (removed || !term) return
|
|
3398
|
+
try { term.options.theme = terminalThemeFor(container) } catch {}
|
|
3399
|
+
})
|
|
3273
3400
|
term.onData((data) => {
|
|
3274
3401
|
const id = sessionKey
|
|
3275
3402
|
if (!id || !data || !opened) return
|
|
@@ -3319,6 +3446,14 @@ window.__ModuleLoader__.load({
|
|
|
3319
3446
|
let lastSeq = 0
|
|
3320
3447
|
const pendingEvents = []
|
|
3321
3448
|
|
|
3449
|
+
const clearTerminal = () => {
|
|
3450
|
+
if (!term) return
|
|
3451
|
+
try {
|
|
3452
|
+
term.clear()
|
|
3453
|
+
term.write('\x1b[H\x1b[2J')
|
|
3454
|
+
} catch {}
|
|
3455
|
+
}
|
|
3456
|
+
|
|
3322
3457
|
const applyOutput = (event) => {
|
|
3323
3458
|
const seq = Number(event.seq)
|
|
3324
3459
|
if (Number.isFinite(seq)) {
|
|
@@ -3332,6 +3467,9 @@ window.__ModuleLoader__.load({
|
|
|
3332
3467
|
if (event.kind === 'output') {
|
|
3333
3468
|
if (!synced) pendingEvents.push(event)
|
|
3334
3469
|
else applyOutput(event)
|
|
3470
|
+
} else if (event.kind === 'clear') {
|
|
3471
|
+
if (!synced) pendingEvents.push(event)
|
|
3472
|
+
else clearTerminal()
|
|
3335
3473
|
} else if (event.kind === 'exit') {
|
|
3336
3474
|
if (!synced) {
|
|
3337
3475
|
pendingEvents.push(event)
|
|
@@ -3378,18 +3516,23 @@ window.__ModuleLoader__.load({
|
|
|
3378
3516
|
closePtySession(id)
|
|
3379
3517
|
return
|
|
3380
3518
|
}
|
|
3381
|
-
if (removed) return
|
|
3382
|
-
if (
|
|
3519
|
+
if (removed || stopped || finished) return
|
|
3520
|
+
if (clearRequested.current) {
|
|
3521
|
+
clearRequested.current = false
|
|
3522
|
+
clearTerminal()
|
|
3523
|
+
clearPtySession(id)
|
|
3524
|
+
} else if (value && value.output) {
|
|
3383
3525
|
try { term.write(decodeBase64(value.output)) } catch {}
|
|
3384
3526
|
}
|
|
3385
3527
|
const seq = Number(value?.seq)
|
|
3386
3528
|
if (Number.isFinite(seq)) lastSeq = seq
|
|
3387
3529
|
synced = true
|
|
3388
3530
|
for (const event of pendingEvents.splice(0)) {
|
|
3389
|
-
if (finished) break
|
|
3531
|
+
if (finished || stopped) break
|
|
3390
3532
|
handleEvent(event)
|
|
3391
3533
|
}
|
|
3392
|
-
if (finished) return
|
|
3534
|
+
if (finished || stopped || removed) return
|
|
3535
|
+
syncedRef.current = true
|
|
3393
3536
|
opened = true
|
|
3394
3537
|
setStatus('ready')
|
|
3395
3538
|
setMessage('')
|
|
@@ -3414,6 +3557,7 @@ window.__ModuleLoader__.load({
|
|
|
3414
3557
|
|
|
3415
3558
|
return () => {
|
|
3416
3559
|
removed = true
|
|
3560
|
+
if (themeUnsub) themeUnsub()
|
|
3417
3561
|
if (streamLost) streamLost()
|
|
3418
3562
|
if (resizeObserver) resizeObserver.disconnect()
|
|
3419
3563
|
teardown()
|
|
@@ -3801,6 +3945,28 @@ window.__ModuleLoader__.load({
|
|
|
3801
3945
|
setDraft((prev) => ({ ...prev, [field]: value }))
|
|
3802
3946
|
}
|
|
3803
3947
|
|
|
3948
|
+
const dirty = password !== '' || JSON.stringify(draft) !== JSON.stringify(state.draft)
|
|
3949
|
+
const requestClose = () => {
|
|
3950
|
+
if (busy) return
|
|
3951
|
+
if (!dirty) {
|
|
3952
|
+
model.setEditor(null)
|
|
3953
|
+
return
|
|
3954
|
+
}
|
|
3955
|
+
let discard = true
|
|
3956
|
+
try {
|
|
3957
|
+
discard = typeof window.confirm !== 'function' || window.confirm(t('editor.discardChanges'))
|
|
3958
|
+
} catch {}
|
|
3959
|
+
if (discard) model.setEditor(null)
|
|
3960
|
+
}
|
|
3961
|
+
|
|
3962
|
+
React.useEffect(() => {
|
|
3963
|
+
const guard = () => requestClose()
|
|
3964
|
+
model.editorCloseGuard.current = guard
|
|
3965
|
+
return () => {
|
|
3966
|
+
if (model.editorCloseGuard.current === guard) model.editorCloseGuard.current = null
|
|
3967
|
+
}
|
|
3968
|
+
})
|
|
3969
|
+
|
|
3804
3970
|
async function submit() {
|
|
3805
3971
|
const alias = draft.host.trim()
|
|
3806
3972
|
const hostname = draft.hostname.trim()
|
|
@@ -3856,7 +4022,7 @@ window.__ModuleLoader__.load({
|
|
|
3856
4022
|
t('editor.delete'),
|
|
3857
4023
|
)
|
|
3858
4024
|
: h('span', { key: 'spacer', className: 'ssh-toolSpacer' }),
|
|
3859
|
-
h(Button, { key: 'cancel', onClick:
|
|
4025
|
+
h(Button, { key: 'cancel', onClick: requestClose, disabled: busy }, t('common.cancel')),
|
|
3860
4026
|
h(
|
|
3861
4027
|
Button,
|
|
3862
4028
|
{ key: 'save', variant: 'primary', onClick: () => void submit(), disabled: busy },
|
|
@@ -3873,7 +4039,7 @@ window.__ModuleLoader__.load({
|
|
|
3873
4039
|
width: 700,
|
|
3874
4040
|
icon: h(IconTerminal, { size: 16 }),
|
|
3875
4041
|
closeLabel: t('common.close'),
|
|
3876
|
-
onClose:
|
|
4042
|
+
onClose: requestClose,
|
|
3877
4043
|
footer,
|
|
3878
4044
|
},
|
|
3879
4045
|
h(
|
|
@@ -3996,6 +4162,17 @@ window.__ModuleLoader__.load({
|
|
|
3996
4162
|
const groups = buildGroups(hosts, t).filter((group) => group.key !== '')
|
|
3997
4163
|
const [drafts, setDrafts] = React.useState(() => Object.fromEntries(groups.map((group) => [group.key, group.key])))
|
|
3998
4164
|
const busy = isPending('groups')
|
|
4165
|
+
const requestClose = () => {
|
|
4166
|
+
if (!busy) model.setGroupsOpen(false)
|
|
4167
|
+
}
|
|
4168
|
+
|
|
4169
|
+
React.useEffect(() => {
|
|
4170
|
+
const guard = () => requestClose()
|
|
4171
|
+
model.groupsCloseGuard.current = guard
|
|
4172
|
+
return () => {
|
|
4173
|
+
if (model.groupsCloseGuard.current === guard) model.groupsCloseGuard.current = null
|
|
4174
|
+
}
|
|
4175
|
+
})
|
|
3999
4176
|
|
|
4000
4177
|
async function commit(name) {
|
|
4001
4178
|
const next = String(drafts[name] ?? '').trim()
|
|
@@ -4012,8 +4189,8 @@ window.__ModuleLoader__.load({
|
|
|
4012
4189
|
width: 560,
|
|
4013
4190
|
icon: h(IconFolder, { size: 16 }),
|
|
4014
4191
|
closeLabel: t('common.close'),
|
|
4015
|
-
onClose:
|
|
4016
|
-
footer: [h(Button, { key: 'close', onClick:
|
|
4192
|
+
onClose: requestClose,
|
|
4193
|
+
footer: [h(Button, { key: 'close', onClick: requestClose }, t('common.close'))],
|
|
4017
4194
|
},
|
|
4018
4195
|
groups.length === 0
|
|
4019
4196
|
? h('div', { className: 'ssh-sectionHint' }, t('groups.empty'))
|
|
@@ -4204,7 +4381,7 @@ window.__ModuleLoader__.load({
|
|
|
4204
4381
|
label: t('table.select'),
|
|
4205
4382
|
}),
|
|
4206
4383
|
h('span', { className: 'ssh-targetName' }, hostName(host)),
|
|
4207
|
-
h('span', { className: 'ssh-targetAddr' },
|
|
4384
|
+
h('span', { className: 'ssh-targetAddr' }, hostTarget(host)),
|
|
4208
4385
|
h('span', { className: 'ssh-targetCred' }, h(Badge, { tone: credential.tone }, credential.label)),
|
|
4209
4386
|
)
|
|
4210
4387
|
}),
|
package/index.js
CHANGED
|
@@ -17,6 +17,7 @@ const PTY_INPUT_ROUTE = '/dsh-ssh-manage/pty/input'
|
|
|
17
17
|
const PTY_RESIZE_ROUTE = '/dsh-ssh-manage/pty/resize'
|
|
18
18
|
const PTY_ATTACH_ROUTE = '/dsh-ssh-manage/pty/attach'
|
|
19
19
|
const PTY_CLOSE_ROUTE = '/dsh-ssh-manage/pty/close'
|
|
20
|
+
const PTY_CLEAR_ROUTE = '/dsh-ssh-manage/pty/clear'
|
|
20
21
|
const MAX_PTY_SESSIONS = 16
|
|
21
22
|
const PTY_BUFFER_BYTES = 256 * 1024
|
|
22
23
|
const PTY_IDLE_MS = 6 * 60 * 60 * 1000
|
|
@@ -75,14 +76,26 @@ function errorMessage(error) {
|
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
async function state(paths) {
|
|
78
|
-
const [current,
|
|
79
|
+
const [current, metadataResult, passwordsResult] = await Promise.all([
|
|
80
|
+
listSshHosts(paths),
|
|
81
|
+
loadMetadata(paths).then((value) => ({ value }), (error) => ({ error })),
|
|
82
|
+
listPasswordAliases(paths).then((value) => ({ value }), (error) => ({ error })),
|
|
83
|
+
])
|
|
84
|
+
const warnings = []
|
|
85
|
+
let entries = {}
|
|
86
|
+
if ('error' in metadataResult) warnings.push({ store: 'metadata', message: errorMessage(metadataResult.error) })
|
|
87
|
+
else entries = metadataResult.value.entries
|
|
88
|
+
let passwords = []
|
|
89
|
+
if ('error' in passwordsResult) warnings.push({ store: 'vault', message: errorMessage(passwordsResult.error) })
|
|
90
|
+
else passwords = passwordsResult.value
|
|
79
91
|
const passwordSet = new Set(passwords)
|
|
80
92
|
const stateDir = storagePaths(paths).stateDir
|
|
81
93
|
return {
|
|
82
94
|
...current,
|
|
83
95
|
stateDir,
|
|
84
96
|
backupDir: paths.backupDir || path.join(stateDir, 'backups'),
|
|
85
|
-
hosts: current.hosts.map((host) => ({ ...host, ...(
|
|
97
|
+
hosts: current.hosts.map((host) => ({ ...host, ...(entries[host.host] || {}), passwordConfigured: passwordSet.has(host.host) })),
|
|
98
|
+
warnings,
|
|
86
99
|
}
|
|
87
100
|
}
|
|
88
101
|
|
|
@@ -282,6 +295,16 @@ const ptyPendingIds = new Set()
|
|
|
282
295
|
const ptyEventClients = new Set()
|
|
283
296
|
let ptyOpening = 0
|
|
284
297
|
|
|
298
|
+
function createPtyRuntime() {
|
|
299
|
+
return { generation: 0, active: true, upgrades: new Set() }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const defaultPtyRuntime = createPtyRuntime()
|
|
303
|
+
|
|
304
|
+
function isPtyRuntimeActive(runtime, generation) {
|
|
305
|
+
return runtime.active && runtime.generation === generation
|
|
306
|
+
}
|
|
307
|
+
|
|
285
308
|
function closeTextSession(id, notify = true) {
|
|
286
309
|
const entry = ptyTextSessions.get(id)
|
|
287
310
|
if (!entry) return
|
|
@@ -311,15 +334,17 @@ function broadcastPty(event) {
|
|
|
311
334
|
for (const client of [...ptyEventClients]) writePtyEvent(client, event)
|
|
312
335
|
}
|
|
313
336
|
|
|
314
|
-
async function openTextSession(paths, payload) {
|
|
337
|
+
async function openTextSession(paths, payload, runtime = defaultPtyRuntime) {
|
|
338
|
+
if (!runtime.active) throw new Error('Terminal runtime is not active')
|
|
315
339
|
const id = typeof payload?.id === 'string' ? payload.id.trim() : ''
|
|
316
340
|
if (!/^[A-Za-z0-9_-]{8,80}$/.test(id)) throw new Error('A valid session id is required')
|
|
341
|
+
const cols = boundedInteger(payload?.cols, 20, 500, 120)
|
|
342
|
+
const rows = boundedInteger(payload?.rows, 5, 300, 32)
|
|
343
|
+
const alias = typeof payload?.host === 'string' ? payload.host.trim() : ''
|
|
344
|
+
if (alias === '') throw new Error('host is required')
|
|
317
345
|
if (ptyTextSessions.has(id) || ptyPendingIds.has(id)) throw new Error('Terminal session already exists')
|
|
346
|
+
const generation = runtime.generation
|
|
318
347
|
ptyPendingIds.add(id)
|
|
319
|
-
const cols = boundedInteger(payload.cols, 20, 500, 120)
|
|
320
|
-
const rows = boundedInteger(payload.rows, 5, 300, 32)
|
|
321
|
-
const alias = typeof payload.host === 'string' ? payload.host.trim() : ''
|
|
322
|
-
if (alias === '') throw new Error('host is required')
|
|
323
348
|
ptyOpening += 1
|
|
324
349
|
try {
|
|
325
350
|
if (ptySessions.size + ptyTextSessions.size + ptyOpening > MAX_PTY_SESSIONS) {
|
|
@@ -327,7 +352,12 @@ async function openTextSession(paths, payload) {
|
|
|
327
352
|
}
|
|
328
353
|
const host = await findHost(paths, alias)
|
|
329
354
|
const password = await requirePassword(paths, host)
|
|
355
|
+
if (!isPtyRuntimeActive(runtime, generation)) throw new Error('Terminal runtime is not active')
|
|
330
356
|
const session = await openPty(host, password, { cols, rows })
|
|
357
|
+
if (!isPtyRuntimeActive(runtime, generation)) {
|
|
358
|
+
try { session.close() } catch {}
|
|
359
|
+
throw new Error('Terminal runtime is not active')
|
|
360
|
+
}
|
|
331
361
|
if (ptyTextSessions.has(id)) {
|
|
332
362
|
try { session.close() } catch {}
|
|
333
363
|
throw new Error('Terminal session already exists')
|
|
@@ -363,7 +393,7 @@ async function openTextSession(paths, payload) {
|
|
|
363
393
|
}
|
|
364
394
|
}
|
|
365
395
|
|
|
366
|
-
function registerPtyHttpRoutes(ctx, paths) {
|
|
396
|
+
function registerPtyHttpRoutes(ctx, paths, runtime) {
|
|
367
397
|
const register = (route, label) => {
|
|
368
398
|
ctx.effect(() => ctx.webServer.register(route), `dsh-ssh-manage: ${label}`)
|
|
369
399
|
}
|
|
@@ -423,7 +453,7 @@ function registerPtyHttpRoutes(ctx, paths) {
|
|
|
423
453
|
}
|
|
424
454
|
const body = await readJsonBody(req)
|
|
425
455
|
try {
|
|
426
|
-
const value = await openTextSession(paths, body ?? {})
|
|
456
|
+
const value = await openTextSession(paths, body ?? {}, runtime)
|
|
427
457
|
sendJson(res, 200, { ok: true, value })
|
|
428
458
|
} catch (error) {
|
|
429
459
|
sendJson(res, 200, { ok: false, error: { code: 'pty-open-failed', message: errorMessage(error) } })
|
|
@@ -473,6 +503,7 @@ function registerPtyHttpRoutes(ctx, paths) {
|
|
|
473
503
|
[PTY_INPUT_ROUTE, 'input', 'pty input route'],
|
|
474
504
|
[PTY_RESIZE_ROUTE, 'resize', 'pty resize route'],
|
|
475
505
|
[PTY_CLOSE_ROUTE, 'close', 'pty close route'],
|
|
506
|
+
[PTY_CLEAR_ROUTE, 'clear', 'pty clear route'],
|
|
476
507
|
]) {
|
|
477
508
|
register(
|
|
478
509
|
{
|
|
@@ -504,6 +535,10 @@ function registerPtyHttpRoutes(ctx, paths) {
|
|
|
504
535
|
0,
|
|
505
536
|
0,
|
|
506
537
|
)
|
|
538
|
+
} else if (action === 'clear') {
|
|
539
|
+
entry.buffer = []
|
|
540
|
+
entry.bufferBytes = 0
|
|
541
|
+
broadcastPty({ kind: 'clear', id })
|
|
507
542
|
} else {
|
|
508
543
|
closeTextSession(id)
|
|
509
544
|
}
|
|
@@ -554,7 +589,11 @@ function upgradeRejection(ctx, req) {
|
|
|
554
589
|
return rejection === undefined ? undefined : rejection
|
|
555
590
|
}
|
|
556
591
|
|
|
557
|
-
async function handlePtyUpgrade(ctx, paths, req, socket, head) {
|
|
592
|
+
async function handlePtyUpgrade(ctx, paths, req, socket, head, runtime = defaultPtyRuntime) {
|
|
593
|
+
if (!runtime.active) {
|
|
594
|
+
denyUpgrade(socket, 403)
|
|
595
|
+
return
|
|
596
|
+
}
|
|
558
597
|
if (!isWebSocketUpgrade(req)) {
|
|
559
598
|
try { socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n') } catch {}
|
|
560
599
|
socket.destroy()
|
|
@@ -567,22 +606,30 @@ async function handlePtyUpgrade(ctx, paths, req, socket, head) {
|
|
|
567
606
|
}
|
|
568
607
|
const ws = acceptWebSocket(req, socket, head)
|
|
569
608
|
if (!ws) return
|
|
609
|
+
const generation = runtime.generation
|
|
570
610
|
let session = null
|
|
571
611
|
let opening = false
|
|
572
612
|
let cleaned = false
|
|
573
613
|
let paused = false
|
|
614
|
+
let shutdown = null
|
|
574
615
|
const keepalive = setInterval(() => ws.ping(), 25_000)
|
|
575
616
|
keepalive.unref?.()
|
|
576
617
|
const cleanup = () => {
|
|
577
618
|
if (cleaned) return
|
|
578
619
|
cleaned = true
|
|
579
620
|
clearInterval(keepalive)
|
|
621
|
+
if (shutdown) runtime.upgrades.delete(shutdown)
|
|
580
622
|
if (session) {
|
|
581
623
|
ptySessions.delete(session)
|
|
582
624
|
try { session.close() } catch {}
|
|
583
625
|
session = null
|
|
584
626
|
}
|
|
585
627
|
}
|
|
628
|
+
shutdown = () => {
|
|
629
|
+
cleanup()
|
|
630
|
+
try { ws.close(1001) } catch {}
|
|
631
|
+
}
|
|
632
|
+
runtime.upgrades.add(shutdown)
|
|
586
633
|
ws.on('close', cleanup)
|
|
587
634
|
socket.on('drain', () => {
|
|
588
635
|
if (!paused || !session) return
|
|
@@ -599,7 +646,7 @@ async function handlePtyUpgrade(ctx, paths, req, socket, head) {
|
|
|
599
646
|
}
|
|
600
647
|
if (!message || typeof message.type !== 'string' || cleaned) return
|
|
601
648
|
if (!session) {
|
|
602
|
-
if (message.type !== 'open' || opening) return
|
|
649
|
+
if (message.type !== 'open' || opening || !runtime.active) return
|
|
603
650
|
opening = true
|
|
604
651
|
ptyOpening += 1
|
|
605
652
|
try {
|
|
@@ -610,8 +657,9 @@ async function handlePtyUpgrade(ctx, paths, req, socket, head) {
|
|
|
610
657
|
const rows = boundedInteger(message.rows, 5, 300, 32)
|
|
611
658
|
const host = await findHost(paths, alias)
|
|
612
659
|
const password = await requirePassword(paths, host)
|
|
660
|
+
if (!isPtyRuntimeActive(runtime, generation)) throw new Error('Terminal runtime is not active')
|
|
613
661
|
session = await openPty(host, password, { cols, rows })
|
|
614
|
-
if (cleaned) {
|
|
662
|
+
if (cleaned || !isPtyRuntimeActive(runtime, generation)) {
|
|
615
663
|
try { session.close() } catch {}
|
|
616
664
|
session = null
|
|
617
665
|
return
|
|
@@ -714,7 +762,7 @@ function registerAssetRoutes(ctx) {
|
|
|
714
762
|
}
|
|
715
763
|
}
|
|
716
764
|
|
|
717
|
-
export const __test = { resolveSshPaths, listSshHosts, saveSshHost, deleteSshHost, runAction, managerTool, MAX_BODY_BYTES, PTY_ROUTE, PTY_EVENTS_ROUTE, PTY_OPEN_ROUTE, PTY_ATTACH_ROUTE, PTY_INPUT_ROUTE, PTY_RESIZE_ROUTE, PTY_CLOSE_ROUTE, MAX_PTY_SESSIONS, PTY_BUFFER_BYTES, ASSETS, handlePtyUpgrade, ptyTextSessions }
|
|
765
|
+
export const __test = { resolveSshPaths, listSshHosts, saveSshHost, deleteSshHost, runAction, managerTool, MAX_BODY_BYTES, PTY_ROUTE, PTY_EVENTS_ROUTE, PTY_OPEN_ROUTE, PTY_ATTACH_ROUTE, PTY_INPUT_ROUTE, PTY_RESIZE_ROUTE, PTY_CLOSE_ROUTE, PTY_CLEAR_ROUTE, MAX_PTY_SESSIONS, PTY_BUFFER_BYTES, ASSETS, handlePtyUpgrade, ptyTextSessions }
|
|
718
766
|
|
|
719
767
|
export const inject = ['webServer']
|
|
720
768
|
|
|
@@ -723,6 +771,7 @@ export function apply(ctx) {
|
|
|
723
771
|
throw new Error('dsh-ssh-manage requires DeepSeek Harness >=0.2.1-rc.3: ctx.webServer.register is unavailable.')
|
|
724
772
|
}
|
|
725
773
|
const paths = resolveSshPaths(process.env)
|
|
774
|
+
const runtime = createPtyRuntime()
|
|
726
775
|
ctx.effect(
|
|
727
776
|
() =>
|
|
728
777
|
ctx.webServer.register({
|
|
@@ -772,7 +821,7 @@ export function apply(ctx) {
|
|
|
772
821
|
}),
|
|
773
822
|
'dsh-ssh-manage: action route',
|
|
774
823
|
)
|
|
775
|
-
registerPtyHttpRoutes(ctx, paths)
|
|
824
|
+
registerPtyHttpRoutes(ctx, paths, runtime)
|
|
776
825
|
registerAssetRoutes(ctx)
|
|
777
826
|
if (typeof ctx.webServer.tapIndex === 'function') {
|
|
778
827
|
const tags = [
|
|
@@ -793,7 +842,7 @@ export function apply(ctx) {
|
|
|
793
842
|
ctx.webServer.registerUpgrade({
|
|
794
843
|
path: PTY_ROUTE,
|
|
795
844
|
handler: (req, socket, head) => {
|
|
796
|
-
void handlePtyUpgrade(ctx, paths, req, socket, head).catch(() => {
|
|
845
|
+
void handlePtyUpgrade(ctx, paths, req, socket, head, runtime).catch(() => {
|
|
797
846
|
try { socket.destroy() } catch {}
|
|
798
847
|
})
|
|
799
848
|
},
|
|
@@ -802,6 +851,12 @@ export function apply(ctx) {
|
|
|
802
851
|
)
|
|
803
852
|
ctx.effect(
|
|
804
853
|
() => () => {
|
|
854
|
+
runtime.active = false
|
|
855
|
+
runtime.generation += 1
|
|
856
|
+
for (const shutdown of [...runtime.upgrades]) {
|
|
857
|
+
try { shutdown() } catch {}
|
|
858
|
+
}
|
|
859
|
+
runtime.upgrades.clear()
|
|
805
860
|
clearInterval(ptyIdleTimer)
|
|
806
861
|
for (const session of ptySessions) {
|
|
807
862
|
try { session.close() } catch {}
|
package/lib/metadata.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { atomicWriteFile, readJson, storagePaths } from './storage.js'
|
|
1
|
+
import { atomicWriteFile, readJson, storagePaths, withStoreLock } from './storage.js'
|
|
3
2
|
|
|
4
3
|
function clean(value, max = 80) {
|
|
5
4
|
return String(value ?? '').trim().slice(0, max)
|
|
@@ -27,7 +26,12 @@ function normalizeEntry(alias, input = {}) {
|
|
|
27
26
|
}
|
|
28
27
|
}
|
|
29
28
|
|
|
30
|
-
|
|
29
|
+
function metadataLockKey(paths) {
|
|
30
|
+
return `metadata:${storagePaths(paths).stateDir}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Read the metadata store without taking the store lock; mutators hold it. */
|
|
34
|
+
async function loadMetadataUnlocked(paths) {
|
|
31
35
|
const resolved = storagePaths(paths)
|
|
32
36
|
const value = await readJson(resolved.metadataPath, {})
|
|
33
37
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return { paths: resolved, entries: {} }
|
|
@@ -35,24 +39,37 @@ export async function loadMetadata(paths) {
|
|
|
35
39
|
return { paths: resolved, entries }
|
|
36
40
|
}
|
|
37
41
|
|
|
38
|
-
|
|
42
|
+
/** Write the metadata store without taking the store lock; mutators hold it. */
|
|
43
|
+
async function saveMetadataUnlocked(paths, entries) {
|
|
39
44
|
const resolved = storagePaths(paths)
|
|
40
45
|
const normalized = Object.fromEntries(Object.entries(entries || {}).map(([alias, data]) => [alias, normalizeEntry(alias, data)]))
|
|
41
46
|
await atomicWriteFile(resolved.metadataPath, `${JSON.stringify(normalized, null, 2)}\n`, 0o600)
|
|
42
47
|
return { saved: true }
|
|
43
48
|
}
|
|
44
49
|
|
|
50
|
+
export async function loadMetadata(paths) {
|
|
51
|
+
return loadMetadataUnlocked(paths)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function saveMetadata(paths, entries) {
|
|
55
|
+
return withStoreLock(metadataLockKey(paths), () => saveMetadataUnlocked(paths, entries))
|
|
56
|
+
}
|
|
57
|
+
|
|
45
58
|
export async function updateMetadata(paths, alias, data) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
59
|
+
return withStoreLock(metadataLockKey(paths), async () => {
|
|
60
|
+
const current = await loadMetadataUnlocked(paths)
|
|
61
|
+
current.entries[alias] = normalizeEntry(alias, { ...current.entries[alias], ...data })
|
|
62
|
+
await saveMetadataUnlocked(current.paths, current.entries)
|
|
63
|
+
return current.entries[alias]
|
|
64
|
+
})
|
|
50
65
|
}
|
|
51
66
|
|
|
52
67
|
export async function deleteMetadata(paths, alias) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
68
|
+
return withStoreLock(metadataLockKey(paths), async () => {
|
|
69
|
+
const current = await loadMetadataUnlocked(paths)
|
|
70
|
+
delete current.entries[alias]
|
|
71
|
+
await saveMetadataUnlocked(current.paths, current.entries)
|
|
72
|
+
})
|
|
56
73
|
}
|
|
57
74
|
|
|
58
75
|
export const __test = { normalizeEntry }
|
package/lib/ssh-config.js
CHANGED
|
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto'
|
|
|
2
2
|
import * as fs from 'node:fs/promises'
|
|
3
3
|
import os from 'node:os'
|
|
4
4
|
import path from 'node:path'
|
|
5
|
+
import { withStoreLock } from './storage.js'
|
|
5
6
|
|
|
6
7
|
const HOST_RE = /^[A-Za-z0-9][A-Za-z0-9_.@-]{0,63}$/
|
|
7
8
|
const OPTION_NAMES = new Map([
|
|
@@ -246,13 +247,15 @@ export async function listSshHosts(paths = resolveSshPaths()) {
|
|
|
246
247
|
return { configPath: paths.configPath, hosts: parsed.hosts, exists: text.length > 0 }
|
|
247
248
|
}
|
|
248
249
|
|
|
249
|
-
|
|
250
|
+
async function saveSshHostUnlocked({ paths, host, originalHost }) {
|
|
250
251
|
const nextHost = validateHost(host)
|
|
251
252
|
const aliasesProvided = Array.isArray(host?.aliases) && host.aliases.filter(Boolean).length > 0
|
|
252
253
|
const previous = originalHost ? String(originalHost).trim() : null
|
|
254
|
+
if (previous !== null && (!HOST_RE.test(previous) || isWildPattern(previous))) throw new Error('Invalid SSH host alias')
|
|
253
255
|
const text = await readConfig(paths)
|
|
254
256
|
const parsed = parseSshConfig(text)
|
|
255
257
|
const block = previous ? blockForHost(parsed, previous) : null
|
|
258
|
+
if (previous !== null && !block) throw new Error(`SSH host "${previous}" was not found`)
|
|
256
259
|
const collision = blockForHost(parsed, nextHost.host)
|
|
257
260
|
if (collision && (!block || collision.start !== block.start)) throw new Error(`SSH host "${nextHost.host}" already exists`)
|
|
258
261
|
let lines = parsed.lines
|
|
@@ -274,7 +277,12 @@ export async function saveSshHost({ paths = resolveSshPaths(), host, originalHos
|
|
|
274
277
|
return { saved: true, host: nextHost, backup }
|
|
275
278
|
}
|
|
276
279
|
|
|
277
|
-
export async function
|
|
280
|
+
export async function saveSshHost(options = {}) {
|
|
281
|
+
const paths = options?.paths ?? resolveSshPaths()
|
|
282
|
+
return withStoreLock(`config:${paths.configPath}`, () => saveSshHostUnlocked({ ...options, paths }))
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function deleteSshHostUnlocked({ paths, host }) {
|
|
278
286
|
const name = String(host ?? '').trim()
|
|
279
287
|
if (!HOST_RE.test(name) || isWildPattern(name)) throw new Error('Invalid SSH host alias')
|
|
280
288
|
const text = await readConfig(paths)
|
|
@@ -289,4 +297,9 @@ export async function deleteSshHost({ paths = resolveSshPaths(), host } = {}) {
|
|
|
289
297
|
return { removed: true, backup }
|
|
290
298
|
}
|
|
291
299
|
|
|
300
|
+
export async function deleteSshHost(options = {}) {
|
|
301
|
+
const paths = options?.paths ?? resolveSshPaths()
|
|
302
|
+
return withStoreLock(`config:${paths.configPath}`, () => deleteSshHostUnlocked({ ...options, paths }))
|
|
303
|
+
}
|
|
304
|
+
|
|
292
305
|
export const __test = { patchHostBlock }
|
package/lib/storage.js
CHANGED
|
@@ -3,6 +3,8 @@ import * as fs from 'node:fs/promises'
|
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
|
|
5
5
|
const KEY_BYTES = 32
|
|
6
|
+
const storeLocks = new Map()
|
|
7
|
+
const keyCreations = new Map()
|
|
6
8
|
|
|
7
9
|
export function storagePaths(paths = {}) {
|
|
8
10
|
const stateDir = paths.stateDir || path.join(paths.home || process.env.HOME || process.cwd(), '.dsh', '.ssh-manager')
|
|
@@ -15,6 +17,23 @@ export function storagePaths(paths = {}) {
|
|
|
15
17
|
}
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Serialize one async read-modify-write sequence per store key. Callers must
|
|
22
|
+
* only take the lock around their own read/write pair; the unlocked helpers in
|
|
23
|
+
* the vault, metadata and SSH config modules exist so nested locks never wait
|
|
24
|
+
* on themselves.
|
|
25
|
+
*/
|
|
26
|
+
export function withStoreLock(key, fn) {
|
|
27
|
+
const previous = storeLocks.get(key) ?? Promise.resolve()
|
|
28
|
+
const next = previous.then(() => fn())
|
|
29
|
+
const tail = next.then(() => undefined, () => undefined)
|
|
30
|
+
storeLocks.set(key, tail)
|
|
31
|
+
void tail.then(() => {
|
|
32
|
+
if (storeLocks.get(key) === tail) storeLocks.delete(key)
|
|
33
|
+
})
|
|
34
|
+
return next
|
|
35
|
+
}
|
|
36
|
+
|
|
18
37
|
export async function atomicWriteFile(filePath, data, mode = 0o600) {
|
|
19
38
|
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 })
|
|
20
39
|
const temporary = `${filePath}.tmp-${process.pid}-${randomBytes(6).toString('hex')}`
|
|
@@ -28,23 +47,51 @@ export async function atomicWriteFile(filePath, data, mode = 0o600) {
|
|
|
28
47
|
}
|
|
29
48
|
}
|
|
30
49
|
|
|
50
|
+
function decodeKey(raw) {
|
|
51
|
+
if (Buffer.isBuffer(raw) && raw.length === KEY_BYTES) return raw
|
|
52
|
+
const decoded = Buffer.from(String(raw).trim(), 'base64')
|
|
53
|
+
if (decoded.length !== KEY_BYTES) throw new Error('SSH manager key has an invalid length')
|
|
54
|
+
return decoded
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function readKey(keyPath) {
|
|
58
|
+
const raw = await fs.readFile(keyPath)
|
|
59
|
+
await fs.chmod(keyPath, 0o600).catch(() => {})
|
|
60
|
+
return decodeKey(raw)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Create the key exactly once per path in this process; adopt another writer's key on EEXIST. */
|
|
64
|
+
function createKeyOnce(keyPath) {
|
|
65
|
+
const pending = keyCreations.get(keyPath)
|
|
66
|
+
if (pending) return pending
|
|
67
|
+
const creation = (async () => {
|
|
68
|
+
const key = randomBytes(KEY_BYTES)
|
|
69
|
+
await fs.mkdir(path.dirname(keyPath), { recursive: true, mode: 0o700 })
|
|
70
|
+
try {
|
|
71
|
+
await fs.writeFile(keyPath, key.toString('base64'), { encoding: 'utf8', mode: 0o600, flag: 'wx' })
|
|
72
|
+
return key
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error?.code !== 'EEXIST') throw error
|
|
75
|
+
// Another process created the key first: never overwrite an existing key.
|
|
76
|
+
return await readKey(keyPath)
|
|
77
|
+
}
|
|
78
|
+
})()
|
|
79
|
+
keyCreations.set(keyPath, creation)
|
|
80
|
+
const forget = () => {
|
|
81
|
+
if (keyCreations.get(keyPath) === creation) keyCreations.delete(keyPath)
|
|
82
|
+
}
|
|
83
|
+
void creation.then(forget, forget)
|
|
84
|
+
return creation
|
|
85
|
+
}
|
|
86
|
+
|
|
31
87
|
export async function loadOrCreateKey(paths) {
|
|
32
88
|
const resolved = storagePaths(paths)
|
|
33
89
|
try {
|
|
34
|
-
|
|
35
|
-
await fs.chmod(resolved.keyPath, 0o600).catch(() => {})
|
|
36
|
-
if (raw.length === KEY_BYTES) return raw
|
|
37
|
-
const decoded = Buffer.from(raw.toString('utf8').trim(), 'base64')
|
|
38
|
-
if (decoded.length !== KEY_BYTES) throw new Error('SSH manager key has an invalid length')
|
|
39
|
-
return decoded
|
|
90
|
+
return await readKey(resolved.keyPath)
|
|
40
91
|
} catch (error) {
|
|
41
92
|
if (error?.code !== 'ENOENT') throw error
|
|
42
|
-
const key = randomBytes(KEY_BYTES)
|
|
43
|
-
await atomicWriteFile(resolved.keyPath, key.toString('base64'), 0o600)
|
|
44
|
-
// The base64 representation is easier to inspect safely while retaining 256 bits.
|
|
45
|
-
const encoded = await fs.readFile(resolved.keyPath, 'utf8')
|
|
46
|
-
return Buffer.from(encoded.trim(), 'base64')
|
|
47
93
|
}
|
|
94
|
+
return createKeyOnce(resolved.keyPath)
|
|
48
95
|
}
|
|
49
96
|
|
|
50
97
|
export async function readJson(filePath, fallback) {
|
package/lib/vault.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
|
|
2
|
-
import { atomicWriteFile, loadOrCreateKey, readJson, storagePaths } from './storage.js'
|
|
2
|
+
import { atomicWriteFile, loadOrCreateKey, readJson, storagePaths, withStoreLock } from './storage.js'
|
|
3
3
|
|
|
4
4
|
const ALGORITHM = 'aes-256-gcm'
|
|
5
5
|
const IV_BYTES = 12
|
|
@@ -41,7 +41,8 @@ export function decryptRecords(payload, key) {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
/** Read the vault without taking the store lock; callers that mutate must hold it. */
|
|
45
|
+
async function loadVaultUnlocked(paths) {
|
|
45
46
|
const resolved = storagePaths(paths)
|
|
46
47
|
const key = await loadOrCreateKey(resolved)
|
|
47
48
|
const payload = await readJson(resolved.vaultPath, null)
|
|
@@ -49,7 +50,8 @@ export async function loadVault(paths) {
|
|
|
49
50
|
return { paths: resolved, key, records }
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
|
|
53
|
+
/** Write the vault without taking the store lock; callers that read-modify-write must hold it. */
|
|
54
|
+
async function saveVaultUnlocked(paths, records) {
|
|
53
55
|
const resolved = storagePaths(paths)
|
|
54
56
|
const key = await loadOrCreateKey(resolved)
|
|
55
57
|
const payload = encryptRecords(normalizeRecords(records), key)
|
|
@@ -57,22 +59,38 @@ export async function saveVault(paths, records) {
|
|
|
57
59
|
return { saved: true }
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
function vaultLockKey(paths) {
|
|
63
|
+
return `vault:${storagePaths(paths).stateDir}`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function loadVault(paths) {
|
|
67
|
+
return loadVaultUnlocked(paths)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function saveVault(paths, records) {
|
|
71
|
+
return withStoreLock(vaultLockKey(paths), () => saveVaultUnlocked(paths, records))
|
|
72
|
+
}
|
|
73
|
+
|
|
60
74
|
export async function setPassword(paths, alias, password) {
|
|
61
75
|
const key = assertAlias(alias)
|
|
62
76
|
if (typeof password !== 'string' || password.length === 0) throw new Error('Password is required')
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
77
|
+
return withStoreLock(vaultLockKey(paths), async () => {
|
|
78
|
+
const vault = await loadVaultUnlocked(paths)
|
|
79
|
+
vault.records[key] = password
|
|
80
|
+
await saveVaultUnlocked(vault.paths, vault.records)
|
|
81
|
+
return { saved: true, host: key }
|
|
82
|
+
})
|
|
67
83
|
}
|
|
68
84
|
|
|
69
85
|
export async function removePassword(paths, alias) {
|
|
70
86
|
const key = assertAlias(alias)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
87
|
+
return withStoreLock(vaultLockKey(paths), async () => {
|
|
88
|
+
const vault = await loadVaultUnlocked(paths)
|
|
89
|
+
const removed = Object.prototype.hasOwnProperty.call(vault.records, key)
|
|
90
|
+
delete vault.records[key]
|
|
91
|
+
if (removed) await saveVaultUnlocked(vault.paths, vault.records)
|
|
92
|
+
return { removed }
|
|
93
|
+
})
|
|
76
94
|
}
|
|
77
95
|
|
|
78
96
|
export async function hasPassword(paths, alias) {
|