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
+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 {