diff --git a/README.md b/README.md index 6c5cc4c..a9ab0f1 100644 --- a/README.md +++ b/README.md @@ -57,3 +57,19 @@ paused/restarting states fail safely. Each restart has a 120-second deadline and recovery continues after individual failures. Shutdown waits for cleanup; the rc script refuses a forced kill or overlapping restart if cleanup exceeds five minutes. Hard crashes or power loss still require checking workload state and backup results. + +### Mirror an array disk to a mounted USB disk (2026.09.27.r001) + +Create an Rsync job and select the mounted array disk, for example `/mnt/disk1`, +as the source. Select the USB disk under `/mnt/disks`, for example +`/mnt/disks/USB-Backup`, as the target. URBM copies the selected directory, so this +creates `/mnt/disks/USB-Backup/disk1` and copies its contents there. Select the USB +root, not its existing `disk1` subdirectory, to avoid nesting another `disk1`. + +Enable overwrite to update existing files. Enable destination deletion for a +mirror that also removes files deleted from the source; deletion is off by default. +Use the dry-run option to preview a job without changing files. The USB disk must +already be mounted, for example through Unassigned Devices. URBM checks array and +USB mounts before starting the job and refuses leftover directories after an +unmount. Keep the USB disk connected throughout the run. Reload the page after +mounting a new array disk to refresh the available sources. diff --git a/dist/urbm-2026.09.27.r001-x86_64-1.txz b/dist/urbm-2026.09.27.r001-x86_64-1.txz new file mode 100644 index 0000000..a621c21 Binary files /dev/null and b/dist/urbm-2026.09.27.r001-x86_64-1.txz differ diff --git a/dist/urbm-2026.09.27.r001-x86_64-1.txz.sha256 b/dist/urbm-2026.09.27.r001-x86_64-1.txz.sha256 new file mode 100644 index 0000000..20c3154 --- /dev/null +++ b/dist/urbm-2026.09.27.r001-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +e66e3d540fba172a1e0b8e60f7b36e33b38ba8357e5a45a07879a32c2d655222 urbm-2026.09.27.r001-x86_64-1.txz diff --git a/dist/urbm.plg b/dist/urbm.plg index 9d5f16b..d327344 100644 --- a/dist/urbm.plg +++ b/dist/urbm.plg @@ -2,13 +2,19 @@ - + - + ]> +### 2026.09.27.r001 +- Discover mounted array disks as selectable Rsync sources, including complete disks. +- Show directory symlinks only when they resolve within allowed storage roots. +- Refuse Rsync disk jobs when the array source or USB destination is not mounted. +- Document array-to-USB mirroring and cover disk discovery, mount checks and safe browsing with regression tests. + ### 2026.09.21.r001 - Notify on successful and failed repository checks; complete notification attempts before finishing a task. - Preserve stopped Docker/VM workloads and refuse ambiguous paused or restarting states. diff --git a/internal/model/validate_test.go b/internal/model/validate_test.go index afcbb51..103d197 100644 --- a/internal/model/validate_test.go +++ b/internal/model/validate_test.go @@ -154,3 +154,10 @@ func TestValidateConfigRejectsUnsafeRestoreRoot(t *testing.T) { t.Fatalf("safe restoreRoot rejected: %v", err) } } + +func TestRsyncArrayDiskToUSB(t *testing.T) { + job := Job{SchemaVersion: 1, ID: "disk-mirror", Name: "Array to USB", Type: JobRsync, Sources: []Source{{Path: "/mnt/disk1"}}, Rsync: RsyncOptions{Target: "/mnt/disks/USB Backup", Overwrite: true, Delete: true}} + if err := ValidateJob(job); err != nil { + t.Fatal(err) + } +} diff --git a/internal/platform/browser.go b/internal/platform/browser.go index b659112..2cf0ebf 100644 --- a/internal/platform/browser.go +++ b/internal/platform/browser.go @@ -16,7 +16,12 @@ type DirectoryEntry struct { var browseRoots = []string{"/mnt/user", "/mnt/disks", "/mnt/remotes", "/boot"} func BrowseDirectories(path string) ([]DirectoryEntry, error) { - clean, err := validateBrowsePath(path) + mounts, _ := mountedPaths() + return browseDirectories(path, storageBrowseRoots(mounts)) +} + +func browseDirectories(path string, roots []string) ([]DirectoryEntry, error) { + clean, err := validateBrowsePathWithRoots(path, roots) if err != nil { return nil, err } @@ -26,17 +31,29 @@ func BrowseDirectories(path string) ([]DirectoryEntry, error) { } result := make([]DirectoryEntry, 0, len(entries)) for _, entry := range entries { - if entry.IsDir() && entry.Type()&os.ModeSymlink == 0 { - result = append(result, DirectoryEntry{Name: entry.Name(), Path: filepath.Join(clean, entry.Name())}) + child := filepath.Join(clean, entry.Name()) + if entry.Type()&os.ModeSymlink != 0 { + if _, err := validateBrowsePathWithRoots(child, roots); err != nil { + continue + } + info, err := os.Stat(child) + if err != nil || !info.IsDir() { + continue + } + } else if !entry.IsDir() { + continue } + result = append(result, DirectoryEntry{Name: entry.Name(), Path: child}) } sort.Slice(result, func(i, j int) bool { return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name) }) return result, nil } func BrowseRoots() []DirectoryEntry { - result := make([]DirectoryEntry, 0, len(browseRoots)) - for _, root := range browseRoots { + mounts, _ := mountedPaths() + roots := storageBrowseRoots(mounts) + result := make([]DirectoryEntry, 0, len(roots)) + for _, root := range roots { if info, err := os.Stat(root); err == nil && info.IsDir() { result = append(result, DirectoryEntry{Name: root, Path: root}) } @@ -45,6 +62,11 @@ func BrowseRoots() []DirectoryEntry { } func validateBrowsePath(path string) (string, error) { + mounts, _ := mountedPaths() + return validateBrowsePathWithRoots(path, storageBrowseRoots(mounts)) +} + +func validateBrowsePathWithRoots(path string, roots []string) (string, error) { if path == "" || path == "/" { return "", errors.New("validation: select an allowed browse root") } @@ -53,7 +75,7 @@ func validateBrowsePath(path string) (string, error) { return "", errors.New("validation: browse path must be absolute") } allowed := false - for _, root := range browseRoots { + for _, root := range roots { if clean == root || strings.HasPrefix(clean, root+string(os.PathSeparator)) { allowed = true break @@ -66,9 +88,9 @@ func validateBrowsePath(path string) (string, error) { if err != nil { return "", err } - for _, root := range browseRoots { + for _, root := range roots { if resolved == root || strings.HasPrefix(resolved, root+string(os.PathSeparator)) { - return resolved, nil + return clean, nil } } return "", errors.New("validation: browse path resolves outside allowed Unraid storage roots") diff --git a/internal/platform/browser_test.go b/internal/platform/browser_test.go index eb6af57..d6a17f3 100644 --- a/internal/platform/browser_test.go +++ b/internal/platform/browser_test.go @@ -1,6 +1,11 @@ package platform -import "testing" +import ( + "os" + "path/filepath" + "reflect" + "testing" +) func TestValidateBrowsePathRejectsUnsafePaths(t *testing.T) { for _, path := range []string{"", "/", "/etc", "/mnt/user/../../etc", "relative"} { @@ -9,3 +14,69 @@ func TestValidateBrowsePathRejectsUnsafePaths(t *testing.T) { } } } + +func TestBrowseDirectoriesIncludesSafeLinks(t *testing.T) { + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + outside := t.TempDir() + for _, name := range []string{"disk1", "USB"} { + if err := os.Mkdir(filepath.Join(root, name), 0755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(root, "file"), nil, 0600); err != nil { + t.Fatal(err) + } + for name, target := range map[string]string{"alias": "USB", "escape": outside, "broken": "missing", "file-link": "file"} { + if err := os.Symlink(target, filepath.Join(root, name)); err != nil { + t.Fatal(err) + } + } + items, err := browseDirectories(root, []string{root}) + if err != nil { + t.Fatal(err) + } + var names []string + for _, item := range items { + names = append(names, item.Name) + } + if !reflect.DeepEqual(names, []string{"alias", "disk1", "USB"}) { + t.Fatalf("entries = %v", names) + } + if _, err := browseDirectories(filepath.Join(root, "alias"), []string{root}); err != nil { + t.Fatal(err) + } + if _, err := browseDirectories(filepath.Join(root, "escape"), []string{root}); err == nil { + t.Fatal("escaped storage root") + } +} + +func TestStorageBrowseRootsOnlyMountedArrayDisks(t *testing.T) { + mounts := []string{"/mnt/disk1", "/mnt/disk12", "/mnt/disk1", "/mnt/disk0", "/mnt/disk01", "/mnt/disk1-extra", "/mnt/disk2/subdir", "/etc", "/mnt/disks/USB"} + want := append(append([]string{}, browseRoots...), "/mnt/disk1", "/mnt/disk12") + if got := storageBrowseRoots(mounts); !reflect.DeepEqual(got, want) { + t.Fatalf("roots = %v", got) + } +} + +func TestMountPathsAndUnmountedDisks(t *testing.T) { + mounts := parseMountPaths("36 25 8:1 / /mnt/disk1 rw - xfs /dev/md1 rw\n37 25 8:2 / /mnt/disks/USB\\040Backup rw - xfs /dev/sdb1 rw\n") + if !reflect.DeepEqual(mounts, []string{"/mnt/disk1", "/mnt/disks/USB Backup"}) { + t.Fatalf("mounts = %v", mounts) + } + for _, path := range []string{"/mnt/disk1", "/mnt/disk1/data", "/mnt/disks/USB Backup", "/mnt/disks/USB Backup/disk1"} { + if !diskPathMounted(path, mounts) { + t.Fatalf("mounted path rejected: %s", path) + } + } + for _, path := range []string{"/mnt/disk2", "/mnt/disk10", "/mnt/disks", "/mnt/disks/USB Backup-old", "/mnt/disks/unmounted"} { + if diskPathMounted(path, mounts) { + t.Fatalf("unmounted path accepted: %s", path) + } + } + if diskPathMounted("/mnt/disks/USB", []string{"/", "/mnt", "/mnt/disks"}) { + t.Fatal("parent mount accepted as USB mount") + } +} diff --git a/internal/platform/paths.go b/internal/platform/paths.go index d66a79f..aac30e7 100644 --- a/internal/platform/paths.go +++ b/internal/platform/paths.go @@ -10,6 +10,19 @@ import ( ) func ValidateRsyncPaths(job model.Job) error { + needsMountCheck := pathUnder(filepath.Clean(job.Rsync.Target), "/mnt/disks") + for _, source := range job.Sources { + needsMountCheck = needsMountCheck || isArrayDiskPath(source.Path) || pathUnder(filepath.Clean(source.Path), "/mnt/disks") + } + if needsMountCheck { + mounts, err := mountedPaths() + if err != nil { + return errors.New("environment: cannot inspect rsync disk mounts: " + err.Error()) + } + if err := validateRsyncMounts(job, mounts); err != nil { + return err + } + } target, err := filepath.EvalSymlinks(job.Rsync.Target) if err != nil { return errors.New("validation: resolve rsync target: " + err.Error()) diff --git a/internal/platform/storage.go b/internal/platform/storage.go new file mode 100644 index 0000000..bf3c274 --- /dev/null +++ b/internal/platform/storage.go @@ -0,0 +1,86 @@ +package platform + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "git.casaderoll.de/michael/urbm/internal/model" +) + +var arrayDiskName = regexp.MustCompile(`^disk[1-9][0-9]*$`) + +func isArrayDiskPath(path string) bool { + parts := strings.Split(strings.TrimPrefix(filepath.Clean(path), "/"), "/") + return len(parts) >= 2 && parts[0] == "mnt" && arrayDiskName.MatchString(parts[1]) +} + +func mountedPaths() ([]string, error) { + data, err := os.ReadFile("/proc/self/mountinfo") + if err != nil { + return nil, err + } + return parseMountPaths(string(data)), nil +} + +func parseMountPaths(data string) []string { + var paths []string + unescape := strings.NewReplacer(`\040`, " ", `\011`, "\t", `\012`, "\n", `\134`, `\`) + for _, line := range strings.Split(data, "\n") { + fields := strings.Fields(line) + if len(fields) >= 10 { + paths = append(paths, unescape.Replace(fields[4])) + } + } + return paths +} + +func storageBrowseRoots(mounts []string) []string { + roots := append([]string{}, browseRoots...) + seen := make(map[string]bool) + for _, mount := range mounts { + if filepath.Dir(mount) == "/mnt" && isArrayDiskPath(mount) && !seen[mount] { + roots = append(roots, mount) + seen[mount] = true + } + } + return roots +} + +func pathUnder(path, root string) bool { + return path == root || strings.HasPrefix(path, root+string(os.PathSeparator)) +} + +// Require an actual mount for removable disks and array sources. An empty +// directory left behind by an unmount must never become a backup destination. +func validateRsyncMounts(job model.Job, mounts []string) error { + paths := []string{job.Rsync.Target} + for _, source := range job.Sources { + paths = append(paths, source.Path) + } + for _, path := range paths { + clean := filepath.Clean(path) + if !isArrayDiskPath(clean) && !pathUnder(clean, "/mnt/disks") { + continue + } + resolved, err := filepath.EvalSymlinks(clean) + if err != nil { + return fmt.Errorf("environment: rsync disk unavailable: %s: %w", clean, err) + } + if !diskPathMounted(resolved, mounts) { + return fmt.Errorf("environment: rsync disk is not mounted: %s", clean) + } + } + return nil +} + +func diskPathMounted(resolved string, mounts []string) bool { + for _, mount := range mounts { + if ((isArrayDiskPath(mount) && filepath.Dir(mount) == "/mnt") || strings.HasPrefix(mount, "/mnt/disks/")) && pathUnder(resolved, mount) { + return true + } + } + return false +} diff --git a/internal/rsync/rsync_test.go b/internal/rsync/rsync_test.go index 034fabc..4c3f99f 100644 --- a/internal/rsync/rsync_test.go +++ b/internal/rsync/rsync_test.go @@ -23,3 +23,11 @@ func TestParseProgress(t *testing.T) { t.Fatalf("progress = %#v, %v", progress, ok) } } + +func TestArrayDiskMirrorKeepsDiskDirectory(t *testing.T) { + job := model.Job{Sources: []model.Source{{Path: "/mnt/disk1"}}, Rsync: model.RsyncOptions{Target: "/mnt/disks/USB Backup", Overwrite: true, Delete: true}} + args := Arguments(job) + if !slices.Equal(args[len(args)-3:], []string{"--", "/mnt/disk1", "/mnt/disks/USB Backup/"}) || !slices.Contains(args, "--delete-delay") { + t.Fatalf("mirror arguments = %v", args) + } +} diff --git a/plugin/urbm.plg b/plugin/urbm.plg index 083c1c5..eab5511 100644 --- a/plugin/urbm.plg +++ b/plugin/urbm.plg @@ -2,13 +2,19 @@ - + ]> +### 2026.09.27.r001 +- Discover mounted array disks as selectable Rsync sources, including complete disks. +- Show directory symlinks only when they resolve within allowed storage roots. +- Refuse Rsync disk jobs when the array source or USB destination is not mounted. +- Document array-to-USB mirroring and cover disk discovery, mount checks and safe browsing with regression tests. + ### 2026.09.21.r001 - Notify on successful and failed repository checks; complete notification attempts before finishing a task. - Preserve stopped Docker/VM workloads and refuse ambiguous paused or restarting states. diff --git a/webgui/URBM.page b/webgui/URBM.page index 60cdd16..9c974a0 100644 --- a/webgui/URBM.page +++ b/webgui/URBM.page @@ -8,7 +8,7 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore" --- diff --git a/webgui/assets/urbm.js b/webgui/assets/urbm.js index 1c7fe65..c1c92c3 100644 --- a/webgui/assets/urbm.js +++ b/webgui/assets/urbm.js @@ -406,9 +406,10 @@ async function load() { try { - const [configResult,runsResult,metricsResult,statusResult,daemonResult] = await Promise.allSettled([api('/v1/config'),api('/v1/runs'),api('/v1/backup-metrics'),api('/v1/dashboard-status'),api('/v1/health')]); + const [configResult,runsResult,metricsResult,statusResult,daemonResult,rootsResult] = await Promise.allSettled([api('/v1/config'),api('/v1/runs'),api('/v1/backup-metrics'),api('/v1/dashboard-status'),api('/v1/health'),api('/v1/filesystem/directories')]); if(configResult.status!=='fulfilled') throw configResult.reason; state.config=configResult.value; + if(rootsResult.status==='fulfilled' && Array.isArray(rootsResult.value)) sourceRoots.splice(0,sourceRoots.length,...rootsResult.value); if(runsResult.status==='fulfilled'){state.runs=newestRuns(Array.isArray(runsResult.value)?runsResult.value:[]);state.runsError='';}else state.runsError=runsResult.reason?.message||'Aktivitäten konnten nicht geladen werden.'; if(metricsResult.status==='fulfilled'){state.backupMetrics=Array.isArray(metricsResult.value)?metricsResult.value:[];state.backupMetricsError='';}else state.backupMetricsError=metricsResult.reason?.message||'Diagrammdaten konnten nicht geladen werden.'; if(statusResult.status==='fulfilled'){state.dashboardStatus=statusResult.value;state.dashboardStatusError='';lastDashboardStatusRefresh=Date.now();}else state.dashboardStatusError=statusResult.reason?.message||'Backup-Status konnte nicht geladen werden.'; @@ -554,7 +555,7 @@ loadWorkloads(type); } else if (type === 'rsync') { const o=job.rsync||{}; - host.innerHTML = `

Direkte Rsync-Kopie

Rsync erstellt keine verschlüsselten Snapshots. Die ausgewählten Ordner werden direkt in das Ziel kopiert. Die Option „Am Ziel löschen“ spiegelt Löschungen und sollte bewusst aktiviert werden.

Zielordner

Rsync-Optionen
`; + host.innerHTML = `

Direkte Rsync-Kopie

Rsync erstellt keine verschlüsselten Snapshots. Die ausgewählten Ordner werden direkt in das Ziel kopiert (z. B. /mnt/disk1 nach /mnt/disks/USB-Backup/disk1). Array-Disks erscheinen als eigene Quellen, gemountete USB-Disks unter /mnt/disks. Die Option „Am Ziel löschen“ spiegelt Löschungen und sollte bewusst aktiviert werden.

Zielordner

Rsync-Optionen
`; host.querySelector('.bu-event-options').insertAdjacentHTML('beforeend', ``); initSourceTree(sourceRoots.map(item=>item.path), false); initDirectoryTree('rsyncTarget','Rsync-Ziel auswählen','#bu-job-form [name="rsyncTarget"]'); } else {