feat: backup and restore

This commit is contained in:
Jacky 2025-03-29 18:47:23 +08:00
parent 60f35ef863
commit 4cb4695e7b
No known key found for this signature in database
GPG key ID: 215C21B10DF38B4D
52 changed files with 9270 additions and 1439 deletions

169
internal/backup/backup.go Normal file
View file

@ -0,0 +1,169 @@
package backup
import (
"bytes"
"encoding/base64"
"fmt"
"os"
"path/filepath"
"time"
"github.com/0xJacky/Nginx-UI/internal/version"
"github.com/uozi-tech/cosy"
"github.com/uozi-tech/cosy/logger"
)
// Directory and file names
const (
BackupDirPrefix = "nginx-ui-backup-"
NginxUIDir = "nginx-ui"
NginxDir = "nginx"
HashInfoFile = "hash_info.txt"
NginxUIZipName = "nginx-ui.zip"
NginxZipName = "nginx.zip"
)
// BackupResult contains the results of a backup operation
type BackupResult struct {
BackupContent []byte `json:"-"` // Backup content as byte array
BackupName string `json:"name"` // Backup file name
AESKey string `json:"aes_key"` // Base64 encoded AES key
AESIv string `json:"aes_iv"` // Base64 encoded AES IV
}
// HashInfo contains hash information for verification
type HashInfo struct {
NginxUIHash string `json:"nginx_ui_hash"`
NginxHash string `json:"nginx_hash"`
Timestamp string `json:"timestamp"`
Version string `json:"version"`
}
// Backup creates a backup of nginx-ui configuration and database files,
// and nginx configuration directory, compressed into an encrypted archive
func Backup() (BackupResult, error) {
// Generate timestamps for filenames
timestamp := time.Now().Format("20060102-150405")
backupName := fmt.Sprintf("backup-%s.zip", timestamp)
// Generate AES key and IV
key, err := GenerateAESKey()
if err != nil {
return BackupResult{}, cosy.WrapErrorWithParams(ErrGenerateAESKey, err.Error())
}
iv, err := GenerateIV()
if err != nil {
return BackupResult{}, cosy.WrapErrorWithParams(ErrGenerateIV, err.Error())
}
// Create temporary directory for files to be archived
tempDir, err := os.MkdirTemp("", "nginx-ui-backup-*")
if err != nil {
return BackupResult{}, cosy.WrapErrorWithParams(ErrCreateTempDir, err.Error())
}
defer os.RemoveAll(tempDir)
// Create directories in temp
nginxUITempDir := filepath.Join(tempDir, NginxUIDir)
nginxTempDir := filepath.Join(tempDir, NginxDir)
if err := os.MkdirAll(nginxUITempDir, 0755); err != nil {
return BackupResult{}, cosy.WrapErrorWithParams(ErrCreateTempSubDir, err.Error())
}
if err := os.MkdirAll(nginxTempDir, 0755); err != nil {
return BackupResult{}, cosy.WrapErrorWithParams(ErrCreateTempSubDir, err.Error())
}
// Backup nginx-ui config and database to a directory
if err := backupNginxUIFiles(nginxUITempDir); err != nil {
return BackupResult{}, cosy.WrapErrorWithParams(ErrBackupNginxUI, err.Error())
}
// Backup nginx configs to a directory
if err := backupNginxFiles(nginxTempDir); err != nil {
return BackupResult{}, cosy.WrapErrorWithParams(ErrBackupNginx, err.Error())
}
// Create individual zip files for nginx-ui and nginx directories
nginxUIZipPath := filepath.Join(tempDir, NginxUIZipName)
nginxZipPath := filepath.Join(tempDir, NginxZipName)
// Create zip archives for each directory
if err := createZipArchive(nginxUIZipPath, nginxUITempDir); err != nil {
return BackupResult{}, cosy.WrapErrorWithParams(ErrCreateZipArchive, err.Error())
}
if err := createZipArchive(nginxZipPath, nginxTempDir); err != nil {
return BackupResult{}, cosy.WrapErrorWithParams(ErrCreateZipArchive, err.Error())
}
// Calculate hashes for the zip files
nginxUIHash, err := calculateFileHash(nginxUIZipPath)
if err != nil {
return BackupResult{}, cosy.WrapErrorWithParams(ErrCalculateHash, err.Error())
}
nginxHash, err := calculateFileHash(nginxZipPath)
if err != nil {
return BackupResult{}, cosy.WrapErrorWithParams(ErrCalculateHash, err.Error())
}
// Get current version information
versionInfo := version.GetVersionInfo()
// Create hash info file
hashInfo := HashInfo{
NginxUIHash: nginxUIHash,
NginxHash: nginxHash,
Timestamp: timestamp,
Version: versionInfo.Version,
}
// Write hash info to file
hashInfoPath := filepath.Join(tempDir, HashInfoFile)
if err := writeHashInfoFile(hashInfoPath, hashInfo); err != nil {
return BackupResult{}, cosy.WrapErrorWithParams(ErrCreateHashFile, err.Error())
}
// Encrypt the individual files
if err := encryptFile(hashInfoPath, key, iv); err != nil {
return BackupResult{}, cosy.WrapErrorWithParams(ErrEncryptFile, HashInfoFile)
}
if err := encryptFile(nginxUIZipPath, key, iv); err != nil {
return BackupResult{}, cosy.WrapErrorWithParams(ErrEncryptNginxUIDir, err.Error())
}
if err := encryptFile(nginxZipPath, key, iv); err != nil {
return BackupResult{}, cosy.WrapErrorWithParams(ErrEncryptNginxDir, err.Error())
}
// Remove the original directories to avoid duplicating them in the final archive
if err := os.RemoveAll(nginxUITempDir); err != nil {
return BackupResult{}, cosy.WrapErrorWithParams(ErrCleanupTempDir, err.Error())
}
if err := os.RemoveAll(nginxTempDir); err != nil {
return BackupResult{}, cosy.WrapErrorWithParams(ErrCleanupTempDir, err.Error())
}
// Create final zip file to memory buffer
var buffer bytes.Buffer
if err := createZipArchiveToBuffer(&buffer, tempDir); err != nil {
return BackupResult{}, cosy.WrapErrorWithParams(ErrCreateZipArchive, err.Error())
}
// Convert AES key and IV to base64 encoded strings
keyBase64 := base64.StdEncoding.EncodeToString(key)
ivBase64 := base64.StdEncoding.EncodeToString(iv)
// Return result
result := BackupResult{
BackupContent: buffer.Bytes(),
BackupName: backupName,
AESKey: keyBase64,
AESIv: ivBase64,
}
logger.Infof("Backup created successfully: %s", backupName)
return result, nil
}

View file

