Add safe repository password rotation
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user