mirror of
https://github.com/certd/certd.git
synced 2026-04-14 20:40:53 +08:00
feat: 【破坏性更新】插件改为metadata加载模式,plugin-cert、plugin-lib包部分代码转移到certd-server中,影响自定义插件,需要修改相关import引用
ssh、aliyun、tencent、qiniu、oss等 access和client需要转移import
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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的叫 keyId,ssl.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();
|
||||
@@ -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();
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./eab-access.js";
|
||||
export * from "./google-access.js";
|
||||
@@ -1,3 +1 @@
|
||||
export * from "./access/index.js";
|
||||
export * from "./plugin/index.js";
|
||||
export * from "./dns-provider/index.js";
|
||||
export * from "@certd/plugin-lib";
|
||||
@@ -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);
|
||||
// });
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.pfx:pfx格式证书文件,iis服务器使用
|
||||
cert.der:der格式证书文件
|
||||
cert.jks:jks格式证书文件,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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -1 +0,0 @@
|
||||
export const dnsList = [];
|
||||
@@ -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();
|
||||
@@ -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:"];
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from "./aliyun-access.js";
|
||||
export * from "./alioss-access.js";
|
||||
export * from "./aliesa-access.js";
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./lib/index.js";
|
||||
export * from "./access/index.js";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from "./base-client.js";
|
||||
export * from "./ssl-client.js";
|
||||
export * from "./oss-client.js";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
2
packages/plugins/plugin-lib/src/cert/consts.ts
Normal file
2
packages/plugins/plugin-lib/src/cert/consts.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const CertApplyPluginNames = [":cert:"];
|
||||
export const EVENT_CERT_APPLY_SUCCESS = "CertApply.success";
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
4
packages/plugins/plugin-lib/src/cert/index.ts
Normal file
4
packages/plugins/plugin-lib/src/cert/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./convert.js";
|
||||
export * from "./cert-reader.js";
|
||||
export * from "./consts.js";
|
||||
export * from "./dns-provider/index.js";
|
||||
@@ -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();
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./access/ctyun-access.js";
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./access.js";
|
||||
export * from "./client.js";
|
||||
@@ -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";
|
||||
17
packages/plugins/plugin-lib/src/lib/check.ts
Normal file
17
packages/plugins/plugin-lib/src/lib/check.ts
Normal 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>;
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./ocr-api.js";
|
||||
export * from "./check.js";
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./factory.js";
|
||||
export * from "./api.js";
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from "./access.js";
|
||||
export * from "./access-oss.js";
|
||||
export * from "./lib/sdk.js";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./access.js";
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from "./ssh.js";
|
||||
export * from "./ssh-access.js";
|
||||
export * from "./sftp-access.js";
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from "./access.js";
|
||||
export * from "./access-cos.js";
|
||||
export * from "./lib/index.js";
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./ssl-client.js";
|
||||
export * from "./cos-client.js";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user