@marcusrbrown/infra 0.13.0 → 0.13.2
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/dist/cli.js +4977 -0
- package/package.json +7 -3
- package/src/__snapshots__/cli.test.ts.snap +0 -219
- package/src/__test__/mcp-ctx-fixture.test.ts +0 -108
- package/src/__test__/mcp-ctx-fixture.ts +0 -104
- package/src/cli.test.ts +0 -66
- package/src/cli.ts +0 -43
- package/src/commands/cliproxy/config.test.ts +0 -475
- package/src/commands/cliproxy/config.ts +0 -238
- package/src/commands/cliproxy/deploy.test.ts +0 -181
- package/src/commands/cliproxy/deploy.ts +0 -157
- package/src/commands/cliproxy/host.test.ts +0 -73
- package/src/commands/cliproxy/host.ts +0 -31
- package/src/commands/cliproxy/index.ts +0 -21
- package/src/commands/cliproxy/keys.test.ts +0 -326
- package/src/commands/cliproxy/keys.ts +0 -201
- package/src/commands/cliproxy/login.test.ts +0 -415
- package/src/commands/cliproxy/login.ts +0 -131
- package/src/commands/cliproxy/models.test.ts +0 -592
- package/src/commands/cliproxy/models.ts +0 -237
- package/src/commands/cliproxy/open.test.ts +0 -132
- package/src/commands/cliproxy/open.ts +0 -78
- package/src/commands/cliproxy/setup/gh.test.ts +0 -218
- package/src/commands/cliproxy/setup/gh.ts +0 -250
- package/src/commands/cliproxy/setup/preview.test.ts +0 -159
- package/src/commands/cliproxy/setup/preview.ts +0 -41
- package/src/commands/cliproxy/setup/prompts.test.ts +0 -58
- package/src/commands/cliproxy/setup/prompts.ts +0 -99
- package/src/commands/cliproxy/setup/providers.test.ts +0 -245
- package/src/commands/cliproxy/setup/providers.ts +0 -136
- package/src/commands/cliproxy/setup/smoke-test.test.ts +0 -821
- package/src/commands/cliproxy/setup/smoke-test.ts +0 -223
- package/src/commands/cliproxy/setup/templates.test.ts +0 -357
- package/src/commands/cliproxy/setup/templates.ts +0 -145
- package/src/commands/cliproxy/setup/validation.test.ts +0 -567
- package/src/commands/cliproxy/setup/validation.ts +0 -209
- package/src/commands/cliproxy/setup/workflow-analyzer.test.ts +0 -363
- package/src/commands/cliproxy/setup/workflow-analyzer.ts +0 -136
- package/src/commands/cliproxy/setup.test.ts +0 -2366
- package/src/commands/cliproxy/setup.ts +0 -809
- package/src/commands/cliproxy/shared.test.ts +0 -118
- package/src/commands/cliproxy/shared.ts +0 -84
- package/src/commands/cliproxy/status.test.ts +0 -1157
- package/src/commands/cliproxy/status.ts +0 -597
- package/src/commands/dashboard/deploy.test.ts +0 -215
- package/src/commands/dashboard/deploy.ts +0 -135
- package/src/commands/dashboard/host.test.ts +0 -79
- package/src/commands/dashboard/host.ts +0 -33
- package/src/commands/dashboard/index.ts +0 -13
- package/src/commands/dashboard/logs.test.ts +0 -317
- package/src/commands/dashboard/logs.ts +0 -180
- package/src/commands/dashboard/status.test.ts +0 -708
- package/src/commands/dashboard/status.ts +0 -293
- package/src/commands/gateway/backup.test.ts +0 -426
- package/src/commands/gateway/backup.ts +0 -205
- package/src/commands/gateway/deploy.test.ts +0 -491
- package/src/commands/gateway/deploy.ts +0 -173
- package/src/commands/gateway/host.test.ts +0 -89
- package/src/commands/gateway/host.ts +0 -33
- package/src/commands/gateway/index.ts +0 -17
- package/src/commands/gateway/logs.test.ts +0 -263
- package/src/commands/gateway/logs.ts +0 -160
- package/src/commands/gateway/restore.test.ts +0 -650
- package/src/commands/gateway/restore.ts +0 -308
- package/src/commands/gateway/status.test.ts +0 -755
- package/src/commands/gateway/status.ts +0 -255
- package/src/commands/keeweb/deploy.test.ts +0 -177
- package/src/commands/keeweb/deploy.ts +0 -191
- package/src/commands/keeweb/index.ts +0 -11
- package/src/commands/keeweb/open.test.ts +0 -54
- package/src/commands/keeweb/open.ts +0 -30
- package/src/commands/keeweb/status.test.ts +0 -509
- package/src/commands/keeweb/status.ts +0 -359
- package/src/commands/mcp.test.ts +0 -260
- package/src/commands/mcp.ts +0 -58
- package/src/commands/status.test.ts +0 -222
- package/src/commands/status.ts +0 -161
- package/src/commands/umami/deploy.test.ts +0 -202
- package/src/commands/umami/deploy.ts +0 -132
- package/src/commands/umami/host.test.ts +0 -79
- package/src/commands/umami/host.ts +0 -33
- package/src/commands/umami/index.ts +0 -13
- package/src/commands/umami/logs.test.ts +0 -191
- package/src/commands/umami/logs.ts +0 -163
- package/src/commands/umami/status.test.ts +0 -633
- package/src/commands/umami/status.ts +0 -285
- package/src/commands/vpn/client.test.ts +0 -949
- package/src/commands/vpn/client.ts +0 -467
- package/src/commands/vpn/deploy.test.ts +0 -208
- package/src/commands/vpn/deploy.ts +0 -143
- package/src/commands/vpn/host.test.ts +0 -89
- package/src/commands/vpn/host.ts +0 -36
- package/src/commands/vpn/index.ts +0 -15
- package/src/commands/vpn/logs.test.ts +0 -234
- package/src/commands/vpn/logs.ts +0 -136
- package/src/commands/vpn/peers.test.ts +0 -478
- package/src/commands/vpn/shared.test.ts +0 -454
- package/src/commands/vpn/shared.ts +0 -170
- package/src/commands/vpn/status.test.ts +0 -142
- package/src/commands/vpn/status.ts +0 -78
- package/src/conventions.test.ts +0 -1180
- package/src/lib/action-ctx.ts +0 -25
- package/src/lib/known-hosts.test.ts +0 -192
- package/src/lib/known-hosts.ts +0 -76
- package/src/lib/redact.ts +0 -21
- package/src/lib/ssh-identity.test.ts +0 -134
- package/src/lib/ssh-identity.ts +0 -76
- /package/{src/resources/known_hosts → dist/known_hosts-tk724cp0.} +0 -0
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
import type {goke} from 'goke'
|
|
2
|
-
|
|
3
|
-
import {z} from 'zod'
|
|
4
|
-
|
|
5
|
-
declare const process: {
|
|
6
|
-
env: Record<string, string | undefined>
|
|
7
|
-
exitCode?: number
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
const REPO = 'marcusrbrown/infra'
|
|
11
|
-
const WORKFLOW_NAME = 'Deploy VPN'
|
|
12
|
-
const WORKFLOW_URL = 'https://github.com/marcusrbrown/infra/actions/workflows/deploy-vpn.yaml'
|
|
13
|
-
|
|
14
|
-
type CliInstance = ReturnType<typeof goke>
|
|
15
|
-
|
|
16
|
-
// ─── Env helpers ─────────────────────────────────────────────────────────────
|
|
17
|
-
|
|
18
|
-
export function getVpnDeployEnv(): Record<string, string> {
|
|
19
|
-
const path = process.env.PATH
|
|
20
|
-
const home = process.env.HOME
|
|
21
|
-
const sshAuthSock = process.env.SSH_AUTH_SOCK
|
|
22
|
-
|
|
23
|
-
if (!path) {
|
|
24
|
-
throw new Error('PATH is required for local deploy')
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
if (!home) {
|
|
28
|
-
throw new Error('HOME is required for local deploy')
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
if (!sshAuthSock) {
|
|
32
|
-
throw new Error('SSH_AUTH_SOCK is required for local deploy. Start ssh-agent and load your deploy key first.')
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
return {
|
|
36
|
-
PATH: path,
|
|
37
|
-
HOME: home,
|
|
38
|
-
SSH_AUTH_SOCK: sshAuthSock,
|
|
39
|
-
VPN_HOST: process.env.VPN_HOST ?? '',
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function validateVpnRemotePreconditions(): void {
|
|
44
|
-
if (!Bun.which('gh')) {
|
|
45
|
-
throw new Error('gh CLI is required for remote deploy. Install gh and run `gh auth login`.')
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// ─── Command registration ─────────────────────────────────────────────────────
|
|
50
|
-
|
|
51
|
-
export function registerVpnDeploy(cli: CliInstance): void {
|
|
52
|
-
cli
|
|
53
|
-
.command(
|
|
54
|
-
'vpn deploy',
|
|
55
|
-
'Deploy the VPN. Default mode triggers the GitHub Deploy VPN workflow, while --local runs apps/vpn deploy directly with Bun.',
|
|
56
|
-
)
|
|
57
|
-
.option(
|
|
58
|
-
'--local',
|
|
59
|
-
z
|
|
60
|
-
.boolean()
|
|
61
|
-
.default(false)
|
|
62
|
-
.describe('Run local deployment with Bun using apps/vpn instead of triggering GitHub Actions.'),
|
|
63
|
-
)
|
|
64
|
-
.option(
|
|
65
|
-
'--dry-run',
|
|
66
|
-
z
|
|
67
|
-
.boolean()
|
|
68
|
-
.default(false)
|
|
69
|
-
.describe(
|
|
70
|
-
'Validate deploy prerequisites and print planned actions without executing local deploy or dispatching workflow.',
|
|
71
|
-
),
|
|
72
|
-
)
|
|
73
|
-
.option(
|
|
74
|
-
'--force-server-key',
|
|
75
|
-
z
|
|
76
|
-
.boolean()
|
|
77
|
-
.default(false)
|
|
78
|
-
.describe(
|
|
79
|
-
'Force regeneration of the server WireGuard keypair. WARNING: invalidates all existing client configs.',
|
|
80
|
-
),
|
|
81
|
-
)
|
|
82
|
-
.example('# Trigger remote GitHub Actions deploy (default mode)')
|
|
83
|
-
.example('infra vpn deploy')
|
|
84
|
-
.example('# Validate local deploy preconditions and planned command')
|
|
85
|
-
.example('infra vpn deploy --local --dry-run')
|
|
86
|
-
.example('# Run local deploy with explicit SSH agent context')
|
|
87
|
-
.example('infra vpn deploy --local')
|
|
88
|
-
.action(async options => {
|
|
89
|
-
if (options.local) {
|
|
90
|
-
const command = [
|
|
91
|
-
'bun',
|
|
92
|
-
'run',
|
|
93
|
-
'--cwd',
|
|
94
|
-
'apps/vpn',
|
|
95
|
-
'deploy',
|
|
96
|
-
...(options.forceServerKey ? ['--force-server-key'] : []),
|
|
97
|
-
]
|
|
98
|
-
|
|
99
|
-
if (options.dryRun) {
|
|
100
|
-
console.log('Dry run: local vpn deploy')
|
|
101
|
-
console.log(`- command: ${command.join(' ')}`)
|
|
102
|
-
return
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const env = getVpnDeployEnv()
|
|
106
|
-
|
|
107
|
-
const child = Bun.spawn(command, {
|
|
108
|
-
env,
|
|
109
|
-
stdout: 'inherit',
|
|
110
|
-
stderr: 'inherit',
|
|
111
|
-
})
|
|
112
|
-
|
|
113
|
-
const exitCode = await child.exited
|
|
114
|
-
if (exitCode !== 0) {
|
|
115
|
-
throw new Error(`Local deploy failed with exit code ${exitCode}`)
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
return
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
validateVpnRemotePreconditions()
|
|
122
|
-
|
|
123
|
-
if (options.dryRun) {
|
|
124
|
-
console.log('Dry run: remote vpn deploy')
|
|
125
|
-
console.log(`- command: gh workflow run "${WORKFLOW_NAME}" --repo ${REPO}`)
|
|
126
|
-
console.log(`- workflow URL: ${WORKFLOW_URL}`)
|
|
127
|
-
return
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const child = Bun.spawn(['gh', 'workflow', 'run', WORKFLOW_NAME, '--repo', REPO], {
|
|
131
|
-
stdout: 'inherit',
|
|
132
|
-
stderr: 'inherit',
|
|
133
|
-
})
|
|
134
|
-
|
|
135
|
-
const exitCode = await child.exited
|
|
136
|
-
if (exitCode !== 0) {
|
|
137
|
-
throw new Error(`Failed to trigger "${WORKFLOW_NAME}" workflow (exit code ${exitCode})`)
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
console.log(`Workflow triggered: ${WORKFLOW_URL}`)
|
|
141
|
-
console.log('Approve the vpn environment deployment in GitHub Actions to continue.')
|
|
142
|
-
})
|
|
143
|
-
}
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import {describe, expect, it} from 'bun:test'
|
|
2
|
-
|
|
3
|
-
import {validateVpnHost} from './host'
|
|
4
|
-
|
|
5
|
-
// ─── validateVpnHost ──────────────────────────────────────────────────────────
|
|
6
|
-
|
|
7
|
-
describe('validateVpnHost', () => {
|
|
8
|
-
// ── Valid inputs ────────────────────────────────────────────────────────────
|
|
9
|
-
|
|
10
|
-
it('accepts a standard FQDN', () => {
|
|
11
|
-
expect(() => validateVpnHost('vpn.example.com')).not.toThrow()
|
|
12
|
-
expect(validateVpnHost('vpn.example.com')).toBe('vpn.example.com')
|
|
13
|
-
})
|
|
14
|
-
|
|
15
|
-
it('accepts localhost', () => {
|
|
16
|
-
expect(() => validateVpnHost('localhost')).not.toThrow()
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
it('accepts an IPv4 address', () => {
|
|
20
|
-
expect(() => validateVpnHost('1.2.3.4')).not.toThrow()
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
it('accepts a single-character hostname', () => {
|
|
24
|
-
expect(() => validateVpnHost('a')).not.toThrow()
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
it('accepts a hostname with hyphens', () => {
|
|
28
|
-
expect(() => validateVpnHost('my-vpn.prod.example.com')).not.toThrow()
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
// ── Injection attacks ───────────────────────────────────────────────────────
|
|
32
|
-
|
|
33
|
-
it('rejects a leading-hyphen value (ProxyCommand injection vector)', () => {
|
|
34
|
-
expect(() => validateVpnHost('-oProxyCommand=evil')).toThrow('Invalid VPN_HOST')
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
it('rejects a value with shell metacharacters (semicolon)', () => {
|
|
38
|
-
expect(() => validateVpnHost('vpn.example.com;rm -rf')).toThrow('Invalid VPN_HOST')
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('rejects a value with shell metacharacters (backtick)', () => {
|
|
42
|
-
expect(() => validateVpnHost('vpn.example.com`id`')).toThrow('Invalid VPN_HOST')
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
it('rejects a value with spaces', () => {
|
|
46
|
-
expect(() => validateVpnHost('vpn example.com')).toThrow('Invalid VPN_HOST')
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
it('rejects a value with an at-sign', () => {
|
|
50
|
-
expect(() => validateVpnHost('user@vpn.example.com')).toThrow('Invalid VPN_HOST')
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
// ── Empty / blank ───────────────────────────────────────────────────────────
|
|
54
|
-
|
|
55
|
-
it('rejects an empty string', () => {
|
|
56
|
-
expect(() => validateVpnHost('')).toThrow('Invalid VPN_HOST')
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
// ── Error message sanitization ──────────────────────────────────────────────
|
|
60
|
-
|
|
61
|
-
it('omits the rejected value entirely from the error message', () => {
|
|
62
|
-
const longMalicious = `-oProxyCommand=${'A'.repeat(100)}`
|
|
63
|
-
let message = ''
|
|
64
|
-
try {
|
|
65
|
-
validateVpnHost(longMalicious)
|
|
66
|
-
} catch (error) {
|
|
67
|
-
message = error instanceof Error ? error.message : String(error)
|
|
68
|
-
}
|
|
69
|
-
// The value is never echoed (it may be a misdirected secret) — only the constraint is shown.
|
|
70
|
-
expect(message).toContain('Invalid VPN_HOST')
|
|
71
|
-
expect(message).not.toContain('ProxyCommand')
|
|
72
|
-
expect(message).not.toContain('AAAA')
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
// ── Secret value redaction ───────────────────────────────────────────────────
|
|
76
|
-
|
|
77
|
-
it('does not echo the rejected value in the error message', () => {
|
|
78
|
-
const secretValue = 'AWS_SECRET_KEY=AKIA1234567890EXAMPLE'
|
|
79
|
-
let message = ''
|
|
80
|
-
try {
|
|
81
|
-
validateVpnHost(secretValue)
|
|
82
|
-
} catch (error) {
|
|
83
|
-
message = error instanceof Error ? error.message : String(error)
|
|
84
|
-
}
|
|
85
|
-
expect(message).toContain('Invalid VPN_HOST')
|
|
86
|
-
expect(message).not.toContain('AKIA1234567890EXAMPLE')
|
|
87
|
-
expect(message).not.toContain('AWS_SECRET_KEY')
|
|
88
|
-
})
|
|
89
|
-
})
|
package/src/commands/vpn/host.ts
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
// ─── VPN CLI host validation ──────────────────────────────────────────────────
|
|
2
|
-
//
|
|
3
|
-
// Validates VPN_HOST values before they are passed as ssh argv arguments.
|
|
4
|
-
// A value starting with `-` would be interpreted by ssh as an option flag,
|
|
5
|
-
// enabling ProxyCommand injection and local code execution.
|
|
6
|
-
//
|
|
7
|
-
// This is the CLI-side validator. The deploy-side validator lives at
|
|
8
|
-
// apps/vpn/src/host.ts. Both enforce the same rules.
|
|
9
|
-
|
|
10
|
-
const VALID_HOST_RE = /^[a-z\d][a-z\d.\-]*$/i
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Validates a candidate VPN_HOST value against a strict hostname allowlist.
|
|
14
|
-
*
|
|
15
|
-
* Accepts: hostnames, FQDNs, IPv4 addresses, `localhost`.
|
|
16
|
-
* Rejects: empty strings, values starting with `-`, and anything containing
|
|
17
|
-
* characters outside `[A-Za-z0-9.-]`.
|
|
18
|
-
*
|
|
19
|
-
* @throws {Error} when empty or not a valid hostname. The rejected value is
|
|
20
|
-
* never included in the message (it may be a misdirected secret).
|
|
21
|
-
* @returns The validated host string (unchanged).
|
|
22
|
-
*/
|
|
23
|
-
export function validateVpnHost(host: string): string {
|
|
24
|
-
if (!host) {
|
|
25
|
-
throw new Error('Invalid VPN_HOST: value is empty')
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
if (!VALID_HOST_RE.test(host)) {
|
|
29
|
-
// Do NOT echo the value — it may be a secret that was redirected via --key
|
|
30
|
-
throw new Error(
|
|
31
|
-
`Invalid VPN_HOST: value is not a valid hostname (must match ${String.raw`[A-Za-z0-9][A-Za-z0-9.\-]*`})`,
|
|
32
|
-
)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
return host
|
|
36
|
-
}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import type {goke} from 'goke'
|
|
2
|
-
|
|
3
|
-
import {registerVpnClient} from './client'
|
|
4
|
-
import {registerVpnDeploy} from './deploy'
|
|
5
|
-
import {registerVpnLogs} from './logs'
|
|
6
|
-
import {registerVpnStatus} from './status'
|
|
7
|
-
|
|
8
|
-
export {getVpnStatusSummary} from './shared'
|
|
9
|
-
|
|
10
|
-
export function registerVpnCommands(cli: ReturnType<typeof goke>): void {
|
|
11
|
-
registerVpnStatus(cli)
|
|
12
|
-
registerVpnDeploy(cli)
|
|
13
|
-
registerVpnLogs(cli)
|
|
14
|
-
registerVpnClient(cli)
|
|
15
|
-
}
|
|
@@ -1,234 +0,0 @@
|
|
|
1
|
-
import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
|
|
2
|
-
|
|
3
|
-
import {streamVpnLogs} from './logs'
|
|
4
|
-
|
|
5
|
-
// ─── SpawnFn type ─────────────────────────────────────────────────────────────
|
|
6
|
-
|
|
7
|
-
type SpawnFn = (
|
|
8
|
-
cmd: string[],
|
|
9
|
-
opts: {env: Record<string, string>; stdout: 'inherit'; stderr: 'inherit'},
|
|
10
|
-
) => {
|
|
11
|
-
exited: Promise<number>
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function makeNeverSpawn(): SpawnFn {
|
|
15
|
-
return () => {
|
|
16
|
-
throw new Error('spawn should not have been called')
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function makeSpawnOk(): SpawnFn {
|
|
21
|
-
return (_cmd, _opts) => ({exited: Promise.resolve(0)})
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// ─── streamVpnLogs — host validation (SEC1) ───────────────────────────────────
|
|
25
|
-
|
|
26
|
-
describe('streamVpnLogs — host validation (SEC1)', () => {
|
|
27
|
-
it('rejects a leading-hyphen host and does not invoke ssh', async () => {
|
|
28
|
-
delete process.env.CI
|
|
29
|
-
|
|
30
|
-
await expect(
|
|
31
|
-
streamVpnLogs({host: '-oProxyCommand=evil', tail: 100, allowCi: false}, makeNeverSpawn()),
|
|
32
|
-
).rejects.toThrow('Invalid VPN_HOST')
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
it('rejects a host with shell metacharacters and does not invoke ssh', async () => {
|
|
36
|
-
delete process.env.CI
|
|
37
|
-
|
|
38
|
-
await expect(
|
|
39
|
-
streamVpnLogs({host: 'vpn.example.com;rm -rf', tail: 100, allowCi: false}, makeNeverSpawn()),
|
|
40
|
-
).rejects.toThrow('Invalid VPN_HOST')
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
it('rejects an empty host and does not invoke ssh', async () => {
|
|
44
|
-
delete process.env.CI
|
|
45
|
-
|
|
46
|
-
await expect(streamVpnLogs({host: '', tail: 100, allowCi: false}, makeNeverSpawn())).rejects.toThrow(
|
|
47
|
-
'Invalid VPN_HOST',
|
|
48
|
-
)
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
it('accepts a valid FQDN and invokes ssh normally', async () => {
|
|
52
|
-
delete process.env.CI
|
|
53
|
-
|
|
54
|
-
const result = await streamVpnLogs({host: 'vpn.example.com', tail: 100, allowCi: false}, makeSpawnOk())
|
|
55
|
-
|
|
56
|
-
expect(result.refused).toBe(false)
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
it('accepts localhost as a valid host', async () => {
|
|
60
|
-
delete process.env.CI
|
|
61
|
-
|
|
62
|
-
const result = await streamVpnLogs({host: 'localhost', tail: 100, allowCi: false}, makeSpawnOk())
|
|
63
|
-
|
|
64
|
-
expect(result.refused).toBe(false)
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
it('accepts an IPv4 address as a valid host', async () => {
|
|
68
|
-
delete process.env.CI
|
|
69
|
-
|
|
70
|
-
const result = await streamVpnLogs({host: '1.2.3.4', tail: 100, allowCi: false}, makeSpawnOk())
|
|
71
|
-
|
|
72
|
-
expect(result.refused).toBe(false)
|
|
73
|
-
})
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
// ─── CI guard ────────────────────────────────────────────────────────────────
|
|
77
|
-
|
|
78
|
-
describe('CI guard', () => {
|
|
79
|
-
let originalCI: string | undefined
|
|
80
|
-
|
|
81
|
-
beforeEach(() => {
|
|
82
|
-
originalCI = process.env.CI
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
afterEach(() => {
|
|
86
|
-
if (originalCI === undefined) {
|
|
87
|
-
delete process.env.CI
|
|
88
|
-
} else {
|
|
89
|
-
process.env.CI = originalCI
|
|
90
|
-
}
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
it('refuses to stream logs in CI without --allow-ci', async () => {
|
|
94
|
-
process.env.CI = 'true'
|
|
95
|
-
|
|
96
|
-
const messages: string[] = []
|
|
97
|
-
const result = await streamVpnLogs(
|
|
98
|
-
{host: 'vpn.example.com', tail: 100, allowCi: false},
|
|
99
|
-
makeNeverSpawn(),
|
|
100
|
-
(msg: string) => {
|
|
101
|
-
messages.push(msg)
|
|
102
|
-
},
|
|
103
|
-
)
|
|
104
|
-
|
|
105
|
-
expect(result.refused).toBe(true)
|
|
106
|
-
expect(messages.some(m => m.includes('--allow-ci'))).toBe(true)
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
it('streams logs in CI when --allow-ci is set, printing stderr warning first', async () => {
|
|
110
|
-
process.env.CI = 'true'
|
|
111
|
-
|
|
112
|
-
const warnings: string[] = []
|
|
113
|
-
const result = await streamVpnLogs(
|
|
114
|
-
{host: 'vpn.example.com', tail: 100, allowCi: true},
|
|
115
|
-
makeSpawnOk(),
|
|
116
|
-
undefined,
|
|
117
|
-
(msg: string) => {
|
|
118
|
-
warnings.push(msg)
|
|
119
|
-
},
|
|
120
|
-
)
|
|
121
|
-
|
|
122
|
-
expect(result.refused).toBe(false)
|
|
123
|
-
expect(warnings.some(w => w.includes('sensitive'))).toBe(true)
|
|
124
|
-
})
|
|
125
|
-
|
|
126
|
-
it('streams logs in non-CI context, printing stderr warning first', async () => {
|
|
127
|
-
delete process.env.CI
|
|
128
|
-
|
|
129
|
-
const warnings: string[] = []
|
|
130
|
-
const result = await streamVpnLogs(
|
|
131
|
-
{host: 'vpn.example.com', tail: 100, allowCi: false},
|
|
132
|
-
makeSpawnOk(),
|
|
133
|
-
undefined,
|
|
134
|
-
(msg: string) => {
|
|
135
|
-
warnings.push(msg)
|
|
136
|
-
},
|
|
137
|
-
)
|
|
138
|
-
|
|
139
|
-
expect(result.refused).toBe(false)
|
|
140
|
-
expect(warnings.some(w => w.includes('sensitive'))).toBe(true)
|
|
141
|
-
})
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
// ─── SSH user + privilege contract ───────────────────────────────────────────
|
|
145
|
-
|
|
146
|
-
describe('streamVpnLogs — SSH user + privilege contract', () => {
|
|
147
|
-
it('SSH user contract: connects as ubuntu@host, not root@host', async () => {
|
|
148
|
-
delete process.env.CI
|
|
149
|
-
|
|
150
|
-
let capturedCmd: string[] = []
|
|
151
|
-
const spawnCapture: SpawnFn = (cmd, _opts) => {
|
|
152
|
-
capturedCmd = cmd
|
|
153
|
-
return {exited: Promise.resolve(0)}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
await streamVpnLogs({host: 'vpn.example.com', tail: 100, allowCi: false}, spawnCapture)
|
|
157
|
-
|
|
158
|
-
const destination = capturedCmd.find(arg => arg.includes('@'))
|
|
159
|
-
expect(destination).toBeDefined()
|
|
160
|
-
expect(destination).toMatch(/^ubuntu@/)
|
|
161
|
-
expect(destination).not.toMatch(/^root@/)
|
|
162
|
-
})
|
|
163
|
-
|
|
164
|
-
it('privilege contract: journalctl is prefixed with sudo', async () => {
|
|
165
|
-
delete process.env.CI
|
|
166
|
-
|
|
167
|
-
let capturedCmd: string[] = []
|
|
168
|
-
const spawnCapture: SpawnFn = (cmd, _opts) => {
|
|
169
|
-
capturedCmd = cmd
|
|
170
|
-
return {exited: Promise.resolve(0)}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
await streamVpnLogs({host: 'vpn.example.com', tail: 100, allowCi: false}, spawnCapture)
|
|
174
|
-
|
|
175
|
-
// The last element of the SSH command is the remote command to run
|
|
176
|
-
const remoteCmd = capturedCmd.at(-1)
|
|
177
|
-
expect(remoteCmd).toMatch(/^sudo journalctl/)
|
|
178
|
-
})
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
// ─── --tail forwarding ───────────────────────────────────────────────────────
|
|
182
|
-
|
|
183
|
-
describe('tail forwarding', () => {
|
|
184
|
-
it('forwards --tail N to journalctl command', async () => {
|
|
185
|
-
delete process.env.CI
|
|
186
|
-
|
|
187
|
-
let capturedCmd: string[] = []
|
|
188
|
-
const spawnCapture: SpawnFn = (cmd, _opts) => {
|
|
189
|
-
capturedCmd = cmd
|
|
190
|
-
return {exited: Promise.resolve(0)}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
await streamVpnLogs({host: 'vpn.example.com', tail: 25, allowCi: false}, spawnCapture)
|
|
194
|
-
|
|
195
|
-
expect(capturedCmd.join(' ')).toContain('25')
|
|
196
|
-
})
|
|
197
|
-
})
|
|
198
|
-
|
|
199
|
-
// ─── SSH command includes repo-pinned UserKnownHostsFile ─────────────────────
|
|
200
|
-
|
|
201
|
-
describe('streamVpnLogs — SSH command includes UserKnownHostsFile', () => {
|
|
202
|
-
it('passes -o UserKnownHostsFile=<repo>/.github/known_hosts to ssh', async () => {
|
|
203
|
-
delete process.env.CI
|
|
204
|
-
|
|
205
|
-
let capturedCmd: string[] = []
|
|
206
|
-
const spawnCapture: SpawnFn = (cmd, _opts) => {
|
|
207
|
-
capturedCmd = cmd
|
|
208
|
-
return {exited: Promise.resolve(0)}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
await streamVpnLogs({host: 'vpn.example.com', tail: 100, allowCi: false}, spawnCapture)
|
|
212
|
-
|
|
213
|
-
const knownHostsIdx = capturedCmd.findIndex(arg => arg.startsWith('UserKnownHostsFile='))
|
|
214
|
-
expect(knownHostsIdx).toBeGreaterThan(-1)
|
|
215
|
-
expect(capturedCmd[knownHostsIdx - 1]).toBe('-o')
|
|
216
|
-
expect(capturedCmd[knownHostsIdx]).toMatch(/\.github[/\\]known_hosts$/)
|
|
217
|
-
})
|
|
218
|
-
|
|
219
|
-
it('does not weaken StrictHostKeyChecking when UserKnownHostsFile is added', async () => {
|
|
220
|
-
delete process.env.CI
|
|
221
|
-
|
|
222
|
-
let capturedCmd: string[] = []
|
|
223
|
-
const spawnCapture: SpawnFn = (cmd, _opts) => {
|
|
224
|
-
capturedCmd = cmd
|
|
225
|
-
return {exited: Promise.resolve(0)}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
await streamVpnLogs({host: 'vpn.example.com', tail: 100, allowCi: false}, spawnCapture)
|
|
229
|
-
|
|
230
|
-
const strictIdx = capturedCmd.findIndex(arg => arg.startsWith('StrictHostKeyChecking='))
|
|
231
|
-
expect(strictIdx).toBeGreaterThan(-1)
|
|
232
|
-
expect(capturedCmd[strictIdx]).toBe('StrictHostKeyChecking=yes')
|
|
233
|
-
})
|
|
234
|
-
})
|
package/src/commands/vpn/logs.ts
DELETED
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
import type {goke} from 'goke'
|
|
2
|
-
|
|
3
|
-
import {z} from 'zod'
|
|
4
|
-
|
|
5
|
-
import {buildKnownHostsArgs} from '../../lib/known-hosts'
|
|
6
|
-
import {validateVpnHost} from './host'
|
|
7
|
-
|
|
8
|
-
declare const process: {
|
|
9
|
-
env: Record<string, string | undefined>
|
|
10
|
-
exitCode?: number
|
|
11
|
-
stderr: {write: (msg: string) => void}
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const SENSITIVE_WARNING =
|
|
15
|
-
'Warning: Logs may contain peer IP addresses, handshake data, or other sensitive VPN information. Treat output as sensitive; do not capture in shared logs or chat.'
|
|
16
|
-
|
|
17
|
-
// ─── Injectable spawn type ────────────────────────────────────────────────────
|
|
18
|
-
|
|
19
|
-
export type LogsSpawnFn = (
|
|
20
|
-
cmd: string[],
|
|
21
|
-
opts: {env: Record<string, string>; stdout: 'inherit'; stderr: 'inherit'},
|
|
22
|
-
) => {
|
|
23
|
-
exited: Promise<number>
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export interface StreamVpnLogsOpts {
|
|
27
|
-
host: string
|
|
28
|
-
tail: number
|
|
29
|
-
allowCi: boolean
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export interface StreamVpnLogsResult {
|
|
33
|
-
refused: boolean
|
|
34
|
-
exitCode?: number
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function defaultLogsSpawn(
|
|
38
|
-
cmd: string[],
|
|
39
|
-
opts: {env: Record<string, string>; stdout: 'inherit'; stderr: 'inherit'},
|
|
40
|
-
): {exited: Promise<number>} {
|
|
41
|
-
return Bun.spawn(cmd, opts)
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export async function streamVpnLogs(
|
|
45
|
-
opts: StreamVpnLogsOpts,
|
|
46
|
-
spawn: LogsSpawnFn = defaultLogsSpawn,
|
|
47
|
-
printOut?: (msg: string) => void,
|
|
48
|
-
printErr?: (msg: string) => void,
|
|
49
|
-
): Promise<StreamVpnLogsResult> {
|
|
50
|
-
const isCI = process.env.CI === 'true'
|
|
51
|
-
|
|
52
|
-
if (isCI && !opts.allowCi) {
|
|
53
|
-
const msg = 'Refusing to stream logs in CI without --allow-ci. Logs may contain sensitive VPN data.'
|
|
54
|
-
if (printOut) {
|
|
55
|
-
printOut(msg)
|
|
56
|
-
} else {
|
|
57
|
-
console.log(msg)
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
return {refused: true}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Warn on every run — logs are sensitive regardless of context
|
|
64
|
-
if (printErr) {
|
|
65
|
-
printErr(SENSITIVE_WARNING)
|
|
66
|
-
} else {
|
|
67
|
-
console.error(SENSITIVE_WARNING)
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
validateVpnHost(opts.host)
|
|
71
|
-
|
|
72
|
-
const sshCmd = [
|
|
73
|
-
'ssh',
|
|
74
|
-
'-o',
|
|
75
|
-
'BatchMode=yes',
|
|
76
|
-
'-o',
|
|
77
|
-
'StrictHostKeyChecking=yes',
|
|
78
|
-
...buildKnownHostsArgs(),
|
|
79
|
-
`ubuntu@${opts.host}`,
|
|
80
|
-
`sudo journalctl -u wg-quick@wg0 --no-pager -n ${opts.tail}`,
|
|
81
|
-
]
|
|
82
|
-
|
|
83
|
-
const env: Record<string, string> = {
|
|
84
|
-
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
|
85
|
-
HOME: process.env.HOME ?? '/tmp',
|
|
86
|
-
...(process.env.SSH_AUTH_SOCK ? {SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK} : {}),
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const child = spawn(sshCmd, {env, stdout: 'inherit', stderr: 'inherit'})
|
|
90
|
-
const exitCode = await child.exited
|
|
91
|
-
|
|
92
|
-
return {refused: false, exitCode}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// ─── Command registration ─────────────────────────────────────────────────────
|
|
96
|
-
|
|
97
|
-
export function registerVpnLogs(cli: ReturnType<typeof goke>): void {
|
|
98
|
-
cli
|
|
99
|
-
.command('vpn logs', 'Stream WireGuard VPN logs via SSH and journalctl.')
|
|
100
|
-
.option('--tail [n]', z.number().default(100).describe('Number of log lines to tail.'))
|
|
101
|
-
.option(
|
|
102
|
-
'--allow-ci',
|
|
103
|
-
z
|
|
104
|
-
.boolean()
|
|
105
|
-
.default(false)
|
|
106
|
-
.describe('Allow log streaming in CI environments. Logs may contain sensitive VPN data.'),
|
|
107
|
-
)
|
|
108
|
-
.option(
|
|
109
|
-
'--key [key]',
|
|
110
|
-
z.string().describe('Environment variable name holding the SSH host. Falls back to VPN_HOST when omitted.'),
|
|
111
|
-
)
|
|
112
|
-
.action(async options => {
|
|
113
|
-
const hostEnvKey = options.key ?? 'VPN_HOST'
|
|
114
|
-
const host = process.env[hostEnvKey]
|
|
115
|
-
|
|
116
|
-
if (!host) {
|
|
117
|
-
console.error(`VPN host not set. Export ${hostEnvKey} or pass --key <env-name> pointing to a set variable.`)
|
|
118
|
-
process.exitCode = 1
|
|
119
|
-
return
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const tail = typeof options.tail === 'number' ? options.tail : 100
|
|
123
|
-
const allowCi = options.allowCi === true
|
|
124
|
-
|
|
125
|
-
const result = await streamVpnLogs({host, tail, allowCi})
|
|
126
|
-
|
|
127
|
-
if (result.refused) {
|
|
128
|
-
process.exitCode = 1
|
|
129
|
-
return
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
if (result.exitCode !== 0) {
|
|
133
|
-
process.exitCode = result.exitCode ?? 1
|
|
134
|
-
}
|
|
135
|
-
})
|
|
136
|
-
}
|