feat(device-plane): enable B2 discovery ingress

This commit is contained in:
Codex 2026-07-26 00:52:31 +03:00
parent 3c538ad98c
commit 2b1795509b
19 changed files with 2375 additions and 156 deletions

View File

@ -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"
}

View File

@ -43,7 +43,9 @@ services:
DEVICE_DATABASE_USER: device_plane DEVICE_DATABASE_USER: device_plane
DEVICE_DATABASE_PASSWORD_FILE: /run/nodedc-secrets/postgres-password DEVICE_DATABASE_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
DEVICE_DATABASE_POOL_SIZE: "10" 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: volumes:
- type: bind - type: bind
source: /volume1/docker/nodedc-device-plane/secrets/postgres-password source: /volume1/docker/nodedc-device-plane/secrets/postgres-password
@ -51,6 +53,18 @@ services:
read_only: true read_only: true
bind: bind:
create_host_path: false 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: ports:
- "127.0.0.1:18120:18120" - "127.0.0.1:18120:18120"
networks: networks:
@ -85,16 +99,34 @@ services:
environment: environment:
DEVICE_GATEWAY_HEALTH_HOST: 0.0.0.0 DEVICE_GATEWAY_HEALTH_HOST: 0.0.0.0
DEVICE_GATEWAY_HEALTH_PORT: "18121" DEVICE_GATEWAY_HEALTH_PORT: "18121"
DEVICE_GATEWAY_LISTEN_ENABLED: "false" DEVICE_GATEWAY_LISTEN_ENABLED: "true"
DEVICE_GATEWAY_TCP_HOST: 127.0.0.1 DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED: "true"
DEVICE_GATEWAY_TCP_HOST: 0.0.0.0
DEVICE_GATEWAY_TCP_PORT: "9921" 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: "100"
DEVICE_GATEWAY_MAX_SESSIONS_PER_ADDRESS: "10"
DEVICE_GATEWAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS: "30"
DEVICE_GATEWAY_SESSION_TIMEOUT_MS: "10000" 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: ports:
- "127.0.0.1:18121:18121" - "127.0.0.1:18121:18121"
- "0.0.0.0:9921:9921"
networks: networks:
- device-plane-private - device-plane-private
- device-plane-control - device-plane-control
depends_on:
device-control-core:
condition: service_healthy
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
cap_drop: cap_drop:

View File

@ -1,11 +1,10 @@
# Device Plane Implementation Baseline # Device Plane Implementation Baseline
Status: PostgreSQL, Control Core and Gateway foundation are running healthy on Status: PostgreSQL, Control Core and Gateway foundation are running healthy on
Synology with public ingress, discovery ingest and commands disabled. The first Synology. The accepted foundation has public ingress and discovery ingest
application artifact is terminal-failed because its health gate timed out and disabled. The next additive transition enables only an authenticated,
its automatic rollback could not model the DB-bootstrap predecessor. Recovery quarantine-only ARUSNAVI B2 discovery path on raw TCP 9921. Command transport
is a separate source-only canonical transition; the failed artifact is never remains disabled.
retried.
## Product boundary ## Product boundary
@ -87,9 +86,12 @@ does not build, restart, recreate or remove any service.
## Network boundary ## Network boundary
The baseline publishes no device port. The planned pilot ingress is The accepted foundation publishes no device port. The reviewed pilot transition
`device.nodedc.ru:9921/TCP` only after a port-collision check, quarantine/rate publishes `9921/TCP` from Device Gateway after a port-collision check and keeps
limits and a separate reviewed ingress transition. 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 DSM HTTP/HTTPS Reverse Proxy is not a raw TCP ingress and must not be configured
as `443 -> 9921`. as `443 -> 9921`.
@ -121,10 +123,21 @@ The official B2 material proves:
- server route fields for DNS/IP, TCP port, protocol and optional ID; - server route fields for DNS/IP, TCP port, protocol and optional ID;
- SMS/TCP command families and a six-digit device access password. - SMS/TCP command families and a six-digit device access password.
The public material inspected so far does not define enough exact INTERNAL The official ARUSNAVI INTERNAL protocol sheet now provides the first read-path
packet framing and acknowledgement detail to implement a production decoder. framing contract:
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. - 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 ## Command boundary
@ -151,8 +164,13 @@ raw TCP remain forbidden until separate reviewed acceptance slices.
claimed devices, Foundry bindings and append-only audit events. claimed devices, Foundry bindings and append-only audit events.
- Core health endpoint and an authenticated quarantine-ingest boundary that is - Core health endpoint and an authenticated quarantine-ingest boundary that is
disabled unless explicitly enabled with file-backed secrets. disabled unless explicitly enabled with file-backed secrets.
- Gateway health endpoint and a loopback-only test listener that records only - Gateway discovery-only HEADER2/PACKAGE state machine with bounded buffers,
a bounded evidence hash, sends no acknowledgement and emits no identifier. 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 - Recursive rejection of secret-like fields, raw payloads and command-shaped
input in presentation contracts. input in presentation contracts.
- Automated contract, adapter, migration, Core and Gateway tests. - Automated contract, adapter, migration, Core and Gateway tests.
@ -169,13 +187,16 @@ raw TCP remain forbidden until separate reviewed acceptance slices.
## Next source slice ## Next source slice
1. Promote the reviewed recovery-capable runner through the standalone root 1. Add the versioned B2 discovery-ingress descriptor and runner acceptance
administrative gate and run a fresh `verify-install`. contract without weakening foundation/recovery acceptance.
2. Plan and apply the exact source-only foundation recovery artifact once, then 2. Build a deterministic data-only app-overlay and promote the runner extension
reconcile its journal/backup and repeat runtime acceptance. through the standalone root administrative gate.
3. Obtain or capture the exact official B2 INTERNAL framing and acknowledgement 3. Plan/apply the exact discovery-ingress artifact and independently prove:
contract; keep ingress disabled until its fixtures pass. Core/Gateway/PostgreSQL health, PostgreSQL generation preservation,
4. Add an authenticated internal Core/Gateway discovery boundary and explicit `0.0.0.0:9921` publication, verified framing, quarantine-only Core ingest and
platform-admin claim operation. disabled command transport.
5. Open raw TCP ingress only in a later reviewed slice after quarantine, 4. Only after runtime acceptance, allow TCP 9921 in DSM firewall and configure
framing, rate-limit and audit acceptance. 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.

