diff --git a/packages/core/pipeline/src/context/index.ts b/packages/core/pipeline/src/context/index.ts index 5679590e..0dd6b147 100644 --- a/packages/core/pipeline/src/context/index.ts +++ b/packages/core/pipeline/src/context/index.ts @@ -11,7 +11,6 @@ export type PageSearch = { // sortOrder?: "asc" | "desc"; }; - export type PageRes = { pageNo?: number; pageSize?: number; diff --git a/packages/ui/certd-client/src/views/framework/home/dashboard/charts/pie-count.vue b/packages/ui/certd-client/src/views/framework/home/dashboard/charts/pie-count.vue index 9a508dc2..35950083 100644 --- a/packages/ui/certd-client/src/views/framework/home/dashboard/charts/pie-count.vue +++ b/packages/ui/certd-client/src/views/framework/home/dashboard/charts/pie-count.vue @@ -41,7 +41,7 @@ const option = ref({ center: ["60%", "50%"], name: "状态", type: "pie", - radius: "80%", + radius: ["30%", "70%"], avoidLabelOverlap: false, itemStyle: { borderRadius: 0, diff --git a/packages/ui/certd-server/src/plugins/index.ts b/packages/ui/certd-server/src/plugins/index.ts index 50e343e0..aa4ab492 100644 --- a/packages/ui/certd-server/src/plugins/index.ts +++ b/packages/ui/certd-server/src/plugins/index.ts @@ -31,3 +31,4 @@ export * from './plugin-namesilo/index.js' export * from './plugin-proxmox/index.js' export * from './plugin-wangsu/index.js' export * from './plugin-admin/index.js' +export * from './plugin-ksyun/index.js' diff --git a/packages/ui/certd-server/src/plugins/plugin-ksyun/access.ts b/packages/ui/certd-server/src/plugins/plugin-ksyun/access.ts new file mode 100644 index 00000000..6c18bb49 --- /dev/null +++ b/packages/ui/certd-server/src/plugins/plugin-ksyun/access.ts @@ -0,0 +1,110 @@ +import {AccessInput, BaseAccess, IsAccess} from "@certd/pipeline"; +import {KsyunClient} from './client.js' +import {CertInfo} from "@certd/plugin-cert"; + +/** + */ +@IsAccess({ + name: "ksyun", + title: "金山云授权", + desc: "", + icon: "svg:icon-ksyun" +}) +export class KsyunAccess extends BaseAccess { + + @AccessInput({ + title: 'AccessKeyID', + component: { + placeholder: 'AccessKeyID', + }, + helper: "[获取密钥](https://uc.console.ksyun.com/pro/iam/#/set/keyManage)", + 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.getCdnClient() + await this.getCertList({client}) + return "ok" + } + + + async getCertList(opts?:{client:KsyunClient,pageNo?:number;pageSize?:number}) { + const res = await opts.client.doRequest({ + action: "GetCertificates", + version: "2016-09-01", + method:"POST", + url:"/2016-09-01/cert/GetCertificates", + data:{ + PageNum:opts?.pageNo || 1, + PageSize: opts?.pageSize || 30 + } + }) + this.ctx.logger.info(res) + return res + } + + /** + * CertificateId 是 string 证书对应的唯一ID + * CertificateName 是 String 安全证书名称 + * ServerCertificate 是 String 域名对应的安全证书内容 + * PrivateKey + * @param opts + */ + async updateCert(opts:{ + client:KsyunClient, + certId:string, + certName:string, + cert:CertInfo + }){ + const res = await opts.client.doRequest({ + action: "SetCertificate", + version: "2016-09-01", + method:"POST", + url:"/2016-09-01/cert/SetCertificate", + data:{ + CertificateId: parseInt(opts.certId), + CertificateName: opts.certName, + ServerCertificate: opts.cert.crt, + PrivateKey: opts.cert.key + } + }) + this.ctx.logger.info(res) + return res + } + + async getCdnClient() { + return new KsyunClient({ + accessKeyId: this.accessKeyId, + secretAccessKey: this.accessKeySecret, + region: 'cn-beijing-6', + service: 'cdn', + endpoint: 'cdn.api.ksyun.com', + logger: this.ctx.logger, + http: this.ctx.http + }) + } +} + + +new KsyunAccess(); diff --git a/packages/ui/certd-server/src/plugins/plugin-ksyun/client.ts b/packages/ui/certd-server/src/plugins/plugin-ksyun/client.ts new file mode 100644 index 00000000..7a25af0d --- /dev/null +++ b/packages/ui/certd-server/src/plugins/plugin-ksyun/client.ts @@ -0,0 +1,351 @@ +import crypto from 'crypto'; +import querystring from 'querystring' +import {HttpClient, HttpRequestConfig, ILogger} from "@certd/basic"; + +export class KsyunClient { + + accessKeyId: string; + secretAccessKey: string; + region: string; + service: string; + endpoint: string; + logger: ILogger; + http: HttpClient + constructor(opts:{accessKeyId:string; secretAccessKey:string; region?:string; service :string;endpoint :string,logger:ILogger,http:HttpClient}) { + this.accessKeyId = opts.accessKeyId; + this.secretAccessKey = opts.secretAccessKey; + this.region = opts.region || 'cn-beijing-6'; + this.service = opts.service; + this.endpoint =opts.endpoint + this.logger = opts.logger; + this.http = opts.http; + } + + + async doRequest(opts: {action:string;version:string} &HttpRequestConfig){ + const config = this.signRequest({ + method: opts.method || 'GET', + url: opts.url || '/2016-09-01/domain/GetCdnDomains', + baseURL: `https://${this.endpoint}`, + params: opts.params, + headers: { + 'X-Action': opts.action, + 'X-Version': opts.version + }, + data: opts.data + }); + + try{ + return await this.http.request(config) + }catch (e) { + if (e.response?.data?.Error?.Message){ + throw new Error(e.response?.data?.Error?.Message) + } + throw e + } + + } + + /** + * 签名请求 + * @param {Object} config Axios 请求配置 + * @returns {Object} 签名后的请求配置 + */ + signRequest(config) { + // 确保有必要的配置 + if (!this.accessKeyId || !this.secretAccessKey) { + throw new Error('AccessKeyId and SecretAccessKey are required'); + } + + // 设置默认值 + config.method = config.method || 'GET'; + config.headers = config.headers || {}; + + // 获取当前时间并设置 X-Amz-Date + const requestDate = this.getRequestDate(); + config.headers['x-amz-date'] = requestDate; + + // 处理不同的请求方法 + let canonicalQueryString = ''; + let hashedPayload = this.hashPayload(config.data || ''); + + if (config.method.toUpperCase() === 'GET') { + // GET 请求 - 参数在 URL 中 + const urlParts = config.url.split('?'); + const path = urlParts[0]; + const query = urlParts[1] || ''; + + // 合并现有查询参数和额外参数 + const queryParams = { + ...querystring.parse(query), + ...(config.params || {}) + }; + + // 生成规范查询字符串 + canonicalQueryString = this.createCanonicalQueryString(queryParams); + config.url = `${path}?${canonicalQueryString}`; + config.params = {}; // 清空 params,因为已经合并到 URL 中 + } else { + // POST/PUT 等请求 - 参数在 body 中 + canonicalQueryString = ''; + if (config.data && typeof config.data === 'object') { + // 如果 data 是对象,转换为 JSON 字符串 + config.data = JSON.stringify(config.data); + hashedPayload = this.hashPayload(config.data); + } + } + + // 生成规范请求 + const canonicalRequest = this.createCanonicalRequest( + config.method, + config.url, + canonicalQueryString, + config.headers, + hashedPayload + ); + + // 生成签名字符串 + const credentialScope = this.createCredentialScope(requestDate); + const stringToSign = this.createStringToSign(requestDate, credentialScope, canonicalRequest); + + // 计算签名 + const signature = this.calculateSignature(requestDate, stringToSign); + + // 生成 Authorization 头 + const signedHeaders = this.getSignedHeaders(config.headers); + const authorizationHeader = this.createAuthorizationHeader( + credentialScope, + signedHeaders, + signature + ); + + // 添加 Authorization 头 + config.headers.Authorization = authorizationHeader; + + return config; + } + + /** + * 获取当前时间 (格式: YYYYMMDD'T'HHMMSS'Z') + * @returns {string} 格式化后的时间字符串 + */ + getRequestDate() { + const now = new Date(); + const year = now.getUTCFullYear(); + const month = String(now.getUTCMonth() + 1).padStart(2, '0'); + const day = String(now.getUTCDate()).padStart(2, '0'); + const hours = String(now.getUTCHours()).padStart(2, '0'); + const minutes = String(now.getUTCMinutes()).padStart(2, '0'); + const seconds = String(now.getUTCSeconds()).padStart(2, '0'); + + return `${year}${month}${day}T${hours}${minutes}${seconds}Z`; + } + + /** + * 哈希 payload + * @param {string} payload 请求体内容 + * @returns {string} 哈希后的16进制字符串 + */ + hashPayload(payload) { + if (typeof payload !== 'string') { + payload = ''; + } + return crypto.createHash('sha256').update(payload).digest('hex').toLowerCase(); + } + + /** + * 创建规范查询字符串 + * @param {Object} params 查询参数对象 + * @returns {string} 规范化的查询字符串 + */ + createCanonicalQueryString(params) { + // 对参数名和值进行 URI 编码 + const encodedParams = {}; + for (const key in params) { + if (params.hasOwnProperty(key)) { + const encodedKey = this.uriEncode(key); + const encodedValue = this.uriEncode(params[key].toString()); + encodedParams[encodedKey] = encodedValue; + } + } + + // 按 ASCII 顺序排序 + const sortedKeys = Object.keys(encodedParams).sort(); + + // 构建查询字符串 + return sortedKeys.map(key => `${key}=${encodedParams[key]}`).join('&'); + } + + /** + * URI 编码 (符合 AWS 规范) + * @param {string} str 要编码的字符串 + * @returns {string} 编码后的字符串 + */ + uriEncode(str) { + return encodeURIComponent(str) + .replace(/[^A-Za-z0-9\-_.~]/g, c => + '%' + c.charCodeAt(0).toString(16).toUpperCase()); + } + + /** + * 创建规范请求 + * @param {string} method HTTP 方法 + * @param {string} url 请求 URL + * @param {string} queryString 查询字符串 + * @param {Object} headers 请求头 + * @param {string} hashedPayload 哈希后的 payload + * @returns {string} 规范化的请求字符串 + */ + createCanonicalRequest(method, url, queryString, headers, hashedPayload) { + // 获取规范 URI + const urlObj = new URL(url, 'http://dummy.com'); // 使用虚拟基础 URL 来解析路径 + const canonicalUri = this.uriEncodePath(urlObj.pathname) || '/'; + + // 获取规范 headers 和 signed headers + const { canonicalHeaders, signedHeaders } = this.createCanonicalHeaders(headers); + + return [ + method.toUpperCase(), + canonicalUri, + queryString, + canonicalHeaders, + signedHeaders, + hashedPayload + ].join('\n'); + } + + /** + * URI 编码路径部分 + * @param {string} path 路径 + * @returns {string} 编码后的路径 + */ + uriEncodePath(path) { + // 分割路径为各个部分,分别编码 + return path.split('/').map(part => this.uriEncode(part)).join('/'); + } + + /** + * 创建规范 headers 和 signed headers + * @param {Object} headers 原始请求头 + * @returns {Object} { canonicalHeaders: string, signedHeaders: string } + */ + createCanonicalHeaders(headers) { + // 处理 headers + const headerMap:any = {}; + + // 标准化 headers + for (const key in headers) { + if (headers.hasOwnProperty(key)) { + const lowerKey = key.toLowerCase(); + let value = headers[key] + if (value) { + value = value.toString().replace(/\s+/g, ' ').trim(); + headerMap[lowerKey] = value; + } + } + } + + // 确保 host 和 x-amz-date 存在 + if (!headerMap.host) { + const url = headers.host ||this.endpoint || 'cdn.api.ksyun.com'; // 默认值 + headerMap.host = url.replace(/^https?:\/\//, '').split('/')[0]; + } + + // 按 header 名称排序 + const sortedHeaderNames = Object.keys(headerMap).sort(); + + // 构建规范 headers + let canonicalHeaders = ''; + for (const name of sortedHeaderNames) { + canonicalHeaders += `${name}:${headerMap[name]}\n`; + } + + // 构建 signed headers + const signedHeaders = sortedHeaderNames.join(';'); + + return { canonicalHeaders, signedHeaders }; + } + + /** + * 获取 signed headers + * @param {Object} headers 请求头 + * @returns {string} signed headers 字符串 + */ + getSignedHeaders(headers) { + const { signedHeaders } = this.createCanonicalHeaders(headers); + return signedHeaders; + } + + /** + * 创建信任状范围 + * @param {string} requestDate 请求日期 (YYYYMMDDTHHMMSSZ) + * @returns {string} 信任状范围字符串 + */ + createCredentialScope(requestDate) { + const date = requestDate.split('T')[0]; + return `${date}/${this.region}/${this.service}/aws4_request`; + } + + /** + * 创建签名字符串 + * @param {string} requestDate 请求日期 + * @param {string} credentialScope 信任状范围 + * @param {string} canonicalRequest 规范请求 + * @returns {string} 签名字符串 + */ + createStringToSign(requestDate, credentialScope, canonicalRequest) { + const algorithm = 'AWS4-HMAC-SHA256'; + const hashedCanonicalRequest = crypto.createHash('sha256') + .update(canonicalRequest) + .digest('hex') + .toLowerCase(); + + return [ + algorithm, + requestDate, + credentialScope, + hashedCanonicalRequest + ].join('\n'); + } + + /** + * 计算签名 + * @param {string} requestDate 请求日期 + * @param {string} stringToSign 签名字符串 + * @returns {string} 签名值 + */ + calculateSignature(requestDate, stringToSign) { + const date = requestDate.split('T')[0]; + const kDate = this.hmac(`AWS4${this.secretAccessKey}`, date); + const kRegion = this.hmac(kDate, this.region); + const kService = this.hmac(kRegion, this.service); + const kSigning = this.hmac(kService, 'aws4_request'); + + return this.hmac(kSigning, stringToSign, 'hex'); + } + + /** + * HMAC-SHA256 计算 + * @param {string|Buffer} key 密钥 + * @param {string} data 数据 + * @param {string} [encoding] 输出编码 + * @returns {string|Buffer} HMAC 结果 + */ + hmac(key, data, encoding = null) { + const hmac = crypto.createHmac('sha256', key); + hmac.update(data); + return encoding ? hmac.digest(encoding) : hmac.digest(); + } + + /** + * 创建 Authorization 头 + * @param {string} credentialScope 信任状范围 + * @param {string} signedHeaders signed headers + * @param {string} signature 签名值 + * @returns {string} Authorization 头值 + */ + createAuthorizationHeader(credentialScope, signedHeaders, signature) { + return `AWS4-HMAC-SHA256 Credential=${this.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; + } +} + diff --git a/packages/ui/certd-server/src/plugins/plugin-ksyun/index.ts b/packages/ui/certd-server/src/plugins/plugin-ksyun/index.ts new file mode 100644 index 00000000..02dc3945 --- /dev/null +++ b/packages/ui/certd-server/src/plugins/plugin-ksyun/index.ts @@ -0,0 +1,2 @@ +export * from "./plugins/index.js"; +export * from "./access.js"; diff --git a/packages/ui/certd-server/src/plugins/plugin-ksyun/plugins/index.ts b/packages/ui/certd-server/src/plugins/plugin-ksyun/plugins/index.ts new file mode 100644 index 00000000..705339de --- /dev/null +++ b/packages/ui/certd-server/src/plugins/plugin-ksyun/plugins/index.ts @@ -0,0 +1 @@ +import "./plugin-refresh-cert.js" diff --git a/packages/ui/certd-server/src/plugins/plugin-ksyun/plugins/plugin-refresh-cert.ts b/packages/ui/certd-server/src/plugins/plugin-ksyun/plugins/plugin-refresh-cert.ts new file mode 100644 index 00000000..786ab6ff --- /dev/null +++ b/packages/ui/certd-server/src/plugins/plugin-ksyun/plugins/plugin-refresh-cert.ts @@ -0,0 +1,133 @@ +import { + AbstractTaskPlugin, + IsTaskPlugin, + Pager, + PageSearch, + pluginGroups, + RunStrategy, + TaskInput +} from "@certd/pipeline"; +import {CertApplyPluginNames, CertInfo, CertReader} from "@certd/plugin-cert"; +import {createCertDomainGetterInputDefine, createRemoteSelectInputDefine} from "@certd/plugin-lib"; +import {KsyunAccess} from "../access.js"; + +@IsTaskPlugin({ + //命名规范,插件类型+功能(就是目录plugin-demo中的demo),大写字母开头,驼峰命名 + name: "KsyunRefreshCert", + title: "金山云-更新CDN证书", + desc: "金山云自动更新CDN证书", + icon: "svg:icon-lucky", + //插件分组 + group: pluginGroups.cdn.key, + needPlus: false, + default: { + //默认值配置照抄即可 + strategy: { + runStrategy: RunStrategy.SkipWhenSucceed + } + } +}) +//类名规范,跟上面插件名称(name)一致 +export class KsyunRefreshCDNCert 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: "ksyun" //固定授权类型 + }, + required: true //必填 + }) + accessId!: string; + // + + @TaskInput( + createRemoteSelectInputDefine({ + title: "证书Id", + helper: "要更新的金山云CDN证书id,如果这里没有,请先给cdn域名手动绑定一次证书", + action: KsyunRefreshCDNCert.prototype.onGetCertList.name, + pager: false, + search: false + }) + ) + certList!: string[]; + + //插件实例化时执行的方法 + async onInstance() { + } + + //插件执行方法 + async execute(): Promise { + const access = await this.getAccess(this.accessId); + + const certReader = new CertReader(this.cert) + const certName = certReader.buildCertName() + const client = await access.getCdnClient(); + for (const item of this.certList) { + this.logger.info(`----------- 开始更新证书:${item}`); + await access.updateCert({ + client, + certId: item, + certName, + cert: this.cert + }); + this.logger.info(`----------- 更新证书${item}成功`); + } + + this.logger.info("部署完成"); + } + + async onGetCertList(data: PageSearch = {}) { + const access = await this.getAccess(this.accessId); + + const client = await access.getCdnClient(); + const pager = new Pager(data) + const res = await access.getCertList({client, + pageNo: pager.pageNo , + pageSize: pager.pageSize + }) + const list = res.Certificates + if (!list || list.length === 0) { + throw new Error("没有找到证书,请先在控制台手动上传一次证书"); + } + + const total = res.TotalCount + + /** + * certificate-id + * name + * dns-names + */ + const options = list.map((item: any) => { + return { + label: `${item.CertificateName}<${item.CertificateId}-${item.ConfigDomainNames}>`, + value: item.CertificateId, + domain: item.ConfigDomainNames + }; + }); + return { + list: this.ctx.utils.options.buildGroupOptions(options, this.certDomains), + total: total, + pageNo: pager.pageNo, + pageSize: pager.pageSize + }; + } +} + +//实例化一下,注册插件 +new KsyunRefreshCDNCert();