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 userRemoveCmd := &cobra.Command{
117 Use: "remove <username>",
118 Short: "Remove a user",
119 Args: cobra.ExactArgs(1),
120 RunE: c.removeUser,
121 }
122
123 rotatePasswordCmd := &cobra.Command{
124 Use: "rotate-password <username>",
125 Short: "Generate a new password for a user",
126 Args: cobra.ExactArgs(1),
127 RunE: c.rotateUserPassword,
128 }
129
130 userCmd.AddCommand(userListCmd)
131 userCmd.AddCommand(addUserCmd)
132 userCmd.AddCommand(addUserAddressCmd)
133 userCmd.AddCommand(userRemoveCmd)
134 userCmd.AddCommand(rotatePasswordCmd)
135
136 backupMxCmd := &cobra.Command{
137 Use: "backup-mx",
138 Short: "Manage inbound SMTP authentication",
139 }
140
141 backupMxListCmd := &cobra.Command{
142 Use: "list",
143 Short: "List all SMTP auth users",
144 Args: cobra.NoArgs,
145 RunE: c.listSMTPAuth,
146 }
147
148 backupMxAddCmd := &cobra.Command{
149 Use: "add <name>",
150 Short: "Add SMTP auth user",
151 Args: cobra.ExactArgs(1),
152 RunE: c.addSMTPAuth,
153 }
154
155 backupMxRemoveCmd := &cobra.Command{
156 Use: "remove <name>",
157 Short: "Remove SMTP auth user",
158 Args: cobra.ExactArgs(1),
159 RunE: c.removeSMTPAuth,
160 }
161
162 backupMxCmd.AddCommand(backupMxListCmd)
163 backupMxCmd.AddCommand(backupMxAddCmd)
164 backupMxCmd.AddCommand(backupMxRemoveCmd)
165
166 dkimCmd := &cobra.Command{
167 Use: "dkim",
168 Short: "Manage DKIM selectors",
169 }
170
171 dkimListCmd := &cobra.Command{
172 Use: "list",
173 Short: "List all available selectors",
174 Args: cobra.NoArgs,
175 RunE: c.listDKIM,
176 }
177
178 addDKIMCmd := &cobra.Command{
179 Use: "add [selector] [domain]",
180 Short: "Add DKIM selector for domain",
181 Args: cobra.ExactArgs(2),
182 RunE: c.addDKIM,
183 }
184
185 getDKIMCmd := &cobra.Command{
186 Use: "get [selector] [domain]",
187 Short: "Get public DKIM record for the given selector and domain",
188 Args: cobra.ExactArgs(2),
189 RunE: c.getDKIM,
190 }
191
192 enableDKIMCmd := &cobra.Command{
193 Use: "enable [selector] [domain]",
194 Short: "Enable DKIM selector",
195 Args: cobra.ExactArgs(2),
196 RunE: func(cmd *cobra.Command, args []string) error {
197 return c.setDKIMEnabled(cmd, args, true)
198 },
199 }
200 enableDKIMCmd.Flags().BoolP("force", "f", false, "Force enable DKIM even if validation fails")
201
202 disableDKIMCmd := &cobra.Command{
203 Use: "disable [selector] [domain]",
204 Short: "Disable DKIM selector",
205 Args: cobra.ExactArgs(2),
206 RunE: func(cmd *cobra.Command, args []string) error {
207 return c.setDKIMEnabled(cmd, args, false)
208 },
209 }
210
211 dkimRemoveCmd := &cobra.Command{
212 Use: "remove [selector] [domain]",
213 Short: "Remove DKIM selector",
214 Args: cobra.ExactArgs(2),
215 RunE: c.removeDKIM,
216 }
217
218 dkimCmd.AddCommand(dkimListCmd)
219 dkimCmd.AddCommand(addDKIMCmd)
220 dkimCmd.AddCommand(getDKIMCmd)
221 dkimCmd.AddCommand(enableDKIMCmd)
222 dkimCmd.AddCommand(disableDKIMCmd)
223 dkimCmd.AddCommand(dkimRemoveCmd)
224
225 kvCmd := &cobra.Command{
226 Use: "kv",
227 Short: "Inspect the key-value store",
228 }
229
230 kvListCmd := &cobra.Command{
231 Use: "list",
232 Short: "List all keys",
233 Args: cobra.NoArgs,
234 RunE: c.kvList,
235 }
236
237 kvGetCmd := &cobra.Command{
238 Use: "get <key>",
239 Short: "Get the value for a key",
240 Args: cobra.ExactArgs(1),
241 RunE: c.kvGet,
242 }
243
244 kvPopCmd := &cobra.Command{
245 Use: "pop <key>",
246 Short: "Remove key and return its value",
247 Args: cobra.ExactArgs(1),
248 RunE: c.kvPop,
249 }
250
251 kvCmd.AddCommand(kvListCmd)
252 kvCmd.AddCommand(kvGetCmd)
253 kvCmd.AddCommand(kvPopCmd)
254
255 rootCmd.AddCommand(serveCmd)
256 rootCmd.AddCommand(pruneCmd)
257 rootCmd.AddCommand(userCmd)
258 rootCmd.AddCommand(backupMxCmd)
259 rootCmd.AddCommand(dkimCmd)
260 rootCmd.AddCommand(kvCmd)
261
262 return rootCmd
263}
264
265func (c *Command) Execute() error {
266 return c.rootCmd().Execute()
267}
268
269func (c *Command) startServer(_ *cobra.Command, _ []string) error {
270 // Lazy bootstrap: only initialize resources when serve command is run
271 if c.bootstrap != nil {
272 deps, err := c.bootstrap()
273 if err != nil {
274 return err
275 }
276 c.db = deps.DB
277 c.persistence = deps.Persistence
278 c.dkim = deps.DKIM
279 c.services = deps.Services
280 }
281
282 fmt.Println("Starting postern server...")
283 slog.SetLogLoggerLevel(slog.LevelDebug)
284
285 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
286 defer stop()
287
288 // The admin socket always runs and can't be configured in the policy.star
289 c.services["http"] = NewHTTPService(c.socketPath, c)
290
291 var wg sync.WaitGroup
292 for name, svc := range c.services {
293 wg.Go(func() {
294 err := svc.Start()
295 if err != nil {
296 slog.Error("running service", "name", name, "error", err)
297 }
298 })
299 }
300
301 go func() {
302 <-ctx.Done()
303 slog.Info("shutdown signal received, stopping services")
304 for name, svc := range c.services {
305 if err := svc.Stop(); err != nil {
306 slog.Error("stopping service", "name", name, "error", err)
307 }
308 }
309 }()
310
311 wg.Wait() // wait for services
312
313 if c.db != nil {
314 err := c.db.Close()
315 if err != nil {
316 slog.Error("closing database connection", "error", err)
317 }
318 slog.Info("closing database connection")
319 }
320 return nil
321}