feat(authz): add email-authorized GHDR dual signing
This commit is contained in:
parent
84f7c4c198
commit
bec7a3d7a0
10 changed files with 771 additions and 5 deletions
|
|
@ -11,6 +11,8 @@ const { executeRegisteredAction } = require("./action-client");
|
|||
const { enqueueDeploymentEvent } = require("./deployment-event");
|
||||
const { GuanghuRouter, loadDevices } = require("./guanghu-router");
|
||||
const { HoloLakeSessionManager } = require("./hololake-session");
|
||||
const { GhdrAuthorizer } = require("./ghdr-authorizer");
|
||||
const { GhdrControllerBroker, loadControllers } = require("./ghdr-controller-broker");
|
||||
const {
|
||||
HoloLakeAiGateway,
|
||||
HoloLakeKnowledgeProvider,
|
||||
|
|
@ -48,6 +50,7 @@ const DEFAULT_ACTIONS = Object.freeze({
|
|||
],
|
||||
"repo-push": ["read-navigation-map", "push-repository"],
|
||||
"linked-node-ops": ["authorize-linked-node-session"],
|
||||
"native-recovery": ["read-navigation-map", "sign-native-layout-plan"],
|
||||
});
|
||||
|
||||
function createApp(options = {}) {
|
||||
|
|
@ -57,6 +60,7 @@ function createApp(options = {}) {
|
|||
const approvers = options.approvers || loadApprovers(options.approversFile || process.env.LAKE_LAMP_APPROVERS_FILE || "", ownerEmail);
|
||||
const publicBaseUrl = String(options.publicBaseUrl || process.env.LAKE_LAMP_PUBLIC_URL || "").replace(/\/$/, "");
|
||||
const targets = new Set(options.targets || splitCsv(process.env.LAKE_LAMP_TARGETS || "JD-FD-PRIMARY,BS-GZ-006"));
|
||||
targets.add("GH-CVM-MAIN-PROD-01");
|
||||
const actions = options.actions || DEFAULT_ACTIONS;
|
||||
const devices = options.devices || loadDevices(
|
||||
options.devicesFile
|
||||
|
|
@ -145,6 +149,7 @@ function createApp(options = {}) {
|
|||
);
|
||||
const mapGate = options.mapGate || new MapGate({
|
||||
mapsDir: options.mapsDir || process.env.LAKE_LAMP_MAPS_DIR || "/etc/guanghu/navigation-maps",
|
||||
fallbackMapsDir: options.fallbackMapsDir || path.join(__dirname, "navigation-maps"),
|
||||
stateFile: Object.prototype.hasOwnProperty.call(options, "mapStateFile") ? options.mapStateFile : (process.env.LAKE_LAMP_MAP_STATE_FILE || "/var/lib/guanghu/lake-lamp-authz/map-acks.json"),
|
||||
});
|
||||
const repoGrantDir = options.repoGrantDir || process.env.LAKE_LAMP_REPO_GRANT_DIR || "/var/lib/guanghu/repo-authorizations";
|
||||
|
|
@ -173,6 +178,36 @@ function createApp(options = {}) {
|
|||
const deploymentQueueDir = options.deploymentQueueDir || process.env.LAKE_LAMP_DEPLOYMENT_EVENT_DIR || "/var/lib/guanghu/deployment-events";
|
||||
const deploymentRegistryFile = options.deploymentRegistryFile || process.env.LAKE_LAMP_DEPLOYMENT_REPOSITORIES || "/etc/guanghu/lake-lamp/deployment-repositories.json";
|
||||
const executeAction = options.executeAction || executeRegisteredAction;
|
||||
let ghdrAuthorizer = null;
|
||||
const getGhdrAuthorizer = options.getGhdrAuthorizer || (() => {
|
||||
if (!ghdrAuthorizer) {
|
||||
ghdrAuthorizer = new GhdrAuthorizer({
|
||||
privateKeyFile: options.ghdrAuthorizerPrivateKeyFile
|
||||
|| process.env.GHDR_AUTHORIZER_PRIVATE_KEY_FILE
|
||||
|| "/var/lib/guanghu/lake-lamp-authz/ghdr-authorizer-private.pem",
|
||||
});
|
||||
}
|
||||
return ghdrAuthorizer;
|
||||
});
|
||||
let ghdrControllerBroker = null;
|
||||
const getGhdrControllerBroker = options.getGhdrControllerBroker || (() => {
|
||||
if (!ghdrControllerBroker) {
|
||||
const registryFile = options.ghdrControllerRegistryFile
|
||||
|| process.env.GHDR_CONTROLLER_REGISTRY_FILE
|
||||
|| path.join(__dirname, "ghdr-controllers.json");
|
||||
ghdrControllerBroker = new GhdrControllerBroker({
|
||||
controllers: loadControllers(registryFile),
|
||||
stateDir: options.ghdrJobStateDir
|
||||
|| process.env.GHDR_JOB_STATE_DIR
|
||||
|| "/var/lib/guanghu/lake-lamp-authz/ghdr-jobs",
|
||||
authorizer: getGhdrAuthorizer(),
|
||||
});
|
||||
}
|
||||
return ghdrControllerBroker;
|
||||
});
|
||||
const signGhdrPlan = options.signGhdrPlan || (
|
||||
request => getGhdrControllerBroker().queueAndWait(request)
|
||||
);
|
||||
// Creating a powerless request must never become harder than the human mail
|
||||
// handoff. Keep at least three attempts per network each hour.
|
||||
const publicCreateLimit = Math.max(3, Number(options.publicCreateLimit || process.env.LAKE_LAMP_PUBLIC_CREATE_LIMIT || 24));
|
||||
|
|
@ -1035,6 +1070,97 @@ function createApp(options = {}) {
|
|||
return json(res, queued.state === "queued_for_resident_agent" ? 202 : 400, { ok: queued.state === "queued_for_resident_agent", deployment: queued, receipt: operationReceipt });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/ghdr/controllers/poll") {
|
||||
const body = await readJson(req);
|
||||
if (!body) return json(res, 400, failure("invalid_json"));
|
||||
const result = getGhdrControllerBroker().poll(body);
|
||||
return json(res, result.ok ? 200 : 403, result);
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/ghdr/controllers/result") {
|
||||
const body = await readJson(req);
|
||||
if (!body) return json(res, 400, failure("invalid_json"));
|
||||
try {
|
||||
const result = getGhdrControllerBroker().submit(body);
|
||||
return json(res, result.ok ? 200 : 403, result);
|
||||
} catch {
|
||||
return json(res, 400, failure("ghdr_result_invalid"));
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/ghdr/sign-layout") {
|
||||
const body = await readJson(req);
|
||||
if (!body) return json(res, 400, failure("invalid_json"));
|
||||
if (body.cmd || body.command || body.shell || body.args) {
|
||||
return json(res, 400, failure("arbitrary_command_forbidden"));
|
||||
}
|
||||
const token = bearer(req);
|
||||
const target = String(body.target || "");
|
||||
const scope = String(body.scope || "native-recovery");
|
||||
const resource = String(body.resource || "");
|
||||
const binding = validateGhdrLayoutRequest(body.plan, target, resource);
|
||||
if (!binding.ok) return json(res, 400, failure(binding.error));
|
||||
const verified = manager.verifySession(
|
||||
token,
|
||||
{ pid: String(body.persona_id || "") },
|
||||
target,
|
||||
scope,
|
||||
"sign-native-layout-plan",
|
||||
Date.now() / 1000,
|
||||
resource,
|
||||
);
|
||||
if (!verified.ok) return json(res, 403, failure(verified.reason));
|
||||
const map = mapGate.read(target);
|
||||
if (!mapGate.verify(token, target, map.hash).ok) {
|
||||
return json(res, 423, failure("map_ack_required", "先读取并确认导航图。", {
|
||||
required_action: "read-navigation-map",
|
||||
}));
|
||||
}
|
||||
const result = await signGhdrPlan({
|
||||
plan: body.plan,
|
||||
binding,
|
||||
workorderId: verified.session.workorderId,
|
||||
authorizer: getGhdrAuthorizer(),
|
||||
});
|
||||
const signatures = Array.isArray(result.signatures) ? result.signatures : [];
|
||||
const independent = signatures.length === 2
|
||||
&& new Set(signatures.map(item => item && item.node_id)).size === 2
|
||||
&& new Set(signatures.map(item => item && item.failure_domain)).size === 2;
|
||||
const succeeded = Boolean(result.ok && independent);
|
||||
const operationReceipt = receipt({
|
||||
state: succeeded ? "succeeded" : "failed",
|
||||
diagnostic_code: succeeded
|
||||
? "ghdr_layout_double_signature_succeeded"
|
||||
: String(result.error || "ghdr_layout_double_signature_failed"),
|
||||
workorder_id: verified.session.workorderId,
|
||||
target,
|
||||
action: "sign-native-layout-plan",
|
||||
evidence: {
|
||||
layout_payload_sha256: binding.payload_sha256,
|
||||
generation: binding.generation,
|
||||
controller_count: signatures.length,
|
||||
controller_ids: signatures.map(item => String(item && item.node_id || "")),
|
||||
},
|
||||
next_step: succeeded
|
||||
? "把两份签名合并回同一份布局计划,并由企业目标机独立验证公钥、故障域、实时读回与有效期。"
|
||||
: "读取 diagnostic_code;不得降级为单签、复制私钥或绕过邮件授权。",
|
||||
});
|
||||
manager.recordReceipt(token, operationReceipt);
|
||||
return json(res, succeeded ? 200 : 502, {
|
||||
ok: succeeded,
|
||||
layout_payload_sha256: binding.payload_sha256,
|
||||
signatures: succeeded ? signatures : [],
|
||||
receipt: operationReceipt,
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/ghdr/authorizer-public-key") {
|
||||
return json(res, 200, {
|
||||
ok: true,
|
||||
binding: getGhdrAuthorizer().publicBinding(),
|
||||
});
|
||||
}
|
||||
|
||||
return json(res, 404, { error: "not_found" });
|
||||
} catch (error) {
|
||||
process.stderr.write(`lake-lamp request error: ${String(error && error.message || "unknown").slice(0, 240)}\n`);
|
||||
|
|
@ -1248,15 +1374,44 @@ function validateWorkorderBody(body, targets, actions) {
|
|||
const immutableResourceAction = action === "provision-approved-architecture" || action === "dispatch-approved-deployment";
|
||||
const repoPushResourceAction = action === "push-repository";
|
||||
const linkedNodeResourceAction = action === "authorize-linked-node-session";
|
||||
const ghdrLayoutResourceAction = action === "sign-native-layout-plan";
|
||||
if (immutableResourceAction && !/^[A-Z0-9][A-Z0-9._-]{5,119}@[0-9a-f]{40}$/.test(resource)) return { ok: false, status: 400, error: "immutable_architecture_resource_required" };
|
||||
if (repoPushResourceAction && resource && !/^bingshuo\/[a-z0-9._-]+@[a-z0-9][a-z0-9._/-]{0,199}$/.test(resource)) return { ok: false, status: 400, error: "repo_push_resource_invalid" };
|
||||
if (linkedNodeResourceAction && !/^[A-Z0-9][A-Z0-9._-]{5,119}:[A-Za-z0-9._-]{3,120}$/.test(resource)) return { ok: false, status: 400, error: "linked_node_resource_required" };
|
||||
if (!immutableResourceAction && !repoPushResourceAction && !linkedNodeResourceAction && resource) return { ok: false, status: 400, error: "resource_not_allowed_for_action" };
|
||||
if (ghdrLayoutResourceAction && !/^GH-CVM-MAIN-PROD-01:[0-9a-f]{64}:[1-9][0-9]{0,19}$/.test(resource)) return { ok: false, status: 400, error: "ghdr_layout_resource_required" };
|
||||
if (!immutableResourceAction && !repoPushResourceAction && !linkedNodeResourceAction && !ghdrLayoutResourceAction && resource) return { ok: false, status: 400, error: "resource_not_allowed_for_action" };
|
||||
if (body.owner_notify !== undefined && typeof body.owner_notify !== "boolean") return { ok: false, status: 400, error: "invalid_owner_notify" };
|
||||
if (body.owner_notify === true && provenance.system_entry !== "光湖语言人格系统当前实例") return { ok: false, status: 400, error: "owner_notify_requires_language_system_provenance" };
|
||||
if (Object.values(provenance).some(Boolean) && (provenance.system_entry !== "光湖语言人格系统当前实例" || Object.values(provenance).some(item => !item || item.length > 120))) return { ok: false, status: 400, error: "invalid_instance_provenance" };
|
||||
return { ok: true, request: { persona: { pid: personaId, name: personaName }, provenance, target, scope, action, allowedActions: actions[scope], description, resource } };
|
||||
}
|
||||
function validateGhdrLayoutRequest(plan, target, resource) {
|
||||
if (target !== "GH-CVM-MAIN-PROD-01"
|
||||
|| !plan
|
||||
|| plan.schema !== "guanghu.ghdr-signed-layout-plan/v1"
|
||||
|| !plan.payload
|
||||
|| !Array.isArray(plan.signatures)
|
||||
|| plan.signatures.length !== 0) {
|
||||
return { ok: false, error: "ghdr_layout_plan_invalid" };
|
||||
}
|
||||
const payload = plan.payload;
|
||||
const generation = Number(payload.generation);
|
||||
if (payload.node_id !== target
|
||||
|| payload.provider !== "tencent_cloud"
|
||||
|| payload.region !== "ap-guangzhou"
|
||||
|| payload.system_disk !== "/dev/vda"
|
||||
|| payload.operation !== "install_native_ab"
|
||||
|| !Number.isSafeInteger(generation)
|
||||
|| generation < 1) {
|
||||
return { ok: false, error: "ghdr_layout_binding_mismatch" };
|
||||
}
|
||||
const canonical = JSON.stringify(payload);
|
||||
const payloadSha256 = crypto.createHash("sha256").update(canonical).digest("hex");
|
||||
if (resource !== `${target}:${payloadSha256}:${generation}`) {
|
||||
return { ok: false, error: "ghdr_layout_resource_mismatch" };
|
||||
}
|
||||
return { ok: true, payload_sha256: payloadSha256, generation, resource };
|
||||
}
|
||||
function originLabel(order) {
|
||||
const value = order && order.provenance || {};
|
||||
return value.software || value.model || value.instance ? `${value.software || "未知软件"} · ${value.model || "未知模型"} · ${value.instance || "当前实例"}` : "旧版工单未记录";
|
||||
|
|
@ -1286,4 +1441,11 @@ if (require.main === module) {
|
|||
createApp().listen(port, host, () => process.stdout.write(`lake-lamp-authz listening on ${host}:${port}\n`));
|
||||
}
|
||||
|
||||
module.exports = { createApp, DEFAULT_ACTIONS, SlidingWindowLimiter, loadApprovers, selectApprover };
|
||||
module.exports = {
|
||||
createApp,
|
||||
DEFAULT_ACTIONS,
|
||||
SlidingWindowLimiter,
|
||||
loadApprovers,
|
||||
selectApprover,
|
||||
validateGhdrLayoutRequest,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue