diff --git a/README.md b/README.md index 28364d7..444269c 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ URBM is a native Unraid 7 plugin that manages encrypted, deduplicated and versioned backups through Restic. It supports shares, appdata, Docker metadata and data, VM definitions and disks, and the Unraid boot drive. Destinations can be local, SFTP, SMB or NFS. +USB flash jobs always back up the browsable `/boot` file tree and can optionally stream the complete physical boot device into Restic as `urbm-usb-flash.img` for block-level recovery to an equal-sized or larger replacement device. + URBM is an independent community plugin and is not affiliated with or endorsed by Lime Technology, Inc. ## Architecture diff --git a/dist/urbm-2026.06.15.r006-x86_64-1.txz.sha256 b/dist/urbm-2026.06.15.r006-x86_64-1.txz.sha256 deleted file mode 100644 index 5bdcbf3..0000000 --- a/dist/urbm-2026.06.15.r006-x86_64-1.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -b5bcaefc48ac2258d036822d8e735d85f24384ec185720571c5a3a919c71fd70 urbm-2026.06.15.r006-x86_64-1.txz diff --git a/dist/urbm-2026.06.15.r006-x86_64-1.txz b/dist/urbm-2026.06.15.r007-x86_64-1.txz similarity index 55% rename from dist/urbm-2026.06.15.r006-x86_64-1.txz rename to dist/urbm-2026.06.15.r007-x86_64-1.txz index cc7d765..e4177a0 100644 Binary files a/dist/urbm-2026.06.15.r006-x86_64-1.txz and b/dist/urbm-2026.06.15.r007-x86_64-1.txz differ diff --git a/dist/urbm-2026.06.15.r007-x86_64-1.txz.sha256 b/dist/urbm-2026.06.15.r007-x86_64-1.txz.sha256 new file mode 100644 index 0000000..cf5e256 --- /dev/null +++ b/dist/urbm-2026.06.15.r007-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +61d61cf633884a645c15400665fc352637f641d93bd9a25594fb3e9659d72fe0 urbm-2026.06.15.r007-x86_64-1.txz diff --git a/dist/urbm.plg b/dist/urbm.plg index d9581dc..0648798 100644 --- a/dist/urbm.plg +++ b/dist/urbm.plg @@ -2,13 +2,18 @@ - + - + ]> +### 2026.06.15.r007 +- Add an optional raw USB flash image to flash backup jobs while retaining the normal browsable /boot backup. +- Stream the physical boot device directly into Restic as urbm-usb-flash.img without a temporary full-size image file. +- Detect the whole USB device behind the mounted /boot partition and tag image snapshots for retention and recovery. + ### 2026.06.15.r006 - Place running backup controls directly beside progress instead of beyond an empty message column. - Remove the unnecessary activity-table action column and its horizontal overflow. diff --git a/internal/model/model.go b/internal/model/model.go index 334e910..961fda5 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -54,6 +54,7 @@ type Job struct { Compression string `json:"compression"` Excludes []string `json:"excludes,omitempty"` Tags []string `json:"tags,omitempty"` + FlashImage bool `json:"flashImage,omitempty"` Retention Retention `json:"retention"` NotifyOn []string `json:"notifyOn,omitempty"` ShutdownSecs int `json:"shutdownTimeoutSeconds"` diff --git a/internal/platform/workloads.go b/internal/platform/workloads.go index 4adc2b5..908ef0a 100644 --- a/internal/platform/workloads.go +++ b/internal/platform/workloads.go @@ -14,7 +14,12 @@ import ( "git.casaderoll.de/michael/urbm/internal/model" ) -type WorkloadManager struct{ RuntimeDir string } +type WorkloadManager struct { + RuntimeDir string + FindmntPath string + LsblkPath string + Stat func(string) (os.FileInfo, error) +} type Workload struct { ID string `json:"id"` @@ -149,6 +154,54 @@ func (w *WorkloadManager) Prepare(ctx context.Context, job model.Job) (Prepared, return prepared, nil } +func (w *WorkloadManager) FlashDevice(ctx context.Context) (string, error) { + findmnt := w.FindmntPath + if findmnt == "" { + findmnt = "findmnt" + } + output, err := exec.CommandContext(ctx, findmnt, "--noheadings", "--output", "SOURCE", "--target", "/boot").Output() + if err != nil { + return "", fmt.Errorf("source: determine USB flash partition: %w", err) + } + partition := strings.TrimSpace(string(output)) + if index := strings.IndexByte(partition, '['); index >= 0 { + partition = partition[:index] + } + partition = filepath.Clean(partition) + if !strings.HasPrefix(partition, "/dev/") || strings.ContainsAny(partition, "\r\n\x00") { + return "", fmt.Errorf("source: /boot is not backed by a device: %q", partition) + } + + lsblk := w.LsblkPath + if lsblk == "" { + lsblk = "lsblk" + } + output, err = exec.CommandContext(ctx, lsblk, "--noheadings", "--output", "PKNAME", "--", partition).Output() + if err != nil { + return "", fmt.Errorf("source: determine USB flash device for %s: %w", partition, err) + } + parent := strings.TrimSpace(string(output)) + device := partition + if parent != "" { + if strings.ContainsAny(parent, "/\r\n\x00") { + return "", fmt.Errorf("source: invalid USB flash parent device %q", parent) + } + device = filepath.Join("/dev", parent) + } + stat := w.Stat + if stat == nil { + stat = os.Stat + } + info, err := stat(device) + if err != nil { + return "", fmt.Errorf("source: access USB flash device %s: %w", device, err) + } + if info.Mode()&os.ModeDevice == 0 { + return "", fmt.Errorf("source: USB flash source %s is not a block device", device) + } + return device, nil +} + func (w *WorkloadManager) Cleanup(ctx context.Context, prepared Prepared) error { var first error for i := len(prepared.Stopped) - 1; i >= 0; i-- { diff --git a/internal/platform/workloads_test.go b/internal/platform/workloads_test.go index c01d5ae..0475709 100644 --- a/internal/platform/workloads_test.go +++ b/internal/platform/workloads_test.go @@ -1,11 +1,22 @@ package platform import ( + "context" "os" "path/filepath" "testing" + "time" ) +type blockDeviceInfo struct{ name string } + +func (i blockDeviceInfo) Name() string { return i.name } +func (blockDeviceInfo) Size() int64 { return 0 } +func (blockDeviceInfo) Mode() os.FileMode { return os.ModeDevice } +func (blockDeviceInfo) ModTime() time.Time { return time.Time{} } +func (blockDeviceInfo) IsDir() bool { return false } +func (blockDeviceInfo) Sys() any { return nil } + func TestUnraidServiceEnabled(t *testing.T) { path := filepath.Join(t.TempDir(), "domain.cfg") for _, test := range []struct { @@ -24,3 +35,32 @@ func TestUnraidServiceEnabled(t *testing.T) { } } } + +func TestFlashDeviceResolvesWholeDiskBehindBootPartition(t *testing.T) { + dir := t.TempDir() + findmnt := filepath.Join(dir, "findmnt") + lsblk := filepath.Join(dir, "lsblk") + if err := os.WriteFile(findmnt, []byte("#!/bin/sh\nprintf '/dev/sda1\\n'\n"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(lsblk, []byte("#!/bin/sh\nprintf 'sda\\n'\n"), 0700); err != nil { + t.Fatal(err) + } + w := &WorkloadManager{ + FindmntPath: findmnt, + LsblkPath: lsblk, + Stat: func(path string) (os.FileInfo, error) { + if path != "/dev/sda" { + t.Fatalf("stat path = %q", path) + } + return blockDeviceInfo{name: "sda"}, nil + }, + } + device, err := w.FlashDevice(context.Background()) + if err != nil { + t.Fatal(err) + } + if device != "/dev/sda" { + t.Fatalf("device = %q", device) + } +} diff --git a/internal/restic/restic.go b/internal/restic/restic.go index 3b2c633..634f70d 100644 --- a/internal/restic/restic.go +++ b/internal/restic/restic.go @@ -125,6 +125,28 @@ func (r *Runner) Backup(ctx context.Context, repo model.Repository, job model.Jo return summary, err } +func (r *Runner) BackupImage(ctx context.Context, repo model.Repository, job model.Job, image io.Reader, progress ProgressCallback) (Summary, error) { + args := []string{"backup", "--json", "--stdin", "--stdin-filename", "urbm-usb-flash.img", "--compression", job.Compression} + for _, tag := range append([]string{"urbm", "job:" + job.ID, "usb-image"}, job.Tags...) { + args = append(args, "--tag", tag) + } + var summary Summary + err := r.runWithInput(ctx, repo, args, func(line []byte) { + var status Progress + if json.Unmarshal(line, &status) == nil && status.MessageType == "status" { + if progress != nil { + progress(status) + } + return + } + var candidate Summary + if json.Unmarshal(line, &candidate) == nil && candidate.MessageType == "summary" { + summary = candidate + } + }, nil, image) + return summary, err +} + func (r *Runner) Forget(ctx context.Context, repo model.Repository, job model.Job) error { args := []string{"forget", "--tag", "job:" + job.ID} if job.Retention.KeepWithinDays > 0 { @@ -201,6 +223,10 @@ func (r *Runner) Restore(ctx context.Context, repo model.Repository, task model. } func (r *Runner) run(ctx context.Context, repo model.Repository, args []string, onLine func([]byte), capture *[]byte) error { + return r.runWithInput(ctx, repo, args, onLine, capture, nil) +} + +func (r *Runner) runWithInput(ctx context.Context, repo model.Repository, args []string, onLine func([]byte), capture *[]byte, input io.Reader) error { password, err := r.Secrets.Get(repo.PasswordRef) if err != nil { return fmt.Errorf("authentication: load repository password: %w", err) @@ -257,6 +283,7 @@ func (r *Runner) run(ctx context.Context, repo model.Repository, args []string, } fullArgs := append(globalArgs, args...) cmd := exec.CommandContext(ctx, r.Binary, fullArgs...) + cmd.Stdin = input cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.Cancel = func() error { if cmd.Process == nil { diff --git a/internal/restic/restic_test.go b/internal/restic/restic_test.go index 977ddb7..a69d515 100644 --- a/internal/restic/restic_test.go +++ b/internal/restic/restic_test.go @@ -51,6 +51,37 @@ func TestBackupUsesPasswordFileAndStructuredArguments(t *testing.T) { } } +func TestBackupImageStreamsRawDeviceWithStableFilename(t *testing.T) { + dir := t.TempDir() + argsPath := filepath.Join(dir, "args") + imagePath := filepath.Join(dir, "image") + script := filepath.Join(dir, "restic") + body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\ncat > '%s'\nprintf '%%s\\n' '{\"message_type\":\"summary\",\"snapshot_id\":\"image123\",\"data_added\":4,\"total_bytes_processed\":4,\"total_files_processed\":1}'\n", argsPath, imagePath) + if err := os.WriteFile(script, []byte(body), 0700); err != nil { + t.Fatal(err) + } + runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "secret"}} + repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"} + job := model.Job{ID: "flash", Compression: "auto"} + summary, err := runner.BackupImage(context.Background(), repo, job, strings.NewReader("disk"), nil) + if err != nil { + t.Fatal(err) + } + if summary.SnapshotID != "image123" || summary.TotalBytesProcessed != 4 { + t.Fatalf("unexpected summary: %+v", summary) + } + args, _ := os.ReadFile(argsPath) + for _, expected := range []string{"--stdin", "--stdin-filename", "urbm-usb-flash.img", "usb-image"} { + if !strings.Contains(string(args), expected) { + t.Fatalf("arguments missing %q: %s", expected, args) + } + } + image, _ := os.ReadFile(imagePath) + if string(image) != "disk" { + t.Fatalf("streamed image = %q", image) + } +} + func TestForgetUsesAgeAndCalendarRetention(t *testing.T) { dir := t.TempDir() argsPath := filepath.Join(dir, "args") diff --git a/internal/service/service.go b/internal/service/service.go index 0b9dff5..636f414 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -329,6 +329,39 @@ func (s *Service) executeBackup(ctx context.Context, run model.Run) (result mode return failed(run, "source", "source unavailable: "+path) } } + var imageSummary restic.Summary + if job.Type == model.JobFlash && job.FlashImage { + device, err := s.workloads.FlashDevice(ctx) + if err != nil { + return failedError(run, err) + } + image, err := os.Open(device) + if err != nil { + return failed(run, "source", "open USB flash device: "+err.Error()) + } + appendLiveLog(&live, "USB-Rohabbild wird direkt aus "+device+" zu Restic übertragen") + s.updateActive(live) + imageSummary, err = s.restic.BackupImage(ctx, mounted.Repository, job, image, func(progress restic.Progress) { + live.ProgressPercent = progress.PercentDone * 100 + live.ProgressBytes, live.ProgressTotal = progress.BytesDone, progress.TotalBytes + live.ProgressFiles, live.ProgressFileTotal = progress.FilesDone, progress.TotalFiles + live.SecondsRemaining = progress.SecondsRemaining + live.CurrentFile = "urbm-usb-flash.img" + s.updateActive(live) + }) + closeErr := image.Close() + if err != nil { + appendLiveLog(&live, "USB-Rohabbild fehlgeschlagen: "+err.Error()) + failedRun := failedError(run, err) + failedRun.LiveLog = live.LiveLog + return failedRun + } + if closeErr != nil { + return failed(run, "source", "close USB flash device: "+closeErr.Error()) + } + appendLiveLog(&live, "USB-Rohabbild als urbm-usb-flash.img gesichert") + s.updateActive(live) + } appendLiveLog(&live, fmt.Sprintf("Restic-Backup gestartet: %d Quelle(n)", len(prepared.Sources))) s.updateActive(live) lastLoggedPercent := -5 @@ -364,8 +397,12 @@ func (s *Service) executeBackup(ctx context.Context, run model.Run) (result mode } else { run.Status, run.Message = "success", "backup completed" } - run.SnapshotID, run.BytesAdded, run.FilesNew, run.FilesChanged = summary.SnapshotID, summary.DataAdded, summary.FilesNew, summary.FilesChanged - run.BytesProcessed, run.FilesProcessed = summary.TotalBytesProcessed, summary.TotalFilesProcessed + run.SnapshotID = summary.SnapshotID + run.BytesAdded = summary.DataAdded + imageSummary.DataAdded + run.FilesNew = summary.FilesNew + imageSummary.FilesNew + run.FilesChanged = summary.FilesChanged + imageSummary.FilesChanged + run.BytesProcessed = summary.TotalBytesProcessed + imageSummary.TotalBytesProcessed + run.FilesProcessed = summary.TotalFilesProcessed + imageSummary.TotalFilesProcessed run.ProgressPercent, run.ProgressBytes, run.ProgressTotal = 100, live.ProgressBytes, live.ProgressTotal run.ProgressFiles, run.ProgressFileTotal = live.ProgressFiles, live.ProgressFileTotal run.BytesPerSecond, run.SecondsRemaining, run.CurrentFile = live.BytesPerSecond, 0, live.CurrentFile diff --git a/plugin/urbm.plg b/plugin/urbm.plg index 23abaa2..efd3906 100644 --- a/plugin/urbm.plg +++ b/plugin/urbm.plg @@ -2,13 +2,18 @@ - + ]> +### 2026.06.15.r007 +- Add an optional raw USB flash image to flash backup jobs while retaining the normal browsable /boot backup. +- Stream the physical boot device directly into Restic as urbm-usb-flash.img without a temporary full-size image file. +- Detect the whole USB device behind the mounted /boot partition and tag image snapshots for retention and recovery. + ### 2026.06.15.r006 - Place running backup controls directly beside progress instead of beyond an empty message column. - Remove the unnecessary activity-table action column and its horizontal overflow. diff --git a/webgui/URBM.page b/webgui/URBM.page index fd196ed..cc564bd 100644 --- a/webgui/URBM.page +++ b/webgui/URBM.page @@ -9,12 +9,12 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore" - +
- -

