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