@ -0,0 +1,128 @@
package backup
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"io"
"os"
"github.com/uozi-tech/cosy"
)
// AESEncrypt encrypts data using AES-256-CBC
func AESEncrypt(data []byte, key []byte, iv []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, cosy.WrapErrorWithParams(ErrEncryptData, err.Error())
}
// Pad data to be a multiple of block size
padding := aes.BlockSize - (len(data) % aes.BlockSize)
padtext := make([]byte, len(data)+padding)
copy(padtext, data)
// PKCS#7 padding
for i := len(data); i < len(padtext); i++ {
padtext[i] = byte(padding)
}
// Create CBC encrypter
mode := cipher.NewCBCEncrypter(block, iv)
encrypted := make([]byte, len(padtext))
mode.CryptBlocks(encrypted, padtext)
return encrypted, nil
}
// AESDecrypt decrypts data using AES-256-CBC
func AESDecrypt(encrypted []byte, key []byte, iv []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, cosy.WrapErrorWithParams(ErrDecryptData, err.Error())
}
// Create CBC decrypter
mode := cipher.NewCBCDecrypter(block, iv)
decrypted := make([]byte, len(encrypted))
mode.CryptBlocks(decrypted, encrypted)
// Remove padding
padding := int(decrypted[len(decrypted)-1])
if padding < 1 || padding > aes.BlockSize {
return nil, ErrInvalidPadding
}
return decrypted[:len(decrypted)-padding], nil
}
// GenerateAESKey generates a random 32-byte AES key
func GenerateAESKey() ([]byte, error) {
key := make([]byte, 32) // 256-bit key
if _, err := io.ReadFull(rand.Reader, key); err != nil {
return nil, cosy.WrapErrorWithParams(ErrGenerateAESKey, err.Error())
}
return key, nil
}
// GenerateIV generates a random 16-byte initialization vector
func GenerateIV() ([]byte, error) {
iv := make([]byte, aes.BlockSize)
if _, err := io.ReadFull(rand.Reader, iv); err != nil {
return nil, cosy.WrapErrorWithParams(ErrGenerateIV, err.Error())
}
return iv, nil
}
// encryptFile encrypts a single file using AES encryption
func encryptFile(filePath string, key []byte, iv []byte) error {
// Read file content
data, err := os.ReadFile(filePath)
if err != nil {
return cosy.WrapErrorWithParams(ErrReadFile, filePath)
}
// Encrypt file content
encrypted, err := AESEncrypt(data, key, iv)
if err != nil {
return cosy.WrapErrorWithParams(ErrEncryptFile, filePath)
}
// Write encrypted content back
if err := os.WriteFile(filePath, encrypted, 0644); err != nil {
return cosy.WrapErrorWithParams(ErrWriteEncryptedFile, filePath)
}
return nil
}
// decryptFile decrypts a single file using AES decryption
func decryptFile(filePath string, key []byte, iv []byte) error {
// Read encrypted file content
encryptedData, err := os.ReadFile(filePath)
if err != nil {
return cosy.WrapErrorWithParams(ErrReadEncryptedFile, filePath)
}
// Decrypt file content
decryptedData, err := AESDecrypt(encryptedData, key, iv)
if err != nil {
return cosy.WrapErrorWithParams(ErrDecryptFile, filePath)
}
// Write decrypted content back
if err := os.WriteFile(filePath, decryptedData, 0644); err != nil {
return cosy.WrapErrorWithParams(ErrWriteDecryptedFile, filePath)
}
return nil
}
// EncodeToBase64 encodes byte slice to base64 string
func EncodeToBase64(data []byte) string {
return base64.StdEncoding.EncodeToString(data)
}
// DecodeFromBase64 decodes base64 string to byte slice
func DecodeFromBase64(encoded string) ([]byte, error) {
return base64.StdEncoding.DecodeString(encoded)
}

View file

@ -0,0 +1,76 @@
package backup
import (
"fmt"
"os"
"path/filepath"
"github.com/0xJacky/Nginx-UI/settings"
"github.com/uozi-tech/cosy"
"github.com/uozi-tech/cosy/logger"
cosysettings "github.com/uozi-tech/cosy/settings"
)
// backupNginxUIFiles backs up the nginx-ui configuration and database files
func backupNginxUIFiles(destDir string) error {
// Get config file path
configPath := cosysettings.ConfPath
if configPath == "" {
return ErrConfigPathEmpty
}
// Always save the config file as app.ini, regardless of its original name
destConfigPath := filepath.Join(destDir, "app.ini")
if err := copyFile(configPath, destConfigPath); err != nil {
return cosy.WrapErrorWithParams(ErrCopyConfigFile, err.Error())
}
// Get database file name and path
dbName := settings.DatabaseSettings.GetName()
dbFile := dbName + ".db"
// Database directory is the same as config file directory
dbDir := filepath.Dir(configPath)
dbPath := filepath.Join(dbDir, dbFile)
// Copy database file
if _, err := os.Stat(dbPath); err == nil {
// Database exists as file
destDBPath := filepath.Join(destDir, dbFile)
if err := copyFile(dbPath, destDBPath); err != nil {
return cosy.WrapErrorWithParams(ErrCopyDBFile, err.Error())
}
} else {
logger.Warn("Database file not found: %s", dbPath)
}
return nil
}
// backupNginxFiles backs up the nginx configuration directory
func backupNginxFiles(destDir string) error {
// Get nginx config directory
nginxConfigDir := settings.NginxSettings.ConfigDir
if nginxConfigDir == "" {
return ErrNginxConfigDirEmpty
}
// Copy nginx config directory
if err := copyDirectory(nginxConfigDir, destDir); err != nil {
return cosy.WrapErrorWithParams(ErrCopyNginxConfigDir, err.Error())
}
return nil
}
// writeHashInfoFile creates a hash information file for verification
func writeHashInfoFile(hashFilePath string, info HashInfo) error {
content := fmt.Sprintf("nginx-ui_hash: %s\nnginx_hash: %s\ntimestamp: %s\nversion: %s\n",
info.NginxUIHash, info.NginxHash, info.Timestamp, info.Version)
if err := os.WriteFile(hashFilePath, []byte(content), 0644); err != nil {
return cosy.WrapErrorWithParams(ErrCreateHashFile, err.Error())
}
return nil
}

View file

