maddy

Fork https://github.com/foxcpp/maddy

git clone git://git.lin.moe/go/maddy.git

  1/*
  2Maddy Mail Server - Composable all-in-one email server.
  3Copyright © 2019-2020 Max Mazurov <fox.cpp@disroot.org>, Maddy Mail Server contributors
  4
  5This program is free software: you can redistribute it and/or modify
  6it under the terms of the GNU General Public License as published by
  7the Free Software Foundation, either version 3 of the License, or
  8(at your option) any later version.
  9
 10This program is distributed in the hope that it will be useful,
 11but WITHOUT ANY WARRANTY; without even the implied warranty of
 12MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 13GNU General Public License for more details.
 14
 15You should have received a copy of the GNU General Public License
 16along with this program.  If not, see <https://www.gnu.org/licenses/>.
 17*/
 18
 19package rspamd
 20
 21import (
 22	"bytes"
 23	"context"
 24	"crypto/tls"
 25	"encoding/json"
 26	"fmt"
 27	"io"
 28	"net"
 29	"net/http"
 30	"strconv"
 31	"strings"
 32
 33	"github.com/emersion/go-message/textproto"
 34	"github.com/foxcpp/maddy/framework/buffer"
 35	"github.com/foxcpp/maddy/framework/config"
 36	modconfig "github.com/foxcpp/maddy/framework/config/module"
 37	tls2 "github.com/foxcpp/maddy/framework/config/tls"
 38	"github.com/foxcpp/maddy/framework/exterrors"
 39	"github.com/foxcpp/maddy/framework/log"
 40	"github.com/foxcpp/maddy/framework/module"
 41	"github.com/foxcpp/maddy/internal/target"
 42)
 43
 44const modName = "check.rspamd"
 45
 46type Check struct {
 47	instName string
 48	log      log.Logger
 49
 50	apiPath    string
 51	flags      string
 52	settingsID string
 53	tag        string
 54	mtaName    string
 55
 56	ioErrAction       modconfig.FailAction
 57	errorRespAction   modconfig.FailAction
 58	addHdrAction      modconfig.FailAction
 59	rewriteSubjAction modconfig.FailAction
 60
 61	client *http.Client
 62}
 63
 64func New(modName, instName string, _, inlineArgs []string) (module.Module, error) {
 65	c := &Check{
 66		instName: instName,
 67		client:   http.DefaultClient,
 68		log:      log.Logger{Name: modName, Debug: log.DefaultLogger.Debug},
 69	}
 70
 71	switch len(inlineArgs) {
 72	case 1:
 73		c.apiPath = inlineArgs[0]
 74	case 0:
 75		c.apiPath = "http://127.0.0.1:11333"
 76	default:
 77		return nil, fmt.Errorf("%s: unexpected amount of inline arguments", modName)
 78	}
 79
 80	return c, nil
 81}
 82
 83func (c *Check) Name() string {
 84	return modName
 85}
 86
 87func (c *Check) InstanceName() string {
 88	return c.instName
 89}
 90
 91func (c *Check) Init(cfg *config.Map) error {
 92	var (
 93		tlsConfig tls.Config
 94		flags     []string
 95	)
 96
 97	cfg.Custom("tls_client", true, false, func() (interface{}, error) {
 98		return tls.Config{}, nil
 99	}, tls2.TLSClientBlock, &tlsConfig)
100	cfg.String("api_path", false, false, c.apiPath, &c.apiPath)
101	cfg.String("settings_id", false, false, "", &c.settingsID)
102	cfg.String("tag", false, false, "maddy", &c.tag)
103	cfg.String("hostname", true, false, "", &c.mtaName)
104	cfg.Custom("io_error_action", false, false,
105		func() (interface{}, error) {
106			return modconfig.FailAction{}, nil
107		}, modconfig.FailActionDirective, &c.ioErrAction)
108	cfg.Custom("error_resp_action", false, false,
109		func() (interface{}, error) {
110			return modconfig.FailAction{}, nil
111		}, modconfig.FailActionDirective, &c.errorRespAction)
112	cfg.Custom("add_header_action", false, false,
113		func() (interface{}, error) {
114			return modconfig.FailAction{Quarantine: true}, nil
115		}, modconfig.FailActionDirective, &c.addHdrAction)
116	cfg.Custom("rewrite_subj_action", false, false,
117		func() (interface{}, error) {
118			return modconfig.FailAction{Quarantine: true}, nil
119		}, modconfig.FailActionDirective, &c.rewriteSubjAction)
120	cfg.StringList("flags", false, false, []string{"pass_all"}, &flags)
121	if _, err := cfg.Process(); err != nil {
122		return err
123	}
124
125	c.client = &http.Client{
126		Transport: &http.Transport{
127			TLSClientConfig: &tlsConfig,
128		},
129	}
130	c.flags = strings.Join(flags, ",")
131
132	return nil
133}
134
135type state struct {
136	c       *Check
137	msgMeta *module.MsgMetadata
138	log     log.Logger
139
140	mailFrom string
141	rcpt     []string
142}
143
144func (c *Check) CheckStateForMsg(ctx context.Context, msgMeta *module.MsgMetadata) (module.CheckState, error) {
145	return &state{
146		c:       c,
147		msgMeta: msgMeta,
148		log:     target.DeliveryLogger(c.log, msgMeta),
149	}, nil
150}
151
152func (s *state) CheckConnection(ctx context.Context) module.CheckResult {
153	return module.CheckResult{}
154}
155
156func (s *state) CheckSender(ctx context.Context, addr string) module.CheckResult {
157	s.mailFrom = addr
158	return module.CheckResult{}
159}
160
161func (s *state) CheckRcpt(ctx context.Context, addr string) module.CheckResult {
162	s.rcpt = append(s.rcpt, addr)
163	return module.CheckResult{}
164}
165
166func addConnHeaders(r *http.Request, meta *module.MsgMetadata, mailFrom string, rcpts []string) {
167	r.Header.Add("From", mailFrom)
168	for _, rcpt := range rcpts {
169		r.Header.Add("Rcpt", rcpt)
170	}
171
172	r.Header.Add("Queue-ID", meta.ID)
173
174	conn := meta.Conn
175	if conn != nil {
176		if meta.Conn.AuthUser != "" {
177			r.Header.Add("User", meta.Conn.AuthUser)
178		}
179
180		if tcpAddr, ok := conn.RemoteAddr.(*net.TCPAddr); ok {
181			r.Header.Add("IP", tcpAddr.IP.String())
182		}
183		r.Header.Add("Helo", conn.Hostname)
184		name, err := conn.RDNSName.Get()
185		if err == nil && name != nil {
186			r.Header.Add("Hostname", name.(string))
187		}
188
189		if conn.TLS.HandshakeComplete {
190			r.Header.Add("TLS-Cipher", tls.CipherSuiteName(conn.TLS.CipherSuite))
191			switch conn.TLS.Version {
192			case tls.VersionTLS13:
193				r.Header.Add("TLS-Version", "1.3")
194			case tls.VersionTLS12:
195				r.Header.Add("TLS-Version", "1.2")
196			case tls.VersionTLS11:
197				r.Header.Add("TLS-Version", "1.1")
198			case tls.VersionTLS10:
199				r.Header.Add("TLS-Version", "1.0")
200			}
201		}
202	}
203}
204
205func (s *state) CheckBody(ctx context.Context, hdr textproto.Header, body buffer.Buffer) module.CheckResult {
206	bodyR, err := body.Open()
207	if err != nil {
208		return module.CheckResult{
209			Reject: true,
210			Reason: exterrors.WithFields(err, map[string]interface{}{"check": modName}),
211		}
212	}
213
214	var buf bytes.Buffer
215	if err := textproto.WriteHeader(&buf, hdr); err != nil {
216		return module.CheckResult{
217			Reject: true,
218			Reason: exterrors.WithFields(err, map[string]interface{}{"check": modName}),
219		}
220	}
221
222	r, err := http.NewRequest("POST", s.c.apiPath+"/checkv2", io.MultiReader(&buf, bodyR))
223	if err != nil {
224		return module.CheckResult{
225			Reject: true,
226			Reason: exterrors.WithFields(err, map[string]interface{}{"check": modName}),
227		}
228	}
229
230	r.Header.Add("Pass", "all") // TODO: does that need to be configurable?
231	// TODO: include version (needs maddy.Version moved somewhere to break circular dependency)
232	r.Header.Add("User-Agent", "maddy")
233	if s.c.tag != "" {
234		r.Header.Add("MTA-Tag", s.c.tag)
235	}
236	if s.c.settingsID != "" {
237		r.Header.Add("Settings-ID", s.c.settingsID)
238	}
239	if s.c.mtaName != "" {
240		r.Header.Add("MTA-Name", s.c.mtaName)
241	}
242
243	addConnHeaders(r, s.msgMeta, s.mailFrom, s.rcpt)
244	r.Header.Add("Content-Length", strconv.Itoa(body.Len()))
245
246	resp, err := s.c.client.Do(r)
247	if err != nil {
248		return s.c.ioErrAction.Apply(module.CheckResult{
249			Reason: &exterrors.SMTPError{
250				Code:         451,
251				EnhancedCode: exterrors.EnhancedCode{4, 7, 0},
252				Message:      "Internal error during policy check",
253				CheckName:    modName,
254				Err:          err,
255			},
256		})
257	}
258	if resp.StatusCode/100 != 2 {
259		return s.c.errorRespAction.Apply(module.CheckResult{
260			Reason: &exterrors.SMTPError{
261				Code:         451,
262				EnhancedCode: exterrors.EnhancedCode{4, 7, 0},
263				Message:      "Internal error during policy check",
264				CheckName:    modName,
265				Err:          fmt.Errorf("HTTP %d", resp.StatusCode),
266			},
267		})
268	}
269	defer resp.Body.Close()
270
271	var respData response
272	if err := json.NewDecoder(resp.Body).Decode(&respData); err != nil {
273		return s.c.ioErrAction.Apply(module.CheckResult{
274			Reason: &exterrors.SMTPError{
275				Code:         451,
276				EnhancedCode: exterrors.EnhancedCode{4, 9, 0},
277				Message:      "Internal error during policy check",
278				CheckName:    modName,
279				Err:          err,
280			},
281		})
282	}
283
284	switch respData.Action {
285	case "no action":
286		return module.CheckResult{}
287	case "greylist":
288		// uuh... TODO: Implement greylisting?
289		hdrAdd := textproto.Header{}
290		hdrAdd.Add("X-Spam-Score", strconv.FormatFloat(respData.Score, 'f', 2, 64))
291		return module.CheckResult{
292			Header: hdrAdd,
293		}
294	case "add header":
295		hdrAdd := textproto.Header{}
296		hdrAdd.Add("X-Spam-Flag", "Yes")
297		hdrAdd.Add("X-Spam-Score", strconv.FormatFloat(respData.Score, 'f', 2, 64))
298		return s.c.addHdrAction.Apply(module.CheckResult{
299			Reason: &exterrors.SMTPError{
300				Code:         450,
301				EnhancedCode: exterrors.EnhancedCode{4, 7, 0},
302				Message:      "Message rejected due to local policy",
303				CheckName:    modName,
304				Misc:         map[string]interface{}{"action": "add header"},
305			},
306			Header: hdrAdd,
307		})
308	case "rewrite subject":
309		hdrAdd := textproto.Header{}
310		hdrAdd.Add("X-Spam-Flag", "Yes")
311		hdrAdd.Add("X-Spam-Score", strconv.FormatFloat(respData.Score, 'f', 2, 64))
312		return s.c.rewriteSubjAction.Apply(module.CheckResult{
313			Reason: &exterrors.SMTPError{
314				Code:         450,
315				EnhancedCode: exterrors.EnhancedCode{4, 7, 0},
316				Message:      "Message rejected due to local policy",
317				CheckName:    modName,
318				Misc:         map[string]interface{}{"action": "rewrite subject"},
319			},
320			Header: hdrAdd,
321		})
322	case "soft reject":
323		return module.CheckResult{
324			Reject: true,
325			Reason: &exterrors.SMTPError{
326				Code:         450,
327				EnhancedCode: exterrors.EnhancedCode{4, 7, 0},
328				Message:      "Message rejected due to local policy",
329				CheckName:    modName,
330				Misc:         map[string]interface{}{"action": "soft reject"},
331			},
332		}
333	case "reject":
334		return module.CheckResult{
335			Reject: true,
336			Reason: &exterrors.SMTPError{
337				Code:         550,
338				EnhancedCode: exterrors.EnhancedCode{5, 7, 0},
339				Message:      "Message rejected due to local policy",
340				CheckName:    modName,
341				Misc:         map[string]interface{}{"action": "reject"},
342			},
343		}
344	}
345
346	s.log.Msg("unhandled action", "action", respData.Action)
347
348	return module.CheckResult{}
349}
350
351type response struct {
352	Score   float64 `json:"score"`
353	Action  string  `json:"action"`
354	Subject string  `json:"subject"`
355	Symbols map[string]struct {
356		Name  string  `json:"name"`
357		Score float64 `json:"score"`
358	}
359}
360
361func (s *state) Close() error {
362	return nil
363}
364
365func init() {
366	module.Register(modName, New)
367}