feat(device-plane): enable B2 discovery ingress
This commit is contained in:
@@ -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_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
|
||||
DEVICE_DATABASE_POOL_SIZE: "10"
|
||||
DEVICE_DISCOVERY_INGEST_ENABLED: "false"
|
||||
DEVICE_DISCOVERY_INGEST_ENABLED: "true"
|
||||
DEVICE_GATEWAY_CORE_TOKEN_FILE: /run/nodedc-secrets/gateway-core-token
|
||||
DEVICE_IDENTIFIER_PEPPER_FILE: /run/nodedc-secrets/identifier-pepper
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/postgres-password
|
||||
@@ -51,6 +53,18 @@ services:
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/gateway-core-token
|
||||
target: /run/nodedc-secrets/gateway-core-token
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/identifier-pepper
|
||||
target: /run/nodedc-secrets/identifier-pepper
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
ports:
|
||||
- "127.0.0.1:18120:18120"
|
||||
networks:
|
||||
@@ -85,16 +99,34 @@ services:
|
||||
environment:
|
||||
DEVICE_GATEWAY_HEALTH_HOST: 0.0.0.0
|
||||
DEVICE_GATEWAY_HEALTH_PORT: "18121"
|
||||
DEVICE_GATEWAY_LISTEN_ENABLED: "false"
|
||||
DEVICE_GATEWAY_TCP_HOST: 127.0.0.1
|
||||
DEVICE_GATEWAY_LISTEN_ENABLED: "true"
|
||||
DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED: "true"
|
||||
DEVICE_GATEWAY_TCP_HOST: 0.0.0.0
|
||||
DEVICE_GATEWAY_TCP_PORT: "9921"
|
||||
DEVICE_GATEWAY_CORE_URL: http://device-control-core:18120
|
||||
DEVICE_GATEWAY_CORE_TOKEN_FILE: /run/nodedc-secrets/gateway-core-token
|
||||
DEVICE_GATEWAY_CORE_TIMEOUT_MS: "5000"
|
||||
DEVICE_GATEWAY_MAX_BUFFERED_BYTES: "65536"
|
||||
DEVICE_GATEWAY_MAX_SESSIONS: "100"
|
||||
DEVICE_GATEWAY_MAX_SESSIONS_PER_ADDRESS: "10"
|
||||
DEVICE_GATEWAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS: "30"
|
||||
DEVICE_GATEWAY_SESSION_TIMEOUT_MS: "10000"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/gateway-core-token
|
||||
target: /run/nodedc-secrets/gateway-core-token
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
ports:
|
||||
- "127.0.0.1:18121:18121"
|
||||
- "0.0.0.0:9921:9921"
|
||||
networks:
|
||||
- device-plane-private
|
||||
- device-plane-control
|
||||
depends_on:
|
||||
device-control-core:
|
||||
condition: service_healthy
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
# Device Plane Implementation Baseline
|
||||
|
||||
Status: PostgreSQL, Control Core and Gateway foundation are running healthy on
|
||||
Synology with public ingress, discovery ingest and commands disabled. The first
|
||||
application artifact is terminal-failed because its health gate timed out and
|
||||
its automatic rollback could not model the DB-bootstrap predecessor. Recovery
|
||||
is a separate source-only canonical transition; the failed artifact is never
|
||||
retried.
|
||||
Synology. The accepted foundation has public ingress and discovery ingest
|
||||
disabled. The next additive transition enables only an authenticated,
|
||||
quarantine-only ARUSNAVI B2 discovery path on raw TCP 9921. Command transport
|
||||
remains disabled.
|
||||
|
||||
## Product boundary
|
||||
|
||||
@@ -87,9 +86,12 @@ does not build, restart, recreate or remove any service.
|
||||
|
||||
## Network boundary
|
||||
|
||||
The baseline publishes no device port. The planned pilot ingress is
|
||||
`device.nodedc.ru:9921/TCP` only after a port-collision check, quarantine/rate
|
||||
limits and a separate reviewed ingress transition.
|
||||
The accepted foundation publishes no device port. The reviewed pilot transition
|
||||
publishes `9921/TCP` from Device Gateway after a port-collision check and keeps
|
||||
health endpoints loopback-only.
|
||||
|
||||
`device.nodedc.ru` is a DNS name, not an HTTP/TCP mode. The same name may later
|
||||
serve an HTTPS Control API on 443 and the B2 raw TCP protocol on 9921.
|
||||
|
||||
DSM HTTP/HTTPS Reverse Proxy is not a raw TCP ingress and must not be configured
|
||||
as `443 -> 9921`.
|
||||
@@ -121,10 +123,21 @@ The official B2 material proves:
|
||||
- server route fields for DNS/IP, TCP port, protocol and optional ID;
|
||||
- SMS/TCP command families and a six-digit device access password.
|
||||
|
||||
The public material inspected so far does not define enough exact INTERNAL
|
||||
packet framing and acknowledgement detail to implement a production decoder.
|
||||
The B2 adapter therefore hashes bounded evidence and fails closed. It does not
|
||||
search arbitrary bytes for a 15-digit sequence and call that an IMEI.
|
||||
The official ARUSNAVI INTERNAL protocol sheet now provides the first read-path
|
||||
framing contract:
|
||||
|
||||
- HEADER2 for GPRS is `FF 23` followed by an eight-byte little-endian IMEI;
|
||||
- the server confirms HEADER2 with a bounded `SERVER_COM` carrying Unix time;
|
||||
- a PACKAGE begins with `5B`, carries a package number in `01..FB`, contains
|
||||
one or more length-framed PACKET records and ends with `5D`;
|
||||
- every PACKET checksum is verified before acknowledgement;
|
||||
- every valid PACKAGE is acknowledged by package number;
|
||||
- without acknowledgement the tracker repeats the transmission.
|
||||
|
||||
The pilot codec implements only that verified read/acknowledgement subset. It
|
||||
does not decode telemetry tags, export command builders or accept arbitrary
|
||||
server commands. An IMEI parsed from a valid HEADER2 remains a claimed
|
||||
identifier and never proves tenant ownership.
|
||||
|
||||
## Command boundary
|
||||
|
||||
@@ -151,8 +164,13 @@ raw TCP remain forbidden until separate reviewed acceptance slices.
|
||||
claimed devices, Foundry bindings and append-only audit events.
|
||||
- Core health endpoint and an authenticated quarantine-ingest boundary that is
|
||||
disabled unless explicitly enabled with file-backed secrets.
|
||||
- Gateway health endpoint and a loopback-only test listener that records only
|
||||
a bounded evidence hash, sends no acknowledgement and emits no identifier.
|
||||
- Gateway discovery-only HEADER2/PACKAGE state machine with bounded buffers,
|
||||
handshake timeout, concurrent/per-source session limits and per-source
|
||||
connection rate limits.
|
||||
- Authenticated Gateway-to-Core discovery ingest. Core HMAC-hashes the full IMEI
|
||||
and persists only its digest, masked view and verified framing evidence.
|
||||
- Only HEADER2 and valid PACKAGE acknowledgements are emitted; no command
|
||||
builder or command transport is present.
|
||||
- Recursive rejection of secret-like fields, raw payloads and command-shaped
|
||||
input in presentation contracts.
|
||||
- Automated contract, adapter, migration, Core and Gateway tests.
|
||||
@@ -169,13 +187,16 @@ raw TCP remain forbidden until separate reviewed acceptance slices.
|
||||
|
||||
## Next source slice
|
||||
|
||||
1. Promote the reviewed recovery-capable runner through the standalone root
|
||||
administrative gate and run a fresh `verify-install`.
|
||||
2. Plan and apply the exact source-only foundation recovery artifact once, then
|
||||
reconcile its journal/backup and repeat runtime acceptance.
|
||||
3. Obtain or capture the exact official B2 INTERNAL framing and acknowledgement
|
||||
contract; keep ingress disabled until its fixtures pass.
|
||||
4. Add an authenticated internal Core/Gateway discovery boundary and explicit
|
||||
platform-admin claim operation.
|
||||
5. Open raw TCP ingress only in a later reviewed slice after quarantine,
|
||||
framing, rate-limit and audit acceptance.
|
||||
1. Add the versioned B2 discovery-ingress descriptor and runner acceptance
|
||||
contract without weakening foundation/recovery acceptance.
|
||||
2. Build a deterministic data-only app-overlay and promote the runner extension
|
||||
through the standalone root administrative gate.
|
||||
3. Plan/apply the exact discovery-ingress artifact and independently prove:
|
||||
Core/Gateway/PostgreSQL health, PostgreSQL generation preservation,
|
||||
`0.0.0.0:9921` publication, verified framing, quarantine-only Core ingest and
|
||||
disabled command transport.
|
||||
4. Only after runtime acceptance, allow TCP 9921 in DSM firewall and configure
|
||||
WAN-to-NAS TCP forwarding. Keep the HTTP(S) Reverse Proxy out of this path.
|
||||
5. Add the NDC route to one approved B2 free server slot while preserving
|
||||
Gelios, then prove HEADER/discovery/PACKAGE acknowledgement. Claim and
|
||||
tenant assignment remain a later explicit platform-admin operation.
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
import { createHash } from "node:crypto";
|
||||
export const ARUSNAVI_INTERNAL_SPECIFICATION_REF =
|
||||
"arusnavi.internal.protocol-sheet.gid-12.v1";
|
||||
|
||||
const HEADER2_LENGTH = 10;
|
||||
const HEADER_START = 0xff;
|
||||
const HEADER2_GPRS_VERSION = 0x23;
|
||||
const PACKAGE_START = 0x5b;
|
||||
const PACKAGE_END = 0x5d;
|
||||
const SERVER_COMMAND_START = 0x7b;
|
||||
const SERVER_COMMAND_END = 0x7d;
|
||||
const MIN_PACKAGE_NUMBER = 0x01;
|
||||
const MAX_PACKAGE_NUMBER = 0xfb;
|
||||
const PACKET_FIXED_LENGTH = 8;
|
||||
const MAX_PACKET_DATA_LENGTH = 32 * 1024;
|
||||
const MAX_PACKAGE_LENGTH = 64 * 1024;
|
||||
|
||||
export const ARUSNAVI_B2_MODEL_PROFILE = deepFreeze({
|
||||
schemaVersion: "nodedc.device-model-profile.v1",
|
||||
@@ -19,8 +33,15 @@ export const ARUSNAVI_B2_MODEL_PROFILE = deepFreeze({
|
||||
preserveExistingRoutes: true,
|
||||
},
|
||||
framing: {
|
||||
status: "blocked_pending_official_specification",
|
||||
maxInitialBytes: 4096,
|
||||
status: "verified-read-only",
|
||||
specificationRef: ARUSNAVI_INTERNAL_SPECIFICATION_REF,
|
||||
headerVersion: "HEADER2_GPRS_0x23",
|
||||
headerBytes: HEADER2_LENGTH,
|
||||
maxBufferedBytes: MAX_PACKAGE_LENGTH,
|
||||
},
|
||||
acknowledgement: {
|
||||
header: "server-time-only",
|
||||
package: "package-number-only",
|
||||
},
|
||||
commandTransport: {
|
||||
status: "disabled",
|
||||
@@ -32,28 +53,154 @@ export const ARUSNAVI_B2_MODEL_PROFILE = deepFreeze({
|
||||
},
|
||||
});
|
||||
|
||||
export function inspectUnverifiedInitialBytes(input) {
|
||||
if (!Buffer.isBuffer(input)) {
|
||||
throw new TypeError("b2_initial_bytes_buffer_required");
|
||||
export function tryParseB2Header2(input) {
|
||||
assertBuffer(input, "b2_header_buffer_required");
|
||||
if (input.length < HEADER2_LENGTH) {
|
||||
return Object.freeze({
|
||||
status: "incomplete",
|
||||
minimumBytes: HEADER2_LENGTH,
|
||||
});
|
||||
}
|
||||
if (input.length === 0) {
|
||||
throw new TypeError("b2_initial_bytes_empty");
|
||||
if (input[0] !== HEADER_START) {
|
||||
throw new TypeError("b2_header_start_invalid");
|
||||
}
|
||||
if (input.length > ARUSNAVI_B2_MODEL_PROFILE.framing.maxInitialBytes) {
|
||||
throw new TypeError("b2_initial_bytes_limit_exceeded");
|
||||
if (input[1] !== HEADER2_GPRS_VERSION) {
|
||||
throw new TypeError("b2_header_version_unsupported");
|
||||
}
|
||||
|
||||
const identifier = input.readBigUInt64LE(2).toString(10);
|
||||
if (!/^\d{15}$/.test(identifier)) {
|
||||
throw new TypeError("b2_header_imei_invalid");
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
schemaVersion: "nodedc.device.protocol-evidence.v1",
|
||||
profileRef: ARUSNAVI_B2_MODEL_PROFILE.profileRef,
|
||||
status: "official_framing_required",
|
||||
bytesObserved: input.length,
|
||||
contentDigest: `sha256:${createHash("sha256").update(input).digest("hex")}`,
|
||||
identifierExtracted: false,
|
||||
commandTransport: "disabled",
|
||||
status: "complete",
|
||||
bytesConsumed: HEADER2_LENGTH,
|
||||
identifier: Object.freeze({
|
||||
kind: "imei",
|
||||
value: identifier,
|
||||
trust: "claimed-not-ownership-proof",
|
||||
}),
|
||||
evidence: Object.freeze({
|
||||
transport: "tcp",
|
||||
bytesObserved: HEADER2_LENGTH,
|
||||
framingStatus: "verified",
|
||||
specificationRef: ARUSNAVI_INTERNAL_SPECIFICATION_REF,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function buildB2HeaderAcknowledgement(unixSeconds) {
|
||||
const timestamp = normalizeUInt32(
|
||||
unixSeconds,
|
||||
"b2_header_ack_timestamp_invalid",
|
||||
);
|
||||
const commandData = Buffer.allocUnsafe(4);
|
||||
commandData.writeUInt32LE(timestamp);
|
||||
return Buffer.from([
|
||||
SERVER_COMMAND_START,
|
||||
commandData.length,
|
||||
0x00,
|
||||
checksum(commandData),
|
||||
...commandData,
|
||||
SERVER_COMMAND_END,
|
||||
]);
|
||||
}
|
||||
|
||||
export function tryParseB2Package(input) {
|
||||
assertBuffer(input, "b2_package_buffer_required");
|
||||
if (input.length === 0) {
|
||||
return Object.freeze({ status: "incomplete", minimumBytes: 1 });
|
||||
}
|
||||
if (input[0] !== PACKAGE_START) {
|
||||
throw new TypeError("b2_package_start_invalid");
|
||||
}
|
||||
if (input.length < 3) {
|
||||
return Object.freeze({ status: "incomplete", minimumBytes: 3 });
|
||||
}
|
||||
|
||||
const packageNumber = input[1];
|
||||
if (
|
||||
packageNumber < MIN_PACKAGE_NUMBER
|
||||
|| packageNumber > MAX_PACKAGE_NUMBER
|
||||
) {
|
||||
throw new TypeError("b2_package_number_invalid");
|
||||
}
|
||||
|
||||
let offset = 2;
|
||||
let packetCount = 0;
|
||||
while (true) {
|
||||
if (offset >= MAX_PACKAGE_LENGTH) {
|
||||
throw new TypeError("b2_package_length_exceeded");
|
||||
}
|
||||
if (offset >= input.length) {
|
||||
return Object.freeze({
|
||||
status: "incomplete",
|
||||
minimumBytes: offset + 1,
|
||||
});
|
||||
}
|
||||
if (input[offset] === PACKAGE_END) {
|
||||
if (packetCount === 0) {
|
||||
throw new TypeError("b2_package_empty");
|
||||
}
|
||||
return Object.freeze({
|
||||
status: "complete",
|
||||
bytesConsumed: offset + 1,
|
||||
packageNumber,
|
||||
packetCount,
|
||||
});
|
||||
}
|
||||
if (input.length - offset < 3) {
|
||||
return Object.freeze({
|
||||
status: "incomplete",
|
||||
minimumBytes: offset + 3,
|
||||
});
|
||||
}
|
||||
|
||||
const dataLength = input.readUInt16LE(offset + 1);
|
||||
if (dataLength > MAX_PACKET_DATA_LENGTH) {
|
||||
throw new TypeError("b2_packet_data_length_exceeded");
|
||||
}
|
||||
const packetLength = PACKET_FIXED_LENGTH + dataLength;
|
||||
const packetEnd = offset + packetLength;
|
||||
if (packetEnd + 1 > MAX_PACKAGE_LENGTH) {
|
||||
throw new TypeError("b2_package_length_exceeded");
|
||||
}
|
||||
if (input.length < packetEnd) {
|
||||
return Object.freeze({
|
||||
status: "incomplete",
|
||||
minimumBytes: packetEnd,
|
||||
});
|
||||
}
|
||||
|
||||
const expectedChecksum = checksum(
|
||||
input.subarray(offset + 3, packetEnd - 1),
|
||||
);
|
||||
if (input[packetEnd - 1] !== expectedChecksum) {
|
||||
throw new TypeError("b2_packet_checksum_invalid");
|
||||
}
|
||||
packetCount += 1;
|
||||
offset = packetEnd;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildB2PackageAcknowledgement(packageNumber) {
|
||||
const normalized = Number(packageNumber);
|
||||
if (
|
||||
!Number.isSafeInteger(normalized)
|
||||
|| normalized < MIN_PACKAGE_NUMBER
|
||||
|| normalized > MAX_PACKAGE_NUMBER
|
||||
) {
|
||||
throw new TypeError("b2_package_ack_number_invalid");
|
||||
}
|
||||
return Buffer.from([
|
||||
SERVER_COMMAND_START,
|
||||
0x00,
|
||||
normalized,
|
||||
SERVER_COMMAND_END,
|
||||
]);
|
||||
}
|
||||
|
||||
export function assertB2ProfileInvariant(profile = ARUSNAVI_B2_MODEL_PROFILE) {
|
||||
if (profile.monitoringServerSlots !== 4) {
|
||||
throw new TypeError("b2_server_slot_count_invalid");
|
||||
@@ -64,6 +211,15 @@ export function assertB2ProfileInvariant(profile = ARUSNAVI_B2_MODEL_PROFILE) {
|
||||
if (profile.serverIdentity.kind !== "imei") {
|
||||
throw new TypeError("b2_identity_kind_invalid");
|
||||
}
|
||||
if (profile.framing.status !== "verified-read-only") {
|
||||
throw new TypeError("b2_framing_must_be_verified");
|
||||
}
|
||||
if (
|
||||
profile.framing.specificationRef
|
||||
!== ARUSNAVI_INTERNAL_SPECIFICATION_REF
|
||||
) {
|
||||
throw new TypeError("b2_framing_specification_invalid");
|
||||
}
|
||||
if (profile.commandTransport.status !== "disabled") {
|
||||
throw new TypeError("b2_command_transport_must_be_disabled");
|
||||
}
|
||||
@@ -73,6 +229,30 @@ export function assertB2ProfileInvariant(profile = ARUSNAVI_B2_MODEL_PROFILE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function checksum(input) {
|
||||
let value = 0;
|
||||
for (const byte of input) value = (value + byte) & 0xff;
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeUInt32(value, errorCode) {
|
||||
const normalized = Number(value);
|
||||
if (
|
||||
!Number.isSafeInteger(normalized)
|
||||
|| normalized < 0
|
||||
|| normalized > 0xffffffff
|
||||
) {
|
||||
throw new TypeError(errorCode);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function assertBuffer(input, errorCode) {
|
||||
if (!Buffer.isBuffer(input)) {
|
||||
throw new TypeError(errorCode);
|
||||
}
|
||||
}
|
||||
|
||||
function deepFreeze(value) {
|
||||
if (!value || typeof value !== "object" || Object.isFrozen(value)) {
|
||||
return value;
|
||||
|
||||
@@ -3,42 +3,118 @@ import test from "node:test";
|
||||
|
||||
import {
|
||||
ARUSNAVI_B2_MODEL_PROFILE,
|
||||
ARUSNAVI_INTERNAL_SPECIFICATION_REF,
|
||||
assertB2ProfileInvariant,
|
||||
inspectUnverifiedInitialBytes,
|
||||
buildB2HeaderAcknowledgement,
|
||||
buildB2PackageAcknowledgement,
|
||||
tryParseB2Header2,
|
||||
tryParseB2Package,
|
||||
} from "../src/index.mjs";
|
||||
|
||||
test("records the official B2 route and identity evidence", () => {
|
||||
const specificationHeader = Buffer.from(
|
||||
"FF23E9EF782DE7120300",
|
||||
"hex",
|
||||
);
|
||||
const specificationPackage = Buffer.from(
|
||||
"5B01010000FBDEC251EC5D",
|
||||
"hex",
|
||||
);
|
||||
|
||||
test("records the official B2 route, framing and identity evidence", () => {
|
||||
assert.equal(assertB2ProfileInvariant(), true);
|
||||
assert.equal(ARUSNAVI_B2_MODEL_PROFILE.monitoringServerSlots, 4);
|
||||
assert.equal(ARUSNAVI_B2_MODEL_PROFILE.protocol, "INTERNAL");
|
||||
assert.equal(ARUSNAVI_B2_MODEL_PROFILE.serverIdentity.kind, "imei");
|
||||
assert.equal(
|
||||
ARUSNAVI_B2_MODEL_PROFILE.framing.specificationRef,
|
||||
ARUSNAVI_INTERNAL_SPECIFICATION_REF,
|
||||
);
|
||||
assert.equal(
|
||||
ARUSNAVI_B2_MODEL_PROFILE.routeCompatibility.gelios,
|
||||
"parallel-preserved",
|
||||
);
|
||||
});
|
||||
|
||||
test("fails closed instead of guessing an IMEI from unverified bytes", () => {
|
||||
const fakeBytes = Buffer.from(
|
||||
"unverified-frame-with-fake-identifier-000000000000001",
|
||||
"utf8",
|
||||
test("parses the official HEADER2 example as a claimed IMEI", () => {
|
||||
assert.equal(
|
||||
tryParseB2Header2(specificationHeader.subarray(0, 9)).status,
|
||||
"incomplete",
|
||||
);
|
||||
const evidence = inspectUnverifiedInitialBytes(fakeBytes);
|
||||
const serialized = JSON.stringify(evidence);
|
||||
assert.equal(evidence.status, "official_framing_required");
|
||||
assert.equal(evidence.identifierExtracted, false);
|
||||
assert.equal(serialized.includes("000000000000001"), false);
|
||||
assert.match(evidence.contentDigest, /^sha256:[a-f0-9]{64}$/);
|
||||
const parsed = tryParseB2Header2(specificationHeader);
|
||||
assert.equal(parsed.status, "complete");
|
||||
assert.equal(parsed.bytesConsumed, 10);
|
||||
assert.equal(parsed.identifier.kind, "imei");
|
||||
assert.equal(parsed.identifier.value, "865209039777769");
|
||||
assert.equal(parsed.identifier.trust, "claimed-not-ownership-proof");
|
||||
assert.deepEqual(parsed.evidence, {
|
||||
transport: "tcp",
|
||||
bytesObserved: 10,
|
||||
framingStatus: "verified",
|
||||
specificationRef: ARUSNAVI_INTERNAL_SPECIFICATION_REF,
|
||||
});
|
||||
});
|
||||
|
||||
test("enforces the bounded initial frame evidence window", () => {
|
||||
test("builds the official HEADER2 acknowledgement example", () => {
|
||||
assert.equal(
|
||||
buildB2HeaderAcknowledgement(0x52db95de).toString("hex").toUpperCase(),
|
||||
"7B0400A0DE95DB527D",
|
||||
);
|
||||
});
|
||||
|
||||
test("parses and acknowledges the official package example", () => {
|
||||
assert.equal(
|
||||
tryParseB2Package(specificationPackage.subarray(0, -1)).status,
|
||||
"incomplete",
|
||||
);
|
||||
assert.deepEqual(tryParseB2Package(specificationPackage), {
|
||||
status: "complete",
|
||||
bytesConsumed: specificationPackage.length,
|
||||
packageNumber: 1,
|
||||
packetCount: 1,
|
||||
});
|
||||
assert.equal(
|
||||
buildB2PackageAcknowledgement(1).toString("hex").toUpperCase(),
|
||||
"7B00017D",
|
||||
);
|
||||
});
|
||||
|
||||
test("uses packet lengths and checksum instead of scanning for 0x5D", () => {
|
||||
const packetData = Buffer.from([0x5d]);
|
||||
const unixTime = Buffer.from([0x01, 0x00, 0x00, 0x00]);
|
||||
const checksum = (0x01 + 0x5d) & 0xff;
|
||||
const packageBytes = Buffer.from([
|
||||
0x5b,
|
||||
0x02,
|
||||
0x01,
|
||||
packetData.length,
|
||||
0x00,
|
||||
...unixTime,
|
||||
...packetData,
|
||||
checksum,
|
||||
0x5d,
|
||||
]);
|
||||
assert.deepEqual(tryParseB2Package(packageBytes), {
|
||||
status: "complete",
|
||||
bytesConsumed: packageBytes.length,
|
||||
packageNumber: 2,
|
||||
packetCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("fails closed on unsupported headers and malformed packages", () => {
|
||||
assert.throws(
|
||||
() => inspectUnverifiedInitialBytes(Buffer.alloc(0)),
|
||||
/b2_initial_bytes_empty/,
|
||||
() => tryParseB2Header2(Buffer.from("FE23E9EF782DE7120300", "hex")),
|
||||
/b2_header_start_invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() => inspectUnverifiedInitialBytes(Buffer.alloc(4097)),
|
||||
/b2_initial_bytes_limit_exceeded/,
|
||||
() => tryParseB2Header2(Buffer.from("FF24E9EF782DE7120300", "hex")),
|
||||
/b2_header_version_unsupported/,
|
||||
);
|
||||
const badChecksum = Buffer.from(specificationPackage);
|
||||
badChecksum[badChecksum.length - 2] ^= 0xff;
|
||||
assert.throws(
|
||||
() => tryParseB2Package(badChecksum),
|
||||
/b2_packet_checksum_invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
ARUSNAVI_B2_MODEL_PROFILE,
|
||||
inspectUnverifiedInitialBytes,
|
||||
buildB2HeaderAcknowledgement,
|
||||
buildB2PackageAcknowledgement,
|
||||
tryParseB2Header2,
|
||||
tryParseB2Package,
|
||||
} from "../../../packages/arusnavi-b2-adapter/src/index.mjs";
|
||||
import {
|
||||
DEVICE_DISCOVERY_SIGNAL_SCHEMA,
|
||||
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||
|
||||
export function createDeviceGatewayRuntime(options = {}) {
|
||||
const config = normalizeConfig(options);
|
||||
const sessions = new Map();
|
||||
const sessionsByAddress = new Map();
|
||||
const connectionWindows = new Map();
|
||||
let totalAccepted = 0;
|
||||
let totalRejected = 0;
|
||||
let totalEvidence = 0;
|
||||
let totalDiscoveries = 0;
|
||||
let totalPackagesAcknowledged = 0;
|
||||
|
||||
const tcpServer = createTcpServer((socket) => {
|
||||
if (sessions.size >= config.maxConcurrentSessions) {
|
||||
const remoteAddress = normalizeRemoteAddress(socket.remoteAddress);
|
||||
if (
|
||||
sessions.size >= config.maxConcurrentSessions
|
||||
|| currentAddressSessions(remoteAddress) >= config.maxSessionsPerAddress
|
||||
|| !consumeConnectionPermit(remoteAddress)
|
||||
) {
|
||||
totalRejected += 1;
|
||||
socket.destroy();
|
||||
return;
|
||||
@@ -24,40 +38,42 @@ export function createDeviceGatewayRuntime(options = {}) {
|
||||
const sessionRef = `session:${randomUUID()}`;
|
||||
const session = {
|
||||
sessionRef,
|
||||
bytes: [],
|
||||
byteLength: 0,
|
||||
evidenceRecorded: false,
|
||||
remoteAddress,
|
||||
buffer: Buffer.alloc(0),
|
||||
state: "awaiting-header",
|
||||
processing: false,
|
||||
rejected: false,
|
||||
closed: false,
|
||||
};
|
||||
sessions.set(socket, session);
|
||||
incrementAddressSessions(remoteAddress);
|
||||
totalAccepted += 1;
|
||||
socket.setNoDelay(true);
|
||||
socket.setTimeout(config.sessionTimeoutMs);
|
||||
|
||||
socket.on("data", (chunk) => {
|
||||
if (session.evidenceRecorded) return;
|
||||
session.byteLength += chunk.length;
|
||||
if (session.byteLength > config.maxInitialBytes) {
|
||||
totalRejected += 1;
|
||||
socket.destroy();
|
||||
if (session.closed || session.rejected) return;
|
||||
socket.pause();
|
||||
session.buffer = Buffer.concat(
|
||||
[session.buffer, chunk],
|
||||
session.buffer.length + chunk.length,
|
||||
);
|
||||
if (session.buffer.length > config.maxBufferedBytes) {
|
||||
rejectSession(socket, session);
|
||||
return;
|
||||
}
|
||||
session.bytes.push(chunk);
|
||||
const evidence = inspectUnverifiedInitialBytes(
|
||||
Buffer.concat(session.bytes, session.byteLength),
|
||||
);
|
||||
session.evidenceRecorded = true;
|
||||
totalEvidence += 1;
|
||||
config.onEvidence?.({
|
||||
sessionRef,
|
||||
evidence,
|
||||
});
|
||||
// Until exact official framing is implemented, the gateway never sends
|
||||
// acknowledgement or command bytes and never guesses an identifier.
|
||||
socket.end();
|
||||
if (session.processing) return;
|
||||
session.processing = true;
|
||||
void processSession(socket, session)
|
||||
.catch(() => rejectSession(socket, session))
|
||||
.finally(() => {
|
||||
session.processing = false;
|
||||
if (!session.closed && !session.rejected) socket.resume();
|
||||
});
|
||||
});
|
||||
socket.on("timeout", () => socket.destroy());
|
||||
socket.on("close", () => sessions.delete(socket));
|
||||
socket.on("error", () => sessions.delete(socket));
|
||||
socket.on("timeout", () => rejectSession(socket, session));
|
||||
socket.on("close", () => closeSession(socket, session));
|
||||
socket.on("error", () => closeSession(socket, session));
|
||||
});
|
||||
|
||||
const healthServer = createHttpServer((request, response) => {
|
||||
@@ -74,14 +90,17 @@ export function createDeviceGatewayRuntime(options = {}) {
|
||||
service: "nodedc-device-gateway",
|
||||
protocolProfile: ARUSNAVI_B2_MODEL_PROFILE.profileRef,
|
||||
framing: ARUSNAVI_B2_MODEL_PROFILE.framing.status,
|
||||
tcpListener: config.listenEnabled ? "internal-test-only" : "disabled",
|
||||
publicIngress: "disabled",
|
||||
tcpListener: config.listenEnabled ? "discovery-only" : "disabled",
|
||||
publicIngress: config.publicIngressEnabled
|
||||
? "discovery-only"
|
||||
: "disabled",
|
||||
commandTransport: "disabled",
|
||||
sessions: {
|
||||
active: sessions.size,
|
||||
accepted: totalAccepted,
|
||||
rejected: totalRejected,
|
||||
evidenceRecorded: totalEvidence,
|
||||
discoveries: totalDiscoveries,
|
||||
packagesAcknowledged: totalPackagesAcknowledged,
|
||||
},
|
||||
})}\n`);
|
||||
});
|
||||
@@ -98,7 +117,10 @@ export function createDeviceGatewayRuntime(options = {}) {
|
||||
};
|
||||
},
|
||||
async stop() {
|
||||
for (const socket of sessions.keys()) socket.destroy();
|
||||
for (const [socket, session] of sessions) {
|
||||
session.closed = true;
|
||||
socket.destroy();
|
||||
}
|
||||
await Promise.all([
|
||||
closeServer(healthServer),
|
||||
config.listenEnabled ? closeServer(tcpServer) : Promise.resolve(),
|
||||
@@ -109,25 +131,120 @@ export function createDeviceGatewayRuntime(options = {}) {
|
||||
activeSessions: sessions.size,
|
||||
totalAccepted,
|
||||
totalRejected,
|
||||
totalEvidence,
|
||||
totalDiscoveries,
|
||||
totalPackagesAcknowledged,
|
||||
commandTransport: "disabled",
|
||||
publicIngress: "disabled",
|
||||
publicIngress: config.publicIngressEnabled
|
||||
? "discovery-only"
|
||||
: "disabled",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
async function processSession(socket, session) {
|
||||
while (!session.closed && !session.rejected) {
|
||||
if (session.state === "awaiting-header") {
|
||||
const parsed = tryParseB2Header2(session.buffer);
|
||||
if (parsed.status === "incomplete") return;
|
||||
|
||||
const observedAt = config.now().toISOString();
|
||||
await config.onDiscovery?.({
|
||||
schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA,
|
||||
sessionRef: session.sessionRef,
|
||||
modelProfileRef: ARUSNAVI_B2_MODEL_PROFILE.profileRef,
|
||||
protocol: ARUSNAVI_B2_MODEL_PROFILE.protocol,
|
||||
observedAt,
|
||||
identifier: {
|
||||
kind: parsed.identifier.kind,
|
||||
value: parsed.identifier.value,
|
||||
},
|
||||
evidence: parsed.evidence,
|
||||
});
|
||||
session.buffer = session.buffer.subarray(parsed.bytesConsumed);
|
||||
session.state = "packages";
|
||||
totalDiscoveries += 1;
|
||||
socket.write(buildB2HeaderAcknowledgement(
|
||||
Math.floor(new Date(observedAt).getTime() / 1000),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = tryParseB2Package(session.buffer);
|
||||
if (parsed.status === "incomplete") return;
|
||||
session.buffer = session.buffer.subarray(parsed.bytesConsumed);
|
||||
totalPackagesAcknowledged += 1;
|
||||
socket.write(buildB2PackageAcknowledgement(parsed.packageNumber));
|
||||
}
|
||||
}
|
||||
|
||||
function currentAddressSessions(remoteAddress) {
|
||||
return sessionsByAddress.get(remoteAddress) || 0;
|
||||
}
|
||||
|
||||
function incrementAddressSessions(remoteAddress) {
|
||||
sessionsByAddress.set(
|
||||
remoteAddress,
|
||||
currentAddressSessions(remoteAddress) + 1,
|
||||
);
|
||||
}
|
||||
|
||||
function decrementAddressSessions(remoteAddress) {
|
||||
const current = currentAddressSessions(remoteAddress);
|
||||
if (current <= 1) {
|
||||
sessionsByAddress.delete(remoteAddress);
|
||||
} else {
|
||||
sessionsByAddress.set(remoteAddress, current - 1);
|
||||
}
|
||||
}
|
||||
|
||||
function consumeConnectionPermit(remoteAddress) {
|
||||
const nowMs = config.now().getTime();
|
||||
const current = connectionWindows.get(remoteAddress);
|
||||
if (!current || nowMs - current.startedAt >= 60_000) {
|
||||
connectionWindows.set(remoteAddress, {
|
||||
startedAt: nowMs,
|
||||
count: 1,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (current.count >= config.maxConnectionsPerMinutePerAddress) {
|
||||
return false;
|
||||
}
|
||||
current.count += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
function rejectSession(socket, session) {
|
||||
if (!session.rejected) {
|
||||
session.rejected = true;
|
||||
totalRejected += 1;
|
||||
}
|
||||
socket.destroy();
|
||||
}
|
||||
|
||||
function closeSession(socket, session) {
|
||||
if (session.closed) return;
|
||||
session.closed = true;
|
||||
sessions.delete(socket);
|
||||
decrementAddressSessions(session.remoteAddress);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeConfig(input) {
|
||||
const listenEnabled = input.listenEnabled === true;
|
||||
const maxInitialBytes = parseInteger(
|
||||
input.maxInitialBytes,
|
||||
ARUSNAVI_B2_MODEL_PROFILE.framing.maxInitialBytes,
|
||||
1,
|
||||
ARUSNAVI_B2_MODEL_PROFILE.framing.maxInitialBytes,
|
||||
"device_gateway_initial_bytes_invalid",
|
||||
);
|
||||
const publicIngressEnabled = input.publicIngressEnabled === true;
|
||||
if (publicIngressEnabled && !listenEnabled) {
|
||||
throw new TypeError("device_gateway_public_ingress_listener_required");
|
||||
}
|
||||
if (
|
||||
publicIngressEnabled
|
||||
&& typeof input.onDiscovery !== "function"
|
||||
) {
|
||||
throw new TypeError("device_gateway_discovery_sink_required");
|
||||
}
|
||||
return {
|
||||
listenEnabled,
|
||||
publicIngressEnabled,
|
||||
healthHost: normalizeHealthHost(input.healthHost, "127.0.0.1"),
|
||||
healthPort: parseInteger(
|
||||
input.healthPort,
|
||||
@@ -136,7 +253,11 @@ function normalizeConfig(input) {
|
||||
65535,
|
||||
"device_gateway_health_port_invalid",
|
||||
),
|
||||
tcpHost: normalizeTcpHost(input.tcpHost, "127.0.0.1"),
|
||||
tcpHost: normalizeTcpHost(
|
||||
input.tcpHost,
|
||||
publicIngressEnabled ? "0.0.0.0" : "127.0.0.1",
|
||||
publicIngressEnabled,
|
||||
),
|
||||
tcpPort: parseInteger(
|
||||
input.tcpPort,
|
||||
9921,
|
||||
@@ -144,7 +265,13 @@ function normalizeConfig(input) {
|
||||
65535,
|
||||
"device_gateway_tcp_port_invalid",
|
||||
),
|
||||
maxInitialBytes,
|
||||
maxBufferedBytes: parseInteger(
|
||||
input.maxBufferedBytes,
|
||||
ARUSNAVI_B2_MODEL_PROFILE.framing.maxBufferedBytes,
|
||||
1024,
|
||||
ARUSNAVI_B2_MODEL_PROFILE.framing.maxBufferedBytes,
|
||||
"device_gateway_buffer_limit_invalid",
|
||||
),
|
||||
maxConcurrentSessions: parseInteger(
|
||||
input.maxConcurrentSessions,
|
||||
100,
|
||||
@@ -152,6 +279,20 @@ function normalizeConfig(input) {
|
||||
10000,
|
||||
"device_gateway_session_limit_invalid",
|
||||
),
|
||||
maxSessionsPerAddress: parseInteger(
|
||||
input.maxSessionsPerAddress,
|
||||
10,
|
||||
1,
|
||||
1000,
|
||||
"device_gateway_address_session_limit_invalid",
|
||||
),
|
||||
maxConnectionsPerMinutePerAddress: parseInteger(
|
||||
input.maxConnectionsPerMinutePerAddress,
|
||||
30,
|
||||
1,
|
||||
10000,
|
||||
"device_gateway_address_rate_limit_invalid",
|
||||
),
|
||||
sessionTimeoutMs: parseInteger(
|
||||
input.sessionTimeoutMs,
|
||||
10000,
|
||||
@@ -159,9 +300,10 @@ function normalizeConfig(input) {
|
||||
60000,
|
||||
"device_gateway_session_timeout_invalid",
|
||||
),
|
||||
onEvidence: typeof input.onEvidence === "function"
|
||||
? input.onEvidence
|
||||
onDiscovery: typeof input.onDiscovery === "function"
|
||||
? input.onDiscovery
|
||||
: undefined,
|
||||
now: typeof input.now === "function" ? input.now : () => new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -173,14 +315,26 @@ function normalizeHealthHost(value, fallback) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeTcpHost(value, fallback) {
|
||||
function normalizeTcpHost(value, fallback, publicIngressEnabled) {
|
||||
const normalized = String(value || fallback).trim();
|
||||
if (!["127.0.0.1", "::1"].includes(normalized)) {
|
||||
throw new TypeError("device_gateway_baseline_loopback_only");
|
||||
const allowed = publicIngressEnabled
|
||||
? ["0.0.0.0", "::"]
|
||||
: ["127.0.0.1", "::1"];
|
||||
if (!allowed.includes(normalized)) {
|
||||
throw new TypeError(
|
||||
publicIngressEnabled
|
||||
? "device_gateway_public_ingress_host_invalid"
|
||||
: "device_gateway_baseline_loopback_only",
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeRemoteAddress(value) {
|
||||
const normalized = String(value || "unknown").trim();
|
||||
return normalized.slice(0, 64) || "unknown";
|
||||
}
|
||||
|
||||
function parseInteger(value, fallback, minimum, maximum, errorCode) {
|
||||
const parsed = Number(value ?? fallback);
|
||||
if (
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { createCoreDiscoveryClient } from "./core-client.mjs";
|
||||
import { createDeviceGatewayRuntime } from "./runtime.mjs";
|
||||
|
||||
const listenEnabled = parseBoolean(
|
||||
process.env.DEVICE_GATEWAY_LISTEN_ENABLED,
|
||||
false,
|
||||
);
|
||||
const config = await readConfig();
|
||||
const onDiscovery = config.listenEnabled
|
||||
? createCoreDiscoveryClient({
|
||||
coreUrl: config.coreUrl,
|
||||
gatewayToken: config.gatewayToken,
|
||||
timeoutMs: config.coreTimeoutMs,
|
||||
})
|
||||
: undefined;
|
||||
const runtime = createDeviceGatewayRuntime({
|
||||
listenEnabled,
|
||||
healthHost: String(process.env.DEVICE_GATEWAY_HEALTH_HOST || "127.0.0.1"),
|
||||
healthPort: parsePort(process.env.DEVICE_GATEWAY_HEALTH_PORT, 18121),
|
||||
tcpHost: String(process.env.DEVICE_GATEWAY_TCP_HOST || "127.0.0.1"),
|
||||
tcpPort: parsePort(process.env.DEVICE_GATEWAY_TCP_PORT, 9921),
|
||||
maxConcurrentSessions: parsePositiveInt(
|
||||
process.env.DEVICE_GATEWAY_MAX_SESSIONS,
|
||||
100,
|
||||
),
|
||||
sessionTimeoutMs: parsePositiveInt(
|
||||
process.env.DEVICE_GATEWAY_SESSION_TIMEOUT_MS,
|
||||
10000,
|
||||
),
|
||||
listenEnabled: config.listenEnabled,
|
||||
publicIngressEnabled: config.publicIngressEnabled,
|
||||
healthHost: config.healthHost,
|
||||
healthPort: config.healthPort,
|
||||
tcpHost: config.tcpHost,
|
||||
tcpPort: config.tcpPort,
|
||||
maxBufferedBytes: config.maxBufferedBytes,
|
||||
maxConcurrentSessions: config.maxConcurrentSessions,
|
||||
maxSessionsPerAddress: config.maxSessionsPerAddress,
|
||||
maxConnectionsPerMinutePerAddress:
|
||||
config.maxConnectionsPerMinutePerAddress,
|
||||
sessionTimeoutMs: config.sessionTimeoutMs,
|
||||
onDiscovery,
|
||||
});
|
||||
|
||||
const addresses = await runtime.start();
|
||||
@@ -25,7 +32,9 @@ console.log(JSON.stringify({
|
||||
event: "device_gateway_started",
|
||||
health: addresses.healthAddress,
|
||||
tcp: addresses.tcpAddress,
|
||||
publicIngress: "disabled",
|
||||
publicIngress: config.publicIngressEnabled
|
||||
? "discovery-only"
|
||||
: "disabled",
|
||||
commandTransport: "disabled",
|
||||
}));
|
||||
|
||||
@@ -37,6 +46,80 @@ async function shutdown() {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
async function readConfig() {
|
||||
const listenEnabled = parseBoolean(
|
||||
process.env.DEVICE_GATEWAY_LISTEN_ENABLED,
|
||||
false,
|
||||
);
|
||||
const publicIngressEnabled = parseBoolean(
|
||||
process.env.DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED,
|
||||
false,
|
||||
);
|
||||
return {
|
||||
listenEnabled,
|
||||
publicIngressEnabled,
|
||||
healthHost: String(
|
||||
process.env.DEVICE_GATEWAY_HEALTH_HOST || "127.0.0.1",
|
||||
),
|
||||
healthPort: parsePort(process.env.DEVICE_GATEWAY_HEALTH_PORT, 18121),
|
||||
tcpHost: String(
|
||||
process.env.DEVICE_GATEWAY_TCP_HOST
|
||||
|| (publicIngressEnabled ? "0.0.0.0" : "127.0.0.1"),
|
||||
),
|
||||
tcpPort: parsePort(process.env.DEVICE_GATEWAY_TCP_PORT, 9921),
|
||||
maxBufferedBytes: parsePositiveInt(
|
||||
process.env.DEVICE_GATEWAY_MAX_BUFFERED_BYTES,
|
||||
65536,
|
||||
),
|
||||
maxConcurrentSessions: parsePositiveInt(
|
||||
process.env.DEVICE_GATEWAY_MAX_SESSIONS,
|
||||
100,
|
||||
),
|
||||
maxSessionsPerAddress: parsePositiveInt(
|
||||
process.env.DEVICE_GATEWAY_MAX_SESSIONS_PER_ADDRESS,
|
||||
10,
|
||||
),
|
||||
maxConnectionsPerMinutePerAddress: parsePositiveInt(
|
||||
process.env.DEVICE_GATEWAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS,
|
||||
30,
|
||||
),
|
||||
sessionTimeoutMs: parsePositiveInt(
|
||||
process.env.DEVICE_GATEWAY_SESSION_TIMEOUT_MS,
|
||||
10000,
|
||||
),
|
||||
coreUrl: listenEnabled
|
||||
? requiredValue(
|
||||
process.env.DEVICE_GATEWAY_CORE_URL,
|
||||
"device_gateway_core_url_required",
|
||||
)
|
||||
: "",
|
||||
gatewayToken: listenEnabled
|
||||
? await readRequiredSecretFile(
|
||||
process.env.DEVICE_GATEWAY_CORE_TOKEN_FILE,
|
||||
"device_gateway_core_token_file_required",
|
||||
)
|
||||
: "",
|
||||
coreTimeoutMs: parsePositiveInt(
|
||||
process.env.DEVICE_GATEWAY_CORE_TIMEOUT_MS,
|
||||
5000,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function readRequiredSecretFile(path, errorCode) {
|
||||
const normalized = requiredValue(path, errorCode);
|
||||
const value = (await readFile(normalized, "utf8")).trim();
|
||||
if (value.length < 32) throw new Error(errorCode);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requiredValue(value, errorCode) {
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
throw new Error(errorCode);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function parsePort(value, fallback) {
|
||||
const parsed = Number(value || fallback);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
const specificationHeader = Buffer.from(
|
||||
"FF23E9EF782DE7120300",
|
||||
"hex",
|
||||
);
|
||||
const specificationPackage = Buffer.from(
|
||||
"5B01010000FBDEC251EC5D",
|
||||
"hex",
|
||||
);
|
||||
|
||||
test("baseline health exposes no public ingress and no command transport", async () => {
|
||||
const runtime = createDeviceGatewayRuntime({
|
||||
healthPort: 0,
|
||||
@@ -25,37 +34,93 @@ test("baseline health exposes no public ingress and no command transport", async
|
||||
}
|
||||
});
|
||||
|
||||
test("loopback evidence listener emits no acknowledgement or command bytes", async () => {
|
||||
test("discovery-only ingress persists HEADER2 before acknowledging packages", async () => {
|
||||
const captured = [];
|
||||
const runtime = createDeviceGatewayRuntime({
|
||||
healthPort: 0,
|
||||
tcpHost: "0.0.0.0",
|
||||
tcpPort: 0,
|
||||
listenEnabled: true,
|
||||
publicIngressEnabled: true,
|
||||
now: () => new Date(0x52db95de * 1000),
|
||||
onDiscovery: async (value) => captured.push(value),
|
||||
});
|
||||
const addresses = await runtime.start();
|
||||
const client = await connectAndCollect(addresses.tcpAddress.port);
|
||||
try {
|
||||
client.socket.write(specificationHeader.subarray(0, 4));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(client.bytes().length, 0);
|
||||
|
||||
client.socket.write(specificationHeader.subarray(4));
|
||||
await client.waitForBytes(9);
|
||||
assert.equal(
|
||||
client.bytes().subarray(0, 9).toString("hex").toUpperCase(),
|
||||
"7B0400A0DE95DB527D",
|
||||
);
|
||||
assert.equal(captured.length, 1);
|
||||
assert.equal(captured[0].identifier.value, "865209039777769");
|
||||
assert.equal(captured[0].evidence.framingStatus, "verified");
|
||||
assert.equal(captured[0].commandTransport, undefined);
|
||||
|
||||
client.socket.write(specificationPackage);
|
||||
await client.waitForBytes(13);
|
||||
assert.equal(
|
||||
client.bytes().subarray(9).toString("hex").toUpperCase(),
|
||||
"7B00017D",
|
||||
);
|
||||
assert.equal(runtime.status().totalDiscoveries, 1);
|
||||
assert.equal(runtime.status().totalPackagesAcknowledged, 1);
|
||||
assert.equal(runtime.status().commandTransport, "disabled");
|
||||
assert.equal(runtime.status().publicIngress, "discovery-only");
|
||||
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${addresses.healthAddress.port}/healthz`,
|
||||
);
|
||||
const body = await response.json();
|
||||
assert.equal(body.framing, "verified-read-only");
|
||||
assert.equal(body.tcpListener, "discovery-only");
|
||||
assert.equal(body.publicIngress, "discovery-only");
|
||||
assert.equal(body.commandTransport, "disabled");
|
||||
} finally {
|
||||
client.socket.destroy();
|
||||
await runtime.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("does not acknowledge malformed or unverified initial bytes", async () => {
|
||||
const captured = [];
|
||||
const runtime = createDeviceGatewayRuntime({
|
||||
healthPort: 0,
|
||||
tcpPort: 0,
|
||||
listenEnabled: true,
|
||||
onEvidence: (value) => captured.push(value),
|
||||
onDiscovery: async (value) => captured.push(value),
|
||||
});
|
||||
const addresses = await runtime.start();
|
||||
try {
|
||||
const received = await sendAndCollect(
|
||||
addresses.tcpAddress.port,
|
||||
Buffer.from(
|
||||
"unverified-frame-with-fake-identifier-000000000000001",
|
||||
"utf8",
|
||||
),
|
||||
Buffer.from("not-a-b2-header", "utf8"),
|
||||
);
|
||||
assert.equal(received.length, 0);
|
||||
assert.equal(captured.length, 1);
|
||||
assert.equal(captured[0].evidence.identifierExtracted, false);
|
||||
assert.equal(
|
||||
JSON.stringify(captured).includes("000000000000001"),
|
||||
false,
|
||||
);
|
||||
assert.equal(runtime.status().totalEvidence, 1);
|
||||
assert.equal(runtime.status().commandTransport, "disabled");
|
||||
assert.equal(captured.length, 0);
|
||||
assert.equal(runtime.status().totalRejected, 1);
|
||||
} finally {
|
||||
await runtime.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("public ingress requires an authenticated discovery sink", () => {
|
||||
assert.throws(
|
||||
() => createDeviceGatewayRuntime({
|
||||
listenEnabled: true,
|
||||
publicIngressEnabled: true,
|
||||
tcpHost: "0.0.0.0",
|
||||
}),
|
||||
/device_gateway_discovery_sink_required/,
|
||||
);
|
||||
});
|
||||
|
||||
test("baseline rejects non-loopback binding", () => {
|
||||
assert.throws(
|
||||
() => createDeviceGatewayRuntime({
|
||||
@@ -66,7 +131,7 @@ test("baseline rejects non-loopback binding", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("container health may bind all interfaces while TCP stays loopback-only", async () => {
|
||||
test("container health may bind all interfaces while TCP stays disabled", async () => {
|
||||
const runtime = createDeviceGatewayRuntime({
|
||||
healthHost: "0.0.0.0",
|
||||
healthPort: 0,
|
||||
@@ -82,6 +147,40 @@ test("container health may bind all interfaces while TCP stays loopback-only", a
|
||||
}
|
||||
});
|
||||
|
||||
function connectAndCollect(port) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
let byteLength = 0;
|
||||
const waiters = [];
|
||||
const socket = connect({ host: "127.0.0.1", port }, () => {
|
||||
resolve({
|
||||
socket,
|
||||
bytes: () => Buffer.concat(chunks, byteLength),
|
||||
waitForBytes: (minimum) => {
|
||||
if (byteLength >= minimum) return Promise.resolve();
|
||||
return new Promise((waitResolve, waitReject) => {
|
||||
waiters.push({ minimum, waitResolve, waitReject });
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
socket.on("data", (chunk) => {
|
||||
chunks.push(chunk);
|
||||
byteLength += chunk.length;
|
||||
for (let index = waiters.length - 1; index >= 0; index -= 1) {
|
||||
if (byteLength >= waiters[index].minimum) {
|
||||
waiters[index].waitResolve();
|
||||
waiters.splice(index, 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
socket.on("error", (error) => {
|
||||
for (const waiter of waiters.splice(0)) waiter.waitReject(error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sendAndCollect(port, payload) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
@@ -89,7 +188,7 @@ function sendAndCollect(port, payload) {
|
||||
socket.end(payload);
|
||||
});
|
||||
socket.on("data", (chunk) => chunks.push(chunk));
|
||||
socket.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
socket.on("close", () => resolve(Buffer.concat(chunks)));
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user