diff --git a/packages/core/pipeline/package.json b/packages/core/pipeline/package.json index 3350f6ec..ab11f9cb 100644 --- a/packages/core/pipeline/package.json +++ b/packages/core/pipeline/package.json @@ -15,12 +15,14 @@ "dependencies": { "@types/lodash-es": "^4.17.12", "axios": "^1.7.2", + "fix-path": "^4.0.0", "lodash-es": "^4.17.21", "node-forge": "^1.3.1", "nodemailer": "^6.9.3", "qs": "^6.11.2" }, "devDependencies": { + "iconv-lite": "^0.6.3", "@rollup/plugin-commonjs": "^23.0.4", "@rollup/plugin-json": "^6.0.0", "@rollup/plugin-node-resolve": "^15.0.1", @@ -33,7 +35,6 @@ "@typescript-eslint/eslint-plugin": "^5.59.7", "@typescript-eslint/parser": "^5.59.7", "chai": "4.3.10", - "mocha": "^10.2.0", "dayjs": "^1.11.7", "eslint": "^8.41.0", "eslint-config-prettier": "^8.8.0", @@ -41,6 +42,7 @@ "eslint-plugin-node": "^11.1.0", "eslint-plugin-prettier": "^4.2.1", "log4js": "^6.9.1", + "mocha": "^10.2.0", "prettier": "^2.8.8", "reflect-metadata": "^0.1.13", "rollup": "^3.7.4", diff --git a/packages/core/pipeline/rollup.config.js b/packages/core/pipeline/rollup.config.js deleted file mode 100644 index 6f53eb8c..00000000 --- a/packages/core/pipeline/rollup.config.js +++ /dev/null @@ -1,43 +0,0 @@ -const resolve = require("@rollup/plugin-node-resolve"); -const commonjs = require("@rollup/plugin-commonjs"); -//const Typescript = require("rollup-plugin-typescript2"); -const Typescript = require("@rollup/plugin-typescript"); -const json = require("@rollup/plugin-json"); -const terser = require("@rollup/plugin-terser"); -module.exports = { - input: "src/index.ts", - output: { - file: "dist/bundle.js", - format: "cjs", - }, - plugins: [ - // 解析第三方依赖 - resolve(), - // 识别 commonjs 模式第三方依赖 - commonjs(), - Typescript({ - target: "esnext", - rootDir: "src", - declaration: true, - declarationDir: "dist/d", - exclude: ["./node_modules/**", "./src/**/*.vue"], - allowSyntheticDefaultImports: true, - }), - json(), - terser(), - ], - external: [ - "vue", - "lodash-es", - "dayjs", - "@certd/acme-client", - "@certd/pipeline", - "@certd/plugin-cert", - "@certd/plugin-aliyun", - "@certd/plugin-tencent", - "@certd/plugin-huawei", - "@certd/plugin-host", - "@certd/plugin-tencent", - "@certd/plugin-util", - ], -}; diff --git a/packages/core/pipeline/src/decorator/utils.ts b/packages/core/pipeline/src/decorator/utils.ts index bd2d5c4b..b008f000 100644 --- a/packages/core/pipeline/src/decorator/utils.ts +++ b/packages/core/pipeline/src/decorator/utils.ts @@ -11,7 +11,11 @@ function attachProperty(target: any, propertyKey: string | symbol) { } function getClassProperties(target: any) { - return propertyMap[target] || {}; + //获取父类 + const parent = Object.getPrototypeOf(target); + const parentMap = propertyMap[parent] || {}; + const current = propertyMap[target] || {}; + return _.merge({}, parentMap, current); } function target(target: any, propertyKey?: string | symbol) { diff --git a/packages/core/pipeline/src/plugin/decorator.ts b/packages/core/pipeline/src/plugin/decorator.ts index da14c28a..b4541fa8 100644 --- a/packages/core/pipeline/src/plugin/decorator.ts +++ b/packages/core/pipeline/src/plugin/decorator.ts @@ -31,7 +31,24 @@ export function IsTaskPlugin(define: PluginDefine): ClassDecorator { outputs[property] = output; } } - _.merge(define, { input: inputs, autowire: autowires, output: outputs }); + + // inputs 转换为array,根据order排序,然后再转换为map + + let inputArray = []; + for (const key in inputs) { + const _input = inputs[key]; + if (_input.order == null) { + _input.order = 0; + } + inputArray.push([key, _input]); + } + inputArray = _.sortBy(inputArray, (item) => item[1].order); + const inputMap: any = {}; + inputArray.forEach((item) => { + inputMap[item[0]] = item[1]; + }); + + _.merge(define, { input: inputMap, autowire: autowires, output: outputs }); Reflect.defineMetadata(PLUGIN_CLASS_KEY, define, target); diff --git a/packages/core/pipeline/src/utils/index.ts b/packages/core/pipeline/src/utils/index.ts index 54e1027e..2d90b162 100644 --- a/packages/core/pipeline/src/utils/index.ts +++ b/packages/core/pipeline/src/utils/index.ts @@ -2,6 +2,8 @@ import sleep from "./util.sleep.js"; import { request } from "./util.request.js"; export * from "./util.log.js"; export * from "./util.file.js"; +export * from "./util.sp.js"; +export * as promises from "./util.promise.js"; export const utils = { sleep, http: request, diff --git a/packages/core/pipeline/src/utils/util.promise.ts b/packages/core/pipeline/src/utils/util.promise.ts index e1aec384..5757e4b6 100644 --- a/packages/core/pipeline/src/utils/util.promise.ts +++ b/packages/core/pipeline/src/utils/util.promise.ts @@ -1,3 +1,5 @@ +import { logger } from "./util.log.js"; + export function TimeoutPromise(callback: () => Promise, ms = 30 * 1000) { let timeout: any; return Promise.race([ @@ -11,3 +13,14 @@ export function TimeoutPromise(callback: () => Promise, ms = 30 * 1000) { clearTimeout(timeout); }); } + +export function safePromise(callback: (resolve: (ret: T) => void, reject: (ret: any) => void) => void): Promise { + return new Promise((resolve, reject) => { + try { + callback(resolve, reject); + } catch (e) { + logger.error(e); + reject(e); + } + }); +} diff --git a/packages/core/pipeline/src/utils/util.sleep.ts b/packages/core/pipeline/src/utils/util.sleep.ts index 5dcca1d8..496e2d2e 100644 --- a/packages/core/pipeline/src/utils/util.sleep.ts +++ b/packages/core/pipeline/src/utils/util.sleep.ts @@ -5,3 +5,4 @@ export default function (timeout: number) { }, timeout); }); } + diff --git a/packages/core/pipeline/src/utils/util.sp.ts b/packages/core/pipeline/src/utils/util.sp.ts new file mode 100644 index 00000000..3482247e --- /dev/null +++ b/packages/core/pipeline/src/utils/util.sp.ts @@ -0,0 +1,110 @@ +//转换为import +import childProcess from "child_process"; +import { safePromise } from "./util.promise.js"; +import { ILogger, logger } from "./util.log.js"; + +export type ExecOption = { + cmd: string | string[]; + env: any; + logger?: ILogger; + options?: any; +}; + +async function exec(opts: ExecOption): Promise { + let cmd = ""; + const log = opts.logger || logger; + if (opts.cmd instanceof Array) { + for (const item of opts.cmd) { + if (cmd) { + cmd += " && " + item; + } else { + cmd = item; + } + } + } + log.info(`执行命令: ${cmd}`); + return safePromise((resolve, reject) => { + childProcess.exec( + cmd, + { + env: { + ...process.env, + ...opts.env, + }, + ...opts.options, + }, + (error, stdout, stderr) => { + if (error) { + log.error(`exec error: ${error}`); + reject(error); + } else { + const res = stdout.toString("utf-8"); + log.info(`stdout: ${res}`); + resolve(res); + } + } + ); + }); +} + +export type SpawnOption = { + cmd: string | string[]; + onStdout?: (data: string) => void; + onStderr?: (data: string) => void; + env: any; + logger?: ILogger; + options?: any; +}; +async function spawn(opts: SpawnOption): Promise { + let cmd = ""; + const log = opts.logger || logger; + if (opts.cmd instanceof Array) { + for (const item of opts.cmd) { + if (cmd) { + cmd += " && " + item; + } else { + cmd = item; + } + } + } + log.info(`执行命令: ${cmd}`); + let stdout = ""; + let stderr = ""; + return safePromise((resolve, reject) => { + const ls = childProcess.spawn(cmd, { + shell: process.platform == "win32", + env: { + ...process.env, + ...opts.env, + }, + ...opts.options, + }); + ls.stdout.on("data", (data) => { + log.info(`stdout: ${data}`); + stdout += data; + }); + + ls.stderr.on("data", (data) => { + log.error(`stderr: ${data}`); + stderr += data; + }); + ls.on("error", (error) => { + log.error(`child process error: ${error}`); + reject(error); + }); + + ls.on("close", (code: number) => { + if (code !== 0) { + log.error(`child process exited with code ${code}`); + reject(new Error(stderr)); + } else { + resolve(stdout); + } + }); + }); +} + +export const sp = { + spawn, + exec, +}; diff --git a/packages/core/pipeline/vite.config.js b/packages/core/pipeline/vite.config.js deleted file mode 100644 index d91f5a99..00000000 --- a/packages/core/pipeline/vite.config.js +++ /dev/null @@ -1,59 +0,0 @@ -import { defineConfig } from "vite"; -import visualizer from "rollup-plugin-visualizer"; -import typescript from "@rollup/plugin-typescript"; -// https://vitejs.dev/config/ -export default defineConfig({ - plugins: [], - build: { - target: "es2015", - lib: { - entry: "src/index.ts", - name: "CertdPipeline", - }, - rollupOptions: { - plugins: [ - visualizer(), - typescript({ - target: "es2015", - rootDir: "src", - declaration: true, - declarationDir: "dist/d", - exclude: ["./node_modules/**", "./src/**/*.vue"], - allowSyntheticDefaultImports: true, - }), - ], - external: [ - "vue", - "lodash-es", - "dayjs", - "@certd/acme-client", - "@certd/plugin-cert", - "@certd/plugin-aliyun", - "@certd/plugin-tencent", - "@certd/plugin-huawei", - "@certd/plugin-host", - "@certd/plugin-tencent", - "@certd/plugin-util", - "log4js", - "@midwayjs/core", - "@midwayjs/decorator", - ], - output: { - globals: { - vue: "Vue", - lodash: "_", - dayjs: "dayjs", - "@certd/plugin-cert": "CertdPluginCert", - "@certd/acme-client": "CertdAcmeClient", - "@certd/plugin-aliyun": "CertdPluginAliyun", - "@certd/plugin-host": "CertdPluginHost", - "@certd/plugin-huawei": "CertdPluginHuawei", - "@certd/plugin-util": "CertdPluginUtil", - log4js: "log4js", - "@midwayjs/core": "MidwayjsCore", - "@midwayjs/decorator": "MidwayjsDecorator", - }, - }, - }, - }, -}); diff --git a/packages/plugins/plugin-cert/src/plugin/cert-plugin/base.ts b/packages/plugins/plugin-cert/src/plugin/cert-plugin/base.ts new file mode 100644 index 00000000..e674c133 --- /dev/null +++ b/packages/plugins/plugin-cert/src/plugin/cert-plugin/base.ts @@ -0,0 +1,255 @@ +import { AbstractTaskPlugin, HttpClient, IAccessService, IContext, Step, TaskInput, TaskOutput } from "@certd/pipeline"; +import dayjs from "dayjs"; +import type { CertInfo } from "./acme.js"; +import { Logger } from "log4js"; +import { CertReader } from "./cert-reader.js"; +import JSZip from "jszip"; + +export { CertReader }; +export type { CertInfo }; + +export abstract class CertApplyBasePlugin extends AbstractTaskPlugin { + @TaskInput({ + title: "域名", + component: { + name: "a-select", + vModel: "value", + mode: "tags", + open: false, + }, + required: true, + col: { + span: 24, + }, + helper: + "1、支持通配符域名,例如: *.foo.com、foo.com、*.test.handsfree.work\n" + + "2、支持多个域名、多个子域名、多个通配符域名打到一个证书上(域名必须是在同一个DNS提供商解析)\n" + + "3、多级子域名要分成多个域名输入(*.foo.com的证书不能用于xxx.yyy.foo.com、foo.com)\n" + + "4、输入一个回车之后,再输入下一个", + }) + domains!: string[]; + + @TaskInput({ + title: "邮箱", + component: { + name: "a-input", + vModel: "value", + }, + required: true, + helper: "请输入邮箱", + }) + email!: string; + + @TaskInput({ + title: "更新天数", + 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: "配置说明", + order: 9999, + helper: "运行策略请选择总是运行,其他证书部署任务请选择成功后跳过;当证书快到期前将会自动重新申请证书,然后会清空后续任务的成功状态,部署任务将会重新运行", + }) + intro!: string; + + // @TaskInput({ + // title: "CsrInfo", + // helper: "暂时没有用", + // }) + csrInfo!: string; + + logger!: Logger; + userContext!: IContext; + accessService!: IAccessService; + http!: HttpClient; + lastStatus!: Step; + + @TaskOutput({ + title: "域名证书", + }) + cert?: CertInfo; + + async onInstance() { + 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; + await this.onInit(); + } + + abstract onInit(): Promise; + + abstract doCertApply(): Promise; + + async execute(): Promise { + 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; + + if (isNew) { + const applyTime = dayjs(certReader.detail.validity.notBefore).format("YYYYMMDD_HHmmss"); + await this.zipCert(cert, applyTime); + } else { + this.extendsFiles(); + } + // thi + // s.logger.info(JSON.stringify(certReader.detail)); + } + + async zipCert(cert: CertInfo, applyTime: string) { + const zip = new JSZip(); + zip.file("cert.crt", cert.crt); + zip.file("cert.key", cert.key); + const domain_name = this.domains[0].replace(".", "_").replace("*", "_"); + const filename = `cert_${domain_name}_${applyTime}.zip`; + const content = await zip.generateAsync({ type: "nodebuffer" }); + this.saveFile(filename, content); + this.logger.info(`已保存文件:${filename}`); + } + + /** + * 是否更新证书 + */ + async condition() { + if (this.forceUpdate) { + return null; + } + + let inputChanged = false; + const oldInput = JSON.stringify(this.lastStatus?.input?.domains); + const thisInput = JSON.stringify(this.domains); + if (oldInput !== thisInput) { + inputChanged = true; + } + + 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; + } + + if (inputChanged) { + 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 { + 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); + } + } +} diff --git a/packages/plugins/plugin-cert/src/plugin/cert-plugin/index.ts b/packages/plugins/plugin-cert/src/plugin/cert-plugin/index.ts index 5dd3a383..8d3e6887 100644 --- a/packages/plugins/plugin-cert/src/plugin/cert-plugin/index.ts +++ b/packages/plugins/plugin-cert/src/plugin/cert-plugin/index.ts @@ -1,12 +1,10 @@ -import { AbstractTaskPlugin, Decorator, HttpClient, IAccessService, IContext, IsTaskPlugin, RunStrategy, Step, TaskInput, TaskOutput } from "@certd/pipeline"; -import dayjs from "dayjs"; -import { AcmeService } from "./acme.js"; +import { Decorator, IsTaskPlugin, RunStrategy, TaskInput } from "@certd/pipeline"; import type { CertInfo, SSLProvider } from "./acme.js"; +import { AcmeService } from "./acme.js"; import _ from "lodash-es"; -import { Logger } from "log4js"; import { DnsProviderContext, DnsProviderDefine, dnsProviderRegistry } from "../../dns-provider/index.js"; import { CertReader } from "./cert-reader.js"; -import JSZip from "jszip"; +import { CertApplyBasePlugin } from "./base.js"; export { CertReader }; export type { CertInfo }; @@ -25,38 +23,7 @@ export type { CertInfo }; }, }, }) -export class CertApplyPlugin extends AbstractTaskPlugin { - @TaskInput({ - title: "域名", - component: { - name: "a-select", - vModel: "value", - mode: "tags", - open: false, - }, - required: true, - col: { - span: 24, - }, - helper: - "1、支持通配符域名,例如: *.foo.com、foo.com、*.test.handsfree.work\n" + - "2、支持多个域名、多个子域名、多个通配符域名打到一个证书上(域名必须是在同一个DNS提供商解析)\n" + - "3、多级子域名要分成多个域名输入(*.foo.com的证书不能用于xxx.yyy.foo.com、foo.com)\n" + - "4、输入一个回车之后,再输入下一个", - }) - domains!: string[]; - - @TaskInput({ - title: "邮箱", - component: { - name: "a-input", - vModel: "value", - }, - required: true, - helper: "请输入邮箱", - }) - email!: string; - +export class CertApplyPlugin extends CertApplyBasePlugin { @TaskInput({ title: "证书提供商", default: "letsencrypt", @@ -121,69 +88,9 @@ export class CertApplyPlugin extends AbstractTaskPlugin { }) skipLocalVerify = false; - @TaskInput({ - title: "更新天数", - component: { - name: "a-input-number", - vModel: "value", - }, - required: true, - helper: "到期前多少天后更新证书,注意:流水线默认不会自动运行,请设置定时器,每天定时运行本流水线", - }) - renewDays!: number; - - @TaskInput({ - title: "强制更新", - component: { - name: "a-switch", - vModel: "checked", - }, - helper: "是否强制重新申请证书", - }) - forceUpdate!: string; - - @TaskInput({ - title: "成功后邮件通知", - value: true, - component: { - name: "a-switch", - vModel: "checked", - }, - helper: "申请成功后是否发送邮件通知", - }) - successNotify = true; - - // @TaskInput({ - // title: "CsrInfo", - // helper: "暂时没有用", - // }) - csrInfo!: string; - - @TaskInput({ - title: "配置说明", - helper: "运行策略请选择总是运行,其他证书部署任务请选择成功后跳过;当证书快到期前将会自动重新申请证书,然后会清空后续任务的成功状态,部署任务将会重新运行", - }) - intro!: string; - acme!: AcmeService; - logger!: Logger; - userContext!: IContext; - accessService!: IAccessService; - http!: HttpClient; - lastStatus!: Step; - - @TaskOutput({ - title: "域名证书", - }) - cert?: CertInfo; - - async onInstance() { - 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; + async onInit() { let eab: any = null; if (this.eabAccessId) { eab = await this.ctx.accessService.getById(this.eabAccessId); @@ -197,90 +104,6 @@ export class CertApplyPlugin extends AbstractTaskPlugin { }); } - async execute(): Promise { - 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; - - if (isNew) { - const applyTime = dayjs(certReader.detail.validity.notBefore).format("YYYYMMDD_HHmmss"); - await this.zipCert(cert, applyTime); - } else { - this.extendsFiles(); - } - // thi - // s.logger.info(JSON.stringify(certReader.detail)); - } - - async zipCert(cert: CertInfo, applyTime: string) { - const zip = new JSZip(); - zip.file("cert.crt", cert.crt); - zip.file("cert.key", cert.key); - const domain_name = this.domains[0].replace(".", "_").replace("*", "_"); - const filename = `cert_${domain_name}_${applyTime}.zip`; - const content = await zip.generateAsync({ type: "nodebuffer" }); - this.saveFile(filename, content); - this.logger.info(`已保存文件:${filename}`); - } - - /** - * 是否更新证书 - */ - async condition() { - if (this.forceUpdate) { - return null; - } - - let inputChanged = false; - const oldInput = JSON.stringify(this.lastStatus?.input?.domains); - const thisInput = JSON.stringify(this.domains); - if (oldInput !== thisInput) { - inputChanged = true; - } - - 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; - } - - if (inputChanged) { - 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; - } - async doCertApply() { const email = this["email"]; const domains = this["domains"]; @@ -331,63 +154,6 @@ export class CertApplyPlugin extends AbstractTaskPlugin { throw e; } } - - 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 { - 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); - } - } } new CertApplyPlugin(); diff --git a/packages/plugins/plugin-cert/src/plugin/cert-plugin/lego.ts b/packages/plugins/plugin-cert/src/plugin/cert-plugin/lego.ts new file mode 100644 index 00000000..ee37a215 --- /dev/null +++ b/packages/plugins/plugin-cert/src/plugin/cert-plugin/lego.ts @@ -0,0 +1,137 @@ +import { IsTaskPlugin, RunStrategy, sp, Step, TaskInput } from "@certd/pipeline"; +import type { CertInfo } from "./acme.js"; +import { CertReader } from "./cert-reader.js"; +import { CertApplyBasePlugin } from "./base.js"; +import fs from "fs"; +import { EabAccess } from "../../access"; +import path from "path"; + +export { CertReader }; +export type { CertInfo }; + +@IsTaskPlugin({ + name: "CertApplyLego", + title: "证书申请(Lego)", + desc: "支持海量DNS解析提供商,推荐使用", + default: { + input: { + renewDays: 20, + forceUpdate: false, + }, + strategy: { + runStrategy: RunStrategy.AlwaysRun, + }, + }, +}) +export class CertApplyLegoPlugin extends CertApplyBasePlugin { + @TaskInput({ + title: "DNS类型", + component: { + name: "a-input", + vModel: "value", + }, + required: true, + }) + dnsType!: string; + + @TaskInput({ + title: "环境变量", + component: { + name: "a-textarea", + vModel: "value", + rows: 6, + }, + required: true, + helper: "一行一条,例如 appKeyId=xxxxx", + }) + environment!: string; + + @TaskInput({ + title: "EAB授权", + component: { + name: "pi-access-selector", + type: "eab", + }, + helper: "如果需要提供EAB授权", + }) + eabAccessId!: number; + + @TaskInput({ + title: "自定义LEGO参数", + component: { + name: "a-input", + vModel: "value", + }, + }) + customArgs = ""; + + eab?: EabAccess; + + async onInstance() { + 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; + if (this.eabAccessId) { + this.eab = await this.ctx.accessService.getById(this.eabAccessId); + } + } + async onInit(): Promise {} + + async doCertApply() { + const env: any = {}; + const env_lines = this.environment.split("\n"); + for (const line of env_lines) { + const [key, value] = line.trim().split("="); + env[key] = value.trim(); + } + + let domainArgs = ""; + for (const domain of this.domains) { + domainArgs += ` -d "${domain}"`; + } + this.logger.info(`环境变量:${JSON.stringify(env)}`); + let eabArgs = ""; + if (this.eab) { + eabArgs = ` --eab "${this.eab.kid}" --kid "${this.eab.kid}" --hmac "${this.eab.hmacKey}"`; + } + const keyType = "-k rsa2048"; + + const saveDir = `./data/.lego/pipeline_${this.pipeline.id}/`; + const savePathArgs = `--path "${saveDir}"`; + const os_type = process.platform === "win32" ? "windows" : "linux"; + const legoPath = path.resolve("./tools", os_type, "lego"); + const cmds = [ + `${legoPath} -a --email "${this.email}" --dns ${this.dnsType} ${keyType} ${domainArgs} ${eabArgs} ${savePathArgs} ${this.customArgs || ""} run`, + ]; + + await sp.spawn({ + cmd: cmds, + logger: this.logger, + env, + }); + + //读取证书文件 + // example.com.crt + // example.com.issuer.crt + // example.com.json + // example.com.key + + let domain1 = this.domains[0]; + domain1 = domain1.replaceAll("*", "_"); + const crtPath = path.resolve(saveDir, "certificates", `${domain1}.crt`); + if (fs.existsSync(crtPath) === false) { + throw new Error(`证书文件不存在,证书申请失败:${crtPath}`); + } + const crt = fs.readFileSync(crtPath, "utf8"); + const keyPath = path.resolve(saveDir, "certificates", `${domain1}.key`); + const key = fs.readFileSync(keyPath, "utf8"); + const csr = ""; + const cert = { crt, key, csr }; + const certInfo = this.formatCerts(cert); + return new CertReader(certInfo); + } +} + +new CertApplyLegoPlugin(); diff --git a/packages/plugins/plugin-cert/src/plugin/index.ts b/packages/plugins/plugin-cert/src/plugin/index.ts index 2fa7c73f..1bb9235c 100644 --- a/packages/plugins/plugin-cert/src/plugin/index.ts +++ b/packages/plugins/plugin-cert/src/plugin/index.ts @@ -1 +1,2 @@ export * from "./cert-plugin/index.js"; +export * from "./cert-plugin/lego.js"; diff --git a/packages/ui/certd-server/src/plugins/plugin-host/lib/ssh.ts b/packages/ui/certd-server/src/plugins/plugin-host/lib/ssh.ts index 39496322..644c197b 100644 --- a/packages/ui/certd-server/src/plugins/plugin-host/lib/ssh.ts +++ b/packages/ui/certd-server/src/plugins/plugin-host/lib/ssh.ts @@ -28,18 +28,22 @@ export class AsyncSsh2Client { async connect() { this.logger.info(`开始连接,${this.connConf.host}:${this.connConf.port}`); return new Promise((resolve, reject) => { - const conn = new ssh2.Client(); - conn - .on('error', (err: any) => { - this.logger.error('连接失败', err); - reject(err); - }) - .on('ready', () => { - this.logger.info('连接成功'); - this.conn = conn; - resolve(this.conn); - }) - .connect(this.connConf); + try { + const conn = new ssh2.Client(); + conn + .on('error', (err: any) => { + this.logger.error('连接失败', err); + reject(err); + }) + .on('ready', () => { + this.logger.info('连接成功'); + this.conn = conn; + resolve(this.conn); + }) + .connect(this.connConf); + } catch (e) { + reject(e); + } }); } async getSftp() { @@ -96,9 +100,7 @@ export class AsyncSsh2Client { .stderr.on('data', (ret: Buffer) => { const err = this.convert(ret); data += err; - this.logger.info( - `[${this.connConf.host}][error]: ` + err.trimEnd() - ); + this.logger.info(`[${this.connConf.host}][error]: ` + err.trimEnd()); }); }); }); @@ -154,11 +156,7 @@ export class SshClient { } * @param options */ - async uploadFiles(options: { - connectConf: SshAccess; - transports: any; - mkdirs: boolean; - }) { + async uploadFiles(options: { connectConf: SshAccess; transports: any; mkdirs: boolean }) { const { connectConf, transports, mkdirs } = options; await this._call({ connectConf, @@ -172,9 +170,7 @@ export class SshClient { if (conn.windows) { if (filePath.indexOf('/') > -1) { this.logger.info('--------------------------'); - this.logger.info( - '请注意:windows下,文件目录分隔应该写成\\而不是/' - ); + this.logger.info('请注意:windows下,文件目录分隔应该写成\\而不是/'); this.logger.info('--------------------------'); } const spec = await conn.exec('echo %COMSPEC%'); diff --git a/packages/ui/certd-server/tools/linux/lego b/packages/ui/certd-server/tools/linux/lego new file mode 100644 index 00000000..1b3edf28 Binary files /dev/null and b/packages/ui/certd-server/tools/linux/lego differ diff --git a/packages/ui/certd-server/tools/windows/lego.exe b/packages/ui/certd-server/tools/windows/lego.exe new file mode 100644 index 00000000..87f9e07b Binary files /dev/null and b/packages/ui/certd-server/tools/windows/lego.exe differ