Create named subdirectories for new repositories

This commit is contained in:
Mikei386
2026-06-14 23:52:37 +02:00
parent 854d9c9e3e
commit 7542133692
12 changed files with 117 additions and 17 deletions
-1
View File
@@ -1 +0,0 @@
ad85d7a2225c4f35d3628f613274612ab6a90dc06263fc60b47657f79f723540 urbm-2026.06.14.r003-x86_64-1.txz
+1
View File
@@ -0,0 +1 @@
83eb2bd7c3c60ca5b973755355b016262a5b7020d18eb45bf4aa000c1f533053 urbm-2026.06.14.r004-x86_64-1.txz
+6 -2
View File
@@ -2,13 +2,17 @@
<!DOCTYPE PLUGIN [
<!ENTITY name "urbm">
<!ENTITY author "Michael Roll">
<!ENTITY version "2026.06.14.r003">
<!ENTITY version "2026.06.14.r004">
<!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 packageSHA256 "ad85d7a2225c4f35d3628f613274612ab6a90dc06263fc60b47657f79f723540">
<!ENTITY packageSHA256 "83eb2bd7c3c60ca5b973755355b016262a5b7020d18eb45bf4aa000c1f533053">
]>
<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>
### 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.
+6
View File
@@ -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"]
+11
View File
@@ -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")
}
}
+14 -3
View File
@@ -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()
}
+32
View File
@@ -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")
}
}
+5
View File
@@ -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)
+5 -1
View File
@@ -2,13 +2,17 @@
<!DOCTYPE PLUGIN [
<!ENTITY name "urbm">
<!ENTITY author "Michael Roll">
<!ENTITY version "2026.06.14.r003">
<!ENTITY version "2026.06.14.r004">
<!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 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">
<CHANGES>
### 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.
+4 -4
View File
@@ -9,12 +9,12 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore"
<?php
$pluginRoot = '/plugins/urbm';
?>
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260614r003">
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260614r004">
<div id="urbm-app" data-api="<?= $pluginRoot ?>/api.php">
<header class="bu-header">
<div class="bu-brand">
<img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260614r003" alt="URBM-Logo">
<div><h1>URBM <span class="bu-version">2026.06.14.r003</span></h1><p>Restic Backup Manager für Unraid</p></div>
<img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260614r004" alt="URBM-Logo">
<div><h1>URBM <span class="bu-version">2026.06.14.r004</span></h1><p>Restic Backup Manager für Unraid</p></div>
</div>
<div id="bu-health" class="bu-health" tabindex="0" data-tooltip="Zeigt, ob die URBM-Hintergrundkomponente erreichbar ist. Beispiel: 'Daemon online' bedeutet, dass Jobs gestartet werden können.">Verbindung wird hergestellt...</div>
</header>
@@ -32,4 +32,4 @@ $pluginRoot = '/plugins/urbm';
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
</div>
<script>window.URBM_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
<script src="<?= $pluginRoot ?>/assets/urbm-2026.06.14.r003.js"></script>
<script src="<?= $pluginRoot ?>/assets/urbm-2026.06.14.r004.js"></script>
+33 -6
View File
@@ -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 = `<section class="bu-card"><h2>Repository ${repo.id?'bearbeiten':'erstellen'}</h2><form id="bu-repo-form" class="bu-form"><input type="hidden" name="id" value="${esc(repo.id||'')}"><input type="hidden" name="passwordRef" value="${esc(passwordRef)}"><input type="hidden" name="credentialRef" value="${esc(credentialRef)}"><input type="hidden" name="hasCredential" value="${repo.credentialRef?'true':'false'}">
<label>Name<input name="name" required value="${esc(repo.name||'')}"></label><label>Typ<select name="type">${['local','smb','nfs','sftp'].map(v=>`<option value="${v}" ${v===repo.type?'selected':''}>${esc(typeLabels[v])}</option>`).join('')}</select></label>
<div id="bu-repository-specific" class="wide"></div>
${repo.id?'':'<div class="wide bu-info-card"><h3>Automatischer Repository-Unterordner</h3><p>URBM erstellt im gewählten Basisordner einen Unterordner aus dem Repository-Namen.</p><p>Endgültiger Speicherort: <strong id="bu-repository-final-location">Name und Basisordner eingeben</strong></p></div>'}
<fieldset class="wide bu-fieldset"><legend>Restic-Verschlüsselung</legend><label>Repository-Passwort<input name="password" type="password" autocomplete="new-password" ${repo.id?'':'required'} placeholder="${repo.id?'Leer lassen, um das vorhandene Passwort zu behalten':'Ein starkes Repository-Passwort eingeben'}"></label><p class="bu-muted">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.'}</p></fieldset>
<div class="wide bu-actions">${button('Speichern','submit-repo','primary')}${button('Abbrechen','cancel-form')}</div></form></section>`;
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')}<div id="bu-repository-browser"></div>`;
host.innerHTML = `${repositoryLocationField(repo.id?'Lokaler Repository-Ordner':'Lokaler Basisordner',repo.location,'/mnt/user/backups')}<div id="bu-repository-browser"></div>`;
renderRepositoryBrowser();
} else if (type === 'smb' || type === 'nfs') {
const managed = repo.mount?.managed === true;
host.innerHTML = `<fieldset class="bu-fieldset"><legend>${type.toUpperCase()}-Ziel</legend><label class="bu-inline-check"><input name="managed" type="checkbox" ${managed?'checked':''}><span>URBM soll diese Freigabe einbinden</span></label><div class="bu-external-mount" ${managed?'hidden':''}>${repositoryLocationField('Bereits eingebundener Ordner',repo.location,'/mnt/remotes/backup-nas')}</div><div class="bu-managed-mount" ${managed?'':'hidden'}><div class="bu-form-grid"><label>Netzwerkfreigabe<input name="remote" placeholder="${type==='smb'?'//192.168.1.20/backup':'192.168.1.20:/volume1/backup'}" value="${esc(repo.mount?.remote||'')}"></label><label>Einhängepunkt<input name="mountPoint" placeholder="/mnt/remotes/urbm-backup" value="${esc(repo.mount?.mountPoint||repo.location||'')}"></label>${type==='smb'?`<label class="wide">SMB-Zugangsdaten<textarea name="credential" placeholder="${repo.credentialRef?'Leer lassen, um vorhandene Zugangsdaten zu behalten':'username=backup&#10;password=geheim'}"></textarea></label>`:''}</div></div></fieldset>`;
host.innerHTML = `<fieldset class="bu-fieldset"><legend>${type.toUpperCase()}-Ziel</legend><label class="bu-inline-check"><input name="managed" type="checkbox" ${managed?'checked':''}><span>URBM soll diese Freigabe einbinden</span></label><div class="bu-external-mount" ${managed?'hidden':''}>${repositoryLocationField(repo.id?'Bereits eingebundener Repository-Ordner':'Bereits eingebundener Basisordner',repo.location,'/mnt/remotes/backup-nas')}</div><div class="bu-managed-mount" ${managed?'':'hidden'}><div class="bu-form-grid"><label>Netzwerkfreigabe<input name="remote" placeholder="${type==='smb'?'//192.168.1.20/backup':'192.168.1.20:/volume1/backup'}" value="${esc(repo.mount?.remote||'')}"></label><label>${repo.id?'Einhängepunkt':'Basis-Einhängepunkt'}<input name="mountPoint" placeholder="/mnt/remotes/urbm-backup" value="${esc(repo.mount?.mountPoint||repo.location||'')}"></label>${type==='smb'?`<label class="wide">SMB-Zugangsdaten<textarea name="credential" placeholder="${repo.credentialRef?'Leer lassen, um vorhandene Zugangsdaten zu behalten':'username=backup&#10;password=geheim'}"></textarea></label>`:''}</div></div></fieldset>`;
} else {
host.innerHTML = `<fieldset class="bu-fieldset"><legend>SFTP-Ziel</legend><div class="bu-form-grid">${repositoryLocationField('SFTP-Repository-Adresse',repo.location,'backup@192.168.1.20:/srv/backups/urbm')}<label>Pfad zu SFTP known_hosts<input name="knownHostsPath" value="${esc(repo.options?.knownHostsPath||'/root/.ssh/known_hosts')}"></label><label class="wide">Privater SSH-Schlüssel (optional)<textarea name="credential" placeholder="${repo.credentialRef?'Leer lassen, um den vorhandenen Schlüssel zu behalten':'-----BEGIN OPENSSH PRIVATE KEY-----'}"></textarea></label></div></fieldset>`;
host.innerHTML = `<fieldset class="bu-fieldset"><legend>SFTP-Ziel</legend><div class="bu-form-grid">${repositoryLocationField(repo.id?'SFTP-Repository-Adresse':'SFTP-Basisadresse',repo.location,'backup@192.168.1.20:/srv/backups')}<label>Pfad zu SFTP known_hosts<input name="knownHostsPath" value="${esc(repo.options?.knownHostsPath||'/root/.ssh/known_hosts')}"></label><label class="wide">Privater SSH-Schlüssel (optional)<textarea name="credential" placeholder="${repo.credentialRef?'Leer lassen, um den vorhandenen Schlüssel zu behalten':'-----BEGIN OPENSSH PRIVATE KEY-----'}"></textarea></label></div></fieldset>`;
}
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;