all repos — postern

@ 24b6f0d7bd5c4c748592f78a47165959a192fdc2

Modern mail management

clone
git clone https:///postern.git

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}