Files
certd/packages/plugins/plugin-cert/src/plugin/cert-plugin/base.ts
2025-03-07 18:01:51 +08:00

344 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { AbstractTaskPlugin, IContext, NotificationBody, Step, TaskEmitter, TaskInput, TaskOutput } from "@certd/pipeline";
import dayjs from "dayjs";
import type { CertInfo } from "./acme.js";
import { CertReader } from "./cert-reader.js";
import JSZip from "jszip";
import { CertConverter } from "./convert.js";
import { pick } from "lodash-es";
export const EVENT_CERT_APPLY_SUCCESS = "CertApply.success";
export async function emitCertApplySuccess(emitter: TaskEmitter, cert: CertReader) {
await emitter.emit(EVENT_CERT_APPLY_SUCCESS, cert);
}
export abstract class CertApplyBasePlugin extends AbstractTaskPlugin {
@TaskInput({
title: "域名",
component: {
name: "a-select",
vModel: "value",
mode: "tags",
open: false,
placeholder: "foo.com / *.foo.com / *.bar.com",
tokenSeparators: [",", " ", "", "、", "|"],
},
rules: [{ type: "domains" }],
required: true,
col: {
span: 24,
},
order: -999,
helper:
"1、支持多个域名打到一个证书上例如 foo.com*.foo.com*.bar.com\n" +
"2、子域名被通配符包含的不要填写例如www.foo.com已经被*.foo.com包含不要填写www.foo.com\n" +
"3、泛域名只能通配*号那一级(*.foo.com的证书不能用于xxx.yyy.foo.com、不能用于foo.com\n" +
"4、输入一个空格之后再输入下一个",
})
domains!: string[];
@TaskInput({
title: "邮箱",
component: {
name: "a-input",
vModel: "value",
},
rules: [{ type: "email", message: "请输入正确的邮箱" }],
required: true,
order: -1,
helper: "请输入邮箱",
})
email!: string;
@TaskInput({
title: "证书密码",
component: {
name: "input-password",
vModel: "value",
},
required: false,
order: 100,
helper: "PFX、jks格式证书是否加密\njks必须设置密码不传则默认123456\npfx不传则为空密码",
})
pfxPassword!: string;
@TaskInput({
title: "PFX证书转换参数",
value: "-macalg SHA1 -keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES",
component: {
name: "a-auto-complete",
vModel: "value",
options: [
{ value: "", label: "兼容 Windows Server 最新" },
{ value: "-macalg SHA1 -keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES", label: "兼容 Windows Server 2016" },
{ value: "-nomac -keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES", label: "兼容 Windows Server 2008" },
],
},
required: false,
order: 100,
helper: "兼容Windows Server各个版本",
})
pfxArgs = "-macalg SHA1 -keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES";
@TaskInput({
title: "更新天数",
value: 35,
component: {
name: "a-input-number",
vModel: "value",
},
required: true,
order: 100,
helper: "到期前多少天后更新证书,注意:流水线默认不会自动运行,请设置定时器,每天定时运行本流水线",
})
renewDays!: number;
@TaskInput({
title: "证书申请成功通知",
value: true,
component: {
name: "a-switch",
vModel: "checked",
},
order: 100,
helper: "证书申请成功后是否发送通知,优先使用默认通知渠道",
})
successNotify = true;
// @TaskInput({
// title: "CsrInfo",
// helper: "暂时没有用",
// })
csrInfo!: string;
userContext!: IContext;
lastStatus!: Step;
@TaskOutput({
title: "域名证书",
})
cert?: CertInfo;
async onInstance() {
this.userContext = this.ctx.userContext;
this.lastStatus = this.ctx.lastStatus as Step;
await this.onInit();
}
abstract onInit(): Promise<void>;
abstract doCertApply(): Promise<CertReader>;
async execute(): Promise<string | void> {
const oldCert = await this.condition();
if (oldCert != null) {
await this.output(oldCert, false);
return "skip";
}
const cert = await this.doCertApply();
if (cert != null) {
await this.output(cert, true);
await emitCertApplySuccess(this.ctx.emitter, cert);
//清空后续任务的状态,让后续任务能够重新执行
this.clearLastStatus();
if (this.successNotify) {
await this.sendSuccessNotify();
}
} else {
throw new Error("申请证书失败");
}
}
async output(certReader: CertReader, isNew: boolean) {
const cert: CertInfo = certReader.toCertInfo();
this.cert = cert;
this._result.pipelineVars.certExpiresTime = dayjs(certReader.detail.notAfter).valueOf();
if (!this._result.pipelinePrivateVars) {
this._result.pipelinePrivateVars = {};
}
this._result.pipelinePrivateVars.cert = cert;
if (isNew) {
try {
const converter = new CertConverter({ logger: this.logger });
const res = await converter.convert({
cert,
pfxPassword: this.pfxPassword,
pfxArgs: this.pfxArgs,
});
if (cert.pfx == null && res.pfx) {
cert.pfx = res.pfx;
}
if (cert.der == null && res.der) {
cert.der = res.der;
}
if (cert.jks == null && res.jks) {
cert.jks = res.jks;
}
this.logger.info("转换证书格式成功");
} catch (e) {
this.logger.error("转换证书格式失败", e);
}
}
if (isNew) {
const zipFileName = certReader.buildCertFileName("zip", certReader.detail.notBefore);
await this.zipCert(cert, zipFileName);
} else {
this.extendsFiles();
}
}
async zipCert(cert: CertInfo, filename: string) {
const zip = new JSZip();
zip.file("证书.pem", cert.crt);
zip.file("私钥.pem", cert.key);
zip.file("中间证书.pem", cert.ic);
zip.file("cert.crt", cert.crt);
zip.file("cert.key", cert.key);
zip.file("intermediate.crt", cert.ic);
zip.file("origin.crt", cert.oc);
zip.file("one.pem", cert.one);
if (cert.pfx) {
zip.file("cert.pfx", Buffer.from(cert.pfx, "base64"));
}
if (cert.der) {
zip.file("cert.der", Buffer.from(cert.der, "base64"));
}
if (cert.jks) {
zip.file("cert.jks", Buffer.from(cert.jks, "base64"));
}
zip.file(
"说明.txt",
`证书文件说明
cert.crt证书文件包含证书链pem格式
cert.key私钥文件pem格式
intermediate.crt中间证书文件pem格式
origin.crt原始证书文件不含证书链pem格式
one.pem 证书和私钥简单合并成一个文件pem格式crt正文+key正文
cert.pfxpfx格式证书文件iis服务器使用
cert.derder格式证书文件
cert.jksjks格式证书文件java服务器使用
`
);
const content = await zip.generateAsync({ type: "nodebuffer" });
this.saveFile(filename, content);
this.logger.info(`已保存文件:${filename}`);
}
/**
* 是否更新证书
*/
async condition() {
// if (this.forceUpdate) {
// this.logger.info("强制更新证书选项已勾选,准备申请新证书");
// this.logger.warn("申请完之后,切记取消强制更新,避免申请过多证书。");
// return null;
// }
const checkInputChanges = ["domains", "sslProvider", "privateKeyType", "dnsProviderType", "pfxPassword"];
const oldInput = JSON.stringify(pick(this.lastStatus?.input, checkInputChanges));
const thisInput = JSON.stringify(pick(this, checkInputChanges));
const inputChanged = oldInput !== thisInput;
this.logger.info(`旧参数:${oldInput}`);
this.logger.info(`新参数:${thisInput}`);
if (inputChanged) {
this.logger.info("输入参数变更,准备申请新证书");
return null;
} else {
this.logger.info("输入参数未变更,检查证书是否过期");
}
let oldCert: CertReader | undefined = undefined;
try {
this.logger.info("读取上次证书");
oldCert = await this.readLastCert();
} catch (e) {
this.logger.warn("读取cert失败", e);
}
if (oldCert == null) {
this.logger.info("还未申请过,准备申请新证书");
return null;
}
const ret = this.isWillExpire(oldCert.expires, this.renewDays);
if (!ret.isWillExpire) {
this.logger.info(`证书还未过期:过期时间${dayjs(oldCert.expires).format("YYYY-MM-DD HH:mm:ss")},剩余${ret.leftDays}`);
return oldCert;
}
this.logger.info("即将过期,开始更新证书");
return null;
}
formatCert(pem: string) {
pem = pem.replace(/\r/g, "");
pem = pem.replace(/\n\n/g, "\n");
pem = pem.replace(/\n$/g, "");
return pem;
}
formatCerts(cert: { crt: string; key: string; csr: string }) {
const newCert: CertInfo = {
crt: this.formatCert(cert.crt),
key: this.formatCert(cert.key),
csr: this.formatCert(cert.csr),
};
return newCert;
}
async readLastCert(): Promise<CertReader | undefined> {
const cert = this.lastStatus?.status?.output?.cert;
if (cert == null) {
this.logger.info("没有找到上次的证书");
return undefined;
}
return new CertReader(cert);
}
/**
* 检查是否过期默认提前35天
* @param expires
* @param maxDays
*/
isWillExpire(expires: number, maxDays = 20) {
if (expires == null) {
throw new Error("过期时间不能为空");
}
// 检查有效期
const leftDays = dayjs(expires).diff(dayjs(), "day");
return {
isWillExpire: leftDays <= maxDays,
leftDays,
};
}
async sendSuccessNotify() {
this.logger.info("发送证书申请成功通知");
const url = await this.ctx.urlService.getPipelineDetailUrl(this.pipeline.id, this.ctx.runtime.id);
const body: NotificationBody = {
title: `证书申请成功【${this.pipeline.title}`,
content: `域名:${this.domains.join(",")}`,
url: url,
};
try {
await this.ctx.notificationService.send({
useDefault: true,
useEmail: true,
emailAddress: this.email,
logger: this.logger,
body,
});
} catch (e) {
this.logger.error("证书申请成功通知发送失败", e);
}
}
}