internal/cli/cli.go (view raw)
1package cli
2
3import (
4 "context"
5 "fmt"
6 "log/slog"
7 "net/http"
8 "os"
9 "os/signal"
10 "sync"
11 "syscall"
12
13 "postern/internal/db"
14 "postern/internal/dkim"
15 "postern/internal/persistence"
16
17 "github.com/spf13/cobra"
18)
19
20type Service interface {
21 Start() error
22 Stop() error
23 Status() string
24}
25
26// BootstrapDeps holds the resources created by the Bootstrap closure, which
27// are only required when running the "serve" command.
28type BootstrapDeps struct {
29 DB *db.DB
30 Persistence *persistence.Persistence
31 DKIM *dkim.DKIM
32 Services map[string]Service
33}
34
35type Config struct {
36 // SocketPath is always required; non-serve commands use it to reach the
37 // running daemon over a unix socket.
38 SocketPath string
39 // Bootstrap is invoked lazily by the "serve" command to create the DB,
40 // persistence, DKIM, and network services. It may be nil when deps are
41 // supplied directly (e.g. in tests).
42 Bootstrap func() (*BootstrapDeps, error)
43
44 // Optional pre-populated dependencies (for tests / direct usage).
45 DB *db.DB
46 Persistence *persistence.Persistence
47 DKIM *dkim.DKIM
48 Services map[string]Service
49}
50
51type Command struct {
52 db *db.DB
53 persistence *persistence.Persistence
54 dkim *dkim.DKIM
55 services map[string]Service
56 socketPath string
57 httpClient *http.Client
58 bootstrap func() (*BootstrapDeps, error)
59}
60
61func New(config *Config) (*Command, error) {
62 return &Command{
63 db: config.DB,
64 persistence: config.Persistence,
65 dkim: config.DKIM,
66 services: config.Services,
67 socketPath: config.SocketPath,
68 httpClient: newUnixHTTPClient(config.SocketPath),
69 bootstrap: config.Bootstrap,
70 }, nil
71}
72
73func (c *Command) rootCmd() *cobra.Command {
74 rootCmd := &cobra.Command{
75 Use: "postern",
76 Short: "Modern mail management",
77 }
78
79 serveCmd := &cobra.Command{
80 Use: "serve",
81 Short: "Start the mail server",
82 RunE: c.startServer,
83 }
84
85 pruneCmd := &cobra.Command{
86 Use: "prune",
87 Short: "Delete orphaned mails",
88 RunE: c.pruneMails,
89 }
90
91 userCmd := &cobra.Command{
92 Use: "user",
93 Short: "Manage local login accounts",
94 }
95
96 userListCmd := &cobra.Command{
97 Use: "list",
98 Short: "List all users",
99 RunE: c.listUsers,
100 }
101
102 addUserCmd := &cobra.Command{
103 Use: "add <username>",
104 Short: "Add a user",
105 Args: cobra.ExactArgs(1),
106 RunE: c.addUser,
107 }
108
109 addUserAddressCmd := &cobra.Command{
110 Use: "add-address <username> <address>",
111 Short: "Add an address for a user",
112 Args: cobra.ExactArgs(2),
113 RunE: c.addUserAddress,
114 }
115
116 removeAddressCmd := &cobra.Command{
117 Use: "remove-address <username> <address>",
118 Short: "Remove an address for a user",
119 Args: cobra.ExactArgs(2),
120 RunE: c.removeUserAddress,
121 }
122
123 listAddressesCmd := &cobra.Command{
124 Use: "list-addresses <username>",
125 Short: "List all addresses for a user",
126 Args: cobra.ExactArgs(1),
127 RunE: c.listUserAddresses,
128 }
129
130 userRemoveCmd := &cobra.Command{
131 Use: "remove <username>",
132 Short: "Remove a user",
133 Args: cobra.ExactArgs(1),
134 RunE: c.removeUser,
135 }
136
137 rotatePasswordCmd := &cobra.Command{
138 Use: "rotate-password <username>",
139 Short: "Generate a new password for a user",
140 Args: cobra.ExactArgs(1),
141 RunE: c.rotateUserPassword,
142 }
143
144 userCmd.AddCommand(userListCmd)
145 userCmd.AddCommand(addUserCmd)
146 userCmd.AddCommand(addUserAddressCmd)
147 userCmd.AddCommand(removeAddressCmd)
148 userCmd.AddCommand(listAddressesCmd)
149 userCmd.AddCommand(userRemoveCmd)
150 userCmd.AddCommand(rotatePasswordCmd)
151
152 backupMxCmd := &cobra.Command{
153 Use: "backup-mx",
154 Short: "Manage inbound SMTP authentication",
155 }
156
157 backupMxListCmd := &cobra.Command{
158 Use: "list",
159 Short: "List all SMTP auth users",
160 Args: cobra.NoArgs,
161 RunE: c.listSMTPAuth,
162 }
163
164 backupMxAddCmd := &cobra.Command{
165 Use: "add <name>",
166 Short: "Add SMTP auth user",
167 Args: cobra.ExactArgs(1),
168 RunE: c.addSMTPAuth,
169 }
170
171 backupMxRemoveCmd := &cobra.Command{
172 Use: "remove <name>",
173 Short: "Remove SMTP auth user",
174 Args: cobra.ExactArgs(1),
175 RunE: c.removeSMTPAuth,
176 }
177
178 backupMxCmd.AddCommand(backupMxListCmd)
179 backupMxCmd.AddCommand(backupMxAddCmd)
180 backupMxCmd.AddCommand(backupMxRemoveCmd)
181
182 dkimCmd := &cobra.Command{
183 Use: "dkim",
184 Short: "Manage DKIM selectors",
185 }
186
187 dkimListCmd := &cobra.Command{
188 Use: "list",
189 Short: "List all available selectors",
190 Args: cobra.NoArgs,
191 RunE: c.listDKIM,
192 }
193
194 addDKIMCmd := &cobra.Command{
195 Use: "add [selector] [domain]",
196 Short: "Add DKIM selector for domain",
197 Args: cobra.ExactArgs(2),
198 RunE: c.addDKIM,
199 }
200
201 getDKIMCmd := &cobra.Command{
202 Use: "get [selector] [domain]",
203 Short: "Get public DKIM record for the given selector and domain",
204 Args: cobra.ExactArgs(2),
205 RunE: c.getDKIM,
206 }
207
208 enableDKIMCmd := &cobra.Command{
209 Use: "enable [selector] [domain]",
210 Short: "Enable DKIM selector",
211 Args: cobra.ExactArgs(2),
212 RunE: func(cmd *cobra.Command, args []string) error {
213 return c.setDKIMEnabled(cmd, args, true)
214 },
215 }
216 enableDKIMCmd.Flags().BoolP("force", "f", false, "Force enable DKIM even if validation fails")
217
218 disableDKIMCmd := &cobra.Command{
219 Use: "disable [selector] [domain]",
220 Short: "Disable DKIM selector",
221 Args: cobra.ExactArgs(2),
222 RunE: func(cmd *cobra.Command, args []string) error {
223 return c.setDKIMEnabled(cmd, args, false)
224 },
225 }
226
227 dkimRemoveCmd := &cobra.Command{
228 Use: "remove [selector] [domain]",
229 Short: "Remove DKIM selector",
230 Args: cobra.ExactArgs(2),
231 RunE: c.removeDKIM,
232 }
233
234 dkimCmd.AddCommand(dkimListCmd)
235 dkimCmd.AddCommand(addDKIMCmd)
236 dkimCmd.AddCommand(getDKIMCmd)
237 dkimCmd.AddCommand(enableDKIMCmd)
238 dkimCmd.AddCommand(disableDKIMCmd)
239 dkimCmd.AddCommand(dkimRemoveCmd)
240
241 kvCmd := &cobra.Command{
242 Use: "kv",
243 Short: "Inspect the key-value store",
244 }
245
246 kvListCmd := &cobra.Command{
247 Use: "list",
248 Short: "List all keys",
249 Args: cobra.NoArgs,
250 RunE: c.kvList,
251 }
252
253 kvGetCmd := &cobra.Command{
254 Use: "get <key>",
255 Short: "Get the value for a key",
256 Args: cobra.ExactArgs(1),
257 RunE: c.kvGet,
258 }
259
260 kvPopCmd := &cobra.Command{
261 Use: "pop <key>",
262 Short: "Remove key and return its value",
263 Args: cobra.ExactArgs(1),
264 RunE: c.kvPop,
265 }
266
267 kvCmd.AddCommand(kvListCmd)
268 kvCmd.AddCommand(kvGetCmd)
269 kvCmd.AddCommand(kvPopCmd)
270
271 rootCmd.AddCommand(serveCmd)
272 rootCmd.AddCommand(pruneCmd)
273 rootCmd.AddCommand(userCmd)
274 rootCmd.AddCommand(backupMxCmd)
275 rootCmd.AddCommand(dkimCmd)
276 rootCmd.AddCommand(kvCmd)
277
278 return rootCmd
279}
280
281func (c *Command) Execute() error {
282 return c.rootCmd().Execute()
283}
284
285func (c *Command) startServer(_ *cobra.Command, _ []string) error {
286 // Lazy bootstrap: only initialize resources when serve command is run
287 if c.bootstrap != nil {
288 deps, err := c.bootstrap()
289 if err != nil {
290 return err
291 }
292 c.db = deps.DB
293 c.persistence = deps.Persistence
294 c.dkim = deps.DKIM
295 c.services = deps.Services
296 }
297
298 fmt.Println("Starting postern server...")
299 slog.SetLogLoggerLevel(slog.LevelDebug)
300
301 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
302 defer stop()
303
304 // The admin socket always runs and can't be configured in the policy.star
305 c.services["http"] = NewHTTPService(c.socketPath, c)
306
307 var wg sync.WaitGroup
308 for name, svc := range c.services {
309 wg.Go(func() {
310 err := svc.Start()
311 if err != nil {
312 slog.Error("running service", "name", name, "error", err)
313 }
314 })
315 }
316
317 go func() {
318 <-ctx.Done()
319 slog.Info("shutdown signal received, stopping services")
320 for name, svc := range c.services {
321 if err := svc.Stop(); err != nil {
322 slog.Error("stopping service", "name", name, "error", err)
323 }
324 }
325 }()
326
327 wg.Wait() // wait for services
328
329 if c.db != nil {
330 err := c.db.Close()
331 if err != nil {
332 slog.Error("closing database connection", "error", err)
333 }
334 slog.Info("closing database connection")
335 }
336 return nil
337}