2024-06-14 01:22:07 +08:00
|
|
|
|
import { AbstractTaskPlugin, Decorator, HttpClient, IAccessService, IContext, IsTaskPlugin, RunStrategy, Step, TaskInput, TaskOutput } from "@certd/pipeline";
|
2022-11-08 22:10:42 +08:00
|
|
|
|
import dayjs from "dayjs";
|
2024-07-04 01:14:09 +08:00
|
|
|
|
import { AcmeService, CertInfo, SSLProvider } from "./acme";
|
2022-11-08 22:10:42 +08:00
|
|
|
|
import _ from "lodash";
|
2024-06-14 01:22:07 +08:00
|
|
|
|
import { Logger } from "log4js";
|
|
|
|
|
|
import { DnsProviderContext, DnsProviderDefine, dnsProviderRegistry } from "../../dns-provider";
|
|
|
|
|
|
import { CertReader } from "./cert-reader";
|
2023-06-25 23:45:13 +08:00
|
|
|
|
import JSZip from "jszip";
|
2024-05-30 10:54:18 +08:00
|
|
|
|
|
2023-05-23 18:01:20 +08:00
|
|
|
|
export { CertReader };
|
|
|
|
|
|
export type { CertInfo };
|
2023-01-11 20:39:48 +08:00
|
|
|
|
|
2022-12-29 23:52:51 +08:00
|
|
|
|
@IsTaskPlugin({
|
|
|
|
|
|
name: "CertApply",
|
|
|
|
|
|
title: "证书申请",
|
|
|
|
|
|
desc: "免费通配符域名证书申请,支持多个域名打到同一个证书上",
|
|
|
|
|
|
default: {
|
2022-11-08 22:10:42 +08:00
|
|
|
|
input: {
|
2022-12-29 23:52:51 +08:00
|
|
|
|
renewDays: 20,
|
|
|
|
|
|
forceUpdate: false,
|
2022-11-08 22:10:42 +08:00
|
|
|
|
},
|
2022-12-29 23:52:51 +08:00
|
|
|
|
strategy: {
|
|
|
|
|
|
runStrategy: RunStrategy.AlwaysRun,
|
2022-11-08 22:10:42 +08:00
|
|
|
|
},
|
2022-12-29 23:52:51 +08:00
|
|
|
|
},
|
2022-11-08 22:10:42 +08:00
|
|
|
|
})
|
2023-05-24 15:41:35 +08:00
|
|
|
|
export class CertApplyPlugin extends AbstractTaskPlugin {
|
2022-12-29 23:52:51 +08:00
|
|
|
|
@TaskInput({
|
|
|
|
|
|
title: "域名",
|
|
|
|
|
|
component: {
|
|
|
|
|
|
name: "a-select",
|
|
|
|
|
|
vModel: "value",
|
|
|
|
|
|
mode: "tags",
|
|
|
|
|
|
open: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
required: true,
|
|
|
|
|
|
col: {
|
|
|
|
|
|
span: 24,
|
|
|
|
|
|
},
|
|
|
|
|
|
helper:
|
2024-03-08 17:42:47 +08:00
|
|
|
|
"支持通配符域名,例如: *.foo.com、foo.com、*.test.handsfree.work\n" +
|
2022-12-29 23:52:51 +08:00
|
|
|
|
"支持多个域名、多个子域名、多个通配符域名打到一个证书上(域名必须是在同一个DNS提供商解析)\n" +
|
2024-03-08 17:44:03 +08:00
|
|
|
|
"多级子域名要分成多个域名输入(*.foo.com的证书不能用于xxx.yyy.foo.com、foo.com)\n" +
|
2022-12-29 23:52:51 +08:00
|
|
|
|
"输入一个回车之后,再输入下一个",
|
|
|
|
|
|
})
|
|
|
|
|
|
domains!: string;
|
|
|
|
|
|
|
|
|
|
|
|
@TaskInput({
|
|
|
|
|
|
title: "邮箱",
|
|
|
|
|
|
component: {
|
|
|
|
|
|
name: "a-input",
|
|
|
|
|
|
vModel: "value",
|
|
|
|
|
|
},
|
|
|
|
|
|
required: true,
|
|
|
|
|
|
helper: "请输入邮箱",
|
|
|
|
|
|
})
|
|
|
|
|
|
email!: string;
|
|
|
|
|
|
|
2024-07-04 01:14:09 +08:00
|
|
|
|
@TaskInput({
|
|
|
|
|
|
title: "证书提供商",
|
2024-07-04 02:22:52 +08:00
|
|
|
|
default: "letsencrypt",
|
2024-07-04 01:14:09 +08:00
|
|
|
|
component: {
|
|
|
|
|
|
name: "a-select",
|
|
|
|
|
|
vModel: "value",
|
|
|
|
|
|
options: [
|
|
|
|
|
|
{ value: "letsencrypt", label: "Let's Encrypt" },
|
|
|
|
|
|
// { value: "buypass", label: "Buypass" },
|
|
|
|
|
|
{ value: "zerossl", label: "ZeroSSL" },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
required: true,
|
|
|
|
|
|
})
|
|
|
|
|
|
sslProvider!: SSLProvider;
|
|
|
|
|
|
|
|
|
|
|
|
@TaskInput({
|
|
|
|
|
|
title: "EAB授权",
|
|
|
|
|
|
component: {
|
|
|
|
|
|
name: "pi-access-selector",
|
|
|
|
|
|
type: "eab",
|
|
|
|
|
|
},
|
|
|
|
|
|
helper: "如果使用ZeroSSL证书,需要提供EAB授权, 请前往 https://app.zerossl.com/developer 生成 'EAB Credentials for ACME Clients' ",
|
|
|
|
|
|
})
|
|
|
|
|
|
eabAccessId!: number;
|
|
|
|
|
|
|
2022-12-29 23:52:51 +08:00
|
|
|
|
@TaskInput({
|
|
|
|
|
|
title: "DNS提供商",
|
|
|
|
|
|
component: {
|
|
|
|
|
|
name: "pi-dns-provider-selector",
|
|
|
|
|
|
},
|
|
|
|
|
|
required: true,
|
|
|
|
|
|
helper: "请选择dns解析提供商",
|
|
|
|
|
|
})
|
|
|
|
|
|
dnsProviderType!: string;
|
|
|
|
|
|
|
|
|
|
|
|
@TaskInput({
|
|
|
|
|
|
title: "DNS解析授权",
|
|
|
|
|
|
component: {
|
|
|
|
|
|
name: "pi-access-selector",
|
|
|
|
|
|
},
|
|
|
|
|
|
required: true,
|
|
|
|
|
|
helper: "请选择dns解析提供商授权",
|
2023-06-25 16:25:23 +08:00
|
|
|
|
reference: [
|
|
|
|
|
|
{
|
|
|
|
|
|
src: "form.dnsProviderType",
|
|
|
|
|
|
dest: "component.type",
|
|
|
|
|
|
type: "computed",
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
2022-12-29 23:52:51 +08:00
|
|
|
|
})
|
|
|
|
|
|
dnsProviderAccess!: string;
|
|
|
|
|
|
|
2024-07-08 15:35:58 +08:00
|
|
|
|
@TaskInput({
|
|
|
|
|
|
title: "跳过本地校验DNS解析",
|
|
|
|
|
|
default: false,
|
|
|
|
|
|
component: {
|
|
|
|
|
|
name: "a-switch",
|
|
|
|
|
|
vModel: "checked",
|
|
|
|
|
|
},
|
|
|
|
|
|
helper: "如果重试多次出现Authorization not found TXT record,导致无法申请成功,请尝试开启此选项",
|
|
|
|
|
|
})
|
|
|
|
|
|
skipLocalVerify = false;
|
|
|
|
|
|
|
2022-12-29 23:52:51 +08:00
|
|
|
|
@TaskInput({
|
|
|
|
|
|
title: "更新天数",
|
|
|
|
|
|
component: {
|
|
|
|
|
|
name: "a-input-number",
|
|
|
|
|
|
vModel: "value",
|
|
|
|
|
|
},
|
|
|
|
|
|
required: true,
|
2024-06-25 10:52:58 +08:00
|
|
|
|
helper: "到期前多少天后更新证书,注意:流水线默认不会自动运行,请设置定时器,每天定时运行本流水线",
|
2022-12-29 23:52:51 +08:00
|
|
|
|
})
|
|
|
|
|
|
renewDays!: number;
|
|
|
|
|
|
|
|
|
|
|
|
@TaskInput({
|
|
|
|
|
|
title: "强制更新",
|
|
|
|
|
|
component: {
|
|
|
|
|
|
name: "a-switch",
|
|
|
|
|
|
vModel: "checked",
|
|
|
|
|
|
},
|
|
|
|
|
|
helper: "是否强制重新申请证书",
|
|
|
|
|
|
})
|
|
|
|
|
|
forceUpdate!: string;
|
|
|
|
|
|
|
|
|
|
|
|
@TaskInput({
|
|
|
|
|
|
title: "CsrInfo",
|
2024-06-25 10:52:58 +08:00
|
|
|
|
helper: "暂时没有用",
|
2022-12-29 23:52:51 +08:00
|
|
|
|
})
|
2024-06-15 02:17:34 +08:00
|
|
|
|
csrInfo!: string;
|
2022-12-29 23:52:51 +08:00
|
|
|
|
|
2024-06-25 10:52:58 +08:00
|
|
|
|
@TaskInput({
|
|
|
|
|
|
title: "配置说明",
|
|
|
|
|
|
helper: "运行策略请选择总是运行,其他证书部署任务请选择成功后跳过;当证书快到期前将会自动重新申请证书,然后会清空后续任务的成功状态,部署任务将会重新运行",
|
|
|
|
|
|
})
|
|
|
|
|
|
intro!: string;
|
|
|
|
|
|
|
2023-06-25 23:25:56 +08:00
|
|
|
|
acme!: AcmeService;
|
2022-12-29 23:52:51 +08:00
|
|
|
|
logger!: Logger;
|
|
|
|
|
|
userContext!: IContext;
|
|
|
|
|
|
accessService!: IAccessService;
|
|
|
|
|
|
http!: HttpClient;
|
2023-05-24 15:41:35 +08:00
|
|
|
|
lastStatus!: Step;
|
2022-12-29 23:52:51 +08:00
|
|
|
|
|
|
|
|
|
|
@TaskOutput({
|
|
|
|
|
|
title: "域名证书",
|
|
|
|
|
|
})
|
|
|
|
|
|
cert?: CertInfo;
|
|
|
|
|
|
|
2023-05-09 10:16:49 +08:00
|
|
|
|
async onInstance() {
|
2023-06-25 23:25:56 +08:00
|
|
|
|
this.accessService = this.ctx.accessService;
|
|
|
|
|
|
this.logger = this.ctx.logger;
|
|
|
|
|
|
this.userContext = this.ctx.userContext;
|
|
|
|
|
|
this.http = this.ctx.http;
|
|
|
|
|
|
this.lastStatus = this.ctx.lastStatus as Step;
|
|
|
|
|
|
|
2024-07-04 01:14:09 +08:00
|
|
|
|
let eab: any = null;
|
|
|
|
|
|
if (this.eabAccessId) {
|
|
|
|
|
|
eab = await this.ctx.accessService.getById(this.eabAccessId);
|
|
|
|
|
|
}
|
2024-07-08 15:35:58 +08:00
|
|
|
|
this.acme = new AcmeService({
|
|
|
|
|
|
userContext: this.userContext,
|
|
|
|
|
|
logger: this.logger,
|
|
|
|
|
|
sslProvider: this.sslProvider,
|
|
|
|
|
|
eab,
|
|
|
|
|
|
skipLocalVerify: this.skipLocalVerify,
|
|
|
|
|
|
});
|
2022-11-08 22:10:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2022-12-29 23:52:51 +08:00
|
|
|
|
async execute(): Promise<void> {
|
|
|
|
|
|
const oldCert = await this.condition();
|
2022-11-08 22:10:42 +08:00
|
|
|
|
if (oldCert != null) {
|
2024-05-30 10:12:48 +08:00
|
|
|
|
return await this.output(oldCert);
|
2022-11-08 22:10:42 +08:00
|
|
|
|
}
|
2022-12-29 23:52:51 +08:00
|
|
|
|
const cert = await this.doCertApply();
|
2023-05-23 18:01:20 +08:00
|
|
|
|
if (cert != null) {
|
2024-05-30 10:12:48 +08:00
|
|
|
|
await this.output(cert);
|
2023-05-24 15:41:35 +08:00
|
|
|
|
//清空后续任务的状态,让后续任务能够重新执行
|
|
|
|
|
|
this.clearLastStatus();
|
2023-05-23 18:01:20 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
throw new Error("申请证书失败");
|
|
|
|
|
|
}
|
2022-12-29 23:52:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2024-05-30 10:12:48 +08:00
|
|
|
|
async output(certReader: CertReader) {
|
|
|
|
|
|
const cert: CertInfo = certReader.toCertInfo();
|
2022-12-29 23:52:51 +08:00
|
|
|
|
this.cert = cert;
|
2024-05-30 10:12:48 +08:00
|
|
|
|
// this.logger.info(JSON.stringify(certReader.detail));
|
2024-05-30 10:54:18 +08:00
|
|
|
|
const applyTime = dayjs(certReader.detail.validity.notBefore).format("YYYYMMDD_HHmmss");
|
2024-05-30 10:12:48 +08:00
|
|
|
|
await this.zipCert(cert, applyTime);
|
2023-06-25 23:45:13 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2024-05-30 10:12:48 +08:00
|
|
|
|
async zipCert(cert: CertInfo, applyTime: string) {
|
2023-06-25 23:45:13 +08:00
|
|
|
|
const zip = new JSZip();
|
|
|
|
|
|
zip.file("cert.crt", cert.crt);
|
|
|
|
|
|
zip.file("cert.key", cert.key);
|
2024-05-30 10:12:48 +08:00
|
|
|
|
const domain_name = this.domains[0].replace(".", "_").replace("*", "_");
|
|
|
|
|
|
const filename = `cert_${domain_name}_${applyTime}.zip`;
|
2023-06-25 23:45:13 +08:00
|
|
|
|
const content = await zip.generateAsync({ type: "nodebuffer" });
|
2024-05-30 10:12:48 +08:00
|
|
|
|
this.saveFile(filename, content);
|
2024-05-30 10:54:18 +08:00
|
|
|
|
this.logger.info(`已保存文件:${filename}`);
|
2022-11-08 22:10:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 是否更新证书
|
|
|
|
|
|
*/
|
2022-12-29 23:52:51 +08:00
|
|
|
|
async condition() {
|
|
|
|
|
|
if (this.forceUpdate) {
|
2022-11-08 22:10:42 +08:00
|
|
|
|
return null;
|
|
|
|
|
|
}
|
2023-05-09 13:52:25 +08:00
|
|
|
|
|
|
|
|
|
|
let inputChanged = false;
|
2023-05-24 15:41:35 +08:00
|
|
|
|
const oldInput = JSON.stringify(this.lastStatus?.input?.domains);
|
2023-05-23 18:01:20 +08:00
|
|
|
|
const thisInput = JSON.stringify(this.domains);
|
2023-05-09 13:52:25 +08:00
|
|
|
|
if (oldInput !== thisInput) {
|
|
|
|
|
|
inputChanged = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2023-05-23 18:01:20 +08:00
|
|
|
|
let oldCert: CertReader | undefined = undefined;
|
2022-11-08 22:10:42 +08:00
|
|
|
|
try {
|
2023-05-24 15:41:35 +08:00
|
|
|
|
oldCert = await this.readLastCert();
|
2022-11-08 22:10:42 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
this.logger.warn("读取cert失败:", e);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (oldCert == null) {
|
|
|
|
|
|
this.logger.info("还未申请过,准备申请新证书");
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2023-05-09 13:52:25 +08:00
|
|
|
|
if (inputChanged) {
|
|
|
|
|
|
this.logger.info("输入参数变更,申请新证书");
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2022-12-29 23:52:51 +08:00
|
|
|
|
const ret = this.isWillExpire(oldCert.expires, this.renewDays);
|
2022-11-08 22:10:42 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2022-12-29 23:52:51 +08:00
|
|
|
|
async doCertApply() {
|
|
|
|
|
|
const email = this["email"];
|
|
|
|
|
|
const domains = this["domains"];
|
|
|
|
|
|
const dnsProviderType = this["dnsProviderType"];
|
|
|
|
|
|
const dnsProviderAccessId = this["dnsProviderAccess"];
|
2022-11-08 22:10:42 +08:00
|
|
|
|
const csrInfo = _.merge(
|
|
|
|
|
|
{
|
|
|
|
|
|
country: "CN",
|
|
|
|
|
|
state: "GuangDong",
|
|
|
|
|
|
locality: "ShengZhen",
|
|
|
|
|
|
organization: "CertD Org.",
|
|
|
|
|
|
organizationUnit: "IT Department",
|
|
|
|
|
|
emailAddress: email,
|
|
|
|
|
|
},
|
2024-06-15 02:17:34 +08:00
|
|
|
|
this.csrInfo ? JSON.parse(this.csrInfo) : {}
|
2022-11-08 22:10:42 +08:00
|
|
|
|
);
|
|
|
|
|
|
this.logger.info("开始申请证书,", email, domains);
|
|
|
|
|
|
|
2023-01-11 20:39:48 +08:00
|
|
|
|
const dnsProviderPlugin = dnsProviderRegistry.get(dnsProviderType);
|
|
|
|
|
|
const DnsProviderClass = dnsProviderPlugin.target;
|
|
|
|
|
|
const dnsProviderDefine = dnsProviderPlugin.define as DnsProviderDefine;
|
2022-11-08 22:10:42 +08:00
|
|
|
|
const access = await this.accessService.getById(dnsProviderAccessId);
|
2023-01-11 20:39:48 +08:00
|
|
|
|
|
2022-11-08 22:10:42 +08:00
|
|
|
|
// @ts-ignore
|
2023-01-11 20:39:48 +08:00
|
|
|
|
const dnsProvider: IDnsProvider = new DnsProviderClass();
|
2024-06-14 01:22:07 +08:00
|
|
|
|
const context: DnsProviderContext = { access, logger: this.logger, http: this.http };
|
2023-01-11 20:39:48 +08:00
|
|
|
|
Decorator.inject(dnsProviderDefine.autowire, dnsProvider, context);
|
2024-06-14 01:22:07 +08:00
|
|
|
|
dnsProvider.setCtx(context);
|
2023-05-09 10:16:49 +08:00
|
|
|
|
await dnsProvider.onInstance();
|
2022-11-08 22:10:42 +08:00
|
|
|
|
|
|
|
|
|
|
const cert = await this.acme.order({
|
|
|
|
|
|
email,
|
|
|
|
|
|
domains,
|
|
|
|
|
|
dnsProvider,
|
|
|
|
|
|
csrInfo,
|
|
|
|
|
|
isTest: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2023-05-24 15:41:35 +08:00
|
|
|
|
const certInfo = this.formatCerts(cert);
|
|
|
|
|
|
return new CertReader(certInfo);
|
2022-11-08 22:10:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
formatCert(pem: string) {
|
|
|
|
|
|
pem = pem.replace(/\r/g, "");
|
|
|
|
|
|
pem = pem.replace(/\n\n/g, "\n");
|
|
|
|
|
|
pem = pem.replace(/\n$/g, "");
|
|
|
|
|
|
return pem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2023-05-24 15:41:35 +08:00
|
|
|
|
formatCerts(cert: { crt: string; key: string; csr: string }) {
|
2023-05-23 18:01:20 +08:00
|
|
|
|
const newCert: CertInfo = {
|
2022-11-08 22:10:42 +08:00
|
|
|
|
crt: this.formatCert(cert.crt),
|
|
|
|
|
|
key: this.formatCert(cert.key),
|
|
|
|
|
|
csr: this.formatCert(cert.csr),
|
|
|
|
|
|
};
|
2023-05-24 15:41:35 +08:00
|
|
|
|
return newCert;
|
2022-11-08 22:10:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2023-05-24 15:41:35 +08:00
|
|
|
|
async readLastCert(): Promise<CertReader | undefined> {
|
|
|
|
|
|
const cert = this.lastStatus?.status?.output?.cert;
|
2022-11-08 22:10:42 +08:00
|
|
|
|
if (cert == null) {
|
|
|
|
|
|
return undefined;
|
|
|
|
|
|
}
|
2023-05-23 18:01:20 +08:00
|
|
|
|
return new CertReader(cert);
|
2022-11-08 22:10:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 检查是否过期,默认提前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,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2023-05-09 09:56:31 +08:00
|
|
|
|
|
|
|
|
|
|
new CertApplyPlugin();
|