feat(device-plane): add core-initiated edge channel
This commit is contained in:
@@ -0,0 +1,449 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { randomUUID, X509Certificate } from "node:crypto";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { connect as connectHttp2 } from "node:http2";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import {
|
||||
DEVICE_ADAPTER_ACCEPTANCE_SCHEMA,
|
||||
DEVICE_ADAPTER_MESSAGE_SCHEMA,
|
||||
DEVICE_DISCOVERY_SIGNAL_SCHEMA,
|
||||
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||
import { createDeviceGatewayCoreChannelClient } from
|
||||
"../../device-gateway-core/src/runtime.mjs";
|
||||
import { createDeviceEdgeChannelServer } from "../src/runtime.mjs";
|
||||
|
||||
let fixtureDirectory;
|
||||
let certificates;
|
||||
|
||||
before(async () => {
|
||||
fixtureDirectory = await mkdtemp(join(tmpdir(), "nodedc-edge-channel-"));
|
||||
certificates = await generateCertificateFixture(fixtureDirectory);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await rm(fixtureDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("accepts synthetic discovery and durable message results over Core-initiated mTLS", async () => {
|
||||
const acceptedMessages = new Map();
|
||||
let messageCalls = 0;
|
||||
const pair = await startPair({
|
||||
acceptMessage: async (message) => {
|
||||
messageCalls += 1;
|
||||
const previous = acceptedMessages.get(message.idempotencyKey);
|
||||
if (previous) return { ...previous, replayed: true };
|
||||
const acceptance = acceptanceFor(message, false);
|
||||
acceptedMessages.set(message.idempotencyKey, acceptance);
|
||||
return acceptance;
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const discovery = await pair.edge.submitDiscovery(discoverySignal());
|
||||
assert.equal(discovery.lifecycleState, "quarantine");
|
||||
assert.equal(discovery.identifier.masked, "***********1088");
|
||||
|
||||
const message = adapterMessage();
|
||||
const first = await pair.edge.submitAdapterMessage(message);
|
||||
const replay = await pair.edge.submitAdapterMessage(message);
|
||||
assert.equal(first.replayed, false);
|
||||
assert.equal(replay.replayed, true);
|
||||
assert.equal(messageCalls, 2);
|
||||
assert.equal(pair.edge.status().eventsAccepted, 3);
|
||||
assert.equal(pair.core.status().eventsAccepted, 3);
|
||||
assert.equal(pair.edge.status().trackerIngress, "disabled");
|
||||
assert.equal(pair.core.status().commandTransport, "disabled");
|
||||
} finally {
|
||||
await stopPair(pair);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the channel alive and reconnects without losing idempotency", async () => {
|
||||
const acceptedMessages = new Map();
|
||||
const pair = await startPair({
|
||||
keepaliveMs: 20,
|
||||
deadPeerMs: 60,
|
||||
reconnectMinimumMs: 20,
|
||||
reconnectMaximumMs: 40,
|
||||
acceptMessage: async (message) => {
|
||||
const previous = acceptedMessages.get(message.idempotencyKey);
|
||||
if (previous) return { ...previous, replayed: true };
|
||||
const acceptance = acceptanceFor(message, false);
|
||||
acceptedMessages.set(message.idempotencyKey, acceptance);
|
||||
return acceptance;
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await delay(120);
|
||||
assert.equal(pair.edge.status().channel, "accepted");
|
||||
const message = adapterMessage();
|
||||
assert.equal((await pair.edge.submitAdapterMessage(message)).replayed, false);
|
||||
|
||||
pair.edge.disconnectActiveChannel();
|
||||
await waitFor(() => pair.core.status().connectionAttempts >= 2
|
||||
&& pair.edge.status().channel === "accepted", 2_000);
|
||||
assert.equal((await pair.edge.submitAdapterMessage(message)).replayed, true);
|
||||
assert.ok(pair.core.status().reconnects >= 1);
|
||||
} finally {
|
||||
await stopPair(pair);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a revoked Edge registration before opening a channel", async () => {
|
||||
const edge = createEdgeServer();
|
||||
const address = await edge.start();
|
||||
const core = createCoreClient({
|
||||
address,
|
||||
lifecycleState: "revoked",
|
||||
});
|
||||
try {
|
||||
await core.start();
|
||||
await waitFor(() => core.status().connectionAttempts >= 1, 500);
|
||||
assert.equal(core.status().channel, "absent");
|
||||
assert.equal(edge.status().channelsAccepted, 0);
|
||||
assert.match(core.status().lastErrorCode, /registration_inactive/);
|
||||
} finally {
|
||||
await core.stop();
|
||||
await edge.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects an authenticated but non-allowlisted Core certificate", async () => {
|
||||
const edge = createEdgeServer();
|
||||
const address = await edge.start();
|
||||
const core = createCoreClient({
|
||||
address,
|
||||
clientCertificate: certificates.intruder,
|
||||
});
|
||||
try {
|
||||
await core.start();
|
||||
await waitFor(() => edge.status().channelsRejected >= 1, 2_000);
|
||||
assert.equal(edge.status().channelsAccepted, 0);
|
||||
assert.equal(edge.status().channel, "absent");
|
||||
} finally {
|
||||
await core.stop();
|
||||
await edge.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects an unknown Edge certificate fingerprint", async () => {
|
||||
const edge = createEdgeServer();
|
||||
const address = await edge.start();
|
||||
const core = createCoreClient({
|
||||
address,
|
||||
expectedEdgeFingerprint: certificates.intruder.fingerprint,
|
||||
});
|
||||
try {
|
||||
await core.start();
|
||||
await waitFor(() => core.status().protocolFailures >= 1, 2_000);
|
||||
assert.equal(core.status().channelsAccepted, 0);
|
||||
assert.match(core.status().lastErrorCode, /edge_identity_mismatch/);
|
||||
} finally {
|
||||
await core.stop();
|
||||
await edge.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("returns a conclusive rejection when Core cannot accept a package", async () => {
|
||||
const pair = await startPair({
|
||||
acceptMessage: async () => {
|
||||
throw new Error("device_control_core_unavailable");
|
||||
},
|
||||
});
|
||||
try {
|
||||
await assert.rejects(
|
||||
pair.edge.submitAdapterMessage(adapterMessage()),
|
||||
/device_control_core_unavailable/,
|
||||
);
|
||||
assert.equal(pair.edge.status().eventsRejected, 1);
|
||||
assert.equal(pair.core.status().eventsRejected, 1);
|
||||
} finally {
|
||||
await stopPair(pair);
|
||||
}
|
||||
});
|
||||
|
||||
test("closes the logical session on an unknown message kind", async () => {
|
||||
const edge = createEdgeServer();
|
||||
const address = await edge.start();
|
||||
const session = connectHttp2(`https://127.0.0.1:${address.port}`, {
|
||||
key: certificates.core.key,
|
||||
cert: certificates.core.cert,
|
||||
ca: certificates.ca,
|
||||
servername: "localhost",
|
||||
minVersion: "TLSv1.3",
|
||||
maxVersion: "TLSv1.3",
|
||||
rejectUnauthorized: true,
|
||||
});
|
||||
try {
|
||||
await onceEvent(session, "connect");
|
||||
const request = session.request({
|
||||
":method": "POST",
|
||||
":path": "/internal/v1/device-edge/channel",
|
||||
}, { endStream: false });
|
||||
const hello = await readFirstEnvelope(request);
|
||||
const invalid = {
|
||||
schemaVersion: hello.schemaVersion,
|
||||
edgeRegistrationId: hello.edgeRegistrationId,
|
||||
channelGeneration: hello.channelGeneration,
|
||||
trackerSessionId: "channel:control",
|
||||
adapterProfileRef: "channel.control.v1",
|
||||
sequence: 1,
|
||||
eventAt: new Date().toISOString(),
|
||||
receivedAt: new Date().toISOString(),
|
||||
payloadBytes: 2,
|
||||
messageKind: "tcp.forward",
|
||||
correlationId: `correlation:${randomUUID()}`,
|
||||
payload: {},
|
||||
};
|
||||
request.write(`${JSON.stringify(invalid)}\n`);
|
||||
await waitFor(() => edge.status().protocolFailures >= 1, 1_000);
|
||||
assert.equal(edge.status().channel, "absent");
|
||||
request.close();
|
||||
} finally {
|
||||
session.close();
|
||||
await edge.stop();
|
||||
}
|
||||
});
|
||||
|
||||
async function startPair(options = {}) {
|
||||
const edge = createEdgeServer(options);
|
||||
const address = await edge.start();
|
||||
const core = createCoreClient({ address, ...options });
|
||||
await core.start();
|
||||
await core.waitForReady(2_000);
|
||||
await waitFor(() => edge.status().channel === "accepted", 2_000);
|
||||
return { edge, core };
|
||||
}
|
||||
|
||||
function createEdgeServer(options = {}) {
|
||||
return createDeviceEdgeChannelServer({
|
||||
edgeRegistrationId: "edge:pilot-1",
|
||||
channelGeneration: "generation:pilot-1",
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
tls: {
|
||||
key: certificates.edge.key,
|
||||
cert: certificates.edge.cert,
|
||||
ca: certificates.ca,
|
||||
allowedCoreFingerprints: [certificates.core.fingerprint],
|
||||
},
|
||||
keepaliveMs: options.keepaliveMs ?? 50,
|
||||
deadPeerMs: options.deadPeerMs ?? 150,
|
||||
acceptanceTimeoutMs: 500,
|
||||
});
|
||||
}
|
||||
|
||||
function createCoreClient(options) {
|
||||
const clientCertificate = options.clientCertificate ?? certificates.core;
|
||||
return createDeviceGatewayCoreChannelClient({
|
||||
registration: {
|
||||
edgeRegistrationId: "edge:pilot-1",
|
||||
endpoint: `https://127.0.0.1:${options.address.port}/`,
|
||||
servername: "localhost",
|
||||
certificateFingerprint: options.expectedEdgeFingerprint
|
||||
?? certificates.edge.fingerprint,
|
||||
lifecycleState: options.lifecycleState ?? "active",
|
||||
},
|
||||
tls: {
|
||||
key: clientCertificate.key,
|
||||
cert: clientCertificate.cert,
|
||||
ca: certificates.ca,
|
||||
},
|
||||
coreIdentity: "workload:device-gateway-core",
|
||||
keepaliveMs: options.keepaliveMs ?? 50,
|
||||
deadPeerMs: options.deadPeerMs ?? 150,
|
||||
reconnectMinimumMs: options.reconnectMinimumMs ?? 20,
|
||||
reconnectMaximumMs: options.reconnectMaximumMs ?? 80,
|
||||
random: () => 0,
|
||||
observeDiscovery: options.observeDiscovery ?? (async () => ({
|
||||
schemaVersion: "nodedc.device.discovery-view.v1",
|
||||
lifecycleState: "quarantine",
|
||||
commandTransport: "disabled",
|
||||
identifier: { kind: "imei", masked: "***********1088" },
|
||||
})),
|
||||
acceptMessage: options.acceptMessage ?? (async (message) =>
|
||||
acceptanceFor(message, false)),
|
||||
});
|
||||
}
|
||||
|
||||
async function stopPair(pair) {
|
||||
await pair.core.stop();
|
||||
await pair.edge.stop();
|
||||
}
|
||||
|
||||
function discoverySignal() {
|
||||
return {
|
||||
schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA,
|
||||
sessionRef: "session:pilot-1",
|
||||
modelProfileRef: "arusnavi.internal.b2.v1",
|
||||
protocol: "INTERNAL",
|
||||
observedAt: new Date().toISOString(),
|
||||
identifier: { kind: "imei", value: "863151070211088" },
|
||||
evidence: {
|
||||
transport: "tcp",
|
||||
bytesObserved: 10,
|
||||
framingStatus: "verified",
|
||||
specificationRef: "arusnavi.internal.protocol.v1",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function adapterMessage() {
|
||||
return {
|
||||
schemaVersion: DEVICE_ADAPTER_MESSAGE_SCHEMA,
|
||||
edgeRef: "edge:pilot-1",
|
||||
adapterRef: "arusnavi-b2",
|
||||
protocolProfileRef: "arusnavi.internal.b2.v1",
|
||||
protocol: "INTERNAL",
|
||||
sessionRef: "session:pilot-1",
|
||||
messageRef: "message:pilot-1",
|
||||
messageType: "telemetry.package",
|
||||
sequence: 1,
|
||||
observedAt: new Date().toISOString(),
|
||||
idempotencyKey: `sha256:${"a".repeat(64)}`,
|
||||
identifier: { kind: "imei", value: "863151070211088" },
|
||||
payloadSchemaRef: "arusnavi.internal.package-metadata.v1",
|
||||
payload: {
|
||||
packageNumber: 1,
|
||||
packetCount: 1,
|
||||
byteLength: 11,
|
||||
packageDigest: `sha256:${"b".repeat(64)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function acceptanceFor(message, replayed) {
|
||||
return {
|
||||
schemaVersion: DEVICE_ADAPTER_ACCEPTANCE_SCHEMA,
|
||||
acceptanceRef: "acceptance:pilot-1",
|
||||
idempotencyKey: message.idempotencyKey,
|
||||
status: "accepted",
|
||||
replayed,
|
||||
acceptedAt: "2026-08-11T12:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
async function generateCertificateFixture(directory) {
|
||||
runOpenSsl(directory, [
|
||||
"req", "-x509", "-newkey", "rsa:2048", "-nodes", "-sha256",
|
||||
"-days", "1", "-subj", "/CN=NODEDC Test Device Edge CA",
|
||||
"-addext", "basicConstraints=critical,CA:TRUE",
|
||||
"-addext", "keyUsage=critical,keyCertSign,cRLSign",
|
||||
"-keyout", "ca.key", "-out", "ca.crt",
|
||||
]);
|
||||
await issueCertificate(directory, "edge", "localhost", [
|
||||
"subjectAltName=DNS:localhost,IP:127.0.0.1",
|
||||
"extendedKeyUsage=serverAuth",
|
||||
]);
|
||||
await issueCertificate(directory, "core", "nodedc-device-gateway-core", [
|
||||
"extendedKeyUsage=clientAuth",
|
||||
]);
|
||||
await issueCertificate(directory, "intruder", "unapproved-core", [
|
||||
"extendedKeyUsage=clientAuth",
|
||||
]);
|
||||
const ca = await readFile(join(directory, "ca.crt"));
|
||||
return {
|
||||
ca,
|
||||
edge: await readCertificate(directory, "edge"),
|
||||
core: await readCertificate(directory, "core"),
|
||||
intruder: await readCertificate(directory, "intruder"),
|
||||
};
|
||||
}
|
||||
|
||||
async function issueCertificate(directory, name, commonName, extensions) {
|
||||
runOpenSsl(directory, [
|
||||
"req", "-newkey", "rsa:2048", "-nodes", "-sha256",
|
||||
"-subj", `/CN=${commonName}`,
|
||||
"-keyout", `${name}.key`, "-out", `${name}.csr`,
|
||||
]);
|
||||
const extensionFile = `${name}.ext`;
|
||||
await writeFile(join(directory, extensionFile), [
|
||||
"basicConstraints=critical,CA:FALSE",
|
||||
"keyUsage=critical,digitalSignature,keyEncipherment",
|
||||
...extensions,
|
||||
].join("\n"));
|
||||
runOpenSsl(directory, [
|
||||
"x509", "-req", "-sha256", "-days", "1",
|
||||
"-in", `${name}.csr`, "-CA", "ca.crt", "-CAkey", "ca.key",
|
||||
"-CAcreateserial", "-extfile", extensionFile, "-out", `${name}.crt`,
|
||||
]);
|
||||
}
|
||||
|
||||
async function readCertificate(directory, name) {
|
||||
const cert = await readFile(join(directory, `${name}.crt`));
|
||||
return {
|
||||
key: await readFile(join(directory, `${name}.key`)),
|
||||
cert,
|
||||
fingerprint: new X509Certificate(cert).fingerprint256,
|
||||
};
|
||||
}
|
||||
|
||||
function runOpenSsl(directory, arguments_) {
|
||||
const result = spawnSync("openssl", arguments_, {
|
||||
cwd: directory,
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`openssl_failed:${result.stderr}`);
|
||||
}
|
||||
}
|
||||
|
||||
function readFirstEnvelope(stream) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffered = "";
|
||||
const onData = (chunk) => {
|
||||
buffered += chunk.toString("utf8");
|
||||
const newline = buffered.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
cleanup();
|
||||
resolve(JSON.parse(buffered.slice(0, newline)));
|
||||
};
|
||||
const onError = (error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
const cleanup = () => {
|
||||
stream.off("data", onData);
|
||||
stream.off("error", onError);
|
||||
};
|
||||
stream.on("data", onData);
|
||||
stream.once("error", onError);
|
||||
});
|
||||
}
|
||||
|
||||
function onceEvent(emitter, event) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onEvent = (...args) => {
|
||||
cleanup();
|
||||
resolve(args);
|
||||
};
|
||||
const onError = (error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
const cleanup = () => {
|
||||
emitter.off(event, onEvent);
|
||||
emitter.off("error", onError);
|
||||
};
|
||||
emitter.once(event, onEvent);
|
||||
emitter.once("error", onError);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitFor(predicate, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return;
|
||||
await delay(10);
|
||||
}
|
||||
assert.fail("condition_timeout");
|
||||
}
|
||||
|
||||
function delay(milliseconds) {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
Reference in New Issue
Block a user