diff --git a/dist/urbm-2026.06.15.r019-x86_64-1.txz.sha256 b/dist/urbm-2026.06.15.r019-x86_64-1.txz.sha256 deleted file mode 100644 index 0a6d7da..0000000 --- a/dist/urbm-2026.06.15.r019-x86_64-1.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -89f13497e0503b91602af00343fb15cd897e775189e63fbd6af4e0c302ff06d4 urbm-2026.06.15.r019-x86_64-1.txz diff --git a/dist/urbm-2026.06.15.r019-x86_64-1.txz b/dist/urbm-2026.06.15.r020-x86_64-1.txz similarity index 56% rename from dist/urbm-2026.06.15.r019-x86_64-1.txz rename to dist/urbm-2026.06.15.r020-x86_64-1.txz index 5603746..df95c44 100644 Binary files a/dist/urbm-2026.06.15.r019-x86_64-1.txz and b/dist/urbm-2026.06.15.r020-x86_64-1.txz differ diff --git a/dist/urbm-2026.06.15.r020-x86_64-1.txz.sha256 b/dist/urbm-2026.06.15.r020-x86_64-1.txz.sha256 new file mode 100644 index 0000000..8554c6e --- /dev/null +++ b/dist/urbm-2026.06.15.r020-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +a9b5d02c0b92ea7f0a72d0d30a62870a88165065aaf97637c57aec2e7df37139 urbm-2026.06.15.r020-x86_64-1.txz diff --git a/dist/urbm.plg b/dist/urbm.plg index d5a3893..64ec485 100644 --- a/dist/urbm.plg +++ b/dist/urbm.plg @@ -2,13 +2,20 @@ - + - + ]> +### 2026.06.15.r020 +- Add a guided Restic repository password change that creates and verifies a new key before removing the old key. +- Update the encrypted URBM secret only after Restic accepts the new password, with automatic repository rollback on secret-storage failure. +- Add a verified local-password adoption action for additional URBM servers that share a repository whose password was changed elsewhere. +- Block password changes while tasks are queued, running, or paused and replace misleading password fields in normal repository editing. +- Write encrypted secret records atomically to protect the previous password during filesystem failures. + ### 2026.06.15.r019 - Add a clearly labeled manual-only schedule mode for every backup and Rsync job. - Keep manual-only jobs enabled for one-click starts while excluding them from scheduled and catch-up runs. diff --git a/internal/api/server.go b/internal/api/server.go index fc411c6..2d2553c 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -46,6 +46,8 @@ func New(socket string, svc *service.Service, log *slog.Logger) *Server { 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}/password", s.changeRepositoryPassword) + mux.HandleFunc("POST /v1/repositories/{id}/password/adopt", s.adoptRepositoryPassword) mux.HandleFunc("POST /v1/repositories/{id}/{action}", s.maintenance) mux.HandleFunc("GET /v1/repositories/stats", s.repositoryStats) mux.HandleFunc("GET /v1/repositories/{id}/snapshots", s.snapshots) @@ -192,6 +194,34 @@ func (s *Server) unlockRepository(w http.ResponseWriter, r *http.Request) { } writeJSON(w, 200, map[string]bool{"ok": true}) } +func (s *Server) changeRepositoryPassword(w http.ResponseWriter, r *http.Request) { + s.repositoryPasswordAction(w, r, false) +} +func (s *Server) adoptRepositoryPassword(w http.ResponseWriter, r *http.Request) { + s.repositoryPasswordAction(w, r, true) +} +func (s *Server) repositoryPasswordAction(w http.ResponseWriter, r *http.Request, adopt bool) { + var body struct { + Password string `json:"password"` + } + if err := decode(r, &body); err != nil { + writeError(w, err) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) + defer cancel() + var err error + if adopt { + err = s.service.AdoptRepositoryPassword(ctx, r.PathValue("id"), body.Password) + } else { + err = s.service.ChangeRepositoryPassword(ctx, r.PathValue("id"), body.Password) + } + if 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 ef14639..13cde7e 100644 --- a/internal/restic/restic.go +++ b/internal/restic/restic.go @@ -106,6 +106,19 @@ func (r *Runner) Unlock(ctx context.Context, repo model.Repository) error { return r.run(ctx, repo, []string{"unlock"}, nil, nil) } +func (r *Runner) ChangePassword(ctx context.Context, repo model.Repository, currentPassword, newPassword string) error { + newPasswordPath, cleanup, err := r.writePasswordFile("restic-new-password-*", newPassword) + if err != nil { + return err + } + defer cleanup() + return r.runWithInputEnvPassword(ctx, repo, []string{"key", "passwd", "--new-password-file", newPasswordPath}, nil, nil, nil, nil, currentPassword) +} + +func (r *Runner) TestPassword(ctx context.Context, repo model.Repository, password string) error { + return r.runWithInputEnvPassword(ctx, repo, []string{"snapshots", "--json"}, nil, nil, nil, nil, password) +} + 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...) { @@ -283,28 +296,17 @@ func (r *Runner) runWithInputAndEnv(ctx context.Context, repo model.Repository, if err != nil { return fmt.Errorf("authentication: load repository password: %w", err) } - secretDir := filepath.Join(r.RuntimeDir, "secrets") - if err := os.MkdirAll(secretDir, 0700); err != nil { - return err - } - f, err := os.CreateTemp(secretDir, "restic-password-*") + return r.runWithInputEnvPassword(ctx, repo, args, onLine, capture, input, environment, password) +} + +func (r *Runner) runWithInputEnvPassword(ctx context.Context, repo model.Repository, args []string, onLine func([]byte), capture *[]byte, input io.Reader, environment []string, password string) error { + passwordPath, cleanup, err := r.writePasswordFile("restic-password-*", password) if err != nil { return err } - passwordPath := f.Name() - defer os.Remove(passwordPath) - if err := f.Chmod(0600); err != nil { - f.Close() - return err - } - if _, err := io.WriteString(f, password); err != nil { - f.Close() - return err - } - if err := f.Close(); err != nil { - return err - } + defer cleanup() + secretDir := filepath.Join(r.RuntimeDir, "secrets") globalArgs := []string{"-r", repositoryLocation(repo)} credentialPath := "" if repo.Type == model.RepositorySFTP && repo.CredentialRef != "" { @@ -397,6 +399,34 @@ func (r *Runner) runWithInputAndEnv(ctx context.Context, repo model.Repository, return nil } +func (r *Runner) writePasswordFile(pattern, password string) (string, func(), error) { + secretDir := filepath.Join(r.RuntimeDir, "secrets") + if err := os.MkdirAll(secretDir, 0700); err != nil { + return "", nil, err + } + f, err := os.CreateTemp(secretDir, pattern) + if err != nil { + return "", nil, err + } + passwordPath := f.Name() + cleanup := func() { _ = os.Remove(passwordPath) } + if err := f.Chmod(0600); err != nil { + f.Close() + cleanup() + return "", nil, err + } + if _, err := io.WriteString(f, password); err != nil { + f.Close() + cleanup() + return "", nil, err + } + if err := f.Close(); err != nil { + cleanup() + return "", nil, err + } + return passwordPath, cleanup, nil +} + func backupEnvironment(job model.Job) []string { if job.CPUCores <= 0 { return nil diff --git a/internal/restic/restic_test.go b/internal/restic/restic_test.go index 2ef0edc..e159392 100644 --- a/internal/restic/restic_test.go +++ b/internal/restic/restic_test.go @@ -51,6 +51,42 @@ func TestBackupUsesPasswordFileAndStructuredArguments(t *testing.T) { } } +func TestChangePasswordUsesCurrentAndNewPasswordFiles(t *testing.T) { + dir := t.TempDir() + argsPath := filepath.Join(dir, "args") + currentPath := filepath.Join(dir, "current") + newPath := filepath.Join(dir, "new") + script := filepath.Join(dir, "restic") + body := fmt.Sprintf(`#!/bin/sh +printf '%%s\n' "$@" > '%s' +cat "$RESTIC_PASSWORD_FILE" > '%s' +previous='' +for value in "$@"; do + if [ "$previous" = "--new-password-file" ]; then + cat "$value" > '%s' + fi + previous="$value" +done +`, argsPath, currentPath, newPath) + if err := os.WriteFile(script, []byte(body), 0700); err != nil { + t.Fatal(err) + } + runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "stored-old"}} + repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"} + if err := runner.ChangePassword(context.Background(), repo, "current-secret", "new-secret"); err != nil { + t.Fatal(err) + } + args, _ := os.ReadFile(argsPath) + if !strings.Contains(string(args), "key\npasswd\n--new-password-file") { + t.Fatalf("unexpected arguments: %s", args) + } + current, _ := os.ReadFile(currentPath) + newPassword, _ := os.ReadFile(newPath) + if string(current) != "current-secret" || string(newPassword) != "new-secret" { + t.Fatalf("password files current=%q new=%q", current, newPassword) + } +} + func TestBackupLimitsCPUForThisJob(t *testing.T) { dir := t.TempDir() envPath := filepath.Join(dir, "gomaxprocs") diff --git a/internal/secrets/store.go b/internal/secrets/store.go index f3c44a1..5f5c0fc 100644 --- a/internal/secrets/store.go +++ b/internal/secrets/store.go @@ -75,7 +75,28 @@ func (s *Store) Put(id, kind, plaintext string) error { if err != nil { return err } - return os.WriteFile(filepath.Join(s.dir, id+".json"), append(b, '\n'), 0600) + tmp, err := os.CreateTemp(s.dir, ".secret-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if err := tmp.Chmod(0600); err != nil { + tmp.Close() + return err + } + if _, err := tmp.Write(append(b, '\n')); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpPath, filepath.Join(s.dir, id+".json")) } func (s *Store) Get(id string) (string, error) { diff --git a/internal/service/service.go b/internal/service/service.go index c4aaf1a..c5b0719 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -128,6 +128,82 @@ func (s *Service) PutSecret(id, kind, value string) error { func (s *Service) DeleteSecret(id string) error { return s.secrets.Delete(id) } +func (s *Service) ChangeRepositoryPassword(ctx context.Context, repoID, newPassword string) error { + if err := s.validatePasswordChange(newPassword); err != nil { + return err + } + repo, ok := s.repository(repoID) + if !ok { + return errors.New("validation: unknown repository") + } + oldPassword, err := s.secrets.Get(repo.PasswordRef) + if err != nil { + return fmt.Errorf("authentication: load current repository password: %w", err) + } + if oldPassword == newPassword { + return errors.New("validation: Das neue Repository-Passwort muss sich vom bisherigen Passwort unterscheiden") + } + mounted, err := s.mounts.Prepare(ctx, repo) + if err != nil { + return err + } + defer s.mounts.Cleanup(context.Background(), mounted) + if err := s.restic.ChangePassword(ctx, mounted.Repository, oldPassword, newPassword); err != nil { + return err + } + if err := s.secrets.Put(repo.PasswordRef, "restic-password", newPassword); err != nil { + rollbackCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + rollbackErr := s.restic.ChangePassword(rollbackCtx, mounted.Repository, newPassword, oldPassword) + if rollbackErr != nil { + return fmt.Errorf("internal: Neues Repository-Passwort wurde aktiviert, konnte aber nicht in URBM gespeichert werden; automatischer Rollback schlug ebenfalls fehl: %v; ursprünglicher Speicherfehler: %w", rollbackErr, err) + } + return fmt.Errorf("internal: Neues Passwort konnte nicht in URBM gespeichert werden; Repository wurde erfolgreich auf das bisherige Passwort zurückgesetzt: %w", err) + } + if _, err := s.restic.Snapshots(ctx, mounted.Repository); err != nil { + rollbackCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + rollbackErr := s.restic.ChangePassword(rollbackCtx, mounted.Repository, newPassword, oldPassword) + secretErr := s.secrets.Put(repo.PasswordRef, "restic-password", oldPassword) + if rollbackErr != nil || secretErr != nil { + return fmt.Errorf("internal: Prüfung des neuen Passworts fehlgeschlagen und Rollback war unvollständig (repository: %v, secret: %v): %w", rollbackErr, secretErr, err) + } + return fmt.Errorf("authentication: Prüfung des neuen Passworts fehlgeschlagen; Änderung wurde zurückgesetzt: %w", err) + } + return nil +} + +func (s *Service) AdoptRepositoryPassword(ctx context.Context, repoID, password string) error { + if err := s.validatePasswordChange(password); err != nil { + return err + } + 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) + if err := s.restic.TestPassword(ctx, mounted.Repository, password); err != nil { + return fmt.Errorf("authentication: Das angegebene Passwort wurde vom Repository nicht akzeptiert: %w", err) + } + return s.secrets.Put(repo.PasswordRef, "restic-password", password) +} + +func (s *Service) validatePasswordChange(password string) error { + if len(password) < 8 { + return errors.New("validation: Das Repository-Passwort muss mindestens 8 Zeichen lang sein") + } + for _, run := range s.queue.Snapshot() { + if run.Status == "queued" || run.Status == "running" || run.Status == "paused" { + return errors.New("validation: Repository-Passwörter können nur geändert werden, wenn keine Aufgabe wartet, läuft oder pausiert ist") + } + } + return nil +} + func (s *Service) EnqueueJob(jobID string, priority int) (model.Run, error) { job, ok := s.job(jobID) if !ok { diff --git a/plugin/urbm.plg b/plugin/urbm.plg index 5aa8e73..b0de9a2 100644 --- a/plugin/urbm.plg +++ b/plugin/urbm.plg @@ -2,13 +2,20 @@ - + ]> +### 2026.06.15.r020 +- Add a guided Restic repository password change that creates and verifies a new key before removing the old key. +- Update the encrypted URBM secret only after Restic accepts the new password, with automatic repository rollback on secret-storage failure. +- Add a verified local-password adoption action for additional URBM servers that share a repository whose password was changed elsewhere. +- Block password changes while tasks are queued, running, or paused and replace misleading password fields in normal repository editing. +- Write encrypted secret records atomically to protect the previous password during filesystem failures. + ### 2026.06.15.r019 - Add a clearly labeled manual-only schedule mode for every backup and Rsync job. - Keep manual-only jobs enabled for one-click starts while excluding them from scheduled and catch-up runs. diff --git a/webgui/URBM.page b/webgui/URBM.page index ac6e754..3a07f45 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.r019

