feat: establish standalone Device Core repository
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
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");
|
||||
}
|
||||
Reference in New Issue
Block a user