Release 2026.09.27.r001: support array disk rsync backups to mounted USB disks
This commit is contained in:
@@ -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
|
recovery continues after individual failures. Shutdown waits for cleanup; the rc
|
||||||
script refuses a forced kill or overlapping restart if cleanup exceeds five minutes.
|
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.
|
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.
|
||||||
|
|||||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
e66e3d540fba172a1e0b8e60f7b36e33b38ba8357e5a45a07879a32c2d655222 urbm-2026.09.27.r001-x86_64-1.txz
|
||||||
Vendored
+8
-2
@@ -2,13 +2,19 @@
|
|||||||
<!DOCTYPE PLUGIN [
|
<!DOCTYPE PLUGIN [
|
||||||
<!ENTITY name "urbm">
|
<!ENTITY name "urbm">
|
||||||
<!ENTITY author "Michael Roll">
|
<!ENTITY author "Michael Roll">
|
||||||
<!ENTITY version "2026.09.21.r001">
|
<!ENTITY version "2026.09.27.r001">
|
||||||
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg">
|
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg">
|
||||||
<!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz">
|
<!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz">
|
||||||
<!ENTITY packageSHA256 "55d48398ecd446793f51c0fcc987994ba77e52ac950dbc7f5a32f983449e5b83">
|
<!ENTITY packageSHA256 "e66e3d540fba172a1e0b8e60f7b36e33b38ba8357e5a45a07879a32c2d655222">
|
||||||
]>
|
]>
|
||||||
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png">
|
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png">
|
||||||
<CHANGES>
|
<CHANGES>
|
||||||
|
### 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
|
### 2026.09.21.r001
|
||||||
- Notify on successful and failed repository checks; complete notification attempts before finishing a task.
|
- 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.
|
- Preserve stopped Docker/VM workloads and refuse ambiguous paused or restarting states.
|
||||||
|
|||||||
@@ -154,3 +154,10 @@ func TestValidateConfigRejectsUnsafeRestoreRoot(t *testing.T) {
|
|||||||
t.Fatalf("safe restoreRoot rejected: %v", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ type DirectoryEntry struct {
|
|||||||
var browseRoots = []string{"/mnt/user", "/mnt/disks", "/mnt/remotes", "/boot"}
|
var browseRoots = []string{"/mnt/user", "/mnt/disks", "/mnt/remotes", "/boot"}
|
||||||
|
|
||||||
func BrowseDirectories(path string) ([]DirectoryEntry, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -26,17 +31,29 @@ func BrowseDirectories(path string) ([]DirectoryEntry, error) {
|
|||||||
}
|
}
|
||||||
result := make([]DirectoryEntry, 0, len(entries))
|
result := make([]DirectoryEntry, 0, len(entries))
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.IsDir() && entry.Type()&os.ModeSymlink == 0 {
|
child := filepath.Join(clean, entry.Name())
|
||||||
result = append(result, DirectoryEntry{Name: entry.Name(), Path: 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) })
|
sort.Slice(result, func(i, j int) bool { return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name) })
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func BrowseRoots() []DirectoryEntry {
|
func BrowseRoots() []DirectoryEntry {
|
||||||
result := make([]DirectoryEntry, 0, len(browseRoots))
|
mounts, _ := mountedPaths()
|
||||||
for _, root := range browseRoots {
|
roots := storageBrowseRoots(mounts)
|
||||||
|
result := make([]DirectoryEntry, 0, len(roots))
|
||||||
|
for _, root := range roots {
|
||||||
if info, err := os.Stat(root); err == nil && info.IsDir() {
|
if info, err := os.Stat(root); err == nil && info.IsDir() {
|
||||||
result = append(result, DirectoryEntry{Name: root, Path: root})
|
result = append(result, DirectoryEntry{Name: root, Path: root})
|
||||||
}
|
}
|
||||||
@@ -45,6 +62,11 @@ func BrowseRoots() []DirectoryEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateBrowsePath(path string) (string, error) {
|
func validateBrowsePath(path string) (string, error) {
|
||||||
|
mounts, _ := mountedPaths()
|
||||||
|
return validateBrowsePathWithRoots(path, storageBrowseRoots(mounts))
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateBrowsePathWithRoots(path string, roots []string) (string, error) {
|
||||||
if path == "" || path == "/" {
|
if path == "" || path == "/" {
|
||||||
return "", errors.New("validation: select an allowed browse root")
|
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")
|
return "", errors.New("validation: browse path must be absolute")
|
||||||
}
|
}
|
||||||
allowed := false
|
allowed := false
|
||||||
for _, root := range browseRoots {
|
for _, root := range roots {
|
||||||
if clean == root || strings.HasPrefix(clean, root+string(os.PathSeparator)) {
|
if clean == root || strings.HasPrefix(clean, root+string(os.PathSeparator)) {
|
||||||
allowed = true
|
allowed = true
|
||||||
break
|
break
|
||||||
@@ -66,9 +88,9 @@ func validateBrowsePath(path string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
for _, root := range browseRoots {
|
for _, root := range roots {
|
||||||
if resolved == root || strings.HasPrefix(resolved, root+string(os.PathSeparator)) {
|
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")
|
return "", errors.New("validation: browse path resolves outside allowed Unraid storage roots")
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package platform
|
package platform
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestValidateBrowsePathRejectsUnsafePaths(t *testing.T) {
|
func TestValidateBrowsePathRejectsUnsafePaths(t *testing.T) {
|
||||||
for _, path := range []string{"", "/", "/etc", "/mnt/user/../../etc", "relative"} {
|
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 {
|
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)
|
target, err := filepath.EvalSymlinks(job.Rsync.Target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("validation: resolve rsync target: " + err.Error())
|
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
|
||||||
|
}
|
||||||
@@ -23,3 +23,11 @@ func TestParseProgress(t *testing.T) {
|
|||||||
t.Fatalf("progress = %#v, %v", progress, ok)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+7
-1
@@ -2,13 +2,19 @@
|
|||||||
<!DOCTYPE PLUGIN [
|
<!DOCTYPE PLUGIN [
|
||||||
<!ENTITY name "urbm">
|
<!ENTITY name "urbm">
|
||||||
<!ENTITY author "Michael Roll">
|
<!ENTITY author "Michael Roll">
|
||||||
<!ENTITY version "2026.09.21.r001">
|
<!ENTITY version "2026.09.27.r001">
|
||||||
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg">
|
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg">
|
||||||
<!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz">
|
<!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz">
|
||||||
<!ENTITY packageSHA256 "REPLACE_DURING_RELEASE">
|
<!ENTITY packageSHA256 "REPLACE_DURING_RELEASE">
|
||||||
]>
|
]>
|
||||||
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png">
|
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png">
|
||||||
<CHANGES>
|
<CHANGES>
|
||||||
|
### 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
|
### 2026.09.21.r001
|
||||||
- Notify on successful and failed repository checks; complete notification attempts before finishing a task.
|
- 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.
|
- Preserve stopped Docker/VM workloads and refuse ambiguous paused or restarting states.
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore"
|
|||||||
---
|
---
|
||||||
<?php
|
<?php
|
||||||
$pluginRoot = '/plugins/urbm';
|
$pluginRoot = '/plugins/urbm';
|
||||||
$urbmVersion = '2026.09.21.r001';
|
$urbmVersion = '2026.09.27.r001';
|
||||||
$urbmAssetVersion = preg_replace('/[^A-Za-z0-9]/', '', $urbmVersion);
|
$urbmAssetVersion = preg_replace('/[^A-Za-z0-9]/', '', $urbmVersion);
|
||||||
?>
|
?>
|
||||||
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=<?= $urbmAssetVersion ?>">
|
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=<?= $urbmAssetVersion ?>">
|
||||||
|
|||||||
@@ -406,9 +406,10 @@
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
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;
|
if(configResult.status!=='fulfilled') throw configResult.reason;
|
||||||
state.config=configResult.value;
|
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(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(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.';
|
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);
|
loadWorkloads(type);
|
||||||
} else if (type === 'rsync') {
|
} else if (type === 'rsync') {
|
||||||
const o=job.rsync||{};
|
const o=job.rsync||{};
|
||||||
host.innerHTML = `<h3>Direkte Rsync-Kopie</h3><div class="bu-inline-info">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.</div><div id="bu-source-browser"></div><label class="bu-manual-source">Zusätzliche manuelle Quellpfade<textarea name="sources"></textarea></label><h3>Zielordner</h3><label class="wide">Rsync-Ziel<input name="rsyncTarget" required value="${esc(o.target||'')}" placeholder="/mnt/remotes/backup"></label><div id="bu-dir-tree-rsyncTarget"></div><fieldset class="wide bu-fieldset"><legend>Rsync-Optionen</legend><div class="bu-event-options"><label><input name="rsyncOverwrite" type="checkbox" ${o.overwrite!==false?'checked':''}><span>Vorhandene Dateien aktualisieren / überschreiben</span></label><label><input name="rsyncDelete" type="checkbox" ${o.delete===true?'checked':''}><span>Dateien am Ziel löschen, die in der Quelle nicht mehr vorhanden sind</span></label><label><input name="rsyncPermissions" type="checkbox" ${o.preservePermissions!==false?'checked':''}><span>Dateiberechtigungen übernehmen</span></label><label><input name="rsyncOwner" type="checkbox" ${o.preserveOwner===true?'checked':''}><span>Besitzer übernehmen</span></label><label><input name="rsyncGroup" type="checkbox" ${o.preserveGroup===true?'checked':''}><span>Gruppen übernehmen</span></label><label><input name="rsyncTimes" type="checkbox" ${o.preserveTimes!==false?'checked':''}><span>Zeitstempel übernehmen</span></label><label><input name="rsyncLinks" type="checkbox" ${o.preserveLinks!==false?'checked':''}><span>Symbolische Links als Links übernehmen</span></label><label><input name="rsyncACLs" type="checkbox" ${o.preserveAcls===true?'checked':''}><span>ACLs übernehmen</span></label><label><input name="rsyncXattrs" type="checkbox" ${o.preserveXattrs===true?'checked':''}><span>Erweiterte Attribute übernehmen</span></label><label><input name="rsyncChecksum" type="checkbox" ${o.checksum===true?'checked':''}><span>Dateien per Prüfsumme vergleichen (langsamer)</span></label><label><input name="rsyncDryRun" type="checkbox" ${o.dryRun===true?'checked':''}><span>Testlauf ohne Änderungen</span></label></div></fieldset>`;
|
host.innerHTML = `<h3>Direkte Rsync-Kopie</h3><div class="bu-inline-info">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.</div><div id="bu-source-browser"></div><label class="bu-manual-source">Zusätzliche manuelle Quellpfade<textarea name="sources"></textarea></label><h3>Zielordner</h3><label class="wide">Rsync-Ziel<input name="rsyncTarget" required value="${esc(o.target||'')}" placeholder="/mnt/remotes/backup"></label><div id="bu-dir-tree-rsyncTarget"></div><fieldset class="wide bu-fieldset"><legend>Rsync-Optionen</legend><div class="bu-event-options"><label><input name="rsyncOverwrite" type="checkbox" ${o.overwrite!==false?'checked':''}><span>Vorhandene Dateien aktualisieren / überschreiben</span></label><label><input name="rsyncDelete" type="checkbox" ${o.delete===true?'checked':''}><span>Dateien am Ziel löschen, die in der Quelle nicht mehr vorhanden sind</span></label><label><input name="rsyncPermissions" type="checkbox" ${o.preservePermissions!==false?'checked':''}><span>Dateiberechtigungen übernehmen</span></label><label><input name="rsyncOwner" type="checkbox" ${o.preserveOwner===true?'checked':''}><span>Besitzer übernehmen</span></label><label><input name="rsyncGroup" type="checkbox" ${o.preserveGroup===true?'checked':''}><span>Gruppen übernehmen</span></label><label><input name="rsyncTimes" type="checkbox" ${o.preserveTimes!==false?'checked':''}><span>Zeitstempel übernehmen</span></label><label><input name="rsyncLinks" type="checkbox" ${o.preserveLinks!==false?'checked':''}><span>Symbolische Links als Links übernehmen</span></label><label><input name="rsyncACLs" type="checkbox" ${o.preserveAcls===true?'checked':''}><span>ACLs übernehmen</span></label><label><input name="rsyncXattrs" type="checkbox" ${o.preserveXattrs===true?'checked':''}><span>Erweiterte Attribute übernehmen</span></label><label><input name="rsyncChecksum" type="checkbox" ${o.checksum===true?'checked':''}><span>Dateien per Prüfsumme vergleichen (langsamer)</span></label><label><input name="rsyncDryRun" type="checkbox" ${o.dryRun===true?'checked':''}><span>Testlauf ohne Änderungen</span></label></div></fieldset>`;
|
||||||
host.querySelector('.bu-event-options').insertAdjacentHTML('beforeend', `<label><input name="rsyncSkipSpaceCheck" type="checkbox" ${o.skipSpaceCheck===true?'checked':''}><span>Kapazitätsprüfung überspringen (riskant; nur wenn vorhandene Zieldateien ausreichend Platz freigeben)</span></label>`);
|
host.querySelector('.bu-event-options').insertAdjacentHTML('beforeend', `<label><input name="rsyncSkipSpaceCheck" type="checkbox" ${o.skipSpaceCheck===true?'checked':''}><span>Kapazitätsprüfung überspringen (riskant; nur wenn vorhandene Zieldateien ausreichend Platz freigeben)</span></label>`);
|
||||||
initSourceTree(sourceRoots.map(item=>item.path), false); initDirectoryTree('rsyncTarget','Rsync-Ziel auswählen','#bu-job-form [name="rsyncTarget"]');
|
initSourceTree(sourceRoots.map(item=>item.path), false); initDirectoryTree('rsyncTarget','Rsync-Ziel auswählen','#bu-job-form [name="rsyncTarget"]');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user