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

View File

@@ -21,13 +21,8 @@
"@certd/basic": "^1.37.17",
"@certd/pipeline": "^1.37.17",
"@certd/plugin-lib": "^1.37.17",
"@google-cloud/publicca": "^1.3.0",
"dayjs": "^1.11.7",
"jszip": "^3.10.1",
"lodash-es": "^4.17.21",
"psl": "^1.9.0",
"punycode.js": "^2.3.1",
"rimraf": "^5.0.5"
"psl": "^1.9.0"
},
"devDependencies": {
"@types/chai": "^4.3.3",

View File

@@ -1,43 +0,0 @@
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();

View File

@@ -1,98 +0,0 @@
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();

View File

@@ -1,2 +0,0 @@
export * from "./eab-access.js";
export * from "./google-access.js";

View File

@@ -1,3 +1 @@
export * from "./access/index.js";
export * from "./plugin/index.js";
export * from "./dns-provider/index.js";
export * from "@certd/plugin-lib";

View File

@@ -1,62 +0,0 @@
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);
// });

View File

@@ -1,462 +0,0 @@
// @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";
import { IContext } from "@certd/pipeline";
import { ILogger, utils } from "@certd/basic";
import { IDnsProvider, IDomainParser } from "../../dns-provider/index.js";
import punycode from "punycode.js";
import { IOssClient } 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;
}
}

View File

@@ -1,212 +0,0 @@
import { AbstractTaskPlugin, FileItem, IContext, Step, TaskInput, TaskOutput } from "@certd/pipeline";
import dayjs from "dayjs";
import type { CertInfo } from "./acme.js";
import { CertReader } from "./cert-reader.js";
import JSZip from "jszip";
import { CertConverter } from "./convert.js";
export const EVENT_CERT_APPLY_SUCCESS = "CertApply.success";
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);
}
}

View File

@@ -1,171 +0,0 @@
import { NotificationBody, Step, TaskInput } from "@certd/pipeline";
import dayjs from "dayjs";
import { CertReader } from "./cert-reader.js";
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);
}
}
}

View File

@@ -1,193 +0,0 @@
import { IsTaskPlugin, pluginGroups, RunStrategy, Step, TaskInput, TaskOutput } from "@certd/pipeline";
import type { CertInfo } from "../acme.js";
import { CertReader } from "../cert-reader.js";
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();

View File

@@ -1,165 +0,0 @@
import { IsTaskPlugin, PageSearch, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { AliyunAccess, createRemoteSelectInputDefine } from "@certd/plugin-lib";
import type { CertInfo } from "../acme.js";
import { CertApplyBasePlugin } from "../base.js";
import { CertReader } from "../cert-reader.js";
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();

View File

@@ -1,670 +0,0 @@
import { CancelError, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { utils } from "@certd/basic";
import { AcmeService, CertInfo, DomainsVerifyPlan, DomainVerifyPlan, PrivateKeyType, SSLProvider } from "./acme.js";
import * as _ from "lodash-es";
import { createDnsProvider, DnsProviderContext, DnsVerifier, DomainVerifiers, HttpVerifier, IDnsProvider, IDomainVerifierGetter, ISubDomainsGetter } from "../../dns-provider/index.js";
import { CertReader } from "./cert-reader.js";
import { CertApplyBasePlugin } from "./base.js";
import { GoogleClient } from "../../libs/google.js";
import { EabAccess } from "../../access";
import { DomainParser } from "../../dns-provider/domain-parser.js";
import { ossClientFactory } from "@certd/plugin-lib";
export * from "./base.js";
export type { CertInfo };
export * from "./cert-reader.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();

View File

@@ -1 +0,0 @@
export const dnsList = [];

View File

@@ -1,250 +0,0 @@
import { IsTaskPlugin, pluginGroups, RunStrategy, Step, TaskInput } from "@certd/pipeline";
import type { CertInfo } from "../acme.js";
import { CertReader } from "../cert-reader.js";
import { CertApplyBasePlugin } from "../base.js";
import fs from "fs";
import { EabAccess } from "../../../access/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();

View File

@@ -1,7 +0,0 @@
export { EVENT_CERT_APPLY_SUCCESS } from "./cert-plugin/base-convert.js";
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";
export const CertApplyPluginNames = [":cert:"];

View File

@@ -21,6 +21,7 @@
"@alicloud/openapi-util": "^0.3.2",
"@alicloud/pop-core": "^1.7.10",
"@alicloud/tea-util": "^1.4.10",
"psl": "^1.15.0",
"@aws-sdk/client-s3": "^3.787.0",
"@certd/basic": "^1.37.17",
"@certd/pipeline": "^1.37.17",
@@ -37,7 +38,10 @@
"socks-proxy-agent": "^8.0.4",
"ssh2": "1.17.0",
"strip-ansi": "^7.1.0",
"tencentcloud-sdk-nodejs": "^4.0.1005"
"tencentcloud-sdk-nodejs": "^4.0.1005",
"@certd/acme-client": "^1.37.17",
"@certd/plus-core": "^1.37.17",
"punycode.js": "^2.3.1"
},
"devDependencies": {
"@types/chai": "^4.3.3",

View File

@@ -1,45 +0,0 @@
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();

View File

@@ -1,71 +0,0 @@
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();

View File

@@ -1,129 +0,0 @@
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();

View File

@@ -1,3 +0,0 @@
export * from "./aliyun-access.js";
export * from "./alioss-access.js";
export * from "./aliesa-access.js";

View File

@@ -1,2 +0,0 @@
export * from "./lib/index.js";
export * from "./access/index.js";

View File

@@ -1,78 +0,0 @@
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;
}
}