View File

@ -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({ export const ARUSNAVI_B2_MODEL_PROFILE = deepFreeze({
schemaVersion: "nodedc.device-model-profile.v1", schemaVersion: "nodedc.device-model-profile.v1",
@ -19,8 +33,15 @@ export const ARUSNAVI_B2_MODEL_PROFILE = deepFreeze({
preserveExistingRoutes: true, preserveExistingRoutes: true,
}, },
framing: { framing: {
status: "blocked_pending_official_specification", status: "verified-read-only",
maxInitialBytes: 4096, 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: { commandTransport: {
status: "disabled", status: "disabled",
@ -32,28 +53,154 @@ export const ARUSNAVI_B2_MODEL_PROFILE = deepFreeze({
}, },
}); });
export function inspectUnverifiedInitialBytes(input) { export function tryParseB2Header2(input) {
if (!Buffer.isBuffer(input)) { assertBuffer(input, "b2_header_buffer_required");
throw new TypeError("b2_initial_bytes_buffer_required"); if (input.length < HEADER2_LENGTH) {
return Object.freeze({
status: "incomplete",
minimumBytes: HEADER2_LENGTH,
});
} }
if (input.length === 0) { if (input[0] !== HEADER_START) {
throw new TypeError("b2_initial_bytes_empty"); throw new TypeError("b2_header_start_invalid");
} }
if (input.length > ARUSNAVI_B2_MODEL_PROFILE.framing.maxInitialBytes) { if (input[1] !== HEADER2_GPRS_VERSION) {
throw new TypeError("b2_initial_bytes_limit_exceeded"); 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({ return Object.freeze({
schemaVersion: "nodedc.device.protocol-evidence.v1", status: "complete",
profileRef: ARUSNAVI_B2_MODEL_PROFILE.profileRef, bytesConsumed: HEADER2_LENGTH,
status: "official_framing_required", identifier: Object.freeze({
bytesObserved: input.length, kind: "imei",
contentDigest: `sha256:${createHash("sha256").update(input).digest("hex")}`, value: identifier,
identifierExtracted: false, trust: "claimed-not-ownership-proof",
commandTransport: "disabled", }),
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) { export function assertB2ProfileInvariant(profile = ARUSNAVI_B2_MODEL_PROFILE) {
if (profile.monitoringServerSlots !== 4) { if (profile.monitoringServerSlots !== 4) {
throw new TypeError("b2_server_slot_count_invalid"); 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") { if (profile.serverIdentity.kind !== "imei") {
throw new TypeError("b2_identity_kind_invalid"); 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") { if (profile.commandTransport.status !== "disabled") {
throw new TypeError("b2_command_transport_must_be_disabled"); throw new TypeError("b2_command_transport_must_be_disabled");
} }
@ -73,6 +229,30 @@ export function assertB2ProfileInvariant(profile = ARUSNAVI_B2_MODEL_PROFILE) {
return true; 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) { function deepFreeze(value) {
if (!value || typeof value !== "object" || Object.isFrozen(value)) { if (!value || typeof value !== "object" || Object.isFrozen(value)) {
return value; return value;

View File

@ -3,42 +3,118 @@ import test from "node:test";
import { import {
ARUSNAVI_B2_MODEL_PROFILE, ARUSNAVI_B2_MODEL_PROFILE,
ARUSNAVI_INTERNAL_SPECIFICATION_REF,
assertB2ProfileInvariant, assertB2ProfileInvariant,
inspectUnverifiedInitialBytes, buildB2HeaderAcknowledgement,
buildB2PackageAcknowledgement,
tryParseB2Header2,
tryParseB2Package,
} from "../src/index.mjs"; } 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(assertB2ProfileInvariant(), true);
assert.equal(ARUSNAVI_B2_MODEL_PROFILE.monitoringServerSlots, 4); assert.equal(ARUSNAVI_B2_MODEL_PROFILE.monitoringServerSlots, 4);
assert.equal(ARUSNAVI_B2_MODEL_PROFILE.protocol, "INTERNAL"); assert.equal(ARUSNAVI_B2_MODEL_PROFILE.protocol, "INTERNAL");
assert.equal(ARUSNAVI_B2_MODEL_PROFILE.serverIdentity.kind, "imei"); assert.equal(ARUSNAVI_B2_MODEL_PROFILE.serverIdentity.kind, "imei");
assert.equal(
ARUSNAVI_B2_MODEL_PROFILE.framing.specificationRef,
ARUSNAVI_INTERNAL_SPECIFICATION_REF,
);
assert.equal( assert.equal(
ARUSNAVI_B2_MODEL_PROFILE.routeCompatibility.gelios, ARUSNAVI_B2_MODEL_PROFILE.routeCompatibility.gelios,
"parallel-preserved", "parallel-preserved",
); );
}); });
test("fails closed instead of guessing an IMEI from unverified bytes", () => { test("parses the official HEADER2 example as a claimed IMEI", () => {
const fakeBytes = Buffer.from( assert.equal(
"unverified-frame-with-fake-identifier-000000000000001", tryParseB2Header2(specificationHeader.subarray(0, 9)).status,
"utf8", "incomplete",
); );
const evidence = inspectUnverifiedInitialBytes(fakeBytes); const parsed = tryParseB2Header2(specificationHeader);
const serialized = JSON.stringify(evidence); assert.equal(parsed.status, "complete");
assert.equal(evidence.status, "official_framing_required"); assert.equal(parsed.bytesConsumed, 10);
assert.equal(evidence.identifierExtracted, false); assert.equal(parsed.identifier.kind, "imei");
assert.equal(serialized.includes("000000000000001"), false); assert.equal(parsed.identifier.value, "865209039777769");
assert.match(evidence.contentDigest, /^sha256:[a-f0-9]{64}$/); 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( assert.throws(
() => inspectUnverifiedInitialBytes(Buffer.alloc(0)), () => tryParseB2Header2(Buffer.from("FE23E9EF782DE7120300", "hex")),
/b2_initial_bytes_empty/, /b2_header_start_invalid/,
); );
assert.throws( assert.throws(
() => inspectUnverifiedInitialBytes(Buffer.alloc(4097)), () => tryParseB2Header2(Buffer.from("FF24E9EF782DE7120300", "hex")),
/b2_initial_bytes_limit_exceeded/, /b2_header_version_unsupported/,
);
const badChecksum = Buffer.from(specificationPackage);
badChecksum[badChecksum.length - 2] ^= 0xff;
assert.throws(
() => tryParseB2Package(badChecksum),
/b2_packet_checksum_invalid/,
); );
}); });

View File

@ -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");
}
}

View File

@ -4,18 +4,32 @@ import { createServer as createTcpServer } from "node:net";
import { import {
ARUSNAVI_B2_MODEL_PROFILE, ARUSNAVI_B2_MODEL_PROFILE,
inspectUnverifiedInitialBytes, buildB2HeaderAcknowledgement,
buildB2PackageAcknowledgement,
tryParseB2Header2,
tryParseB2Package,
} from "../../../packages/arusnavi-b2-adapter/src/index.mjs"; } 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 = {}) { export function createDeviceGatewayRuntime(options = {}) {
const config = normalizeConfig(options); const config = normalizeConfig(options);
const sessions = new Map(); const sessions = new Map();
const sessionsByAddress = new Map();
const connectionWindows = new Map();
let totalAccepted = 0; let totalAccepted = 0;
let totalRejected = 0; let totalRejected = 0;
let totalEvidence = 0; let totalDiscoveries = 0;
let totalPackagesAcknowledged = 0;
const tcpServer = createTcpServer((socket) => { 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; totalRejected += 1;
socket.destroy(); socket.destroy();
return; return;
@ -24,40 +38,42 @@ export function createDeviceGatewayRuntime(options = {}) {
const sessionRef = `session:${randomUUID()}`; const sessionRef = `session:${randomUUID()}`;
const session = { const session = {
sessionRef, sessionRef,
bytes: [], remoteAddress,
byteLength: 0, buffer: Buffer.alloc(0),
evidenceRecorded: false, state: "awaiting-header",
processing: false,
rejected: false,
closed: false,
}; };
sessions.set(socket, session); sessions.set(socket, session);
incrementAddressSessions(remoteAddress);
totalAccepted += 1; totalAccepted += 1;
socket.setNoDelay(true); socket.setNoDelay(true);
socket.setTimeout(config.sessionTimeoutMs); socket.setTimeout(config.sessionTimeoutMs);
socket.on("data", (chunk) => { socket.on("data", (chunk) => {
if (session.evidenceRecorded) return; if (session.closed || session.rejected) return;
session.byteLength += chunk.length; socket.pause();
if (session.byteLength > config.maxInitialBytes) { session.buffer = Buffer.concat(
totalRejected += 1; [session.buffer, chunk],
socket.destroy(); session.buffer.length + chunk.length,
);
if (session.buffer.length > config.maxBufferedBytes) {
rejectSession(socket, session);
return; return;
} }
session.bytes.push(chunk); if (session.processing) return;
const evidence = inspectUnverifiedInitialBytes( session.processing = true;
Buffer.concat(session.bytes, session.byteLength), void processSession(socket, session)
); .catch(() => rejectSession(socket, session))
session.evidenceRecorded = true; .finally(() => {
totalEvidence += 1; session.processing = false;
config.onEvidence?.({ if (!session.closed && !session.rejected) socket.resume();
sessionRef, });
evidence,
});
// Until exact official framing is implemented, the gateway never sends
// acknowledgement or command bytes and never guesses an identifier.
socket.end();
}); });
socket.on("timeout", () => socket.destroy()); socket.on("timeout", () => rejectSession(socket, session));
socket.on("close", () => sessions.delete(socket)); socket.on("close", () => closeSession(socket, session));
socket.on("error", () => sessions.delete(socket)); socket.on("error", () => closeSession(socket, session));
}); });
const healthServer = createHttpServer((request, response) => { const healthServer = createHttpServer((request, response) => {
@ -74,14 +90,17 @@ export function createDeviceGatewayRuntime(options = {}) {
service: "nodedc-device-gateway", service: "nodedc-device-gateway",
protocolProfile: ARUSNAVI_B2_MODEL_PROFILE.profileRef, protocolProfile: ARUSNAVI_B2_MODEL_PROFILE.profileRef,
framing: ARUSNAVI_B2_MODEL_PROFILE.framing.status, framing: ARUSNAVI_B2_MODEL_PROFILE.framing.status,
tcpListener: config.listenEnabled ? "internal-test-only" : "disabled", tcpListener: config.listenEnabled ? "discovery-only" : "disabled",
publicIngress: "disabled", publicIngress: config.publicIngressEnabled
? "discovery-only"
: "disabled",
commandTransport: "disabled", commandTransport: "disabled",
sessions: { sessions: {
active: sessions.size, active: sessions.size,
accepted: totalAccepted, accepted: totalAccepted,
rejected: totalRejected, rejected: totalRejected,
evidenceRecorded: totalEvidence, discoveries: totalDiscoveries,
packagesAcknowledged: totalPackagesAcknowledged,
}, },
})}\n`); })}\n`);
}); });
@ -98,7 +117,10 @@ export function createDeviceGatewayRuntime(options = {}) {
}; };
}, },
async stop() { async stop() {
for (const socket of sessions.keys()) socket.destroy(); for (const [socket, session] of sessions) {
session.closed = true;
socket.destroy();
}
await Promise.all([ await Promise.all([
closeServer(healthServer), closeServer(healthServer),
config.listenEnabled ? closeServer(tcpServer) : Promise.resolve(), config.listenEnabled ? closeServer(tcpServer) : Promise.resolve(),
@ -109,25 +131,120 @@ export function createDeviceGatewayRuntime(options = {}) {
activeSessions: sessions.size, activeSessions: sessions.size,
totalAccepted, totalAccepted,
totalRejected, totalRejected,
totalEvidence, totalDiscoveries,
totalPackagesAcknowledged,
commandTransport: "disabled", 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) { function normalizeConfig(input) {
const listenEnabled = input.listenEnabled === true; const listenEnabled = input.listenEnabled === true;
const maxInitialBytes = parseInteger( const publicIngressEnabled = input.publicIngressEnabled === true;
input.maxInitialBytes, if (publicIngressEnabled && !listenEnabled) {
ARUSNAVI_B2_MODEL_PROFILE.framing.maxInitialBytes, throw new TypeError("device_gateway_public_ingress_listener_required");
1, }
ARUSNAVI_B2_MODEL_PROFILE.framing.maxInitialBytes, if (
"device_gateway_initial_bytes_invalid", publicIngressEnabled
); && typeof input.onDiscovery !== "function"
) {
throw new TypeError("device_gateway_discovery_sink_required");
}
return { return {
listenEnabled, listenEnabled,
publicIngressEnabled,
healthHost: normalizeHealthHost(input.healthHost, "127.0.0.1"), healthHost: normalizeHealthHost(input.healthHost, "127.0.0.1"),
healthPort: parseInteger( healthPort: parseInteger(
input.healthPort, input.healthPort,
@ -136,7 +253,11 @@ function normalizeConfig(input) {
65535, 65535,
"device_gateway_health_port_invalid", "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( tcpPort: parseInteger(
input.tcpPort, input.tcpPort,
9921, 9921,
@ -144,7 +265,13 @@ function normalizeConfig(input) {
65535, 65535,
"device_gateway_tcp_port_invalid", "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( maxConcurrentSessions: parseInteger(
input.maxConcurrentSessions, input.maxConcurrentSessions,
100, 100,
@ -152,6 +279,20 @@ function normalizeConfig(input) {
10000, 10000,
"device_gateway_session_limit_invalid", "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( sessionTimeoutMs: parseInteger(
input.sessionTimeoutMs, input.sessionTimeoutMs,
10000, 10000,
@ -159,9 +300,10 @@ function normalizeConfig(input) {
60000, 60000,
"device_gateway_session_timeout_invalid", "device_gateway_session_timeout_invalid",
), ),
onEvidence: typeof input.onEvidence === "function" onDiscovery: typeof input.onDiscovery === "function"
? input.onEvidence ? input.onDiscovery
: undefined, : undefined,
now: typeof input.now === "function" ? input.now : () => new Date(),
}; };
} }
@ -173,14 +315,26 @@ function normalizeHealthHost(value, fallback) {
return normalized; return normalized;
} }
function normalizeTcpHost(value, fallback) { function normalizeTcpHost(value, fallback, publicIngressEnabled) {
const normalized = String(value || fallback).trim(); const normalized = String(value || fallback).trim();
if (!["127.0.0.1", "::1"].includes(normalized)) { const allowed = publicIngressEnabled
throw new TypeError("device_gateway_baseline_loopback_only"); ? ["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; return normalized;
} }
function normalizeRemoteAddress(value) {
const normalized = String(value || "unknown").trim();
return normalized.slice(0, 64) || "unknown";
}
function parseInteger(value, fallback, minimum, maximum, errorCode) { function parseInteger(value, fallback, minimum, maximum, errorCode) {
const parsed = Number(value ?? fallback); const parsed = Number(value ?? fallback);
if ( if (

View File

@ -1,23 +1,30 @@
import { readFile } from "node:fs/promises";
import { createCoreDiscoveryClient } from "./core-client.mjs";
import { createDeviceGatewayRuntime } from "./runtime.mjs"; import { createDeviceGatewayRuntime } from "./runtime.mjs";
const listenEnabled = parseBoolean( const config = await readConfig();
process.env.DEVICE_GATEWAY_LISTEN_ENABLED, const onDiscovery = config.listenEnabled
false, ? createCoreDiscoveryClient({
); coreUrl: config.coreUrl,
gatewayToken: config.gatewayToken,
timeoutMs: config.coreTimeoutMs,
})
: undefined;
const runtime = createDeviceGatewayRuntime({ const runtime = createDeviceGatewayRuntime({
listenEnabled, listenEnabled: config.listenEnabled,
healthHost: String(process.env.DEVICE_GATEWAY_HEALTH_HOST || "127.0.0.1"), publicIngressEnabled: config.publicIngressEnabled,
healthPort: parsePort(process.env.DEVICE_GATEWAY_HEALTH_PORT, 18121), healthHost: config.healthHost,
tcpHost: String(process.env.DEVICE_GATEWAY_TCP_HOST || "127.0.0.1"), healthPort: config.healthPort,
tcpPort: parsePort(process.env.DEVICE_GATEWAY_TCP_PORT, 9921), tcpHost: config.tcpHost,
maxConcurrentSessions: parsePositiveInt( tcpPort: config.tcpPort,
process.env.DEVICE_GATEWAY_MAX_SESSIONS, maxBufferedBytes: config.maxBufferedBytes,
100, maxConcurrentSessions: config.maxConcurrentSessions,
), maxSessionsPerAddress: config.maxSessionsPerAddress,
sessionTimeoutMs: parsePositiveInt( maxConnectionsPerMinutePerAddress:
process.env.DEVICE_GATEWAY_SESSION_TIMEOUT_MS, config.maxConnectionsPerMinutePerAddress,
10000, sessionTimeoutMs: config.sessionTimeoutMs,
), onDiscovery,
}); });
const addresses = await runtime.start(); const addresses = await runtime.start();
@ -25,7 +32,9 @@ console.log(JSON.stringify({
event: "device_gateway_started", event: "device_gateway_started",
health: addresses.healthAddress, health: addresses.healthAddress,
tcp: addresses.tcpAddress, tcp: addresses.tcpAddress,
publicIngress: "disabled", publicIngress: config.publicIngressEnabled
? "discovery-only"
: "disabled",
commandTransport: "disabled", commandTransport: "disabled",
})); }));
@ -37,6 +46,80 @@ async function shutdown() {
process.exit(0); 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) { function parsePort(value, fallback) {
const parsed = Number(value || fallback); const parsed = Number(value || fallback);
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535) { if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535) {

View File

@ -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/,
);
});

View File

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

View File

@ -4,6 +4,15 @@ import test from "node:test";
import { createDeviceGatewayRuntime } from "../src/runtime.mjs"; 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 () => { test("baseline health exposes no public ingress and no command transport", async () => {
const runtime = createDeviceGatewayRuntime({ const runtime = createDeviceGatewayRuntime({
healthPort: 0, 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 captured = [];
const runtime = createDeviceGatewayRuntime({ const runtime = createDeviceGatewayRuntime({
healthPort: 0, healthPort: 0,
tcpPort: 0, tcpPort: 0,
listenEnabled: true, listenEnabled: true,
onEvidence: (value) => captured.push(value), onDiscovery: async (value) => captured.push(value),
}); });
const addresses = await runtime.start(); const addresses = await runtime.start();
try { try {
const received = await sendAndCollect( const received = await sendAndCollect(
addresses.tcpAddress.port, addresses.tcpAddress.port,
Buffer.from( Buffer.from("not-a-b2-header", "utf8"),
"unverified-frame-with-fake-identifier-000000000000001",
"utf8",
),
); );
assert.equal(received.length, 0); assert.equal(received.length, 0);
assert.equal(captured.length, 1); assert.equal(captured.length, 0);
assert.equal(captured[0].evidence.identifierExtracted, false); assert.equal(runtime.status().totalRejected, 1);
assert.equal(
JSON.stringify(captured).includes("000000000000001"),
false,
);
assert.equal(runtime.status().totalEvidence, 1);
assert.equal(runtime.status().commandTransport, "disabled");
} finally { } finally {
await runtime.stop(); 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", () => { test("baseline rejects non-loopback binding", () => {
assert.throws( assert.throws(
() => createDeviceGatewayRuntime({ () => 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({ const runtime = createDeviceGatewayRuntime({
healthHost: "0.0.0.0", healthHost: "0.0.0.0",
healthPort: 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) { function sendAndCollect(port, payload) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const chunks = []; const chunks = [];
@ -89,7 +188,7 @@ function sendAndCollect(port, payload) {
socket.end(payload); socket.end(payload);
}); });
socket.on("data", (chunk) => chunks.push(chunk)); 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); socket.on("error", reject);
}); });
} }

View File

@ -58,10 +58,7 @@ await assertSourceBoundary();
try { try {
await mkdir(payload, { recursive: true }); await mkdir(payload, { recursive: true });
for (const sourceRelative of files) { for (const sourceRelative of files) {
const source = ( const source = sourceRelative === "docker-compose.device-plane.yml"
patchId === "device-plane-foundation-20260725-001"
&& sourceRelative === "docker-compose.device-plane.yml"
)
? failedFoundationCompose ? failedFoundationCompose
: resolve(sourceRoot, sourceRelative); : resolve(sourceRoot, sourceRelative);
await copySafe( await copySafe(
@ -119,9 +116,7 @@ try {
} }
async function assertSourceBoundary() { async function assertSourceBoundary() {
const composeSource = patchId === "device-plane-foundation-20260725-001" const composeSource = failedFoundationCompose;
? failedFoundationCompose
: resolve(sourceRoot, "docker-compose.device-plane.yml");
const compose = await readFile( const compose = await readFile(
composeSource, composeSource,
"utf8", "utf8",

View File

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

View File

@ -18,6 +18,10 @@ import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url)); const scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../.."); const platformRoot = resolve(scriptDir, "../..");
const sourceRoot = resolve(platformRoot, "device-plane"); const sourceRoot = resolve(platformRoot, "device-plane");
const networkPublicationCompose = resolve(
scriptDir,
"fixtures/device-plane-foundation-network-publication-v1.yml",
);
const artifactDir = resolve( const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|| resolve(scriptDir, "../deploy-artifacts"), || resolve(scriptDir, "../deploy-artifacts"),
@ -61,8 +65,11 @@ await assertBoundary();
try { try {
await mkdir(payload, { recursive: true }); await mkdir(payload, { recursive: true });
for (const sourceRelative of files) { for (const sourceRelative of files) {
const source = sourceRelative === "docker-compose.device-plane.yml"
? networkPublicationCompose
: resolve(sourceRoot, sourceRelative);
await copySafe( await copySafe(
resolve(sourceRoot, sourceRelative), source,
join(payload, sourceRelative), join(payload, sourceRelative),
); );
} }
@ -134,7 +141,7 @@ try {
async function assertBoundary() { async function assertBoundary() {
const compose = await readFile( const compose = await readFile(
resolve(sourceRoot, "docker-compose.device-plane.yml"), networkPublicationCompose,
"utf8", "utf8",
); );
for (const fragment of [ for (const fragment of [

View File

@ -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

View File

@ -77,6 +77,22 @@ DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_ENTRIES = (
*DEVICE_PLANE_FOUNDATION_ENTRIES, *DEVICE_PLANE_FOUNDATION_ENTRIES,
DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_REL, 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_FAILED_PATCH_ID = (
"device-plane-foundation-20260725-001" "device-plane-foundation-20260725-001"
) )
@ -2865,6 +2881,7 @@ def allowed_payload_path(component, rel):
DEVICE_PLANE_POSTGRES_BOOTSTRAP_REL, DEVICE_PLANE_POSTGRES_BOOTSTRAP_REL,
DEVICE_PLANE_FOUNDATION_RECOVERY_REL, DEVICE_PLANE_FOUNDATION_RECOVERY_REL,
DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_REL, DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_REL,
DEVICE_PLANE_B2_DISCOVERY_INGRESS_REL,
"packages/device-protocol-contract", "packages/device-protocol-contract",
"packages/arusnavi-b2-adapter", "packages/arusnavi-b2-adapter",
"services/device-control-core", "services/device-control-core",
@ -7901,6 +7918,11 @@ def load_artifact(artifact, work_dir):
validate_device_plane_foundation_network_publication_payload( validate_device_plane_foundation_network_publication_payload(
payload_dir 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": if manifest["component"] == "n8n-private-extension":
validate_n8n_private_extension_release(payload_dir, entries) validate_n8n_private_extension_release(payload_dir, entries)
if manifest["component"] == "engine": 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(): def expected_device_plane_foundation_recovery_descriptor():
return { return {
"schemaVersion": "nodedc.device-plane.foundation-recovery.v1", "schemaVersion": "nodedc.device-plane.foundation-recovery.v1",
@ -8329,6 +8359,91 @@ def validate_device_plane_foundation_network_publication_payload(payload_dir):
return descriptor 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): def device_plane_service_container_ids(service):
if service not in DEVICE_PLANE_RUNTIME_SERVICES: if service not in DEVICE_PLANE_RUNTIME_SERVICES:
die(f"Device Plane runtime service is not registered: {service}") 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 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(): def validate_device_plane_runtime_secret_metadata():
try: try:
directory_stat = DEVICE_PLANE_SECRET_DIR.lstat() directory_stat = DEVICE_PLANE_SECRET_DIR.lstat()
@ -13237,6 +13608,7 @@ def plan_artifact(artifact):
l2_closed_loop_preflight = None l2_closed_loop_preflight = None
device_plane_foundation_recovery_preflight = None device_plane_foundation_recovery_preflight = None
device_plane_network_publication_preflight = None device_plane_network_publication_preflight = None
device_plane_b2_ingress_preflight = None
device_plane_runtime_before = None device_plane_runtime_before = None
composite_provider_v4_preflight = None composite_provider_v4_preflight = None
provider_rotating_slot_preflight = None provider_rotating_slot_preflight = None
@ -13420,6 +13792,15 @@ def plan_artifact(artifact):
payload_dir 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"] component = manifest["component"]
root = component_root(component) root = component_root(component)
@ -14800,7 +15181,11 @@ def plan_artifact(artifact):
f"{device_plane_postgres_plan_selection(device_plane_postgres_preflight)}" f"{device_plane_postgres_plan_selection(device_plane_postgres_preflight)}"
) )
print("device_postgres_volume=preserved:nodedc-device-plane-postgres-data") 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("device_gateway_command_transport=disabled")
print("gelios=untouched") print("gelios=untouched")
if device_plane_foundation_recovery_preflight is not None: if device_plane_foundation_recovery_preflight is not None:
@ -14875,6 +15260,46 @@ def plan_artifact(artifact):
"device_plane_rollback=" "device_plane_rollback="
"partial-source+internal-only-stateless-runtime" "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: if device_plane_postgres_preflight is not None:
print( print(
"device_postgres_bootstrap=" "device_postgres_bootstrap="
@ -16076,6 +16501,14 @@ def assert_loopback_tcp_port_closed(port):
die(f"unexpected loopback TCP listener is open: {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): def external_data_plane_healthcheck(require_managed=True):
expected_json = { expected_json = {
"ok": True, "ok": True,
@ -16170,6 +16603,30 @@ def component_healthchecks(component, entries=None, services=None):
if component == "module-foundry": if component == "module-foundry":
return (module_foundry_healthcheck(),) return (module_foundry_healthcheck(),)
if component == "device-plane": 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 = ( selected_services = (
tuple(services) tuple(services)
if services is not None if services is not None
@ -16632,6 +17089,22 @@ def run_healthchecks(component, entries=None, services=None):
) )
assert_loopback_tcp_port_closed(9921) assert_loopback_tcp_port_closed(9921)
return 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): if component == "platform" and entries is not None and is_platform_provider_catalog_only(entries):
return return
if is_engine_l2_closed_loop_slice(component, entries): if is_engine_l2_closed_loop_slice(component, entries):
@ -17449,6 +17922,13 @@ def apply_artifact(artifact):
validate_device_plane_foundation_network_publication_evidence( validate_device_plane_foundation_network_publication_evidence(
payload_dir 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 not root.is_dir():
if bootstrap_root: if bootstrap_root:
root.mkdir(parents=True, exist_ok=True) 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") die("Engine L2 closed-loop app generation was not recreated")
run_healthchecks(component, entries, services) 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) applied_path = move_artifact(artifact, APPLIED_DIR)
append_jsonl(STATE_FILE, { append_jsonl(STATE_FILE, {

View File

@ -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()

View File

@ -19,8 +19,8 @@ BUILDER = (
) )
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
COMPOSE = ( COMPOSE = (
SCRIPT_DIR.parent.parent SCRIPT_DIR
/ "device-plane/docker-compose.device-plane.yml" / "fixtures/device-plane-foundation-network-publication-v1.yml"
) )
PREDECESSOR_COMPOSE = ( PREDECESSOR_COMPOSE = (
SCRIPT_DIR SCRIPT_DIR

View File

@ -179,7 +179,9 @@ class DevicePlaneFoundationRecoveryArtifactTest(unittest.TestCase):
) )
self.assertEqual( self.assertEqual(
failed_build["sha256"], failed_build["sha256"],
RUNNER.DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT_SHA256, hashlib.sha256(
Path(failed_build["artifact"]).read_bytes()
).hexdigest(),
) )
failed_artifact = ( failed_artifact = (
failed_root / RUNNER.DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT failed_root / RUNNER.DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT
@ -240,7 +242,7 @@ class DevicePlaneFoundationRecoveryArtifactTest(unittest.TestCase):
"<urlopen error [Errno 111] Connection refused>", "<urlopen error [Errno 111] Connection refused>",
"rollback_status": "failed:DeployError", "rollback_status": "failed:DeployError",
"sha256": "sha256":
RUNNER.DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT_SHA256, failed_build["sha256"],
"started_apply": True, "started_apply": True,
"status": "failed", "status": "failed",
}) })
@ -290,6 +292,11 @@ class DevicePlaneFoundationRecoveryArtifactTest(unittest.TestCase):
"component_root", "component_root",
return_value=live_root, return_value=live_root,
), ),
mock.patch.object(
RUNNER,
"DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT_SHA256",
failed_build["sha256"],
),
mock.patch.dict( mock.patch.dict(
RUNNER.DEVICE_PLANE_FOUNDATION_RECOVERY_BACKUP_SHA256, RUNNER.DEVICE_PLANE_FOUNDATION_RECOVERY_BACKUP_SHA256,
backup_hashes, backup_hashes,