feat: 支持证书托管和节点自动安装

This commit is contained in:
2026-09-29 13:06:56 +08:00
parent ab7d1c937a
commit 586c2399d1
20 changed files with 1600 additions and 20 deletions
+85
View File
@@ -0,0 +1,85 @@
import { X509Certificate, createPrivateKey } from "node:crypto";
import { ensureDb } from "@/lib/db";
import { encryptText } from "@/lib/crypto";
export class ProvisionError extends Error {}
export type ManagedCertificate = {
id: number;
name: string;
certificate_pem: string;
private_key_encrypted: string;
expires_at: string;
created_at: string;
};
export function validateCertificate(pem: string, key: string, sni?: string) {
if (pem.length > 128 * 1024 || key.length > 32 * 1024)
throw new ProvisionError("证书或私钥文件过大。");
let certificate: X509Certificate;
try {
certificate = new X509Certificate(pem);
const privateKey = createPrivateKey(key);
if (!certificate.checkPrivateKey(privateKey)) throw new Error();
const chain = pem.match(
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g,
);
if (!chain?.length) throw new Error();
chain.forEach((part) => new X509Certificate(part));
} catch {
throw new ProvisionError(
"无法解析 PEM 证书/私钥,或私钥与证书不匹配。请上传未加密的私钥和完整证书链。",
);
}
if (
Date.parse(certificate.validFrom) > Date.now() ||
Date.parse(certificate.validTo) <= Date.now()
)
throw new ProvisionError("证书尚未生效或已过期。");
if (sni && !certificate.checkHost(sni, { subject: "never" }))
throw new ProvisionError("SNI 域名与证书的 SAN 不匹配。");
return certificate;
}
export async function saveCertificate(name: string, pem: string, key: string) {
if (!name.trim() || name.length > 100)
throw new ProvisionError("请填写 1–100 字的证书名称。");
const certificate = validateCertificate(pem, key);
const db = await ensureDb();
return db
.prepare(
`INSERT INTO managed_certificates
(name,certificate_pem,private_key_encrypted,expires_at,created_at) VALUES (?,?,?,?,?)`,
)
.run(
name.trim(),
pem,
encryptText(key),
new Date(certificate.validTo).toISOString(),
new Date().toISOString(),
).lastInsertRowid;
}
export async function listCertificates() {
const db = await ensureDb();
return db
.prepare(
"SELECT id,name,expires_at,created_at FROM managed_certificates ORDER BY id DESC",
)
.all() as Pick<
ManagedCertificate,
"id" | "name" | "expires_at" | "created_at"
>[];
}
export async function deleteCertificate(id: number) {
const db = await ensureDb();
if (
db
.prepare("SELECT 1 FROM node_installations WHERE certificate_id=?")
.get(id)
)
throw new ProvisionError(
"此证书仍关联安装记录,请先删除相关安装记录。已安装服务器的证书文件不会随之删除。",
);
db.prepare("DELETE FROM managed_certificates WHERE id=?").run(id);
}