feat(device-plane): supervise pinned edge channels in core
This commit is contained in:
@@ -2,16 +2,11 @@ import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
import {
|
||||
assertSafeProjection,
|
||||
hashRestrictedIdentifier,
|
||||
maskRestrictedIdentifier,
|
||||
normalizeAdapterAcceptance,
|
||||
normalizeAdapterMessage,
|
||||
normalizeDiscoverySignal,
|
||||
normalizeRestrictedIdentifier,
|
||||
toSafeDiscoveryView,
|
||||
toSafeAdapterMessageView,
|
||||
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||
import { createDeviceGatewayIngest } from "./gateway-ingest.mjs";
|
||||
import {
|
||||
normalizeManagementActor,
|
||||
} from "./project-management.mjs";
|
||||
@@ -59,6 +54,8 @@ export function createControlCoreApp({
|
||||
discoveryIngestEnabled = false,
|
||||
managementApiEnabled = false,
|
||||
managementToken = "",
|
||||
gatewayIngest = null,
|
||||
edgeChannelStatusProvider = null,
|
||||
} = {}) {
|
||||
if (!repository || typeof repository.health !== "function") {
|
||||
throw new TypeError("device_repository_required");
|
||||
@@ -88,6 +85,24 @@ export function createControlCoreApp({
|
||||
throw new TypeError("device_identifier_pepper_invalid");
|
||||
}
|
||||
}
|
||||
const ingest = discoveryIngestEnabled
|
||||
? gatewayIngest ?? createDeviceGatewayIngest({ repository, identifierPepper })
|
||||
: gatewayIngest;
|
||||
if (
|
||||
ingest
|
||||
&& (
|
||||
typeof ingest.observeDiscovery !== "function"
|
||||
|| typeof ingest.acceptMessage !== "function"
|
||||
)
|
||||
) {
|
||||
throw new TypeError("device_gateway_ingest_invalid");
|
||||
}
|
||||
if (
|
||||
edgeChannelStatusProvider != null
|
||||
&& typeof edgeChannelStatusProvider !== "function"
|
||||
) {
|
||||
throw new TypeError("device_edge_channel_status_provider_invalid");
|
||||
}
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
@@ -108,6 +123,9 @@ export function createControlCoreApp({
|
||||
database,
|
||||
discoveryIngest: discoveryIngestEnabled ? "enabled" : "disabled",
|
||||
managementApi: managementApiEnabled ? "enabled" : "disabled",
|
||||
edgeChannels: edgeChannelStatusProvider
|
||||
? edgeChannelStatusProvider()
|
||||
: { enabled: false, configured: 0, accepted: 0, degraded: 0 },
|
||||
commandTransport: "disabled",
|
||||
});
|
||||
}
|
||||
@@ -236,22 +254,11 @@ export function createControlCoreApp({
|
||||
}
|
||||
|
||||
const input = await readJsonBody(request, 32 * 1024);
|
||||
const signal = normalizeDiscoverySignal(input);
|
||||
const identifierDigest = hashRestrictedIdentifier(
|
||||
signal.identifier,
|
||||
identifierPepper,
|
||||
);
|
||||
const safeView = assertSafeProjection(toSafeDiscoveryView(signal));
|
||||
const discovery = await repository.upsertQuarantineDiscovery({
|
||||
identifierDigest,
|
||||
safeView,
|
||||
sessionRef: signal.sessionRef,
|
||||
routeRef: signal.routeRef ?? null,
|
||||
});
|
||||
const discovery = await ingest.observeDiscovery(input);
|
||||
return writeJson(response, discovery.created ? 201 : 200, {
|
||||
ok: true,
|
||||
created: discovery.created,
|
||||
discovery: assertSafeProjection(discovery.value),
|
||||
discovery: discovery.value,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -273,31 +280,7 @@ export function createControlCoreApp({
|
||||
}
|
||||
|
||||
const input = await readJsonBody(request, 1024 * 1024);
|
||||
const message = normalizeAdapterMessage(input);
|
||||
const identifierDigest = hashRestrictedIdentifier(
|
||||
message.identifier,
|
||||
identifierPepper,
|
||||
);
|
||||
const safeView = assertSafeProjection(toSafeAdapterMessageView(message));
|
||||
const requestDigest = gatewayMessageRequestDigest({
|
||||
edgeRef: safeView.edgeRef,
|
||||
adapterRef: safeView.adapterRef,
|
||||
protocolProfileRef: safeView.protocolProfileRef,
|
||||
protocol: safeView.protocol,
|
||||
routeRef: safeView.routeRef ?? null,
|
||||
idempotencyKey: safeView.idempotencyKey,
|
||||
identifierKind: safeView.identifier.kind,
|
||||
identifierDigest,
|
||||
payloadSchemaRef: safeView.payloadSchemaRef,
|
||||
payload: safeView.payload,
|
||||
});
|
||||
const acceptance = normalizeAdapterAcceptance(
|
||||
await repository.acceptAdapterMessage({
|
||||
identifierDigest,
|
||||
requestDigest,
|
||||
safeView,
|
||||
}),
|
||||
);
|
||||
const acceptance = await ingest.acceptMessage(input);
|
||||
return writeJson(response, acceptance.replayed ? 200 : 201, {
|
||||
ok: true,
|
||||
acceptance,
|
||||
@@ -421,12 +404,6 @@ function managementRequestDigest(value) {
|
||||
.digest("hex")}`;
|
||||
}
|
||||
|
||||
function gatewayMessageRequestDigest(value) {
|
||||
return `sha256:${createHash("sha256")
|
||||
.update(JSON.stringify(value), "utf8")
|
||||
.digest("hex")}`;
|
||||
}
|
||||
|
||||
function matchesBearer(header, expected) {
|
||||
if (typeof header !== "string" || !header.startsWith("Bearer ")) return false;
|
||||
const actual = Buffer.from(header.slice("Bearer ".length), "utf8");
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import { createHash, X509Certificate } from "node:crypto";
|
||||
import { lstat, readFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
import {
|
||||
normalizeCertificateFingerprint,
|
||||
normalizeCertificateIdentities,
|
||||
} from "../../../packages/device-edge-channel-contract/src/index.mjs";
|
||||
import {
|
||||
createDeviceGatewayCoreChannelClient,
|
||||
} from "../../device-gateway-core/src/runtime.mjs";
|
||||
|
||||
const DEFAULT_TRUST_ROOT = "/run/nodedc-secrets/device-edge-channel/peers";
|
||||
|
||||
export function createDeviceEdgeChannelSupervisor(options = {}) {
|
||||
const config = normalizeConfiguration(options);
|
||||
const clients = new Map();
|
||||
const failures = new Map();
|
||||
let running = false;
|
||||
let timer = null;
|
||||
let reconcilePromise = null;
|
||||
let requestedCount = 0;
|
||||
let reconciliationFailures = 0;
|
||||
let lastErrorCode = null;
|
||||
|
||||
return Object.freeze({
|
||||
async start() {
|
||||
if (running) return;
|
||||
running = true;
|
||||
await reconcile();
|
||||
schedule();
|
||||
},
|
||||
async stop() {
|
||||
running = false;
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
if (reconcilePromise) await reconcilePromise.catch(() => undefined);
|
||||
const stopping = [...clients.values()].map(({ client }) => client.stop());
|
||||
clients.clear();
|
||||
failures.clear();
|
||||
await Promise.allSettled(stopping);
|
||||
},
|
||||
reconcile,
|
||||
status,
|
||||
});
|
||||
|
||||
async function reconcile() {
|
||||
if (!running) return status();
|
||||
if (reconcilePromise) return reconcilePromise;
|
||||
reconcilePromise = performReconcile().finally(() => {
|
||||
reconcilePromise = null;
|
||||
});
|
||||
return reconcilePromise;
|
||||
}
|
||||
|
||||
async function performReconcile() {
|
||||
let registrations;
|
||||
try {
|
||||
registrations = await config.repository
|
||||
.listActiveEdgeChannelRegistrations(config.maxEdges);
|
||||
if (!Array.isArray(registrations) || registrations.length > config.maxEdges) {
|
||||
throw new TypeError("device_edge_channel_registration_set_invalid");
|
||||
}
|
||||
registrations = registrations.map(normalizeRegistration);
|
||||
if (new Set(registrations.map((item) => item.edgeRegistrationId)).size
|
||||
!== registrations.length) {
|
||||
throw new TypeError("device_edge_channel_registration_set_invalid");
|
||||
}
|
||||
requestedCount = registrations.length;
|
||||
} catch (error) {
|
||||
reconciliationFailures += 1;
|
||||
lastErrorCode = safeErrorCode(error);
|
||||
return status();
|
||||
}
|
||||
|
||||
const desiredIds = new Set(registrations.map((item) => item.edgeRegistrationId));
|
||||
for (const [edgeRegistrationId, active] of clients) {
|
||||
if (!desiredIds.has(edgeRegistrationId)) {
|
||||
clients.delete(edgeRegistrationId);
|
||||
await active.client.stop().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
for (const edgeRegistrationId of failures.keys()) {
|
||||
if (!desiredIds.has(edgeRegistrationId)) failures.delete(edgeRegistrationId);
|
||||
}
|
||||
|
||||
for (const registration of registrations) {
|
||||
const digest = registrationDigest(registration);
|
||||
const current = clients.get(registration.edgeRegistrationId);
|
||||
if (current?.digest === digest) {
|
||||
failures.delete(registration.edgeRegistrationId);
|
||||
continue;
|
||||
}
|
||||
if (current) {
|
||||
clients.delete(registration.edgeRegistrationId);
|
||||
await current.client.stop().catch(() => undefined);
|
||||
}
|
||||
try {
|
||||
const ca = await config.readPeerTrust({
|
||||
registration,
|
||||
trustRoot: config.trustRoot,
|
||||
});
|
||||
const client = config.clientFactory({
|
||||
registration,
|
||||
tls: {
|
||||
key: config.coreIdentity.key,
|
||||
cert: config.coreIdentity.cert,
|
||||
ca,
|
||||
},
|
||||
coreIdentity: config.coreIdentity.identityRef,
|
||||
observeDiscovery: config.gatewayIngest.observeDiscovery,
|
||||
acceptMessage: config.gatewayIngest.acceptMessage,
|
||||
});
|
||||
assertClient(client);
|
||||
clients.set(registration.edgeRegistrationId, { client, digest });
|
||||
failures.delete(registration.edgeRegistrationId);
|
||||
await client.start();
|
||||
} catch (error) {
|
||||
const code = safeErrorCode(error);
|
||||
failures.set(registration.edgeRegistrationId, code);
|
||||
lastErrorCode = code;
|
||||
}
|
||||
}
|
||||
return status();
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (!running) return;
|
||||
timer = setTimeout(async () => {
|
||||
timer = null;
|
||||
await reconcile().catch(() => undefined);
|
||||
schedule();
|
||||
}, config.reconcileIntervalMs);
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
function status() {
|
||||
let accepted = 0;
|
||||
let connecting = 0;
|
||||
let degraded = failures.size;
|
||||
const edges = [];
|
||||
for (const [edgeRegistrationId, { client }] of clients) {
|
||||
const clientStatus = client.status();
|
||||
if (clientStatus.channel === "accepted") accepted += 1;
|
||||
else connecting += 1;
|
||||
if (clientStatus.lastErrorCode) degraded += 1;
|
||||
edges.push(Object.freeze({
|
||||
edgeRegistrationId,
|
||||
channel: clientStatus.channel,
|
||||
lastErrorCode: clientStatus.lastErrorCode ?? null,
|
||||
}));
|
||||
}
|
||||
for (const [edgeRegistrationId, code] of failures) {
|
||||
edges.push(Object.freeze({
|
||||
edgeRegistrationId,
|
||||
channel: "absent",
|
||||
lastErrorCode: code,
|
||||
}));
|
||||
}
|
||||
edges.sort((left, right) =>
|
||||
left.edgeRegistrationId.localeCompare(right.edgeRegistrationId)
|
||||
);
|
||||
return Object.freeze({
|
||||
enabled: true,
|
||||
running,
|
||||
configured: requestedCount,
|
||||
accepted,
|
||||
connecting,
|
||||
degraded,
|
||||
reconciliationFailures,
|
||||
lastErrorCode,
|
||||
commandTransport: "disabled",
|
||||
edges: Object.freeze(edges),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function readPinnedEdgeTrust({ registration, trustRoot }) {
|
||||
const match = registration.trustBundleRef.match(
|
||||
/^edge-trust:([a-z][a-z0-9-]{1,62})$/,
|
||||
);
|
||||
if (!match) throw new TypeError("device_edge_channel_trust_bundle_ref_invalid");
|
||||
const root = resolve(trustRoot);
|
||||
const path = resolve(root, `${match[1]}.pem`);
|
||||
if (dirname(path) !== root) {
|
||||
throw new TypeError("device_edge_channel_trust_bundle_path_invalid");
|
||||
}
|
||||
const state = await lstat(path);
|
||||
if (state.isSymbolicLink() || !state.isFile() || state.size < 1 || state.size > 64 * 1024) {
|
||||
throw new Error("device_edge_channel_trust_bundle_file_invalid");
|
||||
}
|
||||
const pem = await readFile(path);
|
||||
const blocks = pem.toString("utf8").match(
|
||||
/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g,
|
||||
);
|
||||
if (!blocks || blocks.length < 1 || blocks.length > 2) {
|
||||
throw new Error("device_edge_channel_trust_bundle_invalid");
|
||||
}
|
||||
const expected = new Set(
|
||||
registration.certificateIdentities.map((item) => item.fingerprint),
|
||||
);
|
||||
const observed = new Set(blocks.map((block) => normalizeCertificateFingerprint(
|
||||
new X509Certificate(block).fingerprint256,
|
||||
)));
|
||||
if (
|
||||
observed.size !== expected.size
|
||||
|| [...observed].some((fingerprint) => !expected.has(fingerprint))
|
||||
) {
|
||||
throw new Error("device_edge_channel_trust_bundle_identity_mismatch");
|
||||
}
|
||||
return pem;
|
||||
}
|
||||
|
||||
function normalizeConfiguration(options) {
|
||||
if (
|
||||
!options.repository
|
||||
|| typeof options.repository.listActiveEdgeChannelRegistrations !== "function"
|
||||
) {
|
||||
throw new TypeError("device_edge_channel_repository_required");
|
||||
}
|
||||
if (
|
||||
!options.gatewayIngest
|
||||
|| typeof options.gatewayIngest.observeDiscovery !== "function"
|
||||
|| typeof options.gatewayIngest.acceptMessage !== "function"
|
||||
) {
|
||||
throw new TypeError("device_edge_channel_gateway_ingest_required");
|
||||
}
|
||||
const coreIdentity = options.coreIdentity;
|
||||
if (
|
||||
!coreIdentity
|
||||
|| !(typeof coreIdentity.key === "string" || Buffer.isBuffer(coreIdentity.key))
|
||||
|| !(typeof coreIdentity.cert === "string" || Buffer.isBuffer(coreIdentity.cert))
|
||||
|| !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(coreIdentity.identityRef)
|
||||
) {
|
||||
throw new TypeError("device_edge_channel_core_identity_invalid");
|
||||
}
|
||||
const maxEdges = normalizeInteger(options.maxEdges, 1, 64, 32);
|
||||
const reconcileIntervalMs = normalizeInteger(
|
||||
options.reconcileIntervalMs,
|
||||
1_000,
|
||||
300_000,
|
||||
15_000,
|
||||
);
|
||||
const trustRoot = resolve(options.trustRoot ?? DEFAULT_TRUST_ROOT);
|
||||
return Object.freeze({
|
||||
repository: options.repository,
|
||||
gatewayIngest: options.gatewayIngest,
|
||||
coreIdentity: Object.freeze({ ...coreIdentity }),
|
||||
maxEdges,
|
||||
reconcileIntervalMs,
|
||||
trustRoot,
|
||||
readPeerTrust: options.readPeerTrust ?? readPinnedEdgeTrust,
|
||||
clientFactory: options.clientFactory ?? createDeviceGatewayCoreChannelClient,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRegistration(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError("device_edge_channel_registration_invalid");
|
||||
}
|
||||
const endpoint = new URL(String(value.endpoint || ""));
|
||||
if (
|
||||
endpoint.protocol !== "https:"
|
||||
|| endpoint.username
|
||||
|| endpoint.password
|
||||
|| endpoint.pathname !== "/"
|
||||
|| endpoint.search
|
||||
|| endpoint.hash
|
||||
|| endpoint.port !== "8443"
|
||||
|| endpoint.hostname !== String(value.servername || "").toLowerCase()
|
||||
|| !isPublicIpv4(endpoint.hostname)
|
||||
) {
|
||||
throw new TypeError("device_edge_channel_registration_endpoint_invalid");
|
||||
}
|
||||
if (value.lifecycleState !== "active") {
|
||||
throw new TypeError("device_edge_channel_registration_inactive");
|
||||
}
|
||||
return Object.freeze({
|
||||
edgeRegistrationId: normalizeRef(value.edgeRegistrationId),
|
||||
endpoint: endpoint.toString(),
|
||||
servername: endpoint.hostname,
|
||||
channelGeneration: normalizeRef(value.channelGeneration),
|
||||
trustBundleRef: normalizeTrustRef(value.trustBundleRef),
|
||||
certificateIdentities: normalizeCertificateIdentities(
|
||||
value.certificateIdentities,
|
||||
),
|
||||
lifecycleState: "active",
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRef(value) {
|
||||
if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value)) {
|
||||
throw new TypeError("device_edge_channel_registration_ref_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeTrustRef(value) {
|
||||
if (typeof value !== "string" || !/^edge-trust:[a-z][a-z0-9-]{1,62}$/.test(value)) {
|
||||
throw new TypeError("device_edge_channel_trust_bundle_ref_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isPublicIpv4(value) {
|
||||
const octets = value.split(".").map(Number);
|
||||
if (octets.length !== 4 || octets.some((item) =>
|
||||
!Number.isInteger(item) || item < 0 || item > 255
|
||||
)) return false;
|
||||
const [a, b, c] = octets;
|
||||
return a >= 1 && a < 224
|
||||
&& a !== 10 && a !== 127
|
||||
&& !(a === 100 && b >= 64 && b <= 127)
|
||||
&& !(a === 169 && b === 254)
|
||||
&& !(a === 172 && b >= 16 && b <= 31)
|
||||
&& !(a === 192 && (b === 0 || b === 168))
|
||||
&& !(a === 192 && b === 88 && c === 99)
|
||||
&& !(a === 198 && (b === 18 || b === 19 || b === 51))
|
||||
&& !(a === 203 && b === 0 && c === 113);
|
||||
}
|
||||
|
||||
function registrationDigest(value) {
|
||||
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
||||
}
|
||||
|
||||
function assertClient(value) {
|
||||
if (
|
||||
!value
|
||||
|| typeof value.start !== "function"
|
||||
|| typeof value.stop !== "function"
|
||||
|| typeof value.status !== "function"
|
||||
) throw new TypeError("device_edge_channel_client_invalid");
|
||||
}
|
||||
|
||||
function normalizeInteger(value, minimum, maximum, fallback) {
|
||||
const parsed = Number(value ?? fallback);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
|
||||
throw new TypeError("device_edge_channel_integer_invalid");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function safeErrorCode(error) {
|
||||
const value = String(error?.message || error || "device_edge_channel_error")
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9._:-]/g, "_")
|
||||
.slice(0, 128);
|
||||
return /^[a-z][a-z0-9._:-]{2,127}$/.test(value)
|
||||
? value
|
||||
: "device_edge_channel_error";
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import {
|
||||
assertSafeProjection,
|
||||
hashRestrictedIdentifier,
|
||||
normalizeAdapterAcceptance,
|
||||
normalizeAdapterMessage,
|
||||
normalizeDiscoverySignal,
|
||||
toSafeAdapterMessageView,
|
||||
toSafeDiscoveryView,
|
||||
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||
|
||||
export function createDeviceGatewayIngest({ repository, identifierPepper } = {}) {
|
||||
if (!repository || typeof repository.upsertQuarantineDiscovery !== "function") {
|
||||
throw new TypeError("device_discovery_repository_required");
|
||||
}
|
||||
if (typeof repository.acceptAdapterMessage !== "function") {
|
||||
throw new TypeError("device_gateway_message_repository_required");
|
||||
}
|
||||
if (typeof identifierPepper !== "string" || identifierPepper.length < 32) {
|
||||
throw new TypeError("device_identifier_pepper_invalid");
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
async observeDiscovery(input) {
|
||||
const signal = normalizeDiscoverySignal(input);
|
||||
const identifierDigest = hashRestrictedIdentifier(
|
||||
signal.identifier,
|
||||
identifierPepper,
|
||||
);
|
||||
const safeView = assertSafeProjection(toSafeDiscoveryView(signal));
|
||||
const discovery = await repository.upsertQuarantineDiscovery({
|
||||
identifierDigest,
|
||||
safeView,
|
||||
sessionRef: signal.sessionRef,
|
||||
routeRef: signal.routeRef ?? null,
|
||||
});
|
||||
return Object.freeze({
|
||||
created: discovery.created === true,
|
||||
value: assertSafeProjection(discovery.value),
|
||||
});
|
||||
},
|
||||
|
||||
async acceptMessage(input) {
|
||||
const message = normalizeAdapterMessage(input);
|
||||
const identifierDigest = hashRestrictedIdentifier(
|
||||
message.identifier,
|
||||
identifierPepper,
|
||||
);
|
||||
const safeView = assertSafeProjection(toSafeAdapterMessageView(message));
|
||||
const requestDigest = gatewayMessageRequestDigest({
|
||||
edgeRef: safeView.edgeRef,
|
||||
adapterRef: safeView.adapterRef,
|
||||
protocolProfileRef: safeView.protocolProfileRef,
|
||||
protocol: safeView.protocol,
|
||||
routeRef: safeView.routeRef ?? null,
|
||||
idempotencyKey: safeView.idempotencyKey,
|
||||
identifierKind: safeView.identifier.kind,
|
||||
identifierDigest,
|
||||
payloadSchemaRef: safeView.payloadSchemaRef,
|
||||
payload: safeView.payload,
|
||||
});
|
||||
return normalizeAdapterAcceptance(
|
||||
await repository.acceptAdapterMessage({
|
||||
identifierDigest,
|
||||
requestDigest,
|
||||
safeView,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function gatewayMessageRequestDigest(value) {
|
||||
return `sha256:${createHash("sha256")
|
||||
.update(JSON.stringify(value), "utf8")
|
||||
.digest("hex")}`;
|
||||
}
|
||||
@@ -2,6 +2,9 @@ import {
|
||||
assertIdentifierDigest,
|
||||
assertSafeProjection,
|
||||
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||
import {
|
||||
normalizeCertificateIdentities,
|
||||
} from "../../../packages/device-edge-channel-contract/src/index.mjs";
|
||||
import { normalizeManagementActor } from "./project-management.mjs";
|
||||
|
||||
export const DEVICE_INFRASTRUCTURE_COMMAND_KINDS = Object.freeze([
|
||||
@@ -166,8 +169,9 @@ export function normalizeInfrastructureManagementCommand(kind, input) {
|
||||
"displayName",
|
||||
"deploymentRef",
|
||||
"lifecycleState",
|
||||
"channel",
|
||||
]);
|
||||
return Object.freeze({
|
||||
const normalized = {
|
||||
edgeKey: normalizeKey(input.edgeKey, "device_edge_key_invalid"),
|
||||
displayName: normalizeDisplayText(
|
||||
input.displayName,
|
||||
@@ -183,7 +187,11 @@ export function normalizeInfrastructureManagementCommand(kind, input) {
|
||||
new Set(["provisioning", "active", "suspended", "retired"]),
|
||||
"device_edge_state_invalid",
|
||||
),
|
||||
});
|
||||
};
|
||||
if (input.channel !== undefined) {
|
||||
normalized.channel = normalizeEdgeChannel(input.channel);
|
||||
}
|
||||
return Object.freeze(normalized);
|
||||
}
|
||||
|
||||
if (kind === "route.ensure") {
|
||||
@@ -339,6 +347,104 @@ function normalizeOptionalOpaqueRef(value, code) {
|
||||
return normalizeOpaqueRef(value, code);
|
||||
}
|
||||
|
||||
function normalizeEdgeChannel(input) {
|
||||
assertPlainObject(input, "device_edge_channel_invalid");
|
||||
assertAllowedKeys(input, [
|
||||
"endpoint",
|
||||
"servername",
|
||||
"generationRef",
|
||||
"trustBundleRef",
|
||||
"certificateIdentities",
|
||||
"lifecycleState",
|
||||
]);
|
||||
const lifecycleState = normalizeEnum(
|
||||
input.lifecycleState ?? "disabled",
|
||||
new Set(["disabled", "active", "revoked"]),
|
||||
"device_edge_channel_state_invalid",
|
||||
);
|
||||
if (lifecycleState === "disabled") {
|
||||
if (Object.keys(input).some((key) => key !== "lifecycleState")) {
|
||||
throw new TypeError("device_edge_channel_disabled_configuration_invalid");
|
||||
}
|
||||
return Object.freeze({
|
||||
endpoint: null,
|
||||
servername: null,
|
||||
generationRef: null,
|
||||
trustBundleRef: null,
|
||||
certificateIdentities: Object.freeze([]),
|
||||
lifecycleState,
|
||||
});
|
||||
}
|
||||
|
||||
const endpoint = normalizeEdgeEndpoint(input.endpoint);
|
||||
const servername = normalizePattern(
|
||||
input.servername,
|
||||
/^[A-Za-z0-9.-]{1,253}$/,
|
||||
"device_edge_channel_servername_invalid",
|
||||
).toLowerCase();
|
||||
if (servername !== endpoint.hostname) {
|
||||
throw new TypeError("device_edge_channel_servername_mismatch");
|
||||
}
|
||||
return Object.freeze({
|
||||
endpoint: endpoint.toString(),
|
||||
servername,
|
||||
generationRef: normalizeProfileRef(
|
||||
input.generationRef,
|
||||
"device_edge_channel_generation_invalid",
|
||||
),
|
||||
trustBundleRef: normalizePattern(
|
||||
input.trustBundleRef,
|
||||
/^edge-trust:[a-z][a-z0-9-]{1,62}$/,
|
||||
"device_edge_channel_trust_bundle_ref_invalid",
|
||||
),
|
||||
certificateIdentities: normalizeCertificateIdentities(
|
||||
input.certificateIdentities,
|
||||
),
|
||||
lifecycleState,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeEdgeEndpoint(value) {
|
||||
let endpoint;
|
||||
try {
|
||||
endpoint = new URL(String(value || ""));
|
||||
} catch {
|
||||
throw new TypeError("device_edge_channel_endpoint_invalid");
|
||||
}
|
||||
if (
|
||||
endpoint.protocol !== "https:"
|
||||
|| endpoint.username
|
||||
|| endpoint.password
|
||||
|| endpoint.pathname !== "/"
|
||||
|| endpoint.search
|
||||
|| endpoint.hash
|
||||
|| endpoint.port !== "8443"
|
||||
|| !isPublicIpv4(endpoint.hostname)
|
||||
) {
|
||||
throw new TypeError("device_edge_channel_endpoint_invalid");
|
||||
}
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
function isPublicIpv4(value) {
|
||||
const octets = value.split(".").map(Number);
|
||||
if (
|
||||
octets.length !== 4
|
||||
|| octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
|
||||
) return false;
|
||||
const [a, b, c] = octets;
|
||||
if (a < 1 || a >= 224) return false;
|
||||
if (a === 10 || a === 127) return false;
|
||||
if (a === 100 && b >= 64 && b <= 127) return false;
|
||||
if (a === 169 && b === 254) return false;
|
||||
if (a === 172 && b >= 16 && b <= 31) return false;
|
||||
if (a === 192 && (b === 0 || b === 168)) return false;
|
||||
if (a === 192 && b === 88 && c === 99) return false;
|
||||
if (a === 198 && (b === 18 || b === 19 || b === 51)) return false;
|
||||
if (a === 203 && b === 0 && c === 113) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizePattern(value, pattern, code) {
|
||||
if (typeof value !== "string" || !pattern.test(value)) {
|
||||
throw new TypeError(code);
|
||||
|
||||
@@ -304,6 +304,15 @@ async function registerModelProfile(client, actor, command) {
|
||||
|
||||
async function ensureEdge(client, actor, command) {
|
||||
assertPlatformCatalogAuthority(actor);
|
||||
const channelProvided = command.channel !== undefined;
|
||||
const channel = command.channel ?? {
|
||||
endpoint: null,
|
||||
servername: null,
|
||||
generationRef: null,
|
||||
trustBundleRef: null,
|
||||
certificateIdentities: [],
|
||||
lifecycleState: "disabled",
|
||||
};
|
||||
const result = await client.query(
|
||||
`insert into device_edges (
|
||||
id,
|
||||
@@ -311,35 +320,78 @@ async function ensureEdge(client, actor, command) {
|
||||
display_name,
|
||||
deployment_ref,
|
||||
lifecycle_state,
|
||||
channel_endpoint,
|
||||
channel_servername,
|
||||
channel_generation_ref,
|
||||
channel_trust_bundle_ref,
|
||||
channel_certificate_identities,
|
||||
channel_lifecycle_state,
|
||||
created_by_ref
|
||||
) values ($1, $2, $3, $4, $5, $6)
|
||||
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11, $13)
|
||||
on conflict (edge_key) do update set
|
||||
display_name = excluded.display_name,
|
||||
deployment_ref = excluded.deployment_ref,
|
||||
lifecycle_state = excluded.lifecycle_state,
|
||||
channel_endpoint = case when $12 then excluded.channel_endpoint
|
||||
else device_edges.channel_endpoint end,
|
||||
channel_servername = case when $12 then excluded.channel_servername
|
||||
else device_edges.channel_servername end,
|
||||
channel_generation_ref = case when $12 then excluded.channel_generation_ref
|
||||
else device_edges.channel_generation_ref end,
|
||||
channel_trust_bundle_ref = case when $12 then excluded.channel_trust_bundle_ref
|
||||
else device_edges.channel_trust_bundle_ref end,
|
||||
channel_certificate_identities = case when $12
|
||||
then excluded.channel_certificate_identities
|
||||
else device_edges.channel_certificate_identities end,
|
||||
channel_lifecycle_state = case when $12
|
||||
then excluded.channel_lifecycle_state
|
||||
else device_edges.channel_lifecycle_state end,
|
||||
updated_at = now()
|
||||
where
|
||||
device_edges.lifecycle_state = excluded.lifecycle_state
|
||||
or (
|
||||
device_edges.lifecycle_state = 'provisioning'
|
||||
and excluded.lifecycle_state in ('active', 'retired')
|
||||
where (
|
||||
device_edges.lifecycle_state = excluded.lifecycle_state
|
||||
or (
|
||||
device_edges.lifecycle_state = 'provisioning'
|
||||
and excluded.lifecycle_state in ('active', 'retired')
|
||||
)
|
||||
or (
|
||||
device_edges.lifecycle_state = 'active'
|
||||
and excluded.lifecycle_state in ('suspended', 'retired')
|
||||
)
|
||||
or (
|
||||
device_edges.lifecycle_state = 'suspended'
|
||||
and excluded.lifecycle_state in ('active', 'retired')
|
||||
)
|
||||
)
|
||||
or (
|
||||
device_edges.lifecycle_state = 'active'
|
||||
and excluded.lifecycle_state in ('suspended', 'retired')
|
||||
)
|
||||
or (
|
||||
device_edges.lifecycle_state = 'suspended'
|
||||
and excluded.lifecycle_state in ('active', 'retired')
|
||||
and (
|
||||
not $12
|
||||
or device_edges.channel_lifecycle_state = excluded.channel_lifecycle_state
|
||||
or (
|
||||
device_edges.channel_lifecycle_state = 'disabled'
|
||||
and excluded.channel_lifecycle_state = 'active'
|
||||
)
|
||||
or (
|
||||
device_edges.channel_lifecycle_state = 'active'
|
||||
and excluded.channel_lifecycle_state in ('disabled', 'revoked')
|
||||
)
|
||||
)
|
||||
returning id, edge_key, display_name, deployment_ref, lifecycle_state,
|
||||
created_at, updated_at, (xmax = 0) as created`,
|
||||
channel_endpoint, channel_servername, channel_generation_ref,
|
||||
channel_trust_bundle_ref, channel_certificate_identities,
|
||||
channel_lifecycle_state, created_at, updated_at,
|
||||
(xmax = 0) as created`,
|
||||
[
|
||||
randomUUID(),
|
||||
command.edgeKey,
|
||||
command.displayName,
|
||||
command.deploymentRef,
|
||||
command.lifecycleState,
|
||||
channel.endpoint,
|
||||
channel.servername,
|
||||
channel.generationRef,
|
||||
channel.trustBundleRef,
|
||||
JSON.stringify(channel.certificateIdentities),
|
||||
channel.lifecycleState,
|
||||
channelProvided,
|
||||
actor.userRef,
|
||||
],
|
||||
);
|
||||
@@ -352,6 +404,9 @@ async function ensureEdge(client, actor, command) {
|
||||
edgeKey: row.edge_key,
|
||||
deploymentRef: row.deployment_ref,
|
||||
lifecycleState: row.lifecycle_state,
|
||||
channelLifecycleState: row.channel_lifecycle_state,
|
||||
channelGenerationRef: row.channel_generation_ref,
|
||||
channelTrustBundleRef: row.channel_trust_bundle_ref,
|
||||
},
|
||||
});
|
||||
return {
|
||||
@@ -767,6 +822,14 @@ function edgeView(row) {
|
||||
displayName: row.display_name,
|
||||
deploymentRef: row.deployment_ref ?? null,
|
||||
lifecycleState: row.lifecycle_state,
|
||||
channel: {
|
||||
lifecycleState: row.channel_lifecycle_state ?? "disabled",
|
||||
endpoint: row.channel_endpoint ?? null,
|
||||
servername: row.channel_servername ?? null,
|
||||
generationRef: row.channel_generation_ref ?? null,
|
||||
trustBundleRef: row.channel_trust_bundle_ref ?? null,
|
||||
certificateIdentities: [...(row.channel_certificate_identities ?? [])],
|
||||
},
|
||||
createdAt: toIso(row.created_at),
|
||||
updatedAt: toIso(row.updated_at),
|
||||
};
|
||||
|
||||
@@ -57,6 +57,7 @@ const migrationFiles = [
|
||||
"010_device_control_resources.sql",
|
||||
"011_device_control_resource_commands.sql",
|
||||
"012_device_gateway_message_receipts.sql",
|
||||
"013_device_edge_channels.sql",
|
||||
];
|
||||
|
||||
export class PostgresDeviceRepository {
|
||||
@@ -168,6 +169,36 @@ export class PostgresDeviceRepository {
|
||||
);
|
||||
}
|
||||
|
||||
async listActiveEdgeChannelRegistrations(limit = 64) {
|
||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) {
|
||||
throw new TypeError("device_edge_channel_registration_limit_invalid");
|
||||
}
|
||||
return this.#executeRead(async (client) => {
|
||||
const result = await client.query(
|
||||
`select id, channel_endpoint, channel_servername,
|
||||
channel_generation_ref, channel_trust_bundle_ref,
|
||||
channel_certificate_identities, channel_lifecycle_state
|
||||
from device_edges
|
||||
where lifecycle_state = 'active'
|
||||
and channel_lifecycle_state = 'active'
|
||||
order by id
|
||||
limit $1`,
|
||||
[limit],
|
||||
);
|
||||
return result.rows.map((row) => Object.freeze({
|
||||
edgeRegistrationId: `edge:${row.id}`,
|
||||
endpoint: row.channel_endpoint,
|
||||
servername: row.channel_servername,
|
||||
channelGeneration: row.channel_generation_ref,
|
||||
trustBundleRef: row.channel_trust_bundle_ref,
|
||||
certificateIdentities: Object.freeze(
|
||||
[...(row.channel_certificate_identities ?? [])],
|
||||
),
|
||||
lifecycleState: row.channel_lifecycle_state,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async #executeRead(operation) {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
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";
|
||||
|
||||
const config = await readConfig();
|
||||
@@ -12,6 +22,24 @@ const repository = new PostgresDeviceRepository({
|
||||
|
||||
await repository.migrate();
|
||||
|
||||
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,
|
||||
})
|
||||
: null;
|
||||
await edgeChannelSupervisor?.start();
|
||||
|
||||
const server = createControlCoreApp({
|
||||
repository,
|
||||
gatewayToken: config.gatewayToken,
|
||||
@@ -19,6 +47,10 @@ const server = createControlCoreApp({
|
||||
discoveryIngestEnabled: config.discoveryIngestEnabled,
|
||||
managementApiEnabled: config.managementApiEnabled,
|
||||
managementToken: config.managementToken,
|
||||
gatewayIngest,
|
||||
edgeChannelStatusProvider: edgeChannelSupervisor
|
||||
? () => edgeChannelSupervisor.status()
|
||||
: null,
|
||||
});
|
||||
|
||||
server.listen(config.port, config.host, () => {
|
||||
@@ -28,6 +60,7 @@ server.listen(config.port, config.host, () => {
|
||||
port: config.port,
|
||||
discoveryIngest: config.discoveryIngestEnabled,
|
||||
managementApi: config.managementApiEnabled,
|
||||
edgeChannels: config.edgeChannelEnabled,
|
||||
commandTransport: "disabled",
|
||||
}));
|
||||
});
|
||||
@@ -37,6 +70,7 @@ process.on("SIGINT", shutdown);
|
||||
|
||||
async function shutdown() {
|
||||
server.close(async () => {
|
||||
await edgeChannelSupervisor?.stop();
|
||||
await repository.close();
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -51,6 +85,13 @@ async function readConfig() {
|
||||
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),
|
||||
@@ -61,13 +102,15 @@ async function readConfig() {
|
||||
),
|
||||
discoveryIngestEnabled,
|
||||
managementApiEnabled,
|
||||
edgeChannelEnabled,
|
||||
gatewayToken: discoveryIngestEnabled
|
||||
? await readRequiredSecretFile(
|
||||
process.env.DEVICE_GATEWAY_CORE_TOKEN_FILE,
|
||||
"device_gateway_core_token_file_required",
|
||||
)
|
||||
: "",
|
||||
identifierPepper: discoveryIngestEnabled || managementApiEnabled
|
||||
identifierPepper:
|
||||
discoveryIngestEnabled || managementApiEnabled || edgeChannelEnabled
|
||||
? await readRequiredSecretFile(
|
||||
process.env.DEVICE_IDENTIFIER_PEPPER_FILE,
|
||||
"device_identifier_pepper_file_required",
|
||||
@@ -79,9 +122,82 @@ async function readConfig() {
|
||||
"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();
|
||||
@@ -112,6 +228,18 @@ function parsePositiveInt(value, fallback) {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user