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,160 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
import { HttpClient } from "@certd/basic";
import { OnePanelClient } from "./client.js";
/**
* 这个注解将注册一个授权配置
* 在certd的后台管理系统中,用户可以选择添加此类型的授权
*/
@IsAccess({
name: "1panel",
title: "1panel授权",
desc: "账号和密码",
icon: "svg:icon-onepanel",
})
export class OnePanelAccess extends BaseAccess {
@AccessInput({
title: "1Panel面板的url",
component: {
placeholder: "http://xxxx.com:1231",
},
helper: "不要带安全入口",
required: true,
})
baseUrl = "";
@AccessInput({
title: "安全入口",
component: {
placeholder: "登录的安全入口",
},
encrypt: true,
required: false,
})
safeEnter = "";
@AccessInput({
title: "授权方式",
component: {
name: "a-select",
vModel: "value",
options: [
{ label: "模拟登录【不推荐】", value: "password" },
{ label: "接口密钥【推荐】", value: "apikey" },
],
},
required: true,
})
type = "";
@AccessInput({
title: "接口版本",
value: "v1",
component: {
placeholder: "v1 / v2",
name: "a-select",
vModel: "value",
options: [
{ label: "v1", value: "v1" },
{ label: "v2", value: "v2" },
],
},
required: true,
})
apiVersion = "v1";
@AccessInput({
title: "用户名",
component: {
placeholder: "username",
},
mergeScript: `
return {
show: ctx.compute(({form})=>{
return form.access.type === 'password';
})
}
`,
required: true,
})
username = "";
@AccessInput({
title: "密码",
component: {
placeholder: "password",
},
helper: "",
mergeScript: `
return {
show: ctx.compute(({form})=>{
return form.access.type === 'password';
})
}
`,
required: true,
encrypt: true,
})
password = "";
@AccessInput({
title: "接口密钥",
component: {
placeholder: "接口密钥",
},
mergeScript: `
return {
show: ctx.compute(({form})=>{
return form.access.type === 'apikey';
})
}
`,
helper: "面板设置->API接口中获取",
required: true,
encrypt: true,
})
apiKey = "";
@AccessInput({
title: "忽略证书校验",
value: true,
component: {
name: "a-switch",
vModel: "checked",
},
helper: "如果面板的url是https,且使用的是自签名证书,则需要开启此选项,其他情况可以关闭",
})
skipSslVerify = true;
@AccessInput({
title: "测试",
component: {
name: "api-test",
action: "onTestRequest",
},
helper: "点击测试接口看是否正常\nIP需要加白名单,如果是同一台机器部署的,可以试试面板的url使用网卡docker0的ip,白名单使用172.16.0.0/12",
})
testRequest = true;
async onTestRequest() {
const http: HttpClient = this.ctx.http;
const client = new OnePanelClient({
logger: this.ctx.logger,
http,
access: this,
utils: this.ctx.utils,
});
await client.doRequest({
url: `/api/${this.apiVersion}/websites/ssl/search`,
method: "post",
data: {
page: 1,
pageSize: 1,
},
});
return "ok";
}
}
new OnePanelAccess();
@@ -0,0 +1,228 @@
import { HttpClient, HttpRequestConfig, ILogger } from "@certd/basic";
import { OnePanelAccess } from "./access.js";
export class OnePanelClient {
access: OnePanelAccess;
http: HttpClient;
logger: ILogger;
utils: any;
token: string;
constructor(opts: { access: OnePanelAccess; http: HttpClient; logger: ILogger; utils: any }) {
this.access = opts.access;
this.http = opts.http;
this.logger = opts.logger;
this.utils = opts.utils;
}
//
// //http://xxx:xxxx/1panel/swagger/index.html#/App/get_apps__key
// async execute(): Promise<void> {
// //login 获取token
// /**
// * curl 'http://127.0.0.1:7001/api/v1/auth/login' --data-binary '{"name":"admin_test","password":"admin_test1234","ignoreCaptcha":true,"captcha":"","captchaID":"nY8Cqeut3TjZMfJMAz0k","authMethod":"jwt","language":"zh"}' -H 'EntranceCode: emhhbmd5eg=='
// * curl 'http://127.0.0.1:7001/api/v1/dashboard/current/all/all' -H 'PanelAuthorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJJRCI6MCwiTmFtZSI6ImFkbWluX3Rlc3QiLCJCdWZmZXJUaW1lIjozNjAwLCJpc3MiOiIxUGFuZWwiLCJleHAiOjE3MDkyODg4MDl9.pdknJdjLY4Fp8wCE9Gvaiic2rLoSdvUSJB9ossyya_I'
// */
// const sslIds = this.sslIds;
// for (const sslId of sslIds) {
// try {
// const certRes = await this.get1PanelCertInfo(sslId);
// if (!this.isNeedUpdate(certRes)) {
// continue;
// }
//
// const uploadRes = await this.doRequest({
// url: "/api/v1/websites/ssl/upload",
// method: "post",
// data: {
// sslIds,
// certificate: this.cert.crt,
// certificatePath: "",
// description: certRes.description || this.appendTimeSuffix("certd"),
// privateKey: this.cert.key,
// privateKeyPath: "",
// sslID: sslId,
// type: "paste",
// },
// });
// console.log("uploadRes", JSON.stringify(uploadRes));
// } catch (e) {
// this.logger.warn(`更新证书(id:${sslId})失败`, e);
// this.logger.info("可能1Panel正在重启,等待10秒后检查证书是否更新成功");
// await this.ctx.utils.sleep(10000);
// const certRes = await this.get1PanelCertInfo(sslId);
// if (!this.isNeedUpdate(certRes)) {
// continue;
// }
// throw e;
// }
// }
// }
async get1PanelCertInfo(sslId: string) {
const certRes = await this.doRequest({
url: `/api/${this.access.apiVersion}/websites/ssl/${sslId}`,
method: "get",
});
if (!certRes) {
throw new Error(`没有找到证书(id:${sslId}),请先在1Panel中手动上传证书,后续才可以自动更新`);
}
return certRes;
}
async doRequest(config: { currentNode?: string } & HttpRequestConfig<any>) {
const tokenHeaders = await this.getAccessToken();
config.headers = {
...tokenHeaders,
};
if (config.currentNode) {
config.headers.CurrentNode = this.getNodeValue(config.currentNode);
delete config.currentNode;
}
return await this.doRequestWithoutAuth(config);
}
async doRequestWithoutAuth(config: HttpRequestConfig<any>) {
config.baseURL = this.access.baseUrl;
config.skipSslVerify = this.access.skipSslVerify ?? false;
config.logRes = false;
config.logParams = false;
const res = await this.http.request(config);
if (config.returnOriginRes) {
return res;
}
if (res.code === 200) {
return res.data;
}
throw new Error(res.message);
}
async getCookie(name: string) {
// https://www.docmirror.cn:20001/api/v1/auth/language
const response = await this.doRequestWithoutAuth({
url: `/api/${this.access.apiVersion}/auth/language`,
method: "GET",
returnOriginRes: true,
});
const cookies = response.headers["set-cookie"];
//根据name 返回对应的cookie
const found = cookies.find(cookie => cookie.includes(name));
if (!found) {
return null;
}
const cookie = found.split(";")[0];
return cookie.substring(cookie.indexOf("=") + 1);
}
async encryptPassword(password: string) {
const rsaPublicKeyText = await this.getCookie("panel_public_key");
if (!rsaPublicKeyText) {
return password;
}
// 使用rsa加密
const { encryptPassword } = await import("./util.js");
return encryptPassword(rsaPublicKeyText, password);
}
async getAccessToken() {
if (this.access.type === "apikey") {
return this.getAccessTokenByApiKey();
} else {
return await this.getAccessTokenByPassword();
}
}
async getAccessTokenByApiKey() {
/**
* Token = md5('1panel' + API-Key + UnixTimestamp)
* 组成部分:
* 固定前缀: '1panel'
* API-Key: 面板 API 接口密钥
* UnixTimestamp: 当前的时间戳(秒级)
* 请求 Header 设计¶
* 每次请求必须携带以下两个 Header:
*
* Header 名称 说明
* 1Panel-Token 自定义的 Token 值
* 1Panel-Timestamp 当前时间戳
* 示例请求头:¶
*
* curl -X GET "http://localhost:4004/api/v1/dashboard/current" \
* -H "1Panel-Token: <1panel_token>" \
* -H "1Panel-Timestamp: <current_unix_timestamp>"
*/
const timestamp = Math.floor(Date.now() / 1000);
const token = this.utils.hash.md5(`1panel${this.access.apiKey}${timestamp}`);
return {
"1Panel-Token": token,
"1Panel-Timestamp": timestamp,
};
}
async getAccessTokenByPassword() {
// console.log("getAccessToken", this);
// const tokenCacheKey = `1panel-token-${this.accessId}`;
// let token = this.utils.cache.get(tokenCacheKey);
// if (token) {
// return token;
// }
if (this.token) {
return {
PanelAuthorization: this.token,
};
}
let password = this.access.password;
password = await this.encryptPassword(password);
const loginRes = await this.doRequestWithoutAuth({
url: `/api/${this.access.apiVersion}/auth/login`,
method: "post",
headers: {
EntranceCode: Buffer.from(this.access.safeEnter).toString("base64"),
},
data: {
name: this.access.username,
password: password,
ignoreCaptcha: true,
captcha: "",
captchaID: "",
authMethod: "jwt",
language: "zh",
},
});
this.token = loginRes.token;
return {
PanelAuthorization: this.token,
};
}
async onGetSSLIds() {
// if (!isPlus()) {
// throw new Error("自动获取站点列表为专业版功能,您可以手动输入证书id进行部署");
// }
const res = await this.doRequest({
url: `/api/${this.access.apiVersion}/websites/ssl/search`,
method: "post",
data: {
page: 1,
pageSize: 99999,
},
});
if (!res?.items) {
throw new Error("没有找到证书,请先在1Panel中手动上传证书,并关联站点,后续才可以自动更新");
}
const options = res.items.map(item => {
return {
label: `${item.primaryDomain}<${item.id},${item.description || "无备注"}>`,
value: item.id,
};
});
return options;
}
getNodeValue(node?: string) {
const node_master_key = "local";
const _value = node || node_master_key;
return encodeURIComponent(_value);
}
}
@@ -0,0 +1,3 @@
export * from "./plugins/index.js";
export * from "./access.js";
export * from "./client.js";
@@ -0,0 +1,212 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert";
import { OnePanelAccess } from "../access.js";
import { createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from "@certd/plugin-lib";
import { OnePanelClient } from "../client.js";
@IsTaskPlugin({
name: "1PanelDeployToWebsitePlugin",
title: "1Panel-部署证书到1Panel",
icon: "svg:icon-onepanel",
desc: "更新1Panel的证书",
group: pluginGroups.panel.key,
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
needPlus: false,
})
export class OnePanelDeployToWebsitePlugin extends AbstractTaskPlugin {
//证书选择,此项必须要有
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames],
},
required: true,
})
cert!: CertInfo;
@TaskInput(createCertDomainGetterInputDefine())
certDomains!: string[];
//授权选择框
@TaskInput({
title: "1Panel授权",
helper: "1Panel授权",
component: {
name: "access-selector",
type: "1panel",
},
required: true,
})
accessId!: string;
@TaskInput(
createRemoteSelectInputDefine({
title: "1Panel节点",
helper: "要更新的1Panel证书的节点信息,目前只有v2存在此概念",
typeName: "OnePanelDeployToWebsitePlugin",
action: OnePanelDeployToWebsitePlugin.prototype.onGetNodes.name,
value: "local",
required: true,
})
)
currentNode!: string;
@TaskInput(
createRemoteSelectInputDefine({
title: "1Panel证书ID",
typeName: "1PanelDeployToWebsitePlugin",
action: OnePanelDeployToWebsitePlugin.prototype.onGetSSLIds.name,
watches: ["accessId"],
helper: "要更新的1Panel证书id,选择授权之后,从下拉框中选择\nIP需要加白名单,如果是同一台机器部署的,可以试试172.16.0.0/12",
required: true,
})
)
sslIds!: string[];
access: OnePanelAccess;
async onInstance() {
this.access = await this.getAccess(this.accessId);
}
//http://xxx:xxxx/1panel/swagger/index.html#/App/get_apps__key
async execute(): Promise<void> {
//login 获取token
/**
* curl 'http://127.0.0.1:7001/api/v1/auth/login' --data-binary '{"name":"admin_test","password":"admin_test1234","ignoreCaptcha":true,"captcha":"","captchaID":"nY8Cqeut3TjZMfJMAz0k","authMethod":"jwt","language":"zh"}' -H 'EntranceCode: emhhbmd5eg=='
* curl 'http://127.0.0.1:7001/api/v1/dashboard/current/all/all' -H 'PanelAuthorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJJRCI6MCwiTmFtZSI6ImFkbWluX3Rlc3QiLCJCdWZmZXJUaW1lIjozNjAwLCJpc3MiOiIxUGFuZWwiLCJleHAiOjE3MDkyODg4MDl9.pdknJdjLY4Fp8wCE9Gvaiic2rLoSdvUSJB9ossyya_I'
*/
const client = new OnePanelClient({
access: this.access,
http: this.http,
logger: this.logger,
utils: this.ctx.utils,
});
const sslIds = this.sslIds;
for (const sslId of sslIds) {
try {
const certRes = await this.get1PanelCertInfo(client, sslId);
if (!this.isNeedUpdate(certRes)) {
continue;
}
const uploadRes = await client.doRequest({
url: `/api/${this.access.apiVersion}/websites/ssl/upload`,
method: "post",
data: {
sslIds,
certificate: this.cert.crt,
certificatePath: "",
description: certRes.description || this.appendTimeSuffix("certd"),
privateKey: this.cert.key,
privateKeyPath: "",
sslID: sslId,
type: "paste",
},
currentNode: this.currentNode,
});
console.log("uploadRes", JSON.stringify(uploadRes));
} catch (e) {
this.logger.warn(`更新证书(id:${sslId})失败`, e);
this.logger.info("可能1Panel正在重启,等待10秒后检查证书是否更新成功");
await this.ctx.utils.sleep(10000);
const certRes = await this.get1PanelCertInfo(client, sslId);
if (!this.isNeedUpdate(certRes)) {
continue;
}
throw e;
}
}
}
isNeedUpdate(certRes: any) {
if (certRes.pem === this.cert.crt && certRes.key === this.cert.key) {
this.logger.info(`证书(id:${certRes.id})已经是最新的了,不需要更新`);
return false;
}
return true;
}
async get1PanelCertInfo(client: OnePanelClient, sslId: string) {
const certRes = await client.doRequest({
url: `/api/${this.access.apiVersion}/websites/ssl/${sslId}`,
method: "get",
currentNode: this.currentNode,
});
if (!certRes) {
throw new Error(`没有找到证书(id:${sslId}),请先在1Panel中手动上传证书,后续才可以自动更新`);
}
return certRes;
}
async onGetNodes() {
const options = [{ label: "主节点", value: "local" }];
if (this.access.apiVersion === "v1") {
return options;
}
if (!this.access) {
throw new Error("请先选择授权");
}
const client = new OnePanelClient({
access: this.access,
http: this.http,
logger: this.logger,
utils: this.ctx.utils,
});
const resp = await client.doRequest({
url: `/api/${this.access.apiVersion}/core/nodes/list`,
method: "post",
data: {},
});
// console.log('resp', resp)
return [...options, ...(resp?.map(item => ({ label: `${item.addr}(${item.name})`, value: item.name })) || [])];
}
// requestHandle
async onGetSSLIds() {
// if (!isPlus()) {
// throw new Error("自动获取站点列表为专业版功能,您可以手动输入证书id进行部署");
// }
if (!this.access) {
throw new Error("请先选择授权");
}
const client = new OnePanelClient({
access: this.access,
http: this.http,
logger: this.logger,
utils: this.ctx.utils,
});
const res = await client.doRequest({
url: `api/${this.access.apiVersion}/websites/ssl/search`,
method: "post",
data: {
page: 1,
pageSize: 99999,
},
currentNode: this.currentNode,
});
if (!res?.items) {
throw new Error("没有找到证书,请先在1Panel中手动上传证书,并关联站点,后续才可以自动更新");
}
const list = res.items.map(item => {
const domains = item.domains ? [] : item.domains.split(",");
const allDomains = [item.primaryDomain, ...domains];
return {
label: `${item.primaryDomain}<${item.id},${item.description || "无备注"}>`,
value: item.id,
domain: allDomains,
};
});
return this.ctx.utils.options.buildGroupOptions(list, this.certDomains);
}
}
new OnePanelDeployToWebsitePlugin();
@@ -0,0 +1 @@
export * from "./deploy-to-website.js";
@@ -0,0 +1,65 @@
import CryptoJS from "crypto-js";
import crypto from "crypto";
function rsaEncrypt(data: string, publicKey: string) {
if (!data) {
return data;
}
// const jsEncrypt = new JSEncrypt();
// jsEncrypt.setPublicKey(publicKey);
// return jsEncrypt.encrypt(data);
// RSA encryption is not supported in browser
//换一种nodejs的实现
return crypto
.publicEncrypt(
{
key: publicKey,
padding: crypto.constants.RSA_PKCS1_PADDING, // 明确指定填充方式
},
Buffer.from(data, "utf-8")
)
.toString("base64");
}
function aesEncrypt(data: string, key: string) {
const keyBytes = CryptoJS.enc.Utf8.parse(key);
const iv = CryptoJS.lib.WordArray.random(16);
const encrypted = CryptoJS.AES.encrypt(data, keyBytes, {
iv: iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
});
return iv.toString(CryptoJS.enc.Base64) + ":" + encrypted.toString();
}
function urlDecode(value: string): string {
return decodeURIComponent(value.replace(/\+/g, " "));
}
function generateAESKey(): string {
const keyLength = 16;
const randomBytes = new Uint8Array(keyLength);
crypto.getRandomValues(randomBytes);
return Array.from(randomBytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
export const encryptPassword = (rsaPublicKeyText: string, password: string) => {
if (!password) {
return "";
}
// let rsaPublicKeyText = getCookie("panel_public_key");
if (!rsaPublicKeyText) {
console.log("RSA public key not found");
return password;
}
rsaPublicKeyText = urlDecode(rsaPublicKeyText);
const aesKey = generateAESKey();
rsaPublicKeyText = rsaPublicKeyText.replaceAll('"', "");
const rsaPublicKey = atob(rsaPublicKeyText);
const keyCipher = rsaEncrypt(aesKey, rsaPublicKey);
const passwordCipher = aesEncrypt(password, aesKey);
return `${keyCipher}:${passwordCipher}`;
};
@@ -0,0 +1,48 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
@IsAccess({
name: "alipay",
title: "支付宝",
icon: "ion:logo-alipay",
})
export class AlipayAccess extends BaseAccess {
/**
* appId: "<-- 请填写您的AppId,例如:2019091767145019 -->",
* privateKey: "<-- 请填写您的应用私钥,例如:MIIEvQIBADANB ... ... -->",
* alipayPublicKey: "<-- 请填写您的支付宝公钥,例如:MIIBIjANBg... -->",
*/
@AccessInput({
title: "AppId",
component: {
placeholder: "201909176714xxxx",
},
required: true,
encrypt: false,
})
appId: string;
@AccessInput({
title: "应用私钥",
component: {
placeholder: "MIIEvQIBADANB...",
name: "a-textarea",
rows: 3,
},
required: true,
encrypt: true,
})
privateKey: string;
@AccessInput({
title: "支付宝公钥",
component: {
name: "a-textarea",
rows: 3,
placeholder: "MIIBIjANBg...",
},
required: true,
encrypt: true,
})
alipayPublicKey: string;
}
new AlipayAccess();
@@ -0,0 +1 @@
export * from "./access.js";
@@ -0,0 +1,61 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
import { BaiduYunCertClient } from "./client.js";
/**
* 这个注解将注册一个授权配置
* 在certd的后台管理系统中,用户可以选择添加此类型的授权
*/
@IsAccess({
name: "baidu",
title: "百度云授权",
desc: "",
icon: "ant-design:baidu-outlined",
order: 2,
})
export class BaiduAccess extends BaseAccess {
@AccessInput({
title: "AccessKey",
component: {
placeholder: "AccessKey",
},
helper: "[百度智能云->安全认证获取](https://console.bce.baidu.com/iam/#/iam/accesslist)",
required: true,
encrypt: false,
})
accessKey = "";
@AccessInput({
title: "SecretKey",
component: {
placeholder: "SecretKey",
},
helper: "",
required: true,
encrypt: true,
})
secretKey = "";
@AccessInput({
title: "测试",
component: {
name: "api-test",
action: "onTestRequest",
},
helper: "点击测试接口看是否正常",
})
testRequest = true;
async onTestRequest() {
const certClient = new BaiduYunCertClient({
access: this,
logger: this.ctx.logger,
http: this.ctx.http,
});
const res = await certClient.getCertList();
this.ctx.logger.info("测试接口返回", res);
return "ok";
}
}
new BaiduAccess();
@@ -0,0 +1,191 @@
import { HttpClient, HttpRequestConfig, ILogger } from "@certd/basic";
import { BaiduAccess } from "./access.js";
import crypto from "crypto";
import { CertInfo } from "@certd/plugin-cert";
export type BaiduYunClientOptions = {
access: BaiduAccess;
logger: ILogger;
http: HttpClient;
};
export type BaiduYunReq = {
host: string;
uri: string;
body?: any;
headers?: any;
query?: any;
method: string;
};
export class BaiduYunClient {
opts: BaiduYunClientOptions;
constructor(opts: BaiduYunClientOptions) {
this.opts = opts;
}
// 调用百度云接口,传接口uri和json参数
async doRequest(req: BaiduYunReq, config?: HttpRequestConfig) {
const host = req.host;
const timestamp = this.getTimestampString();
const queryString = this.getQueryString(req.query);
const Authorization = this.getAuthString(host, req.method, req.uri, queryString, timestamp);
const ContentType = "application/json; charset=utf-8";
let url = "https://" + host + req.uri;
if (req.query) {
url += "?" + queryString;
}
const res = await this.opts.http.request({
url: url,
method: req.method,
data: req.body,
headers: {
Authorization: Authorization,
"Content-Type": ContentType,
Host: host,
"x-bce-date": timestamp,
...req.headers,
},
...config,
});
if (res.code) {
throw new Error(`请求失败:${res.message}`);
}
return res;
}
// 获取UTC时间
getTimestampString() {
return new Date().toISOString().replace(/\.\d*/, "");
}
// 获取参数拼接字符串
getQueryString(params) {
let queryString = "";
let paramKeyArray = [];
if (params) {
for (const key in params) {
paramKeyArray.push(key);
}
paramKeyArray = paramKeyArray.sort();
}
if (paramKeyArray && paramKeyArray.length > 0) {
for (const key of paramKeyArray) {
queryString += encodeURIComponent(key) + "=" + encodeURIComponent(params[key]) + "&";
}
queryString = queryString.substring(0, queryString.length - 1);
}
return queryString;
}
uriEncode(input: string, encodeSlash = false) {
let result = "";
for (let i = 0; i < input.length; i++) {
const ch = input.charAt(i);
if ((ch >= "A" && ch <= "Z") || (ch >= "a" && ch <= "z") || (ch >= "0" && ch <= "9") || ch === "_" || ch === "-" || ch === "~" || ch === ".") {
result += ch;
} else if (ch === "/") {
result += encodeSlash ? "%2F" : ch;
} else {
result += this.toHexUTF8(ch);
}
}
return result;
}
toHexUTF8(ch) {
// Convert character to UTF-8 bytes and return the hex representation
const utf8Bytes = new TextEncoder().encode(ch);
let hexString = "";
for (const byte of utf8Bytes) {
hexString += "%" + byte.toString(16).padStart(2, "0").toUpperCase();
}
return hexString;
}
// 签名
getAuthString(Host: string, Method: string, CanonicalURI: string, CanonicalQueryString: string, timestamp: string) {
// 1
const expirationPeriodInSeconds = 120;
const authStringPrefix = `bce-auth-v1/${this.opts.access.accessKey}/${timestamp}/${expirationPeriodInSeconds}`;
// 2
const signedHeaders = "host;x-bce-date";
const CanonicalHeaders = encodeURIComponent("host") + ":" + encodeURIComponent(Host) + "\n" + encodeURIComponent("x-bce-date") + ":" + encodeURIComponent(timestamp);
const CanonicalRequest = Method.toUpperCase() + "\n" + this.uriEncode(CanonicalURI, false) + "\n" + CanonicalQueryString + "\n" + CanonicalHeaders;
// 3
const SigningKey = crypto.createHmac("sha256", this.opts.access.secretKey).update(authStringPrefix).digest().toString("hex");
// 4
const Signature = crypto.createHmac("sha256", SigningKey).update(CanonicalRequest).digest().toString("hex");
// 5
return `${authStringPrefix}/${signedHeaders}/${Signature}`;
}
}
export class BaiduYunCertClient {
client: BaiduYunClient;
constructor(opts: BaiduYunClientOptions) {
this.client = new BaiduYunClient(opts);
}
async createCert(opts: { certName: string; cert: CertInfo }) {
// /v1/certificate
const res = await this.client.doRequest({
host: "certificate.baidubce.com",
uri: `/v1/certificate`,
method: "post",
body: {
/**
* certName String 必须 证书的名称。长度限制为1-65个字符,以字母开头,只允许包含字母、数字、’-‘、’/’、’.’、’’,Java正则表达式` ^[a-zA-Z]a-zA-Z0-9\-/\.]{2,64}$`
* certServerData String 必须 服务器证书的数据内容 (Base64编码)
* certPrivateData String 必须 证书的私钥数据内容 (Base64编码)
*/
certName: "certd_" + opts.certName, // 字母开头,且小于64长度
certServerData: opts.cert.crt,
certPrivateData: opts.cert.key,
},
});
return res;
}
async getCertList() {
/**
* GET /v1/certificate HTTP/1.1
* HOST: certificate.baidubce.com
* Authorization: {authorization}
* Content-Type: application/json; charset=utf-8
* x-bce-date: 2014-06-01T23:00:10Z
*/
return await this.client.doRequest({
host: "certificate.baidubce.com",
uri: `/v1/certificate`,
method: "get",
});
}
async updateCert(opts: { certId: string; certName: string; cert: CertInfo }) {
/**
* /v1/certificate/{certId}?certData
* certName String 必须 证书的名称。长度限制为1-65个字符,以字母开头,只允许包含字母、数字、’-‘、’/’、’.’、’’,Java正则表达式` ^[a-zA-Z]a-zA-Z0-9\-/\.]{2,64}$`
* certServerData String 必须 服务器证书的数据内容 (Base64编码)
* certPrivateData String 必须 证书的私钥数据内容 (Base64编码)
* certLinkData String 可选 证书链数据内容 (Base64编码)
* certType Integer 可选 证书类型,非必填,默认为1
*/
return await this.client.doRequest({
host: "certificate.baidubce.com",
uri: `/v1/certificate/${opts.certId}`,
method: "put",
body: {
certName: opts.certName,
certServerData: opts.cert.crt,
certPrivateData: opts.cert.key,
},
});
}
}
@@ -0,0 +1,3 @@
export * from "./plugins/index.js";
export * from "./access.js";
export * from "./client.js";
@@ -0,0 +1,3 @@
export * from "./plugin-deploy-to-cdn.js";
export * from "./plugin-deploy-to-blb.js";
export * from "./plugin-upload-to-baidu.js";
@@ -0,0 +1,323 @@
import { AbstractTaskPlugin, IsTaskPlugin, PageSearch, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo, CertReader } from "@certd/plugin-cert";
import { createCertDomainGetterInputDefine } from "@certd/plugin-lib";
import { BaiduYunCertClient, BaiduYunClient } from "../client.js";
@IsTaskPlugin({
name: "BaiduDeployToBLB",
title: "百度云-部署证书到负载均衡",
icon: "ant-design:baidu-outlined",
group: pluginGroups.baidu.key,
desc: "部署到百度云负载均衡,包括BLB、APPBLB",
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
needPlus: false,
})
export class BaiduDeployToBLBPlugin extends AbstractTaskPlugin {
//证书选择,此项必须要有
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames, "BaiduUploadCert"],
},
required: true,
})
cert!: CertInfo | string;
@TaskInput(createCertDomainGetterInputDefine())
certDomains!: string[];
@TaskInput({
title: "区域",
component: {
name: "a-select",
vModel: "value",
options: [
/**
* 北京 blb.bj.baidubce.com HTTP/HTTPS
* 广州 blb.gz.baidubce.com HTTP/HTTPS
* 苏州 blb.su.baidubce.com HTTP/HTTPS
* 香港 blb.hkg.baidubce.com HTTP/HTTPS
* 武汉 blb.fwh.baidubce.com HTTP/HTTPS
* 保定 blb.bd.baidubce.com HTTP/HTTPS
* 上海 blb.fsh.baidubce.com HTTP/HTTPS
* 新加坡 blb.sin.baidubce.com HTTP/HTTPS
*/
{ value: "bj", label: "北京" },
{ value: "fsh", label: "上海" },
{ value: "gz", label: "广州" },
{ value: "fwh", label: "武汉" },
{ value: "su", label: "苏州" },
{ value: "bd", label: "保定" },
{ value: "hkg", label: "香港" },
{ value: "sin", label: "新加坡" },
],
},
required: true,
})
region!: string;
@TaskInput({
title: "负载均衡类型",
component: {
name: "a-select",
vModel: "value",
options: [
{ value: "blb", label: "普通负载均衡" },
{ value: "appblb", label: "应用负载均衡" },
],
},
required: true,
})
blbType!: string;
//授权选择框
@TaskInput({
title: "百度云授权",
helper: "百度云授权",
component: {
name: "access-selector",
type: "baidu",
},
required: true,
})
accessId!: string;
@TaskInput({
title: "负载均衡ID",
component: {
name: "remote-select",
vModel: "value",
mode: "tags",
action: "GetBLBList",
watches: ["certDomains", "blbType", "accessId"],
},
required: true,
})
blbIds!: string[];
@TaskInput({
title: "监听器ID",
component: {
name: "remote-select",
vModel: "value",
mode: "tags",
action: "GetListenerList",
watches: ["certDomains", "accessId", "blbIds"],
},
required: true,
})
listenerIds!: string[];
async onInstance() {}
async execute(): Promise<void> {
this.logger.info("开始更新百度云监听器证书");
const access = await this.getAccess(this.accessId);
const certClient = new BaiduYunCertClient({
access,
logger: this.logger,
http: this.ctx.http,
});
let certId = this.cert as string;
if (typeof this.cert !== "string") {
this.logger.info("上传证书到百度云");
const res = await certClient.createCert({
cert: this.cert,
certName: CertReader.buildCertName(this.cert),
});
certId = res.certId;
this.logger.info(`上传证书到百度云成功:${certId}`);
}
const baiduyunClient = new BaiduYunClient({
access,
logger: this.logger,
http: this.ctx.http,
});
for (const listenerId of this.listenerIds) {
const listenerParams = listenerId.split("_");
const blbId = listenerParams[0];
const listenerType = listenerParams[1];
const listenerPort = listenerParams[2];
let additionalCertHost = null;
if (listenerParams.length > 3) {
additionalCertHost = listenerParams[3];
}
this.logger.info(`更新监听器证书开始:${listenerId}`);
if (!additionalCertHost) {
await this.updateListenerCert({
client: baiduyunClient,
blbId,
listenerType,
listenerPort,
certId,
});
} else {
const listenerDomains = await this.getListeners(baiduyunClient, blbId, listenerType, listenerPort);
if (!listenerDomains || listenerDomains.length === 0) {
throw new Error(`未找到监听器:${listenerId}`);
}
const oldAdditionals = listenerDomains[0].additionalCertDomains;
for (const oldAddi of oldAdditionals) {
if (oldAddi.host === additionalCertHost) {
oldAddi.certId = certId;
}
}
await this.updateListenerCert({
client: baiduyunClient,
blbId,
listenerType,
listenerPort,
certId,
additionalCertDomains: oldAdditionals,
});
}
this.logger.info(`更新监听器证书成功:${listenerId}`);
await this.ctx.utils.sleep(3000);
}
this.logger.info(`更新百度云监听器证书完成`);
}
async onGetListenerList(data: PageSearch = {}) {
const access = await this.getAccess(this.accessId);
const client = new BaiduYunClient({
access,
logger: this.logger,
http: this.ctx.http,
});
const listeners = [];
for (const blbId of this.blbIds) {
/**
* GET /v{version}/appblb/{blbId}/TCPlistener?listenerPort={listenerPort}&marker={marker}&maxKeys={maxKeys} HTTP/1.1
* Host: blb.bj.baidubce.com
*/
const listenerTypes = ["HTTPSlistener", "SSLlistener"];
for (const listenerType of listenerTypes) {
const list = await this.getListeners(client, blbId, listenerType);
if (list && list.length > 0) {
for (const item of list) {
const key = `${blbId}_${listenerType}_${item.listenerPort}`;
listeners.push({
value: key,
label: key,
});
if (item.additionalCertDomains && item.additionalCertDomains.length > 0) {
for (const addi of item.additionalCertDomains) {
const addiKey = `${key}_${addi.host}`;
listeners.push({
value: addiKey,
label: `${addiKey}【扩展】`,
});
}
}
}
}
}
}
if (!listeners || listeners.length === 0) {
throw new Error("未找到https/SSL监听器");
}
return listeners;
}
private async getListeners(client: BaiduYunClient, blbId: string, listenerType: string, listenerPort?: number | string) {
const query: any = {
maxItems: 1000,
};
if (listenerPort) {
query.listenerPort = listenerPort;
}
const res = await client.doRequest({
host: `blb.${this.region}.baidubce.com`,
uri: `/v1/${this.blbType}/${blbId}/${listenerType}`,
method: "GET",
query,
});
return res.listenerList;
}
async onGetBLBList(data: PageSearch = {}) {
const access = await this.getAccess(this.accessId);
const client = new BaiduYunClient({
access,
logger: this.logger,
http: this.ctx.http,
});
/**
* GET /v{version}/appblb?address={address}&name={name}&blbId={blbId}&marker={marker}&maxKeys={maxKeys} HTTP/1.1
* Host: blb.bj.baidubce.com
*/
const res = await client.doRequest({
host: `blb.${this.region}.baidubce.com`,
uri: `/v1/${this.blbType}`,
method: "GET",
query: {
maxItems: 1000,
},
});
const list = res.blbList;
if (!list || list.length === 0) {
throw new Error("没有数据,你可以手动输入");
}
const options: any[] = [];
for (const item of list) {
options.push({
value: item.blbId,
label: item.name,
});
}
return options;
}
private async updateListenerCert(param: { client: BaiduYunClient; blbId: string; listenerType: string; listenerPort: string; certId?: any; additionalCertDomains?: any[] }) {
/**
* PUT /v{version}/appblb/{blbId}/SSLlistener?clientToken={clientToken}&listenerPort={listenerPort} HTTP/1.1
* Host: blb.bj.baidubce.com
* Authorization: authorization string
*
* {
* "scheduler":scheduler,
* "certIds":[certId],
* "encryptionType":encryptionType,
* "encryptionProtocols":[protocol1, protacol2],
* "dualAuth":false,
* "clientCertIds":[clientCertId],
* "description":description
* }
*/
const { client, blbId, listenerType, listenerPort, certId, additionalCertDomains } = param;
const body: any = {};
if (additionalCertDomains) {
body.additionalCertDomains = additionalCertDomains;
}
if (certId) {
body.certIds = [certId];
}
const res = await client.doRequest({
host: `blb.${this.region}.baidubce.com`,
uri: `/v1/${this.blbType}/${blbId}/${listenerType}`,
method: "PUT",
query: {
listenerPort,
},
body,
});
return res;
}
}
new BaiduDeployToBLBPlugin();
@@ -0,0 +1,145 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo, CertReader } from "@certd/plugin-cert";
import { BaiduYunCertClient, BaiduYunClient } from "../client.js";
import { createCertDomainGetterInputDefine } from "@certd/plugin-lib";
@IsTaskPlugin({
name: "BaiduDeployToCDN",
title: "百度云-部署证书到CDN",
icon: "ant-design:baidu-outlined",
group: pluginGroups.baidu.key,
desc: "部署到百度云CDN",
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
needPlus: false,
})
export class BaiduDeployToCDNPlugin extends AbstractTaskPlugin {
//证书选择,此项必须要有
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames, "BaiduUploadCert"],
},
required: true,
})
cert!: CertInfo | string;
@TaskInput(createCertDomainGetterInputDefine())
certDomains!: string[];
//授权选择框
@TaskInput({
title: "百度云授权",
helper: "百度云授权",
component: {
name: "access-selector",
type: "baidu",
},
required: true,
})
accessId!: string;
//证书选择,此项必须要有
@TaskInput({
title: "CDN域名",
component: {
name: "remote-select",
vModel: "value",
mode: "tags",
action: "GetDomainList",
watches: ["certDomains", "accessId"],
},
required: true,
})
domains!: string[];
async onInstance() {}
async execute(): Promise<void> {
const access = await this.getAccess(this.accessId);
const client = new BaiduYunClient({
access,
logger: this.logger,
http: this.ctx.http,
});
const certClient = new BaiduYunCertClient({
access,
logger: this.logger,
http: this.ctx.http,
});
let certId = this.cert as string;
if (typeof this.cert !== "string") {
this.logger.info("上传证书到百度云");
const res = await certClient.createCert({
cert: this.cert,
certName: CertReader.buildCertName(this.cert),
});
certId = res.certId;
this.logger.info(`上传证书到百度云成功:${certId}`);
}
const body = {
https: {
enabled: true,
certId: certId,
},
};
for (const domain of this.domains) {
await client.doRequest({
host: "cdn.baidubce.com",
uri: `/v2/domain/${domain}/config`,
body,
query: {
https: "",
},
method: "put",
});
this.logger.info(`部署证书到${domain}成功`);
}
}
async onGetDomainList() {
// if (!isPlus()) {
// throw new Error("自动获取站点列表为专业版功能,您可以手动输入站点域名/站点名称进行部署");
// }
const access = await this.getAccess(this.accessId);
const client = new BaiduYunClient({
access,
logger: this.logger,
http: this.ctx.http,
});
const res = await client.doRequest({
host: "cdn.baidubce.com",
uri: `/v2/domain`,
method: "GET",
query: {
maxItems: 1000,
},
});
const list = res.domains;
if (!list || list.length === 0) {
throw new Error("未找到加速域名,你可以手动输入");
}
const options: any[] = [];
for (const item of list) {
options.push({
value: item.name,
label: item.name,
domain: item.name,
});
}
return this.ctx.utils.options.buildGroupOptions(options, this.certDomains);
}
}
new BaiduDeployToCDNPlugin();
@@ -0,0 +1,68 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput, TaskOutput } from "@certd/pipeline";
import { CertApplyPluginNames, CertReader } from "@certd/plugin-cert";
import { BaiduAccess } from "../access.js";
import { BaiduYunCertClient } from "../client.js";
@IsTaskPlugin({
name: "BaiduUploadCert",
title: "百度云-上传到证书托管",
icon: "ant-design:baidu-outlined",
desc: "上传证书到百度云证书托管中心",
group: pluginGroups.baidu.key,
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
})
export class BaiduUploadCert extends AbstractTaskPlugin {
// @TaskInput({ title: '证书名称' })
// name!: string;
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames],
},
required: true,
})
cert!: any;
@TaskInput({
title: "Access授权",
helper: "access授权",
component: {
name: "access-selector",
type: "baidu",
},
required: true,
})
accessId!: string;
@TaskOutput({
title: "百度云CertId",
})
baiduCertId?: string;
async execute(): Promise<void> {
const access = await this.getAccess<BaiduAccess>(this.accessId);
const certClient = new BaiduYunCertClient({
access,
logger: this.logger,
http: this.http,
});
const certItem = await certClient.createCert({
cert: this.cert,
certName: CertReader.buildCertName(this.cert),
});
this.baiduCertId = certItem.certId;
this.logger.info(`上传成功,证书ID${certItem.certId}`);
}
}
new BaiduUploadCert();
@@ -0,0 +1,26 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
/**
* 这个注解将注册一个授权配置
* 在certd的后台管理系统中,用户可以选择添加此类型的授权
*/
@IsAccess({
name: "baishan",
title: "白山云授权",
desc: "",
icon: "material-symbols:shield-outline",
})
export class BaishanAccess extends BaseAccess {
@AccessInput({
title: "token",
component: {
placeholder: "token",
},
helper: "自行联系提供商申请",
required: true,
encrypt: true,
})
token = "";
}
new BaishanAccess();
@@ -0,0 +1,2 @@
export * from "./plugins/index.js";
export * from "./access.js";
@@ -0,0 +1 @@
export * from "./plugin-update-cert.js";
@@ -0,0 +1,103 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert";
import { BaishanAccess } from "../access.js";
@IsTaskPlugin({
name: "BaishanUpdateCert",
title: "白山云-更新证书",
icon: "material-symbols:shield-outline",
group: pluginGroups.cdn.key,
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
needPlus: false,
})
export class BaishanUpdateCert extends AbstractTaskPlugin {
//测试参数
@TaskInput({
title: "证书ID",
component: {
name: "a-input-number",
vModel: "value",
},
helper: "证书ID,在证书管理页面查看,每条记录都有证书id",
})
certId!: number;
//测试参数
@TaskInput({
title: "证书名称",
component: {
name: "a-input",
vModel: "value",
},
helper: "给证书设置一个名字,便于区分",
})
certName!: string;
//证书选择,此项必须要有
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames],
},
required: true,
})
cert!: CertInfo;
//授权选择框
@TaskInput({
title: "白山云授权",
helper: "白山云授权",
component: {
name: "access-selector",
type: "baishan",
},
required: true,
})
accessId!: string;
async onInstance() {}
async execute(): Promise<void> {
const access = await this.getAccess<BaishanAccess>(this.accessId);
// https://cdn.api.baishan.com/v2/domain/certificate
try {
const res = await this.ctx.http.request({
url: "/v2/domain/certificate?token=" + access.token,
baseURL: "https://cdn.api.baishan.com",
method: "post",
data: {
cert_id: this.certId,
name: this.certName,
certificate: this.cert.crt,
key: this.cert.key,
},
});
if (res.code !== 0) {
throw new Error("修改证书失败:" + res.message || res.msg || JSON.stringify(res));
}
} catch (e: any) {
if (e.message?.indexOf("this certificate is exists") > -1) {
// this certificate is exists, cert_id is (224995)
//提取id
const id = e.message.match(/\d+/);
if (id && id.length > 0 && id[0] !== this.certId + "") {
throw new Error("证书已存在,但证书id不一致,当前证书id为" + this.certId + ",已存在证书id为" + id);
}
this.logger.info("证书已存在,无需更新");
return;
}
throw e;
}
this.logger.info("证书更新成功");
}
}
new BaishanUpdateCert();
@@ -0,0 +1,86 @@
import { IsAccess, AccessInput, BaseAccess } from "@certd/pipeline";
import { HttpClient } from "@certd/basic";
import { BaotaClient } from "./lib/client.js";
/**
* 这个注解将注册一个授权配置
* 在certd的后台管理系统中,用户可以选择添加此类型的授权
*/
@IsAccess({
name: "baota",
title: "baota授权",
desc: "",
icon: "svg:icon-bt",
order: 2,
})
export class BaotaAccess extends BaseAccess {
@AccessInput({
title: "宝塔URL地址",
component: {
placeholder: "http://192.168.42.237:41896",
},
helper: "宝塔面板的url地址,不要带安全入口,例如:http://192.168.42.237:41896",
required: true,
})
panelUrl = "";
@AccessInput({
title: "接口密钥",
component: {
placeholder: "接口密钥",
},
helper: "宝塔面板设置->面板设置->API接口->接口配置->接口密钥。\n必须要加IP白名单,您可以点击下方测试按钮,报错之后会打印IP,将IP加入白名单之后再次测试即可",
required: true,
encrypt: true,
})
apiSecret = "";
@AccessInput({
title: "忽略证书校验",
value: true,
component: {
name: "a-switch",
vModel: "checked",
},
helper: "如果面板的url是https,且使用的是自签名证书,则需要开启此选项,其他情况可以关闭",
})
skipSslVerify = true;
@AccessInput({
title: "windows版",
value: false,
component: {
name: "a-switch",
vModel: "checked",
},
helper: "是否是windows版",
})
isWindows = false;
@AccessInput({
title: "测试",
component: {
name: "api-test",
action: "TestRequest",
},
helper: "点击测试接口看是否正常",
})
testRequest = true;
async onTestRequest() {
const http: HttpClient = this.ctx.http;
const client = new BaotaClient(this, http);
if (this.isWindows) {
await client.doWindowsRequest("/site/get_site_types", {}, { skipCheckRes: false });
return "ok";
}
const url = "/site?action=get_site_types";
const data = {};
await client.doRequest(url, null, data, { skipCheckRes: false });
return "ok";
}
}
new BaotaAccess();
@@ -0,0 +1,4 @@
export * from "./plugins/index.js";
export * from "./access.js";
export * from "./waf-access.js";
export * from "./lib/client.js";
@@ -0,0 +1,88 @@
import crypto from "node:crypto";
import { BaotaAccess } from "../access.js";
import { HttpClient, HttpRequestConfig } from "@certd/basic";
import * as querystring from "node:querystring";
export class BaotaClient {
access: BaotaAccess;
http: HttpClient;
constructor(access: BaotaAccess, http: HttpClient) {
this.access = access;
this.http = http;
}
//将以上 java代码 翻译成nodejs 代码
getRequestToken() {
const timestamps = Math.floor(new Date().getTime() / 1000);
const md5Sign = this.getMd5(this.access.apiSecret);
const temp = timestamps + md5Sign;
return {
request_token: this.getMd5(temp),
request_time: "" + timestamps,
};
}
getMd5(content: string) {
return crypto.createHash("md5").update(content).digest("hex");
}
async doRequest(path: string, action: string, data: any = {}, options?: HttpRequestConfig<any>) {
const token = this.getRequestToken();
const body = {
...token,
...data,
};
const bodyStr = querystring.stringify(body);
// const agent = new https.Agent({
// rejectUnauthorized: false,
// });
let panelUrl = this.access.panelUrl;
if (panelUrl.endsWith("/")) {
panelUrl = panelUrl.substring(0, panelUrl.length - 1);
}
let url = `${panelUrl}${path}`;
if (action) {
url = `${url}?action=${action}`;
}
const res: any = await this.http.request({
url: url,
method: "post",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
data: bodyStr,
// httpsAgent: agent,
...options,
skipSslVerify: this.access.skipSslVerify ?? true,
});
if (!options?.skipCheckRes && res.status === false) {
throw new Error(res.msg);
}
return res;
}
async doWindowsRequest(path: string, data: any, options?: HttpRequestConfig<any>) {
const token = this.getRequestToken();
const body = {
...token,
...data,
};
// const agent = new https.Agent({
// rejectUnauthorized: false,
// });
const url = `${this.access.panelUrl}${path}`;
const res: any = await this.http.request({
url: url,
method: "post",
data: body,
// httpsAgent: agent,
...options,
skipSslVerify: this.access.skipSslVerify ?? true,
});
if (!options?.skipCheckRes && res.status === false) {
throw new Error(res.msg);
}
return res;
}
}
@@ -0,0 +1,5 @@
export * from "./plugin-deploy-to-panel.js";
export * from "./plugin-deploy-to-website.js";
export * from "./plugin-deploy-to-aawaf.js";
export * from "./plugin-deploy-to-website-win.js";
export * from "./plugin-delete-expiring-cert.js";
@@ -0,0 +1,69 @@
import { IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { BaotaClient } from "../lib/client.js";
import { AbstractPlusTaskPlugin } from "@certd/plugin-lib";
import dayjs from "dayjs";
@IsTaskPlugin({
name: "BaotaDeleteExpiringCert",
title: "宝塔-删除过期证书",
icon: "svg:icon-bt",
group: pluginGroups.panel.key,
desc: "删除证书夹中过期证书",
showRunStrategy: true,
default: {
strategy: {
runStrategy: RunStrategy.AlwaysRun,
},
},
needPlus: true,
})
export class BaotaDeleteExpiringCert extends AbstractPlusTaskPlugin {
//授权选择框
@TaskInput({
title: "宝塔授权",
helper: "baota的接口密钥",
component: {
name: "access-selector",
type: "baota",
},
required: true,
})
accessId!: string;
async onInstance() {}
async execute(): Promise<void> {
const { accessId } = this;
const access = await this.getAccess(accessId);
const http = this.ctx.http;
const client = new BaotaClient(access, http);
//https://baota.docmirror.cn:20001/ssl?action=get_cert_list
const res = await client.doRequest("/ssl", "get_cert_list", null);
const now = new Date().getTime();
for (const item of res) {
if (dayjs(item.not_after).valueOf() < now) {
//https://baota.docmirror.cn:20001/ssl?action=remove_cloud_cert
/**
* local: 1
* ssl_hash: fbe087d5253b78ba37264486415181ab
*/
this.logger.info(`证书: ${item.name} 过期时间: ${item.not_after},已过期,删除`);
try {
await client.doRequest("/ssl", "remove_cloud_cert", {
local: 1,
ssl_hash: item.hash,
});
} catch (e) {
this.logger.error(`删除证书: ${item.name} 失败`, e);
}
await this.ctx.utils.sleep(1000);
} else {
this.logger.info(`证书: ${item.name} 过期时间: ${item.not_after},未过期`);
}
}
this.logger.info(res?.msg);
}
}
new BaotaDeleteExpiringCert();
@@ -0,0 +1,143 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert";
import { createCertDomainGetterInputDefine } from "@certd/plugin-lib";
import { BaotaWafAccess } from "../waf-access.js";
type SiteItem = {
value: string;
label: string;
domain: string;
};
@IsTaskPlugin({
name: "BaotaDeployWAF",
title: "宝塔-WAF证书部署",
icon: "svg:icon-bt",
group: pluginGroups.panel.key,
desc: "部署宝塔云WAF/aaWAF",
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
needPlus: false,
})
export class BaotaDeployWAF extends AbstractTaskPlugin {
//证书选择,此项必须要有
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames],
},
required: true,
})
cert!: CertInfo;
@TaskInput(createCertDomainGetterInputDefine())
certDomains!: string[];
//授权选择框
@TaskInput({
title: "宝塔WAF授权",
helper: "aaWAF的接口密钥",
component: {
name: "access-selector",
type: "baotawaf",
},
required: true,
})
accessId!: string;
//证书选择,此项必须要有
@TaskInput({
title: "站点ID",
component: {
name: "remote-select",
vModel: "value",
mode: "tags",
action: "onGetSiteList",
search: true,
watches: ["certDomains", "accessId"],
},
required: true,
helper: "将会自动获取证书匹配的站点,请选择要部署证书的站点",
})
siteIds!: string[];
async onInstance() {}
async execute(): Promise<void> {
const { cert, accessId } = this;
const access = await this.getAccess<BaotaWafAccess>(accessId);
const lockKey = `baota-lock-${accessId}`;
for (const siteId of this.siteIds) {
this.logger.info(`为站点:${siteId}设置证书`);
const info = await this.getSiteInfo(access, siteId);
const listen_ssl_port = info.server.listen_ssl_port;
await this.ctx.utils.locker.execute(lockKey, async () => {
await access.doRequest({
url: "/api/wafmastersite/modify_site",
data: {
types: "openCert",
site_id: siteId,
server: {
listen_ssl_port,
ssl: { is_ssl: 1, full_chain: cert.crt, private_key: cert.key },
},
},
});
});
this.logger.info(`站点:${siteId} 证书部署成功`);
}
this.logger.info(`部署成功`);
}
async getSiteInfo(access: BaotaWafAccess, siteId: string) {
// /api/wafmastersite/get_site_list
const res = await access.doRequest({
url: "/api/wafmastersite/get_site_list",
data: {
site_id: siteId,
p_size: 1,
p: 1,
site_name: "",
},
});
if (!res.list || res.list.length === 0) {
throw new Error(`未找到站点:${siteId}`);
}
return res.list[0];
}
async onGetSiteList(data: { searchKey?: string }) {
// if (!isPlus()) {
// throw new Error("自动获取站点列表为专业版功能,您可以手动输入站点域名/站点名称进行部署");
// }
const access = await this.getAccess<BaotaWafAccess>(this.accessId);
const res = await access.getSiteList({
pageNo: 1,
pageSize: 100,
query: data.searchKey || "",
});
const list = res.list;
if (!list || list.length === 0) {
throw new Error("未找到站点,你可以手动输入");
}
const options: SiteItem[] = [];
for (const item of list) {
options.push({
value: item.site_id,
label: `${item.site_name}<${item.site_id}>`,
domain: item.server.server_name,
});
}
return this.ctx.utils.options.buildGroupOptions(options, this.certDomains);
}
}
new BaotaDeployWAF();
@@ -0,0 +1,68 @@
import { IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertInfo } from "@certd/plugin-cert";
import { BaotaClient } from "../lib/client.js";
import { AbstractPlusTaskPlugin } from "@certd/plugin-lib";
import { CertApplyPluginNames } from "@certd/plugin-cert";
@IsTaskPlugin({
name: "BaotaDeployPanelCert",
title: "宝塔-面板证书部署",
icon: "svg:icon-bt",
group: pluginGroups.panel.key,
desc: "部署宝塔面板本身的ssl证书",
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
needPlus: true,
})
export class BaotaDeployPanelCertPlugin extends AbstractPlusTaskPlugin {
//证书选择,此项必须要有
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames],
},
required: true,
})
cert!: CertInfo;
//授权选择框
@TaskInput({
title: "宝塔授权",
helper: "baota的接口密钥",
component: {
name: "access-selector",
type: "baota",
},
required: true,
})
accessId!: string;
async onInstance() {}
async execute(): Promise<void> {
const { cert, accessId } = this;
const access = await this.getAccess(accessId);
const http = this.ctx.http;
const client = new BaotaClient(access, http);
const lockKey = `baota-lock-${accessId}`;
await this.ctx.utils.locker.execute(lockKey, async () => {
const res = await client.doRequest(
"/config",
"SavePanelSSL",
{
privateKey: cert.key,
certPem: cert.crt,
},
{
skipSslVerify: true,
}
);
this.logger.info(res?.msg);
});
}
}
new BaotaDeployPanelCertPlugin();
@@ -0,0 +1,143 @@
import { HttpClient } from "@certd/basic";
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert";
import { BaotaClient } from "../lib/client.js";
import { createCertDomainGetterInputDefine } from "@certd/plugin-lib";
type SiteItem = {
value: string;
label: string;
domain: string;
};
@IsTaskPlugin({
name: "BaotaDeployWebSiteWin",
title: "宝塔win-网站证书部署",
icon: "svg:icon-bt",
group: pluginGroups.panel.key,
desc: "部署到Windows版宝塔管理的站点的ssl证书",
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
needPlus: false,
})
export class BaotaDeployWebSiteWin extends AbstractTaskPlugin {
//证书选择,此项必须要有
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames],
},
required: true,
})
cert!: CertInfo;
@TaskInput(createCertDomainGetterInputDefine())
certDomains!: string[];
//授权选择框
@TaskInput({
title: "宝塔授权",
helper: "baota的接口密钥",
component: {
name: "access-selector",
type: "baota",
},
required: true,
})
accessId!: string;
//证书选择,此项必须要有
@TaskInput({
title: "站点Id",
component: {
name: "remote-select",
vModel: "value",
mode: "tags",
action: "GetSiteList",
watches: ["certDomains", "accessId"],
},
required: true,
mergeScript: `
return {
component:{
form: ctx.compute(({form})=>{
return form
})
},
}
`,
helper: "将会自动获取证书匹配的站点名称",
})
siteIds!: number[];
async onInstance() {}
async execute(): Promise<void> {
const { cert, accessId } = this;
const access = await this.getAccess(accessId);
const http: HttpClient = this.ctx.http;
const client = new BaotaClient(access, http);
this.logger.info(`siteIds:${this.siteIds}`);
const siteIds = this.siteIds ?? [];
const lockKey = `baota-lock-${accessId}`;
for (const site of siteIds) {
await this.ctx.utils.locker.execute(lockKey, async () => {
this.logger.info(`为站点:${site}设置证书`);
const res = await client.doWindowsRequest("/site/set_site_ssl", {
siteid: site,
status: true,
sslType: "",
cert: cert.crt,
key: cert.key,
});
this.logger.info(res?.msg);
});
}
}
async onGetSiteList() {
// if (!isPlus()) {
// throw new Error("自动获取站点列表为专业版功能,您可以手动输入站点域名/站点名称进行部署");
// }
const access = await this.getAccess(this.accessId);
const http: HttpClient = this.ctx.http;
const client = new BaotaClient(access, http);
const domains = this.certDomains;
let all = [];
const getPhpSite = async () => {
const url = "/datalist/get_data_list";
const data = {
table: "sites",
limit: 500,
};
const res = await client.doWindowsRequest(url, data, { skipCheckRes: false });
this.logger.info(res.data);
all = res.data || [];
};
//查找docker 站点
await getPhpSite();
if (!all || all.length === 0) {
throw new Error("未找到站点,你可以手动输入");
}
const options: SiteItem[] = [];
for (const item of all) {
options.push({
value: item.id,
label: `${item.name}<${item.id}>`,
domain: item.name,
});
}
return this.ctx.utils.options.buildGroupOptions(options, domains);
}
}
new BaotaDeployWebSiteWin();
@@ -0,0 +1,232 @@
import { HttpClient } from "@certd/basic";
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert";
import { BaotaClient } from "../lib/client.js";
import { createCertDomainGetterInputDefine } from "@certd/plugin-lib";
import { uniq } from "lodash-es";
export type SiteItem = {
value: string;
label: string;
domain: string;
};
@IsTaskPlugin({
name: "BaotaDeployWebSiteCert",
title: "宝塔-网站证书部署",
icon: "svg:icon-bt",
group: pluginGroups.panel.key,
desc: "部署宝塔管理的站点的ssl证书,目前支持宝塔网站站点、docker站点等。本插件也支持aaPanel。",
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
needPlus: false,
})
export class BaotaDeployWebSiteCert extends AbstractTaskPlugin {
//证书选择,此项必须要有
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames],
},
required: true,
})
cert!: CertInfo;
@TaskInput(createCertDomainGetterInputDefine())
certDomains!: string[];
//授权选择框
@TaskInput({
title: "宝塔授权",
helper: "baota的接口密钥",
component: {
name: "access-selector",
type: "baota",
},
required: true,
})
accessId!: string;
@TaskInput({
title: "是否Docker站点",
value: false,
component: {
name: "a-switch",
vModel: "checked",
},
helper: "是否为docker站点",
required: true,
})
isDockerSite = false;
//证书选择,此项必须要有
@TaskInput({
title: "站点名称",
component: {
name: "remote-select",
vModel: "value",
mode: "tags",
action: "GetSiteList",
watches: ["certDomains", "accessId", "isDockerSite"],
},
required: true,
mergeScript: `
return {
component:{
form: ctx.compute(({form})=>{
return form
})
},
}
`,
helper: "将会自动获取证书匹配的站点名称\n宝塔版本低于9.0.0时,此处会获取失败,忽略错误,手动输入站点域名即可",
})
siteName!: string | string[];
async onInstance() {}
async execute(): Promise<void> {
const { cert, accessId } = this;
const access = await this.getAccess(accessId);
const http: HttpClient = this.ctx.http;
const client = new BaotaClient(access, http);
this.logger.info(`siteName:${this.siteName}`);
const siteNames = [];
if (typeof this.siteName === "string") {
siteNames.push(this.siteName);
} else {
siteNames.push(...this.siteName);
}
const lockKey = `baota-lock-${accessId}`;
for (const site of siteNames) {
// 加锁,防止并发部署证书, 宝塔并发部署会导致nginx的conf错乱
await this.ctx.utils.locker.execute(lockKey, async () => {
this.logger.info(`为站点:${site}设置证书,目前支持宝塔网站站点、docker站点`);
if (this.isDockerSite) {
const res = await client.doRequest("/mod/docker/com/set_ssl", "", {
site_name: site,
key: cert.key,
csr: cert.crt,
});
this.logger.info(res?.msg);
} else {
const res = await client.doRequest("/site", "SetSSL", {
type: 0,
siteName: site,
key: cert.key,
csr: cert.crt,
});
this.logger.info(res?.msg);
}
});
}
//上传证书
// const uploadCertUrl = "/ssl/cert/save_cert";
// const uploadCertData = {
// csr: cert.crt,
// key: cert.key,
// };
// const uploadCertRes = await client.doRequest(uploadCertUrl, null, uploadCertData, {
// skipCheckRes: true,
// });
// if (uploadCertRes.msg === "证书已存在") {
// this.logger.info(`证书已存在:${JSON.stringify(uploadCertRes)}`);
// } else if (uploadCertRes.status === false) {
// this.logger.info(`上传证书失败:${JSON.stringify(uploadCertRes)}`);
// } else {
// this.logger.info(`上传证书成功:${JSON.stringify(uploadCertRes)}`);
// }
// const certHash = this.ctx.utils.hash.md5(cert.crt + "\n");
// for (const site of siteNames) {
// const url = "/ssl/cert/SetCertToSite";
// const data = {
// siteName: site,
// ssl_hash: certHash,
// };
// this.logger.info(`开始部署站点【${site}】的证书:${JSON.stringify(data)}`);
// const res = await client.doRequest(url, null, data);
// this.logger.info(`站点【${site}】部署证书成功:${res.msg}`);
// }
// const batchInfo = [];
// for (const site of siteNames) {
// batchInfo.push({
// ssl_hash: certHash,
// siteName: site,
// certName: this.certDomains[0],
// });
// }
// const batchInfoStr = JSON.stringify(batchInfo);
// const data = { BatchInfo: batchInfoStr };
// this.logger.info("body=", data);
// const res = await client.doRequest("/ssl", "SetBatchCertToSite", data);
// if (res.failed > 0) {
// throw new Error(`部署失败:${JSON.stringify(res)}`);
// }
// this.logger.info(`部署成功:${JSON.stringify(res)}`);
}
async onGetSiteList() {
// if (!isPlus()) {
// throw new Error("自动获取站点列表为专业版功能,您可以手动输入站点域名/站点名称进行部署");
// }
const access = await this.getAccess(this.accessId);
const http: HttpClient = this.ctx.http;
const client = new BaotaClient(access, http);
const domains = this.certDomains;
let all = [];
const getPhpSite = async () => {
const url = "/ssl?action=GetSiteDomain";
const data = {
cert_list: JSON.stringify(domains),
};
const res = await client.doRequest(url, null, data, { skipCheckRes: false });
this.logger.info(res);
all = res.all || [];
};
//查找docker 站点
const getDockerSite = async () => {
const url2 = "/mod/docker/com/get_site_list";
const res2 = await client.doRequest(url2, null, {});
this.logger.info(res2);
if (res2.data) {
const dockerDomains = res2.data.map(item => {
return item.name;
});
all = [...all, ...dockerDomains];
all = uniq(all);
}
};
if (this.isDockerSite) {
await getDockerSite();
} else {
await getPhpSite();
}
if (!all || all.length === 0) {
throw new Error("未找到站点,你可以手动输入");
}
const options: SiteItem[] = [];
for (const item of all) {
options.push({
value: item,
label: item,
domain: item,
});
}
return this.ctx.utils.options.buildGroupOptions(options, domains);
}
}
new BaotaDeployWebSiteCert();
@@ -0,0 +1,128 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
import { HttpRequestConfig, utils } from "@certd/basic";
/**
* 这个注解将注册一个授权配置
* 在certd的后台管理系统中,用户可以选择添加此类型的授权
*/
@IsAccess({
name: "baotawaf",
title: "宝塔云WAF授权",
desc: "用于连接和管理宝塔云WAF服务的授权配置",
icon: "svg:icon-bt",
})
export class BaotaWafAccess extends BaseAccess {
@AccessInput({
title: "在宝塔WAF URL",
component: {
placeholder: "http://192.168.42.237:41896",
},
helper: "在宝塔WAF的URL地址,不要带安全入口,例如:http://192.168.42.237:41896",
required: true,
})
panelUrl = "";
@AccessInput({
title: "WAF API 密钥",
component: {
placeholder: "请输入WAF API接口密钥",
},
helper: "在宝塔WAF设置页面 - API接口中获取的API密钥。\n必须添加IP白名单,请确保已将CertD服务器IP加入白名单",
required: true,
encrypt: true,
})
apiSecret = "";
@AccessInput({
title: "忽略SSL证书校验",
value: false,
component: {
name: "a-switch",
vModel: "checked",
},
helper: "如果面板使用的是自签名SSL证书,则需要开启此选项",
})
skipSslVerify = false;
@AccessInput({
title: "测试",
component: {
name: "api-test",
action: "onTestRequest",
},
helper: "点击测试WAF请求",
})
testRequest = true;
async onTestRequest() {
const body = {
p: 1,
p_size: 1,
site_name: "",
};
// 发送测试请求
await this.doRequest({
url: "/api/wafmastersite/get_site_list",
data: body,
});
}
async getSiteList(req: { query?: string; pageNo?: number; pageSize?: number } = {}) {
const body = {
p: req.pageNo ?? 1,
p_size: req.pageSize ?? 100,
site_name: req.query ?? "",
};
return await this.doRequest({
url: "/api/wafmastersite/get_site_list",
data: body,
});
}
async doRequest(req: HttpRequestConfig) {
const http = this.ctx.http;
let panelUrl = this.panelUrl;
if (panelUrl.endsWith("/")) {
panelUrl = panelUrl.substring(0, panelUrl.length - 1);
}
// 构建请求头
/**
* __WAF_KEY = 接口密钥 (在WAF设置页面 - API 接口中获取)
* waf_request_time = 当前请求时间的 uinx 时间戳 ( php: time() / python: time.time() )
* waf_request_token = md5(string(request_time) + md5(api_sk))
*/
const timestamp = Math.floor(Date.now() / 1000);
const token = utils.hash.md5(timestamp + utils.hash.md5(this.apiSecret));
const headers = {
waf_request_time: timestamp,
waf_request_token: token,
...req.headers,
};
// 发送测试请求
const response = await http.request({
// https://192.168.182.201:8379/api/wafmastersite/get_site_list
baseURL: panelUrl,
method: "POST",
data: req.data,
skipSslVerify: this.skipSslVerify,
...req,
headers: {
...headers,
},
});
// 检查响应是否成功
if (response && response.code === 0) {
return response.res;
} else {
throw new Error(`请求失败: ${response.msg || "未知错误"}`);
}
}
}
// 实例化插件
new BaotaWafAccess();
@@ -0,0 +1,198 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
import { HttpRequestConfig } from "@certd/basic";
/**
* 这个注解将注册一个授权配置
* 在certd的后台管理系统中,用户可以选择添加此类型的授权
*/
@IsAccess({
name: "cdnfly",
title: "cdnfly授权",
desc: "",
icon: "majesticons:cloud-line",
})
export class CdnflyAccess extends BaseAccess {
@AccessInput({
title: "cdnfly系统网址",
component: {
name: "a-input",
vModel: "value",
},
required: true,
helper: "例如:http://demo.cdnfly.cn",
})
url!: string;
@AccessInput({
title: "授权方式",
value: "apikey",
component: {
name: "a-select",
vModel: "value",
options: [
{ label: "接口密钥", value: "apikey" },
{ label: "模拟登录", value: "password" },
],
},
required: true,
})
type = "apikey";
@AccessInput({
title: "用户名",
component: {
placeholder: "username",
},
mergeScript: `
return {
show: ctx.compute(({form})=>{
return form.access.type === 'password';
})
}
`,
required: true,
})
username = "";
@AccessInput({
title: "密码",
component: {
placeholder: "password",
},
helper: "",
mergeScript: `
return {
show: ctx.compute(({form})=>{
return form.access.type === 'password';
})
}
`,
required: true,
encrypt: true,
})
password = "";
@AccessInput({
title: "api_key",
component: {
placeholder: "api_key",
},
helper: "登录cdnfly控制台->账户中心->Api密钥,点击开启后获取",
required: true,
encrypt: true,
mergeScript: `
return {
show: ctx.compute(({form})=>{
return form.access.type === 'apikey';
})
}
`,
})
apiKey = "";
@AccessInput({
title: "api_secret",
component: {
placeholder: "api_secret",
},
helper: "登录cdnfly控制台->账户中心->Api密钥,点击开启后获取",
required: true,
encrypt: true,
mergeScript: `
return {
show: ctx.compute(({form})=>{
return form.access.type === 'apikey';
})
}
`,
})
apiSecret = "";
@AccessInput({
title: "测试",
component: {
name: "api-test",
action: "onTestRequest",
},
helper: "点击测试接口看是否正常\nIP需要加白名单,如果是同一台机器部署的,可以试试面板的url使用网卡docker0的ip,白名单使用172.16.0.0/12",
})
testRequest = true;
accessToken!: string;
async onTestRequest() {
const certUrl = `/v1/certs`;
const query: any = {
limit: 100,
};
await this.doRequest({
url: certUrl,
method: "GET",
data: query,
});
return "ok";
}
async getToken() {
if (this.type !== "password") {
throw new Error("only support password type");
}
if (this.accessToken) {
return this.accessToken;
}
const res = await this.ctx.http.request({
url: "/v1/login",
baseURL: this.url,
method: "POST",
data: {
account: this.username,
password: this.password,
},
});
if (res.code != 0) {
throw new Error(res.msg);
}
this.accessToken = res.data.access_token;
return this.accessToken;
}
async doRequest(config?: HttpRequestConfig) {
const http = this.ctx.http;
let headers: any = {};
if (this.type === "password") {
//模拟登陆
await this.getToken();
headers = {
"Access-Token": `${this.accessToken}`,
};
} else {
const { apiKey, apiSecret } = this;
headers = {
"api-key": apiKey,
"api-secret": apiSecret,
};
}
const data = config.data;
const method = config.method || "POST";
const baseURL = config.baseURL || this.url;
if (!baseURL) {
throw new Error("请配置授权内的url参数");
}
const res: any = await http.request({
url: config.url,
baseURL: baseURL,
method: method,
headers,
logRes: false,
params: method === "GET" ? data : {},
data: method !== "GET" ? data : undefined,
});
if (res.code != 0) {
throw new Error(res.msg);
}
return res;
}
}
new CdnflyAccess();
@@ -0,0 +1,2 @@
export * from "./plugins/index.js";
export * from "./access.js";
@@ -0,0 +1 @@
export * from "./plugin-deploy-to-cdn.js";
@@ -0,0 +1,261 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert";
import { createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from "@certd/plugin-lib";
import { CdnflyAccess } from "../access.js";
@IsTaskPlugin({
name: "CdnflyDeployToCDN",
title: "cdnfly-部署证书到cdnfly",
icon: "majesticons:cloud-line",
group: pluginGroups.cdn.key,
desc: "cdnfly",
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
needPlus: false,
})
export class CdnflyDeployToCDNPlugin extends AbstractTaskPlugin {
//证书选择,此项必须要有
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames],
},
required: true,
})
cert!: CertInfo;
@TaskInput(
createCertDomainGetterInputDefine({
props: { required: false },
})
)
certDomains!: string[];
//授权选择框
@TaskInput({
title: "cdnfly授权",
helper: "cdnfly授权",
component: {
name: "access-selector",
type: "cdnfly",
},
required: true,
})
accessId!: string;
@TaskInput({
title: "自动匹配站点",
component: {
name: "a-switch",
vModel: "checked",
},
helper: "是否自动匹配站点进行部署\n如果选择自动匹配,则下方参数无需填写",
})
autoMatch!: boolean;
//测试参数
@TaskInput(
createRemoteSelectInputDefine({
title: "证书ID",
helper: "请选择证书Id,需要先手动上传一次证书,后续可以自动更新证书【推荐】",
search: true,
typeName: "CdnflyDeployToCDNPlugin",
action: CdnflyDeployToCDNPlugin.prototype.onGetCertList.name,
watches: ["cert", "accessId"],
required: false,
})
)
certId!: number | number[];
@TaskInput(
createRemoteSelectInputDefine({
title: "网站Id",
helper: "请选择要部署证书的网站Id",
search: true,
action: CdnflyDeployToCDNPlugin.prototype.onGetSiteList.name,
watches: ["url", "cert", "accessId"],
required: false,
})
)
siteId!: number[];
access: CdnflyAccess;
uploadCertId!: number;
async onInstance() {
this.access = await this.getAccess<CdnflyAccess>(this.accessId);
}
async execute(): Promise<void> {
const { cert, siteId, certId } = this;
if (this.autoMatch) {
this.logger.info(`自动匹配站点更新证书`);
await this.updateByDomain();
return;
}
if (certId != null) {
let certIds = certId as number[];
if (!Array.isArray(certId)) {
certIds = [certId];
}
for (const item of certIds) {
await this.updateByCertId(cert, item);
}
}
if (siteId != null) {
let siteIds = siteId as number[];
if (!Array.isArray(siteId)) {
siteIds = [siteId];
}
for (const item of siteIds) {
await this.updateBySiteId(cert, item);
}
}
}
private async updateByCertId(cert: CertInfo, certId: number | number[]) {
this.logger.info(`更新证书,证书ID:${certId}`);
const url = `/v1/certs/${certId}`;
await this.doRequest(url, "PUT", {
cert: cert.crt,
key: cert.key,
});
}
async doRequest(url: string, method: string, data: any) {
return await this.access.doRequest({
url: url,
method: method,
data: data,
});
}
private async updateByDomain() {
//查询站点
const sites = await this.querySite();
for (const row of sites) {
const domains = row.domain.split(" ");
if (this.ctx.utils.domain.match(domains, this.certDomains)) {
this.logger.info(`站点:${row.id},${row.domain},域名已匹配`);
await this.updateBySiteId(this.cert, row.id);
} else {
this.logger.info(`站点:${row.id},${row.domain},域名未匹配`);
}
}
}
private async updateBySiteId(cert: CertInfo, siteId: any) {
const siteInfoUrl = `/v1/sites/${siteId}`;
const site = await this.doRequest(siteInfoUrl, "GET", {});
if (!site) {
throw new Error(`站点:${siteId}不存在`);
}
this.logger.info(`更新站点证书:${siteId}`);
const data = site.data;
let https_listen = data.https_listen;
if (https_listen && typeof https_listen === "string") {
https_listen = JSON.parse(https_listen);
}
if (https_listen?.cert) {
//该网站已有证书id
const certId = https_listen.cert;
this.logger.info(`该站点已有证书,更新证书,证书ID:${certId}`);
await this.updateByCertId(cert, certId);
return;
}
if (!this.uploadCertId) {
//创建证书
this.logger.info(`创建证书,域名:${this.certDomains}`);
const certUrl = `/v1/certs`;
const name = this.buildCertName(this.certDomains[0]);
await this.doRequest(certUrl, "POST", {
name,
type: "custom",
cert: cert.crt,
key: cert.key,
});
const certs: any = await this.doRequest(certUrl, "GET", {
name,
});
this.uploadCertId = certs.data[0].id;
}
const siteUrl = `/v1/sites`;
await this.doRequest(siteUrl, "PUT", { id: site.id, https_listen: { cert: this.uploadCertId } });
}
async querySite(domain?: string) {
const siteUrl = `/v1/sites`;
const query: any = {
limit: 100,
};
if (domain) {
query.domain = domain;
}
const res = await this.doRequest(siteUrl, "GET", query);
return res.data;
}
async queryCert(domain?: string) {
const certUrl = `/v1/certs`;
const query: any = {
limit: 100,
};
if (domain) {
query.domain = domain;
}
const res = await this.doRequest(certUrl, "GET", query);
return res.data;
}
async onGetSiteList(data: { searchKey: string }) {
if (!this.accessId) {
throw new Error("请选择Access授权");
}
const list = await this.querySite(data?.searchKey);
if (!list || list.length === 0) {
throw new Error("没有找到任何站点,您可以手动输入网站Id");
}
const options = list.map((item: any) => {
return {
label: `${item.id}<${item.domain}>`,
value: item.id,
domain: item.domain.split(" "),
};
});
return this.ctx.utils.options.buildGroupOptions(options, this.certDomains);
}
async onGetCertList(data: any) {
if (!this.accessId) {
throw new Error("请选择Access授权");
}
const list = await this.queryCert(data?.searchKey);
if (!list || list.length === 0) {
throw new Error("没有找到证书列表,您可以手动输入证书Id");
}
const options = list.map((item: any) => {
return {
label: `${item.id}<${item.domain}>`,
value: item.id,
domain: item.domain.split(" "),
};
});
return this.ctx.utils.options.buildGroupOptions(options, this.certDomains);
}
}
new CdnflyDeployToCDNPlugin();
@@ -0,0 +1,32 @@
import { IsAccess, AccessInput, BaseAccess } from "@certd/pipeline";
@IsAccess({
name: "ctyun",
title: "天翼云授权",
desc: "",
icon: "ant-design:aliyun-outlined",
order: 2,
})
export class CtyunAccess extends BaseAccess {
@AccessInput({
title: "accessKeyId",
component: {
placeholder: "accessKeyId",
},
helper: "[前往创建天翼云AccessKey](https://iam.ctyun.cn/myAccessKey)",
required: true,
})
accessKeyId = "";
@AccessInput({
title: "securityKey",
component: {
placeholder: "securityKey",
},
required: true,
encrypt: true,
helper: "",
})
securityKey = "";
}
new CtyunAccess();
@@ -0,0 +1,3 @@
export * from "./plugins/index.js";
export * from "./access/ctyun-access.js";
export * from "./lib.js";
@@ -0,0 +1,249 @@
import { HttpClient, ILogger } from "@certd/basic";
import * as querystring from "node:querystring";
import { CtyunAccess } from "./access/ctyun-access.js";
export type CtyunCdnDomainInfo = {
area_scope: number;
insert_date: number;
domain: string;
cname: string;
record_num: string;
product_code: string;
product_name: string;
status: number;
};
export type CtyunClientOptions = {
access: CtyunAccess;
logger: ILogger;
http: HttpClient;
};
export class CtyunClient {
opts: CtyunClientOptions;
access: CtyunAccess;
constructor(opts: CtyunClientOptions) {
this.opts = opts;
this.access = opts.access;
}
encode(str) {
return Buffer.from(str, "utf-8").toString("base64");
}
// 解码
decode(str) {
return Buffer.from(str, "base64").toString("utf-8");
}
// 用 - 和 _ 来代替 + 和 / ,以适应URL中的传输
urlEncode(str) {
const encoded = this.encode(str);
return encoded.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
urlDecode(str) {
str = str.replace(/-/g, "+").replace(/_/g, "/");
// 补全字符数为4的整数
while (str.length % 4) {
str += "=";
}
return this.decode(str);
}
async hmacsha256(content, key) {
const crypto = await import("crypto");
const h = crypto.createHmac("sha256", Buffer.from(key, "base64")).update(content).digest();
return this.urlEncode(h);
}
async getHeader(uri) {
// AK
const AK = this.access.accessKeyId;
// SK
const SK = this.access.securityKey;
const dateNow = +new Date();
const content = AK + "\n" + dateNow + "\n" + uri;
const authorizedKey = await this.hmacsha256(AK + ":" + parseInt(String(dateNow / 86400000)), SK);
const signature = await this.hmacsha256(content, authorizedKey);
return {
"x-alogic-now": dateNow,
"x-alogic-app": AK,
"x-alogic-signature": signature, // 对本次调用信息的签名
"x-alogic-ac": "app", // 访问控制器id,取固定值app
};
}
/**
* 接口描述:调用本接口创建证书。
* 请求方式:post
* 请求路径:/v1/cert/create
* 使用说明: 单个用户一分钟限制调用10000次,并发不超过100;
*
* 请求参数说明:
*
* 参数 类型 是否必传 名称 描述
* name string 是 证书备注名
* key string 是 证书私钥 仅支持PEM格式
* certs string 是 证书公钥 仅支持PEM格式
* email string 否 用户邮箱
* 返回参数说明:
*
* 参数 类型 是否必传 名称及描述
* code int 是 状态码
* message string 是 描述信息
* id int 是 证书id
* 示例:
* 请求路径:https://open.ctcdn.cn/api/v1/cert/create
*
* 示例1
* 请求参数:
*
* {
* "name": "xxxx",
* "certs": "xxxxx",
* "key": "xxxxxx"
* }
*
* 返回结果:
*
* {
* "code": 100000,
* "message": "success",
* "id": 7028
* }
*/
async doRequest({ uri, method, data }: any) {
const http = this.opts.http;
const body: any = {};
if (method === "get") {
if (data) {
uri = uri + "?" + querystring.stringify(data);
}
} else {
body.data = data;
}
const header = await this.getHeader(uri);
const url = "https://open.ctcdn.cn" + uri;
const res = await http.request({
url,
method,
...body,
headers: {
...header,
"Content-Type": "application/json",
},
});
if (res.code !== 100000) {
throw new Error(`请求失败:${res.message}`);
}
return res;
}
async certCreate(name: string, crt: string, key: string) {
const uri = "/v1/cert/create";
const method = "post";
const data = {
name,
key,
certs: crt,
};
return this.doRequest({ uri, method, data });
}
/**
* 接口描述:调用本接口查询域名列表及域名的基础信息
* 请求方式:get
* 请求路径:/v2/domain/query
* 使用说明:
*
* 新建的域名需要配置部署完毕(预计10分钟),本接口才能查询到
* 单个用户一分钟限制调用10000次,并发不超过10
* 请求参数说明:
*
* 参数
* 类型
* 是否必填
* 名称
* 说明
* access_mode int 否 接入方式 枚举值:1(域名接入方式),2(无域名接入方式),不传默认1
* domain string 否 域名 域名,不填默认所有域名
* instance string 否 实例名称 不超过10个字的中/英文/数字组合;当access_mode=2时,必填
* product_code string 否 产品类型 支持产品类型:“001”(静态加速),“003”(下载加速),“004”(视频点播加速),“008”(CDN加速),“007”(安全加速),“005”(直播加速),“006”(全站加速),“009”(应用加速),“010”(web应用防火墙(边缘云版)),“011”(高防DDoS(边缘云版)),“014”(下载加速闲时),“020”(边缘安全加速),“024”(边缘接入),不填默认所有产品
* status int 否 域名状态 枚举值:4(已启用),6(已停止);不填默认所有状态
* area_scope int 否 加速范围 1(国内);2(海外);3(全球),不填默认所有加速范围
* page int 否 页码 不填默认1
* page_size int 否 每页条数 不填默认50,最大100
* 返回参数说明:
*
* 参数 类型 是否必传 名称及描述
* code int 是 状态码
* message string 是 描述信息
* total int 否 查询结果总条数
* total_count int 否 查询结果总条数
* page int 否 当前页数
* page_size int 否 每页条数
* page_count int 否 查询结果总页数
* result list<object> 否 返回结果列表
* result[*].domain string 否 域名
* result[*].cname string 否 cname
* result[*].product_code string 否 产品类型
* result[*].product_name string 否 产品名称
* result[*].status int 否 域名状态
* result[*].insert_date int 否 域名创建时间,单位毫秒
* result[*].area_scope int 否 加速范围
* result[*].record_num string 否 备案号
* 示例:
* 请求路径:https://open.ctcdn.cn/api/v2/domain/query
*
* 示例1https://open.ctcdn.cn/api/v2/domain/query?page=1&page_size=2
* 返回结果:
*
* {
* "code": 100000,
* "message": "success",
* "total": 52,
* "total_count": 52,
* "page": 1,
* "page_count": 26,
* "page_size": 2,
* "result": [
* {
* "area_scope": 1,
* "insert_date": 1667882163000,
* "domain": "sd54sdhmytest.baidu.ctyun.cn",
* "cname": "sd54sdhmytest.baidu.ctyun.cn.ctadns.cn.",
* "record_num": "京ICP证030173号-1",
* "product_code": "008",
* "product_name": "CDN加速",
* "status": 4
* },
* {
* "area_scope": 1,
* "insert_date": 1666765345000,
* "domain": "sd54sd.baidu.ctyun.cn",
* "cname": "sd54sd.baidu.ctyun.cn.ctadns.cn.",
* "record_num": "京ICP证030173号-1",
* "product_code": "008",
* "product_name": "CDN加速",
* "status": 6
* }
* ]
* }
* @param cert_list
*/
async getDomainList({ productCode }: any): Promise<CtyunCdnDomainInfo[]> {
const uri = "/v2/domain/query";
const method = "get";
const data = {
product_code: productCode,
page_size: 100,
};
const res = await this.doRequest({ uri, method, data });
return res.result;
}
}
@@ -0,0 +1 @@
export * from "./plugin-deploy-to-cdn.js";
@@ -0,0 +1,161 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertInfo } from "@certd/plugin-cert";
import { createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from "@certd/plugin-lib";
import { HttpClient } from "@certd/basic";
import { CtyunClient } from "../lib.js";
import { CertApplyPluginNames } from "@certd/plugin-cert";
@IsTaskPlugin({
name: "CtyunDeployToCDN",
title: "天翼云-部署证书到CDN",
icon: "svg:icon-ctyun",
group: pluginGroups.cdn.key,
desc: "部署证书到天翼云CDN和全站加速",
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
needPlus: false,
})
export class CtyunDeployToCDN extends AbstractTaskPlugin {
//证书选择,此项必须要有
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames],
},
required: true,
})
cert!: CertInfo;
@TaskInput(createCertDomainGetterInputDefine())
certDomains!: string[];
@TaskInput({
title: "产品类型",
helper: "加速产品类型",
component: {
name: "a-select",
options: [
/**
* “001”(静态加速),“003”:(下载加速), “004”(视频点播加速),“008”(CDN加速),“006”(全站加速),“007”(安全加速) ,“014”(下载加速闲时)
*/
{ label: "静态加速", value: "001" },
{ label: "下载加速", value: "003" },
{ label: "视频点播加速", value: "004" },
{ label: "CDN加速", value: "008" },
{ label: "全站加速", value: "006" },
{ label: "安全加速", value: "007" },
{ label: "下载加速闲时", value: "014" },
],
},
required: true,
})
productCode: string;
//授权选择框
@TaskInput({
title: "天翼云授权",
helper: "天翼云授权",
component: {
name: "access-selector",
type: "ctyun",
},
required: true,
})
accessId!: string;
@TaskInput(
createRemoteSelectInputDefine({
title: "加速域名",
helper: "请选择加速域名",
typeName: "CtyunDeployToCDN",
action: CtyunDeployToCDN.prototype.onGetDomainList.name,
})
)
domains!: string[];
async onInstance() {}
async execute(): Promise<void> {
/*
接口描述:调用本接口批量修改加速域名配置信息
请求方式:post
请求路径:/v1/domain/batch_update_configuration_information
使用说明:
修改域名之前,您需要先开通对应产品类型的服务,且保证资源包/按需服务有效;
该域名没有在途工单;
单个用户一分钟限制调用10次
*/
const access = await this.getAccess(this.accessId);
const client = new CtyunClient({
access,
http: this.ctx.http,
logger: this.ctx.logger,
});
const certName = this.appendTimeSuffix("certd");
await client.certCreate(certName, this.cert.crt, this.cert.key);
const uri = "/v1/domain/batch_update_configuration_information";
const lockKey = `ctyun-deploy-to-cdn-${this.accessId}`;
await this.ctx.utils.locker.execute(lockKey, async () => {
const res = await client.doRequest({
uri,
method: "post",
data: {
domain: this.domains,
product_code: this.productCode,
cert_name: certName,
https_status: "on",
},
});
const domain_details = res.domain_details;
const errorMessage = "";
for (const domainDetail of domain_details) {
// "code":200002,"domain":"ctyun.handsfree.work","message":"参数cert_name只在https_status为on时才有效"}
if (domainDetail.code !== 100000) {
const thisMessage = `部署失败[${domainDetail.code}]${domainDetail.domain}:${domainDetail.message}`;
if (thisMessage.includes("已有进行中的工单") || errorMessage.includes("域名正在操作中")) {
this.logger.warn(thisMessage);
}
}
}
if (errorMessage) {
throw new Error(errorMessage);
}
await this.ctx.utils.sleep(5000);
});
this.logger.info("部署成功");
}
async onGetDomainList() {
const access = await this.getAccess(this.accessId);
const http: HttpClient = this.ctx.http;
const client = new CtyunClient({
access,
http,
logger: this.ctx.logger,
});
const all = await client.getDomainList({ productCode: this.productCode });
if (!all || all.length === 0) {
throw new Error("未找到加速域名,你可以手动输入");
}
const options = all.map(item => {
return {
label: item.domain,
value: item.domain,
};
});
return this.ctx.utils.options.buildGroupOptions(options, this.certDomains);
}
}
new CtyunDeployToCDN();
@@ -0,0 +1 @@
export * from "./plugins/index.js";
@@ -0,0 +1 @@
export * from "./plugin-upload-to-ftp.js";
@@ -0,0 +1,215 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo, CertReader } from "@certd/plugin-cert";
import { FtpAccess } from "../../../plugin-lib/ftp/access.js";
import { FtpClient } from "../../../plugin-lib/ftp/client.js";
@IsTaskPlugin({
name: "UploadCertToFTP",
title: "FTP-上传证书到FTP",
icon: "mdi:folder-upload-outline",
group: pluginGroups.host.key,
desc: "将证书上传到FTP服务器",
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
needPlus: false,
})
export class UploadCertToFTPPlugin extends AbstractTaskPlugin {
@TaskInput({
title: "证书格式",
helper: "要部署的证书格式,支持pem、pfx、der、jks",
component: {
name: "a-select",
options: [
{ value: "pem", label: "pemNginx等大部分应用" },
{ value: "pfx", label: "pfx,一般用于IIS" },
{ value: "der", label: "der,一般用于Apache" },
{ value: "jks", label: "jks,一般用于JAVA应用" },
{ value: "one", label: "一体化证书,证书和私钥合并为一个pem文件" },
],
},
required: true,
})
certType!: string;
@TaskInput({
title: "PEM证书保存路径",
helper: "需要有写入权限,路径要包含文件名",
component: {
placeholder: "/test/fullchain.pem",
},
mergeScript: `
return {
show: ctx.compute(({form})=>{
return form.certType === 'pem';
})
}
`,
required: true,
rules: [{ type: "filepath" }],
})
crtPath!: string;
@TaskInput({
title: "私钥保存路径",
helper: "需要有写入权限,路径要包含文件名",
component: {
placeholder: "/test/privatekey.pem",
},
mergeScript: `
return {
show: ctx.compute(({form})=>{
return form.certType === 'pem';
})
}
`,
required: true,
rules: [{ type: "filepath" }],
})
keyPath!: string;
@TaskInput({
title: "中间证书保存路径",
helper: "需要有写入权限,路径要包含文件名",
component: {
placeholder: "/test/immediate.pem",
},
mergeScript: `
return {
show: ctx.compute(({form})=>{
return form.certType === 'pem';
})
}
`,
rules: [{ type: "filepath" }],
})
icPath!: string;
@TaskInput({
title: "PFX证书保存路径",
helper: "需要有写入权限,路径要包含文件名",
component: {
placeholder: "/test/cert.pfx",
},
mergeScript: `
return {
show: ctx.compute(({form})=>{
return form.certType === 'pfx';
})
}
`,
required: true,
rules: [{ type: "filepath" }],
})
pfxPath!: string;
@TaskInput({
title: "DER证书保存路径",
helper: "需要有写入权限,路径要包含文件名\n.der和.cer是相同的东西,改个后缀名即可",
component: {
placeholder: "/test/cert.der 或 /test/cert.cer",
},
mergeScript: `
return {
show: ctx.compute(({form})=>{
return form.certType === 'der';
})
}
`,
required: true,
rules: [{ type: "filepath" }],
})
derPath!: string;
@TaskInput({
title: "jks证书保存路径",
helper: "证书原本的保存路径,路径要包含文件名",
component: {
placeholder: "/test/javaapp/cert.jks",
},
mergeScript: `
return {
show: ctx.compute(({form})=>{
return form.certType === 'jks';
})
}
`,
required: true,
rules: [{ type: "filepath" }],
})
jksPath!: string;
@TaskInput({
title: "一体化证书保存路径",
helper: "证书原本的保存路径,路径要包含文件名",
component: {
placeholder: "/app/ssl/one.pem",
},
mergeScript: `
return {
show: ctx.compute(({form})=>{
return form.certType === 'one';
})
}
`,
required: true,
rules: [{ type: "filepath" }],
})
onePath!: string;
//证书选择,此项必须要有
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames],
},
required: true,
})
cert!: CertInfo;
//授权选择框
@TaskInput({
title: "FTP授权",
component: {
name: "access-selector",
type: "ftp",
},
required: true,
})
accessId!: string;
async onInstance() {}
async execute(): Promise<void> {
const { cert, accessId } = this;
const access = await this.getAccess<FtpAccess>(accessId);
const client = new FtpClient({
access,
logger: this.logger,
});
await client.connect(async () => {
const certReader = new CertReader(cert);
const handle = async ({ reader, tmpCrtPath, tmpKeyPath, tmpDerPath, tmpPfxPath, tmpIcPath, tmpJksPath, tmpOnePath }) => {
try {
await client.upload(tmpCrtPath, this.crtPath);
await client.upload(tmpKeyPath, this.keyPath);
await client.upload(tmpIcPath, this.icPath);
await client.upload(tmpPfxPath, this.pfxPath);
await client.upload(tmpDerPath, this.derPath);
await client.upload(tmpJksPath, this.jksPath);
await client.upload(tmpOnePath, this.onePath);
} catch (e) {
this.logger.error("请确认路径是否包含文件名,路径本身不能是目录,路径不能有*?之类的特殊符号,要有写入权限");
throw e;
}
};
await certReader.readCertFile({ logger: this.logger, handle });
});
this.logger.info("执行完成");
}
}
new UploadCertToFTPPlugin();
@@ -0,0 +1,21 @@
export * from "./baota/index.js";
export * from "./yidun/index.js";
export * from "./ftp/index.js";
export * from "./cdnfly/index.js";
export * from "./synology/index.js";
export * from "./k8s/index.js";
export * from "./1panel/index.js";
export * from "./baidu/index.js";
export * from "./lecdn/index.js";
export * from "./baishan/index.js";
export * from "./plesk/index.js";
export * from "./yizhifu/index.js";
export * from "./alipay/index.js";
export * from "./wxpay/index.js";
export * from "./safeline/index.js";
export * from "./ctyun/index.js";
export * from "./lucky/index.js";
export * from "./kuocai/index.js";
export * from "./unicloud/index.js";
export * from "./maoyun/index.js";
export * from "./xinnet/index.js";
@@ -0,0 +1,34 @@
import { IsAccess, AccessInput, BaseAccess } from "@certd/pipeline";
@IsAccess({
name: "k8s",
title: "k8s授权",
desc: "",
icon: "mdi:kubernetes",
})
export class K8sAccess extends BaseAccess {
@AccessInput({
title: "kubeconfig",
component: {
name: "a-textarea",
vModel: "value",
placeholder: "kubeconfig",
},
required: true,
encrypt: true,
})
kubeconfig = "";
@AccessInput({
title: "忽略证书校验",
component: {
name: "a-switch",
vModel: "checked",
},
required: false,
encrypt: false,
})
skipTLSVerify: boolean;
}
new K8sAccess();
@@ -0,0 +1,2 @@
export * from "./access.js";
export * from "./plugins/index.js";
@@ -0,0 +1,3 @@
export * from "./plugin-secret.js";
export * from "./plugin-ingress.js";
export * from "./plugin-apply.js";
@@ -0,0 +1,135 @@
import { utils } from "@certd/basic";
import { IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo, CertReader } from "@certd/plugin-cert";
import dayjs from "dayjs";
import { get } from "lodash-es";
import { K8sAccess } from "../access.js";
import { AbstractPlusTaskPlugin } from "@certd/plugin-lib";
@IsTaskPlugin({
name: "K8sApply",
title: "K8S-Apply自定义yaml",
icon: "mdi:kubernetes",
desc: "apply自定义yaml到k8s",
needPlus: true,
group: pluginGroups.panel.key,
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
})
export class K8sApplyPlugin extends AbstractPlusTaskPlugin {
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames],
},
required: true,
})
cert!: CertInfo;
@TaskInput({
title: "前置任务输出",
helper: "请选择前置任务输出的内容",
component: {
name: "output-selector",
from: ["::"],
},
required: false,
})
preOutput!: any;
@TaskInput({
title: "k8s授权",
helper: "kubeconfig",
component: {
name: "access-selector",
type: "k8s",
},
required: true,
})
accessId!: string;
// @TaskInput({
// title: "命名空间",
// value: "default",
// component: {
// placeholder: "命名空间",
// },
// required: true,
// })
// namespace!: string;
@TaskInput({
title: "yaml",
required: true,
helper: "apply yaml,模板变量:主域名=${mainDomain}、全部域名=${domains}、过期时间=${expiresTime}、证书PEM=${crt}、证书私钥=${key}、中间证书/CA证书=${ic}、前置任务输出=${preOutput}",
component: {
name: "a-textarea",
vModel: "value",
rows: 6,
},
})
yamlContent!: string;
K8sClient: any;
async onInstance() {
const sdk = await import("@certd/lib-k8s");
this.K8sClient = sdk.K8sClient;
}
async execute(): Promise<void> {
const access: K8sAccess = await this.getAccess(this.accessId);
const client = new this.K8sClient({
kubeConfigStr: access.kubeconfig,
logger: this.logger,
skipTLSVerify: access.skipTLSVerify,
});
if (!this.yamlContent) {
throw new Error("yamlContent is empty");
}
const certReader = new CertReader(this.cert);
const mainDomain = certReader.getMainDomain();
const domains = certReader.getAllDomains().join(",");
const data = {
mainDomain,
domains,
expiresTime: dayjs(certReader.expires).format("YYYY-MM-DD HH:mm:ss"),
crt: this.cert.crt,
key: this.cert.key,
ic: this.cert.ic,
preOutput: this.preOutput,
};
const compile = this.compile(this.yamlContent);
const compiledYaml = compile(data);
// 解析 YAML 内容(可能包含多个文档)
// const yamlDocs = yaml.loadAll(compiledYaml);
try {
// this.logger.info("apply yaml:", compiledYaml);
// this.logger.info("apply yamlDoc:", JSON.stringify(doc));
const res = await client.apply(compiledYaml);
this.logger.info("apply result:", res);
} catch (e) {
if (e.response?.body) {
throw new Error(JSON.stringify(e.response.body));
}
throw e;
}
await utils.sleep(5000); // 停留2秒,等待secret部署完成
}
compile(templateString: string) {
return function (data) {
return templateString.replace(/\${(.*?)}/g, (match, key) => {
const value = get(data, key, "");
return String(value);
});
};
}
}
new K8sApplyPlugin();
@@ -0,0 +1,130 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { utils } from "@certd/basic";
import { CertInfo } from "@certd/plugin-cert";
import { K8sAccess } from "../access.js";
import { CertApplyPluginNames } from "@certd/plugin-cert";
@IsTaskPlugin({
name: "K8sDeployToIngress",
title: "K8S-Ingress 证书部署",
icon: "mdi:kubernetes",
desc: "部署证书到k8s的Ingress",
needPlus: false,
group: pluginGroups.panel.key,
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
})
export class K8sDeployToIngressPlugin extends AbstractTaskPlugin {
@TaskInput({
title: "命名空间",
value: "default",
component: {
placeholder: "命名空间",
},
required: true,
})
namespace!: string;
@TaskInput({
title: "IngressName",
required: true,
helper: "Ingress名称,根据名称查找证书Secret,然后更新",
})
ingressName!: string;
@TaskInput({
title: "k8s授权",
helper: "kubeconfig",
component: {
name: "access-selector",
type: "k8s",
},
required: true,
})
accessId!: string;
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames],
},
required: true,
})
cert!: CertInfo;
@TaskInput({
title: "Secret自动创建",
helper: "如果Secret不存在,则创建",
value: false,
component: {
name: "a-switch",
vModel: "checked",
},
})
createOnNotFound: boolean;
async execute(): Promise<void> {
const access: K8sAccess = await this.getAccess(this.accessId);
const sdk = await import("@certd/lib-k8s");
const K8sClient = sdk.K8sClient;
const k8sClient = new K8sClient({
kubeConfigStr: access.kubeconfig,
logger: this.logger,
skipTLSVerify: access.skipTLSVerify,
});
const ingressList = await k8sClient.getIngressList({
namespace: this.namespace,
});
const ingress = ingressList.items.find((ingress: any) => ingress.metadata.name === this.ingressName);
if (!ingress) {
throw new Error(`Ingress不存在:${this.ingressName}`);
}
if (!ingress.spec.tls) {
throw new Error(`Ingress:${this.ingressName} 还未配置证书,请先手动配置好证书,创建一个Secret`);
}
const secretNames = ingress.spec.tls.map((tls: any) => tls.secretName);
if (!secretNames || secretNames.length === 0) {
throw new Error(`Ingress:${this.ingressName} 未找到证书Secret`);
}
await this.patchNginxCertSecret({ cert: this.cert, k8sClient, secretNames });
await utils.sleep(5000); // 停留5秒,等待secret部署完成
}
async patchNginxCertSecret(options: { cert: CertInfo; k8sClient: any; secretNames: string[] }) {
const { cert, k8sClient } = options;
const crt = cert.crt;
const key = cert.key;
const crtBase64 = Buffer.from(crt).toString("base64");
const keyBase64 = Buffer.from(key).toString("base64");
const { namespace } = this;
const body: any = {
data: {
"tls.crt": crtBase64,
"tls.key": keyBase64,
},
metadata: {
labels: {
certd: this.appendTimeSuffix("certd"),
},
},
};
for (const secret of options.secretNames) {
this.logger.info(`更新ingress cert Secret:${secret}`);
await k8sClient.patchSecret({ namespace, secretName: secret, body, createOnNotFound: this.createOnNotFound });
this.logger.info(`ingress cert Secret已更新:${secret}`);
}
await utils.sleep(5000); // 停留5秒,等待secret部署完成
if (this.ingressName) {
await k8sClient.restartIngress(namespace, [this.ingressName], { certd: this.appendTimeSuffix("certd") });
}
}
}
new K8sDeployToIngressPlugin();
@@ -0,0 +1,148 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { utils } from "@certd/basic";
import { CertInfo } from "@certd/plugin-cert";
import { K8sAccess } from "../access.js";
import { CertApplyPluginNames } from "@certd/plugin-cert";
@IsTaskPlugin({
name: "K8sDeployToSecret",
title: "K8S-部署证书到Secret",
icon: "mdi:kubernetes",
desc: "部署证书到k8s的secret",
needPlus: false,
group: pluginGroups.panel.key,
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
})
export class K8sDeployToSecretPlugin extends AbstractTaskPlugin {
@TaskInput({
title: "命名空间",
value: "default",
component: {
placeholder: "命名空间",
},
required: true,
})
namespace!: string;
@TaskInput({
title: "保密字典Id",
component: {
name: "a-select",
vModel: "value",
mode: "tags",
open: false,
},
helper: "原本存储证书的secret的name",
required: true,
})
secretName!: string | string[];
@TaskInput({
title: "k8s授权",
helper: "kubeconfig",
component: {
name: "access-selector",
type: "k8s",
},
required: true,
})
accessId!: string;
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames],
},
required: true,
})
cert!: CertInfo;
@TaskInput({
title: "ingress名称",
required: false,
helper: "填写之后会自动重启ingress",
component: {
name: "a-select",
vModel: "value",
mode: "tags",
open: false,
},
})
ingressName!: string[];
@TaskInput({
title: "Secret自动创建",
helper: "如果Secret不存在,则创建",
value: false,
component: {
name: "a-switch",
vModel: "checked",
},
})
createOnNotFound: boolean;
K8sClient: any;
async onInstance() {
const sdk = await import("@certd/lib-k8s");
this.K8sClient = sdk.K8sClient;
}
async execute(): Promise<void> {
const access: K8sAccess = await this.getAccess(this.accessId);
const k8sClient = new this.K8sClient({
kubeConfigStr: access.kubeconfig,
logger: this.logger,
skipTLSVerify: access.skipTLSVerify,
});
try {
await this.patchCertSecret({ cert: this.cert, k8sClient });
} catch (e) {
if (e.response?.body) {
throw new Error(JSON.stringify(e.response.body));
}
throw e;
}
await utils.sleep(5000); // 停留2秒,等待secret部署完成
}
async patchCertSecret(options: { cert: CertInfo; k8sClient: any }) {
const { cert, k8sClient } = options;
const crt = cert.crt;
const key = cert.key;
const crtBase64 = Buffer.from(crt).toString("base64");
const keyBase64 = Buffer.from(key).toString("base64");
const { namespace, secretName } = this;
const body: any = {
data: {
"tls.crt": crtBase64,
"tls.key": keyBase64,
},
metadata: {
labels: {
certd: this.appendTimeSuffix("certd"),
},
},
};
let secretNames: any = secretName;
if (typeof secretName === "string") {
secretNames = [secretName];
}
for (const secret of secretNames) {
body.metadata.name = secret;
await k8sClient.patchSecret({ namespace, secretName: secret, body, createOnNotFound: this.createOnNotFound });
this.logger.info(`ingress cert Secret已更新:${secret}`);
}
await utils.sleep(5000); // 停留5秒,等待secret部署完成
if (this.ingressName && this.ingressName.length > 0) {
await k8sClient.restartIngress(namespace, this.ingressName, { certd: this.appendTimeSuffix("certd") });
}
}
}
new K8sDeployToSecretPlugin();
@@ -0,0 +1,35 @@
import { IsAccess, AccessInput, BaseAccess } from "@certd/pipeline";
/**
* 这个注解将注册一个授权配置
* 在certd的后台管理系统中,用户可以选择添加此类型的授权
*/
@IsAccess({
name: "kuocaicdn",
title: "括彩云cdn授权",
icon: "material-symbols:shield-outline",
desc: "括彩云CDN,每月免费30G[注册即领](https://kuocaicdn.com/register?code=8mn536rrzfbf8)",
})
export class KuocaiCdnAccess extends BaseAccess {
@AccessInput({
title: "账户",
component: {
placeholder: "手机号",
},
required: true,
encrypt: true,
})
username = "";
@AccessInput({
title: "密码",
component: {
placeholder: "password",
},
required: true,
encrypt: true,
})
password = "";
}
new KuocaiCdnAccess();
@@ -0,0 +1,2 @@
export * from "./plugins/index.js";
export * from "./access.js";
@@ -0,0 +1 @@
export * from "./plugin-deploy-to-cdn.js";
@@ -0,0 +1,168 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert";
import { createRemoteSelectInputDefine } from "@certd/plugin-lib";
import { KuocaiCdnAccess } from "../access.js";
@IsTaskPlugin({
name: "KuocaiDeployToRCDN",
title: "括彩云-部署到括彩云CDN",
icon: "material-symbols:shield-outline",
group: pluginGroups.cdn.key,
desc: "括彩云CDN,每月免费30G[注册即领](https://kuocaicdn.com/register?code=8mn536rrzfbf8)",
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
needPlus: false,
})
export class KuocaiDeployToCDNPlugin extends AbstractTaskPlugin {
//证书选择,此项必须要有
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames],
},
required: true,
})
cert!: CertInfo;
//授权选择框
@TaskInput({
title: "括彩云CDN授权",
helper: "括彩云CDN授权",
component: {
name: "access-selector",
type: "kuocaicdn",
},
required: true,
})
accessId!: string;
@TaskInput(
createRemoteSelectInputDefine({
title: "域名列表",
helper: "选择要部署证书的站点域名",
typeName: "KuocaiDeployToCDNPlugin",
action: KuocaiDeployToCDNPlugin.prototype.onGetDomainList.name,
})
)
domains!: string[];
async onInstance() {}
async execute(): Promise<void> {
const access = await this.getAccess<KuocaiCdnAccess>(this.accessId);
const loginRes = await this.getLoginToken(access);
const curl = "https://kuocaicdn.com/CdnDomainHttps/httpsConfiguration";
for (const domain of this.domains) {
// const data = {
// doMainId: domain,
// https: {
// https_status: "off"
// },
// }
// //先关闭https
// const res = await this.doRequest(curl, loginRes, data);
const cert = this.cert;
const update = {
doMainId: domain,
https: {
https_status: "on",
certificate_name: this.appendTimeSuffix("certd"),
certificate_source: "0",
certificate_value: cert.crt,
private_key: cert.key,
},
};
await this.doRequest(curl, loginRes, update);
this.logger.info(`站点${domain}证书更新成功`);
}
}
async getLoginToken(access: KuocaiCdnAccess) {
const url = "https://kuocaicdn.com/login/loginUser";
const data = {
userAccount: access.username,
userPwd: access.password,
remember: true,
};
const http = this.ctx.http;
const res: any = await http.request({
url,
method: "POST",
data,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
returnOriginRes: true,
});
if (!res.data?.success) {
throw new Error(res.data?.message);
}
const jsessionId = this.ctx.utils.request.getCookie(res, "JSESSIONID");
const token = res.data?.data;
return {
jsessionId,
token,
};
}
async getDomainList(loginRes: any) {
const url = "https://kuocaicdn.com/CdnDomain/queryForDatatables";
const data = {
draw: 1,
start: 0,
length: 1000,
search: {
value: "",
regex: false,
},
};
const res = await this.doRequest(url, loginRes, data);
return res.data?.data;
}
private async doRequest(url: string, loginRes: any, data: any) {
const http = this.ctx.http;
const res: any = await http.request({
url,
method: "POST",
headers: {
Cookie: `JSESSIONID=${loginRes.jsessionId};kuocai_cdn_token=${loginRes.token}`,
},
data,
});
if (!res.success) {
throw new Error(res.message);
}
return res;
}
async onGetDomainList(data: any) {
if (!this.accessId) {
throw new Error("请选择Access授权");
}
const access = await this.getAccess<KuocaiCdnAccess>(this.accessId);
const loginRes = await this.getLoginToken(access);
const list = await this.getDomainList(loginRes);
if (!list || list.length === 0) {
throw new Error("您账户下还没有站点域名,请先添加域名");
}
return list.map((item: any) => {
return {
label: `${item.domainName}<${item.id}>`,
value: item.id,
};
});
}
}
new KuocaiDeployToCDNPlugin();
@@ -0,0 +1,90 @@
import { IsAccess, AccessInput, BaseAccess } from "@certd/pipeline";
/**
*/
@IsAccess({
name: "lecdn",
title: "LeCDN授权",
desc: "",
icon: "material-symbols:shield-outline",
})
export class LeCDNAccess extends BaseAccess {
@AccessInput({
title: "LeCDN系统网址",
component: {
name: "a-input",
vModel: "value",
},
required: true,
helper: "例如:http://demo.xxxx.cn",
})
url!: string;
@AccessInput({
title: "认证类型",
component: {
placeholder: "请选择",
name: "a-select",
vModel: "value",
options: [
{ value: "token", label: "API访问令牌" },
{ value: "password", label: "账号密码(旧版本)" },
],
},
required: true,
})
type!: string;
@AccessInput({
title: "用户名",
component: {
placeholder: "username",
},
mergeScript: `
return {
show:ctx.compute(({form})=>{
return form.access.type === 'password';
})
}
`,
required: true,
encrypt: false,
})
username = "";
@AccessInput({
title: "登录密码",
component: {
placeholder: "password",
},
required: true,
encrypt: true,
mergeScript: `
return {
show:ctx.compute(({form})=>{
return form.access.type === 'password';
})
}
`,
})
password = "";
@AccessInput({
title: "Api访问令牌",
component: {
placeholder: "apiToken",
},
required: true,
encrypt: true,
mergeScript: `
return {
show:ctx.compute(({form})=>{
return form.access.type === 'token';
})
}
`,
})
apiToken = "";
}
new LeCDNAccess();
@@ -0,0 +1,2 @@
export * from "./plugins/index.js";
export * from "./access.js";
@@ -0,0 +1,2 @@
export * from "./plugin-update-cert.js";
export * from "./plugin-update-cert-v2.js";
@@ -0,0 +1,175 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert";
import { LeCDNAccess } from "../access.js";
import { merge } from "lodash-es";
import { createRemoteSelectInputDefine } from "@certd/plugin-lib";
import { utils } from "@certd/basic";
@IsTaskPlugin({
name: "LeCDNUpdateCertV2",
title: "LeCDN-更新证书V2",
desc: "支持新版本LeCDN",
icon: "material-symbols:shield-outline",
group: pluginGroups.cdn.key,
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
needPlus: false,
})
export class LeCDNUpdateCertV2 extends AbstractTaskPlugin {
//授权选择框
@TaskInput({
title: "LeCDN授权",
component: {
name: "access-selector",
type: "lecdn",
},
required: true,
})
accessId!: string;
//测试参数
@TaskInput(
createRemoteSelectInputDefine({
title: "证书ID",
helper: "选择要更新的证书id,注意域名是否与证书匹配",
typeName: "LeCDNUpdateCertV2",
action: LeCDNUpdateCertV2.prototype.onGetCertList.name,
})
)
certIds!: number[];
//证书选择,此项必须要有
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames],
},
required: true,
})
cert!: CertInfo;
access: LeCDNAccess;
token: string;
async onInstance() {
this.access = await this.getAccess<LeCDNAccess>(this.accessId);
this.token = await this.getToken();
}
async doRequest(config: any) {
const access = this.access;
const Authorization = this.access.type === "token" ? this.access.apiToken : `Bearer ${this.token}`;
const res = await this.ctx.http.request({
baseURL: access.url,
headers: {
Authorization,
},
...config,
});
this.checkRes(res);
return res.data;
}
async getToken() {
if (this.access.type === "token") {
return this.access.apiToken;
}
// http://cdnadmin.kxfox.com/prod-api/login
const access = this.access;
const res = await this.ctx.http.request({
url: `/prod-api/login`,
baseURL: access.url,
method: "post",
data: {
//新旧版本不一样,旧版本是username,新版本是email
email: access.username,
username: access.username,
password: access.password,
},
});
this.checkRes(res);
//新旧版本不一样,旧版本是access_token,新版本是token
return res.data.access_token || res.data.token;
}
async getCertInfo(id: number) {
// http://cdnadmin.kxfox.com/prod-api/certificate/9
// Bearer edGkiOiIJ8
return await this.doRequest({
url: `/prod-api/certificate/${id}`,
method: "get",
});
}
async updateCert(id: number, cert: CertInfo) {
const certInfo = await this.getCertInfo(id);
const body = {
ssl_key: utils.hash.base64(cert.key),
ssl_pem: utils.hash.base64(cert.crt),
};
merge(certInfo, body);
this.logger.info(`证书名称:${certInfo.name}`);
return await this.doRequest({
url: `/prod-api/certificate/${id}`,
method: "put",
data: certInfo,
});
}
async execute(): Promise<void> {
for (const certId of this.certIds) {
this.logger.info(`更新证书:${certId}`);
await this.updateCert(certId, this.cert);
this.logger.info(`更新证书成功:${certId}`);
}
this.logger.info(`更新证书完成`);
}
private checkRes(res: any) {
if (res.code !== 0 && res.code !== 200) {
throw new Error(res.message);
}
}
async onGetCertList(data: any) {
if (!this.accessId) {
throw new Error("请选择Access授权");
}
const res = await this.getCerts();
//新旧版本不一样,一个data 一个是items
const list = res.items || res.data;
if (!res || list.length === 0) {
throw new Error("没有找到证书,请先手动上传一次证书,并让站点使用该证书");
}
return list.map((item: any) => {
return {
label: `${item.name}-${item.domain_name}<${item.id}>`,
value: item.id,
};
});
}
private async getCerts() {
// http://cdnadmin.kxfox.com/prod-api/certificate?current_page=1&total=3&page_size=10
return await this.doRequest({
url: `/prod-api/certificate`,
method: "get",
params: {
current_page: 1,
page_size: 1000,
},
});
}
}
new LeCDNUpdateCertV2();
@@ -0,0 +1,165 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertInfo } from "@certd/plugin-cert";
import { LeCDNAccess } from "../access.js";
import { merge } from "lodash-es";
import { createRemoteSelectInputDefine } from "@certd/plugin-lib";
import { CertApplyPluginNames } from "@certd/plugin-cert";
@IsTaskPlugin({
name: "LeCDNUpdateCert",
title: "LeCDN-更新证书",
icon: "material-symbols:shield-outline",
group: pluginGroups.cdn.key,
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
needPlus: false,
})
export class LeCDNUpdateCert extends AbstractTaskPlugin {
//授权选择框
@TaskInput({
title: "LeCDN授权",
component: {
name: "access-selector",
type: "lecdn",
},
required: true,
})
accessId!: string;
//测试参数
@TaskInput(
createRemoteSelectInputDefine({
title: "证书ID",
helper: "选择要更新的证书id,注意域名是否与证书匹配",
typeName: "LeCDNUpdateCert",
action: LeCDNUpdateCert.prototype.onGetCertList.name,
})
)
certIds!: number[];
//证书选择,此项必须要有
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames],
},
required: true,
})
cert!: CertInfo;
access: LeCDNAccess;
token: string;
async onInstance() {
this.access = await this.getAccess<LeCDNAccess>(this.accessId);
this.token = await this.getToken();
}
async doRequest(config: any) {
const access = this.access;
const res = await this.ctx.http.request({
baseURL: access.url,
headers: {
Authorization: `Bearer ${this.token}`,
},
...config,
});
this.checkRes(res);
return res.data;
}
async getToken() {
// http://cdnadmin.kxfox.com/prod-api/login
const access = this.access;
const res = await this.ctx.http.request({
url: `/prod-api/login`,
baseURL: access.url,
method: "post",
data: {
username: access.username,
password: access.password,
},
});
this.checkRes(res);
return res.data.access_token;
}
async getCertInfo(id: number) {
// http://cdnadmin.kxfox.com/prod-api/certificate/9
// Bearer edGkiOiIJ8
return await this.doRequest({
url: `/prod-api/certificate/${id}`,
method: "get",
});
}
async updateCert(id: number, cert: CertInfo) {
const certInfo = await this.getCertInfo(id);
const body = {
ssl_key: cert.key,
ssl_pem: cert.crt,
};
merge(certInfo, body);
this.logger.info(`证书名称:${certInfo.name}`);
return await this.doRequest({
url: `/prod-api/certificate/${id}`,
method: "put",
data: certInfo,
});
}
async execute(): Promise<void> {
for (const certId of this.certIds) {
this.logger.info(`更新证书:${certId}`);
await this.updateCert(certId, this.cert);
this.logger.info(`更新证书成功:${certId}`);
}
this.logger.info(`更新证书完成`);
}
private checkRes(res: any) {
if (res.code !== 0) {
throw new Error(res.message);
}
}
async onGetCertList(data: any) {
if (!this.accessId) {
throw new Error("请选择Access授权");
}
const res = await this.getCerts();
if (!res || res.data.length === 0) {
throw new Error("没有找到证书,请先手动上传一次证书,并让站点使用该证书");
}
return res.data.map((item: any) => {
return {
label: `${item.name}-${item.description}<${item.id}>`,
value: item.id,
};
});
}
private async getCerts() {
// http://cdnadmin.kxfox.com/prod-api/certificate?current_page=1&total=3&page_size=10
return await this.doRequest({
url: `/prod-api/certificate`,
method: "get",
params: {
current_page: 1,
page_size: 1000,
},
});
}
}
new LeCDNUpdateCert();
@@ -0,0 +1,54 @@
import { IsAccess, AccessInput, BaseAccess } from "@certd/pipeline";
/**
* 这个注解将注册一个授权配置
* 在certd的后台管理系统中,用户可以选择添加此类型的授权
*/
@IsAccess({
name: "lucky",
title: "lucky",
desc: "",
icon: "svg:icon-lucky",
})
export class LuckyAccess extends BaseAccess {
/**
* 授权属性配置
*/
@AccessInput({
title: "访问url",
component: {
placeholder: "http://xxx.xx.xx:16301",
},
helper: "不要带安全入口",
required: true,
encrypt: false,
})
url = "";
@AccessInput({
title: "安全入口",
component: {
placeholder: "/your_safe_path",
},
helper: "请参考lucky设置中关于安全入口的配置,",
required: false,
encrypt: true,
})
safePath = "";
/**
* 授权属性配置
*/
@AccessInput({
title: "OpenToken",
component: {
placeholder: "OpenToken",
},
helper: "设置->最下面开发者设置->启用OpenToken",
required: true,
encrypt: true,
})
openToken = "";
}
new LuckyAccess();
@@ -0,0 +1,2 @@
export * from "./plugins/index.js";
export * from "./access.js";
@@ -0,0 +1 @@
export * from "./plugin-upload.js";
@@ -0,0 +1,170 @@
import { IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert";
import { LuckyAccess } from "../access.js";
import { createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from "@certd/plugin-lib";
import { AbstractPlusTaskPlugin } from "@certd/plugin-lib";
import { isArray } from "lodash-es";
@IsTaskPlugin({
//命名规范,插件类型+功能(就是目录plugin-demo中的demo),大写字母开头,驼峰命名
name: "LuckyUpdateCert",
title: "lucky-更新Lucky证书",
icon: "svg:icon-lucky",
//插件分组
group: pluginGroups.panel.key,
needPlus: true,
default: {
//默认值配置照抄即可
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
})
//类名规范,跟上面插件名称(name)一致
export class LuckyUpdateCert extends AbstractPlusTaskPlugin {
//证书选择,此项必须要有
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames],
},
// required: true, // 必填
})
cert!: CertInfo;
@TaskInput(createCertDomainGetterInputDefine({ props: { required: false } }))
certDomains!: string[];
//授权选择框
@TaskInput({
title: "Lucky授权",
component: {
name: "access-selector",
type: "lucky", //固定授权类型
},
required: true, //必填
})
accessId!: string;
//
@TaskInput(
createRemoteSelectInputDefine({
title: "Lucky证书",
helper: "要更新的Lucky证书",
typeName: "LuckyUpdateCert",
action: LuckyUpdateCert.prototype.onGetCertList.name,
watches: ["accessId"],
})
)
certList!: string[];
//插件实例化时执行的方法
async onInstance() {}
//插件执行方法
async execute(): Promise<void> {
const { cert } = this;
const access: LuckyAccess = await this.getAccess<LuckyAccess>(this.accessId);
const list = await this.onGetCertList();
const certMap: any = {};
list.forEach(item => {
certMap[item.value] = item.item;
});
for (const item of this.certList) {
this.logger.info(`开始更新证书:${item}`);
const old = certMap[item];
if (!old) {
throw new Error(`没有找到证书:Key=${item},请确认该证书是否存在`);
}
const remark = old.Remark;
const res = await this.doRequest({
access,
urlPath: "/api/ssl",
method: "PUT",
data: {
AddFrom: "file",
CertBase64: this.ctx.utils.hash.base64(cert.crt),
Enable: true,
Key: item,
MappingChangeScript: "",
MappingPath: "",
MappingToPath: false,
KeyBase64: this.ctx.utils.hash.base64(cert.key),
IssuerCertificate: "",
ExtParams: {},
Remark: remark,
},
});
this.logger.info(`更新成功:${JSON.stringify(res)}`);
}
this.logger.info("部署成功");
}
async doRequest(req: { access: LuckyAccess; urlPath: string; data: any; method?: string }) {
const { access, urlPath, data, method } = req;
let url = `${access.url}/${access.safePath || ""}${urlPath}?_=${Math.floor(new Date().getTime())}`;
// 从第7个字符起,将//替换成/
const protocol = url.substring(0, 7);
let suffix = url.substring(7);
suffix = suffix.replaceAll("//", "/");
suffix = suffix.replaceAll("//", "/");
url = protocol + suffix;
const headers: any = {
// Origin: access.url,
"Content-Type": "application/json",
// "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.6261.95 Safari/537.36",
};
headers["openToken"] = access.openToken;
const res = await this.http.request({
method: method || "POST",
url,
data,
headers,
skipSslVerify: true,
});
if (res.ret !== 0) {
throw new Error(`请求失败:${res.msg}`);
}
return res;
}
async onGetCertList() {
const access: LuckyAccess = await this.getAccess<LuckyAccess>(this.accessId);
const res = await this.doRequest({
access,
urlPath: "/api/ssl",
data: {},
method: "GET",
});
const list = res.list;
if (!list || list.length === 0) {
throw new Error("没有找到证书,请先在SSL/TLS证书页面中手动上传一次证书");
}
const options = list.map((item: any) => {
const certsInfo = item.CertsInfo;
let label = "";
if (isArray(certsInfo)) {
label = item.CertsInfo[0].Domains;
} else {
label = item.CertsInfo.Domains[0];
}
return {
label: `${item.Remark}<${label}>`,
value: item.Key,
item: item,
};
});
return this.ctx.utils.options.buildGroupOptions(options, this.certDomains);
}
}
//实例化一下,注册插件
new LuckyUpdateCert();
@@ -0,0 +1,54 @@
import { IsAccess, AccessInput, BaseAccess } from "@certd/pipeline";
/**
*/
@IsAccess({
name: "maoyun",
title: "猫云授权",
desc: "",
icon: "svg:icon-lucky",
})
export class MaoyunAccess extends BaseAccess {
@AccessInput({
title: "用户名",
component: {
placeholder: "username/手机号/email",
name: "a-input",
vModel: "value",
},
helper: "用户名",
required: true,
})
username!: string;
@AccessInput({
title: "password",
component: {
placeholder: "密码",
component: {
name: "a-input-password",
vModel: "value",
},
},
encrypt: true,
helper: "密码",
required: true,
})
password!: string;
@AccessInput({
title: "HttpProxy",
component: {
placeholder: "http://192.168.x.x:10811",
component: {
name: "a-input",
vModel: "value",
},
},
encrypt: false,
required: false,
})
httpProxy!: string;
}
new MaoyunAccess();
@@ -0,0 +1,98 @@
import { HttpClient, HttpRequestConfig, ILogger } from "@certd/basic";
import { MaoyunAccess } from "./access.js";
export class MaoyunClient {
privateKeyPem = "";
http: HttpClient;
logger: ILogger;
access: MaoyunAccess;
token: string;
constructor(opts: { logger: ILogger; http: HttpClient; access: MaoyunAccess }) {
this.logger = opts.logger;
this.http = opts.http;
this.access = opts.access;
this.privateKeyPem =
"\n-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEAt83xKlUSU0i09/pwwQ0MQQ0v71IULdVGJ3AFo+anwLX1TRCp\nxmY5i+xmT9tshHqiPGN8qeg+lDaqA+iwmS6zqi+KlNmmKJc3kUx/h24MI3nff0xy\nz605ZfDgJhwBkJpTI6Sk4+OLX+lZxOiET0nOT7jrhKiFCKX8+0ZXjTJ1cmdifKaj\nqXmjD+XYZzBwA2fCr1kPq2xKvU097Ksu6QvM+La5X/tt+FJOuedmuqZmsb6YQ+6O\n6mJ0bcY0kFDGNkoeY6dEyeJAkIDJbda3n0I71KwRR2J0CSN3TF+w1hSQa7Hp1rXw\n+zQvR6p7O2VY8zQeZZKRKGl7OGdKW5F79iz2fQIDAQABAoIBAEN0BaRGciI0VY2H\n0CdY1X1uDIBke9lSIpvIhZlfxYJ4hFxS2CtiSo4qJGX8HbgElVNaI17rR0P3R6+F\njoG43OCA7/euZEcTL6ZYD5kw7q16RWYfNSc36A+cNXZm4sAhko9LFeQ4FmcNaQ9V\nUXEToe4p6+zUN3Y0DEJezzSXJvjjjodT5L03i2HCW+/xZIHi6oh1DuXdy7h1Ah8s\nSxN188HsX7/SoDHAxDqi/SSGyoYg/SvtOetPtrcZCfqoHfxkR+jQHNaOTq3vGmsu\np8KPtRBoFvSPMxSSHNLb4qbIFvlWRLNXfIhYnenTPtmCnnqogotZZ9CoCHL9dX5R\nt4q5L6ECgYEA5jYhqpRIhqSZOTJopGgy3LBy5T1PHDTfedTuSxnoywYWCuGNwgjI\nRgd94jcUuizO9euobxvDUTdOZ6LdK1NStfwOspb2NojvlE+9SfC8JDv7ZeRz8egB\nClrT6jtCUr80K1I0eF31ha0YMjgi7WZJvTMp53fqI0b1yQO2FaBNgWUCgYEAzGT6\nay+QlO2Fdt9mqeIJy9QiugItC7lk75fQMg5fa8A8wj9DO86o/2k4rKhl7SPg0H+R\nSJQoZGuS4M2f9muEHnLmVF8EzizuHZoR3HO4mie2adVf9NfAmkFsCluRAZKtQkNc\nt/VwlJEC6dChoZkU8Wzd0fSJKrdhjik2ayGXmzkCgYEAuie9s5UyzIXfTSwhCAkm\nT+TzE8Iu7Y0nxPnVM6+g2kNyoZvgqK23XUGDnuCRhzbiqGPGkQovN8Z0RUOiev1m\n3bgUHoAKWvECYrjURS1AxkAmuy8wPsYvyTLHOBpxOD5bLkjMGyVHe7AL59gTDktv\nh2oPEZibIamo6MJyhCxbYC0CgYAIZhnYL7MsO3phgRqR3oTyiDwJEq/RLIQWSFG4\nzNhk8BhPDxRvL7XIEQXQKndNwEyrpKJOri/euIDnlet9z7s1GRmX2/OxmS0LsFoN\nif/K7djUDn2L7RWwAQI0hsC1pNZTw7raoE5I/JB3FSifIFA4/3U5/GdqhvCOS+k9\ni7rUGQKBgQDPspapfGj2ozgWChJ2xMTGBhJhynM81w3j9w7MLvO/7/U43zYzKzyc\n7YJzApQOSwX/nLdquzi+UIbvuCB3npZVZl52S4f7BBcgLNQpdmcfWrAbDv5lySfn\n/KTN22Wxmhh20QgiNSxj+o+KIgdAgZCgWt7NrkZ5UX7Lo+ZfYU1xbg==\n-----END RSA PRIVATE KEY-----";
}
async sign(data: string) {
const { KJUR, KEYUTIL, hextob64 } = await import("jsrsasign");
const privateKey = KEYUTIL.getKey(this.privateKeyPem);
// 创建签名实例
const signature = new KJUR.crypto.Signature({
alg: "SHA256withRSA",
});
// 初始化私钥
signature.init(privateKey);
// 更新待签名数据(假设原文是字符串)
signature.updateString(data);
// 生成签名(默认返回十六进制字符串)
const hexSignature = signature.sign();
// 转换为 Base64(假设 Ix 是 Base64 编码)
return hextob64(hexSignature);
}
async doRequest(req: HttpRequestConfig) {
const timestamp = Date.now();
let data = "";
if (req.method.toLowerCase() === "get") {
// area_codes=&channel_type=0,1,2&domain_name=&https_status=&nonce=1747242446238&order=&page=1&page_size=10&status=&timestamp=1747242446238
let queryList = [];
for (const key in req.params) {
queryList.push(`${key}=${req.params[key]}`);
}
queryList.push(`nonce=${timestamp}`);
queryList.push(`timestamp=${timestamp}`);
//sort
queryList = queryList.sort();
data = queryList.join("&");
} else {
data = `body=${JSON.stringify(req.data || {})}&nonce=${timestamp}&timestamp=${timestamp}`;
}
const sign = await this.sign(data);
const headers: any = {
sign: sign,
timestamp: timestamp,
nonce: timestamp,
};
if (this.token) {
headers.Token = this.token;
}
const res = await this.http.request({
...req,
headers,
baseURL: "https://testaa.5678.jp",
});
if (!res.success && res.code !== 200) {
throw new Error(`请求失败:${res.msg}`);
}
return res.data;
}
async login() {
const req = {
email: this.access.username,
password: this.access.password,
accountType: 1,
};
const res = await this.doRequest({
url: "/api/vcloud/v1/userApi/noAuth/login",
method: "post",
data: req,
logRes: false,
logParams: false,
});
const { token } = res;
this.logger.info(`登录成功`);
this.token = token;
}
}
@@ -0,0 +1,4 @@
// 隐藏 通过下载插件形式分发
// export * from "./plugins/index.js";
export * from "./access.js";
export * from "./client.js";
@@ -0,0 +1 @@
// export * from "./plugin-deploy-to-cdn.js";
@@ -0,0 +1,150 @@
import { IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert";
import { createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from "@certd/plugin-lib";
import { AbstractPlusTaskPlugin } from "@certd/plugin-lib";
import { MaoyunAccess } from "../access.js";
import { MaoyunClient } from "../client.js";
@IsTaskPlugin({
//命名规范,插件类型+功能(就是目录plugin-demo中的demo),大写字母开头,驼峰命名
name: "MaoyunDeployToCdn",
title: "Maoyun-更新猫云CDN证书",
icon: "svg:icon-lucky",
//插件分组
group: pluginGroups.cdn.key,
needPlus: true,
default: {
//默认值配置照抄即可
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
})
//类名规范,跟上面插件名称(name)一致
export class MaoyunDeployToCdn extends AbstractPlusTaskPlugin {
//证书选择,此项必须要有
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames],
},
// required: true, // 必填
})
cert!: CertInfo;
@TaskInput(createCertDomainGetterInputDefine({ props: { required: false } }))
certDomains!: string[];
//授权选择框
@TaskInput({
title: "Maoyun授权",
component: {
name: "access-selector",
type: "maoyun", //固定授权类型
},
required: true, //必填
})
accessId!: string;
//
@TaskInput(
createRemoteSelectInputDefine({
title: "CDN加速域名",
helper: "要部署证书的域名",
action: MaoyunDeployToCdn.prototype.onGetDomainList.name,
watches: ["accessId"],
})
)
domainList!: string[];
//插件实例化时执行的方法
async onInstance() {}
//插件执行方法
async execute(): Promise<void> {
const { cert } = this;
const access: MaoyunAccess = await this.getAccess<MaoyunAccess>(this.accessId);
const client = new MaoyunClient({
http: this.ctx.http,
logger: this.logger,
access,
});
await client.login();
for (const item of this.domainList) {
this.logger.info(`开始更新证书:${item}`);
// https://testaa.5678.jp/cdn/domain/6219/https_conf
/**
* {status: 1, new_certificate: {name: "certd",…}}
* new_certificate
* :
* {name: "certd",…}
* content
* :
* name
* :
* "certd"
* private_key
* :
* status
* :
* 1
*/
await client.doRequest({
method: "PUT",
url: `/cdn/domain/${item}/https_conf`,
data: {
status: 1,
new_certificate: {
name: this.appendTimeSuffix("certd"),
content: cert.crt,
private_key: cert.key,
},
},
});
this.logger.info(`部署${item}证书成功`);
}
this.logger.info("部署完成");
}
async onGetDomainList() {
const access: MaoyunAccess = await this.getAccess<MaoyunAccess>(this.accessId);
const client = new MaoyunClient({
http: this.ctx.http,
logger: this.logger,
access,
});
await client.login();
const res = await client.doRequest({
url: "/cdn/domain",
data: {},
params: {
channel_type: "0,1,2",
page: 1,
page_size: 1000,
},
method: "GET",
});
const list = res.data;
if (!list || list.length === 0) {
throw new Error("没有找到加速域名,请先在控制台添加加速域名");
}
const options = list.map((item: any) => {
return {
label: `${item.domain}<${item.id}>`,
value: item.id,
domain: item.domain,
};
});
return this.ctx.utils.options.buildGroupOptions(options, this.certDomains);
}
}
//实例化一下,注册插件
new MaoyunDeployToCdn();
@@ -0,0 +1,204 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
import FormData from "form-data";
import { HttpError, HttpRequestConfig } from "@certd/basic";
export type PleskReq = {
sessionId?: string;
token?: string;
formData?: FormData;
checkRes?: boolean;
} & HttpRequestConfig;
/**
*/
@IsAccess({
name: "plesk",
title: "plesk授权",
desc: "",
icon: "svg:icon-plesk",
})
export class PleskAccess extends BaseAccess {
@AccessInput({
title: "Plesk网址",
component: {
name: "a-input",
vModel: "value",
},
required: true,
helper: "例如:https://xxxx.xxxxx:8443/",
})
url!: string;
@AccessInput({
title: "用户名",
component: {
placeholder: "username",
},
required: true,
encrypt: false,
})
username = "";
@AccessInput({
title: "登录密码",
component: {
placeholder: "password",
},
required: true,
encrypt: true,
})
password = "";
@AccessInput({
title: "测试",
component: {
name: "api-test",
action: "onTestRequest",
},
helper: "点击测试接口看是否正常",
})
testRequest = true;
async onTestRequest() {
const sessionId = await this.getSessionId();
if (!sessionId) {
throw new Error("获取sessionId失败");
}
return "ok";
}
async getSessionId() {
const formData = new FormData();
formData.append("login_name", this.username);
formData.append("passwd", this.password);
formData.append("locale_id", "default");
try {
await this.ctx.http.request({
url: `/login_up.php`,
baseURL: this.url,
method: "post",
headers: {
origin: this.url,
...formData.getHeaders(),
},
data: formData,
withCredentials: true,
logRes: false,
returnOriginRes: true,
maxRedirects: 0,
});
} catch (e: any) {
if (e && e instanceof HttpError && e.status === 303 && e.response) {
//获取cookie
const cookies = e.response.headers.get("set-cookie");
const sessId = cookies[0].match(/PLESKSESSID=(.*?);/)[1];
if (sessId) {
this.ctx.logger.info(`获取Plesk SessionId 成功`);
return sessId;
}
}
throw e;
}
}
async getCertList(sessionId: string) {
const detail = await this.doGetRequest({
url: "modules/sslit/index.php/main-page/index",
sessionId,
});
// "domainList": [ ... "domainSummaryEnabled":
const listStr = detail.match(/"domainList":(.*?),"domainSummaryEnabled"/)[1];
const list = JSON.parse(listStr);
const token = this.getTokenFromDetail(detail);
return { list, token };
}
getTokenFromDetail(detail: string) {
return detail.match(/forgery_protection_token" content="(.*?)"/)[1];
}
async doGetRequest(req: PleskReq) {
const detail = await this.ctx.http.request({
//https://vps-b6941c0f.vps.ovh.net:8443/modules/sslit/index.php/index/certificate/id/2
url: req.url,
baseURL: this.url,
method: req.method ?? "get",
withCredentials: true,
headers: {
Cookie: `PLESKSESSID=${req.sessionId}`,
},
logRes: false,
});
return detail;
}
async doEditRequest(req: PleskReq) {
const res = await this.ctx.http.request({
url: req.url,
baseURL: this.url,
method: req.method ?? "post",
withCredentials: true,
headers: {
// Origin:
// https://vps-b6941c0f.vps.ovh.net:8443
// Referer:
// https://vps-b6941c0f.vps.ovh.net:8443/modules/sslit/index.php/index/certificate/id/1
Cookie: `PLESKSESSID=${req.sessionId}`,
Origin: this.url,
"X-Forgery-Protection-Token": req.token,
...req.formData.getHeaders(),
},
logRes: req.logRes ?? false,
data: req.formData,
});
if (req.checkRes === false) {
return res;
}
if (res && res.status === "success") {
return res;
} else {
throw new Error(`${JSON.stringify(res.actionMessages || res.statusMessages || res)}`);
}
}
async deleteUnusedCert(req: { sessionId: any; token: string; siteDomainId: number }) {
//查询哪些证书是未使用的
let detail = await this.doGetRequest({
url: `/smb/ssl-certificate/list/id/${req.siteDomainId}`,
sessionId: req.sessionId,
});
detail = detail.substring(detail.indexOf("Plesk.require('app/ssl-certificate/list',"));
// "data": [....] "locale":
const listStr1 = detail.match(/"data":(.*?),"locale"/)[1];
const listStr = listStr1.match(/"data":(.*)/)[1];
const list = JSON.parse(listStr);
const unused = list.filter((item: any) => item.usageCount === "0").map(item => item.id);
if (unused.length === 0) {
this.ctx.logger.info(`没有未使用的证书`);
return;
}
const formData = new FormData();
for (let i = 0; i < unused.length; i++) {
formData.append(`ids[${i}]`, unused[i]);
}
await this.doEditRequest({
url: `/smb/ssl-certificate/delete/id/${req.siteDomainId}`,
sessionId: req.sessionId,
method: "post",
formData,
token: req.token,
logRes: false,
});
this.ctx.logger.info(`删除未使用的证书成功`);
}
}
new PleskAccess();
@@ -0,0 +1,2 @@
export * from "./plugins/index.js";
export * from "./access.js";
@@ -0,0 +1,3 @@
export * from "./plugin-deploy-cert.js";
// 暂时不可用,需要保持私钥不变才能更新证书
// export * from "./plugin-refresh-cert.js";
@@ -0,0 +1,141 @@
import { IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert";
import { AbstractPlusTaskPlugin } from "@certd/plugin-lib";
import { PleskAccess } from "../access.js";
import { createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from "@certd/plugin-lib";
import FormData from "form-data";
@IsTaskPlugin({
name: "PleskDeploySiteCert",
title: "Plesk-部署Plesk网站证书",
icon: "svg:icon-plesk",
group: pluginGroups.panel.key,
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
needPlus: true,
})
export class PleskDeploySiteCert extends AbstractPlusTaskPlugin {
//证书选择,此项必须要有
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames],
},
required: true,
})
cert!: CertInfo;
@TaskInput(createCertDomainGetterInputDefine({ props: { required: false } }))
certDomains!: string[];
//授权选择框
@TaskInput({
title: "Plesk授权",
component: {
name: "access-selector",
type: "plesk",
},
required: true,
})
accessId!: string;
//测试参数
@TaskInput(
createRemoteSelectInputDefine({
title: "网站域名列表",
helper: "选择要更新的站点域名,注意域名是否与证书匹配",
action: PleskDeploySiteCert.prototype.onGetDomainList.name,
})
)
siteDomainIds!: number[];
@TaskInput({
title: "删除未使用证书",
component: {
name: "a-switch",
vModel: "checked",
},
required: false,
})
clearUnused!: boolean;
async execute(): Promise<void> {
const access = await this.getAccess<PleskAccess>(this.accessId);
//get cookie
const sessionId = await access.getSessionId();
for (const siteDomainId of this.siteDomainIds) {
const formData = new FormData();
formData.append("id", siteDomainId);
formData.append("fileContent", this.cert.one);
formData.append("fileName", "cert.pem");
const detail = await access.doGetRequest({
//https://vps-b6941c0f.vps.ovh.net:8443/modules/sslit/index.php/index/certificate/id/2
url: `/modules/sslit/index.php/index/certificate/id/${siteDomainId}`,
sessionId,
});
//获取防伪令牌
// <meta name="forgery_protection_token" id="forgery_protection_token" content="0206c10e5c19c9cbc3ea89ccd485822a">
const token = access.getTokenFromDetail(detail);
await access.doEditRequest({
url: `/modules/sslit/index.php/index/upload/`,
sessionId,
token,
formData,
});
this.logger.info(`部署站点<${siteDomainId}>证书成功`);
//删除未使用的证书
if (this.clearUnused) {
await this.ctx.utils.sleep(3000);
this.logger.info(`开始删除未使用的证书`);
try {
await access.deleteUnusedCert({ sessionId, token, siteDomainId });
} catch (e) {
this.logger.warn(`删除未使用的证书失败:${e.message}`);
}
}
}
this.logger.info(`部署证书完成`);
}
async onGetDomainList(data: any) {
if (!this.accessId) {
throw new Error("请选择Access授权");
}
const access = await this.getAccess<PleskAccess>(this.accessId);
const res = await this.http.request({
url: `/api/v2/domains`,
baseURL: access.url,
method: "get",
headers: {
// authorization: Basic =='
Authorization: `Basic ${Buffer.from(`${access.username}:${access.password}`).toString("base64")}`,
},
});
if (!res || res.length === 0) {
throw new Error("没有找站点域名");
}
const options = res.map((item: any) => {
return {
label: item.name,
value: item.id,
domain: item.name,
};
});
return this.ctx.utils.options.buildGroupOptions(options, this.certDomains);
}
}
new PleskDeploySiteCert();
@@ -0,0 +1,114 @@
import { IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo, CertReader } from "@certd/plugin-cert";
import { AbstractPlusTaskPlugin } from "@certd/plugin-lib";
import { PleskAccess } from "../access.js";
import { createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from "@certd/plugin-lib";
import FormData from "form-data";
@IsTaskPlugin({
name: "PleskRefreshCert",
title: "Plesk-更新证书",
icon: "svg:icon-plesk",
desc: "不会创建新证书记录,直接更新旧的证书",
group: pluginGroups.panel.key,
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
needPlus: true,
})
export class PleskRefreshCert extends AbstractPlusTaskPlugin {
//证书选择,此项必须要有
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames],
},
required: true,
})
cert!: CertInfo;
@TaskInput(createCertDomainGetterInputDefine({ props: { required: false } }))
certDomains!: string[];
//授权选择框
@TaskInput({
title: "Plesk授权",
component: {
name: "access-selector",
type: "plesk",
},
required: true,
})
accessId!: string;
//测试参数
@TaskInput(
createRemoteSelectInputDefine({
title: "证书列表",
helper: "选择要更新的站点域名,注意域名是否与证书匹配",
action: PleskRefreshCert.prototype.onGetCertList.name,
})
)
domainCertIds!: string[];
async execute(): Promise<void> {
const access = await this.getAccess<PleskAccess>(this.accessId);
//get cookie
const sessionId = await access.getSessionId();
const { token } = await access.getCertList(sessionId);
const certReader = new CertReader(this.cert);
for (const certIds of this.domainCertIds) {
const [domainId, certId] = certIds.split("_")[0];
const formData = new FormData();
formData.append("name", this.buildCertName(certReader.getMainDomain()));
formData.append("type", "sendText");
formData.append("uploadText[certificateText]", this.cert.crt);
formData.append("uploadText[privateKeyText]", this.cert.key); // 这里没用
formData.append("forgery_protection_token", token);
const res = await access.doEditRequest({
url: `/smb/ssl-certificate/edit/id/${domainId}/certificateId/${certId}`,
sessionId,
token,
formData,
checkRes: false,
});
this.logger.info(`更新证书成功:${certIds}_${certReader.getMainDomain()}`, res);
}
this.logger.info(`部署证书完成`);
}
async onGetCertList(data: any) {
if (!this.accessId) {
throw new Error("请选择Access授权");
}
const access = await this.getAccess<PleskAccess>(this.accessId);
const sessionId = await access.getSessionId();
const { list } = await access.getCertList(sessionId);
if (!list || list.length === 0) {
throw new Error("没有找到证书");
}
const options = list.map((item: any) => {
return {
label: `${item.domainName}(${item.domainId}_${item.certificateId})`,
value: `${item.domainId}_${item.certificateId}`,
domain: item.domainName,
};
});
return this.ctx.utils.options.buildGroupOptions(options, this.certDomains);
}
}
new PleskRefreshCert();
@@ -0,0 +1,45 @@
import { IsAccess, AccessInput, BaseAccess } from "@certd/pipeline";
/**
* 这个注解将注册一个授权配置
* 在certd的后台管理系统中,用户可以选择添加此类型的授权
*/
@IsAccess({
name: "safeline",
title: "长亭雷池授权",
icon: "svg:icon-safeline",
})
export class SafelineAccess extends BaseAccess {
@AccessInput({
title: "雷池的访问url",
component: {
placeholder: "https://xxxx.com:9443",
},
required: true,
})
baseUrl = "";
@AccessInput({
title: "ApiToken",
component: {
placeholder: "apiToken",
},
helper: "",
required: true,
encrypt: true,
})
apiToken = "";
@AccessInput({
title: "忽略证书校验",
value: true,
component: {
name: "a-switch",
vModel: "checked",
},
helper: "如果面板的url是https,且使用的是自签名证书,则需要开启此选项,其他情况可以关闭",
})
skipSslVerify = true;
}
new SafelineAccess();
@@ -0,0 +1,2 @@
export * from "./plugins/index.js";
export * from "./access.js";
@@ -0,0 +1,124 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { HttpRequestConfig } from "@certd/basic";
import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert";
import { SafelineAccess } from "../access.js";
import { createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from "@certd/plugin-lib";
@IsTaskPlugin({
name: "SafelineDeployToWebsitePlugin",
title: "雷池-更新证书",
icon: "svg:icon-safeline",
desc: "更新长亭雷池WAF的证书",
group: pluginGroups.panel.key,
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
needPlus: false,
})
export class SafelineDeployToWebsitePlugin extends AbstractTaskPlugin {
//证书选择,此项必须要有
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames],
},
required: true,
})
cert!: CertInfo;
@TaskInput(createCertDomainGetterInputDefine({ props: { required: false } }))
certDomains!: string[];
//授权选择框
@TaskInput({
title: "雷池授权",
helper: "长亭雷池授权",
component: {
name: "access-selector",
type: "safeline",
},
required: true,
})
accessId!: string;
@TaskInput(
createRemoteSelectInputDefine({
title: "雷池证书",
typeName: "SafelineDeployToWebsitePlugin",
action: SafelineDeployToWebsitePlugin.prototype.onGetCertIds.name,
helper: "请选择要更新的雷池的证书Id,需要先手动到雷池控制台上传一次",
required: true,
})
)
certIds!: number[];
access: SafelineAccess;
async onInstance() {
this.access = await this.getAccess(this.accessId);
}
async execute(): Promise<void> {
for (const certId of this.certIds) {
await this.uploadCert(certId);
}
this.logger.info("雷池证书更新完成");
}
async uploadCert(certId: number) {
await this.doRequest({
url: "/api/open/cert",
method: "post",
data: {
id: certId,
manual: {
crt: this.cert.crt,
key: this.cert.key,
},
type: 2,
},
});
this.logger.info(`证书<${certId}>更新成功`);
}
async doRequest(config: HttpRequestConfig<any>) {
config.baseURL = this.access.baseUrl;
config.skipSslVerify = this.access.skipSslVerify ?? false;
config.logRes = false;
config.logParams = false;
config.headers = {
"X-SLCE-API-TOKEN": this.access.apiToken,
};
const res = await this.ctx.http.request(config);
if (!res.err) {
return res.data;
}
throw new Error(res.msg);
}
// requestHandle
async onGetCertIds() {
const res = await this.doRequest({
url: "/api/open/cert",
method: "get",
data: {},
});
const nodes = res?.nodes;
if (!nodes || nodes.length === 0) {
throw new Error("没有找到证书,请先在雷池控制台中手动上传证书,并关联防护站点,后续才可以自动更新");
}
const options = nodes.map(item => {
return {
label: `<${item.id}>${item.domains.join(",")}`,
value: item.id,
domain: item.domains,
};
});
return this.ctx.utils.options.buildGroupOptions(options, this.certDomains);
}
}
new SafelineDeployToWebsitePlugin();
@@ -0,0 +1 @@
export * from "./deploy-to-website.js";
@@ -0,0 +1,135 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
import { SynologyClient } from "./client.js";
/**
* 这个注解将注册一个授权配置
* 在certd的后台管理系统中,用户可以选择添加此类型的授权
*/
@IsAccess({
name: "synology",
title: "群晖登录授权",
desc: "",
icon: "simple-icons:synology",
})
export class SynologyAccess extends BaseAccess {
@AccessInput({
title: "群晖版本",
component: {
name: "a-select",
vModel: "value",
options: [
{ label: "7.x", value: "7" },
{ label: "6.x", value: "6" },
],
},
required: true,
})
version = "7";
@AccessInput({
title: "群晖面板的url",
component: {
placeholder: "https://yourdomain:5006",
},
helper: "群晖面板的访问地址,例如:https://yourdomain:5006",
required: true,
})
baseUrl = "";
@AccessInput({
title: "账号",
component: {
placeholder: "账号",
},
helper: "群晖面板登录账号,必须是处于管理员用户组",
required: true,
})
username = "";
@AccessInput({
title: "密码",
component: {
placeholder: "密码",
},
helper: "群晖面板登录密码",
required: true,
encrypt: true,
})
password = "";
@AccessInput({
title: "双重认证",
value: false,
component: {
name: "a-switch",
vModel: "checked",
},
helper: "是否启用了双重认证",
required: true,
})
otp = false;
@AccessInput({
title: "设备ID",
component: {
placeholder: "设备ID",
name: "synology-device-id-getter",
type: "access",
typeName: "synology",
},
mergeScript: `
return {
component:{
form: ctx.compute(({form})=>{
return form
})
},
show: ctx.compute(({form})=>{
return form.access.otp
})
}
`,
helper: `1.如果开启了双重认证,需要获取设备ID
2.填好上面的必填项,然后点击获取设备ID,输入双重认证APP上的码,确认即可获得设备ID,此操作只需要做一次
3.注意:必须勾选‘安全性->允许网页浏览器的用户通过信任设备来跳过双重验证
4.注意:在群晖信任设备页面里面会生成一条记录,不要删除
5.注意:需要将流水线证书申请过期前多少天设置为30天以下,避免设备ID过期`,
required: false,
encrypt: true,
})
deviceId = "";
@AccessInput({
title: "忽略证书校验",
value: true,
component: {
name: "a-switch",
vModel: "checked",
},
helper: "如果面板的url是https,且使用的是自签名证书,则需要开启此选项,其他情况可以关闭",
})
skipSslVerify = true;
/**
* 请求超时时间设置
* @param data
*/
@AccessInput({
title: "请求超时",
value: 120,
component: {
name: "a-input-number",
vModel: "value",
},
helper: "请求超时时间,单位:秒",
})
timeout = 120;
onLoginWithOPTCode(data: { otpCode: string }) {
console.log("onLoginWithOPTCode", this);
const ctx = this.ctx;
const client = new SynologyClient(this, ctx.http, ctx.logger, this.skipSslVerify);
return client.doLoginWithOTPCode(data.otpCode);
}
}
new SynologyAccess();
@@ -0,0 +1,190 @@
import { SynologyAccess } from "./access.js";
import { HttpClient, ILogger } from "@certd/basic";
import qs from "querystring";
export type SynologyAccessToken = {
sid: string;
did?: string;
synotoken: string;
};
export type SynologyRequest = {
method?: string;
apiParams: {
api: string;
version: number;
method: string;
};
params?: any;
data?: any;
form?: any;
headers?: any;
useSynoToken?: boolean;
};
const device_name = "certd";
export class SynologyClient {
access: SynologyAccess;
http: HttpClient;
logger: ILogger;
skipSslVerify: boolean;
token: SynologyAccessToken;
constructor(access: SynologyAccess, http: HttpClient, logger: ILogger, skipSslVerify: boolean) {
this.access = access;
this.http = http;
this.logger = logger;
this.skipSslVerify = skipSslVerify;
}
// 登录 DSM 的函数
async doLogin() {
const access = this.access;
if (access.otp && access.deviceId != null) {
this.logger.info("OTP登录");
return await this.doLoginWithDeviceId(access.deviceId);
}
this.logger.info("使用普通登录");
const loginUrl = this.getLoginUrl();
const res = await this.http.request({
url: loginUrl,
method: "GET",
params: {
api: "SYNO.API.Auth",
version: 6,
method: "login",
account: access.username,
passwd: access.password,
session: "Certd",
format: "sid",
enable_syno_token: "yes",
},
skipSslVerify: this.skipSslVerify ?? true,
timeout: this.access.timeout * 1000 || 120000,
});
if (!res.success) {
throw new Error(`登录失败: `, res.error);
}
this.logger.info("登录成功");
this.token = res.data as SynologyAccessToken;
return this.token;
}
async doLoginWithOTPCode(otpCode: string) {
const loginUrl = this.getLoginUrl();
const access = this.access;
const res = await this.http.request({
url: loginUrl,
method: "GET",
params: {
api: "SYNO.API.Auth",
version: 6,
method: "login",
account: access.username,
passwd: access.password,
otp_code: otpCode,
enable_device_token: "yes",
device_name,
},
timeout: this.access.timeout * 1000 || 30000,
skipSslVerify: this.skipSslVerify ?? true,
});
if (!res.success) {
throw new Error(`登录失败: `, res.error);
}
this.logger.info("登录成功");
this.token = res.data as SynologyAccessToken;
return this.token;
}
private getLoginUrl() {
const access = this.access;
const loginPath = access.version === "6" ? "auth.cgi" : "entry.cgi";
return `${access.baseUrl}/webapi/${loginPath}`;
}
async doLoginWithDeviceId(device_id: string) {
const access = this.access;
const loginUrl = this.getLoginUrl();
const res = await this.http.request({
url: loginUrl,
method: "GET",
params: {
api: "SYNO.API.Auth",
version: 6,
method: "login",
account: access.username,
passwd: access.password,
device_name,
device_id,
session: "Certd",
format: "sid",
enable_syno_token: "yes",
},
timeout: this.access.timeout * 1000 || 30000,
skipSslVerify: this.skipSslVerify ?? true,
});
if (!res.success) {
throw new Error(`登录失败: `, res.error);
}
this.logger.info("登录成功");
this.token = res.data as SynologyAccessToken;
return this.token;
}
async doRequest(req: SynologyRequest) {
const sid = this.token.sid;
const method = req.method || "POST";
const params = {
...req.apiParams,
_sid: sid, // 使用登录后获得的 session ID
...req.params,
SynoToken: this.token.synotoken,
};
const res = await this.http.request({
url: `${this.access.baseUrl}/webapi/entry.cgi?${qs.stringify(params)}`,
method,
data: req.data,
headers: req.headers,
skipSslVerify: this.skipSslVerify ?? true,
timeout: this.access.timeout * 1000 || 30000,
});
if (!res.success) {
throw new Error(`API 调用失败: ${JSON.stringify(res.error)}`);
}
return res.data;
}
async getCertList() {
this.logger.info("获取证书列表");
return await this.doRequest({
method: "GET",
apiParams: {
api: "SYNO.Core.Certificate.CRT",
version: 1,
method: "list",
},
});
}
async getInfo() {
this.logger.info("获取信息");
return await this.doRequest({
method: "GET",
apiParams: {
api: "SYNO.API.Info",
version: 1,
method: "query",
},
});
}
}
@@ -0,0 +1,3 @@
export * from "./plugins/index.js";
export * from "./access.js";
export * from "./client.js";
@@ -0,0 +1 @@
export * from "./plugin-deploy-to-panel.js";
@@ -0,0 +1,139 @@
import { IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertInfo, CertReader } from "@certd/plugin-cert";
import { AbstractPlusTaskPlugin } from "@certd/plugin-lib";
import { SynologyClient } from "../client.js";
import fs from "fs";
import FormData from "form-data";
import { SynologyAccess } from "../access.js";
import { CertApplyPluginNames } from "@certd/plugin-cert";
@IsTaskPlugin({
name: "SynologyDeployToPanel",
title: "群晖-部署证书到群晖面板",
icon: "simple-icons:synology",
group: pluginGroups.panel.key,
desc: "Synology,支持6.x以上版本",
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
needPlus: true,
})
export class SynologyDeployToPanel extends AbstractPlusTaskPlugin {
//测试参数
@TaskInput({
title: "群晖证书描述",
component: {
name: "a-input",
vModel: "value",
placeholder: "群晖证书描述",
},
required: false,
helper: "在群晖证书管理页面里面,选择证书,点击操作,给证书设置描述,然后填写到这里\n如果不填,则覆盖更新全部证书",
})
certName!: string;
//证书选择,此项必须要有
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames],
},
required: true,
})
cert!: CertInfo;
//授权选择框
@TaskInput({
title: "群晖授权",
helper: "群晖登录授权,请确保账户是管理员用户组",
component: {
name: "access-selector",
type: "synology",
},
required: true,
})
accessId!: string;
async onInstance() {}
async execute(): Promise<void> {
const access: SynologyAccess = await this.getAccess<SynologyAccess>(this.accessId);
const client = new SynologyClient(access, this.ctx.http, this.ctx.logger, access.skipSslVerify);
// await client.init();
await client.doLogin();
// const res = await client.getInfo();
// this.logger.info(res);
const certListRes = await client.getCertList();
if (this.certName) {
const certItem = certListRes.certificates.find((item: any) => {
return item.desc === this.certName || item.subject.common_name === this.certName;
});
if (!certItem) {
throw new Error(`未找到证书: ${this.certName}`);
}
this.logger.info(`找到证书: ${certItem.id}`);
await this.updateCertToPanel(client, certItem);
} else {
this.logger.info("开始更新全部证书");
for (const item of certListRes.certificates) {
this.logger.info(`更新证书: ${item.id}`);
await this.updateCertToPanel(client, item);
}
}
}
async updateCertToPanel(client: SynologyClient, certItem: any) {
/**
* query
* api: SYNO.Core.Certificate
* method: import
* version: 1
* SynoToken: Bvum9p7BNeSc6
*
* key: (二进制)
* cert: (二进制)
* inter_cert: (二进制)
* id: yxTtcC
* desc: certd
* as_default:
*/
this.logger.info(`更新证书:${certItem.id}`);
const certReader = new CertReader(this.cert);
return certReader.readCertFile({
logger: this.logger,
handle: async (ctx) => {
const form = new FormData();
const { tmpCrtPath, tmpKeyPath, tmpIcPath } = ctx;
this.logger.info(`上传证书:${tmpCrtPath},${tmpKeyPath}`);
form.append("key", fs.createReadStream(tmpKeyPath));
form.append("cert", fs.createReadStream(tmpCrtPath));
if (certReader.cert.ic) {
this.logger.info(`包含中间证书:${tmpIcPath}`);
form.append("inter_cert", fs.createReadStream(tmpIcPath));
}
form.append("id", certItem.id);
form.append("desc", certItem.desc);
// form传输必须是stringbool要改成string
// form.append("as_default", certItem.is_default + "");
console.log(JSON.stringify(form.getHeaders()));
return await client.doRequest({
method: "POST",
apiParams: {
api: "SYNO.Core.Certificate",
version: 1,
method: "import",
},
data: form,
headers: {
...form.getHeaders(),
},
});
},
});
}
}
new SynologyDeployToPanel();
@@ -0,0 +1,34 @@
import { IsAccess, AccessInput, BaseAccess } from "@certd/pipeline";
/**
*/
@IsAccess({
name: "unicloud",
title: "uniCloud",
icon: "material-symbols:shield-outline",
desc: "unicloud授权",
})
export class UniCloudAccess extends BaseAccess {
@AccessInput({
title: "账号",
component: {
placeholder: "email",
},
helper: "登录邮箱",
required: true,
encrypt: false,
})
email = "";
@AccessInput({
title: "密码",
component: {
placeholder: "密码",
},
required: true,
encrypt: true,
})
password = "";
}
new UniCloudAccess();
@@ -0,0 +1,169 @@
import { UniCloudAccess } from "./access.js";
import { http, HttpClient, HttpRequestConfig, ILogger } from "@certd/basic";
import { CertInfo } from "@certd/plugin-cert";
type UniCloudClientOpts = { access: UniCloudAccess; logger: ILogger; http: HttpClient };
export class UniCloudClient {
opts: UniCloudClientOpts;
deviceId: string;
xToken: string;
token: string;
cookie: string;
constructor(opts: UniCloudClientOpts) {
this.opts = opts;
this.deviceId = new Date().getTime() + Math.floor(Math.random() * 1000000) + "";
}
async sign(data: any, secretKey: string) {
const Crypto = await import("crypto-js");
const CryptoJS = Crypto.default;
let content = "";
Object.keys(data)
.sort()
.forEach(function (key) {
if (data[key]) {
content = content + "&" + key + "=" + data[key];
}
});
content = content.slice(1);
return CryptoJS.HmacMD5(content, secretKey).toString();
}
async doRequest(req: HttpRequestConfig) {
const res = await http.request({
...req,
logRes: false,
returnOriginRes: true,
});
const data = res.data;
if (data.ret != null) {
if (data.ret !== 0) {
throw new Error(JSON.stringify(data));
}
return data.data;
}
if (!data.success) {
throw new Error(JSON.stringify(data.error));
}
if (data.data?.errCode) {
throw new Error(JSON.stringify(data.data));
}
return data.data;
}
async login() {
if (this.xToken) {
return this.xToken;
}
const deviceId = this.deviceId;
const username = this.opts.access.email;
const password = this.opts.access.password;
function getClientInfo(appId) {
return `{"PLATFORM":"web","OS":"windows","APPID":"${appId}","DEVICEID":"${deviceId}","scene":1001,"appId":"${appId}","appLanguage":"zh-Hans","appName":"账号中心","appVersion":"1.0.0","appVersionCode":"100","browserName":"chrome","browserVersion":"122.0.6261.95","deviceId":"174585375190823882061","deviceModel":"PC","deviceType":"pc","hostName":"chrome","hostVersion":"122.0.6261.95","osName":"windows","osVersion":"10 x64","ua":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.6261.95 Safari/537.36","uniCompilerVersion":"4.45","uniPlatform":"web","uniRuntimeVersion":"4.45","locale":"zh-Hans","LOCALE":"zh-Hans"}`;
}
const clientInfo = getClientInfo("__UNI__uniid_server");
const loginData = {
method: "serverless.function.runtime.invoke",
params: `{"functionTarget":"uni-id-co","functionArgs":{"method":"login","params":[{"password":"${password}","captcha":"","resetAppId":"__UNI__unicloud_console","resetUniPlatform":"web","isReturnToken":false,"email":"${username}"}],"clientInfo":${clientInfo}}}`,
spaceId: "uni-id-server",
timestamp: new Date().getTime(),
};
const secretKey = "ba461799-fde8-429f-8cc4-4b6d306e2339";
const xSign = await this.sign(loginData, secretKey);
const res = await this.doRequest({
url: "https://account.dcloud.net.cn/client",
method: "POST",
data: loginData,
headers: {
"X-Serverless-Sign": xSign,
Origin: "https://account.dcloud.net.cn",
Referer: "https://account.dcloud.net.cn",
},
});
const token = res.newToken.token;
// const uid = res.data.uid;
this.xToken = token;
this.opts.logger.info("登录成功:", token);
return token;
}
async getToken() {
if (this.token) {
return {
token: this.token,
cookie: this.cookie,
};
}
const xToken = await this.login();
const deviceId = this.deviceId;
const secretKey = "4c1f7fbf-c732-42b0-ab10-4634a8bbe834";
const clientInfo = `{"PLATFORM":"web","OS":"windows","APPID":"__UNI__unicloud_console","DEVICEID":"${deviceId}","scene":1001,"appId":"__UNI__unicloud_console","appLanguage":"zh-Hans","appName":"uniCloud控制台","appVersion":"1.0.0","appVersionCode":"100","browserName":"chrome","browserVersion":"122.0.6261.95","deviceId":"${deviceId}","deviceModel":"PC","deviceType":"pc","hostName":"chrome","hostVersion":"122.0.6261.95","osName":"windows","osVersion":"10 x64","ua":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.6261.95 Safari/537.36","uniCompilerVersion":"4.57","uniPlatform":"web","uniRuntimeVersion":"4.57","locale":"zh-Hans","LOCALE":"zh-Hans"}`;
const body = {
method: "serverless.function.runtime.invoke",
params: `{"functionTarget":"uni-cloud-kernel","functionArgs":{"action":"user/getUserToken","data":{"isLogin":true},"clientInfo":${clientInfo},"uniIdToken":"${xToken}"}}`,
spaceId: "dc-6nfabcn6ada8d3dd",
timestamp: new Date().getTime(),
};
const xSign = await this.sign(body, secretKey);
const res = await this.doRequest({
url: "https://unicloud.dcloud.net.cn/client",
method: "POST",
data: body,
headers: {
"X-Client-Info": encodeURIComponent(clientInfo),
"X-Serverless-Sign": xSign,
"X-Client-Token": xToken,
Origin: "https://unicloud.dcloud.net.cn",
Referer: "https://unicloud.dcloud.net.cn",
},
});
const token = res.data.data.token;
const cookies = res.headers["set-cookie"];
let cookie = "";
for (let i = 0; i < cookies.length; i++) {
const item = cookies[i].substring(0, cookies[i].indexOf(";"));
cookie += item + ";";
}
this.token = token;
this.opts.logger.info("获取token成功:", token);
this.cookie = cookie;
return {
token,
cookie,
};
}
async createCert(req: { spaceId: string; domain: string; provider: string; cert: CertInfo }) {
await this.getToken();
const { spaceId, domain, cert, provider } = req;
this.opts.logger.info(`开始部署证书, provider:${provider},spaceId:${spaceId},domain:${domain}`);
const crt = encodeURIComponent(cert.crt);
const key = encodeURIComponent(cert.key);
const body = {
appid: "",
provider,
spaceId: spaceId,
domain: domain,
cert: crt,
key,
};
const res = await this.doRequest({
url: "https://unicloud-api.dcloud.net.cn/unicloud/api/host/create-domain-with-cert",
method: "POST",
data: body,
headers: {
Token: this.token,
Cookie: this.cookie,
},
});
this.opts.logger.info("证书部署成功:", JSON.stringify(res));
}
}
@@ -0,0 +1,3 @@
export * from "./plugins/index.js";
export * from "./access.js";
export * from "./client.js";
@@ -0,0 +1 @@
export * from "./plugin-deploy-to-space.js";
@@ -0,0 +1,111 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert";
import { UniCloudAccess } from "../access.js";
import { UniCloudClient } from "../client.js";
@IsTaskPlugin({
name: "UniCloudDeployToSpace",
title: "uniCloud-部署到服务空间",
icon: "material-symbols:shield-outline",
group: pluginGroups.panel.key,
desc: "部署到服务空间",
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
needPlus: false,
})
export class UniCloudDeployToSpace extends AbstractTaskPlugin {
//证书选择,此项必须要有
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames],
},
required: true,
})
cert!: CertInfo;
//授权选择框
@TaskInput({
title: "uniCloud授权",
helper: "uniCloud授权",
component: {
name: "access-selector",
type: "unicloud",
},
required: true,
})
accessId!: string;
//测试参数
@TaskInput({
title: "服务空间ID",
component: {
name: "a-input",
vModel: "value",
},
helper: "spaceId",
})
spaceId!: string;
@TaskInput({
title: "空间提供商",
component: {
name: "a-select",
vModel: "value",
options: [
{
label: "阿里云",
value: "aliyun",
},
{
label: "腾讯云",
value: "tencent",
},
{
label: "支付宝云",
value: "alipay",
},
],
},
helper: "空间提供商",
})
provider!: string;
@TaskInput({
title: "空间域名",
component: {
name: "a-select",
vModel: "value",
mode: "tags",
open: false,
},
helper: "空间域名",
})
domains!: string[];
async onInstance() {}
async execute(): Promise<void> {
const access = await this.getAccess<UniCloudAccess>(this.accessId);
const client = new UniCloudClient({
access,
logger: this.logger,
http: this.http,
});
for (const domain of this.domains) {
await client.createCert({
domain,
provider: this.provider,
spaceId: this.spaceId,
cert: this.cert,
});
}
this.logger.info("部署成功");
}
}
new UniCloudDeployToSpace();
@@ -0,0 +1,65 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
@IsAccess({
name: "wxpay",
title: "微信支付",
icon: "tdesign:logo-wechatpay-filled",
})
export class WxpayAccess extends BaseAccess {
/**
* appId: "<-- 请填写您的AppId,例如:2019091767145019 -->",
* privateKey: "<-- 请填写您的应用私钥,例如:MIIEvQIBADANB ... ... -->",
* alipayPublicKey: "<-- 请填写您的支付宝公钥,例如:MIIBIjANBg... -->",
*/
@AccessInput({
title: "AppId",
component: {
placeholder: "201909176714xxxx",
},
required: true,
encrypt: false,
})
appId: string;
@AccessInput({
title: "商户ID",
component: {
placeholder: "201909176714xxxx",
},
required: true,
encrypt: false,
})
mchid: string;
@AccessInput({
title: "公钥",
component: {
name: "a-textarea",
rows: 3,
placeholder: "MIIBIjANBg...",
},
required: true,
encrypt: true,
})
publicKey: string;
@AccessInput({
title: "私钥",
component: {
placeholder: "MIIEvQIBADANB...",
name: "a-textarea",
rows: 3,
},
required: true,
encrypt: true,
})
privateKey: string;
@AccessInput({
title: "APIv3密钥",
helper: "微信商户平台—>账户设置—>API安全—>设置APIv3密钥",
required: true,
encrypt: true,
})
key: string;
}
new WxpayAccess();
@@ -0,0 +1 @@
export * from "./access.js";
@@ -0,0 +1,314 @@
import crypto from "crypto-js";
import { HttpClient, HttpRequestConfig, ILogger, utils } from "@certd/basic";
import { Pager, PageSearch } from "@certd/pipeline";
export class XinnetClient {
access = null;
http = null;
logger = null;
xTickets = null;
loginCookies = null;
domainTokenCookie = null;
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0";
constructor(opts: { access: { username: string; password: string }; logger: ILogger; http: HttpClient }) {
this.access = opts.access;
this.http = opts.http;
this.logger = opts.logger;
}
async doRedirectRequest(conf: HttpRequestConfig) {
let resRedirect = null;
try {
resRedirect = await this.http.request(conf);
} catch (e) {
resRedirect = e.response;
this.logger.info(resRedirect.headers);
if (!resRedirect) {
throw new Error("请求失败:", e);
}
}
return resRedirect;
}
getCookie(response: any) {
const setCookie = response.headers["set-cookie"];
if (!setCookie || setCookie.length === 0) {
throw new Error("未获取到cookie", response);
}
return setCookie
.map(item => {
return item.split(";")[0];
})
.join(";");
}
async getToken() {
const res = await this.http.request({
url: "https://login.xinnet.com/queryUOne",
method: "get",
});
this.logger.info("queryUOne", res.data);
const { uOne, uTwo } = res.data;
const res1 = await this.doRedirectRequest({
url: "https://login.xinnet.com/newlogin",
method: "get",
headers: {
Host: "login.xinnet.com",
Origin: "https://login.xinnet.com",
Referer: "https://login.xinnet.com/separatePage/?service=https://www.xinnet.com/",
},
maxRedirects: 0,
withCredentials: true,
returnOriginRes: true,
});
const cookie = this.getCookie(res1);
this.logger.info("firstCookie", cookie);
function encrypt(password, utwo) {
// return "" + crypto.encrypt(password, utwo);
return crypto.AES.encrypt(password, utwo).toString();
}
const data = {
username: this.access.username,
password: encrypt(this.access.password, uTwo),
uOne: uOne,
randStr: "",
ticket: "",
service: "",
isRemoteLogin: false,
};
const formData = new FormData();
for (const key in data) {
formData.append(key, data[key]);
}
const res2 = await this.http.request({
url: "https://login.xinnet.com/newlogin",
method: "post",
headers: {
Origin: "https://login.xinnet.com",
Referer: "https://login.xinnet.com/separatePage/?service=https://www.xinnet.com/",
"Content-Type": "multipart/form-data",
Cookie: cookie,
},
data: formData,
withCredentials: true,
returnOriginRes: true,
});
// console.log(res2.data);
const loginedCookie = this.getCookie(res2);
this.logger.info("登录成功,loginCookie", loginedCookie);
const tickets = res2.data.data.xTickets;
this.logger.info("tickets", tickets);
this.xTickets = tickets;
this.loginCookies = loginedCookie;
const xticketArr = this.xTickets.split("###");
// const ssoTiccket = xticketArr[0];
const domainTicket = xticketArr[3];
// "jsonp_" + (Math.floor(1e5 * Math.random()) * Date.now()).toString(16)
const jsonp = "jsonp_" + (Math.floor(1e5 * Math.random()) * Date.now()).toString(16);
const xtokenUrl = `https://domain.xinnet.com/domainsso/getXtoken?xticket=${domainTicket}&callback=${jsonp}`;
console.log("getxtoken-------", xtokenUrl);
const res4 = await this.doRedirectRequest({
// https://domain.xinnet.com/domainsso/getXtoken?xticket=gZNBBDObcyxKaQqRVDj&callback=jsonp_6227d9fe0004c4
url: xtokenUrl,
method: "get",
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0",
cookie: loginedCookie,
},
maxRedirects: 0,
withCredentials: true,
returnOriginRes: true,
});
const cookie4 = this.getCookie(res4);
this.logger.info("获取domainXtoken成功:", cookie4);
this.domainTokenCookie = cookie4;
}
async getDomainList(data: PageSearch): Promise<{ totalRows: number; list: { domainName: string; serviceCode: string }[] }> {
if (!this.domainTokenCookie) {
await this.getToken();
}
const pager = new Pager(data);
const domainListUrl = "https://domain.xinnet.com/domainManage/domainList";
const res = await this.doDomainRequest({
url: domainListUrl,
method: "post",
data: {
pageNo: pager.pageNo,
pageSize: pager.pageSize,
domainName: data.searchKey ?? "",
},
});
return res;
}
async doDomainRequest(conf: HttpRequestConfig) {
if (!this.domainTokenCookie) {
await this.getToken();
}
const res = await this.http.request({
url: conf.url,
method: conf.method ?? "post",
headers: {
Host: "domain.xinnet.com",
Origin: "https://domain.xinnet.com",
Referer: "https://domain.xinnet.com/",
"User-Agent": this.userAgent,
cookie: this.domainTokenCookie,
},
data: conf.data,
withCredentials: true,
});
return res;
}
async getDcpCookie(opts: { serviceCode: string }) {
if (!this.domainTokenCookie) {
await this.getToken();
}
const domainTokenCookie = this.domainTokenCookie;
const serviceCode = opts.serviceCode;
const redirectDcpUrl = "https://domain.xinnet.com/dcp?serviceCode=" + serviceCode + "&type=analytic";
const res10 = await this.doRedirectRequest({
url: redirectDcpUrl,
method: "get",
headers: {
cookie: domainTokenCookie,
},
maxRedirects: 0,
withCredentials: true,
returnOriginRes: true,
});
const location = res10.headers["location"];
console.log("跳转到dcp:", location);
const resRedirect = await this.doRedirectRequest({
url: location,
method: "get",
maxRedirects: 0,
withCredentials: true,
returnOriginRes: true,
});
const newCookie = this.getCookie(resRedirect);
this.logger.info("dcpCookie", newCookie);
return newCookie;
}
async getDomainDnsList(opts: { serviceCode: string; recordValue?: string; dcpCookie }) {
const dnsListURL = "https://dcp.xinnet.com/dcp/domaincloudanalytic/list";
const res = await this.http.request({
url: dnsListURL,
method: "post",
headers: {
"Content-Type": "application/json; charset=UTF-8",
Host: "dcp.xinnet.com",
Origin: "https://dcp.xinnet.com",
Referer: "https://dcp.xinnet.com/dcpProduct.html",
cookie: opts.dcpCookie,
},
data: {
type: "ALL",
content: opts.recordValue || "",
skip: 1,
limit: 10,
},
withCredentials: true,
});
if (res.code != 0) {
this.logger.error("获取DNS列表失败", JSON.stringify(res));
throw new Error("获取DNS列表失败");
}
return res.data?.list;
}
async addDomainDnsRecord(req: { recordName: string; type: string; recordValue: string }, opts: { serviceCode: string; dcpCookie: string }) {
const addDnsUrl = "https://dcp.xinnet.com/dcp/domaincloudanalytic/add";
const addRes = await this.doDcpRequest(
{
url: addDnsUrl,
method: "post",
data: {
recordName: req.recordName,
type: req.type,
content: req.recordValue,
ttl: 600,
phoneCode: 1,
},
},
opts
);
this.logger.info(addRes);
await utils.sleep(3000);
const res = await this.getDomainDnsList({
serviceCode: opts.serviceCode,
recordValue: req.recordValue,
dcpCookie: opts.dcpCookie,
});
// console.log(res.data);
if (!res || res.length === 0) {
throw new Error("未找到添加的DNS记录");
}
const item = res[0];
return {
recordId: item.id,
recordFullName: item.name,
recordValue: item.content,
type: item.type,
};
}
async deleteDomainDnsRecord(req: { recordId: number; recordFullName: string; type: string; recordValue: string }, opts: { serviceCode: string; dcpCookie }) {
const delDnsUrl = "https://dcp.xinnet.com/dcp/domaincloudanalytic/delete";
const res13 = await this.doDcpRequest(
{
url: delDnsUrl,
method: "post",
data: {
recordId: req.recordId,
recordName: req.recordFullName,
content: req.recordValue,
type: req.type,
isBatch: 0,
phoneCode: 1,
},
},
opts
);
console.log(res13.data);
return res13;
}
async doDcpRequest(req: HttpRequestConfig, opts: { serviceCode: string; dcpCookie: string }) {
return await this.http.request({
url: req.url,
method: req.method ?? "post",
headers: {
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
Host: "dcp.xinnet.com",
Origin: "https://dcp.xinnet.com",
Referer: "https://dcp.xinnet.com/dcpProduct.html",
cookie: opts.dcpCookie,
},
withCredentials: true,
data: req.data,
});
}
}
@@ -0,0 +1 @@
export * from "./client.js";
@@ -0,0 +1,161 @@
import axios from "axios";
import crypto from "crypto-js";
import https from "https";
import qs from "qs";
function getCookie(res1) {
let setCookie = res1.headers["set-cookie"];
console.log(setCookie);
let cookie = setCookie
.map(item => {
return item.split(";")[0];
})
.join(";");
return cookie;
}
async function login() {
const httpsAgent = new https.Agent({
rejectUnauthorized: false, // 这里可以设置为 false 来忽略 SSL 证书验证
});
const instance = axios.create({
timeout: 3000, // 请求超时时间
withCredentials: true,
httpsAgent,
headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0" }, // 设置请求头
});
//
// const res = await instance.get("https://login.xinnet.com/queryUOne");
//
// console.log(res.data?.data);
// const { uOne, uTwo } = res.data.data;
let res1 = null;
try {
res1 = await instance.request({
url: "https://dcp.xinnet.com/",
method: "get",
headers: {},
maxRedirects: 0,
withCredentials: true,
});
} catch (e) {
console.log(e.response.headers);
res1 = e.response;
}
let cookie = getCookie(res1);
console.log(cookie);
function encrypt(password, secret) {
// return "" + crypto.encrypt(password, utwo);
return crypto.AES.encrypt(password, secret).toString();
}
const codeGetUrl = "https://dcp.xinnet.com/domain/getValidatePic";
const res2 = await instance.request({
url: codeGetUrl,
method: "get",
responseType: "arraybuffer",
headers: {
"Content-Type": "application/json;charset=utf-8",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0",
Host: "dcp.xinnet.com",
cookie: cookie,
},
// maxRedirects: 0,
withCredentials: true,
});
console.log("status:", res2.status);
const imageData = res2.data;
let imageBuffer = Buffer.from(imageData, "binary");
const res3 = await axios.request({
url: "https://ocr.com/ocr",
method: "post",
headers: {
Authorization: "Basic " + Buffer.from("username:password").toString("base64"),
},
httpsAgent,
data: {
image: imageBuffer.toString("base64"),
},
});
console.log(res3.data.result.ocr_response);
let text = res3.data.result.ocr_response.map(item => item.text.trim()).join("");
text = text.replaceAll(" ", "");
console.log(text);
const url = "https://dcp.xinnet.com/domain/validEnter";
const password = encrypt("jidian1zu", "this is temp before https");
// const body = {
// domainName: "ulogin.top",
// password: encodeURIComponent(password),
// checkCode: encodeURIComponent( text),
// }
const body = {
domainName: "ulogin.top",
password: password,
checkCode: text,
};
const query = qs.stringify(body);
console.log(query);
const res4 = await instance.request({
url: url,
method: "post",
headers: {
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
// cookie: cookie,
Host: "dcp.xinnet.com",
Origin: "https://dcp.xinnet.com",
Referer: "https://dcp.xinnet.com/",
cookie: cookie,
},
data: body,
withCredentials: true,
});
console.log(res4.data);
const domainEnterUrl = "https://dcp.xinnet.com/domain/domainEnter";
const res6 = await instance.request({
url: domainEnterUrl,
method: "post",
headers: {
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
Host: "dcp.xinnet.com",
Origin: "https://dcp.xinnet.com",
Referer: "https://dcp.xinnet.com/",
cookie: cookie,
},
data: body,
withCredentials: true,
});
console.log(res6.data);
const listUrl = "https://dcp.xinnet.com/dcp/domaincloudanalytic/list";
const res5 = await instance.request({
url: listUrl,
method: "post",
headers: {
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
Host: "dcp.xinnet.com",
Origin: "https://dcp.xinnet.com",
Referer: "https://dcp.xinnet.com/dcpProduct.html",
cookie: cookie,
},
withCredentials: true,
data: {
type: "ALL",
content: "",
limit: 10,
},
});
console.log(res5.data);
}
login();
@@ -0,0 +1,341 @@
import axios from "axios";
import crypto from "crypto-js";
function getCookie(res1) {
let setCookie = res1.headers["set-cookie"];
console.log(setCookie);
let cookie = setCookie
.map(item => {
return item.split(";")[0];
})
.join(";");
return cookie;
}
async function login() {
const instance = axios.create({
timeout: 3000, // 请求超时时间
withCredentials: true,
headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0" }, // 设置请求头
});
const res = await instance.get("https://login.xinnet.com/queryUOne");
console.log(res.data?.data);
const { uOne, uTwo } = res.data.data;
let res1 = null;
try {
res1 = await instance.request({
url: "https://login.xinnet.com/newlogin",
method: "get",
headers: {
Host: "login.xinnet.com",
Origin: "https://login.xinnet.com",
Referer: "https://login.xinnet.com/separatePage/?service=https://www.xinnet.com/",
},
maxRedirects: 0,
withCredentials: true,
});
} catch (e) {
console.log(e.response.headers);
res1 = e.response;
}
let cookie = getCookie(res1);
function encrypt(password, utwo) {
// return "" + crypto.encrypt(password, utwo);
return crypto.AES.encrypt(password, utwo).toString();
}
const data = {
username: 18603046467,
password: encrypt("xxxxxxxxxxxxxpassword", uTwo),
uOne: uOne,
randStr: "",
ticket: "",
service: "",
isRemoteLogin: false,
};
const formData = new FormData();
for (const key in data) {
formData.append(key, data[key]);
}
const res2 = await instance.request({
url: "https://login.xinnet.com/newlogin",
method: "post",
headers: {
Origin: "https://login.xinnet.com",
Referer: "https://login.xinnet.com/separatePage/?service=https://www.xinnet.com/",
"Content-Type": "multipart/form-data",
Cookie: cookie,
},
data: formData,
withCredentials: true,
});
console.log(res2.data);
let loginedCookie = getCookie(res2);
console.log("loginCookie", loginedCookie);
const tickets = res2.data.data.xTickets;
const xticketArr = tickets.split("###");
const ssoTiccket = xticketArr[0];
const domainTicket = xticketArr[3];
// "jsonp_" + (Math.floor(1e5 * Math.random()) * Date.now()).toString(16)
const jsonp = "jsonp_" + (Math.floor(1e5 * Math.random()) * Date.now()).toString(16);
// const ssoUrl = `https://www.xinnet.com/sso/getXtoken?xticket=${ssoTiccket}&callback=${jsonp}`;
// const res3 = await axios.request({
// url: ssoUrl,
// method: "get",
// headers: {
// Host: "www.xinnet.com",
// Origin: "https://login.xinnet.com",
// Referer: "https://login.xinnet.com/separatePage/?service=https://www.xinnet.com/",
// "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/}"
// },
// cookie:loginedCookie,
// maxRedirects: 0,
// withCredentials: true
// });
//
// console.log(res3.data);
// let cookie2 = getCookie(res3);
// console.log(cookie2);
let res4 = null;
try {
const xtokenUrl = `https://domain.xinnet.com/domainsso/getXtoken?xticket=${domainTicket}&callback=${jsonp}`;
console.log("getxtoken-------", xtokenUrl);
res4 = await instance.request({
// https://domain.xinnet.com/domainsso/getXtoken?xticket=gZNBBDObcyxKaQqRVDj&callback=jsonp_6227d9fe0004c4
url: xtokenUrl,
method: "get",
headers: {
/**
* Host:
* ssl.xinnet.com
* Referer:
* https://login.xinnet.com/separatePage/?service=https://www.xinnet.com/
*/
// Host: "ssl.xinnet.com",
// Referer: "https://login.xinnet.com/separatePage/?service=https://www.xinnet.com/",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0",
cookie: loginedCookie,
},
maxRedirects: 0,
withCredentials: true,
});
} catch (e) {
res4 = e.response;
console.log(res4.headers);
}
console.log(res4.data);
let domainTokenCookie = getCookie(res4);
console.log("domainTokenCookie", domainTokenCookie);
//
// let res8 = null;
// const consoleXtokenUrl = `https://console.xinnet.com/sso/getXtoken?xticket=${domainTicket}&callback=${jsonp}`;
// console.log("getConsolextoken-------", consoleXtokenUrl);
// res8 = await instance.request({
// // https://domain.xinnet.com/domainsso/getXtoken?xticket=gZNBBDObcyxKaQqRVDj&callback=jsonp_6227d9fe0004c4
// url: consoleXtokenUrl,
// method: "get",
// headers: {
// "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0",
// cookie: loginedCookie
// },
// maxRedirects: 0,
// withCredentials: true
// });
//
// console.log(res8.data);
// let consoleTokenCookie = getCookie(res8);
// console.log("consoleTokenCookie", consoleTokenCookie);
// const consoleIdUrl = "https://console.xinnet.com/usercommon/getShopcartNum";
//
// const res7 = await instance.request({
// url: consoleIdUrl,
// method: "get",
// headers: {
// Host: "console.xinnet.com",
// Referer: "https://domain.xinnet.com/",
// cookie: consoleTokenCookie +";"+ loginedCookie
// }
// });
// console.log(res7.data);
// let consoleIdCookie = getCookie(res7);
// console.log("consoleIdCookie",consoleIdCookie);
const domainListUrl = "https://domain.xinnet.com/domainManage/domainList";
const res5 = await instance.request({
url: domainListUrl,
method: "post",
headers: {
/**
* Host:
* domain.xinnet.com
* Origin:
* https://domain.xinnet.com
* Referer:
* https://domain.xinnet.com/
*/
Host: "domain.xinnet.com",
Origin: "https://domain.xinnet.com",
Referer: "https://domain.xinnet.com/",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0",
cookie: domainTokenCookie,
},
data: {
/**
* pageNo: 1
* pageSize: 10
* orderByProperty: expire_date
* orderByType: asc
*/
pageNo: 1,
pageSize: 10,
},
withCredentials: true,
});
console.log(res5.data);
// const bindUrls = [
// "https://domain.xinnet.com/domainManage/inspectDomainRealname",
// "https://domain.xinnet.com/domainManage/inspectDomainBindPhone",
// "https://domain.xinnet.com/domainManage/inspectDomainXinnetDns",
// "https://domain.xinnet.com/domainManage/inspectDomainEvents"
// ];
// const consoleIdCookie = consoleTokenCookie.split(";")[0];
// for (const url of bindUrls) {
// console.log("do bind:", url);
// const cookie1 = consoleIdCookie + ";" + domainTokenCookie;
// console.log("cookie1", cookie1);
// const res9 = await instance.request({
// url: url,
// method: "post",
// headers: {
// "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
// Host: "domain.xinnet.com",
// Origin: "https://domain.xinnet.com",
// Referer: "https://domain.xinnet.com",
// cookie: cookie1
// },
// data: {
// domainName: "ulogin.top"
// },
// withCredentials: true
// });
// console.log(res9.data);
// }
const serviceCode = "D76534287817377";
const redirectDcpUrl = "https://domain.xinnet.com/dcp?serviceCode=" + serviceCode + "&type=analytic";
let res10 = null;
try {
res10 = await instance.request({
url: redirectDcpUrl,
method: "get",
headers: {
cookie: domainTokenCookie,
},
maxRedirects: 0,
withCredentials: true,
});
} catch (e) {
res10 = e.response;
console.log(res10.headers);
}
const location = res10.headers["location"];
console.log("跳转到dcp:", location);
let resRedirect = null;
try {
resRedirect = await instance.request({
url: location,
method: "get",
maxRedirects: 0,
withCredentials: true,
});
} catch (e) {
resRedirect = e.response;
console.log(resRedirect.headers);
}
const newCookie = getCookie(resRedirect);
console.log("newCookie", newCookie);
const dnsListURL = "https://dcp.xinnet.com/dcp/domaincloudanalytic/list";
const res11 = await instance.request({
url: dnsListURL,
method: "post",
headers: {
"Content-Type": "application/json; charset=UTF-8",
Host: "dcp.xinnet.com",
Origin: "https://dcp.xinnet.com",
Referer: "https://dcp.xinnet.com/dcpProduct.html",
cookie: newCookie,
},
data: {
type: "ALL",
content: "",
skip: 1,
limit: 10,
},
withCredentials: true,
});
console.log(res11.data);
//add dns
// const addDnsUrl = "https://dcp.xinnet.com/dcp/domaincloudanalytic/add"
// const res12 = await instance.request({
// url: addDnsUrl,
// method: "post",
// headers: {
// "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
// Host: "dcp.xinnet.com",
// Origin: "https://dcp.xinnet.com",
// Referer: "https://dcp.xinnet.com/dcpProduct.html",
// cookie: newCookie
// },
// data: {
// recordName: "343533",
// type: "TXT",
// content: "456456",
// ttl: 600,
// phoneCode: 1,
// }
// })
// console.log(res12.data);
//
// const delDnsUrl = "https://dcp.xinnet.com/dcp/domaincloudanalytic/delete"
//
// const res13 = await instance.request({
// url: delDnsUrl,
// method: "post",
// headers: {
// "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
// Host: "dcp.xinnet.com",
// Origin: "https://dcp.xinnet.com",
// Referer: "https://dcp.xinnet.com/dcpProduct.html",
// cookie: newCookie
// },
// data:{
// recordId: 167529045,
// recordName: "aaaa.ulogin.top",
// content: "aaaa",
// type: "TXT",
// isBatch: 0,
// phoneCode: 1,
// }
// })
// console.log(res13.data);
}
login();
@@ -0,0 +1,35 @@
import { IsAccess, AccessInput, BaseAccess } from "@certd/pipeline";
/**
* 这个注解将注册一个授权配置
* 在certd的后台管理系统中,用户可以选择添加此类型的授权
*/
@IsAccess({
name: "yidunrcdn",
title: "易盾rcdn授权",
icon: "material-symbols:shield-outline",
desc: "易盾CDN,每月免费30G[注册即领](https://rhcdn.yiduncdn.com/register?code=8mn536rrzfbf8)",
})
export class YidunRcdnAccess extends BaseAccess {
@AccessInput({
title: "账户",
component: {
placeholder: "手机号",
},
required: true,
encrypt: true,
})
username = "";
@AccessInput({
title: "密码",
component: {
placeholder: "password",
},
required: true,
encrypt: true,
})
password = "";
}
new YidunRcdnAccess();
@@ -0,0 +1,36 @@
import { IsAccess, AccessInput, BaseAccess } from "@certd/pipeline";
/**
* 这个注解将注册一个授权配置
* 在certd的后台管理系统中,用户可以选择添加此类型的授权
*/
@IsAccess({
name: "yfysms",
title: "易发云短信",
icon: "material-symbols:shield-outline",
desc: "sms.yfyidc.cn/",
})
export class YfySmsAccess extends BaseAccess {
@AccessInput({
title: "KeyID",
component: {
placeholder: "api_key",
},
helper: "[获取密钥](http://sms.yfyidc.cn/user/index#)",
required: true,
encrypt: true,
})
keyId = "";
@AccessInput({
title: "KeySecret",
component: {
placeholder: "",
},
required: true,
encrypt: true,
})
keySecret = "";
}
new YfySmsAccess();
@@ -0,0 +1,37 @@
import { IsAccess, AccessInput, BaseAccess } from "@certd/pipeline";
/**
* 这个注解将注册一个授权配置
* 在certd的后台管理系统中,用户可以选择添加此类型的授权
*/
@IsAccess({
name: "yidun",
title: "易盾DCDN授权",
icon: "material-symbols:shield-outline",
desc: "https://user.yiduncdn.com",
})
export class YidunAccess extends BaseAccess {
@AccessInput({
title: "api_key",
component: {
placeholder: "api_key",
},
helper: "http://user.yiduncdn.com/console/index.html#/account/config/api,点击开启后获取",
required: true,
encrypt: true,
})
apiKey = "";
@AccessInput({
title: "api_secret",
component: {
placeholder: "api_secret",
},
helper: "http://user.yiduncdn.com/console/index.html#/account/config/api,点击开启后获取",
required: true,
encrypt: true,
})
apiSecret = "";
}
new YidunAccess();
@@ -0,0 +1,4 @@
export * from "./plugins/index.js";
export * from "./access.js";
export * from "./access-rcdn.js";
export * from "./access-sms.js";
@@ -0,0 +1,2 @@
export * from "./plugin-deploy-to-cdn.js";
export * from "./plugin-deploy-to-rcdn.js";
@@ -0,0 +1,147 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert";
@IsTaskPlugin({
name: "YidunDeployToCDN",
title: "易盾-部署到易盾DCDN",
icon: "material-symbols:shield-outline",
group: pluginGroups.cdn.key,
desc: "主要是防御,http://user.yiduncdn.com/",
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
needPlus: false,
})
export class YidunDeployToCDNPlugin extends AbstractTaskPlugin {
//测试参数
@TaskInput({
title: "证书ID",
component: {
name: "a-input-number",
vModel: "value",
},
helper: "证书ID,在证书管理页面查看,每条记录都有证书id",
})
certId!: number;
@TaskInput({
title: "网站域名",
component: {
name: "a-input",
vModel: "value",
},
helper: "网站域名和证书ID选填其中一个,填了证书ID,则忽略网站域名",
})
domain!: number;
//证书选择,此项必须要有
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames],
},
required: true,
})
cert!: CertInfo;
//授权选择框
@TaskInput({
title: "易盾授权",
helper: "易盾CDN授权",
component: {
name: "access-selector",
type: "yidun",
},
required: true,
})
accessId!: string;
async onInstance() {}
async execute(): Promise<void> {
const { domain, certId, cert } = this;
if (!domain && !certId) {
throw new Error("证书ID和网站域名必须填写一个");
}
if (certId > 0) {
await this.updateByCertId(cert, certId);
} else {
await this.updateByDomain(cert);
}
}
private async updateByCertId(cert: CertInfo, certId: number) {
this.logger.info(`更新证书,证书ID:${certId}`);
const url = `http://user.yiduncdn.com/v1/certs/${certId}`;
await this.doRequest(url, "PUT", {
cert: cert.crt,
key: cert.key,
});
}
async doRequest(url: string, method: string, data: any) {
const access = await this.getAccess(this.accessId);
const { apiKey, apiSecret } = access;
const http = this.ctx.http;
const res: any = await http.request({
url,
method,
headers: {
"api-key": apiKey,
"api-secret": apiSecret,
},
data,
});
if (res.code != 0) {
throw new Error(res.msg);
}
return res;
}
private async updateByDomain(cert: CertInfo) {
//查询站点
const siteUrl = "http://user.yiduncdn.com/v1/sites";
const res = await this.doRequest(siteUrl, "GET", { domain: this.domain });
if (res.data.length === 0) {
throw new Error(`未找到域名相关站点:${this.domain}`);
}
let site = null;
for (const row of res.data) {
if (row.domain === this.domain) {
site = row;
}
}
if (!site) {
throw new Error(`未找到域名匹配的站点:${this.domain}`);
}
if (site.https_listen?.cert) {
//有证书id
const certId = site.https_listen.cert;
await this.updateByCertId(cert, certId);
} else {
//创建证书
this.logger.info(`创建证书,域名:${this.domain}`);
const certUrl = `http://user.yiduncdn.com/v1/certs`;
const name = this.domain + "_" + new Date().getTime();
await this.doRequest(certUrl, "POST", {
name,
type: "custom",
cert: cert.crt,
key: cert.key,
});
const certs: any = await this.doRequest(certUrl, "GET", {
name,
});
const certId = certs.data[0].id;
const siteUrl = "http://user.yiduncdn.com/v1/sites";
await this.doRequest(siteUrl, "PUT", { id: site.id, https_listen: { cert: certId } });
}
}
}
new YidunDeployToCDNPlugin();
@@ -0,0 +1,168 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert";
import { createRemoteSelectInputDefine } from "@certd/plugin-lib";
import { YidunRcdnAccess } from "../access-rcdn.js";
@IsTaskPlugin({
name: "YidunDeployToRCDN",
title: "易盾-部署到易盾RCDN",
icon: "material-symbols:shield-outline",
group: pluginGroups.cdn.key,
desc: "易盾CDN,每月免费30G[注册即领](https://rhcdn.yiduncdn.com/register?code=8mn536rrzfbf8)",
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
needPlus: false,
})
export class YidunDeployToRCDNPlugin extends AbstractTaskPlugin {
//证书选择,此项必须要有
@TaskInput({
title: "域名证书",
helper: "请选择前置任务输出的域名证书",
component: {
name: "output-selector",
from: [...CertApplyPluginNames],
},
required: true,
})
cert!: CertInfo;
//授权选择框
@TaskInput({
title: "易盾RCDN授权",
helper: "易盾RCDN授权",
component: {
name: "access-selector",
type: "yidunrcdn",
},
required: true,
})
accessId!: string;
@TaskInput(
createRemoteSelectInputDefine({
title: "域名列表",
helper: "选择要部署证书的站点域名",
typeName: "YidunDeployToRCDNPlugin",
action: YidunDeployToRCDNPlugin.prototype.onGetDomainList.name,
})
)
domains!: string[];
async onInstance() {}
async execute(): Promise<void> {
const access = await this.getAccess<YidunRcdnAccess>(this.accessId);
const loginRes = await this.getLoginToken(access);
const curl = "https://rhcdn.yiduncdn.com/CdnDomainHttps/httpsConfiguration";
for (const domain of this.domains) {
// const data = {
// doMainId: domain,
// https: {
// https_status: "off"
// },
// }
// //先关闭https
// const res = await this.doRequest(curl, loginRes, data);
const cert = this.cert;
const update = {
doMainId: domain,
https: {
https_status: "on",
certificate_name: this.appendTimeSuffix("certd"),
certificate_source: "0",
certificate_value: cert.crt,
private_key: cert.key,
},
};
await this.doRequest(curl, loginRes, update);
this.logger.info(`站点${domain}证书更新成功`);
}
}
async getLoginToken(access: YidunRcdnAccess) {
const url = "https://rhcdn.yiduncdn.com/login/loginUser";
const data = {
userAccount: access.username,
userPwd: access.password,
remember: true,
};
const http = this.ctx.http;
const res: any = await http.request({
url,
method: "POST",
data,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
returnOriginRes: true,
});
if (!res.data?.success) {
throw new Error(res.data?.message);
}
const jsessionId = this.ctx.utils.request.getCookie(res, "JSESSIONID");
const token = res.data?.data;
return {
jsessionId,
token,
};
}
async getDomainList(loginRes: any) {
const url = "https://rhcdn.yiduncdn.com/CdnDomain/queryForDatatables";
const data = {
draw: 1,
start: 0,
length: 1000,
search: {
value: "",
regex: false,
},
};
const res = await this.doRequest(url, loginRes, data);
return res.data?.data;
}
private async doRequest(url: string, loginRes: any, data: any) {
const http = this.ctx.http;
const res: any = await http.request({
url,
method: "POST",
headers: {
Cookie: `JSESSIONID=${loginRes.jsessionId};kuocai_cdn_token=${loginRes.token}`,
},
data,
});
if (!res.success) {
throw new Error(res.message);
}
return res;
}
async onGetDomainList(data: any) {
if (!this.accessId) {
throw new Error("请选择Access授权");
}
const access = await this.getAccess<YidunRcdnAccess>(this.accessId);
const loginRes = await this.getLoginToken(access);
const list = await this.getDomainList(loginRes);
if (!list || list.length === 0) {
throw new Error("您账户下还没有站点域名,请先添加域名");
}
return list.map((item: any) => {
return {
label: `${item.domainName}<${item.id}>`,
value: item.id,
};
});
}
}
new YidunDeployToRCDNPlugin();
@@ -0,0 +1,70 @@
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
@IsAccess({
name: "yizhifu",
title: "易支付",
icon: "svg:icon-yizhifu",
})
export class YizhifuAccess extends BaseAccess {
@AccessInput({
title: "url",
component: {
placeholder: "https://pay.xxxx.com",
},
helper: "易支付系统地址",
required: true,
encrypt: false,
})
url: string;
@AccessInput({
title: "商户id",
component: {
placeholder: "pid",
},
required: true,
encrypt: false,
})
pid: string;
@AccessInput({
title: "key",
component: {
placeholder: "key",
},
required: true,
encrypt: true,
})
key: string;
@AccessInput({
title: "固定支付方式",
component: {
placeholder: "固定一种支付方式,也就是submit.php中的type参数",
},
helper: "不填则跳转到收银台由用户自己选择,如果您的易支付系统不支持收银台,则必须填写",
required: false,
encrypt: false,
})
payType: string;
@AccessInput({
title: "签名方式",
component: {
name: "a-select",
vModel: "value",
options: [
{
label: "MD5",
value: "MD5",
},
{
label: "SHA256",
value: "SHA256",
},
],
},
required: true,
encrypt: false,
})
signType: string;
}
new YizhifuAccess();
@@ -0,0 +1 @@
export * from "./access.js";