@ -0,0 +1,466 @@
package backup
import (
"os"
"path/filepath"
"testing"
"github.com/0xJacky/Nginx-UI/settings"
"github.com/stretchr/testify/assert"
cosylogger "github.com/uozi-tech/cosy/logger"
cosysettings "github.com/uozi-tech/cosy/settings"
)
func init() {
// Initialize logging system to avoid nil pointer exceptions during tests
cosylogger.Init("debug")
// Clean up backup files at the start of tests
cleanupBackupFiles()
}
// cleanupBackupFiles removes all backup files in the current directory
func cleanupBackupFiles() {
// Get current directory
dir, err := os.Getwd()
if err != nil {
return
}
// Delete all backup files
matches, err := filepath.Glob(filepath.Join(dir, "backup-*.zip"))
if err == nil {
for _, file := range matches {
os.Remove(file)
}
}
}
// setupTestEnvironment creates a temporary environment for testing
func setupTestEnvironment(t *testing.T) (string, func()) {
// Create temporary test directory
tempDir, err := os.MkdirTemp("", "backup-test-*")
assert.NoError(t, err)
// Set up necessary directories
nginxDir := filepath.Join(tempDir, "nginx")
nginxUIDir := filepath.Join(tempDir, "nginx-ui")
configDir := filepath.Join(tempDir, "config")
backupDir := filepath.Join(tempDir, "backup")
// Create directories
for _, dir := range []string{nginxDir, nginxUIDir, configDir, backupDir} {
err = os.MkdirAll(dir, 0755)
assert.NoError(t, err)
}
// Create some test files
testFiles := map[string]string{
filepath.Join(nginxDir, "nginx.conf"): "user nginx;\nworker_processes auto;\n",
filepath.Join(nginxUIDir, "config.json"): `{"version": "1.0", "settings": {"theme": "dark"}}`,
}
for file, content := range testFiles {
err = os.WriteFile(file, []byte(content), 0644)
assert.NoError(t, err)
}
// Save original configuration
origNginxConfigDir := settings.NginxSettings.ConfigDir
origNginxUIConfigPath := cosysettings.ConfPath
// Set test configuration
settings.NginxSettings.ConfigDir = nginxDir
cosysettings.ConfPath = filepath.Join(configDir, "config.ini")
// Return cleanup function
cleanup := func() {
// Restore original configuration
settings.NginxSettings.ConfigDir = origNginxConfigDir
cosysettings.ConfPath = origNginxUIConfigPath
// Delete temporary directory
os.RemoveAll(tempDir)
}
return tempDir, cleanup
}
// Test backup and restore functionality
func TestBackupAndRestore(t *testing.T) {
// Make sure backup files are cleaned up at the start and end of the test
cleanupBackupFiles()
defer cleanupBackupFiles()
// Create test configuration
tempDir, err := os.MkdirTemp("", "nginx-ui-backup-test-*")
assert.NoError(t, err)
defer os.RemoveAll(tempDir)
// Create config file
configPath := filepath.Join(tempDir, "config.ini")
testConfig := []byte("[app]\nName = Nginx UI Test\n")
err = os.WriteFile(configPath, testConfig, 0644)
assert.NoError(t, err)
// Create database file
dbName := settings.DatabaseSettings.GetName()
dbFile := dbName + ".db"
dbPath := filepath.Join(tempDir, dbFile)
testDB := []byte("CREATE TABLE users (id INT, name TEXT);")
err = os.WriteFile(dbPath, testDB, 0644)
assert.NoError(t, err)
// Create nginx directory
nginxConfigDir := filepath.Join(tempDir, "nginx")
err = os.MkdirAll(nginxConfigDir, 0755)
assert.NoError(t, err)
// Create test nginx config
testNginxContent := []byte("server {\n listen 80;\n server_name example.com;\n}\n")
err = os.WriteFile(filepath.Join(nginxConfigDir, "nginx.conf"), testNginxContent, 0644)
assert.NoError(t, err)
// Setup settings for testing
originalConfPath := cosysettings.ConfPath
originalNginxConfigDir := settings.NginxSettings.ConfigDir
cosysettings.ConfPath = configPath
settings.NginxSettings.ConfigDir = nginxConfigDir
// Restore original settings after test
defer func() {
cosysettings.ConfPath = originalConfPath
settings.NginxSettings.ConfigDir = originalNginxConfigDir
}()
// Run backup
result, err := Backup()
assert.NoError(t, err)
assert.NotEmpty(t, result.BackupContent)
assert.NotEmpty(t, result.BackupName)
assert.NotEmpty(t, result.AESKey)
assert.NotEmpty(t, result.AESIv)
// Save backup content to a temporary file for restore testing
backupPath := filepath.Join(tempDir, result.BackupName)
err = os.WriteFile(backupPath, result.BackupContent, 0644)
assert.NoError(t, err)
// Test restore functionality
restoreDir, err := os.MkdirTemp("", "nginx-ui-restore-test-*")
assert.NoError(t, err)
defer os.RemoveAll(restoreDir)
// Decode AES key and IV
aesKey, err := DecodeFromBase64(result.AESKey)
assert.NoError(t, err)
aesIv, err := DecodeFromBase64(result.AESIv)
assert.NoError(t, err)
// Perform restore
restoreResult, err := Restore(RestoreOptions{
BackupPath: backupPath,
AESKey: aesKey,
AESIv: aesIv,
RestoreDir: restoreDir,
RestoreNginx: true,
RestoreNginxUI: true,
VerifyHash: true,
})
assert.NoError(t, err)
assert.NotEmpty(t, restoreResult.RestoreDir)
// Verify restored directories
nginxUIDir := filepath.Join(restoreDir, NginxUIDir)
nginxDir := filepath.Join(restoreDir, NginxDir)
_, err = os.Stat(nginxUIDir)
assert.NoError(t, err)
_, err = os.Stat(nginxDir)
assert.NoError(t, err)
// Verify hash info exists
_, err = os.Stat(filepath.Join(restoreDir, HashInfoFile))
assert.NoError(t, err)
}
// Test AES encryption/decryption
func TestEncryptionDecryption(t *testing.T) {
// Test data
testData := []byte("This is a test message to encrypt and decrypt")
// Create temp dir for testing
testDir, err := os.MkdirTemp("", "nginx-ui-crypto-test-*")
assert.NoError(t, err)
defer os.RemoveAll(testDir)
// Create test file
testFile := filepath.Join(testDir, "test.txt")
err = os.WriteFile(testFile, testData, 0644)
assert.NoError(t, err)
// Generate AES key and IV
key, err := GenerateAESKey()
assert.NoError(t, err)
iv, err := GenerateIV()
assert.NoError(t, err)
// Test encrypt file
err = encryptFile(testFile, key, iv)
assert.NoError(t, err)
// Read encrypted data
encryptedData, err := os.ReadFile(testFile)
assert.NoError(t, err)
assert.NotEqual(t, string(testData), string(encryptedData))
// Test decrypt file
err = decryptFile(testFile, key, iv)
assert.NoError(t, err)
// Read decrypted data
decryptedData, err := os.ReadFile(testFile)
assert.NoError(t, err)
assert.Equal(t, string(testData), string(decryptedData))
}
// Test AES direct encryption/decryption
func TestAESEncryptDecrypt(t *testing.T) {
// Generate key and IV
key, err := GenerateAESKey()
assert.NoError(t, err)
iv, err := GenerateIV()
assert.NoError(t, err)
// Test data
original := []byte("This is a test message for encryption and decryption")
// Encrypt
encrypted, err := AESEncrypt(original, key, iv)
assert.NoError(t, err)
assert.NotEqual(t, original, encrypted)
// Decrypt
decrypted, err := AESDecrypt(encrypted, key, iv)
assert.NoError(t, err)
assert.Equal(t, original, decrypted)
}
// Test Base64 encoding/decoding
func TestEncodeDecodeBase64(t *testing.T) {
original := []byte("Test data for base64 encoding")
// Encode
encoded := EncodeToBase64(original)
// Decode
decoded, err := DecodeFromBase64(encoded)
assert.NoError(t, err)
assert.Equal(t, original, decoded)
}
func TestGenerateAESKey(t *testing.T) {
key, err := GenerateAESKey()
assert.NoError(t, err)
assert.Equal(t, 32, len(key))
}
func TestGenerateIV(t *testing.T) {
iv, err := GenerateIV()
assert.NoError(t, err)
assert.Equal(t, 16, len(iv))
}
func TestEncryptDecryptFile(t *testing.T) {
// Create temp directory
tempDir, err := os.MkdirTemp("", "encrypt-file-test-*")
assert.NoError(t, err)
defer os.RemoveAll(tempDir)
// Create test file
testFile := filepath.Join(tempDir, "test.txt")
testContent := []byte("This is test content for file encryption")
err = os.WriteFile(testFile, testContent, 0644)
assert.NoError(t, err)
// Generate key and IV
key, err := GenerateAESKey()
assert.NoError(t, err)
iv, err := GenerateIV()
assert.NoError(t, err)
// Encrypt file
err = encryptFile(testFile, key, iv)
assert.NoError(t, err)
// Read encrypted content
encryptedContent, err := os.ReadFile(testFile)
assert.NoError(t, err)
assert.NotEqual(t, testContent, encryptedContent)
// Decrypt file
err = decryptFile(testFile, key, iv)
assert.NoError(t, err)
// Read decrypted content
decryptedContent, err := os.ReadFile(testFile)
assert.NoError(t, err)
assert.Equal(t, testContent, decryptedContent)
}
func TestBackupRestore(t *testing.T) {
// Set up test environment
tempDir, cleanup := setupTestEnvironment(t)
defer cleanup()
// Create a config.ini file since it's required for the test
configDir := filepath.Join(tempDir, "config")
configPath := filepath.Join(configDir, "config.ini")
err := os.WriteFile(configPath, []byte("[app]\nName = Nginx UI Test\n"), 0644)
assert.NoError(t, err)
// Update Cosy settings path
originalConfPath := cosysettings.ConfPath
cosysettings.ConfPath = configPath
defer func() {
cosysettings.ConfPath = originalConfPath
}()
// Create backup
backupResult, err := Backup()
// If there's an error, log it but continue testing
if err != nil {
t.Logf("Backup failed with error: %v", err)
t.Fail()
return
}
assert.NotNil(t, backupResult.BackupContent)
assert.NotEmpty(t, backupResult.BackupName)
assert.NotEmpty(t, backupResult.AESKey)
assert.NotEmpty(t, backupResult.AESIv)
// Create temporary file for restore testing
backupPath := filepath.Join(tempDir, backupResult.BackupName)
err = os.WriteFile(backupPath, backupResult.BackupContent, 0644)
assert.NoError(t, err)
// Decode key and IV
key, err := DecodeFromBase64(backupResult.AESKey)
assert.NoError(t, err)
iv, err := DecodeFromBase64(backupResult.AESIv)
assert.NoError(t, err)
// Create restore directory
restoreDir := filepath.Join(tempDir, "restore")
err = os.MkdirAll(restoreDir, 0755)
assert.NoError(t, err)
// Create restore options
options := RestoreOptions{
BackupPath: backupPath,
AESKey: key,
AESIv: iv,
RestoreDir: restoreDir,
VerifyHash: true,
// Avoid modifying the system
RestoreNginx: false,
RestoreNginxUI: false,
}
// Test restore
result, err := Restore(options)
if err != nil {
t.Logf("Restore failed with error: %v", err)
t.Fail()
return
}
assert.Equal(t, restoreDir, result.RestoreDir)
// If hash verification is enabled, check the result
if options.VerifyHash {
assert.True(t, result.HashMatch, "Hash verification should pass")
}
}
func TestCreateZipArchive(t *testing.T) {
// Create temp directories
tempSourceDir, err := os.MkdirTemp("", "zip-source-test-*")
assert.NoError(t, err)
defer os.RemoveAll(tempSourceDir)
// Create some test files
testFiles := []string{"file1.txt", "file2.txt", "subdir/file3.txt"}
testContent := []byte("Test content")
for _, file := range testFiles {
filePath := filepath.Join(tempSourceDir, file)
dirPath := filepath.Dir(filePath)
err = os.MkdirAll(dirPath, 0755)
assert.NoError(t, err)
err = os.WriteFile(filePath, testContent, 0644)
assert.NoError(t, err)
}
// Create zip file
zipPath := filepath.Join(tempSourceDir, "test.zip")
err = createZipArchive(zipPath, tempSourceDir)
assert.NoError(t, err)
// Verify zip file was created
_, err = os.Stat(zipPath)
assert.NoError(t, err)
// Extract to new directory to verify contents
extractDir := filepath.Join(tempSourceDir, "extract")
err = os.MkdirAll(extractDir, 0755)
assert.NoError(t, err)
err = extractZipArchive(zipPath, extractDir)
assert.NoError(t, err)
// Verify extracted files
for _, file := range testFiles {
extractedPath := filepath.Join(extractDir, file)
content, err := os.ReadFile(extractedPath)
assert.NoError(t, err)
assert.Equal(t, testContent, content)
}
}
func TestHashCalculation(t *testing.T) {
// Create temp file
tempFile, err := os.CreateTemp("", "hash-test-*.txt")
assert.NoError(t, err)
defer os.Remove(tempFile.Name())
// Write content
testContent := []byte("Test content for hash calculation")
_, err = tempFile.Write(testContent)
assert.NoError(t, err)
tempFile.Close()
// Calculate hash
hash, err := calculateFileHash(tempFile.Name())
assert.NoError(t, err)
assert.NotEmpty(t, hash)
// Calculate again to verify consistency
hash2, err := calculateFileHash(tempFile.Name())
assert.NoError(t, err)
assert.Equal(t, hash, hash2)
// Modify file and check hash changes
err = os.WriteFile(tempFile.Name(), []byte("Modified content"), 0644)
assert.NoError(t, err)
hash3, err := calculateFileHash(tempFile.Name())
assert.NoError(t, err)
assert.NotEqual(t, hash, hash3)
}

