// dmarcator, a milter server to reject mails based on DMARC headers // // Copyright (C) 2025 Nicolas Peugnet <nicolas@club1.fr> // // This program is free software; you can redistribute it and/or // modify it under the terms of the GNU General Public License // as published by the Free Software Foundation; either version 2 // of the License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program; if not, see <https://www.gnu.org/licenses/>. package main import ( "flag" "fmt" "log" "mime" "net" "net/textproto" "os" "os/signal" "strings" "syscall" "github.com/BurntSushi/toml" "github.com/emersion/go-milter" "github.com/emersion/go-msgauth/authres" ) type Conf struct { AuthservID string ListenURI string RejectDomains []string RejectFmt string UMask int } // Default values var conf = Conf{ ListenURI: "unix:///run/dmarcator/dmarcator.sock", RejectFmt: "rejected because of DMARC failure for %s overriding policy", UMask: 0o002, } // Set by the compiler var version = "unknown" var rejectDomains = make(map[string]bool) var l *log.Logger = log.New(os.Stderr, "", 0) const ( fieldAuthres = 1 << iota fieldFrom // Keep last fieldLast fieldAll = fieldLast - 1 ) type Session struct { milter.NoOpMilter fieldsFound uint dmarcResult *authres.DMARCResult shouldReject bool headerFrom string } func shouldRejectDMARCRes(result *authres.DMARCResult) bool { return result.Value != authres.ResultPass && rejectDomains[strings.ToLower(result.From)] } func newRejectResponse(domain string) milter.Response { return milter.NewResponseStr(byte(milter.ActReplyCode), "550 5.7.1 "+fmt.Sprintf(conf.RejectFmt, domain)) } func (s *Session) MailFrom(from string, m *milter.Modifier) (milter.Response, error) { // Skip emails from authenticated clients, e.g. SASL authenticated in Postfix. if m.Macros["{auth_authen}"] != "" { return milter.RespAccept, nil } return milter.RespContinue, nil } func (s *Session) Header(name string, value string, m *milter.Modifier) (milter.Response, error) { if s.fieldsFound == fieldAll { return milter.RespContinue, nil } if s.fieldsFound&fieldFrom == 0 && strings.EqualFold(name, "From") { s.fieldsFound |= fieldFrom decoder := new(mime.WordDecoder) if v, err := decoder.DecodeHeader(value); err == nil { s.headerFrom = v } else { s.headerFrom = value } return milter.RespContinue, nil } if s.fieldsFound&fieldAuthres == 0 && strings.EqualFold(name, "Authentication-Results") { queueID := m.Macros["i"] id, results, err := authres.Parse(value) if err != nil { // Simply log in case we can't parse an AR header, because we cannot // handle it better than that. l.Printf("%s: failed to parse header: %v: %q", queueID, err, name+": "+value) return milter.RespContinue, nil } if !strings.EqualFold(id, conf.AuthservID) { // Not our Authentication-Results, ignore the field return milter.RespContinue, nil } for _, result := range results { if r, ok := result.(*authres.DMARCResult); ok { s.fieldsFound |= fieldAuthres s.dmarcResult = r s.shouldReject = shouldRejectDMARCRes(r) } } } return milter.RespContinue, nil } func (s *Session) Headers(h textproto.MIMEHeader, m *milter.Modifier) (milter.Response, error) { queueID := m.Macros["i"] if s.dmarcResult == nil { l.Printf("%s: accept dmarc=unknown from=unknown addr=%q", queueID, s.headerFrom) return milter.RespAccept, nil } r := s.dmarcResult if s.shouldReject { l.Printf("%s: reject dmarc=%v from=%s addr=%q", queueID, r.Value, r.From, s.headerFrom) return newRejectResponse(r.From), nil } else { l.Printf("%s: accept dmarc=%v from=%s addr=%q", queueID, r.Value, r.From, s.headerFrom) return milter.RespAccept, nil } } const ( usageFmt = `Usage: dmarcator [OPTION]... Milter server that rejects mails based on the DMARC Authentication-Results header added by a previous milter (e.g. OpenDMARC). Options: -c FILE Read config from FILE. (default %q) -h, --help Show this help and exit. --version Show version and exit. ` flagConfDef = "/etc/dmarcator.conf" ) func main() { cli := flag.NewFlagSet("dmarcator", flag.ExitOnError) cli.Usage = func() { fmt.Fprintf(cli.Output(), usageFmt, flagConfDef) } var ( flagConf string flagHelp bool flagVersion bool ) cli.StringVar(&flagConf, "c", flagConfDef, "") cli.BoolVar(&flagHelp, "h", false, "") cli.BoolVar(&flagHelp, "help", false, "") cli.BoolVar(&flagVersion, "version", false, "") cli.Parse(os.Args[1:]) if flagHelp { cli.SetOutput(os.Stdout) cli.Usage() os.Exit(0) } if flagVersion { fmt.Println("dmarcator", version) os.Exit(0) } conffile, err := os.Open(flagConf) if err != nil { l.Fatal("Failed to open conf file: ", err) } decoder := toml.NewDecoder(conffile) if _, err := decoder.Decode(&conf); err != nil { l.Fatalf("Failed to parse conf file %s: %v", flagConf, err) } if conf.AuthservID == "" { var err error conf.AuthservID, err = os.Hostname() if err != nil { l.Fatal("Failed to read hostname: ", err) } } network, address, found := strings.Cut(conf.ListenURI, "://") if !found { l.Fatal("Invalid listen URI") } for _, domain := range conf.RejectDomains { rejectDomains[strings.ToLower(domain)] = true } s := milter.Server{ NewMilter: func() milter.Milter { return &Session{} }, Protocol: milter.OptNoConnect | milter.OptNoHelo | milter.OptNoRcptTo | milter.OptNoBody, } // Allows to set the permissions of the created unix socket syscall.Umask(conf.UMask) ln, err := net.Listen(network, address) if err != nil { l.Fatal("Failed to setup listener: ", err) } // Closing the listener will unlink the unix socket, if any sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) go func() { <-sigs if err := s.Close(); err != nil { l.Fatal("Failed to close server: ", err) } }() l.Printf("Milter listening at %s://%v", ln.Addr().Network(), ln.Addr()) if err := s.Serve(ln); err != nil && err != milter.ErrServerClosed { l.Fatal("Failed to serve: ", err) } }