View File

@@ -1,3 +0,0 @@
export * from "./base-client.js";
export * from "./ssl-client.js";
export * from "./oss-client.js";

View File

@@ -1,87 +0,0 @@
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;
}
}

View File

@@ -1,185 +0,0 @@
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;
}
}

View File

@@ -1,4 +1,3 @@
import { CertInfo } from "./acme.js";
import fs from "fs";
import os from "os";
import path from "path";
@@ -7,6 +6,19 @@ import { ILogger } from "@certd/basic";
import dayjs from "dayjs";
import { uniq } from "lodash-es";
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 CertReaderHandleContext = {
reader: CertReader;
tmpCrtPath: string;

View File

@@ -0,0 +1,2 @@
export const CertApplyPluginNames = [":cert:"];
export const EVENT_CERT_APPLY_SUCCESS = "CertApply.success";

View File

@@ -1,13 +1,10 @@
import { ILogger, sp } from "@certd/basic";
import type { CertInfo } from "../cert-plugin/acme.js";
import { CertReader, CertReaderHandleContext } from "../cert-plugin/cert-reader.js";
import type { CertInfo } from "./cert-reader.js";
import { CertReader, CertReaderHandleContext } from "./cert-reader.js";
import path from "path";
import os from "os";
import fs from "fs";
export { CertReader };
export type { CertInfo };
export class CertConverter {
logger: ILogger;

View File

@@ -8,6 +8,9 @@ export const DNS_PROVIDER_CLASS_KEY = "pipeline:dns-provider";
export function IsDnsProvider(define: DnsProviderDefine): ClassDecorator {
return (target: any) => {
if (process.env.certd_plugin_loadmode === "metadata") {
return;
}
target = Decorator.target(target);
Reflect.defineMetadata(DNS_PROVIDER_CLASS_KEY, define, target);

View File

@@ -0,0 +1,4 @@
export * from "./convert.js";
export * from "./cert-reader.js";
export * from "./consts.js";
export * from "./dns-provider/index.js";

View File

@@ -1,32 +0,0 @@
import { IsAccess, AccessInput, BaseAccess } from "@certd/pipeline";
@IsAccess({
name: "ctyun",
title: "天翼云授权",
desc: "",
icon: "ant-design:aliyun-outlined",
order: 2,
})
export class CtyunAccess extends BaseAccess {
@AccessInput({
title: "accessKeyId",
component: {
placeholder: "accessKeyId",
},
helper: "[前往创建天翼云AccessKey](https://iam.ctyun.cn/myAccessKey)",
required: true,
})
accessKeyId = "";
@AccessInput({
title: "securityKey",
component: {
placeholder: "securityKey",
},
required: true,
encrypt: true,
helper: "",
})
securityKey = "";
}
new CtyunAccess();

View File

@@ -1 +0,0 @@
export * from "./access/ctyun-access.js";

View File

@@ -1,77 +0,0 @@
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();

View File

@@ -1,63 +0,0 @@
import { FtpAccess } from "./access";
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);
}
}

View File

@@ -1,2 +0,0 @@
export * from "./access.js";
export * from "./client.js";

View File

@@ -1,11 +1,4 @@
export * from "./ssh/index.js";
export * from "./aliyun/index.js";
export * from "./common/index.js";
export * from "./ftp/index.js";
export * from "./tencent/index.js";
export * from "./qiniu/index.js";
export * from "./ctyun/index.js";
export * from "./oss/index.js";
export * from "./s3/index.js";
export * from "./lib/index.js";
export * from "./service/index.js";
export * from "./cert/index.js";

View File

@@ -0,0 +1,17 @@
import { AbstractTaskPlugin, TaskInstanceContext } from "@certd/pipeline";
import { isPlus } from "@certd/plus-core";
export function mustPlus() {
if (!isPlus()) {
throw new Error("此插件仅供专业版中使用");
}
}
export abstract class AbstractPlusTaskPlugin extends AbstractTaskPlugin {
setCtx(ctx: TaskInstanceContext) {
super.setCtx(ctx);
mustPlus();
}
abstract execute(): Promise<void>;
}

View File

@@ -1 +1,2 @@
export * from "./ocr-api.js";
export * from "./check.js";

View File

@@ -1,90 +0,0 @@
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 });
}
}
}
}

