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

View File

@ -145,7 +145,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
search: { search: {
show: true show: true
}, },
type: "text", type: "copyable",
form: { form: {
rules: [ rules: [
{ required: true, message: "请输入域名" }, { required: true, message: "请输入域名" },
@ -154,8 +154,15 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
] ]
}, },
column: { column: {
width: 160, width: 200,
sorter: true sorter: true,
cellRender({ value }) {
return (
<a-tooltip title={value} placement="left">
<fs-copyable modelValue={value}></fs-copyable>
</a-tooltip>
);
}
} }
}, },
httpsPort: { httpsPort: {
@ -185,7 +192,14 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
column: { column: {
width: 200, width: 200,
sorter: true, sorter: true,
show: true show: true,
cellRender({ value }) {
return (
<a-tooltip title={value} placement="left">
{value}
</a-tooltip>
);
}
} }
}, },
certProvider: { certProvider: {
@ -199,7 +213,10 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
}, },
column: { column: {
width: 200, width: 200,
sorter: true sorter: true,
cellRender({ value }) {
return <a-tooltip title={value}>{value}</a-tooltip>;
}
} }
}, },
certStatus: { certStatus: {
@ -256,7 +273,8 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
show: false show: false
}, },
column: { column: {
sorter: true sorter: true,
width: 155
} }
}, },
checkStatus: { checkStatus: {
@ -268,6 +286,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
dict: dict({ dict: dict({
data: [ data: [
{ label: "正常", value: "ok", color: "green" }, { label: "正常", value: "ok", color: "green" },
{ label: "检查中", value: "checking", color: "blue" },
{ label: "异常", value: "error", color: "red" } { label: "异常", value: "error", color: "red" }
] ]
}), }),
@ -291,7 +310,10 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
}, },
column: { column: {
width: 200, width: 200,
sorter: true sorter: true,
cellRender({ value }) {
return <a-tooltip title={value}>{value}</a-tooltip>;
}
} }
}, },
pipelineId: { pipelineId: {
@ -336,7 +358,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
value: false value: false
}, },
column: { column: {
width: 100, width: 90,
sorter: true 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 SuiteValue from "/@/views/sys/suite/product/suite-value.vue";
import DurationValue from "/@/views/sys/suite/product/duration-value.vue"; import DurationValue from "/@/views/sys/suite/product/duration-value.vue";
import UserSuiteStatus from "/@/views/certd/suite/mine/user-suite-status.vue"; import UserSuiteStatus from "/@/views/certd/suite/mine/user-suite-status.vue";
import dayjs from "dayjs";
export default function ({ crudExpose, context }: CreateCrudOptionsProps): CreateCrudOptionsRet { export default function ({ crudExpose, context }: CreateCrudOptionsProps): CreateCrudOptionsRet {
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => { const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetList(query); return await api.GetList(query);
@ -286,7 +286,10 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
component: { component: {
name: "expires-time-text", name: "expires-time-text",
vModel: "value", 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 createCrudOptionsUser from "/@/views/sys/authority/user/crud";
import UserSuiteStatus from "/@/views/certd/suite/mine/user-suite-status.vue"; import UserSuiteStatus from "/@/views/certd/suite/mine/user-suite-status.vue";
import SuiteDurationSelector from "../setting/suite-duration-selector.vue"; import SuiteDurationSelector from "../setting/suite-duration-selector.vue";
import dayjs from "dayjs";
export default function ({ crudExpose, context }: CreateCrudOptionsProps): CreateCrudOptionsRet { export default function ({ crudExpose, context }: CreateCrudOptionsProps): CreateCrudOptionsRet {
const api = sysUserSuiteApi; const api = sysUserSuiteApi;
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => { const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
@ -345,7 +346,10 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
component: { component: {
name: "expires-time-text", name: "expires-time-text",
vModel: "value", 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) { async add(@Body(ALL) bean: any) {
bean.userId = this.getUserId(); bean.userId = this.getUserId();
const res = await this.service.add(bean); 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); return this.ok(res);
} }
@ -49,7 +49,7 @@ export class SiteInfoController extends CrudController<SiteInfoService> {
await this.service.checkUserId(bean.id, this.getUserId()); await this.service.checkUserId(bean.id, this.getUserId());
delete bean.userId; delete bean.userId;
await this.service.update(bean); await this.service.update(bean);
await this.service.check(bean.id); await this.service.check(bean.id, false, 0);
return this.ok(); return this.ok();
} }
@Post('/info', { summary: Constants.per.authOnly }) @Post('/info', { summary: Constants.per.authOnly })
@ -67,14 +67,14 @@ export class SiteInfoController extends CrudController<SiteInfoService> {
@Post('/check', { summary: Constants.per.authOnly }) @Post('/check', { summary: Constants.per.authOnly })
async check(@Body('id') id: number) { async check(@Body('id') id: number) {
await this.service.checkUserId(id, this.getUserId()); await this.service.checkUserId(id, this.getUserId());
await this.service.check(id, false); await this.service.check(id, false, 0);
return this.ok(); return this.ok();
} }
@Post('/checkAll', { summary: Constants.per.authOnly }) @Post('/checkAll', { summary: Constants.per.authOnly })
async checkAll() { async checkAll() {
const userId = this.getUserId(); const userId = this.getUserId();
this.service.checkAll(userId); await this.service.checkAllByUsers(userId);
return this.ok(); return this.ok();
} }
} }

View File

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

View File

@ -5,7 +5,7 @@ import { Repository } from 'typeorm';
import { SiteInfoEntity } from '../entity/site-info.js'; import { SiteInfoEntity } from '../entity/site-info.js';
import { siteTester } from './site-tester.js'; import { siteTester } from './site-tester.js';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { logger } from '@certd/basic'; import { logger, utils } from '@certd/basic';
import { PeerCertificate } from 'tls'; import { PeerCertificate } from 'tls';
import { NotificationService } from '../../pipeline/service/notification-service.js'; import { NotificationService } from '../../pipeline/service/notification-service.js';
import { isComm, isPlus } from '@certd/plus-core'; import { isComm, isPlus } from '@certd/plus-core';
@ -76,23 +76,35 @@ export class SiteInfoService extends BaseService<SiteInfoEntity> {
* *
* @param site * @param site
* @param notify * @param notify
* @param retryTimes
*/ */
async doCheck(site: SiteInfoEntity, notify = true) { async doCheck(site: SiteInfoEntity, notify = true, retryTimes = 3) {
if (!site?.domain) { if (!site?.domain) {
throw new Error('站点域名不能为空'); throw new Error('站点域名不能为空');
} }
try { try {
await this.update({
id: site.id,
checkStatus: 'checking',
lastCheckTime: dayjs,
});
const res = await siteTester.test({ const res = await siteTester.test({
host: site.domain, host: site.domain,
port: site.httpsPort, port: site.httpsPort,
retryTimes,
}); });
const certi: PeerCertificate = res.certificate; const certi: PeerCertificate = res.certificate;
if (!certi) { if (!certi) {
return; throw new Error('没有发现证书');
} }
const expires = certi.valid_to; 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 issuer = `${certi.issuer.O}<${certi.issuer.CN}>`;
const isExpired = dayjs().valueOf() > dayjs(expires).valueOf(); const isExpired = dayjs().valueOf() > dayjs(expires).valueOf();
const status = isExpired ? 'expired' : 'ok'; const status = isExpired ? 'expired' : 'ok';
@ -139,13 +151,14 @@ export class SiteInfoService extends BaseService<SiteInfoEntity> {
* *
* @param id * @param id
* @param notify * @param notify
* @param retryTimes
*/ */
async check(id: number, notify = false) { async check(id: number, notify = false, retryTimes = 3) {
const site = await this.info(id); const site = await this.info(id);
if (!site) { if (!site) {
throw new Error('站点不存在'); throw new Error('站点不存在');
} }
return await this.doCheck(site, notify); return await this.doCheck(site, notify, retryTimes);
} }
async sendCheckErrorNotify(site: SiteInfoEntity) { async sendCheckErrorNotify(site: SiteInfoEntity) {
@ -206,15 +219,22 @@ export class SiteInfoService extends BaseService<SiteInfoEntity> {
} }
} }
async checkAll(userId: any) { async checkAllByUsers(userId: any) {
if (!userId) { if (!userId) {
throw new Error('userId is required'); throw new Error('userId is required');
} }
const sites = await this.repository.find({ const sites = await this.repository.find({
where: { userId }, where: { userId },
}); });
this.checkList(sites);
}
async checkList(sites: SiteInfoEntity[]) {
for (const site of sites) { 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 { merge } from 'lodash-es';
import https from 'https'; import https from 'https';
import { PeerCertificate } from 'tls'; import { PeerCertificate } from 'tls';
@ -6,6 +6,7 @@ export type SiteTestReq = {
host: string; // 只用域名部分 host: string; // 只用域名部分
port?: number; port?: number;
method?: string; method?: string;
retryTimes?: number;
}; };
export type SiteTestRes = { export type SiteTestRes = {
@ -14,6 +15,28 @@ export type SiteTestRes = {
export class SiteTester { export class SiteTester {
async test(req: SiteTestReq): Promise<SiteTestRes> { async test(req: SiteTestReq): Promise<SiteTestRes> {
logger.info('测试站点:', JSON.stringify(req)); 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 agent = new https.Agent({ keepAlive: false });
const options: any = merge( const options: any = merge(