Files
NODEDC_PLATFORM/device-plane/services/device-control-core/src/app.mjs
T

449 lines
15 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,
} = {}) {
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 (
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: "disabled",
});
}
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,
);
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 acceptance = await ingest.acceptMessage(input);
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";
}