diff --git a/.trae/skills/task-plugin-dev/SKILL.md b/.trae/skills/task-plugin-dev/SKILL.md index 86dbd60b7..ec5110fbe 100644 --- a/.trae/skills/task-plugin-dev/SKILL.md +++ b/.trae/skills/task-plugin-dev/SKILL.md @@ -211,6 +211,9 @@ export class DemoTest extends AbstractTaskPlugin { //当以下参数变化时,触发获取选项 watches: ['certDomains', 'accessId'], required: true, + single: false, // 是否单选 + pager: true, // 是否卡其分页查询 + search: true, // 是否开启搜索 }) ) siteName!: string | string[]; @@ -260,9 +263,12 @@ export class DemoTest extends AbstractTaskPlugin { throw new Error('请选择Access授权'); } + const pager = new Pager(req); + // @ts-ignore const access = await this.getAccess(this.accessId); - + // + // 根据接口情况是否支持翻页查询,和关键字查询, 传递对应的参数,pager.pageNo,pager.pageSize, req.searchKey // const siteRes = await access.GetDomainList(req); //以下是模拟数据 const siteRes = [ diff --git a/packages/core/acme-client/package.json b/packages/core/acme-client/package.json index b330133bb..dcd9d6c5b 100644 --- a/packages/core/acme-client/package.json +++ b/packages/core/acme-client/package.json @@ -41,7 +41,6 @@ "eslint-plugin-import": "^2.29.1", "eslint-plugin-prettier": "^5.1.3", "esmock": "^2.7.5", - "jsdoc-to-markdown": "^8.0.1", "mocha": "^10.6.0", "nock": "^13.5.4", "prettier": "3.3.3", diff --git a/packages/plugins/plugin-lib/package.json b/packages/plugins/plugin-lib/package.json index 48bdfe456..127cf6b6e 100644 --- a/packages/plugins/plugin-lib/package.json +++ b/packages/plugins/plugin-lib/package.json @@ -25,7 +25,8 @@ "@certd/pipeline": "^1.41.4", "dayjs": "^1.11.7", "lodash-es": "^4.17.21", - "psl": "^1.15.0" + "psl": "^1.15.0", + "punycode.js": "^2.3.1" }, "devDependencies": { "rimraf": "^5.0.5", diff --git a/packages/ui/certd-server/package.json b/packages/ui/certd-server/package.json index 0ca7bee5d..89f1f312e 100644 --- a/packages/ui/certd-server/package.json +++ b/packages/ui/certd-server/package.json @@ -115,9 +115,32 @@ "uuid": "^10.0.0", "wechatpay-node-v3": "^2.2.1", "whoiser": "2.0.0-beta.10", - "xml2js": "^0.6.2" + "xml2js": "^0.6.2", + "mwtsc": "^1.15.1" }, - "lazyDependencies": { + "devDependencies": { + "mwts": "^1.3.0", + "@midwayjs/mock": "3.20.11", + "@types/ali-oss": "^6.16.11", + "@types/cache-manager": "^4.0.6", + "@types/jest": "^29.5.13", + "@types/koa": "2.15.0", + "@types/lodash-es": "^4.17.12", + "@types/mocha": "^10.0.6", + "@types/node": "^18", + "@types/nodemailer": "^6.4.8", + "c8": "^10.1.2", + "cross-env": "^7.0.3", + "esmock": "^2.7.5", + "mocha": "^10.6.0", + "prettier": "3.3.3", + "rimraf": "^5.0.5", + "ts-node": "^10.9.2", + "tslib": "^2.8.1", + "typescript": "^5.4.2", + "why-is-node-running": "^3.2.2" + }, + "lazyDependencies": { "@alicloud/fc20230330": "^4.1.7", "@alicloud/tea-typescript": "^1.8.0", "@alicloud/openapi-client": "^0.4.12", @@ -147,30 +170,7 @@ "@google-cloud/publicca": "^1.3.0", "basic-ftp": "^5.0.5", "esdk-obs-nodejs": "^3.25.6", - "qiniu": "^7.12.0", - "mwtsc": "^1.15.1" - }, - "devDependencies": { - "mwts": "^1.3.0", - "@midwayjs/mock": "3.20.11", - "@types/ali-oss": "^6.16.11", - "@types/cache-manager": "^4.0.6", - "@types/jest": "^29.5.13", - "@types/koa": "2.15.0", - "@types/lodash-es": "^4.17.12", - "@types/mocha": "^10.0.6", - "@types/node": "^18", - "@types/nodemailer": "^6.4.8", - "c8": "^10.1.2", - "cross-env": "^7.0.3", - "esmock": "^2.7.5", - "mocha": "^10.6.0", - "prettier": "3.3.3", - "rimraf": "^5.0.5", - "ts-node": "^10.9.2", - "tslib": "^2.8.1", - "typescript": "^5.4.2", - "why-is-node-running": "^3.2.2" + "qiniu": "^7.12.0" }, "engines": { "node": ">=20.0.0" diff --git a/packages/ui/certd-server/src/plugins/index.ts b/packages/ui/certd-server/src/plugins/index.ts index 2b29f51bb..a14bbc92c 100644 --- a/packages/ui/certd-server/src/plugins/index.ts +++ b/packages/ui/certd-server/src/plugins/index.ts @@ -30,8 +30,9 @@ // export * from './plugin-wangsu/index.js' // export * from './plugin-admin/index.js' // export * from './plugin-ksyun/index.js' -// export * from './plugin-apisix/index.js' -// export * from './plugin-dokploy/index.js' +// export * from './plugin-apisix/index.js'; +// export * from './plugin-asiaisp/index.js'; +// export * from './plugin-dokploy/index.js'; // export * from './plugin-godaddy/index.js' // export * from './plugin-captcha/index.js' // export * from './plugin-xinnet/index.js' diff --git a/packages/ui/certd-server/src/plugins/plugin-asiaisp/access.ts b/packages/ui/certd-server/src/plugins/plugin-asiaisp/access.ts new file mode 100644 index 000000000..0125e2f26 --- /dev/null +++ b/packages/ui/certd-server/src/plugins/plugin-asiaisp/access.ts @@ -0,0 +1,56 @@ +import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline"; +import { AsiaIspClient } from "./client.js"; + +@IsAccess({ + name: "asiaisp", + title: "橙域网络(asia-isp)授权", + desc: "橙域网络CDN API授权,用于部署证书到橙域CDN", + icon: "clarity:plugin-line", +}) +export class AsiaIspAccess extends BaseAccess { + @AccessInput({ + title: "AccessKeyId", + component: { + placeholder: "请输入 AccessKeyId", + }, + required: true, + }) + accessKeyId = ""; + + @AccessInput({ + title: "AccessKeySecret", + component: { + placeholder: "请输入 AccessKeySecret", + }, + required: true, + encrypt: true, + }) + accessKeySecret = ""; + + @AccessInput({ + title: "测试连接", + component: { + name: "api-test", + action: "TestRequest", + }, + helper: "点击测试接口是否正常", + }) + testRequest = true; + + async onTestRequest() { + const client = await this.getClient(); + const list = await client.getCertList(); + return `连接成功,共 ${list.length} 个证书`; + } + + async getClient() { + return new AsiaIspClient({ + accessKeyId: this.accessKeyId, + accessKeySecret: this.accessKeySecret, + http: this.ctx.http, + logger: this.ctx.logger, + }); + } +} + +new AsiaIspAccess(); diff --git a/packages/ui/certd-server/src/plugins/plugin-asiaisp/client.ts b/packages/ui/certd-server/src/plugins/plugin-asiaisp/client.ts new file mode 100644 index 000000000..eedb337ed --- /dev/null +++ b/packages/ui/certd-server/src/plugins/plugin-asiaisp/client.ts @@ -0,0 +1,240 @@ +import { HttpClient, ILogger } from "@certd/basic"; +import { CertInfo, CertReader } from "@certd/plugin-cert"; +import * as crypto from "crypto"; + +const BASE_URL = "https://api.asia-isp.com"; +const URI = "/openapi/v3/stat"; + +export interface AsiaIspConfig { + accessKeyId: string; + accessKeySecret: string; + http: HttpClient; + logger: ILogger; +} + +export interface AsiaIspDomain { + domain: string; + serviceType: string; + scope: string; + protocol: string; + certId: number; + cname: string; + originHost: string; + originAddr: string; + originProtocol: string; + originType: string; + domainStatus: number; + operatingStatus: number; +} + +/** + * 橙域网络(asia-isp) CDN API 客户端 + * 签名算法参照 Python 参考实现: + * message = accessKeySecret={sk}&[body={json}]&method={method}&nonce={nonce}&queryString={qs}×tamp={ts}&uri={uri} + * signature = URL-safe-Base64(HMAC-SHA1(sk, message)) + */ +export class AsiaIspClient { + private config: AsiaIspConfig; + private http: HttpClient; + private logger: ILogger; + + constructor(config: AsiaIspConfig) { + this.config = config; + this.http = config.http; + this.logger = config.logger; + + if (!this.config.accessKeyId) { + throw new Error("accessKeyId 不能为空"); + } + if (!this.config.accessKeySecret) { + throw new Error("accessKeySecret 不能为空"); + } + } + + /** + * 生成 HMAC-SHA1 签名,结果做 URL-safe Base64(替换 + → -,/ → _) + */ + private buildSignature(opts: { + body?: any; + method: string; + nonce: string; + queryString: string; + timestamp: string; + }): string { + const { body, method, nonce, queryString, timestamp } = opts; + const sk = this.config.accessKeySecret; + + const pieces: string[] = [`accessKeySecret=${sk}`]; + + // body 仅在 POST/PUT 时存在,对应 Python 的 body is not None + if (body !== undefined && body !== null) { + pieces.push(`body=${JSON.stringify(body)}`); + } + + pieces.push( + `method=${method}`, + `nonce=${nonce}`, + `queryString=${queryString}`, + `timestamp=${timestamp}`, + `uri=${URI}` + ); + + const message = pieces.join("&"); + const hmac = crypto.createHmac("sha1", sk).update(message).digest("base64"); + // URL-safe Base64 + return hmac.replace(/\+/g, "-").replace(/\//g, "_"); + } + + /** + * 通用 API 请求(完全对齐 Python 实现) + */ + async doRequest(req: { + method: string; + action: string; + data?: any; + }): Promise { + const { method, action, data } = req; + const nonce = String(Math.floor(Math.random() * 90000000) + 10000000); + const timestamp = Date.now().toString(); + const queryString = `action=${action}`; + + const signature = this.buildSignature({ + body: method === "POST" || method === "PUT" ? data : undefined, + method, + nonce, + queryString, + timestamp, + }); + + const headers: Record = { + "Content-Type": "application/json", + accessKeyId: this.config.accessKeyId, + nonce, + timestamp, + signature, + }; + + const url = `${BASE_URL}${URI}?${queryString}`; + + try { + const response = await this.http.request({ + url, + method, + headers, + data: method === "POST" || method === "PUT" ? data : undefined, + skipSslVerify: true, + logParams: false, + logRes: false, + logData: false, + }); + + if (response.code !== "0") { + this.logger.error(`接口请求失败: code=${response.code}, msg=${response.msg}`); + throw new Error(response.msg || "接口请求失败"); + } + + return response; + } catch (error: any) { + if (error.message && !error.message.includes("接口请求失败")) { + this.logger.error(`接口请求异常: ${error.message}`); + throw new Error(`接口请求异常: ${error.message}`); + } + throw error; + } + } + + /** + * 获取域名列表 + * GET /openapi/v3/stat?action=domainQueryList + */ + async getDomainList(): Promise { + const res = await this.doRequest({ + method: "GET", + action: "domainQueryList", + }); + const list = res.data || []; + this.logger.info(`获取域名列表成功,共 ${list.length} 个域名`); + return list; + } + + /** + * 获取证书列表 + * GET /openapi/v3/stat?action=certificateQueryList + */ + async getCertList(): Promise> { + const res = await this.doRequest({ + method: "GET", + action: "certificateQueryList", + }); + return res.data || []; + } + + /** + * 查询证书详情 + * GET /openapi/v3/stat?action=certificateQuery&certId=xxx + */ + async getCertDetail(certId: number): Promise { + const res = await this.doRequest({ + method: "GET", + action: `certificateQuery&certId=${certId}`, + }); + return res.data; + } + + /** + * 上传证书 + * POST /openapi/v3/stat?action=certificateUpload + * 返回证书ID + */ + async uploadCert(req: { cert: CertInfo; name?: string }): Promise { + const certReader = new CertReader(req.cert); + const certName = req.name || certReader.buildCertName(); + + const res = await this.doRequest({ + method: "POST", + action: "certificateUpload", + data: { + name: certName, + publicKey: req.cert.crt, + privateKey: req.cert.key, + }, + }); + + const certId = res.data; + this.logger.info(`上传证书成功,证书ID: ${certId}`); + return certId; + } + + /** + * 删除证书 + * DELETE /openapi/v3/stat?action=certificateDelete&certId=xxx + */ + async deleteCert(certId: number): Promise { + await this.doRequest({ + method: "DELETE", + action: `certificateDelete&certId=${certId}`, + }); + this.logger.info(`删除证书成功,证书ID: ${certId}`); + } + + /** + * 部署证书到 CDN 域名(修改域名配置,绑定证书) + * PUT /openapi/v3/stat?action=domainModify + */ + async deployCertToDomain(req: { + domain: string; + certId: number; + protocol: string; + }): Promise { + await this.doRequest({ + method: "PUT", + action: "domainModify", + data: { + domain: req.domain, + certId: `${req.certId}`, + protocol: req.protocol || "https", + }, + }); + this.logger.info(`部署证书到域名成功: ${req.domain}, certId=${req.certId}`); + } +} \ No newline at end of file diff --git a/packages/ui/certd-server/src/plugins/plugin-asiaisp/index.ts b/packages/ui/certd-server/src/plugins/plugin-asiaisp/index.ts new file mode 100644 index 000000000..cd4b41cd8 --- /dev/null +++ b/packages/ui/certd-server/src/plugins/plugin-asiaisp/index.ts @@ -0,0 +1,2 @@ +export * from "./access.js"; +export * from "./plugin-deploy-to-cdn.js"; diff --git a/packages/ui/certd-server/src/plugins/plugin-asiaisp/plugin-deploy-to-cdn.ts b/packages/ui/certd-server/src/plugins/plugin-asiaisp/plugin-deploy-to-cdn.ts new file mode 100644 index 000000000..749b8cc7e --- /dev/null +++ b/packages/ui/certd-server/src/plugins/plugin-asiaisp/plugin-deploy-to-cdn.ts @@ -0,0 +1,122 @@ +import { AbstractTaskPlugin, IsTaskPlugin, PageSearch, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline"; +import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert"; +import { createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from "@certd/plugin-lib"; +import { AsiaIspAccess } from "./access.js"; + +@IsTaskPlugin({ + name: "AsiaIspDeployToCDN", + title: "橙域网络-部署证书到CDN", + desc: "部署证书到橙域网络(asia-isp) CDN加速域名", + icon: "clarity:plugin-line", + group: pluginGroups.cdn.key, + default: { + strategy: { + runStrategy: RunStrategy.SkipWhenSucceed, + }, + }, +}) +export class AsiaIspDeployToCDN extends AbstractTaskPlugin { + @TaskInput({ + title: "域名证书", + helper: "请选择前置任务输出的域名证书", + component: { + name: "output-selector", + from: [...CertApplyPluginNames], + }, + required: true, + }) + cert!: CertInfo; + + @TaskInput(createCertDomainGetterInputDefine({ props: { required: false } })) + certDomains!: string[]; + + @TaskInput({ + title: "橙域网络授权", + component: { + name: "access-selector", + type: "asiaisp", + }, + required: true, + }) + accessId!: string; + + @TaskInput( + createRemoteSelectInputDefine({ + title: "加速域名", + helper: "选择要部署证书的橙域CDN加速域名", + action: AsiaIspDeployToCDN.prototype.onGetDomainList.name, + pager: true, + search: true, + pageSize: 10, + single: false, + watches: ["certDomains", "accessId"], + required: true, + }) + ) + domainList!: string[]; + + async onInstance() {} + + async execute(): Promise { + const access = await this.getAccess(this.accessId); + const client = await access.getClient(); + + this.logger.info("开始部署证书到橙域网络CDN"); + + // 1. 上传证书到橙域平台 + this.logger.info("上传证书到橙域网络..."); + const certId = await client.uploadCert({ + cert: this.cert, + }); + + // 2. 为每个选中的域名绑定证书 + for (const domain of this.domainList) { + this.logger.info(`部署证书到域名: ${domain}`); + await client.deployCertToDomain({ + domain, + certId, + protocol: "https", + }); + } + + this.logger.info(`证书部署完成,共处理 ${this.domainList.length} 个域名`); + } + + async onGetDomainList(data: PageSearch = {}) { + const access = await this.getAccess(this.accessId); + const client = await access.getClient(); + const list = await client.getDomainList(); + + if (!list || list.length === 0) { + throw new Error("没有找到加速域名"); + } + + // 客户端过滤:按搜索关键词匹配域名 + let filtered = list; + const keyword = data.searchKey?.trim().toLowerCase(); + if (keyword) { + filtered = list.filter((item: any) => item.domain?.toLowerCase().includes(keyword)); + } + + // 客户端分页 + const pageNo = data.pageNo || 1; + const pageSize = data.pageSize || 10; + const start = (pageNo - 1) * pageSize; + const paged = filtered.slice(start, start + pageSize); + + const options = paged.map((item: any) => { + return { + label: `${item.domain}${item.protocol === "https" ? " (HTTPS)" : ""}`, + value: item.domain, + domain: item.domain, + }; + }); + + return { + list: this.ctx.utils.options.buildGroupOptions(options, this.certDomains), + total: filtered.length, + }; + } +} + +new AsiaIspDeployToCDN();