505 lines
17 KiB
JavaScript
505 lines
17 KiB
JavaScript
import { createHash, timingSafeEqual } from "node:crypto";
|
|
import { createServer } from "node:http";
|
|
|
|
import {
|
|
hashRestrictedIdentifier,
|
|
maskRestrictedIdentifier,
|
|
normalizeRestrictedIdentifier,
|
|
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
|
import { createDeviceGatewayIngest } from "./gateway-ingest.mjs";
|
|
import {
|
|
normalizeManagementActor,
|
|
} from "./project-management.mjs";
|
|
import { normalizeDeviceManagementCommand } from "./management-command.mjs";
|
|
|
|
const managementRoutes = new Map([
|
|
["/internal/v1/management/owner-scopes:ensure", "owner_scope.ensure"],
|
|
["/internal/v1/management/projects:ensure", "project.ensure"],
|
|
["/internal/v1/management/collections:ensure", "collection.ensure"],
|
|
["/internal/v1/management/project-grants:upsert", "project_grant.upsert"],
|
|
["/internal/v1/management/adapter-packages:ensure", "adapter_package.ensure"],
|
|
["/internal/v1/management/adapter-versions:register", "adapter_version.register"],
|
|
["/internal/v1/management/model-profiles:register", "model_profile.register"],
|
|
["/internal/v1/management/edges:ensure", "edge.ensure"],
|
|
["/internal/v1/management/routes:ensure", "route.ensure"],
|
|
["/internal/v1/management/enrollment-intents:ensure", "enrollment_intent.ensure"],
|
|
["/internal/v1/management/devices:claim", "device.claim"],
|
|
["/internal/v1/management/devices:transfer", "device.transfer"],
|
|
["/internal/v1/management/discoveries:reject", "discovery.reject"],
|
|
["/internal/v1/management/discoveries:expire", "discovery.expire"],
|
|
[
|
|
"/internal/v1/management/device-credential-bindings:upsert",
|
|
"device_credential_binding.upsert",
|
|
],
|
|
[
|
|
"/internal/v1/management/device-credential-bindings:revoke",
|
|
"device_credential_binding.revoke",
|
|
],
|
|
["/internal/v1/management/device-bindings:ensure", "device_binding.ensure"],
|
|
["/internal/v1/management/device-bindings:revoke", "device_binding.revoke"],
|
|
[
|
|
"/internal/v1/management/device-configuration-revisions:create",
|
|
"device_configuration_revision.create",
|
|
],
|
|
[
|
|
"/internal/v1/management/device-configurations:set-desired",
|
|
"device_configuration_desired.set",
|
|
],
|
|
]);
|
|
|
|
export function createControlCoreApp({
|
|
repository,
|
|
gatewayToken = "",
|
|
identifierPepper = "",
|
|
discoveryIngestEnabled = false,
|
|
managementApiEnabled = false,
|
|
managementToken = "",
|
|
gatewayIngest = null,
|
|
edgeChannelStatusProvider = null,
|
|
typedCommandRuntime = null,
|
|
} = {}) {
|
|
if (!repository || typeof repository.health !== "function") {
|
|
throw new TypeError("device_repository_required");
|
|
}
|
|
if (discoveryIngestEnabled) {
|
|
if (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 gatewayToken !== "string" || gatewayToken.length < 32) {
|
|
throw new TypeError("device_gateway_token_invalid");
|
|
}
|
|
if (typeof identifierPepper !== "string" || identifierPepper.length < 32) {
|
|
throw new TypeError("device_identifier_pepper_invalid");
|
|
}
|
|
}
|
|
if (managementApiEnabled) {
|
|
if (typeof repository.executeManagementCommand !== "function") {
|
|
throw new TypeError("device_management_repository_required");
|
|
}
|
|
if (typeof managementToken !== "string" || managementToken.length < 32) {
|
|
throw new TypeError("device_management_token_invalid");
|
|
}
|
|
if (typeof identifierPepper !== "string" || identifierPepper.length < 32) {
|
|
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 (
|
|
typedCommandRuntime != null
|
|
&& (
|
|
typeof typedCommandRuntime.planServicePing !== "function"
|
|
|| typeof typedCommandRuntime.status !== "function"
|
|
)
|
|
) {
|
|
throw new TypeError("device_typed_command_runtime_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");
|
|
response.setHeader("Cache-Control", "no-store");
|
|
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
|
|
try {
|
|
const requestUrl = new URL(
|
|
request.url || "/",
|
|
`http://${request.headers.host || "127.0.0.1"}`,
|
|
);
|
|
|
|
if (request.method === "GET" && requestUrl.pathname === "/healthz") {
|
|
const database = await repository.health();
|
|
return writeJson(response, 200, {
|
|
ok: true,
|
|
service: "nodedc-device-control-core",
|
|
database,
|
|
discoveryIngest: discoveryIngestEnabled ? "enabled" : "disabled",
|
|
managementApi: managementApiEnabled ? "enabled" : "disabled",
|
|
edgeChannels: edgeChannelStatusProvider
|
|
? edgeChannelStatusProvider()
|
|
: { enabled: false, configured: 0, accepted: 0, degraded: 0 },
|
|
commandTransport: typedCommandRuntime
|
|
? "typed-service-ping-v1"
|
|
: "disabled",
|
|
});
|
|
}
|
|
|
|
if (
|
|
request.method === "POST"
|
|
&& requestUrl.pathname === "/internal/v1/commands:service-ping"
|
|
) {
|
|
if (!managementApiEnabled || !typedCommandRuntime) {
|
|
return writeJson(response, 404, {
|
|
ok: false,
|
|
error: "device_command_transport_disabled",
|
|
});
|
|
}
|
|
if (!matchesBearer(request.headers.authorization, managementToken)) {
|
|
return writeJson(response, 401, {
|
|
ok: false,
|
|
error: "device_management_auth_required",
|
|
});
|
|
}
|
|
const idempotencyKey = normalizeIdempotencyKey(
|
|
request.headers["idempotency-key"],
|
|
);
|
|
const actor = managementActorFromHeaders(request.headers);
|
|
const input = await readJsonBody(request, 8 * 1024);
|
|
const execution = await typedCommandRuntime.planServicePing({
|
|
idempotencyKey,
|
|
actor,
|
|
input,
|
|
});
|
|
response.setHeader("Idempotency-Key", idempotencyKey);
|
|
response.setHeader(
|
|
"Idempotency-Replayed",
|
|
execution.replayed ? "true" : "false",
|
|
);
|
|
return writeJson(response, 200, {
|
|
ok: true,
|
|
replayed: execution.replayed,
|
|
result: execution.command,
|
|
});
|
|
}
|
|
|
|
const managementCommandKind = managementRoutes.get(requestUrl.pathname);
|
|
if (request.method === "POST" && managementCommandKind) {
|
|
if (!managementApiEnabled) {
|
|
return writeJson(response, 404, {
|
|
ok: false,
|
|
error: "device_management_api_disabled",
|
|
});
|
|
}
|
|
if (!matchesBearer(request.headers.authorization, managementToken)) {
|
|
return writeJson(response, 401, {
|
|
ok: false,
|
|
error: "device_management_auth_required",
|
|
});
|
|
}
|
|
|
|
const idempotencyKey = normalizeIdempotencyKey(
|
|
request.headers["idempotency-key"],
|
|
);
|
|
const actor = managementActorFromHeaders(request.headers);
|
|
const input = await readJsonBody(request, 64 * 1024);
|
|
const protectedInput = managementCommandKind === "enrollment_intent.ensure"
|
|
? protectEnrollmentIdentifier(input, identifierPepper)
|
|
: input;
|
|
const command = normalizeDeviceManagementCommand(
|
|
managementCommandKind,
|
|
protectedInput,
|
|
);
|
|
const requestDigest = managementRequestDigest({
|
|
actor,
|
|
commandKind: managementCommandKind,
|
|
command,
|
|
});
|
|
const execution = await repository.executeManagementCommand({
|
|
idempotencyKey,
|
|
commandKind: managementCommandKind,
|
|
requestDigest,
|
|
actor,
|
|
command,
|
|
});
|
|
response.setHeader("Idempotency-Key", idempotencyKey);
|
|
response.setHeader(
|
|
"Idempotency-Replayed",
|
|
execution.replayed ? "true" : "false",
|
|
);
|
|
return writeJson(response, 200, {
|
|
ok: true,
|
|
replayed: execution.replayed,
|
|
result: execution.result,
|
|
});
|
|
}
|
|
|
|
if (
|
|
request.method === "GET"
|
|
&& requestUrl.pathname === "/internal/v1/query/projects"
|
|
) {
|
|
if (!managementApiEnabled) {
|
|
return writeJson(response, 404, {
|
|
ok: false,
|
|
error: "device_management_api_disabled",
|
|
});
|
|
}
|
|
if (!matchesBearer(request.headers.authorization, managementToken)) {
|
|
return writeJson(response, 401, {
|
|
ok: false,
|
|
error: "device_management_auth_required",
|
|
});
|
|
}
|
|
if (typeof repository.listAccessibleProjects !== "function") {
|
|
return writeJson(response, 503, {
|
|
ok: false,
|
|
error: "device_query_repository_unavailable",
|
|
});
|
|
}
|
|
const actor = managementActorFromHeaders(request.headers);
|
|
const projects = await repository.listAccessibleProjects(actor);
|
|
return writeJson(response, 200, { ok: true, projects });
|
|
}
|
|
|
|
const workspaceProjectId = projectWorkspaceId(requestUrl.pathname);
|
|
if (request.method === "GET" && workspaceProjectId) {
|
|
if (!managementApiEnabled) {
|
|
return writeJson(response, 404, {
|
|
ok: false,
|
|
error: "device_management_api_disabled",
|
|
});
|
|
}
|
|
if (!matchesBearer(request.headers.authorization, managementToken)) {
|
|
return writeJson(response, 401, {
|
|
ok: false,
|
|
error: "device_management_auth_required",
|
|
});
|
|
}
|
|
if (typeof repository.getProjectWorkspace !== "function") {
|
|
return writeJson(response, 503, {
|
|
ok: false,
|
|
error: "device_query_repository_unavailable",
|
|
});
|
|
}
|
|
const actor = managementActorFromHeaders(request.headers);
|
|
const workspace = await repository.getProjectWorkspace(
|
|
actor,
|
|
workspaceProjectId,
|
|
{
|
|
commandTransport: typedCommandRuntime
|
|
? "typed-service-ping-v1"
|
|
: "disabled",
|
|
},
|
|
);
|
|
return writeJson(response, 200, { ok: true, workspace });
|
|
}
|
|
|
|
if (
|
|
request.method === "POST"
|
|
&& requestUrl.pathname === "/internal/v1/device-discoveries:observe"
|
|
) {
|
|
if (!discoveryIngestEnabled) {
|
|
return writeJson(response, 404, {
|
|
ok: false,
|
|
error: "device_discovery_ingest_disabled",
|
|
});
|
|
}
|
|
if (!matchesBearer(request.headers.authorization, gatewayToken)) {
|
|
return writeJson(response, 401, {
|
|
ok: false,
|
|
error: "device_gateway_auth_required",
|
|
});
|
|
}
|
|
|
|
const input = await readJsonBody(request, 32 * 1024);
|
|
const discovery = await ingest.observeDiscovery(input);
|
|
return writeJson(response, discovery.created ? 201 : 200, {
|
|
ok: true,
|
|
created: discovery.created,
|
|
discovery: discovery.value,
|
|
});
|
|
}
|
|
|
|
if (
|
|
request.method === "POST"
|
|
&& requestUrl.pathname === "/internal/v1/gateway/messages:accept"
|
|
) {
|
|
if (!discoveryIngestEnabled) {
|
|
return writeJson(response, 404, {
|
|
ok: false,
|
|
error: "device_gateway_message_ingest_disabled",
|
|
});
|
|
}
|
|
if (!matchesBearer(request.headers.authorization, gatewayToken)) {
|
|
return writeJson(response, 401, {
|
|
ok: false,
|
|
error: "device_gateway_auth_required",
|
|
});
|
|
}
|
|
|
|
const input = await readJsonBody(request, 1024 * 1024);
|
|
const receipt = await ingest.acceptMessage(input);
|
|
const acceptance = receipt.value;
|
|
return writeJson(response, acceptance.replayed ? 200 : 201, {
|
|
ok: true,
|
|
acceptance,
|
|
});
|
|
}
|
|
|
|
return writeJson(response, 404, {
|
|
ok: false,
|
|
error: "device_control_core_route_not_found",
|
|
});
|
|
} catch (error) {
|
|
const status = Number(error?.statusCode || 400);
|
|
return writeJson(
|
|
response,
|
|
Number.isInteger(status) && status >= 400 && status < 600
|
|
? status
|
|
: 500,
|
|
{
|
|
ok: false,
|
|
error: safeErrorCode(error),
|
|
},
|
|
);
|
|
}
|
|
});
|
|
|
|
return server;
|
|
}
|
|
|
|
function protectEnrollmentIdentifier(input, identifierPepper) {
|
|
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
throw new TypeError("device_enrollment_input_invalid");
|
|
}
|
|
const allowedKeys = new Set([
|
|
"projectRef",
|
|
"enrollmentKey",
|
|
"routeRef",
|
|
"modelProfileRef",
|
|
"displayName",
|
|
"identifier",
|
|
"expiresAt",
|
|
]);
|
|
for (const key of Object.keys(input)) {
|
|
if (!allowedKeys.has(key)) {
|
|
throw new TypeError("device_enrollment_input_field_unexpected");
|
|
}
|
|
}
|
|
const identifier = normalizeRestrictedIdentifier(input.identifier);
|
|
return Object.freeze({
|
|
projectRef: input.projectRef,
|
|
enrollmentKey: input.enrollmentKey,
|
|
routeRef: input.routeRef,
|
|
modelProfileRef: input.modelProfileRef,
|
|
displayName: input.displayName,
|
|
identifierKind: identifier.kind,
|
|
identifierDigest: hashRestrictedIdentifier(identifier, identifierPepper),
|
|
identifierMasked: maskRestrictedIdentifier(identifier),
|
|
expiresAt: input.expiresAt,
|
|
});
|
|
}
|
|
|
|
function projectWorkspaceId(pathname) {
|
|
const match = pathname.match(
|
|
/^\/internal\/v1\/query\/projects\/([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\/workspace$/i,
|
|
);
|
|
return match?.[1]?.toLowerCase() ?? null;
|
|
}
|
|
|
|
function managementActorFromHeaders(headers) {
|
|
return normalizeManagementActor({
|
|
userRef: singleHeader(headers["x-nodedc-user-ref"]),
|
|
hubRole: singleHeader(headers["x-nodedc-hub-role"]),
|
|
groupRefs: commaSeparatedHeader(headers["x-nodedc-group-refs"]),
|
|
ownerScopes: ownerScopeHeader(headers["x-nodedc-owner-scopes"]),
|
|
});
|
|
}
|
|
|
|
function ownerScopeHeader(value) {
|
|
return commaSeparatedHeader(value).map((claim) => {
|
|
const separatorIndex = claim.indexOf("=");
|
|
if (separatorIndex < 1 || separatorIndex === claim.length - 1) {
|
|
throw new TypeError("device_actor_owner_scopes_invalid");
|
|
}
|
|
return {
|
|
scopeKind: claim.slice(0, separatorIndex),
|
|
ownerRef: claim.slice(separatorIndex + 1),
|
|
};
|
|
});
|
|
}
|
|
|
|
function commaSeparatedHeader(value) {
|
|
const header = singleHeader(value, true);
|
|
if (!header) return [];
|
|
return header.split(",").map((item) => item.trim()).filter(Boolean);
|
|
}
|
|
|
|
function singleHeader(value, optional = false) {
|
|
if (Array.isArray(value)) throw new TypeError("device_management_header_invalid");
|
|
if (value == null || value === "") {
|
|
if (optional) return "";
|
|
throw new TypeError("device_management_header_required");
|
|
}
|
|
if (typeof value !== "string" || value.length > 4096) {
|
|
throw new TypeError("device_management_header_invalid");
|
|
}
|
|
return value.trim();
|
|
}
|
|
|
|
function normalizeIdempotencyKey(value) {
|
|
const key = singleHeader(value);
|
|
if (!/^[\x21-\x7e]{8,256}$/.test(key)) {
|
|
const error = new Error("device_idempotency_key_invalid");
|
|
error.statusCode = 400;
|
|
throw error;
|
|
}
|
|
return key;
|
|
}
|
|
|
|
function managementRequestDigest(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");
|
|
const required = Buffer.from(expected, "utf8");
|
|
return (
|
|
actual.length === required.length
|
|
&& required.length > 0
|
|
&& timingSafeEqual(actual, required)
|
|
);
|
|
}
|
|
|
|
async function readJsonBody(request, maxBytes) {
|
|
const chunks = [];
|
|
let size = 0;
|
|
for await (const chunk of request) {
|
|
size += chunk.length;
|
|
if (size > maxBytes) {
|
|
const error = new Error("device_request_body_too_large");
|
|
error.statusCode = 413;
|
|
throw error;
|
|
}
|
|
chunks.push(chunk);
|
|
}
|
|
if (size === 0) throw new TypeError("device_request_body_required");
|
|
try {
|
|
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
} catch {
|
|
throw new TypeError("device_request_json_invalid");
|
|
}
|
|
}
|
|
|
|
function writeJson(response, status, body) {
|
|
response.statusCode = status;
|
|
return response.end(`${JSON.stringify(body)}\n`);
|
|
}
|
|
|
|
function safeErrorCode(error) {
|
|
const value = error instanceof Error ? error.message : "device_control_error";
|
|
return /^[a-z0-9_:-]{1,128}$/.test(value)
|
|
? value
|
|
: "device_control_error";
|
|
}
|