[PR #1] [CLOSED] Add per-response-code DNSBL scoring with custom messages #1

Closed
opened 2026-04-08 13:54:00 +00:00 by thisisjaymehta · 0 comments

📋 Pull Request Information

Original PR: https://github.com/thisisjaymehta/maddy/pull/1
Author: @Copilot
Created: 1/10/2026
Status: Closed

Base: masterHead: copilot/improve-dnsbl-response-handling


📝 Commits (8)

  • c39c921 Initial plan
  • b66f114 Implement per-response-code DNSBL scoring with custom messages
  • 3404ef6 Fix config parsing to use node.Children instead of AllowUnknown
  • fe91076 Fix rule matching to count each rule only once
  • 11be667 Improve documentation with clearer scoring behavior examples
  • 66339dd Address upstream PR feedback: use cfg.Callback, extract function, remove version annotation
  • c29208c Add ResponseRules support for checkDomain (domain-based lookups)
  • 0d68e63 Formatted files with goimports

📊 Changes

5 files changed (+667 additions, -42 deletions)

View changed files

📝 docs/reference/checks/dnsbl.md (+85 -0)
📝 internal/check/dnsbl/common.go (+122 -39)
📝 internal/check/dnsbl/common_test.go (+101 -0)
📝 internal/check/dnsbl/dnsbl.go (+80 -3)
📝 internal/check/dnsbl/dnsbl_test.go (+279 -0)

📄 Description

Implementation Plan for Per-Response-Code DNSBL Scoring

  • 1. Update ListedErr struct in common.go to include Score and Message fields
  • 2. Add ResponseRule struct to dnsbl.go
  • 3. Update List struct in dnsbl.go to include ResponseRules field
  • 4. Update checkIP function in common.go to handle ResponseRules and calculate scores
  • 5. Update checkDomain function in common.go to handle ResponseRules for domain-based lookups
  • 6. Add parseResponseRule function to parse response configuration blocks
  • 7. Update readListCfg in dnsbl.go to parse response blocks using parseResponseRule
  • 8. Update checkLists in dnsbl.go to use score from ListedErr instead of flat list.ScoreAdj
  • 9. Update ListedErr.Fields() method to use custom message when available
  • 10. Add comprehensive test cases for new functionality
  • 11. Update documentation in docs/reference/checks/dnsbl.md
  • 12. Run full test suite to validate all changes - All tests pass
  • 13. Fix config parsing to use node.Children directly instead of AllowUnknown()
  • 14. Fix rule matching logic to count each rule only once
  • 15. Improve documentation with concrete scoring examples
  • 16. Address all upstream PR #813 feedback
  • 17. Add ResponseRules support for domain-based lookups (ehlo, mailfrom)

Changes Made

This PR adds support for per-response-code scoring in DNSBL checks, allowing administrators to assign different scores and custom rejection messages based on specific DNSBL return codes. This enables efficient use of combined DNSBLs like Spamhaus ZEN with granular control.

Key Features

  • Per-response-code scoring: Different scores for different DNSBL response codes
  • Custom rejection messages: Per-rule custom messages shown to clients
  • Score summing: When multiple response codes are returned, scores from different rules are summed
  • Works for both IP and domain checks: ResponseRules apply to both client IP lookups and domain-based lookups (ehlo, mailfrom)
  • Backwards compatible: Legacy responses + score configuration continues to work

Implementation Details

  1. Use cfg.Callback for response blocks - Uses the callback pattern for complex config parsing (preferred pattern in Maddy)
  2. Extract ResponseRules logic - Created matchResponseRules function shared by both checkIP and checkDomain for consistency
  3. Avoid TXT lookup duplication - Refactored to share TXT lookup code between ResponseRules and legacy paths
  4. Add warning for mixed config - Log warning when both response blocks and responses directive are specified
  5. Comprehensive testing - Added tests for IP-based and domain-based lookups with ResponseRules

Configuration Example

check.dnsbl {
    reject_threshold 10
    quarantine_threshold 5

    zen.spamhaus.org {
        client_ipv4 yes
        ehlo yes
        
        # SBL - Known spam sources (high severity)
        response 127.0.0.2 127.0.0.3 {
            score 10
            message "Listed in Spamhaus SBL. See https://check.spamhaus.org/"
        }
        
        # PBL - Dynamic IPs (lower severity)
        response 127.0.0.10 127.0.0.11 {
            score 5
            message "Listed in Spamhaus PBL. See https://check.spamhaus.org/"
        }
    }
}

All tests pass and code follows Maddy conventions.

Original prompt

Problem

Currently, Maddy's DNSBL implementation treats all response codes within a configured range equally. When using combined DNSBLs like Spamhaus ZEN (zen.spamhaus.org), different return codes indicate different listing types with different severity:

  • 127.0.0.2, 127.0.0.3 → SBL (known spam sources) - high severity
  • 127.0.0.4-127.0.0.7 → XBL (exploited/compromised hosts) - high severity
  • 127.0.0.10, 127.0.0.11 → PBL (policy block, dynamic IPs) - lower severity

Currently, Maddy:

  1. Counts any response within 127.0.0.1/24 as a single "hit"
  2. Applies the same score regardless of which specific code was returned
  3. Cannot provide response-code-specific rejection messages
  4. If multiple codes are returned (e.g., 127.0.0.3, 127.0.0.11, 127.0.0.4), they all count as one hit with one score

This means users who want different scores for different listing types must query separate lists (sbl.spamhaus.org, xbl.spamhaus.org, pbl.spamhaus.org), resulting in 3 DNS queries instead of 1.

Reference: https://docs.spamhaus.com/datasets/docs/source/40-real-world-usage/PublicMirrors/MTAs/020-Postfix.html

Proposed Solution

Add a new response configuration block that allows per-response-code scoring and custom messages:

check.dnsbl {
    reject_threshold 10
    quarantine_threshold 5

    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/"
        }
    }
}

