mirror of https://github.com/certd/certd
perf: 优化站点证书检查页面,检查增加3次重试
parent
aa1da7c11a
commit
e6dd7cd54a
|
@ -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解析记录,通配符域名只能用这种方式
|
|
||||||
* 证书续期:
|
* 证书续期:
|
||||||
* 实际上没有办法不改变证书文件本身情况下直接续期或者续签。
|
* 实际上没有办法不改变证书文件本身情况下直接续期或者续签。
|
||||||
* 我们所说的续期,其实就是按照全套流程重新申请一份新证书,然后重新部署上去。
|
* 我们所说的续期,其实就是按照全套流程重新申请一份新证书,然后重新部署上去。
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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");
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -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");
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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('站点证书检查完成');
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(
|
||||||
|
|
Loading…
Reference in New Issue