feat: 【破坏性更新】插件改为metadata加载模式,plugin-cert、plugin-lib包部分代码转移到certd-server中,影响自定义插件,需要修改相关import引用

ssh、aliyun、tencent、qiniu、oss等 access和client需要转移import
This commit is contained in:
xiaojunnuo
2025-12-31 17:01:37 +08:00
parent 9c26598831
commit a3fb24993d
312 changed files with 14321 additions and 597 deletions
@@ -1,5 +1,3 @@
export * from '@certd/plugin-cert';
export * from '@certd/plugin-plus';
export * from './plugin-aliyun/index.js';
export * from './plugin-tencent/index.js';
export * from './plugin-host/index.js';
@@ -43,3 +41,6 @@ export * from './plugin-cmcc/index.js'
export * from './plugin-template/index.js'
export * from './plugin-ucloud/index.js'
export * from './plugin-goedge/index.js'
export * from './plugin-lib/index.js'
export * from './plugin-plus/index.js'
export * from './plugin-cert/index.js'
@@ -2,10 +2,11 @@ import { IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipel
import fs from "fs";
import path from "path";
import dayjs from "dayjs";
import { AbstractPlusTaskPlugin } from "@certd/plugin-plus";
import { AbstractPlusTaskPlugin } from "@certd/plugin-lib";
import JSZip from "jszip";
import * as os from "node:os";
import { OssClientContext, ossClientFactory, OssClientRemoveByOpts, SshAccess, SshClient } from "@certd/plugin-lib";
import { OssClientContext, ossClientFactory, OssClientRemoveByOpts} from "../plugin-lib/oss/index.js";
import { SshAccess, SshClient } from "../plugin-lib/ssh/index.js";
import { pipeline } from "stream/promises";
const defaultBackupDir = "certd_backup";
const defaultFilePrefix = "db_backup";
@@ -1,7 +1,7 @@
import { IAccessService } from '@certd/pipeline';
import { AbstractDnsProvider, CreateRecordOptions, IsDnsProvider, RemoveRecordOptions } from '@certd/plugin-cert';
import { AliesaAccess, AliyunAccess, AliyunClientV2 } from '../../plugin-lib/aliyun/index.js';
import { AliesaAccess, AliyunAccess, AliyunClientV2 } from '@certd/plugin-lib';
@IsDnsProvider({
name: 'aliesa',
@@ -1,6 +1,7 @@
import { AbstractDnsProvider, CreateRecordOptions, IsDnsProvider, RemoveRecordOptions } from '@certd/plugin-cert';
import { AliyunAccess } from '../../plugin-lib/aliyun/access/aliyun-access.js';
import { AliyunClient } from '../../plugin-lib/aliyun/index.js';
import { AliyunAccess, AliyunClient } from '@certd/plugin-lib';
@IsDnsProvider({
name: 'aliyun',
@@ -1,2 +1,2 @@
export * from './dns-provider/index.js';
export * from './plugin/index.js';
export * from './plugin/index.js';
@@ -0,0 +1,277 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { utils } from "@certd/basic";
import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert";
import { AliyunAccess, AliyunClient } from "../../../plugin-lib/aliyun/index.js";
@IsTaskPlugin({
name: "DeployCertToAliyunAck",
title: "阿里云-部署到Ack",
icon: "svg:icon-aliyun",
desc: "部署到阿里云Ack集群Ingress等通过Secret管理证书的应用",
group: pluginGroups.aliyun.key,
needPlus: false,
input: {},
output: {},
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
})
export class DeployCertToAliyunAckPlugin extends AbstractTaskPlugin {
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames],
},
required: true,
})
cert!: CertInfo;
@TaskInput({
title: "Access授权",
helper: "阿里云授权AccessKeyId、AccessKeySecret",
component: {
name: "access-selector",
type: "aliyun",
},
required: true,
})
accessId!: string;
@TaskInput({
title: "大区",
component: {
name: "a-auto-complete",
vModel: "value",
options: [
{ value: "cn-qingdao", label: "华北1(青岛)" },
{ value: "cn-beijing", label: "华北2(北京)" },
{ value: "cn-zhangjiakou", label: "华北3(张家口)" },
{ value: "cn-huhehaote", label: "华北5(呼和浩特)" },
{ value: "cn-wulanchabu", label: "华北6(乌兰察布)" },
{ value: "cn-hangzhou", label: "华东1(杭州)" },
{ value: "cn-shanghai", label: "华东2(上海)" },
{ value: "cn-shenzhen", label: "华南1(深圳)" },
{ value: "cn-guangzhou", label: "华南3(广州)" },
{ value: "ap-southeast-2", label: "澳大利亚(悉尼)" },
{ value: "ap-southeast-3", label: "马来西亚(吉隆坡)" },
{ value: "ap-northeast-1", label: "日本(东京)" },
{ value: "cn-chengdu", label: "西南1(成都)" },
{ value: "ap-southeast-1", label: "新加坡" },
{ value: "ap-southeast-5", label: "印度尼西亚(雅加达)" },
{ value: "cn-hongkong", label: "中国香港" },
{ value: "eu-central-1", label: "德国(法兰克福)" },
{ value: "us-east-1", label: "美国(弗吉尼亚)" },
{ value: "us-west-1", label: "美国(硅谷)" },
{ value: "eu-west-1", label: "英国(伦敦)" },
{ value: "me-east-1", label: "阿联酋(迪拜)" },
//金融云
{ value: "cn-beijing-finance-1", label: "华北2 金融云(邀测)" },
{ value: "cn-hangzhou-finance", label: "华东1 金融云" },
{ value: "cn-shanghai-finance-1", label: "华东2 金融云" },
{ value: "cn-shenzhen-finance-1", label: "华南1 金融云" },
],
placeholder: "集群所属大区",
},
required: true,
})
regionId!: string;
@TaskInput({
title: "集群id",
component: {
placeholder: "集群id",
},
required: true,
})
clusterId!: string;
@TaskInput({
title: "保密字典Id",
component: {
placeholder: "保密字典Id",
},
helper: "原本存储证书的secret的name",
required: true,
})
secretName!: string | string[];
@TaskInput({
title: "命名空间",
value: "default",
component: {
placeholder: "命名空间",
},
required: true,
})
namespace = "default";
@TaskInput({
title: "是否私网ip",
value: false,
component: {
name: "a-switch",
vModel: "checked",
placeholder: "集群连接端点是否是私网ip",
},
helper: "如果您当前certd运行在同一个私网下,可以选择是。",
required: true,
})
isPrivateIpAddress!: boolean;
@TaskInput({
title: "忽略证书校验",
required: false,
helper: "是否忽略证书校验",
component: {
name: "a-switch",
vModel: "checked",
},
})
skipTLSVerify!: boolean;
@TaskInput({
title: "Secret自动创建",
helper: "如果Secret不存在,则创建",
value: false,
component: {
name: "a-switch",
vModel: "checked",
},
})
createOnNotFound: boolean;
K8sClient: any;
async onInstance() {
const sdk = await import("@certd/lib-k8s");
this.K8sClient = sdk.K8sClient;
}
async execute(): Promise<void> {
this.logger.info("开始部署证书到阿里云Ack");
const { regionId, clusterId, isPrivateIpAddress, cert } = this;
const access = (await this.getAccess(this.accessId)) as AliyunAccess;
const client = await this.getClient(access, regionId);
const kubeConfigStr = await this.getKubeConfig(client, clusterId, isPrivateIpAddress);
this.logger.info("kubeconfig已成功获取");
const k8sClient = new this.K8sClient({
kubeConfigStr,
logger: this.logger,
skipTLSVerify: this.skipTLSVerify,
});
await this.patchCertSecret({ cert, k8sClient });
await utils.sleep(5000); // 停留5秒,等待secret部署完成
try {
await this.restartIngress({ k8sClient });
} catch (e) {
this.logger.warn(`重启ingress失败:${e.message}`);
}
}
async restartIngress(options: { k8sClient: any }) {
const { k8sClient } = options;
const { namespace } = this;
const body = {
metadata: {
labels: {
certd: this.appendTimeSuffix("certd"),
},
},
};
const ingressList = await k8sClient.getIngressList({ namespace });
this.logger.info("ingressList:", ingressList);
if (!ingressList || !ingressList.items) {
return;
}
const ingressNames = ingressList.items
.filter((item: any) => {
if (!item.spec.tls) {
return false;
}
for (const tls of item.spec.tls) {
if (tls.secretName === this.secretName) {
return true;
}
}
return false;
})
.map((item: any) => {
return item.metadata.name;
});
for (const ingress of ingressNames) {
await k8sClient.patchIngress({ namespace, ingressName: ingress, body, createOnNotFound: this.createOnNotFound });
this.logger.info(`ingress已重启:${ingress}`);
}
}
async patchCertSecret(options: { cert: CertInfo; k8sClient: any }) {
const { cert, k8sClient } = options;
const crt = cert.crt;
const key = cert.key;
const crtBase64 = Buffer.from(crt).toString("base64");
const keyBase64 = Buffer.from(key).toString("base64");
const { namespace, secretName } = this;
const body = {
data: {
"tls.crt": crtBase64,
"tls.key": keyBase64,
},
metadata: {
labels: {
certd: this.appendTimeSuffix("certd"),
},
},
};
let secretNames: any = secretName;
if (typeof secretName === "string") {
secretNames = [secretName];
}
for (const secret of secretNames) {
await k8sClient.patchSecret({ namespace, secretName: secret, body });
this.logger.info(`cert secret已更新: ${secret}`);
}
}
async getClient(aliyunProvider: any, regionId: string) {
const client = new AliyunClient({ logger: this.logger, useROAClient: true });
await client.init({
accessKeyId: aliyunProvider.accessKeyId,
accessKeySecret: aliyunProvider.accessKeySecret,
endpoint: `https://cs.${regionId}.aliyuncs.com`,
apiVersion: "2015-12-15",
});
return client;
}
async getKubeConfig(client: any, clusterId: string, isPrivateIpAddress = false) {
const httpMethod = "GET";
const uriPath = `/k8s/${clusterId}/user_config`;
const queries = {
PrivateIpAddress: isPrivateIpAddress,
TemporaryDurationMinutes: 15,
};
const body = {};
const headers = {
"Content-Type": "application/json",
};
const requestOption = {};
try {
const res = await client.request(httpMethod, uriPath, queries, body, headers, requestOption);
return res.config;
} catch (e) {
console.error("请求出错:", e);
throw e;
}
}
}
new DeployCertToAliyunAckPlugin();
@@ -1,13 +1,11 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo, CertReader } from "@certd/plugin-cert";
import {
AliyunAccess,
AliyunClient,
AliyunClientV2,
AliyunSslClient,
createCertDomainGetterInputDefine,
createRemoteSelectInputDefine
} from "@certd/plugin-lib";
import { AliyunAccess, AliyunClientV2 } from "../../../plugin-lib/aliyun/access/index.js";
import { AliyunClient, AliyunSslClient } from "../../../plugin-lib/aliyun/lib/index.js";
@IsTaskPlugin({
name: "AliyunDeployCertToALB",
@@ -0,0 +1,307 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert";
import { createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from "@certd/plugin-lib";
import { AliyunAccess } from "../../../plugin-lib/aliyun/access/index.js";
import { AliyunSslClient } from "../../../plugin-lib/aliyun/lib/ssl-client.js";
@IsTaskPlugin({
name: "AliyunDeployCertToAll",
title: "阿里云-部署至任意云资源",
icon: "svg:icon-aliyun",
group: pluginGroups.aliyun.key,
desc: "【不建议使用】需要消耗阿里云自动部署次数,支持SLB、LIVE、webHosting、VOD、CR、DCDN、DDoS、CDN、ALB、APIGateway、FC、GA、MSE、NLB、OSS、SAE、WAF等云产品",
needPlus: false,
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
})
export class AliyunDeployCertToAll extends AbstractTaskPlugin {
@TaskInput({
title: "域名证书",
helper: "请选择证书申请任务输出的域名证书\n或者选择前置任务“上传证书到阿里云”任务的证书ID,可以减少上传到阿里云的证书数量",
component: {
name: "output-selector",
from: [...CertApplyPluginNames, "uploadCertToAliyun"],
},
required: true,
})
cert!: CertInfo | number;
@TaskInput(createCertDomainGetterInputDefine({ props: { required: false } }))
certDomains!: string[];
@TaskInput({
title: "接入点",
helper: "不会选就按默认",
value: "cas.aliyuncs.com",
component: {
name: "a-select",
options: [
{ value: "cas.aliyuncs.com", label: "中国大陆" },
{ value: "cas.ap-southeast-1.aliyuncs.com", label: "新加坡" },
{ value: "cas.eu-central-1.aliyuncs.com", label: "德国(法兰克福)" },
],
},
required: true,
})
endpoint!: string;
@TaskInput({
title: "Access授权",
helper: "阿里云授权AccessKeyId、AccessKeySecret",
component: {
name: "access-selector",
type: "aliyun",
},
required: true,
})
accessId!: string;
/**
* SLB:传统型负载均衡 CLB(仅中国站)
* LIVE:视频直播(仅中国站)
* webHosting:云虚拟主机(仅中国站)
* VOD:视频点播(仅中国站)
* CR:容器镜像服务(仅中国站)
* DCDN:全站加速
* DDoSDDos 防护
* CDN:内容分发网络
* ALB:应用负载均衡
* APIGatewayAPI 网关
* FC:函数计算
* GA:全球加速
* MSE:微服务引擎
* NLB:网络型负载均衡
* OSS:对象存储
* SAEServerless 应用引擎
* WAFWeb 应用防火墙
*/
@TaskInput({
title: "云产品类型",
helper: "请选择云产品类型",
component: {
name: "a-select",
vModel: "value",
options: [
{ value: "SLB", label: "SLB-传统型负载均衡 CLB(仅中国站)" },
{ value: "LIVE", label: "LIVE-视频直播(仅中国站)" },
{ value: "webHosting", label: "webHosting-云虚拟主机(仅中国站)" },
{ value: "VOD", label: "VOD-视频点播(仅中国站)" },
{ value: "CR", label: "CR-容器镜像服务(仅中国站)" },
{ value: "DCDN", label: "DCDN-全站加速" },
{ value: "DDoS", label: "DDos 防护" },
{ value: "CDN", label: "CDN-内容分发网络" },
{ value: "ALB", label: "ALB-应用负载均衡" },
{ value: "APIGateway", label: "APIGateway-API 网关" },
{ value: "FC", label: "FC-函数计算" },
{ value: "GA", label: "GA-全球加速" },
{ value: "MSE", label: "MSE-微服务引擎" },
{ value: "NLB", label: "NLB-网络型负载均衡" },
{ value: "OSS", label: "OSS-对象存储" },
{ value: "SAE", label: "SAE-Serverless应用引擎" },
{ value: "WAF", label: "WAF-Web应用防火墙" },
],
},
required: true,
})
cloudProduct!: string;
@TaskInput(
createRemoteSelectInputDefine({
title: "要部署证书的云产品",
helper: "请选择要部署证书的云产品,注意:新创建的云产品资源可能需要过1-2小时才会在此处显示",
typeName: "AliyunDeployCertToAll",
action: AliyunDeployCertToAll.prototype.onGetProductList.name,
watches: ["cloudProduct", "accessId"],
})
)
productIds!: string[];
@TaskInput(
createRemoteSelectInputDefine({
title: "联系人",
helper: "请选择联系人,如果没有,需要先到[阿里云控制台创建联系人](https://yundun.console.aliyun.com/?p=cas#/informationManagement/person/)",
typeName: "AliyunDeployCertToAll",
action: AliyunDeployCertToAll.prototype.onGetContactList.name,
})
)
contactIds!: string[];
@TaskInput({
title: "检查超时时间",
helper: "检查部署任务超时时间,单位分钟",
value: 10,
component: {
name: "a-input-number",
vModel: "value",
},
required: true,
})
checkTimeout!: number;
async onInstance() {}
async execute(): Promise<void> {
this.logger.info("开始部署证书到阿里云");
const access = await this.getAccess<AliyunAccess>(this.accessId);
const sslClient = new AliyunSslClient({
access,
logger: this.logger,
endpoint: this.endpoint,
});
//
let certId: any = this.cert;
if (typeof this.cert === "object") {
certId = await sslClient.uploadCert({
name: this.appendTimeSuffix("certd"),
cert: this.cert,
});
}
const jobId = await this.createDeployJob(sslClient, certId);
await this.updateJobStatus(sslClient, jobId, "scheduling");
this.logger.info("开始检查部署任务执行结果");
const startTime = Date.now();
while (Date.now() < startTime + this.checkTimeout * 60 * 1000) {
this.checkSignal();
await this.ctx.utils.sleep(10000);
let res: any = {};
try {
res = await this.getJobDetail(sslClient, jobId);
} catch (e: any) {
this.logger.error(e);
break;
}
const status = res.Status;
if (status == "success") {
this.logger.info("部署任务执行成功:", status);
return;
} else if (status == "error") {
this.logger.error(`部署任务执行失败,请前往 https://yundun.console.aliyun.com/?p=cas#/deployDetail/user/${jobId} 查看失败原因: `, res);
throw new Error("部署任务执行失败,");
} else {
/**
* pending:待执行
* editing:编辑中
* scheduling:调度中
* processing:部署中
* error:部署失败
* success:部署成功
*/
this.logger.info("部署任务正在执行中: ", status);
}
}
throw new Error("部署任务执行超时,请手动检查任务状态");
}
async updateJobStatus(sslClient: AliyunSslClient, jobId: string, status: string) {
const params = {
JobId: jobId,
Status: status,
};
const requestOption = {
method: "POST",
formatParams: false,
};
const res = await sslClient.doRequest("UpdateDeploymentJobStatus", params, requestOption);
this.logger.info("部署任务开始执行,部署需要时间,RequestId=", res.RequestId);
}
async onGetProductList(data: any) {
if (!this.accessId) {
throw new Error("请选择Access授权");
}
const access = await this.getAccess<AliyunAccess>(this.accessId);
const sslClient = new AliyunSslClient({
access,
logger: this.logger,
endpoint: this.endpoint,
});
if (!this.cloudProduct) {
throw new Error("请选择云产品类型");
}
const res = await sslClient.getResourceList({
cloudProduct: this.cloudProduct,
});
if (!res?.Data || res?.Data.length === 0) {
throw new Error("没有找到对应类型的云资源");
}
const options = res.Data.map((item: any) => {
return {
label: `${item.Domain}<${item.Id}>`,
value: item.Id,
title: `${item.CloudProduct}:${item.CertName || "证书未命名"}`,
domain: item.Domain,
};
});
return this.ctx.utils.options.buildGroupOptions(options, this.certDomains);
}
async onGetContactList(data: any) {
if (!this.accessId) {
throw new Error("请选择Access授权");
}
const access = await this.getAccess<AliyunAccess>(this.accessId);
const sslClient = new AliyunSslClient({
access,
logger: this.logger,
endpoint: this.endpoint,
});
const res = await sslClient.getContactList();
/*
"Email": "@qq.com",
"EmailStatus": 0,
"MobileStatus": 0,
"ContactId": 378992,
"Mobile": "",
"Name": ""
*/
if (!res?.ContactList || res?.ContactList.length === 0) {
throw new Error("没有找到联系人");
}
return res.ContactList.map((item: any) => {
return {
label: `${item.Name}<${item.Email}:${item.ContactId}>`,
value: item.ContactId,
};
});
}
async getJobDetail(sslClient: AliyunSslClient, jobId: number) {
const params = {
JobId: jobId,
};
const requestOption = {
method: "POST",
formatParams: false,
};
return await sslClient.doRequest("DescribeDeploymentJob", params, requestOption);
}
private async createDeployJob(sslClient: AliyunSslClient, certId: any) {
const res = await sslClient.createDeploymentJob({
name: "自动部署证书(By Certd)",
jobType: "user",
contactIds: this.contactIds,
resourceIds: this.productIds,
certIds: [certId],
});
const jobId = res.JobId;
this.logger.info("部署任务创建成功: jobId=", jobId);
return jobId;
}
}
new AliyunDeployCertToAll();
@@ -1,10 +1,10 @@
import {AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput} from '@certd/pipeline';
import {
AliyunAccess,
AliyunSslClient,
createCertDomainGetterInputDefine,
createRemoteSelectInputDefine
} from "@certd/plugin-lib";
import { AliyunAccess } from "../../../plugin-lib/aliyun/access/index.js";
import { AliyunSslClient } from "../../../plugin-lib/aliyun/lib/ssl-client.js";
import { CertApplyPluginNames, CertInfo, CertReader } from "@certd/plugin-cert";
import {optionsUtils} from "@certd/basic";
@@ -1,7 +1,8 @@
import {AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput} from '@certd/pipeline';
import {AliyunAccess, createCertDomainGetterInputDefine, createRemoteSelectInputDefine} from "@certd/plugin-lib";
import { createCertDomainGetterInputDefine, createRemoteSelectInputDefine} from "@certd/plugin-lib";
import {CertApplyPluginNames, CertInfo} from '@certd/plugin-cert';
import {optionsUtils} from "@certd/basic";
import { AliyunAccess } from "../../../plugin-lib/aliyun/access/index.js";
@IsTaskPlugin({
name: 'DeployCertToAliyunApiGateway',
@@ -1,7 +1,9 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from '@certd/pipeline';
import { AliyunAccess, AliyunClient, AliyunSslClient, createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from '@certd/plugin-lib';
import { createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from '@certd/plugin-lib';
import { AliyunAccess } from "../../../plugin-lib/aliyun/access/index.js";
import { optionsUtils } from '@certd/basic';
import { CertApplyPluginNames, CertReader } from "@certd/plugin-cert";
import { AliyunClient, AliyunSslClient } from "../../../plugin-lib/aliyun/lib/index.js";
@IsTaskPlugin({
name: 'DeployCertToAliyunCDN',
title: '阿里云-部署证书至CDN',
@@ -1,14 +1,15 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from '@certd/pipeline';
import dayjs from 'dayjs';
import {
AliyunAccess,
AliyunClient,
createCertDomainGetterInputDefine,
createRemoteSelectInputDefine
} from "@certd/plugin-lib";
import { AliyunAccess } from "../../../plugin-lib/aliyun/access/index.js";
import { CertInfo } from '@certd/plugin-cert';
import { CertApplyPluginNames} from '@certd/plugin-cert';
import { optionsUtils } from "@certd/basic";
import { AliyunClient } from "../../../plugin-lib/aliyun/lib/index.js";
@IsTaskPlugin({
name: 'DeployCertToAliyunDCDN',
title: '阿里云-部署证书至DCDN',
@@ -1,11 +1,11 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo, CertReader } from "@certd/plugin-cert";
import {
AliyunAccess, AliyunClientV2,
AliyunSslClient,
createCertDomainGetterInputDefine,
createRemoteSelectInputDefine
} from "@certd/plugin-lib";
import { AliyunAccess, AliyunClientV2 } from "../../../plugin-lib/aliyun/access/index.js";
import { AliyunSslClient } from "../../../plugin-lib/aliyun/lib/ssl-client.js";
@IsTaskPlugin({
name: "AliyunDeployCertToESA",
@@ -1,10 +1,11 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo, CertReader } from "@certd/plugin-cert";
import { AliyunAccess, createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from "@certd/plugin-lib";
import { createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from "@certd/plugin-lib";
import fs from "fs";
import path from "path";
import { tmpdir } from "node:os";
import { sp } from "@certd/basic";
import { AliyunAccess } from "../../../plugin-lib/aliyun/access/index.js";
@IsTaskPlugin({
name: 'AliyunDeployCertToFC',
@@ -1,14 +1,12 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from '@certd/pipeline';
import { CertInfo, CertReader } from "@certd/plugin-cert";
import {
AliyunAccess,
AliyunClient,
AliyunClientV2,
AliyunSslClient,
createCertDomainGetterInputDefine,
createRemoteSelectInputDefine
} from "@certd/plugin-lib";
import { CertApplyPluginNames} from '@certd/plugin-cert';
import { AliyunAccess, AliyunClientV2 } from "../../../plugin-lib/aliyun/access/index.js";
import { AliyunClient, AliyunSslClient } from "../../../plugin-lib/aliyun/lib/index.js";
@IsTaskPlugin({
name: 'AliyunDeployCertToNLB',
title: '阿里云-部署至NLB(网络负载均衡)',
@@ -1,7 +1,5 @@
import {AbstractTaskPlugin, IsTaskPlugin, Pager, pluginGroups, RunStrategy, TaskInput} from '@certd/pipeline';
import {
AliyunAccess,
AliyunSslClient,
createCertDomainGetterInputDefine,
createRemoteSelectInputDefine
} from '@certd/plugin-lib';
@@ -9,6 +7,8 @@ import {CertInfo, CertReader} from '@certd/plugin-cert';
import { CertApplyPluginNames} from '@certd/plugin-cert';
import {optionsUtils} from "@certd/basic";
import {isArray} from "lodash-es";
import { AliyunAccess } from '../../../plugin-lib/aliyun/access/index.js';
import { AliyunSslClient } from '../../../plugin-lib/aliyun/lib/index.js';
@IsTaskPlugin({
name: 'DeployCertToAliyunOSS',
title: '阿里云-部署证书至OSS',
@@ -1,14 +1,12 @@
import {AbstractTaskPlugin, IsTaskPlugin, PageSearch, pluginGroups, RunStrategy, TaskInput} from '@certd/pipeline';
import {CertInfo} from '@certd/plugin-cert';
import {
AliyunAccess,
AliyunClient,
AliyunSslClient,
CasCertInfo,
createCertDomainGetterInputDefine,
createRemoteSelectInputDefine
} from '@certd/plugin-lib';
import {CertApplyPluginNames} from '@certd/plugin-cert';
import { AliyunAccess } from '../../../plugin-lib/aliyun/access/index.js';
import { AliyunClient, AliyunSslClient, CasCertInfo } from '../../../plugin-lib/aliyun/lib/index.js';
@IsTaskPlugin({
name: 'AliyunDeployCertToSLB',
@@ -1,6 +1,7 @@
import { AbstractTaskPlugin, IsTaskPlugin, PageSearch, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert";
import { AliyunAccess, createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from "@certd/plugin-lib";
import { createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from "@certd/plugin-lib";
import { AliyunAccess } from "../../../plugin-lib/aliyun/access/index.js";
@IsTaskPlugin({
name: "AliyunDeployCertToVod",
@@ -1,12 +1,11 @@
import { AbstractTaskPlugin, IsTaskPlugin, Pager,PageSearch, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo, CertReader } from "@certd/plugin-cert";
import {
AliyunAccess,
AliyunClient,
AliyunSslClient,
createCertDomainGetterInputDefine,
createRemoteSelectInputDefine
} from "@certd/plugin-lib";
import { AliyunAccess } from "../../../plugin-lib/aliyun/access/index.js";
import { AliyunClient, AliyunSslClient } from "../../../plugin-lib/aliyun/lib/index.js";
@IsTaskPlugin({
name: 'AliyunDeployCertToWaf',
@@ -11,3 +11,4 @@ export * from './deploy-to-esa/index.js';
export * from './deploy-to-vod/index.js';
export * from './deploy-to-apigateway/index.js';
export * from './deploy-to-apig/index.js';
export * from './deploy-to-ack/index.js';
@@ -1,7 +1,7 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput, TaskOutput } from '@certd/pipeline';
import { AliyunAccess } from '@certd/plugin-lib';
import { AliyunSslClient } from '@certd/plugin-lib';
import { CertApplyPluginNames, CertReader } from "@certd/plugin-cert";
import { AliyunAccess } from '../../../plugin-lib/aliyun/access/index.js';
import { AliyunSslClient } from '../../../plugin-lib/aliyun/lib/index.js';
/**
* 华东1(杭州) cn-hangzhou cas.aliyuncs.com cas-vpc.cn-hangzhou.aliyuncs.com
* 马来西亚(吉隆坡) ap-southeast-3 cas.ap-southeast-3.aliyuncs.com cas-vpc.ap-southeast-3.aliyuncs.com
@@ -1,8 +1,7 @@
import {IsTaskPlugin, PageSearch, pluginGroups, RunStrategy, TaskInput} from "@certd/pipeline";
import {CertApplyPluginNames, CertInfo} from "@certd/plugin-cert";
import {createCertDomainGetterInputDefine, createRemoteSelectInputDefine} from "@certd/plugin-lib";
import {AbstractPlusTaskPlugin, createCertDomainGetterInputDefine, createRemoteSelectInputDefine} from "@certd/plugin-lib";
import {ApisixAccess} from "../access.js";
import {AbstractPlusTaskPlugin} from "@certd/plugin-plus";
@IsTaskPlugin({
//命名规范,插件类型+功能(就是目录plugin-demo中的demo),大写字母开头,驼峰命名
@@ -1,6 +1,6 @@
import { AddonInput, BaseAddon, IsAddon } from "@certd/lib-server";
import { ICaptchaAddon } from "../api.js";
import { TencentAccess } from "@certd/plugin-lib";
import { TencentAccess } from "../../plugin-lib/tencent/access.js";
@IsAddon({
addonType: "captcha",
@@ -0,0 +1,43 @@
import { IsAccess, AccessInput, BaseAccess } from "@certd/pipeline";
@IsAccess({
name: "eab",
title: "EAB授权",
desc: "ZeroSSL证书申请需要EAB授权",
icon: "ic:outline-lock",
})
export class EabAccess extends BaseAccess {
@AccessInput({
title: "KID",
component: {
placeholder: "kid / keyId",
},
helper: "EAB KID google的叫 keyIdssl.com的叫Account/ACME Key",
required: true,
encrypt: true,
})
kid = "";
@AccessInput({
title: "HMACKey",
component: {
placeholder: "HMAC Key / b64MacKey",
},
helper: "EAB HMAC Key google的叫b64MacKey",
required: true,
encrypt: true,
})
hmacKey = "";
@AccessInput({
title: "email",
component: {
placeholder: "绑定一个邮箱",
},
rules: [{ type: "email", message: "请输入正确的邮箱" }],
helper: "Google的EAB申请证书,更换邮箱会导致EAB失效,可以在此处绑定一个邮箱避免此问题",
required: true,
})
email = "";
}
new EabAccess();
@@ -0,0 +1,98 @@
import { IsAccess, AccessInput, BaseAccess } from "@certd/pipeline";
@IsAccess({
name: "google",
title: "google cloud",
desc: "谷歌云授权",
icon: "flat-color-icons:google",
})
export class GoogleAccess extends BaseAccess {
@AccessInput({
title: "密钥类型",
value: "serviceAccount",
component: {
placeholder: "密钥类型",
name: "a-select",
vModel: "value",
options: [
{ value: "serviceAccount", label: "服务账号密钥" },
{ value: "apiKey", label: "ApiKey,暂不可用", disabled: true },
],
},
helper: "密钥类型",
required: true,
encrypt: false,
})
type = "";
@AccessInput({
title: "项目ID",
component: {
placeholder: "ProjectId",
},
helper: "ProjectId",
required: true,
encrypt: false,
mergeScript: `
return {
show:ctx.compute(({form})=>{
return form.access.type === 'apiKey'
})
}
`,
})
projectId = "";
@AccessInput({
title: "ApiKey",
component: {
placeholder: "ApiKey",
},
helper: "不要选,目前没有用",
required: true,
encrypt: true,
mergeScript: `
return {
show:ctx.compute(({form})=>{
return form.access.type === 'apiKey'
})
}
`,
})
apiKey = "";
@AccessInput({
title: "服务账号密钥",
component: {
placeholder: "serviceAccountSecret",
name: "a-textarea",
vModel: "value",
rows: 4,
},
helper:
"[如何创建服务账号](https://cloud.google.com/iam/docs/service-accounts-create?hl=zh-CN) \n[获取密钥](https://console.cloud.google.com/iam-admin/serviceaccounts?hl=zh-cn),点击详情,点击创建密钥,将下载json文件,把内容填在此处",
required: true,
encrypt: true,
mergeScript: `
return {
show:ctx.compute(({form})=>{
return form.access.type === 'serviceAccount'
})
}
`,
})
serviceAccountSecret = "";
@AccessInput({
title: "https代理",
component: {
placeholder: "http://127.0.0.1:10811",
},
helper: "Google的请求需要走代理,如果不配置,则会使用环境变量中的全局HTTPS_PROXY配置\n或者服务器本身在海外,则不需要配置",
required: false,
encrypt: false,
})
httpsProxy = "";
}
new GoogleAccess();
@@ -0,0 +1,2 @@
export * from "./eab-access.js";
export * from "./google-access.js";
@@ -0,0 +1,2 @@
export * from "./access/index.js";
export * from "./plugin/index.js";
@@ -0,0 +1,62 @@
import { EabAccess, GoogleAccess } from "../access/index.js";
import { ILogger } from "@certd/basic";
export class GoogleClient {
access: GoogleAccess;
logger: ILogger;
constructor(opts: { logger: ILogger; access: GoogleAccess }) {
this.access = opts.access;
this.logger = opts.logger;
}
async getEab() {
// https://cloud.google.com/docs/authentication/api-keys-use#using-with-client-libs
const { v1 } = await import("@google-cloud/publicca");
// process.env.HTTPS_PROXY = "http://127.0.0.1:10811";
const access = this.access;
if (!access.serviceAccountSecret) {
throw new Error("服务账号密钥 不能为空");
}
const credentials = JSON.parse(access.serviceAccountSecret);
const client = new v1.PublicCertificateAuthorityServiceClient({ credentials });
const parent = `projects/${credentials.project_id}/locations/global`;
const externalAccountKey = {};
const request = {
parent,
externalAccountKey,
};
let envHttpsProxy = "";
try {
if (this.access.httpsProxy) {
//设置临时使用代理
envHttpsProxy = process.env.HTTPS_PROXY;
process.env.HTTPS_PROXY = this.access.httpsProxy;
}
this.logger.info("开始获取google eab授权");
const response = await client.createExternalAccountKey(request);
const { keyId, b64MacKey } = response[0];
const eabAccess = new EabAccess();
eabAccess.kid = keyId;
eabAccess.hmacKey = b64MacKey.toString();
this.logger.info(`google eab授权获取成功,kid: ${eabAccess.kid}`);
return eabAccess;
} finally {
if (envHttpsProxy) {
process.env.HTTPS_PROXY = envHttpsProxy;
}
}
}
}
// const access = new GoogleAccess();
// access.projectId = "hip-light-432411-d4";
// access.serviceAccountSecret = `
//
//
// `;
// // process.env.HTTPS_PROXY = "http://127.0.0.1:10811";
// const client = new GoogleClient(access);
// client.getEab().catch((e) => {
// console.error(e);
// });
@@ -0,0 +1,462 @@
// @ts-ignore
import * as acme from "@certd/acme-client";
import { ClientExternalAccountBindingOptions, UrlMapping } from "@certd/acme-client";
import * as _ from "lodash-es";
import { Challenge } from "@certd/acme-client/types/rfc8555.js";
import { IContext } from "@certd/pipeline";
import { ILogger, utils } from "@certd/basic";
import punycode from "punycode.js";
import { IOssClient } from "../../../plugin-lib/index.js";
import { IDnsProvider, IDomainParser } from "@certd/plugin-lib";
export type CnameVerifyPlan = {
type?: string;
domain: string;
fullRecord: string;
dnsProvider: IDnsProvider;
};
export type HttpVerifyPlan = {
type: string;
domain: string;
httpUploader: IOssClient;
};
export type DomainVerifyPlan = {
domain: string;
mainDomain: string;
type: "cname" | "dns" | "http";
dnsProvider?: IDnsProvider;
cnameVerifyPlan?: CnameVerifyPlan;
httpVerifyPlan?: HttpVerifyPlan;
};
export type DomainsVerifyPlan = {
[key: string]: DomainVerifyPlan;
};
export type Providers = {
dnsProvider?: IDnsProvider;
domainsVerifyPlan?: DomainsVerifyPlan;
};
export type CertInfo = {
crt: string; //fullchain证书
key: string; //私钥
csr: string; //csr
oc?: string; //仅证书,非fullchain证书
ic?: string; //中间证书
pfx?: string;
der?: string;
jks?: string;
one?: string;
p7b?: string;
};
export type SSLProvider = "letsencrypt" | "google" | "zerossl" | "sslcom" | "letsencrypt_staging";
export type PrivateKeyType = "rsa_1024" | "rsa_2048" | "rsa_3072" | "rsa_4096" | "ec_256" | "ec_384" | "ec_521";
type AcmeServiceOptions = {
userContext: IContext;
logger: ILogger;
sslProvider: SSLProvider;
eab?: ClientExternalAccountBindingOptions;
skipLocalVerify?: boolean;
useMappingProxy?: boolean;
reverseProxy?: string;
privateKeyType?: PrivateKeyType;
signal?: AbortSignal;
maxCheckRetryCount?: number;
userId: number;
domainParser: IDomainParser;
waitDnsDiffuseTime?: number;
};
export class AcmeService {
options: AcmeServiceOptions;
userContext: IContext;
logger: ILogger;
sslProvider: SSLProvider;
skipLocalVerify = true;
eab?: ClientExternalAccountBindingOptions;
constructor(options: AcmeServiceOptions) {
this.options = options;
this.userContext = options.userContext;
this.logger = options.logger;
this.sslProvider = options.sslProvider || "letsencrypt";
this.eab = options.eab;
this.skipLocalVerify = options.skipLocalVerify ?? false;
// acme.setLogger((message: any, ...args: any[]) => {
// this.logger.info(message, ...args);
// });
}
async getAccountConfig(email: string, urlMapping: UrlMapping): Promise<any> {
const conf = (await this.userContext.getObj(this.buildAccountKey(email))) || {};
if (urlMapping && urlMapping.mappings) {
for (const key in urlMapping.mappings) {
if (Object.prototype.hasOwnProperty.call(urlMapping.mappings, key)) {
const element = urlMapping.mappings[key];
if (conf.accountUrl?.indexOf(element) > -1) {
//如果用了代理url,要替换回去
conf.accountUrl = conf.accountUrl.replace(element, key);
}
}
}
}
return conf;
}
buildAccountKey(email: string) {
return `acme.config.${this.sslProvider}.${email}`;
}
async saveAccountConfig(email: string, conf: any) {
await this.userContext.setObj(this.buildAccountKey(email), conf);
}
async getAcmeClient(email: string): Promise<acme.Client> {
const mappings = {};
if (this.sslProvider === "letsencrypt") {
mappings["acme-v02.api.letsencrypt.org"] = this.options.reverseProxy || "le.px.certd.handfree.work";
} else if (this.sslProvider === "google") {
mappings["dv.acme-v02.api.pki.goog"] = this.options.reverseProxy || "gg.px.certd.handfree.work";
}
const urlMapping: UrlMapping = {
enabled: false,
mappings,
};
const conf = await this.getAccountConfig(email, urlMapping);
if (conf.key == null) {
conf.key = await this.createNewKey();
await this.saveAccountConfig(email, conf);
this.logger.info(`创建新的Accountkey:${email}`);
}
const directoryUrl = acme.getDirectoryUrl({ sslProvider: this.sslProvider, pkType: this.options.privateKeyType });
if (this.options.useMappingProxy) {
urlMapping.enabled = true;
} else {
//测试directory是否可以访问
const isOk = await this.testDirectory(directoryUrl);
if (!isOk) {
this.logger.info("测试访问失败,自动使用代理");
urlMapping.enabled = true;
}
}
const client = new acme.Client({
sslProvider: this.sslProvider,
directoryUrl: directoryUrl,
accountKey: conf.key,
accountUrl: conf.accountUrl,
externalAccountBinding: this.eab,
backoffAttempts: this.options.maxCheckRetryCount || 20,
backoffMin: 5000,
backoffMax: 10000,
urlMapping,
signal: this.options.signal,
logger: this.logger,
});
if (conf.accountUrl == null) {
const accountPayload = {
termsOfServiceAgreed: true,
contact: [`mailto:${email}`],
externalAccountBinding: this.eab,
};
await client.createAccount(accountPayload);
conf.accountUrl = client.getAccountUrl();
await this.saveAccountConfig(email, conf);
}
return client;
}
async createNewKey() {
const key = await acme.crypto.createPrivateKey(2048);
return key.toString();
}
async challengeCreateFn(authz: any, keyAuthorizationGetter: (challenge: Challenge) => Promise<string>, providers: Providers) {
this.logger.info("Triggered challengeCreateFn()");
const fullDomain = authz.identifier.value;
let domain = await this.options.domainParser.parse(fullDomain);
this.logger.info("主域名为:" + domain);
const getChallenge = (type: string) => {
return authz.challenges.find((c: any) => c.type === type);
};
const doHttpVerify = async (challenge: any, httpUploader: IOssClient) => {
const keyAuthorization = await keyAuthorizationGetter(challenge);
this.logger.info("http校验");
const filePath = `.well-known/acme-challenge/${challenge.token}`;
const fileContents = keyAuthorization;
this.logger.info(`校验 ${fullDomain} ,准备上传文件:${filePath}`);
await httpUploader.upload(filePath, Buffer.from(fileContents));
this.logger.info(`上传文件【${filePath}】成功`);
return {
challenge,
keyAuthorization,
httpUploader,
};
};
const doDnsVerify = async (challenge: any, fullRecord: string, dnsProvider: IDnsProvider) => {
this.logger.info("dns校验");
const keyAuthorization = await keyAuthorizationGetter(challenge);
const mainDomain = dnsProvider.usePunyCode() ? domain : punycode.toUnicode(domain);
fullRecord = dnsProvider.usePunyCode() ? fullRecord : punycode.toUnicode(fullRecord);
const recordValue = keyAuthorization;
let hostRecord = fullRecord.replace(`${mainDomain}`, "");
if (hostRecord.endsWith(".")) {
hostRecord = hostRecord.substring(0, hostRecord.length - 1);
}
const recordReq = {
domain: mainDomain,
fullRecord,
hostRecord,
type: "TXT",
value: recordValue,
};
this.logger.info("添加 TXT 解析记录", JSON.stringify(recordReq));
const recordRes = await dnsProvider.createRecord(recordReq);
this.logger.info("添加 TXT 解析记录成功", JSON.stringify(recordRes));
return {
recordReq,
recordRes,
dnsProvider,
challenge,
keyAuthorization,
};
};
let dnsProvider = providers.dnsProvider;
let fullRecord = `_acme-challenge.${fullDomain}`;
// const origDomain = punycode.toUnicode(domain);
const origFullDomain = punycode.toUnicode(fullDomain);
const isIp = utils.domain.isIp(origFullDomain);
function checkIpChallenge(type: string) {
if (isIp) {
throw new Error(`IP证书不支持${type}校验方式,请选择HTTP方式校验`);
}
}
if (providers.domainsVerifyPlan) {
//按照计划执行
const domainVerifyPlan = providers.domainsVerifyPlan[origFullDomain];
if (domainVerifyPlan) {
if (domainVerifyPlan.type === "dns") {
checkIpChallenge("dns");
dnsProvider = domainVerifyPlan.dnsProvider;
} else if (domainVerifyPlan.type === "cname") {
checkIpChallenge("cname");
const cname: CnameVerifyPlan = domainVerifyPlan.cnameVerifyPlan;
if (cname) {
dnsProvider = cname.dnsProvider;
domain = await this.options.domainParser.parse(cname.domain);
fullRecord = cname.fullRecord;
} else {
this.logger.error(`未找到域名${fullDomain}的CNAME校验计划,请修改证书申请配置`);
}
if (dnsProvider == null) {
throw new Error(`未找到域名${fullDomain}CNAME校验计划的DnsProvider,请修改证书申请配置`);
}
} else if (domainVerifyPlan.type === "http") {
const plan: HttpVerifyPlan = domainVerifyPlan.httpVerifyPlan;
if (plan) {
const httpChallenge = getChallenge("http-01");
if (httpChallenge == null) {
throw new Error("该域名不支持http-01方式校验");
}
return await doHttpVerify(httpChallenge, plan.httpUploader);
} else {
throw new Error("未找到域名【" + fullDomain + "】的http校验配置");
}
} else {
throw new Error("不支持的校验类型", domainVerifyPlan.type);
}
} else {
this.logger.warn(`未找到域名${fullDomain}的校验计划,使用默认的dnsProvider`);
}
}
if (!dnsProvider) {
throw new Error(`域名${fullDomain}没有匹配到任何校验方式,证书申请失败`);
}
const dnsChallenge = getChallenge("dns-01");
checkIpChallenge("dns");
return await doDnsVerify(dnsChallenge, fullRecord, dnsProvider);
}
/**
* Function used to remove an ACME challenge response
*
* @param {object} authz Authorization object
* @param {object} challenge Selected challenge
* @param {string} keyAuthorization Authorization key
* @param recordReq
* @param recordRes
* @param dnsProvider dnsProvider
* @param httpUploader
* @returns {Promise}
*/
async challengeRemoveFn(authz: any, challenge: any, keyAuthorization: string, recordReq: any, recordRes: any, dnsProvider?: IDnsProvider, httpUploader?: IOssClient) {
this.logger.info("执行清理");
/* http-01 */
const fullDomain = authz.identifier.value;
if (challenge.type === "http-01") {
const filePath = `.well-known/acme-challenge/${challenge.token}`;
this.logger.info(`Removing challenge response for ${fullDomain} at file: ${filePath}`);
await httpUploader.remove(filePath);
this.logger.info(`删除文件【${filePath}】成功`);
} else if (challenge.type === "dns-01") {
this.logger.info(`删除 TXT 解析记录:${JSON.stringify(recordReq)} ,recordRes = ${JSON.stringify(recordRes)}`);
try {
await dnsProvider.removeRecord({
recordReq,
recordRes,
});
this.logger.info("删除解析记录成功");
} catch (e) {
this.logger.error("删除解析记录出错:", e);
throw e;
}
}
}
async order(options: {
email: string;
domains: string | string[];
dnsProvider?: any;
domainsVerifyPlan?: DomainsVerifyPlan;
httpUploader?: any;
csrInfo: any;
privateKeyType?: string;
profile?: string;
preferredChain?: string;
}): Promise<CertInfo> {
const { email, csrInfo, dnsProvider, domainsVerifyPlan, profile, preferredChain } = options;
const client: acme.Client = await this.getAcmeClient(email);
let domains = options.domains;
const encodingDomains = [];
for (const domain of domains) {
encodingDomains.push(punycode.toASCII(domain));
}
domains = encodingDomains;
/* Create CSR */
const { altNames } = this.buildCommonNameByDomains(domains);
let privateKey = null;
const privateKeyType = options.privateKeyType || "rsa_2048";
const privateKeyArr = privateKeyType.split("_");
const type = privateKeyArr[0];
let size = 2048;
if (privateKeyArr.length > 1) {
size = parseInt(privateKeyArr[1]);
}
let encodingType = "pkcs8";
if (privateKeyArr.length > 2) {
encodingType = privateKeyArr[2];
}
if (type == "ec") {
const name: any = "P-" + size;
privateKey = await acme.crypto.createPrivateEcdsaKey(name, encodingType);
} else {
privateKey = await acme.crypto.createPrivateRsaKey(size, encodingType);
}
let createCsr: any = acme.crypto.createCsr;
if (encodingType === "pkcs1") {
//兼容老版本
createCsr = acme.forge.createCsr;
}
const csrData: any = {
// commonName,
...csrInfo,
altNames,
// emailAddress: email,
};
const [key, csr] = await createCsr(csrData, privateKey);
if (dnsProvider == null && domainsVerifyPlan == null) {
throw new Error("dnsProvider 、 domainsVerifyPlan不能都为空");
}
const providers: Providers = {
dnsProvider,
domainsVerifyPlan,
};
/* 自动申请证书 */
const crt = await client.auto({
csr,
email: email,
termsOfServiceAgreed: true,
skipChallengeVerification: this.skipLocalVerify,
challengePriority: ["dns-01", "http-01"],
challengeCreateFn: async (
authz: acme.Authorization,
keyAuthorizationGetter: (challenge: Challenge) => Promise<string>
): Promise<{ recordReq?: any; recordRes?: any; dnsProvider?: any; challenge: Challenge; keyAuthorization: string }> => {
return await this.challengeCreateFn(authz, keyAuthorizationGetter, providers);
},
challengeRemoveFn: async (authz: acme.Authorization, challenge: Challenge, keyAuthorization: string, recordReq: any, recordRes: any, dnsProvider: IDnsProvider, httpUploader: IOssClient): Promise<any> => {
return await this.challengeRemoveFn(authz, challenge, keyAuthorization, recordReq, recordRes, dnsProvider, httpUploader);
},
signal: this.options.signal,
profile,
preferredChain,
});
const crtString = crt.toString();
const cert: CertInfo = {
crt: crtString,
key: key.toString(),
csr: csr.toString(),
};
/* Done */
this.logger.debug(`CSR:\n${cert.csr}`);
this.logger.debug(`Certificate:\n${cert.crt}`);
this.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: domains,
};
}
private async testDirectory(directoryUrl: string) {
try {
await utils.http.request({
url: directoryUrl,
method: "GET",
timeout: 10000,
});
} catch (e) {
this.logger.error(`${directoryUrl},测试访问失败`, e.message);
return false;
}
this.logger.info(`${directoryUrl},测试访问成功`);
return true;
}
}
@@ -0,0 +1,211 @@
import { AbstractTaskPlugin, FileItem, IContext, Step, TaskInput, TaskOutput } from "@certd/pipeline";
import dayjs from "dayjs";
import type { CertInfo } from "./acme.js";
import { CertReader,CertConverter, EVENT_CERT_APPLY_SUCCESS } from "@certd/plugin-lib";
import JSZip from "jszip";
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、输入一个,空格之后,再输入下一个 \n" +
"5、如果设置了子域托管解析(比如免费的二级域名托管在CF或者阿里云),请先[设置托管子域名](#/certd/pipeline/subDomain)",
})
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: "域名证书",
type: "cert",
})
cert?: CertInfo;
@TaskOutput({
title: "域名证书压缩文件",
type: "certZip",
})
certZip?: FileItem;
async onInstance() {
this.userContext = this.ctx.userContext;
this.lastStatus = this.ctx.lastStatus as Step;
await this.onInit();
}
abstract onInit(): Promise<void>;
//必须output之后执行
async emitCertApplySuccess() {
const emitter = this.ctx.emitter;
const value = {
cert: this.cert,
file: this._result.files[0].path,
};
await emitter.emit(EVENT_CERT_APPLY_SUCCESS, value);
}
async output(certReader: CertReader, isNew: boolean) {
const cert: CertInfo = certReader.toCertInfo();
this.cert = cert;
this._result.pipelineVars.certEffectiveTime = dayjs(certReader.detail.notBefore).valueOf();
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;
}
if (cert.p7b == null && res.p7b) {
cert.p7b = res.p7b;
}
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();
}
this.certZip = this._result.files[0];
}
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);
zip.file("cert.p7b", cert.p7b);
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;
}
async readLastCert(): Promise<CertReader | undefined> {
const cert = this.lastStatus?.status?.output?.cert;
if (cert == null) {
this.logger.info("没有找到上次的证书");
return undefined;
}
return new CertReader(cert);
}
}
@@ -0,0 +1,171 @@
import { NotificationBody, Step, TaskInput } from "@certd/pipeline";
import dayjs from "dayjs";
import { CertReader } from "@certd/plugin-lib";
import { pick } from "lodash-es";
import { CertApplyBaseConvertPlugin } from "./base-convert.js";
export abstract class CertApplyBasePlugin extends CertApplyBaseConvertPlugin {
@TaskInput({
title: "邮箱",
component: {
name: "email-selector",
vModel: "value",
},
rules: [{ type: "email", message: "请输入正确的邮箱" }],
required: true,
order: -1,
helper: "请输入邮箱",
})
email!: string;
@TaskInput({
title: "更新天数",
value: 18,
component: {
name: "a-input-number",
vModel: "value",
},
required: true,
order: 100,
helper: "到期前多少天后更新证书,注意:流水线默认不会自动运行,请设置定时器,每天定时运行本流水线",
})
renewDays!: number;
@TaskInput({
title: "证书申请成功通知",
value: false,
component: {
name: "a-switch",
vModel: "checked",
},
order: 100,
helper: "证书申请成功后是否发送通知,优先使用默认通知渠道",
})
successNotify = false;
// @TaskInput({
// title: "CsrInfo",
// helper: "暂时没有用",
// })
csrInfo!: string;
async onInstance() {
this.userContext = this.ctx.userContext;
this.lastStatus = this.ctx.lastStatus as Step;
await this.onInit();
}
abstract onInit(): Promise<void>;
abstract doCertApply(): Promise<CertReader>;
async execute(): Promise<string | void> {
const oldCert = await this.condition();
if (oldCert != null) {
await this.output(oldCert, false);
return "skip";
}
const cert = await this.doCertApply();
if (cert != null) {
await this.output(cert, true);
await this.emitCertApplySuccess();
//清空后续任务的状态,让后续任务能够重新执行
this.clearLastStatus();
if (this.successNotify) {
await this.sendSuccessNotify();
}
} else {
throw new Error("申请证书失败");
}
}
getCheckChangeInputKeys() {
//插件哪些字段参与校验是否需要更新
return ["domains", "sslProvider", "privateKeyType", "dnsProviderType", "pfxPassword"];
}
/**
* 是否更新证书
*/
async condition() {
// if (this.forceUpdate) {
// this.logger.info("强制更新证书选项已勾选,准备申请新证书");
// this.logger.warn("申请完之后,切记取消强制更新,避免申请过多证书。");
// return null;
// }
const checkInputChanges = this.getCheckChangeInputKeys();
const oldInput = JSON.stringify(pick(this.lastStatus?.input, checkInputChanges));
const thisInput = JSON.stringify(pick(this, checkInputChanges));
const inputChanged = oldInput !== thisInput;
this.logger.info(`旧参数:${oldInput}`);
this.logger.info(`新参数:${thisInput}`);
if (inputChanged) {
this.logger.info("输入参数变更,准备申请新证书");
return null;
} else {
this.logger.info("输入参数未变更,检查证书是否过期");
}
let oldCert: CertReader | undefined = undefined;
try {
this.logger.info("读取上次证书");
oldCert = await this.readLastCert();
} catch (e) {
this.logger.warn("读取cert失败:", e);
}
if (oldCert == null) {
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;
}
/**
* 检查是否过期,默认提前35天
* @param expires
* @param maxDays
*/
isWillExpire(expires: number, maxDays = 20) {
if (expires == null) {
throw new Error("过期时间不能为空");
}
// 检查有效期
const leftDays = Math.floor((expires - dayjs().valueOf()) / (1000 * 60 * 60 * 24));
this.logger.info(`证书剩余天数:${leftDays}`);
return {
isWillExpire: leftDays <= maxDays,
leftDays,
};
}
async sendSuccessNotify() {
this.logger.info("发送证书申请成功通知");
const url = await this.ctx.urlService.getPipelineDetailUrl(this.pipeline.id, this.ctx.runtime.id);
const body: NotificationBody = {
title: `证书申请成功【${this.pipeline.title}`,
content: `域名:${this.domains.join(",")}`,
url: url,
notificationType: "certApplySuccess",
};
try {
await this.ctx.notificationService.send({
useDefault: true,
useEmail: true,
emailAddress: this.email,
logger: this.logger,
body,
});
} catch (e) {
this.logger.error("证书申请成功通知发送失败", e);
}
}
}
@@ -0,0 +1,193 @@
import { IsTaskPlugin, pluginGroups, RunStrategy, Step, TaskInput, TaskOutput } from "@certd/pipeline";
import type { CertInfo } from "../acme.js";
import { CertReader } from "@certd/plugin-lib";
import { CertApplyBaseConvertPlugin } from "../base-convert.js";
import dayjs from "dayjs";
export { CertReader };
export type { CertInfo };
@IsTaskPlugin({
name: "CertApplyUpload",
icon: "ph:certificate",
title: "商用证书托管",
group: pluginGroups.cert.key,
desc: "手动上传自定义证书后,自动部署(每次证书有更新,都需要手动上传一次)",
default: {
strategy: {
runStrategy: RunStrategy.AlwaysRun,
},
},
shortcut: {
certUpdate: {
title: "更新证书",
icon: "ion:upload",
action: "onCertUpdate",
form: {
columns: {
crt: {
title: "证书",
type: "text",
form: {
component: {
name: "pem-input",
vModel: "modelValue",
textarea: {
rows: 4,
placeholder: "-----BEGIN CERTIFICATE-----\n...\n...\n-----END CERTIFICATE-----",
},
},
rules: [{ required: true, message: "此项必填" }],
col: { span: 24 },
},
},
key: {
title: "私钥",
type: "text",
form: {
component: {
name: "pem-input",
vModel: "modelValue",
textarea: {
rows: 4,
placeholder: "-----BEGIN PRIVATE KEY-----\n...\n...\n-----END PRIVATE KEY----- ",
},
},
rules: [{ required: true, message: "此项必填" }],
col: { span: 24 },
},
},
},
},
},
},
})
export class CertApplyUploadPlugin extends CertApplyBaseConvertPlugin {
@TaskInput({
title: "过期前提醒",
value: 10,
component: {
name: "a-input-number",
vModel: "value",
},
required: true,
order: 100,
helper: "到期前多少天提醒",
})
renewDays!: number;
@TaskInput({
title: "手动上传证书",
component: {
name: "cert-info-updater",
vModel: "modelValue",
},
helper: "手动上传证书",
order: -9999,
required: true,
mergeScript: `
return {
component:{
on:{
updated(scope){
scope.form.input.domains = scope.$event?.domains
}
}
}
}
`,
})
uploadCert!: CertInfo;
@TaskOutput({
title: "证书MD5",
type: "certMd5",
})
certMd5?: string;
async onInstance() {
this.accessService = this.ctx.accessService;
this.logger = this.ctx.logger;
this.userContext = this.ctx.userContext;
this.lastStatus = this.ctx.lastStatus as Step;
}
async onInit(): Promise<void> {}
async getCertFromStore() {
let certReader = null;
try {
this.logger.info("读取上次证书");
certReader = await this.readLastCert();
} catch (e) {
this.logger.warn("读取cert失败:", e);
}
return certReader;
}
private checkExpires(certReader: CertReader) {
const renewDays = (this.renewDays ?? 10) * 24 * 60 * 60 * 1000;
if (certReader.expires) {
if (certReader.expires < new Date().getTime()) {
throw new Error("证书已过期,停止部署,请尽快上传新证书");
}
if (certReader.expires < new Date().getTime() + renewDays) {
throw new Error("证书即将已过期,停止部署,请尽快上传新证书");
}
}
}
async execute(): Promise<string | void> {
const oldCertReader = await this.getCertFromStore();
if (oldCertReader) {
const leftDays = dayjs(oldCertReader.expires).diff(dayjs(), "day");
this.logger.info(`证书过期时间${dayjs(oldCertReader.expires).format("YYYY-MM-DD HH:mm:ss")},剩余${leftDays}`);
this.checkExpires(oldCertReader);
if (!this.ctx.inputChanged) {
this.logger.info("输入参数无变化");
const lastCrtMd5 = this.lastStatus?.status?.output?.certMd5;
const newCrtMd5 = this.ctx.utils.hash.md5(this.uploadCert.crt);
this.logger.info("证书MD5", newCrtMd5);
this.logger.info("上次证书MD5", lastCrtMd5);
if (lastCrtMd5 === newCrtMd5) {
this.logger.info("证书无变化,跳过");
//输出证书MD5
this.certMd5 = newCrtMd5;
await this.output(oldCertReader, false);
return "skip";
}
this.logger.info("证书有变化,重新部署");
} else {
this.logger.info("输入参数有变化,重新部署");
}
}
const newCertReader = new CertReader(this.uploadCert);
this.clearLastStatus();
//输出证书MD5
this.certMd5 = this.ctx.utils.hash.md5(newCertReader.cert.crt);
const newLeftDays = dayjs(newCertReader.expires).diff(dayjs(), "day");
this.logger.info(`新证书过期时间${dayjs(newCertReader.expires).format("YYYY-MM-DD HH:mm:ss")},剩余${newLeftDays}`);
this.checkExpires(newCertReader);
await this.output(newCertReader, true);
//必须output之后执行
await this.emitCertApplySuccess();
return;
}
async onCertUpdate(data: any) {
const certReader = new CertReader(data);
return {
input: {
uploadCert: {
crt: data.crt,
key: data.key,
},
domains: certReader.getAllDomains(),
},
};
}
}
new CertApplyUploadPlugin();
@@ -0,0 +1,165 @@
import { IsTaskPlugin, PageSearch, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { AliyunAccess } from "../../../../plugin-lib/aliyun/access/index.js";
import type { CertInfo } from "../acme.js";
import { CertApplyBasePlugin } from "../base.js";
import { CertReader, createRemoteSelectInputDefine } from "@certd/plugin-lib";
import dayjs from "dayjs";
export { CertReader };
export type { CertInfo };
@IsTaskPlugin({
name: "CertApplyGetFormAliyun",
icon: "ph:certificate",
title: "获取阿里云订阅证书",
group: pluginGroups.cert.key,
desc: "从阿里云拉取订阅模式的商用证书",
default: {
strategy: {
runStrategy: RunStrategy.AlwaysRun,
},
},
})
export class CertApplyGetFormAliyunPlugin extends CertApplyBasePlugin {
@TaskInput({
title: "Access授权",
helper: "阿里云授权AccessKeyId、AccessKeySecret",
component: {
name: "access-selector",
type: "aliyun",
},
required: true,
})
accessId!: string;
@TaskInput(
createRemoteSelectInputDefine({
title: "证书订单ID",
helper: "订阅模式的证书订单Id",
typeName: "CertApplyGetFormAliyun",
component: {
name: "RemoteAutoComplete",
vModel: "value",
},
action: CertApplyGetFormAliyunPlugin.prototype.onGetOrderList.name,
})
)
orderId!: string;
async onInit(): Promise<void> {}
async doCertApply(): Promise<CertReader> {
const access = await this.getAccess<AliyunAccess>(this.accessId);
const client = await access.getClient("cas.aliyuncs.com");
this.logger.info(`开始获取证书,orderId:${this.orderId}`);
let orderId: any = this.orderId;
if (!orderId) {
throw new Error("请先输入证书订单ID");
}
if (typeof orderId !== "string") {
orderId = parseInt(orderId);
}
const certState = await this.getCertificateState(client, orderId);
this.logger.info(`获取到证书Id:${JSON.stringify(certState.CertId)}`);
const certDetail = await this.getCertDetail(client, certState.CertId);
this.logger.info(`获取到证书:${certDetail.getAllDomains()}, 过期时间:${dayjs(certDetail.expires).format("YYYY-MM-DD HH:mm:ss")}`);
return certDetail;
}
async getCertDetail(client: any, certId: any) {
const res = await client.doRequest({
// 接口名称
// 接口名称
action: "GetUserCertificateDetail",
// 接口版本
version: "2020-04-07",
// 接口协议
protocol: "HTTPS",
// 接口 HTTP 方法
method: "POST",
authType: "AK",
style: "RPC",
// 接口 PATH
pathname: `/`,
data: {
query: {
CertId: certId,
},
},
});
const crt = res.Cert;
const key = res.Key;
return new CertReader({
crt,
key,
csr: "",
});
}
async getCertificateState(client: any, orderId: any): Promise<{ CertId: string; Type: string; Domain: string }> {
const res = await client.doRequest({
// 接口名称
action: "DescribeCertificateState",
// 接口版本
version: "2020-04-07",
// 接口协议
protocol: "HTTPS",
// 接口 HTTP 方法
method: "POST",
authType: "AK",
style: "RPC",
// 接口 PATH
pathname: `/`,
data: {
query: {
OrderId: orderId,
},
},
});
return res;
}
async onGetOrderList(req: PageSearch) {
if (!this.accessId) {
throw new Error("请先选择Access授权");
}
const access = await this.getAccess<AliyunAccess>(this.accessId);
const client = await access.getClient("cas.aliyuncs.com");
const res = await client.doRequest({
// 接口名称
action: "ListUserCertificateOrder",
// 接口版本
version: "2020-04-07",
method: "POST",
authType: "AK",
style: "RPC",
// 接口 PATH
pathname: `/`,
data: {
query: {
Status: "ISSUED",
},
},
});
const list = res?.CertificateOrderList || [];
if (!list || list.length === 0) {
throw new Error("没有找到已签发的证书订单");
}
return list.map((item: any) => {
const label = `${item.Domain}<${item.OrderId}>`;
return {
label: label,
value: item.OrderId,
Domain: item.Domain,
};
});
}
}
new CertApplyGetFormAliyunPlugin();
@@ -0,0 +1,668 @@
import { CancelError, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { utils } from "@certd/basic";
import { AcmeService, DomainsVerifyPlan, DomainVerifyPlan, PrivateKeyType, SSLProvider } from "./acme.js";
import * as _ from "lodash-es";
import { createDnsProvider, DnsProviderContext, DnsVerifier, DomainVerifiers, HttpVerifier, IDnsProvider, IDomainVerifierGetter, ISubDomainsGetter } from "@certd/plugin-lib";
import { CertReader } from "@certd/plugin-lib";
import { CertApplyBasePlugin } from "./base.js";
import { GoogleClient } from "../../libs/google.js";
import { EabAccess } from "../../access/index.js";
import { DomainParser } from "@certd/plugin-lib";
import { ossClientFactory } from "../../../plugin-lib/oss/factory.js";
export * from "./base.js";
export type CnameRecordInput = {
id: number;
status: string;
};
export type HttpRecordInput = {
domain: string;
httpUploaderType: string;
httpUploaderAccess: number;
httpUploadRootDir: string;
};
export type DomainVerifyPlanInput = {
domain: string;
type: "cname" | "dns" | "http";
dnsProviderType?: string;
dnsProviderAccessType?: string;
dnsProviderAccessId?: number;
cnameVerifyPlan?: Record<string, CnameRecordInput>;
httpVerifyPlan?: Record<string, HttpRecordInput>;
};
export type DomainsVerifyPlanInput = {
[key: string]: DomainVerifyPlanInput;
};
const preferredChainConfigs = {
letsencrypt: {
helper: "如无特殊需求保持默认即可",
options: [
{ value: "ISRG Root X1", label: "ISRG Root X1" },
{ value: "ISRG Root X2", label: "ISRG Root X2" },
],
},
google: {
helper: "GlobalSign 提供对老旧设备更好的兼容性,但证书链会变长",
options: [
{ value: "GTS Root R1", label: "GTS Root R1" },
{ value: "GlobalSign", label: "GlobalSign" },
],
},
} as const;
const preferredChainSupportedProviders = Object.keys(preferredChainConfigs);
const preferredChainMergeScript = (() => {
const configs = JSON.stringify(preferredChainConfigs);
const supportedProviders = JSON.stringify(preferredChainSupportedProviders);
const defaultProvider = JSON.stringify(preferredChainSupportedProviders[0]);
return `
const chainConfigs = ${configs};
const supportedProviders = ${supportedProviders};
const defaultProvider = ${defaultProvider};
const getConfig = (provider)=> chainConfigs[provider] || chainConfigs[defaultProvider];
return {
show: ctx.compute(({form})=> supportedProviders.includes(form.sslProvider)),
component: {
options: ctx.compute(({form})=> getConfig(form.sslProvider).options)
},
helper: ctx.compute(({form})=> getConfig(form.sslProvider).helper),
value: ctx.compute(({form})=>{
const { options } = getConfig(form.sslProvider);
const allowed = options.map(item=>item.value);
const current = form.preferredChain;
if(allowed.includes(current)){
return current;
}
return allowed[0];
})
};
`;
})();
@IsTaskPlugin({
name: "CertApply",
title: "证书申请(JS版)",
icon: "ph:certificate",
group: pluginGroups.cert.key,
desc: "免费通配符域名证书申请,支持多个域名打到同一个证书上",
default: {
input: {
renewDays: 18,
forceUpdate: false,
},
strategy: {
runStrategy: RunStrategy.AlwaysRun,
},
},
})
export class CertApplyPlugin extends CertApplyBasePlugin {
@TaskInput({
title: "域名验证方式",
value: "dns",
component: {
name: "a-select",
vModel: "value",
options: [
{ value: "dns", label: "DNS直接验证" },
{ value: "cname", label: "CNAME代理验证" },
{ value: "http", label: "HTTP文件验证(IP证书只能选它)" },
{ value: "dnses", label: "多DNS提供商" },
{ value: "auto", label: "自动匹配" },
],
},
required: true,
helper: `1. <b>DNS直接验证</b>:当域名dns解析已被本系统支持时(即下方DNS解析服务商选项中可选),推荐选择此方式
2. <b>CNAME代理验证</b>:支持任何注册商的域名,第一次需要手动添加[CNAME记录](#/certd/cname/record)(如果经常申请失败,建议将DNS服务器修改为阿里云/腾讯云的,然后使用DNS直接验证)
3. <b>HTTP文件验证</b>:不支持泛域名,需要配置网站文件上传(IP证书必须选它)
4. <b>多DNS提供商</b>:每个域名可以选择独立的DNS提供商
5. <b>自动匹配</b>:此处无需选择校验方式,需要在[域名管理](#/certd/cert/domain)中提前配置好校验方式
`,
})
challengeType!: string;
@TaskInput({
title: "证书颁发机构",
value: "letsencrypt",
component: {
name: "icon-select",
vModel: "value",
options: [
{ value: "letsencrypt", label: "Let's Encrypt(免费,新手推荐,支持IP证书)", icon: "simple-icons:letsencrypt" },
{ value: "google", label: "Google(免费)", icon: "flat-color-icons:google" },
{ value: "zerossl", label: "ZeroSSL(免费)", icon: "emojione:digit-zero" },
{ value: "litessl", label: "litessl(免费)", icon: "roentgen:free" },
{ value: "sslcom", label: "SSL.com(仅主域名和www免费)", icon: "la:expeditedssl" },
{ value: "letsencrypt_staging", label: "Let's Encrypt测试环境(仅供测试)", icon: "simple-icons:letsencrypt" },
],
},
helper: "Let's Encrypt:申请最简单\nGoogle:大厂光环,兼容性好,仅首次需要翻墙获取EAB授权\nZeroSSL:需要EAB授权,无需翻墙\nSSL.com:仅主域名和www免费,必须设置CAA记录",
required: true,
})
sslProvider!: SSLProvider;
@TaskInput({
title: "DNS解析服务商",
component: {
name: "dns-provider-selector",
},
mergeScript: `
return {
show: ctx.compute(({form})=>{
return form.challengeType === 'dns'
}),
component:{
onSelectedChange: ctx.compute(({form})=>{
return ($event)=>{
form.dnsProviderAccessType = $event.accessType
}
})
}
}
`,
required: true,
helper: "您的域名注册商,或者域名的dns服务器属于哪个平台\n如果这里没有,请选择CNAME代理验证校验方式",
})
dnsProviderType!: string;
// dns解析授权类型,勿删
dnsProviderAccessType!: string;
@TaskInput({
title: "DNS解析授权",
component: {
name: "access-selector",
},
required: true,
helper: "请选择dns解析服务商授权",
mergeScript: `return {
component:{
type: ctx.compute(({form})=>{
return form.dnsProviderAccessType || form.dnsProviderType
})
},
show: ctx.compute(({form})=>{
return form.challengeType === 'dns'
})
}
`,
})
dnsProviderAccess!: number;
@TaskInput({
title: "域名验证配置",
component: {
name: "domains-verify-plan-editor",
},
rules: [{ type: "checkDomainVerifyPlan" }],
required: true,
col: {
span: 24,
},
mergeScript: `return {
component:{
domains: ctx.compute(({form})=>{
return form.domains
}),
defaultType: ctx.compute(({form})=>{
return form.challengeType || 'cname'
})
},
show: ctx.compute(({form})=>{
return form.challengeType === 'cname' || form.challengeType === 'http' || form.challengeType === 'dnses'
}),
helper: ctx.compute(({form})=>{
if(form.challengeType === 'cname' ){
return '请按照上面的提示,给要申请证书的域名添加CNAME记录,添加后,点击验证,验证成功后不要删除记录,申请和续期证书会一直用它'
}else if (form.challengeType === 'http'){
return '请按照上面的提示,给每个域名设置文件上传配置,证书申请过程中会上传校验文件到网站根目录的.well-known/acme-challenge/目录下'
}else if (form.challengeType === 'http'){
return '给每个域名单独配置dns提供商'
}
})
}
`,
})
domainsVerifyPlan!: DomainsVerifyPlanInput;
@TaskInput({
title: "Google公共EAB授权",
isSys: true,
show: false,
})
googleCommonEabAccessId!: number;
@TaskInput({
title: "ZeroSSL公共EAB授权",
isSys: true,
show: false,
})
zerosslCommonEabAccessId!: number;
@TaskInput({
title: "SSL.com公共EAB授权",
isSys: true,
show: false,
})
sslcomCommonEabAccessId!: number;
@TaskInput({
title: "litessl公共EAB授权",
isSys: true,
show: false,
})
litesslCommonEabAccessId!: number;
@TaskInput({
title: "EAB授权",
component: {
name: "access-selector",
type: "eab",
},
maybeNeed: true,
required: false,
helper:
"需要提供EAB授权" +
"\nZeroSSL:请前往[zerossl开发者中心](https://app.zerossl.com/developer),生成 'EAB Credentials'" +
"\nGoogle:请查看[google获取eab帮助文档](https://certd.docmirror.cn/guide/use/google/),用过一次后会绑定邮箱,后续复用EAB要用同一个邮箱" +
"\nSSL.com:[SSL.com账号页面](https://secure.ssl.com/account),然后点击api credentials链接,然后点击编辑按钮,查看Secret key和HMAC key" +
"\nlitessl:[litesslEAB页面](https://freessl.cn/automation/eab-manager),然后点击新增EAB",
mergeScript: `
return {
show: ctx.compute(({form})=>{
return (form.sslProvider === 'zerossl' && !form.zerosslCommonEabAccessId)
|| (form.sslProvider === 'google' && !form.googleCommonEabAccessId)
|| (form.sslProvider === 'sslcom' && !form.sslcomCommonEabAccessId)
|| (form.sslProvider === 'litessl' && !form.litesslCommonEabAccessId)
})
}
`,
})
eabAccessId!: number;
@TaskInput({
title: "服务账号授权",
component: {
name: "access-selector",
type: "google",
},
maybeNeed: true,
required: false,
helper: "google服务账号授权与EAB授权选填其中一个,[服务账号授权获取方法](https://certd.docmirror.cn/guide/use/google/)\n服务账号授权需要配置代理或者服务器本身在海外",
mergeScript: `
return {
show: ctx.compute(({form})=>{
return form.sslProvider === 'google' && !form.googleCommonEabAccessId
})
}
`,
})
googleAccessId!: number;
@TaskInput({
title: "加密算法",
value: "rsa_2048",
component: {
name: "a-select",
vModel: "value",
options: [
{ value: "rsa_1024", label: "RSA 1024" },
{ value: "rsa_2048", label: "RSA 2048" },
{ value: "rsa_3072", label: "RSA 3072" },
{ value: "rsa_4096", label: "RSA 4096" },
{ value: "rsa_2048_pkcs1", label: "RSA 2048 pkcs1 (旧版)" },
{ value: "ec_256", label: "EC 256" },
{ value: "ec_384", label: "EC 384" },
// { value: "ec_521", label: "EC 521" },
],
},
helper: "如无特殊需求,默认即可\n选择RSA 2048 pkcs1可以获得旧版RSA证书",
required: true,
})
privateKeyType!: PrivateKeyType;
@TaskInput({
title: "证书配置",
value: "classic",
component: {
name: "a-select",
vModel: "value",
options: [
{ value: "classic", label: "经典(classic" },
{ value: "tlsserver", label: "TLS服务器(tlsserver" },
{ value: "shortlived", label: "短暂的(shortlived" },
],
},
helper: "如无特殊需求,默认即可",
required: false,
mergeScript: `
return {
show: ctx.compute(({form})=>{
return form.sslProvider === 'letsencrypt'
})
}
`,
})
certProfile!: string;
@TaskInput({
title: "首选链",
component: {
name: "a-select",
vModel: "value",
options: preferredChainConfigs.letsencrypt.options,
},
helper: preferredChainConfigs.letsencrypt.helper,
required: false,
mergeScript: preferredChainMergeScript,
})
preferredChain!: string;
@TaskInput({
title: "使用代理",
value: false,
component: {
name: "a-switch",
vModel: "checked",
},
helper: "如果acme-v02.api.letsencrypt.org或dv.acme-v02.api.pki.goog被墙无法访问,请尝试开启此选项\n默认情况会进行测试,如果无法访问,将会自动使用代理",
})
useProxy = false;
@TaskInput({
title: "自定义反代地址",
component: {
placeholder: "google.yourproxy.com",
},
helper: "填写你的自定义反代地址,不要带http://\nletsencrypt反代目标:acme-v02.api.letsencrypt.org\ngoogle反代目标:dv.acme-v02.api.pki.goog",
})
reverseProxy = "";
@TaskInput({
title: "跳过本地校验DNS",
value: false,
component: {
name: "a-switch",
vModel: "checked",
},
helper: "跳过本地校验可以加快申请速度,同时也会增加失败概率。",
})
skipLocalVerify = false;
@TaskInput({
title: "检查解析重试次数",
value: 20,
component: {
name: "a-input-number",
vModel: "value",
},
helper: "检查域名验证解析记录重试次数,如果你的域名服务商解析生效速度慢,可以适当增加此值",
})
maxCheckRetryCount = 20;
@TaskInput({
title: "等待解析生效时长",
value: 30,
component: {
name: "a-input-number",
vModel: "value",
},
helper: "等待解析生效时长(秒),如果使用CNAME方式校验,本地验证失败,可以尝试延长此时间(比如5-10分钟)",
})
waitDnsDiffuseTime = 30;
acme!: AcmeService;
eab!: EabAccess;
async onInit() {
let eab: EabAccess = null;
if (this.sslProvider && !this.sslProvider.startsWith("letsencrypt")) {
if (this.sslProvider === "google" && this.googleAccessId) {
this.logger.info("当前正在使用 google服务账号授权获取EAB");
const googleAccess = await this.getAccess(this.googleAccessId);
const googleClient = new GoogleClient({
access: googleAccess,
logger: this.logger,
});
eab = await googleClient.getEab();
} else {
const getEab = async (type: string) => {
if (this.eabAccessId) {
this.logger.info(`当前正在使用 ${type} EAB授权`);
eab = await this.getAccess(this.eabAccessId);
} else if (this[`${type}CommonEabAccessId`]) {
this.logger.info(`当前正在使用 ${type} 公共EAB授权`);
eab = await this.getAccess(this[`${type}CommonEabAccessId`], true);
} else {
throw new Error(`${type}需要配置EAB授权`);
}
};
await getEab(this.sslProvider);
}
}
this.eab = eab;
const subDomainsGetter = await this.ctx.serviceGetter.get<ISubDomainsGetter>("subDomainsGetter");
const domainParser = new DomainParser(subDomainsGetter, this.logger);
this.acme = new AcmeService({
userId: this.ctx.user.id,
userContext: this.userContext,
logger: this.logger,
sslProvider: this.sslProvider,
eab,
skipLocalVerify: this.skipLocalVerify,
useMappingProxy: this.useProxy,
reverseProxy: this.reverseProxy,
privateKeyType: this.privateKeyType,
signal: this.ctx.signal,
maxCheckRetryCount: this.maxCheckRetryCount,
domainParser,
waitDnsDiffuseTime: this.waitDnsDiffuseTime,
});
}
async doCertApply() {
let email = this.email;
if (this.eab && this.eab.email) {
email = this.eab.email;
}
const domains = this["domains"];
const csrInfo = _.merge(
{
// country: "CN",
// state: "GuangDong",
// locality: "ShengZhen",
// organization: "CertD Org.",
// organizationUnit: "IT Department",
// emailAddress: email,
},
this.csrInfo ? JSON.parse(this.csrInfo) : {}
);
this.logger.info("开始申请证书,", email, domains);
let dnsProvider: IDnsProvider = null;
let domainsVerifyPlan: DomainsVerifyPlan = null;
if (this.challengeType === "cname" || this.challengeType === "http" || this.challengeType === "dnses") {
domainsVerifyPlan = await this.createDomainsVerifyPlan(domains, this.domainsVerifyPlan);
} else if (this.challengeType === "auto") {
domainsVerifyPlan = await this.createDomainsVerifyPlanByAuto(domains);
} else {
const dnsProviderType = this.dnsProviderType;
const access = await this.getAccess(this.dnsProviderAccess);
dnsProvider = await this.createDnsProvider(dnsProviderType, access);
}
try {
const cert = await this.acme.order({
email,
domains,
dnsProvider,
domainsVerifyPlan,
csrInfo,
privateKeyType: this.privateKeyType,
profile: this.certProfile,
preferredChain: this.preferredChain,
});
const certInfo = this.formatCerts(cert);
return new CertReader(certInfo);
} catch (e: any) {
const message: string = e?.message;
if (message != null && message.indexOf("redundant with a wildcard domain in the same request") >= 0) {
this.logger.error(e);
throw new Error(`通配符域名已经包含了普通域名,请删除其中一个(${message}`);
}
if (e.name === "CancelError") {
throw new CancelError(e.message);
}
throw e;
}
}
async createDnsProvider(dnsProviderType: string, dnsProviderAccess: any): Promise<IDnsProvider> {
const domainParser = this.acme.options.domainParser;
const context: DnsProviderContext = {
access: dnsProviderAccess,
logger: this.logger,
http: this.ctx.http,
utils,
domainParser,
serviceGetter: this.ctx.serviceGetter,
};
return await createDnsProvider({
dnsProviderType,
context,
});
}
async createDomainsVerifyPlan(domains: string[], verifyPlanSetting: DomainsVerifyPlanInput): Promise<DomainsVerifyPlan> {
const plan: DomainsVerifyPlan = {};
const domainParser = this.acme.options.domainParser;
for (const fullDomain of domains) {
const domain = fullDomain.replaceAll("*.", "");
const mainDomain = await domainParser.parse(domain);
const planSetting: DomainVerifyPlanInput = verifyPlanSetting[mainDomain];
if (planSetting == null) {
throw new Error(`没有找到域名(${domain})的校验计划(如果您在流水线创建之后设置了子域名托管,需要重新编辑证书申请任务和重新校验cname记录的校验状态)`);
}
if (planSetting.type === "dns") {
plan[domain] = await this.createDnsDomainVerifyPlan(planSetting, domain, mainDomain);
} else if (planSetting.type === "cname") {
plan[domain] = await this.createCnameDomainVerifyPlan(domain, mainDomain);
} else if (planSetting.type === "http") {
plan[domain] = await this.createHttpDomainVerifyPlan(planSetting.httpVerifyPlan[domain], domain, mainDomain);
}
}
return plan;
}
private async createDomainsVerifyPlanByAuto(domains: string[]) {
//从数据库里面自动选择校验方式
// domain list
const domainList = new Set<string>();
//整理域名
for (let domain of domains) {
domain = domain.replaceAll("*.", "");
domainList.add(domain);
}
const domainVerifierGetter: IDomainVerifierGetter = await this.ctx.serviceGetter.get("domainVerifierGetter");
const verifiers: DomainVerifiers = await domainVerifierGetter.getVerifiers([...domainList]);
const plan: DomainsVerifyPlan = {};
for (const domain in verifiers) {
const verifier = verifiers[domain];
if (verifier == null) {
throw new Error(`没有找到与该域名(${domain})匹配的校验方式,请先到‘域名管理’页面添加校验方式`);
}
if (verifier.type === "dns") {
plan[domain] = await this.createDnsDomainVerifyPlan(verifier.dns, domain, verifier.mainDomain);
} else if (verifier.type === "cname") {
plan[domain] = await this.createCnameDomainVerifyPlan(domain, verifier.mainDomain);
} else if (verifier.type === "http") {
plan[domain] = await this.createHttpDomainVerifyPlan(verifier.http, domain, verifier.mainDomain);
}
}
return plan;
}
private async createDnsDomainVerifyPlan(planSetting: DnsVerifier, domain: string, mainDomain: string): Promise<DomainVerifyPlan> {
const access = await this.getAccess(planSetting.dnsProviderAccessId);
return {
type: "dns",
mainDomain,
domain,
dnsProvider: await this.createDnsProvider(planSetting.dnsProviderType, access),
};
}
private async createHttpDomainVerifyPlan(httpSetting: HttpVerifier, domain: string, mainDomain: string): Promise<DomainVerifyPlan> {
const httpUploaderContext = {
accessService: this.ctx.accessService,
logger: this.logger,
utils,
};
const access = await this.getAccess(httpSetting.httpUploaderAccess);
let rootDir = httpSetting.httpUploadRootDir;
if (!rootDir.endsWith("/") && !rootDir.endsWith("\\")) {
rootDir = rootDir + "/";
}
this.logger.info("上传方式", httpSetting.httpUploaderType);
const httpUploader = await ossClientFactory.createOssClientByType(httpSetting.httpUploaderType, {
access,
rootDir: rootDir,
ctx: httpUploaderContext,
});
return {
type: "http",
domain,
mainDomain,
httpVerifyPlan: {
type: "http",
domain,
httpUploader,
},
};
}
private async createCnameDomainVerifyPlan(domain: string, mainDomain: string): Promise<DomainVerifyPlan> {
const cnameRecord = await this.ctx.cnameProxyService.getByDomain(domain);
if (cnameRecord == null) {
throw new Error(`请先配置${domain}的CNAME记录,并通过校验`);
}
if (cnameRecord.status !== "valid") {
throw new Error(`CNAME记录${domain}的校验状态为${cnameRecord.status},请等待校验通过`);
}
// 主域名异常
if (cnameRecord.mainDomain && mainDomain && cnameRecord.mainDomain !== mainDomain) {
throw new Error(`CNAME记录${domain}的域名与配置的主域名不一致(${cnameRecord.mainDomain}${mainDomain}),请确认是否在流水线创建之后修改了子域名托管,您需要重新校验CNAME记录的校验状态`);
}
let dnsProvider = cnameRecord.commonDnsProvider;
if (cnameRecord.cnameProvider.id > 0) {
dnsProvider = await this.createDnsProvider(cnameRecord.cnameProvider.dnsProviderType, cnameRecord.cnameProvider.access);
}
return {
type: "cname",
domain,
mainDomain,
cnameVerifyPlan: {
domain: cnameRecord.cnameProvider.domain,
fullRecord: cnameRecord.recordValue,
dnsProvider,
},
};
}
}
new CertApplyPlugin();
@@ -0,0 +1 @@
export const dnsList = [];
@@ -0,0 +1,250 @@
import { IsTaskPlugin, pluginGroups, RunStrategy, Step, TaskInput } from "@certd/pipeline";
import type { CertInfo } from "../acme.js";
import { CertReader } from "@certd/plugin-lib";
import { CertApplyBasePlugin } from "../base.js";
import fs from "fs";
import { EabAccess } from "../../../access/index.js";
import path from "path";
import JSZip from "jszip";
export { CertReader };
export type { CertInfo };
export type PrivateKeyType = "rsa2048" | "rsa3072" | "rsa4096" | "rsa8192" | "ec256" | "ec384";
@IsTaskPlugin({
name: "CertApplyLego",
icon: "ph:certificate",
title: "证书申请(Lego",
group: pluginGroups.cert.key,
desc: "支持海量DNS解析提供商,推荐使用,一样的免费通配符域名证书申请,支持多个域名打到同一个证书上",
default: {
input: {
renewDays: 35,
forceUpdate: false,
},
strategy: {
runStrategy: RunStrategy.AlwaysRun,
},
},
})
export class CertApplyLegoPlugin extends CertApplyBasePlugin {
// @TaskInput({
// title: "ACME服务端点",
// default: "https://acme-v02.api.letsencrypt.org/directory",
// component: {
// name: "a-select",
// vModel: "value",
// options: [
// { value: "https://acme-v02.api.letsencrypt.org/directory", label: "Let's Encrypt" },
// { value: "https://letsencrypt.proxy.handsfree.work/directory", label: "Let's Encrypt代理,letsencrypt.org无法访问时使用" },
// ],
// },
// required: true,
// })
acmeServer!: string;
@TaskInput({
title: "DNS类型",
component: {
name: "a-input",
vModel: "value",
placeholder: "alidns",
},
helper: "你的域名是通过哪家提供商进行解析的,具体应该配置什么请参考lego文档:https://go-acme.github.io/lego/dns/",
required: true,
})
dnsType!: string;
@TaskInput({
title: "环境变量",
component: {
name: "a-textarea",
vModel: "value",
rows: 4,
placeholder: "ALICLOUD_ACCESS_KEY=abcdefghijklmnopqrstuvwx\nALICLOUD_SECRET_KEY=your-secret-key",
},
required: true,
helper: "一行一条,例如 appKeyId=xxxxx,具体配置请参考lego文档:https://go-acme.github.io/lego/dns/",
})
environment!: string;
@TaskInput({
title: "EAB授权",
component: {
name: "access-selector",
type: "eab",
},
maybeNeed: true,
helper: "如果需要提供EAB授权",
})
legoEabAccessId!: number;
@TaskInput({
title: "自定义LEGO全局参数",
component: {
name: "a-input",
vModel: "value",
placeholder: "--dns-timeout 30",
},
helper: "额外的lego全局命令行参数,参考文档:https://go-acme.github.io/lego/usage/cli/options/",
maybeNeed: true,
})
customArgs = "";
@TaskInput({
title: "自定义LEGO签名参数",
component: {
name: "a-input",
vModel: "value",
placeholder: "--no-bundle",
},
helper: "额外的lego签名命令行参数,参考文档:https://go-acme.github.io/lego/usage/cli/options/",
maybeNeed: true,
})
customCommandOptions = "";
@TaskInput({
title: "加密算法",
value: "ec256",
component: {
name: "a-select",
vModel: "value",
options: [
{ value: "rsa2048", label: "RSA 2048" },
{ value: "rsa3072", label: "RSA 3072" },
{ value: "rsa4096", label: "RSA 4096" },
{ value: "rsa8192", label: "RSA 8192" },
{ value: "ec256", label: "EC 256" },
{ value: "ec384", label: "EC 384" },
// { value: "ec_521", label: "EC 521" },
],
},
helper: "如无特殊需求,默认即可",
required: true,
})
privateKeyType!: PrivateKeyType;
eab?: EabAccess;
getCheckChangeInputKeys() {
return ["domains", "privateKeyType", "dnsType"];
}
async onInstance() {
this.accessService = this.ctx.accessService;
this.logger = this.ctx.logger;
this.userContext = this.ctx.userContext;
this.lastStatus = this.ctx.lastStatus as Step;
if (this.legoEabAccessId) {
this.eab = await this.accessService.getById(this.legoEabAccessId);
}
}
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 --kid "${this.eab.kid}" --hmac "${this.eab.hmacKey}"`;
}
const keyType = `-k ${this.privateKeyType?.replaceAll("_", "")}`;
const saveDir = `./data/.lego/pipeline_${this.pipeline.id}/`;
const savePathArgs = `--path "${saveDir}"`;
const os_type = process.platform === "win32" ? "windows" : "linux";
const legoDir = "./tools/lego";
const legoPath = path.resolve(legoDir, os_type === "windows" ? "lego.exe" : "lego");
if (!fs.existsSync(legoPath)) {
//解压缩
const arch = process.arch;
let platform = "amd64";
if (arch === "arm64" || arch === "arm") {
platform = "arm64";
}
const LEGO_VERSION = process.env.LEGO_VERSION;
let legoZipFileName = `lego_v${LEGO_VERSION}_windows_${platform}.zip`;
if (os_type === "linux") {
legoZipFileName = `lego_v${LEGO_VERSION}_linux_${platform}.tar.gz`;
}
const legoZipFilePath = `${legoDir}/${legoZipFileName}`;
if (!fs.existsSync(legoZipFilePath)) {
this.logger.info(`lego文件不存在:${legoZipFilePath},准备下载`);
const downloadUrl = `https://github.com/go-acme/lego/releases/download/v${LEGO_VERSION}/${legoZipFileName}`;
await this.ctx.download(
{
url: downloadUrl,
method: "GET",
logRes: false,
},
legoZipFilePath
);
this.logger.info("下载lego成功");
}
if (os_type === "linux") {
//tar是否存在
await this.ctx.utils.sp.spawn({
cmd: `tar -zxvf ${legoZipFilePath} -C ${legoDir}/`,
});
await this.ctx.utils.sp.spawn({
cmd: `chmod +x ${legoDir}/*`,
});
this.logger.info("解压lego成功");
} else {
const zip = new JSZip();
const data = fs.readFileSync(legoZipFilePath);
const zipData = await zip.loadAsync(data);
const files = Object.keys(zipData.files);
for (const file of files) {
const content = await zipData.files[file].async("nodebuffer");
fs.writeFileSync(`${legoDir}/${file}`, content);
}
this.logger.info("解压lego成功");
}
}
let serverArgs = "";
if (this.acmeServer) {
serverArgs = ` --server ${this.acmeServer}`;
}
const cmds = [`${legoPath} -a --email "${this.email}" --dns ${this.dnsType} ${keyType} ${domainArgs} ${serverArgs} ${eabArgs} ${savePathArgs} ${this.customArgs || ""} run ${this.customCommandOptions || ""}`];
await this.ctx.utils.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();
@@ -0,0 +1,4 @@
export * from "./cert-plugin/index.js";
export * from "./cert-plugin/lego/index.js";
export * from "./cert-plugin/custom/index.js";
export * from "./cert-plugin/getter/aliyun.js";
@@ -7,7 +7,7 @@ import {
} from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert";
import { createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from "@certd/plugin-lib";
import { AbstractPlusTaskPlugin } from "@certd/plugin-plus";
import { AbstractPlusTaskPlugin } from "@certd/plugin-lib";
import { CmccAccess } from "./access.js";
@IsTaskPlugin({
@@ -3,9 +3,9 @@ import { CertApplyPluginNames, CertInfo, CertReader } from "@certd/plugin-cert";
import {
createCertDomainGetterInputDefine,
createRemoteSelectInputDefine,
SshAccess,
SshClient
} from "@certd/plugin-lib";
import { SshAccess } from "../plugin-lib/ssh/ssh-access.js";
import { SshClient } from "../plugin-lib/ssh/ssh.js";
@IsTaskPlugin({
//命名规范,插件类型+功能(就是目录plugin-demo中的demo),大写字母开头,驼峰命名
@@ -1,6 +1,6 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput, TaskOutput } from "@certd/pipeline";
import { GithubAccess } from "../access.js";
import {SshClient} from "@certd/plugin-lib";
import { SshClient } from "../../plugin-lib/ssh/ssh.js";
@IsTaskPlugin({
//命名规范,插件类型+功能(就是目录plugin-demo中的demo),大写字母开头,驼峰命名
@@ -1,5 +1,5 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from '@certd/pipeline';
import { SshClient } from '@certd/plugin-lib';
import { SshClient } from '../../../plugin-lib/ssh/index.js';
@IsTaskPlugin({
name: 'hostShellExecute',
title: '主机-执行远程主机脚本命令',
@@ -2,3 +2,4 @@ export * from './host-shell-execute/index.js';
export * from './upload-to-host/index.js';
export * from './copy-to-local/index.js'
export * from './plugin-upload-to-oss.js'
export * from './plugin-deploy-to-iis.js'
@@ -0,0 +1,283 @@
import { IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertInfo } from "@certd/plugin-cert";
import { AbstractPlusTaskPlugin } from "@certd/plugin-lib";
import { createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from "@certd/plugin-lib";
import { tmpdir } from "node:os";
import path from "node:path";
import fs from "fs";
import { utils } from "@certd/basic";
import { CertApplyPluginNames } from "@certd/plugin-cert";
import { SshAccess } from "../../plugin-lib/ssh/ssh-access.js";
import { SshClient } from "../../plugin-lib/ssh/ssh.js";
@IsTaskPlugin({
name: "HostDeployToIIS",
title: "IIS-部署到IIS站点",
icon: "devicon:windows8",
group: pluginGroups.host.key,
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
needPlus: true,
})
export class HostDeployToIIS extends AbstractPlusTaskPlugin {
//证书选择,此项必须要有
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames],
},
required: true,
})
cert!: CertInfo;
@TaskInput(createCertDomainGetterInputDefine({ props: { required: false } }))
certDomains!: string[];
//授权选择框
@TaskInput({
title: "主机SSH授权",
component: {
name: "access-selector",
type: "ssh",
},
required: true,
})
accessId!: string;
@TaskInput(
createRemoteSelectInputDefine({
title: "站点名称",
helper: "选择或手动输入网站名称",
action: HostDeployToIIS.prototype.onGetSiteList.name,
})
)
siteNames!: string[];
//授权选择框
@TaskInput({
title: "证书密码",
required: false,
})
pfxPassword!: string;
async onInstance() {}
async execute(): Promise<void> {
const sshConf = await this.getAccess<SshAccess>(this.accessId);
const sshClient = new SshClient(this.logger);
await this.checkTerminalType(sshClient, sshConf);
const tempDir = path.join(tmpdir(), "certd");
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
const tempCertPath = path.join(tempDir, this.appendTimeSuffix("cert") + ".pfx");
fs.writeFileSync(tempCertPath, Buffer.from(this.cert.pfx, "base64"));
const remoteTmpDir = await sshClient.exec({
connectConf: sshConf,
// 获取远程服务器的tmpdir
script: ["$env:temp"],
});
const remoteTempCertPath = path.join(remoteTmpDir.trim(), "certd", "iis", path.basename(tempCertPath));
await sshClient.uploadFiles({
connectConf: sshConf,
transports: [
{
localPath: tempCertPath,
remotePath: remoteTempCertPath,
},
],
mkdirs: true,
});
try {
for (const siteName of this.siteNames) {
const script = updateCertScriptTemplate
.replaceAll("{SITE_NAME}", siteName)
.replaceAll("{PASSWORD}", this.pfxPassword || "")
.replaceAll("{CERT_PATH}", remoteTempCertPath);
await sshClient.exec({
connectConf: sshConf,
script: [script],
throwOnStdErr: true,
});
}
} finally {
fs.unlinkSync(tempCertPath);
}
this.logger.info(`更新证书完成`);
}
async onGetSiteList() {
if (!this.accessId) {
throw new Error("请选择Access授权");
}
const sshConf = await this.getAccess<SshAccess>(this.accessId);
const sshClient = new SshClient(this.logger);
await this.checkTerminalType(sshClient, sshConf);
const res = await sshClient.exec({
connectConf: sshConf,
script: [getAllBindingsScript],
});
/**
*
* SiteName HttpsBindings
* -------- -------------
* first *:443:first.handsfree.work
*/
// 解析获取网站名称
const siteOptions = [];
let start = false;
const lines = res.split("\n");
lines.map((item: any) => {
if (item.includes("-------result start--------")) {
start = true;
return;
}
if (item.includes("-------result end--------")) {
start = false;
return;
}
if (!start) {
return;
}
item = item.trim();
if (item === "" || !item.includes(" | ")) {
return;
}
const [name, value] = item.split(" | ");
const siteName = name.trim();
let domain = value;
if (value.includes(":")) {
domain = value.split(":")[2].trim();
}
siteOptions.push({
label: `${siteName}<${domain}>`,
value: siteName,
domain: domain,
});
});
return utils.options.buildGroupOptions(siteOptions, this.certDomains);
}
private async checkTerminalType(sshClient: SshClient, sshConf: SshAccess) {
this.logger.info("检查终端类型");
const isCmd = await sshClient.getIsCmd({
connectConf: sshConf,
});
this.logger.info("isCmd", isCmd);
if (isCmd) {
throw new Error("不支持cmd,请将OpenSSH的默认终端设置为powershell,如何设置请参考: https://certd.docmirror.cn/guide/use/host/windows.html");
}
}
}
const updateCertScriptTemplate = `
try{
$siteName = "{SITE_NAME}"
$password = "{PASSWORD}"
$certPath = "{CERT_PATH}"
if($password -eq "") {
Write-Host "cert password is empty"
}else{
$password = ConvertTo-SecureString -String $password -AsPlainText -Force
}
# 导入 PFX 证书
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2
# $cert.Import($certPath, $password, [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::MachineKeySet)
$cert.Import(
$certPath,
$password,
[System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::MachineKeySet -bor
[System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::PersistKeySet
)
# 打开 LocalMachine 的证书存储
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store("My", "LocalMachine")
$store.Open("ReadWrite")
$store.Add($cert)
$store.Close()
Write-Host "import cert success"
# 导入 IIS 模块
Import-Module WebAdministration
# 网站名称和证书指纹
$thumbprint = $cert.Thumbprint # 获取证书指纹
Write-Host 'site name:' $siteName
Write-Host 'cert info:' $cert.Thumbprint
# 绑定新的证书
$binding = Get-WebBinding -Name $siteName -Protocol https
if ($binding) {
$binding.AddSslCertificate($thumbprint, 'My')
\tWrite-Host "update cert success"
} else {
Write-Error "HTTPS bind not found, cant add SSL certHttps绑定不存在,请先创建https绑定"
}
# 确认更改
Get-WebBinding -Name $siteName -Protocol https
# 重启 IIS
iisreset
} catch {
Write-Error "发生错误:$($_.Exception.Message)"
}
`;
const getAllBindingsScript = `
try{
# 导入 WebAdministration 模块
Import-Module WebAdministration
# 获取所有 IIS 站点
$sites = Get-IISSite
# 创建一个列表用于存储结果
$result = @()
# 遍历每个站点
foreach ($site in $sites) {
# 获取该站点的所有绑定
$bindings = Get-WebBinding -Name $site.Name
# 筛选出 HTTPS 绑定
$httpsBindings = $bindings | Where-Object { $_.protocol -eq "https" }
# 将站点名称和 HTTPS 绑定信息添加到结果
$result += [PSCustomObject]@{
SiteName = $site.Name
HttpsBindings = if ($httpsBindings) {
$httpsBindings | ForEach-Object { $_.bindingInformation }
} else {
"No HTTPS bindings"
}
}
}
# 显示结果
$result | Format-Table -AutoSize
Write-Output "-------result start--------"
foreach ($item in $result) {
Write-Output "$($item.SiteName) | $($item.HttpsBindings)"
}
Write-Output "-------result end--------"
} catch {
Write-Error "发生错误:$($_.Exception.Message)"
}
`;
new HostDeployToIIS();
@@ -1,7 +1,7 @@
import {AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput} from '@certd/pipeline';
import {CertInfo} from "@certd/plugin-cert";
import {ossClientFactory} from "@certd/plugin-lib";
import {utils} from "@certd/basic";
import { ossClientFactory } from '../../plugin-lib/oss/factory.js';
@IsTaskPlugin({
name: 'UploadCertToOss',
@@ -1,7 +1,7 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput, TaskOutput } from '@certd/pipeline';
import { CertInfo, CertReader, CertReaderHandleContext } from '@certd/plugin-cert';
import dayjs from 'dayjs';
import { SshAccess, SshClient } from '@certd/plugin-lib';
import { SshAccess, SshClient } from '../../../plugin-lib/ssh/index.js';
import { CertApplyPluginNames} from '@certd/plugin-cert';
@IsTaskPlugin({
name: 'uploadCertToHost',
@@ -0,0 +1,45 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
@IsAccess({
name: "aliesa",
title: "阿里云ESA授权",
desc: "",
icon: "ant-design:aliyun-outlined",
order: 0,
})
export class AliesaAccess extends BaseAccess {
@AccessInput({
title: "阿里云授权",
component: {
name: "access-selector",
vModel: "modelValue",
type: "aliyun",
},
helper: "请选择阿里云授权",
required: true,
})
accessId = "";
@AccessInput({
title: "地区",
component: {
name: "a-select",
vModel: "value",
options: [
{
label: "杭州",
value: "cn-hangzhou",
},
{
label: "新加坡",
value: "ap-southeast-1",
},
],
},
helper: "请选择ESA地区",
required: true,
})
region = "";
}
new AliesaAccess();
@@ -0,0 +1,71 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
@IsAccess({
name: "alioss",
title: "阿里云OSS授权",
desc: "包含地域和Bucket",
icon: "ant-design:aliyun-outlined",
})
export class AliossAccess extends BaseAccess {
@AccessInput({
title: "阿里云授权",
component: {
name: "access-selector",
vModel: "modelValue",
type: "aliyun",
},
helper: "请选择阿里云授权",
required: true,
})
accessId = "";
@AccessInput({
title: "大区",
component: {
name: "a-auto-complete",
vModel: "value",
options: [
{ value: "oss-cn-hangzhou", label: "华东1(杭州)" },
{ value: "oss-cn-shanghai", label: "华东2(上海)" },
{ value: "oss-cn-nanjing", label: "华东5(南京-本地地域)" },
{ value: "oss-cn-fuzhou", label: "华东6(福州-本地地域)" },
{ value: "oss-cn-wuhan-lr", label: "华中1(武汉-本地地域)" },
{ value: "oss-cn-qingdao", label: "华北1(青岛)" },
{ value: "oss-cn-beijing", label: "华北2(北京)" },
{ value: "oss-cn-zhangjiakou", label: "华北 3(张家口)" },
{ value: "oss-cn-huhehaote", label: "华北5(呼和浩特)" },
{ value: "oss-cn-wulanchabu", label: "华北6(乌兰察布)" },
{ value: "oss-cn-shenzhen", label: "华南1(深圳)" },
{ value: "oss-cn-heyuan", label: "华南2(河源)" },
{ value: "oss-cn-guangzhou", label: "华南3(广州)" },
{ value: "oss-cn-chengdu", label: "西南1(成都)" },
{ value: "oss-cn-hongkong", label: "中国香港" },
{ value: "oss-us-west-1", label: "美国(硅谷)①" },
{ value: "oss-us-east-1", label: "美国(弗吉尼亚)①" },
{ value: "oss-ap-northeast-1", label: "日本(东京)①" },
{ value: "oss-ap-northeast-2", label: "韩国(首尔)" },
{ value: "oss-ap-southeast-1", label: "新加坡①" },
{ value: "oss-ap-southeast-2", label: "澳大利亚(悉尼)①" },
{ value: "oss-ap-southeast-3", label: "马来西亚(吉隆坡)①" },
{ value: "oss-ap-southeast-5", label: "印度尼西亚(雅加达)①" },
{ value: "oss-ap-southeast-6", label: "菲律宾(马尼拉)" },
{ value: "oss-ap-southeast-7", label: "泰国(曼谷)" },
{ value: "oss-eu-central-1", label: "德国(法兰克福)①" },
{ value: "oss-eu-west-1", label: "英国(伦敦)" },
{ value: "oss-me-east-1", label: "阿联酋(迪拜)①" },
{ value: "oss-rg-china-mainland", label: "无地域属性(中国内地)" },
],
},
required: true,
})
region!: string;
@AccessInput({
title: "Bucket",
helper: "存储桶名称",
required: true,
})
bucket!: string;
}
new AliossAccess();
@@ -0,0 +1,129 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
import { ILogger } from "@certd/basic";
export type AliyunClientV2Req = {
action: string;
version: string;
protocol?: "HTTPS";
// 接口 HTTP 方法
method?: "GET" | "POST";
authType?: "AK";
style?: "RPC" | "ROA";
// 接口 PATH
pathname?: string;
data?: any;
};
export class AliyunClientV2 {
access: AliyunAccess;
logger: ILogger;
endpoint: string;
client: any;
constructor(opts: { access: AliyunAccess; logger: ILogger; endpoint: string }) {
this.access = opts.access;
this.logger = opts.logger;
this.endpoint = opts.endpoint;
}
async getClient() {
if (this.client) {
return this.client;
}
const $OpenApi = await import("@alicloud/openapi-client");
// const Credential = await import("@alicloud/credentials");
// //@ts-ignore
// const credential = new Credential.default.default({
//
// type: "access_key",
// });
const config = new $OpenApi.Config({
accessKeyId: this.access.accessKeyId,
accessKeySecret: this.access.accessKeySecret,
});
// Endpoint 请参考 https://api.aliyun.com/product/FC
// config.endpoint = `esa.${this.regionId}.aliyuncs.com`;
config.endpoint = this.endpoint;
//@ts-ignore
this.client = new $OpenApi.default.default(config);
return this.client;
}
async doRequest(req: AliyunClientV2Req) {
const client = await this.getClient();
const $OpenApi = await import("@alicloud/openapi-client");
const $Util = await import("@alicloud/tea-util");
const OpenApiUtil = await import("@alicloud/openapi-util");
const params = new $OpenApi.Params({
// 接口名称
action: req.action,
// 接口版本
version: req.version,
// 接口协议
protocol: "HTTPS",
// 接口 HTTP 方法
method: req.method ?? "POST",
authType: req.authType ?? "AK",
style: req.style ?? "RPC",
// 接口 PATH
pathname: req.pathname ?? `/`,
// 接口请求体内容格式
reqBodyType: "json",
// 接口响应体内容格式
bodyType: "json",
});
if (req.data?.query) {
//@ts-ignore
req.data.query = OpenApiUtil.default.default.query(req.data.query);
}
const runtime = new $Util.RuntimeOptions({});
const request = new $OpenApi.OpenApiRequest(req.data);
// 复制代码运行请自行打印 API 的返回值
// 返回值实际为 Map 类型,可从 Map 中获得三类数据:响应体 body、响应头 headers、HTTP 返回的状态码 statusCode。
const res = await client.callApi(params, request, runtime);
/**
* res?.body?.
*/
return res?.body;
}
}
@IsAccess({
name: "aliyun",
title: "阿里云授权",
desc: "",
icon: "ant-design:aliyun-outlined",
order: 0,
})
export class AliyunAccess extends BaseAccess {
@AccessInput({
title: "accessKeyId",
component: {
placeholder: "accessKeyId",
},
helper: "登录阿里云控制台->AccessKey管理页面获取。",
required: true,
})
accessKeyId = "";
@AccessInput({
title: "accessKeySecret",
component: {
placeholder: "accessKeySecret",
},
required: true,
encrypt: true,
helper: "注意:证书申请需要dns解析权限;其他阿里云插件,需要对应的权限,比如证书上传需要证书管理权限;嫌麻烦就用主账号的全量权限的accessKey",
})
accessKeySecret = "";
getClient(endpoint: string) {
return new AliyunClientV2({
access: this,
logger: this.ctx.logger,
endpoint: endpoint,
});
}
}
new AliyunAccess();
@@ -0,0 +1,3 @@
export * from "./aliyun-access.js";
export * from "./alioss-access.js";
export * from "./aliesa-access.js";
@@ -0,0 +1,3 @@
export * from "./access/index.js";
export * from "./lib/index.js";
export * from "./utils/index.js";
@@ -0,0 +1,78 @@
import { getGlobalAgents, ILogger } from "@certd/basic";
export class AliyunClient {
client: any;
logger: ILogger;
agent: any;
useROAClient: boolean;
constructor(opts: { logger: ILogger; useROAClient?: boolean }) {
this.logger = opts.logger;
this.useROAClient = opts.useROAClient || false;
const agents = getGlobalAgents();
this.agent = agents.httpsAgent;
}
async getSdk() {
if (this.useROAClient) {
return await this.getROAClient();
}
const Core = await import("@alicloud/pop-core");
return Core.default;
}
async getROAClient() {
const Core = await import("@alicloud/pop-core");
console.log("aliyun sdk", Core);
// @ts-ignore
return Core.ROAClient;
}
async init(opts: any) {
const Core = await this.getSdk();
this.client = new Core(opts);
return this.client;
}
checkRet(ret: any) {
if (ret.Code != null && ret.Code !== "OK" && ret.Message !== "OK") {
throw new Error("执行失败:" + ret.Message);
}
}
async request(
name: string,
params: any,
requestOption: any = {
method: "POST",
formatParams: false,
}
) {
if (!this.useROAClient) {
requestOption.agent = this.agent;
}
const getNumberFromEnv = (key: string, defValue: number) => {
const value = process.env[key];
if (value) {
try {
return parseInt(value);
} catch (e: any) {
this.logger.error(`环境变量${key}设置错误,应该是一个数字,当前值为${value},将使用默认值:${defValue}`);
return defValue;
}
} else {
return defValue;
}
};
// 连接超时设置,仅对当前请求有效。
requestOption.connectTimeout = getNumberFromEnv("ALIYUN_CLIENT_CONNECT_TIMEOUT", 8000);
// 读超时设置,仅对当前请求有效。
requestOption.readTimeout = getNumberFromEnv("ALIYUN_CLIENT_READ_TIMEOUT", 8000);
const res = await this.client.request(name, params, requestOption);
this.checkRet(res);
return res;
}
}
@@ -0,0 +1,3 @@
export * from "./base-client.js";
export * from "./ssl-client.js";
export * from "./oss-client.js";
@@ -0,0 +1,87 @@
import { AliyunAccess } from "../access/index.js";
export class AliossClient {
access: AliyunAccess;
region: string;
bucket: string;
client: any;
constructor(opts: { access: AliyunAccess; bucket: string; region: string }) {
this.access = opts.access;
this.bucket = opts.bucket;
this.region = opts.region;
}
async init() {
if (this.client) {
return;
}
// @ts-ignore
const OSS = await import("ali-oss");
const ossClient = new OSS.default({
accessKeyId: this.access.accessKeyId,
accessKeySecret: this.access.accessKeySecret,
// yourRegion填写Bucket所在地域。以华东1(杭州)为例,Region填写为oss-cn-hangzhou。
region: this.region,
//@ts-ignore
authorizationV4: true,
// yourBucketName填写Bucket名称。
bucket: this.bucket,
});
// oss
this.client = ossClient;
}
async doRequest(bucket: string, xml: string, params: any) {
await this.init();
params = this.client._bucketRequestParams("POST", bucket, {
...params,
});
params.content = xml;
params.mime = "xml";
params.successStatuses = [200];
const res = await this.client.request(params);
this.checkRet(res);
return res;
}
checkRet(ret: any) {
if (ret.Code != null) {
throw new Error("执行失败:" + ret.Message);
}
}
async uploadFile(filePath: string, content: Buffer | string, timeout = 1000 * 60 * 60) {
await this.init();
return await this.client.put(filePath, content, {
timeout,
});
}
async removeFile(filePath: string) {
await this.init();
return await this.client.delete(filePath);
}
async downloadFile(key: string, savePath: string, timeout = 1000 * 60 * 60) {
await this.init();
return await this.client.get(key, savePath, {
timeout,
});
}
async listDir(dirKey: string) {
await this.init();
const res = await this.client.listV2({
prefix: dirKey,
// max-keys: 100,
// continuation-token: "token",
// delimiter: "/",
// marker: "marker",
// encoding-type: "url",
});
return res.objects;
}
}
@@ -0,0 +1,185 @@
import { ILogger } from "@certd/basic";
import { AliyunAccess } from "../access/index.js";
import { AliyunClient } from "./index.js";
export type AliyunCertInfo = {
crt: string; //fullchain证书
key: string; //私钥
};
export type AliyunSslClientOpts = {
access: AliyunAccess;
logger: ILogger;
endpoint?: string;
region?: string;
};
export type AliyunSslGetResourceListReq = {
cloudProduct: string;
};
export type AliyunSslCreateDeploymentJobReq = {
name: string;
jobType: string;
contactIds: string[];
resourceIds: string[];
certIds: string[];
};
export type AliyunSslUploadCertReq = {
name: string;
cert: AliyunCertInfo;
};
export type CasCertInfo = { certId: number; certName: string; certIdentifier: string; notAfter: number; casRegion: string };
export class AliyunSslClient {
opts: AliyunSslClientOpts;
logger: ILogger;
constructor(opts: AliyunSslClientOpts) {
this.opts = opts;
this.logger = opts.logger;
}
checkRet(ret: any) {
if (ret.Code != null) {
throw new Error("执行失败:" + ret.Message);
}
}
async getClient() {
const access = this.opts.access;
const client = new AliyunClient({ logger: this.opts.logger });
let endpoint = this.opts.endpoint || "cas.aliyuncs.com";
if (this.opts.endpoint == null && this.opts.region) {
if (this.opts.region === "cn-hangzhou") {
endpoint = "cas.aliyuncs.com";
} else {
endpoint = `cas.${this.opts.region}.aliyuncs.com`;
}
}
await client.init({
accessKeyId: access.accessKeyId,
accessKeySecret: access.accessKeySecret,
endpoint: `https://${endpoint}`,
apiVersion: "2020-04-07",
});
return client;
}
async getCertInfo(certId: number): Promise<CasCertInfo> {
const client = await this.getClient();
const params = {
CertId: certId,
};
const res = await client.request("GetUserCertificateDetail", params);
this.checkRet(res);
return {
certId: certId,
certName: res.Name,
certIdentifier: res.CertIdentifier,
notAfter: res.NotAfter,
casRegion: this.getCasRegionFromEndpoint(this.opts.endpoint),
};
}
async uploadCert(req: AliyunSslUploadCertReq) {
const client = await this.getClient();
const params = {
Name: req.name,
Cert: req.cert.crt,
Key: req.cert.key,
};
const requestOption = {
method: "POST",
};
this.opts.logger.info(`开始上传证书:${req.name}`);
const ret: any = await client.request("UploadUserCertificate", params, requestOption);
this.checkRet(ret);
this.opts.logger.info("证书上传成功:aliyunCertId=", ret.CertId);
//output
return ret.CertId;
}
async getResourceList(req: AliyunSslGetResourceListReq) {
const client = await this.getClient();
const params = {
CloudName: "aliyun",
CloudProduct: req.cloudProduct,
};
const requestOption = {
method: "POST",
formatParams: false,
};
const res = await client.request("ListCloudResources", params, requestOption);
this.checkRet(res);
return res;
}
async createDeploymentJob(req: AliyunSslCreateDeploymentJobReq) {
const client = await this.getClient();
const params = {
Name: req.name,
JobType: req.jobType,
ContactIds: req.contactIds.join(","),
ResourceIds: req.resourceIds.join(","),
CertIds: req.certIds.join(","),
};
const requestOption = {
method: "POST",
formatParams: false,
};
const res = await client.request("CreateDeploymentJob", params, requestOption);
this.checkRet(res);
return res;
}
async getContactList() {
const params = {};
const requestOption = {
method: "POST",
formatParams: false,
};
const client = await this.getClient();
const res = await client.request("ListContact", params, requestOption);
this.checkRet(res);
return res;
}
async doRequest(action: string, params: any, requestOption: any) {
const client = await this.getClient();
const res = await client.request(action, params, requestOption);
this.checkRet(res);
return res;
}
async deleteCert(certId: any) {
await this.doRequest("DeleteUserCertificate", { CertId: certId }, { method: "POST" });
}
getCasRegionFromEndpoint(endpoint: string) {
if (!endpoint) {
return "cn-hangzhou";
}
/**
* {value: 'cas.aliyuncs.com', label: '中国大陆'},
* {value: 'cas.ap-southeast-1.aliyuncs.com', label: '新加坡'},
* {value: 'cas.eu-central-1.aliyuncs.com', label: '德国(法兰克福)'},
*/
const region = endpoint.replace(".aliyuncs.com", "").replace("cas.", "");
if (region === "cas") {
return "cn-hangzhou";
}
return region;
}
}
@@ -1,6 +1,6 @@
import dayjs from "dayjs";
import { AliyunSslClient } from "@certd/plugin-lib";
import { CertInfo, CertReader } from "@certd/plugin-cert";
import { AliyunSslClient } from "../lib/index.js";
export const ZoneOptions = [{ value: 'cn-hangzhou' }];
export function appendTimeSuffix(name: string) {
@@ -0,0 +1,77 @@
import { IsAccess, AccessInput, BaseAccess } from "@certd/pipeline";
/**
* 这个注解将注册一个授权配置
* 在certd的后台管理系统中,用户可以选择添加此类型的授权
*/
@IsAccess({
name: "ftp",
title: "FTP授权",
desc: "",
icon: "mdi:folder-upload-outline",
})
export class FtpAccess extends BaseAccess {
@AccessInput({
title: "host",
component: {
placeholder: "ip / 域名",
name: "a-input",
vModel: "value",
},
helper: "FTP地址",
required: true,
})
host!: string;
@AccessInput({
title: "端口",
value: 21,
component: {
placeholder: "21",
name: "a-input-number",
vModel: "value",
},
helper: "FTP端口",
required: true,
})
port!: string;
@AccessInput({
title: "user",
component: {
placeholder: "用户名",
},
helper: "FTP用户名",
required: true,
})
user!: string;
@AccessInput({
title: "password",
component: {
placeholder: "密码",
component: {
name: "a-input-password",
vModel: "value",
},
},
encrypt: true,
helper: "FTP密码",
required: true,
})
password!: string;
@AccessInput({
title: "secure",
value: false,
component: {
name: "a-switch",
vModel: "checked",
},
helper: "是否使用SSL",
required: true,
})
secure?: boolean = false;
}
new FtpAccess();
@@ -0,0 +1,63 @@
import { FtpAccess } from "./access.js";
import { ILogger } from "@certd/basic";
import path from "node:path";
export class FtpClient {
access: FtpAccess = null;
logger: ILogger = null;
client: any;
constructor(opts: { access: FtpAccess; logger: ILogger }) {
this.access = opts.access;
this.logger = opts.logger;
}
async connect(callback: (client: FtpClient) => Promise<any>) {
const ftp = await import("basic-ftp");
const Client = ftp.Client;
const client = new Client();
client.ftp.verbose = true;
this.logger.info("开始连接FTP");
await client.access(this.access as any);
this.logger.info("FTP连接成功");
this.client = client;
try {
return await callback(this);
} finally {
if (client) {
client.close();
}
}
}
async upload(filePath: string, remotePath: string): Promise<void> {
if (!remotePath) {
return;
}
const dirname = path.dirname(remotePath);
this.logger.info(`确保目录存在:${dirname}`);
await this.client.ensureDir(dirname);
this.logger.info(`开始上传文件${filePath} -> ${remotePath}`);
await this.client.uploadFrom(filePath, remotePath);
}
async remove(filePath: string): Promise<void> {
this.logger.info(`开始删除文件${filePath}`);
await this.client.remove(filePath, true);
}
async listDir(dir: string): Promise<any[]> {
if (!dir) {
return [];
}
if (!dir.endsWith("/")) {
dir = dir + "/";
}
this.logger.info(`开始列出目录${dir}`);
return await this.client.list(dir);
}
async download(filePath: string, savePath: string): Promise<void> {
this.logger.info(`开始下载文件${filePath} -> ${savePath}`);
await this.client.downloadTo(savePath, filePath);
}
}
@@ -0,0 +1,2 @@
export * from "./access.js";
export * from "./client.js";
@@ -0,0 +1,7 @@
export * from "./ssh/index.js";
export * from "./ftp/index.js";
export * from "./tencent/index.js";
export * from "./qiniu/index.js";
export * from "./oss/index.js";
export * from "./s3/index.js";
export * from "./aliyun/index.js";
@@ -0,0 +1,90 @@
import { IAccessService } from "@certd/pipeline";
import { ILogger, utils } from "@certd/basic";
import dayjs from "dayjs";
export type OssClientRemoveByOpts = {
dir?: string;
//删除多少天前的文件
beforeDays?: number;
};
export type OssFileItem = {
//文件全路径
path: string;
size: number;
//毫秒时间戳
lastModified: number;
};
export type IOssClient = {
upload: (fileName: string, fileContent: Buffer) => Promise<void>;
remove: (fileName: string, opts?: { joinRootDir?: boolean }) => Promise<void>;
download: (fileName: string, savePath: string) => Promise<void>;
removeBy: (removeByOpts: OssClientRemoveByOpts) => Promise<void>;
listDir: (dir: string) => Promise<OssFileItem[]>;
};
export type OssClientContext = {
accessService: IAccessService;
logger: ILogger;
utils: typeof utils;
};
export abstract class BaseOssClient<A> implements IOssClient {
rootDir: string = "";
access: A = null;
logger: ILogger;
utils: typeof utils;
ctx: OssClientContext;
protected constructor(opts: { rootDir?: string; access: A }) {
this.rootDir = opts.rootDir || "";
this.access = opts.access;
}
join(...strs: string[]) {
let res = "";
for (const item of strs) {
if (item) {
if (!res) {
res = item;
} else {
res += "/" + item;
}
}
}
res = res.replace(/[\\/]+/g, "/");
return res;
}
async setCtx(ctx: any) {
// set context
this.ctx = ctx;
this.logger = ctx.logger;
this.utils = ctx.utils;
await this.init();
}
async init() {
// do nothing
}
abstract remove(fileName: string, opts?: { joinRootDir?: boolean }): Promise<void>;
abstract upload(fileName: string, fileContent: Buffer): Promise<void>;
abstract download(fileName: string, savePath: string): Promise<void>;
abstract listDir(dir: string): Promise<OssFileItem[]>;
async removeBy(removeByOpts: OssClientRemoveByOpts): Promise<void> {
const list = await this.listDir(removeByOpts.dir);
// removeByOpts.beforeDays = 0;
const beforeDate = dayjs().subtract(removeByOpts.beforeDays, "day");
for (const item of list) {
if (item.lastModified && item.lastModified < beforeDate.valueOf()) {
await this.remove(item.path, { joinRootDir: false });
}
}
}
}
@@ -0,0 +1,41 @@
import { OssClientContext } from "./api.js";
export class OssClientFactory {
async getClassByType(type: string) {
if (type === "alioss") {
const module = await import("./impls/alioss.js");
return module.default;
} else if (type === "ssh") {
const module = await import("./impls/ssh.js");
return module.default;
} else if (type === "sftp") {
const module = await import("./impls/sftp.js");
return module.default;
} else if (type === "ftp") {
const module = await import("./impls/ftp.js");
return module.default;
} else if (type === "tencentcos") {
const module = await import("./impls/tencentcos.js");
return module.default;
} else if (type === "qiniuoss") {
const module = await import("./impls/qiniuoss.js");
return module.default;
} else if (type === "s3") {
const module = await import("./impls/s3.js");
return module.default;
} else {
throw new Error(`暂不支持此文件上传方式: ${type}`);
}
}
async createOssClientByType(type: string, opts: { rootDir?: string; access: any; ctx: OssClientContext }) {
const cls = await this.getClassByType(type);
if (cls) {
// @ts-ignore
const instance = new cls(opts);
await instance.setCtx(opts.ctx);
return instance;
}
}
}
export const ossClientFactory = new OssClientFactory();
@@ -0,0 +1,57 @@
import { BaseOssClient, OssFileItem } from "../api.js";
import { AliossAccess, AliossClient, AliyunAccess } from "../../aliyun/index.js";
import dayjs from "dayjs";
export default class AliOssClientImpl extends BaseOssClient<AliossAccess> {
client: AliossClient;
join(...strs: string[]) {
const str = super.join(...strs);
if (str.startsWith("/")) {
return str.substring(1);
}
return str;
}
async init() {
const aliyunAccess = await this.ctx.accessService.getById<AliyunAccess>(this.access.accessId);
const client = new AliossClient({
access: aliyunAccess,
bucket: this.access.bucket,
region: this.access.region,
});
await client.init();
this.client = client;
}
async download(filePath: string, savePath: string): Promise<void> {
const key = this.join(this.rootDir, filePath);
await this.client.downloadFile(key, savePath);
}
async listDir(dir: string): Promise<OssFileItem[]> {
const dirKey = this.join(this.rootDir, dir) + "/";
const list = await this.client.listDir(dirKey);
this.logger.info(`列出目录: ${dirKey},文件数:${list.length}`);
return list.map(item => {
return {
path: item.name,
lastModified: dayjs(item.lastModified).valueOf(),
size: item.size,
};
});
}
async upload(filePath: string, fileContent: Buffer | string) {
const key = this.join(this.rootDir, filePath);
this.logger.info(`开始上传文件: ${key}`);
await this.client.uploadFile(key, fileContent);
this.logger.info(`文件上传成功: ${filePath}`);
}
async remove(filePath: string, opts?: { joinRootDir?: boolean }) {
if (opts?.joinRootDir !== false) {
filePath = this.join(this.rootDir, filePath);
}
const key = filePath;
// remove file from alioss
await this.client.removeFile(key);
this.logger.info(`文件删除成功: ${key}`);
}
}
@@ -0,0 +1,78 @@
import { BaseOssClient } from "../api.js";
import path from "path";
import os from "os";
import fs from "fs";
import { FtpAccess, FtpClient } from "../../ftp/index.js";
export default class FtpOssClientImpl extends BaseOssClient<FtpAccess> {
join(...strs: string[]) {
const str = super.join(...strs);
if (!str.startsWith("/")) {
return "/" + str;
}
return str;
}
async download(fileName: string, savePath: string) {
const client = this.getFtpClient();
await client.connect(async client => {
const path = this.join(this.rootDir, fileName);
await client.download(path, savePath);
});
}
async listDir(dir: string) {
const client = this.getFtpClient();
return await client.connect(async (client: FtpClient) => {
const path = this.join(this.rootDir, dir);
const res = await client.listDir(path);
return res.map(item => {
return {
path: this.join(path, item.name),
size: item.size,
lastModified: item.modifiedAt.getTime(),
};
});
});
}
async upload(filePath: string, fileContent: Buffer | string) {
const client = this.getFtpClient();
await client.connect(async client => {
let tmpFilePath = fileContent as string;
if (typeof fileContent !== "string") {
tmpFilePath = path.join(os.tmpdir(), "cert", "oss", filePath);
const dir = path.dirname(tmpFilePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(tmpFilePath, fileContent);
}
try {
// Write file to temp path
const path = this.join(this.rootDir, filePath);
await client.upload(tmpFilePath, path);
} finally {
// Remove temp file
fs.unlinkSync(tmpFilePath);
}
});
}
private getFtpClient() {
return new FtpClient({
access: this.access,
logger: this.logger,
});
}
async remove(filePath: string, opts?: { joinRootDir?: boolean }) {
if (opts?.joinRootDir !== false) {
filePath = this.join(this.rootDir, filePath);
}
const client = this.getFtpClient();
await client.connect(async client => {
await client.client.remove(filePath);
this.logger.info(`删除文件成功: ${filePath}`);
});
}
}
@@ -0,0 +1,50 @@
import { QiniuAccess, QiniuClient, QiniuOssAccess } from "../../qiniu/index.js";
import { BaseOssClient, OssFileItem } from "../api.js";
export default class QiniuOssClientImpl extends BaseOssClient<QiniuOssAccess> {
client: QiniuClient;
join(...strs: string[]) {
const str = super.join(...strs);
if (str.startsWith("/")) {
return str.substring(1);
}
return str;
}
async init() {
const qiniuAccess = await this.ctx.accessService.getById<QiniuAccess>(this.access.accessId);
this.client = new QiniuClient({
access: qiniuAccess,
logger: this.logger,
http: this.ctx.utils.http,
});
}
async download(fileName: string, savePath: string): Promise<void> {
const path = this.join(this.rootDir, fileName);
await this.client.downloadFile(this.access.bucket, path, savePath);
}
async listDir(dir: string): Promise<OssFileItem[]> {
const path = this.join(this.rootDir, dir);
const res = await this.client.listDir(this.access.bucket, path);
return res.items.map(item => {
return {
path: item.key,
size: item.fsize,
//ns ,纳秒,去掉低4位 为毫秒
lastModified: Math.floor(item.putTime / 10000),
};
});
}
async upload(filePath: string, fileContent: Buffer | string) {
const path = this.join(this.rootDir, filePath);
await this.client.uploadFile(this.access.bucket, path, fileContent);
}
async remove(filePath: string, opts?: { joinRootDir?: boolean }) {
if (opts?.joinRootDir !== false) {
filePath = this.join(this.rootDir, filePath);
}
await this.client.removeFile(this.access.bucket, filePath);
}
}
@@ -0,0 +1,101 @@
import { BaseOssClient, OssFileItem } from "../api.js";
import path from "node:path";
import { S3Access } from "../../s3/access.js";
import fs from "fs";
import dayjs from "dayjs";
export default class S3OssClientImpl extends BaseOssClient<S3Access> {
client: any;
join(...strs: string[]) {
const str = super.join(...strs);
if (str.startsWith("/")) {
return str.substring(1);
}
return str;
}
async init() {
// import { S3Client } from "@aws-sdk/client-s3";
//@ts-ignore
const { S3Client } = await import("@aws-sdk/client-s3");
this.client = new S3Client({
forcePathStyle: true,
//@ts-ignore
s3ForcePathStyle: true,
credentials: {
accessKeyId: this.access.accessKeyId, // 默认 MinIO 访问密钥
secretAccessKey: this.access.secretAccessKey, // 默认 MinIO 秘密密钥
},
region: "us-east-1",
endpoint: this.access.endpoint,
});
}
async download(filePath: string, savePath: string): Promise<void> {
// @ts-ignore
const { GetObjectCommand } = await import("@aws-sdk/client-s3");
const key = path.join(this.rootDir, filePath);
const params = {
Bucket: this.access.bucket, // The name of the bucket. For example, 'sample_bucket_101'.
Key: key, // The name of the object. For example, 'sample_upload.txt'.
};
const res = await this.client.send(new GetObjectCommand({ ...params }));
const fileContent = fs.createWriteStream(savePath);
res.Body.pipe(fileContent);
this.logger.info(`文件下载成功: ${savePath}`);
}
async listDir(dir: string): Promise<OssFileItem[]> {
// @ts-ignore
const { ListObjectsCommand } = await import("@aws-sdk/client-s3");
const dirKey = this.join(this.rootDir, dir);
const params = {
Bucket: this.access.bucket, // The name of the bucket. For example, 'sample_bucket_101'.
Prefix: dirKey, // The name of the object. For example, 'sample_upload.txt'.
};
const res = await this.client.send(new ListObjectsCommand({ ...params }));
if (!res.Contents) {
return [];
}
return res.Contents.map(item => {
return {
path: item.Key,
size: item.Size,
lastModified: dayjs(item.LastModified).valueOf(),
};
});
}
async upload(filePath: string, fileContent: Buffer | string) {
// @ts-ignore
const { PutObjectCommand } = await import("@aws-sdk/client-s3");
const key = path.join(this.rootDir, filePath);
this.logger.info(`开始上传文件: ${key}`);
const params = {
Bucket: this.access.bucket, // The name of the bucket. For example, 'sample_bucket_101'.
Key: key, // The name of the object. For example, 'sample_upload.txt'.
};
if (typeof fileContent === "string") {
fileContent = fs.createReadStream(fileContent) as any;
}
await this.client.send(new PutObjectCommand({ Body: fileContent, ...params }));
this.logger.info(`文件上传成功: ${filePath}`);
}
async remove(filePath: string, opts?: { joinRootDir?: boolean }) {
if (opts?.joinRootDir !== false) {
filePath = this.join(this.rootDir, filePath);
}
const key = filePath;
// @ts-ignore
const { DeleteObjectCommand } = await import("@aws-sdk/client-s3");
await this.client.send(
new DeleteObjectCommand({
Bucket: this.access.bucket,
Key: key,
})
);
this.logger.info(`文件删除成功: ${key}`);
}
}
@@ -0,0 +1,82 @@
import { BaseOssClient, OssFileItem } from "../api.js";
import path from "path";
import os from "os";
import fs from "fs";
import { SftpAccess, SshAccess, SshClient } from "../../ssh/index.js";
export default class SftpOssClientImpl extends BaseOssClient<SftpAccess> {
async download(fileName: string, savePath: string): Promise<void> {
const path = this.join(this.rootDir, fileName);
const client = new SshClient(this.logger);
const access = await this.ctx.accessService.getById<SshAccess>(this.access.sshAccess);
await client.download({
connectConf: access,
filePath: path,
savePath,
});
}
async listDir(dir: string): Promise<OssFileItem[]> {
const path = this.join(this.rootDir, dir);
const client = new SshClient(this.logger);
const access = await this.ctx.accessService.getById<SshAccess>(this.access.sshAccess);
const res = await client.listDir({
connectConf: access,
dir: path,
});
return res.map(item => {
return {
path: this.join(path, item.filename),
size: item.size,
lastModified: item.attrs.atime * 1000,
};
});
}
async upload(filePath: string, fileContent: Buffer | string) {
let tmpFilePath = fileContent as string;
if (typeof fileContent !== "string") {
tmpFilePath = path.join(os.tmpdir(), "cert", "oss", filePath);
const dir = path.dirname(tmpFilePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(tmpFilePath, fileContent);
}
const access = await this.ctx.accessService.getById<SshAccess>(this.access.sshAccess);
const key = this.join(this.rootDir, filePath);
try {
const client = new SshClient(this.logger);
await client.uploadFiles({
connectConf: access,
mkdirs: true,
transports: [
{
localPath: tmpFilePath,
remotePath: key,
},
],
uploadType: "sftp",
opts: {
mode: this.access?.fileMode ?? undefined,
},
});
} finally {
// Remove temp file
fs.unlinkSync(tmpFilePath);
}
}
async remove(filePath: string, opts?: { joinRootDir?: boolean }) {
const access = await this.ctx.accessService.getById<SshAccess>(this.access.sshAccess);
const client = new SshClient(this.logger);
if (opts?.joinRootDir !== false) {
filePath = this.join(this.rootDir, filePath);
}
await client.removeFiles({
connectConf: access,
files: [filePath],
});
}
}
@@ -0,0 +1,61 @@
import { BaseOssClient, OssClientRemoveByOpts, OssFileItem } from "../api.js";
import path from "path";
import os from "os";
import fs from "fs";
import { SshAccess, SshClient } from "../../ssh/index.js";
//废弃
export default class SshOssClientImpl extends BaseOssClient<SshAccess> {
download(fileName: string, savePath: string): Promise<void> {
throw new Error("Method not implemented.");
}
removeBy(removeByOpts: OssClientRemoveByOpts): Promise<void> {
throw new Error("Method not implemented.");
}
listDir(dir: string): Promise<OssFileItem[]> {
throw new Error("Method not implemented.");
}
async upload(filePath: string, fileContent: Buffer) {
if (!filePath) {
filePath = "";
}
filePath = filePath.trim();
const tmpFilePath = path.join(os.tmpdir(), "cert", "http", filePath);
// Write file to temp path
const dir = path.dirname(tmpFilePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(tmpFilePath, fileContent);
const key = this.rootDir + filePath;
try {
const client = new SshClient(this.logger);
await client.uploadFiles({
connectConf: this.access,
mkdirs: true,
transports: [
{
localPath: tmpFilePath,
remotePath: key,
},
],
});
} finally {
// Remove temp file
fs.unlinkSync(tmpFilePath);
}
}
async remove(filePath: string, opts?: { joinRootDir?: boolean }) {
if (opts?.joinRootDir !== false) {
filePath = this.join(this.rootDir, filePath);
}
const client = new SshClient(this.logger);
await client.removeFiles({
connectConf: this.access,
files: [filePath],
});
}
}
@@ -0,0 +1,54 @@
import dayjs from "dayjs";
import { TencentAccess, TencentCosAccess, TencentCosClient } from "../../tencent/index.js";
import { BaseOssClient, OssFileItem } from "../api.js";
export default class TencentOssClientImpl extends BaseOssClient<TencentCosAccess> {
client: TencentCosClient;
join(...strs: string[]) {
const str = super.join(...strs);
if (str.startsWith("/")) {
return str.substring(1);
}
return str;
}
async init() {
const access = await this.ctx.accessService.getById<TencentAccess>(this.access.accessId);
this.client = new TencentCosClient({
access: access,
logger: this.logger,
region: this.access.region,
bucket: this.access.bucket,
});
}
async download(filePath: string, savePath: string): Promise<void> {
const key = this.join(this.rootDir, filePath);
await this.client.downloadFile(key, savePath);
}
async listDir(dir: string): Promise<OssFileItem[]> {
const dirKey = this.join(this.rootDir, dir) + "/";
// @ts-ignore
const res: any[] = await this.client.listDir(dirKey);
return res.map(item => {
return {
path: item.Key,
size: item.Size,
lastModified: dayjs(item.LastModified).valueOf(),
};
});
}
async upload(filePath: string, fileContent: Buffer | string) {
const key = this.join(this.rootDir, filePath);
await this.client.uploadFile(key, fileContent);
this.logger.info(`文件上传成功: ${filePath}`);
}
async remove(filePath: string, opts?: { joinRootDir?: boolean }) {
if (opts?.joinRootDir !== false) {
filePath = this.join(this.rootDir, filePath);
}
await this.client.removeFile(filePath);
this.logger.info(`文件删除成功: ${filePath}`);
}
}
@@ -0,0 +1,2 @@
export * from "./factory.js";
export * from "./api.js";
@@ -0,0 +1,31 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
@IsAccess({
name: "qiniuoss",
title: "七牛OSS授权",
desc: "",
icon: "svg:icon-qiniuyun",
input: {},
})
export class QiniuOssAccess extends BaseAccess {
@AccessInput({
title: "七牛云授权",
component: {
name: "access-selector",
vModel: "modelValue",
type: "qiniu",
},
helper: "请选择七牛云授权",
required: true,
})
accessId = "";
@AccessInput({
title: "Bucket",
helper: "存储桶名称",
required: true,
})
bucket = "";
}
new QiniuOssAccess();
@@ -0,0 +1,26 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
@IsAccess({
name: "qiniu",
title: "七牛云授权",
desc: "",
icon: "svg:icon-qiniuyun",
input: {},
order: 2,
})
export class QiniuAccess extends BaseAccess {
@AccessInput({
title: "AccessKey",
rules: [{ required: true, message: "此项必填" }],
helper: "AK,前往[密钥管理](https://portal.qiniu.com/developer/user/key)获取",
})
accessKey!: string;
@AccessInput({
title: "SecretKey",
encrypt: true,
helper: "SK",
})
secretKey!: string;
}
new QiniuAccess();
@@ -0,0 +1,3 @@
export * from "./access.js";
export * from "./access-oss.js";
export * from "./lib/sdk.js";
@@ -0,0 +1,180 @@
import { HttpClient, ILogger, safePromise, utils } from "@certd/basic";
import { QiniuAccess } from "../access.js";
import fs from "fs";
export type QiniuCertInfo = {
key: string;
crt: string;
};
export class QiniuClient {
http: HttpClient;
access: QiniuAccess;
logger: ILogger;
constructor(opts: { http: HttpClient; access: QiniuAccess; logger: ILogger }) {
this.http = opts.http;
this.access = opts.access;
this.logger = opts.logger;
}
async uploadCert(cert: QiniuCertInfo, certName?: string) {
const url = "https://api.qiniu.com/sslcert";
const body = {
name: certName,
common_name: "certd",
pri: cert.key,
ca: cert.crt,
};
const res = await this.doRequest(url, "post", body);
return res.certID;
}
async bindCert(body: { certid: string; domain: string }) {
const url = "https://api.qiniu.com/cert/bind";
return await this.doRequest(url, "post", body);
}
async getCertBindings() {
const url = "https://api.qiniu.com/cert/bindings";
const res = await this.doRequest(url, "get");
return res;
}
async doRequest(url: string, method: string, body?: any) {
const { generateAccessToken } = await import("qiniu/qiniu/util.js");
const token = generateAccessToken(this.access, url);
const res = await this.http.request({
url,
method: method,
headers: {
Authorization: token,
},
data: body,
logRes: false,
});
if (res && res.error) {
if (res.error.includes("domaintype")) {
throw new Error("请求失败:" + res.error + ",该域名属于CDN域名,请使用部署到七牛云CDN插件");
}
throw new Error("请求失败:" + res.error);
}
console.log("res", res);
return res;
}
async doRequestV2(opts: { url: string; method: string; body?: any; contentType: string }) {
const { HttpClient } = await import("qiniu/qiniu/httpc/client.js");
const { QiniuAuthMiddleware } = await import("qiniu/qiniu/httpc/middleware/qiniuAuth.js");
// X-Qiniu-Date: 20060102T150405Z
const auth = new QiniuAuthMiddleware({
mac: {
...this.access,
options: {},
},
});
const http = new HttpClient({ timeout: 10000, middlewares: [auth] });
console.log("http", http);
return safePromise((resolve, reject) => {
try {
http.get({
url: opts.url,
headers: {
"Content-Type": opts.contentType,
},
callback: (nullable, res) => {
console.log("nullable", nullable, "res", res);
if (res?.error) {
reject(res);
} else {
resolve(res);
}
},
});
} catch (e) {
reject(e);
}
});
}
async uploadFile(bucket: string, key: string, content: Buffer | string) {
const sdk = await import("qiniu");
const qiniu = sdk.default;
const mac = new qiniu.auth.digest.Mac(this.access.accessKey, this.access.secretKey);
const options = {
scope: bucket,
};
const putPolicy = new qiniu.rs.PutPolicy(options);
const uploadToken = putPolicy.uploadToken(mac);
const config = new qiniu.conf.Config();
const formUploader = new qiniu.form_up.FormUploader(config);
const putExtra = new qiniu.form_up.PutExtra();
let res: any = {};
if (typeof content === "string") {
const readableStream = fs.createReadStream(content);
res = await formUploader.putStream(uploadToken, key, readableStream, putExtra);
} else {
// 文件上传
res = await formUploader.put(uploadToken, key, content, putExtra);
}
const { data, resp } = res;
if (resp.statusCode === 200) {
this.logger.info("文件上传成功:" + key);
return data;
} else {
console.log(resp.statusCode);
throw new Error("上传失败:" + JSON.stringify(resp));
}
}
async removeFile(bucket: string, key: string) {
const bucketManager = await this.getBucketManager();
const { resp } = await bucketManager.delete(bucket, key);
if (resp.statusCode === 200) {
this.logger.info("文件删除成功:" + key);
return;
} else {
throw new Error("删除失败:" + JSON.stringify(resp));
}
}
async downloadFile(bucket: string, path: string, savePath: string) {
const bucketManager = await this.getBucketManager();
const privateBucketDomain = `http://${bucket}.qiniudn.com`;
const deadline = Math.floor(Date.now() / 1000) + 3600; // 1小时过期
const privateDownloadUrl = bucketManager.privateDownloadUrl(privateBucketDomain, path, deadline);
await utils.request.download({
http: this.http,
logger: this.logger,
config: {
url: privateDownloadUrl,
method: "get",
},
savePath,
});
}
private async getBucketManager() {
const sdk = await import("qiniu");
const qiniu = sdk.default;
const mac = new qiniu.auth.digest.Mac(this.access.accessKey, this.access.secretKey);
const config = new qiniu.conf.Config();
config.useHttpsDomain = true;
return new qiniu.rs.BucketManager(mac, config);
}
async listDir(bucket: string, path: string) {
const bucketManager = await this.getBucketManager();
const res = await bucketManager.listPrefix(bucket, {
prefix: path,
limit: 1000,
});
return res.data;
}
}
@@ -0,0 +1,115 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
import { ossClientFactory } from "../oss/index.js";
import S3OssClientImpl from "../oss/impls/s3.js";
/**
* 这个注解将注册一个授权配置
* 在certd的后台管理系统中,用户可以选择添加此类型的授权
*/
@IsAccess({
name: "s3",
title: "s3/minio授权",
desc: "S3/minio oss授权",
icon: "mdi:folder-upload-outline",
})
export class S3Access extends BaseAccess {
@AccessInput({
title: "endpoint",
component: {
placeholder: "http://xxxxxx:9000",
name: "a-input",
vModel: "value",
},
helper: "Minio的地址,如果是aws s3 则无需填写",
required: false,
})
endpoint!: string;
/**
* const minioClient = new S3Client({
* endpoint: "http://localhost:9000",
* forcePathStyle: true,
* credentials: {
* accessKeyId: "minioadmin", // 默认 MinIO 访问密钥
* secretAccessKey: "minioadmin", // 默认 MinIO 秘密密钥
* },
* region: "us-east-1",
* });
*/
@AccessInput({
title: "accessKeyId",
component: {
placeholder: "accessKeyId",
},
helper: "accessKeyId",
required: true,
})
accessKeyId!: string;
@AccessInput({
title: "secretAccessKey",
component: {
placeholder: "secretAccessKey",
component: {
name: "a-input",
vModel: "value",
},
},
helper: "secretAccessKey",
encrypt: true,
required: true,
})
secretAccessKey!: string;
@AccessInput({
title: "地区",
value: "us-east-1",
component: {
name: "a-input",
vModel: "value",
},
helper: "region",
required: true,
})
region!: string;
@AccessInput({
title: "存储桶",
component: {
name: "a-input",
vModel: "value",
},
helper: "bucket 名称",
required: true,
})
bucket!: string;
@AccessInput({
title: "测试",
component: {
name: "api-test",
action: "TestRequest",
},
helper: "点击测试接口是否正常",
})
testRequest = true;
async onTestRequest() {
const client: S3OssClientImpl = await ossClientFactory.createOssClientByType("s3", {
access: this,
rootDir: "",
ctx: {
accessService: this.ctx.accessService,
logger: this.ctx.logger,
utils: this.ctx.utils,
},
});
await client.listDir("/");
return "ok";
}
}
new S3Access();
@@ -0,0 +1 @@
export * from "./access.js";
@@ -0,0 +1,3 @@
export * from "./ssh.js";
export * from "./ssh-access.js";
export * from "./sftp-access.js";
@@ -0,0 +1,34 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
@IsAccess({
name: "sftp",
title: "SFTP授权",
desc: "",
icon: "clarity:host-line",
input: {},
})
export class SftpAccess extends BaseAccess {
@AccessInput({
title: "SSH授权",
component: {
name: "access-selector",
type: "ssh",
vModel: "modelValue",
},
helper: "请选择一个SSH授权",
required: true,
})
sshAccess!: string;
@AccessInput({
title: "文件权限",
component: {
name: "a-input",
vModel: "value",
placeholder: "777",
},
helper: "文件上传后是否修改文件权限",
})
fileMode!: string;
}
new SftpAccess();
@@ -0,0 +1,173 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
@IsAccess({
name: "ssh",
title: "主机登录授权",
desc: "",
icon: "clarity:host-line",
input: {},
order: 0,
})
export class SshAccess extends BaseAccess {
@AccessInput({
title: "主机地址",
component: {
placeholder: "主机域名或IP地址",
},
required: true,
})
host!: string;
@AccessInput({
title: "端口",
value: 22,
component: {
name: "a-input-number",
placeholder: "22",
},
rules: [{ required: true, message: "此项必填" }],
})
port!: number;
@AccessInput({
title: "用户名",
value: "root",
rules: [{ required: true, message: "此项必填" }],
})
username!: string;
@AccessInput({
title: "密码",
component: {
name: "a-input-password",
vModel: "value",
},
encrypt: true,
helper: "登录密码或密钥必填一项",
})
password!: string;
@AccessInput({
title: "私钥登录",
helper: "私钥或密码必填一项",
component: {
name: "pem-input",
vModel: "modelValue",
},
encrypt: true,
})
privateKey!: string;
@AccessInput({
title: "私钥密码",
helper: "如果你的私钥有密码的话",
component: {
name: "a-input-password",
vModel: "value",
},
encrypt: true,
})
passphrase!: string;
@AccessInput({
title: "脚本类型",
helper: "bash 、sh 、fish",
component: {
name: "a-select",
vModel: "value",
options: [
{ value: "default", label: "默认" },
{ value: "sh", label: "sh" },
{ value: "bash", label: "bash" },
{ value: "fish", label: "fish(不支持set -e)" },
],
},
})
scriptType: string;
@AccessInput({
title: "伪终端",
helper: "如果登录报错:all authentication methods failed,可以尝试开启伪终端模式进行keyboard-interactive方式登录\n开启后对日志输出有一定的影响",
component: {
name: "a-switch",
vModel: "checked",
},
})
pty!: boolean;
@AccessInput({
title: "socks代理",
helper: "socks代理配置,格式:socks5://user:password@host:port",
component: {
name: "a-input",
vModel: "value",
placeholder: "socks5://user:password@host:port",
},
encrypt: false,
})
socksProxy!: string;
@AccessInput({
title: "超时时间",
helper: "执行命令的超时时间,单位秒,默认30分钟",
component: {
name: "a-input-number",
},
})
timeout: number;
@AccessInput({
title: "是否Windows",
helper: "如果是Windows主机,请勾选此项\n并且需要windows[安装OpenSSH](https://certd.docmirror.cn/guide/use/host/windows.html)",
component: {
name: "a-switch",
vModel: "checked",
},
})
windows = false;
@AccessInput({
title: "命令编码",
helper: "如果是Windows主机,且出现乱码了,请尝试设置为GBK",
component: {
name: "a-select",
vModel: "value",
options: [
{ value: "", label: "默认" },
{ value: "GBK", label: "GBK" },
{ value: "UTF8", label: "UTF-8" },
],
},
})
encoding: string;
@AccessInput({
title: "测试",
component: {
name: "api-test",
type: "access",
typeName: "ssh",
action: "TestRequest",
},
mergeScript: `
return {
component:{
form: ctx.compute(({form})=>{
return form
})
},
}
`,
helper: "点击测试",
})
testRequest = true;
async onTestRequest() {
const { SshClient } = await import("./ssh.js");
const client = new SshClient(this.ctx.logger);
const script = ["echo hello", "exit"];
await client.exec({
connectConf: this,
script: script,
});
return "ok";
}
}
new SshAccess();
@@ -0,0 +1,643 @@
// @ts-ignore
import path from "path";
import { isArray } from "lodash-es";
import { ILogger, safePromise } from "@certd/basic";
import { SshAccess } from "./ssh-access.js";
import fs from "fs";
import { SocksProxyType } from "socks/typings/common/constants.js";
export type TransportItem = { localPath: string; remotePath: string };
export interface SocksProxy {
ipaddress?: string;
host?: string;
port: number;
type: any;
userId?: string;
password?: string;
custom_auth_method?: number;
custom_auth_request_handler?: () => Promise<Buffer>;
custom_auth_response_size?: number;
custom_auth_response_handler?: (data: Buffer) => Promise<boolean>;
}
export type SshConnectConfig = {
sock?: any;
};
export class AsyncSsh2Client {
conn: any;
logger: ILogger;
connConf: SshAccess & SshConnectConfig;
windows = false;
encoding: string;
constructor(connConf: SshAccess, logger: ILogger) {
this.connConf = connConf;
this.logger = logger;
this.windows = connConf.windows || false;
this.encoding = connConf.encoding;
}
convert(iconv: any, buffer: Buffer) {
if (this.encoding) {
return iconv.decode(buffer, this.encoding);
}
return buffer.toString().replaceAll("\r\n", "\n");
}
async connect() {
this.logger.info(`开始连接,${this.connConf.host}:${this.connConf.port}`);
if (this.connConf.socksProxy) {
this.logger.info(`使用代理${this.connConf.socksProxy}`);
if (typeof this.connConf.port === "string") {
this.connConf.port = parseInt(this.connConf.port);
}
const { SocksClient } = await import("socks");
const proxyOption = this.parseSocksProxyFromUri(this.connConf.socksProxy);
const info = await SocksClient.createConnection({
proxy: proxyOption,
command: "connect",
destination: {
host: this.connConf.host,
port: this.connConf.port,
},
});
this.logger.info("代理连接成功");
this.connConf.sock = info.socket;
}
const ssh2 = await import("ssh2");
const ssh2Constants = await import("ssh2/lib/protocol/constants.js");
const { SUPPORTED_KEX, SUPPORTED_SERVER_HOST_KEY, SUPPORTED_CIPHER, SUPPORTED_MAC } = ssh2Constants.default;
return safePromise((resolve, reject) => {
try {
const conn = new ssh2.default.Client();
conn
.on("error", (err: any) => {
this.logger.error("连接失败", err);
reject(err);
})
.on("ready", () => {
this.logger.info("连接成功");
this.conn = conn;
resolve(this.conn);
})
.on("keyboard-interactive", (name, descr, lang, prompts, finish) => {
// For illustration purposes only! It's not safe to do this!
// You can read it from process.stdin or whatever else...
const password = this.connConf.password;
return finish([password]);
// And remember, server may trigger this event multiple times
// and for different purposes (not only auth)
})
.connect({
...this.connConf,
tryKeyboard: true,
algorithms: {
serverHostKey: SUPPORTED_SERVER_HOST_KEY,
cipher: SUPPORTED_CIPHER,
hmac: SUPPORTED_MAC,
kex: SUPPORTED_KEX,
},
});
} catch (e) {
reject(e);
}
});
}
async getSftp() {
return safePromise((resolve, reject) => {
this.logger.info("获取sftp");
this.conn.sftp((err: any, sftp: any) => {
if (err) {
reject(err);
return;
}
resolve(sftp);
});
});
}
async fastPut(options: { sftp: any; localPath: string; remotePath: string; opts?: { mode?: string } }) {
const { sftp, localPath, remotePath, opts } = options;
return safePromise((resolve, reject) => {
this.logger.info(`开始上传:${localPath} => ${remotePath}`);
sftp.fastPut(localPath, remotePath, { ...(opts ?? {}) }, (err: Error) => {
if (err) {
reject(err);
this.logger.error("请确认路径是否包含文件名,路径本身不能是目录,路径不能有*?之类的特殊符号,要有写入权限");
return;
}
this.logger.info(`上传文件成功:${localPath} => ${remotePath}`);
resolve({});
});
});
}
async listDir(options: { sftp: any; remotePath: string }) {
const { sftp, remotePath } = options;
return safePromise((resolve, reject) => {
this.logger.info(`listDir${remotePath}`);
sftp.readdir(remotePath, (err: Error, list: any) => {
if (err) {
reject(err);
return;
}
resolve(list);
});
});
}
async unlink(options: { sftp: any; remotePath: string }) {
const { sftp, remotePath } = options;
return safePromise((resolve, reject) => {
this.logger.info(`开始删除远程文件:${remotePath}`);
sftp.unlink(remotePath, (err: Error) => {
if (err) {
reject(err);
return;
}
this.logger.info(`删除文件成功:${remotePath}`);
resolve({});
});
});
}
/**
*
* @param script
* @param opts {withStdErr 返回{stdOut,stdErr}}
*/
async exec(
script: string,
opts: {
throwOnStdErr?: boolean;
withStdErr?: boolean;
env?: any;
} = {}
): Promise<string> {
if (!script) {
this.logger.info("script 为空,取消执行");
return;
}
let iconv: any = await import("iconv-lite");
iconv = iconv.default;
// if (this.connConf.windows) {
// script += "\r\nexit\r\n";
// //保证windows下正常退出
// }
if (script.includes(" -i ")) {
this.logger.warn("不支持交互式命令,请不要使用-i参数");
}
return safePromise((resolve, reject) => {
this.logger.info(`执行命令:[${this.connConf.host}][exec]: \n` + script);
// pty 伪终端,window下的输出会带上conhost.exe之类的多余的字符串,影响返回结果判断
// linux下 当使用keyboard-interactive 登录时,需要pty
const pty = this.connConf.pty; //linux下开启伪终端,windows下不开启
this.conn.exec(script, { pty, env: opts.env }, (err: Error, stream: any) => {
if (err) {
reject(err);
return;
}
let data = "";
let stdErr = "";
let hasErrorLog = false;
stream
.on("close", (code: any, signal: any) => {
this.logger.info(`[${this.connConf.host}][close]:code:${code}`);
/**
* ]pipeline 执行命令:[10.123.0.2][exec]:cd /d D:\nginx-1.27.5 && D:\nginx-1.27.5\nginx.exe -t && D:\nginx-1.27.5\nginx.exe -s reload
* [2025-07-09T10:24:11.219] [ERROR]pipeline - [10. 123.0. 2][error]: nginx: the configuration file D: \nginx-1.27. 5/conf/nginx. conf syntax is ok
* [2025-07-09T10:24:11.231] [ERROR][10. 123. 0. 2] [error]: nginx: configuration file D: \nginx-1.27.5/conf/nginx.conf test is successful
* pipeline-
* [2025-07-09T10:24:11.473] [INFO]pipeline -[10.123.0.2][close]:code:0
* [2025-07-09T10:24:11.473][ERRoR] pipeline- [step][主机一执行远程主机脚本命令]<id:53hyarN3yvmbijNuMiNAt>: [Eror: nginx: the configuration fileD:\nginx-1.27.5/conf/nginx.conf syntax is ok
//需要忽略windows的错误
*/
// if (opts.throwOnStdErr == null && this.windows) {
// opts.throwOnStdErr = true;
// }
if (opts.throwOnStdErr && hasErrorLog) {
reject(new Error(data));
}
if (code === 0) {
if (opts.withStdErr === true) {
//@ts-ignore
resolve({
stdErr,
stdOut: data,
});
} else {
resolve(data);
}
} else {
reject(new Error(data));
}
})
.on("data", (ret: Buffer) => {
const out = this.convert(iconv, ret);
data += out;
this.logger.info(`[${this.connConf.host}][info]: ` + out.trimEnd());
})
.on("error", (err: any) => {
reject(err);
this.logger.error(err);
})
.stderr.on("data", (ret: Buffer) => {
const err = this.convert(iconv, ret);
stdErr += err;
hasErrorLog = true;
if (err.includes("sudo: a password is required")) {
this.logger.warn("请配置sudo免密,否则命令无法执行");
}
this.logger.error(`[${this.connConf.host}][error]: ` + err.trimEnd());
});
});
});
}
async shell(script: string | string[]): Promise<string> {
const stripAnsiModule = await import("strip-ansi");
const stripAnsi = stripAnsiModule.default;
return safePromise<any>((resolve, reject) => {
this.logger.info(`执行shell脚本:[${this.connConf.host}][shell]: ` + script);
this.conn.shell((err: Error, stream: any) => {
if (err) {
reject(err);
return;
}
let output = "";
function ansiHandle(data: string) {
data = data.replace(/\[[0-9]+;1H/g, "");
data = stripAnsi(data);
return data.replaceAll("\r\n", "\n");
}
stream
.on("close", (code: any) => {
this.logger.info("Stream :: close,code: " + code);
resolve(output);
})
.on("data", (ret: Buffer) => {
const data = ansiHandle(ret.toString());
this.logger.info(data);
output += data;
})
.on("error", (err: any) => {
reject(err);
this.logger.error(err);
})
.stderr.on("data", (ret: Buffer) => {
const data = ansiHandle(ret.toString());
output += data;
this.logger.error(`[${this.connConf.host}][error]: ` + data);
});
//保证windows下正常退出
const exit = "\r\nexit\r\n";
stream.end(script + exit);
});
});
}
end() {
if (this.conn) {
this.conn.end();
this.conn.destroy();
this.conn = null;
}
}
private parseSocksProxyFromUri(socksProxyUri: string): SocksProxy {
const url = new URL(socksProxyUri);
let type: SocksProxyType = 5;
if (url.protocol.startsWith("socks4")) {
type = 4;
}
const proxy: SocksProxy = {
host: url.hostname,
port: parseInt(url.port),
type,
};
if (url.username) {
proxy.userId = url.username;
}
if (url.password) {
proxy.password = url.password;
}
return proxy;
}
async download(param: { remotePath: string; savePath: string; sftp: any }) {
return safePromise((resolve, reject) => {
const { remotePath, savePath, sftp } = param;
sftp.fastGet(
remotePath,
savePath,
{
step: (transferred: any, chunk: any, total: any) => {
this.logger.info(`${transferred} / ${total}`);
},
},
(err: any) => {
if (err) {
reject(err);
} else {
resolve({});
}
}
);
});
}
}
export class SshClient {
logger: ILogger;
/**
*
* @param connectConf
{
host: '192.168.100.100',
port: 22,
username: 'frylock',
password: 'nodejsrules'
}
* @param options
*/
async uploadFiles(options: { connectConf: SshAccess; transports: TransportItem[]; mkdirs: boolean; opts?: { mode?: string }; uploadType?: string }) {
const { connectConf, transports, mkdirs, opts } = options;
await this._call({
connectConf,
callable: async (conn: AsyncSsh2Client) => {
this.logger.info("开始上传");
if (mkdirs !== false) {
this.logger.info("初始化父目录");
for (const transport of transports) {
const filePath = path.dirname(transport.remotePath);
let mkdirCmd = `mkdir -p ${filePath} `;
if (conn.windows) {
if (filePath.indexOf("/") > -1) {
this.logger.info("--------------------------");
this.logger.info("请注意:windows下,文件目录分隔应该写成\\而不是/");
this.logger.info("--------------------------");
}
const isCmd = await this.isCmd(conn);
if (!isCmd) {
mkdirCmd = `New-Item -ItemType Directory -Path "${filePath}" -Force`;
} else {
mkdirCmd = `if not exist "${filePath}" mkdir "${filePath}"`;
}
}
await conn.exec(mkdirCmd);
}
}
if (options.uploadType === "scp") {
//scp
for (const transport of transports) {
await this.scpUpload({ conn, ...transport, opts });
await new Promise(resolve => setTimeout(resolve, 1000));
}
} else {
const sftp = await conn.getSftp();
for (const transport of transports) {
await conn.fastPut({ sftp, ...transport, opts });
}
}
this.logger.info("文件全部上传成功");
},
});
}
constructor(logger: ILogger) {
this.logger = logger;
}
async scpUpload(options: { conn: any; localPath: string; remotePath: string; opts?: { mode?: string } }) {
const { conn, localPath, remotePath } = options;
return safePromise((resolve, reject) => {
// 关键步骤:构造 SCP 命令
this.logger.info(`开始上传:${localPath} => ${remotePath}`);
conn.conn.exec(
`scp -t ${remotePath}`, // -t 表示目标模式
(err, stream) => {
if (err) {
return reject(err);
}
try {
// 准备 SCP 协议头
const fileStats = fs.statSync(localPath);
const fileName = path.basename(localPath);
// SCP 协议格式:C[权限] [文件大小] [文件名]\n
stream.write(`C0644 ${fileStats.size} ${fileName}\n`);
// 通过管道传输文件
fs.createReadStream(localPath)
.on("error", e => {
this.logger.info("read stream error", e);
reject(e);
})
.pipe(stream)
.on("finish", async () => {
this.logger.info(`上传完成:${localPath} => ${remotePath}`);
resolve(true);
})
.on("error", reject);
} catch (e) {
reject(e);
}
}
);
});
}
async removeFiles(opts: { connectConf: SshAccess; files: string[] }) {
const { connectConf, files } = opts;
await this._call({
connectConf,
callable: async (conn: AsyncSsh2Client) => {
const sftp = await conn.getSftp();
this.logger.info("开始删除");
for (const file of files) {
await conn.unlink({
sftp,
remotePath: file,
});
}
this.logger.info("文件全部删除成功");
},
});
}
async isCmd(conn: AsyncSsh2Client) {
const spec = await conn.exec("echo %COMSPEC% ");
const ret = spec.toString().trim();
if (ret.includes("%COMSPEC%") && !ret.includes("echo %COMSPEC%")) {
return false;
} else {
return true;
}
}
async getIsCmd(options: { connectConf: SshAccess }) {
const { connectConf } = options;
return await this._call<boolean>({
connectConf,
callable: async (conn: AsyncSsh2Client) => {
return await this.isCmd(conn);
},
});
}
/**
*
* Set-ItemProperty -Path "HKLM:\SOFTWARE\OpenSSH" -Name DefaultShell -Value "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"
* Start-Service sshd
*
* Set-ItemProperty -Path "HKLM:\SOFTWARE\OpenSSH" -Name DefaultShell -Value "C:\Windows\System32\cmd.exe"
* @param options
*/
async exec(options: { connectConf: SshAccess; script: string | Array<string>; env?: any; throwOnStdErr?: boolean; stopOnError?: boolean }): Promise<string> {
let { script } = options;
const { connectConf, throwOnStdErr } = options;
// this.logger.info('命令:', script);
return await this._call({
connectConf,
callable: async (conn: AsyncSsh2Client) => {
let isWinCmd = false;
const isLinux = !connectConf.windows;
const envScripts = [];
if (connectConf.windows) {
isWinCmd = await this.isCmd(conn);
}
if (options.env) {
for (const key in options.env) {
if (isLinux) {
envScripts.push(`export ${key}=${options.env[key]}`);
} else if (isWinCmd) {
//win cmd
envScripts.push(`set ${key}=${options.env[key]}`);
} else {
//powershell
envScripts.push(`$env:${key}="${options.env[key]}"`);
}
}
}
if (isWinCmd) {
if (typeof script === "string") {
script = script.split("\n");
}
//组合成&&的形式
script = envScripts.concat(script);
script = script as Array<string>;
script = script.join(" && ");
} else {
const newLine = isLinux ? "\n" : "\r\n";
if (isArray(script)) {
script = script as Array<string>;
script = script.join(newLine);
}
if (envScripts.length > 0) {
script = envScripts.join(newLine) + newLine + script;
}
}
if (isLinux) {
if (options.connectConf.scriptType == "bash") {
script = "#!/usr/bin/env bash \n" + script;
} else if (options.connectConf.scriptType == "sh") {
script = "#!/bin/sh\n" + script;
}
if (options.connectConf.scriptType != "fish" && options.stopOnError !== false) {
script = "set -e\n" + script;
}
}
return await conn.exec(script as string, { throwOnStdErr });
},
});
}
async shell(options: { connectConf: SshAccess; script: string | Array<string> }): Promise<string> {
let { script } = options;
const { connectConf } = options;
if (isArray(script)) {
script = script as Array<string>;
if (connectConf.windows) {
script = script.join("\r\n");
} else {
script = script.join("\n");
}
} else {
if (connectConf.windows) {
//@ts-ignore
script = script.replaceAll("\n", "\r\n");
}
}
return await this._call({
connectConf,
callable: async (conn: AsyncSsh2Client) => {
return await conn.shell(script as string);
},
});
}
async _call<T = any>(options: { connectConf: SshAccess; callable: (conn: AsyncSsh2Client) => Promise<T> }): Promise<T> {
const { connectConf, callable } = options;
const conn = new AsyncSsh2Client(connectConf, this.logger);
try {
await conn.connect();
} catch (e: any) {
if (e.message?.indexOf("All configured authentication methods failed") > -1) {
this.logger.error(e);
throw new Error("登录失败,请检查用户名/密码/密钥是否正确");
}
throw e;
}
let timeoutId = null;
try {
timeoutId = setTimeout(() => {
this.logger.info("执行超时,断开连接");
conn.end();
}, 1000 * (connectConf.timeout || 1800));
return await callable(conn);
} finally {
clearTimeout(timeoutId);
conn.end();
}
}
async listDir(param: { connectConf: any; dir: string }) {
return await this._call<any>({
connectConf: param.connectConf,
callable: async (conn: AsyncSsh2Client) => {
const sftp = await conn.getSftp();
return await conn.listDir({
sftp,
remotePath: param.dir,
});
},
});
}
async download(param: { connectConf: any; filePath: string; savePath: string }) {
return await this._call<any>({
connectConf: param.connectConf,
callable: async (conn: AsyncSsh2Client) => {
const sftp = await conn.getSftp();
return await conn.download({
sftp,
remotePath: param.filePath,
savePath: param.savePath,
});
},
});
}
}
@@ -0,0 +1,65 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
@IsAccess({
name: "tencentcos",
title: "腾讯云COS授权",
icon: "svg:icon-tencentcloud",
desc: "腾讯云对象存储授权,包含地域和存储桶",
})
export class TencentCosAccess extends BaseAccess {
@AccessInput({
title: "腾讯云授权",
component: {
name: "access-selector",
vModel: "modelValue",
type: "tencent",
},
helper: "请选择腾讯云授权",
required: true,
})
accessId = "";
@AccessInput({
title: "所在地域",
helper: "存储桶所在地域",
component: {
name: "a-auto-complete",
vModel: "value",
options: [
{ value: "", label: "--------中国大陆地区-------", disabled: true },
{ value: "ap-beijing-1", label: "北京1区" },
{ value: "ap-beijing", label: "北京" },
{ value: "ap-nanjing", label: "南京" },
{ value: "ap-shanghai", label: "上海" },
{ value: "ap-guangzhou", label: "广州" },
{ value: "ap-chengdu", label: "成都" },
{ value: "ap-chongqing", label: "重庆" },
{ value: "ap-shenzhen-fsi", label: "深圳金融" },
{ value: "ap-shanghai-fsi", label: "上海金融" },
{ value: "ap-beijing-fsi", label: "北京金融" },
{ value: "", label: "--------中国香港及境外-------", disabled: true },
{ value: "ap-hongkong", label: "中国香港" },
{ value: "ap-singapore", label: "新加坡" },
{ value: "ap-mumbai", label: "孟买" },
{ value: "ap-jakarta", label: "雅加达" },
{ value: "ap-seoul", label: "首尔" },
{ value: "ap-bangkok", label: "曼谷" },
{ value: "ap-tokyo", label: "东京" },
{ value: "na-siliconvalley", label: "硅谷" },
{ value: "na-ashburn", label: "弗吉尼亚" },
{ value: "sa-saopaulo", label: "圣保罗" },
{ value: "eu-frankfurt", label: "法兰克福" },
],
},
})
region!: string;
@AccessInput({
title: "Bucket",
helper: "存储桶名称",
required: true,
})
bucket = "";
}
new TencentCosAccess();
@@ -0,0 +1,71 @@
import { IsAccess, AccessInput, BaseAccess } from "@certd/pipeline";
@IsAccess({
name: "tencent",
title: "腾讯云",
icon: "svg:icon-tencentcloud",
order: 0,
})
export class TencentAccess extends BaseAccess {
@AccessInput({
title: "secretId",
helper: "使用对应的插件需要有对应的权限,比如上传证书,需要证书管理权限;部署到clb需要clb相关权限\n前往[密钥管理](https://console.cloud.tencent.com/cam/capi)进行创建",
component: {
placeholder: "secretId",
},
rules: [{ required: true, message: "该项必填" }],
})
secretId = "";
@AccessInput({
title: "secretKey",
component: {
placeholder: "secretKey",
},
encrypt: true,
rules: [{ required: true, message: "该项必填" }],
})
secretKey = "";
@AccessInput({
title: "站点类型",
value: "cn",
component: {
name: "a-select",
options: [
{
label: "国内站",
value: "cn",
},
{
label: "国际站",
value: "intl",
},
],
},
encrypt: false,
rules: [{ required: true, message: "该项必填" }],
})
accountType: string;
@AccessInput({
title: "关闭证书过期通知",
value: true,
component: {
name: "a-switch",
vModel: "checked",
},
})
closeExpiresNotify: boolean = true;
isIntl() {
return this.accountType === "intl";
}
intlDomain() {
return this.isIntl() ? "intl." : "";
}
buildEndpoint(endpoint: string) {
return `${this.intlDomain()}${endpoint}`;
}
}
@@ -0,0 +1,3 @@
export * from "./access.js";
export * from "./access-cos.js";
export * from "./lib/index.js";
@@ -0,0 +1,117 @@
import { TencentAccess } from "../access.js";
import { ILogger, safePromise } from "@certd/basic";
import fs from "fs";
export class TencentCosClient {
access: TencentAccess;
logger: ILogger;
region: string;
bucket: string;
constructor(opts: { access: TencentAccess; logger: ILogger; region: string; bucket: string }) {
this.access = opts.access;
this.logger = opts.logger;
this.bucket = opts.bucket;
this.region = opts.region;
}
async getCosClient() {
const sdk = await import("cos-nodejs-sdk-v5");
const clientConfig = {
SecretId: this.access.secretId,
SecretKey: this.access.secretKey,
};
return new sdk.default(clientConfig);
}
async uploadFile(key: string, file: Buffer | string) {
const cos = await this.getCosClient();
return safePromise((resolve, reject) => {
let readableStream = file as any;
if (typeof file === "string") {
readableStream = fs.createReadStream(file);
}
cos.putObject(
{
Bucket: this.bucket /* 必须 */,
Region: this.region /* 必须 */,
Key: key /* 必须 */,
Body: readableStream, // 上传文件对象
onProgress: function (progressData) {
console.log(JSON.stringify(progressData));
},
},
function (err, data) {
if (err) {
reject(err);
return;
}
resolve(data);
}
);
});
}
async removeFile(key: string) {
const cos = await this.getCosClient();
return safePromise((resolve, reject) => {
cos.deleteObject(
{
Bucket: this.bucket,
Region: this.region,
Key: key,
},
function (err, data) {
if (err) {
reject(err);
return;
}
resolve(data);
}
);
});
}
async downloadFile(key: string, savePath: string) {
const cos = await this.getCosClient();
const writeStream = fs.createWriteStream(savePath);
return safePromise((resolve, reject) => {
cos.getObject(
{
Bucket: this.bucket,
Region: this.region,
Key: key,
Output: writeStream,
},
function (err, data) {
if (err) {
reject(err);
return;
}
resolve(data);
}
);
});
}
async listDir(dirKey: string) {
const cos = await this.getCosClient();
return safePromise((resolve, reject) => {
cos.getBucket(
{
Bucket: this.bucket,
Region: this.region,
Prefix: dirKey,
MaxKeys: 1000,
},
function (err, data) {
if (err) {
reject(err);
return;
}
resolve(data.Contents);
}
);
});
}
}
@@ -0,0 +1,2 @@
export * from "./ssl-client.js";
export * from "./cos-client.js";
@@ -0,0 +1,102 @@
import { ILogger } from "@certd/basic";
import { TencentAccess } from "../access.js";
export type TencentCertInfo = {
key: string;
crt: string;
};
export class TencentSslClient {
access: TencentAccess;
logger: ILogger;
region?: string;
constructor(opts: { access: TencentAccess; logger: ILogger; region?: string }) {
this.access = opts.access;
this.logger = opts.logger;
this.region = opts.region;
}
async getSslClient(): Promise<any> {
const sdk = await import("tencentcloud-sdk-nodejs/tencentcloud/services/ssl/v20191205/index.js");
const SslClient = sdk.v20191205.Client;
const clientConfig = {
credential: {
secretId: this.access.secretId,
secretKey: this.access.secretKey,
},
region: this.region,
profile: {
httpProfile: {
endpoint: this.access.isIntl() ? "ssl.intl.tencentcloudapi.com" : "ssl.tencentcloudapi.com",
},
},
};
return new SslClient(clientConfig);
}
checkRet(ret: any) {
if (!ret || ret.Error) {
throw new Error("请求失败:" + ret.Error.Code + "," + ret.Error.Message + ",requestId" + ret.RequestId);
}
}
async uploadToTencent(opts: { certName: string; cert: TencentCertInfo }): Promise<string> {
const client = await this.getSslClient();
const params = {
CertificatePublicKey: opts.cert.crt,
CertificatePrivateKey: opts.cert.key,
Alias: opts.certName,
};
const ret = await client.UploadCertificate(params);
this.checkRet(ret);
this.logger.info(`证书[${opts.certName}]上传成功:tencentCertId=`, ret.CertificateId);
if (this.access.closeExpiresNotify) {
await this.switchCertNotify([ret.CertificateId], true);
}
return ret.CertificateId;
}
async switchCertNotify(certIds: string[], disabled: boolean) {
const client = await this.getSslClient();
const params = {
CertificateIds: certIds,
SwitchStatus: disabled ? 1 : 0, //1是忽略通知,0是不忽略
};
const ret = await client.ModifyCertificatesExpiringNotificationSwitch(params);
this.checkRet(ret);
this.logger.info(`关闭证书${certIds}过期通知成功`);
return ret.RequestId;
}
async deployCertificateInstance(params: any) {
return await this.doRequest("DeployCertificateInstance", params);
}
async DescribeHostUploadUpdateRecordDetail(params: any) {
return await this.doRequest("DescribeHostUploadUpdateRecordDetail", params);
}
async UploadUpdateCertificateInstance(params: any) {
return await this.doRequest("UploadUpdateCertificateInstance", params);
}
async DescribeCertificates(params: { Limit?: number; Offset?: number; SearchKey?: string }) {
return await this.doRequest("DescribeCertificates", {
ExpirationSort: "ASC",
...params,
});
}
async doRequest(action: string, params: any) {
const client = await this.getSslClient();
try {
const res = await client.request(action, params);
this.checkRet(res);
return res;
} catch (e) {
this.logger.error(`action ${action} error: ${e.message},requestId=${e.RequestId}`);
throw e;
}
}
}
@@ -0,0 +1,160 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
import { HttpClient } from "@certd/basic";
import { OnePanelClient } from "./client.js";
/**
* 这个注解将注册一个授权配置
* 在certd的后台管理系统中,用户可以选择添加此类型的授权
*/
@IsAccess({
name: "1panel",
title: "1panel授权",
desc: "账号和密码",
icon: "svg:icon-onepanel",
})
export class OnePanelAccess extends BaseAccess {
@AccessInput({
title: "1Panel面板的url",
component: {
placeholder: "http://xxxx.com:1231",
},
helper: "不要带安全入口",
required: true,
})
baseUrl = "";
@AccessInput({
title: "安全入口",
component: {
placeholder: "登录的安全入口",
},
encrypt: true,
required: false,
})
safeEnter = "";
@AccessInput({
title: "授权方式",
component: {
name: "a-select",
vModel: "value",
options: [
{ label: "模拟登录【不推荐】", value: "password" },
{ label: "接口密钥【推荐】", value: "apikey" },
],
},
required: true,
})
type = "";
@AccessInput({
title: "接口版本",
value: "v1",
component: {
placeholder: "v1 / v2",
name: "a-select",
vModel: "value",
options: [
{ label: "v1", value: "v1" },
{ label: "v2", value: "v2" },
],
},
required: true,
})
apiVersion = "v1";
@AccessInput({
title: "用户名",
component: {
placeholder: "username",
},
mergeScript: `
return {
show: ctx.compute(({form})=>{
return form.access.type === 'password';
})
}
`,
required: true,
})
username = "";
@AccessInput({
title: "密码",
component: {
placeholder: "password",
},
helper: "",
mergeScript: `
return {
show: ctx.compute(({form})=>{
return form.access.type === 'password';
})
}
`,
required: true,
encrypt: true,
})
password = "";
@AccessInput({
title: "接口密钥",
component: {
placeholder: "接口密钥",
},
mergeScript: `
return {
show: ctx.compute(({form})=>{
return form.access.type === 'apikey';
})
}
`,
helper: "面板设置->API接口中获取",
required: true,
encrypt: true,
})
apiKey = "";
@AccessInput({
title: "忽略证书校验",
value: true,
component: {
name: "a-switch",
vModel: "checked",
},
helper: "如果面板的url是https,且使用的是自签名证书,则需要开启此选项,其他情况可以关闭",
})
skipSslVerify = true;
@AccessInput({
title: "测试",
component: {
name: "api-test",
action: "onTestRequest",
},
helper: "点击测试接口看是否正常\nIP需要加白名单,如果是同一台机器部署的,可以试试面板的url使用网卡docker0的ip,白名单使用172.16.0.0/12",
})
testRequest = true;
async onTestRequest() {
const http: HttpClient = this.ctx.http;
const client = new OnePanelClient({
logger: this.ctx.logger,
http,
access: this,
utils: this.ctx.utils,
});
await client.doRequest({
url: `/api/${this.apiVersion}/websites/ssl/search`,
method: "post",
data: {
page: 1,
pageSize: 1,
},
});
return "ok";
}
}
new OnePanelAccess();
@@ -0,0 +1,228 @@
import { HttpClient, HttpRequestConfig, ILogger } from "@certd/basic";
import { OnePanelAccess } from "./access.js";
export class OnePanelClient {
access: OnePanelAccess;
http: HttpClient;
logger: ILogger;
utils: any;
token: string;
constructor(opts: { access: OnePanelAccess; http: HttpClient; logger: ILogger; utils: any }) {
this.access = opts.access;
this.http = opts.http;
this.logger = opts.logger;
this.utils = opts.utils;
}
//
// //http://xxx:xxxx/1panel/swagger/index.html#/App/get_apps__key
// async execute(): Promise<void> {
// //login 获取token
// /**
// * curl 'http://127.0.0.1:7001/api/v1/auth/login' --data-binary '{"name":"admin_test","password":"admin_test1234","ignoreCaptcha":true,"captcha":"","captchaID":"nY8Cqeut3TjZMfJMAz0k","authMethod":"jwt","language":"zh"}' -H 'EntranceCode: emhhbmd5eg=='
// * curl 'http://127.0.0.1:7001/api/v1/dashboard/current/all/all' -H 'PanelAuthorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJJRCI6MCwiTmFtZSI6ImFkbWluX3Rlc3QiLCJCdWZmZXJUaW1lIjozNjAwLCJpc3MiOiIxUGFuZWwiLCJleHAiOjE3MDkyODg4MDl9.pdknJdjLY4Fp8wCE9Gvaiic2rLoSdvUSJB9ossyya_I'
// */
// const sslIds = this.sslIds;
// for (const sslId of sslIds) {
// try {
// const certRes = await this.get1PanelCertInfo(sslId);
// if (!this.isNeedUpdate(certRes)) {
// continue;
// }
//
// const uploadRes = await this.doRequest({
// url: "/api/v1/websites/ssl/upload",
// method: "post",
// data: {
// sslIds,
// certificate: this.cert.crt,
// certificatePath: "",
// description: certRes.description || this.appendTimeSuffix("certd"),
// privateKey: this.cert.key,
// privateKeyPath: "",
// sslID: sslId,
// type: "paste",
// },
// });
// console.log("uploadRes", JSON.stringify(uploadRes));
// } catch (e) {
// this.logger.warn(`更新证书(id:${sslId})失败`, e);
// this.logger.info("可能1Panel正在重启,等待10秒后检查证书是否更新成功");
// await this.ctx.utils.sleep(10000);
// const certRes = await this.get1PanelCertInfo(sslId);
// if (!this.isNeedUpdate(certRes)) {
// continue;
// }
// throw e;
// }
// }
// }
async get1PanelCertInfo(sslId: string) {
const certRes = await this.doRequest({
url: `/api/${this.access.apiVersion}/websites/ssl/${sslId}`,
method: "get",
});
if (!certRes) {
throw new Error(`没有找到证书(id:${sslId}),请先在1Panel中手动上传证书,后续才可以自动更新`);
}
return certRes;
}
async doRequest(config: { currentNode?: string } & HttpRequestConfig<any>) {
const tokenHeaders = await this.getAccessToken();
config.headers = {
...tokenHeaders,
};
if (config.currentNode) {
config.headers.CurrentNode = this.getNodeValue(config.currentNode);
delete config.currentNode;
}
return await this.doRequestWithoutAuth(config);
}
async doRequestWithoutAuth(config: HttpRequestConfig<any>) {
config.baseURL = this.access.baseUrl;
config.skipSslVerify = this.access.skipSslVerify ?? false;
config.logRes = false;
config.logParams = false;
const res = await this.http.request(config);
if (config.returnOriginRes) {
return res;
}
if (res.code === 200) {
return res.data;
}
throw new Error(res.message);
}
async getCookie(name: string) {
// https://www.docmirror.cn:20001/api/v1/auth/language
const response = await this.doRequestWithoutAuth({
url: `/api/${this.access.apiVersion}/auth/language`,
method: "GET",
returnOriginRes: true,
});
const cookies = response.headers["set-cookie"];
//根据name 返回对应的cookie
const found = cookies.find(cookie => cookie.includes(name));
if (!found) {
return null;
}
const cookie = found.split(";")[0];
return cookie.substring(cookie.indexOf("=") + 1);
}
async encryptPassword(password: string) {
const rsaPublicKeyText = await this.getCookie("panel_public_key");
if (!rsaPublicKeyText) {
return password;
}
// 使用rsa加密
const { encryptPassword } = await import("./util.js");
return encryptPassword(rsaPublicKeyText, password);
}
async getAccessToken() {
if (this.access.type === "apikey") {
return this.getAccessTokenByApiKey();
} else {
return await this.getAccessTokenByPassword();
}
}
async getAccessTokenByApiKey() {
/**
* Token = md5('1panel' + API-Key + UnixTimestamp)
* 组成部分:
* 固定前缀: '1panel'
* API-Key: 面板 API 接口密钥
* UnixTimestamp: 当前的时间戳(秒级)
* 请求 Header 设计¶
* 每次请求必须携带以下两个 Header:
*
* Header 名称 说明
* 1Panel-Token 自定义的 Token 值
* 1Panel-Timestamp 当前时间戳
* 示例请求头:¶
*
* curl -X GET "http://localhost:4004/api/v1/dashboard/current" \
* -H "1Panel-Token: <1panel_token>" \
* -H "1Panel-Timestamp: <current_unix_timestamp>"
*/
const timestamp = Math.floor(Date.now() / 1000);
const token = this.utils.hash.md5(`1panel${this.access.apiKey}${timestamp}`);
return {
"1Panel-Token": token,
"1Panel-Timestamp": timestamp,
};
}
async getAccessTokenByPassword() {
// console.log("getAccessToken", this);
// const tokenCacheKey = `1panel-token-${this.accessId}`;
// let token = this.utils.cache.get(tokenCacheKey);
// if (token) {
// return token;
// }
if (this.token) {
return {
PanelAuthorization: this.token,
};
}
let password = this.access.password;
password = await this.encryptPassword(password);
const loginRes = await this.doRequestWithoutAuth({
url: `/api/${this.access.apiVersion}/auth/login`,
method: "post",
headers: {
EntranceCode: Buffer.from(this.access.safeEnter).toString("base64"),
},
data: {
name: this.access.username,
password: password,
ignoreCaptcha: true,
captcha: "",
captchaID: "",
authMethod: "jwt",
language: "zh",
},
});
this.token = loginRes.token;
return {
PanelAuthorization: this.token,
};
}
async onGetSSLIds() {
// if (!isPlus()) {
// throw new Error("自动获取站点列表为专业版功能,您可以手动输入证书id进行部署");
// }
const res = await this.doRequest({
url: `/api/${this.access.apiVersion}/websites/ssl/search`,
method: "post",
data: {
page: 1,
pageSize: 99999,
},
});
if (!res?.items) {
throw new Error("没有找到证书,请先在1Panel中手动上传证书,并关联站点,后续才可以自动更新");
}
const options = res.items.map(item => {
return {
label: `${item.primaryDomain}<${item.id},${item.description || "无备注"}>`,
value: item.id,
};
});
return options;
}
getNodeValue(node?: string) {
const node_master_key = "local";
const _value = node || node_master_key;
return encodeURIComponent(_value);
}
}
@@ -0,0 +1,3 @@
export * from "./plugins/index.js";
export * from "./access.js";
export * from "./client.js";
@@ -0,0 +1,212 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert";
import { OnePanelAccess } from "../access.js";
import { createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from "@certd/plugin-lib";
import { OnePanelClient } from "../client.js";
@IsTaskPlugin({
name: "1PanelDeployToWebsitePlugin",
title: "1Panel-部署证书到1Panel",
icon: "svg:icon-onepanel",
desc: "更新1Panel的证书",
group: pluginGroups.panel.key,
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
needPlus: false,
})
export class OnePanelDeployToWebsitePlugin extends AbstractTaskPlugin {
//证书选择,此项必须要有
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames],
},
required: true,
})
cert!: CertInfo;
@TaskInput(createCertDomainGetterInputDefine())
certDomains!: string[];
//授权选择框
@TaskInput({
title: "1Panel授权",
helper: "1Panel授权",
component: {
name: "access-selector",
type: "1panel",
},
required: true,
})
accessId!: string;
@TaskInput(
createRemoteSelectInputDefine({
title: "1Panel节点",
helper: "要更新的1Panel证书的节点信息,目前只有v2存在此概念",
typeName: "OnePanelDeployToWebsitePlugin",
action: OnePanelDeployToWebsitePlugin.prototype.onGetNodes.name,
value: "local",
required: true,
})
)
currentNode!: string;
@TaskInput(
createRemoteSelectInputDefine({
title: "1Panel证书ID",
typeName: "1PanelDeployToWebsitePlugin",
action: OnePanelDeployToWebsitePlugin.prototype.onGetSSLIds.name,
watches: ["accessId"],
helper: "要更新的1Panel证书id,选择授权之后,从下拉框中选择\nIP需要加白名单,如果是同一台机器部署的,可以试试172.16.0.0/12",
required: true,
})
)
sslIds!: string[];
access: OnePanelAccess;
async onInstance() {
this.access = await this.getAccess(this.accessId);
}
//http://xxx:xxxx/1panel/swagger/index.html#/App/get_apps__key
async execute(): Promise<void> {
//login 获取token
/**
* curl 'http://127.0.0.1:7001/api/v1/auth/login' --data-binary '{"name":"admin_test","password":"admin_test1234","ignoreCaptcha":true,"captcha":"","captchaID":"nY8Cqeut3TjZMfJMAz0k","authMethod":"jwt","language":"zh"}' -H 'EntranceCode: emhhbmd5eg=='
* curl 'http://127.0.0.1:7001/api/v1/dashboard/current/all/all' -H 'PanelAuthorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJJRCI6MCwiTmFtZSI6ImFkbWluX3Rlc3QiLCJCdWZmZXJUaW1lIjozNjAwLCJpc3MiOiIxUGFuZWwiLCJleHAiOjE3MDkyODg4MDl9.pdknJdjLY4Fp8wCE9Gvaiic2rLoSdvUSJB9ossyya_I'
*/
const client = new OnePanelClient({
access: this.access,
http: this.http,
logger: this.logger,
utils: this.ctx.utils,
});
const sslIds = this.sslIds;
for (const sslId of sslIds) {
try {
const certRes = await this.get1PanelCertInfo(client, sslId);
if (!this.isNeedUpdate(certRes)) {
continue;
}
const uploadRes = await client.doRequest({
url: `/api/${this.access.apiVersion}/websites/ssl/upload`,
method: "post",
data: {
sslIds,
certificate: this.cert.crt,
certificatePath: "",
description: certRes.description || this.appendTimeSuffix("certd"),
privateKey: this.cert.key,
privateKeyPath: "",
sslID: sslId,
type: "paste",
},
currentNode: this.currentNode,
});
console.log("uploadRes", JSON.stringify(uploadRes));
} catch (e) {
this.logger.warn(`更新证书(id:${sslId})失败`, e);
this.logger.info("可能1Panel正在重启,等待10秒后检查证书是否更新成功");
await this.ctx.utils.sleep(10000);
const certRes = await this.get1PanelCertInfo(client, sslId);
if (!this.isNeedUpdate(certRes)) {
continue;
}
throw e;
}
}
}
isNeedUpdate(certRes: any) {
if (certRes.pem === this.cert.crt && certRes.key === this.cert.key) {
this.logger.info(`证书(id:${certRes.id})已经是最新的了,不需要更新`);
return false;
}
return true;
}
async get1PanelCertInfo(client: OnePanelClient, sslId: string) {
const certRes = await client.doRequest({
url: `/api/${this.access.apiVersion}/websites/ssl/${sslId}`,
method: "get",
currentNode: this.currentNode,
});
if (!certRes) {
throw new Error(`没有找到证书(id:${sslId}),请先在1Panel中手动上传证书,后续才可以自动更新`);
}
return certRes;
}
async onGetNodes() {
const options = [{ label: "主节点", value: "local" }];
if (this.access.apiVersion === "v1") {
return options;
}
if (!this.access) {
throw new Error("请先选择授权");
}
const client = new OnePanelClient({
access: this.access,
http: this.http,
logger: this.logger,
utils: this.ctx.utils,
});
const resp = await client.doRequest({
url: `/api/${this.access.apiVersion}/core/nodes/list`,
method: "post",
data: {},
});
// console.log('resp', resp)
return [...options, ...(resp?.map(item => ({ label: `${item.addr}(${item.name})`, value: item.name })) || [])];
}
// requestHandle
async onGetSSLIds() {
// if (!isPlus()) {
// throw new Error("自动获取站点列表为专业版功能,您可以手动输入证书id进行部署");
// }
if (!this.access) {
throw new Error("请先选择授权");
}
const client = new OnePanelClient({
access: this.access,
http: this.http,
logger: this.logger,
utils: this.ctx.utils,
});
const res = await client.doRequest({
url: `api/${this.access.apiVersion}/websites/ssl/search`,
method: "post",
data: {
page: 1,
pageSize: 99999,
},
currentNode: this.currentNode,
});
if (!res?.items) {
throw new Error("没有找到证书,请先在1Panel中手动上传证书,并关联站点,后续才可以自动更新");
}
const list = res.items.map(item => {
const domains = item.domains ? [] : item.domains.split(",");
const allDomains = [item.primaryDomain, ...domains];
return {
label: `${item.primaryDomain}<${item.id},${item.description || "无备注"}>`,
value: item.id,
domain: allDomains,
};
});
return this.ctx.utils.options.buildGroupOptions(list, this.certDomains);
}
}
new OnePanelDeployToWebsitePlugin();
@@ -0,0 +1 @@
export * from "./deploy-to-website.js";
@@ -0,0 +1,65 @@
import CryptoJS from "crypto-js";
import crypto from "crypto";
function rsaEncrypt(data: string, publicKey: string) {
if (!data) {
return data;
}
// const jsEncrypt = new JSEncrypt();
// jsEncrypt.setPublicKey(publicKey);
// return jsEncrypt.encrypt(data);
// RSA encryption is not supported in browser
//换一种nodejs的实现
return crypto
.publicEncrypt(
{
key: publicKey,
padding: crypto.constants.RSA_PKCS1_PADDING, // 明确指定填充方式
},
Buffer.from(data, "utf-8")
)
.toString("base64");
}
function aesEncrypt(data: string, key: string) {
const keyBytes = CryptoJS.enc.Utf8.parse(key);
const iv = CryptoJS.lib.WordArray.random(16);
const encrypted = CryptoJS.AES.encrypt(data, keyBytes, {
iv: iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
});
return iv.toString(CryptoJS.enc.Base64) + ":" + encrypted.toString();
}
function urlDecode(value: string): string {
return decodeURIComponent(value.replace(/\+/g, " "));
}
function generateAESKey(): string {
const keyLength = 16;
const randomBytes = new Uint8Array(keyLength);
crypto.getRandomValues(randomBytes);
return Array.from(randomBytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
export const encryptPassword = (rsaPublicKeyText: string, password: string) => {
if (!password) {
return "";
}
// let rsaPublicKeyText = getCookie("panel_public_key");
if (!rsaPublicKeyText) {
console.log("RSA public key not found");
return password;
}
rsaPublicKeyText = urlDecode(rsaPublicKeyText);
const aesKey = generateAESKey();
rsaPublicKeyText = rsaPublicKeyText.replaceAll('"', "");
const rsaPublicKey = atob(rsaPublicKeyText);
const keyCipher = rsaEncrypt(aesKey, rsaPublicKey);
const passwordCipher = aesEncrypt(password, aesKey);
return `${keyCipher}:${passwordCipher}`;
};
@@ -0,0 +1,48 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
@IsAccess({
name: "alipay",
title: "支付宝",
icon: "ion:logo-alipay",
})
export class AlipayAccess extends BaseAccess {
/**
* appId: "<-- 请填写您的AppId,例如:2019091767145019 -->",
* privateKey: "<-- 请填写您的应用私钥,例如:MIIEvQIBADANB ... ... -->",
* alipayPublicKey: "<-- 请填写您的支付宝公钥,例如:MIIBIjANBg... -->",
*/
@AccessInput({
title: "AppId",
component: {
placeholder: "201909176714xxxx",
},
required: true,
encrypt: false,
})
appId: string;
@AccessInput({
title: "应用私钥",
component: {
placeholder: "MIIEvQIBADANB...",
name: "a-textarea",
rows: 3,
},
required: true,
encrypt: true,
})
privateKey: string;
@AccessInput({
title: "支付宝公钥",
component: {
name: "a-textarea",
rows: 3,
placeholder: "MIIBIjANBg...",
},
required: true,
encrypt: true,
})
alipayPublicKey: string;
}
new AlipayAccess();
@@ -0,0 +1 @@
export * from "./access.js";
@@ -0,0 +1,61 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
import { BaiduYunCertClient } from "./client.js";
/**
*
* certd的后台管理系统中
*/
@IsAccess({
name: "baidu",
title: "百度云授权",
desc: "",
icon: "ant-design:baidu-outlined",
order: 2,
})
export class BaiduAccess extends BaseAccess {
@AccessInput({
title: "AccessKey",
component: {
placeholder: "AccessKey",
},
helper: "[百度智能云->安全认证获取](https://console.bce.baidu.com/iam/#/iam/accesslist)",
required: true,
encrypt: false,
})
accessKey = "";
@AccessInput({
title: "SecretKey",
component: {
placeholder: "SecretKey",
},
helper: "",
required: true,
encrypt: true,
})
secretKey = "";
@AccessInput({
title: "测试",
component: {
name: "api-test",
action: "onTestRequest",
},
helper: "点击测试接口看是否正常",
})
testRequest = true;
async onTestRequest() {
const certClient = new BaiduYunCertClient({
access: this,
logger: this.ctx.logger,
http: this.ctx.http,
});
const res = await certClient.getCertList();
this.ctx.logger.info("测试接口返回", res);
return "ok";
}
}
new BaiduAccess();
@@ -0,0 +1,191 @@
import { HttpClient, HttpRequestConfig, ILogger } from "@certd/basic";
import { BaiduAccess } from "./access.js";
import crypto from "crypto";
import { CertInfo } from "@certd/plugin-cert";
export type BaiduYunClientOptions = {
access: BaiduAccess;
logger: ILogger;
http: HttpClient;
};
export type BaiduYunReq = {
host: string;
uri: string;
body?: any;
headers?: any;
query?: any;
method: string;
};
export class BaiduYunClient {
opts: BaiduYunClientOptions;
constructor(opts: BaiduYunClientOptions) {
this.opts = opts;
}
// 调用百度云接口,传接口uri和json参数
async doRequest(req: BaiduYunReq, config?: HttpRequestConfig) {
const host = req.host;
const timestamp = this.getTimestampString();
const queryString = this.getQueryString(req.query);
const Authorization = this.getAuthString(host, req.method, req.uri, queryString, timestamp);
const ContentType = "application/json; charset=utf-8";
let url = "https://" + host + req.uri;
if (req.query) {
url += "?" + queryString;
}
const res = await this.opts.http.request({
url: url,
method: req.method,
data: req.body,
headers: {
Authorization: Authorization,
"Content-Type": ContentType,
Host: host,
"x-bce-date": timestamp,
...req.headers,
},
...config,
});
if (res.code) {
throw new Error(`请求失败:${res.message}`);
}
return res;
}
// 获取UTC时间
getTimestampString() {
return new Date().toISOString().replace(/\.\d*/, "");
}
// 获取参数拼接字符串
getQueryString(params) {
let queryString = "";
let paramKeyArray = [];
if (params) {
for (const key in params) {
paramKeyArray.push(key);
}
paramKeyArray = paramKeyArray.sort();
}
if (paramKeyArray && paramKeyArray.length > 0) {
for (const key of paramKeyArray) {
queryString += encodeURIComponent(key) + "=" + encodeURIComponent(params[key]) + "&";
}
queryString = queryString.substring(0, queryString.length - 1);
}
return queryString;
}
uriEncode(input: string, encodeSlash = false) {
let result = "";
for (let i = 0; i < input.length; i++) {
const ch = input.charAt(i);
if ((ch >= "A" && ch <= "Z") || (ch >= "a" && ch <= "z") || (ch >= "0" && ch <= "9") || ch === "_" || ch === "-" || ch === "~" || ch === ".") {
result += ch;
} else if (ch === "/") {
result += encodeSlash ? "%2F" : ch;
} else {
result += this.toHexUTF8(ch);
}
}
return result;
}
toHexUTF8(ch) {
// Convert character to UTF-8 bytes and return the hex representation
const utf8Bytes = new TextEncoder().encode(ch);
let hexString = "";
for (const byte of utf8Bytes) {
hexString += "%" + byte.toString(16).padStart(2, "0").toUpperCase();
}
return hexString;
}
// 签名
getAuthString(Host: string, Method: string, CanonicalURI: string, CanonicalQueryString: string, timestamp: string) {
// 1
const expirationPeriodInSeconds = 120;
const authStringPrefix = `bce-auth-v1/${this.opts.access.accessKey}/${timestamp}/${expirationPeriodInSeconds}`;
// 2
const signedHeaders = "host;x-bce-date";
const CanonicalHeaders = encodeURIComponent("host") + ":" + encodeURIComponent(Host) + "\n" + encodeURIComponent("x-bce-date") + ":" + encodeURIComponent(timestamp);
const CanonicalRequest = Method.toUpperCase() + "\n" + this.uriEncode(CanonicalURI, false) + "\n" + CanonicalQueryString + "\n" + CanonicalHeaders;
// 3
const SigningKey = crypto.createHmac("sha256", this.opts.access.secretKey).update(authStringPrefix).digest().toString("hex");
// 4
const Signature = crypto.createHmac("sha256", SigningKey).update(CanonicalRequest).digest().toString("hex");
// 5
return `${authStringPrefix}/${signedHeaders}/${Signature}`;
}
}
export class BaiduYunCertClient {
client: BaiduYunClient;
constructor(opts: BaiduYunClientOptions) {
this.client = new BaiduYunClient(opts);
}
async createCert(opts: { certName: string; cert: CertInfo }) {
// /v1/certificate
const res = await this.client.doRequest({
host: "certificate.baidubce.com",
uri: `/v1/certificate`,
method: "post",
body: {
/**
* certName String 1-65-/.Java正则表达式` ^[a-zA-Z]a-zA-Z0-9\-/\.]{2,64}$`
* certServerData String (Base64编码)
* certPrivateData String (Base64编码)
*/
certName: "certd_" + opts.certName, // 字母开头,且小于64长度
certServerData: opts.cert.crt,
certPrivateData: opts.cert.key,
},
});
return res;
}
async getCertList() {
/**
* GET /v1/certificate HTTP/1.1
* HOST: certificate.baidubce.com
* Authorization: {authorization}
* Content-Type: application/json; charset=utf-8
* x-bce-date: 2014-06-01T23:00:10Z
*/
return await this.client.doRequest({
host: "certificate.baidubce.com",
uri: `/v1/certificate`,
method: "get",
});
}
async updateCert(opts: { certId: string; certName: string; cert: CertInfo }) {
/**
* /v1/certificate/{certId}?certData
* certName String 1-65-/.Java正则表达式` ^[a-zA-Z]a-zA-Z0-9\-/\.]{2,64}$`
* certServerData String (Base64编码)
* certPrivateData String (Base64编码)
* certLinkData String (Base64编码)
* certType Integer 1
*/
return await this.client.doRequest({
host: "certificate.baidubce.com",
uri: `/v1/certificate/${opts.certId}`,
method: "put",
body: {
certName: opts.certName,
certServerData: opts.cert.crt,
certPrivateData: opts.cert.key,
},
});
}
}
@@ -0,0 +1,3 @@
export * from "./plugins/index.js";
export * from "./access.js";
export * from "./client.js";
@@ -0,0 +1,3 @@
export * from "./plugin-deploy-to-cdn.js";
export * from "./plugin-deploy-to-blb.js";
export * from "./plugin-upload-to-baidu.js";
@@ -0,0 +1,323 @@
import { AbstractTaskPlugin, IsTaskPlugin, PageSearch, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo, CertReader } from "@certd/plugin-cert";
import { createCertDomainGetterInputDefine } from "@certd/plugin-lib";
import { BaiduYunCertClient, BaiduYunClient } from "../client.js";
@IsTaskPlugin({
name: "BaiduDeployToBLB",
title: "百度云-部署证书到负载均衡",
icon: "ant-design:baidu-outlined",
group: pluginGroups.baidu.key,
desc: "部署到百度云负载均衡,包括BLB、APPBLB",
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
needPlus: false,
})
export class BaiduDeployToBLBPlugin extends AbstractTaskPlugin {
//证书选择,此项必须要有
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames, "BaiduUploadCert"],
},
required: true,
})
cert!: CertInfo | string;
@TaskInput(createCertDomainGetterInputDefine())
certDomains!: string[];
@TaskInput({
title: "区域",
component: {
name: "a-select",
vModel: "value",
options: [
/**
* blb.bj.baidubce.com HTTP/HTTPS
* 广 blb.gz.baidubce.com HTTP/HTTPS
* blb.su.baidubce.com HTTP/HTTPS
* blb.hkg.baidubce.com HTTP/HTTPS
* blb.fwh.baidubce.com HTTP/HTTPS
* blb.bd.baidubce.com HTTP/HTTPS
* blb.fsh.baidubce.com HTTP/HTTPS
* blb.sin.baidubce.com HTTP/HTTPS
*/
{ value: "bj", label: "北京" },
{ value: "fsh", label: "上海" },
{ value: "gz", label: "广州" },
{ value: "fwh", label: "武汉" },
{ value: "su", label: "苏州" },
{ value: "bd", label: "保定" },
{ value: "hkg", label: "香港" },
{ value: "sin", label: "新加坡" },
],
},
required: true,
})
region!: string;
@TaskInput({
title: "负载均衡类型",
component: {
name: "a-select",
vModel: "value",
options: [
{ value: "blb", label: "普通负载均衡" },
{ value: "appblb", label: "应用负载均衡" },
],
},
required: true,
})
blbType!: string;
//授权选择框
@TaskInput({
title: "百度云授权",
helper: "百度云授权",
component: {
name: "access-selector",
type: "baidu",
},
required: true,
})
accessId!: string;
@TaskInput({
title: "负载均衡ID",
component: {
name: "remote-select",
vModel: "value",
mode: "tags",
action: "GetBLBList",
watches: ["certDomains", "blbType", "accessId"],
},
required: true,
})
blbIds!: string[];
@TaskInput({
title: "监听器ID",
component: {
name: "remote-select",
vModel: "value",
mode: "tags",
action: "GetListenerList",
watches: ["certDomains", "accessId", "blbIds"],
},
required: true,
})
listenerIds!: string[];
async onInstance() {}
async execute(): Promise<void> {
this.logger.info("开始更新百度云监听器证书");
const access = await this.getAccess(this.accessId);
const certClient = new BaiduYunCertClient({
access,
logger: this.logger,
http: this.ctx.http,
});
let certId = this.cert as string;
if (typeof this.cert !== "string") {
this.logger.info("上传证书到百度云");
const res = await certClient.createCert({
cert: this.cert,
certName: CertReader.buildCertName(this.cert),
});
certId = res.certId;
this.logger.info(`上传证书到百度云成功:${certId}`);
}
const baiduyunClient = new BaiduYunClient({
access,
logger: this.logger,
http: this.ctx.http,
});
for (const listenerId of this.listenerIds) {
const listenerParams = listenerId.split("_");
const blbId = listenerParams[0];
const listenerType = listenerParams[1];
const listenerPort = listenerParams[2];
let additionalCertHost = null;
if (listenerParams.length > 3) {
additionalCertHost = listenerParams[3];
}
this.logger.info(`更新监听器证书开始:${listenerId}`);
if (!additionalCertHost) {
await this.updateListenerCert({
client: baiduyunClient,
blbId,
listenerType,
listenerPort,
certId,
});
} else {
const listenerDomains = await this.getListeners(baiduyunClient, blbId, listenerType, listenerPort);
if (!listenerDomains || listenerDomains.length === 0) {
throw new Error(`未找到监听器:${listenerId}`);
}
const oldAdditionals = listenerDomains[0].additionalCertDomains;
for (const oldAddi of oldAdditionals) {
if (oldAddi.host === additionalCertHost) {
oldAddi.certId = certId;
}
}
await this.updateListenerCert({
client: baiduyunClient,
blbId,
listenerType,
listenerPort,
certId,
additionalCertDomains: oldAdditionals,
});
}
this.logger.info(`更新监听器证书成功:${listenerId}`);
await this.ctx.utils.sleep(3000);
}
this.logger.info(`更新百度云监听器证书完成`);
}
async onGetListenerList(data: PageSearch = {}) {
const access = await this.getAccess(this.accessId);
const client = new BaiduYunClient({
access,
logger: this.logger,
http: this.ctx.http,
});
const listeners = [];
for (const blbId of this.blbIds) {
/**
* GET /v{version}/appblb/{blbId}/TCPlistener?listenerPort={listenerPort}&marker={marker}&maxKeys={maxKeys} HTTP/1.1
* Host: blb.bj.baidubce.com
*/
const listenerTypes = ["HTTPSlistener", "SSLlistener"];
for (const listenerType of listenerTypes) {
const list = await this.getListeners(client, blbId, listenerType);
if (list && list.length > 0) {
for (const item of list) {
const key = `${blbId}_${listenerType}_${item.listenerPort}`;
listeners.push({
value: key,
label: key,
});
if (item.additionalCertDomains && item.additionalCertDomains.length > 0) {
for (const addi of item.additionalCertDomains) {
const addiKey = `${key}_${addi.host}`;
listeners.push({
value: addiKey,
label: `${addiKey}【扩展】`,
});
}
}
}
}
}
}
if (!listeners || listeners.length === 0) {
throw new Error("未找到https/SSL监听器");
}
return listeners;
}
private async getListeners(client: BaiduYunClient, blbId: string, listenerType: string, listenerPort?: number | string) {
const query: any = {
maxItems: 1000,
};
if (listenerPort) {
query.listenerPort = listenerPort;
}
const res = await client.doRequest({
host: `blb.${this.region}.baidubce.com`,
uri: `/v1/${this.blbType}/${blbId}/${listenerType}`,
method: "GET",
query,
});
return res.listenerList;
}
async onGetBLBList(data: PageSearch = {}) {
const access = await this.getAccess(this.accessId);
const client = new BaiduYunClient({
access,
logger: this.logger,
http: this.ctx.http,
});
/**
* GET /v{version}/appblb?address={address}&name={name}&blbId={blbId}&marker={marker}&maxKeys={maxKeys} HTTP/1.1
* Host: blb.bj.baidubce.com
*/
const res = await client.doRequest({
host: `blb.${this.region}.baidubce.com`,
uri: `/v1/${this.blbType}`,
method: "GET",
query: {
maxItems: 1000,
},
});
const list = res.blbList;
if (!list || list.length === 0) {
throw new Error("没有数据,你可以手动输入");
}
const options: any[] = [];
for (const item of list) {
options.push({
value: item.blbId,
label: item.name,
});
}
return options;
}
private async updateListenerCert(param: { client: BaiduYunClient; blbId: string; listenerType: string; listenerPort: string; certId?: any; additionalCertDomains?: any[] }) {
/**
* PUT /v{version}/appblb/{blbId}/SSLlistener?clientToken={clientToken}&listenerPort={listenerPort} HTTP/1.1
* Host: blb.bj.baidubce.com
* Authorization: authorization string
*
* {
* "scheduler":scheduler,
* "certIds":[certId],
* "encryptionType":encryptionType,
* "encryptionProtocols":[protocol1, protacol2],
* "dualAuth":false,
* "clientCertIds":[clientCertId],
* "description":description
* }
*/
const { client, blbId, listenerType, listenerPort, certId, additionalCertDomains } = param;
const body: any = {};
if (additionalCertDomains) {
body.additionalCertDomains = additionalCertDomains;
}
if (certId) {
body.certIds = [certId];
}
const res = await client.doRequest({
host: `blb.${this.region}.baidubce.com`,
uri: `/v1/${this.blbType}/${blbId}/${listenerType}`,
method: "PUT",
query: {
listenerPort,
},
body,
});
return res;
}
}
new BaiduDeployToBLBPlugin();
@@ -0,0 +1,145 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo, CertReader } from "@certd/plugin-cert";
import { BaiduYunCertClient, BaiduYunClient } from "../client.js";
import { createCertDomainGetterInputDefine } from "@certd/plugin-lib";
@IsTaskPlugin({
name: "BaiduDeployToCDN",
title: "百度云-部署证书到CDN",
icon: "ant-design:baidu-outlined",
group: pluginGroups.baidu.key,
desc: "部署到百度云CDN",
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
needPlus: false,
})
export class BaiduDeployToCDNPlugin extends AbstractTaskPlugin {
//证书选择,此项必须要有
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames, "BaiduUploadCert"],
},
required: true,
})
cert!: CertInfo | string;
@TaskInput(createCertDomainGetterInputDefine())
certDomains!: string[];
//授权选择框
@TaskInput({
title: "百度云授权",
helper: "百度云授权",
component: {
name: "access-selector",
type: "baidu",
},
required: true,
})
accessId!: string;
//证书选择,此项必须要有
@TaskInput({
title: "CDN域名",
component: {
name: "remote-select",
vModel: "value",
mode: "tags",
action: "GetDomainList",
watches: ["certDomains", "accessId"],
},
required: true,
})
domains!: string[];
async onInstance() {}
async execute(): Promise<void> {
const access = await this.getAccess(this.accessId);
const client = new BaiduYunClient({
access,
logger: this.logger,
http: this.ctx.http,
});
const certClient = new BaiduYunCertClient({
access,
logger: this.logger,
http: this.ctx.http,
});
let certId = this.cert as string;
if (typeof this.cert !== "string") {
this.logger.info("上传证书到百度云");
const res = await certClient.createCert({
cert: this.cert,
certName: CertReader.buildCertName(this.cert),
});
certId = res.certId;
this.logger.info(`上传证书到百度云成功:${certId}`);
}
const body = {
https: {
enabled: true,
certId: certId,
},
};
for (const domain of this.domains) {
await client.doRequest({
host: "cdn.baidubce.com",
uri: `/v2/domain/${domain}/config`,
body,
query: {
https: "",
},
method: "put",
});
this.logger.info(`部署证书到${domain}成功`);
}
}
async onGetDomainList() {
// if (!isPlus()) {
// throw new Error("自动获取站点列表为专业版功能,您可以手动输入站点域名/站点名称进行部署");
// }
const access = await this.getAccess(this.accessId);
const client = new BaiduYunClient({
access,
logger: this.logger,
http: this.ctx.http,
});
const res = await client.doRequest({
host: "cdn.baidubce.com",
uri: `/v2/domain`,
method: "GET",
query: {
maxItems: 1000,
},
});
const list = res.domains;
if (!list || list.length === 0) {
throw new Error("未找到加速域名,你可以手动输入");
}
const options: any[] = [];
for (const item of list) {
options.push({
value: item.name,
label: item.name,
domain: item.name,
});
}
return this.ctx.utils.options.buildGroupOptions(options, this.certDomains);
}
}
new BaiduDeployToCDNPlugin();
@@ -0,0 +1,68 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput, TaskOutput } from "@certd/pipeline";
import { CertApplyPluginNames, CertReader } from "@certd/plugin-cert";
import { BaiduAccess } from "../access.js";
import { BaiduYunCertClient } from "../client.js";
@IsTaskPlugin({
name: "BaiduUploadCert",
title: "百度云-上传到证书托管",
icon: "ant-design:baidu-outlined",
desc: "上传证书到百度云证书托管中心",
group: pluginGroups.baidu.key,
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
})
export class BaiduUploadCert extends AbstractTaskPlugin {
// @TaskInput({ title: '证书名称' })
// name!: string;
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames],
},
required: true,
})
cert!: any;
@TaskInput({
title: "Access授权",
helper: "access授权",
component: {
name: "access-selector",
type: "baidu",
},
required: true,
})
accessId!: string;
@TaskOutput({
title: "百度云CertId",
})
baiduCertId?: string;
async execute(): Promise<void> {
const access = await this.getAccess<BaiduAccess>(this.accessId);
const certClient = new BaiduYunCertClient({
access,
logger: this.logger,
http: this.http,
});
const certItem = await certClient.createCert({
cert: this.cert,
certName: CertReader.buildCertName(this.cert),
});
this.baiduCertId = certItem.certId;
this.logger.info(`上传成功,证书ID${certItem.certId}`);
}
}
new BaiduUploadCert();

Some files were not shown because too many files have changed in this diff Show More