Author SHA1 Message Date
Codex 26dbcfd262 deploy: keep manager health out of plan 2026-08-21 16:23:59 +03:00
Codex a9ea31f00a deploy: advance device manager v3 core baseline 2026-08-21 13:38:34 +03:00
Codex 827bf0a58a refactor: move Device Core source to standalone repository 2026-08-21 12:23:21 +03:00
Codex 40fbfcf351 fix(device-core): activate adopted model profiles safely 2026-08-13 10:51:02 +03:00
Codex 185c54ced2 fix(device-core): adopt legacy model profiles safely 2026-08-13 10:23:38 +03:00
Codex 9c359111ea fix(deploy): allow repeatable typed core releases 2026-08-13 09:44:07 +03:00
Codex 71fff82c99 fix(device-core): bind edge telemetry to enrolled route 2026-08-13 09:41:14 +03:00
Codex fe1c8054be test(device-plane): anonymize B2 command fixture 2026-08-12 22:59:43 +03:00
Codex 3ea2133bcd feat(deploy): add typed command transport releases 2026-08-12 22:57:20 +03:00
Codex 4b73a15765 feat(device-plane): add typed B2 service ping transport 2026-08-12 22:57:09 +03:00
Codex bdb85cb4f7 feat(deploy): register Device Control Core releases 2026-08-12 18:37:20 +03:00
Codex 66e0c62451 fix(device-plane): unwrap durable discovery receipts 2026-08-12 18:08:44 +03:00
Codex 606872edcc feat(device-edge): open bounded tracker ingress 2026-08-12 15:10:27 +03:00
Codex 6fd172ecc5 feat(device-edge): retire superseded VPS tailnet 2026-08-12 14:52:36 +03:00
Codex e21c188f85 fix(device-edge): validate Core egress transition 2026-08-12 14:00:42 +03:00
Codex b513ed973b Revert "fix(device-edge): route Core through dedicated egress"
This reverts commit 20ef5894cc.
2026-08-12 13:01:01 +03:00
Codex 20ef5894cc fix(device-edge): route Core through dedicated egress 2026-08-12 10:41:51 +03:00
Codex 9fa74a1243 feat(deploy): add versioned Core channel upgrade v2 2026-08-12 09:48:51 +03:00
Codex 020d4dbad7 fix(device-edge): bound stalled Core channel connects 2026-08-12 09:26:40 +03:00
Codex d8c0e9f3b1 feat(deploy): add Core channel forward upgrade 2026-08-12 01:43:09 +03:00
Codex c2d06bddc7 feat(device-edge): standardize Core channel on mTLS 443 2026-08-12 01:43:04 +03:00
Codex 2eb6116880 fix(device-edge): use compatible Node hardening 2026-08-12 01:23:38 +03:00
Codex 6fa9164933 fix(device-edge): reconcile runtime modes safely 2026-08-12 01:18:40 +03:00
Codex e85bb1448e fix(device-edge): pin accepted foundation baseline 2026-08-12 01:11:23 +03:00
Codex 3a13f0effc fix(device-plane): correct gateway receipt immutable trigger 2026-08-12 00:20:33 +03:00
Codex 1e01161a83 fix(deploy): recognize failed edge certificate shape 2026-08-11 23:33:57 +03:00
Codex d641744dca fix(device-plane): split edge channel bootstrap 2026-08-11 23:27:16 +03:00
Codex bb0724c52c fix(deploy): keep manager releases on stable runner 2026-08-11 21:54:49 +03:00
Codex 094cf7143a fix(deploy): make manager release build context complete 2026-08-11 21:37:35 +03:00
Codex f53759cb15 fix(deploy): include edge channel contract in manager release 2026-08-11 21:15:26 +03:00
Codex 35c37de586 fix(deploy): track installed device manager release generation 2026-08-11 21:01:37 +03:00
Codex 0f44cca27d fix(deploy): version device manager edge release contract 2026-08-11 20:43:07 +03:00
Codex 724e745628 feat(device-plane): activate canonical outbound edge channel 2026-08-11 20:27:50 +03:00
Codex be964eccb7 feat(device-plane): supervise pinned edge channels in core 2026-08-11 20:14:55 +03:00
Codex 6461e7fca8 feat(device-edge): add canonical core channel deployment 2026-08-11 20:00:58 +03:00
Codex 1124c15216 feat(device-plane): harden edge channel rotation 2026-08-11 19:37:32 +03:00
Codex 50b9179fe4 feat(device-plane): add core-initiated edge channel 2026-08-11 19:30:18 +03:00
Codex 393741f1bd feat(device-plane): add universal adapter acceptance boundary 2026-08-11 19:11:48 +03:00
Codex 227c7c26c1 build(device-plane): prepare device profile workspace release 2026-08-11 18:13:49 +03:00
Codex c9d6068f36 build(device-plane): prepare workspace query fix release 2026-08-11 17:13:07 +03:00
Codex 42892cd43b fix(device-core): allow workspace reads in readonly transactions 2026-08-11 17:11:55 +03:00
Codex 5565486ac9 build(foundry): harden map release artifacts 2026-08-11 12:29:59 +03:00
Codex 28371f67a5 build(device-plane): prepare device enrollment release 2026-08-11 12:04:08 +03:00
Codex 6a8c1ce1ef feat(device-core): protect enrollment identifiers 2026-08-11 12:03:21 +03:00
Codex 9f12edd736 build(device-plane): prepare device manager redirect fix 2026-08-11 11:29:13 +03:00
Codex 72b4846b32 refactor(deploy): make device manager releases declarative 2026-08-11 10:37:13 +03:00
Codex 1adbabefcf fix(device-plane): activate manager from v2 reconciliation 2026-08-11 10:14:24 +03:00
Codex 37bdbebb4e fix(device-plane): reconcile failed manager activation 2026-08-11 09:35:58 +03:00
Codex 8defd55715 fix(device-plane): activate manager from reconciled baseline 2026-08-11 01:12:44 +03:00
Codex 04ba3a9f99 fix(device-plane): reconcile failed manager rollout 2026-08-11 00:32:35 +03:00
Codex 4374a9b4e7 build(deploy): package Hub service trust UI 2026-08-10 23:31:02 +03:00
Codex 0679142561 test(device-plane): prove Device Manager rollback 2026-08-10 22:13:20 +03:00
Codex 29ba5de92e feat(device-plane): add canonical Device Manager runtime 2026-08-10 22:00:59 +03:00
Codex 1d1e9a96b3 feat(device-core): expose safe project control views 2026-08-10 20:49:10 +03:00
Codex f5d7916338 feat(device-core): add authorized project read model 2026-08-10 20:10:29 +03:00
Codex 43dc9b1f45 feat(device-core): add control resource ledger 2026-08-10 19:05:48 +03:00
Codex 422ddb020f feat(device-core): add restricted identity references 2026-08-10 18:31:30 +03:00
Codex fceaca9546 feat(device-core): add device ownership lifecycle 2026-08-10 18:03:56 +03:00
Codex 72db23c0e9 feat(device-core): add registry management commands 2026-08-10 17:44:14 +03:00
Codex 9fdaed1c95 feat(device-core): add device registry schema 2026-08-10 17:34:50 +03:00
Codex 70bafdd028 feat(device-core): add idempotent project management 2026-08-10 17:27:07 +03:00
Codex 336602c7ca feat(device-core): add project ownership schema 2026-08-10 16:59:15 +03:00
Codex bf0bc50abb feat(device-plane): accept core edge transport ADR 2026-08-10 16:40:48 +03:00
93 changed files with 5898 additions and 10594 deletions
-12
View File
@@ -1,12 +0,0 @@
.git
.DS_Store
.env
.env.*
docs
node_modules
**/test
**/*.log
**/*.prev-*
**/*.next-*
runtime
secrets
-63
View File
@@ -1,63 +0,0 @@
# 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.
@@ -1,39 +0,0 @@
{
"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"
}
@@ -1,25 +0,0 @@
{
"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"
}
@@ -1,36 +0,0 @@
{
"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"
}
@@ -1,30 +0,0 @@
{
"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"
}
@@ -1,23 +0,0 @@
{
"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"
}
@@ -1,36 +0,0 @@
{
"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"
}
@@ -1,24 +0,0 @@
{
"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"
}
@@ -1,16 +0,0 @@
{
"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"
}
@@ -1,8 +0,0 @@
{
"schemaVersion": "nodedc.device-plane.postgres-bootstrap.v1",
"service": "device-postgres",
"volume": "nodedc-device-plane-postgres-data",
"mode": "create-if-absent",
"ordinaryApplicationSelection": "forbidden",
"rollbackVolumePolicy": "preserve"
}
@@ -1,82 +0,0 @@
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
@@ -1,35 +0,0 @@
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
@@ -1,44 +0,0 @@
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
@@ -1,39 +0,0 @@
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
@@ -1,158 +0,0 @@
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
@@ -1,106 +0,0 @@
# 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.
@@ -1,277 +0,0 @@
# 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
@@ -1,30 +0,0 @@
# 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
@@ -1,223 +0,0 @@
{
"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
@@ -1,16 +0,0 @@
{
"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"
}
}
@@ -1,15 +0,0 @@
{
"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"
}
}
@@ -1,262 +0,0 @@
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);
}
@@ -1,127 +0,0 @@
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,
);
});
@@ -1,15 +0,0 @@
{
"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"
}
}
@@ -1,239 +0,0 @@
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`);
}
}
@@ -1,108 +0,0 @@
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);
});
@@ -1,12 +0,0 @@
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"]
@@ -1,35 +0,0 @@
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
@@ -1,16 +0,0 @@
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"]
@@ -1,99 +0,0 @@
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;
@@ -1,16 +0,0 @@
{
"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"
}
}
@@ -1,152 +0,0 @@
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";
}
@@ -1,73 +0,0 @@
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;
}
@@ -1,128 +0,0 @@
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;
}
@@ -1,107 +0,0 @@
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");
}
@@ -1,143 +0,0 @@
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()));
}),
};
}
@@ -1,50 +0,0 @@
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,
);
});
@@ -1,32 +0,0 @@
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'/);
});
@@ -1,9 +0,0 @@
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"]
@@ -1,34 +0,0 @@
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");
});
@@ -1,9 +0,0 @@
FROM node:22-alpine
WORKDIR /app
COPY services/device-edge-relay/src ./src
USER node
CMD ["node", "src/server.mjs"]
@@ -1,13 +0,0 @@
{
"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"
}
}
@@ -1,384 +0,0 @@
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()));
});
}
@@ -1,85 +0,0 @@
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");
}
@@ -1,110 +0,0 @@
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");
});
@@ -1,163 +0,0 @@
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()));
});
}
@@ -1,16 +0,0 @@
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"]
@@ -1,13 +0,0 @@
{
"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"
}
}
@@ -1,75 +0,0 @@
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");
}
}
@@ -1,362 +0,0 @@
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()));
});
}
@@ -1,145 +0,0 @@
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");
}
@@ -1,60 +0,0 @@
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/,
);
});
@@ -1,112 +0,0 @@
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);
});
}
@@ -1,194 +0,0 @@
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);
});
}
@@ -1,265 +0,0 @@
#!/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);
}
}
@@ -1,201 +0,0 @@
#!/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);
}
}
@@ -1,271 +0,0 @@
#!/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);
}
}
@@ -1,175 +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 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");
}
@@ -1,256 +0,0 @@
#!/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));
}
}
@@ -1,274 +0,0 @@
#!/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);
}
}
@@ -1,255 +0,0 @@
#!/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);
}
}
@@ -1,162 +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 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");
}
@@ -0,0 +1,63 @@
#!/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");
}
@@ -0,0 +1,80 @@
#!/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");
}
@@ -13,7 +13,7 @@ const foundryRoot = resolve(workspaceRoot, "NODEDC_DESIGN_GUIDELINE");
const artifactDir = resolve( const artifactDir = resolve(
process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"), process.env.NODEDC_DEPLOY_ARTIFACT_DIR || resolve(scriptDir, "../deploy-artifacts"),
); );
const [patchId = "module-foundry-map-grid-lod-20260805-001", ...extra] = const [patchId = "module-foundry-map-grid-lod-20260805-002", ...extra] =
process.argv.slice(2); process.argv.slice(2);
if (extra.length || !/^module-foundry-map-grid-lod-\d{8}-\d{3}$/.test(patchId)) { if (extra.length || !/^module-foundry-map-grid-lod-\d{8}-\d{3}$/.test(patchId)) {
@@ -28,9 +28,10 @@ const files = Object.freeze([
"apps/catalog/src/MapFixturePreview.tsx", "apps/catalog/src/MapFixturePreview.tsx",
"apps/catalog/src/mapGridPolicy.d.mts", "apps/catalog/src/mapGridPolicy.d.mts",
"apps/catalog/src/mapGridPolicy.mjs", "apps/catalog/src/mapGridPolicy.mjs",
"package.json", "apps/catalog/src/styles.css",
"runtime-seed/page-layouts/map.json", "runtime-seed/page-layouts/map.json",
"scripts/map-grid-lod.test.mjs", "scripts/map-grid-lod.test.mjs",
"scripts/map-object-layers.test.mjs",
"server/catalog-server.mjs", "server/catalog-server.mjs",
"server/foundry-mcp.mjs", "server/foundry-mcp.mjs",
]); ]);
@@ -69,7 +70,7 @@ try {
checksum, checksum,
sha256, sha256,
services: ["nodedc-module-foundry"], services: ["nodedc-module-foundry"],
transition: "five-band-grid-lod-with-seamless-layer-swap", transition: "five-lod-mmap-parity-fast-grid-and-focus-recovery",
files, files,
}, null, 2)); }, null, 2));
} finally { } finally {
@@ -80,31 +81,40 @@ async function assertGridLodBoundary() {
const policy = await readFile(join(foundryRoot, "apps/catalog/src/mapGridPolicy.mjs"), "utf8"); 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 renderer = await readFile(join(foundryRoot, "apps/catalog/src/CesiumMapRenderer.tsx"), "utf8");
const inspector = await readFile(join(foundryRoot, "apps/catalog/src/MapFixturePreview.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 server = await readFile(join(foundryRoot, "server/catalog-server.mjs"), "utf8");
const tests = await readFile(join(foundryRoot, "scripts/map-grid-lod.test.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 ["gridLod1MaxHeightKm", "gridLod5StepKm", "GRID_LOD_HYSTERESIS_RATIO"]) { for (const marker of ["gridLodProfile", "gridLodProfiles", "GRID_LOD_HYSTERESIS_RATIO"]) {
if (!policy.includes(marker)) throw new Error(`grid_policy_marker_missing:${marker}`); if (!policy.includes(marker)) throw new Error(`grid_policy_marker_missing:${marker}`);
} }
for (const marker of ["class GridLayerController", "corridor:", "gridLineDiameterMeters"]) { for (const marker of ["class GridLayerController", "gridLegacyMode", "lineDiameterMeters", "viewer.flyTo(entity"]) {
if (!renderer.includes(marker)) throw new Error(`grid_renderer_marker_missing:${marker}`); if (!renderer.includes(marker)) throw new Error(`grid_renderer_marker_missing:${marker}`);
} }
if (/function rebuildElevatedGrid[\s\S]*?entities\.removeAll\(\)/.test(renderer)) { if (/function rebuildElevatedGrid[\s\S]*?entities\.removeAll\(\)/.test(renderer)) {
throw new Error("grid_renderer_destructive_layer_swap_detected"); throw new Error("grid_renderer_destructive_layer_swap_detected");
} }
for (const marker of ["LOD 5: режим", "3D-линии: диаметр", "Гратикула: толщина"]) { for (const marker of ["LOD ${index + 1}", "Конус видимости 3D", "Размер major-тайла ENU", "Радиус ENU-поля", "Диаметр 3D-линий", "Кружки: диаметр", "Кресты: длина"]) {
if (!inspector.includes(marker)) throw new Error(`grid_inspector_marker_missing:${marker}`); if (!inspector.includes(marker)) throw new Error(`grid_inspector_marker_missing:${marker}`);
} }
for (const marker of ["gridLod5Mode", "gridLineDiameterMeters", "gridRebuildOnMoveEnd"]) { 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"]) {
if (!server.includes(marker)) throw new Error(`grid_server_contract_missing:${marker}`); if (!server.includes(marker)) throw new Error(`grid_server_contract_missing:${marker}`);
} }
for (const marker of [ for (const marker of [
"five grid LODs preserve the close 3D and distant graticule contract", "canonical defaults preserve the exact effective MMAP/MOSCOWMAP five-LOD donor profile",
"LOD hysteresis holds the previous band around a threshold", "every LOD owns independent metric and angular spacing and visual fields",
"renderer swaps double-buffered data sources and never clears the live grid first", "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",
]) { ]) {
if (!tests.includes(marker)) throw new Error(`grid_regression_missing:${marker}`); 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) { async function assertFresh(path) {
@@ -0,0 +1,132 @@
#!/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,72 @@
#!/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");
}
@@ -0,0 +1,54 @@
#!/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,118 +0,0 @@
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
@@ -1,126 +0,0 @@
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
File diff suppressed because it is too large Load Diff
-868
View File
@@ -1,868 +0,0 @@
#!/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)
@@ -1,323 +0,0 @@
#!/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)
@@ -1,156 +0,0 @@
#!/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)
@@ -1,477 +0,0 @@
#!/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()
@@ -1,323 +0,0 @@
#!/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()
@@ -1,649 +0,0 @@
#!/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()
@@ -1,302 +0,0 @@
#!/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()
@@ -1,386 +0,0 @@
#!/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)
@@ -1,210 +0,0 @@
#!/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)
@@ -1,6 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import importlib.machinery import importlib.machinery
import importlib.util import importlib.util
import inspect
import json import json
import tempfile import tempfile
import unittest import unittest
@@ -27,6 +28,75 @@ RUNNER = load_runner()
class DevicePlaneRegistryTest(unittest.TestCase): class DevicePlaneRegistryTest(unittest.TestCase):
def test_manager_predecessor_health_is_phase_scoped(self):
with self.assertRaisesRegex(
RUNNER.DeployError,
"preflight phase is invalid",
):
RUNNER.validate_device_plane_manager_activation_predecessor(
Path("/not-used"),
preflight_phase="unknown",
)
plan_source = inspect.getsource(RUNNER.plan_artifact)
self.assertIn('preflight_phase="plan"', plan_source)
apply_source = inspect.getsource(RUNNER.apply_artifact)
self.assertIn('preflight_phase="apply"', apply_source)
def test_manager_v3_health_gate_checks_only_preserved_services(self):
descriptor = {
"schemaVersion": (
"nodedc.device-plane.device-manager-release.v3"
),
}
with mock.patch.object(
RUNNER,
"healthcheck_compose_service",
) as healthcheck:
services = (
RUNNER.validate_device_plane_manager_preserved_runtime_health(
descriptor
)
)
self.assertEqual(
services,
("device-control-core", "device-postgres"),
)
self.assertEqual(
healthcheck.call_args_list,
[
mock.call("device-plane", "device-control-core"),
mock.call("device-plane", "device-postgres"),
],
)
self.assertNotIn(
mock.call("device-plane", "device-manager"),
healthcheck.call_args_list,
)
def test_legacy_manager_health_gate_excludes_selected_services(self):
descriptor = {
"schemaVersion": (
"nodedc.device-plane.device-manager-release.v2"
),
}
with mock.patch.object(
RUNNER,
"healthcheck_compose_service",
) as healthcheck:
services = (
RUNNER.validate_device_plane_manager_preserved_runtime_health(
descriptor
)
)
self.assertEqual(services, ("device-postgres",))
healthcheck.assert_called_once_with(
"device-plane",
"device-postgres",
)
def test_registry_has_exact_roots_project_and_stateless_services(self): def test_registry_has_exact_roots_project_and_stateless_services(self):
component = RUNNER.COMPONENTS["device-plane"] component = RUNNER.COMPONENTS["device-plane"]
root = Path("/volume1/docker/nodedc-device-plane") root = Path("/volume1/docker/nodedc-device-plane")
@@ -107,6 +177,7 @@ class DevicePlaneRegistryTest(unittest.TestCase):
".dockerignore", ".dockerignore",
"docker-compose.device-plane.yml", "docker-compose.device-plane.yml",
"packages/device-protocol-contract/src/index.mjs", "packages/device-protocol-contract/src/index.mjs",
"packages/device-edge-channel-contract/src/index.mjs",
"services/device-control-core/Dockerfile", "services/device-control-core/Dockerfile",
"services/device-gateway/src/runtime.mjs", "services/device-gateway/src/runtime.mjs",
): ):
@@ -119,6 +190,9 @@ class DevicePlaneRegistryTest(unittest.TestCase):
"secrets/postgres-password", "secrets/postgres-password",
"runtime/postgres/data", "runtime/postgres/data",
"services/unknown/server.mjs", "services/unknown/server.mjs",
"packages/device-adapter-runtime/package.json",
"packages/device-adapter-catalog/package.json",
"services/device-gateway-core/src/runtime.mjs",
"docker-compose.yml", "docker-compose.yml",
"services/device-control-core/start.sh", "services/device-control-core/start.sh",
"services/device-control-core/test/app.test.mjs", "services/device-control-core/test/app.test.mjs",
@@ -22,9 +22,10 @@ EXPECTED_FILES = (
"apps/catalog/src/MapFixturePreview.tsx", "apps/catalog/src/MapFixturePreview.tsx",
"apps/catalog/src/mapGridPolicy.d.mts", "apps/catalog/src/mapGridPolicy.d.mts",
"apps/catalog/src/mapGridPolicy.mjs", "apps/catalog/src/mapGridPolicy.mjs",
"package.json", "apps/catalog/src/styles.css",
"runtime-seed/page-layouts/map.json", "runtime-seed/page-layouts/map.json",
"scripts/map-grid-lod.test.mjs", "scripts/map-grid-lod.test.mjs",
"scripts/map-object-layers.test.mjs",
"server/catalog-server.mjs", "server/catalog-server.mjs",
"server/foundry-mcp.mjs", "server/foundry-mcp.mjs",
) )
@@ -81,9 +82,16 @@ class ModuleFoundryGridLodArtifactTest(unittest.TestCase):
inspector = (payload / EXPECTED_FILES[1]).read_text(encoding="utf-8") inspector = (payload / EXPECTED_FILES[1]).read_text(encoding="utf-8")
policy = (payload / EXPECTED_FILES[3]).read_text(encoding="utf-8") policy = (payload / EXPECTED_FILES[3]).read_text(encoding="utf-8")
self.assertIn("class GridLayerController", renderer) self.assertIn("class GridLayerController", renderer)
self.assertIn("gridLineDiameterMeters", renderer) self.assertIn("viewer.flyTo(entity", renderer)
self.assertIn("LOD 5: режим", inspector) self.assertIn("lineDiameterMeters", renderer)
self.assertIn("LOD ${index + 1}", inspector)
self.assertIn("Конус видимости 3D", inspector)
self.assertIn("Размер major-тайла ENU", inspector)
self.assertIn("Радиус ENU-поля", inspector)
self.assertIn("Диаметр 3D-линий", inspector)
self.assertIn("Кресты: длина", inspector)
self.assertIn("GRID_LOD_HYSTERESIS_RATIO", policy) self.assertIn("GRID_LOD_HYSTERESIS_RATIO", policy)
self.assertIn("gridLodProfiles", policy)
with tarfile.open(first_artifact, "r:gz") as archive: with tarfile.open(first_artifact, "r:gz") as archive:
members = archive.getmembers() members = archive.getmembers()
@@ -0,0 +1,93 @@
#!/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
PLATFORM_ROOT = SCRIPT_DIR.parent.parent
BUILDER = SCRIPT_DIR / "build-module-foundry-map-focus-artifact.mjs"
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
PATCH_ID = "module-foundry-map-focus-recovery-20260805-999"
EXPECTED_FILES = (
"apps/catalog/src/CesiumMapRenderer.tsx",
"scripts/map-object-layers.test.mjs",
)
def load_runner():
loader = importlib.machinery.SourceFileLoader(
"nodedc_module_foundry_map_focus_artifact",
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 ModuleFoundryMapFocusArtifactTest(unittest.TestCase):
def build(self, artifact_dir):
environment = os.environ.copy()
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
completed = subprocess.run(
["node", str(BUILDER), PATCH_ID],
cwd=PLATFORM_ROOT,
env=environment,
check=True,
capture_output=True,
text=True,
)
return json.loads(completed.stdout)
def test_builder_is_deterministic_exact_scope_and_runner_compatible(self):
with tempfile.TemporaryDirectory(prefix="nodedc-foundry-map-focus-") as directory:
root = Path(directory)
first = self.build(root / "first")
second = self.build(root / "second")
first_artifact = Path(first["artifact"])
second_artifact = Path(second["artifact"])
self.assertEqual(first_artifact.read_bytes(), second_artifact.read_bytes())
self.assertEqual(first["sha256"], hashlib.sha256(first_artifact.read_bytes()).hexdigest())
self.assertEqual(tuple(first["files"]), EXPECTED_FILES)
extracted = root / "loaded"
extracted.mkdir()
manifest, entries, payload = RUNNER.load_artifact(first_artifact, extracted)
self.assertEqual(manifest["component"], "module-foundry")
self.assertEqual(tuple(entries), EXPECTED_FILES)
self.assertEqual(RUNNER.component_services("module-foundry", entries), ("nodedc-module-foundry",))
renderer = (payload / EXPECTED_FILES[0]).read_text(encoding="utf-8")
regression = (payload / EXPECTED_FILES[1]).read_text(encoding="utf-8")
self.assertIn("void viewer.flyTo(entity, {", renderer)
self.assertIn("offset: new HeadingPitchRange(0, -0.9, 8_000)", renderer)
self.assertIn("void viewer\\.flyTo\\(entity, \\{", regression)
with tarfile.open(first_artifact, "r:gz") as archive:
members = archive.getmembers()
names = [member.name for member in members]
regular_payloads = [
archive.extractfile(member).read()
for member in members
if member.isfile()
]
self.assertFalse(any(Path(name).name.startswith("._") for name in names))
self.assertFalse(any("/.git/" in name or "/node_modules/" in name or "/runtime-data/" in name for name in names))
self.assertNotIn(b"-----BEGIN PRIVATE KEY-----", b"\n".join(regular_payloads))
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -0,0 +1,11 @@
{
"schemaVersion": "nodedc.platform.device-core-hub-trust.v1",
"action": "activate",
"serviceSlug": "device-core",
"launcherCredential": "runner-managed-file",
"credentialScope": [
"handoff.consume",
"session.validate"
],
"publicRoute": "unchanged"
}
@@ -0,0 +1,8 @@
{
"schemaVersion": "nodedc.platform.device-manager-public-route.v1",
"action": "activate",
"hostname": "device.nodedc.ru",
"upstream": "device-manager:18122",
"transport": "reverse-proxy",
"rawTcpIngress": "forbidden"
}
+10
View File
@@ -87,6 +87,16 @@ http://hub.nodedc.ru {
} }
} }
http://device.nodedc.ru {
reverse_proxy device-manager:18122 {
header_up Host device.nodedc.ru
header_up X-Forwarded-Host device.nodedc.ru
header_up X-Forwarded-Proto https
header_up X-Forwarded-Port 443
header_up X-Forwarded-For {remote_host}
}
}
http://ops.nodedc.ru { http://ops.nodedc.ru {
reverse_proxy {$SYNOLOGY_TASK_MANAGER_UPSTREAM} { reverse_proxy {$SYNOLOGY_TASK_MANAGER_UPSTREAM} {
header_up Host ops.nodedc.ru header_up Host ops.nodedc.ru
@@ -46,11 +46,18 @@ services:
AUTHENTIK_BASE_URL: http://nodedc-platform-authentik-server:9000 AUTHENTIK_BASE_URL: http://nodedc-platform-authentik-server:9000
NODEDC_NOTIFICATION_CORE_URL: http://notification-core:5185 NODEDC_NOTIFICATION_CORE_URL: http://notification-core:5185
NODEDC_AI_WORKSPACE_ASSISTANT_URL: http://ai-workspace-assistant:18082 NODEDC_AI_WORKSPACE_ASSISTANT_URL: http://ai-workspace-assistant:18082
NODEDC_DEVICE_CORE_INTERNAL_TOKEN_FILE: /run/nodedc-secrets/device-core-internal-token
expose: expose:
- "5173" - "5173"
volumes: volumes:
- ../launcher/server-storage:/app/server/storage - ../launcher/server-storage:/app/server/storage
- ../launcher/uploads:/app/server/storage/uploads - ../launcher/uploads:/app/server/storage/uploads
- type: bind
source: /volume1/docker/nodedc-platform/secrets/device-core-internal-token
target: /run/nodedc-secrets/device-core-internal-token
read_only: true
bind:
create_host_path: false
extra_hosts: extra_hosts:
- "id.nodedc.ru:host-gateway" - "id.nodedc.ru:host-gateway"
- "hub.nodedc.ru:host-gateway" - "hub.nodedc.ru:host-gateway"
@@ -0,0 +1,29 @@
export const NDC_CREDENTIAL_REFERENCE_OWNER = "ndc_l2_credentials";
const CREDENTIAL_REFERENCE_PATTERN =
/^ndc-credref:[A-Za-z0-9][A-Za-z0-9._:-]{7,240}$/;
export function normalizeNdcCredentialReference(input) {
if (!input || typeof input !== "object" || Array.isArray(input)) {
throw new TypeError("ndc_credential_reference_invalid");
}
for (const key of Object.keys(input)) {
if (!new Set(["owner", "reference"]).has(key)) {
throw new TypeError(`ndc_credential_reference_field_unexpected:${key}`);
}
}
if (input.owner !== NDC_CREDENTIAL_REFERENCE_OWNER) {
throw new TypeError("ndc_credential_reference_owner_invalid");
}
if (!isNdcCredentialReferenceValue(input.reference)) {
throw new TypeError("ndc_credential_reference_value_invalid");
}
return Object.freeze({
owner: NDC_CREDENTIAL_REFERENCE_OWNER,
reference: input.reference,
});
}
export function isNdcCredentialReferenceValue(value) {
return typeof value === "string" && CREDENTIAL_REFERENCE_PATTERN.test(value);
}
@@ -1,6 +1,12 @@
import { EXTERNAL_PROVIDER_CONTRACT_VERSION } from "./contract-version.mjs"; import { EXTERNAL_PROVIDER_CONTRACT_VERSION } from "./contract-version.mjs";
import { isNdcCredentialReferenceValue } from "./credential-reference.mjs";
export { EXTERNAL_PROVIDER_CONTRACT_VERSION } from "./contract-version.mjs"; export { EXTERNAL_PROVIDER_CONTRACT_VERSION } from "./contract-version.mjs";
export {
NDC_CREDENTIAL_REFERENCE_OWNER,
isNdcCredentialReferenceValue,
normalizeNdcCredentialReference,
} from "./credential-reference.mjs";
export { validateIntakeBatch } from "./intake-batch.mjs"; export { validateIntakeBatch } from "./intake-batch.mjs";
export { export {
DATA_PRODUCT_GEOMETRY_TYPES, DATA_PRODUCT_GEOMETRY_TYPES,
@@ -199,6 +205,12 @@ export function validateConnectionProfile(value) {
if (value?.credentialRef?.owner && value.credentialRef.owner !== "ndc_l2_credentials") { if (value?.credentialRef?.owner && value.credentialRef.owner !== "ndc_l2_credentials") {
errors.push("credentialRef.owner_must_be_ndc_l2_credentials"); errors.push("credentialRef.owner_must_be_ndc_l2_credentials");
} }
if (
typeof value?.credentialRef?.reference === "string"
&& !isNdcCredentialReferenceValue(value.credentialRef.reference)
) {
errors.push("credentialRef.reference_must_be_canonical_ndc_ref");
}
if (containsSecretLikeMaterial(value)) errors.push("profile_must_not_contain_secret_material"); if (containsSecretLikeMaterial(value)) errors.push("profile_must_not_contain_secret_material");
if (value?.scope !== undefined) { if (value?.scope !== undefined) {
if (!isPlainObject(value.scope)) { if (!isPlainObject(value.scope)) {
@@ -1,4 +1,6 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { isNdcCredentialReferenceValue } from "./credential-reference.mjs";
import { validateProviderPackage } from "./provider-package.mjs"; import { validateProviderPackage } from "./provider-package.mjs";
import { import {
compileTelemetryFieldProjection, compileTelemetryFieldProjection,
@@ -20,7 +22,6 @@ export const L2_EXECUTION_PLAN_SUPPORTED_COMPILER_VERSIONS = Object.freeze([
const HASH = /^(?:sha256:)?[a-f0-9]{64}$/; const HASH = /^(?:sha256:)?[a-f0-9]{64}$/;
const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/; const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/;
const CREDENTIAL_REF = /^ndc-credref:[A-Za-z0-9._:-]{8,255}$/;
const COMPILE_OPTION_KEYS = new Set(["telemetryFieldRegistry"]); const COMPILE_OPTION_KEYS = new Set(["telemetryFieldRegistry"]);
const RECEIPT_OPTION_KEYS = new Set(["graphRevision", "graphDigest", "materializedStepIds"]); const RECEIPT_OPTION_KEYS = new Set(["graphRevision", "graphDigest", "materializedStepIds"]);
const STEP_RUNTIME_KINDS = Object.freeze({ const STEP_RUNTIME_KINDS = Object.freeze({
@@ -478,7 +479,7 @@ function validateConnectionInstance(providerPackage, value) {
if (typeof value.connectionId !== "string" || !IDENTIFIER.test(value.connectionId)) { if (typeof value.connectionId !== "string" || !IDENTIFIER.test(value.connectionId)) {
throw new Error("l2_execution_plan_connection_id_invalid"); throw new Error("l2_execution_plan_connection_id_invalid");
} }
if (!CREDENTIAL_REF.test(String(value.credentialRefs?.provider?.reference || "")) if (!isNdcCredentialReferenceValue(value.credentialRefs?.provider?.reference)
|| value.credentialRefs?.provider?.owner !== "ndc_l2_credentials") { || value.credentialRefs?.provider?.owner !== "ndc_l2_credentials") {
throw new Error("l2_execution_plan_provider_credential_ref_invalid"); throw new Error("l2_execution_plan_provider_credential_ref_invalid");
} }
@@ -4,6 +4,7 @@ export const L2_CONNECTION_INSTANCE_SCHEMA_VERSION = "nodedc.l2-connection-insta
export const SEMANTIC_MAPPING_SCHEMA_VERSION = "nodedc.semantic-mapping/v1"; export const SEMANTIC_MAPPING_SCHEMA_VERSION = "nodedc.semantic-mapping/v1";
import { SECRET_LIKE_VALUE as SECRET_VALUE } from "./sensitive-field-policy.mjs"; import { SECRET_LIKE_VALUE as SECRET_VALUE } from "./sensitive-field-policy.mjs";
import { isNdcCredentialReferenceValue } from "./credential-reference.mjs";
import { isBoundedTelemetryReadings } from "./telemetry-readings.mjs"; import { isBoundedTelemetryReadings } from "./telemetry-readings.mjs";
const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/; const IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/;
@@ -1319,7 +1320,7 @@ function requiredString(value, path, errors) {
} }
function requiredOpaqueReference(value, path, errors) { function requiredOpaqueReference(value, path, errors) {
if (typeof value !== "string" || !/^ndc-credref:[A-Za-z0-9][A-Za-z0-9._:-]{7,240}$/.test(value)) errors.push(`${path}_invalid`); if (!isNdcCredentialReferenceValue(value)) errors.push(`${path}_invalid`);
} }
function validateProviderBaseUrl(value, path, errors) { function validateProviderBaseUrl(value, path, errors) {
@@ -2,7 +2,10 @@ import assert from "node:assert/strict";
import { import {
EXTERNAL_PROVIDER_CONTRACT_VERSION, EXTERNAL_PROVIDER_CONTRACT_VERSION,
FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION, FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION,
NDC_CREDENTIAL_REFERENCE_OWNER,
assertValid, assertValid,
isNdcCredentialReferenceValue,
normalizeNdcCredentialReference,
validateCollectionProfile, validateCollectionProfile,
validateConnectionProfile, validateConnectionProfile,
validateDataProduct, validateDataProduct,
@@ -19,6 +22,29 @@ assert.equal(validateConnectionProfile({
...geliosPositionsCurrentExample.connection, ...geliosPositionsCurrentExample.connection,
credentialRef: { ...geliosPositionsCurrentExample.connection.credentialRef, owner: "engine" }, credentialRef: { ...geliosPositionsCurrentExample.connection.credentialRef, owner: "engine" },
}).errors.includes("credentialRef.owner_must_be_ndc_l2_credentials"), true); }).errors.includes("credentialRef.owner_must_be_ndc_l2_credentials"), true);
assert.deepEqual(normalizeNdcCredentialReference({
owner: NDC_CREDENTIAL_REFERENCE_OWNER,
reference: "ndc-credref:provider-example-0001",
}), {
owner: "ndc_l2_credentials",
reference: "ndc-credref:provider-example-0001",
});
assert.equal(isNdcCredentialReferenceValue("ndc-credref:provider-example-0001"), true);
assert.equal(isNdcCredentialReferenceValue("secret://provider-example"), false);
assert.equal(validateConnectionProfile({
...geliosPositionsCurrentExample.connection,
credentialRef: {
owner: "ndc_l2_credentials",
reference: "provider-example-0001",
},
}).errors.includes("credentialRef.reference_must_be_canonical_ndc_ref"), true);
assert.throws(
() => normalizeNdcCredentialReference({
owner: "device_core",
reference: "ndc-credref:provider-example-0001",
}),
/ndc_credential_reference_owner_invalid/,
);
assert.equal(validateCollectionProfile(geliosPositionsCurrentExample.collectionProfile).ok, true); assert.equal(validateCollectionProfile(geliosPositionsCurrentExample.collectionProfile).ok, true);
assert.equal(validateDataProduct(geliosPositionsCurrentExample.dataProduct).ok, true); assert.equal(validateDataProduct(geliosPositionsCurrentExample.dataProduct).ok, true);
assert.equal(validateFoundryBinding(geliosPositionsCurrentExample.foundryBinding).ok, true); assert.equal(validateFoundryBinding(geliosPositionsCurrentExample.foundryBinding).ok, true);