170 lines
11 KiB
JavaScript
170 lines
11 KiB
JavaScript
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; }
|
|
}
|