diff --git a/dist/urbm-2026.06.14.r003-x86_64-1.txz.sha256 b/dist/urbm-2026.06.14.r003-x86_64-1.txz.sha256 deleted file mode 100644 index a19c528..0000000 --- a/dist/urbm-2026.06.14.r003-x86_64-1.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -ad85d7a2225c4f35d3628f613274612ab6a90dc06263fc60b47657f79f723540 urbm-2026.06.14.r003-x86_64-1.txz diff --git a/dist/urbm-2026.06.14.r003-x86_64-1.txz b/dist/urbm-2026.06.14.r004-x86_64-1.txz similarity index 55% rename from dist/urbm-2026.06.14.r003-x86_64-1.txz rename to dist/urbm-2026.06.14.r004-x86_64-1.txz index 86c11f7..dc0c5ca 100644 Binary files a/dist/urbm-2026.06.14.r003-x86_64-1.txz and b/dist/urbm-2026.06.14.r004-x86_64-1.txz differ diff --git a/dist/urbm-2026.06.14.r004-x86_64-1.txz.sha256 b/dist/urbm-2026.06.14.r004-x86_64-1.txz.sha256 new file mode 100644 index 0000000..064db2d --- /dev/null +++ b/dist/urbm-2026.06.14.r004-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +83eb2bd7c3c60ca5b973755355b016262a5b7020d18eb45bf4aa000c1f533053 urbm-2026.06.14.r004-x86_64-1.txz diff --git a/dist/urbm.plg b/dist/urbm.plg index 9e6e9c0..63bf332 100644 --- a/dist/urbm.plg +++ b/dist/urbm.plg @@ -2,13 +2,17 @@ - + - + ]> +### 2026.06.14.r004 +- Create a dedicated repository subdirectory from the repository name for every newly configured local, SMB, NFS, or SFTP destination. +- Preserve existing repository locations and safely separate managed network mount points from their repository subdirectories. + ### 2026.06.14.r003 - Use URBM consistently as the Unraid navigation and page name instead of the translated Backup / Sicherung labels. diff --git a/internal/model/validate.go b/internal/model/validate.go index 1cbc9c1..7f33172 100644 --- a/internal/model/validate.go +++ b/internal/model/validate.go @@ -166,6 +166,12 @@ func ValidateRepository(r Repository) error { if r.Mount.Remote == "" || !filepath.IsAbs(r.Mount.MountPoint) { return errors.New("managed mount requires remote and absolute mountPoint") } + location := filepath.Clean(r.Location) + mountPoint := filepath.Clean(r.Mount.MountPoint) + relative, err := filepath.Rel(mountPoint, location) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return errors.New("managed repository location must be inside mountPoint") + } } if r.Type == RepositorySFTP && r.CredentialRef != "" { knownHosts := r.Options["knownHostsPath"] diff --git a/internal/model/validate_test.go b/internal/model/validate_test.go index 31ebcda..69bb2d2 100644 --- a/internal/model/validate_test.go +++ b/internal/model/validate_test.go @@ -79,3 +79,14 @@ func TestRetentionRequiresAtLeastOneRule(t *testing.T) { t.Fatalf("age retention rejected: %v", err) } } + +func TestManagedRepositoryLocationMustRemainInsideMountPoint(t *testing.T) { + repo := Repository{SchemaVersion: 1, ID: "repo", Name: "NAS", Type: RepositorySMB, Location: "/mnt/remotes/nas/NAS", PasswordRef: "password", Mount: &MountConfig{Managed: true, Remote: "//nas/backups", MountPoint: "/mnt/remotes/nas"}} + if err := ValidateRepository(repo); err != nil { + t.Fatalf("repository subdirectory rejected: %v", err) + } + repo.Location = "/mnt/remotes/other/NAS" + if err := ValidateRepository(repo); err == nil { + t.Fatal("repository location outside mountPoint accepted") + } +} diff --git a/internal/platform/mounts.go b/internal/platform/mounts.go index 356b5b6..2a60965 100644 --- a/internal/platform/mounts.go +++ b/internal/platform/mounts.go @@ -22,6 +22,7 @@ type MountManager struct { type MountedRepository struct { Repository model.Repository Mounted bool + MountPoint string CredentialFile string } @@ -32,7 +33,12 @@ func (m *MountManager) Prepare(ctx context.Context, repo model.Repository) (Moun } if repo.Mount == nil || !repo.Mount.Managed { if _, err := os.Stat(repo.Location); err != nil { - return result, fmt.Errorf("connectivity: external mount unavailable: %w", err) + if !os.IsNotExist(err) { + return result, fmt.Errorf("connectivity: external mount unavailable: %w", err) + } + if _, parentErr := os.Stat(filepath.Dir(repo.Location)); parentErr != nil { + return result, fmt.Errorf("connectivity: external mount unavailable: %w", parentErr) + } } return result, nil } @@ -40,6 +46,11 @@ func (m *MountManager) Prepare(ctx context.Context, repo model.Repository) (Moun if !strings.HasPrefix(point, "/mnt/remotes/") && !strings.HasPrefix(point, "/mnt/disks/") { return result, errors.New("validation: managed mountPoint must be below /mnt/remotes or /mnt/disks") } + repositoryPath := filepath.Clean(repo.Location) + relative, err := filepath.Rel(point, repositoryPath) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return result, errors.New("validation: managed repository location must be inside its mountPoint") + } if err := os.MkdirAll(point, 0700); err != nil { return result, err } @@ -86,7 +97,7 @@ func (m *MountManager) Prepare(ctx context.Context, repo model.Repository) (Moun return result, fmt.Errorf("connectivity: mount repository: %w", err) } result.Mounted = true - result.Repository.Location = point + result.MountPoint = point return result, nil } @@ -97,5 +108,5 @@ func (m *MountManager) Cleanup(ctx context.Context, mounted MountedRepository) e if !mounted.Mounted { return nil } - return exec.CommandContext(ctx, "umount", mounted.Repository.Location).Run() + return exec.CommandContext(ctx, "umount", mounted.MountPoint).Run() } diff --git a/internal/platform/mounts_test.go b/internal/platform/mounts_test.go new file mode 100644 index 0000000..6792c07 --- /dev/null +++ b/internal/platform/mounts_test.go @@ -0,0 +1,32 @@ +package platform + +import ( + "context" + "path/filepath" + "testing" + + "git.casaderoll.de/michael/urbm/internal/model" +) + +func TestExternalMountAllowsNewRepositorySubdirectory(t *testing.T) { + base := t.TempDir() + repo := model.Repository{Type: model.RepositorySMB, Location: filepath.Join(base, "Photos")} + manager := &MountManager{} + + mounted, err := manager.Prepare(context.Background(), repo) + if err != nil { + t.Fatalf("new repository subdirectory rejected: %v", err) + } + if mounted.Repository.Location != repo.Location { + t.Fatalf("location = %q, want %q", mounted.Repository.Location, repo.Location) + } +} + +func TestExternalMountRejectsMissingBaseDirectory(t *testing.T) { + repo := model.Repository{Type: model.RepositoryNFS, Location: filepath.Join(t.TempDir(), "missing", "Photos")} + manager := &MountManager{} + + if _, err := manager.Prepare(context.Background(), repo); err == nil { + t.Fatal("missing external mount base accepted") + } +} diff --git a/internal/service/service.go b/internal/service/service.go index dc773f7..ad1b08f 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -226,6 +226,11 @@ func (s *Service) TestRepository(ctx context.Context, repoID string, initialize } defer s.mounts.Cleanup(context.Background(), mounted) if initialize { + if mounted.Repository.Type != model.RepositorySFTP { + if err := os.MkdirAll(mounted.Repository.Location, 0750); err != nil { + return fmt.Errorf("repository: create destination directory: %w", err) + } + } return s.restic.Init(ctx, mounted.Repository) } _, err = s.restic.Snapshots(ctx, mounted.Repository) diff --git a/plugin/urbm.plg b/plugin/urbm.plg index 3f11bdb..ec75efb 100644 --- a/plugin/urbm.plg +++ b/plugin/urbm.plg @@ -2,13 +2,17 @@ - + ]> +### 2026.06.14.r004 +- Create a dedicated repository subdirectory from the repository name for every newly configured local, SMB, NFS, or SFTP destination. +- Preserve existing repository locations and safely separate managed network mount points from their repository subdirectories. + ### 2026.06.14.r003 - Use URBM consistently as the Unraid navigation and page name instead of the translated Backup / Sicherung labels. diff --git a/webgui/URBM.page b/webgui/URBM.page index 787776f..6c5b1a7 100644 --- a/webgui/URBM.page +++ b/webgui/URBM.page @@ -9,12 +9,12 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore" - +
- -

