From bf0bc50abbf062915ba74dc14e4b8a99f52467d5 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 10 Aug 2026 16:40:48 +0300 Subject: [PATCH] feat(device-plane): accept core edge transport ADR --- .../device-edge-core-channel-v1.json | 114 ++ .../device-edge-vps-backhaul-v1.json | 22 + .../device-edge-vps-foundation-v1.json | 22 + .../deployment/device-edge-vps-relay-v1.json | 27 + ...vice-plane-backhaul-vps-enrollment-v1.json | 28 + ...superseded-vps-initiated-transport-v1.json | 33 + .../tailscale-device-edge-policy.hujson | 55 + .../ADR_0001_CORE_INITIATED_EDGE_CHANNEL.md | 367 +++++ .../DEVICE_EDGE_B2_PUBLIC_PILOT_RUNBOOK.md | 8 + ..._MANAGER_DIRECT_B2_VPS_AUDIT_2026-08-06.md | 898 +++++++++++++ device-plane/docs/IMPLEMENTATION_BASELINE.md | 10 + ...B_B2_VPS_CONFIGURATION_BIBLE_2026-08-06.md | 539 ++++++++ device-plane/docs/TEST_MATRIX.md | 8 + .../test/core-edge-channel-contract.test.mjs | 98 ++ device-plane/vps/config/00-nodedc-b2-vps.conf | 16 + device-plane/vps/config/backhaul_ssh_config | 23 + .../vps/config/nftables-foundation.conf | 22 + device-plane/vps/config/nftables-relay.conf | 24 + .../vps/systemd/nodedc-b2-backhaul.service | 33 + .../vps/systemd/nodedc-b2-relay.service | 48 + .../vps/systemd/nodedc-b2-tailscaled.service | 36 + .../build-device-edge-vps-artifact.mjs | 267 ++++ ...plane-backhaul-vps-enrollment-artifact.mjs | 160 +++ infra/deploy-runner/nodedc-b2-vps-deploy | 1181 +++++++++++++++++ infra/deploy-runner/nodedc-deploy | 436 +++++- .../test_device_edge_vps_artifact.py | 349 +++++ ..._plane_backhaul_vps_enrollment_artifact.py | 236 ++++ 27 files changed, 5034 insertions(+), 26 deletions(-) create mode 100644 device-plane/deployment/device-edge-core-channel-v1.json create mode 100644 device-plane/deployment/device-edge-vps-backhaul-v1.json create mode 100644 device-plane/deployment/device-edge-vps-foundation-v1.json create mode 100644 device-plane/deployment/device-edge-vps-relay-v1.json create mode 100644 device-plane/deployment/device-plane-backhaul-vps-enrollment-v1.json create mode 100644 device-plane/deployment/superseded-vps-initiated-transport-v1.json create mode 100644 device-plane/deployment/tailscale-device-edge-policy.hujson create mode 100644 device-plane/docs/ADR_0001_CORE_INITIATED_EDGE_CHANNEL.md create mode 100644 device-plane/docs/DEVICE_MANAGER_DIRECT_B2_VPS_AUDIT_2026-08-06.md create mode 100644 device-plane/docs/ROBOT2B_B2_VPS_CONFIGURATION_BIBLE_2026-08-06.md create mode 100644 device-plane/packages/device-protocol-contract/test/core-edge-channel-contract.test.mjs create mode 100644 device-plane/vps/config/00-nodedc-b2-vps.conf create mode 100644 device-plane/vps/config/backhaul_ssh_config create mode 100644 device-plane/vps/config/nftables-foundation.conf create mode 100644 device-plane/vps/config/nftables-relay.conf create mode 100644 device-plane/vps/systemd/nodedc-b2-backhaul.service create mode 100644 device-plane/vps/systemd/nodedc-b2-relay.service create mode 100644 device-plane/vps/systemd/nodedc-b2-tailscaled.service create mode 100644 infra/deploy-runner/build-device-edge-vps-artifact.mjs create mode 100644 infra/deploy-runner/build-device-plane-backhaul-vps-enrollment-artifact.mjs create mode 100755 infra/deploy-runner/nodedc-b2-vps-deploy create mode 100644 infra/deploy-runner/test_device_edge_vps_artifact.py create mode 100644 infra/deploy-runner/test_device_plane_backhaul_vps_enrollment_artifact.py diff --git a/device-plane/deployment/device-edge-core-channel-v1.json b/device-plane/deployment/device-edge-core-channel-v1.json new file mode 100644 index 0000000..e8fef6c --- /dev/null +++ b/device-plane/deployment/device-edge-core-channel-v1.json @@ -0,0 +1,114 @@ +{ + "schemaVersion": "nodedc.device-edge.core-channel.v1", + "status": "accepted-design", + "authority": "DCPLATFORM-76/ADR-0001", + "direction": "device-gateway-core-initiated", + "transport": { + "protocol": "http2-bidirectional-stream", + "tls": "TLSv1.3-mutual-authentication", + "edgeListen": "0.0.0.0:8443", + "endpointSource": "device-control-core.edge-registration", + "browserAccess": "forbidden", + "bearerOnlyAuthentication": "forbidden", + "genericTcpForwarding": "forbidden" + }, + "identity": { + "corePrivateKeyLocation": "synology-canonical-secret-boundary", + "edgePrivateKeyLocation": "edge-runner-managed-trust-boundary", + "privateKeysInArtifacts": false, + "certificateRotation": "generation-bound-audited", + "unknownOrRevokedEdge": "reject" + }, + "networkBoundary": { + "synologyPublicIngress": false, + "synologyPortForward": false, + "vpsInitiatedSynologyConnection": false, + "subnetRoutes": false, + "exitNode": false, + "tailscaleSsh": false, + "dockerSocket": false, + "allowedEdgeListeners": [ + "management-ssh", + "raw-device-tcp/9921", + "core-channel-mtls/8443" + ] + }, + "messageContract": { + "versioned": true, + "bounded": true, + "requiredKeys": [ + "schemaVersion", + "edgeRegistrationId", + "channelGeneration", + "trackerSessionId", + "adapterProfileRef", + "sequence", + "eventAt", + "receivedAt", + "messageKind", + "correlationId" + ], + "unknownKind": "close-logical-session", + "rawArbitraryDestination": "forbidden" + }, + "acknowledgement": { + "trackerPackageAck": "only-after-bounded-core-acceptance", + "coreUnavailable": "do-not-acknowledge-tracker-package", + "deduplicationKey": [ + "edgeRegistrationId", + "channelGeneration", + "trackerSessionId", + "packageNumber", + "contentDigest" + ], + "deliverySemantics": "at-least-once" + }, + "pilotLimits": { + "maxTrackerSessions": 128, + "maxSessionsPerObservedSource": 16, + "maxNewConnectionsPerMinutePerObservedSource": 60, + "maxBufferedBytesPerTrackerSession": 262144, + "maxAggregateBufferedBytes": 33554432, + "maxEnvelopePayloadBytes": 1048576, + "keepaliveSeconds": 15, + "deadPeerSeconds": 45, + "reconnectMinimumSeconds": 1, + "reconnectMaximumSeconds": 30, + "durableEdgeSpool": false + }, + "pilotSlo": { + "trackerAckBeforeDurableCoreAcceptance": 0, + "lossOfCoreAcceptedPackages": 0, + "edgeReceiveToCoreAcceptanceP95Milliseconds": 2000, + "edgeReceiveToCoreAcceptanceP99Milliseconds": 5000, + "channelReestablishmentP95Seconds": 60, + "channelReestablishmentHardCeilingSeconds": 120, + "deadCoreDetectionHardCeilingSeconds": 45, + "malformedOrUnauthenticatedAcceptedRecords": 0, + "availabilityCommitment": "deferred-until-measured" + }, + "commandBoundary": { + "typedOnly": true, + "rawPayload": "forbidden", + "durableEdgeQueue": false, + "sentEqualsSuccess": false, + "protocolAckMeans": "acknowledged-not-verified", + "unsafeAutomaticRetry": "forbidden" + }, + "preserved": [ + "device-control-core-database", + "device-gateway-core", + "hub-authentik", + "engine", + "foundry-runtime", + "gelios-production-path" + ], + "rollout": [ + "source-and-ops-contract", + "closed-port-synthetic-core-channel", + "negative-network-and-identity-acceptance", + "separate-public-device-ingress-transition", + "one-device-pilot" + ], + "rollback": "restore-closed-port-predecessor-without-vps-initiated-backhaul" +} diff --git a/device-plane/deployment/device-edge-vps-backhaul-v1.json b/device-plane/deployment/device-edge-vps-backhaul-v1.json new file mode 100644 index 0000000..e2c2969 --- /dev/null +++ b/device-plane/deployment/device-edge-vps-backhaul-v1.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": "nodedc.device-edge-vps.backhaul.v1", + "mode": "tailscale-userspace-key-only-ssh-local-forward", + "runtimeHost": "koffyvngij", + "component": "device-edge-vps", + "tailscaleNodeName": "nodedc-b2-vps", + "tailnetDnsSuffix": "tail8d32ac.ts.net", + "targetHost": "100.109.216.21", + "targetPort": 2222, + "targetHostKeyFingerprint": "SHA256:QERJ5CIUXRj0nLChGT6HMtoX+WTaeaEY5ZgaWqT8d30", + "targetUser": "device-backhaul", + "runtimeUser": "nodedc-backhaul", + "credentialBoundary": "private-key-readable-only-by-nodedc-backhaul", + "permitOpen": "127.0.0.1:9921", + "localForward": "127.0.0.1:19921", + "proxy": "tailscale-userspace-socks5-127.0.0.1:1055", + "keyIdentity": "nodedc-device-edge-vps-backhaul", + "publicB2Ingress": "disabled", + "commandTransport": "disabled", + "gelios": "untouched", + "rollback": "remove-backhaul-unit-and-user-restore-foundation-key-ownership" +} diff --git a/device-plane/deployment/device-edge-vps-foundation-v1.json b/device-plane/deployment/device-edge-vps-foundation-v1.json new file mode 100644 index 0000000..b4ce848 --- /dev/null +++ b/device-plane/deployment/device-edge-vps-foundation-v1.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": "nodedc.device-edge-vps.foundation.v1", + "mode": "static-runtime-key-only-ssh-default-deny-no-public-b2", + "runtimeHost": "koffyvngij", + "publicIpv4": "155.212.211.15", + "component": "device-edge-vps", + "nodeVersion": "22.23.2", + "nodeArchiveSha256": "d60acfe00a2932254bb0ad20e01b0d74397a0875595de719654b214f4b03f307", + "tailscaleVersion": "1.102.2", + "tailscaleArchiveSha256": "ad2cde12f8de95f7b93a1e0401e652291c603d42b9d60a33fb1741eb38ab04d8", + "serviceUser": "nodedc-edge", + "managementSsh": "root-key-only", + "managementKeyFingerprint": "SHA256:DYYy1E3DaxIQGC0jnsW6SP7gXdBHUy3A1zn4pvgVUEw", + "serverHostKeyFingerprint": "SHA256:mhqNn2S6zstkYL7VFdvt3SYHv1nLjB4J7/s57RrKG6w", + "firewall": "default-deny-public-22-only", + "tailscale": "userspace-needs-external-enrollment", + "backhaulKey": "runner-managed-new-ed25519", + "publicB2Ingress": "disabled", + "commandTransport": "disabled", + "gelios": "untouched", + "rollback": "restore-exact-ssh-firewall-service-and-absent-runtime-predecessor" +} diff --git a/device-plane/deployment/device-edge-vps-relay-v1.json b/device-plane/deployment/device-edge-vps-relay-v1.json new file mode 100644 index 0000000..c382c0f --- /dev/null +++ b/device-plane/deployment/device-edge-vps-relay-v1.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": "nodedc.device-edge-vps.relay.v1", + "mode": "public-b2-opaque-bounded-relay", + "runtimeHost": "koffyvngij", + "publicIpv4": "155.212.211.15", + "component": "device-edge-vps", + "runtimeUser": "nodedc-relay", + "credentialAccess": "none", + "listen": "0.0.0.0:9921", + "health": "127.0.0.1:18221", + "privateUpstream": "127.0.0.1:19921", + "sourceAdmission": "public-ipv4-only", + "maxSessions": 128, + "maxSessionsPerAddress": 16, + "maxConnectionsPerMinutePerAddress": 60, + "maxTrackedSourceAddresses": 4096, + "maxBytesPerDirection": 67108864, + "sessionTimeoutMs": 300000, + "protocolInspection": "gateway-owned", + "identityTrust": "claimed-not-ownership-proof", + "discoveryLifecycle": "quarantine", + "commandTransport": "disabled", + "gelios": "untouched", + "dns": "unchanged", + "b2Routes": "unchanged", + "rollback": "close-9921-stop-relay-remove-user-and-restore-accepted-backhaul" +} diff --git a/device-plane/deployment/device-plane-backhaul-vps-enrollment-v1.json b/device-plane/deployment/device-plane-backhaul-vps-enrollment-v1.json new file mode 100644 index 0000000..44c0670 --- /dev/null +++ b/device-plane/deployment/device-plane-backhaul-vps-enrollment-v1.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": "nodedc.device-plane.backhaul-vps-enrollment.v1", + "mode": "rotate-backhaul-client-mini-to-vps", + "predecessorPatchId": "device-plane-backhaul-target-tailnet-serve-20260804-002", + "predecessorArtifactSha256": "219408705dd4d80a962ed00eeb53a69df0b9ab6458443734d5c9cd1d1f795eba", + "sourceAction": "publish-vps-enrollment-marker-only", + "runtimeAction": "rotate-authorized-key-and-recreate-backhaul-target", + "selectedServices": [ + "device-backhaul-target" + ], + "preservedServices": [ + "device-control-core", + "device-gateway", + "device-postgres" + ], + "previousEnrollment": "device-edge-backhaul.pub", + "nextEnrollment": "device-edge-vps-backhaul.pub", + "nextKeyFingerprint": "SHA256:HHTiDYiCRxSiKjBLCip6JMSzGfLGrDz5g8SIkosJcVw", + "permittedTarget": "127.0.0.1:9921", + "tailnetAddress": "100.109.216.21", + "dockerPortPublication": "disabled", + "routerNatFirewall": "unchanged", + "edgePublicIngress": "disabled", + "funnel": "disabled", + "commandTransport": "disabled", + "gelios": "untouched", + "rollback": "restore-previous-authorized-key-and-recreate-target" +} diff --git a/device-plane/deployment/superseded-vps-initiated-transport-v1.json b/device-plane/deployment/superseded-vps-initiated-transport-v1.json new file mode 100644 index 0000000..e52cb41 --- /dev/null +++ b/device-plane/deployment/superseded-vps-initiated-transport-v1.json @@ -0,0 +1,33 @@ +{ + "schemaVersion": "nodedc.device-edge.superseded-transport.v1", + "status": "frozen", + "frozenAt": "2026-08-10", + "authority": "DCPLATFORM-76/ADR-0001", + "reason": "The public VPS must not initiate a private connection to Synology.", + "successor": "nodedc.device-edge.core-channel.v1", + "forbiddenForNewPlanOrApply": [ + "nodedc.device-edge-vps.backhaul.v1", + "nodedc.device-edge-vps.relay.v1:privateUpstream=127.0.0.1:19921", + "nodedc.device-plane.backhaul-vps-enrollment.v1", + "tailscale-userspace-key-only-ssh-local-forward", + "rotate-backhaul-client-mini-to-vps" + ], + "historicalSource": [ + "deployment/device-edge-vps-backhaul-v1.json", + "deployment/device-edge-vps-relay-v1.json", + "deployment/device-plane-backhaul-vps-enrollment-v1.json", + "deployment/tailscale-device-edge-policy.hujson", + "vps/config/backhaul_ssh_config", + "vps/systemd/nodedc-b2-backhaul.service", + "vps/systemd/nodedc-b2-relay.service", + "infra/deploy-runner/build-device-edge-vps-artifact.mjs:backhaul|relay", + "infra/deploy-runner/build-device-plane-backhaul-vps-enrollment-artifact.mjs", + "infra/deploy-runner/nodedc-b2-vps-deploy:backhaul|relay" + ], + "testOnlyReconstruction": { + "environment": "NODEDC_ALLOW_SUPERSEDED_TRANSPORT", + "value": "test-only", + "deployCandidate": false + }, + "runtimeMutationInPhase0": false +} diff --git a/device-plane/deployment/tailscale-device-edge-policy.hujson b/device-plane/deployment/tailscale-device-edge-policy.hujson new file mode 100644 index 0000000..6d8dba3 --- /dev/null +++ b/device-plane/deployment/tailscale-device-edge-policy.hujson @@ -0,0 +1,55 @@ +// NODE.DC tailnet policy: user devices retain self-access while the public +// Robot2B Device Edge VPS receives one purpose-bound egress grant. +{ + "tagOwners": { + "tag:device-edge-vps": ["autogroup:admin"], + }, + + "hosts": { + "device-plane-backhaul": "100.109.216.21", + "nodedc-admin-macbook": "100.114.248.4", + "nodedc-device-edge": "100.64.19.31", + }, + + "grants": [ + // Preserve unrestricted connectivity only between devices owned by the + // same authenticated tailnet member. Tagged service nodes are excluded. + { + "src": ["autogroup:member"], + "dst": ["autogroup:self"], + "ip": ["*"], + }, + + // The public VPS can reach exactly the private SSH forwarding target. + { + "src": ["tag:device-edge-vps"], + "dst": ["device-plane-backhaul"], + "ip": ["tcp:2222"], + }, + ], + + // Preserve the existing Tailscale SSH policy for user-owned devices. + "ssh": [ + { + "action": "check", + "src": ["autogroup:member"], + "dst": ["autogroup:self"], + "users": ["autogroup:nonroot", "root"], + }, + ], + + // These assertions are evaluated by Tailscale before every policy save. + "tests": [ + { + "src": "tag:device-edge-vps", + "proto": "tcp", + "accept": ["device-plane-backhaul:2222"], + "deny": [ + "device-plane-backhaul:22", + "device-plane-backhaul:5001", + "nodedc-admin-macbook:22", + "nodedc-device-edge:22", + ], + }, + ], +} diff --git a/device-plane/docs/ADR_0001_CORE_INITIATED_EDGE_CHANNEL.md b/device-plane/docs/ADR_0001_CORE_INITIATED_EDGE_CHANNEL.md new file mode 100644 index 0000000..0da8113 --- /dev/null +++ b/device-plane/docs/ADR_0001_CORE_INITIATED_EDGE_CHANNEL.md @@ -0,0 +1,367 @@ +# ADR 0001: Core-initiated Device Gateway Edge channel + +Status: accepted for implementation on 2026-08-10. + +Scope: NODE.DC Device Core / Device Gateway / public Device Edge VPS. + +Authority: NDC PLATFORM `DCPLATFORM-76`, with deploy implementation governed by +`DCPLATFORM-21`. + +## Decision + +The NODE.DC Core side initiates and owns the only private control/data channel +to a public Device Gateway Edge. The Edge never initiates a network connection +to Synology, DSM, Docker, Hub, Engine, Foundry, PostgreSQL, a Mac workstation or +another LAN service. + +The target transport is TLS 1.3 mutual authentication over one long-lived +HTTP/2 bidirectional session: + +```text +ARUSNAVI B2 + -> raw TCP/9921 + -> Device Gateway Edge on the VPS + -> accepted Core-owned mTLS session on TCP/8443 + -> Device Gateway Core on Synology + -> Device Control Core +``` + +The Core dials an endpoint selected from an approved Edge registration. The +endpoint is not hardcoded into an adapter, device record, Foundry application, +Engine workflow or deployment artifact. + +`device.nodedc.ru` remains the HTTPS user surface for Device Core. It is not the +raw B2 endpoint and is not moved to the VPS for this transport. + +## Why this direction is mandatory + +The VPS is a public, replaceable and partially untrusted edge host. A VPS owner +or a compromised root account must not gain a route into the private NODE.DC +network. An outbound Core connection works through NAT without a Synology port +forward and gives the Core a single explicit peer and protocol to validate. + +The previous design used a tagged userspace Tailscale process on the VPS, +SOCKS5, SSH local forwarding and a Synology backhaul target. Although that +design had narrow ACLs, the trust direction was still VPS-initiated and it kept +an avoidable private-network membership on the public host. It is superseded. + +## Component ownership + +Device Gateway Edge owns only: + +- public tracker TCP sessions; +- allowlisted adapter framing and protocol timing; +- the minimum HEADER/PACKAGE acknowledgement state; +- bounded in-memory buffers and flow-control counters; +- the server side of the mutually authenticated Core channel; +- typed command delivery to an already connected tracker session. + +Device Gateway Core and Device Control Core own: + +- Edge registration and certificate identity; +- owner scopes, Device Projects and project access; +- enrollment, quarantine, claim and transfer policy; +- restricted identifiers and credential references; +- complete telemetry decoding/normalization and data classification; +- command policy, confirmation, ledger and audit; +- Engine/Data Product and Foundry bindings. + +The Edge does not contain a business database, durable telemetry store, Hub or +Authentik credentials, Engine/Foundry tokens, PostgreSQL credentials or the +Core client private key. + +## Adapter split + +One versioned adapter package exposes explicit role-scoped interfaces: + +- `edge-session`: framing, bounded validation, HEADER/PACKAGE ACK and typed + command wire encoding required by the active socket; +- `core-decoder`: complete tag decoding, safe observation normalization and + model/firmware capability mapping; +- `command-contract`: typed commands and acknowledgement semantics shared by + policy and delivery code. + +The Edge artifact contains only the allowlisted `edge-session` and required +wire command implementation. It does not contain owner, workflow or Foundry +logic. The Core artifact may contain the full adapter package. A new device +model is added through the adapter registry; it does not create another Device +Manager application or a model-specific Synology service. + +## Authentication and key ownership + +- The Core has a unique client certificate and private key stored only in the + canonical Synology secret/trust boundary. +- The Edge has a unique server certificate and private key stored only in the + Edge runtime trust boundary. +- Both certificates chain to the dedicated Device Edge private CA or an + equivalent separately approved workload-identity issuer. +- The Core verifies the Edge registration id, certificate identity, CA, + validity and configured endpoint. +- The Edge accepts only an approved Core workload identity and never accepts a + browser, bearer-token-only or anonymous channel. +- Certificates have bounded lifetime, explicit generation and audited rotation. +- No private key, enrollment token or certificate bundle is carried in Git, + Ops, MCP payloads or ordinary deploy artifacts. + +TLS terminates on the Edge process. Root compromise of the VPS can therefore +read tracker traffic and impersonate that Edge identity until it is revoked. +It cannot obtain the Core private key or open a new connection into Synology. + +## Session protocol + +The application protocol is versioned and fail-closed. Every envelope has: + +- schema version; +- Edge registration id; +- channel generation; +- tracker session id; +- adapter/profile reference and version; +- monotonically increasing direction-local sequence; +- event timestamp and receive timestamp; +- bounded payload length; +- message kind and correlation id. + +Allowed Edge-to-Core messages are limited to channel hello/health, tracker +session opened/closed, discovery evidence, bounded verified frame, delivery +acknowledgement and bounded counters. Allowed Core-to-Edge messages are limited +to channel acceptance, flow-control window, session disposition and typed +command delivery. + +Unknown schema versions, message kinds, adapters, Edge ids or oversized frames +close the logical session and create a safe audit event. They never fall back +to arbitrary TCP forwarding. + +## Tracker acknowledgement rule + +The VPS has no durable business store. It must not acknowledge a valid tracker +PACKAGE merely because bytes reached the VPS. + +The Edge sends the verified frame to Core and waits for a bounded Core +acceptance acknowledgement. Only then may the Edge send the protocol PACKAGE +acknowledgement to the tracker. If the Core channel is unavailable or the +acceptance deadline expires, the Edge does not acknowledge the PACKAGE; the +tracker remains responsible for its documented retry behavior. + +HEADER acknowledgement follows the same ownership boundary: discovery must be +accepted into Core quarantine or matched to an admitted device session before +the Edge completes the handshake. An unavailable Core means no admitted +tracker session. + +This gives at-least-once delivery without a VPS database. Core deduplicates by +Edge generation, tracker session, package number and content digest. + +## Realtime and flow control + +The Core does not poll the VPS for batches. Telemetry returns immediately over +the established channel. The initial implementation contract is: + +- one Core channel per Edge generation; +- maximum 128 concurrent tracker sessions on the pilot VPS; +- maximum 16 sessions per observed source address; +- maximum 60 new tracker connections per minute per observed source; +- maximum 256 KiB buffered per tracker session; +- maximum 32 MiB aggregate tracker/channel buffering; +- maximum 1 MiB for one protocol frame before adapter-specific lower limits; +- 15-second keepalive and 45-second dead-peer deadline; +- reconnect with jittered exponential delay from 1 to 30 seconds; +- no unbounded disk spool; +- explicit accepted, duplicate, late, dropped, rejected and throttled counters. + +These are pilot ceilings, not a claim that one 961 MiB VPS supports production +scale. Load gates at 1, 100 and 1000 synthetic sessions determine the production +Edge topology. + +## Pilot service objectives + +These are acceptance objectives for the controlled pilot, not a published +production SLA: + +- zero tracker PACKAGE acknowledgements before durable Core acceptance in all + normal, timeout, disconnect and Core-restart tests; +- zero loss of Core-accepted packages in the acceptance run; duplicate delivery + is permitted on reconnect but must collapse to one normalized observation; +- Edge receive to Core acceptance latency at 128 concurrent synthetic sessions: + p95 at or below 2 seconds and p99 at or below 5 seconds; +- after both peers and the network are healthy, channel re-establishment: p95 at + or below 60 seconds and hard acceptance ceiling of 120 seconds; +- dead Core detection at the Edge no later than 45 seconds after the last valid + channel activity; +- memory stays inside the configured 32 MiB aggregate application buffer plus + the separately measured fixed runtime baseline; exceeding a bound throttles + or closes the offender and never expands the limit; +- malformed, unknown, unauthenticated and revoked-identity inputs produce zero + accepted telemetry records and zero command deliveries; +- a typed command for a currently connected test tracker reaches Edge wire + delivery or a conclusive rejection in p95 at or below 2 seconds; verified + device execution is a separate adapter-defined objective. + +Availability percentage and the 1000-session production capacity are explicitly +uncommitted until measured on the target host and recorded by a later transition. + +## Commands + +Commands use the same Core-owned channel and the existing tracker TCP session. +The VPS never exposes a command HTTP API and never accepts an arbitrary raw +payload. + +Core sends a typed command containing exact device/session, adapter/profile, +command catalog version, parameters, idempotency key, expiry and correlation +id. Edge either rejects it before wire delivery or returns delivery evidence. +Protocol acknowledgement is `acknowledged`, not `verified`. Verification needs +a subsequent device observation or explicit readback defined by the adapter. + +No command is queued durably on the VPS. Channel loss before a conclusive +outcome yields `unknown` or `failed` according to the command contract. Unsafe +automatic retry is forbidden. + +## Network boundary + +The target Edge exposes only: + +- management SSH according to the separately accepted management policy; +- raw tracker ingress TCP/9921; +- Core channel TCP/8443 with mandatory mTLS. + +Synology exposes no public device or backhaul port. The Edge receives no subnet +route, exit-node capability, Tailscale SSH, Docker socket or generic proxy. The +Core channel cannot request an arbitrary destination or port. + +Firewall policy is default deny. Raw ingress and Core channel have independent +connection/rate/resource limits. DDoS and malformed traffic terminate at the +provider/VPS and cannot be forwarded as a generic byte stream to Synology. + +## Threat model + +Protected assets are the Synology/LAN network, Device Core data and credentials, +Hub/Authentik identities, Engine and Foundry capabilities, command authority and +the integrity of admitted telemetry. + +The design assumes any of the following can happen independently: VPS root is +compromised; the VPS owner makes an unsafe change; an Internet client floods or +sends malformed B2 traffic; an Edge certificate is copied; a tracker identifier +is spoofed; the Core channel is interrupted; a valid package is replayed; or a +command outcome becomes unknown during disconnect. + +The boundary mitigates lateral entry into the private platform, arbitrary TCP +proxying, anonymous/bearer-only channel access, unbounded memory growth, replay +as a second normalized observation, command injection through a raw API, secret +distribution to the VPS and acknowledgement of telemetry that only reached +volatile Edge memory. + +Residual risks are explicit: compromised VPS root can read, drop, delay or forge +traffic attributed to that Edge until revocation, attack trackers connected to +it and exhaust the VPS or its uplink. Provider-scale DDoS protection, tracker +firmware trust and physical tracker compromise are outside this component. None +of those residual risks grants an inbound route or credential to Synology. + +## Tailscale decision + +Direct mTLS is the accepted target. Tailscale is not required for the product +channel. + +The current VPS userspace Tailscale foundation is a live predecessor and is not +removed in Phase 0. A later canonical transition removes it after the mTLS +channel is accepted. If direct mTLS proves impossible for an externally +evidenced reason, a new ADR may admit Tailscale only with all of the following: + +- Core still initiates the application session; +- tagged service identity; +- no user ownership, subnet routes, DNS, exit node or Tailscale SSH; +- an ACL to one exact Edge application endpoint only; +- negative tests for DSM 22/5001, Docker, MacBook and LAN; +- no SSH LocalForward or generic SOCKS backhaul. + +The old VPS-to-Synology SSH local-forward is not an allowed fallback. + +## Superseded source + +The following source is historical/recovery evidence and must not be used for a +new plan or apply: + +- `deployment/device-edge-vps-backhaul-v1.json`; +- `deployment/device-edge-vps-relay-v1.json` when it forwards to port 19921; +- `deployment/device-plane-backhaul-vps-enrollment-v1.json`; +- `deployment/tailscale-device-edge-policy.hujson` for the old SSH target; +- `vps/config/backhaul_ssh_config`; +- `vps/systemd/nodedc-b2-backhaul.service`; +- `vps/systemd/nodedc-b2-relay.service` with the old upstream; +- `infra/deploy-runner/build-device-plane-backhaul-vps-enrollment-artifact.mjs`; +- `infra/deploy-runner/nodedc-b2-vps-deploy` backhaul/relay phases. + +Builders fail closed by default. A test-only environment switch may reconstruct +historical artifacts for deterministic regression tests, but artifacts built in +that mode are not deploy candidates. Both reviewed runner sources reject the +superseded VPS phases and Synology enrollment even if such an archive exists. + +## Deployment boundaries + +The successor is a new additive Edge transport generation, not a weakened edit +of the old phase. Before an application artifact exists, DCPLATFORM-21 must +define: + +- component and transition identity; +- exact payload allowlist/denylist; +- fixed Edge roots, systemd units and trust roots; +- runtime-secret ownership and rotation; +- predecessor checks for the current Tailscale foundation; +- services stopped/started and preserved state; +- health and negative network acceptance inside apply; +- automatic rollback to the current closed-port predecessor. + +The first successor apply must leave TCP/9921 closed. It accepts only the mTLS +Core channel in a no-device/synthetic mode. Public tracker ingress is a separate +later transition after Core-channel acceptance. + +## Acceptance gates + +Phase 0 is accepted when: + +- this ADR and a machine-readable contract are present; +- the old builders fail closed by default; +- the reviewed VPS runner source rejects old backhaul/relay phases; +- regression tests prove the freeze and the new direction; +- Ops records exact SLO, threat and rollback boundaries; +- no runtime, DNS, route, port or tracker setting changed. + +The future Core-channel slice is accepted only when: + +- Synology has no new public listener or router mapping; +- a Core client without the exact identity cannot connect; +- an Edge with an unknown/revoked identity is rejected by Core; +- the Edge cannot reach DSM, Docker, Hub, Engine, Foundry, PostgreSQL, MacBook + or LAN targets; +- unknown/oversized/replayed envelopes fail closed; +- Core loss causes no tracker PACKAGE acknowledgement after the deadline; +- reconnect and deduplication preserve at-least-once behavior; +- secrets and unrestricted identifiers are absent from artifacts, logs, Ops, + metrics and MCP; +- rollback stops the successor channel, restores the closed-port predecessor + and preserves Device Plane DB, Gelios, Foundry and Engine. + +## Rollback + +Phase 0 changes only source and Ops; rollback is a source revert with no runtime +effect. + +The future transport apply owns an automatic rollback partition containing the +new Edge/Core channel units, configs, certificate references, firewall entries +and source publication. Rollback must: + +- stop and disable only the candidate channel units; +- remove only candidate firewall rules/listeners; +- restore the exact accepted predecessor files and unit states; +- keep public B2/9921 closed unless it was already an accepted predecessor; +- preserve Device Core/PostgreSQL, Gateway Core, Hub, Engine, Foundry and + Gelios; +- preserve audit evidence and record the failed generation; +- never restore or invent the superseded VPS-initiated SSH backhaul. + +## Consequences + +The Edge contains protocol-session code and can be replaced independently. A +VPS compromise can disrupt or falsify its tracker observations and can attack +connected trackers, but it does not become a path into the private platform. + +The design deliberately accepts temporary telemetry unavailability when Core +is unreachable instead of acknowledging data into an untrusted, non-durable +VPS buffer. This is the correct failure mode for the stated trust boundary. diff --git a/device-plane/docs/DEVICE_EDGE_B2_PUBLIC_PILOT_RUNBOOK.md b/device-plane/docs/DEVICE_EDGE_B2_PUBLIC_PILOT_RUNBOOK.md index 8f0df8d..f932980 100644 --- a/device-plane/docs/DEVICE_EDGE_B2_PUBLIC_PILOT_RUNBOOK.md +++ b/device-plane/docs/DEVICE_EDGE_B2_PUBLIC_PILOT_RUNBOOK.md @@ -1,5 +1,13 @@ # Device Edge B2 public pilot runbook +> Frozen historical runbook — 2026-08-10 +> +> Do not configure these NAT rules, Mini relay, DNS changes or tracker route. +> This path is superseded by +> `docs/ADR_0001_CORE_INITIATED_EDGE_CHANNEL.md`. The public pilot will receive +> a new runbook only after the Core-initiated mTLS channel and VPS Edge pass +> their separate security/deploy acceptance. + Status: router/NAT is **not configured** by this document. It is a one-pilot, human-operated exposure gate for the already accepted Mini relay. It never changes Synology, Gelios, VPN, Device Plane command transport or device diff --git a/device-plane/docs/DEVICE_MANAGER_DIRECT_B2_VPS_AUDIT_2026-08-06.md b/device-plane/docs/DEVICE_MANAGER_DIRECT_B2_VPS_AUDIT_2026-08-06.md new file mode 100644 index 0000000..0a0bb38 --- /dev/null +++ b/device-plane/docs/DEVICE_MANAGER_DIRECT_B2_VPS_AUDIT_2026-08-06.md @@ -0,0 +1,898 @@ +# NDC Device Manager / Direct ARUSNAVI B2 / VPS + +> Historical audit notice — 2026-08-10 +> +> Live evidence in this document remains useful, but the product and transport +> decisions are superseded by `DCPLATFORM-76` and +> `docs/ADR_0001_CORE_INITIATED_EDGE_CHANNEL.md`. Device Core is a standalone +> Hub application, not a Foundry Page; the Edge contains a bounded adapter +> session role; Synology/Core initiates the private channel to the VPS; the old +> VPS-to-Synology relay/backhaul must not be deployed. + +Актуализированный архитектурный аудит и план перехода на Direct-primary с Gelios read fallback. + +Дата фиксации: 6 августа 2026 года. + +## 1. Итоговое решение + +Целевая read-архитектура зафиксирована так: + +- ARUSNAVI B2 отправляет данные параллельно в два monitoring server slot; +- прямой route B2 → NODE.DC становится основным источником позиции и текущей телеметрии; +- Gelios остаётся постоянно работающим legacy/read-only кандидатом для fallback; +- при недоступности прямого потока переключение выполняется отдельно для каждого устройства, а не глобально по одному health endpoint; +- наружу публикуется один канонический факт на один трайк; два источника не пишут одновременно в одну current/history projection; +- identity трайка, существующие `sourceId`, joins Foundry и Timescale-инфраструктура сохраняются; +- команды через Gelios не используются; +- команды через Direct B2 не входят в текущий этап и остаются выключенными; +- Mac Mini окончательно исключён из production ingress; его артефакты остаются историческим прототипом; +- VPS выполняет только публичный L4 ingress и зашифрованный backhaul. На нём нет БД, Engine, EDP, Foundry, Device Control Core, decoder, ACK-логики или command transport. + +Важная терминологическая поправка: B2 приходит на VPS не HTTP-пакетами, а сырым TCP-потоком `INTERNAL`. HTTP/HTTPS может использоваться только внутри закрытого контура после Gateway/decoder. Шифрование начинается на плече VPS → private NODE.DC contour; первый hop B2 → VPS остаётся обычным raw TCP, если сам B2 не поддерживает иной транспорт. + +## 2. Что проверено live + +Этот документ опирается не только на Ops, но и на live MCP и текущий source: + +- Ops: проекты `NDC PLATFORM` и `ROBOT2B`, полные карточки и комментарии; +- Engine: granted L2 targets, графы, runtime, executions, credential binding status, output profiles и telemetry catalog; +- Foundry: application instance, Map page, bindings, profiles и server-owned consumer progress; +- Ontology: live catalog, entities, relations и guardrails; +- source: Device Plane, ARUSNAVI adapter, Gateway, Control Core, EDP writer/reader scope и deployment artifacts; +- DNS: текущий A-record; +- SSH: доступные локальные aliases и наличие однозначной VPS identity. + +Ops в этом аудите является журналом решений и статусов. Реализация считается подтверждённой только там, где она совпадает с live MCP, runtime или source. + +## 3. Что в исходном аудите устарело + +### 3.1 MCP уже работает + +Исходный текст говорил, что Ops, Engine, Ontology и Foundry MCP недоступны. На момент этой актуализации все четыре live boundary доступны и прочитаны. + +### 3.2 Канонический продукт — v5, не v3 + +Production Map получает: + +```text +fleet.positions.current.v5@5.0.0 +ontology.map.moving_object.v3 +delivery = snapshot+patch +history = latest observation / 60 s bucket +retention = 90 days +``` + +Определение продукта находится в `platform/services/external-data-plane/definitions/fleet.positions.current.v5.json`. + +### 3.3 Device Manager Page ещё не существует + +Live Page Library Foundry содержит только `map@0.1.0`. Канонического шаблона `Device Manager`, server-owned `device-plane-control` provider и соответствующего UI сейчас нет. + +Device Manager не блокирует Direct ingestion: текущий этап можно завершить через Device Plane, Engine/EDP и существующую Map. UI управления устройствами остаётся отдельной более поздней фазой. + +### 3.4 VPS проверен live по SSH + +После получения точной SSH identity выполнен read-only аудит `root@155.212.211.15`. Изменений на host не выполнялось. + +Проверенная identity: + +- hostname `koffyvngij`; +- KVM/QEMU; +- Ubuntu 24.04.4 LTS, kernel `6.8.0-137-generic`; +- SSH host key уже был pinned локально; ED25519 fingerprint `SHA256:mhqNn2S6zstkYL7VFdvt3SYHv1nLjB4J7/s57RrKG6w`. +- используемый client key имеет mode `0600` и fingerprint `SHA256:DYYy1E3DaxIQGC0jnsW6SP7gXdBHUy3A1zn4pvgVUEw`; +- в `/root/.ssh/authorized_keys` находятся два unrestricted key lines: этот Mac key и отдельный provider `beget-access-key`; их дальнейшая судьба должна быть explicit management-access policy, а не ручное удаление во время аудита. + +Проверенная ёмкость: + +- 1 vCPU; +- 961 MiB RAM, около 621 MiB available во время аудита; +- swap отсутствует; +- root filesystem 8.7 GiB, 2.3 GiB used, 6.4 GiB available; +- inode usage 11%; +- system clock synchronized, NTP active, timezone UTC; +- failed systemd units отсутствуют; +- journal занимает 9.9 MiB. + +Проверенная сеть/runtime: + +- `eth0` имеет public `155.212.211.15/32`, default route через `100.100.1.1` on-link; +- наружу слушает только SSH на `0.0.0.0:22` и `[::]:22`; `9921/TCP` не слушает; +- Docker, Podman, containerd, Tailscale, WireGuard и relay отсутствуют; +- Fail2Ban активен для `sshd`; unattended upgrades и time sync активны. + +Текущий security baseline не принят для production: + +- UFW inactive; +- nftables/iptables INPUT policy `ACCEPT`; единственное правило — Fail2Ban reject set для SSH; +- `PermitRootLogin yes`; +- `PasswordAuthentication yes`, root password установлен; +- `X11Forwarding yes`; +- `AllowTcpForwarding yes`, `PermitOpen any`, `DisableForwarding no`. + +Следовательно, VPS подходит по мощности для одного bounded relay и encrypted backhaul, но public `9921` нельзя включать до canonical firewall/SSH/runtime bootstrap и rollback acceptance. + +### 3.5 Домен сейчас указывает не на VPS + +`device.nodedc.ru` на 6 августа 2026 года резолвится в `95.165.91.235`. По `DCPLATFORM-34` это внешний адрес Synology/DSM, а не подтверждённый адрес нового VPS. + +`device.dc.ru`, упомянутый устно, A-record не вернул. Каноническое имя в архитектуре — `device.nodedc.ru`. + +До VPS acceptance и DNS cutover нельзя настраивать B2 на текущий `device.nodedc.ru:9921`: raw ingress на Synology запрещён. + +### 3.6 Полный IMEI уже существует в restricted Foundry contour + +Live Foundry получает `device_imei` в двух restricted bindings: + +- `fleet.units.contacts.current.v1`; +- `fleet.units.identity.current.v1`. + +Карточка объекта показывает полный IMEI как `restricted` text field из aspect `unit_identity`. Это не public/unrestricted поле, но это и не маскированное значение. + +Новая принятая политика: + +- IMEI не является паролем или secret; +- он может проходить внутри NODE.DC contour и использоваться для сопоставления устройства; +- он остаётся classified `restricted` identifier; +- его нельзя без необходимости писать в Ops, обычные логи, публичные payload, metrics labels или unrestricted MCP output; +- VPS видит IMEI только как часть непрозрачного TCP stream и не должен его извлекать или логировать; +- основной lookup в Device Plane может оставаться HMAC digest; полный IMEI разрешён в restricted projection там, где он действительно нужен. + +IMEI по-прежнему не является доказательством ownership. Он является идентификатором для lookup/crosswalk, а claim/tenant assignment остаются административным действием. + +## 4. Фактическая production read-цепочка + +```mermaid +flowchart LR + B2["ARUSNAVI B2"] --> GELIOS["Gelios monitoring route"] + GELIOS --> L2["Engine alxvw4fn\nGELIOS — REALTIME POSITIONS"] + L2 --> EDP["EDP / Timescale\nfleet.positions.current.v5"] + EDP --> FOUNDRY["Foundry\nRobot2B Trike Positions"] + + B2 -. "Direct route пока не подключён" .-> VPS["VPS Device Edge\nfoundation accepted"] + VPS -. "backhaul/relay gated" .-> GW["Synology Device Gateway"] + GW -. "PACKAGE сейчас ACK + discard" .-> STOP["Нет decoder / EDP publish"] +``` + +### 4.1 Live Engine + +Production owner позиции и телеметрии: + +- L1 workflow: `WCb62yGL8v`; +- target: `alxvw4fn`; +- name: `GELIOS — REALTIME POSITIONS`; +- revision: `68f4c852b8bc25b99c40b22fb4ca724fa6a0a34adbff74d1224b12ca7087bad8`; +- runtime workflow: `RupsyGGlawBzmUFm`; +- runtime active; +- schedule: каждые 10 секунд; +- рабочий request: `GET /api/v1/units` с `incltrip=true`, `inclcntrs=true`, `inclsnsrs=true`, `incllsv=true`; +- writer bindings и Gelios rotating credential зарегистрированы со status `ok`. + +Во время проверки executions завершались успешно примерно за 5–10 секунд. Execution `1297364` был `success` и дал структурный профиль 107 subjects. + +Normalized fact содержит: + +- `sourceId = gelios-unit-`; +- `semanticType = map.moving_object`; +- geometry Point; +- `display_name`; +- `position_source = gelios`; +- `signal_state`; +- `movement_state`; +- speed/course/elevation; +- satellite count, HDOP/accuracy при наличии; +- mileage, engine hours; +- `sensor_readings`. + +Live telemetry catalog execution `1297364` подтвердил 17 безопасных reading IDs без rejected/unsupported values: + +```text +sensor.param.call_btn +sensor.param.gps_mod +sensor.param.gsm +sensor.param.gsm_level +sensor.param.gsm_st +sensor.param.gyro +sensor.param.ign_virt +sensor.param.in_0 +sensor.param.in_1 +sensor.param.in0 +sensor.param.in1 +sensor.param.nav_st +sensor.param.pwr_ext +sensor.param.pwr_int +sensor.param.sim1_st +sensor.param.sim2_st +sensor.param.v_in +``` + +`in_0` и `in_1` имеют provider-configured label conflict; это уже отражено в telemetry catalog и не должно скрываться при Direct comparison. + +### 4.2 Инертный duplicate target + +Target `g7q86421` (`gelios.positions.current.realtime.v7`) не является вторым production writer. + +В его live graph соединены только: + +```text +manual trigger +→ monitoring config +→ units request +→ extraction +→ ontology map +``` + +Scheduled trigger и `NDC Data Product Publish • fleet.positions.current.v5` физически отсоединены. Schedule всё ещё создаёт короткие успешные пустые executions примерно раз в две минуты, но публикации не выполняются. + +Это подтверждает текущий правильный инвариант: у `fleet.positions.current.v5` один активный producer path. + +### 4.3 Engine platform debt + +При будущей переработке L2 нельзя игнорировать открытые карточки: + +- `DCPLATFORM-72`: UI `node.parameters` и executable `data.n8n.parameters` могут расходиться; +- `DCPLATFORM-73`: managed writer не всегда корректно перепривязывается к новой graph revision без revoke/recreate. + +Любое изменение arbiter graph требует post-write graph equality, deep validation, свежего execution и exact writer acceptance. Успешный patch preview сам по себе недостаточен. + +## 5. Live Foundry + +Application: + +- ID: `1c7dcdbb-6e50-4272-b1bc-aa5ece77ae99`; +- name: `Robot2B Trike Positions`; +- slug: `robot2b-trike-positions`; +- status: `draft`; +- version: `0.1.0`; +- одна page `map` на `/`; +- Map template `map@0.1.0`; +- updated: `2026-08-05T17:29:28.077Z`. + +Live server-owned consumers: + +| Binding | Product | Subjects | Cursor | Last error | +|---|---|---:|---:|---| +| `trike-current-positions` | `fleet.positions.current.v5` | 107 | 124720 | null | +| `trike-unit-profile` | `fleet.units.profile.current.v1` | 107 | 74 | null | +| `trike-unit-contacts` | `fleet.units.contacts.current.v1` | 107 | 55 | null | +| `trike-unit-identity` | `fleet.units.identity.current.v1` | 107 | 3651 | null | +| `depttrans-pmd-slow-zones` | `map.zones.current.v2` | 903 | 10 | null | + +Все consumers включены и используют `target-scoped-server-only` reader grant. Positions consumer получил свежий patch во время аудита. + +Существующее представление уже provider-neutral по renderer и composition: + +- один primary moving-object binding; +- profile/identity joins выполняются по стабильному `sourceId`; +- в Data tab уже показываются `sourceId`, `semanticType`, `position_source`, `dataProductId`, `receivedAt`; +- отдельный Direct pin или отдельная Direct Map не нужны. + +Чего нет: + +- source-selection mode `primary|fallback|shadow|stale`; +- причина выбора источника; +- последнее Direct и последнее Gelios observation одновременно; +- source health/freshness; +- source badge/facet/counter; +- Arnavi/Direct contract; +- Device Manager Page. + +Consumer policy v5 имеет `freshness=none`, `staleAfterMs=null` и `staleTransitions=0`. Следовательно, Foundry сам не определит падение VPS: selection и freshness должны приходить из server-owned upstream contract. + +Отдельная визуальная деталь: сохранённый state `trike-current-positions` имеет `visible=true`, но filters `movement_state=[]` и `signal_state=[]`. По контракту Foundry пустой массив matches nothing. Это может объяснять пустую Map при наличии 107 subjects. Состояние не изменялось в ходе аудита; перед visual acceptance надо отдельно подтвердить, намеренно ли сохранён explicit empty view. + +`trike-unit-contacts` выглядит избыточным: subject detail profile его не использует, а нужные IMEI/contacts уже присутствуют в `unit_identity`. Удалять binding до отдельного UI review не следует. + +## 6. Live Ontology + +Live catalog: + +- hash: `cdf3f5310359cb36`; +- 204 entities; +- 183 relations; +- 143 aliases; +- 46 guardrails; +- 41 blocked conflations. + +Provider-neutral каркас уже существует: + +```text +integration.provider +integration.connection +integration.collection_profile +integration.collection_run +integration.raw_envelope +integration.canonical_subject +integration.read_model +integration.realtime_channel +map.moving_object +map.state_facet +``` + +Поэтому не нужен новый renderer, новая Map, новый сайт или отдельная пользовательская сущность `Arnavi trike`. + +Но семантический разрыв реальный: + +- tracker entity есть только как `gelios.tracker_device`; +- IMEI/device identifier не определён provider-neutral; +- Arnavi/ARUSNAVI provider package отсутствует; +- source selection/failover semantics отсутствуют; +- `gelios.telemetry_snapshot`, `gelios.signal_state` и `gelios.movement_state` имеют Gelios-specific authority. + +Нельзя публиковать Direct B2 под видом Gelios mapping. Особенно нельзя молча объявить Direct `signal_state` результатом Gelios monitoring-config или добавить `fallback/stale` в закрытые `active|inactive` / `moving|stopped` enum. + +Минимальное canonical изменение — не новая бизнес-сущность, а: + +1. source-evidenced ARUSNAVI B2 provider/mapping package; +2. provider-neutral policy для выбранного источника и freshness; +3. нейтральное определение `signal_state`/`movement_state` для successor product; +4. restricted device identifier/crosswalk semantics по открытой `DCPLATFORM-70`. + +## 7. Текущий Direct B2 код + +### 7.1 Что реализовано + +ARUSNAVI adapter реализует: + +- HEADER2 `FF 23`; +- 8-byte little-endian IMEI; +- 15-digit validation; +- PACKAGE boundaries; +- packet length и checksum; +- HEADER acknowledgement с Unix time; +- package-number acknowledgement; +- bounded frame/buffer limits; +- commands disabled. + +Основные source anchors: + +- `platform/device-plane/packages/arusnavi-b2-adapter/src/index.mjs:56` — HEADER2; +- `.../index.mjs:93` — HEADER ACK; +- `.../index.mjs:110` — PACKAGE framing/checksum; +- `.../index.mjs:187` — PACKAGE ACK; +- `platform/device-plane/services/device-gateway/src/runtime.mjs:144` — session state machine. + +Device Plane test suite: 41 passed, 0 failed. + +### 7.2 Критический ACK/data-loss gap + +`tryParseB2Package()` возвращает только: + +```text +bytesConsumed +packageNumber +packetCount +``` + +Он не возвращает packet data или decoded tags. + +Gateway после успешного parse: + +```text +buffer = buffer after package +ACK counter++ +send PACKAGE ACK +``` + +Package sink, durable queue, EDP publish и decoder отсутствуют. Иными словами, текущий Gateway сообщает B2 «пакет принят», после чего payload теряется. + +Этот режим допустим только как discovery/framing pilot. Он непригоден для Direct-primary telemetry. + +### 7.3 Реализован только quarantine observe + +Core имеет только: + +```text +POST /internal/v1/device-discoveries:observe +``` + +Он HMAC-хэширует IMEI, создаёт/обновляет quarantine discovery и отдаёт masked projection. Claim endpoint, inventory import и device CRUD отсутствуют. + +Дополнительный blocker: Gateway Core client принимает только response `lifecycleState=quarantine`. Если discovery будет переведён в `claimed`, текущий client отклонит response, а HEADER ACK не будет отправлен. + +### 7.4 Нет связи device → canonical trike + +`device_bindings` сейчас содержит только: + +```text +contour_id +target_kind +target_ref +capabilities +``` + +В нём нет `device_id`. В `device_instances` нет `canonical_subject_ref`. + +Следовательно, из одного contour binding нельзя доказать, какой IMEI соответствует какому `gelios-unit-*`. Без explicit crosswalk Direct publisher либо создаст дубликаты, либо присвоит наблюдение неправильному трайку. + +Минимальное исправление без новой доменной сущности: + +- добавить `canonical_subject_ref` к существующему `device_instance`/claim contract; +- сопоставлять IMEI digest с restricted Gelios identity inventory; +- сохранять существующий `gelios-unit-*` как canonical `sourceId` на переходном этапе; +- неизвестный/duplicate IMEI оставлять unmatched quarantine; +- автоматический matcher может только предложить mapping; принятие принадлежности остаётся explicit admin action. + +## 8. Почему Mini artifacts нельзя применить к VPS + +Mini deployment жёстко фиксирует: + +- runtime host `ndcmini12`; +- NIC `enp1s0f0`; +- IPvlan `192.168.71.253`; +- LAN `192.168.68.0/22`; +- gateway `192.168.68.1`; +- Mini host `192.168.68.54/22`; +- Amnezia routes; +- userspace Tailnet SOCKS container; +- Deco/double-NAT topology. + +Эти assumptions находятся в: + +- `docker-compose.device-edge.ingress.yml`; +- `deployment/device-edge-ingress-ipvlan-v1.json`; +- `deployment/device-edge-admission-gate-v1.json`; +- `deployment/device-edge-backhaul-v1.json`; +- `infra/deploy-runner/build-device-edge-ingress-artifact.mjs`; +- `infra/deploy-runner/nodedc-edge-deploy`; +- `docs/DEVICE_EDGE_B2_PUBLIC_PILOT_RUNBOOK.md`. + +Переиспользовать можно relay implementation и fail-closed limits. Переиспользовать Mini Compose overlay, descriptor, builder или runner нельзя. + +VPS требует отдельный versioned placement/transition того же логического component `device-edge`, с собственными: + +- exact host identity и predecessor; +- public interface/bind; +- firewall contract; +- Tailscale/WireGuard route; +- SSH backhaul trust; +- resource limits; +- acceptance; +- backup/journal/rollback. + +Это additive extension `DCPLATFORM-21`, а не ручной `docker compose up` и не ослабление канона. + +## 9. Целевая архитектура + +```mermaid +flowchart TB + B2["ARUSNAVI B2\nдва server slot"] + + B2 -->|"INTERNAL raw TCP"| GELIOS["Gelios legacy read route"] + B2 -->|"INTERNAL raw TCP :9921"| VPS["VPS Device Edge\nopaque relay only"] + + VPS -->|"encrypted private backhaul"| GW["Synology Device Gateway\nframing + decoder + ACK owner"] + GW --> CORE["Device Control Core\nclaim + canonical subject crosswalk"] + GW -->|"after decode and durable acceptance"| DIRECT["Direct candidate Data Product\nworking contract"] + + GELIOS --> GL2["Existing Gelios L2 alxvw4fn"] + GL2 --> V5["fleet.positions.current.v5\nGelios candidate + legacy history"] + + DIRECT --> ARB["Server-owned per-device arbiter"] + V5 --> ARB + ARB --> V6["fleet.positions.current.v6\none canonical writer"] + V6 --> MAP["Existing Foundry Map binding\nsame page/profile/sourceId"] + + CORE -. "future, separate red domain" .-> CMD["Commands disabled"] +``` + +### 9.1 Что остаётся существующим + +- Device Plane PostgreSQL/Core/Gateway на Synology; +- EDP/Timescale; +- Robot2B L1; +- Gelios collector `alxvw4fn`; +- `fleet.positions.current.v5` как legacy candidate и immutable pre-cutover history; +- Foundry application, page, presentation profile и detail profile; +- `sourceId = gelios-unit-*` как переходная canonical identity; +- profile/identity products и joins; +- Gelios route на B2. + +### 9.2 Какие технические контракты неизбежно добавляются + +«Без новых сущностей» достижимо на уровне продукта/UI: не нужны новый сайт, новый backend, новая DB, новая Map или второй трайк. + +Но полностью без новых технических контрактов нельзя: + +- нужен Direct candidate product или другой durable server-owned candidate store; +- нужен ARUSNAVI provider/mapping package; +- нужен один canonical arbiter writer scope; +- нужен successor `fleet.positions.current.v6`, потому что v5 не разрешает поля selection/fallback provenance и имеет старую семантическую authority; +- нужен exact device → canonical subject field/claim contract. + +Это versioning и integration configuration, а не новые пользовательские domain objects. + +### 9.3 Где должен жить arbiter + +Не на VPS и не в Foundry. + +Engine подходит для чтения candidate products, нормализации и orchestration. EDP должен оставаться durable state и единственным publish authority. Практический вариант: + +1. Gelios продолжает писать v5. +2. Gateway/Direct adapter пишет отдельный candidate product после durable acceptance. +3. Arbiter L2 читает оба products отдельными managed reader grants. +4. Arbiter читает предыдущий canonical state для hysteresis или хранит selection state в каноническом fact. +5. Только arbiter имеет writer grant на v6. + +Существующий `g7q86421` можно рассмотреть как runtime slot для arbiter только после neutral rematerialization. Сейчас он Gelios-scoped; публиковать Direct под его Gelios connection нельзя. Если Engine не позволяет безопасно сменить connection authority, нужен новый служебный L2 target внутри существующего Robot2B L1. Это не новая бизнес-сущность. + +### 9.4 Правило выбора источника + +Выбор выполняется по каждому `canonical_subject_ref`. + +Direct eligible, только если одновременно выполнены: + +- device claimed и crosswalk однозначен; +- HEADER/PACKAGE framing и checksum валидны; +- telemetry packet decoded по versioned ARUSNAVI contract; +- observed time валиден и не уходит недопустимо в будущее; +- observation монотонно либо явно допустимо out-of-order; +- EDP candidate receipt durable; +- direct observation свежее per-device threshold; +- Gateway/backhaul не сообщает terminal fault. + +Fallback: + +```text +direct stale or invalid +→ grace period +→ select latest valid Gelios observation +→ selection_mode=fallback +``` + +Возврат: + +```text +direct returns +→ shadow only +→ N consecutive valid observations / accepted time window +→ identity and timestamp checks pass +→ selection_mode=primary +``` + +Точные grace/N/timeout не следует угадывать. Их надо вывести из pilot packet cadence и shadow statistics. + +Первый arbiter должен выбирать цельный position+telemetry observation атомарно. Не следует молча смешивать координаты Direct с sensor readings Gelios в одном fact. Cold profile и restricted identity могут временно продолжать приходить из Gelios отдельными joined aspects. + +### 9.5 Provenance successor product + +Рабочий набор полей v6: + +```text +position_source +telemetry_source +source_selection_mode +source_selection_reason +selected_observed_at +direct_last_observed_at +gelios_last_observed_at +selection_changed_at +``` + +Точные field IDs и enum должны сначала пройти Ontology/Data Product authority. `fallback` нельзя перегружать в `signal_state`. + +Для визуальной совместимости можно сохранить enum: + +```text +signal_state = active | inactive +movement_state = moving | stopped +``` + +Но v6 должен определить их как neutral Robot2B policy. Direct `signal_state` выводится из свежести direct observation, а не из Gelios monitoring-config. Movement threshold, если сохраняется `speed > 2`, должен быть явно принят как platform policy, а не назван «нативным B2 статусом». + +### 9.6 История + +Timescale/Postgres остаётся тем же сервисом и volume, но история EDP scoped по: + +```text +tenant + connection + provider + dataProductId + sourceId +``` + +Поэтому смена writer connection/product не создаёт автоматическую бесшовную history query через старые v5 rows. + +Правильный cutover: + +- v5 history остаётся immutable legacy history до даты переключения; +- v6 пишет новую canonical history с тем же `sourceId`; +- `NDC Robot2B History and Reports` получает version-aware read: v5 до cutover, v6 после cutover; +- никакой второй Timescale DB и никакая destructive migration не создаются. + +## 10. Durable ACK contract + +Production Direct path должен иметь такой порядок: + +```text +HEADER2 +→ resolve/create discovery +→ resolve claimed device and canonical subject when available +→ HEADER ACK + +PACKAGE +→ validate length/checksum +→ decode packet tags +→ normalize candidate observation +→ idempotent durable EDP acceptance +→ PACKAGE ACK +``` + +Если durable acceptance недоступен, PACKAGE ACK не отправляется, и B2 получает возможность повторить пакет. + +Idempotency нельзя строить только по package number: диапазон ограничен и номер переиспользуется. Candidate key должен включать device identity, session/package context и digest подтверждённых bytes/packet timestamp. Raw payload не обязан сохраняться; можно сохранять digest и normalized facts. + +Для quarantine pilot допустим отдельный режим framing proof: validated PACKAGE ACK после безопасного quarantine evidence без заявления, что телеметрия сохранена. Этот режим должен быть явно отличим от production telemetry acceptance. + +Gateway, а не VPS, остаётся ACK owner. + +## 11. VPS contract + +### 11.1 Runtime role + +VPS: + +- слушает public `TCP/9921`; +- применяет bounded session/rate/buffer policy; +- непрозрачно передаёт stream на private backhaul; +- держит минимальный loopback/internal health; +- не читает IMEI и telemetry; +- не ACK’ает B2; +- не хранит raw packet или DB; +- не имеет Core/EDP/Engine/Foundry credentials; +- не выполняет commands. + +### 11.2 Предлагаемый минимальный placement + +На слабом VPS достаточно: + +- host Tailscale или WireGuard; +- `device-edge-relay` container; +- `device-edge-backhaul` container с key-only SSH local forward в Synology target; +- Docker Compose plugin; +- system firewall default-deny; +- time sync; +- bounded Docker logs; +- root-owned deploy runner и state outside artifact. + +Live baseline подтверждает, что host нельзя использовать для production image builds или тяжёлого runtime. Начальные resource limits для reviewed candidate: + +- relay memory limit около 192 MiB; +- backhaul около 64 MiB; +- PID limits; +- logs `10 MiB × 3`; +- image build не выполнять на production host, если 1 GiB RAM не выдерживает; использовать reviewed prebuilt/digest-pinned image или swap policy, утверждённую отдельно. + +До application artifact нужен отдельный bootstrap transition, который fail-closed переводит host из текущего baseline: + +- firewall INPUT `ACCEPT` → default-deny с сохранением проверенного SSH access; +- root/password/X11/unrestricted forwarding → отдельный key-only management boundary; +- установить только утверждённый container/runtime и encrypted backhaul prerequisites; +- создать versioned service identities, fixed roots, bounded logs/resources и rollback; +- не открывать `9921` в bootstrap transition. + +Текущие Mini defaults `10 s session timeout` и `256 KiB per direction` являются pilot constraints. Их нельзя автоматически переносить в production B2: packet cadence и session lifetime должны быть измерены на одном реальном B2. + +### 11.3 Public surface + +Наружу: + +- `9921/TCP` для B2; +- management SSH только key-only и максимально ограниченно, предпочтительно через Tailnet/allowlist. + +Не публикуются: + +- health endpoint; +- Docker API; +- UDP range; +- Core/Gateway/EDP ports; +- database; +- DSM; +- reverse proxy `443 → 9921`. + +### 11.4 DNS + +DNS меняется только после: + +1. exact VPS identity; +2. canonical deploy-ok; +3. public listener/firewall acceptance; +4. encrypted backhaul acceptance; +5. external synthetic TCP proof; +6. rollback proof. + +Только затем `device.nodedc.ru` переводится с `95.165.91.235` на VPS. Изменение B2 server slot выполняется после DNS convergence и только для одного pilot; Gelios slot не трогается. + +## 12. Фазовый план + +### Phase 0 — authority и точный VPS target + +- точный target `root@155.212.211.15` получен; +- pinned ED25519 fingerprint проверен; +- live read-only inventory завершён; +- VPS placement зафиксирован в `DCPLATFORM-21` и `DCPLATFORM-74`; +- не менять DNS, B2 и Synology. + +### Phase 1 — canonical VPS bootstrap + +- отдельный VPS `device-edge` descriptor/runner; +- install Docker/Compose, runtime user, firewall, time sync, log policy, private network client; +- deploy relay/backhaul exact artifact; +- prove resource limits, ports, health, backup/journal и automatic rollback; +- terminal state только `deploy-ok`. + +### Phase 2 — transport acceptance без трекера + +- synthetic TCP from external host → VPS `9921` → encrypted backhaul → loopback Gateway; +- prove VPS does not parse/ACK; +- prove Synology remains non-public; +- prove closing/rollback removes only VPS `9921` and preserves Gelios; +- no DNS/B2 change yet. + +### Phase 3 — один B2, quarantine shadow + +- вручную добавить свободный B2 INTERNAL slot на accepted VPS address/hostname; +- Gelios slot сохранить; +- доказать HEADER2 → masked quarantine; +- доказать PACKAGE framing/checksum/ACK; +- commands remain disabled; +- не объявлять telemetry production, пока PACKAGE всё ещё discard. + +### Phase 4 — decoder, claim и durable candidate + +- принять точную официальную tag/framing specification для firmware pilot B2; +- parser возвращает packet data/typed records; +- реализовать explicit claim/crosswalk в существующем Device Instance contract; +- исправить Gateway/Core lifecycle для claimed devices; +- добавить Direct candidate product и managed writer; +- ACK только после durable candidate receipt; +- unknown tags fail closed или попадают в bounded classification evidence, но не в unrestricted Data Product. + +### Phase 5 — shadow comparison + +Для одного pilot сравнивать Direct и Gelios без canonical switch: + +- identity match; +- observation timestamps и clock skew; +- coordinate distance; +- speed/course/elevation; +- satellites/HDOP/accuracy; +- mileage/engine hours; +- все 17 текущих telemetry reading IDs; +- missing/extra tags; +- duplicate/retry/out-of-order behavior; +- reconnect cadence и session lifetime. + +До этой фазы утверждение «Gelios отдаёт ровно те же B2 пакеты» не считается доказанным. Сейчас доказано лишь, что Gelios предоставляет данные, относящиеся к B2 units, и текущий normalized surface совпадает с ожидаемым B2 профилем. Exact packet equivalence требует Direct shadow. + +### Phase 6 — arbiter и v6 + +- neutral Ontology/source-selection contract; +- per-device eligibility, grace и hysteresis; +- один arbiter writer; +- successor v6; +- history cutover policy; +- no field-level silent mixing; +- simulate Direct failure and recovery; +- Gelios continues collecting at all times. + +### Phase 7 — Foundry cutover + +- существующий `trike-current-positions` binding переводится на v6; +- page, binding ID, presentation/detail profiles и sourceId сохраняются; +- Data tab получает selection provenance; +- optional source badge/facet добавляется только по versioned field contract; +- решить explicit empty filters; +- проверить все joins profile/identity и 107 subjects; +- rollback возвращает binding на v5. + +### Phase 8 — эксплуатационная приёмка + +- sustained Direct-primary window; +- controlled VPS/backhaul/Gateway failure → per-device Gelios fallback; +- controlled recovery → shadow → Direct promotion; +- no duplicate current facts/history; +- no timestamp regression; +- bounded logs/resources; +- alerts/metrics без full IMEI; +- documented rollback. + +### Phase 9 — команды, отдельная работа + +Только после принятого Direct read path: + +- official command contract; +- draft/plan/confirm/queue/dispatch/ACK/reconciliation; +- immutable audit и idempotency; +- no automatic Gelios command fallback; +- no blind retry after unknown outcome. + +## 13. Ops reconciliation + +Фактическое значение карточек после аудита: + +- `DCPLATFORM-74` — master architecture; Mini placement устарел, VPS должен стать новым accepted Device Edge placement; +- `ROBOT2B-5` — старый pause superseded: Direct track возобновлён, SSH access подтверждён, но работа пока blocked на canonical VPS bootstrap/backhaul и Direct data path gaps; +- `ROBOT2B-6` — current production Gelios baseline; теперь это сохраняемый legacy candidate/fallback, а не конечная архитектура; +- `DCPLATFORM-21` — authority для отдельного VPS runner/artifact/rollback; +- `DCPLATFORM-70` — provider-neutral device/identifier/crosswalk debt; +- `DCPLATFORM-72` и `DCPLATFORM-73` — обязательные Engine change acceptance risks. + +Актуализация записана в Ops 6 августа 2026 года: + +- `DCPLATFORM-74`: comment `fcdb49c3-8cfc-4c94-8e67-137de14ec9f2`; +- `ROBOT2B-5`: comment `eb6284b5-4d0e-4064-8eda-659485b94115`, карточка переведена из Backlog в In Progress; +- `ROBOT2B-6`: comment `5fecce6e-fec0-4119-814e-0e2bb7a6a41e`; +- `DCPLATFORM-21`: comment `c9bbe692-d301-4127-8734-9c1e91bb5083`. + +После live SSH-аудита VPS добавлены уточнения: + +- `DCPLATFORM-74`: comment `fa26ac68-17c2-4d56-8b6b-f483b1275739`; +- `DCPLATFORM-21`: comment `8db0d7e2-1bb4-4390-93e3-aebb68429a1b`; +- `ROBOT2B-5`: comment `750aeab7-b2eb-4ea1-bb4a-e6e098c5a02c`. + +Ops не должен утверждать, что: + +- Direct telemetry уже принимается; +- VPS relay/bootstrap уже развёрнут и принят; +- `device.nodedc.ru` уже указывает на VPS; +- Device Manager Page существует; +- IMEI полностью отсутствует в Foundry; +- текущий Gateway выполняет durable PACKAGE acceptance; +- Gelios и Direct уже переключаются автоматически. + +## 14. Текущие блокеры + +1. VPS firewall/SSH baseline не hardened и не принят canonical runner-ом. +2. Не выбран и не enrolled exact encrypted VPS → private NODE.DC backhaul identity/target. +3. Current DNS указывает на Synology. +4. Mini deploy artifacts не переносимы на VPS. +5. Gateway ACK’ает и отбрасывает PACKAGE. +6. Нет telemetry tag decoder. +7. Нет claim endpoint и claimed-device handshake. +8. Нет device → canonical trike crosswalk. +9. Нет Direct candidate product/writer. +10. Нет neutral source-selection ontology/product contract. +11. Нет arbiter и successor product/history cutover. +12. Foundry source provenance минимальна, а saved filters могут скрывать все subjects. +13. Команды намеренно выключены. + +## 15. Следующее разрешённое действие + +Следующий шаг — зафиксировать exact encrypted backhaul choice/identity и подготовить additive VPS deployment domain: root-owned runner, deterministic bootstrap artifact, exact predecessor, firewall/SSH acceptance и rollback. Первый bootstrap не открывает `9921`. Никакой B2, DNS или production Map mutation до отдельного transport acceptance не требуется. + +## 16. Implementation update — 2026-08-06 + +Этот раздел заменяет устаревшие operational assertions в разделах 3.4, 11.2, +12 Phase 0–1, 13–15. Архитектурные выводы остальных разделов сохраняются. + +Реализовано: + +- созданы отдельные Ops cards `DCPLATFORM-75` и `ROBOT2B-7`; +- создан отдельный root-owned VPS deploy domain + `/usr/local/sbin/nodedc-b2-vps-deploy`; +- Docker на 961 MiB VPS сознательно не устанавливался; +- foundation `device-edge-vps-foundation-20260806-003` принят с `deploy-ok`; +- SSH переведён в key-only, nftables — в default-deny, публично только TCP/22; +- Node.js 22.23.2 и Tailscale 1.102.2 установлены из pinned static archives; +- Tailscale runtime user `nodedc-edge` и отдельный ED25519 backhaul key созданы + runner-ом; isolation-aware backhaul/relay transitions используют отдельные + `nodedc-backhaul` и `nodedc-relay`; +- default wildcard tailnet grant удалён; VPS переведён из user ownership в + `tag:device-edge-vps` с единственным egress `100.109.216.21:2222/tcp`; +- отрицательные проверки с VPS подтвердили запрет Synology SSH/DSM, MacBook и + второго edge-узла; +- TCP/9921 остаётся закрыт; +- backhaul и relay artifacts собраны и стадированы, но не применены до + predecessor acceptance; +- public VPS key стадирован в Synology enrollment; +- marker-only Synology key-rotation artifact и runner candidate стадированы. + +Текущие blockers/gates: + +1. Root на Synology должен promote/verify exact runner, review plan и выполнить + exact enrollment apply. +2. После этого VPS runner может принять backhaul, затем relay. +3. DNS и B2 routes остаются неизменными до отдельного transport pilot. +4. Provider recovery key `beget-access-key` — RSA-1024; его отзыв требует + отдельного owner decision. + +Полный повторяемый manual и release evidence находятся в +`device-plane/docs/ROBOT2B_B2_VPS_CONFIGURATION_BIBLE_2026-08-06.md`. diff --git a/device-plane/docs/IMPLEMENTATION_BASELINE.md b/device-plane/docs/IMPLEMENTATION_BASELINE.md index b277a4c..a4acdcf 100644 --- a/device-plane/docs/IMPLEMENTATION_BASELINE.md +++ b/device-plane/docs/IMPLEMENTATION_BASELINE.md @@ -1,5 +1,15 @@ # Device Plane Implementation Baseline +> Superseded topology notice — 2026-08-10 +> +> The historical Foundry-Page product boundary, Mini ingress, VPS-initiated +> Tailscale/SSH backhaul and `device.nodedc.ru` raw-TCP assumptions below are +> retained only as implementation history. They must not be used for a new +> plan/apply. The accepted successor is +> `docs/ADR_0001_CORE_INITIATED_EDGE_CHANNEL.md`: Device Core is a standalone +> Hub application, Synology/Core initiates a mutually authenticated full-duplex +> channel to the VPS, and `device.nodedc.ru` remains the HTTPS UI surface. + Status: PostgreSQL, Control Core and Gateway foundation are running healthy on Synology. The accepted foundation has public ingress and discovery ingest disabled. The next additive transition enables only an authenticated, diff --git a/device-plane/docs/ROBOT2B_B2_VPS_CONFIGURATION_BIBLE_2026-08-06.md b/device-plane/docs/ROBOT2B_B2_VPS_CONFIGURATION_BIBLE_2026-08-06.md new file mode 100644 index 0000000..a40eb11 --- /dev/null +++ b/device-plane/docs/ROBOT2B_B2_VPS_CONFIGURATION_BIBLE_2026-08-06.md @@ -0,0 +1,539 @@ +# Robot2B B2 VPS — конфигурационная Библия + +> Замороженная историческая конфигурация — 10.08.2026 +> +> Не применять описанные ниже `backhaul`, `relay`, Tailscale/SSH LocalForward, +> Synology enrollment или открытие `9921`. Целевой транспорт заменён на +> Core-initiated mTLS full-duplex channel по +> `docs/ADR_0001_CORE_INITIATED_EDGE_CHANNEL.md`. Документ сохраняется как +> evidence уже выполненных экспериментов и текущего predecessor VPS. + +Статус документа: живой manual реализации от 2026-08-06. + +Контур: Robot2B / NODE.DC Device Plane. + +VPS: `155.212.211.15`, hostname `koffyvngij`. + +## 1. Назначение + +VPS является минимальным публичным Device Edge для трекеров Arnavi B2. Он не +владеет бизнес-логикой, протоколом, БД, карточками устройств, визуализацией или +командами. Его единственная целевая функция — принять ограниченный поток raw TCP +на `155.212.211.15:9921`, не интерпретируя пакет, и передать его по шифрованному +private backhaul в уже существующий `device-gateway` NODE.DC. + +Gelios остаётся отдельным legacy-источником. Эта конфигурация не меняет Gelios, +DNS `device.nodedc.ru`, маршруты B2 или command transport. + +```mermaid +flowchart LR + B2["Arnavi B2 trackers"] + VPS["Device Edge VPS\n155.212.211.15:9921"] + TS["Tailscale userspace\nSOCKS5 127.0.0.1:1055"] + SSH["SSH local forward\n127.0.0.1:19921"] + TARGET["Synology device-backhaul-target\n100.109.216.21:2222"] + GW["device-gateway\n127.0.0.1:9921"] + CORE["device-control-core + PostgreSQL"] + FOUNDRY["Foundry / visualization"] + GELIOS["Gelios legacy source"] + + B2 -->|"raw TCP, planned route"| VPS + VPS --> TS -->|"grant: tag:device-edge-vps → TCP/2222 only"| SSH --> TARGET --> GW --> CORE --> FOUNDRY + GELIOS -->|"independent legacy ingest"| CORE +``` + +## 2. Красные границы + +- На VPS нет базы данных и durable telemetry storage. +- VPS не разбирает Arnavi/B2 protocol и не принимает решения по IMEI. +- IMEI остаётся claimed identifier, а не доказательством владения устройством. +- Неизвестное устройство должно попадать в quarantine/discovery lifecycle. +- Command transport отключён. Команды через VPS или Gelios не вводятся. +- Gelios не выключается и не перетирается новым потоком. +- На Synology не публикуется Docker-порт `2222`; доступ только через private + Tailscale Serve. +- Public VPS не является пользовательским tailnet-узлом. Его service identity + `tag:device-edge-vps` может инициировать только TCP к `100.109.216.21:2222`. +- VPS не принимает subnet routes, DNS tailnet, exit-node capability или + Tailscale SSH. Доступ к DSM, MacBook и другим tailnet-узлам запрещён. +- Приватные ключи, auth keys, токены и пароли не входят в Git, deploy artifacts, + Ops или этот документ. +- До отдельного pilot/cutover не меняются `device.nodedc.ru` и настройки B2. + +## 3. Фактический predecessor VPS + +Снято до первого apply: + +- Ubuntu `24.04.4 LTS`, kernel `6.8.0-137-generic`, KVM/QEMU. +- 1 vCPU, 961 MiB RAM, swap отсутствует. +- Root filesystem около 8.7 GiB; свободно около 6.4 GiB. +- `eth0`: `155.212.211.15/32`, default gateway `100.100.1.1`. +- Публично слушал только TCP/22; TCP/9921 был закрыт. +- Docker, Node.js, Tailscale и WireGuard отсутствовали. +- `fail2ban`, NTP и unattended upgrades были активны. +- UFW был inactive, nftables/iptables использовали INPUT ACCEPT. +- SSH допускал root/password и forwarding; это устранено foundation-переходом. + +## 4. Канонические переходы + +Конфигурация разделена на независимые data-only артефакты. Порядок нельзя +переставлять. + +1. `foundation`: + - pin статических Node.js и Tailscale runtimes; + - service account `nodedc-edge`; + - key-only SSH; + - default-deny nftables, публично только TCP/22; + - отдельный ED25519 backhaul key, сгенерированный на VPS; + - userspace `tailscaled`, но без скрытого auth key; + - TCP/9921 закрыт. +2. Внешняя регистрация и сегментация `nodedc-b2-vps` в существующем tailnet: + - заменить default wildcard policy на проверяемую deny-by-default policy; + - назначить `tag:device-edge-vps`, тем самым удалить user ownership; + - разрешить тегу только `100.109.216.21:2222/tcp`; + - отрицательно проверить Synology `22/5001`, MacBook `22` и другие узлы; + - только после этого вернуть Tailscale service в состояние Running. +3. Synology `backhaul-vps-enrollment`: + - принять только публичный VPS key через enrollment; + - заменить прежний Mini key; + - пересоздать только `device-backhaul-target`; + - сохранить Device Plane, PostgreSQL, Tailscale Serve и Gelios; + - при ошибке автоматически вернуть прежний key и target generation. +4. VPS `backhaul`: + - pinned Synology host key; + - key-only SSH через Tailscale userspace SOCKS5; + - `127.0.0.1:19921 -> 127.0.0.1:9921`; + - публичный TCP/9921 всё ещё закрыт. +5. VPS `relay`: + - открыть публичный TCP/9921; + - bounded opaque relay на `127.0.0.1:19921`; + - loopback health на `127.0.0.1:18221`. +6. Отдельный pilot: изменить адрес сервера у ограниченной группы B2. Это не + часть конфигурации VPS. + +## 5. Идентичности и ключи + +### 5.1 Management SSH + +Команда с MacBook: + +```bash +ssh -i ~/.ssh/nodedc_b2_vps \ + -o IdentitiesOnly=yes \ + -o StrictHostKeyChecking=yes \ + root@155.212.211.15 +``` + +В Ops и manual фиксируются только путь и fingerprints: + +- локальный private key path: `~/.ssh/nodedc_b2_vps`; +- MacBook management public key fingerprint: + `SHA256:DYYy1E3DaxIQGC0jnsW6SP7gXdBHUy3A1zn4pvgVUEw` (ED25519); +- VPS SSH server host key fingerprint: + `SHA256:mhqNn2S6zstkYL7VFdvt3SYHv1nLjB4J7/s57RrKG6w` (ED25519). + +Foundation принудительно задаёт: + +- `PermitRootLogin prohibit-password`; +- `AuthenticationMethods publickey`; +- `PasswordAuthentication no`; +- `KbdInteractiveAuthentication no`; +- forwarding, agent forwarding, X11 и tunnels запрещены; +- `MaxAuthTries 3`, `LoginGraceTime 20`. + +В `/root/.ssh/authorized_keys` остаются две management identity: + +- MacBook ED25519 — fingerprint выше; +- provider recovery key `beget-access-key`, RSA 1024, + fingerprint `SHA256:9W1cgovqOlegteEWV0r5j4OjJgG0PGr97eVmpUBjg54`. + +RSA-1024 provider key — остаточный риск. Он не удалён автоматически, потому +что отзыв внешнего recovery-доступа является отдельным необратимым решением. +После подтверждения владельца нужно либо удалить его отдельным каноническим +переходом, либо документировать как принятый break-glass access. + +### 5.2 VPS backhaul client identity + +Private key генерирует root-owned runner непосредственно на VPS: + +- private: `/var/lib/nodedc-b2-vps/trust/backhaul_ed25519`, owner + `nodedc-edge` на foundation и `nodedc-backhaul` после backhaul apply, + mode `0400`; +- public: `/var/lib/nodedc-b2-vps/trust/backhaul_ed25519.pub`, mode `0444`; +- fingerprint: + `SHA256:HHTiDYiCRxSiKjBLCip6JMSzGfLGrDz5g8SIkosJcVw`; +- public key: + `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGUlvJ8a83qov9DHO2S/BHbVAEH4Chyc4v/DKwOIWeES nodedc-device-edge-vps-backhaul`. + +В Synology staging этот public key хранится по пути: + +`/volume1/docker/nodedc-device-plane/enrollment/device-edge-vps-backhaul.pub` + +Private key никогда не покидает VPS. + +Foundation генерирует ключ до появления публичного relay, поэтому его +временным владельцем является `nodedc-edge`. Backhaul transition атомарно +создаёт `nodedc-backhaul`, передаёт ему каталог trust и ключ и валидирует mode. +Relay запускается как третий пользователь `nodedc-relay`; он не может читать ни +private key, ни Tailscale state. Даже локальный доступ relay к SOCKS5 не расширяет +полномочия: tailnet grant разрешает только target `2222`, где аутентификация +дополнительно требует private key пользователя `nodedc-backhaul`. + +### 5.3 Synology backhaul target identity + +- Tailnet IP: `100.109.216.21`. +- Private SSH endpoint: `100.109.216.21:2222` через Tailscale Serve. +- User: `device-backhaul`. +- PermitOpen: только `127.0.0.1:9921`. +- Host key fingerprint: + `SHA256:QERJ5CIUXRj0nLChGT6HMtoX+WTaeaEY5ZgaWqT8d30`. +- Public host key: + `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJsmoyS+0Tbhz9VXxrSxXwNMFpfbdTckCilObOnKdlEc nodedc-device-plane-backhaul-target`. +- VPS pinned known_hosts: + `/var/lib/nodedc-b2-vps/trust/backhaul_known_hosts`. + +### 5.4 Tailscale service identity и grant + +Живая policy сохранена 2026-08-06. Каноническая копия: +`device-plane/deployment/tailscale-device-edge-policy.hujson`. + +- `autogroup:member -> autogroup:self`, все протоколы: сохраняет привычный + доступ пользователя только между собственными user-owned устройствами; +- `tag:device-edge-vps -> device-plane-backhaul`, только `tcp:2222`; +- `device-plane-backhaul = 100.109.216.21`; +- tag owner: только `autogroup:admin`; +- policy test требует accept `100.109.216.21:2222` и deny для Synology + `22/5001`, MacBook `22`, `nodedc-device-edge:22`. + +Назначение тега удалило `dcctouch@gmail.com` из поля `Managed by`; живой VPS +управляется `tag:device-edge-vps`. После включения проверено с самого VPS: + +- `100.109.216.21:2222` — reachable; +- `100.109.216.21:22` — blocked; +- `100.109.216.21:5001` — blocked; +- `100.114.248.4:22` — blocked; +- `100.64.19.31:22` — blocked. + +Enrollment запускается с `--accept-dns=false --accept-routes=false --ssh=false`. +Runner для backhaul/relay дополнительно требует `BackendState=Running`, online +hostname `nodedc-b2-vps` и ровно один tag `tag:device-edge-vps`. + +## 6. Runtime и файловая система VPS + +### 6.1 Root-owned source/runtime + +- runner: `/usr/local/sbin/nodedc-b2-vps-deploy`; +- live root: `/opt/nodedc-b2-vps`; +- deploy state: `/var/lib/nodedc-b2-vps-deploy`; +- inbox: `/var/lib/nodedc-b2-vps-deploy/inbox`; +- applied/failed artifacts: `applied/`, `failed/`; +- backups: `backups/`; +- journals: `state/applied.jsonl`, `state/failed.jsonl`; +- lock: `state/deploy.lock`; +- service state/trust: `/var/lib/nodedc-b2-vps`. + +### 6.2 Pinned runtimes + +- Node.js `22.23.2`; + archive SHA-256 + `d60acfe00a2932254bb0ad20e01b0d74397a0875595de719654b214f4b03f307`. +- Tailscale `1.102.2`; + archive SHA-256 + `ad2cde12f8de95f7b93a1e0401e652291c603d42b9d60a33fb1741eb38ab04d8`. + +Docker не устанавливается: для 961 MiB RAM он не нужен и добавляет лишний +daemon/state surface. + +### 6.3 systemd units + +- `nodedc-b2-tailscaled.service`: + - user `nodedc-edge`; + - userspace networking, без TUN; + - socket `/run/nodedc-b2-vps/tailscaled.sock`; + - SOCKS5 `127.0.0.1:1055`; + - `MemoryMax=160M`; + - разрешены `AF_UNIX AF_INET AF_INET6 AF_NETLINK`. +- `nodedc-b2-backhaul.service`: + - user `nodedc-backhaul`, единственный читатель backhaul private key; + - strict host key pinning; + - local forward `127.0.0.1:19921`; + - `MemoryMax=64M`. +- `nodedc-b2-relay.service`: + - user `nodedc-relay`, без credential access; + - public `0.0.0.0:9921`; + - health `127.0.0.1:18221`; + - upstream `127.0.0.1:19921`; + - `MemoryMax=192M`. + +Все units используют `NoNewPrivileges`, `ProtectSystem=strict`, +`ProtectHome=yes`, `PrivateTmp`, `PrivateDevices`, ограничение address families, +tasks и file descriptors. + +## 7. Firewall и порты + +Foundation nftables policy: + +- input: default drop; +- loopback, established/related и ICMP разрешены; +- новый TCP/22 ограничен `30/minute`, burst `60`; +- TCP/9921 отсутствует; +- forward: default drop; +- output: accept. + +Relay policy добавляет: + +- drop новых соединений на TCP/9921 сверх `300/second`; +- accept TCP/9921 после rate guard. + +Портовая матрица: + +| Endpoint | Видимость | Владелец | Стадия | +|---|---|---|---| +| `155.212.211.15:22` | public | OpenSSH | foundation | +| `127.0.0.1:1055` | loopback | tailscaled SOCKS5 | foundation | +| `127.0.0.1:19921` | loopback | SSH local forward | backhaul | +| `127.0.0.1:18221` | loopback | relay health | relay | +| `155.212.211.15:9921` | public | bounded relay | relay | +| `100.109.216.21:2222` | tailnet grant только для VPS tag | Synology target | existing | +| `127.0.0.1:9921` на Synology | loopback | device-gateway | existing | + +## 8. Relay limits + +- max concurrent sessions: `128`; +- max sessions per source IP: `16`; +- max new connections/minute/source IP: `60`; +- max tracked source IPs: `4096`; +- max bytes per direction/session: `64 MiB`; +- session timeout: `300000 ms`; +- source policy: `public-ipv4-only`; +- protocol inspection: disabled на VPS; +- command transport: disabled. + +## 9. Установленный foundation release + +Принят 2026-08-06: + +- patch: `device-edge-vps-foundation-20260806-003`; +- artifact SHA-256: + `1be852f144e9f0fea32af70bebd07a2607b6a1818825094bd4c1b4062064716a`; +- foundation-time runner SHA-256: + `3f42d23431937e70c16ce1fd346fb84a706e506ae99d89eaf11780ff1ad56c03`; +- current promoted runner SHA-256: + `5ccdc1b53ce0688e7c120976e82937842bc8491a2e05eb5f280165accfd40b6c`; +- backup: + `device-edge-vps-foundation-20260806-003-20260806-151415`; +- terminal result: `deploy-ok`; +- fresh MacBook key-only SSH acceptance: success. + +Два предыдущих ID терминальны и никогда не должны применяться повторно: + +- `...-001`: `tailscaled` заблокирован отсутствием `AF_NETLINK`; automatic + rollback `ok`; +- `...-002`: cloud-init `50-cloud-init.conf` опередил `90-*` и сохранил + `PasswordAuthentication yes`; automatic rollback `ok`; +- `...-003`: исправлены `AF_NETLINK` и ранний `00-nodedc-b2-vps.conf`; + acceptance успешна. + +Следующие exact isolation-aware artifacts стадированы в VPS inbox и не +применяются до своих predecessor barriers: + +- backhaul `device-edge-vps-backhaul-20260806-002`, SHA-256 + `830750da8f9590ca4db458ec9e90f4d48ad8d1403160d3878a968b54e9eb6913`; +- relay `device-edge-vps-relay-20260806-002`, SHA-256 + `305a6de769f24b2c6cee801426ec43b98a44d10e08cad75a96fd65d20b16b697`. + +Версии `...-001` не применялись и recoverably перемещены из inbox в +`/var/lib/nodedc-b2-vps-deploy/withdrawn/*.superseded-by-002`, потому что в них +все три процесса использовали один Unix account `nodedc-edge`. + +## 10. Synology VPS enrollment release + +Стадировано, но до авторизации Tailscale и root plan/apply не считается +применённым: + +- runner candidate: + `/volume1/docker/nodedc-deploy/runner-install/candidates/nodedc-deploy.device-plane-backhaul-vps-enrollment-20260806-011`; +- runner SHA-256: + `453228c41b411d9c925091c77dc94e501f2eb3534fc241db0d2e58f0a28e12e2`; +- artifact: + `/volume1/docker/nodedc-deploy/inbox/nodedc-device-plane-device-plane-backhaul-vps-enrollment-20260806-001.tgz`; +- artifact SHA-256: + `576dabdafde5e3b2de09c7265127928c4b463e7b40dd554fd71babc16cc70e08`; +- VPS enrollment public-key file SHA-256: + `c2718c117fd09965386524d32fa9816d9d2d9cf00b59010dcf8bcf78f29bea8c`. + +Root-переход на Synology: + +```bash +sudo sha256sum \ + /volume1/docker/nodedc-deploy/runner-install/candidates/nodedc-deploy.device-plane-backhaul-vps-enrollment-20260806-011 + +sudo install -o root -g root -m 0755 \ + /volume1/docker/nodedc-deploy/runner-install/candidates/nodedc-deploy.device-plane-backhaul-vps-enrollment-20260806-011 \ + /usr/local/sbin/nodedc-deploy + +sudo /usr/local/sbin/nodedc-deploy verify-install + +sudo /usr/local/sbin/nodedc-deploy plan \ + /volume1/docker/nodedc-deploy/inbox/nodedc-device-plane-device-plane-backhaul-vps-enrollment-20260806-001.tgz + +# Apply только после review exact plan. +sudo /usr/local/sbin/nodedc-deploy apply \ + /volume1/docker/nodedc-deploy/inbox/nodedc-device-plane-device-plane-backhaul-vps-enrollment-20260806-001.tgz +``` + +Ожидаемый plan обязан показать: + +- predecessor patch `device-plane-backhaul-target-tailnet-serve-20260804-002`; +- predecessor artifact SHA + `219408705dd4d80a962ed00eeb53a69df0b9ab6458443734d5c9cd1d1f795eba`; +- build `none`; +- recreate только `device-backhaul-target`; +- next fingerprint `SHA256:HHTi…osJcVw`; +- public ingress disabled; +- Docker port publication disabled; +- Tailscale Serve, router/NAT/firewall, PostgreSQL и Gelios unchanged; +- automatic rollback на previous key + target recreate. + +## 11. Внешняя регистрация Tailscale + +На VPS auth key не хранится. Для первой регистрации root запускает: + +```bash +/opt/nodedc-b2-vps/runtime/tailscale/tailscale \ + --socket=/run/nodedc-b2-vps/tailscaled.sock \ + up \ + --hostname=nodedc-b2-vps \ + --accept-dns=false \ + --accept-routes=false \ + --ssh=false +``` + +Одноразовый login URL не копируется в Ops. До включения service владелец tailnet +обязан сохранить policy и назначить tag из раздела 5.4. Backhaul plan требует +`BackendState=Running`, online hostname `nodedc-b2-vps` и exact service tag. + +## 12. Сборка VPS artifacts + +Из корня repository `platform`: + +```bash +NODEDC_DEVICE_EDGE_VPS_RUNTIME_DIR=/tmp \ +node infra/deploy-runner/build-device-edge-vps-artifact.mjs \ + foundation + +node infra/deploy-runner/build-device-edge-vps-artifact.mjs \ + backhaul + +node infra/deploy-runner/build-device-edge-vps-artifact.mjs \ + relay +``` + +Builder создаёт deterministic archive с `manifest.env`, `files.txt` и +`payload/`. В artifact запрещены `.env`, keys, trust, runtime, logs, uploads, +node_modules и symlinks. Foundation дополнительно проверяет pinned runtime +digests. + +Каждый release: + +1. собрать дважды и сравнить SHA-256; +2. проверить file list и отсутствие секретов; +3. скопировать exact artifact в VPS inbox; +4. выполнить свежий `plan`; +5. review границ; +6. выполнить один exact `apply`; +7. считать `deploy-ok` терминальным результатом и не запускать apply повторно. + +## 13. Rollback model + +VPS runner перед mutation создаёт backup exact partition и сохраняет текущий +nft ruleset и service enablement. При ошибке: + +- candidate services останавливаются; +- source/config возвращаются; +- nftables и SSH config восстанавливаются; +- service enablement возвращается; +- при failed foundation удаляются созданные live/runtime roots и service user; +- при failed backhaul ключ возвращается `nodedc-edge`, а созданный + `nodedc-backhaul` удаляется; +- при failed relay созданный `nodedc-relay` удаляется; +- artifact переносится в `failed/`; +- failed ID и digest становятся терминальными. + +Synology VPS enrollment отдельно сохраняет previous `authorized_keys`. При любой +ошибке он: + +- удаляет candidate marker; +- атомарно возвращает прежний restricted key; +- пересоздаёт только `device-backhaul-target`; +- проверяет target, три preserved Device Plane services, Tailscale Serve и + loopback `9921`. + +## 14. Развёртывание аналогичного VPS + +Перед клонированием нельзя просто переиспользовать текущий artifact. Нужно +создать новый descriptor/release с новыми параметрами: + +1. Получить чистый Ubuntu 24.04 LTS VPS и зафиксировать hostname, public `/32`, + gateway, CPU/RAM/disk. +2. Добавить отдельный ED25519 management key и проверить fresh connection. +3. Зафиксировать server host key fingerprint с доверенного канала. +4. Проверить active fail2ban/NTP/unattended upgrades и отсутствие listeners, + кроме TCP/22. +5. Изменить pin в runner/descriptor: + `RUNTIME_HOST`, `PUBLIC_IPV4`, management/server fingerprints и Tailscale + node name. +6. Не переносить private backhaul key: новый VPS должен сгенерировать новую + пару самостоятельно. +7. Собрать deterministic foundation с новым terminal patch ID. +8. Promoted runner проверить отдельным `verify-install`. +9. Сделать plan → review → apply. +10. Зарегистрировать новый tailnet node без сохранения auth key. +11. Скопировать только public backhaul key в новый enrollment path. +12. Выполнить отдельный Synology key rotation/grant transition. +13. Только после принятого private backhaul применять relay. +14. Только после relay acceptance перенаправлять ограниченный pilot B2. + +## 15. Acceptance checklist + +- [x] Foundation artifact deterministic и secret-free. +- [x] Foundation accepted с automatic rollback coverage. +- [x] Fresh key-only SSH с MacBook работает. +- [x] Public TCP/9921 не открыт на foundation. +- [x] Отдельный VPS backhaul public key создан и pinned. +- [x] Synology enrollment key, runner candidate и artifact стадированы. +- [ ] Tailnet node `nodedc-b2-vps` авторизован и `Running`. +- [x] Tailnet wildcard grant удалён; VPS tagged и negative-route tests пройдены. +- [x] Isolation-aware VPS runner promoted и `verify-install-ok`. +- [ ] Synology runner candidate promoted и `verify-install-ok`. +- [ ] Synology enrollment `plan` reviewed и `deploy-ok`. +- [ ] VPS backhaul artifact `deploy-ok`. +- [ ] VPS relay artifact `deploy-ok`. +- [ ] Public TCP/9921 принят внешним probe. +- [ ] Pilot B2 route согласован отдельно. +- [ ] Provider RSA-1024 recovery key удалён или принят как documented risk. + +## 16. Исходники и проверки + +Канонические файлы: + +- `infra/deploy-runner/nodedc-b2-vps-deploy`; +- `infra/deploy-runner/build-device-edge-vps-artifact.mjs`; +- `infra/deploy-runner/test_device_edge_vps_artifact.py`; +- `device-plane/vps/config/`; +- `device-plane/vps/systemd/`; +- `device-plane/deployment/device-edge-vps-*-v1.json`; +- `infra/deploy-runner/nodedc-deploy`; +- `infra/deploy-runner/build-device-plane-backhaul-vps-enrollment-artifact.mjs`; +- `infra/deploy-runner/test_device_plane_backhaul_vps_enrollment_artifact.py`. + +Проверки на момент документа: + +- VPS artifact tests: 9/9 OK; +- Device Plane targeted runner/artifact tests: 48/48 OK; +- полный platform `npm test`: 41/41 OK во внешнем сетевом sandbox; +- Python compilation: OK; +- systemd unit syntax: OK; отсутствие binaries до foundation было ожидаемым; +- `nft -c` foundation/relay: OK; +- effective candidate OpenSSH policy: key-only; +- `git diff --check`: OK для целевых файлов. diff --git a/device-plane/docs/TEST_MATRIX.md b/device-plane/docs/TEST_MATRIX.md index 28bb7bc..27fc9df 100644 --- a/device-plane/docs/TEST_MATRIX.md +++ b/device-plane/docs/TEST_MATRIX.md @@ -28,3 +28,11 @@ | Artifact policy | `.env`, secrets, runtime state, tests, logs and `node_modules` are excluded | | Artifact reproducibility | Repeated builds for the same patch id are byte-identical | | Runner compatibility | Existing canonical Platform registry tests remain green | +| Core/Edge trust direction | Core initiates the only private channel; VPS-initiated Synology connections are forbidden | +| Core channel authentication | TLS 1.3 mutual authentication; unknown/revoked Edge identity fails closed | +| Core channel bounds | Versioned envelopes, per-session and aggregate memory limits, keepalive and reconnect bounds | +| Pilot SLO | Zero pre-Core ACK/loss, p95/p99 acceptance latency, reconnect/dead-peer ceilings and no premature availability claim | +| Tracker acknowledgement | PACKAGE ACK is emitted only after bounded Core acceptance | +| Superseded transport freeze | Old VPS backhaul/relay/enrollment builders fail closed outside explicit test-only reconstruction | +| Superseded runner freeze | VPS runner rejects old phases and Synology runner rejects a prebuilt old enrollment artifact | +| Runtime preservation in Phase 0 | VPS, Synology, DNS, B2 routes, Gelios, Engine and Foundry remain unchanged | diff --git a/device-plane/packages/device-protocol-contract/test/core-edge-channel-contract.test.mjs b/device-plane/packages/device-protocol-contract/test/core-edge-channel-contract.test.mjs new file mode 100644 index 0000000..ea90b1e --- /dev/null +++ b/device-plane/packages/device-protocol-contract/test/core-edge-channel-contract.test.mjs @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const contractUrl = new URL( + "../../../deployment/device-edge-core-channel-v1.json", + import.meta.url, +); +const freezeUrl = new URL( + "../../../deployment/superseded-vps-initiated-transport-v1.json", + import.meta.url, +); +const edgeBuilder = fileURLToPath(new URL( + "../../../../infra/deploy-runner/build-device-edge-vps-artifact.mjs", + import.meta.url, +)); +const enrollmentBuilder = fileURLToPath(new URL( + "../../../../infra/deploy-runner/build-device-plane-backhaul-vps-enrollment-artifact.mjs", + import.meta.url, +)); + +async function readJson(url) { + return JSON.parse(await readFile(url, "utf8")); +} + +test("pins a Core-initiated mutually authenticated Edge channel", async () => { + const contract = await readJson(contractUrl); + + assert.equal(contract.status, "accepted-design"); + assert.equal(contract.direction, "device-gateway-core-initiated"); + assert.equal(contract.transport.tls, "TLSv1.3-mutual-authentication"); + assert.equal(contract.transport.genericTcpForwarding, "forbidden"); + assert.equal(contract.networkBoundary.synologyPublicIngress, false); + assert.equal(contract.networkBoundary.vpsInitiatedSynologyConnection, false); + assert.equal(contract.networkBoundary.subnetRoutes, false); + assert.equal(contract.networkBoundary.exitNode, false); + assert.equal(contract.identity.privateKeysInArtifacts, false); +}); + +test("requires Core acceptance before acknowledging tracker packages", async () => { + const contract = await readJson(contractUrl); + + assert.equal( + contract.acknowledgement.trackerPackageAck, + "only-after-bounded-core-acceptance", + ); + assert.equal( + contract.acknowledgement.coreUnavailable, + "do-not-acknowledge-tracker-package", + ); + assert.equal(contract.acknowledgement.deliverySemantics, "at-least-once"); + assert.equal(contract.pilotLimits.durableEdgeSpool, false); + assert.ok(contract.pilotLimits.maxBufferedBytesPerTrackerSession <= 262144); + assert.ok(contract.pilotLimits.maxAggregateBufferedBytes <= 33554432); + assert.equal(contract.pilotSlo.trackerAckBeforeDurableCoreAcceptance, 0); + assert.equal(contract.pilotSlo.lossOfCoreAcceptedPackages, 0); + assert.ok( + contract.pilotSlo.edgeReceiveToCoreAcceptanceP99Milliseconds <= 5000, + ); + assert.ok(contract.pilotSlo.deadCoreDetectionHardCeilingSeconds <= 45); +}); + +test("freezes the VPS-initiated Tailscale and SSH backhaul", async () => { + const freeze = await readJson(freezeUrl); + + assert.equal(freeze.status, "frozen"); + assert.equal(freeze.successor, "nodedc.device-edge.core-channel.v1"); + assert.equal(freeze.runtimeMutationInPhase0, false); + assert.ok(freeze.forbiddenForNewPlanOrApply.includes( + "nodedc.device-plane.backhaul-vps-enrollment.v1", + )); + assert.ok(freeze.forbiddenForNewPlanOrApply.includes( + "tailscale-userspace-key-only-ssh-local-forward", + )); +}); + +test("superseded artifact builders fail closed outside test-only reconstruction", () => { + const environment = { ...process.env }; + delete environment.NODEDC_ALLOW_SUPERSEDED_TRANSPORT; + + const edge = spawnSync( + process.execPath, + [edgeBuilder, "backhaul", "superseded-backhaul-unit"], + { encoding: "utf8", env: environment }, + ); + assert.notEqual(edge.status, 0); + assert.match(edge.stderr, /vps_initiated_transport_frozen:ADR-0001/); + + const enrollment = spawnSync( + process.execPath, + [enrollmentBuilder, "superseded-enrollment-unit"], + { encoding: "utf8", env: environment }, + ); + assert.notEqual(enrollment.status, 0); + assert.match(enrollment.stderr, /vps_initiated_transport_frozen:ADR-0001/); +}); diff --git a/device-plane/vps/config/00-nodedc-b2-vps.conf b/device-plane/vps/config/00-nodedc-b2-vps.conf new file mode 100644 index 0000000..3b92552 --- /dev/null +++ b/device-plane/vps/config/00-nodedc-b2-vps.conf @@ -0,0 +1,16 @@ +PasswordAuthentication no +KbdInteractiveAuthentication no +PermitRootLogin prohibit-password +PubkeyAuthentication yes +AuthenticationMethods publickey +X11Forwarding no +AllowAgentForwarding no +AllowTcpForwarding no +GatewayPorts no +PermitTunnel no +PermitUserEnvironment no +MaxAuthTries 3 +LoginGraceTime 20 +ClientAliveInterval 60 +ClientAliveCountMax 3 +UseDNS no diff --git a/device-plane/vps/config/backhaul_ssh_config b/device-plane/vps/config/backhaul_ssh_config new file mode 100644 index 0000000..5d0b799 --- /dev/null +++ b/device-plane/vps/config/backhaul_ssh_config @@ -0,0 +1,23 @@ +Host device-backhaul-target + HostName 100.109.216.21 + Port 2222 + User device-backhaul + AddressFamily inet + IdentityFile /var/lib/nodedc-b2-vps/trust/backhaul_ed25519 + IdentitiesOnly yes + PreferredAuthentications publickey + PasswordAuthentication no + KbdInteractiveAuthentication no + StrictHostKeyChecking yes + UserKnownHostsFile /var/lib/nodedc-b2-vps/trust/backhaul_known_hosts + GlobalKnownHostsFile /dev/null + ProxyCommand /usr/bin/nc -X 5 -x 127.0.0.1:1055 %h %p + LocalForward 127.0.0.1:19921 127.0.0.1:9921 + ExitOnForwardFailure yes + ServerAliveInterval 30 + ServerAliveCountMax 3 + TCPKeepAlive yes + ClearAllForwardings no + RequestTTY no + SessionType none + LogLevel VERBOSE diff --git a/device-plane/vps/config/nftables-foundation.conf b/device-plane/vps/config/nftables-foundation.conf new file mode 100644 index 0000000..be08dc0 --- /dev/null +++ b/device-plane/vps/config/nftables-foundation.conf @@ -0,0 +1,22 @@ +flush ruleset + +table inet nodedc_b2_vps { + chain input { + type filter hook input priority -10; policy drop; + + iifname "lo" accept + ct state invalid drop + ct state established,related accept + ip protocol icmp accept + ip6 nexthdr ipv6-icmp accept + tcp dport 22 ct state new limit rate 30/minute burst 60 packets accept + } + + chain forward { + type filter hook forward priority -10; policy drop; + } + + chain output { + type filter hook output priority -10; policy accept; + } +} diff --git a/device-plane/vps/config/nftables-relay.conf b/device-plane/vps/config/nftables-relay.conf new file mode 100644 index 0000000..dfdd114 --- /dev/null +++ b/device-plane/vps/config/nftables-relay.conf @@ -0,0 +1,24 @@ +flush ruleset + +table inet nodedc_b2_vps { + chain input { + type filter hook input priority -10; policy drop; + + iifname "lo" accept + ct state invalid drop + ct state established,related accept + ip protocol icmp accept + ip6 nexthdr ipv6-icmp accept + tcp dport 22 ct state new limit rate 30/minute burst 60 packets accept + tcp dport 9921 ct state new limit rate over 300/second drop + tcp dport 9921 accept + } + + chain forward { + type filter hook forward priority -10; policy drop; + } + + chain output { + type filter hook output priority -10; policy accept; + } +} diff --git a/device-plane/vps/systemd/nodedc-b2-backhaul.service b/device-plane/vps/systemd/nodedc-b2-backhaul.service new file mode 100644 index 0000000..d429896 --- /dev/null +++ b/device-plane/vps/systemd/nodedc-b2-backhaul.service @@ -0,0 +1,33 @@ +[Unit] +Description=NODE.DC B2 VPS encrypted private backhaul +After=network-online.target nodedc-b2-tailscaled.service +Wants=network-online.target +Requires=nodedc-b2-tailscaled.service + +[Service] +Type=simple +User=nodedc-backhaul +Group=nodedc-backhaul +ExecStart=/usr/bin/ssh -N -F /opt/nodedc-b2-vps/config/backhaul_ssh_config device-backhaul-target +Restart=always +RestartSec=3s +NoNewPrivileges=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectSystem=strict +ProtectHome=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +SystemCallArchitectures=native +MemoryMax=64M +TasksMax=32 +LimitNOFILE=1024 +UMask=0077 + +[Install] +WantedBy=multi-user.target diff --git a/device-plane/vps/systemd/nodedc-b2-relay.service b/device-plane/vps/systemd/nodedc-b2-relay.service new file mode 100644 index 0000000..2f579e5 --- /dev/null +++ b/device-plane/vps/systemd/nodedc-b2-relay.service @@ -0,0 +1,48 @@ +[Unit] +Description=NODE.DC B2 VPS bounded raw TCP relay +After=network-online.target nodedc-b2-backhaul.service +Wants=network-online.target +Requires=nodedc-b2-backhaul.service + +[Service] +Type=simple +User=nodedc-relay +Group=nodedc-relay +WorkingDirectory=/opt/nodedc-b2-vps +Environment=DEVICE_EDGE_RELAY_HEALTH_HOST=127.0.0.1 +Environment=DEVICE_EDGE_RELAY_HEALTH_PORT=18221 +Environment=DEVICE_EDGE_RELAY_INGRESS_ENABLED=true +Environment=DEVICE_EDGE_RELAY_TCP_HOST=0.0.0.0 +Environment=DEVICE_EDGE_RELAY_TCP_PORT=9921 +Environment=DEVICE_EDGE_RELAY_UPSTREAM_HOST=127.0.0.1 +Environment=DEVICE_EDGE_RELAY_UPSTREAM_PORT=19921 +Environment=DEVICE_EDGE_RELAY_MAX_SESSIONS=128 +Environment=DEVICE_EDGE_RELAY_MAX_SESSIONS_PER_ADDRESS=16 +Environment=DEVICE_EDGE_RELAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS=60 +Environment=DEVICE_EDGE_RELAY_MAX_TRACKED_SOURCE_ADDRESSES=4096 +Environment=DEVICE_EDGE_RELAY_MAX_BYTES_PER_DIRECTION=67108864 +Environment=DEVICE_EDGE_RELAY_SESSION_TIMEOUT_MS=300000 +Environment=DEVICE_EDGE_RELAY_SOURCE_POLICY=public-ipv4-only +ExecStart=/opt/nodedc-b2-vps/runtime/node/bin/node /opt/nodedc-b2-vps/services/device-edge-relay/src/server.mjs +Restart=always +RestartSec=3s +NoNewPrivileges=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectSystem=strict +ProtectHome=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +LockPersonality=yes +MemoryDenyWriteExecute=no +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +SystemCallArchitectures=native +MemoryMax=192M +TasksMax=64 +LimitNOFILE=4096 +UMask=0077 + +[Install] +WantedBy=multi-user.target diff --git a/device-plane/vps/systemd/nodedc-b2-tailscaled.service b/device-plane/vps/systemd/nodedc-b2-tailscaled.service new file mode 100644 index 0000000..5e6fc42 --- /dev/null +++ b/device-plane/vps/systemd/nodedc-b2-tailscaled.service @@ -0,0 +1,36 @@ +[Unit] +Description=NODE.DC B2 VPS private Tailscale transport +After=network-online.target +Wants=network-online.target + +[Service] +Type=notify +User=nodedc-edge +Group=nodedc-edge +RuntimeDirectory=nodedc-b2-vps +RuntimeDirectoryMode=0750 +StateDirectory=nodedc-b2-vps/tailscale +StateDirectoryMode=0700 +ExecStart=/opt/nodedc-b2-vps/runtime/tailscale/tailscaled --state=/var/lib/nodedc-b2-vps/tailscale/tailscaled.state --socket=/run/nodedc-b2-vps/tailscaled.sock --tun=userspace-networking --socks5-server=127.0.0.1:1055 +Restart=always +RestartSec=3s +NoNewPrivileges=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectSystem=strict +ProtectHome=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +LockPersonality=yes +MemoryDenyWriteExecute=no +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +SystemCallArchitectures=native +MemoryMax=160M +TasksMax=96 +LimitNOFILE=8192 +UMask=0077 + +[Install] +WantedBy=multi-user.target diff --git a/infra/deploy-runner/build-device-edge-vps-artifact.mjs b/infra/deploy-runner/build-device-edge-vps-artifact.mjs new file mode 100644 index 0000000..808255d --- /dev/null +++ b/infra/deploy-runner/build-device-edge-vps-artifact.mjs @@ -0,0 +1,267 @@ +#!/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 { basename, 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 runtimeCache = resolve( + process.env.NODEDC_DEVICE_EDGE_VPS_RUNTIME_DIR || "/tmp", +); + +const [phase, patchId, ...extra] = process.argv.slice(2); +if ( + extra.length + || !["foundation", "backhaul", "relay"].includes(phase) + || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId || "") +) { + throw new Error( + "usage: build-device-edge-vps-artifact.mjs ", + ); +} + +const supersededTransportPhases = new Set(["backhaul", "relay"]); +if ( + supersededTransportPhases.has(phase) + && process.env.NODEDC_ALLOW_SUPERSEDED_TRANSPORT !== "test-only" +) { + throw new Error("vps_initiated_transport_frozen:ADR-0001"); +} + +const nodeArchive = "node-v22.23.2-linux-x64.tar.xz"; +const tailscaleArchive = "tailscale_1.102.2_amd64.tgz"; +const runtimeDigests = new Map([ + [nodeArchive, "d60acfe00a2932254bb0ad20e01b0d74397a0875595de719654b214f4b03f307"], + [tailscaleArchive, "ad2cde12f8de95f7b93a1e0401e652291c603d42b9d60a33fb1741eb38ab04d8"], +]); + +const entriesByPhase = { + foundation: [ + "vps/config/00-nodedc-b2-vps.conf", + "vps/config/nftables-foundation.conf", + "vps/systemd/nodedc-b2-tailscaled.service", + "deployment/device-edge-vps-foundation-v1.json", + `vendor/${nodeArchive}`, + `vendor/${tailscaleArchive}`, + ], + backhaul: [ + "vps/config/backhaul_ssh_config", + "vps/systemd/nodedc-b2-backhaul.service", + "deployment/device-edge-vps-backhaul-v1.json", + ], + relay: [ + "vps/config/nftables-relay.conf", + "vps/systemd/nodedc-b2-relay.service", + "services/device-edge-relay/src", + "deployment/device-edge-vps-relay-v1.json", + ], +}; +const entries = entriesByPhase[phase]; +const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]); +const stage = await mkdtemp(join(tmpdir(), `nodedc-device-edge-vps-${phase}-`)); +const payload = join(stage, "payload"); +const target = join( + artifactDir, + `nodedc-device-edge-vps-${patchId}.tgz`, +); + +await assertBoundary(); + +try { + await mkdir(payload, { recursive: true }); + for (const entry of entries) { + if (entry.startsWith("vendor/")) { + const name = basename(entry); + const source = resolve(runtimeCache, name); + const actual = createHash("sha256").update(await readFile(source)).digest("hex"); + if (actual !== runtimeDigests.get(name)) { + throw new Error(`runtime_digest_mismatch:${name}:${actual}`); + } + await mkdir(dirname(join(payload, entry)), { recursive: true }); + await cp(source, join(payload, entry), { force: true }); + continue; + } + await copySafe(resolve(sourceRoot, entry), join(payload, entry)); + } + + await writeFile( + join(stage, "manifest.env"), + `id=${patchId}\ncomponent=device-edge-vps\ntype=app-overlay\n`, + "utf8", + ); + await writeFile(join(stage, "files.txt"), `${entries.join("\n")}\n`, "utf8"); + await mkdir(artifactDir, { recursive: true }); + + const tar = spawnSync( + "python3", + ["-c", canonicalTarScript(), target, stage], + { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 }, + ); + if (tar.status !== 0) { + throw new Error(`tar_failed:${tar.stderr || tar.stdout}`); + } + + const bytes = await readFile(target); + const digest = createHash("sha256").update(bytes).digest("hex"); + console.log(JSON.stringify({ + ok: true, + patchId, + phase, + artifact: target, + sha256: digest, + size: bytes.length, + component: "device-edge-vps", + entries, + publicIngress: phase === "relay" ? "tcp/9921" : "disabled", + commandTransport: "disabled", + gelios: "untouched", + }, null, 2)); +} finally { + await rm(stage, { recursive: true, force: true }); +} + +async function assertBoundary() { + const descriptorPath = resolve( + sourceRoot, + `deployment/device-edge-vps-${phase}-v1.json`, + ); + const descriptor = JSON.parse(await readFile(descriptorPath, "utf8")); + if ( + descriptor.component !== "device-edge-vps" + || descriptor.runtimeHost !== "koffyvngij" + || descriptor.commandTransport !== "disabled" + || descriptor.gelios !== "untouched" + || !String(descriptor.rollback || "").length + ) { + throw new Error(`descriptor_boundary_mismatch:${phase}`); + } + + const selectedText = await Promise.all( + entries + .filter((entry) => !entry.startsWith("vendor/") && !entry.endsWith("/src")) + .map((entry) => readFile(resolve(sourceRoot, entry), "utf8")), + ); + const combined = selectedText.join("\n"); + for (const forbidden of [ + "PRIVATE KEY", + "AuthKey", + "TS_AUTHKEY", + "PasswordAuthentication yes", + "commandTransport\": \"enabled", + "device.dc.ru", + ]) { + if (combined.includes(forbidden)) { + throw new Error(`vps_boundary_violation:${forbidden}`); + } + } + + if (phase === "foundation") { + for (const required of [ + "PermitRootLogin prohibit-password", + "PasswordAuthentication no", + "AllowTcpForwarding no", + "policy drop", + "tcp dport 22", + "--tun=userspace-networking", + "--socks5-server=127.0.0.1:1055", + ]) { + if (!combined.includes(required)) { + throw new Error(`foundation_boundary_missing:${required}`); + } + } + if (combined.includes("tcp dport 9921")) { + throw new Error("foundation_must_not_open_9921"); + } + } + if (phase === "backhaul") { + for (const required of [ + "\"runtimeUser\": \"nodedc-backhaul\"", + "User=nodedc-backhaul", + "HostName 100.109.216.21", + "Port 2222", + "StrictHostKeyChecking yes", + "LocalForward 127.0.0.1:19921 127.0.0.1:9921", + "ProxyCommand /usr/bin/nc -X 5 -x 127.0.0.1:1055", + "MemoryMax=64M", + ]) { + if (!combined.includes(required)) { + throw new Error(`backhaul_boundary_missing:${required}`); + } + } + } + if (phase === "relay") { + for (const required of [ + "\"runtimeUser\": \"nodedc-relay\"", + "User=nodedc-relay", + "tcp dport 9921", + "DEVICE_EDGE_RELAY_UPSTREAM_HOST=127.0.0.1", + "DEVICE_EDGE_RELAY_UPSTREAM_PORT=19921", + "DEVICE_EDGE_RELAY_SOURCE_POLICY=public-ipv4-only", + "MemoryMax=192M", + ]) { + if (!combined.includes(required)) { + throw new Error(`relay_boundary_missing:${required}`); + } + } + } +} + +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); + } +} diff --git a/infra/deploy-runner/build-device-plane-backhaul-vps-enrollment-artifact.mjs b/infra/deploy-runner/build-device-plane-backhaul-vps-enrollment-artifact.mjs new file mode 100644 index 0000000..27dcc61 --- /dev/null +++ b/infra/deploy-runner/build-device-plane-backhaul-vps-enrollment-artifact.mjs @@ -0,0 +1,160 @@ +#!/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-backhaul-vps-enrollment-v1.json"; +const artifactDir = resolve( + process.env.NODEDC_DEPLOY_ARTIFACT_DIR + || resolve(scriptDir, "../deploy-artifacts"), +); +const [ + patchId = "device-plane-backhaul-vps-enrollment-20260806-001", + ...extra +] = process.argv.slice(2); + +if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) { + throw new Error( + "usage: build-device-plane-backhaul-vps-enrollment-artifact.mjs [patch-id]", + ); +} + +if (process.env.NODEDC_ALLOW_SUPERSEDED_TRANSPORT !== "test-only") { + throw new Error("vps_initiated_transport_frozen:ADR-0001"); +} + +const files = [descriptorRelative]; +const stage = await mkdtemp( + join(tmpdir(), "nodedc-device-plane-vps-enrollment-"), +); +const payload = join(stage, "payload"); +const target = join( + artifactDir, + `nodedc-device-plane-${patchId}.tgz`, +); + +await assertDescriptor(); + +try { + const source = resolve(sourceRoot, descriptorRelative); + const sourceStat = await lstat(source); + if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) { + throw new Error("device_plane_vps_enrollment_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: 32 * 1024 * 1024 }, + ); + if (tar.status !== 0) { + throw new Error(`tar_failed:${tar.stderr || tar.stdout}`); + } + + const bytes = await readFile(target); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + console.log(JSON.stringify({ + ok: true, + patchId, + artifact: target, + sha256, + component: "device-plane", + transition: "rotate-backhaul-client-mini-to-vps", + entries: files, + build: [], + services: ["device-backhaul-target"], + preservedRuntime: [ + "device-control-core", + "device-gateway", + "device-postgres", + "nodedc-device-plane-postgres-data", + "Tailscale Serve", + "Gelios", + ], + publicIngress: "disabled", + commandTransport: "disabled", + runtimeKeyMaterial: "external-enrollment-only", + rollback: "restore-previous-authorized-key-and-recreate-target", + }, null, 2)); +} finally { + await rm(stage, { recursive: true, force: true }); +} + +async function assertDescriptor() { + const descriptor = JSON.parse(await readFile( + resolve(sourceRoot, descriptorRelative), + "utf8", + )); + if ( + descriptor.schemaVersion + !== "nodedc.device-plane.backhaul-vps-enrollment.v1" + || descriptor.mode !== "rotate-backhaul-client-mini-to-vps" + || descriptor.predecessorPatchId + !== "device-plane-backhaul-target-tailnet-serve-20260804-002" + || descriptor.predecessorArtifactSha256 + !== "219408705dd4d80a962ed00eeb53a69df0b9ab6458443734d5c9cd1d1f795eba" + || descriptor.nextKeyFingerprint + !== "SHA256:HHTiDYiCRxSiKjBLCip6JMSzGfLGrDz5g8SIkosJcVw" + || descriptor.commandTransport !== "disabled" + || descriptor.gelios !== "untouched" + || descriptor.edgePublicIngress !== "disabled" + ) { + throw new Error("device_plane_vps_enrollment_descriptor_mismatch"); + } + const text = JSON.stringify(descriptor); + for (const forbidden of [ + "PRIVATE KEY", + "authorized_keys", + "TS_AUTHKEY", + "password", + ]) { + if (text.includes(forbidden)) { + throw new Error(`device_plane_vps_enrollment_boundary:${forbidden}`); + } + } +} + +function canonicalTarScript() { + return [ + "import gzip,io,pathlib,sys,tarfile", + "root=pathlib.Path(sys.argv[2])", + "with open(sys.argv[1],'wb') as out:", + " with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:", + " with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:", + " for top in ('manifest.env','files.txt','payload'):", + " p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])", + " for x in paths:", + " info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())", + " info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644", + " with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)", + ].join("\n"); +} diff --git a/infra/deploy-runner/nodedc-b2-vps-deploy b/infra/deploy-runner/nodedc-b2-vps-deploy new file mode 100755 index 0000000..23f9e72 --- /dev/null +++ b/infra/deploy-runner/nodedc-b2-vps-deploy @@ -0,0 +1,1181 @@ +#!/usr/bin/env python3 +"""Canonical data-only deploy runner for the Robot2B public Device Edge VPS.""" + +from __future__ import annotations + +import hashlib +import json +import os +import pwd +import re +import shutil +import socket +import subprocess +import sys +import tarfile +import tempfile +import time +import urllib.request +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath + + +RUNNER_PATH = Path("/usr/local/sbin/nodedc-b2-vps-deploy") +LIVE_ROOT = Path("/opt/nodedc-b2-vps") +INBOX_ROOT = Path("/var/lib/nodedc-b2-vps-deploy/inbox") +STATE_ROOT = Path("/var/lib/nodedc-b2-vps-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" + +COMPONENT = "device-edge-vps" +ARTIFACT_TYPE = "app-overlay" +PATCH_ID_RE = re.compile(r"^[A-Za-z0-9._-]{1,96}$") +MAX_ARTIFACT_BYTES = 128 * 1024 * 1024 + +RUNTIME_HOST = "koffyvngij" +PUBLIC_IPV4 = "155.212.211.15" +SERVICE_USER = "nodedc-edge" +SERVICE_GROUP = "nodedc-edge" +BACKHAUL_USER = "nodedc-backhaul" +BACKHAUL_GROUP = "nodedc-backhaul" +RELAY_USER = "nodedc-relay" +RELAY_GROUP = "nodedc-relay" +TAILSCALE_REQUIRED_TAG = "tag:device-edge-vps" +MANAGEMENT_KEY_FINGERPRINT = ( + "SHA256:DYYy1E3DaxIQGC0jnsW6SP7gXdBHUy3A1zn4pvgVUEw" +) +SERVER_HOST_KEY_FINGERPRINT = ( + "SHA256:mhqNn2S6zstkYL7VFdvt3SYHv1nLjB4J7/s57RrKG6w" +) + +NODE_VERSION = "22.23.2" +NODE_ARCHIVE = "node-v22.23.2-linux-x64.tar.xz" +NODE_ARCHIVE_SHA256 = ( + "d60acfe00a2932254bb0ad20e01b0d74397a0875595de719654b214f4b03f307" +) +TAILSCALE_VERSION = "1.102.2" +TAILSCALE_ARCHIVE = "tailscale_1.102.2_amd64.tgz" +TAILSCALE_ARCHIVE_SHA256 = ( + "ad2cde12f8de95f7b93a1e0401e652291c603d42b9d60a33fb1741eb38ab04d8" +) + +NODE_BIN = LIVE_ROOT / "runtime/node/bin/node" +TAILSCALE_BIN = LIVE_ROOT / "runtime/tailscale/tailscale" +TAILSCALED_BIN = LIVE_ROOT / "runtime/tailscale/tailscaled" +TAILSCALE_SOCKET = Path("/run/nodedc-b2-vps/tailscaled.sock") +TAILSCALE_STATE = Path("/var/lib/nodedc-b2-vps/tailscale/tailscaled.state") +TRUST_ROOT = Path("/var/lib/nodedc-b2-vps/trust") +BACKHAUL_PRIVATE_KEY = TRUST_ROOT / "backhaul_ed25519" +BACKHAUL_PUBLIC_KEY = TRUST_ROOT / "backhaul_ed25519.pub" +BACKHAUL_KNOWN_HOSTS = TRUST_ROOT / "backhaul_known_hosts" +BACKHAUL_KEY_COMMENT = "nodedc-device-edge-vps-backhaul" + +BACKHAUL_TARGET_IP = "100.109.216.21" +BACKHAUL_TARGET_PORT = 2222 +BACKHAUL_TARGET_HOST_KEY = ( + "ssh-ed25519 " + "AAAAC3NzaC1lZDI1NTE5AAAAIJsmoyS+0Tbhz9VXxrSxXwNMFpfbdTckCilObOnKdlEc" +) +BACKHAUL_TARGET_FINGERPRINT = ( + "SHA256:QERJ5CIUXRj0nLChGT6HMtoX+WTaeaEY5ZgaWqT8d30" +) + +SSHD_DROPIN = Path("/etc/ssh/sshd_config.d/00-nodedc-b2-vps.conf") +NFTABLES_CONFIG = Path("/etc/nftables.conf") +TAILSCALE_UNIT = Path("/etc/systemd/system/nodedc-b2-tailscaled.service") +BACKHAUL_UNIT = Path("/etc/systemd/system/nodedc-b2-backhaul.service") +RELAY_UNIT = Path("/etc/systemd/system/nodedc-b2-relay.service") + +FOUNDATION_ENTRIES = ( + "vps/config/00-nodedc-b2-vps.conf", + "vps/config/nftables-foundation.conf", + "vps/systemd/nodedc-b2-tailscaled.service", + "deployment/device-edge-vps-foundation-v1.json", + f"vendor/{NODE_ARCHIVE}", + f"vendor/{TAILSCALE_ARCHIVE}", +) +BACKHAUL_ENTRIES = ( + "vps/config/backhaul_ssh_config", + "vps/systemd/nodedc-b2-backhaul.service", + "deployment/device-edge-vps-backhaul-v1.json", +) +RELAY_ENTRIES = ( + "vps/config/nftables-relay.conf", + "vps/systemd/nodedc-b2-relay.service", + "services/device-edge-relay/src", + "deployment/device-edge-vps-relay-v1.json", +) + +PHASE_ENTRIES = { + "foundation": FOUNDATION_ENTRIES, + "backhaul": BACKHAUL_ENTRIES, + "relay": RELAY_ENTRIES, +} + +SUPERSEDED_TRANSPORT_PHASES = frozenset({"backhaul", "relay"}) + +PHASE_FILE_SHA256 = { + "foundation": { + "vps/config/00-nodedc-b2-vps.conf": + "cc94d0579f85d0af9746b9ce760bc72980f4a22fb59027e1f5f9c7bf3aaebd64", + "vps/config/nftables-foundation.conf": + "4d44f902d8d98d1aa8506fca9d9582e700f6424def2b1d667ab2cd5a5ee84934", + "vps/systemd/nodedc-b2-tailscaled.service": + "de147d29bc1759f56533d31058993df55e1200973f2899903bdbf3d13ff579da", + "deployment/device-edge-vps-foundation-v1.json": + "317c98b42520fff3238275908482de7aa611b4ee41c6b1f8062abd2730ab072a", + f"vendor/{NODE_ARCHIVE}": NODE_ARCHIVE_SHA256, + f"vendor/{TAILSCALE_ARCHIVE}": TAILSCALE_ARCHIVE_SHA256, + }, + "backhaul": { + "vps/config/backhaul_ssh_config": + "ff1a3575b5a55a56b08a8642aa820d1f8e5f07e0e7698fdd0e01a142452674d4", + "vps/systemd/nodedc-b2-backhaul.service": + "64c26cad21cc17675c67ae4a57fc43b129065a8d22fda894648340b310d3aa8c", + "deployment/device-edge-vps-backhaul-v1.json": + "d7bac0132f7940cf8d4c59f1ae0d627c4921b63fa4728c31d2f95cfa35f21cf5", + }, + "relay": { + "vps/config/nftables-relay.conf": + "d99290bf825a3aff2ad8f9dbc6502ba6c32295f0cabd3f71c3d9b94c06373259", + "vps/systemd/nodedc-b2-relay.service": + "12a927a4cb42016229ac438f6e75969e1bf015037f2b5b0a60ab0591bdf424d2", + "services/device-edge-relay/src/runtime.mjs": + "21e83678980aa61127bf9f3d77982dd485c4aaae208c43818db7bb1cc150b83a", + "services/device-edge-relay/src/server.mjs": + "1b99ec944f1d3fbadded045b159f08624e2829620cec39a97f6b4b8cdcd2be22", + "deployment/device-edge-vps-relay-v1.json": + "cb3c2fff4878021783efef4f4a4d1ec31c8e3d8e6325fe33657fec22bec20165", + }, +} + + +class DeployError(RuntimeError): + pass + + +def die(message: str) -> None: + raise DeployError(message) + + +def run(command, *, check=True, capture=True, timeout=180, cwd=None): + result = subprocess.run( + [str(value) for value in command], + check=False, + capture_output=capture, + text=True, + timeout=timeout, + cwd=str(cwd) if cwd else None, + ) + 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 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 assert_root(): + if os.geteuid() != 0: + die("nodedc-b2-vps-deploy must run as root") + + +def assert_regular_nonsymlink(path: Path, label: str): + try: + path_stat = path.lstat() + except FileNotFoundError: + die(f"{label} is missing") + if path.is_symlink() or not path.is_file(): + die(f"{label} must be a regular non-symlink file") + return path_stat + + +def assert_directory_nonsymlink(path: Path, label: str): + try: + path_stat = path.lstat() + except FileNotFoundError: + die(f"{label} is missing") + if path.is_symlink() or not path.is_dir(): + die(f"{label} must be a non-symlink directory") + return path_stat + + +def assert_executable_command_path(path: Path, label: str): + try: + resolved = path.resolve(strict=True) + resolved_stat = resolved.stat() + except (FileNotFoundError, OSError, RuntimeError): + die(f"{label} is missing or has an invalid symlink chain") + if not resolved.is_file(): + die(f"{label} must resolve to a regular file") + if not (resolved_stat.st_mode & 0o111): + die(f"{label} is not executable") + return resolved + + +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 phase_from_entries(entries): + for phase, expected in PHASE_ENTRIES.items(): + if tuple(entries) == expected: + return phase + die("VPS artifact file selection mismatch") + + +def validate_payload(payload: Path, phase: str): + actual = { + path.relative_to(payload).as_posix(): sha256_file(path) + for path in payload.rglob("*") + if path.is_file() + } + if actual != PHASE_FILE_SHA256[phase]: + die(f"VPS {phase} payload digest set mismatch") + descriptor = json.loads( + ( + payload + / f"deployment/device-edge-vps-{phase}-v1.json" + ).read_text(encoding="utf-8") + ) + if ( + descriptor.get("component") != COMPONENT + or descriptor.get("runtimeHost") != RUNTIME_HOST + or descriptor.get("commandTransport") != "disabled" + or descriptor.get("gelios") != "untouched" + or not descriptor.get("rollback") + ): + die(f"VPS {phase} descriptor mismatch") + return descriptor + + +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 VPS 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 len(entries) != len(set(entries)): + die("artifact files list contains duplicates") + phase = phase_from_entries(entries) + payload = extraction_root / "payload" + descriptor = validate_payload(payload, phase) + return { + "manifest": manifest, + "entries": entries, + "phase": phase, + "payload": payload, + "descriptor": descriptor, + "sha256": sha256_file(artifact), + "artifact": artifact, + } + + +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("VPS patch id is terminally recorded") + if any(record.get("sha256") == artifact_sha256 for record in records): + die("VPS artifact digest is terminally recorded") + + +def applied_phase_record(phase: str): + matches = [ + record + for record in journal_records(APPLIED_JOURNAL) + if record.get("phase") == phase and record.get("status") == "ok" + ] + if len(matches) != 1: + die(f"exactly one accepted {phase} predecessor is required") + return matches[0] + + +def assert_host_identity(): + if socket.gethostname() != RUNTIME_HOST: + die("VPS runtime hostname mismatch") + os_release = Path("/etc/os-release").read_text(encoding="utf-8") + if 'VERSION_ID="24.04"' not in os_release or 'ID=ubuntu' not in os_release: + die("VPS operating system predecessor mismatch") + address = run(["/usr/sbin/ip", "-4", "-brief", "address", "show", "dev", "eth0"]).stdout + if f"{PUBLIC_IPV4}/32" not in address or "UP" not in address: + die("VPS public interface predecessor mismatch") + + +def port_is_open(host: str, port: int, timeout=1.5): + try: + connection = socket.create_connection((host, port), timeout=timeout) + except OSError: + return False + connection.close() + return True + + +def assert_port_closed(port: int): + if port_is_open("127.0.0.1", port): + die(f"unexpected loopback listener is open: {port}") + + +def assert_management_key(): + result = run(["/usr/bin/ssh-keygen", "-lf", "/root/.ssh/authorized_keys"]) + if MANAGEMENT_KEY_FINGERPRINT not in result.stdout: + die("verified Mac management key is missing") + + +def source_file_state(phase: str): + expected = PHASE_FILE_SHA256[phase] + actual = {} + for relative, digest in expected.items(): + path = LIVE_ROOT / relative + assert_regular_nonsymlink(path, f"installed {phase} file {relative}") + actual[relative] = sha256_file(path) + if actual[relative] != digest: + die(f"installed {phase} source drift: {relative}") + return actual + + +def systemctl(*args, check=True): + return run(["/usr/bin/systemctl", *args], check=check) + + +def service_active(name: str): + return systemctl("is-active", name, check=False).returncode == 0 + + +def tailscale_status(): + result = run( + [str(TAILSCALE_BIN), f"--socket={TAILSCALE_SOCKET}", "status", "--json"], + check=False, + ) + if result.returncode != 0: + return {"BackendState": "Unavailable", "Error": (result.stderr or result.stdout).strip()} + try: + return json.loads(result.stdout) + except json.JSONDecodeError: + die("Tailscale status JSON is invalid") + + +def current_phase_preflight(phase: str): + assert_host_identity() + assert_management_key() + if phase == "foundation": + if LIVE_ROOT.exists() or TRUST_ROOT.exists(): + die("VPS foundation live/runtime root already exists") + if any(path.exists() for path in (SSHD_DROPIN, TAILSCALE_UNIT, BACKHAUL_UNIT, RELAY_UNIT)): + die("VPS foundation system path already exists") + for port in (1055, 18221, 19921, 9921): + assert_port_closed(port) + return {"predecessor": "clean-ubuntu-24.04.4"} + + applied_phase_record("foundation") + source_file_state("foundation") + validate_foundation_runtime( + require_running_tailnet=phase in {"backhaul", "relay"}, + expected_key_user=BACKHAUL_USER if phase == "relay" else SERVICE_USER, + ) + if phase == "backhaul": + for tool in (Path("/usr/bin/ssh"), Path("/usr/bin/nc")): + assert_executable_command_path( + tool, + f"VPS backhaul prerequisite {tool}", + ) + if BACKHAUL_UNIT.exists() or (LIVE_ROOT / BACKHAUL_ENTRIES[-1]).exists(): + die("VPS backhaul target path already exists") + if user_exists(BACKHAUL_USER): + die("VPS backhaul runtime user already exists") + assert_port_closed(19921) + return {"predecessor": "accepted-foundation"} + + applied_phase_record("backhaul") + source_file_state("backhaul") + validate_backhaul_runtime() + if RELAY_UNIT.exists() or (LIVE_ROOT / RELAY_ENTRIES[-1]).exists(): + die("VPS relay target path already exists") + if user_exists(RELAY_USER): + die("VPS relay runtime user already exists") + assert_port_closed(9921) + assert_port_closed(18221) + return {"predecessor": "accepted-backhaul"} + + +def preflight(loaded): + if loaded["phase"] in SUPERSEDED_TRANSPORT_PHASES: + die("vps_initiated_transport_frozen:ADR-0001") + assert_new_identity(loaded["manifest"]["id"], loaded["sha256"]) + return current_phase_preflight(loaded["phase"]) + + +def ensure_state_directories(): + for path in ( + INBOX_ROOT, + 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("VPS 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 backup_targets_for_phase(phase: str): + common = [LIVE_ROOT / entry for entry in PHASE_ENTRIES[phase]] + if phase == "foundation": + return common + [SSHD_DROPIN, NFTABLES_CONFIG, TAILSCALE_UNIT] + if phase == "backhaul": + return common + [BACKHAUL_UNIT, BACKHAUL_KNOWN_HOSTS] + return common + [RELAY_UNIT, NFTABLES_CONFIG] + + +def path_backup_relative(path: Path): + return path.as_posix().lstrip("/") + + +def create_backup(patch_id: str, phase: 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 source in backup_targets_for_phase(phase): + relative = path_backup_relative(source) + target = backup / "filesystem" / relative + if not source.exists() and not source.is_symlink(): + absent.append(relative) + continue + if source.is_symlink(): + die(f"backup target symlink rejected: {source}") + 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) + nft_rules = run(["/usr/sbin/nft", "list", "ruleset"], check=False).stdout + (backup / "nft-ruleset-before.nft").write_text(nft_rules, encoding="utf-8") + metadata = { + "schemaVersion": "nodedc.device-edge-vps.backup.v1", + "patch": patch_id, + "phase": phase, + "present": present, + "absent": absent, + "serviceUserExisted": user_exists(), + "serviceUsersExisted": { + name: user_exists(name) + for name in (SERVICE_USER, BACKHAUL_USER, RELAY_USER) + }, + "services": { + name: { + "active": service_active(name), + "enabled": systemctl("is-enabled", name, check=False).returncode == 0, + } + for name in ( + "nftables.service", + "ufw.service", + "fail2ban.service", + "nodedc-b2-tailscaled.service", + "nodedc-b2-backhaul.service", + "nodedc-b2-relay.service", + ) + }, + } + (backup / "backup.json").write_text( + json.dumps(metadata, sort_keys=True, indent=2) + "\n", + encoding="utf-8", + ) + return backup_id, backup + + +def publish_payload(payload: Path, entries): + for relative in entries: + source = payload / relative + target = LIVE_ROOT / relative + if target.exists() or target.is_symlink(): + if target.is_symlink(): + die(f"live payload target symlink rejected: {target}") + 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) + for path in LIVE_ROOT.rglob("*"): + if path.is_symlink(): + die(f"published live source contains symlink: {path}") + if path.is_dir(): + os.chmod(path, 0o755) + else: + os.chmod(path, 0o644) + os.chown(LIVE_ROOT, 0, 0) + + +def restore_backup(backup: Path, phase: str): + metadata = json.loads((backup / "backup.json").read_text(encoding="utf-8")) + if metadata.get("phase") != phase: + die("rollback backup phase mismatch") + targets = backup_targets_for_phase(phase) + for target in targets: + if target.exists() or target.is_symlink(): + if target.is_symlink() or target.is_file(): + target.unlink() + else: + shutil.rmtree(target) + for relative in metadata["present"]: + source = backup / "filesystem" / relative + target = Path("/") / 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 install_file(source: Path, target: Path, mode=0o644): + assert_regular_nonsymlink(source, f"install source {source}") + target.parent.mkdir(parents=True, exist_ok=True) + temporary = target.with_name(f".{target.name}.installing") + if temporary.exists() or temporary.is_symlink(): + die(f"install staging path exists: {temporary}") + shutil.copy2(source, temporary, follow_symlinks=False) + os.chown(temporary, 0, 0) + os.chmod(temporary, mode) + os.replace(temporary, target) + + +def user_exists(name=SERVICE_USER): + try: + pwd.getpwnam(name) + return True + except KeyError: + return False + + +def ensure_service_user(name=SERVICE_USER, home_dir="/var/lib/nodedc-b2-vps"): + if not user_exists(name): + run([ + "/usr/sbin/useradd", + "--system", + "--user-group", + "--home-dir", + home_dir, + "--shell", + "/usr/sbin/nologin", + name, + ]) + account = pwd.getpwnam(name) + if account.pw_shell != "/usr/sbin/nologin": + die("VPS service user shell mismatch") + return account + + +def extract_vendor_binary(archive: Path, member_name: str, target: Path, mode=0o755): + with tarfile.open(archive, "r:*") as package: + try: + member = package.getmember(member_name) + except KeyError: + die(f"vendor binary member missing: {member_name}") + if not member.isfile() or member.issym() or member.islnk(): + die(f"vendor binary member unsafe: {member_name}") + source = package.extractfile(member) + if source is None: + die(f"vendor binary cannot be read: {member_name}") + target.parent.mkdir(parents=True, exist_ok=True) + temporary = target.with_name(f".{target.name}.installing") + with temporary.open("wb") as handle: + shutil.copyfileobj(source, handle) + os.chown(temporary, 0, 0) + os.chmod(temporary, mode) + os.replace(temporary, target) + + +def ensure_backhaul_key(account): + TRUST_ROOT.mkdir(parents=True, exist_ok=True, mode=0o700) + os.chown(TRUST_ROOT, account.pw_uid, account.pw_gid) + os.chmod(TRUST_ROOT, 0o700) + if not BACKHAUL_PRIVATE_KEY.exists(): + run([ + "/usr/bin/ssh-keygen", + "-q", + "-t", + "ed25519", + "-N", + "", + "-C", + BACKHAUL_KEY_COMMENT, + "-f", + str(BACKHAUL_PRIVATE_KEY), + ]) + for path, mode in ((BACKHAUL_PRIVATE_KEY, 0o400), (BACKHAUL_PUBLIC_KEY, 0o444)): + stat_result = assert_regular_nonsymlink(path, f"backhaul key {path.name}") + if stat_result.st_size > 2048: + die("backhaul key file is unexpectedly large") + os.chown(path, account.pw_uid, account.pw_gid) + os.chmod(path, mode) + fingerprint = run(["/usr/bin/ssh-keygen", "-lf", str(BACKHAUL_PUBLIC_KEY)]).stdout.strip() + if "ED25519" not in fingerprint or BACKHAUL_KEY_COMMENT not in fingerprint: + die("backhaul public key fingerprint mismatch") + return fingerprint + + +def assign_backhaul_trust(account): + assert_directory_nonsymlink(TRUST_ROOT, "backhaul trust root") + os.chown(TRUST_ROOT, account.pw_uid, account.pw_gid) + os.chmod(TRUST_ROOT, 0o700) + for path, mode in ((BACKHAUL_PRIVATE_KEY, 0o400), (BACKHAUL_PUBLIC_KEY, 0o444)): + assert_regular_nonsymlink(path, f"backhaul trust {path.name}") + os.chown(path, account.pw_uid, account.pw_gid) + os.chmod(path, mode) + if BACKHAUL_KNOWN_HOSTS.exists(): + assert_regular_nonsymlink(BACKHAUL_KNOWN_HOSTS, "backhaul known_hosts") + os.chown(BACKHAUL_KNOWN_HOSTS, account.pw_uid, account.pw_gid) + os.chmod(BACKHAUL_KNOWN_HOSTS, 0o444) + + +def apply_nftables(source: Path): + install_file(source, NFTABLES_CONFIG, 0o644) + run(["/usr/sbin/nft", "-c", "-f", str(NFTABLES_CONFIG)]) + run(["/usr/sbin/nft", "-f", str(NFTABLES_CONFIG)]) + systemctl("enable", "nftables.service") + systemctl("disable", "ufw.service", check=False) + if systemctl("is-enabled", "fail2ban.service", check=False).returncode == 0: + systemctl("restart", "fail2ban.service") + + +def apply_foundation(payload: Path): + account = ensure_service_user() + extract_vendor_binary( + LIVE_ROOT / f"vendor/{NODE_ARCHIVE}", + f"node-v{NODE_VERSION}-linux-x64/bin/node", + NODE_BIN, + ) + extract_vendor_binary( + LIVE_ROOT / f"vendor/{TAILSCALE_ARCHIVE}", + f"tailscale_{TAILSCALE_VERSION}_amd64/tailscale", + TAILSCALE_BIN, + ) + extract_vendor_binary( + LIVE_ROOT / f"vendor/{TAILSCALE_ARCHIVE}", + f"tailscale_{TAILSCALE_VERSION}_amd64/tailscaled", + TAILSCALED_BIN, + ) + ensure_backhaul_key(account) + install_file(LIVE_ROOT / FOUNDATION_ENTRIES[0], SSHD_DROPIN, 0o644) + install_file(LIVE_ROOT / FOUNDATION_ENTRIES[2], TAILSCALE_UNIT, 0o644) + run(["/usr/sbin/sshd", "-t"]) + apply_nftables(LIVE_ROOT / FOUNDATION_ENTRIES[1]) + systemctl("daemon-reload") + systemctl("enable", "--now", "nodedc-b2-tailscaled.service") + systemctl("reload", "ssh.service") + validate_foundation_runtime(require_running_tailnet=False) + + +def materialize_known_hosts(account): + line = ( + f"[{BACKHAUL_TARGET_IP}]:{BACKHAUL_TARGET_PORT} " + f"{BACKHAUL_TARGET_HOST_KEY}\n" + ) + TRUST_ROOT.mkdir(parents=True, exist_ok=True, mode=0o700) + temporary = BACKHAUL_KNOWN_HOSTS.with_name(".backhaul_known_hosts.installing") + temporary.write_text(line, encoding="ascii") + os.chown(temporary, account.pw_uid, account.pw_gid) + os.chmod(temporary, 0o444) + os.replace(temporary, BACKHAUL_KNOWN_HOSTS) + fingerprint = run(["/usr/bin/ssh-keygen", "-lf", str(BACKHAUL_KNOWN_HOSTS)]).stdout + if BACKHAUL_TARGET_FINGERPRINT not in fingerprint: + die("backhaul target host key fingerprint mismatch") + + +def apply_backhaul(payload: Path): + account = ensure_service_user(BACKHAUL_USER, "/var/lib/nodedc-b2-vps/backhaul") + assign_backhaul_trust(account) + materialize_known_hosts(account) + install_file(LIVE_ROOT / BACKHAUL_ENTRIES[1], BACKHAUL_UNIT, 0o644) + systemctl("daemon-reload") + systemctl("enable", "--now", "nodedc-b2-backhaul.service") + validate_backhaul_runtime() + + +def apply_relay(payload: Path): + ensure_service_user(RELAY_USER, "/var/lib/nodedc-b2-vps/relay") + install_file(LIVE_ROOT / RELAY_ENTRIES[1], RELAY_UNIT, 0o644) + apply_nftables(LIVE_ROOT / RELAY_ENTRIES[0]) + systemctl("daemon-reload") + systemctl("enable", "--now", "nodedc-b2-relay.service") + validate_relay_runtime() + + +def sshd_effective(): + return run(["/usr/sbin/sshd", "-T"]).stdout.lower() + + +def validate_foundation_runtime(*, require_running_tailnet: bool, expected_key_user=SERVICE_USER): + source_file_state("foundation") + if run([str(NODE_BIN), "--version"]).stdout.strip() != f"v{NODE_VERSION}": + die("Node runtime version mismatch") + tailscale_version = run([str(TAILSCALE_BIN), "version"]).stdout.splitlines()[0].strip() + if tailscale_version != TAILSCALE_VERSION: + die("Tailscale runtime version mismatch") + if not service_active("nodedc-b2-tailscaled.service"): + die("VPS tailscaled service is not active") + effective = sshd_effective() + for required in ( + "permitrootlogin without-password", + "passwordauthentication no", + "kbdinteractiveauthentication no", + "pubkeyauthentication yes", + "x11forwarding no", + "allowagentforwarding no", + "allowtcpforwarding no", + "gatewayports no", + "permittunnel no", + "permituserenvironment no", + "maxauthtries 3", + "logingracetime 20", + ): + if required not in effective: + die(f"VPS effective SSH boundary mismatch: {required}") + nft = run(["/usr/sbin/nft", "list", "table", "inet", "nodedc_b2_vps"]).stdout + if "policy drop" not in nft or "tcp dport 22" not in nft: + die("VPS foundation firewall contract mismatch") + if "tcp dport 9921" in nft: + die("VPS foundation unexpectedly opens B2 ingress") + for port in (18221, 19921, 9921): + assert_port_closed(port) + tailscale_account = pwd.getpwnam(SERVICE_USER) + if tailscale_account.pw_shell != "/usr/sbin/nologin": + die("VPS service user runtime mismatch") + account = pwd.getpwnam(expected_key_user) + if account.pw_shell != "/usr/sbin/nologin": + die("VPS backhaul credential owner shell mismatch") + trust_stat = assert_directory_nonsymlink( + TRUST_ROOT, + "runtime trust root", + ) + if trust_stat.st_uid != account.pw_uid or (trust_stat.st_mode & 0o777) != 0o700: + die("VPS backhaul trust root ownership/mode mismatch") + for path, mode in ((BACKHAUL_PRIVATE_KEY, 0o400), (BACKHAUL_PUBLIC_KEY, 0o444)): + path_stat = assert_regular_nonsymlink(path, f"runtime trust {path.name}") + if path_stat.st_uid != account.pw_uid or (path_stat.st_mode & 0o777) != mode: + die("VPS backhaul key ownership/mode mismatch") + status = tailscale_status() + state = status.get("BackendState") + if require_running_tailnet: + if state != "Running": + die(f"VPS Tailscale node is not enrolled/running: {state}") + self_state = status.get("Self") or {} + if self_state.get("HostName") != "nodedc-b2-vps": + die("VPS Tailscale node name mismatch") + if self_state.get("Online") is not True: + die("VPS Tailscale node is not online") + if sorted(self_state.get("Tags") or []) != [TAILSCALE_REQUIRED_TAG]: + die("VPS Tailscale service tag mismatch") + elif state not in {"NeedsLogin", "Stopped", "Running", "NoState", "Starting"}: + die(f"VPS Tailscale foundation state is unexpected: {state}") + return status + + +def validate_backhaul_runtime(): + validate_foundation_runtime( + require_running_tailnet=True, + expected_key_user=BACKHAUL_USER, + ) + source_file_state("backhaul") + if not service_active("nodedc-b2-backhaul.service"): + die("VPS backhaul service is not active") + if not port_is_open("127.0.0.1", 19921, timeout=5): + die("VPS backhaul local forward is unavailable") + assert_port_closed(9921) + assert_port_closed(18221) + account = pwd.getpwnam(BACKHAUL_USER) + tailscale_account = pwd.getpwnam(SERVICE_USER) + if account.pw_uid == tailscale_account.pw_uid: + die("backhaul and Tailscale runtime identities are not isolated") + known = assert_regular_nonsymlink(BACKHAUL_KNOWN_HOSTS, "backhaul known_hosts") + if known.st_uid != account.pw_uid or (known.st_mode & 0o777) != 0o444: + die("backhaul known_hosts ownership/mode mismatch") + return True + + +def relay_health(): + last_error = None + for _attempt in range(60): + try: + with urllib.request.urlopen("http://127.0.0.1:18221/healthz", timeout=3) as response: + payload = json.loads(response.read(65537).decode("utf-8")) + if response.status == 200: + return payload + except Exception as error: + last_error = str(error) + time.sleep(2) + die(f"VPS relay health timeout: {last_error}") + + +def validate_relay_runtime(): + validate_backhaul_runtime() + source_file_state("relay") + if not service_active("nodedc-b2-relay.service"): + die("VPS relay service is not active") + relay_account = pwd.getpwnam(RELAY_USER) + backhaul_account = pwd.getpwnam(BACKHAUL_USER) + tailscale_account = pwd.getpwnam(SERVICE_USER) + if len({relay_account.pw_uid, backhaul_account.pw_uid, tailscale_account.pw_uid}) != 3: + die("relay, backhaul, and Tailscale runtime identities are not isolated") + health = relay_health() + expected = { + "ok": True, + "service": "nodedc-device-edge-relay", + "ingress": "relay-only", + "protocolInspection": "disabled", + "commandTransport": "disabled", + "sourceAdmission": "public-ipv4-only", + } + for key, value in expected.items(): + if health.get(key) != value: + die(f"VPS relay health contract mismatch: {key}") + if not port_is_open(PUBLIC_IPV4, 9921, timeout=5): + die("VPS public B2 listener is unavailable") + nft = run(["/usr/sbin/nft", "list", "table", "inet", "nodedc_b2_vps"]).stdout + if "policy drop" not in nft or "tcp dport 9921" not in nft: + die("VPS relay firewall contract mismatch") + return health + + +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("VPS artifact archive collision") + os.replace(artifact, destination) + return destination + + +def restore_service_enablement(metadata): + for name, state in metadata.get("services", {}).items(): + if state.get("enabled"): + systemctl("enable", name, check=False) + else: + systemctl("disable", name, check=False) + if state.get("active"): + systemctl("start", name, check=False) + else: + systemctl("stop", name, check=False) + + +def rollback(backup: Path, phase: str): + for service in ( + "nodedc-b2-relay.service", + "nodedc-b2-backhaul.service", + "nodedc-b2-tailscaled.service", + ): + if phase == "foundation" or service != "nodedc-b2-tailscaled.service": + systemctl("disable", "--now", service, check=False) + restore_backup(backup, phase) + metadata = json.loads((backup / "backup.json").read_text(encoding="utf-8")) + systemctl("daemon-reload", check=False) + if NFTABLES_CONFIG.exists(): + run(["/usr/sbin/nft", "-f", str(NFTABLES_CONFIG)], check=False) + else: + rules = backup / "nft-ruleset-before.nft" + if rules.exists() and rules.stat().st_size: + run(["/usr/sbin/nft", "-f", str(rules)], check=False) + restore_service_enablement(metadata) + systemctl("restart", "fail2ban.service", check=False) + run(["/usr/sbin/sshd", "-t"], check=False) + systemctl("reload", "ssh.service", check=False) + users_before = metadata.get("serviceUsersExisted", {}) + if phase == "backhaul": + assign_backhaul_trust(pwd.getpwnam(SERVICE_USER)) + if not users_before.get(BACKHAUL_USER, False) and user_exists(BACKHAUL_USER): + run(["/usr/sbin/userdel", BACKHAUL_USER], check=False) + if phase == "relay": + if not users_before.get(RELAY_USER, False) and user_exists(RELAY_USER): + run(["/usr/sbin/userdel", RELAY_USER], check=False) + if phase == "foundation" and not metadata.get("serviceUserExisted"): + runtime_state_root = Path("/var/lib/nodedc-b2-vps") + if LIVE_ROOT.exists() and not LIVE_ROOT.is_symlink(): + shutil.rmtree(LIVE_ROOT) + if runtime_state_root.exists() and not runtime_state_root.is_symlink(): + shutil.rmtree(runtime_state_root) + if user_exists(): + run(["/usr/sbin/userdel", SERVICE_USER], check=False) + + +def plan_artifact(artifact_argument: str): + assert_root() + artifact = Path(artifact_argument) + with tempfile.TemporaryDirectory(prefix="nodedc-b2-vps-plan-") as directory: + loaded = load_artifact(artifact, Path(directory)) + evidence = preflight(loaded) + phase = loaded["phase"] + print("== plan ==") + print(f"artifact={loaded['artifact'].name}") + print(f"sha256={loaded['sha256']}") + print(f"id={loaded['manifest']['id']}") + print(f"component={COMPONENT}") + print(f"type={ARTIFACT_TYPE}") + print(f"phase={phase}") + print(f"predecessor={evidence['predecessor']}") + print(f"payload_root={LIVE_ROOT}") + print(f"runtime_host={RUNTIME_HOST}") + print(f"public_ipv4={PUBLIC_IPV4}") + print("management_ssh=root-key-only:tcp/22") + print(f"management_key_fingerprint={MANAGEMENT_KEY_FINGERPRINT}") + print(f"server_host_key_fingerprint={SERVER_HOST_KEY_FINGERPRINT}") + if phase == "foundation": + print(f"node_runtime={NODE_VERSION}:sha256:{NODE_ARCHIVE_SHA256}") + print(f"tailscale_runtime={TAILSCALE_VERSION}:sha256:{TAILSCALE_ARCHIVE_SHA256}") + print("firewall=default-deny:public-tcp/22-only") + print("tailscale=enrollment-required-after-deploy-ok") + print("backhaul_key=runner-managed-new-ed25519") + print("public_b2_ingress=disabled") + print("services=nodedc-b2-tailscaled") + print(f"tailscale_runtime_identity={SERVICE_USER}") + elif phase == "backhaul": + print(f"target={BACKHAUL_TARGET_IP}:{BACKHAUL_TARGET_PORT}") + print(f"target_host_key_fingerprint={BACKHAUL_TARGET_FINGERPRINT}") + print("local_forward=127.0.0.1:19921=>127.0.0.1:9921") + print("proxy=tailscale-userspace-socks5:127.0.0.1:1055") + print("public_b2_ingress=disabled") + print("services=nodedc-b2-backhaul") + print(f"backhaul_runtime_identity={BACKHAUL_USER}:private-key-owner") + else: + print("public_b2_ingress=155.212.211.15:9921/tcp") + print("health=127.0.0.1:18221") + print("private_upstream=127.0.0.1:19921") + print("source_admission=public-ipv4-only") + print("services=nodedc-b2-relay") + print(f"relay_runtime_identity={RELAY_USER}:no-credentials") + print("command_transport=disabled") + print("gelios=untouched") + print("dns=unchanged") + print("b2_routes=unchanged") + print("state=new") + print("== files ==") + for entry in loaded["entries"]: + print(f" {entry}") + + +def apply_artifact(artifact_argument: str): + assert_root() + acquire_lock() + loaded = None + backup_id = None + backup = None + try: + with tempfile.TemporaryDirectory(prefix="nodedc-b2-vps-apply-") as directory: + loaded = load_artifact(Path(artifact_argument), Path(directory)) + preflight(loaded) + backup_id, backup = create_backup( + loaded["manifest"]["id"], + loaded["phase"], + ) + publish_payload(loaded["payload"], loaded["entries"]) + if loaded["phase"] == "foundation": + apply_foundation(loaded["payload"]) + elif loaded["phase"] == "backhaul": + apply_backhaul(loaded["payload"]) + else: + apply_relay(loaded["payload"]) + + archived = archive_artifact(loaded["artifact"], APPLIED_ROOT) + record = { + "status": "ok", + "patch": loaded["manifest"]["id"], + "component": COMPONENT, + "phase": loaded["phase"], + "sha256": loaded["sha256"], + "artifact": archived.name, + "backup": backup_id, + "appliedAt": datetime.now(timezone.utc).isoformat(), + } + write_journal(APPLIED_JOURNAL, record) + print( + f"deploy-ok patch={record['patch']} component={COMPONENT} " + f"backup={backup_id}" + ) + except Exception as error: + rollback_status = "not-required" + if backup is not None and loaded is not None: + try: + rollback(backup, loaded["phase"]) + rollback_status = "ok" + except Exception as rollback_error: + rollback_status = f"failed:{type(rollback_error).__name__}" + if loaded is not None and loaded["artifact"].exists(): + destination = FAILED_ROOT / ( + f"{loaded['artifact'].name}." + f"{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}" + ) + os.replace(loaded["artifact"], destination) + if loaded is not None: + write_journal(FAILED_JOURNAL, { + "status": "failed", + "patch": loaded["manifest"]["id"], + "component": COMPONENT, + "phase": loaded["phase"], + "sha256": loaded["sha256"], + "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") + print(f"path={path}") + print(f"sha256={sha256_file(path)}") + print(f"python={sys.version.split()[0]}") + print(f"runtime_host={socket.gethostname()}") + print(f"component={COMPONENT}") + print(f"live_root={LIVE_ROOT}") + print(f"inbox_root={INBOX_ROOT}") + print(f"node_runtime={'present' if NODE_BIN.exists() else 'absent'}") + print(f"tailscale_runtime={'present' if TAILSCALE_BIN.exists() else 'absent'}") + 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-b2-vps-deploy verify-install | plan | apply ", + 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) diff --git a/infra/deploy-runner/nodedc-deploy b/infra/deploy-runner/nodedc-deploy index df125a9..42d6067 100755 --- a/infra/deploy-runner/nodedc-deploy +++ b/infra/deploy-runner/nodedc-deploy @@ -132,6 +132,27 @@ DEVICE_PLANE_BACKHAUL_ENROLLMENT_DIR = DEVICE_PLANE_ROOT / "enrollment" DEVICE_PLANE_BACKHAUL_ENROLLMENT_PUBLIC_KEY_FILE = ( DEVICE_PLANE_BACKHAUL_ENROLLMENT_DIR / "device-edge-backhaul.pub" ) +DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_PUBLIC_KEY_FILE = ( + DEVICE_PLANE_BACKHAUL_ENROLLMENT_DIR / "device-edge-vps-backhaul.pub" +) +DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_REL = ( + "deployment/device-plane-backhaul-vps-enrollment-v1.json" +) +DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_ENTRIES = ( + DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_REL, +) +DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_PREDECESSOR_PATCH_ID = ( + "device-plane-backhaul-target-tailnet-serve-20260804-002" +) +DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_PREDECESSOR_ARTIFACT_SHA256 = ( + "219408705dd4d80a962ed00eeb53a69df0b9ab6458443734d5c9cd1d1f795eba" +) +DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_FINGERPRINT = ( + "SHA256:HHTiDYiCRxSiKjBLCip6JMSzGfLGrDz5g8SIkosJcVw" +) +DEVICE_PLANE_BACKHAUL_AUTHORIZED_KEYS_BACKUP = ( + "device-plane-backhaul-authorized-keys-before" +) DEVICE_PLANE_BACKHAUL_SECRET_DIR = DEVICE_PLANE_SECRET_DIR / "backhaul-target" DEVICE_PLANE_BACKHAUL_HOST_KEY_FILE = ( DEVICE_PLANE_BACKHAUL_SECRET_DIR / "ssh_host_ed25519_key" @@ -8053,6 +8074,12 @@ def load_artifact(artifact, work_dir): manifest = parse_manifest(manifest_path) entries = parse_files_list(files_path) + if is_device_plane_backhaul_vps_enrollment_slice( + manifest["component"], + entries, + ): + die("vps_initiated_transport_frozen:ADR-0001") + for rel in entries: allowed_payload_path(manifest["component"], rel) if not (payload_dir / rel).exists(): @@ -8093,6 +8120,11 @@ def load_artifact(artifact, work_dir): entries, ): validate_device_plane_backhaul_target_payload(payload_dir) + if is_device_plane_backhaul_vps_enrollment_slice( + manifest["component"], + entries, + ): + validate_device_plane_backhaul_vps_enrollment_payload(payload_dir) if manifest["component"] == "n8n-private-extension": validate_n8n_private_extension_release(payload_dir, entries) if manifest["component"] == "engine": @@ -8470,6 +8502,63 @@ def is_device_plane_backhaul_target_slice(component, entries): ) +def is_device_plane_backhaul_vps_enrollment_slice(component, entries): + return ( + component == "device-plane" + and entries is not None + and tuple(entries) + == DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_ENTRIES + ) + + +def expected_device_plane_backhaul_vps_enrollment_descriptor(): + return { + "schemaVersion": ( + "nodedc.device-plane.backhaul-vps-enrollment.v1" + ), + "mode": "rotate-backhaul-client-mini-to-vps", + "predecessorPatchId": ( + DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_PREDECESSOR_PATCH_ID + ), + "predecessorArtifactSha256": ( + DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_PREDECESSOR_ARTIFACT_SHA256 + ), + "sourceAction": "publish-vps-enrollment-marker-only", + "runtimeAction": ( + "rotate-authorized-key-and-recreate-backhaul-target" + ), + "selectedServices": [DEVICE_PLANE_BACKHAUL_TARGET_SERVICE], + "preservedServices": list(DEVICE_PLANE_RUNTIME_SERVICES), + "previousEnrollment": "device-edge-backhaul.pub", + "nextEnrollment": "device-edge-vps-backhaul.pub", + "nextKeyFingerprint": ( + DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_FINGERPRINT + ), + "permittedTarget": DEVICE_PLANE_BACKHAUL_PERMITTED_TARGET, + "tailnetAddress": DEVICE_PLANE_BACKHAUL_TAILNET_ADDRESS, + "dockerPortPublication": "disabled", + "routerNatFirewall": "unchanged", + "edgePublicIngress": "disabled", + "funnel": "disabled", + "commandTransport": "disabled", + "gelios": "untouched", + "rollback": ( + "restore-previous-authorized-key-and-recreate-target" + ), + } + + +def validate_device_plane_backhaul_vps_enrollment_payload(payload_dir): + descriptor = read_strict_json( + payload_dir / DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_REL, + "Device Plane VPS backhaul enrollment descriptor", + max_bytes=16 * 1024, + ) + if descriptor != expected_device_plane_backhaul_vps_enrollment_descriptor(): + die("Device Plane VPS backhaul enrollment descriptor mismatch") + return descriptor + + def expected_device_plane_backhaul_target_descriptor(): return { "schemaVersion": ( @@ -8534,14 +8623,13 @@ def validate_device_plane_backhaul_target_payload(payload_dir): return descriptor -def read_device_plane_backhaul_enrollment_public_key(): - path = DEVICE_PLANE_BACKHAUL_ENROLLMENT_PUBLIC_KEY_FILE +def read_device_plane_ed25519_enrollment_public_key(path, comment, label): try: path_stat = path.lstat() text = path.read_text(encoding="ascii") except (FileNotFoundError, OSError, UnicodeDecodeError): die( - "Device Plane Edge enrollment public key is missing or unreadable: " + f"{label} is missing or unreadable: " f"{path}" ) if ( @@ -8549,28 +8637,56 @@ def read_device_plane_backhaul_enrollment_public_key(): or not stat.S_ISREG(path_stat.st_mode) or path_stat.st_size > 1024 ): - die("Device Plane Edge enrollment public key is unsafe") + die(f"{label} is unsafe") if text != text.strip() + "\n" or "\n" in text.strip(): - die("Device Plane Edge enrollment public key must be one line") + die(f"{label} must be one line") parts = text.strip().split() if len(parts) not in (2, 3) or parts[0] != "ssh-ed25519": - die("Device Plane Edge enrollment public key type mismatch") + die(f"{label} type mismatch") try: blob = base64.b64decode(parts[1], validate=True) except Exception: - die("Device Plane Edge enrollment public key encoding mismatch") + die(f"{label} encoding mismatch") expected_prefix = b"\x00\x00\x00\x0bssh-ed25519\x00\x00\x00\x20" if len(blob) != len(expected_prefix) + 32 or not blob.startswith( expected_prefix ): - die("Device Plane Edge enrollment public key shape mismatch") - normalized = f"ssh-ed25519 {parts[1]} nodedc-device-edge-backhaul" + die(f"{label} shape mismatch") + normalized = f"ssh-ed25519 {parts[1]} {comment}" return { "line": normalized, "sha256": hashlib.sha256((normalized + "\n").encode("ascii")).hexdigest(), + "fingerprint": ( + "SHA256:" + + base64.b64encode(hashlib.sha256(blob).digest()) + .decode("ascii") + .rstrip("=") + ), } +def read_device_plane_backhaul_enrollment_public_key(): + return read_device_plane_ed25519_enrollment_public_key( + DEVICE_PLANE_BACKHAUL_ENROLLMENT_PUBLIC_KEY_FILE, + "nodedc-device-edge-backhaul", + "Device Plane Edge enrollment public key", + ) + + +def read_device_plane_backhaul_vps_enrollment_public_key(): + enrollment = read_device_plane_ed25519_enrollment_public_key( + DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_PUBLIC_KEY_FILE, + "nodedc-device-edge-vps-backhaul", + "Device Plane VPS Edge enrollment public key", + ) + if ( + enrollment["fingerprint"] + != DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_FINGERPRINT + ): + die("Device Plane VPS Edge enrollment fingerprint mismatch") + return enrollment + + def device_plane_tailscale_drop_privileges(uid, gid): def demote(): os.setgroups([]) @@ -9136,6 +9252,37 @@ def validate_device_plane_backhaul_target_evidence(payload_dir): } +def validate_device_plane_backhaul_vps_enrollment_evidence(payload_dir): + descriptor = validate_device_plane_backhaul_vps_enrollment_payload( + payload_dir + ) + if not state_has_patch_id( + DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_PREDECESSOR_PATCH_ID + ): + die("Device Plane VPS enrollment predecessor patch is not applied") + if not state_has_sha( + DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_PREDECESSOR_ARTIFACT_SHA256 + ): + die("Device Plane VPS enrollment predecessor artifact is not applied") + root = component_root("device-plane") + marker = root / DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_REL + if marker.exists() or marker.is_symlink(): + die("Device Plane VPS enrollment marker already exists") + runtime = device_plane_runtime_inventory(DEVICE_PLANE_RUNTIME_SERVICES) + validate_device_plane_backhaul_target_runtime(runtime) + previous = read_device_plane_backhaul_enrollment_public_key() + next_enrollment = read_device_plane_backhaul_vps_enrollment_public_key() + if previous["line"] == next_enrollment["line"]: + die("Device Plane VPS enrollment key is not a new identity") + return { + "mode": descriptor["mode"], + "runtime": runtime, + "previousEnrollmentPublicKeySha256": previous["sha256"], + "nextEnrollmentPublicKeySha256": next_enrollment["sha256"], + "nextKeyFingerprint": next_enrollment["fingerprint"], + } + + def expected_device_plane_foundation_recovery_descriptor(): return { "schemaVersion": "nodedc.device-plane.foundation-recovery.v1", @@ -10713,7 +10860,10 @@ def validate_device_plane_preserved_runtime_unchanged(runtime_before, label): return current -def validate_device_plane_backhaul_target_runtime(runtime_before): +def validate_device_plane_backhaul_target_runtime( + runtime_before, + expected_enrollment=None, +): current = validate_device_plane_preserved_runtime_unchanged( runtime_before, "Device Plane backhaul", @@ -10840,7 +10990,11 @@ def validate_device_plane_backhaul_target_runtime(runtime_before): f"{required}" ) - enrollment = read_device_plane_backhaul_enrollment_public_key() + enrollment = ( + expected_enrollment + if expected_enrollment is not None + else read_device_plane_backhaul_enrollment_public_key() + ) authorized = ( 'restrict,port-forwarding,permitopen="127.0.0.1:9921" ' f"{enrollment['line']}\n" @@ -12557,6 +12711,9 @@ def is_platform_provider_catalog_only(entries): def component_services(component, entries=None): + if is_device_plane_backhaul_vps_enrollment_slice(component, entries): + return (DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,) + if is_device_plane_backhaul_target_slice(component, entries): return (DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,) @@ -12931,6 +13088,9 @@ def component_build_args(component, entries=None): def component_builds(component, entries=None): + if is_device_plane_backhaul_vps_enrollment_slice(component, entries): + return () + if is_device_plane_backhaul_target_slice(component, entries): return (( DEVICE_PLANE_ROOT, @@ -14940,6 +15100,7 @@ def plan_artifact(artifact): device_plane_b2_ingress_preflight = None device_plane_b2_recovery_preflight = None device_plane_backhaul_preflight = None + device_plane_backhaul_vps_enrollment_preflight = None device_plane_runtime_before = None composite_provider_v4_preflight = None provider_rotating_slot_preflight = None @@ -15149,6 +15310,15 @@ def plan_artifact(artifact): device_plane_backhaul_preflight = ( validate_device_plane_backhaul_target_evidence(payload_dir) ) + if is_device_plane_backhaul_vps_enrollment_slice( + manifest["component"], + entries, + ): + device_plane_backhaul_vps_enrollment_preflight = ( + validate_device_plane_backhaul_vps_enrollment_evidence( + payload_dir + ) + ) component = manifest["component"] root = component_root(component) @@ -16677,6 +16847,55 @@ def plan_artifact(artifact): ) print("device_gateway_tcp_9921=disabled:unpublished") print("device_plane_rollback=marker-only-runtime-unchanged") + if device_plane_backhaul_vps_enrollment_preflight is not None: + print( + "device_plane_transition=" + f"{device_plane_backhaul_vps_enrollment_preflight['mode']}" + ) + print( + "device_plane_predecessor_patch=" + f"{DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_PREDECESSOR_PATCH_ID}" + ) + print( + "device_plane_predecessor_artifact_sha256=" + f"{DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_PREDECESSOR_ARTIFACT_SHA256}" + ) + print("device_plane_build=none") + print( + "device_plane_runtime_mutation=" + "rotate-authorized-key+recreate:device-backhaul-target" + ) + print( + "device_plane_runtime_services=" + "preserved:device-control-core,device-gateway,device-postgres" + ) + print( + "device_backhaul_previous_enrollment_public_key_sha256=" + f"{device_plane_backhaul_vps_enrollment_preflight['previousEnrollmentPublicKeySha256']}" + ) + print( + "device_backhaul_next_enrollment_public_key_sha256=" + f"{device_plane_backhaul_vps_enrollment_preflight['nextEnrollmentPublicKeySha256']}" + ) + print( + "device_backhaul_next_key_fingerprint=" + f"{device_plane_backhaul_vps_enrollment_preflight['nextKeyFingerprint']}" + ) + print( + "device_backhaul_permitopen=" + f"{DEVICE_PLANE_BACKHAUL_PERMITTED_TARGET}" + ) + print("device_backhaul_docker_port_publication=disabled") + print("device_backhaul_tailscale_serve=unchanged") + print("device_backhaul_tailscale_funnel=disabled") + print("device_backhaul_router_nat_firewall=unchanged") + print("device_edge_public_ingress=disabled") + print("device_command_transport=disabled") + print("gelios=untouched") + print( + "device_plane_rollback=" + "restore-previous-authorized-key+recreate-target" + ) if device_plane_backhaul_preflight is not None: print( "device_plane_transition=" @@ -16849,6 +17068,25 @@ def create_backup(root, backup_dir, entries, include_nginx_html): (backup_dir / "missing-files.txt").write_text("\n".join(missing) + ("\n" if missing else ""), encoding="utf-8") +def backup_device_plane_backhaul_authorized_keys(backup_dir): + source_stat = DEVICE_PLANE_BACKHAUL_AUTHORIZED_KEYS_FILE.lstat() + if ( + stat.S_ISLNK(source_stat.st_mode) + or not stat.S_ISREG(source_stat.st_mode) + or source_stat.st_uid != 0 + or stat.S_IMODE(source_stat.st_mode) != 0o444 + or source_stat.st_size > 2048 + ): + die("Device Plane backhaul authorized_keys backup source is unsafe") + destination = backup_dir / DEVICE_PLANE_BACKHAUL_AUTHORIZED_KEYS_BACKUP + if destination.exists() or destination.is_symlink(): + die("Device Plane backhaul authorized_keys backup collision") + shutil.copy2(DEVICE_PLANE_BACKHAUL_AUTHORIZED_KEYS_FILE, destination) + os.chown(destination, 0, 0) + destination.chmod(0o600) + return sha256_file(destination) + + def read_backup_path_list(path): if not path.is_file(): die(f"deploy backup path list missing: {path}") @@ -17216,6 +17454,53 @@ def rollback_device_plane_apply( return f"source+runtime-restored:{restored_count}" +def rollback_device_plane_backhaul_vps_enrollment( + root, + backup_dir, + entries, + current_stamp, + runtime_before, +): + restored_count = restore_platform_overlay( + root, + backup_dir, + entries, + current_stamp, + ) + backup = backup_dir / DEVICE_PLANE_BACKHAUL_AUTHORIZED_KEYS_BACKUP + backup_stat = backup.lstat() + previous = read_device_plane_backhaul_enrollment_public_key() + expected = ( + 'restrict,port-forwarding,permitopen="127.0.0.1:9921" ' + f"{previous['line']}\n" + ) + if ( + stat.S_ISLNK(backup_stat.st_mode) + or not stat.S_ISREG(backup_stat.st_mode) + or backup_stat.st_uid != 0 + or stat.S_IMODE(backup_stat.st_mode) != 0o600 + or backup_stat.st_size > 2048 + or backup.read_text(encoding="ascii") != expected + ): + die("Device Plane VPS enrollment rollback backup mismatch") + install_device_plane_backhaul_authorized_key(previous) + run_compose( + "device-plane", + (DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,), + entries, + ) + run_healthchecks( + "device-plane", + entries, + (DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,), + ) + validate_device_plane_backhaul_target_runtime( + runtime_before, + expected_enrollment=previous, + ) + return f"previous-key+target+source-restored:{restored_count}" + + def rollback_engine_apply(root, backup_dir, entries, current_stamp, runtime_started, applied_services): existing = read_backup_path_list(backup_dir / "existing-files.txt") missing = read_backup_path_list(backup_dir / "missing-files.txt") @@ -17764,6 +18049,32 @@ def run_engine_node_intelligence_compose(services, entries): ) +def install_device_plane_backhaul_authorized_key(enrollment): + DEVICE_PLANE_BACKHAUL_SECRET_DIR.mkdir( + parents=True, + exist_ok=True, + ) + os.chown(DEVICE_PLANE_BACKHAUL_SECRET_DIR, 0, 0) + DEVICE_PLANE_BACKHAUL_SECRET_DIR.chmod(0o700) + authorized = ( + 'restrict,port-forwarding,permitopen="127.0.0.1:9921" ' + f"{enrollment['line']}\n" + ) + temporary = DEVICE_PLANE_BACKHAUL_AUTHORIZED_KEYS_FILE.with_suffix( + ".installing" + ) + if temporary.exists() or temporary.is_symlink(): + die("Device Plane backhaul authorized_keys staging path exists") + temporary.write_text(authorized, encoding="ascii") + os.chown(temporary, 0, 0) + temporary.chmod(0o444) + os.replace(temporary, DEVICE_PLANE_BACKHAUL_AUTHORIZED_KEYS_FILE) + expected_sha256 = hashlib.sha256(authorized.encode("ascii")).hexdigest() + if sha256_file(DEVICE_PLANE_BACKHAUL_AUTHORIZED_KEYS_FILE) != expected_sha256: + die("Device Plane backhaul authorized key verification failed") + return expected_sha256 + + def ensure_device_plane_backhaul_target_state(): enrollment = read_device_plane_backhaul_enrollment_public_key() DEVICE_PLANE_BACKHAUL_SECRET_DIR.mkdir( @@ -17818,19 +18129,9 @@ def ensure_device_plane_backhaul_target_state(): os.chown(path, 0, 0) path.chmod(expected_mode) - authorized = ( - 'restrict,port-forwarding,permitopen="127.0.0.1:9921" ' - f"{enrollment['line']}\n" + expected_authorized_sha256 = install_device_plane_backhaul_authorized_key( + enrollment ) - temporary = DEVICE_PLANE_BACKHAUL_AUTHORIZED_KEYS_FILE.with_suffix( - ".installing" - ) - if temporary.exists() or temporary.is_symlink(): - die("Device Plane backhaul authorized_keys staging path exists") - temporary.write_text(authorized, encoding="ascii") - os.chown(temporary, 0, 0) - temporary.chmod(0o444) - os.replace(temporary, DEVICE_PLANE_BACKHAUL_AUTHORIZED_KEYS_FILE) DEVICE_PLANE_BACKHAUL_TRUST_DIR.mkdir(parents=True, exist_ok=True) os.chown(DEVICE_PLANE_BACKHAUL_TRUST_DIR, 0, 0) @@ -17846,9 +18147,6 @@ def ensure_device_plane_backhaul_target_state(): public_temporary.chmod(0o444) os.replace(public_temporary, DEVICE_PLANE_BACKHAUL_HOST_PUBLIC_KEY_FILE) - expected_authorized_sha256 = hashlib.sha256( - authorized.encode("ascii") - ).hexdigest() if ( sha256_file(DEVICE_PLANE_BACKHAUL_AUTHORIZED_KEYS_FILE) != expected_authorized_sha256 @@ -17929,6 +18227,14 @@ def prepare_component_runtime(component, entries=None): MAP_GATEWAY_SECRET_RE, "device plane identifier pepper", ) + if is_device_plane_backhaul_vps_enrollment_slice( + component, + entries, + ): + install_device_plane_backhaul_authorized_key( + read_device_plane_backhaul_vps_enrollment_public_key() + ) + return if is_device_plane_backhaul_target_slice(component, entries): ensure_device_plane_backhaul_target_state() return @@ -18685,6 +18991,17 @@ process.stdout.write('engine-l2-closed-loop:0.7.0:cas+safe-profile+external-plan def run_healthchecks(component, entries=None, services=None): + if is_device_plane_backhaul_vps_enrollment_slice(component, entries): + if tuple(services or ()) != (DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,): + die("Device Plane VPS enrollment service set mismatch") + for service in DEVICE_PLANE_RUNTIME_SERVICES: + healthcheck_compose_service("device-plane", service) + healthcheck_compose_service( + "device-plane", + DEVICE_PLANE_BACKHAUL_TARGET_SERVICE, + ) + return + if is_device_plane_backhaul_target_slice(component, entries): if tuple(services or ()) != (DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,): die("Device Plane backhaul target service set mismatch") @@ -19551,6 +19868,7 @@ def apply_artifact(artifact): node_intelligence_descriptor = None l2_closed_loop_preflight = None device_plane_backhaul_preflight = None + device_plane_backhaul_vps_enrollment_preflight = None node_intelligence_service_stopped = False apply_started = False engine_backend_recreated = False @@ -19628,6 +19946,15 @@ def apply_artifact(artifact): payload_dir ) ) + if is_device_plane_backhaul_vps_enrollment_slice( + component, + entries, + ): + device_plane_backhaul_vps_enrollment_preflight = ( + validate_device_plane_backhaul_vps_enrollment_evidence( + payload_dir + ) + ) if not root.is_dir(): if bootstrap_root: root.mkdir(parents=True, exist_ok=True) @@ -19955,6 +20282,13 @@ def apply_artifact(artifact): encoding="utf-8", ) runtime_inventory_path.chmod(0o600) + if ( + device_plane_backhaul_vps_enrollment_preflight + is not None + ): + backup_device_plane_backhaul_authorized_keys( + backup_dir + ) if device_plane_backhaul_preflight is not None: tailscale_before_path = ( backup_dir / "tailscale-serve-before.json" @@ -20067,6 +20401,21 @@ def apply_artifact(artifact): validate_device_plane_backhaul_target_runtime( device_plane_runtime_before ) + if is_device_plane_backhaul_vps_enrollment_slice( + component, + entries, + ): + if device_plane_runtime_before is None: + die( + "Device Plane VPS enrollment predecessor runtime " + "inventory is missing" + ) + validate_device_plane_backhaul_target_runtime( + device_plane_runtime_before, + expected_enrollment=( + read_device_plane_backhaul_vps_enrollment_public_key() + ), + ) applied_path = move_artifact(artifact, APPLIED_DIR) append_jsonl(STATE_FILE, { @@ -20305,6 +20654,41 @@ def apply_artifact(artifact): "automatic-rollback=failed", file=sys.stderr, ) + elif ( + is_device_plane_backhaul_vps_enrollment_slice( + component, + entries, + ) + and device_plane_runtime_before is not None + ): + try: + restored_state = ( + rollback_device_plane_backhaul_vps_enrollment( + root, + backup_dir, + entries, + current_stamp, + device_plane_runtime_before, + ) + ) + rollback_status = ( + "ok:device-plane-vps-enrollment:" + f"{restored_state}" + ) + print( + "device-plane-vps-enrollment-" + f"automatic-rollback={rollback_status}", + file=sys.stderr, + ) + except Exception as rollback_exc: + rollback_status = ( + f"failed:{type(rollback_exc).__name__}" + ) + print( + "device-plane-vps-enrollment-" + "automatic-rollback=failed", + file=sys.stderr, + ) elif ( component == "device-plane" and entries is not None diff --git a/infra/deploy-runner/test_device_edge_vps_artifact.py b/infra/deploy-runner/test_device_edge_vps_artifact.py new file mode 100644 index 0000000..c7aee62 --- /dev/null +++ b/infra/deploy-runner/test_device_edge_vps_artifact.py @@ -0,0 +1,349 @@ +#!/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-vps-artifact.mjs" +RUNNER_PATH = SCRIPT_DIR / "nodedc-b2-vps-deploy" +DEFAULT_RUNTIME_CACHE = Path( + os.environ.get("NODEDC_DEVICE_EDGE_VPS_RUNTIME_DIR", "/tmp") +) + + +def load_runner(): + loader = importlib.machinery.SourceFileLoader( + "nodedc_b2_vps_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 DeviceEdgeVpsArtifactTest(unittest.TestCase): + def build(self, artifact_dir, phase, patch_id, runtime_cache=None): + environment = os.environ.copy() + environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) + environment["NODEDC_DEVICE_EDGE_VPS_RUNTIME_DIR"] = str( + runtime_cache or DEFAULT_RUNTIME_CACHE + ) + if phase in {"backhaul", "relay"}: + environment["NODEDC_ALLOW_SUPERSEDED_TRANSPORT"] = "test-only" + return subprocess.run( + ["node", str(BUILDER), phase, patch_id], + check=False, + capture_output=True, + text=True, + env=environment, + ) + + def test_superseded_transport_builds_fail_closed_by_default(self): + environment = os.environ.copy() + environment.pop("NODEDC_ALLOW_SUPERSEDED_TRANSPORT", None) + with tempfile.TemporaryDirectory(prefix="nodedc-vps-frozen-") as directory: + environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = directory + for phase in ("backhaul", "relay"): + with self.subTest(phase=phase): + result = subprocess.run( + [ + "node", + str(BUILDER), + phase, + f"device-edge-vps-{phase}-frozen-001", + ], + check=False, + capture_output=True, + text=True, + env=environment, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn( + "vps_initiated_transport_frozen:ADR-0001", + result.stderr, + ) + + def test_runner_rejects_superseded_transport_before_host_preflight(self): + for phase in ("backhaul", "relay"): + with self.subTest(phase=phase), self.assertRaises(RUNNER.DeployError): + RUNNER.preflight({"phase": phase}) + + def require_runtime_cache(self): + for name, digest in ( + (RUNNER.NODE_ARCHIVE, RUNNER.NODE_ARCHIVE_SHA256), + (RUNNER.TAILSCALE_ARCHIVE, RUNNER.TAILSCALE_ARCHIVE_SHA256), + ): + path = DEFAULT_RUNTIME_CACHE / name + self.assertTrue(path.is_file(), f"missing runtime fixture: {path}") + self.assertEqual(hashlib.sha256(path.read_bytes()).hexdigest(), digest) + + def test_builders_are_deterministic_narrow_and_secret_free(self): + self.require_runtime_cache() + for phase in ("foundation", "backhaul", "relay"): + with self.subTest(phase=phase), tempfile.TemporaryDirectory( + prefix=f"nodedc-vps-{phase}-" + ) as directory: + root = Path(directory) + patch_id = f"device-edge-vps-{phase}-unit-001" + first = self.build(root, phase, patch_id) + self.assertEqual(first.returncode, 0, first.stderr) + first_result = json.loads(first.stdout) + first_bytes = Path(first_result["artifact"]).read_bytes() + second = self.build(root, phase, patch_id) + self.assertEqual(second.returncode, 0, second.stderr) + second_result = json.loads(second.stdout) + second_bytes = Path(second_result["artifact"]).read_bytes() + + self.assertEqual(first_bytes, second_bytes) + self.assertEqual(first_result["sha256"], second_result["sha256"]) + self.assertEqual( + first_result["sha256"], + hashlib.sha256(first_bytes).hexdigest(), + ) + self.assertEqual(first_result["entries"], list(RUNNER.PHASE_ENTRIES[phase])) + + with tarfile.open(first_result["artifact"], "r:gz") as archive: + members = archive.getmembers() + names = {member.name for member in members} + payload = b"\n".join( + archive.extractfile(member).read() + for member in members + if member.isfile() and member.size < 2 * 1024 * 1024 + ) + self.assertIn("manifest.env", names) + self.assertIn("files.txt", names) + self.assertFalse(any( + "/secrets/" in name + or "/keys/" in name + or "/trust/" in name + or "/runtime/" in name + or "/node_modules/" in name + or Path(name).name.startswith(".env") + for name in names + )) + self.assertNotIn(b"PRIVATE KEY", payload) + self.assertNotIn(b"TS_AUTHKEY", payload) + + def test_foundation_builder_rejects_modified_runtime_archive(self): + self.require_runtime_cache() + with tempfile.TemporaryDirectory(prefix="nodedc-vps-corrupt-") as directory: + cache = Path(directory) / "cache" + artifacts = Path(directory) / "artifacts" + cache.mkdir() + for name in (RUNNER.NODE_ARCHIVE, RUNNER.TAILSCALE_ARCHIVE): + (cache / name).write_bytes((DEFAULT_RUNTIME_CACHE / name).read_bytes()) + with (cache / RUNNER.NODE_ARCHIVE).open("ab") as handle: + handle.write(b"corrupt") + result = self.build( + artifacts, + "foundation", + "device-edge-vps-foundation-corrupt-001", + runtime_cache=cache, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("runtime_digest_mismatch", result.stderr) + + def test_runner_loads_each_exact_phase(self): + self.require_runtime_cache() + with tempfile.TemporaryDirectory(prefix="nodedc-vps-load-") as directory: + inbox = Path(directory) / "inbox" + inbox.mkdir() + old_inbox = RUNNER.INBOX_ROOT + RUNNER.INBOX_ROOT = inbox + try: + for phase in ("foundation", "backhaul", "relay"): + result = self.build( + inbox, + phase, + f"device-edge-vps-{phase}-load-001", + ) + self.assertEqual(result.returncode, 0, result.stderr) + artifact = Path(json.loads(result.stdout)["artifact"]) + extraction = Path(directory) / f"extract-{phase}" + extraction.mkdir() + loaded = RUNNER.load_artifact(artifact, extraction) + self.assertEqual(loaded["phase"], phase) + self.assertEqual(loaded["entries"], RUNNER.PHASE_ENTRIES[phase]) + self.assertEqual( + loaded["sha256"], + hashlib.sha256(artifact.read_bytes()).hexdigest(), + ) + finally: + RUNNER.INBOX_ROOT = old_inbox + + def test_plan_is_exact_and_never_claims_dns_or_b2_mutation(self): + self.require_runtime_cache() + with tempfile.TemporaryDirectory(prefix="nodedc-vps-plan-") as directory: + inbox = Path(directory) / "inbox" + inbox.mkdir() + result = self.build( + inbox, + "foundation", + "device-edge-vps-foundation-plan-001", + ) + self.assertEqual(result.returncode, 0, result.stderr) + artifact = Path(json.loads(result.stdout)["artifact"]) + old_inbox = RUNNER.INBOX_ROOT + RUNNER.INBOX_ROOT = inbox + try: + with patch.object(RUNNER, "assert_root"), patch.object( + RUNNER, + "preflight", + return_value={"predecessor": "unit-predecessor"}, + ), patch("builtins.print") as output: + RUNNER.plan_artifact(str(artifact)) + finally: + RUNNER.INBOX_ROOT = old_inbox + rendered = "\n".join( + " ".join(str(arg) for arg in call.args) + for call in output.call_args_list + ) + self.assertIn("phase=foundation", rendered) + self.assertIn("public_b2_ingress=disabled", rendered) + self.assertIn("dns=unchanged", rendered) + self.assertIn("b2_routes=unchanged", rendered) + self.assertIn("command_transport=disabled", rendered) + + def test_units_and_firewalls_keep_the_required_boundaries(self): + source_root = SCRIPT_DIR.parent.parent / "device-plane" + foundation = (source_root / "vps/config/nftables-foundation.conf").read_text() + relay = (source_root / "vps/config/nftables-relay.conf").read_text() + sshd = (source_root / "vps/config/00-nodedc-b2-vps.conf").read_text() + backhaul = (source_root / "vps/config/backhaul_ssh_config").read_text() + tailscale_unit = ( + source_root / "vps/systemd/nodedc-b2-tailscaled.service" + ).read_text() + relay_unit = (source_root / "vps/systemd/nodedc-b2-relay.service").read_text() + backhaul_unit = ( + source_root / "vps/systemd/nodedc-b2-backhaul.service" + ).read_text() + + self.assertIn("policy drop", foundation) + self.assertIn("tcp dport 22", foundation) + self.assertNotIn("tcp dport 9921", foundation) + self.assertIn("tcp dport 9921", relay) + self.assertIn("PasswordAuthentication no", sshd) + self.assertIn("AllowTcpForwarding no", sshd) + self.assertIn("StrictHostKeyChecking yes", backhaul) + self.assertIn("ProxyCommand /usr/bin/nc -X 5 -x 127.0.0.1:1055", backhaul) + self.assertIn("AF_NETLINK", tailscale_unit) + self.assertIn("User=nodedc-edge", tailscale_unit) + self.assertIn("StateDirectoryMode=0700", tailscale_unit) + self.assertIn("User=nodedc-backhaul", backhaul_unit) + self.assertNotIn("User=nodedc-edge", backhaul_unit) + self.assertIn("User=nodedc-relay", relay_unit) + self.assertNotIn("User=nodedc-edge", relay_unit) + self.assertIn("DEVICE_EDGE_RELAY_SOURCE_POLICY=public-ipv4-only", relay_unit) + self.assertIn("MemoryMax=192M", relay_unit) + + def test_runner_has_registered_rollback_and_no_generic_latest(self): + source = RUNNER_PATH.read_text(encoding="utf-8") + self.assertIn("def rollback(", source) + self.assertIn('TAILSCALE_REQUIRED_TAG = "tag:device-edge-vps"', source) + self.assertIn("assign_backhaul_trust", source) + self.assertIn("deploy-ok patch=", source) + self.assertNotIn("apply-latest", source) + self.assertNotIn("compose down", source) + self.assertNotIn("docker system prune", source) + + def test_executable_preflight_accepts_a_valid_alternatives_symlink(self): + with tempfile.TemporaryDirectory(prefix="nodedc-vps-tool-") as directory: + root = Path(directory) + target = root / "netcat.openbsd" + target.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + target.chmod(0o755) + command = root / "nc" + command.symlink_to(target.name) + + self.assertEqual( + RUNNER.assert_executable_command_path(command, "test command"), + target.resolve(), + ) + + def test_executable_preflight_rejects_a_broken_symlink(self): + with tempfile.TemporaryDirectory(prefix="nodedc-vps-tool-") as directory: + command = Path(directory) / "nc" + command.symlink_to("missing-netcat") + with self.assertRaises(RUNNER.DeployError): + RUNNER.assert_executable_command_path(command, "test command") + + def test_backup_restore_preserves_the_exact_relay_partition(self): + with tempfile.TemporaryDirectory(prefix="nodedc-vps-backup-") as directory: + root = Path(directory) + live = root / "live" + backups = root / "backups" + nft = root / "etc/nftables.conf" + relay_unit = root / "etc/nodedc-b2-relay.service" + backups.mkdir() + nft.parent.mkdir(parents=True) + nft.write_text("foundation-firewall\n", encoding="utf-8") + relay_unit.write_text("old-unit\n", encoding="utf-8") + for relative in RUNNER.RELAY_ENTRIES: + target = live / relative + if relative.endswith("/src"): + target.mkdir(parents=True) + (target / "server.mjs").write_text("old-source\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 + old_nft = RUNNER.NFTABLES_CONFIG + old_relay_unit = RUNNER.RELAY_UNIT + RUNNER.LIVE_ROOT = live + RUNNER.BACKUP_ROOT = backups + RUNNER.NFTABLES_CONFIG = nft + RUNNER.RELAY_UNIT = relay_unit + completed = subprocess.CompletedProcess([], 0, "table inet old {}\n", "") + try: + with patch.object(RUNNER, "run", return_value=completed), patch.object( + RUNNER, + "service_active", + return_value=False, + ), patch.object( + RUNNER, + "systemctl", + return_value=completed, + ), patch.object( + RUNNER, + "user_exists", + return_value=False, + ): + _backup_id, backup = RUNNER.create_backup("relay-unit", "relay") + nft.write_text("candidate-firewall\n", encoding="utf-8") + relay_unit.write_text("candidate-unit\n", encoding="utf-8") + (live / "services/device-edge-relay/src/server.mjs").write_text( + "candidate-source\n", + encoding="utf-8", + ) + RUNNER.restore_backup(backup, "relay") + finally: + RUNNER.LIVE_ROOT = old_live + RUNNER.BACKUP_ROOT = old_backups + RUNNER.NFTABLES_CONFIG = old_nft + RUNNER.RELAY_UNIT = old_relay_unit + + self.assertEqual(nft.read_text(), "foundation-firewall\n") + self.assertEqual(relay_unit.read_text(), "old-unit\n") + self.assertEqual( + (live / "services/device-edge-relay/src/server.mjs").read_text(), + "old-source\n", + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/infra/deploy-runner/test_device_plane_backhaul_vps_enrollment_artifact.py b/infra/deploy-runner/test_device_plane_backhaul_vps_enrollment_artifact.py new file mode 100644 index 0000000..b94fccc --- /dev/null +++ b/infra/deploy-runner/test_device_plane_backhaul_vps_enrollment_artifact.py @@ -0,0 +1,236 @@ +#!/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 unittest import mock + + +SCRIPT_DIR = Path(__file__).resolve().parent +BUILDER = ( + SCRIPT_DIR / "build-device-plane-backhaul-vps-enrollment-artifact.mjs" +) +RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy" + + +def load_runner(): + loader = importlib.machinery.SourceFileLoader( + "nodedc_device_plane_vps_enrollment_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 DevicePlaneBackhaulVpsEnrollmentArtifactTest(unittest.TestCase): + def build(self, artifact_dir, patch_id): + environment = os.environ.copy() + environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir) + environment["NODEDC_ALLOW_SUPERSEDED_TRANSPORT"] = "test-only" + return subprocess.run( + ["node", str(BUILDER), patch_id], + check=False, + capture_output=True, + text=True, + env=environment, + ) + + def test_builder_fails_closed_without_test_only_reconstruction(self): + environment = os.environ.copy() + environment.pop("NODEDC_ALLOW_SUPERSEDED_TRANSPORT", None) + with tempfile.TemporaryDirectory( + prefix="nodedc-vps-enrollment-frozen-", + ) as directory: + environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = directory + result = subprocess.run( + ["node", str(BUILDER), "device-plane-vps-enrollment-frozen-001"], + check=False, + capture_output=True, + text=True, + env=environment, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn( + "vps_initiated_transport_frozen:ADR-0001", + result.stderr, + ) + + def test_runner_rejects_a_prebuilt_superseded_enrollment_artifact(self): + with tempfile.TemporaryDirectory( + prefix="nodedc-vps-enrollment-frozen-runner-", + ) as directory: + root = Path(directory) + result = self.build( + root, + "device-plane-vps-enrollment-frozen-runner-001", + ) + self.assertEqual(result.returncode, 0, result.stderr) + artifact = Path(json.loads(result.stdout)["artifact"]) + extracted = root / "extracted" + extracted.mkdir() + with self.assertRaises(RUNNER.DeployError) as raised: + RUNNER.load_artifact(artifact, extracted) + self.assertIn( + "vps_initiated_transport_frozen:ADR-0001", + str(raised.exception), + ) + + def test_artifact_is_deterministic_marker_only_and_secret_free(self): + with tempfile.TemporaryDirectory( + prefix="nodedc-vps-enrollment-artifact-", + ) as directory: + root = Path(directory) + first = self.build(root, "device-plane-vps-enrollment-unit-001") + self.assertEqual(first.returncode, 0, first.stderr) + first_result = json.loads(first.stdout) + first_bytes = Path(first_result["artifact"]).read_bytes() + second = self.build(root, "device-plane-vps-enrollment-unit-001") + self.assertEqual(second.returncode, 0, second.stderr) + second_result = json.loads(second.stdout) + second_bytes = Path(second_result["artifact"]).read_bytes() + self.assertEqual(first_bytes, second_bytes) + self.assertEqual( + first_result["sha256"], + hashlib.sha256(first_bytes).hexdigest(), + ) + self.assertEqual( + first_result["entries"], + list(RUNNER.DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_ENTRIES), + ) + with tarfile.open(first_result["artifact"], "r:gz") as archive: + names = archive.getnames() + payload = b"\n".join( + archive.extractfile(member).read() + for member in archive.getmembers() + if member.isfile() + ) + self.assertEqual( + set(names), + { + "manifest.env", + "files.txt", + "payload", + "payload/deployment", + "payload/deployment/device-plane-backhaul-vps-enrollment-v1.json", + }, + ) + self.assertNotIn(b"PRIVATE KEY", payload) + self.assertNotIn(b"authorized_keys", payload) + + def test_registry_selects_only_existing_target_without_build(self): + entries = RUNNER.DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_ENTRIES + self.assertTrue( + RUNNER.is_device_plane_backhaul_vps_enrollment_slice( + "device-plane", + entries, + ) + ) + self.assertEqual( + RUNNER.component_services("device-plane", entries), + (RUNNER.DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,), + ) + self.assertEqual(RUNNER.component_builds("device-plane", entries), ()) + + def test_vps_public_key_is_pinned_by_computed_fingerprint(self): + blob = ( + b"\x00\x00\x00\x0bssh-ed25519\x00\x00\x00\x20" + + bytes(range(32)) + ) + key = ( + "ssh-ed25519 " + + base64.b64encode(blob).decode("ascii") + + " source-comment\n" + ) + fingerprint = ( + "SHA256:" + + base64.b64encode(hashlib.sha256(blob).digest()) + .decode("ascii") + .rstrip("=") + ) + with tempfile.TemporaryDirectory( + prefix="nodedc-vps-enrollment-key-", + ) as directory: + path = Path(directory) / "device-edge-vps-backhaul.pub" + path.write_text(key, encoding="ascii") + with ( + mock.patch.object( + RUNNER, + "DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_PUBLIC_KEY_FILE", + path, + ), + mock.patch.object( + RUNNER, + "DEVICE_PLANE_BACKHAUL_VPS_ENROLLMENT_FINGERPRINT", + fingerprint, + ), + ): + enrollment = ( + RUNNER.read_device_plane_backhaul_vps_enrollment_public_key() + ) + self.assertEqual(enrollment["fingerprint"], fingerprint) + self.assertTrue( + enrollment["line"].endswith( + " nodedc-device-edge-vps-backhaul" + ) + ) + + def test_runtime_authorized_key_is_external_and_atomic(self): + blob = ( + b"\x00\x00\x00\x0bssh-ed25519\x00\x00\x00\x20" + + bytes(reversed(range(32))) + ) + enrollment = { + "line": ( + "ssh-ed25519 " + + base64.b64encode(blob).decode("ascii") + + " nodedc-device-edge-vps-backhaul" + ) + } + with tempfile.TemporaryDirectory( + prefix="nodedc-vps-enrollment-runtime-", + ) as directory: + secret_dir = Path(directory) / "secret" + authorized = secret_dir / "authorized_keys" + with ( + mock.patch.object( + RUNNER, + "DEVICE_PLANE_BACKHAUL_SECRET_DIR", + secret_dir, + ), + mock.patch.object( + RUNNER, + "DEVICE_PLANE_BACKHAUL_AUTHORIZED_KEYS_FILE", + authorized, + ), + mock.patch.object(RUNNER.os, "chown"), + ): + digest = RUNNER.install_device_plane_backhaul_authorized_key( + enrollment + ) + expected = ( + 'restrict,port-forwarding,permitopen="127.0.0.1:9921" ' + + enrollment["line"] + + "\n" + ) + self.assertEqual(authorized.read_text(encoding="ascii"), expected) + self.assertEqual( + digest, + hashlib.sha256(expected.encode("ascii")).hexdigest(), + ) + self.assertEqual(authorized.stat().st_mode & 0o777, 0o444) + + +if __name__ == "__main__": + unittest.main(verbosity=2)