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}`;
};