617 lines
19 KiB
JavaScript
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()));
|
|
}
|