mirror of
https://github.com/acepanel/panel.git
synced 2026-02-04 03:07:20 +08:00
984 lines
27 KiB
Go
984 lines
27 KiB
Go
package data
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"os"
|
||
"path/filepath"
|
||
"slices"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/leonelquinteros/gotext"
|
||
"gorm.io/gorm"
|
||
|
||
"github.com/acepanel/panel/internal/app"
|
||
"github.com/acepanel/panel/internal/biz"
|
||
"github.com/acepanel/panel/pkg/config"
|
||
"github.com/acepanel/panel/pkg/db"
|
||
"github.com/acepanel/panel/pkg/io"
|
||
"github.com/acepanel/panel/pkg/shell"
|
||
"github.com/acepanel/panel/pkg/storage"
|
||
"github.com/acepanel/panel/pkg/tools"
|
||
"github.com/acepanel/panel/pkg/types"
|
||
)
|
||
|
||
type backupRepo struct {
|
||
hr string
|
||
t *gotext.Locale
|
||
conf *config.Config
|
||
db *gorm.DB
|
||
log *slog.Logger
|
||
setting biz.SettingRepo
|
||
website biz.WebsiteRepo
|
||
}
|
||
|
||
func NewBackupRepo(t *gotext.Locale, conf *config.Config, db *gorm.DB, log *slog.Logger, setting biz.SettingRepo, website biz.WebsiteRepo) biz.BackupRepo {
|
||
return &backupRepo{
|
||
hr: "+----------------------------------------------------",
|
||
t: t,
|
||
conf: conf,
|
||
db: db,
|
||
log: log,
|
||
setting: setting,
|
||
website: website,
|
||
}
|
||
}
|
||
|
||
// List 备份列表
|
||
func (r *backupRepo) List(typ biz.BackupType) ([]*types.BackupFile, error) {
|
||
path := r.GetDefaultPath(typ)
|
||
files, err := os.ReadDir(path)
|
||
if err != nil {
|
||
if errors.Is(err, os.ErrNotExist) {
|
||
return make([]*types.BackupFile, 0), nil
|
||
}
|
||
return nil, err
|
||
}
|
||
|
||
list := make([]*types.BackupFile, 0)
|
||
for _, file := range files {
|
||
info, err := file.Info()
|
||
if err != nil {
|
||
continue
|
||
}
|
||
list = append(list, &types.BackupFile{
|
||
Name: file.Name(),
|
||
Path: filepath.Join(path, file.Name()),
|
||
Size: tools.FormatBytes(float64(info.Size())),
|
||
Time: info.ModTime(),
|
||
})
|
||
}
|
||
|
||
return list, nil
|
||
}
|
||
|
||
// Create 创建备份
|
||
// typ 备份类型
|
||
// target 目标名称
|
||
// storage 备份存储ID
|
||
func (r *backupRepo) Create(ctx context.Context, typ biz.BackupType, target string, storage uint) error {
|
||
// 取备份存储,0 为本地备份
|
||
backupStorage := new(biz.BackupStorage)
|
||
if storage != 0 {
|
||
if err := r.db.First(backupStorage, storage).Error; err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return errors.New(r.t.Get("backup storage not found"))
|
||
}
|
||
return err
|
||
}
|
||
} else {
|
||
backupStorage = &biz.BackupStorage{
|
||
Name: r.t.Get("Local Storage"),
|
||
Type: biz.BackupStorageTypeLocal,
|
||
Info: types.BackupStorageInfo{
|
||
Path: filepath.Dir(r.GetDefaultPath(typ)), // 需要取根目录
|
||
},
|
||
}
|
||
}
|
||
|
||
client, err := r.getStorage(*backupStorage)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
start := time.Now()
|
||
name := fmt.Sprintf("%s_%s", target, start.Format("20060102150405"))
|
||
if app.IsCli {
|
||
fmt.Println(r.hr)
|
||
fmt.Println(r.t.Get("★ Start backup [%s]", start.Format(time.DateTime)))
|
||
fmt.Println(r.hr)
|
||
fmt.Println(r.t.Get("|-Backup type: %s", string(typ)))
|
||
fmt.Println(r.t.Get("|-Backup storage: %s", backupStorage.Name))
|
||
fmt.Println(r.t.Get("|-Backup target: %s", target))
|
||
}
|
||
|
||
switch typ {
|
||
case biz.BackupTypeWebsite:
|
||
err = r.createWebsite(name, client, target)
|
||
case biz.BackupTypeMySQL:
|
||
err = r.createMySQL(name, client, target)
|
||
case biz.BackupTypePostgres:
|
||
err = r.createPostgres(name, client, target)
|
||
default:
|
||
return errors.New(r.t.Get("unknown backup type"))
|
||
}
|
||
|
||
if app.IsCli {
|
||
fmt.Println(r.t.Get("|-Backup time: %s", time.Since(start).String()))
|
||
fmt.Println(r.hr)
|
||
}
|
||
if err != nil {
|
||
r.log.Warn("backup failed",
|
||
slog.String("type", biz.OperationTypeBackup),
|
||
slog.Uint64("operator_id", getOperatorID(ctx)),
|
||
slog.String("backup_type", string(typ)),
|
||
slog.String("target", target),
|
||
)
|
||
if app.IsCli {
|
||
fmt.Println(r.t.Get("☆ Backup failed: %v [%s]", err, time.Now().Format(time.DateTime)))
|
||
}
|
||
} else {
|
||
r.log.Info("backup created",
|
||
slog.String("type", biz.OperationTypeBackup),
|
||
slog.Uint64("operator_id", getOperatorID(ctx)),
|
||
slog.String("backup_type", string(typ)),
|
||
slog.String("target", target),
|
||
)
|
||
if app.IsCli {
|
||
fmt.Println(r.t.Get("☆ Backup completed [%s]", time.Now().Format(time.DateTime)))
|
||
}
|
||
}
|
||
|
||
if app.IsCli {
|
||
fmt.Println(r.hr)
|
||
}
|
||
|
||
return err
|
||
}
|
||
|
||
// CreatePanel 创建面板备份
|
||
// 面板备份始终保存在本地
|
||
func (r *backupRepo) CreatePanel() error {
|
||
start := time.Now()
|
||
|
||
backup := filepath.Join(r.GetDefaultPath(biz.BackupTypePanel), fmt.Sprintf("panel_%s.zip", time.Now().Format("20060102150405")))
|
||
|
||
temp, err := os.MkdirTemp("", "ace-backup-*")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer func(path string) { _ = os.RemoveAll(path) }(temp)
|
||
|
||
if err = io.Cp(filepath.Join(app.Root, "panel"), temp); err != nil {
|
||
return err
|
||
}
|
||
if err = io.Cp("/usr/local/sbin/acepanel", temp); err != nil {
|
||
return err
|
||
}
|
||
|
||
_ = io.Chmod(temp, 0600)
|
||
if err = io.Compress(temp, nil, backup); err != nil {
|
||
return err
|
||
}
|
||
if err = io.Chmod(backup, 0600); err != nil {
|
||
return err
|
||
}
|
||
|
||
if app.IsCli {
|
||
fmt.Println(r.t.Get("|-Backup time: %s", time.Since(start).String()))
|
||
fmt.Println(r.t.Get("|-Backup file: %s", filepath.Base(backup)))
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// Delete 删除备份
|
||
func (r *backupRepo) Delete(ctx context.Context, typ biz.BackupType, name string) error {
|
||
path := r.GetDefaultPath(typ)
|
||
|
||
file := filepath.Join(path, name)
|
||
if err := io.Remove(file); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 记录日志
|
||
r.log.Info("backup deleted", slog.String("type", biz.OperationTypeBackup), slog.Uint64("operator_id", getOperatorID(ctx)), slog.String("backup_type", string(typ)), slog.String("name", name))
|
||
|
||
return nil
|
||
}
|
||
|
||
// Restore 恢复备份
|
||
// typ 备份类型
|
||
// backup 备份压缩包,可以是绝对路径或者相对路径
|
||
// target 目标名称
|
||
func (r *backupRepo) Restore(ctx context.Context, typ biz.BackupType, backup, target string) error {
|
||
if !io.Exists(backup) {
|
||
backup = filepath.Join(r.GetDefaultPath(typ), backup)
|
||
}
|
||
if !io.Exists(backup) {
|
||
return errors.New(r.t.Get("backup file not exists"))
|
||
}
|
||
|
||
var err error
|
||
switch typ {
|
||
case biz.BackupTypeWebsite:
|
||
err = r.restoreWebsite(backup, target)
|
||
case biz.BackupTypeMySQL:
|
||
err = r.restoreMySQL(backup, target)
|
||
case biz.BackupTypePostgres:
|
||
err = r.restorePostgres(backup, target)
|
||
default:
|
||
return errors.New(r.t.Get("unknown backup type"))
|
||
}
|
||
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 记录日志
|
||
r.log.Info("backup restored",
|
||
slog.String("type", biz.OperationTypeBackup),
|
||
slog.Uint64("operator_id", getOperatorID(ctx)),
|
||
slog.String("backup_type", string(typ)),
|
||
slog.String("target", target),
|
||
)
|
||
|
||
return nil
|
||
}
|
||
|
||
// GetDefaultPath 获取默认备份路径
|
||
func (r *backupRepo) GetDefaultPath(typ biz.BackupType) string {
|
||
backupPath, err := r.setting.Get(biz.SettingKeyBackupPath)
|
||
if err != nil {
|
||
return filepath.Join(app.Root, "backup", string(typ))
|
||
}
|
||
return filepath.Join(backupPath, string(typ))
|
||
}
|
||
|
||
// CutoffLog 切割日志
|
||
// path 保存目录绝对路径
|
||
// target 待切割日志文件绝对路径
|
||
func (r *backupRepo) CutoffLog(path, target string) error {
|
||
if !io.Exists(target) {
|
||
return errors.New(r.t.Get("log file %s not exists", target))
|
||
}
|
||
|
||
to := filepath.Join(path, fmt.Sprintf("%s_%s.zip", time.Now().Format("20060102150405"), filepath.Base(target)))
|
||
if err := io.Compress(filepath.Dir(target), []string{filepath.Base(target)}, to); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 原文件不能直接删除,直接删的话仍会占用空间直到重启相关的应用
|
||
if _, err := shell.Execf("cat /dev/null > '%s'", target); err != nil {
|
||
return err
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// ClearExpired 清理过期备份
|
||
// path 备份目录绝对路径
|
||
// prefix 目标文件前缀
|
||
// save 保存份数
|
||
func (r *backupRepo) ClearExpired(path, prefix string, save uint) error {
|
||
files, err := os.ReadDir(path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
var filtered []os.FileInfo
|
||
for _, file := range files {
|
||
if strings.HasPrefix(file.Name(), prefix) && strings.HasSuffix(file.Name(), ".zip") {
|
||
info, err := os.Stat(filepath.Join(path, file.Name()))
|
||
if err != nil {
|
||
continue
|
||
}
|
||
filtered = append(filtered, info)
|
||
}
|
||
}
|
||
|
||
// 排序所有备份文件,从新到旧
|
||
slices.SortFunc(filtered, func(a, b os.FileInfo) int {
|
||
if a.ModTime().After(b.ModTime()) {
|
||
return -1
|
||
}
|
||
if a.ModTime().Before(b.ModTime()) {
|
||
return 1
|
||
}
|
||
return 0
|
||
})
|
||
if uint(len(filtered)) <= save {
|
||
return nil
|
||
}
|
||
|
||
// 切片保留 save 份,删除剩余
|
||
toDelete := filtered[save:]
|
||
for _, file := range toDelete {
|
||
filePath := filepath.Join(path, file.Name())
|
||
if app.IsCli {
|
||
fmt.Println(r.t.Get("|-Cleaning expired file: %s", filePath))
|
||
}
|
||
if err = os.Remove(filePath); err != nil {
|
||
return errors.New(r.t.Get("Cleanup failed: %v", err))
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// ClearStorageExpired 清理备份账号过期备份
|
||
func (r *backupRepo) ClearStorageExpired(storage uint, typ biz.BackupType, prefix string, save uint) error {
|
||
backupStorage := new(biz.BackupStorage)
|
||
if err := r.db.First(backupStorage, storage).Error; err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return errors.New(r.t.Get("backup storage not found"))
|
||
}
|
||
return err
|
||
}
|
||
|
||
client, err := r.getStorage(*backupStorage)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
files, err := client.List(string(typ))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
type fileInfo struct {
|
||
name string
|
||
modTime time.Time
|
||
}
|
||
var filtered []fileInfo
|
||
for _, file := range files {
|
||
if strings.HasPrefix(file, prefix) && strings.HasSuffix(file, ".zip") {
|
||
lastModified, modErr := client.LastModified(filepath.Join(string(typ), file))
|
||
if modErr != nil {
|
||
continue
|
||
}
|
||
filtered = append(filtered, fileInfo{name: file, modTime: lastModified})
|
||
}
|
||
}
|
||
|
||
// 排序所有备份文件,从新到旧
|
||
slices.SortFunc(filtered, func(a, b fileInfo) int {
|
||
if a.modTime.After(b.modTime) {
|
||
return -1
|
||
}
|
||
if a.modTime.Before(b.modTime) {
|
||
return 1
|
||
}
|
||
return 0
|
||
})
|
||
if uint(len(filtered)) <= save {
|
||
return nil
|
||
}
|
||
|
||
// 切片保留 save 份,删除剩余
|
||
toDelete := filtered[save:]
|
||
for _, file := range toDelete {
|
||
filePath := filepath.Join(string(typ), file.name)
|
||
if app.IsCli {
|
||
fmt.Println(r.t.Get("|-Cleaning expired file: %s", filePath))
|
||
}
|
||
if err = client.Delete(filePath); err != nil {
|
||
return errors.New(r.t.Get("Cleanup failed: %v", err))
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// getStorage 获取存储器
|
||
func (r *backupRepo) getStorage(backupStorage biz.BackupStorage) (storage.Storage, error) {
|
||
switch backupStorage.Type {
|
||
case biz.BackupStorageTypeLocal:
|
||
return storage.NewLocal(backupStorage.Info.Path)
|
||
case biz.BackupStorageTypeS3:
|
||
return storage.NewS3(storage.S3Config{
|
||
Region: backupStorage.Info.Region,
|
||
Bucket: backupStorage.Info.Bucket,
|
||
AccessKey: backupStorage.Info.AccessKey,
|
||
SecretKey: backupStorage.Info.SecretKey,
|
||
Endpoint: backupStorage.Info.Endpoint,
|
||
Scheme: backupStorage.Info.Scheme,
|
||
BasePath: backupStorage.Info.Path,
|
||
AddressingStyle: storage.S3AddressingStyle(backupStorage.Info.Style),
|
||
})
|
||
case biz.BackupStorageTypeSFTP:
|
||
return storage.NewSFTP(storage.SFTPConfig{
|
||
Host: backupStorage.Info.Host,
|
||
Port: backupStorage.Info.Port,
|
||
Username: backupStorage.Info.Username,
|
||
Password: backupStorage.Info.Password,
|
||
PrivateKey: backupStorage.Info.PrivateKey,
|
||
BasePath: backupStorage.Info.Path,
|
||
})
|
||
case biz.BackupStorageTypeWebDAV:
|
||
return storage.NewWebDav(storage.WebDavConfig{
|
||
URL: backupStorage.Info.URL,
|
||
Username: backupStorage.Info.Username,
|
||
Password: backupStorage.Info.Password,
|
||
BasePath: backupStorage.Info.Path,
|
||
})
|
||
default:
|
||
return nil, errors.New(r.t.Get("unknown storage type"))
|
||
}
|
||
}
|
||
|
||
// createWebsite 创建网站备份
|
||
func (r *backupRepo) createWebsite(name string, storage storage.Storage, target string) error {
|
||
website, err := r.website.GetByName(target)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 创建用于压缩的临时目录
|
||
tmpDir, err := os.MkdirTemp("", "ace-backup-*")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer func(path string) { _ = os.RemoveAll(path) }(tmpDir)
|
||
|
||
if app.IsCli {
|
||
fmt.Println(r.t.Get("|-Temporary directory: %s", tmpDir))
|
||
}
|
||
|
||
// 压缩网站
|
||
name = name + ".zip"
|
||
if err = io.Compress(website.Path, nil, filepath.Join(tmpDir, name)); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 上传备份文件到存储器
|
||
file, err := os.Open(filepath.Join(tmpDir, name))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer func(file *os.File) { _ = file.Close() }(file)
|
||
|
||
if err = storage.Put(filepath.Join("website", name), file); err != nil {
|
||
return err
|
||
}
|
||
|
||
if app.IsCli {
|
||
fmt.Println(r.t.Get("|-Backup file: %s", name))
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// createMySQL 创建 MySQL 备份
|
||
func (r *backupRepo) createMySQL(name string, storage storage.Storage, target string) error {
|
||
rootPassword, err := r.setting.Get(biz.SettingKeyMySQLRootPassword)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
mysql, err := db.NewMySQL("root", rootPassword, "/tmp/mysql.sock", "unix")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer mysql.Close()
|
||
if exist, _ := mysql.DatabaseExists(target); !exist {
|
||
return errors.New(r.t.Get("database does not exist: %s", target))
|
||
}
|
||
|
||
// 创建用于压缩的临时目录
|
||
tmpDir, err := os.MkdirTemp("", "ace-backup-*")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer func(path string) { _ = os.RemoveAll(path) }(tmpDir)
|
||
|
||
if app.IsCli {
|
||
fmt.Println(r.t.Get("|-Temporary directory: %s", tmpDir))
|
||
}
|
||
|
||
// 导出数据库
|
||
name = name + ".sql"
|
||
_ = os.Setenv("MYSQL_PWD", rootPassword)
|
||
if _, err = shell.Execf(`mysqldump -u root '%s' > '%s'`, target, filepath.Join(tmpDir, name)); err != nil {
|
||
return err
|
||
}
|
||
_ = os.Unsetenv("MYSQL_PWD")
|
||
|
||
// 压缩备份文件
|
||
if err = io.Compress(tmpDir, []string{name}, filepath.Join(tmpDir, name+".zip")); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 上传备份文件到存储器
|
||
name = name + ".zip"
|
||
file, err := os.Open(filepath.Join(tmpDir, name))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer func(file *os.File) { _ = file.Close() }(file)
|
||
|
||
if err = storage.Put(filepath.Join("mysql", name), file); err != nil {
|
||
return err
|
||
}
|
||
|
||
if app.IsCli {
|
||
fmt.Println(r.t.Get("|-Backup file: %s", name))
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// createPostgres 创建 PostgreSQL 备份
|
||
func (r *backupRepo) createPostgres(name string, storage storage.Storage, target string) error {
|
||
postgresPassword, err := r.setting.Get(biz.SettingKeyPostgresPassword)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
postgres, err := db.NewPostgres("postgres", postgresPassword, "127.0.0.1", 5432)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer postgres.Close()
|
||
if exist, _ := postgres.DatabaseExists(target); !exist {
|
||
return errors.New(r.t.Get("database does not exist: %s", target))
|
||
}
|
||
|
||
// 创建用于压缩的临时目录
|
||
tmpDir, err := os.MkdirTemp("", "ace-backup-*")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer func(path string) { _ = os.RemoveAll(path) }(tmpDir)
|
||
|
||
if app.IsCli {
|
||
fmt.Println(r.t.Get("|-Temporary directory: %s", tmpDir))
|
||
}
|
||
|
||
// 导出数据库
|
||
name = name + ".sql"
|
||
_ = os.Setenv("PGPASSWORD", postgresPassword)
|
||
if _, err = shell.Execf(`pg_dump -h 127.0.0.1 -U postgres '%s' > '%s'`, target, filepath.Join(tmpDir, name)); err != nil {
|
||
return err
|
||
}
|
||
_ = os.Unsetenv("PGPASSWORD")
|
||
|
||
// 压缩备份文件
|
||
if err = io.Compress(tmpDir, []string{name}, filepath.Join(tmpDir, name+".zip")); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 上传备份文件到存储器
|
||
name = name + ".zip"
|
||
file, err := os.Open(filepath.Join(tmpDir, name))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer func(file *os.File) { _ = file.Close() }(file)
|
||
|
||
if err = storage.Put(filepath.Join("postgres", name), file); err != nil {
|
||
return err
|
||
}
|
||
|
||
if app.IsCli {
|
||
fmt.Println(r.t.Get("|-Backup file: %s", name))
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// restoreWebsite 恢复网站备份
|
||
func (r *backupRepo) restoreWebsite(backup, target string) error {
|
||
website, err := r.website.GetByName(target)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if err = io.Remove(website.Path); err != nil {
|
||
return err
|
||
}
|
||
if err = io.UnCompress(backup, website.Path); err != nil {
|
||
return err
|
||
}
|
||
if err = io.Chmod(website.Path, 0755); err != nil {
|
||
return err
|
||
}
|
||
if err = io.Chown(website.Path, "www", "www"); err != nil {
|
||
return err
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// restoreMySQL 恢复 MySQL 备份
|
||
func (r *backupRepo) restoreMySQL(backup, target string) error {
|
||
rootPassword, err := r.setting.Get(biz.SettingKeyMySQLRootPassword)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
mysql, err := db.NewMySQL("root", rootPassword, "/tmp/mysql.sock", "unix")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer mysql.Close()
|
||
if exist, _ := mysql.DatabaseExists(target); !exist {
|
||
return errors.New(r.t.Get("database does not exist: %s", target))
|
||
}
|
||
|
||
clean := false
|
||
if !strings.HasSuffix(backup, ".sql") {
|
||
backup, err = r.autoUnCompressSQL(backup)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
clean = true
|
||
}
|
||
|
||
_ = os.Setenv("MYSQL_PWD", rootPassword)
|
||
if _, err = shell.Execf(`mysql -u root '%s' < '%s'`, target, backup); err != nil {
|
||
return err
|
||
}
|
||
_ = os.Unsetenv("MYSQL_PWD")
|
||
if clean {
|
||
_ = io.Remove(filepath.Dir(backup))
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// restorePostgres 恢复 PostgreSQL 备份
|
||
func (r *backupRepo) restorePostgres(backup, target string) error {
|
||
postgresPassword, err := r.setting.Get(biz.SettingKeyPostgresPassword)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
postgres, err := db.NewPostgres("postgres", postgresPassword, "127.0.0.1", 5432)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer postgres.Close()
|
||
if exist, _ := postgres.DatabaseExists(target); !exist {
|
||
return errors.New(r.t.Get("database does not exist: %s", target))
|
||
}
|
||
|
||
clean := false
|
||
if !strings.HasSuffix(backup, ".sql") {
|
||
backup, err = r.autoUnCompressSQL(backup)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
clean = true
|
||
}
|
||
|
||
_ = os.Setenv("PGPASSWORD", postgresPassword)
|
||
if _, err = shell.Execf(`psql -h 127.0.0.1 -U postgres '%s' < '%s'`, target, backup); err != nil {
|
||
return err
|
||
}
|
||
_ = os.Unsetenv("PGPASSWORD")
|
||
if clean {
|
||
_ = io.Remove(filepath.Dir(backup))
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// autoUnCompressSQL 自动处理压缩文件
|
||
func (r *backupRepo) autoUnCompressSQL(backup string) (string, error) {
|
||
temp, err := os.MkdirTemp("", "acepanel-sql-*")
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
if err = io.UnCompress(backup, temp); err != nil {
|
||
return "", err
|
||
}
|
||
|
||
backup = "" // 置空,防止干扰后续判断
|
||
if files, err := os.ReadDir(temp); err == nil {
|
||
if len(files) != 1 {
|
||
return "", errors.New(r.t.Get("The number of files contained in the compressed file is not 1, actual %d", len(files)))
|
||
}
|
||
if strings.HasSuffix(files[0].Name(), ".sql") {
|
||
backup = filepath.Join(temp, files[0].Name())
|
||
}
|
||
}
|
||
|
||
if backup == "" {
|
||
return "", errors.New(r.t.Get("could not find .sql backup file"))
|
||
}
|
||
|
||
return backup, nil
|
||
}
|
||
|
||
func (r *backupRepo) FixPanel() error {
|
||
if app.IsCli {
|
||
fmt.Println(r.t.Get("|-Start fixing the panel..."))
|
||
}
|
||
|
||
// 检查关键文件是否正常
|
||
flag := !io.Exists(filepath.Join(app.Root, "panel", "ace")) ||
|
||
!io.Exists(filepath.Join(app.Root, "panel", "storage", "config.yml")) ||
|
||
!io.Exists(filepath.Join(app.Root, "panel", "storage", "panel.db")) ||
|
||
io.Exists("/tmp/panel-storage.zip")
|
||
// 检查数据库连接
|
||
if err := r.db.Exec("VACUUM").Error; err != nil {
|
||
flag = true
|
||
}
|
||
if err := r.db.Exec("PRAGMA wal_checkpoint(TRUNCATE);").Error; err != nil {
|
||
flag = true
|
||
}
|
||
if !flag {
|
||
return errors.New(r.t.Get("Files are normal and do not need to be repaired, please run acepanel update to update the panel"))
|
||
}
|
||
|
||
// 再次确认是否需要修复
|
||
if io.Exists("/tmp/panel-storage.zip") {
|
||
// 文件齐全情况下只移除临时文件
|
||
if io.Exists(filepath.Join(app.Root, "panel", "ace")) &&
|
||
io.Exists(filepath.Join(app.Root, "panel", "storage", "config.yml")) &&
|
||
io.Exists(filepath.Join(app.Root, "panel", "storage", "panel.db")) {
|
||
if err := io.Remove("/tmp/panel-storage.zip"); err != nil {
|
||
return errors.New(r.t.Get("failed to clean temporary files: %v", err))
|
||
}
|
||
if app.IsCli {
|
||
fmt.Println(r.t.Get("|-Cleaned up temporary files, please run acepanel update to update the panel"))
|
||
}
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// 从备份目录中找最新的备份文件
|
||
files, err := os.ReadDir(r.GetDefaultPath(biz.BackupTypePanel))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
var list []os.FileInfo
|
||
for _, file := range files {
|
||
info, infoErr := file.Info()
|
||
if infoErr != nil {
|
||
continue
|
||
}
|
||
list = append(list, info)
|
||
}
|
||
slices.SortFunc(list, func(a os.FileInfo, b os.FileInfo) int {
|
||
return int(b.ModTime().Unix() - a.ModTime().Unix())
|
||
})
|
||
if len(list) == 0 {
|
||
return errors.New(r.t.Get("No backup file found, unable to automatically repair"))
|
||
}
|
||
latest := list[0]
|
||
latestPath := filepath.Join(r.GetDefaultPath(biz.BackupTypePanel), latest.Name())
|
||
if app.IsCli {
|
||
fmt.Println(r.t.Get("|-Backup file used: %s", latest.Name()))
|
||
}
|
||
|
||
// 解压备份文件
|
||
if app.IsCli {
|
||
fmt.Println(r.t.Get("|-Unzip backup file..."))
|
||
}
|
||
if err = io.Remove("/tmp/panel-fix"); err != nil {
|
||
return errors.New(r.t.Get("Cleaning temporary directory failed: %v", err))
|
||
}
|
||
if err = io.UnCompress(latestPath, "/tmp/panel-fix"); err != nil {
|
||
return errors.New(r.t.Get("Unzip backup file failed: %v", err))
|
||
}
|
||
|
||
// 移动文件到对应位置
|
||
if app.IsCli {
|
||
fmt.Println(r.t.Get("|-Move backup file..."))
|
||
}
|
||
if io.Exists(filepath.Join("/tmp/panel-fix", "panel")) && io.IsDir(filepath.Join("/tmp/panel-fix", "panel")) {
|
||
if err = io.Remove(filepath.Join(app.Root, "panel")); err != nil {
|
||
return errors.New(r.t.Get("Remove panel file failed: %v", err))
|
||
}
|
||
if err = io.Mv(filepath.Join("/tmp/panel-fix", "panel"), filepath.Join(app.Root)); err != nil {
|
||
return errors.New(r.t.Get("Move panel file failed: %v", err))
|
||
}
|
||
}
|
||
if io.Exists(filepath.Join("/tmp/panel-fix", "acepanel")) {
|
||
if err = io.Mv(filepath.Join("/tmp/panel-fix", "acepanel"), "/usr/local/sbin/acepanel"); err != nil {
|
||
return errors.New(r.t.Get("Move acepanel file failed: %v", err))
|
||
}
|
||
}
|
||
|
||
// tmp 目录下如果有 storage 备份,则解压回去
|
||
if app.IsCli {
|
||
fmt.Println(r.t.Get("|-Restore panel data..."))
|
||
}
|
||
if io.Exists("/tmp/panel-storage.zip") {
|
||
if err = io.UnCompress("/tmp/panel-storage.zip", filepath.Join(app.Root, "panel")); err != nil {
|
||
return errors.New(r.t.Get("Unzip panel data failed: %v", err))
|
||
}
|
||
if err = io.Remove("/tmp/panel-storage.zip"); err != nil {
|
||
return errors.New(r.t.Get("Cleaning temporary file failed: %v", err))
|
||
}
|
||
}
|
||
|
||
// 下载服务文件
|
||
if !io.Exists("/etc/systemd/system/acepanel.service") {
|
||
if _, err = shell.Execf(`wget -O /etc/systemd/system/acepanel.service https://%s/acepanel.service && sed -i "s|/opt/ace|%s|g" /etc/systemd/system/acepanel.service`, r.conf.App.DownloadEndpoint, app.Root); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
// 处理权限
|
||
if app.IsCli {
|
||
fmt.Println(r.t.Get("|-Set key file permissions..."))
|
||
}
|
||
if err = io.Chmod(filepath.Join(app.Root, "panel", "storage", "config.yml"), 0600); err != nil {
|
||
return err
|
||
}
|
||
if err = io.Chmod(filepath.Join(app.Root, "panel", "storage", "panel.db"), 0600); err != nil {
|
||
return err
|
||
}
|
||
if err = io.Chmod("/etc/systemd/system/acepanel.service", 0644); err != nil {
|
||
return err
|
||
}
|
||
if err = io.Chmod("/usr/local/sbin/acepanel", 0700); err != nil {
|
||
return err
|
||
}
|
||
if err = io.Chmod(filepath.Join(app.Root, "panel"), 0700); err != nil {
|
||
return err
|
||
}
|
||
|
||
if err = io.Remove("/tmp/panel-fix"); err != nil {
|
||
return err
|
||
}
|
||
|
||
if app.IsCli {
|
||
fmt.Println(r.t.Get("|-Fix completed"))
|
||
}
|
||
|
||
tools.RestartPanel()
|
||
return nil
|
||
}
|
||
|
||
func (r *backupRepo) UpdatePanel(version, url, checksum string) error {
|
||
// 预先优化数据库
|
||
if err := r.db.Exec("VACUUM").Error; err != nil {
|
||
return err
|
||
}
|
||
if err := r.db.Exec("PRAGMA wal_checkpoint(TRUNCATE);").Error; err != nil {
|
||
return err
|
||
}
|
||
|
||
name := filepath.Base(url)
|
||
if app.IsCli {
|
||
fmt.Println(r.t.Get("|-Target version: %s", version))
|
||
fmt.Println(r.t.Get("|-Download link: %s", url))
|
||
fmt.Println(r.t.Get("|-File name: %s", name))
|
||
}
|
||
|
||
if app.IsCli {
|
||
fmt.Println(r.t.Get("|-Downloading..."))
|
||
}
|
||
if _, err := shell.Execf("wget -T 120 -t 3 -O /tmp/%s %s", name, url); err != nil {
|
||
return errors.New(r.t.Get("Download failed: %v", err))
|
||
}
|
||
if _, err := shell.Execf("wget -T 20 -t 3 -O /tmp/%s %s", name+".sha256", checksum); err != nil {
|
||
return errors.New(r.t.Get("Download failed: %v", err))
|
||
}
|
||
if !io.Exists(filepath.Join("/tmp", name)) || !io.Exists(filepath.Join("/tmp", name+".sha256")) {
|
||
return errors.New(r.t.Get("Download file check failed"))
|
||
}
|
||
|
||
if app.IsCli {
|
||
fmt.Println(r.t.Get("|-Verify download file..."))
|
||
}
|
||
if check, err := shell.Execf("cd /tmp && sha256sum -c %s --ignore-missing", name+".sha256"); check != name+": OK" || err != nil {
|
||
return errors.New(r.t.Get("Verify download file failed: %v", err))
|
||
}
|
||
if err := io.Remove(filepath.Join("/tmp", name+".sha256")); err != nil {
|
||
return errors.New(r.t.Get("|-Clean up verification file failed: %v", err))
|
||
}
|
||
|
||
if io.Exists("/tmp/panel-storage.zip") {
|
||
return errors.New(r.t.Get("Temporary file detected in /tmp, this may be caused by the last update failure, please run acepanel fix to repair and try again"))
|
||
}
|
||
|
||
if app.IsCli {
|
||
fmt.Println(r.t.Get("|-Backup panel data..."))
|
||
}
|
||
// 备份面板
|
||
if err := r.CreatePanel(); err != nil {
|
||
return errors.New(r.t.Get("|-Backup panel data failed: %v", err))
|
||
}
|
||
if err := io.Compress(filepath.Join(app.Root, "panel/storage"), nil, "/tmp/panel-storage.zip"); err != nil {
|
||
return errors.New(r.t.Get("|-Backup panel data failed: %v", err))
|
||
}
|
||
if !io.Exists("/tmp/panel-storage.zip") {
|
||
return errors.New(r.t.Get("|-Backup panel data failed, missing file"))
|
||
}
|
||
|
||
if app.IsCli {
|
||
fmt.Println(r.t.Get("|-Cleaning old version..."))
|
||
}
|
||
if _, err := shell.Execf("rm -rf %s/panel/*", app.Root); err != nil {
|
||
return errors.New(r.t.Get("|-Cleaning old version failed: %v", err))
|
||
}
|
||
|
||
if app.IsCli {
|
||
fmt.Println(r.t.Get("|-Unzip new version..."))
|
||
}
|
||
if err := io.UnCompress(filepath.Join("/tmp", name), filepath.Join(app.Root, "panel")); err != nil {
|
||
return errors.New(r.t.Get("|-Unzip new version failed: %v", err))
|
||
}
|
||
if !io.Exists(filepath.Join(app.Root, "panel", "ace")) {
|
||
return errors.New(r.t.Get("|-Unzip new version failed, missing file"))
|
||
}
|
||
if err := io.Remove(filepath.Join("/tmp", name)); err != nil {
|
||
return errors.New(r.t.Get("|-Clean up temporary file failed: %v", err))
|
||
}
|
||
|
||
if app.IsCli {
|
||
fmt.Println(r.t.Get("|-Restore panel data..."))
|
||
}
|
||
if err := io.UnCompress("/tmp/panel-storage.zip", filepath.Join(app.Root, "panel", "storage")); err != nil {
|
||
return errors.New(r.t.Get("|-Restore panel data failed: %v", err))
|
||
}
|
||
if !io.Exists(filepath.Join(app.Root, "panel/storage/panel.db")) {
|
||
return errors.New(r.t.Get("|-Restore panel data failed, missing file"))
|
||
}
|
||
|
||
if app.IsCli {
|
||
fmt.Println(r.t.Get("|-Run post-update script..."))
|
||
}
|
||
if _, err := shell.Execf("curl -sSLm 10 https://%s/auto_update.sh | bash", r.conf.App.DownloadEndpoint); err != nil {
|
||
return errors.New(r.t.Get("|-Run post-update script failed: %v", err))
|
||
}
|
||
if _, err := shell.Execf(
|
||
`wget -O /etc/systemd/system/acepanel.service https://%s/acepanel.service && sed -i "s|/www|%s|g" /etc/systemd/system/acepanel.service`,
|
||
r.conf.App.DownloadEndpoint, app.Root,
|
||
); err != nil {
|
||
return errors.New(r.t.Get("|-Download panel service file failed: %v", err))
|
||
}
|
||
if _, err := shell.Execf("acepanel setting write version %s", version); err != nil {
|
||
return errors.New(r.t.Get("|-Write new panel version failed: %v", err))
|
||
}
|
||
if err := io.Mv(filepath.Join(app.Root, "panel/cli"), "/usr/local/sbin/acepanel"); err != nil {
|
||
return errors.New(r.t.Get("|-Move acepanel tool failed: %v", err))
|
||
}
|
||
|
||
if app.IsCli {
|
||
fmt.Println(r.t.Get("|-Set key file permissions..."))
|
||
}
|
||
_ = io.Chmod("/usr/local/sbin/acepanel", 0700)
|
||
_ = io.Chmod("/etc/systemd/system/acepanel.service", 0644)
|
||
_ = io.Chmod(filepath.Join(app.Root, "panel"), 0700)
|
||
|
||
if app.IsCli {
|
||
fmt.Println(r.t.Get("|-Update completed"))
|
||
}
|
||
|
||
_, _ = shell.Execf("systemctl daemon-reload")
|
||
_ = io.Remove("/tmp/panel-storage.zip")
|
||
_ = io.Remove(filepath.Join(app.Root, "panel/config.example.yml"))
|
||
if sqlDB, err := r.db.DB(); err == nil {
|
||
_ = sqlDB.Close()
|
||
}
|
||
tools.RestartPanel()
|
||
|
||
return nil
|
||
}
|