@marcusrbrown/infra 0.10.3 → 0.10.4
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/status.test.ts +140 -0
- package/src/commands/gateway/status.ts +19 -7
- package/src/commands/umami/status.test.ts +117 -0
- package/src/commands/umami/status.ts +19 -7
- package/src/commands/vpn/shared.test.ts +121 -1
- package/src/commands/vpn/shared.ts +20 -8
- package/src/lib/ssh-identity.test.ts +134 -0
- package/src/lib/ssh-identity.ts +76 -0
package/package.json
CHANGED
|
@@ -613,3 +613,143 @@ describe('getGatewayComposeStatus — SSH error redaction', () => {
|
|
|
613
613
|
expect(result.error).not.toContain(secretHost.toLowerCase())
|
|
614
614
|
})
|
|
615
615
|
})
|
|
616
|
+
|
|
617
|
+
// ─── SSH identity injection (GATEWAY_SSH_KEY) ─────────────────────────────────
|
|
618
|
+
|
|
619
|
+
describe('getGatewayComposeStatus — SSH identity injection', () => {
|
|
620
|
+
let originalEnv: Record<string, string | undefined>
|
|
621
|
+
|
|
622
|
+
beforeEach(() => {
|
|
623
|
+
originalEnv = {GATEWAY_SSH_KEY: process.env.GATEWAY_SSH_KEY}
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
afterEach(() => {
|
|
627
|
+
if (originalEnv.GATEWAY_SSH_KEY === undefined) {
|
|
628
|
+
delete process.env.GATEWAY_SSH_KEY
|
|
629
|
+
} else {
|
|
630
|
+
process.env.GATEWAY_SSH_KEY = originalEnv.GATEWAY_SSH_KEY
|
|
631
|
+
}
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
it('includes -i <path> and IdentitiesOnly=yes in ssh argv when GATEWAY_SSH_KEY is set', async () => {
|
|
635
|
+
const {getGatewayComposeStatus} = await import('./status')
|
|
636
|
+
process.env.GATEWAY_SSH_KEY = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
|
|
637
|
+
|
|
638
|
+
let capturedCmd: string[] = []
|
|
639
|
+
const capturingSpawn: SpawnFn = (cmd, _opts) => {
|
|
640
|
+
capturedCmd = cmd
|
|
641
|
+
const enc = new TextEncoder()
|
|
642
|
+
return {
|
|
643
|
+
stdout: new ReadableStream({
|
|
644
|
+
start(c) {
|
|
645
|
+
c.enqueue(enc.encode(JSON.stringify([{Name: 'gateway', State: 'running', Health: 'healthy'}])))
|
|
646
|
+
c.close()
|
|
647
|
+
},
|
|
648
|
+
}),
|
|
649
|
+
stderr: new ReadableStream({
|
|
650
|
+
start(c) {
|
|
651
|
+
c.close()
|
|
652
|
+
},
|
|
653
|
+
}),
|
|
654
|
+
exited: Promise.resolve(0),
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
await getGatewayComposeStatus('gateway.example.com', capturingSpawn)
|
|
659
|
+
|
|
660
|
+
const iIdx = capturedCmd.indexOf('-i')
|
|
661
|
+
expect(iIdx).toBeGreaterThan(-1)
|
|
662
|
+
expect(capturedCmd[iIdx + 1]).toBeTruthy() // path to temp key file
|
|
663
|
+
|
|
664
|
+
const identitiesOnlyIdx = capturedCmd.indexOf('IdentitiesOnly=yes')
|
|
665
|
+
expect(identitiesOnlyIdx).toBeGreaterThan(-1)
|
|
666
|
+
|
|
667
|
+
const destination = capturedCmd.find(arg => arg.includes('@'))
|
|
668
|
+
expect(destination).toBe('root@gateway.example.com')
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
it('does not include -i or IdentitiesOnly=yes when GATEWAY_SSH_KEY is absent', async () => {
|
|
672
|
+
const {getGatewayComposeStatus} = await import('./status')
|
|
673
|
+
delete process.env.GATEWAY_SSH_KEY
|
|
674
|
+
|
|
675
|
+
let capturedCmd: string[] = []
|
|
676
|
+
const capturingSpawn: SpawnFn = (cmd, _opts) => {
|
|
677
|
+
capturedCmd = cmd
|
|
678
|
+
const enc = new TextEncoder()
|
|
679
|
+
return {
|
|
680
|
+
stdout: new ReadableStream({
|
|
681
|
+
start(c) {
|
|
682
|
+
c.enqueue(enc.encode(JSON.stringify([{Name: 'gateway', State: 'running', Health: 'healthy'}])))
|
|
683
|
+
c.close()
|
|
684
|
+
},
|
|
685
|
+
}),
|
|
686
|
+
stderr: new ReadableStream({
|
|
687
|
+
start(c) {
|
|
688
|
+
c.close()
|
|
689
|
+
},
|
|
690
|
+
}),
|
|
691
|
+
exited: Promise.resolve(0),
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
await getGatewayComposeStatus('gateway.example.com', capturingSpawn)
|
|
696
|
+
|
|
697
|
+
expect(capturedCmd.indexOf('-i')).toBe(-1)
|
|
698
|
+
expect(capturedCmd.indexOf('IdentitiesOnly=yes')).toBe(-1)
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
it('cleans up the temp key file after the SSH command completes', async () => {
|
|
702
|
+
const {getGatewayComposeStatus} = await import('./status')
|
|
703
|
+
const {statSync} = await import('node:fs')
|
|
704
|
+
process.env.GATEWAY_SSH_KEY = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
|
|
705
|
+
|
|
706
|
+
let capturedKeyPath: string | undefined
|
|
707
|
+
const capturingSpawn: SpawnFn = (cmd, _opts) => {
|
|
708
|
+
const iIdx = cmd.indexOf('-i')
|
|
709
|
+
if (iIdx !== -1) capturedKeyPath = cmd[iIdx + 1]
|
|
710
|
+
const enc = new TextEncoder()
|
|
711
|
+
return {
|
|
712
|
+
stdout: new ReadableStream({
|
|
713
|
+
start(c) {
|
|
714
|
+
c.enqueue(enc.encode(JSON.stringify([{Name: 'gateway', State: 'running', Health: 'healthy'}])))
|
|
715
|
+
c.close()
|
|
716
|
+
},
|
|
717
|
+
}),
|
|
718
|
+
stderr: new ReadableStream({
|
|
719
|
+
start(c) {
|
|
720
|
+
c.close()
|
|
721
|
+
},
|
|
722
|
+
}),
|
|
723
|
+
exited: Promise.resolve(0),
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
await getGatewayComposeStatus('gateway.example.com', capturingSpawn)
|
|
728
|
+
|
|
729
|
+
expect(capturedKeyPath).toBeTruthy()
|
|
730
|
+
const keyPath = capturedKeyPath
|
|
731
|
+
if (keyPath) {
|
|
732
|
+
expect(() => statSync(keyPath)).toThrow()
|
|
733
|
+
}
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
it('cleans up the temp key dir even when spawn throws synchronously', async () => {
|
|
737
|
+
const {getGatewayComposeStatus} = await import('./status')
|
|
738
|
+
const {statSync} = await import('node:fs')
|
|
739
|
+
process.env.GATEWAY_SSH_KEY = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
|
|
740
|
+
|
|
741
|
+
let capturedKeyPath: string | undefined
|
|
742
|
+
const throwingSpawn: SpawnFn = (cmd, _opts) => {
|
|
743
|
+
const iIdx = cmd.indexOf('-i')
|
|
744
|
+
if (iIdx !== -1) capturedKeyPath = cmd[iIdx + 1]
|
|
745
|
+
throw new Error('spawn failed')
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
await expect(getGatewayComposeStatus('gateway.example.com', throwingSpawn)).rejects.toThrow('spawn failed')
|
|
749
|
+
|
|
750
|
+
expect(capturedKeyPath).toBeTruthy()
|
|
751
|
+
if (capturedKeyPath) {
|
|
752
|
+
expect(() => statSync(capturedKeyPath as string)).toThrow()
|
|
753
|
+
}
|
|
754
|
+
})
|
|
755
|
+
})
|
|
@@ -5,6 +5,7 @@ import {z} from 'zod'
|
|
|
5
5
|
|
|
6
6
|
import {buildKnownHostsArgs} from '../../lib/known-hosts'
|
|
7
7
|
import {redactHost} from '../../lib/redact'
|
|
8
|
+
import {buildIdentityArgs} from '../../lib/ssh-identity'
|
|
8
9
|
// ─── Minimal ctx interface (subset of GokeExecutionContext used by this action) ─
|
|
9
10
|
import {validateGatewayHost} from './host'
|
|
10
11
|
|
|
@@ -103,13 +104,17 @@ export async function getGatewayComposeStatus(
|
|
|
103
104
|
): Promise<GatewayStatusResult> {
|
|
104
105
|
validateGatewayHost(host)
|
|
105
106
|
|
|
107
|
+
const knownHostsArgs = buildKnownHostsArgs()
|
|
108
|
+
const {args: identityArgs, cleanup} = buildIdentityArgs(process.env.GATEWAY_SSH_KEY)
|
|
109
|
+
|
|
106
110
|
const sshCmd = [
|
|
107
111
|
'ssh',
|
|
108
112
|
'-o',
|
|
109
113
|
'BatchMode=yes',
|
|
110
114
|
'-o',
|
|
111
115
|
'StrictHostKeyChecking=yes',
|
|
112
|
-
...
|
|
116
|
+
...knownHostsArgs,
|
|
117
|
+
...identityArgs,
|
|
113
118
|
`root@${host}`,
|
|
114
119
|
`docker compose --project-directory ${COMPOSE_PROJECT_DIR} ps --format json`,
|
|
115
120
|
]
|
|
@@ -120,13 +125,20 @@ export async function getGatewayComposeStatus(
|
|
|
120
125
|
...(process.env.SSH_AUTH_SOCK ? {SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK} : {}),
|
|
121
126
|
}
|
|
122
127
|
|
|
123
|
-
|
|
128
|
+
let stdoutText: string
|
|
129
|
+
let stderrText: string
|
|
130
|
+
let exitCode: number
|
|
124
131
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
132
|
+
try {
|
|
133
|
+
const child = spawn(sshCmd, {env, stdout: 'pipe', stderr: 'pipe'})
|
|
134
|
+
;[stdoutText, stderrText, exitCode] = await Promise.all([
|
|
135
|
+
new Response(child.stdout).text(),
|
|
136
|
+
new Response(child.stderr).text(),
|
|
137
|
+
child.exited,
|
|
138
|
+
])
|
|
139
|
+
} finally {
|
|
140
|
+
cleanup()
|
|
141
|
+
}
|
|
130
142
|
|
|
131
143
|
if (exitCode !== 0) {
|
|
132
144
|
const redacted = redactHost(stderrText.trim(), host) || 'unknown error'
|
|
@@ -514,3 +514,120 @@ describe('getUmamiComposeStatus — SSH error redaction', () => {
|
|
|
514
514
|
expect(result.error).not.toContain(secretHost.toLowerCase())
|
|
515
515
|
})
|
|
516
516
|
})
|
|
517
|
+
|
|
518
|
+
// ─── SSH identity injection (UMAMI_SSH_KEY) ───────────────────────────────────
|
|
519
|
+
|
|
520
|
+
describe('getUmamiComposeStatus — SSH identity injection', () => {
|
|
521
|
+
let originalEnv: Record<string, string | undefined>
|
|
522
|
+
|
|
523
|
+
beforeEach(() => {
|
|
524
|
+
originalEnv = {UMAMI_SSH_KEY: process.env.UMAMI_SSH_KEY}
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
afterEach(() => {
|
|
528
|
+
if (originalEnv.UMAMI_SSH_KEY === undefined) {
|
|
529
|
+
delete process.env.UMAMI_SSH_KEY
|
|
530
|
+
} else {
|
|
531
|
+
process.env.UMAMI_SSH_KEY = originalEnv.UMAMI_SSH_KEY
|
|
532
|
+
}
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
it('includes -i <path> and IdentitiesOnly=yes in ssh argv when UMAMI_SSH_KEY is set', async () => {
|
|
536
|
+
process.env.UMAMI_SSH_KEY = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
|
|
537
|
+
|
|
538
|
+
let capturedCmd: string[] = []
|
|
539
|
+
const capturingSpawn: SpawnFn = (cmd, _opts) => {
|
|
540
|
+
capturedCmd = cmd
|
|
541
|
+
const enc = new TextEncoder()
|
|
542
|
+
return {
|
|
543
|
+
stdout: new ReadableStream({
|
|
544
|
+
start(c) {
|
|
545
|
+
c.enqueue(enc.encode(JSON.stringify([{Name: 'umami', State: 'running', Health: 'healthy'}])))
|
|
546
|
+
c.close()
|
|
547
|
+
},
|
|
548
|
+
}),
|
|
549
|
+
stderr: new ReadableStream({
|
|
550
|
+
start(c) {
|
|
551
|
+
c.close()
|
|
552
|
+
},
|
|
553
|
+
}),
|
|
554
|
+
exited: Promise.resolve(0),
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
await getUmamiComposeStatus('metrics.fro.bot', capturingSpawn)
|
|
559
|
+
|
|
560
|
+
const iIdx = capturedCmd.indexOf('-i')
|
|
561
|
+
expect(iIdx).toBeGreaterThan(-1)
|
|
562
|
+
expect(capturedCmd[iIdx + 1]).toBeTruthy()
|
|
563
|
+
|
|
564
|
+
const identitiesOnlyIdx = capturedCmd.indexOf('IdentitiesOnly=yes')
|
|
565
|
+
expect(identitiesOnlyIdx).toBeGreaterThan(-1)
|
|
566
|
+
|
|
567
|
+
const destination = capturedCmd.find(arg => arg.includes('@'))
|
|
568
|
+
expect(destination).toBe('root@metrics.fro.bot')
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
it('does not include -i or IdentitiesOnly=yes when UMAMI_SSH_KEY is absent', async () => {
|
|
572
|
+
delete process.env.UMAMI_SSH_KEY
|
|
573
|
+
|
|
574
|
+
let capturedCmd: string[] = []
|
|
575
|
+
const capturingSpawn: SpawnFn = (cmd, _opts) => {
|
|
576
|
+
capturedCmd = cmd
|
|
577
|
+
const enc = new TextEncoder()
|
|
578
|
+
return {
|
|
579
|
+
stdout: new ReadableStream({
|
|
580
|
+
start(c) {
|
|
581
|
+
c.enqueue(enc.encode(JSON.stringify([{Name: 'umami', State: 'running', Health: 'healthy'}])))
|
|
582
|
+
c.close()
|
|
583
|
+
},
|
|
584
|
+
}),
|
|
585
|
+
stderr: new ReadableStream({
|
|
586
|
+
start(c) {
|
|
587
|
+
c.close()
|
|
588
|
+
},
|
|
589
|
+
}),
|
|
590
|
+
exited: Promise.resolve(0),
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
await getUmamiComposeStatus('metrics.fro.bot', capturingSpawn)
|
|
595
|
+
|
|
596
|
+
expect(capturedCmd.indexOf('-i')).toBe(-1)
|
|
597
|
+
expect(capturedCmd.indexOf('IdentitiesOnly=yes')).toBe(-1)
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
it('cleans up the temp key file after the SSH command completes', async () => {
|
|
601
|
+
const {statSync} = await import('node:fs')
|
|
602
|
+
process.env.UMAMI_SSH_KEY = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
|
|
603
|
+
|
|
604
|
+
let capturedKeyPath: string | undefined
|
|
605
|
+
const capturingSpawn: SpawnFn = (cmd, _opts) => {
|
|
606
|
+
const iIdx = cmd.indexOf('-i')
|
|
607
|
+
if (iIdx !== -1) capturedKeyPath = cmd[iIdx + 1]
|
|
608
|
+
const enc = new TextEncoder()
|
|
609
|
+
return {
|
|
610
|
+
stdout: new ReadableStream({
|
|
611
|
+
start(c) {
|
|
612
|
+
c.enqueue(enc.encode(JSON.stringify([{Name: 'umami', State: 'running', Health: 'healthy'}])))
|
|
613
|
+
c.close()
|
|
614
|
+
},
|
|
615
|
+
}),
|
|
616
|
+
stderr: new ReadableStream({
|
|
617
|
+
start(c) {
|
|
618
|
+
c.close()
|
|
619
|
+
},
|
|
620
|
+
}),
|
|
621
|
+
exited: Promise.resolve(0),
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
await getUmamiComposeStatus('metrics.fro.bot', capturingSpawn)
|
|
626
|
+
|
|
627
|
+
expect(capturedKeyPath).toBeTruthy()
|
|
628
|
+
const keyPath = capturedKeyPath
|
|
629
|
+
if (keyPath) {
|
|
630
|
+
expect(() => statSync(keyPath)).toThrow()
|
|
631
|
+
}
|
|
632
|
+
})
|
|
633
|
+
})
|
|
@@ -5,6 +5,7 @@ import {z} from 'zod'
|
|
|
5
5
|
|
|
6
6
|
import {buildKnownHostsArgs} from '../../lib/known-hosts'
|
|
7
7
|
import {redactHost} from '../../lib/redact'
|
|
8
|
+
import {buildIdentityArgs} from '../../lib/ssh-identity'
|
|
8
9
|
import {validateUmamiHost} from './host'
|
|
9
10
|
|
|
10
11
|
declare const process: {
|
|
@@ -97,13 +98,17 @@ function defaultSpawn(
|
|
|
97
98
|
export async function getUmamiComposeStatus(host: string, spawn: SpawnFn = defaultSpawn): Promise<UmamiStatusResult> {
|
|
98
99
|
validateUmamiHost(host)
|
|
99
100
|
|
|
101
|
+
const knownHostsArgs = buildKnownHostsArgs()
|
|
102
|
+
const {args: identityArgs, cleanup} = buildIdentityArgs(process.env.UMAMI_SSH_KEY)
|
|
103
|
+
|
|
100
104
|
const sshCmd = [
|
|
101
105
|
'ssh',
|
|
102
106
|
'-o',
|
|
103
107
|
'BatchMode=yes',
|
|
104
108
|
'-o',
|
|
105
109
|
'StrictHostKeyChecking=yes',
|
|
106
|
-
...
|
|
110
|
+
...knownHostsArgs,
|
|
111
|
+
...identityArgs,
|
|
107
112
|
`root@${host}`,
|
|
108
113
|
`docker compose --project-directory ${COMPOSE_PROJECT_DIR} ps --format json`,
|
|
109
114
|
]
|
|
@@ -114,13 +119,20 @@ export async function getUmamiComposeStatus(host: string, spawn: SpawnFn = defau
|
|
|
114
119
|
...(process.env.SSH_AUTH_SOCK ? {SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK} : {}),
|
|
115
120
|
}
|
|
116
121
|
|
|
117
|
-
|
|
122
|
+
let stdoutText: string
|
|
123
|
+
let stderrText: string
|
|
124
|
+
let exitCode: number
|
|
118
125
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
126
|
+
try {
|
|
127
|
+
const child = spawn(sshCmd, {env, stdout: 'pipe', stderr: 'pipe'})
|
|
128
|
+
;[stdoutText, stderrText, exitCode] = await Promise.all([
|
|
129
|
+
new Response(child.stdout).text(),
|
|
130
|
+
new Response(child.stderr).text(),
|
|
131
|
+
child.exited,
|
|
132
|
+
])
|
|
133
|
+
} finally {
|
|
134
|
+
cleanup()
|
|
135
|
+
}
|
|
124
136
|
|
|
125
137
|
if (exitCode !== 0) {
|
|
126
138
|
return {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {describe, expect, it} from 'bun:test'
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
|
|
2
2
|
|
|
3
3
|
import {getVpnStatusSummary, parseWgShowOutput, type WgShowResult} from './shared'
|
|
4
4
|
|
|
@@ -332,3 +332,123 @@ describe('WgShowResult type', () => {
|
|
|
332
332
|
expect(result.peerCount).toBe(2)
|
|
333
333
|
})
|
|
334
334
|
})
|
|
335
|
+
|
|
336
|
+
// ─── SSH identity injection (VPN_SSH_KEY) ─────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
describe('getVpnWgStatus — SSH identity injection', () => {
|
|
339
|
+
let originalEnv: Record<string, string | undefined>
|
|
340
|
+
|
|
341
|
+
beforeEach(() => {
|
|
342
|
+
originalEnv = {VPN_SSH_KEY: process.env.VPN_SSH_KEY}
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
afterEach(() => {
|
|
346
|
+
if (originalEnv.VPN_SSH_KEY === undefined) {
|
|
347
|
+
delete process.env.VPN_SSH_KEY
|
|
348
|
+
} else {
|
|
349
|
+
process.env.VPN_SSH_KEY = originalEnv.VPN_SSH_KEY
|
|
350
|
+
}
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('includes -i <path> and IdentitiesOnly=yes in ssh argv when VPN_SSH_KEY is set', async () => {
|
|
354
|
+
const {getVpnWgStatus} = await import('./shared')
|
|
355
|
+
process.env.VPN_SSH_KEY = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
|
|
356
|
+
|
|
357
|
+
const wgOutput = 'interface: wg0\n public key: key==\n private key: (hidden)\n listening port: 51820\n'
|
|
358
|
+
let capturedCmd: string[] = []
|
|
359
|
+
const capturingSpawn: SpawnFn = (cmd, _opts) => {
|
|
360
|
+
capturedCmd = cmd
|
|
361
|
+
const enc = new TextEncoder()
|
|
362
|
+
return {
|
|
363
|
+
stdout: new ReadableStream({
|
|
364
|
+
start(c) {
|
|
365
|
+
c.enqueue(enc.encode(wgOutput))
|
|
366
|
+
c.close()
|
|
367
|
+
},
|
|
368
|
+
}),
|
|
369
|
+
stderr: new ReadableStream({
|
|
370
|
+
start(c) {
|
|
371
|
+
c.close()
|
|
372
|
+
},
|
|
373
|
+
}),
|
|
374
|
+
exited: Promise.resolve(0),
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
await getVpnWgStatus('1.2.3.4', capturingSpawn)
|
|
379
|
+
|
|
380
|
+
const iIdx = capturedCmd.indexOf('-i')
|
|
381
|
+
expect(iIdx).toBeGreaterThan(-1)
|
|
382
|
+
expect(capturedCmd[iIdx + 1]).toBeTruthy()
|
|
383
|
+
|
|
384
|
+
const identitiesOnlyIdx = capturedCmd.indexOf('IdentitiesOnly=yes')
|
|
385
|
+
expect(identitiesOnlyIdx).toBeGreaterThan(-1)
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
it('does not include -i or IdentitiesOnly=yes when VPN_SSH_KEY is absent', async () => {
|
|
389
|
+
const {getVpnWgStatus} = await import('./shared')
|
|
390
|
+
delete process.env.VPN_SSH_KEY
|
|
391
|
+
|
|
392
|
+
const wgOutput = 'interface: wg0\n public key: key==\n private key: (hidden)\n listening port: 51820\n'
|
|
393
|
+
let capturedCmd: string[] = []
|
|
394
|
+
const capturingSpawn: SpawnFn = (cmd, _opts) => {
|
|
395
|
+
capturedCmd = cmd
|
|
396
|
+
const enc = new TextEncoder()
|
|
397
|
+
return {
|
|
398
|
+
stdout: new ReadableStream({
|
|
399
|
+
start(c) {
|
|
400
|
+
c.enqueue(enc.encode(wgOutput))
|
|
401
|
+
c.close()
|
|
402
|
+
},
|
|
403
|
+
}),
|
|
404
|
+
stderr: new ReadableStream({
|
|
405
|
+
start(c) {
|
|
406
|
+
c.close()
|
|
407
|
+
},
|
|
408
|
+
}),
|
|
409
|
+
exited: Promise.resolve(0),
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
await getVpnWgStatus('1.2.3.4', capturingSpawn)
|
|
414
|
+
|
|
415
|
+
expect(capturedCmd.indexOf('-i')).toBe(-1)
|
|
416
|
+
expect(capturedCmd.indexOf('IdentitiesOnly=yes')).toBe(-1)
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
it('cleans up the temp key file after the SSH command completes', async () => {
|
|
420
|
+
const {getVpnWgStatus} = await import('./shared')
|
|
421
|
+
const {statSync} = await import('node:fs')
|
|
422
|
+
process.env.VPN_SSH_KEY = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
|
|
423
|
+
|
|
424
|
+
const wgOutput = 'interface: wg0\n public key: key==\n private key: (hidden)\n listening port: 51820\n'
|
|
425
|
+
let capturedKeyPath: string | undefined
|
|
426
|
+
const capturingSpawn: SpawnFn = (cmd, _opts) => {
|
|
427
|
+
const iIdx = cmd.indexOf('-i')
|
|
428
|
+
if (iIdx !== -1) capturedKeyPath = cmd[iIdx + 1]
|
|
429
|
+
const enc = new TextEncoder()
|
|
430
|
+
return {
|
|
431
|
+
stdout: new ReadableStream({
|
|
432
|
+
start(c) {
|
|
433
|
+
c.enqueue(enc.encode(wgOutput))
|
|
434
|
+
c.close()
|
|
435
|
+
},
|
|
436
|
+
}),
|
|
437
|
+
stderr: new ReadableStream({
|
|
438
|
+
start(c) {
|
|
439
|
+
c.close()
|
|
440
|
+
},
|
|
441
|
+
}),
|
|
442
|
+
exited: Promise.resolve(0),
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
await getVpnWgStatus('1.2.3.4', capturingSpawn)
|
|
447
|
+
|
|
448
|
+
expect(capturedKeyPath).toBeTruthy()
|
|
449
|
+
const keyPath = capturedKeyPath
|
|
450
|
+
if (keyPath) {
|
|
451
|
+
expect(() => statSync(keyPath)).toThrow()
|
|
452
|
+
}
|
|
453
|
+
})
|
|
454
|
+
})
|
|
@@ -2,6 +2,7 @@ import type {StatusSummary} from '../status'
|
|
|
2
2
|
|
|
3
3
|
import {buildKnownHostsArgs} from '../../lib/known-hosts'
|
|
4
4
|
import {redactHost} from '../../lib/redact'
|
|
5
|
+
import {buildIdentityArgs} from '../../lib/ssh-identity'
|
|
5
6
|
import {validateVpnHost} from './host'
|
|
6
7
|
|
|
7
8
|
declare const process: {
|
|
@@ -79,13 +80,17 @@ export interface VpnStatusResult {
|
|
|
79
80
|
export async function getVpnWgStatus(host: string, spawn: SpawnFn = defaultSpawn): Promise<VpnStatusResult> {
|
|
80
81
|
validateVpnHost(host)
|
|
81
82
|
|
|
83
|
+
const knownHostsArgs = buildKnownHostsArgs()
|
|
84
|
+
const {args: identityArgs, cleanup} = buildIdentityArgs(process.env.VPN_SSH_KEY)
|
|
85
|
+
|
|
82
86
|
const sshCmd = [
|
|
83
87
|
'ssh',
|
|
84
88
|
'-o',
|
|
85
89
|
'BatchMode=yes',
|
|
86
90
|
'-o',
|
|
87
91
|
'StrictHostKeyChecking=yes',
|
|
88
|
-
...
|
|
92
|
+
...knownHostsArgs,
|
|
93
|
+
...identityArgs,
|
|
89
94
|
`ubuntu@${host}`,
|
|
90
95
|
'sudo wg show wg0',
|
|
91
96
|
]
|
|
@@ -96,13 +101,20 @@ export async function getVpnWgStatus(host: string, spawn: SpawnFn = defaultSpawn
|
|
|
96
101
|
...(process.env.SSH_AUTH_SOCK ? {SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK} : {}),
|
|
97
102
|
}
|
|
98
103
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
child
|
|
105
|
-
|
|
104
|
+
let stdoutText: string
|
|
105
|
+
let stderrText: string
|
|
106
|
+
let exitCode: number
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const child = spawn(sshCmd, {env, stdout: 'pipe', stderr: 'pipe'})
|
|
110
|
+
;[stdoutText, stderrText, exitCode] = await Promise.all([
|
|
111
|
+
new Response(child.stdout).text(),
|
|
112
|
+
new Response(child.stderr).text(),
|
|
113
|
+
child.exited,
|
|
114
|
+
])
|
|
115
|
+
} finally {
|
|
116
|
+
cleanup()
|
|
117
|
+
}
|
|
106
118
|
|
|
107
119
|
if (exitCode !== 0) {
|
|
108
120
|
const redacted = redactHost(stderrText.trim(), host) || 'unknown error'
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import {readFileSync, statSync} from 'node:fs'
|
|
2
|
+
import {describe, expect, it} from 'bun:test'
|
|
3
|
+
|
|
4
|
+
import {buildIdentityArgs, materializeIdentityFile} from './ssh-identity'
|
|
5
|
+
|
|
6
|
+
// ─── materializeIdentityFile ──────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
describe('materializeIdentityFile', () => {
|
|
9
|
+
it('writes the key to a temp file and returns a path', () => {
|
|
10
|
+
const key = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
|
|
11
|
+
const {path, cleanup} = materializeIdentityFile(key)
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
expect(path).toBeTruthy()
|
|
15
|
+
const stat = statSync(path)
|
|
16
|
+
expect(stat.isFile()).toBe(true)
|
|
17
|
+
} finally {
|
|
18
|
+
cleanup()
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('writes the file with mode 0600', () => {
|
|
23
|
+
const key = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
|
|
24
|
+
const {path, cleanup} = materializeIdentityFile(key)
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const stat = statSync(path)
|
|
28
|
+
const mode = stat.mode & 0o777
|
|
29
|
+
expect(mode).toBe(0o600)
|
|
30
|
+
} finally {
|
|
31
|
+
cleanup()
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('ensures the key file ends with a newline', () => {
|
|
36
|
+
const keyWithoutNewline = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----'
|
|
37
|
+
const {path, cleanup} = materializeIdentityFile(keyWithoutNewline)
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const text = readFileSync(path, 'utf8')
|
|
41
|
+
expect(text.endsWith('\n')).toBe(true)
|
|
42
|
+
} finally {
|
|
43
|
+
cleanup()
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('does not double-add a newline when key already ends with one', () => {
|
|
48
|
+
const keyWithNewline = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
|
|
49
|
+
const {path, cleanup} = materializeIdentityFile(keyWithNewline)
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const text = readFileSync(path, 'utf8')
|
|
53
|
+
expect(text.endsWith('\n\n')).toBe(false)
|
|
54
|
+
expect(text.endsWith('\n')).toBe(true)
|
|
55
|
+
} finally {
|
|
56
|
+
cleanup()
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('cleanup removes the temp file and directory', () => {
|
|
61
|
+
const key = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
|
|
62
|
+
const {path, cleanup} = materializeIdentityFile(key)
|
|
63
|
+
const dir = path.slice(0, path.lastIndexOf('/'))
|
|
64
|
+
|
|
65
|
+
cleanup()
|
|
66
|
+
|
|
67
|
+
// Both the key file and its containing temp dir must be gone.
|
|
68
|
+
expect(() => statSync(path)).toThrow()
|
|
69
|
+
expect(() => statSync(dir)).toThrow()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('cleanup is idempotent (calling twice does not throw)', () => {
|
|
73
|
+
const key = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
|
|
74
|
+
const {cleanup} = materializeIdentityFile(key)
|
|
75
|
+
|
|
76
|
+
cleanup()
|
|
77
|
+
expect(() => cleanup()).not.toThrow()
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
// ─── buildIdentityArgs ────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
describe('buildIdentityArgs', () => {
|
|
84
|
+
it('returns [-i, <path>, -o, IdentitiesOnly=yes] when a key is provided', () => {
|
|
85
|
+
const key = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
|
|
86
|
+
const {args, cleanup} = buildIdentityArgs(key)
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
expect(args).toHaveLength(4)
|
|
90
|
+
expect(args[0]).toBe('-i')
|
|
91
|
+
expect(args[1]).toBeTruthy() // path to temp file
|
|
92
|
+
expect(args[2]).toBe('-o')
|
|
93
|
+
expect(args[3]).toBe('IdentitiesOnly=yes')
|
|
94
|
+
} finally {
|
|
95
|
+
cleanup()
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('returns empty args and a no-op cleanup when key is empty string', () => {
|
|
100
|
+
const {args, cleanup} = buildIdentityArgs('')
|
|
101
|
+
|
|
102
|
+
expect(args).toHaveLength(0)
|
|
103
|
+
expect(() => cleanup()).not.toThrow()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('returns empty args and a no-op cleanup when key is whitespace-only', () => {
|
|
107
|
+
const {args, cleanup} = buildIdentityArgs(' \n ')
|
|
108
|
+
|
|
109
|
+
expect(args).toHaveLength(0)
|
|
110
|
+
expect(() => cleanup()).not.toThrow()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('returns empty args and a no-op cleanup when key is undefined', () => {
|
|
114
|
+
const {args, cleanup} = buildIdentityArgs(undefined)
|
|
115
|
+
|
|
116
|
+
expect(args).toHaveLength(0)
|
|
117
|
+
expect(() => cleanup()).not.toThrow()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('the -i path points to a real 0600 file', () => {
|
|
121
|
+
const key = '-----BEGIN OPENSSH PRIVATE KEY-----\nfakekey\n-----END OPENSSH PRIVATE KEY-----\n'
|
|
122
|
+
const {args, cleanup} = buildIdentityArgs(key)
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const keyPath = args[1]
|
|
126
|
+
expect(keyPath).toBeTruthy()
|
|
127
|
+
const stat = statSync(keyPath as string)
|
|
128
|
+
const mode = stat.mode & 0o777
|
|
129
|
+
expect(mode).toBe(0o600)
|
|
130
|
+
} finally {
|
|
131
|
+
cleanup()
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import {chmodSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'
|
|
2
|
+
import {tmpdir} from 'node:os'
|
|
3
|
+
import {join} from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A materialized private-key file plus a best-effort cleanup callback.
|
|
7
|
+
* The cleanup removes the temp directory containing the key file.
|
|
8
|
+
*/
|
|
9
|
+
export interface MaterializedIdentity {
|
|
10
|
+
path: string
|
|
11
|
+
cleanup: () => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// NOTE: This intentionally duplicates the key-materialization logic in
|
|
15
|
+
// packages/shared/server/droplet-helpers.ts. The published CLI must not depend
|
|
16
|
+
// on @marcusrbrown/infra-shared (that dep breaks npm install for external users).
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Writes a private key to a 0600 temp file (with a guaranteed single trailing
|
|
20
|
+
* newline) and returns its path plus an idempotent best-effort cleanup callback.
|
|
21
|
+
*
|
|
22
|
+
* The trailing newline guards against env/secret injection stripping it
|
|
23
|
+
* (OpenSSH rejects keys without it). Empty/whitespace-key guards live in
|
|
24
|
+
* `buildIdentityArgs`; this function materializes whatever key bytes it receives.
|
|
25
|
+
*/
|
|
26
|
+
export function materializeIdentityFile(privateKey: string): MaterializedIdentity {
|
|
27
|
+
const dir = mkdtempSync(join(tmpdir(), 'infra-ssh-key-'))
|
|
28
|
+
const path = join(dir, 'id')
|
|
29
|
+
|
|
30
|
+
const cleanup = (): void => {
|
|
31
|
+
try {
|
|
32
|
+
rmSync(dir, {recursive: true, force: true})
|
|
33
|
+
} catch {
|
|
34
|
+
// Best-effort: a missing temp dir (already cleaned) is fine.
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const contents = privateKey.endsWith('\n') ? privateKey : `${privateKey}\n`
|
|
40
|
+
writeFileSync(path, contents, {mode: 0o600})
|
|
41
|
+
chmodSync(path, 0o600)
|
|
42
|
+
} catch (error) {
|
|
43
|
+
// Don't leave a partial 0600 key (or its temp dir) behind on write failure.
|
|
44
|
+
cleanup()
|
|
45
|
+
throw error
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {path, cleanup}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Result of `buildIdentityArgs` — the SSH argv fragment plus a cleanup callback.
|
|
53
|
+
* When the key is absent/empty, `args` is empty and `cleanup` is a no-op.
|
|
54
|
+
*/
|
|
55
|
+
export interface IdentityArgs {
|
|
56
|
+
/** SSH argv fragment: `['-i', '<path>', '-o', 'IdentitiesOnly=yes']` or `[]`. */
|
|
57
|
+
args: string[]
|
|
58
|
+
/** Removes the temp key file. Idempotent and safe to call multiple times. */
|
|
59
|
+
cleanup: () => void
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Builds the `-i <keyfile> -o IdentitiesOnly=yes` SSH argv fragment from an
|
|
64
|
+
* optional private key string.
|
|
65
|
+
*
|
|
66
|
+
* When `privateKey` is absent, empty, or whitespace-only, returns empty args
|
|
67
|
+
* and a no-op cleanup so callers fall back to ssh-agent behaviour unchanged.
|
|
68
|
+
*/
|
|
69
|
+
export function buildIdentityArgs(privateKey: string | undefined): IdentityArgs {
|
|
70
|
+
if (!privateKey?.trim()) {
|
|
71
|
+
return {args: [], cleanup: () => undefined}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const {path, cleanup} = materializeIdentityFile(privateKey)
|
|
75
|
+
return {args: ['-i', path, '-o', 'IdentitiesOnly=yes'], cleanup}
|
|
76
|
+
}
|