mirror of https://github.com/certd/certd
feat: 支持lego,海量DNS提供商
parent
b1cd055342
commit
0bc6d0a211
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
],
|
||||
};
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { logger } from "./util.log.js";
|
||||
|
||||
export function TimeoutPromise(callback: () => Promise<void>, ms = 30 * 1000) {
|
||||
let timeout: any;
|
||||
return Promise.race([
|
||||
|
@ -11,3 +13,14 @@ export function TimeoutPromise(callback: () => Promise<void>, ms = 30 * 1000) {
|
|||
clearTimeout(timeout);
|
||||
});
|
||||
}
|
||||
|
||||
export function safePromise<T>(callback: (resolve: (ret: T) => void, reject: (ret: any) => void) => void): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
callback(resolve, reject);
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -5,3 +5,4 @@ export default function (timeout: number) {
|
|||
}, timeout);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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<string> {
|
||||
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<string> {
|
||||
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,
|
||||
};
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
|
@ -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<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;
|
||||
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<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;
|
||||
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
new CertApplyPlugin();
|
||||
|
|
|
@ -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<void> {}
|
||||
|
||||
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();
|
|
@ -1 +1,2 @@
|
|||
export * from "./cert-plugin/index.js";
|
||||
export * from "./cert-plugin/lego.js";
|
||||
|
|
|
@ -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%');
|
||||
|
|
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue