all repos — postern

@ 24b6f0d7bd5c4c748592f78a47165959a192fdc2

Modern mail management

clone
git clone https:///postern.git
add password-rotation feature for CLI
Raphael Sprenger
Fri, 02 Oct 2026 19:27:43 +0200
commit

24b6f0d7bd5c4c748592f78a47165959a192fdc2

parent

50dc541db37adabbbb220f4abf62aba899c48f5d

M internal/cli/admin.go → internal/cli/admin.go

@@ -1,6 +1,7 @@

package cli import ( + "bufio" "context" "crypto/rand" "errors"

@@ -46,6 +47,33 @@ return err

} _, _ = fmt.Fprintf(cmd.OutOrStdout(), "User %q removed.\n", username) + return nil +} + +func (c *Command) rotateUserPassword(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true // suppress runtime error help text + ctx := cmd.Context() + username := args[0] + + fmt.Fprintf(cmd.OutOrStdout(), "Rotate password for %q? Type \"yes\" to confirm: ", username) + reader := bufio.NewReader(cmd.InOrStdin()) + line, err := reader.ReadString('\n') + if err != nil && line == "" { + return fmt.Errorf("reading confirmation: %w", err) + } + + if !strings.EqualFold(strings.TrimSpace(line), "yes") { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Aborted.") + return nil + } + + user, err := c.clientRotateUserPassword(ctx, username) + if err != nil { + return err + } + + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Password rotated for %q. Your password will only be shown once.\n", username) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Password: %s\n", user.Password) return nil }

@@ -332,6 +360,35 @@ }

user.SetPassword(hash[:]) return user, nil +} + +// RotateUserPassword generates a new password for an existing user and +// returns the user with the new one-time plaintext password. +func (c *Command) RotateUserPassword(ctx context.Context, username string) (model.User, error) { + if exists, err := c.db.UserExists(ctx, username); err != nil { + return model.User{}, err + } else if !exists { + return model.User{}, fmt.Errorf("%w: user %q", model.ErrNotFound, username) + } + + password, err := generateSafeToken(16) + if err != nil { + return model.User{}, err + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return model.User{}, err + } + + if err := c.db.UpdateUserPassword(ctx, username, hash[:]); err != nil { + return model.User{}, err + } + + return model.User{ + Name: username, + Password: password, + }, nil } // RemoveUser removes a user by name.
M internal/cli/admin_test.go → internal/cli/admin_test.go

@@ -3,7 +3,6 @@

import ( "bytes" "crypto/rand" - "log" "os" "path" "strings"

@@ -27,16 +26,29 @@ err := cmd.Execute()

return buf.String(), err } -// TestAddUserCommand checks the loop of creating a new user, finding it in the db and checking that the password works. -func TestAddRemoveUserCommand(t *testing.T) { +// executeCommandWithInput is like executeCommand but also injects stdin, used +// for commands that require an interactive confirmation. +func executeCommandWithInput(cmd *cobra.Command, input string, args ...string) (string, error) { + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetIn(strings.NewReader(input)) + cmd.SetArgs(args) + + err := cmd.Execute() + return buf.String(), err +} + +// setupTestCommand builds a *Command backed by a real DB and a running HTTP +// service on a temp unix socket, for exercising the client/CLI path end-to-end. +func setupTestCommand(t *testing.T) (*Command, *HTTPService) { + t.Helper() f := t.TempDir() - // create temporary masterkey mkeyPath := path.Join(f, "masterkey") secretKey := make([]byte, 32) _, _ = rand.Read(secretKey) - err := os.WriteFile(mkeyPath, secretKey, 0666) - if err != nil { + if err := os.WriteFile(mkeyPath, secretKey, 0666); err != nil { t.Fatal(err) }

@@ -45,14 +57,14 @@ BlobRoot: path.Join(f, "blobs"),