URBM 2026.06.15.r006

Restic Backup Manager für Unraid

+ +

URBM 2026.06.15.r007

Restic Backup Manager für Unraid

Verbindung wird hergestellt...
@@ -32,4 +32,4 @@ $pluginRoot = '/plugins/urbm';
- + diff --git a/webgui/assets/urbm.js b/webgui/assets/urbm.js index 2429e18..a302922 100644 --- a/webgui/assets/urbm.js +++ b/webgui/assets/urbm.js @@ -368,7 +368,7 @@ loadWorkloads(type); } else { state.backupSelections = ['/boot']; - host.innerHTML = `

Unraid-USB-Flash-Backup

Das vollständige Konfigurationslaufwerk /boot wird gesichert. Es ist keine zusätzliche Quellenauswahl erforderlich.

`; + host.innerHTML = `

Unraid-USB-Flash-Backup

Das vollständige Konfigurationslaufwerk /boot wird immer dateibasiert gesichert.

Das Abbild wird ohne Zwischenkopie direkt zu Restic übertragen und kann nach einer Wiederherstellung mit einem Image-Programm auf einen mindestens gleich großen Stick geschrieben werden. Die an die USB-GUID gebundene Unraid-Lizenz muss bei einem Stickwechsel separat übertragen werden.

`; } enhanceTooltips(host); } @@ -590,7 +590,7 @@ else sources=[...state.backupSelections.map(path=>({path})),...lines(f.get('sources')||'').map(path=>({path}))].filter((item,index,all)=>all.findIndex(other=>other.path===item.path)===index); const retention={keepWithinDays:Number(f.get('keepWithinDays')),daily:Number(f.get('keepDaily')),weekly:Number(f.get('keepWeekly')),monthly:Number(f.get('keepMonthly'))}; if(!retention.keepWithinDays&&!retention.daily&&!retention.weekly&&!retention.monthly) throw new Error('Die Aufbewahrung benötigt mindestens eine Alters- oder Kalenderregel.'); - const job={schemaVersion:1,id:f.get('id')||id('job'),name:f.get('name'),type,enabled:f.get('enabled')==='on',repositoryId:f.get('repositoryId'),sources,schedule:{cron:scheduleCron(f),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},consistency:{mode:f.get('consistency')||'live'},compression:f.get('compression'),excludes:lines(f.get('excludes')),tags:existing?.tags||[],retention,notifyOn:existing?.notifyOn||['success','warning','failed'],shutdownTimeoutSeconds:Number(f.get('shutdownSecs')||120)}; + const job={schemaVersion:1,id:f.get('id')||id('job'),name:f.get('name'),type,enabled:f.get('enabled')==='on',repositoryId:f.get('repositoryId'),sources,schedule:{cron:scheduleCron(f),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},consistency:{mode:f.get('consistency')||'live'},compression:f.get('compression'),excludes:lines(f.get('excludes')),tags:existing?.tags||[],flashImage:type==='flash'&&f.get('flashImage')==='on',retention,notifyOn:existing?.notifyOn||['success','warning','failed'],shutdownTimeoutSeconds:Number(f.get('shutdownSecs')||120)}; state.config.jobs=state.config.jobs.filter(j=>j.id!==job.id).concat(job); await saveConfig(); render(); } if (kind === 'submit-repo') {