diff --git a/dist/urbm-2026.06.15.r001-x86_64-1.txz.sha256 b/dist/urbm-2026.06.15.r001-x86_64-1.txz.sha256 deleted file mode 100644 index f255b1e..0000000 --- a/dist/urbm-2026.06.15.r001-x86_64-1.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -ac221002f02d50e7220ce4a0ef84acb02a0f7d5d81f9992c82cd4a3484dbe720 urbm-2026.06.15.r001-x86_64-1.txz diff --git a/dist/urbm-2026.06.15.r001-x86_64-1.txz b/dist/urbm-2026.06.15.r002-x86_64-1.txz similarity index 56% rename from dist/urbm-2026.06.15.r001-x86_64-1.txz rename to dist/urbm-2026.06.15.r002-x86_64-1.txz index b85a800..087c0d5 100644 Binary files a/dist/urbm-2026.06.15.r001-x86_64-1.txz and b/dist/urbm-2026.06.15.r002-x86_64-1.txz differ diff --git a/dist/urbm-2026.06.15.r002-x86_64-1.txz.sha256 b/dist/urbm-2026.06.15.r002-x86_64-1.txz.sha256 new file mode 100644 index 0000000..d371553 --- /dev/null +++ b/dist/urbm-2026.06.15.r002-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +6dad60f99de38355bfe32185dc0437f208ac69c6f09f02c2965390a873347d82 urbm-2026.06.15.r002-x86_64-1.txz diff --git a/dist/urbm.plg b/dist/urbm.plg index 475fa67..aed3ecb 100644 --- a/dist/urbm.plg +++ b/dist/urbm.plg @@ -2,13 +2,16 @@ - + - + ]> +### 2026.06.15.r002 +- Add a confirmed repository unlock action that invokes Restic's safe stale-lock cleanup without `--remove-all`. + ### 2026.06.15.r001 - Require repository passwords to be entered twice and reject mismatched values before saving secrets or configuration. diff --git a/internal/api/server.go b/internal/api/server.go index 454f5db..afae989 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -45,6 +45,7 @@ func New(socket string, svc *service.Service, log *slog.Logger) *Server { mux.HandleFunc("POST /v1/runs/{id}/resume", s.resumeRun) mux.HandleFunc("POST /v1/repositories/{id}/test", s.testRepository) mux.HandleFunc("POST /v1/repositories/{id}/init", s.initRepository) + mux.HandleFunc("POST /v1/repositories/{id}/unlock", s.unlockRepository) mux.HandleFunc("POST /v1/repositories/{id}/{action}", s.maintenance) mux.HandleFunc("GET /v1/repositories/{id}/snapshots", s.snapshots) mux.HandleFunc("GET /v1/repositories/{id}/snapshots/{snapshot}/files", s.snapshotFiles) @@ -181,6 +182,15 @@ func (s *Server) testRepository(w http.ResponseWriter, r *http.Request) { func (s *Server) initRepository(w http.ResponseWriter, r *http.Request) { s.repositoryAction(w, r, true) } +func (s *Server) unlockRepository(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) + defer cancel() + if err := s.service.UnlockRepository(ctx, r.PathValue("id")); err != nil { + writeError(w, err) + return + } + writeJSON(w, 200, map[string]bool{"ok": true}) +} func (s *Server) repositoryAction(w http.ResponseWriter, r *http.Request, initialize bool) { ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) defer cancel() diff --git a/internal/restic/restic.go b/internal/restic/restic.go index 4667d28..14ee956 100644 --- a/internal/restic/restic.go +++ b/internal/restic/restic.go @@ -95,6 +95,10 @@ func (r *Runner) Init(ctx context.Context, repo model.Repository) error { return r.run(ctx, repo, []string{"init"}, nil, nil) } +func (r *Runner) Unlock(ctx context.Context, repo model.Repository) error { + return r.run(ctx, repo, []string{"unlock"}, nil, nil) +} + func (r *Runner) Backup(ctx context.Context, repo model.Repository, job model.Job, sources []string, progress ProgressCallback) (Summary, error) { args := []string{"backup", "--json", "--compression", job.Compression} for _, tag := range append([]string{"urbm", "job:" + job.ID}, job.Tags...) { diff --git a/internal/restic/restic_test.go b/internal/restic/restic_test.go index 94b8148..a439039 100644 --- a/internal/restic/restic_test.go +++ b/internal/restic/restic_test.go @@ -76,6 +76,28 @@ func TestForgetUsesAgeAndCalendarRetention(t *testing.T) { } } +func TestUnlockUsesSafeResticCommand(t *testing.T) { + dir := t.TempDir() + argsPath := filepath.Join(dir, "args") + script := filepath.Join(dir, "restic") + body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\n", argsPath) + if err := os.WriteFile(script, []byte(body), 0700); err != nil { + t.Fatal(err) + } + runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "secret"}} + repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"} + if err := runner.Unlock(context.Background(), repo); err != nil { + t.Fatal(err) + } + args, _ := os.ReadFile(argsPath) + if !strings.Contains(string(args), "unlock") { + t.Fatalf("unlock command missing: %s", args) + } + if strings.Contains(string(args), "--remove-all") { + t.Fatalf("unsafe remove-all option used: %s", args) + } +} + func TestResticErrorExplainsExistingRepository(t *testing.T) { err := resticError("Fatal: create repository failed: config file already exists", fmt.Errorf("exit status 1")) if !strings.Contains(err.Error(), "already initialized") || !strings.Contains(err.Error(), "select Test") { diff --git a/internal/service/service.go b/internal/service/service.go index dc773f7..0b9dff5 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -232,6 +232,19 @@ func (s *Service) TestRepository(ctx context.Context, repoID string, initialize return err } +func (s *Service) UnlockRepository(ctx context.Context, repoID string) error { + repo, ok := s.repository(repoID) + if !ok { + return errors.New("validation: unknown repository") + } + mounted, err := s.mounts.Prepare(ctx, repo) + if err != nil { + return err + } + defer s.mounts.Cleanup(context.Background(), mounted) + return s.restic.Unlock(ctx, mounted.Repository) +} + func (s *Service) execute(ctx context.Context, run model.Run) model.Run { ctx = restic.WithRunID(ctx, run.ID) if run.TaskType == "backup" { diff --git a/plugin/urbm.plg b/plugin/urbm.plg index 737b28b..578a0f5 100644 --- a/plugin/urbm.plg +++ b/plugin/urbm.plg @@ -2,13 +2,16 @@ - + ]> +### 2026.06.15.r002 +- Add a confirmed repository unlock action that invokes Restic's safe stale-lock cleanup without `--remove-all`. + ### 2026.06.15.r001 - Require repository passwords to be entered twice and reject mismatched values before saving secrets or configuration. diff --git a/webgui/URBM.page b/webgui/URBM.page index accba9d..4562485 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.r001

