feat(device-edge): add canonical core channel deployment
This commit is contained in:
@@ -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"
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user