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_WORKER_PATH = path.join( repoRoot, "services/nginx/app/resources/edge-gateway-agent/lan-worker.php" ); const DEFAULT_PHP_IMAGE = "php:8.2-cli-bookworm"; const DEFAULT_TIMEOUT_MS = 15000; const AGENT_TOKEN = "agent-token"; function parseArgs(argv = process.argv.slice(2)) { const options = { agentPath: DEFAULT_AGENT_PATH, workerPath: DEFAULT_WORKER_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 "--worker-path": options.workerPath = 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-to-shelly-proof.mjs [options] Runs the PHP edge agent and real LAN worker against fake broker, API, and Shelly RPC endpoints. Verifies that a broker-connected SET_RELAY_STATE command drains from the API, reaches the worker, triggers a Shelly-style Switch.Set call, reads Switch.GetStatus, and posts the command result. Options: --agent-path PHP agent artifact to execute. Default: ${DEFAULT_AGENT_PATH} --worker-path PHP LAN worker artifact to execute. Default: ${DEFAULT_WORKER_PATH} --php-image Docker PHP image with curl, sqlite3, and pdo_sqlite. Default: ${DEFAULT_PHP_IMAGE} --timeout-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 requestJson({ method = "GET", port, path: requestPath, body = null, headers = {} }) { return new Promise((resolve, reject) => { const payload = body === null ? null : JSON.stringify(body); const request = http.request({ hostname: "127.0.0.1", port, path: requestPath, method, headers: { accept: "application/json", ...(payload === null ? {} : { "content-type": "application/json", "content-length": Buffer.byteLength(payload), }), ...headers, }, timeout: 1000, }, (response) => { let raw = ""; response.setEncoding("utf8"); response.on("data", (chunk) => { raw += chunk; }); response.on("end", () => { let decoded = {}; try { decoded = raw.trim() === "" ? {} : JSON.parse(raw); } catch { decoded = { __invalid: raw }; } resolve({ status: response.statusCode || 0, body: decoded }); }); }); request.on("error", reject); request.on("timeout", () => { request.destroy(new Error("request timed out")); }); if (payload !== null) { request.write(payload); } request.end(); }); } 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()); }); } async function reservePort() { const server = net.createServer(); const port = await new Promise((resolve) => { server.listen(0, "127.0.0.1", () => resolve(server.address().port)); }); await closeServer(server); return port; } 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 createShellyServer(state) { return http.createServer((request, response) => { const url = new URL(request.url, "http://127.0.0.1"); state.requests.push({ service: "shelly", method: request.method, path: url.pathname, query: Object.fromEntries(url.searchParams.entries()), }); if (request.method === "GET" && url.pathname === "/rpc/Switch.Set") { state.shellySwitchSetSeen = true; if (url.searchParams.get("id") !== "0" || url.searchParams.get("on") !== "true") { state.failure = new Error(`unexpected Shelly Switch.Set query: ${url.search}`); } sendJson(response, 200, { was_on: false, output: true }); return; } if (request.method === "GET" && url.pathname === "/rpc/Switch.GetStatus") { state.shellyStatusSeen = true; if (url.searchParams.get("id") !== "0") { state.failure = new Error(`unexpected Shelly Switch.GetStatus query: ${url.search}`); } sendJson(response, 200, { id: 0, output: true, source: "fake-shelly-rpc" }); return; } if (url.pathname.startsWith("/relay/")) { state.failure = new Error(`legacy Shelly endpoint should not be used for generation 2 proof: ${url.pathname}`); } sendJson(response, 404, { message: "not found" }); }); } function createApiServer(state, brokerPort, shellyAddress) { 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: shellyAddress, local_ip: shellyAddress, channel: 0, on: true, relayId: "relay-proof", relay_id: "relay-proof", deviceGeneration: 2, device_generation: 2, }, }, }); 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?.output !== true || body.result?.raw?.source !== "fake-shelly-rpc" ) { 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 }); }); } 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 { containerConfigPath: `${containerProofDir}/config.json` }; } function spawnWorker({ workerPath, phpImage, workerPort }) { return spawn("docker", [ "run", "--rm", "--network", "host", "-e", `TRUCKWASH_WORKER_TOKEN=${AGENT_TOKEN}`, "-v", `${workerPath}:/lan-worker.php:ro`, phpImage, "php", "-S", `127.0.0.1:${workerPort}`, "/lan-worker.php", ], { stdio: ["ignore", "pipe", "pipe"] }); } 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 waitForWorker(workerPort, child, timeoutMs) { const deadline = Date.now() + timeoutMs; let lastError = null; while (Date.now() < deadline) { if (child.exitCode !== null || child.signalCode !== null) { throw new Error(`LAN worker exited before becoming healthy: ${child.exitCode ?? child.signalCode}`); } try { const response = await requestJson({ port: workerPort, path: "/health" }); if (response.status === 200 && response.body?.service === "lan-worker") { return; } } catch (error) { lastError = error; } await new Promise((resolve) => setTimeout(resolve, 100)); } throw lastError || new Error("LAN worker did not become healthy"); } 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, agentExited, workerExited) { return { brokerHandshakeSeen: state.brokerHandshakeSeen, commandPollSeen: state.commandPollSeen, shellySwitchSetSeen: state.shellySwitchSetSeen, shellyStatusSeen: state.shellyStatusSeen, resultSeen: state.resultSeen, agentStayedRunningUntilProofComplete: !agentExited, workerStayedRunningUntilProofComplete: !workerExited, }; } 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}`); } if (!fs.existsSync(options.workerPath)) { throw new Error(`LAN worker artifact not found: ${options.workerPath}`); } const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "edge-agent-to-shelly-proof-")); fs.mkdirSync(path.join(tempDir, "runtime"), { recursive: true }); const state = { brokerHandshakeSeen: false, commandPollSeen: false, shellySwitchSetSeen: false, shellyStatusSeen: false, resultSeen: false, commandDelivered: false, failure: null, requests: [], }; const broker = createBrokerServer(state); const shellyServer = createShellyServer(state); let apiServer = null; let agent = null; let worker = null; let agentStdout = ""; let agentStderr = ""; let workerStdout = ""; let workerStderr = ""; let agentExited = false; let workerExited = false; try { const brokerPort = await listen(broker.server); const shellyPort = await listen(shellyServer); const workerPort = await reservePort(); const shellyAddress = `127.0.0.1:${shellyPort}`; apiServer = createApiServer(state, brokerPort, shellyAddress); const apiPort = await listen(apiServer); const { containerConfigPath } = writeConfig(tempDir, apiPort, brokerPort, workerPort); worker = spawnWorker({ ...options, workerPort }); worker.stdout.on("data", (chunk) => { workerStdout += chunk.toString(); }); worker.stderr.on("data", (chunk) => { workerStderr += chunk.toString(); }); worker.once("exit", () => { workerExited = true; }); await waitForWorker(workerPort, worker, 5000); agent = spawnAgent({ ...options, tempDir, containerConfigPath }); agent.stdout.on("data", (chunk) => { agentStdout += chunk.toString(); }); agent.stderr.on("data", (chunk) => { agentStderr += chunk.toString(); }); agent.once("exit", () => { agentExited = true; }); const deadline = Date.now() + options.timeoutMs; while (Date.now() < deadline && !state.failure && !agentExited && !workerExited) { if ( state.brokerHandshakeSeen && state.commandPollSeen && state.shellySwitchSetSeen && state.shellyStatusSeen && state.resultSeen ) { break; } await new Promise((resolve) => setTimeout(resolve, 100)); } const evidence = evidenceFromState(state, agentExited, workerExited); if ( state.failure || !state.brokerHandshakeSeen || !state.commandPollSeen || !state.shellySwitchSetSeen || !state.shellyStatusSeen || !state.resultSeen ) { const error = state.failure || new Error("missing proof evidence"); error.evidence = evidence; error.requests = state.requests; error.agentStdout = agentStdout.slice(-3000); error.agentStderr = agentStderr.slice(-3000); error.workerStdout = workerStdout.slice(-3000); error.workerStderr = workerStderr.slice(-3000); throw error; } return { evidence, agentPath: options.agentPath, workerPath: options.workerPath, phpImage: options.phpImage, tempDir, requestCount: state.requests.length, }; } finally { if (agent) { await stopChild(agent); } if (worker) { await stopChild(worker); } for (const socket of broker.sockets) { socket.destroy(); } await Promise.allSettled([ closeServer(broker.server), closeServer(shellyServer), 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 triggered real LAN worker Shelly RPC signal and posted result\n"); process.stdout.write(`${JSON.stringify(result.evidence)}\n`); process.stdout.write(`Agent: ${result.agentPath}\n`); process.stdout.write(`LAN worker: ${result.workerPath}\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.agentStdout) { process.stderr.write(`agent stdout: ${error.agentStdout}\n`); } if (error.agentStderr) { process.stderr.write(`agent stderr: ${error.agentStderr}\n`); } if (error.workerStdout) { process.stderr.write(`worker stdout: ${error.workerStdout}\n`); } if (error.workerStderr) { process.stderr.write(`worker stderr: ${error.workerStderr}\n`); } process.exit(1); }); }