dsh-dbhub-live 5.0.1 → 5.2.0-dev.3
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/AGENTS.md +95 -209
- package/README.en.md +251 -169
- package/README.md +251 -169
- package/doc/ARCHITECTURE.md +108 -0
- package/doc/CLIENT.md +124 -0
- package/doc/DESENSITIZE.md +56 -0
- package/doc/DEVELOPMENT.md +139 -0
- package/doc/IMPACT-MATRIX.md +36 -0
- package/doc/RELEASE.md +93 -0
- package/doc/REQUIREMENTS.md +177 -105
- package/doc/images/chat-query.png +0 -0
- package/doc/images/row-editor.png +0 -0
- package/doc/images/settings-page.png +0 -0
- package/lib/adhoc.mjs +412 -34
- package/lib/capability.mjs +157 -0
- package/lib/client.js +1243 -80
- package/lib/config.mjs +370 -21
- package/lib/i18n.mjs +119 -4
- package/lib/index.mjs +401 -43
- package/lib/mcp.mjs +496 -391
- package/lib/meta.mjs +267 -0
- package/lib/options.mjs +25 -5
- package/lib/policy.mjs +92 -0
- package/lib/toml.mjs +188 -0
- package/lib/tools.mjs +1093 -827
- package/lib/update.mjs +244 -0
- package/package.json +34 -10
- package/scripts/desensitize-check.mjs +10 -3
- package/scripts/desensitize.config.mjs +255 -237
- package/scripts/docs-check.mjs +214 -0
- package/scripts/release-preflight.mjs +180 -0
- package/scripts/render-check.mjs +1366 -843
- package/test/adhoc-toml.test.mjs +365 -0
- package/test/bridge.test.mjs +227 -185
- package/test/capability.test.mjs +231 -0
- package/test/client-format.test.mjs +515 -352
- package/test/configure-options.test.mjs +244 -0
- package/test/configure-ssh.test.mjs +232 -0
- package/test/copy-from.test.mjs +155 -0
- package/test/env-options.test.mjs +456 -0
- package/test/meta.test.mjs +234 -0
- package/test/options.test.mjs +160 -123
- package/test/policy.test.mjs +132 -0
- package/test/row-cache.test.mjs +237 -138
- package/test/ssh-probe.test.mjs +834 -0
- package/test/test-op-fastpath.test.mjs +208 -0
- package/test/toml.test.mjs +352 -0
- package/test/update.test.mjs +270 -0
- package/test/util.test.mjs +17 -0
- package/test/zero-knowledge.test.mjs +120 -78
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
// dsh-dbhub-live: the SSH-layer probe and the failure-attribution helpers.
|
|
2
|
+
//
|
|
3
|
+
// 5.1.0-dev.3 answers "is it the tunnel or the database?" with five pieces, all
|
|
4
|
+
// pinned here (with the measured dbhub 1.4.0 stderr as the fixture):
|
|
5
|
+
// · `diagnosticTail` keeps the informative END of dbhub's output — the fatal
|
|
6
|
+
// line is printed LAST while the banner comes first, and the ~10 ` at …`
|
|
7
|
+
// stack frames that follow it are dropped, so the line NAMING the reason is
|
|
8
|
+
// what survives the 600-character cap;
|
|
9
|
+
// · `missingSshFields` says WHAT is missing, and `failureLayerOf` /
|
|
10
|
+
// `failureLayerLabel` attribute a failure to the plugin, the SSH layer or the
|
|
11
|
+
// database step (and to nothing when no tunnel is set);
|
|
12
|
+
// · `probeSshTunnel` validates ONLY the tunnel: a loopback TCP connect to the
|
|
13
|
+
// bastion port, the local private-key file for key auth, and then a real
|
|
14
|
+
// dbhub connect THROUGH the tunnel to `127.0.0.1:1` (a port that cannot be
|
|
15
|
+
// listening). The evidence is dbhub's own error text: naming the forwarded
|
|
16
|
+
// target without an authentication/handshake wording proves the tunnel
|
|
17
|
+
// carried the connection (`tunnel`), an SSH-layer signature means the tunnel
|
|
18
|
+
// itself is broken (`ssh`), and neither means the answer is honestly
|
|
19
|
+
// `unknown` rather than a guess;
|
|
20
|
+
// · `mergeSshOptions` (lib/config.mjs) implements the browser path's "blank
|
|
21
|
+
// secret = keep the stored value" rule (the UI cannot echo a secret back);
|
|
22
|
+
// · `scrubSecrets` (lib/config.mjs) collapses the WHOLE userinfo of every
|
|
23
|
+
// URL-shaped connection string, because dbhub's banner masks only the
|
|
24
|
+
// password and prints the user name verbatim.
|
|
25
|
+
//
|
|
26
|
+
// `handleTestOp` (lib/index.mjs) is NOT exported, so its `{op:'test', kind:'ssh'}`
|
|
27
|
+
// branch is covered INDIRECTLY: `apply()` wires the HTTP bridge over a fake ctx,
|
|
28
|
+
// the op goes in through `POST /op`, and the report comes back in the published
|
|
29
|
+
// `testResult` field.
|
|
30
|
+
//
|
|
31
|
+
// The fake dbhub never answers MCP, so `runAdhoc`/`probeSshTunnel` resolve a
|
|
32
|
+
// failure by design — the assertions are about how the canned stderr is
|
|
33
|
+
// attributed, never about a dbhub reply.
|
|
34
|
+
//
|
|
35
|
+
// Test isolation: config.mjs derives the storage dir from DSH_HOME at import
|
|
36
|
+
// time, so point it at a temp dir BEFORE importing.
|
|
37
|
+
|
|
38
|
+
import test from 'node:test'
|
|
39
|
+
import assert from 'node:assert/strict'
|
|
40
|
+
import { createServer } from 'node:net'
|
|
41
|
+
import { mkdtempSync, rmSync, existsSync } from 'node:fs'
|
|
42
|
+
import { tmpdir } from 'node:os'
|
|
43
|
+
import { join } from 'node:path'
|
|
44
|
+
|
|
45
|
+
const home = mkdtempSync(join(tmpdir(), 'dsh-dbhub-sshprobe-'))
|
|
46
|
+
process.env.DSH_HOME = home
|
|
47
|
+
// runtime.mjs prefers a mise-managed dbhub discovered through MISE_DATA_DIR;
|
|
48
|
+
// drop it so the fake executable is what every call resolves.
|
|
49
|
+
delete process.env.MISE_DATA_DIR
|
|
50
|
+
|
|
51
|
+
const adhoc = await import('../lib/adhoc.mjs')
|
|
52
|
+
const cfg = await import('../lib/config.mjs')
|
|
53
|
+
const state = await import('../lib/state.mjs')
|
|
54
|
+
const index = await import('../lib/index.mjs')
|
|
55
|
+
const { BRIDGE_ROUTES } = await import('../lib/bridge.mjs')
|
|
56
|
+
const { currentT } = await import('../lib/i18n.mjs')
|
|
57
|
+
|
|
58
|
+
// ── fixtures (loopback / RFC 5737 hosts and CHANGE_ME placeholders only) ────
|
|
59
|
+
|
|
60
|
+
const DSN = 'mysql://user:CHANGE_ME@127.0.0.1:3306/app'
|
|
61
|
+
/** A tunnelled descriptor: `conn.ssh` is what turns attribution on. */
|
|
62
|
+
const SSH_CONN = {
|
|
63
|
+
dsn: DSN,
|
|
64
|
+
ro: false,
|
|
65
|
+
ssh: { host: '192.0.2.10', port: 22, user: 'ops', auth: 'password', password: 'CHANGE_ME' },
|
|
66
|
+
}
|
|
67
|
+
const STORED_PASSWORD = { host: '192.0.2.10', port: 22, user: 'ops', auth: 'password', password: 'CHANGE_ME' }
|
|
68
|
+
|
|
69
|
+
// MEASURED dbhub 1.4.0 stderr (sanitized: absolute paths and the build hash are
|
|
70
|
+
// low-entropy placeholders). A tunnel probe against a closed target, exactly as
|
|
71
|
+
// dbhub prints it: the fatal line, ~10 ` at …` frames, then the tunnel-error
|
|
72
|
+
// property dbhub sets on that error object (named verbatim in the fixture
|
|
73
|
+
// below). Both the attribution and the tail have to survive this shape; dbhub
|
|
74
|
+
// masks the password itself with eight stars, but the user name is printed
|
|
75
|
+
// verbatim.
|
|
76
|
+
const DBHUB_SSH_CAPTURE = [
|
|
77
|
+
'Configuration source: dbhub-1234-1-abc123.toml',
|
|
78
|
+
'Connecting to 1 database source(s)...',
|
|
79
|
+
' - default: mysql://probe:********@127.0.0.1:1/probe',
|
|
80
|
+
'Fatal error: Error: SSH connection error: connect ECONNREFUSED 127.0.0.1:1',
|
|
81
|
+
' at Client.onError (file:///opt/dbhub/dist/chunk-ABCDEF.js:399:16)',
|
|
82
|
+
' at Client.emit (node:events:514:28)',
|
|
83
|
+
' at Socket.<anonymous> (/opt/dbhub/node_modules/ssh2/lib/client.js:805:12)',
|
|
84
|
+
' at Socket.emit (node:events:514:28)',
|
|
85
|
+
' at process.processTicksAndRejections (node:internal/process/task_queues:90:21) {',
|
|
86
|
+
' __dbhubSSHTunnelError: true',
|
|
87
|
+
'}',
|
|
88
|
+
].join('\n')
|
|
89
|
+
const STORED_KEY = { host: '192.0.2.10', port: 2222, user: 'ops', auth: 'key', keyPath: '~/.ssh/id_ed25519', passphrase: 'CHANGE_ME' }
|
|
90
|
+
const PLUGIN_FAIL = '启动临时 dbhub 失败'
|
|
91
|
+
|
|
92
|
+
// MEASURED dbhub 1.4.0 stderr for a tunnel that DID come up: the forwarded target
|
|
93
|
+
// closed the connection, so the driver reports a DATABASE-protocol error and
|
|
94
|
+
// dbhub tears the tunnel down ("SSH tunnel closed"). Nothing here names the
|
|
95
|
+
// forwarded target, which is exactly why a target-name-only rule answered
|
|
96
|
+
// "cannot classify" for a perfectly good tunnel.
|
|
97
|
+
const DBHUB_TUNNEL_UP_CAPTURE = [
|
|
98
|
+
'Configuration source: dbhub-1234-2-def456.toml',
|
|
99
|
+
'Connecting to 1 database source(s)...',
|
|
100
|
+
' - default: mysql://probe:********@127.0.0.1:1/probe',
|
|
101
|
+
'SSH tunnel closed',
|
|
102
|
+
'Fatal error: Error: Connection lost: The server closed the connection.',
|
|
103
|
+
' at Query.query (/opt/dbhub/dist/mysql-ABCDEF.js:135:38)',
|
|
104
|
+
' at process.processTicksAndRejections (node:internal/process/task_queues:90:21) {',
|
|
105
|
+
' fatal: true,',
|
|
106
|
+
" code: 'PROTOCOL_CONNECTION_LOST'",
|
|
107
|
+
'}',
|
|
108
|
+
].join('\n')
|
|
109
|
+
|
|
110
|
+
// A fresh DSH_HOME has no credentials.json, so state.mjs starts enabled; make
|
|
111
|
+
// that explicit so every probe below really reaches its dbhub step.
|
|
112
|
+
if (!state.isEnabled()) state.setEnabled(true)
|
|
113
|
+
|
|
114
|
+
test.after(async () => {
|
|
115
|
+
for (const server of [...liveServers]) await server.close()
|
|
116
|
+
rmSync(home, { recursive: true, force: true })
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
// ── fake host services ─────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A recording stand-in for the host `subprocess` service. It never runs a real
|
|
123
|
+
* binary: `spawn` records the exact spec (`runAdhoc` awaits `waitForExit`) and
|
|
124
|
+
* the canned `stderr` is emitted the moment the MCP client subscribes — which
|
|
125
|
+
* is how `runAdhoc` builds its "[dbhub stderr] …" detail. `spawnThrows` models
|
|
126
|
+
* the plugin-side failure path ("启动临时 dbhub 失败: …").
|
|
127
|
+
*/
|
|
128
|
+
function recorder(opts = {}) {
|
|
129
|
+
const specs = []
|
|
130
|
+
return {
|
|
131
|
+
specs,
|
|
132
|
+
subprocess: {
|
|
133
|
+
resolveExecutable: async (name) => '/fake/bin/' + name,
|
|
134
|
+
spawn(spec) {
|
|
135
|
+
specs.push(spec)
|
|
136
|
+
if (opts.spawnThrows) throw new Error(String(opts.spawnThrows))
|
|
137
|
+
return {
|
|
138
|
+
stdin: { write() {} },
|
|
139
|
+
stdout: { on() {} },
|
|
140
|
+
stderr: {
|
|
141
|
+
on(event, handler) {
|
|
142
|
+
if (event === 'data' && opts.stderr) handler(opts.stderr)
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
done: Promise.resolve({ exitCode: 1 }),
|
|
146
|
+
terminate() {},
|
|
147
|
+
waitForExit: async () => ({ exitCode: 1 }),
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── real loopback listeners ────────────────────────────────────────────────
|
|
155
|
+
//
|
|
156
|
+
// `tcpReachable` is tested for real. Every server opened here is tracked and
|
|
157
|
+
// closed in `test.after` as well as at the end of its own test, so a leaked
|
|
158
|
+
// listener can never keep this file from exiting.
|
|
159
|
+
|
|
160
|
+
const liveServers = new Set()
|
|
161
|
+
|
|
162
|
+
/** Start a throwaway loopback server on an ephemeral port. */
|
|
163
|
+
function listenOnce() {
|
|
164
|
+
return new Promise((resolve, reject) => {
|
|
165
|
+
const sockets = new Set()
|
|
166
|
+
const server = createServer((socket) => {
|
|
167
|
+
// The probe connects and destroys immediately: an ECONNRESET on the
|
|
168
|
+
// server side must never surface as an unhandled 'error' event.
|
|
169
|
+
socket.on('error', () => {})
|
|
170
|
+
sockets.add(socket)
|
|
171
|
+
socket.on('close', () => sockets.delete(socket))
|
|
172
|
+
})
|
|
173
|
+
server.once('error', reject)
|
|
174
|
+
server.listen(0, '127.0.0.1', () => {
|
|
175
|
+
const handle = {
|
|
176
|
+
port: server.address().port,
|
|
177
|
+
close: () =>
|
|
178
|
+
new Promise((done) => {
|
|
179
|
+
for (const socket of sockets) {
|
|
180
|
+
try {
|
|
181
|
+
socket.destroy()
|
|
182
|
+
} catch (e) {
|
|
183
|
+
/* already gone */
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
server.close(() => {
|
|
187
|
+
liveServers.delete(handle)
|
|
188
|
+
done()
|
|
189
|
+
})
|
|
190
|
+
}),
|
|
191
|
+
}
|
|
192
|
+
liveServers.add(handle)
|
|
193
|
+
resolve(handle)
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** A port that was just bound and released: connecting to it must fail. */
|
|
199
|
+
async function closedPort() {
|
|
200
|
+
const server = await listenOnce()
|
|
201
|
+
const port = server.port
|
|
202
|
+
await server.close()
|
|
203
|
+
return port
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── diagnosticTail ─────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
test('diagnosticTail keeps the END of a diagnostic and trims blank edges', () => {
|
|
209
|
+
const banner = '\n Configuration source: file \r\nConnecting to 1 database source… \n\n\nfatal: access denied\n\n'
|
|
210
|
+
// \r dropped, trailing spaces trimmed per line, leading/trailing blank lines
|
|
211
|
+
// removed — the interior line structure (and indentation) survives.
|
|
212
|
+
assert.equal(
|
|
213
|
+
adhoc.diagnosticTail(banner),
|
|
214
|
+
' Configuration source: file\nConnecting to 1 database source…\n\n\nfatal: access denied',
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
assert.equal(adhoc.diagnosticTail(''), '')
|
|
218
|
+
assert.equal(adhoc.diagnosticTail(undefined), '')
|
|
219
|
+
assert.equal(adhoc.diagnosticTail(null), '')
|
|
220
|
+
assert.equal(adhoc.diagnosticTail(' \n\n \n'), '', 'whitespace-only output collapses to nothing')
|
|
221
|
+
assert.equal(adhoc.diagnosticTail('one line'), 'one line')
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test('diagnosticTail drops stack frames so the reason survives the cap', () => {
|
|
225
|
+
// Measured on dbhub 1.4.0: a plain tail-of-N-chars keeps the ~10 ` at …`
|
|
226
|
+
// frames and cuts the very line that names the reason.
|
|
227
|
+
const tail = adhoc.diagnosticTail(DBHUB_SSH_CAPTURE)
|
|
228
|
+
assert.ok(tail.includes('SSH connection error'), tail)
|
|
229
|
+
assert.ok(tail.includes('__dbhubSSHTunnelError: true'), tail, 'the trailing marker survives')
|
|
230
|
+
assert.equal(/\n\s*at /.test(tail), false, 'no stack frame survives')
|
|
231
|
+
assert.equal(tail.includes('.js:'), false, 'frame paths go too')
|
|
232
|
+
|
|
233
|
+
// Dropped frames do not eat the cap.
|
|
234
|
+
const padded = ' at Client.onError (file:///opt/dbhub/dist/chunk-ABCDEF.js:399:16)\n'.repeat(40) + DBHUB_SSH_CAPTURE
|
|
235
|
+
const capped = adhoc.diagnosticTail(padded)
|
|
236
|
+
assert.ok(capped.length <= adhoc.DIAG_TAIL_MAX + 1, 'still capped')
|
|
237
|
+
assert.ok(capped.includes('SSH connection error'))
|
|
238
|
+
assert.equal(/\n\s*at /.test(capped), false)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test('diagnosticTail cuts from the FRONT once the text exceeds the cap', () => {
|
|
242
|
+
const long = 'A'.repeat(700) + 'END'
|
|
243
|
+
const cut = adhoc.diagnosticTail(long, 100)
|
|
244
|
+
assert.equal(cut, '…' + long.slice(-100))
|
|
245
|
+
assert.equal(cut.length, 101, 'one leading ellipsis plus at most `max` characters')
|
|
246
|
+
assert.ok(cut.startsWith('…'), 'a cut is marked on the side that was dropped')
|
|
247
|
+
assert.ok(cut.endsWith('END'), 'the informative END is what is kept')
|
|
248
|
+
|
|
249
|
+
// The default cap is DIAG_TAIL_MAX, and a non-positive/invalid max means "default".
|
|
250
|
+
const big = Array.from({ length: 100 }, (_, i) => 'line ' + i).join('\n')
|
|
251
|
+
assert.ok(big.length > adhoc.DIAG_TAIL_MAX)
|
|
252
|
+
const defaultCut = adhoc.diagnosticTail(big)
|
|
253
|
+
assert.ok(defaultCut.startsWith('…'), 'a cut is marked on the side that was dropped')
|
|
254
|
+
assert.ok(defaultCut.endsWith('line 99'), 'the informative END is what is kept')
|
|
255
|
+
assert.ok(defaultCut.length <= adhoc.DIAG_TAIL_MAX + 1)
|
|
256
|
+
// The cut snaps to a line boundary: a dangling half-line reads as corruption.
|
|
257
|
+
assert.match(defaultCut.split('\n')[0], /^…line \d+$/)
|
|
258
|
+
assert.equal(adhoc.diagnosticTail(big, 0), defaultCut)
|
|
259
|
+
assert.equal(adhoc.diagnosticTail(big, Number.NaN), defaultCut)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
test('the probe constants describe a closed loopback target', () => {
|
|
263
|
+
assert.equal(adhoc.DIAG_TAIL_MAX, 600)
|
|
264
|
+
assert.equal(adhoc.SSH_PROBE_DSN, 'mysql://probe:CHANGE_ME@127.0.0.1:1/probe')
|
|
265
|
+
assert.equal(adhoc.SSH_TCP_TIMEOUT_MS, 5000)
|
|
266
|
+
assert.equal(adhoc.SSH_PROBE_DSN.startsWith('mysql://probe:'), true)
|
|
267
|
+
assert.equal(adhoc.SSH_PROBE_DSN.includes('127.0.0.1:1/'), true, 'port 1 cannot be listening')
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
// ── failure-layer attribution ──────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
test('failureLayerOf is internal for a plugin-side failure, ssh/db for a tunnel', () => {
|
|
273
|
+
// Plugin-side messages win even when a tunnel is configured: they were
|
|
274
|
+
// produced before dbhub could even try the tunnel.
|
|
275
|
+
for (const text of [
|
|
276
|
+
PLUGIN_FAIL + ': spawn refused',
|
|
277
|
+
'生成临时 dbhub 配置失败: bad template',
|
|
278
|
+
'无法获取 dbhub: ENOENT',
|
|
279
|
+
'dsh-dbhub-live 已禁用:所有 dbhub 工具暂不可用。',
|
|
280
|
+
]) {
|
|
281
|
+
assert.equal(adhoc.failureLayerOf(text, SSH_CONN), 'internal', text)
|
|
282
|
+
assert.equal(adhoc.failureLayerOf(text, DSN), 'internal', text)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Every signature the SSH layer is recognized by.
|
|
286
|
+
for (const signature of [
|
|
287
|
+
'All configured authentication methods failed',
|
|
288
|
+
'authentication methods failed',
|
|
289
|
+
'Cannot parse privateKey',
|
|
290
|
+
'Encrypted private key detected',
|
|
291
|
+
'a passphrase is required (no passphrase given)',
|
|
292
|
+
'handshake failed',
|
|
293
|
+
'ssh_host is invalid',
|
|
294
|
+
'ssh_user is invalid',
|
|
295
|
+
'SSH tunnel requires a password',
|
|
296
|
+
'proxy jump is not supported',
|
|
297
|
+
'the jump host is unreachable',
|
|
298
|
+
'ssh2: stream error',
|
|
299
|
+
]) {
|
|
300
|
+
assert.equal(adhoc.failureLayerOf(signature, SSH_CONN), 'ssh', signature)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// No SSH signature = the failure happened after the tunnel was up.
|
|
304
|
+
assert.equal(adhoc.failureLayerOf('connect ECONNREFUSED 127.0.0.1:1', SSH_CONN), 'db')
|
|
305
|
+
assert.equal(adhoc.failureLayerOf('Access denied for user', SSH_CONN), 'db')
|
|
306
|
+
assert.equal(adhoc.failureLayerOf('', SSH_CONN), 'db', 'a silent tunnel failure is still attributed to a layer')
|
|
307
|
+
assert.equal(adhoc.failureLayerOf(undefined, SSH_CONN), 'db')
|
|
308
|
+
|
|
309
|
+
// No tunnel: no attribution at all, whatever the text says.
|
|
310
|
+
assert.equal(adhoc.failureLayerOf('All configured authentication methods failed', DSN), '')
|
|
311
|
+
assert.equal(adhoc.failureLayerOf('connect ECONNREFUSED 127.0.0.1:1', { dsn: DSN, ro: true, ssh: null }), '')
|
|
312
|
+
assert.equal(adhoc.failureLayerOf('connect ECONNREFUSED 127.0.0.1:1', undefined), '')
|
|
313
|
+
|
|
314
|
+
// The measured dbhub 1.4.0 capture: dbhub's own error carries
|
|
315
|
+
// `__dbhubSSHTunnelError: true`, so this IS the SSH layer — and with no
|
|
316
|
+
// tunnel configured there is nothing to attribute.
|
|
317
|
+
assert.equal(adhoc.failureLayerOf(DBHUB_SSH_CAPTURE, { dsn: adhoc.SSH_PROBE_DSN, ssh: SSH_CONN.ssh }), 'ssh')
|
|
318
|
+
assert.equal(adhoc.failureLayerOf(DBHUB_SSH_CAPTURE, { dsn: adhoc.SSH_PROBE_DSN }), '')
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
test('probeSshTunnel agrees with failureLayerOf on the measured capture (no split verdict)', async () => {
|
|
322
|
+
// Regression: the capture's BANNER line names the forwarded target
|
|
323
|
+
// (`- default: mysql://…@127.0.0.1:1/probe`) in EVERY run, so a
|
|
324
|
+
// "names the target ⇒ tunnel is up" rule consulted first turned a real SSH
|
|
325
|
+
// failure into "your tunnel works". The SSH signature must win.
|
|
326
|
+
const server = await listenOnce()
|
|
327
|
+
try {
|
|
328
|
+
const { subprocess } = recorder({ stderr: DBHUB_SSH_CAPTURE })
|
|
329
|
+
const block = { host: '127.0.0.1', port: server.port, user: 'ops', auth: 'password', password: 'CHANGE_ME' }
|
|
330
|
+
const res = await adhoc.probeSshTunnel(subprocess, block)
|
|
331
|
+
assert.equal(res.ok, false, res.message)
|
|
332
|
+
assert.equal(res.layer, 'ssh', res.message)
|
|
333
|
+
assert.equal(adhoc.failureLayerOf(DBHUB_SSH_CAPTURE, { dsn: adhoc.SSH_PROBE_DSN, ssh: block }), 'ssh')
|
|
334
|
+
assert.ok(res.message.includes('SSH connection error'), 'the reason is carried through')
|
|
335
|
+
} finally {
|
|
336
|
+
await server.close()
|
|
337
|
+
}
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
test('failureLayerLabel is a localized prefix, empty when nothing is known', () => {
|
|
341
|
+
assert.equal(adhoc.failureLayerLabel('connect ECONNREFUSED 127.0.0.1:1', SSH_CONN), currentT('result.layerDb'))
|
|
342
|
+
assert.equal(adhoc.failureLayerLabel('All configured authentication methods failed', SSH_CONN), currentT('result.layerSsh'))
|
|
343
|
+
assert.equal(adhoc.failureLayerLabel(PLUGIN_FAIL + ': x', SSH_CONN), currentT('result.layerInternal'))
|
|
344
|
+
assert.equal(adhoc.failureLayerLabel('connect ECONNREFUSED 127.0.0.1:1', DSN), '', 'no tunnel, no prefix')
|
|
345
|
+
for (const key of ['result.layerSsh', 'result.layerDb', 'result.layerInternal']) {
|
|
346
|
+
assert.ok(currentT(key).length > 0, `${key} must be localized`)
|
|
347
|
+
}
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
// ── tcpReachable ───────────────────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
test('tcpReachable succeeds against a live loopback listener and fails once closed', async () => {
|
|
353
|
+
const server = await listenOnce()
|
|
354
|
+
try {
|
|
355
|
+
assert.deepEqual(await adhoc.tcpReachable('127.0.0.1', server.port), { ok: true, code: '' })
|
|
356
|
+
} finally {
|
|
357
|
+
await server.close()
|
|
358
|
+
}
|
|
359
|
+
const miss = await adhoc.tcpReachable('127.0.0.1', server.port)
|
|
360
|
+
assert.equal(miss.ok, false)
|
|
361
|
+
assert.equal(miss.code, 'ECONNREFUSED', 'a released loopback port refuses the connect')
|
|
362
|
+
assert.equal(typeof miss.code, 'string')
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
// ── probeSshTunnel: one case per layer ─────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
test('probeSshTunnel: an unusable block is settled as `config` without any spawn', async () => {
|
|
368
|
+
const { subprocess, specs } = recorder()
|
|
369
|
+
const blocks = [
|
|
370
|
+
undefined,
|
|
371
|
+
null,
|
|
372
|
+
{},
|
|
373
|
+
{ host: '127.0.0.1' },
|
|
374
|
+
{ host: '127.0.0.1', user: 'ops' },
|
|
375
|
+
{ host: '127.0.0.1', user: 'ops', auth: 'password' },
|
|
376
|
+
{ host: '127.0.0.1', user: 'ops', auth: 'key', keyPath: 'x', proxyJump: 'a:22,b:22' },
|
|
377
|
+
]
|
|
378
|
+
for (const bad of blocks) {
|
|
379
|
+
const res = await adhoc.probeSshTunnel(subprocess, bad)
|
|
380
|
+
assert.equal(res.ok, false)
|
|
381
|
+
assert.equal(res.layer, 'config')
|
|
382
|
+
// The answer names WHAT is missing instead of a generic "incomplete" — or, for
|
|
383
|
+
// a complete-but-multi-hop block, the reason normalize actually rejected.
|
|
384
|
+
const missing = adhoc.missingSshFields(bad)
|
|
385
|
+
assert.equal(res.message, missing
|
|
386
|
+
? currentT('result.sshTestBadConfig', { missing })
|
|
387
|
+
: currentT('result.sshTestMultiHop'))
|
|
388
|
+
assert.equal(res.message.includes('{missing}'), false, 'the placeholder is always resolved')
|
|
389
|
+
// Never a dangling "missing ." sentence.
|
|
390
|
+
assert.equal(res.message.includes('缺少 。'), false, res.message)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// …and the list itself, spelled out field by field.
|
|
394
|
+
const list = (...keys) => keys.map((key) => currentT(key)).join('、')
|
|
395
|
+
assert.equal(adhoc.missingSshFields({}), list('field.host', 'field.user', 'field.authKind'))
|
|
396
|
+
assert.equal(adhoc.missingSshFields(null), list('field.host', 'field.user', 'field.authKind'))
|
|
397
|
+
assert.equal(adhoc.missingSshFields({ host: '127.0.0.1' }), list('field.user', 'field.authKind'))
|
|
398
|
+
assert.equal(adhoc.missingSshFields({ host: '127.0.0.1', user: 'ops', auth: 'key' }), list('field.keyPath'))
|
|
399
|
+
assert.equal(adhoc.missingSshFields({ host: '127.0.0.1', user: 'ops', auth: 'password' }), list('field.password'))
|
|
400
|
+
assert.equal(
|
|
401
|
+
adhoc.missingSshFields({ host: '127.0.0.1', user: 'ops', auth: 'password', password: 'CHANGE_ME' }),
|
|
402
|
+
'',
|
|
403
|
+
'a complete block is missing nothing',
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
assert.equal(specs.length, 0, 'an unusable ssh block never reaches dbhub')
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
test('probeSshTunnel: a closed bastion port is settled as `reach`', async () => {
|
|
410
|
+
const port = await closedPort()
|
|
411
|
+
const { subprocess, specs } = recorder()
|
|
412
|
+
const res = await adhoc.probeSshTunnel(subprocess, { host: '127.0.0.1', port, user: 'ops', auth: 'password', password: 'CHANGE_ME' })
|
|
413
|
+
const code = (await adhoc.tcpReachable('127.0.0.1', port)).code
|
|
414
|
+
|
|
415
|
+
assert.equal(res.ok, false)
|
|
416
|
+
assert.equal(res.layer, 'reach')
|
|
417
|
+
assert.equal(res.message, currentT('result.sshTestUnreachable', { host: '127.0.0.1', port: String(port), code }))
|
|
418
|
+
assert.ok(res.message.includes('127.0.0.1'))
|
|
419
|
+
assert.ok(res.message.includes(String(port)))
|
|
420
|
+
assert.equal(specs.length, 0, 'an unreachable bastion is answered before dbhub is spawned')
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
test('probeSshTunnel: key auth checks the local private-key file (`key`)', async () => {
|
|
424
|
+
const server = await listenOnce()
|
|
425
|
+
const keyDir = mkdtempSync(join(tmpdir(), 'dsh-dbhub-nokey-'))
|
|
426
|
+
try {
|
|
427
|
+
// The bastion port is REACHABLE, so this must get past step 1; the key file
|
|
428
|
+
// deliberately does not exist under the temp dir (never a real user path).
|
|
429
|
+
const keyPath = join(keyDir, 'id_ed25519')
|
|
430
|
+
const { subprocess, specs } = recorder()
|
|
431
|
+
const res = await adhoc.probeSshTunnel(subprocess, { host: '127.0.0.1', port: server.port, user: 'ops', auth: 'key', keyPath })
|
|
432
|
+
|
|
433
|
+
assert.equal(res.ok, false)
|
|
434
|
+
assert.equal(res.layer, 'key')
|
|
435
|
+
assert.equal(res.message, currentT('result.sshTestKeyMissing', { path: keyPath }))
|
|
436
|
+
assert.ok(res.message.includes('id_ed25519'))
|
|
437
|
+
assert.equal(specs.length, 0, 'a missing key is reported without spawning dbhub')
|
|
438
|
+
} finally {
|
|
439
|
+
rmSync(keyDir, { recursive: true, force: true })
|
|
440
|
+
await server.close()
|
|
441
|
+
}
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
test('probeSshTunnel: a blank key path falls back to the placeholder default', async () => {
|
|
445
|
+
// The field's placeholder promises `~/.ssh/id_ed25519`; a key-auth block with no
|
|
446
|
+
// path must therefore TRY that file instead of being rejected as incomplete.
|
|
447
|
+
const server = await listenOnce()
|
|
448
|
+
try {
|
|
449
|
+
const { subprocess, specs } = recorder({ stderr: 'Error: All configured authentication methods failed' })
|
|
450
|
+
const res = await adhoc.probeSshTunnel(subprocess, { host: '127.0.0.1', port: server.port, user: 'ops', auth: 'key' })
|
|
451
|
+
|
|
452
|
+
// Either the default file is missing on this machine (reported as `key` with
|
|
453
|
+
// the default path named) or it exists and dbhub is really attempted.
|
|
454
|
+
const defaultExists = existsSync(cfg.expandHome(cfg.DEFAULT_SSH_KEY_PATH))
|
|
455
|
+
if (defaultExists) {
|
|
456
|
+
assert.equal(res.layer, 'ssh', res.message)
|
|
457
|
+
assert.equal(specs.length, 1, 'the default key got as far as a real dbhub attempt')
|
|
458
|
+
} else {
|
|
459
|
+
assert.equal(res.layer, 'key', res.message)
|
|
460
|
+
assert.equal(specs.length, 0)
|
|
461
|
+
}
|
|
462
|
+
assert.ok(res.message.includes(cfg.DEFAULT_SSH_KEY_PATH), 'the path that was tried is named: ' + res.message)
|
|
463
|
+
// …and the report says the default was used, so nobody hunts for a typo.
|
|
464
|
+
assert.ok(res.message.includes(currentT('result.sshTestDefaultKey', { path: cfg.DEFAULT_SSH_KEY_PATH })), res.message)
|
|
465
|
+
// The command layer merges BEFORE the probe, so the note must survive that:
|
|
466
|
+
// `defaultKeyNoteOf(what the card sent, what will be tried)`.
|
|
467
|
+
const note = currentT('result.sshTestDefaultKey', { path: cfg.DEFAULT_SSH_KEY_PATH })
|
|
468
|
+
assert.equal(adhoc.defaultKeyNoteOf({ auth: 'key' }, { auth: 'key', keyPath: cfg.DEFAULT_SSH_KEY_PATH }), note)
|
|
469
|
+
assert.equal(adhoc.defaultKeyNoteOf(null, { auth: 'key', keyPath: cfg.DEFAULT_SSH_KEY_PATH }), note)
|
|
470
|
+
// A named or stored path is never announced as a fallback.
|
|
471
|
+
assert.equal(adhoc.defaultKeyNoteOf({ auth: 'key', keyPath: '~/.ssh/other' }, { auth: 'key', keyPath: '~/.ssh/other' }), '')
|
|
472
|
+
assert.equal(adhoc.defaultKeyNoteOf({ auth: 'key' }, { auth: 'key', keyPath: '~/.ssh/stored' }), '')
|
|
473
|
+
assert.equal(adhoc.defaultKeyNoteOf({ auth: 'password', password: 'x' }, { auth: 'password', password: 'x' }), '')
|
|
474
|
+
} finally {
|
|
475
|
+
await server.close()
|
|
476
|
+
}
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
test('probeSshTunnel: an SSH signature from dbhub is `ssh`', async () => {
|
|
480
|
+
const server = await listenOnce()
|
|
481
|
+
try {
|
|
482
|
+
const signature = 'Error: All configured authentication methods failed'
|
|
483
|
+
const { subprocess, specs } = recorder({ stderr: signature })
|
|
484
|
+
const res = await adhoc.probeSshTunnel(subprocess, {
|
|
485
|
+
host: '127.0.0.1', port: server.port, user: 'ops', auth: 'password', password: 'CHANGE_ME',
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
assert.equal(specs.length, 1, 'the SSH failure came from a spawned dbhub')
|
|
489
|
+
assert.equal(specs[0].argv.join(' ').includes('--config'), true, 'a tunnel always uses the one-shot TOML shape')
|
|
490
|
+
assert.equal(specs[0].env.DSH_DBHUB_SEC_SSH_PASSWORD, 'CHANGE_ME')
|
|
491
|
+
assert.equal(res.ok, false)
|
|
492
|
+
assert.equal(res.layer, 'ssh')
|
|
493
|
+
const prefix = currentT('result.sshTestAuthFail', { host: '127.0.0.1', port: String(server.port), msg: '' })
|
|
494
|
+
assert.ok(res.message.startsWith(prefix), res.message)
|
|
495
|
+
assert.ok(res.message.includes(signature), 'the dbhub tail is kept for the user')
|
|
496
|
+
assert.equal(res.message.includes('CHANGE_ME'), false, 'no tunnel secret in the report')
|
|
497
|
+
} finally {
|
|
498
|
+
await server.close()
|
|
499
|
+
}
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
test('probeSshTunnel: a database-step failure proves the tunnel (`tunnel`)', async () => {
|
|
503
|
+
const server = await listenOnce()
|
|
504
|
+
try {
|
|
505
|
+
const { subprocess, specs } = recorder({ stderr: 'failed to connect: connect ECONNREFUSED 127.0.0.1:1' })
|
|
506
|
+
const res = await adhoc.probeSshTunnel(subprocess, {
|
|
507
|
+
host: '127.0.0.1', port: server.port, user: 'ops', auth: 'password', password: 'CHANGE_ME',
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
assert.equal(specs.length, 1)
|
|
511
|
+
assert.equal(res.ok, true, 'the probe proves the SSH layer, never the database behind it')
|
|
512
|
+
assert.equal(res.layer, 'tunnel')
|
|
513
|
+
assert.equal(res.message, currentT('result.sshTestOk', { host: '127.0.0.1', port: String(server.port) }))
|
|
514
|
+
} finally {
|
|
515
|
+
await server.close()
|
|
516
|
+
}
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
test('probeSshTunnel: a DATABASE-protocol failure with no target named is still `tunnel`', async () => {
|
|
520
|
+
// Regression for a real user report: the tunnel opened, the forwarded target
|
|
521
|
+
// closed the connection, and dbhub said only
|
|
522
|
+
// `Connection lost … PROTOCOL_CONNECTION_LOST` + `SSH tunnel closed` — never the
|
|
523
|
+
// forwarded `127.0.0.1:1`. The old rule answered "cannot classify", which told
|
|
524
|
+
// the user nothing even though the SSH layer was demonstrably fine.
|
|
525
|
+
const server = await listenOnce()
|
|
526
|
+
try {
|
|
527
|
+
const { subprocess } = recorder({ stderr: DBHUB_TUNNEL_UP_CAPTURE })
|
|
528
|
+
const res = await adhoc.probeSshTunnel(subprocess, {
|
|
529
|
+
host: '127.0.0.1', port: server.port, user: 'ops', auth: 'password', password: 'CHANGE_ME',
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
assert.equal(res.ok, true, res.message)
|
|
533
|
+
assert.equal(res.layer, 'tunnel')
|
|
534
|
+
assert.ok(res.message.includes('SSH 隧道可用'), res.message)
|
|
535
|
+
} finally {
|
|
536
|
+
await server.close()
|
|
537
|
+
}
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
test('probeSshTunnel: an unclassifiable failure is `unknown`, never guessed', async () => {
|
|
541
|
+
const server = await listenOnce()
|
|
542
|
+
try {
|
|
543
|
+
// No SSH signature and no mention of the forwarded `127.0.0.1:1`: nothing is
|
|
544
|
+
// proven either way, and the honest answer is "could not classify".
|
|
545
|
+
const { subprocess, specs } = recorder({ stderr: 'dbhub stopped for an unknown reason' })
|
|
546
|
+
const res = await adhoc.probeSshTunnel(subprocess, {
|
|
547
|
+
host: '127.0.0.1', port: server.port, user: 'ops', auth: 'password', password: 'CHANGE_ME',
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
assert.equal(specs.length, 1)
|
|
551
|
+
assert.equal(res.ok, false, 'neither the tunnel nor the database step is proven')
|
|
552
|
+
assert.equal(res.layer, 'unknown')
|
|
553
|
+
const prefix = currentT('result.sshTestUnknown', { host: '127.0.0.1', port: String(server.port), msg: '' })
|
|
554
|
+
assert.ok(res.message.startsWith(prefix), res.message)
|
|
555
|
+
assert.ok(res.message.includes('dbhub stopped for an unknown reason'), 'the raw tail is included')
|
|
556
|
+
} finally {
|
|
557
|
+
await server.close()
|
|
558
|
+
}
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
test('probeSshTunnel: a plugin-side spawn failure is `internal`', async () => {
|
|
562
|
+
const server = await listenOnce()
|
|
563
|
+
try {
|
|
564
|
+
const { subprocess, specs } = recorder({ spawnThrows: 'spawn refused' })
|
|
565
|
+
const res = await adhoc.probeSshTunnel(subprocess, {
|
|
566
|
+
host: '127.0.0.1', port: server.port, user: 'ops', auth: 'password', password: 'CHANGE_ME',
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
assert.equal(specs.length, 1, 'the spawn was attempted and refused')
|
|
570
|
+
assert.equal(res.ok, false)
|
|
571
|
+
assert.equal(res.layer, 'internal')
|
|
572
|
+
assert.ok(res.message.includes(currentT('result.sshTestInternal', { msg: '' })), res.message)
|
|
573
|
+
assert.ok(res.message.includes(PLUGIN_FAIL + ': spawn refused'), res.message)
|
|
574
|
+
} finally {
|
|
575
|
+
await server.close()
|
|
576
|
+
}
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
// ── mergeSshOptions: "blank secret = keep the stored value" ────────────────
|
|
580
|
+
|
|
581
|
+
test('mergeSshOptions keeps the stored password when the form sends a blank one', () => {
|
|
582
|
+
// The card cannot echo a secret back, so a user editing only the host — or
|
|
583
|
+
// pressing "test the tunnel" on a saved row — sends no/blank password.
|
|
584
|
+
const blank = cfg.mergeSshOptions({ host: '127.0.0.1', user: 'ops', auth: 'password', password: '' }, STORED_PASSWORD)
|
|
585
|
+
assert.equal(blank.host, '127.0.0.1', 'non-secret fields overwrite')
|
|
586
|
+
assert.equal(blank.port, 22, 'a blank port falls back to the stored one')
|
|
587
|
+
assert.equal(blank.user, 'ops')
|
|
588
|
+
assert.equal(blank.password, 'CHANGE_ME', 'the stored secret is kept')
|
|
589
|
+
|
|
590
|
+
const omitted = cfg.mergeSshOptions({ host: '127.0.0.1', user: 'ops', auth: 'password' }, STORED_PASSWORD)
|
|
591
|
+
assert.equal(omitted.password, 'CHANGE_ME', 'an omitted secret key behaves like a blank one')
|
|
592
|
+
|
|
593
|
+
// Whatever the merge produced must still be a usable block.
|
|
594
|
+
assert.deepEqual(cfg.normalizeSshOptions(blank), {
|
|
595
|
+
host: '127.0.0.1', port: 22, user: 'ops', auth: 'password', password: 'CHANGE_ME',
|
|
596
|
+
})
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
test('mergeSshOptions falls back to the stored value for every blank non-secret field', () => {
|
|
600
|
+
const merged = cfg.mergeSshOptions({ host: '', port: '', user: '', auth: 'password' }, STORED_PASSWORD)
|
|
601
|
+
assert.equal(merged.host, STORED_PASSWORD.host)
|
|
602
|
+
assert.equal(merged.port, STORED_PASSWORD.port)
|
|
603
|
+
assert.equal(merged.user, STORED_PASSWORD.user)
|
|
604
|
+
assert.equal(merged.password, 'CHANGE_ME')
|
|
605
|
+
assert.deepEqual(cfg.normalizeSshOptions(merged), cfg.normalizeSshOptions(STORED_PASSWORD))
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
test('mergeSshOptions drops the other kind\'s secret when the auth kind switches', () => {
|
|
609
|
+
// key → password: the passphrase AND the key path go away.
|
|
610
|
+
const toPassword = cfg.mergeSshOptions(
|
|
611
|
+
{ host: '192.0.2.10', user: 'ops', auth: 'password', password: 'CHANGE_ME' },
|
|
612
|
+
STORED_KEY,
|
|
613
|
+
)
|
|
614
|
+
assert.equal('keyPath' in toPassword, false, 'the key path must not survive the switch')
|
|
615
|
+
assert.equal('passphrase' in toPassword, false, 'the passphrase must not survive the switch')
|
|
616
|
+
assert.equal(toPassword.password, 'CHANGE_ME')
|
|
617
|
+
assert.deepEqual(cfg.normalizeSshOptions(toPassword), {
|
|
618
|
+
host: '192.0.2.10', port: 2222, user: 'ops', auth: 'password', password: 'CHANGE_ME',
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
// password → key: the password goes away, and the new key path is required.
|
|
622
|
+
const toKey = cfg.mergeSshOptions(
|
|
623
|
+
{ host: '192.0.2.10', user: 'ops', auth: 'key', keyPath: '~/.ssh/id_ed25519' },
|
|
624
|
+
STORED_PASSWORD,
|
|
625
|
+
)
|
|
626
|
+
assert.equal('password' in toKey, false, 'the password must not survive the switch')
|
|
627
|
+
assert.equal(toKey.keyPath, '~/.ssh/id_ed25519')
|
|
628
|
+
assert.equal('passphrase' in toKey, false, 'no passphrase was given, none is invented')
|
|
629
|
+
assert.deepEqual(cfg.normalizeSshOptions(toKey), {
|
|
630
|
+
host: '192.0.2.10', port: 22, user: 'ops', auth: 'key', keyPath: '~/.ssh/id_ed25519',
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
// The same kind keeps its secret; a switch with nothing stored invents none.
|
|
634
|
+
const sameKind = cfg.mergeSshOptions({ host: '192.0.2.10', user: 'ops', auth: 'key' }, STORED_KEY)
|
|
635
|
+
assert.equal(sameKind.keyPath, '~/.ssh/id_ed25519')
|
|
636
|
+
assert.equal(sameKind.passphrase, 'CHANGE_ME')
|
|
637
|
+
assert.equal('password' in cfg.mergeSshOptions({ host: '192.0.2.10', user: 'ops', auth: 'password' }, null), false)
|
|
638
|
+
assert.equal(cfg.normalizeSshOptions(cfg.mergeSshOptions({ host: '192.0.2.10', user: 'ops', auth: 'password' }, null)), null)
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
test('mergeSshOptions passes a non-object through and keeps a single ProxyJump hop', () => {
|
|
642
|
+
assert.equal(cfg.mergeSshOptions(null, STORED_PASSWORD), null)
|
|
643
|
+
assert.equal(cfg.mergeSshOptions('nope', STORED_PASSWORD), 'nope')
|
|
644
|
+
assert.deepEqual(cfg.mergeSshOptions([], STORED_PASSWORD), [])
|
|
645
|
+
|
|
646
|
+
const jumped = cfg.mergeSshOptions(
|
|
647
|
+
{ host: '192.0.2.10', user: 'ops', auth: 'password', proxyJump: 'bastion.example.com:22' },
|
|
648
|
+
STORED_PASSWORD,
|
|
649
|
+
)
|
|
650
|
+
assert.equal(jumped.proxyJump, 'bastion.example.com:22')
|
|
651
|
+
assert.equal(cfg.normalizeSshOptions(jumped).proxyJump, 'bastion.example.com:22')
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
test('normalizeSshOptions: defaults, control-character stripping and hard rejects', () => {
|
|
655
|
+
const base = { host: '127.0.0.1', user: 'ops', auth: 'password', password: 'p' }
|
|
656
|
+
assert.deepEqual(cfg.normalizeSshOptions(base), { host: '127.0.0.1', port: 22, user: 'ops', auth: 'password', password: 'p' })
|
|
657
|
+
assert.equal(cfg.normalizeSshOptions({ ...base, port: 'abc' }).port, 22, 'a non-numeric port falls back to 22')
|
|
658
|
+
assert.equal(cfg.normalizeSshOptions({ ...base, port: 70000 }).port, 22, 'an out-of-range port falls back to 22')
|
|
659
|
+
assert.equal(cfg.normalizeSshOptions({ ...base, port: '2222' }).port, 2222)
|
|
660
|
+
assert.equal(
|
|
661
|
+
cfg.normalizeSshOptions({ ...base, host: '127.0.0.1\u0000\u001b', user: 'o\u0007ps' }).user,
|
|
662
|
+
'ops',
|
|
663
|
+
'control characters are stripped, not stored',
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
for (const bad of [
|
|
667
|
+
null,
|
|
668
|
+
undefined,
|
|
669
|
+
'nope',
|
|
670
|
+
[],
|
|
671
|
+
{ user: 'ops', auth: 'password', password: 'p' },
|
|
672
|
+
{ host: '127.0.0.1', auth: 'password', password: 'p' },
|
|
673
|
+
{ host: '127.0.0.1', user: 'ops', auth: 'agent', password: 'p' },
|
|
674
|
+
{ host: '127.0.0.1', user: 'ops', auth: 'password' },
|
|
675
|
+
{ host: '127.0.0.1', user: 'ops', auth: 'key' },
|
|
676
|
+
]) {
|
|
677
|
+
assert.equal(cfg.normalizeSshOptions(bad), null, JSON.stringify(bad))
|
|
678
|
+
}
|
|
679
|
+
})
|
|
680
|
+
|
|
681
|
+
// ── scrubSecrets ───────────────────────────────────────────────────────────
|
|
682
|
+
|
|
683
|
+
test('scrubSecrets collapses the whole userinfo of a URL-shaped connection string', () => {
|
|
684
|
+
const out = cfg.scrubSecrets('mysql://root:secret.pw@127.0.0.1:3306/app', 'mysql://root:secret.pw@127.0.0.1:3306/app')
|
|
685
|
+
assert.equal(out, 'mysql://****:****@127.0.0.1:3306/app')
|
|
686
|
+
assert.equal(out.includes('root'), false, 'the user name is a secret too')
|
|
687
|
+
assert.equal(out.includes('secret.pw'), false)
|
|
688
|
+
assert.ok(out.includes('://****:****@'))
|
|
689
|
+
|
|
690
|
+
// dbhub's own banner masks only the password (`mysql://root:****@host:3306/db`)
|
|
691
|
+
// — that exact text is what lands in a connection-test diagnostic.
|
|
692
|
+
const banner = cfg.scrubSecrets('Configuration source: mysql://root:****@127.0.0.1:3306/app', undefined)
|
|
693
|
+
assert.equal(banner, 'Configuration source: mysql://****:****@127.0.0.1:3306/app')
|
|
694
|
+
assert.equal(banner.includes('root'), false)
|
|
695
|
+
|
|
696
|
+
// The measured capture: dbhub masks the password itself (eight stars) but
|
|
697
|
+
// prints the user name, so the whole userinfo has to collapse. The DATABASE
|
|
698
|
+
// name is metadata and legitimately survives.
|
|
699
|
+
const scrubbed = cfg.scrubSecrets(DBHUB_SSH_CAPTURE, adhoc.SSH_PROBE_DSN)
|
|
700
|
+
assert.ok(scrubbed.includes(' - default: mysql://****:****@127.0.0.1:1/probe'), scrubbed)
|
|
701
|
+
assert.equal(/probe:/.test(scrubbed), false, 'the user name is gone (the database name stays)')
|
|
702
|
+
assert.ok(scrubbed.includes('SSH connection error'), 'the reason is untouched')
|
|
703
|
+
})
|
|
704
|
+
|
|
705
|
+
test('scrubSecrets strips a bare userinfo and leaves a metadata label alone', () => {
|
|
706
|
+
const bare = cfg.scrubSecrets('mysql://ops@127.0.0.1:3306/app', undefined)
|
|
707
|
+
assert.equal(bare, 'mysql://****:****@127.0.0.1:3306/app')
|
|
708
|
+
assert.equal(bare.includes('ops'), false)
|
|
709
|
+
|
|
710
|
+
// A DSN without userinfo is a plain metadata label: it must pass through
|
|
711
|
+
// byte for byte (it is exactly the `connLabel` the model already sees).
|
|
712
|
+
const label = 'mysql://127.0.0.1:3306/app'
|
|
713
|
+
assert.equal(cfg.scrubSecrets(label, label), label)
|
|
714
|
+
assert.equal(cfg.scrubSecrets(label, undefined), label)
|
|
715
|
+
assert.equal(cfg.scrubSecrets('postgres://127.0.0.1:5432/app', undefined), 'postgres://127.0.0.1:5432/app')
|
|
716
|
+
})
|
|
717
|
+
|
|
718
|
+
test('scrubSecrets still masks password tokens and every extra secret', () => {
|
|
719
|
+
assert.equal(cfg.scrubSecrets('password=secret.pw and passwd:CHANGE_ME', undefined), 'password=**** and passwd:****')
|
|
720
|
+
assert.equal(cfg.scrubSecrets('pwd=CHANGE_ME', undefined), 'pwd=****')
|
|
721
|
+
assert.equal(cfg.scrubSecrets('mysql://root:secret.pw@127.0.0.1:3306/app', undefined), 'mysql://****:****@127.0.0.1:3306/app')
|
|
722
|
+
|
|
723
|
+
// extraSecrets carries what a generated TOML put in the child environment
|
|
724
|
+
// (the SSH login password / key passphrase).
|
|
725
|
+
const text = 'tunnel login used CHANGE_ME for user ops'
|
|
726
|
+
assert.equal(cfg.scrubSecrets(text, undefined, ['CHANGE_ME']), 'tunnel login used **** for user ops')
|
|
727
|
+
assert.equal(cfg.scrubSecrets(text, undefined, ['', null, undefined]), text, 'empty extras are ignored')
|
|
728
|
+
})
|
|
729
|
+
|
|
730
|
+
// ── the handleTestOp SSH branch (indirect: it is not exported) ──────────────
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* The bare minimum `apply(ctx, config)` needs to wire BOTH channels: a tool
|
|
734
|
+
* registry, a workspace registry, one `inject(['settings'])` scope (the legacy
|
|
735
|
+
* namespace) and one `inject(['connection'])` scope whose `fetch.register`
|
|
736
|
+
* records the HTTP routes.
|
|
737
|
+
*/
|
|
738
|
+
function hostCtx({ subprocess, workspaces }) {
|
|
739
|
+
const tools = []
|
|
740
|
+
const routes = []
|
|
741
|
+
const settings = {
|
|
742
|
+
register: () => ({ replace: async () => {}, watch: () => () => {} }),
|
|
743
|
+
describe: () => [],
|
|
744
|
+
}
|
|
745
|
+
const scopeFor = (names) => {
|
|
746
|
+
const scope = { effect: (fn) => { try { fn() } catch (e) { /* contained */ } return () => {} } }
|
|
747
|
+
if (names.includes('settings')) scope.settings = settings
|
|
748
|
+
if (names.includes('connection')) scope.connection = { fetch: { register: (route) => { routes.push(route) } } }
|
|
749
|
+
return scope
|
|
750
|
+
}
|
|
751
|
+
const ctx = {
|
|
752
|
+
tools: { register: (def) => { tools.push(def); return () => {} } },
|
|
753
|
+
get(name) {
|
|
754
|
+
if (name === 'subprocess') return subprocess
|
|
755
|
+
if (name === 'workspaceRegistry') return { list: async () => workspaces }
|
|
756
|
+
if (name === 'fs') return { resolve: async () => { throw new Error('no .env') } }
|
|
757
|
+
return undefined
|
|
758
|
+
},
|
|
759
|
+
inject: (names, callback) => { callback(scopeFor(names)); return () => {} },
|
|
760
|
+
effect: (fn) => { try { fn() } catch (e) { /* contained */ } return () => {} },
|
|
761
|
+
on: () => {},
|
|
762
|
+
}
|
|
763
|
+
return { ctx, tools, routes }
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/** POST one card command through the bridge route and decode the reply. */
|
|
767
|
+
async function postOp(route, op) {
|
|
768
|
+
const response = await route.fetch(new Request('http://127.0.0.1' + BRIDGE_ROUTES.op, {
|
|
769
|
+
method: 'POST',
|
|
770
|
+
headers: { 'content-type': 'application/json' },
|
|
771
|
+
body: JSON.stringify({ op }),
|
|
772
|
+
}))
|
|
773
|
+
assert.equal(response.status, 200)
|
|
774
|
+
return response.json()
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
test('the {op:"test", kind:"ssh"} branch merges the stored secret and reports via testResult', async () => {
|
|
778
|
+
const WS = 'D:/work/probe'
|
|
779
|
+
assert.equal(typeof index.handleTestOp, 'undefined', 'handleTestOp is internal — this is the indirect path')
|
|
780
|
+
|
|
781
|
+
// The saved row carries the secret; the "form" echoes no password back.
|
|
782
|
+
const port = await closedPort()
|
|
783
|
+
cfg.setWorkspaceEnv(WS, 'default', DSN, 'user')
|
|
784
|
+
cfg.setWorkspaceEnv(WS, 'tunnel', DSN, 'user', {
|
|
785
|
+
ssh: { host: '127.0.0.1', port, user: 'ops', auth: 'password', password: 'CHANGE_ME' },
|
|
786
|
+
})
|
|
787
|
+
cfg.setWorkspaceEnv(WS, 'broken', DSN, 'user')
|
|
788
|
+
|
|
789
|
+
const { subprocess } = recorder()
|
|
790
|
+
const { ctx, tools, routes } = hostCtx({ subprocess, workspaces: [{ path: WS, title: 'probe' }] })
|
|
791
|
+
await index.apply(ctx, undefined)
|
|
792
|
+
assert.equal(tools.length, 4, 'apply still registers the four constant tools')
|
|
793
|
+
assert.equal(routes.length, 4, 'apply still wires the four bridge routes')
|
|
794
|
+
const opRoute = routes.find((route) => route.path === BRIDGE_ROUTES.op)
|
|
795
|
+
assert.ok(opRoute, 'the /op route must be registered')
|
|
796
|
+
|
|
797
|
+
// (a) A saved row whose stored password is not echoed back: the merge makes
|
|
798
|
+
// the block usable, so the probe gets past `config` and reaches the network.
|
|
799
|
+
const first = await postOp(opRoute, {
|
|
800
|
+
op: 'test',
|
|
801
|
+
kind: 'ssh',
|
|
802
|
+
workspace: WS,
|
|
803
|
+
env: 'tunnel',
|
|
804
|
+
nonce: 'probe-1',
|
|
805
|
+
ssh: { host: '127.0.0.1', port, user: 'ops', auth: 'password' },
|
|
806
|
+
})
|
|
807
|
+
assert.deepEqual(first.patches, [], 'an SSH-layer test changes no rows')
|
|
808
|
+
const report = JSON.parse(first.value.testResult)
|
|
809
|
+
assert.equal(report.nonce, 'probe-1', 'the report is addressed to the nonce the card sent')
|
|
810
|
+
assert.equal(report.ok, false)
|
|
811
|
+
const code = (await adhoc.tcpReachable('127.0.0.1', port)).code
|
|
812
|
+
assert.equal(
|
|
813
|
+
report.message,
|
|
814
|
+
currentT('result.sshTestUnreachable', { host: '127.0.0.1', port: String(port), code }),
|
|
815
|
+
'a `config` answer here would mean the stored secret was NOT merged',
|
|
816
|
+
)
|
|
817
|
+
assert.equal(report.message.includes('CHANGE_ME'), false, 'the report never echoes the secret')
|
|
818
|
+
|
|
819
|
+
// (b) An incomplete block with no saved ssh row: the branch reports the
|
|
820
|
+
// localized "incomplete configuration" answer (naming the missing fields)
|
|
821
|
+
// instead of probing. The merge turned `{host}` into `{host, port, user:'',
|
|
822
|
+
// auth:''}`, so what is missing is the user and the auth kind.
|
|
823
|
+
const second = await postOp(opRoute, {
|
|
824
|
+
op: 'test', kind: 'ssh', workspace: WS, env: 'broken', nonce: 'probe-2', ssh: { host: '127.0.0.1' },
|
|
825
|
+
})
|
|
826
|
+
const broken = JSON.parse(second.value.testResult)
|
|
827
|
+
assert.equal(broken.nonce, 'probe-2')
|
|
828
|
+
assert.equal(broken.ok, false)
|
|
829
|
+
assert.equal(
|
|
830
|
+
broken.message,
|
|
831
|
+
currentT('result.sshTestBadConfig', { missing: currentT('field.user') + '、' + currentT('field.authKind') }),
|
|
832
|
+
)
|
|
833
|
+
assert.deepEqual(second.patches, [])
|
|
834
|
+
})
|