Release 2026.09.27.r001: support array disk rsync backups to mounted USB disks

This commit is contained in:
Mikei386
2026-09-27 16:07:00 +02:00
parent 5009dc202b
commit 106f9c9387
13 changed files with 252 additions and 15 deletions
+7
View File
@@ -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)
}
}
+30 -8
View File
@@ -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")
+72 -1
View File
@@ -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")
}
}
+13
View File
@@ -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())
+86
View File
@@ -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
}
+8
View File
@@ -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)
}
}