perf: 支持全自动匹配部署宝塔网站证书

This commit is contained in:
xiaojunnuo
2026-06-27 00:20:04 +08:00
parent 8abe0daf20
commit 4dff48e807
12 changed files with 193 additions and 79 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

+16 -26
View File
@@ -1,15 +1,23 @@
# google证书申请教程 # google证书申请教程
## 1、启用API ## 1、 添加流水线
点击“创建证书流水线”按钮
## 2、 生成google ACME账号
![](./images/google-acme.png)
## 3、 获取Google EAB
### 3.1、启用API
打开如下链接,启用 API 打开如下链接,启用 API
https://console.cloud.google.com/apis/library/publicca.googleapis.com https://console.cloud.google.com/apis/library/publicca.googleapis.com
打开该链接后点击“启用”,随后等待右侧出现“API已启用”则可以关闭该页。 打开该链接后点击“启用”,随后等待右侧出现“API已启用”则可以关闭该页。
## 2、 获取授权 ## 3.2、 创建EAB
以下两种方式任选其一
### 2.1 直接获取EAB 【推荐】
1. 打开“Google Cloud Shell”(在右上角点击激活CloudShell图标)。 1. 打开“Google Cloud Shell”(在右上角点击激活CloudShell图标)。
@@ -28,31 +36,13 @@ keyId: xxxxxxxxxxxxx]
``` ```
![](./images/google-eab.png) ![](./images/google-eab.png)
3. 到Certd中,创建一条EAB授权记录,填写keyId(=kid) 和 b64MacKey 信息 3. 到Certd中,创建一条EAB授权记录,填写keyId(=kid) 和 b64MacKey 信息
注意:keyId没有`]`结尾,不要把`]`也复制了 注意:keyId没有`]`结尾,不要把`]`也复制了
## 4、 生成Google ACME账号
注意:EAB授权使用过一次之后,会绑定邮箱,后续再次使用时,要使用相同的邮箱,所以邮箱切记不要修改 注意:EAB授权使用过一次之后,会绑定邮箱,后续再次使用时,要使用相同的邮箱,所以邮箱切记不要修改
否则会报错 `Unknown external account binding (EAB) key. This may be due to the EAB key expiring which occurs 7 days after creation` 否则会报错 `Unknown external account binding (EAB) key. This may be due to the EAB key expiring which occurs 7 days after creation`
4. 创建证书流水线,选择证书提供商为google,选择EAB授权,运行流水线申请证书 ## 5、创建证书流水线,运行流水线申请证书
### 2.2 通过google服务账号接口获取授权
此方式可以自动获取EAB,需要服务端配置代理
1. 创建服务账号
https://console.cloud.google.com/projectselector2/iam-admin/serviceaccounts/create?walkthrough_id=iam--create-service-account&hl=zh-cn#step_index=1
2. 选择一个项目,进入创建服务账号页面
3. 给服务账号起一个名字,点击`创建并继续`
4. 向此服务账号授予对项目的访问权限: `选择角色`->`基本`->`Owner`
5. 点击完成
6. 点击服务账号,进入服务账号详情页面
7. 点击`添加密钥`->`创建新密钥`->`JSON`,下载密钥文件
8. 将json文件内容粘贴到 certd中 Google服务授权输入框中
9. 创建证书流水线,选择证书提供商为google, 选择服务账号授权,运行流水线申请证书
@@ -1,10 +1,10 @@
import { PermissionException, ValidateException } from './exception/index.js'; import { PermissionException, ValidateException } from "./exception/index.js";
import { EntityTarget, FindOneOptions, In, Repository, SelectQueryBuilder } from 'typeorm'; import { EntityTarget, FindOneOptions, In, Repository, SelectQueryBuilder } from "typeorm";
import { Inject } from '@midwayjs/core'; import { Inject } from "@midwayjs/core";
import { TypeORMDataSourceManager } from '@midwayjs/typeorm'; import { TypeORMDataSourceManager } from "@midwayjs/typeorm";
import { EntityManager } from 'typeorm/entity-manager/EntityManager.js'; import { EntityManager } from "typeorm/entity-manager/EntityManager.js";
import { FindManyOptions } from 'typeorm'; import { FindManyOptions } from "typeorm";
import { Constants } from './constants.js'; import { Constants } from "./constants.js";
export type PageReq<T = any> = { export type PageReq<T = any> = {
page?: { offset: number; limit: number }; page?: { offset: number; limit: number };
@@ -34,7 +34,7 @@ export abstract class BaseService<T> {
abstract getRepository(): Repository<T>; abstract getRepository(): Repository<T>;
async transaction(callback: (entityManager: EntityManager) => Promise<any>) { async transaction(callback: (entityManager: EntityManager) => Promise<any>) {
const dataSource = this.dataSourceManager.getDataSource('default'); const dataSource = this.dataSourceManager.getDataSource("default");
return await dataSource.transaction(callback as any); return await dataSource.transaction(callback as any);
} }
@@ -52,7 +52,7 @@ export abstract class BaseService<T> {
if (ctx.manager) { if (ctx.manager) {
return ctx.manager.getRepository(entity); return ctx.manager.getRepository(entity);
} }
const dataSource = this.dataSourceManager.getDataSource('default'); const dataSource = this.dataSourceManager.getDataSource("default");
return dataSource.getRepository(entity); return dataSource.getRepository(entity);
} }
@@ -73,7 +73,7 @@ export abstract class BaseService<T> {
*/ */
async info(id, infoIgnoreProperty?): Promise<T | null> { async info(id, infoIgnoreProperty?): Promise<T | null> {
if (!id) { if (!id) {
throw new ValidateException('id不能为空'); throw new ValidateException("id不能为空");
} }
const info = await this.getRepository().findOneBy({ id } as any); const info = await this.getRepository().findOneBy({ id } as any);
if (info && infoIgnoreProperty) { if (info && infoIgnoreProperty) {
@@ -119,18 +119,18 @@ export abstract class BaseService<T> {
...where, ...where,
}); });
await this.modifyAfter(idArr); await this.modifyAfter(idArr);
return ids return ids;
} }
resolveIdArr(ids: string | any[]) { resolveIdArr(ids: string | any[]) {
if (!ids) { if (!ids) {
throw new ValidateException('ids不能为空'); throw new ValidateException("ids不能为空");
} }
if (typeof ids === 'string') { if (typeof ids === "string") {
return ids.split(','); return ids.split(",");
} else if(!Array.isArray(ids)){ } else if (!Array.isArray(ids)) {
return [ids]; return [ids];
}else { } else {
return ids; return ids;
} }
} }
@@ -147,7 +147,7 @@ export abstract class BaseService<T> {
* 新增 * 新增
* @param param 数据 * @param param 数据
*/ */
async add(param: any) { async add(param: any): Promise<{ id: number; [key: string]: any }> {
const now = new Date(); const now = new Date();
param.createTime = now; param.createTime = now;
param.updateTime = now; param.updateTime = now;
@@ -163,7 +163,7 @@ export abstract class BaseService<T> {
* @param param 数据 * @param param 数据
*/ */
async update(param: any) { async update(param: any) {
if (!param.id) throw new ValidateException('id 不能为空'); if (!param.id) throw new ValidateException("id 不能为空");
param.updateTime = new Date(); param.updateTime = new Date();
await this.addOrUpdate(param); await this.addOrUpdate(param);
await this.modifyAfter(param); await this.modifyAfter(param);
@@ -201,10 +201,10 @@ export abstract class BaseService<T> {
} }
private buildListQuery(listReq: ListReq<T>) { private buildListQuery(listReq: ListReq<T>) {
const { query, sort, buildQuery,select } = listReq; const { query, sort, buildQuery, select } = listReq;
const qb = this.getRepository().createQueryBuilder('main'); const qb = this.getRepository().createQueryBuilder("main");
if (select) { if (select) {
qb.setFindOptions({select}); qb.setFindOptions({ select });
} }
if (query) { if (query) {
const keys = Object.keys(query); const keys = Object.keys(query);
@@ -223,10 +223,10 @@ export abstract class BaseService<T> {
} }
}); });
if (found) { if (found) {
qb.addOrderBy('main.' + sort.prop, sort.asc ? 'ASC' : 'DESC'); qb.addOrderBy("main." + sort.prop, sort.asc ? "ASC" : "DESC");
} }
} }
qb.addOrderBy('id', 'DESC'); qb.addOrderBy("id", "DESC");
//自定义query //自定义query
if (buildQuery) { if (buildQuery) {
buildQuery(qb); buildQuery(qb);
@@ -243,12 +243,12 @@ export abstract class BaseService<T> {
return await qb.getMany(); return await qb.getMany();
} }
async checkUserId(ids: number | number[] = 0, userId: number, userKey = 'userId') { async checkUserId(ids: number | number[] = 0, userId: number, userKey = "userId") {
if (ids == null) { if (ids == null) {
throw new ValidateException('id不能为空'); throw new ValidateException("id不能为空");
} }
if (userId == null) { if (userId == null) {
throw new ValidateException('userId不能为空'); throw new ValidateException("userId不能为空");
} }
if (!Array.isArray(ids)) { if (!Array.isArray(ids)) {
ids = [ids]; ids = [ids];
@@ -268,20 +268,20 @@ export abstract class BaseService<T> {
if (!res || res.length === ids.length) { if (!res || res.length === ids.length) {
return; return;
} }
throw new PermissionException('权限不足'); throw new PermissionException("权限不足");
} }
filterIds(ids: any[]) { filterIds(ids: any[]) {
if (!ids) { if (!ids) {
throw new ValidateException('ids不能为空'); throw new ValidateException("ids不能为空");
} }
return ids.filter((item) => { return ids.filter(item => {
return item!=null && item != "" return item != null && item != "";
}); });
} }
async batchDelete(ids: number[], userId: number,projectId?:number) { async batchDelete(ids: number[], userId: number, projectId?: number) {
ids = this.filterIds(ids); ids = this.filterIds(ids);
if(userId!=null){ if (userId != null) {
const userProjectQuery = this.buildUserProjectQuery(userId, projectId); const userProjectQuery = this.buildUserProjectQuery(userId, projectId);
const list = await this.getRepository().find({ const list = await this.getRepository().find({
where: { where: {
@@ -289,9 +289,9 @@ export abstract class BaseService<T> {
id: In(ids), id: In(ids),
...userProjectQuery, ...userProjectQuery,
}, },
}) });
// @ts-ignore // @ts-ignore
ids = list.map(item => item.id) ids = list.map(item => item.id);
} }
await this.delete(ids); await this.delete(ids);
@@ -300,19 +300,18 @@ export abstract class BaseService<T> {
async findOne(options: FindOneOptions<T>) { async findOne(options: FindOneOptions<T>) {
return await this.getRepository().findOne(options); return await this.getRepository().findOne(options);
} }
} }
export function checkUserProjectParam(userId: number, projectId: number) { export function checkUserProjectParam(userId: number, projectId: number) {
if (projectId != null ){ if (projectId != null) {
if( userId !== Constants.enterpriseUserId) { if (userId !== Constants.enterpriseUserId) {
throw new ValidateException('userId projectId 错误'); throw new ValidateException("userId projectId 错误");
} }
return true return true;
}else{ } else {
if( userId != null) { if (userId != null) {
return true return true;
} }
throw new ValidateException('userId不能为空'); throw new ValidateException("userId不能为空");
} }
} }
@@ -35,7 +35,7 @@ export class AccessService extends BaseService<AccessEntity> {
return res; return res;
} }
async add(param) { async add(param: any): Promise<{ id: number; [key: string]: any }> {
let oldEntity = null; let oldEntity = null;
if (param._copyFrom) { if (param._copyFrom) {
oldEntity = await this.info(param._copyFrom); oldEntity = await this.info(param._copyFrom);
+3 -1
View File
@@ -19,6 +19,8 @@
"@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-empty-function": "off", "@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-unused-vars": "off", "@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-this-alias": "off" "@typescript-eslint/no-this-alias": "off",
// 允许any
"@typescript-eslint/no-unsafe-anyassignment": "off"
} }
} }
@@ -55,7 +55,7 @@ export class FileController extends BaseController {
const key = await this.fileService.saveFile(this.getUserId(), cacheKey, "public"); const key = await this.fileService.saveFile(this.getUserId(), cacheKey, "public");
return this.ok({ return this.ok({
key, key,
url: `/api/basic/file/download?key=${encodeURIComponent(key)}`, url: `/api/basic/file/download?key=${encodeURIComponent(key as string)}`,
}); });
} }
return this.ok({ return this.ok({
@@ -21,7 +21,7 @@ export class CertInfoWildcardDomainCountFix {
}, },
}); });
let fixedCount = 0; let fixedCount = 0;
for (const item of list) { for (const item of list as any[]) {
if (!item.domains) { if (!item.domains) {
continue; continue;
} }
@@ -112,7 +112,7 @@ export class CommonEabToAcmeAccountFix {
return null; return null;
} }
const email = eabAccess.email || `${caType}@common.certd.local`; const email = eabAccess.email || `${caType}@common.certd.local`;
const exists = await this.accessService.findOne({ const exists: any = await this.accessService.findOne({
where: { where: {
userId: 0, userId: 0,
projectId: null, projectId: null,
@@ -36,7 +36,7 @@ export class RoleService extends BaseService<RoleEntity> {
} }
async getRoleIdsByUserId(id: any) { async getRoleIdsByUserId(id: any) {
const userRoles = await this.userRoleService.find({ const userRoles: any = await this.userRoleService.find({
where: { userId: id }, where: { userId: id },
}); });
return userRoles.map(item => item.roleId); return userRoles.map(item => item.roleId);
@@ -131,7 +131,7 @@ export class RoleService extends BaseService<RoleEntity> {
async delete(id: any) { async delete(id: any) {
const idArr = this.resolveIdArr(id); const idArr = this.resolveIdArr(id);
//@ts-ignore //@ts-ignore
const urs = await this.userRoleService.find({ where: { roleId: In(idArr) } }); const urs:any = await this.userRoleService.find({ where: { roleId: In(idArr) } });
if (urs.length > 0) { if (urs.length > 0) {
throw new Error("该角色已被用户使用,无法删除"); throw new Error("该角色已被用户使用,无法删除");
} }
@@ -100,7 +100,7 @@ export class AcmeAccountAccess extends BaseAccess {
"\nGoogle:请查看[google获取eab帮助文档](https://certd.docmirror.cn/guide/use/google/),用过一次后会绑定邮箱,后续复用EAB要用同一个邮箱" + "\nGoogle:请查看[google获取eab帮助文档](https://certd.docmirror.cn/guide/use/google/),用过一次后会绑定邮箱,后续复用EAB要用同一个邮箱" +
"\nSSL.com:[SSL.com账号页面](https://secure.ssl.com/account),然后点击api credentials链接,然后点击编辑按钮,查看Secret key和HMAC key" + "\nSSL.com:[SSL.com账号页面](https://secure.ssl.com/account),然后点击api credentials链接,然后点击编辑按钮,查看Secret key和HMAC key" +
"\nlitessl:[litesslEAB页面](https://freessl.cn/automation/eab-manager),然后点击新增EAB", "\nlitessl:[litesslEAB页面](https://freessl.cn/automation/eab-manager),然后点击新增EAB",
required: false, required: true,
encrypt: true, encrypt: true,
mergeScript: ` mergeScript: `
return { return {
@@ -121,7 +121,7 @@ export class AcmeAccountAccess extends BaseAccess {
component: { component: {
placeholder: "需要EAB的颁发机构生成账号时填写", placeholder: "需要EAB的颁发机构生成账号时填写",
}, },
required: false, required: true,
encrypt: true, encrypt: true,
mergeScript: ` mergeScript: `
return { return {
@@ -3,3 +3,4 @@ export * from "./plugin-deploy-to-website.js";
export * from "./plugin-deploy-to-aawaf.js"; export * from "./plugin-deploy-to-aawaf.js";
export * from "./plugin-deploy-to-website-win.js"; export * from "./plugin-deploy-to-website-win.js";
export * from "./plugin-delete-expiring-cert.js"; export * from "./plugin-delete-expiring-cert.js";
export * from "./plugin-deploy-automatch.js";
@@ -0,0 +1,122 @@
import { HttpClient } from "@certd/basic";
import { AbstractTaskPlugin, CertTargetItem, IsTaskPlugin, PageSearch, pluginGroups, RunStrategy, TaskInput, TaskOutput } from "@certd/pipeline";
import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert";
import { createCertDomainGetterInputDefine } from "@certd/plugin-lib";
import { BaotaClient } from "../lib/client.js";
import { BaotaAccess } from "../access.js";
/**
* 宝塔-全自动部署插件
* 根据证书域名自动匹配宝塔站点,全自动部署SSL证书
* 参照阿里云DCDN部署插件的"根据证书匹配"模式实现
*/
@IsTaskPlugin({
name: "BaotaAutoDeploySiteCert",
title: "宝塔-全自动部署",
icon: "svg:icon-bt",
group: pluginGroups.panel.key,
desc: "根据证书域名自动匹配宝塔站点,全自动部署SSL证书。新增加速域名自动感知,自动新增部署",
runStrategy: RunStrategy.AlwaysRun,
})
export class BaotaAutoDeploySiteCert 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: "baota的接口密钥",
component: {
name: "access-selector",
type: "baota",
},
required: true,
})
accessId!: string;
/** 输出:已部署过的站点列表 */
@TaskOutput({
title: "已部署过的站点",
})
deployedList!: string[];
async onInstance() {}
async execute(): Promise<any> {
this.logger.info("开始宝塔全自动部署证书");
const access = await this.getAccess<BaotaAccess>(this.accessId);
const http: HttpClient = this.ctx.http;
const client = new BaotaClient(access, http);
// 宝塔并发部署会导致nginx的conf错乱,用锁串行化
const lockKey = `baota-lock-${this.accessId}`;
const { result, deployedList } = await this.autoMatchedDeploy({
targetName: "宝塔站点",
// 1. 获取证书域名列表
getCertDomains: async () => {
return this.certDomains;
},
// 上传证书(宝塔不需要预上传,直接传入key/crt部署)
uploadCert: async () => {
return { key: this.cert.key, crt: this.cert.crt };
},
// 4. 部署证书到匹配的站点
deployOne: async (req: { target: CertTargetItem; cert: any }) => {
await this.ctx.utils.locker.execute(lockKey, async () => {
this.logger.info(`为站点: ${req.target.label} 设置证书`);
const res = await client.doRequest("/site", "SetSSL", {
type: 0,
siteName: req.target.value,
key: req.cert.key,
csr: req.cert.crt,
});
this.logger.info(res?.msg || `站点 ${req.target.label} 部署证书成功`);
});
},
// 2. 获取待部署证书目标列表
getDeployTargetList: async (data: PageSearch) => {
return await this.querySiteList(client);
},
});
this.deployedList = deployedList;
return result;
}
/**
* 从宝塔查询站点列表,按证书域名匹配分组
*/
private async querySiteList(client: BaotaClient): Promise<{ list: CertTargetItem[]; total: number }> {
const domains = this.certDomains;
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?.total ?? res?.all?.length ?? 0}`);
const all: string[] = res.all || [];
const options: CertTargetItem[] = all.map((item: string) => ({
value: item,
label: item,
domain: item,
}));
return {
list: options,
total: options.length,
};
}
}
new BaotaAutoDeploySiteCert();