feat(device-plane): add fail-closed deploy foundation

This commit is contained in:
Codex
2026-07-25 21:29:05 +03:00
parent e9e03143cd
commit e217723784
36 changed files with 3729 additions and 0 deletions
@@ -0,0 +1,14 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
COPY packages ./packages
COPY services/device-gateway ./services/device-gateway
COPY services/device-control-core/package.json ./services/device-control-core/package.json
RUN npm ci --omit=dev --ignore-scripts
USER node
CMD ["node", "services/device-gateway/src/server.mjs"]
@@ -0,0 +1,13 @@
{
"name": "@nodedc/device-gateway",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/server.mjs",
"test": "node --test test/*.test.mjs"
},
"engines": {
"node": ">=20"
}
}
@@ -0,0 +1,208 @@
import { randomUUID } from "node:crypto";
import { createServer as createHttpServer } from "node:http";
import { createServer as createTcpServer } from "node:net";
import {
ARUSNAVI_B2_MODEL_PROFILE,
inspectUnverifiedInitialBytes,
} from "../../../packages/arusnavi-b2-adapter/src/index.mjs";
export function createDeviceGatewayRuntime(options = {}) {
const config = normalizeConfig(options);
const sessions = new Map();
let totalAccepted = 0;
let totalRejected = 0;
let totalEvidence = 0;
const tcpServer = createTcpServer((socket) => {
if (sessions.size >= config.maxConcurrentSessions) {
totalRejected += 1;
socket.destroy();
return;
}
const sessionRef = `session:${randomUUID()}`;
const session = {
sessionRef,
bytes: [],
byteLength: 0,
evidenceRecorded: false,
};
sessions.set(socket, session);
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();
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();
});
socket.on("timeout", () => socket.destroy());
socket.on("close", () => sessions.delete(socket));
socket.on("error", () => sessions.delete(socket));
});
const healthServer = createHttpServer((request, response) => {
response.setHeader("Content-Type", "application/json; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
response.setHeader("X-Content-Type-Options", "nosniff");
if (request.method !== "GET" || request.url !== "/healthz") {
response.statusCode = 404;
return response.end('{"ok":false,"error":"device_gateway_route_not_found"}\n');
}
response.statusCode = 200;
return response.end(`${JSON.stringify({
ok: true,
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",
commandTransport: "disabled",
sessions: {
active: sessions.size,
accepted: totalAccepted,
rejected: totalRejected,
evidenceRecorded: totalEvidence,
},
})}\n`);
});
return {
async start() {
await listen(healthServer, config.healthPort, config.healthHost);
if (config.listenEnabled) {
await listen(tcpServer, config.tcpPort, config.tcpHost);
}
return {
healthAddress: healthServer.address(),
tcpAddress: config.listenEnabled ? tcpServer.address() : null,
};
},
async stop() {
for (const socket of sessions.keys()) socket.destroy();
await Promise.all([
closeServer(healthServer),
config.listenEnabled ? closeServer(tcpServer) : Promise.resolve(),
]);
},
status() {
return {
activeSessions: sessions.size,
totalAccepted,
totalRejected,
totalEvidence,
commandTransport: "disabled",
publicIngress: "disabled",
};
},
};
}
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",
);
return {
listenEnabled,
healthHost: normalizeHealthHost(input.healthHost, "127.0.0.1"),
healthPort: parseInteger(
input.healthPort,
18121,
0,
65535,
"device_gateway_health_port_invalid",
),
tcpHost: normalizeTcpHost(input.tcpHost, "127.0.0.1"),
tcpPort: parseInteger(
input.tcpPort,
9921,
0,
65535,
"device_gateway_tcp_port_invalid",
),
maxInitialBytes,
maxConcurrentSessions: parseInteger(
input.maxConcurrentSessions,
100,
1,
10000,
"device_gateway_session_limit_invalid",
),
sessionTimeoutMs: parseInteger(
input.sessionTimeoutMs,
10000,
100,
60000,
"device_gateway_session_timeout_invalid",
),
onEvidence: typeof input.onEvidence === "function"
? input.onEvidence
: undefined,
};
}
function normalizeHealthHost(value, fallback) {
const normalized = String(value || fallback).trim();
if (!["127.0.0.1", "::1", "0.0.0.0", "::"].includes(normalized)) {
throw new TypeError("device_gateway_health_host_invalid");
}
return normalized;
}
function normalizeTcpHost(value, fallback) {
const normalized = String(value || fallback).trim();
if (!["127.0.0.1", "::1"].includes(normalized)) {
throw new TypeError("device_gateway_baseline_loopback_only");
}
return normalized;
}
function parseInteger(value, fallback, minimum, maximum, errorCode) {
const parsed = Number(value ?? fallback);
if (
!Number.isSafeInteger(parsed)
|| parsed < minimum
|| parsed > maximum
) {
throw new TypeError(errorCode);
}
return parsed;
}
function listen(server, port, host) {
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(port, host, resolve);
});
}
function closeServer(server) {
if (!server.listening) return Promise.resolve();
return new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
@@ -0,0 +1,62 @@
import { createDeviceGatewayRuntime } from "./runtime.mjs";
const listenEnabled = parseBoolean(
process.env.DEVICE_GATEWAY_LISTEN_ENABLED,
false,
);
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,
),
});
const addresses = await runtime.start();
console.log(JSON.stringify({
event: "device_gateway_started",
health: addresses.healthAddress,
tcp: addresses.tcpAddress,
publicIngress: "disabled",
commandTransport: "disabled",
}));
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
async function shutdown() {
await runtime.stop();
process.exit(0);
}
function parsePort(value, fallback) {
const parsed = Number(value || fallback);
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535) {
throw new Error("device_gateway_port_invalid");
}
return parsed;
}
function parsePositiveInt(value, fallback) {
const parsed = Number(value || fallback);
if (!Number.isSafeInteger(parsed) || parsed < 1) {
throw new Error("device_gateway_positive_integer_invalid");
}
return parsed;
}
function parseBoolean(value, fallback) {
if (value === undefined || value === null || value === "") return fallback;
const normalized = String(value).trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(normalized)) return true;
if (["0", "false", "no", "off"].includes(normalized)) return false;
throw new Error("device_gateway_boolean_invalid");
}
@@ -0,0 +1,95 @@
import assert from "node:assert/strict";
import { connect } from "node:net";
import test from "node:test";
import { createDeviceGatewayRuntime } from "../src/runtime.mjs";
test("baseline health exposes no public ingress and no command transport", async () => {
const runtime = createDeviceGatewayRuntime({
healthPort: 0,
listenEnabled: false,
});
const addresses = await runtime.start();
try {
const response = await fetch(
`http://127.0.0.1:${addresses.healthAddress.port}/healthz`,
);
assert.equal(response.status, 200);
const body = await response.json();
assert.equal(body.publicIngress, "disabled");
assert.equal(body.commandTransport, "disabled");
assert.equal(body.tcpListener, "disabled");
assert.equal(addresses.tcpAddress, null);
} finally {
await runtime.stop();
}
});
test("loopback evidence listener emits no acknowledgement or command bytes", async () => {
const captured = [];
const runtime = createDeviceGatewayRuntime({
healthPort: 0,
tcpPort: 0,
listenEnabled: true,
onEvidence: (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",
),
);
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");
} finally {
await runtime.stop();
}
});
test("baseline rejects non-loopback binding", () => {
assert.throws(
() => createDeviceGatewayRuntime({
listenEnabled: true,
tcpHost: "0.0.0.0",
}),
/device_gateway_baseline_loopback_only/,
);
});
test("container health may bind all interfaces while TCP stays loopback-only", async () => {
const runtime = createDeviceGatewayRuntime({
healthHost: "0.0.0.0",
healthPort: 0,
listenEnabled: false,
});
const addresses = await runtime.start();
try {
assert.equal(addresses.healthAddress.address, "0.0.0.0");
assert.equal(addresses.tcpAddress, null);
assert.equal(runtime.status().publicIngress, "disabled");
} finally {
await runtime.stop();
}
});
function sendAndCollect(port, payload) {
return new Promise((resolve, reject) => {
const chunks = [];
const socket = connect({ host: "127.0.0.1", port }, () => {
socket.end(payload);
});
socket.on("data", (chunk) => chunks.push(chunk));
socket.on("end", () => resolve(Buffer.concat(chunks)));
socket.on("error", reject);
});
}