fix: 安全更新,备份数据库插件仅限管理员运行

This commit is contained in:
xiaojunnuo
2025-07-22 11:51:27 +08:00
parent 9d9cd8a362
commit 13dfca1749
8 changed files with 25 additions and 6 deletions
@@ -0,0 +1,3 @@
export * from './plugin-restart.js';
export * from './plugin-script.js';
export * from './plugin-db-backup.js';
@@ -0,0 +1,316 @@
import { IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
import fs from "fs";
import path from "path";
import dayjs from "dayjs";
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';
@IsTaskPlugin({
name: 'DBBackupPlugin',
title: '数据库备份',
icon: 'lucide:database-backup',
desc: '【仅管理员可用】仅支持备份SQLite数据库',
group: pluginGroups.admin.key,
showRunStrategy: true,
default: {
strategy: {
runStrategy: RunStrategy.AlwaysRun,
},
},
onlyAdmin:true,
needPlus: true,
})
export class DBBackupPlugin extends AbstractPlusTaskPlugin {
@TaskInput({
title: '备份方式',
value: 'local',
component: {
name: 'a-select',
options: [
{label: '本地复制', value: 'local'},
{label: 'ssh上传', value: 'ssh'},
{label: 'oss上传', value: 'oss'},
],
placeholder: '',
},
helper: '支持本地复制、ssh上传',
required: true,
})
backupMode = 'local';
@TaskInput({
title: '主机登录授权',
component: {
name: 'access-selector',
type: 'ssh',
},
mergeScript: `
return {
show:ctx.compute(({form})=>{
return form.backupMode === 'ssh';
})
}
`,
required: true,
})
sshAccessId!: number;
@TaskInput({
title: 'OSS类型',
component: {
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"},
]
},
mergeScript: `
return {
show:ctx.compute(({form})=>{
return form.backupMode === 'oss';
})
}
`,
required: true,
})
ossType!: string;
@TaskInput({
title: 'OSS授权',
component: {
name: 'access-selector',
},
mergeScript: `
return {
show:ctx.compute(({form})=>{
return form.backupMode === 'oss';
}),
component:{
type: ctx.compute(({form})=>{
return form.ossType;
}),
}
}
`,
required: true,
})
ossAccessId!: number;
@TaskInput({
title: '备份保存目录',
component: {
name: 'a-input',
type: 'value',
placeholder: `默认${defaultBackupDir}`,
},
helper: `ssh方式默认保存在当前用户的${defaultBackupDir}目录下,本地方式默认保存在data/${defaultBackupDir}目录下,也可以填写绝对路径`,
required: false,
})
backupDir: string = defaultBackupDir;
@TaskInput({
title: '备份文件前缀',
component: {
name: 'a-input',
vModel: 'value',
placeholder: `默认${defaultFilePrefix}`,
},
required: false,
})
filePrefix: string = defaultFilePrefix;
@TaskInput({
title: '附加上传文件',
value: true,
component: {
name: 'a-switch',
vModel: 'checked',
placeholder: `是否备份上传的头像等文件`,
},
required: false,
})
withUpload = true;
@TaskInput({
title: '删除过期备份',
component: {
name: 'a-input-number',
vModel: 'value',
placeholder: '20',
},
helper: '删除多少天前的备份,不填则不删除,windows暂不支持',
required: false,
})
retainDays!: number;
async onInstance() {
}
async execute(): Promise<void> {
if (!this.isAdmin()) {
throw new Error('只有管理员才能运行此任务');
}
this.logger.info('开始备份数据库');
let dbPath = process.env.certd_typeorm_dataSource_default_database;
dbPath = dbPath || './data/db.sqlite';
if (!fs.existsSync(dbPath)) {
this.logger.error('数据库文件不存在:', dbPath);
return;
}
const dbTmpFilename = `${this.filePrefix}_${dayjs().format('YYYYMMDD_HHmmss')}_sqlite`;
const dbZipFilename = `${dbTmpFilename}.zip`;
const tempDir = path.resolve(os.tmpdir(), 'certd_backup');
if (!fs.existsSync(tempDir)) {
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'});
const uploadDir = path.resolve('data', 'upload');
if (this.withUpload && fs.existsSync(uploadDir)) {
zip.folder(uploadDir);
}
const content = await zip.generateAsync({type: 'nodebuffer'});
await fs.promises.writeFile(dbZipPath, content);
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') {
await this.sshBackup(dbZipPath, backupDir, backupFilePath);
} else if (this.backupMode === 'oss') {
await this.ossBackup(dbZipPath, backupDir, backupFilePath);
} else {
throw new Error(`不支持的备份方式:${this.backupMode}`);
}
this.logger.info('数据库备份完成');
}
private async localBackup(dbPath: string, backupDir: string, backupPath: string) {
if (!backupPath.startsWith('/')) {
backupPath = path.join('./data/', backupPath);
}
const dir = path.dirname(backupPath);
if (!fs.existsSync(dir)) {
await fs.promises.mkdir(dir, {recursive: true});
}
backupPath = path.resolve(backupPath);
await fs.promises.copyFile(dbPath, backupPath);
this.logger.info('备份文件路径:', backupPath);
if (this.retainDays > 0) {
// 删除过期备份
this.logger.info('开始删除过期备份文件');
const files = fs.readdirSync(dir);
const now = Date.now();
let count = 0;
files.forEach(file => {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (now - stat.mtimeMs > this.retainDays * 24 * 60 * 60 * 1000) {
fs.unlinkSync(filePath as fs.PathLike);
count++;
this.logger.info('删除过期备份文件:', filePath);
}
});
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);
await sshClient.uploadFiles({
connectConf: access,
transports: [{localPath: dbPath, remotePath: backupPath}],
mkdirs: true,
});
this.logger.info('备份文件上传完成');
if (this.retainDays > 0) {
// 删除过期备份
this.logger.info('开始删除过期备份文件');
const isWin = access.windows;
let script: string[] = [];
if (isWin) {
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 {} \\;`];
}
await sshClient.exec({
connectConf: access,
script,
});
this.logger.info('删除过期备份文件完成');
}
}
private async ossBackup(dbPath: string, backupDir: string, backupPath: string) {
if (!this.ossAccessId) {
throw new Error('未配置ossAccessId');
}
const access = await this.getAccess(this.ossAccessId);
const ossType = this.ossType
const ctx: OssClientContext = {
logger: this.logger,
utils: this.ctx.utils,
accessService:this.accessService
}
this.logger.info(`开始备份文件到:${ossType}`);
const client = await ossClientFactory.createOssClientByType(ossType, {
access,
ctx,
})
await client.upload(backupPath, dbPath);
if (this.retainDays > 0) {
// 删除过期备份
this.logger.info('开始删除过期备份文件');
const removeByOpts: OssClientRemoveByOpts = {
dir: backupDir,
beforeDays: this.retainDays,
};
await client.removeBy(removeByOpts);
this.logger.info('删除过期备份文件完成');
}else{
this.logger.info('已禁止删除过期文件');
}
}
}
new DBBackupPlugin();
@@ -0,0 +1,28 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy } from '@certd/pipeline';
import { httpsServer } from '../../modules/auto/https/server.js';
@IsTaskPlugin({
name: 'RestartCertd',
title: '重启 Certd',
icon: 'mdi:restart',
desc: '【仅管理员可用】 重启 certd的https服务,用于更新 Certd 的 ssl 证书',
group: pluginGroups.other.key,
onlyAdmin:true,
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
})
export class RestartCertdPlugin extends AbstractTaskPlugin {
async onInstance() {}
async execute(): Promise<void> {
if (!this.isAdmin()) {
throw new Error('只有管理员才能运行此任务');
}
this.logger.info('Certd https server 将在 3 秒后重启');
await this.ctx.utils.sleep(3000);
await httpsServer.restart();
}
}
new RestartCertdPlugin();
@@ -0,0 +1,64 @@
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput, TaskInstanceContext } from '@certd/pipeline';
import { CertInfo, CertReader } from '@certd/plugin-cert';
import { CertApplyPluginNames} from '@certd/plugin-cert';
export type CustomScriptContext = {
CertReader: typeof CertReader;
self: CustomScriptPlugin;
} & TaskInstanceContext;
@IsTaskPlugin({
name: 'CustomScript',
title: '自定义js脚本',
icon: 'ri:javascript-line',
desc: '【仅管理员】运行自定义js脚本执行',
group: pluginGroups.other.key,
showRunStrategy: true,
onlyAdmin: true,
default: {
strategy: {
runStrategy: RunStrategy.SkipWhenSucceed,
},
},
})
export class CustomScriptPlugin extends AbstractTaskPlugin {
@TaskInput({
title: '脚本',
helper: '自定义js脚本,[脚本编写帮助文档](https://certd.docmirror.cn/guide/use/custom-script/)',
component: {
name: 'a-textarea',
vModel: 'value',
rows: 10,
style: 'background-color: #000c17;color: #fafafa;',
},
required: true,
})
script!: string;
@TaskInput({
title: '域名证书',
helper: '请选择前置任务输出的域名证书',
component: {
name: 'output-selector',
from: [...CertApplyPluginNames],
},
required: false,
})
cert!: CertInfo;
async onInstance() {}
async execute(): Promise<void> {
if (!this.isAdmin()) {
throw new Error('只有管理员才能运行此任务');
}
this.logger.info('执行自定义脚本:\n', this.script);
const ctx: CustomScriptContext = {
CertReader,
self: this,
...this.ctx,
};
const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor;
const func = new AsyncFunction('ctx', this.script);
return await func(ctx);
}
}
new CustomScriptPlugin();