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 dnsbl
 20
 21import (
 22	"context"
 23	"errors"
 24	"net"
 25	"runtime/trace"
 26	"strings"
 27	"sync"
 28
 29	"github.com/emersion/go-message/textproto"
 30	"github.com/foxcpp/maddy/framework/address"
 31	"github.com/foxcpp/maddy/framework/buffer"
 32	"github.com/foxcpp/maddy/framework/config"
 33	"github.com/foxcpp/maddy/framework/dns"
 34	"github.com/foxcpp/maddy/framework/exterrors"
 35	"github.com/foxcpp/maddy/framework/log"
 36	"github.com/foxcpp/maddy/framework/module"
 37	"github.com/foxcpp/maddy/internal/target"
 38	"golang.org/x/sync/errgroup"
 39)
 40
 41type List struct {
 42	Zone string
 43
 44	ClientIPv4 bool
 45	ClientIPv6 bool
 46
 47	EHLO     bool
 48	MAILFROM bool
 49
 50	ScoreAdj  int
 51	Responses []net.IPNet
 52}
 53
 54var defaultBL = List{
 55	ClientIPv4: true,
 56}
 57
 58type DNSBL struct {
 59	instName   string
 60	checkEarly bool
 61	inlineBls  []string
 62	bls        []List
 63
 64	quarantineThres int
 65	rejectThres     int
 66
 67	resolver dns.Resolver
 68	log      log.Logger
 69}
 70
 71func NewDNSBL(_, instName string, _, inlineArgs []string) (module.Module, error) {
 72	return &DNSBL{
 73		instName:  instName,
 74		inlineBls: inlineArgs,
 75
 76		resolver: dns.DefaultResolver(),
 77		log:      log.Logger{Name: "dnsbl"},
 78	}, nil
 79}
 80
 81func (bl *DNSBL) Name() string {
 82	return "dnsbl"
 83}
 84
 85func (bl *DNSBL) InstanceName() string {
 86	return bl.instName
 87}
 88
 89func (bl *DNSBL) Init(cfg *config.Map) error {
 90	cfg.Bool("debug", false, false, &bl.log.Debug)
 91	cfg.Bool("check_early", false, false, &bl.checkEarly)
 92	cfg.Int("quarantine_threshold", false, false, 1, &bl.quarantineThres)
 93	cfg.Int("reject_threshold", false, false, 9999, &bl.rejectThres)
 94	cfg.AllowUnknown()
 95	unknown, err := cfg.Process()
 96	if err != nil {
 97		return err
 98	}
 99
100	for _, inlineBl := range bl.inlineBls {
101		cfg := defaultBL
102		cfg.Zone = inlineBl
103		go bl.testList(cfg)
104		bl.bls = append(bl.bls, cfg)
105	}
106
107	for _, node := range unknown {
108		if err := bl.readListCfg(node); err != nil {
109			return err
110		}
111	}
112
113	return nil
114}
115
116func (bl *DNSBL) readListCfg(node config.Node) error {
117	var (
118		listCfg      List
119		responseNets []string
120	)
121
122	cfg := config.NewMap(nil, node)
123	cfg.Bool("client_ipv4", false, defaultBL.ClientIPv4, &listCfg.ClientIPv4)
124	cfg.Bool("client_ipv6", false, defaultBL.ClientIPv4, &listCfg.ClientIPv6)
125	cfg.Bool("ehlo", false, defaultBL.EHLO, &listCfg.EHLO)
126	cfg.Bool("mailfrom", false, defaultBL.EHLO, &listCfg.MAILFROM)
127	cfg.Int("score", false, false, 1, &listCfg.ScoreAdj)
128	cfg.StringList("responses", false, false, []string{"127.0.0.1/24"}, &responseNets)
129	if _, err := cfg.Process(); err != nil {
130		return err
131	}
132
133	for _, resp := range responseNets {
134		// If there is no / - it is a plain IP address, append
135		// '/32'.
136		if !strings.Contains(resp, "/") {
137			resp += "/32"
138		}
139
140		_, ipNet, err := net.ParseCIDR(resp)
141		if err != nil {
142			return err
143		}
144		listCfg.Responses = append(listCfg.Responses, *ipNet)
145	}
146
147	for _, zone := range append([]string{node.Name}, node.Args...) {
148		zoneCfg := listCfg
149		zoneCfg.Zone = zone
150
151		if listCfg.ScoreAdj < 0 {
152			if zoneCfg.EHLO {
153				return errors.New("dnsbl: 'ehlo' should not be used with negative score")
154			}
155			if zoneCfg.MAILFROM {
156				return errors.New("dnsbl: 'mailfrom' should not be used with negative score")
157			}
158		}
159		bl.bls = append(bl.bls, zoneCfg)
160
161		// From RFC 5782 Section 7:
162		// >To avoid this situation, systems that use
163		// >DNSxLs SHOULD check for the test entries described in Section 5 to
164		// >ensure that a domain actually has the structure of a DNSxL, and
165		// >SHOULD NOT use any DNSxL domain that does not have correct test
166		// >entries.
167		// Sadly, however, many DNSBLs lack test records so at most we can
168		// log a warning. Also, DNS is kinda slow so we do checks
169		// asynchronously to prevent slowing down server start-up.
170		go bl.testList(zoneCfg)
171	}
172
173	return nil
174}
175
176func (bl *DNSBL) testList(listCfg List) {
177	// Check RFC 5782 Section 5 requirements.
178
179	bl.log.DebugMsg("testing list for RFC 5782 requirements...", "list", listCfg.Zone)
180
181	// 1. IPv4-based DNSxLs MUST contain an entry for 127.0.0.2 for testing purposes.
182	if listCfg.ClientIPv4 {
183		err := checkIP(context.Background(), bl.resolver, listCfg, net.IPv4(127, 0, 0, 2))
184		if err == nil {
185			bl.log.Msg("List does not contain a test record for 127.0.0.2", "list", listCfg.Zone)
186		} else if _, ok := err.(ListedErr); !ok {
187			bl.log.Error("lookup error, bailing out", err, "list", listCfg.Zone)
188			return
189		}
190
191		// 2. IPv4-based DNSxLs MUST NOT contain an entry for 127.0.0.1.
192		err = checkIP(context.Background(), bl.resolver, listCfg, net.IPv4(127, 0, 0, 1))
193		if err != nil {
194			_, ok := err.(ListedErr)
195			if !ok {
196				bl.log.Error("lookup error, bailing out", err, "list", listCfg.Zone)
197				return
198			}
199			bl.log.Msg("List contains a record for 127.0.0.1", "list", listCfg.Zone)
200		}
201	}
202
203	if listCfg.ClientIPv6 {
204		// 1. IPv6-based DNSxLs MUST contain an entry for ::FFFF:7F00:2
205		mustIP := net.ParseIP("::FFFF:7F00:2")
206
207		err := checkIP(context.Background(), bl.resolver, listCfg, mustIP)
208		if err == nil {
209			bl.log.Msg("List does not contain a test record for ::FFFF:7F00:2", "list", listCfg.Zone)
210		} else if _, ok := err.(ListedErr); !ok {
211			bl.log.Error("lookup error, bailing out", err, "list", listCfg.Zone)
212			return
213		}
214
215		// 2. IPv4-based DNSxLs MUST NOT contain an entry for ::FFFF:7F00:1
216		mustNotIP := net.ParseIP("::FFFF:7F00:1")
217		err = checkIP(context.Background(), bl.resolver, listCfg, mustNotIP)
218		if err != nil {
219			_, ok := err.(ListedErr)
220			if !ok {
221				bl.log.Error("lookup error, bailing out", err, "list", listCfg.Zone)
222				return
223			}
224			bl.log.Msg("List contains a record for ::FFFF:7F00:1", "list", listCfg.Zone)
225		}
226	}
227
228	if listCfg.EHLO || listCfg.MAILFROM {
229		// Domain-name-based DNSxLs MUST contain an entry for the reserved
230		// domain name "TEST".
231		err := checkDomain(context.Background(), bl.resolver, listCfg, "test")
232		if err == nil {
233			bl.log.Msg("List does not contain a test record for 'test' TLD", "list", listCfg.Zone)
234		} else if _, ok := err.(ListedErr); !ok {
235			bl.log.Error("lookup error, bailing out", err, "list", listCfg.Zone)
236			return
237		}
238
239		// ... and MUST NOT contain an entry for the reserved domain name
240		// "INVALID".
241		err = checkDomain(context.Background(), bl.resolver, listCfg, "invalid")
242		if err != nil {
243			_, ok := err.(ListedErr)
244			if !ok {
245				bl.log.Error("lookup error, bailing out", err, "list", listCfg.Zone)
246				return
247			}
248			bl.log.Msg("List contains a record for 'invalid' TLD", "list", listCfg.Zone)
249		}
250	}
251}
252
253func (bl *DNSBL) checkList(ctx context.Context, list List, ip net.IP, ehlo, mailFrom string) error {
254	if list.ClientIPv4 || list.ClientIPv6 {
255		if err := checkIP(ctx, bl.resolver, list, ip); err != nil {
256			return err
257		}
258	}
259
260	if list.EHLO && ehlo != "" {
261		// Skip IPs in EHLO.
262		if strings.HasPrefix(ehlo, "[") && strings.HasSuffix(ehlo, "]") {
263			return nil
264		}
265
266		if err := checkDomain(ctx, bl.resolver, list, ehlo); err != nil {
267			return err
268		}
269	}
270
271	if list.MAILFROM && mailFrom != "" {
272		_, domain, err := address.Split(mailFrom)
273		if err != nil || domain == "" {
274			// Probably <postmaster> or <>, not much we can check.
275			return nil
276		}
277
278		// If EHLO == domain (usually the case for small/private email servers)
279		// then don't do a second lookup for the same domain.
280		if list.EHLO && dns.Equal(domain, ehlo) {
281			return nil
282		}
283
284		if err := checkDomain(ctx, bl.resolver, list, domain); err != nil {
285			return err
286		}
287	}
288
289	return nil
290}
291
292func (bl *DNSBL) checkLists(ctx context.Context, ip net.IP, ehlo, mailFrom string) module.CheckResult {
293	var (
294		eg = errgroup.Group{}
295
296		// Protects variables below.
297		lck      sync.Mutex
298		score    int
299		listedOn []string
300		reasons  []string
301	)
302
303	for _, list := range bl.bls {
304		eg.Go(func() error {
305			err := bl.checkList(ctx, list, ip, ehlo, mailFrom)
306			if err != nil {
307				listErr, listed := err.(ListedErr)
308				if !listed {
309					return err
310				}
311
312				lck.Lock()
313				defer lck.Unlock()
314				listedOn = append(listedOn, listErr.List)
315				reasons = append(reasons, listErr.Reason)
316				score += list.ScoreAdj
317			}
318			return nil
319		})
320	}
321
322	err := eg.Wait()
323	if err != nil {
324		// Lookup error for BL, hard-fail.
325		return module.CheckResult{
326			Reject: true,
327			Reason: &exterrors.SMTPError{
328				Code:         exterrors.SMTPCode(err, 451, 554),
329				EnhancedCode: exterrors.SMTPEnchCode(err, exterrors.EnhancedCode{0, 7, 0}),
330				Message:      "DNS error during policy check",
331				Err:          err,
332				CheckName:    "dnsbl",
333			},
334		}
335	}
336
337	if score >= bl.rejectThres {
338		return module.CheckResult{
339			Reject: true,
340			Reason: &exterrors.SMTPError{
341				Code:         554,
342				EnhancedCode: exterrors.EnhancedCode{5, 7, 0},
343				Message:      "Client identity is listed in the used DNSBL",
344				Err:          err,
345				CheckName:    "dnsbl",
346			},
347		}
348	}
349	if score >= bl.quarantineThres {
350		return module.CheckResult{
351			Quarantine: true,
352			Reason: &exterrors.SMTPError{
353				Code:         554,
354				EnhancedCode: exterrors.EnhancedCode{5, 7, 0},
355				Message:      "Client identity is listed in the used DNSBL",
356				Err:          err,
357				CheckName:    "dnsbl",
358			},
359		}
360	}
361
362	return module.CheckResult{}
363}
364
365// CheckConnection implements module.EarlyCheck.
366func (bl *DNSBL) CheckConnection(ctx context.Context, state *module.ConnState) error {
367	defer trace.StartRegion(ctx, "dnsbl/CheckConnection (Early)").End()
368
369	ip, ok := state.RemoteAddr.(*net.TCPAddr)
370	if !ok {
371		bl.log.Msg("non-TCP/IP source",
372			"src_addr", state.RemoteAddr,
373			"src_host", state.Hostname)
374		return nil
375	}
376
377	result := bl.checkLists(ctx, ip.IP, state.Hostname, "")
378	if result.Reject && bl.checkEarly {
379		return result.Reason
380	}
381
382	state.ModData.Set(bl, true, result)
383
384	return nil
385}
386
387type state struct {
388	bl      *DNSBL
389	msgMeta *module.MsgMetadata
390	log     log.Logger
391}
392
393func (bl *DNSBL) CheckStateForMsg(ctx context.Context, msgMeta *module.MsgMetadata) (module.CheckState, error) {
394	return &state{
395		bl:      bl,
396		msgMeta: msgMeta,
397		log:     target.DeliveryLogger(bl.log, msgMeta),
398	}, nil
399}
400
401func (s *state) CheckConnection(ctx context.Context) module.CheckResult {
402	defer trace.StartRegion(ctx, "dnsbl/CheckConnection").End()
403
404	if s.msgMeta.Conn == nil {
405		s.log.Msg("locally generated message, ignoring")
406		return module.CheckResult{}
407	}
408
409	result := s.msgMeta.Conn.ModData.Get(s.bl, true)
410	if result != nil {
411		return result.(module.CheckResult)
412	}
413
414	return module.CheckResult{}
415}
416
417func (*state) CheckSender(context.Context, string) module.CheckResult {
418	return module.CheckResult{}
419}
420
421func (*state) CheckRcpt(context.Context, string) module.CheckResult {
422	return module.CheckResult{}
423}
424
425func (*state) CheckBody(context.Context, textproto.Header, buffer.Buffer) module.CheckResult {
426	return module.CheckResult{}
427}
428
429func (*state) Close() error {
430	return nil
431}
432
433func init() {
434	module.Register("check.dnsbl", NewDNSBL)
435}