This commit is contained in:
Executable
+399
@@ -0,0 +1,399 @@
|
||||
#!/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);
|
||||
Reference in New Issue
Block a user