mirror of
https://github.com/certd/certd.git
synced 2026-06-27 05:47:34 +08:00
perf: 支持全自动匹配部署宝塔网站证书
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 379 KiB |
@@ -1,15 +1,23 @@
|
||||
# google证书申请教程
|
||||
|
||||
## 1、启用API
|
||||
## 1、 添加流水线
|
||||
|
||||
点击“创建证书流水线”按钮
|
||||
|
||||
## 2、 生成google ACME账号
|
||||
|
||||

|
||||
|
||||
## 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]
|
||||
```
|
||||

|
||||
|
||||
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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
+1
-1
@@ -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";
|
||||
|
||||
+122
@@ -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();
|
||||
Reference in New Issue
Block a user