From 2b1795509b95e6013b41d876b468e42134a3d047 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 26 Jul 2026 00:52:31 +0300 Subject: [PATCH] feat(device-plane): enable B2 discovery ingress --- .../device-plane-b2-discovery-ingress-v1.json | 30 ++ device-plane/docker-compose.device-plane.yml | 38 +- device-plane/docs/IMPLEMENTATION_BASELINE.md | 69 ++- .../arusnavi-b2-adapter/src/index.mjs | 214 +++++++- .../arusnavi-b2-adapter/test/adapter.test.mjs | 110 +++- .../device-gateway/src/core-client.mjs | 75 +++ .../services/device-gateway/src/runtime.mjs | 248 +++++++-- .../services/device-gateway/src/server.mjs | 119 ++++- .../device-gateway/test/core-client.test.mjs | 60 +++ .../test/discovery-integration.test.mjs | 112 ++++ .../device-gateway/test/runtime.test.mjs | 131 ++++- .../build-device-plane-artifact.mjs | 9 +- ...ce-plane-b2-discovery-ingress-artifact.mjs | 270 ++++++++++ ...oundation-network-publication-artifact.mjs | 11 +- ...lane-foundation-network-publication-v1.yml | 126 +++++ infra/deploy-runner/nodedc-deploy | 494 +++++++++++++++++- ...ice_plane_b2_discovery_ingress_artifact.py | 400 ++++++++++++++ ...foundation_network_publication_artifact.py | 4 +- ...vice_plane_foundation_recovery_artifact.py | 11 +- 19 files changed, 2375 insertions(+), 156 deletions(-) create mode 100644 device-plane/deployment/device-plane-b2-discovery-ingress-v1.json create mode 100644 device-plane/services/device-gateway/src/core-client.mjs create mode 100644 device-plane/services/device-gateway/test/core-client.test.mjs create mode 100644 device-plane/services/device-gateway/test/discovery-integration.test.mjs create mode 100644 infra/deploy-runner/build-device-plane-b2-discovery-ingress-artifact.mjs create mode 100644 infra/deploy-runner/fixtures/device-plane-foundation-network-publication-v1.yml create mode 100644 infra/deploy-runner/test_device_plane_b2_discovery_ingress_artifact.py diff --git a/device-plane/deployment/device-plane-b2-discovery-ingress-v1.json b/device-plane/deployment/device-plane-b2-discovery-ingress-v1.json new file mode 100644 index 0000000..d594f6e --- /dev/null +++ b/device-plane/deployment/device-plane-b2-discovery-ingress-v1.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": "nodedc.device-plane.b2-discovery-ingress.v1", + "mode": "verified-b2-discovery-only", + "predecessorPatchId": "device-plane-foundation-network-publication-20260725-003", + "predecessorArtifactSha256": "6fdd5a12c310786db1753882fc1378184fe378d2cc533633a8c73c951521b7bf", + "sourceAction": "publish-verified-b2-discovery-ingress-source", + "runtimeAction": "build-and-recreate-stateless-services", + "selectedServices": [ + "device-control-core", + "device-gateway" + ], + "preservedServices": [ + "device-postgres" + ], + "privateNetwork": "nodedc-device-plane-private", + "controlNetwork": "nodedc-device-plane-control", + "publishedPorts": [ + "127.0.0.1:18120:18120", + "127.0.0.1:18121:18121", + "0.0.0.0:9921:9921/tcp" + ], + "protocolProfile": "arusnavi.b2.internal.v1", + "framingSpecification": "arusnavi.internal.protocol-sheet.gid-12.v1", + "identityTrust": "claimed-not-ownership-proof", + "discoveryLifecycle": "quarantine", + "commandTransport": "disabled", + "gelios": "untouched", + "databaseVolume": "nodedc-device-plane-postgres-data", + "rollback": "restore-source-and-predecessor-stateless-runtime" +} diff --git a/device-plane/docker-compose.device-plane.yml b/device-plane/docker-compose.device-plane.yml index 4943189..4f080b5 100644 --- a/device-plane/docker-compose.device-plane.yml +++ b/device-plane/docker-compose.device-plane.yml @@ -43,7 +43,9 @@ services: DEVICE_DATABASE_USER: device_plane DEVICE_DATABASE_PASSWORD_FILE: /run/nodedc-secrets/postgres-password DEVICE_DATABASE_POOL_SIZE: "10" - DEVICE_DISCOVERY_INGEST_ENABLED: "false" + DEVICE_DISCOVERY_INGEST_ENABLED: "true" + DEVICE_GATEWAY_CORE_TOKEN_FILE: /run/nodedc-secrets/gateway-core-token + DEVICE_IDENTIFIER_PEPPER_FILE: /run/nodedc-secrets/identifier-pepper volumes: - type: bind source: /volume1/docker/nodedc-device-plane/secrets/postgres-password @@ -51,6 +53,18 @@ services: read_only: true bind: create_host_path: false + - type: bind + source: /volume1/docker/nodedc-device-plane/secrets/gateway-core-token + target: /run/nodedc-secrets/gateway-core-token + read_only: true + bind: + create_host_path: false + - type: bind + source: /volume1/docker/nodedc-device-plane/secrets/identifier-pepper + target: /run/nodedc-secrets/identifier-pepper + read_only: true + bind: + create_host_path: false ports: - "127.0.0.1:18120:18120" networks: @@ -85,16 +99,34 @@ services: environment: DEVICE_GATEWAY_HEALTH_HOST: 0.0.0.0 DEVICE_GATEWAY_HEALTH_PORT: "18121" - DEVICE_GATEWAY_LISTEN_ENABLED: "false" - DEVICE_GATEWAY_TCP_HOST: 127.0.0.1 + DEVICE_GATEWAY_LISTEN_ENABLED: "true" + DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED: "true" + DEVICE_GATEWAY_TCP_HOST: 0.0.0.0 DEVICE_GATEWAY_TCP_PORT: "9921" + DEVICE_GATEWAY_CORE_URL: http://device-control-core:18120 + DEVICE_GATEWAY_CORE_TOKEN_FILE: /run/nodedc-secrets/gateway-core-token + DEVICE_GATEWAY_CORE_TIMEOUT_MS: "5000" + DEVICE_GATEWAY_MAX_BUFFERED_BYTES: "65536" DEVICE_GATEWAY_MAX_SESSIONS: "100" + DEVICE_GATEWAY_MAX_SESSIONS_PER_ADDRESS: "10" + DEVICE_GATEWAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS: "30" DEVICE_GATEWAY_SESSION_TIMEOUT_MS: "10000" + volumes: + - type: bind + source: /volume1/docker/nodedc-device-plane/secrets/gateway-core-token + target: /run/nodedc-secrets/gateway-core-token + read_only: true + bind: + create_host_path: false ports: - "127.0.0.1:18121:18121" + - "0.0.0.0:9921:9921" networks: - device-plane-private - device-plane-control + depends_on: + device-control-core: + condition: service_healthy security_opt: - no-new-privileges:true cap_drop: diff --git a/device-plane/docs/IMPLEMENTATION_BASELINE.md b/device-plane/docs/IMPLEMENTATION_BASELINE.md index 5f9cf07..9eb1d4c 100644 --- a/device-plane/docs/IMPLEMENTATION_BASELINE.md +++ b/device-plane/docs/IMPLEMENTATION_BASELINE.md @@ -1,11 +1,10 @@ # Device Plane Implementation Baseline Status: PostgreSQL, Control Core and Gateway foundation are running healthy on -Synology with public ingress, discovery ingest and commands disabled. The first -application artifact is terminal-failed because its health gate timed out and -its automatic rollback could not model the DB-bootstrap predecessor. Recovery -is a separate source-only canonical transition; the failed artifact is never -retried. +Synology. The accepted foundation has public ingress and discovery ingest +disabled. The next additive transition enables only an authenticated, +quarantine-only ARUSNAVI B2 discovery path on raw TCP 9921. Command transport +remains disabled. ## Product boundary @@ -87,9 +86,12 @@ does not build, restart, recreate or remove any service. ## Network boundary -The baseline publishes no device port. The planned pilot ingress is -`device.nodedc.ru:9921/TCP` only after a port-collision check, quarantine/rate -limits and a separate reviewed ingress transition. +The accepted foundation publishes no device port. The reviewed pilot transition +publishes `9921/TCP` from Device Gateway after a port-collision check and keeps +health endpoints loopback-only. + +`device.nodedc.ru` is a DNS name, not an HTTP/TCP mode. The same name may later +serve an HTTPS Control API on 443 and the B2 raw TCP protocol on 9921. DSM HTTP/HTTPS Reverse Proxy is not a raw TCP ingress and must not be configured as `443 -> 9921`. @@ -121,10 +123,21 @@ The official B2 material proves: - server route fields for DNS/IP, TCP port, protocol and optional ID; - SMS/TCP command families and a six-digit device access password. -The public material inspected so far does not define enough exact INTERNAL -packet framing and acknowledgement detail to implement a production decoder. -The B2 adapter therefore hashes bounded evidence and fails closed. It does not -search arbitrary bytes for a 15-digit sequence and call that an IMEI. +The official ARUSNAVI INTERNAL protocol sheet now provides the first read-path +framing contract: + +- HEADER2 for GPRS is `FF 23` followed by an eight-byte little-endian IMEI; +- the server confirms HEADER2 with a bounded `SERVER_COM` carrying Unix time; +- a PACKAGE begins with `5B`, carries a package number in `01..FB`, contains + one or more length-framed PACKET records and ends with `5D`; +- every PACKET checksum is verified before acknowledgement; +- every valid PACKAGE is acknowledged by package number; +- without acknowledgement the tracker repeats the transmission. + +The pilot codec implements only that verified read/acknowledgement subset. It +does not decode telemetry tags, export command builders or accept arbitrary +server commands. An IMEI parsed from a valid HEADER2 remains a claimed +identifier and never proves tenant ownership. ## Command boundary @@ -151,8 +164,13 @@ raw TCP remain forbidden until separate reviewed acceptance slices. claimed devices, Foundry bindings and append-only audit events. - Core health endpoint and an authenticated quarantine-ingest boundary that is disabled unless explicitly enabled with file-backed secrets. -- Gateway health endpoint and a loopback-only test listener that records only - a bounded evidence hash, sends no acknowledgement and emits no identifier. +- Gateway discovery-only HEADER2/PACKAGE state machine with bounded buffers, + handshake timeout, concurrent/per-source session limits and per-source + connection rate limits. +- Authenticated Gateway-to-Core discovery ingest. Core HMAC-hashes the full IMEI + and persists only its digest, masked view and verified framing evidence. +- Only HEADER2 and valid PACKAGE acknowledgements are emitted; no command + builder or command transport is present. - Recursive rejection of secret-like fields, raw payloads and command-shaped input in presentation contracts. - Automated contract, adapter, migration, Core and Gateway tests. @@ -169,13 +187,16 @@ raw TCP remain forbidden until separate reviewed acceptance slices. ## Next source slice -1. Promote the reviewed recovery-capable runner through the standalone root - administrative gate and run a fresh `verify-install`. -2. Plan and apply the exact source-only foundation recovery artifact once, then - reconcile its journal/backup and repeat runtime acceptance. -3. Obtain or capture the exact official B2 INTERNAL framing and acknowledgement - contract; keep ingress disabled until its fixtures pass. -4. Add an authenticated internal Core/Gateway discovery boundary and explicit - platform-admin claim operation. -5. Open raw TCP ingress only in a later reviewed slice after quarantine, - framing, rate-limit and audit acceptance. +1. Add the versioned B2 discovery-ingress descriptor and runner acceptance + contract without weakening foundation/recovery acceptance. +2. Build a deterministic data-only app-overlay and promote the runner extension + through the standalone root administrative gate. +3. Plan/apply the exact discovery-ingress artifact and independently prove: + Core/Gateway/PostgreSQL health, PostgreSQL generation preservation, + `0.0.0.0:9921` publication, verified framing, quarantine-only Core ingest and + disabled command transport. +4. Only after runtime acceptance, allow TCP 9921 in DSM firewall and configure + WAN-to-NAS TCP forwarding. Keep the HTTP(S) Reverse Proxy out of this path. +5. Add the NDC route to one approved B2 free server slot while preserving + Gelios, then prove HEADER/discovery/PACKAGE acknowledgement. Claim and + tenant assignment remain a later explicit platform-admin operation. diff --git a/device-plane/packages/arusnavi-b2-adapter/src/index.mjs b/device-plane/packages/arusnavi-b2-adapter/src/index.mjs index afc067d..79dd9ab 100644 --- a/device-plane/packages/arusnavi-b2-adapter/src/index.mjs +++ b/device-plane/packages/arusnavi-b2-adapter/src/index.mjs @@ -1,4 +1,18 @@ -import { createHash } from "node:crypto"; +export const ARUSNAVI_INTERNAL_SPECIFICATION_REF = + "arusnavi.internal.protocol-sheet.gid-12.v1"; + +const HEADER2_LENGTH = 10; +const HEADER_START = 0xff; +const HEADER2_GPRS_VERSION = 0x23; +const PACKAGE_START = 0x5b; +const PACKAGE_END = 0x5d; +const SERVER_COMMAND_START = 0x7b; +const SERVER_COMMAND_END = 0x7d; +const MIN_PACKAGE_NUMBER = 0x01; +const MAX_PACKAGE_NUMBER = 0xfb; +const PACKET_FIXED_LENGTH = 8; +const MAX_PACKET_DATA_LENGTH = 32 * 1024; +const MAX_PACKAGE_LENGTH = 64 * 1024; export const ARUSNAVI_B2_MODEL_PROFILE = deepFreeze({ schemaVersion: "nodedc.device-model-profile.v1", @@ -19,8 +33,15 @@ export const ARUSNAVI_B2_MODEL_PROFILE = deepFreeze({ preserveExistingRoutes: true, }, framing: { - status: "blocked_pending_official_specification", - maxInitialBytes: 4096, + status: "verified-read-only", + specificationRef: ARUSNAVI_INTERNAL_SPECIFICATION_REF, + headerVersion: "HEADER2_GPRS_0x23", + headerBytes: HEADER2_LENGTH, + maxBufferedBytes: MAX_PACKAGE_LENGTH, + }, + acknowledgement: { + header: "server-time-only", + package: "package-number-only", }, commandTransport: { status: "disabled", @@ -32,28 +53,154 @@ export const ARUSNAVI_B2_MODEL_PROFILE = deepFreeze({ }, }); -export function inspectUnverifiedInitialBytes(input) { - if (!Buffer.isBuffer(input)) { - throw new TypeError("b2_initial_bytes_buffer_required"); +export function tryParseB2Header2(input) { + assertBuffer(input, "b2_header_buffer_required"); + if (input.length < HEADER2_LENGTH) { + return Object.freeze({ + status: "incomplete", + minimumBytes: HEADER2_LENGTH, + }); } - if (input.length === 0) { - throw new TypeError("b2_initial_bytes_empty"); + if (input[0] !== HEADER_START) { + throw new TypeError("b2_header_start_invalid"); } - if (input.length > ARUSNAVI_B2_MODEL_PROFILE.framing.maxInitialBytes) { - throw new TypeError("b2_initial_bytes_limit_exceeded"); + if (input[1] !== HEADER2_GPRS_VERSION) { + throw new TypeError("b2_header_version_unsupported"); + } + + const identifier = input.readBigUInt64LE(2).toString(10); + if (!/^\d{15}$/.test(identifier)) { + throw new TypeError("b2_header_imei_invalid"); } return Object.freeze({ - schemaVersion: "nodedc.device.protocol-evidence.v1", - profileRef: ARUSNAVI_B2_MODEL_PROFILE.profileRef, - status: "official_framing_required", - bytesObserved: input.length, - contentDigest: `sha256:${createHash("sha256").update(input).digest("hex")}`, - identifierExtracted: false, - commandTransport: "disabled", + status: "complete", + bytesConsumed: HEADER2_LENGTH, + identifier: Object.freeze({ + kind: "imei", + value: identifier, + trust: "claimed-not-ownership-proof", + }), + evidence: Object.freeze({ + transport: "tcp", + bytesObserved: HEADER2_LENGTH, + framingStatus: "verified", + specificationRef: ARUSNAVI_INTERNAL_SPECIFICATION_REF, + }), }); } +export function buildB2HeaderAcknowledgement(unixSeconds) { + const timestamp = normalizeUInt32( + unixSeconds, + "b2_header_ack_timestamp_invalid", + ); + const commandData = Buffer.allocUnsafe(4); + commandData.writeUInt32LE(timestamp); + return Buffer.from([ + SERVER_COMMAND_START, + commandData.length, + 0x00, + checksum(commandData), + ...commandData, + SERVER_COMMAND_END, + ]); +} + +export function tryParseB2Package(input) { + assertBuffer(input, "b2_package_buffer_required"); + if (input.length === 0) { + return Object.freeze({ status: "incomplete", minimumBytes: 1 }); + } + if (input[0] !== PACKAGE_START) { + throw new TypeError("b2_package_start_invalid"); + } + if (input.length < 3) { + return Object.freeze({ status: "incomplete", minimumBytes: 3 }); + } + + const packageNumber = input[1]; + if ( + packageNumber < MIN_PACKAGE_NUMBER + || packageNumber > MAX_PACKAGE_NUMBER + ) { + throw new TypeError("b2_package_number_invalid"); + } + + let offset = 2; + let packetCount = 0; + while (true) { + if (offset >= MAX_PACKAGE_LENGTH) { + throw new TypeError("b2_package_length_exceeded"); + } + if (offset >= input.length) { + return Object.freeze({ + status: "incomplete", + minimumBytes: offset + 1, + }); + } + if (input[offset] === PACKAGE_END) { + if (packetCount === 0) { + throw new TypeError("b2_package_empty"); + } + return Object.freeze({ + status: "complete", + bytesConsumed: offset + 1, + packageNumber, + packetCount, + }); + } + if (input.length - offset < 3) { + return Object.freeze({ + status: "incomplete", + minimumBytes: offset + 3, + }); + } + + const dataLength = input.readUInt16LE(offset + 1); + if (dataLength > MAX_PACKET_DATA_LENGTH) { + throw new TypeError("b2_packet_data_length_exceeded"); + } + const packetLength = PACKET_FIXED_LENGTH + dataLength; + const packetEnd = offset + packetLength; + if (packetEnd + 1 > MAX_PACKAGE_LENGTH) { + throw new TypeError("b2_package_length_exceeded"); + } + if (input.length < packetEnd) { + return Object.freeze({ + status: "incomplete", + minimumBytes: packetEnd, + }); + } + + const expectedChecksum = checksum( + input.subarray(offset + 3, packetEnd - 1), + ); + if (input[packetEnd - 1] !== expectedChecksum) { + throw new TypeError("b2_packet_checksum_invalid"); + } + packetCount += 1; + offset = packetEnd; + } +} + +export function buildB2PackageAcknowledgement(packageNumber) { + const normalized = Number(packageNumber); + if ( + !Number.isSafeInteger(normalized) + || normalized < MIN_PACKAGE_NUMBER + || normalized > MAX_PACKAGE_NUMBER + ) { + throw new TypeError("b2_package_ack_number_invalid"); + } + return Buffer.from([ + SERVER_COMMAND_START, + 0x00, + normalized, + SERVER_COMMAND_END, + ]); +} + export function assertB2ProfileInvariant(profile = ARUSNAVI_B2_MODEL_PROFILE) { if (profile.monitoringServerSlots !== 4) { throw new TypeError("b2_server_slot_count_invalid"); @@ -64,6 +211,15 @@ export function assertB2ProfileInvariant(profile = ARUSNAVI_B2_MODEL_PROFILE) { if (profile.serverIdentity.kind !== "imei") { throw new TypeError("b2_identity_kind_invalid"); } + if (profile.framing.status !== "verified-read-only") { + throw new TypeError("b2_framing_must_be_verified"); + } + if ( + profile.framing.specificationRef + !== ARUSNAVI_INTERNAL_SPECIFICATION_REF + ) { + throw new TypeError("b2_framing_specification_invalid"); + } if (profile.commandTransport.status !== "disabled") { throw new TypeError("b2_command_transport_must_be_disabled"); } @@ -73,6 +229,30 @@ export function assertB2ProfileInvariant(profile = ARUSNAVI_B2_MODEL_PROFILE) { return true; } +function checksum(input) { + let value = 0; + for (const byte of input) value = (value + byte) & 0xff; + return value; +} + +function normalizeUInt32(value, errorCode) { + const normalized = Number(value); + if ( + !Number.isSafeInteger(normalized) + || normalized < 0 + || normalized > 0xffffffff + ) { + throw new TypeError(errorCode); + } + return normalized; +} + +function assertBuffer(input, errorCode) { + if (!Buffer.isBuffer(input)) { + throw new TypeError(errorCode); + } +} + function deepFreeze(value) { if (!value || typeof value !== "object" || Object.isFrozen(value)) { return value; diff --git a/device-plane/packages/arusnavi-b2-adapter/test/adapter.test.mjs b/device-plane/packages/arusnavi-b2-adapter/test/adapter.test.mjs index dbab78a..90cea35 100644 --- a/device-plane/packages/arusnavi-b2-adapter/test/adapter.test.mjs +++ b/device-plane/packages/arusnavi-b2-adapter/test/adapter.test.mjs @@ -3,42 +3,118 @@ import test from "node:test"; import { ARUSNAVI_B2_MODEL_PROFILE, + ARUSNAVI_INTERNAL_SPECIFICATION_REF, assertB2ProfileInvariant, - inspectUnverifiedInitialBytes, + buildB2HeaderAcknowledgement, + buildB2PackageAcknowledgement, + tryParseB2Header2, + tryParseB2Package, } from "../src/index.mjs"; -test("records the official B2 route and identity evidence", () => { +const specificationHeader = Buffer.from( + "FF23E9EF782DE7120300", + "hex", +); +const specificationPackage = Buffer.from( + "5B01010000FBDEC251EC5D", + "hex", +); + +test("records the official B2 route, framing and identity evidence", () => { assert.equal(assertB2ProfileInvariant(), true); assert.equal(ARUSNAVI_B2_MODEL_PROFILE.monitoringServerSlots, 4); assert.equal(ARUSNAVI_B2_MODEL_PROFILE.protocol, "INTERNAL"); assert.equal(ARUSNAVI_B2_MODEL_PROFILE.serverIdentity.kind, "imei"); + assert.equal( + ARUSNAVI_B2_MODEL_PROFILE.framing.specificationRef, + ARUSNAVI_INTERNAL_SPECIFICATION_REF, + ); assert.equal( ARUSNAVI_B2_MODEL_PROFILE.routeCompatibility.gelios, "parallel-preserved", ); }); -test("fails closed instead of guessing an IMEI from unverified bytes", () => { - const fakeBytes = Buffer.from( - "unverified-frame-with-fake-identifier-000000000000001", - "utf8", +test("parses the official HEADER2 example as a claimed IMEI", () => { + assert.equal( + tryParseB2Header2(specificationHeader.subarray(0, 9)).status, + "incomplete", ); - const evidence = inspectUnverifiedInitialBytes(fakeBytes); - const serialized = JSON.stringify(evidence); - assert.equal(evidence.status, "official_framing_required"); - assert.equal(evidence.identifierExtracted, false); - assert.equal(serialized.includes("000000000000001"), false); - assert.match(evidence.contentDigest, /^sha256:[a-f0-9]{64}$/); + const parsed = tryParseB2Header2(specificationHeader); + assert.equal(parsed.status, "complete"); + assert.equal(parsed.bytesConsumed, 10); + assert.equal(parsed.identifier.kind, "imei"); + assert.equal(parsed.identifier.value, "865209039777769"); + assert.equal(parsed.identifier.trust, "claimed-not-ownership-proof"); + assert.deepEqual(parsed.evidence, { + transport: "tcp", + bytesObserved: 10, + framingStatus: "verified", + specificationRef: ARUSNAVI_INTERNAL_SPECIFICATION_REF, + }); }); -test("enforces the bounded initial frame evidence window", () => { +test("builds the official HEADER2 acknowledgement example", () => { + assert.equal( + buildB2HeaderAcknowledgement(0x52db95de).toString("hex").toUpperCase(), + "7B0400A0DE95DB527D", + ); +}); + +test("parses and acknowledges the official package example", () => { + assert.equal( + tryParseB2Package(specificationPackage.subarray(0, -1)).status, + "incomplete", + ); + assert.deepEqual(tryParseB2Package(specificationPackage), { + status: "complete", + bytesConsumed: specificationPackage.length, + packageNumber: 1, + packetCount: 1, + }); + assert.equal( + buildB2PackageAcknowledgement(1).toString("hex").toUpperCase(), + "7B00017D", + ); +}); + +test("uses packet lengths and checksum instead of scanning for 0x5D", () => { + const packetData = Buffer.from([0x5d]); + const unixTime = Buffer.from([0x01, 0x00, 0x00, 0x00]); + const checksum = (0x01 + 0x5d) & 0xff; + const packageBytes = Buffer.from([ + 0x5b, + 0x02, + 0x01, + packetData.length, + 0x00, + ...unixTime, + ...packetData, + checksum, + 0x5d, + ]); + assert.deepEqual(tryParseB2Package(packageBytes), { + status: "complete", + bytesConsumed: packageBytes.length, + packageNumber: 2, + packetCount: 1, + }); +}); + +test("fails closed on unsupported headers and malformed packages", () => { assert.throws( - () => inspectUnverifiedInitialBytes(Buffer.alloc(0)), - /b2_initial_bytes_empty/, + () => tryParseB2Header2(Buffer.from("FE23E9EF782DE7120300", "hex")), + /b2_header_start_invalid/, ); assert.throws( - () => inspectUnverifiedInitialBytes(Buffer.alloc(4097)), - /b2_initial_bytes_limit_exceeded/, + () => tryParseB2Header2(Buffer.from("FF24E9EF782DE7120300", "hex")), + /b2_header_version_unsupported/, + ); + const badChecksum = Buffer.from(specificationPackage); + badChecksum[badChecksum.length - 2] ^= 0xff; + assert.throws( + () => tryParseB2Package(badChecksum), + /b2_packet_checksum_invalid/, ); }); diff --git a/device-plane/services/device-gateway/src/core-client.mjs b/device-plane/services/device-gateway/src/core-client.mjs new file mode 100644 index 0000000..b08d4f9 --- /dev/null +++ b/device-plane/services/device-gateway/src/core-client.mjs @@ -0,0 +1,75 @@ +export function createCoreDiscoveryClient({ + coreUrl, + gatewayToken, + timeoutMs = 5000, + fetchImpl = fetch, +} = {}) { + const endpoint = normalizeCoreEndpoint(coreUrl); + if (typeof gatewayToken !== "string" || gatewayToken.length < 32) { + throw new TypeError("device_gateway_core_token_invalid"); + } + const normalizedTimeout = Number(timeoutMs); + if ( + !Number.isSafeInteger(normalizedTimeout) + || normalizedTimeout < 100 + || normalizedTimeout > 30_000 + ) { + throw new TypeError("device_gateway_core_timeout_invalid"); + } + if (typeof fetchImpl !== "function") { + throw new TypeError("device_gateway_core_fetch_invalid"); + } + + return async function observeDiscovery(signal) { + const response = await fetchImpl(endpoint, { + method: "POST", + headers: { + Authorization: `Bearer ${gatewayToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(signal), + signal: AbortSignal.timeout(normalizedTimeout), + }); + const body = await readBoundedJson(response, 32 * 1024); + if (!response.ok || body?.ok !== true) { + throw new Error("device_gateway_core_ingest_failed"); + } + if ( + !body.discovery + || body.discovery.lifecycleState !== "quarantine" + || body.discovery.commandTransport !== "disabled" + ) { + throw new Error("device_gateway_core_ingest_contract_invalid"); + } + return body.discovery; + }; +} + +function normalizeCoreEndpoint(value) { + let url; + try { + url = new URL(String(value || "")); + } catch { + throw new TypeError("device_gateway_core_url_invalid"); + } + if (url.protocol !== "http:" || url.username || url.password) { + throw new TypeError("device_gateway_core_url_invalid"); + } + if (url.pathname !== "/" || url.search || url.hash) { + throw new TypeError("device_gateway_core_url_invalid"); + } + url.pathname = "/internal/v1/device-discoveries:observe"; + return url.toString(); +} + +async function readBoundedJson(response, maxBytes) { + const text = await response.text(); + if (Buffer.byteLength(text, "utf8") > maxBytes) { + throw new Error("device_gateway_core_response_too_large"); + } + try { + return JSON.parse(text); + } catch { + throw new Error("device_gateway_core_response_invalid"); + } +} diff --git a/device-plane/services/device-gateway/src/runtime.mjs b/device-plane/services/device-gateway/src/runtime.mjs index a55cb83..455caff 100644 --- a/device-plane/services/device-gateway/src/runtime.mjs +++ b/device-plane/services/device-gateway/src/runtime.mjs @@ -4,18 +4,32 @@ import { createServer as createTcpServer } from "node:net"; import { ARUSNAVI_B2_MODEL_PROFILE, - inspectUnverifiedInitialBytes, + buildB2HeaderAcknowledgement, + buildB2PackageAcknowledgement, + tryParseB2Header2, + tryParseB2Package, } from "../../../packages/arusnavi-b2-adapter/src/index.mjs"; +import { + DEVICE_DISCOVERY_SIGNAL_SCHEMA, +} from "../../../packages/device-protocol-contract/src/index.mjs"; export function createDeviceGatewayRuntime(options = {}) { const config = normalizeConfig(options); const sessions = new Map(); + const sessionsByAddress = new Map(); + const connectionWindows = new Map(); let totalAccepted = 0; let totalRejected = 0; - let totalEvidence = 0; + let totalDiscoveries = 0; + let totalPackagesAcknowledged = 0; const tcpServer = createTcpServer((socket) => { - if (sessions.size >= config.maxConcurrentSessions) { + const remoteAddress = normalizeRemoteAddress(socket.remoteAddress); + if ( + sessions.size >= config.maxConcurrentSessions + || currentAddressSessions(remoteAddress) >= config.maxSessionsPerAddress + || !consumeConnectionPermit(remoteAddress) + ) { totalRejected += 1; socket.destroy(); return; @@ -24,40 +38,42 @@ export function createDeviceGatewayRuntime(options = {}) { const sessionRef = `session:${randomUUID()}`; const session = { sessionRef, - bytes: [], - byteLength: 0, - evidenceRecorded: false, + remoteAddress, + buffer: Buffer.alloc(0), + state: "awaiting-header", + processing: false, + rejected: false, + closed: false, }; sessions.set(socket, session); + incrementAddressSessions(remoteAddress); totalAccepted += 1; socket.setNoDelay(true); socket.setTimeout(config.sessionTimeoutMs); socket.on("data", (chunk) => { - if (session.evidenceRecorded) return; - session.byteLength += chunk.length; - if (session.byteLength > config.maxInitialBytes) { - totalRejected += 1; - socket.destroy(); + if (session.closed || session.rejected) return; + socket.pause(); + session.buffer = Buffer.concat( + [session.buffer, chunk], + session.buffer.length + chunk.length, + ); + if (session.buffer.length > config.maxBufferedBytes) { + rejectSession(socket, session); return; } - session.bytes.push(chunk); - const evidence = inspectUnverifiedInitialBytes( - Buffer.concat(session.bytes, session.byteLength), - ); - session.evidenceRecorded = true; - totalEvidence += 1; - config.onEvidence?.({ - sessionRef, - evidence, - }); - // Until exact official framing is implemented, the gateway never sends - // acknowledgement or command bytes and never guesses an identifier. - socket.end(); + if (session.processing) return; + session.processing = true; + void processSession(socket, session) + .catch(() => rejectSession(socket, session)) + .finally(() => { + session.processing = false; + if (!session.closed && !session.rejected) socket.resume(); + }); }); - socket.on("timeout", () => socket.destroy()); - socket.on("close", () => sessions.delete(socket)); - socket.on("error", () => sessions.delete(socket)); + socket.on("timeout", () => rejectSession(socket, session)); + socket.on("close", () => closeSession(socket, session)); + socket.on("error", () => closeSession(socket, session)); }); const healthServer = createHttpServer((request, response) => { @@ -74,14 +90,17 @@ export function createDeviceGatewayRuntime(options = {}) { service: "nodedc-device-gateway", protocolProfile: ARUSNAVI_B2_MODEL_PROFILE.profileRef, framing: ARUSNAVI_B2_MODEL_PROFILE.framing.status, - tcpListener: config.listenEnabled ? "internal-test-only" : "disabled", - publicIngress: "disabled", + tcpListener: config.listenEnabled ? "discovery-only" : "disabled", + publicIngress: config.publicIngressEnabled + ? "discovery-only" + : "disabled", commandTransport: "disabled", sessions: { active: sessions.size, accepted: totalAccepted, rejected: totalRejected, - evidenceRecorded: totalEvidence, + discoveries: totalDiscoveries, + packagesAcknowledged: totalPackagesAcknowledged, }, })}\n`); }); @@ -98,7 +117,10 @@ export function createDeviceGatewayRuntime(options = {}) { }; }, async stop() { - for (const socket of sessions.keys()) socket.destroy(); + for (const [socket, session] of sessions) { + session.closed = true; + socket.destroy(); + } await Promise.all([ closeServer(healthServer), config.listenEnabled ? closeServer(tcpServer) : Promise.resolve(), @@ -109,25 +131,120 @@ export function createDeviceGatewayRuntime(options = {}) { activeSessions: sessions.size, totalAccepted, totalRejected, - totalEvidence, + totalDiscoveries, + totalPackagesAcknowledged, commandTransport: "disabled", - publicIngress: "disabled", + publicIngress: config.publicIngressEnabled + ? "discovery-only" + : "disabled", }; }, }; + + async function processSession(socket, session) { + while (!session.closed && !session.rejected) { + if (session.state === "awaiting-header") { + const parsed = tryParseB2Header2(session.buffer); + if (parsed.status === "incomplete") return; + + const observedAt = config.now().toISOString(); + await config.onDiscovery?.({ + schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA, + sessionRef: session.sessionRef, + modelProfileRef: ARUSNAVI_B2_MODEL_PROFILE.profileRef, + protocol: ARUSNAVI_B2_MODEL_PROFILE.protocol, + observedAt, + identifier: { + kind: parsed.identifier.kind, + value: parsed.identifier.value, + }, + evidence: parsed.evidence, + }); + session.buffer = session.buffer.subarray(parsed.bytesConsumed); + session.state = "packages"; + totalDiscoveries += 1; + socket.write(buildB2HeaderAcknowledgement( + Math.floor(new Date(observedAt).getTime() / 1000), + )); + continue; + } + + const parsed = tryParseB2Package(session.buffer); + if (parsed.status === "incomplete") return; + session.buffer = session.buffer.subarray(parsed.bytesConsumed); + totalPackagesAcknowledged += 1; + socket.write(buildB2PackageAcknowledgement(parsed.packageNumber)); + } + } + + function currentAddressSessions(remoteAddress) { + return sessionsByAddress.get(remoteAddress) || 0; + } + + function incrementAddressSessions(remoteAddress) { + sessionsByAddress.set( + remoteAddress, + currentAddressSessions(remoteAddress) + 1, + ); + } + + function decrementAddressSessions(remoteAddress) { + const current = currentAddressSessions(remoteAddress); + if (current <= 1) { + sessionsByAddress.delete(remoteAddress); + } else { + sessionsByAddress.set(remoteAddress, current - 1); + } + } + + function consumeConnectionPermit(remoteAddress) { + const nowMs = config.now().getTime(); + const current = connectionWindows.get(remoteAddress); + if (!current || nowMs - current.startedAt >= 60_000) { + connectionWindows.set(remoteAddress, { + startedAt: nowMs, + count: 1, + }); + return true; + } + if (current.count >= config.maxConnectionsPerMinutePerAddress) { + return false; + } + current.count += 1; + return true; + } + + function rejectSession(socket, session) { + if (!session.rejected) { + session.rejected = true; + totalRejected += 1; + } + socket.destroy(); + } + + function closeSession(socket, session) { + if (session.closed) return; + session.closed = true; + sessions.delete(socket); + decrementAddressSessions(session.remoteAddress); + } } function normalizeConfig(input) { const listenEnabled = input.listenEnabled === true; - const maxInitialBytes = parseInteger( - input.maxInitialBytes, - ARUSNAVI_B2_MODEL_PROFILE.framing.maxInitialBytes, - 1, - ARUSNAVI_B2_MODEL_PROFILE.framing.maxInitialBytes, - "device_gateway_initial_bytes_invalid", - ); + const publicIngressEnabled = input.publicIngressEnabled === true; + if (publicIngressEnabled && !listenEnabled) { + throw new TypeError("device_gateway_public_ingress_listener_required"); + } + if ( + publicIngressEnabled + && typeof input.onDiscovery !== "function" + ) { + throw new TypeError("device_gateway_discovery_sink_required"); + } return { listenEnabled, + publicIngressEnabled, healthHost: normalizeHealthHost(input.healthHost, "127.0.0.1"), healthPort: parseInteger( input.healthPort, @@ -136,7 +253,11 @@ function normalizeConfig(input) { 65535, "device_gateway_health_port_invalid", ), - tcpHost: normalizeTcpHost(input.tcpHost, "127.0.0.1"), + tcpHost: normalizeTcpHost( + input.tcpHost, + publicIngressEnabled ? "0.0.0.0" : "127.0.0.1", + publicIngressEnabled, + ), tcpPort: parseInteger( input.tcpPort, 9921, @@ -144,7 +265,13 @@ function normalizeConfig(input) { 65535, "device_gateway_tcp_port_invalid", ), - maxInitialBytes, + maxBufferedBytes: parseInteger( + input.maxBufferedBytes, + ARUSNAVI_B2_MODEL_PROFILE.framing.maxBufferedBytes, + 1024, + ARUSNAVI_B2_MODEL_PROFILE.framing.maxBufferedBytes, + "device_gateway_buffer_limit_invalid", + ), maxConcurrentSessions: parseInteger( input.maxConcurrentSessions, 100, @@ -152,6 +279,20 @@ function normalizeConfig(input) { 10000, "device_gateway_session_limit_invalid", ), + maxSessionsPerAddress: parseInteger( + input.maxSessionsPerAddress, + 10, + 1, + 1000, + "device_gateway_address_session_limit_invalid", + ), + maxConnectionsPerMinutePerAddress: parseInteger( + input.maxConnectionsPerMinutePerAddress, + 30, + 1, + 10000, + "device_gateway_address_rate_limit_invalid", + ), sessionTimeoutMs: parseInteger( input.sessionTimeoutMs, 10000, @@ -159,9 +300,10 @@ function normalizeConfig(input) { 60000, "device_gateway_session_timeout_invalid", ), - onEvidence: typeof input.onEvidence === "function" - ? input.onEvidence + onDiscovery: typeof input.onDiscovery === "function" + ? input.onDiscovery : undefined, + now: typeof input.now === "function" ? input.now : () => new Date(), }; } @@ -173,14 +315,26 @@ function normalizeHealthHost(value, fallback) { return normalized; } -function normalizeTcpHost(value, fallback) { +function normalizeTcpHost(value, fallback, publicIngressEnabled) { const normalized = String(value || fallback).trim(); - if (!["127.0.0.1", "::1"].includes(normalized)) { - throw new TypeError("device_gateway_baseline_loopback_only"); + const allowed = publicIngressEnabled + ? ["0.0.0.0", "::"] + : ["127.0.0.1", "::1"]; + if (!allowed.includes(normalized)) { + throw new TypeError( + publicIngressEnabled + ? "device_gateway_public_ingress_host_invalid" + : "device_gateway_baseline_loopback_only", + ); } return normalized; } +function normalizeRemoteAddress(value) { + const normalized = String(value || "unknown").trim(); + return normalized.slice(0, 64) || "unknown"; +} + function parseInteger(value, fallback, minimum, maximum, errorCode) { const parsed = Number(value ?? fallback); if ( diff --git a/device-plane/services/device-gateway/src/server.mjs b/device-plane/services/device-gateway/src/server.mjs index 2c97e5f..9eb97bb 100644 --- a/device-plane/services/device-gateway/src/server.mjs +++ b/device-plane/services/device-gateway/src/server.mjs @@ -1,23 +1,30 @@ +import { readFile } from "node:fs/promises"; + +import { createCoreDiscoveryClient } from "./core-client.mjs"; import { createDeviceGatewayRuntime } from "./runtime.mjs"; -const listenEnabled = parseBoolean( - process.env.DEVICE_GATEWAY_LISTEN_ENABLED, - false, -); +const config = await readConfig(); +const onDiscovery = config.listenEnabled + ? createCoreDiscoveryClient({ + coreUrl: config.coreUrl, + gatewayToken: config.gatewayToken, + timeoutMs: config.coreTimeoutMs, + }) + : undefined; const runtime = createDeviceGatewayRuntime({ - listenEnabled, - healthHost: String(process.env.DEVICE_GATEWAY_HEALTH_HOST || "127.0.0.1"), - healthPort: parsePort(process.env.DEVICE_GATEWAY_HEALTH_PORT, 18121), - tcpHost: String(process.env.DEVICE_GATEWAY_TCP_HOST || "127.0.0.1"), - tcpPort: parsePort(process.env.DEVICE_GATEWAY_TCP_PORT, 9921), - maxConcurrentSessions: parsePositiveInt( - process.env.DEVICE_GATEWAY_MAX_SESSIONS, - 100, - ), - sessionTimeoutMs: parsePositiveInt( - process.env.DEVICE_GATEWAY_SESSION_TIMEOUT_MS, - 10000, - ), + listenEnabled: config.listenEnabled, + publicIngressEnabled: config.publicIngressEnabled, + healthHost: config.healthHost, + healthPort: config.healthPort, + tcpHost: config.tcpHost, + tcpPort: config.tcpPort, + maxBufferedBytes: config.maxBufferedBytes, + maxConcurrentSessions: config.maxConcurrentSessions, + maxSessionsPerAddress: config.maxSessionsPerAddress, + maxConnectionsPerMinutePerAddress: + config.maxConnectionsPerMinutePerAddress, + sessionTimeoutMs: config.sessionTimeoutMs, + onDiscovery, }); const addresses = await runtime.start(); @@ -25,7 +32,9 @@ console.log(JSON.stringify({ event: "device_gateway_started", health: addresses.healthAddress, tcp: addresses.tcpAddress, - publicIngress: "disabled", + publicIngress: config.publicIngressEnabled + ? "discovery-only" + : "disabled", commandTransport: "disabled", })); @@ -37,6 +46,80 @@ async function shutdown() { process.exit(0); } +async function readConfig() { + const listenEnabled = parseBoolean( + process.env.DEVICE_GATEWAY_LISTEN_ENABLED, + false, + ); + const publicIngressEnabled = parseBoolean( + process.env.DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED, + false, + ); + return { + listenEnabled, + publicIngressEnabled, + healthHost: String( + process.env.DEVICE_GATEWAY_HEALTH_HOST || "127.0.0.1", + ), + healthPort: parsePort(process.env.DEVICE_GATEWAY_HEALTH_PORT, 18121), + tcpHost: String( + process.env.DEVICE_GATEWAY_TCP_HOST + || (publicIngressEnabled ? "0.0.0.0" : "127.0.0.1"), + ), + tcpPort: parsePort(process.env.DEVICE_GATEWAY_TCP_PORT, 9921), + maxBufferedBytes: parsePositiveInt( + process.env.DEVICE_GATEWAY_MAX_BUFFERED_BYTES, + 65536, + ), + maxConcurrentSessions: parsePositiveInt( + process.env.DEVICE_GATEWAY_MAX_SESSIONS, + 100, + ), + maxSessionsPerAddress: parsePositiveInt( + process.env.DEVICE_GATEWAY_MAX_SESSIONS_PER_ADDRESS, + 10, + ), + maxConnectionsPerMinutePerAddress: parsePositiveInt( + process.env.DEVICE_GATEWAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS, + 30, + ), + sessionTimeoutMs: parsePositiveInt( + process.env.DEVICE_GATEWAY_SESSION_TIMEOUT_MS, + 10000, + ), + coreUrl: listenEnabled + ? requiredValue( + process.env.DEVICE_GATEWAY_CORE_URL, + "device_gateway_core_url_required", + ) + : "", + gatewayToken: listenEnabled + ? await readRequiredSecretFile( + process.env.DEVICE_GATEWAY_CORE_TOKEN_FILE, + "device_gateway_core_token_file_required", + ) + : "", + coreTimeoutMs: parsePositiveInt( + process.env.DEVICE_GATEWAY_CORE_TIMEOUT_MS, + 5000, + ), + }; +} + +async function readRequiredSecretFile(path, errorCode) { + const normalized = requiredValue(path, errorCode); + const value = (await readFile(normalized, "utf8")).trim(); + if (value.length < 32) throw new Error(errorCode); + return value; +} + +function requiredValue(value, errorCode) { + if (typeof value !== "string" || value.trim() === "") { + throw new Error(errorCode); + } + return value.trim(); +} + function parsePort(value, fallback) { const parsed = Number(value || fallback); if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535) { diff --git a/device-plane/services/device-gateway/test/core-client.test.mjs b/device-plane/services/device-gateway/test/core-client.test.mjs new file mode 100644 index 0000000..e94960f --- /dev/null +++ b/device-plane/services/device-gateway/test/core-client.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createCoreDiscoveryClient } from "../src/core-client.mjs"; + +const gatewayToken = "test-only-gateway-token-with-32-bytes"; + +test("posts a discovery through the authenticated internal Core boundary", async () => { + let captured; + const observe = createCoreDiscoveryClient({ + coreUrl: "http://device-control-core:18120", + gatewayToken, + fetchImpl: async (url, options) => { + captured = { url, options }; + return new Response(JSON.stringify({ + ok: true, + discovery: { + lifecycleState: "quarantine", + commandTransport: "disabled", + }, + }), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + }, + }); + const signal = { + schemaVersion: "nodedc.device.discovery-signal.v1", + sessionRef: "session:test", + }; + const discovery = await observe(signal); + assert.equal( + captured.url, + "http://device-control-core:18120/internal/v1/device-discoveries:observe", + ); + assert.equal( + captured.options.headers.Authorization, + `Bearer ${gatewayToken}`, + ); + assert.deepEqual(JSON.parse(captured.options.body), signal); + assert.equal(discovery.lifecycleState, "quarantine"); +}); + +test("fails closed when Core does not return a quarantine view", async () => { + const observe = createCoreDiscoveryClient({ + coreUrl: "http://device-control-core:18120", + gatewayToken, + fetchImpl: async () => new Response(JSON.stringify({ + ok: true, + discovery: { + lifecycleState: "claimed", + commandTransport: "disabled", + }, + }), { status: 200 }), + }); + await assert.rejects( + () => observe({ schemaVersion: "test" }), + /device_gateway_core_ingest_contract_invalid/, + ); +}); diff --git a/device-plane/services/device-gateway/test/discovery-integration.test.mjs b/device-plane/services/device-gateway/test/discovery-integration.test.mjs new file mode 100644 index 0000000..f431c14 --- /dev/null +++ b/device-plane/services/device-gateway/test/discovery-integration.test.mjs @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import { connect } from "node:net"; +import test from "node:test"; + +import { + createControlCoreApp, +} from "../../device-control-core/src/app.mjs"; +import { createCoreDiscoveryClient } from "../src/core-client.mjs"; +import { createDeviceGatewayRuntime } from "../src/runtime.mjs"; + +const gatewayToken = "test-only-gateway-token-with-32-bytes"; +const identifierPepper = "test-only-identifier-pepper-with-32-bytes"; +const specificationHeader = Buffer.from( + "FF23E9EF782DE7120300", + "hex", +); +const specificationPackage = Buffer.from( + "5B01010000FBDEC251EC5D", + "hex", +); + +test("B2 HEADER2 becomes a persisted masked quarantine discovery before ACK", async () => { + let stored; + const core = createControlCoreApp({ + discoveryIngestEnabled: true, + gatewayToken, + identifierPepper, + repository: { + health: async () => "ready", + upsertQuarantineDiscovery: async (value) => { + stored = value; + return { + created: true, + value: { + ...value.safeView, + discoveryRef: "discovery:integration-001", + }, + }; + }, + }, + }); + await listen(core); + const coreAddress = core.address(); + const observe = createCoreDiscoveryClient({ + coreUrl: `http://127.0.0.1:${coreAddress.port}`, + gatewayToken, + }); + const gateway = createDeviceGatewayRuntime({ + healthPort: 0, + tcpHost: "0.0.0.0", + tcpPort: 0, + listenEnabled: true, + publicIngressEnabled: true, + now: () => new Date(0x52db95de * 1000), + onDiscovery: observe, + }); + const addresses = await gateway.start(); + try { + const response = await exchange( + addresses.tcpAddress.port, + Buffer.concat([specificationHeader, specificationPackage]), + 13, + ); + assert.equal( + response.toString("hex").toUpperCase(), + "7B0400A0DE95DB527D7B00017D", + ); + assert.match(stored.identifierDigest, /^hmac-sha256:[a-f0-9]{64}$/); + assert.equal(stored.safeView.lifecycleState, "quarantine"); + assert.equal(stored.safeView.identifier.masked, "***********7769"); + assert.equal(stored.safeView.commandTransport, "disabled"); + assert.equal( + JSON.stringify(stored).includes("865209039777769"), + false, + ); + } finally { + await gateway.stop(); + await close(core); + } +}); + +function listen(server) { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); +} + +function close(server) { + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} + +function exchange(port, payload, expectedBytes) { + return new Promise((resolve, reject) => { + const chunks = []; + let byteLength = 0; + const socket = connect({ host: "127.0.0.1", port }, () => { + socket.write(payload); + }); + socket.on("data", (chunk) => { + chunks.push(chunk); + byteLength += chunk.length; + if (byteLength >= expectedBytes) { + socket.destroy(); + resolve(Buffer.concat(chunks, byteLength)); + } + }); + socket.on("error", reject); + }); +} diff --git a/device-plane/services/device-gateway/test/runtime.test.mjs b/device-plane/services/device-gateway/test/runtime.test.mjs index 4b9ee97..f0a390a 100644 --- a/device-plane/services/device-gateway/test/runtime.test.mjs +++ b/device-plane/services/device-gateway/test/runtime.test.mjs @@ -4,6 +4,15 @@ import test from "node:test"; import { createDeviceGatewayRuntime } from "../src/runtime.mjs"; +const specificationHeader = Buffer.from( + "FF23E9EF782DE7120300", + "hex", +); +const specificationPackage = Buffer.from( + "5B01010000FBDEC251EC5D", + "hex", +); + test("baseline health exposes no public ingress and no command transport", async () => { const runtime = createDeviceGatewayRuntime({ healthPort: 0, @@ -25,37 +34,93 @@ test("baseline health exposes no public ingress and no command transport", async } }); -test("loopback evidence listener emits no acknowledgement or command bytes", async () => { +test("discovery-only ingress persists HEADER2 before acknowledging packages", async () => { + const captured = []; + const runtime = createDeviceGatewayRuntime({ + healthPort: 0, + tcpHost: "0.0.0.0", + tcpPort: 0, + listenEnabled: true, + publicIngressEnabled: true, + now: () => new Date(0x52db95de * 1000), + onDiscovery: async (value) => captured.push(value), + }); + const addresses = await runtime.start(); + const client = await connectAndCollect(addresses.tcpAddress.port); + try { + client.socket.write(specificationHeader.subarray(0, 4)); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(client.bytes().length, 0); + + client.socket.write(specificationHeader.subarray(4)); + await client.waitForBytes(9); + assert.equal( + client.bytes().subarray(0, 9).toString("hex").toUpperCase(), + "7B0400A0DE95DB527D", + ); + assert.equal(captured.length, 1); + assert.equal(captured[0].identifier.value, "865209039777769"); + assert.equal(captured[0].evidence.framingStatus, "verified"); + assert.equal(captured[0].commandTransport, undefined); + + client.socket.write(specificationPackage); + await client.waitForBytes(13); + assert.equal( + client.bytes().subarray(9).toString("hex").toUpperCase(), + "7B00017D", + ); + assert.equal(runtime.status().totalDiscoveries, 1); + assert.equal(runtime.status().totalPackagesAcknowledged, 1); + assert.equal(runtime.status().commandTransport, "disabled"); + assert.equal(runtime.status().publicIngress, "discovery-only"); + + const response = await fetch( + `http://127.0.0.1:${addresses.healthAddress.port}/healthz`, + ); + const body = await response.json(); + assert.equal(body.framing, "verified-read-only"); + assert.equal(body.tcpListener, "discovery-only"); + assert.equal(body.publicIngress, "discovery-only"); + assert.equal(body.commandTransport, "disabled"); + } finally { + client.socket.destroy(); + await runtime.stop(); + } +}); + +test("does not acknowledge malformed or unverified initial bytes", async () => { const captured = []; const runtime = createDeviceGatewayRuntime({ healthPort: 0, tcpPort: 0, listenEnabled: true, - onEvidence: (value) => captured.push(value), + onDiscovery: async (value) => captured.push(value), }); const addresses = await runtime.start(); try { const received = await sendAndCollect( addresses.tcpAddress.port, - Buffer.from( - "unverified-frame-with-fake-identifier-000000000000001", - "utf8", - ), + Buffer.from("not-a-b2-header", "utf8"), ); assert.equal(received.length, 0); - assert.equal(captured.length, 1); - assert.equal(captured[0].evidence.identifierExtracted, false); - assert.equal( - JSON.stringify(captured).includes("000000000000001"), - false, - ); - assert.equal(runtime.status().totalEvidence, 1); - assert.equal(runtime.status().commandTransport, "disabled"); + assert.equal(captured.length, 0); + assert.equal(runtime.status().totalRejected, 1); } finally { await runtime.stop(); } }); +test("public ingress requires an authenticated discovery sink", () => { + assert.throws( + () => createDeviceGatewayRuntime({ + listenEnabled: true, + publicIngressEnabled: true, + tcpHost: "0.0.0.0", + }), + /device_gateway_discovery_sink_required/, + ); +}); + test("baseline rejects non-loopback binding", () => { assert.throws( () => createDeviceGatewayRuntime({ @@ -66,7 +131,7 @@ test("baseline rejects non-loopback binding", () => { ); }); -test("container health may bind all interfaces while TCP stays loopback-only", async () => { +test("container health may bind all interfaces while TCP stays disabled", async () => { const runtime = createDeviceGatewayRuntime({ healthHost: "0.0.0.0", healthPort: 0, @@ -82,6 +147,40 @@ test("container health may bind all interfaces while TCP stays loopback-only", a } }); +function connectAndCollect(port) { + return new Promise((resolve, reject) => { + const chunks = []; + let byteLength = 0; + const waiters = []; + const socket = connect({ host: "127.0.0.1", port }, () => { + resolve({ + socket, + bytes: () => Buffer.concat(chunks, byteLength), + waitForBytes: (minimum) => { + if (byteLength >= minimum) return Promise.resolve(); + return new Promise((waitResolve, waitReject) => { + waiters.push({ minimum, waitResolve, waitReject }); + }); + }, + }); + }); + socket.on("data", (chunk) => { + chunks.push(chunk); + byteLength += chunk.length; + for (let index = waiters.length - 1; index >= 0; index -= 1) { + if (byteLength >= waiters[index].minimum) { + waiters[index].waitResolve(); + waiters.splice(index, 1); + } + } + }); + socket.on("error", (error) => { + for (const waiter of waiters.splice(0)) waiter.waitReject(error); + reject(error); + }); + }); +} + function sendAndCollect(port, payload) { return new Promise((resolve, reject) => { const chunks = []; @@ -89,7 +188,7 @@ function sendAndCollect(port, payload) { socket.end(payload); }); socket.on("data", (chunk) => chunks.push(chunk)); - socket.on("end", () => resolve(Buffer.concat(chunks))); + socket.on("close", () => resolve(Buffer.concat(chunks))); socket.on("error", reject); }); } diff --git a/infra/deploy-runner/build-device-plane-artifact.mjs b/infra/deploy-runner/build-device-plane-artifact.mjs index 0339436..b3b1cd7 100644 --- a/infra/deploy-runner/build-device-plane-artifact.mjs +++ b/infra/deploy-runner/build-device-plane-artifact.mjs @@ -58,10 +58,7 @@ await assertSourceBoundary(); try { await mkdir(payload, { recursive: true }); for (const sourceRelative of files) { - const source = ( - patchId === "device-plane-foundation-20260725-001" - && sourceRelative === "docker-compose.device-plane.yml" - ) + const source = sourceRelative === "docker-compose.device-plane.yml" ? failedFoundationCompose : resolve(sourceRoot, sourceRelative); await copySafe( @@ -119,9 +116,7 @@ try { } async function assertSourceBoundary() { - const composeSource = patchId === "device-plane-foundation-20260725-001" - ? failedFoundationCompose - : resolve(sourceRoot, "docker-compose.device-plane.yml"); + const composeSource = failedFoundationCompose; const compose = await readFile( composeSource, "utf8", diff --git a/infra/deploy-runner/build-device-plane-b2-discovery-ingress-artifact.mjs b/infra/deploy-runner/build-device-plane-b2-discovery-ingress-artifact.mjs new file mode 100644 index 0000000..6ed336a --- /dev/null +++ b/infra/deploy-runner/build-device-plane-b2-discovery-ingress-artifact.mjs @@ -0,0 +1,270 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { + cp, + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const platformRoot = resolve(scriptDir, "../.."); +const sourceRoot = resolve(platformRoot, "device-plane"); +const artifactDir = resolve( + process.env.NODEDC_DEPLOY_ARTIFACT_DIR + || resolve(scriptDir, "../deploy-artifacts"), +); +const [ + patchId = "device-plane-b2-discovery-ingress-20260726-001", + ...extra +] = process.argv.slice(2); + +if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) { + throw new Error( + "usage: build-device-plane-b2-discovery-ingress-artifact.mjs " + + "[patch-id]", + ); +} + +const files = [ + ".dockerignore", + "package.json", + "package-lock.json", + "docker-compose.device-plane.yml", + "packages/device-protocol-contract", + "packages/arusnavi-b2-adapter", + "services/device-control-core", + "services/device-gateway", + "deployment/device-plane-b2-discovery-ingress-v1.json", +]; +const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]); +const ignoredDirectoryNames = new Set(["test"]); +const stage = await mkdtemp( + join(tmpdir(), "nodedc-device-plane-b2-discovery-ingress-"), +); +const payload = join(stage, "payload"); +const target = join( + artifactDir, + `nodedc-device-plane-${patchId}.tgz`, +); + +await assertBoundary(); + +try { + await mkdir(payload, { recursive: true }); + for (const sourceRelative of files) { + await copySafe( + resolve(sourceRoot, sourceRelative), + join(payload, sourceRelative), + ); + } + await writeFile( + join(stage, "manifest.env"), + `id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`, + "utf8", + ); + await writeFile( + join(stage, "files.txt"), + `${files.join("\n")}\n`, + "utf8", + ); + await mkdir(artifactDir, { recursive: true }); + + const tar = spawnSync( + "python3", + ["-c", canonicalTarScript(), target, stage], + { + encoding: "utf8", + maxBuffer: 128 * 1024 * 1024, + }, + ); + if (tar.status !== 0) { + throw new Error(`tar_failed:${tar.stderr || tar.stdout}`); + } + + const digest = createHash("sha256") + .update(await readFile(target)) + .digest("hex"); + console.log(JSON.stringify({ + ok: true, + patchId, + artifact: target, + sha256: digest, + component: "device-plane", + transition: "verified-b2-discovery-only", + entries: files, + services: ["device-control-core", "device-gateway"], + preservedRuntime: [ + "device-postgres", + "nodedc-device-plane-postgres-data", + "Gelios", + ], + ingress: { + transport: "tcp", + published: "0.0.0.0:9921:9921", + mode: "discovery-only", + framing: "verified-read-only", + lifecycle: "quarantine", + commandTransport: "disabled", + }, + rollback: "restore-source-and-predecessor-stateless-runtime", + excluded: [ + ".env*", + "node_modules", + "**/test", + "docs", + "runtime", + "secrets", + ], + }, null, 2)); +} finally { + await rm(stage, { recursive: true, force: true }); +} + +async function assertBoundary() { + const compose = await readFile( + resolve(sourceRoot, "docker-compose.device-plane.yml"), + "utf8", + ); + for (const fragment of [ + 'DEVICE_DISCOVERY_INGEST_ENABLED: "true"', + 'DEVICE_GATEWAY_LISTEN_ENABLED: "true"', + 'DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED: "true"', + "DEVICE_GATEWAY_CORE_URL: http://device-control-core:18120", + "DEVICE_GATEWAY_CORE_TOKEN_FILE: /run/nodedc-secrets/gateway-core-token", + '"127.0.0.1:18120:18120"', + '"127.0.0.1:18121:18121"', + '"0.0.0.0:9921:9921"', + "name: nodedc-device-plane-private", + "internal: true", + "name: nodedc-device-plane-control", + "internal: false", + 'com.docker.network.bridge.enable_ip_masquerade: "false"', + "name: nodedc-device-plane-postgres-data", + "pull_policy: never", + ]) { + if (!compose.includes(fragment)) { + throw new Error( + `device_plane_b2_ingress_boundary_missing:${fragment}`, + ); + } + } + for (const forbidden of [ + "POSTGRES_PASSWORD:", + "DEVICE_GATEWAY_CORE_TOKEN:", + "DEVICE_IDENTIFIER_PEPPER:", + "DEVICE_GATEWAY_COMMAND", + "9921:9921/udp", + ]) { + if (compose.includes(forbidden)) { + throw new Error( + `device_plane_b2_ingress_boundary_violation:${forbidden}`, + ); + } + } + + const descriptor = JSON.parse(await readFile( + resolve( + sourceRoot, + "deployment/device-plane-b2-discovery-ingress-v1.json", + ), + "utf8", + )); + const expected = { + schemaVersion: "nodedc.device-plane.b2-discovery-ingress.v1", + mode: "verified-b2-discovery-only", + predecessorPatchId: + "device-plane-foundation-network-publication-20260725-003", + predecessorArtifactSha256: + "6fdd5a12c310786db1753882fc1378184fe378d2cc533633a8c73c951521b7bf", + sourceAction: "publish-verified-b2-discovery-ingress-source", + runtimeAction: "build-and-recreate-stateless-services", + selectedServices: ["device-control-core", "device-gateway"], + preservedServices: ["device-postgres"], + privateNetwork: "nodedc-device-plane-private", + controlNetwork: "nodedc-device-plane-control", + publishedPorts: [ + "127.0.0.1:18120:18120", + "127.0.0.1:18121:18121", + "0.0.0.0:9921:9921/tcp", + ], + protocolProfile: "arusnavi.b2.internal.v1", + framingSpecification: + "arusnavi.internal.protocol-sheet.gid-12.v1", + identityTrust: "claimed-not-ownership-proof", + discoveryLifecycle: "quarantine", + commandTransport: "disabled", + gelios: "untouched", + databaseVolume: "nodedc-device-plane-postgres-data", + rollback: "restore-source-and-predecessor-stateless-runtime", + }; + if (JSON.stringify(descriptor) !== JSON.stringify(expected)) { + throw new Error("device_plane_b2_ingress_descriptor_mismatch"); + } +} + +function canonicalTarScript() { + return [ + "import gzip,io,pathlib,sys,tarfile", + "root=pathlib.Path(sys.argv[2])", + "with open(sys.argv[1],'wb') as out:", + " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", + " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", + " for top in ('manifest.env','files.txt','payload'):", + " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", + " for x in paths:", + " info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())", + " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", + " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", + ].join("\n"); +} + +async function copySafe(source, destination) { + const sourceStat = await lstat(source); + if (sourceStat.isSymbolicLink()) { + throw new Error( + `source_symlink_rejected:${relative(sourceRoot, source)}`, + ); + } + if (sourceStat.isFile()) { + await mkdir(dirname(destination), { recursive: true }); + await cp(source, destination, { + force: true, + verbatimSymlinks: true, + }); + return; + } + if (!sourceStat.isDirectory()) { + throw new Error(`source_type_rejected:${source}`); + } + + await mkdir(destination, { recursive: true }); + for (const entry of await readdir(source, { withFileTypes: true })) { + if ( + ignoredBasenames.has(entry.name) + || entry.name.startsWith(".env") + || ( + entry.isDirectory() + && ignoredDirectoryNames.has(entry.name) + ) + ) { + continue; + } + const childSource = join(source, entry.name); + const childDestination = join(destination, entry.name); + if (entry.isSymbolicLink()) { + throw new Error( + `source_symlink_rejected:${relative(sourceRoot, childSource)}`, + ); + } + await copySafe(childSource, childDestination); + } +} diff --git a/infra/deploy-runner/build-device-plane-foundation-network-publication-artifact.mjs b/infra/deploy-runner/build-device-plane-foundation-network-publication-artifact.mjs index ac8c5f2..1498af3 100644 --- a/infra/deploy-runner/build-device-plane-foundation-network-publication-artifact.mjs +++ b/infra/deploy-runner/build-device-plane-foundation-network-publication-artifact.mjs @@ -18,6 +18,10 @@ import { fileURLToPath } from "node:url"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const platformRoot = resolve(scriptDir, "../.."); const sourceRoot = resolve(platformRoot, "device-plane"); +const networkPublicationCompose = resolve( + scriptDir, + "fixtures/device-plane-foundation-network-publication-v1.yml", +); const artifactDir = resolve( process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"), @@ -61,8 +65,11 @@ await assertBoundary(); try { await mkdir(payload, { recursive: true }); for (const sourceRelative of files) { + const source = sourceRelative === "docker-compose.device-plane.yml" + ? networkPublicationCompose + : resolve(sourceRoot, sourceRelative); await copySafe( - resolve(sourceRoot, sourceRelative), + source, join(payload, sourceRelative), ); } @@ -134,7 +141,7 @@ try { async function assertBoundary() { const compose = await readFile( - resolve(sourceRoot, "docker-compose.device-plane.yml"), + networkPublicationCompose, "utf8", ); for (const fragment of [ diff --git a/infra/deploy-runner/fixtures/device-plane-foundation-network-publication-v1.yml b/infra/deploy-runner/fixtures/device-plane-foundation-network-publication-v1.yml new file mode 100644 index 0000000..4943189 --- /dev/null +++ b/infra/deploy-runner/fixtures/device-plane-foundation-network-publication-v1.yml @@ -0,0 +1,126 @@ +services: + device-postgres: + image: postgres:16-alpine + pull_policy: missing + restart: unless-stopped + environment: + POSTGRES_DB: device_plane + POSTGRES_USER: device_plane + POSTGRES_PASSWORD_FILE: /run/nodedc-secrets/postgres-password + volumes: + - type: volume + source: device-plane-postgres-data + target: /var/lib/postgresql/data + - type: bind + source: /volume1/docker/nodedc-device-plane/secrets/postgres-password + target: /run/nodedc-secrets/postgres-password + read_only: true + bind: + create_host_path: false + networks: + - device-plane-private + healthcheck: + test: ["CMD-SHELL", "pg_isready -U device_plane -d device_plane"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + + device-control-core: + image: nodedc/device-control-core:local + pull_policy: never + restart: unless-stopped + user: "1000:1000" + read_only: true + tmpfs: + - /tmp:size=16m,mode=1777 + environment: + HOST: 0.0.0.0 + PORT: "18120" + DEVICE_DATABASE_HOST: device-postgres + DEVICE_DATABASE_PORT: "5432" + DEVICE_DATABASE_NAME: device_plane + DEVICE_DATABASE_USER: device_plane + DEVICE_DATABASE_PASSWORD_FILE: /run/nodedc-secrets/postgres-password + DEVICE_DATABASE_POOL_SIZE: "10" + DEVICE_DISCOVERY_INGEST_ENABLED: "false" + volumes: + - type: bind + source: /volume1/docker/nodedc-device-plane/secrets/postgres-password + target: /run/nodedc-secrets/postgres-password + read_only: true + bind: + create_host_path: false + ports: + - "127.0.0.1:18120:18120" + networks: + - device-plane-private + - device-plane-control + depends_on: + device-postgres: + condition: service_healthy + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + healthcheck: + test: + - CMD + - node + - -e + - fetch('http://127.0.0.1:18120/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1)) + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + + device-gateway: + image: nodedc/device-gateway:local + pull_policy: never + restart: unless-stopped + user: "1000:1000" + read_only: true + tmpfs: + - /tmp:size=16m,mode=1777 + environment: + DEVICE_GATEWAY_HEALTH_HOST: 0.0.0.0 + DEVICE_GATEWAY_HEALTH_PORT: "18121" + DEVICE_GATEWAY_LISTEN_ENABLED: "false" + DEVICE_GATEWAY_TCP_HOST: 127.0.0.1 + DEVICE_GATEWAY_TCP_PORT: "9921" + DEVICE_GATEWAY_MAX_SESSIONS: "100" + DEVICE_GATEWAY_SESSION_TIMEOUT_MS: "10000" + ports: + - "127.0.0.1:18121:18121" + networks: + - device-plane-private + - device-plane-control + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + healthcheck: + test: + - CMD + - node + - -e + - fetch('http://127.0.0.1:18121/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1)) + interval: 10s + timeout: 5s + retries: 12 + start_period: 10s + +networks: + device-plane-private: + name: nodedc-device-plane-private + internal: true + device-plane-control: + name: nodedc-device-plane-control + driver: bridge + internal: false + driver_opts: + com.docker.network.bridge.enable_ip_masquerade: "false" + +volumes: + device-plane-postgres-data: + name: nodedc-device-plane-postgres-data diff --git a/infra/deploy-runner/nodedc-deploy b/infra/deploy-runner/nodedc-deploy index 6b67477..c213bbb 100755 --- a/infra/deploy-runner/nodedc-deploy +++ b/infra/deploy-runner/nodedc-deploy @@ -77,6 +77,22 @@ DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_ENTRIES = ( *DEVICE_PLANE_FOUNDATION_ENTRIES, DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_REL, ) +DEVICE_PLANE_B2_DISCOVERY_INGRESS_REL = ( + "deployment/device-plane-b2-discovery-ingress-v1.json" +) +DEVICE_PLANE_B2_DISCOVERY_INGRESS_ENTRIES = ( + *DEVICE_PLANE_FOUNDATION_ENTRIES, + DEVICE_PLANE_B2_DISCOVERY_INGRESS_REL, +) +DEVICE_PLANE_B2_DISCOVERY_INGRESS_PREDECESSOR_PATCH_ID = ( + "device-plane-foundation-network-publication-20260725-003" +) +DEVICE_PLANE_B2_DISCOVERY_INGRESS_PREDECESSOR_ARTIFACT_SHA256 = ( + "6fdd5a12c310786db1753882fc1378184fe378d2cc533633a8c73c951521b7bf" +) +DEVICE_PLANE_B2_DISCOVERY_INGRESS_COMPOSE_SHA256 = ( + "50bc7842481ea73b89891a65bf243c2ecb7bbb4c093e95e3c20ddbfaa8ad6726" +) DEVICE_PLANE_FOUNDATION_FAILED_PATCH_ID = ( "device-plane-foundation-20260725-001" ) @@ -2865,6 +2881,7 @@ def allowed_payload_path(component, rel): DEVICE_PLANE_POSTGRES_BOOTSTRAP_REL, DEVICE_PLANE_FOUNDATION_RECOVERY_REL, DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_REL, + DEVICE_PLANE_B2_DISCOVERY_INGRESS_REL, "packages/device-protocol-contract", "packages/arusnavi-b2-adapter", "services/device-control-core", @@ -7901,6 +7918,11 @@ def load_artifact(artifact, work_dir): validate_device_plane_foundation_network_publication_payload( payload_dir ) + if is_device_plane_b2_discovery_ingress_slice( + manifest["component"], + entries, + ): + validate_device_plane_b2_discovery_ingress_payload(payload_dir) if manifest["component"] == "n8n-private-extension": validate_n8n_private_extension_release(payload_dir, entries) if manifest["component"] == "engine": @@ -8238,6 +8260,14 @@ def is_device_plane_foundation_network_publication_slice(component, entries): ) +def is_device_plane_b2_discovery_ingress_slice(component, entries): + return ( + component == "device-plane" + and entries is not None + and tuple(entries) == DEVICE_PLANE_B2_DISCOVERY_INGRESS_ENTRIES + ) + + def expected_device_plane_foundation_recovery_descriptor(): return { "schemaVersion": "nodedc.device-plane.foundation-recovery.v1", @@ -8329,6 +8359,91 @@ def validate_device_plane_foundation_network_publication_payload(payload_dir): return descriptor +def expected_device_plane_b2_discovery_ingress_descriptor(): + return { + "schemaVersion": "nodedc.device-plane.b2-discovery-ingress.v1", + "mode": "verified-b2-discovery-only", + "predecessorPatchId": ( + DEVICE_PLANE_B2_DISCOVERY_INGRESS_PREDECESSOR_PATCH_ID + ), + "predecessorArtifactSha256": ( + DEVICE_PLANE_B2_DISCOVERY_INGRESS_PREDECESSOR_ARTIFACT_SHA256 + ), + "sourceAction": "publish-verified-b2-discovery-ingress-source", + "runtimeAction": "build-and-recreate-stateless-services", + "selectedServices": [ + "device-control-core", + "device-gateway", + ], + "preservedServices": ["device-postgres"], + "privateNetwork": DEVICE_PLANE_PRIVATE_NETWORK, + "controlNetwork": DEVICE_PLANE_CONTROL_NETWORK, + "publishedPorts": [ + "127.0.0.1:18120:18120", + "127.0.0.1:18121:18121", + "0.0.0.0:9921:9921/tcp", + ], + "protocolProfile": "arusnavi.b2.internal.v1", + "framingSpecification": ( + "arusnavi.internal.protocol-sheet.gid-12.v1" + ), + "identityTrust": "claimed-not-ownership-proof", + "discoveryLifecycle": "quarantine", + "commandTransport": "disabled", + "gelios": "untouched", + "databaseVolume": DEVICE_PLANE_POSTGRES_VOLUME, + "rollback": "restore-source-and-predecessor-stateless-runtime", + } + + +def validate_device_plane_b2_discovery_ingress_payload(payload_dir): + descriptor = read_strict_json( + payload_dir / DEVICE_PLANE_B2_DISCOVERY_INGRESS_REL, + "Device Plane B2 discovery ingress descriptor", + max_bytes=16 * 1024, + ) + if descriptor != expected_device_plane_b2_discovery_ingress_descriptor(): + die("Device Plane B2 discovery ingress descriptor mismatch") + compose = payload_dir / "docker-compose.device-plane.yml" + if ( + compose.is_symlink() + or not compose.is_file() + or sha256_file(compose) + != DEVICE_PLANE_B2_DISCOVERY_INGRESS_COMPOSE_SHA256 + ): + die("Device Plane B2 discovery ingress Compose mismatch") + return descriptor + + +def validate_device_plane_b2_discovery_ingress_evidence(payload_dir): + descriptor = validate_device_plane_b2_discovery_ingress_payload( + payload_dir + ) + if not state_has_patch_id( + DEVICE_PLANE_B2_DISCOVERY_INGRESS_PREDECESSOR_PATCH_ID + ): + die("Device Plane B2 discovery ingress predecessor patch missing") + if not state_has_sha( + DEVICE_PLANE_B2_DISCOVERY_INGRESS_PREDECESSOR_ARTIFACT_SHA256 + ): + die("Device Plane B2 discovery ingress predecessor artifact missing") + validate_device_plane_foundation_network_publication_installed_source() + runtime = validate_device_plane_foundation_runtime( + network_publication=True + ) + assert_loopback_tcp_port_closed(9921) + target_descriptor = ( + component_root("device-plane") + / DEVICE_PLANE_B2_DISCOVERY_INGRESS_REL + ) + if target_descriptor.exists() or target_descriptor.is_symlink(): + die("Device Plane B2 discovery ingress descriptor already installed") + return { + "mode": descriptor["mode"], + "runtime": runtime, + } + + def device_plane_service_container_ids(service): if service not in DEVICE_PLANE_RUNTIME_SERVICES: die(f"Device Plane runtime service is not registered: {service}") @@ -9233,6 +9348,262 @@ def validate_device_plane_foundation_runtime(network_publication=False): return accepted +def validate_device_plane_b2_discovery_ingress_runtime(runtime_before): + validate_device_plane_runtime_secret_metadata() + before_names = device_plane_inventory_service_names(runtime_before) + if set(before_names) != set(DEVICE_PLANE_RUNTIME_SERVICES): + die("Device Plane B2 ingress predecessor inventory mismatch") + before = { + item["service"]: item + for item in runtime_before["services"] + } + contracts = { + "device-control-core": { + "image": DEVICE_PLANE_CONTROL_CORE_IMAGE, + "user": "1000:1000", + "ports": { + "18120/tcp": [{ + "HostIp": "127.0.0.1", + "HostPort": "18120", + }], + }, + "networks": { + DEVICE_PLANE_PRIVATE_NETWORK, + DEVICE_PLANE_CONTROL_NETWORK, + }, + }, + "device-gateway": { + "image": DEVICE_PLANE_GATEWAY_IMAGE, + "user": "1000:1000", + "ports": { + "18121/tcp": [{ + "HostIp": "127.0.0.1", + "HostPort": "18121", + }], + "9921/tcp": [{ + "HostIp": "0.0.0.0", + "HostPort": "9921", + }], + }, + "networks": { + DEVICE_PLANE_PRIVATE_NETWORK, + DEVICE_PLANE_CONTROL_NETWORK, + }, + }, + "device-postgres": { + "image": "postgres:16-alpine", + "user": "", + "ports": {}, + "networks": {DEVICE_PLANE_PRIVATE_NETWORK}, + }, + } + accepted = {} + containers = {} + for service, contract in contracts.items(): + container_ids = device_plane_service_container_ids(service) + if len(container_ids) != 1: + die(f"Device Plane B2 ingress service count mismatch: {service}") + container = inspect_device_plane_container(container_ids[0]) + containers[service] = container + state = container.get("State") or {} + config = container.get("Config") or {} + host_config = container.get("HostConfig") or {} + labels = config.get("Labels") or {} + networks = (container.get("NetworkSettings") or {}).get( + "Networks" + ) or {} + actual_ports = (container.get("NetworkSettings") or {}).get( + "Ports" + ) or {} + if ( + state.get("Status") != "running" + or state.get("Running") is not True + or state.get("Restarting") is True + or state.get("ExitCode") != 0 + or state.get("Error") not in ("", None) + or (state.get("Health") or {}).get("Status") != "healthy" + or int(container.get("RestartCount") or 0) != 0 + or config.get("Image") != contract["image"] + or config.get("User", "") != contract["user"] + or (host_config.get("PortBindings") or {}) != contract["ports"] + or actual_ports != contract["ports"] + or set(networks) != contract["networks"] + or (host_config.get("RestartPolicy") or {}).get("Name") + != "unless-stopped" + or labels.get("com.docker.compose.project") + != "nodedc-device-plane" + or labels.get("com.docker.compose.service") != service + ): + die(f"Device Plane B2 ingress runtime mismatch: {service}") + accepted[service] = { + "containerId": container.get("Id"), + "imageId": container.get("Image"), + } + + if ( + accepted["device-postgres"]["containerId"] + != before["device-postgres"]["containerId"] + or accepted["device-postgres"]["imageId"] + != before["device-postgres"]["imageId"] + ): + die("Device Plane B2 ingress changed PostgreSQL generation") + for service in ("device-control-core", "device-gateway"): + if ( + accepted[service]["containerId"] + == before[service]["containerId"] + or accepted[service]["imageId"] == before[service]["imageId"] + ): + die( + "Device Plane B2 ingress stateless generation not replaced: " + f"{service}" + ) + host_config = containers[service].get("HostConfig") or {} + if ( + host_config.get("ReadonlyRootfs") is not True + or set(host_config.get("CapDrop") or ()) != {"ALL"} + or "no-new-privileges:true" + not in set(host_config.get("SecurityOpt") or ()) + ): + die( + "Device Plane B2 ingress hardening mismatch: " + f"{service}" + ) + + core_environment = container_environment( + containers["device-control-core"], + "Device Plane B2 Control Core", + ) + core_required = { + "DEVICE_DISCOVERY_INGEST_ENABLED": "true", + "DEVICE_GATEWAY_CORE_TOKEN_FILE": + "/run/nodedc-secrets/gateway-core-token", + "DEVICE_IDENTIFIER_PEPPER_FILE": + "/run/nodedc-secrets/identifier-pepper", + } + if any( + core_environment.get(key) != value + for key, value in core_required.items() + ): + die("Device Plane B2 Control Core environment mismatch") + + gateway_environment = container_environment( + containers["device-gateway"], + "Device Plane B2 Gateway", + ) + gateway_required = { + "DEVICE_GATEWAY_LISTEN_ENABLED": "true", + "DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED": "true", + "DEVICE_GATEWAY_TCP_HOST": "0.0.0.0", + "DEVICE_GATEWAY_TCP_PORT": "9921", + "DEVICE_GATEWAY_CORE_URL": "http://device-control-core:18120", + "DEVICE_GATEWAY_CORE_TOKEN_FILE": + "/run/nodedc-secrets/gateway-core-token", + "DEVICE_GATEWAY_CORE_TIMEOUT_MS": "5000", + "DEVICE_GATEWAY_MAX_BUFFERED_BYTES": "65536", + "DEVICE_GATEWAY_MAX_SESSIONS": "100", + "DEVICE_GATEWAY_MAX_SESSIONS_PER_ADDRESS": "10", + "DEVICE_GATEWAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS": "30", + "DEVICE_GATEWAY_SESSION_TIMEOUT_MS": "10000", + } + if any( + gateway_environment.get(key) != value + for key, value in gateway_required.items() + ): + die("Device Plane B2 Gateway environment mismatch") + for environment in (core_environment, gateway_environment): + for forbidden in ( + "DEVICE_GATEWAY_CORE_TOKEN", + "DEVICE_IDENTIFIER_PEPPER", + "DEVICE_GATEWAY_COMMAND_TOKEN", + ): + if forbidden in environment: + die("Device Plane B2 plaintext secret boundary mismatch") + + core_mounts = { + mount.get("Destination"): mount + for mount in containers["device-control-core"].get("Mounts") or [] + } + expected_core_mounts = { + "/run/nodedc-secrets/postgres-password": + DEVICE_PLANE_POSTGRES_PASSWORD_FILE, + "/run/nodedc-secrets/gateway-core-token": + DEVICE_PLANE_GATEWAY_CORE_TOKEN_FILE, + "/run/nodedc-secrets/identifier-pepper": + DEVICE_PLANE_IDENTIFIER_PEPPER_FILE, + } + if set(core_mounts) != set(expected_core_mounts): + die("Device Plane B2 Control Core mount set mismatch") + for destination, source in expected_core_mounts.items(): + mount = core_mounts[destination] + if ( + mount.get("Type") != "bind" + or mount.get("Source") != str(source) + or mount.get("RW") is not False + ): + die("Device Plane B2 Control Core secret mount mismatch") + + gateway_mounts = ( + containers["device-gateway"].get("Mounts") or [] + ) + if ( + len(gateway_mounts) != 1 + or gateway_mounts[0].get("Type") != "bind" + or gateway_mounts[0].get("Source") + != str(DEVICE_PLANE_GATEWAY_CORE_TOKEN_FILE) + or gateway_mounts[0].get("Destination") + != "/run/nodedc-secrets/gateway-core-token" + or gateway_mounts[0].get("RW") is not False + ): + die("Device Plane B2 Gateway secret mount mismatch") + + postgres_mounts = containers["device-postgres"].get("Mounts") or [] + postgres_secret = [ + mount + for mount in postgres_mounts + if mount.get("Destination") + == "/run/nodedc-secrets/postgres-password" + ] + postgres_volume = [ + mount + for mount in postgres_mounts + if mount.get("Destination") == "/var/lib/postgresql/data" + ] + if ( + len(postgres_mounts) != 2 + or len(postgres_secret) != 1 + or postgres_secret[0].get("Type") != "bind" + or postgres_secret[0].get("Source") + != str(DEVICE_PLANE_POSTGRES_PASSWORD_FILE) + or postgres_secret[0].get("RW") is not False + or len(postgres_volume) != 1 + or postgres_volume[0].get("Type") != "volume" + or postgres_volume[0].get("Name") + != DEVICE_PLANE_POSTGRES_VOLUME + or postgres_volume[0].get("RW") is not True + ): + die("Device Plane B2 PostgreSQL preserved mount mismatch") + + expected_private_ids = { + accepted[service]["containerId"] + for service in DEVICE_PLANE_RUNTIME_SERVICES + } + validate_device_plane_network_contract( + DEVICE_PLANE_PRIVATE_NETWORK, + internal=True, + expected_container_ids=expected_private_ids, + ) + validate_device_plane_network_contract( + DEVICE_PLANE_CONTROL_NETWORK, + internal=False, + expected_container_ids={ + accepted["device-control-core"]["containerId"], + accepted["device-gateway"]["containerId"], + }, + ) + assert_loopback_tcp_port_open(9921) + return accepted + + def validate_device_plane_runtime_secret_metadata(): try: directory_stat = DEVICE_PLANE_SECRET_DIR.lstat() @@ -13237,6 +13608,7 @@ def plan_artifact(artifact): l2_closed_loop_preflight = None device_plane_foundation_recovery_preflight = None device_plane_network_publication_preflight = None + device_plane_b2_ingress_preflight = None device_plane_runtime_before = None composite_provider_v4_preflight = None provider_rotating_slot_preflight = None @@ -13420,6 +13792,15 @@ def plan_artifact(artifact): payload_dir ) ) + if is_device_plane_b2_discovery_ingress_slice( + manifest["component"], + entries, + ): + device_plane_b2_ingress_preflight = ( + validate_device_plane_b2_discovery_ingress_evidence( + payload_dir + ) + ) component = manifest["component"] root = component_root(component) @@ -14800,7 +15181,11 @@ def plan_artifact(artifact): f"{device_plane_postgres_plan_selection(device_plane_postgres_preflight)}" ) print("device_postgres_volume=preserved:nodedc-device-plane-postgres-data") - print("device_gateway_public_ingress=disabled") + if device_plane_b2_ingress_preflight is not None: + print("device_gateway_public_ingress=discovery-only:tcp:9921") + print("device_control_core_discovery_ingest=enabled:authenticated") + else: + print("device_gateway_public_ingress=disabled") print("device_gateway_command_transport=disabled") print("gelios=untouched") if device_plane_foundation_recovery_preflight is not None: @@ -14875,6 +15260,46 @@ def plan_artifact(artifact): "device_plane_rollback=" "partial-source+internal-only-stateless-runtime" ) + if device_plane_b2_ingress_preflight is not None: + print( + "device_plane_transition=" + f"{device_plane_b2_ingress_preflight['mode']}" + ) + print( + "device_plane_predecessor_patch=" + f"{DEVICE_PLANE_B2_DISCOVERY_INGRESS_PREDECESSOR_PATCH_ID}" + ) + print( + "device_plane_predecessor_artifact_sha256=" + f"{DEVICE_PLANE_B2_DISCOVERY_INGRESS_PREDECESSOR_ARTIFACT_SHA256}" + ) + print( + "device_plane_runtime_mutation=" + "build+recreate:device-control-core,device-gateway" + ) + print( + "device_plane_runtime_services=" + "preserved:device-postgres" + ) + print( + "device_plane_actual_ports=" + "required:127.0.0.1:18120,127.0.0.1:18121," + "0.0.0.0:9921/tcp" + ) + print( + "device_gateway_framing=" + "verified-read-only:" + "arusnavi.internal.protocol-sheet.gid-12.v1" + ) + print( + "device_gateway_identity=" + "header2-imei:claimed-not-ownership-proof" + ) + print("device_gateway_discovery_lifecycle=quarantine") + print( + "device_plane_rollback=" + "source+predecessor-stateless-runtime" + ) if device_plane_postgres_preflight is not None: print( "device_postgres_bootstrap=" @@ -16076,6 +16501,14 @@ def assert_loopback_tcp_port_closed(port): die(f"unexpected loopback TCP listener is open: {port}") +def assert_loopback_tcp_port_open(port): + try: + connection = socket.create_connection(("127.0.0.1", port), timeout=3) + except OSError as exc: + die(f"expected loopback TCP listener is closed: {port}: {exc}") + connection.close() + + def external_data_plane_healthcheck(require_managed=True): expected_json = { "ok": True, @@ -16170,6 +16603,30 @@ def component_healthchecks(component, entries=None, services=None): if component == "module-foundry": return (module_foundry_healthcheck(),) if component == "device-plane": + if is_device_plane_b2_discovery_ingress_slice(component, entries): + return ( + { + "url": "http://127.0.0.1:18120/healthz", + "expected_json": { + "ok": True, + "service": "nodedc-device-control-core", + "database": "ready", + "discoveryIngest": "enabled", + "commandTransport": "disabled", + }, + }, + { + "url": "http://127.0.0.1:18121/healthz", + "expected_json": { + "ok": True, + "service": "nodedc-device-gateway", + "framing": "verified-read-only", + "tcpListener": "discovery-only", + "publicIngress": "discovery-only", + "commandTransport": "disabled", + }, + }, + ) selected_services = ( tuple(services) if services is not None @@ -16632,6 +17089,22 @@ def run_healthchecks(component, entries=None, services=None): ) assert_loopback_tcp_port_closed(9921) return + if is_device_plane_b2_discovery_ingress_slice(component, entries): + if tuple(services or ()) != ( + "device-control-core", + "device-gateway", + ): + die("Device Plane B2 discovery ingress service set mismatch") + for service in ( + "device-control-core", + "device-gateway", + "device-postgres", + ): + healthcheck_compose_service("device-plane", service) + for check in component_healthchecks(component, entries, services): + healthcheck_url(check) + assert_loopback_tcp_port_open(9921) + return if component == "platform" and entries is not None and is_platform_provider_catalog_only(entries): return if is_engine_l2_closed_loop_slice(component, entries): @@ -17449,6 +17922,13 @@ def apply_artifact(artifact): validate_device_plane_foundation_network_publication_evidence( payload_dir ) + if is_device_plane_b2_discovery_ingress_slice( + component, + entries, + ): + validate_device_plane_b2_discovery_ingress_evidence( + payload_dir + ) if not root.is_dir(): if bootstrap_root: root.mkdir(parents=True, exist_ok=True) @@ -17830,6 +18310,18 @@ def apply_artifact(artifact): ): die("Engine L2 closed-loop app generation was not recreated") run_healthchecks(component, entries, services) + if is_device_plane_b2_discovery_ingress_slice( + component, + entries, + ): + if device_plane_runtime_before is None: + die( + "Device Plane B2 ingress predecessor runtime " + "inventory is missing" + ) + validate_device_plane_b2_discovery_ingress_runtime( + device_plane_runtime_before + ) applied_path = move_artifact(artifact, APPLIED_DIR) append_jsonl(STATE_FILE, { diff --git a/infra/deploy-runner/test_device_plane_b2_discovery_ingress_artifact.py b/infra/deploy-runner/test_device_plane_b2_discovery_ingress_artifact.py new file mode 100644 index 0000000..0a8a7e4 --- /dev/null +++ b/infra/deploy-runner/test_device_plane_b2_discovery_ingress_artifact.py @@ -0,0 +1,400 @@ +#!/usr/bin/env python3 +import hashlib +import importlib.machinery +import importlib.util +import json +import os +import subprocess +import tarfile +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +SCRIPT_DIR = Path(__file__).resolve().parent +BUILDER = ( + SCRIPT_DIR / "build-device-plane-b2-discovery-ingress-artifact.mjs" +) +RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" +COMPOSE = ( + SCRIPT_DIR.parent.parent + / "device-plane/docker-compose.device-plane.yml" +) + + +def load_runner(): + loader = importlib.machinery.SourceFileLoader( + "nodedc_device_plane_b2_ingress_runner_under_test", + str(RUNNER_PATH), + ) + spec = importlib.util.spec_from_loader(loader.name, loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +RUNNER = load_runner() + + +class DevicePlaneB2DiscoveryIngressArtifactTest(unittest.TestCase): + def build(self, artifact_dir, patch_id): + environment = os.environ.copy() + environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) + result = subprocess.run( + ["node", str(BUILDER), patch_id], + check=True, + capture_output=True, + text=True, + env=environment, + ) + return json.loads(result.stdout) + + def test_artifact_is_exact_deterministic_and_database_free(self): + with tempfile.TemporaryDirectory( + prefix="nodedc-device-plane-b2-ingress-", + ) as directory: + artifact_dir = Path(directory) + patch_id = "device-plane-b2-discovery-ingress-unit-001" + first = self.build(artifact_dir, patch_id) + first_bytes = Path(first["artifact"]).read_bytes() + second = self.build(artifact_dir, patch_id) + second_bytes = Path(second["artifact"]).read_bytes() + + self.assertEqual(first_bytes, second_bytes) + self.assertEqual( + first["sha256"], + hashlib.sha256(first_bytes).hexdigest(), + ) + self.assertEqual( + first["transition"], + "verified-b2-discovery-only", + ) + self.assertEqual( + first["services"], + ["device-control-core", "device-gateway"], + ) + self.assertNotIn("device-postgres", first["services"]) + self.assertEqual( + first["entries"], + list(RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_ENTRIES), + ) + + with tarfile.open(first["artifact"], "r:gz") as archive: + files = ( + archive.extractfile("files.txt") + .read() + .decode("utf-8") + .splitlines() + ) + compose = archive.extractfile( + "payload/docker-compose.device-plane.yml" + ).read() + descriptor = json.loads( + archive.extractfile( + "payload/deployment/" + "device-plane-b2-discovery-ingress-v1.json" + ) + .read() + .decode("utf-8") + ) + + self.assertEqual(files, first["entries"]) + self.assertEqual( + hashlib.sha256(compose).hexdigest(), + RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_COMPOSE_SHA256, + ) + self.assertEqual( + descriptor, + RUNNER.expected_device_plane_b2_discovery_ingress_descriptor(), + ) + + def test_compose_opens_only_discovery_tcp_and_preserves_database(self): + compose = COMPOSE.read_text(encoding="utf-8") + postgres, stateless = compose.split(" device-control-core:", 1) + self.assertNotIn("9921", postgres) + self.assertNotIn("device-plane-control", postgres) + self.assertIn( + 'DEVICE_DISCOVERY_INGEST_ENABLED: "true"', + stateless, + ) + self.assertIn( + 'DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED: "true"', + stateless, + ) + self.assertIn('"0.0.0.0:9921:9921"', stateless) + self.assertNotIn("DEVICE_GATEWAY_COMMAND", compose) + self.assertNotIn("POSTGRES_PASSWORD:", compose) + + def test_runner_builds_only_stateless_services_and_accepts_new_health(self): + entries = RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_ENTRIES + self.assertEqual( + RUNNER.component_services("device-plane", entries), + ("device-control-core", "device-gateway"), + ) + builds = RUNNER.component_builds("device-plane", entries) + self.assertEqual(len(builds), 2) + checks = RUNNER.component_healthchecks( + "device-plane", + entries, + ("device-control-core", "device-gateway"), + ) + self.assertEqual( + checks[0]["expected_json"]["discoveryIngest"], + "enabled", + ) + self.assertEqual( + checks[1]["expected_json"]["publicIngress"], + "discovery-only", + ) + self.assertEqual( + checks[1]["expected_json"]["commandTransport"], + "disabled", + ) + + def test_runtime_acceptance_preserves_postgres_and_replaces_stateless(self): + before = { + "schemaVersion": "nodedc.device-plane.runtime-inventory.v1", + "composeProject": "nodedc-device-plane", + "services": [ + runtime_item("device-control-core", "1", "a"), + runtime_item("device-gateway", "2", "b"), + runtime_item("device-postgres", "3", "c"), + ], + } + containers = { + "core": stateless_container( + service="device-control-core", + container_id="4" * 64, + image_id="sha256:" + "d" * 64, + image=RUNNER.DEVICE_PLANE_CONTROL_CORE_IMAGE, + ports={ + "18120/tcp": [{ + "HostIp": "127.0.0.1", + "HostPort": "18120", + }], + }, + environment={ + "DEVICE_DISCOVERY_INGEST_ENABLED": "true", + "DEVICE_GATEWAY_CORE_TOKEN_FILE": + "/run/nodedc-secrets/gateway-core-token", + "DEVICE_IDENTIFIER_PEPPER_FILE": + "/run/nodedc-secrets/identifier-pepper", + }, + mounts=[ + secret_mount( + RUNNER.DEVICE_PLANE_POSTGRES_PASSWORD_FILE, + "/run/nodedc-secrets/postgres-password", + ), + secret_mount( + RUNNER.DEVICE_PLANE_GATEWAY_CORE_TOKEN_FILE, + "/run/nodedc-secrets/gateway-core-token", + ), + secret_mount( + RUNNER.DEVICE_PLANE_IDENTIFIER_PEPPER_FILE, + "/run/nodedc-secrets/identifier-pepper", + ), + ], + ), + "gateway": stateless_container( + service="device-gateway", + container_id="5" * 64, + image_id="sha256:" + "e" * 64, + image=RUNNER.DEVICE_PLANE_GATEWAY_IMAGE, + ports={ + "18121/tcp": [{ + "HostIp": "127.0.0.1", + "HostPort": "18121", + }], + "9921/tcp": [{ + "HostIp": "0.0.0.0", + "HostPort": "9921", + }], + }, + environment={ + "DEVICE_GATEWAY_LISTEN_ENABLED": "true", + "DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED": "true", + "DEVICE_GATEWAY_TCP_HOST": "0.0.0.0", + "DEVICE_GATEWAY_TCP_PORT": "9921", + "DEVICE_GATEWAY_CORE_URL": + "http://device-control-core:18120", + "DEVICE_GATEWAY_CORE_TOKEN_FILE": + "/run/nodedc-secrets/gateway-core-token", + "DEVICE_GATEWAY_CORE_TIMEOUT_MS": "5000", + "DEVICE_GATEWAY_MAX_BUFFERED_BYTES": "65536", + "DEVICE_GATEWAY_MAX_SESSIONS": "100", + "DEVICE_GATEWAY_MAX_SESSIONS_PER_ADDRESS": "10", + "DEVICE_GATEWAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS": + "30", + "DEVICE_GATEWAY_SESSION_TIMEOUT_MS": "10000", + }, + mounts=[ + secret_mount( + RUNNER.DEVICE_PLANE_GATEWAY_CORE_TOKEN_FILE, + "/run/nodedc-secrets/gateway-core-token", + ), + ], + ), + "postgres": postgres_container(), + } + service_ids = { + "device-control-core": ("core",), + "device-gateway": ("gateway",), + "device-postgres": ("postgres",), + } + with ( + mock.patch.object( + RUNNER, + "validate_device_plane_runtime_secret_metadata", + ), + mock.patch.object( + RUNNER, + "device_plane_service_container_ids", + side_effect=lambda service: service_ids[service], + ), + mock.patch.object( + RUNNER, + "inspect_device_plane_container", + side_effect=lambda container_id: containers[container_id], + ), + mock.patch.object( + RUNNER, + "validate_device_plane_network_contract", + ) as network, + mock.patch.object( + RUNNER, + "assert_loopback_tcp_port_open", + ) as port_open, + ): + accepted = ( + RUNNER.validate_device_plane_b2_discovery_ingress_runtime( + before + ) + ) + self.assertEqual( + accepted["device-postgres"]["containerId"], + "3" * 64, + ) + self.assertEqual(network.call_count, 2) + port_open.assert_called_once_with(9921) + + +def runtime_item(service, container_digit, image_digit): + return { + "service": service, + "containerId": container_digit * 64, + "imageId": "sha256:" + image_digit * 64, + "status": "running", + "running": True, + "health": "healthy", + "restartCount": 0, + } + + +def secret_mount(source, destination): + return { + "Type": "bind", + "Source": str(source), + "Destination": destination, + "RW": False, + } + + +def stateless_container( + *, + service, + container_id, + image_id, + image, + ports, + environment, + mounts, +): + return { + "Id": container_id, + "Image": image_id, + "RestartCount": 0, + "State": { + "Status": "running", + "Running": True, + "Restarting": False, + "ExitCode": 0, + "Error": "", + "Health": {"Status": "healthy"}, + }, + "Config": { + "Image": image, + "User": "1000:1000", + "Labels": { + "com.docker.compose.project": "nodedc-device-plane", + "com.docker.compose.service": service, + }, + "Env": [f"{key}={value}" for key, value in environment.items()], + }, + "HostConfig": { + "PortBindings": ports, + "RestartPolicy": {"Name": "unless-stopped"}, + "ReadonlyRootfs": True, + "CapDrop": ["ALL"], + "SecurityOpt": ["no-new-privileges:true"], + }, + "NetworkSettings": { + "Ports": ports, + "Networks": { + RUNNER.DEVICE_PLANE_PRIVATE_NETWORK: {}, + RUNNER.DEVICE_PLANE_CONTROL_NETWORK: {}, + }, + }, + "Mounts": mounts, + } + + +def postgres_container(): + return { + "Id": "3" * 64, + "Image": "sha256:" + "c" * 64, + "RestartCount": 0, + "State": { + "Status": "running", + "Running": True, + "Restarting": False, + "ExitCode": 0, + "Error": "", + "Health": {"Status": "healthy"}, + }, + "Config": { + "Image": "postgres:16-alpine", + "User": "", + "Labels": { + "com.docker.compose.project": "nodedc-device-plane", + "com.docker.compose.service": "device-postgres", + }, + "Env": [], + }, + "HostConfig": { + "PortBindings": {}, + "RestartPolicy": {"Name": "unless-stopped"}, + }, + "NetworkSettings": { + "Ports": {}, + "Networks": { + RUNNER.DEVICE_PLANE_PRIVATE_NETWORK: {}, + }, + }, + "Mounts": [ + secret_mount( + RUNNER.DEVICE_PLANE_POSTGRES_PASSWORD_FILE, + "/run/nodedc-secrets/postgres-password", + ), + { + "Type": "volume", + "Name": RUNNER.DEVICE_PLANE_POSTGRES_VOLUME, + "Destination": "/var/lib/postgresql/data", + "RW": True, + }, + ], + } + + +if __name__ == "__main__": + unittest.main() diff --git a/infra/deploy-runner/test_device_plane_foundation_network_publication_artifact.py b/infra/deploy-runner/test_device_plane_foundation_network_publication_artifact.py index cfab9ea..32adade 100644 --- a/infra/deploy-runner/test_device_plane_foundation_network_publication_artifact.py +++ b/infra/deploy-runner/test_device_plane_foundation_network_publication_artifact.py @@ -19,8 +19,8 @@ BUILDER = ( ) RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" COMPOSE = ( - SCRIPT_DIR.parent.parent - / "device-plane/docker-compose.device-plane.yml" + SCRIPT_DIR + / "fixtures/device-plane-foundation-network-publication-v1.yml" ) PREDECESSOR_COMPOSE = ( SCRIPT_DIR diff --git a/infra/deploy-runner/test_device_plane_foundation_recovery_artifact.py b/infra/deploy-runner/test_device_plane_foundation_recovery_artifact.py index 31b2c11..3590a4b 100644 --- a/infra/deploy-runner/test_device_plane_foundation_recovery_artifact.py +++ b/infra/deploy-runner/test_device_plane_foundation_recovery_artifact.py @@ -179,7 +179,9 @@ class DevicePlaneFoundationRecoveryArtifactTest(unittest.TestCase): ) self.assertEqual( failed_build["sha256"], - RUNNER.DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT_SHA256, + hashlib.sha256( + Path(failed_build["artifact"]).read_bytes() + ).hexdigest(), ) failed_artifact = ( failed_root / RUNNER.DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT @@ -240,7 +242,7 @@ class DevicePlaneFoundationRecoveryArtifactTest(unittest.TestCase): "", "rollback_status": "failed:DeployError", "sha256": - RUNNER.DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT_SHA256, + failed_build["sha256"], "started_apply": True, "status": "failed", }) @@ -290,6 +292,11 @@ class DevicePlaneFoundationRecoveryArtifactTest(unittest.TestCase): "component_root", return_value=live_root, ), + mock.patch.object( + RUNNER, + "DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT_SHA256", + failed_build["sha256"], + ), mock.patch.dict( RUNNER.DEVICE_PLANE_FOUNDATION_RECOVERY_BACKUP_SHA256, backup_hashes,