diff --git a/dist/urbm-2026.06.15.r013-x86_64-1.txz.sha256 b/dist/urbm-2026.06.15.r013-x86_64-1.txz.sha256 deleted file mode 100644 index fea4adb..0000000 --- a/dist/urbm-2026.06.15.r013-x86_64-1.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -250d5afee9c2939738b74f0f6f719db12d9399068bf9452e08e2b03a67d381ad urbm-2026.06.15.r013-x86_64-1.txz diff --git a/dist/urbm-2026.06.15.r013-x86_64-1.txz b/dist/urbm-2026.06.15.r014-x86_64-1.txz similarity index 55% rename from dist/urbm-2026.06.15.r013-x86_64-1.txz rename to dist/urbm-2026.06.15.r014-x86_64-1.txz index 197a55d..9f8c8f5 100644 Binary files a/dist/urbm-2026.06.15.r013-x86_64-1.txz and b/dist/urbm-2026.06.15.r014-x86_64-1.txz differ diff --git a/dist/urbm-2026.06.15.r014-x86_64-1.txz.sha256 b/dist/urbm-2026.06.15.r014-x86_64-1.txz.sha256 new file mode 100644 index 0000000..a421a79 --- /dev/null +++ b/dist/urbm-2026.06.15.r014-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +2cb10896997b03d476b52275aebe077114c77b14ac796c7ba6e608f85b324536 urbm-2026.06.15.r014-x86_64-1.txz diff --git a/dist/urbm.plg b/dist/urbm.plg index 4004918..b584193 100644 --- a/dist/urbm.plg +++ b/dist/urbm.plg @@ -2,13 +2,18 @@ - + - + ]> +### 2026.06.15.r014 +- Add a dedicated workflow for attaching and immediately testing an existing Restic repository without initialization. +- Detect a missing Restic config at local and mounted paths and suggest direct child directories that contain a repository. +- Explain that an existing repository path must directly contain config, data, index, keys, and snapshots. + ### 2026.06.15.r013 - Replace invalid USB raw-image percentages, file counts, and multi-million-hour ETAs with streaming progress. - Show bytes read and transfer rate while the final image size remains unknown until EOF. diff --git a/internal/restic/restic.go b/internal/restic/restic.go index ecd9b0d..ef14639 100644 --- a/internal/restic/restic.go +++ b/internal/restic/restic.go @@ -414,7 +414,7 @@ func resticError(message string, commandErr error) error { case strings.Contains(lower, "wrong password") || strings.Contains(lower, "no key found"): return errors.New("authentication: wrong repository password. Enter the original Restic password under Edit and select Test") case strings.Contains(lower, "repository does not exist") || strings.Contains(lower, "is there a repository at the following location?"): - return errors.New("repository: destination is not initialized; open Repositories and select Initialize once") + return errors.New("repository: Am konfigurierten Ziel wurde keine Restic-config gefunden. Für ein vorhandenes Repository den exakten Repository-Ordner eintragen; nur ein wirklich neues, leeres Ziel initialisieren") default: message = strings.TrimSpace(strings.TrimPrefix(message, "Fatal:")) return fmt.Errorf("restic: %s: %w", message, commandErr) diff --git a/internal/restic/restic_test.go b/internal/restic/restic_test.go index a2fa6b0..2ef0edc 100644 --- a/internal/restic/restic_test.go +++ b/internal/restic/restic_test.go @@ -298,6 +298,13 @@ func TestResticErrorExplainsCiphertextVerification(t *testing.T) { } } +func TestResticErrorExplainsMissingExistingRepositoryPath(t *testing.T) { + err := resticError("Fatal: repository does not exist", fmt.Errorf("exit status 10")) + if !strings.Contains(err.Error(), "exakten Repository-Ordner") || !strings.Contains(err.Error(), "wirklich neues") { + t.Fatalf("unexpected error: %v", err) + } +} + func TestRunnerPausesResumesAndCancelsProcessGroup(t *testing.T) { dir := t.TempDir() ticksPath := filepath.Join(dir, "ticks") diff --git a/internal/service/repository_test.go b/internal/service/repository_test.go new file mode 100644 index 0000000..52d5c5c --- /dev/null +++ b/internal/service/repository_test.go @@ -0,0 +1,41 @@ +package service + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestValidateExistingRepositoryPathAcceptsRepositoryRoot(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "config"), []byte("restic"), 0600); err != nil { + t.Fatal(err) + } + if err := validateExistingRepositoryPath(root); err != nil { + t.Fatalf("repository root rejected: %v", err) + } +} + +func TestValidateExistingRepositoryPathSuggestsDirectChild(t *testing.T) { + root := t.TempDir() + repository := filepath.Join(root, "server-backup") + if err := os.Mkdir(repository, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repository, "config"), []byte("restic"), 0600); err != nil { + t.Fatal(err) + } + err := validateExistingRepositoryPath(root) + if err == nil || !strings.Contains(err.Error(), repository) { + t.Fatalf("missing repository suggestion: %v", err) + } +} + +func TestValidateExistingRepositoryPathExplainsMissingConfig(t *testing.T) { + root := t.TempDir() + err := validateExistingRepositoryPath(root) + if err == nil || !strings.Contains(err.Error(), "config, data, index, keys und snapshots") { + t.Fatalf("missing layout guidance: %v", err) + } +} diff --git a/internal/service/service.go b/internal/service/service.go index 1d6ea56..fa97d19 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -262,10 +262,43 @@ func (s *Service) TestRepository(ctx context.Context, repoID string, initialize if initialize { return s.restic.Init(ctx, mounted.Repository) } + if mounted.Repository.Type != model.RepositorySFTP { + if err := validateExistingRepositoryPath(mounted.Repository.Location); err != nil { + return err + } + } _, err = s.restic.Snapshots(ctx, mounted.Repository) return err } +func validateExistingRepositoryPath(location string) error { + configPath := filepath.Join(location, "config") + if info, err := os.Stat(configPath); err == nil && !info.IsDir() { + return nil + } + entries, readErr := os.ReadDir(location) + if readErr != nil { + return fmt.Errorf("repository: Repository-Ordner %q kann nicht gelesen werden: %w", location, readErr) + } + candidates := make([]string, 0, 3) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + candidate := filepath.Join(location, entry.Name()) + if info, err := os.Stat(filepath.Join(candidate, "config")); err == nil && !info.IsDir() { + candidates = append(candidates, candidate) + if len(candidates) == 3 { + break + } + } + } + if len(candidates) > 0 { + return fmt.Errorf("repository: Im gewählten Ordner liegt keine Restic-config. Verwende direkt den Repository-Ordner: %s", strings.Join(candidates, ", ")) + } + return fmt.Errorf("repository: Im gewählten Ordner %q wurde keine Restic-config gefunden. Ein vorhandenes Repository muss direkt auf den Ordner zeigen, der config, data, index, keys und snapshots enthält", location) +} + func (s *Service) UnlockRepository(ctx context.Context, repoID string) error { repo, ok := s.repository(repoID) if !ok { diff --git a/plugin/urbm.plg b/plugin/urbm.plg index 2051d4d..db98b5c 100644 --- a/plugin/urbm.plg +++ b/plugin/urbm.plg @@ -2,13 +2,18 @@ - + ]> +### 2026.06.15.r014 +- Add a dedicated workflow for attaching and immediately testing an existing Restic repository without initialization. +- Detect a missing Restic config at local and mounted paths and suggest direct child directories that contain a repository. +- Explain that an existing repository path must directly contain config, data, index, keys, and snapshots. + ### 2026.06.15.r013 - Replace invalid USB raw-image percentages, file counts, and multi-million-hour ETAs with streaming progress. - Show bytes read and transfer rate while the final image size remains unknown until EOF. diff --git a/webgui/URBM.page b/webgui/URBM.page index 0950361..7b14923 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.15.r013

