feat(device-plane): accept core edge transport ADR

This commit is contained in:
Codex
2026-08-10 16:40:48 +03:00
parent 07224c6f0d
commit bf0bc50abb
27 changed files with 5034 additions and 26 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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
}
@@ -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",
],
},
],
}
@@ -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.
@@ -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
@@ -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-<provider unit id>`;
- `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 01, 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`.
@@ -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,
@@ -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 <unique-patch-id>
node infra/deploy-runner/build-device-edge-vps-artifact.mjs \
backhaul <unique-patch-id>
node infra/deploy-runner/build-device-edge-vps-artifact.mjs \
relay <unique-patch-id>
```
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 для целевых файлов.
+8
View File
@@ -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 |
@@ -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/);
});
@@ -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
@@ -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
@@ -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;
}
}
@@ -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;
}
}
@@ -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
@@ -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
@@ -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
@@ -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 <foundation|backhaul|relay> <patch-id>",
);
}
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);
}
}
@@ -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");
}
File diff suppressed because it is too large Load Diff
+410 -26
View File
@@ -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
@@ -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)
@@ -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)