feat: comprehensive debug logging across all controller modules

Add detailed [DEBUG] logging to every controller module when
logging.level is set to "debug". Each module with stateful debug
uses SetDebug(bool) wired from main.go. Covers stacks, backup,
cloudflare, integrations, system, monitor, settings, scheduler,
web handlers, storage, metrics, API, selfupdate, and assets.

Also includes the app export/import (.fab bundles) feature from
v0.32.0 and its debug page integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 18:14:43 +01:00
parent f6caea8067
commit 95c821deb2
54 changed files with 5015 additions and 82 deletions
+228
View File
@@ -0,0 +1,228 @@
package appexport
import (
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"errors"
"fmt"
"io"
"os"
"golang.org/x/crypto/scrypt"
)
const (
magicHeader = "FABE" // Felhom App Bundle Encrypted
scryptN = 1 << 15 // 32768
scryptR = 8
scryptP = 1
saltSize = 32
aesKeySize = 32
hmacKeySize = 32
ivSize = aes.BlockSize // 16
)
// deriveKeys derives an AES-256 key and HMAC-SHA256 key from password + salt.
func deriveKeys(password string, salt []byte) (aesKey, hmacKey []byte, err error) {
derived, err := scrypt.Key([]byte(password), salt, scryptN, scryptR, scryptP, aesKeySize+hmacKeySize)
if err != nil {
return nil, nil, err
}
return derived[:aesKeySize], derived[aesKeySize:], nil
}
// IsEncryptedFAB checks if a file starts with the "FABE" magic header.
func IsEncryptedFAB(path string) (bool, error) {
f, err := os.Open(path)
if err != nil {
return false, err
}
defer f.Close()
magic := make([]byte, 4)
n, err := f.Read(magic)
if err != nil || n < 4 {
return false, nil
}
return string(magic) == magicHeader, nil
}
// EncryptFile encrypts a plaintext file with a password.
// Uses AES-256-CTR + HMAC-SHA256 with scrypt key derivation.
// Format: "FABE" (4) || salt (32) || IV (16) || encrypted_data || HMAC-SHA256 (32)
func EncryptFile(inputPath, outputPath, password string) error {
in, err := os.Open(inputPath)
if err != nil {
return fmt.Errorf("open input: %w", err)
}
defer in.Close()
out, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("create output: %w", err)
}
defer out.Close()
salt := make([]byte, saltSize)
if _, err := rand.Read(salt); err != nil {
return fmt.Errorf("generating salt: %w", err)
}
iv := make([]byte, ivSize)
if _, err := rand.Read(iv); err != nil {
return fmt.Errorf("generating IV: %w", err)
}
aesKey, hmKey, err := deriveKeys(password, salt)
if err != nil {
return fmt.Errorf("deriving keys: %w", err)
}
block, err := aes.NewCipher(aesKey)
if err != nil {
return err
}
stream := cipher.NewCTR(block, iv)
mac := hmac.New(sha256.New, hmKey)
// Write header (magic is NOT in HMAC; salt + IV are)
if _, err := out.Write([]byte(magicHeader)); err != nil {
return err
}
mac.Write(salt)
if _, err := out.Write(salt); err != nil {
return err
}
mac.Write(iv)
if _, err := out.Write(iv); err != nil {
return err
}
// Encrypt and stream data
buf := make([]byte, 64*1024)
for {
n, readErr := in.Read(buf)
if n > 0 {
encrypted := make([]byte, n)
stream.XORKeyStream(encrypted, buf[:n])
mac.Write(encrypted)
if _, err := out.Write(encrypted); err != nil {
return err
}
}
if readErr == io.EOF {
break
}
if readErr != nil {
return fmt.Errorf("read: %w", readErr)
}
}
// Append HMAC tag
if _, err := out.Write(mac.Sum(nil)); err != nil {
return err
}
return out.Sync()
}
// DecryptFile decrypts an encrypted .fab file with a password.
// Returns a clear error if the password is wrong or the file is corrupted.
func DecryptFile(inputPath, outputPath, password string) error {
in, err := os.Open(inputPath)
if err != nil {
return fmt.Errorf("open input: %w", err)
}
defer in.Close()
// Verify magic header
magic := make([]byte, 4)
if _, err := io.ReadFull(in, magic); err != nil {
return fmt.Errorf("reading header: %w", err)
}
if string(magic) != magicHeader {
return errors.New("not an encrypted FAB file")
}
// Read salt and IV
salt := make([]byte, saltSize)
if _, err := io.ReadFull(in, salt); err != nil {
return fmt.Errorf("reading salt: %w", err)
}
iv := make([]byte, ivSize)
if _, err := io.ReadFull(in, iv); err != nil {
return fmt.Errorf("reading IV: %w", err)
}
// Calculate data size (total - header - HMAC tag)
stat, err := in.Stat()
if err != nil {
return err
}
headerSize := int64(4 + saltSize + ivSize)
tagSize := int64(sha256.Size)
dataSize := stat.Size() - headerSize - tagSize
if dataSize < 0 {
return errors.New("file too small to be valid")
}
aesKey, hmKey, err := deriveKeys(password, salt)
if err != nil {
return fmt.Errorf("deriving keys: %w", err)
}
mac := hmac.New(sha256.New, hmKey)
mac.Write(salt)
mac.Write(iv)
block, err := aes.NewCipher(aesKey)
if err != nil {
return err
}
stream := cipher.NewCTR(block, iv)
out, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("create output: %w", err)
}
defer out.Close()
// Decrypt data section
buf := make([]byte, 64*1024)
remaining := dataSize
for remaining > 0 {
toRead := int64(len(buf))
if toRead > remaining {
toRead = remaining
}
n, readErr := in.Read(buf[:toRead])
if n > 0 {
mac.Write(buf[:n])
decrypted := make([]byte, n)
stream.XORKeyStream(decrypted, buf[:n])
if _, err := out.Write(decrypted); err != nil {
return err
}
remaining -= int64(n)
}
if readErr == io.EOF {
break
}
if readErr != nil {
return fmt.Errorf("read: %w", readErr)
}
}
// Verify HMAC tag
storedMAC := make([]byte, sha256.Size)
if _, err := io.ReadFull(in, storedMAC); err != nil {
return fmt.Errorf("reading HMAC: %w", err)
}
if !hmac.Equal(mac.Sum(nil), storedMAC) {
os.Remove(outputPath)
return errors.New("jelszó hibás vagy a fájl sérült")
}
return out.Sync()
}
+177
View File
@@ -0,0 +1,177 @@
package appexport
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// ExportEstimate holds pre-export size and space estimation.
type ExportEstimate struct {
ConfigSizeBytes int64 `json:"config_size_bytes"`
ConfigSizeHuman string `json:"config_size_human"`
DataSizeBytes int64 `json:"data_size_bytes"`
DataSizeHuman string `json:"data_size_human"`
TotalSizeBytes int64 `json:"total_size_bytes"`
TotalSizeHuman string `json:"total_size_human"`
EstimatedMinutes int `json:"estimated_minutes"`
DestFreeBytes int64 `json:"dest_free_bytes"`
DestFreeHuman string `json:"dest_free_human"`
FitsOnDest bool `json:"fits_on_dest"`
}
// EstimateExport calculates size estimates for an app export.
func (e *Exporter) EstimateExport(stackName, destDrive string) (*ExportEstimate, error) {
stackDir, ok := e.provider.GetStackDir(stackName)
if !ok {
return nil, fmt.Errorf("stack %q not found", stackName)
}
e.debugf("EstimateExport: stack=%s stackDir=%s destDrive=%s", stackName, stackDir, destDrive)
est := &ExportEstimate{}
// Config size: sum of all files in the stack directory
est.ConfigSizeBytes = dirSize(stackDir)
est.ConfigSizeHuman = humanizeBytes(est.ConfigSizeBytes)
e.debugf("EstimateExport: configSize=%s (%d bytes)", est.ConfigSizeHuman, est.ConfigSizeBytes)
// Data size: HDD bind mounts or Docker volumes
if e.provider.GetStackNeedsHDD(stackName) {
mounts := e.provider.GetStackHDDMounts(stackName)
e.debugf("EstimateExport: HDD mounts: %v", mounts)
for _, mount := range mounts {
mountSize := duBytes(mount)
e.debugf("EstimateExport: mount %s = %s", mount, humanizeBytes(mountSize))
est.DataSizeBytes += mountSize
}
} else {
volumes := e.provider.GetDockerVolumes(stackName)
e.debugf("EstimateExport: Docker volumes: %v", volumes)
for _, vol := range volumes {
volSize := dockerVolumeSize(vol)
e.debugf("EstimateExport: volume %s = %s", vol, humanizeBytes(volSize))
est.DataSizeBytes += volSize
}
}
est.DataSizeHuman = humanizeBytes(est.DataSizeBytes)
est.TotalSizeBytes = est.ConfigSizeBytes + est.DataSizeBytes
est.TotalSizeHuman = humanizeBytes(est.TotalSizeBytes)
// Rough time estimate: ~500 MB/min for HDDs, minimum 1 minute
minutes := int(est.TotalSizeBytes / (500 * 1024 * 1024))
if minutes < 1 {
minutes = 1
}
est.EstimatedMinutes = minutes
// Destination free space
exportDir := ExportDir(destDrive)
os.MkdirAll(exportDir, 0755)
est.DestFreeBytes = diskFree(exportDir)
est.DestFreeHuman = humanizeBytes(est.DestFreeBytes)
// Need ~10% overhead for tar.gz metadata + compression margin
needed := est.TotalSizeBytes + est.TotalSizeBytes/10
est.FitsOnDest = est.DestFreeBytes >= needed
e.debugf("EstimateExport: total=%s free=%s fits=%v needed=%s minutes=%d",
est.TotalSizeHuman, est.DestFreeHuman, est.FitsOnDest, humanizeBytes(needed), est.EstimatedMinutes)
return est, nil
}
// dirSize returns the total size of all files in a directory (non-recursive for config dirs).
func dirSize(dir string) int64 {
var total int64
entries, err := os.ReadDir(dir)
if err != nil {
return 0
}
for _, e := range entries {
if e.IsDir() {
continue
}
info, err := e.Info()
if err != nil {
continue
}
total += info.Size()
}
return total
}
// duBytes runs du -sb on a path and returns the byte count.
func duBytes(path string) int64 {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "du", "-sb", path).Output()
if err != nil {
return 0
}
var size int64
fmt.Sscanf(strings.Fields(string(out))[0], "%d", &size)
return size
}
// dockerVolumeSize estimates the size of a Docker named volume.
func dockerVolumeSize(volumeName string) int64 {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Use docker system df -v and parse, or inspect the volume mount path
out, err := exec.CommandContext(ctx, "docker", "volume", "inspect",
"--format", "{{.Mountpoint}}", volumeName).Output()
if err != nil {
return 0
}
mountpoint := strings.TrimSpace(string(out))
if mountpoint == "" {
return 0
}
return duBytes(mountpoint)
}
// diskFree returns available bytes on the filesystem containing path.
func diskFree(path string) int64 {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "df", "--output=avail", "-B1", path).Output()
if err != nil {
return 0
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) < 2 {
return 0
}
var size int64
fmt.Sscanf(strings.TrimSpace(lines[1]), "%d", &size)
return size
}
// ExportDir returns the exports directory on a drive.
func ExportDir(drivePath string) string {
return filepath.Join(drivePath, "felhom-data", "exports")
}
// humanizeBytes converts bytes to human-readable format.
func humanizeBytes(b int64) string {
const (
KB = 1024
MB = KB * 1024
GB = MB * 1024
)
switch {
case b >= GB:
return fmt.Sprintf("%.1f GB", float64(b)/float64(GB))
case b >= MB:
return fmt.Sprintf("%.1f MB", float64(b)/float64(MB))
case b >= KB:
return fmt.Sprintf("%.1f KB", float64(b)/float64(KB))
default:
return fmt.Sprintf("%d B", b)
}
}
+793
View File
@@ -0,0 +1,793 @@
package appexport
import (
"archive/tar"
"compress/gzip"
"context"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
)
// Step tracks one step of an export/import operation.
type Step struct {
Label string `json:"label"`
Status string `json:"status"` // "pending", "running", "done", "failed"
Error string `json:"error,omitempty"`
}
// Job tracks an in-progress export or import operation.
type Job struct {
mu sync.RWMutex
StackName string `json:"stack_name"`
DisplayName string `json:"display_name"`
Steps []Step `json:"steps"`
Running bool `json:"running"`
Done bool `json:"done"`
Error string `json:"error,omitempty"`
OutputPath string `json:"output_path,omitempty"`
OutputSize string `json:"output_size,omitempty"`
JobType string `json:"job_type"` // "export" or "import"
}
// Snapshot returns a thread-safe copy for JSON serialization.
func (j *Job) Snapshot() map[string]interface{} {
j.mu.RLock()
defer j.mu.RUnlock()
steps := make([]Step, len(j.Steps))
copy(steps, j.Steps)
return map[string]interface{}{
"ok": true,
"running": j.Running,
"done": j.Done,
"error": j.Error,
"steps": steps,
"output_path": j.OutputPath,
"output_size": j.OutputSize,
"stack_name": j.StackName,
"display_name": j.DisplayName,
"job_type": j.JobType,
}
}
func (j *Job) setStep(idx int, status, errMsg string) {
j.mu.Lock()
defer j.mu.Unlock()
if idx < len(j.Steps) {
j.Steps[idx].Status = status
j.Steps[idx].Error = errMsg
}
}
// ExportRequest holds user-provided parameters for an export.
type ExportRequest struct {
StackName string
DestDrive string // drive mount path (e.g., "/mnt/hdd_1")
Password string // empty = no encryption
StopApp bool // stop app before export
}
// Exporter manages app export/import operations.
type Exporter struct {
provider ExportStackProvider
logger *log.Logger
version string
debug bool
mu sync.Mutex
activeJob *Job
}
// NewExporter creates a new export/import engine.
func NewExporter(provider ExportStackProvider, logger *log.Logger, version string) *Exporter {
return &Exporter{
provider: provider,
logger: logger,
version: version,
}
}
// SetDebug enables or disables verbose debug logging.
func (e *Exporter) SetDebug(debug bool) {
e.debug = debug
}
// debugf logs a message only when debug mode is enabled.
func (e *Exporter) debugf(format string, args ...interface{}) {
if e.debug {
e.logger.Printf("[DEBUG] [appexport] "+format, args...)
}
}
// IsRunning returns true if an export/import is in progress.
func (e *Exporter) IsRunning() bool {
e.mu.Lock()
defer e.mu.Unlock()
return e.activeJob != nil && e.activeJob.Running
}
// GetActiveJob returns the current job (for status polling).
func (e *Exporter) GetActiveJob() *Job {
e.mu.Lock()
defer e.mu.Unlock()
return e.activeJob
}
// StartExport validates and starts an async export. Returns error if blocked.
func (e *Exporter) StartExport(req ExportRequest) error {
e.mu.Lock()
if e.activeJob != nil && e.activeJob.Running {
e.mu.Unlock()
e.debugf("StartExport rejected: another job is already running")
return fmt.Errorf("export or import already in progress")
}
if !e.provider.IsStackDeployed(req.StackName) {
e.mu.Unlock()
e.debugf("StartExport rejected: stack %q is not deployed", req.StackName)
return fmt.Errorf("stack %q is not deployed", req.StackName)
}
e.debugf("StartExport: stack=%q dest=%q password=%v stopApp=%v",
req.StackName, req.DestDrive, req.Password != "", req.StopApp)
steps := []Step{
{Label: "Előkészítés", Status: "pending"},
{Label: "Konfiguráció mentése", Status: "pending"},
{Label: "Adatbázis mentése", Status: "pending"},
{Label: "Felhasználói adatok", Status: "pending"},
{Label: "Csomag készítése", Status: "pending"},
}
if req.Password != "" {
steps = append(steps, Step{Label: "Titkosítás", Status: "pending"})
}
job := &Job{
StackName: req.StackName,
DisplayName: e.provider.GetStackDisplayName(req.StackName),
Steps: steps,
Running: true,
JobType: "export",
}
e.activeJob = job
e.mu.Unlock()
go e.executeExport(req, job)
return nil
}
func (e *Exporter) executeExport(req ExportRequest, job *Job) {
exportStart := time.Now()
e.debugf("=== EXPORT START: stack=%q dest=%q encrypted=%v stopApp=%v ===",
req.StackName, req.DestDrive, req.Password != "", req.StopApp)
defer func() {
job.mu.Lock()
job.Running = false
job.Done = true
job.mu.Unlock()
e.debugf("=== EXPORT END: stack=%q elapsed=%v ===", req.StackName, time.Since(exportStart))
}()
step := 0
// --- Step 0: Preparation ---
job.setStep(step, "running", "")
stepStart := time.Now()
destDir := ExportDir(req.DestDrive)
e.debugf("export dest dir: %s", destDir)
if err := os.MkdirAll(destDir, 0755); err != nil {
e.failJob(job, step, fmt.Sprintf("Nem sikerült létrehozni az export könyvtárat: %v", err))
return
}
// Check free space
est, err := e.EstimateExport(req.StackName, req.DestDrive)
if err != nil {
e.debugf("estimate error (non-fatal): %v", err)
} else {
e.debugf("estimate: config=%s data=%s total=%s destFree=%s fits=%v",
est.ConfigSizeHuman, est.DataSizeHuman, est.TotalSizeHuman, est.DestFreeHuman, est.FitsOnDest)
if !est.FitsOnDest {
e.failJob(job, step, fmt.Sprintf("Nincs elég hely: szükséges ~%s, szabad %s",
est.TotalSizeHuman, est.DestFreeHuman))
return
}
}
// Optionally stop the app
wasRunning := false
if req.StopApp && e.provider.IsStackRunning(req.StackName) {
wasRunning = true
e.logger.Printf("[INFO] Export: stopping %s", req.StackName)
e.debugf("stopping stack %s before export", req.StackName)
if err := e.provider.StopStack(req.StackName); err != nil {
e.logger.Printf("[WARN] Export: could not stop %s: %v", req.StackName, err)
} else {
e.debugf("stack %s stopped successfully", req.StackName)
}
} else {
e.debugf("skip stop: stopApp=%v isRunning=%v", req.StopApp, e.provider.IsStackRunning(req.StackName))
}
// Always restart after export if we stopped it
if wasRunning {
defer func() {
e.logger.Printf("[INFO] Export: restarting %s", req.StackName)
e.debugf("restarting stack %s after export", req.StackName)
if err := e.provider.StartStack(req.StackName); err != nil {
e.logger.Printf("[WARN] Export: could not restart %s: %v", req.StackName, err)
} else {
e.debugf("stack %s restarted successfully", req.StackName)
}
}()
}
e.debugf("step 0 (preparation) done in %v", time.Since(stepStart))
job.setStep(step, "done", "")
step++
// --- Step 1: Config files ---
job.setStep(step, "running", "")
stepStart = time.Now()
tmpDir, err := os.MkdirTemp("", "felhom-export-*")
if err != nil {
e.failJob(job, step, fmt.Sprintf("Temp könyvtár hiba: %v", err))
return
}
e.debugf("temp dir: %s", tmpDir)
defer os.RemoveAll(tmpDir)
configDir := filepath.Join(tmpDir, "config")
if err := os.MkdirAll(configDir, 0755); err != nil {
e.failJob(job, step, err.Error())
return
}
stackDir, ok := e.provider.GetStackDir(req.StackName)
if !ok {
e.failJob(job, step, "Stack könyvtár nem található")
return
}
e.debugf("stack dir: %s", stackDir)
configFiles, err := copyStackConfig(stackDir, configDir, req.StackName, e.provider)
if err != nil {
e.failJob(job, step, fmt.Sprintf("Konfiguráció mentése sikertelen: %v", err))
return
}
e.debugf("config files copied: %v (%d files)", configFiles, len(configFiles))
e.debugf("step 1 (config) done in %v", time.Since(stepStart))
job.setStep(step, "done", "")
step++
// --- Step 2: Database dump ---
job.setStep(step, "running", "")
stepStart = time.Now()
dbDir := filepath.Join(tmpDir, "database")
os.MkdirAll(dbDir, 0755)
manifest := &Manifest{
Version: ManifestVersion,
AppName: req.StackName,
DisplayName: e.provider.GetStackDisplayName(req.StackName),
ExportedAt: time.Now().UTC(),
ControllerVer: e.version,
NeedsHDD: e.provider.GetStackNeedsHDD(req.StackName),
Encrypted: req.Password != "",
ConfigFiles: configFiles,
}
e.debugf("manifest: app=%s display=%s needsHDD=%v encrypted=%v",
manifest.AppName, manifest.DisplayName, manifest.NeedsHDD, manifest.Encrypted)
dbDumped := e.dumpDatabase(req.StackName, dbDir, manifest)
if !dbDumped {
e.debugf("no database found for %s — skipping DB step", req.StackName)
os.Remove(dbDir)
} else {
e.debugf("database dumped: type=%s", manifest.DBType)
// Log the dump file size
entries, _ := os.ReadDir(dbDir)
for _, entry := range entries {
if info, err := entry.Info(); err == nil {
e.debugf(" db dump file: %s (%s)", entry.Name(), humanizeBytes(info.Size()))
}
}
}
e.debugf("step 2 (database) done in %v", time.Since(stepStart))
job.setStep(step, "done", "")
step++
// --- Step 3: User data ---
job.setStep(step, "running", "")
stepStart = time.Now()
dataDir := filepath.Join(tmpDir, "data")
os.MkdirAll(dataDir, 0755)
if e.provider.GetStackNeedsHDD(req.StackName) {
e.debugf("exporting HDD data for %s", req.StackName)
e.exportHDDData(req.StackName, dataDir, manifest)
e.debugf("HDD data exported: subdirs=%v hasData=%v", manifest.HDDSubdirs, manifest.HasHDDData)
} else {
e.debugf("exporting Docker volumes for %s", req.StackName)
e.exportVolumeData(req.StackName, dataDir, manifest)
e.debugf("volume data exported: volumes=%v hasData=%v", manifest.VolumeNames, manifest.HasVolumeData)
}
e.debugf("step 3 (user data) done in %v", time.Since(stepStart))
job.setStep(step, "done", "")
step++
// --- Step 4: Create .fab bundle ---
job.setStep(step, "running", "")
stepStart = time.Now()
// Calculate total size
manifest.TotalSizeBytes = calcDirSize(tmpDir)
e.debugf("total bundle content size: %s (%d bytes)", humanizeBytes(manifest.TotalSizeBytes), manifest.TotalSizeBytes)
// Write manifest.json to tmpDir root
manifestData, err := manifest.Marshal()
if err != nil {
e.failJob(job, step, fmt.Sprintf("Manifest hiba: %v", err))
return
}
e.debugf("manifest JSON: %d bytes", len(manifestData))
if err := os.WriteFile(filepath.Join(tmpDir, "manifest.json"), manifestData, 0644); err != nil {
e.failJob(job, step, err.Error())
return
}
timestamp := time.Now().Format("20060102-150405")
fabName := fmt.Sprintf("%s_%s.fab", req.StackName, timestamp)
fabPath := filepath.Join(destDir, fabName)
e.debugf("target .fab path: %s", fabPath)
// Build tar.gz (to .tmp if encrypting, to final path if not)
targetPath := fabPath
if req.Password != "" {
targetPath = fabPath + ".tgz.tmp"
} else {
targetPath = fabPath + ".tmp"
}
e.debugf("creating tar.gz: %s", targetPath)
tgzStart := time.Now()
if err := createTarGz(targetPath, tmpDir); err != nil {
os.Remove(targetPath)
e.failJob(job, step, fmt.Sprintf("Csomag készítése sikertelen: %v", err))
return
}
if tgzInfo, err := os.Stat(targetPath); err == nil {
e.debugf("tar.gz created: %s (%s) in %v", targetPath, humanizeBytes(tgzInfo.Size()), time.Since(tgzStart))
}
job.setStep(step, "done", "")
step++
// --- Step 5: Encrypt (optional) ---
if req.Password != "" {
job.setStep(step, "running", "")
encStart := time.Now()
encPath := fabPath + ".tmp"
e.debugf("encrypting: %s → %s", targetPath, encPath)
if err := EncryptFile(targetPath, encPath, req.Password); err != nil {
os.Remove(targetPath)
e.failJob(job, step, fmt.Sprintf("Titkosítás sikertelen: %v", err))
return
}
os.Remove(targetPath)
targetPath = encPath
if encInfo, err := os.Stat(encPath); err == nil {
e.debugf("encryption done: %s in %v", humanizeBytes(encInfo.Size()), time.Since(encStart))
}
job.setStep(step, "done", "")
}
// Atomic rename to final path
e.debugf("atomic rename: %s → %s", targetPath, fabPath)
if err := os.Rename(targetPath, fabPath); err != nil {
e.failJob(job, step, fmt.Sprintf("Fájl átnevezés sikertelen: %v", err))
return
}
// Record result
stat, _ := os.Stat(fabPath)
job.mu.Lock()
job.OutputPath = fabPath
if stat != nil {
job.OutputSize = humanizeBytes(stat.Size())
}
job.mu.Unlock()
e.logger.Printf("[INFO] Export completed: %s → %s (%s) in %v", req.StackName, fabPath, job.OutputSize, time.Since(exportStart))
}
func (e *Exporter) failJob(job *Job, stepIdx int, msg string) {
job.setStep(stepIdx, "failed", msg)
job.mu.Lock()
job.Error = msg
job.mu.Unlock()
e.logger.Printf("[ERROR] Export/import failed at step %d: %s", stepIdx, msg)
e.debugf("FAIL at step %d: %s", stepIdx, msg)
}
// GetDebugInfo returns diagnostic information about the exporter state.
func (e *Exporter) GetDebugInfo() map[string]interface{} {
e.mu.Lock()
defer e.mu.Unlock()
info := map[string]interface{}{
"debug_enabled": e.debug,
"version": e.version,
"has_active_job": e.activeJob != nil,
}
if e.activeJob != nil {
info["active_job"] = e.activeJob.Snapshot()
}
return info
}
// copyStackConfig copies all relevant config files from the stack dir.
// app.yaml is saved with decrypted (plaintext) secrets for portability.
func copyStackConfig(stackDir, configDir, stackName string, provider ExportStackProvider) ([]string, error) {
var copied []string
entries, err := os.ReadDir(stackDir)
if err != nil {
return nil, err
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
// Skip temp files
if strings.HasSuffix(name, ".tmp") {
continue
}
src := filepath.Join(stackDir, name)
dst := filepath.Join(configDir, name)
// app.yaml: save with plaintext secrets
if name == "app.yaml" {
env := provider.GetDecryptedEnv(stackName)
if env != nil {
if err := writeDecryptedAppYaml(dst, env); err != nil {
return nil, fmt.Errorf("writing decrypted app.yaml: %w", err)
}
copied = append(copied, name)
continue
}
}
// Copy file as-is
if err := copyFile(src, dst); err != nil {
return nil, fmt.Errorf("copying %s: %w", name, err)
}
copied = append(copied, name)
}
return copied, nil
}
// writeDecryptedAppYaml writes a plaintext app.yaml with the given env map.
func writeDecryptedAppYaml(dst string, env map[string]string) error {
var sb strings.Builder
sb.WriteString("# Exported by felhom-controller — plaintext secrets\n")
sb.WriteString("deployed: true\n")
sb.WriteString("env:\n")
for k, v := range env {
// YAML-safe: quote values
sb.WriteString(fmt.Sprintf(" %s: %q\n", k, v))
}
return os.WriteFile(dst, []byte(sb.String()), 0644)
}
// dumpDatabase discovers and dumps the database for a stack.
func (e *Exporter) dumpDatabase(stackName, dbDir string, manifest *Manifest) bool {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
e.debugf("discovering databases (looking for stack %s)...", stackName)
dbs, err := backup.DiscoverDatabases(ctx, e.logger, e.debug)
if err != nil {
e.logger.Printf("[WARN] Export: DB discovery error: %v", err)
return false
}
e.debugf("found %d databases total", len(dbs))
for i := range dbs {
e.debugf(" db[%d]: stack=%s container=%s type=%s", i, dbs[i].StackName, dbs[i].ContainerName, dbs[i].DBType)
}
var stackDB *backup.DiscoveredDB
for i := range dbs {
if dbs[i].StackName == stackName {
stackDB = &dbs[i]
break
}
}
if stackDB == nil {
e.debugf("no database container found for stack %s", stackName)
return false
}
e.debugf("matched DB: container=%s type=%s", stackDB.ContainerName, stackDB.DBType)
dumpStart := time.Now()
result := backup.DumpOne(ctx, *stackDB, dbDir, e.logger, e.debug)
if result.Error != nil {
e.logger.Printf("[WARN] Export: DB dump failed for %s: %v", stackName, result.Error)
return false
}
e.debugf("DB dump completed in %v: %s", time.Since(dumpStart), result.FilePath)
// Gzip the dump
if result.FilePath != "" {
gzPath := result.FilePath + ".gz"
gzStart := time.Now()
if err := gzipFile(result.FilePath, gzPath); err != nil {
e.logger.Printf("[WARN] Export: gzip dump failed: %v", err)
} else {
if origInfo, _ := os.Stat(result.FilePath); origInfo != nil {
if gzInfo, _ := os.Stat(gzPath); gzInfo != nil {
e.debugf("gzip: %s → %s (ratio %.1f%%) in %v",
humanizeBytes(origInfo.Size()), humanizeBytes(gzInfo.Size()),
float64(gzInfo.Size())/float64(origInfo.Size())*100, time.Since(gzStart))
}
}
os.Remove(result.FilePath)
}
}
manifest.HasDatabase = true
manifest.DBType = string(stackDB.DBType)
return true
}
// exportHDDData copies HDD bind mount data for the export.
func (e *Exporter) exportHDDData(stackName, dataDir string, manifest *Manifest) {
hddDir := filepath.Join(dataDir, "hdd")
os.MkdirAll(hddDir, 0755)
mounts := e.provider.GetStackHDDMounts(stackName)
e.debugf("HDD mounts for %s: %v (%d total)", stackName, mounts, len(mounts))
if len(mounts) == 0 {
e.debugf("no HDD mounts — skipping HDD data export")
return
}
for _, mount := range mounts {
if _, err := os.Stat(mount); os.IsNotExist(err) {
e.debugf("HDD mount %s does not exist — skipping", mount)
continue
}
subdir := filepath.Base(mount)
manifest.HDDSubdirs = append(manifest.HDDSubdirs, subdir)
tarPath := filepath.Join(hddDir, subdir+".tar")
e.debugf("tarring HDD mount: %s → %s", mount, tarPath)
tarStart := time.Now()
if err := tarDirectory(mount, tarPath); err != nil {
e.logger.Printf("[WARN] Export: failed to tar %s: %v", mount, err)
} else {
if info, _ := os.Stat(tarPath); info != nil {
e.debugf("HDD tar complete: %s (%s) in %v", subdir, humanizeBytes(info.Size()), time.Since(tarStart))
}
}
}
manifest.HasHDDData = len(manifest.HDDSubdirs) > 0
}
// exportVolumeData exports Docker named volumes for apps without HDD storage.
func (e *Exporter) exportVolumeData(stackName, dataDir string, manifest *Manifest) {
volDir := filepath.Join(dataDir, "volumes")
os.MkdirAll(volDir, 0755)
volumes := e.provider.GetDockerVolumes(stackName)
e.debugf("Docker volumes for %s: %v (%d total)", stackName, volumes, len(volumes))
if len(volumes) == 0 {
e.debugf("no Docker volumes — skipping volume data export")
return
}
for _, volName := range volumes {
tarPath := filepath.Join(volDir, volName+".tar")
e.debugf("exporting volume %s via docker run alpine tar...", volName)
volStart := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
cmd := exec.CommandContext(ctx, "docker", "run", "--rm",
"-v", volName+":/vol:ro",
"-v", volDir+":/out",
"alpine", "tar", "cf", "/out/"+volName+".tar", "-C", "/vol", ".")
out, err := cmd.CombinedOutput()
cancel()
if err != nil {
e.logger.Printf("[WARN] Export: volume %s export failed: %s — %v",
volName, strings.TrimSpace(string(out)), err)
e.debugf("volume %s export failed: %s", volName, strings.TrimSpace(string(out)))
os.Remove(tarPath)
continue
}
if info, _ := os.Stat(tarPath); info != nil {
e.debugf("volume %s exported: %s in %v", volName, humanizeBytes(info.Size()), time.Since(volStart))
}
manifest.VolumeNames = append(manifest.VolumeNames, volName)
}
manifest.HasVolumeData = len(manifest.VolumeNames) > 0
}
// createTarGz creates a gzipped tar archive of a directory.
func createTarGz(outputPath, sourceDir string) error {
outFile, err := os.Create(outputPath)
if err != nil {
return err
}
defer outFile.Close()
gw := gzip.NewWriter(outFile)
defer gw.Close()
tw := tar.NewWriter(gw)
defer tw.Close()
return filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Get path relative to sourceDir
relPath, err := filepath.Rel(sourceDir, path)
if err != nil {
return err
}
if relPath == "." {
return nil
}
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
header.Name = relPath
if err := tw.WriteHeader(header); err != nil {
return err
}
if info.IsDir() {
return nil
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(tw, f)
return err
})
}
// tarDirectory creates a tar (not gzipped) of a directory's contents.
func tarDirectory(sourceDir, outputPath string) error {
outFile, err := os.Create(outputPath)
if err != nil {
return err
}
defer outFile.Close()
tw := tar.NewWriter(outFile)
defer tw.Close()
return filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(sourceDir, path)
if err != nil {
return err
}
if relPath == "." {
return nil
}
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
header.Name = relPath
if err := tw.WriteHeader(header); err != nil {
return err
}
if info.IsDir() {
return nil
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(tw, f)
return err
})
}
// gzipFile compresses a file with gzip.
func gzipFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
gw := gzip.NewWriter(out)
defer gw.Close()
_, err = io.Copy(gw, in)
return err
}
// copyFile copies a file from src to dst.
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Sync()
}
// calcDirSize recursively calculates total file size in a directory.
func calcDirSize(dir string) int64 {
var total int64
filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
total += info.Size()
return nil
})
return total
}
+42
View File
@@ -0,0 +1,42 @@
package appexport
import (
"encoding/json"
"time"
)
// ManifestVersion is the current bundle format version.
const ManifestVersion = 1
// Manifest is the JSON metadata stored inside a .fab file.
type Manifest struct {
Version int `json:"version"`
AppName string `json:"app_name"`
DisplayName string `json:"display_name"`
ExportedAt time.Time `json:"exported_at"`
ControllerVer string `json:"controller_version"`
NeedsHDD bool `json:"needs_hdd"`
Encrypted bool `json:"encrypted"`
HasDatabase bool `json:"has_database"`
HasHDDData bool `json:"has_hdd_data"`
HasVolumeData bool `json:"has_volume_data"`
DBType string `json:"db_type,omitempty"`
TotalSizeBytes int64 `json:"total_size_bytes"`
ConfigFiles []string `json:"config_files"`
VolumeNames []string `json:"volume_names,omitempty"`
HDDSubdirs []string `json:"hdd_subdirs,omitempty"`
}
// Marshal returns the manifest as indented JSON.
func (m *Manifest) Marshal() ([]byte, error) {
return json.MarshalIndent(m, "", " ")
}
// UnmarshalManifest parses a manifest from JSON bytes.
func UnmarshalManifest(data []byte) (*Manifest, error) {
var m Manifest
if err := json.Unmarshal(data, &m); err != nil {
return nil, err
}
return &m, nil
}
+52
View File
@@ -0,0 +1,52 @@
// Package appexport provides per-app export/import via .fab bundles.
// A .fab file is a tar.gz (optionally password-encrypted) containing an app's
// config, database dump, and all user data — everything needed to restore
// the app to its current state.
package appexport
// ExportStackProvider provides stack data without circular imports.
// Implemented by exportAdapter in main.go (same pattern as backup.StackDataProvider).
type ExportStackProvider interface {
// GetStackDir returns the stack's directory path (e.g., /opt/docker/stacks/nextcloud).
GetStackDir(name string) (string, bool)
// GetStackComposePath returns the compose file path.
GetStackComposePath(name string) (string, bool)
// GetStackHDDMounts returns resolved HDD bind mount host paths for the stack.
GetStackHDDMounts(name string) []string
// GetStackHDDPath returns the raw HDD_PATH env var from app.yaml.
GetStackHDDPath(name string) string
// IsStackRunning returns true if the stack has running containers.
IsStackRunning(name string) bool
// StopStack stops the stack via docker compose down.
StopStack(name string) error
// StartStack starts the stack via docker compose up -d.
StartStack(name string) error
// GetStackDisplayName returns the human-readable name from .felhom.yml.
GetStackDisplayName(name string) string
// GetStackNeedsHDD returns true if the app requires HDD storage.
GetStackNeedsHDD(name string) bool
// GetDockerVolumes returns named Docker volume names from the compose file.
GetDockerVolumes(name string) []string
// IsStackDeployed returns true if the stack has a saved app.yaml config.
IsStackDeployed(name string) bool
// GetDecryptedEnv returns the decrypted env var map from app.yaml.
GetDecryptedEnv(name string) map[string]string
// --- Import-specific methods ---
// GetStacksBaseDir returns the base directory where stacks live (e.g., /opt/docker/stacks).
GetStacksBaseDir() string
// SaveEncryptedAppConfig saves app.yaml with re-encrypted sensitive fields.
// env is the plaintext env map from the bundle; encryption uses the current server key.
SaveEncryptedAppConfig(stackDir string, env map[string]string) error
// RefreshStacks rescans all stacks and refreshes container state.
RefreshStacks() error
// RemoveStackVolumes stops the stack and removes its named Docker volumes.
RemoveStackVolumes(name string) error
}
// DrivePathInfo holds a registered storage path and its label.
type DrivePathInfo struct {
Path string
Label string
}
File diff suppressed because it is too large Load Diff