dsh-ssh-tunnel 1.0.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/CHANGELOG.md +305 -0
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/README_en.md +171 -0
- package/cordis.patch.yml +4 -0
- package/lib/client.js +1472 -0
- package/lib/index.js +1611 -0
- package/lib/session.js +525 -0
- package/lib/shared/host-key.js +32 -0
- package/lib/shared/host-summary.js +75 -0
- package/lib/shared/http-trust.js +59 -0
- package/lib/shared/path.js +156 -0
- package/lib/shared/persist.js +41 -0
- package/lib/shared/session-auth.js +39 -0
- package/lib/shared/session-policy.js +125 -0
- package/lib/shared/shell-buffer.js +10 -0
- package/lib/shared/vendor.js +50 -0
- package/package.json +84 -0
- package/scripts/install.ps1 +148 -0
- package/scripts/install.sh +226 -0
- package/scripts/portal-probe.mjs +106 -0
- package/scripts/smoke-test.mjs +511 -0
- package/scripts/sync-to-dsh.sh +32 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Offline smoke tests (no SSH, no DSH process).
|
|
4
|
+
* Run: node scripts/smoke-test.mjs
|
|
5
|
+
*/
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
import { tmpdir } from 'node:os'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
import { existsSync, mkdirSync, realpathSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
10
|
+
import { normalizeProjectKey, isPathInsideRoots, joinUnderRoot, constrainToWorkspace, workspaceRoot, workspaceRoots } from '../lib/shared/path.js'
|
|
11
|
+
import {
|
|
12
|
+
hostCredentialConfigured,
|
|
13
|
+
publicHost,
|
|
14
|
+
modelHostSummary,
|
|
15
|
+
assertNoSecretFields,
|
|
16
|
+
} from '../lib/shared/host-summary.js'
|
|
17
|
+
import { chunkFromBuffer } from '../lib/shared/shell-buffer.js'
|
|
18
|
+
import {
|
|
19
|
+
fingerprintFromHostKey,
|
|
20
|
+
formatHostFingerprint,
|
|
21
|
+
isUsableHostFingerprint,
|
|
22
|
+
} from '../lib/shared/host-key.js'
|
|
23
|
+
import {
|
|
24
|
+
clampTimeoutMs,
|
|
25
|
+
defaultTimeoutMsForAction,
|
|
26
|
+
findReusableLive,
|
|
27
|
+
pickOldestTombstone,
|
|
28
|
+
withTimeout,
|
|
29
|
+
SSH_TIMEOUT_MIN_MS,
|
|
30
|
+
SSH_TIMEOUT_MAX_MS,
|
|
31
|
+
SSH_EXEC_DEFAULT_TIMEOUT_MS,
|
|
32
|
+
SSH_SFTP_TRANSFER_TIMEOUT_MS,
|
|
33
|
+
SSH_STATUS_CONNECTED,
|
|
34
|
+
SSH_STATUS_DISCONNECTED,
|
|
35
|
+
disconnectedSessionError,
|
|
36
|
+
kbiUnsupportedError,
|
|
37
|
+
} from '../lib/shared/session-policy.js'
|
|
38
|
+
import { createSessionRegistry } from '../lib/session.js'
|
|
39
|
+
import { isLoopbackHostname, isTrustedRequest } from '../lib/shared/http-trust.js'
|
|
40
|
+
import {
|
|
41
|
+
findExistingHostByEndpoint,
|
|
42
|
+
readJsonFile,
|
|
43
|
+
writeJsonAtomic,
|
|
44
|
+
} from '../lib/shared/persist.js'
|
|
45
|
+
import {
|
|
46
|
+
assertSessionStillAuthorized,
|
|
47
|
+
discardRevokedLocalFile,
|
|
48
|
+
} from '../lib/shared/session-auth.js'
|
|
49
|
+
import {
|
|
50
|
+
VENDOR_ASSETS,
|
|
51
|
+
isVendorAssetAllowed,
|
|
52
|
+
resolveVendorAsset,
|
|
53
|
+
} from '../lib/shared/vendor.js'
|
|
54
|
+
|
|
55
|
+
// Hermetic workspace root for the path guard: point it at a throwaway directory
|
|
56
|
+
// so the suite runs on hosts whose real workspace is not `/workspace` (for
|
|
57
|
+
// example Windows drive paths). workspaceRoot() reads the variable at call time.
|
|
58
|
+
const WS_ROOT = join(tmpdir(), `dsh-ssh-ws-${process.pid}`)
|
|
59
|
+
mkdirSync(WS_ROOT, { recursive: true })
|
|
60
|
+
process.env.DSH_SSH_TUNNEL_WORKSPACE_ROOT = WS_ROOT
|
|
61
|
+
const wsPath = (...segs) => normalizeProjectKey(join(WS_ROOT, ...segs))
|
|
62
|
+
const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
63
|
+
|
|
64
|
+
let failed = 0
|
|
65
|
+
const pending = []
|
|
66
|
+
function test(name, fn) {
|
|
67
|
+
const run = () => {
|
|
68
|
+
try {
|
|
69
|
+
const result = fn()
|
|
70
|
+
if (result && typeof result.then === 'function') {
|
|
71
|
+
return result.then(
|
|
72
|
+
() => console.log('ok ', name),
|
|
73
|
+
(e) => {
|
|
74
|
+
failed++
|
|
75
|
+
console.error('FAIL', name, e && e.message ? e.message : e)
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
console.log('ok ', name)
|
|
80
|
+
} catch (e) {
|
|
81
|
+
failed++
|
|
82
|
+
console.error('FAIL', name, e && e.message ? e.message : e)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const out = run()
|
|
86
|
+
if (out && typeof out.then === 'function') pending.push(out)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
test('normalizeProjectKey strips trailing slash', () => {
|
|
90
|
+
const key = normalizeProjectKey(wsPath('DSH-plugin') + '/')
|
|
91
|
+
assert.equal(key, wsPath('DSH-plugin'))
|
|
92
|
+
assert.ok(!key.endsWith('/') && !key.endsWith('\\'), 'no trailing separator')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('normalizeProjectKey empty', () => {
|
|
96
|
+
assert.equal(normalizeProjectKey(''), '')
|
|
97
|
+
assert.equal(normalizeProjectKey(null), '')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('isPathInsideRoots allows children only', () => {
|
|
101
|
+
const root = wsPath('DSH-plugin')
|
|
102
|
+
assert.equal(isPathInsideRoots(wsPath('DSH-plugin', 'a'), [root]), true)
|
|
103
|
+
assert.equal(isPathInsideRoots(wsPath('other'), [root]), false)
|
|
104
|
+
assert.equal(isPathInsideRoots(root, [root]), true)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('isPathInsideRoots accepts children of filesystem roots', () => {
|
|
108
|
+
const root = normalizeProjectKey('/')
|
|
109
|
+
assert.equal(isPathInsideRoots(normalizeProjectKey('/etc/passwd'), [root]), true)
|
|
110
|
+
assert.equal(isPathInsideRoots(root, [root]), true)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('joinUnderRoot rejects escape', () => {
|
|
114
|
+
const root = wsPath('DSH-plugin')
|
|
115
|
+
assert.throws(() => joinUnderRoot(root, join('..', '..', 'etc', 'passwd')))
|
|
116
|
+
const ok = joinUnderRoot(root, join('sub', 'file.txt'))
|
|
117
|
+
assert.equal(ok, wsPath('DSH-plugin', 'sub', 'file.txt'))
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('constrainToWorkspace rejects lexical escape', () => {
|
|
121
|
+
const re = new RegExp('must be under ' + escRe(workspaceRoot()))
|
|
122
|
+
assert.throws(() => constrainToWorkspace('/home/node/.dsh/ssh-tunnel/secrets.json'), re)
|
|
123
|
+
assert.throws(() => constrainToWorkspace('/tmp'), re)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// The guard used to know only `/workspace`, so on hosts whose project
|
|
127
|
+
// workspace is a Windows drive path every real local path was rejected and
|
|
128
|
+
// SFTP upload/download never started. The project workspace the host passes at
|
|
129
|
+
// call time is now an allowed root (the env override stays allowed too).
|
|
130
|
+
test('constrainToWorkspace accepts the project workspace root', () => {
|
|
131
|
+
const project = join(realpathSync(WS_ROOT), 'DSH-project')
|
|
132
|
+
mkdirSync(project, { recursive: true })
|
|
133
|
+
const file = join(project, 'upload.txt')
|
|
134
|
+
writeFileSync(file, 'probe', 'utf8')
|
|
135
|
+
assert.equal(constrainToWorkspace(file, { root: project }), file)
|
|
136
|
+
assert.equal(
|
|
137
|
+
constrainToWorkspace(join('nested', 'child.txt'), { root: project }),
|
|
138
|
+
join(project, 'nested', 'child.txt'),
|
|
139
|
+
)
|
|
140
|
+
assert.equal(constrainToWorkspace(wsPath('DSH-plugin', 'a'), { root: project }), wsPath('DSH-plugin', 'a'))
|
|
141
|
+
assert.throws(
|
|
142
|
+
() => constrainToWorkspace(join(tmpdir(), 'dsh-ssh-outside-probe.txt'), { root: project }),
|
|
143
|
+
/must be under/,
|
|
144
|
+
)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('workspaceRoots lists the project workspace then the override', () => {
|
|
148
|
+
const project = normalizeProjectKey(join(realpathSync(WS_ROOT), 'DSH-project'))
|
|
149
|
+
assert.deepEqual(workspaceRoots(), [normalizeProjectKey(WS_ROOT)])
|
|
150
|
+
assert.deepEqual(workspaceRoots(project), [project, normalizeProjectKey(WS_ROOT)])
|
|
151
|
+
assert.equal(workspaceRoot(project), project)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('persist: corrupt json throws; atomic write replaces', () => {
|
|
155
|
+
const dir = join(tmpdir(), `dsh-ssh-persist-${process.pid}`)
|
|
156
|
+
mkdirSync(dir, { recursive: true })
|
|
157
|
+
const p = join(dir, 'secrets.json')
|
|
158
|
+
writeFileSync(p, '{not json', 'utf8')
|
|
159
|
+
assert.throws(() => readJsonFile(p, { version: 1, byHostId: {} }), /corrupt json/)
|
|
160
|
+
writeJsonAtomic(p, { version: 1, byHostId: { h1: { password: 'x' } } }, 0o600)
|
|
161
|
+
const loaded = readJsonFile(p, null)
|
|
162
|
+
assert.equal(loaded.byHostId.h1.password, 'x')
|
|
163
|
+
assert.equal(existsSync(p + '.tmp'), false)
|
|
164
|
+
assert.equal(findExistingHostByEndpoint(
|
|
165
|
+
[{ host: 'a', port: 22, username: 'root', id: '1' }],
|
|
166
|
+
'a',
|
|
167
|
+
22,
|
|
168
|
+
'root',
|
|
169
|
+
)?.id, '1')
|
|
170
|
+
assert.equal(findExistingHostByEndpoint(
|
|
171
|
+
[{ host: 'a', port: 22, username: 'root', id: '1' }],
|
|
172
|
+
'a',
|
|
173
|
+
22,
|
|
174
|
+
'other',
|
|
175
|
+
), undefined)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('http-trust: 0.0.0.0 is not loopback; evil Origin is rejected', () => {
|
|
179
|
+
assert.equal(isLoopbackHostname('127.0.0.1'), true)
|
|
180
|
+
assert.equal(isLoopbackHostname('::1'), true)
|
|
181
|
+
assert.equal(isLoopbackHostname('0.0.0.0'), false)
|
|
182
|
+
const loopReq = { headers: { host: '127.0.0.1:3080' } }
|
|
183
|
+
assert.equal(isTrustedRequest(loopReq, []), true)
|
|
184
|
+
const zeroReq = { headers: { host: '0.0.0.0:3080' } }
|
|
185
|
+
assert.equal(isTrustedRequest(zeroReq, []), false)
|
|
186
|
+
const csrf = { headers: { host: '127.0.0.1:3080', origin: 'http://evil.example' } }
|
|
187
|
+
assert.equal(isTrustedRequest(csrf, []), false)
|
|
188
|
+
const sameOrigin = { headers: { host: '127.0.0.1:3080', origin: 'http://127.0.0.1:3080' } }
|
|
189
|
+
assert.equal(isTrustedRequest(sameOrigin, []), true)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('constrainToWorkspace rejects outbound symlink', () => {
|
|
193
|
+
const base = wsPath('DSH-plugin')
|
|
194
|
+
const link = join(base, '.audit-path-guard-link')
|
|
195
|
+
mkdirSync(base, { recursive: true })
|
|
196
|
+
try {
|
|
197
|
+
try { unlinkSync(link) } catch {}
|
|
198
|
+
try {
|
|
199
|
+
// The link target must exist for the realpath walk to observe the escape.
|
|
200
|
+
symlinkSync(tmpdir(), link, 'dir')
|
|
201
|
+
} catch (e) {
|
|
202
|
+
if (e && (e.code === 'EPERM' || e.code === 'EACCES' || e.code === 'ENOSYS')) {
|
|
203
|
+
console.log('skip constrainToWorkspace rejects outbound symlink (symlink unavailable)')
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
throw e
|
|
207
|
+
}
|
|
208
|
+
assert.throws(
|
|
209
|
+
() => constrainToWorkspace(join(link, 'ssh-tunnel', '_nope')),
|
|
210
|
+
new RegExp('escapes ' + escRe(workspaceRoot()) + ' via symlink'),
|
|
211
|
+
)
|
|
212
|
+
const unlinked = constrainToWorkspace(link, { forUnlink: true })
|
|
213
|
+
assert.equal(unlinked, normalizeProjectKey(link))
|
|
214
|
+
} finally {
|
|
215
|
+
try { unlinkSync(link) } catch {}
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test('credential + publicHost never leak secrets', () => {
|
|
220
|
+
const host = {
|
|
221
|
+
id: 'h1',
|
|
222
|
+
name: 'n',
|
|
223
|
+
host: '1.2.3.4',
|
|
224
|
+
port: 22,
|
|
225
|
+
username: 'root',
|
|
226
|
+
authType: 'password',
|
|
227
|
+
}
|
|
228
|
+
const secrets = { byHostId: { h1: { password: 's3cret', privateKeyPem: 'BEGIN' } } }
|
|
229
|
+
assert.equal(hostCredentialConfigured(host, secrets), true)
|
|
230
|
+
const pub = publicHost(host, secrets)
|
|
231
|
+
assert.equal(pub.passwordConfigured, true)
|
|
232
|
+
assert.equal(pub.credentialStatus, 'saved')
|
|
233
|
+
assert.equal('password' in pub, false)
|
|
234
|
+
assert.equal('privateKeyPem' in pub, false)
|
|
235
|
+
assertNoSecretFields(pub)
|
|
236
|
+
const model = modelHostSummary(host, secrets)
|
|
237
|
+
assertNoSecretFields(model)
|
|
238
|
+
assert.equal(model.credentialStatus, 'saved')
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test('retired keyboard-interactive is unsupported, not a usable credential', () => {
|
|
242
|
+
const host = { id: 'h2', authType: 'keyboardInteractive', host: 'x', username: 'u' }
|
|
243
|
+
const pub = publicHost(host, { byHostId: {} })
|
|
244
|
+
assert.equal(pub.credentialStatus, 'unsupported')
|
|
245
|
+
assert.equal(pub.credentialConfigured, false)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test('chunkFromBuffer incremental', () => {
|
|
249
|
+
const a = chunkFromBuffer('hello', 0)
|
|
250
|
+
assert.equal(a.chunk, 'hello')
|
|
251
|
+
assert.equal(a.length, 5)
|
|
252
|
+
const b = chunkFromBuffer('hello world', 5)
|
|
253
|
+
assert.equal(b.chunk, ' world')
|
|
254
|
+
assert.equal(b.since, 5)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
test('host key fingerprint is stable sha256 hex (no UTF-8 collision)', () => {
|
|
258
|
+
const prefix = Buffer.concat([
|
|
259
|
+
Buffer.from([0, 0, 0, 11]),
|
|
260
|
+
Buffer.from('ssh-ed25519'),
|
|
261
|
+
Buffer.from([0, 0, 0, 32]),
|
|
262
|
+
])
|
|
263
|
+
const k1 = Buffer.concat([prefix, Buffer.alloc(32, 0x80)])
|
|
264
|
+
const k2 = Buffer.concat([prefix, Buffer.alloc(32, 0x81)])
|
|
265
|
+
// Legacy bug: String(buf) collapsed different keys via U+FFFD.
|
|
266
|
+
assert.equal(String(k1) === String(k2), true)
|
|
267
|
+
const f1 = fingerprintFromHostKey(k1)
|
|
268
|
+
const f2 = fingerprintFromHostKey(k2)
|
|
269
|
+
assert.equal(isUsableHostFingerprint(f1), true)
|
|
270
|
+
assert.equal(isUsableHostFingerprint(f2), true)
|
|
271
|
+
assert.notEqual(f1, f2)
|
|
272
|
+
// ssh2 hostHash path: already-hex digest is kept as-is (lowercased).
|
|
273
|
+
assert.equal(fingerprintFromHostKey(f1.toUpperCase()), f1)
|
|
274
|
+
assert.equal(formatHostFingerprint(f1), 'SHA256:' + f1)
|
|
275
|
+
// Legacy binary stored strings are rejected as unusable.
|
|
276
|
+
assert.equal(isUsableHostFingerprint(String(k1)), false)
|
|
277
|
+
assert.equal(isUsableHostFingerprint('\u0000ssh-ed25519'), false)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test('clampTimeoutMs bounds and fallback', () => {
|
|
281
|
+
assert.equal(clampTimeoutMs(undefined, 30_000), 30_000)
|
|
282
|
+
assert.equal(clampTimeoutMs(0, 30_000), 30_000)
|
|
283
|
+
assert.equal(clampTimeoutMs(500, 30_000), SSH_TIMEOUT_MIN_MS)
|
|
284
|
+
assert.equal(clampTimeoutMs(999_999, 30_000), SSH_TIMEOUT_MAX_MS)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
test('defaultTimeoutMsForAction', () => {
|
|
288
|
+
assert.equal(defaultTimeoutMsForAction('exec'), SSH_EXEC_DEFAULT_TIMEOUT_MS)
|
|
289
|
+
assert.equal(defaultTimeoutMsForAction('sftp_upload'), SSH_SFTP_TRANSFER_TIMEOUT_MS)
|
|
290
|
+
assert.equal(defaultTimeoutMsForAction('sftp_list'), 30_000)
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
test('reuse live vs tombstone pick', () => {
|
|
294
|
+
const key = '/workspace'
|
|
295
|
+
const sessions = [
|
|
296
|
+
{ id: 'dead', hostId: 'h1', projectPathKey: key, status: 'disconnected', running: false, sftpEnabled: true, createdAt: 1 },
|
|
297
|
+
{ id: 'live', hostId: 'h1', projectPathKey: key, status: 'connected', running: true, sftpEnabled: true, createdAt: 2 },
|
|
298
|
+
]
|
|
299
|
+
assert.equal(findReusableLive(sessions, { projectPathKey: key, hostId: 'h1', needsSftp: true }).id, 'live')
|
|
300
|
+
assert.equal(pickOldestTombstone(sessions, { projectPathKey: key, hostId: 'h1', needsSftp: true }).id, 'dead')
|
|
301
|
+
assert.equal(
|
|
302
|
+
findReusableLive(
|
|
303
|
+
sessions.filter((s) => s.id === 'dead'),
|
|
304
|
+
{ projectPathKey: key, hostId: 'h1', needsSftp: true },
|
|
305
|
+
),
|
|
306
|
+
undefined,
|
|
307
|
+
)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
test('withTimeout rejects and calls onTimeout', async () => {
|
|
311
|
+
let hit = false
|
|
312
|
+
await assert.rejects(
|
|
313
|
+
() => withTimeout(new Promise(() => {}), 1000, { onTimeout: () => { hit = true } }),
|
|
314
|
+
(e) => e.timedOut === true && hit === true,
|
|
315
|
+
)
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
test('disconnectedSessionError is stable English for the model', () => {
|
|
319
|
+
assert.match(disconnectedSessionError(), /disconnected/i)
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
test('kbiUnsupportedError tells the model to switch auth', () => {
|
|
323
|
+
assert.match(kbiUnsupportedError(), /not supported/i)
|
|
324
|
+
assert.match(kbiUnsupportedError(), /password or private key/i)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
/* ------------------------------------------------------------------ *
|
|
328
|
+
* Security regression: revoking a host's project grant must make its *
|
|
329
|
+
* established tunnel sessions unusable AND unreconnectable, and revoke *
|
|
330
|
+
* must terminate those sessions (DSH plugin upgrade audit 2026-09-08). *
|
|
331
|
+
* ------------------------------------------------------------------ */
|
|
332
|
+
|
|
333
|
+
function makeRegistry({ isHostAuthorized = () => true } = {}) {
|
|
334
|
+
return createSessionRegistry({
|
|
335
|
+
connectClient: async () => {
|
|
336
|
+
throw new Error('connectClient must not be used in offline tests')
|
|
337
|
+
},
|
|
338
|
+
loadSecrets: () => ({ byHostId: {} }),
|
|
339
|
+
getPrompt: () => undefined,
|
|
340
|
+
getHostById: (id) => ({
|
|
341
|
+
id,
|
|
342
|
+
name: 'x',
|
|
343
|
+
host: '1.2.3.4',
|
|
344
|
+
port: 22,
|
|
345
|
+
username: 'root',
|
|
346
|
+
authType: 'password',
|
|
347
|
+
}),
|
|
348
|
+
isHostAuthorized,
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function liveRec(id, hostId, projectPathKey) {
|
|
353
|
+
return {
|
|
354
|
+
id,
|
|
355
|
+
hostId,
|
|
356
|
+
projectPathKey,
|
|
357
|
+
status: SSH_STATUS_CONNECTED,
|
|
358
|
+
running: true,
|
|
359
|
+
client: {},
|
|
360
|
+
sftpEnabled: true,
|
|
361
|
+
closing: false,
|
|
362
|
+
createdAt: Date.now(),
|
|
363
|
+
updatedAt: Date.now(),
|
|
364
|
+
reconnectRunner: false,
|
|
365
|
+
reconnectAttempt: 0,
|
|
366
|
+
connectionGeneration: 1,
|
|
367
|
+
title: `t-${id}`,
|
|
368
|
+
endpoint: '',
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
test('revoked host: requireLiveSession refuses use of established session', () => {
|
|
373
|
+
const registry = makeRegistry({ isHostAuthorized: () => false })
|
|
374
|
+
const key = normalizeProjectKey('/workspace/DSH-plugin')
|
|
375
|
+
registry.sessions.set('s1', liveRec('s1', 'h-sg', key))
|
|
376
|
+
// Shell / SFTP operations all go through requireLiveSession; a revoked host
|
|
377
|
+
// must not be usable even though the session object is still live.
|
|
378
|
+
assert.throws(() => registry.requireLiveSession('s1', key), /not authorized/)
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
test('granted host: requireLiveSession still allows use', () => {
|
|
382
|
+
const registry = makeRegistry({ isHostAuthorized: () => true })
|
|
383
|
+
const key = normalizeProjectKey('/workspace/DSH-plugin')
|
|
384
|
+
registry.sessions.set('s1', liveRec('s1', 'h-sg', key))
|
|
385
|
+
assert.equal(registry.requireLiveSession('s1', key).id, 's1')
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
test('revoked host: reconnect refuses to resurrect the session', async () => {
|
|
389
|
+
const registry = makeRegistry({ isHostAuthorized: () => false })
|
|
390
|
+
const key = normalizeProjectKey('/workspace/DSH-plugin')
|
|
391
|
+
const rec = liveRec('s1', 'h-sg', key)
|
|
392
|
+
rec.status = SSH_STATUS_DISCONNECTED
|
|
393
|
+
rec.running = false
|
|
394
|
+
registry.sessions.set('s1', rec)
|
|
395
|
+
await assert.rejects(
|
|
396
|
+
() => registry.reconnectSession('s1', { manual: true, projectPathKey: key }),
|
|
397
|
+
/not authorized/,
|
|
398
|
+
)
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
test('requireLiveSession and closeSession require projectPathKey', () => {
|
|
402
|
+
const registry = makeRegistry()
|
|
403
|
+
const key = normalizeProjectKey('/workspace/DSH-plugin')
|
|
404
|
+
registry.sessions.set('s1', liveRec('s1', 'h-sg', key))
|
|
405
|
+
assert.throws(() => registry.requireLiveSession('s1'), /projectPathKey required/)
|
|
406
|
+
assert.throws(() => registry.requireLiveSession('s1', ''), /projectPathKey required/)
|
|
407
|
+
assert.throws(() => registry.requireLiveSession('s1', normalizeProjectKey('/workspace/hk')), /not in this project/)
|
|
408
|
+
assert.throws(() => registry.closeSession('s1'), /projectPathKey required/)
|
|
409
|
+
assert.equal(registry.closeSession('s1', key).ok, true)
|
|
410
|
+
assert.equal(registry.sessions.has('s1'), false)
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
test('closeSessionsForHost terminates only the revoked host sessions in the project', () => {
|
|
414
|
+
const registry = makeRegistry()
|
|
415
|
+
const keyA = normalizeProjectKey('/workspace/DSH-plugin')
|
|
416
|
+
const keyB = normalizeProjectKey('/workspace/other')
|
|
417
|
+
registry.sessions.set('a1', liveRec('a1', 'h-sg', keyA)) // revoked host, this project
|
|
418
|
+
registry.sessions.set('b1', liveRec('b1', 'h2', keyA)) // other host, same project
|
|
419
|
+
registry.sessions.set('c1', liveRec('c1', 'h-sg', keyB)) // same host, other project
|
|
420
|
+
const out = registry.closeSessionsForHost(keyA, 'h-sg')
|
|
421
|
+
assert.equal(out.closed, 1)
|
|
422
|
+
assert.equal(registry.sessions.has('a1'), false)
|
|
423
|
+
assert.equal(registry.sessions.has('b1'), true)
|
|
424
|
+
assert.equal(registry.sessions.has('c1'), true)
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
/* ------------------------------------------------------------------ *
|
|
428
|
+
* Security regression (v0.4.3): a tool result must not be delivered *
|
|
429
|
+
* after the host's grant is revoked mid-operation, and a downloaded *
|
|
430
|
+
* file must not survive a mid-transfer revocation. *
|
|
431
|
+
* ------------------------------------------------------------------ */
|
|
432
|
+
|
|
433
|
+
test('result gate: revoked host denies tool result delivery', () => {
|
|
434
|
+
assert.throws(
|
|
435
|
+
() =>
|
|
436
|
+
assertSessionStillAuthorized(
|
|
437
|
+
{ projectPathKey: '/workspace/DSH-plugin', hostId: 'h-sg' },
|
|
438
|
+
() => false,
|
|
439
|
+
),
|
|
440
|
+
/not authorized/,
|
|
441
|
+
)
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
test('result gate: granted host still delivers tool results', () => {
|
|
445
|
+
assert.doesNotThrow(() =>
|
|
446
|
+
assertSessionStillAuthorized({ projectPathKey: '/workspace/DSH-plugin', hostId: 'h-sg' }, () => true),
|
|
447
|
+
)
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
test('result gate: absent predicate is fail-closed', () => {
|
|
451
|
+
assert.throws(
|
|
452
|
+
() =>
|
|
453
|
+
assertSessionStillAuthorized({ projectPathKey: '/workspace/DSH-plugin', hostId: 'h-sg' }, undefined),
|
|
454
|
+
/predicate required/,
|
|
455
|
+
)
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
test('download discard: revoked host removes the local file', () => {
|
|
459
|
+
const p = join(tmpdir(), `dsh-ssh-tunnel-revoke-${process.pid}.txt`)
|
|
460
|
+
writeFileSync(p, 'secret data', 'utf8')
|
|
461
|
+
const discarded = discardRevokedLocalFile(
|
|
462
|
+
{ projectPathKey: '/workspace/DSH-plugin', hostId: 'h-sg' },
|
|
463
|
+
p,
|
|
464
|
+
() => false,
|
|
465
|
+
)
|
|
466
|
+
assert.equal(discarded, true)
|
|
467
|
+
assert.equal(existsSync(p), false)
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
test('download discard: granted host keeps the local file', () => {
|
|
471
|
+
const p = join(tmpdir(), `dsh-ssh-tunnel-grant-${process.pid}.txt`)
|
|
472
|
+
writeFileSync(p, 'data', 'utf8')
|
|
473
|
+
try {
|
|
474
|
+
const discarded = discardRevokedLocalFile(
|
|
475
|
+
{ projectPathKey: '/workspace/DSH-plugin', hostId: 'h-sg' },
|
|
476
|
+
p,
|
|
477
|
+
() => true,
|
|
478
|
+
)
|
|
479
|
+
assert.equal(discarded, false)
|
|
480
|
+
assert.equal(existsSync(p), true)
|
|
481
|
+
} finally {
|
|
482
|
+
try {
|
|
483
|
+
unlinkSync(p)
|
|
484
|
+
} catch {}
|
|
485
|
+
}
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
test('vendor: allowlist resolves the three xterm assets to existing files', () => {
|
|
489
|
+
assert.deepEqual(Object.keys(VENDOR_ASSETS).sort(), ['addon-fit.js', 'xterm.css', 'xterm.js'])
|
|
490
|
+
for (const [name, entry] of Object.entries(VENDOR_ASSETS)) {
|
|
491
|
+
assert.equal(isVendorAssetAllowed(name), true, name)
|
|
492
|
+
const r = resolveVendorAsset(name)
|
|
493
|
+
assert.equal(existsSync(r.path), true, `${name} -> ${r.path}`)
|
|
494
|
+
assert.equal(typeof r.type, 'string')
|
|
495
|
+
}
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
test('vendor: unknown or traversing names are rejected', () => {
|
|
499
|
+
assert.equal(isVendorAssetAllowed('../../etc/passwd'), false)
|
|
500
|
+
assert.equal(isVendorAssetAllowed('xterm.js?x=1'), false)
|
|
501
|
+
assert.equal(isVendorAssetAllowed('xterm.js/../'), false)
|
|
502
|
+
assert.throws(() => resolveVendorAsset('../../etc/passwd'), /not allowed/)
|
|
503
|
+
assert.throws(() => resolveVendorAsset(''), /not allowed/)
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
await Promise.all(pending)
|
|
507
|
+
if (failed) {
|
|
508
|
+
console.error(`\n${failed} failed`)
|
|
509
|
+
process.exit(1)
|
|
510
|
+
}
|
|
511
|
+
console.log('\nall passed')
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# 把本仓库作为本地 link 插件接入 DSH profile(开发用)。
|
|
3
|
+
#
|
|
4
|
+
# 早期版本把文件复制进 $DSH_HOME/local-plugins/<pkg> 并就地 npm install;
|
|
5
|
+
# 当前运行时不再读取该目录,于是那条路径等于静默无效。现在改走官方入口:
|
|
6
|
+
# dsh plugin --profile <profile> add "link:<repo>"
|
|
7
|
+
# 由 DSH 自己登记 dsh.profile.bundles 并解析依赖(ssh2 / @xterm/*),仓库改动无需再同步。
|
|
8
|
+
#
|
|
9
|
+
# 环境:DSH_PROFILE(默认 web)、DSH_CMD(默认 dsh);DSH_HOME 由 dsh 自行解析。
|
|
10
|
+
set -euo pipefail
|
|
11
|
+
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
12
|
+
PROFILE="${DSH_PROFILE:-web}"
|
|
13
|
+
DSH_CMD="${DSH_CMD:-dsh}"
|
|
14
|
+
|
|
15
|
+
command -v node >/dev/null 2>&1 || { echo "需要 node(>= 20)" >&2; exit 1; }
|
|
16
|
+
NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null)" || NODE_MAJOR=0
|
|
17
|
+
[ "${NODE_MAJOR:-0}" -ge 20 ] || { echo "Node.js $(node -v) 过旧(需要 ≥ 20)" >&2; exit 1; }
|
|
18
|
+
|
|
19
|
+
echo "== 自检:$ROOT =="
|
|
20
|
+
node --check "$ROOT/lib/index.js"
|
|
21
|
+
node "$ROOT/scripts/smoke-test.mjs"
|
|
22
|
+
|
|
23
|
+
if ! command -v "$DSH_CMD" >/dev/null 2>&1; then
|
|
24
|
+
echo "未找到 $DSH_CMD(可用 DSH_CMD 指定);手动执行:" >&2
|
|
25
|
+
echo " dsh plugin --profile $PROFILE add \"link:$ROOT\"" >&2
|
|
26
|
+
exit 1
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
echo "== 接入 profile ${PROFILE}(link) =="
|
|
30
|
+
"$DSH_CMD" plugin --profile "$PROFILE" add "link:$ROOT"
|
|
31
|
+
echo "已接入:$ROOT"
|
|
32
|
+
echo "下一步:重启 dsh web,并硬刷新浏览器。"
|