aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md35
-rw-r--r--go.mod3
-rw-r--r--main.go85
-rw-r--r--protocol.go101
-rw-r--r--server.go140
-rw-r--r--users.go23
6 files changed, 387 insertions, 0 deletions
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..2a08f5b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,35 @@
+# User Group Membership for Postfix
+
+Limit E-Mail access for users using group membership in Postfix.
+
+The default Postfix setup restricts E-Mail to all system users, which includes
+service users such as 'www'.
+
+## Installation
+
+```sh
+go build .
+sudo mv postfix-unix-users /usr/local/bin
+sudo chown root:root /usr/local/bin/postfix-unix-users
+```
+
+## Configuration
+
+Tell Postfix to start the program as part of its operation, using allowed user groups listed in the file '/etc/postfix/user_groups'.
+
+/etc/postfix/master.cf:
+```
+unix-users unix - n n - 1 spawn
+ user=nobody argv=/usr/local/bin/postfix-unix-users
+ --hostname $myhostname
+ --groups /etc/postfix/user_groups
+ email-group-1 email-group-2
+```
+
+Tell Postfix to lookup local users using the program's unix socket.
+
+/etc/postfix/main.cf:
+```
+# Replace 'unix:passwd.byname' with 'socketmap:unix:private/unix-users:membership'.
+local_recipient_maps = socketmap:unix:private/unix-users:membership $alias_maps
+```
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..20a49c3
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module server.kyl/slackcoder/postfix-unix-users
+
+go 1.22.7
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..fc6c8af
--- /dev/null
+++ b/main.go
@@ -0,0 +1,85 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "net"
+ "os"
+ "os/signal"
+ "syscall"
+)
+
+type Flags struct {
+ Groups string
+ HostName string
+ Listen string
+ StdErr string
+}
+
+func ParseArgs() (*Flags, []string) {
+ var flags Flags
+
+ name, _ := os.Hostname()
+
+ flag.StringVar(&flags.Groups, "groups", "/etc/postfix/user_groups", "User group list")
+ flag.StringVar(&flags.HostName, "hostname", name, "E-Mail hostname")
+ flag.StringVar(&flags.Listen, "listen", "-", "listen socket path")
+ flag.StringVar(&flags.StdErr, "std-err", name, "Redirect errors to filepath")
+
+ flag.Parse()
+
+ return &flags, flag.Args()
+}
+
+func handleSignal(c <-chan os.Signal, listenner net.Listener) {
+ for v := range c {
+ switch v {
+ case syscall.SIGINT:
+ _ = listenner.Close()
+ default:
+ }
+ }
+}
+
+func exitOnErr(err error) {
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err.Error())
+ os.Exit(1)
+ }
+}
+
+func main() {
+ flags, groups := ParseArgs()
+
+ if flags.StdErr != "" {
+ f, _ := os.OpenFile(flags.StdErr, os.O_APPEND|os.O_RDWR|os.O_CREATE, 0666)
+ os.Stderr = f
+ defer f.Close()
+ }
+
+ srv := &Server{
+ HostName: flags.HostName,
+ GroupsFile: flags.Groups,
+ Groups: groups,
+ }
+
+ if flags.Listen == "-" {
+ srv.HandleConn(os.Stdout, os.Stdin)
+
+ return
+ }
+
+ listenner, err := net.Listen("unix", flags.Listen)
+ exitOnErr(err)
+ defer os.Remove(flags.Listen)
+
+ err = os.Chmod(flags.Listen, os.ModePerm)
+ exitOnErr(err)
+
+ c := make(chan os.Signal, 1)
+ signal.Notify(c, syscall.SIGHUP, syscall.SIGINT)
+ go handleSignal(c, listenner)
+
+ err = ServeUnixSocket(listenner, srv)
+ exitOnErr(err)
+}
diff --git a/protocol.go b/protocol.go
new file mode 100644
index 0000000..b2a4cb6
--- /dev/null
+++ b/protocol.go
@@ -0,0 +1,101 @@
+package main
+
+// postmap -v 'user' /path/to.socket
+
+import (
+ "encoding"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+)
+
+// Postfix request.
+type Request struct {
+ Name string
+ Key string
+}
+
+func (s *Request) UnmarshalText(buf []byte) error {
+ strs := strings.SplitN(string(buf), " ", 2)
+ if len(strs) > 0 {
+ s.Name = strs[0]
+ }
+ if len(strs) > 1 {
+ s.Key = strs[1]
+ }
+ if len(strs) > 2 {
+ return fmt.Errorf("bad format")
+ }
+
+ return nil
+}
+
+func (s *Request) String() string {
+ return fmt.Sprintf("%s %s", s.Name, s.Key)
+}
+
+// Value found.
+type ReplyOK struct {
+ Data string
+}
+
+func (s *ReplyOK) String() string {
+ return fmt.Sprintf("OK %s", s.Data)
+}
+
+// Value not found.
+type ReplyNotFound struct{}
+
+func (s *ReplyNotFound) String() string {
+ return "NOTFOUND "
+}
+
+// Something happened..
+type ReplyPerm struct {
+ Reason string
+}
+
+func (s *ReplyPerm) String() string {
+ return fmt.Sprintf("PERM %s", s.Reason)
+}
+
+func readNetString(r io.Reader, v encoding.TextUnmarshaler) (int, error) {
+ var strLen int
+
+ bytesRead, err := fmt.Fscanf(r, "%d:", &strLen)
+ if err != nil {
+ return bytesRead, err
+ }
+
+ payload := make([]byte, strLen)
+ n, err := r.Read(payload)
+ if err != nil {
+ return bytesRead, err
+ }
+ bytesRead += n
+
+ buf := make([]byte, 1)
+ n, err = r.Read(buf)
+ bytesRead += n
+ if err != nil {
+ return bytesRead, err
+ }
+
+ if buf[0] != ',' {
+ return bytesRead, errors.New("bad format")
+ }
+
+ err = v.UnmarshalText(payload)
+ if err != nil {
+ return bytesRead, err
+ }
+
+ return bytesRead, nil
+}
+
+func writeNetString(w io.Writer, v fmt.Stringer) (int, error) {
+ str := v.String()
+ resp := fmt.Sprintf("%d:%s,", len(str), str)
+ return w.Write([]byte(resp))
+}
diff --git a/server.go b/server.go
new file mode 100644
index 0000000..e34e3f2
--- /dev/null
+++ b/server.go
@@ -0,0 +1,140 @@
+package main
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "os"
+ "os/user"
+ "strings"
+)
+
+const TableName = "membership"
+
+type Server struct {
+ HostName string
+ Groups []string
+ GroupsFile string
+}
+
+func ServeUnixSocket(listenner net.Listener, srv *Server) error {
+ for {
+ // Accept an incoming connection.
+ conn, err := listenner.Accept()
+ if errors.Is(err, net.ErrClosed) {
+ break
+ } else if err != nil {
+ fmt.Fprintln(os.Stderr, err.Error())
+
+ continue
+ }
+
+ go func(conn net.Conn) {
+ defer conn.Close()
+
+ err = srv.HandleConn(conn, conn)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err.Error())
+ }
+ }(conn)
+
+ }
+
+ return nil
+}
+
+func (s *Server) readGroupList() ([]string, error) {
+ f, err := os.Open(s.GroupsFile)
+ if err != nil {
+ return nil, err
+ }
+
+ buf, err := io.ReadAll(f)
+ if err != nil {
+ return nil, err
+ }
+
+ groups := strings.Split(string(buf), "\n")
+
+ return groups, nil
+}
+
+func (s *Server) HandleConn(w io.Writer, r io.Reader) error {
+ for {
+ var req Request
+
+ _, err := readNetString(r, &req)
+ if errors.Is(err, io.EOF) {
+ break
+ }
+
+ if req.Name != TableName {
+ if _, err = writeNetString(w, &ReplyPerm{Reason: "unexpected name"}); err != nil {
+ return err
+ }
+
+ return nil
+ }
+
+ strs := strings.SplitN(req.Key, "@", 2)
+ if len(strs) != 2 {
+ return fmt.Errorf("unexpected email address format")
+ }
+ userName := strs[0]
+ hostName := strs[1]
+
+ if hostName != s.HostName {
+ if _, err := writeNetString(w, &ReplyNotFound{}); err != nil {
+ return err
+ }
+
+ return nil
+ }
+
+ u, err := user.Lookup(userName)
+ if _, ok := err.(user.UnknownUserError); ok {
+ _, err := writeNetString(w, &ReplyNotFound{})
+ return err
+ } else if err != nil {
+ return err
+ }
+
+ groups, err := lookupGroupNames(u)
+ if err != nil {
+ return err
+ }
+
+ isListed := make(map[string]bool)
+
+ for _, v := range s.Groups {
+ isListed[v] = true
+ }
+
+ groupsFile, err := s.readGroupList()
+ if err != nil {
+ return fmt.Errorf("reading white list: %w", err)
+ }
+
+ for _, v := range groupsFile {
+ isListed[v] = true
+ }
+
+ found := false
+
+ for _, name := range groups {
+ found = isListed[name]
+ if found {
+ _, err = writeNetString(w, &ReplyOK{Data: "OK"})
+ return err
+ }
+ }
+
+ _, err = writeNetString(w, &ReplyNotFound{})
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
diff --git a/users.go b/users.go
new file mode 100644
index 0000000..77101f7
--- /dev/null
+++ b/users.go
@@ -0,0 +1,23 @@
+package main
+
+import "os/user"
+
+func lookupGroupNames(u *user.User) ([]string, error) {
+ groups, err := u.GroupIds()
+ if err != nil {
+ return nil, err
+ }
+
+ var names []string
+
+ for _, v := range groups {
+ group, err := user.LookupGroupId(v)
+ if err != nil {
+ return nil, err
+ }
+
+ names = append(names, group.Name)
+ }
+
+ return names, nil
+}