View file

@ -0,0 +1,290 @@
package backup
import (
"archive/zip"
"bytes"
"crypto/sha256"
"encoding/hex"
"io"
"os"
"path/filepath"
"github.com/uozi-tech/cosy"
)
// createZipArchive creates a zip archive from a directory
func createZipArchive(zipPath, srcDir string) error {
// Create a new zip file
zipFile, err := os.Create(zipPath)
if err != nil {
return cosy.WrapErrorWithParams(ErrCreateZipFile, err.Error())
}
defer zipFile.Close()
// Create a new zip writer
zipWriter := zip.NewWriter(zipFile)
defer zipWriter.Close()
// Walk through all files in the source directory
err = filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Get relative path
relPath, err := filepath.Rel(srcDir, path)
if err != nil {
return err
}
// Skip if it's the source directory itself
if relPath == "." {
return nil
}
// Check if it's a symlink
if info.Mode()&os.ModeSymlink != 0 {
// Get target of symlink
linkTarget, err := os.Readlink(path)
if err != nil {
return cosy.WrapErrorWithParams(ErrReadSymlink, err.Error())
}
// Create symlink entry in zip
header := &zip.FileHeader{
Name: relPath,
Method: zip.Deflate,
}
header.SetMode(info.Mode())
writer, err := zipWriter.CreateHeader(header)
if err != nil {
return cosy.WrapErrorWithParams(ErrCreateZipEntry, err.Error())
}
// Write link target as content (common way to store symlinks in zip)
_, err = writer.Write([]byte(linkTarget))
if err != nil {
return cosy.WrapErrorWithParams(ErrCopyContent, relPath)
}
return nil
}
// Create zip header
header, err := zip.FileInfoHeader(info)
if err != nil {
return cosy.WrapErrorWithParams(ErrCreateZipHeader, err.Error())
}
// Set relative path as name
header.Name = relPath
if info.IsDir() {
header.Name += "/"
}
// Set compression method
header.Method = zip.Deflate
// Create zip entry writer
writer, err := zipWriter.CreateHeader(header)
if err != nil {
return cosy.WrapErrorWithParams(ErrCreateZipEntry, err.Error())
}
// Skip if it's a directory
if info.IsDir() {
return nil
}
// Open source file
source, err := os.Open(path)
if err != nil {
return cosy.WrapErrorWithParams(ErrOpenSourceFile, err.Error())
}
defer source.Close()
// Copy to zip
_, err = io.Copy(writer, source)
return err
})
return err
}
// createZipArchiveFromFiles creates a zip archive from a list of files
func createZipArchiveFromFiles(zipPath string, files []string) error {
// Create a new zip file
zipFile, err := os.Create(zipPath)
if err != nil {
return cosy.WrapErrorWithParams(ErrCreateZipFile, err.Error())
}
defer zipFile.Close()
// Create a new zip writer
zipWriter := zip.NewWriter(zipFile)
defer zipWriter.Close()
// Add each file to the zip
for _, file := range files {
// Get file info
info, err := os.Stat(file)
if err != nil {
return cosy.WrapErrorWithParams(ErrOpenSourceFile, err.Error())
}
// Create zip header
header, err := zip.FileInfoHeader(info)
if err != nil {
return cosy.WrapErrorWithParams(ErrCreateZipHeader, err.Error())
}
// Set base name as header name
header.Name = filepath.Base(file)
// Set compression method
header.Method = zip.Deflate
// Create zip entry writer
writer, err := zipWriter.CreateHeader(header)
if err != nil {
return cosy.WrapErrorWithParams(ErrCreateZipEntry, err.Error())
}
// Open source file
source, err := os.Open(file)
if err != nil {
return cosy.WrapErrorWithParams(ErrOpenSourceFile, err.Error())
}
defer source.Close()
// Copy to zip
_, err = io.Copy(writer, source)
if err != nil {
return cosy.WrapErrorWithParams(ErrCopyContent, file)
}
}
return nil
}
// calculateFileHash calculates the SHA-256 hash of a file
func calculateFileHash(filePath string) (string, error) {
// Open file
file, err := os.Open(filePath)
if err != nil {
return "", cosy.WrapErrorWithParams(ErrReadFile, filePath)
}
defer file.Close()
// Create hash
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return "", cosy.WrapErrorWithParams(ErrCalculateHash, err.Error())
}
// Return hex hash
return hex.EncodeToString(hash.Sum(nil)), nil
}
// createZipArchiveToBuffer creates a zip archive of files in the specified directory
// and writes the zip content to the provided buffer
func createZipArchiveToBuffer(buffer *bytes.Buffer, sourceDir string) error {
// Create a zip writer that writes to the buffer
zipWriter := zip.NewWriter(buffer)
defer zipWriter.Close()
// Walk through all files in the source directory
err := filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Skip the source directory itself
if path == sourceDir {
return nil
}
// Get the relative path to the source directory
relPath, err := filepath.Rel(sourceDir, path)
if err != nil {
return err
}
// Check if it's a symlink
if info.Mode()&os.ModeSymlink != 0 {
// Get target of symlink
linkTarget, err := os.Readlink(path)
if err != nil {
return cosy.WrapErrorWithParams(ErrReadSymlink, err.Error())
}
// Create symlink entry in zip
header := &zip.FileHeader{
Name: relPath,
Method: zip.Deflate,
}
header.SetMode(info.Mode())
writer, err := zipWriter.CreateHeader(header)
if err != nil {
return cosy.WrapErrorWithParams(ErrCreateZipEntry, err.Error())
}
// Write link target as content
_, err = writer.Write([]byte(linkTarget))
if err != nil {
return cosy.WrapErrorWithParams(ErrCopyContent, relPath)
}
return nil
}
// Create a zip header from the file info
header, err := zip.FileInfoHeader(info)
if err != nil {
return cosy.WrapErrorWithParams(ErrCreateZipHeader, err.Error())
}
// Set the name to be relative to the source directory
header.Name = relPath
// Set the compression method
if !info.IsDir() {
header.Method = zip.Deflate
}
// Create the entry in the zip file
writer, err := zipWriter.CreateHeader(header)
if err != nil {
return cosy.WrapErrorWithParams(ErrCreateZipEntry, err.Error())
}
// If it's a directory, we're done
if info.IsDir() {
return nil
}
// Open the source file
file, err := os.Open(path)
if err != nil {
return cosy.WrapErrorWithParams(ErrOpenSourceFile, err.Error())
}
defer file.Close()
// Copy the file contents to the zip entry
_, err = io.Copy(writer, file)
if err != nil {
return cosy.WrapErrorWithParams(ErrCopyContent, relPath)
}
return nil
})
if err != nil {
return err
}
// Close the zip writer to ensure all data is written
return zipWriter.Close()
}

