feat(device-plane): supervise pinned edge channels in core
This commit is contained in:
@@ -44,6 +44,7 @@ services:
|
|||||||
DEVICE_DATABASE_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
|
DEVICE_DATABASE_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
|
||||||
DEVICE_DATABASE_POOL_SIZE: "10"
|
DEVICE_DATABASE_POOL_SIZE: "10"
|
||||||
DEVICE_DISCOVERY_INGEST_ENABLED: "true"
|
DEVICE_DISCOVERY_INGEST_ENABLED: "true"
|
||||||
|
DEVICE_EDGE_CHANNEL_ENABLED: "false"
|
||||||
DEVICE_GATEWAY_CORE_TOKEN_FILE: /run/nodedc-secrets/gateway-core-token
|
DEVICE_GATEWAY_CORE_TOKEN_FILE: /run/nodedc-secrets/gateway-core-token
|
||||||
DEVICE_IDENTIFIER_PEPPER_FILE: /run/nodedc-secrets/identifier-pepper
|
DEVICE_IDENTIFIER_PEPPER_FILE: /run/nodedc-secrets/identifier-pepper
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
COPY packages/device-protocol-contract ./packages/device-protocol-contract
|
COPY packages/device-protocol-contract ./packages/device-protocol-contract
|
||||||
|
COPY packages/device-edge-channel-contract ./packages/device-edge-channel-contract
|
||||||
COPY packages/device-adapter-runtime/package.json ./packages/device-adapter-runtime/package.json
|
COPY packages/device-adapter-runtime/package.json ./packages/device-adapter-runtime/package.json
|
||||||
COPY packages/device-adapter-catalog/package.json ./packages/device-adapter-catalog/package.json
|
COPY packages/device-adapter-catalog/package.json ./packages/device-adapter-catalog/package.json
|
||||||
COPY packages/arusnavi-b2-adapter/package.json ./packages/arusnavi-b2-adapter/package.json
|
COPY packages/arusnavi-b2-adapter/package.json ./packages/arusnavi-b2-adapter/package.json
|
||||||
COPY services/device-control-core ./services/device-control-core
|
COPY services/device-control-core ./services/device-control-core
|
||||||
COPY services/device-gateway/package.json ./services/device-gateway/package.json
|
COPY services/device-gateway/package.json ./services/device-gateway/package.json
|
||||||
COPY services/device-edge-relay/package.json ./services/device-edge-relay/package.json
|
COPY services/device-edge-relay/package.json ./services/device-edge-relay/package.json
|
||||||
|
COPY services/device-gateway-core ./services/device-gateway-core
|
||||||
|
|
||||||
RUN npm ci --omit=dev --ignore-scripts
|
RUN npm ci --omit=dev --ignore-scripts
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
begin;
|
||||||
|
|
||||||
|
alter table device_edges
|
||||||
|
add column if not exists channel_endpoint text,
|
||||||
|
add column if not exists channel_servername text,
|
||||||
|
add column if not exists channel_generation_ref text,
|
||||||
|
add column if not exists channel_trust_bundle_ref text,
|
||||||
|
add column if not exists channel_certificate_identities jsonb not null
|
||||||
|
default '[]'::jsonb,
|
||||||
|
add column if not exists channel_lifecycle_state text not null
|
||||||
|
default 'disabled';
|
||||||
|
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
if not exists (
|
||||||
|
select 1 from pg_constraint
|
||||||
|
where conname = 'device_edges_channel_lifecycle_state_check'
|
||||||
|
) then
|
||||||
|
alter table device_edges add constraint device_edges_channel_lifecycle_state_check
|
||||||
|
check (channel_lifecycle_state in ('disabled', 'active', 'revoked'));
|
||||||
|
end if;
|
||||||
|
if not exists (
|
||||||
|
select 1 from pg_constraint
|
||||||
|
where conname = 'device_edges_channel_configuration_check'
|
||||||
|
) then
|
||||||
|
alter table device_edges add constraint device_edges_channel_configuration_check
|
||||||
|
check (
|
||||||
|
(
|
||||||
|
channel_lifecycle_state = 'disabled'
|
||||||
|
and channel_endpoint is null
|
||||||
|
and channel_servername is null
|
||||||
|
and channel_generation_ref is null
|
||||||
|
and channel_trust_bundle_ref is null
|
||||||
|
and channel_certificate_identities = '[]'::jsonb
|
||||||
|
)
|
||||||
|
or
|
||||||
|
(
|
||||||
|
channel_lifecycle_state in ('active', 'revoked')
|
||||||
|
and length(btrim(channel_endpoint)) between 12 and 256
|
||||||
|
and channel_servername ~ '^[A-Za-z0-9.-]{1,253}$'
|
||||||
|
and channel_generation_ref ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'
|
||||||
|
and channel_trust_bundle_ref ~ '^edge-trust:[a-z][a-z0-9-]{1,62}$'
|
||||||
|
and jsonb_typeof(channel_certificate_identities) = 'array'
|
||||||
|
and jsonb_array_length(channel_certificate_identities) between 1 and 2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
end if;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
create index if not exists device_edges_active_channel_idx
|
||||||
|
on device_edges (channel_lifecycle_state, updated_at desc)
|
||||||
|
where channel_lifecycle_state = 'active';
|
||||||
|
|
||||||
|
commit;
|
||||||
@@ -2,16 +2,11 @@ import { createHash, timingSafeEqual } from "node:crypto";
|
|||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
assertSafeProjection,
|
|
||||||
hashRestrictedIdentifier,
|
hashRestrictedIdentifier,
|
||||||
maskRestrictedIdentifier,
|
maskRestrictedIdentifier,
|
||||||
normalizeAdapterAcceptance,
|
|
||||||
normalizeAdapterMessage,
|
|
||||||
normalizeDiscoverySignal,
|
|
||||||
normalizeRestrictedIdentifier,
|
normalizeRestrictedIdentifier,
|
||||||
toSafeDiscoveryView,
|
|
||||||
toSafeAdapterMessageView,
|
|
||||||
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||||
|
import { createDeviceGatewayIngest } from "./gateway-ingest.mjs";
|
||||||
import {
|
import {
|
||||||
normalizeManagementActor,
|
normalizeManagementActor,
|
||||||
} from "./project-management.mjs";
|
} from "./project-management.mjs";
|
||||||
@@ -59,6 +54,8 @@ export function createControlCoreApp({
|
|||||||
discoveryIngestEnabled = false,
|
discoveryIngestEnabled = false,
|
||||||
managementApiEnabled = false,
|
managementApiEnabled = false,
|
||||||
managementToken = "",
|
managementToken = "",
|
||||||
|
gatewayIngest = null,
|
||||||
|
edgeChannelStatusProvider = null,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
if (!repository || typeof repository.health !== "function") {
|
if (!repository || typeof repository.health !== "function") {
|
||||||
throw new TypeError("device_repository_required");
|
throw new TypeError("device_repository_required");
|
||||||
@@ -88,6 +85,24 @@ export function createControlCoreApp({
|
|||||||
throw new TypeError("device_identifier_pepper_invalid");
|
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) => {
|
const server = createServer(async (request, response) => {
|
||||||
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||||
@@ -108,6 +123,9 @@ export function createControlCoreApp({
|
|||||||
database,
|
database,
|
||||||
discoveryIngest: discoveryIngestEnabled ? "enabled" : "disabled",
|
discoveryIngest: discoveryIngestEnabled ? "enabled" : "disabled",
|
||||||
managementApi: managementApiEnabled ? "enabled" : "disabled",
|
managementApi: managementApiEnabled ? "enabled" : "disabled",
|
||||||
|
edgeChannels: edgeChannelStatusProvider
|
||||||
|
? edgeChannelStatusProvider()
|
||||||
|
: { enabled: false, configured: 0, accepted: 0, degraded: 0 },
|
||||||
commandTransport: "disabled",
|
commandTransport: "disabled",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -236,22 +254,11 @@ export function createControlCoreApp({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const input = await readJsonBody(request, 32 * 1024);
|
const input = await readJsonBody(request, 32 * 1024);
|
||||||
const signal = normalizeDiscoverySignal(input);
|
const discovery = await ingest.observeDiscovery(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 writeJson(response, discovery.created ? 201 : 200, {
|
return writeJson(response, discovery.created ? 201 : 200, {
|
||||||
ok: true,
|
ok: true,
|
||||||
created: discovery.created,
|
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 input = await readJsonBody(request, 1024 * 1024);
|
||||||
const message = normalizeAdapterMessage(input);
|
const acceptance = await ingest.acceptMessage(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,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return writeJson(response, acceptance.replayed ? 200 : 201, {
|
return writeJson(response, acceptance.replayed ? 200 : 201, {
|
||||||
ok: true,
|
ok: true,
|
||||||
acceptance,
|
acceptance,
|
||||||
@@ -421,12 +404,6 @@ function managementRequestDigest(value) {
|
|||||||
.digest("hex")}`;
|
.digest("hex")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function gatewayMessageRequestDigest(value) {
|
|
||||||
return `sha256:${createHash("sha256")
|
|
||||||
.update(JSON.stringify(value), "utf8")
|
|
||||||
.digest("hex")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchesBearer(header, expected) {
|
function matchesBearer(header, expected) {
|
||||||
if (typeof header !== "string" || !header.startsWith("Bearer ")) return false;
|
if (typeof header !== "string" || !header.startsWith("Bearer ")) return false;
|
||||||
const actual = Buffer.from(header.slice("Bearer ".length), "utf8");
|
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,
|
assertIdentifierDigest,
|
||||||
assertSafeProjection,
|
assertSafeProjection,
|
||||||
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
} 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";
|
import { normalizeManagementActor } from "./project-management.mjs";
|
||||||
|
|
||||||
export const DEVICE_INFRASTRUCTURE_COMMAND_KINDS = Object.freeze([
|
export const DEVICE_INFRASTRUCTURE_COMMAND_KINDS = Object.freeze([
|
||||||
@@ -166,8 +169,9 @@ export function normalizeInfrastructureManagementCommand(kind, input) {
|
|||||||
"displayName",
|
"displayName",
|
||||||
"deploymentRef",
|
"deploymentRef",
|
||||||
"lifecycleState",
|
"lifecycleState",
|
||||||
|
"channel",
|
||||||
]);
|
]);
|
||||||
return Object.freeze({
|
const normalized = {
|
||||||
edgeKey: normalizeKey(input.edgeKey, "device_edge_key_invalid"),
|
edgeKey: normalizeKey(input.edgeKey, "device_edge_key_invalid"),
|
||||||
displayName: normalizeDisplayText(
|
displayName: normalizeDisplayText(
|
||||||
input.displayName,
|
input.displayName,
|
||||||
@@ -183,7 +187,11 @@ export function normalizeInfrastructureManagementCommand(kind, input) {
|
|||||||
new Set(["provisioning", "active", "suspended", "retired"]),
|
new Set(["provisioning", "active", "suspended", "retired"]),
|
||||||
"device_edge_state_invalid",
|
"device_edge_state_invalid",
|
||||||
),
|
),
|
||||||
});
|
};
|
||||||
|
if (input.channel !== undefined) {
|
||||||
|
normalized.channel = normalizeEdgeChannel(input.channel);
|
||||||
|
}
|
||||||
|
return Object.freeze(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kind === "route.ensure") {
|
if (kind === "route.ensure") {
|
||||||
@@ -339,6 +347,104 @@ function normalizeOptionalOpaqueRef(value, code) {
|
|||||||
return normalizeOpaqueRef(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) {
|
function normalizePattern(value, pattern, code) {
|
||||||
if (typeof value !== "string" || !pattern.test(value)) {
|
if (typeof value !== "string" || !pattern.test(value)) {
|
||||||
throw new TypeError(code);
|
throw new TypeError(code);
|
||||||
|
|||||||
@@ -304,6 +304,15 @@ async function registerModelProfile(client, actor, command) {
|
|||||||
|
|
||||||
async function ensureEdge(client, actor, command) {
|
async function ensureEdge(client, actor, command) {
|
||||||
assertPlatformCatalogAuthority(actor);
|
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(
|
const result = await client.query(
|
||||||
`insert into device_edges (
|
`insert into device_edges (
|
||||||
id,
|
id,
|
||||||
@@ -311,35 +320,78 @@ async function ensureEdge(client, actor, command) {
|
|||||||
display_name,
|
display_name,
|
||||||
deployment_ref,
|
deployment_ref,
|
||||||
lifecycle_state,
|
lifecycle_state,
|
||||||
|
channel_endpoint,
|
||||||
|
channel_servername,
|
||||||
|
channel_generation_ref,
|
||||||
|
channel_trust_bundle_ref,
|
||||||
|
channel_certificate_identities,
|
||||||
|
channel_lifecycle_state,
|
||||||
created_by_ref
|
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
|
on conflict (edge_key) do update set
|
||||||
display_name = excluded.display_name,
|
display_name = excluded.display_name,
|
||||||
deployment_ref = excluded.deployment_ref,
|
deployment_ref = excluded.deployment_ref,
|
||||||
lifecycle_state = excluded.lifecycle_state,
|
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()
|
updated_at = now()
|
||||||
where
|
where (
|
||||||
device_edges.lifecycle_state = excluded.lifecycle_state
|
device_edges.lifecycle_state = excluded.lifecycle_state
|
||||||
or (
|
or (
|
||||||
device_edges.lifecycle_state = 'provisioning'
|
device_edges.lifecycle_state = 'provisioning'
|
||||||
and excluded.lifecycle_state in ('active', 'retired')
|
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 (
|
and (
|
||||||
device_edges.lifecycle_state = 'active'
|
not $12
|
||||||
and excluded.lifecycle_state in ('suspended', 'retired')
|
or device_edges.channel_lifecycle_state = excluded.channel_lifecycle_state
|
||||||
)
|
or (
|
||||||
or (
|
device_edges.channel_lifecycle_state = 'disabled'
|
||||||
device_edges.lifecycle_state = 'suspended'
|
and excluded.channel_lifecycle_state = 'active'
|
||||||
and excluded.lifecycle_state in ('active', 'retired')
|
)
|
||||||
|
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,
|
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(),
|
randomUUID(),
|
||||||
command.edgeKey,
|
command.edgeKey,
|
||||||
command.displayName,
|
command.displayName,
|
||||||
command.deploymentRef,
|
command.deploymentRef,
|
||||||
command.lifecycleState,
|
command.lifecycleState,
|
||||||
|
channel.endpoint,
|
||||||
|
channel.servername,
|
||||||
|
channel.generationRef,
|
||||||
|
channel.trustBundleRef,
|
||||||
|
JSON.stringify(channel.certificateIdentities),
|
||||||
|
channel.lifecycleState,
|
||||||
|
channelProvided,
|
||||||
actor.userRef,
|
actor.userRef,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -352,6 +404,9 @@ async function ensureEdge(client, actor, command) {
|
|||||||
edgeKey: row.edge_key,
|
edgeKey: row.edge_key,
|
||||||
deploymentRef: row.deployment_ref,
|
deploymentRef: row.deployment_ref,
|
||||||
lifecycleState: row.lifecycle_state,
|
lifecycleState: row.lifecycle_state,
|
||||||
|
channelLifecycleState: row.channel_lifecycle_state,
|
||||||
|
channelGenerationRef: row.channel_generation_ref,
|
||||||
|
channelTrustBundleRef: row.channel_trust_bundle_ref,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
@@ -767,6 +822,14 @@ function edgeView(row) {
|
|||||||
displayName: row.display_name,
|
displayName: row.display_name,
|
||||||
deploymentRef: row.deployment_ref ?? null,
|
deploymentRef: row.deployment_ref ?? null,
|
||||||
lifecycleState: row.lifecycle_state,
|
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),
|
createdAt: toIso(row.created_at),
|
||||||
updatedAt: toIso(row.updated_at),
|
updatedAt: toIso(row.updated_at),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ const migrationFiles = [
|
|||||||
"010_device_control_resources.sql",
|
"010_device_control_resources.sql",
|
||||||
"011_device_control_resource_commands.sql",
|
"011_device_control_resource_commands.sql",
|
||||||
"012_device_gateway_message_receipts.sql",
|
"012_device_gateway_message_receipts.sql",
|
||||||
|
"013_device_edge_channels.sql",
|
||||||
];
|
];
|
||||||
|
|
||||||
export class PostgresDeviceRepository {
|
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) {
|
async #executeRead(operation) {
|
||||||
const client = await this.pool.connect();
|
const client = await this.pool.connect();
|
||||||
try {
|
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 { createControlCoreApp } from "./app.mjs";
|
||||||
import { resolveDeviceDatabaseUrl } from "./database-config.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 { PostgresDeviceRepository } from "./postgres-repository.mjs";
|
||||||
|
|
||||||
const config = await readConfig();
|
const config = await readConfig();
|
||||||
@@ -12,6 +22,24 @@ const repository = new PostgresDeviceRepository({
|
|||||||
|
|
||||||
await repository.migrate();
|
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({
|
const server = createControlCoreApp({
|
||||||
repository,
|
repository,
|
||||||
gatewayToken: config.gatewayToken,
|
gatewayToken: config.gatewayToken,
|
||||||
@@ -19,6 +47,10 @@ const server = createControlCoreApp({
|
|||||||
discoveryIngestEnabled: config.discoveryIngestEnabled,
|
discoveryIngestEnabled: config.discoveryIngestEnabled,
|
||||||
managementApiEnabled: config.managementApiEnabled,
|
managementApiEnabled: config.managementApiEnabled,
|
||||||
managementToken: config.managementToken,
|
managementToken: config.managementToken,
|
||||||
|
gatewayIngest,
|
||||||
|
edgeChannelStatusProvider: edgeChannelSupervisor
|
||||||
|
? () => edgeChannelSupervisor.status()
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(config.port, config.host, () => {
|
server.listen(config.port, config.host, () => {
|
||||||
@@ -28,6 +60,7 @@ server.listen(config.port, config.host, () => {
|
|||||||
port: config.port,
|
port: config.port,
|
||||||
discoveryIngest: config.discoveryIngestEnabled,
|
discoveryIngest: config.discoveryIngestEnabled,
|
||||||
managementApi: config.managementApiEnabled,
|
managementApi: config.managementApiEnabled,
|
||||||
|
edgeChannels: config.edgeChannelEnabled,
|
||||||
commandTransport: "disabled",
|
commandTransport: "disabled",
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
@@ -37,6 +70,7 @@ process.on("SIGINT", shutdown);
|
|||||||
|
|
||||||
async function shutdown() {
|
async function shutdown() {
|
||||||
server.close(async () => {
|
server.close(async () => {
|
||||||
|
await edgeChannelSupervisor?.stop();
|
||||||
await repository.close();
|
await repository.close();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
@@ -51,6 +85,13 @@ async function readConfig() {
|
|||||||
process.env.DEVICE_MANAGEMENT_API_ENABLED,
|
process.env.DEVICE_MANAGEMENT_API_ENABLED,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
const edgeChannelEnabled = parseBoolean(
|
||||||
|
process.env.DEVICE_EDGE_CHANNEL_ENABLED,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
const edgeChannelCoreIdentity = edgeChannelEnabled
|
||||||
|
? await readCoreIdentity(process.env)
|
||||||
|
: null;
|
||||||
return {
|
return {
|
||||||
host: String(process.env.HOST || "127.0.0.1").trim(),
|
host: String(process.env.HOST || "127.0.0.1").trim(),
|
||||||
port: parsePort(process.env.PORT, 18120),
|
port: parsePort(process.env.PORT, 18120),
|
||||||
@@ -61,13 +102,15 @@ async function readConfig() {
|
|||||||
),
|
),
|
||||||
discoveryIngestEnabled,
|
discoveryIngestEnabled,
|
||||||
managementApiEnabled,
|
managementApiEnabled,
|
||||||
|
edgeChannelEnabled,
|
||||||
gatewayToken: discoveryIngestEnabled
|
gatewayToken: discoveryIngestEnabled
|
||||||
? await readRequiredSecretFile(
|
? await readRequiredSecretFile(
|
||||||
process.env.DEVICE_GATEWAY_CORE_TOKEN_FILE,
|
process.env.DEVICE_GATEWAY_CORE_TOKEN_FILE,
|
||||||
"device_gateway_core_token_file_required",
|
"device_gateway_core_token_file_required",
|
||||||
)
|
)
|
||||||
: "",
|
: "",
|
||||||
identifierPepper: discoveryIngestEnabled || managementApiEnabled
|
identifierPepper:
|
||||||
|
discoveryIngestEnabled || managementApiEnabled || edgeChannelEnabled
|
||||||
? await readRequiredSecretFile(
|
? await readRequiredSecretFile(
|
||||||
process.env.DEVICE_IDENTIFIER_PEPPER_FILE,
|
process.env.DEVICE_IDENTIFIER_PEPPER_FILE,
|
||||||
"device_identifier_pepper_file_required",
|
"device_identifier_pepper_file_required",
|
||||||
@@ -79,9 +122,82 @@ async function readConfig() {
|
|||||||
"device_management_core_token_file_required",
|
"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) {
|
async function readRequiredSecretFile(path, errorCode) {
|
||||||
const normalized = requiredValue(path, errorCode);
|
const normalized = requiredValue(path, errorCode);
|
||||||
const value = (await readFile(normalized, "utf8")).trim();
|
const value = (await readFile(normalized, "utf8")).trim();
|
||||||
@@ -112,6 +228,18 @@ function parsePositiveInt(value, fallback) {
|
|||||||
return parsed;
|
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) {
|
function parseBoolean(value, fallback) {
|
||||||
if (value === undefined || value === null || value === "") return fallback;
|
if (value === undefined || value === null || value === "") return fallback;
|
||||||
const normalized = String(value).trim().toLowerCase();
|
const normalized = String(value).trim().toLowerCase();
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ test("health reports database readiness and disabled command transport", async (
|
|||||||
database: "ready",
|
database: "ready",
|
||||||
discoveryIngest: "disabled",
|
discoveryIngest: "disabled",
|
||||||
managementApi: "disabled",
|
managementApi: "disabled",
|
||||||
|
edgeChannels: {
|
||||||
|
enabled: false,
|
||||||
|
configured: 0,
|
||||||
|
accepted: 0,
|
||||||
|
degraded: 0,
|
||||||
|
},
|
||||||
commandTransport: "disabled",
|
commandTransport: "disabled",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
const migrationUrl = new URL(
|
||||||
|
"../migrations/013_device_edge_channels.sql",
|
||||||
|
import.meta.url,
|
||||||
|
);
|
||||||
|
|
||||||
|
test("Edge channel migration extends the canonical Edge without storing keys", async () => {
|
||||||
|
const sql = await readFile(migrationUrl, "utf8");
|
||||||
|
|
||||||
|
assert.match(sql, /alter table device_edges/);
|
||||||
|
assert.match(sql, /channel_endpoint text/);
|
||||||
|
assert.match(sql, /channel_generation_ref text/);
|
||||||
|
assert.match(sql, /channel_trust_bundle_ref text/);
|
||||||
|
assert.match(sql, /channel_certificate_identities jsonb/);
|
||||||
|
assert.match(sql, /channel_lifecycle_state in \('disabled', 'active', 'revoked'\)/);
|
||||||
|
assert.match(sql, /where channel_lifecycle_state = 'active'/);
|
||||||
|
assert.doesNotMatch(sql, /private[_ ]?key/i);
|
||||||
|
assert.doesNotMatch(sql, /password/i);
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDeviceEdgeChannelSupervisor,
|
||||||
|
} from "../src/edge-channel-supervisor.mjs";
|
||||||
|
|
||||||
|
test("supervisor reconciles one in-process client per active Edge", async () => {
|
||||||
|
let registrations = [registration("channel-generation:1")];
|
||||||
|
const clients = [];
|
||||||
|
const supervisor = createDeviceEdgeChannelSupervisor({
|
||||||
|
repository: {
|
||||||
|
listActiveEdgeChannelRegistrations: async () => registrations,
|
||||||
|
},
|
||||||
|
gatewayIngest: {
|
||||||
|
observeDiscovery: async () => ({}),
|
||||||
|
acceptMessage: async () => ({}),
|
||||||
|
},
|
||||||
|
coreIdentity: {
|
||||||
|
identityRef: "workload:device-control-core",
|
||||||
|
key: "test-key",
|
||||||
|
cert: "test-cert",
|
||||||
|
},
|
||||||
|
readPeerTrust: async () => "test-edge-certificate",
|
||||||
|
clientFactory: (options) => {
|
||||||
|
const state = { started: 0, stopped: 0, options };
|
||||||
|
clients.push(state);
|
||||||
|
return {
|
||||||
|
async start() { state.started += 1; },
|
||||||
|
async stop() { state.stopped += 1; },
|
||||||
|
status: () => ({ channel: "accepted", lastErrorCode: null }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
reconcileIntervalMs: 300_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await supervisor.start();
|
||||||
|
assert.equal(clients.length, 1);
|
||||||
|
assert.equal(supervisor.status().accepted, 1);
|
||||||
|
assert.equal(clients[0].options.registration.channelGeneration, "channel-generation:1");
|
||||||
|
|
||||||
|
await supervisor.reconcile();
|
||||||
|
assert.equal(clients.length, 1);
|
||||||
|
|
||||||
|
registrations = [registration("channel-generation:2")];
|
||||||
|
await supervisor.reconcile();
|
||||||
|
assert.equal(clients.length, 2);
|
||||||
|
assert.equal(clients[0].stopped, 1);
|
||||||
|
assert.equal(clients[1].started, 1);
|
||||||
|
|
||||||
|
registrations = [];
|
||||||
|
await supervisor.reconcile();
|
||||||
|
assert.equal(clients[1].stopped, 1);
|
||||||
|
assert.equal(supervisor.status().configured, 0);
|
||||||
|
assert.equal(JSON.stringify(supervisor.status()).includes("155.212"), false);
|
||||||
|
await supervisor.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("supervisor keeps a failed trust enrollment isolated from other Edges", async () => {
|
||||||
|
const supervisor = createDeviceEdgeChannelSupervisor({
|
||||||
|
repository: {
|
||||||
|
listActiveEdgeChannelRegistrations: async () => [
|
||||||
|
registration("channel-generation:1", "edge:good"),
|
||||||
|
registration("channel-generation:1", "edge:bad"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
gatewayIngest: {
|
||||||
|
observeDiscovery: async () => ({}),
|
||||||
|
acceptMessage: async () => ({}),
|
||||||
|
},
|
||||||
|
coreIdentity: {
|
||||||
|
identityRef: "workload:device-control-core",
|
||||||
|
key: "test-key",
|
||||||
|
cert: "test-cert",
|
||||||
|
},
|
||||||
|
readPeerTrust: async ({ registration: value }) => {
|
||||||
|
if (value.edgeRegistrationId === "edge:bad") {
|
||||||
|
throw new Error("device_edge_channel_trust_bundle_identity_mismatch");
|
||||||
|
}
|
||||||
|
return "test-edge-certificate";
|
||||||
|
},
|
||||||
|
clientFactory: () => ({
|
||||||
|
start: async () => undefined,
|
||||||
|
stop: async () => undefined,
|
||||||
|
status: () => ({ channel: "accepted", lastErrorCode: null }),
|
||||||
|
}),
|
||||||
|
reconcileIntervalMs: 300_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await supervisor.start();
|
||||||
|
const status = supervisor.status();
|
||||||
|
assert.equal(status.configured, 2);
|
||||||
|
assert.equal(status.accepted, 1);
|
||||||
|
assert.equal(status.degraded, 1);
|
||||||
|
assert.equal(status.commandTransport, "disabled");
|
||||||
|
assert.equal(status.edges.find((item) => item.edgeRegistrationId === "edge:bad")
|
||||||
|
.lastErrorCode, "device_edge_channel_trust_bundle_identity_mismatch");
|
||||||
|
await supervisor.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
function registration(channelGeneration, edgeRegistrationId = "edge:pilot") {
|
||||||
|
return {
|
||||||
|
edgeRegistrationId,
|
||||||
|
endpoint: "https://155.212.211.15:8443/",
|
||||||
|
servername: "155.212.211.15",
|
||||||
|
channelGeneration,
|
||||||
|
trustBundleRef: "edge-trust:moscow-edge",
|
||||||
|
certificateIdentities: [{
|
||||||
|
generationRef: "edge-identity:1",
|
||||||
|
fingerprint: "AA:".repeat(31) + "AA",
|
||||||
|
status: "active",
|
||||||
|
}],
|
||||||
|
lifecycleState: "active",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEVICE_ADAPTER_MESSAGE_SCHEMA,
|
||||||
|
DEVICE_DISCOVERY_SIGNAL_SCHEMA,
|
||||||
|
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||||
|
import { createDeviceGatewayIngest } from "../src/gateway-ingest.mjs";
|
||||||
|
|
||||||
|
const identifierPepper = "test-only-identifier-pepper-with-32-bytes";
|
||||||
|
const rawImei = "000000000000001";
|
||||||
|
|
||||||
|
test("shared gateway ingest masks identifiers for HTTP and Edge callers", async () => {
|
||||||
|
const stored = [];
|
||||||
|
const ingest = createDeviceGatewayIngest({
|
||||||
|
identifierPepper,
|
||||||
|
repository: {
|
||||||
|
async upsertQuarantineDiscovery(value) {
|
||||||
|
stored.push(value);
|
||||||
|
return {
|
||||||
|
created: true,
|
||||||
|
value: { ...value.safeView, discoveryRef: "discovery:test" },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async acceptAdapterMessage(value) {
|
||||||
|
stored.push(value);
|
||||||
|
return {
|
||||||
|
schemaVersion: "nodedc.device-adapter-acceptance.v1",
|
||||||
|
acceptanceRef: "acceptance:test",
|
||||||
|
idempotencyKey: value.safeView.idempotencyKey,
|
||||||
|
status: "accepted",
|
||||||
|
replayed: false,
|
||||||
|
acceptedAt: "2026-08-11T12:00:00.000Z",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const discovery = await ingest.observeDiscovery(discoverySignal());
|
||||||
|
const acceptance = await ingest.acceptMessage(adapterMessage());
|
||||||
|
|
||||||
|
assert.equal(discovery.value.identifier.masked, "***********0001");
|
||||||
|
assert.equal(acceptance.status, "accepted");
|
||||||
|
assert.match(stored[0].identifierDigest, /^hmac-sha256:[a-f0-9]{64}$/);
|
||||||
|
assert.match(stored[1].requestDigest, /^sha256:[a-f0-9]{64}$/);
|
||||||
|
assert.equal(JSON.stringify(stored).includes(rawImei), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
function discoverySignal() {
|
||||||
|
return {
|
||||||
|
schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA,
|
||||||
|
sessionRef: "session:test",
|
||||||
|
modelProfileRef: "arusnavi.b2.internal.v1",
|
||||||
|
protocol: "INTERNAL",
|
||||||
|
observedAt: "2026-08-11T12:00:00.000Z",
|
||||||
|
identifier: { kind: "imei", value: rawImei },
|
||||||
|
evidence: {
|
||||||
|
transport: "tcp",
|
||||||
|
bytesObserved: 16,
|
||||||
|
framingStatus: "verified",
|
||||||
|
specificationRef: "arusnavi.internal.framing.test-v1",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function adapterMessage() {
|
||||||
|
return {
|
||||||
|
schemaVersion: DEVICE_ADAPTER_MESSAGE_SCHEMA,
|
||||||
|
edgeRef: "edge:test",
|
||||||
|
adapterRef: "arusnavi-b2",
|
||||||
|
protocolProfileRef: "arusnavi.b2.internal.v1",
|
||||||
|
protocol: "INTERNAL",
|
||||||
|
sessionRef: "session:test",
|
||||||
|
messageRef: "package:1:test",
|
||||||
|
messageType: "telemetry.package",
|
||||||
|
sequence: 1,
|
||||||
|
observedAt: "2026-08-11T12:00:00.000Z",
|
||||||
|
idempotencyKey: `sha256:${"a".repeat(64)}`,
|
||||||
|
identifier: { kind: "imei", value: rawImei },
|
||||||
|
payloadSchemaRef: "arusnavi.internal.package-metadata.v1",
|
||||||
|
payload: {
|
||||||
|
packageNumber: 1,
|
||||||
|
packetCount: 1,
|
||||||
|
packageDigest: `sha256:${"b".repeat(64)}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -143,6 +143,62 @@ test("shared catalog and Edge authority requires the Hub owner ceiling", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("normalizes only a pinned Core-initiated public Edge channel", () => {
|
||||||
|
const command = normalizeInfrastructureManagementCommand("edge.ensure", {
|
||||||
|
edgeKey: "moscow-edge",
|
||||||
|
displayName: "Moscow Edge",
|
||||||
|
deploymentRef: "deployment:device-edge/moscow-1",
|
||||||
|
lifecycleState: "active",
|
||||||
|
channel: {
|
||||||
|
endpoint: "https://155.212.211.15:8443/",
|
||||||
|
servername: "155.212.211.15",
|
||||||
|
generationRef: "channel-generation:1",
|
||||||
|
trustBundleRef: "edge-trust:moscow-edge",
|
||||||
|
certificateIdentities: [{
|
||||||
|
generationRef: "edge-identity:1",
|
||||||
|
fingerprint: "AA:".repeat(31) + "AA",
|
||||||
|
status: "active",
|
||||||
|
}],
|
||||||
|
lifecycleState: "active",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(command.channel.endpoint, "https://155.212.211.15:8443/");
|
||||||
|
assert.equal(command.channel.lifecycleState, "active");
|
||||||
|
assert.equal(command.channel.certificateIdentities.length, 1);
|
||||||
|
for (const endpoint of [
|
||||||
|
"https://127.0.0.1:8443/",
|
||||||
|
"https://192.168.1.1:8443/",
|
||||||
|
"https://155.212.211.15:9921/",
|
||||||
|
"http://155.212.211.15:8443/",
|
||||||
|
]) {
|
||||||
|
assert.throws(
|
||||||
|
() => normalizeInfrastructureManagementCommand("edge.ensure", {
|
||||||
|
edgeKey: "bad-edge",
|
||||||
|
displayName: "Bad Edge",
|
||||||
|
channel: { ...command.channel, endpoint },
|
||||||
|
}),
|
||||||
|
/device_edge_channel_endpoint_invalid/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.throws(
|
||||||
|
() => normalizeInfrastructureManagementCommand("edge.ensure", {
|
||||||
|
edgeKey: "bad-edge",
|
||||||
|
displayName: "Bad Edge",
|
||||||
|
channel: { ...command.channel, servername: "example.invalid" },
|
||||||
|
}),
|
||||||
|
/device_edge_channel_servername_mismatch/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => normalizeInfrastructureManagementCommand("edge.ensure", {
|
||||||
|
edgeKey: "bad-edge",
|
||||||
|
displayName: "Bad Edge",
|
||||||
|
channel: { lifecycleState: "disabled", endpoint: command.channel.endpoint },
|
||||||
|
}),
|
||||||
|
/device_edge_channel_disabled_configuration_invalid/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
function actor(hubRole) {
|
function actor(hubRole) {
|
||||||
return normalizeManagementActor({
|
return normalizeManagementActor({
|
||||||
userRef: "user:platform-admin",
|
userRef: "user:platform-admin",
|
||||||
|
|||||||
@@ -192,6 +192,48 @@ test("stores enrollment digest but returns and audits only its masked projection
|
|||||||
assert.equal(client.released, true);
|
assert.equal(client.released, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("lists only bounded active Edge channel registrations without secrets", async () => {
|
||||||
|
const client = scriptedClient([
|
||||||
|
step("begin transaction read only"),
|
||||||
|
step("from device_edges", {
|
||||||
|
rows: [{
|
||||||
|
id: edgeId,
|
||||||
|
channel_endpoint: "https://155.212.211.15:8443/",
|
||||||
|
channel_servername: "155.212.211.15",
|
||||||
|
channel_generation_ref: "channel-generation:1",
|
||||||
|
channel_trust_bundle_ref: "edge-trust:moscow-edge",
|
||||||
|
channel_certificate_identities: [{
|
||||||
|
generationRef: "edge-identity:1",
|
||||||
|
fingerprint: "AA:".repeat(31) + "AA",
|
||||||
|
status: "active",
|
||||||
|
}],
|
||||||
|
channel_lifecycle_state: "active",
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
step("commit"),
|
||||||
|
]);
|
||||||
|
const repository = repositoryWithClient(client);
|
||||||
|
|
||||||
|
const registrations = await repository.listActiveEdgeChannelRegistrations(8);
|
||||||
|
|
||||||
|
assert.deepEqual(registrations[0], {
|
||||||
|
edgeRegistrationId: `edge:${edgeId}`,
|
||||||
|
endpoint: "https://155.212.211.15:8443/",
|
||||||
|
servername: "155.212.211.15",
|
||||||
|
channelGeneration: "channel-generation:1",
|
||||||
|
trustBundleRef: "edge-trust:moscow-edge",
|
||||||
|
certificateIdentities: [{
|
||||||
|
generationRef: "edge-identity:1",
|
||||||
|
fingerprint: "AA:".repeat(31) + "AA",
|
||||||
|
status: "active",
|
||||||
|
}],
|
||||||
|
lifecycleState: "active",
|
||||||
|
});
|
||||||
|
assert.equal(JSON.stringify(registrations).includes("private"), false);
|
||||||
|
assert.equal(client.remaining(), 0);
|
||||||
|
assert.equal(client.released, true);
|
||||||
|
});
|
||||||
|
|
||||||
function managementActor(hubRole) {
|
function managementActor(hubRole) {
|
||||||
return normalizeManagementActor({
|
return normalizeManagementActor({
|
||||||
userRef: "user:platform-owner",
|
userRef: "user:platform-owner",
|
||||||
|
|||||||
@@ -173,6 +173,24 @@ test("rejects a revoked Edge registration before opening a channel", async () =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("rejects an Edge hello from an unexpected channel generation", async () => {
|
||||||
|
const edge = createEdgeServer({
|
||||||
|
channelGeneration: "generation:edge-unexpected",
|
||||||
|
});
|
||||||
|
const address = await edge.start();
|
||||||
|
const core = createCoreClient({ address });
|
||||||
|
try {
|
||||||
|
await core.start();
|
||||||
|
await waitFor(() => core.status().protocolFailures >= 1, 2_000);
|
||||||
|
assert.notEqual(core.status().channel, "accepted");
|
||||||
|
assert.match(core.status().lastErrorCode, /channel_generation_mismatch/);
|
||||||
|
assert.equal(edge.status().channelsAccepted, 0);
|
||||||
|
} finally {
|
||||||
|
await core.stop();
|
||||||
|
await edge.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("rejects an authenticated but non-allowlisted Core certificate", async () => {
|
test("rejects an authenticated but non-allowlisted Core certificate", async () => {
|
||||||
const edge = createEdgeServer();
|
const edge = createEdgeServer();
|
||||||
const address = await edge.start();
|
const address = await edge.start();
|
||||||
@@ -370,7 +388,7 @@ function createEdgeServer(options = {}) {
|
|||||||
const edgeCertificate = options.edgeCertificate ?? certificates.edge;
|
const edgeCertificate = options.edgeCertificate ?? certificates.edge;
|
||||||
return createDeviceEdgeChannelServer({
|
return createDeviceEdgeChannelServer({
|
||||||
edgeRegistrationId: "edge:pilot-1",
|
edgeRegistrationId: "edge:pilot-1",
|
||||||
channelGeneration: "generation:pilot-1",
|
channelGeneration: options.channelGeneration ?? "generation:pilot-1",
|
||||||
trustGeneration: options.edgeTrustGeneration ?? "trust-generation:1",
|
trustGeneration: options.edgeTrustGeneration ?? "trust-generation:1",
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
port: 0,
|
port: 0,
|
||||||
@@ -427,6 +445,7 @@ function createCoreClient(options) {
|
|||||||
function edgeRegistration(address, certificateIdentities, lifecycleState = "active") {
|
function edgeRegistration(address, certificateIdentities, lifecycleState = "active") {
|
||||||
return {
|
return {
|
||||||
edgeRegistrationId: "edge:pilot-1",
|
edgeRegistrationId: "edge:pilot-1",
|
||||||
|
channelGeneration: "generation:pilot-1",
|
||||||
endpoint: `https://127.0.0.1:${address.port}/`,
|
endpoint: `https://127.0.0.1:${address.port}/`,
|
||||||
servername: "localhost",
|
servername: "localhost",
|
||||||
certificateIdentities,
|
certificateIdentities,
|
||||||
|
|||||||
@@ -224,6 +224,11 @@ export function createDeviceGatewayCoreChannelClient(options = {}) {
|
|||||||
if (envelope.messageKind !== "channel.hello") {
|
if (envelope.messageKind !== "channel.hello") {
|
||||||
throw new Error("device_gateway_core_channel_hello_required");
|
throw new Error("device_gateway_core_channel_hello_required");
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
envelope.channelGeneration !== connection.registration.channelGeneration
|
||||||
|
) {
|
||||||
|
throw new Error("device_gateway_core_channel_generation_mismatch");
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
envelope.payload?.status !== "ready"
|
envelope.payload?.status !== "ready"
|
||||||
|| envelope.payload?.transport !== "http2-mtls"
|
|| envelope.payload?.transport !== "http2-mtls"
|
||||||
@@ -233,7 +238,7 @@ export function createDeviceGatewayCoreChannelClient(options = {}) {
|
|||||||
) {
|
) {
|
||||||
throw new Error("device_gateway_core_channel_hello_invalid");
|
throw new Error("device_gateway_core_channel_hello_invalid");
|
||||||
}
|
}
|
||||||
connection.channelGeneration = envelope.channelGeneration;
|
connection.channelGeneration = connection.registration.channelGeneration;
|
||||||
send(connection, "channel.accepted", {
|
send(connection, "channel.accepted", {
|
||||||
status: "accepted",
|
status: "accepted",
|
||||||
coreIdentity: config.coreIdentity,
|
coreIdentity: config.coreIdentity,
|
||||||
@@ -549,6 +554,10 @@ function normalizeRegistration(value) {
|
|||||||
value.edgeRegistrationId,
|
value.edgeRegistrationId,
|
||||||
"edge_registration_id",
|
"edge_registration_id",
|
||||||
),
|
),
|
||||||
|
channelGeneration: normalizeRef(
|
||||||
|
value.channelGeneration,
|
||||||
|
"channel_generation",
|
||||||
|
),
|
||||||
endpoint: endpoint.toString(),
|
endpoint: endpoint.toString(),
|
||||||
servername,
|
servername,
|
||||||
certificateIdentities: normalizeCertificateIdentities(
|
certificateIdentities: normalizeCertificateIdentities(
|
||||||
|
|||||||
Reference in New Issue
Block a user