Implementation Details

1. Add new ResponseRule struct in internal/check/dnsbl/dnsbl.go:

type ResponseRule struct {
    Networks []net.IPNet
    Score    int
    Message  string // Custom rejection/quarantine message
}

2. Update List struct to include ResponseRules:

type List struct {
    Zone string
    ClientIPv4 bool
    ClientIPv6 bool
    EHLO     bool
    MAILFROM bool
    
    // Legacy: flat score for any response (used when ResponseRules is empty)
    ScoreAdj  int
    Responses []net.IPNet

    // New: per-response-code rules
    ResponseRules []ResponseRule
}

3. Update ListedErr in internal/check/dnsbl/common.go to include score and message:

type ListedErr struct {
    Identity string
    List     string
    Reason   string
    Score    int    // Score from matched response rule
    Message  string // Custom message from matched response rule
}

4. Update checkIP function in internal/check/dnsbl/common.go to handle ResponseRules:

  • If ResponseRules is configured, match each returned IP against rules and sum scores
  • If only legacy Responses is configured, use existing behavior for backwards compatibility
  • The function should return a ListedErr with the total score from all matched response rules

5. Update checkLists in internal/check/dnsbl/dnsbl.go to use score from ListedErr:

  • Use ListedErr.Score when set (new behavior)
  • Fall back to list.ScoreAdj for backwards compatibility
  • Collect custom messages for rejection responses

6. Update readListCfg in internal/check/dnsbl/dnsbl.go to parse new response blocks:

  • Add a new parseResponseRule function to handle the response block
  • Parse IP addresses/CIDRs from arguments
  • Parse score and message directives from the block body

7. Update Fields() method on ListedErr to use custom message if available

8. Update documentation in docs/reference/checks/dnsbl.md to document the new response block syntax

Files to Modify

  1. internal/check/dnsbl/dnsbl.go - Add ResponseRule struct, update List struct, update readListCfg, update checkLists
  2. internal/check/dnsbl/common.go - Update ListedErr, update checkIP to handle ResponseRules
  3. internal/check/dnsbl/dnsbl_test.go - Add test cases for new functionality
  4. docs/reference/checks/dnsbl.md - Document new configuration syntax

Test Cases to Add

  1. Multiple return codes from single DNSBL with different scores → scores should sum
  2. Only low-severity code returned → quarantine but not reject
  3. High-severity code returned → reject
  4. Legacy configuration without response blocks → existing behavior preserved
  5. Mixed configuration (some lists with response blocks, some without) → both work correctly

Benefits

