feat(device-edge): add isolated B2 ingress domain

This commit is contained in:
Codex
2026-08-04 11:39:01 +03:00
parent b1a5a26b9a
commit 3bb5e6dc27
35 changed files with 5790 additions and 19 deletions
@@ -0,0 +1,12 @@
FROM alpine:3.22
RUN apk add --no-cache openssh-server \
&& addgroup -g 11021 device-backhaul \
&& adduser -D -H -u 11021 -G device-backhaul -s /sbin/nologin device-backhaul \
&& passwd -u device-backhaul
COPY services/device-backhaul-target/sshd_config /etc/ssh/sshd_config
EXPOSE 2222/tcp
CMD ["sh", "-c", "mkdir -p /run/sshd && exec /usr/sbin/sshd -D -e -f /etc/ssh/sshd_config"]
@@ -0,0 +1,35 @@
Port 2222
ListenAddress 127.0.0.1
AddressFamily inet
Protocol 2
HostKey /run/nodedc-secrets/ssh_host_ed25519_key
AuthorizedKeysFile /run/nodedc-secrets/authorized_keys
StrictModes no
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
AllowUsers device-backhaul
AllowTcpForwarding local
PermitOpen 127.0.0.1:9921
GatewayPorts no
PermitTunnel no
AllowAgentForwarding no
X11Forwarding no
PermitTTY no
PermitUserEnvironment no
ForceCommand /bin/false
ClientAliveInterval 30
ClientAliveCountMax 3
LoginGraceTime 20
MaxAuthTries 3
MaxSessions 4
MaxStartups 4:30:8
UseDNS no
PrintMotd no
LogLevel VERBOSE
@@ -3,9 +3,11 @@ FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
COPY packages ./packages
COPY packages/device-protocol-contract ./packages/device-protocol-contract
COPY packages/arusnavi-b2-adapter ./packages/arusnavi-b2-adapter
COPY services/device-control-core ./services/device-control-core
COPY services/device-gateway/package.json ./services/device-gateway/package.json
COPY services/device-edge-relay/package.json ./services/device-edge-relay/package.json
RUN npm ci --omit=dev --ignore-scripts
@@ -0,0 +1,9 @@
FROM alpine:3.22
RUN apk add --no-cache openssh-client-default netcat-openbsd \
&& addgroup -g 1000 edge-backhaul \
&& adduser -D -H -u 1000 -G edge-backhaul -s /sbin/nologin edge-backhaul
USER 1000:1000
ENTRYPOINT ["/usr/bin/ssh"]
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const here = dirname(fileURLToPath(import.meta.url));
const devicePlane = resolve(here, "../../..");
test("edge backhaul is key-only, pinned and never host-published", async () => {
const compose = await readFile(
resolve(devicePlane, "docker-compose.device-edge.backhaul.yml"),
"utf8",
);
const descriptor = JSON.parse(await readFile(
resolve(devicePlane, "deployment/device-edge-backhaul-v1.json"),
"utf8",
));
assert.match(compose, /StrictHostKeyChecking=yes/);
assert.match(compose, /UserKnownHostsFile=\/run\/trust\/known_hosts/);
assert.match(compose, /PasswordAuthentication=no/);
assert.match(compose, /KbdInteractiveAuthentication=no/);
assert.match(compose, /ExitOnForwardFailure=yes/);
assert.match(compose, /ProxyCommand=nc -X 5 -x nodedc-device-edge-tailnet-1:1055/);
assert.match(compose, /\.\.\/secrets\/backhaul\/id_ed25519/);
assert.doesNotMatch(compose, /\.\.\/keys\/edge-to-synology/);
assert.match(compose, /0\.0\.0\.0:19921:127\.0\.0\.1:9921/);
assert.doesNotMatch(compose, /^\s+ports:/m);
assert.doesNotMatch(compose, /0\.0\.0\.0:9921/);
assert.equal(descriptor.hostPortPublication, "disabled");
assert.equal(descriptor.deviceIngress, "disabled");
assert.equal(descriptor.commandTransport, "disabled");
});
@@ -0,0 +1,9 @@
FROM node:22-alpine
WORKDIR /app
COPY services/device-edge-relay/src ./src
USER node
CMD ["node", "src/server.mjs"]
@@ -0,0 +1,13 @@
{
"name": "@nodedc/device-edge-relay",
"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,294 @@
import { createServer as createHttpServer } from "node:http";
import { connect, createServer as createTcpServer } from "node:net";
export function createDeviceEdgeRelayRuntime(options = {}) {
const config = normalizeConfig(options);
const sessions = new Map();
const sessionsByAddress = new Map();
const connectionWindows = new Map();
let totalAccepted = 0;
let totalRejected = 0;
let totalForwarded = 0;
const tcpServer = createTcpServer({ allowHalfOpen: true }, (socket) => {
const remoteAddress = normalizeRemoteAddress(socket.remoteAddress);
if (
sessions.size >= config.maxConcurrentSessions
|| currentAddressSessions(remoteAddress) >= config.maxSessionsPerAddress
|| !consumeConnectionPermit(remoteAddress)
) {
totalRejected += 1;
socket.destroy();
return;
}
const session = {
remoteAddress,
socket,
upstream: null,
closed: false,
forwarded: false,
};
sessions.set(socket, session);
incrementAddressSessions(remoteAddress);
totalAccepted += 1;
socket.setNoDelay(true);
socket.setTimeout(config.sessionTimeoutMs);
socket.pause();
socket.on("timeout", () => rejectSession(session));
socket.on("close", () => closeSession(session));
socket.on("error", () => rejectSession(session));
const upstream = connect({
host: config.upstreamHost,
port: config.upstreamPort,
});
session.upstream = upstream;
upstream.setNoDelay(true);
upstream.setTimeout(config.sessionTimeoutMs);
upstream.on("connect", () => {
if (session.closed) {
upstream.destroy();
return;
}
session.forwarded = true;
totalForwarded += 1;
socket.pipe(upstream);
upstream.pipe(socket);
socket.resume();
});
upstream.on("timeout", () => rejectSession(session));
upstream.on("error", () => rejectSession(session));
upstream.on("close", () => closeSession(session));
});
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;
response.end('{"ok":false,"error":"device_edge_relay_route_not_found"}\n');
return;
}
response.statusCode = 200;
response.end(`${JSON.stringify({
ok: true,
service: "nodedc-device-edge-relay",
ingress: config.ingressEnabled ? "relay-only" : "disabled",
protocolInspection: "disabled",
commandTransport: "disabled",
sessions: {
active: sessions.size,
accepted: totalAccepted,
rejected: totalRejected,
forwarded: totalForwarded,
},
})}\n`);
});
return {
async start() {
await listen(healthServer, config.healthPort, config.healthHost);
if (config.ingressEnabled) {
await listen(tcpServer, config.tcpPort, config.tcpHost);
}
return {
healthAddress: healthServer.address(),
tcpAddress: config.ingressEnabled ? tcpServer.address() : null,
};
},
async stop() {
for (const session of sessions.values()) rejectSession(session);
await Promise.all([
closeServer(healthServer),
config.ingressEnabled ? closeServer(tcpServer) : Promise.resolve(),
]);
},
status() {
return {
activeSessions: sessions.size,
totalAccepted,
totalRejected,
totalForwarded,
ingress: config.ingressEnabled ? "relay-only" : "disabled",
protocolInspection: "disabled",
commandTransport: "disabled",
};
},
};
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(session) {
if (!session.closed) totalRejected += 1;
session.socket.destroy();
session.upstream?.destroy();
closeSession(session);
}
function closeSession(session) {
if (session.closed) return;
session.closed = true;
sessions.delete(session.socket);
decrementAddressSessions(session.remoteAddress);
}
}
function normalizeConfig(input) {
const ingressEnabled = input.ingressEnabled === true;
return {
ingressEnabled,
healthHost: normalizeHost(input.healthHost, "127.0.0.1"),
healthPort: parseInteger(
input.healthPort,
18221,
0,
65535,
"device_edge_relay_health_port_invalid",
),
tcpHost: normalizeTcpHost(input.tcpHost, ingressEnabled),
tcpPort: parseInteger(
input.tcpPort,
9921,
0,
65535,
"device_edge_relay_tcp_port_invalid",
),
upstreamHost: ingressEnabled
? normalizeUpstreamHost(input.upstreamHost)
: "disabled",
upstreamPort: ingressEnabled
? parseInteger(
input.upstreamPort,
undefined,
1,
65535,
"device_edge_relay_upstream_port_invalid",
)
: 0,
maxConcurrentSessions: parseInteger(
input.maxConcurrentSessions,
100,
1,
10000,
"device_edge_relay_session_limit_invalid",
),
maxSessionsPerAddress: parseInteger(
input.maxSessionsPerAddress,
10,
1,
1000,
"device_edge_relay_address_session_limit_invalid",
),
maxConnectionsPerMinutePerAddress: parseInteger(
input.maxConnectionsPerMinutePerAddress,
30,
1,
10000,
"device_edge_relay_connection_rate_invalid",
),
sessionTimeoutMs: parseInteger(
input.sessionTimeoutMs,
10000,
100,
60000,
"device_edge_relay_session_timeout_invalid",
),
now: typeof input.now === "function" ? input.now : () => new Date(),
};
}
function normalizeHost(value, fallback) {
const normalized = String(value || fallback).trim();
if (!["127.0.0.1", "::1", "0.0.0.0", "::"].includes(normalized)) {
throw new TypeError("device_edge_relay_health_host_invalid");
}
return normalized;
}
function normalizeTcpHost(value, ingressEnabled) {
const fallback = ingressEnabled ? "0.0.0.0" : "127.0.0.1";
const normalized = String(value || fallback).trim();
const allowed = ingressEnabled ? ["0.0.0.0", "::"] : ["127.0.0.1", "::1"];
if (!allowed.includes(normalized)) {
throw new TypeError(
ingressEnabled
? "device_edge_relay_public_ingress_host_invalid"
: "device_edge_relay_baseline_loopback_only",
);
}
return normalized;
}
function normalizeUpstreamHost(value) {
const normalized = String(value || "").trim();
if (
normalized.length === 0
|| normalized.length > 253
|| /[/:\\s]/.test(normalized)
) {
throw new TypeError("device_edge_relay_upstream_host_invalid");
}
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 (!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, () => {
server.off("error", reject);
resolve();
});
});
}
function closeServer(server) {
return new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
@@ -0,0 +1,76 @@
import { createDeviceEdgeRelayRuntime } from "./runtime.mjs";
const runtime = createDeviceEdgeRelayRuntime({
ingressEnabled: parseBoolean(
process.env.DEVICE_EDGE_RELAY_INGRESS_ENABLED,
false,
),
healthHost: process.env.DEVICE_EDGE_RELAY_HEALTH_HOST || "127.0.0.1",
healthPort: parsePort(process.env.DEVICE_EDGE_RELAY_HEALTH_PORT, 18221),
tcpHost: process.env.DEVICE_EDGE_RELAY_TCP_HOST,
tcpPort: parsePort(process.env.DEVICE_EDGE_RELAY_TCP_PORT, 9921),
upstreamHost: process.env.DEVICE_EDGE_RELAY_UPSTREAM_HOST,
upstreamPort: parsePort(
process.env.DEVICE_EDGE_RELAY_UPSTREAM_PORT,
undefined,
),
maxConcurrentSessions: parsePositiveInt(
process.env.DEVICE_EDGE_RELAY_MAX_SESSIONS,
100,
),
maxSessionsPerAddress: parsePositiveInt(
process.env.DEVICE_EDGE_RELAY_MAX_SESSIONS_PER_ADDRESS,
10,
),
maxConnectionsPerMinutePerAddress: parsePositiveInt(
process.env.DEVICE_EDGE_RELAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS,
30,
),
sessionTimeoutMs: parsePositiveInt(
process.env.DEVICE_EDGE_RELAY_SESSION_TIMEOUT_MS,
10000,
),
});
const addresses = await runtime.start();
console.log(JSON.stringify({
event: "device_edge_relay_started",
health: addresses.healthAddress,
tcp: addresses.tcpAddress,
ingress: runtime.status().ingress,
protocolInspection: "disabled",
commandTransport: "disabled",
}));
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
async function shutdown() {
await runtime.stop();
process.exit(0);
}
function parsePort(value, fallback) {
if (value === undefined && fallback === undefined) return undefined;
const parsed = Number(value ?? fallback);
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535) {
throw new Error("device_edge_relay_port_invalid");
}
return parsed;
}
function parsePositiveInt(value, fallback) {
const parsed = Number(value || fallback);
if (!Number.isSafeInteger(parsed) || parsed < 1) {
throw new Error("device_edge_relay_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_edge_relay_boolean_invalid");
}
@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
const devicePlaneRoot = resolve(
dirname(fileURLToPath(import.meta.url)),
"../../..",
);
test("single-NIC ingress source has no host publication and a fixed ipvlan", async () => {
const baseline = await readFile(
resolve(devicePlaneRoot, "docker-compose.device-edge.yml"),
"utf8",
);
const ingress = await readFile(
resolve(devicePlaneRoot, "docker-compose.device-edge.ingress.yml"),
"utf8",
);
assert.match(
baseline,
/DEVICE_EDGE_RELAY_HEALTH_HOST: 127\.0\.0\.1/,
);
assert.doesNotMatch(baseline, /^\s+ports:/m);
assert.doesNotMatch(baseline, /device-edge-control/);
for (const required of [
'DEVICE_EDGE_RELAY_INGRESS_ENABLED: "true"',
"DEVICE_EDGE_RELAY_UPSTREAM_HOST: device-edge-backhaul",
'DEVICE_EDGE_RELAY_UPSTREAM_PORT: "19921"',
"name: nodedc-device-edge-ingress",
"driver: ipvlan",
"parent: enp1s0f0",
"ipvlan_mode: l2",
"ipv4_address: 192.168.71.253",
"subnet: 192.168.68.0/22",
"gateway: 192.168.68.1",
"gw_priority: 100",
]) {
assert.ok(ingress.includes(required), `missing ingress boundary: ${required}`);
}
for (const forbidden of [
"ports:",
"network_mode: host",
"privileged: true",
"DEVICE_EDGE_RELAY_COMMAND",
"0.0.0.0:9921:9921",
]) {
assert.ok(
!ingress.includes(forbidden),
`forbidden ingress boundary: ${forbidden}`,
);
}
});
test("ingress descriptor keeps address approval and router exposure gated", async () => {
const descriptor = JSON.parse(await readFile(
resolve(
devicePlaneRoot,
"deployment/device-edge-ingress-ipvlan-v1.json",
),
"utf8",
));
assert.equal(descriptor.component, "device-edge");
assert.deepEqual(descriptor.selectedServices, ["device-edge-relay"]);
assert.deepEqual(
descriptor.preservedServices,
["device-edge-backhaul", "tailnet"],
);
assert.equal(descriptor.ingressIpv4Approval, "approved-outside-dhcp-pool");
assert.equal(descriptor.hostPortPublication, "disabled");
assert.equal(descriptor.healthPublication, "disabled");
assert.equal(descriptor.privateUpstream, "device-edge-backhaul:19921");
assert.equal(descriptor.protocolInspection, "gateway-owned");
assert.equal(descriptor.discoveryLifecycle, "quarantine");
assert.equal(descriptor.commandTransport, "disabled");
assert.equal(descriptor.gelios, "untouched");
assert.equal(descriptor.amneziaHostFullTunnel, "preserved");
assert.equal(descriptor.routerNatFirewall, "separate-manual-gate");
});
@@ -0,0 +1,95 @@
import assert from "node:assert/strict";
import { createServer, connect } from "node:net";
import test from "node:test";
import { createDeviceEdgeRelayRuntime } from "../src/runtime.mjs";
test("baseline starts only loopback health and no device TCP listener", async () => {
const runtime = createDeviceEdgeRelayRuntime({ healthPort: 0 });
const addresses = await runtime.start();
try {
assert.equal(addresses.tcpAddress, null);
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.ingress, "disabled");
assert.equal(body.protocolInspection, "disabled");
assert.equal(body.commandTransport, "disabled");
} finally {
await runtime.stop();
}
});
test("relay is transparent and never emits its own protocol bytes", async () => {
const upstream = await startEchoServer();
const runtime = createDeviceEdgeRelayRuntime({
healthPort: 0,
ingressEnabled: true,
tcpHost: "0.0.0.0",
tcpPort: 0,
upstreamHost: "127.0.0.1",
upstreamPort: upstream.port,
});
const addresses = await runtime.start();
try {
const response = await sendAndCollect(
addresses.tcpAddress.port,
Buffer.from("ff230102030405060708", "hex"),
);
assert.equal(response.toString("hex"), "ff230102030405060708");
assert.equal(runtime.status().totalForwarded, 1);
assert.equal(runtime.status().commandTransport, "disabled");
} finally {
await runtime.stop();
await closeServer(upstream.server);
}
});
test("enabled relay requires a concrete private upstream", () => {
assert.throws(
() => createDeviceEdgeRelayRuntime({
ingressEnabled: true,
tcpHost: "0.0.0.0",
upstreamPort: 19921,
}),
/device_edge_relay_upstream_host_invalid/,
);
});
test("baseline rejects a non-loopback device binding", () => {
assert.throws(
() => createDeviceEdgeRelayRuntime({ tcpHost: "0.0.0.0" }),
/device_edge_relay_baseline_loopback_only/,
);
});
function startEchoServer() {
const server = createServer((socket) => socket.pipe(socket));
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolve({ server, port: server.address().port });
});
});
}
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("close", () => resolve(Buffer.concat(chunks)));
socket.on("error", reject);
});
}
function closeServer(server) {
return new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
@@ -3,9 +3,11 @@ FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
COPY packages ./packages
COPY packages/device-protocol-contract ./packages/device-protocol-contract
COPY packages/arusnavi-b2-adapter ./packages/arusnavi-b2-adapter
COPY services/device-gateway ./services/device-gateway
COPY services/device-control-core/package.json ./services/device-control-core/package.json
COPY services/device-edge-relay/package.json ./services/device-edge-relay/package.json
RUN npm ci --omit=dev --ignore-scripts