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) addDKIM(cmd *cobra.Command, args []string) error {
116 cmd.SilenceUsage = true
117 selector := args[0]
118 domain := args[1]
119
120 record, err := c.clientAddDKIM(cmd.Context(), selector, domain)
121 if err != nil {
122 return err
123 }
124
125 fmt.Println("Create a DNS TXT record for", domainkeyName(selector, domain))
126 fmt.Println("Remember to enable this selector once it was made available through DNS. (dkim enable)")
127 fmt.Println(record)
128 return nil
129}
130
131func (c *Command) listDKIM(cmd *cobra.Command, _ []string) error {
132 cmd.SilenceUsage = true // suppress runtime error help text
133
134 dkims, err := c.clientListDKIM(cmd.Context())
135 if err != nil {
136 fmt.Println("Error list DKIM")
137 return err
138 }
139
140 if len(dkims) == 0 {
141 fmt.Println("No DKIM records found")
142 return nil
143 }
144
145 w := tabwriter.NewWriter(os.Stdout, 1, 1, 1, ' ', 0)
146 _, _ = fmt.Fprintln(w, "Identifier\tKey Type\tEnabled")
147 for _, dkim := range dkims {
148 _, _ = fmt.Fprintf(w, "%s\t%s\t%v\n", domainkeyName(dkim.Selector, dkim.Domain), dkim.KeyType, dkim.Enabled)
149 }
150 _ = w.Flush()
151
152 return nil
153}
154
155func (c *Command) getDKIM(cmd *cobra.Command, args []string) error {
156 cmd.SilenceUsage = true
157 selector := args[0]
158 domain := args[1]
159
160 record, err := c.clientGetDKIM(cmd.Context(), selector, domain)
161 if err != nil {
162 return err
163 }
164
165 fmt.Println("Create a DNS TXT record for", domainkeyName(selector, domain))
166 fmt.Println("Remember to enable this selector once it was made available through DNS. (dkim enable)")
167 fmt.Println(record)
168
169 return nil
170}
171
172func (c *Command) setDKIMEnabled(cmd *cobra.Command, args []string, enable bool) error {
173 cmd.SilenceUsage = true
174 selector := args[0]
175 domain := args[1]
176
177 // For cobra, we respect the force flag
178 force, err := cmd.Flags().GetBool("force")
179 if err != nil {
180 return err
181 }
182
183 err = c.clientSetDKIMEnabled(cmd.Context(), selector, domain, enable, force)
184 if err != nil {
185 return err
186 }
187
188 if enable {
189 fmt.Printf("Enabling DKIM selector %q for domain %q\n", selector, domain)
190 } else {
191 fmt.Printf("Disabling DKIM selector %q for domain %q\n", selector, domain)
192 }
193 return nil
194}
195
196func (c *Command) removeDKIM(cmd *cobra.Command, args []string) error {
197 cmd.SilenceUsage = true
198 selector := args[0]
199 domain := args[1]
200
201 err := c.clientRemoveDKIM(cmd.Context(), selector, domain)
202 if err != nil {
203 return err
204 }
205
206 return nil
207}
208
209func (c *Command) listSMTPAuth(cmd *cobra.Command, _ []string) error {
210 cmd.SilenceUsage = true
211
212 smtpAuths, err := c.clientListSMTPAuth(cmd.Context())
213 if err != nil {
214 fmt.Println("Error listing SMTP auth users")
215 return err
216 }
217
218 w := tabwriter.NewWriter(os.Stdout, 1, 1, 1, ' ', 0)
219 _, _ = fmt.Fprintln(w, "Name")
220 for _, auth := range smtpAuths {
221 _, _ = fmt.Fprintf(w, "%s\n", auth.Name)
222 }
223 _ = w.Flush()
224
225 return nil
226}
227
228func (c *Command) addSMTPAuth(cmd *cobra.Command, args []string) error {
229 cmd.SilenceUsage = true // suppress runtime error help text
230 ctx := cmd.Context()
231 name := args[0]
232
233 auth, err := c.clientCreateSMTPAuth(ctx, name)
234 if err != nil {
235 return err
236 }
237
238 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "SMTP Auth %q created. The password will only be shown once.\n", name)
239 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Password: %s\n", auth.Password)
240 return nil
241}
242
243func (c *Command) removeSMTPAuth(cmd *cobra.Command, args []string) error {
244 cmd.SilenceUsage = true // suppress runtime error help text
245 ctx := cmd.Context()
246 name := args[0]
247
248 err := c.clientRemoveSMTPAuth(ctx, name)
249 if err != nil {
250 return err
251 }
252
253 _, _ = fmt.Fprintf(cmd.OutOrStdout(), "User %q removed.\n", name)
254 return nil
255}
256
257func domainkeyName(selector, domain string) string {
258 if !strings.HasSuffix(domain, ".") {
259 domain = domain + "."
260 }
261 return fmt.Sprintf("%s._domainkey.%s", selector, domain)
262}
263
264// Define the character set:
265// - Numbers: 2-9 (No 0, 1)
266// - Uppercase: A-Z (No I, O)
267// - Lowercase: a-z (No l, o)
268const letterBytes = "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz"
269
270func generateSafeToken(length int) (string, error) {
271 if length <= 0 {
272 return "", fmt.Errorf("length must be greater than 0")
273 }
274
275 ret := make([]byte, length)
276
277 // We calculate the max index based on the charset length
278 charsetLen := big.NewInt(int64(len(letterBytes)))
279
280 for i := range length {
281 // rand.Int returns a uniform random value in [0, max).
282 // It automatically handles modulo bias, ensuring an even distribution.
283 num, err := rand.Int(rand.Reader, charsetLen)
284 if err != nil {
285 return "", err
286 }
287
288 // Use the random number as an index to pick a character
289 ret[i] = letterBytes[num.Int64()]
290 }
291
292 return string(ret), nil
293}
294
295// CreateUser creates a new user with a generated password and returns the user with the password.
296func (c *Command) CreateUser(ctx context.Context, username string) (model.User, error) {
297 if exists, err := c.db.UserExists(ctx, username); err != nil {
298 return model.User{}, err
299 } else if exists {
300 return model.User{}, fmt.Errorf("%w: user %q", model.ErrAlreadyExists, username)
301 }
302
303 password, err := generateSafeToken(16)
304 if err != nil {
305 return model.User{}, err
306 }
307
308 hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
309 if err != nil {
310 return model.User{}, err
311 }
312
313 tx, err := c.db.GetWriteTx(ctx)
314 if err != nil {
315 return model.User{}, fmt.Errorf("beginning transaction: %w", err)
316 }
317
318 userID, err := db.InsertUser(ctx, tx, username, hash[:])
319 if err != nil {
320 txErr := tx.Rollback()
321 if txErr != nil {
322 slog.Error("rolling back transaction", "error", err)
323 }
324 return model.User{}, fmt.Errorf("inserting user %q: %w", username, err)
325 }
326
327 if err := db.InsertDefaultMailboxes(ctx, tx, userID); err != nil {
328 txErr := tx.Rollback()
329 if txErr != nil {
330 slog.Error("rolling back transaction", "error", err)
331 }
332 return model.User{}, fmt.Errorf("inserting default mailboxes: %w", err)
333 }
334
335 if err := tx.Commit(); err != nil {
336 return model.User{}, fmt.Errorf("committing transaction: %w", err)
337 }
338
339 // subscribe user to all mailboxes
340 allMailboxes, err := c.db.GetUserMailboxes(ctx, userID)
341 if err != nil {
342 return model.User{}, err
343 }
344
345 for _, mb := range allMailboxes {
346 err = c.db.Subscribe(ctx, userID, mb.ID, mb.Name)
347 if err != nil {
348 return model.User{}, fmt.Errorf("subscribing mailbox %q: %w", mb.Name, err)
349 }
350 slog.Debug("subscribed mailbox", "mailbox", mb.Name)
351 }
352
353 // Return user with password
354 user := model.User{
355 ID: userID,
356 Name: username,
357 Addresses: []string{},
358 Password: password,
359 }
360 user.SetPassword(hash[:])
361
362 return user, nil
363}
364
365// RotateUserPassword generates a new password for an existing user and
366// returns the user with the new one-time plaintext password.
367func (c *Command) RotateUserPassword(ctx context.Context, username string) (model.User, error) {
368 if exists, err := c.db.UserExists(ctx, username); err != nil {
369 return model.User{}, err
370 } else if !exists {
371 return model.User{}, fmt.Errorf("%w: user %q", model.ErrNotFound, username)
372 }
373
374 password, err := generateSafeToken(16)
375 if err != nil {
376 return model.User{}, err
377 }
378
379 hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
380 if err != nil {
381 return model.User{}, err
382 }
383
384 if err := c.db.UpdateUserPassword(ctx, username, hash[:]); err != nil {
385 return model.User{}, err
386 }
387
388 return model.User{
389 Name: username,
390 Password: password,
391 }, nil
392}
393
394// RemoveUser removes a user by name.
395func (c *Command) RemoveUser(ctx context.Context, username string) error {
396 if exists, err := c.db.UserExists(ctx, username); err != nil {
397 return err
398 } else if !exists {
399 return fmt.Errorf("%w: user %q", model.ErrNotFound, username)
400 }
401
402 return c.db.RemoveUser(ctx, username)
403}
404
405// ListUsers returns all users.
406func (c *Command) ListUsers(ctx context.Context) ([]model.User, error) {
407 return c.db.ListUsers(ctx)
408}
409
410// AddUserAddress adds an email address to a user.
411func (c *Command) AddUserAddress(ctx context.Context, username, address string) error {
412 parsedAddress, err := mail.ParseAddress(address)
413 if err != nil {
414 return fmt.Errorf("%w: could not parse address %q", model.ErrInvalidInput, address)
415 }
416
417 i := strings.LastIndex(parsedAddress.Address, "@")
418 if i == -1 {
419 return fmt.Errorf("%w: invalid address does not have a domain part %q", model.ErrInvalidInput, parsedAddress.Address)
420 }
421
422 return c.db.InsertUserAddress(ctx, username, parsedAddress.Address)
423}
424
425// CreateSMTPAuth creates a new SMTP authentication user with a generated password and returns the user with the password.
426func (c *Command) CreateSMTPAuth(ctx context.Context, name string) (model.SMTPAuth, error) {
427 if exists, err := c.db.SMTPAuthNameExists(ctx, name); err != nil {
428 return model.SMTPAuth{}, err
429 } else if exists {
430 return model.SMTPAuth{}, fmt.Errorf("%w: user %q", model.ErrAlreadyExists, name)
431 }
432
433 password, err := generateSafeToken(16)
434 if err != nil {
435 return model.SMTPAuth{}, err
436 }
437
438 hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
439 if err != nil {
440 return model.SMTPAuth{}, err
441 }
442
443 err = c.db.InsertSMTPAuthUser(ctx, name, hash[:])
444 if err != nil {
445 return model.SMTPAuth{}, fmt.Errorf("inserting smtp auth %q: %w", name, err)
446 }
447
448 // Return smtp auth with password
449 auth := model.SMTPAuth{
450 ID: 0, // Will be set by DB
451 Name: name,
452 Password: password,
453 }
454 auth.SetPassword(hash[:])
455
456 return auth, nil
457}
458
459// RemoveSMTPAuth removes an SMTP authentication user by name.
460func (c *Command) RemoveSMTPAuth(ctx context.Context, name string) error {
461 if exists, err := c.db.SMTPAuthNameExists(ctx, name); err != nil {
462 return err
463 } else if !exists {
464 return fmt.Errorf("%w: user %q", model.ErrNotFound, name)
465 }
466
467 return c.db.RemoveSMTPAuthUser(ctx, name)
468}
469
470// ListSMTPAuth returns all SMTP authentication users.
471func (c *Command) ListSMTPAuth(ctx context.Context) ([]model.SMTPAuth, error) {
472 return c.db.ListSMTPAuthUsers(ctx)
473}
474
475// AddDKIM adds a new DKIM selector and returns the DNS record.
476func (c *Command) AddDKIM(ctx context.Context, selector, domain string) (string, error) {
477 return c.dkim.Add(ctx, selector, domain)
478}
479
480// ListDKIM returns all DKIM selectors.
481func (c *Command) ListDKIM(ctx context.Context) ([]model.DKIM, error) {
482 return c.dkim.List(ctx)
483}
484
485// GetDKIM returns the public DNS record for a DKIM selector.
486func (c *Command) GetDKIM(ctx context.Context, selector, domain string) (string, error) {
487 return c.dkim.PublicDNSRecord(ctx, selector, domain)
488}
489
490// SetDKIMEnabled enables or disables a DKIM selector.
491func (c *Command) SetDKIMEnabled(ctx context.Context, selector, domain string, enable, force bool) error {
492 if enable {
493 if !force {
494 // Perform DNS validation when force is false
495 s := domainkeyName(selector, domain)
496 records, err := net.LookupTXT(s)
497 if err != nil {
498 return fmt.Errorf("resolving TXT records: %w", err)
499 }
500
501 expected, err := c.dkim.PublicDNSRecord(ctx, selector, domain)
502 if err != nil {
503 return err
504 }
505
506 if expected != strings.Join(records, "") {
507 return errors.New("DKIM record does not match the expected record")
508 }
509 }
510 }
511
512 return c.dkim.SetEnabled(ctx, selector, domain, enable)
513}
514
515// RemoveDKIM removes a DKIM selector.
516func (c *Command) RemoveDKIM(ctx context.Context, selector, domain string) error {
517 return c.dkim.Remove(ctx, selector, domain)
518}