1/*2Maddy Mail Server - Composable all-in-one email server.3Copyright © 2019-2020 Max Mazurov <fox.cpp@disroot.org>, Maddy Mail Server contributors45This program is free software: you can redistribute it and/or modify6it under the terms of the GNU General Public License as published by7the Free Software Foundation, either version 3 of the License, or8(at your option) any later version.910This program is distributed in the hope that it will be useful,11but WITHOUT ANY WARRANTY; without even the implied warranty of12MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the13GNU General Public License for more details.1415You should have received a copy of the GNU General Public License16along with this program. If not, see <https://www.gnu.org/licenses/>.17*/1819package dnsbl2021import (22 "context"23 "errors"24 "net"25 "runtime/trace"26 "strings"27 "sync"2829 "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)4041type List struct {42 Zone string4344 ClientIPv4 bool45 ClientIPv6 bool4647 EHLO bool48 MAILFROM bool4950 ScoreAdj int51 Responses []net.IPNet52}5354var defaultBL = List{55 ClientIPv4: true,56}5758type DNSBL struct {59 instName string60 checkEarly bool61 inlineBls []string62 bls []List6364 quarantineThres int65 rejectThres int6667 resolver dns.Resolver68 log log.Logger69}7071func NewDNSBL(_, instName string, _, inlineArgs []string) (module.Module, error) {72 return &DNSBL{73 instName: instName,74 inlineBls: inlineArgs,7576 resolver: dns.DefaultResolver(),77 log: log.Logger{Name: "dnsbl"},78 }, nil79}8081func (bl *DNSBL) Name() string {82 return "dnsbl"83}8485func (bl *DNSBL) InstanceName() string {86 return bl.instName87}8889func (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 err98 }99100 for _, inlineBl := range bl.inlineBls {101 cfg := defaultBL102 cfg.Zone = inlineBl103 go bl.testList(cfg)104 bl.bls = append(bl.bls, cfg)105 }106107 for _, node := range unknown {108 if err := bl.readListCfg(node); err != nil {109 return err110 }111 }112113 return nil114}115116func (bl *DNSBL) readListCfg(node config.Node) error {117 var (118 listCfg List119 responseNets []string120 )121122 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 err131 }132133 for _, resp := range responseNets {134 // If there is no / - it is a plain IP address, append135 // '/32'.136 if !strings.Contains(resp, "/") {137 resp += "/32"138 }139140 _, ipNet, err := net.ParseCIDR(resp)141 if err != nil {142 return err143 }144 listCfg.Responses = append(listCfg.Responses, *ipNet)145 }146147 for _, zone := range append([]string{node.Name}, node.Args...) {148 zoneCfg := listCfg149 zoneCfg.Zone = zone150151 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)160161 // From RFC 5782 Section 7:162 // >To avoid this situation, systems that use163 // >DNSxLs SHOULD check for the test entries described in Section 5 to164 // >ensure that a domain actually has the structure of a DNSxL, and165 // >SHOULD NOT use any DNSxL domain that does not have correct test166 // >entries.167 // Sadly, however, many DNSBLs lack test records so at most we can168 // log a warning. Also, DNS is kinda slow so we do checks169 // asynchronously to prevent slowing down server start-up.170 go bl.testList(zoneCfg)171 }172173 return nil174}175176func (bl *DNSBL) testList(listCfg List) {177 // Check RFC 5782 Section 5 requirements.178179 bl.log.DebugMsg("testing list for RFC 5782 requirements...", "list", listCfg.Zone)180181 // 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 return189 }190191 // 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 return198 }199 bl.log.Msg("List contains a record for 127.0.0.1", "list", listCfg.Zone)200 }201 }202203 if listCfg.ClientIPv6 {204 // 1. IPv6-based DNSxLs MUST contain an entry for ::FFFF:7F00:2205 mustIP := net.ParseIP("::FFFF:7F00:2")206207 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 return213 }214215 // 2. IPv4-based DNSxLs MUST NOT contain an entry for ::FFFF:7F00:1216 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 return223 }224 bl.log.Msg("List contains a record for ::FFFF:7F00:1", "list", listCfg.Zone)225 }226 }227228 if listCfg.EHLO || listCfg.MAILFROM {229 // Domain-name-based DNSxLs MUST contain an entry for the reserved230 // 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 return237 }238239 // ... and MUST NOT contain an entry for the reserved domain name240 // "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 return247 }248 bl.log.Msg("List contains a record for 'invalid' TLD", "list", listCfg.Zone)249 }250 }251}252253func (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 err257 }258 }259260 if list.EHLO && ehlo != "" {261 // Skip IPs in EHLO.262 if strings.HasPrefix(ehlo, "[") && strings.HasSuffix(ehlo, "]") {263 return nil264 }265266 if err := checkDomain(ctx, bl.resolver, list, ehlo); err != nil {267 return err268 }269 }270271 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 nil276 }277278 // 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 nil282 }283284 if err := checkDomain(ctx, bl.resolver, list, domain); err != nil {285 return err286 }287 }288289 return nil290}291292func (bl *DNSBL) checkLists(ctx context.Context, ip net.IP, ehlo, mailFrom string) module.CheckResult {293 var (294 eg = errgroup.Group{}295296 // Protects variables below.297 lck sync.Mutex298 score int299 listedOn []string300 reasons []string301 )302303 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 err310 }311312 lck.Lock()313 defer lck.Unlock()314 listedOn = append(listedOn, listErr.List)315 reasons = append(reasons, listErr.Reason)316 score += list.ScoreAdj317 }318 return nil319 })320 }321322 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 }336337 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 }361362 return module.CheckResult{}363}364365// CheckConnection implements module.EarlyCheck.366func (bl *DNSBL) CheckConnection(ctx context.Context, state *module.ConnState) error {367 defer trace.StartRegion(ctx, "dnsbl/CheckConnection (Early)").End()368369 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 nil375 }376377 result := bl.checkLists(ctx, ip.IP, state.Hostname, "")378 if result.Reject && bl.checkEarly {379 return result.Reason380 }381382 state.ModData.Set(bl, true, result)383384 return nil385}386387type state struct {388 bl *DNSBL389 msgMeta *module.MsgMetadata390 log log.Logger391}392393func (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 }, nil399}400401func (s *state) CheckConnection(ctx context.Context) module.CheckResult {402 defer trace.StartRegion(ctx, "dnsbl/CheckConnection").End()403404 if s.msgMeta.Conn == nil {405 s.log.Msg("locally generated message, ignoring")406 return module.CheckResult{}407 }408409 result := s.msgMeta.Conn.ModData.Get(s.bl, true)410 if result != nil {411 return result.(module.CheckResult)412 }413414 return module.CheckResult{}415}416417func (*state) CheckSender(context.Context, string) module.CheckResult {418 return module.CheckResult{}419}420421func (*state) CheckRcpt(context.Context, string) module.CheckResult {422 return module.CheckResult{}423}424425func (*state) CheckBody(context.Context, textproto.Header, buffer.Buffer) module.CheckResult {426 return module.CheckResult{}427}428429func (*state) Close() error {430 return nil431}432433func init() {434 module.Register("check.dnsbl", NewDNSBL)435}