chore: 支持手动上传证书并部署

This commit is contained in:
xiaojunnuo
2025-03-18 00:52:50 +08:00
parent 29a6a992f0
commit de40be430b
74 changed files with 1040 additions and 597 deletions

View File

@@ -0,0 +1,177 @@
import { AbstractTaskPlugin, IContext, Step, 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";
export abstract class CertApplyBaseConvertPlugin 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: "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";
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>;
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}`);
}
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;
}
}

View File

@@ -1,10 +1,8 @@
import { AbstractTaskPlugin, IContext, NotificationBody, Step, TaskEmitter, TaskInput, TaskOutput } from "@certd/pipeline";
import { NotificationBody, Step, TaskEmitter, TaskInput } 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";
import { CertApplyBaseConvertPlugin } from "./base-convert.js";
export const EVENT_CERT_APPLY_SUCCESS = "CertApply.success";
@@ -12,30 +10,7 @@ export async function emitCertApplySuccess(emitter: TaskEmitter, cert: CertReade
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[];
export abstract class CertApplyBasePlugin extends CertApplyBaseConvertPlugin {
@TaskInput({
title: "邮箱",
@@ -50,36 +25,6 @@ export abstract class CertApplyBasePlugin extends AbstractTaskPlugin {
})
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,
@@ -111,14 +56,6 @@ export abstract class CertApplyBasePlugin extends AbstractTaskPlugin {
// })
csrInfo!: string;
userContext!: IContext;
lastStatus!: Step;
@TaskOutput({
title: "域名证书",
})
cert?: CertInfo;
async onInstance() {
this.userContext = this.ctx.userContext;
this.lastStatus = this.ctx.lastStatus as Step;
@@ -151,89 +88,6 @@ export abstract class CertApplyBasePlugin extends AbstractTaskPlugin {
}
}
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}`);
}
/**
* 是否更新证书
*/
@@ -279,22 +133,6 @@ cert.jksjks格式证书文件java服务器使用
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) {

View File

@@ -1,38 +1,53 @@
import { IsTaskPlugin, pluginGroups, RunStrategy, Step, TaskInput } from "@certd/pipeline";
import { IsTaskPlugin, pluginGroups, RunStrategy, Step, TaskInput, TaskOutput } from "@certd/pipeline";
import type { CertInfo } from "../acme.js";
import { CertReader } from "../cert-reader.js";
import { CertApplyBasePlugin } from "../base.js";
import { CertApplyBaseConvertPlugin } from "../base-convert.js";
import dayjs from "dayjs";
export { CertReader };
export type { CertInfo };
@IsTaskPlugin({
name: "CertUpload",
name: "CertApplyUpload",
icon: "ph:certificate",
title: "证书手动上传",
group: pluginGroups.cert.key,
desc: "在证书仓库手动上传后触发部署证书",
default: {
input: {
renewDays: 35,
forceUpdate: false,
},
strategy: {
runStrategy: RunStrategy.AlwaysRun,
},
},
})
export class CertUploadPlugin extends CertApplyBasePlugin {
export class CertApplyUploadPlugin extends CertApplyBaseConvertPlugin {
@TaskInput({
title: "证书仓库ID",
component: {
name: "a-cert-select",
vModel: "value",
name: "cert-info-selector",
vModel: "modelValue",
},
order: -9999,
required: true,
mergeScript: `
return {
component:{
on:{
selectedChange(scope){
console.log(scope)
scope.form.input.domains = scope.$event?.domains
}
}
}
}
`,
})
certInfoId!: string;
@TaskOutput({
title: "证书MD5",
})
certMd5?: string;
async onInstance() {
this.accessService = this.ctx.accessService;
this.logger = this.ctx.logger;
@@ -41,21 +56,45 @@ export class CertUploadPlugin extends CertApplyBasePlugin {
}
async onInit(): Promise<void> {}
async doCertApply() {
const siteInfoService = this.ctx.serviceContainer["CertInfoService"];
async getCertFromStore() {
const siteInfoService = await this.ctx.serviceGetter.get("CertInfoService");
const certInfo = await siteInfoService.getCertInfo({
certId: this.certInfoId,
userid: this.pipeline.userId,
userId: this.pipeline.userId,
});
const certReader = new CertReader(certInfo);
if (!certReader.expires && certReader.expires < new Date().getTime()) {
throw new Error("证书已过期,停止部署");
throw new Error("证书已过期,停止部署,请重新上传证书");
}
return certReader;
}
async execute(): Promise<string | void> {
const certReader = await this.getCertFromStore();
const crtMd5 = this.ctx.utils.hash.md5(certReader.cert.crt);
const leftDays = dayjs(certReader.expires).diff(dayjs(), "day");
this.logger.info(`证书过期时间${dayjs(certReader.expires).format("YYYY-MM-DD HH:mm:ss")},剩余${leftDays}`);
const lastCrtMd5 = this.lastStatus.status.output?.certMd5;
this.logger.info("证书MD5", crtMd5);
this.logger.info("上次证书MD5", lastCrtMd5);
if (lastCrtMd5 === crtMd5) {
this.logger.info("证书无变化,跳过");
//输出证书MD5
this.certMd5 = crtMd5;
await this.output(certReader, false);
return "skip";
}
this.logger.info("证书有变化,重新部署");
this.clearLastStatus();
//输出证书MD5
this.certMd5 = crtMd5;
await this.output(certReader, true);
return;
}
}
new CertUploadPlugin();
new CertApplyUploadPlugin();

View File

@@ -1,2 +1,4 @@
export * from "./cert-plugin/index.js";
export * from "./cert-plugin/lego/index.js";
export * from "./cert-plugin/custom/index.js";
export const CertApplyPluginNames = ["CertApply", "CertApplyLego", "CertApplyUpload"];