View File

@@ -1,41 +0,0 @@
import { OssClientContext } from "./api";
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();

View File

@@ -1,57 +0,0 @@
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}`);
}
}

View File

@@ -1,78 +0,0 @@
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}`);
});
}
}

View File

@@ -1,50 +0,0 @@
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);
}
}

View File

@@ -1,101 +0,0 @@
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}`);
}
}

View File

@@ -1,82 +0,0 @@
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],
});
}
}

View File

@@ -1,61 +0,0 @@
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],
});
}
}

View File

@@ -1,54 +0,0 @@
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}`);
}
}

View File

@@ -1,2 +0,0 @@
export * from "./factory.js";
export * from "./api.js";

View File

@@ -1,31 +0,0 @@
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();

View File

@@ -1,26 +0,0 @@
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();

View File

@@ -1,3 +0,0 @@
export * from "./access.js";
export * from "./access-oss.js";
export * from "./lib/sdk.js";

View File

@@ -1,180 +0,0 @@
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;
}
}

View File

@@ -1,115 +0,0 @@
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();

View File

@@ -1 +0,0 @@
export * from "./access.js";

View File

@@ -1,3 +0,0 @@
export * from "./ssh.js";
export * from "./ssh-access.js";
export * from "./sftp-access.js";

View File

@@ -1,34 +0,0 @@
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();

View File

@@ -1,173 +0,0 @@
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();

View File

@@ -1,643 +0,0 @@
// @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";
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,
});
},
});
}
}

View File

@@ -1,65 +0,0 @@
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();

View File

@@ -1,71 +0,0 @@
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}`;
}
}

View File

@@ -1,3 +0,0 @@
export * from "./access.js";
export * from "./access-cos.js";
export * from "./lib/index.js";

View File

@@ -1,183 +0,0 @@
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.TencentCosClient = void 0;
var basic_1 = require("@certd/basic");
var fs_1 = require("fs");
var TencentCosClient = /** @class */ (function () {
function TencentCosClient(opts) {
this.access = opts.access;
this.logger = opts.logger;
this.bucket = opts.bucket;
this.region = opts.region;
}
TencentCosClient.prototype.getCosClient = function () {
return __awaiter(this, void 0, void 0, function () {
var sdk, clientConfig;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, Promise.resolve().then(function () { return require("cos-nodejs-sdk-v5"); })];
case 1:
sdk = _a.sent();
clientConfig = {
SecretId: this.access.secretId,
SecretKey: this.access.secretKey,
};
return [2 /*return*/, new sdk.default(clientConfig)];
}
});
});
};
TencentCosClient.prototype.uploadFile = function (key, file) {
return __awaiter(this, void 0, void 0, function () {
var cos;
var _this = this;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, this.getCosClient()];
case 1:
cos = _a.sent();
return [2 /*return*/, (0, basic_1.safePromise)(function (resolve, reject) {
var readableStream = file;
if (typeof file === "string") {
readableStream = fs_1.default.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);
});
})];
}
});
});
};
TencentCosClient.prototype.removeFile = function (key) {
return __awaiter(this, void 0, void 0, function () {
var cos;
var _this = this;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, this.getCosClient()];
case 1:
cos = _a.sent();
return [2 /*return*/, (0, basic_1.safePromise)(function (resolve, reject) {
cos.deleteObject({
Bucket: _this.bucket,
Region: _this.region,
Key: key,
}, function (err, data) {
if (err) {
reject(err);
return;
}
resolve(data);
});
})];
}
});
});
};
TencentCosClient.prototype.downloadFile = function (key, savePath) {
return __awaiter(this, void 0, void 0, function () {
var cos, writeStream;
var _this = this;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, this.getCosClient()];
case 1:
cos = _a.sent();
writeStream = fs_1.default.createWriteStream(savePath);
return [2 /*return*/, (0, basic_1.safePromise)(function (resolve, reject) {
cos.getObject({
Bucket: _this.bucket,
Region: _this.region,
Key: key,
Output: writeStream,
}, function (err, data) {
if (err) {
reject(err);
return;
}
resolve(data);
});
})];
}
});
});
};
TencentCosClient.prototype.listDir = function (dirKey) {
return __awaiter(this, void 0, void 0, function () {
var cos;
var _this = this;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, this.getCosClient()];
case 1:
cos = _a.sent();
return [2 /*return*/, (0, basic_1.safePromise)(function (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);
});
})];
}
});
});
};
return TencentCosClient;
}());
exports.TencentCosClient = TencentCosClient;

View File

@@ -1,117 +0,0 @@
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);
}
);
});
}
}

View File

@@ -1,2 +0,0 @@
export * from "./ssl-client.js";
export * from "./cos-client.js";

View File

@@ -1,102 +0,0 @@
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;
}
}
}