perf: 站点证书监控通知发送,每天定时检查

pull/330/head
xiaojunnuo 2024-12-23 18:11:06 +08:00
parent 89c7f07034
commit bb4910f4e5
16 changed files with 536 additions and 143 deletions

View File

@ -1,4 +1,4 @@
export function isDev() { export function isDev() {
const nodeEnv = process.env.NODE_ENV || ''; const nodeEnv = process.env.NODE_ENV || '';
return nodeEnv === 'development' || nodeEnv.indexOf('local') >= 0 || nodeEnv.includes('dev'); return nodeEnv === 'development' || nodeEnv.includes('local') || nodeEnv.startsWith('dev');
} }

View File

@ -3,6 +3,7 @@ import { In, Repository, SelectQueryBuilder } from 'typeorm';
import { Inject } from '@midwayjs/core'; import { Inject } from '@midwayjs/core';
import { TypeORMDataSourceManager } from '@midwayjs/typeorm'; import { TypeORMDataSourceManager } from '@midwayjs/typeorm';
import { EntityManager } from 'typeorm/entity-manager/EntityManager.js'; import { EntityManager } from 'typeorm/entity-manager/EntityManager.js';
import { FindManyOptions } from 'typeorm';
export type PageReq<T = any> = { export type PageReq<T = any> = {
page?: { offset: number; limit: number }; page?: { offset: number; limit: number };
@ -15,6 +16,7 @@ export type ListReq<T = any> = {
asc: boolean; asc: boolean;
}; };
buildQuery?: (bq: SelectQueryBuilder<any>) => void; buildQuery?: (bq: SelectQueryBuilder<any>) => void;
select?: any;
}; };
/** /**
@ -53,7 +55,7 @@ export abstract class BaseService<T> {
* *
* @param options * @param options
*/ */
async find(options) { async find(options: FindManyOptions<T>) {
return await this.getRepository().find(options); return await this.getRepository().find(options);
} }

View File

@ -84,6 +84,10 @@ export class CertReader {
} }
getCrtDetail(crt: string = this.cert.crt) { getCrtDetail(crt: string = this.cert.crt) {
return CertReader.readCertDetail(crt);
}
static readCertDetail(crt: string) {
const detail = crypto.readCertificateInfo(crt.toString()); const detail = crypto.readCertificateInfo(crt.toString());
const expires = detail.notAfter; const expires = detail.notAfter;
return { detail, expires }; return { detail, expires };

View File

@ -41,7 +41,7 @@ export const certdResources = [
} }
}, },
{ {
title: "证书监控", title: "站点证书监控",
name: "SiteCertMonitor", name: "SiteCertMonitor",
path: "/certd/monitor/site", path: "/certd/monitor/site",
component: "/certd/monitor/site/index.vue", component: "/certd/monitor/site/index.vue",

View File

@ -170,33 +170,63 @@ export const sysResources = [
permission: "sys:auth:user:view" permission: "sys:auth:user:view"
} }
}, },
{ {
title: "套餐设置", title: "套餐管理",
name: "SuiteSetting", name: "SuiteManager",
path: "/sys/suite/setting", path: "/sys/suite",
component: "/sys/suite/setting/index.vue",
meta: { meta: {
icon: "ion:cart-outline",
permission: "sys:settings:edit",
show: () => { show: () => {
const settingStore = useSettingStore(); const settingStore = useSettingStore();
return settingStore.isComm; return settingStore.isComm;
}
},
children: [
{
title: "套餐设置",
name: "SuiteSetting",
path: "/sys/suite/setting",
component: "/sys/suite/setting/index.vue",
meta: {
show: () => {
const settingStore = useSettingStore();
return settingStore.isComm;
},
icon: "ion:cart",
permission: "sys:settings:edit"
}
}, },
icon: "ion:cart", {
permission: "sys:settings:edit" title: "订单管理",
} name: "OrderManager",
}, path: "/sys/suite/trade",
{ component: "/sys/suite/trade/index.vue",
title: "订单管理", meta: {
name: "OrderManager", show: () => {
path: "/sys/suite/trade", const settingStore = useSettingStore();
component: "/sys/suite/trade/index.vue", return settingStore.isComm;
meta: { },
show: () => { icon: "ion:bag-check",
const settingStore = useSettingStore(); permission: "sys:settings:edit"
return settingStore.isComm; }
}, },
icon: "ion:bag-check", {
permission: "sys:settings:edit" title: "用户套餐",
} name: "UserSuites",
path: "/sys/suite/user-suite",
component: "/certd/suite/user-suite/index.vue",
meta: {
show: () => {
const settingStore = useSettingStore();
return settingStore.isComm;
},
icon: "ion:gift-outline",
auth: true
}
}
]
} }
] ]
} }

