dsh-ssh-manage 0.2.2 → 0.3.1

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/index.js CHANGED
@@ -18,15 +18,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
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 = [
27
28
  { path: '/dsh-ssh-manage/assets/xterm.js', file: 'xterm.js', contentType: 'text/javascript; charset=utf-8' },
28
29
  { path: '/dsh-ssh-manage/assets/xterm.css', file: 'xterm.css', contentType: 'text/css; charset=utf-8' },
29
30
  { path: '/dsh-ssh-manage/assets/addon-fit.js', file: 'addon-fit.js', contentType: 'text/javascript; charset=utf-8' },
31
+ { path: '/dsh-ssh-manage/assets/addon-search.js', file: 'addon-search.js', contentType: 'text/javascript; charset=utf-8' },
30
32
  ]
31
33
  const MAX_BODY_BYTES = 16 * 1024 * 1024
32
34
  const ACTIONS = ['overview', 'list_hosts', 'save_host', 'delete_host', 'set_metadata', 'save_password', 'delete_password', 'test_connection', 'inspect_host', 'exec', 'batch_exec', 'sftp_list', 'sftp_upload', 'sftp_download']
@@ -552,18 +554,48 @@ function registerPtyHttpRoutes(ctx, paths, runtime) {
552
554
  label,
553
555
  )
554
556
  }
557
+
558
+ register(
559
+ {
560
+ kind: 'exact',
561
+ path: PTY_TOUCH_ROUTE,
562
+ handler: async (req, res) => {
563
+ if (rejectUnauthenticated(ctx, req, res)) return
564
+ if (req.method !== 'POST') {
565
+ res.statusCode = 405
566
+ res.setHeader('allow', 'POST')
567
+ res.end()
568
+ return
569
+ }
570
+ const body = (await readJsonBody(req)) ?? {}
571
+ const ids = Array.isArray(body.ids) ? body.ids.slice(0, 256) : []
572
+ const now = Date.now()
573
+ let touched = 0
574
+ for (const id of ids) {
575
+ if (typeof id !== 'string') continue
576
+ const entry = ptyTextSessions.get(id)
577
+ if (!entry) continue
578
+ entry.lastActivity = now
579
+ touched += 1
580
+ }
581
+ sendJson(res, 200, { ok: true, value: { touched } })
582
+ },
583
+ },
584
+ 'pty touch route',
585
+ )
555
586
  }
556
587
 
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)
588
+ /** Close every PTY text session idle beyond PTY_IDLE_MS and return the number reaped. */
589
+ function sweepIdlePtySessions(now = Date.now()) {
590
+ let reaped = 0
591
+ for (const [id, entry] of [...ptyTextSessions]) {
592
+ if (now - entry.lastActivity > PTY_IDLE_MS) {
593
+ closeTextSession(id)
594
+ reaped += 1
595
+ }
564
596
  }
565
- }, 60_000)
566
- ptyIdleTimer.unref?.()
597
+ return reaped
598
+ }
567
599
 
568
600
  function boundedInteger(value, min, max, fallback) {
569
601
  const numeric = Number(value)
@@ -762,7 +794,7 @@ function registerAssetRoutes(ctx) {
762
794
  }
763
795
  }
764
796
 
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 }
797
+ 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
798
 
767
799
  export const inject = ['webServer']
768
800
 
@@ -772,6 +804,14 @@ export function apply(ctx) {
772
804
  }
773
805
  const paths = resolveSshPaths(process.env)
774
806
  const runtime = createPtyRuntime()
807
+ const runtimeIdleTimer = setInterval(() => {
808
+ if (!runtime.active) return
809
+ // Browser heartbeats (/pty/touch) keep attached sessions alive, so a session
810
+ // left behind by a closed tab is reclaimed even while another tab retains
811
+ // an SSE stream. All liveness sources refresh lastActivity.
812
+ sweepIdlePtySessions()
813
+ }, 60_000)
814
+ runtimeIdleTimer.unref?.()
775
815
  ctx.effect(
776
816
  () =>
777
817
  ctx.webServer.register({
@@ -857,7 +897,7 @@ export function apply(ctx) {
857
897
  try { shutdown() } catch {}
858
898
  }
859
899
  runtime.upgrades.clear()
860
- clearInterval(ptyIdleTimer)
900
+ clearInterval(runtimeIdleTimer)
861
901
  for (const session of ptySessions) {
862
902
  try { session.close() } catch {}
863
903
  }
@@ -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.1",
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,