From 4fdeedab452852e690e20352e4acfcf8ffcd8fc9 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Wed, 1 Jul 2026 13:41:20 +0200 Subject: [PATCH] Handle invalid edge installer tokens --- scripts/staging-edge-gateway-smoke.mjs | 76 ++++++++++++++++++- scripts/staging-edge-gateway-smoke.test.mjs | 43 +++++++++++ .../edgegateway/routes/edgeGatewaysRoute.php | 54 ++++++++----- 3 files changed, 149 insertions(+), 24 deletions(-) diff --git a/scripts/staging-edge-gateway-smoke.mjs b/scripts/staging-edge-gateway-smoke.mjs index 42e54527..5ecc8759 100644 --- a/scripts/staging-edge-gateway-smoke.mjs +++ b/scripts/staging-edge-gateway-smoke.mjs @@ -1,14 +1,32 @@ import process from "node:process"; import path from "node:path"; +import { createHash } from "node:crypto"; import { pathToFileURL } from "node:url"; export const DEFAULT_STAGING_BASE_URL = "https://api.truckwash.io:4433"; +export const EXPECTED_INSTALL_VERSION = "compose-php-agent-v3"; +export const REQUIRED_MANIFEST_ARTIFACTS = [ + "agent.php", + "lan-worker.php", + "auto-updater.php", + "docker-compose.gateway.yml", + "Dockerfile.edge-agent", + "Dockerfile.lan-worker", + "Dockerfile.auto-updater", + "gateway-launcher.sh", + "truckwash-edge-gateway-stack.service", + "truckwash-edge-agent.service", +]; export const INSTALLER_SCRIPT_REQUIRED_SNIPPETS = [ "/edge-agent/install-token/status", + "/edge-agent/artifacts/manifest.json", "report_install_status", 'begin_install_phase "VERIFY_TOKEN"', + 'begin_install_phase "VERIFY_ARTIFACTS"', 'begin_install_phase "WAIT_FOR_CLAIM"', 'report_install_status "FAILED"', + "verify_manifest_artifact", + EXPECTED_INSTALL_VERSION, ]; export function normalizeBaseUrl(url) { @@ -55,13 +73,20 @@ export function buildChecks(baseUrl, installToken) { name: "Ping", url: `${normalizedBaseUrl}/ping`, }, + { + name: "Artifact manifest", + url: `${normalizedBaseUrl}/edge-agent/artifacts/manifest.json`, + artifactName: "manifest.json", + }, { name: "Agent PHP artifact", url: `${normalizedBaseUrl}/edge-agent/artifacts/agent.php`, + artifactName: "agent.php", }, { name: "Service unit artifact", url: `${normalizedBaseUrl}/edge-agent/artifacts/truckwash-edge-agent.service`, + artifactName: "truckwash-edge-agent.service", }, { name: "Installer script", @@ -70,6 +95,42 @@ export function buildChecks(baseUrl, installToken) { ]; } +export function validateArtifactManifestBody(body) { + const manifest = JSON.parse(String(body || "")); + if (manifest.version !== EXPECTED_INSTALL_VERSION) { + throw new Error(`Artifact manifest version mismatch: expected ${EXPECTED_INSTALL_VERSION}, got ${manifest.version}`); + } + if (!Array.isArray(manifest.artifacts)) { + throw new Error("Artifact manifest is missing artifacts."); + } + + const byName = new Map(manifest.artifacts.map((artifact) => [artifact?.name, artifact])); + const missingArtifacts = REQUIRED_MANIFEST_ARTIFACTS.filter((artifactName) => !byName.has(artifactName)); + if (missingArtifacts.length) { + throw new Error(`Artifact manifest is missing required artifacts: ${missingArtifacts.join(", ")}`); + } + + return manifest; +} + +export function validateArtifactBodyAgainstManifest(manifest, artifactName, body) { + const artifact = manifest?.artifacts?.find((entry) => entry?.name === artifactName); + if (!artifact) { + throw new Error(`Artifact ${artifactName} is missing from manifest.`); + } + + const buffer = Buffer.isBuffer(body) ? body : Buffer.from(String(body || "")); + const sha256 = createHash("sha256").update(buffer).digest("hex"); + if (sha256 !== artifact.sha256) { + throw new Error(`Artifact ${artifactName} hash mismatch: ${sha256} !== ${artifact.sha256}`); + } + if (buffer.length !== artifact.bytes) { + throw new Error(`Artifact ${artifactName} size mismatch: ${buffer.length} !== ${artifact.bytes}`); + } + + return artifact; +} + export function validateInstallerScriptBody(body) { const source = String(body || ""); const missingSnippets = INSTALLER_SCRIPT_REQUIRED_SNIPPETS.filter((snippet) => !source.includes(snippet)); @@ -107,16 +168,17 @@ export async function runSmoke({ baseUrl, installToken }) { const checks = buildChecks(baseUrl, installToken); const results = []; + let artifactManifest = null; for (const check of checks) { process.stdout.write(`[staging-smoke] GET ${check.url}\n`); const response = await fetch(check.url); - const body = await response.text(); + const body = Buffer.from(await response.arrayBuffer()); const result = { ...check, status: response.status, ok: response.ok, - bodyPreview: previewBody(body), + bodyPreview: previewBody(body.toString("utf8")), }; results.push(result); @@ -127,8 +189,16 @@ export async function runSmoke({ baseUrl, installToken }) { ); } + if (check.name === "Artifact manifest") { + artifactManifest = validateArtifactManifestBody(body.toString("utf8")); + result.version = artifactManifest.version; + result.artifactCount = artifactManifest.artifacts.length; + } + if (artifactManifest && check.artifactName && check.artifactName !== "manifest.json") { + result.verifiedArtifact = validateArtifactBodyAgainstManifest(artifactManifest, check.artifactName, body); + } if (check.name === "Installer script") { - result.verifiedSnippets = validateInstallerScriptBody(body); + result.verifiedSnippets = validateInstallerScriptBody(body.toString("utf8")); } } diff --git a/scripts/staging-edge-gateway-smoke.test.mjs b/scripts/staging-edge-gateway-smoke.test.mjs index 2d8299fa..33c8fa98 100644 --- a/scripts/staging-edge-gateway-smoke.test.mjs +++ b/scripts/staging-edge-gateway-smoke.test.mjs @@ -3,10 +3,13 @@ import assert from "node:assert/strict"; import { DEFAULT_STAGING_BASE_URL, + EXPECTED_INSTALL_VERSION, INSTALLER_SCRIPT_REQUIRED_SNIPPETS, buildChecks, normalizeBaseUrl, parseArgs, + validateArtifactBodyAgainstManifest, + validateArtifactManifestBody, validateInstallerScriptBody, } from "./staging-edge-gateway-smoke.mjs"; @@ -32,18 +35,58 @@ test("buildChecks targets the public staging endpoints", () => { assert.deepEqual(checks.map((check) => check.url), [ "https://api.truckwash.io:4433/ping", + "https://api.truckwash.io:4433/edge-agent/artifacts/manifest.json", "https://api.truckwash.io:4433/edge-agent/artifacts/agent.php", "https://api.truckwash.io:4433/edge-agent/artifacts/truckwash-edge-agent.service", "https://api.truckwash.io:4433/edge-agent/install.sh?token=abc%20123", ]); }); +test("validateArtifactManifestBody requires v3 install artifacts", () => { + const artifacts = [ + "agent.php", + "lan-worker.php", + "auto-updater.php", + "docker-compose.gateway.yml", + "Dockerfile.edge-agent", + "Dockerfile.lan-worker", + "Dockerfile.auto-updater", + "gateway-launcher.sh", + "truckwash-edge-gateway-stack.service", + "truckwash-edge-agent.service", + ].map((name) => ({ name, sha256: "abc", bytes: 1 })); + + const manifest = validateArtifactManifestBody(JSON.stringify({ + version: EXPECTED_INSTALL_VERSION, + artifacts, + })); + + assert.equal(manifest.version, EXPECTED_INSTALL_VERSION); +}); + +test("validateArtifactBodyAgainstManifest verifies size and hash", () => { + const body = Buffer.from("hello"); + const manifest = { + artifacts: [{ + name: "agent.php", + sha256: "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + bytes: body.length, + }], + }; + + assert.equal(validateArtifactBodyAgainstManifest(manifest, "agent.php", body).name, "agent.php"); +}); + test("validateInstallerScriptBody requires install-session reporting wiring", () => { const script = ` INSTALL_STATUS_URL="https://api.truckwash.io:4433/edge-agent/install-token/status" + fetch_http "Download artifact manifest" "https://api.truckwash.io:4433/edge-agent/artifacts/manifest.json" report_install_status "FAILED" begin_install_phase "VERIFY_TOKEN" "Verifying install token" + begin_install_phase "VERIFY_ARTIFACTS" "Verifying edge gateway artifacts" begin_install_phase "WAIT_FOR_CLAIM" "Waiting for gateway heartbeat and claim" + verify_manifest_artifact + ${EXPECTED_INSTALL_VERSION} `; assert.deepEqual(validateInstallerScriptBody(script), INSTALLER_SCRIPT_REQUIRED_SNIPPETS); diff --git a/services/nginx/app/modules/edgegateway/routes/edgeGatewaysRoute.php b/services/nginx/app/modules/edgegateway/routes/edgeGatewaysRoute.php index 67748a4b..13b09971 100644 --- a/services/nginx/app/modules/edgegateway/routes/edgeGatewaysRoute.php +++ b/services/nginx/app/modules/edgegateway/routes/edgeGatewaysRoute.php @@ -402,7 +402,11 @@ class edgeGatewaysRoute $response->error('Missing token', 400); } - $response->success($this->install()->verifyInstallToken($token)); + try { + $response->success($this->install()->verifyInstallToken($token)); + } catch (Exception $exception) { + $response->error($exception->getMessage(), 400); + } } private function handleAgentInstallTokenStatus(): void @@ -411,17 +415,21 @@ class edgeGatewaysRoute self::requireParameters(['token', 'status']); $payload = self::getParametersAsArray(); - $response->success($this->registry()->reportInstallTokenStatus( - (string)$payload['token'], - [ - 'status' => (string)$payload['status'], - 'step' => isset($payload['step']) ? (string)$payload['step'] : null, - 'message' => isset($payload['message']) ? (string)$payload['message'] : null, - 'diagnostics' => isset($payload['diagnostics']) && is_array($payload['diagnostics']) ? (array)$payload['diagnostics'] : [], - 'gateway_id' => isset($payload['gateway_id']) ? (int)$payload['gateway_id'] : null, - 'last_error' => isset($payload['last_error']) ? (string)$payload['last_error'] : null, - ] - )); + try { + $response->success($this->registry()->reportInstallTokenStatus( + (string)$payload['token'], + [ + 'status' => (string)$payload['status'], + 'step' => isset($payload['step']) ? (string)$payload['step'] : null, + 'message' => isset($payload['message']) ? (string)$payload['message'] : null, + 'diagnostics' => isset($payload['diagnostics']) && is_array($payload['diagnostics']) ? (array)$payload['diagnostics'] : [], + 'gateway_id' => isset($payload['gateway_id']) ? (int)$payload['gateway_id'] : null, + 'last_error' => isset($payload['last_error']) ? (string)$payload['last_error'] : null, + ] + )); + } catch (Exception $exception) { + $response->error($exception->getMessage(), 400); + } } private function renderArtifact(string $fileName): void @@ -443,15 +451,19 @@ class edgeGatewaysRoute self::requireParameters(['token']); $payload = self::getParametersAsArray(); - $response->success( - $this->registry()->claimGateway( - (string)$payload['token'], - trim((string)($payload['hostname'] ?? gethostname() ?: 'unknown-gateway')), - isset($payload['installed_version']) ? (string)$payload['installed_version'] : null, - isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : [] - ), - 201 - ); + try { + $response->success( + $this->registry()->claimGateway( + (string)$payload['token'], + trim((string)($payload['hostname'] ?? gethostname() ?: 'unknown-gateway')), + isset($payload['installed_version']) ? (string)$payload['installed_version'] : null, + isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : [] + ), + 201 + ); + } catch (Exception $exception) { + $response->error($exception->getMessage(), 400); + } } private function handleAgentHeartbeat(): void