426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
334 lines
10 KiB
TypeScript
334 lines
10 KiB
TypeScript
/**
|
|
* Route tests for the docker-node bootstrap-callback identity guard (#12876).
|
|
*
|
|
* Exercises the re-bootstrap rule on the EXISTING-node branch: a client cannot
|
|
* rewrite a node's SSH identity (hostname/ssh_user/ssh_port) via the shared
|
|
* bootstrap secret alone; only a request presenting the pinned host key
|
|
* fingerprint may change it. Uses a deterministic in-memory repository stub —
|
|
* the guard is pure route logic, so no DB is needed to prove the behavior.
|
|
*/
|
|
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
|
|
import * as dockerNodesActual from "@/db/repositories/docker-nodes";
|
|
import * as loggerActual from "@/lib/utils/logger";
|
|
|
|
interface StoredNode {
|
|
id: string;
|
|
node_id: string;
|
|
hostname: string;
|
|
ssh_port: number;
|
|
ssh_user: string;
|
|
capacity: number;
|
|
host_key_fingerprint: string | null;
|
|
status: string;
|
|
metadata: Record<string, unknown>;
|
|
}
|
|
|
|
const EXISTING: StoredNode = {
|
|
id: "node-row-1",
|
|
node_id: "node-1",
|
|
hostname: "10.0.0.1",
|
|
ssh_port: 22,
|
|
ssh_user: "root",
|
|
capacity: 8,
|
|
host_key_fingerprint: "SHA256:pinned-fingerprint",
|
|
status: "healthy",
|
|
metadata: { provider: "operator-provisioned" },
|
|
};
|
|
|
|
let stored: StoredNode | null = EXISTING;
|
|
let lastUpdateArg: Partial<StoredNode> | null = null;
|
|
|
|
const mockFindByNodeId = mock(async (_nodeId: string) => stored);
|
|
const mockUpdate = mock(async (_id: string, data: Partial<StoredNode>) => {
|
|
lastUpdateArg = data;
|
|
if (!stored) return null;
|
|
stored = { ...stored, ...data };
|
|
return stored;
|
|
});
|
|
const mockCreate = mock(async (data: StoredNode) => {
|
|
stored = { ...data, id: "node-row-new" };
|
|
return stored;
|
|
});
|
|
|
|
mock.module("@/db/repositories/docker-nodes", () => ({
|
|
...dockerNodesActual,
|
|
dockerNodesRepository: {
|
|
...dockerNodesActual.dockerNodesRepository,
|
|
findByNodeId: mockFindByNodeId,
|
|
update: mockUpdate,
|
|
create: mockCreate,
|
|
},
|
|
}));
|
|
|
|
mock.module("@/lib/utils/logger", () => ({
|
|
...loggerActual,
|
|
logger: {
|
|
...loggerActual.logger,
|
|
info: mock(),
|
|
warn: mock(),
|
|
error: mock(),
|
|
debug: mock(),
|
|
},
|
|
}));
|
|
|
|
const BOOTSTRAP_SECRET = "test-bootstrap-secret";
|
|
process.env.CONTAINERS_BOOTSTRAP_SECRET = BOOTSTRAP_SECRET;
|
|
|
|
const { default: app } = await import(
|
|
"../v1/admin/docker-nodes/bootstrap-callback/route"
|
|
);
|
|
|
|
async function post(body: Record<string, unknown>): Promise<Response> {
|
|
return app.fetch(
|
|
new Request("http://localhost/", {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-bootstrap-secret": BOOTSTRAP_SECRET,
|
|
},
|
|
body: JSON.stringify(body),
|
|
}),
|
|
);
|
|
}
|
|
|
|
describe("bootstrap-callback node-identity guard (#12876)", () => {
|
|
beforeEach(() => {
|
|
stored = { ...EXISTING, metadata: { ...EXISTING.metadata } };
|
|
lastUpdateArg = null;
|
|
mockUpdate.mockClear();
|
|
mockCreate.mockClear();
|
|
mockFindByNodeId.mockClear();
|
|
});
|
|
|
|
test("rejects hostname mutation on existing node without the required fingerprint", async () => {
|
|
const res = await post({
|
|
nodeId: "node-1",
|
|
hostname: "10.6.6.6", // attacker-controlled MITM host
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
const json = (await res.json()) as { success: boolean; error: string };
|
|
expect(json.success).toBe(false);
|
|
expect(json.error).toContain("Validation failed");
|
|
|
|
// The SSH identity must NOT have been written.
|
|
expect(mockUpdate).not.toHaveBeenCalled();
|
|
expect(stored?.hostname).toBe("10.0.0.1");
|
|
expect(stored?.ssh_user).toBe("root");
|
|
expect(stored?.ssh_port).toBe(22);
|
|
});
|
|
|
|
test("rejects ssh_user mutation without a matching fingerprint", async () => {
|
|
const res = await post({
|
|
nodeId: "node-1",
|
|
hostname: "10.0.0.1",
|
|
sshUser: "attacker",
|
|
hostKeyFingerprint: "SHA256:wrong-fingerprint",
|
|
});
|
|
|
|
expect(res.status).toBe(409);
|
|
expect(mockUpdate).not.toHaveBeenCalled();
|
|
expect(stored?.ssh_user).toBe("root");
|
|
});
|
|
|
|
test("rejects ssh_port mutation without a matching fingerprint", async () => {
|
|
const res = await post({
|
|
nodeId: "node-1",
|
|
hostname: "10.0.0.1",
|
|
sshPort: 2222,
|
|
hostKeyFingerprint: "SHA256:wrong-fingerprint",
|
|
});
|
|
|
|
expect(res.status).toBe(409);
|
|
expect(mockUpdate).not.toHaveBeenCalled();
|
|
expect(stored?.ssh_port).toBe(22);
|
|
});
|
|
|
|
test("rejects identity mutation when the node has no pinned fingerprint at all", async () => {
|
|
stored = {
|
|
...EXISTING,
|
|
host_key_fingerprint: null,
|
|
metadata: { ...EXISTING.metadata },
|
|
};
|
|
|
|
const res = await post({
|
|
nodeId: "node-1",
|
|
hostname: "10.6.6.6",
|
|
hostKeyFingerprint: "SHA256:anything",
|
|
});
|
|
|
|
expect(res.status).toBe(409);
|
|
expect(mockUpdate).not.toHaveBeenCalled();
|
|
expect(stored?.hostname).toBe("10.0.0.1");
|
|
});
|
|
|
|
test("allows first host-key pin on an existing autoscaler placeholder when identity is unchanged", async () => {
|
|
stored = {
|
|
...EXISTING,
|
|
host_key_fingerprint: null,
|
|
metadata: { provider: "hetzner-cloud", autoscaled: true },
|
|
};
|
|
|
|
const res = await post({
|
|
nodeId: "node-1",
|
|
hostname: "10.0.0.1",
|
|
sshUser: "root",
|
|
sshPort: 22,
|
|
hostKeyFingerprint: "SHA256:first-real-pin",
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockUpdate).toHaveBeenCalledTimes(1);
|
|
expect(lastUpdateArg?.host_key_fingerprint).toBe("SHA256:first-real-pin");
|
|
expect(stored?.host_key_fingerprint).toBe("SHA256:first-real-pin");
|
|
});
|
|
|
|
test("allows identity mutation with a matching pinned fingerprint", async () => {
|
|
const res = await post({
|
|
nodeId: "node-1",
|
|
hostname: "10.0.0.9",
|
|
sshUser: "deploy",
|
|
sshPort: 2200,
|
|
hostKeyFingerprint: "SHA256:pinned-fingerprint",
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as { success: boolean };
|
|
expect(json.success).toBe(true);
|
|
|
|
expect(mockUpdate).toHaveBeenCalledTimes(1);
|
|
expect(lastUpdateArg?.hostname).toBe("10.0.0.9");
|
|
expect(lastUpdateArg?.ssh_user).toBe("deploy");
|
|
expect(lastUpdateArg?.ssh_port).toBe(2200);
|
|
});
|
|
|
|
test("liveness re-bootstrap with unchanged identity succeeds with the matching fingerprint", async () => {
|
|
const res = await post({
|
|
nodeId: "node-1",
|
|
hostname: "10.0.0.1",
|
|
sshUser: "root",
|
|
sshPort: 22,
|
|
capacity: 16,
|
|
hostKeyFingerprint: "SHA256:pinned-fingerprint",
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockUpdate).toHaveBeenCalledTimes(1);
|
|
// Identity preserved. Capacity is operator-owned once the row exists, so a
|
|
// re-bootstrap must NOT write it — the request value is ignored here.
|
|
expect(lastUpdateArg?.hostname).toBe("10.0.0.1");
|
|
expect(lastUpdateArg?.ssh_user).toBe("root");
|
|
expect(lastUpdateArg?.ssh_port).toBe(22);
|
|
expect(lastUpdateArg).not.toHaveProperty("capacity");
|
|
});
|
|
|
|
test("re-bootstrap preserves an operator-tuned capacity (does not reset to the request/default)", async () => {
|
|
// A 252 GB robot the operator hand-tuned to 24 slots via a direct DB write.
|
|
stored = { ...EXISTING, capacity: 24, metadata: { ...EXISTING.metadata } };
|
|
|
|
const res = await post({
|
|
nodeId: "node-1",
|
|
hostname: "10.0.0.1",
|
|
sshUser: "root",
|
|
sshPort: 22,
|
|
// Callback reports the small-box default; it must not clobber the tune.
|
|
capacity: 8,
|
|
hostKeyFingerprint: "SHA256:pinned-fingerprint",
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockUpdate).toHaveBeenCalledTimes(1);
|
|
expect(lastUpdateArg).not.toHaveProperty("capacity");
|
|
expect(stored?.capacity).toBe(24);
|
|
});
|
|
|
|
test("brand-new node still gets its capacity stamped from the request", async () => {
|
|
stored = null; // findByNodeId returns null → insert path
|
|
let createdCapacity: number | undefined;
|
|
mockCreate.mockImplementationOnce(async (data: StoredNode) => {
|
|
createdCapacity = data.capacity;
|
|
stored = { ...data, id: "node-row-new" };
|
|
return stored;
|
|
});
|
|
|
|
const res = await post({
|
|
nodeId: "node-3",
|
|
hostname: "10.0.0.60",
|
|
sshUser: "root",
|
|
sshPort: 22,
|
|
capacity: 24,
|
|
hostKeyFingerprint: "SHA256:new-node",
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(mockCreate).toHaveBeenCalledTimes(1);
|
|
expect(mockUpdate).not.toHaveBeenCalled();
|
|
expect(createdCapacity).toBe(24);
|
|
});
|
|
|
|
test("rejects liveness re-bootstrap without the required pinned fingerprint", async () => {
|
|
const res = await post({
|
|
nodeId: "node-1",
|
|
hostname: "10.0.0.1",
|
|
sshUser: "root",
|
|
sshPort: 22,
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(mockUpdate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("rejects host-key fingerprint mutation even when SSH identity is unchanged", async () => {
|
|
const res = await post({
|
|
nodeId: "node-1",
|
|
hostname: "10.0.0.1",
|
|
sshUser: "root",
|
|
sshPort: 22,
|
|
hostKeyFingerprint: "SHA256:attacker-first-pin",
|
|
});
|
|
|
|
expect(res.status).toBe(409);
|
|
const json = (await res.json()) as { success: boolean; error: string };
|
|
expect(json.success).toBe(false);
|
|
expect(json.error).toContain("Host key fingerprint");
|
|
expect(mockUpdate).not.toHaveBeenCalled();
|
|
expect(stored?.host_key_fingerprint).toBe("SHA256:pinned-fingerprint");
|
|
});
|
|
|
|
test("first bootstrap of a brand-new node still creates the row", async () => {
|
|
stored = null; // findByNodeId returns null → insert path
|
|
|
|
const res = await post({
|
|
nodeId: "node-2",
|
|
hostname: "10.0.0.50",
|
|
sshUser: "root",
|
|
sshPort: 22,
|
|
hostKeyFingerprint: "SHA256:new-node",
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as { data: { action: string } };
|
|
expect(json.data.action).toBe("created");
|
|
expect(mockCreate).toHaveBeenCalledTimes(1);
|
|
expect(mockUpdate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("rejects unauthorized requests (bad secret) before any DB access", async () => {
|
|
const res = await app.fetch(
|
|
new Request("http://localhost/", {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-bootstrap-secret": "wrong-secret",
|
|
},
|
|
body: JSON.stringify({ nodeId: "node-1", hostname: "10.6.6.6" }),
|
|
}),
|
|
);
|
|
|
|
expect(res.status).toBe(401);
|
|
expect(mockFindByNodeId).not.toHaveBeenCalled();
|
|
expect(mockUpdate).not.toHaveBeenCalled();
|
|
});
|
|
});
|