Release 2026.09.27.r001: support array disk rsync backups to mounted USB disks
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user