83
internal/backup/errors.go Normal file
View file

@ -0,0 +1,83 @@
package backup
import (
"github.com/uozi-tech/cosy"
)
var (
errScope = cosy.NewErrorScope("backup")
// Backup errors
ErrCreateTempDir = errScope.New(4002, "Failed to create temporary directory")
ErrCreateTempSubDir = errScope.New(4003, "Failed to create temporary subdirectory")
ErrBackupNginxUI = errScope.New(4004, "Failed to backup Nginx UI files: {0}")
ErrBackupNginx = errScope.New(4005, "Failed to backup Nginx config files: {0}")
ErrCreateHashFile = errScope.New(4006, "Failed to create hash info file: {0}")
ErrEncryptNginxUIDir = errScope.New(4007, "Failed to encrypt Nginx UI directory: {0}")
ErrEncryptNginxDir = errScope.New(4008, "Failed to encrypt Nginx directory: {0}")
ErrCreateZipArchive = errScope.New(4009, "Failed to create zip archive: {0}")
ErrGenerateAESKey = errScope.New(4011, "Failed to generate AES key: {0}")
ErrGenerateIV = errScope.New(4012, "Failed to generate initialization vector: {0}")
ErrCreateBackupFile = errScope.New(4013, "Failed to create backup file: {0}")
ErrCleanupTempDir = errScope.New(4014, "Failed to cleanup temporary directory: {0}")
// Config and file errors
ErrConfigPathEmpty = errScope.New(4101, "Config path is empty")
ErrCopyConfigFile = errScope.New(4102, "Failed to copy config file: {0}")
ErrCopyDBDir = errScope.New(4103, "Failed to copy database directory: {0}")
ErrCopyDBFile = errScope.New(4104, "Failed to copy database file: {0}")
ErrCalculateHash = errScope.New(4105, "Failed to calculate hash: {0}")
ErrNginxConfigDirEmpty = errScope.New(4106, "Nginx config directory is not set")
ErrCopyNginxConfigDir = errScope.New(4107, "Failed to copy Nginx config directory: {0}")
ErrReadSymlink = errScope.New(4108, "Failed to read symlink: {0}")
// Encryption and decryption errors
ErrReadFile = errScope.New(4201, "Failed to read file: {0}")
ErrEncryptFile = errScope.New(4202, "Failed to encrypt file: {0}")
ErrWriteEncryptedFile = errScope.New(4203, "Failed to write encrypted file: {0}")
ErrEncryptData = errScope.New(4204, "Failed to encrypt data: {0}")
ErrDecryptData = errScope.New(4205, "Failed to decrypt data: {0}")
ErrInvalidPadding = errScope.New(4206, "Invalid padding in decrypted data")
// Zip file errors
ErrCreateZipFile = errScope.New(4301, "Failed to create zip file: {0}")
ErrCreateZipEntry = errScope.New(4302, "Failed to create zip entry: {0}")
ErrOpenSourceFile = errScope.New(4303, "Failed to open source file: {0}")
ErrCreateZipHeader = errScope.New(4304, "Failed to create zip header: {0}")
ErrCopyContent = errScope.New(4305, "Failed to copy file content: {0}")
ErrWriteZipBuffer = errScope.New(4306, "Failed to write to zip buffer: {0}")
// Restore errors
ErrCreateRestoreDir = errScope.New(4501, "Failed to create restore directory: {0}")
ErrExtractArchive = errScope.New(4505, "Failed to extract archive: {0}")
ErrDecryptNginxUIDir = errScope.New(4506, "Failed to decrypt Nginx UI directory: {0}")
ErrDecryptNginxDir = errScope.New(4507, "Failed to decrypt Nginx directory: {0}")
ErrVerifyHashes = errScope.New(4508, "Failed to verify hashes: {0}")
ErrRestoreNginxConfigs = errScope.New(4509, "Failed to restore Nginx configs: {0}")
ErrRestoreNginxUIFiles = errScope.New(4510, "Failed to restore Nginx UI files: {0}")
ErrBackupFileNotFound = errScope.New(4511, "Backup file not found: {0}")
ErrInvalidSecurityToken = errScope.New(4512, "Invalid security token format")
ErrInvalidAESKey = errScope.New(4513, "Invalid AES key format: {0}")
ErrInvalidAESIV = errScope.New(4514, "Invalid AES IV format: {0}")
// Zip extraction errors
ErrOpenZipFile = errScope.New(4601, "Failed to open zip file: {0}")
ErrCreateDir = errScope.New(4602, "Failed to create directory: {0}")
ErrCreateParentDir = errScope.New(4603, "Failed to create parent directory: {0}")
ErrCreateFile = errScope.New(4604, "Failed to create file: {0}")
ErrOpenZipEntry = errScope.New(4605, "Failed to open zip entry: {0}")
ErrCreateSymlink = errScope.New(4606, "Failed to create symbolic link: {0}")
ErrInvalidFilePath = errScope.New(4607, "Invalid file path: {0}")
ErrEvalSymlinks = errScope.New(4608, "Failed to evaluate symbolic links: {0}")
// Decryption errors
ErrReadEncryptedFile = errScope.New(4701, "Failed to read encrypted file: {0}")
ErrDecryptFile = errScope.New(4702, "Failed to decrypt file: {0}")
ErrWriteDecryptedFile = errScope.New(4703, "Failed to write decrypted file: {0}")
// Hash verification errors
ErrReadHashFile = errScope.New(4801, "Failed to read hash info file: {0}")
ErrCalculateUIHash = errScope.New(4802, "Failed to calculate Nginx UI hash: {0}")
ErrCalculateNginxHash = errScope.New(4803, "Failed to calculate Nginx hash: {0}")
ErrHashMismatch = errScope.New(4804, "Hash verification failed: file integrity compromised")
)

