256 lines
7.6 KiB
JavaScript
256 lines
7.6 KiB
JavaScript
import {
|
|
createPrivateKey,
|
|
createPublicKey,
|
|
timingSafeEqual,
|
|
X509Certificate,
|
|
} from "node:crypto";
|
|
import { lstat, readFile } from "node:fs/promises";
|
|
|
|
import { createControlCoreApp } from "./app.mjs";
|
|
import { resolveDeviceDatabaseUrl } from "./database-config.mjs";
|
|
import {
|
|
createDeviceEdgeChannelSupervisor,
|
|
} from "./edge-channel-supervisor.mjs";
|
|
import { createDeviceGatewayIngest } from "./gateway-ingest.mjs";
|
|
import { PostgresDeviceRepository } from "./postgres-repository.mjs";
|
|
import { createTypedCommandRuntime } from "./typed-command-runtime.mjs";
|
|
|
|
const config = await readConfig();
|
|
const repository = new PostgresDeviceRepository({
|
|
databaseUrl: config.databaseUrl,
|
|
poolSize: config.databasePoolSize,
|
|
});
|
|
|
|
await repository.migrate();
|
|
const typedCommandRuntime = config.managementApiEnabled && config.edgeChannelEnabled
|
|
? createTypedCommandRuntime({ repository })
|
|
: null;
|
|
|
|
const gatewayIngest = config.discoveryIngestEnabled || config.edgeChannelEnabled
|
|
? createDeviceGatewayIngest({
|
|
repository,
|
|
identifierPepper: config.identifierPepper,
|
|
})
|
|
: null;
|
|
const edgeChannelSupervisor = config.edgeChannelEnabled
|
|
? createDeviceEdgeChannelSupervisor({
|
|
repository,
|
|
gatewayIngest,
|
|
coreIdentity: config.edgeChannelCoreIdentity,
|
|
trustRoot: config.edgeChannelTrustRoot,
|
|
maxEdges: config.edgeChannelMaxEdges,
|
|
reconcileIntervalMs: config.edgeChannelReconcileIntervalMs,
|
|
typedCommandRuntime,
|
|
})
|
|
: null;
|
|
await edgeChannelSupervisor?.start();
|
|
|
|
const server = createControlCoreApp({
|
|
repository,
|
|
gatewayToken: config.gatewayToken,
|
|
identifierPepper: config.identifierPepper,
|
|
discoveryIngestEnabled: config.discoveryIngestEnabled,
|
|
managementApiEnabled: config.managementApiEnabled,
|
|
managementToken: config.managementToken,
|
|
gatewayIngest,
|
|
edgeChannelStatusProvider: edgeChannelSupervisor
|
|
? () => edgeChannelSupervisor.status()
|
|
: null,
|
|
typedCommandRuntime,
|
|
});
|
|
|
|
server.listen(config.port, config.host, () => {
|
|
console.log(JSON.stringify({
|
|
event: "device_control_core_started",
|
|
host: config.host,
|
|
port: config.port,
|
|
discoveryIngest: config.discoveryIngestEnabled,
|
|
managementApi: config.managementApiEnabled,
|
|
edgeChannels: config.edgeChannelEnabled,
|
|
commandTransport: typedCommandRuntime ? "typed-service-ping-v1" : "disabled",
|
|
}));
|
|
});
|
|
|
|
process.on("SIGTERM", shutdown);
|
|
process.on("SIGINT", shutdown);
|
|
|
|
async function shutdown() {
|
|
server.close(async () => {
|
|
await edgeChannelSupervisor?.stop();
|
|
await repository.close();
|
|
process.exit(0);
|
|
});
|
|
}
|
|
|
|
async function readConfig() {
|
|
const discoveryIngestEnabled = parseBoolean(
|
|
process.env.DEVICE_DISCOVERY_INGEST_ENABLED,
|
|
false,
|
|
);
|
|
const managementApiEnabled = parseBoolean(
|
|
process.env.DEVICE_MANAGEMENT_API_ENABLED,
|
|
false,
|
|
);
|
|
const edgeChannelEnabled = parseBoolean(
|
|
process.env.DEVICE_EDGE_CHANNEL_ENABLED,
|
|
false,
|
|
);
|
|
const edgeChannelCoreIdentity = edgeChannelEnabled
|
|
? await readCoreIdentity(process.env)
|
|
: null;
|
|
return {
|
|
host: String(process.env.HOST || "127.0.0.1").trim(),
|
|
port: parsePort(process.env.PORT, 18120),
|
|
databaseUrl: await resolveDeviceDatabaseUrl(process.env),
|
|
databasePoolSize: parsePositiveInt(
|
|
process.env.DEVICE_DATABASE_POOL_SIZE,
|
|
10,
|
|
),
|
|
discoveryIngestEnabled,
|
|
managementApiEnabled,
|
|
edgeChannelEnabled,
|
|
gatewayToken: discoveryIngestEnabled
|
|
? await readRequiredSecretFile(
|
|
process.env.DEVICE_GATEWAY_CORE_TOKEN_FILE,
|
|
"device_gateway_core_token_file_required",
|
|
)
|
|
: "",
|
|
identifierPepper:
|
|
discoveryIngestEnabled || managementApiEnabled || edgeChannelEnabled
|
|
? await readRequiredSecretFile(
|
|
process.env.DEVICE_IDENTIFIER_PEPPER_FILE,
|
|
"device_identifier_pepper_file_required",
|
|
)
|
|
: "",
|
|
managementToken: managementApiEnabled
|
|
? await readRequiredSecretFile(
|
|
process.env.DEVICE_MANAGEMENT_CORE_TOKEN_FILE,
|
|
"device_management_core_token_file_required",
|
|
)
|
|
: "",
|
|
edgeChannelCoreIdentity,
|
|
edgeChannelTrustRoot: edgeChannelEnabled
|
|
? await readRequiredDirectory(
|
|
process.env.DEVICE_EDGE_CHANNEL_TRUST_ROOT,
|
|
"device_edge_channel_trust_root_required",
|
|
)
|
|
: "",
|
|
edgeChannelMaxEdges: parseBoundedInt(
|
|
process.env.DEVICE_EDGE_CHANNEL_MAX_EDGES,
|
|
32,
|
|
1,
|
|
64,
|
|
),
|
|
edgeChannelReconcileIntervalMs: parseBoundedInt(
|
|
process.env.DEVICE_EDGE_CHANNEL_RECONCILE_INTERVAL_MS,
|
|
15_000,
|
|
1_000,
|
|
300_000,
|
|
),
|
|
};
|
|
}
|
|
|
|
async function readCoreIdentity(environment) {
|
|
const keyPath = requiredValue(
|
|
environment.DEVICE_EDGE_CHANNEL_CORE_KEY_FILE,
|
|
"device_edge_channel_core_key_file_required",
|
|
);
|
|
const certificatePath = requiredValue(
|
|
environment.DEVICE_EDGE_CHANNEL_CORE_CERTIFICATE_FILE,
|
|
"device_edge_channel_core_certificate_file_required",
|
|
);
|
|
const [key, cert] = await Promise.all([
|
|
readBoundedRegularFile(keyPath, 32 * 1024),
|
|
readBoundedRegularFile(certificatePath, 32 * 1024),
|
|
]);
|
|
const privatePublic = createPublicKey(createPrivateKey(key)).export({
|
|
type: "spki",
|
|
format: "der",
|
|
});
|
|
const certificatePublic = new X509Certificate(cert).publicKey.export({
|
|
type: "spki",
|
|
format: "der",
|
|
});
|
|
if (
|
|
privatePublic.length !== certificatePublic.length
|
|
|| !timingSafeEqual(privatePublic, certificatePublic)
|
|
) {
|
|
throw new Error("device_edge_channel_core_identity_mismatch");
|
|
}
|
|
return Object.freeze({
|
|
identityRef: "workload:device-control-core",
|
|
key,
|
|
cert,
|
|
});
|
|
}
|
|
|
|
async function readBoundedRegularFile(path, maximumBytes) {
|
|
const state = await lstat(path);
|
|
if (
|
|
state.isSymbolicLink()
|
|
|| !state.isFile()
|
|
|| state.size < 1
|
|
|| state.size > maximumBytes
|
|
) {
|
|
throw new Error("device_edge_channel_core_identity_file_invalid");
|
|
}
|
|
return readFile(path);
|
|
}
|
|
|
|
async function readRequiredDirectory(path, errorCode) {
|
|
const normalized = requiredValue(path, errorCode);
|
|
const state = await lstat(normalized);
|
|
if (state.isSymbolicLink() || !state.isDirectory()) throw new Error(errorCode);
|
|
return normalized;
|
|
}
|
|
|
|
async function readRequiredSecretFile(path, errorCode) {
|
|
const normalized = requiredValue(path, errorCode);
|
|
const value = (await readFile(normalized, "utf8")).trim();
|
|
if (value.length < 32) throw new Error(errorCode);
|
|
return value;
|
|
}
|
|
|
|
function requiredValue(value, errorCode) {
|
|
if (typeof value !== "string" || value.trim() === "") {
|
|
throw new Error(errorCode);
|
|
}
|
|
return value.trim();
|
|
}
|
|
|
|
function parsePort(value, fallback) {
|
|
const parsed = Number(value || fallback);
|
|
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535) {
|
|
throw new Error("device_control_port_invalid");
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function parsePositiveInt(value, fallback) {
|
|
const parsed = Number(value || fallback);
|
|
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
|
throw new Error("device_positive_integer_invalid");
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function parseBoundedInt(value, fallback, minimum, maximum) {
|
|
const parsed = Number(value ?? fallback);
|
|
if (
|
|
!Number.isSafeInteger(parsed)
|
|
|| parsed < minimum
|
|
|| parsed > maximum
|
|
) {
|
|
throw new Error("device_bounded_integer_invalid");
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function parseBoolean(value, fallback) {
|
|
if (value === undefined || value === null || value === "") return fallback;
|
|
const normalized = String(value).trim().toLowerCase();
|
|
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
|
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
|
throw new Error("device_boolean_invalid");
|
|
}
|