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