guanghu-ice-heart/server-tools/lake-lamp-authz/ghdr-controller-broker.js

195 lines
7.5 KiB
JavaScript
Raw Normal View History

"use strict";
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const POLL_FIELDS = [
"schema", "node_id", "issued_at_unix", "nonce",
];
const RESULT_FIELDS = [
"schema", "node_id", "job_id", "layout_payload_sha256",
"signature_hex", "issued_at_unix", "nonce",
];
class GhdrControllerBroker {
constructor({
controllers,
stateDir = "/var/lib/guanghu/lake-lamp-authz/ghdr-jobs",
authorizer,
now = () => Math.floor(Date.now() / 1000),
waitMs = 45000,
}) {
if (!Array.isArray(controllers) || controllers.length !== 2) {
throw new Error("ghdr_controller_registry_requires_exactly_two");
}
this.controllers = new Map(controllers.map(item => [item.node_id, validateController(item)]));
if (this.controllers.size !== 2
|| new Set(controllers.map(item => item.failure_domain)).size !== 2) {
throw new Error("ghdr_controller_registry_not_independent");
}
this.stateDir = stateDir;
this.authorizer = authorizer;
this.now = now;
this.waitMs = waitMs;
this.replay = new Map();
fs.mkdirSync(this.stateDir, { recursive: true, mode: 0o700 });
}
async queueAndWait({ plan, binding, workorderId }) {
const jobId = crypto.randomUUID();
const job = {
schema: "guanghu.ghdr-controller-job/v1",
job_id: jobId,
created_at_unix: this.now(),
expires_at_unix: this.now() + 120,
layout_payload_sha256: binding.payload_sha256,
resource: binding.resource,
workorder_id: workorderId,
plan,
authorizations: {},
results: {},
};
for (const controller of this.controllers.values()) {
job.authorizations[controller.node_id] = this.authorizer.issue({
controllerNodeId: controller.node_id,
targetNodeId: "GH-CVM-MAIN-PROD-01",
layoutPayloadSha256: binding.payload_sha256,
resource: binding.resource,
workorderId,
});
}
this.writeJob(job);
const deadline = Date.now() + this.waitMs;
while (Date.now() < deadline) {
const current = this.readJob(jobId);
if (Object.keys(current.results).length === 2) {
return {
ok: true,
signatures: [...this.controllers.keys()].map(nodeId => current.results[nodeId]),
};
}
await new Promise(resolve => setTimeout(resolve, 250));
}
return { ok: false, error: "ghdr_controller_poll_timeout" };
}
poll(body) {
const authenticated = this.authenticate(body && body.request, body && body.request_signature_hex, POLL_FIELDS, "guanghu.ghdr-controller-poll/v1");
if (!authenticated.ok) return authenticated;
const nodeId = authenticated.node_id;
const names = fs.readdirSync(this.stateDir).filter(name => /^[0-9a-f-]{36}\.json$/.test(name)).sort();
for (const name of names) {
const job = this.readJob(name.slice(0, -5));
if (job.expires_at_unix <= this.now() || job.results[nodeId]) continue;
return {
ok: true,
job: {
job_id: job.job_id,
expires_at_unix: job.expires_at_unix,
layout_payload_sha256: job.layout_payload_sha256,
authorization: job.authorizations[nodeId],
plan: job.plan,
},
};
}
return { ok: true, job: null };
}
submit(body) {
const authenticated = this.authenticate(body && body.request, body && body.request_signature_hex, RESULT_FIELDS, "guanghu.ghdr-controller-result/v1");
if (!authenticated.ok) return authenticated;
const request = body.request;
const job = this.readJob(request.job_id);
if (job.expires_at_unix <= this.now()) return { ok: false, error: "ghdr_job_expired" };
if (job.layout_payload_sha256 !== request.layout_payload_sha256) {
return { ok: false, error: "ghdr_result_layout_mismatch" };
}
const controller = this.controllers.get(request.node_id);
const signature = body.signature;
if (!signature
|| signature.node_id !== controller.node_id
|| signature.failure_domain !== controller.failure_domain
|| signature.public_key_hex !== controller.layout_public_key_hex
|| signature.signature_hex !== request.signature_hex) {
return { ok: false, error: "ghdr_result_binding_mismatch" };
}
const payload = Buffer.from(JSON.stringify(job.plan.payload));
if (!verifyHex(controller.layout_public_key_hex, payload, request.signature_hex)) {
return { ok: false, error: "ghdr_layout_signature_invalid" };
}
if (job.results[controller.node_id]) {
return { ok: false, error: "ghdr_result_already_submitted" };
}
job.results[controller.node_id] = signature;
this.writeJob(job);
return { ok: true };
}
authenticate(request, signatureHex, fields, schema) {
if (!request || typeof request !== "object" || JSON.stringify(Object.keys(request)) !== JSON.stringify(fields)) {
return { ok: false, error: "ghdr_controller_auth_not_canonical" };
}
if (request.schema !== schema || !this.controllers.has(request.node_id)) {
return { ok: false, error: "ghdr_controller_auth_unknown" };
}
if (!Number.isSafeInteger(request.issued_at_unix)
|| Math.abs(this.now() - request.issued_at_unix) > 30
|| !/^[A-Za-z0-9_-]{32}$/.test(String(request.nonce || ""))) {
return { ok: false, error: "ghdr_controller_auth_expired" };
}
const controller = this.controllers.get(request.node_id);
const canonical = Buffer.from(JSON.stringify(request));
if (!verifyHex(controller.transport_public_key_hex, canonical, signatureHex)) {
return { ok: false, error: "ghdr_controller_auth_invalid" };
}
const replay = crypto.createHash("sha256").update(canonical).digest("hex");
for (const [digest, expires] of this.replay) {
if (expires < this.now()) this.replay.delete(digest);
}
if (this.replay.has(replay)) return { ok: false, error: "ghdr_controller_auth_replayed" };
this.replay.set(replay, this.now() + 35);
return { ok: true, node_id: request.node_id };
}
readJob(jobId) {
if (!/^[0-9a-f-]{36}$/.test(String(jobId))) throw new Error("ghdr_job_id_invalid");
return JSON.parse(fs.readFileSync(path.join(this.stateDir, `${jobId}.json`), "utf8"));
}
writeJob(job) {
const destination = path.join(this.stateDir, `${job.job_id}.json`);
const temporary = `${destination}.${process.pid}.tmp`;
fs.writeFileSync(temporary, JSON.stringify(job), { mode: 0o600 });
fs.renameSync(temporary, destination);
}
}
function validateController(controller) {
if (!controller
|| !/^GH-CTRL-(GZ|SG)-01$/.test(String(controller.node_id))
|| !/^tencent\/ap-(guangzhou|singapore)\/[A-Z0-9-]+$/.test(String(controller.failure_domain))
|| !/^[0-9a-f]{64}$/.test(String(controller.layout_public_key_hex))
|| !/^[0-9a-f]{64}$/.test(String(controller.transport_public_key_hex))) {
throw new Error("ghdr_controller_registry_invalid");
}
return Object.freeze({ ...controller });
}
function verifyHex(publicKeyHex, message, signatureHex) {
if (!/^[0-9a-f]{128}$/.test(String(signatureHex || ""))) return false;
const prefix = Buffer.from("302a300506032b6570032100", "hex");
const publicKey = crypto.createPublicKey({
key: Buffer.concat([prefix, Buffer.from(publicKeyHex, "hex")]),
format: "der",
type: "spki",
});
return crypto.verify(null, message, publicKey, Buffer.from(signatureHex, "hex"));
}
function loadControllers(file) {
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
return parsed.controllers;
}
module.exports = { GhdrControllerBroker, loadControllers, verifyHex };