MasterkeyFile: mkeyPath, }) if err != nil { - log.Fatal(err) + t.Fatal(err) } posternDB, err := db.OpenDB(&db.Config{ DBPath: path.Join(f, "meta.db"), }) if err != nil { - log.Fatal(err) + t.Fatal(err) } c, err := New(&Config{

@@ -63,12 +75,10 @@ if err != nil {

t.Fatal(err) } - // Start HTTP service on a temp socket for the test tempSocket := path.Join(f, "test.sock") c.socketPath = tempSocket c.httpClient = newUnixHTTPClient(tempSocket) - // Start the HTTP service in background service := NewHTTPService(tempSocket, c) go func() { _ = service.Start()

@@ -83,9 +93,16 @@ select {

case <-timeout: t.Fatal("timed out waiting for http service to start") case <-ticker.C: - // Wait for next tick } } + + t.Cleanup(func() { _ = service.Stop() }) + return c, service +} + +// TestAddUserCommand checks the loop of creating a new user, finding it in the db and checking that the password works. +func TestAddRemoveUserCommand(t *testing.T) { + c, service := setupTestCommand(t) // Run the cobra command output, err := executeCommand(c.rootCmd(), "user", "add", "user1")

@@ -128,3 +145,68 @@

// Clean up _ = service.Stop() } + +// TestRotateUserPasswordCommand checks that the password is rotated, the new +// password verifies, the old one no longer does, and that declining aborts. +func TestRotateUserPasswordCommand(t *testing.T) { + c, _ := setupTestCommand(t) + + // Create a user first. + output, err := executeCommand(c.rootCmd(), "user", "add", "user1") + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + _, oldPassword, found := strings.Cut(output, "Password: ") + if !found { + t.Fatal("expected to find a password in output. Found none") + } + oldPassword = strings.TrimSpace(oldPassword) + + // Decline the confirmation: nothing should change. + out, err := executeCommandWithInput(c.rootCmd(), "no\n", "user", "rotate-password", "user1") + if err != nil { + t.Fatalf("expected no error on decline, got: %v", err) + } + if !strings.Contains(out, "Aborted") { + t.Fatalf("expected 'Aborted' in output, got: %q", out) + } + u, err := c.db.GetUser(t.Context(), "user1") + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if err := u.VerifyPassword([]byte(oldPassword)); err != nil { + t.Fatalf("old password should still verify after decline, got: %v", err) + } + + // Confirm the rotation. + out, err = executeCommandWithInput(c.rootCmd(), "yes\n", "user", "rotate-password", "user1") + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + _, newPassword, found := strings.Cut(out, "Password: ") + if !found { + t.Fatal("expected to find a new password in output. Found none") + } + newPassword = strings.TrimSpace(newPassword) + + if newPassword == oldPassword { + t.Fatal("expected a different password after rotation") + } + + u, err = c.db.GetUser(t.Context(), "user1") + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if err := u.VerifyPassword([]byte(newPassword)); err != nil { + t.Fatalf("new password mismatch, got: %v", err) + } + if err := u.VerifyPassword([]byte(oldPassword)); err == nil { + t.Fatal("old password should no longer verify after rotation") + } + + // Rotating a non-existent user should error. + _, err = executeCommandWithInput(c.rootCmd(), "yes\n", "user", "rotate-password", "ghost") + if err == nil { + t.Error("expected error when rotating non-existing user, got none") + } +}
M internal/cli/cli.go → internal/cli/cli.go

@@ -120,10 +120,18 @@ Args: cobra.ExactArgs(1),

RunE: c.removeUser, } + rotatePasswordCmd := &cobra.Command{ + Use: "rotate-password <username>", + Short: "Generate a new password for a user", + Args: cobra.ExactArgs(1), + RunE: c.rotateUserPassword, + } + userCmd.AddCommand(userListCmd) userCmd.AddCommand(addUserCmd) userCmd.AddCommand(addUserAddressCmd) userCmd.AddCommand(userRemoveCmd) + userCmd.AddCommand(rotatePasswordCmd) backupMxCmd := &cobra.Command{ Use: "backup-mx",
M internal/cli/client.go → internal/cli/client.go

@@ -69,6 +69,31 @@

return user, nil } +// clientRotateUserPassword rotates a user's password via the daemon. +func (c *Command) clientRotateUserPassword(ctx context.Context, username string) (_ model.User, err error) { + path, err := url.JoinPath("http://unix/users", username, "rotate-password") + if err != nil { + return model.User{}, fmt.Errorf("failed to create rotate password request path: %w", err) + } + + resp, err := c.httpClient.Post(path, "application/json", nil) + if err != nil { + return model.User{}, fmt.Errorf("failed to rotate user password: %w", err) + } + defer model.Wrap(resp.Body.Close, &err, "clientRotateUserPassword closing response body") + + if resp.StatusCode != http.StatusOK { + return model.User{}, fmt.Errorf("failed to rotate user password: %d", resp.StatusCode) + } + + var user model.User + if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { + return model.User{}, fmt.Errorf("failed to decode user response: %w", err) + } + + return user, nil +} + // clientRemoveUser removes a user via the daemon. func (c *Command) clientRemoveUser(ctx context.Context, username string) (err error) { req, err := http.NewRequestWithContext(ctx, http.MethodDelete, "http://unix/users/"+username, nil)
M internal/cli/http.go → internal/cli/http.go

@@ -91,6 +91,7 @@ mux.HandleFunc("GET /users", h.listUsers)

mux.HandleFunc("POST /users", h.createUser) mux.HandleFunc("DELETE /users/{username}", h.deleteUser) mux.HandleFunc("POST /users/{username}/addresses", h.addUserAddress) + mux.HandleFunc("POST /users/{username}/rotate-password", h.rotateUserPassword) // SMTP auth endpoints mux.HandleFunc("GET /smtp-auth", h.listSMTPAuth)

@@ -212,6 +213,22 @@ return

} w.WriteHeader(http.StatusCreated) +} + +func (h *HTTPService) rotateUserPassword(w http.ResponseWriter, r *http.Request) { + username := r.PathValue("username") + if username == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username is required"}) + return + } + + user, err := h.cmd.RotateUserPassword(r.Context(), username) + if err != nil { + writeError(w, err) + return + } + + writeJSON(w, http.StatusOK, user) } func (h *HTTPService) listSMTPAuth(w http.ResponseWriter, r *http.Request) {
M internal/cli/kv_test.go → internal/cli/kv_test.go

@@ -2,60 +2,12 @@ package cli

import ( "fmt" - "path" "strings" "testing" - "time" - - "postern/internal/db" ) -// newKVTestCommand sets up a DB-backed Command with a running HTTP service and -// returns it. The service is stopped when the test finishes. -func newKVTestCommand(t *testing.T) *Command { - t.Helper() - f := t.TempDir() - - posternDB, err := db.OpenDB(&db.Config{ - DBPath: path.Join(f, "meta.db"), - }) - if err != nil { - t.Fatal(err) - } - - c, err := New(&Config{ - DB: posternDB, - }) - if err != nil { - t.Fatal(err) - } - - tempSocket := path.Join(f, "test.sock") - c.socketPath = tempSocket - c.httpClient = newUnixHTTPClient(tempSocket) - - service := NewHTTPService(tempSocket, c) - go func() { - _ = service.Start() - }() - - timeout := time.After(10 * time.Second) - ticker := time.NewTicker(100 * time.Millisecond) - defer ticker.Stop() - for service.Status() != "running" { - select { - case <-timeout: - t.Fatal("timed out waiting for http service to start") - case <-ticker.C: - } - } - t.Cleanup(func() { _ = service.Stop() }) - - return c -} - func TestKVListCommand(t *testing.T) { - c := newKVTestCommand(t) + c, _ := setupTestCommand(t) ctx := t.Context() seed := map[string]any{

@@ -85,7 +37,7 @@ }

} func TestKVGetCommand(t *testing.T) { - c := newKVTestCommand(t) + c, _ := setupTestCommand(t) ctx := t.Context() if err := c.db.KVSet(ctx, "counter", 42); err != nil {

@@ -120,7 +72,7 @@ }

} func TestKVPopCommand(t *testing.T) { - c := newKVTestCommand(t) + c, _ := setupTestCommand(t) ctx := t.Context() if err := c.db.KVSet(ctx, "counter", 42); err != nil {
M internal/db/user.go → internal/db/user.go

@@ -155,3 +155,20 @@ func (db *DB) RemoveUser(ctx context.Context, username string) error {

_, err := db.write.ExecContext(ctx, "DELETE FROM users WHERE name = ?", username) return err } + +// UpdateUserPassword sets a new bcrypt password hash for an existing user. +// Returns model.ErrNotFound-wrapped error if the user does not exist. +func (db *DB) UpdateUserPassword(ctx context.Context, username string, hash []byte) error { + res, err := db.write.ExecContext(ctx, "UPDATE users SET password_hash = ? WHERE name = ?", hash, username) + if err != nil { + return fmt.Errorf("updating user password: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("checking rows affected: %w", err) + } + if n == 0 { + return fmt.Errorf("%w: user %q", model.ErrNotFound, username) + } + return nil +}