feat(device-plane): accept core edge transport ADR
This commit is contained in:
@@ -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 0–1, 13–15. Архитектурные выводы остальных разделов сохраняются.
|
||||
|
||||
Реализовано:
|
||||
|
||||
- созданы отдельные Ops cards `DCPLATFORM-75` и `ROBOT2B-7`;
|
||||
- создан отдельный root-owned VPS deploy domain
|
||||
`/usr/local/sbin/nodedc-b2-vps-deploy`;
|
||||
- Docker на 961 MiB VPS сознательно не устанавливался;
|
||||
- foundation `device-edge-vps-foundation-20260806-003` принят с `deploy-ok`;
|
||||
- SSH переведён в key-only, nftables — в default-deny, публично только TCP/22;
|
||||
- Node.js 22.23.2 и Tailscale 1.102.2 установлены из pinned static archives;
|
||||
- Tailscale runtime user `nodedc-edge` и отдельный ED25519 backhaul key созданы
|
||||
runner-ом; isolation-aware backhaul/relay transitions используют отдельные
|
||||
`nodedc-backhaul` и `nodedc-relay`;
|
||||
- default wildcard tailnet grant удалён; VPS переведён из user ownership в
|
||||
`tag:device-edge-vps` с единственным egress `100.109.216.21:2222/tcp`;
|
||||
- отрицательные проверки с VPS подтвердили запрет Synology SSH/DSM, MacBook и
|
||||
второго edge-узла;
|
||||
- TCP/9921 остаётся закрыт;
|
||||
- backhaul и relay artifacts собраны и стадированы, но не применены до
|
||||
predecessor acceptance;
|
||||
- public VPS key стадирован в Synology enrollment;
|
||||
- marker-only Synology key-rotation artifact и runner candidate стадированы.
|
||||
|
||||
Текущие blockers/gates:
|
||||
|
||||
1. Root на Synology должен promote/verify exact runner, review plan и выполнить
|
||||
exact enrollment apply.
|
||||
2. После этого VPS runner может принять backhaul, затем relay.
|
||||
3. DNS и B2 routes остаются неизменными до отдельного transport pilot.
|
||||
4. Provider recovery key `beget-access-key` — RSA-1024; его отзыв требует
|
||||
отдельного owner decision.
|
||||
|
||||
Полный повторяемый manual и release evidence находятся в
|
||||
`device-plane/docs/ROBOT2B_B2_VPS_CONFIGURATION_BIBLE_2026-08-06.md`.
|
||||
@@ -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 для целевых файлов.
|
||||
@@ -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 |
|
||||
|
||||
+98
@@ -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");
|
||||
}
|
||||
Executable
+1181
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
Reference in New Issue
Block a user