diff --git a/device-plane/deployment/device-edge-vps-core-channel-v1.json b/device-plane/deployment/device-edge-vps-core-channel-v1.json new file mode 100644 index 0000000..4e6e8cc --- /dev/null +++ b/device-plane/deployment/device-edge-vps-core-channel-v1.json @@ -0,0 +1,54 @@ +{ + "schemaVersion": "nodedc.device-edge-vps.core-channel.v1", + "mode": "provider-neutral-core-initiated-mtls-http2", + "status": "closed-tracker-ingress", + "authority": "DCPLATFORM-21/DCPLATFORM-76/ADR-0001", + "component": "device-edge-vps", + "phase": "core-channel", + "runtimeHost": "koffyvngij", + "runtimeUser": "nodedc-channel", + "runtimeService": "nodedc-device-edge-channel.service", + "runtime": "accepted-node-v22.23.2-no-docker", + "publicIngress": "tcp/8443-mtls-only", + "health": "127.0.0.1:18222", + "trackerIngress": "disabled", + "rawDeviceTcp9921": "closed", + "commandTransport": "disabled", + "gelios": "untouched", + "privateKeyBoundary": "runner-managed-host-local-only", + "peerTrustPrerequisite": "exact-pinned-self-signed-core-certificate-and-fingerprint", + "tls": "TLSv1.3+h2+mutual-authentication", + "resourceCeilings": { + "memory": "128M", + "swap": "0", + "cpu": "50%", + "tasks": 64, + "openFiles": 1024 + }, + "preserved": [ + "management-ssh-key", + "accepted-node-runtime", + "foundation-source", + "gelios-production-path" + ], + "forbidden": [ + "vps-initiated-synology-connection", + "generic-tcp-forwarding", + "tailscale-ssh-backhaul", + "docker", + "public-health", + "tracker-tcp/9921" + ], + "acceptance": [ + "exact-non-root-runtime-identity", + "tls13-h2-mutual-authentication", + "edge-server-and-core-client-self-signed-identities-mutually-pinned", + "core-initiated-channel-accepted", + "unknown-core-certificate-rejected", + "public-8443-only-beside-management-ssh", + "tracker-tcp-9921-closed", + "loopback-health-contract", + "resource-ceilings-present" + ], + "rollback": "close-8443-stop-channel-restore-exact-accepted-foundation-without-backhaul-or-relay" +} diff --git a/device-plane/services/device-edge-channel/src/server.mjs b/device-plane/services/device-edge-channel/src/server.mjs new file mode 100644 index 0000000..555ff3e --- /dev/null +++ b/device-plane/services/device-edge-channel/src/server.mjs @@ -0,0 +1,203 @@ +import { lstat, readFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { pathToFileURL } from "node:url"; + +import { createDeviceEdgeChannelServer } from "./runtime.mjs"; + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} + +async function main() { + const runtime = await readRuntimeConfiguration(process.env); + const channel = createDeviceEdgeChannelServer(runtime.channel); + const health = createHealthServer(channel, runtime.health); + let stopping = false; + + await channel.start(); + await listen(health, runtime.health.port, runtime.health.host); + + console.log(JSON.stringify({ + event: "device_edge_channel_started", + host: runtime.channel.host, + port: runtime.channel.port, + healthHost: runtime.health.host, + healthPort: runtime.health.port, + edgeRegistrationId: runtime.channel.edgeRegistrationId, + channelGeneration: runtime.channel.channelGeneration, + trustGeneration: runtime.channel.trustGeneration, + trackerIngress: "disabled", + commandTransport: "disabled", + })); + + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); + + async function shutdown() { + if (stopping) return; + stopping = true; + await Promise.allSettled([ + channel.stop(), + closeServer(health), + ]); + process.exit(0); + } +} + +export async function readRuntimeConfiguration(environment = {}) { + const configPath = requiredPath( + environment.DEVICE_EDGE_CHANNEL_CONFIG_FILE, + "device_edge_channel_config_file_required", + ); + const keyPath = requiredPath( + environment.DEVICE_EDGE_CHANNEL_KEY_FILE, + "device_edge_channel_key_file_required", + ); + const certificatePath = requiredPath( + environment.DEVICE_EDGE_CHANNEL_CERTIFICATE_FILE, + "device_edge_channel_certificate_file_required", + ); + const coreTrustPath = requiredPath( + environment.DEVICE_EDGE_CHANNEL_CORE_TRUST_FILE, + "device_edge_channel_core_trust_file_required", + ); + const config = normalizeRuntimeDocument(JSON.parse( + await readBoundedRegularFile(configPath, 32 * 1024, "utf8"), + )); + const [key, cert, ca] = await Promise.all([ + readBoundedRegularFile(keyPath, 32 * 1024), + readBoundedRegularFile(certificatePath, 32 * 1024), + readBoundedRegularFile(coreTrustPath, 64 * 1024), + ]); + return Object.freeze({ + channel: Object.freeze({ + edgeRegistrationId: config.edgeRegistrationId, + channelGeneration: config.channelGeneration, + trustGeneration: config.trustGeneration, + host: normalizeHost(environment.DEVICE_EDGE_CHANNEL_HOST ?? "0.0.0.0"), + port: normalizePort(environment.DEVICE_EDGE_CHANNEL_PORT, 8443), + tls: Object.freeze({ + key, + cert, + ca, + allowedCoreFingerprints: config.allowedCoreFingerprints, + }), + }), + health: Object.freeze({ + host: normalizeHost( + environment.DEVICE_EDGE_CHANNEL_HEALTH_HOST ?? "127.0.0.1", + ), + port: normalizePort(environment.DEVICE_EDGE_CHANNEL_HEALTH_PORT, 18222), + }), + }); +} + +export function normalizeRuntimeDocument(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new TypeError("device_edge_channel_runtime_config_invalid"); + } + const allowedKeys = new Set([ + "schemaVersion", + "edgeRegistrationId", + "channelGeneration", + "trustGeneration", + "allowedCoreFingerprints", + ]); + if (Object.keys(value).some((key) => !allowedKeys.has(key))) { + throw new TypeError("device_edge_channel_runtime_config_key_invalid"); + } + if (value.schemaVersion !== "nodedc.device-edge.channel-runtime.v1") { + throw new TypeError("device_edge_channel_runtime_schema_invalid"); + } + const fingerprints = value.allowedCoreFingerprints; + if (!Array.isArray(fingerprints) || fingerprints.length < 1 || fingerprints.length > 2) { + throw new TypeError("device_edge_channel_runtime_core_identity_invalid"); + } + if (new Set(fingerprints).size !== fingerprints.length) { + throw new TypeError("device_edge_channel_runtime_core_identity_invalid"); + } + return Object.freeze({ + schemaVersion: value.schemaVersion, + edgeRegistrationId: normalizeRef(value.edgeRegistrationId, "edge_registration"), + channelGeneration: normalizeRef(value.channelGeneration, "channel_generation"), + trustGeneration: normalizeRef(value.trustGeneration, "trust_generation"), + allowedCoreFingerprints: Object.freeze(fingerprints.map((fingerprint) => { + if (typeof fingerprint !== "string" || !/^([A-F0-9]{2}:){31}[A-F0-9]{2}$/.test(fingerprint)) { + throw new TypeError("device_edge_channel_runtime_core_identity_invalid"); + } + return fingerprint; + })), + }); +} + +function createHealthServer(channel, 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; + } + const status = channel.status(); + response.statusCode = 200; + response.end(JSON.stringify({ + ok: true, + service: "nodedc-device-edge-channel", + health: `${healthConfig.host}:${healthConfig.port}`, + ...status, + })); + }); +} + +async function readBoundedRegularFile(path, maximumBytes, encoding = null) { + const state = await lstat(path); + if (!state.isFile() || state.isSymbolicLink() || state.size < 1 || state.size > maximumBytes) { + throw new Error("device_edge_channel_runtime_file_invalid"); + } + return readFile(path, encoding ?? undefined); +} + +function requiredPath(value, code) { + if (typeof value !== "string" || value.trim() === "" || !value.startsWith("/")) { + throw new Error(code); + } + return value; +} + +function normalizeRef(value, field) { + if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)) { + throw new TypeError(`device_edge_channel_runtime_${field}_invalid`); + } + return value; +} + +function normalizeHost(value) { + if (typeof value !== "string" || value.length < 1 || value.length > 253) { + throw new TypeError("device_edge_channel_runtime_host_invalid"); + } + return value; +} + +function normalizePort(value, fallback) { + const number = Number(value ?? fallback); + if (!Number.isSafeInteger(number) || number < 1 || number > 65_535) { + throw new TypeError("device_edge_channel_runtime_port_invalid"); + } + return number; +} + +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) { + return new Promise((resolve) => server.close(() => resolve())); +} diff --git a/device-plane/services/device-edge-channel/test/server-config.test.mjs b/device-plane/services/device-edge-channel/test/server-config.test.mjs new file mode 100644 index 0000000..80cfd72 --- /dev/null +++ b/device-plane/services/device-edge-channel/test/server-config.test.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { normalizeRuntimeDocument } from "../src/server.mjs"; + +const fingerprint = Array.from({ length: 32 }, () => "AB").join(":"); + +test("runtime document accepts one generation-bound Core identity", () => { + const result = normalizeRuntimeDocument({ + schemaVersion: "nodedc.device-edge.channel-runtime.v1", + edgeRegistrationId: "edge:moscow-vps-1", + channelGeneration: "channel:1", + trustGeneration: "trust:1", + allowedCoreFingerprints: [fingerprint], + }); + + assert.equal(result.edgeRegistrationId, "edge:moscow-vps-1"); + assert.deepEqual(result.allowedCoreFingerprints, [fingerprint]); +}); + +test("runtime document rejects hidden authority and missing identity", () => { + assert.throws(() => normalizeRuntimeDocument({ + schemaVersion: "nodedc.device-edge.channel-runtime.v1", + edgeRegistrationId: "edge:moscow-vps-1", + channelGeneration: "channel:1", + trustGeneration: "trust:1", + allowedCoreFingerprints: [fingerprint], + endpoint: "https://attacker.invalid", + }), /runtime_config_key_invalid/); + + assert.throws(() => normalizeRuntimeDocument({ + schemaVersion: "nodedc.device-edge.channel-runtime.v1", + edgeRegistrationId: "edge:moscow-vps-1", + channelGeneration: "channel:1", + trustGeneration: "trust:1", + allowedCoreFingerprints: [], + }), /runtime_core_identity_invalid/); + + assert.throws(() => normalizeRuntimeDocument({ + schemaVersion: "nodedc.device-edge.channel-runtime.v1", + edgeRegistrationId: "edge:moscow-vps-1", + channelGeneration: "channel:1", + trustGeneration: "trust:1", + allowedCoreFingerprints: [fingerprint, fingerprint], + }), /runtime_core_identity_invalid/); +}); diff --git a/device-plane/vps/config/nftables-core-channel.conf b/device-plane/vps/config/nftables-core-channel.conf new file mode 100644 index 0000000..40b45f5 --- /dev/null +++ b/device-plane/vps/config/nftables-core-channel.conf @@ -0,0 +1,26 @@ +#!/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 8443 ct state new limit rate 120/minute burst 120 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/systemd/nodedc-device-edge-channel.service b/device-plane/vps/systemd/nodedc-device-edge-channel.service new file mode 100644 index 0000000..0e41d4e --- /dev/null +++ b/device-plane/vps/systemd/nodedc-device-edge-channel.service @@ -0,0 +1,50 @@ +[Unit] +Description=NODE.DC provider-neutral Device Edge Core channel +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 --jitless /opt/nodedc-b2-vps/services/device-edge-channel/src/server.mjs +Environment=DEVICE_EDGE_CHANNEL_HOST=0.0.0.0 +Environment=DEVICE_EDGE_CHANNEL_PORT=8443 +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 +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=yes +SystemCallArchitectures=native +RestrictAddressFamilies=AF_INET AF_INET6 +CapabilityBoundingSet= +AmbientCapabilities= +UMask=0077 +MemoryMax=128M +MemorySwapMax=0 +CPUQuota=50% +TasksMax=64 +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 808255d..1bb197a 100644 --- a/infra/deploy-runner/build-device-edge-vps-artifact.mjs +++ b/infra/deploy-runner/build-device-edge-vps-artifact.mjs @@ -29,11 +29,11 @@ const runtimeCache = resolve( const [phase, patchId, ...extra] = process.argv.slice(2); if ( extra.length - || !["foundation", "backhaul", "relay"].includes(phase) + || !["foundation", "backhaul", "relay", "core-channel"].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 ", ); } @@ -72,6 +72,17 @@ const entriesByPhase = { "services/device-edge-relay/src", "deployment/device-edge-vps-relay-v1.json", ], + "core-channel": [ + "packages/device-protocol-contract/package.json", + "packages/device-protocol-contract/src", + "packages/device-edge-channel-contract/package.json", + "packages/device-edge-channel-contract/src", + "services/device-edge-channel/package.json", + "services/device-edge-channel/src", + "vps/config/nftables-core-channel.conf", + "vps/systemd/nodedc-device-edge-channel.service", + "deployment/device-edge-vps-core-channel-v1.json", + ], }; const entries = entriesByPhase[phase]; const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]); @@ -129,7 +140,11 @@ try { size: bytes.length, component: "device-edge-vps", entries, - publicIngress: phase === "relay" ? "tcp/9921" : "disabled", + publicIngress: phase === "relay" + ? "tcp/9921" + : phase === "core-channel" + ? "tcp/8443-mtls-only" + : "disabled", commandTransport: "disabled", gelios: "untouched", }, null, 2)); @@ -221,6 +236,34 @@ async function assertBoundary() { } } } + if (phase === "core-channel") { + for (const required of [ + "\"runtimeUser\": \"nodedc-channel\"", + "\"trackerIngress\": \"disabled\"", + "User=nodedc-channel", + "node --jitless", + "tcp dport 8443", + "MemoryMax=128M", + "MemorySwapMax=0", + "CPUQuota=50%", + "TasksMax=64", + "LimitNOFILE=1024", + ]) { + if (!combined.includes(required)) { + throw new Error(`core_channel_boundary_missing:${required}`); + } + } + for (const forbidden of [ + "tcp dport 9921", + "LocalForward", + "tailscale-userspace", + "DEVICE_EDGE_RELAY_UPSTREAM", + ]) { + if (combined.includes(forbidden)) { + throw new Error(`core_channel_boundary_violation:${forbidden}`); + } + } + } } function canonicalTarScript() { diff --git a/infra/deploy-runner/nodedc-b2-vps-deploy b/infra/deploy-runner/nodedc-b2-vps-deploy index 23f9e72..91d8641 100755 --- a/infra/deploy-runner/nodedc-b2-vps-deploy +++ b/infra/deploy-runner/nodedc-b2-vps-deploy @@ -44,6 +44,8 @@ BACKHAUL_USER = "nodedc-backhaul" BACKHAUL_GROUP = "nodedc-backhaul" RELAY_USER = "nodedc-relay" RELAY_GROUP = "nodedc-relay" +CHANNEL_USER = "nodedc-channel" +CHANNEL_GROUP = "nodedc-channel" TAILSCALE_REQUIRED_TAG = "tag:device-edge-vps" MANAGEMENT_KEY_FINGERPRINT = ( "SHA256:DYYy1E3DaxIQGC0jnsW6SP7gXdBHUy3A1zn4pvgVUEw" @@ -89,6 +91,14 @@ NFTABLES_CONFIG = Path("/etc/nftables.conf") TAILSCALE_UNIT = Path("/etc/systemd/system/nodedc-b2-tailscaled.service") BACKHAUL_UNIT = Path("/etc/systemd/system/nodedc-b2-backhaul.service") RELAY_UNIT = Path("/etc/systemd/system/nodedc-b2-relay.service") +CHANNEL_UNIT = Path("/etc/systemd/system/nodedc-device-edge-channel.service") +CHANNEL_TRUST_ROOT = Path("/var/lib/nodedc-b2-vps/channel-trust") +CHANNEL_PRIVATE_KEY = CHANNEL_TRUST_ROOT / "edge-private-key.pem" +CHANNEL_CERTIFICATE = CHANNEL_TRUST_ROOT / "edge-certificate.pem" +CHANNEL_CORE_CERTIFICATE = CHANNEL_TRUST_ROOT / "core-certificate.pem" +CHANNEL_RUNTIME_CONFIG = CHANNEL_TRUST_ROOT / "runtime.json" +CHANNEL_HEALTH_PORT = 18222 +CHANNEL_PUBLIC_PORT = 8443 FOUNDATION_ENTRIES = ( "vps/config/00-nodedc-b2-vps.conf", @@ -109,11 +119,23 @@ RELAY_ENTRIES = ( "services/device-edge-relay/src", "deployment/device-edge-vps-relay-v1.json", ) +CORE_CHANNEL_ENTRIES = ( + "packages/device-protocol-contract/package.json", + "packages/device-protocol-contract/src", + "packages/device-edge-channel-contract/package.json", + "packages/device-edge-channel-contract/src", + "services/device-edge-channel/package.json", + "services/device-edge-channel/src", + "vps/config/nftables-core-channel.conf", + "vps/systemd/nodedc-device-edge-channel.service", + "deployment/device-edge-vps-core-channel-v1.json", +) PHASE_ENTRIES = { "foundation": FOUNDATION_ENTRIES, "backhaul": BACKHAUL_ENTRIES, "relay": RELAY_ENTRIES, + "core-channel": CORE_CHANNEL_ENTRIES, } SUPERSEDED_TRANSPORT_PHASES = frozenset({"backhaul", "relay"}) @@ -121,19 +143,19 @@ SUPERSEDED_TRANSPORT_PHASES = frozenset({"backhaul", "relay"}) PHASE_FILE_SHA256 = { "foundation": { "vps/config/00-nodedc-b2-vps.conf": - "cc94d0579f85d0af9746b9ce760bc72980f4a22fb59027e1f5f9c7bf3aaebd64", + "2079748f48b2297ecb067e16ae46248e3a981b26331aa0998dc14fdf7454cd4a", "vps/config/nftables-foundation.conf": - "4d44f902d8d98d1aa8506fca9d9582e700f6424def2b1d667ab2cd5a5ee84934", + "bce5b8c6e2226d47d8d322f8ee9e7158f2d7a553c5e2c4bc2a722d6067f46e28", "vps/systemd/nodedc-b2-tailscaled.service": "de147d29bc1759f56533d31058993df55e1200973f2899903bdbf3d13ff579da", "deployment/device-edge-vps-foundation-v1.json": - "317c98b42520fff3238275908482de7aa611b4ee41c6b1f8062abd2730ab072a", + "5ae007195b17d6cfaeb564abbed6dc9234eb36bf62db23bf2b9ace539c398898", f"vendor/{NODE_ARCHIVE}": NODE_ARCHIVE_SHA256, f"vendor/{TAILSCALE_ARCHIVE}": TAILSCALE_ARCHIVE_SHA256, }, "backhaul": { "vps/config/backhaul_ssh_config": - "ff1a3575b5a55a56b08a8642aa820d1f8e5f07e0e7698fdd0e01a142452674d4", + "d0df8b70dda025b7c1c3fecd2dafffe60a5bb753650d3bc37db65db626cfc1af", "vps/systemd/nodedc-b2-backhaul.service": "64c26cad21cc17675c67ae4a57fc43b129065a8d22fda894648340b310d3aa8c", "deployment/device-edge-vps-backhaul-v1.json": @@ -141,7 +163,7 @@ PHASE_FILE_SHA256 = { }, "relay": { "vps/config/nftables-relay.conf": - "d99290bf825a3aff2ad8f9dbc6502ba6c32295f0cabd3f71c3d9b94c06373259", + "497485a2fb1b79faa95eb67ca30a89a3b65c3d4d8ddcb14cd1dc9acfeb2bb6f2", "vps/systemd/nodedc-b2-relay.service": "12a927a4cb42016229ac438f6e75969e1bf015037f2b5b0a60ab0591bdf424d2", "services/device-edge-relay/src/runtime.mjs": @@ -151,6 +173,28 @@ PHASE_FILE_SHA256 = { "deployment/device-edge-vps-relay-v1.json": "cb3c2fff4878021783efef4f4a4d1ec31c8e3d8e6325fe33657fec22bec20165", }, + "core-channel": { + "packages/device-protocol-contract/package.json": + "19d0d07da0341e8c2e8d3485400767566b18f6270245f0e9a9681c95013ba3b7", + "packages/device-protocol-contract/src/index.mjs": + "21a8b2b85a807899f946387c7976eaffcdc438b7db5e3d41fc3930f4437f0ec7", + "packages/device-edge-channel-contract/package.json": + "57d5349b5dcef2cacd4f3e4fad010359a65d59f5f903eff07d89f67c497f97c0", + "packages/device-edge-channel-contract/src/index.mjs": + "09d45e6104779212605fed650544794aafe1c2b1ab66f33f07bef4d92f430c80", + "services/device-edge-channel/package.json": + "bdf502be43b62bdd6db05b022a532d93ba954277ac5143d6058d2f27f6a2e9d2", + "services/device-edge-channel/src/runtime.mjs": + "cbb07f7e644a68e9c8c36c1c2ecf0224c06c46c08c49339d62637b10a1495501", + "services/device-edge-channel/src/server.mjs": + "ea891634a18efb9eb44f17b56c95ba97527215c4d0a6147cc2b7bad1d7356e36", + "vps/config/nftables-core-channel.conf": + "1a04a5450042e80b8a20da3c6634dd6bc68693f191a463ba9a62984279d81d0c", + "vps/systemd/nodedc-device-edge-channel.service": + "57c6c0c5eb952e1e196f50c410a8537468af145941b21dd4aad6ff0e8ca56249", + "deployment/device-edge-vps-core-channel-v1.json": + "938f6f7959b78e6be3a6e91a54dca2922fbd813f1a33dca5cebe0dc256a83a14", + }, } @@ -452,7 +496,7 @@ def current_phase_preflight(phase: str): die("VPS foundation live/runtime root already exists") if any(path.exists() for path in (SSHD_DROPIN, TAILSCALE_UNIT, BACKHAUL_UNIT, RELAY_UNIT)): die("VPS foundation system path already exists") - for port in (1055, 18221, 19921, 9921): + for port in (1055, 18221, CHANNEL_HEALTH_PORT, 19921, 9921, CHANNEL_PUBLIC_PORT): assert_port_closed(port) return {"predecessor": "clean-ubuntu-24.04.4"} @@ -462,6 +506,19 @@ def current_phase_preflight(phase: str): require_running_tailnet=phase in {"backhaul", "relay"}, expected_key_user=BACKHAUL_USER if phase == "relay" else SERVICE_USER, ) + if phase == "core-channel": + if service_active("nodedc-b2-backhaul.service"): + die("frozen VPS backhaul service must remain inactive") + if service_active("nodedc-b2-relay.service"): + die("frozen VPS relay service must remain inactive") + if CHANNEL_UNIT.exists() or (LIVE_ROOT / CORE_CHANNEL_ENTRIES[-1]).exists(): + die("VPS Core channel target path already exists") + if user_exists(CHANNEL_USER): + die("VPS Core channel runtime user already exists") + assert_channel_trust(require_runtime_owner=False) + for port in (CHANNEL_HEALTH_PORT, CHANNEL_PUBLIC_PORT, 9921): + assert_port_closed(port) + return {"predecessor": "accepted-foundation-closed-channel"} if phase == "backhaul": for tool in (Path("/usr/bin/ssh"), Path("/usr/bin/nc")): assert_executable_command_path( @@ -529,6 +586,8 @@ def backup_targets_for_phase(phase: str): return common + [SSHD_DROPIN, NFTABLES_CONFIG, TAILSCALE_UNIT] if phase == "backhaul": return common + [BACKHAUL_UNIT, BACKHAUL_KNOWN_HOSTS] + if phase == "core-channel": + return common + [CHANNEL_UNIT, NFTABLES_CONFIG, CHANNEL_TRUST_ROOT] return common + [RELAY_UNIT, NFTABLES_CONFIG] @@ -568,7 +627,7 @@ def create_backup(patch_id: str, phase: str): "serviceUserExisted": user_exists(), "serviceUsersExisted": { name: user_exists(name) - for name in (SERVICE_USER, BACKHAUL_USER, RELAY_USER) + for name in (SERVICE_USER, BACKHAUL_USER, RELAY_USER, CHANNEL_USER) }, "services": { name: { @@ -582,6 +641,7 @@ def create_backup(patch_id: str, phase: str): "nodedc-b2-tailscaled.service", "nodedc-b2-backhaul.service", "nodedc-b2-relay.service", + "nodedc-device-edge-channel.service", ) }, } @@ -740,6 +800,146 @@ def assign_backhaul_trust(account): os.chmod(BACKHAUL_KNOWN_HOSTS, 0o444) +def certificate_fingerprint(path: Path): + output = run([ + "/usr/bin/openssl", + "x509", + "-in", + str(path), + "-noout", + "-fingerprint", + "-sha256", + ]).stdout.strip() + prefix = "sha256 Fingerprint=" + if not output.lower().startswith(prefix.lower()): + die("VPS certificate fingerprint output is invalid") + fingerprint = output.split("=", 1)[1].upper() + if not re.fullmatch(r"(?:[A-F0-9]{2}:){31}[A-F0-9]{2}", fingerprint): + die("VPS certificate fingerprint is invalid") + return fingerprint + + +def assert_channel_trust(*, require_runtime_owner: bool): + directory = assert_directory_nonsymlink( + CHANNEL_TRUST_ROOT, + "Core channel trust root", + ) + expected_uid = 0 + expected_gid = 0 + if require_runtime_owner: + account = pwd.getpwnam(CHANNEL_USER) + expected_uid = account.pw_uid + expected_gid = account.pw_gid + if ( + directory.st_uid != expected_uid + or directory.st_gid != expected_gid + or (directory.st_mode & 0o777) != 0o700 + ): + die("Core channel trust root ownership/mode mismatch") + + for path, maximum, mode in ( + (CHANNEL_PRIVATE_KEY, 32 * 1024, 0o400), + (CHANNEL_CERTIFICATE, 32 * 1024, 0o444), + (CHANNEL_CORE_CERTIFICATE, 32 * 1024, 0o444), + (CHANNEL_RUNTIME_CONFIG, 32 * 1024, 0o444), + ): + state = assert_regular_nonsymlink(path, f"Core channel trust {path.name}") + if state.st_size < 1 or state.st_size > maximum: + die(f"Core channel trust file size mismatch: {path.name}") + if ( + state.st_uid != expected_uid + or state.st_gid != expected_gid + or (state.st_mode & 0o777) != mode + ): + die(f"Core channel trust file ownership/mode mismatch: {path.name}") + + private_text = CHANNEL_PRIVATE_KEY.read_text(encoding="ascii") + public_text = ( + CHANNEL_CERTIFICATE.read_text(encoding="ascii") + + CHANNEL_CORE_CERTIFICATE.read_text(encoding="ascii") + ) + if "PRIVATE KEY" not in private_text or "PRIVATE KEY" in public_text: + die("Core channel private/public trust boundary mismatch") + for path in ( + CHANNEL_CERTIFICATE, + CHANNEL_CORE_CERTIFICATE, + ): + if path.read_text(encoding="ascii").count("-----BEGIN CERTIFICATE-----") != 1: + die(f"Core channel certificate cardinality mismatch: {path.name}") + + run(["/usr/bin/openssl", "pkey", "-in", str(CHANNEL_PRIVATE_KEY), "-check", "-noout"]) + run(["/usr/bin/openssl", "x509", "-in", str(CHANNEL_CERTIFICATE), "-noout"]) + run(["/usr/bin/openssl", "x509", "-in", str(CHANNEL_CORE_CERTIFICATE), "-noout"]) + run([ + "/usr/bin/openssl", "verify", "-purpose", "sslserver", + "-CAfile", str(CHANNEL_CERTIFICATE), str(CHANNEL_CERTIFICATE), + ]) + run([ + "/usr/bin/openssl", "verify", "-purpose", "sslclient", + "-CAfile", str(CHANNEL_CORE_CERTIFICATE), str(CHANNEL_CORE_CERTIFICATE), + ]) + run([ + "/usr/bin/openssl", "x509", "-in", str(CHANNEL_CERTIFICATE), + "-noout", "-checkip", PUBLIC_IPV4, + ]) + certificate_key = run([ + "/usr/bin/openssl", "x509", "-in", str(CHANNEL_CERTIFICATE), "-pubkey", "-noout", + ]).stdout.strip() + private_key = run([ + "/usr/bin/openssl", "pkey", "-in", str(CHANNEL_PRIVATE_KEY), "-pubout", + ]).stdout.strip() + if certificate_key != private_key: + die("Core channel Edge certificate/private key mismatch") + + try: + document = json.loads(CHANNEL_RUNTIME_CONFIG.read_text(encoding="utf-8")) + except json.JSONDecodeError: + die("Core channel runtime configuration is invalid JSON") + if set(document) != { + "schemaVersion", + "edgeRegistrationId", + "channelGeneration", + "trustGeneration", + "allowedCoreFingerprints", + }: + die("Core channel runtime configuration key set mismatch") + if document.get("schemaVersion") != "nodedc.device-edge.channel-runtime.v1": + die("Core channel runtime configuration schema mismatch") + for key in ("edgeRegistrationId", "channelGeneration", "trustGeneration"): + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", str(document.get(key, ""))): + die(f"Core channel runtime configuration ref mismatch: {key}") + fingerprints = document.get("allowedCoreFingerprints") + if ( + not isinstance(fingerprints, list) + or not 1 <= len(fingerprints) <= 2 + or len(fingerprints) != len(set(fingerprints)) + or any( + not isinstance(value, str) + or not re.fullmatch(r"(?:[A-F0-9]{2}:){31}[A-F0-9]{2}", value) + for value in fingerprints + ) + ): + die("Core channel Core identity allowlist mismatch") + if certificate_fingerprint(CHANNEL_CORE_CERTIFICATE) not in fingerprints: + die("Core channel Core trust fingerprint mismatch") + return document + + +def assign_channel_trust(account): + assert_channel_trust(require_runtime_owner=False) + os.chown(CHANNEL_TRUST_ROOT, account.pw_uid, account.pw_gid) + os.chmod(CHANNEL_TRUST_ROOT, 0o700) + for path, mode in ( + (CHANNEL_PRIVATE_KEY, 0o400), + (CHANNEL_CERTIFICATE, 0o444), + (CHANNEL_CORE_CERTIFICATE, 0o444), + (CHANNEL_RUNTIME_CONFIG, 0o444), + ): + os.chown(path, account.pw_uid, account.pw_gid) + os.chmod(path, mode) + return assert_channel_trust(require_runtime_owner=True) + + def apply_nftables(source: Path): install_file(source, NFTABLES_CONFIG, 0o644) run(["/usr/sbin/nft", "-c", "-f", str(NFTABLES_CONFIG)]) @@ -813,6 +1013,19 @@ def apply_relay(payload: Path): validate_relay_runtime() +def apply_core_channel(payload: Path): + account = ensure_service_user( + CHANNEL_USER, + "/var/lib/nodedc-b2-vps/channel-runtime", + ) + assign_channel_trust(account) + install_file(LIVE_ROOT / CORE_CHANNEL_ENTRIES[7], CHANNEL_UNIT, 0o644) + apply_nftables(LIVE_ROOT / CORE_CHANNEL_ENTRIES[6]) + systemctl("daemon-reload") + systemctl("enable", "--now", "nodedc-device-edge-channel.service") + validate_core_channel_runtime() + + def sshd_effective(): return run(["/usr/sbin/sshd", "-T"]).stdout.lower() @@ -919,6 +1132,85 @@ def relay_health(): die(f"VPS relay health timeout: {last_error}") +def core_channel_health(*, require_accepted: bool): + last_error = None + for _attempt in range(60): + try: + with urllib.request.urlopen( + f"http://127.0.0.1:{CHANNEL_HEALTH_PORT}/healthz", + timeout=3, + ) as response: + payload = json.loads(response.read(65537).decode("utf-8")) + if ( + response.status == 200 + and payload.get("ok") is True + and ( + not require_accepted + or payload.get("channel") == "accepted" + ) + ): + return payload + last_error = f"channel={payload.get('channel')}" + except Exception as error: + last_error = str(error) + time.sleep(2) + die(f"VPS Core channel health timeout: {last_error}") + + +def validate_core_channel_runtime(): + validate_foundation_runtime( + require_running_tailnet=False, + expected_key_user=SERVICE_USER, + ) + source_file_state("core-channel") + assert_channel_trust(require_runtime_owner=True) + if service_active("nodedc-b2-backhaul.service"): + die("frozen VPS backhaul service became active") + if service_active("nodedc-b2-relay.service"): + die("frozen VPS relay service became active") + if not service_active("nodedc-device-edge-channel.service"): + die("VPS Core channel service is not active") + health = core_channel_health(require_accepted=True) + expected = { + "ok": True, + "service": "nodedc-device-edge-channel", + "channel": "accepted", + "trackerIngress": "disabled", + "commandTransport": "disabled", + } + for key, value in expected.items(): + if health.get(key) != value: + die(f"VPS Core channel health contract mismatch: {key}") + if not port_is_open(PUBLIC_IPV4, CHANNEL_PUBLIC_PORT, timeout=5): + die("VPS public Core channel listener is unavailable") + assert_port_closed(9921) + nft = run(["/usr/sbin/nft", "list", "table", "inet", "nodedc_b2_vps"]).stdout + if ( + "policy drop" not in nft + or "tcp dport 8443" not in nft + or "tcp dport 9921" in nft + ): + die("VPS Core channel firewall contract mismatch") + unit = run([ + "/usr/bin/systemctl", + "show", + "nodedc-device-edge-channel.service", + "--property=User,Group,NoNewPrivileges,MemoryMax,MemorySwapMax,CPUQuotaPerSecUSec,TasksMax,LimitNOFILE", + ]).stdout + for required in ( + "User=nodedc-channel", + "Group=nodedc-channel", + "NoNewPrivileges=yes", + "MemoryMax=134217728", + "MemorySwapMax=0", + "TasksMax=64", + "LimitNOFILE=1024", + ): + if required not in unit: + die(f"VPS Core channel resource boundary mismatch: {required}") + return health + + def validate_relay_runtime(): validate_backhaul_runtime() source_file_state("relay") @@ -979,6 +1271,7 @@ def rollback(backup: Path, phase: str): for service in ( "nodedc-b2-relay.service", "nodedc-b2-backhaul.service", + "nodedc-device-edge-channel.service", "nodedc-b2-tailscaled.service", ): if phase == "foundation" or service != "nodedc-b2-tailscaled.service": @@ -1004,6 +1297,9 @@ def rollback(backup: Path, phase: str): if phase == "relay": if not users_before.get(RELAY_USER, False) and user_exists(RELAY_USER): run(["/usr/sbin/userdel", RELAY_USER], check=False) + if phase == "core-channel": + if not users_before.get(CHANNEL_USER, False) and user_exists(CHANNEL_USER): + run(["/usr/sbin/userdel", CHANNEL_USER], check=False) if phase == "foundation" and not metadata.get("serviceUserExisted"): runtime_state_root = Path("/var/lib/nodedc-b2-vps") if LIVE_ROOT.exists() and not LIVE_ROOT.is_symlink(): @@ -1052,13 +1348,21 @@ def plan_artifact(artifact_argument: str): print("public_b2_ingress=disabled") print("services=nodedc-b2-backhaul") print(f"backhaul_runtime_identity={BACKHAUL_USER}:private-key-owner") - else: + elif phase == "relay": print("public_b2_ingress=155.212.211.15:9921/tcp") print("health=127.0.0.1:18221") print("private_upstream=127.0.0.1:19921") print("source_admission=public-ipv4-only") print("services=nodedc-b2-relay") print(f"relay_runtime_identity={RELAY_USER}:no-credentials") + else: + print("public_core_channel=155.212.211.15:8443/tcp:tls13-mtls-h2") + print(f"health=127.0.0.1:{CHANNEL_HEALTH_PORT}") + print("public_b2_ingress=disabled") + print("tracker_tcp_9921=closed") + 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") print("command_transport=disabled") print("gelios=untouched") print("dns=unchanged") @@ -1088,8 +1392,10 @@ def apply_artifact(artifact_argument: str): apply_foundation(loaded["payload"]) elif loaded["phase"] == "backhaul": apply_backhaul(loaded["payload"]) - else: + elif loaded["phase"] == "relay": apply_relay(loaded["payload"]) + else: + apply_core_channel(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 c7aee62..93c91dc 100644 --- a/infra/deploy-runner/test_device_edge_vps_artifact.py +++ b/infra/deploy-runner/test_device_edge_vps_artifact.py @@ -92,7 +92,7 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase): def test_builders_are_deterministic_narrow_and_secret_free(self): self.require_runtime_cache() - for phase in ("foundation", "backhaul", "relay"): + for phase in ("foundation", "backhaul", "relay", "core-channel"): with self.subTest(phase=phase), tempfile.TemporaryDirectory( prefix=f"nodedc-vps-{phase}-" ) as directory: @@ -164,7 +164,7 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase): old_inbox = RUNNER.INBOX_ROOT RUNNER.INBOX_ROOT = inbox try: - for phase in ("foundation", "backhaul", "relay"): + for phase in ("foundation", "backhaul", "relay", "core-channel"): result = self.build( inbox, phase, @@ -217,10 +217,55 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase): self.assertIn("b2_routes=unchanged", rendered) self.assertIn("command_transport=disabled", rendered) + def test_core_channel_plan_is_exact_and_keeps_tracker_ingress_closed(self): + with tempfile.TemporaryDirectory(prefix="nodedc-vps-channel-plan-") as directory: + inbox = Path(directory) / "inbox" + inbox.mkdir() + result = self.build( + inbox, + "core-channel", + "device-edge-vps-core-channel-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-foundation-closed-channel"}, + ), 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=core-channel", rendered) + self.assertIn( + "predecessor=accepted-foundation-closed-channel", + rendered, + ) + self.assertIn( + "public_core_channel=155.212.211.15:8443/tcp:tls13-mtls-h2", + rendered, + ) + self.assertIn("tracker_tcp_9921=closed", rendered) + self.assertIn("public_b2_ingress=disabled", rendered) + self.assertIn( + "peer_trust=preprovisioned-pinned-self-signed-core-certificate+fingerprint", + rendered, + ) + self.assertIn("command_transport=disabled", rendered) + self.assertIn("gelios=untouched", 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() + channel = (source_root / "vps/config/nftables-core-channel.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 = ( @@ -230,11 +275,16 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase): backhaul_unit = ( source_root / "vps/systemd/nodedc-b2-backhaul.service" ).read_text() + channel_unit = ( + source_root / "vps/systemd/nodedc-device-edge-channel.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("tcp dport 8443", channel) + self.assertNotIn("tcp dport 9921", channel) self.assertIn("PasswordAuthentication no", sshd) self.assertIn("AllowTcpForwarding no", sshd) self.assertIn("StrictHostKeyChecking yes", backhaul) @@ -248,6 +298,15 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase): 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) + self.assertIn("User=nodedc-channel", channel_unit) + self.assertIn("node --jitless", channel_unit) + self.assertIn("MemoryMax=128M", channel_unit) + self.assertIn("MemorySwapMax=0", channel_unit) + self.assertIn("CPUQuota=50%", channel_unit) + self.assertIn("TasksMax=64", channel_unit) + self.assertIn("LimitNOFILE=1024", channel_unit) + self.assertNotIn("LocalForward", channel_unit) + self.assertNotIn("DEVICE_EDGE_RELAY_UPSTREAM", channel_unit) def test_runner_has_registered_rollback_and_no_generic_latest(self): source = RUNNER_PATH.read_text(encoding="utf-8") @@ -344,6 +403,82 @@ class DeviceEdgeVpsArtifactTest(unittest.TestCase): "old-source\n", ) + def test_backup_restore_preserves_core_channel_source_trust_and_firewall(self): + with tempfile.TemporaryDirectory(prefix="nodedc-vps-channel-backup-") as directory: + root = Path(directory) + live = root / "live" + backups = root / "backups" + nft = root / "etc/nftables.conf" + channel_unit = root / "etc/nodedc-device-edge-channel.service" + trust = root / "state/channel-trust" + backups.mkdir() + nft.parent.mkdir(parents=True) + trust.mkdir(parents=True) + nft.write_text("foundation-firewall\n", encoding="utf-8") + channel_unit.parent.mkdir(parents=True, exist_ok=True) + channel_unit.write_text("old-channel-unit\n", encoding="utf-8") + (trust / "runtime.json").write_text("old-runtime\n", encoding="utf-8") + for relative in RUNNER.CORE_CHANNEL_ENTRIES: + target = live / relative + if relative.endswith("/src"): + target.mkdir(parents=True) + (target / "server.mjs").write_text("old-channel-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_unit = RUNNER.CHANNEL_UNIT + old_trust = RUNNER.CHANNEL_TRUST_ROOT + RUNNER.LIVE_ROOT = live + RUNNER.BACKUP_ROOT = backups + RUNNER.NFTABLES_CONFIG = nft + RUNNER.CHANNEL_UNIT = channel_unit + RUNNER.CHANNEL_TRUST_ROOT = trust + 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( + "channel-unit", + "core-channel", + ) + nft.write_text("candidate-firewall\n", encoding="utf-8") + channel_unit.write_text("candidate-channel-unit\n", encoding="utf-8") + (trust / "runtime.json").write_text("candidate-runtime\n", encoding="utf-8") + (live / "services/device-edge-channel/src/server.mjs").write_text( + "candidate-channel-source\n", + encoding="utf-8", + ) + RUNNER.restore_backup(backup, "core-channel") + finally: + RUNNER.LIVE_ROOT = old_live + RUNNER.BACKUP_ROOT = old_backups + RUNNER.NFTABLES_CONFIG = old_nft + RUNNER.CHANNEL_UNIT = old_unit + RUNNER.CHANNEL_TRUST_ROOT = old_trust + + self.assertEqual(nft.read_text(), "foundation-firewall\n") + self.assertEqual(channel_unit.read_text(), "old-channel-unit\n") + self.assertEqual((trust / "runtime.json").read_text(), "old-runtime\n") + self.assertEqual( + (live / "services/device-edge-channel/src/server.mjs").read_text(), + "old-channel-source\n", + ) + if __name__ == "__main__": unittest.main(verbosity=2)