Files
certd/packages/ui/certd-server/src/modules/monitor/service/site-info-service.ts
xiaojunnuo b0b7ac3efb chore: 1
2025-12-29 10:31:11 +08:00

519 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {Inject, Provide, Scope, ScopeEnum} from "@midwayjs/core";
import {BaseService, NeedSuiteException, NeedVIPException, SysSettingsService} from "@certd/lib-server";
import {InjectEntityModel} from "@midwayjs/typeorm";
import {Repository} from "typeorm";
import {SiteInfoEntity} from "../entity/site-info.js";
import {siteTester} from "./site-tester.js";
import dayjs from "dayjs";
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";
import {UserSuiteService} from "@certd/commercial-core";
import {UserSettingsService} from "../../mine/service/user-settings-service.js";
import {UserSiteMonitorSetting} from "../../mine/service/models.js";
import {SiteIpService} from "./site-ip-service.js";
import {SiteIpEntity} from "../entity/site-ip.js";
import {Cron} from "../../cron/cron.js";
import { dnsContainer } from "./dns-custom.js";
@Provide()
@Scope(ScopeEnum.Request, {allowDowngrade: true})
export class SiteInfoService extends BaseService<SiteInfoEntity> {
@InjectEntityModel(SiteInfoEntity)
repository: Repository<SiteInfoEntity>;
@Inject()
notificationService: NotificationService;
@Inject()
sysSettingsService: SysSettingsService;
@Inject()
userSuiteService: UserSuiteService;
@Inject()
userSettingsService: UserSettingsService;
@Inject()
siteIpService: SiteIpService;
@Inject()
cron: Cron;
//@ts-ignore
getRepository() {
return this.repository;
}
async add(data: SiteInfoEntity) {
if (!data.userId) {
throw new Error("userId is required");
}
if (isComm()) {
const suiteSetting = await this.userSuiteService.getSuiteSetting();
if (suiteSetting.enabled) {
const userSuite = await this.userSuiteService.getMySuiteDetail(data.userId);
if (userSuite.monitorCount.max != -1 && userSuite.monitorCount.max <= userSuite.monitorCount.used) {
throw new NeedSuiteException("站点监控数量已达上限,请购买或升级套餐");
}
}
} else if (!isPlus()) {
const count = await this.getUserMonitorCount(data.userId);
if (count >= 1) {
throw new NeedVIPException("站点监控数量已达上限,请升级专业版");
}
}
data.disabled = false;
const found = await this.repository.findOne({
where: {
domain: data.domain,
userId: data.userId,
httpsPort: data.httpsPort || 443
}
});
if (found) {
return {id: found.id};
}
return await super.add(data);
}
async update(data: any) {
if (!data.id) {
throw new Error("id is required");
}
delete data.userId;
await super.update(data);
}
async getUserMonitorCount(userId: number) {
if (!userId) {
throw new Error("userId is required");
}
return await this.repository.count({
where: {userId}
});
}
/**
* 检查站点证书过期时间
* @param site
* @param notify
* @param retryTimes
*/
async doCheck(site: SiteInfoEntity, notify = true, retryTimes = null) {
if (!site?.domain) {
throw new Error("站点域名不能为空");
}
const setting = await this.userSettingsService.getSetting<UserSiteMonitorSetting>(site.userId, UserSiteMonitorSetting);
const dnsServer = setting.dnsServer
let customDns = null
if (dnsServer && dnsServer.length > 0) {
customDns = dnsContainer.getDns(dnsServer) as any
}
try {
await this.update({
id: site.id,
checkStatus: "checking",
lastCheckTime: dayjs().valueOf()
});
const res = await siteTester.test({
host: site.domain,
port: site.httpsPort,
retryTimes,
customDns
});
const certi: PeerCertificate = res.certificate;
if (!certi) {
throw new Error("没有发现证书");
}
const effective = certi.valid_from;
const expires = certi.valid_to;
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";
const updateData = {
id: site.id,
certDomains: domains.join(","),
certStatus: status,
certProvider: issuer,
certEffectiveTime: dayjs(effective).valueOf(),
certExpiresTime: dayjs(expires).valueOf(),
lastCheckTime: dayjs().valueOf(),
error: null,
checkStatus: "ok"
};
logger.info(`测试站点成功id=${updateData.id},site=${site.name},certEffectiveTime=${updateData.certEffectiveTime},expiresTime=${updateData.certExpiresTime}`)
if (site.ipCheck) {
delete updateData.checkStatus
}
await this.update(updateData);
//检查ip
await this.checkAllIp(site,retryTimes);
if (!notify) {
return;
}
try {
await this.sendExpiresNotify(site.id);
} 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.id);
} catch (e) {
logger.error("send notify error", e);
}
}
}
async checkAllIp(site: SiteInfoEntity,retryTimes = null) {
if (!site.ipCheck) {
return;
}
const certExpiresTime = site.certExpiresTime;
const onFinished = async (list: SiteIpEntity[]) => {
let errorCount = 0;
let errorMessage = "";
for (const item of list) {
if (!item) {
continue;
}
errorCount++;
if (item.error) {
errorMessage += `${item.ipAddress}${item.error} \n`;
} else if (item.certExpiresTime !== certExpiresTime) {
errorMessage += `${item.ipAddress}:与主站证书过期时间不一致(主站:${dayjs(certExpiresTime).format("YYYY-MM-DD")}IP${dayjs(item.certExpiresTime).format("YYYY-MM-DD")}) \n`;
} else {
errorCount--;
}
}
if (errorCount <= 0) {
//检查正常
await this.update({
id: site.id,
checkStatus: "ok",
error: "",
ipErrorCount: 0
});
return;
}
await this.update({
id: site.id,
checkStatus: "error",
error: errorMessage,
ipErrorCount: errorCount
});
try {
await this.sendCheckErrorNotify(site.id, true);
} catch (e) {
logger.error("send notify error", e);
}
};
await this.siteIpService.syncAndCheck(site, retryTimes,onFinished);
}
/**
* 检查
* @param id
* @param notify
* @param retryTimes
*/
async check(id: number, notify = false, retryTimes = null) {
const site = await this.info(id);
if (!site) {
throw new Error("站点不存在");
}
return await this.doCheck(site, notify, retryTimes);
}
async sendCheckErrorNotify(siteId: number, fromIpCheck = false) {
const site = await this.info(siteId);
const url = await this.notificationService.getBindUrl("#/certd/monitor/site");
const setting = await this.userSettingsService.getSetting<UserSiteMonitorSetting>(site.userId, UserSiteMonitorSetting)
// 发邮件
await this.notificationService.send(
{
id: setting?.notificationId,
useDefault: true,
logger: logger,
body: {
url,
title: `站点证书${fromIpCheck ? "(IP)" : ""}检查出错<${site.name}>`,
content: `站点名称: ${site.name} \n站点域名 ${site.domain} \n错误信息${site.error}`,
errorMessage: site.error,
notificationType: "siteCheckError",
}
},
site.userId
);
}
async sendExpiresNotify(siteId: number) {
const site = await this.info(siteId);
const setting = await this.userSettingsService.getSetting<UserSiteMonitorSetting>(site.userId, UserSiteMonitorSetting)
const tipDays = setting?.certValidDays || 10;
const expires = site.certExpiresTime;
const validDays = dayjs(expires).diff(dayjs(), "day");
const url = await this.notificationService.getBindUrl("#/certd/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 < tipDays) {
// 发通知
await this.notificationService.send(
{
id: setting?.notificationId,
useDefault: true,
logger: logger,
body: {
title: `站点证书即将过期,剩余${validDays}天,<${site.name}>`,
content,
url,
errorMessage: "站点证书即将过期",
notificationType: "siteCertExpireRemind",
}
},
site.userId
);
} else if (validDays < 0) {
//发过期通知
await this.notificationService.send(
{
id: setting?.notificationId,
useDefault: true,
logger: logger,
body: {
title: `站点证书已过期${-validDays}天<${site.name}>`,
content,
url,
errorMessage: "站点证书已过期",
notificationType: "siteCertExpireRemind",
}
},
site.userId
);
}
}
async checkAllByUsers(userId: any) {
if (!userId) {
throw new Error("userId is required");
}
const sites = await this.repository.find({
where: {userId}
});
this.checkList(sites,false);
}
async checkList(sites: SiteInfoEntity[],isCommon: boolean) {
const cache = {}
const getFromCache = async (userId: number) =>{
if (cache[userId]) {
return cache[userId];
}
const setting = await this.userSettingsService.getSetting<UserSiteMonitorSetting>(userId, UserSiteMonitorSetting)
cache[userId] = setting
return setting;
}
for (const site of sites) {
const setting = await getFromCache(site.userId)
if (isCommon) {
//公共的检查排除有设置cron的用户
if (setting?.cron) {
//设置了cron跳过公共检查
continue;
}
}
let retryTimes = setting?.retryTimes
this.doCheck(site,true,retryTimes).catch(e => {
logger.error(`检查站点证书失败,${site.domain}`, e.message);
});
await utils.sleep(100);
}
}
async getSetting(userId: number) {
return await this.userSettingsService.getSetting<UserSiteMonitorSetting>(userId, UserSiteMonitorSetting);
}
async saveSetting(userId: number, bean: UserSiteMonitorSetting) {
await this.userSettingsService.saveSetting(userId, bean);
if(bean.cron){
//注册job
await this.registerSiteMonitorJob(userId);
}else{
this.clearSiteMonitorJob(userId);
}
}
async ipCheckChange(req: { id: any; ipCheck: any }) {
await this.update({
id: req.id,
ipCheck: req.ipCheck
});
if (req.ipCheck) {
const site = await this.info(req.id);
await this.siteIpService.sync(site);
}
}
async disabledChange(req: { disabled: any; id: any }) {
await this.update({
id: req.id,
disabled: req.disabled
});
if (!req.disabled) {
const site = await this.info(req.id);
await this.doCheck(site);
}
}
async doImport(req: { text: string; userId: number,groupId?:number }) {
if (!req.text) {
throw new Error("text is required");
}
if (!req.userId) {
throw new Error("userId is required");
}
const rows = req.text.split("\n");
const list = [];
for (const item of rows) {
if (!item) {
continue;
}
const arr = item.trim().split(":");
if (arr.length === 0) {
continue;
}
const domain = arr[0];
let port = 443;
let name = domain;
if (arr.length > 1) {
try {
port = parseInt(arr[1] || "443");
} catch (e) {
throw new Error(`${item}格式错误`);
}
}
if (arr.length > 2) {
name = arr[2] || domain;
}
let remark:string = "";
if (arr.length > 3) {
remark = arr[3] || "";
}
list.push({
domain,
name,
httpsPort: port,
userId: req.userId,
remark,
groupId: req.groupId
});
}
const batchAdd = async (list: any[]) => {
for (const item of list) {
await this.add(item);
}
// await this.checkAllByUsers(req.userId);
};
await batchAdd(list);
}
clearSiteMonitorJob(userId: number) {
this.cron.remove(`siteMonitor-${userId}`);
}
async registerSiteMonitorJob(userId?: number) {
if(!userId){
//注册公共job
logger.info(`注册站点证书检查定时任务`)
this.cron.register({
name: 'siteMonitor',
cron: '0 0 0 * * *',
job:async ()=>{
await this.triggerJobOnce()
},
});
logger.info(`注册站点证书检查定时任务完成`)
}else{
const setting = await this.userSettingsService.getSetting<UserSiteMonitorSetting>(userId, UserSiteMonitorSetting);
if (!setting.cron) {
return;
}
//注册个人的
this.cron.register({
name: `siteMonitor-${userId}`,
cron: setting.cron,
job: () => this.triggerJobOnce(userId),
});
}
}
async triggerJobOnce(userId?:number) {
logger.info(`站点证书检查开始执行[${userId??'所有用户'}]`);
const query:any = { disabled: false };
if(userId){
query.userId = userId;
//判断是否已关闭
const setting = await this.userSettingsService.getSetting<UserSiteMonitorSetting>(userId, UserSiteMonitorSetting);
if (!setting.cron) {
return;
}
}
let offset = 0;
const limit = 50;
while (true) {
const res = await this.page({
query: query,
page: { offset, limit },
});
const { records } = res;
if (records.length === 0) {
break;
}
offset += records.length;
const isCommon = !userId;
await this.checkList(records,isCommon);
}
logger.info(`站点证书检查完成[${userId??'所有用户'}]`);
}
}