feat(device-edge): add fail-closed ingress admission
This commit is contained in:
@@ -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: {}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user