Restic Backup Manager für Unraid

+ +

URBM 2026.06.15.r020

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 d1d3f31..347ecac 100644 --- a/webgui/assets/urbm.js +++ b/webgui/assets/urbm.js @@ -59,6 +59,8 @@ '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.', + 'submit-repo-password': 'Erstellt und prüft einen neuen Restic-Zugangs-Key, entfernt danach den bisherigen Key und aktualisiert das geschützte URBM-Secret.', + 'adopt-repo-password': 'Prüft ein bereits gültiges Repository-Passwort und speichert es nur auf diesem URBM-Server, ohne Restic-Keys zu verändern.', '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.', @@ -150,6 +152,7 @@ '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.', + 'change-repo-password': 'Ändert den Restic-Key im Repository und aktualisiert danach das geschützte URBM-Secret. Vorhandene Snapshots bleiben unverändert.', 'delete-repo': 'Löscht nur die URBM-Konfiguration. Die Backupdaten am Speicherziel werden nicht gelöscht.', 'browse': 'Öffnet den Dateibaum dieses Snapshots, um einzelne Dateien oder Ordner auszuwählen.', 'open-dir': 'Öffnet dieses Verzeichnis innerhalb des schreibgeschützten Snapshots.', @@ -492,7 +495,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('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'); + const actions = initialize+button('Testen',`test-repo:${r.id}`)+button('Passwort ändern',`change-repo-password:${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('Vorhandenes einbinden','existing-repo')}${button('Neues Repository','new-repo','primary')}
${rows ? `${rows}
NameTypSpeicherortEinbindungAktionen
` : '
Keine Repositorys konfiguriert.
'}
`; @@ -506,12 +509,17 @@ ${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.'}

+ ${repo.id?`
Restic-Verschlüsselung

Das Repository-Passwort wird getrennt von den Ziel- und Mount-Einstellungen geändert.

${button('Repository-Passwort ändern',`change-repo-password:${repo.id}`)}
`:`
Restic-Verschlüsselung

Das Passwort wird von URBM geschützt gespeichert. Bewahre dieses Passwort zusätzlich sicher auf; ohne das Passwort können Backups nicht wiederhergestellt werden.

`}
${button(existingRepository?'Einbinden und testen':'Speichern','submit-repo','primary')}${button('Abbrechen','cancel-form')}
`; enhanceTooltips(content); renderRepositorySpecific(repo.type || 'local', repo); } + function repositoryPasswordForm(repo) { + content.innerHTML=`

Repository-Passwort verwalten

Repository: ${esc(repo.name)}
Passwort sicher ändern erstellt einen neuen Restic-Key und entfernt den bisherigen. Snapshots und Backupdaten werden nicht neu verschlüsselt oder kopiert.
Vorhandenes Passwort übernehmen ist für weitere URBM-Server gedacht, nachdem das Passwort bereits auf einem anderen Server geändert wurde.
Der Vorgang ist nur möglich, wenn keine URBM-Aufgabe wartet, läuft oder pausiert ist. Nach einer echten Passwortänderung müssen weitere Server mit „Vorhandenes Passwort übernehmen“ auf das neue Passwort umgestellt werden.
${button('Passwort sicher ändern','submit-repo-password','primary')}${button('Vorhandenes Passwort übernehmen','adopt-repo-password')}${button('Abbrechen','cancel-form')}
`; + enhanceTooltips(content); + } + function repositoryLocationField(label, value, placeholder) { return ``; } @@ -631,6 +639,7 @@ 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 === 'change-repo-password') return repositoryPasswordForm(state.config.repositories.find(r=>r.id===a)); if (name === 'cancel-form') return render(); if (name === 'refresh') return load(); if (name === 'run-job') { await api(`/v1/jobs/${a}/run`,{method:'POST'}); notify('Backup wurde eingereiht'); return load(); } @@ -661,6 +670,7 @@ if (name === 'delete-job') { if(confirm('Diesen Job löschen?')) { state.config.jobs=state.config.jobs.filter(j=>j.id!==a); await saveConfig(); render(); } return; } if (name === 'duplicate-job') { const source=state.config.jobs.find(j=>j.id===a); const copy=structuredClone(source); copy.id=id('job'); copy.name += ' Kopie'; copy.enabled=false; state.config.jobs.push(copy); await saveConfig(); return render(); } if (name === 'delete-repo') { if(state.config.jobs.some(j=>j.repositoryId===a)) throw new Error('Das Repository wird noch von einem Job verwendet.'); if(confirm('Dieses Repository aus der URBM-Konfiguration löschen? Die Backup-Daten am Ziel bleiben erhalten.')) { state.config.repositories=state.config.repositories.filter(r=>r.id!==a); await saveConfig(); render(); } return; } + if (name === 'adopt-repo-password') { await submitRepositoryPassword(true); return; } if (name.startsWith('submit-')) { await submit(name); return; } } catch (error) { notify(error.message, true); } } @@ -707,12 +717,27 @@ render(); notify(`Repository gespeichert: ${saved.location}`); } } + if (kind === 'submit-repo-password') { + await submitRepositoryPassword(false); + } 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'),rsyncPath:state.config.settings.rsyncPath||'/usr/bin/rsync',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(); } if (kind === 'submit-unraid-notify') { const f=new FormData(document.getElementById('bu-unraid-notify-form')); const target={schemaVersion:1,id:f.get('id')||'unraid',name:'Unraid',type:'unraid',enabled:f.get('enabled')==='on',events:f.getAll('events')}; if(target.enabled&&!target.events.length) throw new Error('Wähle mindestens ein Ereignis für Unraid-Benachrichtigungen aus.'); state.config.notifications=state.config.notifications.filter(n=>n.type!=='unraid'&&n.type!=='ntfy').concat(target); await saveConfig(); render(); } if (kind === 'submit-gotify-notify') { const f=new FormData(document.getElementById('bu-gotify-notify-form')); const existing=state.config.notifications.find(n=>n.type==='gotify'); const target={schemaVersion:1,id:f.get('id')||'gotify',name:'Gotify',type:'gotify',enabled:f.get('enabled')==='on',url:String(f.get('url')||'').trim(),tokenRef:String(f.get('tokenRef')||'').trim(),events:f.getAll('events')}; if(target.enabled&&!target.url) throw new Error('Die Gotify-Server-URL ist erforderlich.'); if(target.enabled&&!target.events.length) throw new Error('Wähle mindestens ein Ereignis für Gotify-Benachrichtigungen aus.'); if(target.enabled&&!existing&&!f.get('token')) throw new Error('Ein Gotify-Anwendungs-Token ist erforderlich.'); if(f.get('token')) await api(`/v1/secrets/${encodeURIComponent(target.tokenRef)}`,{method:'PUT',body:JSON.stringify({type:'gotify-token',value:f.get('token')})}); state.config.notifications=state.config.notifications.filter(n=>n.type!=='gotify'&&n.type!=='ntfy'); if(target.enabled||target.url) state.config.notifications.push(target); await saveConfig(); render(); } } + async function submitRepositoryPassword(adopt) { + const f=new FormData(document.getElementById('bu-repo-password-form')); + const password=String(f.get('password')||''); + if(password.length<8) throw new Error('Das Repository-Passwort muss mindestens 8 Zeichen lang sein.'); + if(password!==String(f.get('passwordConfirm')||'')) throw new Error('Die beiden Repository-Passwörter stimmen nicht überein.'); + const prompt=adopt?'Dieses bereits gültige Passwort prüfen und nur auf diesem URBM-Server übernehmen?':'Repository-Passwort jetzt wirklich ändern?\n\nDas bisherige Passwort funktioniert danach für den bisherigen Restic-Key nicht mehr. Weitere Server müssen anschließend auf das neue Passwort umgestellt werden.'; + if(!confirm(prompt)) return; + const suffix=adopt?'/password/adopt':'/password'; + await api(`/v1/repositories/${encodeURIComponent(f.get('id'))}${suffix}`,{method:'POST',body:JSON.stringify({password})}); + notify(adopt?'Repository-Passwort wurde geprüft und auf diesem Server übernommen':'Repository-Passwort wurde sicher geändert'); render(); + } + document.querySelector('.bu-tabs').addEventListener('click', e => { const button=e.target.closest('[data-view]'); if(!button)return; state.view=button.dataset.view; document.querySelectorAll('.bu-tabs button').forEach(x=>x.classList.toggle('active',x===button)); render(); if(state.view==='dashboard')loadRepositoryStats(); }); content.addEventListener('click', e => { const target=e.target.closest('[data-action]'); if(!target)return; e.preventDefault(); handle(target.dataset.action,target); }); content.addEventListener('change', e => { @@ -754,7 +779,7 @@ const form = e.target.closest('form'); if (!form) return; e.preventDefault(); - const action = form.id === 'bu-repo-form' ? 'submit-repo' : form.id === 'bu-job-form' ? 'submit-job' : form.id === 'bu-restore-form' ? 'submit-restore' : form.id === 'bu-settings-form' ? 'submit-settings' : form.id === 'bu-unraid-notify-form' ? 'submit-unraid-notify' : form.id === 'bu-gotify-notify-form' ? 'submit-gotify-notify' : ''; + const action = form.id === 'bu-repo-form' ? 'submit-repo' : form.id === 'bu-repo-password-form' ? 'submit-repo-password' : form.id === 'bu-job-form' ? 'submit-job' : form.id === 'bu-restore-form' ? 'submit-restore' : form.id === 'bu-settings-form' ? 'submit-settings' : form.id === 'bu-unraid-notify-form' ? 'submit-unraid-notify' : form.id === 'bu-gotify-notify-form' ? 'submit-gotify-notify' : ''; if (action) handle(action); }); content.addEventListener('toggle', e => {