dsh-ssh-manage 0.2.2 → 0.3.0
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 +8 -8
- package/README.md +11 -10
- package/client.js +134 -3
- package/index.js +51 -12
- package/lib/known-hosts.js +136 -0
- package/lib/ssh-client.js +20 -3
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -64,7 +64,7 @@ No more jumping between a terminal and DSH, and no need to remember where each h
|
|
|
64
64
|
- Ciphertext and key live in the same user directory, so this mainly protects against accidental file reads, not against malicious processes running as the same user.
|
|
65
65
|
- Groups, tags, favorites, notes and inspection data live in `hosts.json`; passwords live in `vault.json`; configuration changes are snapshotted to the backup directory.
|
|
66
66
|
|
|
67
|
-
> **Host key verification:**
|
|
67
|
+
> **Host key verification:** connections verify host keys against `known_hosts` (default `~/.ssh/known_hosts`, override with `SSH_KNOWN_HOSTS`). The first connection to an unknown host, or a changed host key, is refused with the server `SHA256:` fingerprint, expected fingerprint and known_hosts path. Verify the fingerprint out of band, then add the host line (`host key-type base64-blob`, or `[host]:port ...` for non-22) or import it with `ssh-keyscan`; `DSH_SSH_MANAGER_INSECURE_HOSTKEY=1` disables verification for emergency use only. Host certificates signed by a CA are not supported.
|
|
68
68
|
|
|
69
69
|
### Agent tool
|
|
70
70
|
|
|
@@ -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.
|
|
99
|
+
npm package: [`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage) (current version `0.3.0`).
|
|
100
100
|
|
|
101
101
|
```text
|
|
102
|
-
plugin_manager action=install_bundle target=dsh-ssh-manage@0.
|
|
102
|
+
plugin_manager action=install_bundle target=dsh-ssh-manage@0.3.0
|
|
103
103
|
```
|
|
104
104
|
|
|
105
105
|
Or with the DSH CLI:
|
|
106
106
|
|
|
107
107
|
```bash
|
|
108
|
-
dsh plugin add dsh-ssh-manage@0.
|
|
108
|
+
dsh plugin add dsh-ssh-manage@0.3.0
|
|
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.1-rc.3 <0.3.0`** (validated on DSH Desktop `0.2.1-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) |
|
|
@@ -190,7 +190,7 @@ and make sure the package is resolvable by the profile (for example `link:` to t
|
|
|
190
190
|
- `vault.json` uses AES-256-GCM with a key in the current user directory; `hosts.json` holds groups/tags/notes and other non-secret metadata.
|
|
191
191
|
- All Host / PTY / static-asset routes require an authenticated DSH browser session; unauthenticated requests return 401.
|
|
192
192
|
- Terminal traffic only flows between your browser and the target SSH host; no third-party service is involved.
|
|
193
|
-
-
|
|
193
|
+
- Host keys are verified against `~/.ssh/known_hosts` / `SSH_KNOWN_HOSTS`; unknown or changed keys are refused. This plugin only reads those files, not ssh_config `UserKnownHostsFile`/`GlobalKnownHostsFile`, and does not validate SSH CA host certificates — such hosts will be blocked until their key is added to the configured known_hosts file.
|
|
194
194
|
|
|
195
195
|
## Development
|
|
196
196
|
|
|
@@ -215,8 +215,8 @@ The terminal uses vendored xterm.js 6.0.0 (MIT, see `assets/LICENSE.xterm`) and
|
|
|
215
215
|
|
|
216
216
|
## Known limitations
|
|
217
217
|
|
|
218
|
-
- **Host key verification**
|
|
219
|
-
- **Session lifetime**: sessions are kept on the Host; switching to another DSH session, temporarily leaving the panel, or refreshing the page re-attaches to the existing shell and replays roughly the last 256 KB of output.
|
|
218
|
+
- **Host key verification** uses `known_hosts`; hosts with no matching entry are refused with a fingerprint and path hint. No in-panel trust/import flow yet; custom `UserKnownHostsFile`/SSH CA setups need manual entry.
|
|
219
|
+
- **Session lifetime**: sessions are kept on the Host; switching to another DSH session, temporarily leaving the panel, or refreshing the page re-attaches to the existing shell and replays roughly the last 256 KB of output. The browser sends a `/pty/touch` heartbeat every 60s while the page is open; a session with no output, input or heartbeat for 30 minutes is reaped (max 16 sessions).
|
|
220
220
|
- **Password auth only**: IdentityFile / SSH agent / ProxyJump are not used for transport; those config fields are preserved but ignored. ProxyJump hosts fail with a clear error.
|
|
221
221
|
- **Config editing scope**: the editor only changes `Host`, `HostName`, `User`, `Port` and plugin metadata; maintain other SSH directives in `~/.ssh/config`.
|
|
222
222
|
- **Batch commands** run one command; they are not an interactive terminal. Per-host output is capped at 1 MB and marked `truncated` when exceeded.
|
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
### 交互式终端
|
|
45
45
|
|
|
46
46
|
- 点「连接」打开真正的 PTY shell,不是一条命令一条命令地执行。
|
|
47
|
-
- 多台主机以标签页常驻:切换到其它 DSH 会话再回来,终端会重新附着到原来的 shell,并回放最近约 256 KB
|
|
47
|
+
- 多台主机以标签页常驻:切换到其它 DSH 会话再回来,终端会重新附着到原来的 shell,并回放最近约 256 KB 输出;关闭标签、Host 退出,或 30 分钟无输出/输入/心跳才会结束会话。
|
|
48
48
|
- xterm.js 6.0.0 渲染,支持 256 色、光标、滚动回看、选中复制、粘贴、窗口 resize。
|
|
49
49
|
- 终端主题跟随 DSH 明暗主题;会话顶部有地址、密码状态、测试连接、文件、详情、清屏、关闭等快捷操作。
|
|
50
50
|
- 输出走同源 HTTP/SSE 长连接,输入 / 尺寸 / 关闭走同源 HTTP POST;插件同时保留经过测试的 WebSocket 备用通道。
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
- 密码密文与本地密钥位于同一用户目录,主要防止文件被直接误读,不能抵御同一用户下的恶意进程。
|
|
65
65
|
- 主机分组、标签、收藏、备注、巡检结果保存在 `hosts.json`;密码保存在 `vault.json`;删除/修改配置前写入备份目录。
|
|
66
66
|
|
|
67
|
-
>
|
|
67
|
+
> **主机密钥校验**:连接会按 `known_hosts` 校验主机密钥(默认 `~/.ssh/known_hosts`,可用 `SSH_KNOWN_HOSTS` 指向其它文件)。首次连接或服务器密钥变更时会拒绝连接,错误信息包含服务器 `SHA256:` 指纹、期望指纹和 known_hosts 路径;请先通过可信渠道核对指纹,再将该主机的 `[host]:port key-type base64-blob` 行写入 known_hosts(或使用 `ssh-keyscan` 后人工核对)。`DSH_SSH_MANAGER_INSECURE_HOSTKEY=1` 会完全关闭校验,仅限紧急排障,不要长期使用。
|
|
68
68
|
|
|
69
69
|
### Agent 工具
|
|
70
70
|
|
|
@@ -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.
|
|
101
|
+
npm 包:[`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage)(当前版本 `0.3.0`)。
|
|
102
102
|
|
|
103
103
|
```text
|
|
104
|
-
plugin_manager action=install_bundle target=dsh-ssh-manage@0.
|
|
104
|
+
plugin_manager action=install_bundle target=dsh-ssh-manage@0.3.0
|
|
105
105
|
```
|
|
106
106
|
|
|
107
107
|
或使用 DSH CLI:
|
|
108
108
|
|
|
109
109
|
```bash
|
|
110
|
-
dsh plugin add dsh-ssh-manage@0.
|
|
110
|
+
dsh plugin add dsh-ssh-manage@0.3.0
|
|
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.1-rc.3 <0.3.0`**(实机验证版本:DSH Desktop `0.2.1-rc.3`) |
|
|
143
143
|
| **Node.js** | `>=20`(跟随 DSH 内置运行时) |
|
|
144
144
|
| **平台** | 实机验证 macOS;测试套件只依赖 Node 内置模块,可跨平台运行 |
|
|
145
145
|
| **网络** | 需要能访问目标 SSH 主机的 22 端口(或配置的端口) |
|
|
@@ -178,7 +178,8 @@ plugin_manager action=install_bundle target=<仓库绝对路径>/dsh-ssh-manage
|
|
|
178
178
|
| --- | --- |
|
|
179
179
|
| `index.js` | Host 插件:HTTP/SSE/WS 路由、action 分发、PTY 会话、`ssh_manager` 工具。 |
|
|
180
180
|
| `client.js` | 浏览器插件:侧边栏入口、管理页、xterm 终端;只 `require('react')`。 |
|
|
181
|
-
| `lib/ssh-client.js` | ssh2 密码认证、PTY、exec、SFTP
|
|
181
|
+
| `lib/ssh-client.js` | ssh2 密码认证、PTY、exec、SFTP、巡检;known_hosts 主机密钥校验与 keepalive。 |
|
|
182
|
+
| `lib/known-hosts.js` | `known_hosts` 解析与主机密钥校验(plain/bracket/wildcard/hashed/revoked,SHA256 指纹)。 |
|
|
182
183
|
| `lib/ssh-config.js` | `~/.ssh/config` 解析、局部写入、备份、原子保存。 |
|
|
183
184
|
| `lib/storage.js` / `lib/vault.js` / `lib/metadata.js` | 状态目录、AES-256-GCM 凭据仓库、主机元数据。 |
|
|
184
185
|
| `lib/ws.js` | 无依赖 RFC6455 WebSocket 服务端(备用 PTY 通道)。 |
|
|
@@ -192,7 +193,7 @@ plugin_manager action=install_bundle target=<仓库绝对路径>/dsh-ssh-manage
|
|
|
192
193
|
- 状态目录中的 `vault.json` 使用 AES-256-GCM 加密,密钥在当前用户目录;`hosts.json` 保存分组/标签/备注等非敏感元数据。
|
|
193
194
|
- 所有 Host / PTY / 静态资源路由都要求 DSH 浏览器会话认证,未认证请求返回 401。
|
|
194
195
|
- 终端输出仅在你自己的浏览器与目标 SSH 主机之间传输,不经过第三方服务。
|
|
195
|
-
-
|
|
196
|
+
- 连接按 OpenSSH `known_hosts` 校验主机密钥(默认 `~/.ssh/known_hosts`,可用 `SSH_KNOWN_HOSTS` 覆盖);未知或变更的主机密钥会拒绝连接,并显示 `SHA256:` 指纹与 known_hosts 路径提示。`DSH_SSH_MANAGER_INSECURE_HOSTKEY=1` 仅限紧急排障时关闭校验。
|
|
196
197
|
|
|
197
198
|
## 开发
|
|
198
199
|
|
|
@@ -217,8 +218,8 @@ npm test
|
|
|
217
218
|
|
|
218
219
|
## 已知限制
|
|
219
220
|
|
|
220
|
-
-
|
|
221
|
-
- **会话生命周期**:终端会话由 Host 保留,切换 DSH 会话/暂时离开插件面板/刷新页面后会重新附着并回放最近约 256 KB
|
|
221
|
+
- **主机密钥校验**:依赖本机 `known_hosts`;首次连接需要先导入主机密钥(连接会失败并给出服务器 `SHA256:` 指纹、期望指纹与 known_hosts 路径)。只读取 `~/.ssh/known_hosts` / `SSH_KNOWN_HOSTS`,不读取 ssh_config 的 `UserKnownHostsFile`/`GlobalKnownHostsFile`,也不支持 SSH CA 主机证书。紧急情况下可设置 `DSH_SSH_MANAGER_INSECURE_HOSTKEY=1` 临时关闭校验。
|
|
222
|
+
- **会话生命周期**:终端会话由 Host 保留,切换 DSH 会话/暂时离开插件面板/刷新页面后会重新附着并回放最近约 256 KB 输出;浏览器通过 `/pty/touch` 心跳续期,Host 侧超过 30 分钟无输出、输入或心跳的会话会作为孤儿清理(最大 16 个)。
|
|
222
223
|
- **仅密码认证**:不支持 IdentityFile / SSH agent / ProxyJump 传输;配置中的这些字段会原样保留,但不会用于连接。ProxyJump 主机会明确报错。
|
|
223
224
|
- **配置编辑范围**:编辑器只修改 `Host`、`HostName`、`User`、`Port` 与插件元数据;其它 SSH 指令请在 `~/.ssh/config` 中维护。
|
|
224
225
|
- **批量命令**:一次执行一条命令,不等同于交互式终端;单台输出上限 1 MB,超出会标记 `truncated`。
|
package/client.js
CHANGED
|
@@ -18,6 +18,12 @@ window.__ModuleLoader__.load({
|
|
|
18
18
|
'tabs.sessions': ['会话', 'Sessions'],
|
|
19
19
|
'session.restoring': ['正在恢复终端会话…', 'Restoring terminal sessions…'],
|
|
20
20
|
'session.tooMany': ['最多同时打开 16 个终端会话,请先关闭一个。', 'At most 16 terminal sessions can be open. Close one first.'],
|
|
21
|
+
'session.attention': ['有会话已结束或出错', 'A session exited or failed'],
|
|
22
|
+
'session.state.loading': ['加载中', 'Loading'],
|
|
23
|
+
'session.state.connecting': ['连接中', 'Connecting'],
|
|
24
|
+
'session.state.ready': ['运行中', 'Running'],
|
|
25
|
+
'session.state.closed': ['已结束', 'Exited'],
|
|
26
|
+
'session.state.error': ['错误', 'Error'],
|
|
21
27
|
'tabs.batch': ['批量命令', 'Batch'],
|
|
22
28
|
'tabs.settings': ['设置', 'Settings'],
|
|
23
29
|
'session.new': ['新建连接', 'New connection'],
|
|
@@ -154,6 +160,7 @@ window.__ModuleLoader__.load({
|
|
|
154
160
|
'terminal.reconnect': ['重新连接', 'Reconnect'],
|
|
155
161
|
'terminal.openDetails': ['打开主机详情', 'Open details'],
|
|
156
162
|
'terminal.assetsError': ['终端组件加载失败:{message}。如果刚更新插件,请重启 DSH 后重试。', 'Could not load the terminal component: {message}. If the plugin was just updated, restart DSH and retry.'],
|
|
163
|
+
'terminal.resyncFailed': ['终端输出曾中断,已尝试重新同步;如仍异常请重连。', 'Terminal output was interrupted; attempted a resync. Reconnect if problems persist.'],
|
|
157
164
|
'terminal.backendHint': ['无法连接到插件后端。如果刚更新插件,请重启 DSH 后重试。', 'Cannot reach the plugin backend. If the plugin was just updated, restart DSH and retry.'],
|
|
158
165
|
'terminal.passwordMissing': ['该主机还没有保存密码,请先打开「主机详情」设置服务器密码。', 'No password stored for this host. Open Details and set the server password first.'],
|
|
159
166
|
'drawer.exit': ['退出码 {code}', 'exit {code}'],
|
|
@@ -1181,11 +1188,16 @@ window.__ModuleLoader__.load({
|
|
|
1181
1188
|
const [activeSessionId, setActiveSessionId] = React.useState(() => persistentPanelState.activeSessionId)
|
|
1182
1189
|
const [treeWidth, setTreeWidth] = React.useState(readTreeWidth)
|
|
1183
1190
|
const [clearSignals, setClearSignals] = React.useState({})
|
|
1191
|
+
const [sessionStates, setSessionStates] = React.useState({})
|
|
1184
1192
|
const toastTimer = React.useRef(null)
|
|
1185
1193
|
const floatingRef = React.useRef(null)
|
|
1186
1194
|
const editorCloseGuard = React.useRef(null)
|
|
1187
1195
|
const groupsCloseGuard = React.useRef(null)
|
|
1188
1196
|
|
|
1197
|
+
const setSessionState = React.useCallback((id, status) => {
|
|
1198
|
+
setSessionStates((prev) => (prev[id] === status ? prev : { ...prev, [id]: status }))
|
|
1199
|
+
}, [])
|
|
1200
|
+
|
|
1189
1201
|
const notify = React.useCallback((message, tone = 'info') => {
|
|
1190
1202
|
if (toastTimer.current) clearTimeout(toastTimer.current)
|
|
1191
1203
|
setToastState({ message, tone })
|
|
@@ -1314,6 +1326,13 @@ window.__ModuleLoader__.load({
|
|
|
1314
1326
|
for (const key of keys) if (ids.has(key)) next[key] = prev[key]
|
|
1315
1327
|
return next
|
|
1316
1328
|
})
|
|
1329
|
+
setSessionStates((prev) => {
|
|
1330
|
+
const keys = Object.keys(prev)
|
|
1331
|
+
if (keys.every((key) => ids.has(key))) return prev
|
|
1332
|
+
const next = {}
|
|
1333
|
+
for (const key of keys) if (ids.has(key)) next[key] = prev[key]
|
|
1334
|
+
return next
|
|
1335
|
+
})
|
|
1317
1336
|
}, [sessions])
|
|
1318
1337
|
|
|
1319
1338
|
// Keep one valid active session, and leave the sessions tab when it becomes empty.
|
|
@@ -1700,6 +1719,8 @@ window.__ModuleLoader__.load({
|
|
|
1700
1719
|
setTreeWidth,
|
|
1701
1720
|
sessions,
|
|
1702
1721
|
activeSessionId,
|
|
1722
|
+
sessionStates,
|
|
1723
|
+
setSessionState,
|
|
1703
1724
|
setActiveSessionId,
|
|
1704
1725
|
clearSignals,
|
|
1705
1726
|
openSession,
|
|
@@ -1884,9 +1905,15 @@ window.__ModuleLoader__.load({
|
|
|
1884
1905
|
]
|
|
1885
1906
|
}
|
|
1886
1907
|
|
|
1908
|
+
const sessionAttention = sessions.some((session) => {
|
|
1909
|
+
const state = sessionStates[session.id]
|
|
1910
|
+
return state === 'error' || state === 'closed'
|
|
1911
|
+
})
|
|
1887
1912
|
const tabItems = [
|
|
1888
1913
|
['hosts', t('tabs.hosts')],
|
|
1889
|
-
...(sessions.length > 0
|
|
1914
|
+
...(sessions.length > 0
|
|
1915
|
+
? [['sessions', h('span', { className: 'ssh-tabLabel' }, t('tabs.sessions'), sessionAttention ? h('span', { className: 'ssh-tabAlert', title: t('session.attention') }) : null)]]
|
|
1916
|
+
: []),
|
|
1890
1917
|
['batch', t('tabs.batch')],
|
|
1891
1918
|
['settings', t('tabs.settings')],
|
|
1892
1919
|
]
|
|
@@ -2669,6 +2696,7 @@ window.__ModuleLoader__.load({
|
|
|
2669
2696
|
t,
|
|
2670
2697
|
hosts,
|
|
2671
2698
|
sessions,
|
|
2699
|
+
sessionStates,
|
|
2672
2700
|
activeSessionId,
|
|
2673
2701
|
setActiveSessionId,
|
|
2674
2702
|
setActiveAlias,
|
|
@@ -2732,6 +2760,10 @@ window.__ModuleLoader__.load({
|
|
|
2732
2760
|
h('span', {
|
|
2733
2761
|
className: cx('ssh-sessionDot', credential ? (credential.key === 'password' ? 'ok' : 'warn') : 'warn'),
|
|
2734
2762
|
}),
|
|
2763
|
+
h('span', {
|
|
2764
|
+
className: cx('ssh-sessionState', sessionStates[session.id] ?? 'loading'),
|
|
2765
|
+
title: t(`session.state.${sessionStates[session.id] ?? 'loading'}`),
|
|
2766
|
+
}),
|
|
2735
2767
|
h('span', { className: 'ssh-sessionTabTitle' }, host ? hostName(host) : session.hostAlias),
|
|
2736
2768
|
h(
|
|
2737
2769
|
'button',
|
|
@@ -3118,12 +3150,28 @@ window.__ModuleLoader__.load({
|
|
|
3118
3150
|
const PTY_EVENTS_ROUTE = '/dsh-ssh-manage/pty/events'
|
|
3119
3151
|
const PTY_ATTACH_ROUTE = '/dsh-ssh-manage/pty/attach'
|
|
3120
3152
|
const PTY_CLEAR_ROUTE = '/dsh-ssh-manage/pty/clear'
|
|
3153
|
+
const PTY_TOUCH_ROUTE = '/dsh-ssh-manage/pty/touch'
|
|
3121
3154
|
const ptyStream = { source: null, ready: false, promise: null, listeners: new Map() }
|
|
3122
3155
|
const ptyActiveIds = new Set()
|
|
3123
3156
|
const ptyClosedIds = new Set()
|
|
3124
3157
|
const MAX_TERMINAL_SESSIONS = 16
|
|
3125
3158
|
const ptyStreamLostHandlers = new Set()
|
|
3126
3159
|
|
|
3160
|
+
if (typeof window !== 'undefined') {
|
|
3161
|
+
const heartbeat = setInterval(() => {
|
|
3162
|
+
if (ptyActiveIds.size === 0) return
|
|
3163
|
+
try {
|
|
3164
|
+
void fetch(PTY_TOUCH_ROUTE, {
|
|
3165
|
+
method: 'POST',
|
|
3166
|
+
headers: { 'content-type': 'application/json' },
|
|
3167
|
+
body: JSON.stringify({ ids: [...ptyActiveIds] }),
|
|
3168
|
+
keepalive: true,
|
|
3169
|
+
}).catch(() => {})
|
|
3170
|
+
} catch {}
|
|
3171
|
+
}, 60_000)
|
|
3172
|
+
heartbeat.unref?.()
|
|
3173
|
+
}
|
|
3174
|
+
|
|
3127
3175
|
function closePtySession(id) {
|
|
3128
3176
|
if (!id) return
|
|
3129
3177
|
ptyClosedIds.add(id)
|
|
@@ -3286,7 +3334,13 @@ window.__ModuleLoader__.load({
|
|
|
3286
3334
|
const [message, setMessage] = React.useState('')
|
|
3287
3335
|
const [retryToken, setRetryToken] = React.useState(0)
|
|
3288
3336
|
const clearRequested = React.useRef(false)
|
|
3337
|
+
const reportStatus = model.setSessionState
|
|
3338
|
+
|
|
3339
|
+
React.useEffect(() => {
|
|
3340
|
+
reportStatus?.(sessionId, status)
|
|
3341
|
+
}, [reportStatus, sessionId, status])
|
|
3289
3342
|
const syncedRef = React.useRef(false)
|
|
3343
|
+
const clearEpochRef = React.useRef(0)
|
|
3290
3344
|
|
|
3291
3345
|
React.useEffect(() => {
|
|
3292
3346
|
activeRef.current = active === true
|
|
@@ -3303,6 +3357,7 @@ window.__ModuleLoader__.load({
|
|
|
3303
3357
|
|
|
3304
3358
|
React.useEffect(() => {
|
|
3305
3359
|
if (!clearSignal) return
|
|
3360
|
+
clearEpochRef.current += 1
|
|
3306
3361
|
// Only a clear issued before attach needs to survive into the attach
|
|
3307
3362
|
// replay. A clear on a live, synced terminal is handled by the server
|
|
3308
3363
|
// route directly and must not truncate the next attach replay.
|
|
@@ -3443,7 +3498,6 @@ window.__ModuleLoader__.load({
|
|
|
3443
3498
|
sessionKey = sessionId
|
|
3444
3499
|
const id = sessionKey
|
|
3445
3500
|
let synced = false
|
|
3446
|
-
let lastSeq = 0
|
|
3447
3501
|
const pendingEvents = []
|
|
3448
3502
|
|
|
3449
3503
|
const clearTerminal = () => {
|
|
@@ -3454,16 +3508,87 @@ window.__ModuleLoader__.load({
|
|
|
3454
3508
|
} catch {}
|
|
3455
3509
|
}
|
|
3456
3510
|
|
|
3511
|
+
let resyncing = false
|
|
3512
|
+
let resyncFailedAt = 0
|
|
3513
|
+
let resyncAttempts = 0
|
|
3514
|
+
let resyncGraceUntil = 0
|
|
3515
|
+
const resyncQueue = []
|
|
3516
|
+
let lastSeq = 0
|
|
3517
|
+
|
|
3457
3518
|
const applyOutput = (event) => {
|
|
3458
3519
|
const seq = Number(event.seq)
|
|
3459
3520
|
if (Number.isFinite(seq)) {
|
|
3460
3521
|
if (seq <= lastSeq) return
|
|
3461
|
-
lastSeq
|
|
3522
|
+
if (lastSeq > 0 && seq > lastSeq + 1) {
|
|
3523
|
+
if (Date.now() < resyncGraceUntil) {
|
|
3524
|
+
lastSeq = seq
|
|
3525
|
+
} else {
|
|
3526
|
+
resyncAttempts += 1
|
|
3527
|
+
if (resyncAttempts > 3) {
|
|
3528
|
+
resyncAttempts = 0
|
|
3529
|
+
resyncGraceUntil = Date.now() + 5_000
|
|
3530
|
+
lastSeq = seq
|
|
3531
|
+
} else {
|
|
3532
|
+
resyncQueue.push(event)
|
|
3533
|
+
void resync()
|
|
3534
|
+
return
|
|
3535
|
+
}
|
|
3536
|
+
}
|
|
3537
|
+
} else {
|
|
3538
|
+
lastSeq = seq
|
|
3539
|
+
resyncAttempts = 0
|
|
3540
|
+
}
|
|
3462
3541
|
}
|
|
3463
3542
|
try { term.write(decodeBase64(event.data)) } catch {}
|
|
3464
3543
|
}
|
|
3465
3544
|
|
|
3545
|
+
const resync = async () => {
|
|
3546
|
+
if (resyncing || stopped || finished || !sessionKey) return
|
|
3547
|
+
const clearEpoch = clearEpochRef.current
|
|
3548
|
+
resyncing = true
|
|
3549
|
+
try {
|
|
3550
|
+
const value = await ptyPost(PTY_ATTACH_ROUTE, {
|
|
3551
|
+
id: sessionKey,
|
|
3552
|
+
cols: term?.cols ?? 80,
|
|
3553
|
+
rows: term?.rows ?? 24,
|
|
3554
|
+
}, 15_000)
|
|
3555
|
+
if (removed || stopped || finished) {
|
|
3556
|
+
resyncing = false
|
|
3557
|
+
return
|
|
3558
|
+
}
|
|
3559
|
+
if (clearEpochRef.current !== clearEpoch) {
|
|
3560
|
+
clearRequested.current = false
|
|
3561
|
+
clearTerminal()
|
|
3562
|
+
} else if (value && value.output) {
|
|
3563
|
+
try {
|
|
3564
|
+
term.reset()
|
|
3565
|
+
term.write(decodeBase64(value.output))
|
|
3566
|
+
} catch {}
|
|
3567
|
+
}
|
|
3568
|
+
const seq = Number(value?.seq)
|
|
3569
|
+
if (Number.isFinite(seq)) lastSeq = seq
|
|
3570
|
+
resyncing = false
|
|
3571
|
+
for (const event of resyncQueue.splice(0)) {
|
|
3572
|
+
if (finished || stopped) break
|
|
3573
|
+
handleEvent(event)
|
|
3574
|
+
}
|
|
3575
|
+
} catch {
|
|
3576
|
+
resyncing = false
|
|
3577
|
+
resyncFailedAt = Date.now()
|
|
3578
|
+
resyncGraceUntil = Date.now() + 5_000
|
|
3579
|
+
try { model.notify?.(t('terminal.resyncFailed'), 'error') } catch {}
|
|
3580
|
+
for (const event of resyncQueue.splice(0)) {
|
|
3581
|
+
if (finished || stopped) break
|
|
3582
|
+
handleEvent(event)
|
|
3583
|
+
}
|
|
3584
|
+
}
|
|
3585
|
+
}
|
|
3586
|
+
|
|
3466
3587
|
const handleEvent = (event) => {
|
|
3588
|
+
if (resyncing) {
|
|
3589
|
+
resyncQueue.push(event)
|
|
3590
|
+
return
|
|
3591
|
+
}
|
|
3467
3592
|
if (event.kind === 'output') {
|
|
3468
3593
|
if (!synced) pendingEvents.push(event)
|
|
3469
3594
|
else applyOutput(event)
|
|
@@ -4823,6 +4948,12 @@ body.ssh-col-resizing,body.ssh-col-resizing *{cursor:col-resize!important;user-s
|
|
|
4823
4948
|
.ssh-terminalOverlayBody{max-width:560px;font-size:11.5px;line-height:1.7;color:var(--ssh-text-3);word-break:break-word}
|
|
4824
4949
|
.ssh-terminalOverlayActions{display:flex;gap:8px;flex-wrap:wrap;justify-content:center}
|
|
4825
4950
|
.ssh-sessionLayer{flex:1;min-height:0;display:flex;flex-direction:column;background:var(--ssh-bg)}
|
|
4951
|
+
.ssh-sessionState{width:6px;height:6px;border-radius:50%;background:var(--ssh-dim);flex:0 0 auto}
|
|
4952
|
+
.ssh-sessionState.loading,.ssh-sessionState.connecting{background:var(--ssh-warn)}
|
|
4953
|
+
.ssh-sessionState.ready{background:var(--ssh-success)}
|
|
4954
|
+
.ssh-sessionState.error,.ssh-sessionState.closed{background:var(--ssh-danger)}
|
|
4955
|
+
.ssh-tabLabel{display:inline-flex;align-items:center;gap:5px}
|
|
4956
|
+
.ssh-tabAlert{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--ssh-danger);flex:0 0 auto}
|
|
4826
4957
|
.ssh-sessions{flex:1;min-height:0;display:flex;flex-direction:column}
|
|
4827
4958
|
.ssh-sessionBar{flex:0 0 auto;display:flex;align-items:center;gap:10px;padding:7px 12px;border-bottom:.5px solid var(--ssh-border);min-height:48px}
|
|
4828
4959
|
.ssh-sessionTabs{flex:1;min-width:0;display:flex;align-items:center;gap:4px;overflow-x:auto;overflow-y:hidden;padding-bottom:1px}
|
package/index.js
CHANGED
|
@@ -18,9 +18,10 @@ 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
20
|
const PTY_CLEAR_ROUTE = '/dsh-ssh-manage/pty/clear'
|
|
21
|
+
const PTY_TOUCH_ROUTE = '/dsh-ssh-manage/pty/touch'
|
|
21
22
|
const MAX_PTY_SESSIONS = 16
|
|
22
23
|
const PTY_BUFFER_BYTES = 256 * 1024
|
|
23
|
-
const PTY_IDLE_MS =
|
|
24
|
+
const PTY_IDLE_MS = 30 * 60 * 1000
|
|
24
25
|
const MAX_PTY_BUFFER_BYTES = 4 * 1024 * 1024
|
|
25
26
|
const PACKAGE_DIRECTORY = path.dirname(fileURLToPath(import.meta.url))
|
|
26
27
|
const ASSETS = [
|
|
@@ -552,18 +553,48 @@ function registerPtyHttpRoutes(ctx, paths, runtime) {
|
|
|
552
553
|
label,
|
|
553
554
|
)
|
|
554
555
|
}
|
|
556
|
+
|
|
557
|
+
register(
|
|
558
|
+
{
|
|
559
|
+
kind: 'exact',
|
|
560
|
+
path: PTY_TOUCH_ROUTE,
|
|
561
|
+
handler: async (req, res) => {
|
|
562
|
+
if (rejectUnauthenticated(ctx, req, res)) return
|
|
563
|
+
if (req.method !== 'POST') {
|
|
564
|
+
res.statusCode = 405
|
|
565
|
+
res.setHeader('allow', 'POST')
|
|
566
|
+
res.end()
|
|
567
|
+
return
|
|
568
|
+
}
|
|
569
|
+
const body = (await readJsonBody(req)) ?? {}
|
|
570
|
+
const ids = Array.isArray(body.ids) ? body.ids.slice(0, 256) : []
|
|
571
|
+
const now = Date.now()
|
|
572
|
+
let touched = 0
|
|
573
|
+
for (const id of ids) {
|
|
574
|
+
if (typeof id !== 'string') continue
|
|
575
|
+
const entry = ptyTextSessions.get(id)
|
|
576
|
+
if (!entry) continue
|
|
577
|
+
entry.lastActivity = now
|
|
578
|
+
touched += 1
|
|
579
|
+
}
|
|
580
|
+
sendJson(res, 200, { ok: true, value: { touched } })
|
|
581
|
+
},
|
|
582
|
+
},
|
|
583
|
+
'pty touch route',
|
|
584
|
+
)
|
|
555
585
|
}
|
|
556
586
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
587
|
+
/** Close every PTY text session idle beyond PTY_IDLE_MS and return the number reaped. */
|
|
588
|
+
function sweepIdlePtySessions(now = Date.now()) {
|
|
589
|
+
let reaped = 0
|
|
590
|
+
for (const [id, entry] of [...ptyTextSessions]) {
|
|
591
|
+
if (now - entry.lastActivity > PTY_IDLE_MS) {
|
|
592
|
+
closeTextSession(id)
|
|
593
|
+
reaped += 1
|
|
594
|
+
}
|
|
564
595
|
}
|
|
565
|
-
|
|
566
|
-
|
|
596
|
+
return reaped
|
|
597
|
+
}
|
|
567
598
|
|
|
568
599
|
function boundedInteger(value, min, max, fallback) {
|
|
569
600
|
const numeric = Number(value)
|
|
@@ -762,7 +793,7 @@ function registerAssetRoutes(ctx) {
|
|
|
762
793
|
}
|
|
763
794
|
}
|
|
764
795
|
|
|
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 }
|
|
796
|
+
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, PTY_TOUCH_ROUTE, PTY_IDLE_MS, sweepIdlePtySessions, MAX_PTY_SESSIONS, PTY_BUFFER_BYTES, ASSETS, handlePtyUpgrade, ptyTextSessions }
|
|
766
797
|
|
|
767
798
|
export const inject = ['webServer']
|
|
768
799
|
|
|
@@ -772,6 +803,14 @@ export function apply(ctx) {
|
|
|
772
803
|
}
|
|
773
804
|
const paths = resolveSshPaths(process.env)
|
|
774
805
|
const runtime = createPtyRuntime()
|
|
806
|
+
const runtimeIdleTimer = setInterval(() => {
|
|
807
|
+
if (!runtime.active) return
|
|
808
|
+
// Browser heartbeats (/pty/touch) keep attached sessions alive, so a session
|
|
809
|
+
// left behind by a closed tab is reclaimed even while another tab retains
|
|
810
|
+
// an SSE stream. All liveness sources refresh lastActivity.
|
|
811
|
+
sweepIdlePtySessions()
|
|
812
|
+
}, 60_000)
|
|
813
|
+
runtimeIdleTimer.unref?.()
|
|
775
814
|
ctx.effect(
|
|
776
815
|
() =>
|
|
777
816
|
ctx.webServer.register({
|
|
@@ -857,7 +896,7 @@ export function apply(ctx) {
|
|
|
857
896
|
try { shutdown() } catch {}
|
|
858
897
|
}
|
|
859
898
|
runtime.upgrades.clear()
|
|
860
|
-
clearInterval(
|
|
899
|
+
clearInterval(runtimeIdleTimer)
|
|
861
900
|
for (const session of ptySessions) {
|
|
862
901
|
try { session.close() } catch {}
|
|
863
902
|
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
/** Resolve the known_hosts file: SSH_KNOWN_HOSTS, else ~/.ssh/known_hosts. */
|
|
7
|
+
export function knownHostsPath(env = process.env) {
|
|
8
|
+
return env.SSH_KNOWN_HOSTS || path.join(env.HOME || os.homedir(), '.ssh', 'known_hosts')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** OpenSSH-style SHA256 fingerprint of a raw SSH public key blob. */
|
|
12
|
+
export function keyFingerprint(key) {
|
|
13
|
+
const buffer = Buffer.isBuffer(key) ? key : Buffer.from(key ?? '')
|
|
14
|
+
return `SHA256:${createHash('sha256').update(buffer).digest('base64').replace(/=+$/, '')}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Host token used by known_hosts: plain host on port 22, bracketed host:port otherwise. */
|
|
18
|
+
export function hostKeyTarget(host) {
|
|
19
|
+
const hostname = String((host && (host.hostname || host.host)) || '')
|
|
20
|
+
const raw = Number((host && host.port) || 22)
|
|
21
|
+
const port = Number.isInteger(raw) && raw > 0 && raw <= 65535 ? raw : 22
|
|
22
|
+
return port === 22 ? hostname : `[${hostname}]:${port}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function hashedPatternMatches(pattern, target) {
|
|
26
|
+
const parts = pattern.split('|')
|
|
27
|
+
if (parts.length !== 4 || parts[1] !== '1') return false
|
|
28
|
+
let salt
|
|
29
|
+
let expected
|
|
30
|
+
try {
|
|
31
|
+
salt = Buffer.from(parts[2], 'base64')
|
|
32
|
+
expected = Buffer.from(parts[3], 'base64')
|
|
33
|
+
} catch {
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
if (salt.length === 0 || expected.length === 0) return false
|
|
37
|
+
const candidates = target === target.toLowerCase() ? [target] : [target, target.toLowerCase()]
|
|
38
|
+
return candidates.some((candidate) => {
|
|
39
|
+
const actual = createHmac('sha1', salt).update(candidate).digest()
|
|
40
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected)
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function globPatternMatches(pattern, target) {
|
|
45
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')
|
|
46
|
+
return new RegExp(`^${escaped}$`, 'i').test(target)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Match one known_hosts host pattern against a target token (supports hashes, wildcards and negation). */
|
|
50
|
+
export function matchHostPattern(pattern, target) {
|
|
51
|
+
const value = String(pattern ?? '').trim()
|
|
52
|
+
if (value === '') return false
|
|
53
|
+
if (value.startsWith('|1|')) return hashedPatternMatches(value, target)
|
|
54
|
+
if (value.includes('*') || value.includes('?')) return globPatternMatches(value, target)
|
|
55
|
+
return value.toLowerCase() === target.toLowerCase()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function patternsMatch(patterns, target) {
|
|
59
|
+
let matched = false
|
|
60
|
+
for (const raw of patterns) {
|
|
61
|
+
const pattern = String(raw ?? '').trim()
|
|
62
|
+
if (pattern === '') continue
|
|
63
|
+
const negated = pattern.startsWith('!')
|
|
64
|
+
const body = negated ? pattern.slice(1) : pattern
|
|
65
|
+
if (!matchHostPattern(body, target)) continue
|
|
66
|
+
if (negated) return false
|
|
67
|
+
matched = true
|
|
68
|
+
}
|
|
69
|
+
return matched
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Parse a known_hosts file body into `{ marker, patterns, keyType, keyBlob }` entries. */
|
|
73
|
+
export function parseKnownHosts(text = '') {
|
|
74
|
+
const entries = []
|
|
75
|
+
for (const raw of String(text).split(/\r?\n/)) {
|
|
76
|
+
const line = raw.trim()
|
|
77
|
+
if (line === '' || line.startsWith('#')) continue
|
|
78
|
+
const tokens = line.split(/\s+/)
|
|
79
|
+
let index = 0
|
|
80
|
+
let marker = null
|
|
81
|
+
if (tokens[0].startsWith('@')) {
|
|
82
|
+
marker = tokens[0].toLowerCase()
|
|
83
|
+
index = 1
|
|
84
|
+
}
|
|
85
|
+
const patternToken = tokens[index]
|
|
86
|
+
const keyType = tokens[index + 1]
|
|
87
|
+
const keyBlob = tokens[index + 2]
|
|
88
|
+
if (!patternToken || !keyType || !keyBlob) continue
|
|
89
|
+
entries.push({ marker, patterns: patternToken.split(','), keyType, keyBlob, line })
|
|
90
|
+
}
|
|
91
|
+
return entries
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function sameBlob(knownHostsBlob, presented) {
|
|
95
|
+
const known = Buffer.from(String(knownHostsBlob), 'base64')
|
|
96
|
+
return known.length > 0 && known.equals(presented)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Synchronously verify a server host key (raw SSH public key blob) against known_hosts.
|
|
101
|
+
* Returns `{ ok: true, fingerprint, path, target }` or
|
|
102
|
+
* `{ ok: false, reason, fingerprint, path, target }`; mismatches also include `expected`.
|
|
103
|
+
*/
|
|
104
|
+
export function verifyHostKey(host, key, env = process.env) {
|
|
105
|
+
const keyBuffer = Buffer.isBuffer(key) ? key : Buffer.from(key ?? '')
|
|
106
|
+
const fingerprint = keyFingerprint(keyBuffer)
|
|
107
|
+
const target = hostKeyTarget(host)
|
|
108
|
+
const filePath = knownHostsPath(env)
|
|
109
|
+
let text
|
|
110
|
+
try {
|
|
111
|
+
text = readFileSync(filePath, 'utf8')
|
|
112
|
+
} catch (error) {
|
|
113
|
+
const reason = error?.code === 'ENOENT'
|
|
114
|
+
? `known_hosts is missing at ${filePath}`
|
|
115
|
+
: `cannot read known_hosts at ${filePath}: ${error?.message ?? String(error)}`
|
|
116
|
+
return { ok: false, reason, fingerprint, path: filePath, target }
|
|
117
|
+
}
|
|
118
|
+
const entries = parseKnownHosts(text).filter((entry) => patternsMatch(entry.patterns, target))
|
|
119
|
+
const revoked = entries.find((entry) => entry.marker === '@revoked' && sameBlob(entry.keyBlob, keyBuffer))
|
|
120
|
+
if (revoked) return { ok: false, reason: `host key is revoked for ${target}`, fingerprint, path: filePath, target }
|
|
121
|
+
let hostMatched = false
|
|
122
|
+
let expected = ''
|
|
123
|
+
for (const entry of entries) {
|
|
124
|
+
if (entry.marker) continue
|
|
125
|
+
hostMatched = true
|
|
126
|
+
if (sameBlob(entry.keyBlob, keyBuffer)) return { ok: true, fingerprint, path: filePath, target }
|
|
127
|
+
if (expected === '') {
|
|
128
|
+
const blob = Buffer.from(entry.keyBlob, 'base64')
|
|
129
|
+
if (blob.length > 0) expected = keyFingerprint(blob)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (hostMatched) return { ok: false, reason: `host key mismatch for ${target}`, fingerprint, expected, path: filePath, target }
|
|
133
|
+
return { ok: false, reason: `host key is not in known_hosts for ${target}`, fingerprint, path: filePath, target }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const __test = { patternsMatch, globPatternMatches, hashedPatternMatches }
|
package/lib/ssh-client.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Client } from 'ssh2'
|
|
2
|
+
import { verifyHostKey } from './known-hosts.js'
|
|
2
3
|
|
|
3
4
|
const DEFAULT_TIMEOUT = 15_000
|
|
4
5
|
const MAX_OUTPUT_BYTES = 1024 * 1024
|
|
@@ -13,14 +14,28 @@ const PROBE_COMMAND = [
|
|
|
13
14
|
`printf 'SYS=%s\\nOS=%s\\nCORES=%s\\nMEM_MB=%s\\nDISK_MB=%s\\n' "$SYS" "$OS" "$CORES" "$MEM_MB" "$DISK_MB"`,
|
|
14
15
|
].join('\n')
|
|
15
16
|
|
|
16
|
-
function connectionConfig(host, password) {
|
|
17
|
+
function connectionConfig(host, password, verifyState = {}) {
|
|
18
|
+
const label = host.host || host.hostname || ''
|
|
17
19
|
const config = {
|
|
18
20
|
host: host.hostname || host.host,
|
|
19
21
|
port: Number(host.port || 22),
|
|
20
22
|
username: host.user || undefined,
|
|
21
23
|
password,
|
|
22
24
|
readyTimeout: DEFAULT_TIMEOUT,
|
|
25
|
+
keepaliveInterval: 15_000,
|
|
26
|
+
keepaliveCountMax: 3,
|
|
23
27
|
tryKeyboard: true,
|
|
28
|
+
hostVerifier: (key) => {
|
|
29
|
+
// Emergency-only escape hatch: disables host-key verification entirely.
|
|
30
|
+
if (process.env.DSH_SSH_MANAGER_INSECURE_HOSTKEY === '1') return true
|
|
31
|
+
const result = verifyHostKey(host, key)
|
|
32
|
+
if (result.ok) return true
|
|
33
|
+
const expected = result.expected ? ` (expected ${result.expected})` : ''
|
|
34
|
+
verifyState.error = new Error(
|
|
35
|
+
`Host key verification failed for "${label}": ${result.reason}${expected} (fingerprint ${result.fingerprint}; known_hosts: ${result.path})`,
|
|
36
|
+
)
|
|
37
|
+
return false
|
|
38
|
+
},
|
|
24
39
|
}
|
|
25
40
|
if (host.proxyJump) throw new Error('ProxyJump is not supported by the plugin transport yet')
|
|
26
41
|
return config
|
|
@@ -30,6 +45,7 @@ function connect(host, password, timeout = DEFAULT_TIMEOUT) {
|
|
|
30
45
|
if (typeof password !== 'string' || password.length === 0) return Promise.reject(new Error('Password is required'))
|
|
31
46
|
return new Promise((resolve, reject) => {
|
|
32
47
|
const client = new Client()
|
|
48
|
+
const verifyState = {}
|
|
33
49
|
let settled = false
|
|
34
50
|
const timer = setTimeout(() => {
|
|
35
51
|
if (settled) return
|
|
@@ -42,7 +58,8 @@ function connect(host, password, timeout = DEFAULT_TIMEOUT) {
|
|
|
42
58
|
settled = true
|
|
43
59
|
clearTimeout(timer)
|
|
44
60
|
client.end()
|
|
45
|
-
|
|
61
|
+
const reported = verifyState.error ?? error
|
|
62
|
+
reject(reported instanceof Error ? reported : new Error(String(reported)))
|
|
46
63
|
}
|
|
47
64
|
client.once('ready', () => {
|
|
48
65
|
if (settled) return
|
|
@@ -55,7 +72,7 @@ function connect(host, password, timeout = DEFAULT_TIMEOUT) {
|
|
|
55
72
|
client.on('keyboard-interactive', (_name, _instructions, _language, prompts, finish) => {
|
|
56
73
|
finish(prompts.map(() => password))
|
|
57
74
|
})
|
|
58
|
-
try { client.connect(connectionConfig(host, password)) } catch (error) { fail(error) }
|
|
75
|
+
try { client.connect(connectionConfig(host, password, verifyState)) } catch (error) { fail(error) }
|
|
59
76
|
})
|
|
60
77
|
}
|
|
61
78
|
|