369
internal/backup/restore.go Normal file
View file

@ -0,0 +1,369 @@
package backup
import (
"archive/zip"
"io"
"os"
"path/filepath"
"strings"
"github.com/0xJacky/Nginx-UI/internal/nginx"
"github.com/0xJacky/Nginx-UI/settings"
"github.com/uozi-tech/cosy"
cosysettings "github.com/uozi-tech/cosy/settings"
)
// RestoreResult contains the results of a restore operation
type RestoreResult struct {
RestoreDir string
NginxUIRestored bool
NginxRestored bool
HashMatch bool
}
// RestoreOptions contains options for restore operation
type RestoreOptions struct {
BackupPath string
AESKey []byte
AESIv []byte
RestoreDir string
RestoreNginx bool
VerifyHash bool
RestoreNginxUI bool
}
// Restore restores data from a backup archive
func Restore(options RestoreOptions) (RestoreResult, error) {
// Create restore directory if it doesn't exist
if err := os.MkdirAll(options.RestoreDir, 0755); err != nil {
return RestoreResult{}, cosy.WrapErrorWithParams(ErrCreateRestoreDir, err.Error())
}
// Extract main archive to restore directory
if err := extractZipArchive(options.BackupPath, options.RestoreDir); err != nil {
return RestoreResult{}, cosy.WrapErrorWithParams(ErrExtractArchive, err.Error())
}
// Decrypt the extracted files
hashInfoPath := filepath.Join(options.RestoreDir, HashInfoFile)
nginxUIZipPath := filepath.Join(options.RestoreDir, NginxUIZipName)
nginxZipPath := filepath.Join(options.RestoreDir, NginxZipName)
// Decrypt hash info file
if err := decryptFile(hashInfoPath, options.AESKey, options.AESIv); err != nil {
return RestoreResult{}, cosy.WrapErrorWithParams(ErrDecryptFile, HashInfoFile)
}
// Decrypt nginx-ui.zip
if err := decryptFile(nginxUIZipPath, options.AESKey, options.AESIv); err != nil {
return RestoreResult{}, cosy.WrapErrorWithParams(ErrDecryptNginxUIDir, err.Error())
}
// Decrypt nginx.zip
if err := decryptFile(nginxZipPath, options.AESKey, options.AESIv); err != nil {
return RestoreResult{}, cosy.WrapErrorWithParams(ErrDecryptNginxDir, err.Error())
}
// Extract zip files to subdirectories
nginxUIDir := filepath.Join(options.RestoreDir, NginxUIDir)
nginxDir := filepath.Join(options.RestoreDir, NginxDir)
if err := os.MkdirAll(nginxUIDir, 0755); err != nil {
return RestoreResult{}, cosy.WrapErrorWithParams(ErrCreateDir, nginxUIDir)
}
if err := os.MkdirAll(nginxDir, 0755); err != nil {
return RestoreResult{}, cosy.WrapErrorWithParams(ErrCreateDir, nginxDir)
}
// Extract nginx-ui.zip to nginx-ui directory
if err := extractZipArchive(nginxUIZipPath, nginxUIDir); err != nil {
return RestoreResult{}, cosy.WrapErrorWithParams(ErrExtractArchive, "nginx-ui.zip")
}
// Extract nginx.zip to nginx directory
if err := extractZipArchive(nginxZipPath, nginxDir); err != nil {
return RestoreResult{}, cosy.WrapErrorWithParams(ErrExtractArchive, "nginx.zip")
}
result := RestoreResult{
RestoreDir: options.RestoreDir,
NginxUIRestored: false,
NginxRestored: false,
HashMatch: false,
}
// Verify hashes if requested
if options.VerifyHash {
hashMatch, err := verifyHashes(options.RestoreDir, nginxUIZipPath, nginxZipPath)
if err != nil {
return result, cosy.WrapErrorWithParams(ErrVerifyHashes, err.Error())
}
result.HashMatch = hashMatch
}
// Restore nginx configs if requested
if options.RestoreNginx {
if err := restoreNginxConfigs(nginxDir); err != nil {
return result, cosy.WrapErrorWithParams(ErrRestoreNginxConfigs, err.Error())
}
result.NginxRestored = true
}
// Restore nginx-ui config if requested
if options.RestoreNginxUI {
if err := restoreNginxUIConfig(nginxUIDir); err != nil {
return result, cosy.WrapErrorWithParams(ErrBackupNginxUI, err.Error())
}
result.NginxUIRestored = true
}
return result, nil
}
// extractZipArchive extracts a zip archive to the specified directory
func extractZipArchive(zipPath, destDir string) error {
reader, err := zip.OpenReader(zipPath)
if err != nil {
return cosy.WrapErrorWithParams(ErrOpenZipFile, err.Error())
}
defer reader.Close()
for _, file := range reader.File {
err := extractZipFile(file, destDir)
if err != nil {
return err
}
}
return nil
}
// extractZipFile extracts a single file from a zip archive
func extractZipFile(file *zip.File, destDir string) error {
// Check for directory traversal elements in the file name
if strings.Contains(file.Name, "..") {
return cosy.WrapErrorWithParams(ErrInvalidFilePath, file.Name)
}
// Create directory path if needed
filePath := filepath.Join(destDir, file.Name)
// Ensure the resulting file path is within the destination directory
destDirAbs, err := filepath.Abs(destDir)
if err != nil {
return cosy.WrapErrorWithParams(ErrInvalidFilePath, "cannot resolve destination path")
}
filePathAbs, err := filepath.Abs(filePath)
if err != nil {
return cosy.WrapErrorWithParams(ErrInvalidFilePath, file.Name)
}
if !strings.HasPrefix(filePathAbs, destDirAbs+string(os.PathSeparator)) {
return cosy.WrapErrorWithParams(ErrInvalidFilePath, file.Name)
}
if file.FileInfo().IsDir() {
if err := os.MkdirAll(filePath, file.Mode()); err != nil {
return cosy.WrapErrorWithParams(ErrCreateDir, filePath)
}
return nil
}
// Create parent directory if needed
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
return cosy.WrapErrorWithParams(ErrCreateParentDir, filePath)
}
// Check if this is a symlink by examining mode bits
if file.Mode()&os.ModeSymlink != 0 {
// Open source file in zip to read the link target
srcFile, err := file.Open()
if err != nil {
return cosy.WrapErrorWithParams(ErrOpenZipEntry, file.Name)
}
defer srcFile.Close()
// Read the link target
linkTargetBytes, err := io.ReadAll(srcFile)
if err != nil {
return cosy.WrapErrorWithParams(ErrReadSymlink, file.Name)
}
linkTarget := string(linkTargetBytes)
// Verify the link target doesn't escape the destination directory
absLinkTarget := filepath.Clean(filepath.Join(filepath.Dir(filePath), linkTarget))
if !strings.HasPrefix(absLinkTarget, destDirAbs+string(os.PathSeparator)) {
return cosy.WrapErrorWithParams(ErrInvalidFilePath, linkTarget)
}
// Remove any existing file/link at the target path
_ = os.Remove(filePath)
// Create the symlink
if err := os.Symlink(linkTarget, filePath); err != nil {
return cosy.WrapErrorWithParams(ErrCreateSymlink, file.Name)
}
// Verify the resolved symlink path is within destination directory
resolvedPath, err := filepath.EvalSymlinks(filePath)
if err != nil {
return cosy.WrapErrorWithParams(ErrEvalSymlinks, filePath)
}
resolvedPathAbs, err := filepath.Abs(resolvedPath)
if err != nil {
return cosy.WrapErrorWithParams(ErrInvalidFilePath, resolvedPath)
}
if !strings.HasPrefix(resolvedPathAbs, destDirAbs+string(os.PathSeparator)) {
// Remove the symlink if it points outside the destination directory
_ = os.Remove(filePath)
return cosy.WrapErrorWithParams(ErrInvalidFilePath, resolvedPath)
}
return nil
}
// Create file
destFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode())
if err != nil {
return cosy.WrapErrorWithParams(ErrCreateFile, filePath)
}
defer destFile.Close()
// Open source file in zip
srcFile, err := file.Open()
if err != nil {
return cosy.WrapErrorWithParams(ErrOpenZipEntry, file.Name)
}
defer srcFile.Close()
// Copy content
if _, err := io.Copy(destFile, srcFile); err != nil {
return cosy.WrapErrorWithParams(ErrCopyContent, file.Name)
}
return nil
}
// verifyHashes verifies the hashes of the extracted zip files
func verifyHashes(restoreDir, nginxUIZipPath, nginxZipPath string) (bool, error) {
hashFile := filepath.Join(restoreDir, HashInfoFile)
hashContent, err := os.ReadFile(hashFile)
if err != nil {
return false, cosy.WrapErrorWithParams(ErrReadHashFile, err.Error())
}
hashInfo := parseHashInfo(string(hashContent))
// Calculate hash for nginx-ui.zip
nginxUIHash, err := calculateFileHash(nginxUIZipPath)
if err != nil {
return false, cosy.WrapErrorWithParams(ErrCalculateUIHash, err.Error())
}
// Calculate hash for nginx.zip
nginxHash, err := calculateFileHash(nginxZipPath)
if err != nil {
return false, cosy.WrapErrorWithParams(ErrCalculateNginxHash, err.Error())
}
// Verify hashes
return (hashInfo.NginxUIHash == nginxUIHash && hashInfo.NginxHash == nginxHash), nil
}
// parseHashInfo parses hash info from content string
func parseHashInfo(content string) HashInfo {
info := HashInfo{}
lines := strings.Split(content, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
switch key {
case "nginx-ui_hash":
info.NginxUIHash = value
case "nginx_hash":
info.NginxHash = value
case "timestamp":
info.Timestamp = value
case "version":
info.Version = value
}
}
return info
}
// restoreNginxConfigs restores nginx configuration files
func restoreNginxConfigs(nginxBackupDir string) error {
destDir := nginx.GetConfPath()
if destDir == "" {
return ErrNginxConfigDirEmpty
}
// Remove all contents in the destination directory first
// Read directory entries
entries, err := os.ReadDir(destDir)
if err != nil {
return cosy.WrapErrorWithParams(ErrCopyNginxConfigDir, "failed to read directory: "+err.Error())
}
// Remove each entry
for _, entry := range entries {
entryPath := filepath.Join(destDir, entry.Name())
err := os.RemoveAll(entryPath)
if err != nil {
return cosy.WrapErrorWithParams(ErrCopyNginxConfigDir, "failed to remove: "+err.Error())
}
}
// Copy files from backup to nginx config directory
if err := copyDirectory(nginxBackupDir, destDir); err != nil {
return err
}
return nil
}
// restoreNginxUIConfig restores nginx-ui configuration files
func restoreNginxUIConfig(nginxUIBackupDir string) error {
// Get config directory
configDir := filepath.Dir(cosysettings.ConfPath)
if configDir == "" {
return ErrConfigPathEmpty
}
// Restore app.ini to the configured location
srcConfigPath := filepath.Join(nginxUIBackupDir, "app.ini")
if err := copyFile(srcConfigPath, cosysettings.ConfPath); err != nil {
return err
}
// Restore database file if exists
dbName := settings.DatabaseSettings.GetName()
srcDBPath := filepath.Join(nginxUIBackupDir, dbName+".db")
destDBPath := filepath.Join(configDir, dbName+".db")
// Only attempt to copy if database file exists in backup
if _, err := os.Stat(srcDBPath); err == nil {
if err := copyFile(srcDBPath, destDBPath); err != nil {
return err
}
}
return nil
}

