diff --git a/device-plane/deployment/device-edge-admission-gate-v1.json b/device-plane/deployment/device-edge-admission-gate-v1.json new file mode 100644 index 0000000..2b728e4 --- /dev/null +++ b/device-plane/deployment/device-edge-admission-gate-v1.json @@ -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" +} diff --git a/device-plane/docker-compose.device-edge.ingress.yml b/device-plane/docker-compose.device-edge.ingress.yml index 39512a7..a3d4006 100644 --- a/device-plane/docker-compose.device-edge.ingress.yml +++ b/device-plane/docker-compose.device-edge.ingress.yml @@ -9,6 +9,9 @@ services: DEVICE_EDGE_RELAY_MAX_SESSIONS: "100" DEVICE_EDGE_RELAY_MAX_SESSIONS_PER_ADDRESS: "10" 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" networks: device-edge-private: diff --git a/device-plane/docker-compose.device-edge.yml b/device-plane/docker-compose.device-edge.yml index a5875cf..95da66f 100644 --- a/device-plane/docker-compose.device-edge.yml +++ b/device-plane/docker-compose.device-edge.yml @@ -18,6 +18,8 @@ services: DEVICE_EDGE_RELAY_MAX_SESSIONS: "100" DEVICE_EDGE_RELAY_MAX_SESSIONS_PER_ADDRESS: "10" 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" networks: device-edge-private: {} diff --git a/device-plane/docs/IMPLEMENTATION_BASELINE.md b/device-plane/docs/IMPLEMENTATION_BASELINE.md index a08e9ad..b277a4c 100644 --- a/device-plane/docs/IMPLEMENTATION_BASELINE.md +++ b/device-plane/docs/IMPLEMENTATION_BASELINE.md @@ -108,9 +108,10 @@ does not build, restart, recreate or remove any service. ## Network boundary -The accepted foundation publishes no device port. The reviewed pilot transition -publishes `9921/TCP` from Device Gateway after a port-collision check and keeps -health endpoints loopback-only. +The accepted Synology foundation publishes no device port. Device Gateway's +raw B2 listener is reachable only through `127.0.0.1:9921`; its health +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 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 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 -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 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, host-network mode, privileged container or VPN teardown is allowed. -Enabling ingress is a separate reviewed operation and requires all of the -following evidence: +Enabling public ingress is a separate reviewed operation and requires all of +the following evidence: 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 @@ -147,9 +160,10 @@ following evidence: 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 Synology. -3. Router evidence proving the fixed relay IPv4 is outside DHCP, followed by a - manual router/NAT rule mapping only TCP `9921` to that IPv4. The artifact +3. Router evidence proving the fixed relay IPv4 is outside DHCP. The artifact 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 exact single-NIC IPvlan design must pass duplicate-address detection, 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 root-owned Edge runner and review its `plan`. The Synology runner and inbox are not used for this host. -3. Apply only `device-edge-relay`; prove exact IPvlan runtime, no host ports, - internal health, private backhaul reachability, unchanged backhaul/tailnet - identities and preserved Amnezia routes. Automatic rollback restores the - predecessor relay and removes the unused ingress network. -4. Independently add the single router/NAT rule for TCP `9921` only and verify - that Synology still exposes no public device port. +3. Apply the admission-gate update only to `device-edge-relay`; prove exact + IPvlan runtime, no host ports, `public-ipv4-only` admission, byte/source + limits, internal health, private backhaul reachability, unchanged + backhaul/tailnet identities and preserved Amnezia routes. Automatic rollback + restores the reviewed IPvlan predecessor and leaves router state unchanged. +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 Gelios, then prove HEADER/discovery/PACKAGE acknowledgement. Claim and tenant assignment remain a later explicit platform-admin operation. diff --git a/device-plane/services/device-edge-relay/src/runtime.mjs b/device-plane/services/device-edge-relay/src/runtime.mjs index bd33dae..20ca8da 100644 --- a/device-plane/services/device-edge-relay/src/runtime.mjs +++ b/device-plane/services/device-edge-relay/src/runtime.mjs @@ -11,9 +11,10 @@ export function createDeviceEdgeRelayRuntime(options = {}) { let totalForwarded = 0; const tcpServer = createTcpServer({ allowHalfOpen: true }, (socket) => { - const remoteAddress = normalizeRemoteAddress(socket.remoteAddress); + const remoteAddress = config.resolveRemoteAddress(socket.remoteAddress); if ( - sessions.size >= config.maxConcurrentSessions + !allowsSource(remoteAddress) + || sessions.size >= config.maxConcurrentSessions || currentAddressSessions(remoteAddress) >= config.maxSessionsPerAddress || !consumeConnectionPermit(remoteAddress) ) { @@ -28,6 +29,8 @@ export function createDeviceEdgeRelayRuntime(options = {}) { upstream: null, closed: false, forwarded: false, + inboundBytes: 0, + outboundBytes: 0, }; sessions.set(socket, session); incrementAddressSessions(remoteAddress); @@ -39,6 +42,12 @@ export function createDeviceEdgeRelayRuntime(options = {}) { socket.on("timeout", () => rejectSession(session)); socket.on("close", () => closeSession(session)); socket.on("error", () => rejectSession(session)); + socket.on("data", (chunk) => { + session.inboundBytes += chunk.length; + if (session.inboundBytes > config.maxBytesPerDirection) { + rejectSession(session); + } + }); const upstream = connect({ host: config.upstreamHost, @@ -61,6 +70,12 @@ export function createDeviceEdgeRelayRuntime(options = {}) { upstream.on("timeout", () => rejectSession(session)); upstream.on("error", () => rejectSession(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) => { @@ -79,6 +94,7 @@ export function createDeviceEdgeRelayRuntime(options = {}) { ingress: config.ingressEnabled ? "relay-only" : "disabled", protocolInspection: "disabled", commandTransport: "disabled", + sourceAdmission: config.sourcePolicy, sessions: { active: sessions.size, accepted: totalAccepted, @@ -115,6 +131,7 @@ export function createDeviceEdgeRelayRuntime(options = {}) { ingress: config.ingressEnabled ? "relay-only" : "disabled", protocolInspection: "disabled", commandTransport: "disabled", + sourceAdmission: config.sourcePolicy, }; }, }; @@ -141,8 +158,16 @@ export function createDeviceEdgeRelayRuntime(options = {}) { function consumeConnectionPermit(remoteAddress) { 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); if (!current || nowMs - current.startedAt >= 60_000) { + if (connectionWindows.size >= config.maxTrackedSourceAddresses) { + return false; + } connectionWindows.set(remoteAddress, { startedAt: nowMs, count: 1 }); return true; } @@ -151,6 +176,11 @@ export function createDeviceEdgeRelayRuntime(options = {}) { return true; } + function allowsSource(remoteAddress) { + if (config.sourcePolicy === "any") return true; + return isPublicIpv4Address(remoteAddress); + } + function rejectSession(session) { if (!session.closed) totalRejected += 1; session.socket.destroy(); @@ -219,6 +249,24 @@ function normalizeConfig(input) { 10000, "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( input.sessionTimeoutMs, 10000, @@ -269,6 +317,48 @@ function normalizeRemoteAddress(value) { 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) { const parsed = Number(value ?? fallback); if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) { diff --git a/device-plane/services/device-edge-relay/src/server.mjs b/device-plane/services/device-edge-relay/src/server.mjs index 3a00a0a..8ca8429 100644 --- a/device-plane/services/device-edge-relay/src/server.mjs +++ b/device-plane/services/device-edge-relay/src/server.mjs @@ -26,6 +26,15 @@ const runtime = createDeviceEdgeRelayRuntime({ process.env.DEVICE_EDGE_RELAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS, 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( process.env.DEVICE_EDGE_RELAY_SESSION_TIMEOUT_MS, 10000, diff --git a/device-plane/services/device-edge-relay/test/ingress-source.test.mjs b/device-plane/services/device-edge-relay/test/ingress-source.test.mjs index e5e68b7..128cda5 100644 --- a/device-plane/services/device-edge-relay/test/ingress-source.test.mjs +++ b/device-plane/services/device-edge-relay/test/ingress-source.test.mjs @@ -38,6 +38,9 @@ test("single-NIC ingress source has no host publication and a fixed ipvlan", asy "subnet: 192.168.68.0/22", "gateway: 192.168.68.1", "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}`); } @@ -82,3 +85,26 @@ test("ingress descriptor keeps address approval and router exposure gated", asyn assert.equal(descriptor.amneziaHostFullTunnel, "preserved"); 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"); +}); diff --git a/device-plane/services/device-edge-relay/test/runtime.test.mjs b/device-plane/services/device-edge-relay/test/runtime.test.mjs index 0b36acc..28ffd40 100644 --- a/device-plane/services/device-edge-relay/test/runtime.test.mjs +++ b/device-plane/services/device-edge-relay/test/runtime.test.mjs @@ -31,6 +31,7 @@ test("relay is transparent and never emits its own protocol bytes", async () => tcpPort: 0, upstreamHost: "127.0.0.1", upstreamPort: upstream.port, + resolveRemoteAddress: () => "8.8.8.8", }); const addresses = await runtime.start(); 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", () => { assert.throws( () => createDeviceEdgeRelayRuntime({ @@ -77,14 +145,14 @@ function startEchoServer() { } function sendAndCollect(port, payload) { - return new Promise((resolve, reject) => { + return new Promise((resolve) => { 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); + socket.on("error", () => {}); }); } diff --git a/infra/deploy-runner/build-device-edge-ingress-artifact.mjs b/infra/deploy-runner/build-device-edge-ingress-artifact.mjs index 34da0ae..68827b3 100644 --- a/infra/deploy-runner/build-device-edge-ingress-artifact.mjs +++ b/infra/deploy-runner/build-device-edge-ingress-artifact.mjs @@ -23,7 +23,7 @@ const artifactDir = resolve( || resolve(scriptDir, "../deploy-artifacts"), ); const [ - patchId = "device-edge-ingress-ipvlan-20260804-001", + patchId = "device-edge-admission-gate-20260804-002", ...extra ] = process.argv.slice(2); @@ -38,7 +38,7 @@ const files = [ "docker-compose.device-edge.ingress.yml", "services/device-edge-relay/Dockerfile", "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 descriptor = await assertBoundary(); @@ -84,7 +84,7 @@ try { artifact: target, sha256: digest, component: "device-edge", - transition: "single-nic-ipvlan-b2-relay-only", + transition: "reviewed-ipvlan-b2-relay-admission-gate", entries: files, services: ["device-edge-relay"], preservedRuntime: ["device-edge-backhaul", "tailnet", "Gelios"], @@ -96,6 +96,9 @@ try { ipv4Approval: descriptor.ingressIpv4Approval, tcp: 9921, hostPortPublication: "disabled", + sourceAdmission: descriptor.sourceAdmission, + maxTrackedSourceAddresses: descriptor.maxTrackedSourceAddresses, + maxBytesPerDirection: descriptor.maxBytesPerDirection, lifecycle: "quarantine", commandTransport: "disabled", }, @@ -117,7 +120,7 @@ async function assertBoundary() { const descriptor = JSON.parse(await readFile( resolve( sourceRoot, - "deployment/device-edge-ingress-ipvlan-v1.json", + "deployment/device-edge-admission-gate-v1.json", ), "utf8", )); @@ -145,6 +148,9 @@ async function assertBoundary() { 'DEVICE_EDGE_RELAY_INGRESS_ENABLED: "true"', "DEVICE_EDGE_RELAY_UPSTREAM_HOST: device-edge-backhaul", '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", "driver: ipvlan", "parent: enp1s0f0", @@ -171,7 +177,7 @@ async function assertBoundary() { } const expected = { - schemaVersion: "nodedc.device-edge.ingress-ipvlan.v1", + schemaVersion: "nodedc.device-edge.admission-gate.v1", mode: "single-nic-ipvlan-b2-relay-only", runtimeHost: "ndcmini12", component: "device-edge", @@ -192,6 +198,9 @@ async function assertBoundary() { 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", @@ -199,7 +208,7 @@ async function assertBoundary() { gelios: "untouched", amneziaHostFullTunnel: "preserved", 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)) { throw new Error("device_edge_ingress_descriptor_mismatch"); diff --git a/infra/deploy-runner/nodedc-edge-deploy b/infra/deploy-runner/nodedc-edge-deploy index 4c42e67..74366e7 100644 --- a/infra/deploy-runner/nodedc-edge-deploy +++ b/infra/deploy-runner/nodedc-edge-deploy @@ -60,10 +60,25 @@ ENTRIES = ( "docker-compose.device-edge.ingress.yml", "services/device-edge-relay/Dockerfile", "services/device-edge-relay/src", - "deployment/device-edge-ingress-ipvlan-v1.json", + "deployment/device-edge-admission-gate-v1.json", ) 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": "7f13c11d6d4d541964053c0a8cf791e401947d34c42e0f7c26f9f9df26fa00b5", "docker-compose.device-edge.ingress.yml": @@ -78,20 +93,8 @@ PAYLOAD_FILE_SHA256 = { "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 = { - "docker-compose.device-edge.ingress.yml", - "deployment/device-edge-ingress-ipvlan-v1.json", + "deployment/device-edge-admission-gate-v1.json", } @@ -136,7 +139,7 @@ def docker_json(*args): def expected_descriptor(): return { - "schemaVersion": "nodedc.device-edge.ingress-ipvlan.v1", + "schemaVersion": "nodedc.device-edge.admission-gate.v1", "mode": "single-nic-ipvlan-b2-relay-only", "runtimeHost": "ndcmini12", "component": COMPONENT, @@ -157,6 +160,9 @@ def expected_descriptor(): "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", @@ -164,7 +170,7 @@ def expected_descriptor(): "gelios": "untouched", "amneziaHostFullTunnel": "preserved", "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: die("Device Edge artifact payload digest set mismatch") descriptor = json.loads( - (payload / "deployment/device-edge-ingress-ipvlan-v1.json") + (payload / "deployment/device-edge-admission-gate-v1.json") .read_text(encoding="utf-8") ) if descriptor != expected_descriptor(): @@ -352,18 +358,24 @@ def assert_preserved_runtime(snapshot): def validate_predecessor_runtime(): relay = inspect_container(RELAY_CONTAINER) 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": - 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 []) - if "DEVICE_EDGE_RELAY_INGRESS_ENABLED=false" not in environment: - die("Device Edge predecessor ingress is not disabled") + required = { + "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 {} - expected = {"18221/tcp": [{"HostIp": "127.0.0.1", "HostPort": "18221"}]} - if bindings != expected: - die("Device Edge predecessor host publication mismatch") - if INGRESS_NETWORK in (relay.get("NetworkSettings", {}).get("Networks") or {}): - die("Device Edge predecessor unexpectedly uses ingress network") + if bindings not in ({}, None): + die("Device Edge IPvlan predecessor host publication mismatch") + validate_network_runtime(relay) def validate_host_network_boundary(): @@ -435,10 +447,6 @@ def preflight(manifest, artifact_sha256): validate_predecessor_runtime() preserved = preserved_runtime_snapshot() 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 @@ -626,6 +634,9 @@ def validate_relay_runtime(preserved): "DEVICE_EDGE_RELAY_TCP_PORT=9921", "DEVICE_EDGE_RELAY_UPSTREAM_HOST=device-edge-backhaul", "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): die("Device Edge relay environment mismatch") @@ -648,6 +659,7 @@ def validate_relay_runtime(preserved): "ingress": "relay-only", "protocolInspection": "disabled", "commandTransport": "disabled", + "sourceAdmission": "public-ipv4-only", } for key, expected in expected_health.items(): if health.get(key) != expected: @@ -688,10 +700,8 @@ def rollback(backup: Path, preserved): "--pull", "never", RELAY_SERVICE, - baseline=True, ), cwd=LIVE_ROOT, timeout=300, capture=False) wait_healthy(RELAY_CONTAINER) - run([DOCKER, "network", "rm", INGRESS_NETWORK], check=False) current_source_state() validate_predecessor_runtime() assert_preserved_runtime(preserved) @@ -725,12 +735,15 @@ def plan_artifact(artifact_argument: str): print("device_edge_host_port_publication=disabled") print("device_edge_health_publication=disabled") 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_discovery_lifecycle=quarantine") print("device_edge_gelios=untouched") print("device_edge_amnezia=preserved:active:host-full-tunnel") 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("== files ==") for entry in entries: @@ -826,6 +839,7 @@ def verify_install(): print(f"compose={compose_version}") print(f"device_edge_ingress_ipv4={INGRESS_IPV4}") print(f"device_edge_ingress_ipv4_approval={INGRESS_IPV4_APPROVAL}") + print("device_edge_source_admission=public-ipv4-only") print("verify-install-ok") diff --git a/infra/deploy-runner/test_device_edge_ingress_artifact.py b/infra/deploy-runner/test_device_edge_ingress_artifact.py index f754487..b222f00 100644 --- a/infra/deploy-runner/test_device_edge_ingress_artifact.py +++ b/infra/deploy-runner/test_device_edge_ingress_artifact.py @@ -109,7 +109,7 @@ class DeviceEdgeIngressArtifactTest(unittest.TestCase): ) as directory: result = self.build( Path(directory), - "device-edge-ingress-ipvlan-20260804-001", + "device-edge-admission-gate-20260804-002", ) self.assertEqual(result.returncode, 0, result.stderr) built = json.loads(result.stdout) @@ -128,7 +128,7 @@ class DeviceEdgeIngressArtifactTest(unittest.TestCase): inbox.mkdir() result = self.build( inbox, - "device-edge-ingress-ipvlan-20260804-002", + "device-edge-admission-gate-20260804-003", ) self.assertEqual(result.returncode, 0, result.stderr) 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( json.loads( - (payload / "deployment/device-edge-ingress-ipvlan-v1.json") + (payload / "deployment/device-edge-admission-gate-v1.json") .read_text(encoding="utf-8") ), RUNNER.expected_descriptor(),