diff --git a/device-plane/deployment/device-edge-core-channel-source-v1.json b/device-plane/deployment/device-edge-core-channel-source-v1.json new file mode 100644 index 0000000..174890e --- /dev/null +++ b/device-plane/deployment/device-edge-core-channel-source-v1.json @@ -0,0 +1,67 @@ +{ + "schemaVersion": "nodedc.device-edge.core-channel-source.v1", + "status": "source-accepted", + "authority": "DCPLATFORM-76/ADR-0001", + "designContract": "nodedc.device-edge.core-channel.v1", + "implementedAt": "2026-08-11", + "transport": { + "initiator": "device-gateway-core", + "listener": "device-edge-channel", + "protocol": "http2-bidirectional-ndjson", + "tls": "TLSv1.3-mutual-authentication", + "bearerAuthentication": false, + "genericTcpForwarding": false + }, + "sourceComponents": [ + "packages/device-edge-channel-contract", + "services/device-edge-channel", + "services/device-gateway-core" + ], + "identity": { + "edgeRegistrationRequired": true, + "edgeCertificatePinRequired": true, + "coreCertificateAllowlistRequired": true, + "unknownOrRevokedIdentity": "reject", + "privateKeysInSource": false, + "privateKeysInArtifact": false + }, + "messageBoundary": { + "schema": "nodedc.device-edge.channel-envelope.v1", + "maximumEnvelopeBytes": 1048576, + "directionLocalSequence": true, + "unknownSchemaOrKind": "close-logical-session", + "acceptanceWindow": 128, + "trackerAckRule": "core-acceptance-required" + }, + "realtime": { + "polling": false, + "keepaliveSeconds": 15, + "deadPeerSeconds": 45, + "reconnectMinimumSeconds": 1, + "reconnectMaximumSeconds": 30, + "delivery": "at-least-once-with-core-idempotency" + }, + "sourceAcceptance": { + "devicePlaneTestsPassed": 189, + "tls13MutualAuthenticationTested": true, + "keepaliveTested": true, + "disconnectReconnectTested": true, + "idempotentReplayTested": true, + "unknownAndRevokedIdentityTested": true, + "oversizedEnvelopeTested": true, + "coreUnavailableRejectionTested": true, + "existingCoreImageBuild": "passed-no-cache", + "existingGatewayImageBuild": "passed-no-cache" + }, + "runtime": { + "mutationInThisTransition": false, + "edgePort8443Published": false, + "trackerPort9921Published": false, + "synologyPublicIngress": false, + "trackerIngress": "disabled", + "commandTransport": "disabled", + "gelios": "untouched" + }, + "nextGate": "closed-port-synthetic-runtime-transition-under-DCPLATFORM-21", + "rollback": "source-revert-only-runtime-unchanged" +} diff --git a/device-plane/package-lock.json b/device-plane/package-lock.json index f6466c2..092d03f 100644 --- a/device-plane/package-lock.json +++ b/device-plane/package-lock.json @@ -31,6 +31,14 @@ "resolved": "services/device-control-core", "link": true }, + "node_modules/@nodedc/device-edge-channel": { + "resolved": "services/device-edge-channel", + "link": true + }, + "node_modules/@nodedc/device-edge-channel-contract": { + "resolved": "packages/device-edge-channel-contract", + "link": true + }, "node_modules/@nodedc/device-edge-relay": { "resolved": "services/device-edge-relay", "link": true @@ -39,6 +47,10 @@ "resolved": "services/device-gateway", "link": true }, + "node_modules/@nodedc/device-gateway-core": { + "resolved": "services/device-gateway-core", + "link": true + }, "node_modules/@nodedc/device-protocol-contract": { "resolved": "packages/device-protocol-contract", "link": true @@ -210,6 +222,13 @@ "node": ">=20" } }, + "packages/device-edge-channel-contract": { + "name": "@nodedc/device-edge-channel-contract", + "version": "0.1.0", + "engines": { + "node": ">=20" + } + }, "packages/device-protocol-contract": { "name": "@nodedc/device-protocol-contract", "version": "0.1.0", @@ -227,6 +246,13 @@ "node": ">=20" } }, + "services/device-edge-channel": { + "name": "@nodedc/device-edge-channel", + "version": "0.1.0", + "engines": { + "node": ">=20" + } + }, "services/device-edge-relay": { "name": "@nodedc/device-edge-relay", "version": "0.1.0", @@ -240,6 +266,13 @@ "engines": { "node": ">=20" } + }, + "services/device-gateway-core": { + "name": "@nodedc/device-gateway-core", + "version": "0.1.0", + "engines": { + "node": ">=20" + } } } } diff --git a/device-plane/packages/device-edge-channel-contract/package.json b/device-plane/packages/device-edge-channel-contract/package.json new file mode 100644 index 0000000..4b8c29d --- /dev/null +++ b/device-plane/packages/device-edge-channel-contract/package.json @@ -0,0 +1,12 @@ +{ + "name": "@nodedc/device-edge-channel-contract", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.mjs" + }, + "engines": { + "node": ">=20" + } +} diff --git a/device-plane/packages/device-edge-channel-contract/src/index.mjs b/device-plane/packages/device-edge-channel-contract/src/index.mjs new file mode 100644 index 0000000..16ab0a5 --- /dev/null +++ b/device-plane/packages/device-edge-channel-contract/src/index.mjs @@ -0,0 +1,347 @@ +export const DEVICE_EDGE_CHANNEL_SCHEMA = + "nodedc.device-edge.channel-envelope.v1"; + +export const DEVICE_EDGE_CHANNEL_PATH = + "/internal/v1/device-edge/channel"; + +export const DEVICE_EDGE_CHANNEL_LIMITS = Object.freeze({ + maxEnvelopeBytes: 1024 * 1024, + maxPendingAcceptances: 128, + keepaliveMs: 15_000, + deadPeerMs: 45_000, + reconnectMinimumMs: 1_000, + reconnectMaximumMs: 30_000, +}); + +export const EDGE_TO_CORE_MESSAGE_KINDS = Object.freeze([ + "channel.hello", + "channel.heartbeat", + "tracker.session-opened", + "tracker.session-closed", + "discovery.observed", + "adapter.message", + "delivery.acknowledged", + "channel.counters", +]); + +export const CORE_TO_EDGE_MESSAGE_KINDS = Object.freeze([ + "channel.accepted", + "channel.heartbeat", + "flow.window", + "session.disposition", + "event.accepted", + "event.rejected", +]); + +const OPAQUE_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/; +const forbiddenKeyFragments = Object.freeze([ + "password", + "secret", + "credential", + "authorization", + "privatekey", + "bearertoken", +]); + +export function normalizeChannelEnvelope(input, options = {}) { + assertPlainObject(input, "device_edge_channel_envelope"); + const direction = normalizeDirection(options.direction); + const maxEnvelopeBytes = normalizeLimit( + options.maxEnvelopeBytes, + DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes, + ); + const allowedKeys = new Set([ + "schemaVersion", + "edgeRegistrationId", + "channelGeneration", + "trackerSessionId", + "adapterProfileRef", + "sequence", + "eventAt", + "receivedAt", + "payloadBytes", + "messageKind", + "correlationId", + "payload", + ]); + rejectUnexpectedKeys(input, allowedKeys); + rejectForbiddenKeys(input); + + if (input.schemaVersion !== DEVICE_EDGE_CHANNEL_SCHEMA) { + throw new TypeError("device_edge_channel_schema_invalid"); + } + const allowedKinds = direction === "edge-to-core" + ? EDGE_TO_CORE_MESSAGE_KINDS + : CORE_TO_EDGE_MESSAGE_KINDS; + if (!allowedKinds.includes(input.messageKind)) { + throw new TypeError("device_edge_channel_message_kind_invalid"); + } + const sequence = Number(input.sequence); + if (!Number.isSafeInteger(sequence) || sequence < 1) { + throw new TypeError("device_edge_channel_sequence_invalid"); + } + assertJsonValue(input.payload, 0); + const payload = cloneJsonValue(input.payload); + const payloadBytes = Buffer.byteLength(JSON.stringify(payload), "utf8"); + if (payloadBytes > maxEnvelopeBytes) { + throw new TypeError("device_edge_channel_payload_too_large"); + } + if (Number(input.payloadBytes) !== payloadBytes) { + throw new TypeError("device_edge_channel_payload_length_mismatch"); + } + + const normalized = { + schemaVersion: DEVICE_EDGE_CHANNEL_SCHEMA, + edgeRegistrationId: normalizeRef( + input.edgeRegistrationId, + "edge_registration_id", + ), + channelGeneration: normalizeRef( + input.channelGeneration, + "channel_generation", + ), + trackerSessionId: normalizeRef( + input.trackerSessionId, + "tracker_session_id", + ), + adapterProfileRef: normalizeRef( + input.adapterProfileRef, + "adapter_profile_ref", + ), + sequence, + eventAt: normalizeTimestamp(input.eventAt, "event_at"), + receivedAt: normalizeTimestamp(input.receivedAt, "received_at"), + payloadBytes, + messageKind: input.messageKind, + correlationId: normalizeRef(input.correlationId, "correlation_id"), + payload, + }; + return deepFreeze(normalized); +} + +export function createChannelEnvelope(fields, options = {}) { + assertPlainObject(fields, "device_edge_channel_fields"); + const payload = fields.payload ?? {}; + assertJsonValue(payload, 0); + const clonedPayload = cloneJsonValue(payload); + return normalizeChannelEnvelope({ + ...fields, + schemaVersion: DEVICE_EDGE_CHANNEL_SCHEMA, + payloadBytes: Buffer.byteLength(JSON.stringify(clonedPayload), "utf8"), + payload: clonedPayload, + }, options); +} + +export function encodeChannelEnvelope(envelope, options = {}) { + const normalized = normalizeChannelEnvelope(envelope, options); + const encoded = Buffer.from(`${JSON.stringify(normalized)}\n`, "utf8"); + const maxEnvelopeBytes = normalizeLimit( + options.maxEnvelopeBytes, + DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes, + ); + if (encoded.length > maxEnvelopeBytes) { + throw new TypeError("device_edge_channel_envelope_too_large"); + } + return encoded; +} + +export function createChannelEnvelopeDecoder(options = {}) { + const direction = normalizeDirection(options.direction); + const maxEnvelopeBytes = normalizeLimit( + options.maxEnvelopeBytes, + DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes, + ); + let buffered = Buffer.alloc(0); + + return Object.freeze({ + push(chunk) { + if (!Buffer.isBuffer(chunk) && !(chunk instanceof Uint8Array)) { + throw new TypeError("device_edge_channel_chunk_invalid"); + } + let incoming = Buffer.from(chunk); + const envelopes = []; + while (incoming.length > 0) { + const newlineIndex = incoming.indexOf(0x0a); + if (newlineIndex < 0) { + if (buffered.length + incoming.length > maxEnvelopeBytes) { + throw new TypeError("device_edge_channel_envelope_too_large"); + } + buffered = buffered.length === 0 + ? incoming + : Buffer.concat([buffered, incoming]); + break; + } + if (buffered.length + newlineIndex === 0) { + throw new TypeError("device_edge_channel_envelope_empty"); + } + if (buffered.length + newlineIndex + 1 > maxEnvelopeBytes) { + throw new TypeError("device_edge_channel_envelope_too_large"); + } + const segment = incoming.subarray(0, newlineIndex); + const line = buffered.length === 0 + ? segment + : Buffer.concat([buffered, segment]); + buffered = Buffer.alloc(0); + incoming = incoming.subarray(newlineIndex + 1); + let parsed; + try { + parsed = JSON.parse(line.toString("utf8")); + } catch { + throw new TypeError("device_edge_channel_envelope_json_invalid"); + } + envelopes.push(normalizeChannelEnvelope(parsed, { + direction, + maxEnvelopeBytes, + })); + } + return envelopes; + }, + finish() { + if (buffered.length !== 0) { + throw new TypeError("device_edge_channel_envelope_truncated"); + } + }, + bufferedBytes() { + return buffered.length; + }, + }); +} + +export function normalizeCertificateFingerprint(value) { + const compact = String(value || "").replaceAll(":", "").toUpperCase(); + if (!/^[A-F0-9]{64}$/.test(compact)) { + throw new TypeError("device_edge_channel_certificate_fingerprint_invalid"); + } + return compact.match(/.{2}/g).join(":"); +} + +export function nextReconnectDelay(attempt, options = {}) { + const normalizedAttempt = Number(attempt); + if (!Number.isSafeInteger(normalizedAttempt) || normalizedAttempt < 0) { + throw new TypeError("device_edge_channel_reconnect_attempt_invalid"); + } + const minimumMs = normalizeReconnectDuration( + options.minimumMs, + DEVICE_EDGE_CHANNEL_LIMITS.reconnectMinimumMs, + ); + const maximumMs = normalizeReconnectDuration( + options.maximumMs, + DEVICE_EDGE_CHANNEL_LIMITS.reconnectMaximumMs, + ); + if (maximumMs < minimumMs) { + throw new TypeError("device_edge_channel_reconnect_range_invalid"); + } + const random = options.random ?? Math.random; + if (typeof random !== "function") { + throw new TypeError("device_edge_channel_random_invalid"); + } + const ceiling = Math.min(maximumMs, minimumMs * (2 ** normalizedAttempt)); + const floor = Math.max(minimumMs, Math.floor(ceiling / 2)); + const sample = Number(random()); + if (!Number.isFinite(sample) || sample < 0 || sample > 1) { + throw new TypeError("device_edge_channel_random_invalid"); + } + return Math.floor(floor + ((ceiling - floor) * sample)); +} + +function normalizeReconnectDuration(value, fallback) { + const number = value == null ? fallback : Number(value); + if (!Number.isSafeInteger(number) || number < 10 || number > 120_000) { + throw new TypeError("device_edge_channel_reconnect_duration_invalid"); + } + return number; +} + +function normalizeDirection(value) { + if (!['edge-to-core', 'core-to-edge'].includes(value)) { + throw new TypeError("device_edge_channel_direction_invalid"); + } + return value; +} + +function normalizeLimit(value, fallback) { + const number = value == null ? fallback : Number(value); + if (!Number.isSafeInteger(number) || number < 256 || number > 4 * 1024 * 1024) { + throw new TypeError("device_edge_channel_limit_invalid"); + } + return number; +} + +function normalizeRef(value, field) { + if (typeof value !== "string" || !OPAQUE_REF_RE.test(value)) { + throw new TypeError(`device_edge_channel_${field}_invalid`); + } + return value; +} + +function normalizeTimestamp(value, field) { + if (typeof value !== "string" || !ISO_TIMESTAMP_RE.test(value)) { + throw new TypeError(`device_edge_channel_${field}_invalid`); + } + const date = new Date(value); + if (!Number.isFinite(date.getTime())) { + throw new TypeError(`device_edge_channel_${field}_invalid`); + } + return date.toISOString(); +} + +function assertPlainObject(value, name) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError(`${name}_invalid`); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError(`${name}_invalid`); + } +} + +function rejectUnexpectedKeys(value, allowedKeys) { + for (const key of Object.keys(value)) { + if (!allowedKeys.has(key)) { + throw new TypeError(`device_edge_channel_field_unexpected:${key}`); + } + } +} + +function rejectForbiddenKeys(value, depth = 0) { + if (depth > 16 || value == null || typeof value !== "object") return; + for (const [key, child] of Object.entries(value)) { + const compact = key.toLowerCase().replaceAll(/[^a-z0-9]/g, ""); + if (forbiddenKeyFragments.some((fragment) => compact.includes(fragment))) { + throw new TypeError(`device_edge_channel_forbidden_field:${key}`); + } + rejectForbiddenKeys(child, depth + 1); + } +} + +function assertJsonValue(value, depth) { + if (depth > 16) { + throw new TypeError("device_edge_channel_payload_depth_invalid"); + } + if (value == null || typeof value === "string" || typeof value === "boolean") { + return; + } + if (typeof value === "number" && Number.isFinite(value)) return; + if (Array.isArray(value)) { + for (const child of value) assertJsonValue(child, depth + 1); + return; + } + assertPlainObject(value, "device_edge_channel_payload"); + for (const child of Object.values(value)) { + assertJsonValue(child, depth + 1); + } +} + +function cloneJsonValue(value) { + return JSON.parse(JSON.stringify(value)); +} + +function deepFreeze(value) { + if (!value || typeof value !== "object" || Object.isFrozen(value)) { + return value; + } + Object.freeze(value); + for (const child of Object.values(value)) deepFreeze(child); + return value; +} diff --git a/device-plane/packages/device-edge-channel-contract/test/contract.test.mjs b/device-plane/packages/device-edge-channel-contract/test/contract.test.mjs new file mode 100644 index 0000000..031083e --- /dev/null +++ b/device-plane/packages/device-edge-channel-contract/test/contract.test.mjs @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + createChannelEnvelope, + createChannelEnvelopeDecoder, + encodeChannelEnvelope, + nextReconnectDelay, + normalizeChannelEnvelope, +} from "../src/index.mjs"; + +const now = "2026-08-11T12:00:00.000Z"; + +function envelope(overrides = {}) { + return createChannelEnvelope({ + edgeRegistrationId: "edge:pilot-1", + channelGeneration: "generation:pilot-1", + trackerSessionId: "channel:control", + adapterProfileRef: "channel.control.v1", + sequence: 1, + eventAt: now, + receivedAt: now, + messageKind: "channel.hello", + correlationId: "correlation:hello-1", + payload: { status: "ready" }, + ...overrides, + }, { direction: "edge-to-core" }); +} + +test("round-trips a bounded versioned Edge envelope", () => { + const decoder = createChannelEnvelopeDecoder({ direction: "edge-to-core" }); + const encoded = encodeChannelEnvelope(envelope(), { + direction: "edge-to-core", + }); + const split = Math.floor(encoded.length / 2); + + assert.deepEqual(decoder.push(encoded.subarray(0, split)), []); + assert.deepEqual(decoder.push(encoded.subarray(split)), [envelope()]); + assert.equal(decoder.bufferedBytes(), 0); + decoder.finish(); +}); + +test("fails closed on unknown kinds, payload mismatches and oversized frames", () => { + const valid = envelope(); + assert.throws(() => normalizeChannelEnvelope({ + ...valid, + messageKind: "tcp.forward", + }, { direction: "edge-to-core" }), /message_kind_invalid/); + assert.throws(() => normalizeChannelEnvelope({ + ...valid, + payloadBytes: valid.payloadBytes + 1, + }, { direction: "edge-to-core" }), /payload_length_mismatch/); + + const decoder = createChannelEnvelopeDecoder({ + direction: "edge-to-core", + maxEnvelopeBytes: 256, + }); + assert.throws(() => decoder.push(Buffer.alloc(257, 0x61)), /envelope_too_large/); +}); + +test("uses bounded jittered exponential reconnect delays", () => { + assert.equal(nextReconnectDelay(0, { + minimumMs: 1000, + maximumMs: 30_000, + random: () => 1, + }), 1000); + assert.equal(nextReconnectDelay(5, { + minimumMs: 1000, + maximumMs: 30_000, + random: () => 1, + }), 30_000); + assert.equal(nextReconnectDelay(5, { + minimumMs: 1000, + maximumMs: 30_000, + random: () => 0, + }), 15_000); +}); diff --git a/device-plane/packages/device-protocol-contract/test/core-edge-channel-contract.test.mjs b/device-plane/packages/device-protocol-contract/test/core-edge-channel-contract.test.mjs index ea90b1e..02b171a 100644 --- a/device-plane/packages/device-protocol-contract/test/core-edge-channel-contract.test.mjs +++ b/device-plane/packages/device-protocol-contract/test/core-edge-channel-contract.test.mjs @@ -12,6 +12,10 @@ const freezeUrl = new URL( "../../../deployment/superseded-vps-initiated-transport-v1.json", import.meta.url, ); +const sourceAcceptanceUrl = new URL( + "../../../deployment/device-edge-core-channel-source-v1.json", + import.meta.url, +); const edgeBuilder = fileURLToPath(new URL( "../../../../infra/deploy-runner/build-device-edge-vps-artifact.mjs", import.meta.url, @@ -62,6 +66,22 @@ test("requires Core acceptance before acknowledging tracker packages", async () assert.ok(contract.pilotSlo.deadCoreDetectionHardCeilingSeconds <= 45); }); +test("records source acceptance without opening an Edge or tracker port", async () => { + const acceptance = await readJson(sourceAcceptanceUrl); + + assert.equal(acceptance.status, "source-accepted"); + assert.equal(acceptance.transport.initiator, "device-gateway-core"); + assert.equal(acceptance.transport.tls, "TLSv1.3-mutual-authentication"); + assert.equal(acceptance.identity.privateKeysInSource, false); + assert.equal(acceptance.identity.privateKeysInArtifact, false); + assert.equal(acceptance.runtime.mutationInThisTransition, false); + assert.equal(acceptance.runtime.edgePort8443Published, false); + assert.equal(acceptance.runtime.trackerPort9921Published, false); + assert.equal(acceptance.runtime.synologyPublicIngress, false); + assert.equal(acceptance.runtime.commandTransport, "disabled"); + assert.equal(acceptance.runtime.gelios, "untouched"); +}); + test("freezes the VPS-initiated Tailscale and SSH backhaul", async () => { const freeze = await readJson(freezeUrl); diff --git a/device-plane/services/device-edge-channel/package.json b/device-plane/services/device-edge-channel/package.json new file mode 100644 index 0000000..a12e550 --- /dev/null +++ b/device-plane/services/device-edge-channel/package.json @@ -0,0 +1,12 @@ +{ + "name": "@nodedc/device-edge-channel", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/runtime.mjs" + }, + "engines": { + "node": ">=20" + } +} diff --git a/device-plane/services/device-edge-channel/src/runtime.mjs b/device-plane/services/device-edge-channel/src/runtime.mjs new file mode 100644 index 0000000..53fe3d2 --- /dev/null +++ b/device-plane/services/device-edge-channel/src/runtime.mjs @@ -0,0 +1,495 @@ +import { randomUUID } 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); + 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: config.tls.key, + cert: config.tls.cert, + ca: config.tls.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", + commandTransport: "disabled", + }, { + 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 result.discovery; + }, + 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 acceptance; + }, + status() { + return Object.freeze({ + listening: started, + channel: active?.accepted ? "accepted" : active ? "negotiating" : "absent", + edgeRegistrationId: config.edgeRegistrationId, + channelGeneration: config.channelGeneration, + pendingAcceptances: pending.size, + channelsAccepted: totalChannelsAccepted, + channelsRejected: totalChannelsRejected, + eventsSubmitted: totalEventsSubmitted, + eventsAccepted: totalEventsAccepted, + eventsRejected: totalEventsRejected, + protocolFailures: totalProtocolFailures, + trackerIngress: "disabled", + commandTransport: "disabled", + }); + }, + 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") { + 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 config.tls.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 tls = normalizeTls(options.tls); + 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, + tls, + host: normalizeHost(options.host ?? "127.0.0.1"), + port: normalizePort(options.port ?? 8443), + 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 normalizeTls(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) { + throw new TypeError("device_edge_channel_core_identity_allowlist_invalid"); + } + return Object.freeze({ + key: value.key, + cert: value.cert, + ca: value.ca, + allowedCoreFingerprints: new Set( + value.allowedCoreFingerprints.map(normalizeCertificateFingerprint), + ), + }); +} + +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())); +} diff --git a/device-plane/services/device-edge-channel/test/channel-integration.test.mjs b/device-plane/services/device-edge-channel/test/channel-integration.test.mjs new file mode 100644 index 0000000..49e82e3 --- /dev/null +++ b/device-plane/services/device-edge-channel/test/channel-integration.test.mjs @@ -0,0 +1,449 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { randomUUID, X509Certificate } from "node:crypto"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { connect as connectHttp2 } from "node:http2"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, test } from "node:test"; + +import { + DEVICE_ADAPTER_ACCEPTANCE_SCHEMA, + DEVICE_ADAPTER_MESSAGE_SCHEMA, + DEVICE_DISCOVERY_SIGNAL_SCHEMA, +} from "../../../packages/device-protocol-contract/src/index.mjs"; +import { createDeviceGatewayCoreChannelClient } from + "../../device-gateway-core/src/runtime.mjs"; +import { createDeviceEdgeChannelServer } from "../src/runtime.mjs"; + +let fixtureDirectory; +let certificates; + +before(async () => { + fixtureDirectory = await mkdtemp(join(tmpdir(), "nodedc-edge-channel-")); + certificates = await generateCertificateFixture(fixtureDirectory); +}); + +after(async () => { + await rm(fixtureDirectory, { recursive: true, force: true }); +}); + +test("accepts synthetic discovery and durable message results over Core-initiated mTLS", async () => { + const acceptedMessages = new Map(); + let messageCalls = 0; + const pair = await startPair({ + acceptMessage: async (message) => { + messageCalls += 1; + const previous = acceptedMessages.get(message.idempotencyKey); + if (previous) return { ...previous, replayed: true }; + const acceptance = acceptanceFor(message, false); + acceptedMessages.set(message.idempotencyKey, acceptance); + return acceptance; + }, + }); + + try { + const discovery = await pair.edge.submitDiscovery(discoverySignal()); + assert.equal(discovery.lifecycleState, "quarantine"); + assert.equal(discovery.identifier.masked, "***********1088"); + + const message = adapterMessage(); + const first = await pair.edge.submitAdapterMessage(message); + const replay = await pair.edge.submitAdapterMessage(message); + assert.equal(first.replayed, false); + assert.equal(replay.replayed, true); + assert.equal(messageCalls, 2); + assert.equal(pair.edge.status().eventsAccepted, 3); + assert.equal(pair.core.status().eventsAccepted, 3); + assert.equal(pair.edge.status().trackerIngress, "disabled"); + assert.equal(pair.core.status().commandTransport, "disabled"); + } finally { + await stopPair(pair); + } +}); + +test("keeps the channel alive and reconnects without losing idempotency", async () => { + const acceptedMessages = new Map(); + const pair = await startPair({ + keepaliveMs: 20, + deadPeerMs: 60, + reconnectMinimumMs: 20, + reconnectMaximumMs: 40, + acceptMessage: async (message) => { + const previous = acceptedMessages.get(message.idempotencyKey); + if (previous) return { ...previous, replayed: true }; + const acceptance = acceptanceFor(message, false); + acceptedMessages.set(message.idempotencyKey, acceptance); + return acceptance; + }, + }); + + try { + await delay(120); + assert.equal(pair.edge.status().channel, "accepted"); + const message = adapterMessage(); + assert.equal((await pair.edge.submitAdapterMessage(message)).replayed, false); + + pair.edge.disconnectActiveChannel(); + await waitFor(() => pair.core.status().connectionAttempts >= 2 + && pair.edge.status().channel === "accepted", 2_000); + assert.equal((await pair.edge.submitAdapterMessage(message)).replayed, true); + assert.ok(pair.core.status().reconnects >= 1); + } finally { + await stopPair(pair); + } +}); + +test("rejects a revoked Edge registration before opening a channel", async () => { + const edge = createEdgeServer(); + const address = await edge.start(); + const core = createCoreClient({ + address, + lifecycleState: "revoked", + }); + try { + await core.start(); + await waitFor(() => core.status().connectionAttempts >= 1, 500); + assert.equal(core.status().channel, "absent"); + assert.equal(edge.status().channelsAccepted, 0); + assert.match(core.status().lastErrorCode, /registration_inactive/); + } finally { + await core.stop(); + await edge.stop(); + } +}); + +test("rejects an authenticated but non-allowlisted Core certificate", async () => { + const edge = createEdgeServer(); + const address = await edge.start(); + const core = createCoreClient({ + address, + clientCertificate: certificates.intruder, + }); + try { + await core.start(); + await waitFor(() => edge.status().channelsRejected >= 1, 2_000); + assert.equal(edge.status().channelsAccepted, 0); + assert.equal(edge.status().channel, "absent"); + } finally { + await core.stop(); + await edge.stop(); + } +}); + +test("rejects an unknown Edge certificate fingerprint", async () => { + const edge = createEdgeServer(); + const address = await edge.start(); + const core = createCoreClient({ + address, + expectedEdgeFingerprint: certificates.intruder.fingerprint, + }); + try { + await core.start(); + await waitFor(() => core.status().protocolFailures >= 1, 2_000); + assert.equal(core.status().channelsAccepted, 0); + assert.match(core.status().lastErrorCode, /edge_identity_mismatch/); + } finally { + await core.stop(); + await edge.stop(); + } +}); + +test("returns a conclusive rejection when Core cannot accept a package", async () => { + const pair = await startPair({ + acceptMessage: async () => { + throw new Error("device_control_core_unavailable"); + }, + }); + try { + await assert.rejects( + pair.edge.submitAdapterMessage(adapterMessage()), + /device_control_core_unavailable/, + ); + assert.equal(pair.edge.status().eventsRejected, 1); + assert.equal(pair.core.status().eventsRejected, 1); + } finally { + await stopPair(pair); + } +}); + +test("closes the logical session on an unknown message kind", async () => { + const edge = createEdgeServer(); + const address = await edge.start(); + const session = connectHttp2(`https://127.0.0.1:${address.port}`, { + key: certificates.core.key, + cert: certificates.core.cert, + ca: certificates.ca, + servername: "localhost", + minVersion: "TLSv1.3", + maxVersion: "TLSv1.3", + rejectUnauthorized: true, + }); + try { + await onceEvent(session, "connect"); + const request = session.request({ + ":method": "POST", + ":path": "/internal/v1/device-edge/channel", + }, { endStream: false }); + const hello = await readFirstEnvelope(request); + const invalid = { + schemaVersion: hello.schemaVersion, + edgeRegistrationId: hello.edgeRegistrationId, + channelGeneration: hello.channelGeneration, + trackerSessionId: "channel:control", + adapterProfileRef: "channel.control.v1", + sequence: 1, + eventAt: new Date().toISOString(), + receivedAt: new Date().toISOString(), + payloadBytes: 2, + messageKind: "tcp.forward", + correlationId: `correlation:${randomUUID()}`, + payload: {}, + }; + request.write(`${JSON.stringify(invalid)}\n`); + await waitFor(() => edge.status().protocolFailures >= 1, 1_000); + assert.equal(edge.status().channel, "absent"); + request.close(); + } finally { + session.close(); + await edge.stop(); + } +}); + +async function startPair(options = {}) { + const edge = createEdgeServer(options); + const address = await edge.start(); + const core = createCoreClient({ address, ...options }); + await core.start(); + await core.waitForReady(2_000); + await waitFor(() => edge.status().channel === "accepted", 2_000); + return { edge, core }; +} + +function createEdgeServer(options = {}) { + return createDeviceEdgeChannelServer({ + edgeRegistrationId: "edge:pilot-1", + channelGeneration: "generation:pilot-1", + host: "127.0.0.1", + port: 0, + tls: { + key: certificates.edge.key, + cert: certificates.edge.cert, + ca: certificates.ca, + allowedCoreFingerprints: [certificates.core.fingerprint], + }, + keepaliveMs: options.keepaliveMs ?? 50, + deadPeerMs: options.deadPeerMs ?? 150, + acceptanceTimeoutMs: 500, + }); +} + +function createCoreClient(options) { + const clientCertificate = options.clientCertificate ?? certificates.core; + return createDeviceGatewayCoreChannelClient({ + registration: { + edgeRegistrationId: "edge:pilot-1", + endpoint: `https://127.0.0.1:${options.address.port}/`, + servername: "localhost", + certificateFingerprint: options.expectedEdgeFingerprint + ?? certificates.edge.fingerprint, + lifecycleState: options.lifecycleState ?? "active", + }, + tls: { + key: clientCertificate.key, + cert: clientCertificate.cert, + ca: certificates.ca, + }, + coreIdentity: "workload:device-gateway-core", + keepaliveMs: options.keepaliveMs ?? 50, + deadPeerMs: options.deadPeerMs ?? 150, + reconnectMinimumMs: options.reconnectMinimumMs ?? 20, + reconnectMaximumMs: options.reconnectMaximumMs ?? 80, + random: () => 0, + observeDiscovery: options.observeDiscovery ?? (async () => ({ + schemaVersion: "nodedc.device.discovery-view.v1", + lifecycleState: "quarantine", + commandTransport: "disabled", + identifier: { kind: "imei", masked: "***********1088" }, + })), + acceptMessage: options.acceptMessage ?? (async (message) => + acceptanceFor(message, false)), + }); +} + +async function stopPair(pair) { + await pair.core.stop(); + await pair.edge.stop(); +} + +function discoverySignal() { + return { + schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA, + sessionRef: "session:pilot-1", + modelProfileRef: "arusnavi.internal.b2.v1", + protocol: "INTERNAL", + observedAt: new Date().toISOString(), + identifier: { kind: "imei", value: "863151070211088" }, + evidence: { + transport: "tcp", + bytesObserved: 10, + framingStatus: "verified", + specificationRef: "arusnavi.internal.protocol.v1", + }, + }; +} + +function adapterMessage() { + return { + schemaVersion: DEVICE_ADAPTER_MESSAGE_SCHEMA, + edgeRef: "edge:pilot-1", + adapterRef: "arusnavi-b2", + protocolProfileRef: "arusnavi.internal.b2.v1", + protocol: "INTERNAL", + sessionRef: "session:pilot-1", + messageRef: "message:pilot-1", + messageType: "telemetry.package", + sequence: 1, + observedAt: new Date().toISOString(), + idempotencyKey: `sha256:${"a".repeat(64)}`, + identifier: { kind: "imei", value: "863151070211088" }, + payloadSchemaRef: "arusnavi.internal.package-metadata.v1", + payload: { + packageNumber: 1, + packetCount: 1, + byteLength: 11, + packageDigest: `sha256:${"b".repeat(64)}`, + }, + }; +} + +function acceptanceFor(message, replayed) { + return { + schemaVersion: DEVICE_ADAPTER_ACCEPTANCE_SCHEMA, + acceptanceRef: "acceptance:pilot-1", + idempotencyKey: message.idempotencyKey, + status: "accepted", + replayed, + acceptedAt: "2026-08-11T12:00:00.000Z", + }; +} + +async function generateCertificateFixture(directory) { + runOpenSsl(directory, [ + "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-sha256", + "-days", "1", "-subj", "/CN=NODEDC Test Device Edge CA", + "-addext", "basicConstraints=critical,CA:TRUE", + "-addext", "keyUsage=critical,keyCertSign,cRLSign", + "-keyout", "ca.key", "-out", "ca.crt", + ]); + await issueCertificate(directory, "edge", "localhost", [ + "subjectAltName=DNS:localhost,IP:127.0.0.1", + "extendedKeyUsage=serverAuth", + ]); + await issueCertificate(directory, "core", "nodedc-device-gateway-core", [ + "extendedKeyUsage=clientAuth", + ]); + await issueCertificate(directory, "intruder", "unapproved-core", [ + "extendedKeyUsage=clientAuth", + ]); + const ca = await readFile(join(directory, "ca.crt")); + return { + ca, + edge: await readCertificate(directory, "edge"), + core: await readCertificate(directory, "core"), + intruder: await readCertificate(directory, "intruder"), + }; +} + +async function issueCertificate(directory, name, commonName, extensions) { + runOpenSsl(directory, [ + "req", "-newkey", "rsa:2048", "-nodes", "-sha256", + "-subj", `/CN=${commonName}`, + "-keyout", `${name}.key`, "-out", `${name}.csr`, + ]); + const extensionFile = `${name}.ext`; + await writeFile(join(directory, extensionFile), [ + "basicConstraints=critical,CA:FALSE", + "keyUsage=critical,digitalSignature,keyEncipherment", + ...extensions, + ].join("\n")); + runOpenSsl(directory, [ + "x509", "-req", "-sha256", "-days", "1", + "-in", `${name}.csr`, "-CA", "ca.crt", "-CAkey", "ca.key", + "-CAcreateserial", "-extfile", extensionFile, "-out", `${name}.crt`, + ]); +} + +async function readCertificate(directory, name) { + const cert = await readFile(join(directory, `${name}.crt`)); + return { + key: await readFile(join(directory, `${name}.key`)), + cert, + fingerprint: new X509Certificate(cert).fingerprint256, + }; +} + +function runOpenSsl(directory, arguments_) { + const result = spawnSync("openssl", arguments_, { + cwd: directory, + encoding: "utf8", + }); + if (result.status !== 0) { + throw new Error(`openssl_failed:${result.stderr}`); + } +} + +function readFirstEnvelope(stream) { + return new Promise((resolve, reject) => { + let buffered = ""; + const onData = (chunk) => { + buffered += chunk.toString("utf8"); + const newline = buffered.indexOf("\n"); + if (newline < 0) return; + cleanup(); + resolve(JSON.parse(buffered.slice(0, newline))); + }; + const onError = (error) => { + cleanup(); + reject(error); + }; + const cleanup = () => { + stream.off("data", onData); + stream.off("error", onError); + }; + stream.on("data", onData); + stream.once("error", onError); + }); +} + +function onceEvent(emitter, event) { + return new Promise((resolve, reject) => { + const onEvent = (...args) => { + cleanup(); + resolve(args); + }; + const onError = (error) => { + cleanup(); + reject(error); + }; + const cleanup = () => { + emitter.off(event, onEvent); + emitter.off("error", onError); + }; + emitter.once(event, onEvent); + emitter.once("error", onError); + }); +} + +async function waitFor(predicate, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + await delay(10); + } + assert.fail("condition_timeout"); +} + +function delay(milliseconds) { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} diff --git a/device-plane/services/device-gateway-core/package.json b/device-plane/services/device-gateway-core/package.json new file mode 100644 index 0000000..6109d2e --- /dev/null +++ b/device-plane/services/device-gateway-core/package.json @@ -0,0 +1,12 @@ +{ + "name": "@nodedc/device-gateway-core", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/runtime.mjs" + }, + "engines": { + "node": ">=20" + } +} diff --git a/device-plane/services/device-gateway-core/src/runtime.mjs b/device-plane/services/device-gateway-core/src/runtime.mjs new file mode 100644 index 0000000..c24092c --- /dev/null +++ b/device-plane/services/device-gateway-core/src/runtime.mjs @@ -0,0 +1,565 @@ +import { randomUUID } from "node:crypto"; +import { connect as connectHttp2 } from "node:http2"; + +import { + DEVICE_EDGE_CHANNEL_LIMITS, + DEVICE_EDGE_CHANNEL_PATH, + createChannelEnvelope, + createChannelEnvelopeDecoder, + encodeChannelEnvelope, + nextReconnectDelay, + normalizeCertificateFingerprint, +} from "../../../packages/device-edge-channel-contract/src/index.mjs"; +import { + 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 createDeviceGatewayCoreChannelClient(options = {}) { + const config = normalizeConfig(options); + const readyWaiters = new Set(); + let running = false; + let state = null; + let reconnectTimer = null; + let reconnectAttempt = 0; + let connectionSerial = 0; + let totalConnectionAttempts = 0; + let totalChannelsAccepted = 0; + let totalReconnects = 0; + let totalEventsAccepted = 0; + let totalEventsRejected = 0; + let totalProtocolFailures = 0; + let lastErrorCode = null; + + return Object.freeze({ + async start() { + if (running) return; + running = true; + void connectNow(); + }, + async stop() { + running = false; + clearTimeout(reconnectTimer); + reconnectTimer = null; + const current = state; + state = null; + if (current) closeConnection(current, false); + rejectReadyWaiters("device_gateway_core_channel_stopped"); + }, + waitForReady(timeoutMs = 5_000) { + if (state?.ready && !state.closed) return Promise.resolve(status()); + const normalizedTimeout = normalizeInteger( + timeoutMs, + 10, + 120_000, + 5_000, + "ready_timeout", + ); + return new Promise((resolve, reject) => { + const waiter = { resolve, reject, timer: null }; + waiter.timer = setTimeout(() => { + readyWaiters.delete(waiter); + reject(new Error("device_gateway_core_channel_ready_timeout")); + }, normalizedTimeout); + waiter.timer.unref?.(); + readyWaiters.add(waiter); + }); + }, + status, + disconnect() { + if (state) closeConnection(state, true); + }, + }); + + function status() { + return Object.freeze({ + running, + channel: state?.ready ? "accepted" : state ? "connecting" : "absent", + edgeRegistrationId: state?.registration?.edgeRegistrationId ?? null, + channelGeneration: state?.channelGeneration ?? null, + connectionAttempts: totalConnectionAttempts, + channelsAccepted: totalChannelsAccepted, + reconnects: totalReconnects, + eventsAccepted: totalEventsAccepted, + eventsRejected: totalEventsRejected, + protocolFailures: totalProtocolFailures, + lastErrorCode, + trackerIngress: "remote-edge-only", + commandTransport: "disabled", + }); + } + + async function connectNow() { + if (!running || state) return; + totalConnectionAttempts += 1; + const serial = ++connectionSerial; + let registration; + try { + registration = normalizeRegistration(await config.registrationProvider()); + if (registration.lifecycleState !== "active") { + throw new Error("device_gateway_core_edge_registration_inactive"); + } + } catch (error) { + lastErrorCode = safeErrorCode(error); + scheduleReconnect(); + return; + } + + const connection = { + serial, + registration, + session: null, + request: null, + decoder: createChannelEnvelopeDecoder({ + direction: "edge-to-core", + maxEnvelopeBytes: config.maxEnvelopeBytes, + }), + channelGeneration: null, + edgeSequence: 0, + coreSequence: 0, + lastEdgeActivityAt: config.clock(), + heartbeatTimer: null, + ready: false, + closed: false, + }; + state = connection; + const endpoint = new URL(registration.endpoint); + const authority = `${endpoint.protocol}//${endpoint.host}`; + const session = connectHttp2(authority, { + key: config.tls.key, + cert: config.tls.cert, + ca: config.tls.ca, + minVersion: "TLSv1.3", + maxVersion: "TLSv1.3", + rejectUnauthorized: true, + servername: registration.servername, + ALPNProtocols: ["h2"], + settings: { + enablePush: false, + initialWindowSize: 1024 * 1024, + }, + }); + connection.session = session; + session.once("error", (error) => failConnection(connection, error)); + session.once("close", () => closeConnection(connection, true)); + session.once("connect", () => { + try { + verifyEdgePeer(connection); + openChannelStream(connection); + } catch (error) { + failConnection(connection, error); + } + }); + } + + function openChannelStream(connection) { + assertCurrent(connection); + const request = connection.session.request({ + ":method": "POST", + ":path": DEVICE_EDGE_CHANNEL_PATH, + "content-type": "application/x-ndjson", + "cache-control": "no-store", + }, { endStream: false }); + connection.request = request; + request.once("response", (headers) => { + if (Number(headers[":status"]) !== 200) { + failConnection(connection, new Error( + `device_gateway_core_channel_http_status_${headers[":status"]}`, + )); + } + }); + let processing = Promise.resolve(); + request.on("data", (chunk) => { + request.pause(); + processing = processing + .then(async () => { + const envelopes = connection.decoder.push(chunk); + for (const envelope of envelopes) { + await handleEdgeEnvelope(connection, envelope); + } + }) + .catch((error) => failConnection(connection, error)) + .finally(() => { + if (!connection.closed) request.resume(); + }); + }); + request.once("aborted", () => closeConnection(connection, true)); + request.once("close", () => closeConnection(connection, true)); + request.once("error", (error) => failConnection(connection, error)); + connection.heartbeatTimer = setInterval( + () => checkChannelHealth(connection), + config.keepaliveMs, + ); + connection.heartbeatTimer.unref?.(); + } + + async function handleEdgeEnvelope(connection, envelope) { + assertCurrent(connection); + if ( + envelope.edgeRegistrationId !== connection.registration.edgeRegistrationId + || envelope.sequence !== connection.edgeSequence + 1 + ) { + throw new Error("device_gateway_core_edge_envelope_mismatch"); + } + if ( + connection.channelGeneration + && envelope.channelGeneration !== connection.channelGeneration + ) { + throw new Error("device_gateway_core_channel_generation_mismatch"); + } + connection.edgeSequence = envelope.sequence; + connection.lastEdgeActivityAt = config.clock(); + + if (!connection.ready) { + if (envelope.messageKind !== "channel.hello") { + throw new Error("device_gateway_core_channel_hello_required"); + } + if ( + envelope.payload?.status !== "ready" + || envelope.payload?.transport !== "http2-mtls" + || envelope.payload?.commandTransport !== "disabled" + ) { + throw new Error("device_gateway_core_channel_hello_invalid"); + } + connection.channelGeneration = envelope.channelGeneration; + send(connection, "channel.accepted", { + status: "accepted", + coreIdentity: config.coreIdentity, + commandTransport: "disabled", + }, { + trackerSessionId: CHANNEL_TRACKER_SESSION_ID, + adapterProfileRef: CHANNEL_PROFILE_REF, + correlationId: envelope.correlationId, + }); + connection.ready = true; + reconnectAttempt = 0; + totalChannelsAccepted += 1; + lastErrorCode = null; + resolveReadyWaiters(); + return; + } + if (envelope.messageKind === "channel.heartbeat") return; + if (envelope.messageKind === "discovery.observed") { + await acceptDiscovery(connection, envelope); + return; + } + if (envelope.messageKind === "adapter.message") { + await acceptAdapterMessage(connection, envelope); + return; + } + throw new Error("device_gateway_core_edge_message_unhandled"); + } + + async function acceptDiscovery(connection, envelope) { + try { + const signal = normalizeDiscoverySignal(envelope.payload?.signal); + const discovery = await config.observeDiscovery(signal); + sendEventResult(connection, envelope, { discovery }); + totalEventsAccepted += 1; + } catch (error) { + sendEventRejection(connection, envelope, error); + totalEventsRejected += 1; + } + } + + async function acceptAdapterMessage(connection, envelope) { + try { + const message = normalizeAdapterMessage(envelope.payload?.message, { + maxBytes: config.maxEnvelopeBytes, + }); + const acceptance = await config.acceptMessage(message); + sendEventResult(connection, envelope, { acceptance }); + totalEventsAccepted += 1; + } catch (error) { + sendEventRejection(connection, envelope, error); + totalEventsRejected += 1; + } + } + + function sendEventResult(connection, envelope, result) { + send(connection, "event.accepted", { result }, { + trackerSessionId: envelope.trackerSessionId, + adapterProfileRef: envelope.adapterProfileRef, + correlationId: envelope.correlationId, + }); + } + + function sendEventRejection(connection, envelope, error) { + send(connection, "event.rejected", { + errorCode: safeErrorCode(error), + }, { + trackerSessionId: envelope.trackerSessionId, + adapterProfileRef: envelope.adapterProfileRef, + correlationId: envelope.correlationId, + }); + } + + function send(connection, messageKind, payload, metadata) { + assertCurrent(connection); + if (!connection.channelGeneration) { + throw new Error("device_gateway_core_channel_generation_absent"); + } + connection.coreSequence += 1; + const now = config.now(); + const envelope = createChannelEnvelope({ + edgeRegistrationId: connection.registration.edgeRegistrationId, + channelGeneration: connection.channelGeneration, + trackerSessionId: metadata.trackerSessionId, + adapterProfileRef: metadata.adapterProfileRef, + sequence: connection.coreSequence, + eventAt: metadata.eventAt ?? now, + receivedAt: now, + messageKind, + correlationId: metadata.correlationId, + payload, + }, { + direction: "core-to-edge", + maxEnvelopeBytes: config.maxEnvelopeBytes, + }); + connection.request.write(encodeChannelEnvelope(envelope, { + direction: "core-to-edge", + maxEnvelopeBytes: config.maxEnvelopeBytes, + })); + } + + function checkChannelHealth(connection) { + if (connection.closed || state !== connection) return; + if (config.clock() - connection.lastEdgeActivityAt >= config.deadPeerMs) { + failConnection(connection, new Error("device_gateway_core_edge_dead_peer")); + return; + } + if (connection.ready) { + try { + send(connection, "channel.heartbeat", { status: "alive" }, { + trackerSessionId: CHANNEL_TRACKER_SESSION_ID, + adapterProfileRef: CHANNEL_PROFILE_REF, + correlationId: `correlation:${randomUUID()}`, + }); + } catch (error) { + failConnection(connection, error); + } + } + } + + function verifyEdgePeer(connection) { + const socket = connection.session.socket; + if (!socket?.authorized || socket.alpnProtocol !== "h2") { + throw new Error("device_gateway_core_edge_tls_unauthorized"); + } + const observed = normalizeCertificateFingerprint( + socket.getPeerCertificate()?.fingerprint256, + ); + if (observed !== connection.registration.certificateFingerprint) { + throw new Error("device_gateway_core_edge_identity_mismatch"); + } + } + + function failConnection(connection, error) { + if (connection.closed) return; + totalProtocolFailures += 1; + lastErrorCode = safeErrorCode(error); + closeConnection(connection, true); + } + + function closeConnection(connection, reconnect) { + if (connection.closed) return; + connection.closed = true; + clearInterval(connection.heartbeatTimer); + connection.heartbeatTimer = null; + try { + connection.request?.close(); + } catch {} + try { + connection.session?.close(); + } catch {} + if (state === connection) state = null; + if (reconnect && running) scheduleReconnect(); + } + + function scheduleReconnect() { + if (!running || reconnectTimer || state) return; + const delay = nextReconnectDelay(reconnectAttempt, { + minimumMs: config.reconnectMinimumMs, + maximumMs: config.reconnectMaximumMs, + random: config.random, + }); + reconnectAttempt += 1; + totalReconnects += 1; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + void connectNow(); + }, delay); + reconnectTimer.unref?.(); + } + + function assertCurrent(connection) { + if (!running || connection.closed || state !== connection) { + throw new Error("device_gateway_core_channel_unavailable"); + } + } + + function resolveReadyWaiters() { + const value = status(); + for (const waiter of readyWaiters) { + clearTimeout(waiter.timer); + waiter.resolve(value); + } + readyWaiters.clear(); + } + + function rejectReadyWaiters(code) { + for (const waiter of readyWaiters) { + clearTimeout(waiter.timer); + waiter.reject(new Error(code)); + } + readyWaiters.clear(); + } +} + +function normalizeConfig(options) { + if (typeof options.observeDiscovery !== "function") { + throw new TypeError("device_gateway_core_observe_discovery_invalid"); + } + if (typeof options.acceptMessage !== "function") { + throw new TypeError("device_gateway_core_accept_message_invalid"); + } + const registrationProvider = typeof options.registrationProvider === "function" + ? options.registrationProvider + : async () => options.registration; + const tls = normalizeTls(options.tls); + const keepaliveMs = normalizeInteger( + options.keepaliveMs, + 10, + 120_000, + DEVICE_EDGE_CHANNEL_LIMITS.keepaliveMs, + "keepalive", + ); + const deadPeerMs = normalizeInteger( + options.deadPeerMs, + keepaliveMs * 2, + 120_000, + DEVICE_EDGE_CHANNEL_LIMITS.deadPeerMs, + "dead_peer", + ); + const reconnectMinimumMs = normalizeInteger( + options.reconnectMinimumMs, + 10, + 120_000, + DEVICE_EDGE_CHANNEL_LIMITS.reconnectMinimumMs, + "reconnect_minimum", + ); + const reconnectMaximumMs = normalizeInteger( + options.reconnectMaximumMs, + 10, + 120_000, + DEVICE_EDGE_CHANNEL_LIMITS.reconnectMaximumMs, + "reconnect_maximum", + ); + if (reconnectMaximumMs < reconnectMinimumMs) { + throw new TypeError("device_gateway_core_reconnect_range_invalid"); + } + return Object.freeze({ + registrationProvider, + tls, + coreIdentity: normalizeRef(options.coreIdentity, "core_identity"), + observeDiscovery: options.observeDiscovery, + acceptMessage: options.acceptMessage, + keepaliveMs, + deadPeerMs, + reconnectMinimumMs, + reconnectMaximumMs, + maxEnvelopeBytes: normalizeInteger( + options.maxEnvelopeBytes, + 256, + DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes, + DEVICE_EDGE_CHANNEL_LIMITS.maxEnvelopeBytes, + "max_envelope_bytes", + ), + random: typeof options.random === "function" ? options.random : Math.random, + clock: typeof options.clock === "function" ? options.clock : Date.now, + now: typeof options.now === "function" + ? () => new Date(options.now()).toISOString() + : () => new Date().toISOString(), + }); +} + +function normalizeRegistration(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError("device_gateway_core_edge_registration_invalid"); + } + let endpoint; + try { + endpoint = new URL(String(value.endpoint || "")); + } catch { + throw new TypeError("device_gateway_core_edge_endpoint_invalid"); + } + if ( + endpoint.protocol !== "https:" + || endpoint.username + || endpoint.password + || endpoint.pathname !== "/" + || endpoint.search + || endpoint.hash + ) { + throw new TypeError("device_gateway_core_edge_endpoint_invalid"); + } + if (!["active", "revoked", "disabled"].includes(value.lifecycleState)) { + throw new TypeError("device_gateway_core_edge_lifecycle_invalid"); + } + const servername = String(value.servername || ""); + if (!/^[A-Za-z0-9.-]{1,253}$/.test(servername)) { + throw new TypeError("device_gateway_core_edge_servername_invalid"); + } + return Object.freeze({ + edgeRegistrationId: normalizeRef( + value.edgeRegistrationId, + "edge_registration_id", + ), + endpoint: endpoint.toString(), + servername, + certificateFingerprint: normalizeCertificateFingerprint( + value.certificateFingerprint, + ), + lifecycleState: value.lifecycleState, + }); +} + +function normalizeTls(value) { + if (!value || typeof value !== "object") { + throw new TypeError("device_gateway_core_channel_tls_invalid"); + } + for (const key of ["key", "cert", "ca"]) { + if (!(typeof value[key] === "string" || Buffer.isBuffer(value[key]))) { + throw new TypeError(`device_gateway_core_channel_tls_${key}_invalid`); + } + } + return Object.freeze({ key: value.key, cert: value.cert, ca: value.ca }); +} + +function safeErrorCode(error) { + const value = String(error?.message || error || "device_gateway_core_error") + .toLowerCase() + .replaceAll(/[^a-z0-9._:-]/g, "_") + .slice(0, 128); + return /^[a-z][a-z0-9._:-]{2,127}$/.test(value) + ? value + : "device_gateway_core_event_rejected"; +} + +function normalizeRef(value, field) { + if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)) { + throw new TypeError(`device_gateway_core_${field}_invalid`); + } + return value; +} + +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_gateway_core_${field}_invalid`); + } + return number; +}