commit 5352a9da22e822a9b1687977c99a044ba3749172 Author: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Sat Jun 13 22:07:31 2026 +0200 Initial Backupper Unraid plugin diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..46ba08b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/build/ +/.cache/ +*.log +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d73695b --- /dev/null +++ b/LICENSE @@ -0,0 +1,14 @@ +GNU GENERAL PUBLIC LICENSE +Version 3, 29 June 2007 + +Copyright (C) 2026 Backupper Project + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU General Public License as published by the Free Software +Foundation, either version 3 of the License, or (at your option) any later +version. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See for the complete +license text. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d477065 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# Backupper for Unraid + +Backupper is a native Unraid 7 plugin that manages encrypted, deduplicated and versioned backups through Restic. The MVP supports shares, appdata, Docker metadata and data, VM definitions and disks, and the Unraid flash drive. Destinations can be local, SFTP, SMB or NFS. + +## Architecture + +- `backupperd` is a static Go daemon with a single global task queue and an HTTP API on `/run/backupper/backupper.sock`. +- The Unraid PHP page proxies authenticated WebGUI requests to that Unix socket. +- Persistent configuration and AES-256-GCM encrypted secrets live in `/boot/config/plugins/backupper`. +- Runtime secrets and transient metadata live below `/run/backupper` and are deleted after each operation. +- Restic 0.19.0 is pinned and checksum-verified by the package build. + +## Development + +Requirements: Go 1.23+, Node.js, `curl`, `bzip2`, `tar`, and `sha256sum`. + +```bash +./scripts/check.sh +./packaging/build-package.sh +``` + +The package is written to `dist/`. Replace `REPLACE_DURING_RELEASE` in the copied PLG manifest with the generated package checksum before publishing a release. + +## Unraid installation + +Install the generated manifest URL through Unraid's Plugins page: + +```text +https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper.plg +``` + +Configuration is retained when uninstalling so repositories remain recoverable. + +## Security model + +The daemon has no TCP listener. Restic passwords are passed through root-only temporary files, never command-line arguments. Automatic boot unlock protects against accidental disclosure but does not protect against root compromise or theft of the complete flash drive. In-place restores require explicit confirmation and protected system roots are rejected. + +## Current scope + +The code implements the MVP control plane and adapters. Docker and VM operations require the standard Unraid `docker` and `virsh` commands. Managed SMB/NFS mounts require the corresponding Unraid mount helpers. Hardware and end-to-end compatibility must be validated on supported Unraid 7 releases before a stable publication. diff --git a/cmd/backupperd/main.go b/cmd/backupperd/main.go new file mode 100644 index 0000000..8c5702c --- /dev/null +++ b/cmd/backupperd/main.go @@ -0,0 +1,89 @@ +package main + +import ( + "context" + "flag" + "log/slog" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/backupper-unraid/backupper/internal/api" + "github.com/backupper-unraid/backupper/internal/notify" + "github.com/backupper-unraid/backupper/internal/platform" + "github.com/backupper-unraid/backupper/internal/restic" + "github.com/backupper-unraid/backupper/internal/scheduler" + "github.com/backupper-unraid/backupper/internal/secrets" + "github.com/backupper-unraid/backupper/internal/service" + "github.com/backupper-unraid/backupper/internal/store" +) + +var version = "dev" + +func main() { + configDir := flag.String("config-dir", "/boot/config/plugins/backupper", "persistent configuration directory") + runtimeDir := flag.String("runtime-dir", "/run/backupper", "runtime directory") + stateDir := flag.String("state-dir", "/var/lib/backupper", "volatile state directory") + socket := flag.String("socket", "/run/backupper/backupper.sock", "Unix API socket") + flag.Parse() + + log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + if err := os.MkdirAll(*runtimeDir, 0750); err != nil { + log.Error("create runtime directory", "error", err) + os.Exit(1) + } + if err := os.MkdirAll(*stateDir, 0750); err != nil { + log.Error("create state directory", "error", err) + os.Exit(1) + } + + configStore := store.New(*configDir) + if err := configStore.Init(); err != nil { + log.Error("initialize config store", "error", err) + os.Exit(1) + } + secretStore := secrets.New(filepath.Join(*configDir, "secrets")) + if err := secretStore.Init(); err != nil { + log.Error("initialize secret store", "error", err) + os.Exit(1) + } + + config, err := configStore.LoadConfig() + if err != nil { + log.Error("load config", "error", err) + os.Exit(1) + } + rr := &restic.Runner{Binary: config.Settings.ResticPath, RuntimeDir: *runtimeDir, Secrets: secretStore, Log: log} + mounts := &platform.MountManager{RuntimeDir: *runtimeDir, Secrets: secretStore} + workloads := &platform.WorkloadManager{RuntimeDir: *runtimeDir} + notifier := ¬ify.Sender{Secrets: secretStore} + svc, err := service.New(configStore, secretStore, rr, mounts, workloads, notifier, log) + if err != nil { + log.Error("initialize service", "error", err) + os.Exit(1) + } + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + go svc.RunQueue(ctx) + api.Version = version + sched := scheduler.New(svc.Config, svc.EnqueueScheduled, svc.EnqueueMaintenance, svc.LastRun, log) + go sched.Run(ctx) + + server := api.New(*socket, svc, log) + go func() { + if err := server.ListenAndServe(); err != nil { + log.Error("api server stopped", "error", err) + cancel() + } + }() + log.Info("backupper daemon started", "socket", *socket) + <-ctx.Done() + svc.Stop() + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second) + defer shutdownCancel() + _ = server.Shutdown(shutdownCtx) + log.Info("backupper daemon stopped") +} diff --git a/dist/backupper-0.1.0-x86_64-1.txz b/dist/backupper-0.1.0-x86_64-1.txz new file mode 100644 index 0000000..2e733ae Binary files /dev/null and b/dist/backupper-0.1.0-x86_64-1.txz differ diff --git a/dist/backupper-0.1.0-x86_64-1.txz.sha256 b/dist/backupper-0.1.0-x86_64-1.txz.sha256 new file mode 100644 index 0000000..be6d711 --- /dev/null +++ b/dist/backupper-0.1.0-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +9c8cff4604b2a2ddf9b961e0f006d68202862598c17e384f5bab4186a5185f23 backupper-0.1.0-x86_64-1.txz diff --git a/dist/backupper.plg b/dist/backupper.plg new file mode 100644 index 0000000..d8ded6d --- /dev/null +++ b/dist/backupper.plg @@ -0,0 +1,32 @@ + + + + + + + +]> + + +### 0.1.0 +- Initial MVP release. + + + &packageURL; + &packageSHA256; + + + +chmod 0700 /boot/config/plugins/&name; +/etc/rc.d/rc.backupper start + + + + +/etc/rc.d/rc.backupper stop || true +removepkg &name; +rm -rf /usr/local/emhttp/plugins/&name; /usr/local/libexec/&name; /usr/local/sbin/backupperd /etc/rc.d/rc.backupper /run/backupper /var/lib/backupper + + + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6794ee0 --- /dev/null +++ b/go.mod @@ -0,0 +1,4 @@ +module github.com/backupper-unraid/backupper + +go 1.23 + diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..cddafe6 --- /dev/null +++ b/internal/api/server.go @@ -0,0 +1,232 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/backupper-unraid/backupper/internal/model" + "github.com/backupper-unraid/backupper/internal/service" +) + +type Server struct { + service *service.Service + log *slog.Logger + http *http.Server + socket string +} + +var Version = "dev" + +func New(socket string, svc *service.Service, log *slog.Logger) *Server { + s := &Server{service: svc, log: log, socket: socket} + mux := http.NewServeMux() + mux.HandleFunc("GET /v1/health", s.health) + mux.HandleFunc("GET /v1/config", s.getConfig) + mux.HandleFunc("PUT /v1/config", s.putConfig) + mux.HandleFunc("PUT /v1/secrets/{id}", s.putSecret) + mux.HandleFunc("DELETE /v1/secrets/{id}", s.deleteSecret) + mux.HandleFunc("GET /v1/runs", s.runs) + mux.HandleFunc("POST /v1/jobs/{id}/run", s.runJob) + mux.HandleFunc("POST /v1/runs/{id}/cancel", s.cancelRun) + mux.HandleFunc("POST /v1/repositories/{id}/test", s.testRepository) + mux.HandleFunc("POST /v1/repositories/{id}/init", s.initRepository) + mux.HandleFunc("POST /v1/repositories/{id}/{action}", s.maintenance) + mux.HandleFunc("GET /v1/repositories/{id}/snapshots", s.snapshots) + mux.HandleFunc("GET /v1/repositories/{id}/snapshots/{snapshot}/files", s.snapshotFiles) + mux.HandleFunc("POST /v1/restores", s.restore) + s.http = &http.Server{Handler: requestLog(log, mux), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 5 * time.Minute, IdleTimeout: 30 * time.Second} + return s +} + +func (s *Server) ListenAndServe() error { + if err := os.MkdirAll(filepath.Dir(s.socket), 0750); err != nil { + return err + } + _ = os.Remove(s.socket) + listener, err := net.Listen("unix", s.socket) + if err != nil { + return err + } + if err := os.Chmod(s.socket, 0660); err != nil { + listener.Close() + return err + } + err = s.http.Serve(listener) + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err +} + +func (s *Server) Shutdown(ctx context.Context) error { + defer os.Remove(s.socket) + return s.http.Shutdown(ctx) +} + +func (s *Server) health(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, 200, map[string]any{"status": "ok", "version": Version, "schemaVersion": model.SchemaVersion}) +} +func (s *Server) getConfig(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, 200, s.service.Config()) +} + +func (s *Server) putConfig(w http.ResponseWriter, r *http.Request) { + var config model.Config + if err := decode(r, &config); err != nil { + writeError(w, err) + return + } + if err := s.service.SaveConfig(config); err != nil { + writeError(w, err) + return + } + writeJSON(w, 200, config) +} + +func (s *Server) putSecret(w http.ResponseWriter, r *http.Request) { + var body struct { + Type string `json:"type"` + Value string `json:"value"` + } + if err := decode(r, &body); err != nil { + writeError(w, err) + return + } + if err := s.service.PutSecret(r.PathValue("id"), body.Type, body.Value); err != nil { + writeError(w, err) + return + } + writeJSON(w, 204, nil) +} +func (s *Server) deleteSecret(w http.ResponseWriter, r *http.Request) { + if err := s.service.DeleteSecret(r.PathValue("id")); err != nil { + writeError(w, err) + return + } + w.WriteHeader(204) +} +func (s *Server) runs(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, s.service.Runs()) } + +func (s *Server) runJob(w http.ResponseWriter, r *http.Request) { + run, err := s.service.EnqueueJob(r.PathValue("id"), 10) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, 202, run) +} +func (s *Server) cancelRun(w http.ResponseWriter, r *http.Request) { + if !s.service.Cancel(r.PathValue("id")) { + writeJSON(w, 404, map[string]string{"error": "run not found"}) + return + } + w.WriteHeader(204) +} + +func (s *Server) testRepository(w http.ResponseWriter, r *http.Request) { + s.repositoryAction(w, r, false) +} +func (s *Server) initRepository(w http.ResponseWriter, r *http.Request) { + s.repositoryAction(w, r, true) +} +func (s *Server) repositoryAction(w http.ResponseWriter, r *http.Request, initialize bool) { + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) + defer cancel() + if err := s.service.TestRepository(ctx, r.PathValue("id"), initialize); err != nil { + writeError(w, err) + return + } + writeJSON(w, 200, map[string]bool{"ok": true}) +} + +func (s *Server) maintenance(w http.ResponseWriter, r *http.Request) { + run, err := s.service.EnqueueMaintenance(r.PathValue("id"), r.PathValue("action")) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, 202, run) +} + +func (s *Server) snapshots(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) + defer cancel() + items, err := s.service.Snapshots(ctx, r.PathValue("id")) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, 200, items) +} +func (s *Server) snapshotFiles(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) + defer cancel() + items, err := s.service.SnapshotFiles(ctx, r.PathValue("id"), r.PathValue("snapshot"), r.URL.Query().Get("path")) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, 200, items) +} +func (s *Server) restore(w http.ResponseWriter, r *http.Request) { + var task model.RestoreTask + if err := decode(r, &task); err != nil { + writeError(w, err) + return + } + run, err := s.service.EnqueueRestore(task) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, 202, run) +} + +func decode(r *http.Request, target any) error { + decoder := json.NewDecoder(io.LimitReader(r.Body, 2*1024*1024)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return fmt.Errorf("validation: invalid JSON: %w", err) + } + return nil +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if status != 204 && value != nil { + _ = json.NewEncoder(w).Encode(value) + } +} +func writeError(w http.ResponseWriter, err error) { + status := 500 + message := err.Error() + if strings.HasPrefix(message, "validation:") { + status = 400 + } + if strings.Contains(message, "unknown") { + status = 404 + } + if strings.Contains(message, "already queued") { + status = 409 + } + writeJSON(w, status, map[string]string{"error": message}) +} + +func requestLog(log *slog.Logger, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + started := time.Now() + next.ServeHTTP(w, r) + log.Info("api request", "method", r.Method, "path", r.URL.Path, "durationMs", time.Since(started).Milliseconds()) + }) +} diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..f4056bf --- /dev/null +++ b/internal/model/model.go @@ -0,0 +1,164 @@ +package model + +import "time" + +const SchemaVersion = 1 + +type JobType string + +const ( + JobShare JobType = "share" + JobAppdata JobType = "appdata" + JobDocker JobType = "docker" + JobVM JobType = "vm" + JobFlash JobType = "flash" +) + +type RepositoryType string + +const ( + RepositoryLocal RepositoryType = "local" + RepositorySMB RepositoryType = "smb" + RepositoryNFS RepositoryType = "nfs" + RepositorySFTP RepositoryType = "sftp" +) + +type Config struct { + SchemaVersion int `json:"schemaVersion"` + Jobs []Job `json:"jobs"` + Repositories []Repository `json:"repositories"` + Notifications []NotificationTarget `json:"notifications"` + Settings Settings `json:"settings"` +} + +type Settings struct { + ResticPath string `json:"resticPath"` + RestoreRoot string `json:"restoreRoot"` + PersistentLogDir string `json:"persistentLogDir,omitempty"` + CatchUpWindowHrs int `json:"catchUpWindowHours"` + CheckCron string `json:"checkCron"` + PruneCron string `json:"pruneCron"` +} + +type Job struct { + SchemaVersion int `json:"schemaVersion"` + ID string `json:"id"` + Name string `json:"name"` + Type JobType `json:"type"` + Enabled bool `json:"enabled"` + RepositoryID string `json:"repositoryId"` + Sources []Source `json:"sources"` + Schedule Schedule `json:"schedule"` + Consistency Consistency `json:"consistency"` + Compression string `json:"compression"` + Excludes []string `json:"excludes,omitempty"` + Tags []string `json:"tags,omitempty"` + Retention Retention `json:"retention"` + NotifyOn []string `json:"notifyOn,omitempty"` + ShutdownSecs int `json:"shutdownTimeoutSeconds"` +} + +type Source struct { + Path string `json:"path,omitempty"` + WorkloadID string `json:"workloadId,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +type Schedule struct { + Cron string `json:"cron"` + Timezone string `json:"timezone,omitempty"` +} + +type Consistency struct { + Mode string `json:"mode"` +} + +type Retention struct { + Daily int `json:"daily"` + Weekly int `json:"weekly"` + Monthly int `json:"monthly"` +} + +type Repository struct { + SchemaVersion int `json:"schemaVersion"` + ID string `json:"id"` + Name string `json:"name"` + Type RepositoryType `json:"type"` + Location string `json:"location"` + PasswordRef string `json:"passwordRef"` + CredentialRef string `json:"credentialRef,omitempty"` + Mount *MountConfig `json:"mount,omitempty"` + Options map[string]string `json:"options,omitempty"` +} + +type MountConfig struct { + Managed bool `json:"managed"` + Remote string `json:"remote,omitempty"` + MountPoint string `json:"mountPoint,omitempty"` + ReadOnly bool `json:"readOnly,omitempty"` +} + +type NotificationTarget struct { + SchemaVersion int `json:"schemaVersion"` + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Enabled bool `json:"enabled"` + URL string `json:"url,omitempty"` + Topic string `json:"topic,omitempty"` + TokenRef string `json:"tokenRef,omitempty"` + Events []string `json:"events,omitempty"` +} + +type Run struct { + SchemaVersion int `json:"schemaVersion"` + ID string `json:"id"` + JobID string `json:"jobId,omitempty"` + TaskType string `json:"taskType"` + Status string `json:"status"` + Priority int `json:"priority"` + CreatedAt time.Time `json:"createdAt"` + StartedAt *time.Time `json:"startedAt,omitempty"` + FinishedAt *time.Time `json:"finishedAt,omitempty"` + SnapshotID string `json:"snapshotId,omitempty"` + Message string `json:"message,omitempty"` + ErrorCode string `json:"errorCode,omitempty"` + BytesAdded int64 `json:"bytesAdded,omitempty"` + FilesNew int64 `json:"filesNew,omitempty"` +} + +type RestoreTask struct { + SchemaVersion int `json:"schemaVersion"` + ID string `json:"id"` + RepositoryID string `json:"repositoryId"` + SnapshotID string `json:"snapshotId"` + Includes []string `json:"includes"` + Target string `json:"target"` + InPlace bool `json:"inPlace"` + Confirmed bool `json:"confirmed"` +} + +type Snapshot struct { + ID string `json:"id"` + ShortID string `json:"short_id"` + Time time.Time `json:"time"` + Hostname string `json:"hostname"` + Paths []string `json:"paths"` + Tags []string `json:"tags"` +} + +func DefaultConfig() Config { + return Config{ + SchemaVersion: SchemaVersion, + Jobs: []Job{}, + Repositories: []Repository{}, + Notifications: []NotificationTarget{}, + Settings: Settings{ + ResticPath: "/usr/local/libexec/backupper/restic", + RestoreRoot: "/mnt/user/backupper-restores", + CatchUpWindowHrs: 24, + CheckCron: "0 3 1 * *", + PruneCron: "0 4 * * 0", + }, + } +} diff --git a/internal/model/validate.go b/internal/model/validate.go new file mode 100644 index 0000000..1e67538 --- /dev/null +++ b/internal/model/validate.go @@ -0,0 +1,124 @@ +package model + +import ( + "errors" + "fmt" + "path/filepath" + "regexp" + "strings" +) + +var idPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`) + +func ValidateConfig(c Config) error { + if c.SchemaVersion != SchemaVersion { + return fmt.Errorf("unsupported schemaVersion %d", c.SchemaVersion) + } + repos := make(map[string]Repository, len(c.Repositories)) + for _, repo := range c.Repositories { + if err := ValidateRepository(repo); err != nil { + return fmt.Errorf("repository %q: %w", repo.ID, err) + } + if _, exists := repos[repo.ID]; exists { + return fmt.Errorf("duplicate repository id %q", repo.ID) + } + repos[repo.ID] = repo + } + jobs := make(map[string]struct{}, len(c.Jobs)) + repositoryOwners := make(map[string]string, len(c.Jobs)) + for _, job := range c.Jobs { + if err := ValidateJob(job); err != nil { + return fmt.Errorf("job %q: %w", job.ID, err) + } + if _, exists := jobs[job.ID]; exists { + return fmt.Errorf("duplicate job id %q", job.ID) + } + jobs[job.ID] = struct{}{} + if _, exists := repos[job.RepositoryID]; !exists { + return fmt.Errorf("job %q references unknown repository %q", job.ID, job.RepositoryID) + } + if owner, exists := repositoryOwners[job.RepositoryID]; exists { + return fmt.Errorf("repository %q is already assigned to job %q", job.RepositoryID, owner) + } + repositoryOwners[job.RepositoryID] = job.ID + } + if !filepath.IsAbs(c.Settings.RestoreRoot) { + return errors.New("restoreRoot must be absolute") + } + return nil +} + +func ValidateJob(j Job) error { + if j.SchemaVersion != SchemaVersion || !idPattern.MatchString(j.ID) { + return errors.New("invalid schemaVersion or id") + } + if strings.TrimSpace(j.Name) == "" || strings.TrimSpace(j.RepositoryID) == "" { + return errors.New("name and repositoryId are required") + } + switch j.Type { + case JobShare, JobAppdata, JobDocker, JobVM, JobFlash: + default: + return fmt.Errorf("unsupported job type %q", j.Type) + } + if len(j.Sources) == 0 && j.Type != JobFlash { + return errors.New("at least one source is required") + } + if j.Type == JobFlash && len(j.Sources) == 0 { + j.Sources = []Source{{Path: "/boot"}} + } + for _, source := range j.Sources { + if source.Path != "" && !filepath.IsAbs(source.Path) { + return fmt.Errorf("source path %q must be absolute", source.Path) + } + if source.Path == "" && source.WorkloadID == "" { + return errors.New("source requires path or workloadId") + } + } + if j.Compression != "auto" && j.Compression != "off" && j.Compression != "max" { + return errors.New("compression must be auto, off, or max") + } + if j.Consistency.Mode != "live" && j.Consistency.Mode != "stop" { + return errors.New("consistency mode must be live or stop") + } + if j.Retention.Daily < 0 || j.Retention.Weekly < 0 || j.Retention.Monthly < 0 { + return errors.New("retention values cannot be negative") + } + for _, exclude := range j.Excludes { + if strings.ContainsAny(exclude, "\x00\r\n") { + return errors.New("exclude contains forbidden control characters") + } + } + return nil +} + +func ValidateRepository(r Repository) error { + if r.SchemaVersion != SchemaVersion || !idPattern.MatchString(r.ID) { + return errors.New("invalid schemaVersion or id") + } + if r.Name == "" || r.Location == "" || r.PasswordRef == "" { + return errors.New("name, location, and passwordRef are required") + } + if !idPattern.MatchString(r.PasswordRef) || (r.CredentialRef != "" && !idPattern.MatchString(r.CredentialRef)) { + return errors.New("secret references must be valid IDs") + } + switch r.Type { + case RepositoryLocal, RepositorySMB, RepositoryNFS, RepositorySFTP: + default: + return fmt.Errorf("unsupported repository type %q", r.Type) + } + if r.Type == RepositoryLocal && !filepath.IsAbs(r.Location) { + return errors.New("local repository location must be absolute") + } + if (r.Type == RepositorySMB || r.Type == RepositoryNFS) && r.Mount != nil && r.Mount.Managed { + if r.Mount.Remote == "" || !filepath.IsAbs(r.Mount.MountPoint) { + return errors.New("managed mount requires remote and absolute mountPoint") + } + } + if r.Type == RepositorySFTP && r.CredentialRef != "" { + knownHosts := r.Options["knownHostsPath"] + if !filepath.IsAbs(knownHosts) || strings.ContainsAny(knownHosts, " \t\r\n") { + return errors.New("SFTP key authentication requires an absolute knownHostsPath without whitespace") + } + } + return nil +} diff --git a/internal/model/validate_test.go b/internal/model/validate_test.go new file mode 100644 index 0000000..9967151 --- /dev/null +++ b/internal/model/validate_test.go @@ -0,0 +1,36 @@ +package model + +import "testing" + +func TestValidateConfig(t *testing.T) { + c := DefaultConfig() + c.Repositories = append(c.Repositories, Repository{SchemaVersion: 1, ID: "repo", Name: "Local", Type: RepositoryLocal, Location: "/tmp/repo", PasswordRef: "password"}) + c.Jobs = append(c.Jobs, Job{SchemaVersion: 1, ID: "job", Name: "Share", Type: JobShare, Enabled: true, RepositoryID: "repo", Sources: []Source{{Path: "/mnt/user/data"}}, Schedule: Schedule{Cron: "0 2 * * *"}, Consistency: Consistency{Mode: "live"}, Compression: "auto", Retention: Retention{Daily: 7, Weekly: 4, Monthly: 12}, ShutdownSecs: 120}) + if err := ValidateConfig(c); err != nil { + t.Fatalf("valid config rejected: %v", err) + } + c.Jobs[0].Sources[0].Path = "relative" + if err := ValidateConfig(c); err == nil { + t.Fatal("relative source path accepted") + } +} + +func TestRepositoryIsolation(t *testing.T) { + c := DefaultConfig() + c.Jobs = []Job{{SchemaVersion: 1, ID: "job", Name: "Broken", Type: JobFlash, RepositoryID: "missing", Consistency: Consistency{Mode: "live"}, Compression: "auto"}} + if err := ValidateConfig(c); err == nil { + t.Fatal("unknown repository reference accepted") + } +} + +func TestRepositoryCannotBeSharedByJobs(t *testing.T) { + c := DefaultConfig() + c.Repositories = []Repository{{SchemaVersion: 1, ID: "repo", Name: "Local", Type: RepositoryLocal, Location: "/tmp/repo", PasswordRef: "password"}} + base := Job{SchemaVersion: 1, Name: "Flash", Type: JobFlash, RepositoryID: "repo", Consistency: Consistency{Mode: "live"}, Compression: "auto"} + first, second := base, base + first.ID, second.ID = "first", "second" + c.Jobs = []Job{first, second} + if err := ValidateConfig(c); err == nil { + t.Fatal("shared repository accepted") + } +} diff --git a/internal/notify/notify.go b/internal/notify/notify.go new file mode 100644 index 0000000..27e2b4a --- /dev/null +++ b/internal/notify/notify.go @@ -0,0 +1,65 @@ +package notify + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/url" + "os/exec" + "strings" + "time" + + "github.com/backupper-unraid/backupper/internal/model" +) + +type SecretGetter interface{ Get(string) (string, error) } + +type Sender struct { + Secrets SecretGetter + Client *http.Client +} + +func (s *Sender) Send(ctx context.Context, target model.NotificationTarget, title, message, severity string) error { + if !target.Enabled { + return nil + } + switch target.Type { + case "unraid": + importance := "normal" + if severity == "error" { + importance = "alert" + } + return exec.CommandContext(ctx, "/usr/local/emhttp/webGui/scripts/notify", "-e", "Backupper", "-s", title, "-d", message, "-i", importance).Run() + case "ntfy": + endpoint := strings.TrimRight(target.URL, "/") + "/" + url.PathEscape(target.Topic) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBufferString(message)) + if err != nil { + return err + } + req.Header.Set("Title", title) + req.Header.Set("Tags", "floppy_disk") + if target.TokenRef != "" { + token, err := s.Secrets.Get(target.TokenRef) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + } + client := s.Client + if client == nil { + client = &http.Client{Timeout: 15 * time.Second} + } + response, err := client.Do(req) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("ntfy returned %s", response.Status) + } + return nil + default: + return fmt.Errorf("unsupported notification type %q", target.Type) + } +} diff --git a/internal/platform/mounts.go b/internal/platform/mounts.go new file mode 100644 index 0000000..8a46c53 --- /dev/null +++ b/internal/platform/mounts.go @@ -0,0 +1,101 @@ +package platform + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/backupper-unraid/backupper/internal/model" +) + +type CredentialGetter interface{ Get(string) (string, error) } + +type MountManager struct { + RuntimeDir string + Secrets CredentialGetter +} + +type MountedRepository struct { + Repository model.Repository + Mounted bool + CredentialFile string +} + +func (m *MountManager) Prepare(ctx context.Context, repo model.Repository) (MountedRepository, error) { + result := MountedRepository{Repository: repo} + if repo.Type != model.RepositorySMB && repo.Type != model.RepositoryNFS { + return result, nil + } + if repo.Mount == nil || !repo.Mount.Managed { + if _, err := os.Stat(repo.Location); err != nil { + return result, fmt.Errorf("connectivity: external mount unavailable: %w", err) + } + return result, nil + } + point := filepath.Clean(repo.Mount.MountPoint) + if !strings.HasPrefix(point, "/mnt/remotes/") && !strings.HasPrefix(point, "/mnt/disks/") { + return result, errors.New("validation: managed mountPoint must be below /mnt/remotes or /mnt/disks") + } + if err := os.MkdirAll(point, 0700); err != nil { + return result, err + } + mountType := string(repo.Type) + if repo.Type == model.RepositorySMB { + mountType = "cifs" + } + args := []string{"-t", mountType, repo.Mount.Remote, point} + if repo.Type == model.RepositorySMB { + options := []string{"nosuid", "nodev"} + if repo.CredentialRef != "" { + credential, err := m.Secrets.Get(repo.CredentialRef) + if err != nil { + return result, fmt.Errorf("authentication: load mount credential: %w", err) + } + dir := filepath.Join(m.RuntimeDir, "secrets") + if err := os.MkdirAll(dir, 0700); err != nil { + return result, err + } + f, err := os.CreateTemp(dir, "smb-credentials-*") + if err != nil { + return result, err + } + result.CredentialFile = f.Name() + if err := f.Chmod(0600); err != nil { + f.Close() + return result, err + } + if _, err := f.WriteString(credential); err != nil { + f.Close() + return result, err + } + if err := f.Close(); err != nil { + return result, err + } + options = append(options, "credentials="+result.CredentialFile) + } + args = append(args, "-o", strings.Join(options, ",")) + } else { + args = append(args, "-o", "nosuid,nodev") + } + if err := exec.CommandContext(ctx, "mount", args...).Run(); err != nil { + m.Cleanup(context.Background(), result) + return result, fmt.Errorf("connectivity: mount repository: %w", err) + } + result.Mounted = true + result.Repository.Location = point + return result, nil +} + +func (m *MountManager) Cleanup(ctx context.Context, mounted MountedRepository) error { + if mounted.CredentialFile != "" { + defer os.Remove(mounted.CredentialFile) + } + if !mounted.Mounted { + return nil + } + return exec.CommandContext(ctx, "umount", mounted.Repository.Location).Run() +} diff --git a/internal/platform/paths.go b/internal/platform/paths.go new file mode 100644 index 0000000..90279d8 --- /dev/null +++ b/internal/platform/paths.go @@ -0,0 +1,61 @@ +package platform + +import ( + "errors" + "os" + "path/filepath" + "strings" +) + +func ValidateRestoreTarget(target, restoreRoot string, inPlace, confirmed bool) (string, error) { + if !filepath.IsAbs(target) { + return "", errors.New("validation: restore target must be absolute") + } + clean := filepath.Clean(target) + if clean == "/" || clean == "/boot" || clean == "/etc" || clean == "/usr" || clean == "/var" { + return "", errors.New("validation: protected system restore target") + } + if !inPlace { + root := filepath.Clean(restoreRoot) + if clean != root && !strings.HasPrefix(clean, root+string(os.PathSeparator)) { + return "", errors.New("validation: staging restore must remain below restoreRoot") + } + if err := rejectSymlinks(root, clean); err != nil { + return "", err + } + } else if !confirmed { + return "", errors.New("validation: in-place restore requires explicit confirmation") + } + if inPlace { + anchor := string(os.PathSeparator) + strings.Split(strings.TrimPrefix(clean, string(os.PathSeparator)), string(os.PathSeparator))[0] + if err := rejectSymlinks(anchor, clean); err != nil { + return "", err + } + } + return clean, nil +} + +func rejectSymlinks(root, target string) error { + relative, err := filepath.Rel(root, target) + if err != nil { + return err + } + current := root + for _, component := range strings.Split(relative, string(os.PathSeparator)) { + if component == "." || component == "" { + continue + } + current = filepath.Join(current, component) + info, err := os.Lstat(current) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return errors.New("validation: restore target traverses a symlink") + } + } + return nil +} diff --git a/internal/platform/paths_test.go b/internal/platform/paths_test.go new file mode 100644 index 0000000..f223cde --- /dev/null +++ b/internal/platform/paths_test.go @@ -0,0 +1,36 @@ +package platform + +import ( + "os" + "path/filepath" + "testing" +) + +func TestValidateRestoreTarget(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "task") + if got, err := ValidateRestoreTarget(target, root, false, false); err != nil || got != target { + t.Fatalf("staging target rejected: %q %v", got, err) + } + if _, err := ValidateRestoreTarget("/etc", root, true, true); err == nil { + t.Fatal("protected target accepted") + } + if _, err := ValidateRestoreTarget("/mnt/user/data", root, true, false); err == nil { + t.Fatal("unconfirmed in-place restore accepted") + } +} + +func TestValidateRestoreTargetRejectsSymlink(t *testing.T) { + root := t.TempDir() + real := filepath.Join(root, "real") + link := filepath.Join(root, "link") + if err := os.Mkdir(real, 0755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(real, link); err != nil { + t.Fatal(err) + } + if _, err := ValidateRestoreTarget(filepath.Join(link, "task"), root, false, false); err == nil { + t.Fatal("symlink traversal accepted") + } +} diff --git a/internal/platform/workloads.go b/internal/platform/workloads.go new file mode 100644 index 0000000..8db81df --- /dev/null +++ b/internal/platform/workloads.go @@ -0,0 +1,215 @@ +package platform + +import ( + "context" + "encoding/json" + "encoding/xml" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/backupper-unraid/backupper/internal/model" +) + +type WorkloadManager struct{ RuntimeDir string } + +type Prepared struct { + Sources []string + Stopped []model.Source + MetadataDir string + Kind model.JobType +} + +func (w *WorkloadManager) Prepare(ctx context.Context, job model.Job) (Prepared, error) { + prepared := Prepared{Kind: job.Type} + for _, source := range job.Sources { + if source.Path != "" { + prepared.Sources = append(prepared.Sources, source.Path) + } + } + if job.Type != model.JobDocker && job.Type != model.JobVM { + return prepared, nil + } + metadataDir, err := os.MkdirTemp(w.RuntimeDir, "metadata-"+job.ID+"-") + if err != nil { + return prepared, err + } + prepared.MetadataDir = metadataDir + prepared.Sources = append(prepared.Sources, metadataDir) + if job.Type == model.JobDocker { + if _, err := os.Stat("/boot/config/plugins/dockerMan/templates-user"); err == nil { + prepared.Sources = append(prepared.Sources, "/boot/config/plugins/dockerMan/templates-user") + } + } + for _, source := range job.Sources { + if source.WorkloadID == "" { + continue + } + discovered, err := w.captureMetadata(ctx, job.Type, source.WorkloadID, metadataDir) + if err != nil { + w.Cleanup(context.Background(), prepared) + return Prepared{}, err + } + prepared.Sources = append(prepared.Sources, discovered...) + if job.Consistency.Mode == "stop" { + if err := w.stop(ctx, job.Type, source.WorkloadID, time.Duration(job.ShutdownSecs)*time.Second); err != nil { + w.Cleanup(context.Background(), prepared) + return Prepared{}, err + } + prepared.Stopped = append(prepared.Stopped, source) + } + } + prepared.Sources = uniqueStrings(prepared.Sources) + return prepared, nil +} + +func (w *WorkloadManager) Cleanup(ctx context.Context, prepared Prepared) error { + var first error + for i := len(prepared.Stopped) - 1; i >= 0; i-- { + source := prepared.Stopped[i] + if err := w.start(ctx, prepared.Kind, source.WorkloadID); err != nil && first == nil { + first = err + } + } + if prepared.MetadataDir != "" { + _ = os.RemoveAll(prepared.MetadataDir) + } + return first +} + +func (w *WorkloadManager) captureMetadata(ctx context.Context, kind model.JobType, id, dir string) ([]string, error) { + var cmd *exec.Cmd + var name string + if kind == model.JobDocker { + cmd, name = exec.CommandContext(ctx, "docker", "inspect", id), "docker-"+safeName(id)+".json" + } else { + cmd, name = exec.CommandContext(ctx, "virsh", "dumpxml", id), "vm-"+safeName(id)+".xml" + } + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("source: capture metadata for %s: %w", id, err) + } + if err := os.WriteFile(filepath.Join(dir, name), output, 0600); err != nil { + return nil, err + } + if kind == model.JobDocker { + var inspected []struct { + Mounts []struct { + Type string `json:"Type"` + Source string `json:"Source"` + } `json:"Mounts"` + } + if err := json.Unmarshal(output, &inspected); err != nil { + return nil, fmt.Errorf("source: decode docker metadata: %w", err) + } + var paths []string + if len(inspected) > 0 { + for _, mount := range inspected[0].Mounts { + if mount.Type == "bind" && filepath.IsAbs(mount.Source) { + paths = append(paths, mount.Source) + } + } + } + return paths, nil + } + var domain struct { + OS struct { + NVRAM string `xml:"nvram"` + } `xml:"os"` + Devices struct { + Disks []struct { + Device string `xml:"device,attr"` + Source struct { + File string `xml:"file,attr"` + Dev string `xml:"dev,attr"` + } `xml:"source"` + } `xml:"disk"` + } `xml:"devices"` + } + if err := xml.Unmarshal(output, &domain); err != nil { + return nil, fmt.Errorf("source: decode vm metadata: %w", err) + } + paths := []string{} + if filepath.IsAbs(strings.TrimSpace(domain.OS.NVRAM)) { + paths = append(paths, strings.TrimSpace(domain.OS.NVRAM)) + } + for _, disk := range domain.Devices.Disks { + if disk.Device != "disk" && disk.Device != "lun" { + continue + } + path := disk.Source.File + if path == "" { + path = disk.Source.Dev + } + if filepath.IsAbs(path) { + paths = append(paths, path) + } + } + return paths, nil +} + +func (w *WorkloadManager) stop(ctx context.Context, kind model.JobType, id string, timeout time.Duration) error { + if timeout <= 0 { + timeout = 120 * time.Second + } + if kind == model.JobDocker { + seconds := fmt.Sprint(int(timeout.Seconds())) + if err := exec.CommandContext(ctx, "docker", "stop", "--time", seconds, id).Run(); err != nil { + return fmt.Errorf("source: stop docker %s: %w", id, err) + } + return nil + } + if err := exec.CommandContext(ctx, "virsh", "shutdown", id).Run(); err != nil { + return fmt.Errorf("source: shutdown vm %s: %w", id, err) + } + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + output, _ := exec.CommandContext(ctx, "virsh", "domstate", id).Output() + if strings.Contains(strings.ToLower(string(output)), "shut off") { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + } + } + return fmt.Errorf("source: vm %s did not stop before timeout", id) +} + +func (w *WorkloadManager) start(ctx context.Context, kind model.JobType, id string) error { + if kind == model.JobDocker { + return exec.CommandContext(ctx, "docker", "start", id).Run() + } + return exec.CommandContext(ctx, "virsh", "start", id).Run() +} + +func safeName(value string) string { + value = strings.Map(func(r rune) rune { + if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' { + return r + } + return '_' + }, value) + if value == "" { + return "workload" + } + return value +} + +func uniqueStrings(values []string) []string { + seen := make(map[string]struct{}, len(values)) + result := make([]string, 0, len(values)) + for _, value := range values { + value = filepath.Clean(value) + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + return result +} diff --git a/internal/queue/queue.go b/internal/queue/queue.go new file mode 100644 index 0000000..f8279dc --- /dev/null +++ b/internal/queue/queue.go @@ -0,0 +1,157 @@ +package queue + +import ( + "context" + "errors" + "sort" + "sync" + "time" + + "github.com/backupper-unraid/backupper/internal/model" +) + +var ErrDuplicate = errors.New("task already queued or running") + +type Handler func(context.Context, model.Run) model.Run +type OnChange func([]model.Run) + +type Queue struct { + mu sync.Mutex + cond *sync.Cond + pending []model.Run + history []model.Run + active *model.Run + cancel context.CancelFunc + stopping bool + handler Handler + onChange OnChange +} + +func New(handler Handler, onChange OnChange) *Queue { + q := &Queue{handler: handler, onChange: onChange} + q.cond = sync.NewCond(&q.mu) + return q +} + +func (q *Queue) RestoreHistory(runs []model.Run) { + q.mu.Lock() + defer q.mu.Unlock() + for _, run := range runs { + if run.Status == "queued" || run.Status == "running" { + run.Status = "failed" + run.ErrorCode = "environment" + run.Message = "daemon restarted while task was active" + now := time.Now().UTC() + run.FinishedAt = &now + } + q.history = append(q.history, run) + } +} + +func (q *Queue) Enqueue(run model.Run) error { + q.mu.Lock() + defer q.mu.Unlock() + if q.stopping { + return errors.New("queue is stopping") + } + if q.active != nil && run.JobID != "" && q.active.JobID == run.JobID { + return ErrDuplicate + } + for _, item := range q.pending { + if run.JobID != "" && item.JobID == run.JobID { + return ErrDuplicate + } + } + run.Status = "queued" + q.pending = append(q.pending, run) + sort.SliceStable(q.pending, func(i, j int) bool { return q.pending[i].Priority > q.pending[j].Priority }) + q.emitLocked() + q.cond.Signal() + return nil +} + +func (q *Queue) Run(ctx context.Context) { + for { + q.mu.Lock() + for len(q.pending) == 0 && !q.stopping { + q.cond.Wait() + } + if q.stopping { + q.mu.Unlock() + return + } + run := q.pending[0] + q.pending = q.pending[1:] + now := time.Now().UTC() + run.Status, run.StartedAt = "running", &now + workCtx, cancel := context.WithCancel(ctx) + q.cancel, q.active = cancel, &run + q.emitLocked() + q.mu.Unlock() + + result := q.handler(workCtx, run) + finished := time.Now().UTC() + result.FinishedAt = &finished + + q.mu.Lock() + q.active, q.cancel = nil, nil + q.history = append(q.history, result) + if len(q.history) > 500 { + q.history = q.history[len(q.history)-500:] + } + q.emitLocked() + q.mu.Unlock() + } +} + +func (q *Queue) Cancel(id string) bool { + q.mu.Lock() + defer q.mu.Unlock() + if q.active != nil && q.active.ID == id && q.cancel != nil { + q.cancel() + return true + } + for i := range q.pending { + if q.pending[i].ID == id { + run := q.pending[i] + q.pending = append(q.pending[:i], q.pending[i+1:]...) + now := time.Now().UTC() + run.Status, run.ErrorCode, run.FinishedAt = "cancelled", "cancelled", &now + q.history = append(q.history, run) + q.emitLocked() + return true + } + } + return false +} + +func (q *Queue) Stop() { + q.mu.Lock() + q.stopping = true + if q.cancel != nil { + q.cancel() + } + q.cond.Broadcast() + q.mu.Unlock() +} + +func (q *Queue) Snapshot() []model.Run { + q.mu.Lock() + defer q.mu.Unlock() + return q.snapshotLocked() +} + +func (q *Queue) snapshotLocked() []model.Run { + result := append([]model.Run{}, q.history...) + if q.active != nil { + result = append(result, *q.active) + } + result = append(result, q.pending...) + return result +} + +func (q *Queue) emitLocked() { + if q.onChange != nil { + q.onChange(q.snapshotLocked()) + } +} diff --git a/internal/queue/queue_test.go b/internal/queue/queue_test.go new file mode 100644 index 0000000..55bfa13 --- /dev/null +++ b/internal/queue/queue_test.go @@ -0,0 +1,30 @@ +package queue + +import ( + "context" + "testing" + "time" + + "github.com/backupper-unraid/backupper/internal/model" +) + +func TestQueueDeduplicatesJobAndRunsTask(t *testing.T) { + done := make(chan struct{}) + q := New(func(_ context.Context, run model.Run) model.Run { run.Status = "success"; close(done); return run }, nil) + run := model.Run{ID: "one", JobID: "job", TaskType: "backup", CreatedAt: time.Now()} + if err := q.Enqueue(run); err != nil { + t.Fatal(err) + } + if err := q.Enqueue(model.Run{ID: "two", JobID: "job"}); err != ErrDuplicate { + t.Fatalf("duplicate error = %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go q.Run(ctx) + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("queue did not execute task") + } + q.Stop() +} diff --git a/internal/restic/restic.go b/internal/restic/restic.go new file mode 100644 index 0000000..dec9abd --- /dev/null +++ b/internal/restic/restic.go @@ -0,0 +1,233 @@ +package restic + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/backupper-unraid/backupper/internal/model" +) + +type SecretGetter interface{ Get(string) (string, error) } + +type Runner struct { + Binary string + RuntimeDir string + Secrets SecretGetter + Log *slog.Logger +} + +type Summary struct { + MessageType string `json:"message_type"` + SnapshotID string `json:"snapshot_id"` + DataAdded int64 `json:"data_added"` + FilesNew int64 `json:"files_new"` +} + +func (r *Runner) Init(ctx context.Context, repo model.Repository) error { + return r.run(ctx, repo, []string{"init"}, nil, nil) +} + +func (r *Runner) Backup(ctx context.Context, repo model.Repository, job model.Job, sources []string) (Summary, error) { + args := []string{"backup", "--json", "--compression", job.Compression} + for _, tag := range append([]string{"backupper", "job:" + job.ID}, job.Tags...) { + args = append(args, "--tag", tag) + } + for _, exclude := range job.Excludes { + args = append(args, "--exclude", exclude) + } + args = append(args, sources...) + var summary Summary + err := r.run(ctx, repo, args, func(line []byte) { + var candidate Summary + if json.Unmarshal(line, &candidate) == nil && candidate.MessageType == "summary" { + summary = candidate + } + }, nil) + return summary, err +} + +func (r *Runner) Forget(ctx context.Context, repo model.Repository, job model.Job) error { + args := []string{"forget", "--tag", "job:" + job.ID, "--keep-daily", fmt.Sprint(job.Retention.Daily), "--keep-weekly", fmt.Sprint(job.Retention.Weekly), "--keep-monthly", fmt.Sprint(job.Retention.Monthly)} + return r.run(ctx, repo, args, nil, nil) +} + +func (r *Runner) Check(ctx context.Context, repo model.Repository) error { + return r.run(ctx, repo, []string{"check"}, nil, nil) +} +func (r *Runner) Prune(ctx context.Context, repo model.Repository) error { + return r.run(ctx, repo, []string{"prune"}, nil, nil) +} + +func (r *Runner) Snapshots(ctx context.Context, repo model.Repository) ([]model.Snapshot, error) { + var output []byte + err := r.run(ctx, repo, []string{"snapshots", "--json"}, nil, &output) + if err != nil { + return nil, err + } + var snapshots []model.Snapshot + if err := json.Unmarshal(output, &snapshots); err != nil { + return nil, fmt.Errorf("decode snapshots: %w", err) + } + return snapshots, nil +} + +func (r *Runner) List(ctx context.Context, repo model.Repository, snapshot, path string) ([]map[string]any, error) { + args := []string{"ls", snapshot, "--json"} + if path != "" { + args = append(args, path) + } + var output []byte + if err := r.run(ctx, repo, args, nil, &output); err != nil { + return nil, err + } + items := []map[string]any{} + scanner := bufio.NewScanner(strings.NewReader(string(output))) + for scanner.Scan() { + var item map[string]any + if json.Unmarshal(scanner.Bytes(), &item) == nil && item["struct_type"] == "node" { + items = append(items, item) + } + if len(items) >= 5000 { + break + } + } + return items, scanner.Err() +} + +func (r *Runner) Restore(ctx context.Context, repo model.Repository, task model.RestoreTask) error { + args := []string{"restore", task.SnapshotID, "--target", task.Target} + for _, include := range task.Includes { + args = append(args, "--include", include) + } + return r.run(ctx, repo, args, nil, nil) +} + +func (r *Runner) run(ctx context.Context, repo model.Repository, args []string, onLine func([]byte), capture *[]byte) error { + password, err := r.Secrets.Get(repo.PasswordRef) + if err != nil { + return fmt.Errorf("authentication: load repository password: %w", err) + } + secretDir := filepath.Join(r.RuntimeDir, "secrets") + if err := os.MkdirAll(secretDir, 0700); err != nil { + return err + } + f, err := os.CreateTemp(secretDir, "restic-password-*") + if err != nil { + return err + } + passwordPath := f.Name() + defer os.Remove(passwordPath) + if err := f.Chmod(0600); err != nil { + f.Close() + return err + } + if _, err := io.WriteString(f, password); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + + globalArgs := []string{"-r", repositoryLocation(repo)} + credentialPath := "" + if repo.Type == model.RepositorySFTP && repo.CredentialRef != "" { + privateKey, err := r.Secrets.Get(repo.CredentialRef) + if err != nil { + return fmt.Errorf("authentication: load SFTP private key: %w", err) + } + keyFile, err := os.CreateTemp(secretDir, "sftp-key-*") + if err != nil { + return err + } + credentialPath = keyFile.Name() + defer os.Remove(credentialPath) + if err := keyFile.Chmod(0600); err != nil { + keyFile.Close() + return err + } + if _, err := io.WriteString(keyFile, privateKey); err != nil { + keyFile.Close() + return err + } + if err := keyFile.Close(); err != nil { + return err + } + knownHosts := repo.Options["knownHostsPath"] + command := "ssh -i " + credentialPath + " -o IdentitiesOnly=yes -o UserKnownHostsFile=" + knownHosts + " -o StrictHostKeyChecking=yes" + globalArgs = append(globalArgs, "-o", "sftp.command="+command) + } + fullArgs := append(globalArgs, args...) + cmd := exec.CommandContext(ctx, r.Binary, fullArgs...) + cmd.Env = []string{"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "HOME=/root", "RESTIC_PASSWORD_FILE=" + passwordPath} + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return err + } + if err := cmd.Start(); err != nil { + return classify(err) + } + var captured []byte + done := make(chan struct{}) + go func() { + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 64*1024), 2*1024*1024) + for scanner.Scan() { + line := append([]byte(nil), scanner.Bytes()...) + if capture != nil { + captured = append(captured, line...) + captured = append(captured, '\n') + } + if onLine != nil { + onLine(line) + } + } + close(done) + }() + errBytes, _ := io.ReadAll(io.LimitReader(stderr, 1024*1024)) + err = cmd.Wait() + <-done + if capture != nil { + *capture = captured + } + if err != nil { + message := redact(string(errBytes), repo) + if errors.Is(ctx.Err(), context.Canceled) { + return fmt.Errorf("cancelled: %w", ctx.Err()) + } + return fmt.Errorf("restic: %s: %w", strings.TrimSpace(message), err) + } + return nil +} + +func repositoryLocation(repo model.Repository) string { + if repo.Type == model.RepositorySFTP && !strings.HasPrefix(repo.Location, "sftp:") { + return "sftp:" + repo.Location + } + return repo.Location +} + +func redact(value string, repo model.Repository) string { + value = strings.ReplaceAll(value, repo.Location, "[repository]") + return value +} + +func classify(err error) error { + if errors.Is(err, exec.ErrNotFound) { + return fmt.Errorf("environment: restic binary not found: %w", err) + } + return err +} diff --git a/internal/restic/restic_test.go b/internal/restic/restic_test.go new file mode 100644 index 0000000..64103b5 --- /dev/null +++ b/internal/restic/restic_test.go @@ -0,0 +1,47 @@ +package restic + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/backupper-unraid/backupper/internal/model" +) + +type fakeSecrets map[string]string + +func (s fakeSecrets) Get(id string) (string, error) { return s[id], nil } + +func TestBackupUsesPasswordFileAndStructuredArguments(t *testing.T) { + dir := t.TempDir() + argsPath := filepath.Join(dir, "args") + passwordPath := filepath.Join(dir, "password") + script := filepath.Join(dir, "restic") + body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\ncat \"$RESTIC_PASSWORD_FILE\" > '%s'\nprintf '%%s\\n' '{\"message_type\":\"summary\",\"snapshot_id\":\"abc123\",\"data_added\":42,\"files_new\":3}'\n", argsPath, passwordPath) + if err := os.WriteFile(script, []byte(body), 0700); err != nil { + t.Fatal(err) + } + runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "top-secret"}} + repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo path", PasswordRef: "password"} + job := model.Job{ID: "job", Compression: "auto", Tags: []string{"daily"}, Excludes: []string{"*.tmp"}} + summary, err := runner.Backup(context.Background(), repo, job, []string{"/source path"}) + if err != nil { + t.Fatal(err) + } + if summary.SnapshotID != "abc123" || summary.DataAdded != 42 { + t.Fatalf("unexpected summary: %+v", summary) + } + args, _ := os.ReadFile(argsPath) + for _, expected := range []string{"/repo path", "backup", "--json", "/source path"} { + if !strings.Contains(string(args), expected) { + t.Fatalf("arguments missing %q: %s", expected, args) + } + } + password, _ := os.ReadFile(passwordPath) + if string(password) != "top-secret" { + t.Fatal("password file was not provided") + } +} diff --git a/internal/scheduler/cron.go b/internal/scheduler/cron.go new file mode 100644 index 0000000..0a27024 --- /dev/null +++ b/internal/scheduler/cron.go @@ -0,0 +1,96 @@ +package scheduler + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +type Expression struct{ fields [5]field } +type field map[int]bool + +var bounds = [5][2]int{{0, 59}, {0, 23}, {1, 31}, {1, 12}, {0, 6}} + +func Parse(spec string) (Expression, error) { + parts := strings.Fields(spec) + if len(parts) != 5 { + return Expression{}, fmt.Errorf("cron requires five fields") + } + var expression Expression + for i, part := range parts { + parsed, err := parseField(part, bounds[i][0], bounds[i][1]) + if err != nil { + return Expression{}, fmt.Errorf("cron field %d: %w", i+1, err) + } + expression.fields[i] = parsed + } + return expression, nil +} + +func parseField(value string, min, max int) (field, error) { + result := field{} + for _, segment := range strings.Split(value, ",") { + step := 1 + base := segment + if strings.Contains(segment, "/") { + parts := strings.Split(segment, "/") + if len(parts) != 2 { + return nil, fmt.Errorf("invalid step %q", segment) + } + base = parts[0] + var err error + step, err = strconv.Atoi(parts[1]) + if err != nil || step < 1 { + return nil, fmt.Errorf("invalid step %q", parts[1]) + } + } + start, end := min, max + if base != "*" { + if strings.Contains(base, "-") { + parts := strings.Split(base, "-") + if len(parts) != 2 { + return nil, fmt.Errorf("invalid range %q", base) + } + start, _ = strconv.Atoi(parts[0]) + end, _ = strconv.Atoi(parts[1]) + } else { + var err error + start, err = strconv.Atoi(base) + end = start + if err != nil { + return nil, fmt.Errorf("invalid value %q", base) + } + } + } + if start < min || end > max || start > end { + return nil, fmt.Errorf("value outside %d-%d", min, max) + } + for n := start; n <= end; n += step { + result[n] = true + } + } + return result, nil +} + +func (e Expression) Matches(t time.Time) bool { + values := [5]int{t.Minute(), t.Hour(), t.Day(), int(t.Month()), int(t.Weekday())} + for i, value := range values { + if !e.fields[i][value] { + return false + } + } + return true +} + +func (e Expression) Next(after time.Time) time.Time { + t := after.Truncate(time.Minute).Add(time.Minute) + limit := t.AddDate(2, 0, 0) + for t.Before(limit) { + if e.Matches(t) { + return t + } + t = t.Add(time.Minute) + } + return time.Time{} +} diff --git a/internal/scheduler/cron_test.go b/internal/scheduler/cron_test.go new file mode 100644 index 0000000..a2ade8b --- /dev/null +++ b/internal/scheduler/cron_test.go @@ -0,0 +1,45 @@ +package scheduler + +import ( + "testing" + "time" +) + +func TestCronMatchesAndNext(t *testing.T) { + expr, err := Parse("*/15 2 * * 1-5") + if err != nil { + t.Fatal(err) + } + monday := time.Date(2026, 6, 15, 2, 30, 0, 0, time.UTC) + if !expr.Matches(monday) { + t.Fatal("expected expression to match") + } + next := expr.Next(time.Date(2026, 6, 15, 2, 31, 0, 0, time.UTC)) + want := time.Date(2026, 6, 15, 2, 45, 0, 0, time.UTC) + if !next.Equal(want) { + t.Fatalf("next = %v, want %v", next, want) + } +} + +func TestCronRejectsInvalid(t *testing.T) { + for _, value := range []string{"", "* * * *", "61 * * * *", "*/0 * * * *"} { + if _, err := Parse(value); err == nil { + t.Fatalf("accepted invalid cron %q", value) + } + } +} + +func TestPreviousWithinCatchUpWindow(t *testing.T) { + expr, err := Parse("0 2 * * *") + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 6, 13, 8, 30, 0, 0, time.UTC) + want := time.Date(2026, 6, 13, 2, 0, 0, 0, time.UTC) + if got := previous(expr, now, 24*time.Hour); !got.Equal(want) { + t.Fatalf("previous = %v, want %v", got, want) + } + if got := previous(expr, now, 2*time.Hour); !got.IsZero() { + t.Fatalf("unexpected catch-up match %v", got) + } +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go new file mode 100644 index 0000000..05f3f09 --- /dev/null +++ b/internal/scheduler/scheduler.go @@ -0,0 +1,153 @@ +package scheduler + +import ( + "context" + "log/slog" + "sync" + "time" + + "github.com/backupper-unraid/backupper/internal/model" +) + +type ConfigProvider func() model.Config +type Enqueue func(model.Job) error +type EnqueueMaintenance func(string, string) (model.Run, error) +type LastRun func(string) time.Time + +type Scheduler struct { + config ConfigProvider + enqueue Enqueue + maintenance EnqueueMaintenance + lastRun LastRun + log *slog.Logger + mu sync.Mutex + last map[string]time.Time +} + +func New(config ConfigProvider, enqueue Enqueue, maintenance EnqueueMaintenance, lastRun LastRun, log *slog.Logger) *Scheduler { + return &Scheduler{config: config, enqueue: enqueue, maintenance: maintenance, lastRun: lastRun, log: log, last: map[string]time.Time{}} +} + +func (s *Scheduler) Run(ctx context.Context) { + s.catchUp(time.Now()) + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + s.tick(time.Now()) + for { + select { + case <-ctx.Done(): + return + case now := <-ticker.C: + s.tick(now) + } + } +} + +func (s *Scheduler) tick(now time.Time) { + config := s.config() + minute := now.Truncate(time.Minute) + for _, job := range config.Jobs { + if !job.Enabled || job.Schedule.Cron == "" { + continue + } + at, expression, ok := scheduledTime(job, minute, s.log) + if !ok || !expression.Matches(at) { + continue + } + job := job + s.once("job:"+job.ID, minute, func() error { return s.enqueue(job) }) + } + s.scheduleMaintenance(config, minute, "check", config.Settings.CheckCron) + s.scheduleMaintenance(config, minute, "prune", config.Settings.PruneCron) +} + +func (s *Scheduler) catchUp(now time.Time) { + config := s.config() + window := time.Duration(config.Settings.CatchUpWindowHrs) * time.Hour + if window <= 0 { + return + } + for _, job := range config.Jobs { + if !job.Enabled || job.Schedule.Cron == "" { + continue + } + loc := time.Local + if job.Schedule.Timezone != "" { + if loaded, err := time.LoadLocation(job.Schedule.Timezone); err == nil { + loc = loaded + } + } + expression, err := Parse(job.Schedule.Cron) + if err != nil { + continue + } + lastScheduled := previous(expression, now.In(loc), window) + if !lastScheduled.IsZero() && s.lastRun(job.ID).Before(lastScheduled.UTC()) { + if err := s.enqueue(job); err != nil { + s.log.Warn("catch-up enqueue failed", "jobId", job.ID, "error", err) + } + } + } +} + +func (s *Scheduler) scheduleMaintenance(config model.Config, minute time.Time, action, spec string) { + if spec == "" { + return + } + expression, err := Parse(spec) + if err != nil { + s.log.Warn("invalid maintenance schedule", "action", action, "error", err) + return + } + if !expression.Matches(minute) { + return + } + for _, repo := range config.Repositories { + repoID := repo.ID + s.once(action+":"+repoID, minute, func() error { + _, err := s.maintenance(repoID, action) + return err + }) + } +} + +func (s *Scheduler) once(key string, minute time.Time, action func() error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.last[key].Equal(minute) { + return + } + s.last[key] = minute + if err := action(); err != nil { + s.log.Warn("schedule enqueue failed", "key", key, "error", err) + } +} + +func scheduledTime(job model.Job, minute time.Time, log *slog.Logger) (time.Time, Expression, bool) { + expression, err := Parse(job.Schedule.Cron) + if err != nil { + log.Warn("invalid job schedule", "jobId", job.ID, "error", err) + return time.Time{}, Expression{}, false + } + loc := time.Local + if job.Schedule.Timezone != "" { + if loaded, err := time.LoadLocation(job.Schedule.Timezone); err == nil { + loc = loaded + } else { + log.Warn("invalid job timezone", "jobId", job.ID, "error", err) + } + } + return minute.In(loc), expression, true +} + +func previous(expression Expression, now time.Time, window time.Duration) time.Time { + current := now.Truncate(time.Minute) + limit := current.Add(-window) + for !current.Before(limit) { + if expression.Matches(current) { + return current + } + current = current.Add(-time.Minute) + } + return time.Time{} +} diff --git a/internal/secrets/store.go b/internal/secrets/store.go new file mode 100644 index 0000000..f3c44a1 --- /dev/null +++ b/internal/secrets/store.go @@ -0,0 +1,144 @@ +package secrets + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "regexp" + "sync" + "time" +) + +type Record struct { + ID string `json:"id"` + Type string `json:"type"` + Nonce string `json:"nonce"` + Ciphertext string `json:"ciphertext"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type Store struct { + dir string + mu sync.Mutex +} + +var validID = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`) + +func New(dir string) *Store { return &Store{dir: dir} } + +func (s *Store) Init() error { + if err := os.MkdirAll(s.dir, 0700); err != nil { + return err + } + keyPath := filepath.Join(s.dir, "secret.key") + if _, err := os.Stat(keyPath); errors.Is(err, os.ErrNotExist) { + key := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, key); err != nil { + return err + } + return os.WriteFile(keyPath, key, 0600) + } + return nil +} + +func (s *Store) Put(id, kind, plaintext string) error { + if !validID.MatchString(id) || kind == "" { + return errors.New("invalid secret id or type") + } + s.mu.Lock() + defer s.mu.Unlock() + key, err := s.key() + if err != nil { + return err + } + block, err := aes.NewCipher(key) + if err != nil { + return err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return err + } + sealed := gcm.Seal(nil, nonce, []byte(plaintext), []byte(id+":"+kind)) + record := Record{ID: id, Type: kind, Nonce: base64.StdEncoding.EncodeToString(nonce), Ciphertext: base64.StdEncoding.EncodeToString(sealed), UpdatedAt: time.Now().UTC()} + b, err := json.Marshal(record) + if err != nil { + return err + } + return os.WriteFile(filepath.Join(s.dir, id+".json"), append(b, '\n'), 0600) +} + +func (s *Store) Get(id string) (string, error) { + if !validID.MatchString(id) { + return "", errors.New("invalid secret id") + } + s.mu.Lock() + defer s.mu.Unlock() + b, err := os.ReadFile(filepath.Join(s.dir, id+".json")) + if err != nil { + return "", err + } + var record Record + if err := json.Unmarshal(b, &record); err != nil { + return "", err + } + key, err := s.key() + if err != nil { + return "", err + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce, err := base64.StdEncoding.DecodeString(record.Nonce) + if err != nil { + return "", err + } + sealed, err := base64.StdEncoding.DecodeString(record.Ciphertext) + if err != nil { + return "", err + } + plain, err := gcm.Open(nil, nonce, sealed, []byte(record.ID+":"+record.Type)) + if err != nil { + return "", err + } + return string(plain), nil +} + +func (s *Store) Delete(id string) error { + if !validID.MatchString(id) { + return errors.New("invalid secret id") + } + s.mu.Lock() + defer s.mu.Unlock() + err := os.Remove(filepath.Join(s.dir, id+".json")) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} + +func (s *Store) key() ([]byte, error) { + key, err := os.ReadFile(filepath.Join(s.dir, "secret.key")) + if err != nil { + return nil, err + } + if len(key) != 32 { + return nil, errors.New("invalid secret key length") + } + return key, nil +} diff --git a/internal/secrets/store_test.go b/internal/secrets/store_test.go new file mode 100644 index 0000000..554b6c4 --- /dev/null +++ b/internal/secrets/store_test.go @@ -0,0 +1,58 @@ +package secrets + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestEncryptedRoundTrip(t *testing.T) { + dir := t.TempDir() + s := New(dir) + if err := s.Init(); err != nil { + t.Fatal(err) + } + if err := s.Put("repo", "restic-password", "correct horse battery staple"); err != nil { + t.Fatal(err) + } + plain, err := s.Get("repo") + if err != nil { + t.Fatal(err) + } + if plain != "correct horse battery staple" { + t.Fatalf("unexpected plaintext %q", plain) + } + raw, err := os.ReadFile(filepath.Join(dir, "repo.json")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), plain) { + t.Fatal("plaintext stored on disk") + } +} + +func TestCiphertextBoundToIdentity(t *testing.T) { + dir := t.TempDir() + s := New(dir) + if err := s.Init(); err != nil { + t.Fatal(err) + } + if err := s.Put("one", "password", "secret"); err != nil { + t.Fatal(err) + } + b, _ := os.ReadFile(filepath.Join(dir, "one.json")) + var record Record + if err := json.Unmarshal(b, &record); err != nil { + t.Fatal(err) + } + record.ID = "two" + b, _ = json.Marshal(record) + if err := os.WriteFile(filepath.Join(dir, "two.json"), b, 0600); err != nil { + t.Fatal(err) + } + if _, err := s.Get("two"); err == nil { + t.Fatal("renamed secret record decrypted") + } +} diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..f9409e1 --- /dev/null +++ b/internal/service/service.go @@ -0,0 +1,375 @@ +package service + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/backupper-unraid/backupper/internal/model" + "github.com/backupper-unraid/backupper/internal/notify" + "github.com/backupper-unraid/backupper/internal/platform" + "github.com/backupper-unraid/backupper/internal/queue" + "github.com/backupper-unraid/backupper/internal/restic" + "github.com/backupper-unraid/backupper/internal/scheduler" + "github.com/backupper-unraid/backupper/internal/store" +) + +type SecretStore interface { + Get(string) (string, error) + Put(string, string, string) error + Delete(string) error +} + +type Service struct { + store *store.Store + secrets SecretStore + restic *restic.Runner + mounts *platform.MountManager + workloads *platform.WorkloadManager + notifier *notify.Sender + log *slog.Logger + queue *queue.Queue + mu sync.RWMutex + config model.Config +} + +func New(st *store.Store, secrets SecretStore, rr *restic.Runner, mounts *platform.MountManager, workloads *platform.WorkloadManager, notifier *notify.Sender, log *slog.Logger) (*Service, error) { + config, err := st.LoadConfig() + if err != nil { + return nil, err + } + s := &Service{store: st, secrets: secrets, restic: rr, mounts: mounts, workloads: workloads, notifier: notifier, log: log, config: config} + s.queue = queue.New(s.execute, func(runs []model.Run) { + if err := st.SaveRuns(runs); err != nil { + log.Error("save run history", "error", err) + } + }) + if runs, err := st.LoadRuns(); err == nil { + s.queue.RestoreHistory(runs) + } + return s, nil +} + +func (s *Service) RunQueue(ctx context.Context) { s.queue.Run(ctx) } +func (s *Service) Stop() { s.queue.Stop() } +func (s *Service) Config() model.Config { s.mu.RLock(); defer s.mu.RUnlock(); return s.config } +func (s *Service) Runs() []model.Run { return s.queue.Snapshot() } + +func (s *Service) LastRun(jobID string) time.Time { + var latest time.Time + for _, run := range s.queue.Snapshot() { + if run.JobID == jobID && run.CreatedAt.After(latest) { + latest = run.CreatedAt + } + } + return latest +} + +func (s *Service) SaveConfig(config model.Config) error { + for _, job := range config.Jobs { + if job.Schedule.Cron != "" { + if _, err := scheduler.Parse(job.Schedule.Cron); err != nil { + return fmt.Errorf("validation: job %q schedule: %w", job.ID, err) + } + } + if job.Schedule.Timezone != "" { + if _, err := time.LoadLocation(job.Schedule.Timezone); err != nil { + return fmt.Errorf("validation: job %q timezone: %w", job.ID, err) + } + } + } + for name, spec := range map[string]string{"check": config.Settings.CheckCron, "prune": config.Settings.PruneCron} { + if spec != "" { + if _, err := scheduler.Parse(spec); err != nil { + return fmt.Errorf("validation: %s schedule: %w", name, err) + } + } + } + if err := s.store.SaveConfig(config); err != nil { + return err + } + s.mu.Lock() + s.config = config + s.mu.Unlock() + s.restic.Binary = config.Settings.ResticPath + return nil +} + +func (s *Service) PutSecret(id, kind, value string) error { + if id == "" || value == "" { + return errors.New("validation: id and value are required") + } + return s.secrets.Put(id, kind, value) +} + +func (s *Service) DeleteSecret(id string) error { return s.secrets.Delete(id) } + +func (s *Service) EnqueueJob(jobID string, priority int) (model.Run, error) { + job, ok := s.job(jobID) + if !ok { + return model.Run{}, errors.New("validation: unknown job") + } + run := model.Run{SchemaVersion: model.SchemaVersion, ID: newID(), JobID: job.ID, TaskType: "backup", Priority: priority, CreatedAt: time.Now().UTC()} + return run, s.queue.Enqueue(run) +} + +func (s *Service) EnqueueScheduled(job model.Job) error { + _, err := s.EnqueueJob(job.ID, 0) + return err +} + +func (s *Service) EnqueueMaintenance(repoID, action string) (model.Run, error) { + if action != "check" && action != "prune" { + return model.Run{}, errors.New("validation: invalid maintenance action") + } + if _, ok := s.repository(repoID); !ok { + return model.Run{}, errors.New("validation: unknown repository") + } + run := model.Run{SchemaVersion: model.SchemaVersion, ID: newID(), JobID: repoID, TaskType: action, Priority: 5, CreatedAt: time.Now().UTC()} + return run, s.queue.Enqueue(run) +} + +func (s *Service) EnqueueRestore(task model.RestoreTask) (model.Run, error) { + config := s.Config() + target, err := platform.ValidateRestoreTarget(task.Target, config.Settings.RestoreRoot, task.InPlace, task.Confirmed) + if err != nil { + return model.Run{}, err + } + task.Target = target + if task.ID == "" { + task.ID = newID() + } + task.SchemaVersion = model.SchemaVersion + encoded := strings.Join(append([]string{task.RepositoryID, task.SnapshotID, task.Target, fmt.Sprint(task.InPlace)}, task.Includes...), "\x00") + run := model.Run{SchemaVersion: model.SchemaVersion, ID: task.ID, JobID: encoded, TaskType: "restore", Priority: 10, CreatedAt: time.Now().UTC()} + return run, s.queue.Enqueue(run) +} + +func (s *Service) Cancel(runID string) bool { return s.queue.Cancel(runID) } + +func (s *Service) Snapshots(ctx context.Context, repoID string) ([]model.Snapshot, error) { + repo, ok := s.repository(repoID) + if !ok { + return nil, errors.New("validation: unknown repository") + } + mounted, err := s.mounts.Prepare(ctx, repo) + if err != nil { + return nil, err + } + defer s.mounts.Cleanup(context.Background(), mounted) + return s.restic.Snapshots(ctx, mounted.Repository) +} + +func (s *Service) SnapshotFiles(ctx context.Context, repoID, snapshot, path string) ([]map[string]any, error) { + repo, ok := s.repository(repoID) + if !ok { + return nil, errors.New("validation: unknown repository") + } + mounted, err := s.mounts.Prepare(ctx, repo) + if err != nil { + return nil, err + } + defer s.mounts.Cleanup(context.Background(), mounted) + return s.restic.List(ctx, mounted.Repository, snapshot, path) +} + +func (s *Service) TestRepository(ctx context.Context, repoID string, initialize bool) error { + repo, ok := s.repository(repoID) + if !ok { + return errors.New("validation: unknown repository") + } + mounted, err := s.mounts.Prepare(ctx, repo) + if err != nil { + return err + } + defer s.mounts.Cleanup(context.Background(), mounted) + if initialize { + return s.restic.Init(ctx, mounted.Repository) + } + _, err = s.restic.Snapshots(ctx, mounted.Repository) + return err +} + +func (s *Service) execute(ctx context.Context, run model.Run) model.Run { + if run.TaskType == "backup" { + return s.executeBackup(ctx, run) + } + if run.TaskType == "restore" { + return s.executeRestore(ctx, run) + } + repo, ok := s.repository(run.JobID) + if !ok { + return failed(run, "validation", "repository no longer exists") + } + mounted, err := s.mounts.Prepare(ctx, repo) + if err != nil { + return failedError(run, err) + } + defer s.mounts.Cleanup(context.Background(), mounted) + if run.TaskType == "check" { + err = s.restic.Check(ctx, mounted.Repository) + } else { + err = s.restic.Prune(ctx, mounted.Repository) + } + if err != nil { + return failedError(run, err) + } + run.Status, run.Message = "success", run.TaskType+" completed" + return run +} + +func (s *Service) executeBackup(ctx context.Context, run model.Run) (result model.Run) { + job, ok := s.job(run.JobID) + if !ok { + return failed(run, "validation", "job no longer exists") + } + defer func() { result = s.finishJob(run, job, result) }() + repo, ok := s.repository(job.RepositoryID) + if !ok { + return failed(run, "validation", "repository no longer exists") + } + mounted, err := s.mounts.Prepare(ctx, repo) + if err != nil { + return failedError(run, err) + } + defer func() { + if err := s.mounts.Cleanup(context.Background(), mounted); err != nil && result.Status == "success" { + result.Status, result.ErrorCode, result.Message = "warning", "connectivity", result.Message+"; repository unmount failed: "+err.Error() + } + }() + prepared, err := s.workloads.Prepare(ctx, job) + if err != nil { + return failedError(run, err) + } + defer func() { + if err := s.workloads.Cleanup(context.Background(), prepared); err != nil { + if result.Status == "success" { + result.Status, result.ErrorCode = "warning", "source" + } + result.Message += "; workload restart failed: " + err.Error() + } + }() + if job.Type == model.JobFlash && len(prepared.Sources) == 0 { + prepared.Sources = []string{"/boot"} + } + for _, path := range prepared.Sources { + if _, err := os.Stat(path); err != nil { + return failed(run, "source", "source unavailable: "+path) + } + } + summary, err := s.restic.Backup(ctx, mounted.Repository, job, prepared.Sources) + if err != nil { + return failedError(run, err) + } + if err := s.restic.Forget(ctx, mounted.Repository, job); err != nil { + run.Status, run.Message, run.ErrorCode = "warning", "backup succeeded but retention failed: "+err.Error(), "repository" + } else { + run.Status, run.Message = "success", "backup completed" + } + run.SnapshotID, run.BytesAdded, run.FilesNew = summary.SnapshotID, summary.DataAdded, summary.FilesNew + return run +} + +func (s *Service) executeRestore(ctx context.Context, run model.Run) model.Run { + parts := strings.Split(run.JobID, "\x00") + if len(parts) < 4 { + return failed(run, "internal", "invalid restore payload") + } + repo, ok := s.repository(parts[0]) + if !ok { + return failed(run, "validation", "repository no longer exists") + } + task := model.RestoreTask{SchemaVersion: model.SchemaVersion, ID: run.ID, RepositoryID: parts[0], SnapshotID: parts[1], Target: parts[2], InPlace: parts[3] == "true", Confirmed: true, Includes: parts[4:]} + if err := os.MkdirAll(task.Target, 0750); err != nil { + return failedError(run, err) + } + mounted, err := s.mounts.Prepare(ctx, repo) + if err != nil { + return failedError(run, err) + } + defer s.mounts.Cleanup(context.Background(), mounted) + if err := s.restic.Restore(ctx, mounted.Repository, task); err != nil { + return failedError(run, err) + } + run.Status, run.Message, run.JobID = "success", "restore completed", "" + return run +} + +func (s *Service) finishJob(run model.Run, job model.Job, result model.Run) model.Run { + event := result.Status + for _, wanted := range job.NotifyOn { + if wanted == event { + go s.notify(job.Name, result) + break + } + } + return result +} + +func (s *Service) notify(name string, run model.Run) { + for _, target := range s.Config().Notifications { + for _, event := range target.Events { + if event == run.Status { + _ = s.notifier.Send(context.Background(), target, "Backupper: "+name, run.Message, run.Status) + break + } + } + } +} + +func (s *Service) job(id string) (model.Job, bool) { + for _, item := range s.Config().Jobs { + if item.ID == id { + return item, true + } + } + return model.Job{}, false +} +func (s *Service) repository(id string) (model.Repository, bool) { + for _, item := range s.Config().Repositories { + if item.ID == id { + return item, true + } + } + return model.Repository{}, false +} + +func failed(run model.Run, code, message string) model.Run { + run.Status, run.ErrorCode, run.Message = "failed", code, message + return run +} +func failedError(run model.Run, err error) model.Run { + code := "internal" + message := err.Error() + for _, candidate := range []string{"validation", "environment", "connectivity", "authentication", "repository", "source", "restic", "cancelled"} { + if strings.HasPrefix(message, candidate+":") { + code = candidate + break + } + } + if errors.Is(err, context.Canceled) { + code = "cancelled" + run.Status = "cancelled" + } else { + run.Status = "failed" + } + run.ErrorCode, run.Message = code, message + return run +} +func newID() string { + b := make([]byte, 12) + if _, err := rand.Read(b); err != nil { + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return hex.EncodeToString(b) +} + +func DefaultRestoreTarget(root string) string { return filepath.Join(root, newID()) } diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..25247dc --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,113 @@ +package store + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + + "github.com/backupper-unraid/backupper/internal/model" +) + +type Store struct { + dir string + mu sync.RWMutex +} + +func New(dir string) *Store { return &Store{dir: dir} } + +func (s *Store) Init() error { + if err := os.MkdirAll(s.dir, 0700); err != nil { + return err + } + if _, err := os.Stat(s.configPath()); errors.Is(err, os.ErrNotExist) { + return s.SaveConfig(model.DefaultConfig()) + } + return nil +} + +func (s *Store) LoadConfig() (model.Config, error) { + s.mu.RLock() + defer s.mu.RUnlock() + var c model.Config + if err := readJSON(s.configPath(), &c); err != nil { + return c, err + } + return c, model.ValidateConfig(c) +} + +func (s *Store) SaveConfig(c model.Config) error { + if err := model.ValidateConfig(c); err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + return writeJSONAtomic(s.configPath(), c, 0600) +} + +func (s *Store) LoadRuns() ([]model.Run, error) { + s.mu.RLock() + defer s.mu.RUnlock() + var runs []model.Run + if err := readJSON(s.runsPath(), &runs); errors.Is(err, os.ErrNotExist) { + return []model.Run{}, nil + } else if err != nil { + return nil, err + } + return runs, nil +} + +func (s *Store) SaveRuns(runs []model.Run) error { + s.mu.Lock() + defer s.mu.Unlock() + if len(runs) > 500 { + runs = runs[len(runs)-500:] + } + return writeJSONAtomic(s.runsPath(), runs, 0600) +} + +func (s *Store) configPath() string { return filepath.Join(s.dir, "config.json") } +func (s *Store) runsPath() string { return filepath.Join(s.dir, "runs.json") } + +func readJSON(path string, target any) error { + b, err := os.ReadFile(path) + if err != nil { + return err + } + if err := json.Unmarshal(b, target); err != nil { + return fmt.Errorf("decode %s: %w", path, err) + } + return nil +} + +func writeJSONAtomic(path string, value any, mode os.FileMode) error { + b, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + b = append(b, '\n') + tmp, err := os.CreateTemp(filepath.Dir(path), ".backupper-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if err := tmp.Chmod(mode); err != nil { + tmp.Close() + return err + } + if _, err := tmp.Write(b); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..ad4fcdd --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,30 @@ +package store + +import ( + "os" + "testing" + + "github.com/backupper-unraid/backupper/internal/model" +) + +func TestStoreInitializesAndPersists(t *testing.T) { + dir := t.TempDir() + s := New(dir) + if err := s.Init(); err != nil { + t.Fatal(err) + } + c, err := s.LoadConfig() + if err != nil { + t.Fatal(err) + } + if c.SchemaVersion != model.SchemaVersion { + t.Fatalf("schema version = %d", c.SchemaVersion) + } + info, err := os.Stat(s.configPath()) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("config mode = %o", info.Mode().Perm()) + } +} diff --git a/packaging/build-package.sh b/packaging/build-package.sh new file mode 100755 index 0000000..daea870 --- /dev/null +++ b/packaging/build-package.sh @@ -0,0 +1,42 @@ +#!/bin/bash +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +VERSION=${VERSION:-0.1.0} +RESTIC_VERSION=0.19.0 +RESTIC_SHA256=13176fe6d89d4357947a2cd107218ab2873a5f9d8e1ac2d4cd1c8e07e6839c21 +BUILD="$ROOT/build" +STAGE="$BUILD/package" +DIST="$ROOT/dist" + +command -v go >/dev/null || { echo "Go 1.23 or newer is required" >&2; exit 1; } +command -v curl >/dev/null || { echo "curl is required" >&2; exit 1; } +command -v bzip2 >/dev/null || { echo "bzip2 is required" >&2; exit 1; } + +rm -rf "$BUILD" "$DIST" +mkdir -p "$STAGE/usr/local/sbin" "$STAGE/usr/local/libexec/backupper" "$STAGE/usr/local/emhttp/plugins/backupper/assets" "$STAGE/etc/rc.d" "$STAGE/install" "$DIST" + +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "-s -w -X main.version=$VERSION" -o "$STAGE/usr/local/sbin/backupperd" ./cmd/backupperd + +RESTIC_ARCHIVE="$BUILD/restic.bz2" +curl --fail --location --silent --show-error "https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_amd64.bz2" -o "$RESTIC_ARCHIVE" +read -r ACTUAL_RESTIC_SHA256 _ < <(sha256sum "$RESTIC_ARCHIVE") +if [ "$ACTUAL_RESTIC_SHA256" != "$RESTIC_SHA256" ]; then + echo "Restic checksum mismatch" >&2 + exit 1 +fi +bzip2 -dc "$RESTIC_ARCHIVE" > "$STAGE/usr/local/libexec/backupper/restic" +chmod 0755 "$STAGE/usr/local/libexec/backupper/restic" "$STAGE/usr/local/sbin/backupperd" + +cp "$ROOT/webgui/Backupper.page" "$STAGE/usr/local/emhttp/plugins/backupper/" +cp "$ROOT/webgui/api.php" "$STAGE/usr/local/emhttp/plugins/backupper/" +cp "$ROOT/webgui/assets/"* "$STAGE/usr/local/emhttp/plugins/backupper/assets/" +cp "$ROOT/plugin/rc.backupper" "$STAGE/etc/rc.d/rc.backupper" +cp "$ROOT/plugin/doinst.sh" "$ROOT/plugin/slack-desc" "$STAGE/install/" +chmod 0755 "$STAGE/etc/rc.d/rc.backupper" "$STAGE/install/doinst.sh" + +(cd "$STAGE" && tar --owner=0 --group=0 -cJf "$DIST/backupper-${VERSION}-x86_64-1.txz" .) +(cd "$DIST" && sha256sum "backupper-${VERSION}-x86_64-1.txz" > "backupper-${VERSION}-x86_64-1.txz.sha256") +PACKAGE_SHA256=$(sha256sum "$DIST/backupper-${VERSION}-x86_64-1.txz" | awk '{print $1}') +sed "s/REPLACE_DURING_RELEASE/$PACKAGE_SHA256/" "$ROOT/plugin/backupper.plg" > "$DIST/backupper.plg" +echo "Built $DIST/backupper-${VERSION}-x86_64-1.txz" diff --git a/plugin/backupper.plg b/plugin/backupper.plg new file mode 100644 index 0000000..8bed2c6 --- /dev/null +++ b/plugin/backupper.plg @@ -0,0 +1,32 @@ + + + + + + + +]> + + +### 0.1.0 +- Initial MVP release. + + + &packageURL; + &packageSHA256; + + + +chmod 0700 /boot/config/plugins/&name; +/etc/rc.d/rc.backupper start + + + + +/etc/rc.d/rc.backupper stop || true +removepkg &name; +rm -rf /usr/local/emhttp/plugins/&name; /usr/local/libexec/&name; /usr/local/sbin/backupperd /etc/rc.d/rc.backupper /run/backupper /var/lib/backupper + + + diff --git a/plugin/doinst.sh b/plugin/doinst.sh new file mode 100755 index 0000000..c646a9b --- /dev/null +++ b/plugin/doinst.sh @@ -0,0 +1,12 @@ +#!/bin/bash +set -e + +CONFIG=/boot/config/plugins/backupper +mkdir -p "$CONFIG" /run/backupper /var/lib/backupper +chmod 0700 "$CONFIG" +chmod 0750 /run/backupper /var/lib/backupper + +if [ -x /etc/rc.d/rc.backupper ]; then + /etc/rc.d/rc.backupper restart || true +fi + diff --git a/plugin/rc.backupper b/plugin/rc.backupper new file mode 100755 index 0000000..ef3be80 --- /dev/null +++ b/plugin/rc.backupper @@ -0,0 +1,40 @@ +#!/bin/bash + +DAEMON=/usr/local/sbin/backupperd +PIDFILE=/run/backupper/backupper.pid +LOGFILE=/var/log/backupper.log + +start() { + mkdir -p /run/backupper /var/lib/backupper /boot/config/plugins/backupper + chmod 0750 /run/backupper /var/lib/backupper + chmod 0700 /boot/config/plugins/backupper + if [ -s "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then + return 0 + fi + nohup "$DAEMON" >>"$LOGFILE" 2>&1 & + echo $! >"$PIDFILE" +} + +stop() { + if [ -s "$PIDFILE" ]; then + PID=$(cat "$PIDFILE") + kill "$PID" 2>/dev/null || true + for _ in $(seq 1 30); do + kill -0 "$PID" 2>/dev/null || break + sleep 1 + done + kill -9 "$PID" 2>/dev/null || true + rm -f "$PIDFILE" + fi +} + +case "${1:-}" in + start) start ;; + stop) stop ;; + restart) stop; start ;; + status) + if [ -s "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then echo "backupperd is running"; else echo "backupperd is stopped"; exit 1; fi + ;; + *) echo "Usage: $0 {start|stop|restart|status}"; exit 2 ;; +esac + diff --git a/plugin/slack-desc b/plugin/slack-desc new file mode 100644 index 0000000..5012f6e --- /dev/null +++ b/plugin/slack-desc @@ -0,0 +1,12 @@ +backupper: Backupper for Unraid +backupper: +backupper: Native Restic backup management for Unraid 7. +backupper: Provides encrypted, deduplicated and versioned backups, +backupper: snapshot browsing, restores, scheduling and notifications. +backupper: +backupper: https://github.com/backupper-unraid/backupper +backupper: +backupper: +backupper: +backupper: + diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..4e693b9 --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +cd "$ROOT" + +go test ./... +go vet ./... +node --check webgui/assets/backupper.js +bash -n plugin/rc.backupper packaging/build-package.sh scripts/check.sh + diff --git a/webgui/Backupper.page b/webgui/Backupper.page new file mode 100644 index 0000000..71cc9f4 --- /dev/null +++ b/webgui/Backupper.page @@ -0,0 +1,30 @@ +Menu="Tasks:50" +Title="Backupper" +Icon="fa-floppy-o" +Type="php" +Tag="backup restic snapshots restore" +--- + + +
+
+

