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
+16
View File
@@ -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.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
e66e3d540fba172a1e0b8e60f7b36e33b38ba8357e5a45a07879a32c2d655222 urbm-2026.09.27.r001-x86_64-1.txz
+8 -2
View File
@@ -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.
+7
View File
@@ -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)
}
}
+30 -8
View File
@@ -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")
+72 -1
View File
@@ -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")
}
}
+13
View File
@@ -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())
+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) 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
View File
@@ -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
View File
@@ -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 ?>">
+3 -2
View File
@@ -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 {