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

This commit is contained in:
Codex
2026-08-04 11:39:01 +03:00
parent b1a5a26b9a
commit 3bb5e6dc27
35 changed files with 5790 additions and 19 deletions
+2
View File
@@ -6,5 +6,7 @@ docs
node_modules
**/test
**/*.log
**/*.prev-*
**/*.next-*
runtime
secrets
+12
View File
@@ -28,6 +28,18 @@ The planned runtime services are:
- `device-gateway`: raw TCP sessions, bounded codecs and presence;
- `device-postgres`: private persistent state.
`services/device-edge-relay` is a separate, disabled-by-default runtime for the
dedicated Debian ingress host. It is an L4 byte relay: it neither decodes
ARUSNAVI B2, acknowledges a device nor holds a Core token, database credential
or command capability. The implemented backhaul forwards only through a
restricted outbound SSH path to the Synology Gateway loopback listener. The
reviewed ingress target gives only this container a fixed IPvlan L2 address on
the Mini's single Ethernet link, so the host Amnezia full tunnel stays intact
and Docker publishes no host port. The fixed relay address is
`192.168.71.253`, explicitly outside the documented Deco DHCP pool
`192.168.68.50``192.168.71.250`. See `docs/IMPLEMENTATION_BASELINE.md` for
the remaining activation gates.
The Foundry `Device Manager` is a canonical page template using a server-owned
`device-plane-control` binding. It is not a service in this directory.
@@ -0,0 +1,25 @@
{
"schemaVersion": "nodedc.device-edge.backhaul.v1",
"mode": "private-tailnet-ssh-local-forward",
"runtimeHost": "ndcmini12",
"selectedServices": [
"device-edge-backhaul"
],
"preservedServices": [
"device-edge-relay",
"tailnet"
],
"tailnetSocksTarget": "nodedc-device-edge-tailnet-1:1055",
"sshTarget": "100.109.216.21:2222",
"sshUser": "device-backhaul",
"localForward": "0.0.0.0:19921",
"permittedRemoteTarget": "127.0.0.1:9921",
"hostPortPublication": "disabled",
"deviceIngress": "disabled",
"protocolInspection": "disabled",
"commandTransport": "disabled",
"privateKey": "runtime-only-read-only",
"knownHosts": "runner-prepared-exact-ed25519",
"routerNatFirewall": "unchanged",
"gelios": "untouched"
}
@@ -0,0 +1,36 @@
{
"schemaVersion": "nodedc.device-edge.ingress-ipvlan.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",
"protocolInspection": "gateway-owned",
"identityTrust": "claimed-not-ownership-proof",
"discoveryLifecycle": "quarantine",
"commandTransport": "disabled",
"gelios": "untouched",
"amneziaHostFullTunnel": "preserved",
"routerNatFirewall": "separate-manual-gate",
"rollback": "restore-predecessor-relay-remove-unused-ingress-network"
}
@@ -0,0 +1,23 @@
{
"schemaVersion": "nodedc.device-plane.b2-discovery-loopback-recovery.v1",
"mode": "failed-b2-loopback-build-reconciliation",
"failedPatchId": "device-plane-b2-discovery-loopback-20260801-003",
"failedArtifactSha256": "7273c5bf67fe6bc1f1da66ad726009240d39ee3aee58201b96c23d6f707a3d84",
"failedBackupId": "device-plane-device-plane-b2-discovery-loopback-20260801-003-20260802-154311",
"sourceAction": "publish-reconciliation-marker-only",
"runtimeAction": "read-only-acceptance",
"preservedServices": [
"device-control-core",
"device-gateway",
"device-postgres"
],
"expectedLoopbackPorts": [
"127.0.0.1:18120:18120",
"127.0.0.1:18121:18121"
],
"closedPort": "127.0.0.1:9921/tcp",
"databaseVolume": "nodedc-device-plane-postgres-data",
"commandTransport": "disabled",
"gelios": "untouched",
"rollback": "marker-only-runtime-unchanged"
}
@@ -0,0 +1,36 @@
{
"schemaVersion": "nodedc.device-plane.backhaul-target-tailnet-serve.v1",
"mode": "failed-backhaul-target-to-loopback-tailnet-serve",
"failedPatchId": "device-plane-backhaul-target-20260803-001",
"failedArtifactSha256": "ed0bda4110a756c32be68990e2e0f647409d5a77eec7e26c18502bafbdc1bb76",
"failedBackupId": "device-plane-device-plane-backhaul-target-20260803-001-20260804-035519",
"predecessorPatchId": "device-plane-b2-discovery-loopback-20260803-006",
"predecessorArtifactSha256": "25f9e9e55e283e9b7bb5e128ff14a244f848b1c063acca9724a23206131c9adf",
"sourceAction": "publish-loopback-backhaul-target-source",
"runtimeAction": "build-create-target-and-register-private-tailnet-serve",
"composeOverlay": "docker-compose.device-plane.backhaul-target.yml",
"selectedServices": [
"device-backhaul-target"
],
"preservedServices": [
"device-control-core",
"device-gateway",
"device-postgres"
],
"loopbackListenAddress": "127.0.0.1",
"listenPort": 2222,
"tailnetAddress": "100.109.216.21",
"tailnetExposure": "tailscale-serve-private",
"tailscaleServeTarget": "tcp://127.0.0.1:2222",
"permittedTarget": "127.0.0.1:9921",
"networkMode": "host",
"dockerPortPublication": "disabled",
"routerNatFirewall": "unchanged",
"edgePublicIngress": "disabled",
"funnel": "disabled",
"commandTransport": "disabled",
"gelios": "untouched",
"databaseVolume": "nodedc-device-plane-postgres-data",
"runtimeTrust": "runner-managed",
"rollback": "remove-tailnet-serve-target-and-restore-source"
}
@@ -0,0 +1,82 @@
services:
device-edge-backhaul:
build:
context: .
dockerfile: services/device-edge-backhaul/Dockerfile
image: nodedc/device-edge-backhaul:local
pull_policy: never
restart: unless-stopped
user: "1000:1000"
read_only: true
tmpfs:
- /tmp:size=8m,mode=1777
volumes:
- ../secrets/backhaul/id_ed25519:/run/keys/edge-to-synology:ro
- ../trust/synology-backhaul-known_hosts:/run/trust/known_hosts:ro
command:
- -N
- -T
- -p
- "2222"
- -i
- /run/keys/edge-to-synology
- -L
- 0.0.0.0:19921:127.0.0.1:9921
- -o
- BatchMode=yes
- -o
- PasswordAuthentication=no
- -o
- KbdInteractiveAuthentication=no
- -o
- PubkeyAuthentication=yes
- -o
- IdentitiesOnly=yes
- -o
- StrictHostKeyChecking=yes
- -o
- UserKnownHostsFile=/run/trust/known_hosts
- -o
- UpdateHostKeys=no
- -o
- ExitOnForwardFailure=yes
- -o
- ServerAliveInterval=15
- -o
- ServerAliveCountMax=3
- -o
- TCPKeepAlive=yes
- -o
- LogLevel=VERBOSE
- -o
- ProxyCommand=nc -X 5 -x nodedc-device-edge-tailnet-1:1055 %h %p
- device-backhaul@100.109.216.21
networks:
- device-edge-private
- device-edge-tailnet
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
pids_limit: 32
healthcheck:
test:
- CMD
- nc
- -z
- -w
- "3"
- 127.0.0.1
- "19921"
interval: 10s
timeout: 5s
retries: 12
start_period: 10s
networks:
device-edge-private:
name: nodedc-device-edge-private
external: true
device-edge-tailnet:
name: nodedc-device-edge-tailnet
external: true
@@ -0,0 +1,32 @@
services:
device-edge-relay:
environment:
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"
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_SESSION_TIMEOUT_MS: "10000"
networks:
device-edge-private:
gw_priority: 0
device-edge-ingress:
ipv4_address: 192.168.71.253
gw_priority: 100
networks:
device-edge-private:
name: nodedc-device-edge-private
device-edge-ingress:
name: nodedc-device-edge-ingress
driver: ipvlan
driver_opts:
parent: enp1s0f0
ipvlan_mode: l2
ipam:
config:
- subnet: 192.168.68.0/22
gateway: 192.168.68.1
@@ -0,0 +1,42 @@
services:
device-edge-relay:
build:
context: .
dockerfile: services/device-edge-relay/Dockerfile
image: nodedc/device-edge-relay:local
pull_policy: never
restart: unless-stopped
user: "1000:1000"
read_only: true
tmpfs:
- /tmp:size=16m,mode=1777
environment:
DEVICE_EDGE_RELAY_HEALTH_HOST: 127.0.0.1
DEVICE_EDGE_RELAY_HEALTH_PORT: "18221"
DEVICE_EDGE_RELAY_INGRESS_ENABLED: "false"
DEVICE_EDGE_RELAY_TCP_PORT: "9921"
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_SESSION_TIMEOUT_MS: "10000"
networks:
device-edge-private: {}
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test:
- CMD
- node
- -e
- fetch('http://127.0.0.1:18221/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
interval: 10s
timeout: 5s
retries: 12
start_period: 10s
networks:
device-edge-private:
name: nodedc-device-edge-private
internal: true
@@ -0,0 +1,39 @@
services:
device-backhaul-target:
image: nodedc/device-backhaul-target:local
pull_policy: never
restart: unless-stopped
network_mode: host
read_only: true
tmpfs:
- /run:size=8m,mode=0755
- /tmp:size=8m,mode=1777
volumes:
- type: bind
source: /volume1/docker/nodedc-device-plane/secrets/backhaul-target/ssh_host_ed25519_key
target: /run/nodedc-secrets/ssh_host_ed25519_key
read_only: true
bind:
create_host_path: false
- type: bind
source: /volume1/docker/nodedc-device-plane/secrets/backhaul-target/authorized_keys
target: /run/nodedc-secrets/authorized_keys
read_only: true
bind:
create_host_path: false
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- DAC_OVERRIDE
- SETGID
- SETUID
- SYS_CHROOT
healthcheck:
test: ["CMD", "nc", "-z", "-w", "3", "127.0.0.1", "2222"]
interval: 10s
timeout: 5s
retries: 12
start_period: 10s
+74 -16
View File
@@ -27,6 +27,26 @@ Device Control Core <-> Device PostgreSQL
Device Gateway <-> physical devices
```
The isolated ingress placement replaces the direct physical-device arrow when
the raw route must not terminate on the multi-service Synology:
```text
ARUSNAVI B2 device
|
| raw TCP 9921 (future, separately approved)
v
Device Edge Relay on dedicated mini
|
| outbound restricted SSH local-forward; opaque byte stream only
v
Synology loopback 127.0.0.1:9921 -> Device Gateway -> Device Control Core
```
The Edge Relay owns neither protocol acknowledgement nor device identity. It
does not receive the Gateway/Core token, PostgreSQL credentials, Foundry
bindings or any command capability. The Synology Gateway remains the sole B2
codec and acknowledgement owner.
Engine L2 may consume safe decoded observations and build workflows/Data
Products. It does not own TCP sessions, secrets or the command transport.
@@ -50,7 +70,9 @@ platform/device-plane/
packages/arusnavi-b2-adapter/
services/device-control-core/
services/device-gateway/
services/device-edge-relay/
docker-compose.device-plane.yml
docker-compose.device-edge.yml
```
Planned Synology runtime:
@@ -99,6 +121,41 @@ as `443 -> 9921`.
The artifact never changes DSM firewall, DSM Router Configuration, DNS or a
physical router.
### Dedicated mini Device Edge
The Debian mini is the isolated raw-TCP edge. Its accepted predecessor keeps the
relay disabled and publishes health only on `127.0.0.1:18221`. The reviewed
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.
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
Amnezia `0.0.0.0/1` plus `128.0.0.0/1` routes. The relay has its own fixed LAN
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:
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
tunnel are required.
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
cannot choose an address and never changes router, firewall or DHCP state.
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.
5. One pre-authorized B2 pilot route, quarantine-only Gateway/Core ingest and
disabled command transport.
## Identity and onboarding
An IMEI is a claimed protocol identifier, not proof of tenant ownership.
@@ -185,20 +242,21 @@ raw TCP remain forbidden until separate reviewed acceptance slices.
absence preflight: an existing database container or volume fails closed,
and rollback never removes the volume.
## Next source slice
## Next activation slice
1. Add the versioned B2 discovery-ingress descriptor and runner acceptance
contract without weakening foundation/recovery acceptance.
2. Build a deterministic data-only app-overlay and promote the runner extension
through the standalone root administrative gate.
3. Plan/apply the exact discovery-ingress artifact and independently prove:
Core/Gateway/PostgreSQL health, PostgreSQL generation preservation,
`127.0.0.1:9921` loopback-only publication, verified framing,
quarantine-only Core ingest and disabled command transport.
4. Do not publish raw device TCP from the multi-service Synology. Design and
independently review an isolated edge/DMZ/VPS ingress with no lateral NAS
access, explicit firewall/NAT evidence, acceptance and rollback.
5. Only after that separate edge gate, 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.
1. The Deco DHCP range has been recorded as `192.168.68.50` through
`192.168.71.250`; the fixed Relay IPv4 is `192.168.71.253`, outside that
pool and independently DAD-tested. It is pinned in Compose, descriptor,
builder and the separate Edge runner.
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.
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.
+11
View File
@@ -23,6 +23,10 @@
"resolved": "services/device-control-core",
"link": true
},
"node_modules/@nodedc/device-edge-relay": {
"resolved": "services/device-edge-relay",
"link": true
},
"node_modules/@nodedc/device-gateway": {
"resolved": "services/device-gateway",
"link": true
@@ -201,6 +205,13 @@
"node": ">=20"
}
},
"services/device-edge-relay": {
"name": "@nodedc/device-edge-relay",
"version": "0.1.0",
"engines": {
"node": ">=20"
}
},
"services/device-gateway": {
"name": "@nodedc/device-gateway",
"version": "0.1.0",
@@ -0,0 +1,12 @@
FROM alpine:3.22
RUN apk add --no-cache openssh-server \
&& addgroup -g 11021 device-backhaul \
&& adduser -D -H -u 11021 -G device-backhaul -s /sbin/nologin device-backhaul \
&& passwd -u device-backhaul
COPY services/device-backhaul-target/sshd_config /etc/ssh/sshd_config
EXPOSE 2222/tcp
CMD ["sh", "-c", "mkdir -p /run/sshd && exec /usr/sbin/sshd -D -e -f /etc/ssh/sshd_config"]
@@ -0,0 +1,35 @@
Port 2222
ListenAddress 127.0.0.1
AddressFamily inet
Protocol 2
HostKey /run/nodedc-secrets/ssh_host_ed25519_key
AuthorizedKeysFile /run/nodedc-secrets/authorized_keys
StrictModes no
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
AllowUsers device-backhaul
AllowTcpForwarding local
PermitOpen 127.0.0.1:9921
GatewayPorts no
PermitTunnel no
AllowAgentForwarding no
X11Forwarding no
PermitTTY no
PermitUserEnvironment no
ForceCommand /bin/false
ClientAliveInterval 30
ClientAliveCountMax 3
LoginGraceTime 20
MaxAuthTries 3
MaxSessions 4
MaxStartups 4:30:8
UseDNS no
PrintMotd no
LogLevel VERBOSE
@@ -3,9 +3,11 @@ FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
COPY packages ./packages
COPY packages/device-protocol-contract ./packages/device-protocol-contract
COPY packages/arusnavi-b2-adapter ./packages/arusnavi-b2-adapter
COPY services/device-control-core ./services/device-control-core
COPY services/device-gateway/package.json ./services/device-gateway/package.json
COPY services/device-edge-relay/package.json ./services/device-edge-relay/package.json
RUN npm ci --omit=dev --ignore-scripts
@@ -0,0 +1,9 @@
FROM alpine:3.22
RUN apk add --no-cache openssh-client-default netcat-openbsd \
&& addgroup -g 1000 edge-backhaul \
&& adduser -D -H -u 1000 -G edge-backhaul -s /sbin/nologin edge-backhaul
USER 1000:1000
ENTRYPOINT ["/usr/bin/ssh"]
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const here = dirname(fileURLToPath(import.meta.url));
const devicePlane = resolve(here, "../../..");
test("edge backhaul is key-only, pinned and never host-published", async () => {
const compose = await readFile(
resolve(devicePlane, "docker-compose.device-edge.backhaul.yml"),
"utf8",
);
const descriptor = JSON.parse(await readFile(
resolve(devicePlane, "deployment/device-edge-backhaul-v1.json"),
"utf8",
));
assert.match(compose, /StrictHostKeyChecking=yes/);
assert.match(compose, /UserKnownHostsFile=\/run\/trust\/known_hosts/);
assert.match(compose, /PasswordAuthentication=no/);
assert.match(compose, /KbdInteractiveAuthentication=no/);
assert.match(compose, /ExitOnForwardFailure=yes/);
assert.match(compose, /ProxyCommand=nc -X 5 -x nodedc-device-edge-tailnet-1:1055/);
assert.match(compose, /\.\.\/secrets\/backhaul\/id_ed25519/);
assert.doesNotMatch(compose, /\.\.\/keys\/edge-to-synology/);
assert.match(compose, /0\.0\.0\.0:19921:127\.0\.0\.1:9921/);
assert.doesNotMatch(compose, /^\s+ports:/m);
assert.doesNotMatch(compose, /0\.0\.0\.0:9921/);
assert.equal(descriptor.hostPortPublication, "disabled");
assert.equal(descriptor.deviceIngress, "disabled");
assert.equal(descriptor.commandTransport, "disabled");
});
@@ -0,0 +1,9 @@
FROM node:22-alpine
WORKDIR /app
COPY services/device-edge-relay/src ./src
USER node
CMD ["node", "src/server.mjs"]
@@ -0,0 +1,13 @@
{
"name": "@nodedc/device-edge-relay",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/server.mjs",
"test": "node --test test/*.test.mjs"
},
"engines": {
"node": ">=20"
}
}
@@ -0,0 +1,294 @@
import { createServer as createHttpServer } from "node:http";
import { connect, createServer as createTcpServer } from "node:net";
export function createDeviceEdgeRelayRuntime(options = {}) {
const config = normalizeConfig(options);
const sessions = new Map();
const sessionsByAddress = new Map();
const connectionWindows = new Map();
let totalAccepted = 0;
let totalRejected = 0;
let totalForwarded = 0;
const tcpServer = createTcpServer({ allowHalfOpen: true }, (socket) => {
const remoteAddress = normalizeRemoteAddress(socket.remoteAddress);
if (
sessions.size >= config.maxConcurrentSessions
|| currentAddressSessions(remoteAddress) >= config.maxSessionsPerAddress
|| !consumeConnectionPermit(remoteAddress)
) {
totalRejected += 1;
socket.destroy();
return;
}
const session = {
remoteAddress,
socket,
upstream: null,
closed: false,
forwarded: false,
};
sessions.set(socket, session);
incrementAddressSessions(remoteAddress);
totalAccepted += 1;
socket.setNoDelay(true);
socket.setTimeout(config.sessionTimeoutMs);
socket.pause();
socket.on("timeout", () => rejectSession(session));
socket.on("close", () => closeSession(session));
socket.on("error", () => rejectSession(session));
const upstream = connect({
host: config.upstreamHost,
port: config.upstreamPort,
});
session.upstream = upstream;
upstream.setNoDelay(true);
upstream.setTimeout(config.sessionTimeoutMs);
upstream.on("connect", () => {
if (session.closed) {
upstream.destroy();
return;
}
session.forwarded = true;
totalForwarded += 1;
socket.pipe(upstream);
upstream.pipe(socket);
socket.resume();
});
upstream.on("timeout", () => rejectSession(session));
upstream.on("error", () => rejectSession(session));
upstream.on("close", () => closeSession(session));
});
const healthServer = createHttpServer((request, response) => {
response.setHeader("Content-Type", "application/json; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
response.setHeader("X-Content-Type-Options", "nosniff");
if (request.method !== "GET" || request.url !== "/healthz") {
response.statusCode = 404;
response.end('{"ok":false,"error":"device_edge_relay_route_not_found"}\n');
return;
}
response.statusCode = 200;
response.end(`${JSON.stringify({
ok: true,
service: "nodedc-device-edge-relay",
ingress: config.ingressEnabled ? "relay-only" : "disabled",
protocolInspection: "disabled",
commandTransport: "disabled",
sessions: {
active: sessions.size,
accepted: totalAccepted,
rejected: totalRejected,
forwarded: totalForwarded,
},
})}\n`);
});
return {
async start() {
await listen(healthServer, config.healthPort, config.healthHost);
if (config.ingressEnabled) {
await listen(tcpServer, config.tcpPort, config.tcpHost);
}
return {
healthAddress: healthServer.address(),
tcpAddress: config.ingressEnabled ? tcpServer.address() : null,
};
},
async stop() {
for (const session of sessions.values()) rejectSession(session);
await Promise.all([
closeServer(healthServer),
config.ingressEnabled ? closeServer(tcpServer) : Promise.resolve(),
]);
},
status() {
return {
activeSessions: sessions.size,
totalAccepted,
totalRejected,
totalForwarded,
ingress: config.ingressEnabled ? "relay-only" : "disabled",
protocolInspection: "disabled",
commandTransport: "disabled",
};
},
};
function currentAddressSessions(remoteAddress) {
return sessionsByAddress.get(remoteAddress) || 0;
}
function incrementAddressSessions(remoteAddress) {
sessionsByAddress.set(
remoteAddress,
currentAddressSessions(remoteAddress) + 1,
);
}
function decrementAddressSessions(remoteAddress) {
const current = currentAddressSessions(remoteAddress);
if (current <= 1) {
sessionsByAddress.delete(remoteAddress);
} else {
sessionsByAddress.set(remoteAddress, current - 1);
}
}
function consumeConnectionPermit(remoteAddress) {
const nowMs = config.now().getTime();
const current = connectionWindows.get(remoteAddress);
if (!current || nowMs - current.startedAt >= 60_000) {
connectionWindows.set(remoteAddress, { startedAt: nowMs, count: 1 });
return true;
}
if (current.count >= config.maxConnectionsPerMinutePerAddress) return false;
current.count += 1;
return true;
}
function rejectSession(session) {
if (!session.closed) totalRejected += 1;
session.socket.destroy();
session.upstream?.destroy();
closeSession(session);
}
function closeSession(session) {
if (session.closed) return;
session.closed = true;
sessions.delete(session.socket);
decrementAddressSessions(session.remoteAddress);
}
}
function normalizeConfig(input) {
const ingressEnabled = input.ingressEnabled === true;
return {
ingressEnabled,
healthHost: normalizeHost(input.healthHost, "127.0.0.1"),
healthPort: parseInteger(
input.healthPort,
18221,
0,
65535,
"device_edge_relay_health_port_invalid",
),
tcpHost: normalizeTcpHost(input.tcpHost, ingressEnabled),
tcpPort: parseInteger(
input.tcpPort,
9921,
0,
65535,
"device_edge_relay_tcp_port_invalid",
),
upstreamHost: ingressEnabled
? normalizeUpstreamHost(input.upstreamHost)
: "disabled",
upstreamPort: ingressEnabled
? parseInteger(
input.upstreamPort,
undefined,
1,
65535,
"device_edge_relay_upstream_port_invalid",
)
: 0,
maxConcurrentSessions: parseInteger(
input.maxConcurrentSessions,
100,
1,
10000,
"device_edge_relay_session_limit_invalid",
),
maxSessionsPerAddress: parseInteger(
input.maxSessionsPerAddress,
10,
1,
1000,
"device_edge_relay_address_session_limit_invalid",
),
maxConnectionsPerMinutePerAddress: parseInteger(
input.maxConnectionsPerMinutePerAddress,
30,
1,
10000,
"device_edge_relay_connection_rate_invalid",
),
sessionTimeoutMs: parseInteger(
input.sessionTimeoutMs,
10000,
100,
60000,
"device_edge_relay_session_timeout_invalid",
),
now: typeof input.now === "function" ? input.now : () => new Date(),
};
}
function normalizeHost(value, fallback) {
const normalized = String(value || fallback).trim();
if (!["127.0.0.1", "::1", "0.0.0.0", "::"].includes(normalized)) {
throw new TypeError("device_edge_relay_health_host_invalid");
}
return normalized;
}
function normalizeTcpHost(value, ingressEnabled) {
const fallback = ingressEnabled ? "0.0.0.0" : "127.0.0.1";
const normalized = String(value || fallback).trim();
const allowed = ingressEnabled ? ["0.0.0.0", "::"] : ["127.0.0.1", "::1"];
if (!allowed.includes(normalized)) {
throw new TypeError(
ingressEnabled
? "device_edge_relay_public_ingress_host_invalid"
: "device_edge_relay_baseline_loopback_only",
);
}
return normalized;
}
function normalizeUpstreamHost(value) {
const normalized = String(value || "").trim();
if (
normalized.length === 0
|| normalized.length > 253
|| /[/:\\s]/.test(normalized)
) {
throw new TypeError("device_edge_relay_upstream_host_invalid");
}
return normalized;
}
function normalizeRemoteAddress(value) {
const normalized = String(value || "unknown").trim();
return normalized.slice(0, 64) || "unknown";
}
function parseInteger(value, fallback, minimum, maximum, errorCode) {
const parsed = Number(value ?? fallback);
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
throw new TypeError(errorCode);
}
return parsed;
}
function listen(server, port, host) {
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(port, host, () => {
server.off("error", reject);
resolve();
});
});
}
function closeServer(server) {
return new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
@@ -0,0 +1,76 @@
import { createDeviceEdgeRelayRuntime } from "./runtime.mjs";
const runtime = createDeviceEdgeRelayRuntime({
ingressEnabled: parseBoolean(
process.env.DEVICE_EDGE_RELAY_INGRESS_ENABLED,
false,
),
healthHost: process.env.DEVICE_EDGE_RELAY_HEALTH_HOST || "127.0.0.1",
healthPort: parsePort(process.env.DEVICE_EDGE_RELAY_HEALTH_PORT, 18221),
tcpHost: process.env.DEVICE_EDGE_RELAY_TCP_HOST,
tcpPort: parsePort(process.env.DEVICE_EDGE_RELAY_TCP_PORT, 9921),
upstreamHost: process.env.DEVICE_EDGE_RELAY_UPSTREAM_HOST,
upstreamPort: parsePort(
process.env.DEVICE_EDGE_RELAY_UPSTREAM_PORT,
undefined,
),
maxConcurrentSessions: parsePositiveInt(
process.env.DEVICE_EDGE_RELAY_MAX_SESSIONS,
100,
),
maxSessionsPerAddress: parsePositiveInt(
process.env.DEVICE_EDGE_RELAY_MAX_SESSIONS_PER_ADDRESS,
10,
),
maxConnectionsPerMinutePerAddress: parsePositiveInt(
process.env.DEVICE_EDGE_RELAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS,
30,
),
sessionTimeoutMs: parsePositiveInt(
process.env.DEVICE_EDGE_RELAY_SESSION_TIMEOUT_MS,
10000,
),
});
const addresses = await runtime.start();
console.log(JSON.stringify({
event: "device_edge_relay_started",
health: addresses.healthAddress,
tcp: addresses.tcpAddress,
ingress: runtime.status().ingress,
protocolInspection: "disabled",
commandTransport: "disabled",
}));
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
async function shutdown() {
await runtime.stop();
process.exit(0);
}
function parsePort(value, fallback) {
if (value === undefined && fallback === undefined) return undefined;
const parsed = Number(value ?? fallback);
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535) {
throw new Error("device_edge_relay_port_invalid");
}
return parsed;
}
function parsePositiveInt(value, fallback) {
const parsed = Number(value || fallback);
if (!Number.isSafeInteger(parsed) || parsed < 1) {
throw new Error("device_edge_relay_positive_integer_invalid");
}
return parsed;
}
function parseBoolean(value, fallback) {
if (value === undefined || value === null || value === "") return fallback;
const normalized = String(value).trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(normalized)) return true;
if (["0", "false", "no", "off"].includes(normalized)) return false;
throw new Error("device_edge_relay_boolean_invalid");
}
@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
const devicePlaneRoot = resolve(
dirname(fileURLToPath(import.meta.url)),
"../../..",
);
test("single-NIC ingress source has no host publication and a fixed ipvlan", async () => {
const baseline = await readFile(
resolve(devicePlaneRoot, "docker-compose.device-edge.yml"),
"utf8",
);
const ingress = await readFile(
resolve(devicePlaneRoot, "docker-compose.device-edge.ingress.yml"),
"utf8",
);
assert.match(
baseline,
/DEVICE_EDGE_RELAY_HEALTH_HOST: 127\.0\.0\.1/,
);
assert.doesNotMatch(baseline, /^\s+ports:/m);
assert.doesNotMatch(baseline, /device-edge-control/);
for (const required of [
'DEVICE_EDGE_RELAY_INGRESS_ENABLED: "true"',
"DEVICE_EDGE_RELAY_UPSTREAM_HOST: device-edge-backhaul",
'DEVICE_EDGE_RELAY_UPSTREAM_PORT: "19921"',
"name: nodedc-device-edge-ingress",
"driver: ipvlan",
"parent: enp1s0f0",
"ipvlan_mode: l2",
"ipv4_address: 192.168.71.253",
"subnet: 192.168.68.0/22",
"gateway: 192.168.68.1",
"gw_priority: 100",
]) {
assert.ok(ingress.includes(required), `missing ingress boundary: ${required}`);
}
for (const forbidden of [
"ports:",
"network_mode: host",
"privileged: true",
"DEVICE_EDGE_RELAY_COMMAND",
"0.0.0.0:9921:9921",
]) {
assert.ok(
!ingress.includes(forbidden),
`forbidden ingress boundary: ${forbidden}`,
);
}
});
test("ingress descriptor keeps address approval and router exposure gated", async () => {
const descriptor = JSON.parse(await readFile(
resolve(
devicePlaneRoot,
"deployment/device-edge-ingress-ipvlan-v1.json",
),
"utf8",
));
assert.equal(descriptor.component, "device-edge");
assert.deepEqual(descriptor.selectedServices, ["device-edge-relay"]);
assert.deepEqual(
descriptor.preservedServices,
["device-edge-backhaul", "tailnet"],
);
assert.equal(descriptor.ingressIpv4Approval, "approved-outside-dhcp-pool");
assert.equal(descriptor.hostPortPublication, "disabled");
assert.equal(descriptor.healthPublication, "disabled");
assert.equal(descriptor.privateUpstream, "device-edge-backhaul:19921");
assert.equal(descriptor.protocolInspection, "gateway-owned");
assert.equal(descriptor.discoveryLifecycle, "quarantine");
assert.equal(descriptor.commandTransport, "disabled");
assert.equal(descriptor.gelios, "untouched");
assert.equal(descriptor.amneziaHostFullTunnel, "preserved");
assert.equal(descriptor.routerNatFirewall, "separate-manual-gate");
});
@@ -0,0 +1,95 @@
import assert from "node:assert/strict";
import { createServer, connect } from "node:net";
import test from "node:test";
import { createDeviceEdgeRelayRuntime } from "../src/runtime.mjs";
test("baseline starts only loopback health and no device TCP listener", async () => {
const runtime = createDeviceEdgeRelayRuntime({ healthPort: 0 });
const addresses = await runtime.start();
try {
assert.equal(addresses.tcpAddress, null);
const response = await fetch(
`http://127.0.0.1:${addresses.healthAddress.port}/healthz`,
);
assert.equal(response.status, 200);
const body = await response.json();
assert.equal(body.ingress, "disabled");
assert.equal(body.protocolInspection, "disabled");
assert.equal(body.commandTransport, "disabled");
} finally {
await runtime.stop();
}
});
test("relay is transparent and never emits its own protocol bytes", async () => {
const upstream = await startEchoServer();
const runtime = createDeviceEdgeRelayRuntime({
healthPort: 0,
ingressEnabled: true,
tcpHost: "0.0.0.0",
tcpPort: 0,
upstreamHost: "127.0.0.1",
upstreamPort: upstream.port,
});
const addresses = await runtime.start();
try {
const response = await sendAndCollect(
addresses.tcpAddress.port,
Buffer.from("ff230102030405060708", "hex"),
);
assert.equal(response.toString("hex"), "ff230102030405060708");
assert.equal(runtime.status().totalForwarded, 1);
assert.equal(runtime.status().commandTransport, "disabled");
} finally {
await runtime.stop();
await closeServer(upstream.server);
}
});
test("enabled relay requires a concrete private upstream", () => {
assert.throws(
() => createDeviceEdgeRelayRuntime({
ingressEnabled: true,
tcpHost: "0.0.0.0",
upstreamPort: 19921,
}),
/device_edge_relay_upstream_host_invalid/,
);
});
test("baseline rejects a non-loopback device binding", () => {
assert.throws(
() => createDeviceEdgeRelayRuntime({ tcpHost: "0.0.0.0" }),
/device_edge_relay_baseline_loopback_only/,
);
});
function startEchoServer() {
const server = createServer((socket) => socket.pipe(socket));
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolve({ server, port: server.address().port });
});
});
}
function sendAndCollect(port, payload) {
return new Promise((resolve, reject) => {
const chunks = [];
const socket = connect({ host: "127.0.0.1", port }, () => {
socket.end(payload);
});
socket.on("data", (chunk) => chunks.push(chunk));
socket.on("close", () => resolve(Buffer.concat(chunks)));
socket.on("error", reject);
});
}
function closeServer(server) {
return new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
@@ -3,9 +3,11 @@ FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
COPY packages ./packages
COPY packages/device-protocol-contract ./packages/device-protocol-contract
COPY packages/arusnavi-b2-adapter ./packages/arusnavi-b2-adapter
COPY services/device-gateway ./services/device-gateway
COPY services/device-control-core/package.json ./services/device-control-core/package.json
COPY services/device-edge-relay/package.json ./services/device-edge-relay/package.json
RUN npm ci --omit=dev --ignore-scripts
+50
View File
@@ -20,6 +20,56 @@ The runner accepts data-only app-overlay artifacts from:
/volume1/docker/nodedc-deploy/inbox
```
## Dedicated Device Edge runner
The Debian Device Edge is a separate root-owned deployment domain. It does not
use the Synology runner, inbox, state or backup tree. Its live runner and fixed
roots are:
```text
/usr/local/sbin/nodedc-edge-deploy
/home/ndcsudo/nodedc-device-edge/deploy/inbox
/home/ndcsudo/nodedc-device-edge/source
/var/lib/nodedc-edge-deploy
```
`nodedc-edge-deploy` accepts only `component=device-edge`, validates an exact
five-entry payload and can build/recreate only `device-edge-relay`. The existing
`device-edge-backhaul` and `tailnet` containers are identity-snapshotted before
the transition and must remain byte-for-byte runtime-equivalent through apply
or automatic rollback. It has no registry entry in the Synology runner.
The first ingress transition uses a Docker IPvlan L2 address on the Mini's
single Ethernet parent. It publishes no Docker host port and preserves the
Amnezia host full tunnel; only the relay container receives a LAN-routable
address. The fixed IPv4 is a runner/Compose/descriptor constant:
`192.168.71.253`. Router evidence on 2026-08-04 proves the Deco DHCP pool is
`192.168.68.50` through `192.168.71.250`, so the address is explicitly outside
the pool. Router port-forwarding/firewall remains a separate manual gate.
Build and test the transition source:
```bash
PYTHONDONTWRITEBYTECODE=1 \
python3 infra/deploy-runner/test_device_edge_ingress_artifact.py
npm test --prefix device-plane
```
After the fixed address is approved, build the production artifact with a fresh
transition id, stage it into the Edge inbox, then use only the canonical pair:
```bash
sudo /usr/local/sbin/nodedc-edge-deploy plan \
/home/ndcsudo/nodedc-device-edge/deploy/inbox/<artifact>.tgz
sudo /usr/local/sbin/nodedc-edge-deploy apply \
/home/ndcsudo/nodedc-device-edge/deploy/inbox/<artifact>.tgz
```
The apply acceptance checks the exact IPvlan parent/subnet/gateway/address,
absence of host port publication, internal relay health, private backhaul
reachability, preserved VPN routes and unchanged backhaul/tailnet container
identity. Gelios and Device Plane command transport are outside this domain.
Supported components in this source:
- `engine`
@@ -0,0 +1,256 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import {
cp,
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../..");
const sourceRoot = resolve(platformRoot, "device-plane");
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|| resolve(scriptDir, "../deploy-artifacts"),
);
const [
patchId = "device-edge-ingress-ipvlan-20260804-001",
...extra
] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error(
"usage: build-device-edge-ingress-artifact.mjs [patch-id]",
);
}
const files = [
"docker-compose.device-edge.yml",
"docker-compose.device-edge.ingress.yml",
"services/device-edge-relay/Dockerfile",
"services/device-edge-relay/src",
"deployment/device-edge-ingress-ipvlan-v1.json",
];
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
const descriptor = await assertBoundary();
if (descriptor.ingressIpv4Approval !== "approved-outside-dhcp-pool") {
throw new Error("device_edge_ingress_ipv4_approval_pending");
}
const stage = await mkdtemp(join(tmpdir(), "nodedc-device-edge-ingress-"));
const payload = join(stage, "payload");
const target = join(artifactDir, `nodedc-device-edge-${patchId}.tgz`);
try {
await mkdir(payload, { recursive: true });
for (const sourceRelative of files) {
await copySafe(
resolve(sourceRoot, sourceRelative),
join(payload, sourceRelative),
);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=device-edge\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
const tar = spawnSync(
"python3",
["-c", canonicalTarScript(), target, stage],
{ encoding: "utf8", maxBuffer: 128 * 1024 * 1024 },
);
if (tar.status !== 0) {
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
}
const digest = createHash("sha256")
.update(await readFile(target))
.digest("hex");
console.log(JSON.stringify({
ok: true,
patchId,
artifact: target,
sha256: digest,
component: "device-edge",
transition: "single-nic-ipvlan-b2-relay-only",
entries: files,
services: ["device-edge-relay"],
preservedRuntime: ["device-edge-backhaul", "tailnet", "Gelios"],
ingress: {
parent: descriptor.parentInterface,
subnet: descriptor.lanSubnet,
gateway: descriptor.lanGateway,
ipv4: descriptor.ingressIpv4,
ipv4Approval: descriptor.ingressIpv4Approval,
tcp: 9921,
hostPortPublication: "disabled",
lifecycle: "quarantine",
commandTransport: "disabled",
},
rollback: descriptor.rollback,
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertBoundary() {
const baseline = await readFile(
resolve(sourceRoot, "docker-compose.device-edge.yml"),
"utf8",
);
const ingress = await readFile(
resolve(sourceRoot, "docker-compose.device-edge.ingress.yml"),
"utf8",
);
const descriptor = JSON.parse(await readFile(
resolve(
sourceRoot,
"deployment/device-edge-ingress-ipvlan-v1.json",
),
"utf8",
));
for (const fragment of [
"DEVICE_EDGE_RELAY_HEALTH_HOST: 127.0.0.1",
'DEVICE_EDGE_RELAY_INGRESS_ENABLED: "false"',
"read_only: true",
'user: "1000:1000"',
"no-new-privileges:true",
"cap_drop:",
"- ALL",
]) {
if (!baseline.includes(fragment)) {
throw new Error(`device_edge_baseline_boundary_missing:${fragment}`);
}
}
for (const forbidden of ["ports:", "device-edge-control"]){
if (baseline.includes(forbidden)) {
throw new Error(`device_edge_baseline_boundary_violation:${forbidden}`);
}
}
for (const fragment of [
'DEVICE_EDGE_RELAY_INGRESS_ENABLED: "true"',
"DEVICE_EDGE_RELAY_UPSTREAM_HOST: device-edge-backhaul",
'DEVICE_EDGE_RELAY_UPSTREAM_PORT: "19921"',
"name: nodedc-device-edge-ingress",
"driver: ipvlan",
"parent: enp1s0f0",
"ipvlan_mode: l2",
"ipv4_address: 192.168.71.253",
"gw_priority: 100",
"subnet: 192.168.68.0/22",
"gateway: 192.168.68.1",
]) {
if (!ingress.includes(fragment)) {
throw new Error(`device_edge_ingress_boundary_missing:${fragment}`);
}
}
for (const forbidden of [
"ports:",
"network_mode: host",
"privileged: true",
"DEVICE_EDGE_RELAY_COMMAND",
"0.0.0.0:9921:9921",
]) {
if (ingress.includes(forbidden)) {
throw new Error(`device_edge_ingress_boundary_violation:${forbidden}`);
}
}
const expected = {
schemaVersion: "nodedc.device-edge.ingress-ipvlan.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",
protocolInspection: "gateway-owned",
identityTrust: "claimed-not-ownership-proof",
discoveryLifecycle: "quarantine",
commandTransport: "disabled",
gelios: "untouched",
amneziaHostFullTunnel: "preserved",
routerNatFirewall: "separate-manual-gate",
rollback: "restore-predecessor-relay-remove-unused-ingress-network",
};
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
throw new Error("device_edge_ingress_descriptor_mismatch");
}
return descriptor;
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
async function copySafe(source, destination) {
const sourceStat = await lstat(source);
if (sourceStat.isSymbolicLink()) {
throw new Error(`source_symlink_rejected:${relative(sourceRoot, source)}`);
}
if (sourceStat.isFile()) {
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, { force: true, verbatimSymlinks: true });
return;
}
if (!sourceStat.isDirectory()) {
throw new Error(`source_type_rejected:${source}`);
}
await mkdir(destination, { recursive: true });
for (const entry of await readdir(source, { withFileTypes: true })) {
if (
ignoredBasenames.has(entry.name)
|| entry.name.startsWith(".env")
) {
continue;
}
const childSource = join(source, entry.name);
const childDestination = join(destination, entry.name);
if (entry.isSymbolicLink()) {
throw new Error(
`source_symlink_rejected:${relative(sourceRoot, childSource)}`,
);
}
await copySafe(childSource, childDestination);
}
}
@@ -43,6 +43,7 @@ const files = [
"packages/arusnavi-b2-adapter",
"services/device-control-core",
"services/device-gateway",
"services/device-edge-relay/package.json",
"deployment/device-plane-b2-discovery-ingress-v1.json",
];
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
@@ -0,0 +1,175 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import {
cp,
lstat,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../..");
const sourceRoot = resolve(platformRoot, "device-plane");
const descriptorRelative =
"deployment/device-plane-b2-discovery-loopback-recovery-v1.json";
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|| resolve(scriptDir, "../deploy-artifacts"),
);
const [
patchId = "device-plane-b2-discovery-loopback-recovery-20260802-004",
...extra
] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error(
"usage: "
+ "build-device-plane-b2-discovery-loopback-recovery-artifact.mjs "
+ "[patch-id]",
);
}
const files = [descriptorRelative];
const stage = await mkdtemp(
join(tmpdir(), "nodedc-device-plane-b2-loopback-recovery-"),
);
const payload = join(stage, "payload");
const target = join(
artifactDir,
`nodedc-device-plane-${patchId}.tgz`,
);
await assertRecoveryDescriptor();
try {
const source = resolve(sourceRoot, descriptorRelative);
const sourceStat = await lstat(source);
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
throw new Error("device_plane_b2_recovery_descriptor_unsafe");
}
await mkdir(dirname(join(payload, descriptorRelative)), {
recursive: true,
});
await cp(source, join(payload, descriptorRelative), {
force: true,
verbatimSymlinks: true,
});
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
"utf8",
);
await writeFile(
join(stage, "files.txt"),
`${files.join("\n")}\n`,
"utf8",
);
await mkdir(artifactDir, { recursive: true });
const tar = spawnSync(
"python3",
["-c", canonicalTarScript(), target, stage],
{
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
},
);
if (tar.status !== 0) {
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
}
const digest = createHash("sha256")
.update(await readFile(target))
.digest("hex");
console.log(JSON.stringify({
ok: true,
patchId,
artifact: target,
sha256: digest,
component: "device-plane",
transition: "failed-b2-loopback-build-reconciliation",
entries: files,
build: [],
services: [],
preservedRuntime: [
"device-control-core",
"device-gateway",
"device-postgres",
"nodedc-device-plane-postgres-data",
"Gelios",
],
sourceAction: "publish-reconciliation-marker-only",
runtimeAction: "read-only-acceptance",
ingress: "disabled:127.0.0.1:9921/tcp:closed",
rollback: "marker-only-runtime-unchanged",
excluded: [
"application-source",
"compose",
"Dockerfile",
"secrets",
"runtime",
"database",
"Gelios",
],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertRecoveryDescriptor() {
const descriptor = JSON.parse(await readFile(
resolve(sourceRoot, descriptorRelative),
"utf8",
));
const expected = {
schemaVersion: "nodedc.device-plane.b2-discovery-loopback-recovery.v1",
mode: "failed-b2-loopback-build-reconciliation",
failedPatchId: "device-plane-b2-discovery-loopback-20260801-003",
failedArtifactSha256:
"7273c5bf67fe6bc1f1da66ad726009240d39ee3aee58201b96c23d6f707a3d84",
failedBackupId:
"device-plane-device-plane-b2-discovery-loopback-20260801-003-20260802-154311",
sourceAction: "publish-reconciliation-marker-only",
runtimeAction: "read-only-acceptance",
preservedServices: [
"device-control-core",
"device-gateway",
"device-postgres",
],
expectedLoopbackPorts: [
"127.0.0.1:18120:18120",
"127.0.0.1:18121:18121",
],
closedPort: "127.0.0.1:9921/tcp",
databaseVolume: "nodedc-device-plane-postgres-data",
commandTransport: "disabled",
gelios: "untouched",
rollback: "marker-only-runtime-unchanged",
};
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
throw new Error("device_plane_b2_recovery_descriptor_mismatch");
}
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
@@ -0,0 +1,256 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import {
cp,
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const platformRoot = resolve(scriptDir, "../..");
const sourceRoot = resolve(platformRoot, "device-plane");
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|| resolve(scriptDir, "../deploy-artifacts"),
);
const [
patchId = "device-plane-backhaul-target-tailnet-serve-20260804-002",
...extra
] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error(
"usage: build-device-plane-backhaul-target-artifact.mjs [patch-id]",
);
}
const files = [
"docker-compose.device-plane.backhaul-target.yml",
"services/device-backhaul-target",
"deployment/device-plane-backhaul-target-tailnet-serve-v1.json",
];
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
const stage = await mkdtemp(
join(tmpdir(), "nodedc-device-plane-backhaul-target-"),
);
const payload = join(stage, "payload");
const target = join(
artifactDir,
`nodedc-device-plane-${patchId}.tgz`,
);
await assertBoundary();
try {
await mkdir(payload, { recursive: true });
for (const sourceRelative of files) {
await copySafe(
resolve(sourceRoot, sourceRelative),
join(payload, sourceRelative),
);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
const tar = spawnSync(
"python3",
["-c", canonicalTarScript(), target, stage],
{ encoding: "utf8", maxBuffer: 128 * 1024 * 1024 },
);
if (tar.status !== 0) {
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
}
const digest = createHash("sha256")
.update(await readFile(target))
.digest("hex");
console.log(JSON.stringify({
ok: true,
patchId,
artifact: target,
sha256: digest,
component: "device-plane",
transition: "failed-backhaul-target-to-loopback-tailnet-serve",
entries: files,
services: ["device-backhaul-target"],
preservedRuntime: [
"device-control-core",
"device-gateway",
"device-postgres",
"nodedc-device-plane-postgres-data",
"Gelios",
],
ingress: {
loopbackListen: "127.0.0.1:2222/tcp",
tailnetListen: "100.109.216.21:2222/tcp",
transport: "tailscale-serve-private-ssh",
serveTarget: "tcp://127.0.0.1:2222",
permittedTarget: "127.0.0.1:9921",
dockerPortPublication: "disabled",
routerNatFirewall: "unchanged",
edgePublicIngress: "disabled",
funnel: "disabled",
commandTransport: "disabled",
},
runtimeTrust: "runner-managed-not-in-artifact",
rollback: "remove-tailnet-serve-target-and-restore-source",
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertBoundary() {
const compose = await readFile(
resolve(sourceRoot, "docker-compose.device-plane.backhaul-target.yml"),
"utf8",
);
for (const fragment of [
"device-backhaul-target:",
"image: nodedc/device-backhaul-target:local",
"network_mode: host",
'"127.0.0.1", "2222"',
"/secrets/backhaul-target/ssh_host_ed25519_key",
"/secrets/backhaul-target/authorized_keys",
"no-new-privileges:true",
]) {
if (!compose.includes(fragment)) {
throw new Error(`device_plane_backhaul_boundary_missing:${fragment}`);
}
}
for (const forbidden of [
"PasswordAuthentication yes",
"0.0.0.0:2222",
"9921:9921/udp",
"DEVICE_GATEWAY_COMMAND",
]) {
if (compose.includes(forbidden)) {
throw new Error(`device_plane_backhaul_boundary_violation:${forbidden}`);
}
}
const sshd = await readFile(
resolve(sourceRoot, "services/device-backhaul-target/sshd_config"),
"utf8",
);
for (const fragment of [
"ListenAddress 127.0.0.1",
"PasswordAuthentication no",
"KbdInteractiveAuthentication no",
"AllowTcpForwarding local",
"PermitOpen 127.0.0.1:9921",
"GatewayPorts no",
"PermitTunnel no",
"AllowAgentForwarding no",
"PermitTTY no",
"ForceCommand /bin/false",
]) {
if (!sshd.includes(fragment)) {
throw new Error(`device_plane_backhaul_sshd_boundary_missing:${fragment}`);
}
}
const descriptor = JSON.parse(await readFile(
resolve(
sourceRoot,
"deployment/device-plane-backhaul-target-tailnet-serve-v1.json",
),
"utf8",
));
const expected = {
schemaVersion: "nodedc.device-plane.backhaul-target-tailnet-serve.v1",
mode: "failed-backhaul-target-to-loopback-tailnet-serve",
failedPatchId: "device-plane-backhaul-target-20260803-001",
failedArtifactSha256:
"ed0bda4110a756c32be68990e2e0f647409d5a77eec7e26c18502bafbdc1bb76",
failedBackupId:
"device-plane-device-plane-backhaul-target-20260803-001-20260804-035519",
predecessorPatchId:
"device-plane-b2-discovery-loopback-20260803-006",
predecessorArtifactSha256:
"25f9e9e55e283e9b7bb5e128ff14a244f848b1c063acca9724a23206131c9adf",
sourceAction: "publish-loopback-backhaul-target-source",
runtimeAction: "build-create-target-and-register-private-tailnet-serve",
composeOverlay: "docker-compose.device-plane.backhaul-target.yml",
selectedServices: ["device-backhaul-target"],
preservedServices: [
"device-control-core",
"device-gateway",
"device-postgres",
],
loopbackListenAddress: "127.0.0.1",
listenPort: 2222,
tailnetAddress: "100.109.216.21",
tailnetExposure: "tailscale-serve-private",
tailscaleServeTarget: "tcp://127.0.0.1:2222",
permittedTarget: "127.0.0.1:9921",
networkMode: "host",
dockerPortPublication: "disabled",
routerNatFirewall: "unchanged",
edgePublicIngress: "disabled",
funnel: "disabled",
commandTransport: "disabled",
gelios: "untouched",
databaseVolume: "nodedc-device-plane-postgres-data",
runtimeTrust: "runner-managed",
rollback: "remove-tailnet-serve-target-and-restore-source",
};
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
throw new Error("device_plane_backhaul_descriptor_mismatch");
}
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'wb') as out:",
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
" for top in ('manifest.env','files.txt','payload'):",
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
" for x in paths:",
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
].join("\n");
}
async function copySafe(source, destination) {
const sourceStat = await lstat(source);
if (sourceStat.isSymbolicLink()) {
throw new Error(`source_symlink_rejected:${relative(sourceRoot, source)}`);
}
if (sourceStat.isFile()) {
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, { force: true, verbatimSymlinks: true });
return;
}
if (!sourceStat.isDirectory()) {
throw new Error(`source_type_rejected:${source}`);
}
await mkdir(destination, { recursive: true });
for (const entry of await readdir(source, { withFileTypes: true })) {
if (
ignoredBasenames.has(entry.name)
|| entry.name.startsWith(".env")
|| entry.name.endsWith("~")
) {
continue;
}
await copySafe(join(source, entry.name), join(destination, entry.name));
}
}
File diff suppressed because it is too large Load Diff
+854
View File
@@ -0,0 +1,854 @@
#!/usr/bin/env python3
"""Canonical data-only deploy runner for the dedicated NODE.DC Device Edge."""
from __future__ import annotations
import hashlib
import json
import os
import re
import select
import shutil
import socket
import struct
import subprocess
import sys
import tarfile
import tempfile
import time
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
RUNNER_PATH = Path("/usr/local/sbin/nodedc-edge-deploy")
LIVE_ROOT = Path("/home/ndcsudo/nodedc-device-edge/source")
INBOX_ROOT = Path("/home/ndcsudo/nodedc-device-edge/deploy/inbox")
STATE_ROOT = Path("/var/lib/nodedc-edge-deploy")
APPLIED_ROOT = STATE_ROOT / "applied"
FAILED_ROOT = STATE_ROOT / "failed"
BACKUP_ROOT = STATE_ROOT / "backups"
APPLIED_JOURNAL = STATE_ROOT / "state/applied.jsonl"
FAILED_JOURNAL = STATE_ROOT / "state/failed.jsonl"
DEPLOY_LOCK = STATE_ROOT / "state/deploy.lock"
DOCKER = "/usr/bin/docker"
COMPONENT = "device-edge"
ARTIFACT_TYPE = "app-overlay"
PATCH_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,96}$")
MAX_ARTIFACT_BYTES = 16 * 1024 * 1024
COMPOSE_PROJECT = "nodedc-device-edge"
BASE_COMPOSE = LIVE_ROOT / "docker-compose.device-edge.yml"
INGRESS_COMPOSE = LIVE_ROOT / "docker-compose.device-edge.ingress.yml"
RELAY_SERVICE = "device-edge-relay"
RELAY_CONTAINER = "nodedc-device-edge-device-edge-relay-1"
BACKHAUL_CONTAINER = "nodedc-device-edge-device-edge-backhaul-1"
TAILNET_CONTAINER = "nodedc-device-edge-tailnet-1"
RELAY_IMAGE = "nodedc/device-edge-relay:local"
INGRESS_PARENT = "enp1s0f0"
INGRESS_SUBNET = "192.168.68.0/22"
INGRESS_GATEWAY = "192.168.68.1"
INGRESS_IPV4 = "192.168.71.253"
INGRESS_PORT = 9921
INGRESS_NETWORK = "nodedc-device-edge-ingress"
INGRESS_IPV4_APPROVED = True
INGRESS_IPV4_APPROVAL = "approved-outside-dhcp-pool"
ENTRIES = (
"docker-compose.device-edge.yml",
"docker-compose.device-edge.ingress.yml",
"services/device-edge-relay/Dockerfile",
"services/device-edge-relay/src",
"deployment/device-edge-ingress-ipvlan-v1.json",
)
PAYLOAD_FILE_SHA256 = {
"docker-compose.device-edge.yml":
"7f13c11d6d4d541964053c0a8cf791e401947d34c42e0f7c26f9f9df26fa00b5",
"docker-compose.device-edge.ingress.yml":
"a4afd04755530fc3b9be64d1a65f0f7282a9539bcc1985bfd880aa904e1c4d8f",
"services/device-edge-relay/Dockerfile":
"f2f15b7618ac2ab3a1d4dd40041d06d1e9a695edcfc168d8835f9560b4897e70",
"services/device-edge-relay/src/runtime.mjs":
"ae8bf8b55603bab266b6fa6e9bc65c9f310a9d94a54db04e2130704e38622ffc",
"services/device-edge-relay/src/server.mjs":
"e4b051b74f934bd37322440e6a013fb6774a76607da08f9cc1e844fc109c83c1",
"deployment/device-edge-ingress-ipvlan-v1.json":
"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",
}
class DeployError(RuntimeError):
pass
def die(message: str) -> None:
raise DeployError(message)
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def run(command, *, check=True, capture=True, cwd=None, timeout=180):
result = subprocess.run(
[str(value) for value in command],
check=False,
capture_output=capture,
text=True,
cwd=str(cwd) if cwd else None,
timeout=timeout,
)
if check and result.returncode != 0:
detail = (result.stderr or result.stdout or "command failed").strip()
die(f"command failed: {command[0]}: {detail}")
return result
def docker_json(*args):
result = run([DOCKER, *args])
try:
return json.loads(result.stdout)
except json.JSONDecodeError as error:
die(f"Docker JSON response invalid: {error}")
def expected_descriptor():
return {
"schemaVersion": "nodedc.device-edge.ingress-ipvlan.v1",
"mode": "single-nic-ipvlan-b2-relay-only",
"runtimeHost": "ndcmini12",
"component": COMPONENT,
"selectedServices": [RELAY_SERVICE],
"preservedServices": ["device-edge-backhaul", "tailnet"],
"composeProject": COMPOSE_PROJECT,
"composeFiles": [
"docker-compose.device-edge.yml",
"docker-compose.device-edge.ingress.yml",
],
"parentInterface": INGRESS_PARENT,
"lanSubnet": INGRESS_SUBNET,
"lanGateway": INGRESS_GATEWAY,
"ingressIpv4": INGRESS_IPV4,
"ingressIpv4Approval": INGRESS_IPV4_APPROVAL,
"ingressNetwork": INGRESS_NETWORK,
"deviceTcpListen": f"{INGRESS_IPV4}:{INGRESS_PORT}",
"hostPortPublication": "disabled",
"healthPublication": "disabled",
"privateUpstream": "device-edge-backhaul:19921",
"protocolInspection": "gateway-owned",
"identityTrust": "claimed-not-ownership-proof",
"discoveryLifecycle": "quarantine",
"commandTransport": "disabled",
"gelios": "untouched",
"amneziaHostFullTunnel": "preserved",
"routerNatFirewall": "separate-manual-gate",
"rollback": "restore-predecessor-relay-remove-unused-ingress-network",
}
def assert_root():
if os.geteuid() != 0:
die("nodedc-edge-deploy must run as root")
def assert_regular_nonsymlink(path: Path, label: str):
if not path.exists() or path.is_symlink() or not path.is_file():
die(f"{label} must be a regular non-symlink file")
def parse_manifest(raw: str):
values = {}
for line in raw.splitlines():
if not line or "=" not in line:
die("artifact manifest is malformed")
key, value = line.split("=", 1)
if key in values or key not in {"id", "component", "type"}:
die("artifact manifest key set is invalid")
values[key] = value
if set(values) != {"id", "component", "type"}:
die("artifact manifest key set is incomplete")
if not PATCH_ID_RE.fullmatch(values["id"]):
die("artifact patch id is invalid")
if values["component"] != COMPONENT or values["type"] != ARTIFACT_TYPE:
die("artifact component/type mismatch")
return values
def safe_tar_member(member: tarfile.TarInfo):
path = PurePosixPath(member.name)
if path.is_absolute() or ".." in path.parts or not path.parts:
die("artifact contains an unsafe path")
if not (member.isfile() or member.isdir()):
die("artifact contains a non-file/non-directory member")
lowered = {part.lower() for part in path.parts}
if any(
part.startswith(".env")
or part in {
".git",
"node_modules",
"secrets",
"keys",
"trust",
"runtime",
"logs",
"uploads",
}
for part in lowered
):
die("artifact contains a forbidden boundary")
if any(part.startswith("._") for part in path.parts):
die("artifact contains AppleDouble metadata")
def load_artifact(artifact: Path, extraction_root: Path):
artifact = artifact.resolve(strict=True)
if artifact.parent != INBOX_ROOT.resolve(strict=True):
die("artifact must be an explicit file in the Device Edge inbox")
assert_regular_nonsymlink(artifact, "artifact")
if artifact.suffix != ".tgz" or artifact.stat().st_size > MAX_ARTIFACT_BYTES:
die("artifact extension/size rejected")
seen = set()
with tarfile.open(artifact, "r:gz") as archive:
for member in archive.getmembers():
safe_tar_member(member)
if member.name in seen:
die("artifact contains duplicate members")
seen.add(member.name)
required = {"manifest.env", "files.txt", "payload"}
if not required.issubset(seen):
die("artifact top-level contract is incomplete")
if any(name.split("/", 1)[0] not in required for name in seen):
die("artifact contains an unexpected top-level member")
archive.extractall(extraction_root, filter="data")
manifest = parse_manifest(
(extraction_root / "manifest.env").read_text(encoding="utf-8")
)
entries = tuple(
line for line in
(extraction_root / "files.txt").read_text(encoding="utf-8").splitlines()
if line
)
if entries != ENTRIES or len(entries) != len(set(entries)):
die("Device Edge artifact file selection mismatch")
payload = extraction_root / "payload"
validate_payload(payload)
return manifest, entries, payload, sha256_file(artifact), artifact
def validate_payload(payload: Path):
actual_files = {
path.relative_to(payload).as_posix(): sha256_file(path)
for path in payload.rglob("*")
if path.is_file()
}
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")
.read_text(encoding="utf-8")
)
if descriptor != expected_descriptor():
die("Device Edge ingress descriptor mismatch")
def journal_records(path: Path):
if not path.exists():
return []
records = []
for line in path.read_text(encoding="utf-8").splitlines():
if not line:
continue
try:
records.append(json.loads(line))
except json.JSONDecodeError:
die(f"journal is malformed: {path}")
return records
def assert_new_identity(patch_id: str, artifact_sha256: str):
records = journal_records(APPLIED_JOURNAL) + journal_records(FAILED_JOURNAL)
if any(record.get("patch") == patch_id for record in records):
die("Device Edge patch id is terminally recorded")
if any(record.get("sha256") == artifact_sha256 for record in records):
die("Device Edge artifact digest is terminally recorded")
def current_source_state():
state = {}
for relative, expected in PREDECESSOR_FILE_SHA256.items():
path = LIVE_ROOT / relative
assert_regular_nonsymlink(path, f"predecessor {relative}")
state[relative] = sha256_file(path)
if state[relative] != expected:
die(f"Device Edge predecessor drift: {relative}")
for relative in PREDECESSOR_ABSENT:
if (LIVE_ROOT / relative).exists():
die(f"Device Edge predecessor unexpected path: {relative}")
return state
def inspect_container(name: str):
response = docker_json("inspect", name)
if len(response) != 1:
die(f"container inspect cardinality mismatch: {name}")
return response[0]
def container_health(container):
health = container.get("State", {}).get("Health")
return health.get("Status") if health else None
def preserved_runtime_snapshot():
snapshot = {}
for name in (BACKHAUL_CONTAINER, TAILNET_CONTAINER):
container = inspect_container(name)
if container.get("State", {}).get("Status") != "running":
die(f"preserved Device Edge service is not running: {name}")
if name == BACKHAUL_CONTAINER and container_health(container) != "healthy":
die("Device Edge backhaul is not healthy")
snapshot[name] = {
"Id": container.get("Id"),
"Image": container.get("Image"),
"StartedAt": container.get("State", {}).get("StartedAt"),
"RestartCount": container.get("RestartCount"),
"PortBindings": container.get("HostConfig", {}).get("PortBindings"),
}
return snapshot
def assert_preserved_runtime(snapshot):
current_snapshot = preserved_runtime_snapshot()
for name, expected in snapshot.items():
current = current_snapshot[name]
if current != expected:
die(f"preserved Device Edge runtime changed: {name}")
def validate_predecessor_runtime():
relay = inspect_container(RELAY_CONTAINER)
if relay.get("State", {}).get("Status") != "running":
die("Device Edge predecessor relay is not running")
if container_health(relay) != "healthy":
die("Device Edge 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")
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")
def validate_host_network_boundary():
if socket.gethostname() != "ndcmini12":
die("Device Edge runtime host mismatch")
route = run(["/usr/sbin/ip", "-4", "route", "show"]).stdout
for line in (
"0.0.0.0/1 dev amn0 metric 1",
"128.0.0.0/1 dev amn0 metric 1",
"default via 192.168.68.1 dev enp1s0f0",
"192.168.68.0/22 dev enp1s0f0",
):
if line not in route:
die(f"Device Edge host route boundary mismatch: {line}")
if run(["/usr/bin/systemctl", "is-active", "AmneziaVPN.service"]).stdout.strip() != "active":
die("AmneziaVPN must remain active for this transition")
interface = run([
"/usr/sbin/ip", "-4", "-brief", "address", "show", "dev", INGRESS_PARENT,
]).stdout
if "192.168.68.54/22" not in interface or "UP" not in interface:
die("Device Edge physical interface boundary mismatch")
def arp_duplicate_detected(target_ip: str, interface: str, attempts=3):
protocol = 0x0806
raw = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(protocol))
try:
raw.bind((interface, 0))
source_mac = raw.getsockname()[4]
target = socket.inet_aton(target_ip)
ethernet = b"\xff" * 6 + source_mac + struct.pack("!H", protocol)
arp = struct.pack(
"!HHBBH6s4s6s4s",
1,
0x0800,
6,
4,
1,
source_mac,
b"\x00" * 4,
b"\x00" * 6,
target,
)
raw.setblocking(False)
for _ in range(attempts):
raw.send(ethernet + arp)
deadline = time.monotonic() + 0.7
while time.monotonic() < deadline:
ready, _, _ = select.select([raw], [], [], deadline - time.monotonic())
if not ready:
break
packet = raw.recv(2048)
if len(packet) < 42 or packet[12:14] != b"\x08\x06":
continue
if packet[28:32] == target and packet[22:28] != source_mac:
return True
return False
finally:
raw.close()
def preflight(manifest, artifact_sha256):
if not INGRESS_IPV4_APPROVED:
die("Device Edge ingress IPv4 approval is not granted")
if INGRESS_IPV4_APPROVAL != "approved-outside-dhcp-pool":
die("Device Edge ingress IPv4 approval contract mismatch")
assert_new_identity(manifest["id"], artifact_sha256)
current_source_state()
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
def compose_command(*args, baseline=False):
command = [
DOCKER,
"compose",
"--project-name",
COMPOSE_PROJECT,
"--file",
str(BASE_COMPOSE),
]
if not baseline:
command.extend(["--file", str(INGRESS_COMPOSE)])
command.extend(args)
return command
def ensure_state_directories():
for path in (
APPLIED_ROOT,
FAILED_ROOT,
BACKUP_ROOT,
APPLIED_JOURNAL.parent,
):
path.mkdir(parents=True, exist_ok=True, mode=0o750)
os.chmod(path, 0o750)
def acquire_lock():
ensure_state_directories()
try:
descriptor = os.open(
DEPLOY_LOCK,
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
0o600,
)
except FileExistsError:
die("Device Edge deploy lock is present")
os.write(descriptor, f"pid={os.getpid()}\n".encode())
os.close(descriptor)
def release_lock():
try:
DEPLOY_LOCK.unlink()
except FileNotFoundError:
pass
def create_backup(patch_id: str):
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
backup_id = f"{patch_id}-{timestamp}"
backup = BACKUP_ROOT / backup_id
backup.mkdir(parents=False, mode=0o750)
present = []
absent = []
for relative in ENTRIES:
source = LIVE_ROOT / relative
target = backup / "payload" / relative
if not source.exists():
absent.append(relative)
continue
present.append(relative)
target.parent.mkdir(parents=True, exist_ok=True)
if source.is_dir():
shutil.copytree(source, target, symlinks=False)
else:
shutil.copy2(source, target, follow_symlinks=False)
(backup / "backup.json").write_text(json.dumps({
"schemaVersion": "nodedc.device-edge.backup.v1",
"patch": patch_id,
"present": present,
"absent": absent,
}, sort_keys=True, indent=2) + "\n", encoding="utf-8")
return backup_id, backup
def publish_payload(payload: Path):
for relative in ENTRIES:
source = payload / relative
target = LIVE_ROOT / relative
if target.exists():
if target.is_dir():
shutil.rmtree(target)
else:
target.unlink()
target.parent.mkdir(parents=True, exist_ok=True)
if source.is_dir():
shutil.copytree(source, target, symlinks=False)
else:
shutil.copy2(source, target, follow_symlinks=False)
def restore_backup(backup: Path):
descriptor = json.loads((backup / "backup.json").read_text(encoding="utf-8"))
for relative in ENTRIES:
target = LIVE_ROOT / relative
if target.exists():
if target.is_dir():
shutil.rmtree(target)
else:
target.unlink()
for relative in descriptor["present"]:
source = backup / "payload" / relative
target = LIVE_ROOT / relative
target.parent.mkdir(parents=True, exist_ok=True)
if source.is_dir():
shutil.copytree(source, target, symlinks=False)
else:
shutil.copy2(source, target, follow_symlinks=False)
def build_relay():
run([
DOCKER,
"build",
"--no-cache",
"--network=host",
"--file",
"services/device-edge-relay/Dockerfile",
"--tag",
RELAY_IMAGE,
".",
], cwd=LIVE_ROOT, timeout=900, capture=False)
def wait_healthy(name: str, timeout_seconds=150):
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
try:
container = inspect_container(name)
except DeployError:
time.sleep(2)
continue
if (
container.get("State", {}).get("Status") == "running"
and container_health(container) == "healthy"
):
return container
if container.get("State", {}).get("Status") in {"exited", "dead"}:
die(f"container stopped before health acceptance: {name}")
time.sleep(2)
die(f"container health timeout: {name}")
def validate_network_runtime(relay):
networks = relay.get("NetworkSettings", {}).get("Networks") or {}
if set(networks) != {"nodedc-device-edge-private", INGRESS_NETWORK}:
die("Device Edge relay network set mismatch")
if networks[INGRESS_NETWORK].get("IPAddress") != INGRESS_IPV4:
die("Device Edge relay IPvlan address mismatch")
response = docker_json("network", "inspect", INGRESS_NETWORK)
if len(response) != 1:
die("Device Edge ingress network cardinality mismatch")
network = response[0]
if network.get("Driver") != "ipvlan" or network.get("Internal") is True:
die("Device Edge ingress network driver mismatch")
options = network.get("Options") or {}
if options.get("parent") != INGRESS_PARENT or options.get("ipvlan_mode") != "l2":
die("Device Edge ingress network option mismatch")
configs = network.get("IPAM", {}).get("Config") or []
if len(configs) != 1:
die("Device Edge ingress IPAM cardinality mismatch")
if configs[0].get("Subnet") != INGRESS_SUBNET or configs[0].get("Gateway") != INGRESS_GATEWAY:
die("Device Edge ingress IPAM mismatch")
def validate_relay_runtime(preserved):
relay = wait_healthy(RELAY_CONTAINER)
if relay.get("Config", {}).get("User") != "1000:1000":
die("Device Edge relay user mismatch")
host = relay.get("HostConfig", {})
if host.get("ReadonlyRootfs") is not True or host.get("Privileged") is not False:
die("Device Edge relay filesystem/privilege mismatch")
if set(host.get("CapDrop") or []) != {"ALL"}:
die("Device Edge relay capability mismatch")
if host.get("PortBindings") not in ({}, None):
die("Device Edge relay host port publication detected")
environment = set(relay.get("Config", {}).get("Env") or [])
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 relay environment mismatch")
validate_network_runtime(relay)
health_result = run([
DOCKER,
"exec",
RELAY_CONTAINER,
"node",
"-e",
"fetch('http://127.0.0.1:18221/healthz').then(async r=>{if(!r.ok)process.exit(2);console.log(await r.text())}).catch(()=>process.exit(3))",
])
try:
health = json.loads(health_result.stdout)
except json.JSONDecodeError:
die("Device Edge relay health JSON invalid")
expected_health = {
"ok": True,
"service": "nodedc-device-edge-relay",
"ingress": "relay-only",
"protocolInspection": "disabled",
"commandTransport": "disabled",
}
for key, expected in expected_health.items():
if health.get(key) != expected:
die(f"Device Edge relay health contract mismatch: {key}")
run([
DOCKER,
"exec",
RELAY_CONTAINER,
"node",
"-e",
"const n=require('node:net');const s=n.connect({host:'device-edge-backhaul',port:19921});s.setTimeout(5000);s.once('connect',()=>{s.destroy();process.exit(0)});s.once('timeout',()=>process.exit(2));s.once('error',()=>process.exit(3))",
])
validate_host_network_boundary()
assert_preserved_runtime(preserved)
def write_journal(path: Path, record):
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(record, sort_keys=True) + "\n")
def archive_artifact(artifact: Path, destination_root: Path):
destination = destination_root / artifact.name
if destination.exists():
die("Device Edge artifact archive collision")
os.replace(artifact, destination)
return destination
def rollback(backup: Path, preserved):
restore_backup(backup)
run(compose_command(
"up",
"--detach",
"--no-deps",
"--force-recreate",
"--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)
def plan_artifact(artifact_argument: str):
assert_root()
artifact = Path(artifact_argument)
with tempfile.TemporaryDirectory(prefix="nodedc-edge-plan-") as directory:
manifest, entries, _payload, digest, resolved = load_artifact(
artifact,
Path(directory),
)
preflight(manifest, digest)
print("== plan ==")
print(f"artifact={resolved.name}")
print(f"sha256={digest}")
print(f"id={manifest['id']}")
print(f"component={COMPONENT}")
print(f"type={ARTIFACT_TYPE}")
print(f"payload_root={LIVE_ROOT}")
print(f"compose_root={LIVE_ROOT}")
print(f"compose_project={COMPOSE_PROJECT}")
print("compose_files=docker-compose.device-edge.yml docker-compose.device-edge.ingress.yml")
print("build=/usr/bin/docker build --no-cache --network=host -f services/device-edge-relay/Dockerfile -t nodedc/device-edge-relay:local .")
print("services=device-edge-relay")
print("preserved_services=device-edge-backhaul tailnet")
print(f"device_edge_ingress=ipvlan:l2:{INGRESS_PARENT}:{INGRESS_IPV4}:{INGRESS_PORT}/tcp")
print(f"device_edge_lan={INGRESS_SUBNET}:gateway:{INGRESS_GATEWAY}")
print(f"device_edge_ingress_ipv4_approval={INGRESS_IPV4_APPROVAL}")
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_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("state=new")
print("== files ==")
for entry in entries:
print(f" {entry}")
def apply_artifact(artifact_argument: str):
assert_root()
artifact = Path(artifact_argument)
acquire_lock()
manifest = None
digest = None
resolved = None
backup_id = None
backup = None
preserved = None
try:
with tempfile.TemporaryDirectory(prefix="nodedc-edge-apply-") as directory:
manifest, _entries, payload, digest, resolved = load_artifact(
artifact,
Path(directory),
)
preserved = preflight(manifest, digest)
backup_id, backup = create_backup(manifest["id"])
publish_payload(payload)
build_relay()
run(compose_command(
"up",
"--detach",
"--no-deps",
"--force-recreate",
"--pull",
"never",
RELAY_SERVICE,
), cwd=LIVE_ROOT, timeout=300, capture=False)
validate_relay_runtime(preserved)
archived = archive_artifact(resolved, APPLIED_ROOT)
write_journal(APPLIED_JOURNAL, {
"status": "ok",
"patch": manifest["id"],
"component": COMPONENT,
"sha256": digest,
"artifact": archived.name,
"backup": backup_id,
"appliedAt": datetime.now(timezone.utc).isoformat(),
})
print(
f"deploy-ok patch={manifest['id']} component={COMPONENT} "
f"backup={backup_id}"
)
except Exception as error:
rollback_status = "not-started"
if backup is not None and preserved is not None:
try:
rollback(backup, preserved)
rollback_status = "ok"
except Exception as rollback_error:
rollback_status = f"failed:{type(rollback_error).__name__}"
if resolved is not None and resolved.exists():
failed_name = (
FAILED_ROOT
/ f"{resolved.name}.{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}"
)
os.replace(resolved, failed_name)
if manifest is not None and digest is not None:
write_journal(FAILED_JOURNAL, {
"status": "failed",
"patch": manifest["id"],
"component": COMPONENT,
"sha256": digest,
"backup": backup_id,
"rollback": rollback_status,
"error": type(error).__name__,
"failedAt": datetime.now(timezone.utc).isoformat(),
})
if rollback_status.startswith("failed"):
die(f"apply failed and rollback failed: {error}")
die(f"apply failed; automatic rollback={rollback_status}: {error}")
finally:
release_lock()
def verify_install():
assert_root()
path = RUNNER_PATH if RUNNER_PATH.exists() else Path(__file__).resolve()
assert_regular_nonsymlink(path, "runner")
docker_version = run([DOCKER, "version", "--format", "{{.Server.Version}}"]).stdout.strip()
compose_version = run([DOCKER, "compose", "version", "--short"]).stdout.strip()
print(f"path={path}")
print(f"sha256={sha256_file(path)}")
print(f"python={sys.version.split()[0]}")
print(f"docker={docker_version}")
print(f"compose={compose_version}")
print(f"device_edge_ingress_ipv4={INGRESS_IPV4}")
print(f"device_edge_ingress_ipv4_approval={INGRESS_IPV4_APPROVAL}")
print("verify-install-ok")
def main(arguments):
if len(arguments) == 1 and arguments[0] == "verify-install":
verify_install()
return 0
if len(arguments) == 2 and arguments[0] == "plan":
plan_artifact(arguments[1])
return 0
if len(arguments) == 2 and arguments[0] == "apply":
apply_artifact(arguments[1])
return 0
print(
"usage: nodedc-edge-deploy verify-install | plan <artifact.tgz> | apply <artifact.tgz>",
file=sys.stderr,
)
return 2
if __name__ == "__main__":
try:
raise SystemExit(main(sys.argv[1:]))
except DeployError as error:
print(f"ERROR: {error}", file=sys.stderr)
raise SystemExit(1)
@@ -0,0 +1,323 @@
#!/usr/bin/env python3
import hashlib
import importlib.machinery
import importlib.util
import json
import os
import subprocess
import tarfile
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
SCRIPT_DIR = Path(__file__).resolve().parent
BUILDER = SCRIPT_DIR / "build-device-edge-ingress-artifact.mjs"
RUNNER_PATH = SCRIPT_DIR / "nodedc-edge-deploy"
def load_runner():
loader = importlib.machinery.SourceFileLoader(
"nodedc_edge_runner_under_test",
str(RUNNER_PATH),
)
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
RUNNER = load_runner()
class DeviceEdgeIngressArtifactTest(unittest.TestCase):
def build(self, artifact_dir, patch_id):
environment = os.environ.copy()
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
return subprocess.run(
["node", str(BUILDER), patch_id],
check=False,
capture_output=True,
text=True,
env=environment,
)
def test_builder_is_deterministic_narrow_and_secret_free(self):
with tempfile.TemporaryDirectory(
prefix="nodedc-device-edge-artifact-",
) as directory:
artifact_dir = Path(directory)
patch_id = "device-edge-ingress-ipvlan-unit-001"
first_result = self.build(artifact_dir, patch_id)
self.assertEqual(first_result.returncode, 0, first_result.stderr)
first = json.loads(first_result.stdout)
first_bytes = Path(first["artifact"]).read_bytes()
second_result = self.build(artifact_dir, patch_id)
self.assertEqual(second_result.returncode, 0, second_result.stderr)
second = json.loads(second_result.stdout)
second_bytes = Path(second["artifact"]).read_bytes()
self.assertEqual(first_bytes, second_bytes)
self.assertEqual(first["sha256"], second["sha256"])
self.assertEqual(
first["sha256"],
hashlib.sha256(first_bytes).hexdigest(),
)
self.assertEqual(first["component"], "device-edge")
self.assertEqual(first["entries"], list(RUNNER.ENTRIES))
self.assertEqual(first["services"], ["device-edge-relay"])
self.assertEqual(
first["ingress"]["ipv4Approval"],
"approved-outside-dhcp-pool",
)
with tarfile.open(first["artifact"], "r:gz") as archive:
members = archive.getmembers()
names = {member.name for member in members}
manifest = archive.extractfile("manifest.env").read().decode()
files = archive.extractfile("files.txt").read().decode().splitlines()
payload_bytes = b"\n".join(
archive.extractfile(member).read()
for member in members
if member.isfile()
)
self.assertEqual(
manifest,
f"id={patch_id}\ncomponent=device-edge\ntype=app-overlay\n",
)
self.assertEqual(files, list(RUNNER.ENTRIES))
self.assertIn(
"payload/docker-compose.device-edge.ingress.yml",
names,
)
self.assertNotIn(b"PRIVATE KEY", payload_bytes)
self.assertFalse(any(
"/test/" in name
or "/secrets/" in name
or "/keys/" in name
or "/trust/" in name
or "/node_modules/" in name
or Path(name).name.startswith(".env")
for name in names
))
def test_production_builder_accepts_the_explicitly_approved_address(self):
with tempfile.TemporaryDirectory(
prefix="nodedc-device-edge-address-gate-",
) as directory:
result = self.build(
Path(directory),
"device-edge-ingress-ipvlan-20260804-001",
)
self.assertEqual(result.returncode, 0, result.stderr)
built = json.loads(result.stdout)
self.assertEqual(
built["ingress"]["ipv4Approval"],
"approved-outside-dhcp-pool",
)
self.assertTrue(Path(built["artifact"]).is_file())
def test_runner_loads_exact_artifact_and_enters_runtime_preflight(self):
with tempfile.TemporaryDirectory(
prefix="nodedc-device-edge-runner-load-",
) as directory:
workspace = Path(directory)
inbox = workspace / "inbox"
inbox.mkdir()
result = self.build(
inbox,
"device-edge-ingress-ipvlan-20260804-002",
)
self.assertEqual(result.returncode, 0, result.stderr)
artifact = Path(json.loads(result.stdout)["artifact"])
extracted = workspace / "extracted"
extracted.mkdir()
old_inbox = RUNNER.INBOX_ROOT
RUNNER.INBOX_ROOT = inbox
try:
manifest, entries, payload, digest, resolved = (
RUNNER.load_artifact(artifact, extracted)
)
finally:
RUNNER.INBOX_ROOT = old_inbox
self.assertEqual(manifest["component"], "device-edge")
self.assertEqual(entries, RUNNER.ENTRIES)
self.assertEqual(resolved, artifact.resolve())
self.assertEqual(digest, hashlib.sha256(artifact.read_bytes()).hexdigest())
self.assertEqual(
json.loads(
(payload / "deployment/device-edge-ingress-ipvlan-v1.json")
.read_text(encoding="utf-8")
),
RUNNER.expected_descriptor(),
)
preserved = {
RUNNER.BACKHAUL_CONTAINER: {"Id": "backhaul"},
RUNNER.TAILNET_CONTAINER: {"Id": "tailnet"},
}
with patch.object(RUNNER, "assert_new_identity"), patch.object(
RUNNER,
"current_source_state",
), patch.object(RUNNER, "validate_predecessor_runtime"), patch.object(
RUNNER,
"preserved_runtime_snapshot",
return_value=preserved,
), patch.object(RUNNER, "validate_host_network_boundary"), patch.object(
RUNNER,
"arp_duplicate_detected",
return_value=False,
), patch.object(
RUNNER,
"run",
return_value=subprocess.CompletedProcess([], 1, "", ""),
):
self.assertEqual(RUNNER.preflight(manifest, digest), preserved)
def test_backup_restore_preserves_exact_predecessor_partition(self):
with tempfile.TemporaryDirectory(
prefix="nodedc-device-edge-backup-",
) as directory:
workspace = Path(directory)
live = workspace / "live"
backups = workspace / "backups"
live.mkdir()
backups.mkdir()
for relative in RUNNER.ENTRIES:
if relative in RUNNER.PREDECESSOR_ABSENT:
continue
target = live / relative
if relative.endswith("/src"):
target.mkdir(parents=True)
(target / "server.mjs").write_text("old\n", encoding="utf-8")
else:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(f"old:{relative}\n", encoding="utf-8")
old_live = RUNNER.LIVE_ROOT
old_backups = RUNNER.BACKUP_ROOT
RUNNER.LIVE_ROOT = live
RUNNER.BACKUP_ROOT = backups
try:
_backup_id, backup = RUNNER.create_backup("unit-backup")
for relative in RUNNER.ENTRIES:
target = live / relative
if target.exists():
if target.is_dir():
import shutil
shutil.rmtree(target)
else:
target.unlink()
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text("candidate\n", encoding="utf-8")
RUNNER.restore_backup(backup)
finally:
RUNNER.LIVE_ROOT = old_live
RUNNER.BACKUP_ROOT = old_backups
for relative in RUNNER.PREDECESSOR_ABSENT:
self.assertFalse((live / relative).exists())
self.assertEqual(
(live / "docker-compose.device-edge.yml").read_text(),
"old:docker-compose.device-edge.yml\n",
)
self.assertEqual(
(live / "services/device-edge-relay/src/server.mjs").read_text(),
"old\n",
)
def test_runner_selection_and_compose_commands_are_exact(self):
self.assertTrue(RUNNER.INGRESS_IPV4_APPROVED)
self.assertEqual(
RUNNER.INGRESS_IPV4_APPROVAL,
"approved-outside-dhcp-pool",
)
self.assertEqual(RUNNER.RELAY_SERVICE, "device-edge-relay")
self.assertEqual(
RUNNER.expected_descriptor()["preservedServices"],
["device-edge-backhaul", "tailnet"],
)
self.assertEqual(
RUNNER.compose_command(
"up",
"--detach",
"--no-deps",
"--force-recreate",
"--pull",
"never",
RUNNER.RELAY_SERVICE,
),
[
RUNNER.DOCKER,
"compose",
"--project-name",
RUNNER.COMPOSE_PROJECT,
"--file",
str(RUNNER.BASE_COMPOSE),
"--file",
str(RUNNER.INGRESS_COMPOSE),
"up",
"--detach",
"--no-deps",
"--force-recreate",
"--pull",
"never",
RUNNER.RELAY_SERVICE,
],
)
self.assertNotIn("down", RUNNER_PATH.read_text(encoding="utf-8"))
def test_preserved_runtime_is_compared_from_one_atomic_snapshot(self):
expected = {
RUNNER.BACKHAUL_CONTAINER: {"Id": "backhaul"},
RUNNER.TAILNET_CONTAINER: {"Id": "tailnet"},
}
with patch.object(
RUNNER,
"preserved_runtime_snapshot",
return_value=expected,
) as snapshot:
RUNNER.assert_preserved_runtime(expected)
snapshot.assert_called_once_with()
def test_network_acceptance_rejects_any_non_ipvlan_substitution(self):
relay = {
"NetworkSettings": {
"Networks": {
"nodedc-device-edge-private": {"IPAddress": "172.18.0.4"},
RUNNER.INGRESS_NETWORK: {"IPAddress": RUNNER.INGRESS_IPV4},
},
},
}
accepted_network = [{
"Driver": "ipvlan",
"Internal": False,
"Options": {
"parent": RUNNER.INGRESS_PARENT,
"ipvlan_mode": "l2",
},
"IPAM": {
"Config": [{
"Subnet": RUNNER.INGRESS_SUBNET,
"Gateway": RUNNER.INGRESS_GATEWAY,
}],
},
}]
with patch.object(RUNNER, "docker_json", return_value=accepted_network):
RUNNER.validate_network_runtime(relay)
rejected_network = json.loads(json.dumps(accepted_network))
rejected_network[0]["Driver"] = "bridge"
with patch.object(RUNNER, "docker_json", return_value=rejected_network):
with self.assertRaisesRegex(
RUNNER.DeployError,
"ingress network driver mismatch",
):
RUNNER.validate_network_runtime(relay)
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -98,6 +98,22 @@ class DevicePlaneB2DiscoveryIngressArtifactTest(unittest.TestCase):
.read()
.decode("utf-8")
)
edge_manifest = json.loads(
archive.extractfile(
"payload/services/device-edge-relay/package.json"
)
.read()
.decode("utf-8")
)
dockerignore = archive.extractfile(
"payload/.dockerignore"
).read().decode("utf-8")
core_dockerfile = archive.extractfile(
"payload/services/device-control-core/Dockerfile"
).read().decode("utf-8")
gateway_dockerfile = archive.extractfile(
"payload/services/device-gateway/Dockerfile"
).read().decode("utf-8")
self.assertEqual(files, first["entries"])
self.assertEqual(
@@ -108,6 +124,24 @@ class DevicePlaneB2DiscoveryIngressArtifactTest(unittest.TestCase):
descriptor,
RUNNER.expected_device_plane_b2_discovery_ingress_descriptor(),
)
self.assertEqual(
edge_manifest["name"],
"@nodedc/device-edge-relay",
)
self.assertIn("**/*.prev-*", dockerignore.splitlines())
self.assertIn("**/*.next-*", dockerignore.splitlines())
for dockerfile in (core_dockerfile, gateway_dockerfile):
self.assertNotIn("COPY packages ./packages", dockerfile)
self.assertIn(
"COPY packages/device-protocol-contract "
"./packages/device-protocol-contract",
dockerfile,
)
self.assertIn(
"COPY packages/arusnavi-b2-adapter "
"./packages/arusnavi-b2-adapter",
dockerfile,
)
def test_compose_opens_only_discovery_tcp_and_preserves_database(self):
compose = COMPOSE.read_text(encoding="utf-8")
@@ -153,6 +187,48 @@ class DevicePlaneB2DiscoveryIngressArtifactTest(unittest.TestCase):
"disabled",
)
def test_ingress_preflight_requires_terminal_recovery_marker(self):
with tempfile.TemporaryDirectory(
prefix="nodedc-device-plane-b2-recovery-barrier-",
) as directory:
workspace = Path(directory)
built = self.build(
workspace,
"device-plane-b2-discovery-ingress-unit-recovery-barrier",
)
extracted = workspace / "extracted"
extracted.mkdir()
_manifest, _entries, payload = RUNNER.load_artifact(
Path(built["artifact"]),
extracted,
)
def has_patch(patch_id):
return (
patch_id
== RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_PREDECESSOR_PATCH_ID
)
with (
mock.patch.object(
RUNNER,
"state_has_patch_id",
side_effect=has_patch,
),
mock.patch.object(
RUNNER,
"state_has_sha",
return_value=True,
),
):
with self.assertRaisesRegex(
RUNNER.DeployError,
"rollback recovery patch is not applied",
):
RUNNER.validate_device_plane_b2_discovery_ingress_evidence(
payload
)
def test_runtime_acceptance_preserves_postgres_and_replaces_stateless(self):
before = {
"schemaVersion": "nodedc.device-plane.runtime-inventory.v1",
@@ -0,0 +1,323 @@
#!/usr/bin/env python3
import hashlib
import importlib.machinery
import importlib.util
import json
import os
import subprocess
import tarfile
import tempfile
import unittest
from pathlib import Path
from unittest import mock
SCRIPT_DIR = Path(__file__).resolve().parent
BUILDER = (
SCRIPT_DIR
/ "build-device-plane-b2-discovery-loopback-recovery-artifact.mjs"
)
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
def load_runner():
loader = importlib.machinery.SourceFileLoader(
"nodedc_device_plane_b2_recovery_runner_under_test",
str(RUNNER_PATH),
)
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
RUNNER = load_runner()
class DevicePlaneB2DiscoveryLoopbackRecoveryArtifactTest(
unittest.TestCase,
):
def build(self, artifact_dir, patch_id):
environment = os.environ.copy()
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
result = subprocess.run(
["node", str(BUILDER), patch_id],
check=True,
capture_output=True,
text=True,
env=environment,
)
return json.loads(result.stdout)
def test_artifact_is_marker_only_exact_and_deterministic(self):
with tempfile.TemporaryDirectory(
prefix="nodedc-device-plane-b2-recovery-artifact-",
) as directory:
artifact_dir = Path(directory)
patch_id = RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_PATCH_ID
first = self.build(artifact_dir, patch_id)
first_bytes = Path(first["artifact"]).read_bytes()
second = self.build(artifact_dir, patch_id)
second_bytes = Path(second["artifact"]).read_bytes()
self.assertEqual(first_bytes, second_bytes)
self.assertEqual(
first["sha256"],
hashlib.sha256(first_bytes).hexdigest(),
)
self.assertEqual(first["build"], [])
self.assertEqual(first["services"], [])
self.assertEqual(
first["entries"],
list(
RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_ENTRIES
),
)
with tarfile.open(first["artifact"], "r:gz") as archive:
names = {
member.name
for member in archive.getmembers()
if member.isfile()
}
descriptor = json.loads(
archive.extractfile(
"payload/"
+ RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_REL
)
.read()
.decode("utf-8")
)
self.assertEqual(
names,
{
"manifest.env",
"files.txt",
"payload/"
+ RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_REL,
},
)
self.assertEqual(
descriptor,
RUNNER.expected_device_plane_b2_discovery_rollback_recovery_descriptor(),
)
entries = (
RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_ENTRIES
)
self.assertEqual(
RUNNER.component_services("device-plane", entries),
(),
)
self.assertEqual(
RUNNER.component_builds("device-plane", entries),
(),
)
def test_recovery_preflight_requires_exact_failed_evidence(self):
with tempfile.TemporaryDirectory(
prefix="nodedc-device-plane-b2-recovery-preflight-",
) as directory:
workspace = Path(directory)
artifacts = workspace / "artifacts"
failed_root = workspace / "failed"
backups_root = workspace / "backups"
state_root = workspace / "state"
temp_root = workspace / "tmp"
live_root = workspace / "live"
for path in (
artifacts,
failed_root,
backups_root,
state_root,
temp_root,
live_root,
):
path.mkdir(parents=True, exist_ok=True)
recovery = self.build(
artifacts,
RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_PATCH_ID,
)
extracted = workspace / "extracted"
extracted.mkdir()
_manifest, entries, payload = RUNNER.load_artifact(
Path(recovery["artifact"]),
extracted,
)
self.assertEqual(
tuple(entries),
RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_ENTRIES,
)
failed_artifact = (
failed_root / RUNNER.DEVICE_PLANE_B2_DISCOVERY_FAILED_ARTIFACT
)
failed_artifact.write_bytes(b"failed-b2-artifact-fixture\n")
failed_sha = RUNNER.DEVICE_PLANE_B2_DISCOVERY_FAILED_ARTIFACT_SHA256
backup = (
backups_root
/ RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_BACKUP_ID
)
backup.mkdir()
backup_names = (
"manifest.env",
"files.txt",
"existing-files.txt",
"missing-files.txt",
"runtime-before.json",
"source-before.tgz",
)
for name in backup_names:
(backup / name).write_text(
f"fixture:{name}\n",
encoding="utf-8",
)
backup_hashes = {
name: hashlib.sha256((backup / name).read_bytes()).hexdigest()
for name in backup_names
}
(state_root / "failed.jsonl").write_text(
json.dumps({
"artifact": RUNNER.DEVICE_PLANE_B2_DISCOVERY_FAILED_ARTIFACT,
"backup_id": (
RUNNER.DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_BACKUP_ID
),
"component": "device-plane",
"id": RUNNER.DEVICE_PLANE_B2_DISCOVERY_FAILED_PATCH_ID,
"message": (
"Command '['/usr/local/bin/docker', 'build', "
"'--no-cache', '--network=host', '-f', "
"'services/device-control-core/Dockerfile', '-t', "
"'nodedc/device-control-core:local', '.']' returned "
"non-zero exit status 1."
),
"rollback_status": "failed:CalledProcessError",
"sha256": failed_sha,
"started_apply": True,
"status": "failed",
})
+ "\n",
encoding="utf-8",
)
runtime = {"accepted": True}
failed_manifest = {
"id": RUNNER.DEVICE_PLANE_B2_DISCOVERY_FAILED_PATCH_ID,
"component": "device-plane",
"type": "app-overlay",
}
with (
mock.patch.object(RUNNER, "BACKUPS_DIR", backups_root),
mock.patch.object(RUNNER, "FAILED_DIR", failed_root),
mock.patch.object(
RUNNER,
"FAILED_STATE_FILE",
state_root / "failed.jsonl",
),
mock.patch.object(RUNNER, "TMP_DIR", temp_root),
mock.patch.object(
RUNNER,
"component_root",
return_value=live_root,
),
mock.patch.object(
RUNNER,
"DEVICE_PLANE_B2_DISCOVERY_ROLLBACK_RECOVERY_BACKUP_SHA256",
backup_hashes,
),
mock.patch.object(
RUNNER,
"sha256_file",
side_effect=lambda path: (
failed_sha
if Path(path) == failed_artifact
else hashlib.sha256(Path(path).read_bytes()).hexdigest()
),
),
mock.patch.object(
RUNNER,
"load_artifact",
return_value=(
failed_manifest,
list(RUNNER.DEVICE_PLANE_B2_DISCOVERY_FAILED_ENTRIES),
workspace / "unused-payload",
),
),
mock.patch.object(
RUNNER,
"validate_device_plane_foundation_network_publication_installed_source",
),
mock.patch.object(
RUNNER,
"validate_device_plane_foundation_runtime",
return_value=runtime,
),
mock.patch.object(RUNNER, "assert_loopback_tcp_port_closed"),
):
evidence = (
RUNNER.validate_device_plane_b2_discovery_rollback_recovery_evidence(
payload
)
)
self.assertEqual(
evidence["mode"],
"failed-b2-loopback-build-reconciliation",
)
self.assertEqual(evidence["runtime"], runtime)
def test_build_failure_does_not_mark_runtime_started(self):
marker = mock.Mock()
entries = RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_ENTRIES
services = ("device-control-core", "device-gateway")
failure = subprocess.CalledProcessError(1, ["docker", "build"])
with (
mock.patch.object(RUNNER, "run_build", side_effect=failure),
mock.patch.object(RUNNER, "prepare_component_runtime") as prepare,
mock.patch.object(RUNNER, "run_compose") as compose,
):
with self.assertRaises(subprocess.CalledProcessError):
RUNNER.run_device_plane_runtime_for_apply(
entries,
services,
marker,
)
marker.assert_not_called()
prepare.assert_not_called()
compose.assert_not_called()
def test_compose_failure_is_marked_after_build_and_prepare(self):
events = []
def mark():
events.append("mark")
with (
mock.patch.object(
RUNNER,
"run_build",
side_effect=lambda *_args: events.append("build"),
),
mock.patch.object(
RUNNER,
"prepare_component_runtime",
side_effect=lambda *_args: events.append("prepare"),
),
mock.patch.object(
RUNNER,
"run_compose",
side_effect=RuntimeError("compose failed"),
),
):
with self.assertRaisesRegex(RuntimeError, "compose failed"):
RUNNER.run_device_plane_runtime_for_apply(
RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_ENTRIES,
("device-control-core", "device-gateway"),
mark,
)
self.assertEqual(events, ["build", "prepare", "mark"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,649 @@
#!/usr/bin/env python3
import base64
import hashlib
import importlib.machinery
import importlib.util
import json
import os
import subprocess
import tarfile
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
SCRIPT_DIR = Path(__file__).resolve().parent
BUILDER = SCRIPT_DIR / "build-device-plane-backhaul-target-artifact.mjs"
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
COMPOSE = (
SCRIPT_DIR.parent.parent
/ "device-plane/docker-compose.device-plane.backhaul-target.yml"
)
PREDECESSOR_COMPOSE = (
SCRIPT_DIR.parent.parent / "device-plane/docker-compose.device-plane.yml"
)
SSHD_CONFIG = (
SCRIPT_DIR.parent.parent
/ "device-plane/services/device-backhaul-target/sshd_config"
)
def load_runner():
loader = importlib.machinery.SourceFileLoader(
"nodedc_device_plane_backhaul_runner_under_test",
str(RUNNER_PATH),
)
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
RUNNER = load_runner()
def valid_public_key(comment="nodedc-device-edge-backhaul"):
blob = b"\x00\x00\x00\x0bssh-ed25519\x00\x00\x00\x20" + bytes(range(32))
return f"ssh-ed25519 {base64.b64encode(blob).decode()} {comment}\n"
def healthy_inventory():
return {
"schemaVersion": "nodedc.device-plane.runtime-inventory.v1",
"composeProject": "nodedc-device-plane",
"services": [
{
"service": service,
"containerId": character * 64,
"imageId": f"sha256:{character * 64}",
"status": "running",
"running": True,
"health": "healthy",
"restartCount": 0,
}
for service, character in (
("device-control-core", "a"),
("device-gateway", "b"),
("device-postgres", "c"),
)
],
}
class DevicePlaneBackhaulTargetArtifactTest(unittest.TestCase):
def build(self, artifact_dir, patch_id):
environment = os.environ.copy()
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
result = subprocess.run(
["node", str(BUILDER), patch_id],
check=True,
capture_output=True,
text=True,
env=environment,
)
return json.loads(result.stdout)
def test_artifact_is_exact_deterministic_and_contains_no_keys(self):
with tempfile.TemporaryDirectory(
prefix="nodedc-device-plane-backhaul-artifact-",
) as directory:
target = Path(directory)
first = self.build(target, "device-plane-backhaul-target-unit-001")
first_bytes = Path(first["artifact"]).read_bytes()
second = self.build(target, "device-plane-backhaul-target-unit-001")
second_bytes = Path(second["artifact"]).read_bytes()
self.assertEqual(first_bytes, second_bytes)
self.assertEqual(first["sha256"], hashlib.sha256(first_bytes).hexdigest())
self.assertEqual(
first["entries"],
list(RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_ENTRIES),
)
self.assertEqual(first["services"], ["device-backhaul-target"])
with tarfile.open(first["artifact"], "r:gz") as archive:
names = archive.getnames()
files = archive.extractfile("files.txt").read().decode().splitlines()
descriptor = json.loads(
archive.extractfile(
"payload/deployment/"
"device-plane-backhaul-target-tailnet-serve-v1.json"
).read()
)
self.assertEqual(files, first["entries"])
self.assertEqual(
descriptor,
RUNNER.expected_device_plane_backhaul_target_descriptor(),
)
self.assertFalse(any(
name.endswith((".key", ".pem", "authorized_keys"))
for name in names
))
def test_registry_selects_only_target_and_preserves_red_boundaries(self):
entries = RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_ENTRIES
self.assertEqual(
RUNNER.component_services("device-plane", entries),
("device-backhaul-target",),
)
builds = RUNNER.component_builds("device-plane", entries)
self.assertEqual(len(builds), 1)
self.assertIn("services/device-backhaul-target/Dockerfile", builds[0][1])
self.assertIn(RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_IMAGE, builds[0][1])
for path in (
RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_COMPOSE_REL,
"services/device-backhaul-target/Dockerfile",
"services/device-backhaul-target/sshd_config",
RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_REL,
):
self.assertTrue(RUNNER.allowed_payload_path("device-plane", path))
compose = COMPOSE.read_text(encoding="utf-8")
sshd = SSHD_CONFIG.read_text(encoding="utf-8")
self.assertIn("network_mode: host", compose)
self.assertNotIn("0.0.0.0:2222", compose)
self.assertIn('"127.0.0.1", "2222"', compose)
self.assertIn("ListenAddress 127.0.0.1", sshd)
self.assertIn("AllowTcpForwarding local", sshd)
self.assertIn("PermitOpen 127.0.0.1:9921", sshd)
self.assertIn("ForceCommand /bin/false", sshd)
self.assertIn("PasswordAuthentication no", sshd)
def test_preflight_requires_exact_applied_006_and_enrollment_key(self):
with tempfile.TemporaryDirectory(
prefix="nodedc-device-plane-backhaul-preflight-",
) as directory:
work = Path(directory)
built = self.build(work, "device-plane-backhaul-target-unit-002")
extracted = work / "extracted"
extracted.mkdir()
_manifest, _entries, payload = RUNNER.load_artifact(
Path(built["artifact"]),
extracted,
)
live = work / "live"
live.mkdir()
(live / "docker-compose.device-plane.yml").write_bytes(
PREDECESSOR_COMPOSE.read_bytes()
)
descriptor = live / RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_REL
descriptor.parent.mkdir(parents=True)
descriptor.write_text(
json.dumps(RUNNER.expected_device_plane_b2_discovery_ingress_descriptor()),
encoding="utf-8",
)
enrollment = work / "device-edge-backhaul.pub"
enrollment.write_text(valid_public_key(), encoding="ascii")
def has_patch(value):
return value == RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_PREDECESSOR_PATCH_ID
def has_sha(value):
return value == RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_PREDECESSOR_ARTIFACT_SHA256
with (
mock.patch.object(RUNNER, "component_root", return_value=live),
mock.patch.object(
RUNNER,
"DEVICE_PLANE_BACKHAUL_ENROLLMENT_PUBLIC_KEY_FILE",
enrollment,
),
mock.patch.object(RUNNER, "state_has_patch_id", side_effect=has_patch),
mock.patch.object(RUNNER, "state_has_sha", side_effect=has_sha),
mock.patch.object(
RUNNER,
"device_plane_service_container_ids",
return_value=[],
),
mock.patch.object(
RUNNER,
"device_plane_runtime_inventory",
return_value=healthy_inventory(),
),
mock.patch.object(RUNNER, "assert_loopback_tcp_port_open"),
mock.patch.object(
RUNNER,
"validate_device_plane_backhaul_failed_evidence",
return_value={
"backup": work / "failed-backup",
"failedArtifact": work / "failed-artifact.tgz",
},
),
mock.patch.object(
RUNNER,
"validate_device_plane_tailscale_cli",
return_value={
"binary": str(RUNNER.DEVICE_PLANE_TAILSCALE),
"uid": 1024,
"gid": 1024,
"binarySha256": "d" * 64,
},
),
mock.patch.object(
RUNNER,
"validate_device_plane_tailscale_runtime",
return_value={
"self": {
"Online": True,
"TailscaleIPs": ["100.109.216.21"],
},
"serve": {},
},
),
):
accepted = RUNNER.validate_device_plane_backhaul_target_evidence(payload)
self.assertEqual(
accepted["mode"],
"failed-backhaul-target-to-loopback-tailnet-serve",
)
self.assertRegex(accepted["enrollmentPublicKeySha256"], r"^[a-f0-9]{64}$")
self.assertEqual(accepted["tailscaleServeBefore"], {})
self.assertEqual(accepted["tailscaleCli"]["uid"], 1024)
def test_registered_health_gate_checks_preserved_and_target_services(self):
with mock.patch.object(RUNNER, "healthcheck_compose_service") as health:
RUNNER.run_healthchecks(
"device-plane",
RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_ENTRIES,
(RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,),
)
self.assertEqual(
[call.args[1] for call in health.call_args_list],
[
"device-control-core",
"device-gateway",
"device-postgres",
"device-backhaul-target",
],
)
def test_candidate_rollback_removes_only_target_and_preserves_runtime(self):
runtime = healthy_inventory()
with (
mock.patch.object(
RUNNER,
"read_backup_path_list",
side_effect=[[], list(RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_ENTRIES)],
),
mock.patch.object(
RUNNER,
"validate_backup_partition",
return_value=(set(), set(RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_ENTRIES)),
),
mock.patch.object(
RUNNER,
"read_strict_json",
side_effect=[runtime, {}],
),
mock.patch.object(
RUNNER,
"disable_device_plane_tailscale_serve",
) as disable_serve,
mock.patch.object(RUNNER, "stop_and_remove_compose_services") as stop,
mock.patch.object(RUNNER, "restore_platform_overlay", return_value=3),
mock.patch.object(
RUNNER,
"device_plane_service_container_ids",
return_value=[],
),
mock.patch.object(
RUNNER,
"device_plane_runtime_inventory",
return_value=runtime,
),
mock.patch.object(
RUNNER,
"validate_device_plane_tailscale_runtime",
return_value={"serve": {}},
),
mock.patch.object(RUNNER, "assert_loopback_tcp_port_open") as port,
):
result = RUNNER.rollback_device_plane_apply(
Path("/unused/live"),
Path("/unused/backup"),
RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_ENTRIES,
"20260803-000000",
True,
(RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,),
)
self.assertEqual(
result,
"tailscale-serve-restored-source-restored-target-removed-"
"preserved-runtime-unchanged:3",
)
stop.assert_called_once_with(
"device-plane",
(RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,),
)
disable_serve.assert_called_once_with({})
port.assert_called_once_with(9921)
def test_tailscale_serve_port_parser_rejects_funnel_and_nested_collision(self):
clean = {"TCP": {"443": {"HTTPS": True}}}
self.assertEqual(
RUNNER.device_plane_tailscale_handlers_for_port(clean, 2222),
[],
)
active = {
"TCP": {"2222": {"TCPForward": "127.0.0.1:2222"}},
"Foreground": {
"session": {
"TCP": {"443": {"HTTPS": True}},
},
},
}
self.assertEqual(
RUNNER.device_plane_tailscale_handlers_for_port(active, 2222),
[((), {"TCPForward": "127.0.0.1:2222"})],
)
self.assertFalse(
RUNNER.device_plane_tailscale_funnel_uses_port(active, 2222)
)
active["AllowFunnel"] = {"edge.example.ts.net:2222": True}
self.assertTrue(
RUNNER.device_plane_tailscale_funnel_uses_port(active, 2222)
)
def test_runtime_activation_enables_private_tailscale_serve_after_health(self):
calls = []
with (
mock.patch.object(RUNNER, "run_build", side_effect=lambda *a: calls.append("build")),
mock.patch.object(
RUNNER,
"prepare_component_runtime",
side_effect=lambda *a: calls.append("prepare"),
),
mock.patch.object(
RUNNER,
"run_compose",
side_effect=lambda *a: calls.append("compose"),
),
mock.patch.object(
RUNNER,
"healthcheck_compose_service",
side_effect=lambda *a: calls.append("health"),
),
mock.patch.object(
RUNNER,
"enable_device_plane_tailscale_serve",
side_effect=lambda *a: calls.append("serve"),
) as enable,
):
RUNNER.run_device_plane_runtime_for_apply(
RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_ENTRIES,
(RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,),
lambda: calls.append("started"),
backhaul_serve_before={"TCP": {"443": {"HTTPS": True}}},
)
self.assertEqual(
calls,
["build", "prepare", "started", "compose", "health", "serve"],
)
enable.assert_called_once_with({"TCP": {"443": {"HTTPS": True}}})
def test_tailscale_serve_enable_and_disable_preserve_unrelated_routes(self):
before = {"TCP": {"443": {"HTTPS": True}}}
active = {
"TCP": {
"443": {"HTTPS": True},
"2222": {"TCPForward": "127.0.0.1:2222"},
},
}
with (
mock.patch.object(
RUNNER,
"validate_device_plane_tailscale_runtime",
side_effect=[{"serve": before}, {"serve": active}],
),
mock.patch.object(RUNNER, "run_device_plane_tailscale") as run,
):
result = RUNNER.enable_device_plane_tailscale_serve(before)
self.assertEqual(result, active)
self.assertEqual(
run.call_args.args[0],
[
"serve",
"--bg",
"--yes",
"--tcp=2222",
"tcp://127.0.0.1:2222",
],
)
with (
mock.patch.object(
RUNNER,
"read_device_plane_tailscale_json",
return_value=active,
),
mock.patch.object(
RUNNER,
"validate_device_plane_tailscale_runtime",
return_value={"serve": before},
),
mock.patch.object(RUNNER, "run_device_plane_tailscale") as run,
):
changed = RUNNER.disable_device_plane_tailscale_serve(before)
self.assertTrue(changed)
self.assertEqual(
run.call_args.args[0],
[
"serve",
"--tcp=2222",
"off",
],
)
def test_tailscale_cli_runs_as_official_package_account(self):
context = {
"binary": "/var/packages/Tailscale/target/bin/tailscale",
"uid": 1051,
"gid": 1051,
"binarySha256": "e" * 64,
}
with (
mock.patch.object(
RUNNER,
"validate_device_plane_tailscale_cli",
return_value=context,
),
mock.patch.object(RUNNER.subprocess, "run") as run,
mock.patch.object(
RUNNER,
"device_plane_tailscale_drop_privileges",
return_value="drop-to-package-account",
) as drop,
):
RUNNER.run_device_plane_tailscale(
["status", "--json"],
check=False,
capture_output=True,
text=True,
)
self.assertEqual(
run.call_args.args[0],
[context["binary"], "status", "--json"],
)
self.assertEqual(
run.call_args.kwargs["preexec_fn"],
"drop-to-package-account",
)
drop.assert_called_once_with(1051, 1051)
def test_tailscale_cli_accepts_package_owned_binary_without_root_execution(self):
privilege_path = mock.MagicMock()
privilege_path.__str__.return_value = (
"/var/packages/Tailscale/conf/privilege"
)
privilege_path.lstat.return_value = SimpleNamespace(
st_mode=RUNNER.stat.S_IFREG | 0o644,
st_uid=0,
)
binary_path = mock.MagicMock()
binary_path.__str__.return_value = (
"/var/packages/Tailscale/target/bin/tailscale"
)
binary_path.lstat.return_value = SimpleNamespace(
st_mode=RUNNER.stat.S_IFREG | 0o755,
st_uid=1051,
st_gid=1051,
st_size=32 * 1024 * 1024,
)
account = SimpleNamespace(pw_uid=1051, pw_gid=1051)
group = SimpleNamespace(gr_gid=1051)
help_result = SimpleNamespace(
stdout="--tcp --bg --yes",
stderr="",
)
with (
mock.patch.object(
RUNNER,
"DEVICE_PLANE_TAILSCALE_PRIVILEGE",
privilege_path,
),
mock.patch.object(
RUNNER,
"DEVICE_PLANE_TAILSCALE",
binary_path,
),
mock.patch.object(
RUNNER,
"read_strict_json",
return_value={
"defaults": {"run-as": "package"},
"username": "tailscale",
"groupname": "tailscale",
},
),
mock.patch.object(RUNNER.pwd, "getpwnam", return_value=account),
mock.patch.object(RUNNER.grp, "getgrnam", return_value=group),
mock.patch.object(
RUNNER,
"sha256_file",
return_value="f" * 64,
),
mock.patch.object(
RUNNER.subprocess,
"run",
return_value=help_result,
) as run,
):
context = RUNNER.validate_device_plane_tailscale_cli()
self.assertEqual(context["uid"], 1051)
self.assertEqual(context["gid"], 1051)
self.assertEqual(context["binarySha256"], "f" * 64)
self.assertTrue(callable(run.call_args.kwargs["preexec_fn"]))
def test_failed_001_evidence_is_exact_and_terminal(self):
with tempfile.TemporaryDirectory(
prefix="nodedc-device-plane-backhaul-failed-evidence-",
) as directory:
root = Path(directory)
backups = root / "backups"
failed = root / "failed"
state = root / "state"
tmp = root / "tmp"
for path in (backups, failed, state, tmp):
path.mkdir()
backup = backups / RUNNER.DEVICE_PLANE_BACKHAUL_FAILED_BACKUP_ID
backup.mkdir()
backup_hashes = {}
for name in (
"manifest.env",
"files.txt",
"source-before.tgz",
"existing-files.txt",
"missing-files.txt",
"runtime-before.json",
):
payload = f"fixture:{name}\n".encode()
(backup / name).write_bytes(payload)
backup_hashes[name] = hashlib.sha256(payload).hexdigest()
stage = root / "failed-stage"
payload = stage / "payload"
service = payload / "services/device-backhaul-target"
deployment = payload / "deployment"
service.mkdir(parents=True)
deployment.mkdir(parents=True)
(stage / "manifest.env").write_text(
"id=device-plane-backhaul-target-20260803-001\n"
"component=device-plane\n"
"type=app-overlay\n",
encoding="utf-8",
)
(stage / "files.txt").write_text(
"\n".join(RUNNER.DEVICE_PLANE_BACKHAUL_FAILED_TARGET_ENTRIES)
+ "\n",
encoding="utf-8",
)
(payload / RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_COMPOSE_REL).write_text(
"services: {}\n",
encoding="utf-8",
)
(service / "Dockerfile").write_text(
"FROM scratch\n",
encoding="utf-8",
)
(service / "sshd_config").write_text(
"PasswordAuthentication no\n",
encoding="utf-8",
)
(payload / RUNNER.DEVICE_PLANE_BACKHAUL_FAILED_TARGET_REL).write_text(
json.dumps(
RUNNER.expected_failed_device_plane_backhaul_target_descriptor()
)
+ "\n",
encoding="utf-8",
)
failed_artifact = failed / RUNNER.DEVICE_PLANE_BACKHAUL_FAILED_ARTIFACT
with tarfile.open(failed_artifact, "w:gz") as archive:
for name in ("manifest.env", "files.txt", "payload"):
archive.add(stage / name, arcname=name)
failed_sha = hashlib.sha256(failed_artifact.read_bytes()).hexdigest()
failed_state = state / "failed.jsonl"
failed_state.write_text(
json.dumps({
"artifact": RUNNER.DEVICE_PLANE_BACKHAUL_FAILED_ARTIFACT,
"backup_id": RUNNER.DEVICE_PLANE_BACKHAUL_FAILED_BACKUP_ID,
"component": "device-plane",
"id": RUNNER.DEVICE_PLANE_BACKHAUL_FAILED_PATCH_ID,
"message": RUNNER.DEVICE_PLANE_BACKHAUL_FAILED_MESSAGE,
"rollback_status": (
"ok:device-plane-overlay:source-restored-target-removed-"
"preserved-runtime-unchanged:3"
),
"sha256": failed_sha,
"started_apply": True,
"status": "failed",
})
+ "\n",
encoding="utf-8",
)
with (
mock.patch.object(RUNNER, "BACKUPS_DIR", backups),
mock.patch.object(RUNNER, "FAILED_DIR", failed),
mock.patch.object(RUNNER, "FAILED_STATE_FILE", failed_state),
mock.patch.object(RUNNER, "TMP_DIR", tmp),
mock.patch.object(
RUNNER,
"DEVICE_PLANE_BACKHAUL_FAILED_BACKUP_SHA256",
backup_hashes,
),
mock.patch.object(
RUNNER,
"DEVICE_PLANE_BACKHAUL_FAILED_ARTIFACT_SHA256",
failed_sha,
),
):
evidence = RUNNER.validate_device_plane_backhaul_failed_evidence()
self.assertEqual(evidence["backup"], backup)
self.assertEqual(evidence["failedArtifact"], failed_artifact)
if __name__ == "__main__":
unittest.main()