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

ssh、aliyun、tencent、qiniu、oss等 access和client需要转移import
This commit is contained in:
xiaojunnuo
2025-12-31 17:01:37 +08:00
parent 9c26598831
commit a3fb24993d
312 changed files with 14321 additions and 597 deletions
@@ -0,0 +1,45 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
@IsAccess({
name: "aliesa",
title: "阿里云ESA授权",
desc: "",
icon: "ant-design:aliyun-outlined",
order: 0,
})
export class AliesaAccess extends BaseAccess {
@AccessInput({
title: "阿里云授权",
component: {
name: "access-selector",
vModel: "modelValue",
type: "aliyun",
},
helper: "请选择阿里云授权",
required: true,
})
accessId = "";
@AccessInput({
title: "地区",
component: {
name: "a-select",
vModel: "value",
options: [
{
label: "杭州",
value: "cn-hangzhou",
},
{
label: "新加坡",
value: "ap-southeast-1",
},
],
},
helper: "请选择ESA地区",
required: true,
})
region = "";
}
new AliesaAccess();
@@ -0,0 +1,71 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
@IsAccess({
name: "alioss",
title: "阿里云OSS授权",
desc: "包含地域和Bucket",
icon: "ant-design:aliyun-outlined",
})
export class AliossAccess extends BaseAccess {
@AccessInput({
title: "阿里云授权",
component: {
name: "access-selector",
vModel: "modelValue",
type: "aliyun",
},
helper: "请选择阿里云授权",
required: true,
})
accessId = "";
@AccessInput({
title: "大区",
component: {
name: "a-auto-complete",
vModel: "value",
options: [
{ value: "oss-cn-hangzhou", label: "华东1(杭州)" },
{ value: "oss-cn-shanghai", label: "华东2(上海)" },
{ value: "oss-cn-nanjing", label: "华东5(南京-本地地域)" },
{ value: "oss-cn-fuzhou", label: "华东6(福州-本地地域)" },
{ value: "oss-cn-wuhan-lr", label: "华中1(武汉-本地地域)" },
{ value: "oss-cn-qingdao", label: "华北1(青岛)" },
{ value: "oss-cn-beijing", label: "华北2(北京)" },
{ value: "oss-cn-zhangjiakou", label: "华北 3(张家口)" },
{ value: "oss-cn-huhehaote", label: "华北5(呼和浩特)" },
{ value: "oss-cn-wulanchabu", label: "华北6(乌兰察布)" },
{ value: "oss-cn-shenzhen", label: "华南1(深圳)" },
{ value: "oss-cn-heyuan", label: "华南2(河源)" },
{ value: "oss-cn-guangzhou", label: "华南3(广州)" },
{ value: "oss-cn-chengdu", label: "西南1(成都)" },
{ value: "oss-cn-hongkong", label: "中国香港" },
{ value: "oss-us-west-1", label: "美国(硅谷)①" },
{ value: "oss-us-east-1", label: "美国(弗吉尼亚)①" },
{ value: "oss-ap-northeast-1", label: "日本(东京)①" },
{ value: "oss-ap-northeast-2", label: "韩国(首尔)" },
{ value: "oss-ap-southeast-1", label: "新加坡①" },
{ value: "oss-ap-southeast-2", label: "澳大利亚(悉尼)①" },
{ value: "oss-ap-southeast-3", label: "马来西亚(吉隆坡)①" },
{ value: "oss-ap-southeast-5", label: "印度尼西亚(雅加达)①" },
{ value: "oss-ap-southeast-6", label: "菲律宾(马尼拉)" },
{ value: "oss-ap-southeast-7", label: "泰国(曼谷)" },
{ value: "oss-eu-central-1", label: "德国(法兰克福)①" },
{ value: "oss-eu-west-1", label: "英国(伦敦)" },
{ value: "oss-me-east-1", label: "阿联酋(迪拜)①" },
{ value: "oss-rg-china-mainland", label: "无地域属性(中国内地)" },
],
},
required: true,
})
region!: string;
@AccessInput({
title: "Bucket",
helper: "存储桶名称",
required: true,
})
bucket!: string;
}
new AliossAccess();
@@ -0,0 +1,129 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
import { ILogger } from "@certd/basic";
export type AliyunClientV2Req = {
action: string;
version: string;
protocol?: "HTTPS";
// 接口 HTTP 方法
method?: "GET" | "POST";
authType?: "AK";
style?: "RPC" | "ROA";
// 接口 PATH
pathname?: string;
data?: any;
};
export class AliyunClientV2 {
access: AliyunAccess;
logger: ILogger;
endpoint: string;
client: any;
constructor(opts: { access: AliyunAccess; logger: ILogger; endpoint: string }) {
this.access = opts.access;
this.logger = opts.logger;
this.endpoint = opts.endpoint;
}
async getClient() {
if (this.client) {
return this.client;
}
const $OpenApi = await import("@alicloud/openapi-client");
// const Credential = await import("@alicloud/credentials");
// //@ts-ignore
// const credential = new Credential.default.default({
//
// type: "access_key",
// });
const config = new $OpenApi.Config({
accessKeyId: this.access.accessKeyId,
accessKeySecret: this.access.accessKeySecret,
});
// Endpoint 请参考 https://api.aliyun.com/product/FC
// config.endpoint = `esa.${this.regionId}.aliyuncs.com`;
config.endpoint = this.endpoint;
//@ts-ignore
this.client = new $OpenApi.default.default(config);
return this.client;
}
async doRequest(req: AliyunClientV2Req) {
const client = await this.getClient();
const $OpenApi = await import("@alicloud/openapi-client");
const $Util = await import("@alicloud/tea-util");
const OpenApiUtil = await import("@alicloud/openapi-util");
const params = new $OpenApi.Params({
// 接口名称
action: req.action,
// 接口版本
version: req.version,
// 接口协议
protocol: "HTTPS",
// 接口 HTTP 方法
method: req.method ?? "POST",
authType: req.authType ?? "AK",
style: req.style ?? "RPC",
// 接口 PATH
pathname: req.pathname ?? `/`,
// 接口请求体内容格式
reqBodyType: "json",
// 接口响应体内容格式
bodyType: "json",
});
if (req.data?.query) {
//@ts-ignore
req.data.query = OpenApiUtil.default.default.query(req.data.query);
}
const runtime = new $Util.RuntimeOptions({});
const request = new $OpenApi.OpenApiRequest(req.data);
// 复制代码运行请自行打印 API 的返回值
// 返回值实际为 Map 类型,可从 Map 中获得三类数据:响应体 body、响应头 headers、HTTP 返回的状态码 statusCode。
const res = await client.callApi(params, request, runtime);
/**
* res?.body?.
*/
return res?.body;
}
}
@IsAccess({
name: "aliyun",
title: "阿里云授权",
desc: "",
icon: "ant-design:aliyun-outlined",
order: 0,
})
export class AliyunAccess extends BaseAccess {
@AccessInput({
title: "accessKeyId",
component: {
placeholder: "accessKeyId",
},
helper: "登录阿里云控制台->AccessKey管理页面获取。",
required: true,
})
accessKeyId = "";
@AccessInput({
title: "accessKeySecret",
component: {
placeholder: "accessKeySecret",
},
required: true,
encrypt: true,
helper: "注意:证书申请需要dns解析权限;其他阿里云插件,需要对应的权限,比如证书上传需要证书管理权限;嫌麻烦就用主账号的全量权限的accessKey",
})
accessKeySecret = "";
getClient(endpoint: string) {
return new AliyunClientV2({
access: this,
logger: this.ctx.logger,
endpoint: endpoint,
});
}
}
new AliyunAccess();
@@ -0,0 +1,3 @@
export * from "./aliyun-access.js";
export * from "./alioss-access.js";
export * from "./aliesa-access.js";
@@ -0,0 +1,3 @@
export * from "./access/index.js";
export * from "./lib/index.js";
export * from "./utils/index.js";
@@ -0,0 +1,78 @@
import { getGlobalAgents, ILogger } from "@certd/basic";
export class AliyunClient {
client: any;
logger: ILogger;
agent: any;
useROAClient: boolean;
constructor(opts: { logger: ILogger; useROAClient?: boolean }) {
this.logger = opts.logger;
this.useROAClient = opts.useROAClient || false;
const agents = getGlobalAgents();
this.agent = agents.httpsAgent;
}
async getSdk() {
if (this.useROAClient) {
return await this.getROAClient();
}
const Core = await import("@alicloud/pop-core");
return Core.default;
}
async getROAClient() {
const Core = await import("@alicloud/pop-core");
console.log("aliyun sdk", Core);
// @ts-ignore
return Core.ROAClient;
}
async init(opts: any) {
const Core = await this.getSdk();
this.client = new Core(opts);
return this.client;
}
checkRet(ret: any) {
if (ret.Code != null && ret.Code !== "OK" && ret.Message !== "OK") {
throw new Error("执行失败:" + ret.Message);
}
}
async request(
name: string,
params: any,
requestOption: any = {
method: "POST",
formatParams: false,
}
) {
if (!this.useROAClient) {
requestOption.agent = this.agent;
}
const getNumberFromEnv = (key: string, defValue: number) => {
const value = process.env[key];
if (value) {
try {
return parseInt(value);
} catch (e: any) {
this.logger.error(`环境变量${key}设置错误,应该是一个数字,当前值为${value},将使用默认值:${defValue}`);
return defValue;
}
} else {
return defValue;
}
};
// 连接超时设置,仅对当前请求有效。
requestOption.connectTimeout = getNumberFromEnv("ALIYUN_CLIENT_CONNECT_TIMEOUT", 8000);
// 读超时设置,仅对当前请求有效。
requestOption.readTimeout = getNumberFromEnv("ALIYUN_CLIENT_READ_TIMEOUT", 8000);
const res = await this.client.request(name, params, requestOption);
this.checkRet(res);
return res;
}
}
@@ -0,0 +1,3 @@
export * from "./base-client.js";
export * from "./ssl-client.js";
export * from "./oss-client.js";
@@ -0,0 +1,87 @@
import { AliyunAccess } from "../access/index.js";
export class AliossClient {
access: AliyunAccess;
region: string;
bucket: string;
client: any;
constructor(opts: { access: AliyunAccess; bucket: string; region: string }) {
this.access = opts.access;
this.bucket = opts.bucket;
this.region = opts.region;
}
async init() {
if (this.client) {
return;
}
// @ts-ignore
const OSS = await import("ali-oss");
const ossClient = new OSS.default({
accessKeyId: this.access.accessKeyId,
accessKeySecret: this.access.accessKeySecret,
// yourRegion填写Bucket所在地域。以华东1(杭州)为例,Region填写为oss-cn-hangzhou。
region: this.region,
//@ts-ignore
authorizationV4: true,
// yourBucketName填写Bucket名称。
bucket: this.bucket,
});
// oss
this.client = ossClient;
}
async doRequest(bucket: string, xml: string, params: any) {
await this.init();
params = this.client._bucketRequestParams("POST", bucket, {
...params,
});
params.content = xml;
params.mime = "xml";
params.successStatuses = [200];
const res = await this.client.request(params);
this.checkRet(res);
return res;
}
checkRet(ret: any) {
if (ret.Code != null) {
throw new Error("执行失败:" + ret.Message);
}
}
async uploadFile(filePath: string, content: Buffer | string, timeout = 1000 * 60 * 60) {
await this.init();
return await this.client.put(filePath, content, {
timeout,
});
}
async removeFile(filePath: string) {
await this.init();
return await this.client.delete(filePath);
}
async downloadFile(key: string, savePath: string, timeout = 1000 * 60 * 60) {
await this.init();
return await this.client.get(key, savePath, {
timeout,
});
}
async listDir(dirKey: string) {
await this.init();
const res = await this.client.listV2({
prefix: dirKey,
// max-keys: 100,
// continuation-token: "token",
// delimiter: "/",
// marker: "marker",
// encoding-type: "url",
});
return res.objects;
}
}
@@ -0,0 +1,185 @@
import { ILogger } from "@certd/basic";
import { AliyunAccess } from "../access/index.js";
import { AliyunClient } from "./index.js";
export type AliyunCertInfo = {
crt: string; //fullchain证书
key: string; //私钥
};
export type AliyunSslClientOpts = {
access: AliyunAccess;
logger: ILogger;
endpoint?: string;
region?: string;
};
export type AliyunSslGetResourceListReq = {
cloudProduct: string;
};
export type AliyunSslCreateDeploymentJobReq = {
name: string;
jobType: string;
contactIds: string[];
resourceIds: string[];
certIds: string[];
};
export type AliyunSslUploadCertReq = {
name: string;
cert: AliyunCertInfo;
};
export type CasCertInfo = { certId: number; certName: string; certIdentifier: string; notAfter: number; casRegion: string };
export class AliyunSslClient {
opts: AliyunSslClientOpts;
logger: ILogger;
constructor(opts: AliyunSslClientOpts) {
this.opts = opts;
this.logger = opts.logger;
}
checkRet(ret: any) {
if (ret.Code != null) {
throw new Error("执行失败:" + ret.Message);
}
}
async getClient() {
const access = this.opts.access;
const client = new AliyunClient({ logger: this.opts.logger });
let endpoint = this.opts.endpoint || "cas.aliyuncs.com";
if (this.opts.endpoint == null && this.opts.region) {
if (this.opts.region === "cn-hangzhou") {
endpoint = "cas.aliyuncs.com";
} else {
endpoint = `cas.${this.opts.region}.aliyuncs.com`;
}
}
await client.init({
accessKeyId: access.accessKeyId,
accessKeySecret: access.accessKeySecret,
endpoint: `https://${endpoint}`,
apiVersion: "2020-04-07",
});
return client;
}
async getCertInfo(certId: number): Promise<CasCertInfo> {
const client = await this.getClient();
const params = {
CertId: certId,
};
const res = await client.request("GetUserCertificateDetail", params);
this.checkRet(res);
return {
certId: certId,
certName: res.Name,
certIdentifier: res.CertIdentifier,
notAfter: res.NotAfter,
casRegion: this.getCasRegionFromEndpoint(this.opts.endpoint),
};
}
async uploadCert(req: AliyunSslUploadCertReq) {
const client = await this.getClient();
const params = {
Name: req.name,
Cert: req.cert.crt,
Key: req.cert.key,
};
const requestOption = {
method: "POST",
};
this.opts.logger.info(`开始上传证书:${req.name}`);
const ret: any = await client.request("UploadUserCertificate", params, requestOption);
this.checkRet(ret);
this.opts.logger.info("证书上传成功:aliyunCertId=", ret.CertId);
//output
return ret.CertId;
}
async getResourceList(req: AliyunSslGetResourceListReq) {
const client = await this.getClient();
const params = {
CloudName: "aliyun",
CloudProduct: req.cloudProduct,
};
const requestOption = {
method: "POST",
formatParams: false,
};
const res = await client.request("ListCloudResources", params, requestOption);
this.checkRet(res);
return res;
}
async createDeploymentJob(req: AliyunSslCreateDeploymentJobReq) {
const client = await this.getClient();
const params = {
Name: req.name,
JobType: req.jobType,
ContactIds: req.contactIds.join(","),
ResourceIds: req.resourceIds.join(","),
CertIds: req.certIds.join(","),
};
const requestOption = {
method: "POST",
formatParams: false,
};
const res = await client.request("CreateDeploymentJob", params, requestOption);
this.checkRet(res);
return res;
}
async getContactList() {
const params = {};
const requestOption = {
method: "POST",
formatParams: false,
};
const client = await this.getClient();
const res = await client.request("ListContact", params, requestOption);
this.checkRet(res);
return res;
}
async doRequest(action: string, params: any, requestOption: any) {
const client = await this.getClient();
const res = await client.request(action, params, requestOption);
this.checkRet(res);
return res;
}
async deleteCert(certId: any) {
await this.doRequest("DeleteUserCertificate", { CertId: certId }, { method: "POST" });
}
getCasRegionFromEndpoint(endpoint: string) {
if (!endpoint) {
return "cn-hangzhou";
}
/**
* {value: 'cas.aliyuncs.com', label: '中国大陆'},
* {value: 'cas.ap-southeast-1.aliyuncs.com', label: '新加坡'},
* {value: 'cas.eu-central-1.aliyuncs.com', label: '德国(法兰克福)'},
*/
const region = endpoint.replace(".aliyuncs.com", "").replace("cas.", "");
if (region === "cas") {
return "cn-hangzhou";
}
return region;
}
}
@@ -0,0 +1,41 @@
import dayjs from "dayjs";
import { CertInfo, CertReader } from "@certd/plugin-cert";
import { AliyunSslClient } from "../lib/index.js";
export const ZoneOptions = [{ value: 'cn-hangzhou' }];
export function appendTimeSuffix(name: string) {
if (name == null) {
name = 'certd';
}
return name + '-' + dayjs().format('YYYYMMDD-HHmmss');
}
export function checkRet(ret: any) {
if (ret.Code != null) {
throw new Error('执行失败:' + ret.Message);
}
}
export async function getAliyunCertId(opts:{
cert: string | CertInfo,
sslClient: AliyunSslClient,
}) {
const { cert, sslClient } = opts;
let certId: any = cert;
let certName: any = CertReader.appendTimeSuffix("certd");
if (typeof cert === "object") {
const certReader = new CertReader(cert)
certName = certReader.buildCertName()
certId = await sslClient.uploadCert({
name: certName,
cert: cert,
});
sslClient.logger.info("上传证书成功", certId, certName);
}
return {
certId,
certName,
};
}
@@ -0,0 +1,77 @@
import { IsAccess, AccessInput, BaseAccess } from "@certd/pipeline";
/**
* 这个注解将注册一个授权配置
* 在certd的后台管理系统中,用户可以选择添加此类型的授权
*/
@IsAccess({
name: "ftp",
title: "FTP授权",
desc: "",
icon: "mdi:folder-upload-outline",
})
export class FtpAccess extends BaseAccess {
@AccessInput({
title: "host",
component: {
placeholder: "ip / 域名",
name: "a-input",
vModel: "value",
},
helper: "FTP地址",
required: true,
})
host!: string;
@AccessInput({
title: "端口",
value: 21,
component: {
placeholder: "21",
name: "a-input-number",
vModel: "value",
},
helper: "FTP端口",
required: true,
})
port!: string;
@AccessInput({
title: "user",
component: {
placeholder: "用户名",
},
helper: "FTP用户名",
required: true,
})
user!: string;
@AccessInput({
title: "password",
component: {
placeholder: "密码",
component: {
name: "a-input-password",
vModel: "value",
},
},
encrypt: true,
helper: "FTP密码",
required: true,
})
password!: string;
@AccessInput({
title: "secure",
value: false,
component: {
name: "a-switch",
vModel: "checked",
},
helper: "是否使用SSL",
required: true,
})
secure?: boolean = false;
}
new FtpAccess();
@@ -0,0 +1,63 @@
import { FtpAccess } from "./access.js";
import { ILogger } from "@certd/basic";
import path from "node:path";
export class FtpClient {
access: FtpAccess = null;
logger: ILogger = null;
client: any;
constructor(opts: { access: FtpAccess; logger: ILogger }) {
this.access = opts.access;
this.logger = opts.logger;
}
async connect(callback: (client: FtpClient) => Promise<any>) {
const ftp = await import("basic-ftp");
const Client = ftp.Client;
const client = new Client();
client.ftp.verbose = true;
this.logger.info("开始连接FTP");
await client.access(this.access as any);
this.logger.info("FTP连接成功");
this.client = client;
try {
return await callback(this);
} finally {
if (client) {
client.close();
}
}
}
async upload(filePath: string, remotePath: string): Promise<void> {
if (!remotePath) {
return;
}
const dirname = path.dirname(remotePath);
this.logger.info(`确保目录存在:${dirname}`);
await this.client.ensureDir(dirname);
this.logger.info(`开始上传文件${filePath} -> ${remotePath}`);
await this.client.uploadFrom(filePath, remotePath);
}
async remove(filePath: string): Promise<void> {
this.logger.info(`开始删除文件${filePath}`);
await this.client.remove(filePath, true);
}
async listDir(dir: string): Promise<any[]> {
if (!dir) {
return [];
}
if (!dir.endsWith("/")) {
dir = dir + "/";
}
this.logger.info(`开始列出目录${dir}`);
return await this.client.list(dir);
}
async download(filePath: string, savePath: string): Promise<void> {
this.logger.info(`开始下载文件${filePath} -> ${savePath}`);
await this.client.downloadTo(savePath, filePath);
}
}
@@ -0,0 +1,2 @@
export * from "./access.js";
export * from "./client.js";
@@ -0,0 +1,7 @@
export * from "./ssh/index.js";
export * from "./ftp/index.js";
export * from "./tencent/index.js";
export * from "./qiniu/index.js";
export * from "./oss/index.js";
export * from "./s3/index.js";
export * from "./aliyun/index.js";
@@ -0,0 +1,90 @@
import { IAccessService } from "@certd/pipeline";
import { ILogger, utils } from "@certd/basic";
import dayjs from "dayjs";
export type OssClientRemoveByOpts = {
dir?: string;
//删除多少天前的文件
beforeDays?: number;
};
export type OssFileItem = {
//文件全路径
path: string;
size: number;
//毫秒时间戳
lastModified: number;
};
export type IOssClient = {
upload: (fileName: string, fileContent: Buffer) => Promise<void>;
remove: (fileName: string, opts?: { joinRootDir?: boolean }) => Promise<void>;
download: (fileName: string, savePath: string) => Promise<void>;
removeBy: (removeByOpts: OssClientRemoveByOpts) => Promise<void>;
listDir: (dir: string) => Promise<OssFileItem[]>;
};
export type OssClientContext = {
accessService: IAccessService;
logger: ILogger;
utils: typeof utils;
};
export abstract class BaseOssClient<A> implements IOssClient {
rootDir: string = "";
access: A = null;
logger: ILogger;
utils: typeof utils;
ctx: OssClientContext;
protected constructor(opts: { rootDir?: string; access: A }) {
this.rootDir = opts.rootDir || "";
this.access = opts.access;
}
join(...strs: string[]) {
let res = "";
for (const item of strs) {
if (item) {
if (!res) {
res = item;
} else {
res += "/" + item;
}
}
}
res = res.replace(/[\\/]+/g, "/");
return res;
}
async setCtx(ctx: any) {
// set context
this.ctx = ctx;
this.logger = ctx.logger;
this.utils = ctx.utils;
await this.init();
}
async init() {
// do nothing
}
abstract remove(fileName: string, opts?: { joinRootDir?: boolean }): Promise<void>;
abstract upload(fileName: string, fileContent: Buffer): Promise<void>;
abstract download(fileName: string, savePath: string): Promise<void>;
abstract listDir(dir: string): Promise<OssFileItem[]>;
async removeBy(removeByOpts: OssClientRemoveByOpts): Promise<void> {
const list = await this.listDir(removeByOpts.dir);
// removeByOpts.beforeDays = 0;
const beforeDate = dayjs().subtract(removeByOpts.beforeDays, "day");
for (const item of list) {
if (item.lastModified && item.lastModified < beforeDate.valueOf()) {
await this.remove(item.path, { joinRootDir: false });
}
}
}
}
@@ -0,0 +1,41 @@
import { OssClientContext } from "./api.js";
export class OssClientFactory {
async getClassByType(type: string) {
if (type === "alioss") {
const module = await import("./impls/alioss.js");
return module.default;
} else if (type === "ssh") {
const module = await import("./impls/ssh.js");
return module.default;
} else if (type === "sftp") {
const module = await import("./impls/sftp.js");
return module.default;
} else if (type === "ftp") {
const module = await import("./impls/ftp.js");
return module.default;
} else if (type === "tencentcos") {
const module = await import("./impls/tencentcos.js");
return module.default;
} else if (type === "qiniuoss") {
const module = await import("./impls/qiniuoss.js");
return module.default;
} else if (type === "s3") {
const module = await import("./impls/s3.js");
return module.default;
} else {
throw new Error(`暂不支持此文件上传方式: ${type}`);
}
}
async createOssClientByType(type: string, opts: { rootDir?: string; access: any; ctx: OssClientContext }) {
const cls = await this.getClassByType(type);
if (cls) {
// @ts-ignore
const instance = new cls(opts);
await instance.setCtx(opts.ctx);
return instance;
}
}
}
export const ossClientFactory = new OssClientFactory();
@@ -0,0 +1,57 @@
import { BaseOssClient, OssFileItem } from "../api.js";
import { AliossAccess, AliossClient, AliyunAccess } from "../../aliyun/index.js";
import dayjs from "dayjs";
export default class AliOssClientImpl extends BaseOssClient<AliossAccess> {
client: AliossClient;
join(...strs: string[]) {
const str = super.join(...strs);
if (str.startsWith("/")) {
return str.substring(1);
}
return str;
}
async init() {
const aliyunAccess = await this.ctx.accessService.getById<AliyunAccess>(this.access.accessId);
const client = new AliossClient({
access: aliyunAccess,
bucket: this.access.bucket,
region: this.access.region,
});
await client.init();
this.client = client;
}
async download(filePath: string, savePath: string): Promise<void> {
const key = this.join(this.rootDir, filePath);
await this.client.downloadFile(key, savePath);
}
async listDir(dir: string): Promise<OssFileItem[]> {
const dirKey = this.join(this.rootDir, dir) + "/";
const list = await this.client.listDir(dirKey);
this.logger.info(`列出目录: ${dirKey},文件数:${list.length}`);
return list.map(item => {
return {
path: item.name,
lastModified: dayjs(item.lastModified).valueOf(),
size: item.size,
};
});
}
async upload(filePath: string, fileContent: Buffer | string) {
const key = this.join(this.rootDir, filePath);
this.logger.info(`开始上传文件: ${key}`);
await this.client.uploadFile(key, fileContent);
this.logger.info(`文件上传成功: ${filePath}`);
}
async remove(filePath: string, opts?: { joinRootDir?: boolean }) {
if (opts?.joinRootDir !== false) {
filePath = this.join(this.rootDir, filePath);
}
const key = filePath;
// remove file from alioss
await this.client.removeFile(key);
this.logger.info(`文件删除成功: ${key}`);
}
}
@@ -0,0 +1,78 @@
import { BaseOssClient } from "../api.js";
import path from "path";
import os from "os";
import fs from "fs";
import { FtpAccess, FtpClient } from "../../ftp/index.js";
export default class FtpOssClientImpl extends BaseOssClient<FtpAccess> {
join(...strs: string[]) {
const str = super.join(...strs);
if (!str.startsWith("/")) {
return "/" + str;
}
return str;
}
async download(fileName: string, savePath: string) {
const client = this.getFtpClient();
await client.connect(async client => {
const path = this.join(this.rootDir, fileName);
await client.download(path, savePath);
});
}
async listDir(dir: string) {
const client = this.getFtpClient();
return await client.connect(async (client: FtpClient) => {
const path = this.join(this.rootDir, dir);
const res = await client.listDir(path);
return res.map(item => {
return {
path: this.join(path, item.name),
size: item.size,
lastModified: item.modifiedAt.getTime(),
};
});
});
}
async upload(filePath: string, fileContent: Buffer | string) {
const client = this.getFtpClient();
await client.connect(async client => {
let tmpFilePath = fileContent as string;
if (typeof fileContent !== "string") {
tmpFilePath = path.join(os.tmpdir(), "cert", "oss", filePath);
const dir = path.dirname(tmpFilePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(tmpFilePath, fileContent);
}
try {
// Write file to temp path
const path = this.join(this.rootDir, filePath);
await client.upload(tmpFilePath, path);
} finally {
// Remove temp file
fs.unlinkSync(tmpFilePath);
}
});
}
private getFtpClient() {
return new FtpClient({
access: this.access,
logger: this.logger,
});
}
async remove(filePath: string, opts?: { joinRootDir?: boolean }) {
if (opts?.joinRootDir !== false) {
filePath = this.join(this.rootDir, filePath);
}
const client = this.getFtpClient();
await client.connect(async client => {
await client.client.remove(filePath);
this.logger.info(`删除文件成功: ${filePath}`);
});
}
}
@@ -0,0 +1,50 @@
import { QiniuAccess, QiniuClient, QiniuOssAccess } from "../../qiniu/index.js";
import { BaseOssClient, OssFileItem } from "../api.js";
export default class QiniuOssClientImpl extends BaseOssClient<QiniuOssAccess> {
client: QiniuClient;
join(...strs: string[]) {
const str = super.join(...strs);
if (str.startsWith("/")) {
return str.substring(1);
}
return str;
}
async init() {
const qiniuAccess = await this.ctx.accessService.getById<QiniuAccess>(this.access.accessId);
this.client = new QiniuClient({
access: qiniuAccess,
logger: this.logger,
http: this.ctx.utils.http,
});
}
async download(fileName: string, savePath: string): Promise<void> {
const path = this.join(this.rootDir, fileName);
await this.client.downloadFile(this.access.bucket, path, savePath);
}
async listDir(dir: string): Promise<OssFileItem[]> {
const path = this.join(this.rootDir, dir);
const res = await this.client.listDir(this.access.bucket, path);
return res.items.map(item => {
return {
path: item.key,
size: item.fsize,
//ns ,纳秒,去掉低4位 为毫秒
lastModified: Math.floor(item.putTime / 10000),
};
});
}
async upload(filePath: string, fileContent: Buffer | string) {
const path = this.join(this.rootDir, filePath);
await this.client.uploadFile(this.access.bucket, path, fileContent);
}
async remove(filePath: string, opts?: { joinRootDir?: boolean }) {
if (opts?.joinRootDir !== false) {
filePath = this.join(this.rootDir, filePath);
}
await this.client.removeFile(this.access.bucket, filePath);
}
}
@@ -0,0 +1,101 @@
import { BaseOssClient, OssFileItem } from "../api.js";
import path from "node:path";
import { S3Access } from "../../s3/access.js";
import fs from "fs";
import dayjs from "dayjs";
export default class S3OssClientImpl extends BaseOssClient<S3Access> {
client: any;
join(...strs: string[]) {
const str = super.join(...strs);
if (str.startsWith("/")) {
return str.substring(1);
}
return str;
}
async init() {
// import { S3Client } from "@aws-sdk/client-s3";
//@ts-ignore
const { S3Client } = await import("@aws-sdk/client-s3");
this.client = new S3Client({
forcePathStyle: true,
//@ts-ignore
s3ForcePathStyle: true,
credentials: {
accessKeyId: this.access.accessKeyId, // 默认 MinIO 访问密钥
secretAccessKey: this.access.secretAccessKey, // 默认 MinIO 秘密密钥
},
region: "us-east-1",
endpoint: this.access.endpoint,
});
}
async download(filePath: string, savePath: string): Promise<void> {
// @ts-ignore
const { GetObjectCommand } = await import("@aws-sdk/client-s3");
const key = path.join(this.rootDir, filePath);
const params = {
Bucket: this.access.bucket, // The name of the bucket. For example, 'sample_bucket_101'.
Key: key, // The name of the object. For example, 'sample_upload.txt'.
};
const res = await this.client.send(new GetObjectCommand({ ...params }));
const fileContent = fs.createWriteStream(savePath);
res.Body.pipe(fileContent);
this.logger.info(`文件下载成功: ${savePath}`);
}
async listDir(dir: string): Promise<OssFileItem[]> {
// @ts-ignore
const { ListObjectsCommand } = await import("@aws-sdk/client-s3");
const dirKey = this.join(this.rootDir, dir);
const params = {
Bucket: this.access.bucket, // The name of the bucket. For example, 'sample_bucket_101'.
Prefix: dirKey, // The name of the object. For example, 'sample_upload.txt'.
};
const res = await this.client.send(new ListObjectsCommand({ ...params }));
if (!res.Contents) {
return [];
}
return res.Contents.map(item => {
return {
path: item.Key,
size: item.Size,
lastModified: dayjs(item.LastModified).valueOf(),
};
});
}
async upload(filePath: string, fileContent: Buffer | string) {
// @ts-ignore
const { PutObjectCommand } = await import("@aws-sdk/client-s3");
const key = path.join(this.rootDir, filePath);
this.logger.info(`开始上传文件: ${key}`);
const params = {
Bucket: this.access.bucket, // The name of the bucket. For example, 'sample_bucket_101'.
Key: key, // The name of the object. For example, 'sample_upload.txt'.
};
if (typeof fileContent === "string") {
fileContent = fs.createReadStream(fileContent) as any;
}
await this.client.send(new PutObjectCommand({ Body: fileContent, ...params }));
this.logger.info(`文件上传成功: ${filePath}`);
}
async remove(filePath: string, opts?: { joinRootDir?: boolean }) {
if (opts?.joinRootDir !== false) {
filePath = this.join(this.rootDir, filePath);
}
const key = filePath;
// @ts-ignore
const { DeleteObjectCommand } = await import("@aws-sdk/client-s3");
await this.client.send(
new DeleteObjectCommand({
Bucket: this.access.bucket,
Key: key,
})
);
this.logger.info(`文件删除成功: ${key}`);
}
}
@@ -0,0 +1,82 @@
import { BaseOssClient, OssFileItem } from "../api.js";
import path from "path";
import os from "os";
import fs from "fs";
import { SftpAccess, SshAccess, SshClient } from "../../ssh/index.js";
export default class SftpOssClientImpl extends BaseOssClient<SftpAccess> {
async download(fileName: string, savePath: string): Promise<void> {
const path = this.join(this.rootDir, fileName);
const client = new SshClient(this.logger);
const access = await this.ctx.accessService.getById<SshAccess>(this.access.sshAccess);
await client.download({
connectConf: access,
filePath: path,
savePath,
});
}
async listDir(dir: string): Promise<OssFileItem[]> {
const path = this.join(this.rootDir, dir);
const client = new SshClient(this.logger);
const access = await this.ctx.accessService.getById<SshAccess>(this.access.sshAccess);
const res = await client.listDir({
connectConf: access,
dir: path,
});
return res.map(item => {
return {
path: this.join(path, item.filename),
size: item.size,
lastModified: item.attrs.atime * 1000,
};
});
}
async upload(filePath: string, fileContent: Buffer | string) {
let tmpFilePath = fileContent as string;
if (typeof fileContent !== "string") {
tmpFilePath = path.join(os.tmpdir(), "cert", "oss", filePath);
const dir = path.dirname(tmpFilePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(tmpFilePath, fileContent);
}
const access = await this.ctx.accessService.getById<SshAccess>(this.access.sshAccess);
const key = this.join(this.rootDir, filePath);
try {
const client = new SshClient(this.logger);
await client.uploadFiles({
connectConf: access,
mkdirs: true,
transports: [
{
localPath: tmpFilePath,
remotePath: key,
},
],
uploadType: "sftp",
opts: {
mode: this.access?.fileMode ?? undefined,
},
});
} finally {
// Remove temp file
fs.unlinkSync(tmpFilePath);
}
}
async remove(filePath: string, opts?: { joinRootDir?: boolean }) {
const access = await this.ctx.accessService.getById<SshAccess>(this.access.sshAccess);
const client = new SshClient(this.logger);
if (opts?.joinRootDir !== false) {
filePath = this.join(this.rootDir, filePath);
}
await client.removeFiles({
connectConf: access,
files: [filePath],
});
}
}
@@ -0,0 +1,61 @@
import { BaseOssClient, OssClientRemoveByOpts, OssFileItem } from "../api.js";
import path from "path";
import os from "os";
import fs from "fs";
import { SshAccess, SshClient } from "../../ssh/index.js";
//废弃
export default class SshOssClientImpl extends BaseOssClient<SshAccess> {
download(fileName: string, savePath: string): Promise<void> {
throw new Error("Method not implemented.");
}
removeBy(removeByOpts: OssClientRemoveByOpts): Promise<void> {
throw new Error("Method not implemented.");
}
listDir(dir: string): Promise<OssFileItem[]> {
throw new Error("Method not implemented.");
}
async upload(filePath: string, fileContent: Buffer) {
if (!filePath) {
filePath = "";
}
filePath = filePath.trim();
const tmpFilePath = path.join(os.tmpdir(), "cert", "http", filePath);
// Write file to temp path
const dir = path.dirname(tmpFilePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(tmpFilePath, fileContent);
const key = this.rootDir + filePath;
try {
const client = new SshClient(this.logger);
await client.uploadFiles({
connectConf: this.access,
mkdirs: true,
transports: [
{
localPath: tmpFilePath,
remotePath: key,
},
],
});
} finally {
// Remove temp file
fs.unlinkSync(tmpFilePath);
}
}
async remove(filePath: string, opts?: { joinRootDir?: boolean }) {
if (opts?.joinRootDir !== false) {
filePath = this.join(this.rootDir, filePath);
}
const client = new SshClient(this.logger);
await client.removeFiles({
connectConf: this.access,
files: [filePath],
});
}
}
@@ -0,0 +1,54 @@
import dayjs from "dayjs";
import { TencentAccess, TencentCosAccess, TencentCosClient } from "../../tencent/index.js";
import { BaseOssClient, OssFileItem } from "../api.js";
export default class TencentOssClientImpl extends BaseOssClient<TencentCosAccess> {
client: TencentCosClient;
join(...strs: string[]) {
const str = super.join(...strs);
if (str.startsWith("/")) {
return str.substring(1);
}
return str;
}
async init() {
const access = await this.ctx.accessService.getById<TencentAccess>(this.access.accessId);
this.client = new TencentCosClient({
access: access,
logger: this.logger,
region: this.access.region,
bucket: this.access.bucket,
});
}
async download(filePath: string, savePath: string): Promise<void> {
const key = this.join(this.rootDir, filePath);
await this.client.downloadFile(key, savePath);
}
async listDir(dir: string): Promise<OssFileItem[]> {
const dirKey = this.join(this.rootDir, dir) + "/";
// @ts-ignore
const res: any[] = await this.client.listDir(dirKey);
return res.map(item => {
return {
path: item.Key,
size: item.Size,
lastModified: dayjs(item.LastModified).valueOf(),
};
});
}
async upload(filePath: string, fileContent: Buffer | string) {
const key = this.join(this.rootDir, filePath);
await this.client.uploadFile(key, fileContent);
this.logger.info(`文件上传成功: ${filePath}`);
}
async remove(filePath: string, opts?: { joinRootDir?: boolean }) {
if (opts?.joinRootDir !== false) {
filePath = this.join(this.rootDir, filePath);
}
await this.client.removeFile(filePath);
this.logger.info(`文件删除成功: ${filePath}`);
}
}
@@ -0,0 +1,2 @@
export * from "./factory.js";
export * from "./api.js";
@@ -0,0 +1,31 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
@IsAccess({
name: "qiniuoss",
title: "七牛OSS授权",
desc: "",
icon: "svg:icon-qiniuyun",
input: {},
})
export class QiniuOssAccess extends BaseAccess {
@AccessInput({
title: "七牛云授权",
component: {
name: "access-selector",
vModel: "modelValue",
type: "qiniu",
},
helper: "请选择七牛云授权",
required: true,
})
accessId = "";
@AccessInput({
title: "Bucket",
helper: "存储桶名称",
required: true,
})
bucket = "";
}
new QiniuOssAccess();
@@ -0,0 +1,26 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
@IsAccess({
name: "qiniu",
title: "七牛云授权",
desc: "",
icon: "svg:icon-qiniuyun",
input: {},
order: 2,
})
export class QiniuAccess extends BaseAccess {
@AccessInput({
title: "AccessKey",
rules: [{ required: true, message: "此项必填" }],
helper: "AK,前往[密钥管理](https://portal.qiniu.com/developer/user/key)获取",
})
accessKey!: string;
@AccessInput({
title: "SecretKey",
encrypt: true,
helper: "SK",
})
secretKey!: string;
}
new QiniuAccess();
@@ -0,0 +1,3 @@
export * from "./access.js";
export * from "./access-oss.js";
export * from "./lib/sdk.js";
@@ -0,0 +1,180 @@
import { HttpClient, ILogger, safePromise, utils } from "@certd/basic";
import { QiniuAccess } from "../access.js";
import fs from "fs";
export type QiniuCertInfo = {
key: string;
crt: string;
};
export class QiniuClient {
http: HttpClient;
access: QiniuAccess;
logger: ILogger;
constructor(opts: { http: HttpClient; access: QiniuAccess; logger: ILogger }) {
this.http = opts.http;
this.access = opts.access;
this.logger = opts.logger;
}
async uploadCert(cert: QiniuCertInfo, certName?: string) {
const url = "https://api.qiniu.com/sslcert";
const body = {
name: certName,
common_name: "certd",
pri: cert.key,
ca: cert.crt,
};
const res = await this.doRequest(url, "post", body);
return res.certID;
}
async bindCert(body: { certid: string; domain: string }) {
const url = "https://api.qiniu.com/cert/bind";
return await this.doRequest(url, "post", body);
}
async getCertBindings() {
const url = "https://api.qiniu.com/cert/bindings";
const res = await this.doRequest(url, "get");
return res;
}
async doRequest(url: string, method: string, body?: any) {
const { generateAccessToken } = await import("qiniu/qiniu/util.js");
const token = generateAccessToken(this.access, url);
const res = await this.http.request({
url,
method: method,
headers: {
Authorization: token,
},
data: body,
logRes: false,
});
if (res && res.error) {
if (res.error.includes("domaintype")) {
throw new Error("请求失败:" + res.error + ",该域名属于CDN域名,请使用部署到七牛云CDN插件");
}
throw new Error("请求失败:" + res.error);
}
console.log("res", res);
return res;
}
async doRequestV2(opts: { url: string; method: string; body?: any; contentType: string }) {
const { HttpClient } = await import("qiniu/qiniu/httpc/client.js");
const { QiniuAuthMiddleware } = await import("qiniu/qiniu/httpc/middleware/qiniuAuth.js");
// X-Qiniu-Date: 20060102T150405Z
const auth = new QiniuAuthMiddleware({
mac: {
...this.access,
options: {},
},
});
const http = new HttpClient({ timeout: 10000, middlewares: [auth] });
console.log("http", http);
return safePromise((resolve, reject) => {
try {
http.get({
url: opts.url,
headers: {
"Content-Type": opts.contentType,
},
callback: (nullable, res) => {
console.log("nullable", nullable, "res", res);
if (res?.error) {
reject(res);
} else {
resolve(res);
}
},
});
} catch (e) {
reject(e);
}
});
}
async uploadFile(bucket: string, key: string, content: Buffer | string) {
const sdk = await import("qiniu");
const qiniu = sdk.default;
const mac = new qiniu.auth.digest.Mac(this.access.accessKey, this.access.secretKey);
const options = {
scope: bucket,
};
const putPolicy = new qiniu.rs.PutPolicy(options);
const uploadToken = putPolicy.uploadToken(mac);
const config = new qiniu.conf.Config();
const formUploader = new qiniu.form_up.FormUploader(config);
const putExtra = new qiniu.form_up.PutExtra();
let res: any = {};
if (typeof content === "string") {
const readableStream = fs.createReadStream(content);
res = await formUploader.putStream(uploadToken, key, readableStream, putExtra);
} else {
// 文件上传
res = await formUploader.put(uploadToken, key, content, putExtra);
}
const { data, resp } = res;
if (resp.statusCode === 200) {
this.logger.info("文件上传成功:" + key);
return data;
} else {
console.log(resp.statusCode);
throw new Error("上传失败:" + JSON.stringify(resp));
}
}
async removeFile(bucket: string, key: string) {
const bucketManager = await this.getBucketManager();
const { resp } = await bucketManager.delete(bucket, key);
if (resp.statusCode === 200) {
this.logger.info("文件删除成功:" + key);
return;
} else {
throw new Error("删除失败:" + JSON.stringify(resp));
}
}
async downloadFile(bucket: string, path: string, savePath: string) {
const bucketManager = await this.getBucketManager();
const privateBucketDomain = `http://${bucket}.qiniudn.com`;
const deadline = Math.floor(Date.now() / 1000) + 3600; // 1小时过期
const privateDownloadUrl = bucketManager.privateDownloadUrl(privateBucketDomain, path, deadline);
await utils.request.download({
http: this.http,
logger: this.logger,
config: {
url: privateDownloadUrl,
method: "get",
},
savePath,
});
}
private async getBucketManager() {
const sdk = await import("qiniu");
const qiniu = sdk.default;
const mac = new qiniu.auth.digest.Mac(this.access.accessKey, this.access.secretKey);
const config = new qiniu.conf.Config();
config.useHttpsDomain = true;
return new qiniu.rs.BucketManager(mac, config);
}
async listDir(bucket: string, path: string) {
const bucketManager = await this.getBucketManager();
const res = await bucketManager.listPrefix(bucket, {
prefix: path,
limit: 1000,
});
return res.data;
}
}
@@ -0,0 +1,115 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
import { ossClientFactory } from "../oss/index.js";
import S3OssClientImpl from "../oss/impls/s3.js";
/**
* 这个注解将注册一个授权配置
* 在certd的后台管理系统中,用户可以选择添加此类型的授权
*/
@IsAccess({
name: "s3",
title: "s3/minio授权",
desc: "S3/minio oss授权",
icon: "mdi:folder-upload-outline",
})
export class S3Access extends BaseAccess {
@AccessInput({
title: "endpoint",
component: {
placeholder: "http://xxxxxx:9000",
name: "a-input",
vModel: "value",
},
helper: "Minio的地址,如果是aws s3 则无需填写",
required: false,
})
endpoint!: string;
/**
* const minioClient = new S3Client({
* endpoint: "http://localhost:9000",
* forcePathStyle: true,
* credentials: {
* accessKeyId: "minioadmin", // 默认 MinIO 访问密钥
* secretAccessKey: "minioadmin", // 默认 MinIO 秘密密钥
* },
* region: "us-east-1",
* });
*/
@AccessInput({
title: "accessKeyId",
component: {
placeholder: "accessKeyId",
},
helper: "accessKeyId",
required: true,
})
accessKeyId!: string;
@AccessInput({
title: "secretAccessKey",
component: {
placeholder: "secretAccessKey",
component: {
name: "a-input",
vModel: "value",
},
},
helper: "secretAccessKey",
encrypt: true,
required: true,
})
secretAccessKey!: string;
@AccessInput({
title: "地区",
value: "us-east-1",
component: {
name: "a-input",
vModel: "value",
},
helper: "region",
required: true,
})
region!: string;
@AccessInput({
title: "存储桶",
component: {
name: "a-input",
vModel: "value",
},
helper: "bucket 名称",
required: true,
})
bucket!: string;
@AccessInput({
title: "测试",
component: {
name: "api-test",
action: "TestRequest",
},
helper: "点击测试接口是否正常",
})
testRequest = true;
async onTestRequest() {
const client: S3OssClientImpl = await ossClientFactory.createOssClientByType("s3", {
access: this,
rootDir: "",
ctx: {
accessService: this.ctx.accessService,
logger: this.ctx.logger,
utils: this.ctx.utils,
},
});
await client.listDir("/");
return "ok";
}
}
new S3Access();
@@ -0,0 +1 @@
export * from "./access.js";
@@ -0,0 +1,3 @@
export * from "./ssh.js";
export * from "./ssh-access.js";
export * from "./sftp-access.js";
@@ -0,0 +1,34 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
@IsAccess({
name: "sftp",
title: "SFTP授权",
desc: "",
icon: "clarity:host-line",
input: {},
})
export class SftpAccess extends BaseAccess {
@AccessInput({
title: "SSH授权",
component: {
name: "access-selector",
type: "ssh",
vModel: "modelValue",
},
helper: "请选择一个SSH授权",
required: true,
})
sshAccess!: string;
@AccessInput({
title: "文件权限",
component: {
name: "a-input",
vModel: "value",
placeholder: "777",
},
helper: "文件上传后是否修改文件权限",
})
fileMode!: string;
}
new SftpAccess();
@@ -0,0 +1,173 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
@IsAccess({
name: "ssh",
title: "主机登录授权",
desc: "",
icon: "clarity:host-line",
input: {},
order: 0,
})
export class SshAccess extends BaseAccess {
@AccessInput({
title: "主机地址",
component: {
placeholder: "主机域名或IP地址",
},
required: true,
})
host!: string;
@AccessInput({
title: "端口",
value: 22,
component: {
name: "a-input-number",
placeholder: "22",
},
rules: [{ required: true, message: "此项必填" }],
})
port!: number;
@AccessInput({
title: "用户名",
value: "root",
rules: [{ required: true, message: "此项必填" }],
})
username!: string;
@AccessInput({
title: "密码",
component: {
name: "a-input-password",
vModel: "value",
},
encrypt: true,
helper: "登录密码或密钥必填一项",
})
password!: string;
@AccessInput({
title: "私钥登录",
helper: "私钥或密码必填一项",
component: {
name: "pem-input",
vModel: "modelValue",
},
encrypt: true,
})
privateKey!: string;
@AccessInput({
title: "私钥密码",
helper: "如果你的私钥有密码的话",
component: {
name: "a-input-password",
vModel: "value",
},
encrypt: true,
})
passphrase!: string;
@AccessInput({
title: "脚本类型",
helper: "bash 、sh 、fish",
component: {
name: "a-select",
vModel: "value",
options: [
{ value: "default", label: "默认" },
{ value: "sh", label: "sh" },
{ value: "bash", label: "bash" },
{ value: "fish", label: "fish(不支持set -e)" },
],
},
})
scriptType: string;
@AccessInput({
title: "伪终端",
helper: "如果登录报错:all authentication methods failed,可以尝试开启伪终端模式进行keyboard-interactive方式登录\n开启后对日志输出有一定的影响",
component: {
name: "a-switch",
vModel: "checked",
},
})
pty!: boolean;
@AccessInput({
title: "socks代理",
helper: "socks代理配置,格式:socks5://user:password@host:port",
component: {
name: "a-input",
vModel: "value",
placeholder: "socks5://user:password@host:port",
},
encrypt: false,
})
socksProxy!: string;
@AccessInput({
title: "超时时间",
helper: "执行命令的超时时间,单位秒,默认30分钟",
component: {
name: "a-input-number",
},
})
timeout: number;
@AccessInput({
title: "是否Windows",
helper: "如果是Windows主机,请勾选此项\n并且需要windows[安装OpenSSH](https://certd.docmirror.cn/guide/use/host/windows.html)",
component: {
name: "a-switch",
vModel: "checked",
},
})
windows = false;
@AccessInput({
title: "命令编码",
helper: "如果是Windows主机,且出现乱码了,请尝试设置为GBK",
component: {
name: "a-select",
vModel: "value",
options: [
{ value: "", label: "默认" },
{ value: "GBK", label: "GBK" },
{ value: "UTF8", label: "UTF-8" },
],
},
})
encoding: string;
@AccessInput({
title: "测试",
component: {
name: "api-test",
type: "access",
typeName: "ssh",
action: "TestRequest",
},
mergeScript: `
return {
component:{
form: ctx.compute(({form})=>{
return form
})
},
}
`,
helper: "点击测试",
})
testRequest = true;
async onTestRequest() {
const { SshClient } = await import("./ssh.js");
const client = new SshClient(this.ctx.logger);
const script = ["echo hello", "exit"];
await client.exec({
connectConf: this,
script: script,
});
return "ok";
}
}
new SshAccess();
@@ -0,0 +1,643 @@
// @ts-ignore
import path from "path";
import { isArray } from "lodash-es";
import { ILogger, safePromise } from "@certd/basic";
import { SshAccess } from "./ssh-access.js";
import fs from "fs";
import { SocksProxyType } from "socks/typings/common/constants.js";
export type TransportItem = { localPath: string; remotePath: string };
export interface SocksProxy {
ipaddress?: string;
host?: string;
port: number;
type: any;
userId?: string;
password?: string;
custom_auth_method?: number;
custom_auth_request_handler?: () => Promise<Buffer>;
custom_auth_response_size?: number;
custom_auth_response_handler?: (data: Buffer) => Promise<boolean>;
}
export type SshConnectConfig = {
sock?: any;
};
export class AsyncSsh2Client {
conn: any;
logger: ILogger;
connConf: SshAccess & SshConnectConfig;
windows = false;
encoding: string;
constructor(connConf: SshAccess, logger: ILogger) {
this.connConf = connConf;
this.logger = logger;
this.windows = connConf.windows || false;
this.encoding = connConf.encoding;
}
convert(iconv: any, buffer: Buffer) {
if (this.encoding) {
return iconv.decode(buffer, this.encoding);
}
return buffer.toString().replaceAll("\r\n", "\n");
}
async connect() {
this.logger.info(`开始连接,${this.connConf.host}:${this.connConf.port}`);
if (this.connConf.socksProxy) {
this.logger.info(`使用代理${this.connConf.socksProxy}`);
if (typeof this.connConf.port === "string") {
this.connConf.port = parseInt(this.connConf.port);
}
const { SocksClient } = await import("socks");
const proxyOption = this.parseSocksProxyFromUri(this.connConf.socksProxy);
const info = await SocksClient.createConnection({
proxy: proxyOption,
command: "connect",
destination: {
host: this.connConf.host,
port: this.connConf.port,
},
});
this.logger.info("代理连接成功");
this.connConf.sock = info.socket;
}
const ssh2 = await import("ssh2");
const ssh2Constants = await import("ssh2/lib/protocol/constants.js");
const { SUPPORTED_KEX, SUPPORTED_SERVER_HOST_KEY, SUPPORTED_CIPHER, SUPPORTED_MAC } = ssh2Constants.default;
return safePromise((resolve, reject) => {
try {
const conn = new ssh2.default.Client();
conn
.on("error", (err: any) => {
this.logger.error("连接失败", err);
reject(err);
})
.on("ready", () => {
this.logger.info("连接成功");
this.conn = conn;
resolve(this.conn);
})
.on("keyboard-interactive", (name, descr, lang, prompts, finish) => {
// For illustration purposes only! It's not safe to do this!
// You can read it from process.stdin or whatever else...
const password = this.connConf.password;
return finish([password]);
// And remember, server may trigger this event multiple times
// and for different purposes (not only auth)
})
.connect({
...this.connConf,
tryKeyboard: true,
algorithms: {
serverHostKey: SUPPORTED_SERVER_HOST_KEY,
cipher: SUPPORTED_CIPHER,
hmac: SUPPORTED_MAC,
kex: SUPPORTED_KEX,
},
});
} catch (e) {
reject(e);
}
});
}
async getSftp() {
return safePromise((resolve, reject) => {
this.logger.info("获取sftp");
this.conn.sftp((err: any, sftp: any) => {
if (err) {
reject(err);
return;
}
resolve(sftp);
});
});
}
async fastPut(options: { sftp: any; localPath: string; remotePath: string; opts?: { mode?: string } }) {
const { sftp, localPath, remotePath, opts } = options;
return safePromise((resolve, reject) => {
this.logger.info(`开始上传:${localPath} => ${remotePath}`);
sftp.fastPut(localPath, remotePath, { ...(opts ?? {}) }, (err: Error) => {
if (err) {
reject(err);
this.logger.error("请确认路径是否包含文件名,路径本身不能是目录,路径不能有*?之类的特殊符号,要有写入权限");
return;
}
this.logger.info(`上传文件成功:${localPath} => ${remotePath}`);
resolve({});
});
});
}
async listDir(options: { sftp: any; remotePath: string }) {
const { sftp, remotePath } = options;
return safePromise((resolve, reject) => {
this.logger.info(`listDir${remotePath}`);
sftp.readdir(remotePath, (err: Error, list: any) => {
if (err) {
reject(err);
return;
}
resolve(list);
});
});
}
async unlink(options: { sftp: any; remotePath: string }) {
const { sftp, remotePath } = options;
return safePromise((resolve, reject) => {
this.logger.info(`开始删除远程文件:${remotePath}`);
sftp.unlink(remotePath, (err: Error) => {
if (err) {
reject(err);
return;
}
this.logger.info(`删除文件成功:${remotePath}`);
resolve({});
});
});
}
/**
*
* @param script
* @param opts {withStdErr 返回{stdOut,stdErr}}
*/
async exec(
script: string,
opts: {
throwOnStdErr?: boolean;
withStdErr?: boolean;
env?: any;
} = {}
): Promise<string> {
if (!script) {
this.logger.info("script 为空,取消执行");
return;
}
let iconv: any = await import("iconv-lite");
iconv = iconv.default;
// if (this.connConf.windows) {
// script += "\r\nexit\r\n";
// //保证windows下正常退出
// }
if (script.includes(" -i ")) {
this.logger.warn("不支持交互式命令,请不要使用-i参数");
}
return safePromise((resolve, reject) => {
this.logger.info(`执行命令:[${this.connConf.host}][exec]: \n` + script);
// pty 伪终端,window下的输出会带上conhost.exe之类的多余的字符串,影响返回结果判断
// linux下 当使用keyboard-interactive 登录时,需要pty
const pty = this.connConf.pty; //linux下开启伪终端,windows下不开启
this.conn.exec(script, { pty, env: opts.env }, (err: Error, stream: any) => {
if (err) {
reject(err);
return;
}
let data = "";
let stdErr = "";
let hasErrorLog = false;
stream
.on("close", (code: any, signal: any) => {
this.logger.info(`[${this.connConf.host}][close]:code:${code}`);
/**
* ]pipeline 执行命令:[10.123.0.2][exec]:cd /d D:\nginx-1.27.5 && D:\nginx-1.27.5\nginx.exe -t && D:\nginx-1.27.5\nginx.exe -s reload
* [2025-07-09T10:24:11.219] [ERROR]pipeline - [10. 123.0. 2][error]: nginx: the configuration file D: \nginx-1.27. 5/conf/nginx. conf syntax is ok
* [2025-07-09T10:24:11.231] [ERROR][10. 123. 0. 2] [error]: nginx: configuration file D: \nginx-1.27.5/conf/nginx.conf test is successful
* pipeline-
* [2025-07-09T10:24:11.473] [INFO]pipeline -[10.123.0.2][close]:code:0
* [2025-07-09T10:24:11.473][ERRoR] pipeline- [step][主机一执行远程主机脚本命令]<id:53hyarN3yvmbijNuMiNAt>: [Eror: nginx: the configuration fileD:\nginx-1.27.5/conf/nginx.conf syntax is ok
//需要忽略windows的错误
*/
// if (opts.throwOnStdErr == null && this.windows) {
// opts.throwOnStdErr = true;
// }
if (opts.throwOnStdErr && hasErrorLog) {
reject(new Error(data));
}
if (code === 0) {
if (opts.withStdErr === true) {
//@ts-ignore
resolve({
stdErr,
stdOut: data,
});
} else {
resolve(data);
}
} else {
reject(new Error(data));
}
})
.on("data", (ret: Buffer) => {
const out = this.convert(iconv, ret);
data += out;
this.logger.info(`[${this.connConf.host}][info]: ` + out.trimEnd());
})
.on("error", (err: any) => {
reject(err);
this.logger.error(err);
})
.stderr.on("data", (ret: Buffer) => {
const err = this.convert(iconv, ret);
stdErr += err;
hasErrorLog = true;
if (err.includes("sudo: a password is required")) {
this.logger.warn("请配置sudo免密,否则命令无法执行");
}
this.logger.error(`[${this.connConf.host}][error]: ` + err.trimEnd());
});
});
});
}
async shell(script: string | string[]): Promise<string> {
const stripAnsiModule = await import("strip-ansi");
const stripAnsi = stripAnsiModule.default;
return safePromise<any>((resolve, reject) => {
this.logger.info(`执行shell脚本:[${this.connConf.host}][shell]: ` + script);
this.conn.shell((err: Error, stream: any) => {
if (err) {
reject(err);
return;
}
let output = "";
function ansiHandle(data: string) {
data = data.replace(/\[[0-9]+;1H/g, "");
data = stripAnsi(data);
return data.replaceAll("\r\n", "\n");
}
stream
.on("close", (code: any) => {
this.logger.info("Stream :: close,code: " + code);
resolve(output);
})
.on("data", (ret: Buffer) => {
const data = ansiHandle(ret.toString());
this.logger.info(data);
output += data;
})
.on("error", (err: any) => {
reject(err);
this.logger.error(err);
})
.stderr.on("data", (ret: Buffer) => {
const data = ansiHandle(ret.toString());
output += data;
this.logger.error(`[${this.connConf.host}][error]: ` + data);
});
//保证windows下正常退出
const exit = "\r\nexit\r\n";
stream.end(script + exit);
});
});
}
end() {
if (this.conn) {
this.conn.end();
this.conn.destroy();
this.conn = null;
}
}
private parseSocksProxyFromUri(socksProxyUri: string): SocksProxy {
const url = new URL(socksProxyUri);
let type: SocksProxyType = 5;
if (url.protocol.startsWith("socks4")) {
type = 4;
}
const proxy: SocksProxy = {
host: url.hostname,
port: parseInt(url.port),
type,
};
if (url.username) {
proxy.userId = url.username;
}
if (url.password) {
proxy.password = url.password;
}
return proxy;
}
async download(param: { remotePath: string; savePath: string; sftp: any }) {
return safePromise((resolve, reject) => {
const { remotePath, savePath, sftp } = param;
sftp.fastGet(
remotePath,
savePath,
{
step: (transferred: any, chunk: any, total: any) => {
this.logger.info(`${transferred} / ${total}`);
},
},
(err: any) => {
if (err) {
reject(err);
} else {
resolve({});
}
}
);
});
}
}
export class SshClient {
logger: ILogger;
/**
*
* @param connectConf
{
host: '192.168.100.100',
port: 22,
username: 'frylock',
password: 'nodejsrules'
}
* @param options
*/
async uploadFiles(options: { connectConf: SshAccess; transports: TransportItem[]; mkdirs: boolean; opts?: { mode?: string }; uploadType?: string }) {
const { connectConf, transports, mkdirs, opts } = options;
await this._call({
connectConf,
callable: async (conn: AsyncSsh2Client) => {
this.logger.info("开始上传");
if (mkdirs !== false) {
this.logger.info("初始化父目录");
for (const transport of transports) {
const filePath = path.dirname(transport.remotePath);
let mkdirCmd = `mkdir -p ${filePath} `;
if (conn.windows) {
if (filePath.indexOf("/") > -1) {
this.logger.info("--------------------------");
this.logger.info("请注意:windows下,文件目录分隔应该写成\\而不是/");
this.logger.info("--------------------------");
}
const isCmd = await this.isCmd(conn);
if (!isCmd) {
mkdirCmd = `New-Item -ItemType Directory -Path "${filePath}" -Force`;
} else {
mkdirCmd = `if not exist "${filePath}" mkdir "${filePath}"`;
}
}
await conn.exec(mkdirCmd);
}
}
if (options.uploadType === "scp") {
//scp
for (const transport of transports) {
await this.scpUpload({ conn, ...transport, opts });
await new Promise(resolve => setTimeout(resolve, 1000));
}
} else {
const sftp = await conn.getSftp();
for (const transport of transports) {
await conn.fastPut({ sftp, ...transport, opts });
}
}
this.logger.info("文件全部上传成功");
},
});
}
constructor(logger: ILogger) {
this.logger = logger;
}
async scpUpload(options: { conn: any; localPath: string; remotePath: string; opts?: { mode?: string } }) {
const { conn, localPath, remotePath } = options;
return safePromise((resolve, reject) => {
// 关键步骤:构造 SCP 命令
this.logger.info(`开始上传:${localPath} => ${remotePath}`);
conn.conn.exec(
`scp -t ${remotePath}`, // -t 表示目标模式
(err, stream) => {
if (err) {
return reject(err);
}
try {
// 准备 SCP 协议头
const fileStats = fs.statSync(localPath);
const fileName = path.basename(localPath);
// SCP 协议格式:C[权限] [文件大小] [文件名]\n
stream.write(`C0644 ${fileStats.size} ${fileName}\n`);
// 通过管道传输文件
fs.createReadStream(localPath)
.on("error", e => {
this.logger.info("read stream error", e);
reject(e);
})
.pipe(stream)
.on("finish", async () => {
this.logger.info(`上传完成:${localPath} => ${remotePath}`);
resolve(true);
})
.on("error", reject);
} catch (e) {
reject(e);
}
}
);
});
}
async removeFiles(opts: { connectConf: SshAccess; files: string[] }) {
const { connectConf, files } = opts;
await this._call({
connectConf,
callable: async (conn: AsyncSsh2Client) => {
const sftp = await conn.getSftp();
this.logger.info("开始删除");
for (const file of files) {
await conn.unlink({
sftp,
remotePath: file,
});
}
this.logger.info("文件全部删除成功");
},
});
}
async isCmd(conn: AsyncSsh2Client) {
const spec = await conn.exec("echo %COMSPEC% ");
const ret = spec.toString().trim();
if (ret.includes("%COMSPEC%") && !ret.includes("echo %COMSPEC%")) {
return false;
} else {
return true;
}
}
async getIsCmd(options: { connectConf: SshAccess }) {
const { connectConf } = options;
return await this._call<boolean>({
connectConf,
callable: async (conn: AsyncSsh2Client) => {
return await this.isCmd(conn);
},
});
}
/**
*
* Set-ItemProperty -Path "HKLM:\SOFTWARE\OpenSSH" -Name DefaultShell -Value "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"
* Start-Service sshd
*
* Set-ItemProperty -Path "HKLM:\SOFTWARE\OpenSSH" -Name DefaultShell -Value "C:\Windows\System32\cmd.exe"
* @param options
*/
async exec(options: { connectConf: SshAccess; script: string | Array<string>; env?: any; throwOnStdErr?: boolean; stopOnError?: boolean }): Promise<string> {
let { script } = options;
const { connectConf, throwOnStdErr } = options;
// this.logger.info('命令:', script);
return await this._call({
connectConf,
callable: async (conn: AsyncSsh2Client) => {
let isWinCmd = false;
const isLinux = !connectConf.windows;
const envScripts = [];
if (connectConf.windows) {
isWinCmd = await this.isCmd(conn);
}
if (options.env) {
for (const key in options.env) {
if (isLinux) {
envScripts.push(`export ${key}=${options.env[key]}`);
} else if (isWinCmd) {
//win cmd
envScripts.push(`set ${key}=${options.env[key]}`);
} else {
//powershell
envScripts.push(`$env:${key}="${options.env[key]}"`);
}
}
}
if (isWinCmd) {
if (typeof script === "string") {
script = script.split("\n");
}
//组合成&&的形式
script = envScripts.concat(script);
script = script as Array<string>;
script = script.join(" && ");
} else {
const newLine = isLinux ? "\n" : "\r\n";
if (isArray(script)) {
script = script as Array<string>;
script = script.join(newLine);
}
if (envScripts.length > 0) {
script = envScripts.join(newLine) + newLine + script;
}
}
if (isLinux) {
if (options.connectConf.scriptType == "bash") {
script = "#!/usr/bin/env bash \n" + script;
} else if (options.connectConf.scriptType == "sh") {
script = "#!/bin/sh\n" + script;
}
if (options.connectConf.scriptType != "fish" && options.stopOnError !== false) {
script = "set -e\n" + script;
}
}
return await conn.exec(script as string, { throwOnStdErr });
},
});
}
async shell(options: { connectConf: SshAccess; script: string | Array<string> }): Promise<string> {
let { script } = options;
const { connectConf } = options;
if (isArray(script)) {
script = script as Array<string>;
if (connectConf.windows) {
script = script.join("\r\n");
} else {
script = script.join("\n");
}
} else {
if (connectConf.windows) {
//@ts-ignore
script = script.replaceAll("\n", "\r\n");
}
}
return await this._call({
connectConf,
callable: async (conn: AsyncSsh2Client) => {
return await conn.shell(script as string);
},
});
}
async _call<T = any>(options: { connectConf: SshAccess; callable: (conn: AsyncSsh2Client) => Promise<T> }): Promise<T> {
const { connectConf, callable } = options;
const conn = new AsyncSsh2Client(connectConf, this.logger);
try {
await conn.connect();
} catch (e: any) {
if (e.message?.indexOf("All configured authentication methods failed") > -1) {
this.logger.error(e);
throw new Error("登录失败,请检查用户名/密码/密钥是否正确");
}
throw e;
}
let timeoutId = null;
try {
timeoutId = setTimeout(() => {
this.logger.info("执行超时,断开连接");
conn.end();
}, 1000 * (connectConf.timeout || 1800));
return await callable(conn);
} finally {
clearTimeout(timeoutId);
conn.end();
}
}
async listDir(param: { connectConf: any; dir: string }) {
return await this._call<any>({
connectConf: param.connectConf,
callable: async (conn: AsyncSsh2Client) => {
const sftp = await conn.getSftp();
return await conn.listDir({
sftp,
remotePath: param.dir,
});
},
});
}
async download(param: { connectConf: any; filePath: string; savePath: string }) {
return await this._call<any>({
connectConf: param.connectConf,
callable: async (conn: AsyncSsh2Client) => {
const sftp = await conn.getSftp();
return await conn.download({
sftp,
remotePath: param.filePath,
savePath: param.savePath,
});
},
});
}
}
@@ -0,0 +1,65 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
@IsAccess({
name: "tencentcos",
title: "腾讯云COS授权",
icon: "svg:icon-tencentcloud",
desc: "腾讯云对象存储授权,包含地域和存储桶",
})
export class TencentCosAccess extends BaseAccess {
@AccessInput({
title: "腾讯云授权",
component: {
name: "access-selector",
vModel: "modelValue",
type: "tencent",
},
helper: "请选择腾讯云授权",
required: true,
})
accessId = "";
@AccessInput({
title: "所在地域",
helper: "存储桶所在地域",
component: {
name: "a-auto-complete",
vModel: "value",
options: [
{ value: "", label: "--------中国大陆地区-------", disabled: true },
{ value: "ap-beijing-1", label: "北京1区" },
{ value: "ap-beijing", label: "北京" },
{ value: "ap-nanjing", label: "南京" },
{ value: "ap-shanghai", label: "上海" },
{ value: "ap-guangzhou", label: "广州" },
{ value: "ap-chengdu", label: "成都" },
{ value: "ap-chongqing", label: "重庆" },
{ value: "ap-shenzhen-fsi", label: "深圳金融" },
{ value: "ap-shanghai-fsi", label: "上海金融" },
{ value: "ap-beijing-fsi", label: "北京金融" },
{ value: "", label: "--------中国香港及境外-------", disabled: true },
{ value: "ap-hongkong", label: "中国香港" },
{ value: "ap-singapore", label: "新加坡" },
{ value: "ap-mumbai", label: "孟买" },
{ value: "ap-jakarta", label: "雅加达" },
{ value: "ap-seoul", label: "首尔" },
{ value: "ap-bangkok", label: "曼谷" },
{ value: "ap-tokyo", label: "东京" },
{ value: "na-siliconvalley", label: "硅谷" },
{ value: "na-ashburn", label: "弗吉尼亚" },
{ value: "sa-saopaulo", label: "圣保罗" },
{ value: "eu-frankfurt", label: "法兰克福" },
],
},
})
region!: string;
@AccessInput({
title: "Bucket",
helper: "存储桶名称",
required: true,
})
bucket = "";
}
new TencentCosAccess();
@@ -0,0 +1,71 @@
import { IsAccess, AccessInput, BaseAccess } from "@certd/pipeline";
@IsAccess({
name: "tencent",
title: "腾讯云",
icon: "svg:icon-tencentcloud",
order: 0,
})
export class TencentAccess extends BaseAccess {
@AccessInput({
title: "secretId",
helper: "使用对应的插件需要有对应的权限,比如上传证书,需要证书管理权限;部署到clb需要clb相关权限\n前往[密钥管理](https://console.cloud.tencent.com/cam/capi)进行创建",
component: {
placeholder: "secretId",
},
rules: [{ required: true, message: "该项必填" }],
})
secretId = "";
@AccessInput({
title: "secretKey",
component: {
placeholder: "secretKey",
},
encrypt: true,
rules: [{ required: true, message: "该项必填" }],
})
secretKey = "";
@AccessInput({
title: "站点类型",
value: "cn",
component: {
name: "a-select",
options: [
{
label: "国内站",
value: "cn",
},
{
label: "国际站",
value: "intl",
},
],
},
encrypt: false,
rules: [{ required: true, message: "该项必填" }],
})
accountType: string;
@AccessInput({
title: "关闭证书过期通知",
value: true,
component: {
name: "a-switch",
vModel: "checked",
},
})
closeExpiresNotify: boolean = true;
isIntl() {
return this.accountType === "intl";
}
intlDomain() {
return this.isIntl() ? "intl." : "";
}
buildEndpoint(endpoint: string) {
return `${this.intlDomain()}${endpoint}`;
}
}
@@ -0,0 +1,3 @@
export * from "./access.js";
export * from "./access-cos.js";
export * from "./lib/index.js";
@@ -0,0 +1,117 @@
import { TencentAccess } from "../access.js";
import { ILogger, safePromise } from "@certd/basic";
import fs from "fs";
export class TencentCosClient {
access: TencentAccess;
logger: ILogger;
region: string;
bucket: string;
constructor(opts: { access: TencentAccess; logger: ILogger; region: string; bucket: string }) {
this.access = opts.access;
this.logger = opts.logger;
this.bucket = opts.bucket;
this.region = opts.region;
}
async getCosClient() {
const sdk = await import("cos-nodejs-sdk-v5");
const clientConfig = {
SecretId: this.access.secretId,
SecretKey: this.access.secretKey,
};
return new sdk.default(clientConfig);
}
async uploadFile(key: string, file: Buffer | string) {
const cos = await this.getCosClient();
return safePromise((resolve, reject) => {
let readableStream = file as any;
if (typeof file === "string") {
readableStream = fs.createReadStream(file);
}
cos.putObject(
{
Bucket: this.bucket /* 必须 */,
Region: this.region /* 必须 */,
Key: key /* 必须 */,
Body: readableStream, // 上传文件对象
onProgress: function (progressData) {
console.log(JSON.stringify(progressData));
},
},
function (err, data) {
if (err) {
reject(err);
return;
}
resolve(data);
}
);
});
}
async removeFile(key: string) {
const cos = await this.getCosClient();
return safePromise((resolve, reject) => {
cos.deleteObject(
{
Bucket: this.bucket,
Region: this.region,
Key: key,
},
function (err, data) {
if (err) {
reject(err);
return;
}
resolve(data);
}
);
});
}
async downloadFile(key: string, savePath: string) {
const cos = await this.getCosClient();
const writeStream = fs.createWriteStream(savePath);
return safePromise((resolve, reject) => {
cos.getObject(
{
Bucket: this.bucket,
Region: this.region,
Key: key,
Output: writeStream,
},
function (err, data) {
if (err) {
reject(err);
return;
}
resolve(data);
}
);
});
}
async listDir(dirKey: string) {
const cos = await this.getCosClient();
return safePromise((resolve, reject) => {
cos.getBucket(
{
Bucket: this.bucket,
Region: this.region,
Prefix: dirKey,
MaxKeys: 1000,
},
function (err, data) {
if (err) {
reject(err);
return;
}
resolve(data.Contents);
}
);
});
}
}
@@ -0,0 +1,2 @@
export * from "./ssl-client.js";
export * from "./cos-client.js";
@@ -0,0 +1,102 @@
import { ILogger } from "@certd/basic";
import { TencentAccess } from "../access.js";
export type TencentCertInfo = {
key: string;
crt: string;
};
export class TencentSslClient {
access: TencentAccess;
logger: ILogger;
region?: string;
constructor(opts: { access: TencentAccess; logger: ILogger; region?: string }) {
this.access = opts.access;
this.logger = opts.logger;
this.region = opts.region;
}
async getSslClient(): Promise<any> {
const sdk = await import("tencentcloud-sdk-nodejs/tencentcloud/services/ssl/v20191205/index.js");
const SslClient = sdk.v20191205.Client;
const clientConfig = {
credential: {
secretId: this.access.secretId,
secretKey: this.access.secretKey,
},
region: this.region,
profile: {
httpProfile: {
endpoint: this.access.isIntl() ? "ssl.intl.tencentcloudapi.com" : "ssl.tencentcloudapi.com",
},
},
};
return new SslClient(clientConfig);
}
checkRet(ret: any) {
if (!ret || ret.Error) {
throw new Error("请求失败:" + ret.Error.Code + "," + ret.Error.Message + ",requestId" + ret.RequestId);
}
}
async uploadToTencent(opts: { certName: string; cert: TencentCertInfo }): Promise<string> {
const client = await this.getSslClient();
const params = {
CertificatePublicKey: opts.cert.crt,
CertificatePrivateKey: opts.cert.key,
Alias: opts.certName,
};
const ret = await client.UploadCertificate(params);
this.checkRet(ret);
this.logger.info(`证书[${opts.certName}]上传成功:tencentCertId=`, ret.CertificateId);
if (this.access.closeExpiresNotify) {
await this.switchCertNotify([ret.CertificateId], true);
}
return ret.CertificateId;
}
async switchCertNotify(certIds: string[], disabled: boolean) {
const client = await this.getSslClient();
const params = {
CertificateIds: certIds,
SwitchStatus: disabled ? 1 : 0, //1是忽略通知,0是不忽略
};
const ret = await client.ModifyCertificatesExpiringNotificationSwitch(params);
this.checkRet(ret);
this.logger.info(`关闭证书${certIds}过期通知成功`);
return ret.RequestId;
}
async deployCertificateInstance(params: any) {
return await this.doRequest("DeployCertificateInstance", params);
}
async DescribeHostUploadUpdateRecordDetail(params: any) {
return await this.doRequest("DescribeHostUploadUpdateRecordDetail", params);
}
async UploadUpdateCertificateInstance(params: any) {
return await this.doRequest("UploadUpdateCertificateInstance", params);
}
async DescribeCertificates(params: { Limit?: number; Offset?: number; SearchKey?: string }) {
return await this.doRequest("DescribeCertificates", {
ExpirationSort: "ASC",
...params,
});
}
async doRequest(action: string, params: any) {
const client = await this.getSslClient();
try {
const res = await client.request(action, params);
this.checkRet(res);
return res;
} catch (e) {
this.logger.error(`action ${action} error: ${e.message},requestId=${e.RequestId}`);
throw e;
}
}
}