Files
NODEDC_DEVICE_CORE/services/device-edge-channel/src/runtime.mjs
T

617 lines
19 KiB
JavaScript

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()));
}