diff options
-rw-r--r-- | README.md | 35 | ||||
-rw-r--r-- | go.mod | 3 | ||||
-rw-r--r-- | main.go | 85 | ||||
-rw-r--r-- | protocol.go | 101 | ||||
-rw-r--r-- | server.go | 140 | ||||
-rw-r--r-- | users.go | 23 |
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 +``` @@ -0,0 +1,3 @@ +module server.kyl/slackcoder/postfix-unix-users + +go 1.22.7 @@ -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 +} |