@marcusrbrown/infra 0.9.12 → 0.9.13
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/package.json +1 -1
- package/src/commands/gateway/backup.test.ts +73 -0
- package/src/commands/gateway/backup.ts +2 -0
- package/src/commands/gateway/logs.test.ts +41 -0
- package/src/commands/gateway/logs.ts +2 -0
- package/src/commands/gateway/restore.test.ts +156 -0
- package/src/commands/gateway/restore.ts +12 -1
- package/src/commands/gateway/status.test.ts +65 -0
- package/src/commands/gateway/status.ts +2 -0
- package/src/commands/umami/logs.test.ts +37 -0
- package/src/commands/umami/logs.ts +2 -0
- package/src/commands/umami/status.test.ts +64 -0
- package/src/commands/umami/status.ts +2 -0
- package/src/conventions.test.ts +24 -0
- package/src/lib/known-hosts.test.ts +192 -0
- package/src/lib/known-hosts.ts +76 -0
- package/src/resources/known_hosts +28 -0
package/package.json
CHANGED
|
@@ -265,6 +265,79 @@ describe('backupGatewayCa — atomic write (COR2)', () => {
|
|
|
265
265
|
})
|
|
266
266
|
})
|
|
267
267
|
|
|
268
|
+
// ─── SSH command includes repo-pinned UserKnownHostsFile ─────────────────────
|
|
269
|
+
|
|
270
|
+
describe('backupGatewayCa — SSH command includes UserKnownHostsFile', () => {
|
|
271
|
+
let tmpOutput: string
|
|
272
|
+
|
|
273
|
+
beforeEach(() => {
|
|
274
|
+
tmpOutput = `/tmp/test-backup-known-hosts-${Date.now()}.tar`
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
afterEach(async () => {
|
|
278
|
+
try {
|
|
279
|
+
;(await Bun.file(tmpOutput).exists()) && Bun.spawnSync(['rm', '-f', tmpOutput])
|
|
280
|
+
} catch {
|
|
281
|
+
// ignore
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('passes -o UserKnownHostsFile=<repo>/.github/known_hosts to ssh', async () => {
|
|
286
|
+
let capturedCmd: string[] = []
|
|
287
|
+
const spawnCapture: BackupSpawnFn = (cmd, _opts) => {
|
|
288
|
+
capturedCmd = cmd
|
|
289
|
+
return {
|
|
290
|
+
stdout: new ReadableStream({
|
|
291
|
+
start(controller) {
|
|
292
|
+
controller.enqueue(new Uint8Array([1, 2, 3]))
|
|
293
|
+
controller.close()
|
|
294
|
+
},
|
|
295
|
+
}),
|
|
296
|
+
stderr: new ReadableStream({
|
|
297
|
+
start(controller) {
|
|
298
|
+
controller.close()
|
|
299
|
+
},
|
|
300
|
+
}),
|
|
301
|
+
exited: Promise.resolve(0),
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
await backupGatewayCa({host: 'gateway.example.com', output: tmpOutput, includeCa: true}, spawnCapture)
|
|
306
|
+
|
|
307
|
+
const knownHostsIdx = capturedCmd.findIndex(arg => arg.startsWith('UserKnownHostsFile='))
|
|
308
|
+
expect(knownHostsIdx).toBeGreaterThan(-1)
|
|
309
|
+
expect(capturedCmd[knownHostsIdx - 1]).toBe('-o')
|
|
310
|
+
expect(capturedCmd[knownHostsIdx]).toMatch(/\.github[/\\]known_hosts$/)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('does not weaken StrictHostKeyChecking when UserKnownHostsFile is added', async () => {
|
|
314
|
+
let capturedCmd: string[] = []
|
|
315
|
+
const spawnCapture: BackupSpawnFn = (cmd, _opts) => {
|
|
316
|
+
capturedCmd = cmd
|
|
317
|
+
return {
|
|
318
|
+
stdout: new ReadableStream({
|
|
319
|
+
start(controller) {
|
|
320
|
+
controller.enqueue(new Uint8Array([1, 2, 3]))
|
|
321
|
+
controller.close()
|
|
322
|
+
},
|
|
323
|
+
}),
|
|
324
|
+
stderr: new ReadableStream({
|
|
325
|
+
start(controller) {
|
|
326
|
+
controller.close()
|
|
327
|
+
},
|
|
328
|
+
}),
|
|
329
|
+
exited: Promise.resolve(0),
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
await backupGatewayCa({host: 'gateway.example.com', output: tmpOutput, includeCa: true}, spawnCapture)
|
|
334
|
+
|
|
335
|
+
const strictIdx = capturedCmd.findIndex(arg => arg.startsWith('StrictHostKeyChecking='))
|
|
336
|
+
expect(strictIdx).toBeGreaterThan(-1)
|
|
337
|
+
expect(capturedCmd[strictIdx]).toBe('StrictHostKeyChecking=yes')
|
|
338
|
+
})
|
|
339
|
+
})
|
|
340
|
+
|
|
268
341
|
// ─── SEC1: Tmp file born with mode 0600 (no chmod race) ──────────────────────
|
|
269
342
|
|
|
270
343
|
describe('backupGatewayCa — SEC1: tmp file created with mode 0600 atomically', () => {
|
|
@@ -5,6 +5,7 @@ import type {ActionCtx} from '../../lib/action-ctx'
|
|
|
5
5
|
import {closeSync, constants as fsConstants, openSync, renameSync, unlinkSync, writeSync} from 'node:fs'
|
|
6
6
|
|
|
7
7
|
import {z} from 'zod'
|
|
8
|
+
import {buildKnownHostsArgs} from '../../lib/known-hosts'
|
|
8
9
|
import {validateGatewayHost} from './host'
|
|
9
10
|
|
|
10
11
|
declare const process: {
|
|
@@ -69,6 +70,7 @@ export async function backupGatewayCa(
|
|
|
69
70
|
'BatchMode=yes',
|
|
70
71
|
'-o',
|
|
71
72
|
'StrictHostKeyChecking=yes',
|
|
73
|
+
...buildKnownHostsArgs(),
|
|
72
74
|
`root@${opts.host}`,
|
|
73
75
|
`docker run --rm -v ${MITMPROXY_CERTS_VOLUME}:/src:ro alpine tar -cf - -C /src ${CA_CERT_FILE} ${CA_KEY_FILE}`,
|
|
74
76
|
]
|
|
@@ -220,3 +220,44 @@ describe('tail forwarding', () => {
|
|
|
220
220
|
expect(capturedCmd.join(' ')).toContain('--tail=25')
|
|
221
221
|
})
|
|
222
222
|
})
|
|
223
|
+
|
|
224
|
+
// ─── SSH command includes repo-pinned UserKnownHostsFile ─────────────────────
|
|
225
|
+
|
|
226
|
+
describe('streamGatewayLogs — SSH command includes UserKnownHostsFile', () => {
|
|
227
|
+
it('passes -o UserKnownHostsFile=<repo>/.github/known_hosts to ssh', async () => {
|
|
228
|
+
delete process.env.CI
|
|
229
|
+
|
|
230
|
+
const {streamGatewayLogs} = await import('./logs')
|
|
231
|
+
|
|
232
|
+
let capturedCmd: string[] = []
|
|
233
|
+
const spawnCapture: SpawnFn = (cmd, _opts) => {
|
|
234
|
+
capturedCmd = cmd
|
|
235
|
+
return {exited: Promise.resolve(0)}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
await streamGatewayLogs({host: 'gateway.example.com', service: 'gateway', tail: 100, allowCi: false}, spawnCapture)
|
|
239
|
+
|
|
240
|
+
const knownHostsIdx = capturedCmd.findIndex(arg => arg.startsWith('UserKnownHostsFile='))
|
|
241
|
+
expect(knownHostsIdx).toBeGreaterThan(-1)
|
|
242
|
+
expect(capturedCmd[knownHostsIdx - 1]).toBe('-o')
|
|
243
|
+
expect(capturedCmd[knownHostsIdx]).toMatch(/\.github[/\\]known_hosts$/)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('does not weaken StrictHostKeyChecking when UserKnownHostsFile is added', async () => {
|
|
247
|
+
delete process.env.CI
|
|
248
|
+
|
|
249
|
+
const {streamGatewayLogs} = await import('./logs')
|
|
250
|
+
|
|
251
|
+
let capturedCmd: string[] = []
|
|
252
|
+
const spawnCapture: SpawnFn = (cmd, _opts) => {
|
|
253
|
+
capturedCmd = cmd
|
|
254
|
+
return {exited: Promise.resolve(0)}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
await streamGatewayLogs({host: 'gateway.example.com', service: 'gateway', tail: 100, allowCi: false}, spawnCapture)
|
|
258
|
+
|
|
259
|
+
const strictIdx = capturedCmd.findIndex(arg => arg.startsWith('StrictHostKeyChecking='))
|
|
260
|
+
expect(strictIdx).toBeGreaterThan(-1)
|
|
261
|
+
expect(capturedCmd[strictIdx]).toBe('StrictHostKeyChecking=yes')
|
|
262
|
+
})
|
|
263
|
+
})
|
|
@@ -2,6 +2,7 @@ import type {goke} from 'goke'
|
|
|
2
2
|
|
|
3
3
|
import {z} from 'zod'
|
|
4
4
|
|
|
5
|
+
import {buildKnownHostsArgs} from '../../lib/known-hosts'
|
|
5
6
|
import {validateGatewayHost} from './host'
|
|
6
7
|
|
|
7
8
|
declare const process: {
|
|
@@ -88,6 +89,7 @@ export async function streamGatewayLogs(
|
|
|
88
89
|
'BatchMode=yes',
|
|
89
90
|
'-o',
|
|
90
91
|
'StrictHostKeyChecking=yes',
|
|
92
|
+
...buildKnownHostsArgs(),
|
|
91
93
|
`root@${opts.host}`,
|
|
92
94
|
`docker compose --project-directory ${COMPOSE_PROJECT_DIR} logs --no-color --tail=${opts.tail} ${opts.service}`,
|
|
93
95
|
]
|
|
@@ -403,6 +403,162 @@ describe('restoreGatewayCa — SEC2: unguessable remote tmp path via mktemp', ()
|
|
|
403
403
|
})
|
|
404
404
|
})
|
|
405
405
|
|
|
406
|
+
// ─── SSH and SCP commands include repo-pinned UserKnownHostsFile ─────────────
|
|
407
|
+
|
|
408
|
+
describe('restoreGatewayCa — SSH and SCP commands include UserKnownHostsFile', () => {
|
|
409
|
+
it('passes -o UserKnownHostsFile=<repo>/.github/known_hosts to every ssh invocation', async () => {
|
|
410
|
+
const tarDir = `/tmp/test-kh-ssh-tar-dir-${Date.now()}`
|
|
411
|
+
const realTar = `/tmp/test-kh-ssh-tar-${Date.now()}.tar`
|
|
412
|
+
const certContent = 'CERT'
|
|
413
|
+
const keyContent = 'KEY'
|
|
414
|
+
Bun.spawnSync(['mkdir', '-p', tarDir])
|
|
415
|
+
await Bun.write(`${tarDir}/mitmproxy-ca-cert.pem`, certContent)
|
|
416
|
+
await Bun.write(`${tarDir}/mitmproxy-ca.pem`, keyContent)
|
|
417
|
+
Bun.spawnSync(['tar', '-cf', realTar, '-C', tarDir, 'mitmproxy-ca-cert.pem', 'mitmproxy-ca.pem'])
|
|
418
|
+
|
|
419
|
+
const capturedCmds: string[][] = []
|
|
420
|
+
const spawnCapture: RestoreSpawnFn = (cmd, _opts) => {
|
|
421
|
+
capturedCmds.push([...cmd])
|
|
422
|
+
const cmdStr = cmd.join(' ')
|
|
423
|
+
const isTarTf = cmd[0] === 'tar' && cmd.includes('-tf')
|
|
424
|
+
const isMktemp = cmdStr.includes('mktemp')
|
|
425
|
+
const encoder = new TextEncoder()
|
|
426
|
+
let stdout = ''
|
|
427
|
+
if (isTarTf) stdout = 'mitmproxy-ca-cert.pem\nmitmproxy-ca.pem\n'
|
|
428
|
+
else if (isMktemp) stdout = '/tmp/gateway-ca-restore-kh-test.tar\n'
|
|
429
|
+
else stdout = certContent
|
|
430
|
+
return {
|
|
431
|
+
stdout: new ReadableStream({
|
|
432
|
+
start(controller) {
|
|
433
|
+
controller.enqueue(encoder.encode(stdout))
|
|
434
|
+
controller.close()
|
|
435
|
+
},
|
|
436
|
+
}),
|
|
437
|
+
stderr: new ReadableStream({
|
|
438
|
+
start(controller) {
|
|
439
|
+
controller.close()
|
|
440
|
+
},
|
|
441
|
+
}),
|
|
442
|
+
exited: Promise.resolve(0),
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
await restoreGatewayCa({host: 'gateway.example.com', input: realTar, includeCa: true}, spawnCapture)
|
|
447
|
+
|
|
448
|
+
// Every ssh invocation must include UserKnownHostsFile
|
|
449
|
+
const sshCmds = capturedCmds.filter(c => c[0] === 'ssh')
|
|
450
|
+
expect(sshCmds.length).toBeGreaterThan(0)
|
|
451
|
+
for (const sshCmd of sshCmds) {
|
|
452
|
+
const knownHostsIdx = sshCmd.findIndex(arg => arg.startsWith('UserKnownHostsFile='))
|
|
453
|
+
expect(knownHostsIdx).toBeGreaterThan(-1)
|
|
454
|
+
expect(sshCmd[knownHostsIdx - 1]).toBe('-o')
|
|
455
|
+
expect(sshCmd[knownHostsIdx]).toMatch(/\.github[/\\]known_hosts$/)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
Bun.spawnSync(['rm', '-rf', tarDir, realTar])
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it('passes -o UserKnownHostsFile=<repo>/.github/known_hosts to the scp invocation', async () => {
|
|
462
|
+
const tarDir = `/tmp/test-kh-scp-tar-dir-${Date.now()}`
|
|
463
|
+
const realTar = `/tmp/test-kh-scp-tar-${Date.now()}.tar`
|
|
464
|
+
const certContent = 'CERT'
|
|
465
|
+
const keyContent = 'KEY'
|
|
466
|
+
Bun.spawnSync(['mkdir', '-p', tarDir])
|
|
467
|
+
await Bun.write(`${tarDir}/mitmproxy-ca-cert.pem`, certContent)
|
|
468
|
+
await Bun.write(`${tarDir}/mitmproxy-ca.pem`, keyContent)
|
|
469
|
+
Bun.spawnSync(['tar', '-cf', realTar, '-C', tarDir, 'mitmproxy-ca-cert.pem', 'mitmproxy-ca.pem'])
|
|
470
|
+
|
|
471
|
+
const capturedCmds: string[][] = []
|
|
472
|
+
const spawnCapture: RestoreSpawnFn = (cmd, _opts) => {
|
|
473
|
+
capturedCmds.push([...cmd])
|
|
474
|
+
const cmdStr = cmd.join(' ')
|
|
475
|
+
const isTarTf = cmd[0] === 'tar' && cmd.includes('-tf')
|
|
476
|
+
const isMktemp = cmdStr.includes('mktemp')
|
|
477
|
+
const encoder = new TextEncoder()
|
|
478
|
+
let stdout = ''
|
|
479
|
+
if (isTarTf) stdout = 'mitmproxy-ca-cert.pem\nmitmproxy-ca.pem\n'
|
|
480
|
+
else if (isMktemp) stdout = '/tmp/gateway-ca-restore-kh-scp-test.tar\n'
|
|
481
|
+
else stdout = certContent
|
|
482
|
+
return {
|
|
483
|
+
stdout: new ReadableStream({
|
|
484
|
+
start(controller) {
|
|
485
|
+
controller.enqueue(encoder.encode(stdout))
|
|
486
|
+
controller.close()
|
|
487
|
+
},
|
|
488
|
+
}),
|
|
489
|
+
stderr: new ReadableStream({
|
|
490
|
+
start(controller) {
|
|
491
|
+
controller.close()
|
|
492
|
+
},
|
|
493
|
+
}),
|
|
494
|
+
exited: Promise.resolve(0),
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
await restoreGatewayCa({host: 'gateway.example.com', input: realTar, includeCa: true}, spawnCapture)
|
|
499
|
+
|
|
500
|
+
const scpCmd = capturedCmds.find(c => c[0] === 'scp')
|
|
501
|
+
expect(scpCmd).toBeDefined()
|
|
502
|
+
const knownHostsIdx = scpCmd!.findIndex(arg => arg.startsWith('UserKnownHostsFile='))
|
|
503
|
+
expect(knownHostsIdx).toBeGreaterThan(-1)
|
|
504
|
+
expect(scpCmd![knownHostsIdx - 1]).toBe('-o')
|
|
505
|
+
expect(scpCmd![knownHostsIdx]).toMatch(/\.github[/\\]known_hosts$/)
|
|
506
|
+
|
|
507
|
+
Bun.spawnSync(['rm', '-rf', tarDir, realTar])
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
it('does not weaken StrictHostKeyChecking in ssh or scp commands', async () => {
|
|
511
|
+
const tarDir = `/tmp/test-kh-strict-tar-dir-${Date.now()}`
|
|
512
|
+
const realTar = `/tmp/test-kh-strict-tar-${Date.now()}.tar`
|
|
513
|
+
const certContent = 'CERT'
|
|
514
|
+
const keyContent = 'KEY'
|
|
515
|
+
Bun.spawnSync(['mkdir', '-p', tarDir])
|
|
516
|
+
await Bun.write(`${tarDir}/mitmproxy-ca-cert.pem`, certContent)
|
|
517
|
+
await Bun.write(`${tarDir}/mitmproxy-ca.pem`, keyContent)
|
|
518
|
+
Bun.spawnSync(['tar', '-cf', realTar, '-C', tarDir, 'mitmproxy-ca-cert.pem', 'mitmproxy-ca.pem'])
|
|
519
|
+
|
|
520
|
+
const capturedCmds: string[][] = []
|
|
521
|
+
const spawnCapture: RestoreSpawnFn = (cmd, _opts) => {
|
|
522
|
+
capturedCmds.push([...cmd])
|
|
523
|
+
const cmdStr = cmd.join(' ')
|
|
524
|
+
const isTarTf = cmd[0] === 'tar' && cmd.includes('-tf')
|
|
525
|
+
const isMktemp = cmdStr.includes('mktemp')
|
|
526
|
+
const encoder = new TextEncoder()
|
|
527
|
+
let stdout = ''
|
|
528
|
+
if (isTarTf) stdout = 'mitmproxy-ca-cert.pem\nmitmproxy-ca.pem\n'
|
|
529
|
+
else if (isMktemp) stdout = '/tmp/gateway-ca-restore-kh-strict-test.tar\n'
|
|
530
|
+
else stdout = certContent
|
|
531
|
+
return {
|
|
532
|
+
stdout: new ReadableStream({
|
|
533
|
+
start(controller) {
|
|
534
|
+
controller.enqueue(encoder.encode(stdout))
|
|
535
|
+
controller.close()
|
|
536
|
+
},
|
|
537
|
+
}),
|
|
538
|
+
stderr: new ReadableStream({
|
|
539
|
+
start(controller) {
|
|
540
|
+
controller.close()
|
|
541
|
+
},
|
|
542
|
+
}),
|
|
543
|
+
exited: Promise.resolve(0),
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
await restoreGatewayCa({host: 'gateway.example.com', input: realTar, includeCa: true}, spawnCapture)
|
|
548
|
+
|
|
549
|
+
// All ssh and scp commands must have StrictHostKeyChecking=yes
|
|
550
|
+
const sshAndScpCmds = capturedCmds.filter(c => c[0] === 'ssh' || c[0] === 'scp')
|
|
551
|
+
expect(sshAndScpCmds.length).toBeGreaterThan(0)
|
|
552
|
+
for (const cmd of sshAndScpCmds) {
|
|
553
|
+
const strictIdx = cmd.findIndex(arg => arg.startsWith('StrictHostKeyChecking='))
|
|
554
|
+
expect(strictIdx).toBeGreaterThan(-1)
|
|
555
|
+
expect(cmd[strictIdx]).toBe('StrictHostKeyChecking=yes')
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
Bun.spawnSync(['rm', '-rf', tarDir, realTar])
|
|
559
|
+
})
|
|
560
|
+
})
|
|
561
|
+
|
|
406
562
|
// ─── COR1: validateBackupArchive ─────────────────────────────────────────────
|
|
407
563
|
|
|
408
564
|
describe('validateBackupArchive — COR1', () => {
|
|
@@ -2,6 +2,7 @@ import type {goke} from 'goke'
|
|
|
2
2
|
|
|
3
3
|
import {z} from 'zod'
|
|
4
4
|
|
|
5
|
+
import {buildKnownHostsArgs} from '../../lib/known-hosts'
|
|
5
6
|
import {validateGatewayHost} from './host'
|
|
6
7
|
|
|
7
8
|
declare const process: {
|
|
@@ -38,7 +39,16 @@ export type RestoreResult = {ok: true; confirmed: true} | {ok: false; error: str
|
|
|
38
39
|
|
|
39
40
|
/** Returns the argv array for an SSH command to the given host. */
|
|
40
41
|
function sshCommand(host: string, remote: string): string[] {
|
|
41
|
-
return [
|
|
42
|
+
return [
|
|
43
|
+
'ssh',
|
|
44
|
+
'-o',
|
|
45
|
+
'BatchMode=yes',
|
|
46
|
+
'-o',
|
|
47
|
+
'StrictHostKeyChecking=yes',
|
|
48
|
+
...buildKnownHostsArgs(),
|
|
49
|
+
`root@${host}`,
|
|
50
|
+
remote,
|
|
51
|
+
]
|
|
42
52
|
}
|
|
43
53
|
|
|
44
54
|
async function spawnText(
|
|
@@ -143,6 +153,7 @@ export async function restoreGatewayCa(
|
|
|
143
153
|
'BatchMode=yes',
|
|
144
154
|
'-o',
|
|
145
155
|
'StrictHostKeyChecking=yes',
|
|
156
|
+
...buildKnownHostsArgs(),
|
|
146
157
|
opts.input,
|
|
147
158
|
`root@${opts.host}:${tmpRemote}`,
|
|
148
159
|
]
|
|
@@ -139,6 +139,71 @@ function makeSpawnError(message: string): SpawnFn {
|
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
// ─── SSH command includes repo-pinned UserKnownHostsFile ─────────────────────
|
|
143
|
+
|
|
144
|
+
describe('getGatewayComposeStatus — SSH command includes UserKnownHostsFile', () => {
|
|
145
|
+
it('passes -o UserKnownHostsFile=<repo>/.github/known_hosts to ssh when the file exists', async () => {
|
|
146
|
+
const {getGatewayComposeStatus} = await import('./status')
|
|
147
|
+
let capturedCmd: string[] = []
|
|
148
|
+
|
|
149
|
+
const capturingSpawn: SpawnFn = (cmd, _opts) => {
|
|
150
|
+
capturedCmd = cmd
|
|
151
|
+
const encoder = new TextEncoder()
|
|
152
|
+
return {
|
|
153
|
+
stdout: new ReadableStream({
|
|
154
|
+
start(controller) {
|
|
155
|
+
controller.enqueue(encoder.encode(JSON.stringify([{Name: 'gateway', State: 'running', Health: 'healthy'}])))
|
|
156
|
+
controller.close()
|
|
157
|
+
},
|
|
158
|
+
}),
|
|
159
|
+
stderr: new ReadableStream({
|
|
160
|
+
start(c) {
|
|
161
|
+
c.close()
|
|
162
|
+
},
|
|
163
|
+
}),
|
|
164
|
+
exited: Promise.resolve(0),
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
await getGatewayComposeStatus('gateway.fro.bot', capturingSpawn)
|
|
169
|
+
|
|
170
|
+
const knownHostsIdx = capturedCmd.findIndex(arg => arg.startsWith('UserKnownHostsFile='))
|
|
171
|
+
expect(knownHostsIdx).toBeGreaterThan(-1)
|
|
172
|
+
expect(capturedCmd[knownHostsIdx - 1]).toBe('-o')
|
|
173
|
+
expect(capturedCmd[knownHostsIdx]).toMatch(/\.github[/\\]known_hosts$/)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('does not weaken StrictHostKeyChecking when UserKnownHostsFile is added', async () => {
|
|
177
|
+
const {getGatewayComposeStatus} = await import('./status')
|
|
178
|
+
let capturedCmd: string[] = []
|
|
179
|
+
|
|
180
|
+
const capturingSpawn: SpawnFn = (cmd, _opts) => {
|
|
181
|
+
capturedCmd = cmd
|
|
182
|
+
const encoder = new TextEncoder()
|
|
183
|
+
return {
|
|
184
|
+
stdout: new ReadableStream({
|
|
185
|
+
start(controller) {
|
|
186
|
+
controller.enqueue(encoder.encode(JSON.stringify([{Name: 'gateway', State: 'running', Health: 'healthy'}])))
|
|
187
|
+
controller.close()
|
|
188
|
+
},
|
|
189
|
+
}),
|
|
190
|
+
stderr: new ReadableStream({
|
|
191
|
+
start(c) {
|
|
192
|
+
c.close()
|
|
193
|
+
},
|
|
194
|
+
}),
|
|
195
|
+
exited: Promise.resolve(0),
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
await getGatewayComposeStatus('gateway.fro.bot', capturingSpawn)
|
|
200
|
+
|
|
201
|
+
const strictIdx = capturedCmd.findIndex(arg => arg.startsWith('StrictHostKeyChecking='))
|
|
202
|
+
expect(strictIdx).toBeGreaterThan(-1)
|
|
203
|
+
expect(capturedCmd[strictIdx]).toBe('StrictHostKeyChecking=yes')
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
142
207
|
describe('getGatewayComposeStatus — host validation (SEC1)', () => {
|
|
143
208
|
it('rejects a leading-hyphen host and does not invoke ssh', async () => {
|
|
144
209
|
const {getGatewayComposeStatus} = await import('./status')
|
|
@@ -3,6 +3,7 @@ import type {ActionCtx} from '../../lib/action-ctx'
|
|
|
3
3
|
import type {StatusSummary} from '../status'
|
|
4
4
|
import {z} from 'zod'
|
|
5
5
|
|
|
6
|
+
import {buildKnownHostsArgs} from '../../lib/known-hosts'
|
|
6
7
|
import {redactHost} from '../../lib/redact'
|
|
7
8
|
// ─── Minimal ctx interface (subset of GokeExecutionContext used by this action) ─
|
|
8
9
|
import {validateGatewayHost} from './host'
|
|
@@ -108,6 +109,7 @@ export async function getGatewayComposeStatus(
|
|
|
108
109
|
'BatchMode=yes',
|
|
109
110
|
'-o',
|
|
110
111
|
'StrictHostKeyChecking=yes',
|
|
112
|
+
...buildKnownHostsArgs(),
|
|
111
113
|
`root@${host}`,
|
|
112
114
|
`docker compose --project-directory ${COMPOSE_PROJECT_DIR} ps --format json`,
|
|
113
115
|
]
|
|
@@ -152,3 +152,40 @@ describe('logs command', () => {
|
|
|
152
152
|
expect(spawnCalled).toBe(false)
|
|
153
153
|
})
|
|
154
154
|
})
|
|
155
|
+
|
|
156
|
+
// ─── SSH command includes repo-pinned UserKnownHostsFile ─────────────────────
|
|
157
|
+
|
|
158
|
+
describe('streamUmamiLogs — SSH command includes UserKnownHostsFile', () => {
|
|
159
|
+
it('passes -o UserKnownHostsFile=<repo>/.github/known_hosts to ssh', async () => {
|
|
160
|
+
delete process.env.CI
|
|
161
|
+
|
|
162
|
+
let capturedCmd: string[] = []
|
|
163
|
+
const spawnCapture: LogsSpawnFn = (cmd, _opts) => {
|
|
164
|
+
capturedCmd = cmd
|
|
165
|
+
return {exited: Promise.resolve(0)}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
await streamUmamiLogs({host: 'metrics.fro.bot', service: 'umami', tail: 100, allowCi: false}, spawnCapture)
|
|
169
|
+
|
|
170
|
+
const knownHostsIdx = capturedCmd.findIndex(arg => arg.startsWith('UserKnownHostsFile='))
|
|
171
|
+
expect(knownHostsIdx).toBeGreaterThan(-1)
|
|
172
|
+
expect(capturedCmd[knownHostsIdx - 1]).toBe('-o')
|
|
173
|
+
expect(capturedCmd[knownHostsIdx]).toMatch(/\.github[/\\]known_hosts$/)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('does not weaken StrictHostKeyChecking when UserKnownHostsFile is added', async () => {
|
|
177
|
+
delete process.env.CI
|
|
178
|
+
|
|
179
|
+
let capturedCmd: string[] = []
|
|
180
|
+
const spawnCapture: LogsSpawnFn = (cmd, _opts) => {
|
|
181
|
+
capturedCmd = cmd
|
|
182
|
+
return {exited: Promise.resolve(0)}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
await streamUmamiLogs({host: 'metrics.fro.bot', service: 'umami', tail: 100, allowCi: false}, spawnCapture)
|
|
186
|
+
|
|
187
|
+
const strictIdx = capturedCmd.findIndex(arg => arg.startsWith('StrictHostKeyChecking='))
|
|
188
|
+
expect(strictIdx).toBeGreaterThan(-1)
|
|
189
|
+
expect(capturedCmd[strictIdx]).toBe('StrictHostKeyChecking=yes')
|
|
190
|
+
})
|
|
191
|
+
})
|
|
@@ -2,6 +2,7 @@ import type {goke} from 'goke'
|
|
|
2
2
|
|
|
3
3
|
import {z} from 'zod'
|
|
4
4
|
|
|
5
|
+
import {buildKnownHostsArgs} from '../../lib/known-hosts'
|
|
5
6
|
import {validateUmamiHost} from './host'
|
|
6
7
|
|
|
7
8
|
declare const process: {
|
|
@@ -91,6 +92,7 @@ export async function streamUmamiLogs(
|
|
|
91
92
|
'BatchMode=yes',
|
|
92
93
|
'-o',
|
|
93
94
|
'StrictHostKeyChecking=yes',
|
|
95
|
+
...buildKnownHostsArgs(),
|
|
94
96
|
`root@${opts.host}`,
|
|
95
97
|
`docker compose --project-directory ${COMPOSE_PROJECT_DIR} logs --no-color --tail=${opts.tail} ${opts.service}`,
|
|
96
98
|
]
|
|
@@ -133,6 +133,70 @@ function makeSpawn(stdout: string, stderr = '', exitCode = 0): SpawnFn {
|
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
// ─── SSH command includes repo-pinned UserKnownHostsFile ─────────────────────
|
|
137
|
+
|
|
138
|
+
describe('getUmamiComposeStatus — SSH command includes UserKnownHostsFile', () => {
|
|
139
|
+
it('passes -o UserKnownHostsFile=<repo>/.github/known_hosts to ssh when the file exists', async () => {
|
|
140
|
+
let capturedCmd: string[] = []
|
|
141
|
+
|
|
142
|
+
const capturingSpawn: SpawnFn = (cmd, _opts) => {
|
|
143
|
+
capturedCmd = cmd
|
|
144
|
+
const enc = new TextEncoder()
|
|
145
|
+
return {
|
|
146
|
+
stdout: new ReadableStream({
|
|
147
|
+
start(c) {
|
|
148
|
+
c.enqueue(enc.encode(JSON.stringify([{Name: 'umami', State: 'running', Health: 'healthy'}])))
|
|
149
|
+
c.close()
|
|
150
|
+
},
|
|
151
|
+
}),
|
|
152
|
+
stderr: new ReadableStream({
|
|
153
|
+
start(c) {
|
|
154
|
+
c.close()
|
|
155
|
+
},
|
|
156
|
+
}),
|
|
157
|
+
exited: Promise.resolve(0),
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
await getUmamiComposeStatus('metrics.fro.bot', capturingSpawn)
|
|
162
|
+
|
|
163
|
+
// Find the UserKnownHostsFile option in the ssh command
|
|
164
|
+
const knownHostsIdx = capturedCmd.findIndex(arg => arg.startsWith('UserKnownHostsFile='))
|
|
165
|
+
expect(knownHostsIdx).toBeGreaterThan(-1)
|
|
166
|
+
expect(capturedCmd[knownHostsIdx - 1]).toBe('-o')
|
|
167
|
+
expect(capturedCmd[knownHostsIdx]).toMatch(/\.github[/\\]known_hosts$/)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('does not weaken StrictHostKeyChecking when UserKnownHostsFile is added', async () => {
|
|
171
|
+
let capturedCmd: string[] = []
|
|
172
|
+
|
|
173
|
+
const capturingSpawn: SpawnFn = (cmd, _opts) => {
|
|
174
|
+
capturedCmd = cmd
|
|
175
|
+
const enc = new TextEncoder()
|
|
176
|
+
return {
|
|
177
|
+
stdout: new ReadableStream({
|
|
178
|
+
start(c) {
|
|
179
|
+
c.enqueue(enc.encode(JSON.stringify([{Name: 'umami', State: 'running', Health: 'healthy'}])))
|
|
180
|
+
c.close()
|
|
181
|
+
},
|
|
182
|
+
}),
|
|
183
|
+
stderr: new ReadableStream({
|
|
184
|
+
start(c) {
|
|
185
|
+
c.close()
|
|
186
|
+
},
|
|
187
|
+
}),
|
|
188
|
+
exited: Promise.resolve(0),
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
await getUmamiComposeStatus('metrics.fro.bot', capturingSpawn)
|
|
193
|
+
|
|
194
|
+
const strictIdx = capturedCmd.findIndex(arg => arg.startsWith('StrictHostKeyChecking='))
|
|
195
|
+
expect(strictIdx).toBeGreaterThan(-1)
|
|
196
|
+
expect(capturedCmd[strictIdx]).toBe('StrictHostKeyChecking=yes')
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
136
200
|
describe('getUmamiComposeStatus', () => {
|
|
137
201
|
it('returns service rows from NDJSON output', async () => {
|
|
138
202
|
const ndjson = [
|
|
@@ -3,6 +3,7 @@ import type {ActionCtx} from '../../lib/action-ctx'
|
|
|
3
3
|
import type {StatusSummary} from '../status'
|
|
4
4
|
import {z} from 'zod'
|
|
5
5
|
|
|
6
|
+
import {buildKnownHostsArgs} from '../../lib/known-hosts'
|
|
6
7
|
import {redactHost} from '../../lib/redact'
|
|
7
8
|
import {validateUmamiHost} from './host'
|
|
8
9
|
|
|
@@ -102,6 +103,7 @@ export async function getUmamiComposeStatus(host: string, spawn: SpawnFn = defau
|
|
|
102
103
|
'BatchMode=yes',
|
|
103
104
|
'-o',
|
|
104
105
|
'StrictHostKeyChecking=yes',
|
|
106
|
+
...buildKnownHostsArgs(),
|
|
105
107
|
`root@${host}`,
|
|
106
108
|
`docker compose --project-directory ${COMPOSE_PROJECT_DIR} ps --format json`,
|
|
107
109
|
]
|
package/src/conventions.test.ts
CHANGED
|
@@ -276,6 +276,30 @@ describe('repo conventions', () => {
|
|
|
276
276
|
expect(offenders).toEqual([])
|
|
277
277
|
})
|
|
278
278
|
|
|
279
|
+
it('opencode.jsonc mcp.infra.command uses local repo source (not bunx published package)', async () => {
|
|
280
|
+
const jsoncText = await Bun.file(resolve(REPO_ROOT, 'opencode.jsonc')).text()
|
|
281
|
+
const stripped = jsoncText
|
|
282
|
+
.split('\n')
|
|
283
|
+
.map(line => (/^\s*\/\//.test(line) ? '' : line))
|
|
284
|
+
.join('\n')
|
|
285
|
+
.replaceAll(/,(\s*[}\]])/g, '$1')
|
|
286
|
+
const opencode = JSON.parse(stripped) as {mcp?: {infra?: {command?: unknown}}}
|
|
287
|
+
|
|
288
|
+
const infraCommand = opencode.mcp?.infra?.command
|
|
289
|
+
expect(Array.isArray(infraCommand), 'mcp.infra.command must be an array').toBe(true)
|
|
290
|
+
|
|
291
|
+
const cmd = infraCommand as string[]
|
|
292
|
+
expect(
|
|
293
|
+
cmd,
|
|
294
|
+
'mcp.infra.command must be ["bun", "run", "packages/cli/src/cli.ts", "mcp"] — use local source, not bunx',
|
|
295
|
+
).toEqual(['bun', 'run', 'packages/cli/src/cli.ts', 'mcp'])
|
|
296
|
+
|
|
297
|
+
// Explicit regression guard: bunx @marcusrbrown/infra resolves stale published
|
|
298
|
+
// package cache and exits before the MCP handshake, causing connection closed.
|
|
299
|
+
expect(cmd.join(' ')).not.toContain('bunx')
|
|
300
|
+
expect(cmd.join(' ')).not.toContain('@marcusrbrown/infra')
|
|
301
|
+
})
|
|
302
|
+
|
|
279
303
|
it('gates every sensitive infra MCP tool in opencode.jsonc', async () => {
|
|
280
304
|
// Parse opencode.jsonc tolerantly: it uses JSONC syntax (// line comments,
|
|
281
305
|
// trailing commas). Strategy:
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import {existsSync, mkdirSync, rmSync, writeFileSync} from 'node:fs'
|
|
2
|
+
import {tmpdir} from 'node:os'
|
|
3
|
+
import {join, resolve} from 'node:path'
|
|
4
|
+
|
|
5
|
+
import {describe, expect, it} from 'bun:test'
|
|
6
|
+
|
|
7
|
+
import {buildKnownHostsArgs, resolveKnownHostsPath, resolveRepoKnownHostsPath} from './known-hosts'
|
|
8
|
+
|
|
9
|
+
// ─── resolveRepoKnownHostsPath ────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
describe('resolveRepoKnownHostsPath', () => {
|
|
12
|
+
it('returns the path to .github/known_hosts when it exists at the repo root', () => {
|
|
13
|
+
// The actual repo has .github/known_hosts — this test runs from the repo root
|
|
14
|
+
const result = resolveRepoKnownHostsPath()
|
|
15
|
+
|
|
16
|
+
expect(result).not.toBeNull()
|
|
17
|
+
expect(result).toMatch(/\.github[/\\]known_hosts$/)
|
|
18
|
+
if (result) expect(existsSync(result)).toBe(true)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('returns null when a custom base path has no .github/known_hosts', () => {
|
|
22
|
+
const tmpDir = join(tmpdir(), `known-hosts-test-${Date.now()}`)
|
|
23
|
+
mkdirSync(tmpDir, {recursive: true})
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const result = resolveRepoKnownHostsPath(tmpDir)
|
|
27
|
+
expect(result).toBeNull()
|
|
28
|
+
} finally {
|
|
29
|
+
rmSync(tmpDir, {recursive: true, force: true})
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('returns the path when a custom base path has .github/known_hosts', () => {
|
|
34
|
+
const tmpDir = join(tmpdir(), `known-hosts-test-${Date.now()}`)
|
|
35
|
+
const githubDir = join(tmpDir, '.github')
|
|
36
|
+
mkdirSync(githubDir, {recursive: true})
|
|
37
|
+
writeFileSync(join(githubDir, 'known_hosts'), 'example.com ssh-ed25519 AAAA...\n')
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const result = resolveRepoKnownHostsPath(tmpDir)
|
|
41
|
+
expect(result).not.toBeNull()
|
|
42
|
+
expect(result).toBe(join(tmpDir, '.github', 'known_hosts'))
|
|
43
|
+
} finally {
|
|
44
|
+
rmSync(tmpDir, {recursive: true, force: true})
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// ─── resolveKnownHostsPath (new: multi-layout resolution) ────────────────────
|
|
50
|
+
|
|
51
|
+
describe('resolveKnownHostsPath', () => {
|
|
52
|
+
it('returns the packaged resource path when only src/resources/known_hosts exists (installed package layout)', () => {
|
|
53
|
+
// Simulate: node_modules/@marcusrbrown/infra/src/lib/known-hosts.ts
|
|
54
|
+
// Only src/resources/known_hosts exists; no .github/known_hosts at any ancestor.
|
|
55
|
+
const tmpDir = join(tmpdir(), `known-hosts-installed-${Date.now()}`)
|
|
56
|
+
const libDir = join(tmpDir, 'node_modules', '@marcusrbrown', 'infra', 'src', 'lib')
|
|
57
|
+
const resourcesDir = join(tmpDir, 'node_modules', '@marcusrbrown', 'infra', 'src', 'resources')
|
|
58
|
+
mkdirSync(libDir, {recursive: true})
|
|
59
|
+
mkdirSync(resourcesDir, {recursive: true})
|
|
60
|
+
writeFileSync(join(resourcesDir, 'known_hosts'), 'example.com ssh-ed25519 AAAA...\n')
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
// Pass the simulated import.meta.dir (the lib dir inside the package)
|
|
64
|
+
const result = resolveKnownHostsPath(libDir)
|
|
65
|
+
expect(result).not.toBeNull()
|
|
66
|
+
expect(result).toBe(join(resourcesDir, 'known_hosts'))
|
|
67
|
+
} finally {
|
|
68
|
+
rmSync(tmpDir, {recursive: true, force: true})
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('prefers .github/known_hosts over packaged resource when both exist (repo checkout layout)', () => {
|
|
73
|
+
// Mirror the real layout: packages/cli/src/lib is 4 levels deep from repo root.
|
|
74
|
+
// tmpDir is the "repo root"; libDir is tmpDir/packages/cli/src/lib.
|
|
75
|
+
const tmpDir = join(tmpdir(), `known-hosts-both-${Date.now()}`)
|
|
76
|
+
const libDir = join(tmpDir, 'packages', 'cli', 'src', 'lib')
|
|
77
|
+
const githubDir = join(tmpDir, '.github')
|
|
78
|
+
const resourcesDir = join(tmpDir, 'packages', 'cli', 'src', 'resources')
|
|
79
|
+
mkdirSync(libDir, {recursive: true})
|
|
80
|
+
mkdirSync(githubDir, {recursive: true})
|
|
81
|
+
mkdirSync(resourcesDir, {recursive: true})
|
|
82
|
+
writeFileSync(join(githubDir, 'known_hosts'), 'repo.example.com ssh-ed25519 AAAA...\n')
|
|
83
|
+
writeFileSync(join(resourcesDir, 'known_hosts'), 'packaged.example.com ssh-ed25519 AAAA...\n')
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
// lib is at tmpDir/packages/cli/src/lib — 4 levels up is tmpDir (the "repo root")
|
|
87
|
+
const result = resolveKnownHostsPath(libDir)
|
|
88
|
+
expect(result).not.toBeNull()
|
|
89
|
+
// Should prefer .github/known_hosts (repo checkout)
|
|
90
|
+
expect(result).toBe(join(githubDir, 'known_hosts'))
|
|
91
|
+
} finally {
|
|
92
|
+
rmSync(tmpDir, {recursive: true, force: true})
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('throws a clear error when neither .github/known_hosts nor packaged resource exists', () => {
|
|
97
|
+
const tmpDir = join(tmpdir(), `known-hosts-none-${Date.now()}`)
|
|
98
|
+
const libDir = join(tmpDir, 'src', 'lib')
|
|
99
|
+
mkdirSync(libDir, {recursive: true})
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
expect(() => resolveKnownHostsPath(libDir)).toThrow(
|
|
103
|
+
'Pinned SSH known_hosts file not found; reinstall @marcusrbrown/infra or run from the repo checkout',
|
|
104
|
+
)
|
|
105
|
+
} finally {
|
|
106
|
+
rmSync(tmpDir, {recursive: true, force: true})
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// ─── buildKnownHostsArgs ──────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
describe('buildKnownHostsArgs', () => {
|
|
114
|
+
it('returns UserKnownHostsFile args when .github/known_hosts exists at repo root', () => {
|
|
115
|
+
const args = buildKnownHostsArgs()
|
|
116
|
+
|
|
117
|
+
// The actual repo has .github/known_hosts
|
|
118
|
+
expect(args).toHaveLength(2)
|
|
119
|
+
expect(args[0]).toBe('-o')
|
|
120
|
+
expect(args[1]).toMatch(/^UserKnownHostsFile=/)
|
|
121
|
+
expect(args[1]).toMatch(/\.github[/\\]known_hosts$/)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('throws when no known_hosts file exists (fail-closed behavior)', () => {
|
|
125
|
+
const tmpDir = join(tmpdir(), `known-hosts-test-${Date.now()}`)
|
|
126
|
+
const libDir = join(tmpDir, 'src', 'lib')
|
|
127
|
+
mkdirSync(libDir, {recursive: true})
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
expect(() => buildKnownHostsArgs(libDir)).toThrow(
|
|
131
|
+
'Pinned SSH known_hosts file not found; reinstall @marcusrbrown/infra or run from the repo checkout',
|
|
132
|
+
)
|
|
133
|
+
} finally {
|
|
134
|
+
rmSync(tmpDir, {recursive: true, force: true})
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('returns UserKnownHostsFile args pointing to the packaged resource in installed layout', () => {
|
|
139
|
+
const tmpDir = join(tmpdir(), `known-hosts-installed-${Date.now()}`)
|
|
140
|
+
const libDir = join(tmpDir, 'node_modules', '@marcusrbrown', 'infra', 'src', 'lib')
|
|
141
|
+
const resourcesDir = join(tmpDir, 'node_modules', '@marcusrbrown', 'infra', 'src', 'resources')
|
|
142
|
+
mkdirSync(libDir, {recursive: true})
|
|
143
|
+
mkdirSync(resourcesDir, {recursive: true})
|
|
144
|
+
writeFileSync(join(resourcesDir, 'known_hosts'), 'example.com ssh-ed25519 AAAA...\n')
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const args = buildKnownHostsArgs(libDir)
|
|
148
|
+
expect(args).toHaveLength(2)
|
|
149
|
+
expect(args[0]).toBe('-o')
|
|
150
|
+
expect(args[1]).toBe(`UserKnownHostsFile=${join(resourcesDir, 'known_hosts')}`)
|
|
151
|
+
} finally {
|
|
152
|
+
rmSync(tmpDir, {recursive: true, force: true})
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('returns UserKnownHostsFile args pointing to the custom base path (repo checkout)', () => {
|
|
157
|
+
// Mirror the real layout: packages/cli/src/lib is 4 levels deep from repo root.
|
|
158
|
+
const tmpDir = join(tmpdir(), `known-hosts-test-${Date.now()}`)
|
|
159
|
+
const libDir = join(tmpDir, 'packages', 'cli', 'src', 'lib')
|
|
160
|
+
const githubDir = join(tmpDir, '.github')
|
|
161
|
+
mkdirSync(libDir, {recursive: true})
|
|
162
|
+
mkdirSync(githubDir, {recursive: true})
|
|
163
|
+
writeFileSync(join(githubDir, 'known_hosts'), 'example.com ssh-ed25519 AAAA...\n')
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const args = buildKnownHostsArgs(libDir)
|
|
167
|
+
expect(args).toHaveLength(2)
|
|
168
|
+
expect(args[0]).toBe('-o')
|
|
169
|
+
expect(args[1]).toBe(`UserKnownHostsFile=${join(githubDir, 'known_hosts')}`)
|
|
170
|
+
} finally {
|
|
171
|
+
rmSync(tmpDir, {recursive: true, force: true})
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
// ─── Drift guard: packaged resource matches .github/known_hosts ───────────────
|
|
177
|
+
|
|
178
|
+
describe('packaged known_hosts resource drift guard', () => {
|
|
179
|
+
const REPO_ROOT = resolve(import.meta.dir, '..', '..', '..', '..')
|
|
180
|
+
const REPO_KNOWN_HOSTS = join(REPO_ROOT, '.github', 'known_hosts')
|
|
181
|
+
const PACKAGED_RESOURCE = resolve(import.meta.dir, '..', 'resources', 'known_hosts')
|
|
182
|
+
|
|
183
|
+
it('src/resources/known_hosts exists in the package source tree', () => {
|
|
184
|
+
expect(existsSync(PACKAGED_RESOURCE)).toBe(true)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('src/resources/known_hosts matches .github/known_hosts (no drift)', async () => {
|
|
188
|
+
const repoContent = await Bun.file(REPO_KNOWN_HOSTS).text()
|
|
189
|
+
const packagedContent = await Bun.file(PACKAGED_RESOURCE).text()
|
|
190
|
+
expect(packagedContent).toBe(repoContent)
|
|
191
|
+
})
|
|
192
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import {existsSync} from 'node:fs'
|
|
2
|
+
import {join, resolve} from 'node:path'
|
|
3
|
+
|
|
4
|
+
const FAIL_CLOSED_ERROR =
|
|
5
|
+
'Pinned SSH known_hosts file not found; reinstall @marcusrbrown/infra or run from the repo checkout'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolve the path to the repo-pinned `.github/known_hosts` file.
|
|
9
|
+
*
|
|
10
|
+
* SSH commands in this CLI use `StrictHostKeyChecking=yes` but the invoking
|
|
11
|
+
* user's `~/.ssh/known_hosts` may not contain the pinned host keys for
|
|
12
|
+
* `metrics.fro.bot`, `gateway.fro.bot`, etc. The repo ships authoritative
|
|
13
|
+
* host keys in `.github/known_hosts`. When that file exists, SSH commands
|
|
14
|
+
* should use it via `UserKnownHostsFile=<path>` so host verification succeeds
|
|
15
|
+
* without weakening `StrictHostKeyChecking`.
|
|
16
|
+
*
|
|
17
|
+
* @param repoRoot - Optional override for the repo root directory. Defaults to
|
|
18
|
+
* the actual repo root resolved relative to this file's location. Pass a
|
|
19
|
+
* custom path in tests to avoid touching the real filesystem.
|
|
20
|
+
* @returns Absolute path to `.github/known_hosts` if it exists, otherwise null.
|
|
21
|
+
*/
|
|
22
|
+
export function resolveRepoKnownHostsPath(repoRoot?: string): string | null {
|
|
23
|
+
// This file lives at packages/cli/src/lib/known-hosts.ts
|
|
24
|
+
// Repo root is 4 levels up: lib → src → cli → packages → repo root
|
|
25
|
+
const base = repoRoot ?? resolve(import.meta.dir, '..', '..', '..', '..')
|
|
26
|
+
const candidate = join(base, '.github', 'known_hosts')
|
|
27
|
+
return existsSync(candidate) ? candidate : null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the pinned known_hosts file from either layout:
|
|
32
|
+
*
|
|
33
|
+
* 1. Repo checkout: `.github/known_hosts` at the monorepo root (4 levels up
|
|
34
|
+
* from `src/lib/`).
|
|
35
|
+
* 2. Installed package: `src/resources/known_hosts` bundled alongside the
|
|
36
|
+
* source (1 level up from `src/lib/`, then into `resources/`).
|
|
37
|
+
*
|
|
38
|
+
* Prefers the repo checkout layout when both exist. Throws a clear error if
|
|
39
|
+
* neither is found — fail-closed, never silently falls back to user
|
|
40
|
+
* `~/.ssh/known_hosts`.
|
|
41
|
+
*
|
|
42
|
+
* @param libDir - Override for the directory containing this file (for tests).
|
|
43
|
+
* Defaults to `import.meta.dir`.
|
|
44
|
+
* @returns Absolute path to the pinned known_hosts file.
|
|
45
|
+
* @throws Error if no pinned known_hosts file is found.
|
|
46
|
+
*/
|
|
47
|
+
export function resolveKnownHostsPath(libDir?: string): string {
|
|
48
|
+
const dir = libDir ?? import.meta.dir
|
|
49
|
+
|
|
50
|
+
// Layout 1: repo checkout — walk up 4 levels from src/lib to repo root
|
|
51
|
+
const repoRoot = resolve(dir, '..', '..', '..', '..')
|
|
52
|
+
const repoCandidate = join(repoRoot, '.github', 'known_hosts')
|
|
53
|
+
if (existsSync(repoCandidate)) return repoCandidate
|
|
54
|
+
|
|
55
|
+
// Layout 2: installed package — src/resources/known_hosts is 1 level up from src/lib
|
|
56
|
+
const packagedCandidate = resolve(dir, '..', 'resources', 'known_hosts')
|
|
57
|
+
if (existsSync(packagedCandidate)) return packagedCandidate
|
|
58
|
+
|
|
59
|
+
throw new Error(FAIL_CLOSED_ERROR)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build the SSH `-o UserKnownHostsFile=<path>` argument pair for the
|
|
64
|
+
* repo-pinned known_hosts file.
|
|
65
|
+
*
|
|
66
|
+
* Returns `['-o', 'UserKnownHostsFile=<path>']` when the file is found.
|
|
67
|
+
* Throws a clear error if no pinned known_hosts file is available — fail-closed,
|
|
68
|
+
* never silently omits `UserKnownHostsFile` for SSH commands that rely on
|
|
69
|
+
* pinned verification.
|
|
70
|
+
*
|
|
71
|
+
* @param libDir - Override for the directory containing this file (for tests).
|
|
72
|
+
*/
|
|
73
|
+
export function buildKnownHostsArgs(libDir?: string): string[] {
|
|
74
|
+
const path = resolveKnownHostsPath(libDir)
|
|
75
|
+
return ['-o', `UserKnownHostsFile=${path}`]
|
|
76
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
box.heatvision.co ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJNjkvxUDLB432BdSnrfYGgYyfUQxrdwV6sNqETNkyqs
|
|
2
|
+
box.heatvision.co ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBPnP2/6iqHUeOJX4+aVcFpaal0lLdT6sZrRSF95+MgyCY+p3K21Kgr2xp9eyGJ8splnaJv7Kwv1V7+HC3CMXoqM=
|
|
3
|
+
box.heatvision.co ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCpOziObL6mGk7hT113yo8kElbut5DU6xXoUYw8QWc4ix8iHxJV6T1hLl4wemFkSdA1jUhqhfR94Wdk7DtSGUiq9LidwfmL9ZsrAlnThLJeugZE1f+EtIye9hIqpzmmqMZvHeYFu3zbiB2/Xpc/krTmfKjIOsI0MeOKPAKEiFLG1DU9i5bWKs2ktC8TZCqp+Pfc4xt54Ug4lfLspbGbRZ96SQuxns+SSfyQW5peH13ZMl92/GGA2Tdh3bHausOYI6AvUimd/0mVc6b0bSWVS/k1bdb27rE/6TrZZ4ICXElGkgNndOsX5lFNcJdU+T6pJicA5fO8mpZ/ysakvyesmPigXxYsW3Q/tQkBoQXcKcxi+ItqU/ahJ1I4DObKnvJlu5htr3Gzo4dO5sxpDdufECqgicauFZzvXzD49XZvctRG01ZSy6ZmIekBsiTFHaTMO8zV88/AwVOT8CrgbrmIiwUG6ZfXCPFMztu4Z5IwxMWRnKG2GHWGNBb2/F5PDAz/Xrc=
|
|
4
|
+
|
|
5
|
+
# cliproxy droplet (147.182.133.210 / cliproxy.fro.bot)
|
|
6
|
+
cliproxy.fro.bot ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEq1Jbp0zCI12p7JIEtDTajBcCQyz0gmHls/BNXYTwIJ
|
|
7
|
+
cliproxy.fro.bot ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNIaI6OauhhSiho+UtAl9ZfP2NkbOo/Pw6i/beGyxl/Z/dqnigruDXWuhlitnAtpOvpeQXOcx3xKSOjX8iQkKXc=
|
|
8
|
+
cliproxy.fro.bot ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDcC+mMGekM5zzg3JcpVQD8Q+eRrhTPZptaG7sz1r5R5WVxdOboZ6xtjCQjCuqafig/PIYsZCnJrgmonW0gdKhpNbwobb9FBF8kFbeOa4rRKUoU7k8nmubWCn87L1GCGAtiX50YcxkxTTzZqHPZKiKRV38ExdfwR8uzeP2k8I/ay1uEjP5P1r/LCqHvKU8Dm3qF+NHZPGb0Ri/Iz9aLj3Mo73/BM8D7fhaG/3WXA+vRLwO+vqaIcGraYd5csNFJEXbpU7FBlnJmeKg4VPN7HJHsI69SGmmqGVHQ75A5e5AlNme/nCZmE5SptUy2mt/Y4/BIShDTfuyLG9Q36P1mC3PT0zZwdl4iAO3fciRo2R4/tSPh4nkFT0gEH79SQkZrV42uR5h6BNtf1/Bob8IAwdZL//1J97oc9EAfSntJQvSrYcMCL5ui+uK3S/V24jqYSsywQwfwGNMgDwDHQ66vP/CMbB86PnNmRajHDyF/vQK1ueSVX5sCEYEmQsx2Q6OPLxM=
|
|
9
|
+
|1|h7erWkle+dN76VrVVAiQPmOSee4=|LkpzPt8SQjsCbnPm+txg5Me1SYM= ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDcC+mMGekM5zzg3JcpVQD8Q+eRrhTPZptaG7sz1r5R5WVxdOboZ6xtjCQjCuqafig/PIYsZCnJrgmonW0gdKhpNbwobb9FBF8kFbeOa4rRKUoU7k8nmubWCn87L1GCGAtiX50YcxkxTTzZqHPZKiKRV38ExdfwR8uzeP2k8I/ay1uEjP5P1r/LCqHvKU8Dm3qF+NHZPGb0Ri/Iz9aLj3Mo73/BM8D7fhaG/3WXA+vRLwO+vqaIcGraYd5csNFJEXbpU7FBlnJmeKg4VPN7HJHsI69SGmmqGVHQ75A5e5AlNme/nCZmE5SptUy2mt/Y4/BIShDTfuyLG9Q36P1mC3PT0zZwdl4iAO3fciRo2R4/tSPh4nkFT0gEH79SQkZrV42uR5h6BNtf1/Bob8IAwdZL//1J97oc9EAfSntJQvSrYcMCL5ui+uK3S/V24jqYSsywQwfwGNMgDwDHQ66vP/CMbB86PnNmRajHDyF/vQK1ueSVX5sCEYEmQsx2Q6OPLxM=
|
|
10
|
+
|1|hNHRbNSfyK/BBEM32tiaVoRnRRA=|6KslRRxr9KNaKlbnzkdg1/tQokA= ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNIaI6OauhhSiho+UtAl9ZfP2NkbOo/Pw6i/beGyxl/Z/dqnigruDXWuhlitnAtpOvpeQXOcx3xKSOjX8iQkKXc=
|
|
11
|
+
|1|NMQwWGuubNdklyTxDZ5rRgmaq0k=|J3xvvUOXXMYgC0vvsn1zvRDlSVQ= ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEq1Jbp0zCI12p7JIEtDTajBcCQyz0gmHls/BNXYTwIJ
|
|
12
|
+
|
|
13
|
+
# gateway droplet (162.243.163.59 / gateway.fro.bot)
|
|
14
|
+
gateway.fro.bot ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC3g6QXCHGjaahf8hMBcRYtDWGAQTBpiUn7yrjVsbIb9U9K7d9yypLaTYzYKU0WFVtVFEi25CcRU5/xB6KOTWOSU/izPlgJ8ht7bNDWmSy6X1JaqxOOh2qQWWRklF+g8I1JwXO0X1Bmu7rFGFqaDEyWKChM19DvRi66x+kPYOeCeACwkYOA1L/SeUpF4H82lWGSiql4rzT7M/n/6RXa5gd4oPG1C8rkM8EqPnpFIZSUNv5/TAhGVsZu1RFJ4OvlZrNQba0LON6DUHTKuUE9o2r+VoaehhOuMDq5gOUAcDEDpPdyfCgnWvRkiLZ5osqw5ECcAGhIxXVYdpGhmX157UmLDKosowRbqPibetCJQ4sisa2WPm5jIrQnf/65mFOlkfIIYuzi0OBFG7VOaU0H8+AAOUQ9fnjseIQJV7GaD9F7viE+zgMoCB1fUlG04iIdJ2SgXRLt2+4uUfwuYSJHTvQUxs+6uMtzFQwHUZpBVE71I0g40beyulok7qiCxIBmLZ8=
|
|
15
|
+
gateway.fro.bot ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGCGPvUmuVd8X6/CxbvhTjjO0Ee6rS1D6XujRgmVl0zcHYIFUYLAFZVosN0RKvcmdmppm64PxH/cLAx7gvKF3t0=
|
|
16
|
+
gateway.fro.bot ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBUfj6wqjyh+BLIqHlRxfFlRpBuesAAnUYllFbWN9qzw
|
|
17
|
+
|
|
18
|
+
|1|8fBUi0jyLBNX5eBIp6NllCFFwVw=|gC1yH9h2zglwFYgKVhvslvDqgqE= ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC3g6QXCHGjaahf8hMBcRYtDWGAQTBpiUn7yrjVsbIb9U9K7d9yypLaTYzYKU0WFVtVFEi25CcRU5/xB6KOTWOSU/izPlgJ8ht7bNDWmSy6X1JaqxOOh2qQWWRklF+g8I1JwXO0X1Bmu7rFGFqaDEyWKChM19DvRi66x+kPYOeCeACwkYOA1L/SeUpF4H82lWGSiql4rzT7M/n/6RXa5gd4oPG1C8rkM8EqPnpFIZSUNv5/TAhGVsZu1RFJ4OvlZrNQba0LON6DUHTKuUE9o2r+VoaehhOuMDq5gOUAcDEDpPdyfCgnWvRkiLZ5osqw5ECcAGhIxXVYdpGhmX157UmLDKosowRbqPibetCJQ4sisa2WPm5jIrQnf/65mFOlkfIIYuzi0OBFG7VOaU0H8+AAOUQ9fnjseIQJV7GaD9F7viE+zgMoCB1fUlG04iIdJ2SgXRLt2+4uUfwuYSJHTvQUxs+6uMtzFQwHUZpBVE71I0g40beyulok7qiCxIBmLZ8=
|
|
19
|
+
|1|XsFKIlbq2aifqw1if+WGnRz0MBs=|LrHknyfL8diBLT1G7+ss8+9Kbaw= ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGCGPvUmuVd8X6/CxbvhTjjO0Ee6rS1D6XujRgmVl0zcHYIFUYLAFZVosN0RKvcmdmppm64PxH/cLAx7gvKF3t0=
|
|
20
|
+
|1|Ytflq7+lWpR/TNEQ8C/wvirEIsc=|hcnjv25+apKJxy6UlAHhtR0baRI= ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBUfj6wqjyh+BLIqHlRxfFlRpBuesAAnUYllFbWN9qzw
|
|
21
|
+
|
|
22
|
+
# umami droplet (167.99.236.120 / metrics.fro.bot)
|
|
23
|
+
metrics.fro.bot ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICRCMPA99bZJ1w+QdZPrTzLK06LF7t91tb0GbKfBkA72
|
|
24
|
+
metrics.fro.bot ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCma/Y9qRPlZ8v1wnQQBfwghQ0XbtSMn7QHVgIFGBjbO+Xqim7Lv9EArn58HIVACsZKMD0e9T90r70TDXYTWuZ6+RQ2x2soE6DcG/T/2wyan40hVtXLSCg8hx9DNjLR3RBwkiJcbdrYCqpDmi4kOuJAJ+b5JxUhJAkdF5M9xhUko59HtUm5I/j1T2Df60+7CdKMGZKMrCRios/q4lvgImuWS3aZuNQNuJiv6rfhX7L9LqezlJUgw1AbOzaqPXbZ9xpzt8yklGZQNa5I/CS6pEgn4/SKiRsetpgd4SCB9UuCR7ia/M0VU8itNPMZY11Pi+YwJKfJVZDtS+OSFkFxea8B1MyxDZkvOwnzYVx9xG6W48Oi06x4pQgJcqq5V52xM4upSlVtws5gTLNm/3KaJZ3kb7IK4HnTEUQrz/UShtyP1crsqwj5eoDnvcUfaMHx/YC/7LlQFp8AO8W/KEYWzlycJpZLqPrO/gqoiR0AG9NMP0SSRaD7M2pcQ4tKM1xyopE=
|
|
25
|
+
metrics.fro.bot ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOm6bQkB9dGaPJAVbk9IrVxZSM0KrkUikv8JgVrW0iJMKorJ233GaK03n2/5cuAJ/D3z6nN/pF6QGApWfm6s/JA=
|
|
26
|
+
|1|8o6qExp0klmE80pMU2CYvmbGqSQ=|064FdS/NWWEIU7EjmGBCwCek+DE= ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICRCMPA99bZJ1w+QdZPrTzLK06LF7t91tb0GbKfBkA72
|
|
27
|
+
|1|1nwXP2iRwBq0GXyE94EXYJuaUCQ=|Wxoe6EvzjZbk02Hr4mx1YZOciUM= ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCma/Y9qRPlZ8v1wnQQBfwghQ0XbtSMn7QHVgIFGBjbO+Xqim7Lv9EArn58HIVACsZKMD0e9T90r70TDXYTWuZ6+RQ2x2soE6DcG/T/2wyan40hVtXLSCg8hx9DNjLR3RBwkiJcbdrYCqpDmi4kOuJAJ+b5JxUhJAkdF5M9xhUko59HtUm5I/j1T2Df60+7CdKMGZKMrCRios/q4lvgImuWS3aZuNQNuJiv6rfhX7L9LqezlJUgw1AbOzaqPXbZ9xpzt8yklGZQNa5I/CS6pEgn4/SKiRsetpgd4SCB9UuCR7ia/M0VU8itNPMZY11Pi+YwJKfJVZDtS+OSFkFxea8B1MyxDZkvOwnzYVx9xG6W48Oi06x4pQgJcqq5V52xM4upSlVtws5gTLNm/3KaJZ3kb7IK4HnTEUQrz/UShtyP1crsqwj5eoDnvcUfaMHx/YC/7LlQFp8AO8W/KEYWzlycJpZLqPrO/gqoiR0AG9NMP0SSRaD7M2pcQ4tKM1xyopE=
|
|
28
|
+
|1|lwRlQiSSd0h7iti76fvRXTtbkSw=|D//yCCMd1iZftlbjJ0NhKCCf5/8= ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOm6bQkB9dGaPJAVbk9IrVxZSM0KrkUikv8JgVrW0iJMKorJ233GaK03n2/5cuAJ/D3z6nN/pF6QGApWfm6s/JA=
|