195 lines
7.5 KiB
JavaScript
195 lines
7.5 KiB
JavaScript
"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 };
|