Feature Before After
DNS queries for ZEN 1 (but codes ignored) 1 (codes inter...

This pull request was created from Copilot chat.


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.


🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/thisisjaymehta/maddy/pull/1 **Author:** [@Copilot](https://github.com/apps/copilot-swe-agent) **Created:** 1/10/2026 **Status:** ❌ Closed **Base:** `master` ← **Head:** `copilot/improve-dnsbl-response-handling` --- ### 📝 Commits (8) - [`c39c921`](https://github.com/thisisjaymehta/maddy/commit/c39c921e61f8a3eeb36ee845035252a63b9d950c) Initial plan - [`b66f114`](https://github.com/thisisjaymehta/maddy/commit/b66f114f3c44dbb205799de8f33203e007690bcc) Implement per-response-code DNSBL scoring with custom messages - [`3404ef6`](https://github.com/thisisjaymehta/maddy/commit/3404ef66e6522249e9bb12528fcedcff62419e83) Fix config parsing to use node.Children instead of AllowUnknown - [`fe91076`](https://github.com/thisisjaymehta/maddy/commit/fe91076f597d496ecceab067b88c6199acb35779) Fix rule matching to count each rule only once - [`11be667`](https://github.com/thisisjaymehta/maddy/commit/11be6679520b23ec1fba38bf0ef37a39cec9e904) Improve documentation with clearer scoring behavior examples - [`66339dd`](https://github.com/thisisjaymehta/maddy/commit/66339ddefb8f13303af3377039f2d48c4cd84153) Address upstream PR feedback: use cfg.Callback, extract function, remove version annotation - [`c29208c`](https://github.com/thisisjaymehta/maddy/commit/c29208c95d75e813d4c3bab57a7cf3273a9437c2) Add ResponseRules support for checkDomain (domain-based lookups) - [`0d68e63`](https://github.com/thisisjaymehta/maddy/commit/0d68e634dcc77028839744d4720c6721726b8166) Formatted files with goimports ### 📊 Changes **5 files changed** (+667 additions, -42 deletions) <details> <summary>View changed files</summary> 📝 `docs/reference/checks/dnsbl.md` (+85 -0) 📝 `internal/check/dnsbl/common.go` (+122 -39) 📝 `internal/check/dnsbl/common_test.go` (+101 -0) 📝 `internal/check/dnsbl/dnsbl.go` (+80 -3) 📝 `internal/check/dnsbl/dnsbl_test.go` (+279 -0) </details> ### 📄 Description ## Implementation Plan for Per-Response-Code DNSBL Scoring - [x] 1. Update `ListedErr` struct in `common.go` to include `Score` and `Message` fields - [x] 2. Add `ResponseRule` struct to `dnsbl.go` - [x] 3. Update `List` struct in `dnsbl.go` to include `ResponseRules` field - [x] 4. Update `checkIP` function in `common.go` to handle `ResponseRules` and calculate scores - [x] 5. Update `checkDomain` function in `common.go` to handle `ResponseRules` for domain-based lookups - [x] 6. Add `parseResponseRule` function to parse `response` configuration blocks - [x] 7. Update `readListCfg` in `dnsbl.go` to parse `response` blocks using `parseResponseRule` - [x] 8. Update `checkLists` in `dnsbl.go` to use score from `ListedErr` instead of flat `list.ScoreAdj` - [x] 9. Update `ListedErr.Fields()` method to use custom message when available - [x] 10. Add comprehensive test cases for new functionality - [x] 11. Update documentation in `docs/reference/checks/dnsbl.md` - [x] 12. Run full test suite to validate all changes - All tests pass - [x] 13. Fix config parsing to use node.Children directly instead of AllowUnknown() - [x] 14. Fix rule matching logic to count each rule only once - [x] 15. Improve documentation with concrete scoring examples - [x] 16. Address all upstream PR #813 feedback - [x] 17. Add ResponseRules support for domain-based lookups (ehlo, mailfrom) ## Changes Made This PR adds support for per-response-code scoring in DNSBL checks, allowing administrators to assign different scores and custom rejection messages based on specific DNSBL return codes. This enables efficient use of combined DNSBLs like Spamhaus ZEN with granular control. ### Key Features - **Per-response-code scoring**: Different scores for different DNSBL response codes - **Custom rejection messages**: Per-rule custom messages shown to clients - **Score summing**: When multiple response codes are returned, scores from different rules are summed - **Works for both IP and domain checks**: ResponseRules apply to both client IP lookups and domain-based lookups (ehlo, mailfrom) - **Backwards compatible**: Legacy `responses` + `score` configuration continues to work ### Implementation Details 1. **Use `cfg.Callback` for response blocks** - Uses the callback pattern for complex config parsing (preferred pattern in Maddy) 2. **Extract ResponseRules logic** - Created `matchResponseRules` function shared by both `checkIP` and `checkDomain` for consistency 3. **Avoid TXT lookup duplication** - Refactored to share TXT lookup code between ResponseRules and legacy paths 4. **Add warning for mixed config** - Log warning when both `response` blocks and `responses` directive are specified 5. **Comprehensive testing** - Added tests for IP-based and domain-based lookups with ResponseRules ### Configuration Example ``` check.dnsbl { reject_threshold 10 quarantine_threshold 5 zen.spamhaus.org { client_ipv4 yes ehlo yes # SBL - Known spam sources (high severity) response 127.0.0.2 127.0.0.3 { score 10 message "Listed in Spamhaus SBL. See https://check.spamhaus.org/" } # PBL - Dynamic IPs (lower severity) response 127.0.0.10 127.0.0.11 { score 5 message "Listed in Spamhaus PBL. See https://check.spamhaus.org/" } } } ``` All tests pass and code follows Maddy conventions. <!-- START COPILOT ORIGINAL PROMPT --> <details> <summary>Original prompt</summary> ## Problem Currently, Maddy's DNSBL implementation treats all response codes within a configured range equally. When using combined DNSBLs like Spamhaus ZEN (`zen.spamhaus.org`), different return codes indicate different listing types with different severity: - `127.0.0.2`, `127.0.0.3` → SBL (known spam sources) - high severity - `127.0.0.4`-`127.0.0.7` → XBL (exploited/compromised hosts) - high severity - `127.0.0.10`, `127.0.0.11` → PBL (policy block, dynamic IPs) - lower severity Currently, Maddy: 1. Counts any response within `127.0.0.1/24` as a single "hit" 2. Applies the same score regardless of which specific code was returned 3. Cannot provide response-code-specific rejection messages 4. If multiple codes are returned (e.g., `127.0.0.3`, `127.0.0.11`, `127.0.0.4`), they all count as one hit with one score This means users who want different scores for different listing types must query separate lists (sbl.spamhaus.org, xbl.spamhaus.org, pbl.spamhaus.org), resulting in 3 DNS queries instead of 1. Reference: https://docs.spamhaus.com/datasets/docs/source/40-real-world-usage/PublicMirrors/MTAs/020-Postfix.html ## Proposed Solution Add a new `response` configuration block that allows per-response-code scoring and custom messages: ``` check.dnsbl { reject_threshold 10 quarantine_threshold 5 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/" } } } ``` ## Implementation Details ### 1. Add new `ResponseRule` struct in `internal/check/dnsbl/dnsbl.go`: ```go type ResponseRule struct { Networks []net.IPNet Score int Message string // Custom rejection/quarantine message } ``` ### 2. Update `List` struct to include `ResponseRules`: ```go type List struct { Zone string ClientIPv4 bool ClientIPv6 bool EHLO bool MAILFROM bool // Legacy: flat score for any response (used when ResponseRules is empty) ScoreAdj int Responses []net.IPNet // New: per-response-code rules ResponseRules []ResponseRule } ``` ### 3. Update `ListedErr` in `internal/check/dnsbl/common.go` to include score and message: ```go type ListedErr struct { Identity string List string Reason string Score int // Score from matched response rule Message string // Custom message from matched response rule } ``` ### 4. Update `checkIP` function in `internal/check/dnsbl/common.go` to handle ResponseRules: - If `ResponseRules` is configured, match each returned IP against rules and sum scores - If only legacy `Responses` is configured, use existing behavior for backwards compatibility - The function should return a `ListedErr` with the total score from all matched response rules ### 5. Update `checkLists` in `internal/check/dnsbl/dnsbl.go` to use score from `ListedErr`: - Use `ListedErr.Score` when set (new behavior) - Fall back to `list.ScoreAdj` for backwards compatibility - Collect custom messages for rejection responses ### 6. Update `readListCfg` in `internal/check/dnsbl/dnsbl.go` to parse new `response` blocks: - Add a new `parseResponseRule` function to handle the `response` block - Parse IP addresses/CIDRs from arguments - Parse `score` and `message` directives from the block body ### 7. Update `Fields()` method on `ListedErr` to use custom message if available ### 8. Update documentation in `docs/reference/checks/dnsbl.md` to document the new `response` block syntax ## Files to Modify 1. `internal/check/dnsbl/dnsbl.go` - Add ResponseRule struct, update List struct, update readListCfg, update checkLists 2. `internal/check/dnsbl/common.go` - Update ListedErr, update checkIP to handle ResponseRules 3. `internal/check/dnsbl/dnsbl_test.go` - Add test cases for new functionality 4. `docs/reference/checks/dnsbl.md` - Document new configuration syntax ## Test Cases to Add 1. Multiple return codes from single DNSBL with different scores → scores should sum 2. Only low-severity code returned → quarantine but not reject 3. High-severity code returned → reject 4. Legacy configuration without `response` blocks → existing behavior preserved 5. Mixed configuration (some lists with `response` blocks, some without) → both work correctly ## Benefits | Feature | Before | After | |---------|--------|-------| | DNS queries for ZEN | 1 (but codes ignored) | 1 (codes inter... </details> <!-- START COPILOT CODING AGENT SUFFIX --> *This pull request was created from Copilot chat.* > <!-- START COPILOT CODING AGENT TIPS --> --- 💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more [Copilot coding agent tips](https://gh.io/copilot-coding-agent-tips) in the docs. --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
thisisjaymehta 2026-04-08 13:54:00 +00:00
Sign in to join this conversation.
No labels
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
thisisjaymehta/maddy#1
No description provided.