Add safe repository password rotation

This commit is contained in:
Mikei386
2026-06-15 20:13:10 +02:00
parent 3579756cc9
commit 9249fad65a
12 changed files with 262 additions and 30 deletions
-1
View File
@@ -1 +0,0 @@
89f13497e0503b91602af00343fb15cd897e775189e63fbd6af4e0c302ff06d4 urbm-2026.06.15.r019-x86_64-1.txz
+1
View File
@@ -0,0 +1 @@
a9b5d02c0b92ea7f0a72d0d30a62870a88165065aaf97637c57aec2e7df37139 urbm-2026.06.15.r020-x86_64-1.txz
+9 -2
View File
@@ -2,13 +2,20 @@
<!DOCTYPE PLUGIN [
<!ENTITY name "urbm">
<!ENTITY author "Michael Roll">
<!ENTITY version "2026.06.15.r019">
<!ENTITY version "2026.06.15.r020">
<!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 "89f13497e0503b91602af00343fb15cd897e775189e63fbd6af4e0c302ff06d4">
<!ENTITY packageSHA256 "a9b5d02c0b92ea7f0a72d0d30a62870a88165065aaf97637c57aec2e7df37139">
]>
<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.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.
+30
View File
@@ -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()
+48 -18
View File
@@ -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
+36
View File
@@ -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")
+22 -1
View File
@@ -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) {
+76
View File
@@ -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 {
+8 -1
View File
@@ -2,13 +2,20 @@
<!DOCTYPE PLUGIN [
<!ENTITY name "urbm">
<!ENTITY author "Michael Roll">
<!ENTITY version "2026.06.15.r019">
<!ENTITY version "2026.06.15.r020">
<!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.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.
+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=20260615r019">
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260615r020">
<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=20260615r019" alt="URBM-Logo">
<div><h1>URBM <span class="bu-version">2026.06.15.r019</span></h1><p>Restic Backup Manager für Unraid</p></div>
<img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260615r020" alt="URBM-Logo">
<div><h1>URBM <span class="bu-version">2026.06.15.r020</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.15.r019.js"></script>
<script src="<?= $pluginRoot ?>/assets/urbm-2026.06.15.r020.js"></script>
+28 -3
View File
@@ -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 `<tr><td><strong>${esc(r.name)}</strong><br><span class="bu-muted">${esc(r.id)}</span></td><td>${esc(typeLabels[r.type]||r.type)}</td><td>${esc(r.location)}${volatileLocation(r)?'<span class="bu-warning">Flüchtiger Pfad: Die Daten gehen beim Neustart verloren. Verwende /mnt/user/...</span>':''}</td><td>${r.mount?.managed?'Durch URBM':'Direkt / extern'}</td><td>${actionGroup(actions)}</td></tr>`;
}).join('');
return `<div class="bu-toolbar"><div><h2>Repositorys</h2><div class="bu-muted">Lokale, SMB-, NFS- und SFTP-Ziele.</div></div><div class="bu-actions">${button('Vorhandenes einbinden','existing-repo')}${button('Neues Repository','new-repo','primary')}</div></div><section class="bu-card">${rows ? `<table class="bu-table"><thead><tr><th>Name</th><th>Typ</th><th>Speicherort</th><th>Einbindung</th><th>Aktionen</th></tr></thead><tbody>${rows}</tbody></table>` : '<div class="bu-empty">Keine Repositorys konfiguriert.</div>'}</section>`;
@@ -506,12 +509,17 @@
${existingRepository?'<div class="wide bu-inline-info">Wähle den exakten Ordner des vorhandenen Restic-Repositorys. Darin müssen direkt die Einträge <strong>config</strong>, <strong>data</strong>, <strong>index</strong>, <strong>keys</strong> und <strong>snapshots</strong> liegen. Verwende das ursprüngliche Repository-Passwort. Es wird nichts initialisiert.</div>':''}
<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>
<fieldset class="wide bu-fieldset"><legend>Restic-Verschlüsselung</legend><div class="bu-form-grid"><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><label>Repository-Passwort wiederholen<input name="passwordConfirm" type="password" autocomplete="new-password" ${repo.id?'':'required'} placeholder="${repo.id?'Bei einer Passwortänderung erneut eingeben':'Repository-Passwort erneut eingeben'}"></label></div><p class="bu-muted">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.'}</p></fieldset>
${repo.id?`<fieldset class="wide bu-fieldset"><legend>Restic-Verschlüsselung</legend><p>Das Repository-Passwort wird getrennt von den Ziel- und Mount-Einstellungen geändert.</p><div class="bu-actions">${button('Repository-Passwort ändern',`change-repo-password:${repo.id}`)}</div></fieldset>`:`<fieldset class="wide bu-fieldset"><legend>Restic-Verschlüsselung</legend><div class="bu-form-grid"><label>Repository-Passwort<input name="password" type="password" autocomplete="new-password" required placeholder="Ein starkes Repository-Passwort eingeben"></label><label>Repository-Passwort wiederholen<input name="passwordConfirm" type="password" autocomplete="new-password" required placeholder="Repository-Passwort erneut eingeben"></label></div><p class="bu-muted">Das Passwort wird von URBM geschützt gespeichert. Bewahre dieses Passwort zusätzlich sicher auf; ohne das Passwort können Backups nicht wiederhergestellt werden.</p></fieldset>`}
<div class="wide bu-actions">${button(existingRepository?'Einbinden und testen':'Speichern','submit-repo','primary')}${button('Abbrechen','cancel-form')}</div></form></section>`;
enhanceTooltips(content);
renderRepositorySpecific(repo.type || 'local', repo);
}
function repositoryPasswordForm(repo) {
content.innerHTML=`<section class="bu-card"><h2>Repository-Passwort verwalten</h2><div class="bu-inline-info">Repository: <strong>${esc(repo.name)}</strong><br><strong>Passwort sicher ändern</strong> erstellt einen neuen Restic-Key und entfernt den bisherigen. Snapshots und Backupdaten werden nicht neu verschlüsselt oder kopiert.<br><strong>Vorhandenes Passwort übernehmen</strong> ist für weitere URBM-Server gedacht, nachdem das Passwort bereits auf einem anderen Server geändert wurde.</div><form id="bu-repo-password-form" class="bu-form"><input type="hidden" name="id" value="${esc(repo.id)}"><label>Neues oder bereits geändertes Passwort<input name="password" type="password" minlength="8" required autocomplete="new-password"></label><label>Passwort wiederholen<input name="passwordConfirm" type="password" minlength="8" required autocomplete="new-password"></label><div class="wide bu-warning">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.</div><div class="wide bu-actions">${button('Passwort sicher ändern','submit-repo-password','primary')}${button('Vorhandenes Passwort übernehmen','adopt-repo-password')}${button('Abbrechen','cancel-form')}</div></form></section>`;
enhanceTooltips(content);
}
function repositoryLocationField(label, value, placeholder) {
return `<label class="wide">${label}<input name="location" required placeholder="${esc(placeholder)}" value="${esc(value||'')}"></label>`;
}
@@ -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 => {