160 changed files with 12260 additions and 28921 deletions
@@ -1 +0,0 @@
{"schemaVersion":"nodedc.mission-core-map-access.v1","state":"enabled"}
+12
View File
@@ -0,0 +1,12 @@
.git
.DS_Store
.env
.env.*
docs
node_modules
**/test
**/*.log
**/*.prev-*
**/*.next-*
runtime
secrets
+63
View File
@@ -0,0 +1,63 @@
# NDC Device Plane
`device-plane` is the provider-neutral runtime boundary for physical devices.
It is intentionally separate from Foundry, Engine L2, External Data Plane and
the preserved Gelios integration.
Recorded runtime status: the private Synology foundation and the isolated Mini
IPvlan relay are running through their reviewed artifacts. Synology has no
public device listener; the Mini relay is LAN-addressable only and router/NAT
exposure remains a separate manual gate. The admission-gate source below is
prepared and staged, but has not yet been applied.
- `packages/device-protocol-contract` owns safe discovery and presentation
contracts.
- `packages/arusnavi-b2-adapter` owns the first model-profile evidence and a
fail-closed framing boundary.
- `services/device-control-core` owns the initial PostgreSQL schema, health
boundary and disabled-by-default quarantine ingest.
- `services/device-gateway` owns a disabled-by-default, loopback-only TCP
evidence listener that sends no bytes and extracts no identifier until the
official framing contract is known.
- `docker-compose.device-plane.yml` publishes only loopback health endpoints
and keeps the raw TCP listener unpublished.
- No device command can be built or sent.
- No real IMEI, ICCID, password, packet or provider credential is stored in
this source tree.
The planned runtime services are:
- `device-control-core`: contours, discoveries, devices, bindings, policy and
audit;
- `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 staged admission update keeps the relay
opaque but requires a public IPv4 source and bounds its source table and bytes
per direction; it does not enable router/NAT exposure.
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.
Run the foundation tests:
```bash
npm test
```
See [IMPLEMENTATION_BASELINE.md](docs/IMPLEMENTATION_BASELINE.md) for the
placement, security and rollout contract.
The canonical runner registry and deterministic artifact builder live in
`../infra/deploy-runner`. The runner must be separately promoted and verified
before any Device Plane artifact is staged or planned.
@@ -0,0 +1,39 @@
{
"schemaVersion": "nodedc.device-edge.admission-gate.v1",
"mode": "single-nic-ipvlan-b2-relay-only",
"runtimeHost": "ndcmini12",
"component": "device-edge",
"selectedServices": [
"device-edge-relay"
],
"preservedServices": [
"device-edge-backhaul",
"tailnet"
],
"composeProject": "nodedc-device-edge",
"composeFiles": [
"docker-compose.device-edge.yml",
"docker-compose.device-edge.ingress.yml"
],
"parentInterface": "enp1s0f0",
"lanSubnet": "192.168.68.0/22",
"lanGateway": "192.168.68.1",
"ingressIpv4": "192.168.71.253",
"ingressIpv4Approval": "approved-outside-dhcp-pool",
"ingressNetwork": "nodedc-device-edge-ingress",
"deviceTcpListen": "192.168.71.253:9921",
"hostPortPublication": "disabled",
"healthPublication": "disabled",
"privateUpstream": "device-edge-backhaul:19921",
"sourceAdmission": "public-ipv4-only",
"maxTrackedSourceAddresses": 2048,
"maxBytesPerDirection": 262144,
"protocolInspection": "gateway-owned",
"identityTrust": "claimed-not-ownership-proof",
"discoveryLifecycle": "quarantine",
"commandTransport": "disabled",
"gelios": "untouched",
"amneziaHostFullTunnel": "preserved",
"routerNatFirewall": "separate-manual-gate",
"rollback": "restore-reviewed-ipvlan-predecessor-without-network-or-router-mutation"
}
@@ -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,30 @@
{
"schemaVersion": "nodedc.device-plane.b2-discovery-ingress.v1",
"mode": "verified-b2-loopback-discovery-only",
"predecessorPatchId": "device-plane-foundation-network-publication-20260725-003",
"predecessorArtifactSha256": "6fdd5a12c310786db1753882fc1378184fe378d2cc533633a8c73c951521b7bf",
"sourceAction": "publish-verified-b2-loopback-discovery-source",
"runtimeAction": "build-and-recreate-stateless-services",
"selectedServices": [
"device-control-core",
"device-gateway"
],
"preservedServices": [
"device-postgres"
],
"privateNetwork": "nodedc-device-plane-private",
"controlNetwork": "nodedc-device-plane-control",
"publishedPorts": [
"127.0.0.1:18120:18120",
"127.0.0.1:18121:18121",
"127.0.0.1:9921:9921/tcp"
],
"protocolProfile": "arusnavi.b2.internal.v1",
"framingSpecification": "arusnavi.internal.protocol-sheet.gid-12.v1",
"identityTrust": "claimed-not-ownership-proof",
"discoveryLifecycle": "quarantine",
"commandTransport": "disabled",
"gelios": "untouched",
"databaseVolume": "nodedc-device-plane-postgres-data",
"rollback": "restore-source-and-predecessor-stateless-runtime"
}
@@ -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,24 @@
{
"schemaVersion": "nodedc.device-plane.foundation-network-publication.v1",
"mode": "failed-foundation-network-publication-correction",
"failedRecoveryPatchId": "device-plane-foundation-recovery-20260725-002",
"failedRecoveryArtifactSha256": "9183cc385142584bfd12510bb0a3e6b833b2fd26607436f2486a564c628ea1bf",
"failedRecoveryBackupId": "device-plane-device-plane-foundation-recovery-20260725-002-20260725-232447",
"sourceAction": "publish-network-corrected-foundation-source",
"runtimeAction": "recreate-stateless-services-no-build",
"selectedServices": [
"device-control-core",
"device-gateway"
],
"preservedServices": [
"device-postgres"
],
"privateNetwork": "nodedc-device-plane-private",
"controlNetwork": "nodedc-device-plane-control",
"publishedLoopbackPorts": [
"127.0.0.1:18120:18120",
"127.0.0.1:18121:18121"
],
"databaseVolume": "nodedc-device-plane-postgres-data",
"rollback": "restore-partial-source-and-internal-only-stateless-runtime"
}
@@ -0,0 +1,16 @@
{
"schemaVersion": "nodedc.device-plane.foundation-recovery.v1",
"mode": "failed-foundation-live-runtime-adoption",
"failedPatchId": "device-plane-foundation-20260725-001",
"failedArtifactSha256": "23d428de547854ad8b1a026671e2f850386ab0be98bde80f016f1e9db631ee24",
"backupId": "device-plane-device-plane-foundation-20260725-001-20260725-223441",
"sourceAction": "publish-exact-failed-artifact-source",
"runtimeAction": "read-only-acceptance",
"preservedServices": [
"device-control-core",
"device-gateway",
"device-postgres"
],
"databaseVolume": "nodedc-device-plane-postgres-data",
"rollback": "source-only-runtime-unchanged"
}
@@ -0,0 +1,8 @@
{
"schemaVersion": "nodedc.device-plane.postgres-bootstrap.v1",
"service": "device-postgres",
"volume": "nodedc-device-plane-postgres-data",
"mode": "create-if-absent",
"ordinaryApplicationSelection": "forbidden",
"rollbackVolumePolicy": "preserve"
}
@@ -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,35 @@
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_SOURCE_POLICY: public-ipv4-only
DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES: "2048"
DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION: "262144"
DEVICE_EDGE_RELAY_SESSION_TIMEOUT_MS: "10000"
networks:
device-edge-private:
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,44 @@
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_MAX_TRACKED_SOURCE_ADDRESSES: "2048"
DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION: "262144"
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
@@ -0,0 +1,158 @@
services:
device-postgres:
image: postgres:16-alpine
pull_policy: missing
restart: unless-stopped
environment:
POSTGRES_DB: device_plane
POSTGRES_USER: device_plane
POSTGRES_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
volumes:
- type: volume
source: device-plane-postgres-data
target: /var/lib/postgresql/data
- type: bind
source: /volume1/docker/nodedc-device-plane/secrets/postgres-password
target: /run/nodedc-secrets/postgres-password
read_only: true
bind:
create_host_path: false
networks:
- device-plane-private
healthcheck:
test: ["CMD-SHELL", "pg_isready -U device_plane -d device_plane"]
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
device-control-core:
image: nodedc/device-control-core:local
pull_policy: never
restart: unless-stopped
user: "1000:1000"
read_only: true
tmpfs:
- /tmp:size=16m,mode=1777
environment:
HOST: 0.0.0.0
PORT: "18120"
DEVICE_DATABASE_HOST: device-postgres
DEVICE_DATABASE_PORT: "5432"
DEVICE_DATABASE_NAME: device_plane
DEVICE_DATABASE_USER: device_plane
DEVICE_DATABASE_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
DEVICE_DATABASE_POOL_SIZE: "10"
DEVICE_DISCOVERY_INGEST_ENABLED: "true"
DEVICE_GATEWAY_CORE_TOKEN_FILE: /run/nodedc-secrets/gateway-core-token
DEVICE_IDENTIFIER_PEPPER_FILE: /run/nodedc-secrets/identifier-pepper
volumes:
- type: bind
source: /volume1/docker/nodedc-device-plane/secrets/postgres-password
target: /run/nodedc-secrets/postgres-password
read_only: true
bind:
create_host_path: false
- type: bind
source: /volume1/docker/nodedc-device-plane/secrets/gateway-core-token
target: /run/nodedc-secrets/gateway-core-token
read_only: true
bind:
create_host_path: false
- type: bind
source: /volume1/docker/nodedc-device-plane/secrets/identifier-pepper
target: /run/nodedc-secrets/identifier-pepper
read_only: true
bind:
create_host_path: false
ports:
- "127.0.0.1:18120:18120"
networks:
- device-plane-private
- device-plane-control
depends_on:
device-postgres:
condition: service_healthy
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test:
- CMD
- node
- -e
- fetch('http://127.0.0.1:18120/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
device-gateway:
image: nodedc/device-gateway:local
pull_policy: never
restart: unless-stopped
user: "1000:1000"
read_only: true
tmpfs:
- /tmp:size=16m,mode=1777
environment:
DEVICE_GATEWAY_HEALTH_HOST: 0.0.0.0
DEVICE_GATEWAY_HEALTH_PORT: "18121"
DEVICE_GATEWAY_LISTEN_ENABLED: "true"
DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED: "false"
DEVICE_GATEWAY_TCP_HOST: 127.0.0.1
DEVICE_GATEWAY_TCP_PORT: "9921"
DEVICE_GATEWAY_CORE_URL: http://device-control-core:18120
DEVICE_GATEWAY_CORE_TOKEN_FILE: /run/nodedc-secrets/gateway-core-token
DEVICE_GATEWAY_CORE_TIMEOUT_MS: "5000"
DEVICE_GATEWAY_MAX_BUFFERED_BYTES: "65536"
DEVICE_GATEWAY_MAX_SESSIONS: "100"
DEVICE_GATEWAY_MAX_SESSIONS_PER_ADDRESS: "10"
DEVICE_GATEWAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS: "30"
DEVICE_GATEWAY_SESSION_TIMEOUT_MS: "10000"
volumes:
- type: bind
source: /volume1/docker/nodedc-device-plane/secrets/gateway-core-token
target: /run/nodedc-secrets/gateway-core-token
read_only: true
bind:
create_host_path: false
ports:
- "127.0.0.1:18121:18121"
- "127.0.0.1:9921:9921"
networks:
- device-plane-private
- device-plane-control
depends_on:
device-control-core:
condition: service_healthy
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test:
- CMD
- node
- -e
- fetch('http://127.0.0.1:18121/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
interval: 10s
timeout: 5s
retries: 12
start_period: 10s
networks:
device-plane-private:
name: nodedc-device-plane-private
internal: true
device-plane-control:
name: nodedc-device-plane-control
driver: bridge
internal: false
driver_opts:
com.docker.network.bridge.enable_ip_masquerade: "false"
volumes:
device-plane-postgres-data:
name: nodedc-device-plane-postgres-data
@@ -0,0 +1,106 @@
# Device Edge B2 public pilot runbook
Status: router/NAT is **not configured** by this document. It is a one-pilot,
human-operated exposure gate for the already accepted Mini relay. It never
changes Synology, Gelios, VPN, Device Plane command transport or device
ownership.
## Exact traffic path
```text
ARUSNAVI B2 pilot
-> public IPv4 : TCP/9921
-> provider router : TCP/9921 -> 192.168.1.151:9921
-> Deco X55 : TCP/9921 -> 192.168.71.253:9921
-> Mini IPvlan relay
-> restricted private backhaul
-> Synology Gateway 127.0.0.1:9921
```
The observed Deco WAN is `192.168.1.151` with gateway `192.168.0.1`; it is an
RFC1918 address. Therefore this is a double-NAT topology. A Deco rule alone
cannot make the tracker reachable from the internet.
## Immutable safety boundary
- Forward **TCP only**, external and internal port `9921`.
- Deco target is exactly `192.168.71.253`, never the Mini host
`192.168.68.54` and never Synology.
- Do not enable DMZ, UPnP, port ranges, UDP, 443 forwarding or any catch-all
rule.
- Do not remove, replace or edit the Gelios monitoring route.
- The relay accepts connections only from a public IPv4 source, keeps
quarantine-only discovery and has no command transport. A LAN client will
intentionally be rejected; that is not a test failure.
- The rule must be deleted again if the single pilot is abandoned or the
expected quarantine evidence is not obtained.
## Gate 1 — DNS and public address
1. Determine the actual public IPv4 on the provider-facing edge. Do not use
`192.168.0.1`, `192.168.1.151`, `192.168.68.1` or `192.168.71.253` as a
DNS answer: all are private addresses.
2. Point `device.nodedc.ru` to that public IPv4 only if the tracker UI accepts
a hostname. Otherwise configure the public IPv4 directly.
3. If the provider router has no public WAN IPv4, or an internet check still
cannot reach it after both rules below, stop. This is provider CGNAT/bridge
territory: request a public IPv4 or a bridge/forwarding option from the
provider. Do not introduce a VPS as an unreviewed workaround.
## Gate 2 — provider router
On the upstream/provider router, create one port-forward rule:
| Field | Value |
| --- | --- |
| Name | `NDC B2 pilot to Deco` |
| Protocol | `TCP` |
| External port | `9921` |
| Target address | `192.168.1.151` |
| Target port | `9921` |
Save only that rule. It targets the Deco WAN address, not a service host.
## Gate 3 — Deco X55
In the Deco mobile app: **More → Advanced → NAT Forwarding → Port Forwarding →
Add**. Create exactly:
| Field | Value |
| --- | --- |
| Name | `NDC B2 pilot` |
| Protocol | `TCP` |
| External port | `9921` |
| Internal IP | `192.168.71.253` |
| Internal port | `9921` |
If the app forces a client selection, select the IPvlan relay only if its
address is shown as `192.168.71.253`. Do not select the Mini host or any NAS.
If the UI will not accept the fixed IPvlan address, stop and record that fact;
do not substitute a DHCP address.
## Gate 4 — ARUSNAVI B2 route
Use one known pilot B2 and one unused monitoring-server slot. Configure an
additional route with the documented **INTERNAL** protocol and the public
hostname/IP from Gate 1, TCP port `9921`. Preserve the existing Gelios route in
its current slot. The ARUSNAVI account password stays in the operator surface;
it is not entered into Foundry, Device Plane or the relay.
The device IMEI observed in `HEADER2` is a claimed identifier only. It becomes
a quarantine discovery, not an owned device and never a command target.
## Acceptance and failure handling
The first valid HEADER/PACKAGE through the pilot route must produce a masked,
quarantine-only discovery in Device Control Core and the existing Gelios map
path must continue independently. Do not issue a device command.
On any unexpected behavior, remove the two NAT rules in reverse order:
1. remove the Deco `NDC B2 pilot` rule;
2. remove the provider-router `NDC B2 pilot to Deco` rule.
This ends external reachability while leaving the Mini, VPN, backhaul,
Synology and Gelios unchanged. Do not use a LAN port probe as acceptance: the
relay correctly rejects private source addresses.
@@ -0,0 +1,277 @@
# Device Plane Implementation Baseline
Status: PostgreSQL, Control Core and Gateway foundation are running healthy on
Synology. The accepted foundation has public ingress and discovery ingest
disabled. The next additive transition enables only an authenticated,
quarantine-only ARUSNAVI B2 discovery path on raw TCP 9921. Command transport
remains disabled.
## Product boundary
The Device Manager user interface is a canonical Foundry Page Library
template. Foundry owns page instances, layout, presentation and an opaque
`device-plane-control` binding. It does not own device records, credentials,
raw protocol or command delivery.
The independent NDC Device Plane owns physical-device state and direct
connections:
```text
Foundry Device Manager Page
|
| device-plane-control (typed server boundary)
v
Device Control Core <-> Device PostgreSQL
|
v
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.
## Preserved production path
The existing Gelios -> Engine L2 -> External Data Plane -> Foundry Map path is
outside this implementation slice. Its credentials, workflows, Data Products,
bindings and map presentation must not be changed or restarted by a Device
Plane artifact.
The first B2 pilot adds an NDC server route in parallel and keeps the existing
Gelios route unchanged.
## Source and runtime placement
Source:
```text
platform/device-plane/
packages/device-protocol-contract/
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:
```text
/volume1/docker/nodedc-device-plane
```
Planned Compose project and services:
```text
nodedc-device-plane
device-control-core
device-gateway
device-postgres
```
`device-postgres` is a private persistent prerequisite. Application overlays
must never force-recreate it or its volume.
The canonical runner selects only `device-control-core` and `device-gateway`
with `--no-deps`. Its health acceptance is scoped to the selected services and
requires the fail-closed fields to remain disabled. A failed first activation
removes only candidate stateless services and never requests volume removal.
Rollback now records an explicit pre-apply service inventory in the backup;
the existence of the shared Compose file does not imply that Core or Gateway
existed before apply.
The exact foundation recovery validates the failed archive, journal, backup,
partial live source and observed healthy image/container generations. It then
publishes the matching source and performs read-only runtime acceptance. It
does not build, restart, recreate or remove any service.
## Network boundary
The accepted Synology foundation publishes no device port. Device Gateway's
raw B2 listener is reachable only through `127.0.0.1:9921`; its health
endpoints are loopback-only. The only planned external raw-TCP termination is
the dedicated Mini Edge Relay described below.
`device.nodedc.ru` is a DNS name, not an HTTP/TCP mode. The same name may later
serve an HTTPS Control API on 443 and the B2 raw TCP protocol on 9921.
DSM HTTP/HTTPS Reverse Proxy is not a raw TCP ingress and must not be configured
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, a bounded source-rate table and a per-direction byte budget. It emits no
bytes of its own and does not inspect device payloads.
The admission-gate transition is deliberately fail-closed at the relay: an
ingress instance accepts only a syntactically public IPv4 source, limits its
in-memory source table to 2,048 addresses and closes either direction after
262,144 bytes. Private, loopback, link-local, carrier-grade NAT, multicast,
reserved and documentation addresses are rejected before an upstream connection
is made. This is a connection-admission and resource-boundary control, not a
claim that Docker IPvlan traffic is filtered by a host firewall. A raw B2
protocol has no TLS client identity and cellular devices do not offer a stable
source-IP allowlist, so a router/NAT mapping remains prohibited until its
separate exposure and abuse controls are reviewed.
IPvlan deliberately reuses the Mini's one physical parent `enp1s0f0`; a second
Ethernet adapter is not required. The host keeps `192.168.68.54/22` and the
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 public ingress is a separate reviewed operation and requires all of
the following evidence:
1. A distinct, no-shell Synology SSH account and key whose sole permitted open
target is `127.0.0.1:9921`; host-key pinning and a persistent, monitored
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. The artifact
cannot choose an address and never changes router, firewall or DHCP state.
A manual router/NAT rule is a later independent approval, after the relay's
admission gate and external-exposure runbook have been accepted.
4. The host full-tunnel VPN remains active. Before production activation, the
exact single-NIC IPvlan design must pass duplicate-address detection,
gateway reachability, external return-path and private-backhaul checks.
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.
- An unknown connection produces a quarantine-only discovery.
- A discovery never receives commands.
- Pilot claim requires an explicit platform-admin action.
- Production assignment requires authoritative pre-enrollment or an audited
inventory import.
- First-claim-wins by IMEI is forbidden.
The ARUSNAVI Web account login/password is used only by the human operator to
configure the additional device route. It is not a Device Plane credential.
## Protocol evidence
The official B2 material proves:
- four simultaneous monitoring server routes;
- `INTERNAL`, `EXTERNAL`, `USER_AG` and EGTS variants;
- INTERNAL server-side identification by modem IMEI;
- server route fields for DNS/IP, TCP port, protocol and optional ID;
- SMS/TCP command families and a six-digit device access password.
The official ARUSNAVI INTERNAL protocol sheet now provides the first read-path
framing contract:
- HEADER2 for GPRS is `FF 23` followed by an eight-byte little-endian IMEI;
- the server confirms HEADER2 with a bounded `SERVER_COM` carrying Unix time;
- a PACKAGE begins with `5B`, carries a package number in `01..FB`, contains
one or more length-framed PACKET records and ends with `5D`;
- every PACKET checksum is verified before acknowledgement;
- every valid PACKAGE is acknowledged by package number;
- without acknowledgement the tracker repeats the transmission.
The pilot codec implements only that verified read/acknowledgement subset. It
does not decode telemetry tags, export command builders or accept arbitrary
server commands. An IMEI parsed from a valid HEADER2 remains a claimed
identifier and never proves tenant ownership.
## Command boundary
Outbound command transport is disabled in this baseline. No command builder is
exported.
Later lifecycle:
```text
draft -> planned -> awaiting_confirmation -> queued -> dispatched
-> acknowledged | failed | expired | unknown
```
`send` is not success. An `unknown` result forbids automatic retry.
Erase, factory reset, firmware/custom firmware, physical outputs and arbitrary
raw TCP remain forbidden until separate reviewed acceptance slices.
## Implemented local foundation
- Provider-neutral discovery, contour and opaque Foundry-binding contracts.
- B2 model profile with four parallel routes and INTERNAL/IMEI evidence.
- PostgreSQL migration for model profiles, contours, quarantine discoveries,
claimed devices, Foundry bindings and append-only audit events.
- Core health endpoint and an authenticated quarantine-ingest boundary that is
disabled unless explicitly enabled with file-backed secrets.
- Gateway discovery-only HEADER2/PACKAGE state machine with bounded buffers,
handshake timeout, concurrent/per-source session limits and per-source
connection rate limits.
- Authenticated Gateway-to-Core discovery ingest. Core HMAC-hashes the full IMEI
and persists only its digest, masked view and verified framing evidence.
- Only HEADER2 and valid PACKAGE acknowledgements are emitted; no command
builder or command transport is present.
- Recursive rejection of secret-like fields, raw payloads and command-shaped
input in presentation contracts.
- Automated contract, adapter, migration, Core and Gateway tests.
- Additive `component=device-plane` runner registry with exact roots, builds,
services, allowlist/denylist, runner-owned secrets, health contracts and
automatic source/runtime rollback.
- Deterministic data-only artifact builder and positive/negative regression
tests.
- Compose foundation with a private internal network, preserved PostgreSQL
volume, file-backed database password and loopback-only health publishing.
- Exact one-time PostgreSQL bootstrap descriptor, deterministic builder and
absence preflight: an existing database container or volume fails closed,
and rollback never removes the volume.
## Next activation slice
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 the admission-gate update only to `device-edge-relay`; prove exact
IPvlan runtime, no host ports, `public-ipv4-only` admission, byte/source
limits, internal health, private backhaul reachability, unchanged
backhaul/tailnet identities and preserved Amnezia routes. Automatic rollback
restores the reviewed IPvlan predecessor and leaves router state unchanged.
4. Independently review and add the single router/NAT rule for TCP `9921` only,
then verify that Synology still exposes no public device port.
5. Add the NDC route to one approved B2 free server slot while preserving
Gelios, then prove HEADER/discovery/PACKAGE acknowledgement. Claim and tenant
assignment remain a later explicit platform-admin operation.
+30
View File
@@ -0,0 +1,30 @@
# Device Plane baseline test matrix
| Boundary | Required proof |
| --- | --- |
| Restricted identity | IMEI accepts exactly 15 decimal digits internally |
| Browser projection | Safe discovery view contains only a masked identifier |
| Identifier hashing | HMAC digest is deterministic and does not reveal input |
| Secret boundary | Secret-like or raw-payload keys are rejected recursively |
| Command boundary | Discovery contract rejects command-shaped input |
| Framing bound | B2 evidence inspection rejects empty and oversized buffers |
| Framing honesty | Unverified B2 bytes return `official_framing_required` |
| No identifier guessing | Embedded digit sequences are never returned as IMEI |
| Model profile | Four server routes and INTERNAL identification are recorded |
| Gelios preservation | Gelios is a parallel route, not a dependency or failover |
| Core database secret | Production Compose uses a file-backed password, not a plaintext environment value |
| Core health | Database is ready while discovery ingest and command transport remain disabled |
| Gateway health | Public ingress, TCP listener and command transport remain disabled |
| Compose exposure | Only loopback health ports `18120/18121` are published; raw `9921` is not |
| Application service scope | `files.txt` selects only affected Core/Gateway services with `--no-deps` |
| Database preservation | Ordinary application artifacts never select `device-postgres` |
| Database bootstrap | Exact descriptor selects PostgreSQL only when both container and volume are absent |
| Bootstrap rollback | Candidate container may be removed; named volume is never removed |
| Rollback predecessor | Backup records actual pre-apply services; Compose presence cannot invent Core/Gateway |
| Failed-001 evidence | Recovery requires exact failed archive, journal and backup digests |
| Partial source | Recovery accepts only DB-bootstrap source plus the observed healthy foundation runtime |
| Recovery mutation | Source is published without build, restart, recreate or service removal |
| Recovery rollback | Failed acceptance restores source only and leaves runtime unchanged |
| Artifact policy | `.env`, secrets, runtime state, tests, logs and `node_modules` are excluded |
| Artifact reproducibility | Repeated builds for the same patch id are byte-identical |
| Runner compatibility | Existing canonical Platform registry tests remain green |
+223
View File
@@ -0,0 +1,223 @@
{
"name": "@nodedc/device-plane",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@nodedc/device-plane",
"version": "0.1.0",
"workspaces": [
"packages/*",
"services/*"
],
"engines": {
"node": ">=20"
}
},
"node_modules/@nodedc/arusnavi-b2-adapter": {
"resolved": "packages/arusnavi-b2-adapter",
"link": true
},
"node_modules/@nodedc/device-control-core": {
"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
},
"node_modules/@nodedc/device-protocol-contract": {
"resolved": "packages/device-protocol-contract",
"link": true
},
"node_modules/pg": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.15.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"packages/arusnavi-b2-adapter": {
"name": "@nodedc/arusnavi-b2-adapter",
"version": "0.1.0",
"engines": {
"node": ">=20"
}
},
"packages/device-protocol-contract": {
"name": "@nodedc/device-protocol-contract",
"version": "0.1.0",
"engines": {
"node": ">=20"
}
},
"services/device-control-core": {
"name": "@nodedc/device-control-core",
"version": "0.1.0",
"dependencies": {
"pg": "^8.18.0"
},
"engines": {
"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",
"engines": {
"node": ">=20"
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "@nodedc/device-plane",
"version": "0.1.0",
"private": true,
"type": "module",
"workspaces": [
"packages/*",
"services/*"
],
"scripts": {
"test": "node --test packages/*/test/*.test.mjs services/*/test/*.test.mjs"
},
"engines": {
"node": ">=20"
}
}
@@ -0,0 +1,15 @@
{
"name": "@nodedc/arusnavi-b2-adapter",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.mjs"
},
"scripts": {
"test": "node --test test/*.test.mjs"
},
"engines": {
"node": ">=20"
}
}
@@ -0,0 +1,262 @@
export const ARUSNAVI_INTERNAL_SPECIFICATION_REF =
"arusnavi.internal.protocol-sheet.gid-12.v1";
const HEADER2_LENGTH = 10;
const HEADER_START = 0xff;
const HEADER2_GPRS_VERSION = 0x23;
const PACKAGE_START = 0x5b;
const PACKAGE_END = 0x5d;
const SERVER_COMMAND_START = 0x7b;
const SERVER_COMMAND_END = 0x7d;
const MIN_PACKAGE_NUMBER = 0x01;
const MAX_PACKAGE_NUMBER = 0xfb;
const PACKET_FIXED_LENGTH = 8;
const MAX_PACKET_DATA_LENGTH = 32 * 1024;
const MAX_PACKAGE_LENGTH = 64 * 1024;
export const ARUSNAVI_B2_MODEL_PROFILE = deepFreeze({
schemaVersion: "nodedc.device-model-profile.v1",
profileRef: "arusnavi.b2.internal.v1",
vendor: "ARUSNAVI",
model: "B2",
deviceType: "tracker",
protocol: "INTERNAL",
monitoringServerSlots: 4,
serverIdentity: {
kind: "imei",
source: "modem",
trust: "claimed-not-ownership-proof",
},
bootstrap: {
operatorSurface: "ARUSNAVI_WEB_OR_LOCAL_CONFIGURATOR",
platformCredentialRequired: false,
preserveExistingRoutes: true,
},
framing: {
status: "verified-read-only",
specificationRef: ARUSNAVI_INTERNAL_SPECIFICATION_REF,
headerVersion: "HEADER2_GPRS_0x23",
headerBytes: HEADER2_LENGTH,
maxBufferedBytes: MAX_PACKAGE_LENGTH,
},
acknowledgement: {
header: "server-time-only",
package: "package-number-only",
},
commandTransport: {
status: "disabled",
exportedCommandBuilders: 0,
},
routeCompatibility: {
gelios: "parallel-preserved",
automaticCommandFailover: false,
},
});
export function tryParseB2Header2(input) {
assertBuffer(input, "b2_header_buffer_required");
if (input.length < HEADER2_LENGTH) {
return Object.freeze({
status: "incomplete",
minimumBytes: HEADER2_LENGTH,
});
}
if (input[0] !== HEADER_START) {
throw new TypeError("b2_header_start_invalid");
}
if (input[1] !== HEADER2_GPRS_VERSION) {
throw new TypeError("b2_header_version_unsupported");
}
const identifier = input.readBigUInt64LE(2).toString(10);
if (!/^\d{15}$/.test(identifier)) {
throw new TypeError("b2_header_imei_invalid");
}
return Object.freeze({
status: "complete",
bytesConsumed: HEADER2_LENGTH,
identifier: Object.freeze({
kind: "imei",
value: identifier,
trust: "claimed-not-ownership-proof",
}),
evidence: Object.freeze({
transport: "tcp",
bytesObserved: HEADER2_LENGTH,
framingStatus: "verified",
specificationRef: ARUSNAVI_INTERNAL_SPECIFICATION_REF,
}),
});
}
export function buildB2HeaderAcknowledgement(unixSeconds) {
const timestamp = normalizeUInt32(
unixSeconds,
"b2_header_ack_timestamp_invalid",
);
const commandData = Buffer.allocUnsafe(4);
commandData.writeUInt32LE(timestamp);
return Buffer.from([
SERVER_COMMAND_START,
commandData.length,
0x00,
checksum(commandData),
...commandData,
SERVER_COMMAND_END,
]);
}
export function tryParseB2Package(input) {
assertBuffer(input, "b2_package_buffer_required");
if (input.length === 0) {
return Object.freeze({ status: "incomplete", minimumBytes: 1 });
}
if (input[0] !== PACKAGE_START) {
throw new TypeError("b2_package_start_invalid");
}
if (input.length < 3) {
return Object.freeze({ status: "incomplete", minimumBytes: 3 });
}
const packageNumber = input[1];
if (
packageNumber < MIN_PACKAGE_NUMBER
|| packageNumber > MAX_PACKAGE_NUMBER
) {
throw new TypeError("b2_package_number_invalid");
}
let offset = 2;
let packetCount = 0;
while (true) {
if (offset >= MAX_PACKAGE_LENGTH) {
throw new TypeError("b2_package_length_exceeded");
}
if (offset >= input.length) {
return Object.freeze({
status: "incomplete",
minimumBytes: offset + 1,
});
}
if (input[offset] === PACKAGE_END) {
if (packetCount === 0) {
throw new TypeError("b2_package_empty");
}
return Object.freeze({
status: "complete",
bytesConsumed: offset + 1,
packageNumber,
packetCount,
});
}
if (input.length - offset < 3) {
return Object.freeze({
status: "incomplete",
minimumBytes: offset + 3,
});
}
const dataLength = input.readUInt16LE(offset + 1);
if (dataLength > MAX_PACKET_DATA_LENGTH) {
throw new TypeError("b2_packet_data_length_exceeded");
}
const packetLength = PACKET_FIXED_LENGTH + dataLength;
const packetEnd = offset + packetLength;
if (packetEnd + 1 > MAX_PACKAGE_LENGTH) {
throw new TypeError("b2_package_length_exceeded");
}
if (input.length < packetEnd) {
return Object.freeze({
status: "incomplete",
minimumBytes: packetEnd,
});
}
const expectedChecksum = checksum(
input.subarray(offset + 3, packetEnd - 1),
);
if (input[packetEnd - 1] !== expectedChecksum) {
throw new TypeError("b2_packet_checksum_invalid");
}
packetCount += 1;
offset = packetEnd;
}
}
export function buildB2PackageAcknowledgement(packageNumber) {
const normalized = Number(packageNumber);
if (
!Number.isSafeInteger(normalized)
|| normalized < MIN_PACKAGE_NUMBER
|| normalized > MAX_PACKAGE_NUMBER
) {
throw new TypeError("b2_package_ack_number_invalid");
}
return Buffer.from([
SERVER_COMMAND_START,
0x00,
normalized,
SERVER_COMMAND_END,
]);
}
export function assertB2ProfileInvariant(profile = ARUSNAVI_B2_MODEL_PROFILE) {
if (profile.monitoringServerSlots !== 4) {
throw new TypeError("b2_server_slot_count_invalid");
}
if (profile.protocol !== "INTERNAL") {
throw new TypeError("b2_protocol_invalid");
}
if (profile.serverIdentity.kind !== "imei") {
throw new TypeError("b2_identity_kind_invalid");
}
if (profile.framing.status !== "verified-read-only") {
throw new TypeError("b2_framing_must_be_verified");
}
if (
profile.framing.specificationRef
!== ARUSNAVI_INTERNAL_SPECIFICATION_REF
) {
throw new TypeError("b2_framing_specification_invalid");
}
if (profile.commandTransport.status !== "disabled") {
throw new TypeError("b2_command_transport_must_be_disabled");
}
if (profile.routeCompatibility.gelios !== "parallel-preserved") {
throw new TypeError("b2_gelios_route_must_be_preserved");
}
return true;
}
function checksum(input) {
let value = 0;
for (const byte of input) value = (value + byte) & 0xff;
return value;
}
function normalizeUInt32(value, errorCode) {
const normalized = Number(value);
if (
!Number.isSafeInteger(normalized)
|| normalized < 0
|| normalized > 0xffffffff
) {
throw new TypeError(errorCode);
}
return normalized;
}
function assertBuffer(input, errorCode) {
if (!Buffer.isBuffer(input)) {
throw new TypeError(errorCode);
}
}
function deepFreeze(value) {
if (!value || typeof value !== "object" || Object.isFrozen(value)) {
return value;
}
Object.values(value).forEach(deepFreeze);
return Object.freeze(value);
}
@@ -0,0 +1,127 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
ARUSNAVI_B2_MODEL_PROFILE,
ARUSNAVI_INTERNAL_SPECIFICATION_REF,
assertB2ProfileInvariant,
buildB2HeaderAcknowledgement,
buildB2PackageAcknowledgement,
tryParseB2Header2,
tryParseB2Package,
} from "../src/index.mjs";
const specificationHeader = Buffer.from(
"FF23E9EF782DE7120300",
"hex",
);
const specificationPackage = Buffer.from(
"5B01010000FBDEC251EC5D",
"hex",
);
test("records the official B2 route, framing and identity evidence", () => {
assert.equal(assertB2ProfileInvariant(), true);
assert.equal(ARUSNAVI_B2_MODEL_PROFILE.monitoringServerSlots, 4);
assert.equal(ARUSNAVI_B2_MODEL_PROFILE.protocol, "INTERNAL");
assert.equal(ARUSNAVI_B2_MODEL_PROFILE.serverIdentity.kind, "imei");
assert.equal(
ARUSNAVI_B2_MODEL_PROFILE.framing.specificationRef,
ARUSNAVI_INTERNAL_SPECIFICATION_REF,
);
assert.equal(
ARUSNAVI_B2_MODEL_PROFILE.routeCompatibility.gelios,
"parallel-preserved",
);
});
test("parses the official HEADER2 example as a claimed IMEI", () => {
assert.equal(
tryParseB2Header2(specificationHeader.subarray(0, 9)).status,
"incomplete",
);
const parsed = tryParseB2Header2(specificationHeader);
assert.equal(parsed.status, "complete");
assert.equal(parsed.bytesConsumed, 10);
assert.equal(parsed.identifier.kind, "imei");
assert.equal(parsed.identifier.value, "865209039777769");
assert.equal(parsed.identifier.trust, "claimed-not-ownership-proof");
assert.deepEqual(parsed.evidence, {
transport: "tcp",
bytesObserved: 10,
framingStatus: "verified",
specificationRef: ARUSNAVI_INTERNAL_SPECIFICATION_REF,
});
});
test("builds the official HEADER2 acknowledgement example", () => {
assert.equal(
buildB2HeaderAcknowledgement(0x52db95de).toString("hex").toUpperCase(),
"7B0400A0DE95DB527D",
);
});
test("parses and acknowledges the official package example", () => {
assert.equal(
tryParseB2Package(specificationPackage.subarray(0, -1)).status,
"incomplete",
);
assert.deepEqual(tryParseB2Package(specificationPackage), {
status: "complete",
bytesConsumed: specificationPackage.length,
packageNumber: 1,
packetCount: 1,
});
assert.equal(
buildB2PackageAcknowledgement(1).toString("hex").toUpperCase(),
"7B00017D",
);
});
test("uses packet lengths and checksum instead of scanning for 0x5D", () => {
const packetData = Buffer.from([0x5d]);
const unixTime = Buffer.from([0x01, 0x00, 0x00, 0x00]);
const checksum = (0x01 + 0x5d) & 0xff;
const packageBytes = Buffer.from([
0x5b,
0x02,
0x01,
packetData.length,
0x00,
...unixTime,
...packetData,
checksum,
0x5d,
]);
assert.deepEqual(tryParseB2Package(packageBytes), {
status: "complete",
bytesConsumed: packageBytes.length,
packageNumber: 2,
packetCount: 1,
});
});
test("fails closed on unsupported headers and malformed packages", () => {
assert.throws(
() => tryParseB2Header2(Buffer.from("FE23E9EF782DE7120300", "hex")),
/b2_header_start_invalid/,
);
assert.throws(
() => tryParseB2Header2(Buffer.from("FF24E9EF782DE7120300", "hex")),
/b2_header_version_unsupported/,
);
const badChecksum = Buffer.from(specificationPackage);
badChecksum[badChecksum.length - 2] ^= 0xff;
assert.throws(
() => tryParseB2Package(badChecksum),
/b2_packet_checksum_invalid/,
);
});
test("exports no command builder and keeps transport disabled", () => {
assert.equal(ARUSNAVI_B2_MODEL_PROFILE.commandTransport.status, "disabled");
assert.equal(
ARUSNAVI_B2_MODEL_PROFILE.commandTransport.exportedCommandBuilders,
0,
);
});
@@ -0,0 +1,15 @@
{
"name": "@nodedc/device-protocol-contract",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.mjs"
},
"scripts": {
"test": "node --test test/*.test.mjs"
},
"engines": {
"node": ">=20"
}
}
@@ -0,0 +1,239 @@
import { createHmac } from "node:crypto";
export const DEVICE_DISCOVERY_SIGNAL_SCHEMA =
"nodedc.device.discovery-signal.v1";
export const DEVICE_DISCOVERY_VIEW_SCHEMA =
"nodedc.device.discovery-view.v1";
export const DEVICE_PLANE_BINDING_SCHEMA =
"nodedc.device-plane-control.binding.v1";
export const DEVICE_LIFECYCLE_STATES = Object.freeze([
"quarantine",
"claimed",
"online",
"offline",
"retired",
]);
export const DEVICE_BINDING_CAPABILITIES = Object.freeze([
"observe",
"inspect",
"configure",
"command",
]);
const OPAQUE_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const IMEI_RE = /^\d{15}$/;
const DIGEST_RE = /^hmac-sha256:[a-f0-9]{64}$/;
const forbiddenKeyFragments = Object.freeze([
"password",
"secret",
"credential",
"rawpayload",
"rawpacket",
"command",
"authorization",
"token",
]);
const safeStatusKeys = new Set([
"commandtransport",
]);
export function normalizeDiscoverySignal(input) {
assertPlainObject(input, "discovery_signal");
rejectForbiddenKeys(input);
if (input.schemaVersion !== DEVICE_DISCOVERY_SIGNAL_SCHEMA) {
throw new TypeError("discovery_signal_schema_invalid");
}
const sessionRef = normalizeOpaqueRef(input.sessionRef, "session_ref");
const modelProfileRef = normalizeOpaqueRef(
input.modelProfileRef,
"model_profile_ref",
);
const protocol = normalizeUpperToken(input.protocol, "protocol");
const observedAt = normalizeTimestamp(input.observedAt, "observed_at");
const identifier = normalizeRestrictedIdentifier(input.identifier);
const evidence = normalizeDiscoveryEvidence(input.evidence);
return Object.freeze({
schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA,
sessionRef,
modelProfileRef,
protocol,
observedAt,
identifier,
evidence,
lifecycleState: "quarantine",
commandTransport: "disabled",
});
}
export function toSafeDiscoveryView(signal, options = {}) {
const normalized = normalizeDiscoverySignal(signal);
const discoveryRef = options.discoveryRef
? normalizeOpaqueRef(options.discoveryRef, "discovery_ref")
: undefined;
return Object.freeze({
schemaVersion: DEVICE_DISCOVERY_VIEW_SCHEMA,
...(discoveryRef ? { discoveryRef } : {}),
modelProfileRef: normalized.modelProfileRef,
protocol: normalized.protocol,
observedAt: normalized.observedAt,
lifecycleState: normalized.lifecycleState,
identifier: Object.freeze({
kind: normalized.identifier.kind,
masked: maskRestrictedIdentifier(normalized.identifier),
}),
evidence: normalized.evidence,
commandTransport: "disabled",
});
}
export function hashRestrictedIdentifier(identifier, pepper) {
const normalized = normalizeRestrictedIdentifier(identifier);
if (typeof pepper !== "string" || pepper.length < 32) {
throw new TypeError("identifier_pepper_invalid");
}
const digest = createHmac("sha256", pepper)
.update(`${normalized.kind}\0${normalized.value}`, "utf8")
.digest("hex");
return `hmac-sha256:${digest}`;
}
export function assertIdentifierDigest(value) {
if (typeof value !== "string" || !DIGEST_RE.test(value)) {
throw new TypeError("identifier_digest_invalid");
}
return value;
}
export function normalizeDevicePlaneBinding(input) {
assertPlainObject(input, "device_plane_binding");
rejectForbiddenKeys(input);
if (input.schemaVersion !== DEVICE_PLANE_BINDING_SCHEMA) {
throw new TypeError("device_plane_binding_schema_invalid");
}
const allowed = new Set(DEVICE_BINDING_CAPABILITIES);
if (!Array.isArray(input.capabilities) || input.capabilities.length === 0) {
throw new TypeError("device_plane_binding_capabilities_invalid");
}
const capabilities = [...new Set(input.capabilities.map((value) => {
if (typeof value !== "string" || !allowed.has(value)) {
throw new TypeError("device_plane_binding_capability_invalid");
}
return value;
}))].sort();
return Object.freeze({
schemaVersion: DEVICE_PLANE_BINDING_SCHEMA,
bindingRef: normalizeOpaqueRef(input.bindingRef, "binding_ref"),
contourRef: normalizeOpaqueRef(input.contourRef, "contour_ref"),
capabilities: Object.freeze(capabilities),
});
}
export function assertSafeProjection(value) {
assertPlainObject(value, "safe_projection");
rejectForbiddenKeys(value);
const serialized = JSON.stringify(value);
if (/\b\d{15}\b/.test(serialized)) {
throw new TypeError("safe_projection_contains_unmasked_imei");
}
return value;
}
function normalizeRestrictedIdentifier(input) {
assertPlainObject(input, "restricted_identifier");
if (input.kind !== "imei") {
throw new TypeError("restricted_identifier_kind_unsupported");
}
if (typeof input.value !== "string" || !IMEI_RE.test(input.value)) {
throw new TypeError("restricted_identifier_imei_invalid");
}
return Object.freeze({ kind: "imei", value: input.value });
}
function maskRestrictedIdentifier(identifier) {
if (identifier.kind === "imei") {
return `***********${identifier.value.slice(-4)}`;
}
throw new TypeError("restricted_identifier_kind_unsupported");
}
function normalizeDiscoveryEvidence(input) {
assertPlainObject(input, "discovery_evidence");
rejectForbiddenKeys(input);
if (input.transport !== "tcp") {
throw new TypeError("discovery_evidence_transport_invalid");
}
const bytesObserved = Number(input.bytesObserved);
if (!Number.isSafeInteger(bytesObserved) || bytesObserved < 1 || bytesObserved > 4096) {
throw new TypeError("discovery_evidence_bytes_invalid");
}
if (input.framingStatus !== "verified") {
throw new TypeError("discovery_evidence_framing_unverified");
}
return Object.freeze({
transport: "tcp",
bytesObserved,
framingStatus: "verified",
specificationRef: normalizeOpaqueRef(
input.specificationRef,
"framing_specification_ref",
),
});
}
function rejectForbiddenKeys(value, path = "$") {
if (Array.isArray(value)) {
value.forEach((item, index) => rejectForbiddenKeys(item, `${path}[${index}]`));
return;
}
if (!value || typeof value !== "object") return;
for (const [key, child] of Object.entries(value)) {
const normalizedKey = key.toLowerCase().replace(/[^a-z0-9]/g, "");
if (
!safeStatusKeys.has(normalizedKey)
&& forbiddenKeyFragments.some((fragment) => normalizedKey.includes(fragment))
) {
throw new TypeError(`forbidden_device_field:${path}.${key}`);
}
rejectForbiddenKeys(child, `${path}.${key}`);
}
}
function normalizeOpaqueRef(value, label) {
if (typeof value !== "string" || !OPAQUE_REF_RE.test(value)) {
throw new TypeError(`${label}_invalid`);
}
return value;
}
function normalizeUpperToken(value, label) {
if (typeof value !== "string" || !/^[A-Z][A-Z0-9_]{0,31}$/.test(value)) {
throw new TypeError(`${label}_invalid`);
}
return value;
}
function normalizeTimestamp(value, label) {
if (typeof value !== "string") throw new TypeError(`${label}_invalid`);
const date = new Date(value);
if (!Number.isFinite(date.getTime()) || date.toISOString() !== value) {
throw new TypeError(`${label}_invalid`);
}
return value;
}
function assertPlainObject(value, label) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError(`${label}_invalid`);
}
}
@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
DEVICE_DISCOVERY_SIGNAL_SCHEMA,
DEVICE_PLANE_BINDING_SCHEMA,
assertIdentifierDigest,
assertSafeProjection,
hashRestrictedIdentifier,
normalizeDevicePlaneBinding,
normalizeDiscoverySignal,
toSafeDiscoveryView,
} from "../src/index.mjs";
const fakeImei = "000000000000001";
const fakeSignal = {
schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA,
sessionRef: "session:test-001",
modelProfileRef: "arusnavi.b2.internal.v1",
protocol: "INTERNAL",
observedAt: "2026-07-25T00:00:00.000Z",
identifier: {
kind: "imei",
value: fakeImei,
},
evidence: {
transport: "tcp",
bytesObserved: 128,
framingStatus: "verified",
specificationRef: "arusnavi.internal.framing.test-v1",
},
};
test("normalizes a verified discovery into quarantine with commands disabled", () => {
const signal = normalizeDiscoverySignal(fakeSignal);
assert.equal(signal.lifecycleState, "quarantine");
assert.equal(signal.commandTransport, "disabled");
assert.equal(signal.identifier.value, fakeImei);
});
test("safe discovery projection masks the restricted identifier", () => {
const view = toSafeDiscoveryView(fakeSignal, {
discoveryRef: "discovery:test-001",
});
const serialized = JSON.stringify(view);
assert.equal(view.identifier.masked, "***********0001");
assert.equal(serialized.includes(fakeImei), false);
assertSafeProjection(view);
});
test("identifier hashing requires a strong process-only pepper", () => {
const identifier = { kind: "imei", value: fakeImei };
assert.throws(
() => hashRestrictedIdentifier(identifier, "short"),
/identifier_pepper_invalid/,
);
const digest = hashRestrictedIdentifier(
identifier,
"test-only-pepper-with-at-least-32-bytes",
);
assertIdentifierDigest(digest);
assert.equal(digest.includes(fakeImei), false);
assert.equal(
digest,
hashRestrictedIdentifier(
identifier,
"test-only-pepper-with-at-least-32-bytes",
),
);
});
test("rejects unverified framing and command-shaped discovery input", () => {
assert.throws(
() => normalizeDiscoverySignal({
...fakeSignal,
evidence: { ...fakeSignal.evidence, framingStatus: "unverified" },
}),
/discovery_evidence_framing_unverified/,
);
assert.throws(
() => normalizeDiscoverySignal({
...fakeSignal,
command: { kind: "restart" },
}),
/forbidden_device_field/,
);
});
test("rejects secret-like fields recursively", () => {
assert.throws(
() => normalizeDiscoverySignal({
...fakeSignal,
metadata: { devicePassword: "not-a-real-password" },
}),
/forbidden_device_field/,
);
});
test("normalizes an opaque Foundry control binding without device data", () => {
const binding = normalizeDevicePlaneBinding({
schemaVersion: DEVICE_PLANE_BINDING_SCHEMA,
bindingRef: "binding:test-001",
contourRef: "contour:robot2b-test",
capabilities: ["inspect", "observe", "observe"],
});
assert.deepEqual(binding.capabilities, ["inspect", "observe"]);
assertSafeProjection(binding);
});
@@ -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
@@ -0,0 +1,16 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
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
USER node
CMD ["node", "services/device-control-core/src/server.mjs"]
@@ -0,0 +1,99 @@
begin;
create table if not exists device_model_profiles (
profile_ref text primary key,
schema_version text not null,
vendor text not null,
model text not null,
device_type text not null,
protocol text not null,
profile jsonb not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists device_contours (
id uuid primary key,
owner_scope text not null,
name text not null,
lifecycle_state text not null default 'active'
check (lifecycle_state in ('active', 'suspended', 'retired')),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (owner_scope, name)
);
create table if not exists device_discoveries (
id uuid primary key,
identifier_kind text not null,
identifier_digest text not null,
identifier_masked text not null,
model_profile_ref text not null references device_model_profiles(profile_ref),
protocol text not null,
lifecycle_state text not null default 'quarantine'
check (lifecycle_state in ('quarantine', 'claimed', 'rejected', 'expired')),
first_observed_at timestamptz not null,
last_observed_at timestamptz not null,
evidence jsonb not null,
claimed_device_id uuid,
claimed_at timestamptz,
claimed_by text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (identifier_kind, identifier_digest, model_profile_ref)
);
create index if not exists device_discoveries_state_last_seen_idx
on device_discoveries (lifecycle_state, last_observed_at desc);
create table if not exists device_instances (
id uuid primary key,
contour_id uuid not null references device_contours(id),
model_profile_ref text not null references device_model_profiles(profile_ref),
display_name text not null,
identifier_kind text not null,
identifier_digest text not null,
identifier_masked text not null,
credential_ref text,
lifecycle_state text not null default 'claimed'
check (lifecycle_state in ('claimed', 'online', 'offline', 'suspended', 'retired')),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (identifier_kind, identifier_digest, model_profile_ref)
);
alter table device_discoveries
drop constraint if exists device_discoveries_claimed_device_fk;
alter table device_discoveries
add constraint device_discoveries_claimed_device_fk
foreign key (claimed_device_id) references device_instances(id);
create table if not exists device_bindings (
id uuid primary key,
contour_id uuid not null references device_contours(id),
target_kind text not null,
target_ref text not null,
capabilities text[] not null,
lifecycle_state text not null default 'active'
check (lifecycle_state in ('active', 'revoked')),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (contour_id, target_kind, target_ref)
);
create table if not exists device_audit_events (
id uuid primary key,
event_type text not null,
actor_ref text not null,
contour_id uuid,
device_id uuid,
discovery_id uuid,
payload jsonb not null,
occurred_at timestamptz not null default now()
);
create index if not exists device_audit_events_device_time_idx
on device_audit_events (device_id, occurred_at desc);
commit;
@@ -0,0 +1,16 @@
{
"name": "@nodedc/device-control-core",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/server.mjs",
"test": "node --test test/*.test.mjs"
},
"dependencies": {
"pg": "^8.18.0"
},
"engines": {
"node": ">=20"
}
}
@@ -0,0 +1,152 @@
import { timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
import {
assertSafeProjection,
hashRestrictedIdentifier,
normalizeDiscoverySignal,
toSafeDiscoveryView,
} from "../../../packages/device-protocol-contract/src/index.mjs";
export function createControlCoreApp({
repository,
gatewayToken = "",
identifierPepper = "",
discoveryIngestEnabled = false,
} = {}) {
if (!repository || typeof repository.health !== "function") {
throw new TypeError("device_repository_required");
}
if (discoveryIngestEnabled) {
if (typeof repository.upsertQuarantineDiscovery !== "function") {
throw new TypeError("device_discovery_repository_required");
}
if (typeof gatewayToken !== "string" || gatewayToken.length < 32) {
throw new TypeError("device_gateway_token_invalid");
}
if (typeof identifierPepper !== "string" || identifierPepper.length < 32) {
throw new TypeError("device_identifier_pepper_invalid");
}
}
const server = createServer(async (request, response) => {
response.setHeader("Content-Type", "application/json; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
response.setHeader("X-Content-Type-Options", "nosniff");
try {
const requestUrl = new URL(
request.url || "/",
`http://${request.headers.host || "127.0.0.1"}`,
);
if (request.method === "GET" && requestUrl.pathname === "/healthz") {
const database = await repository.health();
return writeJson(response, 200, {
ok: true,
service: "nodedc-device-control-core",
database,
discoveryIngest: discoveryIngestEnabled ? "enabled" : "disabled",
commandTransport: "disabled",
});
}
if (
request.method === "POST"
&& requestUrl.pathname === "/internal/v1/device-discoveries:observe"
) {
if (!discoveryIngestEnabled) {
return writeJson(response, 404, {
ok: false,
error: "device_discovery_ingest_disabled",
});
}
if (!matchesBearer(request.headers.authorization, gatewayToken)) {
return writeJson(response, 401, {
ok: false,
error: "device_gateway_auth_required",
});
}
const input = await readJsonBody(request, 32 * 1024);
const signal = normalizeDiscoverySignal(input);
const identifierDigest = hashRestrictedIdentifier(
signal.identifier,
identifierPepper,
);
const safeView = assertSafeProjection(toSafeDiscoveryView(signal));
const discovery = await repository.upsertQuarantineDiscovery({
identifierDigest,
safeView,
});
return writeJson(response, discovery.created ? 201 : 200, {
ok: true,
created: discovery.created,
discovery: assertSafeProjection(discovery.value),
});
}
return writeJson(response, 404, {
ok: false,
error: "device_control_core_route_not_found",
});
} catch (error) {
const status = Number(error?.statusCode || 400);
return writeJson(
response,
Number.isInteger(status) && status >= 400 && status < 600
? status
: 500,
{
ok: false,
error: safeErrorCode(error),
},
);
}
});
return server;
}
function matchesBearer(header, expected) {
if (typeof header !== "string" || !header.startsWith("Bearer ")) return false;
const actual = Buffer.from(header.slice("Bearer ".length), "utf8");
const required = Buffer.from(expected, "utf8");
return (
actual.length === required.length
&& required.length > 0
&& timingSafeEqual(actual, required)
);
}
async function readJsonBody(request, maxBytes) {
const chunks = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > maxBytes) {
const error = new Error("device_request_body_too_large");
error.statusCode = 413;
throw error;
}
chunks.push(chunk);
}
if (size === 0) throw new TypeError("device_request_body_required");
try {
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
throw new TypeError("device_request_json_invalid");
}
}
function writeJson(response, status, body) {
response.statusCode = status;
return response.end(`${JSON.stringify(body)}\n`);
}
function safeErrorCode(error) {
const value = error instanceof Error ? error.message : "device_control_error";
return /^[a-z0-9_:-]{1,128}$/.test(value)
? value
: "device_control_error";
}
@@ -0,0 +1,73 @@
import { readFile } from "node:fs/promises";
export async function resolveDeviceDatabaseUrl(
environment = process.env,
readSecret = readFile,
) {
const explicit = optionalValue(environment.DEVICE_DATABASE_URL);
if (explicit) return explicit;
const host = restrictedValue(
environment.DEVICE_DATABASE_HOST,
/^[A-Za-z0-9.-]{1,253}$/,
"device_database_host_invalid",
);
const port = parsePort(environment.DEVICE_DATABASE_PORT, 5432);
const database = restrictedValue(
environment.DEVICE_DATABASE_NAME,
/^[A-Za-z_][A-Za-z0-9_-]{0,62}$/,
"device_database_name_invalid",
);
const user = restrictedValue(
environment.DEVICE_DATABASE_USER,
/^[A-Za-z_][A-Za-z0-9_-]{0,62}$/,
"device_database_user_invalid",
);
const passwordFile = requiredValue(
environment.DEVICE_DATABASE_PASSWORD_FILE,
"device_database_password_file_required",
);
const password = (await readSecret(passwordFile, "utf8")).trim();
if (password.length < 32 || password.length > 512) {
throw new Error("device_database_password_invalid");
}
return [
"postgresql://",
encodeURIComponent(user),
":",
encodeURIComponent(password),
"@",
host,
":",
String(port),
"/",
encodeURIComponent(database),
"?sslmode=disable",
].join("");
}
function optionalValue(value) {
if (typeof value !== "string") return "";
return value.trim();
}
function requiredValue(value, errorCode) {
const normalized = optionalValue(value);
if (!normalized) throw new Error(errorCode);
return normalized;
}
function restrictedValue(value, pattern, errorCode) {
const normalized = requiredValue(value, errorCode);
if (!pattern.test(normalized)) throw new Error(errorCode);
return normalized;
}
function parsePort(value, fallback) {
const parsed = Number(value || fallback);
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535) {
throw new Error("device_database_port_invalid");
}
return parsed;
}
@@ -0,0 +1,128 @@
import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import pg from "pg";
import { ARUSNAVI_B2_MODEL_PROFILE } from "../../../packages/arusnavi-b2-adapter/src/index.mjs";
const { Pool } = pg;
const serviceRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
export class PostgresDeviceRepository {
constructor({ databaseUrl, poolSize = 10 } = {}) {
if (typeof databaseUrl !== "string" || databaseUrl.trim() === "") {
throw new TypeError("device_database_url_required");
}
this.pool = new Pool({
connectionString: databaseUrl,
max: normalizePoolSize(poolSize),
});
}
async migrate() {
const sql = await readFile(
resolve(serviceRoot, "migrations/001_device_plane_foundation.sql"),
"utf8",
);
await this.pool.query(sql);
await this.pool.query(
`insert into device_model_profiles (
profile_ref,
schema_version,
vendor,
model,
device_type,
protocol,
profile
) values ($1, $2, $3, $4, $5, $6, $7::jsonb)
on conflict (profile_ref) do update set
schema_version = excluded.schema_version,
profile = excluded.profile,
updated_at = now()`,
[
ARUSNAVI_B2_MODEL_PROFILE.profileRef,
ARUSNAVI_B2_MODEL_PROFILE.schemaVersion,
ARUSNAVI_B2_MODEL_PROFILE.vendor,
ARUSNAVI_B2_MODEL_PROFILE.model,
ARUSNAVI_B2_MODEL_PROFILE.deviceType,
ARUSNAVI_B2_MODEL_PROFILE.protocol,
JSON.stringify(ARUSNAVI_B2_MODEL_PROFILE),
],
);
}
async health() {
await this.pool.query("select 1");
return "ready";
}
async upsertQuarantineDiscovery({ identifierDigest, safeView }) {
const result = await this.pool.query(
`insert into device_discoveries (
id,
identifier_kind,
identifier_digest,
identifier_masked,
model_profile_ref,
protocol,
lifecycle_state,
first_observed_at,
last_observed_at,
evidence
) values ($1, $2, $3, $4, $5, $6, 'quarantine', $7, $7, $8::jsonb)
on conflict (identifier_kind, identifier_digest, model_profile_ref)
do update set
last_observed_at = greatest(
device_discoveries.last_observed_at,
excluded.last_observed_at
),
evidence = excluded.evidence,
updated_at = now()
returning id, lifecycle_state, model_profile_ref, protocol,
identifier_kind, identifier_masked, first_observed_at,
last_observed_at, (xmax = 0) as created`,
[
randomUUID(),
safeView.identifier.kind,
identifierDigest,
safeView.identifier.masked,
safeView.modelProfileRef,
safeView.protocol,
safeView.observedAt,
JSON.stringify(safeView.evidence),
],
);
const row = result.rows[0];
return {
created: row.created === true,
value: {
schemaVersion: "nodedc.device.discovery-view.v1",
discoveryRef: `discovery:${row.id}`,
modelProfileRef: row.model_profile_ref,
protocol: row.protocol,
observedAt: new Date(row.last_observed_at).toISOString(),
lifecycleState: row.lifecycle_state,
identifier: {
kind: row.identifier_kind,
masked: row.identifier_masked,
},
evidence: safeView.evidence,
commandTransport: "disabled",
},
};
}
async close() {
await this.pool.end();
}
}
function normalizePoolSize(value) {
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 50) {
throw new TypeError("device_database_pool_size_invalid");
}
return parsed;
}
@@ -0,0 +1,107 @@
import { readFile } from "node:fs/promises";
import { createControlCoreApp } from "./app.mjs";
import { resolveDeviceDatabaseUrl } from "./database-config.mjs";
import { PostgresDeviceRepository } from "./postgres-repository.mjs";
const config = await readConfig();
const repository = new PostgresDeviceRepository({
databaseUrl: config.databaseUrl,
poolSize: config.databasePoolSize,
});
await repository.migrate();
const server = createControlCoreApp({
repository,
gatewayToken: config.gatewayToken,
identifierPepper: config.identifierPepper,
discoveryIngestEnabled: config.discoveryIngestEnabled,
});
server.listen(config.port, config.host, () => {
console.log(JSON.stringify({
event: "device_control_core_started",
host: config.host,
port: config.port,
discoveryIngest: config.discoveryIngestEnabled,
commandTransport: "disabled",
}));
});
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
async function shutdown() {
server.close(async () => {
await repository.close();
process.exit(0);
});
}
async function readConfig() {
const discoveryIngestEnabled = parseBoolean(
process.env.DEVICE_DISCOVERY_INGEST_ENABLED,
false,
);
return {
host: String(process.env.HOST || "127.0.0.1").trim(),
port: parsePort(process.env.PORT, 18120),
databaseUrl: await resolveDeviceDatabaseUrl(process.env),
databasePoolSize: parsePositiveInt(
process.env.DEVICE_DATABASE_POOL_SIZE,
10,
),
discoveryIngestEnabled,
gatewayToken: discoveryIngestEnabled
? await readRequiredSecretFile(
process.env.DEVICE_GATEWAY_CORE_TOKEN_FILE,
"device_gateway_core_token_file_required",
)
: "",
identifierPepper: discoveryIngestEnabled
? await readRequiredSecretFile(
process.env.DEVICE_IDENTIFIER_PEPPER_FILE,
"device_identifier_pepper_file_required",
)
: "",
};
}
async function readRequiredSecretFile(path, errorCode) {
const normalized = requiredValue(path, errorCode);
const value = (await readFile(normalized, "utf8")).trim();
if (value.length < 32) throw new Error(errorCode);
return value;
}
function requiredValue(value, errorCode) {
if (typeof value !== "string" || value.trim() === "") {
throw new Error(errorCode);
}
return value.trim();
}
function parsePort(value, fallback) {
const parsed = Number(value || fallback);
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535) {
throw new Error("device_control_port_invalid");
}
return parsed;
}
function parsePositiveInt(value, fallback) {
const parsed = Number(value || fallback);
if (!Number.isSafeInteger(parsed) || parsed < 1) {
throw new Error("device_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_boolean_invalid");
}
@@ -0,0 +1,143 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
DEVICE_DISCOVERY_SIGNAL_SCHEMA,
} from "../../../packages/device-protocol-contract/src/index.mjs";
import { createControlCoreApp } from "../src/app.mjs";
const gatewayToken = "test-only-gateway-token-with-32-bytes";
const identifierPepper = "test-only-identifier-pepper-with-32-bytes";
const fakeImei = "000000000000001";
test("health reports database readiness and disabled command transport", async () => {
const runtime = await startTestServer({
repository: {
health: async () => "ready",
},
});
try {
const response = await fetch(`${runtime.baseUrl}/healthz`);
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), {
ok: true,
service: "nodedc-device-control-core",
database: "ready",
discoveryIngest: "disabled",
commandTransport: "disabled",
});
} finally {
await runtime.close();
}
});
test("discovery ingest is closed by default", async () => {
const runtime = await startTestServer({
repository: {
health: async () => "ready",
},
});
try {
const response = await fetch(
`${runtime.baseUrl}/internal/v1/device-discoveries:observe`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
},
);
assert.equal(response.status, 404);
assert.equal(
(await response.json()).error,
"device_discovery_ingest_disabled",
);
} finally {
await runtime.close();
}
});
test("authenticated ingest stores only digest and returns a masked view", async () => {
let stored;
const runtime = await startTestServer({
discoveryIngestEnabled: true,
gatewayToken,
identifierPepper,
repository: {
health: async () => "ready",
upsertQuarantineDiscovery: async (value) => {
stored = value;
return {
created: true,
value: {
...value.safeView,
discoveryRef: "discovery:test-001",
},
};
},
},
});
try {
const unauthorized = await fetch(
`${runtime.baseUrl}/internal/v1/device-discoveries:observe`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(fakeSignal()),
},
);
assert.equal(unauthorized.status, 401);
const response = await fetch(
`${runtime.baseUrl}/internal/v1/device-discoveries:observe`,
{
method: "POST",
headers: {
Authorization: `Bearer ${gatewayToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(fakeSignal()),
},
);
assert.equal(response.status, 201);
const body = await response.json();
const serialized = JSON.stringify(body);
assert.equal(serialized.includes(fakeImei), false);
assert.equal(body.discovery.identifier.masked, "***********0001");
assert.match(stored.identifierDigest, /^hmac-sha256:[a-f0-9]{64}$/);
assert.equal(JSON.stringify(stored).includes(fakeImei), false);
} finally {
await runtime.close();
}
});
function fakeSignal() {
return {
schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA,
sessionRef: "session:test-001",
modelProfileRef: "arusnavi.b2.internal.v1",
protocol: "INTERNAL",
observedAt: "2026-07-25T00:00:00.000Z",
identifier: { kind: "imei", value: fakeImei },
evidence: {
transport: "tcp",
bytesObserved: 128,
framingStatus: "verified",
specificationRef: "arusnavi.internal.framing.test-v1",
},
};
}
async function startTestServer(options) {
const server = createControlCoreApp(options);
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
return {
baseUrl: `http://127.0.0.1:${address.port}`,
close: () => new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
}),
};
}
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveDeviceDatabaseUrl } from "../src/database-config.mjs";
test("builds the database URL from a file-backed password", async () => {
const password = "test-only-database-password-with-32-bytes";
const url = await resolveDeviceDatabaseUrl(
{
DEVICE_DATABASE_HOST: "device-postgres",
DEVICE_DATABASE_PORT: "5432",
DEVICE_DATABASE_NAME: "device_plane",
DEVICE_DATABASE_USER: "device_plane",
DEVICE_DATABASE_PASSWORD_FILE: "/run/test/postgres-password",
},
async (path, encoding) => {
assert.equal(path, "/run/test/postgres-password");
assert.equal(encoding, "utf8");
return `${password}\n`;
},
);
assert.equal(
url,
`postgresql://device_plane:${encodeURIComponent(password)}@device-postgres:5432/device_plane?sslmode=disable`,
);
});
test("rejects a short file-backed database password", async () => {
await assert.rejects(
resolveDeviceDatabaseUrl(
{
DEVICE_DATABASE_HOST: "device-postgres",
DEVICE_DATABASE_NAME: "device_plane",
DEVICE_DATABASE_USER: "device_plane",
DEVICE_DATABASE_PASSWORD_FILE: "/run/test/postgres-password",
},
async () => "too-short",
),
/device_database_password_invalid/,
);
});
test("keeps an explicit database URL as a compatibility-only boundary", async () => {
const explicit = "postgresql://local:test@127.0.0.1:5432/device_plane";
assert.equal(
await resolveDeviceDatabaseUrl({ DEVICE_DATABASE_URL: explicit }),
explicit,
);
});
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
const migrationUrl = new URL(
"../migrations/001_device_plane_foundation.sql",
import.meta.url,
);
test("foundation migration keeps restricted identifiers hashed and DB private", async () => {
const sql = await readFile(migrationUrl, "utf8");
assert.match(sql, /identifier_digest text not null/);
assert.match(sql, /identifier_masked text not null/);
assert.doesNotMatch(sql, /imei\s+text/i);
assert.doesNotMatch(sql, /password\s+text/i);
assert.doesNotMatch(sql, /raw_packet/i);
});
test("foundation migration has quarantine, contour, binding and audit tables", async () => {
const sql = await readFile(migrationUrl, "utf8");
for (const table of [
"device_model_profiles",
"device_contours",
"device_discoveries",
"device_instances",
"device_bindings",
"device_audit_events",
]) {
assert.match(sql, new RegExp(`create table if not exists ${table}`));
}
assert.match(sql, /default 'quarantine'/);
});
@@ -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,384 @@
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 = config.resolveRemoteAddress(socket.remoteAddress);
if (
!allowsSource(remoteAddress)
|| 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,
inboundBytes: 0,
outboundBytes: 0,
};
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));
socket.on("data", (chunk) => {
session.inboundBytes += chunk.length;
if (session.inboundBytes > config.maxBytesPerDirection) {
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));
upstream.on("data", (chunk) => {
session.outboundBytes += chunk.length;
if (session.outboundBytes > config.maxBytesPerDirection) {
rejectSession(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",
sourceAdmission: config.sourcePolicy,
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",
sourceAdmission: config.sourcePolicy,
};
},
};
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();
for (const [address, window] of connectionWindows) {
if (nowMs - window.startedAt >= 60_000) {
connectionWindows.delete(address);
}
}
const current = connectionWindows.get(remoteAddress);
if (!current || nowMs - current.startedAt >= 60_000) {
if (connectionWindows.size >= config.maxTrackedSourceAddresses) {
return false;
}
connectionWindows.set(remoteAddress, { startedAt: nowMs, count: 1 });
return true;
}
if (current.count >= config.maxConnectionsPerMinutePerAddress) return false;
current.count += 1;
return true;
}
function allowsSource(remoteAddress) {
if (config.sourcePolicy === "any") return true;
return isPublicIpv4Address(remoteAddress);
}
function rejectSession(session) {
if (!session.closed) totalRejected += 1;
session.socket.destroy();
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",
),
maxTrackedSourceAddresses: parseInteger(
input.maxTrackedSourceAddresses,
2048,
1,
65_536,
"device_edge_relay_source_table_limit_invalid",
),
maxBytesPerDirection: parseInteger(
input.maxBytesPerDirection,
262_144,
1_024,
16 * 1024 * 1024,
"device_edge_relay_byte_limit_invalid",
),
sourcePolicy: normalizeSourcePolicy(input.sourcePolicy, ingressEnabled),
resolveRemoteAddress: typeof input.resolveRemoteAddress === "function"
? input.resolveRemoteAddress
: normalizeRemoteAddress,
sessionTimeoutMs: parseInteger(
input.sessionTimeoutMs,
10000,
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 normalizeSourcePolicy(value, ingressEnabled) {
const fallback = ingressEnabled ? "public-ipv4-only" : "any";
const normalized = String(value || fallback).trim().toLowerCase();
if (!["any", "public-ipv4-only"].includes(normalized)) {
throw new TypeError("device_edge_relay_source_policy_invalid");
}
if (ingressEnabled && normalized !== "public-ipv4-only") {
throw new TypeError("device_edge_relay_ingress_source_policy_invalid");
}
return normalized;
}
function isPublicIpv4Address(value) {
const normalized = String(value || "").trim().replace(/^::ffff:/i, "");
const parts = normalized.split(".");
if (parts.length !== 4) return false;
const octets = parts.map((part) => Number(part));
if (octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return false;
}
const [first, second, third] = octets;
if (
first === 0
|| first === 10
|| first === 127
|| first >= 224
|| (first === 100 && second >= 64 && second <= 127)
|| (first === 169 && second === 254)
|| (first === 172 && second >= 16 && second <= 31)
|| (first === 192 && second === 0 && third === 0)
|| (first === 192 && second === 0 && third === 2)
|| (first === 192 && second === 88 && third === 99)
|| (first === 192 && second === 168)
|| (first === 198 && (second === 18 || second === 19))
|| (first === 198 && second === 51 && third === 100)
|| (first === 203 && second === 0 && third === 113)
) {
return false;
}
return true;
}
function parseInteger(value, fallback, minimum, maximum, errorCode) {
const parsed = Number(value ?? fallback);
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
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,85 @@
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,
),
maxTrackedSourceAddresses: parsePositiveInt(
process.env.DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES,
2048,
),
maxBytesPerDirection: parsePositiveInt(
process.env.DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION,
262_144,
),
sourcePolicy: process.env.DEVICE_EDGE_RELAY_SOURCE_POLICY,
sessionTimeoutMs: parsePositiveInt(
process.env.DEVICE_EDGE_RELAY_SESSION_TIMEOUT_MS,
10000,
),
});
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,110 @@
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",
"DEVICE_EDGE_RELAY_SOURCE_POLICY: public-ipv4-only",
'DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES: "2048"',
'DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION: "262144"',
]) {
assert.ok(ingress.includes(required), `missing ingress boundary: ${required}`);
}
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");
});
test("admission-gate descriptor pins the fail-closed relay boundary", async () => {
const descriptor = JSON.parse(await readFile(
resolve(
devicePlaneRoot,
"deployment/device-edge-admission-gate-v1.json",
),
"utf8",
));
assert.equal(
descriptor.schemaVersion,
"nodedc.device-edge.admission-gate.v1",
);
assert.equal(descriptor.sourceAdmission, "public-ipv4-only");
assert.equal(descriptor.maxTrackedSourceAddresses, 2048);
assert.equal(descriptor.maxBytesPerDirection, 262144);
assert.equal(descriptor.hostPortPublication, "disabled");
assert.equal(descriptor.healthPublication, "disabled");
assert.equal(descriptor.commandTransport, "disabled");
assert.equal(descriptor.gelios, "untouched");
assert.equal(descriptor.routerNatFirewall, "separate-manual-gate");
});
@@ -0,0 +1,163 @@
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,
resolveRemoteAddress: () => "8.8.8.8",
});
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 ingress rejects a non-public source before opening upstream", async () => {
const upstream = await startEchoServer();
const runtime = createDeviceEdgeRelayRuntime({
healthPort: 0,
ingressEnabled: true,
tcpHost: "0.0.0.0",
tcpPort: 0,
upstreamHost: "127.0.0.1",
upstreamPort: upstream.port,
resolveRemoteAddress: () => "127.0.0.1",
});
const addresses = await runtime.start();
try {
const response = await sendAndCollect(
addresses.tcpAddress.port,
Buffer.from("denied"),
);
assert.equal(response.length, 0);
assert.equal(runtime.status().totalAccepted, 0);
assert.equal(runtime.status().totalForwarded, 0);
assert.equal(runtime.status().sourceAdmission, "public-ipv4-only");
} finally {
await runtime.stop();
await closeServer(upstream.server);
}
});
test("relay terminates a byte stream that exceeds its per-direction budget", async () => {
const upstream = await startEchoServer();
const runtime = createDeviceEdgeRelayRuntime({
healthPort: 0,
ingressEnabled: true,
tcpHost: "0.0.0.0",
tcpPort: 0,
upstreamHost: "127.0.0.1",
upstreamPort: upstream.port,
resolveRemoteAddress: () => "8.8.8.8",
maxBytesPerDirection: 1024,
});
const addresses = await runtime.start();
try {
const response = await sendAndCollect(
addresses.tcpAddress.port,
Buffer.alloc(1025, 0x5d),
);
assert.ok(response.length <= 1024);
assert.equal(runtime.status().totalForwarded, 1);
assert.ok(runtime.status().totalRejected >= 1);
} finally {
await runtime.stop();
await closeServer(upstream.server);
}
});
test("production ingress cannot opt out of public IPv4 admission", () => {
assert.throws(
() => createDeviceEdgeRelayRuntime({
ingressEnabled: true,
tcpHost: "0.0.0.0",
upstreamHost: "device-edge-backhaul",
upstreamPort: 19921,
sourcePolicy: "any",
}),
/device_edge_relay_ingress_source_policy_invalid/,
);
});
test("enabled relay requires a concrete private upstream", () => {
assert.throws(
() => createDeviceEdgeRelayRuntime({
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) => {
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", () => {});
});
}
function closeServer(server) {
return new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
@@ -0,0 +1,16 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
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
USER node
CMD ["node", "services/device-gateway/src/server.mjs"]
@@ -0,0 +1,13 @@
{
"name": "@nodedc/device-gateway",
"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,75 @@
export function createCoreDiscoveryClient({
coreUrl,
gatewayToken,
timeoutMs = 5000,
fetchImpl = fetch,
} = {}) {
const endpoint = normalizeCoreEndpoint(coreUrl);
if (typeof gatewayToken !== "string" || gatewayToken.length < 32) {
throw new TypeError("device_gateway_core_token_invalid");
}
const normalizedTimeout = Number(timeoutMs);
if (
!Number.isSafeInteger(normalizedTimeout)
|| normalizedTimeout < 100
|| normalizedTimeout > 30_000
) {
throw new TypeError("device_gateway_core_timeout_invalid");
}
if (typeof fetchImpl !== "function") {
throw new TypeError("device_gateway_core_fetch_invalid");
}
return async function observeDiscovery(signal) {
const response = await fetchImpl(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${gatewayToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(signal),
signal: AbortSignal.timeout(normalizedTimeout),
});
const body = await readBoundedJson(response, 32 * 1024);
if (!response.ok || body?.ok !== true) {
throw new Error("device_gateway_core_ingest_failed");
}
if (
!body.discovery
|| body.discovery.lifecycleState !== "quarantine"
|| body.discovery.commandTransport !== "disabled"
) {
throw new Error("device_gateway_core_ingest_contract_invalid");
}
return body.discovery;
};
}
function normalizeCoreEndpoint(value) {
let url;
try {
url = new URL(String(value || ""));
} catch {
throw new TypeError("device_gateway_core_url_invalid");
}
if (url.protocol !== "http:" || url.username || url.password) {
throw new TypeError("device_gateway_core_url_invalid");
}
if (url.pathname !== "/" || url.search || url.hash) {
throw new TypeError("device_gateway_core_url_invalid");
}
url.pathname = "/internal/v1/device-discoveries:observe";
return url.toString();
}
async function readBoundedJson(response, maxBytes) {
const text = await response.text();
if (Buffer.byteLength(text, "utf8") > maxBytes) {
throw new Error("device_gateway_core_response_too_large");
}
try {
return JSON.parse(text);
} catch {
throw new Error("device_gateway_core_response_invalid");
}
}
@@ -0,0 +1,362 @@
import { randomUUID } from "node:crypto";
import { createServer as createHttpServer } from "node:http";
import { createServer as createTcpServer } from "node:net";
import {
ARUSNAVI_B2_MODEL_PROFILE,
buildB2HeaderAcknowledgement,
buildB2PackageAcknowledgement,
tryParseB2Header2,
tryParseB2Package,
} from "../../../packages/arusnavi-b2-adapter/src/index.mjs";
import {
DEVICE_DISCOVERY_SIGNAL_SCHEMA,
} from "../../../packages/device-protocol-contract/src/index.mjs";
export function createDeviceGatewayRuntime(options = {}) {
const config = normalizeConfig(options);
const sessions = new Map();
const sessionsByAddress = new Map();
const connectionWindows = new Map();
let totalAccepted = 0;
let totalRejected = 0;
let totalDiscoveries = 0;
let totalPackagesAcknowledged = 0;
const tcpServer = createTcpServer((socket) => {
const remoteAddress = normalizeRemoteAddress(socket.remoteAddress);
if (
sessions.size >= config.maxConcurrentSessions
|| currentAddressSessions(remoteAddress) >= config.maxSessionsPerAddress
|| !consumeConnectionPermit(remoteAddress)
) {
totalRejected += 1;
socket.destroy();
return;
}
const sessionRef = `session:${randomUUID()}`;
const session = {
sessionRef,
remoteAddress,
buffer: Buffer.alloc(0),
state: "awaiting-header",
processing: false,
rejected: false,
closed: false,
};
sessions.set(socket, session);
incrementAddressSessions(remoteAddress);
totalAccepted += 1;
socket.setNoDelay(true);
socket.setTimeout(config.sessionTimeoutMs);
socket.on("data", (chunk) => {
if (session.closed || session.rejected) return;
socket.pause();
session.buffer = Buffer.concat(
[session.buffer, chunk],
session.buffer.length + chunk.length,
);
if (session.buffer.length > config.maxBufferedBytes) {
rejectSession(socket, session);
return;
}
if (session.processing) return;
session.processing = true;
void processSession(socket, session)
.catch(() => rejectSession(socket, session))
.finally(() => {
session.processing = false;
if (!session.closed && !session.rejected) socket.resume();
});
});
socket.on("timeout", () => rejectSession(socket, session));
socket.on("close", () => closeSession(socket, session));
socket.on("error", () => closeSession(socket, 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;
return response.end('{"ok":false,"error":"device_gateway_route_not_found"}\n');
}
response.statusCode = 200;
return response.end(`${JSON.stringify({
ok: true,
service: "nodedc-device-gateway",
protocolProfile: ARUSNAVI_B2_MODEL_PROFILE.profileRef,
framing: ARUSNAVI_B2_MODEL_PROFILE.framing.status,
tcpListener: config.listenEnabled ? "discovery-only" : "disabled",
publicIngress: config.publicIngressEnabled
? "discovery-only"
: "disabled",
commandTransport: "disabled",
sessions: {
active: sessions.size,
accepted: totalAccepted,
rejected: totalRejected,
discoveries: totalDiscoveries,
packagesAcknowledged: totalPackagesAcknowledged,
},
})}\n`);
});
return {
async start() {
await listen(healthServer, config.healthPort, config.healthHost);
if (config.listenEnabled) {
await listen(tcpServer, config.tcpPort, config.tcpHost);
}
return {
healthAddress: healthServer.address(),
tcpAddress: config.listenEnabled ? tcpServer.address() : null,
};
},
async stop() {
for (const [socket, session] of sessions) {
session.closed = true;
socket.destroy();
}
await Promise.all([
closeServer(healthServer),
config.listenEnabled ? closeServer(tcpServer) : Promise.resolve(),
]);
},
status() {
return {
activeSessions: sessions.size,
totalAccepted,
totalRejected,
totalDiscoveries,
totalPackagesAcknowledged,
commandTransport: "disabled",
publicIngress: config.publicIngressEnabled
? "discovery-only"
: "disabled",
};
},
};
async function processSession(socket, session) {
while (!session.closed && !session.rejected) {
if (session.state === "awaiting-header") {
const parsed = tryParseB2Header2(session.buffer);
if (parsed.status === "incomplete") return;
const observedAt = config.now().toISOString();
await config.onDiscovery?.({
schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA,
sessionRef: session.sessionRef,
modelProfileRef: ARUSNAVI_B2_MODEL_PROFILE.profileRef,
protocol: ARUSNAVI_B2_MODEL_PROFILE.protocol,
observedAt,
identifier: {
kind: parsed.identifier.kind,
value: parsed.identifier.value,
},
evidence: parsed.evidence,
});
session.buffer = session.buffer.subarray(parsed.bytesConsumed);
session.state = "packages";
totalDiscoveries += 1;
socket.write(buildB2HeaderAcknowledgement(
Math.floor(new Date(observedAt).getTime() / 1000),
));
continue;
}
const parsed = tryParseB2Package(session.buffer);
if (parsed.status === "incomplete") return;
session.buffer = session.buffer.subarray(parsed.bytesConsumed);
totalPackagesAcknowledged += 1;
socket.write(buildB2PackageAcknowledgement(parsed.packageNumber));
}
}
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(socket, session) {
if (!session.rejected) {
session.rejected = true;
totalRejected += 1;
}
socket.destroy();
}
function closeSession(socket, session) {
if (session.closed) return;
session.closed = true;
sessions.delete(socket);
decrementAddressSessions(session.remoteAddress);
}
}
function normalizeConfig(input) {
const listenEnabled = input.listenEnabled === true;
const publicIngressEnabled = input.publicIngressEnabled === true;
if (publicIngressEnabled && !listenEnabled) {
throw new TypeError("device_gateway_public_ingress_listener_required");
}
if (
publicIngressEnabled
&& typeof input.onDiscovery !== "function"
) {
throw new TypeError("device_gateway_discovery_sink_required");
}
return {
listenEnabled,
publicIngressEnabled,
healthHost: normalizeHealthHost(input.healthHost, "127.0.0.1"),
healthPort: parseInteger(
input.healthPort,
18121,
0,
65535,
"device_gateway_health_port_invalid",
),
tcpHost: normalizeTcpHost(
input.tcpHost,
publicIngressEnabled ? "0.0.0.0" : "127.0.0.1",
publicIngressEnabled,
),
tcpPort: parseInteger(
input.tcpPort,
9921,
0,
65535,
"device_gateway_tcp_port_invalid",
),
maxBufferedBytes: parseInteger(
input.maxBufferedBytes,
ARUSNAVI_B2_MODEL_PROFILE.framing.maxBufferedBytes,
1024,
ARUSNAVI_B2_MODEL_PROFILE.framing.maxBufferedBytes,
"device_gateway_buffer_limit_invalid",
),
maxConcurrentSessions: parseInteger(
input.maxConcurrentSessions,
100,
1,
10000,
"device_gateway_session_limit_invalid",
),
maxSessionsPerAddress: parseInteger(
input.maxSessionsPerAddress,
10,
1,
1000,
"device_gateway_address_session_limit_invalid",
),
maxConnectionsPerMinutePerAddress: parseInteger(
input.maxConnectionsPerMinutePerAddress,
30,
1,
10000,
"device_gateway_address_rate_limit_invalid",
),
sessionTimeoutMs: parseInteger(
input.sessionTimeoutMs,
10000,
100,
60000,
"device_gateway_session_timeout_invalid",
),
onDiscovery: typeof input.onDiscovery === "function"
? input.onDiscovery
: undefined,
now: typeof input.now === "function" ? input.now : () => new Date(),
};
}
function normalizeHealthHost(value, fallback) {
const normalized = String(value || fallback).trim();
if (!["127.0.0.1", "::1", "0.0.0.0", "::"].includes(normalized)) {
throw new TypeError("device_gateway_health_host_invalid");
}
return normalized;
}
function normalizeTcpHost(value, fallback, publicIngressEnabled) {
const normalized = String(value || fallback).trim();
const allowed = publicIngressEnabled
? ["0.0.0.0", "::"]
: ["127.0.0.1", "::1"];
if (!allowed.includes(normalized)) {
throw new TypeError(
publicIngressEnabled
? "device_gateway_public_ingress_host_invalid"
: "device_gateway_baseline_loopback_only",
);
}
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, resolve);
});
}
function closeServer(server) {
if (!server.listening) return Promise.resolve();
return new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
@@ -0,0 +1,145 @@
import { readFile } from "node:fs/promises";
import { createCoreDiscoveryClient } from "./core-client.mjs";
import { createDeviceGatewayRuntime } from "./runtime.mjs";
const config = await readConfig();
const onDiscovery = config.listenEnabled
? createCoreDiscoveryClient({
coreUrl: config.coreUrl,
gatewayToken: config.gatewayToken,
timeoutMs: config.coreTimeoutMs,
})
: undefined;
const runtime = createDeviceGatewayRuntime({
listenEnabled: config.listenEnabled,
publicIngressEnabled: config.publicIngressEnabled,
healthHost: config.healthHost,
healthPort: config.healthPort,
tcpHost: config.tcpHost,
tcpPort: config.tcpPort,
maxBufferedBytes: config.maxBufferedBytes,
maxConcurrentSessions: config.maxConcurrentSessions,
maxSessionsPerAddress: config.maxSessionsPerAddress,
maxConnectionsPerMinutePerAddress:
config.maxConnectionsPerMinutePerAddress,
sessionTimeoutMs: config.sessionTimeoutMs,
onDiscovery,
});
const addresses = await runtime.start();
console.log(JSON.stringify({
event: "device_gateway_started",
health: addresses.healthAddress,
tcp: addresses.tcpAddress,
publicIngress: config.publicIngressEnabled
? "discovery-only"
: "disabled",
commandTransport: "disabled",
}));
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
async function shutdown() {
await runtime.stop();
process.exit(0);
}
async function readConfig() {
const listenEnabled = parseBoolean(
process.env.DEVICE_GATEWAY_LISTEN_ENABLED,
false,
);
const publicIngressEnabled = parseBoolean(
process.env.DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED,
false,
);
return {
listenEnabled,
publicIngressEnabled,
healthHost: String(
process.env.DEVICE_GATEWAY_HEALTH_HOST || "127.0.0.1",
),
healthPort: parsePort(process.env.DEVICE_GATEWAY_HEALTH_PORT, 18121),
tcpHost: String(
process.env.DEVICE_GATEWAY_TCP_HOST
|| (publicIngressEnabled ? "0.0.0.0" : "127.0.0.1"),
),
tcpPort: parsePort(process.env.DEVICE_GATEWAY_TCP_PORT, 9921),
maxBufferedBytes: parsePositiveInt(
process.env.DEVICE_GATEWAY_MAX_BUFFERED_BYTES,
65536,
),
maxConcurrentSessions: parsePositiveInt(
process.env.DEVICE_GATEWAY_MAX_SESSIONS,
100,
),
maxSessionsPerAddress: parsePositiveInt(
process.env.DEVICE_GATEWAY_MAX_SESSIONS_PER_ADDRESS,
10,
),
maxConnectionsPerMinutePerAddress: parsePositiveInt(
process.env.DEVICE_GATEWAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS,
30,
),
sessionTimeoutMs: parsePositiveInt(
process.env.DEVICE_GATEWAY_SESSION_TIMEOUT_MS,
10000,
),
coreUrl: listenEnabled
? requiredValue(
process.env.DEVICE_GATEWAY_CORE_URL,
"device_gateway_core_url_required",
)
: "",
gatewayToken: listenEnabled
? await readRequiredSecretFile(
process.env.DEVICE_GATEWAY_CORE_TOKEN_FILE,
"device_gateway_core_token_file_required",
)
: "",
coreTimeoutMs: parsePositiveInt(
process.env.DEVICE_GATEWAY_CORE_TIMEOUT_MS,
5000,
),
};
}
async function readRequiredSecretFile(path, errorCode) {
const normalized = requiredValue(path, errorCode);
const value = (await readFile(normalized, "utf8")).trim();
if (value.length < 32) throw new Error(errorCode);
return value;
}
function requiredValue(value, errorCode) {
if (typeof value !== "string" || value.trim() === "") {
throw new Error(errorCode);
}
return value.trim();
}
function parsePort(value, fallback) {
const parsed = Number(value || fallback);
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535) {
throw new Error("device_gateway_port_invalid");
}
return parsed;
}
function parsePositiveInt(value, fallback) {
const parsed = Number(value || fallback);
if (!Number.isSafeInteger(parsed) || parsed < 1) {
throw new Error("device_gateway_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_gateway_boolean_invalid");
}
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createCoreDiscoveryClient } from "../src/core-client.mjs";
const gatewayToken = "test-only-gateway-token-with-32-bytes";
test("posts a discovery through the authenticated internal Core boundary", async () => {
let captured;
const observe = createCoreDiscoveryClient({
coreUrl: "http://device-control-core:18120",
gatewayToken,
fetchImpl: async (url, options) => {
captured = { url, options };
return new Response(JSON.stringify({
ok: true,
discovery: {
lifecycleState: "quarantine",
commandTransport: "disabled",
},
}), {
status: 201,
headers: { "Content-Type": "application/json" },
});
},
});
const signal = {
schemaVersion: "nodedc.device.discovery-signal.v1",
sessionRef: "session:test",
};
const discovery = await observe(signal);
assert.equal(
captured.url,
"http://device-control-core:18120/internal/v1/device-discoveries:observe",
);
assert.equal(
captured.options.headers.Authorization,
`Bearer ${gatewayToken}`,
);
assert.deepEqual(JSON.parse(captured.options.body), signal);
assert.equal(discovery.lifecycleState, "quarantine");
});
test("fails closed when Core does not return a quarantine view", async () => {
const observe = createCoreDiscoveryClient({
coreUrl: "http://device-control-core:18120",
gatewayToken,
fetchImpl: async () => new Response(JSON.stringify({
ok: true,
discovery: {
lifecycleState: "claimed",
commandTransport: "disabled",
},
}), { status: 200 }),
});
await assert.rejects(
() => observe({ schemaVersion: "test" }),
/device_gateway_core_ingest_contract_invalid/,
);
});
@@ -0,0 +1,112 @@
import assert from "node:assert/strict";
import { connect } from "node:net";
import test from "node:test";
import {
createControlCoreApp,
} from "../../device-control-core/src/app.mjs";
import { createCoreDiscoveryClient } from "../src/core-client.mjs";
import { createDeviceGatewayRuntime } from "../src/runtime.mjs";
const gatewayToken = "test-only-gateway-token-with-32-bytes";
const identifierPepper = "test-only-identifier-pepper-with-32-bytes";
const specificationHeader = Buffer.from(
"FF23E9EF782DE7120300",
"hex",
);
const specificationPackage = Buffer.from(
"5B01010000FBDEC251EC5D",
"hex",
);
test("B2 HEADER2 becomes a persisted masked quarantine discovery before ACK", async () => {
let stored;
const core = createControlCoreApp({
discoveryIngestEnabled: true,
gatewayToken,
identifierPepper,
repository: {
health: async () => "ready",
upsertQuarantineDiscovery: async (value) => {
stored = value;
return {
created: true,
value: {
...value.safeView,
discoveryRef: "discovery:integration-001",
},
};
},
},
});
await listen(core);
const coreAddress = core.address();
const observe = createCoreDiscoveryClient({
coreUrl: `http://127.0.0.1:${coreAddress.port}`,
gatewayToken,
});
const gateway = createDeviceGatewayRuntime({
healthPort: 0,
tcpHost: "0.0.0.0",
tcpPort: 0,
listenEnabled: true,
publicIngressEnabled: true,
now: () => new Date(0x52db95de * 1000),
onDiscovery: observe,
});
const addresses = await gateway.start();
try {
const response = await exchange(
addresses.tcpAddress.port,
Buffer.concat([specificationHeader, specificationPackage]),
13,
);
assert.equal(
response.toString("hex").toUpperCase(),
"7B0400A0DE95DB527D7B00017D",
);
assert.match(stored.identifierDigest, /^hmac-sha256:[a-f0-9]{64}$/);
assert.equal(stored.safeView.lifecycleState, "quarantine");
assert.equal(stored.safeView.identifier.masked, "***********7769");
assert.equal(stored.safeView.commandTransport, "disabled");
assert.equal(
JSON.stringify(stored).includes("865209039777769"),
false,
);
} finally {
await gateway.stop();
await close(core);
}
});
function listen(server) {
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
}
function close(server) {
return new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
function exchange(port, payload, expectedBytes) {
return new Promise((resolve, reject) => {
const chunks = [];
let byteLength = 0;
const socket = connect({ host: "127.0.0.1", port }, () => {
socket.write(payload);
});
socket.on("data", (chunk) => {
chunks.push(chunk);
byteLength += chunk.length;
if (byteLength >= expectedBytes) {
socket.destroy();
resolve(Buffer.concat(chunks, byteLength));
}
});
socket.on("error", reject);
});
}
@@ -0,0 +1,194 @@
import assert from "node:assert/strict";
import { connect } from "node:net";
import test from "node:test";
import { createDeviceGatewayRuntime } from "../src/runtime.mjs";
const specificationHeader = Buffer.from(
"FF23E9EF782DE7120300",
"hex",
);
const specificationPackage = Buffer.from(
"5B01010000FBDEC251EC5D",
"hex",
);
test("baseline health exposes no public ingress and no command transport", async () => {
const runtime = createDeviceGatewayRuntime({
healthPort: 0,
listenEnabled: false,
});
const addresses = await runtime.start();
try {
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.publicIngress, "disabled");
assert.equal(body.commandTransport, "disabled");
assert.equal(body.tcpListener, "disabled");
assert.equal(addresses.tcpAddress, null);
} finally {
await runtime.stop();
}
});
test("discovery-only ingress persists HEADER2 before acknowledging packages", async () => {
const captured = [];
const runtime = createDeviceGatewayRuntime({
healthPort: 0,
tcpHost: "0.0.0.0",
tcpPort: 0,
listenEnabled: true,
publicIngressEnabled: true,
now: () => new Date(0x52db95de * 1000),
onDiscovery: async (value) => captured.push(value),
});
const addresses = await runtime.start();
const client = await connectAndCollect(addresses.tcpAddress.port);
try {
client.socket.write(specificationHeader.subarray(0, 4));
await new Promise((resolve) => setImmediate(resolve));
assert.equal(client.bytes().length, 0);
client.socket.write(specificationHeader.subarray(4));
await client.waitForBytes(9);
assert.equal(
client.bytes().subarray(0, 9).toString("hex").toUpperCase(),
"7B0400A0DE95DB527D",
);
assert.equal(captured.length, 1);
assert.equal(captured[0].identifier.value, "865209039777769");
assert.equal(captured[0].evidence.framingStatus, "verified");
assert.equal(captured[0].commandTransport, undefined);
client.socket.write(specificationPackage);
await client.waitForBytes(13);
assert.equal(
client.bytes().subarray(9).toString("hex").toUpperCase(),
"7B00017D",
);
assert.equal(runtime.status().totalDiscoveries, 1);
assert.equal(runtime.status().totalPackagesAcknowledged, 1);
assert.equal(runtime.status().commandTransport, "disabled");
assert.equal(runtime.status().publicIngress, "discovery-only");
const response = await fetch(
`http://127.0.0.1:${addresses.healthAddress.port}/healthz`,
);
const body = await response.json();
assert.equal(body.framing, "verified-read-only");
assert.equal(body.tcpListener, "discovery-only");
assert.equal(body.publicIngress, "discovery-only");
assert.equal(body.commandTransport, "disabled");
} finally {
client.socket.destroy();
await runtime.stop();
}
});
test("does not acknowledge malformed or unverified initial bytes", async () => {
const captured = [];
const runtime = createDeviceGatewayRuntime({
healthPort: 0,
tcpPort: 0,
listenEnabled: true,
onDiscovery: async (value) => captured.push(value),
});
const addresses = await runtime.start();
try {
const received = await sendAndCollect(
addresses.tcpAddress.port,
Buffer.from("not-a-b2-header", "utf8"),
);
assert.equal(received.length, 0);
assert.equal(captured.length, 0);
assert.equal(runtime.status().totalRejected, 1);
} finally {
await runtime.stop();
}
});
test("public ingress requires an authenticated discovery sink", () => {
assert.throws(
() => createDeviceGatewayRuntime({
listenEnabled: true,
publicIngressEnabled: true,
tcpHost: "0.0.0.0",
}),
/device_gateway_discovery_sink_required/,
);
});
test("baseline rejects non-loopback binding", () => {
assert.throws(
() => createDeviceGatewayRuntime({
listenEnabled: true,
tcpHost: "0.0.0.0",
}),
/device_gateway_baseline_loopback_only/,
);
});
test("container health may bind all interfaces while TCP stays disabled", async () => {
const runtime = createDeviceGatewayRuntime({
healthHost: "0.0.0.0",
healthPort: 0,
listenEnabled: false,
});
const addresses = await runtime.start();
try {
assert.equal(addresses.healthAddress.address, "0.0.0.0");
assert.equal(addresses.tcpAddress, null);
assert.equal(runtime.status().publicIngress, "disabled");
} finally {
await runtime.stop();
}
});
function connectAndCollect(port) {
return new Promise((resolve, reject) => {
const chunks = [];
let byteLength = 0;
const waiters = [];
const socket = connect({ host: "127.0.0.1", port }, () => {
resolve({
socket,
bytes: () => Buffer.concat(chunks, byteLength),
waitForBytes: (minimum) => {
if (byteLength >= minimum) return Promise.resolve();
return new Promise((waitResolve, waitReject) => {
waiters.push({ minimum, waitResolve, waitReject });
});
},
});
});
socket.on("data", (chunk) => {
chunks.push(chunk);
byteLength += chunk.length;
for (let index = waiters.length - 1; index >= 0; index -= 1) {
if (byteLength >= waiters[index].minimum) {
waiters[index].waitResolve();
waiters.splice(index, 1);
}
}
});
socket.on("error", (error) => {
for (const waiter of waiters.splice(0)) waiter.waitReject(error);
reject(error);
});
});
}
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);
});
}
+5 -338
View File
@@ -81,347 +81,9 @@ Supported components in this source:
- `n8n-private-extension`
- `module-foundry`
- `device-plane`
- `gitea`
- `proxy-contur`
- `dc-amd-proxy`
## Fresh Gitea installation
`gitea` is a one-time, fresh-install-only component. Its artifact contains
only the reviewed Compose file and strict deployment descriptor; it contains
no database, repository, user, token, key, hook, runtime data or secret. Build
the deterministic artifact locally with:
```bash
node infra/deploy-runner/build-gitea-fresh-install-artifact.mjs \
gitea-fresh-install-YYYYMMDD-NNN
```
The runtime is fixed to Gitea `1.27.1-rootless` by exact digest and
`linux/amd64`. The runner never pulls it: the exact image must first be loaded
through a separately reviewed trusted image-acquisition step. Compose is
invoked with `--pull never --no-deps`, uses `network_mode: none`, publishes no
ports, and serves only the host-mounted Unix socket
`/volume1/docker/nodedc-gitea/socket/gitea.sock`. It exposes no SSH port, uses
fresh SQLite, enables no LFS, and mounts two runner-generated secrets by URI.
Registration and password Basic auth are disabled, while MFA is enforced;
hooks, webhooks, migrations, packages, Actions, OAuth2/OpenID, federation and
update checks are disabled. HTTP Git with PAT remains the intended trusted
client path; PAT clone/push acceptance belongs to the later reviewed
user/repository restoration transition, not this empty installation. LFS may
only be enabled by that later transition after its secret and restart behavior
has been accepted against the pinned runtime.
Plan and apply fail closed unless all live prerequisites are already true:
- `/volume1/docker/nodedc-gitea`, the `nodedc-gitea` project, and any stale
`nodedc-gitea_internal` network are absent; TCP/4022 is closed and Docker
publishes neither TCP/3000 nor TCP/4022;
- Docker Server is exactly 24.0.2, Compose is at least 2.20.1, and the exact
pinned image ID and RepoDigest are local for linux/amd64 with image user
`1000:1000`;
- the separate root-owned Nginx bridge
`/usr/local/etc/nginx/conf.d/http.nodedc-gitea-uds.conf` matches its exact
reviewed bytes, owner/mode and SHA-256; Nginx 1.23.1 configuration validates,
and only its root/uid-1023 processes own `127.0.0.1:3000`. The bridge proxies
to the Unix socket, preserves Host/X-Real-IP/XFF/XFP, rate-limits login and
bounds concurrent requests without limiting Git request bodies;
- DSM reverse-proxy UUID `5bc46027-0307-4261-af7e-4f94a3c508c9` persistently
and in generated nginx config routes `git.dcserve.ru:443` to
`127.0.0.1:3000`; the generated vhost must also retain its exact host guard
and canonical Host/X-Real-IP/XFF/XFP forwarding directives, so the UDS
bridge never trusts a client-supplied `X-Real-IP`;
- the emergency broad INPUT DROP for TCP/3000 has been removed, while the
legacy `172.22.0.222:3000` OUTPUT DROP and TCP/4022 INPUT DROP remain;
- any legacy container named `gitea` is stopped with restart policy `no`.
Changing DSM reverse proxy/firewall rules, acquiring evidence from the old
root, restoring trusted users/repositories, and loading the pinned image are
separate reviewed transitions. This component never reads or mounts
`/volume1/docker/gitea` and never starts the legacy container. Failed apply
stops/removes only the `nodedc-gitea` candidate, proves that it is absent, and
quarantines the complete newly created root (including data/config/socket/secrets) for
evidence, and restores the absent source/root state without deleting runtime
evidence. If candidate absence cannot be proven, rollback preserves the root in
place and records reconciliation-required instead of disconnecting live bind
mounts.
Run the policy suite before publishing:
```bash
PYTHONDONTWRITEBYTECODE=1 \
python3 infra/deploy-runner/test_gitea_fresh_install.py -v
```
## Gitea incident salvage (clean-state recovery)
The additive `gitea-incident-salvage` slice is the reviewed recovery design for
incident `gitea-20260814`. It does not upgrade or mount the compromised state.
The candidate starts with a new SQLite database, new config, new runtime
secrets, new email addresses and new password hashes. The exact v2 decision
bundle is embedded as inert, digest-bound data:
- 2 new active identities (`dctouch`, admin; `SILVER`, non-admin), each with a
new root-contained one-time password and mandatory password change;
- 8 new locked identities with random discarded passwords, inactive,
restricted and prohibited from login;
- 45 exact repositories: 32 private `dctouch` repositories and 13 public
`SILVER` repositories. The other 962 legacy users and 2013 legacy repository
rows are not imported into the clean database.
The successor v3 descriptor embeds the owner-confirmed, canonical
`confirmed-disposition-v1.json` and the additive
`confirmed-closure-disposition-v1.json`. Both are bound to the exact snapshot,
database, identity decision, reference manifest, unsupported-state report,
schema catalog and semantic-topics hashes. The original disposition's 105
reference decisions are explicit rows, not namespace wildcards: 85 normal
heads, 4 tags and 4 wiki heads are eligible for later live reconstruction; the
exact 5 pull refs and 7 remote refs are sealed archive-only evidence and can
never be auto-promoted. The closure disposition authorizes only bounded
plan-time inventory and records the target policy for access, collaboration,
issue/PR metadata, attachments, releases, labels, projects, units, Packages
and Actions. Observed hashes do not become activation authority, and the new
closure-report digest is deliberately unpinned until separate review.
Build the deterministic review artifact locally with:
```bash
node infra/deploy-runner/build-gitea-incident-salvage-artifact.mjs \
gitea-incident-salvage-subrelation-closure-20260814-006
```
The original v1 descriptor and `...salvage-20260814-001.tgz` remain immutable.
The exact `...salvage-disposition-20260814-002.tgz` also remains immutable and
must not be retried: its plan failed safely because it assumed every nonempty
`repository.topics` TEXT value was a JSON array. A bounded immutable-database
classification proved all 45 kept rows are instead exact four-byte canonical
JSON `null`, Gitea's semantic empty representation. The `...topics-...-003`
artifact is the forward fix with a new patch id and digest.
That exact `...topics-...-003` artifact is now also an immutable predecessor.
The local pre-audit `...closure-...-004` draft must not be staged: review found
orphan-join and nullable-Actions gaps. The local
`...closure-reviewed-...-005` artifact is also stale and must not be staged:
its primary closure was fail-closed, but it retained a broad polymorphic-
subrelation blocker. The additive `...subrelation-closure-...-006` successor
closes the concrete comment/history/merger relations in the pinned snapshot
schema and is the only artifact produced by the current builder.
The successor builder refuses to overwrite an existing artifact basename and
publishes from an exclusively created same-directory temporary file using an
atomic no-clobber hard link. A failed build removes only its private temporary
file. The disposition is sorted, compact ASCII JSON with one terminal LF.
The runtime pin is
`docker.gitea.com/gitea:1.27.2-rootless@sha256:7de5f49a…3abd2c`, with exact
linux/amd64 OCI image ID `sha256:272085a8…bc5c4c6`. It runs as `1000:1000`,
uses the direct Gitea binary, `network_mode: none`, no Docker ports, no SSH and
no LFS. Candidate restart policy is `no`. The existing reviewed Nginx bridge
cannot reach the candidate while the socket parent is mode `0700`; publication
is an explicit post-acceptance change to `0750`, followed by public TLS and
smart-HTTP checks. Only after those checks may restart policy become
`unless-stopped`. The transition never changes or enforces two-factor
authentication.
The snapshot boundary is exact: UUID
`f5a3fe3a-93ea-bb4d-847f-6221a6bcbc9f`, immutable SQLite SHA-256
`8db9e74a…1a13052`, and the 972/2058 decisions must recompute from the database
row by row. The runner enumerates only each v2 `repo_relative_path` and
`wiki_relative_path`. It rejects symlinks, special files, hard links, NOCOW,
alternates, shallow/replace/grafts/worktrees, promisor state, unknown object
material and incomplete pack/index pairs. One preloaded mount inventory also
guards the `objects` and `refs` roots plus every traversed directory and file:
all must remain on the trusted snapshot device, outside nested mountpoints and,
for directories, outside nested Btrfs subvolumes. Traversal errors fail closed.
Forbidden-path probes (`objects/info/alternates`, grafts, replace refs and the
other listed sentinels) lstat each path component without following links and
validate every ancestor before probing its child; a symlink or mount boundary
therefore stops the plan before any lookup can escape the snapshot tree.
Only three exact derived-file classes observed in the bounded 45-repository
inventory are excluded: dumb-HTTP `objects/info/packs`,
`objects/pack/pack-<oid>.bitmap` with its exact complete pack/index pair, and
monolithic `objects/info/commit-graph`. Each must be a bounded regular,
single-link, non-NOCOW file. The reference manifest records its exact path,
class and byte size, but the runner neither reads nor copies the excluded
bytes. Unobserved `.rev`, MIDX and split commit-graph layouts stay fail-closed;
`.promisor`, `.mtimes`, `.keep`, unknown and temporary lookalikes are not
classified as disposable accelerators.
The same inventory found receive-pack quarantine directories matching only an
exact direct child `objects/tmp_objdir-incoming-[A-Za-z0-9]{6}`. A match must
retain the reviewed uid/gid `1000:1000`, mode `0755`, single-link and non-NOCOW
directory metadata. Its exact path and lstat fields are recorded, then the
subtree is pruned without reading, hashing, copying or reflinking contents; the
sealed read-only incident snapshot retains the forensic copy. Any name,
metadata, mount or subvolume drift remains a hard stop. A reviewed reference/OID
manifest is required; raw HEAD/refs files are never copied. The intended data
path is same-filesystem per-file `--reflink=always`, with no byte-copy fallback
and a bounded exclusive-allocation gate.
This slice is intentionally **NO-GO for apply** at the current review point.
`plan` inventories and prints the exact ref-manifest digest, byte length and
canonical compact JSON. The JSON is the reviewable, hash-bound reference/OID
artifact: it contains approved repository identities, ref names/OIDs, object
totals and stat-bound derived/quarantine exclusions, but no Git object bytes,
hooks or config. The same plan prints the canonical unsupported-state report
and a separate incident-closure report, each with its SHA-256, byte length and
compact ASCII JSON. The closure report is evidence only: its disposition keeps
`expectedSha256` and `expectedBytes` null and retains an explicit review-pin
blocker.
The reviewed plan evidence bound by the disposition is reference manifest
`9cddaf0e…a311` (36,010 bytes), unsupported-state report
`4b2cecf8…106ac` (119,468 bytes), and schema catalog
`b5e3b677…b6db`. The report has exact 45 repository rows with empty
`schema_missing`, `schema_mismatch`, and `anomalies` arrays. These facts close
only the owner's material-state decision; fsck/reachability, physical blob
inventory, sanitized archive creation and candidate acceptance remain separate
verifiers.
The unsupported-state report has one deterministic row for every one of the
45 kept repository IDs, including explicit zeroes. It separates direct
repository-relation counts, directional base/head PR counts, repo-unit type
counts, text-metadata presence booleans and denormalized numeric hints; it does
not add overlapping relations into a misleading grand total. LFS rows are
reported per repository with validated logical byte sums and distinct-OID
aggregates, including size conflicts and sharing with non-kept repositories.
Every association sharing a kept OID must also have an integer repository ID
that resolves to the exact snapshot `repository` table; invalid and orphan
owners make shared/unique byte aggregates unavailable and add a data-anomaly
blocker.
Attachments are reported per repository with validated logical byte sums and
explicit, overlapping issue/comment/release/unlinked link splits. Neither
category claims that a physical blob exists; no title, body, note, comment,
config, URL, token, key, secret, hash payload or blob content is selected or
exported.
The report also embeds the exact `PRAGMA table_xinfo` catalog and explicit
label-to-table-to-repository-column map for a code-owned allowlist of relevant
tables. `sqlite_master` must prove an ordinary, non-virtual table before any
row query; SQLite 3.37+ adds `PRAGMA table_list` ordinary-main-table proof. On
an older compatible SQLite, the weaker object-kind capability is recorded and
adds a coverage blocker. `query_only=ON` and `trusted_schema=OFF` are read back
before the snapshot connection is accepted. Only column identifiers and
structural metadata are exported, never DDL, defaults or row values. The v3
closure inventory follows code-owned, schema-attested primary joins for the
issue/PR dependency tables (comments, assignees, content histories, labels, issue
users/watches, reactions, reviews/state, dependencies, notifications,
stopwatches, tracked time and auto-merge), plus projects/boards, releases,
attachments, Packages and Actions. It emits only IDs required to prove joins,
actor classes, counts, declared sizes and SQLite byte lengths; it never selects
or prints text bodies, names, URLs, package metadata, workflow payloads,
credentials, tokens, secrets or logs. Package and Actions target features stay
disabled and no legacy row is importable. The successor additionally closes
every concrete FK-like comment field in the pinned schema (labels, projects,
milestones, tracked time, assignees, dependency, review and cross-reference),
content-history-to-comment, review-team and pull-merger provenance. Target
repository and local actor IDs are classified through the exact 2058/972
decisions. Migrated external-author IDs are never treated as local users and
are represented only by presence counts and author-name byte lengths. A
nonzero comment-assignee or review-reviewer team must resolve to an exact team
and organization; because no organization/team allowlist exists, its row,
team and organization IDs enter a sealed HOLD inventory and add the narrowly
scoped `issue-pr-team-mapping-hold` blocker. Orphans, conflicting user/team
identities, invalid cross-reference pairs and schema/type drift fail closed.
The report is still not a physical-file or sanitized-archive verifier, so
those separate blockers remain explicit.
The schema-catalog digest is recorded as observed evidence but deliberately
remains unpinned as reviewed activation authority. Schema absence/type drift,
invalid numeric/OID state or aggregate overflow is a hard error. `apply` fails
before creating `/volume1/docker/nodedc-gitea` until all of the following are
registered:
- root-owned Btrfs proof that the named snapshot UUID is read-only;
- exact stopped/restart-`no` legacy container image ID, image ref and sole
`/volume1/docker/gitea:/data` bind inspect;
- a separately verified exact reference/OID manifest digest, fsck,
reachability and selected-object reconstruction;
- immutable sanitized archives for issues, PRs, comments, releases, labels,
projects and repository descriptions, with no legacy row import;
- physical attachment inventory and reachable LFS pointer/OID/size/SHA proof;
- kept-user mapping before any collaboration recreation; and
- package/Actions schema plus physical zero closure and target unit-policy
acceptance.
All 25 direct repository relations have explicit dispositions, including the
15 exact zero-and-drop categories. Pull base/head counts remain directional
views of the same five PRs. All repository numeric hints are dropped and
recomputed. Repo-unit types 110 are total: clean types 15 and 8 are recreated
once per repository; external wiki/tracker are forbidden; Packages (9) and
Actions (10) are absent in the target and remain globally disabled. Legacy
unit config is never imported. The topics verifier accepts exact JSON `null`
as semantic empty state, or a canonical JSON array of sorted unique lowercase
names matching `^[a-z0-9][-.a-z0-9]*$` with at most 35 UTF-8 bytes. SQL NULL,
whitespace/case variants, quoted `"null"`, objects, numbers, booleans and
noncanonical arrays fail closed. The current snapshot must prove exactly 45
serialized JSON nulls, zero serialized arrays, zero material topics and zero
`repo_topic` rows; target cache is rebuilt from relations.
The activation hook remains frozen until these blockers are closed and the
full clean-database recreation, reflink reconstruction, doctor/fsck,
credential-table zeroing, rollback and public acceptance path has its own
runtime smoke. The current reviewed source identities are:
- runner `nodedc-deploy` SHA-256
`c766985aa02fe911fa5a873717276f3503d9cecded448e41dc6945030dcbce9e`;
- deterministic builder SHA-256
`b92076583aeab854015a05deb9d942c8dfcf3ca475fd3d52115d402d6f61acca`;
- salvage policy test SHA-256
`99b96b6179286a78e7d53ee6a923fc092e3471a8cece382a472449d455ad212c`;
- fresh-install policy test SHA-256
`20653c640142c25e7d2ef712048acc7889c56e1d519e2d03b5cad4127e48947c`;
- confirmed disposition SHA-256
`0a066724bcf6e4933133db6cab6cc273393e3c262dd00dda0bbf9ceebd84f78c`;
- confirmed closure disposition SHA-256
`7ed66d9848268431a703fe24b22c41afbaa7c5ff48949604d6fc448d93e0d243`;
- successor v3 descriptor SHA-256
`9b98eb1a1640fd5569cf051a621837379b167eff4527313a43a0a851e7cc181a`;
- deterministic local review artifact
`nodedc-gitea-gitea-incident-salvage-subrelation-closure-20260814-006.tgz`
SHA-256
`d3e598cf892b1371912dcd9cef64caa991dd23613e48fe979d0f95d493c23d53`.
Only after that exact runner is separately reviewed, promoted to the
root-owned runner path and its installed SHA-256 is re-attested may the exact
artifact be staged into the canonical inbox for a canonical `plan` command.
Staging is evidence collection only; it grants no authority to run `apply`.
The legacy identity is now pinned to the observed ref `gitea/gitea:latest`
and immutable image ID
`sha256:bf95d9a45ce4fe38b027d051cdc4a4bc531513489fa6244af4074efbb1c376d6`.
The mutable tag is provenance only. Every plan re-inspects exact name
`/gitea`, stopped state, restart `no`, and the sole RW bind
`/volume1/docker/gitea:/data`; drift is a hard stop.
The successor plan must report these exact remaining blockers:
- `attachment-physical-verifier-pending`;
- `candidate-root-activation-hard-frozen`;
- `closure-report-review-pin-pending`;
- `collaboration-kept-user-mapping-verifier-pending`;
- `forensic-ref-archive-verifier-pending`;
- `issue-pr-metadata-sanitized-archive-verifier-pending`;
- `lfs-reachable-pointer-physical-verifier-pending`;
- `package-action-physical-closure-verifier-pending`;
- `reference-manifest-fsck-reachability-verifier-pending`;
- `repository-object-reconstruction-verifier-pending`;
- `target-unit-policy-acceptance-pending`;
- `unsupported-schema-catalog-verifier-pending`.
Any other blocker or identity drift is also a stop. `apply` remains prohibited
and hard-frozen before root creation until a subsequent reviewed runner
revision closes every blocker and implements and smoke-tests the complete
activation/rollback path. The successor files must be independently reviewed
before any runner promotion or inbox staging.
Run the bounded policy suite with:
```bash
PYTHONDONTWRITEBYTECODE=1 \
python3 infra/deploy-runner/test_gitea_incident_salvage.py -v
```
The frozen local cutoff passes 54 incident-salvage tests and 26 fresh-install
tests, plus Python compilation and `git diff --check`.
`n8n-private-extension` is a staging-only trust boundary for reviewed offline
n8n private-node releases. Its artifact may contain exactly one digest-bound
`n8n-nodes-ndc` release with `package.tgz`, `release.json` and
@@ -773,6 +435,11 @@ The check is source-aware: if an apply rolls back to the prior source, rollback
acceptance uses that prior health contract instead of falsely requiring a
feature which the restored generation does not contain.
Module Foundry activation and rollback wait for the Compose container health
barrier before strict image/container inventory acceptance. The HTTP health
endpoint may become ready while Docker still reports `starting`; that timing
window is not treated as an application or rollback failure.
The Foundry ↔ Map Gateway signing key is not an application or `.env` setting.
On the first relevant `platform` or `module-foundry` apply, the root-owned
runner creates `/volume1/docker/nodedc-platform/secrets/map-gateway-admin-secret`
@@ -0,0 +1,265 @@
#!/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-admission-gate-20260804-002",
...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-admission-gate-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: "reviewed-ipvlan-b2-relay-admission-gate",
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",
sourceAdmission: descriptor.sourceAdmission,
maxTrackedSourceAddresses: descriptor.maxTrackedSourceAddresses,
maxBytesPerDirection: descriptor.maxBytesPerDirection,
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-admission-gate-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"',
"DEVICE_EDGE_RELAY_SOURCE_POLICY: public-ipv4-only",
'DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES: "2048"',
'DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION: "262144"',
"name: nodedc-device-edge-ingress",
"driver: ipvlan",
"parent: enp1s0f0",
"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.admission-gate.v1",
mode: "single-nic-ipvlan-b2-relay-only",
runtimeHost: "ndcmini12",
component: "device-edge",
selectedServices: ["device-edge-relay"],
preservedServices: ["device-edge-backhaul", "tailnet"],
composeProject: "nodedc-device-edge",
composeFiles: [
"docker-compose.device-edge.yml",
"docker-compose.device-edge.ingress.yml",
],
parentInterface: "enp1s0f0",
lanSubnet: "192.168.68.0/22",
lanGateway: "192.168.68.1",
ingressIpv4: "192.168.71.253",
ingressIpv4Approval: "approved-outside-dhcp-pool",
ingressNetwork: "nodedc-device-edge-ingress",
deviceTcpListen: "192.168.71.253:9921",
hostPortPublication: "disabled",
healthPublication: "disabled",
privateUpstream: "device-edge-backhaul:19921",
sourceAdmission: "public-ipv4-only",
maxTrackedSourceAddresses: 2048,
maxBytesPerDirection: 262144,
protocolInspection: "gateway-owned",
identityTrust: "claimed-not-ownership-proof",
discoveryLifecycle: "quarantine",
commandTransport: "disabled",
gelios: "untouched",
amneziaHostFullTunnel: "preserved",
routerNatFirewall: "separate-manual-gate",
rollback: "restore-reviewed-ipvlan-predecessor-without-network-or-router-mutation",
};
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);
}
}
@@ -0,0 +1,201 @@
#!/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 failedFoundationCompose = resolve(
scriptDir,
"fixtures/device-plane-foundation-internal-only-v1.yml",
);
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|| resolve(scriptDir, "../deploy-artifacts"),
);
const [patchId = "device-plane-foundation-20260725-001", ...extra] =
process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error("usage: build-device-plane-artifact.mjs [patch-id]");
}
const files = [
".dockerignore",
"package.json",
"package-lock.json",
"docker-compose.device-plane.yml",
"packages/device-protocol-contract",
"packages/arusnavi-b2-adapter",
"services/device-control-core",
"services/device-gateway",
];
const ignoredBasenames = new Set([
".DS_Store",
".git",
"node_modules",
]);
const ignoredDirectoryNames = new Set(["test"]);
const stage = await mkdtemp(join(tmpdir(), "nodedc-device-plane-artifact-"));
const payload = join(stage, "payload");
const target = join(artifactDir, `nodedc-device-plane-${patchId}.tgz`);
await assertSourceBoundary();
try {
await mkdir(payload, { recursive: true });
for (const sourceRelative of files) {
const source = sourceRelative === "docker-compose.device-plane.yml"
? failedFoundationCompose
: resolve(sourceRoot, sourceRelative);
await copySafe(
source,
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",
entries: files,
services: ["device-control-core", "device-gateway"],
preserved: [
"device-postgres",
"nodedc-device-plane-postgres-data",
"Gelios",
],
excluded: [
".env*",
"node_modules",
"**/test",
"docs",
"runtime",
"secrets",
],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertSourceBoundary() {
const composeSource = failedFoundationCompose;
const compose = await readFile(
composeSource,
"utf8",
);
for (const fragment of [
'DEVICE_DISCOVERY_INGEST_ENABLED: "false"',
'DEVICE_GATEWAY_LISTEN_ENABLED: "false"',
'"127.0.0.1:18120:18120"',
'"127.0.0.1:18121:18121"',
"source: /volume1/docker/nodedc-device-plane/secrets/postgres-password",
"create_host_path: false",
"name: nodedc-device-plane-postgres-data",
"pull_policy: never",
]) {
if (!compose.includes(fragment)) {
throw new Error(`device_plane_compose_boundary_missing:${fragment}`);
}
}
for (const forbidden of [
"9921:9921",
"0.0.0.0:9921",
"DEVICE_DISCOVERY_INGEST_ENABLED: \"true\"",
"DEVICE_GATEWAY_LISTEN_ENABLED: \"true\"",
"POSTGRES_PASSWORD:",
]) {
if (compose.includes(forbidden)) {
throw new Error(`device_plane_compose_boundary_violation:${forbidden}`);
}
}
}
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.isDirectory() && ignoredDirectoryNames.has(entry.name))
) {
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);
}
}
@@ -0,0 +1,271 @@
#!/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-b2-discovery-loopback-20260726-002",
...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-ingress-artifact.mjs "
+ "[patch-id]",
);
}
const files = [
".dockerignore",
"package.json",
"package-lock.json",
"docker-compose.device-plane.yml",
"packages/device-protocol-contract",
"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"]);
const ignoredDirectoryNames = new Set(["test"]);
const stage = await mkdtemp(
join(tmpdir(), "nodedc-device-plane-b2-discovery-ingress-"),
);
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: "verified-b2-loopback-discovery-only",
entries: files,
services: ["device-control-core", "device-gateway"],
preservedRuntime: [
"device-postgres",
"nodedc-device-plane-postgres-data",
"Gelios",
],
ingress: {
transport: "tcp",
published: "127.0.0.1:9921:9921",
mode: "loopback-discovery-only",
framing: "verified-read-only",
lifecycle: "quarantine",
commandTransport: "disabled",
},
rollback: "restore-source-and-predecessor-stateless-runtime",
excluded: [
".env*",
"node_modules",
"**/test",
"docs",
"runtime",
"secrets",
],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertBoundary() {
const compose = await readFile(
resolve(sourceRoot, "docker-compose.device-plane.yml"),
"utf8",
);
for (const fragment of [
'DEVICE_DISCOVERY_INGEST_ENABLED: "true"',
'DEVICE_GATEWAY_LISTEN_ENABLED: "true"',
'DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED: "false"',
"DEVICE_GATEWAY_CORE_URL: http://device-control-core:18120",
"DEVICE_GATEWAY_CORE_TOKEN_FILE: /run/nodedc-secrets/gateway-core-token",
'"127.0.0.1:18120:18120"',
'"127.0.0.1:18121:18121"',
'"127.0.0.1:9921:9921"',
"name: nodedc-device-plane-private",
"internal: true",
"name: nodedc-device-plane-control",
"internal: false",
'com.docker.network.bridge.enable_ip_masquerade: "false"',
"name: nodedc-device-plane-postgres-data",
"pull_policy: never",
]) {
if (!compose.includes(fragment)) {
throw new Error(
`device_plane_b2_ingress_boundary_missing:${fragment}`,
);
}
}
for (const forbidden of [
"POSTGRES_PASSWORD:",
"DEVICE_GATEWAY_CORE_TOKEN:",
"DEVICE_IDENTIFIER_PEPPER:",
"DEVICE_GATEWAY_COMMAND",
"9921:9921/udp",
]) {
if (compose.includes(forbidden)) {
throw new Error(
`device_plane_b2_ingress_boundary_violation:${forbidden}`,
);
}
}
const descriptor = JSON.parse(await readFile(
resolve(
sourceRoot,
"deployment/device-plane-b2-discovery-ingress-v1.json",
),
"utf8",
));
const expected = {
schemaVersion: "nodedc.device-plane.b2-discovery-ingress.v1",
mode: "verified-b2-loopback-discovery-only",
predecessorPatchId:
"device-plane-foundation-network-publication-20260725-003",
predecessorArtifactSha256:
"6fdd5a12c310786db1753882fc1378184fe378d2cc533633a8c73c951521b7bf",
sourceAction: "publish-verified-b2-loopback-discovery-source",
runtimeAction: "build-and-recreate-stateless-services",
selectedServices: ["device-control-core", "device-gateway"],
preservedServices: ["device-postgres"],
privateNetwork: "nodedc-device-plane-private",
controlNetwork: "nodedc-device-plane-control",
publishedPorts: [
"127.0.0.1:18120:18120",
"127.0.0.1:18121:18121",
"127.0.0.1:9921:9921/tcp",
],
protocolProfile: "arusnavi.b2.internal.v1",
framingSpecification:
"arusnavi.internal.protocol-sheet.gid-12.v1",
identityTrust: "claimed-not-ownership-proof",
discoveryLifecycle: "quarantine",
commandTransport: "disabled",
gelios: "untouched",
databaseVolume: "nodedc-device-plane-postgres-data",
rollback: "restore-source-and-predecessor-stateless-runtime",
};
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
throw new Error("device_plane_b2_ingress_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.isDirectory()
&& ignoredDirectoryNames.has(entry.name)
)
) {
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);
}
}
@@ -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));
}
}
@@ -0,0 +1,274 @@
#!/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 networkPublicationCompose = resolve(
scriptDir,
"fixtures/device-plane-foundation-network-publication-v1.yml",
);
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|| resolve(scriptDir, "../deploy-artifacts"),
);
const [
patchId = "device-plane-foundation-network-publication-20260725-003",
...extra
] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error(
"usage: build-device-plane-foundation-network-publication-artifact.mjs "
+ "[patch-id]",
);
}
const files = [
".dockerignore",
"package.json",
"package-lock.json",
"docker-compose.device-plane.yml",
"packages/device-protocol-contract",
"packages/arusnavi-b2-adapter",
"services/device-control-core",
"services/device-gateway",
"deployment/device-plane-foundation-network-publication-v1.json",
];
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
const ignoredDirectoryNames = new Set(["test"]);
const stage = await mkdtemp(
join(tmpdir(), "nodedc-device-plane-network-publication-"),
);
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) {
const source = sourceRelative === "docker-compose.device-plane.yml"
? networkPublicationCompose
: resolve(sourceRoot, sourceRelative);
await copySafe(
source,
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-foundation-network-publication-correction",
entries: files,
build: [],
services: ["device-control-core", "device-gateway"],
preservedRuntime: [
"device-postgres",
"nodedc-device-plane-postgres-data",
"Gelios",
],
networkChange: {
private: "preserved:internal",
control: "create:non-internal:no-masquerade",
published: [
"127.0.0.1:18120:18120",
"127.0.0.1:18121:18121",
],
disabled: ["9921", "public-ingress", "command-transport"],
},
rollback:
"restore-partial-source-and-internal-only-stateless-runtime",
excluded: [
".env*",
"node_modules",
"**/test",
"docs",
"runtime",
"secrets",
],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertBoundary() {
const compose = await readFile(
networkPublicationCompose,
"utf8",
);
for (const fragment of [
'DEVICE_DISCOVERY_INGEST_ENABLED: "false"',
'DEVICE_GATEWAY_LISTEN_ENABLED: "false"',
'"127.0.0.1:18120:18120"',
'"127.0.0.1:18121:18121"',
"name: nodedc-device-plane-private",
"internal: true",
"name: nodedc-device-plane-control",
"internal: false",
'com.docker.network.bridge.enable_ip_masquerade: "false"',
"name: nodedc-device-plane-postgres-data",
"pull_policy: never",
]) {
if (!compose.includes(fragment)) {
throw new Error(
`device_plane_network_publication_boundary_missing:${fragment}`,
);
}
}
for (const forbidden of [
"9921:9921",
"0.0.0.0:9921",
'DEVICE_DISCOVERY_INGEST_ENABLED: "true"',
'DEVICE_GATEWAY_LISTEN_ENABLED: "true"',
"POSTGRES_PASSWORD:",
]) {
if (compose.includes(forbidden)) {
throw new Error(
`device_plane_network_publication_boundary_violation:${forbidden}`,
);
}
}
const descriptor = JSON.parse(await readFile(
resolve(
sourceRoot,
"deployment/device-plane-foundation-network-publication-v1.json",
),
"utf8",
));
const expected = {
schemaVersion:
"nodedc.device-plane.foundation-network-publication.v1",
mode: "failed-foundation-network-publication-correction",
failedRecoveryPatchId:
"device-plane-foundation-recovery-20260725-002",
failedRecoveryArtifactSha256:
"9183cc385142584bfd12510bb0a3e6b833b2fd26607436f2486a564c628ea1bf",
failedRecoveryBackupId:
"device-plane-device-plane-foundation-recovery-20260725-002-20260725-232447",
sourceAction: "publish-network-corrected-foundation-source",
runtimeAction: "recreate-stateless-services-no-build",
selectedServices: ["device-control-core", "device-gateway"],
preservedServices: ["device-postgres"],
privateNetwork: "nodedc-device-plane-private",
controlNetwork: "nodedc-device-plane-control",
publishedLoopbackPorts: [
"127.0.0.1:18120:18120",
"127.0.0.1:18121:18121",
],
databaseVolume: "nodedc-device-plane-postgres-data",
rollback:
"restore-partial-source-and-internal-only-stateless-runtime",
};
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
throw new Error(
"device_plane_network_publication_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.isDirectory()
&& ignoredDirectoryNames.has(entry.name)
)
) {
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);
}
}
@@ -0,0 +1,255 @@
#!/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 predecessorCompose = resolve(
scriptDir,
"fixtures/device-plane-foundation-internal-only-v1.yml",
);
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|| resolve(scriptDir, "../deploy-artifacts"),
);
const [
patchId = "device-plane-foundation-recovery-20260725-002",
...extra
] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error(
"usage: build-device-plane-foundation-recovery-artifact.mjs [patch-id]",
);
}
const files = [
".dockerignore",
"package.json",
"package-lock.json",
"docker-compose.device-plane.yml",
"packages/device-protocol-contract",
"packages/arusnavi-b2-adapter",
"services/device-control-core",
"services/device-gateway",
"deployment/device-plane-foundation-recovery-v1.json",
];
const ignoredBasenames = new Set([
".DS_Store",
".git",
"node_modules",
]);
const ignoredDirectoryNames = new Set(["test"]);
const stage = await mkdtemp(
join(tmpdir(), "nodedc-device-plane-foundation-recovery-"),
);
const payload = join(stage, "payload");
const target = join(
artifactDir,
`nodedc-device-plane-${patchId}.tgz`,
);
await assertRecoveryBoundary();
try {
await mkdir(payload, { recursive: true });
for (const sourceRelative of files) {
const source = sourceRelative === "docker-compose.device-plane.yml"
? predecessorCompose
: resolve(sourceRoot, sourceRelative);
await copySafe(
source,
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-foundation-live-runtime-adoption",
entries: files,
build: [],
services: [],
preservedRuntime: [
"device-control-core",
"device-gateway",
"device-postgres",
"nodedc-device-plane-postgres-data",
],
sourceAction: "publish-exact-failed-artifact-source",
runtimeAction: "read-only-acceptance",
excluded: [
".env*",
"node_modules",
"**/test",
"docs",
"runtime",
"secrets",
],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertRecoveryBoundary() {
const compose = await readFile(
predecessorCompose,
"utf8",
);
for (const fragment of [
'DEVICE_DISCOVERY_INGEST_ENABLED: "false"',
'DEVICE_GATEWAY_LISTEN_ENABLED: "false"',
'"127.0.0.1:18120:18120"',
'"127.0.0.1:18121:18121"',
"name: nodedc-device-plane-postgres-data",
]) {
if (!compose.includes(fragment)) {
throw new Error(
`device_plane_recovery_compose_boundary_missing:${fragment}`,
);
}
}
for (const forbidden of [
"9921:9921",
"0.0.0.0:9921",
'DEVICE_DISCOVERY_INGEST_ENABLED: "true"',
'DEVICE_GATEWAY_LISTEN_ENABLED: "true"',
"POSTGRES_PASSWORD:",
]) {
if (compose.includes(forbidden)) {
throw new Error(
`device_plane_recovery_compose_boundary_violation:${forbidden}`,
);
}
}
const descriptor = JSON.parse(await readFile(
resolve(
sourceRoot,
"deployment/device-plane-foundation-recovery-v1.json",
),
"utf8",
));
const expected = {
schemaVersion: "nodedc.device-plane.foundation-recovery.v1",
mode: "failed-foundation-live-runtime-adoption",
failedPatchId: "device-plane-foundation-20260725-001",
failedArtifactSha256:
"23d428de547854ad8b1a026671e2f850386ab0be98bde80f016f1e9db631ee24",
backupId:
"device-plane-device-plane-foundation-20260725-001-20260725-223441",
sourceAction: "publish-exact-failed-artifact-source",
runtimeAction: "read-only-acceptance",
preservedServices: [
"device-control-core",
"device-gateway",
"device-postgres",
],
databaseVolume: "nodedc-device-plane-postgres-data",
rollback: "source-only-runtime-unchanged",
};
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
throw new Error("device_plane_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");
}
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.isDirectory()
&& ignoredDirectoryNames.has(entry.name)
)
) {
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);
}
}
@@ -0,0 +1,162 @@
#!/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 artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|| resolve(scriptDir, "../deploy-artifacts"),
);
const [patchId = "device-plane-postgres-bootstrap-20260725-001", ...extra] =
process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error(
"usage: build-device-plane-postgres-bootstrap-artifact.mjs [patch-id]",
);
}
const files = [
"docker-compose.device-plane.yml",
"deployment/device-postgres-bootstrap-v1.json",
];
const stage = await mkdtemp(
join(tmpdir(), "nodedc-device-plane-postgres-bootstrap-"),
);
const payload = join(stage, "payload");
const target = join(
artifactDir,
`nodedc-device-plane-${patchId}.tgz`,
);
await assertSourceBoundary();
try {
await mkdir(payload, { recursive: true });
for (const relativePath of files) {
const source = resolve(sourceRoot, relativePath);
const sourceStat = await lstat(source);
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
throw new Error(`bootstrap_source_file_required:${relativePath}`);
}
const destination = join(payload, relativePath);
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, {
force: true,
verbatimSymlinks: false,
});
}
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 sha256 = createHash("sha256")
.update(await readFile(target))
.digest("hex");
console.log(JSON.stringify({
ok: true,
patchId,
artifact: target,
sha256,
component: "device-plane",
entries: files,
services: ["device-postgres"],
mode: "create-if-absent",
rollbackVolumePolicy: "preserve",
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertSourceBoundary() {
const descriptor = JSON.parse(
await readFile(
resolve(
sourceRoot,
"deployment/device-postgres-bootstrap-v1.json",
),
"utf8",
),
);
const expected = {
schemaVersion: "nodedc.device-plane.postgres-bootstrap.v1",
service: "device-postgres",
volume: "nodedc-device-plane-postgres-data",
mode: "create-if-absent",
ordinaryApplicationSelection: "forbidden",
rollbackVolumePolicy: "preserve",
};
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
throw new Error("device_plane_postgres_bootstrap_descriptor_mismatch");
}
const compose = await readFile(
resolve(sourceRoot, "docker-compose.device-plane.yml"),
"utf8",
);
for (const required of [
"device-postgres:",
"name: nodedc-device-plane-postgres-data",
"POSTGRES_PASSWORD_FILE: /run/nodedc-secrets/postgres-password",
"create_host_path: false",
]) {
if (!compose.includes(required)) {
throw new Error(`device_plane_postgres_boundary_missing:${required}`);
}
}
const postgresStart = compose.indexOf(" device-postgres:");
const postgresEnd = compose.indexOf("\n device-control-core:");
if (
postgresStart < 0
|| postgresEnd <= postgresStart
|| compose.slice(postgresStart, postgresEnd).includes("\n ports:")
) {
throw new Error("device_plane_postgres_host_port_forbidden");
}
}
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");
}
@@ -1,222 +0,0 @@
#!/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 fixtureRoot = resolve(scriptDir, "fixtures/gitea");
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|| resolve(scriptDir, "../deploy-artifacts"),
);
const [patchId = "gitea-fresh-install-20260813-001", ...extra] =
process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error("usage: build-gitea-fresh-install-artifact.mjs [patch-id]");
}
const composeRelative = "docker-compose.gitea.yml";
const descriptorRelative = "deployment/gitea-fresh-install-v1.json";
const files = [composeRelative, descriptorRelative];
const stage = await mkdtemp(join(tmpdir(), "nodedc-gitea-artifact-"));
const payload = join(stage, "payload");
const target = join(artifactDir, `nodedc-gitea-${patchId}.tgz`);
await assertFixtureContract();
try {
await mkdir(payload, { recursive: true });
for (const relative of files) {
const source = resolve(fixtureRoot, relative);
const sourceStat = await lstat(source);
if (sourceStat.isSymbolicLink() || !sourceStat.isFile()) {
throw new Error(`gitea_fixture_type_rejected:${relative}`);
}
const destination = join(payload, relative);
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, { force: true, verbatimSymlinks: true });
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=gitea\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: 16 * 1024 * 1024 },
);
if (tar.status !== 0) {
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
}
const digest = sha256(await readFile(target));
console.log(JSON.stringify({
ok: true,
patchId,
artifact: target,
sha256: digest,
component: "gitea",
entries: files,
services: ["gitea"],
image: "docker.gitea.com/gitea:1.27.1-rootless@sha256:89dc3c214b3992e5bb01e05ad21139d7a8b302d3ea3d8942d3f7e904e92af148",
installMode: "fresh-only",
database: "fresh-sqlite-only",
lfs: "disabled-pending-reviewed-restore-transition",
transport: "unix:/run/gitea/gitea.sock",
networkMode: "none",
minimumComposeVersion: "2.20.1",
preserved: ["legacy-gitea-root-unread-and-untouched"],
excluded: [
"secrets",
"runtime-data",
"database",
"repositories",
"users",
"tokens",
"ssh-keys",
"hooks",
],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertFixtureContract() {
const composeBytes = await readFile(resolve(fixtureRoot, composeRelative));
const compose = composeBytes.toString("utf8");
const descriptor = JSON.parse(
await readFile(resolve(fixtureRoot, descriptorRelative), "utf8"),
);
const expectedImage =
"docker.gitea.com/gitea:1.27.1-rootless@sha256:89dc3c214b3992e5bb01e05ad21139d7a8b302d3ea3d8942d3f7e904e92af148";
const required = [
`image: ${expectedImage}`,
"platform: linux/amd64",
"pull_policy: never",
"network_mode: none",
'user: "1000:1000"',
"stop_grace_period: 30s",
"driver: json-file",
'max-size: "10m"',
'max-file: "3"',
"GITEA__server__PROTOCOL: http+unix",
"GITEA__server__HTTP_ADDR: /run/gitea/gitea.sock",
'GITEA__server__UNIX_SOCKET_PERMISSION: "0666"',
"GITEA__server__LOCAL_ROOT_URL: http://unix/",
'GITEA__server__DISABLE_SSH: "true"',
'GITEA__server__LFS_START_SERVER: "false"',
'GITEA__server__LFS_ALLOW_PURE_SSH: "false"',
"GITEA__security__SECRET_KEY_URI: file:/run/secrets/gitea_secret_key",
"GITEA__security__INTERNAL_TOKEN_URI: file:/run/secrets/gitea_internal_token",
"GITEA__security__TWO_FACTOR_AUTH: enforced",
'GITEA__security__REVERSE_PROXY_LIMIT: "1"',
"GITEA__security__ALLOWED_HOST_LIST: loopback",
"GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES: 127.0.0.0/8,::1/128",
'GITEA__service__DISABLE_REGISTRATION: "true"',
'GITEA__service__ENABLE_REVERSE_PROXY_AUTHENTICATION: "false"',
'GITEA__service__ENABLE_REVERSE_PROXY_AUTHENTICATION_API: "false"',
'GITEA__service__ENABLE_REVERSE_PROXY_AUTO_REGISTRATION: "false"',
'GITEA__service__ENABLE_BASIC_AUTHENTICATION: "false"',
'GITEA__admin__DISABLE_REGULAR_ORG_CREATION: "true"',
"GITEA__admin__USER_DISABLED_FEATURES: deletion,manage_ssh_keys,manage_gpg_keys,change_username",
'GITEA__security__DISABLE_GIT_HOOKS: "true"',
'GITEA__security__DISABLE_WEBHOOKS: "true"',
'GITEA__repository__DISABLE_MIGRATIONS: "true"',
'GITEA__packages__ENABLED: "false"',
'GITEA__oauth2__ENABLED: "false"',
'GITEA__openid__ENABLE_OPENID_SIGNIN: "false"',
'GITEA__cron.update_checker__ENABLED: "false"',
"source: /volume1/docker/nodedc-gitea/socket",
"target: /run/gitea",
"create_host_path: false",
"read_only: true",
"no-new-privileges:true",
];
for (const fragment of required) {
if (!compose.includes(fragment)) {
throw new Error(`gitea_compose_boundary_missing:${fragment}`);
}
}
for (const forbidden of [
"4022",
"2222:2222",
"0.0.0.0:3000",
"ports:",
"networks:",
"/var/run/docker.sock",
"/volume1/docker/gitea",
"privileged: true",
"pull_policy: always",
"__FILE",
"GITEA__security__SECRET_KEY:",
"GITEA__security__INTERNAL_TOKEN:",
"GITEA__server__LFS_JWT_SECRET:",
"GITEA__server__LFS_JWT_SECRET_URI",
"gitea_lfs_jwt_secret",
"lfs-jwt-secret",
"GITEA__server__REVERSE_PROXY_LIMIT",
"GITEA__server__REVERSE_PROXY_TRUSTED_PROXIES",
"GITEA__security__ENABLE_REVERSE_PROXY_AUTHENTICATION",
"GITEA__security__ENABLE_REVERSE_PROXY_AUTHENTICATION_API",
"GITEA__security__ENABLE_REVERSE_PROXY_AUTO_REGISTRATION",
"GITEA__service__DISABLE_REGULAR_ORG_CREATION",
"GITEA__service__USER_DISABLED_FEATURES",
]) {
if (compose.includes(forbidden)) {
throw new Error(`gitea_compose_boundary_violation:${forbidden}`);
}
}
if (
descriptor.schemaVersion !== "nodedc.gitea.fresh-install.v1"
|| descriptor.action !== "fresh-install"
|| descriptor.component !== "gitea"
|| descriptor.compose?.sha256 !== sha256(composeBytes)
|| descriptor.runtime?.image !== expectedImage
|| descriptor.runtime?.minimumComposeVersion !== "2.20.1"
|| descriptor.runtime?.lfs !== "disabled-pending-reviewed-restore-transition"
|| descriptor.runtime?.transport !== "unix:/run/gitea/gitea.sock"
|| descriptor.runtime?.networkMode !== "none"
|| descriptor.runtime?.logging !== "bounded-json-file-10m-x3"
|| descriptor.runtime?.stopGracePeriod !== "30s"
|| descriptor.trust?.artifactSecrets !== "forbidden"
|| descriptor.trust?.legacyRootAccess !== "forbidden"
) {
throw new Error("gitea_descriptor_contract_mismatch");
}
}
function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
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");
}
@@ -1,322 +0,0 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import {
cp,
link,
lstat,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const fixtureRoot = resolve(scriptDir, "fixtures/gitea-salvage");
const decisionRoot = resolve(
scriptDir,
"../../../security-incidents/gitea-20260814/confirmed-decisions-v2",
);
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|| resolve(scriptDir, "../deploy-artifacts"),
);
const [patchId = "gitea-incident-salvage-subrelation-closure-20260814-006", ...extra] =
process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error(
"usage: build-gitea-incident-salvage-artifact.mjs [patch-id]",
);
}
const composeRelative = "docker-compose.gitea.yml";
const descriptorRelative = "deployment/gitea-incident-salvage-v3.json";
const decisionPrefix = "deployment/gitea-incident-salvage";
const dispositionRelative = `${decisionPrefix}/confirmed-disposition-v1.json`;
const closureDispositionRelative =
`${decisionPrefix}/confirmed-closure-disposition-v1.json`;
const descriptorSha256 =
"9b98eb1a1640fd5569cf051a621837379b167eff4527313a43a0a851e7cc181a";
const dispositionSha256 =
"0a066724bcf6e4933133db6cab6cc273393e3c262dd00dda0bbf9ceebd84f78c";
const closureDispositionSha256 =
"7ed66d9848268431a703fe24b22c41afbaa7c5ff48949604d6fc448d93e0d243";
const decisionFiles = [
["confirmed-decision.json", "dc9528462624158eb44218d37cc7054d551ca2d7ded562592982aa3f34c9fc2a"],
["users.decisions.csv", "e3b82f1073a86eea9e567edff062dd1689d21ec0edcf3ed92e844da9351ee8b6"],
["repositories.decisions.csv", "76b4bae2ab5cec490330c19bfc5ae9429abf7636705ae028c1c64fd54a6a0493"],
];
const files = [
composeRelative,
descriptorRelative,
dispositionRelative,
closureDispositionRelative,
...decisionFiles.map(([name]) => `${decisionPrefix}/${name}`),
];
const stage = await mkdtemp(join(tmpdir(), "nodedc-gitea-salvage-artifact-"));
const payload = join(stage, "payload");
const target = join(artifactDir, `nodedc-gitea-${patchId}.tgz`);
const targetTemporary = join(
artifactDir,
`.${basename(target)}.${process.pid}.tmp`,
);
await assertSourceContract();
try {
await mkdir(payload, { recursive: true });
for (const relative of [
composeRelative,
descriptorRelative,
dispositionRelative,
closureDispositionRelative,
]) {
await copyRegularFile(resolve(fixtureRoot, relative), join(payload, relative), relative);
}
for (const [name] of decisionFiles) {
await copyRegularFile(
resolve(decisionRoot, name),
join(payload, decisionPrefix, name),
`${decisionPrefix}/${name}`,
);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=gitea\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(), targetTemporary, stage],
{ encoding: "utf8", maxBuffer: 16 * 1024 * 1024 },
);
if (tar.status !== 0) {
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
}
try {
await link(targetTemporary, target);
} catch (error) {
if (error?.code === "EEXIST") {
throw new Error("gitea_salvage_artifact_target_already_exists");
}
throw error;
}
console.log(JSON.stringify({
ok: true,
patchId,
artifact: target,
sha256: sha256(await readFile(target)),
component: "gitea",
transition: "clean-state-incident-salvage",
entries: files,
services: ["gitea"],
image: "docker.gitea.com/gitea:1.27.2-rootless@sha256:7de5f49ada687b8c8d2938f547cdb7634839764ba51f297457bae35cee3abd2c",
decisions: {
users: { active: 2, locked: 8, delete: 962 },
repositories: { keep: 45, delete: 2013 },
},
disposition: {
sha256: dispositionSha256,
forensicRefs: 105,
liveRefs: 93,
archiveOnlyRefs: 12,
},
closureDisposition: {
sha256: closureDispositionSha256,
state: "plan-report-review-pending",
},
stateBoundary: "new-database-config-secrets-identities",
repositoryBoundary: "exact-45-object-and-validated-ref-material-only",
networkMode: "none",
transport: "unix:/run/gitea/gitea.sock",
stagePolicy: "after-exact-runner-promotion-plan-only",
applyPolicy: "hard-frozen-before-root-creation",
}, null, 2));
} finally {
await rm(targetTemporary, { force: true });
await rm(stage, { recursive: true, force: true });
}
async function copyRegularFile(source, destination, label) {
const sourceStat = await lstat(source);
if (sourceStat.isSymbolicLink() || !sourceStat.isFile()) {
throw new Error(`gitea_salvage_source_type_rejected:${label}`);
}
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, { force: true, verbatimSymlinks: true });
}
function canonicalJson(value) {
if (Array.isArray(value)) {
return value.map(canonicalJson);
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.keys(value).sort().map((key) => [key, canonicalJson(value[key])]),
);
}
return value;
}
async function assertSourceContract() {
for (const [name, expected] of decisionFiles) {
const bytes = await readFile(resolve(decisionRoot, name));
if (sha256(bytes) !== expected) {
throw new Error(`gitea_salvage_decision_digest_mismatch:${name}`);
}
}
const composeBytes = await readFile(resolve(fixtureRoot, composeRelative));
const compose = composeBytes.toString("utf8");
const descriptorBytes = await readFile(
resolve(fixtureRoot, descriptorRelative),
);
const descriptor = JSON.parse(descriptorBytes.toString("utf8"));
if (sha256(descriptorBytes) !== descriptorSha256) {
throw new Error("gitea_salvage_descriptor_digest_mismatch");
}
const dispositionBytes = await readFile(
resolve(fixtureRoot, dispositionRelative),
);
const disposition = JSON.parse(dispositionBytes.toString("utf8"));
if (sha256(dispositionBytes) !== dispositionSha256) {
throw new Error("gitea_salvage_disposition_digest_mismatch");
}
const canonicalDisposition = Buffer.from(
`${JSON.stringify(canonicalJson(disposition))}\n`,
"utf8",
);
if (!dispositionBytes.equals(canonicalDisposition)) {
throw new Error("gitea_salvage_disposition_not_canonical");
}
const closureDispositionBytes = await readFile(
resolve(fixtureRoot, closureDispositionRelative),
);
const closureDisposition = JSON.parse(closureDispositionBytes.toString("utf8"));
if (sha256(closureDispositionBytes) !== closureDispositionSha256) {
throw new Error("gitea_salvage_closure_disposition_digest_mismatch");
}
const canonicalClosureDisposition = Buffer.from(
`${JSON.stringify(canonicalJson(closureDisposition))}\n`,
"utf8",
);
if (!closureDispositionBytes.equals(canonicalClosureDisposition)) {
throw new Error("gitea_salvage_closure_disposition_not_canonical");
}
const expectedImage =
"docker.gitea.com/gitea:1.27.2-rootless@sha256:7de5f49ada687b8c8d2938f547cdb7634839764ba51f297457bae35cee3abd2c";
for (const required of [
`image: ${expectedImage}`,
"platform: linux/amd64",
"pull_policy: never",
"network_mode: none",
'user: "1000:1000"',
"entrypoint:",
"- /usr/local/bin/gitea",
"- /etc/gitea/app.ini",
"read_only: true",
"cap_drop:",
"- ALL",
"no-new-privileges:true",
"source: /volume1/docker/nodedc-gitea/data",
"target: /data",
"source: /volume1/docker/nodedc-gitea/config",
"target: /etc/gitea",
"source: /volume1/docker/nodedc-gitea/socket",
"target: /run/gitea",
"create_host_path: false",
]) {
if (!compose.includes(required)) {
throw new Error(`gitea_salvage_compose_boundary_missing:${required}`);
}
}
for (const forbidden of [
"ports:",
"networks:",
"/var/run/docker.sock",
"/volume1/docker/gitea",
"privileged: true",
"4022",
"TWO_FACTOR_AUTH",
"LFS_JWT_SECRET",
]) {
if (compose.includes(forbidden)) {
throw new Error(`gitea_salvage_compose_boundary_violation:${forbidden}`);
}
}
if (
descriptor.schemaVersion !== "nodedc.gitea.incident-salvage.v3"
|| descriptor.action !== "clean-state-salvage"
|| descriptor.compose?.sha256 !== sha256(composeBytes)
|| descriptor.runtime?.image !== expectedImage
|| descriptor.runtime?.networkMode !== "none"
|| descriptor.runtime?.database !== "new-sqlite-1.27.2-only"
|| descriptor.disposition?.file !== dispositionRelative
|| descriptor.disposition?.sha256 !== dispositionSha256
|| descriptor.closureDisposition?.file !== closureDispositionRelative
|| descriptor.closureDisposition?.sha256 !== closureDispositionSha256
|| descriptor.closureDisposition?.predecessorArtifactSha256
!== "d6870b5583a2f329eadb4e6cda65fdf4d271532df5ffbf8bfb1403968a434672"
|| descriptor.trust?.legacyDatabaseImported !== false
|| descriptor.trust?.legacyCredentialsImported !== false
|| descriptor.identity?.twoFactorAuthentication
!== "not-configured-by-transition"
) {
throw new Error("gitea_salvage_descriptor_contract_mismatch");
}
if (
disposition.schemaVersion !== "nodedc.gitea.incident-disposition.v1"
|| disposition.incidentId !== "gitea-20260814"
|| disposition.activation?.allowedOperation !== "canonical-plan-only"
|| disposition.activation?.applyFrozen !== true
|| disposition.activation?.freezeBoundary
!== "before-candidate-root-creation"
|| disposition.referencePolicy?.forensicScope?.allDiscoveredRefs !== 105
|| disposition.referencePolicy?.exactDecisions?.length !== 105
|| disposition.referencePolicy?.liveRestore?.totalRefs !== 93
|| disposition.referencePolicy?.archiveOnly?.totalRefs !== 12
) {
throw new Error("gitea_salvage_disposition_contract_mismatch");
}
if (
closureDisposition.schemaVersion
!== "nodedc.gitea.incident-closure-disposition.v1"
|| closureDisposition.incidentId !== "gitea-20260814"
|| closureDisposition.activation?.allowedOperation !== "canonical-plan-only"
|| closureDisposition.activation?.applyFrozen !== true
|| closureDisposition.closureReport?.expectedSha256 !== null
|| closureDisposition.predecessor?.dispositionSha256 !== dispositionSha256
|| closureDisposition.remainingBlockers
?.includes("closure-report-review-pin-pending") !== true
) {
throw new Error("gitea_salvage_closure_disposition_contract_mismatch");
}
}
function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function canonicalTarScript() {
return [
"import gzip,io,pathlib,sys,tarfile",
"root=pathlib.Path(sys.argv[2])",
"with open(sys.argv[1],'xb') 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");
}
@@ -1,63 +0,0 @@
#!/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 launcherRoot = resolve(process.env.NODEDC_LAUNCHER_REPO || resolve(scriptDir, "../../../../data/nodedc_launcher"));
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
const [patchId = "launcher-device-core-session-20260810-001", ...extra] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) throw new Error("usage: build-launcher-device-core-artifact.mjs [patch-id]");
const entries = [
"server/control-plane-store.mjs",
"server/dev-server.mjs",
"server/device-core-session-access.mjs",
"server/internal-request-auth.mjs",
"src/shared/api/adminApi.ts",
];
const stage = await mkdtemp(join(tmpdir(), "nodedc-launcher-device-core-"));
const payload = join(stage, "payload");
const target = join(artifactDir, `nodedc-launcher-${patchId}.tgz`);
try {
await mkdir(payload, { recursive: true });
for (const entry of entries) {
const source = resolve(launcherRoot, entry);
const sourceStat = await lstat(source);
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) throw new Error(`launcher_source_rejected:${entry}`);
const destination = join(payload, entry);
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, { force: true });
}
const server = await readFile(join(payload, "server/dev-server.mjs"), "utf8");
for (const required of ["resolveDeviceCoreSessionAccess", "NODEDC_DEVICE_CORE_INTERNAL_TOKEN_FILE", "deviceCoreInternalAccessConfigured"]) {
if (!server.includes(required)) throw new Error(`launcher_device_core_contract_missing:${required}`);
}
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=launcher\ntype=app-overlay\n`, "utf8");
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
const tar = spawnSync("python3", ["-c", canonicalTarScript(), target, stage], { encoding: "utf8" });
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
const sha256 = createHash("sha256").update(await readFile(target)).digest("hex");
console.log(JSON.stringify({ ok: true, patchId, component: "launcher", artifact: target, sha256, entries, services: ["launcher"] }, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
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");
}
@@ -1,80 +0,0 @@
#!/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 launcherRoot = resolve(process.env.NODEDC_LAUNCHER_REPO || resolve(scriptDir, "../../../../data/nodedc_launcher"));
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
const [patchId = "launcher-hub-service-trust-ui-20260810-001", ...extra] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
throw new Error("usage: build-launcher-hub-service-trust-ui-artifact.mjs [patch-id]");
}
const entries = [
"src/app/LauncherApp.tsx",
"src/styles/globals.css",
"src/widgets/admin-overlay/AdminOverlay.tsx",
];
const stage = await mkdtemp(join(tmpdir(), "nodedc-launcher-hub-service-trust-ui-"));
const payload = join(stage, "payload");
const target = join(artifactDir, `nodedc-launcher-${patchId}.tgz`);
try {
await mkdir(payload, { recursive: true });
for (const entry of entries) {
const source = resolve(launcherRoot, entry);
const sourceStat = await lstat(source);
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) throw new Error(`launcher_source_rejected:${entry}`);
const destination = join(payload, entry);
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, { force: true });
}
const app = await readFile(join(payload, "src/app/LauncherApp.tsx"), "utf8");
const overlay = await readFile(join(payload, "src/widgets/admin-overlay/AdminOverlay.tsx"), "utf8");
const styles = await readFile(join(payload, "src/styles/globals.css"), "utf8");
for (const required of ["upsertAdminGrant", 'targetType: "client"', "pendingClientGrantAssignments"]) {
if (!app.includes(required)) throw new Error(`launcher_hub_grant_contract_missing:${required}`);
}
for (const required of ["authentikGroupName", "authHandoffPath", "onSetClientServiceGrant"]) {
if (!overlay.includes(required)) throw new Error(`launcher_hub_service_trust_contract_missing:${required}`);
}
if (!styles.includes(".client-service-grants")) throw new Error("launcher_hub_service_trust_style_missing");
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=launcher\ntype=app-overlay\n`, "utf8");
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
const tar = spawnSync("python3", ["-c", canonicalTarScript(), target, stage], { encoding: "utf8" });
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
const sha256 = createHash("sha256").update(await readFile(target)).digest("hex");
console.log(JSON.stringify({
ok: true,
patchId,
component: "launcher",
artifact: target,
sha256,
entries,
services: ["launcher"],
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
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");
}
@@ -1,48 +0,0 @@
#!/usr/bin/env python3
"""Deterministic data-only declaration for the registered NAS map access domain."""
import argparse
import gzip
import hashlib
import io
import json
import re
import tarfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def build(patch, state="enabled"):
if not re.fullmatch(r"[A-Za-z0-9._-]{1,96}", patch) or state not in ("enabled", "disabled"):
raise ValueError("Invalid map access release")
descriptor = json.loads((ROOT / "deployment/mission-core-map-access/access.json").read_text())
if descriptor != {"schemaVersion": "nodedc.mission-core-map-access.v1", "state": "enabled"}:
raise ValueError("Unexpected source contract")
descriptor["state"] = state
members = {
"manifest.env": f"id={patch}\ncomponent=mission-core-map-access\ntype=app-overlay\n".encode(),
"files.txt": b"access.json\n",
"payload/access.json": (json.dumps(descriptor, sort_keys=True, separators=(",", ":")) + "\n").encode(),
}
result = io.BytesIO()
with gzip.GzipFile(fileobj=result, mode="wb", filename="", mtime=0) as compressed:
with tarfile.open(fileobj=compressed, mode="w", format=tarfile.USTAR_FORMAT) as archive:
for name, content in members.items():
member = tarfile.TarInfo(name); member.mode = 0o644; member.size = len(content)
archive.addfile(member, io.BytesIO(content))
return result.getvalue()
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("patch")
parser.add_argument("--state", choices=("enabled", "disabled"), default="enabled")
args = parser.parse_args()
raw = build(args.patch, args.state)
target = ROOT / "infra/deploy-artifacts" / ("nodedc-" + args.patch + ".tgz")
target.parent.mkdir(parents=True, exist_ok=True)
with target.open("xb") as output:
output.write(raw)
digest = hashlib.sha256(raw).hexdigest()
target.with_suffix(target.suffix + ".sha256").write_text(digest + " " + target.name + "\n")
print(json.dumps({"artifact": str(target), "sha256": digest, "bytes": len(raw)}))
@@ -13,7 +13,7 @@ const foundryRoot = resolve(workspaceRoot, "NODEDC_DESIGN_GUIDELINE");
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"),
);
const [patchId = "module-foundry-map-grid-lod-20260805-002", ...extra] =
const [patchId = "module-foundry-map-grid-lod-20260805-001", ...extra] =
process.argv.slice(2);
if (extra.length || !/^module-foundry-map-grid-lod-\d{8}-\d{3}$/.test(patchId)) {
@@ -28,10 +28,9 @@ const files = Object.freeze([
"apps/catalog/src/MapFixturePreview.tsx",
"apps/catalog/src/mapGridPolicy.d.mts",
"apps/catalog/src/mapGridPolicy.mjs",
"apps/catalog/src/styles.css",
"package.json",
"runtime-seed/page-layouts/map.json",
"scripts/map-grid-lod.test.mjs",
"scripts/map-object-layers.test.mjs",
"server/catalog-server.mjs",
"server/foundry-mcp.mjs",
]);
@@ -70,7 +69,7 @@ try {
checksum,
sha256,
services: ["nodedc-module-foundry"],
transition: "five-lod-mmap-parity-fast-grid-and-focus-recovery",
transition: "five-band-grid-lod-with-seamless-layer-swap",
files,
}, null, 2));
} finally {
@@ -81,40 +80,31 @@ async function assertGridLodBoundary() {
const policy = await readFile(join(foundryRoot, "apps/catalog/src/mapGridPolicy.mjs"), "utf8");
const renderer = await readFile(join(foundryRoot, "apps/catalog/src/CesiumMapRenderer.tsx"), "utf8");
const inspector = await readFile(join(foundryRoot, "apps/catalog/src/MapFixturePreview.tsx"), "utf8");
const styles = await readFile(join(foundryRoot, "apps/catalog/src/styles.css"), "utf8");
const server = await readFile(join(foundryRoot, "server/catalog-server.mjs"), "utf8");
const tests = await readFile(join(foundryRoot, "scripts/map-grid-lod.test.mjs"), "utf8");
const focusTests = await readFile(join(foundryRoot, "scripts/map-object-layers.test.mjs"), "utf8");
for (const marker of ["gridLodProfile", "gridLodProfiles", "GRID_LOD_HYSTERESIS_RATIO"]) {
for (const marker of ["gridLod1MaxHeightKm", "gridLod5StepKm", "GRID_LOD_HYSTERESIS_RATIO"]) {
if (!policy.includes(marker)) throw new Error(`grid_policy_marker_missing:${marker}`);
}
for (const marker of ["class GridLayerController", "gridLegacyMode", "lineDiameterMeters", "viewer.flyTo(entity"]) {
for (const marker of ["class GridLayerController", "corridor:", "gridLineDiameterMeters"]) {
if (!renderer.includes(marker)) throw new Error(`grid_renderer_marker_missing:${marker}`);
}
if (/function rebuildElevatedGrid[\s\S]*?entities\.removeAll\(\)/.test(renderer)) {
throw new Error("grid_renderer_destructive_layer_swap_detected");
}
for (const marker of ["LOD ${index + 1}", "Конус видимости 3D", "Размер major-тайла ENU", "Радиус ENU-поля", "Диаметр 3D-линий", "Кружки: диаметр", "Кресты: длина"]) {
for (const marker of ["LOD 5: режим", "3D-линии: диаметр", "Гратикула: толщина"]) {
if (!inspector.includes(marker)) throw new Error(`grid_inspector_marker_missing:${marker}`);
}
for (const marker of ["catalog-map-grid-lod-tabs", "flex: 1 1 0"]) {
if (!styles.includes(marker)) throw new Error(`grid_styles_marker_missing:${marker}`);
}
for (const marker of ["gridLodProfiles", "validateGridLodProfiles", "gridRebuildOnMoveEnd"]) {
for (const marker of ["gridLod5Mode", "gridLineDiameterMeters", "gridRebuildOnMoveEnd"]) {
if (!server.includes(marker)) throw new Error(`grid_server_contract_missing:${marker}`);
}
for (const marker of [
"canonical defaults preserve the exact effective MMAP/MOSCOWMAP five-LOD donor profile",
"every LOD owns independent metric and angular spacing and visual fields",
"LOD hysteresis holds the previous band for eight percent on either side of a threshold",
"renderer uses fixed ENU sectors, angular graticules and a non-blank double-buffer swap",
"five grid LODs preserve the close 3D and distant graticule contract",
"LOD hysteresis holds the previous band around a threshold",
"renderer swaps double-buffered data sources and never clears the live grid first",
]) {
if (!tests.includes(marker)) throw new Error(`grid_regression_missing:${marker}`);
}
for (const marker of ["void viewer\\.flyTo\\(entity", "HeadingPitchRange\\(0, -0\\.9, 8_000\\)"]) {
if (!focusTests.includes(marker)) throw new Error(`map_focus_regression_missing:${marker}`);
}
}
async function assertFresh(path) {
@@ -1,132 +0,0 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFile, 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 workspaceRoot = resolve(scriptDir, "../../..");
const foundryRoot = resolve(workspaceRoot, "NODEDC_DESIGN_GUIDELINE");
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"),
);
const [patchId = "module-foundry-map-focus-recovery-20260805-001", ...extra] =
process.argv.slice(2);
if (extra.length || !/^module-foundry-map-focus-recovery-\d{8}-\d{3}$/.test(patchId)) {
throw new Error(
"usage: build-module-foundry-map-focus-artifact.mjs "
+ "[module-foundry-map-focus-recovery-YYYYMMDD-NNN]",
);
}
const files = Object.freeze([
"apps/catalog/src/CesiumMapRenderer.tsx",
"scripts/map-object-layers.test.mjs",
]);
const artifact = join(artifactDir, `nodedc-${patchId}.tgz`);
const checksum = `${artifact}.sha256`;
const stage = await mkdtemp(join(tmpdir(), "nodedc-foundry-map-focus-"));
await assertFresh(artifact);
await assertMapFocusBoundary();
try {
for (const relativePath of files) {
const source = join(foundryRoot, relativePath);
const info = await lstat(source);
if (!info.isFile() || info.isSymbolicLink()) {
throw new Error(`source_file_rejected:${relativePath}`);
}
const destination = join(stage, "payload", relativePath);
await mkdir(dirname(destination), { recursive: true });
await copyFile(source, destination);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=module-foundry\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
const sha256 = digest(await readFile(artifact));
await writeFile(checksum, `${sha256} ${artifact.split("/").at(-1)}\n`, "utf8");
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
checksum,
sha256,
services: ["nodedc-module-foundry"],
transition: "restore-entity-aware-map-focus-flight",
files,
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertMapFocusBoundary() {
const renderer = await readFile(join(foundryRoot, files[0]), "utf8");
const regression = await readFile(join(foundryRoot, files[1]), "utf8");
for (const marker of [
"void viewer.flyTo(entity, {",
"duration: 0.45",
"offset: new HeadingPitchRange(0, -0.9, 8_000)",
]) {
if (!renderer.includes(marker)) throw new Error(`map_focus_renderer_marker_missing:${marker}`);
}
for (const marker of [
"void viewer\\.flyTo\\(entity, \\{",
"duration: 0\\.45",
"offset: new HeadingPitchRange\\(0, -0\\.9, 8_000\\)",
]) {
if (!regression.includes(marker)) {
throw new Error(`map_focus_regression_marker_missing:${marker}`);
}
}
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
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");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
}
@@ -0,0 +1,304 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import {
cp,
lstat,
mkdir,
mkdtemp,
readdir,
readFile,
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 declaredFoundryRoot = process.env.NODEDC_FOUNDRY_SOURCE_ROOT;
if (!declaredFoundryRoot) {
throw new Error("NODEDC_FOUNDRY_SOURCE_ROOT_is_required");
}
const foundryRoot = resolve(declaredFoundryRoot);
const expectedSourceCommit = "58800d957632320fa6717ec3fcff972023528759";
const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|| resolve(scriptDir, "../deploy-artifacts"),
);
const [
patchId = "module-foundry-map-runtime-recovery-20260809-003",
...extra
] = process.argv.slice(2);
if (
extra.length
|| !/^module-foundry-map-runtime-recovery-\d{8}-\d{3}$/.test(patchId)
) {
throw new Error(
"usage: build-module-foundry-map-runtime-recovery-artifact.mjs "
+ "[module-foundry-map-runtime-recovery-YYYYMMDD-NNN]",
);
}
const files = Object.freeze([
"package.json",
"package-lock.json",
"apps/catalog/src",
"packages",
"registry",
"runtime-seed/page-layouts/map.json",
"scripts",
"server/catalog-server.mjs",
"server/foundry-mcp.mjs",
"server/map-grid-persistence.test.mjs",
]);
const ignoredBasenames = new Set([
".DS_Store",
".git",
"node_modules",
"runtime-data",
"dist",
]);
const artifact = join(artifactDir, `nodedc-${patchId}.tgz`);
const checksum = `${artifact}.sha256`;
const stage = await mkdtemp(
join(tmpdir(), "nodedc-foundry-map-runtime-recovery-"),
);
await assertFresh(artifact);
await assertExactSource();
await assertRecoveryBoundary();
try {
for (const sourceRelative of files) {
await copySafe(
resolve(foundryRoot, sourceRelative),
join(stage, "payload", sourceRelative),
);
}
await writeFile(
join(stage, "manifest.env"),
`id=${patchId}\ncomponent=module-foundry\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${files.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
run("python3", ["-c", canonicalTarScript(), artifact, stage]);
const sha256 = digest(await readFile(artifact));
await writeFile(
checksum,
`${sha256} ${artifact.split("/").at(-1)}\n`,
"utf8",
);
console.log(JSON.stringify({
ok: true,
patchId,
artifact,
checksum,
sha256,
sourceCommit: expectedSourceCommit,
services: ["nodedc-module-foundry"],
transition: "reconcile-partial-map-source-and-activate-runtime",
files,
}, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
async function assertExactSource() {
const declared = process.env.NODEDC_FOUNDRY_SOURCE_COMMIT;
if (declared !== expectedSourceCommit) {
throw new Error("foundry_source_commit_mismatch");
}
const dotGit = join(foundryRoot, ".git");
try {
await lstat(dotGit);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
const tracked = spawnSync(
"git",
["-C", foundryRoot, "diff", "--quiet", "HEAD", "--", ...files],
{ encoding: "utf8" },
);
if (tracked.status !== 0) {
throw new Error("foundry_selected_source_is_dirty");
}
const untracked = git(foundryRoot, [
"ls-files",
"--others",
"--exclude-standard",
"--",
...files,
]);
if (untracked) {
throw new Error("foundry_selected_source_has_untracked_files");
}
}
async function assertRecoveryBoundary() {
const packageJson = await readFile(join(foundryRoot, "package.json"), "utf8");
const packageLock = await readFile(
join(foundryRoot, "package-lock.json"),
"utf8",
);
const preview = await readFile(
join(foundryRoot, "apps/catalog/src/MapFixturePreview.tsx"),
"utf8",
);
const renderer = await readFile(
join(foundryRoot, "apps/catalog/src/CesiumMapRenderer.tsx"),
"utf8",
);
const policy = await readFile(
join(foundryRoot, "apps/catalog/src/mapGridPolicy.mjs"),
"utf8",
);
const sector = await readFile(
join(foundryRoot, "apps/catalog/src/mapSectorGrid.mjs"),
"utf8",
);
const server = await readFile(
join(foundryRoot, "server/catalog-server.mjs"),
"utf8",
);
const registry = await readFile(
join(foundryRoot, "registry/registry.json"),
"utf8",
);
for (const marker of [
"@nodedc/map-cesium-react",
"npm run build --workspace @nodedc/map-cesium-react",
]) {
if (!packageJson.includes(marker)) {
throw new Error(`map_package_boundary_missing:${marker}`);
}
}
if (!packageLock.includes('"packages/map-cesium-react"')) {
throw new Error("map_package_lock_boundary_missing");
}
for (const marker of [
"const sectorSpatialEntities = useMemo",
"runtimeBindings={[...sectorScopedPrimaryRuntimeBindings, ...referenceRuntimeBindings]}",
'label="Скрыть объекты за сектором"',
]) {
if (!preview.includes(marker)) {
throw new Error(`map_workspace_boundary_missing:${marker}`);
}
}
for (const marker of [
"class GridLayerController",
"gridLegacyMode",
"viewer.flyTo(entity",
]) {
if (!renderer.includes(marker)) {
throw new Error(`map_renderer_boundary_missing:${marker}`);
}
}
for (const marker of [
"gridLodProfiles",
"GRID_LOD_HYSTERESIS_RATIO",
]) {
if (!policy.includes(marker)) {
throw new Error(`map_policy_boundary_missing:${marker}`);
}
}
for (const marker of [
"export function geodeticToLocalGridPlane",
"export function localSectorAtGeodetic",
]) {
if (!sector.includes(marker)) {
throw new Error(`map_sector_boundary_missing:${marker}`);
}
}
for (const marker of [
"gridLodProfiles",
"validateGridLodProfiles",
]) {
if (!server.includes(marker)) {
throw new Error(`map_server_boundary_missing:${marker}`);
}
}
if (!registry.includes('"@nodedc/map-cesium-react"')) {
throw new Error("map_registry_boundary_missing");
}
}
async function copySafe(source, destination) {
const sourceStat = await lstat(source);
if (sourceStat.isSymbolicLink()) {
throw new Error(
`source_symlink_rejected:${relative(foundryRoot, 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:${relative(foundryRoot, 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(".tsbuildinfo")
) {
continue;
}
const childSource = join(source, entry.name);
const childDestination = join(destination, entry.name);
if (entry.isSymbolicLink()) {
throw new Error(
`source_symlink_rejected:${relative(foundryRoot, childSource)}`,
);
}
await copySafe(childSource, childDestination);
}
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error("artifact_already_exists");
}
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");
}
function digest(value) {
return createHash("sha256").update(value).digest("hex");
}
function run(command, args) {
const result = spawnSync(command, args, {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
});
if (result.status !== 0) {
throw new Error(`${command}_failed:${result.stderr || result.stdout}`);
}
}
@@ -1,72 +0,0 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { cp, 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 artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
const [patchId = "platform-device-core-hub-trust-20260810-001", ...extra] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) throw new Error("usage: build-platform-device-core-hub-trust-artifact.mjs [patch-id]");
const entries = [
"platform/docker-compose.platform-http.yml",
"platform/deployment/device-core-hub-trust-v1.json",
];
const sources = new Map([
[entries[0], resolve(platformRoot, "infra/synology/docker-compose.platform-http.yml")],
[entries[1], resolve(platformRoot, "infra/deployment/device-core-hub-trust-v1.json")],
]);
await buildArtifact({ patchId, component: "platform", entries, sources });
async function buildArtifact({ patchId, component, entries, sources }) {
const stage = await mkdtemp(join(tmpdir(), "nodedc-platform-device-core-trust-"));
const payload = join(stage, "payload");
const target = join(artifactDir, `nodedc-${component}-${patchId}.tgz`);
try {
await mkdir(payload, { recursive: true });
for (const entry of entries) {
const destination = join(payload, entry);
await mkdir(dirname(destination), { recursive: true });
await cp(sources.get(entry), destination, { force: true });
}
const compose = await readFile(join(payload, entries[0]), "utf8");
for (const required of [
"NODEDC_DEVICE_CORE_INTERNAL_TOKEN_FILE: /run/nodedc-secrets/device-core-internal-token",
"source: /volume1/docker/nodedc-platform/secrets/device-core-internal-token",
"create_host_path: false",
]) if (!compose.includes(required)) throw new Error(`hub_trust_compose_contract_missing:${required}`);
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=${component}\ntype=app-overlay\n`, "utf8");
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
canonicalTar(target, stage);
const sha256 = createHash("sha256").update(await readFile(target)).digest("hex");
console.log(JSON.stringify({ ok: true, patchId, component, artifact: target, sha256, entries, services: ["launcher"] }, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
}
function canonicalTar(target, stage) {
const result = spawnSync("python3", ["-c", canonicalTarScript(), target, stage], { encoding: "utf8" });
if (result.status !== 0) throw new Error(`tar_failed:${result.stderr || result.stdout}`);
}
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");
}
@@ -1,54 +0,0 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { cp, 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 artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
const [patchId = "platform-device-manager-public-route-20260810-001", ...extra] = process.argv.slice(2);
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) throw new Error("usage: build-platform-device-manager-route-artifact.mjs [patch-id]");
const entries = [
"platform/Caddyfile.http",
"platform/deployment/device-manager-public-route-v1.json",
];
const stage = await mkdtemp(join(tmpdir(), "nodedc-device-manager-route-"));
const payload = join(stage, "payload");
const target = join(artifactDir, `nodedc-platform-${patchId}.tgz`);
try {
await mkdir(join(payload, "platform/deployment"), { recursive: true });
await cp(resolve(platformRoot, "infra/synology/Caddyfile.http"), join(payload, entries[0]), { force: true });
await cp(resolve(platformRoot, "infra/deployment/device-manager-public-route-v1.json"), join(payload, entries[1]), { force: true });
const caddy = await readFile(join(payload, entries[0]), "utf8");
for (const required of ["http://device.nodedc.ru", "reverse_proxy device-manager:18122", "X-Forwarded-Proto https"]) {
if (!caddy.includes(required)) throw new Error(`device_manager_route_contract_missing:${required}`);
}
await writeFile(join(stage, "manifest.env"), `id=${patchId}\ncomponent=platform\ntype=app-overlay\n`, "utf8");
await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, "utf8");
await mkdir(artifactDir, { recursive: true });
const tar = spawnSync("python3", ["-c", canonicalTarScript(), target, stage], { encoding: "utf8" });
if (tar.status !== 0) throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
const sha256 = createHash("sha256").update(await readFile(target)).digest("hex");
console.log(JSON.stringify({ ok: true, patchId, component: "platform", artifact: target, sha256, entries, services: ["reverse-proxy"] }, null, 2));
} finally {
await rm(stage, { recursive: true, force: true });
}
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");
}
@@ -1,126 +0,0 @@
#!/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 taskerRoot = resolve(
process.env.NODEDC_TASKMANAGER_ROOT || resolve(platformRoot, "../../data/dc_taskmanager/NODEDC_TASKMANAGER"),
);
const artifactDir = resolve(process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"));
const [release = "20260829-001", ...extra] = process.argv.slice(2);
if (extra.length || release !== "20260829-001") {
throw new Error("usage: build-tasker-attachment-formats-artifact.mjs [20260829-001]");
}
const descriptor = {
artifactBasename: `nodedc-tasker-attachment-formats-${release}.tgz`,
component: "tasker",
expectedCommit: "f9308539bfe71e4e2359c47ed5c230fa1fb386c9",
files: [
"plane-src/apps/api/plane/settings/common.py",
"plane-src/apps/web/core/components/issues/attachment/attachment-list-item.tsx",
"plane-src/apps/web/core/components/issues/peek-overview/view.tsx",
"plane-src/apps/web/styles/globals.css",
"plane-src/packages/services/src/file/helper.ts",
],
patchId: `tasker-attachment-formats-${release}`,
sourceRoot: taskerRoot,
};
const sourceCommit = gitOutput(descriptor.sourceRoot, ["rev-parse", "HEAD"]);
if (sourceCommit !== descriptor.expectedCommit) {
throw new Error(`source_commit_mismatch:${descriptor.component}:${sourceCommit}`);
}
const sourceStatus = gitOutput(descriptor.sourceRoot, ["status", "--porcelain"]);
if (sourceStatus) throw new Error(`source_worktree_not_clean:${descriptor.component}`);
await mkdir(artifactDir, { recursive: true });
const stage = await mkdtemp(join(tmpdir(), "nodedc-tasker-attachment-formats-"));
const payload = join(stage, "payload");
const artifact = join(artifactDir, descriptor.artifactBasename);
try {
await assertFresh(artifact);
await mkdir(payload, { recursive: true });
for (const relativePath of descriptor.files) {
const source = resolve(descriptor.sourceRoot, relativePath);
const sourceStat = await lstat(source);
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
throw new Error(`source_file_rejected:${descriptor.component}:${relativePath}`);
}
const destination = join(payload, relativePath);
await mkdir(dirname(destination), { recursive: true });
await cp(source, destination, { force: false, verbatimSymlinks: true });
}
await writeFile(
join(stage, "manifest.env"),
`id=${descriptor.patchId}\ncomponent=${descriptor.component}\ntype=app-overlay\n`,
"utf8",
);
await writeFile(join(stage, "files.txt"), `${descriptor.files.join("\n")}\n`, "utf8");
const tar = spawnSync("python3", ["-c", canonicalTarScript(), artifact, stage], {
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
});
if (tar.status !== 0) throw new Error(`tar_failed:${descriptor.component}:${tar.stderr || tar.stdout}`);
const sha256 = createHash("sha256").update(await readFile(artifact)).digest("hex");
console.log(
JSON.stringify(
{
ok: true,
release,
artifact,
component: descriptor.component,
files: descriptor.files,
patchId: descriptor.patchId,
sha256,
sourceCommit,
},
null,
2,
),
);
} finally {
await rm(stage, { recursive: true, force: true });
}
function gitOutput(cwd, args) {
const result = spawnSync("git", args, { cwd, encoding: "utf8" });
if (result.status !== 0) throw new Error(`git_failed:${cwd}:${args.join("_")}:${result.stderr || result.stdout}`);
return result.stdout.trim();
}
async function assertFresh(path) {
try {
await lstat(path);
} catch (error) {
if (error?.code === "ENOENT") return;
throw error;
}
throw new Error(`output_already_exists:${path}`);
}
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,118 @@
services:
device-postgres:
image: postgres:16-alpine
pull_policy: missing
restart: unless-stopped
environment:
POSTGRES_DB: device_plane
POSTGRES_USER: device_plane
POSTGRES_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
volumes:
- type: volume
source: device-plane-postgres-data
target: /var/lib/postgresql/data
- type: bind
source: /volume1/docker/nodedc-device-plane/secrets/postgres-password
target: /run/nodedc-secrets/postgres-password
read_only: true
bind:
create_host_path: false
networks:
- device-plane-private
healthcheck:
test: ["CMD-SHELL", "pg_isready -U device_plane -d device_plane"]
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
device-control-core:
image: nodedc/device-control-core:local
pull_policy: never
restart: unless-stopped
user: "1000:1000"
read_only: true
tmpfs:
- /tmp:size=16m,mode=1777
environment:
HOST: 0.0.0.0
PORT: "18120"
DEVICE_DATABASE_HOST: device-postgres
DEVICE_DATABASE_PORT: "5432"
DEVICE_DATABASE_NAME: device_plane
DEVICE_DATABASE_USER: device_plane
DEVICE_DATABASE_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
DEVICE_DATABASE_POOL_SIZE: "10"
DEVICE_DISCOVERY_INGEST_ENABLED: "false"
volumes:
- type: bind
source: /volume1/docker/nodedc-device-plane/secrets/postgres-password
target: /run/nodedc-secrets/postgres-password
read_only: true
bind:
create_host_path: false
ports:
- "127.0.0.1:18120:18120"
networks:
- device-plane-private
depends_on:
device-postgres:
condition: service_healthy
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test:
- CMD
- node
- -e
- fetch('http://127.0.0.1:18120/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
device-gateway:
image: nodedc/device-gateway:local
pull_policy: never
restart: unless-stopped
user: "1000:1000"
read_only: true
tmpfs:
- /tmp:size=16m,mode=1777
environment:
DEVICE_GATEWAY_HEALTH_HOST: 0.0.0.0
DEVICE_GATEWAY_HEALTH_PORT: "18121"
DEVICE_GATEWAY_LISTEN_ENABLED: "false"
DEVICE_GATEWAY_TCP_HOST: 127.0.0.1
DEVICE_GATEWAY_TCP_PORT: "9921"
DEVICE_GATEWAY_MAX_SESSIONS: "100"
DEVICE_GATEWAY_SESSION_TIMEOUT_MS: "10000"
ports:
- "127.0.0.1:18121:18121"
networks:
- device-plane-private
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test:
- CMD
- node
- -e
- fetch('http://127.0.0.1:18121/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
interval: 10s
timeout: 5s
retries: 12
start_period: 10s
networks:
device-plane-private:
name: nodedc-device-plane-private
internal: true
volumes:
device-plane-postgres-data:
name: nodedc-device-plane-postgres-data
@@ -0,0 +1,126 @@
services:
device-postgres:
image: postgres:16-alpine
pull_policy: missing
restart: unless-stopped
environment:
POSTGRES_DB: device_plane
POSTGRES_USER: device_plane
POSTGRES_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
volumes:
- type: volume
source: device-plane-postgres-data
target: /var/lib/postgresql/data
- type: bind
source: /volume1/docker/nodedc-device-plane/secrets/postgres-password
target: /run/nodedc-secrets/postgres-password
read_only: true
bind:
create_host_path: false
networks:
- device-plane-private
healthcheck:
test: ["CMD-SHELL", "pg_isready -U device_plane -d device_plane"]
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
device-control-core:
image: nodedc/device-control-core:local
pull_policy: never
restart: unless-stopped
user: "1000:1000"
read_only: true
tmpfs:
- /tmp:size=16m,mode=1777
environment:
HOST: 0.0.0.0
PORT: "18120"
DEVICE_DATABASE_HOST: device-postgres
DEVICE_DATABASE_PORT: "5432"
DEVICE_DATABASE_NAME: device_plane
DEVICE_DATABASE_USER: device_plane
DEVICE_DATABASE_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
DEVICE_DATABASE_POOL_SIZE: "10"
DEVICE_DISCOVERY_INGEST_ENABLED: "false"
volumes:
- type: bind
source: /volume1/docker/nodedc-device-plane/secrets/postgres-password
target: /run/nodedc-secrets/postgres-password
read_only: true
bind:
create_host_path: false
ports:
- "127.0.0.1:18120:18120"
networks:
- device-plane-private
- device-plane-control
depends_on:
device-postgres:
condition: service_healthy
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test:
- CMD
- node
- -e
- fetch('http://127.0.0.1:18120/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
device-gateway:
image: nodedc/device-gateway:local
pull_policy: never
restart: unless-stopped
user: "1000:1000"
read_only: true
tmpfs:
- /tmp:size=16m,mode=1777
environment:
DEVICE_GATEWAY_HEALTH_HOST: 0.0.0.0
DEVICE_GATEWAY_HEALTH_PORT: "18121"
DEVICE_GATEWAY_LISTEN_ENABLED: "false"
DEVICE_GATEWAY_TCP_HOST: 127.0.0.1
DEVICE_GATEWAY_TCP_PORT: "9921"
DEVICE_GATEWAY_MAX_SESSIONS: "100"
DEVICE_GATEWAY_SESSION_TIMEOUT_MS: "10000"
ports:
- "127.0.0.1:18121:18121"
networks:
- device-plane-private
- device-plane-control
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test:
- CMD
- node
- -e
- fetch('http://127.0.0.1:18121/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
interval: 10s
timeout: 5s
retries: 12
start_period: 10s
networks:
device-plane-private:
name: nodedc-device-plane-private
internal: true
device-plane-control:
name: nodedc-device-plane-control
driver: bridge
internal: false
driver_opts:
com.docker.network.bridge.enable_ip_masquerade: "false"
volumes:
device-plane-postgres-data:
name: nodedc-device-plane-postgres-data
@@ -1,112 +0,0 @@
{
"schemaVersion": "nodedc.gitea.incident-salvage.v1",
"action": "clean-state-salvage",
"component": "gitea",
"incidentId": "gitea-20260814",
"installRoot": "/volume1/docker/nodedc-gitea",
"compose": {
"file": "docker-compose.gitea.yml",
"project": "nodedc-gitea",
"service": "gitea",
"sha256": "2f031d5bfff4f42c73cabd8c94487ec3e4f1e1b0a96d3b7eec958904f735908a"
},
"runtime": {
"image": "docker.gitea.com/gitea:1.27.2-rootless@sha256:7de5f49ada687b8c8d2938f547cdb7634839764ba51f297457bae35cee3abd2c",
"imageId": "sha256:272085a806e6d182352cdb011c0ebab1d2efc7ec45247de84de5659c7bc5c4c6",
"repoDigest": "docker.gitea.com/gitea@sha256:7de5f49ada687b8c8d2938f547cdb7634839764ba51f297457bae35cee3abd2c",
"platform": "linux/amd64",
"pullPolicy": "never",
"entrypoint": "/usr/local/bin/gitea",
"runAs": "1000:1000",
"transport": "unix:/run/gitea/gitea.sock",
"networkMode": "none",
"ssh": "disabled-no-published-port",
"lfs": "unsupported-hard-stop",
"database": "new-sqlite-1.27.2-only",
"logging": "bounded-json-file-10m-x3",
"stopGracePeriod": "30s",
"candidateRestartPolicy": "no",
"acceptedRestartPolicy": "unless-stopped"
},
"decision": {
"schema": "nodedc.gitea.incident-decision/v2",
"manifestFile": "deployment/gitea-incident-salvage/confirmed-decision.json",
"manifestSha256": "dc9528462624158eb44218d37cc7054d551ca2d7ded562592982aa3f34c9fc2a",
"usersFile": "deployment/gitea-incident-salvage/users.decisions.csv",
"usersSha256": "e3b82f1073a86eea9e567edff062dd1689d21ec0edcf3ed92e844da9351ee8b6",
"repositoriesFile": "deployment/gitea-incident-salvage/repositories.decisions.csv",
"repositoriesSha256": "76b4bae2ab5cec490330c19bfc5ae9429abf7636705ae028c1c64fd54a6a0493",
"users": {
"active": 2,
"locked": 8,
"delete": 962
},
"repositories": {
"keep": 45,
"delete": 2013,
"dctouchPrivate": 32,
"silverPublic": 13
}
},
"snapshot": {
"root": "/volume1/.nodedc-security-snapshots/docker-gitea-incident-20260814",
"uuid": "f5a3fe3a-93ea-bb4d-847f-6221a6bcbc9f",
"readOnlyRequired": true,
"database": "gitea/gitea/gitea.db",
"databaseBytes": 182681600,
"databaseSha256": "8db9e74a5641662a808d8252c5d6c9de43fe9efd8687634bfbff2d4361a13052",
"repositoriesRoot": "gitea/git/repositories"
},
"trust": {
"legacyRootMounted": false,
"legacyDatabaseImported": false,
"legacyConfigImported": false,
"legacyCredentialsImported": false,
"artifactSecrets": "forbidden",
"runtimeSecrets": "runner-generated",
"repositorySelection": "exact-v2-rows-only",
"repositoryCopy": "same-filesystem-per-file-reflink-object-and-validated-ref-material-only",
"forbiddenRepositoryMaterial": [
"hooks",
"config",
"alternates",
"http-alternates",
"shallow",
"replace",
"grafts",
"worktrees",
"commondir",
"lfs"
],
"unsupportedDatabaseState": "zero-material-rows-required-before-apply"
},
"identity": {
"preserveNumericUserIds": false,
"preserveNumericRepositoryIds": false,
"oldToNewIdMapping": "/volume1/docker/nodedc-gitea/audit/identity-map.json",
"emails": "new-local-noreply-only",
"passwordHashes": "never-imported",
"activeBootstrapCredentials": "root-only-runner-state-require-change",
"lockedCredentials": "fresh-random-discarded-and-login-prohibited",
"twoFactorAuthentication": "not-configured-by-transition"
},
"externalPrerequisites": {
"legacyContainer": "exact-inspect-pin-required-stopped-restart-no",
"publicProxy": "reviewed-nginx-uds-bridge-and-dsm-loopback-upstream",
"firewall": "legacy-isolation-retained-and-loopback-3000-ready",
"candidatePublicationGate": "socket-parent-uid1000-gid1023-mode0700",
"publishAction": "runner-chmod-socket-parent-0750-after-internal-acceptance",
"mustPassBeforeApply": true
},
"acceptance": {
"users": "exact-10-row-by-row",
"repositories": "exact-45-row-by-row-and-visibility",
"credentialsSessionsKeysWebhooksActions": "zero",
"git": "fsck-all-and-public-smart-http-smoke",
"registration": "closed",
"ssh": "disabled",
"network": "none-no-ports",
"publicHttps": "required-before-ledger-success"
},
"rollback": "stop-remove-candidate-prove-absent-quarantine-new-root-never-start-legacy"
}
@@ -1,117 +0,0 @@
{
"schemaVersion": "nodedc.gitea.incident-salvage.v2",
"action": "clean-state-salvage",
"component": "gitea",
"incidentId": "gitea-20260814",
"installRoot": "/volume1/docker/nodedc-gitea",
"compose": {
"file": "docker-compose.gitea.yml",
"project": "nodedc-gitea",
"service": "gitea",
"sha256": "2f031d5bfff4f42c73cabd8c94487ec3e4f1e1b0a96d3b7eec958904f735908a"
},
"runtime": {
"image": "docker.gitea.com/gitea:1.27.2-rootless@sha256:7de5f49ada687b8c8d2938f547cdb7634839764ba51f297457bae35cee3abd2c",
"imageId": "sha256:272085a806e6d182352cdb011c0ebab1d2efc7ec45247de84de5659c7bc5c4c6",
"repoDigest": "docker.gitea.com/gitea@sha256:7de5f49ada687b8c8d2938f547cdb7634839764ba51f297457bae35cee3abd2c",
"platform": "linux/amd64",
"pullPolicy": "never",
"entrypoint": "/usr/local/bin/gitea",
"runAs": "1000:1000",
"transport": "unix:/run/gitea/gitea.sock",
"networkMode": "none",
"ssh": "disabled-no-published-port",
"lfs": "disabled-pending-reachable-pointer-physical-sha-verifier",
"database": "new-sqlite-1.27.2-only",
"logging": "bounded-json-file-10m-x3",
"stopGracePeriod": "30s",
"candidateRestartPolicy": "no",
"acceptedRestartPolicy": "unless-stopped"
},
"decision": {
"schema": "nodedc.gitea.incident-decision/v2",
"manifestFile": "deployment/gitea-incident-salvage/confirmed-decision.json",
"manifestSha256": "dc9528462624158eb44218d37cc7054d551ca2d7ded562592982aa3f34c9fc2a",
"usersFile": "deployment/gitea-incident-salvage/users.decisions.csv",
"usersSha256": "e3b82f1073a86eea9e567edff062dd1689d21ec0edcf3ed92e844da9351ee8b6",
"repositoriesFile": "deployment/gitea-incident-salvage/repositories.decisions.csv",
"repositoriesSha256": "76b4bae2ab5cec490330c19bfc5ae9429abf7636705ae028c1c64fd54a6a0493",
"users": {
"active": 2,
"locked": 8,
"delete": 962
},
"repositories": {
"keep": 45,
"delete": 2013,
"dctouchPrivate": 32,
"silverPublic": 13
}
},
"disposition": {
"schema": "nodedc.gitea.incident-disposition.v1",
"file": "deployment/gitea-incident-salvage/confirmed-disposition-v1.json",
"sha256": "0a066724bcf6e4933133db6cab6cc273393e3c262dd00dda0bbf9ceebd84f78c"
},
"snapshot": {
"root": "/volume1/.nodedc-security-snapshots/docker-gitea-incident-20260814",
"uuid": "f5a3fe3a-93ea-bb4d-847f-6221a6bcbc9f",
"readOnlyRequired": true,
"database": "gitea/gitea/gitea.db",
"databaseBytes": 182681600,
"databaseSha256": "8db9e74a5641662a808d8252c5d6c9de43fe9efd8687634bfbff2d4361a13052",
"repositoriesRoot": "gitea/git/repositories"
},
"trust": {
"legacyRootMounted": false,
"legacyDatabaseImported": false,
"legacyConfigImported": false,
"legacyCredentialsImported": false,
"artifactSecrets": "forbidden",
"runtimeSecrets": "runner-generated",
"repositorySelection": "exact-v2-rows-only",
"repositoryCopy": "same-filesystem-per-file-reflink-object-and-validated-ref-material-only",
"forbiddenRepositoryMaterial": [
"hooks",
"config",
"alternates",
"http-alternates",
"shallow",
"replace",
"grafts",
"worktrees",
"commondir",
"lfs"
],
"unsupportedDatabaseState": "confirmed-disposition-bound-no-legacy-row-import-verifiers-pending"
},
"identity": {
"preserveNumericUserIds": false,
"preserveNumericRepositoryIds": false,
"oldToNewIdMapping": "/volume1/docker/nodedc-gitea/audit/identity-map.json",
"emails": "new-local-noreply-only",
"passwordHashes": "never-imported",
"activeBootstrapCredentials": "root-only-runner-state-require-change",
"lockedCredentials": "fresh-random-discarded-and-login-prohibited",
"twoFactorAuthentication": "not-configured-by-transition"
},
"externalPrerequisites": {
"legacyContainer": "exact-inspect-pin-required-stopped-restart-no",
"publicProxy": "reviewed-nginx-uds-bridge-and-dsm-loopback-upstream",
"firewall": "legacy-isolation-retained-and-loopback-3000-ready",
"candidatePublicationGate": "socket-parent-uid1000-gid1023-mode0700",
"publishAction": "runner-chmod-socket-parent-0750-after-internal-acceptance",
"mustPassBeforeApply": true
},
"acceptance": {
"users": "exact-10-row-by-row",
"repositories": "exact-45-row-by-row-and-visibility",
"credentialsSessionsKeysWebhooksActions": "zero",
"git": "fsck-all-and-public-smart-http-smoke",
"registration": "closed",
"ssh": "disabled",
"network": "none-no-ports",
"publicHttps": "required-before-ledger-success"
},
"rollback": "stop-remove-candidate-prove-absent-quarantine-new-root-never-start-legacy"
}
@@ -1,123 +0,0 @@
{
"schemaVersion": "nodedc.gitea.incident-salvage.v3",
"action": "clean-state-salvage",
"component": "gitea",
"incidentId": "gitea-20260814",
"installRoot": "/volume1/docker/nodedc-gitea",
"compose": {
"file": "docker-compose.gitea.yml",
"project": "nodedc-gitea",
"service": "gitea",
"sha256": "2f031d5bfff4f42c73cabd8c94487ec3e4f1e1b0a96d3b7eec958904f735908a"
},
"runtime": {
"image": "docker.gitea.com/gitea:1.27.2-rootless@sha256:7de5f49ada687b8c8d2938f547cdb7634839764ba51f297457bae35cee3abd2c",
"imageId": "sha256:272085a806e6d182352cdb011c0ebab1d2efc7ec45247de84de5659c7bc5c4c6",
"repoDigest": "docker.gitea.com/gitea@sha256:7de5f49ada687b8c8d2938f547cdb7634839764ba51f297457bae35cee3abd2c",
"platform": "linux/amd64",
"pullPolicy": "never",
"entrypoint": "/usr/local/bin/gitea",
"runAs": "1000:1000",
"transport": "unix:/run/gitea/gitea.sock",
"networkMode": "none",
"ssh": "disabled-no-published-port",
"lfs": "disabled-pending-reachable-pointer-physical-sha-verifier",
"database": "new-sqlite-1.27.2-only",
"logging": "bounded-json-file-10m-x3",
"stopGracePeriod": "30s",
"candidateRestartPolicy": "no",
"acceptedRestartPolicy": "unless-stopped"
},
"decision": {
"schema": "nodedc.gitea.incident-decision/v2",
"manifestFile": "deployment/gitea-incident-salvage/confirmed-decision.json",
"manifestSha256": "dc9528462624158eb44218d37cc7054d551ca2d7ded562592982aa3f34c9fc2a",
"usersFile": "deployment/gitea-incident-salvage/users.decisions.csv",
"usersSha256": "e3b82f1073a86eea9e567edff062dd1689d21ec0edcf3ed92e844da9351ee8b6",
"repositoriesFile": "deployment/gitea-incident-salvage/repositories.decisions.csv",
"repositoriesSha256": "76b4bae2ab5cec490330c19bfc5ae9429abf7636705ae028c1c64fd54a6a0493",
"users": {
"active": 2,
"locked": 8,
"delete": 962
},
"repositories": {
"keep": 45,
"delete": 2013,
"dctouchPrivate": 32,
"silverPublic": 13
}
},
"disposition": {
"schema": "nodedc.gitea.incident-disposition.v1",
"file": "deployment/gitea-incident-salvage/confirmed-disposition-v1.json",
"sha256": "0a066724bcf6e4933133db6cab6cc273393e3c262dd00dda0bbf9ceebd84f78c"
},
"snapshot": {
"root": "/volume1/.nodedc-security-snapshots/docker-gitea-incident-20260814",
"uuid": "f5a3fe3a-93ea-bb4d-847f-6221a6bcbc9f",
"readOnlyRequired": true,
"database": "gitea/gitea/gitea.db",
"databaseBytes": 182681600,
"databaseSha256": "8db9e74a5641662a808d8252c5d6c9de43fe9efd8687634bfbff2d4361a13052",
"repositoriesRoot": "gitea/git/repositories"
},
"trust": {
"legacyRootMounted": false,
"legacyDatabaseImported": false,
"legacyConfigImported": false,
"legacyCredentialsImported": false,
"artifactSecrets": "forbidden",
"runtimeSecrets": "runner-generated",
"repositorySelection": "exact-v2-rows-only",
"repositoryCopy": "same-filesystem-per-file-reflink-object-and-validated-ref-material-only",
"forbiddenRepositoryMaterial": [
"hooks",
"config",
"alternates",
"http-alternates",
"shallow",
"replace",
"grafts",
"worktrees",
"commondir",
"lfs"
],
"unsupportedDatabaseState": "closure-disposition-bound-plan-report-review-and-verifiers-pending"
},
"identity": {
"preserveNumericUserIds": false,
"preserveNumericRepositoryIds": false,
"oldToNewIdMapping": "/volume1/docker/nodedc-gitea/audit/identity-map.json",
"emails": "new-local-noreply-only",
"passwordHashes": "never-imported",
"activeBootstrapCredentials": "root-only-runner-state-require-change",
"lockedCredentials": "fresh-random-discarded-and-login-prohibited",
"twoFactorAuthentication": "not-configured-by-transition"
},
"externalPrerequisites": {
"legacyContainer": "exact-inspect-pin-required-stopped-restart-no",
"publicProxy": "reviewed-nginx-uds-bridge-and-dsm-loopback-upstream",
"firewall": "legacy-isolation-retained-and-loopback-3000-ready",
"candidatePublicationGate": "socket-parent-uid1000-gid1023-mode0700",
"publishAction": "runner-chmod-socket-parent-0750-after-internal-acceptance",
"mustPassBeforeApply": true
},
"acceptance": {
"users": "exact-10-row-by-row",
"repositories": "exact-45-row-by-row-and-visibility",
"credentialsSessionsKeysWebhooksActions": "zero",
"git": "fsck-all-and-public-smart-http-smoke",
"registration": "closed",
"ssh": "disabled",
"network": "none-no-ports",
"publicHttps": "required-before-ledger-success"
},
"rollback": "stop-remove-candidate-prove-absent-quarantine-new-root-never-start-legacy",
"closureDisposition": {
"file": "deployment/gitea-incident-salvage/confirmed-closure-disposition-v1.json",
"predecessorArtifactSha256": "d6870b5583a2f329eadb4e6cda65fdf4d271532df5ffbf8bfb1403968a434672",
"schema": "nodedc.gitea.incident-closure-disposition.v1",
"sha256": "7ed66d9848268431a703fe24b22c41afbaa7c5ff48949604d6fc448d93e0d243"
}
}
@@ -1 +0,0 @@
{"activation":{"allowedOperation":"canonical-plan-only","applyFrozen":true,"freezeBoundary":"before-candidate-root-creation"},"authority":{"policyScope":"access-collaboration-issue-pr-attachment-release-label-project-unit-package-action-closure","source":"owner-instruction-in-current-incident-thread","state":"confirmed-policy-evidence-review-pending"},"closureReport":{"expectedBytes":null,"expectedSha256":null,"reviewState":"canonical-plan-output-unreviewed","schema":"nodedc.gitea.salvage-closure-inventory/v1"},"incidentId":"gitea-20260814","policies":{"accessCollaboration":{"accessCache":"DROP_RESET_RECOMPUTE","actorEvidence":"old-user-id-mode-and-kept-deleted-class-only","collaboration":"RECREATE_ONLY_KEPT_ACTORS_AFTER_REVIEWED_OLD_TO_NEW_ID_MAP","deletedActors":962,"keptActors":10,"legacyRowsImported":false,"sensitiveUserPayloadExported":false},"attachments":{"databaseManifest":"ID_UUID_RELATIONS_DECLARED_SIZE_ONLY","filenameOrContentExported":false,"legacyRowsImported":false,"physicalDisposition":"VERIFY_BYTES_THEN_SANITIZED_IMMUTABLE_ARCHIVE_ONLY","physicalPresenceClaimed":false},"issuesPullRequestsMetadata":{"archive":"SANITIZED_IMMUTABLE_ARCHIVE_ONLY","legacyRowsImported":false,"payloadExportedInPlan":false,"planEvidence":"PER_REPOSITORY_COUNTS_ACTOR_CLASSES_TEXT_BYTE_LENGTHS_ONLY","subrelationClosure":{"commentHistoryMerger":"SCHEMA_BOUND_EXACT_RELATION_COUNTS_AND_CLASSES","externalAuthors":"PRESENCE_AND_NAME_BYTE_LENGTHS_ONLY_NO_LOCAL_USER_MAPPING","legacyRowsImported":false,"teamRelations":"EXACT_ROW_TEAM_ORG_IDS_SEALED_HOLD_AND_BLOCK_IF_PRESENT"},"tables":["comment","issue","issue_assignees","issue_content_history","issue_dependency","issue_label","issue_user","issue_watch","notification","project","project_board","project_issue","pull_auto_merge","pull_request","reaction","review","review_state","stopwatch","tracked_time"]},"packagesActions":{"actionsTarget":"DISABLED","legacyPayloadSecretTokenLogImported":false,"packageTarget":"DISABLED","physicalDisposition":"DROP_ONLY_AFTER_SCHEMA_RELATION_AND_PHYSICAL_CLOSURE_VERIFIER","planEvidence":"RELATIONSHIP_COUNTS_AND_SAFE_DECLARED_SIZES_ONLY"},"releasesLabelsProjects":{"archive":"SANITIZED_IMMUTABLE_ARCHIVE_ONLY","legacyRowsImported":false,"payloadExportedInPlan":false},"repositoryState":{"cachedAccessCountersStarsWatches":"DROP_RESET_RECOMPUTE","legacyHooksWebhooksKeysTokensSessionsCredentialsSecrets":"IMPORT_ZERO","topics":"VERIFIED_SEMANTIC_EMPTY_REBUILD_ZERO"},"units":{"actionsType10":"DISABLED_ZERO_TARGET_ROWS","disabledTypes":[6,7,9,10],"enabledCleanTypes":[1,2,3,4,5,8],"legacyConfigImported":false,"legacyRowsImported":false,"packagesType9":"DISABLED_ZERO_TARGET_ROWS"}},"predecessor":{"artifactSha256":"d6870b5583a2f329eadb4e6cda65fdf4d271532df5ffbf8bfb1403968a434672","dispositionFile":"deployment/gitea-incident-salvage/confirmed-disposition-v1.json","dispositionSha256":"0a066724bcf6e4933133db6cab6cc273393e3c262dd00dda0bbf9ceebd84f78c"},"remainingBlockers":["attachment-physical-verifier-pending","candidate-root-activation-hard-frozen","closure-report-review-pin-pending","collaboration-kept-user-mapping-verifier-pending","forensic-ref-archive-verifier-pending","issue-pr-metadata-sanitized-archive-verifier-pending","lfs-reachable-pointer-physical-verifier-pending","package-action-physical-closure-verifier-pending","reference-manifest-fsck-reachability-verifier-pending","repository-object-reconstruction-verifier-pending","target-unit-policy-acceptance-pending","unsupported-schema-catalog-verifier-pending"],"schemaVersion":"nodedc.gitea.incident-closure-disposition.v1","scope":{"deletedRepositories":2013,"deletedUsers":962,"keptRepositories":45,"keptUsers":10},"sourceEvidence":{"databaseSha256":"8db9e74a5641662a808d8252c5d6c9de43fe9efd8687634bfbff2d4361a13052","identityDecisionManifestSha256":"dc9528462624158eb44218d37cc7054d551ca2d7ded562592982aa3f34c9fc2a","referenceManifestSha256":"9cddaf0e4d4cf22dd264a6ae589ccc50d29e07f85c55e9d34b14627cecb8a311","semanticTopicsSha256":"df6e3612186234bfcf3c172ef4e0fff933baaa691a510f9780ebf9e21c8d4d05","snapshotUuid":"f5a3fe3a-93ea-bb4d-847f-6221a6bcbc9f","unsupportedRepositoryReportSha256":"4b2cecf88c62fc5c4a43419885e88a01c9f9aac03133afb19dae0a7caef106ac","unsupportedSchemaCatalogSha256":"b5e3b6776926c4f1627fafd882362ed0ef986bfc86fc6ac6507a43976531b6db"}}
File diff suppressed because one or more lines are too long
@@ -1,62 +0,0 @@
name: nodedc-gitea
services:
gitea:
image: docker.gitea.com/gitea:1.27.2-rootless@sha256:7de5f49ada687b8c8d2938f547cdb7634839764ba51f297457bae35cee3abd2c
platform: linux/amd64
pull_policy: never
network_mode: none
user: "1000:1000"
entrypoint:
- /usr/local/bin/gitea
command:
- web
- --config
- /etc/gitea/app.ini
restart: "no"
stop_grace_period: 30s
init: true
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
pids_limit: 512
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
environment:
USER: git
HOME: /data/gitea
GITEA_WORK_DIR: /data/gitea
GITEA_CUSTOM: /data/gitea
volumes:
- type: bind
source: /volume1/docker/nodedc-gitea/data
target: /data
bind:
create_host_path: false
- type: bind
source: /volume1/docker/nodedc-gitea/config
target: /etc/gitea
read_only: true
bind:
create_host_path: false
- type: bind
source: /volume1/docker/nodedc-gitea/socket
target: /run/gitea
bind:
create_host_path: false
secrets:
- gitea_secret_key
- gitea_internal_token
tmpfs:
- /tmp:rw,noexec,nosuid,nodev,size=256m
secrets:
gitea_secret_key:
file: /volume1/docker/nodedc-gitea/secrets/secret-key
gitea_internal_token:
file: /volume1/docker/nodedc-gitea/secrets/internal-token
@@ -1,42 +0,0 @@
{
"schemaVersion": "nodedc.gitea.fresh-install.v1",
"action": "fresh-install",
"component": "gitea",
"installRoot": "/volume1/docker/nodedc-gitea",
"compose": {
"file": "docker-compose.gitea.yml",
"project": "nodedc-gitea",
"service": "gitea",
"sha256": "25868a40996c405543b4627d06499b68f43556e607d839e969a400b0bc0ddadb"
},
"runtime": {
"image": "docker.gitea.com/gitea:1.27.1-rootless@sha256:89dc3c214b3992e5bb01e05ad21139d7a8b302d3ea3d8942d3f7e904e92af148",
"platform": "linux/amd64",
"pullPolicy": "never",
"minimumComposeVersion": "2.20.1",
"transport": "unix:/run/gitea/gitea.sock",
"socketBind": "/volume1/docker/nodedc-gitea/socket:/run/gitea",
"ssh": "disabled-no-published-port",
"database": "fresh-sqlite-only",
"lfs": "disabled-pending-reviewed-restore-transition",
"networkMode": "none",
"logging": "bounded-json-file-10m-x3",
"stopGracePeriod": "30s"
},
"trust": {
"artifactSecrets": "forbidden",
"runtimeSecrets": "runner-managed-file-mounts",
"legacyRootAccess": "forbidden",
"legacyDatabaseImport": "forbidden",
"legacyRepositoryImport": "forbidden"
},
"reverseProxyPrerequisite": {
"managedOutsideArtifact": true,
"requiredDsmUpstream": "127.0.0.1:3000",
"requiredNginxBridge": "/usr/local/etc/nginx/conf.d/http.nodedc-gitea-uds.conf",
"requiredNginxBridgeSha256": "164f37a12a4f91e656cf20bd5b109978d16d723bdfde236653722aaf820780c9",
"requiredUnixUpstream": "/volume1/docker/nodedc-gitea/socket/gitea.sock",
"mustBeCompletedBeforeApply": true
},
"rollback": "stop-candidate-preserve-fresh-runtime-state-and-restore-source"
}
@@ -1,128 +0,0 @@
name: nodedc-gitea
services:
gitea:
image: docker.gitea.com/gitea:1.27.1-rootless@sha256:89dc3c214b3992e5bb01e05ad21139d7a8b302d3ea3d8942d3f7e904e92af148
platform: linux/amd64
pull_policy: never
network_mode: none
user: "1000:1000"
restart: unless-stopped
stop_grace_period: 30s
init: true
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
pids_limit: 512
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
environment:
GITEA_WORK_DIR: /var/lib/gitea
GITEA__database__DB_TYPE: sqlite3
GITEA__database__PATH: /var/lib/gitea/data/gitea.db
GITEA__server__DOMAIN: git.dcserve.ru
GITEA__server__ROOT_URL: https://git.dcserve.ru/
GITEA__server__PROTOCOL: http+unix
GITEA__server__HTTP_ADDR: /run/gitea/gitea.sock
GITEA__server__UNIX_SOCKET_PERMISSION: "0666"
GITEA__server__LOCAL_ROOT_URL: http://unix/
GITEA__server__DISABLE_SSH: "true"
GITEA__server__START_SSH_SERVER: "false"
GITEA__server__SSH_CREATE_AUTHORIZED_KEYS_FILE: "false"
GITEA__server__LFS_START_SERVER: "false"
GITEA__server__LFS_ALLOW_PURE_SSH: "false"
GITEA__server__OFFLINE_MODE: "true"
GITEA__server__LANDING_PAGE: login
GITEA__security__INSTALL_LOCK: "true"
GITEA__security__SECRET_KEY_URI: file:/run/secrets/gitea_secret_key
GITEA__security__INTERNAL_TOKEN_URI: file:/run/secrets/gitea_internal_token
GITEA__security__REVERSE_PROXY_LIMIT: "1"
GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES: 127.0.0.0/8,::1/128
GITEA__security__DISABLE_GIT_HOOKS: "true"
GITEA__security__DISABLE_WEBHOOKS: "true"
GITEA__security__IMPORT_LOCAL_PATHS: "false"
GITEA__security__ONLY_ALLOW_PUSH_IF_GITEA_ENVIRONMENT_SET: "true"
GITEA__security__PASSWORD_HASH_ALGO: argon2
GITEA__security__MIN_PASSWORD_LENGTH: "16"
GITEA__security__PASSWORD_COMPLEXITY: lower,upper,digit,spec
GITEA__security__TWO_FACTOR_AUTH: enforced
GITEA__security__DISABLE_QUERY_AUTH_TOKEN: "true"
GITEA__security__ALLOWED_HOST_LIST: loopback
GITEA__service__DISABLE_REGISTRATION: "true"
GITEA__service__REQUIRE_SIGNIN_VIEW: "true"
GITEA__service__SHOW_REGISTRATION_BUTTON: "false"
GITEA__service__DEFAULT_KEEP_EMAIL_PRIVATE: "true"
GITEA__service__DEFAULT_ALLOW_CREATE_ORGANIZATION: "false"
GITEA__service__DEFAULT_USER_IS_RESTRICTED: "true"
GITEA__service__DEFAULT_USER_VISIBILITY: private
GITEA__service__ALLOWED_USER_VISIBILITY_MODES: private
GITEA__service__DEFAULT_ORG_VISIBILITY: private
GITEA__service__ENABLE_REVERSE_PROXY_AUTHENTICATION: "false"
GITEA__service__ENABLE_REVERSE_PROXY_AUTHENTICATION_API: "false"
GITEA__service__ENABLE_REVERSE_PROXY_AUTO_REGISTRATION: "false"
GITEA__service__ENABLE_NOTIFY_MAIL: "false"
GITEA__service__ENABLE_BASIC_AUTHENTICATION: "false"
GITEA__admin__DISABLE_REGULAR_ORG_CREATION: "true"
GITEA__admin__USER_DISABLED_FEATURES: deletion,manage_ssh_keys,manage_gpg_keys,change_username
GITEA__repository__FORCE_PRIVATE: "true"
GITEA__repository__DEFAULT_PRIVATE: private
GITEA__repository__USER_MAX_CREATION_LIMIT: "0"
GITEA__repository__ORG_MAX_CREATION_LIMIT: "0"
GITEA__repository__ENABLE_PUSH_CREATE_USER: "false"
GITEA__repository__ENABLE_PUSH_CREATE_ORG: "false"
GITEA__repository__DISABLE_MIGRATIONS: "true"
GITEA__repository__ALLOW_ADOPTION_OF_UNADOPTED_REPOSITORIES: "false"
GITEA__repository__ALLOW_DELETION_OF_UNADOPTED_REPOSITORIES: "false"
GITEA__repository__DISABLE_HTTP_GIT: "false"
GITEA__repository.upload__ENABLED: "false"
GITEA__attachment__ENABLED: "false"
GITEA__actions__ENABLED: "false"
GITEA__packages__ENABLED: "false"
GITEA__oauth2__ENABLED: "false"
GITEA__oauth2_client__ENABLE_AUTO_REGISTRATION: "false"
GITEA__openid__ENABLE_OPENID_SIGNIN: "false"
GITEA__openid__ENABLE_OPENID_SIGNUP: "false"
GITEA__federation__ENABLED: "false"
GITEA__mailer__ENABLED: "false"
GITEA__session__COOKIE_SECURE: "true"
GITEA__session__SAME_SITE: strict
GITEA__api__ENABLE_SWAGGER: "false"
GITEA__migrations__ALLOW_LOCALNETWORKS: "false"
GITEA__migrations__SKIP_TLS_VERIFY: "false"
GITEA__cors__ENABLED: "false"
GITEA__metrics__ENABLED: "false"
GITEA__cron.update_checker__ENABLED: "false"
GITEA__log__MODE: console
GITEA__log__LEVEL: Info
volumes:
- type: bind
source: /volume1/docker/nodedc-gitea/data
target: /var/lib/gitea
bind:
create_host_path: false
- type: bind
source: /volume1/docker/nodedc-gitea/config
target: /etc/gitea
bind:
create_host_path: false
- type: bind
source: /volume1/docker/nodedc-gitea/socket
target: /run/gitea
bind:
create_host_path: false
secrets:
- gitea_secret_key
- gitea_internal_token
tmpfs:
- /tmp:rw,noexec,nosuid,nodev,size=256m
secrets:
gitea_secret_key:
file: /volume1/docker/nodedc-gitea/secrets/secret-key
gitea_internal_token:
file: /volume1/docker/nodedc-gitea/secrets/internal-token
File diff suppressed because it is too large Load Diff
+868
View File
@@ -0,0 +1,868 @@
#!/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-admission-gate-v1.json",
)
PAYLOAD_FILE_SHA256 = {
"docker-compose.device-edge.yml":
"666945ffd9512355e610ecd36a9df96936477315150555def93e0243e8ff1e22",
"docker-compose.device-edge.ingress.yml":
"11bedfd7fdea749ca1bdb3b35b9c136c86b330f51a9001f0b38c4618f6f96108",
"services/device-edge-relay/Dockerfile":
"f2f15b7618ac2ab3a1d4dd40041d06d1e9a695edcfc168d8835f9560b4897e70",
"services/device-edge-relay/src/runtime.mjs":
"21e83678980aa61127bf9f3d77982dd485c4aaae208c43818db7bb1cc150b83a",
"services/device-edge-relay/src/server.mjs":
"1b99ec944f1d3fbadded045b159f08624e2829620cec39a97f6b4b8cdcd2be22",
"deployment/device-edge-admission-gate-v1.json":
"e6c1f21ff297b451c42b6746bc2063484874435dfa9f1614410a7cbe84f0ce6f",
}
PREDECESSOR_FILE_SHA256 = {
"docker-compose.device-edge.yml":
"7f13c11d6d4d541964053c0a8cf791e401947d34c42e0f7c26f9f9df26fa00b5",
"docker-compose.device-edge.ingress.yml":
"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_ABSENT = {
"deployment/device-edge-admission-gate-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.admission-gate.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",
"sourceAdmission": "public-ipv4-only",
"maxTrackedSourceAddresses": 2048,
"maxBytesPerDirection": 262144,
"protocolInspection": "gateway-owned",
"identityTrust": "claimed-not-ownership-proof",
"discoveryLifecycle": "quarantine",
"commandTransport": "disabled",
"gelios": "untouched",
"amneziaHostFullTunnel": "preserved",
"routerNatFirewall": "separate-manual-gate",
"rollback": "restore-reviewed-ipvlan-predecessor-without-network-or-router-mutation",
}
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-admission-gate-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 IPvlan predecessor relay is not running")
if container_health(relay) != "healthy":
die("Device Edge IPvlan predecessor relay is not healthy")
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 IPvlan predecessor environment mismatch")
bindings = relay.get("HostConfig", {}).get("PortBindings") or {}
if bindings not in ({}, None):
die("Device Edge IPvlan predecessor host publication mismatch")
validate_network_runtime(relay)
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()
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",
"DEVICE_EDGE_RELAY_SOURCE_POLICY=public-ipv4-only",
"DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES=2048",
"DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION=262144",
}
if not required.issubset(environment):
die("Device Edge relay environment mismatch")
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",
"sourceAdmission": "public-ipv4-only",
}
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,
), cwd=LIVE_ROOT, timeout=300, capture=False)
wait_healthy(RELAY_CONTAINER)
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_source_admission=public-ipv4-only")
print("device_edge_source_table_limit=2048")
print("device_edge_byte_limit_per_direction=262144")
print("device_edge_command_transport=disabled")
print("device_edge_discovery_lifecycle=quarantine")
print("device_edge_gelios=untouched")
print("device_edge_amnezia=preserved:active:host-full-tunnel")
print("device_edge_router_nat_firewall=unchanged")
print("device_edge_rollback=restore-reviewed-ipvlan-predecessor-no-router-mutation")
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("device_edge_source_admission=public-ipv4-only")
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-admission-gate-20260804-002",
)
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-admission-gate-20260804-003",
)
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-admission-gate-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)
@@ -0,0 +1,156 @@
#!/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
SCRIPT_DIR = Path(__file__).resolve().parent
BUILDER = SCRIPT_DIR / "build-device-plane-artifact.mjs"
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
EXPECTED_ENTRIES = [
".dockerignore",
"package.json",
"package-lock.json",
"docker-compose.device-plane.yml",
"packages/device-protocol-contract",
"packages/arusnavi-b2-adapter",
"services/device-control-core",
"services/device-gateway",
]
def load_runner():
loader = importlib.machinery.SourceFileLoader(
"nodedc_device_plane_artifact_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 DevicePlaneArtifactTest(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_narrow_safe_and_deterministic(self):
with tempfile.TemporaryDirectory(
prefix="nodedc-device-plane-artifact-",
) as directory:
artifact_dir = Path(directory)
first = self.build(
artifact_dir,
"device-plane-foundation-unit-001",
)
artifact = Path(first["artifact"])
first_bytes = artifact.read_bytes()
second = self.build(
artifact_dir,
"device-plane-foundation-unit-001",
)
second_bytes = Path(second["artifact"]).read_bytes()
self.assertEqual(first["component"], "device-plane")
self.assertEqual(first["entries"], EXPECTED_ENTRIES)
self.assertEqual(
first["services"],
["device-control-core", "device-gateway"],
)
self.assertEqual(
first["sha256"],
hashlib.sha256(first_bytes).hexdigest(),
)
self.assertEqual(first["sha256"], second["sha256"])
self.assertEqual(first_bytes, second_bytes)
with tarfile.open(artifact, "r:gz") as archive:
members = archive.getmembers()
names = {member.name for member in members}
files = (
archive.extractfile("files.txt")
.read()
.decode("utf-8")
.splitlines()
)
manifest = (
archive.extractfile("manifest.env")
.read()
.decode("utf-8")
)
compose = (
archive.extractfile(
"payload/docker-compose.device-plane.yml",
)
.read()
.decode("utf-8")
)
regular_payloads = [
archive.extractfile(member).read()
for member in members
if member.isfile()
]
self.assertEqual(files, EXPECTED_ENTRIES)
self.assertEqual(
manifest,
"id=device-plane-foundation-unit-001\n"
"component=device-plane\n"
"type=app-overlay\n",
)
self.assertIn(
"payload/services/device-control-core/Dockerfile",
names,
)
self.assertIn(
"payload/services/device-gateway/Dockerfile",
names,
)
self.assertFalse(any(
"/test/" in name
or "/node_modules/" in name
or Path(name).name.startswith(".env")
or name.startswith("payload/docs/")
or name.startswith("payload/runtime/")
or name.startswith("payload/secrets/")
for name in names
))
self.assertNotIn("9921:9921", compose)
self.assertIn('DEVICE_GATEWAY_LISTEN_ENABLED: "false"', compose)
self.assertIn('DEVICE_DISCOVERY_INGEST_ENABLED: "false"', compose)
self.assertNotIn(b"-----BEGIN PRIVATE KEY-----", b"\n".join(
regular_payloads,
))
with tempfile.TemporaryDirectory(
prefix="nodedc-device-plane-runner-load-",
) as work_directory:
manifest_loaded, entries_loaded, payload_loaded = (
RUNNER.load_artifact(artifact, Path(work_directory))
)
self.assertEqual(manifest_loaded["component"], "device-plane")
self.assertEqual(entries_loaded, EXPECTED_ENTRIES)
self.assertEqual(payload_loaded.name, "payload")
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -0,0 +1,477 @@
#!/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-ingress-artifact.mjs"
)
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
COMPOSE = (
SCRIPT_DIR.parent.parent
/ "device-plane/docker-compose.device-plane.yml"
)
def load_runner():
loader = importlib.machinery.SourceFileLoader(
"nodedc_device_plane_b2_ingress_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 DevicePlaneB2DiscoveryIngressArtifactTest(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_database_free(self):
with tempfile.TemporaryDirectory(
prefix="nodedc-device-plane-b2-ingress-",
) as directory:
artifact_dir = Path(directory)
patch_id = "device-plane-b2-discovery-ingress-unit-001"
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["transition"],
"verified-b2-loopback-discovery-only",
)
self.assertEqual(
first["services"],
["device-control-core", "device-gateway"],
)
self.assertNotIn("device-postgres", first["services"])
self.assertEqual(
first["entries"],
list(RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_ENTRIES),
)
with tarfile.open(first["artifact"], "r:gz") as archive:
files = (
archive.extractfile("files.txt")
.read()
.decode("utf-8")
.splitlines()
)
compose = archive.extractfile(
"payload/docker-compose.device-plane.yml"
).read()
descriptor = json.loads(
archive.extractfile(
"payload/deployment/"
"device-plane-b2-discovery-ingress-v1.json"
)
.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(
hashlib.sha256(compose).hexdigest(),
RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_COMPOSE_SHA256,
)
self.assertEqual(
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")
postgres, stateless = compose.split(" device-control-core:", 1)
self.assertNotIn("9921", postgres)
self.assertNotIn("device-plane-control", postgres)
self.assertIn(
'DEVICE_DISCOVERY_INGEST_ENABLED: "true"',
stateless,
)
self.assertIn(
'DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED: "false"',
stateless,
)
self.assertIn('"127.0.0.1:9921:9921"', stateless)
self.assertNotIn('"0.0.0.0:9921:9921"', stateless)
self.assertNotIn("DEVICE_GATEWAY_COMMAND", compose)
self.assertNotIn("POSTGRES_PASSWORD:", compose)
def test_runner_builds_only_stateless_services_and_accepts_new_health(self):
entries = RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_ENTRIES
self.assertEqual(
RUNNER.component_services("device-plane", entries),
("device-control-core", "device-gateway"),
)
builds = RUNNER.component_builds("device-plane", entries)
self.assertEqual(len(builds), 2)
checks = RUNNER.component_healthchecks(
"device-plane",
entries,
("device-control-core", "device-gateway"),
)
self.assertEqual(
checks[0]["expected_json"]["discoveryIngest"],
"enabled",
)
self.assertEqual(
checks[1]["expected_json"]["publicIngress"],
"disabled",
)
self.assertEqual(
checks[1]["expected_json"]["commandTransport"],
"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",
"composeProject": "nodedc-device-plane",
"services": [
runtime_item("device-control-core", "1", "a"),
runtime_item("device-gateway", "2", "b"),
runtime_item("device-postgres", "3", "c"),
],
}
containers = {
"core": stateless_container(
service="device-control-core",
container_id="4" * 64,
image_id="sha256:" + "d" * 64,
image=RUNNER.DEVICE_PLANE_CONTROL_CORE_IMAGE,
ports={
"18120/tcp": [{
"HostIp": "127.0.0.1",
"HostPort": "18120",
}],
},
environment={
"DEVICE_DISCOVERY_INGEST_ENABLED": "true",
"DEVICE_GATEWAY_CORE_TOKEN_FILE":
"/run/nodedc-secrets/gateway-core-token",
"DEVICE_IDENTIFIER_PEPPER_FILE":
"/run/nodedc-secrets/identifier-pepper",
},
mounts=[
secret_mount(
RUNNER.DEVICE_PLANE_POSTGRES_PASSWORD_FILE,
"/run/nodedc-secrets/postgres-password",
),
secret_mount(
RUNNER.DEVICE_PLANE_GATEWAY_CORE_TOKEN_FILE,
"/run/nodedc-secrets/gateway-core-token",
),
secret_mount(
RUNNER.DEVICE_PLANE_IDENTIFIER_PEPPER_FILE,
"/run/nodedc-secrets/identifier-pepper",
),
],
),
"gateway": stateless_container(
service="device-gateway",
container_id="5" * 64,
image_id="sha256:" + "e" * 64,
image=RUNNER.DEVICE_PLANE_GATEWAY_IMAGE,
ports={
"18121/tcp": [{
"HostIp": "127.0.0.1",
"HostPort": "18121",
}],
"9921/tcp": [{
"HostIp": "127.0.0.1",
"HostPort": "9921",
}],
},
environment={
"DEVICE_GATEWAY_LISTEN_ENABLED": "true",
"DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED": "false",
"DEVICE_GATEWAY_TCP_HOST": "127.0.0.1",
"DEVICE_GATEWAY_TCP_PORT": "9921",
"DEVICE_GATEWAY_CORE_URL":
"http://device-control-core:18120",
"DEVICE_GATEWAY_CORE_TOKEN_FILE":
"/run/nodedc-secrets/gateway-core-token",
"DEVICE_GATEWAY_CORE_TIMEOUT_MS": "5000",
"DEVICE_GATEWAY_MAX_BUFFERED_BYTES": "65536",
"DEVICE_GATEWAY_MAX_SESSIONS": "100",
"DEVICE_GATEWAY_MAX_SESSIONS_PER_ADDRESS": "10",
"DEVICE_GATEWAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS":
"30",
"DEVICE_GATEWAY_SESSION_TIMEOUT_MS": "10000",
},
mounts=[
secret_mount(
RUNNER.DEVICE_PLANE_GATEWAY_CORE_TOKEN_FILE,
"/run/nodedc-secrets/gateway-core-token",
),
],
),
"postgres": postgres_container(),
}
service_ids = {
"device-control-core": ("core",),
"device-gateway": ("gateway",),
"device-postgres": ("postgres",),
}
with (
mock.patch.object(
RUNNER,
"validate_device_plane_runtime_secret_metadata",
),
mock.patch.object(
RUNNER,
"device_plane_service_container_ids",
side_effect=lambda service: service_ids[service],
),
mock.patch.object(
RUNNER,
"inspect_device_plane_container",
side_effect=lambda container_id: containers[container_id],
),
mock.patch.object(
RUNNER,
"validate_device_plane_network_contract",
) as network,
mock.patch.object(
RUNNER,
"assert_loopback_tcp_port_open",
) as port_open,
):
accepted = (
RUNNER.validate_device_plane_b2_discovery_ingress_runtime(
before
)
)
self.assertEqual(
accepted["device-postgres"]["containerId"],
"3" * 64,
)
self.assertEqual(network.call_count, 2)
port_open.assert_called_once_with(9921)
def runtime_item(service, container_digit, image_digit):
return {
"service": service,
"containerId": container_digit * 64,
"imageId": "sha256:" + image_digit * 64,
"status": "running",
"running": True,
"health": "healthy",
"restartCount": 0,
}
def secret_mount(source, destination):
return {
"Type": "bind",
"Source": str(source),
"Destination": destination,
"RW": False,
}
def stateless_container(
*,
service,
container_id,
image_id,
image,
ports,
environment,
mounts,
):
return {
"Id": container_id,
"Image": image_id,
"RestartCount": 0,
"State": {
"Status": "running",
"Running": True,
"Restarting": False,
"ExitCode": 0,
"Error": "",
"Health": {"Status": "healthy"},
},
"Config": {
"Image": image,
"User": "1000:1000",
"Labels": {
"com.docker.compose.project": "nodedc-device-plane",
"com.docker.compose.service": service,
},
"Env": [f"{key}={value}" for key, value in environment.items()],
},
"HostConfig": {
"PortBindings": ports,
"RestartPolicy": {"Name": "unless-stopped"},
"ReadonlyRootfs": True,
"CapDrop": ["ALL"],
"SecurityOpt": ["no-new-privileges:true"],
},
"NetworkSettings": {
"Ports": ports,
"Networks": {
RUNNER.DEVICE_PLANE_PRIVATE_NETWORK: {},
RUNNER.DEVICE_PLANE_CONTROL_NETWORK: {},
},
},
"Mounts": mounts,
}
def postgres_container():
return {
"Id": "3" * 64,
"Image": "sha256:" + "c" * 64,
"RestartCount": 0,
"State": {
"Status": "running",
"Running": True,
"Restarting": False,
"ExitCode": 0,
"Error": "",
"Health": {"Status": "healthy"},
},
"Config": {
"Image": "postgres:16-alpine",
"User": "",
"Labels": {
"com.docker.compose.project": "nodedc-device-plane",
"com.docker.compose.service": "device-postgres",
},
"Env": [],
},
"HostConfig": {
"PortBindings": {},
"RestartPolicy": {"Name": "unless-stopped"},
},
"NetworkSettings": {
"Ports": {},
"Networks": {
RUNNER.DEVICE_PLANE_PRIVATE_NETWORK: {},
},
},
"Mounts": [
secret_mount(
RUNNER.DEVICE_PLANE_POSTGRES_PASSWORD_FILE,
"/run/nodedc-secrets/postgres-password",
),
{
"Type": "volume",
"Name": RUNNER.DEVICE_PLANE_POSTGRES_VOLUME,
"Destination": "/var/lib/postgresql/data",
"RW": True,
},
],
}
if __name__ == "__main__":
unittest.main()
@@ -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()
@@ -0,0 +1,302 @@
#!/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-foundation-network-publication-artifact.mjs"
)
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
COMPOSE = (
SCRIPT_DIR
/ "fixtures/device-plane-foundation-network-publication-v1.yml"
)
PREDECESSOR_COMPOSE = (
SCRIPT_DIR
/ "fixtures/device-plane-foundation-internal-only-v1.yml"
)
def load_runner():
loader = importlib.machinery.SourceFileLoader(
"nodedc_device_plane_network_publication_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 DevicePlaneFoundationNetworkPublicationArtifactTest(
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_database_free(self):
with tempfile.TemporaryDirectory(
prefix="nodedc-device-plane-network-publication-",
) as directory:
artifact_dir = Path(directory)
patch_id = (
"device-plane-foundation-network-publication-unit-003"
)
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["transition"],
"failed-foundation-network-publication-correction",
)
self.assertEqual(first["build"], [])
self.assertEqual(
first["services"],
["device-control-core", "device-gateway"],
)
self.assertNotIn("device-postgres", first["services"])
self.assertEqual(
first["entries"],
list(
RUNNER
.DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_ENTRIES
),
)
with tarfile.open(first["artifact"], "r:gz") as archive:
files = (
archive.extractfile("files.txt")
.read()
.decode("utf-8")
.splitlines()
)
compose = (
archive.extractfile(
"payload/docker-compose.device-plane.yml"
)
.read()
)
descriptor = json.loads(
archive.extractfile(
"payload/deployment/"
"device-plane-foundation-network-publication-v1.json"
)
.read()
.decode("utf-8")
)
self.assertEqual(files, first["entries"])
self.assertEqual(
hashlib.sha256(compose).hexdigest(),
RUNNER
.DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_COMPOSE_SHA256,
)
self.assertEqual(
descriptor,
RUNNER
.expected_device_plane_foundation_network_publication_descriptor(),
)
def test_compose_preserves_database_and_adds_control_network_only_to_stateless_services(
self,
):
compose = COMPOSE.read_text(encoding="utf-8")
predecessor = PREDECESSOR_COMPOSE.read_text(encoding="utf-8")
self.assertEqual(
hashlib.sha256(PREDECESSOR_COMPOSE.read_bytes()).hexdigest(),
RUNNER.DEVICE_PLANE_FOUNDATION_PREDECESSOR_COMPOSE_SHA256,
)
self.assertEqual(
hashlib.sha256(COMPOSE.read_bytes()).hexdigest(),
RUNNER
.DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_COMPOSE_SHA256,
)
self.assertIn("name: nodedc-device-plane-control", compose)
self.assertIn(
'com.docker.network.bridge.enable_ip_masquerade: "false"',
compose,
)
self.assertNotIn("nodedc-device-plane-control", predecessor)
postgres_section, stateless = compose.split(
" device-control-core:",
1,
)
self.assertNotIn("device-plane-control", postgres_section)
self.assertEqual(stateless.count(" - device-plane-control"), 2)
self.assertNotIn("9921:9921", compose)
def test_runner_selects_exact_services_no_build_and_no_deps(self):
entries = (
RUNNER.DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_ENTRIES
)
services = RUNNER.component_services("device-plane", entries)
self.assertEqual(
services,
("device-control-core", "device-gateway"),
)
self.assertEqual(
RUNNER.component_builds("device-plane", entries),
(),
)
with (
mock.patch.object(RUNNER, "run_build") as run_build,
mock.patch.object(
RUNNER,
"prepare_component_runtime",
) as prepare,
mock.patch.object(RUNNER, "run_compose") as run_compose,
):
RUNNER.run_component_runtime(
"device-plane",
entries,
services,
)
run_build.assert_not_called()
prepare.assert_called_once_with("device-plane", entries)
run_compose.assert_called_once_with(
"device-plane",
services,
entries,
)
with mock.patch.object(RUNNER.subprocess, "run") as run:
RUNNER.run_compose("device-plane", services, entries)
command = run.call_args_list[0].args[0]
self.assertIn("--force-recreate", command)
self.assertIn("--no-deps", command)
self.assertEqual(
command[-2:],
["device-control-core", "device-gateway"],
)
def test_terminal_failed_recovery_is_rejected(self):
with self.assertRaisesRegex(
RUNNER.DeployError,
"recovery 002 is terminal failed",
):
RUNNER.reject_terminal_device_plane_foundation_artifact(
{
"id":
RUNNER
.DEVICE_PLANE_FOUNDATION_RECOVERY_FAILED_PATCH_ID,
},
"0" * 64,
)
with self.assertRaisesRegex(
RUNNER.DeployError,
"recovery 002 is terminal failed",
):
RUNNER.reject_terminal_device_plane_foundation_artifact(
{"id": "unrelated"},
RUNNER
.DEVICE_PLANE_FOUNDATION_RECOVERY_FAILED_ARTIFACT_SHA256,
)
def test_rollback_is_stateless_and_removes_only_control_network(self):
entries = (
RUNNER.DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_ENTRIES
)
services = ("device-control-core", "device-gateway")
runtime = {
"device-control-core": {"containerId": "1" * 64},
"device-gateway": {"containerId": "2" * 64},
"device-postgres": {
"containerId":
RUNNER
.DEVICE_PLANE_FOUNDATION_PREDECESSOR_CONTAINER_IDS[
"device-postgres"
],
},
}
with (
mock.patch.object(
RUNNER,
"stop_and_remove_compose_services",
) as stop,
mock.patch.object(
RUNNER,
"restore_platform_overlay",
return_value=9,
) as restore,
mock.patch.object(
RUNNER,
"remove_device_plane_control_network_if_unused",
return_value="removed",
) as remove_network,
mock.patch.object(RUNNER, "run_compose") as compose,
mock.patch.object(
RUNNER,
"healthcheck_compose_service",
) as health,
mock.patch.object(
RUNNER,
"validate_device_plane_foundation_runtime",
return_value=runtime,
) as validate_runtime,
mock.patch.object(
RUNNER,
"assert_loopback_tcp_port_closed",
) as closed,
):
result = (
RUNNER
.rollback_device_plane_network_publication_apply(
Path("/live"),
Path("/backup"),
entries,
"stamp",
True,
services,
)
)
self.assertEqual(result, "source+internal-runtime-restored:9")
stop.assert_called_once_with("device-plane", services)
restore.assert_called_once()
remove_network.assert_called_once_with()
compose.assert_called_once_with(
"device-plane",
services,
("docker-compose.device-plane.yml",),
)
self.assertEqual(health.call_count, 3)
validate_runtime.assert_called_once_with()
self.assertEqual(
[call.args[0] for call in closed.call_args_list],
[18120, 18121, 9921],
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,386 @@
#!/usr/bin/env python3
import hashlib
import importlib.machinery
import importlib.util
import json
import os
import shutil
import subprocess
import tarfile
import tempfile
import unittest
from pathlib import Path
from unittest import mock
SCRIPT_DIR = Path(__file__).resolve().parent
RECOVERY_BUILDER = (
SCRIPT_DIR / "build-device-plane-foundation-recovery-artifact.mjs"
)
FOUNDATION_BUILDER = SCRIPT_DIR / "build-device-plane-artifact.mjs"
PREDECESSOR_COMPOSE = (
SCRIPT_DIR
/ "fixtures/device-plane-foundation-internal-only-v1.yml"
)
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
RECOVERY_ENTRIES = [
".dockerignore",
"package.json",
"package-lock.json",
"docker-compose.device-plane.yml",
"packages/device-protocol-contract",
"packages/arusnavi-b2-adapter",
"services/device-control-core",
"services/device-gateway",
"deployment/device-plane-foundation-recovery-v1.json",
]
def load_runner():
loader = importlib.machinery.SourceFileLoader(
"nodedc_device_plane_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 DevicePlaneFoundationRecoveryArtifactTest(unittest.TestCase):
def build(self, builder, 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_recovery_artifact_is_source_only_exact_and_deterministic(self):
with tempfile.TemporaryDirectory(
prefix="nodedc-device-plane-recovery-artifact-",
) as directory:
artifact_dir = Path(directory)
first = self.build(
RECOVERY_BUILDER,
artifact_dir,
"device-plane-foundation-recovery-unit-002",
)
artifact = Path(first["artifact"])
first_bytes = artifact.read_bytes()
second = self.build(
RECOVERY_BUILDER,
artifact_dir,
"device-plane-foundation-recovery-unit-002",
)
second_bytes = Path(second["artifact"]).read_bytes()
self.assertEqual(first["component"], "device-plane")
self.assertEqual(first["entries"], RECOVERY_ENTRIES)
self.assertEqual(first["build"], [])
self.assertEqual(first["services"], [])
self.assertEqual(
first["transition"],
"failed-foundation-live-runtime-adoption",
)
self.assertEqual(first_bytes, second_bytes)
self.assertEqual(
first["sha256"],
hashlib.sha256(first_bytes).hexdigest(),
)
with tarfile.open(artifact, "r:gz") as archive:
names = {member.name for member in archive.getmembers()}
files = (
archive.extractfile("files.txt")
.read()
.decode("utf-8")
.splitlines()
)
descriptor = json.loads(
archive.extractfile(
"payload/deployment/"
"device-plane-foundation-recovery-v1.json"
)
.read()
.decode("utf-8")
)
self.assertEqual(files, RECOVERY_ENTRIES)
self.assertIn(
"payload/services/device-control-core/src/server.mjs",
names,
)
self.assertIn(
"payload/services/device-gateway/src/server.mjs",
names,
)
self.assertFalse(any(
"/test/" in name
or "/node_modules/" in name
or Path(name).name.startswith(".env")
or name.startswith("payload/runtime/")
or name.startswith("payload/secrets/")
for name in names
))
self.assertEqual(
descriptor,
RUNNER.expected_device_plane_foundation_recovery_descriptor(),
)
self.assertEqual(
RUNNER.component_services(
"device-plane",
tuple(RECOVERY_ENTRIES),
),
(),
)
self.assertEqual(
RUNNER.component_builds(
"device-plane",
tuple(RECOVERY_ENTRIES),
),
(),
)
def test_preflight_requires_exact_failed_evidence_partial_source_and_runtime(
self,
):
with tempfile.TemporaryDirectory(
prefix="nodedc-device-plane-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 / "deployment",
):
path.mkdir(parents=True, exist_ok=True)
failed_build = self.build(
FOUNDATION_BUILDER,
artifacts,
RUNNER.DEVICE_PLANE_FOUNDATION_FAILED_PATCH_ID,
)
self.assertEqual(
failed_build["sha256"],
hashlib.sha256(
Path(failed_build["artifact"]).read_bytes()
).hexdigest(),
)
failed_artifact = (
failed_root / RUNNER.DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT
)
shutil.copy2(failed_build["artifact"], failed_artifact)
recovery_build = self.build(
RECOVERY_BUILDER,
artifacts,
"device-plane-foundation-recovery-unit-002",
)
recovery_extract = workspace / "recovery-extract"
recovery_extract.mkdir()
_manifest, entries, payload = RUNNER.load_artifact(
Path(recovery_build["artifact"]),
recovery_extract,
)
self.assertEqual(entries, RECOVERY_ENTRIES)
backup = (
backups_root
/ RUNNER.DEVICE_PLANE_FOUNDATION_RECOVERY_BACKUP_ID
)
backup.mkdir()
for name in (
"manifest.env",
"files.txt",
"existing-files.txt",
"missing-files.txt",
"source-before.tgz",
):
(backup / name).write_text(
f"fixture:{name}\n",
encoding="utf-8",
)
backup_hashes = {
name: hashlib.sha256((backup / name).read_bytes()).hexdigest()
for name in (
"manifest.env",
"files.txt",
"existing-files.txt",
"missing-files.txt",
"source-before.tgz",
)
}
(state_root / "failed.jsonl").write_text(
json.dumps({
"artifact":
RUNNER.DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT,
"backup_id":
RUNNER.DEVICE_PLANE_FOUNDATION_RECOVERY_BACKUP_ID,
"component": "device-plane",
"id": RUNNER.DEVICE_PLANE_FOUNDATION_FAILED_PATCH_ID,
"message":
"healthcheck failed for "
"http://127.0.0.1:18120/healthz: "
"<urlopen error [Errno 111] Connection refused>",
"rollback_status": "failed:DeployError",
"sha256":
failed_build["sha256"],
"started_apply": True,
"status": "failed",
})
+ "\n",
encoding="utf-8",
)
source_root = SCRIPT_DIR.parent.parent / "device-plane"
shutil.copy2(
PREDECESSOR_COMPOSE,
live_root / "docker-compose.device-plane.yml",
)
shutil.copy2(
source_root
/ "deployment/device-postgres-bootstrap-v1.json",
live_root
/ "deployment/device-postgres-bootstrap-v1.json",
)
runtime = {
service: {
"containerId": service,
"imageId":
RUNNER.DEVICE_PLANE_FOUNDATION_RECOVERY_IMAGE_IDS[
service
],
"health": "healthy",
"restartCount": 0,
}
for service in (
"device-control-core",
"device-gateway",
"device-postgres",
)
}
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_FOUNDATION_FAILED_ARTIFACT_SHA256",
failed_build["sha256"],
),
mock.patch.dict(
RUNNER.DEVICE_PLANE_FOUNDATION_RECOVERY_BACKUP_SHA256,
backup_hashes,
clear=True,
),
mock.patch.object(
RUNNER,
"validate_device_plane_foundation_runtime",
return_value=runtime,
),
):
result = (
RUNNER
.validate_device_plane_foundation_recovery_evidence(
payload
)
)
self.assertEqual(
result["mode"],
"failed-foundation-live-runtime-adoption",
)
self.assertEqual(result["runtime"], runtime)
def test_recovery_health_acceptance_never_mutates_runtime(self):
entries = tuple(RECOVERY_ENTRIES)
checks = ("core-health", "gateway-health")
with (
mock.patch.object(
RUNNER,
"healthcheck_compose_service",
) as compose_health,
mock.patch.object(
RUNNER,
"component_healthchecks",
return_value=checks,
),
mock.patch.object(RUNNER, "healthcheck_url") as url_health,
mock.patch.object(
RUNNER,
"validate_device_plane_foundation_installed_source",
) as source_acceptance,
mock.patch.object(
RUNNER,
"validate_device_plane_foundation_runtime",
) as runtime_acceptance,
mock.patch.object(RUNNER, "run_compose") as compose_mutation,
mock.patch.object(RUNNER, "run_build") as build_mutation,
):
RUNNER.run_healthchecks("device-plane", entries, ())
self.assertEqual(
[call.args for call in compose_health.call_args_list],
[
("device-plane", "device-control-core"),
("device-plane", "device-gateway"),
("device-plane", "device-postgres"),
],
)
self.assertEqual(
[call.args[0] for call in url_health.call_args_list],
list(checks),
)
source_acceptance.assert_called_once_with()
runtime_acceptance.assert_called_once_with()
compose_mutation.assert_not_called()
build_mutation.assert_not_called()
def test_recovery_runtime_phase_has_no_runtime_mutation(self):
entries = tuple(RECOVERY_ENTRIES)
with (
mock.patch.object(
RUNNER,
"prepare_component_runtime",
) as prepare,
mock.patch.object(RUNNER, "run_compose") as compose_mutation,
mock.patch.object(RUNNER, "run_build") as build_mutation,
):
RUNNER.run_component_runtime("device-plane", entries, ())
prepare.assert_not_called()
compose_mutation.assert_not_called()
build_mutation.assert_not_called()
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -0,0 +1,210 @@
#!/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
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
BUILDER = (
SCRIPT_DIR
/ "build-device-plane-postgres-bootstrap-artifact.mjs"
)
EXPECTED_ENTRIES = [
"docker-compose.device-plane.yml",
"deployment/device-postgres-bootstrap-v1.json",
]
def load_runner():
loader = importlib.machinery.SourceFileLoader(
"nodedc_device_plane_postgres_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 DevicePlanePostgresBootstrapTest(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_bootstrap_artifact_is_exact_deterministic_and_runner_accepted(self):
with tempfile.TemporaryDirectory(
prefix="nodedc-device-plane-postgres-artifact-",
) as directory:
artifact_dir = Path(directory)
first = self.build(
artifact_dir,
"device-plane-postgres-bootstrap-unit-001",
)
artifact = Path(first["artifact"])
first_bytes = artifact.read_bytes()
second = self.build(
artifact_dir,
"device-plane-postgres-bootstrap-unit-001",
)
second_bytes = Path(second["artifact"]).read_bytes()
self.assertEqual(first["entries"], EXPECTED_ENTRIES)
self.assertEqual(first["services"], ["device-postgres"])
self.assertEqual(first["mode"], "create-if-absent")
self.assertEqual(first["rollbackVolumePolicy"], "preserve")
self.assertEqual(
first["sha256"],
hashlib.sha256(first_bytes).hexdigest(),
)
self.assertEqual(first_bytes, second_bytes)
with tarfile.open(artifact, "r:gz") as archive:
names = {member.name for member in archive.getmembers()}
self.assertEqual(
archive.extractfile("files.txt")
.read()
.decode("utf-8")
.splitlines(),
EXPECTED_ENTRIES,
)
self.assertEqual(
names,
{
"manifest.env",
"files.txt",
"payload",
"payload/docker-compose.device-plane.yml",
"payload/deployment",
"payload/deployment/device-postgres-bootstrap-v1.json",
},
)
with tempfile.TemporaryDirectory(
prefix="nodedc-device-plane-postgres-load-",
) as work_directory:
manifest, entries, _payload = RUNNER.load_artifact(
artifact,
Path(work_directory),
)
self.assertEqual(manifest["component"], "device-plane")
self.assertEqual(entries, EXPECTED_ENTRIES)
self.assertTrue(
RUNNER.is_device_plane_postgres_bootstrap_slice(
manifest["component"],
entries,
),
)
self.assertEqual(
RUNNER.component_services("device-plane", entries),
("device-postgres",),
)
self.assertEqual(
RUNNER.component_builds("device-plane", entries),
(),
)
def test_preflight_accepts_only_absent_container_and_volume(self):
absent_container = mock.Mock(returncode=0, stdout="")
absent_volume = mock.Mock(returncode=1)
with mock.patch.object(
RUNNER.subprocess,
"run",
side_effect=[absent_container, absent_volume],
):
self.assertEqual(
RUNNER.preflight_device_plane_postgres_bootstrap(),
"absent",
)
existing_container = mock.Mock(
returncode=0,
stdout="abc123def456\n",
)
with mock.patch.object(
RUNNER.subprocess,
"run",
return_value=existing_container,
):
with self.assertRaisesRegex(
RUNNER.DeployError,
"container already exists",
):
RUNNER.preflight_device_plane_postgres_bootstrap()
with mock.patch.object(
RUNNER.subprocess,
"run",
side_effect=[
absent_container,
mock.Mock(returncode=0),
],
):
with self.assertRaisesRegex(
RUNNER.DeployError,
"volume already exists",
):
RUNNER.preflight_device_plane_postgres_bootstrap()
def test_plan_selection_is_unambiguous_for_bootstrap_and_application(self):
self.assertEqual(
RUNNER.device_plane_postgres_plan_selection(None),
"preserved-prerequisite:not-selected",
)
self.assertEqual(
RUNNER.device_plane_postgres_plan_selection("absent"),
"bootstrap-selected:create-if-absent",
)
with self.assertRaisesRegex(
RUNNER.DeployError,
"plan preflight state is invalid",
):
RUNNER.device_plane_postgres_plan_selection("unknown")
def test_bootstrap_health_acceptance_is_database_service_only(self):
with mock.patch.object(
RUNNER,
"healthcheck_compose_service",
) as health:
RUNNER.run_healthchecks(
"device-plane",
EXPECTED_ENTRIES,
("device-postgres",),
)
health.assert_called_once_with(
"device-plane",
"device-postgres",
)
with self.assertRaisesRegex(
RUNNER.DeployError,
"service set mismatch",
):
RUNNER.run_healthchecks(
"device-plane",
EXPECTED_ENTRIES,
("device-control-core",),
)
if __name__ == "__main__":
unittest.main(verbosity=2)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,95 +0,0 @@
import hashlib
import importlib.machinery
import importlib.util
import io
import json
import stat
from types import SimpleNamespace
import tarfile
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parent
def load(name, path):
loader = importlib.machinery.SourceFileLoader(name, str(path))
spec = importlib.util.spec_from_loader(name, loader)
module = importlib.util.module_from_spec(spec); loader.exec_module(module)
return module
R = load("map_access_runner", ROOT / "nodedc-deploy")
B = load("map_access_builder", ROOT / "build-mission-core-map-access-artifact.py")
class MapAccessTests(unittest.TestCase):
def test_scope_never_selects_docker_or_accepts_host_policy_payload(self):
name = "mission-core-map-access"
self.assertEqual(R.component_services(name, ["access.json"]), ())
self.assertEqual(R.component_builds(name, ["access.json"]), ())
self.assertTrue(R.component_artifact_only(name))
self.assertTrue(R.allowed_payload_path(name, "access.json"))
for path in ("sshd_config", "../sshd_config", "script.py", "docker-compose.yml", ".env", "secrets/key"):
with self.assertRaises(R.DeployError): R.allowed_payload_path(name, path)
def test_deterministic_data_bundle_and_fail_closed_descriptor(self):
raw = B.build("map-access-test-001")
self.assertEqual(raw, B.build("map-access-test-001"))
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
artifact = root / "candidate.tgz"
artifact.write_bytes(raw)
with tarfile.open(fileobj=io.BytesIO(raw)) as archive:
self.assertEqual(archive.getnames(), ["manifest.env", "files.txt", "payload/access.json"])
unpacked = root / "unpacked"
unpacked.mkdir()
manifest, entries, payload = R.load_artifact(artifact, unpacked)
self.assertEqual(manifest["component"], "mission-core-map-access")
self.assertEqual(R.read_map_access_descriptor(payload, entries), "enabled")
for value in ({"schemaVersion":"nodedc.mission-core-map-access.v1","state":"enabled","user":"root"}, {"schemaVersion":"unknown","state":"enabled"}):
(payload/"access.json").write_text(json.dumps(value))
with self.assertRaises(R.DeployError): R.read_map_access_descriptor(payload, entries)
def test_exact_predecessor_idempotence_and_explicit_disable(self):
base = b"AllowTcpForwarding no\n"
with patch.object(R, "MAP_ACCESS_BASE_SHA", hashlib.sha256(base).hexdigest()):
enabled = R.map_access_policy(base, "enabled")
self.assertEqual(enabled, base + R.MAP_ACCESS_APPEND)
self.assertEqual(R.map_access_policy(enabled, "enabled"), enabled)
self.assertEqual(R.map_access_policy(enabled, "disabled"), base)
for drift in (base+b"# external drift\n", enabled+b"# external drift\n", enabled+R.MAP_ACCESS_APPEND):
with self.assertRaises(R.DeployError): R.map_access_policy(drift, "enabled")
def test_effective_policy_may_only_change_exact_user_and_destination(self):
with tempfile.TemporaryDirectory() as tmp, patch.object(R, "TMP_DIR", Path(tmp)), patch.object(R.subprocess,"run") as run:
run.return_value.returncode=0
candidate=Path(tmp)/"candidate";candidate.write_bytes(R.MAP_ACCESS_APPEND)
baseline={"allowtcpforwarding":"no", "permitopen":"any", "passwordauthentication":"yes"}
def effective(config,user,address):
value=dict(baseline)
if str(config)==str(candidate) and user=="dctouch":value.update(allowtcpforwarding="local",permitopen="127.0.0.1:18103")
return value
with patch.object(R,"map_access_effective",side_effect=effective):R.map_access_validate_candidate(b"baseline",candidate)
def broad(config,user,address):
value=effective(config,user,address)
if str(config)==str(candidate):value["permitopen"]="any"
return value
with patch.object(R,"map_access_effective",side_effect=broad), self.assertRaises(R.DeployError):R.map_access_validate_candidate(b"baseline",candidate)
def test_reload_failure_is_routed_to_domain_rollback(self):
with tempfile.TemporaryDirectory() as tmp, patch.object(R,"preflight_map_access",return_value=(b"old",b"new")), patch.object(R,"map_access_replace") as replace, patch.object(R,"map_access_reload",side_effect=R.DeployError("reload")):
with self.assertRaises(R.DeployError):R.apply_map_access(Path(tmp),["access.json"])
replace.assert_called_once_with(b"new")
def test_rollback_restores_exact_policy_and_descriptor(self):
base=b"AllowTcpForwarding no\n"
with tempfile.TemporaryDirectory() as tmp:
root=Path(tmp); backup=root/"backup"; backup.mkdir()
(backup/"sshd-config-before").write_bytes(base)
calls=[]
metadata=SimpleNamespace(st_mode=stat.S_IFREG|0o600,st_uid=0)
with patch.object(R,"MAP_ACCESS_BASE_SHA",hashlib.sha256(base).hexdigest()), patch.object(Path,"lstat",return_value=metadata), patch.object(R,"map_access_read_config",side_effect=[base+R.MAP_ACCESS_APPEND,base]), patch.object(R,"map_access_validate_candidate"), patch.object(R,"map_access_replace",side_effect=lambda value:calls.append(("replace",value))), patch.object(R,"map_access_reload",side_effect=lambda:calls.append(("reload",))), patch.object(R,"restore_overlay_source",side_effect=lambda *args:calls.append(("restore",))):
R.rollback_map_access(root,backup,["access.json"],"stamp")
self.assertEqual(calls,[("replace",base),("reload",),("restore",)])
if __name__ == "__main__": unittest.main()

Some files were not shown because too many files have changed in this diff Show More