195 lines
7.1 KiB
JavaScript
Raw Permalink Normal View History

/*
* /admin/secrets ·
* GET /admin/secrets 列出所有 key (遮罩值)
* GET /admin/secrets/manifest 列出按 workflow 分组的 secret 元数据 (用途/如何配置)
* POST /admin/secrets/:key 写入/更新单个 key {value: "..."}
* DELETE /admin/secrets/:key 删除单个 key
* POST /admin/secrets/autodl/save_and_refresh
* 一键: host/port/user/pass 写入 vault, 然后探活 +
* inference-endpoint.json + pm2 reload (PR-3 联动)
*
* cc-004: 全部中文回执
*/
"use strict";
const express = require("express");
const path = require("path");
const { maskValue } = require("../lib/vault");
const manifestLib = require("../lib/manifest");
const refresh = require("../lib/refresh-inference");
// 合法 key 名: 大写字母数字下划线 (跟 GitHub Secrets 风格一致)
const KEY_RE = /^[A-Z][A-Z0-9_]{1,63}$/;
const VALUE_MAX = 8 * 1024; // 8KB 单 value 上限 (秘钥/SSH key 也够)
function chineseError(res, status, code, message) {
return res.status(status).json({ error: true, code, message });
}
function buildRouter({ vault, manifestPath, endpointPath }) {
const router = express.Router();
const known = manifestLib.knownKeys(manifestPath);
// ─── manifest (前端拉分组定义) ────────────────────────────
router.get("/manifest", (_req, res) => {
try {
const groups = manifestLib.buildGroups(manifestPath);
res.json({
_sovereign: "TCS-0002∞ · 国作登字-2026-A-00037559",
groups
});
} catch (e) {
chineseError(res, 500, "manifest_load_failed", "密钥清单加载失败: " + e.message);
}
});
// ─── 列出已配置的 key (只返回遮罩值) ─────────────────────
router.get("/", (_req, res) => {
let all;
try {
all = vault.loadAll();
} catch (e) {
return chineseError(res, 500, "vault_decrypt_failed",
"密钥库读取失败 (主密钥可能换过): " + e.message);
}
const masked = {};
for (const k of Object.keys(all.secrets)) {
masked[k] = {
configured: true,
masked: maskValue(all.secrets[k]),
length: String(all.secrets[k]).length
};
}
res.json({
updated_at: all.updated_at,
configured_keys: Object.keys(all.secrets),
values: masked
});
});
// ─── 写入单个 key ──────────────────────────────────────────
// 注: 路径参数 :key 严格 KEY_RE 校验, 不允许任意字符串落到 vault
router.post("/:key", express.json({ limit: "16kb" }), (req, res) => {
const key = String(req.params.key || "");
if (!KEY_RE.test(key)) {
return chineseError(res, 400, "invalid_key",
"密钥名格式不对 (只允许大写字母+数字+下划线, 2-64 位, 字母开头): " + key);
}
if (!known.has(key)) {
return chineseError(res, 400, "unknown_key",
"这个密钥名不在 secrets-manifest.json 白名单里: " + key + ". 请铸渊先在清单里登记后再来.");
}
const v = req.body && req.body.value;
if (typeof v !== "string") {
return chineseError(res, 400, "missing_value", "请求体必须是 {\"value\":\"...\"} 这样的 JSON.");
}
if (v.length > VALUE_MAX) {
return chineseError(res, 413, "value_too_large",
"密钥值过长 (>" + VALUE_MAX + " 字节). 真要存这么大, 联系铸渊.");
}
// 强校验 (跟 manifest 对齐: 比如 ZY_GITEA_ADMIN_PASS length>=24)
const meta = manifestLib.findSecretMeta(key, manifestPath);
if (meta && meta["强校验"]) {
const rule = meta["强校验"];
const m = rule.match(/^length>=(\d+)$/);
if (m) {
const need = parseInt(m[1], 10);
if (v.length < need) {
return chineseError(res, 400, "weak_value",
`密钥强度不够 · 要求 ${rule}, 当前长度 ${v.length}.`);
}
}
}
let updated_at;
try {
updated_at = vault.set(key, v);
} catch (e) {
return chineseError(res, 500, "vault_write_failed", "密钥写入失败: " + e.message);
}
res.json({
ok: true,
key,
configured: true,
masked: maskValue(v),
updated_at,
message: "已保存 · " + (meta ? meta["用途"] || key : key)
});
});
// ─── 删除单个 key ──────────────────────────────────────────
router.delete("/:key", (req, res) => {
const key = String(req.params.key || "");
if (!KEY_RE.test(key)) {
return chineseError(res, 400, "invalid_key", "密钥名格式不对: " + key);
}
let removed;
try {
removed = vault.delete(key);
} catch (e) {
return chineseError(res, 500, "vault_write_failed", "密钥删除失败: " + e.message);
}
if (!removed) {
return chineseError(res, 404, "not_configured", "这个密钥本来就没存过: " + key);
}
res.json({ ok: true, key, configured: false, message: "已删除 · " + key });
});
// ─── AutoDL 一键: 保存并刷新推理端点 ────────────────────────
// body: { host, port, user?, pass?, scheme? }
router.post("/autodl/save_and_refresh", express.json({ limit: "16kb" }), async (req, res) => {
const { host, port, user, pass, scheme } = req.body || {};
if (typeof host !== "string" || !host.trim()) {
return chineseError(res, 400, "host_required", "AutoDL 连接地址 (host) 不能为空.");
}
if (typeof port !== "string" && typeof port !== "number") {
return chineseError(res, 400, "port_required", "AutoDL 端口 (port) 不能为空.");
}
// 1. 先把 vault 写一份 (即使探活不通也要保留, 方便冰朔下次)
try {
vault.set("ZY_AUTODL_HOST", String(host).trim());
vault.set("ZY_AUTODL_PORT", String(port).trim());
if (typeof user === "string" && user) vault.set("ZY_AUTODL_USER", user);
if (typeof pass === "string" && pass) vault.set("ZY_AUTODL_PASS", pass);
} catch (e) {
return chineseError(res, 500, "vault_write_failed",
"vault 写入失败 (端点未刷新): " + e.message);
}
// 2. 探活 + 写 inference-endpoint.json + pm2 reload
const r = await refresh.saveAndRefresh({
host,
port,
scheme,
endpointPath,
skipPmReload: process.env.VAULT_SKIP_PM_RELOAD === "1"
});
if (!r.ok) {
// 已写 vault, 但端点没刷成 — 给前端透明的中文回执
return res.status(502).json({
error: true,
code: r.code,
message: r.message,
vault_saved: true,
endpoint_refreshed: false
});
}
res.json({
ok: true,
vault_saved: true,
endpoint_refreshed: true,
endpoint: r.endpoint,
message: r.message
});
});
return router;
}
module.exports = { buildRouter, KEY_RE, VALUE_MAX };