2024-08-13 20:30:42 +08:00
|
|
|
|
import { AbstractTaskPlugin, HttpClient, IContext, Step, TaskInput, TaskOutput } from "@certd/pipeline";
|
2024-07-18 21:10:13 +08:00
|
|
|
|
import dayjs from "dayjs";
|
|
|
|
|
|
import type { CertInfo } from "./acme.js";
|
|
|
|
|
|
import { CertReader } from "./cert-reader.js";
|
|
|
|
|
|
import JSZip from "jszip";
|
2024-09-06 00:13:21 +08:00
|
|
|
|
import { CertConverter } from "./convert.js";
|
|
|
|
|
|
import fs from "fs";
|
2024-07-18 21:10:13 +08:00
|
|
|
|
|
|
|
|
|
|
export { CertReader };
|
|
|
|
|
|
export type { CertInfo };
|
|
|
|
|
|
|
|
|
|
|
|
export abstract class CertApplyBasePlugin extends AbstractTaskPlugin {
|
|
|
|
|
|
@TaskInput({
|
|
|
|
|
|
title: "域名",
|
|
|
|
|
|
component: {
|
|
|
|
|
|
name: "a-select",
|
|
|
|
|
|
vModel: "value",
|
|
|
|
|
|
mode: "tags",
|
|
|
|
|
|
open: false,
|
2024-09-04 15:49:00 +08:00
|
|
|
|
tokenSeparators: [",", " ", ",", "、", "|"],
|
2024-07-18 21:10:13 +08:00
|
|
|
|
},
|
2024-10-06 02:21:42 +08:00
|
|
|
|
rules: [{ type: "domains" }],
|
2024-07-18 21:10:13 +08:00
|
|
|
|
required: true,
|
|
|
|
|
|
col: {
|
|
|
|
|
|
span: 24,
|
|
|
|
|
|
},
|
2024-10-07 03:21:16 +08:00
|
|
|
|
order: -999,
|
2024-07-18 21:10:13 +08:00
|
|
|
|
helper:
|
|
|
|
|
|
"1、支持通配符域名,例如: *.foo.com、foo.com、*.test.handsfree.work\n" +
|
|
|
|
|
|
"2、支持多个域名、多个子域名、多个通配符域名打到一个证书上(域名必须是在同一个DNS提供商解析)\n" +
|
|
|
|
|
|
"3、多级子域名要分成多个域名输入(*.foo.com的证书不能用于xxx.yyy.foo.com、foo.com)\n" +
|
2024-10-06 02:21:42 +08:00
|
|
|
|
"4、输入一个,空格之后,再输入下一个",
|
2024-07-18 21:10:13 +08:00
|
|
|
|
})
|
|
|
|
|
|
domains!: string[];
|
|
|
|
|
|
|
|
|
|
|
|
@TaskInput({
|
|
|
|
|
|
title: "邮箱",
|
|
|
|
|
|
component: {
|
|
|
|
|
|
name: "a-input",
|
|
|
|
|
|
vModel: "value",
|
|
|
|
|
|
},
|
2024-10-07 03:21:16 +08:00
|
|
|
|
rules: [{ type: "email" }],
|
2024-07-18 21:10:13 +08:00
|
|
|
|
required: true,
|
2024-07-21 02:26:03 +08:00
|
|
|
|
order: -1,
|
2024-07-18 21:10:13 +08:00
|
|
|
|
helper: "请输入邮箱",
|
|
|
|
|
|
})
|
|
|
|
|
|
email!: string;
|
|
|
|
|
|
|
2024-09-06 00:13:21 +08:00
|
|
|
|
@TaskInput({
|
2024-09-09 16:01:42 +08:00
|
|
|
|
title: "PFX证书密码",
|
2024-09-06 00:13:21 +08:00
|
|
|
|
component: {
|
|
|
|
|
|
name: "a-input-password",
|
|
|
|
|
|
vModel: "value",
|
|
|
|
|
|
},
|
|
|
|
|
|
required: false,
|
|
|
|
|
|
order: 100,
|
|
|
|
|
|
helper: "PFX格式证书是否需要加密",
|
|
|
|
|
|
})
|
|
|
|
|
|
pfxPassword!: string;
|
|
|
|
|
|
|
2024-07-18 21:10:13 +08:00
|
|
|
|
@TaskInput({
|
|
|
|
|
|
title: "更新天数",
|
2024-07-21 02:26:03 +08:00
|
|
|
|
value: 20,
|
2024-07-18 21:10:13 +08:00
|
|
|
|
component: {
|
|
|
|
|
|
name: "a-input-number",
|
|
|
|
|
|
vModel: "value",
|
|
|
|
|
|
},
|
|
|
|
|
|
required: true,
|
|
|
|
|
|
order: 100,
|
|
|
|
|
|
helper: "到期前多少天后更新证书,注意:流水线默认不会自动运行,请设置定时器,每天定时运行本流水线",
|
|
|
|
|
|
})
|
|
|
|
|
|
renewDays!: number;
|
|
|
|
|
|
|
|
|
|
|
|
@TaskInput({
|
|
|
|
|
|
title: "强制更新",
|
|
|
|
|
|
component: {
|
|
|
|
|
|
name: "a-switch",
|
|
|
|
|
|
vModel: "checked",
|
|
|
|
|
|
},
|
|
|
|
|
|
order: 100,
|
|
|
|
|
|
helper: "是否强制重新申请证书",
|
|
|
|
|
|
})
|
|
|
|
|
|
forceUpdate!: string;
|
|
|
|
|
|
|
|
|
|
|
|
@TaskInput({
|
|
|
|
|
|
title: "成功后邮件通知",
|
|
|
|
|
|
value: true,
|
|
|
|
|
|
component: {
|
|
|
|
|
|
name: "a-switch",
|
|
|
|
|
|
vModel: "checked",
|
|
|
|
|
|
},
|
|
|
|
|
|
order: 100,
|
|
|
|
|
|
helper: "申请成功后是否发送邮件通知",
|
|
|
|
|
|
})
|
|
|
|
|
|
successNotify = true;
|
|
|
|
|
|
|
|
|
|
|
|
// @TaskInput({
|
|
|
|
|
|
// title: "CsrInfo",
|
|
|
|
|
|
// helper: "暂时没有用",
|
|
|
|
|
|
// })
|
|
|
|
|
|
csrInfo!: string;
|
|
|
|
|
|
|
|
|
|
|
|
userContext!: IContext;
|
|
|
|
|
|
http!: HttpClient;
|
|
|
|
|
|
lastStatus!: Step;
|
|
|
|
|
|
|
|
|
|
|
|
@TaskOutput({
|
|
|
|
|
|
title: "域名证书",
|
|
|
|
|
|
})
|
|
|
|
|
|
cert?: CertInfo;
|
|
|
|
|
|
|
|
|
|
|
|
async onInstance() {
|
|
|
|
|
|
this.userContext = this.ctx.userContext;
|
|
|
|
|
|
this.http = this.ctx.http;
|
|
|
|
|
|
this.lastStatus = this.ctx.lastStatus as Step;
|
|
|
|
|
|
await this.onInit();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
abstract onInit(): Promise<void>;
|
|
|
|
|
|
|
|
|
|
|
|
abstract doCertApply(): Promise<any>;
|
|
|
|
|
|
|
|
|
|
|
|
async execute(): Promise<void> {
|
|
|
|
|
|
const oldCert = await this.condition();
|
|
|
|
|
|
if (oldCert != null) {
|
|
|
|
|
|
return await this.output(oldCert, false);
|
|
|
|
|
|
}
|
|
|
|
|
|
const cert = await this.doCertApply();
|
|
|
|
|
|
if (cert != null) {
|
|
|
|
|
|
await this.output(cert, true);
|
|
|
|
|
|
//清空后续任务的状态,让后续任务能够重新执行
|
|
|
|
|
|
this.clearLastStatus();
|
|
|
|
|
|
|
|
|
|
|
|
if (this.successNotify) {
|
|
|
|
|
|
await this.sendSuccessEmail();
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
throw new Error("申请证书失败");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async output(certReader: CertReader, isNew: boolean) {
|
|
|
|
|
|
const cert: CertInfo = certReader.toCertInfo();
|
|
|
|
|
|
this.cert = cert;
|
|
|
|
|
|
|
2024-08-25 11:56:15 +08:00
|
|
|
|
this._result.pipelineVars.certExpiresTime = dayjs(certReader.detail.notAfter).valueOf();
|
2024-10-15 12:59:40 +08:00
|
|
|
|
if (!this._result.pipelinePrivateVars) {
|
|
|
|
|
|
this._result.pipelinePrivateVars = {};
|
|
|
|
|
|
}
|
|
|
|
|
|
this._result.pipelinePrivateVars.cert = cert;
|
2024-08-04 02:35:45 +08:00
|
|
|
|
|
2024-09-06 00:13:21 +08:00
|
|
|
|
if (cert.pfx == null || cert.der == null) {
|
2024-09-06 22:32:29 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const converter = new CertConverter({ logger: this.logger });
|
|
|
|
|
|
const res = await converter.convert({
|
|
|
|
|
|
cert,
|
|
|
|
|
|
pfxPassword: this.pfxPassword,
|
|
|
|
|
|
});
|
|
|
|
|
|
const pfxBuffer = fs.readFileSync(res.pfxPath);
|
|
|
|
|
|
cert.pfx = pfxBuffer.toString("base64");
|
|
|
|
|
|
|
|
|
|
|
|
const derBuffer = fs.readFileSync(res.derPath);
|
|
|
|
|
|
cert.der = derBuffer.toString("base64");
|
|
|
|
|
|
|
|
|
|
|
|
this.logger.info("转换证书格式成功");
|
|
|
|
|
|
isNew = true;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
this.logger.error("转换证书格式失败", e);
|
|
|
|
|
|
}
|
2024-09-06 00:13:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2024-07-18 21:10:13 +08:00
|
|
|
|
if (isNew) {
|
2024-09-06 00:13:21 +08:00
|
|
|
|
const zipFileName = certReader.buildCertFileName("zip", certReader.detail.notBefore);
|
|
|
|
|
|
await this.zipCert(cert, zipFileName);
|
2024-07-18 21:10:13 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
this.extendsFiles();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-09-06 00:13:21 +08:00
|
|
|
|
async zipCert(cert: CertInfo, filename: string) {
|
2024-07-18 21:10:13 +08:00
|
|
|
|
const zip = new JSZip();
|
|
|
|
|
|
zip.file("cert.crt", cert.crt);
|
|
|
|
|
|
zip.file("cert.key", cert.key);
|
2024-09-22 23:19:10 +08:00
|
|
|
|
zip.file("intermediate.crt", cert.ic);
|
2024-09-06 22:32:29 +08:00
|
|
|
|
if (cert.pfx) {
|
|
|
|
|
|
zip.file("cert.pfx", Buffer.from(cert.pfx, "base64"));
|
|
|
|
|
|
}
|
|
|
|
|
|
if (cert.der) {
|
|
|
|
|
|
zip.file("cert.der", Buffer.from(cert.der, "base64"));
|
|
|
|
|
|
}
|
2024-07-18 21:10:13 +08:00
|
|
|
|
const content = await zip.generateAsync({ type: "nodebuffer" });
|
|
|
|
|
|
this.saveFile(filename, content);
|
|
|
|
|
|
this.logger.info(`已保存文件:${filename}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 是否更新证书
|
|
|
|
|
|
*/
|
|
|
|
|
|
async condition() {
|
|
|
|
|
|
if (this.forceUpdate) {
|
2024-09-09 16:01:42 +08:00
|
|
|
|
this.logger.info("强制更新证书选项已勾选,准备申请新证书");
|
2024-10-12 16:49:49 +08:00
|
|
|
|
this.logger.warn("申请完之后,切记取消强制更新,避免申请过多证书。");
|
2024-07-18 21:10:13 +08:00
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-09-09 16:01:42 +08:00
|
|
|
|
const inputChanged = this.ctx.inputChanged;
|
|
|
|
|
|
if (inputChanged) {
|
|
|
|
|
|
this.logger.info("输入参数变更,准备申请新证书");
|
|
|
|
|
|
return null;
|
2024-07-18 21:10:13 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let oldCert: CertReader | undefined = undefined;
|
|
|
|
|
|
try {
|
|
|
|
|
|
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) {
|
|
|
|
|
|
return undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
return new CertReader(cert);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 检查是否过期,默认提前20天
|
|
|
|
|
|
* @param expires
|
|
|
|
|
|
* @param maxDays
|
|
|
|
|
|
* @returns {boolean}
|
|
|
|
|
|
*/
|
|
|
|
|
|
isWillExpire(expires: number, maxDays = 20) {
|
|
|
|
|
|
if (expires == null) {
|
|
|
|
|
|
throw new Error("过期时间不能为空");
|
|
|
|
|
|
}
|
|
|
|
|
|
// 检查有效期
|
|
|
|
|
|
const leftDays = dayjs(expires).diff(dayjs(), "day");
|
|
|
|
|
|
return {
|
|
|
|
|
|
isWillExpire: leftDays < maxDays,
|
|
|
|
|
|
leftDays,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private async sendSuccessEmail() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
this.logger.info("发送成功邮件通知:" + this.email);
|
|
|
|
|
|
const subject = `【CertD】证书申请成功【${this.domains[0]}】`;
|
|
|
|
|
|
await this.ctx.emailService.send({
|
|
|
|
|
|
userId: this.ctx.pipeline.userId,
|
|
|
|
|
|
receivers: [this.email],
|
|
|
|
|
|
subject: subject,
|
|
|
|
|
|
content: `证书申请成功,域名:${this.domains.join(",")}`,
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
this.logger.error("send email error", e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|