Restic Backup Manager für Unraid

+ +

URBM 2026.06.15.r014

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 3930b24..8ddf228 100644 --- a/webgui/assets/urbm.js +++ b/webgui/assets/urbm.js @@ -57,6 +57,7 @@ 'new-job': 'Öffnet den Assistenten für einen neuen Backup-Job. Beispiel: tägliches Appdata-Backup.', 'submit-job': 'Prüft und speichert diesen Backup-Job.', 'new-repo': 'Legt ein neues, isoliertes Restic-Speicherziel an.', + 'existing-repo': 'Bindet ein bereits initialisiertes Restic-Repository mit seinem ursprünglichen Passwort ein. URBM initialisiert oder überschreibt es nicht.', 'submit-repo': 'Speichert Ziel und Zugangsdaten. Das Repository wird dadurch noch nicht automatisch initialisiert.', 'init-repo': 'Initialisiert am Ziel einmalig ein neues, leeres Restic-Repository. Bei einem vorhandenen Repository nur das ursprüngliche Passwort eintragen und Test verwenden.', 'unlock-repo': 'Entfernt ausschließlich von Restic als veraltet erkannte Repository-Sperren. Aktive Sperren werden nicht gelöscht.', @@ -445,18 +446,19 @@ const actions = initialize+button('Testen',`test-repo:${r.id}`)+button('Entsperren',`unlock-repo:${r.id}`)+button('Snapshots',`repo-snapshots:${r.id}`)+button('Prüfen',`maint:check:${r.id}`)+button('Bereinigen',`maint:prune:${r.id}`)+button('Bearbeiten',`edit-repo:${r.id}`)+button('Löschen',`delete-repo:${r.id}`,'danger'); return `${esc(r.name)}
${esc(r.id)}${esc(typeLabels[r.type]||r.type)}${esc(r.location)}${volatileLocation(r)?'Flüchtiger Pfad: Die Daten gehen beim Neustart verloren. Verwende /mnt/user/...':''}${r.mount?.managed?'Durch URBM':'Direkt / extern'}${actionGroup(actions)}`; }).join(''); - return `

Repositorys

Lokale, SMB-, NFS- und SFTP-Ziele.
${button('Neues Repository','new-repo','primary')}
${rows ? `${rows}
NameTypSpeicherortEinbindungAktionen
` : '
Keine Repositorys konfiguriert.
'}
`; + return `

Repositorys

Lokale, SMB-, NFS- und SFTP-Ziele.
${button('Vorhandenes einbinden','existing-repo')}${button('Neues Repository','new-repo','primary')}
${rows ? `${rows}
NameTypSpeicherortEinbindungAktionen
` : '
Keine Repositorys konfiguriert.
'}
`; } - function repositoryForm(repo = {}) { + function repositoryForm(repo = {}, existingRepository = false) { state.repositoryBrowserPath = '/'; state.repositoryDirectories = repositoryRoots; state.repositoryBrowserError = ''; const passwordRef=repo.passwordRef||id('repo-password'); const credentialRef=repo.credentialRef||id('repo-credential'); - content.innerHTML = `

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

+ content.innerHTML = `

${repo.id?'Repository bearbeiten':existingRepository?'Vorhandenes Repository einbinden':'Neues Repository erstellen'}

+ ${existingRepository?'
Wähle den exakten Ordner des vorhandenen Restic-Repositorys. Darin müssen direkt die Einträge config, data, index, keys und snapshots liegen. Verwende das ursprüngliche Repository-Passwort. Es wird nichts initialisiert.
':''}
Restic-Verschlüsselung

Das Passwort wird von URBM geschützt gespeichert. ${repo.id?'Beide Felder 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')}
`; +
${button(existingRepository?'Einbinden und testen':'Speichern','submit-repo','primary')}${button('Abbrechen','cancel-form')}
`; enhanceTooltips(content); renderRepositorySpecific(repo.type || 'local', repo); } @@ -578,6 +580,7 @@ if (name === 'new-job') return jobForm(); if (name === 'edit-job') return jobForm(state.config.jobs.find(j=>j.id===a)); if (name === 'new-repo') return repositoryForm(); + if (name === 'existing-repo') return repositoryForm({}, true); if (name === 'edit-repo') return repositoryForm(state.config.repositories.find(r=>r.id===a)); if (name === 'cancel-form') return render(); if (name === 'refresh') return load(); @@ -624,13 +627,14 @@ } if (kind === 'submit-repo') { const f=new FormData(document.getElementById('bu-repo-form')); const value=name=>String(f.get(name)||'').trim(); const type=value('type'); const managed=(type==='smb'||type==='nfs')&&f.get('managed')==='on'; + const existingRepository=value('existingRepository')==='true'; 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 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 password=String(f.get('password')||''); const passwordConfirm=String(f.get('passwordConfirm')||''); - if(!value('id')&&!password) throw new Error('Für ein neues Repository ist ein Passwort erforderlich.'); + if(!value('id')&&!password) throw new Error(existingRepository?'Zum Einbinden ist das ursprüngliche Repository-Passwort erforderlich.':'Für ein neues Repository ist ein Passwort erforderlich.'); if(password!==passwordConfirm) throw new Error('Die beiden Repository-Passwörter stimmen nicht überein.'); if (volatileLocation(repo) && !confirm('WARNUNG: Dieses Ziel liegt unter /tmp oder /run und geht nach einem Unraid-Neustart verloren. Trotzdem speichern?')) return; if(password) await api(`/v1/secrets/${encodeURIComponent(repo.passwordRef)}`,{method:'PUT',body:JSON.stringify({type:'restic-password',value:password})}); @@ -640,7 +644,12 @@ state.config = await api('/v1/config'); const saved = state.config.repositories.find(r=>r.id===repo.id); if (!saved || saved.location !== repo.location) throw new Error(`Repository-Speicherort wurde nicht gespeichert. Erwartet: ${repo.location}`); - render(); notify(`Repository gespeichert: ${saved.location}`); + if(existingRepository) { + await api(`/v1/repositories/${repo.id}/test`,{method:'POST'}); + render(); notify(`Vorhandenes Repository erfolgreich eingebunden: ${saved.location}`); + } else { + render(); notify(`Repository gespeichert: ${saved.location}`); + } } if (kind === 'submit-restore') { const f=new FormData(document.getElementById('bu-restore-form')); await api('/v1/restores',{method:'POST',body:JSON.stringify({schemaVersion:1,id:'',repositoryId:f.get('repositoryId'),snapshotId:f.get('snapshotId'),includes:lines(f.get('includes')),target:f.get('target'),inPlace:f.get('inPlace')==='on',confirmed:f.get('confirmed')==='on'})}); notify('Wiederherstellung wurde eingereiht'); state.view='activity'; render(); } if (kind === 'submit-settings') { const f=new FormData(document.getElementById('bu-settings-form')); state.config.settings={resticPath:f.get('resticPath'),restoreRoot:f.get('restoreRoot'),persistentLogDir:f.get('persistentLogDir'),showLiveLog:f.get('showLiveLog')==='on',catchUpWindowHours:Number(f.get('catchUp')),checkCron:f.get('checkCron'),pruneCron:f.get('pruneCron')}; await saveConfig(); render(); }