diff --git a/docs/reference/checks/dnsbl.md b/docs/reference/checks/dnsbl.md index a2d27362..d7bb74cf 100644 --- a/docs/reference/checks/dnsbl.md +++ b/docs/reference/checks/dnsbl.md @@ -29,6 +29,30 @@ check.dnsbl { mailfrom yes score 1 } + + # Example with per-response-code scoring (new in 0.8) + zen.spamhaus.org { + client_ipv4 yes + client_ipv6 yes + + # SBL - Spamhaus Block List (known spam sources) + response 127.0.0.2 127.0.0.3 { + score 10 + message "Listed in Spamhaus SBL. See https://check.spamhaus.org/" + } + + # XBL - Exploits Block List (compromised hosts) + response 127.0.0.4 127.0.0.5 127.0.0.6 127.0.0.7 { + score 10 + message "Listed in Spamhaus XBL. See https://check.spamhaus.org/" + } + + # PBL - Policy Block List (dynamic IPs) + response 127.0.0.10 127.0.0.11 { + score 5 + message "Listed in Spamhaus PBL. See https://check.spamhaus.org/" + } + } } ``` @@ -171,3 +195,64 @@ will be rejected. It is possible to specify a negative value to make list act like a whitelist and override results of other blocklists. + +**Note:** When using `response` blocks (see below), the score from matching response +rules is used instead of this flat score value. + +--- + +### response _ip..._ + +Defines per-response-code rules for scoring and custom messages. This is useful +for combined DNSBLs like Spamhaus ZEN that return different codes for different +listing types. + +This works for both IP-based lookups (client_ipv4, client_ipv6) and domain-based +lookups (ehlo, mailfrom). + +Each `response` block takes one or more IP addresses or CIDR ranges as arguments +and contains the following directives: + +#### score _integer_ +**Required** + +Score to add when this response code is returned. If multiple response codes +are returned by the DNSBL, and they match different rules, the scores from +all matched rules are summed together. Each rule is counted only once, even +if multiple returned IPs match networks within that rule. + +#### message _string_ +**Optional** + +Custom rejection or quarantine message to include when this response code +matches. This message is shown to the client or logged when the threshold +is reached. + +**Example:** + +``` +zen.spamhaus.org { + client_ipv4 yes + + # High severity - known spam sources + response 127.0.0.2 127.0.0.3 { + score 10 + message "Listed in Spamhaus SBL" + } + + # Lower severity - dynamic IPs + response 127.0.0.10 127.0.0.11 { + score 5 + message "Listed in Spamhaus PBL" + } +} +``` + +**Scoring behavior:** +- If DNSBL returns `127.0.0.2` only → Score: 10 (matches first rule) +- If DNSBL returns `127.0.0.11` only → Score: 5 (matches second rule) +- If DNSBL returns both `127.0.0.2` and `127.0.0.11` → Score: 15 (both rules match, scores sum) +- If DNSBL returns both `127.0.0.2` and `127.0.0.3` → Score: 10 (same rule matches, counted once) + +**Backwards compatibility:** When `response` blocks are not used, the legacy +`responses` and `score` directives work as before. diff --git a/internal/check/dnsbl/common.go b/internal/check/dnsbl/common.go index 7b874cb9..4e90e7e2 100644 --- a/internal/check/dnsbl/common.go +++ b/internal/check/dnsbl/common.go @@ -32,9 +32,15 @@ type ListedErr struct { Identity string List string Reason string + Score int + Message string } func (le ListedErr) Fields() map[string]interface{} { + msg := "Client identity listed in the used DNSBL" + if le.Message != "" { + msg = le.Message + } return map[string]interface{}{ "check": "dnsbl", "list": le.List, @@ -42,7 +48,7 @@ func (le ListedErr) Fields() map[string]interface{} { "reason": le.Reason, "smtp_code": 554, "smtp_enchcode": exterrors.EnhancedCode{5, 7, 0}, - "smtp_msg": "Client identity listed in the used DNSBL", + "smtp_msg": msg, } } @@ -66,26 +72,83 @@ func checkDomain(ctx context.Context, resolver dns.Resolver, cfg List, domain st return nil } - // Attempt to extract explanation string. - txts, err := resolver.LookupTXT(context.Background(), query) - if err != nil || len(txts) == 0 { - // Not significant, include addresses as reason. Usually they are - // mapped to some predefined 'reasons' by BL. - return ListedErr{ - Identity: domain, - List: cfg.Zone, - Reason: strings.Join(addrs, "; "), + var score int + var customMessage string + var filteredAddrs []string + + // If ResponseRules is configured, use new behavior + if len(cfg.ResponseRules) > 0 { + // Convert string addresses to IPAddr for matching + ipAddrs := make([]net.IPAddr, 0, len(addrs)) + for _, addr := range addrs { + if ip := net.ParseIP(addr); ip != nil { + ipAddrs = append(ipAddrs, net.IPAddr{IP: ip}) + } } + + matchedScore, matchedMessages, matchedReasons, matched := matchResponseRules(ipAddrs, cfg.ResponseRules) + if !matched { + return nil + } + score = matchedScore + + // Use first matched message if available + if len(matchedMessages) > 0 { + customMessage = matchedMessages[0] + } + + filteredAddrs = matchedReasons + } else { + // Legacy behavior: accept all addresses + filteredAddrs = addrs } - // Some BLs provide multiple reasons (meta-BLs such as Spamhaus Zen) so - // don't mangle them by joining with "", instead join with "; ". + // Attempt to extract explanation string from TXT records (shared by both paths) + txts, err := resolver.LookupTXT(ctx, query) + var reason string + if err == nil && len(txts) > 0 { + reason = strings.Join(txts, "; ") + } else { + // Not significant, include addresses as reason. Usually they are + // mapped to some predefined 'reasons' by BL. + reason = strings.Join(filteredAddrs, "; ") + } return ListedErr{ Identity: domain, List: cfg.Zone, - Reason: strings.Join(txts, "; "), + Reason: reason, + Score: score, + Message: customMessage, + } +} + +func matchResponseRules(addrs []net.IPAddr, rules []ResponseRule) (score int, messages []string, reasons []string, matched bool) { + // Track which rules have been matched to avoid counting the same rule multiple times + matchedRules := make(map[int]bool) + + for _, addr := range addrs { + for ruleIdx, rule := range rules { + // Skip if this rule has already been matched + if matchedRules[ruleIdx] { + continue + } + + for _, respNet := range rule.Networks { + if respNet.Contains(addr.IP) { + score += rule.Score + if rule.Message != "" { + messages = append(messages, rule.Message) + } + reasons = append(reasons, addr.IP.String()) + matchedRules[ruleIdx] = true + matched = true + break // Move to next rule + } + } + } } + return } func checkIP(ctx context.Context, resolver dns.Resolver, cfg List, ip net.IP) error { @@ -113,52 +176,72 @@ func checkIP(ctx context.Context, resolver dns.Resolver, cfg List, ip net.IP) er return err } - filteredAddrs := make([]net.IPAddr, 0, len(addrs)) -addrsLoop: - for _, addr := range addrs { - // No responses whitelist configured - permit all. - if len(cfg.Responses) == 0 { - filteredAddrs = append(filteredAddrs, addr) - continue - } + var filteredAddrs []net.IPAddr + var score int + var customMessage string - for _, respNet := range cfg.Responses { - if respNet.Contains(addr.IP) { + // If ResponseRules is configured, use new behavior + if len(cfg.ResponseRules) > 0 { + matchedScore, matchedMessages, matchedReasons, matched := matchResponseRules(addrs, cfg.ResponseRules) + if !matched { + return nil + } + score = matchedScore + + // Use first matched message if available + if len(matchedMessages) > 0 { + customMessage = matchedMessages[0] + } + + // Build filteredAddrs from matched reasons for TXT lookup fallback + for _, reason := range matchedReasons { + filteredAddrs = append(filteredAddrs, net.IPAddr{IP: net.ParseIP(reason)}) + } + } else { + // Legacy behavior: use flat Responses filter + filteredAddrs = make([]net.IPAddr, 0, len(addrs)) + addrsLoop: + for _, addr := range addrs { + // No responses whitelist configured - permit all. + if len(cfg.Responses) == 0 { filteredAddrs = append(filteredAddrs, addr) - continue addrsLoop + continue + } + + for _, respNet := range cfg.Responses { + if respNet.Contains(addr.IP) { + filteredAddrs = append(filteredAddrs, addr) + continue addrsLoop + } } } - } - if len(filteredAddrs) == 0 { - return nil + if len(filteredAddrs) == 0 { + return nil + } } - // Attempt to extract explanation string. + // Attempt to extract explanation string from TXT records (shared by both paths) txts, err := resolver.LookupTXT(ctx, query) - if err != nil || len(txts) == 0 { + var reason string + if err == nil && len(txts) > 0 { + reason = strings.Join(txts, "; ") + } else { // Not significant, include addresses as reason. Usually they are // mapped to some predefined 'reasons' by BL. - reasonParts := make([]string, 0, len(filteredAddrs)) for _, addr := range filteredAddrs { reasonParts = append(reasonParts, addr.IP.String()) } - - return ListedErr{ - Identity: ip.String(), - List: cfg.Zone, - Reason: strings.Join(reasonParts, "; "), - } + reason = strings.Join(reasonParts, "; ") } - // Some BLs provide multiple reasons (meta-BLs such as Spamhaus Zen) so - // don't mangle them by joining with "", instead join with "; ". - return ListedErr{ Identity: ip.String(), List: cfg.Zone, - Reason: strings.Join(txts, "; "), + Reason: reason, + Score: score, + Message: customMessage, } } diff --git a/internal/check/dnsbl/common_test.go b/internal/check/dnsbl/common_test.go index caa06575..8d2b24e3 100644 --- a/internal/check/dnsbl/common_test.go +++ b/internal/check/dnsbl/common_test.go @@ -236,3 +236,104 @@ func TestCheckIP(t *testing.T) { Reason: "127.0.0.1", }) } + +func TestCheckDomainWithResponseRules(t *testing.T) { + test := func(zones map[string]mockdns.Zone, cfg List, domain string, expectedErr error) { + t.Helper() + resolver := mockdns.Resolver{Zones: zones} + err := checkDomain(context.Background(), &resolver, cfg, domain) + if expectedErr == nil { + if err != nil { + t.Errorf("expected no error, got '%#v'", err) + } + } else { + if err == nil { + t.Errorf("expected err to be '%#v', got nil", expectedErr) + } else { + expectedLE, okExpected := expectedErr.(ListedErr) + actualLE, okActual := err.(ListedErr) + if !okExpected || !okActual { + t.Errorf("expected err to be '%#v', got '%#v'", expectedErr, err) + } else { + if expectedLE.Identity != actualLE.Identity || + expectedLE.List != actualLE.List || + expectedLE.Score != actualLE.Score || + expectedLE.Message != actualLE.Message { + t.Errorf("expected err to be '%#v', got '%#v'", expectedErr, err) + } + } + } + } + } + + // Test domain with single response code and custom message + test(map[string]mockdns.Zone{ + "spam.example.com.dnsbl.example.org.": { + A: []string{"127.0.0.2"}, + }, + }, List{ + Zone: "dnsbl.example.org", + ResponseRules: []ResponseRule{ + { + Networks: []net.IPNet{ + {IP: net.IPv4(127, 0, 0, 2), Mask: net.IPv4Mask(255, 255, 255, 255)}, + }, + Score: 10, + Message: "Domain listed as spam source", + }, + }, + }, "spam.example.com", ListedErr{ + Identity: "spam.example.com", + List: "dnsbl.example.org", + Score: 10, + Message: "Domain listed as spam source", + }) + + // Test domain with multiple response codes - scores should sum + test(map[string]mockdns.Zone{ + "multi.example.com.dnsbl.example.org.": { + A: []string{"127.0.0.2", "127.0.0.11"}, + }, + }, List{ + Zone: "dnsbl.example.org", + ResponseRules: []ResponseRule{ + { + Networks: []net.IPNet{ + {IP: net.IPv4(127, 0, 0, 2), Mask: net.IPv4Mask(255, 255, 255, 255)}, + }, + Score: 10, + Message: "High severity", + }, + { + Networks: []net.IPNet{ + {IP: net.IPv4(127, 0, 0, 11), Mask: net.IPv4Mask(255, 255, 255, 255)}, + }, + Score: 5, + Message: "Low severity", + }, + }, + }, "multi.example.com", ListedErr{ + Identity: "multi.example.com", + List: "dnsbl.example.org", + Score: 15, // 10 + 5 + Message: "High severity", + }) + + // Test domain with no matching response codes + test(map[string]mockdns.Zone{ + "unknown.example.com.dnsbl.example.org.": { + A: []string{"127.0.0.99"}, + }, + }, List{ + Zone: "dnsbl.example.org", + ResponseRules: []ResponseRule{ + { + Networks: []net.IPNet{ + {IP: net.IPv4(127, 0, 0, 2), Mask: net.IPv4Mask(255, 255, 255, 255)}, + }, + Score: 10, + Message: "Listed", + }, + }, + }, "unknown.example.com", nil) +} diff --git a/internal/check/dnsbl/dnsbl.go b/internal/check/dnsbl/dnsbl.go index 2c91c838..94cbfe80 100644 --- a/internal/check/dnsbl/dnsbl.go +++ b/internal/check/dnsbl/dnsbl.go @@ -38,6 +38,12 @@ import ( "golang.org/x/sync/errgroup" ) +type ResponseRule struct { + Networks []net.IPNet + Score int + Message string +} + type List struct { Zone string @@ -49,6 +55,8 @@ type List struct { ScoreAdj int Responses []net.IPNet + + ResponseRules []ResponseRule } var defaultBL = List{ @@ -126,6 +134,14 @@ func (bl *DNSBL) readListCfg(node config.Node) error { 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 } @@ -144,6 +160,11 @@ func (bl *DNSBL) readListCfg(node config.Node) error { 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 @@ -173,6 +194,44 @@ func (bl *DNSBL) readListCfg(node config.Node) error { 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. @@ -298,6 +357,7 @@ func (bl *DNSBL) checkLists(ctx context.Context, ip net.IP, ehlo, mailFrom strin score int listedOn []string reasons []string + messages []string ) for _, list := range bl.bls { @@ -313,7 +373,18 @@ func (bl *DNSBL) checkLists(ctx context.Context, ip net.IP, ehlo, mailFrom strin defer lck.Unlock() listedOn = append(listedOn, listErr.List) reasons = append(reasons, listErr.Reason) - score += list.ScoreAdj + + // 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 }) @@ -334,13 +405,19 @@ func (bl *DNSBL) checkLists(ctx context.Context, ip net.IP, ehlo, mailFrom strin } } + // 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: "Client identity is listed in the used DNSBL", + Message: message, Err: err, CheckName: "dnsbl", }, @@ -352,7 +429,7 @@ func (bl *DNSBL) checkLists(ctx context.Context, ip net.IP, ehlo, mailFrom strin Reason: &exterrors.SMTPError{ Code: 554, EnhancedCode: exterrors.EnhancedCode{5, 7, 0}, - Message: "Client identity is listed in the used DNSBL", + Message: message, Err: err, CheckName: "dnsbl", }, diff --git a/internal/check/dnsbl/dnsbl_test.go b/internal/check/dnsbl/dnsbl_test.go index 1845aeb7..4dce6c9e 100644 --- a/internal/check/dnsbl/dnsbl_test.go +++ b/internal/check/dnsbl/dnsbl_test.go @@ -211,3 +211,283 @@ func TestCheckLists(t *testing.T) { true, false, ) } + +func TestCheckIPWithResponseRules(t *testing.T) { + test := func(zones map[string]mockdns.Zone, cfg List, ip net.IP, expectedErr error) { + t.Helper() + resolver := mockdns.Resolver{Zones: zones} + err := checkIP(context.Background(), &resolver, cfg, ip) + if expectedErr == nil { + if err != nil { + t.Errorf("expected no error, got '%#v'", err) + } + } else { + if err == nil { + t.Errorf("expected err to be '%#v', got nil", expectedErr) + } else { + expectedLE, okExpected := expectedErr.(ListedErr) + actualLE, okActual := err.(ListedErr) + if !okExpected || !okActual { + t.Errorf("expected err to be '%#v', got '%#v'", expectedErr, err) + } else { + if expectedLE.Identity != actualLE.Identity || + expectedLE.List != actualLE.List || + expectedLE.Score != actualLE.Score || + expectedLE.Message != actualLE.Message { + t.Errorf("expected err to be '%#v', got '%#v'", expectedErr, err) + } + } + } + } + } + + // Test single response code with score and message + test(map[string]mockdns.Zone{ + "4.3.2.1.example.org.": { + A: []string{"127.0.0.2"}, + }, + }, List{ + Zone: "example.org", + ClientIPv4: true, + ResponseRules: []ResponseRule{ + { + Networks: []net.IPNet{ + {IP: net.IPv4(127, 0, 0, 2), Mask: net.IPv4Mask(255, 255, 255, 255)}, + }, + Score: 10, + Message: "Listed in SBL", + }, + }, + }, net.IPv4(1, 2, 3, 4), ListedErr{ + Identity: "1.2.3.4", + List: "example.org", + Score: 10, + Message: "Listed in SBL", + }) + + // Test multiple response codes with different scores - scores should sum + test(map[string]mockdns.Zone{ + "4.3.2.1.example.org.": { + A: []string{"127.0.0.2", "127.0.0.11"}, + }, + }, List{ + Zone: "example.org", + ClientIPv4: true, + ResponseRules: []ResponseRule{ + { + Networks: []net.IPNet{ + {IP: net.IPv4(127, 0, 0, 2), Mask: net.IPv4Mask(255, 255, 255, 255)}, + {IP: net.IPv4(127, 0, 0, 3), Mask: net.IPv4Mask(255, 255, 255, 255)}, + }, + Score: 10, + Message: "Listed in SBL", + }, + { + Networks: []net.IPNet{ + {IP: net.IPv4(127, 0, 0, 10), Mask: net.IPv4Mask(255, 255, 255, 255)}, + {IP: net.IPv4(127, 0, 0, 11), Mask: net.IPv4Mask(255, 255, 255, 255)}, + }, + Score: 5, + Message: "Listed in PBL", + }, + }, + }, net.IPv4(1, 2, 3, 4), ListedErr{ + Identity: "1.2.3.4", + List: "example.org", + Score: 15, // 10 + 5 + Message: "Listed in SBL", + }) + + // Test response code that doesn't match any rule - should return nil + test(map[string]mockdns.Zone{ + "4.3.2.1.example.org.": { + A: []string{"127.0.0.99"}, + }, + }, List{ + Zone: "example.org", + ClientIPv4: true, + ResponseRules: []ResponseRule{ + { + Networks: []net.IPNet{ + {IP: net.IPv4(127, 0, 0, 2), Mask: net.IPv4Mask(255, 255, 255, 255)}, + }, + Score: 10, + Message: "Listed in SBL", + }, + }, + }, net.IPv4(1, 2, 3, 4), nil) + + // Test low severity only - should get score 5 + test(map[string]mockdns.Zone{ + "4.3.2.1.example.org.": { + A: []string{"127.0.0.10"}, + }, + }, List{ + Zone: "example.org", + ClientIPv4: true, + ResponseRules: []ResponseRule{ + { + Networks: []net.IPNet{ + {IP: net.IPv4(127, 0, 0, 2), Mask: net.IPv4Mask(255, 255, 255, 255)}, + }, + Score: 10, + Message: "Listed in SBL", + }, + { + Networks: []net.IPNet{ + {IP: net.IPv4(127, 0, 0, 10), Mask: net.IPv4Mask(255, 255, 255, 255)}, + }, + Score: 5, + Message: "Listed in PBL", + }, + }, + }, net.IPv4(1, 2, 3, 4), ListedErr{ + Identity: "1.2.3.4", + List: "example.org", + Score: 5, + Message: "Listed in PBL", + }) + + // Test high severity - should get score 10 + test(map[string]mockdns.Zone{ + "4.3.2.1.example.org.": { + A: []string{"127.0.0.2"}, + }, + }, List{ + Zone: "example.org", + ClientIPv4: true, + ResponseRules: []ResponseRule{ + { + Networks: []net.IPNet{ + {IP: net.IPv4(127, 0, 0, 2), Mask: net.IPv4Mask(255, 255, 255, 255)}, + }, + Score: 10, + Message: "Listed in SBL", + }, + }, + }, net.IPv4(1, 2, 3, 4), ListedErr{ + Identity: "1.2.3.4", + List: "example.org", + Score: 10, + Message: "Listed in SBL", + }) +} + +func TestCheckListsWithResponseRules(t *testing.T) { + test := func(zones map[string]mockdns.Zone, bls []List, ip net.IP, ehlo, mailFrom string, reject, quarantine bool) { + mod := &DNSBL{ + bls: bls, + resolver: &mockdns.Resolver{Zones: zones}, + log: testutils.Logger(t, "dnsbl"), + quarantineThres: 5, + rejectThres: 10, + } + result := mod.checkLists(context.Background(), ip, ehlo, mailFrom) + + if result.Reject && !reject { + t.Errorf("Expected message to not be rejected") + } + if !result.Reject && reject { + t.Errorf("Expected message to be rejected") + } + if result.Quarantine && !quarantine { + t.Errorf("Expected message to not be quarantined") + } + if !result.Quarantine && quarantine { + t.Errorf("Expected message to be quarantined") + } + } + + // Test: Only low-severity code returned -> quarantine but not reject + test(map[string]mockdns.Zone{ + "4.3.2.1.zen.example.org.": { + A: []string{"127.0.0.11"}, + }, + }, []List{ + { + Zone: "zen.example.org", + ClientIPv4: true, + ResponseRules: []ResponseRule{ + { + Networks: []net.IPNet{ + {IP: net.IPv4(127, 0, 0, 2), Mask: net.IPv4Mask(255, 255, 255, 255)}, + }, + Score: 10, + Message: "Listed in SBL", + }, + { + Networks: []net.IPNet{ + {IP: net.IPv4(127, 0, 0, 10), Mask: net.IPv4Mask(255, 255, 255, 255)}, + {IP: net.IPv4(127, 0, 0, 11), Mask: net.IPv4Mask(255, 255, 255, 255)}, + }, + Score: 5, + Message: "Listed in PBL", + }, + }, + }, + }, net.IPv4(1, 2, 3, 4), "mx.example.com", "foo@example.com", false, true) + + // Test: High-severity code returned -> reject + test(map[string]mockdns.Zone{ + "4.3.2.1.zen.example.org.": { + A: []string{"127.0.0.2"}, + }, + }, []List{ + { + Zone: "zen.example.org", + ClientIPv4: true, + ResponseRules: []ResponseRule{ + { + Networks: []net.IPNet{ + {IP: net.IPv4(127, 0, 0, 2), Mask: net.IPv4Mask(255, 255, 255, 255)}, + }, + Score: 10, + Message: "Listed in SBL", + }, + }, + }, + }, net.IPv4(1, 2, 3, 4), "mx.example.com", "foo@example.com", true, false) + + // Test: Legacy configuration without response blocks -> existing behavior preserved + test(map[string]mockdns.Zone{ + "4.3.2.1.example.org.": { + A: []string{"127.0.0.1"}, + }, + }, []List{ + { + Zone: "example.org", + ClientIPv4: true, + ScoreAdj: 10, + }, + }, net.IPv4(1, 2, 3, 4), "mx.example.com", "foo@example.com", true, false) + + // Test: Mixed configuration (some lists with response blocks, some without) -> both work correctly + test(map[string]mockdns.Zone{ + "4.3.2.1.zen.example.org.": { + A: []string{"127.0.0.11"}, + }, + "4.3.2.1.legacy.example.org.": { + A: []string{"127.0.0.1"}, + }, + }, []List{ + { + Zone: "zen.example.org", + ClientIPv4: true, + ResponseRules: []ResponseRule{ + { + Networks: []net.IPNet{ + {IP: net.IPv4(127, 0, 0, 11), Mask: net.IPv4Mask(255, 255, 255, 255)}, + }, + Score: 5, + Message: "Listed in PBL", + }, + }, + }, + { + Zone: "legacy.example.org", + ClientIPv4: true, + ScoreAdj: 3, + }, + }, net.IPv4(1, 2, 3, 4), "mx.example.com", "foo@example.com", false, true) // 5 + 3 = 8, quarantine but not reject +} +