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证书申请教程
## 1、启用API
## 1、 添加流水线
点击“创建证书流水线”按钮
## 2、 生成google ACME账号
![](./images/google-acme.png)
## 3、 获取Google EAB
### 3.1、启用API
打开如下链接,启用 API
https://console.cloud.google.com/apis/library/publicca.googleapis.com
打开该链接后点击“启用”,随后等待右侧出现“API已启用”则可以关闭该页。
## 2、 获取授权
以下两种方式任选其一
### 2.1 直接获取EAB 【推荐】
## 3.2、 创建EAB
1. 打开“Google Cloud Shell”(在右上角点击激活CloudShell图标)。
@@ -28,31 +36,13 @@ keyId: xxxxxxxxxxxxx]
```
![](./images/google-eab.png)
3. 到Certd中,创建一条EAB授权记录,填写keyId(=kid) 和 b64MacKey 信息
3. 到Certd中,创建一条EAB授权记录,填写keyId(=kid) 和 b64MacKey 信息
注意:keyId没有`]`结尾,不要把`]`也复制了
## 4、 生成Google ACME账号
注意:EAB授权使用过一次之后,会绑定邮箱,后续再次使用时,要使用相同的邮箱,所以邮箱切记不要修改
否则会报错 `Unknown external account binding (EAB) key. This may be due to the EAB key expiring which occurs 7 days after creation`
4. 创建证书流水线,选择证书提供商为google,选择EAB授权,运行流水线申请证书
### 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, 选择服务账号授权,运行流水线申请证书
## 5、创建证书流水线,运行流水线申请证书
@@ -1,10 +1,10 @@
import { PermissionException, ValidateException } from './exception/index.js';
import { EntityTarget, FindOneOptions, In, Repository, SelectQueryBuilder } from 'typeorm';
import { Inject } from '@midwayjs/core';
import { TypeORMDataSourceManager } from '@midwayjs/typeorm';
import { EntityManager } from 'typeorm/entity-manager/EntityManager.js';
import { FindManyOptions } from 'typeorm';
import { Constants } from './constants.js';
import { PermissionException, ValidateException } from "./exception/index.js";
import { EntityTarget, FindOneOptions, In, Repository, SelectQueryBuilder } from "typeorm";
import { Inject } from "@midwayjs/core";
import { TypeORMDataSourceManager } from "@midwayjs/typeorm";
import { EntityManager } from "typeorm/entity-manager/EntityManager.js";
import { FindManyOptions } from "typeorm";
import { Constants } from "./constants.js";
export type PageReq<T = any> = {
page?: { offset: number; limit: number };
@@ -34,7 +34,7 @@ export abstract class BaseService<T> {
abstract getRepository(): Repository<T>;
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);
}
@@ -52,7 +52,7 @@ export abstract class BaseService<T> {
if (ctx.manager) {
return ctx.manager.getRepository(entity);
}
const dataSource = this.dataSourceManager.getDataSource('default');
const dataSource = this.dataSourceManager.getDataSource("default");
return dataSource.getRepository(entity);
}
@@ -73,7 +73,7 @@ export abstract class BaseService<T> {
*/
async info(id, infoIgnoreProperty?): Promise<T | null> {
if (!id) {
throw new ValidateException('id不能为空');
throw new ValidateException("id不能为空");
}
const info = await this.getRepository().findOneBy({ id } as any);
if (info && infoIgnoreProperty) {
@@ -119,18 +119,18 @@ export abstract class BaseService<T> {
...where,
});
await this.modifyAfter(idArr);
return ids
return ids;
}
resolveIdArr(ids: string | any[]) {
if (!ids) {
throw new ValidateException('ids不能为空');
throw new ValidateException("ids不能为空");
}
if (typeof ids === 'string') {
return ids.split(',');
} else if(!Array.isArray(ids)){
if (typeof ids === "string") {
return ids.split(",");
} else if (!Array.isArray(ids)) {
return [ids];
}else {
} else {
return ids;
}
}
@@ -147,7 +147,7 @@ export abstract class BaseService<T> {
* 新增
* @param param 数据
*/
async add(param: any) {
async add(param: any): Promise<{ id: number; [key: string]: any }> {
const now = new Date();
param.createTime = now;
param.updateTime = now;
@@ -163,7 +163,7 @@ export abstract class BaseService<T> {
* @param param 数据
*/
async update(param: any) {
if (!param.id) throw new ValidateException('id 不能为空');
if (!param.id) throw new ValidateException("id 不能为空");
param.updateTime = new Date();
await this.addOrUpdate(param);
await this.modifyAfter(param);
@@ -201,10 +201,10 @@ export abstract class BaseService<T> {
}
private buildListQuery(listReq: ListReq<T>) {
const { query, sort, buildQuery,select } = listReq;
const qb = this.getRepository().createQueryBuilder('main');
const { query, sort, buildQuery, select } = listReq;
const qb = this.getRepository().createQueryBuilder("main");
if (select) {
qb.setFindOptions({select});
qb.setFindOptions({ select });
}
if (query) {
const keys = Object.keys(query);
@@ -223,10 +223,10 @@ export abstract class BaseService<T> {
}
});
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
if (buildQuery) {
buildQuery(qb);
@@ -243,12 +243,12 @@ export abstract class BaseService<T> {
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) {
throw new ValidateException('id不能为空');
throw new ValidateException("id不能为空");
}
if (userId == null) {
throw new ValidateException('userId不能为空');
throw new ValidateException("userId不能为空");
}
if (!Array.isArray(ids)) {
ids = [ids];
@@ -268,20 +268,20 @@ export abstract class BaseService<T> {
if (!res || res.length === ids.length) {
return;
}
throw new PermissionException('权限不足');
throw new PermissionException("权限不足");
}
filterIds(ids: any[]) {
filterIds(ids: any[]) {
if (!ids) {
throw new ValidateException('ids不能为空');
throw new ValidateException("ids不能为空");
}
return ids.filter((item) => {
return item!=null && item != ""
return ids.filter(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);
if(userId!=null){
if (userId != null) {
const userProjectQuery = this.buildUserProjectQuery(userId, projectId);
const list = await this.getRepository().find({
where: {
@@ -289,9 +289,9 @@ export abstract class BaseService<T> {
id: In(ids),
...userProjectQuery,
},
})
});
// @ts-ignore
ids = list.map(item => item.id)
ids = list.map(item => item.id);
}
await this.delete(ids);
@@ -300,19 +300,18 @@ export abstract class BaseService<T> {
async findOne(options: FindOneOptions<T>) {
return await this.getRepository().findOne(options);
}
}
export function checkUserProjectParam(userId: number, projectId: number) {
if (projectId != null ){
if( userId !== Constants.enterpriseUserId) {
throw new ValidateException('userId projectId 错误');
if (projectId != null) {
if (userId !== Constants.enterpriseUserId) {
throw new ValidateException("userId projectId 错误");
}
return true
}else{
if( userId != null) {
return true
return true;
} else {
if (userId != null) {
return true;
}
throw new ValidateException('userId不能为空');
throw new ValidateException("userId不能为空");
}
}
@@ -35,7 +35,7 @@ export class AccessService extends BaseService<AccessEntity> {
return res;
}
async add(param) {
async add(param: any): Promise<{ id: number; [key: string]: any }> {
let oldEntity = null;
if (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-empty-function": "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");
return this.ok({
key,
url: `/api/basic/file/download?key=${encodeURIComponent(key)}`,
url: `/api/basic/file/download?key=${encodeURIComponent(key as string)}`,
});
}
return this.ok({
@@ -21,7 +21,7 @@ export class CertInfoWildcardDomainCountFix {
},
});
let fixedCount = 0;
for (const item of list) {
for (const item of list as any[]) {
if (!item.domains) {
continue;
}
@@ -112,7 +112,7 @@ export class CommonEabToAcmeAccountFix {
return null;
}
const email = eabAccess.email || `${caType}@common.certd.local`;
const exists = await this.accessService.findOne({
const exists: any = await this.accessService.findOne({
where: {
userId: 0,
projectId: null,
@@ -36,7 +36,7 @@ export class RoleService extends BaseService<RoleEntity> {
}
async getRoleIdsByUserId(id: any) {
const userRoles = await this.userRoleService.find({
const userRoles: any = await this.userRoleService.find({
where: { userId: id },
});
return userRoles.map(item => item.roleId);
@@ -131,7 +131,7 @@ export class RoleService extends BaseService<RoleEntity> {
async delete(id: any) {
const idArr = this.resolveIdArr(id);
//@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) {
throw new Error("该角色已被用户使用,无法删除");
}
@@ -100,7 +100,7 @@ export class AcmeAccountAccess extends BaseAccess {
"\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" +
"\nlitessl:[litesslEAB页面](https://freessl.cn/automation/eab-manager),然后点击新增EAB",
required: false,
required: true,
encrypt: true,
mergeScript: `
return {
@@ -121,7 +121,7 @@ export class AcmeAccountAccess extends BaseAccess {
component: {
placeholder: "需要EAB的颁发机构生成账号时填写",
},
required: false,
required: true,
encrypt: true,
mergeScript: `
return {
@@ -3,3 +3,4 @@ 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";
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();