85
internal/backup/utils.go Normal file
View file

@ -0,0 +1,85 @@
package backup
import (
"io"
"os"
"path/filepath"
"github.com/uozi-tech/cosy"
)
// copyFile copies a file from src to dst
func copyFile(src, dst string) error {
// Open source file
source, err := os.Open(src)
if err != nil {
return err
}
defer source.Close()
// Create destination file
destination, err := os.Create(dst)
if err != nil {
return err
}
defer destination.Close()
// Copy content
_, err = io.Copy(destination, source)
return err
}
// copyDirectory copies a directory recursively from src to dst
func copyDirectory(src, dst string) error {
// Check if source is a directory
srcInfo, err := os.Stat(src)
if err != nil {
return err
}
if !srcInfo.IsDir() {
return cosy.WrapErrorWithParams(ErrCopyNginxConfigDir, "%s is not a directory", src)
}
// Create destination directory
if err := os.MkdirAll(dst, srcInfo.Mode()); err != nil {
return err
}
// Walk through source directory
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Calculate relative path
relPath, err := filepath.Rel(src, path)
if err != nil {
return err
}
if relPath == "." {
return nil
}
// Create target path
targetPath := filepath.Join(dst, relPath)
// Check if it's a symlink
if info.Mode()&os.ModeSymlink != 0 {
// Read the link
linkTarget, err := os.Readlink(path)
if err != nil {
return err
}
// Create symlink at target path
return os.Symlink(linkTarget, targetPath)
}
// If it's a directory, create it
if info.IsDir() {
return os.MkdirAll(targetPath, info.Mode())
}
// If it's a file, copy it
return copyFile(path, targetPath)
})
}