Restic Backup Manager für Unraid

+ +

URBM 2026.06.15.r002

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 bc01285..cc46671 100644 --- a/webgui/assets/urbm.js +++ b/webgui/assets/urbm.js @@ -58,6 +58,7 @@ 'new-repo': 'Legt ein neues, isoliertes Restic-Speicherziel an.', '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.', 'load-snapshots': 'Lädt alle Sicherungsstände des ausgewählten Repositorys.', 'restore-selected': 'Übernimmt die markierten Dateien oder Ordner in den Wiederherstellungsassistenten.', 'submit-restore': 'Stellt die Wiederherstellung in die globale Warteschlange; Backups und Wiederherstellungen laufen nacheinander.', @@ -129,6 +130,7 @@ 'delete-job': 'Löscht die Job-Konfiguration. Vorhandene Restic-Snapshots im Repository bleiben erhalten.', 'test-repo': 'Prüft Erreichbarkeit, Passwort und Restic-Zugriff, ohne ein Backup zu starten.', 'init-repo': 'Erstellt einmalig ein neues Restic-Repository. Nicht erneut ausführen und nicht für vorhandene Restic-Repositories verwenden.', + 'unlock-repo': 'Entfernt verwaiste Restic-Locks, etwa nach einem Neustart oder abgebrochenen Prozess. Aktive Locks bleiben erhalten.', 'repo-snapshots': 'Wechselt zu den Sicherungsständen dieses Repositorys.', 'maint': action.includes(':check:') ? 'Prüft Struktur und Integrität des Restic-Repositorys.' : 'Entfernt nach Retention nicht mehr benötigte Datenblöcke und gibt Speicher frei.', 'edit-repo': 'Öffnet Ziel-, Mount- und Zugangseinstellungen dieses Repositorys.', @@ -408,7 +410,7 @@ function renderRepositories() { const rows = state.config.repositories.map(r => { const initialize = volatileLocation(r) ? disabledButton('Initialisieren','Dieses Ziel liegt im flüchtigen RAM. Zuerst über Bearbeiten einen dauerhaften Pfad wie /mnt/user/Backups/HomeServer eintragen.') : button('Initialisieren',`init-repo:${r.id}`,'primary'); - const actions = initialize+button('Testen',`test-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'); + 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.
'}
`; @@ -547,6 +549,7 @@ if (name === 'clear-activity') { if(confirm('Alle abgeschlossenen Aktivitäten und deren gespeicherte Laufprotokolle löschen?\n\nLaufende und wartende Aufgaben bleiben erhalten.')) { const result=await api('/v1/runs',{method:'DELETE'}); notify(result.cleared?`${result.cleared} abgeschlossene Aktivitäten gelöscht`:'Keine abgeschlossenen Aktivitäten vorhanden'); return load(); } return; } if (name === 'test-repo') { await api(`/v1/repositories/${a}/test`,{method:'POST'}); return notify('Repository-Verbindung erfolgreich getestet'); } if (name === 'init-repo') { if(confirm('Hier ein NEUES Restic-Repository erstellen?\n\nNur fortfahren, wenn dieses Ziel noch kein Restic-Repository enthält. Bei einem vorhandenen Repository das ursprüngliche Passwort unter Bearbeiten eintragen und Testen verwenden.')) { await api(`/v1/repositories/${a}/init`,{method:'POST'}); notify('Repository erfolgreich initialisiert'); } return; } + if (name === 'unlock-repo') { if(confirm('Verwaiste Restic-Sperren dieses Repositorys entfernen?\n\nRestic entfernt dabei nur als veraltet erkannte Locks. Verwende diese Aktion nach einem Neustart oder abgebrochenen Restic-Prozess.')) { await api(`/v1/repositories/${a}/unlock`,{method:'POST'}); notify('Verwaiste Repository-Sperren wurden entfernt'); } return; } if (name === 'maint') { await api(`/v1/repositories/${b}/${a}`,{method:'POST'}); notify(`${a==='check'?'Prüfung':'Bereinigung'} wurde eingereiht`); return load(); } if (name === 'repo-snapshots') { state.view='snapshots'; document.querySelector('[data-view="snapshots"]').click(); render(); setTimeout(()=>{document.getElementById('bu-snapshot-repo').value=a; handle('load-snapshots');},0); return; } if (name === 'load-snapshots') { const repo=document.getElementById('bu-snapshot-repo').value; state.snapshots=await api(`/v1/repositories/${repo}/snapshots`); state.snapshotRepo=repo; return render(); }