Files
MikrotikManager/backend/scripts/acceptance/chr-recursive-observation.mjs
T

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; }
}