refactor: pipeline edit view

This commit is contained in:
xiaojunnuo
2022-10-26 09:02:47 +08:00
parent af919c2f6e
commit 370a28c10e
48 changed files with 1606 additions and 3 deletions
@@ -0,0 +1,28 @@
import { AbstractRegistrable } from "../registry";
import { PluginDefine } from "./api";
import { Logger } from "log4js";
import { logger } from "../utils/util.log";
import { IAccessService } from "../access/access-service";
import { IContext } from "../core/context";
export abstract class AbstractPlugin extends AbstractRegistrable {
static define: PluginDefine;
logger: Logger = logger;
// @ts-ignore
accessService: IAccessService;
// @ts-ignore
pipelineContext: IContext;
// @ts-ignore
userContext: IContext;
async doInit(options: { accessService: IAccessService; pipelineContext: IContext; userContext: IContext }) {
this.accessService = options.accessService;
this.pipelineContext = options.pipelineContext;
this.userContext = options.userContext;
await this.onInit();
}
protected async onInit(): Promise<void> {
//
}
}
+60
View File
@@ -0,0 +1,60 @@
import { FormItemProps } from "@fast-crud/fast-crud";
import { Registrable } from "../registry";
import { pluginRegistry } from "./registry";
export type TaskInput = {
[key: string]: any;
};
export type TaskOutput = {
[key: string]: any;
};
export enum ContextScope {
global,
pipeline,
runtime,
}
export type Storage = {
scope: ContextScope;
path: string;
};
export type TaskOutputDefine = {
title: string;
key: string;
value?: any;
storage?: Storage;
};
export type TaskInputDefine = FormItemProps;
export type PluginDefine = Registrable & {
input: {
[key: string]: TaskInputDefine;
};
output: {
[key: string]: TaskOutputDefine;
};
};
export interface TaskPlugin {
execute(input: TaskInput): Promise<TaskOutput>;
}
export type OutputVO = {
key: string;
title: string;
value: any;
};
export function IsTask(define: (() => PluginDefine) | PluginDefine) {
return function (target: any) {
if (define instanceof Function) {
target.define = define();
} else {
target.define = define;
}
pluginRegistry.install(target);
};
}
@@ -0,0 +1,3 @@
import "./plugins";
export * from "./api";
export * from "./registry";
@@ -0,0 +1,198 @@
// @ts-ignore
import acme, { Authorization } from "@certd/acme-client";
import _ from "lodash";
import { logger } from "../../../utils/util.log";
import { AbstractDnsProvider } from "../../../dns-provider/abstract-dns-provider";
import { IContext } from "../../../core/context";
import { IDnsProvider } from "../../../dns-provider";
import { Challenge } from "@certd/acme-client/types/rfc8555";
export class AcmeService {
userContext: IContext;
constructor(options: { userContext: IContext }) {
this.userContext = options.userContext;
acme.setLogger((text: string) => {
logger.info(text);
});
}
async getAccountConfig(email: string) {
return (await this.userContext.get(this.buildAccountKey(email))) || {};
}
buildAccountKey(email: string) {
return "acme.config." + email;
}
async saveAccountConfig(email: string, conf: any) {
await this.userContext.set(this.buildAccountKey(email), conf);
}
async getAcmeClient(email: string, isTest = false): Promise<acme.Client> {
const conf = await this.getAccountConfig(email);
if (conf.key == null) {
conf.key = await this.createNewKey();
await this.saveAccountConfig(email, conf);
}
if (isTest == null) {
isTest = process.env.CERTD_MODE === "test";
}
const client = new acme.Client({
directoryUrl: isTest ? acme.directory.letsencrypt.staging : acme.directory.letsencrypt.production,
accountKey: conf.key,
accountUrl: conf.accountUrl,
backoffAttempts: 20,
backoffMin: 5000,
backoffMax: 10000,
});
if (conf.accountUrl == null) {
const accountPayload = {
termsOfServiceAgreed: true,
contact: [`mailto:${email}`],
};
await client.createAccount(accountPayload);
conf.accountUrl = client.getAccountUrl();
await this.saveAccountConfig(email, conf);
}
return client;
}
async createNewKey() {
const key = await acme.forge.createPrivateKey();
return key.toString();
}
async challengeCreateFn(authz: any, challenge: any, keyAuthorization: string, dnsProvider: IDnsProvider) {
logger.info("Triggered challengeCreateFn()");
/* http-01 */
if (challenge.type === "http-01") {
const filePath = `/var/www/html/.well-known/acme-challenge/${challenge.token}`;
const fileContents = keyAuthorization;
logger.info(`Creating challenge response for ${authz.identifier.value} at path: ${filePath}`);
/* Replace this */
logger.info(`Would write "${fileContents}" to path "${filePath}"`);
// await fs.writeFileAsync(filePath, fileContents);
} else if (challenge.type === "dns-01") {
/* dns-01 */
const dnsRecord = `_acme-challenge.${authz.identifier.value}`;
const recordValue = keyAuthorization;
logger.info(`Creating TXT record for ${authz.identifier.value}: ${dnsRecord}`);
/* Replace this */
logger.info(`Would create TXT record "${dnsRecord}" with value "${recordValue}"`);
return await dnsProvider.createRecord({
fullRecord: dnsRecord,
type: "TXT",
value: recordValue,
});
}
}
/**
* Function used to remove an ACME challenge response
*
* @param {object} authz Authorization object
* @param {object} challenge Selected challenge
* @param {string} keyAuthorization Authorization key
* @param recordItem challengeCreateFn create record item
* @param dnsProvider dnsProvider
* @returns {Promise}
*/
async challengeRemoveFn(authz: any, challenge: any, keyAuthorization: string, recordItem: any, dnsProvider: IDnsProvider) {
logger.info("Triggered challengeRemoveFn()");
/* http-01 */
if (challenge.type === "http-01") {
const filePath = `/var/www/html/.well-known/acme-challenge/${challenge.token}`;
logger.info(`Removing challenge response for ${authz.identifier.value} at path: ${filePath}`);
/* Replace this */
logger.info(`Would remove file on path "${filePath}"`);
// await fs.unlinkAsync(filePath);
} else if (challenge.type === "dns-01") {
const dnsRecord = `_acme-challenge.${authz.identifier.value}`;
const recordValue = keyAuthorization;
logger.info(`Removing TXT record for ${authz.identifier.value}: ${dnsRecord}`);
/* Replace this */
logger.info(`Would remove TXT record "${dnsRecord}" with value "${recordValue}"`);
await dnsProvider.removeRecord({
fullRecord: dnsRecord,
type: "TXT",
value: keyAuthorization,
record: recordItem,
});
}
}
async order(options: { email: string; domains: string | string[]; dnsProvider: AbstractDnsProvider; csrInfo: any; isTest?: boolean }) {
const { email, isTest, domains, csrInfo, dnsProvider } = options;
const client: acme.Client = await this.getAcmeClient(email, isTest);
/* Create CSR */
const { commonName, altNames } = this.buildCommonNameByDomains(domains);
const [key, csr] = await acme.forge.createCsr({
commonName,
...csrInfo,
altNames,
});
if (dnsProvider == null) {
throw new Error("dnsProvider 不能为空");
}
/* 自动申请证书 */
const crt = await client.auto({
csr,
email: email,
termsOfServiceAgreed: true,
challengePriority: ["dns-01"],
challengeCreateFn: async (authz: Authorization, challenge: Challenge, keyAuthorization: string): Promise<any> => {
return await this.challengeCreateFn(authz, challenge, keyAuthorization, dnsProvider);
},
challengeRemoveFn: async (authz: Authorization, challenge: Challenge, keyAuthorization: string, recordItem: any): Promise<any> => {
return await this.challengeRemoveFn(authz, challenge, keyAuthorization, recordItem, dnsProvider);
},
});
const cert = {
crt: crt.toString(),
key: key.toString(),
csr: csr.toString(),
};
/* Done */
logger.debug(`CSR:\n${cert.csr}`);
logger.debug(`Certificate:\n${cert.crt}`);
logger.info("证书申请成功");
return cert;
}
buildCommonNameByDomains(domains: string | string[]): {
commonName: string;
altNames: string[] | undefined;
} {
if (typeof domains === "string") {
domains = domains.split(",");
}
if (domains.length === 0) {
throw new Error("domain can not be empty");
}
const commonName = domains[0];
let altNames: undefined | string[] = undefined;
if (domains.length > 1) {
altNames = _.slice(domains, 1);
}
return {
commonName,
altNames,
};
}
}
@@ -0,0 +1,210 @@
import { AbstractPlugin } from "../../abstract-plugin";
import forge from "node-forge";
import { ContextScope, IsTask, TaskInput, TaskOutput, TaskPlugin } from "../../api";
import dayjs from "dayjs";
import { dnsProviderRegistry } from "../../../dns-provider";
import { AbstractDnsProvider } from "../../../dns-provider/abstract-dns-provider";
import { AcmeService } from "./acme";
export type CertInfo = {
crt: string;
key: string;
csr: string;
};
@IsTask(() => {
return {
name: "CertApply",
title: "证书申请",
input: {
domains: {
component: {
name: "a-select",
vModel: "value",
mode: "tags",
},
col: {
span: 24,
},
helper: "请输入域名",
},
email: {
component: {
name: "a-input",
vModel: "value",
},
helper: "请输入邮箱",
},
dnsProviderType: {
component: {
name: "a-select",
},
helper: "请选择dns解析提供商",
},
dnsProviderAccess: {
component: {
name: "access-selector",
},
helper: "请选择dns解析提供商授权",
},
renewDays: {
title: "更新天数",
component: {
name: "a-number",
value: 20,
},
helper: "到期前多少天后更新证书",
},
forceUpdate: {
title: "强制更新",
component: {
name: "a-switch",
vModel: "checked",
value: false,
},
helper: "强制重新申请证书",
},
},
output: {
cert: {
key: "cert",
type: "CertInfo",
title: "证书",
scope: ContextScope.pipeline,
},
},
};
})
export class CertPlugin extends AbstractPlugin implements TaskPlugin {
// @ts-ignore
acme: AcmeService;
constructor() {
super();
}
protected async onInit() {
this.acme = new AcmeService({ userContext: this.userContext });
}
async execute(input: TaskInput): Promise<TaskOutput> {
const oldCert = await this.condition(input);
if (oldCert != null) {
return {
cert: oldCert,
};
}
const cert = await this.doCertApply(input);
return { cert };
}
/**
* 是否更新证书
* @param input
*/
async condition(input: TaskInput) {
if (input.forceUpdate) {
return null;
}
let oldCert;
try {
oldCert = await this.readCurrentCert();
} catch (e) {
this.logger.warn("读取cert失败:", e);
}
if (oldCert == null) {
this.logger.info("还未申请过,准备申请新证书");
return null;
}
const ret = this.isWillExpire(oldCert.expires, input.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(input: TaskInput) {
const email = input["email"];
const domains = input["domains"];
const dnsProviderType = input["dnsProviderType"];
const dnsProviderAccessId = input["dnsProviderAccess"];
const csrInfo = input["csrInfo"];
this.logger.info("开始申请证书,", email, domains);
const dnsProviderClass = dnsProviderRegistry.get(dnsProviderType);
const access = await this.accessService.getById(dnsProviderAccessId);
// @ts-ignore
const dnsProvider: AbstractDnsProvider = new dnsProviderClass();
dnsProvider.doInit({ access });
const cert = await this.acme.order({
email,
domains,
dnsProvider,
csrInfo,
isTest: false,
});
await this.writeCert(cert);
const ret = await this.readCurrentCert();
return {
...ret,
isNew: true,
};
}
formatCert(pem: string) {
pem = pem.replace(/\r/g, "");
pem = pem.replace(/\n\n/g, "\n");
pem = pem.replace(/\n$/g, "");
return pem;
}
async writeCert(cert: { crt: string; key: string; csr: string }) {
const newCert = {
crt: this.formatCert(cert.crt),
key: this.formatCert(cert.key),
csr: this.formatCert(cert.csr),
};
await this.pipelineContext.set("cert", newCert);
}
async readCurrentCert() {
const cert: CertInfo = await this.pipelineContext.get("cert");
if (cert == null) {
return undefined;
}
const { detail, expires } = this.getCrtDetail(cert.crt);
return {
...cert,
detail,
expires: expires.getTime(),
};
}
getCrtDetail(crt: string) {
const pki = forge.pki;
const detail = pki.certificateFromPem(crt.toString());
const expires = detail.validity.notAfter;
return { detail, expires };
}
/**
* 检查是否过期,默认提前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,
};
}
}
@@ -0,0 +1,101 @@
import { AbstractPlugin } from "../../abstract-plugin";
import { IsTask, TaskInput, TaskOutput, TaskPlugin } from "../../api";
import dayjs from "dayjs";
import Core from "@alicloud/pop-core";
import RPCClient from "@alicloud/pop-core";
import { AliyunAccess } from "../../../access";
import { CertInfo } from "../cert-plugin";
@IsTask(() => {
return {
name: "DeployCertToAliyunCDN",
title: "部署证书至阿里云CDN",
input: {
domainName: {
title: "cdn加速域名",
component: {
placeholder: "cdn加速域名",
},
required: true,
},
certName: {
title: "证书名称",
component: {
placeholder: "上传后将以此名称作为前缀",
},
},
cert: {
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
},
required: true,
},
accessId: {
title: "Access提供者",
helper: "access授权",
component: {
name: "access-selector",
type: "aliyun",
},
required: true,
},
},
output: {},
};
})
export class DeployCertToAliyunCDN extends AbstractPlugin implements TaskPlugin {
constructor() {
super();
}
async execute(input: TaskInput): Promise<TaskOutput> {
console.log("开始部署证书到阿里云cdn");
const access = this.accessService.getById(input.accessId) as AliyunAccess;
const client = this.getClient(access);
const params = await this.buildParams(input);
await this.doRequest(client, params);
return {};
}
getClient(access: AliyunAccess) {
return new Core({
accessKeyId: access.accessKeyId,
accessKeySecret: access.accessKeySecret,
endpoint: "https://cdn.aliyuncs.com",
apiVersion: "2018-05-10",
});
}
async buildParams(input: TaskInput) {
const { certName, domainName, cert } = input;
const CertName = certName + "-" + dayjs().format("YYYYMMDDHHmmss");
const newCert = (await this.pipelineContext.get(cert)) as CertInfo;
return {
RegionId: "cn-hangzhou",
DomainName: domainName,
ServerCertificateStatus: "on",
CertName: CertName,
CertType: "upload",
ServerCertificate: newCert.crt,
PrivateKey: newCert.key,
};
}
async doRequest(client: RPCClient, params: any) {
const requestOption = {
method: "POST",
};
const ret: any = await client.request("SetDomainServerCertificate", params, requestOption);
this.checkRet(ret);
this.logger.info("设置cdn证书成功:", ret.RequestId);
}
checkRet(ret: any) {
if (ret.code != null) {
throw new Error("执行失败:", ret.Message);
}
}
}
@@ -0,0 +1,26 @@
import { AbstractPlugin } from "../abstract-plugin";
import { IsTask, TaskInput, TaskOutput, TaskPlugin } from "../api";
@IsTask(() => {
return {
name: "EchoPlugin",
title: "测试插件回声",
input: {
cert: {
component: {
name: "output-selector",
},
helper: "输出选择",
},
},
output: {},
};
})
export class EchoPlugin extends AbstractPlugin implements TaskPlugin {
async execute(input: TaskInput): Promise<TaskOutput> {
for (const key in input) {
console.log("input :", key, input[key]);
}
return input;
}
}
@@ -0,0 +1,3 @@
export * from "./cert-plugin/index";
export * from "./echo-plugin";
export * from "./deploy-to-cdn/index";
@@ -0,0 +1,4 @@
import { Registry } from "../registry";
import { AbstractPlugin } from "./abstract-plugin";
export const pluginRegistry = new Registry<typeof AbstractPlugin>();