import { request as httpRequest } from 'node:http'; import { request as httpsRequest } from 'node:https'; import { createHash } from 'node:crypto'; import { mkdir, realpath, lstat, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); export const SOURCES = Object.freeze({ '194.226.121.150': Object.freeze({ serverId: 1, port: 19081 }), '104.165.244.7': Object.freeze({ serverId: 2, port: 19082 }), }); export const PATHS = Object.freeze([ '/system/resource', '/routing/route', '/routing/nexthop', '/interface', '/routing/table', '/ip/vrf', '/routing/nexthop', '/routing/route', '/system/resource', ]); const LIMITS = Object.freeze({ response: 1048576, bytes: 8388608, requests: 256, rows: 1000, totalRows: 10000, duration: 120000 }); const sha = value => createHash('sha256').update(value).digest('hex'); const deny = code => { throw new Error(code); }; export function configuration(args) { const options = {}; for (let i = 0; i < args.length; i += 2) { if (!['--source', '--transport', '--output', '--owned-tunnel-attestation'].includes(args[i]) || !args[i + 1] || options[args[i]] !== undefined) deny('invalid-options'); options[args[i]] = args[i + 1]; } const source = options['--source'], transport = options['--transport']; if (!Object.hasOwn(SOURCES, source) || !['https', 'ssh-loopback'].includes(transport) || !options['--output']) deny('invalid-source-or-transport'); const attestation = options['--owned-tunnel-attestation']; if (transport === 'ssh-loopback' && attestation !== 'verified-known-host-161.104.46.222-owned-forward') deny('owned-tunnel-attestation-required'); if (transport === 'https' && attestation !== undefined) deny('irrelevant-tunnel-attestation'); return { source, serverId: SOURCES[source].serverId, transport, output: options['--output'], endpoint: transport === 'https' ? `https://${source}:443` : `http://127.0.0.1:${SOURCES[source].port}`, tunnelAttestation: attestation ?? null }; } export async function privateDirectory(output, root = ROOT) { // Reject symlinks/junctions at every existing component, including tmp itself. const base = path.resolve(root, 'tmp'), target = path.resolve(root, output); const relative = path.relative(base, target); if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) deny('output-must-be-inside-tmp'); await mkdir(base, { recursive: true, mode: 0o700 }); if ((await lstat(base)).isSymbolicLink() || await realpath(base) !== base) deny('output-symlink-refused'); let current = base; for (const part of relative.split(path.sep)) { current = path.join(current, part); try { await mkdir(current, { mode: 0o700 }); } catch (error) { if (error.code !== 'EEXIST') throw error; } const stat = await lstat(current); if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(current) !== current) deny('output-symlink-refused'); } // Exclusive claim prevents mixing runs and refuses existing captures. await writeFile(path.join(target, 'capture.claim'), 'chr-recursive-observation-v1\n', { flag: 'wx', mode: 0o600 }); return target; } export function decodeBody(body) { let text, payload; try { text = new TextDecoder('utf-8', { fatal: true }).decode(body); payload = JSON.parse(text); } catch { deny('invalid-utf8-or-json'); } const rows = Array.isArray(payload) ? payload : payload && typeof payload === 'object' ? [payload] : deny('invalid-row-shape'); if (rows.length > LIMITS.rows || rows.some(row => !row || typeof row !== 'object' || Array.isArray(row))) deny('row-limit-or-shape'); return { rows }; } export function readResponse(endpoint, resource, authorization, budget) { const allowed = Object.entries(SOURCES).flatMap(([host, item]) => [`https://${host}:443`, `http://127.0.0.1:${item.port}`]); if (!allowed.includes(endpoint)) deny('non-whitelisted-endpoint'); if (!PATHS.includes(resource) || budget.bytes >= LIMITS.bytes || budget.rows >= LIMITS.totalRows || ++budget.requests > LIMITS.requests || Date.now() >= budget.deadline) deny('request-budget'); const url = new URL('/rest' + resource, endpoint); return new Promise((resolve, reject) => { const fail = (code, status) => reject(Object.assign(new Error(code), Number.isInteger(status) ? { httpStatus: status } : {})); const req = (url.protocol === 'https:' ? httpsRequest : httpRequest)(url, { method: 'GET', agent: false, rejectUnauthorized: true, maxHeaderSize: 16384, headers: { Authorization: authorization, Accept: 'application/json', 'Accept-Encoding': 'identity' }, }, res => { const chunks = []; let size = 0; res.on('data', chunk => { size += chunk.length; budget.bytes += chunk.length; if (size > LIMITS.response || budget.bytes > LIMITS.bytes) { req.destroy(); fail('byte-limit'); return; } chunks.push(chunk); }); res.on('aborted', () => fail('response-aborted')); res.on('error', () => fail('response-failed')); res.on('end', () => { const body = Buffer.concat(chunks); // Raw failed HTTP responses are excluded: error bodies can reflect secrets. if (res.statusCode !== 200) return fail('http-status-refused', res.statusCode); const type = res.headers['content-type']; if (typeof type !== 'string' || !/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(type) || res.headers['content-encoding'] && res.headers['content-encoding'] !== 'identity') return fail('response-encoding-refused'); try { const { rows } = decodeBody(body); budget.rows += rows.length; if (budget.rows > LIMITS.totalRows || Date.now() >= budget.deadline) return fail('response-budget'); resolve({ body, rows, status: res.statusCode, contentType: type }); } catch { fail('invalid-response'); } }); }); const timer = setTimeout(() => { req.destroy(); fail('absolute-deadline'); }, Math.max(1, budget.deadline - Date.now())); req.on('error', () => fail('transport-failed')); req.on('close', () => clearTimeout(timer)); req.end(); }); } export async function acquire(config, credentials) { if (typeof credentials?.username !== 'string' || !credentials.username || credentials.username.length > 256 || /[:\r\n]/.test(credentials.username) || typeof credentials.password !== 'string' || !credentials.password || credentials.password.length > 4096 || /[\r\n]/.test(credentials.password)) deny('invalid-private-credentials'); // Validate caller configuration again; exported API cannot supply a custom host. const fixed = configuration(['--source', config.source, '--transport', config.transport, '--output', config.output, ...(config.tunnelAttestation ? ['--owned-tunnel-attestation', config.tunnelAttestation] : [])]); const directory = await privateDirectory(fixed.output); const budget = { requests: 0, bytes: 0, rows: 0, deadline: Date.now() + LIMITS.duration }; const authorization = 'Basic ' + Buffer.from(credentials.username + ':' + credentials.password).toString('base64'); const report = { format: 'chr-recursive-raw-acquisition-v1', source: fixed.source, serverId: fixed.serverId, transport: fixed.transport, tunnelAttestation: fixed.tunnelAttestation, startedAt: new Date().toISOString(), semanticProof: false, complete: false, records: [], limits: LIMITS }; let version, attemptedResource; try { for (const [index, resource] of PATHS.entries()) { attemptedResource = resource; const startedAt = new Date().toISOString(); const response = await readResponse(fixed.endpoint, resource, authorization, budget); // Never persist a body reflecting the private credential or Basic value. if ([credentials.password, authorization].some(secret => response.body.includes(Buffer.from(secret)))) deny('credential-reflection-refused'); if (resource === '/system/resource') { const current = response.rows.length === 1 && response.rows[0].version; if (typeof current !== 'string' || !/^7\.2[0-6](?:\.\d{1,3})?(?: \((?:stable|long-term)\))?$/.test(current) || version && version !== current) deny('unsupported-or-drifting-version'); version = current; } const file = `${String(index + 1).padStart(2, '0')}-${resource.slice(1).replaceAll('/', '-')}.json`; await writeFile(path.join(directory, file), response.body, { flag: 'wx', mode: 0o600 }); report.records.push({ file, resource, startedAt, finishedAt: new Date().toISOString(), version, status: response.status, contentType: response.contentType, bytes: response.body.length, rows: response.rows.length, rawSha256: sha(response.body) }); } report.complete = true; } catch (error) { report.failure = 'acquisition-incomplete'; report.failedResource = attemptedResource; if (Number.isInteger(error.httpStatus) && error.httpStatus >= 100 && error.httpStatus <= 599) report.failedHttpStatus = error.httpStatus; } report.finishedAt = new Date().toISOString(); report.usage = { requests: budget.requests, bytes: budget.bytes, rows: budget.rows }; await writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2) + '\n', { flag: 'wx', mode: 0o600 }); return report; } async function privateStdin() { // Piped stdin is private (no terminal echo). TTY raw mode hides all input. const input = process.stdin, chunks = []; let size = 0; if (input.isTTY) { process.stderr.write('Private credentials JSON (hidden), then Enter: '); input.setRawMode(true); } try { for await (const chunk of input) { size += chunk.length; if (size > 8192) deny('private-input-limit'); if (input.isTTY && chunk.includes(3)) deny('private-input-cancelled'); chunks.push(chunk); if (input.isTTY && (chunk.includes(13) || chunk.includes(10))) break; } return JSON.parse(Buffer.concat(chunks).toString('utf8').trim()); } finally { if (input.isTTY) { input.setRawMode(false); process.stderr.write('\n'); } } } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { try { const config = configuration(process.argv.slice(2)); const report = await acquire(config, await privateStdin()); // Stdout contains metadata only; no native bodies, credentials or errors. process.stdout.write(JSON.stringify({ complete: report.complete, semanticProof: false, source: report.source, records: report.records.length }) + '\n'); if (!report.complete) process.exitCode = 1; } catch { process.stderr.write('Read-only acquisition refused or incomplete.\n'); process.exitCode = 1; } }