From 318f6c9b46674b032c0eb01d0191642c06f2cdb6 Mon Sep 17 00:00:00 2001 From: Jamie Banks Date: Thu, 4 Jun 2026 16:59:53 +0000 Subject: [PATCH] feat: implement initial GoDump backup service with management web UI and MySQL support --- .devcontainer/devcontainer.json | 11 + .gitignore | 3 + README.md | 85 +++++- backup/discover.go | 50 ++++ backup/manager.go | 311 ++++++++++++++++++++ backup/mysql.go | 87 ++++++ backup/retention.go | 60 ++++ config/config.go | 99 +++++++ go.mod | 11 + go.sum | 10 + logger/logger.go | 116 ++++++++ main.go | 54 ++++ web/server.go | 398 +++++++++++++++++++++++++ web/templates/icon.png | Bin 0 -> 5808 bytes web/templates/index.html | 502 ++++++++++++++++++++++++++++++++ web/templates/login.html | 139 +++++++++ 16 files changed, 1935 insertions(+), 1 deletion(-) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .gitignore create mode 100644 backup/discover.go create mode 100644 backup/manager.go create mode 100644 backup/mysql.go create mode 100644 backup/retention.go create mode 100644 config/config.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 logger/logger.go create mode 100644 main.go create mode 100644 web/server.go create mode 100644 web/templates/icon.png create mode 100644 web/templates/index.html create mode 100644 web/templates/login.html diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..e585310 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,11 @@ +{ + "name": "Go", + "image": "mcr.microsoft.com/devcontainers/go:2-1.26-trixie", + "customizations": { + "vscode": { + "extensions": [ + "vivaxy.vscode-conventional-commits" + ] + } + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e9524ba --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +godump +config.yaml +backups/ \ No newline at end of file diff --git a/README.md b/README.md index d00bdea..bbb21d6 100644 --- a/README.md +++ b/README.md @@ -1 +1,84 @@ -# GoDump \ No newline at end of file +# GoDump + +GoDump is a lightweight, standalone MariaDB backup application written in Go. It manages multiple MariaDB instances concurrently, automatically discovers databases, runs scheduled backups, enforces retention policies, and presents a sleek, embedded web UI to manage operations. + +## Features + +- **Multi-Instance Support**: Manage multiple MariaDB servers independently. +- **Auto-Discovery**: Automatically discovers all non-system databases (`information_schema`, `performance_schema`, `mysql`, `sys` are ignored) before backing up. +- **Isolated Backups**: Each database is backed up via `mysqldump` in a dedicated subprocess, compressed instantly with `gzip`, and stored independently. Failure of one database does not disrupt others. +- **Retention Policies**: Configurable retention period (in days) per instance. Old backups are automatically groomed after every run. +- **Embedded Web UI**: Single-page modern interface served directly from the Go binary. No external CDN dependencies, fully functional offline. View statuses, trigger manual backups, browse backup files, and read real-time logs. +- **Cron Scheduling**: Uses standard cron expressions to schedule automated jobs. + +## Requirements + +The machine running GoDump must have the following installed in its system PATH: +- `mysqldump` +- `gzip` + +## Building from Source + +Ensure you have Go 1.20+ installed. + +```bash +git clone +cd godump +go mod tidy +GOOS=linux GOARCH=amd64 go build -o godump +``` + +## Configuration + +GoDump uses a YAML configuration file. By default, it looks for `/etc/godump/config.yaml`, but you can specify a custom path using the `--config` flag. + +### Example `config.yaml` + +```yaml +server: + port: 8080 + +logging: + file: "" + +instances: + - name: primary + host: 192.168.1.10 + port: 3306 + user: backup + password: secret + backup_dir: /backups/primary + retention_days: 14 + schedule: "0 2 * * *" + + - name: secondary + host: 192.168.1.20 + port: 3306 + user: backup + password: secret + backup_dir: /backups/secondary + retention_days: 7 + schedule: "0 3 * * *" +``` + +- `server.port`: The HTTP port for the web UI. +- `logging.file`: The path where log files should be written. +- `instances`: An array of MariaDB instances. Each requires its own name, connection details, backup directory, retention configuration (in days), and cron `schedule`. + +> **Note:** Make sure the user specified in the configuration has `SELECT`, `LOCK TABLES`, `SHOW VIEW`, and `TRIGGER` permissions to properly perform `mysqldump` operations across all databases. + +## Usage + +1. Create a `config.yaml` using the template above. +2. Run the application: + ```bash + ./godump --config /path/to/your/config.yaml + ``` +3. Open a web browser and navigate to `http://:`. + +From the UI, you can: +- See the overall status of all configured instances. +- Observe discovered databases. +- Click **Run All Now** to manually trigger backups across all servers at once, or use the **Run Now** button on individual cards for targeted runs. +- Monitor real-time progress via the auto-refreshing log console at the bottom of the page. +- Review your backup inventory grouped by instance and database. \ No newline at end of file diff --git a/backup/discover.go b/backup/discover.go new file mode 100644 index 0000000..b4f4a54 --- /dev/null +++ b/backup/discover.go @@ -0,0 +1,50 @@ +package backup + +import ( + "database/sql" + "fmt" + + "godump/config" + + _ "github.com/go-sql-driver/mysql" +) + +func discoverDatabases(cfg config.InstanceConfig) ([]string, error) { + dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/?parseTime=true", cfg.User, cfg.Password, cfg.Host, cfg.Port) + db, err := sql.Open("mysql", dsn) + if err != nil { + return nil, err + } + defer db.Close() + + // Ensure the connection is actually valid + if err := db.Ping(); err != nil { + return nil, err + } + + query := ` + SELECT SCHEMA_NAME + FROM INFORMATION_SCHEMA.SCHEMATA + WHERE SCHEMA_NAME NOT IN ('information_schema', 'performance_schema', 'mysql', 'sys') + ` + rows, err := db.Query(query) + if err != nil { + return nil, err + } + defer rows.Close() + + var databases []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + databases = append(databases, name) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return databases, nil +} diff --git a/backup/manager.go b/backup/manager.go new file mode 100644 index 0000000..7b4c78b --- /dev/null +++ b/backup/manager.go @@ -0,0 +1,311 @@ +package backup + +import ( + "os" + "path/filepath" + "sync" + "time" + + "godump/config" + "godump/logger" + + "github.com/robfig/cron/v3" +) + +type DBStatus struct { + Name string `json:"name"` + FirstDiscovered time.Time `json:"first_discovered"` + LastBackupTime time.Time `json:"last_backup_time"` + LastBackupSize int64 `json:"last_backup_size"` + LastBackupResult string `json:"last_backup_result"` // success, skipped, failed +} + +type InstanceStatus struct { + Config config.InstanceConfig + LastRunTime time.Time + NextRunTime time.Time + OverallResult string // success, partial, failed, running + Databases map[string]*DBStatus + IsRunning bool + CronEntryID cron.EntryID + mu sync.RWMutex +} + +type DBStatusSnapshot struct { + Name string `json:"name"` + FirstDiscovered time.Time `json:"first_discovered"` + LastBackupTime time.Time `json:"last_backup_time"` + LastBackupSize int64 `json:"last_backup_size"` + LastBackupResult string `json:"last_backup_result"` +} + +type InstanceSnapshot struct { + Name string + Host string + LastRunTime time.Time + NextRunTime time.Time + OverallResult string + IsRunning bool + Databases []DBStatusSnapshot +} + +func (s *InstanceStatus) Snapshot() InstanceSnapshot { + s.mu.RLock() + defer s.mu.RUnlock() + + snap := InstanceSnapshot{ + Name: s.Config.Name, + Host: s.Config.Host, + LastRunTime: s.LastRunTime, + NextRunTime: s.NextRunTime, + OverallResult: s.OverallResult, + IsRunning: s.IsRunning, + Databases: make([]DBStatusSnapshot, 0, len(s.Databases)), + } + + for _, db := range s.Databases { + snap.Databases = append(snap.Databases, DBStatusSnapshot{ + Name: db.Name, + FirstDiscovered: db.FirstDiscovered, + LastBackupTime: db.LastBackupTime, + LastBackupSize: db.LastBackupSize, + LastBackupResult: db.LastBackupResult, + }) + } + + return snap +} + +type Manager struct { + instances map[string]*InstanceStatus + cron *cron.Cron + mu sync.RWMutex +} + +func NewManager(cfg *config.Config) *Manager { + c := cron.New() + c.Start() + + m := &Manager{ + instances: make(map[string]*InstanceStatus), + cron: c, + } + + for _, instCfg := range cfg.Instances { + status := &InstanceStatus{ + Config: instCfg, + Databases: make(map[string]*DBStatus), + } + m.instances[instCfg.Name] = status + + // Schedule cron job + if instCfg.Schedule != "" { + var id cron.EntryID + id, err := c.AddFunc(instCfg.Schedule, func(name string) func() { + return func() { + m.RunInstance(name) + } + }(instCfg.Name)) + + if err != nil { + logger.Error(instCfg.Name, "Failed to schedule cron job: %v", err) + } else { + status.CronEntryID = id + status.NextRunTime = c.Entry(id).Next + logger.Info(instCfg.Name, "Scheduled backups, next run at %v", status.NextRunTime) + } + } + } + + return m +} + +func (m *Manager) GetInstances() []*InstanceStatus { + m.mu.RLock() + defer m.mu.RUnlock() + + var result []*InstanceStatus + for _, status := range m.instances { + result = append(result, status) + } + return result +} + +func (m *Manager) GetInstance(name string) *InstanceStatus { + m.mu.RLock() + defer m.mu.RUnlock() + return m.instances[name] +} + +func (m *Manager) DiscoverInitial() { + m.mu.RLock() + defer m.mu.RUnlock() + for name, inst := range m.instances { + dbs, err := discoverDatabases(inst.Config) + if err != nil { + logger.Error(name, "Initial database discovery failed: %v", err) + inst.mu.Lock() + inst.OverallResult = "failed" + inst.mu.Unlock() + continue + } + + inst.mu.Lock() + var latestInstanceTime time.Time + var hasAnyBackup bool + + for _, db := range dbs { + if _, exists := inst.Databases[db]; !exists { + var lastTime time.Time + var lastSize int64 + var lastResult string + + dbPath := filepath.Join(inst.Config.BackupDir, db) + if entries, err := os.ReadDir(dbPath); err == nil { + for _, e := range entries { + if e.IsDir() { + continue + } + if info, err := e.Info(); err == nil { + if info.ModTime().After(lastTime) { + lastTime = info.ModTime() + lastSize = info.Size() + lastResult = "success" + } + } + } + } + + inst.Databases[db] = &DBStatus{ + Name: db, + FirstDiscovered: time.Now(), + LastBackupTime: lastTime, + LastBackupSize: lastSize, + LastBackupResult: lastResult, + } + if lastTime.After(latestInstanceTime) { + latestInstanceTime = lastTime + } + if lastResult != "" { + hasAnyBackup = true + } + logger.Info(name, "Discovered initial database: %s", db) + } + } + + if latestInstanceTime.After(inst.LastRunTime) { + inst.LastRunTime = latestInstanceTime + } + if hasAnyBackup && inst.OverallResult == "" { + inst.OverallResult = "success" + } + inst.mu.Unlock() + } +} + +func (m *Manager) RunAll() { + m.mu.RLock() + defer m.mu.RUnlock() + for name := range m.instances { + go m.RunInstance(name) + } +} + +func (m *Manager) RunInstance(name string) { + inst := m.GetInstance(name) + if inst == nil { + return + } + + inst.mu.Lock() + if inst.IsRunning { + inst.mu.Unlock() + logger.Warn(name, "Backup job already running, skipping") + return + } + inst.IsRunning = true + inst.OverallResult = "running" + inst.mu.Unlock() + + defer func() { + inst.mu.Lock() + inst.IsRunning = false + inst.LastRunTime = time.Now() + if inst.CronEntryID != 0 { + inst.NextRunTime = m.cron.Entry(inst.CronEntryID).Next + } + inst.mu.Unlock() + }() + + logger.Info(name, "Starting backup job") + + // 1. Discovery + dbs, err := discoverDatabases(inst.Config) + if err != nil { + logger.Error(name, "Database discovery failed: %v", err) + inst.mu.Lock() + inst.OverallResult = "failed" + inst.mu.Unlock() + return + } + + inst.mu.Lock() + for _, db := range dbs { + if _, exists := inst.Databases[db]; !exists { + inst.Databases[db] = &DBStatus{ + Name: db, + FirstDiscovered: time.Now(), + } + logger.Info(name, "Discovered new database: %s", db) + } + } + inst.mu.Unlock() + + // 2. Backup Execution + successCount := 0 + failedCount := 0 + + // We only backup the discovered databases. If a DB disappeared, it will not be in `dbs`. + for _, db := range dbs { + logger.Info(name, "Starting backup for database %s", db) + start := time.Now() + size, err := backupDatabase(inst.Config, db) + duration := time.Since(start) + + inst.mu.Lock() + dbStatus := inst.Databases[db] + dbStatus.LastBackupTime = time.Now() + if err != nil { + logger.Error(name, "Failed backup for database %s: %v", db, err) + dbStatus.LastBackupResult = "failed" + failedCount++ + } else { + logger.Info(name, "Completed backup for database %s in %v, size %d bytes", db, duration, size) + dbStatus.LastBackupResult = "success" + dbStatus.LastBackupSize = size + successCount++ + } + inst.mu.Unlock() + } + + // 3. Retention Enforcement + logger.Info(name, "Running retention policy cleanup (keep %d days)", inst.Config.RetentionDays) + deleted, err := enforceRetention(inst.Config) + if err != nil { + logger.Error(name, "Retention cleanup encountered errors: %v", err) + } else { + logger.Info(name, "Retention cleanup finished, deleted %d files", deleted) + } + + inst.mu.Lock() + if failedCount == 0 { + inst.OverallResult = "success" + } else if successCount == 0 { + inst.OverallResult = "failed" + } else { + inst.OverallResult = "partial" + } + inst.mu.Unlock() + + logger.Info(name, "Backup job completed. Result: %s", inst.OverallResult) +} diff --git a/backup/mysql.go b/backup/mysql.go new file mode 100644 index 0000000..2490a5e --- /dev/null +++ b/backup/mysql.go @@ -0,0 +1,87 @@ +package backup + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "time" + + "godump/config" + "godump/logger" +) + +func backupDatabase(cfg config.InstanceConfig, dbName string) (int64, error) { + // Create backup directory for this database + targetDir := filepath.Join(cfg.BackupDir, dbName) + if err := os.MkdirAll(targetDir, 0755); err != nil { + return 0, fmt.Errorf("failed to create backup directory %s: %w", targetDir, err) + } + + // Filename format: instancename_dbname_2006-01-02_15-04-05.sql.gz + timestamp := time.Now().Format("2006-01-02_15-04-05") + filename := fmt.Sprintf("%s_%s_%s.sql.gz", cfg.Name, dbName, timestamp) + targetFile := filepath.Join(targetDir, filename) + + f, err := os.Create(targetFile) + if err != nil { + return 0, fmt.Errorf("failed to create backup file %s: %w", targetFile, err) + } + defer f.Close() + + // Using sh -c allows us to pipe mysqldump output to gzip easily. + // Since we are taking dbName from INFORMATION_SCHEMA, we should still be careful with quotes, + // but mysqldump arguments can be passed via command directly, and we can pipe in go, or use sh. + // Piping in Go is safer. + + cmdDump := exec.Command("mysqldump", + fmt.Sprintf("-h%s", cfg.Host), + fmt.Sprintf("-P%d", cfg.Port), + fmt.Sprintf("-u%s", cfg.User), + fmt.Sprintf("-p%s", cfg.Password), + "--single-transaction", + "--routines", + "--triggers", + dbName, + ) + + cmdGzip := exec.Command("gzip", "-c") + + // Create pipe between mysqldump and gzip + dumpOut, err := cmdDump.StdoutPipe() + if err != nil { + return 0, fmt.Errorf("failed to create dump stdout pipe: %w", err) + } + cmdDump.Stderr = os.Stderr // or capture it + + cmdGzip.Stdin = dumpOut + cmdGzip.Stdout = f + cmdGzip.Stderr = os.Stderr + + if err := cmdDump.Start(); err != nil { + return 0, fmt.Errorf("failed to start mysqldump: %w", err) + } + + if err := cmdGzip.Start(); err != nil { + cmdDump.Process.Kill() + return 0, fmt.Errorf("failed to start gzip: %w", err) + } + + if err := cmdDump.Wait(); err != nil { + cmdGzip.Process.Kill() + return 0, fmt.Errorf("mysqldump failed: %w", err) + } + + if err := cmdGzip.Wait(); err != nil { + return 0, fmt.Errorf("gzip failed: %w", err) + } + + // Get file size + stat, err := f.Stat() + if err != nil { + logger.Warn(cfg.Name, "Could not stat file %s to get size: %v", targetFile, err) + return 0, nil + } + + return stat.Size(), nil +} diff --git a/backup/retention.go b/backup/retention.go new file mode 100644 index 0000000..459d83a --- /dev/null +++ b/backup/retention.go @@ -0,0 +1,60 @@ +package backup + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "time" + + "godump/config" + "godump/logger" +) + +func enforceRetention(cfg config.InstanceConfig) (int, error) { + if cfg.RetentionDays <= 0 { + return 0, nil + } + + cutoff := time.Now().AddDate(0, 0, -cfg.RetentionDays) + deletedCount := 0 + + err := filepath.WalkDir(cfg.BackupDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + if d.IsDir() { + return nil + } + + info, err := d.Info() + if err != nil { + return err + } + + if info.ModTime().Before(cutoff) { + ageDays := int(time.Since(info.ModTime()).Hours() / 24) + if err := os.Remove(path); err != nil { + logger.Error(cfg.Name, "Failed to delete old backup %s: %v", path, err) + } else { + logger.Info(cfg.Name, "Deleted old backup %s (age: %d days)", path, ageDays) + deletedCount++ + } + } + + return nil + }) + + if err != nil { + if os.IsNotExist(err) { + return 0, nil + } + return deletedCount, fmt.Errorf("error walking backup directory: %w", err) + } + + return deletedCount, nil +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..22336cb --- /dev/null +++ b/config/config.go @@ -0,0 +1,99 @@ +package config + +import ( + "os" + + "gopkg.in/yaml.v3" +) + +type AuthConfig struct { + Enabled bool `yaml:"enabled"` + Username string `yaml:"username"` + Password string `yaml:"password"` +} + +type Config struct { + Server ServerConfig `yaml:"server"` + Auth AuthConfig `yaml:"auth"` + Logging LoggingConfig `yaml:"logging"` + Instances []InstanceConfig `yaml:"instances"` +} + +type ServerConfig struct { + Port int `yaml:"port"` +} + +type LoggingConfig struct { + File string `yaml:"file"` +} + +type InstanceConfig struct { + Name string `yaml:"name"` + Host string `yaml:"host"` + Port int `yaml:"port"` + User string `yaml:"user"` + Password string `yaml:"password"` + BackupDir string `yaml:"backup_dir"` + RetentionDays int `yaml:"retention_days"` + Schedule string `yaml:"schedule"` +} + +func LoadConfig(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + + return &cfg, nil +} + +func GenerateDefaultConfig(path string) error { + defaultCfg := Config{ + Server: ServerConfig{ + Port: 8080, + }, + Auth: AuthConfig{ + Enabled: false, + Username: "admin", + Password: "password", + }, + Logging: LoggingConfig{ + File: "", + }, + Instances: []InstanceConfig{ + { + Name: "primary", + Host: "127.0.0.1", + Port: 3306, + User: "backup", + Password: "secret", + BackupDir: "/backups/primary", + RetentionDays: 14, + Schedule: "0 2 * * *", + }, + }, + } + + data, err := yaml.Marshal(&defaultCfg) + if err != nil { + return err + } + + // Make sure the directory exists + dir := os.Args[0] + for i := len(path) - 1; i >= 0 && !os.IsPathSeparator(path[i]); i-- { + dir = path[:i] + } + if dir != "" { + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + } + + return os.WriteFile(path, data, 0644) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2dbd884 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module godump + +go 1.26.3 + +require ( + github.com/go-sql-driver/mysql v1.10.0 + github.com/robfig/cron/v3 v3.0.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require filippo.io/edwards25519 v1.2.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..25402ef --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/logger/logger.go b/logger/logger.go new file mode 100644 index 0000000..369ce9a --- /dev/null +++ b/logger/logger.go @@ -0,0 +1,116 @@ +package logger + +import ( + "container/ring" + "encoding/json" + "fmt" + "log" + "os" + "sync" + "time" +) + +type LogLevel string + +const ( + INFO LogLevel = "INFO" + WARN LogLevel = "WARN" + ERROR LogLevel = "ERROR" +) + +type LogEntry struct { + Timestamp time.Time `json:"timestamp"` + Level LogLevel `json:"level"` + Instance string `json:"instance,omitempty"` + Message string `json:"message"` +} + +var ( + ringBuffer *ring.Ring + bufferMu sync.Mutex + fileLogger *log.Logger + stdLogger *log.Logger +) + +func Init(logFile string) error { + ringBuffer = ring.New(100) + + stdLogger = log.New(os.Stdout, "", 0) + + if logFile != "" { + f, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + if err != nil { + return err + } + fileLogger = log.New(f, "", 0) + } + + return nil +} + +func logMessage(level LogLevel, instance, format string, v ...interface{}) { + msg := fmt.Sprintf(format, v...) + entry := LogEntry{ + Timestamp: time.Now(), + Level: level, + Instance: instance, + Message: msg, + } + + logLine := fmt.Sprintf("[%s] [%s]", entry.Timestamp.Format(time.RFC3339), entry.Level) + if instance != "" { + logLine += fmt.Sprintf(" [%s]", instance) + } + logLine += " " + entry.Message + + // Log to stdout + if stdLogger != nil { + stdLogger.Println(logLine) + } + + // Log to file + if fileLogger != nil { + fileLogger.Println(logLine) + } + + // Log to ring buffer + bufferMu.Lock() + defer bufferMu.Unlock() + ringBuffer.Value = entry + ringBuffer = ringBuffer.Next() +} + +func Info(instance, format string, v ...interface{}) { + logMessage(INFO, instance, format, v...) +} + +func Warn(instance, format string, v ...interface{}) { + logMessage(WARN, instance, format, v...) +} + +func Error(instance, format string, v ...interface{}) { + logMessage(ERROR, instance, format, v...) +} + +func GetRecentLogs() []LogEntry { + bufferMu.Lock() + defer bufferMu.Unlock() + + var logs []LogEntry + ringBuffer.Do(func(p interface{}) { + if p != nil { + logs = append(logs, p.(LogEntry)) + } + }) + + // Do() iterates forward from the current pointer. In a ring buffer where we just added elements, + // the oldest element is at the current pointer, and the newest element is the one just before it. + // So logs will be ordered from oldest to newest. We want to return newest to oldest or keep it chronological? + // The requirement is "last 100 log lines in a scrollable monospace box". Chronological (oldest to newest) is standard for tailing logs. + return logs +} + +func GetRecentLogsJSON() ([]byte, error) { + logs := GetRecentLogs() + return json.Marshal(logs) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..43c0052 --- /dev/null +++ b/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "godump/backup" + "godump/config" + "godump/logger" + "godump/web" +) + +func main() { + configPath := flag.String("config", "/etc/godump/config.yaml", "Path to configuration file") + flag.Parse() + + cfg, err := config.LoadConfig(*configPath) + if err != nil { + if os.IsNotExist(err) { + fmt.Printf("Config file %s does not exist. Generating a default config...\n", *configPath) + if genErr := config.GenerateDefaultConfig(*configPath); genErr != nil { + fmt.Fprintf(os.Stderr, "Error generating default configuration: %v\n", genErr) + os.Exit(1) + } + cfg, err = config.LoadConfig(*configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading generated configuration: %v\n", err) + os.Exit(1) + } + } else { + fmt.Fprintf(os.Stderr, "Error loading configuration from %s: %v\n", *configPath, err) + os.Exit(1) + } + } + + if err := logger.Init(cfg.Logging.File); err != nil { + fmt.Fprintf(os.Stderr, "Error initializing logger: %v\n", err) + os.Exit(1) + } + + logger.Info("", "Starting GoDump...") + + manager := backup.NewManager(cfg) + + logger.Info("", "Running initial database discovery...") + manager.DiscoverInitial() + + server := web.NewServer(cfg, manager) + if err := server.Start(); err != nil { + logger.Error("", "Server stopped: %v", err) + os.Exit(1) + } +} diff --git a/web/server.go b/web/server.go new file mode 100644 index 0000000..faa66ba --- /dev/null +++ b/web/server.go @@ -0,0 +1,398 @@ +package web + +import ( + "crypto/rand" + "embed" + "encoding/hex" + "fmt" + "html/template" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "godump/backup" + "godump/config" + "godump/logger" +) + +//go:embed templates/* +var templateFS embed.FS + +type Server struct { + cfg *config.Config + manager *backup.Manager + mux *http.ServeMux +} + +type FileInfo struct { + Name string + Size int64 + Timestamp time.Time +} + +type Inventory map[string]map[string][]FileInfo // Instance -> DB -> Files + +func NewServer(cfg *config.Config, manager *backup.Manager) *Server { + s := &Server{ + cfg: cfg, + manager: manager, + mux: http.NewServeMux(), + } + s.routes() + return s +} + +func (s *Server) Start() error { + addr := fmt.Sprintf(":%d", s.cfg.Server.Port) + logger.Info("", "Starting web server on %s", addr) + return http.ListenAndServe(addr, s.mux) +} + +func (s *Server) routes() { + s.mux.HandleFunc("/login", s.handleLogin) + s.mux.HandleFunc("/logout", s.handleLogout) + s.mux.HandleFunc("/icon.png", s.handleIcon) + + s.mux.HandleFunc("/", s.requireAuth(s.handleIndex)) + s.mux.HandleFunc("/api/logs", s.requireAuthAPI(s.handleLogs)) + s.mux.HandleFunc("/api/run/all", s.requireAuthAPI(s.handleRunAll)) + s.mux.HandleFunc("/api/run/", s.requireAuthAPI(s.handleRunInstance)) + s.mux.HandleFunc("/api/download", s.requireAuthAPI(s.handleDownload)) + s.mux.HandleFunc("/api/delete", s.requireAuthAPI(s.handleDelete)) +} + +var sessionToken string + +func init() { + b := make([]byte, 32) + rand.Read(b) + sessionToken = hex.EncodeToString(b) +} + +func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !s.cfg.Auth.Enabled { + next(w, r) + return + } + cookie, err := r.Cookie("godump_session") + if err != nil || cookie.Value != sessionToken { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + next(w, r) + } +} + +func (s *Server) requireAuthAPI(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !s.cfg.Auth.Enabled { + next(w, r) + return + } + cookie, err := r.Cookie("godump_session") + if err != nil || cookie.Value != sessionToken { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + next(w, r) + } +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + if !s.cfg.Auth.Enabled { + http.Redirect(w, r, "/", http.StatusFound) + return + } + + if r.Method == http.MethodGet { + tmpl, err := template.ParseFS(templateFS, "templates/login.html") + if err != nil { + http.Error(w, "Template error", http.StatusInternalServerError) + return + } + tmpl.Execute(w, nil) + return + } + + if r.Method == http.MethodPost { + r.ParseForm() + username := r.FormValue("username") + password := r.FormValue("password") + + if username == s.cfg.Auth.Username && password == s.cfg.Auth.Password { + http.SetCookie(w, &http.Cookie{ + Name: "godump_session", + Value: sessionToken, + Path: "/", + HttpOnly: true, + }) + http.Redirect(w, r, "/", http.StatusFound) + return + } + + tmpl, _ := template.ParseFS(templateFS, "templates/login.html") + tmpl.Execute(w, map[string]string{"Error": "Invalid credentials"}) + return + } +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: "godump_session", + Value: "", + Path: "/", + HttpOnly: true, + MaxAge: -1, + }) + http.Redirect(w, r, "/login", http.StatusFound) +} + +func (s *Server) handleIcon(w http.ResponseWriter, r *http.Request) { + data, err := templateFS.ReadFile("templates/icon.png") + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "image/png") + w.Write(data) +} + +func (s *Server) resolveFilePath(instName, dbName, fileName string) (string, error) { + inst := s.manager.GetInstance(instName) + if inst == nil { + return "", fmt.Errorf("instance not found") + } + + baseDir := filepath.Clean(inst.Config.BackupDir) + targetPath := filepath.Join(baseDir, dbName, fileName) + targetPath = filepath.Clean(targetPath) + + if !strings.HasPrefix(targetPath, baseDir+string(filepath.Separator)) { + return "", fmt.Errorf("invalid file path") + } + + if _, err := os.Stat(targetPath); err != nil { + return "", fmt.Errorf("file not found") + } + + return targetPath, nil +} + +func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + q := r.URL.Query() + instName := q.Get("instance") + dbName := q.Get("db") + fileName := q.Get("file") + + if instName == "" || dbName == "" || fileName == "" { + http.Error(w, "Missing parameters", http.StatusBadRequest) + return + } + + filePath, err := s.resolveFilePath(instName, dbName, fileName) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName)) + w.Header().Set("Content-Type", "application/x-gzip") + http.ServeFile(w, r, filePath) +} + +func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete && r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + q := r.URL.Query() + instName := q.Get("instance") + dbName := q.Get("db") + fileName := q.Get("file") + + if instName == "" || dbName == "" || fileName == "" { + http.Error(w, "Missing parameters", http.StatusBadRequest) + return + } + + filePath, err := s.resolveFilePath(instName, dbName, fileName) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + if err := os.Remove(filePath); err != nil { + logger.Error(instName, "Failed to manually delete backup %s: %v", filePath, err) + http.Error(w, "Failed to delete file", http.StatusInternalServerError) + return + } + + logger.Info(instName, "Manually deleted backup file %s", fileName) + w.WriteHeader(http.StatusOK) +} + +func formatBytes(b int64) string { + const unit = 1024 + if b < unit { + return fmt.Sprintf("%d B", b) + } + div, exp := int64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp]) +} + +type TemplateData struct { + TotalInstances int + TotalDatabases int + UnreachableCount int + TotalBackupSize int64 + AnyRunning bool + AuthEnabled bool + Instances []backup.InstanceSnapshot + Inventory Inventory +} + +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + funcMap := template.FuncMap{ + "formatBytes": formatBytes, + "formatTime": func(t time.Time) string { + if t.IsZero() { + return "Never" + } + return t.Format("2006-01-02 15:04:05") + }, + } + + tmpl, err := template.New("index.html").Funcs(funcMap).ParseFS(templateFS, "templates/index.html") + if err != nil { + http.Error(w, fmt.Sprintf("Template error: %v", err), http.StatusInternalServerError) + return + } + + instances := s.manager.GetInstances() + + inventory, totalSize := s.getInventory() + + data := TemplateData{ + TotalInstances: len(instances), + Inventory: inventory, + TotalBackupSize: totalSize, + AuthEnabled: s.cfg.Auth.Enabled, + } + + for _, inst := range instances { + snap := inst.Snapshot() + data.Instances = append(data.Instances, snap) + data.TotalDatabases += len(snap.Databases) + if snap.OverallResult == "failed" { + data.UnreachableCount++ + } + if snap.IsRunning { + data.AnyRunning = true + } + } + + if err := tmpl.Execute(w, data); err != nil { + logger.Error("", "Error executing template: %v", err) + } +} + +func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + data, err := logger.GetRecentLogsJSON() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Write(data) +} + +func (s *Server) handleRunAll(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + s.manager.RunAll() + w.WriteHeader(http.StatusOK) +} + +func (s *Server) handleRunInstance(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + name := strings.TrimPrefix(r.URL.Path, "/api/run/") + if name == "" { + http.Error(w, "Instance name required", http.StatusBadRequest) + return + } + s.manager.RunInstance(name) + w.WriteHeader(http.StatusOK) +} + +func (s *Server) getInventory() (Inventory, int64) { + inv := make(Inventory) + instances := s.manager.GetInstances() + var totalSize int64 + + for _, inst := range instances { + instName := inst.Config.Name + inv[instName] = make(map[string][]FileInfo) + + entries, err := os.ReadDir(inst.Config.BackupDir) + if err != nil { + continue + } + + for _, dbEntry := range entries { + if !dbEntry.IsDir() { + continue + } + dbName := dbEntry.Name() + dbPath := filepath.Join(inst.Config.BackupDir, dbName) + + files, err := os.ReadDir(dbPath) + if err != nil { + continue + } + + var fileInfos []FileInfo + for _, fileEntry := range files { + if fileEntry.IsDir() { + continue + } + info, err := fileEntry.Info() + if err != nil { + continue + } + fileInfos = append(fileInfos, FileInfo{ + Name: info.Name(), + Size: info.Size(), + Timestamp: info.ModTime(), + }) + totalSize += info.Size() + } + if len(fileInfos) > 0 { + inv[instName][dbName] = fileInfos + } + } + } + return inv, totalSize +} diff --git a/web/templates/icon.png b/web/templates/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..8fc0b9434326a0430fd478cb84cfebd41256bbab GIT binary patch literal 5808 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 + + + + + GoDump + + + + + +
+
+ +

GoDump

+
+
+ {{if .AuthEnabled}} + + {{end}} + +
+
+ +
+
+
{{.TotalInstances}}
+
Managed Instances
+
+
+
{{.TotalDatabases}}
+
Discovered Databases
+
+
+
{{.UnreachableCount}}
+
Unreachable Instances
+
+
+
{{formatBytes .TotalBackupSize}}
+
Total Backup Size
+
+
+ +

Instances

+ + {{range .Instances}} +
+
+
+

{{.Name}} {{if .IsRunning}}Running{{else}}{{if .OverallResult}}{{.OverallResult}}{{else}}Pending{{end}}{{end}}

+
+ Host: {{.Host}} + Last Run: {{formatTime .LastRunTime}} + Next Run: {{formatTime .NextRunTime}} +
+
+ +
+ + {{if .Databases}} +
+ + + + + + + + + + + + {{range .Databases}} + + + + + + + + {{end}} + +
DatabaseFirst DiscoveredLast Backup TimeSizeResult
{{.Name}}{{formatTime .FirstDiscovered}}{{formatTime .LastBackupTime}}{{if gt .LastBackupSize 0}}{{formatBytes .LastBackupSize}}{{else}}-{{end}} + {{if .LastBackupResult}} + {{.LastBackupResult}} + {{else}} + Pending + {{end}} +
+
+ {{else}} +

No databases discovered yet.

+ {{end}} +
+ {{end}} + +
+

Backup Inventory

+ {{range $instName, $dbs := .Inventory}} +
+ + {{$instName}} + +
+ {{range $dbName, $files := $dbs}} +
+ + {{$dbName}} ({{len $files}} files) + +
+ + + + + + + + + {{range $files}} + + + + + + + {{end}} + +
{{.Name}}{{formatTime .Timestamp}}{{formatBytes .Size}} + + +
+
+
+ {{end}} +
+
+ {{end}} +
+ +

Recent Logs

+
+ + + + diff --git a/web/templates/login.html b/web/templates/login.html new file mode 100644 index 0000000..3389d43 --- /dev/null +++ b/web/templates/login.html @@ -0,0 +1,139 @@ + + + + + + GoDump - Login + + + + + + +