Backupper

Encrypted Restic backups for Unraid

+
Connecting...
+
+ +
+
+
+ + + diff --git a/webgui/api.php b/webgui/api.php new file mode 100644 index 0000000..9575ad3 --- /dev/null +++ b/webgui/api.php @@ -0,0 +1,61 @@ + 'invalid API path']); + exit; +} + +if ($method !== 'GET') { + $provided = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; + $runtime = @parse_ini_file('/var/local/emhttp/var.ini') ?: []; + $expected = $runtime['csrf_token'] ?? ''; + if ($expected === '' || !hash_equals((string)$expected, (string)$provided)) { + http_response_code(403); + echo json_encode(['error' => 'invalid CSRF token']); + exit; + } +} + +if (!file_exists($socket)) { + http_response_code(503); + echo json_encode(['error' => 'Backupper daemon is not running']); + exit; +} + +$curl = curl_init(); +curl_setopt_array($curl, [ + CURLOPT_UNIX_SOCKET_PATH => $socket, + CURLOPT_URL => 'http://localhost' . $path, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 3, + CURLOPT_TIMEOUT => 310, + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], +]); + +$body = file_get_contents('php://input'); +if ($method !== 'GET' && $body !== false && $body !== '') { + curl_setopt($curl, CURLOPT_POSTFIELDS, $body); +} + +$response = curl_exec($curl); +$status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE); +$error = curl_error($curl); +curl_close($curl); + +if ($response === false) { + http_response_code(502); + echo json_encode(['error' => 'Daemon connection failed: ' . $error]); + exit; +} + +http_response_code($status > 0 ? $status : 502); +echo $response; diff --git a/webgui/assets/backupper.css b/webgui/assets/backupper.css new file mode 100644 index 0000000..b85ee65 --- /dev/null +++ b/webgui/assets/backupper.css @@ -0,0 +1,51 @@ +:root { + --bu-bg: #14171b; + --bu-panel: #1d2228; + --bu-panel-2: #242b32; + --bu-border: #343e48; + --bu-text: #f3f5f7; + --bu-muted: #9ba8b4; + --bu-accent: #ff8b21; + --bu-good: #2fc98f; + --bu-bad: #ff5c68; +} + +#backupper-app { color: var(--bu-text); max-width: 1500px; padding: 10px 18px 40px; } +.bu-header { align-items: center; display: flex; justify-content: space-between; margin: 6px 0 22px; } +.bu-header h1 { font-size: 30px; margin: 0; } +.bu-header p { color: var(--bu-muted); margin: 4px 0 0; } +.bu-health { background: var(--bu-panel); border: 1px solid var(--bu-border); border-radius: 999px; padding: 8px 14px; } +.bu-health.ok { color: var(--bu-good); } +.bu-health.bad { color: var(--bu-bad); } +.bu-tabs { border-bottom: 1px solid var(--bu-border); display: flex; gap: 4px; margin-bottom: 20px; overflow-x: auto; } +.bu-tabs button { background: transparent; border: 0; border-bottom: 3px solid transparent; color: var(--bu-muted); cursor: pointer; padding: 11px 14px; white-space: nowrap; } +.bu-tabs button.active { border-color: var(--bu-accent); color: var(--bu-text); } +.bu-grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); } +.bu-card { background: var(--bu-panel); border: 1px solid var(--bu-border); border-radius: 8px; padding: 18px; } +.bu-card h2, .bu-card h3 { margin-top: 0; } +.bu-stat { font-size: 30px; font-weight: 700; } +.bu-muted { color: var(--bu-muted); } +.bu-toolbar { align-items: center; display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; margin-bottom: 14px; } +.bu-actions { display: flex; flex-wrap: wrap; gap: 6px; } +.bu-button { background: var(--bu-panel-2); border: 1px solid var(--bu-border); border-radius: 5px; color: var(--bu-text); cursor: pointer; padding: 8px 12px; } +.bu-button.primary { background: var(--bu-accent); border-color: var(--bu-accent); color: #111; font-weight: 700; } +.bu-button.danger { color: var(--bu-bad); } +.bu-button:disabled { cursor: wait; opacity: .55; } +.bu-table { border-collapse: collapse; width: 100%; } +.bu-table th, .bu-table td { border-bottom: 1px solid var(--bu-border); padding: 11px 8px; text-align: left; vertical-align: top; } +.bu-table th { color: var(--bu-muted); font-size: 12px; text-transform: uppercase; } +.bu-status { border-radius: 999px; display: inline-block; font-size: 12px; padding: 3px 8px; text-transform: uppercase; } +.bu-status.success { background: rgba(47,201,143,.15); color: var(--bu-good); } +.bu-status.failed, .bu-status.cancelled { background: rgba(255,92,104,.14); color: var(--bu-bad); } +.bu-status.running, .bu-status.queued, .bu-status.warning { background: rgba(255,139,33,.15); color: var(--bu-accent); } +.bu-form { display: grid; gap: 12px; grid-template-columns: repeat(2, minmax(220px, 1fr)); } +.bu-form label { color: var(--bu-muted); display: grid; gap: 6px; } +.bu-form .wide { grid-column: 1 / -1; } +.bu-form input, .bu-form select, .bu-form textarea { background: #101317; border: 1px solid var(--bu-border); border-radius: 5px; color: var(--bu-text); padding: 9px; } +.bu-form textarea { min-height: 90px; resize: vertical; } +.bu-empty { color: var(--bu-muted); padding: 28px; text-align: center; } +#bu-toast { background: var(--bu-panel-2); border: 1px solid var(--bu-border); border-radius: 6px; bottom: 24px; display: none; max-width: 460px; padding: 12px 16px; position: fixed; right: 24px; z-index: 20; } +#bu-toast.show { display: block; } +#bu-toast.error { border-color: var(--bu-bad); } +@media (max-width: 700px) { .bu-form { grid-template-columns: 1fr; } .bu-header { align-items: flex-start; gap: 12px; } } + diff --git a/webgui/assets/backupper.js b/webgui/assets/backupper.js new file mode 100644 index 0000000..c626d20 --- /dev/null +++ b/webgui/assets/backupper.js @@ -0,0 +1,177 @@ +(() => { + 'use strict'; + const root = document.getElementById('backupper-app'); + if (!root) return; + const content = document.getElementById('bu-content'); + const health = document.getElementById('bu-health'); + const toast = document.getElementById('bu-toast'); + const state = { config: null, runs: [], view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], browserPath: '/', restoreIncludes: [] }; + + const esc = (value) => String(value ?? '').replace(/[&<>'"]/g, c => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c])); + const api = async (path, options = {}) => { + const response = await fetch(`${root.dataset.api}?path=${encodeURIComponent(path)}`, { + ...options, + headers: {'Content-Type':'application/json', 'X-CSRF-Token':window.BACKUPPER_CSRF || '', ...(options.headers || {})} + }); + const text = await response.text(); + let body = null; + try { body = text ? JSON.parse(text) : null; } catch (_) { body = {error:text}; } + if (!response.ok) throw new Error(body?.error || `Request failed (${response.status})`); + return body; + }; + const notify = (message, error = false) => { + toast.textContent = message; toast.className = `show${error ? ' error' : ''}`; + clearTimeout(notify.timer); notify.timer = setTimeout(() => toast.className = '', 4500); + }; + const id = (prefix) => `${prefix}-${crypto.randomUUID()}`; + const repoName = (repoId) => state.config.repositories.find(r => r.id === repoId)?.name || repoId; + const status = (value) => `${esc(value)}`; + const button = (label, action, kind = '') => ``; + + async function load() { + try { + const [config, runs] = await Promise.all([api('/v1/config'), api('/v1/runs')]); + state.config = config; state.runs = runs; + health.textContent = 'Daemon online'; health.className = 'bu-health ok'; render(); + } catch (error) { + health.textContent = 'Daemon unavailable'; health.className = 'bu-health bad'; + content.innerHTML = `

Connection failed

${esc(error.message)}

`; + } + } + + function render() { + const views = {dashboard:renderDashboard, jobs:renderJobs, repositories:renderRepositories, snapshots:renderSnapshots, restore:renderRestore, activity:renderActivity, settings:renderSettings}; + content.innerHTML = views[state.view](); + } + + function renderDashboard() { + const active = state.runs.filter(r => ['queued','running'].includes(r.status)).length; + const failures = state.runs.filter(r => r.status === 'failed').slice(-5); + const last = [...state.runs].reverse().slice(0, 8); + return `
+
Enabled jobs
${state.config.jobs.filter(j => j.enabled).length}
+
Repositories
${state.config.repositories.length}
+
Queued / running
${active}
+
Recent failures
${failures.length}
+

Recent activity

${runsTable(last)}
`; + } + + function renderJobs() { + const rows = state.config.jobs.map(j => `${esc(j.name)}
${esc(j.id)}${esc(j.type)}${esc(repoName(j.repositoryId))}${esc(j.schedule?.cron || 'manual')}${j.enabled ? status('success') : status('cancelled')}${button('Run',`run-job:${j.id}`,'primary')}${button('Edit',`edit-job:${j.id}`)}${button('Duplicate',`duplicate-job:${j.id}`)}${button('Delete',`delete-job:${j.id}`,'danger')}`).join(''); + return `

Backup Jobs

One isolated Restic repository per job.
${button('New job','new-job','primary')}
+
${rows ? `${rows}
JobTypeRepositoryScheduleEnabledActions
` : '
No backup jobs configured.
'}
`; + } + + function jobForm(job = {}) { + const source = (job.sources || []).map(s => s.path || `@${s.workloadId}`).join('\n'); + const options = state.config.repositories.map(r => ``).join(''); + content.innerHTML = `

${job.id ? 'Edit' : 'New'} backup job

+ + + + + + + + + +
${button('Save','submit-job','primary')}${button('Cancel','cancel-form')}
`; + } + + function renderRepositories() { + const rows = state.config.repositories.map(r => `${esc(r.name)}
${esc(r.id)}${esc(r.type)}${esc(r.location)}${r.mount?.managed?'Managed':'Direct / external'}${button('Test',`test-repo:${r.id}`)}${button('Snapshots',`repo-snapshots:${r.id}`)}${button('Check',`maint:check:${r.id}`)}${button('Prune',`maint:prune:${r.id}`)}${button('Edit',`edit-repo:${r.id}`)}${button('Delete',`delete-repo:${r.id}`,'danger')}`).join(''); + return `

Repositories

Local, SMB, NFS and SFTP destinations.
${button('New repository','new-repo','primary')}
${rows ? `${rows}
NameTypeLocationMountActions
` : '
No repositories configured.
'}
`; + } + + function repositoryForm(repo = {}) { + content.innerHTML = `

${repo.id?'Edit':'New'} repository

+ + + + + + +
${button('Save','submit-repo','primary')}${button('Cancel','cancel-form')}
`; + } + + function renderSnapshots() { + const options = state.config.repositories.map(r=>``).join(''); + const rows = state.snapshots.map(s=>`${esc(s.short_id||s.id)}${esc(new Date(s.time).toLocaleString())}${esc(s.hostname)}${esc((s.paths||[]).join(', '))}${button('Browse',`browse:${s.id}`)}`).join(''); + return `

Snapshots

${button('Load','load-snapshots','primary')}
${rows?`${rows}
SnapshotTimeHostPaths
`:'
Select a repository and load snapshots.
'}
${state.selectedSnapshot && state.files.length ? renderFileBrowser() : ''}`; + } + + function renderFileBrowser() { + const rows = state.files.map(item => { + const path = item.path || `${state.browserPath.replace(/\/$/,'')}/${item.name||''}`; + const open = item.type === 'dir' ? button('Open',`open-dir:${encodeURIComponent(path)}`) : ''; + return `${esc(item.type||'')}${esc(item.name||path)}${Number(item.size||0).toLocaleString()}${open}`; + }).join(''); + return `

Snapshot ${esc(state.selectedSnapshot.slice(0,12))}

${esc(state.browserPath)}
${state.browserPath!=='/'?button('Up','browser-up'):''}${button('Restore selected','restore-selected','primary')}
${rows}
TypeNameSize
`; + } + + function renderRestore() { + const options = state.config.repositories.map(r=>``).join(''); + return `

Restore

${button('Queue restore','submit-restore','primary')}
`; + } + + function renderActivity() { return `

Activity

${button('Refresh','refresh','primary')}
${runsTable([...state.runs].reverse())}
`; } + function runsTable(runs) { return runs.length ? `${runs.map(r=>``).join('')}
TaskStatusCreatedMessage
${esc(r.taskType)} ${esc(r.jobId||'')}${status(r.status)}${esc(new Date(r.createdAt).toLocaleString())}${esc(r.message||'')}${['queued','running'].includes(r.status)?button('Cancel',`cancel-run:${r.id}`,'danger'):''}
` : '
No activity recorded.
'; } + + function renderSettings() { + const s = state.config.settings; + const ntfy = state.config.notifications.find(n=>n.type==='ntfy') || {}; + return `

Settings

${button('Save settings','submit-settings','primary')}
+

ntfy notification

${button('Save notification','submit-notify','primary')}
`; + } + + async function saveConfig() { await api('/v1/config',{method:'PUT',body:JSON.stringify(state.config)}); notify('Configuration saved'); } + const lines = (value) => value.split('\n').map(v=>v.trim()).filter(Boolean); + + async function handle(action, element) { + const [name, a, b] = action.split(':'); + try { + if (name === 'new-job') return jobForm(); + if (name === 'edit-job') return jobForm(state.config.jobs.find(j=>j.id===a)); + if (name === 'new-repo') return repositoryForm(); + if (name === 'edit-repo') return repositoryForm(state.config.repositories.find(r=>r.id===a)); + if (name === 'cancel-form') return render(); + if (name === 'refresh') return load(); + if (name === 'run-job') { await api(`/v1/jobs/${a}/run`,{method:'POST'}); notify('Backup queued'); return load(); } + if (name === 'cancel-run') { await api(`/v1/runs/${a}/cancel`,{method:'POST'}); return load(); } + if (name === 'test-repo') { await api(`/v1/repositories/${a}/test`,{method:'POST'}); return notify('Repository connection succeeded'); } + if (name === 'maint') { await api(`/v1/repositories/${b}/${a}`,{method:'POST'}); notify(`${a} queued`); return load(); } + if (name === 'repo-snapshots') { state.view='snapshots'; document.querySelector('[data-view="snapshots"]').click(); render(); setTimeout(()=>{document.getElementById('bu-snapshot-repo').value=a; handle('load-snapshots');},0); return; } + if (name === 'load-snapshots') { const repo=document.getElementById('bu-snapshot-repo').value; state.snapshots=await api(`/v1/repositories/${repo}/snapshots`); state.snapshotRepo=repo; return render(); } + if (name === 'browse') { state.selectedSnapshot=a; state.browserPath='/'; state.files=await api(`/v1/repositories/${state.snapshotRepo}/snapshots/${a}/files?path=${encodeURIComponent('/')}`); return render(); } + if (name === 'open-dir') { state.browserPath=decodeURIComponent(a); state.files=await api(`/v1/repositories/${state.snapshotRepo}/snapshots/${state.selectedSnapshot}/files?path=${encodeURIComponent(state.browserPath)}`); return render(); } + if (name === 'browser-up') { state.browserPath=state.browserPath.replace(/\/$/,'').split('/').slice(0,-1).join('/')||'/'; state.files=await api(`/v1/repositories/${state.snapshotRepo}/snapshots/${state.selectedSnapshot}/files?path=${encodeURIComponent(state.browserPath)}`); return render(); } + if (name === 'restore-selected') { state.restoreIncludes=[...document.querySelectorAll('.bu-file-select:checked')].map(x=>x.value); if(!state.restoreIncludes.length) throw new Error('Select at least one file or directory'); state.view='restore'; document.querySelectorAll('.bu-tabs button').forEach(x=>x.classList.toggle('active',x.dataset.view==='restore')); return render(); } + if (name === 'delete-job') { if(confirm('Delete this job?')) { state.config.jobs=state.config.jobs.filter(j=>j.id!==a); await saveConfig(); render(); } return; } + if (name === 'duplicate-job') { const source=state.config.jobs.find(j=>j.id===a); const copy=structuredClone(source); copy.id=id('job'); copy.name += ' copy'; copy.enabled=false; state.config.jobs.push(copy); await saveConfig(); return render(); } + if (name === 'delete-repo') { if(state.config.jobs.some(j=>j.repositoryId===a)) throw new Error('Repository is still referenced by a job'); if(confirm('Delete this repository?')) { state.config.repositories=state.config.repositories.filter(r=>r.id!==a); await saveConfig(); render(); } return; } + if (name.startsWith('submit-')) return submit(name); + } catch (error) { notify(error.message, true); } + } + + async function submit(kind) { + if (kind === 'submit-job') { + const f=new FormData(document.getElementById('bu-job-form')); const existing=state.config.jobs.find(j=>j.id===f.get('id')); + const job={schemaVersion:1,id:f.get('id')||id('job'),name:f.get('name'),type:f.get('type'),enabled:f.get('enabled')==='on',repositoryId:f.get('repositoryId'),sources:lines(f.get('sources')).map(v=>v.startsWith('@')?{workloadId:v.slice(1)}:{path:v}),schedule:{cron:f.get('cron'),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},consistency:{mode:f.get('consistency')},compression:f.get('compression'),excludes:lines(f.get('excludes')),tags:existing?.tags||[],retention:existing?.retention||{daily:7,weekly:4,monthly:12},notifyOn:existing?.notifyOn||['failed','warning'],shutdownTimeoutSeconds:Number(f.get('shutdownSecs'))}; + state.config.jobs=state.config.jobs.filter(j=>j.id!==job.id).concat(job); await saveConfig(); render(); + } + if (kind === 'submit-repo') { + const f=new FormData(document.getElementById('bu-repo-form')); const repo={schemaVersion:1,id:f.get('id')||id('repo'),name:f.get('name'),type:f.get('type'),location:f.get('location'),passwordRef:f.get('passwordRef'),credentialRef:f.get('credentialRef')||'',mount:f.get('managed')==='on'?{managed:true,remote:f.get('remote'),mountPoint:f.get('mountPoint')}:null,options:f.get('type')==='sftp'?{knownHostsPath:f.get('knownHostsPath')}:{}}; + if(f.get('password')) await api(`/v1/secrets/${encodeURIComponent(repo.passwordRef)}`,{method:'PUT',body:JSON.stringify({type:'restic-password',value:f.get('password')})}); + if(f.get('credential')) { if(!repo.credentialRef) repo.credentialRef=id('mount-credential'); await api(`/v1/secrets/${encodeURIComponent(repo.credentialRef)}`,{method:'PUT',body:JSON.stringify({type:'mount-credential',value:f.get('credential')})}); } + state.config.repositories=state.config.repositories.filter(r=>r.id!==repo.id).concat(repo); await saveConfig(); render(); + } + if (kind === 'submit-restore') { const f=new FormData(document.getElementById('bu-restore-form')); await api('/v1/restores',{method:'POST',body:JSON.stringify({schemaVersion:1,id:'',repositoryId:f.get('repositoryId'),snapshotId:f.get('snapshotId'),includes:lines(f.get('includes')),target:f.get('target'),inPlace:f.get('inPlace')==='on',confirmed:f.get('confirmed')==='on'})}); notify('Restore queued'); state.view='activity'; render(); } + if (kind === 'submit-settings') { const f=new FormData(document.getElementById('bu-settings-form')); state.config.settings={resticPath:f.get('resticPath'),restoreRoot:f.get('restoreRoot'),persistentLogDir:f.get('persistentLogDir'),catchUpWindowHours:Number(f.get('catchUp')),checkCron:f.get('checkCron'),pruneCron:f.get('pruneCron')}; await saveConfig(); } + if (kind === 'submit-notify') { const f=new FormData(document.getElementById('bu-notify-form')); const target={schemaVersion:1,id:f.get('id'),name:'ntfy',type:'ntfy',enabled:f.get('enabled')==='on',url:f.get('url'),topic:f.get('topic'),tokenRef:f.get('tokenRef'),events:['failed','warning','success']}; if(f.get('token')) await api(`/v1/secrets/${encodeURIComponent(target.tokenRef)}`,{method:'PUT',body:JSON.stringify({type:'notification-token',value:f.get('token')})}); state.config.notifications=state.config.notifications.filter(n=>n.type!=='ntfy').concat(target); await saveConfig(); render(); } + } + + document.querySelector('.bu-tabs').addEventListener('click', e => { const button=e.target.closest('[data-view]'); if(!button)return; state.view=button.dataset.view; document.querySelectorAll('.bu-tabs button').forEach(x=>x.classList.toggle('active',x===button)); render(); }); + content.addEventListener('click', e => { const target=e.target.closest('[data-action]'); if(!target)return; e.preventDefault(); handle(target.dataset.action,target); }); + setInterval(async()=>{ if(!state.config)return; try { state.runs=await api('/v1/runs'); if(['dashboard','activity'].includes(state.view))render(); }catch(_){ } },10000); + load(); +})();