feat(device-plane): enable B2 discovery ingress
This commit is contained in:
parent
3c538ad98c
commit
2b1795509b
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 [
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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, {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue