@marcusrbrown/infra 0.13.1 → 0.13.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +11965 -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,89 +0,0 @@
|
|
|
1
|
-
import {describe, expect, it} from 'bun:test'
|
|
2
|
-
|
|
3
|
-
import {validateGatewayHost} from './host'
|
|
4
|
-
|
|
5
|
-
// ─── validateGatewayHost ──────────────────────────────────────────────────────
|
|
6
|
-
|
|
7
|
-
describe('validateGatewayHost', () => {
|
|
8
|
-
// ── Valid inputs ────────────────────────────────────────────────────────────
|
|
9
|
-
|
|
10
|
-
it('accepts a standard FQDN', () => {
|
|
11
|
-
expect(() => validateGatewayHost('gateway.example.com')).not.toThrow()
|
|
12
|
-
expect(validateGatewayHost('gateway.example.com')).toBe('gateway.example.com')
|
|
13
|
-
})
|
|
14
|
-
|
|
15
|
-
it('accepts localhost', () => {
|
|
16
|
-
expect(() => validateGatewayHost('localhost')).not.toThrow()
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
it('accepts an IPv4 address', () => {
|
|
20
|
-
expect(() => validateGatewayHost('147.182.133.210')).not.toThrow()
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
it('accepts a single-character hostname', () => {
|
|
24
|
-
expect(() => validateGatewayHost('a')).not.toThrow()
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
it('accepts a hostname with hyphens', () => {
|
|
28
|
-
expect(() => validateGatewayHost('my-gateway.prod.example.com')).not.toThrow()
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
// ── Injection attacks ───────────────────────────────────────────────────────
|
|
32
|
-
|
|
33
|
-
it('rejects a leading-hyphen value (ProxyCommand injection vector)', () => {
|
|
34
|
-
expect(() => validateGatewayHost('-oProxyCommand=evil')).toThrow('Invalid GATEWAY_HOST')
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
it('rejects a value with shell metacharacters (semicolon)', () => {
|
|
38
|
-
expect(() => validateGatewayHost('gateway.example.com;rm -rf')).toThrow('Invalid GATEWAY_HOST')
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('rejects a value with shell metacharacters (backtick)', () => {
|
|
42
|
-
expect(() => validateGatewayHost('gateway.example.com`id`')).toThrow('Invalid GATEWAY_HOST')
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
it('rejects a value with spaces', () => {
|
|
46
|
-
expect(() => validateGatewayHost('gateway example.com')).toThrow('Invalid GATEWAY_HOST')
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
it('rejects a value with an at-sign', () => {
|
|
50
|
-
expect(() => validateGatewayHost('user@gateway.example.com')).toThrow('Invalid GATEWAY_HOST')
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
// ── Empty / blank ───────────────────────────────────────────────────────────
|
|
54
|
-
|
|
55
|
-
it('rejects an empty string', () => {
|
|
56
|
-
expect(() => validateGatewayHost('')).toThrow('Invalid GATEWAY_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
|
-
validateGatewayHost(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 GATEWAY_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
|
-
validateGatewayHost(secretValue)
|
|
82
|
-
} catch (error) {
|
|
83
|
-
message = error instanceof Error ? error.message : String(error)
|
|
84
|
-
}
|
|
85
|
-
expect(message).toContain('Invalid GATEWAY_HOST')
|
|
86
|
-
expect(message).not.toContain('AKIA1234567890EXAMPLE')
|
|
87
|
-
expect(message).not.toContain('AWS_SECRET_KEY')
|
|
88
|
-
})
|
|
89
|
-
})
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
// ─── Gateway host validation ──────────────────────────────────────────────────
|
|
2
|
-
//
|
|
3
|
-
// Validates GATEWAY_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
|
-
const VALID_HOST_RE = /^[a-z\d][a-z\d.\-]*$/i
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Validates a candidate GATEWAY_HOST value against a strict hostname allowlist.
|
|
11
|
-
*
|
|
12
|
-
* Accepts: hostnames, FQDNs, IPv4 addresses, `localhost`.
|
|
13
|
-
* Rejects: empty strings, values starting with `-`, and anything containing
|
|
14
|
-
* characters outside `[A-Za-z0-9.-]`.
|
|
15
|
-
*
|
|
16
|
-
* @throws {Error} when empty or not a valid hostname. The rejected value is
|
|
17
|
-
* never included in the message (it may be a misdirected secret).
|
|
18
|
-
* @returns The validated host string (unchanged).
|
|
19
|
-
*/
|
|
20
|
-
export function validateGatewayHost(host: string): string {
|
|
21
|
-
if (!host) {
|
|
22
|
-
throw new Error('Invalid GATEWAY_HOST: value is empty')
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
if (!VALID_HOST_RE.test(host)) {
|
|
26
|
-
// Do NOT echo the value — it may be a secret that was redirected via --key
|
|
27
|
-
throw new Error(
|
|
28
|
-
`Invalid GATEWAY_HOST: value is not a valid hostname (must match ${String.raw`[A-Za-z0-9][A-Za-z0-9.\-]*`})`,
|
|
29
|
-
)
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
return host
|
|
33
|
-
}
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import type {goke} from 'goke'
|
|
2
|
-
|
|
3
|
-
import {registerGatewayBackup} from './backup'
|
|
4
|
-
import {registerGatewayDeploy} from './deploy'
|
|
5
|
-
import {registerGatewayLogs} from './logs'
|
|
6
|
-
import {registerGatewayRestore} from './restore'
|
|
7
|
-
import {registerGatewayStatus} from './status'
|
|
8
|
-
|
|
9
|
-
export {getGatewayStatusSummary} from './status'
|
|
10
|
-
|
|
11
|
-
export function registerGatewayCommands(cli: ReturnType<typeof goke>): void {
|
|
12
|
-
registerGatewayStatus(cli)
|
|
13
|
-
registerGatewayDeploy(cli)
|
|
14
|
-
registerGatewayLogs(cli)
|
|
15
|
-
registerGatewayBackup(cli)
|
|
16
|
-
registerGatewayRestore(cli)
|
|
17
|
-
}
|
|
@@ -1,263 +0,0 @@
|
|
|
1
|
-
import {afterEach, beforeEach, describe, expect, it} from 'bun:test'
|
|
2
|
-
|
|
3
|
-
import {VALID_SERVICES, validateService} from './logs'
|
|
4
|
-
|
|
5
|
-
// ─── validateService ─────────────────────────────────────────────────────────
|
|
6
|
-
|
|
7
|
-
describe('validateService', () => {
|
|
8
|
-
it('accepts gateway as a valid service', () => {
|
|
9
|
-
expect(() => validateService('gateway')).not.toThrow()
|
|
10
|
-
})
|
|
11
|
-
|
|
12
|
-
it('accepts workspace as a valid service', () => {
|
|
13
|
-
expect(() => validateService('workspace')).not.toThrow()
|
|
14
|
-
})
|
|
15
|
-
|
|
16
|
-
it('accepts mitmproxy as a valid service', () => {
|
|
17
|
-
expect(() => validateService('mitmproxy')).not.toThrow()
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
it('rejects an unknown service name with a message listing valid services', () => {
|
|
21
|
-
expect(() => validateService('frobnicator')).toThrow(VALID_SERVICES.join(', '))
|
|
22
|
-
})
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
// ─── streamGatewayLogs — host validation (SEC1) ───────────────────────────────
|
|
26
|
-
|
|
27
|
-
describe('streamGatewayLogs — host validation (SEC1)', () => {
|
|
28
|
-
it('rejects a leading-hyphen host and does not invoke ssh', async () => {
|
|
29
|
-
delete process.env.CI
|
|
30
|
-
|
|
31
|
-
const {streamGatewayLogs} = await import('./logs')
|
|
32
|
-
|
|
33
|
-
const neverSpawn: SpawnFn = () => {
|
|
34
|
-
throw new Error('spawn must not be called for an invalid host')
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
await expect(
|
|
38
|
-
streamGatewayLogs({host: '-oProxyCommand=evil', service: 'gateway', tail: 100, allowCi: false}, neverSpawn),
|
|
39
|
-
).rejects.toThrow('Invalid GATEWAY_HOST')
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
it('rejects a host with shell metacharacters and does not invoke ssh', async () => {
|
|
43
|
-
delete process.env.CI
|
|
44
|
-
|
|
45
|
-
const {streamGatewayLogs} = await import('./logs')
|
|
46
|
-
|
|
47
|
-
const neverSpawn: SpawnFn = () => {
|
|
48
|
-
throw new Error('spawn must not be called for an invalid host')
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
await expect(
|
|
52
|
-
streamGatewayLogs(
|
|
53
|
-
{host: 'gateway.example.com;rm -rf', service: 'gateway', tail: 100, allowCi: false},
|
|
54
|
-
neverSpawn,
|
|
55
|
-
),
|
|
56
|
-
).rejects.toThrow('Invalid GATEWAY_HOST')
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
it('rejects an empty host and does not invoke ssh', async () => {
|
|
60
|
-
delete process.env.CI
|
|
61
|
-
|
|
62
|
-
const {streamGatewayLogs} = await import('./logs')
|
|
63
|
-
|
|
64
|
-
const neverSpawn: SpawnFn = () => {
|
|
65
|
-
throw new Error('spawn must not be called for an invalid host')
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
await expect(
|
|
69
|
-
streamGatewayLogs({host: '', service: 'gateway', tail: 100, allowCi: false}, neverSpawn),
|
|
70
|
-
).rejects.toThrow('Invalid GATEWAY_HOST')
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
it('accepts a valid FQDN and invokes ssh normally', async () => {
|
|
74
|
-
delete process.env.CI
|
|
75
|
-
|
|
76
|
-
const {streamGatewayLogs} = await import('./logs')
|
|
77
|
-
|
|
78
|
-
const result = await streamGatewayLogs(
|
|
79
|
-
{host: 'gateway.example.com', service: 'gateway', tail: 100, allowCi: false},
|
|
80
|
-
makeSpawnOk(),
|
|
81
|
-
)
|
|
82
|
-
|
|
83
|
-
expect(result.refused).toBe(false)
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
it('accepts localhost as a valid host', async () => {
|
|
87
|
-
delete process.env.CI
|
|
88
|
-
|
|
89
|
-
const {streamGatewayLogs} = await import('./logs')
|
|
90
|
-
|
|
91
|
-
const result = await streamGatewayLogs(
|
|
92
|
-
{host: 'localhost', service: 'gateway', tail: 100, allowCi: false},
|
|
93
|
-
makeSpawnOk(),
|
|
94
|
-
)
|
|
95
|
-
|
|
96
|
-
expect(result.refused).toBe(false)
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
it('accepts an IPv4 address as a valid host', async () => {
|
|
100
|
-
delete process.env.CI
|
|
101
|
-
|
|
102
|
-
const {streamGatewayLogs} = await import('./logs')
|
|
103
|
-
|
|
104
|
-
const result = await streamGatewayLogs(
|
|
105
|
-
{host: '147.182.133.210', service: 'gateway', tail: 100, allowCi: false},
|
|
106
|
-
makeSpawnOk(),
|
|
107
|
-
)
|
|
108
|
-
|
|
109
|
-
expect(result.refused).toBe(false)
|
|
110
|
-
})
|
|
111
|
-
})
|
|
112
|
-
|
|
113
|
-
// ─── CI guard ────────────────────────────────────────────────────────────────
|
|
114
|
-
|
|
115
|
-
type SpawnFn = (
|
|
116
|
-
cmd: string[],
|
|
117
|
-
opts: {env: Record<string, string>; stdout: 'inherit'; stderr: 'inherit'},
|
|
118
|
-
) => {
|
|
119
|
-
exited: Promise<number>
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function makeNeverSpawn(): SpawnFn {
|
|
123
|
-
return () => {
|
|
124
|
-
throw new Error('spawn should not have been called')
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function makeSpawnOk(): SpawnFn {
|
|
129
|
-
return (_cmd, _opts) => ({exited: Promise.resolve(0)})
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
describe('CI guard', () => {
|
|
133
|
-
let originalCI: string | undefined
|
|
134
|
-
|
|
135
|
-
beforeEach(() => {
|
|
136
|
-
originalCI = process.env.CI
|
|
137
|
-
})
|
|
138
|
-
|
|
139
|
-
afterEach(() => {
|
|
140
|
-
if (originalCI === undefined) {
|
|
141
|
-
delete process.env.CI
|
|
142
|
-
} else {
|
|
143
|
-
process.env.CI = originalCI
|
|
144
|
-
}
|
|
145
|
-
})
|
|
146
|
-
|
|
147
|
-
it('refuses to stream logs in CI without --allow-ci', async () => {
|
|
148
|
-
process.env.CI = 'true'
|
|
149
|
-
|
|
150
|
-
const {streamGatewayLogs} = await import('./logs')
|
|
151
|
-
|
|
152
|
-
const messages: string[] = []
|
|
153
|
-
const result = await streamGatewayLogs(
|
|
154
|
-
{host: 'gateway.example.com', service: 'gateway', tail: 100, allowCi: false},
|
|
155
|
-
makeNeverSpawn(),
|
|
156
|
-
(msg: string) => {
|
|
157
|
-
messages.push(msg)
|
|
158
|
-
},
|
|
159
|
-
)
|
|
160
|
-
|
|
161
|
-
expect(result.refused).toBe(true)
|
|
162
|
-
expect(messages.some(m => m.includes('--allow-ci'))).toBe(true)
|
|
163
|
-
})
|
|
164
|
-
|
|
165
|
-
it('streams logs in CI when --allow-ci is set, printing stderr warning first', async () => {
|
|
166
|
-
process.env.CI = 'true'
|
|
167
|
-
|
|
168
|
-
const {streamGatewayLogs} = await import('./logs')
|
|
169
|
-
|
|
170
|
-
const warnings: string[] = []
|
|
171
|
-
const result = await streamGatewayLogs(
|
|
172
|
-
{host: 'gateway.example.com', service: 'gateway', tail: 100, allowCi: true},
|
|
173
|
-
makeSpawnOk(),
|
|
174
|
-
undefined,
|
|
175
|
-
(msg: string) => {
|
|
176
|
-
warnings.push(msg)
|
|
177
|
-
},
|
|
178
|
-
)
|
|
179
|
-
|
|
180
|
-
expect(result.refused).toBe(false)
|
|
181
|
-
expect(warnings.some(w => w.includes('Discord tokens'))).toBe(true)
|
|
182
|
-
})
|
|
183
|
-
|
|
184
|
-
it('streams logs in non-CI context, printing stderr warning first', async () => {
|
|
185
|
-
delete process.env.CI
|
|
186
|
-
|
|
187
|
-
const {streamGatewayLogs} = await import('./logs')
|
|
188
|
-
|
|
189
|
-
const warnings: string[] = []
|
|
190
|
-
const result = await streamGatewayLogs(
|
|
191
|
-
{host: 'gateway.example.com', service: 'gateway', tail: 100, allowCi: false},
|
|
192
|
-
makeSpawnOk(),
|
|
193
|
-
undefined,
|
|
194
|
-
(msg: string) => {
|
|
195
|
-
warnings.push(msg)
|
|
196
|
-
},
|
|
197
|
-
)
|
|
198
|
-
|
|
199
|
-
expect(result.refused).toBe(false)
|
|
200
|
-
expect(warnings.some(w => w.includes('Discord tokens'))).toBe(true)
|
|
201
|
-
})
|
|
202
|
-
})
|
|
203
|
-
|
|
204
|
-
// ─── --tail forwarding ───────────────────────────────────────────────────────
|
|
205
|
-
|
|
206
|
-
describe('tail forwarding', () => {
|
|
207
|
-
it('forwards --tail N to docker compose logs command', async () => {
|
|
208
|
-
delete process.env.CI
|
|
209
|
-
|
|
210
|
-
const {streamGatewayLogs} = await import('./logs')
|
|
211
|
-
|
|
212
|
-
let capturedCmd: string[] = []
|
|
213
|
-
const spawnCapture: SpawnFn = (cmd, _opts) => {
|
|
214
|
-
capturedCmd = cmd
|
|
215
|
-
return {exited: Promise.resolve(0)}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
await streamGatewayLogs({host: 'gateway.example.com', service: 'gateway', tail: 25, allowCi: false}, spawnCapture)
|
|
219
|
-
|
|
220
|
-
expect(capturedCmd.join(' ')).toContain('--tail=25')
|
|
221
|
-
})
|
|
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
|
-
})
|
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
import type {goke} from 'goke'
|
|
2
|
-
|
|
3
|
-
import {z} from 'zod'
|
|
4
|
-
|
|
5
|
-
import {buildKnownHostsArgs} from '../../lib/known-hosts'
|
|
6
|
-
import {validateGatewayHost} 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 COMPOSE_PROJECT_DIR = '/opt/gateway/deploy'
|
|
15
|
-
const SENSITIVE_WARNING =
|
|
16
|
-
'Warning: Logs may contain Discord tokens, S3 credentials, or user data. Treat output as sensitive; do not capture in shared logs or chat.'
|
|
17
|
-
|
|
18
|
-
export const VALID_SERVICES = ['gateway', 'workspace', 'mitmproxy'] as const
|
|
19
|
-
|
|
20
|
-
export type ValidService = (typeof VALID_SERVICES)[number]
|
|
21
|
-
|
|
22
|
-
// ─── Pure helpers ─────────────────────────────────────────────────────────────
|
|
23
|
-
|
|
24
|
-
export function validateService(service: string): asserts service is ValidService {
|
|
25
|
-
if (!VALID_SERVICES.includes(service as ValidService)) {
|
|
26
|
-
throw new Error(`Invalid service "${service}". Valid services: ${VALID_SERVICES.join(', ')}`)
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// ─── Injectable spawn type ────────────────────────────────────────────────────
|
|
31
|
-
|
|
32
|
-
export type LogsSpawnFn = (
|
|
33
|
-
cmd: string[],
|
|
34
|
-
opts: {env: Record<string, string>; stdout: 'inherit'; stderr: 'inherit'},
|
|
35
|
-
) => {
|
|
36
|
-
exited: Promise<number>
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface StreamLogsOpts {
|
|
40
|
-
host: string
|
|
41
|
-
service: string
|
|
42
|
-
tail: number
|
|
43
|
-
allowCi: boolean
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface StreamLogsResult {
|
|
47
|
-
refused: boolean
|
|
48
|
-
exitCode?: number
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function defaultLogsSpawn(
|
|
52
|
-
cmd: string[],
|
|
53
|
-
opts: {env: Record<string, string>; stdout: 'inherit'; stderr: 'inherit'},
|
|
54
|
-
): {exited: Promise<number>} {
|
|
55
|
-
return Bun.spawn(cmd, opts)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export async function streamGatewayLogs(
|
|
59
|
-
opts: StreamLogsOpts,
|
|
60
|
-
spawn: LogsSpawnFn = defaultLogsSpawn,
|
|
61
|
-
printOut?: (msg: string) => void,
|
|
62
|
-
printErr?: (msg: string) => void,
|
|
63
|
-
): Promise<StreamLogsResult> {
|
|
64
|
-
const isCI = process.env.CI === 'true'
|
|
65
|
-
|
|
66
|
-
if (isCI && !opts.allowCi) {
|
|
67
|
-
const msg = 'Refusing to stream logs in CI without --allow-ci. Logs may contain sensitive tokens or user data.'
|
|
68
|
-
if (printOut) {
|
|
69
|
-
printOut(msg)
|
|
70
|
-
} else {
|
|
71
|
-
console.log(msg)
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
return {refused: true}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Warn on every run — logs are sensitive regardless of context
|
|
78
|
-
if (printErr) {
|
|
79
|
-
printErr(SENSITIVE_WARNING)
|
|
80
|
-
} else {
|
|
81
|
-
console.error(SENSITIVE_WARNING)
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
validateGatewayHost(opts.host)
|
|
85
|
-
|
|
86
|
-
const sshCmd = [
|
|
87
|
-
'ssh',
|
|
88
|
-
'-o',
|
|
89
|
-
'BatchMode=yes',
|
|
90
|
-
'-o',
|
|
91
|
-
'StrictHostKeyChecking=yes',
|
|
92
|
-
...buildKnownHostsArgs(),
|
|
93
|
-
`root@${opts.host}`,
|
|
94
|
-
`docker compose --project-directory ${COMPOSE_PROJECT_DIR} logs --no-color --tail=${opts.tail} ${opts.service}`,
|
|
95
|
-
]
|
|
96
|
-
|
|
97
|
-
const env: Record<string, string> = {
|
|
98
|
-
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
|
99
|
-
HOME: process.env.HOME ?? '/tmp',
|
|
100
|
-
...(process.env.SSH_AUTH_SOCK ? {SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK} : {}),
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const child = spawn(sshCmd, {env, stdout: 'inherit', stderr: 'inherit'})
|
|
104
|
-
const exitCode = await child.exited
|
|
105
|
-
|
|
106
|
-
return {refused: false, exitCode}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// ─── Command registration ─────────────────────────────────────────────────────
|
|
110
|
-
|
|
111
|
-
export function registerGatewayLogs(cli: ReturnType<typeof goke>): void {
|
|
112
|
-
cli
|
|
113
|
-
.command('gateway logs [service]', 'Stream logs from a gateway service via SSH and docker compose.')
|
|
114
|
-
.option('--tail [n]', z.number().default(100).describe('Number of log lines to tail from each service.'))
|
|
115
|
-
.option(
|
|
116
|
-
'--allow-ci',
|
|
117
|
-
z
|
|
118
|
-
.boolean()
|
|
119
|
-
.default(false)
|
|
120
|
-
.describe('Allow log streaming in CI environments. Logs may contain sensitive credentials.'),
|
|
121
|
-
)
|
|
122
|
-
.option(
|
|
123
|
-
'--key [key]',
|
|
124
|
-
z.string().describe('Environment variable name holding the SSH host. Falls back to GATEWAY_HOST when omitted.'),
|
|
125
|
-
)
|
|
126
|
-
.action(async (service, options) => {
|
|
127
|
-
const targetService = (service as string | undefined) ?? 'gateway'
|
|
128
|
-
|
|
129
|
-
try {
|
|
130
|
-
validateService(targetService)
|
|
131
|
-
} catch (error) {
|
|
132
|
-
console.error(error instanceof Error ? error.message : String(error))
|
|
133
|
-
process.exitCode = 1
|
|
134
|
-
return
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const hostEnvKey = options.key ?? 'GATEWAY_HOST'
|
|
138
|
-
const host = process.env[hostEnvKey]
|
|
139
|
-
|
|
140
|
-
if (!host) {
|
|
141
|
-
console.error(`Gateway host not set. Export ${hostEnvKey} or pass --key <env-name> pointing to a set variable.`)
|
|
142
|
-
process.exitCode = 1
|
|
143
|
-
return
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const tail = typeof options.tail === 'number' ? options.tail : 100
|
|
147
|
-
const allowCi = options.allowCi === true
|
|
148
|
-
|
|
149
|
-
const result = await streamGatewayLogs({host, service: targetService, tail, allowCi})
|
|
150
|
-
|
|
151
|
-
if (result.refused) {
|
|
152
|
-
process.exitCode = 1
|
|
153
|
-
return
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
if (result.exitCode !== 0) {
|
|
157
|
-
process.exitCode = result.exitCode ?? 1
|
|
158
|
-
}
|
|
159
|
-
})
|
|
160
|
-
}
|