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 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:** the transport does not verify `known_hosts` host keys (ssh2 accepts the server-provided key, same as the previous one-shot command path). **Use the interactive terminal only on trusted networks** and do not type sudo passwords on untrusted networks.
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.2.2`).
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.2.2
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.2.2
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.2-rc.3 <0.3.0`** (validated on DSH Desktop `0.2.2-rc.3`) |
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
- - `known_hosts` host-key verification is not implemented; only use it on trusted networks for sensitive input.
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** is not implemented; ssh2 accepts the server-provided key. Use trusted networks only.
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. Only closing the tab or exiting the Host process ends them; orphaned sessions are reaped after 6 hours of inactivity (max 16 sessions).
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
- > **主机密钥校验**:当前传输未接入 `known_hosts` 主机密钥校验,ssh2 默认接受服务器提供的主机密钥(与之前的单条命令执行一致)。**请在可信网络中使用交互式终端**,不要在不可信网络中输入 sudo 密码等敏感信息。
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.2.2`)。
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.2.2
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.2.2
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.2-rc.3 <0.3.0`**(实机验证版本:DSH Desktop `0.2.2-rc.3`) |
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
- - 当前未实现 `known_hosts` 主机密钥校验;请只在可信网络输入敏感信息。
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
- - **主机密钥校验**:未接入 `known_hosts`,ssh2 默认接受服务器主机密钥;仅在可信网络使用。
221
- - **会话生命周期**:终端会话由 Host 保留,切换 DSH 会话/暂时离开插件面板/刷新页面后会重新附着并回放最近约 256 KB 输出;只有关闭标签或 Host 进程退出才真正结束。Host 侧空闲超过 6 小时会清理孤儿会话(最大 16 个)。
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 ? [['sessions', t('tabs.sessions')]] : []),
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 = seq
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 = 6 * 60 * 60 * 1000
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
- const ptyIdleTimer = setInterval(() => {
558
- // An attached SSE client keeps its sessions alive even at a silent prompt;
559
- // only reap orphans after every browser event stream has gone away.
560
- if (ptyEventClients.size > 0) return
561
- const now = Date.now()
562
- for (const [id, entry] of ptyTextSessions) {
563
- if (now - entry.lastActivity > PTY_IDLE_MS) closeTextSession(id)
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
- }, 60_000)
566
- ptyIdleTimer.unref?.()
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(ptyIdleTimer)
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
- reject(error instanceof Error ? error : new Error(String(error)))
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ssh-manage",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "A DSH sidebar plugin for managing SSH host profiles with a real interactive PTY terminal.",
5
5
  "license": "MIT",
6
6
  "private": false,