URBM 2026.06.14.r003

Restic Backup Manager für Unraid

+ +

URBM 2026.06.14.r004

Restic Backup Manager für Unraid

Verbindung wird hergestellt...
@@ -32,4 +32,4 @@ $pluginRoot = '/plugins/urbm';
- + diff --git a/webgui/assets/urbm.js b/webgui/assets/urbm.js index b36f331..d147754 100644 --- a/webgui/assets/urbm.js +++ b/webgui/assets/urbm.js @@ -27,7 +27,7 @@ keepWeekly: 'Zusätzliche wöchentliche Stände außerhalb der Altersfrist. 0 deaktiviert diese Zusatzregel.', keepMonthly: 'Zusätzliche monatliche Stände außerhalb der Altersfrist. 0 deaktiviert diese Zusatzregel.', enabled: 'Aktiviert automatische geplante Läufe. Deaktivierte Jobs können weiterhin gespeichert, aber nicht automatisch ausgeführt werden.', - location: 'Restic-Ziel. Lokal: /mnt/user/backups/job1. SFTP: user@server:/backup/job1. Bei SMB/NFS mit Mount wird hier der Mount-Pfad verwendet.', + location: 'Bei neuen Repositorys ist dies der Basisordner. URBM ergänzt automatisch einen sicheren Unterordner aus dem Repository-Namen. Beispiel: /mnt/user/Backups und Fotos Zuhause ergeben /mnt/user/Backups/Fotos-Zuhause.', password: 'Passwort zur Restic-Verschlüsselung. Beispiel: eine lange zufällige Passphrase. Leer lassen, wenn ein vorhandenes Passwort unverändert bleiben soll.', managed: 'Wenn aktiv, mountet URBM SMB/NFS vor dem Job selbst. Wenn aus, muss der angegebene Pfad bereits durch Unraid oder Unassigned Devices gemountet sein.', remote: 'Adresse der Netzwerkfreigabe. SMB-Beispiel: //192.168.1.20/backup. NFS-Beispiel: 192.168.1.20:/volume1/backup.', @@ -117,6 +117,15 @@ const id = (prefix) => `${prefix}-${crypto.randomUUID()}`; const repoName = (repoId) => state.config.repositories.find(r => r.id === repoId)?.name || repoId; const volatileLocation = (repo) => repo.type === 'local' && (/^\/tmp(?:\/|$)/.test(repo.location) || /^\/run(?:\/|$)/.test(repo.location)); + const repositoryFolderName = (name) => String(name || '') + .normalize('NFKD').replace(/[\u0300-\u036f]/g, '') + .replace(/ß/g, 'ss').replace(/[^a-zA-Z0-9._-]+/g, '-') + .replace(/^[._-]+|[._-]+$/g, '').slice(0, 64); + const repositoryChildLocation = (base, name) => { + const folder = repositoryFolderName(name); + if (!folder) throw new Error('Der Repository-Name muss mindestens einen Buchstaben oder eine Zahl enthalten.'); + return `${String(base || '').replace(/\/+$/,'')}/${folder}`; + }; const tipAttr = (text) => text ? ` data-tooltip="${esc(text)}"` : ''; const actionTooltip = (action, label) => { const [name] = action.split(':'); @@ -420,10 +429,12 @@ content.innerHTML = `

Repository ${repo.id?'bearbeiten':'erstellen'}

+ ${repo.id?'':'

Automatischer Repository-Unterordner

URBM erstellt im gewählten Basisordner einen Unterordner aus dem Repository-Namen.

Endgültiger Speicherort: Name und Basisordner eingeben

'}
Restic-Verschlüsselung

Das Passwort wird von URBM geschützt gespeichert. ${repo.id?'Leer lassen, um das vorhandene Passwort zu behalten.':'Bewahre dieses Passwort sicher auf; ohne das Passwort können Backups nicht wiederhergestellt werden.'}

${button('Speichern','submit-repo','primary')}${button('Abbrechen','cancel-form')}
`; enhanceTooltips(content); renderRepositorySpecific(repo.type || 'local', repo); + updateRepositoryLocationPreview(); } function repositoryLocationField(label, value, placeholder) { @@ -434,15 +445,25 @@ const host = document.getElementById('bu-repository-specific'); if (!host) return; if (type === 'local') { - host.innerHTML = `${repositoryLocationField('Lokaler Repository-Ordner',repo.location,'/mnt/user/backups/urbm')}
`; + host.innerHTML = `${repositoryLocationField(repo.id?'Lokaler Repository-Ordner':'Lokaler Basisordner',repo.location,'/mnt/user/backups')}
`; renderRepositoryBrowser(); } else if (type === 'smb' || type === 'nfs') { const managed = repo.mount?.managed === true; - host.innerHTML = `
${type.toUpperCase()}-Ziel
${repositoryLocationField('Bereits eingebundener Ordner',repo.location,'/mnt/remotes/backup-nas')}
${type==='smb'?``:''}
`; + host.innerHTML = `
${type.toUpperCase()}-Ziel
${repositoryLocationField(repo.id?'Bereits eingebundener Repository-Ordner':'Bereits eingebundener Basisordner',repo.location,'/mnt/remotes/backup-nas')}
${type==='smb'?``:''}
`; } else { - host.innerHTML = `
SFTP-Ziel
${repositoryLocationField('SFTP-Repository-Adresse',repo.location,'backup@192.168.1.20:/srv/backups/urbm')}
`; + host.innerHTML = `
SFTP-Ziel
${repositoryLocationField(repo.id?'SFTP-Repository-Adresse':'SFTP-Basisadresse',repo.location,'backup@192.168.1.20:/srv/backups')}
`; } enhanceTooltips(host); + updateRepositoryLocationPreview(); + } + + function updateRepositoryLocationPreview() { + const form=document.getElementById('bu-repo-form'); const output=document.getElementById('bu-repository-final-location'); + if(!form||!output||form.elements.id.value) return; + const type=form.elements.type.value; const managed=(type==='smb'||type==='nfs')&&form.elements.managed?.checked; + const base=managed?form.elements.mountPoint?.value:form.elements.location?.value; + try { output.textContent=base&&form.elements.name.value?repositoryChildLocation(base,form.elements.name.value):'Name und Basisordner eingeben'; } + catch(error) { output.textContent=error.message; } } function renderRepositoryBrowser() { @@ -555,7 +576,7 @@ if (name === 'select-current-source') { if(!state.backupSelections.includes(state.sourceBrowserPath)) state.backupSelections.push(state.sourceBrowserPath); return renderSourceBrowser(); } if (name === 'open-repository-dir') { return loadRepositoryDirectories(decodeURIComponent(a)); } if (name === 'repository-up') { const parent=state.repositoryBrowserPath==='/'?'/':state.repositoryBrowserPath.split('/').slice(0,-1).join('/')||'/'; return loadRepositoryDirectories(parent==='/mnt'?'/':parent); } - if (name === 'select-repository-folder') { const location=document.querySelector('#bu-repo-form [name="location"]'); if(location) location.value=state.repositoryBrowserPath; return; } + if (name === 'select-repository-folder') { const location=document.querySelector('#bu-repo-form [name="location"]'); if(location) location.value=state.repositoryBrowserPath; updateRepositoryLocationPreview(); return; } if (name === 'open-dir') { state.browserPath=decodeURIComponent(a); state.files=await api(`/v1/repositories/${state.snapshotRepo}/snapshots/${state.selectedSnapshot}/files?path=${encodeURIComponent(state.browserPath)}`); return render(); } if (name === 'browser-up') { state.browserPath=state.browserPath.replace(/\/$/,'').split('/').slice(0,-1).join('/')||'/'; state.files=await api(`/v1/repositories/${state.snapshotRepo}/snapshots/${state.selectedSnapshot}/files?path=${encodeURIComponent(state.browserPath)}`); return render(); } if (name === 'select-current-restore') { if(!state.restoreIncludes.includes(state.browserPath)) state.restoreIncludes.push(state.browserPath); return render(); } @@ -584,8 +605,10 @@ let location=managed?value('mountPoint'):value('location'); if(!location) throw new Error(managed?'Ein Einhängepunkt ist erforderlich.':'Ein Repository-Speicherort ist erforderlich.'); if(managed&&!value('remote')) throw new Error('Für eine verwaltete Einbindung ist eine Netzwerkfreigabe erforderlich.'); + const isNew=!value('id'); const baseLocation=location; + if(isNew) location=repositoryChildLocation(baseLocation,value('name')); const usesCredential=type==='sftp'||(type==='smb'&&managed); const credentialRef=usesCredential&&(value('hasCredential')==='true'||Boolean(f.get('credential')))?value('credentialRef'):''; - const repo={schemaVersion:1,id:value('id')||id('repo'),name:value('name'),type,location,passwordRef:value('passwordRef'),credentialRef,mount:managed?{managed:true,remote:value('remote'),mountPoint:value('mountPoint')}:null,options:type==='sftp'?{knownHostsPath:value('knownHostsPath')}: {}}; + const repo={schemaVersion:1,id:value('id')||id('repo'),name:value('name'),type,location,passwordRef:value('passwordRef'),credentialRef,mount:managed?{managed:true,remote:value('remote'),mountPoint:baseLocation}:null,options:type==='sftp'?{knownHostsPath:value('knownHostsPath')}: {}}; if(!value('id')&&!f.get('password')) throw new Error('Für ein neues Repository ist ein Passwort erforderlich.'); if (volatileLocation(repo) && !confirm('WARNUNG: Dieses Ziel liegt unter /tmp oder /run und geht nach einem Unraid-Neustart verloren. Trotzdem speichern?')) return; if(f.get('password')) await api(`/v1/secrets/${encodeURIComponent(repo.passwordRef)}`,{method:'PUT',body:JSON.stringify({type:'restic-password',value:f.get('password')})}); @@ -614,6 +637,7 @@ const managed=fieldset?.querySelector('.bu-managed-mount'); const external=fieldset?.querySelector('.bu-external-mount'); if(managed) managed.hidden=!e.target.checked; if(external) external.hidden=e.target.checked; + updateRepositoryLocationPreview(); } if (e.target.name === 'scheduleMode') { const weekdays=e.target.closest('.bu-schedule')?.querySelector('.bu-weekdays'); @@ -636,6 +660,9 @@ renderWorkloadBrowser(document.querySelector('#bu-job-form [name="type"]')?.value||'docker'); } }); + content.addEventListener('input', e => { + if (e.target.closest('#bu-repo-form') && ['name','location','mountPoint'].includes(e.target.name)) updateRepositoryLocationPreview(); + }); content.addEventListener('submit', e => { const form = e.target.closest('form'); if (!form) return;