internal/cli/admin.go (view raw)
1package cli
2
3import (
4 "bufio"
5 "context"
6 "crypto/rand"
7 "errors"
8 "fmt"
9 "log/slog"
10 "math/big"
11 "net"
12 "net/mail"
13 "os"
14 "strings"
15 "text/tabwriter"
16
17 "postern/internal/db"
18 "postern/internal/model"
19
20 "github.com/spf13/cobra"
21 "golang.org/x/crypto/bcrypt"
22)
23
24func (c *Command) addUser(cmd *cobra.Command, args []string) error {
25 cmd.SilenceUsage = true // suppress runtime error help text
26 ctx := cmd.Context()
27 username := args[0]
28
29 user, err := c.clientCreateUser(ctx, username)
30 if err != nil {
31 return err
32 }
33
34 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "User %q created. Your password will only be shown once.\n", user.Name)
35 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Password: %s\n", user.Password)
36 return nil
37}
38
39func (c *Command) removeUser(cmd *cobra.Command, args []string) error {
40 cmd.SilenceUsage = true // suppress runtime error help text
41 ctx := cmd.Context()
42 username := args[0]
43
44 err := c.clientRemoveUser(ctx, username)
45 if err != nil {
46 return err
47 }
48
49 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "User %q removed.\n", username)
50 return nil
51}
52
53func (c *Command) rotateUserPassword(cmd *cobra.Command, args []string) error {
54 cmd.SilenceUsage = true // suppress runtime error help text
55 ctx := cmd.Context()
56 username := args[0]
57
58 fmt.Fprintf(cmd.OutOrStdout(), "Rotate password for %q? Type \"yes\" to confirm: ", username)
59 reader := bufio.NewReader(cmd.InOrStdin())
60 line, err := reader.ReadString('\n')
61 if err != nil && line == "" {
62 return fmt.Errorf("reading confirmation: %w", err)
63 }
64
65 if !strings.EqualFold(strings.TrimSpace(line), "yes") {
66 _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Aborted.")
67 return nil
68 }
69
70 user, err := c.clientRotateUserPassword(ctx, username)
71 if err != nil {
72 return err
73 }
74
75 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Password rotated for %q. Your password will only be shown once.\n", username)
76 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Password: %s\n", user.Password)
77 return nil
78}
79
80func (c *Command) listUsers(cmd *cobra.Command, _ []string) error {
81 cmd.SilenceUsage = true
82
83 users, err := c.clientListUsers(cmd.Context())
84 if err != nil {
85 fmt.Println("Error listing users")
86 return err
87 }
88
89 w := tabwriter.NewWriter(os.Stdout, 1, 1, 1, ' ', 0)
90 _, _ = fmt.Fprintln(w, "Name")
91 for _, user := range users {
92 _, _ = fmt.Fprintf(w, "%s\n", user.Name)
93 }
94 _ = w.Flush()
95
96 return nil
97}
98
99func (c *Command) addUserAddress(cmd *cobra.Command, args []string) error {
100 cmd.SilenceUsage = true // suppress runtime error help text
101 ctx := cmd.Context()
102 username := args[0]
103 address := args[1]
104
105 err := c.clientAddUserAddress(ctx, username, address)
106 if err != nil {
107 fmt.Printf("Could not parse address: %s\n", address)
108 return err
109 }
110
111 fmt.Printf("address <%s> added for %q\n", address, username)
112 return nil
113}
114
115func (c *Command) removeUserAddress(cmd *cobra.Command, args []string) error {
116 cmd.SilenceUsage = true // suppress runtime error help text
117 ctx := cmd.Context()
118 username := args[0]
119 address := args[1]
120
121 err := c.clientRemoveUserAddress(ctx, username, address)
122 if err != nil {
123 return err
124 }
125
126 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "address <%s> removed for %q\n", address, username)
127 return nil
128}
129
130func (c *Command) listUserAddresses(cmd *cobra.Command, args []string) error {
131 cmd.SilenceUsage = true // suppress runtime error help text
132 ctx := cmd.Context()
133 username := args[0]
134
135 addresses, err := c.clientListUserAddresses(ctx, username)
136 if err != nil {
137 return err
138 }
139
140 if len(addresses) == 0 {
141 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "No addresses found for %q\n", username)
142 return nil
143 }
144
145 _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Address")
146 for _, address := range addresses {
147 _, _ = fmt.Fprintln(cmd.OutOrStdout(), address)
148 }
149
150 return nil
151}
152
153func (c *Command) addDKIM(cmd *cobra.Command, args []string) error {
154 cmd.SilenceUsage = true
155 selector := args[0]
156 domain := args[1]
157
158 record, err := c.clientAddDKIM(cmd.Context(), selector, domain)
159 if err != nil {
160 return err
161 }
162
163 fmt.Println("Create a DNS TXT record for", domainkeyName(selector, domain))
164 fmt.Println("Remember to enable this selector once it was made available through DNS. (dkim enable)")
165 fmt.Println(record)
166 return nil
167}
168
169func (c *Command) listDKIM(cmd *cobra.Command, _ []string) error {
170 cmd.SilenceUsage = true // suppress runtime error help text
171
172 dkims, err := c.clientListDKIM(cmd.Context())
173 if err != nil {
174 fmt.Println("Error list DKIM")
175 return err
176 }
177
178 if len(dkims) == 0 {
179 fmt.Println("No DKIM records found")
180 return nil
181 }
182
183 w := tabwriter.NewWriter(os.Stdout, 1, 1, 1, ' ', 0)
184 _, _ = fmt.Fprintln(w, "Identifier\tKey Type\tEnabled")
185 for _, dkim := range dkims {
186 _, _ = fmt.Fprintf(w, "%s\t%s\t%v\n", domainkeyName(dkim.Selector, dkim.Domain), dkim.KeyType, dkim.Enabled)
187 }
188 _ = w.Flush()
189
190 return nil
191}
192
193func (c *Command) getDKIM(cmd *cobra.Command, args []string) error {
194 cmd.SilenceUsage = true
195 selector := args[0]
196 domain := args[1]
197
198 record, err := c.clientGetDKIM(cmd.Context(), selector, domain)
199 if err != nil {
200 return err
201 }
202
203 fmt.Println("Create a DNS TXT record for", domainkeyName(selector, domain))
204 fmt.Println("Remember to enable this selector once it was made available through DNS. (dkim enable)")
205 fmt.Println(record)
206
207 return nil
208}
209
210func (c *Command) setDKIMEnabled(cmd *cobra.Command, args []string, enable bool) error {
211 cmd.SilenceUsage = true
212 selector := args[0]
213 domain := args[1]
214
215 // For cobra, we respect the force flag
216 force, err := cmd.Flags().GetBool("force")
217 if err != nil {
218 return err
219 }
220
221 err = c.clientSetDKIMEnabled(cmd.Context(), selector, domain, enable, force)
222 if err != nil {
223 return err
224 }
225
226 if enable {
227 fmt.Printf("Enabling DKIM selector %q for domain %q\n", selector, domain)
228 } else {
229 fmt.Printf("Disabling DKIM selector %q for domain %q\n", selector, domain)
230 }
231 return nil
232}
233
234func (c *Command) removeDKIM(cmd *cobra.Command, args []string) error {
235 cmd.SilenceUsage = true
236 selector := args[0]
237 domain := args[1]
238
239 err := c.clientRemoveDKIM(cmd.Context(), selector, domain)
240 if err != nil {
241 return err
242 }
243
244 return nil
245}
246
247func (c *Command) listSMTPAuth(cmd *cobra.Command, _ []string) error {
248 cmd.SilenceUsage = true
249
250 smtpAuths, err := c.clientListSMTPAuth(cmd.Context())
251 if err != nil {
252 fmt.Println("Error listing SMTP auth users")
253 return err
254 }
255
256 w := tabwriter.NewWriter(os.Stdout, 1, 1, 1, ' ', 0)
257 _, _ = fmt.Fprintln(w, "Name")
258 for _, auth := range smtpAuths {
259 _, _ = fmt.Fprintf(w, "%s\n", auth.Name)
260 }
261 _ = w.Flush()
262
263 return nil
264}
265
266func (c *Command) addSMTPAuth(cmd *cobra.Command, args []string) error {
267 cmd.SilenceUsage = true // suppress runtime error help text
268 ctx := cmd.Context()
269 name := args[0]
270
271 auth, err := c.clientCreateSMTPAuth(ctx, name)
272 if err != nil {
273 return err
274 }
275
276 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "SMTP Auth %q created. The password will only be shown once.\n", name)
277 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Password: %s\n", auth.Password)
278 return nil
279}
280
281func (c *Command) removeSMTPAuth(cmd *cobra.Command, args []string) error {
282 cmd.SilenceUsage = true // suppress runtime error help text
283 ctx := cmd.Context()
284 name := args[0]
285
286 err := c.clientRemoveSMTPAuth(ctx, name)
287 if err != nil {
288 return err
289 }
290
291 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "User %q removed.\n", name)
292 return nil
293}
294
295func domainkeyName(selector, domain string) string {
296 if !strings.HasSuffix(domain, ".") {
297 domain = domain + "."
298 }
299 return fmt.Sprintf("%s._domainkey.%s", selector, domain)
300}
301
302// Define the character set:
303// - Numbers: 2-9 (No 0, 1)
304// - Uppercase: A-Z (No I, O)
305// - Lowercase: a-z (No l, o)
306const letterBytes = "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz"
307
308func generateSafeToken(length int) (string, error) {
309 if length <= 0 {
310 return "", fmt.Errorf("length must be greater than 0")
311 }
312
313 ret := make([]byte, length)
314
315 // We calculate the max index based on the charset length
316 charsetLen := big.NewInt(int64(len(letterBytes)))
317
318 for i := range length {
319 // rand.Int returns a uniform random value in [0, max).
320 // It automatically handles modulo bias, ensuring an even distribution.
321 num, err := rand.Int(rand.Reader, charsetLen)
322 if err != nil {
323 return "", err
324 }
325
326 // Use the random number as an index to pick a character
327 ret[i] = letterBytes[num.Int64()]
328 }
329
330 return string(ret), nil
331}
332
333// CreateUser creates a new user with a generated password and returns the user with the password.
334func (c *Command) CreateUser(ctx context.Context, username string) (model.User, error) {
335 if exists, err := c.db.UserExists(ctx, username); err != nil {
336 return model.User{}, err
337 } else if exists {
338 return model.User{}, fmt.Errorf("%w: user %q", model.ErrAlreadyExists, username)
339 }
340
341 password, err := generateSafeToken(16)
342 if err != nil {
343 return model.User{}, err
344 }
345
346 hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
347 if err != nil {
348 return model.User{}, err
349 }
350
351 tx, err := c.db.GetWriteTx(ctx)
352 if err != nil {
353 return model.User{}, fmt.Errorf("beginning transaction: %w", err)
354 }
355
356 userID, err := db.InsertUser(ctx, tx, username, hash[:])
357 if err != nil {
358 txErr := tx.Rollback()
359 if txErr != nil {
360 slog.Error("rolling back transaction", "error", err)
361 }
362 return model.User{}, fmt.Errorf("inserting user %q: %w", username, err)
363 }
364
365 if err := db.InsertDefaultMailboxes(ctx, tx, userID); err != nil {
366 txErr := tx.Rollback()
367 if txErr != nil {
368 slog.Error("rolling back transaction", "error", err)
369 }
370 return model.User{}, fmt.Errorf("inserting default mailboxes: %w", err)
371 }
372
373 if err := tx.Commit(); err != nil {
374 return model.User{}, fmt.Errorf("committing transaction: %w", err)
375 }
376
377 // subscribe user to all mailboxes
378 allMailboxes, err := c.db.GetUserMailboxes(ctx, userID)
379 if err != nil {
380 return model.User{}, err
381 }
382
383 for _, mb := range allMailboxes {
384 err = c.db.Subscribe(ctx, userID, mb.ID, mb.Name)
385 if err != nil {
386 return model.User{}, fmt.Errorf("subscribing mailbox %q: %w", mb.Name, err)
387 }
388 slog.Debug("subscribed mailbox", "mailbox", mb.Name)
389 }
390
391 // Return user with password
392 user := model.User{
393 ID: userID,
394 Name: username,
395 Addresses: []string{},
396 Password: password,
397 }
398 user.SetPassword(hash[:])
399
400 return user, nil
401}
402
403// RotateUserPassword generates a new password for an existing user and
404// returns the user with the new one-time plaintext password.
405func (c *Command) RotateUserPassword(ctx context.Context, username string) (model.User, error) {
406 if exists, err := c.db.UserExists(ctx, username); err != nil {
407 return model.User{}, err
408 } else if !exists {
409 return model.User{}, fmt.Errorf("%w: user %q", model.ErrNotFound, username)
410 }
411
412 password, err := generateSafeToken(16)
413 if err != nil {
414 return model.User{}, err
415 }
416
417 hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
418 if err != nil {
419 return model.User{}, err
420 }
421
422 if err := c.db.UpdateUserPassword(ctx, username, hash[:]); err != nil {
423 return model.User{}, err
424 }
425
426 return model.User{
427 Name: username,
428 Password: password,
429 }, nil
430}
431
432// RemoveUser removes a user by name.
433func (c *Command) RemoveUser(ctx context.Context, username string) error {
434 if exists, err := c.db.UserExists(ctx, username); err != nil {
435 return err
436 } else if !exists {
437 return fmt.Errorf("%w: user %q", model.ErrNotFound, username)
438 }
439
440 return c.db.RemoveUser(ctx, username)
441}
442
443// ListUsers returns all users.
444func (c *Command) ListUsers(ctx context.Context) ([]model.User, error) {
445 return c.db.ListUsers(ctx)
446}
447
448// AddUserAddress adds an email address to a user.
449func (c *Command) AddUserAddress(ctx context.Context, username, address string) error {
450 parsedAddress, err := mail.ParseAddress(address)
451 if err != nil {
452 return fmt.Errorf("%w: could not parse address %q", model.ErrInvalidInput, address)
453 }
454
455 i := strings.LastIndex(parsedAddress.Address, "@")
456 if i == -1 {
457 return fmt.Errorf("%w: invalid address does not have a domain part %q", model.ErrInvalidInput, parsedAddress.Address)
458 }
459
460 return c.db.InsertUserAddress(ctx, username, parsedAddress.Address)
461}
462
463// RemoveUserAddress removes an email address from a user. It validates that the
464// user exists and delegates to the DB, which reports a not-found error if the
465// address is not present.
466func (c *Command) RemoveUserAddress(ctx context.Context, username, address string) error {
467 exists, err := c.db.UserExists(ctx, username)
468 if err != nil {
469 return err
470 } else if !exists {
471 return fmt.Errorf("%w: user %q", model.ErrNotFound, username)
472 }
473
474 return c.db.RemoveUserAddress(ctx, username, address)
475}
476
477// ListUserAddresses returns all email addresses for a user. It validates that
478// the user exists before delegating to the DB.
479func (c *Command) ListUserAddresses(ctx context.Context, username string) ([]string, error) {
480 exists, err := c.db.UserExists(ctx, username)
481 if err != nil {
482 return nil, err
483 } else if !exists {
484 return nil, fmt.Errorf("%w: user %q", model.ErrNotFound, username)
485 }
486
487 return c.db.ListUserAddresses(ctx, username)
488}
489
490// CreateSMTPAuth creates a new SMTP authentication user with a generated password and returns the user with the password.
491func (c *Command) CreateSMTPAuth(ctx context.Context, name string) (model.SMTPAuth, error) {
492 if exists, err := c.db.SMTPAuthNameExists(ctx, name); err != nil {
493 return model.SMTPAuth{}, err
494 } else if exists {
495 return model.SMTPAuth{}, fmt.Errorf("%w: user %q", model.ErrAlreadyExists, name)
496 }
497
498 password, err := generateSafeToken(16)
499 if err != nil {
500 return model.SMTPAuth{}, err
501 }
502
503 hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
504 if err != nil {
505 return model.SMTPAuth{}, err
506 }
507
508 err = c.db.InsertSMTPAuthUser(ctx, name, hash[:])
509 if err != nil {
510 return model.SMTPAuth{}, fmt.Errorf("inserting smtp auth %q: %w", name, err)
511 }
512
513 // Return smtp auth with password
514 auth := model.SMTPAuth{
515 ID: 0, // Will be set by DB
516 Name: name,
517 Password: password,
518 }
519 auth.SetPassword(hash[:])
520
521 return auth, nil
522}
523
524// RemoveSMTPAuth removes an SMTP authentication user by name.
525func (c *Command) RemoveSMTPAuth(ctx context.Context, name string) error {
526 if exists, err := c.db.SMTPAuthNameExists(ctx, name); err != nil {
527 return err
528 } else if !exists {
529 return fmt.Errorf("%w: user %q", model.ErrNotFound, name)
530 }
531
532 return c.db.RemoveSMTPAuthUser(ctx, name)
533}
534
535// ListSMTPAuth returns all SMTP authentication users.
536func (c *Command) ListSMTPAuth(ctx context.Context) ([]model.SMTPAuth, error) {
537 return c.db.ListSMTPAuthUsers(ctx)
538}
539
540// AddDKIM adds a new DKIM selector and returns the DNS record.
541func (c *Command) AddDKIM(ctx context.Context, selector, domain string) (string, error) {
542 return c.dkim.Add(ctx, selector, domain)
543}
544
545// ListDKIM returns all DKIM selectors.
546func (c *Command) ListDKIM(ctx context.Context) ([]model.DKIM, error) {
547 return c.dkim.List(ctx)
548}
549
550// GetDKIM returns the public DNS record for a DKIM selector.
551func (c *Command) GetDKIM(ctx context.Context, selector, domain string) (string, error) {
552 return c.dkim.PublicDNSRecord(ctx, selector, domain)
553}
554
555// SetDKIMEnabled enables or disables a DKIM selector.
556func (c *Command) SetDKIMEnabled(ctx context.Context, selector, domain string, enable, force bool) error {
557 if enable {
558 if !force {
559 // Perform DNS validation when force is false
560 s := domainkeyName(selector, domain)
561 records, err := net.LookupTXT(s)
562 if err != nil {
563 return fmt.Errorf("resolving TXT records: %w", err)
564 }
565
566 expected, err := c.dkim.PublicDNSRecord(ctx, selector, domain)
567 if err != nil {
568 return err
569 }
570
571 if expected != strings.Join(records, "") {
572 return errors.New("DKIM record does not match the expected record")
573 }
574 }
575 }
576
577 return c.dkim.SetEnabled(ctx, selector, domain, enable)
578}
579
580// RemoveDKIM removes a DKIM selector.
581func (c *Command) RemoveDKIM(ctx context.Context, selector, domain string) error {
582 return c.dkim.Remove(ctx, selector, domain)
583}