dsh-ssh-manage 0.2.1 → 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/index.js CHANGED
@@ -17,9 +17,11 @@ 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'
21
+ const PTY_TOUCH_ROUTE = '/dsh-ssh-manage/pty/touch'
20
22
  const MAX_PTY_SESSIONS = 16
21
23
  const PTY_BUFFER_BYTES = 256 * 1024
22
- const PTY_IDLE_MS = 6 * 60 * 60 * 1000
24
+ const PTY_IDLE_MS = 30 * 60 * 1000
23
25
  const MAX_PTY_BUFFER_BYTES = 4 * 1024 * 1024
24
26
  const PACKAGE_DIRECTORY = path.dirname(fileURLToPath(import.meta.url))
25
27
  const ASSETS = [
@@ -75,14 +77,26 @@ function errorMessage(error) {
75
77
  }
76
78
 
77
79
  async function state(paths) {
78
- const [current, metadata, passwords] = await Promise.all([listSshHosts(paths), loadMetadata(paths), listPasswordAliases(paths)])
80
+ const [current, metadataResult, passwordsResult] = await Promise.all([
81
+ listSshHosts(paths),
82
+ loadMetadata(paths).then((value) => ({ value }), (error) => ({ error })),
83
+ listPasswordAliases(paths).then((value) => ({ value }), (error) => ({ error })),
84
+ ])
85
+ const warnings = []
86
+ let entries = {}
87
+ if ('error' in metadataResult) warnings.push({ store: 'metadata', message: errorMessage(metadataResult.error) })
88
+ else entries = metadataResult.value.entries
89
+ let passwords = []
90
+ if ('error' in passwordsResult) warnings.push({ store: 'vault', message: errorMessage(passwordsResult.error) })
91
+ else passwords = passwordsResult.value
79
92
  const passwordSet = new Set(passwords)
80
93
  const stateDir = storagePaths(paths).stateDir
81
94
  return {
82
95
  ...current,
83
96
  stateDir,
84
97
  backupDir: paths.backupDir || path.join(stateDir, 'backups'),
85
- hosts: current.hosts.map((host) => ({ ...host, ...(metadata.entries[host.host] || {}), passwordConfigured: passwordSet.has(host.host) })),
98
+ hosts: current.hosts.map((host) => ({ ...host, ...(entries[host.host] || {}), passwordConfigured: passwordSet.has(host.host) })),
99
+ warnings,
86
100
  }
87
101
  }
88
102
 
@@ -282,6 +296,16 @@ const ptyPendingIds = new Set()
282
296
  const ptyEventClients = new Set()
283
297
  let ptyOpening = 0
284
298
 
299
+ function createPtyRuntime() {
300
+ return { generation: 0, active: true, upgrades: new Set() }
301
+ }
302
+
303
+ const defaultPtyRuntime = createPtyRuntime()
304
+
305
+ function isPtyRuntimeActive(runtime, generation) {
306
+ return runtime.active && runtime.generation === generation
307
+ }
308
+
285
309
  function closeTextSession(id, notify = true) {
286
310
  const entry = ptyTextSessions.get(id)
287
311
  if (!entry) return
@@ -311,15 +335,17 @@ function broadcastPty(event) {
311
335
  for (const client of [...ptyEventClients]) writePtyEvent(client, event)
312
336
  }
313
337
 
314
- async function openTextSession(paths, payload) {
338
+ async function openTextSession(paths, payload, runtime = defaultPtyRuntime) {
339
+ if (!runtime.active) throw new Error('Terminal runtime is not active')
315
340
  const id = typeof payload?.id === 'string' ? payload.id.trim() : ''
316
341
  if (!/^[A-Za-z0-9_-]{8,80}$/.test(id)) throw new Error('A valid session id is required')
342
+ const cols = boundedInteger(payload?.cols, 20, 500, 120)
343
+ const rows = boundedInteger(payload?.rows, 5, 300, 32)
344
+ const alias = typeof payload?.host === 'string' ? payload.host.trim() : ''
345
+ if (alias === '') throw new Error('host is required')
317
346
  if (ptyTextSessions.has(id) || ptyPendingIds.has(id)) throw new Error('Terminal session already exists')
347
+ const generation = runtime.generation
318
348
  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
349
  ptyOpening += 1
324
350
  try {
325
351
  if (ptySessions.size + ptyTextSessions.size + ptyOpening > MAX_PTY_SESSIONS) {
@@ -327,7 +353,12 @@ async function openTextSession(paths, payload) {
327
353
  }
328
354
  const host = await findHost(paths, alias)
329
355
  const password = await requirePassword(paths, host)
356
+ if (!isPtyRuntimeActive(runtime, generation)) throw new Error('Terminal runtime is not active')
330
357
  const session = await openPty(host, password, { cols, rows })
358
+ if (!isPtyRuntimeActive(runtime, generation)) {
359
+ try { session.close() } catch {}
360
+ throw new Error('Terminal runtime is not active')
361
+ }
331
362
  if (ptyTextSessions.has(id)) {
332
363
  try { session.close() } catch {}
333
364
  throw new Error('Terminal session already exists')
@@ -363,7 +394,7 @@ async function openTextSession(paths, payload) {
363
394
  }
364
395
  }
365
396
 
366
- function registerPtyHttpRoutes(ctx, paths) {
397
+ function registerPtyHttpRoutes(ctx, paths, runtime) {
367
398
  const register = (route, label) => {
368
399
  ctx.effect(() => ctx.webServer.register(route), `dsh-ssh-manage: ${label}`)
369
400
  }
@@ -423,7 +454,7 @@ function registerPtyHttpRoutes(ctx, paths) {
423
454
  }
424
455
  const body = await readJsonBody(req)
425
456
  try {
426
- const value = await openTextSession(paths, body ?? {})
457
+ const value = await openTextSession(paths, body ?? {}, runtime)
427
458
  sendJson(res, 200, { ok: true, value })
428
459
  } catch (error) {
429
460
  sendJson(res, 200, { ok: false, error: { code: 'pty-open-failed', message: errorMessage(error) } })
@@ -473,6 +504,7 @@ function registerPtyHttpRoutes(ctx, paths) {
473
504
  [PTY_INPUT_ROUTE, 'input', 'pty input route'],
474
505
  [PTY_RESIZE_ROUTE, 'resize', 'pty resize route'],
475
506
  [PTY_CLOSE_ROUTE, 'close', 'pty close route'],
507
+ [PTY_CLEAR_ROUTE, 'clear', 'pty clear route'],
476
508
  ]) {
477
509
  register(
478
510
  {
@@ -504,6 +536,10 @@ function registerPtyHttpRoutes(ctx, paths) {
504
536
  0,
505
537
  0,
506
538
  )
539
+ } else if (action === 'clear') {
540
+ entry.buffer = []
541
+ entry.bufferBytes = 0
542
+ broadcastPty({ kind: 'clear', id })
507
543
  } else {
508
544
  closeTextSession(id)
509
545
  }
@@ -517,18 +553,48 @@ function registerPtyHttpRoutes(ctx, paths) {
517
553
  label,
518
554
  )
519
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
+ )
520
585
  }
521
586
 
522
- const ptyIdleTimer = setInterval(() => {
523
- // An attached SSE client keeps its sessions alive even at a silent prompt;
524
- // only reap orphans after every browser event stream has gone away.
525
- if (ptyEventClients.size > 0) return
526
- const now = Date.now()
527
- for (const [id, entry] of ptyTextSessions) {
528
- 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
+ }
529
595
  }
530
- }, 60_000)
531
- ptyIdleTimer.unref?.()
596
+ return reaped
597
+ }
532
598
 
533
599
  function boundedInteger(value, min, max, fallback) {
534
600
  const numeric = Number(value)
@@ -554,7 +620,11 @@ function upgradeRejection(ctx, req) {
554
620
  return rejection === undefined ? undefined : rejection
555
621
  }
556
622
 
557
- async function handlePtyUpgrade(ctx, paths, req, socket, head) {
623
+ async function handlePtyUpgrade(ctx, paths, req, socket, head, runtime = defaultPtyRuntime) {
624
+ if (!runtime.active) {
625
+ denyUpgrade(socket, 403)
626
+ return
627
+ }
558
628
  if (!isWebSocketUpgrade(req)) {
559
629
  try { socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n') } catch {}
560
630
  socket.destroy()
@@ -567,22 +637,30 @@ async function handlePtyUpgrade(ctx, paths, req, socket, head) {
567
637
  }
568
638
  const ws = acceptWebSocket(req, socket, head)
569
639
  if (!ws) return
640
+ const generation = runtime.generation
570
641
  let session = null
571
642
  let opening = false
572
643
  let cleaned = false
573
644
  let paused = false
645
+ let shutdown = null
574
646
  const keepalive = setInterval(() => ws.ping(), 25_000)
575
647
  keepalive.unref?.()
576
648
  const cleanup = () => {
577
649
  if (cleaned) return
578
650
  cleaned = true
579
651
  clearInterval(keepalive)
652
+ if (shutdown) runtime.upgrades.delete(shutdown)
580
653
  if (session) {
581
654
  ptySessions.delete(session)
582
655
  try { session.close() } catch {}
583
656
  session = null
584
657
  }
585
658
  }
659
+ shutdown = () => {
660
+ cleanup()
661
+ try { ws.close(1001) } catch {}
662
+ }
663
+ runtime.upgrades.add(shutdown)
586
664
  ws.on('close', cleanup)
587
665
  socket.on('drain', () => {
588
666
  if (!paused || !session) return
@@ -599,7 +677,7 @@ async function handlePtyUpgrade(ctx, paths, req, socket, head) {
599
677
  }
600
678
  if (!message || typeof message.type !== 'string' || cleaned) return
601
679
  if (!session) {
602
- if (message.type !== 'open' || opening) return
680
+ if (message.type !== 'open' || opening || !runtime.active) return
603
681
  opening = true
604
682
  ptyOpening += 1
605
683
  try {
@@ -610,8 +688,9 @@ async function handlePtyUpgrade(ctx, paths, req, socket, head) {
610
688
  const rows = boundedInteger(message.rows, 5, 300, 32)
611
689
  const host = await findHost(paths, alias)
612
690
  const password = await requirePassword(paths, host)
691
+ if (!isPtyRuntimeActive(runtime, generation)) throw new Error('Terminal runtime is not active')
613
692
  session = await openPty(host, password, { cols, rows })
614
- if (cleaned) {
693
+ if (cleaned || !isPtyRuntimeActive(runtime, generation)) {
615
694
  try { session.close() } catch {}
616
695
  session = null
617
696
  return
@@ -714,7 +793,7 @@ function registerAssetRoutes(ctx) {
714
793
  }
715
794
  }
716
795
 
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 }
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 }
718
797
 
719
798
  export const inject = ['webServer']
720
799
 
@@ -723,6 +802,15 @@ export function apply(ctx) {
723
802
  throw new Error('dsh-ssh-manage requires DeepSeek Harness >=0.2.1-rc.3: ctx.webServer.register is unavailable.')
724
803
  }
725
804
  const paths = resolveSshPaths(process.env)
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?.()
726
814
  ctx.effect(
727
815
  () =>
728
816
  ctx.webServer.register({
@@ -772,7 +860,7 @@ export function apply(ctx) {
772
860
  }),
773
861
  'dsh-ssh-manage: action route',
774
862
  )
775
- registerPtyHttpRoutes(ctx, paths)
863
+ registerPtyHttpRoutes(ctx, paths, runtime)
776
864
  registerAssetRoutes(ctx)
777
865
  if (typeof ctx.webServer.tapIndex === 'function') {
778
866
  const tags = [
@@ -793,7 +881,7 @@ export function apply(ctx) {
793
881
  ctx.webServer.registerUpgrade({
794
882
  path: PTY_ROUTE,
795
883
  handler: (req, socket, head) => {
796
- void handlePtyUpgrade(ctx, paths, req, socket, head).catch(() => {
884
+ void handlePtyUpgrade(ctx, paths, req, socket, head, runtime).catch(() => {
797
885
  try { socket.destroy() } catch {}
798
886
  })
799
887
  },
@@ -802,7 +890,13 @@ export function apply(ctx) {
802
890
  )
803
891
  ctx.effect(
804
892
  () => () => {
805
- clearInterval(ptyIdleTimer)
893
+ runtime.active = false
894
+ runtime.generation += 1
895
+ for (const shutdown of [...runtime.upgrades]) {
896
+ try { shutdown() } catch {}
897
+ }
898
+ runtime.upgrades.clear()
899
+ clearInterval(runtimeIdleTimer)
806
900
  for (const session of ptySessions) {
807
901
  try { session.close() } catch {}
808
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/metadata.js CHANGED
@@ -1,5 +1,4 @@
1
- import * as fs from 'node:fs/promises'
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
- export async function loadMetadata(paths) {
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
- export async function saveMetadata(paths, entries) {
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
- const current = await loadMetadata(paths)
47
- current.entries[alias] = normalizeEntry(alias, { ...current.entries[alias], ...data })
48
- await saveMetadata(current.paths, current.entries)
49
- return current.entries[alias]
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
- const current = await loadMetadata(paths)
54
- delete current.entries[alias]
55
- await saveMetadata(current.paths, current.entries)
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-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/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
- export async function saveSshHost({ paths = resolveSshPaths(), host, originalHost } = {}) {
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 deleteSshHost({ paths = resolveSshPaths(), host } = {}) {
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 }