From d0e7df023cadb3d7068e5b09509bc562ad63f10b Mon Sep 17 00:00:00 2001 From: "fox.cpp" Date: Sun, 3 May 2020 20:20:35 +0300 Subject: [PATCH] Preliminary milter client implementation Based on github.com/foxcpp/go-milter fork --- docs/man/maddy-filters.5.scd | 45 ++++ go.mod | 3 + go.sum | 5 + internal/check/milter/milter.go | 394 ++++++++++++++++++++++++++++++++ maddy.go | 1 + 5 files changed, 448 insertions(+) create mode 100644 internal/check/milter/milter.go diff --git a/docs/man/maddy-filters.5.scd b/docs/man/maddy-filters.5.scd index 4fa7a0a..27d5c3c 100644 --- a/docs/man/maddy-filters.5.scd +++ b/docs/man/maddy-filters.5.scd @@ -747,3 +747,48 @@ the message pipeline action. Two codes are defined implicitly, exit code 1 causes the message to be rejected with a permanent error, exit code 2 causes the message to be quarantined. Both action can be overriden using the 'code' directive. + +## Milter protocol check (milter) + +The 'milter' implements subset of Sendmail's milter protocol that can be used +to integrate external software in maddy. + +Notable limitations of protocol implementation in maddy include: +1. Changes of envelope sender address are not supported +2. Removal and addition of envelope recipients is not supported +3. Removal and replacement of header fields is not supported +4. Headers fields can be inserted only on top +5. Milter does not receive some "macros" provided by sendmail. + +Restrictions 1 and 2 are inherent to the maddy checks interface and cannot be +removed without major changes to it. Restrictions 3, 4 and 5 are temporary due to +incomplete implementation. + +``` +milter { + endpoint + fail_open false +} + +milter +``` + +## Arguments + +When defined inline, the first argument specifies endpoint to access milter +via. See below. + +## Configuration directives + +**Syntax:** endpoint _scheme://path_ ++ +**Default:** not set + +Specifies milter protocol endpoint to use. +The endpoit is specified in standard URL-like format: +'tcp://127.0.0.1:6669' or 'unix:///var/lib/milter/filter.sock' + +**Syntax:** fail_open _boolean_ ++ +**Default:** false + +Toggles behavior on milter I/O errors. If false ("fail closed") - message is +rejected with temporary error code. If true ("fail open") - check is skipped. diff --git a/go.mod b/go.mod index 3d85a47..61e45b2 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/emersion/go-imap-specialuse v0.0.0-20161227184202-ba031ced6a62 github.com/emersion/go-imap-unselect v0.0.0-20171113212723-b985794e5f26 github.com/emersion/go-message v0.11.2 + github.com/emersion/go-milter v0.1.0 github.com/emersion/go-msgauth v0.4.1-0.20200429175443-e4c87369d72f github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b github.com/emersion/go-smtp v0.12.2-0.20200219094142-f9be832b5554 @@ -36,3 +37,5 @@ require ( golang.org/x/text v0.3.2 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect ) + +replace github.com/emersion/go-milter => github.com/foxcpp/go-milter v0.1.1-0.20200502214548-312001df0b51 diff --git a/go.sum b/go.sum index 076faa9..6aa86bd 100644 --- a/go.sum +++ b/go.sum @@ -38,6 +38,8 @@ github.com/emersion/go-message v0.11.1/go.mod h1:C4jnca5HOTo4bGN9YdqNQM9sITuT3Y0 github.com/emersion/go-message v0.11.2 h1:oxO9SQ+3wgBAQRdk07eqfkCJ26Tl8ZHF7CcpGVoE00o= github.com/emersion/go-message v0.11.2/go.mod h1:C4jnca5HOTo4bGN9YdqNQM9sITuT3Y0K6bSUw9RklvY= github.com/emersion/go-milter v0.0.0-20190311184326-c3095a41a6fe/go.mod h1:aEaq7U51ARlk+2UeXTtdrDYeYWAUn/QjEwWzs7lD8OU= +github.com/emersion/go-milter v0.1.0 h1:61u1N/QaHCcQpryDrH5icX2pO1hmU/1g3YID209JKc4= +github.com/emersion/go-milter v0.1.0/go.mod h1:Ys4s0SSEt4H2jEl3kDbupNQq61wH4ztM9VHZMBfYKqw= github.com/emersion/go-msgauth v0.4.0 h1:LM9vNyaecGbhWiwKCAnzql7sVfgL072/QL5ZsIaHSL8= github.com/emersion/go-msgauth v0.4.0/go.mod h1:7r9HUSXL1dq+KK7Xqg0JlyBxNFGf5+JouRvSz4wBZCQ= github.com/emersion/go-msgauth v0.4.1-0.20200429121350-af2e57960c76 h1:lH/JVE7BLxJweku8z15q/w9N4Fk/GYy8NqJy/dCmOfU= @@ -60,6 +62,9 @@ github.com/foxcpp/go-imap-i18nlevel v0.0.0-20200208001533-d6ec88553005 h1:pfoFtk github.com/foxcpp/go-imap-i18nlevel v0.0.0-20200208001533-d6ec88553005/go.mod h1:34FwxnjC2N+EFs2wMtsHevrZLWRKRuVU8wEcHWKq/nE= github.com/foxcpp/go-imap-sql v0.4.1-0.20200426175844-c3172a53940a h1:7IHua8lv9Qouf/TnjzB/8eYtjzws5zcn8Zxvf49ZJOo= github.com/foxcpp/go-imap-sql v0.4.1-0.20200426175844-c3172a53940a/go.mod h1:DISGTnTW4OX3qgG8xP5lI+qYRb97b8NvEHV7TSlUy5g= +github.com/foxcpp/go-milter v0.1.1-0.20200430224718-2b7763a588dc/go.mod h1:ablHK0pbLB83kMFBznp/Rj8aV+Kc3jw8cxzzmCNLIOY= +github.com/foxcpp/go-milter v0.1.1-0.20200502214548-312001df0b51 h1:njk1z7faETtQRTr+l9kSgXtUoAS6FghmAC7N8SSA3Ac= +github.com/foxcpp/go-milter v0.1.1-0.20200502214548-312001df0b51/go.mod h1:ablHK0pbLB83kMFBznp/Rj8aV+Kc3jw8cxzzmCNLIOY= github.com/foxcpp/go-mockdns v0.0.0-20191216195825-5eabd8dbfe1f h1:b/CFmrdqIGU6eV774xeaQwd1VfgiLuR/8jiY3LyLiMc= github.com/foxcpp/go-mockdns v0.0.0-20191216195825-5eabd8dbfe1f/go.mod h1:tPg4cp4nseejPd+UKxtCVQ2hUxNTZ7qQZJa7CLriIeo= github.com/foxcpp/go-mockdns v0.0.0-20200218175156-e3faeb733052 h1:a9cMhXBc4p7TUqjHvqUWbhpLmRx+mgpRRPZB7L648GY= diff --git a/internal/check/milter/milter.go b/internal/check/milter/milter.go new file mode 100644 index 0000000..5ca80ed --- /dev/null +++ b/internal/check/milter/milter.go @@ -0,0 +1,394 @@ +package milter + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net" + + "github.com/emersion/go-message/textproto" + "github.com/emersion/go-milter" + "github.com/foxcpp/maddy/internal/buffer" + "github.com/foxcpp/maddy/internal/config" + "github.com/foxcpp/maddy/internal/exterrors" + "github.com/foxcpp/maddy/internal/log" + "github.com/foxcpp/maddy/internal/module" + "github.com/foxcpp/maddy/internal/target" +) + +const modName = "milter" + +type Check struct { + cl *milter.Client + milterUrl string + failOpen bool + instName string + log log.Logger +} + +func New(_, instName string, _, inlineArgs []string) (module.Module, error) { + c := &Check{ + instName: instName, + log: log.Logger{Name: modName, Debug: log.DefaultLogger.Debug}, + } + switch len(inlineArgs) { + case 1: + c.milterUrl = inlineArgs[0] + case 0: + default: + return nil, fmt.Errorf("%s: unexpected amount of arguments, want 1 or 0", modName) + } + return c, nil +} + +func (c *Check) Name() string { + return modName +} + +func (c *Check) InstanceName() string { + return c.instName +} + +func (c *Check) Init(cfg *config.Map) error { + cfg.String("endpoint", false, false, c.milterUrl, &c.milterUrl) + cfg.Bool("fail_open", false, false, &c.failOpen) + if _, err := cfg.Process(); err != nil { + return err + } + + if c.milterUrl == "" { + return fmt.Errorf("%s: milter endpoint is not set", modName) + } + + endp, err := config.ParseEndpoint(c.milterUrl) + if err != nil { + return fmt.Errorf("%s: %v", modName, err) + } + + switch endp.Scheme { + case "tcp", "unix": + default: + return fmt.Errorf("%s: scheme unsupported: %v", modName, endp.Scheme) + } + if endp.Path != "" { + return fmt.Errorf("%s: stray path in endpoint: %v", modName, endp) + } + + c.cl = milter.NewClient(endp.Scheme, endp.Host) + + return nil +} + +type state struct { + c *Check + session *milter.ClientSession + msgMeta *module.MsgMetadata + skipChecks bool + discarded bool + log log.Logger +} + +func (c *Check) CheckStateForMsg(msgMeta *module.MsgMetadata) (module.CheckState, error) { + const supportedActions = milter.OptAddHeader | milter.OptQuarantine + protocolOpts := milter.OptProtocol(0) + if msgMeta.Conn == nil { + protocolOpts = milter.OptNoConnect | milter.OptNoHelo + } + + session, err := c.cl.Session(supportedActions, protocolOpts) + if err != nil { + return nil, err + } + return &state{ + c: c, + session: session, + msgMeta: msgMeta, + log: target.DeliveryLogger(c.log, msgMeta), + }, nil +} + +func (s *state) handleAction(act *milter.Action) module.CheckResult { + switch act.Code { + case milter.ActAccept: + s.skipChecks = true + return module.CheckResult{} + case milter.ActContinue: + return module.CheckResult{} + case milter.ActReplyCode: + return module.CheckResult{ + Reject: true, + Reason: &exterrors.SMTPError{ + Code: act.SMTPCode, + EnhancedCode: exterrors.EnhancedCode{5, 7, 1}, + Message: "Message rejected due to local policy", + Reason: "reply code action", + CheckName: "milter", + Misc: map[string]interface{}{ + "milter": s.c.milterUrl, + }, + }, + } + case milter.ActDiscard: + s.log.Msg("silent discard is not supported, rejecting message") + fallthrough + case milter.ActReject: + return module.CheckResult{ + Reject: true, + Reason: &exterrors.SMTPError{ + Code: 550, + EnhancedCode: exterrors.EnhancedCode{5, 7, 1}, + Message: "Message rejected due to local policy", + Reason: "reject action", + CheckName: "milter", + Misc: map[string]interface{}{ + "milter": s.c.milterUrl, + }, + }, + } + default: + s.log.Msg("unknown action code ignored", "code", act.Code, "milter", s.c.milterUrl) + return module.CheckResult{} + } +} + +// apply applies the modification actions returned by milter to the check results object. +func (s *state) apply(modifyActs []milter.ModifyAction, res module.CheckResult) module.CheckResult { + out := res + for _, act := range modifyActs { + switch act.Code { + case milter.ActAddRcpt, milter.ActDelRcpt: + s.log.Msg("envelope changes are not supported", "rcpt", act.Rcpt, "code", act.Code, "milter", s.c.milterUrl) + case milter.ActChangeFrom: + s.log.Msg("envelope changes are not supported", "from", act.From, "code", act.Code, "milter", s.c.milterUrl) + case milter.ActChangeHeader: + s.log.Msg("header field changes are not supported", "field", act.HdrName, "milter", s.c.milterUrl) + case milter.ActInsertHeader: + if act.HdrIndex != 1 { + s.log.Msg("header inserting not on top is not supported, prepending instead", "field", act.HdrName, "milter", s.c.milterUrl) + } + fallthrough + case milter.ActAddHeader: + // Header field might be arbitarly folded by the caller and we want + // to preserve that exact format in case it is important (DKIM + // signature is added by milter). + field := make([]byte, 0, len(act.HdrName)+2+len(act.HdrValue)+2) + field = append(field, act.HdrName...) + field = append(field, ':', ' ') + field = append(field, act.HdrValue...) + field = append(field, '\r', '\n') + out.Header.AddRaw(field) + case milter.ActQuarantine: + out.Quarantine = true + out.Reason = exterrors.WithFields(errors.New("milter quarantine action"), map[string]interface{}{ + "check": "milter", + "milter": s.c.milterUrl, + "reason": act.Reason, + }) + } + } + return out +} + +func (s *state) CheckConnection(ctx context.Context) module.CheckResult { + if s.msgMeta.Conn == nil { + return module.CheckResult{} + } + + if !s.session.ProtocolOption(milter.OptNoConnect) { + if err := s.session.Macros(milter.CodeConn, + "daemon_name", "maddy", + "if_name", "unknown", + "if_addr", "0.0.0.0", + // TODO: $j + // TODO: $_ + ); err != nil { + return s.ioError(err) + } + + var ( + protoFamily milter.ProtoFamily + port uint16 + addr string + ) + switch rAddr := s.msgMeta.Conn.RemoteAddr.(type) { + case *net.TCPAddr: + port = uint16(rAddr.Port) + if v4 := rAddr.IP.To4(); v4 != nil { + // Make sure to not accidentally send IPv6-mapped IPv4 address. + protoFamily = milter.FamilyInet + addr = v4.String() + } else { + protoFamily = milter.FamilyInet6 + addr = rAddr.IP.String() + } + case *net.UnixAddr: + protoFamily = milter.FamilyUnix + addr = rAddr.Name + default: + protoFamily = milter.FamilyUnknown + } + + act, err := s.session.Conn(s.msgMeta.Conn.Hostname, protoFamily, port, addr) + if err != nil { + return s.ioError(err) + } + if act.Code != milter.ActContinue { + return s.handleAction(act) + } + } + + if !s.session.ProtocolOption(milter.OptNoHelo) { + if s.msgMeta.Conn.TLS.HandshakeComplete { + fields := make([]string, 0, 4*2) + tlsState := s.msgMeta.Conn.TLS + + switch tlsState.Version { + case tls.VersionTLS10: + fields = append(fields, "tls_version", "TLSv1") + case tls.VersionTLS11: + fields = append(fields, "tls_version", "TLSv1.1") + case tls.VersionTLS12: + fields = append(fields, "tls_version", "TLSv1.2") + case tls.VersionTLS13: + fields = append(fields, "tls_version", "TLSv1.3") + } + fields = append(fields, "cipher", tls.CipherSuiteName(tlsState.CipherSuite)) + + if len(tlsState.PeerCertificates) != 0 { + fields = append(fields, "cert_subject", + tlsState.PeerCertificates[len(tlsState.PeerCertificates)-1].Subject.String()) + fields = append(fields, "cert_issuer", + tlsState.PeerCertificates[len(tlsState.PeerCertificates)-1].Issuer.String()) + } + + if err := s.session.Macros(milter.CodeHelo, fields...); err != nil { + return s.ioError(err) + } + } + act, err := s.session.Helo(s.msgMeta.Conn.Hostname) + if err != nil { + return s.ioError(err) + } + return s.handleAction(act) + } + + return module.CheckResult{} +} + +func (s *state) ioError(err error) module.CheckResult { + if s.c.failOpen { + s.skipChecks = true // silently permit processing to continue + s.c.log.Error("I/O error", err) + return module.CheckResult{} + } + + return module.CheckResult{ + Reject: true, + Reason: &exterrors.SMTPError{ + Code: 451, + EnhancedCode: exterrors.EnhancedCode{4, 7, 1}, + Message: "I/O error during policy check", + Err: err, + CheckName: "milter", + Misc: map[string]interface{}{ + "milter": s.c.milterUrl, + }, + }, + } +} + +func (s *state) CheckSender(ctx context.Context, mailFrom string) module.CheckResult { + if s.skipChecks || s.session.ProtocolOption(milter.OptNoMailFrom) { + return module.CheckResult{} + } + + fields := make([]string, 0, 2) + fields = append(fields, "i", s.msgMeta.ID) + // TODO: fields = append(fields, "auth_type", s.msgMeta.???) + if s.msgMeta.Conn.AuthUser != "" { + fields = append(fields, "auth_authen", s.msgMeta.Conn.AuthUser) + } + s.session.Macros(milter.CodeMail, fields...) + + esmtpArgs := make([]string, 0, 2) + if s.msgMeta.SMTPOpts.UTF8 { + esmtpArgs = append(esmtpArgs, "SMTPUTF8") + } + + act, err := s.session.Mail(mailFrom, esmtpArgs) + if err != nil { + return s.ioError(err) + } + return s.handleAction(act) +} + +func (s *state) CheckRcpt(ctx context.Context, rcptTo string) module.CheckResult { + if s.skipChecks { + return module.CheckResult{} + } + + act, err := s.session.Rcpt(rcptTo, nil) + if err != nil { + return s.ioError(err) + } + return s.handleAction(act) +} + +func (s *state) CheckBody(ctx context.Context, header textproto.Header, body buffer.Buffer) module.CheckResult { + if s.skipChecks { + return module.CheckResult{} + } + + act, err := s.session.Header(header) + if err != nil { + return s.ioError(err) + } + if act.Code != milter.ActContinue { + return s.handleAction(act) + } + + var modifyAct []milter.ModifyAction + + if !s.session.ProtocolOption(milter.OptNoBody) { + // body.Open can be expensive for on-disk buffering. + r, err := body.Open() + if err != nil { + // Not ioError(err) because fail_open directive is applied only for external I/O. + return module.CheckResult{ + Reject: true, + Reason: &exterrors.SMTPError{ + Code: 451, + EnhancedCode: exterrors.EnhancedCode{4, 7, 1}, + Message: "Internal error during policy check", + Err: err, + CheckName: "milter", + Misc: map[string]interface{}{ + "milter": s.c.milterUrl, + }, + }, + } + } + + modifyAct, act, err = s.session.Body(r) + if err != nil { + return s.ioError(err) + } + } else { + modifyAct, act, err = s.session.End() + if err != nil { + return s.ioError(err) + } + } + + result := s.handleAction(act) + return s.apply(modifyAct, result) +} + +func (s *state) Close() error { + return s.session.Close() +} + +func init() { + module.Register(modName, New) +} diff --git a/maddy.go b/maddy.go index b574f7e..3895208 100644 --- a/maddy.go +++ b/maddy.go @@ -28,6 +28,7 @@ import ( _ "github.com/foxcpp/maddy/internal/check/dkim" _ "github.com/foxcpp/maddy/internal/check/dns" _ "github.com/foxcpp/maddy/internal/check/dnsbl" + _ "github.com/foxcpp/maddy/internal/check/milter" _ "github.com/foxcpp/maddy/internal/check/requiretls" _ "github.com/foxcpp/maddy/internal/check/spf" _ "github.com/foxcpp/maddy/internal/endpoint/imap"