maddy/internal/check/rspamd/rspamd.go

367 lines
9.9 KiB
Go

/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov <fox.cpp@disroot.org>, Maddy Mail Server contributors
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 3 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 rspamd
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strconv"
"strings"
"github.com/emersion/go-message/textproto"
"github.com/foxcpp/maddy/framework/buffer"
"github.com/foxcpp/maddy/framework/config"
modconfig "github.com/foxcpp/maddy/framework/config/module"
tls2 "github.com/foxcpp/maddy/framework/config/tls"
"github.com/foxcpp/maddy/framework/exterrors"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/internal/target"
)
const modName = "check.rspamd"
type Check struct {
instName string
log log.Logger
apiPath string
flags string
settingsID string
tag string
mtaName string
ioErrAction modconfig.FailAction
errorRespAction modconfig.FailAction
addHdrAction modconfig.FailAction
rewriteSubjAction modconfig.FailAction
client *http.Client
}
func New(modName, instName string, _, inlineArgs []string) (module.Module, error) {
c := &Check{
instName: instName,
client: http.DefaultClient,
log: log.Logger{Name: modName, Debug: log.DefaultLogger.Debug},
}
switch len(inlineArgs) {
case 1:
c.apiPath = inlineArgs[0]
case 0:
c.apiPath = "http://127.0.0.1:11333"
default:
return nil, fmt.Errorf("%s: unexpected amount of inline arguments", 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 {
var (
tlsConfig tls.Config
flags []string
)
cfg.Custom("tls_client", true, false, func() (interface{}, error) {
return tls.Config{}, nil
}, tls2.TLSClientBlock, &tlsConfig)
cfg.String("api_path", false, false, c.apiPath, &c.apiPath)
cfg.String("settings_id", false, false, "", &c.settingsID)
cfg.String("tag", false, false, "maddy", &c.tag)
cfg.String("hostname", true, false, "", &c.mtaName)
cfg.Custom("io_error_action", false, false,
func() (interface{}, error) {
return modconfig.FailAction{}, nil
}, modconfig.FailActionDirective, &c.ioErrAction)
cfg.Custom("error_resp_action", false, false,
func() (interface{}, error) {
return modconfig.FailAction{}, nil
}, modconfig.FailActionDirective, &c.errorRespAction)
cfg.Custom("add_header_action", false, false,
func() (interface{}, error) {
return modconfig.FailAction{Quarantine: true}, nil
}, modconfig.FailActionDirective, &c.addHdrAction)
cfg.Custom("rewrite_subj_action", false, false,
func() (interface{}, error) {
return modconfig.FailAction{Quarantine: true}, nil
}, modconfig.FailActionDirective, &c.rewriteSubjAction)
cfg.StringList("flags", false, false, []string{"pass_all"}, &flags)
if _, err := cfg.Process(); err != nil {
return err
}
c.client = &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tlsConfig,
},
}
c.flags = strings.Join(flags, ",")
return nil
}
type state struct {
c *Check
msgMeta *module.MsgMetadata
log log.Logger
mailFrom string
rcpt []string
}
func (c *Check) CheckStateForMsg(ctx context.Context, msgMeta *module.MsgMetadata) (module.CheckState, error) {
return &state{
c: c,
msgMeta: msgMeta,
log: target.DeliveryLogger(c.log, msgMeta),
}, nil
}
func (s *state) CheckConnection(ctx context.Context) module.CheckResult {
return module.CheckResult{}
}
func (s *state) CheckSender(ctx context.Context, addr string) module.CheckResult {
s.mailFrom = addr
return module.CheckResult{}
}
func (s *state) CheckRcpt(ctx context.Context, addr string) module.CheckResult {
s.rcpt = append(s.rcpt, addr)
return module.CheckResult{}
}
func addConnHeaders(r *http.Request, meta *module.MsgMetadata, mailFrom string, rcpts []string) {
r.Header.Add("From", mailFrom)
for _, rcpt := range rcpts {
r.Header.Add("Rcpt", rcpt)
}
r.Header.Add("Queue-ID", meta.ID)
conn := meta.Conn
if conn != nil {
if meta.Conn.AuthUser != "" {
r.Header.Add("User", meta.Conn.AuthUser)
}
if tcpAddr, ok := conn.RemoteAddr.(*net.TCPAddr); ok {
r.Header.Add("IP", tcpAddr.IP.String())
}
r.Header.Add("Helo", conn.Hostname)
name, err := conn.RDNSName.Get()
if err == nil && name != nil {
r.Header.Add("Hostname", name.(string))
}
if conn.TLS.HandshakeComplete {
r.Header.Add("TLS-Cipher", tls.CipherSuiteName(conn.TLS.CipherSuite))
switch conn.TLS.Version {
case tls.VersionTLS13:
r.Header.Add("TLS-Version", "1.3")
case tls.VersionTLS12:
r.Header.Add("TLS-Version", "1.2")
case tls.VersionTLS11:
r.Header.Add("TLS-Version", "1.1")
case tls.VersionTLS10:
r.Header.Add("TLS-Version", "1.0")
}
}
}
}
func (s *state) CheckBody(ctx context.Context, hdr textproto.Header, body buffer.Buffer) module.CheckResult {
bodyR, err := body.Open()
if err != nil {
return module.CheckResult{
Reject: true,
Reason: exterrors.WithFields(err, map[string]interface{}{"check": modName}),
}
}
var buf bytes.Buffer
if err := textproto.WriteHeader(&buf, hdr); err != nil {
return module.CheckResult{
Reject: true,
Reason: exterrors.WithFields(err, map[string]interface{}{"check": modName}),
}
}
r, err := http.NewRequest("POST", s.c.apiPath+"/checkv2", io.MultiReader(&buf, bodyR))
if err != nil {
return module.CheckResult{
Reject: true,
Reason: exterrors.WithFields(err, map[string]interface{}{"check": modName}),
}
}
r.Header.Add("Pass", "all") // TODO: does that need to be configurable?
// TODO: include version (needs maddy.Version moved somewhere to break circular dependency)
r.Header.Add("User-Agent", "maddy")
if s.c.tag != "" {
r.Header.Add("MTA-Tag", s.c.tag)
}
if s.c.settingsID != "" {
r.Header.Add("Settings-ID", s.c.settingsID)
}
if s.c.mtaName != "" {
r.Header.Add("MTA-Name", s.c.mtaName)
}
addConnHeaders(r, s.msgMeta, s.mailFrom, s.rcpt)
r.Header.Add("Content-Length", strconv.Itoa(body.Len()))
resp, err := s.c.client.Do(r)
if err != nil {
return s.c.ioErrAction.Apply(module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 451,
EnhancedCode: exterrors.EnhancedCode{4, 7, 0},
Message: "Internal error during policy check",
CheckName: modName,
Err: err,
},
})
}
if resp.StatusCode/100 != 2 {
return s.c.errorRespAction.Apply(module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 451,
EnhancedCode: exterrors.EnhancedCode{4, 7, 0},
Message: "Internal error during policy check",
CheckName: modName,
Err: fmt.Errorf("HTTP %d", resp.StatusCode),
},
})
}
defer resp.Body.Close()
var respData response
if err := json.NewDecoder(resp.Body).Decode(&respData); err != nil {
return s.c.ioErrAction.Apply(module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 451,
EnhancedCode: exterrors.EnhancedCode{4, 9, 0},
Message: "Internal error during policy check",
CheckName: modName,
Err: err,
},
})
}
switch respData.Action {
case "no action":
return module.CheckResult{}
case "greylist":
// uuh... TODO: Implement greylisting?
hdrAdd := textproto.Header{}
hdrAdd.Add("X-Spam-Score", strconv.FormatFloat(respData.Score, 'f', 2, 64))
return module.CheckResult{
Header: hdrAdd,
}
case "add header":
hdrAdd := textproto.Header{}
hdrAdd.Add("X-Spam-Flag", "Yes")
hdrAdd.Add("X-Spam-Score", strconv.FormatFloat(respData.Score, 'f', 2, 64))
return s.c.addHdrAction.Apply(module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 450,
EnhancedCode: exterrors.EnhancedCode{4, 7, 0},
Message: "Message rejected due to local policy",
CheckName: modName,
Misc: map[string]interface{}{"action": "add header"},
},
Header: hdrAdd,
})
case "rewrite subject":
hdrAdd := textproto.Header{}
hdrAdd.Add("X-Spam-Flag", "Yes")
hdrAdd.Add("X-Spam-Score", strconv.FormatFloat(respData.Score, 'f', 2, 64))
return s.c.rewriteSubjAction.Apply(module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 450,
EnhancedCode: exterrors.EnhancedCode{4, 7, 0},
Message: "Message rejected due to local policy",
CheckName: modName,
Misc: map[string]interface{}{"action": "rewrite subject"},
},
Header: hdrAdd,
})
case "soft reject":
return module.CheckResult{
Reject: true,
Reason: &exterrors.SMTPError{
Code: 450,
EnhancedCode: exterrors.EnhancedCode{4, 7, 0},
Message: "Message rejected due to local policy",
CheckName: modName,
Misc: map[string]interface{}{"action": "soft reject"},
},
}
case "reject":
return module.CheckResult{
Reject: true,
Reason: &exterrors.SMTPError{
Code: 550,
EnhancedCode: exterrors.EnhancedCode{5, 7, 0},
Message: "Message rejected due to local policy",
CheckName: modName,
Misc: map[string]interface{}{"action": "reject"},
},
}
}
s.log.Msg("unhandled action", "action", respData.Action)
return module.CheckResult{}
}
type response struct {
Score float64 `json:"score"`
Action string `json:"action"`
Subject string `json:"subject"`
Symbols map[string]struct {
Name string `json:"name"`
Score float64 `json:"score"`
}
}
func (s *state) Close() error {
return nil
}
func init() {
module.Register(modName, New)
}