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
|
# 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,
|
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
|
human-operated exposure gate for the already accepted Mini relay. It never
|
||||||
changes Synology, Gelios, VPN, Device Plane command transport or device
|
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
|
# 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
|
Status: PostgreSQL, Control Core and Gateway foundation are running healthy on
|
||||||
Synology. The accepted foundation has public ingress and discovery ingest
|
Synology. The accepted foundation has public ingress and discovery ingest
|
||||||
disabled. The next additive transition enables only an authenticated,
|
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 policy | `.env`, secrets, runtime state, tests, logs and `node_modules` are excluded |
|
||||||
| Artifact reproducibility | Repeated builds for the same patch id are byte-identical |
|
| Artifact reproducibility | Repeated builds for the same patch id are byte-identical |
|
||||||
| Runner compatibility | Existing canonical Platform registry tests remain green |
|
| 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_PUBLIC_KEY_FILE = (
|
||||||
DEVICE_PLANE_BACKHAUL_ENROLLMENT_DIR / "device-edge-backhaul.pub"
|
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_SECRET_DIR = DEVICE_PLANE_SECRET_DIR / "backhaul-target"
|
||||||
DEVICE_PLANE_BACKHAUL_HOST_KEY_FILE = (
|
DEVICE_PLANE_BACKHAUL_HOST_KEY_FILE = (
|
||||||
DEVICE_PLANE_BACKHAUL_SECRET_DIR / "ssh_host_ed25519_key"
|
DEVICE_PLANE_BACKHAUL_SECRET_DIR / "ssh_host_ed25519_key"
|
||||||
@@ -8053,6 +8074,12 @@ def load_artifact(artifact, work_dir):
|
|||||||
manifest = parse_manifest(manifest_path)
|
manifest = parse_manifest(manifest_path)
|
||||||
entries = parse_files_list(files_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:
|
for rel in entries:
|
||||||
allowed_payload_path(manifest["component"], rel)
|
allowed_payload_path(manifest["component"], rel)
|
||||||
if not (payload_dir / rel).exists():
|
if not (payload_dir / rel).exists():
|
||||||
@@ -8093,6 +8120,11 @@ def load_artifact(artifact, work_dir):
|
|||||||
entries,
|
entries,
|
||||||
):
|
):
|
||||||
validate_device_plane_backhaul_target_payload(payload_dir)
|
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":
|
if manifest["component"] == "n8n-private-extension":
|
||||||
validate_n8n_private_extension_release(payload_dir, entries)
|
validate_n8n_private_extension_release(payload_dir, entries)
|
||||||
if manifest["component"] == "engine":
|
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():
|
def expected_device_plane_backhaul_target_descriptor():
|
||||||
return {
|
return {
|
||||||
"schemaVersion": (
|
"schemaVersion": (
|
||||||
@@ -8534,14 +8623,13 @@ def validate_device_plane_backhaul_target_payload(payload_dir):
|
|||||||
return descriptor
|
return descriptor
|
||||||
|
|
||||||
|
|
||||||
def read_device_plane_backhaul_enrollment_public_key():
|
def read_device_plane_ed25519_enrollment_public_key(path, comment, label):
|
||||||
path = DEVICE_PLANE_BACKHAUL_ENROLLMENT_PUBLIC_KEY_FILE
|
|
||||||
try:
|
try:
|
||||||
path_stat = path.lstat()
|
path_stat = path.lstat()
|
||||||
text = path.read_text(encoding="ascii")
|
text = path.read_text(encoding="ascii")
|
||||||
except (FileNotFoundError, OSError, UnicodeDecodeError):
|
except (FileNotFoundError, OSError, UnicodeDecodeError):
|
||||||
die(
|
die(
|
||||||
"Device Plane Edge enrollment public key is missing or unreadable: "
|
f"{label} is missing or unreadable: "
|
||||||
f"{path}"
|
f"{path}"
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
@@ -8549,28 +8637,56 @@ def read_device_plane_backhaul_enrollment_public_key():
|
|||||||
or not stat.S_ISREG(path_stat.st_mode)
|
or not stat.S_ISREG(path_stat.st_mode)
|
||||||
or path_stat.st_size > 1024
|
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():
|
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()
|
parts = text.strip().split()
|
||||||
if len(parts) not in (2, 3) or parts[0] != "ssh-ed25519":
|
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:
|
try:
|
||||||
blob = base64.b64decode(parts[1], validate=True)
|
blob = base64.b64decode(parts[1], validate=True)
|
||||||
except Exception:
|
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"
|
expected_prefix = b"\x00\x00\x00\x0bssh-ed25519\x00\x00\x00\x20"
|
||||||
if len(blob) != len(expected_prefix) + 32 or not blob.startswith(
|
if len(blob) != len(expected_prefix) + 32 or not blob.startswith(
|
||||||
expected_prefix
|
expected_prefix
|
||||||
):
|
):
|
||||||
die("Device Plane Edge enrollment public key shape mismatch")
|
die(f"{label} shape mismatch")
|
||||||
normalized = f"ssh-ed25519 {parts[1]} nodedc-device-edge-backhaul"
|
normalized = f"ssh-ed25519 {parts[1]} {comment}"
|
||||||
return {
|
return {
|
||||||
"line": normalized,
|
"line": normalized,
|
||||||
"sha256": hashlib.sha256((normalized + "\n").encode("ascii")).hexdigest(),
|
"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 device_plane_tailscale_drop_privileges(uid, gid):
|
||||||
def demote():
|
def demote():
|
||||||
os.setgroups([])
|
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():
|
def expected_device_plane_foundation_recovery_descriptor():
|
||||||
return {
|
return {
|
||||||
"schemaVersion": "nodedc.device-plane.foundation-recovery.v1",
|
"schemaVersion": "nodedc.device-plane.foundation-recovery.v1",
|
||||||
@@ -10713,7 +10860,10 @@ def validate_device_plane_preserved_runtime_unchanged(runtime_before, label):
|
|||||||
return current
|
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(
|
current = validate_device_plane_preserved_runtime_unchanged(
|
||||||
runtime_before,
|
runtime_before,
|
||||||
"Device Plane backhaul",
|
"Device Plane backhaul",
|
||||||
@@ -10840,7 +10990,11 @@ def validate_device_plane_backhaul_target_runtime(runtime_before):
|
|||||||
f"{required}"
|
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 = (
|
authorized = (
|
||||||
'restrict,port-forwarding,permitopen="127.0.0.1:9921" '
|
'restrict,port-forwarding,permitopen="127.0.0.1:9921" '
|
||||||
f"{enrollment['line']}\n"
|
f"{enrollment['line']}\n"
|
||||||
@@ -12557,6 +12711,9 @@ def is_platform_provider_catalog_only(entries):
|
|||||||
|
|
||||||
|
|
||||||
def component_services(component, entries=None):
|
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):
|
if is_device_plane_backhaul_target_slice(component, entries):
|
||||||
return (DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,)
|
return (DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,)
|
||||||
|
|
||||||
@@ -12931,6 +13088,9 @@ def component_build_args(component, entries=None):
|
|||||||
|
|
||||||
|
|
||||||
def component_builds(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):
|
if is_device_plane_backhaul_target_slice(component, entries):
|
||||||
return ((
|
return ((
|
||||||
DEVICE_PLANE_ROOT,
|
DEVICE_PLANE_ROOT,
|
||||||
@@ -14940,6 +15100,7 @@ def plan_artifact(artifact):
|
|||||||
device_plane_b2_ingress_preflight = None
|
device_plane_b2_ingress_preflight = None
|
||||||
device_plane_b2_recovery_preflight = None
|
device_plane_b2_recovery_preflight = None
|
||||||
device_plane_backhaul_preflight = None
|
device_plane_backhaul_preflight = None
|
||||||
|
device_plane_backhaul_vps_enrollment_preflight = None
|
||||||
device_plane_runtime_before = None
|
device_plane_runtime_before = None
|
||||||
composite_provider_v4_preflight = None
|
composite_provider_v4_preflight = None
|
||||||
provider_rotating_slot_preflight = None
|
provider_rotating_slot_preflight = None
|
||||||
@@ -15149,6 +15310,15 @@ def plan_artifact(artifact):
|
|||||||
device_plane_backhaul_preflight = (
|
device_plane_backhaul_preflight = (
|
||||||
validate_device_plane_backhaul_target_evidence(payload_dir)
|
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"]
|
component = manifest["component"]
|
||||||
root = component_root(component)
|
root = component_root(component)
|
||||||
@@ -16677,6 +16847,55 @@ def plan_artifact(artifact):
|
|||||||
)
|
)
|
||||||
print("device_gateway_tcp_9921=disabled:unpublished")
|
print("device_gateway_tcp_9921=disabled:unpublished")
|
||||||
print("device_plane_rollback=marker-only-runtime-unchanged")
|
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:
|
if device_plane_backhaul_preflight is not None:
|
||||||
print(
|
print(
|
||||||
"device_plane_transition="
|
"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")
|
(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):
|
def read_backup_path_list(path):
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
die(f"deploy backup path list missing: {path}")
|
die(f"deploy backup path list missing: {path}")
|
||||||
@@ -17216,6 +17454,53 @@ def rollback_device_plane_apply(
|
|||||||
return f"source+runtime-restored:{restored_count}"
|
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):
|
def rollback_engine_apply(root, backup_dir, entries, current_stamp, runtime_started, applied_services):
|
||||||
existing = read_backup_path_list(backup_dir / "existing-files.txt")
|
existing = read_backup_path_list(backup_dir / "existing-files.txt")
|
||||||
missing = read_backup_path_list(backup_dir / "missing-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():
|
def ensure_device_plane_backhaul_target_state():
|
||||||
enrollment = read_device_plane_backhaul_enrollment_public_key()
|
enrollment = read_device_plane_backhaul_enrollment_public_key()
|
||||||
DEVICE_PLANE_BACKHAUL_SECRET_DIR.mkdir(
|
DEVICE_PLANE_BACKHAUL_SECRET_DIR.mkdir(
|
||||||
@@ -17818,19 +18129,9 @@ def ensure_device_plane_backhaul_target_state():
|
|||||||
os.chown(path, 0, 0)
|
os.chown(path, 0, 0)
|
||||||
path.chmod(expected_mode)
|
path.chmod(expected_mode)
|
||||||
|
|
||||||
authorized = (
|
expected_authorized_sha256 = install_device_plane_backhaul_authorized_key(
|
||||||
'restrict,port-forwarding,permitopen="127.0.0.1:9921" '
|
enrollment
|
||||||
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)
|
|
||||||
|
|
||||||
DEVICE_PLANE_BACKHAUL_TRUST_DIR.mkdir(parents=True, exist_ok=True)
|
DEVICE_PLANE_BACKHAUL_TRUST_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
os.chown(DEVICE_PLANE_BACKHAUL_TRUST_DIR, 0, 0)
|
os.chown(DEVICE_PLANE_BACKHAUL_TRUST_DIR, 0, 0)
|
||||||
@@ -17846,9 +18147,6 @@ def ensure_device_plane_backhaul_target_state():
|
|||||||
public_temporary.chmod(0o444)
|
public_temporary.chmod(0o444)
|
||||||
os.replace(public_temporary, DEVICE_PLANE_BACKHAUL_HOST_PUBLIC_KEY_FILE)
|
os.replace(public_temporary, DEVICE_PLANE_BACKHAUL_HOST_PUBLIC_KEY_FILE)
|
||||||
|
|
||||||
expected_authorized_sha256 = hashlib.sha256(
|
|
||||||
authorized.encode("ascii")
|
|
||||||
).hexdigest()
|
|
||||||
if (
|
if (
|
||||||
sha256_file(DEVICE_PLANE_BACKHAUL_AUTHORIZED_KEYS_FILE)
|
sha256_file(DEVICE_PLANE_BACKHAUL_AUTHORIZED_KEYS_FILE)
|
||||||
!= expected_authorized_sha256
|
!= expected_authorized_sha256
|
||||||
@@ -17929,6 +18227,14 @@ def prepare_component_runtime(component, entries=None):
|
|||||||
MAP_GATEWAY_SECRET_RE,
|
MAP_GATEWAY_SECRET_RE,
|
||||||
"device plane identifier pepper",
|
"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):
|
if is_device_plane_backhaul_target_slice(component, entries):
|
||||||
ensure_device_plane_backhaul_target_state()
|
ensure_device_plane_backhaul_target_state()
|
||||||
return
|
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):
|
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 is_device_plane_backhaul_target_slice(component, entries):
|
||||||
if tuple(services or ()) != (DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,):
|
if tuple(services or ()) != (DEVICE_PLANE_BACKHAUL_TARGET_SERVICE,):
|
||||||
die("Device Plane backhaul target service set mismatch")
|
die("Device Plane backhaul target service set mismatch")
|
||||||
@@ -19551,6 +19868,7 @@ def apply_artifact(artifact):
|
|||||||
node_intelligence_descriptor = None
|
node_intelligence_descriptor = None
|
||||||
l2_closed_loop_preflight = None
|
l2_closed_loop_preflight = None
|
||||||
device_plane_backhaul_preflight = None
|
device_plane_backhaul_preflight = None
|
||||||
|
device_plane_backhaul_vps_enrollment_preflight = None
|
||||||
node_intelligence_service_stopped = False
|
node_intelligence_service_stopped = False
|
||||||
apply_started = False
|
apply_started = False
|
||||||
engine_backend_recreated = False
|
engine_backend_recreated = False
|
||||||
@@ -19628,6 +19946,15 @@ def apply_artifact(artifact):
|
|||||||
payload_dir
|
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 not root.is_dir():
|
||||||
if bootstrap_root:
|
if bootstrap_root:
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -19955,6 +20282,13 @@ def apply_artifact(artifact):
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
runtime_inventory_path.chmod(0o600)
|
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:
|
if device_plane_backhaul_preflight is not None:
|
||||||
tailscale_before_path = (
|
tailscale_before_path = (
|
||||||
backup_dir / "tailscale-serve-before.json"
|
backup_dir / "tailscale-serve-before.json"
|
||||||
@@ -20067,6 +20401,21 @@ def apply_artifact(artifact):
|
|||||||
validate_device_plane_backhaul_target_runtime(
|
validate_device_plane_backhaul_target_runtime(
|
||||||
device_plane_runtime_before
|
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)
|
applied_path = move_artifact(artifact, APPLIED_DIR)
|
||||||
append_jsonl(STATE_FILE, {
|
append_jsonl(STATE_FILE, {
|
||||||
@@ -20305,6 +20654,41 @@ def apply_artifact(artifact):
|
|||||||
"automatic-rollback=failed",
|
"automatic-rollback=failed",
|
||||||
file=sys.stderr,
|
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 (
|
elif (
|
||||||
component == "device-plane"
|
component == "device-plane"
|
||||||
and entries is not None
|
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