View File

@ -1,54 +1,58 @@
import { request } from "/src/api/service"; import { request } from "/src/api/service";
export function createApi() { const apiPrefix = "/monitor/site";
const apiPrefix = "/monitor/site";
return {
async GetList(query: any) {
return await request({
url: apiPrefix + "/page",
method: "post",
data: query
});
},
async AddObj(obj: any) { export const siteInfoApi = {
return await request({ async GetList(query: any) {
url: apiPrefix + "/add", return await request({
method: "post", url: apiPrefix + "/page",
data: obj method: "post",
}); data: query
}, });
},
async UpdateObj(obj: any) { async AddObj(obj: any) {
return await request({ return await request({
url: apiPrefix + "/update", url: apiPrefix + "/add",
method: "post", method: "post",
data: obj data: obj
}); });
}, },
async DelObj(id: number) { async UpdateObj(obj: any) {
return await request({ return await request({
url: apiPrefix + "/delete", url: apiPrefix + "/update",
method: "post", method: "post",
params: { id } data: obj
}); });
}, },
async GetObj(id: number) { async DelObj(id: number) {
return await request({ return await request({
url: apiPrefix + "/info", url: apiPrefix + "/delete",
method: "post", method: "post",
params: { id } params: { id }
}); });
}, },
async ListAll() {
return await request({
url: apiPrefix + "/all",
method: "post"
});
}
};
}
export const pipelineGroupApi = createApi(); async GetObj(id: number) {
return await request({
url: apiPrefix + "/info",
method: "post",
params: { id }
});
},
async DoCheck(id: number) {
return await request({
url: apiPrefix + "/check",
method: "post",
data: { id }
});
},
async CheckAll() {
return await request({
url: apiPrefix + "/checkAll",
method: "post"
});
}
};

View File

