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