400 lines
15 KiB
JavaScript
Executable File
400 lines
15 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* Offline regression check for the server egress boundary artifacts.
|
|
*
|
|
* This check never applies host firewall/systemd policy and never contacts the
|
|
* public network. It validates the shipped templates, exercises an in-process
|
|
* exact-host+port proxy policy, and demonstrates the transports that still
|
|
* require the OS boundary because HTTP proxy environment variables do not
|
|
* contain them.
|
|
*/
|
|
import assert from 'node:assert/strict';
|
|
import { readFile } from 'node:fs/promises';
|
|
import { fileURLToPath } from 'node:url';
|
|
import http from 'node:http';
|
|
import net from 'node:net';
|
|
import dgram from 'node:dgram';
|
|
import { Resolver } from 'node:dns/promises';
|
|
import { execFile } from 'node:child_process';
|
|
|
|
const SYSTEMD_FILE = new URL('../../deploy/egress/systemd/maestro-egress.service.example', import.meta.url);
|
|
const SQUID_FILE = new URL('../../deploy/egress/squid.conf', import.meta.url);
|
|
const ALLOWED_HOST = 'allowed.egress.invalid';
|
|
const DENIED_HOST = 'denied.egress.invalid';
|
|
const ALLOWED_PORT = 443;
|
|
const SECRET = 'EGRESS_SECRET_CANARY_7d7fc02c';
|
|
|
|
function listen(server, host) {
|
|
return new Promise((resolve, reject) => {
|
|
server.once('error', reject);
|
|
const onReady = () => {
|
|
server.off('error', reject);
|
|
resolve(server.address());
|
|
};
|
|
if (typeof server.listen === 'function') server.listen(0, host, onReady);
|
|
else server.bind(0, host, onReady);
|
|
});
|
|
}
|
|
|
|
function close(server) {
|
|
server.closeAllConnections?.();
|
|
server.close();
|
|
}
|
|
|
|
function runNode(code, env = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
execFile(
|
|
process.execPath,
|
|
['--use-env-proxy', '-e', code],
|
|
{
|
|
env: { PATH: process.env.PATH || '/usr/bin:/bin', HOME: '/tmp', LANG: 'C.UTF-8', ...env },
|
|
encoding: 'utf8',
|
|
timeout: 8_000,
|
|
maxBuffer: 64 * 1024,
|
|
},
|
|
(error, stdout, stderr) => {
|
|
if (error) {
|
|
reject(new Error(`local child probe failed: ${stderr.trim() || error.message}`));
|
|
return;
|
|
}
|
|
resolve(stdout.trim());
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
function rawExchange(host, port, request) {
|
|
return new Promise((resolve, reject) => {
|
|
const socket = net.createConnection({ host, port });
|
|
let response = '';
|
|
const timer = setTimeout(() => socket.destroy(new Error('raw probe timed out')), 3_000);
|
|
socket.setEncoding('utf8');
|
|
socket.on('connect', () => socket.end(request));
|
|
socket.on('data', (chunk) => { response += chunk; });
|
|
socket.on('error', reject);
|
|
socket.on('close', () => {
|
|
clearTimeout(timer);
|
|
resolve(response);
|
|
});
|
|
});
|
|
}
|
|
|
|
function parseTarget(value, fallbackScheme = 'http:') {
|
|
try {
|
|
const url = value.includes('://') ? new URL(value) : new URL(`${fallbackScheme}//${value}`);
|
|
return {
|
|
hostname: url.hostname,
|
|
port: Number(url.port || (url.protocol === 'https:' ? 443 : 80)),
|
|
};
|
|
} catch {
|
|
return { hostname: '', port: 0 };
|
|
}
|
|
}
|
|
|
|
function allowedTarget(target) {
|
|
return target.hostname === ALLOWED_HOST && target.port === ALLOWED_PORT;
|
|
}
|
|
|
|
async function validateTemplates() {
|
|
const [unit, squid] = await Promise.all([
|
|
readFile(SYSTEMD_FILE, 'utf8'),
|
|
readFile(SQUID_FILE, 'utf8'),
|
|
]);
|
|
|
|
assert.match(unit, /^IPAddressDeny=any$/m);
|
|
assert.match(unit, /^IPAddressAllow=127\.0\.0\.2\/32$/m);
|
|
assert.match(unit, /^IPAddressAllow=127\.0\.0\.3\/32$/m);
|
|
assert.doesNotMatch(unit, /^IPAddressAllow=(?:localhost|127\.0\.0\.0\/8|::1(?:\/128)?)$/m);
|
|
assert.match(unit, /ExecStart=\/usr\/bin\/env .*HTTP_PROXY=http:\/\/127\.0\.0\.2:3128 .*HTTPS_PROXY=http:\/\/127\.0\.0\.2:3128 .*NO_PROXY= .*\/usr\/bin\/node --use-env-proxy/m);
|
|
assert.doesNotMatch(unit, /NODE_OPTIONS=/);
|
|
|
|
assert.match(squid, /^http_port 127\.0\.0\.2:3128$/m);
|
|
assert.match(squid, /^acl maestro_llm_host dstdomain -n allowed\.egress\.invalid$/m);
|
|
assert.match(squid, /^acl maestro_llm_port port 443$/m);
|
|
assert.match(squid, /^http_access allow maestro_llm_host maestro_llm_port$/m);
|
|
assert.match(squid, /^access_log none$/m);
|
|
assert.match(squid, /^cache_store_log none$/m);
|
|
const accessRules = squid.split(/\r?\n/).filter((line) => /^http_access\s+/.test(line.trim()));
|
|
assert.equal(accessRules.at(-1)?.trim(), 'http_access deny all');
|
|
return { systemd: true, squid: true };
|
|
}
|
|
|
|
async function runProxyChecks() {
|
|
const events = [];
|
|
const proxy = http.createServer((req, res) => {
|
|
const selectedTarget = Array.isArray(req.headers['x-egress-target'])
|
|
? req.headers['x-egress-target'][0]
|
|
: req.headers['x-egress-target'];
|
|
const target = parseTarget(selectedTarget || req.url || '');
|
|
const allowed = allowedTarget(target);
|
|
events.push({ protocol: 'http', decision: allowed ? 'allow' : 'deny', port: target.port });
|
|
res.writeHead(allowed ? 200 : 403, { 'content-type': 'text/plain' });
|
|
res.end(allowed ? 'allowed' : 'denied');
|
|
});
|
|
proxy.on('connect', (req, socket) => {
|
|
const target = parseTarget(req.url || '', 'http:');
|
|
const allowed = allowedTarget(target);
|
|
events.push({ protocol: 'connect', decision: allowed ? 'allow' : 'deny', port: target.port });
|
|
socket.end(`HTTP/1.1 ${allowed ? '200 Connection Established' : '403 Forbidden'}\r\nConnection: close\r\n\r\n`);
|
|
});
|
|
proxy.on('upgrade', (req, socket) => {
|
|
const target = parseTarget(req.url || '');
|
|
const allowed = allowedTarget(target);
|
|
events.push({ protocol: 'websocket', decision: allowed ? 'allow' : 'deny', port: target.port });
|
|
socket.end(`HTTP/1.1 ${allowed ? '101 Switching Protocols' : '403 Forbidden'}\r\nConnection: close\r\n\r\n`);
|
|
});
|
|
|
|
const address = await listen(proxy, '127.0.0.1');
|
|
const proxyUrl = `http://127.0.0.1:${address.port}`;
|
|
try {
|
|
const fetchAllowed = await fetch(`${proxyUrl}/private?token=${SECRET}`, {
|
|
headers: { Authorization: `Bearer ${SECRET}`, 'x-egress-target': `${ALLOWED_HOST}:${ALLOWED_PORT}` },
|
|
signal: AbortSignal.timeout(3_000),
|
|
});
|
|
const fetchDenied = await fetch(`${proxyUrl}/private?token=${SECRET}`, {
|
|
headers: { Authorization: `Bearer ${SECRET}`, 'x-egress-target': `${DENIED_HOST}:${ALLOWED_PORT}` },
|
|
signal: AbortSignal.timeout(3_000),
|
|
});
|
|
assert.equal(fetchAllowed.status, 200);
|
|
assert.equal(fetchDenied.status, 403);
|
|
await Promise.all([fetchAllowed.text(), fetchDenied.text()]);
|
|
|
|
const connectAllowed = await rawExchange('127.0.0.1', address.port,
|
|
`CONNECT ${ALLOWED_HOST}:${ALLOWED_PORT} HTTP/1.1\r\nHost: ${ALLOWED_HOST}:${ALLOWED_PORT}\r\nAuthorization: Bearer ${SECRET}\r\n\r\n`);
|
|
const connectDenied = await rawExchange('127.0.0.1', address.port,
|
|
`CONNECT ${DENIED_HOST}:${ALLOWED_PORT} HTTP/1.1\r\nHost: ${DENIED_HOST}:${ALLOWED_PORT}\r\n\r\n`);
|
|
assert.match(connectAllowed, /^HTTP\/1\.1 200/);
|
|
assert.match(connectDenied, /^HTTP\/1\.1 403/);
|
|
|
|
const wsAllowed = await rawExchange('127.0.0.1', address.port,
|
|
`GET ws://${ALLOWED_HOST}:${ALLOWED_PORT}/socket?token=${SECRET} HTTP/1.1\r\nHost: ${ALLOWED_HOST}:${ALLOWED_PORT}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n`);
|
|
const wsDenied = await rawExchange('127.0.0.1', address.port,
|
|
`GET ws://${DENIED_HOST}:${ALLOWED_PORT}/socket HTTP/1.1\r\nHost: ${DENIED_HOST}:${ALLOWED_PORT}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n`);
|
|
assert.match(wsAllowed, /^HTTP\/1\.1 101/);
|
|
assert.match(wsDenied, /^HTTP\/1\.1 403/);
|
|
|
|
const serializedEvents = JSON.stringify(events);
|
|
assert.ok(!serializedEvents.includes(SECRET), 'metadata events must not retain request secrets');
|
|
assert.equal(events.filter((event) => event.decision === 'allow').length, 3);
|
|
assert.equal(events.filter((event) => event.decision === 'deny').length, 3);
|
|
return { allowed: 3, denied: 3, secretRetained: false };
|
|
} finally {
|
|
close(proxy);
|
|
}
|
|
}
|
|
|
|
async function reserveTcpPort() {
|
|
const server = net.createServer();
|
|
const address = await listen(server, '127.0.0.1');
|
|
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
return address.port;
|
|
}
|
|
|
|
async function runActualEnvProxyChild(port) {
|
|
const events = [];
|
|
const proxy = http.createServer((req, res) => {
|
|
const target = parseTarget(req.url || '');
|
|
const allowed = allowedTarget(target);
|
|
events.push({
|
|
transport: 'absolute-form',
|
|
decision: allowed ? 'allow' : 'deny',
|
|
target: `${target.hostname}:${target.port}`,
|
|
absoluteUrl: /^http:\/\//.test(req.url || ''),
|
|
});
|
|
res.writeHead(allowed ? 200 : 403, { 'content-type': 'text/plain', connection: 'close' });
|
|
res.end(allowed ? 'allowed' : 'denied');
|
|
});
|
|
proxy.on('connect', (req, socket) => {
|
|
const target = parseTarget(req.url || '', 'http:');
|
|
const allowed = allowedTarget(target);
|
|
events.push({
|
|
transport: 'connect',
|
|
decision: allowed ? 'allow' : 'deny',
|
|
target: `${target.hostname}:${target.port}`,
|
|
absoluteUrl: false,
|
|
});
|
|
if (!allowed) {
|
|
socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
return;
|
|
}
|
|
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
socket.once('data', () => {
|
|
socket.end('HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 7\r\nConnection: close\r\n\r\nallowed');
|
|
});
|
|
});
|
|
await new Promise((resolve, reject) => {
|
|
proxy.once('error', reject);
|
|
proxy.listen(port, '127.0.0.1', () => {
|
|
proxy.off('error', reject);
|
|
resolve();
|
|
});
|
|
});
|
|
try {
|
|
const init = {
|
|
headers: { Authorization: `Bearer ${SECRET}` },
|
|
signal: AbortSignal.timeout(3_000),
|
|
};
|
|
const allowedResponse = await fetch(`http://${ALLOWED_HOST}:${ALLOWED_PORT}/private?token=${SECRET}`, init);
|
|
const allowedStatus = allowedResponse.status;
|
|
await allowedResponse.text();
|
|
let deniedStatus = 'error';
|
|
try {
|
|
const deniedResponse = await fetch(`http://${DENIED_HOST}:${ALLOWED_PORT}/private?token=${SECRET}`, init);
|
|
deniedStatus = deniedResponse.status;
|
|
await deniedResponse.text();
|
|
} catch {}
|
|
return { allowedStatus, deniedStatus, events };
|
|
} finally {
|
|
close(proxy);
|
|
}
|
|
}
|
|
|
|
async function runActualEnvProxyCheck() {
|
|
const port = await reserveTcpPort();
|
|
const proxyUrl = `http://127.0.0.1:${port}`;
|
|
const childEnv = {
|
|
PATH: process.env.PATH || '/usr/bin:/bin',
|
|
HOME: '/tmp',
|
|
LANG: 'C.UTF-8',
|
|
MAESTRO_EGRESS_PROXY_CHILD: '1',
|
|
MAESTRO_EGRESS_PROXY_PORT: String(port),
|
|
HTTP_PROXY: proxyUrl,
|
|
HTTPS_PROXY: proxyUrl,
|
|
NO_PROXY: '',
|
|
};
|
|
const output = await new Promise((resolve, reject) => {
|
|
execFile(
|
|
process.execPath,
|
|
['--use-env-proxy', fileURLToPath(import.meta.url)],
|
|
{ env: childEnv, encoding: 'utf8', timeout: 8_000, maxBuffer: 64 * 1024 },
|
|
(error, stdout, stderr) => {
|
|
if (error) {
|
|
reject(new Error(`env proxy child failed: ${stderr.trim() || error.message}`));
|
|
return;
|
|
}
|
|
resolve(stdout.trim());
|
|
},
|
|
);
|
|
});
|
|
assert.ok(!output.includes(SECRET));
|
|
const result = JSON.parse(output);
|
|
assert.equal(result.allowedStatus, 200);
|
|
assert.ok(result.deniedStatus === 403 || result.deniedStatus === 'error');
|
|
const allowed = result.events.find((event) => event.decision === 'allow');
|
|
const denied = result.events.find((event) => event.decision === 'deny');
|
|
assert.equal(allowed?.target, `${ALLOWED_HOST}:${ALLOWED_PORT}`);
|
|
assert.equal(denied?.target, `${DENIED_HOST}:${ALLOWED_PORT}`);
|
|
if (allowed.transport === 'absolute-form') assert.equal(allowed.absoluteUrl, true);
|
|
return {
|
|
nodeFlag: '--use-env-proxy',
|
|
allowedTransport: allowed.transport,
|
|
allowedTarget: allowed.target,
|
|
deniedTarget: denied.target,
|
|
cleanChildEnvironment: true,
|
|
secretRetained: false,
|
|
};
|
|
}
|
|
|
|
async function runDirectBypassChecks() {
|
|
const hits = { http: 0, raw: 0, childRaw: 0, ipv6: 0, dns: 0 };
|
|
const httpServer = http.createServer((req, res) => {
|
|
hits.http += 1;
|
|
res.end('direct-http');
|
|
});
|
|
const rawServer = net.createServer((socket) => {
|
|
socket.once('data', (data) => {
|
|
const kind = data.toString() === 'child' ? 'childRaw' : 'raw';
|
|
hits[kind] += 1;
|
|
socket.end('ok');
|
|
});
|
|
});
|
|
const ipv6Server = net.createServer((socket) => {
|
|
hits.ipv6 += 1;
|
|
socket.end('ok');
|
|
});
|
|
const dnsServer = dgram.createSocket('udp4');
|
|
dnsServer.on('message', (message, remote) => {
|
|
hits.dns += 1;
|
|
const response = Buffer.from(message);
|
|
response[2] = 0x81;
|
|
response[3] = 0x83;
|
|
response.writeUInt16BE(0, 6);
|
|
response.writeUInt16BE(0, 8);
|
|
response.writeUInt16BE(0, 10);
|
|
dnsServer.send(response, remote.port, remote.address);
|
|
});
|
|
|
|
const servers = [httpServer, rawServer, ipv6Server, dnsServer];
|
|
try {
|
|
const httpAddress = await listen(httpServer, '127.0.0.1');
|
|
const rawAddress = await listen(rawServer, '127.0.0.1');
|
|
const ipv6Address = await listen(ipv6Server, '::1');
|
|
const dnsAddress = await listen(dnsServer, '127.0.0.1');
|
|
|
|
const directHttp = await new Promise((resolve, reject) => {
|
|
const request = http.get(`http://127.0.0.1:${httpAddress.port}/direct`, (response) => {
|
|
response.resume();
|
|
response.on('end', () => resolve(response.statusCode));
|
|
});
|
|
request.on('error', reject);
|
|
});
|
|
assert.equal(directHttp, 200);
|
|
assert.equal(await rawExchange('127.0.0.1', rawAddress.port, 'parent'), 'ok');
|
|
assert.equal(await rawExchange('::1', ipv6Address.port, 'ipv6'), 'ok');
|
|
|
|
const childResult = await runNode(`
|
|
const net = require('node:net');
|
|
const socket = net.createConnection({ host: '127.0.0.1', port: ${rawAddress.port} });
|
|
socket.on('connect', () => socket.end('child'));
|
|
socket.on('close', () => process.exit(0));
|
|
socket.on('error', (error) => { console.error(error); process.exit(1); });
|
|
`, {
|
|
HTTP_PROXY: 'http://127.0.0.1:9',
|
|
HTTPS_PROXY: 'http://127.0.0.1:9',
|
|
NO_PROXY: '',
|
|
});
|
|
assert.equal(childResult, '');
|
|
|
|
const resolver = new Resolver();
|
|
resolver.setServers([`127.0.0.1:${dnsAddress.port}`]);
|
|
await resolver.resolve4(`dns-${SECRET.toLowerCase()}.invalid`).catch(() => {});
|
|
|
|
assert.deepEqual(hits, { http: 1, raw: 1, childRaw: 1, ipv6: 1, dns: 1 });
|
|
return {
|
|
demonstrated: ['node:http', 'raw-tcp-ipv4', 'spawned-child-raw-tcp', 'raw-tcp-ipv6', 'direct-dns'],
|
|
meaning: 'these transports bypass HTTP_PROXY and must be stopped by the OS boundary',
|
|
};
|
|
} finally {
|
|
for (const server of servers) {
|
|
try { close(server); } catch {}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (process.env.MAESTRO_EGRESS_PROXY_CHILD === '1') {
|
|
const port = Number(process.env.MAESTRO_EGRESS_PROXY_PORT);
|
|
assert.ok(Number.isInteger(port) && port > 0);
|
|
const childResult = await runActualEnvProxyChild(port);
|
|
const childOutput = JSON.stringify(childResult);
|
|
assert.ok(!childOutput.includes(SECRET));
|
|
console.log(childOutput);
|
|
process.exit(0);
|
|
}
|
|
|
|
const templates = await validateTemplates();
|
|
const envProxyRuntime = await runActualEnvProxyCheck();
|
|
const proxyPolicy = await runProxyChecks();
|
|
const proxyScope = await runDirectBypassChecks();
|
|
const result = {
|
|
ok: true,
|
|
externalNetworkUsed: false,
|
|
hostIsolationApplied: false,
|
|
templates,
|
|
envProxyRuntime,
|
|
proxyPolicy,
|
|
proxyScope,
|
|
};
|
|
const output = JSON.stringify(result, null, 2);
|
|
assert.ok(!output.includes(SECRET));
|
|
console.log(output);
|