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 0000000..8fc0b94 Binary files /dev/null and b/web/templates/icon.png differ diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..32aa0e6 --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,502 @@ + + + + + + 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 + + + + + + +