perf: 优化站点证书检查页面,检查增加3次重试

v2
xiaojunnuo 2025-01-04 20:10:00 +08:00
parent aa1da7c11a
commit e6dd7cd54a
8 changed files with 99 additions and 35 deletions

View File

@ -10,6 +10,7 @@ Certd 是一个免费全自动申请和自动部署更新SSL证书的管理系
* 全自动申请证书(支持所有注册商注册的域名)
* 全自动部署更新证书目前支持部署到主机、阿里云、腾讯云等目前已支持40+部署插件)
* 支持DNS-01、HTTP-01、CNAME代理等多种域名验证方式
* 支持通配符域名/泛域名支持多个域名打到一个证书上支持pem、pfx、der、jks等多种证书格式
* 邮件通知、webhook通知
* 私有化部署数据保存本地授权信息加密存储镜像由Github Actions构建过程公开透明
@ -155,9 +156,6 @@ services:
## 六、一些说明
* 本项目ssl证书提供商为letencrypt/Google/ZeroSSL
* 申请过程遵循acme协议
* 需要验证域名所有权一般有两种方式目前本项目仅支持dns-01
* http-01 在网站根目录下放置一份txt文件
* dns-01 需要给域名添加txt解析记录通配符域名只能用这种方式
* 证书续期:
* 实际上没有办法不改变证书文件本身情况下直接续期或者续签。
* 我们所说的续期,其实就是按照全套流程重新申请一份新证书,然后重新部署上去。

View File

@ -145,7 +145,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
search: {
show: true
},
type: "text",
type: "copyable",
form: {
rules: [
{ required: true, message: "请输入域名" },
@ -154,8 +154,15 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
]
},
column: {
width: 160,
sorter: true
width: 200,
sorter: true,
cellRender({ value }) {
return (
<a-tooltip title={value} placement="left">
<fs-copyable modelValue={value}></fs-copyable>
</a-tooltip>
);
}
}
},
httpsPort: {
@ -185,7 +192,14 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
column: {
width: 200,
sorter: true,
show: true
show: true,
cellRender({ value }) {
return (
<a-tooltip title={value} placement="left">
{value}
</a-tooltip>
);
}
}
},
certProvider: {
@ -199,7 +213,10 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
},
column: {
width: 200,
sorter: true
sorter: true,
cellRender({ value }) {
return <a-tooltip title={value}>{value}</a-tooltip>;
}
}
},
certStatus: {
@ -256,7 +273,8 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
show: false
},
column: {
sorter: true
sorter: true,
width: 155
}
},
checkStatus: {
@ -268,6 +286,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
dict: dict({
data: [
{ label: "正常", value: "ok", color: "green" },
{ label: "检查中", value: "checking", color: "blue" },
{ label: "异常", value: "error", color: "red" }
]
}),
@ -291,7 +310,10 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
},
column: {
width: 200,
sorter: true
sorter: true,
cellRender({ value }) {
return <a-tooltip title={value}>{value}</a-tooltip>;
}
}
},
pipelineId: {
@ -336,7 +358,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
value: false
},
column: {
width: 100,
width: 90,
sorter: true
}
}

View File

@ -5,7 +5,7 @@ import SuiteValueEdit from "/@/views/sys/suite/product/suite-value-edit.vue";
import SuiteValue from "/@/views/sys/suite/product/suite-value.vue";
import DurationValue from "/@/views/sys/suite/product/duration-value.vue";
import UserSuiteStatus from "/@/views/certd/suite/mine/user-suite-status.vue";
import dayjs from "dayjs";
export default function ({ crudExpose, context }: CreateCrudOptionsProps): CreateCrudOptionsRet {
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetList(query);
@ -286,7 +286,10 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
component: {
name: "expires-time-text",
vModel: "value",
mode: "tag"
mode: "tag",
title: compute(({ value }) => {
return dayjs(value).format("YYYY-MM-DD HH:mm:ss");
})
}
}
},

View File

@ -7,6 +7,7 @@ import DurationValue from "/@/views/sys/suite/product/duration-value.vue";
import createCrudOptionsUser from "/@/views/sys/authority/user/crud";
import UserSuiteStatus from "/@/views/certd/suite/mine/user-suite-status.vue";
import SuiteDurationSelector from "../setting/suite-duration-selector.vue";
import dayjs from "dayjs";
export default function ({ crudExpose, context }: CreateCrudOptionsProps): CreateCrudOptionsRet {
const api = sysUserSuiteApi;
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
@ -345,7 +346,10 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
component: {
name: "expires-time-text",
vModel: "value",
mode: "tag"
mode: "tag",
title: compute(({ value }) => {
return dayjs(value).format("YYYY-MM-DD HH:mm:ss");
})
}
}
},

View File

