Handle invalid edge installer tokens
This commit is contained in:
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user