feat(device-plane): add fail-closed deploy foundation
This commit is contained in:
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user