feat(device-edge): add isolated B2 ingress domain
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user