@ -1,12 +1,13 @@
// @ts-ignore // @ts-ignore
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { ref } from "vue";
import { AddReq, CreateCrudOptionsProps, CreateCrudOptionsRet, DelReq, dict, EditReq, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud"; import { AddReq, CreateCrudOptionsProps, CreateCrudOptionsRet, DelReq, dict, EditReq, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
import { pipelineGroupApi } from "./api"; import { siteInfoApi } from "./api";
import dayjs from "dayjs";
import { notification } from "ant-design-vue";
export default function ({ crudExpose, context }: CreateCrudOptionsProps): CreateCrudOptionsRet { export default function ({ crudExpose, context }: CreateCrudOptionsProps): CreateCrudOptionsRet {
const { t } = useI18n(); const { t } = useI18n();
const api = pipelineGroupApi; const api = siteInfoApi;
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => { const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetList(query); return await api.GetList(query);
}; };
@ -51,7 +52,24 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
} }
}, },
rowHandle: { rowHandle: {
width: 200 fixed: "right",
width: 240,
buttons: {
check: {
order: 0,
type: "link",
text: null,
title: "立即检查",
icon: "ion:play-sharp",
click: async ({ row }) => {
await api.DoCheck(row.id);
await crudExpose.doRefresh();
notification.success({
message: "检查完成"
});
}
}
}
}, },
columns: { columns: {
id: { id: {
@ -62,44 +80,13 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
show: false show: false
}, },
column: { column: {
width: 100, width: 80,
editable: { align: "center"
disabled: true
}
}, },
form: { form: {
show: false show: false
} }
}, },
domain: {
title: "网站域名",
search: {
show: true
},
type: "text",
form: {
rules: [{ required: true, message: "请输入域名" }]
},
column: {
width: 200,
sorter: true
}
},
port: {
title: "HTTPS端口",
search: {
show: false
},
type: "number",
form: {
value: 443,
rules: [{ required: true, message: "请输入端口" }]
},
column: {
width: 100
}
},
name: { name: {
title: "站点名称", title: "站点名称",
search: { search: {
@ -110,11 +97,40 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
rules: [{ required: true, message: "请输入站点名称" }] rules: [{ required: true, message: "请输入站点名称" }]
}, },
column: { column: {
width: 200 width: 160
} }
}, },
domains: { domain: {
title: "其他域名", title: "网站域名",
search: {
show: true
},
type: "text",
form: {
rules: [{ required: true, message: "请输入域名" }]
},
column: {
width: 160,
sorter: true
}
},
httpsPort: {
title: "HTTPS端口",
search: {
show: false
},
type: "number",
form: {
value: 443,
rules: [{ required: true, message: "请输入端口" }]
},
column: {
align: "center",
width: 100
}
},
certDomains: {
title: "证书域名",
search: { search: {
show: false show: false
}, },
@ -123,13 +139,13 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
show: false show: false
}, },
column: { column: {
width: 300, width: 200,
sorter: true, sorter: true,
show: false show: true
} }
}, },
certInfo: { certProvider: {
title: "证书详情", title: "证书颁发者",
search: { search: {
show: false show: false
}, },
@ -138,8 +154,8 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
show: false show: false
}, },
column: { column: {
width: 100, width: 200,
show: false sorter: true
} }
}, },
certStatus: { certStatus: {
@ -147,13 +163,20 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
search: { search: {
show: true show: true
}, },
type: "text", type: "dict-select",
dict: dict({
data: [
{ label: "正常", value: "ok", color: "green" },
{ label: "过期", value: "expired", color: "red" }
]
}),
form: { form: {
show: false show: false
}, },
column: { column: {
width: 100, width: 100,
sorter: true sorter: true,
show: false
} }
}, },
certExpiresTime: { certExpiresTime: {
@ -166,7 +189,17 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
show: false show: false
}, },
column: { column: {
sorter: true sorter: true,
cellRender({ value }) {
if (!value) {
return "-";
}
const expireDate = dayjs(value).format("YYYY-MM-DD");
const leftDays = dayjs(value).diff(dayjs(), "day");
const color = leftDays < 20 ? "red" : "#389e0d";
const percent = (leftDays / 90) * 100;
return <a-progress title={expireDate + "过期"} percent={percent} strokeColor={color} format={(percent: number) => `${leftDays}`} />;
}
} }
}, },
lastCheckTime: { lastCheckTime: {
@ -187,12 +220,33 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
search: { search: {
show: false show: false
}, },
type: "text", type: "dict-select",
dict: dict({
data: [
{ label: "正常", value: "ok", color: "green" },
{ label: "异常", value: "error", color: "red" }
]
}),
form: { form: {
show: false show: false
}, },
column: { column: {
width: 100, width: 100,
align: "center",
sorter: true
}
},
error: {
title: "错误信息",
search: {
show: false
},
type: "text",
form: {
show: false
},
column: {
width: 200,
sorter: true sorter: true
} }
}, },
@ -205,7 +259,8 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
type: "number", type: "number",
column: { column: {
width: 200, width: 200,
sorter: true sorter: true,
show: false
} }
}, },
certInfoId: { certInfoId: {
@ -234,7 +289,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
] ]
}), }),
form: { form: {
value: true value: false
}, },
column: { column: {
width: 100, width: 100,

View File

@ -3,7 +3,10 @@
<template #header> <template #header>
<div class="title"> <div class="title">
站点证书监控 站点证书监控
<span class="sub">监控网站证书的过期时间并发出提醒未完成开发中</span> <span class="sub">每天0点检查网站证书的过期时间并发出提醒</span>
</div>
<div class="more">
<a-button type="primary" @click="checkAll"></a-button>
</div> </div>
</template> </template>
<fs-crud ref="crudRef" v-bind="crudBinding"> </fs-crud> <fs-crud ref="crudRef" v-bind="crudBinding"> </fs-crud>
@ -11,15 +14,30 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { defineComponent, onActivated, onMounted } from "vue"; import { onActivated, onMounted } from "vue";
import { useFs } from "@fast-crud/fast-crud"; import { useFs } from "@fast-crud/fast-crud";
import createCrudOptions from "./crud"; import createCrudOptions from "./crud";
import { createApi } from "./api"; import { siteInfoApi } from "./api";
import { Modal, notification } from "ant-design-vue";
defineOptions({ defineOptions({
name: "SiteCertMonitor" name: "SiteCertMonitor"
}); });
const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions, context: {} }); const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions, context: {} });
function checkAll() {
Modal.confirm({
title: "确认",
content: "确认触发检查全部站点证书吗?",
onOk: async () => {
await siteInfoApi.CheckAll();
notification.success({
message: "检查任务已提交",
description: "请稍后刷新页面查看结果"
});
}
});
}
// //
onMounted(() => { onMounted(() => {
crudExpose.doRefresh(); crudExpose.doRefresh();

View File

@ -107,14 +107,16 @@ CREATE TABLE "cd_site_info"
"name" varchar(100), "name" varchar(100),
"domain" varchar(100), "domain" varchar(100),
"domains" varchar(10240),
"cert_info" varchar(10240),
"https_port" integer,
"cert_domains" varchar(10240),
"cert_info" varchar(10240),
"cert_provider" varchar(100), "cert_provider" varchar(100),
"cert_status" varchar(100), "cert_status" varchar(100),
"cert_expires_time" integer, "cert_expires_time" integer,
"last_check_time" integer, "last_check_time" integer,
"check_status" varchar(100), "check_status" varchar(100),
"error" varchar(4096),
"pipeline_id" integer, "pipeline_id" integer,
"cert_info_id" integer, "cert_info_id" integer,
"disabled" boolean NOT NULL DEFAULT (false), "disabled" boolean NOT NULL DEFAULT (false),
@ -125,7 +127,6 @@ CREATE TABLE "cd_site_info"
CREATE INDEX "index_site_info_user_id" ON "cd_site_info" ("user_id"); CREATE INDEX "index_site_info_user_id" ON "cd_site_info" ("user_id");
CREATE INDEX "index_site_info_domain" ON "cd_site_info" ("domain"); CREATE INDEX "index_site_info_domain" ON "cd_site_info" ("domain");
CREATE INDEX "index_site_info_domains" ON "cd_site_info" ("domains");
CREATE INDEX "index_site_info_pipeline" ON "cd_site_info" ("pipeline_id"); CREATE INDEX "index_site_info_pipeline" ON "cd_site_info" ("pipeline_id");

View File

@ -49,6 +49,7 @@ const development = {
cron: { cron: {
//启动时立即触发一次 //启动时立即触发一次
immediateTriggerOnce: false, immediateTriggerOnce: false,
immediateTriggerSiteMonitor: false,
//启动时仅注册adminid=1用户的 //启动时仅注册adminid=1用户的
onlyAdminUser: false, onlyAdminUser: false,
}, },

View File

@ -60,13 +60,17 @@ export class SiteInfoController extends CrudController<SiteInfoService> {
return await super.delete(id); return await super.delete(id);
} }
@Post('/all', { summary: Constants.per.authOnly }) @Post('/check', { summary: Constants.per.authOnly })
async all() { async check(@Body('id') id: number) {
const list: any = await this.service.find({ await this.service.checkUserId(id, this.getUserId());
where: { await this.service.check(id, false);
userId: this.getUserId(), return this.ok();
}, }
});
return this.ok(list); @Post('/checkAll', { summary: Constants.per.authOnly })
async checkAll() {
const userId = this.getUserId();
this.service.checkAll(userId);
return this.ok();
} }
} }

View File

@ -2,6 +2,8 @@ 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 } from '@certd/basic';
import { SysSettingsService } from '@certd/lib-server'; import { SysSettingsService } from '@certd/lib-server';
import { SiteInfoService } from '../monitor/index.js';
import { Cron } from '../cron/cron.js';
@Autoload() @Autoload()
@Scope(ScopeEnum.Request, { allowDowngrade: true }) @Scope(ScopeEnum.Request, { allowDowngrade: true })
@ -17,9 +19,18 @@ export class AutoRegisterCron {
@Config('cron.immediateTriggerOnce') @Config('cron.immediateTriggerOnce')
private immediateTriggerOnce = false; private immediateTriggerOnce = false;
@Config('cron.immediateTriggerSiteMonitor')
private immediateTriggerSiteMonitor = false;
@Inject() @Inject()
sysSettingsService: SysSettingsService; sysSettingsService: SysSettingsService;
@Inject()
siteInfoService: SiteInfoService;
@Inject()
cron: Cron;
@Init() @Init()
async init() { async init() {
logger.info('加载定时trigger开始'); logger.info('加载定时trigger开始');
@ -30,5 +41,45 @@ export class AutoRegisterCron {
// console.log('meta', meta); // console.log('meta', meta);
// const metas = listPropertyDataFromClass(CLASS_KEY, this.echoPlugin); // const metas = listPropertyDataFromClass(CLASS_KEY, this.echoPlugin);
// console.log('metas', metas); // console.log('metas', metas);
this.registerSiteMonitorCron();
}
registerSiteMonitorCron() {
const job = async () => {
logger.info('站点证书检查开始执行');
let offset = 0;
const limit = 50;
while (true) {
const res = await this.siteInfoService.page({
query: { disabled: false },
page: { offset, limit },
});
const { records } = res;
if (records.length === 0) {
break;
}
offset += records.length;
for (const record of records) {
try {
await this.siteInfoService.doCheck(record, true);
} catch (e) {
logger.error(`站点${record.name}检查出错:`, e);
}
}
}
logger.info('站点证书检查完成');
};
this.cron.register({
name: 'siteMonitor',
cron: '0 0 0 * * *',
job,
});
if (this.immediateTriggerSiteMonitor) {
job();
}
} }
} }

View File

@ -8,13 +8,16 @@ export class SiteInfoEntity {
id: number; id: number;
@Column({ name: 'user_id', comment: '用户id' }) @Column({ name: 'user_id', comment: '用户id' })
userId: number; userId: number;
@Column({ comment: '站点名称', length: 100 }) @Column({ name: 'name', comment: '站点名称', length: 100 })
name: string; name: string;
@Column({ comment: '域名', length: 100 }) @Column({ name: 'domain', comment: '域名', length: 100 })
domain: string; domain: string;
@Column({ comment: '其他域名', length: 4096 })
domains: string;
@Column({ name: 'https_port', comment: '端口' })
httpsPort: number;
@Column({ name: 'cert_domains', comment: '证书域名', length: 4096 })
certDomains: string;
@Column({ name: 'cert_info', comment: '证书详情', length: 4096 }) @Column({ name: 'cert_info', comment: '证书详情', length: 4096 })
certInfo: string; certInfo: string;
@Column({ name: 'cert_status', comment: '证书状态', length: 100 }) @Column({ name: 'cert_status', comment: '证书状态', length: 100 })
@ -29,7 +32,8 @@ export class SiteInfoEntity {
lastCheckTime: number; lastCheckTime: number;
@Column({ name: 'check_status', comment: '检查状态' }) @Column({ name: 'check_status', comment: '检查状态' })
checkStatus: string; checkStatus: string;
@Column({ name: 'error', comment: '错误信息' })
error: string;
@Column({ name: 'pipeline_id', comment: '关联流水线id' }) @Column({ name: 'pipeline_id', comment: '关联流水线id' })
pipelineId: number; pipelineId: number;

View File

@ -1,14 +1,22 @@
import { Provide } from '@midwayjs/core'; import { Inject, Provide } from '@midwayjs/core';
import { BaseService } from '@certd/lib-server'; import { BaseService } from '@certd/lib-server';
import { InjectEntityModel } from '@midwayjs/typeorm'; import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm'; 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 dayjs from 'dayjs';
import { logger } from '@certd/basic';
import { PeerCertificate } from 'tls';
import { NotificationService } from '../../pipeline/service/notification-service.js';
@Provide() @Provide()
export class SiteInfoService extends BaseService<SiteInfoEntity> { export class SiteInfoService extends BaseService<SiteInfoEntity> {
@InjectEntityModel(SiteInfoEntity) @InjectEntityModel(SiteInfoEntity)
repository: Repository<SiteInfoEntity>; repository: Repository<SiteInfoEntity>;
@Inject()
notificationService: NotificationService;
//@ts-ignore //@ts-ignore
getRepository() { getRepository() {
return this.repository; return this.repository;
@ -22,4 +30,150 @@ export class SiteInfoService extends BaseService<SiteInfoEntity> {
where: { userId }, where: { userId },
}); });
} }
/**
*
* @param site
* @param notify
*/
async doCheck(site: SiteInfoEntity, notify = true) {
if (!site?.domain) {
throw new Error('站点域名不能为空');
}
try {
const res = await siteTester.test({
host: site.domain,
port: site.httpsPort,
});
const certi: PeerCertificate = res.certificate;
if (!certi) {
return;
}
const expires = certi.valid_to;
const domains = [certi.subject?.CN, ...certi.subjectaltname?.replaceAll('DNS:', '').split(',')];
const issuer = `${certi.issuer.O}<${certi.issuer.CN}>`;
const isExpired = dayjs().valueOf() > dayjs(expires).valueOf();
const status = isExpired ? 'expired' : 'ok';
const updateData = {
id: site.id,
certDomains: domains.join(','),
certStatus: status,
certProvider: issuer,
certExpiresTime: dayjs(expires).valueOf(),
lastCheckTime: dayjs().valueOf(),
error: null,
checkStatus: 'ok',
};
await this.update(updateData);
if (!notify) {
return;
}
try {
await this.sendExpiresNotify(site);
} catch (e) {
logger.error('send notify error', e);
}
} catch (e) {
logger.error('check site error', e);
await this.update({
id: site.id,
checkStatus: 'error',
lastCheckTime: dayjs().valueOf(),
error: e.message,
});
if (!notify) {
return;
}
try {
await this.sendCheckErrorNotify(site);
} catch (e) {
logger.error('send notify error', e);
}
}
}
/**
*
* @param id
* @param notify
*/
async check(id: number, notify = false) {
const site = await this.info(id);
if (!site) {
throw new Error('站点不存在');
}
return await this.doCheck(site, notify);
}
async sendCheckErrorNotify(site: SiteInfoEntity) {
const url = await this.notificationService.getBindUrl('#/certd/monitor/site');
// 发邮件
await this.notificationService.send(
{
useDefault: true,
logger: logger,
body: {
url,
title: `站点证书检查出错<${site.name}>`,
content: `站点名称: ${site.name} \n
${site.domain} \n
${site.error}`,
},
},
site.userId
);
}
async sendExpiresNotify(site: SiteInfoEntity) {
const expires = site.certExpiresTime;
const validDays = dayjs(expires).diff(dayjs(), 'day');
const url = await this.notificationService.getBindUrl('#/monitor/site');
const content = `站点名称: ${site.name} \n
${site.domain} \n
${site.certDomains} \n
${site.certProvider} \n
${dayjs(site.certExpiresTime).format('YYYY-MM-DD')} \n`;
if (validDays >= 0 && validDays < 10) {
// 发通知
await this.notificationService.send(
{
useDefault: true,
logger: logger,
body: {
title: `站点证书即将过期,剩余${validDays}天,<${site.name}>`,
content,
url,
},
},
site.userId
);
} else if (validDays < 0) {
//发过期通知
await this.notificationService.send(
{
useDefault: true,
logger: logger,
body: {
title: `站点证书已过期${-validDays}天<${site.name}>`,
content,
url,
},
},
site.userId
);
}
}
async checkAll(userId: any) {
if (!userId) {
throw new Error('userId is required');
}
const sites = await this.repository.find({
where: { userId },
});
for (const site of sites) {
await this.doCheck(site);
}
}
} }

