diff --git a/device-plane/deployment/device-edge-vps-tracker-ingress-v1.json b/device-plane/deployment/device-edge-vps-tracker-ingress-v1.json new file mode 100644 index 0000000..ae36755 --- /dev/null +++ b/device-plane/deployment/device-edge-vps-tracker-ingress-v1.json @@ -0,0 +1,69 @@ +{ + "schemaVersion": "nodedc.device-edge-vps.tracker-ingress.v1", + "mode": "provider-neutral-allowlisted-adapter-ingress-over-accepted-core-channel", + "status": "active-tracker-ingress", + "authority": "DCPLATFORM-21/DCPLATFORM-76/ADR-0001", + "component": "device-edge-vps", + "phase": "tracker-ingress", + "runtimeHost": "koffyvngij", + "predecessorPatch": "device-edge-vps-tailscale-retirement-20260812-011", + "predecessorArtifactSha256": "e7b61ec9c83122fa5631467010eff871b98935746df6a1326b6ff6bb9713d877", + "runtimeUser": "nodedc-channel", + "runtimeService": "nodedc-device-edge-channel.service", + "runtimeComposition": "single-process-core-channel-plus-universal-device-gateway", + "publicIngress": "tcp/443-mtls-core-channel+tcp/9921-tracker-telemetry", + "trackerIngress": "enabled:allowlisted-adapters-only", + "initialAdapterProfile": "arusnavi.b2.internal.v1", + "acknowledgementBoundary": "tracker-ack-only-after-core-durable-acceptance", + "health": "127.0.0.1:18222", + "adapterHealth": "127.0.0.1:18221", + "rawDeviceTcp9921": "public-telemetry-ingest", + "commandTransport": "disabled", + "gelios": "untouched", + "tailscale": "absent", + "dataBoundary": "no-vps-database-no-business-logic-no-synology-route", + "resourceCeilings": { + "memory": "192M", + "swap": "0", + "cpu": "75%", + "tasks": 128, + "openFiles": 1024, + "sessions": 128, + "sessionsPerAddress": 16, + "connectionsPerMinutePerAddress": 60, + "sessionBufferBytes": 65536, + "aggregateBufferBytes": 33554432 + }, + "preserved": [ + "management-ssh-key", + "accepted-node-runtime", + "accepted-core-channel-trust-and-registration", + "retired-tailnet-boundary", + "gelios-production-path" + ], + "forbidden": [ + "vps-initiated-synology-connection", + "generic-tcp-forwarding", + "tailscale-runtime", + "docker", + "public-health", + "vps-database", + "vps-business-logic", + "unregistered-adapter", + "device-command" + ], + "acceptance": [ + "exact-tailscale-retirement-011-predecessor", + "single-non-root-edge-process", + "core-channel-remains-accepted", + "public-tracker-tcp-9921-listening", + "adapter-profile-allowlisted", + "bounded-session-and-buffer-limits", + "tracker-ack-after-core-acceptance", + "tailscale-remains-absent", + "no-vps-to-synology-route", + "command-transport-disabled", + "gelios-untouched" + ], + "rollback": "close-9921-restore-exact-tailscale-retirement-011-source-unit-firewall-and-accepted-core-channel-runtime" +} diff --git a/device-plane/services/device-edge-channel/test/vps-runtime-config.test.mjs b/device-plane/services/device-edge-channel/test/vps-runtime-config.test.mjs new file mode 100644 index 0000000..f055de0 --- /dev/null +++ b/device-plane/services/device-edge-channel/test/vps-runtime-config.test.mjs @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + normalizeTrackerIngressConfiguration, +} from "../../../vps/edge-process/device-edge-runtime.mjs"; + +test("VPS tracker ingress resolves one allowlisted profile within bounded limits", () => { + const config = normalizeTrackerIngressConfiguration({}, "edge:moscow-vps-1"); + + assert.equal(config.protocolProfileRef, "arusnavi.b2.internal.v1"); + assert.equal(config.tcpHost, "0.0.0.0"); + assert.equal(config.tcpPort, 9921); + assert.equal(config.maxConcurrentSessions, 128); + assert.equal(config.maxSessionsPerAddress, 16); + assert.equal(config.maxConnectionsPerMinutePerAddress, 60); + assert.equal(config.maxAggregateBufferedBytes, 32 * 1024 * 1024); +}); + +test("VPS tracker ingress rejects hidden bind and non-allowlisted adapters", () => { + assert.throws(() => normalizeTrackerIngressConfiguration({ + DEVICE_GATEWAY_TCP_HOST: "127.0.0.1", + }, "edge:moscow-vps-1"), /public_host_invalid/); + + assert.throws(() => normalizeTrackerIngressConfiguration({ + DEVICE_GATEWAY_PROTOCOL_PROFILE_REF: "vendor.unknown.v1", + }, "edge:moscow-vps-1"), /profile_not_allowlisted/); + + assert.throws(() => normalizeTrackerIngressConfiguration({ + DEVICE_GATEWAY_MAX_SESSIONS: "129", + }, "edge:moscow-vps-1"), /session_limit_invalid/); +}); diff --git a/device-plane/services/device-gateway/src/runtime.mjs b/device-plane/services/device-gateway/src/runtime.mjs index d4dc70c..cd9bfcf 100644 --- a/device-plane/services/device-gateway/src/runtime.mjs +++ b/device-plane/services/device-gateway/src/runtime.mjs @@ -139,6 +139,9 @@ export function createDeviceGatewayRuntime(options = {}) { }, status() { return { + adapter: config.adapter?.adapterRef ?? "disabled", + protocolProfile: config.profile?.profileRef ?? "disabled", + framing: config.profile?.framing?.status ?? "disabled", activeSessions: sessions.size, totalAccepted, totalRejected, diff --git a/device-plane/vps/config/nftables-tracker-ingress.conf b/device-plane/vps/config/nftables-tracker-ingress.conf new file mode 100644 index 0000000..f62de05 --- /dev/null +++ b/device-plane/vps/config/nftables-tracker-ingress.conf @@ -0,0 +1,27 @@ +#!/usr/sbin/nft -f + +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 443 ct state new limit rate 120/minute burst 120 packets accept + tcp dport 9921 ct state new limit rate 600/minute burst 128 packets accept + } + + chain forward { + type filter hook forward priority -10; policy drop; + } + + chain output { + type filter hook output priority -10; policy accept; + } +} diff --git a/device-plane/vps/edge-process/device-edge-runtime.mjs b/device-plane/vps/edge-process/device-edge-runtime.mjs new file mode 100644 index 0000000..fb1e4c5 --- /dev/null +++ b/device-plane/vps/edge-process/device-edge-runtime.mjs @@ -0,0 +1,243 @@ +import { createServer } from "node:http"; +import { pathToFileURL } from "node:url"; + +import { + DEVICE_ADAPTER_CATALOG, +} from "../../packages/device-adapter-catalog/src/index.mjs"; +import { + createDeviceEdgeChannelServer, +} from "../../services/device-edge-channel/src/runtime.mjs"; +import { + readRuntimeConfiguration, +} from "../../services/device-edge-channel/src/server.mjs"; +import { + createDeviceGatewayRuntime, +} from "../../services/device-gateway/src/runtime.mjs"; + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} + +export async function main(environment = process.env) { + const base = await readRuntimeConfiguration(environment); + const tracker = normalizeTrackerIngressConfiguration( + environment, + base.channel.edgeRegistrationId, + ); + const channel = createDeviceEdgeChannelServer(base.channel); + const gateway = createDeviceGatewayRuntime({ + listenEnabled: true, + publicIngressEnabled: true, + coreChannelAuthenticated: true, + adapterRegistry: DEVICE_ADAPTER_CATALOG.registry, + protocolProfileRef: tracker.protocolProfileRef, + edgeRef: tracker.edgeRef, + healthHost: tracker.healthHost, + healthPort: tracker.healthPort, + tcpHost: tracker.tcpHost, + tcpPort: tracker.tcpPort, + maxBufferedBytes: tracker.maxBufferedBytes, + maxAggregateBufferedBytes: tracker.maxAggregateBufferedBytes, + maxConcurrentSessions: tracker.maxConcurrentSessions, + maxSessionsPerAddress: tracker.maxSessionsPerAddress, + maxConnectionsPerMinutePerAddress: + tracker.maxConnectionsPerMinutePerAddress, + maxTrackedSourceAddresses: tracker.maxTrackedSourceAddresses, + sessionTimeoutMs: tracker.sessionTimeoutMs, + onDiscovery: (signal) => channel.submitDiscovery(signal), + onMessage: (message) => channel.submitAdapterMessage(message), + }); + const health = createCombinedHealthServer(channel, gateway, base.health); + let stopping = false; + + try { + await channel.start(); + await gateway.start(); + await listen(health, base.health.port, base.health.host); + } catch (error) { + await Promise.allSettled([ + gateway.stop(), + channel.stop(), + closeServer(health), + ]); + throw error; + } + + console.log(JSON.stringify({ + event: "device_edge_runtime_started", + channel: `${base.channel.host}:${base.channel.port}`, + health: `${base.health.host}:${base.health.port}`, + trackerIngress: `${tracker.tcpHost}:${tracker.tcpPort}`, + adapterProfile: tracker.protocolProfileRef, + edgeRegistrationId: base.channel.edgeRegistrationId, + channelGeneration: base.channel.channelGeneration, + trustGeneration: base.channel.trustGeneration, + commandTransport: "disabled", + })); + + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); + + async function shutdown() { + if (stopping) return; + stopping = true; + await Promise.allSettled([ + gateway.stop(), + channel.stop(), + closeServer(health), + ]); + process.exit(0); + } +} + +export function normalizeTrackerIngressConfiguration(environment = {}, edgeRef) { + return Object.freeze({ + edgeRef: normalizeRef(edgeRef, "device_edge_runtime_edge_ref_invalid"), + protocolProfileRef: normalizeProfileRef( + environment.DEVICE_GATEWAY_PROTOCOL_PROFILE_REF + ?? DEVICE_ADAPTER_CATALOG.defaultProfileRef, + ), + healthHost: normalizeLoopbackHost( + environment.DEVICE_GATEWAY_HEALTH_HOST ?? "127.0.0.1", + ), + healthPort: normalizePort(environment.DEVICE_GATEWAY_HEALTH_PORT, 18221), + tcpHost: normalizePublicHost( + environment.DEVICE_GATEWAY_TCP_HOST ?? "0.0.0.0", + ), + tcpPort: normalizePort(environment.DEVICE_GATEWAY_TCP_PORT, 9921), + maxBufferedBytes: normalizeInteger( + environment.DEVICE_GATEWAY_MAX_BUFFERED_BYTES, + 64 * 1024, + 1024, + 256 * 1024, + "device_edge_runtime_session_buffer_invalid", + ), + maxAggregateBufferedBytes: normalizeInteger( + environment.DEVICE_GATEWAY_MAX_AGGREGATE_BUFFERED_BYTES, + 32 * 1024 * 1024, + 1024, + 32 * 1024 * 1024, + "device_edge_runtime_aggregate_buffer_invalid", + ), + maxConcurrentSessions: normalizeInteger( + environment.DEVICE_GATEWAY_MAX_SESSIONS, + 128, + 1, + 128, + "device_edge_runtime_session_limit_invalid", + ), + maxSessionsPerAddress: normalizeInteger( + environment.DEVICE_GATEWAY_MAX_SESSIONS_PER_ADDRESS, + 16, + 1, + 16, + "device_edge_runtime_address_session_limit_invalid", + ), + maxConnectionsPerMinutePerAddress: normalizeInteger( + environment.DEVICE_GATEWAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS, + 60, + 1, + 60, + "device_edge_runtime_address_rate_limit_invalid", + ), + maxTrackedSourceAddresses: normalizeInteger( + environment.DEVICE_GATEWAY_MAX_TRACKED_SOURCE_ADDRESSES, + 2048, + 1, + 2048, + "device_edge_runtime_source_tracking_limit_invalid", + ), + sessionTimeoutMs: normalizeInteger( + environment.DEVICE_GATEWAY_SESSION_TIMEOUT_MS, + 10_000, + 100, + 60_000, + "device_edge_runtime_session_timeout_invalid", + ), + }); +} + +function createCombinedHealthServer(channel, gateway, healthConfig) { + return createServer((request, response) => { + response.setHeader("Content-Type", "application/json; charset=utf-8"); + response.setHeader("Cache-Control", "no-store"); + response.setHeader("X-Content-Type-Options", "nosniff"); + if (request.method !== "GET" || request.url !== "/healthz") { + response.statusCode = 404; + response.end(JSON.stringify({ ok: false, error: "not_found" })); + return; + } + response.statusCode = 200; + response.end(JSON.stringify({ + ok: true, + service: "nodedc-device-edge-runtime", + health: `${healthConfig.host}:${healthConfig.port}`, + ...channel.status(), + trackerIngress: "telemetry-ingest", + tracker: gateway.status(), + commandTransport: "disabled", + })); + }); +} + +function normalizeRef(value, errorCode) { + if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)) { + throw new TypeError(errorCode); + } + return value; +} + +function normalizeProfileRef(value) { + if (typeof value !== "string" || !/^[a-z][a-z0-9._-]{2,127}$/.test(value)) { + throw new TypeError("device_edge_runtime_profile_ref_invalid"); + } + DEVICE_ADAPTER_CATALOG.registry.resolveProfile(value); + return value; +} + +function normalizeLoopbackHost(value) { + if (!["127.0.0.1", "::1"].includes(value)) { + throw new TypeError("device_edge_runtime_health_host_invalid"); + } + return value; +} + +function normalizePublicHost(value) { + if (!["0.0.0.0", "::"].includes(value)) { + throw new TypeError("device_edge_runtime_public_host_invalid"); + } + return value; +} + +function normalizePort(value, fallback) { + return normalizeInteger( + value, + fallback, + 1, + 65_535, + "device_edge_runtime_port_invalid", + ); +} + +function normalizeInteger(value, fallback, minimum, maximum, errorCode) { + const normalized = Number(value ?? fallback); + if (!Number.isSafeInteger(normalized) || normalized < minimum || normalized > maximum) { + throw new TypeError(errorCode); + } + return normalized; +} + +function listen(server, port, host) { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, host, () => { + server.off("error", reject); + resolve(); + }); + }); +} + +function closeServer(server) { + if (!server.listening) return Promise.resolve(); + return new Promise((resolve) => server.close(() => resolve())); +} diff --git a/device-plane/vps/systemd/nodedc-device-edge-runtime.service b/device-plane/vps/systemd/nodedc-device-edge-runtime.service new file mode 100644 index 0000000..27d284c --- /dev/null +++ b/device-plane/vps/systemd/nodedc-device-edge-runtime.service @@ -0,0 +1,62 @@ +[Unit] +Description=NODE.DC provider-neutral Device Edge runtime +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=nodedc-channel +Group=nodedc-channel +ExecStart=/opt/nodedc-b2-vps/runtime/node/bin/node /opt/nodedc-b2-vps/vps/edge-process/device-edge-runtime.mjs +Environment=DEVICE_EDGE_CHANNEL_HOST=0.0.0.0 +Environment=DEVICE_EDGE_CHANNEL_PORT=443 +Environment=DEVICE_EDGE_CHANNEL_HEALTH_HOST=127.0.0.1 +Environment=DEVICE_EDGE_CHANNEL_HEALTH_PORT=18222 +Environment=DEVICE_EDGE_CHANNEL_CONFIG_FILE=/var/lib/nodedc-b2-vps/channel-trust/runtime.json +Environment=DEVICE_EDGE_CHANNEL_KEY_FILE=/var/lib/nodedc-b2-vps/channel-trust/edge-private-key.pem +Environment=DEVICE_EDGE_CHANNEL_CERTIFICATE_FILE=/var/lib/nodedc-b2-vps/channel-trust/edge-certificate.pem +Environment=DEVICE_EDGE_CHANNEL_CORE_TRUST_FILE=/var/lib/nodedc-b2-vps/channel-trust/core-certificate.pem +Environment=DEVICE_GATEWAY_PROTOCOL_PROFILE_REF=arusnavi.b2.internal.v1 +Environment=DEVICE_GATEWAY_HEALTH_HOST=127.0.0.1 +Environment=DEVICE_GATEWAY_HEALTH_PORT=18221 +Environment=DEVICE_GATEWAY_TCP_HOST=0.0.0.0 +Environment=DEVICE_GATEWAY_TCP_PORT=9921 +Environment=DEVICE_GATEWAY_MAX_BUFFERED_BYTES=65536 +Environment=DEVICE_GATEWAY_MAX_AGGREGATE_BUFFERED_BYTES=33554432 +Environment=DEVICE_GATEWAY_MAX_SESSIONS=128 +Environment=DEVICE_GATEWAY_MAX_SESSIONS_PER_ADDRESS=16 +Environment=DEVICE_GATEWAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS=60 +Environment=DEVICE_GATEWAY_MAX_TRACKED_SOURCE_ADDRESSES=2048 +Environment=DEVICE_GATEWAY_SESSION_TIMEOUT_MS=10000 +Restart=always +RestartSec=2 +TimeoutStartSec=20 +TimeoutStopSec=15 +NoNewPrivileges=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectSystem=strict +ProtectHome=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectHostname=yes +RestrictSUIDSGID=yes +RestrictRealtime=yes +LockPersonality=yes +MemoryDenyWriteExecute=no +SystemCallArchitectures=native +RestrictAddressFamilies=AF_INET AF_INET6 +CapabilityBoundingSet=CAP_NET_BIND_SERVICE +AmbientCapabilities=CAP_NET_BIND_SERVICE +UMask=0077 +MemoryMax=192M +MemorySwapMax=0 +CPUQuota=75% +TasksMax=128 +LimitNOFILE=1024 + +[Install] +WantedBy=multi-user.target diff --git a/infra/deploy-runner/build-device-edge-vps-artifact.mjs b/infra/deploy-runner/build-device-edge-vps-artifact.mjs index fea1b1f..6d452f4 100644 --- a/infra/deploy-runner/build-device-edge-vps-artifact.mjs +++ b/infra/deploy-runner/build-device-edge-vps-artifact.mjs @@ -36,11 +36,12 @@ if ( "relay", "core-channel", "tailscale-retirement", + "tracker-ingress", ].includes(phase) || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId || "") ) { throw new Error( - "usage: build-device-edge-vps-artifact.mjs ", + "usage: build-device-edge-vps-artifact.mjs ", ); } @@ -96,6 +97,19 @@ const entriesByPhase = { "tailscale-retirement": [ "deployment/device-edge-vps-tailscale-retirement-v1.json", ], + "tracker-ingress": [ + "packages/device-adapter-runtime/package.json", + "packages/device-adapter-runtime/src", + "packages/device-adapter-catalog/package.json", + "packages/device-adapter-catalog/src", + "packages/arusnavi-b2-adapter/package.json", + "packages/arusnavi-b2-adapter/src", + "services/device-gateway/src/runtime.mjs", + "vps/edge-process/device-edge-runtime.mjs", + "vps/config/nftables-tracker-ingress.conf", + "vps/systemd/nodedc-device-edge-runtime.service", + "deployment/device-edge-vps-tracker-ingress-v1.json", + ], }; const entries = entriesByPhase[phase]; const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]); @@ -157,6 +171,8 @@ try { ? "tcp/9921" : ["core-channel", "tailscale-retirement"].includes(phase) ? "tcp/443-mtls-only" + : phase === "tracker-ingress" + ? "tcp/443-mtls+tcp/9921-telemetry" : "disabled", commandTransport: "disabled", gelios: "untouched", @@ -314,6 +330,41 @@ async function assertBoundary() { } } } + if (phase === "tracker-ingress") { + for (const required of [ + '"predecessorPatch": "device-edge-vps-tailscale-retirement-20260812-011"', + '"runtimeComposition": "single-process-core-channel-plus-universal-device-gateway"', + '"trackerIngress": "enabled:allowlisted-adapters-only"', + '"acknowledgementBoundary": "tracker-ack-only-after-core-durable-acceptance"', + '"initialAdapterProfile": "arusnavi.b2.internal.v1"', + "createDeviceGatewayRuntime", + "DEVICE_ADAPTER_CATALOG.registry", + "onDiscovery: (signal) => channel.submitDiscovery(signal)", + "onMessage: (message) => channel.submitAdapterMessage(message)", + "tcp dport 9921", + "User=nodedc-channel", + "MemoryMax=192M", + "MemorySwapMax=0", + "CPUQuota=75%", + "TasksMax=128", + "LimitNOFILE=1024", + ]) { + if (!combined.includes(required)) { + throw new Error(`tracker_ingress_boundary_missing:${required}`); + } + } + for (const forbidden of [ + "LocalForward", + "tailscale-userspace", + "DEVICE_EDGE_RELAY_UPSTREAM", + 'commandTransport": "enabled', + "device.dc.ru", + ]) { + if (combined.includes(forbidden)) { + throw new Error(`tracker_ingress_boundary_violation:${forbidden}`); + } + } + } } function canonicalTarScript() { diff --git a/infra/deploy-runner/nodedc-b2-vps-deploy b/infra/deploy-runner/nodedc-b2-vps-deploy index c17f7e3..52faeae 100755 --- a/infra/deploy-runner/nodedc-b2-vps-deploy +++ b/infra/deploy-runner/nodedc-b2-vps-deploy @@ -106,6 +106,12 @@ CORE_CHANNEL_ACCEPTED_PATCH = "device-edge-vps-core-channel-20260812-010" CORE_CHANNEL_ACCEPTED_SHA256 = ( "c8ef3c4bb45850cad32e881eba081bc4c891c2886e5500d02cb94616d82353f3" ) +TAILSCALE_RETIREMENT_ACCEPTED_PATCH = ( + "device-edge-vps-tailscale-retirement-20260812-011" +) +TAILSCALE_RETIREMENT_ACCEPTED_SHA256 = ( + "e7b61ec9c83122fa5631467010eff871b98935746df6a1326b6ff6bb9713d877" +) FOUNDATION_ENTRIES = ( "vps/config/00-nodedc-b2-vps.conf", @@ -143,6 +149,19 @@ CORE_CHANNEL_ENTRIES = ( TAILSCALE_RETIREMENT_ENTRIES = ( "deployment/device-edge-vps-tailscale-retirement-v1.json", ) +TRACKER_INGRESS_ENTRIES = ( + "packages/device-adapter-runtime/package.json", + "packages/device-adapter-runtime/src", + "packages/device-adapter-catalog/package.json", + "packages/device-adapter-catalog/src", + "packages/arusnavi-b2-adapter/package.json", + "packages/arusnavi-b2-adapter/src", + "services/device-gateway/src/runtime.mjs", + "vps/edge-process/device-edge-runtime.mjs", + "vps/config/nftables-tracker-ingress.conf", + "vps/systemd/nodedc-device-edge-runtime.service", + "deployment/device-edge-vps-tracker-ingress-v1.json", +) PHASE_ENTRIES = { "foundation": FOUNDATION_ENTRIES, @@ -151,6 +170,7 @@ PHASE_ENTRIES = { "relay": RELAY_ENTRIES, "core-channel": CORE_CHANNEL_ENTRIES, "tailscale-retirement": TAILSCALE_RETIREMENT_ENTRIES, + "tracker-ingress": TRACKER_INGRESS_ENTRIES, } SUPERSEDED_TRANSPORT_PHASES = frozenset({"backhaul", "relay"}) @@ -218,6 +238,30 @@ PHASE_FILE_SHA256 = { "deployment/device-edge-vps-tailscale-retirement-v1.json": "bbe11e8cf4103f44ae7888b4d3f3dde7015eeaef8ea5a7c4c00427a7b85f8e33", }, + "tracker-ingress": { + "packages/device-adapter-runtime/package.json": + "293ee6010f255c511df40b34cf8019fcd0d1b06babe30761c2a842c7da7af778", + "packages/device-adapter-runtime/src/index.mjs": + "199fcdd775d1ea15ddc9f85ab24fc2c165bb7757008de26749f1b7e84481dfeb", + "packages/device-adapter-catalog/package.json": + "60dbfc551ca49719e0851755058c159968411c490621eac0bf96ca76bf8b78d1", + "packages/device-adapter-catalog/src/index.mjs": + "614749e763c0624bf0de7905343632d3edb4e026316f427a2f52ce12c4225dca", + "packages/arusnavi-b2-adapter/package.json": + "136fc997f5d6757ba81d370f03037e0a93fd4750dc624def1af593e7ba8b9098", + "packages/arusnavi-b2-adapter/src/index.mjs": + "c5d95391438dfff1f1c12396b2e919effca9003a4a4a3eb3b828be42790f6173", + "services/device-gateway/src/runtime.mjs": + "bf0697674ed150d5e043e19e31fa99a242bb5dcc7d184e7adaccd883a22c5e5e", + "vps/edge-process/device-edge-runtime.mjs": + "7616e894e55b2579de7c2652589baf3cd3804dc0ec8af69661eadac1098979e9", + "vps/config/nftables-tracker-ingress.conf": + "6a6a76a02e5908118104a9a54a9e41b5a7e04476418efdb1485e39f0ee7c995c", + "vps/systemd/nodedc-device-edge-runtime.service": + "7e639efb66eee891da85b2976353715d942bd8aa381ddc1383446debf279817b", + "deployment/device-edge-vps-tracker-ingress-v1.json": + "faecfa0317ad0e3d102c3636c0547b65257b930e9b48f34d8679c00ceb3d323b", + }, } # Exact immutable baselines from terminally accepted predecessor artifacts. @@ -617,6 +661,19 @@ def current_phase_preflight(phase: str): return { "predecessor": "failed-core-channel-001-rollback-runtime-mode-drift", } + if phase == "tracker-ingress": + retirement_record = applied_phase_record("tailscale-retirement") + if ( + retirement_record.get("patch") + != TAILSCALE_RETIREMENT_ACCEPTED_PATCH + or retirement_record.get("sha256") + != TAILSCALE_RETIREMENT_ACCEPTED_SHA256 + ): + die("VPS tracker ingress Tailscale retirement predecessor mismatch") + validate_tailscale_retirement_runtime() + if (LIVE_ROOT / TRACKER_INGRESS_ENTRIES[-1]).exists(): + die("VPS tracker ingress target path already exists") + return {"predecessor": "accepted-tailscale-retirement-011"} validate_foundation_runtime( require_running_tailnet=phase in {"backhaul", "relay"}, expected_key_user=BACKHAUL_USER if phase == "relay" else SERVICE_USER, @@ -724,6 +781,8 @@ def backup_targets_for_phase(phase: str): TAILSCALE_BIN.parent, TRUST_ROOT, ] + if phase == "tracker-ingress": + return common + [CHANNEL_UNIT, NFTABLES_CONFIG] return common + [RELAY_UNIT, NFTABLES_CONFIG] @@ -1194,6 +1253,18 @@ def apply_tailscale_retirement(_payload: Path): validate_tailscale_retirement_runtime() +def apply_tracker_ingress(_payload: Path): + install_file( + LIVE_ROOT / TRACKER_INGRESS_ENTRIES[9], + CHANNEL_UNIT, + 0o644, + ) + apply_nftables(LIVE_ROOT / TRACKER_INGRESS_ENTRIES[8]) + systemctl("daemon-reload") + systemctl("restart", "nodedc-device-edge-channel.service") + validate_tracker_ingress_runtime() + + def sshd_effective(): return run(["/usr/sbin/sshd", "-T"]).stdout.lower() @@ -1462,6 +1533,93 @@ def validate_tailscale_retirement_runtime(): return health +def validate_tracker_ingress_runtime(): + source_file_state("foundation") + source_file_state("core-channel") + source_file_state("tailscale-retirement") + source_file_state("tracker-ingress") + retirement_record = applied_phase_record("tailscale-retirement") + if ( + retirement_record.get("patch") != TAILSCALE_RETIREMENT_ACCEPTED_PATCH + or retirement_record.get("sha256") + != TAILSCALE_RETIREMENT_ACCEPTED_SHA256 + ): + die("tracker ingress retirement identity mismatch") + if run([str(NODE_BIN), "--version"]).stdout.strip() != f"v{NODE_VERSION}": + die("tracker ingress Node version mismatch") + for path in ( + TAILSCALE_UNIT, + TAILSCALE_SOCKET, + TAILSCALE_STATE.parent, + TAILSCALE_BIN.parent, + TRUST_ROOT, + ): + if path.exists() or path.is_symlink(): + die(f"tracker ingress superseded trust boundary present: {path}") + if service_active("nodedc-b2-tailscaled.service"): + die("tracker ingress Tailscale service became active") + if service_active("nodedc-b2-backhaul.service"): + die("tracker ingress frozen backhaul service became active") + if service_active("nodedc-b2-relay.service"): + die("tracker ingress frozen relay service became active") + assert_management_key() + assert_channel_trust(require_runtime_owner=True) + if not service_active("nodedc-device-edge-channel.service"): + die("VPS Device Edge runtime is not active") + health = core_channel_health(require_accepted=True) + expected = { + "ok": True, + "service": "nodedc-device-edge-runtime", + "channel": "accepted", + "trackerIngress": "telemetry-ingest", + "commandTransport": "disabled", + } + for key, value in expected.items(): + if health.get(key) != value: + die(f"VPS tracker ingress health contract mismatch: {key}") + tracker = health.get("tracker") or {} + for key, value in { + "adapter": "arusnavi-b2", + "protocolProfile": "arusnavi.b2.internal.v1", + "framing": "verified-read-only", + "publicIngress": "telemetry-ingest", + "commandTransport": "disabled", + }.items(): + if tracker.get(key) != value: + die(f"VPS tracker adapter health contract mismatch: {key}") + for port in (22, CHANNEL_PUBLIC_PORT, 9921): + if not port_is_open(PUBLIC_IPV4, port, timeout=5): + die(f"VPS required public listener is unavailable: {port}") + if not port_is_open("127.0.0.1", 18221, timeout=5): + die("VPS tracker adapter health listener is unavailable") + if port_is_open("127.0.0.1", 1055): + die("VPS retired Tailscale SOCKS listener returned") + nft = run(["/usr/sbin/nft", "list", "table", "inet", "nodedc_b2_vps"]).stdout + for required in ("policy drop", "tcp dport 22", "tcp dport 443", "tcp dport 9921"): + if required not in nft: + die(f"VPS tracker ingress firewall contract mismatch: {required}") + unit = run([ + "/usr/bin/systemctl", + "show", + "nodedc-device-edge-channel.service", + "--property=User,Group,NoNewPrivileges,CapabilityBoundingSet,AmbientCapabilities,MemoryMax,MemorySwapMax,CPUQuotaPerSecUSec,TasksMax,LimitNOFILE", + ]).stdout + for required in ( + "User=nodedc-channel", + "Group=nodedc-channel", + "NoNewPrivileges=yes", + "CapabilityBoundingSet=cap_net_bind_service", + "AmbientCapabilities=cap_net_bind_service", + "MemoryMax=201326592", + "MemorySwapMax=0", + "TasksMax=128", + "LimitNOFILE=1024", + ): + if required not in unit: + die(f"VPS tracker ingress resource boundary mismatch: {required}") + return health + + def validate_relay_runtime(): validate_backhaul_runtime() source_file_state("relay") @@ -1621,7 +1779,7 @@ def plan_artifact(artifact_argument: str): print("services=nodedc-device-edge-channel") print(f"channel_runtime_identity={CHANNEL_USER}:host-local-private-key") print("peer_trust=preprovisioned-pinned-self-signed-core-certificate+fingerprint") - else: + elif phase == "tailscale-retirement": print("public_core_channel=preserved:155.212.211.15:443/tcp:tls13-mtls-h2") print(f"health=preserved:127.0.0.1:{CHANNEL_HEALTH_PORT}") print("tailscale=stop+disable+destroy-local-runtime-state") @@ -1631,6 +1789,15 @@ def plan_artifact(artifact_argument: str): print("tracker_tcp_9921=closed") print("services=preserved:nodedc-device-edge-channel") print("external_tailnet_machine_cleanup=required-after-deploy-ok") + else: + print("public_core_channel=preserved:155.212.211.15:443/tcp:tls13-mtls-h2") + print(f"health=127.0.0.1:{CHANNEL_HEALTH_PORT}:combined-edge-runtime") + print("public_b2_ingress=155.212.211.15:9921/tcp:telemetry-only") + print("tracker_adapter=arusnavi-b2:profile=arusnavi.b2.internal.v1") + print("tracker_ack=after-core-durable-acceptance-only") + print("runtime_composition=single-non-root-process:core-channel+universal-gateway") + print("tailscale=preserved:absent") + print("services=recreate:nodedc-device-edge-channel") print("command_transport=disabled") print("gelios=untouched") print("dns=unchanged") @@ -1666,8 +1833,10 @@ def apply_artifact(artifact_argument: str): apply_relay(loaded["payload"]) elif loaded["phase"] == "core-channel": apply_core_channel(loaded["payload"]) - else: + elif loaded["phase"] == "tailscale-retirement": apply_tailscale_retirement(loaded["payload"]) + else: + apply_tracker_ingress(loaded["payload"]) archived = archive_artifact(loaded["artifact"], APPLIED_ROOT) record = { diff --git a/infra/deploy-runner/test_device_edge_vps_artifact.py b/infra/deploy-runner/test_device_edge_vps_artifact.py index a651725..649b8d5 100644 --- a/infra/deploy-runner/test_device_edge_vps_artifact.py +++ b/infra/deploy-runner/test_device_edge_vps_artifact.py @@ -99,6 +99,7 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase): "relay", "core-channel", "tailscale-retirement", + "tracker-ingress", ): with self.subTest(phase=phase), tempfile.TemporaryDirectory( prefix=f"nodedc-vps-{phase}-" @@ -178,6 +179,7 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase): "relay", "core-channel", "tailscale-retirement", + "tracker-ingress", ): result = self.build( inbox, @@ -361,6 +363,45 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase): self.assertIn("command_transport=disabled", rendered) self.assertIn("gelios=untouched", rendered) + def test_tracker_ingress_plan_is_single_process_bounded_and_command_free(self): + with tempfile.TemporaryDirectory(prefix="nodedc-vps-ingress-plan-") as directory: + inbox = Path(directory) / "inbox" + inbox.mkdir() + result = self.build( + inbox, + "tracker-ingress", + "device-edge-vps-tracker-ingress-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": "accepted-tailscale-retirement-011"}, + ), 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=tracker-ingress", rendered) + self.assertIn("predecessor=accepted-tailscale-retirement-011", rendered) + self.assertIn( + "public_b2_ingress=155.212.211.15:9921/tcp:telemetry-only", + rendered, + ) + self.assertIn("tracker_adapter=arusnavi-b2", rendered) + self.assertIn("tracker_ack=after-core-durable-acceptance-only", rendered) + self.assertIn("runtime_composition=single-non-root-process", rendered) + self.assertIn("tailscale=preserved:absent", rendered) + self.assertIn("command_transport=disabled", rendered) + self.assertIn("gelios=untouched", rendered) + def test_publish_payload_preserves_unselected_executable_modes(self): with tempfile.TemporaryDirectory(prefix="nodedc-vps-publish-scope-") as directory: root = Path(directory) @@ -435,6 +476,7 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase): foundation = (source_root / "vps/config/nftables-foundation.conf").read_text() relay = (source_root / "vps/config/nftables-relay.conf").read_text() channel = (source_root / "vps/config/nftables-core-channel.conf").read_text() + tracker = (source_root / "vps/config/nftables-tracker-ingress.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 = ( @@ -447,6 +489,9 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase): channel_unit = ( source_root / "vps/systemd/nodedc-device-edge-channel.service" ).read_text() + tracker_unit = ( + source_root / "vps/systemd/nodedc-device-edge-runtime.service" + ).read_text() self.assertIn("policy drop", foundation) self.assertIn("tcp dport 22", foundation) @@ -454,6 +499,8 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase): self.assertIn("tcp dport 9921", relay) self.assertIn("tcp dport 443", channel) self.assertNotIn("tcp dport 9921", channel) + self.assertIn("tcp dport 443", tracker) + self.assertIn("tcp dport 9921", tracker) self.assertIn("PasswordAuthentication no", sshd) self.assertIn("AllowTcpForwarding no", sshd) self.assertIn("StrictHostKeyChecking yes", backhaul) @@ -490,6 +537,17 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase): self.assertIn("LimitNOFILE=1024", channel_unit) self.assertNotIn("LocalForward", channel_unit) self.assertNotIn("DEVICE_EDGE_RELAY_UPSTREAM", channel_unit) + self.assertIn("User=nodedc-channel", tracker_unit) + self.assertIn("vps/edge-process/device-edge-runtime.mjs", tracker_unit) + self.assertIn("DEVICE_GATEWAY_TCP_PORT=9921", tracker_unit) + self.assertIn("DEVICE_GATEWAY_MAX_SESSIONS=128", tracker_unit) + self.assertIn("MemoryMax=192M", tracker_unit) + self.assertIn("MemorySwapMax=0", tracker_unit) + self.assertIn("CPUQuota=75%", tracker_unit) + self.assertIn("TasksMax=128", tracker_unit) + self.assertIn("LimitNOFILE=1024", tracker_unit) + self.assertNotIn("LocalForward", tracker_unit) + self.assertNotIn("DEVICE_EDGE_RELAY_UPSTREAM", tracker_unit) def test_runner_has_registered_rollback_and_no_generic_latest(self): source = RUNNER_PATH.read_text(encoding="utf-8")