/*
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 dnsbl

import (
	"context"
	"errors"
	"net"
	"runtime/trace"
	"strings"
	"sync"

	"github.com/emersion/go-message/textproto"
	"github.com/foxcpp/maddy/framework/address"
	"github.com/foxcpp/maddy/framework/buffer"
	"github.com/foxcpp/maddy/framework/config"
	"github.com/foxcpp/maddy/framework/container"
	"github.com/foxcpp/maddy/framework/dns"
	"github.com/foxcpp/maddy/framework/exterrors"
	"github.com/foxcpp/maddy/framework/log"
	"github.com/foxcpp/maddy/framework/module"
	"github.com/foxcpp/maddy/framework/module/modules"
	"github.com/foxcpp/maddy/internal/target"
	"golang.org/x/sync/errgroup"
)

type ResponseRule struct {
	Networks []net.IPNet
	Score    int
	Message  string
}

type List struct {
	Zone string

	ClientIPv4 bool
	ClientIPv6 bool

	EHLO     bool
	MAILFROM bool

	ScoreAdj  int
	Responses []net.IPNet

	ResponseRules []ResponseRule
}

var defaultBL = List{
	ClientIPv4: true,
}

type DNSBL struct {
	instName   string
	checkEarly bool
	bls        []List

	quarantineThres int
	rejectThres     int

	resolver dns.Resolver
	log      *log.Logger
}

func New(c *container.C, modName, instName string) (module.Module, error) {
	return &DNSBL{
		instName: instName,

		resolver: dns.DefaultResolver(),
		log:      c.DefaultLogger.Sublogger(modName),
	}, nil
}

func (bl *DNSBL) Name() string {
	return "dnsbl"
}

func (bl *DNSBL) InstanceName() string {
	return bl.instName
}

func (bl *DNSBL) Configure(inlineArgs []string, cfg *config.Map) error {
	cfg.Bool("debug", false, false, &bl.log.Debug)
	cfg.Bool("check_early", false, false, &bl.checkEarly)
	cfg.Int("quarantine_threshold", false, false, 1, &bl.quarantineThres)
	cfg.Int("reject_threshold", false, false, 9999, &bl.rejectThres)
	cfg.AllowUnknown()
	unknown, err := cfg.Process()
	if err != nil {
		return err
	}

	for _, inlineBl := range inlineArgs {
		cfg := defaultBL
		cfg.Zone = inlineBl
		go bl.testList(cfg)
		bl.bls = append(bl.bls, cfg)
	}

	for _, node := range unknown {
		if err := bl.readListCfg(node); err != nil {
			return err
		}
	}

	return nil
}

func (bl *DNSBL) readListCfg(node config.Node) error {
	var (
		listCfg      List
		responseNets []string
	)

	cfg := config.NewMap(nil, node)
	cfg.Bool("client_ipv4", false, defaultBL.ClientIPv4, &listCfg.ClientIPv4)
	cfg.Bool("client_ipv6", false, defaultBL.ClientIPv4, &listCfg.ClientIPv6)
	cfg.Bool("ehlo", false, defaultBL.EHLO, &listCfg.EHLO)
	cfg.Bool("mailfrom", false, defaultBL.EHLO, &listCfg.MAILFROM)
	cfg.Int("score", false, false, 1, &listCfg.ScoreAdj)
	cfg.StringList("responses", false, false, []string{"127.0.0.1/24"}, &responseNets)
	cfg.Callback("response", func(_ *config.Map, node config.Node) error {
		rule, err := parseResponseRule(node)
		if err != nil {
			return err
		}
		listCfg.ResponseRules = append(listCfg.ResponseRules, rule)
		return nil
	})
	if _, err := cfg.Process(); err != nil {
		return err
	}

	for _, resp := range responseNets {
		// If there is no / - it is a plain IP address, append
		// '/32'.
		if !strings.Contains(resp, "/") {
			resp += "/32"
		}

		_, ipNet, err := net.ParseCIDR(resp)
		if err != nil {
			return err
		}
		listCfg.Responses = append(listCfg.Responses, *ipNet)
	}

	// Warn if both response and responses are configured
	if len(listCfg.ResponseRules) > 0 && len(responseNets) > 0 {
		bl.log.Msg("both 'response' blocks and 'responses' directive are specified, 'response' blocks take precedence", "list", node.Name)
	}

	for _, zone := range append([]string{node.Name}, node.Args...) {
		zoneCfg := listCfg
		zoneCfg.Zone = zone

		if listCfg.ScoreAdj < 0 {
			if zoneCfg.EHLO {
				return errors.New("dnsbl: 'ehlo' should not be used with negative score")
			}
			if zoneCfg.MAILFROM {
				return errors.New("dnsbl: 'mailfrom' should not be used with negative score")
			}
		}
		bl.bls = append(bl.bls, zoneCfg)

		// From RFC 5782 Section 7:
		// >To avoid this situation, systems that use
		// >DNSxLs SHOULD check for the test entries described in Section 5 to
		// >ensure that a domain actually has the structure of a DNSxL, and
		// >SHOULD NOT use any DNSxL domain that does not have correct test
		// >entries.
		// Sadly, however, many DNSBLs lack test records so at most we can
		// log a warning. Also, DNS is kinda slow so we do checks
		// asynchronously to prevent slowing down server start-up.
		go bl.testList(zoneCfg)
	}

	return nil
}

func parseResponseRule(node config.Node) (ResponseRule, error) {
	var rule ResponseRule

	if len(node.Args) == 0 {
		return rule, config.NodeErr(node, "response block requires at least one IP address or CIDR as argument")
	}

	// Parse IP addresses/CIDRs from arguments
	for _, arg := range node.Args {
		// If there is no / - it is a plain IP address, append '/32' or '/128'
		resp := arg
		if !strings.Contains(resp, "/") {
			// Check if it's IPv6 to determine the mask
			if strings.Contains(resp, ":") {
				resp += "/128"
			} else {
				resp += "/32"
			}
		}

		_, ipNet, err := net.ParseCIDR(resp)
		if err != nil {
			return rule, config.NodeErr(node, "invalid IP address or CIDR: %s: %v", arg, err)
		}
		rule.Networks = append(rule.Networks, *ipNet)
	}

	// Parse directives within the response block
	cfg := config.NewMap(nil, node)
	cfg.Int("score", false, true, 0, &rule.Score)
	cfg.String("message", false, false, "", &rule.Message)
	if _, err := cfg.Process(); err != nil {
		return rule, err
	}

	return rule, nil
}

func (bl *DNSBL) testList(listCfg List) {
	// Check RFC 5782 Section 5 requirements.

	bl.log.DebugMsg("testing list for RFC 5782 requirements...", "list", listCfg.Zone)

	// 1. IPv4-based DNSxLs MUST contain an entry for 127.0.0.2 for testing purposes.
	if listCfg.ClientIPv4 {
		err := checkIP(context.Background(), bl.resolver, listCfg, net.IPv4(127, 0, 0, 2))
		if err == nil {
			bl.log.Msg("List does not contain a test record for 127.0.0.2", "list", listCfg.Zone)
		} else if _, ok := err.(ListedErr); !ok {
			bl.log.Error("lookup error, bailing out", err, "list", listCfg.Zone)
			return
		}

		// 2. IPv4-based DNSxLs MUST NOT contain an entry for 127.0.0.1.
		err = checkIP(context.Background(), bl.resolver, listCfg, net.IPv4(127, 0, 0, 1))
		if err != nil {
			_, ok := err.(ListedErr)
			if !ok {
				bl.log.Error("lookup error, bailing out", err, "list", listCfg.Zone)
				return
			}
			bl.log.Msg("List contains a record for 127.0.0.1", "list", listCfg.Zone)
		}
	}

	if listCfg.ClientIPv6 {
		// 1. IPv6-based DNSxLs MUST contain an entry for ::FFFF:7F00:2
		mustIP := net.ParseIP("::FFFF:7F00:2")

		err := checkIP(context.Background(), bl.resolver, listCfg, mustIP)
		if err == nil {
			bl.log.Msg("List does not contain a test record for ::FFFF:7F00:2", "list", listCfg.Zone)
		} else if _, ok := err.(ListedErr); !ok {
			bl.log.Error("lookup error, bailing out", err, "list", listCfg.Zone)
			return
		}

		// 2. IPv4-based DNSxLs MUST NOT contain an entry for ::FFFF:7F00:1
		mustNotIP := net.ParseIP("::FFFF:7F00:1")
		err = checkIP(context.Background(), bl.resolver, listCfg, mustNotIP)
		if err != nil {
			_, ok := err.(ListedErr)
			if !ok {
				bl.log.Error("lookup error, bailing out", err, "list", listCfg.Zone)
				return
			}
			bl.log.Msg("List contains a record for ::FFFF:7F00:1", "list", listCfg.Zone)
		}
	}

	if listCfg.EHLO || listCfg.MAILFROM {
		// Domain-name-based DNSxLs MUST contain an entry for the reserved
		// domain name "TEST".
		err := checkDomain(context.Background(), bl.resolver, listCfg, "test")
		if err == nil {
			bl.log.Msg("List does not contain a test record for 'test' TLD", "list", listCfg.Zone)
		} else if _, ok := err.(ListedErr); !ok {
			bl.log.Error("lookup error, bailing out", err, "list", listCfg.Zone)
			return
		}

		// ... and MUST NOT contain an entry for the reserved domain name
		// "INVALID".
		err = checkDomain(context.Background(), bl.resolver, listCfg, "invalid")
		if err != nil {
			_, ok := err.(ListedErr)
			if !ok {
				bl.log.Error("lookup error, bailing out", err, "list", listCfg.Zone)
				return
			}
			bl.log.Msg("List contains a record for 'invalid' TLD", "list", listCfg.Zone)
		}
	}
}

func (bl *DNSBL) checkList(ctx context.Context, list List, ip net.IP, ehlo, mailFrom string) error {
	if list.ClientIPv4 || list.ClientIPv6 {
		if err := checkIP(ctx, bl.resolver, list, ip); err != nil {
			return err
		}
	}

	if list.EHLO && ehlo != "" {
		// Skip IPs in EHLO.
		if strings.HasPrefix(ehlo, "[") && strings.HasSuffix(ehlo, "]") {
			return nil
		}

		if err := checkDomain(ctx, bl.resolver, list, ehlo); err != nil {
			return err
		}
	}

	if list.MAILFROM && mailFrom != "" {
		_, domain, err := address.Split(mailFrom)
		if err != nil || domain == "" {
			// Probably <postmaster> or <>, not much we can check.
			return nil
		}

		// If EHLO == domain (usually the case for small/private email servers)
		// then don't do a second lookup for the same domain.
		if list.EHLO && dns.Equal(domain, ehlo) {
			return nil
		}

		if err := checkDomain(ctx, bl.resolver, list, domain); err != nil {
			return err
		}
	}

	return nil
}

func (bl *DNSBL) checkLists(ctx context.Context, ip net.IP, ehlo, mailFrom string) module.CheckResult {
	var (
		eg = errgroup.Group{}

		// Protects variables below.
		lck      sync.Mutex
		score    int
		listedOn []string
		reasons  []string
		messages []string
	)

	for _, list := range bl.bls {
		eg.Go(func() error {
			err := bl.checkList(ctx, list, ip, ehlo, mailFrom)
			if err != nil {
				listErr, listed := err.(ListedErr)
				if !listed {
					return err
				}

				lck.Lock()
				defer lck.Unlock()
				listedOn = append(listedOn, listErr.List)
				reasons = append(reasons, listErr.Reason)

				// Use score from ListedErr if set (new behavior), otherwise use legacy ScoreAdj
				if listErr.Score != 0 {
					score += listErr.Score
				} else {
					score += list.ScoreAdj
				}

				// Collect custom messages if available
				if listErr.Message != "" {
					messages = append(messages, listErr.Message)
				}
			}
			return nil
		})
	}

	err := eg.Wait()
	if err != nil {
		// Lookup error for BL, hard-fail.
		return module.CheckResult{
			Reject: true,
			Reason: &exterrors.SMTPError{
				Code:         exterrors.SMTPCode(err, 451, 554),
				EnhancedCode: exterrors.SMTPEnchCode(err, exterrors.EnhancedCode{0, 7, 0}),
				Message:      "DNS error during policy check",
				Err:          err,
				CheckName:    "dnsbl",
			},
		}
	}

	// Use custom message if available, otherwise use default
	message := "Client identity is listed in the used DNSBL"
	if len(messages) > 0 {
		message = strings.Join(messages, "; ")
	}

	if score >= bl.rejectThres {
		return module.CheckResult{
			Reject: true,
			Reason: &exterrors.SMTPError{
				Code:         554,
				EnhancedCode: exterrors.EnhancedCode{5, 7, 0},
				Message:      message,
				Err:          err,
				CheckName:    "dnsbl",
			},
		}
	}
	if score >= bl.quarantineThres {
		return module.CheckResult{
			Quarantine: true,
			Reason: &exterrors.SMTPError{
				Code:         554,
				EnhancedCode: exterrors.EnhancedCode{5, 7, 0},
				Message:      message,
				Err:          err,
				CheckName:    "dnsbl",
			},
		}
	}

	return module.CheckResult{}
}

// CheckConnection implements module.EarlyCheck.
func (bl *DNSBL) CheckConnection(ctx context.Context, state *module.ConnState) error {
	defer trace.StartRegion(ctx, "dnsbl/CheckConnection (Early)").End()

	ip, ok := state.RemoteAddr.(*net.TCPAddr)
	if !ok {
		bl.log.Msg("non-TCP/IP source",
			"src_addr", state.RemoteAddr,
			"src_host", state.Hostname)
		return nil
	}

	result := bl.checkLists(ctx, ip.IP, state.Hostname, "")
	if result.Reject && bl.checkEarly {
		return result.Reason
	}

	state.ModData.Set(bl, true, result)

	return nil
}

type state struct {
	bl      *DNSBL
	msgMeta *module.MsgMetadata
	log     *log.Logger
}

func (bl *DNSBL) CheckStateForMsg(ctx context.Context, msgMeta *module.MsgMetadata) (module.CheckState, error) {
	return &state{
		bl:      bl,
		msgMeta: msgMeta,
		log:     target.DeliveryLogger(bl.log, msgMeta),
	}, nil
}

func (s *state) CheckConnection(ctx context.Context) module.CheckResult {
	defer trace.StartRegion(ctx, "dnsbl/CheckConnection").End()

	if s.msgMeta.Conn == nil {
		s.log.Msg("locally generated message, ignoring")
		return module.CheckResult{}
	}

	result := s.msgMeta.Conn.ModData.Get(s.bl, true)
	if result != nil {
		return result.(module.CheckResult)
	}

	return module.CheckResult{}
}

func (*state) CheckSender(context.Context, string) module.CheckResult {
	return module.CheckResult{}
}

func (*state) CheckRcpt(context.Context, string) module.CheckResult {
	return module.CheckResult{}
}

func (*state) CheckBody(context.Context, textproto.Header, buffer.Buffer) module.CheckResult {
	return module.CheckResult{}
}

func (*state) Close() error {
	return nil
}

func init() {
	modules.Register("check.dnsbl", New)
}
