feat: establish standalone Device Core repository
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@nodedc/device-edge-channel",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/runtime.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
import { randomUUID, X509Certificate } from "node:crypto";
|
||||
import { createSecureServer } from "node:http2";
|
||||
|
||||
import {
|
||||
DEVICE_EDGE_CHANNEL_LIMITS,
|
||||
DEVICE_EDGE_CHANNEL_PATH,
|
||||
createChannelEnvelope,
|
||||
createChannelEnvelopeDecoder,
|
||||
encodeChannelEnvelope,
|
||||
normalizeCertificateFingerprint,
|
||||
} from "../../../packages/device-edge-channel-contract/src/index.mjs";
|
||||
import {
|
||||
normalizeAdapterAcceptance,
|
||||
normalizeAdapterMessage,
|
||||
normalizeDiscoverySignal,
|
||||
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||
|
||||
const CHANNEL_TRACKER_SESSION_ID = "channel:control";
|
||||
const CHANNEL_PROFILE_REF = "channel.control.v1";
|
||||
|
||||
export function createDeviceEdgeChannelServer(options = {}) {
|
||||
const config = normalizeConfig(options);
|
||||
let trust = config.trust;
|
||||
const pending = new Map();
|
||||
let active = null;
|
||||
let started = false;
|
||||
let heartbeatTimer = null;
|
||||
let totalChannelsAccepted = 0;
|
||||
let totalChannelsRejected = 0;
|
||||
let totalEventsSubmitted = 0;
|
||||
let totalEventsAccepted = 0;
|
||||
let totalEventsRejected = 0;
|
||||
let totalProtocolFailures = 0;
|
||||
|
||||
const server = createSecureServer({
|
||||
key: trust.key,
|
||||
cert: trust.cert,
|
||||
ca: trust.ca,
|
||||
minVersion: "TLSv1.3",
|
||||
maxVersion: "TLSv1.3",
|
||||
allowHTTP1: false,
|
||||
requestCert: true,
|
||||
rejectUnauthorized: true,
|
||||
settings: {
|
||||
enablePush: false,
|
||||
maxConcurrentStreams: 1,
|
||||
initialWindowSize: 1024 * 1024,
|
||||
},
|
||||
});
|
||||
|
||||
server.on("stream", (stream, headers) => {
|
||||
if (headers[":method"] !== "POST" || headers[":path"] !== DEVICE_EDGE_CHANNEL_PATH) {
|
||||
stream.respond({ ":status": 404, "cache-control": "no-store" });
|
||||
stream.end();
|
||||
return;
|
||||
}
|
||||
if (!isApprovedCorePeer(stream)) {
|
||||
totalChannelsRejected += 1;
|
||||
stream.respond({ ":status": 403, "cache-control": "no-store" });
|
||||
stream.end();
|
||||
return;
|
||||
}
|
||||
if (active) {
|
||||
totalChannelsRejected += 1;
|
||||
stream.respond({ ":status": 409, "cache-control": "no-store" });
|
||||
stream.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const state = {
|
||||
stream,
|
||||
decoder: createChannelEnvelopeDecoder({
|
||||
direction: "core-to-edge",
|
||||
maxEnvelopeBytes: config.maxEnvelopeBytes,
|
||||
}),
|
||||
accepted: false,
|
||||
edgeSequence: 0,
|
||||
coreSequence: 0,
|
||||
lastCoreActivityAt: config.clock(),
|
||||
closed: false,
|
||||
};
|
||||
active = state;
|
||||
stream.respond({
|
||||
":status": 200,
|
||||
"content-type": "application/x-ndjson",
|
||||
"cache-control": "no-store",
|
||||
"x-content-type-options": "nosniff",
|
||||
});
|
||||
send(state, "channel.hello", {
|
||||
status: "ready",
|
||||
transport: "http2-mtls",
|
||||
trustGeneration: trust.generationRef,
|
||||
commandTransport: config.commandTransport,
|
||||
}, {
|
||||
trackerSessionId: CHANNEL_TRACKER_SESSION_ID,
|
||||
adapterProfileRef: CHANNEL_PROFILE_REF,
|
||||
correlationId: `correlation:${randomUUID()}`,
|
||||
});
|
||||
|
||||
let processing = Promise.resolve();
|
||||
stream.on("data", (chunk) => {
|
||||
stream.pause();
|
||||
processing = processing
|
||||
.then(async () => {
|
||||
const envelopes = state.decoder.push(chunk);
|
||||
for (const envelope of envelopes) {
|
||||
await handleCoreEnvelope(state, envelope);
|
||||
}
|
||||
})
|
||||
.catch(() => protocolFailure(state))
|
||||
.finally(() => {
|
||||
if (!state.closed) stream.resume();
|
||||
});
|
||||
});
|
||||
stream.on("aborted", () => closeActive(state));
|
||||
stream.on("close", () => closeActive(state));
|
||||
stream.on("error", () => closeActive(state));
|
||||
});
|
||||
server.on("tlsClientError", () => {
|
||||
totalChannelsRejected += 1;
|
||||
});
|
||||
server.on("sessionError", () => {
|
||||
totalProtocolFailures += 1;
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
async start() {
|
||||
if (started) return server.address();
|
||||
await listen(server, config.port, config.host);
|
||||
started = true;
|
||||
heartbeatTimer = setInterval(checkChannelHealth, config.keepaliveMs);
|
||||
heartbeatTimer.unref?.();
|
||||
return server.address();
|
||||
},
|
||||
async stop() {
|
||||
clearInterval(heartbeatTimer);
|
||||
heartbeatTimer = null;
|
||||
const current = active;
|
||||
if (current) {
|
||||
current.stream.close();
|
||||
closeActive(current);
|
||||
}
|
||||
rejectAllPending("device_edge_channel_stopped");
|
||||
if (started) await closeServer(server);
|
||||
started = false;
|
||||
},
|
||||
async submitDiscovery(signal) {
|
||||
const normalized = normalizeDiscoverySignal(signal);
|
||||
const result = await submitEvent("discovery.observed", {
|
||||
signal: normalized,
|
||||
}, {
|
||||
trackerSessionId: normalized.sessionRef,
|
||||
adapterProfileRef: normalized.modelProfileRef,
|
||||
eventAt: normalized.observedAt,
|
||||
});
|
||||
if (!result?.discovery) {
|
||||
throw new Error("device_edge_channel_discovery_acceptance_invalid");
|
||||
}
|
||||
return Object.freeze({
|
||||
...result.discovery,
|
||||
...(result.commandOffer ? { commandOffer: result.commandOffer } : {}),
|
||||
});
|
||||
},
|
||||
async submitAdapterMessage(message) {
|
||||
const normalized = normalizeAdapterMessage(message, {
|
||||
maxBytes: config.maxEnvelopeBytes,
|
||||
});
|
||||
const result = await submitEvent("adapter.message", {
|
||||
message: normalized,
|
||||
}, {
|
||||
trackerSessionId: normalized.sessionRef,
|
||||
adapterProfileRef: normalized.protocolProfileRef,
|
||||
eventAt: normalized.observedAt,
|
||||
});
|
||||
const acceptance = normalizeAdapterAcceptance(result?.acceptance);
|
||||
if (acceptance.idempotencyKey !== normalized.idempotencyKey) {
|
||||
throw new Error("device_edge_channel_acceptance_mismatch");
|
||||
}
|
||||
return Object.freeze({
|
||||
...acceptance,
|
||||
...(result.commandOffer ? { commandOffer: result.commandOffer } : {}),
|
||||
});
|
||||
},
|
||||
async submitCommandStatus(status) {
|
||||
const normalized = normalizeCommandStatus(status);
|
||||
const result = await submitEvent("command.status", {
|
||||
status: normalized,
|
||||
}, {
|
||||
trackerSessionId: normalized.sessionRef,
|
||||
adapterProfileRef: normalized.adapterProfileRef,
|
||||
eventAt: normalized.observedAt,
|
||||
});
|
||||
if (result?.status !== "recorded") {
|
||||
throw new Error("device_edge_channel_command_status_invalid");
|
||||
}
|
||||
return Object.freeze({ status: "recorded" });
|
||||
},
|
||||
status() {
|
||||
return Object.freeze({
|
||||
listening: started,
|
||||
channel: active?.accepted ? "accepted" : active ? "negotiating" : "absent",
|
||||
edgeRegistrationId: config.edgeRegistrationId,
|
||||
channelGeneration: config.channelGeneration,
|
||||
trustGeneration: trust.generationRef,
|
||||
edgeCertificateFingerprint: trust.certificateFingerprint,
|
||||
pendingAcceptances: pending.size,
|
||||
channelsAccepted: totalChannelsAccepted,
|
||||
channelsRejected: totalChannelsRejected,
|
||||
eventsSubmitted: totalEventsSubmitted,
|
||||
eventsAccepted: totalEventsAccepted,
|
||||
eventsRejected: totalEventsRejected,
|
||||
protocolFailures: totalProtocolFailures,
|
||||
trackerIngress: "disabled",
|
||||
commandTransport: config.commandTransport,
|
||||
});
|
||||
},
|
||||
rotateTrust(next) {
|
||||
const nextTrust = normalizeTrust(next);
|
||||
if (nextTrust.generationRef === trust.generationRef) {
|
||||
throw new TypeError("device_edge_channel_trust_generation_unchanged");
|
||||
}
|
||||
server.setSecureContext({
|
||||
key: nextTrust.key,
|
||||
cert: nextTrust.cert,
|
||||
ca: nextTrust.ca,
|
||||
minVersion: "TLSv1.3",
|
||||
maxVersion: "TLSv1.3",
|
||||
});
|
||||
trust = nextTrust;
|
||||
const current = active;
|
||||
if (current) {
|
||||
current.stream.close();
|
||||
closeActive(current);
|
||||
}
|
||||
return Object.freeze({
|
||||
trustGeneration: trust.generationRef,
|
||||
edgeCertificateFingerprint: trust.certificateFingerprint,
|
||||
channel: "reconnect-required",
|
||||
});
|
||||
},
|
||||
disconnectActiveChannel() {
|
||||
active?.stream.close();
|
||||
},
|
||||
});
|
||||
|
||||
async function submitEvent(messageKind, payload, metadata) {
|
||||
const state = active;
|
||||
if (!state?.accepted || state.closed) {
|
||||
throw new Error("device_edge_channel_unavailable");
|
||||
}
|
||||
if (pending.size >= config.maxPendingAcceptances) {
|
||||
throw new Error("device_edge_channel_acceptance_window_full");
|
||||
}
|
||||
const correlationId = `correlation:${randomUUID()}`;
|
||||
totalEventsSubmitted += 1;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(correlationId);
|
||||
totalEventsRejected += 1;
|
||||
reject(new Error("device_edge_channel_acceptance_timeout"));
|
||||
}, config.acceptanceTimeoutMs);
|
||||
timer.unref?.();
|
||||
pending.set(correlationId, { resolve, reject, timer });
|
||||
try {
|
||||
send(state, messageKind, payload, {
|
||||
...metadata,
|
||||
correlationId,
|
||||
});
|
||||
} catch (error) {
|
||||
clearTimeout(timer);
|
||||
pending.delete(correlationId);
|
||||
totalEventsRejected += 1;
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCoreEnvelope(state, envelope) {
|
||||
assertActiveState(state);
|
||||
if (
|
||||
envelope.edgeRegistrationId !== config.edgeRegistrationId
|
||||
|| envelope.channelGeneration !== config.channelGeneration
|
||||
|| envelope.sequence !== state.coreSequence + 1
|
||||
) {
|
||||
throw new Error("device_edge_channel_core_envelope_mismatch");
|
||||
}
|
||||
state.coreSequence = envelope.sequence;
|
||||
state.lastCoreActivityAt = config.clock();
|
||||
|
||||
if (!state.accepted) {
|
||||
if (envelope.messageKind !== "channel.accepted") {
|
||||
throw new Error("device_edge_channel_acceptance_required");
|
||||
}
|
||||
if (
|
||||
envelope.payload?.status !== "accepted"
|
||||
|| envelope.payload?.commandTransport !== config.commandTransport
|
||||
) {
|
||||
throw new Error("device_edge_channel_acceptance_invalid");
|
||||
}
|
||||
state.accepted = true;
|
||||
totalChannelsAccepted += 1;
|
||||
return;
|
||||
}
|
||||
if (envelope.messageKind === "channel.heartbeat") return;
|
||||
if (!["event.accepted", "event.rejected"].includes(envelope.messageKind)) {
|
||||
throw new Error("device_edge_channel_core_message_unhandled");
|
||||
}
|
||||
const receipt = pending.get(envelope.correlationId);
|
||||
if (!receipt) {
|
||||
throw new Error("device_edge_channel_correlation_unknown");
|
||||
}
|
||||
clearTimeout(receipt.timer);
|
||||
pending.delete(envelope.correlationId);
|
||||
if (envelope.messageKind === "event.accepted") {
|
||||
totalEventsAccepted += 1;
|
||||
receipt.resolve(envelope.payload?.result);
|
||||
return;
|
||||
}
|
||||
totalEventsRejected += 1;
|
||||
receipt.reject(new Error(normalizeRejectionCode(envelope.payload?.errorCode)));
|
||||
}
|
||||
|
||||
function send(state, messageKind, payload, metadata) {
|
||||
assertActiveState(state);
|
||||
state.edgeSequence += 1;
|
||||
const now = config.now();
|
||||
const envelope = createChannelEnvelope({
|
||||
edgeRegistrationId: config.edgeRegistrationId,
|
||||
channelGeneration: config.channelGeneration,
|
||||
trackerSessionId: metadata.trackerSessionId,
|
||||
adapterProfileRef: metadata.adapterProfileRef,
|
||||
sequence: state.edgeSequence,
|
||||
eventAt: metadata.eventAt ?? now,
|
||||
receivedAt: now,
|
||||
messageKind,
|
||||
correlationId: metadata.correlationId,
|
||||
payload,
|
||||
}, {
|
||||
direction: "edge-to-core",
|
||||
maxEnvelopeBytes: config.maxEnvelopeBytes,
|
||||
});
|
||||
const encoded = encodeChannelEnvelope(envelope, {
|
||||
direction: "edge-to-core",
|
||||
maxEnvelopeBytes: config.maxEnvelopeBytes,
|
||||
});
|
||||
if (!state.stream.write(encoded)) {
|
||||
state.stream.once("drain", () => {});
|
||||
}
|
||||
}
|
||||
|
||||
function checkChannelHealth() {
|
||||
const state = active;
|
||||
if (!state || state.closed) return;
|
||||
if (config.clock() - state.lastCoreActivityAt >= config.deadPeerMs) {
|
||||
protocolFailure(state);
|
||||
return;
|
||||
}
|
||||
if (state.accepted) {
|
||||
try {
|
||||
send(state, "channel.heartbeat", { status: "alive" }, {
|
||||
trackerSessionId: CHANNEL_TRACKER_SESSION_ID,
|
||||
adapterProfileRef: CHANNEL_PROFILE_REF,
|
||||
correlationId: `correlation:${randomUUID()}`,
|
||||
});
|
||||
} catch {
|
||||
protocolFailure(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isApprovedCorePeer(stream) {
|
||||
const socket = stream.session?.socket;
|
||||
if (!socket?.authorized) return false;
|
||||
let fingerprint;
|
||||
try {
|
||||
fingerprint = normalizeCertificateFingerprint(
|
||||
socket.getPeerCertificate()?.fingerprint256,
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return trust.allowedCoreFingerprints.has(fingerprint);
|
||||
}
|
||||
|
||||
function protocolFailure(state) {
|
||||
totalProtocolFailures += 1;
|
||||
state.stream.close();
|
||||
closeActive(state);
|
||||
}
|
||||
|
||||
function closeActive(state) {
|
||||
if (state.closed) return;
|
||||
state.closed = true;
|
||||
if (active === state) active = null;
|
||||
rejectAllPending("device_edge_channel_disconnected");
|
||||
}
|
||||
|
||||
function rejectAllPending(code) {
|
||||
for (const receipt of pending.values()) {
|
||||
clearTimeout(receipt.timer);
|
||||
receipt.reject(new Error(code));
|
||||
}
|
||||
pending.clear();
|
||||
}
|
||||
|
||||
function assertActiveState(state) {
|
||||
if (!state || state.closed || active !== state) {
|
||||
throw new Error("device_edge_channel_unavailable");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeConfig(options) {
|
||||
const edgeRegistrationId = normalizeRef(
|
||||
options.edgeRegistrationId,
|
||||
"edge_registration_id",
|
||||
);
|
||||
const channelGeneration = normalizeRef(
|
||||
options.channelGeneration,
|
||||
"channel_generation",
|
||||
);
|
||||
const trust = normalizeTrust({
|
||||
...options.tls,
|
||||
generationRef: options.trustGeneration,
|
||||
});
|
||||
const keepaliveMs = normalizeDuration(options.keepaliveMs, 10,
|
||||
DEVICE_EDGE_CHANNEL_LIMITS.keepaliveMs, "keepalive");
|
||||
const deadPeerMs = normalizeDuration(options.deadPeerMs, keepaliveMs * 2,
|
||||
DEVICE_EDGE_CHANNEL_LIMITS.deadPeerMs, "dead_peer");
|
||||
if (deadPeerMs < keepaliveMs * 2) {
|
||||
throw new TypeError("device_edge_channel_dead_peer_invalid");
|
||||
}
|
||||
return Object.freeze({
|
||||
edgeRegistrationId,
|
||||
channelGeneration,
|
||||
commandTransport: normalizeCommandTransport(options.commandTransport),
|
||||
trust,
|
||||
host: normalizeHost(options.host ?? "127.0.0.1"),
|
||||
port: normalizePort(options.port ?? 443),
|
||||
keepaliveMs,
|
||||
deadPeerMs,
|
||||
acceptanceTimeoutMs: normalizeDuration(
|
||||
options.acceptanceTimeoutMs,
|
||||
10,
|
||||
5_000,
|
||||
"acceptance_timeout",
|
||||
),
|
||||
maxEnvelopeBytes: normalizeInteger(
|
||||
options.maxEnvelopeBytes,
|
||||
256,
|
||||
DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes,
|
||||
DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes,
|
||||
"max_envelope_bytes",
|
||||
),
|
||||
maxPendingAcceptances: normalizeInteger(
|
||||
options.maxPendingAcceptances,
|
||||
1,
|
||||
DEVICE_EDGE_CHANNEL_LIMITS.maxPendingAcceptances,
|
||||
DEVICE_EDGE_CHANNEL_LIMITS.maxPendingAcceptances,
|
||||
"max_pending_acceptances",
|
||||
),
|
||||
clock: typeof options.clock === "function" ? options.clock : Date.now,
|
||||
now: typeof options.now === "function"
|
||||
? () => new Date(options.now()).toISOString()
|
||||
: () => new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeCommandTransport(value) {
|
||||
const normalized = value ?? "disabled";
|
||||
if (!["disabled", "typed-service-ping-v1"].includes(normalized)) {
|
||||
throw new TypeError("device_edge_channel_command_transport_invalid");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeCommandStatus(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError("device_edge_channel_command_status_invalid");
|
||||
}
|
||||
const commandRef = normalizeRef(value.commandRef, "command_ref");
|
||||
const transportMessageRef = normalizeRef(
|
||||
value.transportMessageRef,
|
||||
"transport_message_ref",
|
||||
);
|
||||
const sessionRef = normalizeRef(value.sessionRef, "tracker_session_ref");
|
||||
const adapterProfileRef = normalizeRef(
|
||||
value.adapterProfileRef,
|
||||
"adapter_profile_ref",
|
||||
);
|
||||
if (!["acknowledged", "unknown"].includes(value.lifecycleState)) {
|
||||
throw new TypeError("device_edge_channel_command_lifecycle_invalid");
|
||||
}
|
||||
const resultCode = String(value.resultCode || "");
|
||||
if (!/^[a-z][a-z0-9._-]{1,63}$/.test(resultCode)) {
|
||||
throw new TypeError("device_edge_channel_command_result_invalid");
|
||||
}
|
||||
const observedAt = new Date(value.observedAt);
|
||||
if (Number.isNaN(observedAt.getTime())) {
|
||||
throw new TypeError("device_edge_channel_command_observed_at_invalid");
|
||||
}
|
||||
return Object.freeze({
|
||||
commandRef,
|
||||
transportMessageRef,
|
||||
sessionRef,
|
||||
adapterProfileRef,
|
||||
lifecycleState: value.lifecycleState,
|
||||
resultCode,
|
||||
observedAt: observedAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeTrust(value) {
|
||||
if (!value || typeof value !== "object") {
|
||||
throw new TypeError("device_edge_channel_tls_invalid");
|
||||
}
|
||||
for (const key of ["key", "cert", "ca"]) {
|
||||
if (!(typeof value[key] === "string" || Buffer.isBuffer(value[key]))) {
|
||||
throw new TypeError(`device_edge_channel_tls_${key}_invalid`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!Array.isArray(value.allowedCoreFingerprints)
|
||||
|| value.allowedCoreFingerprints.length < 1
|
||||
|| value.allowedCoreFingerprints.length > 2
|
||||
) {
|
||||
throw new TypeError("device_edge_channel_core_identity_allowlist_invalid");
|
||||
}
|
||||
const generationRef = normalizeRef(
|
||||
value.generationRef,
|
||||
"trust_generation",
|
||||
);
|
||||
let certificateFingerprint;
|
||||
try {
|
||||
certificateFingerprint = normalizeCertificateFingerprint(
|
||||
new X509Certificate(value.cert).fingerprint256,
|
||||
);
|
||||
} catch {
|
||||
throw new TypeError("device_edge_channel_tls_cert_invalid");
|
||||
}
|
||||
const allowedCoreFingerprints = new Set(
|
||||
value.allowedCoreFingerprints.map(normalizeCertificateFingerprint),
|
||||
);
|
||||
if (allowedCoreFingerprints.size !== value.allowedCoreFingerprints.length) {
|
||||
throw new TypeError("device_edge_channel_core_identity_allowlist_duplicate");
|
||||
}
|
||||
return Object.freeze({
|
||||
generationRef,
|
||||
key: value.key,
|
||||
cert: value.cert,
|
||||
ca: value.ca,
|
||||
certificateFingerprint,
|
||||
allowedCoreFingerprints,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRejectionCode(value) {
|
||||
if (typeof value !== "string" || !/^[a-z][a-z0-9._:-]{2,127}$/.test(value)) {
|
||||
return "device_edge_channel_event_rejected";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeRef(value, field) {
|
||||
if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)) {
|
||||
throw new TypeError(`device_edge_channel_${field}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeHost(value) {
|
||||
if (typeof value !== "string" || value.length < 1 || value.length > 253) {
|
||||
throw new TypeError("device_edge_channel_host_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizePort(value) {
|
||||
const number = Number(value);
|
||||
if (!Number.isSafeInteger(number) || number < 0 || number > 65_535) {
|
||||
throw new TypeError("device_edge_channel_port_invalid");
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
function normalizeDuration(value, minimum, fallback, field) {
|
||||
return normalizeInteger(value, minimum, 120_000, fallback, field);
|
||||
}
|
||||
|
||||
function normalizeInteger(value, minimum, maximum, fallback, field) {
|
||||
const number = value == null ? fallback : Number(value);
|
||||
if (!Number.isSafeInteger(number) || number < minimum || number > maximum) {
|
||||
throw new TypeError(`device_edge_channel_${field}_invalid`);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
function listen(server, port, host) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onError = (error) => {
|
||||
server.off("listening", onListening);
|
||||
reject(error);
|
||||
};
|
||||
const onListening = () => {
|
||||
server.off("error", onError);
|
||||
resolve();
|
||||
};
|
||||
server.once("error", onError);
|
||||
server.once("listening", onListening);
|
||||
server.listen(port, host);
|
||||
});
|
||||
}
|
||||
|
||||
function closeServer(server) {
|
||||
return new Promise((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { lstat, readFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { createDeviceEdgeChannelServer } from "./runtime.mjs";
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
await main();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const runtime = await readRuntimeConfiguration(process.env);
|
||||
const channel = createDeviceEdgeChannelServer(runtime.channel);
|
||||
const health = createHealthServer(channel, runtime.health);
|
||||
let stopping = false;
|
||||
|
||||
await channel.start();
|
||||
await listen(health, runtime.health.port, runtime.health.host);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
event: "device_edge_channel_started",
|
||||
host: runtime.channel.host,
|
||||
port: runtime.channel.port,
|
||||
healthHost: runtime.health.host,
|
||||
healthPort: runtime.health.port,
|
||||
edgeRegistrationId: runtime.channel.edgeRegistrationId,
|
||||
channelGeneration: runtime.channel.channelGeneration,
|
||||
trustGeneration: runtime.channel.trustGeneration,
|
||||
trackerIngress: "disabled",
|
||||
commandTransport: "disabled",
|
||||
}));
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
|
||||
async function shutdown() {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
await Promise.allSettled([
|
||||
channel.stop(),
|
||||
closeServer(health),
|
||||
]);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readRuntimeConfiguration(environment = {}) {
|
||||
const configPath = requiredPath(
|
||||
environment.DEVICE_EDGE_CHANNEL_CONFIG_FILE,
|
||||
"device_edge_channel_config_file_required",
|
||||
);
|
||||
const keyPath = requiredPath(
|
||||
environment.DEVICE_EDGE_CHANNEL_KEY_FILE,
|
||||
"device_edge_channel_key_file_required",
|
||||
);
|
||||
const certificatePath = requiredPath(
|
||||
environment.DEVICE_EDGE_CHANNEL_CERTIFICATE_FILE,
|
||||
"device_edge_channel_certificate_file_required",
|
||||
);
|
||||
const coreTrustPath = requiredPath(
|
||||
environment.DEVICE_EDGE_CHANNEL_CORE_TRUST_FILE,
|
||||
"device_edge_channel_core_trust_file_required",
|
||||
);
|
||||
const config = normalizeRuntimeDocument(JSON.parse(
|
||||
await readBoundedRegularFile(configPath, 32 * 1024, "utf8"),
|
||||
));
|
||||
const [key, cert, ca] = await Promise.all([
|
||||
readBoundedRegularFile(keyPath, 32 * 1024),
|
||||
readBoundedRegularFile(certificatePath, 32 * 1024),
|
||||
readBoundedRegularFile(coreTrustPath, 64 * 1024),
|
||||
]);
|
||||
return Object.freeze({
|
||||
channel: Object.freeze({
|
||||
edgeRegistrationId: config.edgeRegistrationId,
|
||||
channelGeneration: config.channelGeneration,
|
||||
trustGeneration: config.trustGeneration,
|
||||
host: normalizeHost(environment.DEVICE_EDGE_CHANNEL_HOST ?? "0.0.0.0"),
|
||||
port: normalizePort(environment.DEVICE_EDGE_CHANNEL_PORT, 443),
|
||||
tls: Object.freeze({
|
||||
key,
|
||||
cert,
|
||||
ca,
|
||||
allowedCoreFingerprints: config.allowedCoreFingerprints,
|
||||
}),
|
||||
}),
|
||||
health: Object.freeze({
|
||||
host: normalizeHost(
|
||||
environment.DEVICE_EDGE_CHANNEL_HEALTH_HOST ?? "127.0.0.1",
|
||||
),
|
||||
port: normalizePort(environment.DEVICE_EDGE_CHANNEL_HEALTH_PORT, 18222),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeRuntimeDocument(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError("device_edge_channel_runtime_config_invalid");
|
||||
}
|
||||
const allowedKeys = new Set([
|
||||
"schemaVersion",
|
||||
"edgeRegistrationId",
|
||||
"channelGeneration",
|
||||
"trustGeneration",
|
||||
"allowedCoreFingerprints",
|
||||
]);
|
||||
if (Object.keys(value).some((key) => !allowedKeys.has(key))) {
|
||||
throw new TypeError("device_edge_channel_runtime_config_key_invalid");
|
||||
}
|
||||
if (value.schemaVersion !== "nodedc.device-edge.channel-runtime.v1") {
|
||||
throw new TypeError("device_edge_channel_runtime_schema_invalid");
|
||||
}
|
||||
const fingerprints = value.allowedCoreFingerprints;
|
||||
if (!Array.isArray(fingerprints) || fingerprints.length < 1 || fingerprints.length > 2) {
|
||||
throw new TypeError("device_edge_channel_runtime_core_identity_invalid");
|
||||
}
|
||||
if (new Set(fingerprints).size !== fingerprints.length) {
|
||||
throw new TypeError("device_edge_channel_runtime_core_identity_invalid");
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: value.schemaVersion,
|
||||
edgeRegistrationId: normalizeRef(value.edgeRegistrationId, "edge_registration"),
|
||||
channelGeneration: normalizeRef(value.channelGeneration, "channel_generation"),
|
||||
trustGeneration: normalizeRef(value.trustGeneration, "trust_generation"),
|
||||
allowedCoreFingerprints: Object.freeze(fingerprints.map((fingerprint) => {
|
||||
if (typeof fingerprint !== "string" || !/^([A-F0-9]{2}:){31}[A-F0-9]{2}$/.test(fingerprint)) {
|
||||
throw new TypeError("device_edge_channel_runtime_core_identity_invalid");
|
||||
}
|
||||
return fingerprint;
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
function createHealthServer(channel, healthConfig) {
|
||||
return createServer((request, response) => {
|
||||
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
if (request.method !== "GET" || request.url !== "/healthz") {
|
||||
response.statusCode = 404;
|
||||
response.end(JSON.stringify({ ok: false, error: "not_found" }));
|
||||
return;
|
||||
}
|
||||
const status = channel.status();
|
||||
response.statusCode = 200;
|
||||
response.end(JSON.stringify({
|
||||
ok: true,
|
||||
service: "nodedc-device-edge-channel",
|
||||
health: `${healthConfig.host}:${healthConfig.port}`,
|
||||
...status,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async function readBoundedRegularFile(path, maximumBytes, encoding = null) {
|
||||
const state = await lstat(path);
|
||||
if (!state.isFile() || state.isSymbolicLink() || state.size < 1 || state.size > maximumBytes) {
|
||||
throw new Error("device_edge_channel_runtime_file_invalid");
|
||||
}
|
||||
return readFile(path, encoding ?? undefined);
|
||||
}
|
||||
|
||||
function requiredPath(value, code) {
|
||||
if (typeof value !== "string" || value.trim() === "" || !value.startsWith("/")) {
|
||||
throw new Error(code);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeRef(value, field) {
|
||||
if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)) {
|
||||
throw new TypeError(`device_edge_channel_runtime_${field}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeHost(value) {
|
||||
if (typeof value !== "string" || value.length < 1 || value.length > 253) {
|
||||
throw new TypeError("device_edge_channel_runtime_host_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizePort(value, fallback) {
|
||||
const number = Number(value ?? fallback);
|
||||
if (!Number.isSafeInteger(number) || number < 1 || number > 65_535) {
|
||||
throw new TypeError("device_edge_channel_runtime_port_invalid");
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
function listen(server, port, host) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, host, () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function closeServer(server) {
|
||||
return new Promise((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
@@ -0,0 +1,765 @@
|
||||
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 { createServer as createTcpServer } from "node:net";
|
||||
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 { createDeviceGatewayIngest } from
|
||||
"../../device-control-core/src/gateway-ingest.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({
|
||||
observeDiscovery: productionDiscoveryObserver(),
|
||||
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, "***********0001");
|
||||
assert.equal("created" in discovery, false);
|
||||
assert.equal("value" in discovery, false);
|
||||
|
||||
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("recovers the claimed device from telemetry after a Core restart and completes a typed command", async () => {
|
||||
const commandRef = "command:11111111-1111-4111-8111-111111111111";
|
||||
const deviceRef = "device:22222222-2222-4222-8222-222222222222";
|
||||
const transportMessageRef = "edge-command:33333333-3333-4333-8333-333333333333";
|
||||
const recorded = [];
|
||||
let offers = 0;
|
||||
const pair = await startPair({
|
||||
commandTransport: "typed-service-ping-v1",
|
||||
acceptMessage: async (message) => ({
|
||||
value: acceptanceFor(message, false),
|
||||
claimedDeviceRef: deviceRef,
|
||||
}),
|
||||
offerCommand: async (offeredDeviceRef) => {
|
||||
assert.equal(offeredDeviceRef, deviceRef);
|
||||
offers += 1;
|
||||
return {
|
||||
commandRef,
|
||||
commandType: "service.ping",
|
||||
accessCode: "123456",
|
||||
transportMessageRef,
|
||||
};
|
||||
},
|
||||
recordCommandStatus: async (status) => recorded.push(status),
|
||||
});
|
||||
|
||||
try {
|
||||
const receipt = await pair.edge.submitAdapterMessage(adapterMessage());
|
||||
assert.equal(receipt.status, "accepted");
|
||||
assert.deepEqual(receipt.commandOffer, {
|
||||
commandRef,
|
||||
commandType: "service.ping",
|
||||
accessCode: "123456",
|
||||
transportMessageRef,
|
||||
});
|
||||
assert.equal(offers, 1);
|
||||
|
||||
await pair.edge.submitCommandStatus({
|
||||
commandRef,
|
||||
transportMessageRef,
|
||||
lifecycleState: "acknowledged",
|
||||
resultCode: "serv_ok",
|
||||
observedAt: "2026-08-12T17:30:00.000Z",
|
||||
sessionRef: "session:pilot-1",
|
||||
adapterProfileRef: "arusnavi.internal.b2.v1",
|
||||
});
|
||||
assert.equal(recorded.length, 1);
|
||||
assert.equal(recorded[0].resultCode, "serv_ok");
|
||||
assert.equal(pair.core.status().negotiatedCommandTransport, "typed-service-ping-v1");
|
||||
} 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("bounds a stalled TCP/TLS handshake and reconnects", async () => {
|
||||
const sockets = new Set();
|
||||
const server = createTcpServer((socket) => {
|
||||
sockets.add(socket);
|
||||
socket.once("close", () => sockets.delete(socket));
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const core = createCoreClient({
|
||||
address: server.address(),
|
||||
connectTimeoutMs: 30,
|
||||
reconnectMinimumMs: 20,
|
||||
reconnectMaximumMs: 20,
|
||||
});
|
||||
try {
|
||||
await core.start();
|
||||
await waitFor(() => core.status().connectionAttempts >= 2, 1_000);
|
||||
assert.match(core.status().lastErrorCode, /connect_timeout/);
|
||||
assert.ok(core.status().reconnects >= 1);
|
||||
} finally {
|
||||
await core.stop();
|
||||
for (const socket of sockets) socket.destroy();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test("rotates the Edge certificate through staged overlap and rejects retired identity", async () => {
|
||||
const edge = createEdgeServer();
|
||||
const address = await edge.start();
|
||||
let registration = edgeRegistration(address, [
|
||||
{
|
||||
generationRef: "trust-generation:1",
|
||||
fingerprint: certificates.edge.fingerprint,
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
generationRef: "trust-generation:2",
|
||||
fingerprint: certificates.edgeNext.fingerprint,
|
||||
status: "staged",
|
||||
},
|
||||
]);
|
||||
const core = createCoreClient({
|
||||
address,
|
||||
registrationProvider: async () => registration,
|
||||
});
|
||||
try {
|
||||
await core.start();
|
||||
await core.waitForReady(2_000);
|
||||
await waitFor(() => edge.status().channel === "accepted", 2_000);
|
||||
assert.equal(core.status().edgeTrustGeneration, "trust-generation:1");
|
||||
|
||||
edge.rotateTrust({
|
||||
generationRef: "trust-generation:2",
|
||||
key: certificates.edgeNext.key,
|
||||
cert: certificates.edgeNext.cert,
|
||||
ca: certificates.ca,
|
||||
allowedCoreFingerprints: [certificates.core.fingerprint],
|
||||
});
|
||||
await waitFor(() => core.status().connectionAttempts >= 2
|
||||
&& core.status().channel === "accepted"
|
||||
&& core.status().edgeTrustGeneration === "trust-generation:2"
|
||||
&& edge.status().channel === "accepted", 2_000);
|
||||
|
||||
registration = edgeRegistration(address, [{
|
||||
generationRef: "trust-generation:2",
|
||||
fingerprint: certificates.edgeNext.fingerprint,
|
||||
status: "active",
|
||||
}]);
|
||||
assert.equal((await edge.submitAdapterMessage(adapterMessage())).status, "accepted");
|
||||
|
||||
const failuresBeforeRollback = core.status().protocolFailures;
|
||||
edge.rotateTrust({
|
||||
generationRef: "trust-generation:3",
|
||||
key: certificates.edge.key,
|
||||
cert: certificates.edge.cert,
|
||||
ca: certificates.ca,
|
||||
allowedCoreFingerprints: [certificates.core.fingerprint],
|
||||
});
|
||||
await waitFor(() => core.status().protocolFailures > failuresBeforeRollback, 2_000);
|
||||
assert.notEqual(core.status().channel, "accepted");
|
||||
} finally {
|
||||
await core.stop();
|
||||
await edge.stop();
|
||||
}
|
||||
});
|
||||
|
||||
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 Edge hello from an unexpected channel generation", async () => {
|
||||
const edge = createEdgeServer({
|
||||
channelGeneration: "generation:edge-unexpected",
|
||||
});
|
||||
const address = await edge.start();
|
||||
const core = createCoreClient({ address });
|
||||
try {
|
||||
await core.start();
|
||||
await waitFor(() => core.status().protocolFailures >= 1, 2_000);
|
||||
assert.notEqual(core.status().channel, "accepted");
|
||||
assert.match(core.status().lastErrorCode, /channel_generation_mismatch/);
|
||||
assert.equal(edge.status().channelsAccepted, 0);
|
||||
} 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("rejects a discovery when Core does not return its durable receipt", async () => {
|
||||
const pair = await startPair({
|
||||
observeDiscovery: async () => ({
|
||||
schemaVersion: "nodedc.device.discovery-view.v1",
|
||||
lifecycleState: "quarantine",
|
||||
commandTransport: "disabled",
|
||||
identifier: { kind: "imei", masked: "***********1088" },
|
||||
}),
|
||||
});
|
||||
try {
|
||||
await assert.rejects(
|
||||
pair.edge.submitDiscovery(discoverySignal()),
|
||||
/device_gateway_core_discovery_receipt_invalid/,
|
||||
);
|
||||
assert.equal(pair.edge.status().eventsRejected, 1);
|
||||
assert.equal(pair.core.status().eventsRejected, 1);
|
||||
} finally {
|
||||
await stopPair(pair);
|
||||
}
|
||||
});
|
||||
|
||||
test("isolates tracker session ordering while allowing cross-session progress", async () => {
|
||||
let releaseSlow;
|
||||
const slowGate = new Promise((resolve) => {
|
||||
releaseSlow = resolve;
|
||||
});
|
||||
const calls = [];
|
||||
const pair = await startPair({
|
||||
acceptMessage: async (message) => {
|
||||
calls.push(message.sessionRef);
|
||||
if (message.sessionRef === "session:slow") await slowGate;
|
||||
return acceptanceFor(message, false);
|
||||
},
|
||||
});
|
||||
try {
|
||||
let slowResolved = false;
|
||||
const slow = pair.edge.submitAdapterMessage(adapterMessage({
|
||||
sessionRef: "session:slow",
|
||||
messageRef: "message:slow-1",
|
||||
idempotencyKey: `sha256:${"c".repeat(64)}`,
|
||||
})).then((value) => {
|
||||
slowResolved = true;
|
||||
return value;
|
||||
});
|
||||
await waitFor(() => calls.includes("session:slow"), 500);
|
||||
const fast = await pair.edge.submitAdapterMessage(adapterMessage({
|
||||
sessionRef: "session:fast",
|
||||
messageRef: "message:fast-1",
|
||||
idempotencyKey: `sha256:${"d".repeat(64)}`,
|
||||
}));
|
||||
assert.equal(fast.status, "accepted");
|
||||
assert.equal(slowResolved, false);
|
||||
releaseSlow();
|
||||
assert.equal((await slow).status, "accepted");
|
||||
} finally {
|
||||
releaseSlow?.();
|
||||
await stopPair(pair);
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves per-session order and applies a bounded acceptance window", async () => {
|
||||
let releaseFirst;
|
||||
const firstGate = new Promise((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
const calls = [];
|
||||
const pair = await startPair({
|
||||
maxPendingAcceptances: 2,
|
||||
acceptMessage: async (message) => {
|
||||
calls.push(message.messageRef);
|
||||
if (message.messageRef === "message:ordered-1") await firstGate;
|
||||
return acceptanceFor(message, false);
|
||||
},
|
||||
});
|
||||
try {
|
||||
const first = pair.edge.submitAdapterMessage(adapterMessage({
|
||||
sessionRef: "session:ordered",
|
||||
messageRef: "message:ordered-1",
|
||||
idempotencyKey: `sha256:${"e".repeat(64)}`,
|
||||
sequence: 1,
|
||||
}));
|
||||
await waitFor(() => calls.length === 1, 500);
|
||||
const second = pair.edge.submitAdapterMessage(adapterMessage({
|
||||
sessionRef: "session:ordered",
|
||||
messageRef: "message:ordered-2",
|
||||
idempotencyKey: `sha256:${"f".repeat(64)}`,
|
||||
sequence: 2,
|
||||
}));
|
||||
await delay(30);
|
||||
assert.deepEqual(calls, ["message:ordered-1"]);
|
||||
await assert.rejects(
|
||||
pair.edge.submitAdapterMessage(adapterMessage({
|
||||
sessionRef: "session:overflow",
|
||||
messageRef: "message:overflow-1",
|
||||
idempotencyKey: `sha256:${"1".repeat(64)}`,
|
||||
})),
|
||||
/acceptance_window_full/,
|
||||
);
|
||||
releaseFirst();
|
||||
await Promise.all([first, second]);
|
||||
assert.deepEqual(calls, ["message:ordered-1", "message:ordered-2"]);
|
||||
} finally {
|
||||
releaseFirst?.();
|
||||
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 = {}) {
|
||||
const edgeCertificate = options.edgeCertificate ?? certificates.edge;
|
||||
return createDeviceEdgeChannelServer({
|
||||
edgeRegistrationId: "edge:pilot-1",
|
||||
channelGeneration: options.channelGeneration ?? "generation:pilot-1",
|
||||
trustGeneration: options.edgeTrustGeneration ?? "trust-generation:1",
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
tls: {
|
||||
key: edgeCertificate.key,
|
||||
cert: edgeCertificate.cert,
|
||||
ca: certificates.ca,
|
||||
allowedCoreFingerprints: options.allowedCoreFingerprints
|
||||
?? [certificates.core.fingerprint],
|
||||
},
|
||||
keepaliveMs: options.keepaliveMs ?? 50,
|
||||
deadPeerMs: options.deadPeerMs ?? 150,
|
||||
acceptanceTimeoutMs: 500,
|
||||
maxPendingAcceptances: options.maxPendingAcceptances,
|
||||
commandTransport: options.commandTransport,
|
||||
});
|
||||
}
|
||||
|
||||
function createCoreClient(options) {
|
||||
const clientCertificate = options.clientCertificate ?? certificates.core;
|
||||
const registration = edgeRegistration(options.address,
|
||||
options.edgeCertificateIdentities ?? [{
|
||||
generationRef: "trust-generation:1",
|
||||
fingerprint: options.expectedEdgeFingerprint
|
||||
?? certificates.edge.fingerprint,
|
||||
status: "active",
|
||||
}],
|
||||
options.lifecycleState ?? "active");
|
||||
return createDeviceGatewayCoreChannelClient({
|
||||
...(options.registrationProvider
|
||||
? { registrationProvider: options.registrationProvider }
|
||||
: { registration }),
|
||||
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,
|
||||
connectTimeoutMs: options.connectTimeoutMs,
|
||||
random: () => 0,
|
||||
observeDiscovery: options.observeDiscovery ?? productionDiscoveryObserver(),
|
||||
acceptMessage: options.acceptMessage ?? (async (message) =>
|
||||
acceptanceFor(message, false)),
|
||||
commandTransport: options.commandTransport,
|
||||
offerCommand: options.offerCommand,
|
||||
recordCommandStatus: options.recordCommandStatus,
|
||||
});
|
||||
}
|
||||
|
||||
function productionDiscoveryObserver() {
|
||||
return createDeviceGatewayIngest({
|
||||
identifierPepper: "test-only-device-edge-channel-identifier-pepper",
|
||||
repository: {
|
||||
async upsertQuarantineDiscovery(value) {
|
||||
return {
|
||||
created: true,
|
||||
value: {
|
||||
...value.safeView,
|
||||
discoveryRef: "discovery:pilot-1",
|
||||
},
|
||||
};
|
||||
},
|
||||
async acceptAdapterMessage() {
|
||||
throw new Error("device_edge_channel_test_unexpected_ingest_message");
|
||||
},
|
||||
},
|
||||
}).observeDiscovery;
|
||||
}
|
||||
|
||||
function edgeRegistration(address, certificateIdentities, lifecycleState = "active") {
|
||||
return {
|
||||
edgeRegistrationId: "edge:pilot-1",
|
||||
channelGeneration: "generation:pilot-1",
|
||||
endpoint: `https://127.0.0.1:${address.port}/`,
|
||||
servername: "localhost",
|
||||
certificateIdentities,
|
||||
lifecycleState,
|
||||
};
|
||||
}
|
||||
|
||||
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: "860000000000001" },
|
||||
evidence: {
|
||||
transport: "tcp",
|
||||
bytesObserved: 10,
|
||||
framingStatus: "verified",
|
||||
specificationRef: "arusnavi.internal.protocol.v1",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function adapterMessage(overrides = {}) {
|
||||
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: "860000000000001" },
|
||||
payloadSchemaRef: "arusnavi.internal.package-metadata.v1",
|
||||
payload: {
|
||||
packageNumber: 1,
|
||||
packetCount: 1,
|
||||
byteLength: 11,
|
||||
packageDigest: `sha256:${"b".repeat(64)}`,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
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, "edge-next", "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"),
|
||||
edgeNext: await readCertificate(directory, "edge-next"),
|
||||
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));
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { normalizeRuntimeDocument } from "../src/server.mjs";
|
||||
|
||||
const fingerprint = Array.from({ length: 32 }, () => "AB").join(":");
|
||||
|
||||
test("runtime document accepts one generation-bound Core identity", () => {
|
||||
const result = normalizeRuntimeDocument({
|
||||
schemaVersion: "nodedc.device-edge.channel-runtime.v1",
|
||||
edgeRegistrationId: "edge:moscow-vps-1",
|
||||
channelGeneration: "channel:1",
|
||||
trustGeneration: "trust:1",
|
||||
allowedCoreFingerprints: [fingerprint],
|
||||
});
|
||||
|
||||
assert.equal(result.edgeRegistrationId, "edge:moscow-vps-1");
|
||||
assert.deepEqual(result.allowedCoreFingerprints, [fingerprint]);
|
||||
});
|
||||
|
||||
test("runtime document rejects hidden authority and missing identity", () => {
|
||||
assert.throws(() => normalizeRuntimeDocument({
|
||||
schemaVersion: "nodedc.device-edge.channel-runtime.v1",
|
||||
edgeRegistrationId: "edge:moscow-vps-1",
|
||||
channelGeneration: "channel:1",
|
||||
trustGeneration: "trust:1",
|
||||
allowedCoreFingerprints: [fingerprint],
|
||||
endpoint: "https://attacker.invalid",
|
||||
}), /runtime_config_key_invalid/);
|
||||
|
||||
assert.throws(() => normalizeRuntimeDocument({
|
||||
schemaVersion: "nodedc.device-edge.channel-runtime.v1",
|
||||
edgeRegistrationId: "edge:moscow-vps-1",
|
||||
channelGeneration: "channel:1",
|
||||
trustGeneration: "trust:1",
|
||||
allowedCoreFingerprints: [],
|
||||
}), /runtime_core_identity_invalid/);
|
||||
|
||||
assert.throws(() => normalizeRuntimeDocument({
|
||||
schemaVersion: "nodedc.device-edge.channel-runtime.v1",
|
||||
edgeRegistrationId: "edge:moscow-vps-1",
|
||||
channelGeneration: "channel:1",
|
||||
trustGeneration: "trust:1",
|
||||
allowedCoreFingerprints: [fingerprint, fingerprint],
|
||||
}), /runtime_core_identity_invalid/);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import {
|
||||
normalizeTrackerIngressConfiguration,
|
||||
} from "../../../vps/edge-process/device-edge-runtime.mjs";
|
||||
|
||||
test("VPS tracker ingress resolves one allowlisted profile within bounded limits", () => {
|
||||
const config = normalizeTrackerIngressConfiguration({}, "edge:moscow-vps-1");
|
||||
|
||||
assert.equal(config.protocolProfileRef, "arusnavi.b2.internal.v1");
|
||||
assert.equal(config.tcpHost, "0.0.0.0");
|
||||
assert.equal(config.tcpPort, 9921);
|
||||
assert.equal(config.maxConcurrentSessions, 128);
|
||||
assert.equal(config.maxSessionsPerAddress, 16);
|
||||
assert.equal(config.maxConnectionsPerMinutePerAddress, 60);
|
||||
assert.equal(config.maxAggregateBufferedBytes, 32 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test("VPS tracker ingress rejects hidden bind and non-allowlisted adapters", () => {
|
||||
assert.throws(() => normalizeTrackerIngressConfiguration({
|
||||
DEVICE_GATEWAY_TCP_HOST: "127.0.0.1",
|
||||
}, "edge:moscow-vps-1"), /public_host_invalid/);
|
||||
|
||||
assert.throws(() => normalizeTrackerIngressConfiguration({
|
||||
DEVICE_GATEWAY_PROTOCOL_PROFILE_REF: "vendor.unknown.v1",
|
||||
}, "edge:moscow-vps-1"), /profile_not_allowlisted/);
|
||||
|
||||
assert.throws(() => normalizeTrackerIngressConfiguration({
|
||||
DEVICE_GATEWAY_MAX_SESSIONS: "129",
|
||||
}, "edge:moscow-vps-1"), /session_limit_invalid/);
|
||||
});
|
||||
Reference in New Issue
Block a user