View File

@ -0,0 +1,59 @@
import { logger } from '@certd/basic';
import { merge } from 'lodash-es';
import https from 'https';
import { PeerCertificate } from 'tls';
export type SiteTestReq = {
host: string; // 只用域名部分
port?: number;
method?: string;
};
export type SiteTestRes = {
certificate?: PeerCertificate;
};
export class SiteTester {
async test(req: SiteTestReq): Promise<SiteTestRes> {
logger.info('测试站点:', JSON.stringify(req));
const agent = new https.Agent({ keepAlive: false });
const options: any = merge(
{
port: 443,
method: 'GET',
rejectUnauthorized: false,
},
req
);
options.agent = agent;
// 创建 HTTPS 请求
const requestPromise = new Promise((resolve, reject) => {
const req = https.request(options, res => {
// 获取证书
// @ts-ignore
const certificate = res.socket.getPeerCertificate();
// logger.info('证书信息', certificate);
if (certificate.subject == null) {
logger.warn('证书信息为空');
resolve({
certificate: null,
});
}
resolve({
certificate,
});
res.socket.end();
// 关闭响应
res.destroy();
});
req.on('error', e => {
reject(e);
});
req.end();
});
return await requestPromise;
}
}
export const siteTester = new SiteTester();

View File

@ -1,5 +1,5 @@
import { Inject, Provide, Scope, ScopeEnum } from '@midwayjs/core'; import { Inject, Provide, Scope, ScopeEnum } from '@midwayjs/core';
import { BaseService, SysSettingsService, SysSiteInfo, ValidateException } from '@certd/lib-server'; import { BaseService, SysInstallInfo, SysSettingsService, SysSiteInfo, ValidateException } from '@certd/lib-server';
import { InjectEntityModel } from '@midwayjs/typeorm'; import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { NotificationEntity } from '../entity/notification.js'; import { NotificationEntity } from '../entity/notification.js';
@ -187,4 +187,10 @@ export class NotificationService extends BaseService<NotificationEntity> {
} }
} }
} }
async getBindUrl(path: string) {
const installInfo = await this.sysSettingsService.getSetting<SysInstallInfo>(SysInstallInfo);
const bindUrl = installInfo.bindUrl || 'http://127.0.0.1:7001';
return bindUrl + path;
}
} }