Fix edge gateway relay command draining
This commit is contained in:
@@ -0,0 +1,453 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const DEFAULT_AGENT_PATH = path.join(
|
||||
repoRoot,
|
||||
"services/nginx/app/resources/edge-gateway-agent/agent.php"
|
||||
);
|
||||
const DEFAULT_PHP_IMAGE = "php:8.2-cli-bookworm";
|
||||
const DEFAULT_TIMEOUT_MS = 12000;
|
||||
|
||||
function parseArgs(argv = process.argv.slice(2)) {
|
||||
const options = {
|
||||
agentPath: DEFAULT_AGENT_PATH,
|
||||
phpImage: DEFAULT_PHP_IMAGE,
|
||||
timeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
keepTemp: false,
|
||||
help: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
const next = argv[index + 1];
|
||||
|
||||
switch (arg) {
|
||||
case "--agent-path":
|
||||
options.agentPath = path.resolve(String(next || "").trim());
|
||||
index += 1;
|
||||
break;
|
||||
case "--php-image":
|
||||
options.phpImage = String(next || "").trim() || DEFAULT_PHP_IMAGE;
|
||||
index += 1;
|
||||
break;
|
||||
case "--timeout-ms":
|
||||
options.timeoutMs = Number.parseInt(String(next || ""), 10) || DEFAULT_TIMEOUT_MS;
|
||||
index += 1;
|
||||
break;
|
||||
case "--keep-temp":
|
||||
options.keepTemp = true;
|
||||
break;
|
||||
case "--help":
|
||||
case "-h":
|
||||
options.help = true;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
process.stdout.write(`Usage:
|
||||
node scripts/edge-agent-command-drain-proof.mjs [options]
|
||||
|
||||
Verifies that a broker-connected PHP compose edge agent still drains API-queued
|
||||
SET_RELAY_STATE jobs to the LAN worker /relay/switch endpoint.
|
||||
|
||||
Options:
|
||||
--agent-path <path> PHP agent artifact to execute.
|
||||
Default: ${DEFAULT_AGENT_PATH}
|
||||
--php-image <image> Docker PHP image with curl, sqlite3, and pdo_sqlite.
|
||||
Default: ${DEFAULT_PHP_IMAGE}
|
||||
--timeout-ms <ms> Proof timeout. Default: ${DEFAULT_TIMEOUT_MS}
|
||||
--keep-temp Keep the temporary config/runtime directory.
|
||||
--help Show this help text.
|
||||
`);
|
||||
}
|
||||
|
||||
function readJson(request) {
|
||||
return new Promise((resolve) => {
|
||||
let raw = "";
|
||||
request.setEncoding("utf8");
|
||||
request.on("data", (chunk) => {
|
||||
raw += chunk;
|
||||
});
|
||||
request.on("end", () => {
|
||||
if (raw.trim() === "") {
|
||||
resolve({});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
resolve(JSON.parse(raw));
|
||||
} catch {
|
||||
resolve({ __invalid: raw });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(response, status, payload) {
|
||||
const body = JSON.stringify(payload);
|
||||
response.writeHead(status, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-length": Buffer.byteLength(body),
|
||||
});
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function listen(server) {
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
|
||||
});
|
||||
}
|
||||
|
||||
function closeServer(server) {
|
||||
return new Promise((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
function websocketAcceptKey(key) {
|
||||
return crypto
|
||||
.createHash("sha1")
|
||||
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
|
||||
.digest("base64");
|
||||
}
|
||||
|
||||
function createBrokerServer(state) {
|
||||
const sockets = new Set();
|
||||
const server = net.createServer((socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
|
||||
let buffer = "";
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk.toString("binary");
|
||||
if (state.brokerHandshakeSeen || !buffer.includes("\r\n\r\n")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestText = Buffer.from(buffer, "binary").toString("utf8");
|
||||
const key = requestText.match(/Sec-WebSocket-Key:\s*(.+)\r\n/i)?.[1]?.trim();
|
||||
const requestLine = requestText.split("\r\n")[0] || "";
|
||||
if (!requestLine.includes("/ws/agent?")) {
|
||||
state.failure = new Error(`unexpected broker path: ${requestLine}`);
|
||||
}
|
||||
if (!key) {
|
||||
state.failure = new Error("broker handshake missing Sec-WebSocket-Key");
|
||||
return;
|
||||
}
|
||||
|
||||
socket.write([
|
||||
"HTTP/1.1 101 Switching Protocols",
|
||||
"Upgrade: websocket",
|
||||
"Connection: Upgrade",
|
||||
`Sec-WebSocket-Accept: ${websocketAcceptKey(key)}`,
|
||||
"",
|
||||
"",
|
||||
].join("\r\n"));
|
||||
state.brokerHandshakeSeen = true;
|
||||
buffer = "";
|
||||
});
|
||||
});
|
||||
|
||||
return { server, sockets };
|
||||
}
|
||||
|
||||
function createWorkerServer(state) {
|
||||
return http.createServer(async (request, response) => {
|
||||
const url = new URL(request.url, "http://127.0.0.1");
|
||||
const body = await readJson(request);
|
||||
state.requests.push({ service: "worker", method: request.method, path: url.pathname, body });
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/health") {
|
||||
sendJson(response, 200, { status: "healthy", timestamp: new Date().toISOString() });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/relay/switch") {
|
||||
state.relaySwitchSeen = true;
|
||||
if (body.local_ip !== "10.123.0.31" || body.channel !== 0 || body.on !== true) {
|
||||
state.failure = new Error(`unexpected relay switch payload: ${JSON.stringify(body)}`);
|
||||
}
|
||||
sendJson(response, 200, {
|
||||
online: true,
|
||||
on: true,
|
||||
output: true,
|
||||
raw: { source: "fake-worker" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 404, { message: "not found" });
|
||||
});
|
||||
}
|
||||
|
||||
function createApiServer(state, brokerPort, workerPort) {
|
||||
return http.createServer(async (request, response) => {
|
||||
const url = new URL(request.url, "http://127.0.0.1");
|
||||
const body = await readJson(request);
|
||||
state.requests.push({ service: "api", method: request.method, path: url.pathname, body });
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/heartbeat") {
|
||||
sendJson(response, 200, { data: { ok: true, broker_url: `ws://127.0.0.1:${brokerPort}` } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/selfserve/machine-signal-bindings") {
|
||||
sendJson(response, 200, { data: { monitors: [] } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/poll") {
|
||||
state.commandPollSeen = true;
|
||||
if (body.wait_seconds !== 0) {
|
||||
state.failure = new Error(
|
||||
`broker-connected command poll should be non-blocking, got wait_seconds=${body.wait_seconds}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!state.commandDelivered) {
|
||||
state.commandDelivered = true;
|
||||
sendJson(response, 200, {
|
||||
data: {
|
||||
id: 77,
|
||||
command_type: "SET_RELAY_STATE",
|
||||
payload: {
|
||||
localIp: "10.123.0.31",
|
||||
channel: 0,
|
||||
on: true,
|
||||
relayId: "relay-proof",
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 200, { data: null });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/77/result") {
|
||||
state.resultSeen = true;
|
||||
if (body.ok !== true || body.result?.on !== true || body.result?.raw?.source !== "fake-worker") {
|
||||
state.failure = new Error(`unexpected command result: ${JSON.stringify(body)}`);
|
||||
}
|
||||
sendJson(response, 200, { data: { acknowledged: true } });
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 404, { message: "not found", path: url.pathname, workerPort });
|
||||
});
|
||||
}
|
||||
|
||||
function writeConfig(tempDir, apiPort, brokerPort, workerPort) {
|
||||
const containerProofDir = "/proof";
|
||||
const runtimeDir = `${containerProofDir}/runtime`;
|
||||
const config = {
|
||||
apiUrl: `http://127.0.0.1:${apiPort}`,
|
||||
brokerUrl: `ws://127.0.0.1:${brokerPort}`,
|
||||
gatewayId: 42,
|
||||
agentToken: "agent-token",
|
||||
installDir: containerProofDir,
|
||||
runtimeDir,
|
||||
stateDatabasePath: `${runtimeDir}/gateway-state.sqlite`,
|
||||
workerBaseUrl: `http://127.0.0.1:${workerPort}`,
|
||||
heartbeatIntervalSeconds: 60,
|
||||
operationPollTimeoutSeconds: 20,
|
||||
};
|
||||
|
||||
const configPath = path.join(tempDir, "config.json");
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
return { configPath, containerConfigPath: `${containerProofDir}/config.json` };
|
||||
}
|
||||
|
||||
function spawnAgent({ agentPath, phpImage, tempDir, containerConfigPath }) {
|
||||
return spawn("docker", [
|
||||
"run",
|
||||
"--rm",
|
||||
"--network",
|
||||
"host",
|
||||
"-v",
|
||||
`${agentPath}:/agent.php:ro`,
|
||||
"-v",
|
||||
`${tempDir}:/proof`,
|
||||
phpImage,
|
||||
"php",
|
||||
"/agent.php",
|
||||
"--config",
|
||||
containerConfigPath,
|
||||
], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
}
|
||||
|
||||
async function stopChild(child) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
child.kill("SIGTERM");
|
||||
const hardKill = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, 1500);
|
||||
|
||||
await Promise.race([
|
||||
new Promise((resolve) => child.once("exit", resolve)),
|
||||
new Promise((resolve) => setTimeout(resolve, 2200)),
|
||||
]);
|
||||
clearTimeout(hardKill);
|
||||
}
|
||||
|
||||
function evidenceFromState(state, childExited) {
|
||||
return {
|
||||
brokerHandshakeSeen: state.brokerHandshakeSeen,
|
||||
commandPollSeen: state.commandPollSeen,
|
||||
relaySwitchSeen: state.relaySwitchSeen,
|
||||
resultSeen: state.resultSeen,
|
||||
agentStayedRunningUntilProofComplete: !childExited,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runProof(options) {
|
||||
if (process.platform !== "linux") {
|
||||
throw new Error("This proof uses Docker --network host and currently expects Linux.");
|
||||
}
|
||||
if (!fs.existsSync(options.agentPath)) {
|
||||
throw new Error(`Agent artifact not found: ${options.agentPath}`);
|
||||
}
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "edge-agent-command-drain-proof-"));
|
||||
fs.mkdirSync(path.join(tempDir, "runtime"), { recursive: true });
|
||||
|
||||
const state = {
|
||||
brokerHandshakeSeen: false,
|
||||
commandPollSeen: false,
|
||||
relaySwitchSeen: false,
|
||||
resultSeen: false,
|
||||
commandDelivered: false,
|
||||
failure: null,
|
||||
requests: [],
|
||||
};
|
||||
|
||||
const broker = createBrokerServer(state);
|
||||
const workerServer = createWorkerServer(state);
|
||||
let apiServer = null;
|
||||
let child = null;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let childExited = false;
|
||||
|
||||
try {
|
||||
const brokerPort = await listen(broker.server);
|
||||
const workerPort = await listen(workerServer);
|
||||
apiServer = createApiServer(state, brokerPort, workerPort);
|
||||
const apiPort = await listen(apiServer);
|
||||
const { containerConfigPath } = writeConfig(tempDir, apiPort, brokerPort, workerPort);
|
||||
|
||||
child = spawnAgent({ ...options, tempDir, containerConfigPath });
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.once("exit", () => {
|
||||
childExited = true;
|
||||
});
|
||||
|
||||
const deadline = Date.now() + options.timeoutMs;
|
||||
while (Date.now() < deadline && !state.failure && !childExited) {
|
||||
if (state.brokerHandshakeSeen && state.commandPollSeen && state.relaySwitchSeen && state.resultSeen) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
const evidence = evidenceFromState(state, childExited);
|
||||
if (
|
||||
state.failure ||
|
||||
!state.brokerHandshakeSeen ||
|
||||
!state.commandPollSeen ||
|
||||
!state.relaySwitchSeen ||
|
||||
!state.resultSeen
|
||||
) {
|
||||
const error = state.failure || new Error("missing proof evidence");
|
||||
error.evidence = evidence;
|
||||
error.requests = state.requests;
|
||||
error.stdout = stdout.slice(-3000);
|
||||
error.stderr = stderr.slice(-3000);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
evidence,
|
||||
agentPath: options.agentPath,
|
||||
phpImage: options.phpImage,
|
||||
tempDir,
|
||||
requestCount: state.requests.length,
|
||||
};
|
||||
} finally {
|
||||
if (child) {
|
||||
await stopChild(child);
|
||||
}
|
||||
for (const socket of broker.sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
await Promise.allSettled([
|
||||
closeServer(broker.server),
|
||||
closeServer(workerServer),
|
||||
apiServer ? closeServer(apiServer) : Promise.resolve(),
|
||||
]);
|
||||
if (!options.keepTemp) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs();
|
||||
if (options.help) {
|
||||
printUsage();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await runProof(options);
|
||||
process.stdout.write("PASS broker-connected API command poll triggered local relay switch and posted result\n");
|
||||
process.stdout.write(`${JSON.stringify(result.evidence)}\n`);
|
||||
process.stdout.write(`Agent: ${result.agentPath}\n`);
|
||||
process.stdout.write(`PHP image: ${result.phpImage}\n`);
|
||||
if (options.keepTemp) {
|
||||
process.stdout.write(`Temp dir: ${result.tempDir}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`FAIL ${error.message}\n`);
|
||||
if (error.evidence) {
|
||||
process.stderr.write(`Evidence: ${JSON.stringify(error.evidence)}\n`);
|
||||
}
|
||||
if (error.requests) {
|
||||
process.stderr.write(`Requests: ${JSON.stringify(error.requests, null, 2)}\n`);
|
||||
}
|
||||
if (error.stdout) {
|
||||
process.stderr.write(`stdout: ${error.stdout}\n`);
|
||||
}
|
||||
if (error.stderr) {
|
||||
process.stderr.write(`stderr: ${error.stderr}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user