feat: establish standalone Device Core repository
This commit is contained in:
@@ -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()));
|
||||
}
|
||||
Reference in New Issue
Block a user