diff --git a/packages/ui/certd-server/src/plugins/plugin-acepanel/access.ts b/packages/ui/certd-server/src/plugins/plugin-acepanel/access.ts new file mode 100644 index 000000000..c43b6f71b --- /dev/null +++ b/packages/ui/certd-server/src/plugins/plugin-acepanel/access.ts @@ -0,0 +1,242 @@ +import {AccessInput, BaseAccess, IsAccess, Pager, PageSearch} from "@certd/pipeline"; +import {HttpRequestConfig} from "@certd/basic"; +import crypto from "crypto"; +import url from "url"; + + + +/** + */ +@IsAccess({ + name: "acepanel", + title: "ACEPanel授权", + desc: "", + icon: "svg:icon-lucky" +}) +export class AcepanelAccess extends BaseAccess { + + @AccessInput({ + title: "ACEPanel管理地址", + component: { + placeholder: "http://127.0.0.1:25475/entrance", + }, + helper:"请输入ACEPanel管理地址,格式为http://127.0.0.1:25475/entrance, 要带安全入口,最后面不要加/", + required: true, + }) + endpoint = ''; + + @AccessInput({ + title: '访问令牌ID', + component: { + name: "a-input-number", + vModel: "value", + }, + helper: "AcePanel控制台->设置->用户->访问令牌->创建访问令牌", + required: true, + }) + tokenId :number; + + @AccessInput({ + title: '访问令牌', + component: { + placeholder: 'AccessToken', + }, + helper: "创建访问令牌后复制该令牌填到这里", + required: true, + encrypt: true, + }) + accessToken = ''; + + @AccessInput({ + title: "忽略证书校验", + value: true, + component: { + name: "a-switch", + vModel: "checked", + }, + helper: "如果面板的url是https,且使用的是自签名证书,则需要开启此选项,其他情况可以关闭", + }) + skipSslVerify: boolean; + + @AccessInput({ + title: "测试", + component: { + name: "api-test", + action: "TestRequest" + }, + helper: "点击测试接口是否正常" + }) + testRequest = true; + + async onTestRequest() { + await this.testApi(); + return "ok" + } + + + +/** + * 计算字符串的SHA256哈希值 + */ + sha256Hash(text: string) { + return crypto.createHash('sha256').update(text || '').digest('hex'); +} + +/** + * 使用HMAC-SHA256算法计算签名 + */ + hmacSha256(key: string, message: string) { + return crypto.createHmac('sha256', key).update(message).digest('hex'); +} + +/** + * 为API请求生成签名 + */ + signRequest(method: string, apiUrl: string, body: string, id: number, token: string) { + // 解析URL + const parsedUrl = new url.URL(apiUrl); + const path = parsedUrl.pathname; + const query = parsedUrl.search.slice(1); // 移除开头的'?' + + // 规范化路径 + let canonicalPath = path; + if (!path.startsWith('/api')) { + const apiPos = path.indexOf('/api'); + if (apiPos !== -1) { + canonicalPath = path.slice(apiPos); + } + } + + // 构造规范化请求 + const canonicalRequest = [ + method, + canonicalPath, + query, + this.sha256Hash(body || '') + ].join('\n'); + + // 获取当前时间戳 + const timestamp = Math.floor(Date.now() / 1000); + + // 构造待签名字符串 + const stringToSign = [ + 'HMAC-SHA256', + timestamp, + this.sha256Hash(canonicalRequest) + ].join('\n'); + + // 计算签名 + const signature = this.hmacSha256(token, stringToSign); + + return { + timestamp, + signature, + id + }; +} + + + async doRequest(req: HttpRequestConfig) { + let endpoint = this.endpoint + if (endpoint.endsWith('/')) { + endpoint = endpoint.slice(0, -1); + } + const fullUrl = endpoint + req.url; + + + const method = req.method || 'GET'; + const body = req.data ? JSON.stringify(req.data) : ''; + const token = this.accessToken; + const tokenId = this.tokenId; + const signingData = this.signRequest(method, fullUrl, body, tokenId, token); + + // 准备HTTP请求头 + const headers = { + 'Content-Type': 'application/json', + 'X-Timestamp': signingData.timestamp, + 'Authorization': `HMAC-SHA256 Credential=${signingData.id}, Signature=${signingData.signature}` + }; + + // 发送请求 + const res = await this.ctx.http.request({ + ...req, + method, + headers, + url: fullUrl, + // baseURL: this.endpoint, + logRes: false, + skipSslVerify: this.skipSslVerify, + }); + + return res; + + } + + async testApi() { + + await this.getWebSiteList({ + pageNo: 1, + pageSize: 1, + }) + + return "ok" + } + + async getWebSiteList(opts: PageSearch) { + const pager = new Pager(opts); + const req = { + url: `/api/website?limit=${pager.pageSize}&page=${pager.pageNo}&type=all`, + method: "GET", + }; + return await this.doRequest(req); + } + + async uploadCert(cert: string, key: string) { + const req = { + url: "/api/cert/cert/upload", + method: "POST", + data: { + cert, + key + } + }; + return await this.doRequest(req); + } + + async deployCert(certId: number, websiteId: number) { + const req = { + url: `/api/cert/cert/${certId}/deploy`, + method: "POST", + data: { + id: certId, + website_id: websiteId + } + }; + return await this.doRequest(req); + } + + async updatePanelCert(cert: string, key: string) { + + const oldSettingRes = await this.doRequest({ + url: "/api/setting", + method: "GET", + }); + + const oldSetting = oldSettingRes.data || {}; + const req = { + url: "/api/setting", + method: "POST", + data: { + ...oldSetting, + acme: false, + https: true, + cert, + key + } + }; + return await this.doRequest(req); + } + +} + +new AcepanelAccess(); \ No newline at end of file diff --git a/packages/ui/certd-server/src/plugins/plugin-acepanel/index.ts b/packages/ui/certd-server/src/plugins/plugin-acepanel/index.ts new file mode 100644 index 000000000..68f7e466c --- /dev/null +++ b/packages/ui/certd-server/src/plugins/plugin-acepanel/index.ts @@ -0,0 +1,2 @@ +export * from "./plugins/index.js"; +export * from "./access.js"; \ No newline at end of file diff --git a/packages/ui/certd-server/src/plugins/plugin-acepanel/plugins/index.ts b/packages/ui/certd-server/src/plugins/plugin-acepanel/plugins/index.ts new file mode 100644 index 000000000..244eff2bf --- /dev/null +++ b/packages/ui/certd-server/src/plugins/plugin-acepanel/plugins/index.ts @@ -0,0 +1,3 @@ +export * from "./plugin-deploy-to-website.js"; +export * from "./plugin-panel-cert.js"; + diff --git a/packages/ui/certd-server/src/plugins/plugin-acepanel/plugins/plugin-deploy-to-website.ts b/packages/ui/certd-server/src/plugins/plugin-acepanel/plugins/plugin-deploy-to-website.ts new file mode 100644 index 000000000..cdd393ff9 --- /dev/null +++ b/packages/ui/certd-server/src/plugins/plugin-acepanel/plugins/plugin-deploy-to-website.ts @@ -0,0 +1,102 @@ +import {IsTaskPlugin, PageSearch, pluginGroups, RunStrategy, TaskInput} from "@certd/pipeline"; +import {CertApplyPluginNames, CertInfo} from "@certd/plugin-cert"; +import {createCertDomainGetterInputDefine, createRemoteSelectInputDefine} from "@certd/plugin-lib"; +import {AcepanelAccess} from "../access.js"; +import { AbstractPlusTaskPlugin } from "@certd/plugin-plus"; + +@IsTaskPlugin({ + name: "AcepanelDeployToWebsite", + title: "ACEPanel-部署到网站", + desc: "上传证书并部署到指定网站", + icon: "svg:icon-lucky", + group: pluginGroups.panel.key, + needPlus: true, + default: { + strategy: { + runStrategy: RunStrategy.SkipWhenSucceed + } + } +}) + +export class AcepanelDeployToWebsite extends AbstractPlusTaskPlugin { + @TaskInput({ + title: "域名证书", + helper: "请选择前置任务输出的域名证书", + component: { + name: "output-selector", + from: [...CertApplyPluginNames] + } + }) + cert!: CertInfo; + + @TaskInput(createCertDomainGetterInputDefine({ props: { required: false } })) + certDomains!: string[]; + + @TaskInput({ + title: "ACEPanel授权", + component: { + name: "access-selector", + type: "acepanel" + }, + required: true + }) + accessId!: string; + + @TaskInput( + createRemoteSelectInputDefine({ + title: "部署网站", + helper: "选择需要部署证书的网站", + action: AcepanelDeployToWebsite.prototype.onGetWebsiteList.name, + pager: false, + search: false + }) + ) + websiteList!: number[]; + + async onInstance() { + } + + async onGetWebsiteList(data: PageSearch = {}) { + const access = await this.getAccess(this.accessId); + const res = await access.getWebSiteList(data); + const items = res.data.items; + if (!items || items.length === 0) { + throw new Error("没有找到网站"); + } + const options = items.map((item: any) => { + return { + label: `${item.name} (${item.domains.join(', ')})`, + value: item.id, + domain: item.domains + }; + }); + return { + list: this.ctx.utils.options.buildGroupOptions(options, this.certDomains), + }; + } + + async execute(): Promise { + const access = await this.getAccess(this.accessId); + + // 上传证书 + this.logger.info("开始上传证书"); + const result = await access.uploadCert(this.cert.crt, this.cert.key); + const certId = result.data.id; + this.logger.info(`证书上传成功,证书ID:${certId}`); + this.logger.info(`证书域名:${result.data.domains.join(', ')}`); + + // 部署证书到选择的网站 + if (this.websiteList && this.websiteList.length > 0) { + this.logger.info(`开始部署证书到 ${this.websiteList.length} 个网站`); + for (const websiteId of this.websiteList) { + this.logger.info(`部署证书到网站ID:${websiteId}`); + await access.deployCert(certId, websiteId); + this.logger.info(`证书部署到网站ID:${websiteId} 成功`); + } + } + + this.logger.info("部署完成"); + } +} + +new AcepanelDeployToWebsite(); \ No newline at end of file diff --git a/packages/ui/certd-server/src/plugins/plugin-acepanel/plugins/plugin-panel-cert.ts b/packages/ui/certd-server/src/plugins/plugin-acepanel/plugins/plugin-panel-cert.ts new file mode 100644 index 000000000..7dc19d311 --- /dev/null +++ b/packages/ui/certd-server/src/plugins/plugin-acepanel/plugins/plugin-panel-cert.ts @@ -0,0 +1,53 @@ +import {IsTaskPlugin, pluginGroups, RunStrategy, TaskInput} from "@certd/pipeline"; +import {CertApplyPluginNames, CertInfo} from "@certd/plugin-cert"; +import {AcepanelAccess} from "../access.js"; +import { AbstractPlusTaskPlugin } from "@certd/plugin-plus"; + +@IsTaskPlugin({ + name: "AcepanelPanelCert", + title: "ACEPanel-面板证书", + desc: "部署ACEPanel面板证书", + icon: "svg:icon-lucky", + group: pluginGroups.panel.key, + needPlus: true, + default: { + strategy: { + runStrategy: RunStrategy.SkipWhenSucceed + } + } +}) + +export class AcepanelPanelCert extends AbstractPlusTaskPlugin { + @TaskInput({ + title: "域名证书", + helper: "请选择前置任务输出的域名证书", + component: { + name: "output-selector", + from: [...CertApplyPluginNames] + } + }) + cert!: CertInfo; + + @TaskInput({ + title: "ACEPanel授权", + component: { + name: "access-selector", + type: "acepanel" + }, + required: true + }) + accessId!: string; + + async onInstance() { + } + + async execute(): Promise { + const access = await this.getAccess(this.accessId); + + this.logger.info("开始部署面板证书"); + await access.updatePanelCert(this.cert.crt, this.cert.key); + this.logger.info("面板证书部署完成"); + } +} + +new AcepanelPanelCert(); \ No newline at end of file