diff --git a/packages/cli/src/biscuit.ts b/packages/cli/src/biscuit.ts index c90ab01..5da7084 100644 --- a/packages/cli/src/biscuit.ts +++ b/packages/cli/src/biscuit.ts @@ -19,6 +19,9 @@ type WasmGlueModule = BiscuitModule & { __wbg_set_wasm(wasm: WebAssembly.Instance['exports']): void } +type WasmImports = NonNullable[1]> +type WasmModuleImports = WasmImports[string] + const SNIPPET_MODULES = [ './snippets/biscuit-auth-1c48f52e9814dd36/inline0.js', './snippets/biscuit-auth-314ca57174ae0e6d/inline0.js', @@ -46,7 +49,7 @@ async function initBiscuit(): Promise { } const bg = importedBg - const wasmImports: WebAssembly.ModuleImports = {} + const wasmImports: WasmModuleImports = {} for (const [key, value] of Object.entries(bg)) { if (key.startsWith('__wbg_') || key.startsWith('__wbindgen_')) { wasmImports[key] = value @@ -54,7 +57,7 @@ async function initBiscuit(): Promise { } const snippetExports = { performance_now: () => performance.now() } - const imports: WebAssembly.Imports = { './biscuit_bg.js': wasmImports } + const imports: WasmImports = { './biscuit_bg.js': wasmImports } for (const moduleName of SNIPPET_MODULES) { imports[moduleName] = snippetExports diff --git a/packages/cli/src/commands/profile.ts b/packages/cli/src/commands/profile.ts index ecc59ab..52f0a26 100644 --- a/packages/cli/src/commands/profile.ts +++ b/packages/cli/src/commands/profile.ts @@ -12,7 +12,7 @@ interface CredProfile { } interface CreateCredProfileRequest { - host?: string[] + host: string[] auth?: Record managedOauth?: Record displayName?: string @@ -200,9 +200,13 @@ export async function addProfile(slug: string, hosts: string[], options: AddProf throw new Error(`Invalid profile JSON in ${options.filePath}`) } body = parsed - if ((!body.host || body.host.length === 0) && hosts.length > 0) { + if (body.host.length === 0 && hosts.length > 0) { body.host = hosts } + if (body.host.length === 0) { + console.error('At least one host is required, either in the JSON file or via --host.') + process.exit(1) + } } else { if (hosts.length === 0) { console.error('At least one --host is required.') diff --git a/packages/cli/src/commands/wrap.ts b/packages/cli/src/commands/wrap.ts new file mode 100644 index 0000000..7f18010 --- /dev/null +++ b/packages/cli/src/commands/wrap.ts @@ -0,0 +1,141 @@ +import { spawn } from 'node:child_process' +import { requestJson } from '../http' +import { resolve } from '../resolve' + +interface WrapOptions { + services?: string[] + methods?: string[] + paths?: string[] + ttl?: string +} + +const LOCAL_NO_PROXY_HOSTS = ['127.0.0.1', 'localhost', '::1'] + +function buildConstraint(opts: WrapOptions) { + const constraint: Record = {} + + if (opts.services && opts.services.length > 0) { + constraint.services = opts.services.length === 1 ? opts.services[0] : opts.services + } + + if (opts.methods && opts.methods.length > 0) { + const upper = opts.methods.map(method => method.toUpperCase()) + constraint.methods = upper.length === 1 ? upper[0] : upper + } + + if (opts.paths && opts.paths.length > 0) { + constraint.paths = opts.paths.length === 1 ? opts.paths[0] : opts.paths + } + + if (opts.ttl) { + constraint.ttl = opts.ttl + } + + return constraint +} + +function hasRestrictions(opts: WrapOptions) { + return Object.keys(buildConstraint(opts)).length > 0 +} + +function isLoopbackHost(hostname: string) { + const normalized = hostname.toLowerCase() + return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '::1' +} + +function buildProxyUrl(proxyUrl: string, token: string, opts: WrapOptions) { + const proxy = new URL(proxyUrl) + + if (!hasRestrictions(opts) && isLoopbackHost(proxy.hostname)) { + return proxy.toString() + } + + proxy.username = '_' + proxy.password = token + return proxy.toString() +} + +async function mintRestrictedToken(opts: WrapOptions) { + const constraint = buildConstraint(opts) + + if (!hasRestrictions(opts)) { + return null + } + + const res = await requestJson<{ token: string }>('/tokens/restrict', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ constraints: [constraint] }), + }) + + return res.token +} + +function buildEnv(proxyUrl: string) { + const env = { ...process.env } + const existingNoProxy = [env.NO_PROXY, env.no_proxy] + .flatMap(value => value?.split(',') ?? []) + .map(value => value.trim()) + .filter(Boolean) + const noProxy = Array.from(new Set([...LOCAL_NO_PROXY_HOSTS, ...existingNoProxy])).join(',') + + env.HTTP_PROXY = proxyUrl + env.HTTPS_PROXY = proxyUrl + env.http_proxy = proxyUrl + env.https_proxy = proxyUrl + env.NO_PROXY = noProxy + env.no_proxy = noProxy + return env +} + +function isSignal(value: NodeJS.Signals | number | null): value is NodeJS.Signals { + return typeof value === 'string' +} + +async function spawnChild(command: string, args: string[], env: NodeJS.ProcessEnv) { + const child = spawn(command, args, { + stdio: 'inherit', + env, + }) + + const forwardSignal = (signal: NodeJS.Signals) => { + if (!child.killed) { + child.kill(signal) + } + } + + process.on('SIGINT', forwardSignal) + process.on('SIGTERM', forwardSignal) + + try { + const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + child.on('error', reject) + child.on('exit', (code, signal) => resolve({ code, signal })) + }) + + if (isSignal(result.signal)) { + process.kill(process.pid, result.signal) + return 1 + } + + return result.code ?? 0 + } finally { + process.off('SIGINT', forwardSignal) + process.off('SIGTERM', forwardSignal) + } +} + +export async function wrap(args: string[], opts: WrapOptions) { + if (args.length === 0) { + console.error('Usage: agent.pw wrap -- [args...]') + process.exit(1) + } + + const { url, token } = await resolve() + const proxyToken = await mintRestrictedToken(opts) + const proxyUrl = buildProxyUrl(url, proxyToken ?? token, opts) + const env = buildEnv(proxyUrl) + const [command, ...commandArgs] = args + const exitCode = await spawnChild(command, commandArgs, env) + process.exit(exitCode) +} diff --git a/packages/cli/src/http.ts b/packages/cli/src/http.ts index 4134b60..821a5df 100644 --- a/packages/cli/src/http.ts +++ b/packages/cli/src/http.ts @@ -36,7 +36,7 @@ export async function requestJson(path: string, init: RequestInit = {}): Prom const body = await res.text() throw new HttpStatusError(body || `${res.status} ${res.statusText}`, res.status) } - return res.json() + return JSON.parse(await res.text()) } export async function pageToPaginatedResponse(pagePromise: Promise<{ diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index fb87557..1e027a4 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -268,6 +268,28 @@ export function buildProgram() { return curl(cmd.args) }) + program + .command('wrap') + .description('Run a command with credentials injected via HTTP proxy') + .usage('[options] -- [args...]') + .option('--service ', 'Restrict proxy access to specific hosts') + .option('--host ', 'Restrict proxy access to specific hosts') + .option('--method ', 'Restrict to specific HTTP methods') + .option('--path ', 'Restrict to path prefixes') + .option('--ttl ', 'Token lifetime (e.g. 1h, 30m)') + .allowUnknownOption() + .allowExcessArguments() + .argument('[command...]') + .action(async (command, opts) => { + const { wrap } = await import('./commands/wrap') + return wrap(command, { + services: [...(opts.service ?? []), ...(opts.host ?? [])], + methods: opts.method, + paths: opts.path, + ttl: opts.ttl, + }) + }) + return program } diff --git a/test/cli-wrap.test.ts b/test/cli-wrap.test.ts new file mode 100644 index 0000000..461c7ed --- /dev/null +++ b/test/cli-wrap.test.ts @@ -0,0 +1,130 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + requestJson, + resolve, + spawn, +} = vi.hoisted(() => ({ + requestJson: vi.fn(), + resolve: vi.fn(), + spawn: vi.fn(), +})) + +vi.mock('node:child_process', () => ({ + spawn, +})) + +vi.mock('../packages/cli/src/http', () => ({ + requestJson, +})) + +vi.mock('../packages/cli/src/resolve', () => ({ + resolve, +})) + +class ExitError extends Error { + code: number | undefined + + constructor(code: number | undefined) { + super(`process.exit(${code})`) + this.code = code + } +} + +type MockChildProcess = EventEmitter & { + killed: boolean + kill(signal?: NodeJS.Signals): boolean +} + +function createMockChildProcess( + exitCode: number | null = 0, + signal: NodeJS.Signals | null = null, +) { + const child = new EventEmitter() as MockChildProcess + child.killed = false + child.kill = vi.fn((nextSignal?: NodeJS.Signals) => { + if (nextSignal) { + child.killed = true + } + return true + }) + + queueMicrotask(() => { + child.emit('exit', exitCode, signal) + }) + + return child +} + +describe('CLI wrap command', () => { + beforeEach(() => { + vi.clearAllMocks() + resolve.mockResolvedValue({ + url: 'http://127.0.0.1:9315', + token: 'apw_root', + }) + requestJson.mockResolvedValue({ token: 'apw_child' }) + spawn.mockImplementation(() => createMockChildProcess()) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('uses the loopback proxy directly when no restrictions are requested', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: number) => { + throw new ExitError(code) + }) + const { wrap } = await import('../packages/cli/src/commands/wrap') + + await expect(wrap(['echo', 'hello'], {})).rejects.toMatchObject({ code: 0 }) + + expect(exitSpy).toHaveBeenCalledWith(0) + expect(requestJson).not.toHaveBeenCalled() + expect(spawn).toHaveBeenCalledWith('echo', ['hello'], { + stdio: 'inherit', + env: expect.objectContaining({ + HTTP_PROXY: 'http://127.0.0.1:9315/', + HTTPS_PROXY: 'http://127.0.0.1:9315/', + NO_PROXY: expect.stringContaining('127.0.0.1'), + no_proxy: expect.stringContaining('localhost'), + }), + }) + }) + + it('mints a restricted token and embeds it in the proxy URL when constraints are provided', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: number) => { + throw new ExitError(code) + }) + const { wrap } = await import('../packages/cli/src/commands/wrap') + + await expect(wrap(['env'], { + services: ['api.github.com'], + methods: ['get', 'post'], + paths: ['/user', '/repos'], + ttl: '5m', + })).rejects.toMatchObject({ code: 0 }) + + expect(exitSpy).toHaveBeenCalledWith(0) + expect(requestJson).toHaveBeenCalledWith('/tokens/restrict', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + constraints: [{ + services: 'api.github.com', + methods: ['GET', 'POST'], + paths: ['/user', '/repos'], + ttl: '5m', + }], + }), + }) + expect(spawn).toHaveBeenCalledWith('env', [], { + stdio: 'inherit', + env: expect.objectContaining({ + HTTP_PROXY: 'http://_:apw_child@127.0.0.1:9315/', + HTTPS_PROXY: 'http://_:apw_child@127.0.0.1:9315/', + }), + }) + }) +})