620 lines
18 KiB
JavaScript
620 lines
18 KiB
JavaScript
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 <path> PHP agent artifact to execute.
|
|
Default: ${DEFAULT_AGENT_PATH}
|
|
--worker-path <path> PHP LAN worker artifact to execute.
|
|
Default: ${DEFAULT_WORKER_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 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);
|
|
});
|
|
}
|