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/README.en.md +11 -11
- package/README.md +14 -13
- package/assets/LICENSE.addon-search +19 -0
- package/assets/addon-search.js +2 -0
- package/client.js +496 -14
- package/index.js +52 -12
- package/lib/known-hosts.js +136 -0
- package/lib/ssh-client.js +20 -3
- package/package.json +1 -1
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 =
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
566
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|