Add safe repository password rotation
This commit is contained in:
@@ -1 +0,0 @@
|
||||
89f13497e0503b91602af00343fb15cd897e775189e63fbd6af4e0c302ff06d4 urbm-2026.06.15.r019-x86_64-1.txz
|
||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
||||
a9b5d02c0b92ea7f0a72d0d30a62870a88165065aaf97637c57aec2e7df37139 urbm-2026.06.15.r020-x86_64-1.txz
|
||||
Vendored
+9
-2
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user