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/README.en.md +7 -7
- package/README.md +10 -9
- package/client.js +319 -19
- package/index.js +120 -26
- package/lib/known-hosts.js +136 -0
- package/lib/metadata.js +28 -11
- package/lib/ssh-client.js +20 -3
- package/lib/ssh-config.js +15 -2
- package/lib/storage.js +58 -11
- package/lib/vault.js +30 -12
- package/package.json +1 -1
package/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 =
|
|
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,
|
|
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, ...(
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
531
|
-
|
|
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
|
-
|
|
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
|
|
2
|
-
import { atomicWriteFile, readJson, storagePaths } from './storage.js'
|
|
1
|
+
import { atomicWriteFile, readJson, storagePaths, withStoreLock } from './storage.js'
|
|
3
2
|
|
|
4
3
|
function clean(value, max = 80) {
|
|
5
4
|
return String(value ?? '').trim().slice(0, max)
|
|
@@ -27,7 +26,12 @@ function normalizeEntry(alias, input = {}) {
|
|
|
27
26
|
}
|
|
28
27
|
}
|
|
29
28
|
|
|
30
|
-
|
|
29
|
+
function metadataLockKey(paths) {
|
|
30
|
+
return `metadata:${storagePaths(paths).stateDir}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Read the metadata store without taking the store lock; mutators hold it. */
|
|
34
|
+
async function loadMetadataUnlocked(paths) {
|
|
31
35
|
const resolved = storagePaths(paths)
|
|
32
36
|
const value = await readJson(resolved.metadataPath, {})
|
|
33
37
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return { paths: resolved, entries: {} }
|
|
@@ -35,24 +39,37 @@ export async function loadMetadata(paths) {
|
|
|
35
39
|
return { paths: resolved, entries }
|
|
36
40
|
}
|
|
37
41
|
|
|
38
|
-
|
|
42
|
+
/** Write the metadata store without taking the store lock; mutators hold it. */
|
|
43
|
+
async function saveMetadataUnlocked(paths, entries) {
|
|
39
44
|
const resolved = storagePaths(paths)
|
|
40
45
|
const normalized = Object.fromEntries(Object.entries(entries || {}).map(([alias, data]) => [alias, normalizeEntry(alias, data)]))
|
|
41
46
|
await atomicWriteFile(resolved.metadataPath, `${JSON.stringify(normalized, null, 2)}\n`, 0o600)
|
|
42
47
|
return { saved: true }
|
|
43
48
|
}
|
|
44
49
|
|
|
50
|
+
export async function loadMetadata(paths) {
|
|
51
|
+
return loadMetadataUnlocked(paths)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function saveMetadata(paths, entries) {
|
|
55
|
+
return withStoreLock(metadataLockKey(paths), () => saveMetadataUnlocked(paths, entries))
|
|
56
|
+
}
|
|
57
|
+
|
|
45
58
|
export async function updateMetadata(paths, alias, data) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
59
|
+
return withStoreLock(metadataLockKey(paths), async () => {
|
|
60
|
+
const current = await loadMetadataUnlocked(paths)
|
|
61
|
+
current.entries[alias] = normalizeEntry(alias, { ...current.entries[alias], ...data })
|
|
62
|
+
await saveMetadataUnlocked(current.paths, current.entries)
|
|
63
|
+
return current.entries[alias]
|
|
64
|
+
})
|
|
50
65
|
}
|
|
51
66
|
|
|
52
67
|
export async function deleteMetadata(paths, alias) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
68
|
+
return withStoreLock(metadataLockKey(paths), async () => {
|
|
69
|
+
const current = await loadMetadataUnlocked(paths)
|
|
70
|
+
delete current.entries[alias]
|
|
71
|
+
await saveMetadataUnlocked(current.paths, current.entries)
|
|
72
|
+
})
|
|
56
73
|
}
|
|
57
74
|
|
|
58
75
|
export const __test = { normalizeEntry }
|
package/lib/ssh-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
|
|
package/lib/ssh-config.js
CHANGED
|
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto'
|
|
|
2
2
|
import * as fs from 'node:fs/promises'
|
|
3
3
|
import os from 'node:os'
|
|
4
4
|
import path from 'node:path'
|
|
5
|
+
import { withStoreLock } from './storage.js'
|
|
5
6
|
|
|
6
7
|
const HOST_RE = /^[A-Za-z0-9][A-Za-z0-9_.@-]{0,63}$/
|
|
7
8
|
const OPTION_NAMES = new Map([
|
|
@@ -246,13 +247,15 @@ export async function listSshHosts(paths = resolveSshPaths()) {
|
|
|
246
247
|
return { configPath: paths.configPath, hosts: parsed.hosts, exists: text.length > 0 }
|
|
247
248
|
}
|
|
248
249
|
|
|
249
|
-
|
|
250
|
+
async function saveSshHostUnlocked({ paths, host, originalHost }) {
|
|
250
251
|
const nextHost = validateHost(host)
|
|
251
252
|
const aliasesProvided = Array.isArray(host?.aliases) && host.aliases.filter(Boolean).length > 0
|
|
252
253
|
const previous = originalHost ? String(originalHost).trim() : null
|
|
254
|
+
if (previous !== null && (!HOST_RE.test(previous) || isWildPattern(previous))) throw new Error('Invalid SSH host alias')
|
|
253
255
|
const text = await readConfig(paths)
|
|
254
256
|
const parsed = parseSshConfig(text)
|
|
255
257
|
const block = previous ? blockForHost(parsed, previous) : null
|
|
258
|
+
if (previous !== null && !block) throw new Error(`SSH host "${previous}" was not found`)
|
|
256
259
|
const collision = blockForHost(parsed, nextHost.host)
|
|
257
260
|
if (collision && (!block || collision.start !== block.start)) throw new Error(`SSH host "${nextHost.host}" already exists`)
|
|
258
261
|
let lines = parsed.lines
|
|
@@ -274,7 +277,12 @@ export async function saveSshHost({ paths = resolveSshPaths(), host, originalHos
|
|
|
274
277
|
return { saved: true, host: nextHost, backup }
|
|
275
278
|
}
|
|
276
279
|
|
|
277
|
-
export async function
|
|
280
|
+
export async function saveSshHost(options = {}) {
|
|
281
|
+
const paths = options?.paths ?? resolveSshPaths()
|
|
282
|
+
return withStoreLock(`config:${paths.configPath}`, () => saveSshHostUnlocked({ ...options, paths }))
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function deleteSshHostUnlocked({ paths, host }) {
|
|
278
286
|
const name = String(host ?? '').trim()
|
|
279
287
|
if (!HOST_RE.test(name) || isWildPattern(name)) throw new Error('Invalid SSH host alias')
|
|
280
288
|
const text = await readConfig(paths)
|
|
@@ -289,4 +297,9 @@ export async function deleteSshHost({ paths = resolveSshPaths(), host } = {}) {
|
|
|
289
297
|
return { removed: true, backup }
|
|
290
298
|
}
|
|
291
299
|
|
|
300
|
+
export async function deleteSshHost(options = {}) {
|
|
301
|
+
const paths = options?.paths ?? resolveSshPaths()
|
|
302
|
+
return withStoreLock(`config:${paths.configPath}`, () => deleteSshHostUnlocked({ ...options, paths }))
|
|
303
|
+
}
|
|
304
|
+
|
|
292
305
|
export const __test = { patchHostBlock }
|