Merge branch 'v2-dev' into v2-dev-buy

This commit is contained in:
xiaojunnuo
2025-11-04 23:04:11 +08:00
225 changed files with 5865 additions and 1654 deletions
@@ -27,7 +27,7 @@ const development = {
},
keys: 'certd',
koa: {
hostname:"::",
hostname: "::",
port: 7001,
},
https: {
@@ -20,9 +20,13 @@ import * as commercial from '@certd/commercial-core';
import * as upload from '@midwayjs/upload';
import { setLogger } from '@certd/acme-client';
import {HiddenMiddleware} from "./middleware/hidden.js";
process.on('uncaughtException', error => {
console.error('未捕获的异常:', error);
// 在这里可以添加日志记录、发送错误通知等操作
if(error?.message?.includes('address family not supported')){
logger.error("您的服务器不支持监听IPV6格式的地址(::),请配置环境变量: certd_koa_hostname=0.0.0.0");
}
});
@Configuration({
@@ -107,5 +111,6 @@ export class MainConfiguration {
});
logger.info('当前环境:', this.app.getEnv()); // prod
// throw new Error("address family not supported")
}
}
@@ -1,7 +1,6 @@
import { ALL, Body, Controller, Inject, Post, Provide, Query } from '@midwayjs/core';
import { AccessService, Constants } from '@certd/lib-server';
import { AccessController } from '../../user/pipeline/access-controller.js';
import { checkComm } from '@certd/plus-core';
import { ALL, Body, Controller, Inject, Post, Provide, Query } from "@midwayjs/core";
import { AccessService, Constants } from "@certd/lib-server";
import { AccessController } from "../../user/pipeline/access-controller.js";
/**
* 授权
@@ -17,7 +16,7 @@ export class SysAccessController extends AccessController {
}
getUserId() {
checkComm();
// checkComm();
return 0;
}
@@ -0,0 +1,47 @@
import { BaseController } from '@certd/lib-server';
import { ALL, Body, Controller, Inject, Post, Provide } from '@midwayjs/core';
import { NetTestService } from '../../../modules/sys/nettest/nettest-service.js';
@Provide()
@Controller('/api/sys/nettest/')
export class SysNetTestController extends BaseController {
@Inject()
netTestService: NetTestService;
@Post('/domainResolve', { summary: 'sys:settings:view' })
public async domainResolve(@Body(ALL) body: { domain: string }) {
const { domain } = body;
const result = await this.netTestService.domainResolve(domain);
return this.ok(result);
}
// ping
@Post('/ping', { summary: 'sys:settings:view' })
public async ping(@Body(ALL) body: { domain: string }) {
const { domain } = body;
const result = await this.netTestService.ping(domain);
return this.ok(result);
}
// telnet
@Post('/telnet', { summary: 'sys:settings:view' })
public async telnet(@Body(ALL) body: { domain: string, port: number }) {
const { domain, port } = body;
const result = await this.netTestService.telnet(domain, port);
return this.ok(result);
}
// telnet
@Post('/serverInfo', { summary: 'sys:settings:view' })
public async serverInfo() {
const result = await this.netTestService.serverInfo();
return this.ok(result);
}
}
@@ -192,4 +192,11 @@ export class SysSettingsController extends CrudController<SysSettingsService> {
await this.service.saveSetting(blankSetting);
return this.ok({});
}
@Post("/captchaTest", { summary: "sys:settings:edit" })
async captchaTest(@Body(ALL) body: any) {
await this.codeService.checkCaptcha(body)
return this.ok({});
}
}
@@ -11,56 +11,59 @@ import {
import { AuthService } from "../../../modules/sys/authority/service/auth-service.js";
import { checkPlus } from "@certd/plus-core";
import { http, logger, utils } from "@certd/basic";
import { TaskServiceBuilder } from "../../../modules/pipeline/service/getter/task-service-getter.js";
/**
* Addon
*/
@Provide()
@Controller('/api/addon')
@Controller("/api/addon")
export class AddonController extends CrudController<AddonService> {
@Inject()
service: AddonService;
@Inject()
authService: AuthService;
@Inject()
taskServiceBuilder:TaskServiceBuilder
getService(): AddonService {
return this.service;
}
@Post('/page', { summary: Constants.per.authOnly })
@Post("/page", { summary: Constants.per.authOnly })
async page(@Body(ALL) body) {
body.query = body.query ?? {};
delete body.query.userId;
const buildQuery = qb => {
qb.andWhere('user_id = :userId', { userId: this.getUserId() });
qb.andWhere("user_id = :userId", { userId: this.getUserId() });
};
const res = await this.service.page({
query: body.query,
page: body.page,
sort: body.sort,
buildQuery,
buildQuery
});
return this.ok(res);
}
@Post('/list', { summary: Constants.per.authOnly })
@Post("/list", { summary: Constants.per.authOnly })
async list(@Body(ALL) body) {
body.query = body.query ?? {};
body.query.userId = this.getUserId();
return super.list(body);
}
@Post('/add', { summary: Constants.per.authOnly })
@Post("/add", { summary: Constants.per.authOnly })
async add(@Body(ALL) bean) {
bean.userId = this.getUserId();
const type = bean.type;
const addonType = bean.addonType;
if (! type || !addonType){
throw new ValidateException('请选择Addon类型');
if (!type || !addonType) {
throw new ValidateException("请选择Addon类型");
}
const define: AddonDefine = this.service.getDefineByType(type,addonType);
const define: AddonDefine = this.service.getDefineByType(type, addonType);
if (!define) {
throw new ValidateException('Addon类型不存在');
throw new ValidateException("Addon类型不存在");
}
if (define.needPlus) {
checkPlus();
@@ -68,19 +71,19 @@ export class AddonController extends CrudController<AddonService> {
return super.add(bean);
}
@Post('/update', { summary: Constants.per.authOnly })
@Post("/update", { summary: Constants.per.authOnly })
async update(@Body(ALL) bean) {
await this.service.checkUserId(bean.id, this.getUserId());
const old = await this.service.info(bean.id);
if (!old) {
throw new ValidateException('Addon配置不存在');
throw new ValidateException("Addon配置不存在");
}
if (old.type !== bean.type ) {
if (old.type !== bean.type) {
const addonType = old.type;
const type = bean.type;
const define: AddonDefine = this.service.getDefineByType(type,addonType);
const define: AddonDefine = this.service.getDefineByType(type, addonType);
if (!define) {
throw new ValidateException('Addon类型不存在');
throw new ValidateException("Addon类型不存在");
}
if (define.needPlus) {
checkPlus();
@@ -89,26 +92,27 @@ export class AddonController extends CrudController<AddonService> {
delete bean.userId;
return super.update(bean);
}
@Post('/info', { summary: Constants.per.authOnly })
async info(@Query('id') id: number) {
@Post("/info", { summary: Constants.per.authOnly })
async info(@Query("id") id: number) {
await this.service.checkUserId(id, this.getUserId());
return super.info(id);
}
@Post('/delete', { summary: Constants.per.authOnly })
async delete(@Query('id') id: number) {
@Post("/delete", { summary: Constants.per.authOnly })
async delete(@Query("id") id: number) {
await this.service.checkUserId(id, this.getUserId());
return super.delete(id);
}
@Post('/define', { summary: Constants.per.authOnly })
async define(@Query('type') type: string,@Query('addonType') addonType: string) {
const notification = this.service.getDefineByType(type,addonType);
@Post("/define", { summary: Constants.per.authOnly })
async define(@Query("type") type: string, @Query("addonType") addonType: string) {
const notification = this.service.getDefineByType(type, addonType);
return this.ok(notification);
}
@Post('/getTypeDict', { summary: Constants.per.authOnly })
async getTypeDict(@Query('addonType') addonType: string) {
@Post("/getTypeDict", { summary: Constants.per.authOnly })
async getTypeDict(@Query("addonType") addonType: string) {
const list: any = this.service.getDefineList(addonType);
let dict = [];
for (const item of list) {
@@ -116,7 +120,7 @@ export class AddonController extends CrudController<AddonService> {
value: item.name,
label: item.title,
needPlus: item.needPlus ?? false,
icon: item.icon,
icon: item.icon
});
}
dict = dict.sort(a => {
@@ -125,13 +129,13 @@ export class AddonController extends CrudController<AddonService> {
return this.ok(dict);
}
@Post('/simpleInfo', { summary: Constants.per.authOnly })
async simpleInfo(@Query('addonType') addonType: string,@Query('id') id: number) {
@Post("/simpleInfo", { summary: Constants.per.authOnly })
async simpleInfo(@Query("addonType") addonType: string, @Query("id") id: number) {
if (id === 0) {
//获取默认
const res = await this.service.getDefault(this.getUserId(),addonType);
const res = await this.service.getDefault(this.getUserId(), addonType);
if (!res) {
throw new ValidateException('默认Addon配置不存在');
throw new ValidateException("默认Addon配置不存在");
}
const simple = await this.service.getSimpleInfo(res.id);
return this.ok(simple);
@@ -141,27 +145,27 @@ export class AddonController extends CrudController<AddonService> {
return this.ok(res);
}
@Post('/getDefaultId', { summary: Constants.per.authOnly })
async getDefaultId(@Query('addonType') addonType: string) {
const res = await this.service.getDefault(this.getUserId(),addonType);
@Post("/getDefaultId", { summary: Constants.per.authOnly })
async getDefaultId(@Query("addonType") addonType: string) {
const res = await this.service.getDefault(this.getUserId(), addonType);
return this.ok(res?.id);
}
@Post('/setDefault', { summary: Constants.per.authOnly })
async setDefault(@Query('addonType') addonType: string,@Query('id') id: number) {
@Post("/setDefault", { summary: Constants.per.authOnly })
async setDefault(@Query("addonType") addonType: string, @Query("id") id: number) {
await this.service.checkUserId(id, this.getUserId());
const res = await this.service.setDefault(id, this.getUserId(),addonType);
const res = await this.service.setDefault(id, this.getUserId(), addonType);
return this.ok(res);
}
@Post('/options', { summary: Constants.per.authOnly })
async options(@Query('addonType') addonType: string) {
@Post("/options", { summary: Constants.per.authOnly })
async options(@Query("addonType") addonType: string) {
const res = await this.service.list({
query: {
userId: this.getUserId(),
addonType
},
}
});
for (const item of res) {
delete item.setting;
@@ -170,7 +174,7 @@ export class AddonController extends CrudController<AddonService> {
}
@Post('/handle', { summary: Constants.per.authOnly })
@Post("/handle", { summary: Constants.per.authOnly })
async handle(@Body(ALL) body: AddonRequestHandleReq) {
const userId = this.getUserId();
let inputAddon = body.input.addon;
@@ -178,21 +182,24 @@ export class AddonController extends CrudController<AddonService> {
const oldEntity = await this.service.info(body.input.id);
if (oldEntity) {
if (oldEntity.userId !== userId) {
throw new Error('addon not found');
throw new Error("addon not found");
}
// const param: any = {
// type: body.typeName,
// setting: JSON.stringify(body.input.access),
// };
inputAddon = JSON.parse( oldEntity.setting)
inputAddon = JSON.parse(oldEntity.setting);
}
}
const serviceGetter = this.taskServiceBuilder.create({ userId });
const ctx = {
http: http,
logger:logger,
utils:utils,
}
const addon = await newAddon(body.addonType,body.typeName, inputAddon,ctx);
logger: logger,
utils: utils,
serviceGetter
};
const addon = await newAddon(body.addonType, body.typeName, inputAddon, ctx);
const res = await addon.onRequest(body);
return this.ok(res);
}
@@ -0,0 +1,78 @@
import { ALL, Body, Controller, Inject, Post, Provide, Query } from '@midwayjs/core';
import { Constants, CrudController } from '@certd/lib-server';
import { AuthService } from '../../../modules/sys/authority/service/auth-service.js';
import { GroupService } from '../../../modules/basic/service/group-service.js';
/**
* 通知
*/
@Provide()
@Controller('/api/basic/group')
export class GroupController extends CrudController<GroupService> {
@Inject()
service: GroupService;
@Inject()
authService: AuthService;
getService(): GroupService {
return this.service;
}
@Post('/page', { summary: Constants.per.authOnly })
async page(@Body(ALL) body: any) {
body.query = body.query ?? {};
delete body.query.userId;
const buildQuery = qb => {
qb.andWhere('user_id = :userId', { userId: this.getUserId() });
};
const res = await this.service.page({
query: body.query,
page: body.page,
sort: body.sort,
buildQuery,
});
return this.ok(res);
}
@Post('/list', { summary: Constants.per.authOnly })
async list(@Body(ALL) body: any) {
body.query = body.query ?? {};
body.query.userId = this.getUserId();
return await super.list(body);
}
@Post('/add', { summary: Constants.per.authOnly })
async add(@Body(ALL) bean: any) {
bean.userId = this.getUserId();
return await super.add(bean);
}
@Post('/update', { summary: Constants.per.authOnly })
async update(@Body(ALL) bean) {
await this.service.checkUserId(bean.id, this.getUserId());
delete bean.userId;
return await super.update(bean);
}
@Post('/info', { summary: Constants.per.authOnly })
async info(@Query('id') id: number) {
await this.service.checkUserId(id, this.getUserId());
return await super.info(id);
}
@Post('/delete', { summary: Constants.per.authOnly })
async delete(@Query('id') id: number) {
await this.service.checkUserId(id, this.getUserId());
return await super.delete(id);
}
@Post('/all', { summary: Constants.per.authOnly })
async all(@Query('type') type: string) {
const list: any = await this.service.find({
where: {
userId: this.getUserId(),
type,
},
});
return this.ok(list);
}
}
@@ -85,10 +85,18 @@ export class CnameRecordController extends CrudController<CnameRecordService> {
}
@Post('/verify', { summary: Constants.per.authOnly })
async verify(@Body(ALL) body: { id: string }) {
async verify(@Body(ALL) body: { id: number }) {
const userId = this.getUserId();
await this.service.checkUserId(body.id, userId);
const res = await this.service.verify(body.id);
return this.ok(res);
}
@Post('/resetStatus', { summary: Constants.per.authOnly })
async resetStatus(@Body(ALL) body: { id: number }) {
const userId = this.getUserId();
await this.service.checkUserId(body.id, userId);
const res = await this.service.resetStatus(body.id);
return this.ok(res);
}
}
@@ -92,6 +92,14 @@ export class SiteInfoController extends CrudController<SiteInfoService> {
return await super.delete(id);
}
@Post('/batchDelete', { summary: Constants.per.authOnly })
async batchDelete(@Body(ALL) body: any) {
const userId = this.getUserId();
await this.service.batchDelete(body.ids,userId);
return this.ok();
}
@Post('/check', { summary: Constants.per.authOnly })
async check(@Body('id') id: number) {
await this.service.checkUserId(id, this.getUserId());
@@ -111,6 +119,7 @@ export class SiteInfoController extends CrudController<SiteInfoService> {
const userId = this.getUserId();
await this.service.doImport({
text:body.text,
groupId:body.groupId,
userId
})
return this.ok();
@@ -107,6 +107,9 @@ export class NotificationController extends CrudController<NotificationService>
icon: item.icon,
});
}
dict = dict.sort(a => {
return a.order ? 0 : -1;
});
dict = dict.sort(a => {
return a.needPlus ? 0 : -1;
});
@@ -4,6 +4,8 @@ import { PipelineService } from '../../../modules/pipeline/service/pipeline-serv
import { PipelineEntity } from '../../../modules/pipeline/entity/pipeline.js';
import { HistoryService } from '../../../modules/pipeline/service/history-service.js';
import { AuthService } from '../../../modules/sys/authority/service/auth-service.js';
import { SiteInfoService } from '../../../modules/monitor/index.js';
import { isPlus } from '@certd/plus-core';
/**
* 证书
@@ -20,6 +22,9 @@ export class PipelineController extends CrudController<PipelineService> {
@Inject()
sysSettingsService: SysSettingsService;
@Inject()
siteInfoService: SiteInfoService;
getService() {
return this.service;
}
@@ -74,13 +79,30 @@ export class PipelineController extends CrudController<PipelineService> {
}
@Post('/save', { summary: Constants.per.authOnly })
async save(@Body(ALL) bean: PipelineEntity) {
async save(@Body(ALL) bean: {addToMonitorEnabled: boolean, addToMonitorDomains: string} & PipelineEntity) {
if (bean.id > 0) {
await this.authService.checkEntityUserId(this.ctx, this.getService(), bean.id);
} else {
bean.userId = this.getUserId();
}
if(!this.isAdmin()){
// 非管理员用户 不允许设置流水线有效期
delete bean.validTime
}
await this.service.save(bean);
//是否增加证书监控
if (bean.addToMonitorEnabled && bean.addToMonitorDomains) {
const sysPublicSettings = await this.sysSettingsService.getPublicSettings();
if (isPlus() && sysPublicSettings.certDomainAddToMonitorEnabled) {
//增加证书监控
await this.siteInfoService.doImport({
text: bean.addToMonitorDomains,
userId: this.getUserId(),
});
}
}
return this.ok(bean.id);
}
@@ -101,7 +123,7 @@ export class PipelineController extends CrudController<PipelineService> {
@Post('/trigger', { summary: Constants.per.authOnly })
async trigger(@Query('id') id: number, @Query('stepId') stepId?: string) {
await this.authService.checkEntityUserId(this.ctx, this.getService(), id);
await this.service.trigger(id, stepId);
await this.service.trigger(id, stepId,true);
return this.ok({});
}
@@ -1,7 +1,7 @@
import {ALL, Body, Controller, Inject, Post, Provide, Query} from '@midwayjs/core';
import {Constants, CrudController} from '@certd/lib-server';
import {SubDomainService} from "../../../modules/pipeline/service/sub-domain-service.js";
import {DomainParser} from '@certd/plugin-cert/dist/dns-provider/domain-parser.js';
import {DomainParser} from '@certd/plugin-cert';
import { SubDomainsGetter } from '../../../modules/pipeline/service/getter/sub-domain-getter.js';
/**
@@ -1,6 +1,6 @@
import { Autoload, Config, Init, Inject, Provide, Scope, ScopeEnum } from '@midwayjs/core';
import { IMidwayKoaContext, IWebMiddleware, NextFunction } from '@midwayjs/koa';
import { CommonException } from '@certd/lib-server';
import { CommonException, SysSettingsService } from "@certd/lib-server";
import { UserService } from '../../modules/sys/authority/service/user-service.js';
import { logger } from '@certd/basic';
import {UserSettingsService} from "../../modules/mine/service/user-settings-service.js";
@@ -17,6 +17,8 @@ export class ResetPasswdMiddleware implements IWebMiddleware {
@Inject()
userSettingsService: UserSettingsService;
@Inject()
sysSettingsService: SysSettingsService;
@Config('system.resetAdminPasswd')
private resetAdminPasswd: boolean;
@@ -40,8 +42,12 @@ export class ResetPasswdMiddleware implements IWebMiddleware {
userId: 1,
key:"user.two.factor"
})
const publicSettings = await this.sysSettingsService.getPublicSettings()
publicSettings.captchaEnabled = false
await this.sysSettingsService.savePublicSettings(publicSettings);
const user = await this.userService.info(1);
logger.info(`重置1号管理员用户的密码完成,2FA设置已删除,用户名:${user.username},新密码:${newPasswd},请在登录进去之后尽快修改密码`);
logger.info(`重置1号管理员用户的密码完成,2FA设置已删除,验证码登录已禁用,用户名:${user.username},新密码:${newPasswd},请在登录进去之后尽快修改密码`);
}
}
}
@@ -2,7 +2,7 @@ import { Autoload, Init, Inject, Scope, ScopeEnum } from "@midwayjs/core";
import { CertInfoService } from "../monitor/index.js";
import { pipelineEmitter } from "@certd/pipeline";
import { CertInfo, EVENT_CERT_APPLY_SUCCESS } from "@certd/plugin-cert";
import { PipelineEvent } from "@certd/pipeline/dist/service/emit.js";
import { PipelineEvent } from "@certd/pipeline";
@Autoload()
@Scope(ScopeEnum.Request, { allowDowngrade: true })
@@ -19,6 +19,8 @@ export class AutoZPrint {
@Config('https')
httpsConfig: HttpsServerOptions;
@Config('koa')
koaConfig: any;
@Init()
async init() {
@@ -58,6 +60,7 @@ export class AutoZPrint {
httpsServer.start({
...this.httpsConfig,
app: this.app,
hostname: this.httpsConfig.hostname || this.koaConfig.hostname,
});
}
}
@@ -7,6 +7,7 @@ import {logger, safePromise} from '@certd/basic';
export type HttpsServerOptions = {
enabled: boolean;
app?: Application;
hostname?: string;
port: number;
key: string;
cert: string;
@@ -58,7 +59,7 @@ export class HttpsServer {
opts.app.callback()
);
this.server = httpServer;
const hostname = '::';
let hostname = opts.hostname || '::';
// A function that runs in the context of the http server
// and reports what type of server listens on which port
function listeningReporter() {
@@ -70,7 +71,19 @@ export class HttpsServer {
httpServer.listen(opts.port, hostname, listeningReporter);
return httpServer;
} catch (e) {
logger.error('启动https服务失败', e);
if ( e.message?.includes("address family not supported")) {
hostname = "0.0.0.0"
logger.error(`${e.message},尝试监听${hostname}`, e);
try{
httpServer.listen(opts.port, hostname, listeningReporter);
return httpServer;
}catch (e) {
logger.error('启动https服务失败', e);
}
}else{
logger.error('启动https服务失败', e);
}
}
}
}
@@ -0,0 +1,38 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
export const GROUP_TYPE_SITE = 'site';
@Entity('cd_group')
export class GroupEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'user_id', comment: '用户id' })
userId: number;
@Column({ name: 'name', comment: '分组名称' })
name: string;
@Column({ name: 'icon', comment: '图标' })
icon: string;
@Column({ name: 'favorite', comment: '收藏' })
favorite: boolean;
@Column({ name: 'type', comment: '类型', length: 512 })
type: string;
@Column({
name: 'create_time',
comment: '创建时间',
default: () => 'CURRENT_TIMESTAMP',
})
createTime: Date;
@Column({
name: 'update_time',
comment: '修改时间',
default: () => 'CURRENT_TIMESTAMP',
})
updateTime: Date;
}
@@ -1,7 +1,8 @@
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
import { AddonService, SysSettingsService } from "@certd/lib-server";
import { SysSettingsService } from "@certd/lib-server";
import { logger } from "@certd/basic";
import { ICaptchaAddon } from "../../../plugins/plugin-captcha/api.js";
import { AddonGetterService } from "../../pipeline/service/addon-getter-service.js";
@Provide()
@Scope(ScopeEnum.Request, { allowDowngrade: true })
@@ -9,45 +10,48 @@ export class CaptchaService {
@Inject()
sysSettingsService: SysSettingsService;
@Inject()
addonService: AddonService;
addonGetterService: AddonGetterService;
async getCaptcha(captchaAddonId?:number){
async getCaptcha(captchaAddonId?: number) {
if (!captchaAddonId) {
const settings = await this.sysSettingsService.getPublicSettings()
captchaAddonId = settings.captchaAddonId ?? 0
const settings = await this.sysSettingsService.getPublicSettings();
captchaAddonId = settings.captchaAddonId ?? 0;
}
const addon:ICaptchaAddon = await this.addonService.getAddonById(captchaAddonId,true,0)
const addon: ICaptchaAddon = await this.addonGetterService.getAddonById(captchaAddonId, true, 0, {
type: "captcha",
name: "image"
});
if (!addon) {
throw new Error('验证码插件还未配置')
throw new Error("验证码插件还未配置");
}
return await addon.getCaptcha()
return await addon.getCaptcha();
}
async doValidate(opts:{form:any,must?:boolean,captchaAddonId?:number}){
async doValidate(opts: { form: any, must?: boolean, captchaAddonId?: number }) {
if (!opts.captchaAddonId) {
const settings = await this.sysSettingsService.getPublicSettings()
opts.captchaAddonId = settings.captchaAddonId ?? 0
const settings = await this.sysSettingsService.getPublicSettings();
opts.captchaAddonId = settings.captchaAddonId ?? 0;
}
const addon = await this.addonService.getById(opts.captchaAddonId,0)
const addon = await this.addonGetterService.getById(opts.captchaAddonId, 0);
if (!addon) {
if (opts.must) {
throw new Error('请先配置验证码插件');
throw new Error("请先配置验证码插件");
}
logger.warn('验证码插件还未配置,忽略验证码校验')
return true
logger.warn("验证码插件还未配置,忽略验证码校验");
return true;
}
if (!opts.form) {
throw new Error('请输入验证码');
throw new Error("请输入验证码");
}
const res = await addon.onValidate(opts.form)
const res = await addon.onValidate(opts.form);
if (!res) {
throw new Error('验证码错误');
throw new Error("验证码错误");
}
return true
return true;
}
@@ -3,7 +3,7 @@ import { cache, isDev, randomNumber } from '@certd/basic';
import { SysSettingsService, SysSiteInfo } from '@certd/lib-server';
import { SmsServiceFactory } from '../sms/factory.js';
import { ISmsService } from '../sms/api.js';
import { CodeErrorException } from '@certd/lib-server/dist/basic/exception/code-error-exception.js';
import { CodeErrorException } from '@certd/lib-server';
import { EmailService } from './email-service.js';
import { AccessService } from '@certd/lib-server';
import { AccessSysGetter } from '@certd/lib-server';
@@ -0,0 +1,31 @@
import { Provide, Scope, ScopeEnum } from '@midwayjs/core';
import { BaseService } from '@certd/lib-server';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { merge } from 'lodash-es';
import { GroupEntity } from '../entity/group.js';
@Provide()
@Scope(ScopeEnum.Request, { allowDowngrade: true })
export class GroupService extends BaseService<GroupEntity> {
@InjectEntityModel(GroupEntity)
repository: Repository<GroupEntity>;
//@ts-ignore
getRepository() {
return this.repository;
}
async add(bean: any) {
if (!bean.type) {
throw new Error('type is required');
}
bean = merge(
{
favorite: false,
},
bean
);
return await this.repository.save(bean);
}
}
@@ -4,7 +4,7 @@ import {In, Not, Repository} from 'typeorm';
import {AccessService, BaseService} from '@certd/lib-server';
import {DomainEntity} from '../entity/domain.js';
import {SubDomainService} from "../../pipeline/service/sub-domain-service.js";
import {DomainParser} from "@certd/plugin-cert/dist/dns-provider/domain-parser.js";
import {DomainParser} from "@certd/plugin-cert";
import {DomainVerifiers} from "@certd/plugin-cert";
import { SubDomainsGetter } from '../../pipeline/service/getter/sub-domain-getter.js';
import { CnameRecordService } from '../../cname/service/cname-record-service.js';
@@ -13,6 +13,8 @@ export class CnameRecordEntity {
@Column({ comment: '证书申请域名', length: 100 })
domain: string;
@Column({ comment: '主域名', name: 'main_domain', length: 100 })
mainDomain:string;
@Column({ comment: '主机记录', name: 'host_record', length: 100 })
hostRecord: string;
@@ -17,7 +17,7 @@ import { getAuthoritativeDnsResolver, walkTxtRecord } from "@certd/acme-client";
import { CnameProviderService } from "./cname-provider-service.js";
import { CnameProviderEntity } from "../entity/cname-provider.js";
import { CommonDnsProvider } from "./common-provider.js";
import { DomainParser } from "@certd/plugin-cert/dist/dns-provider/domain-parser.js";
import { DomainParser } from "@certd/plugin-cert";
import punycode from "punycode.js";
import { SubDomainService } from "../../pipeline/service/sub-domain-service.js";
import { SubDomainsGetter } from "../../pipeline/service/getter/sub-domain-getter.js";
@@ -37,7 +37,7 @@ type CnameCheckCacheValue = {
* 授权
*/
@Provide()
@Scope(ScopeEnum.Request, {allowDowngrade: true})
@Scope(ScopeEnum.Request, { allowDowngrade: true })
export class CnameRecordService extends BaseService<CnameRecordEntity> {
@InjectEntityModel(CnameRecordEntity)
repository: Repository<CnameRecordEntity>;
@@ -71,16 +71,16 @@ export class CnameRecordService extends BaseService<CnameRecordEntity> {
*/
async add(param: any): Promise<CnameRecordEntity> {
if (!param.domain) {
throw new ValidateException('域名不能为空');
throw new ValidateException("域名不能为空");
}
if (!param.userId) {
throw new ValidateException('userId不能为空');
throw new ValidateException("userId不能为空");
}
if (param.domain.startsWith('*.')) {
if (param.domain.startsWith("*.")) {
param.domain = param.domain.substring(2);
}
param.domain = param.domain.trim()
const info = await this.getRepository().findOne({where: {domain: param.domain, userId: param.userId}});
param.domain = param.domain.trim();
const info = await this.getRepository().findOne({ where: { domain: param.domain, userId: param.userId } });
if (info) {
return info;
}
@@ -90,63 +90,64 @@ export class CnameRecordService extends BaseService<CnameRecordEntity> {
//获取默认的cnameProviderId
cnameProvider = await this.cnameProviderService.getByPriority();
if (cnameProvider == null) {
throw new ValidateException('找不到CNAME服务,请先前往“系统管理->CNAME服务设置”添加CNAME服务');
throw new ValidateException("找不到CNAME服务,请先前往“系统管理->CNAME服务设置”添加CNAME服务");
}
} else {
cnameProvider = await this.cnameProviderService.info(param.cnameProviderId);
}
await this.cnameProviderChanged(param.userId, param, cnameProvider);
param.status = 'cname';
const {id} = await super.add(param);
param.status = "cname";
const { id } = await super.add(param);
return await this.info(id);
}
private async cnameProviderChanged(userId: number, param: any, cnameProvider: CnameProviderEntity) {
param.cnameProviderId = cnameProvider.id;
const subDomainGetter = new SubDomainsGetter(userId, this.subDomainService)
const subDomainGetter = new SubDomainsGetter(userId, this.subDomainService);
const domainParser = new DomainParser(subDomainGetter);
const realDomain = await domainParser.parse(param.domain);
const prefix = param.domain.replace(realDomain, '');
const prefix = param.domain.replace(realDomain, "");
let hostRecord = `_acme-challenge.${prefix}`;
if (hostRecord.endsWith('.')) {
if (hostRecord.endsWith(".")) {
hostRecord = hostRecord.substring(0, hostRecord.length - 1);
}
param.hostRecord = hostRecord;
param.mainDomain = realDomain;
const randomKey = utils.id.simpleNanoId(6).toLowerCase();
const userIdHex = utils.hash.toHex(userId)
let userKeyHash = ""
const installInfo = await this.sysSettingsService.getSetting<SysInstallInfo>(SysInstallInfo)
userKeyHash = `${installInfo.siteId}_${userIdHex}_${randomKey}`
userKeyHash = utils.hash.md5(userKeyHash).substring(0, 10)
logger.info(`userKeyHash:${userKeyHash},subjectId:${installInfo.siteId},randomKey:${randomKey},userIdHex:${userIdHex}`)
const userIdHex = utils.hash.toHex(userId);
let userKeyHash = "";
const installInfo = await this.sysSettingsService.getSetting<SysInstallInfo>(SysInstallInfo);
userKeyHash = `${installInfo.siteId}_${userIdHex}_${randomKey}`;
userKeyHash = utils.hash.md5(userKeyHash).substring(0, 10);
logger.info(`userKeyHash:${userKeyHash},subjectId:${installInfo.siteId},randomKey:${randomKey},userIdHex:${userIdHex}`);
const cnameKey = `${userKeyHash}-${userIdHex}-${randomKey}`;
const safeDomain = param.domain.replaceAll('.', '-');
const safeDomain = param.domain.replaceAll(".", "-");
param.recordValue = `${safeDomain}.${cnameKey}.${cnameProvider.domain}`;
}
async update(param: any) {
if (!param.id) {
throw new ValidateException('id不能为空');
throw new ValidateException("id不能为空");
}
//hostRecord包含所有权校验信息,不允许用户修改hostRecord
delete param.hostRecord
const old = await this.info(param.id);
if (!old) {
throw new ValidateException('数据不存在');
throw new ValidateException("数据不存在");
}
if (old.domain !== param.domain) {
throw new ValidateException('域名不允许修改');
if (param.domain && old.domain !== param.domain) {
throw new ValidateException("域名不允许修改");
}
if (old.cnameProviderId !== param.cnameProviderId) {
if (param.cnameProviderId && old.cnameProviderId !== param.cnameProviderId) {
const cnameProvider = await this.cnameProviderService.info(param.cnameProviderId);
await this.cnameProviderChanged(old.userId, param, cnameProvider);
param.status = 'cname';
param.status = "cname";
}
return await super.update(param);
}
@@ -171,7 +172,7 @@ export class CnameRecordService extends BaseService<CnameRecordEntity> {
} else {
record.commonDnsProvider = new CommonDnsProvider({
config: record.cnameProvider,
plusService: this.plusService,
plusService: this.plusService
});
}
@@ -180,19 +181,22 @@ export class CnameRecordService extends BaseService<CnameRecordEntity> {
async getByDomain(domain: string, userId: number, createOnNotFound = true) {
if (!domain) {
throw new ValidateException('domain不能为空');
throw new ValidateException("domain不能为空");
}
if (userId == null) {
throw new ValidateException('userId不能为空');
throw new ValidateException("userId不能为空");
}
let record = await this.getRepository().findOne({where: {domain, userId}});
let record = await this.getRepository().findOne({ where: { domain, userId } });
if (record == null) {
if (createOnNotFound) {
record = await this.add({domain, userId});
record = await this.add({ domain, userId });
} else {
throw new ValidateException(`找不到${domain}的CNAME记录`);
}
}
await this.fillMainDomain(record);
const provider = await this.cnameProviderService.info(record.cnameProviderId);
if (provider == null) {
throw new ValidateException(`找不到${domain}的CNAME服务`);
@@ -201,25 +205,53 @@ export class CnameRecordService extends BaseService<CnameRecordEntity> {
return {
...record,
cnameProvider: {
...provider,
} as CnameProvider,
...provider
} as CnameProvider
} as CnameRecord;
}
async fillMainDomain(record: CnameRecordEntity, update = true) {
const notMainDomain = !record.mainDomain;
const hasErrorMainDomain = record.mainDomain && !record.mainDomain.includes(".");
if (notMainDomain || hasErrorMainDomain) {
let domainPrefix = record.hostRecord.replace("_acme-challenge", "");
if (domainPrefix.startsWith(".")) {
domainPrefix = domainPrefix.substring(1);
}
if (domainPrefix) {
const prefixStr = domainPrefix + ".";
record.mainDomain = record.domain.substring(prefixStr.length);
}else{
record.mainDomain = record.domain;
}
if (update) {
await this.update({
id: record.id,
mainDomain: record.mainDomain
});
}
}
}
/**
* 验证是否配置好cname
* @param id
*/
async verify(id: string) {
async verify(id: number) {
const bean = await this.info(id);
if (!bean) {
throw new ValidateException(`CnameRecord:${id} 不存在`);
}
if (bean.status === 'valid') {
if (bean.status === "valid") {
return true;
}
const subDomainGetter = new SubDomainsGetter(bean.userId, this.subDomainService)
await this.getByDomain(bean.domain, bean.userId);
const subDomainGetter = new SubDomainsGetter(bean.userId, this.subDomainService);
const domainParser = new DomainParser(subDomainGetter);
const cacheKey = `cname.record.verify.${bean.id}`;
@@ -229,7 +261,7 @@ export class CnameRecordService extends BaseService<CnameRecordEntity> {
value = {
validating: false,
pass: false,
startTime: new Date().getTime(),
startTime: new Date().getTime()
};
}
let ttl = 5 * 60 * 1000;
@@ -251,16 +283,16 @@ export class CnameRecordService extends BaseService<CnameRecordEntity> {
//公共CNAME
return new CommonDnsProvider({
config: cnameProvider,
plusService: this.plusService,
plusService: this.plusService
});
}
const serviceGetter = this.taskServiceBuilder.create({userId:cnameProvider.userId})
const serviceGetter = this.taskServiceBuilder.create({ userId: cnameProvider.userId });
const access = await this.accessService.getById(cnameProvider.accessId, cnameProvider.userId);
const context = {access, logger, http, utils, domainParser,serviceGetter};
const context = { access, logger, http, utils, domainParser, serviceGetter };
const dnsProvider: IDnsProvider = await createDnsProvider({
dnsProviderType: cnameProvider.dnsProviderType,
context,
context
});
return dnsProvider;
};
@@ -268,15 +300,15 @@ export class CnameRecordService extends BaseService<CnameRecordEntity> {
const clearVerifyRecord = async () => {
cache.delete(cacheKey);
try {
let dnsProvider = value.dnsProvider
let dnsProvider = value.dnsProvider;
if (!dnsProvider) {
dnsProvider = await buildDnsProvider();
}
await dnsProvider.removeRecord({
recordReq: value.recordReq,
recordRes: value.recordRes,
recordRes: value.recordRes
});
logger.info('删除CNAME的校验DNS记录成功');
logger.info("删除CNAME的校验DNS记录成功");
} catch (e) {
logger.error(`删除CNAME的校验DNS记录失败, ${e.message}req:${JSON.stringify(value.recordReq)}recordRes:${JSON.stringify(value.recordRes)}`, e);
}
@@ -289,8 +321,8 @@ export class CnameRecordService extends BaseService<CnameRecordEntity> {
if (value.startTime + ttl < new Date().getTime()) {
logger.warn(`cname验证超时,停止检查,${bean.domain} ${testRecordValue}`);
clearInterval(value.intervalId);
await this.updateStatus(bean.id, 'timeout');
await clearVerifyRecord()
await this.updateStatus(bean.id, "timeout");
await clearVerifyRecord();
return false;
}
@@ -301,7 +333,7 @@ export class CnameRecordService extends BaseService<CnameRecordEntity> {
logger.info(`检查CNAME配置 ${fullDomain} ${testRecordValue}`);
//检查是否有重复的acme配置
await this.checkRepeatAcmeChallengeRecords(fullDomain,bean.recordValue)
await this.checkRepeatAcmeChallengeRecords(fullDomain, bean.recordValue);
// const txtRecords = await dns.promises.resolveTxt(fullDomain);
// if (txtRecords.length) {
@@ -318,9 +350,9 @@ export class CnameRecordService extends BaseService<CnameRecordEntity> {
if (success) {
clearInterval(value.intervalId);
logger.info(`检测到CNAME配置,修改状态 ${fullDomain} ${testRecordValue}`);
await this.updateStatus(bean.id, 'valid', "");
await this.updateStatus(bean.id, "valid", "");
value.pass = true;
await clearVerifyRecord()
await clearVerifyRecord();
return success;
}
};
@@ -331,88 +363,88 @@ export class CnameRecordService extends BaseService<CnameRecordEntity> {
}
cache.set(cacheKey, value, {
ttl: ttl,
ttl: ttl
});
const domain = await domainParser.parse(bean.recordValue);
const fullRecord = bean.recordValue;
const hostRecord = fullRecord.replace(`.${domain}`, '');
const hostRecord = fullRecord.replace(`.${domain}`, "");
const req = {
domain: domain,
fullRecord: fullRecord,
hostRecord: hostRecord,
type: 'TXT',
value: testRecordValue,
type: "TXT",
value: testRecordValue
};
const dnsProvider = await buildDnsProvider();
if(dnsProvider.usePunyCode()){
if (dnsProvider.usePunyCode()) {
//是否需要中文转英文
req.domain = dnsProvider.punyCodeEncode(req.domain)
req.fullRecord = dnsProvider.punyCodeEncode(req.fullRecord)
req.hostRecord = dnsProvider.punyCodeEncode(req.hostRecord)
req.value = dnsProvider.punyCodeEncode(req.value)
req.domain = dnsProvider.punyCodeEncode(req.domain);
req.fullRecord = dnsProvider.punyCodeEncode(req.fullRecord);
req.hostRecord = dnsProvider.punyCodeEncode(req.hostRecord);
req.value = dnsProvider.punyCodeEncode(req.value);
}
const recordRes = await dnsProvider.createRecord(req);
value.dnsProvider = dnsProvider;
value.validating = true;
value.recordReq = req;
value.recordRes = recordRes;
await this.updateStatus(bean.id, 'validating', "");
await this.updateStatus(bean.id, "validating", "");
value.intervalId = setInterval(async () => {
try {
await checkRecordValue();
} catch (e) {
logger.error('检查cname出错:', e);
logger.error("检查cname出错:", e);
await this.updateError(bean.id, e.message);
}
}, 10000);
}
async updateStatus(id: number, status: CnameRecordStatusType, error?: string) {
const updated: any = {status}
const updated: any = { status };
if (error != null) {
updated.error = error
updated.error = error;
}
await this.getRepository().update(id, updated);
}
async updateError(id: number, error: string) {
await this.getRepository().update(id, {error});
await this.getRepository().update(id, { error });
}
async checkRepeatAcmeChallengeRecords(acmeRecordDomain: string,targetCnameDomain:string) {
async checkRepeatAcmeChallengeRecords(acmeRecordDomain: string, targetCnameDomain: string) {
let dnsResolver = null
try{
dnsResolver = await getAuthoritativeDnsResolver(acmeRecordDomain)
}catch (e) {
logger.error(`获取${acmeRecordDomain}的权威DNS服务器失败,${e.message}`)
return
let dnsResolver = null;
try {
dnsResolver = await getAuthoritativeDnsResolver(acmeRecordDomain);
} catch (e) {
logger.error(`获取${acmeRecordDomain}的权威DNS服务器失败,${e.message}`);
return;
}
let cnameRecords = []
try{
let cnameRecords = [];
try {
cnameRecords = await dnsResolver.resolveCname(acmeRecordDomain);
}catch (e) {
logger.error(`查询CNAME记录失败:${e.message}`)
return
} catch (e) {
logger.error(`查询CNAME记录失败:${e.message}`);
return;
}
targetCnameDomain = targetCnameDomain.toLowerCase()
targetCnameDomain = punycode.toASCII(targetCnameDomain)
targetCnameDomain = targetCnameDomain.toLowerCase();
targetCnameDomain = punycode.toASCII(targetCnameDomain);
if (cnameRecords.length > 0) {
for (const cnameRecord of cnameRecords) {
if(cnameRecord.toLowerCase() !== targetCnameDomain){
if (cnameRecord.toLowerCase() !== targetCnameDomain) {
//确保只有一个cname记录
throw new Error(`${acmeRecordDomain}存在多个CNAME记录,请删除多余的CNAME记录:${cnameRecord}`)
throw new Error(`${acmeRecordDomain}存在多个CNAME记录,请删除多余的CNAME记录:${cnameRecord}`);
}
}
}
// 确保权威服务器里面没有纯粹的TXT记录
let txtRecords = []
try{
let txtRecords = [];
try {
const txtRecordRes = await dnsResolver.resolveTxt(acmeRecordDomain);
if (txtRecordRes && txtRecordRes.length > 0) {
@@ -420,13 +452,13 @@ export class CnameRecordService extends BaseService<CnameRecordEntity> {
logger.info(`TXT records: ${JSON.stringify(txtRecords)}`);
txtRecords = txtRecords.concat(...txtRecordRes);
}
}catch (e) {
logger.error(`查询Txt记录失败:${e.message}`)
} catch (e) {
logger.error(`查询Txt记录失败:${e.message}`);
}
if (txtRecords.length === 0) {
//如果权威服务器中查不到txt,无需继续检查
return
return;
}
if (cnameRecords.length > 0) {
// 从cname记录中获取txt记录
@@ -435,11 +467,18 @@ export class CnameRecordService extends BaseService<CnameRecordEntity> {
if (res.length > 0) {
for (const txtRecord of txtRecords) {
if (!res.includes(txtRecord)) {
throw new Error(`${acmeRecordDomain}存在多个TXT记录,请删除多余的TXT记录:${txtRecord}`)
throw new Error(`${acmeRecordDomain}存在多个TXT记录,请删除多余的TXT记录:${txtRecord}`);
}
}
}
}
}
async resetStatus(id: number) {
if (!id) {
throw new ValidateException("id不能为空");
}
await this.getRepository().update(id, { status: "cname", mainDomain: "" });
}
}
@@ -11,12 +11,12 @@ import {
import { RoleService } from "../../sys/authority/service/role-service.js";
import { UserEntity } from "../../sys/authority/entity/user.js";
import { cache, utils } from "@certd/basic";
import { LoginErrorException } from "@certd/lib-server/dist/basic/exception/login-error-exception.js";
import { LoginErrorException } from "@certd/lib-server";
import { CodeService } from "../../basic/service/code-service.js";
import { TwoFactorService } from "../../mine/service/two-factor-service.js";
import { UserSettingsService } from "../../mine/service/user-settings-service.js";
import { isPlus } from "@certd/plus-core";
import { AddonService } from "@certd/lib-server/dist/user/addon/service/addon-service.js";
import { AddonService } from "@certd/lib-server";
/**
* 系统用户
@@ -28,6 +28,7 @@ export class UserSiteMonitorSetting extends BaseSettings {
cron?:string = undefined;
retryTimes?:number = 3;
dnsServer?:string[] = undefined;
certValidDays?:number = 10;
}
export class UserEmailSetting extends BaseSettings {
@@ -56,6 +56,12 @@ export class SiteInfoEntity {
@Column({ name: 'disabled', comment: '禁用启用' })
disabled: boolean;
@Column({ name: 'remark', comment: '备注', length: 512 })
remark: string;
@Column({ name: 'group_id', comment: '分组id' })
groupId: number;
@Column({ name: 'create_time', comment: '创建时间', default: () => 'CURRENT_TIMESTAMP' })
createTime: Date;
@Column({ name: 'update_time', comment: '修改时间', default: () => 'CURRENT_TIMESTAMP' })
@@ -169,8 +169,9 @@ export class SiteInfoService extends BaseService<SiteInfoEntity> {
if (!notify) {
return;
}
try {
await this.sendExpiresNotify(site);
await this.sendExpiresNotify(site.id);
} catch (e) {
logger.error("send notify error", e);
}
@@ -186,7 +187,7 @@ export class SiteInfoService extends BaseService<SiteInfoEntity> {
return;
}
try {
await this.sendCheckErrorNotify(site);
await this.sendCheckErrorNotify(site.id);
} catch (e) {
logger.error("send notify error", e);
}
@@ -231,8 +232,7 @@ export class SiteInfoService extends BaseService<SiteInfoEntity> {
ipErrorCount: errorCount
});
try {
site = await this.info(site.id);
await this.sendCheckErrorNotify(site, true);
await this.sendCheckErrorNotify(site.id, true);
} catch (e) {
logger.error("send notify error", e);
}
@@ -254,7 +254,8 @@ export class SiteInfoService extends BaseService<SiteInfoEntity> {
return await this.doCheck(site, notify, retryTimes);
}
async sendCheckErrorNotify(site: SiteInfoEntity, fromIpCheck = false) {
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)
// 发邮件
@@ -274,14 +275,14 @@ export class SiteInfoService extends BaseService<SiteInfoEntity> {
);
}
async sendExpiresNotify(site: SiteInfoEntity) {
const tipDays = 10;
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 setting = await this.userSettingsService.getSetting<UserSiteMonitorSetting>(site.userId, UserSiteMonitorSetting)
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) {
// 发通知
@@ -392,7 +393,7 @@ export class SiteInfoService extends BaseService<SiteInfoEntity> {
}
}
async doImport(req: { text: string; userId: number }) {
async doImport(req: { text: string; userId: number,groupId?:number }) {
if (!req.text) {
throw new Error("text is required");
}
@@ -420,17 +421,22 @@ export class SiteInfoService extends BaseService<SiteInfoEntity> {
} 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
userId: req.userId,
remark,
groupId: req.groupId
});
}
@@ -43,28 +43,19 @@ export class PipelineEntity {
@Column({ name:"is_template", comment: '是否模版', nullable: true, default: false })
isTemplate: boolean;
@Column({
name: 'last_history_time',
comment: '最后一次执行时间',
nullable: true,
})
@Column({name: 'last_history_time',comment: '最后一次执行时间',nullable: true,})
lastHistoryTime: number;
@Column({name: 'valid_time',comment: '到期时间',nullable: true,default: 0})
validTime: number;
// 变量
lastVars: any;
@Column({
name: 'order',
comment: '排序',
nullable: true,
})
@Column({name: 'order', comment: '排序', nullable: true,})
order: number;
@Column({
name: 'create_time',
comment: '创建时间',
default: () => 'CURRENT_TIMESTAMP',
})
@Column({name: 'create_time',comment: '创建时间', default: () => 'CURRENT_TIMESTAMP',})
createTime: Date;
@Column({
name: 'update_time',
@@ -0,0 +1,65 @@
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
import { http, logger, utils } from "@certd/basic";
import { TaskServiceBuilder } from "./getter/task-service-getter.js";
import { AddonService, newAddon, PermissionException, ValidateException } from "@certd/lib-server";
/**
* Addon
*/
@Provide()
@Scope(ScopeEnum.Request, { allowDowngrade: true })
export class AddonGetterService {
@Inject()
taskServiceBuilder: TaskServiceBuilder;
@Inject()
addonService: AddonService;
async getAddonById(id: any, checkUserId: boolean, userId?: number, defaultAddon?:{type:string,name:string} ): Promise<any> {
const serviceGetter = this.taskServiceBuilder.create({
userId
});
const ctx = {
http,
logger,
utils,
serviceGetter
}
if (!id) {
if (!defaultAddon) {
return null;
}
return await newAddon(defaultAddon.type, defaultAddon.name, {}, ctx);
}
const entity = await this.addonService.info(id);
if (entity == null) {
if (!defaultAddon) {
return null;
}
return await newAddon(defaultAddon.type, defaultAddon.name, {}, ctx);
}
if (checkUserId) {
if (userId == null) {
throw new ValidateException("userId不能为空");
}
if (userId !== entity.userId) {
throw new PermissionException("您对该Addon无访问权限");
}
}
const setting = JSON.parse(entity.setting ?? "{}");
const input = {
id: entity.id,
...setting
};
return await newAddon(entity.addonType, entity.type, input, ctx);
}
async getById(id: any, userId: number): Promise<any> {
return await this.getAddonById(id, true, userId);
}
}
@@ -32,7 +32,7 @@ export class TaskServiceGetter implements IServiceGetter{
return await this.getNotificationService() as T
} else if (serviceName === 'domainVerifierGetter') {
return await this.getDomainVerifierGetter() as T
} else{
}else{
if(!serviceNames.includes(serviceName)){
throw new Error(`${serviceName} not in whitelist`)
}
@@ -53,6 +53,7 @@ export class TaskServiceGetter implements IServiceGetter{
return new AccessGetter(this.userId, accessService.getById.bind(accessService));
}
async getCnameProxyService(): Promise<CnameProxyService> {
const cnameRecordService:CnameRecordService = await this.appCtx.getAsync("cnameRecordService")
return new CnameProxyService(this.userId, cnameRecordService.getWithAccessByDomain.bind(cnameRecordService));
@@ -68,10 +69,6 @@ export class TaskServiceGetter implements IServiceGetter{
return new DomainVerifierGetter(this.userId, domainService);
}
}
export type TaskServiceCreateReq = {
userId: number;
}
@Provide()
@Scope(ScopeEnum.Request, { allowDowngrade: true })
export class TaskServiceBuilder {
@@ -84,6 +81,10 @@ export class TaskServiceBuilder {
}
}
export type TaskServiceCreateReq = {
userId: number;
}
@@ -255,6 +255,11 @@ export class PipelineService extends BaseService<PipelineEntity> {
bean.title = pipeline.title;
}
pipeline.id = bean.id;
if (pipeline.version == null) {
pipeline.version = 0;
}
pipeline.version++;
bean.content = JSON.stringify(pipeline);
await this.addOrUpdate(bean);
await this.registerTrigger(bean);
@@ -368,17 +373,21 @@ export class PipelineService extends BaseService<PipelineEntity> {
}
if (immediateTriggerOnce) {
await this.trigger(pipeline.id);
await sleep(200);
try{
await this.trigger(pipeline.id);
await sleep(200);
}catch(e){
logger.error(e);
}
}
}
async trigger(id: any, stepId?: string) {
async trigger(id: any, stepId?: string , doCheck = false) {
const entity: PipelineEntity = await this.info(id);
if (isComm()) {
await this.checkHasDeployCount(id, entity.userId);
if (doCheck) {
await this.beforeCheck(entity);
}
await this.checkUserStatus(entity.userId);
this.cron.register({
name: `pipeline.${id}.trigger.once`,
cron: null,
@@ -457,11 +466,11 @@ export class PipelineService extends BaseService<PipelineEntity> {
return;
}
cron = cron.trim();
if (cron.startsWith("* *")) {
cron = cron.replace("\* \*", "0 0");
if (cron.startsWith("* * ")) {
cron = "0 0 " + cron.substring(5);
}
if (cron.startsWith("*")) {
cron = cron.replace("\*", "0");
if (cron.startsWith("* ")) {
cron = "0 " + cron.substring(2);
}
const triggerId = trigger.id;
const name = this.buildCronKey(pipelineId, triggerId);
@@ -485,6 +494,17 @@ export class PipelineService extends BaseService<PipelineEntity> {
logger.info("当前定时器数量:", this.cron.getTaskSize());
}
async isPipelineValidTimeEnabled(entity: PipelineEntity) {
const settings = await this.sysSettingsService.getPublicSettings();
if (isPlus() && settings.pipelineValidTimeEnabled){
if (entity.validTime > 0 && entity.validTime < Date.now()){
return false
}
}
return true
}
/**
*
* @param id
@@ -496,20 +516,34 @@ export class PipelineService extends BaseService<PipelineEntity> {
await this.doRun(entity, triggerId, stepId);
}
async doRun(entity: PipelineEntity, triggerId: string, stepId?: string) {
const id = entity.id;
async beforeCheck(entity: PipelineEntity) {
const validTimeEnabled = await this.isPipelineValidTimeEnabled(entity)
if (!validTimeEnabled) {
throw new Error(`流水线${entity.id}已过期,不予执行`);
}
let suite: UserSuiteEntity = null;
if (isComm()) {
suite = await this.checkHasDeployCount(id, entity.userId);
suite = await this.checkHasDeployCount(entity.id, entity.userId);
}
try {
await this.checkUserStatus(entity.userId);
await this.checkUserStatus(entity.userId);
return {
suite
}
}
async doRun(entity: PipelineEntity, triggerId: string, stepId?: string) {
let suite:any = null
try{
const res = await this.beforeCheck(entity);
suite = res.suite
} catch (e) {
logger.info(e.message);
return;
logger.error(`流水线${entity.id}触发${triggerId}失败:${e.message}`);
}
const id = entity.id;
const pipeline = JSON.parse(entity.content);
if (!pipeline.id) {
pipeline.id = id;
@@ -20,7 +20,7 @@ export class AuthService {
return true;
}
async isAdmin(ctx: any) {
isAdmin(ctx: any) {
const roleIds: number[] = ctx.user.roles;
if (roleIds.includes(1)) {
return true;
@@ -0,0 +1,231 @@
import { Provide, Scope, ScopeEnum } from '@midwayjs/core';
import { http, logger, utils } from '@certd/basic';
// 使用@certd/basic包中已有的utils.sp.spawn函数替代自定义的asyncExec
// 该函数已经内置了Windows系统编码问题的解决方案
export type NetTestResult = {
success: boolean; //是否成功
message: string; //结果
testLog: string; //测试日志
error?: string; //执行错误信息
}
@Provide('nettestService')
@Scope(ScopeEnum.Request, { allowDowngrade: true })
export class NetTestService {
/**
* 执行Telnet测试
* @param domain 域名
* @param port 端口
* @returns 测试结果
*/
async telnet(domain: string, port: number): Promise<NetTestResult> {
try {
let command = '';
if (this.isWindows()) {
// Windows系统使用PowerShell执行测试,避免输入重定向问题
// 使用PowerShell的Test-NetConnection命令进行端口测试
command = `powershell -Command "& { $result = Test-NetConnection -ComputerName ${domain} -Port ${port} -InformationLevel Quiet; if ($result) { Write-Host '端口连接成功' } else { Write-Host '端口连接失败' } }"`;
} else {
// Linux系统使用nc命令进行端口测试
command = `nc -zv -w 5 ${domain} ${port} 2>&1`;
}
// 使用utils.sp.spawn执行命令,它会自动处理Windows编码问题
const output = await utils.sp.spawn({
cmd: command,
logger: undefined // 可以根据需要传入logger
});
// 判断测试是否成功
const success = this.isWindows()
? output.includes('端口连接成功')
: output.includes(' open');
// 处理结果
return {
success,
message: success ? '端口连接测试成功' : '端口连接测试失败',
testLog: output,
};
} catch (error) {
return {
success: false,
message: 'Telnet测试执行失败',
testLog: error.stdout || error.stderr || error?.message || String(error),
error: error.stderr || error?.message || String(error),
};
}
}
/**
* 执行Ping测试
* @param domain 域名
* @returns 测试结果
*/
async ping(domain: string): Promise<NetTestResult> {
try {
let command = '';
if (this.isWindows()) {
// Windows系统ping命令,发送4个包
command = `ping -n 4 ${domain}`;
} else {
// Linux系统ping命令,发送4个包
command = `ping -c 4 ${domain}`;
}
// 使用utils.sp.spawn执行命令
const output = await utils.sp.spawn({
cmd: command,
logger: undefined
});
// 判断测试是否成功
const success = this.isWindows()
? output.includes('TTL=')
: output.includes('time=');
return {
success,
message: success ? 'Ping测试成功' : 'Ping测试失败',
testLog: output,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
message: 'Ping测试执行失败',
testLog: error.stderr|| error.stdout || errorMessage,
error: errorMessage
};
}
}
private isWindows() {
return process.platform === 'win32';
}
/**
* 执行域名解析测试
* @param domain 域名
* @returns 解析结果
*/
async domainResolve(domain: string): Promise<NetTestResult> {
try {
let command = '';
if (this.isWindows()) {
// Windows系统使用nslookup命令
command = `nslookup ${domain}`;
} else {
// Linux系统优先使用dig命令,如果没有则回退到nslookup
command = `which dig > /dev/null && dig ${domain} || nslookup ${domain}`;
}
// 使用utils.sp.spawn执行命令
const output = await utils.sp.spawn({
cmd: command,
logger: undefined
});
// 判断测试是否成功
const success = output.includes('Address:') || output.includes('IN A') || output.includes('IN AAAA') ||
(this.isWindows() && output.includes('Name:'));
return {
success,
message: success ? '域名解析测试成功' : '域名解析测试失败',
testLog: output,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
message: '域名解析测试执行失败',
testLog: error.stdoout || error.stderr || errorMessage,
error: errorMessage
};
}
}
async getLocalIP(): Promise<string[]> {
try {
const output = await utils.sp.spawn({
cmd: 'ip a | grep \'inet \' | grep -v \'127.0.0.1\' | awk \'{print $2}\' | cut -d/ -f1',
logger: undefined
});
// 去除 inet 前缀
let ips = output.trim().replace(/inet /g, '');
return ips.split('\n').filter(ip => ip.length > 0);
} catch (error) {
return [error instanceof Error ? error.message : String(error)];
}
}
async getPublicIP(): Promise<string[]> {
try {
const res = await http.request({
url:"https://ipinfo.io/ip",
method:"GET",
})
return[res]
} catch (error) {
return [error instanceof Error ? error.message : String(error)]
}
}
async getDNSservers(): Promise<string[]> {
let dnsServers: string[] = [];
try {
const output = await utils.sp.spawn({
cmd: 'cat /etc/resolv.conf | grep nameserver | awk \'{print $2}\'',
logger: undefined
});
dnsServers = output.trim().split('\n');
} catch (error) {
dnsServers = [error instanceof Error ? error.message : String(error)];
}
try{
/**
* /app # cat /etc/resolv.conf | grep "ExtServers"
# ExtServers: [223.5.5.5 223.6.6.6]
*/
const extDnsServers = await utils.sp.spawn({
cmd: 'cat /etc/resolv.conf | grep "ExtServers"',
logger: undefined
});
const line = extDnsServers.trim()
if (line.includes('ExtServers') && line.includes('[')) {
const extDns = line.substring(line.indexOf('[') + 1, line.indexOf(']')).split(' ');
const dnsList = extDns.map(item=>`Ext:${item}`)
dnsServers = dnsServers.concat(dnsList);
}
} catch (error) {
logger.error('获取DNS ExtServers 服务器失败', error);
// dnsServers.push(error instanceof Error ? error.message : String(error));
}
return dnsServers;
}
/**
* 获取服务器信息(包括本地IP、外网IP和DNS服务器)
* @returns 服务器信息
*/
async serverInfo(): Promise<any> {
const res = {
localIP: [],
publicIP: [],
dnsServers: [],
}
res.localIP = await this.getLocalIP();
res.publicIP = await this.getPublicIP();
res.dnsServers = await this.getDNSservers();
return res
}
}
@@ -37,3 +37,4 @@ export * from './plugin-dokploy/index.js'
export * from './plugin-godaddy/index.js'
export * from './plugin-captcha/index.js'
export * from './plugin-xinnet/index.js'
export * from './plugin-xinnetconnet/index.js'
@@ -6,15 +6,15 @@ import { AbstractPlusTaskPlugin } from "@certd/plugin-plus";
import JSZip from "jszip";
import * as os from "node:os";
import { OssClientContext, ossClientFactory, OssClientRemoveByOpts, SshAccess, SshClient } from "@certd/plugin-lib";
const defaultBackupDir = 'certd_backup';
const defaultFilePrefix = 'db_backup';
import { pipeline } from "stream/promises";
const defaultBackupDir = "certd_backup";
const defaultFilePrefix = "db_backup";
@IsTaskPlugin({
name: 'DBBackupPlugin',
title: '数据库备份',
icon: 'lucide:database-backup',
desc: '【仅管理员可用】仅支持备份SQLite数据库',
name: "DBBackupPlugin",
title: "数据库备份",
icon: "lucide:database-backup",
desc: "【仅管理员可用】仅支持备份SQLite数据库",
group: pluginGroups.admin.key,
showRunStrategy: true,
default: {
@@ -22,32 +22,32 @@ const defaultFilePrefix = 'db_backup';
runStrategy: RunStrategy.AlwaysRun,
},
},
onlyAdmin:true,
onlyAdmin: true,
needPlus: true,
})
export class DBBackupPlugin extends AbstractPlusTaskPlugin {
@TaskInput({
title: '备份方式',
value: 'local',
title: "备份方式",
value: "local",
component: {
name: 'a-select',
name: "a-select",
options: [
{label: '本地复制', value: 'local'},
{label: 'ssh上传', value: 'ssh'},
{label: 'oss上传', value: 'oss'},
{ label: "本地复制", value: "local" },
{ label: "ssh上传", value: "ssh" },
{ label: "oss上传", value: "oss" },
],
placeholder: '',
placeholder: "",
},
helper: '支持本地复制、ssh上传',
helper: "支持本地复制、ssh上传",
required: true,
})
backupMode = 'local';
backupMode = "local";
@TaskInput({
title: '主机登录授权',
title: "主机登录授权",
component: {
name: 'access-selector',
type: 'ssh',
name: "access-selector",
type: "ssh",
},
mergeScript: `
return {
@@ -60,19 +60,18 @@ export class DBBackupPlugin extends AbstractPlusTaskPlugin {
})
sshAccessId!: number;
@TaskInput({
title: 'OSS类型',
title: "OSS类型",
component: {
name: 'a-select',
name: "a-select",
options: [
{value: "alioss", label: "阿里云OSS"},
{value: "s3", label: "MinIO/S3"},
{value: "qiniuoss", label: "七牛云"},
{value: "tencentcos", label: "腾讯云COS"},
{value: "ftp", label: "Ftp"},
{value: "sftp", label: "Sftp"},
]
{ value: "alioss", label: "阿里云OSS" },
{ value: "s3", label: "MinIO/S3" },
{ value: "qiniuoss", label: "七牛云" },
{ value: "tencentcos", label: "腾讯云COS" },
{ value: "ftp", label: "Ftp" },
{ value: "sftp", label: "Sftp" },
],
},
mergeScript: `
return {
@@ -86,9 +85,9 @@ export class DBBackupPlugin extends AbstractPlusTaskPlugin {
ossType!: string;
@TaskInput({
title: 'OSS授权',
title: "OSS授权",
component: {
name: 'access-selector',
name: "access-selector",
},
mergeScript: `
return {
@@ -106,12 +105,11 @@ export class DBBackupPlugin extends AbstractPlusTaskPlugin {
})
ossAccessId!: number;
@TaskInput({
title: '备份保存目录',
title: "备份保存目录",
component: {
name: 'a-input',
type: 'value',
name: "a-input",
type: "value",
placeholder: `默认${defaultBackupDir}`,
},
helper: `ssh方式默认保存在当前用户的${defaultBackupDir}目录下,本地方式默认保存在data/${defaultBackupDir}目录下,也可以填写绝对路径`,
@@ -120,10 +118,10 @@ export class DBBackupPlugin extends AbstractPlusTaskPlugin {
backupDir: string = defaultBackupDir;
@TaskInput({
title: '备份文件前缀',
title: "备份文件前缀",
component: {
name: 'a-input',
vModel: 'value',
name: "a-input",
vModel: "value",
placeholder: `默认${defaultFilePrefix}`,
},
required: false,
@@ -131,11 +129,11 @@ export class DBBackupPlugin extends AbstractPlusTaskPlugin {
filePrefix: string = defaultFilePrefix;
@TaskInput({
title: '附加上传文件',
title: "附加上传文件",
value: true,
component: {
name: 'a-switch',
vModel: 'checked',
name: "a-switch",
vModel: "checked",
placeholder: `是否备份上传的头像等文件`,
},
required: false,
@@ -143,99 +141,119 @@ export class DBBackupPlugin extends AbstractPlusTaskPlugin {
withUpload = true;
@TaskInput({
title: '删除过期备份',
title: "删除过期备份",
component: {
name: 'a-input-number',
vModel: 'value',
placeholder: '20',
name: "a-input-number",
vModel: "value",
placeholder: "20",
},
helper: '删除多少天前的备份,不填则不删除,windows暂不支持',
helper: "删除多少天前的备份,不填则不删除,windows暂不支持",
required: false,
})
retainDays!: number;
async onInstance() {
}
async onInstance() {}
async execute(): Promise<void> {
if (!this.isAdmin()) {
throw new Error('只有管理员才能运行此任务');
throw new Error("只有管理员才能运行此任务");
}
this.logger.info('开始备份数据库');
this.logger.info("开始备份数据库");
let dbPath = process.env.certd_typeorm_dataSource_default_database;
dbPath = dbPath || './data/db.sqlite';
dbPath = dbPath || "./data/db.sqlite";
if (!fs.existsSync(dbPath)) {
this.logger.error('数据库文件不存在:', dbPath);
this.logger.error("数据库文件不存在:", dbPath);
return;
}
const dbTmpFilename = `${this.filePrefix}_${dayjs().format('YYYYMMDD_HHmmss')}_sqlite`;
const dbTmpFilename = `${this.filePrefix}_${dayjs().format("YYYYMMDD_HHmmss")}_sqlite`;
const dbZipFilename = `${dbTmpFilename}.zip`;
const tempDir = path.resolve(os.tmpdir(), 'certd_backup');
const tempDir = path.resolve(os.tmpdir(), "certd_backup");
if (!fs.existsSync(tempDir)) {
await fs.promises.mkdir(tempDir, {recursive: true});
await fs.promises.mkdir(tempDir, { recursive: true });
}
const dbTmpPath = path.resolve(tempDir, dbTmpFilename);
const dbZipPath = path.resolve(tempDir, dbZipFilename);
//复制到临时目录
await fs.promises.copyFile(dbPath, dbTmpPath);
//本地压缩
const zip = new JSZip();
const stream = fs.createReadStream(dbTmpPath);
// 使用流的方式添加文件内容
zip.file(dbTmpFilename, stream, {binary: true, compression: 'DEFLATE'});
try {
//复制到临时目录
await fs.promises.copyFile(dbPath, dbTmpPath);
// //本地压缩
// const zip = new JSZip();
// const stream = fs.createReadStream(dbTmpPath);
// // 使用流的方式添加文件内容
// zip.file(dbTmpFilename, stream, {binary: true, compression: 'DEFLATE'});
const uploadDir = path.resolve('data', 'upload');
if (this.withUpload && fs.existsSync(uploadDir)) {
zip.folder(uploadDir);
}
// const uploadDir = path.resolve('data', 'upload');
// if (this.withUpload && fs.existsSync(uploadDir)) {
// zip.folder(uploadDir);
// }
const content = await zip.generateAsync({type: 'nodebuffer'});
// const content = await zip.generateAsync({type: 'nodebuffer'});
await fs.promises.writeFile(dbZipPath, content);
this.logger.info(`数据库文件压缩完成:${dbZipPath}`);
// await fs.promises.writeFile(dbZipPath, content);
// 创建可写流
const outputStream = fs.createWriteStream(dbZipPath);
const zip = new JSZip();
this.logger.info('开始备份,当前备份方式:', this.backupMode);
const backupDir = this.backupDir || defaultBackupDir;
const backupFilePath = `${backupDir}/${dbZipFilename}`;
// 添加数据库文件
const dbStream = fs.createReadStream(dbTmpPath);
zip.file(dbTmpFilename, dbStream, { binary: true, compression: "DEFLATE" });
try{
if (this.backupMode === 'local') {
// 处理上传目录
const uploadDir = path.resolve("data", "upload");
if (this.withUpload && fs.existsSync(uploadDir)) {
zip.folder("upload"); // 注意:这里应该是相对路径
}
// 使用流式生成
const zipStream = zip.generateNodeStream({
type: "nodebuffer",
streamFiles: true,
compression: "DEFLATE",
});
// 管道传输
await pipeline(zipStream, outputStream);
this.logger.info(`数据库文件压缩完成:${dbZipPath}`);
this.logger.info("开始备份,当前备份方式:", this.backupMode);
const backupDir = this.backupDir || defaultBackupDir;
const backupFilePath = `${backupDir}/${dbZipFilename}`;
if (this.backupMode === "local") {
await this.localBackup(dbZipPath, backupDir, backupFilePath);
} else if (this.backupMode === 'ssh') {
} else if (this.backupMode === "ssh") {
await this.sshBackup(dbZipPath, backupDir, backupFilePath);
} else if (this.backupMode === 'oss') {
} else if (this.backupMode === "oss") {
await this.ossBackup(dbZipPath, backupDir, backupFilePath);
} else {
throw new Error(`不支持的备份方式:${this.backupMode}`);
}
}finally{
} finally {
//删除临时目录
await fs.promises.rm(tempDir, {recursive: true, force: true});
await fs.promises.rm(tempDir, { recursive: true, force: true });
}
this.logger.info('数据库备份完成');
this.logger.info("数据库备份完成");
}
private async localBackup(dbPath: string, backupDir: string, backupPath: string) {
if (!backupPath.startsWith('/')) {
backupPath = path.join('./data/', backupPath);
if (!backupPath.startsWith("/")) {
backupPath = path.join("./data/", backupPath);
}
const dir = path.dirname(backupPath);
if (!fs.existsSync(dir)) {
await fs.promises.mkdir(dir, {recursive: true});
await fs.promises.mkdir(dir, { recursive: true });
}
backupPath = path.resolve(backupPath);
await fs.promises.copyFile(dbPath, backupPath);
this.logger.info('备份文件路径:', backupPath);
this.logger.info("备份文件路径:", backupPath);
if (this.retainDays > 0) {
// 删除过期备份
this.logger.info('开始删除过期备份文件');
this.logger.info("开始删除过期备份文件");
const files = fs.readdirSync(dir);
const now = Date.now();
let count = 0;
@@ -245,76 +263,76 @@ export class DBBackupPlugin extends AbstractPlusTaskPlugin {
if (now - stat.mtimeMs > this.retainDays * 24 * 60 * 60 * 1000) {
fs.unlinkSync(filePath as fs.PathLike);
count++;
this.logger.info('删除过期备份文件:', filePath);
this.logger.info("删除过期备份文件:", filePath);
}
});
this.logger.info('删除过期备份文件数:', count);
this.logger.info("删除过期备份文件数:", count);
}
}
private async sshBackup(dbPath: string, backupDir: string, backupPath: string) {
const access: SshAccess = await this.getAccess(this.sshAccessId);
const sshClient = new SshClient(this.logger);
this.logger.info('备份目录:', backupPath);
this.logger.info("备份目录:", backupPath);
await sshClient.uploadFiles({
connectConf: access,
transports: [{localPath: dbPath, remotePath: backupPath}],
transports: [{ localPath: dbPath, remotePath: backupPath }],
mkdirs: true,
});
this.logger.info('备份文件上传完成');
this.logger.info("备份文件上传完成");
if (this.retainDays > 0) {
// 删除过期备份
this.logger.info('开始删除过期备份文件');
this.logger.info("开始删除过期备份文件");
const isWin = access.windows;
let script: string[] = [];
if (isWin) {
throw new Error('删除过期文件暂不支持windows系统');
throw new Error("删除过期文件暂不支持windows系统");
// script = `forfiles /p ${backupDir} /s /d -${this.retainDays} /c "cmd /c del @path"`;
} else {
script = [`cd ${backupDir}`, 'echo 备份目录', 'pwd', `find . -type f -mtime +${this.retainDays} -name '${this.filePrefix}*' -exec rm -f {} \\;`];
script = [`cd ${backupDir}`, "echo 备份目录", "pwd", `find . -type f -mtime +${this.retainDays} -name '${this.filePrefix}*' -exec rm -f {} \\;`];
}
await sshClient.exec({
connectConf: access,
script,
});
this.logger.info('删除过期备份文件完成');
this.logger.info("删除过期备份文件完成");
}
}
private async ossBackup(dbPath: string, backupDir: string, backupPath: string) {
if (!this.ossAccessId) {
throw new Error('未配置ossAccessId');
throw new Error("未配置ossAccessId");
}
const access = await this.getAccess(this.ossAccessId);
const ossType = this.ossType
const ossType = this.ossType;
const ctx: OssClientContext = {
logger: this.logger,
utils: this.ctx.utils,
accessService:this.accessService
}
accessService: this.accessService,
};
this.logger.info(`开始备份文件到:${ossType}`);
const client = await ossClientFactory.createOssClientByType(ossType, {
const client = await ossClientFactory.createOssClientByType(ossType, {
access,
ctx,
})
});
await client.upload(backupPath, dbPath);
if (this.retainDays > 0) {
// 删除过期备份
this.logger.info('开始删除过期备份文件');
this.logger.info("开始删除过期备份文件");
const removeByOpts: OssClientRemoveByOpts = {
dir: backupDir,
beforeDays: this.retainDays,
};
await client.removeBy(removeByOpts);
this.logger.info('删除过期备份文件完成');
}else{
this.logger.info('已禁止删除过期文件');
this.logger.info("删除过期备份文件完成");
} else {
this.logger.info("已禁止删除过期文件");
}
}
}
@@ -0,0 +1,105 @@
import { IAccessService } from '@certd/pipeline';
import { AbstractDnsProvider, CreateRecordOptions, IsDnsProvider, RemoveRecordOptions } from '@certd/plugin-cert';
import { AliesaAccess, AliyunAccess, AliyunClientV2 } from '@certd/plugin-lib';
@IsDnsProvider({
name: 'aliesa',
title: '阿里ESA',
desc: '阿里ESA DNS解析',
accessType: 'aliesa',
icon: 'svg:icon-aliyun',
order: 0,
})
export class AliesaDnsProvider extends AbstractDnsProvider {
client: AliyunClientV2
async onInstance() {
const access: AliesaAccess = this.ctx.access as AliesaAccess
const accessGetter = await this.ctx.serviceGetter.get("accessService") as IAccessService
const aliAccess = await accessGetter.getById(access.accessId) as AliyunAccess
const endpoint = `esa.${access.region}.aliyuncs.com`
this.client = aliAccess.getClient(endpoint)
}
async getSiteItem(domain: string) {
const ret = await this.client.doRequest({
// 接口名称
action: "ListSites",
// 接口版本
version: "2024-09-10",
// 接口协议
protocol: "HTTPS",
// 接口 HTTP 方法
method: "GET",
authType: "AK",
style: "RPC",
data: {
query: {
SiteName: domain,
// ["SiteSearchType"] = "exact";
SiteSearchType: "exact",
AccessType: "NS"
}
}
})
const list = ret.Sites
if (list?.length === 0) {
throw new Error(`阿里云ESA中不存在此域名站点:${domain},请确认域名已添加到ESA中,且为NS接入方式`);
}
return list[0]
}
async createRecord(options: CreateRecordOptions): Promise<any> {
const { fullRecord, value, type, domain } = options;
this.logger.info('添加域名解析:', fullRecord, value, domain);
const siteItem = await this.getSiteItem(domain)
const siteId = siteItem.SiteId
const res = await this.client.doRequest({
action: "CreateRecord",
version: "2024-09-10",
method: "POST",
data: {
query: {
SiteId: siteId,
RecordName: fullRecord,
Type: type,
// queries["Ttl"] = 1231311;
Ttl: 100,
Data: JSON.stringify({ Value: value }),
}
}
})
this.logger.info('添加域名解析成功:', fullRecord, value, res.RecordId);
return {
RecordId: res.RecordId,
SiteId: siteId,
}
}
async removeRecord(options: RemoveRecordOptions<any>): Promise<any> {
const record = options.recordRes;
await this.client.doRequest({
action: "DeleteRecord",
version: "2024-09-10",
data: {
query: {
RecordId: record.RecordId,
}
}
})
this.logger.info('删除域名解析成功:', record.RecordId);
}
}
new AliesaDnsProvider();
@@ -1 +1,2 @@
import './aliyun-dns-provider.js';
import './aliesa-dns-provider.js';
@@ -6,7 +6,7 @@ import {
createRemoteSelectInputDefine
} from "@certd/plugin-lib";
import { CertApplyPluginNames, CertInfo, CertReader } from "@certd/plugin-cert";
import {optionsUtils} from "@certd/basic/dist/utils/util.options.js";
import {optionsUtils} from "@certd/basic";
@IsTaskPlugin({
name: 'DeployCertToAliyunApig',
@@ -1,7 +1,7 @@
import {AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput} from '@certd/pipeline';
import {AliyunAccess, createCertDomainGetterInputDefine, createRemoteSelectInputDefine} from "@certd/plugin-lib";
import {CertApplyPluginNames, CertInfo} from '@certd/plugin-cert';
import {optionsUtils} from "@certd/basic/dist/utils/util.options.js";
import {optionsUtils} from "@certd/basic";
@IsTaskPlugin({
name: 'DeployCertToAliyunApiGateway',
@@ -1,6 +1,6 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from '@certd/pipeline';
import { AliyunAccess, AliyunClient, AliyunSslClient, createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from '@certd/plugin-lib';
import { optionsUtils } from '@certd/basic/dist/utils/util.options.js';
import { optionsUtils } from '@certd/basic';
import { CertApplyPluginNames, CertReader } from "@certd/plugin-cert";
@IsTaskPlugin({
name: 'DeployCertToAliyunCDN',
@@ -8,7 +8,7 @@ import {
} from "@certd/plugin-lib";
import { CertInfo } from '@certd/plugin-cert';
import { CertApplyPluginNames} from '@certd/plugin-cert';
import { optionsUtils } from "@certd/basic/dist/utils/util.options.js";
import { optionsUtils } from "@certd/basic";
@IsTaskPlugin({
name: 'DeployCertToAliyunDCDN',
title: '阿里云-部署证书至DCDN',
@@ -12,7 +12,7 @@ import {
title: "阿里云-部署至ESA",
icon: "svg:icon-aliyun",
group: pluginGroups.aliyun.key,
desc: "部署证书到阿里云ESA(边缘安全加速)",
desc: "部署证书到阿里云ESA(边缘安全加速),自动删除过期证书",
needPlus: false,
default: {
strategy: {
@@ -125,6 +125,7 @@ export class AliyunDeployCertToESA extends AbstractTaskPlugin {
const { certId, certName } = await this.getAliyunCertId(access);
for (const siteId of this.siteIds) {
try {
const res = await client.doRequest({
// 接口名称
@@ -159,6 +160,7 @@ export class AliyunDeployCertToESA extends AbstractTaskPlugin {
}
}
async getClient(access: AliyunAccess) {
const endpoint = `esa.${this.regionId}.aliyuncs.com`;
return access.getClient(endpoint);
@@ -211,19 +213,24 @@ export class AliyunDeployCertToESA extends AbstractTaskPlugin {
this.logger.info(`证书${item.Name}状态:${item.Status}`);
if (item.Status === "Expired") {
this.logger.info(`证书${item.Name}已过期,执行删除`);
await client.doRequest({
action: "DeleteCertificate",
version: "2024-09-10",
// 接口 HTTP 方法
method: "GET",
data:{
query: {
SiteId: siteId,
Id: item.id
}
}
});
this.logger.info(`证书${item.Name}已删除`);
try{
await client.doRequest({
action: "DeleteCertificate",
version: "2024-09-10",
// 接口 HTTP 方法
method: "GET",
data:{
query: {
SiteId: siteId,
Id: item.id
}
}
});
this.logger.info(`证书${item.Name}已删除`);
}catch (e) {
this.logger.error(`过期证书${item.Name}删除失败:`,e.message)
}
}
}
}
@@ -7,7 +7,7 @@ import {
} from '@certd/plugin-lib';
import {CertInfo, CertReader} from '@certd/plugin-cert';
import { CertApplyPluginNames} from '@certd/plugin-cert';
import {optionsUtils} from "@certd/basic/dist/utils/util.options.js";
import {optionsUtils} from "@certd/basic";
import {isArray} from "lodash-es";
@IsTaskPlugin({
name: 'DeployCertToAliyunOSS',
@@ -121,7 +121,7 @@ export class DeployCertToAliyunOSS extends AbstractTaskPlugin {
name: 'a-select',
options: [
{ value: 'cn-hangzhou', label: '中国大陆' },
{ value: 'southeast-1', label: '新加坡' },
{ value: 'ap-southeast-1', label: '新加坡' },
{ value: 'eu-central-1', label: '德国(法兰克福)' },
],
},
@@ -3,7 +3,7 @@ import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert";
import { AwsAccess, AwsRegions } from "../access.js";
import { AwsAcmClient } from "../libs/aws-acm-client.js";
import { createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from "@certd/plugin-lib";
import { optionsUtils } from "@certd/basic/dist/utils/util.options.js";
import { optionsUtils } from "@certd/basic";
@IsTaskPlugin({
name: 'AwsDeployToCloudFront',
@@ -1,2 +1,3 @@
export * from './geetest/index.js';
export * from './image/index.js';
export * from './tencent/index.js';
@@ -0,0 +1,106 @@
import { AddonInput, BaseAddon, IsAddon } from "@certd/lib-server";
import { ICaptchaAddon } from "../api.js";
import { TencentAccess } from "@certd/plugin-lib";
@IsAddon({
addonType: "captcha",
name: "tencent",
title: "腾讯云验证码",
desc: "",
showTest: false,
})
export class TencentCaptcha extends BaseAddon implements ICaptchaAddon {
@AddonInput({
title: "腾讯云授权",
helper: "腾讯云授权",
component: {
name: "access-selector",
vModel: "modelValue",
from: "sys",
type: "tencent", //固定授权类型
},
required: true,
})
accessId: number;
@AddonInput({
title: "验证ID",
component: {
name: "a-input-number",
placeholder: "CaptchaAppId",
},
helper: "[腾讯云验证码](https://cloud.tencent.com/act/cps/redirect?redirect=37716&cps_key=b3ef73330335d7a6efa4a4bbeeb6b2c9)",
required: true,
})
captchaAppId: number;
@AddonInput({
title: "验证Key",
component: {
placeholder: "AppSecretKey",
},
required: true,
})
appSecretKey = "";
async onValidate(data?: any) {
if (!data) {
return false;
}
const access = await this.getAccess<TencentAccess>(this.accessId);
const sdk = await import("tencentcloud-sdk-nodejs/tencentcloud/services/captcha/v20190722/index.js");
const CaptchaClient = sdk.v20190722.Client;
const clientConfig = {
credential: {
secretId: access.secretId,
secretKey: access.secretKey,
},
region: "",
profile: {
httpProfile: {
endpoint: "captcha.tencentcloudapi.com",
},
},
};
// 实例化要请求产品的client对象,clientProfile是可选的
const client = new CaptchaClient(clientConfig);
const params = {
CaptchaType: 9, //固定值9
UserIp: "127.0.0.1",
Ticket: data.ticket,
Randstr: data.randstr,
AppSecretKey: this.appSecretKey,
CaptchaAppId: this.captchaAppId,
};
try {
const res = await client.DescribeCaptchaResult(params);
if (res.CaptchaCode == 1) {
// 验证成功
// verification successful
return true;
} else {
// 验证失败
// verification failed
this.logger.error("腾讯云验证码验证失败", res.CaptchaMsg);
return false;
}
} catch (err) {
if (data.ticket.startsWith("trerror_") && err.message.includes("账户已欠费")) {
this.logger.error("腾讯云验证码账户欠费,临时放行:", err.message);
return true;
}
throw err
}
}
async getCaptcha(): Promise<any> {
return {
captchaAppId: this.captchaAppId,
};
}
}
@@ -1,7 +1,7 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from '@certd/pipeline';
import { CertInfo, CertReader } from '@certd/plugin-cert';
import { createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from '@certd/plugin-lib';
import { optionsUtils } from '@certd/basic/dist/utils/util.options.js';
import { optionsUtils } from '@certd/basic';
import { CertApplyPluginNames} from '@certd/plugin-cert';
@IsTaskPlugin({
//命名规范,插件类型+功能(就是目录plugin-demo中的demo),大写字母开头,驼峰命名
@@ -6,7 +6,6 @@ import {
SshAccess,
SshClient
} from "@certd/plugin-lib";
import path from "node:path";
@IsTaskPlugin({
//命名规范,插件类型+功能(就是目录plugin-demo中的demo),大写字母开头,驼峰命名
@@ -57,7 +56,7 @@ export class FnOSDeployToNAS extends AbstractTaskPlugin {
@TaskInput(
createRemoteSelectInputDefine({
title: "证书Id",
helper: "要更新的证书id",
helper: "面板证书请选择fnOS,其他FTP、webdav等证书请选择已使用,可多选(如果证书域名都匹配的话)",
action: FnOSDeployToNAS.prototype.onGetCertList.name
})
)
@@ -87,7 +86,9 @@ export class FnOSDeployToNAS extends AbstractTaskPlugin {
this.logger.info(`----------- 找到证书,开始部署:${item.sum},${item.domain}`)
const certPath = item.certificate;
const keyPath = item.privateKey;
const certDir = path.dirname(keyPath)
const certDir = keyPath.substring(0, keyPath.lastIndexOf("/"));
const fullchainPath = certDir+ "/fullchain.crt"
const caPath = certDir+ "/issuer_certificate.crt"
const cmd = `
sudo tee ${certPath} > /dev/null <<'EOF'
${this.cert.crt}
@@ -95,6 +96,12 @@ EOF
sudo tee ${keyPath} > /dev/null <<'EOF'
${this.cert.key}
EOF
sudo tee ${fullchainPath} > /dev/null <<'EOF'
${this.cert.crt}
EOF
sudo tee ${caPath} > /dev/null <<'EOF'
${this.cert.ic}
EOF
sudo chmod 0755 "${certDir}/" -R
@@ -157,7 +164,7 @@ echo "服务重启完成!"
}
if (!list || list.length === 0) {
throw new Error("没有找到证书,请先在证书管理也没上传一次证书");
throw new Error("没有找到证书,请先在证书管理页面上传一次证书");
}
return list
}
@@ -246,7 +246,7 @@ export class UploadCertToHostPlugin extends AbstractTaskPlugin {
rows: 5,
placeholder: 'systemctl restart nginx ',
},
helper: '上传后执行脚本命令,让证书生效(比如重启nginx),不填则不执行\n注意:sudo需要配置免密\n注意:如果目标主机是windows,且终端是cmd,系统会自动将多行命令通过“&&”连接成一行',
helper: '上传后执行脚本命令,让证书生效(比如重启nginx),不填则不执行\n注意:sudo需要配置免密,不要使用-i这种交互式命令\n注意:如果目标主机是windows,且终端是cmd,系统会自动将多行命令通过“&&”连接成一行',
required: false,
})
script!: string;
@@ -10,7 +10,7 @@ import { resetLogConfigure } from "@certd/basic";
title: '华为云-上传证书至CCM',
icon: 'svg:icon-huawei',
group: pluginGroups.huawei.key,
desc: '上传证书到华为云CCM',
desc: '上传证书到华为云云证书管理(CCM',
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
@@ -1,7 +1,7 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from "@certd/plugin-lib";
import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert";
import { optionsUtils } from "@certd/basic/dist/utils/util.options.js";
import { optionsUtils } from "@certd/basic";
import { JDCloudAccess } from "../access.js";
@IsTaskPlugin({
@@ -1,7 +1,7 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from "@certd/plugin-lib";
import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert";
import { optionsUtils } from "@certd/basic/dist/utils/util.options.js";
import { optionsUtils } from "@certd/basic";
import { JDCloudAccess } from "../access.js";
@IsTaskPlugin({
@@ -4,6 +4,7 @@ import { BaseNotification, IsNotification, NotificationBody, NotificationInput }
name: 'email',
title: '电子邮件',
desc: '电子邮件通知',
order: -100,
})
export class EmailNotification extends BaseNotification {
@NotificationInput({
@@ -12,4 +12,5 @@ export * from './bark/index.js';
export * from './feishu/index.js';
export * from './dingtalk/index.js';
export * from './vocechat/index.js';
export * from './onebot/index.js';
export * from './onebot/index.js';
export * from './meow/index.js';
@@ -0,0 +1,89 @@
import { BaseNotification, IsNotification, NotificationBody, NotificationInput } from '@certd/pipeline';
/**
* POST请求
application/json
text/plain
multipart/form-data
application/x-www-form-urlencoded
POST /{}/[title] HTTP/1.1
Content-Type: application/x-www-form-urlencoded
title=&msg=&url=&msgType=html&htmlHeight=400
POST /{}/[title] HTTP/1.1
Content-Type: text/plain
POST JSON示例
POST /JohnDoe?msgType=html&htmlHeight=350 HTTP/1.1
Host: api.chuckfang.com
Content-Type: application/json
{
"title": "系统通知",
"msg": "<p><b>欢迎使用</b>,这是 <i>HTML</i> 格式的消息</p>",
"url": "https://example.com"
}
===
{
"status": 200,
"message": "推送成功"
}
*/
@IsNotification({
name: 'meow',
title: 'MeoW通知',
desc: 'https://api.chuckfang.com/',
needPlus: false,
})
export class MeowNotification extends BaseNotification {
@NotificationInput({
title: 'MeoW接口地址',
component: {
placeholder: 'https://api.xxxxxx.com',
},
required: true,
})
endpoint = '';
@NotificationInput({
title: '昵称',
component: {
placeholder: '',
},
required: true,
})
nickName = '';
async send(body: NotificationBody) {
if (!this.nickName) {
throw new Error('昵称不能为空');
}
let endpoint = this.endpoint;
if (!endpoint.endsWith('/')) {
endpoint += '/';
}
const url = `${endpoint}${this.nickName}/`;
const res = await this.http.request({
url: url,
method: 'POST',
data: {
text: body.title,
msg: body.content,
url: body.url,
},
});
if (res.status !== 200) {
throw new Error(res.message || res.msg);
}
}
}
@@ -5,6 +5,7 @@ import qs from 'qs';
name: 'webhook',
title: '自定义webhook',
desc: '根据模版自定义http请求',
order: -100,
})
export class WebhookNotification extends BaseNotification {
@NotificationInput({
@@ -1,7 +1,7 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from '@certd/pipeline';
import { createCertDomainGetterInputDefine, createRemoteSelectInputDefine, QiniuAccess, QiniuClient } from '@certd/plugin-lib';
import { CertInfo } from '@certd/plugin-cert';
import { optionsUtils } from '@certd/basic/dist/utils/util.options.js';
import { optionsUtils } from '@certd/basic';
import { CertApplyPluginNames} from '@certd/plugin-cert';
@IsTaskPlugin({
name: 'QiniuDeployCertToCDN',
@@ -2,7 +2,7 @@ import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput
import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert";
import { UpyunAccess } from "../access.js";
import { createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from "@certd/plugin-lib";
import { optionsUtils } from "@certd/basic/dist/utils/util.options.js";
import { optionsUtils } from "@certd/basic";
import { UpyunClient } from "../client.js";
@IsTaskPlugin({
@@ -1,7 +1,7 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import { createCertDomainGetterInputDefine, createRemoteSelectInputDefine } from "@certd/plugin-lib";
import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert";
import { optionsUtils } from "@certd/basic/dist/utils/util.options.js";
import { optionsUtils } from "@certd/basic";
import { VolcengineAccess } from "../access.js";
import { VolcengineCdnClient } from "../cdn-client.js";
@@ -0,0 +1,157 @@
import { IsAccess, AccessInput, BaseAccess, Pager, PageSearch } from "@certd/pipeline";
import crypto from "crypto";
/**
*
* certd的后台管理系统中
*/
@IsAccess({
name: "xinnetagent",
title: "新网授权(代理方式)",
icon: "lsicon:badge-new-filled",
desc: ""
})
export class XinnetAgentAccess extends BaseAccess {
/**
*
*/
@AccessInput({
title: "代理账号",
component: {
placeholder: "代理账号,如:agent0001"
},
required: true,
encrypt: false
})
agentCode = "";
@AccessInput({
title: "API密钥",
component: {
name: "a-input-password",
vModel: "value",
placeholder: "API密钥"
},
required: true,
encrypt: true
})
appSecret = "";
@AccessInput({
title: "测试",
component: {
name: "api-test",
action: "TestRequest"
},
helper: "点击测试接口是否正常"
})
testRequest = true;
async onTestRequest() {
// const client = new XinnetClient({
// access: this,
// logger: this.ctx.logger,
// http: this.ctx.http
// });
await this.getDomainList({ pageNo: 1, pageSize: 1 });
return "ok";
}
async getDomainList(req:PageSearch) {
const pager = new Pager(req);
const conf = {
url: "/api/domain/list",
data: {
pageNo: String(pager.pageNo),
pageSize: String(pager.pageSize)
}
}
return await this.doRequest(conf);
}
/**
* UTC 0
*/
generateTimestamp() {
const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, "Z").replaceAll(":", "").replaceAll("-", "");
return timestamp;
}
/**
* 16
*/
bytesToHex(bytes:any) {
return bytes.toString('hex');
}
/**
*
*/
generateSignature(timestamp, urlPath, requestBody) {
const algorithm = 'HMAC-SHA256';
const requestMethod = 'POST';
// 构建待签名字符串
const stringToSign = `${algorithm}\n${timestamp}\n${requestMethod}\n${urlPath}\n${requestBody}`;
// 使用 HMAC-SHA256 计算签名
const hmac = crypto.createHmac('sha256', this.appSecret);
hmac.update(stringToSign);
const signatureBytes = hmac.digest();
// 转换为16进制字符串
return this.bytesToHex(signatureBytes);
}
/**
* authorization header
*/
generateAuthorization(timestamp, urlPath, requestBody) {
const signature = this.generateSignature(timestamp, urlPath, requestBody);
return `HMAC-SHA256 Access=${this.agentCode}, Signature=${signature}`;
}
/**
*
*/
async doRequest(req:any) {
const baseURL = 'https://apiv2.xinnet.com';
const urlPath = req.url;
const requestURL = baseURL + urlPath; // 实际请求URL去掉最后的斜杠
// 请求体
const requestBody = JSON.stringify(req.data);
// 生成时间戳和授权头
const timestamp = this.generateTimestamp();
const authorization = this.generateAuthorization(timestamp, urlPath+"/", requestBody);
// 请求配置
const config = {
method: 'POST',
url: requestURL,
headers: {
'Content-Type': 'application/json',
'timestamp': timestamp,
'authorization': authorization
},
data: requestBody,
};
const res = await this.ctx.http.request(config);
if (res.code !="0"){
throw new Error(`API Error: ${res.code} ${res.requestId} - ${JSON.stringify(res.msg)}`);
}
return res.data;
}
}
new XinnetAgentAccess();
@@ -0,0 +1,90 @@
import { AbstractDnsProvider, CreateRecordOptions, IsDnsProvider, RemoveRecordOptions } from "@certd/plugin-cert";
import { XinnetAgentAccess } from "./access-agent.js";
export type XinnetAgentRecord = {
recordId: number;
domainName: string;
};
// 这里通过IsDnsProvider注册一个dnsProvider
@IsDnsProvider({
name: "xinnetagent",
title: "新网(代理方式)",
desc: "新网域名解析(代理方式)",
icon: "lsicon:badge-new-filled",
// 这里是对应的 cloudflare的access类型名称
accessType: "xinnetagent",
order: 7
})
export class XinnetAgentProvider extends AbstractDnsProvider<XinnetAgentRecord> {
access!: XinnetAgentAccess;
async onInstance() {
//一些初始化的操作
// 也可以通过ctx成员变量传递context
this.access = this.ctx.access as XinnetAgentAccess;
}
/**
* dns解析记录
*/
async createRecord(options: CreateRecordOptions): Promise<XinnetAgentRecord> {
/**
* fullRecord: '_acme-challenge.test.example.com',
* value: 一串uuid
* type: 'TXT',
* domain: 'example.com'
*/
const { fullRecord, value, type, domain } = options;
this.logger.info("添加域名解析:", fullRecord, value, type, domain);
/**
* /api/dns/create
* domainName string test-xinnet-0516-ceshi.cn
recordName string test1.test-xinnet-0516-ceshi.cn@和空字符只需要传域名即可
type string 可选择类型如下: NS A CNAME MX TXT URL SRV AAAA A
value string 192.168.1.50
line string 线 "默认"
*/
const res = await this.access.doRequest({
url:"/api/dns/create",
data:{
domainName: domain,
recordName: fullRecord,
type: type,
value: value,
line: "默认"
}
});
return {
recordId:res,
domainName: domain
};
}
/**
* dns解析记录,
* @param options
*/
async removeRecord(options: RemoveRecordOptions<XinnetAgentRecord>): Promise<void> {
const {domainName,recordId} = options.recordRes;
await this.access.doRequest({
url:"/api/dns/delete",
data:{
recordId: recordId,
domainName: domainName
}
});
}
}
//实例化这个provider,将其自动注册到系统中
new XinnetAgentProvider();
@@ -1,2 +1,5 @@
export * from './dns-provider.js';
export * from './access.js';
export * from './access-agent.js';
export * from './dns-provider-agent.js';
@@ -0,0 +1,147 @@
import { IsAccess, AccessInput, BaseAccess } from '@certd/pipeline';
/**
*
* certd的后台管理系统中
*/
@IsAccess({
name: 'xinnetconnect',
title: '新网互联授权',
icon: 'lsicon:badge-new-filled',
desc: '仅支持代理账号,ip需要加入白名单',
})
export class XinnetConnectAccess extends BaseAccess {
/**
*
*/
@AccessInput({
title: '用户名',
component: {
placeholder: '代理用户名,如:agent001',
help: '新网互联的代理用户名',
},
required: true,
encrypt: false,
})
username = '';
@AccessInput({
title: '密码',
component: {
name: "a-input-password",
vModel: "value",
placeholder: '密码',
},
required: true,
encrypt: true,
})
password = '';
async addDnsRecord(req: {domain:string,hostRecord:string, value:string, type:string}): Promise<any> {
const { domain,hostRecord, value, type } = req;
const bodyXml =`
<add>
<domainname>${domain}</domainname>
<resolvetype>${type}</resolvetype>
<resolvehost>${hostRecord}</resolvehost>
<resolvevalue>${value}</resolvevalue>
<mxlevel>10</mxlevel>
</add>`
const res = await this.doRequest({
url: "/addDnsRecordService",
bodyXml: bodyXml,
service: "addDnsRecord",
})
return res
}
async delDnsRecord(req: {domain:string,hostRecord:string, type:string,value:string}): Promise<any> {
const { domain,hostRecord, type,value } = req;
const bodyXml =`
<del>
<domainname>${domain}</domainname>
<resolvetype>${type}</resolvetype>
<resolvehost>${hostRecord}</resolvehost>
<resolveoldvalue>${value}</resolveoldvalue>
<mxlevel>10</mxlevel>
</del>`
const res = await this.doRequest({
url: "/delDnsRecordService",
bodyXml: bodyXml,
service: "delDnsRecord",
})
return res
}
buildUserXml(){
return `
<user>
<name>${this.username}</name>
<password>${this.password}</password>
</user>
`
}
async doRequest(req: {bodyXml:string,service:string,url:string}) {
const xml2js = await import('xml2js');
const soapRequest = `
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ws="http://ws/">
<soapenv:Header/>
<soapenv:Body>
<ws:${req.service}>
${this.buildUserXml()}
${req.bodyXml}
</ws:${req.service}>
</soapenv:Body>
</soapenv:Envelope>
`;
const response = await this.ctx.http.request({
url: req.url,
baseURL: "https://api.bizcn.com/rrpservices",
data: soapRequest,
headers: {
'Content-Type': 'text/xml; charset=utf-8',
'SOAPAction': '' // 根据WSDLsoapAction为空
},
method: "POST",
returnOriginRes: true,
})
// 解析SOAP响应
const parser = new xml2js.Parser({ explicitArray: false });
const result = await parser.parseStringPromise(response.data);
// 提取返回结果
const soapBody = result['soap:Envelope']['soap:Body'];
const addDnsRecordResponse = soapBody["ns1:addDnsRecordResponse"];
console.log(addDnsRecordResponse)
const resultData = addDnsRecordResponse.response.result;
const res = {
code: resultData.$.code,
msg: resultData.msg
}
console.log('操作结果:', res);
if (res.code != "200") {
throw new Error(res.msg + " code:" + res.code);
}
return resultData;
}
}
new XinnetConnectAccess();
@@ -0,0 +1,68 @@
import { AbstractDnsProvider, CreateRecordOptions, IsDnsProvider, RemoveRecordOptions } from "@certd/plugin-cert";
import { XinnetConnectAccess } from "./access.js";
export type XinnetConnectRecord = {
domain: string;
hostRecord: string;
type: string;
value: string;
};
// 这里通过IsDnsProvider注册一个dnsProvider
@IsDnsProvider({
name: 'xinnetconnect',
title: '新网互联',
desc: '新网互联',
icon: 'lsicon:badge-new-filled',
// 这里是对应的 cloudflare的access类型名称
accessType: 'xinnetconnect',
order:999,
})
export class XinnetConnectDnsProvider extends AbstractDnsProvider<XinnetConnectRecord> {
access!: XinnetConnectAccess;
async onInstance() {
//一些初始化的操作
// 也可以通过ctx成员变量传递context
this.access = this.ctx.access as XinnetConnectAccess;
}
/**
* dns解析记录
*/
async createRecord(options: CreateRecordOptions): Promise<XinnetConnectRecord> {
const { fullRecord,hostRecord, value, type, domain } = options;
this.logger.info('添加域名解析:', fullRecord, value, type, domain);
const recordReq = {
domain: domain,
type: 'TXT',
hostRecord: hostRecord,
value: value,
}
await this.access.addDnsRecord(recordReq)
return recordReq;
}
/**
* dns解析记录,
* @param options
*/
async removeRecord(options: RemoveRecordOptions<XinnetConnectRecord>): Promise<void> {
const { fullRecord, value } = options.recordReq;
const record = options.recordRes;
this.logger.info('删除域名解析:', fullRecord, value);
if (!record) {
this.logger.info('record为空,不执行删除');
return;
}
await this.access.delDnsRecord(record)
this.logger.info(`删除域名解析成功:fullRecord=${fullRecord}`);
}
}
//实例化这个provider,将其自动注册到系统中
new XinnetConnectDnsProvider();
@@ -0,0 +1,2 @@
export * from './dns-provider.js';
export * from './access.js';