mirror of https://github.com/certd/certd
perf: 支持部署到金山云CDN
parent
9e1e4eeec2
commit
dfa74a69f7
|
@ -11,7 +11,6 @@ export type PageSearch = {
|
|||
// sortOrder?: "asc" | "desc";
|
||||
};
|
||||
|
||||
|
||||
export type PageRes = {
|
||||
pageNo?: number;
|
||||
pageSize?: number;
|
||||
|
|
|
@ -41,7 +41,7 @@ const option = ref({
|
|||
center: ["60%", "50%"],
|
||||
name: "状态",
|
||||
type: "pie",
|
||||
radius: "80%",
|
||||
radius: ["30%", "70%"],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 0,
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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();
|
|
@ -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}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./plugins/index.js";
|
||||
export * from "./access.js";
|
|
@ -0,0 +1 @@
|
|||
import "./plugin-refresh-cert.js"
|
|
@ -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<void> {
|
||||
const access = await this.getAccess<KsyunAccess>(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<KsyunAccess>(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();
|
Loading…
Reference in New Issue