feat(device-edge): add fail-closed ingress admission

This commit is contained in:
Codex
2026-08-04 11:53:18 +03:00
parent 3bb5e6dc27
commit 1eb6c462e3
11 changed files with 335 additions and 60 deletions
@@ -0,0 +1,39 @@
{
"schemaVersion": "nodedc.device-edge.admission-gate.v1",
"mode": "single-nic-ipvlan-b2-relay-only",
"runtimeHost": "ndcmini12",
"component": "device-edge",
"selectedServices": [
"device-edge-relay"
],
"preservedServices": [
"device-edge-backhaul",
"tailnet"
],
"composeProject": "nodedc-device-edge",
"composeFiles": [
"docker-compose.device-edge.yml",
"docker-compose.device-edge.ingress.yml"
],
"parentInterface": "enp1s0f0",
"lanSubnet": "192.168.68.0/22",
"lanGateway": "192.168.68.1",
"ingressIpv4": "192.168.71.253",
"ingressIpv4Approval": "approved-outside-dhcp-pool",
"ingressNetwork": "nodedc-device-edge-ingress",
"deviceTcpListen": "192.168.71.253:9921",
"hostPortPublication": "disabled",
"healthPublication": "disabled",
"privateUpstream": "device-edge-backhaul:19921",
"sourceAdmission": "public-ipv4-only",
"maxTrackedSourceAddresses": 2048,
"maxBytesPerDirection": 262144,
"protocolInspection": "gateway-owned",
"identityTrust": "claimed-not-ownership-proof",
"discoveryLifecycle": "quarantine",
"commandTransport": "disabled",
"gelios": "untouched",
"amneziaHostFullTunnel": "preserved",
"routerNatFirewall": "separate-manual-gate",
"rollback": "restore-reviewed-ipvlan-predecessor-without-network-or-router-mutation"
}
@@ -9,6 +9,9 @@ services:
DEVICE_EDGE_RELAY_MAX_SESSIONS: "100" DEVICE_EDGE_RELAY_MAX_SESSIONS: "100"
DEVICE_EDGE_RELAY_MAX_SESSIONS_PER_ADDRESS: "10" DEVICE_EDGE_RELAY_MAX_SESSIONS_PER_ADDRESS: "10"
DEVICE_EDGE_RELAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS: "30" DEVICE_EDGE_RELAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS: "30"
DEVICE_EDGE_RELAY_SOURCE_POLICY: public-ipv4-only
DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES: "2048"
DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION: "262144"
DEVICE_EDGE_RELAY_SESSION_TIMEOUT_MS: "10000" DEVICE_EDGE_RELAY_SESSION_TIMEOUT_MS: "10000"
networks: networks:
device-edge-private: device-edge-private:
@@ -18,6 +18,8 @@ services:
DEVICE_EDGE_RELAY_MAX_SESSIONS: "100" DEVICE_EDGE_RELAY_MAX_SESSIONS: "100"
DEVICE_EDGE_RELAY_MAX_SESSIONS_PER_ADDRESS: "10" DEVICE_EDGE_RELAY_MAX_SESSIONS_PER_ADDRESS: "10"
DEVICE_EDGE_RELAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS: "30" DEVICE_EDGE_RELAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS: "30"
DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES: "2048"
DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION: "262144"
DEVICE_EDGE_RELAY_SESSION_TIMEOUT_MS: "10000" DEVICE_EDGE_RELAY_SESSION_TIMEOUT_MS: "10000"
networks: networks:
device-edge-private: {} device-edge-private: {}
+29 -14
View File
@@ -108,9 +108,10 @@ does not build, restart, recreate or remove any service.
## Network boundary ## Network boundary
The accepted foundation publishes no device port. The reviewed pilot transition The accepted Synology foundation publishes no device port. Device Gateway's
publishes `9921/TCP` from Device Gateway after a port-collision check and keeps raw B2 listener is reachable only through `127.0.0.1:9921`; its health
health endpoints loopback-only. endpoints are loopback-only. The only planned external raw-TCP termination is
the dedicated Mini Edge Relay described below.
`device.nodedc.ru` is a DNS name, not an HTTP/TCP mode. The same name may later `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. serve an HTTPS Control API on 443 and the B2 raw TCP protocol on 9921.
@@ -129,7 +130,19 @@ target removes even that host publication: health remains container-internal,
the relay stays on the `internal: true` private bridge for backhaul, and a the relay stays on the `internal: true` private bridge for backhaul, and a
second IPvlan L2 attachment gives only the relay a LAN-routable address for second IPvlan L2 attachment gives only the relay a LAN-routable address for
`9921/TCP`. The relay has bounded global/per-address sessions and connection `9921/TCP`. The relay has bounded global/per-address sessions and connection
rate, emits no bytes of its own and does not inspect device payloads. rate, a bounded source-rate table and a per-direction byte budget. It emits no
bytes of its own and does not inspect device payloads.
The admission-gate transition is deliberately fail-closed at the relay: an
ingress instance accepts only a syntactically public IPv4 source, limits its
in-memory source table to 2,048 addresses and closes either direction after
262,144 bytes. Private, loopback, link-local, carrier-grade NAT, multicast,
reserved and documentation addresses are rejected before an upstream connection
is made. This is a connection-admission and resource-boundary control, not a
claim that Docker IPvlan traffic is filtered by a host firewall. A raw B2
protocol has no TLS client identity and cellular devices do not offer a stable
source-IP allowlist, so a router/NAT mapping remains prohibited until its
separate exposure and abuse controls are reviewed.
IPvlan deliberately reuses the Mini's one physical parent `enp1s0f0`; a second IPvlan deliberately reuses the Mini's one physical parent `enp1s0f0`; a second
Ethernet adapter is not required. The host keeps `192.168.68.54/22` and the Ethernet adapter is not required. The host keeps `192.168.68.54/22` and the
@@ -138,8 +151,8 @@ IPv4 and default route through `192.168.68.1`, while its private connected route
continues to reach `device-edge-backhaul:19921`. No Docker host `ports:` entry, continues to reach `device-edge-backhaul:19921`. No Docker host `ports:` entry,
host-network mode, privileged container or VPN teardown is allowed. host-network mode, privileged container or VPN teardown is allowed.
Enabling ingress is a separate reviewed operation and requires all of the Enabling public ingress is a separate reviewed operation and requires all of
following evidence: the following evidence:
1. A distinct, no-shell Synology SSH account and key whose sole permitted open 1. A distinct, no-shell Synology SSH account and key whose sole permitted open
target is `127.0.0.1:9921`; host-key pinning and a persistent, monitored target is `127.0.0.1:9921`; host-key pinning and a persistent, monitored
@@ -147,9 +160,10 @@ following evidence:
2. A private backhaul sidecar/network; the raw listener may forward only to 2. A private backhaul sidecar/network; the raw listener may forward only to
that tunnel. The Core token and all Core/Database secrets remain on that tunnel. The Core token and all Core/Database secrets remain on
Synology. Synology.
3. Router evidence proving the fixed relay IPv4 is outside DHCP, followed by a 3. Router evidence proving the fixed relay IPv4 is outside DHCP. The artifact
manual router/NAT rule mapping only TCP `9921` to that IPv4. The artifact
cannot choose an address and never changes router, firewall or DHCP state. cannot choose an address and never changes router, firewall or DHCP state.
A manual router/NAT rule is a later independent approval, after the relay's
admission gate and external-exposure runbook have been accepted.
4. The host full-tunnel VPN remains active. Before production activation, the 4. The host full-tunnel VPN remains active. Before production activation, the
exact single-NIC IPvlan design must pass duplicate-address detection, exact single-NIC IPvlan design must pass duplicate-address detection,
gateway reachability, external return-path and private-backhaul checks. gateway reachability, external return-path and private-backhaul checks.
@@ -251,12 +265,13 @@ raw TCP remain forbidden until separate reviewed acceptance slices.
2. Build the deterministic `component=device-edge` artifact, promote the 2. Build the deterministic `component=device-edge` artifact, promote the
root-owned Edge runner and review its `plan`. The Synology runner and inbox root-owned Edge runner and review its `plan`. The Synology runner and inbox
are not used for this host. are not used for this host.
3. Apply only `device-edge-relay`; prove exact IPvlan runtime, no host ports, 3. Apply the admission-gate update only to `device-edge-relay`; prove exact
internal health, private backhaul reachability, unchanged backhaul/tailnet IPvlan runtime, no host ports, `public-ipv4-only` admission, byte/source
identities and preserved Amnezia routes. Automatic rollback restores the limits, internal health, private backhaul reachability, unchanged
predecessor relay and removes the unused ingress network. backhaul/tailnet identities and preserved Amnezia routes. Automatic rollback
4. Independently add the single router/NAT rule for TCP `9921` only and verify restores the reviewed IPvlan predecessor and leaves router state unchanged.
that Synology still exposes no public device port. 4. Independently review and add the single router/NAT rule for TCP `9921` only,
then verify that Synology still exposes no public device port.
5. Add the NDC route to one approved B2 free server slot while preserving 5. Add the NDC route to one approved B2 free server slot while preserving
Gelios, then prove HEADER/discovery/PACKAGE acknowledgement. Claim and tenant Gelios, then prove HEADER/discovery/PACKAGE acknowledgement. Claim and tenant
assignment remain a later explicit platform-admin operation. assignment remain a later explicit platform-admin operation.
@@ -11,9 +11,10 @@ export function createDeviceEdgeRelayRuntime(options = {}) {
let totalForwarded = 0; let totalForwarded = 0;
const tcpServer = createTcpServer({ allowHalfOpen: true }, (socket) => { const tcpServer = createTcpServer({ allowHalfOpen: true }, (socket) => {
const remoteAddress = normalizeRemoteAddress(socket.remoteAddress); const remoteAddress = config.resolveRemoteAddress(socket.remoteAddress);
if ( if (
sessions.size >= config.maxConcurrentSessions !allowsSource(remoteAddress)
|| sessions.size >= config.maxConcurrentSessions
|| currentAddressSessions(remoteAddress) >= config.maxSessionsPerAddress || currentAddressSessions(remoteAddress) >= config.maxSessionsPerAddress
|| !consumeConnectionPermit(remoteAddress) || !consumeConnectionPermit(remoteAddress)
) { ) {
@@ -28,6 +29,8 @@ export function createDeviceEdgeRelayRuntime(options = {}) {
upstream: null, upstream: null,
closed: false, closed: false,
forwarded: false, forwarded: false,
inboundBytes: 0,
outboundBytes: 0,
}; };
sessions.set(socket, session); sessions.set(socket, session);
incrementAddressSessions(remoteAddress); incrementAddressSessions(remoteAddress);
@@ -39,6 +42,12 @@ export function createDeviceEdgeRelayRuntime(options = {}) {
socket.on("timeout", () => rejectSession(session)); socket.on("timeout", () => rejectSession(session));
socket.on("close", () => closeSession(session)); socket.on("close", () => closeSession(session));
socket.on("error", () => rejectSession(session)); socket.on("error", () => rejectSession(session));
socket.on("data", (chunk) => {
session.inboundBytes += chunk.length;
if (session.inboundBytes > config.maxBytesPerDirection) {
rejectSession(session);
}
});
const upstream = connect({ const upstream = connect({
host: config.upstreamHost, host: config.upstreamHost,
@@ -61,6 +70,12 @@ export function createDeviceEdgeRelayRuntime(options = {}) {
upstream.on("timeout", () => rejectSession(session)); upstream.on("timeout", () => rejectSession(session));
upstream.on("error", () => rejectSession(session)); upstream.on("error", () => rejectSession(session));
upstream.on("close", () => closeSession(session)); upstream.on("close", () => closeSession(session));
upstream.on("data", (chunk) => {
session.outboundBytes += chunk.length;
if (session.outboundBytes > config.maxBytesPerDirection) {
rejectSession(session);
}
});
}); });
const healthServer = createHttpServer((request, response) => { const healthServer = createHttpServer((request, response) => {
@@ -79,6 +94,7 @@ export function createDeviceEdgeRelayRuntime(options = {}) {
ingress: config.ingressEnabled ? "relay-only" : "disabled", ingress: config.ingressEnabled ? "relay-only" : "disabled",
protocolInspection: "disabled", protocolInspection: "disabled",
commandTransport: "disabled", commandTransport: "disabled",
sourceAdmission: config.sourcePolicy,
sessions: { sessions: {
active: sessions.size, active: sessions.size,
accepted: totalAccepted, accepted: totalAccepted,
@@ -115,6 +131,7 @@ export function createDeviceEdgeRelayRuntime(options = {}) {
ingress: config.ingressEnabled ? "relay-only" : "disabled", ingress: config.ingressEnabled ? "relay-only" : "disabled",
protocolInspection: "disabled", protocolInspection: "disabled",
commandTransport: "disabled", commandTransport: "disabled",
sourceAdmission: config.sourcePolicy,
}; };
}, },
}; };
@@ -141,8 +158,16 @@ export function createDeviceEdgeRelayRuntime(options = {}) {
function consumeConnectionPermit(remoteAddress) { function consumeConnectionPermit(remoteAddress) {
const nowMs = config.now().getTime(); const nowMs = config.now().getTime();
for (const [address, window] of connectionWindows) {
if (nowMs - window.startedAt >= 60_000) {
connectionWindows.delete(address);
}
}
const current = connectionWindows.get(remoteAddress); const current = connectionWindows.get(remoteAddress);
if (!current || nowMs - current.startedAt >= 60_000) { if (!current || nowMs - current.startedAt >= 60_000) {
if (connectionWindows.size >= config.maxTrackedSourceAddresses) {
return false;
}
connectionWindows.set(remoteAddress, { startedAt: nowMs, count: 1 }); connectionWindows.set(remoteAddress, { startedAt: nowMs, count: 1 });
return true; return true;
} }
@@ -151,6 +176,11 @@ export function createDeviceEdgeRelayRuntime(options = {}) {
return true; return true;
} }
function allowsSource(remoteAddress) {
if (config.sourcePolicy === "any") return true;
return isPublicIpv4Address(remoteAddress);
}
function rejectSession(session) { function rejectSession(session) {
if (!session.closed) totalRejected += 1; if (!session.closed) totalRejected += 1;
session.socket.destroy(); session.socket.destroy();
@@ -219,6 +249,24 @@ function normalizeConfig(input) {
10000, 10000,
"device_edge_relay_connection_rate_invalid", "device_edge_relay_connection_rate_invalid",
), ),
maxTrackedSourceAddresses: parseInteger(
input.maxTrackedSourceAddresses,
2048,
1,
65_536,
"device_edge_relay_source_table_limit_invalid",
),
maxBytesPerDirection: parseInteger(
input.maxBytesPerDirection,
262_144,
1_024,
16 * 1024 * 1024,
"device_edge_relay_byte_limit_invalid",
),
sourcePolicy: normalizeSourcePolicy(input.sourcePolicy, ingressEnabled),
resolveRemoteAddress: typeof input.resolveRemoteAddress === "function"
? input.resolveRemoteAddress
: normalizeRemoteAddress,
sessionTimeoutMs: parseInteger( sessionTimeoutMs: parseInteger(
input.sessionTimeoutMs, input.sessionTimeoutMs,
10000, 10000,
@@ -269,6 +317,48 @@ function normalizeRemoteAddress(value) {
return normalized.slice(0, 64) || "unknown"; return normalized.slice(0, 64) || "unknown";
} }
function normalizeSourcePolicy(value, ingressEnabled) {
const fallback = ingressEnabled ? "public-ipv4-only" : "any";
const normalized = String(value || fallback).trim().toLowerCase();
if (!["any", "public-ipv4-only"].includes(normalized)) {
throw new TypeError("device_edge_relay_source_policy_invalid");
}
if (ingressEnabled && normalized !== "public-ipv4-only") {
throw new TypeError("device_edge_relay_ingress_source_policy_invalid");
}
return normalized;
}
function isPublicIpv4Address(value) {
const normalized = String(value || "").trim().replace(/^::ffff:/i, "");
const parts = normalized.split(".");
if (parts.length !== 4) return false;
const octets = parts.map((part) => Number(part));
if (octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return false;
}
const [first, second, third] = octets;
if (
first === 0
|| first === 10
|| first === 127
|| first >= 224
|| (first === 100 && second >= 64 && second <= 127)
|| (first === 169 && second === 254)
|| (first === 172 && second >= 16 && second <= 31)
|| (first === 192 && second === 0 && third === 0)
|| (first === 192 && second === 0 && third === 2)
|| (first === 192 && second === 88 && third === 99)
|| (first === 192 && second === 168)
|| (first === 198 && (second === 18 || second === 19))
|| (first === 198 && second === 51 && third === 100)
|| (first === 203 && second === 0 && third === 113)
) {
return false;
}
return true;
}
function parseInteger(value, fallback, minimum, maximum, errorCode) { function parseInteger(value, fallback, minimum, maximum, errorCode) {
const parsed = Number(value ?? fallback); const parsed = Number(value ?? fallback);
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) { if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
@@ -26,6 +26,15 @@ const runtime = createDeviceEdgeRelayRuntime({
process.env.DEVICE_EDGE_RELAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS, process.env.DEVICE_EDGE_RELAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS,
30, 30,
), ),
maxTrackedSourceAddresses: parsePositiveInt(
process.env.DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES,
2048,
),
maxBytesPerDirection: parsePositiveInt(
process.env.DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION,
262_144,
),
sourcePolicy: process.env.DEVICE_EDGE_RELAY_SOURCE_POLICY,
sessionTimeoutMs: parsePositiveInt( sessionTimeoutMs: parsePositiveInt(
process.env.DEVICE_EDGE_RELAY_SESSION_TIMEOUT_MS, process.env.DEVICE_EDGE_RELAY_SESSION_TIMEOUT_MS,
10000, 10000,
@@ -38,6 +38,9 @@ test("single-NIC ingress source has no host publication and a fixed ipvlan", asy
"subnet: 192.168.68.0/22", "subnet: 192.168.68.0/22",
"gateway: 192.168.68.1", "gateway: 192.168.68.1",
"gw_priority: 100", "gw_priority: 100",
"DEVICE_EDGE_RELAY_SOURCE_POLICY: public-ipv4-only",
'DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES: "2048"',
'DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION: "262144"',
]) { ]) {
assert.ok(ingress.includes(required), `missing ingress boundary: ${required}`); assert.ok(ingress.includes(required), `missing ingress boundary: ${required}`);
} }
@@ -82,3 +85,26 @@ test("ingress descriptor keeps address approval and router exposure gated", asyn
assert.equal(descriptor.amneziaHostFullTunnel, "preserved"); assert.equal(descriptor.amneziaHostFullTunnel, "preserved");
assert.equal(descriptor.routerNatFirewall, "separate-manual-gate"); assert.equal(descriptor.routerNatFirewall, "separate-manual-gate");
}); });
test("admission-gate descriptor pins the fail-closed relay boundary", async () => {
const descriptor = JSON.parse(await readFile(
resolve(
devicePlaneRoot,
"deployment/device-edge-admission-gate-v1.json",
),
"utf8",
));
assert.equal(
descriptor.schemaVersion,
"nodedc.device-edge.admission-gate.v1",
);
assert.equal(descriptor.sourceAdmission, "public-ipv4-only");
assert.equal(descriptor.maxTrackedSourceAddresses, 2048);
assert.equal(descriptor.maxBytesPerDirection, 262144);
assert.equal(descriptor.hostPortPublication, "disabled");
assert.equal(descriptor.healthPublication, "disabled");
assert.equal(descriptor.commandTransport, "disabled");
assert.equal(descriptor.gelios, "untouched");
assert.equal(descriptor.routerNatFirewall, "separate-manual-gate");
});
@@ -31,6 +31,7 @@ test("relay is transparent and never emits its own protocol bytes", async () =>
tcpPort: 0, tcpPort: 0,
upstreamHost: "127.0.0.1", upstreamHost: "127.0.0.1",
upstreamPort: upstream.port, upstreamPort: upstream.port,
resolveRemoteAddress: () => "8.8.8.8",
}); });
const addresses = await runtime.start(); const addresses = await runtime.start();
try { try {
@@ -47,6 +48,73 @@ test("relay is transparent and never emits its own protocol bytes", async () =>
} }
}); });
test("enabled ingress rejects a non-public source before opening upstream", 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,
resolveRemoteAddress: () => "127.0.0.1",
});
const addresses = await runtime.start();
try {
const response = await sendAndCollect(
addresses.tcpAddress.port,
Buffer.from("denied"),
);
assert.equal(response.length, 0);
assert.equal(runtime.status().totalAccepted, 0);
assert.equal(runtime.status().totalForwarded, 0);
assert.equal(runtime.status().sourceAdmission, "public-ipv4-only");
} finally {
await runtime.stop();
await closeServer(upstream.server);
}
});
test("relay terminates a byte stream that exceeds its per-direction budget", 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,
resolveRemoteAddress: () => "8.8.8.8",
maxBytesPerDirection: 1024,
});
const addresses = await runtime.start();
try {
const response = await sendAndCollect(
addresses.tcpAddress.port,
Buffer.alloc(1025, 0x5d),
);
assert.ok(response.length <= 1024);
assert.equal(runtime.status().totalForwarded, 1);
assert.ok(runtime.status().totalRejected >= 1);
} finally {
await runtime.stop();
await closeServer(upstream.server);
}
});
test("production ingress cannot opt out of public IPv4 admission", () => {
assert.throws(
() => createDeviceEdgeRelayRuntime({
ingressEnabled: true,
tcpHost: "0.0.0.0",
upstreamHost: "device-edge-backhaul",
upstreamPort: 19921,
sourcePolicy: "any",
}),
/device_edge_relay_ingress_source_policy_invalid/,
);
});
test("enabled relay requires a concrete private upstream", () => { test("enabled relay requires a concrete private upstream", () => {
assert.throws( assert.throws(
() => createDeviceEdgeRelayRuntime({ () => createDeviceEdgeRelayRuntime({
@@ -77,14 +145,14 @@ function startEchoServer() {
} }
function sendAndCollect(port, payload) { function sendAndCollect(port, payload) {
return new Promise((resolve, reject) => { return new Promise((resolve) => {
const chunks = []; const chunks = [];
const socket = connect({ host: "127.0.0.1", port }, () => { const socket = connect({ host: "127.0.0.1", port }, () => {
socket.end(payload); socket.end(payload);
}); });
socket.on("data", (chunk) => chunks.push(chunk)); socket.on("data", (chunk) => chunks.push(chunk));
socket.on("close", () => resolve(Buffer.concat(chunks))); socket.on("close", () => resolve(Buffer.concat(chunks)));
socket.on("error", reject); socket.on("error", () => {});
}); });
} }
@@ -23,7 +23,7 @@ const artifactDir = resolve(
|| resolve(scriptDir, "../deploy-artifacts"), || resolve(scriptDir, "../deploy-artifacts"),
); );
const [ const [
patchId = "device-edge-ingress-ipvlan-20260804-001", patchId = "device-edge-admission-gate-20260804-002",
...extra ...extra
] = process.argv.slice(2); ] = process.argv.slice(2);
@@ -38,7 +38,7 @@ const files = [
"docker-compose.device-edge.ingress.yml", "docker-compose.device-edge.ingress.yml",
"services/device-edge-relay/Dockerfile", "services/device-edge-relay/Dockerfile",
"services/device-edge-relay/src", "services/device-edge-relay/src",
"deployment/device-edge-ingress-ipvlan-v1.json", "deployment/device-edge-admission-gate-v1.json",
]; ];
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]); const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
const descriptor = await assertBoundary(); const descriptor = await assertBoundary();
@@ -84,7 +84,7 @@ try {
artifact: target, artifact: target,
sha256: digest, sha256: digest,
component: "device-edge", component: "device-edge",
transition: "single-nic-ipvlan-b2-relay-only", transition: "reviewed-ipvlan-b2-relay-admission-gate",
entries: files, entries: files,
services: ["device-edge-relay"], services: ["device-edge-relay"],
preservedRuntime: ["device-edge-backhaul", "tailnet", "Gelios"], preservedRuntime: ["device-edge-backhaul", "tailnet", "Gelios"],
@@ -96,6 +96,9 @@ try {
ipv4Approval: descriptor.ingressIpv4Approval, ipv4Approval: descriptor.ingressIpv4Approval,
tcp: 9921, tcp: 9921,
hostPortPublication: "disabled", hostPortPublication: "disabled",
sourceAdmission: descriptor.sourceAdmission,
maxTrackedSourceAddresses: descriptor.maxTrackedSourceAddresses,
maxBytesPerDirection: descriptor.maxBytesPerDirection,
lifecycle: "quarantine", lifecycle: "quarantine",
commandTransport: "disabled", commandTransport: "disabled",
}, },
@@ -117,7 +120,7 @@ async function assertBoundary() {
const descriptor = JSON.parse(await readFile( const descriptor = JSON.parse(await readFile(
resolve( resolve(
sourceRoot, sourceRoot,
"deployment/device-edge-ingress-ipvlan-v1.json", "deployment/device-edge-admission-gate-v1.json",
), ),
"utf8", "utf8",
)); ));
@@ -145,6 +148,9 @@ async function assertBoundary() {
'DEVICE_EDGE_RELAY_INGRESS_ENABLED: "true"', 'DEVICE_EDGE_RELAY_INGRESS_ENABLED: "true"',
"DEVICE_EDGE_RELAY_UPSTREAM_HOST: device-edge-backhaul", "DEVICE_EDGE_RELAY_UPSTREAM_HOST: device-edge-backhaul",
'DEVICE_EDGE_RELAY_UPSTREAM_PORT: "19921"', 'DEVICE_EDGE_RELAY_UPSTREAM_PORT: "19921"',
"DEVICE_EDGE_RELAY_SOURCE_POLICY: public-ipv4-only",
'DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES: "2048"',
'DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION: "262144"',
"name: nodedc-device-edge-ingress", "name: nodedc-device-edge-ingress",
"driver: ipvlan", "driver: ipvlan",
"parent: enp1s0f0", "parent: enp1s0f0",
@@ -171,7 +177,7 @@ async function assertBoundary() {
} }
const expected = { const expected = {
schemaVersion: "nodedc.device-edge.ingress-ipvlan.v1", schemaVersion: "nodedc.device-edge.admission-gate.v1",
mode: "single-nic-ipvlan-b2-relay-only", mode: "single-nic-ipvlan-b2-relay-only",
runtimeHost: "ndcmini12", runtimeHost: "ndcmini12",
component: "device-edge", component: "device-edge",
@@ -192,6 +198,9 @@ async function assertBoundary() {
hostPortPublication: "disabled", hostPortPublication: "disabled",
healthPublication: "disabled", healthPublication: "disabled",
privateUpstream: "device-edge-backhaul:19921", privateUpstream: "device-edge-backhaul:19921",
sourceAdmission: "public-ipv4-only",
maxTrackedSourceAddresses: 2048,
maxBytesPerDirection: 262144,
protocolInspection: "gateway-owned", protocolInspection: "gateway-owned",
identityTrust: "claimed-not-ownership-proof", identityTrust: "claimed-not-ownership-proof",
discoveryLifecycle: "quarantine", discoveryLifecycle: "quarantine",
@@ -199,7 +208,7 @@ async function assertBoundary() {
gelios: "untouched", gelios: "untouched",
amneziaHostFullTunnel: "preserved", amneziaHostFullTunnel: "preserved",
routerNatFirewall: "separate-manual-gate", routerNatFirewall: "separate-manual-gate",
rollback: "restore-predecessor-relay-remove-unused-ingress-network", rollback: "restore-reviewed-ipvlan-predecessor-without-network-or-router-mutation",
}; };
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) { if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
throw new Error("device_edge_ingress_descriptor_mismatch"); throw new Error("device_edge_ingress_descriptor_mismatch");
+47 -33
View File
@@ -60,10 +60,25 @@ ENTRIES = (
"docker-compose.device-edge.ingress.yml", "docker-compose.device-edge.ingress.yml",
"services/device-edge-relay/Dockerfile", "services/device-edge-relay/Dockerfile",
"services/device-edge-relay/src", "services/device-edge-relay/src",
"deployment/device-edge-ingress-ipvlan-v1.json", "deployment/device-edge-admission-gate-v1.json",
) )
PAYLOAD_FILE_SHA256 = { PAYLOAD_FILE_SHA256 = {
"docker-compose.device-edge.yml":
"666945ffd9512355e610ecd36a9df96936477315150555def93e0243e8ff1e22",
"docker-compose.device-edge.ingress.yml":
"11bedfd7fdea749ca1bdb3b35b9c136c86b330f51a9001f0b38c4618f6f96108",
"services/device-edge-relay/Dockerfile":
"f2f15b7618ac2ab3a1d4dd40041d06d1e9a695edcfc168d8835f9560b4897e70",
"services/device-edge-relay/src/runtime.mjs":
"21e83678980aa61127bf9f3d77982dd485c4aaae208c43818db7bb1cc150b83a",
"services/device-edge-relay/src/server.mjs":
"1b99ec944f1d3fbadded045b159f08624e2829620cec39a97f6b4b8cdcd2be22",
"deployment/device-edge-admission-gate-v1.json":
"e6c1f21ff297b451c42b6746bc2063484874435dfa9f1614410a7cbe84f0ce6f",
}
PREDECESSOR_FILE_SHA256 = {
"docker-compose.device-edge.yml": "docker-compose.device-edge.yml":
"7f13c11d6d4d541964053c0a8cf791e401947d34c42e0f7c26f9f9df26fa00b5", "7f13c11d6d4d541964053c0a8cf791e401947d34c42e0f7c26f9f9df26fa00b5",
"docker-compose.device-edge.ingress.yml": "docker-compose.device-edge.ingress.yml":
@@ -78,20 +93,8 @@ PAYLOAD_FILE_SHA256 = {
"b9ce402db0c059a76f07a8d4a34297aff2250fd1c0d1aed9970b3a88f4e75d7f", "b9ce402db0c059a76f07a8d4a34297aff2250fd1c0d1aed9970b3a88f4e75d7f",
} }
PREDECESSOR_FILE_SHA256 = {
"docker-compose.device-edge.yml":
"50dc7366d7db451935dd4d67f76ec8b342b3a94be6d22176976db910981445d0",
"services/device-edge-relay/Dockerfile":
"f2f15b7618ac2ab3a1d4dd40041d06d1e9a695edcfc168d8835f9560b4897e70",
"services/device-edge-relay/src/runtime.mjs":
"ae8bf8b55603bab266b6fa6e9bc65c9f310a9d94a54db04e2130704e38622ffc",
"services/device-edge-relay/src/server.mjs":
"e4b051b74f934bd37322440e6a013fb6774a76607da08f9cc1e844fc109c83c1",
}
PREDECESSOR_ABSENT = { PREDECESSOR_ABSENT = {
"docker-compose.device-edge.ingress.yml", "deployment/device-edge-admission-gate-v1.json",
"deployment/device-edge-ingress-ipvlan-v1.json",
} }
@@ -136,7 +139,7 @@ def docker_json(*args):
def expected_descriptor(): def expected_descriptor():
return { return {
"schemaVersion": "nodedc.device-edge.ingress-ipvlan.v1", "schemaVersion": "nodedc.device-edge.admission-gate.v1",
"mode": "single-nic-ipvlan-b2-relay-only", "mode": "single-nic-ipvlan-b2-relay-only",
"runtimeHost": "ndcmini12", "runtimeHost": "ndcmini12",
"component": COMPONENT, "component": COMPONENT,
@@ -157,6 +160,9 @@ def expected_descriptor():
"hostPortPublication": "disabled", "hostPortPublication": "disabled",
"healthPublication": "disabled", "healthPublication": "disabled",
"privateUpstream": "device-edge-backhaul:19921", "privateUpstream": "device-edge-backhaul:19921",
"sourceAdmission": "public-ipv4-only",
"maxTrackedSourceAddresses": 2048,
"maxBytesPerDirection": 262144,
"protocolInspection": "gateway-owned", "protocolInspection": "gateway-owned",
"identityTrust": "claimed-not-ownership-proof", "identityTrust": "claimed-not-ownership-proof",
"discoveryLifecycle": "quarantine", "discoveryLifecycle": "quarantine",
@@ -164,7 +170,7 @@ def expected_descriptor():
"gelios": "untouched", "gelios": "untouched",
"amneziaHostFullTunnel": "preserved", "amneziaHostFullTunnel": "preserved",
"routerNatFirewall": "separate-manual-gate", "routerNatFirewall": "separate-manual-gate",
"rollback": "restore-predecessor-relay-remove-unused-ingress-network", "rollback": "restore-reviewed-ipvlan-predecessor-without-network-or-router-mutation",
} }
@@ -268,7 +274,7 @@ def validate_payload(payload: Path):
if actual_files != PAYLOAD_FILE_SHA256: if actual_files != PAYLOAD_FILE_SHA256:
die("Device Edge artifact payload digest set mismatch") die("Device Edge artifact payload digest set mismatch")
descriptor = json.loads( descriptor = json.loads(
(payload / "deployment/device-edge-ingress-ipvlan-v1.json") (payload / "deployment/device-edge-admission-gate-v1.json")
.read_text(encoding="utf-8") .read_text(encoding="utf-8")
) )
if descriptor != expected_descriptor(): if descriptor != expected_descriptor():
@@ -352,18 +358,24 @@ def assert_preserved_runtime(snapshot):
def validate_predecessor_runtime(): def validate_predecessor_runtime():
relay = inspect_container(RELAY_CONTAINER) relay = inspect_container(RELAY_CONTAINER)
if relay.get("State", {}).get("Status") != "running": if relay.get("State", {}).get("Status") != "running":
die("Device Edge predecessor relay is not running") die("Device Edge IPvlan predecessor relay is not running")
if container_health(relay) != "healthy": if container_health(relay) != "healthy":
die("Device Edge predecessor relay is not healthy") die("Device Edge IPvlan predecessor relay is not healthy")
environment = set(relay.get("Config", {}).get("Env") or []) environment = set(relay.get("Config", {}).get("Env") or [])
if "DEVICE_EDGE_RELAY_INGRESS_ENABLED=false" not in environment: required = {
die("Device Edge predecessor ingress is not disabled") "DEVICE_EDGE_RELAY_HEALTH_HOST=127.0.0.1",
"DEVICE_EDGE_RELAY_INGRESS_ENABLED=true",
"DEVICE_EDGE_RELAY_TCP_HOST=0.0.0.0",
"DEVICE_EDGE_RELAY_TCP_PORT=9921",
"DEVICE_EDGE_RELAY_UPSTREAM_HOST=device-edge-backhaul",
"DEVICE_EDGE_RELAY_UPSTREAM_PORT=19921",
}
if not required.issubset(environment):
die("Device Edge IPvlan predecessor environment mismatch")
bindings = relay.get("HostConfig", {}).get("PortBindings") or {} bindings = relay.get("HostConfig", {}).get("PortBindings") or {}
expected = {"18221/tcp": [{"HostIp": "127.0.0.1", "HostPort": "18221"}]} if bindings not in ({}, None):
if bindings != expected: die("Device Edge IPvlan predecessor host publication mismatch")
die("Device Edge predecessor host publication mismatch") validate_network_runtime(relay)
if INGRESS_NETWORK in (relay.get("NetworkSettings", {}).get("Networks") or {}):
die("Device Edge predecessor unexpectedly uses ingress network")
def validate_host_network_boundary(): def validate_host_network_boundary():
@@ -435,10 +447,6 @@ def preflight(manifest, artifact_sha256):
validate_predecessor_runtime() validate_predecessor_runtime()
preserved = preserved_runtime_snapshot() preserved = preserved_runtime_snapshot()
validate_host_network_boundary() validate_host_network_boundary()
if arp_duplicate_detected(INGRESS_IPV4, INGRESS_PARENT):
die("Device Edge ingress IPv4 duplicate detected")
if run([DOCKER, "network", "inspect", INGRESS_NETWORK], check=False).returncode == 0:
die("Device Edge ingress network already exists")
return preserved return preserved
@@ -626,6 +634,9 @@ def validate_relay_runtime(preserved):
"DEVICE_EDGE_RELAY_TCP_PORT=9921", "DEVICE_EDGE_RELAY_TCP_PORT=9921",
"DEVICE_EDGE_RELAY_UPSTREAM_HOST=device-edge-backhaul", "DEVICE_EDGE_RELAY_UPSTREAM_HOST=device-edge-backhaul",
"DEVICE_EDGE_RELAY_UPSTREAM_PORT=19921", "DEVICE_EDGE_RELAY_UPSTREAM_PORT=19921",
"DEVICE_EDGE_RELAY_SOURCE_POLICY=public-ipv4-only",
"DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES=2048",
"DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION=262144",
} }
if not required.issubset(environment): if not required.issubset(environment):
die("Device Edge relay environment mismatch") die("Device Edge relay environment mismatch")
@@ -648,6 +659,7 @@ def validate_relay_runtime(preserved):
"ingress": "relay-only", "ingress": "relay-only",
"protocolInspection": "disabled", "protocolInspection": "disabled",
"commandTransport": "disabled", "commandTransport": "disabled",
"sourceAdmission": "public-ipv4-only",
} }
for key, expected in expected_health.items(): for key, expected in expected_health.items():
if health.get(key) != expected: if health.get(key) != expected:
@@ -688,10 +700,8 @@ def rollback(backup: Path, preserved):
"--pull", "--pull",
"never", "never",
RELAY_SERVICE, RELAY_SERVICE,
baseline=True,
), cwd=LIVE_ROOT, timeout=300, capture=False) ), cwd=LIVE_ROOT, timeout=300, capture=False)
wait_healthy(RELAY_CONTAINER) wait_healthy(RELAY_CONTAINER)
run([DOCKER, "network", "rm", INGRESS_NETWORK], check=False)
current_source_state() current_source_state()
validate_predecessor_runtime() validate_predecessor_runtime()
assert_preserved_runtime(preserved) assert_preserved_runtime(preserved)
@@ -725,12 +735,15 @@ def plan_artifact(artifact_argument: str):
print("device_edge_host_port_publication=disabled") print("device_edge_host_port_publication=disabled")
print("device_edge_health_publication=disabled") print("device_edge_health_publication=disabled")
print("device_edge_private_upstream=device-edge-backhaul:19921") print("device_edge_private_upstream=device-edge-backhaul:19921")
print("device_edge_source_admission=public-ipv4-only")
print("device_edge_source_table_limit=2048")
print("device_edge_byte_limit_per_direction=262144")
print("device_edge_command_transport=disabled") print("device_edge_command_transport=disabled")
print("device_edge_discovery_lifecycle=quarantine") print("device_edge_discovery_lifecycle=quarantine")
print("device_edge_gelios=untouched") print("device_edge_gelios=untouched")
print("device_edge_amnezia=preserved:active:host-full-tunnel") print("device_edge_amnezia=preserved:active:host-full-tunnel")
print("device_edge_router_nat_firewall=unchanged") print("device_edge_router_nat_firewall=unchanged")
print("device_edge_rollback=restore-predecessor-relay-remove-unused-ingress-network") print("device_edge_rollback=restore-reviewed-ipvlan-predecessor-no-router-mutation")
print("state=new") print("state=new")
print("== files ==") print("== files ==")
for entry in entries: for entry in entries:
@@ -826,6 +839,7 @@ def verify_install():
print(f"compose={compose_version}") print(f"compose={compose_version}")
print(f"device_edge_ingress_ipv4={INGRESS_IPV4}") print(f"device_edge_ingress_ipv4={INGRESS_IPV4}")
print(f"device_edge_ingress_ipv4_approval={INGRESS_IPV4_APPROVAL}") print(f"device_edge_ingress_ipv4_approval={INGRESS_IPV4_APPROVAL}")
print("device_edge_source_admission=public-ipv4-only")
print("verify-install-ok") print("verify-install-ok")
@@ -109,7 +109,7 @@ class DeviceEdgeIngressArtifactTest(unittest.TestCase):
) as directory: ) as directory:
result = self.build( result = self.build(
Path(directory), Path(directory),
"device-edge-ingress-ipvlan-20260804-001", "device-edge-admission-gate-20260804-002",
) )
self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.returncode, 0, result.stderr)
built = json.loads(result.stdout) built = json.loads(result.stdout)
@@ -128,7 +128,7 @@ class DeviceEdgeIngressArtifactTest(unittest.TestCase):
inbox.mkdir() inbox.mkdir()
result = self.build( result = self.build(
inbox, inbox,
"device-edge-ingress-ipvlan-20260804-002", "device-edge-admission-gate-20260804-003",
) )
self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.returncode, 0, result.stderr)
artifact = Path(json.loads(result.stdout)["artifact"]) artifact = Path(json.loads(result.stdout)["artifact"])
@@ -150,7 +150,7 @@ class DeviceEdgeIngressArtifactTest(unittest.TestCase):
self.assertEqual(digest, hashlib.sha256(artifact.read_bytes()).hexdigest()) self.assertEqual(digest, hashlib.sha256(artifact.read_bytes()).hexdigest())
self.assertEqual( self.assertEqual(
json.loads( json.loads(
(payload / "deployment/device-edge-ingress-ipvlan-v1.json") (payload / "deployment/device-edge-admission-gate-v1.json")
.read_text(encoding="utf-8") .read_text(encoding="utf-8")
), ),
RUNNER.expected_descriptor(), RUNNER.expected_descriptor(),