View file

@ -0,0 +1,117 @@
package backup
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/0xJacky/Nginx-UI/internal/version"
"github.com/0xJacky/Nginx-UI/settings"
"github.com/stretchr/testify/assert"
cosysettings "github.com/uozi-tech/cosy/settings"
)
// TestBackupVersion verifies that the backup file contains correct version information
func TestBackupVersion(t *testing.T) {
// Make sure backup files are cleaned up at the start and end of the test
cleanupBackupFiles()
defer cleanupBackupFiles()
// Create test configuration
tempDir, err := os.MkdirTemp("", "nginx-ui-backup-version-test-*")
assert.NoError(t, err)
defer os.RemoveAll(tempDir)
// Create config file
configPath := filepath.Join(tempDir, "config.ini")
testConfig := []byte("[app]\nName = Nginx UI Test\n")
err = os.WriteFile(configPath, testConfig, 0644)
assert.NoError(t, err)
// Create database file
dbName := settings.DatabaseSettings.GetName()
dbFile := dbName + ".db"
dbPath := filepath.Join(tempDir, dbFile)
testDB := []byte("CREATE TABLE users (id INT, name TEXT);")
err = os.WriteFile(dbPath, testDB, 0644)
assert.NoError(t, err)
// Create nginx directory
nginxConfigDir := filepath.Join(tempDir, "nginx")
err = os.MkdirAll(nginxConfigDir, 0755)
assert.NoError(t, err)
// Create nginx config
testNginxContent := []byte("server {\n listen 80;\n server_name example.com;\n}\n")
err = os.WriteFile(filepath.Join(nginxConfigDir, "nginx.conf"), testNginxContent, 0644)
assert.NoError(t, err)
// Setup test environment
originalConfPath := cosysettings.ConfPath
originalNginxConfigDir := settings.NginxSettings.ConfigDir
cosysettings.ConfPath = configPath
settings.NginxSettings.ConfigDir = nginxConfigDir
// Restore original settings after test
defer func() {
cosysettings.ConfPath = originalConfPath
settings.NginxSettings.ConfigDir = originalNginxConfigDir
}()
// Run backup
result, err := Backup()
assert.NoError(t, err)
assert.NotEmpty(t, result.BackupContent)
assert.NotEmpty(t, result.BackupName)
assert.NotEmpty(t, result.AESKey)
assert.NotEmpty(t, result.AESIv)
// Save backup content to temporary file for restore testing
backupFile := filepath.Join(tempDir, result.BackupName)
err = os.WriteFile(backupFile, result.BackupContent, 0644)
assert.NoError(t, err)
// Decode AES key and IV
key, err := DecodeFromBase64(result.AESKey)
assert.NoError(t, err)
iv, err := DecodeFromBase64(result.AESIv)
assert.NoError(t, err)
// Use the Restore function to extract and verify
restoreDir, err := os.MkdirTemp("", "nginx-ui-restore-version-test-*")
assert.NoError(t, err)
defer os.RemoveAll(restoreDir)
restoreResult, err := Restore(RestoreOptions{
BackupPath: backupFile,
AESKey: key,
AESIv: iv,
RestoreDir: restoreDir,
VerifyHash: true,
RestoreNginx: false,
RestoreNginxUI: false,
})
assert.NoError(t, err)
assert.True(t, restoreResult.HashMatch, "Hash should match")
// Check hash_info.txt file
hashInfoPath := filepath.Join(restoreDir, HashInfoFile)
hashInfoContent, err := os.ReadFile(hashInfoPath)
assert.NoError(t, err)
// Verify version information
versionInfo := version.GetVersionInfo()
expectedVersion := versionInfo.Version
// Check if hash_info.txt contains version info
hashInfoStr := string(hashInfoContent)
t.Logf("Hash info content: %s", hashInfoStr)
assert.True(t, strings.Contains(hashInfoStr, "version: "), "Hash info should contain version field")
// Parse hash_info.txt content
info := parseHashInfo(hashInfoStr)
assert.Equal(t, expectedVersion, info.Version, "Backup version should match current version")
}