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