use socket for CLI communication Instead of configuring the CLI with the "data" path, masterkey etc., it will now access a unix socket for admin tasks.
@@ -70,11 +70,26 @@ 5. Enable and start Postern `systemctl enable postern` and `systemctl start postern`
6. Use the Postern CLI to add configuration. Based on the systemd integration, this is admittedly clunky and will be improved soon. +When running the Postern CLI, it will communicate with the **running** Postern daemon over a unix socket. +The used socket is logged during startup and can be configured with environment variable `POSTERN_SOCKET`. + ```Shell -# The masterkey file is only decrypted with a running postern service and only accessible by root. -# (this needs to be changed soon) -CONFIGURATION_DIRECTORY=/etc/postern POSTERN_PERSISTENCE_DIR=/var/lib/postern POSTERN_MASTERKEY_FILE=/run/credentials/postern.service/masterkey /usr/local/bin/postern +export POSTERN_SOCKET=/run/postern/postern.sock +/usr/local/bin/postern ``` + +### CLI admin access + +Unix sockets have an Owner and a Group like any other file on Unix or Linux. +Instead of creating an admin user and a password in Postern, access is guarded by existing Unix file permissions. + +*root* should always have access. The systemd Postern user also has access, even though in the default configuration +it is dynamically created by systemd. + +If you want to use a non-root and non-Postern user for the CLI, have the systemd service run under a specific group +that your non-root user also belongs to. + +### First configuration steps (example) You will need to create a user, a backup-mx user for email delivery and a DKIM key.
@@ -1,6 +1,6 @@
module postern -go 1.26.5 +go 1.27 require ( github.com/emersion/go-imap/v2 v2.0.0-beta.8@@ -10,10 +10,9 @@ github.com/emersion/go-smtp v0.25.0
github.com/google/uuid v1.6.0 github.com/minio/sio v0.5.1 github.com/spf13/cobra v1.10.2 - go.starlark.net v0.0.0-20260708150628-5395d018f003 - golang.org/x/crypto v0.55.0 - golang.org/x/term v0.45.0 - modernc.org/sqlite v1.56.0 + go.starlark.net v0.0.0-20260904161901-6ecada49e42f + golang.org/x/crypto v0.56.0 + modernc.org/sqlite v1.58.0 ) require (@@ -25,7 +24,7 @@ github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/spf13/pflag v1.0.10 // indirect golang.org/x/sys v0.47.0 // indirect - modernc.org/libc v1.75.3 // indirect + modernc.org/libc v1.75.7 // indirect modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.12.0 // indirect + modernc.org/memory v1.12.1 // indirect )
@@ -38,11 +38,15 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.starlark.net v0.0.0-20260708150628-5395d018f003 h1:cAxcqHgW8fnmT0cEBU3TzvVYHIFt8IIGDMWUF6rImk4= go.starlark.net v0.0.0-20260708150628-5395d018f003/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo= +go.starlark.net v0.0.0-20260904161901-6ecada49e42f h1:m+AwfAsRB2E9t6xjYPQ/0quPxDjXihZSjVr03BmwTs8= +go.starlark.net v0.0.0-20260904161901-6ecada49e42f/go.mod h1:Iue6g6iirlfLoVi/DYCi5/x0h/bAOuWF3dULTKpt2Vo= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=@@ -67,8 +71,6 @@ golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=@@ -98,16 +100,22 @@ modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.75.3 h1:vCqT5+R0jPXMnvMkGo0T2zXvFNth+lYXVCx5X7CCX/g= modernc.org/libc v1.75.3/go.mod h1:MjAX68G+0oufI+hNuh0QXcK+Ap+sL8bNPPcIC6EqOfo= +modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew= +modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.12.0 h1:twkmYNkGXCvtYWzoux02jtK6eovjZbdI0uHFUYp6kuU= modernc.org/memory v1.12.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +modernc.org/sqlite v1.58.0 h1:38u40/bwkfM7f0Myhosl+SEMltSDxnGdQf8o6Kjmys0= +modernc.org/sqlite v1.58.0/go.mod h1:rsD2CckafgObKC4DhBlGBf+RiHxkc3hINGt1Xw32tVY= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
@@ -1,6 +1,7 @@
package cli import ( + "context" "crypto/rand" "errors" "fmt"@@ -13,6 +14,7 @@ "strings"
"text/tabwriter" "postern/internal/db" + "postern/internal/model" "github.com/spf13/cobra" "golang.org/x/crypto/bcrypt"@@ -23,65 +25,13 @@ cmd.SilenceUsage = true // suppress runtime error help text
ctx := cmd.Context() username := args[0] - if exists, err := c.db.UserExists(ctx, username); err != nil || exists { - if err != nil { - return err - } - return fmt.Errorf("user %q already exists", username) - } - - password, err := generateSafeToken(16) - if err != nil { - return err - } - - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + user, err := c.clientCreateUser(ctx, username) if err != nil { return err } - tx, err := c.db.GetWriteTx(ctx) - if err != nil { - return fmt.Errorf("beginning transaction: %w", err) - } - - userID, err := db.InsertUser(ctx, tx, username, hash[:]) - if err != nil { - txErr := tx.Rollback() - if txErr != nil { - slog.Error("rolling back transaction", "error", err) - } - return fmt.Errorf("inserting user %q: %w", username, err) - } - - if err := db.InsertDefaultMailboxes(ctx, tx, userID); err != nil { - txErr := tx.Rollback() - if txErr != nil { - slog.Error("rolling back transaction", "error", err) - } - return fmt.Errorf("inserting default mailboxes: %w", err) - } - - if err := tx.Commit(); err != nil { - return fmt.Errorf("committing transaction: %w", err) - } - - // subscribe user to all mailboxes - allMailboxes, err := c.db.GetUserMailboxes(ctx, userID) - if err != nil { - return err - } - - for _, mb := range allMailboxes { - err = c.db.Subscribe(ctx, userID, mb.ID, mb.Name) - if err != nil { - return fmt.Errorf("subscribing mailbox %q: %w", mb.Name, err) - } - slog.Debug("subscribed mailbox", "mailbox", mb.Name) - } - - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "User %q created. Your password will only be shown once.\n", username) - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Password: %s\n", password) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "User %q created. Your password will only be shown once.\n", user.Name) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Password: %s\n", user.Password) return nil }@@ -90,16 +40,9 @@ cmd.SilenceUsage = true // suppress runtime error help text
ctx := cmd.Context() username := args[0] - if exists, err := c.db.UserExists(ctx, username); err != nil || !exists { - if err != nil { - return err - } - return fmt.Errorf("user %q does not exist", username) - } - - err := c.db.RemoveUser(ctx, username) + err := c.clientRemoveUser(ctx, username) if err != nil { - return fmt.Errorf("removing user %q: %w", username, err) + return err } _, _ = fmt.Fprintf(cmd.OutOrStdout(), "User %q removed.\n", username)@@ -109,7 +52,7 @@
func (c *Command) listUsers(cmd *cobra.Command, _ []string) error { cmd.SilenceUsage = true - users, err := c.db.ListUsers(cmd.Context()) + users, err := c.clientListUsers(cmd.Context()) if err != nil { fmt.Println("Error listing users") return err@@ -131,23 +74,12 @@ ctx := cmd.Context()
username := args[0] address := args[1] - parsedAddress, err := mail.ParseAddress(address) + err := c.clientAddUserAddress(ctx, username, address) if err != nil { fmt.Printf("Could not parse address: %s\n", address) return err } - i := strings.LastIndex(parsedAddress.Address, "@") - if i == -1 { - fmt.Printf("invalid address does not have a domain part: %s\n", parsedAddress.Address) - return errors.New("invalid address") - } - - err = c.db.InsertUserAddress(ctx, username, parsedAddress.Address) - if err != nil { - return err - } - fmt.Printf("address <%s> added for %q\n", address, username) return nil }@@ -157,7 +89,7 @@ cmd.SilenceUsage = true
selector := args[0] domain := args[1] - record, err := c.dkim.Add(cmd.Context(), selector, domain) + record, err := c.clientAddDKIM(cmd.Context(), selector, domain) if err != nil { return err }@@ -171,7 +103,7 @@
func (c *Command) listDKIM(cmd *cobra.Command, _ []string) error { cmd.SilenceUsage = true // suppress runtime error help text - dkims, err := c.dkim.List(cmd.Context()) + dkims, err := c.clientListDKIM(cmd.Context()) if err != nil { fmt.Println("Error list DKIM") return err@@ -197,7 +129,7 @@ cmd.SilenceUsage = true
selector := args[0] domain := args[1] - record, err := c.dkim.PublicDNSRecord(cmd.Context(), selector, domain) + record, err := c.clientGetDKIM(cmd.Context(), selector, domain) if err != nil { return err }@@ -214,44 +146,20 @@ cmd.SilenceUsage = true
selector := args[0] domain := args[1] - if enable { - force, err := cmd.Flags().GetBool("force") - if err != nil { - return err - } + // For cobra, we respect the force flag + force, err := cmd.Flags().GetBool("force") + if err != nil { + return err + } - if !force { - s := domainkeyName(selector, domain) - records, err := net.LookupTXT(s) - if err != nil { - fmt.Println("resolving TXT records: %w", err) - } + err = c.clientSetDKIMEnabled(cmd.Context(), selector, domain, enable, force) + if err != nil { + return err + } - expected, err := c.dkim.PublicDNSRecord(cmd.Context(), selector, domain) - if err != nil { - return err - } - - if expected != strings.Join(records, "") { - fmt.Println("The DKIM record in DNS does not match the expected record.") - fmt.Println("To enable this record anyway, use '--force'.") - fmt.Println("Expected:") - fmt.Println(expected) - fmt.Println("Actual:") - fmt.Println(strings.Join(records, "")) - return errors.New("DKIM record does not match the expected record") - } - } - - err = c.dkim.SetEnabled(cmd.Context(), selector, domain, true) - if err != nil { - return err - } + if enable { fmt.Printf("Enabling DKIM selector %q for domain %q\n", selector, domain) } else { - if err := c.dkim.SetEnabled(cmd.Context(), selector, domain, false); err != nil { - return err - } fmt.Printf("Disabling DKIM selector %q for domain %q\n", selector, domain) } return nil@@ -262,13 +170,18 @@ cmd.SilenceUsage = true
selector := args[0] domain := args[1] - return c.dkim.Remove(cmd.Context(), selector, domain) + err := c.clientRemoveDKIM(cmd.Context(), selector, domain) + if err != nil { + return err + } + + return nil } func (c *Command) listSMTPAuth(cmd *cobra.Command, _ []string) error { cmd.SilenceUsage = true - smtpAuths, err := c.db.ListSMTPAuthUsers(cmd.Context()) + smtpAuths, err := c.clientListSMTPAuth(cmd.Context()) if err != nil { fmt.Println("Error listing SMTP auth users") return err@@ -289,30 +202,13 @@ cmd.SilenceUsage = true // suppress runtime error help text
ctx := cmd.Context() name := args[0] - if exists, err := c.db.SMTPAuthNameExists(ctx, name); err != nil || exists { - if err != nil { - return err - } - return fmt.Errorf("user %q already exists", name) - } - - password, err := generateSafeToken(16) + auth, err := c.clientCreateSMTPAuth(ctx, name) if err != nil { return err } - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - return err - } - - err = c.db.InsertSMTPAuthUser(ctx, name, hash[:]) - if err != nil { - return fmt.Errorf("inserting smtp auth %q: %w", name, err) - } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "SMTP Auth %q created. The password will only be shown once.\n", name) - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Password: %s\n", password) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Password: %s\n", auth.Password) return nil }@@ -321,14 +217,7 @@ cmd.SilenceUsage = true // suppress runtime error help text
ctx := cmd.Context() name := args[0] - if exists, err := c.db.SMTPAuthNameExists(ctx, name); err != nil || !exists { - if err != nil { - return err - } - return fmt.Errorf("user %q does not exist", name) - } - - err := c.db.RemoveSMTPAuthUser(ctx, name) + err := c.clientRemoveSMTPAuth(ctx, name) if err != nil { return err }@@ -374,3 +263,199 @@ }
return string(ret), nil } + +// CreateUser creates a new user with a generated password and returns the user with the password. +func (c *Command) CreateUser(ctx context.Context, username string) (model.User, error) { + if exists, err := c.db.UserExists(ctx, username); err != nil { + return model.User{}, err + } else if exists { + return model.User{}, fmt.Errorf("%w: user %q", model.ErrAlreadyExists, username) + } + + password, err := generateSafeToken(16) + if err != nil { + return model.User{}, err + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return model.User{}, err + } + + tx, err := c.db.GetWriteTx(ctx) + if err != nil { + return model.User{}, fmt.Errorf("beginning transaction: %w", err) + } + + userID, err := db.InsertUser(ctx, tx, username, hash[:]) + if err != nil { + txErr := tx.Rollback() + if txErr != nil { + slog.Error("rolling back transaction", "error", err) + } + return model.User{}, fmt.Errorf("inserting user %q: %w", username, err) + } + + if err := db.InsertDefaultMailboxes(ctx, tx, userID); err != nil { + txErr := tx.Rollback() + if txErr != nil { + slog.Error("rolling back transaction", "error", err) + } + return model.User{}, fmt.Errorf("inserting default mailboxes: %w", err) + } + + if err := tx.Commit(); err != nil { + return model.User{}, fmt.Errorf("committing transaction: %w", err) + } + + // subscribe user to all mailboxes + allMailboxes, err := c.db.GetUserMailboxes(ctx, userID) + if err != nil { + return model.User{}, err + } + + for _, mb := range allMailboxes { + err = c.db.Subscribe(ctx, userID, mb.ID, mb.Name) + if err != nil { + return model.User{}, fmt.Errorf("subscribing mailbox %q: %w", mb.Name, err) + } + slog.Debug("subscribed mailbox", "mailbox", mb.Name) + } + + // Return user with password + user := model.User{ + ID: userID, + Name: username, + Addresses: []string{}, + Password: password, + } + user.SetPassword(hash[:]) + + return user, nil +} + +// RemoveUser removes a user by name. +func (c *Command) RemoveUser(ctx context.Context, username string) error { + if exists, err := c.db.UserExists(ctx, username); err != nil { + return err + } else if !exists { + return fmt.Errorf("%w: user %q", model.ErrNotFound, username) + } + + return c.db.RemoveUser(ctx, username) +} + +// ListUsers returns all users. +func (c *Command) ListUsers(ctx context.Context) ([]model.User, error) { + return c.db.ListUsers(ctx) +} + +// AddUserAddress adds an email address to a user. +func (c *Command) AddUserAddress(ctx context.Context, username, address string) error { + parsedAddress, err := mail.ParseAddress(address) + if err != nil { + return fmt.Errorf("%w: could not parse address %q", model.ErrInvalidInput, address) + } + + i := strings.LastIndex(parsedAddress.Address, "@") + if i == -1 { + return fmt.Errorf("%w: invalid address does not have a domain part %q", model.ErrInvalidInput, parsedAddress.Address) + } + + return c.db.InsertUserAddress(ctx, username, parsedAddress.Address) +} + +// CreateSMTPAuth creates a new SMTP authentication user with a generated password and returns the user with the password. +func (c *Command) CreateSMTPAuth(ctx context.Context, name string) (model.SMTPAuth, error) { + if exists, err := c.db.SMTPAuthNameExists(ctx, name); err != nil { + return model.SMTPAuth{}, err + } else if exists { + return model.SMTPAuth{}, fmt.Errorf("%w: user %q", model.ErrAlreadyExists, name) + } + + password, err := generateSafeToken(16) + if err != nil { + return model.SMTPAuth{}, err + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return model.SMTPAuth{}, err + } + + err = c.db.InsertSMTPAuthUser(ctx, name, hash[:]) + if err != nil { + return model.SMTPAuth{}, fmt.Errorf("inserting smtp auth %q: %w", name, err) + } + + // Return smtp auth with password + auth := model.SMTPAuth{ + ID: 0, // Will be set by DB + Name: name, + Password: password, + } + auth.SetPassword(hash[:]) + + return auth, nil +} + +// RemoveSMTPAuth removes an SMTP authentication user by name. +func (c *Command) RemoveSMTPAuth(ctx context.Context, name string) error { + if exists, err := c.db.SMTPAuthNameExists(ctx, name); err != nil { + return err + } else if !exists { + return fmt.Errorf("%w: user %q", model.ErrNotFound, name) + } + + return c.db.RemoveSMTPAuthUser(ctx, name) +} + +// ListSMTPAuth returns all SMTP authentication users. +func (c *Command) ListSMTPAuth(ctx context.Context) ([]model.SMTPAuth, error) { + return c.db.ListSMTPAuthUsers(ctx) +} + +// AddDKIM adds a new DKIM selector and returns the DNS record. +func (c *Command) AddDKIM(ctx context.Context, selector, domain string) (string, error) { + return c.dkim.Add(ctx, selector, domain) +} + +// ListDKIM returns all DKIM selectors. +func (c *Command) ListDKIM(ctx context.Context) ([]model.DKIM, error) { + return c.dkim.List(ctx) +} + +// GetDKIM returns the public DNS record for a DKIM selector. +func (c *Command) GetDKIM(ctx context.Context, selector, domain string) (string, error) { + return c.dkim.PublicDNSRecord(ctx, selector, domain) +} + +// SetDKIMEnabled enables or disables a DKIM selector. +func (c *Command) SetDKIMEnabled(ctx context.Context, selector, domain string, enable, force bool) error { + if enable { + if !force { + // Perform DNS validation when force is false + s := domainkeyName(selector, domain) + records, err := net.LookupTXT(s) + if err != nil { + return fmt.Errorf("resolving TXT records: %w", err) + } + + expected, err := c.dkim.PublicDNSRecord(ctx, selector, domain) + if err != nil { + return err + } + + if expected != strings.Join(records, "") { + return errors.New("DKIM record does not match the expected record") + } + } + } + + return c.dkim.SetEnabled(ctx, selector, domain, enable) +} + +// RemoveDKIM removes a DKIM selector. +func (c *Command) RemoveDKIM(ctx context.Context, selector, domain string) error { + return c.dkim.Remove(ctx, selector, domain) +}
@@ -8,6 +8,7 @@ "os"
"path" "strings" "testing" + "time" "postern/internal/db" "postern/internal/persistence"@@ -62,6 +63,31 @@ if err != nil {
t.Fatal(err) } + // Start HTTP service on a temp socket for the test + tempSocket := path.Join(f, "test.sock") + c.socketPath = tempSocket + c.httpClient = newUnixHTTPClient(tempSocket) + + // Start the HTTP service in background + service := NewHTTPService(tempSocket, c) + go func() { + _ = service.Start() + }() + + timeout := time.After(10 * time.Second) + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for service.Status() != "running" { + select { + case <-timeout: + t.Fatal("timed out waiting for http service to start") + case <-ticker.C: + // Wait for next tick + } + } + + // Run the cobra command output, err := executeCommand(c.rootCmd(), "user", "add", "user1") if err != nil { t.Fatalf("expected no error, got: %v", err)@@ -73,6 +99,7 @@ if !found {
t.Fatal("expected to find a password in output. Found none") } + // Test that the user was created by directly accessing the DB u, err := c.db.GetUser(t.Context(), "user1") if err != nil { t.Fatalf("expected no error, got: %v", err)@@ -97,4 +124,7 @@ _, err = executeCommand(c.rootCmd(), "user", "remove", "user1")
if err == nil { t.Errorf("expected error when deleting non-existing user, got none") } + + // Clean up + _ = service.Stop() }
@@ -4,6 +4,7 @@ import (
"context" "fmt" "log/slog" + "net/http" "os" "os/signal" "sync"@@ -22,7 +23,25 @@ Stop() error
Status() string } +// BootstrapDeps holds the resources created by the Bootstrap closure, which +// are only required when running the "serve" command. +type BootstrapDeps struct { + DB *db.DB + Persistence *persistence.Persistence + DKIM *dkim.DKIM + Services map[string]Service +} + type Config struct { + // SocketPath is always required; non-serve commands use it to reach the + // running daemon over a unix socket. + SocketPath string + // Bootstrap is invoked lazily by the "serve" command to create the DB, + // persistence, DKIM, and network services. It may be nil when deps are + // supplied directly (e.g. in tests). + Bootstrap func() (*BootstrapDeps, error) + + // Optional pre-populated dependencies (for tests / direct usage). DB *db.DB Persistence *persistence.Persistence DKIM *dkim.DKIM@@ -34,6 +53,9 @@ db *db.DB
persistence *persistence.Persistence dkim *dkim.DKIM services map[string]Service + socketPath string + httpClient *http.Client + bootstrap func() (*BootstrapDeps, error) } func New(config *Config) (*Command, error) {@@ -42,6 +64,9 @@ db: config.DB,
persistence: config.Persistence, dkim: config.DKIM, services: config.Services, + socketPath: config.SocketPath, + httpClient: newUnixHTTPClient(config.SocketPath), + bootstrap: config.Bootstrap, }, nil }@@ -203,12 +228,27 @@ return c.rootCmd().Execute()
} func (c *Command) startServer(_ *cobra.Command, _ []string) error { + // Lazy bootstrap: only initialize resources when serve command is run + if c.bootstrap != nil { + deps, err := c.bootstrap() + if err != nil { + return err + } + c.db = deps.DB + c.persistence = deps.Persistence + c.dkim = deps.DKIM + c.services = deps.Services + } + fmt.Println("Starting postern server...") slog.SetLogLoggerLevel(slog.LevelDebug) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + // The admin socket always runs and can't be configured in the policy.star + c.services["http"] = NewHTTPService(c.socketPath, c) + var wg sync.WaitGroup for name, svc := range c.services { wg.Go(func() {@@ -229,6 +269,14 @@ }
} }() - wg.Wait() + wg.Wait() // wait for services + + if c.db != nil { + err := c.db.Close() + if err != nil { + slog.Error("closing database connection", "error", err) + } + slog.Info("closing database connection") + } return nil }
@@ -0,0 +1,330 @@
+package cli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "net/url" + + "postern/internal/model" +) + +func newUnixHTTPClient(socketPath string) *http.Client { + return &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "unix", socketPath) + }, + }, + } +} + +// clientListUsers fetches the list of users from the daemon. +func (c *Command) clientListUsers(ctx context.Context) ([]model.User, error) { + resp, err := c.httpClient.Get("http://unix/users") + if err != nil { + return nil, fmt.Errorf("failed to list users: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to list users: %d", resp.StatusCode) + } + + var users []model.User + if err := json.NewDecoder(resp.Body).Decode(&users); err != nil { + return nil, fmt.Errorf("failed to decode users response: %w", err) + } + + return users, nil +} + +// clientCreateUser creates a new user via the daemon. +func (c *Command) clientCreateUser(ctx context.Context, username string) (model.User, error) { + reqBody := map[string]string{"username": username} + body, err := json.Marshal(reqBody) + if err != nil { + return model.User{}, fmt.Errorf("failed to marshal user request: %w", err) + } + + resp, err := c.httpClient.Post("http://unix/users", "application/json", bytes.NewReader(body)) + if err != nil { + return model.User{}, fmt.Errorf("failed to create user: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + return model.User{}, fmt.Errorf("failed to create user: %d", resp.StatusCode) + } + + var user model.User + if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { + return model.User{}, fmt.Errorf("failed to decode user response: %w", err) + } + + return user, nil +} + +// clientRemoveUser removes a user via the daemon. +func (c *Command) clientRemoveUser(ctx context.Context, username string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, "http://unix/users/"+username, nil) + if err != nil { + return fmt.Errorf("failed to create delete user request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to remove user: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("failed to remove user: %d", resp.StatusCode) + } + + return nil +} + +// clientAddUserAddress adds an address for a user via the daemon. +func (c *Command) clientAddUserAddress(ctx context.Context, username, address string) error { + reqBody := map[string]string{"address": address} + body, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("failed to marshal user address request: %w", err) + } + + path, err := url.JoinPath("http://unix/users/", username, "addresses") + if err != nil { + return fmt.Errorf("failed to create address request: %w", err) + } + + resp, err := c.httpClient.Post(path, "application/json", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to add user address: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + return fmt.Errorf("failed to add user address: %d", resp.StatusCode) + } + + return nil +} + +// clientListSMTPAuth fetches the list of SMTP auth users from the daemon. +func (c *Command) clientListSMTPAuth(ctx context.Context) ([]model.SMTPAuth, error) { + resp, err := c.httpClient.Get("http://unix/smtp-auth") + if err != nil { + return nil, fmt.Errorf("failed to list smtp auth: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to list smtp auth: %d", resp.StatusCode) + } + + var auths []model.SMTPAuth + if err := json.NewDecoder(resp.Body).Decode(&auths); err != nil { + return nil, fmt.Errorf("failed to decode smtp auth response: %w", err) + } + + return auths, nil +} + +// clientCreateSMTPAuth creates a new SMTP auth user via the daemon. +func (c *Command) clientCreateSMTPAuth(ctx context.Context, name string) (model.SMTPAuth, error) { + reqBody := map[string]string{"name": name} + body, err := json.Marshal(reqBody) + if err != nil { + return model.SMTPAuth{}, fmt.Errorf("failed to marshal smtp auth request: %w", err) + } + + resp, err := c.httpClient.Post("http://unix/smtp-auth", "application/json", bytes.NewReader(body)) + if err != nil { + return model.SMTPAuth{}, fmt.Errorf("failed to create smtp auth: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + return model.SMTPAuth{}, fmt.Errorf("failed to create smtp auth: %d", resp.StatusCode) + } + + var auth model.SMTPAuth + if err := json.NewDecoder(resp.Body).Decode(&auth); err != nil { + return model.SMTPAuth{}, fmt.Errorf("failed to decode smtp auth response: %w", err) + } + + return auth, nil +} + +// clientRemoveSMTPAuth removes an SMTP auth user via the daemon. +func (c *Command) clientRemoveSMTPAuth(ctx context.Context, name string) error { + path, err := url.JoinPath("http://unix/smtp-auth", name) + if err != nil { + return fmt.Errorf("failed to create remove smtp auth request path: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, path, nil) + if err != nil { + return fmt.Errorf("failed to create delete smtp auth request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to remove smtp auth: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("failed to remove smtp auth: %d", resp.StatusCode) + } + + return nil +} + +// clientListDKIM fetches the list of DKIM selectors from the daemon. +func (c *Command) clientListDKIM(ctx context.Context) ([]model.DKIM, error) { + resp, err := c.httpClient.Get("http://unix/dkim") + if err != nil { + return nil, fmt.Errorf("failed to list dkim: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to list dkim: %d", resp.StatusCode) + } + + var dkims []model.DKIM + if err := json.NewDecoder(resp.Body).Decode(&dkims); err != nil { + return nil, fmt.Errorf("failed to decode dkim response: %w", err) + } + + return dkims, nil +} + +// clientAddDKIM adds a new DKIM selector via the daemon. +func (c *Command) clientAddDKIM(ctx context.Context, selector, domain string) (string, error) { + reqBody := map[string]string{"selector": selector, "domain": domain} + body, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("failed to marshal dkim request: %w", err) + } + + resp, err := c.httpClient.Post("http://unix/dkim", "application/json", bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("failed to add dkim: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to add dkim: %d", resp.StatusCode) + } + + var result map[string]string + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("failed to decode dkim response: %w", err) + } + + return result["dns_record"], nil +} + +// clientGetDKIM gets the public DNS record for a DKIM selector via the daemon. +func (c *Command) clientGetDKIM(ctx context.Context, selector, domain string) (string, error) { + path, err := url.JoinPath("http://unix/dkim", selector, domain) + if err != nil { + return "", fmt.Errorf("failed to create dkim request path: %w", err) + } + resp, err := c.httpClient.Get(path) + if err != nil { + return "", fmt.Errorf("failed to get dkim: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to get dkim: %d", resp.StatusCode) + } + + var result map[string]string + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("failed to decode dkim response: %w", err) + } + + return result["dns_record"], nil +} + +// clientSetDKIMEnabled enables/disables a DKIM selector via the daemon. +func (c *Command) clientSetDKIMEnabled(ctx context.Context, selector, domain string, enable bool, force bool) error { + enableKeyword := "enable" + if !enable { + enableKeyword = "disable" + } + + path, err := url.JoinPath("http://unix/dkim", selector, domain, enableKeyword) + if err != nil { + return fmt.Errorf("failed to create dkim request path: %w", err) + } + + // Include force parameter in query string + if !force { + path += "?force=false" + } + + resp, err := c.httpClient.Post(path, "application/json", nil) + if err != nil { + return fmt.Errorf("failed to set dkim enabled: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("failed to set dkim enabled: %d", resp.StatusCode) + } + + return nil +} + +// clientRemoveDKIM removes a DKIM selector via the daemon. +func (c *Command) clientRemoveDKIM(ctx context.Context, selector, domain string) error { + path, err := url.JoinPath("http://unix/dkim", selector, domain) + if err != nil { + return fmt.Errorf("failed to create dkim request path: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, path, nil) + if err != nil { + return fmt.Errorf("failed to create delete dkim request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to remove dkim: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("failed to remove dkim: %d", resp.StatusCode) + } + + return nil +} + +// clientPruneMails prunes orphaned mails via the daemon. +func (c *Command) clientPruneMails(ctx context.Context) (int, error) { + resp, err := c.httpClient.Post("http://unix/prune", "application/json", nil) + if err != nil { + return 0, fmt.Errorf("failed to prune mails: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("failed to prune mails: %d", resp.StatusCode) + } + + var result map[string]int + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return 0, fmt.Errorf("failed to decode prune response: %w", err) + } + + return result["pruned"], nil +}
@@ -0,0 +1,391 @@
+package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "path/filepath" + "time" + + "postern/internal/model" +) + +// HTTPService implements the cli.Service interface for serving HTTP endpoints. +type HTTPService struct { + cmd *Command + socketPath string + srv *http.Server +} + +// NewHTTPService creates a new HTTP service that serves REST endpoints. +func NewHTTPService(socketPath string, cmd *Command) *HTTPService { + return &HTTPService{ + cmd: cmd, + socketPath: socketPath, + } +} + +// Start starts the HTTP server. +func (h *HTTPService) Start() error { + mux := h.HTTPMux() + + // Ensure parent dir exists + if err := os.MkdirAll(filepath.Dir(h.socketPath), 0700); err != nil { + return fmt.Errorf("creating socket dir: %w", err) + } + // Remove stale socket from ungraceful shutdown + _ = os.Remove(h.socketPath) + + listener, err := net.Listen("unix", h.socketPath) + if err != nil { + return fmt.Errorf("listening on unix socket: %w", err) + } + if err := os.Chmod(h.socketPath, 0660); err != nil { + return fmt.Errorf("setting socket permissions: %w", err) + } + + h.srv = &http.Server{ + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + } + + slog.Info("starting HTTP server on unix socket", "path", h.socketPath) + err = h.srv.Serve(listener) + if err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +} + +// Stop stops the HTTP server gracefully. +func (h *HTTPService) Stop() error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + slog.Info("shutting down HTTP server") + return h.srv.Shutdown(ctx) +} + +// Status returns the status of the HTTP service. +func (h *HTTPService) Status() string { + if h.srv != nil { + return "running" + } + return "stopped" +} + +// HTTPMux creates and returns the HTTP ServeMux with all routes. +func (h *HTTPService) HTTPMux() *http.ServeMux { + mux := http.NewServeMux() + + // User endpoints + mux.HandleFunc("GET /users", h.listUsers) + mux.HandleFunc("POST /users", h.createUser) + mux.HandleFunc("DELETE /users/{username}", h.deleteUser) + mux.HandleFunc("POST /users/{username}/addresses", h.addUserAddress) + + // SMTP auth endpoints + mux.HandleFunc("GET /smtp-auth", h.listSMTPAuth) + mux.HandleFunc("POST /smtp-auth", h.createSMTPAuth) + mux.HandleFunc("DELETE /smtp-auth/{name}", h.deleteSMTPAuth) + + // DKIM endpoints + mux.HandleFunc("GET /dkim", h.listDKIM) + mux.HandleFunc("POST /dkim", h.addDKIM) + mux.HandleFunc("GET /dkim/{selector}/{domain}", h.getDKIM) + mux.HandleFunc("POST /dkim/{selector}/{domain}/enable", h.enableDKIM) + mux.HandleFunc("POST /dkim/{selector}/{domain}/disable", h.disableDKIM) + mux.HandleFunc("DELETE /dkim/{selector}/{domain}", h.deleteDKIM) + + // Prune endpoint + mux.HandleFunc("POST /prune", h.pruneMails) + + return mux +} + +// Helper to write JSON responses +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(v); err != nil { + slog.Error("failed to encode JSON response", "error", err) + } +} + +// Helper to write error responses +func writeError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, model.ErrAlreadyExists): + writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()}) + case errors.Is(err, model.ErrNotFound): + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + case errors.Is(err, model.ErrInvalidInput): + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + default: + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } +} + +func (h *HTTPService) listUsers(w http.ResponseWriter, r *http.Request) { + users, err := h.cmd.ListUsers(r.Context()) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, users) +} + +func (h *HTTPService) createUser(w http.ResponseWriter, r *http.Request) { + var req struct { + Username string `json:"username"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + if req.Username == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username is required"}) + return + } + + user, err := h.cmd.CreateUser(r.Context(), req.Username) + if err != nil { + writeError(w, err) + return + } + + writeJSON(w, http.StatusCreated, user) +} + +func (h *HTTPService) deleteUser(w http.ResponseWriter, r *http.Request) { + username := r.PathValue("username") + if username == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username is required"}) + return + } + + err := h.cmd.RemoveUser(r.Context(), username) + if err != nil { + writeError(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *HTTPService) addUserAddress(w http.ResponseWriter, r *http.Request) { + username := r.PathValue("username") + if username == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username is required"}) + return + } + + var req struct { + Address string `json:"address"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + if req.Address == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "address is required"}) + return + } + + err := h.cmd.AddUserAddress(r.Context(), username, req.Address) + if err != nil { + writeError(w, err) + return + } + + w.WriteHeader(http.StatusCreated) +} + +func (h *HTTPService) listSMTPAuth(w http.ResponseWriter, r *http.Request) { + auths, err := h.cmd.ListSMTPAuth(r.Context()) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, auths) +} + +func (h *HTTPService) createSMTPAuth(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + if req.Name == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) + return + } + + auth, err := h.cmd.CreateSMTPAuth(r.Context(), req.Name) + if err != nil { + writeError(w, err) + return + } + + writeJSON(w, http.StatusCreated, auth) +} + +func (h *HTTPService) deleteSMTPAuth(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if name == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) + return + } + + err := h.cmd.RemoveSMTPAuth(r.Context(), name) + if err != nil { + writeError(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *HTTPService) listDKIM(w http.ResponseWriter, r *http.Request) { + dkims, err := h.cmd.ListDKIM(r.Context()) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, dkims) +} + +func (h *HTTPService) addDKIM(w http.ResponseWriter, r *http.Request) { + var req struct { + Selector string `json:"selector"` + Domain string `json:"domain"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) + return + } + if req.Selector == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "selector is required"}) + return + } + if req.Domain == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "domain is required"}) + return + } + + record, err := h.cmd.AddDKIM(r.Context(), req.Selector, req.Domain) + if err != nil { + writeError(w, err) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"dns_record": record}) +} + +func (h *HTTPService) getDKIM(w http.ResponseWriter, r *http.Request) { + selector := r.PathValue("selector") + domain := r.PathValue("domain") + if selector == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "selector is required"}) + return + } + if domain == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "domain is required"}) + return + } + + record, err := h.cmd.GetDKIM(r.Context(), selector, domain) + if err != nil { + writeError(w, err) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"dns_record": record}) +} + +func (h *HTTPService) enableDKIM(w http.ResponseWriter, r *http.Request) { + selector := r.PathValue("selector") + domain := r.PathValue("domain") + if selector == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "selector is required"}) + return + } + if domain == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "domain is required"}) + return + } + + // Parse force query parameter + forceStr := r.URL.Query().Get("force") + force := forceStr != "false" // Default to true if not explicitly set to "false" + + err := h.cmd.SetDKIMEnabled(r.Context(), selector, domain, true, force) + if err != nil { + writeError(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *HTTPService) disableDKIM(w http.ResponseWriter, r *http.Request) { + selector := r.PathValue("selector") + domain := r.PathValue("domain") + if selector == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "selector is required"}) + return + } + if domain == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "domain is required"}) + return + } + + err := h.cmd.SetDKIMEnabled(r.Context(), selector, domain, false, true) + if err != nil { + writeError(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *HTTPService) deleteDKIM(w http.ResponseWriter, r *http.Request) { + selector := r.PathValue("selector") + domain := r.PathValue("domain") + if selector == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "selector is required"}) + return + } + if domain == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "domain is required"}) + return + } + + err := h.cmd.RemoveDKIM(r.Context(), selector, domain) + if err != nil { + writeError(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *HTTPService) pruneMails(w http.ResponseWriter, r *http.Request) { + count, err := h.cmd.PruneMails(r.Context()) + if err != nil { + writeError(w, err) + return + } + + writeJSON(w, http.StatusOK, map[string]int{"pruned": count}) +}
@@ -7,25 +7,37 @@
"github.com/spf13/cobra" ) -func (c *Command) pruneMails(cmd *cobra.Command, _ []string) error { - fmt.Println("Pruning in progress...") - - targets, err := c.db.GetPrunableTargets(context.Background()) +// PruneMails prunes orphaned mails and returns the count of pruned messages. +func (c *Command) PruneMails(ctx context.Context) (int, error) { + targets, err := c.db.GetPrunableTargets(ctx) if err != nil { - return err + return 0, err } + count := 0 for _, target := range targets { err = c.persistence.RemoveBlob(target.BlobHash) if err != nil { - return err + return count, err } - err = c.db.DeleteMessage(context.Background(), target.MessageID) + err = c.db.DeleteMessage(ctx, target.MessageID) + if err != nil { + return count, err + } + count++ } + return count, nil +} + +func (c *Command) pruneMails(cmd *cobra.Command, _ []string) error { + fmt.Println("Pruning in progress...") + + count, err := c.clientPruneMails(cmd.Context()) if err != nil { return err } - fmt.Println("Pruning complete.") + + fmt.Printf("Pruning complete. %d messages pruned.\n", count) return nil }
@@ -3,3 +3,6 @@
import "errors" var ErrMissingFromHeader = errors.New("missing From header") +var ErrAlreadyExists = errors.New("already exists") +var ErrNotFound = errors.New("not found") +var ErrInvalidInput = errors.New("invalid input")
@@ -8,6 +8,7 @@ type User struct {
ID int Name string Addresses []string + Password string `json:"password,omitempty"` password []byte }@@ -23,6 +24,7 @@
type SMTPAuth struct { ID int Name string + Password string `json:"password,omitempty"` password []byte }
@@ -3,10 +3,13 @@
import ( "cmp" "crypto/tls" + "fmt" "log" "log/slog" "os" "path" + "path/filepath" + "runtime" "postern/internal/cli" "postern/internal/db"@@ -19,151 +22,156 @@ "postern/internal/submission"
) func main() { - posternPersistenceDir := cmp.Or(os.Getenv("POSTERN_PERSISTENCE_DIR"), os.Getenv("STATE_DIRECTORY")) - err := persistence.CheckDirWritable(posternPersistenceDir) - if err != nil { - log.Fatal("POSTERN_PERSISTENCE_DIR: ", err) - } - slog.Info("using postern persistence", "dir", posternPersistenceDir) + socketPath := resolveSocketPath() - persistenceMasterkey := os.Getenv("POSTERN_MASTERKEY_FILE") - if persistenceMasterkey == "" { - persistenceMasterkey = path.Join(os.Getenv("CREDENTIALS_DIRECTORY"), "masterkey") - } - p, err := persistence.New(persistence.Config{ - BlobRoot: path.Join(posternPersistenceDir, "blobs"), - MasterkeyFile: persistenceMasterkey, - }) - if err != nil { - log.Fatal(err) - } - - posternDB, err := db.OpenDB(&db.Config{ - DBPath: path.Join(posternPersistenceDir, "meta.db"), - }) - if err != nil { - log.Fatal(err) - } - defer func(db *db.DB) { - if db == nil { - return + // Create a bootstrap closure that encapsulates all the resource initialization + // that is only needed when the "serve" command is executed. + bootstrap := func() (*cli.BootstrapDeps, error) { + posternPersistenceDir := cmp.Or(os.Getenv("POSTERN_PERSISTENCE_DIR"), os.Getenv("STATE_DIRECTORY")) + if posternPersistenceDir == "" { + return nil, fmt.Errorf("POSTERN_PERSISTENCE_DIR or STATE_DIRECTORY is required") } - err := db.Close() + err := persistence.CheckDirWritable(posternPersistenceDir) if err != nil { - log.Fatal(err) + return nil, fmt.Errorf("POSTERN_PERSISTENCE_DIR: %w", err) } - }(posternDB) - - engine, err := policy.NewEngine(path.Join(os.Getenv("CONFIGURATION_DIRECTORY"), "policy.star"), posternDB) - if err != nil { - log.Fatal(err) - } - - dkimSvc := dkim.New(&dkim.Config{ - DB: posternDB, - Persistence: p, - }) + slog.Info("using postern persistence", "dir", posternPersistenceDir) - listenAddrs := make(map[string]policy.ListenerConfig) - for _, l := range engine.Listeners { - listenAddrs[l.Role] = l - } - - services := make(map[string]cli.Service) - - if listener, ok := listenAddrs["imap"]; ok { - cert, err := tls.LoadX509KeyPair(listener.TLSCertFile, listener.TLSKeyFile) + persistenceMasterkey := os.Getenv("POSTERN_MASTERKEY_FILE") + if persistenceMasterkey == "" { + persistenceMasterkey = path.Join(os.Getenv("CREDENTIALS_DIRECTORY"), "masterkey") + } + p, err := persistence.New(persistence.Config{ + BlobRoot: path.Join(posternPersistenceDir, "blobs"), + MasterkeyFile: persistenceMasterkey, + }) if err != nil { - log.Fatal(err) - } - - tlsConfig := &tls.Config{ - Certificates: []tls.Certificate{cert}, + return nil, fmt.Errorf("failed to initialize persistence: %w", err) } - imapServer, err := imap.NewServer(&imap.Config{ - DB: posternDB, - Listen: listener.Address.String(), - Persistence: p, - TLSConfig: tlsConfig, - PolicyEngine: &engine, + posternDB, err := db.OpenDB(&db.Config{ + DBPath: path.Join(posternPersistenceDir, "meta.db"), }) if err != nil { - log.Fatal(err) + return nil, fmt.Errorf("failed to open database: %w", err) } - services["imap"] = imapServer - } - if listener, ok := listenAddrs["smtp_inbound"]; ok { - cert, err := tls.LoadX509KeyPair(listener.TLSCertFile, listener.TLSKeyFile) + engine, err := policy.NewEngine(path.Join(os.Getenv("CONFIGURATION_DIRECTORY"), "policy.star"), posternDB) if err != nil { - log.Fatal(err) + return nil, fmt.Errorf("failed to initialize policy engine: %w", err) } - tlsConfig := &tls.Config{ - Certificates: []tls.Certificate{cert}, + dkimSvc := dkim.New(&dkim.Config{ + DB: posternDB, + Persistence: p, + }) + + listenAddrs := make(map[string]policy.ListenerConfig) + for _, l := range engine.Listeners { + listenAddrs[l.Role] = l } - receptionServer, err := inbound.NewServer(&inbound.Config{ - DB: posternDB, - Deliverer: services["imap"].(inbound.MessageDeliverer), - Listen: listener.Address.String(), - TLS: tlsConfig, - PolicyEngine: &engine, - }) - if err != nil { - log.Fatal(err) + services := make(map[string]cli.Service) + + if listener, ok := listenAddrs["imap"]; ok { + cert, err := tls.LoadX509KeyPair(listener.TLSCertFile, listener.TLSKeyFile) + if err != nil { + return nil, fmt.Errorf("failed to load IMAP TLS cert: %w", err) + } + + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{cert}, + } + + imapServer, err := imap.NewServer(&imap.Config{ + DB: posternDB, + Listen: listener.Address.String(), + Persistence: p, + TLSConfig: tlsConfig, + PolicyEngine: &engine, + }) + if err != nil { + return nil, fmt.Errorf("failed to create IMAP server: %w", err) + } + services["imap"] = imapServer } - services["smtp_inbound"] = receptionServer - } - if listener, ok := listenAddrs["smtp_submission"]; ok { - cert, err := tls.LoadX509KeyPair(listener.TLSCertFile, listener.TLSKeyFile) - if err != nil { - log.Fatal(err) - } + if listener, ok := listenAddrs["smtp_inbound"]; ok { + cert, err := tls.LoadX509KeyPair(listener.TLSCertFile, listener.TLSKeyFile) + if err != nil { + return nil, fmt.Errorf("failed to load SMTP inbound TLS cert: %w", err) + } - tlsConfig := &tls.Config{ - Certificates: []tls.Certificate{cert}, - } + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{cert}, + } - submissionServer, err := submission.NewServer(&submission.Config{ - DB: posternDB, - DKIM: dkimSvc, - Listen: listener.Address.String(), - TLS: tlsConfig, - PolicyEngine: &engine, - }) - if err != nil { - log.Fatal(err) + receptionServer, err := inbound.NewServer(&inbound.Config{ + DB: posternDB, + Deliverer: services["imap"].(inbound.MessageDeliverer), + Listen: listener.Address.String(), + TLS: tlsConfig, + PolicyEngine: &engine, + }) + if err != nil { + return nil, fmt.Errorf("failed to create SMTP inbound server: %w", err) + } + services["smtp_inbound"] = receptionServer } - services["smtp_submission"] = submissionServer - } - if listener, ok := listenAddrs["smtp_local_submission"]; ok { - // Check that address is loopback. Other addresses are not allowed for security reasons. - if !listener.Address.Addr().IsLoopback() { - log.Fatal("smtp_local_submission address is not a loopback address") + if listener, ok := listenAddrs["smtp_submission"]; ok { + cert, err := tls.LoadX509KeyPair(listener.TLSCertFile, listener.TLSKeyFile) + if err != nil { + return nil, fmt.Errorf("failed to load SMTP submission TLS cert: %w", err) + } + + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{cert}, + } + + submissionServer, err := submission.NewServer(&submission.Config{ + DB: posternDB, + DKIM: dkimSvc, + Listen: listener.Address.String(), + TLS: tlsConfig, + PolicyEngine: &engine, + }) + if err != nil { + return nil, fmt.Errorf("failed to create SMTP submission server: %w", err) + } + services["smtp_submission"] = submissionServer } - submissionServer, err := submission.NewServer(&submission.Config{ - DB: posternDB, - DKIM: dkimSvc, - Listen: listener.Address.String(), - PolicyEngine: &engine, - IsLocalSubmission: true, - }) - if err != nil { - log.Fatal(err) + if listener, ok := listenAddrs["smtp_local_submission"]; ok { + // Check that address is loopback. Other addresses are not allowed for security reasons. + if !listener.Address.Addr().IsLoopback() { + return nil, fmt.Errorf("smtp_local_submission address is not a loopback address") + } + + submissionServer, err := submission.NewServer(&submission.Config{ + DB: posternDB, + DKIM: dkimSvc, + Listen: listener.Address.String(), + PolicyEngine: &engine, + IsLocalSubmission: true, + }) + if err != nil { + return nil, fmt.Errorf("failed to create local SMTP submission server: %w", err) + } + services["smtp_local_submission"] = submissionServer } - services["smtp_local_submission"] = submissionServer + + return &cli.BootstrapDeps{ + DB: posternDB, + Persistence: p, + DKIM: dkimSvc, + Services: services, + }, nil } posternCLI, err := cli.New(&cli.Config{ - DB: posternDB, - Persistence: p, - DKIM: dkimSvc, - Services: services, + SocketPath: socketPath, + Bootstrap: bootstrap, }) if err != nil { log.Fatal(err)@@ -173,3 +181,20 @@ if err := posternCLI.Execute(); err != nil {
os.Exit(1) } } + +// resolveSocketPath is runtime agnostic (Linux, MacOS) to choose the admin http socket path +func resolveSocketPath() string { + if p := os.Getenv("POSTERN_SOCKET"); p != "" { + return p + } + if rd := os.Getenv("RUNTIME_DIRECTORY"); rd != "" { + return filepath.Join(rd, "postern.sock") + } + if runtime.GOOS == "linux" { + return "/run/postern/postern.sock" + } + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, ".postern", "postern.sock") + } + return "/tmp/postern.sock" +}
@@ -7,6 +7,8 @@ [Service]
Type=exec DynamicUser=yes StateDirectory=postern +RuntimeDirectory=postern +RuntimeDirectoryMode=0700 ExecStart=/usr/local/bin/postern serve LoadCredentialEncrypted=masterkey ConfigurationDirectory=postern@@ -17,7 +19,7 @@ PrivateDevices=yes
ProtectKernelTunables=yes ProtectKernelModules=yes ProtectControlGroups=yes -RestrictAddressFamilies=AF_INET AF_INET6 +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX RestrictNamespaces=yes # Capabilities