86 lines
2.8 KiB
TypeScript
86 lines
2.8 KiB
TypeScript
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);
|
||
}
|