@ -40,7 +40,7 @@ export class SiteInfoController extends CrudController<SiteInfoService> {
async add(@Body(ALL) bean: any) {
bean.userId = this.getUserId();
const res = await this.service.add(bean);
await this.service.check(res.id);
await this.service.check(res.id, false, 0);
return this.ok(res);
}
@ -49,7 +49,7 @@ export class SiteInfoController extends CrudController<SiteInfoService> {
await this.service.checkUserId(bean.id, this.getUserId());
delete bean.userId;
await this.service.update(bean);
await this.service.check(bean.id);
await this.service.check(bean.id, false, 0);
return this.ok();
}
@Post('/info', { summary: Constants.per.authOnly })
@ -67,14 +67,14 @@ export class SiteInfoController extends CrudController<SiteInfoService> {
@Post('/check', { summary: Constants.per.authOnly })
async check(@Body('id') id: number) {
await this.service.checkUserId(id, this.getUserId());
await this.service.check(id, false);
await this.service.check(id, false, 0);
return this.ok();
}
@Post('/checkAll', { summary: Constants.per.authOnly })
async checkAll() {
const userId = this.getUserId();
this.service.checkAll(userId);
await this.service.checkAllByUsers(userId);
return this.ok();
}
}

View File

@ -1,6 +1,6 @@
import { Autoload, Config, Init, Inject, Scope, ScopeEnum } from '@midwayjs/core';
import { PipelineService } from '../pipeline/service/pipeline-service.js';
import { logger } from '@certd/basic';
import { logger, utils } from '@certd/basic';
import { SysSettingsService } from '@certd/lib-server';
import { SiteInfoService } from '../monitor/index.js';
import { Cron } from '../cron/cron.js';
@ -59,13 +59,7 @@ export class AutoCRegisterCron {
break;
}
offset += records.length;
for (const record of records) {
try {
await this.siteInfoService.doCheck(record, true);
} catch (e) {
logger.error(`站点${record.name}检查出错:`, e);
}
}
await this.siteInfoService.checkList(records);
}
logger.info('站点证书检查完成');

View File

@ -5,7 +5,7 @@ import { Repository } from 'typeorm';
import { SiteInfoEntity } from '../entity/site-info.js';
import { siteTester } from './site-tester.js';
import dayjs from 'dayjs';
import { logger } from '@certd/basic';
import { logger, utils } from '@certd/basic';
import { PeerCertificate } from 'tls';
import { NotificationService } from '../../pipeline/service/notification-service.js';
import { isComm, isPlus } from '@certd/plus-core';
@ -76,23 +76,35 @@ export class SiteInfoService extends BaseService<SiteInfoEntity> {
*
* @param site
* @param notify
* @param retryTimes
*/
async doCheck(site: SiteInfoEntity, notify = true) {
async doCheck(site: SiteInfoEntity, notify = true, retryTimes = 3) {
if (!site?.domain) {
throw new Error('站点域名不能为空');
}
try {
await this.update({
id: site.id,
checkStatus: 'checking',
lastCheckTime: dayjs,
});
const res = await siteTester.test({
host: site.domain,
port: site.httpsPort,
retryTimes,
});
const certi: PeerCertificate = res.certificate;
if (!certi) {
return;
throw new Error('没有发现证书');
}
const expires = certi.valid_to;
const domains = [certi.subject?.CN, ...certi.subjectaltname?.replaceAll('DNS:', '').split(',')];
const allDomains = certi.subjectaltname?.replaceAll('DNS:', '').split(',');
const mainDomain = certi.subject?.CN;
let domains = allDomains;
if (!allDomains.includes(mainDomain)) {
domains = [mainDomain, ...allDomains];
}
const issuer = `${certi.issuer.O}<${certi.issuer.CN}>`;
const isExpired = dayjs().valueOf() > dayjs(expires).valueOf();
const status = isExpired ? 'expired' : 'ok';
@ -139,13 +151,14 @@ export class SiteInfoService extends BaseService<SiteInfoEntity> {
*
* @param id
* @param notify
* @param retryTimes
*/
async check(id: number, notify = false) {
async check(id: number, notify = false, retryTimes = 3) {
const site = await this.info(id);
if (!site) {
throw new Error('站点不存在');
}
return await this.doCheck(site, notify);
return await this.doCheck(site, notify, retryTimes);
}
async sendCheckErrorNotify(site: SiteInfoEntity) {
@ -206,15 +219,22 @@ export class SiteInfoService extends BaseService<SiteInfoEntity> {
}
}
async checkAll(userId: any) {
async checkAllByUsers(userId: any) {
if (!userId) {
throw new Error('userId is required');
}
const sites = await this.repository.find({
where: { userId },
});
this.checkList(sites);
}
async checkList(sites: SiteInfoEntity[]) {
for (const site of sites) {
await this.doCheck(site);
this.doCheck(site).catch(e => {
logger.error(`检查站点证书失败,${site.domain}`, e.message);
});
await utils.sleep(200);
}
}
}

View File

@ -1,4 +1,4 @@
import { logger } from '@certd/basic';
import { logger, utils } from '@certd/basic';
import { merge } from 'lodash-es';
import https from 'https';
import { PeerCertificate } from 'tls';
@ -6,6 +6,7 @@ export type SiteTestReq = {
host: string; // 只用域名部分
port?: number;
method?: string;
retryTimes?: number;
};
export type SiteTestRes = {
@ -14,6 +15,28 @@ export type SiteTestRes = {
export class SiteTester {
async test(req: SiteTestReq): Promise<SiteTestRes> {
logger.info('测试站点:', JSON.stringify(req));
const maxRetryTimes = req.retryTimes ?? 3;
let tryCount = 0;
let result: SiteTestRes = {};
while (true) {
try {
result = await this.doTestOnce(req);
return result;
} catch (e) {
tryCount++;
if (tryCount > maxRetryTimes) {
logger.error(`测试站点出错,重试${maxRetryTimes}`, e);
throw e;
}
//指数退避
const time = 2 ** tryCount;
logger.error(`测试站点出错,${time}s后重试`, e);
await utils.sleep(time * 1000);
}
}
}
async doTestOnce(req: SiteTestReq): Promise<SiteTestRes> {
const agent = new https.Agent({ keepAlive: false });
const options: any = merge(