320 lines
8.3 KiB
Go
320 lines
8.3 KiB
Go
package checker
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net"
|
||
"slices"
|
||
"strings"
|
||
"sync"
|
||
|
||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||
)
|
||
|
||
func init() { Register(&dnsblSource{}) }
|
||
|
||
// dnsblSource fans out a configurable list of DNS-based blocklist
|
||
// queries. Unlike the other sources, it returns one SourceResult per
|
||
// zone (so Spamhaus / SURBL / URIBL each get their own row in the
|
||
// report) while remaining a single Source from the registry's point of
|
||
// view — one ID, one option group, one rule entry.
|
||
type dnsblSource struct{}
|
||
|
||
func (*dnsblSource) ID() string { return "dnsbl" }
|
||
func (*dnsblSource) Name() string { return "DNS blocklists" }
|
||
|
||
func (*dnsblSource) Options() SourceOptions {
|
||
return SourceOptions{
|
||
Admin: []sdk.CheckerOptionField{
|
||
{
|
||
Id: "disabled_dnsbls",
|
||
Type: "string",
|
||
Label: "Disabled DNSBL zones",
|
||
Description: "Comma-separated list of DNSBL zone suffixes to skip (e.g. \"multi.surbl.org\").",
|
||
},
|
||
{
|
||
Id: "extra_dnsbls",
|
||
Type: "string",
|
||
Label: "Extra DNSBL zones",
|
||
Description: "Comma-separated list of extra DNSBL zone suffixes to query in addition to the defaults. Their return codes are surfaced verbatim.",
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
// DNSBLZone is the table row describing one zone known to this source.
|
||
type DNSBLZone struct {
|
||
Zone string
|
||
Label string
|
||
LookupURL string
|
||
RemovalURL string
|
||
Decode func(ip net.IP) []string
|
||
}
|
||
|
||
// DefaultDNSBLZones is the curated list shipped with the checker.
|
||
// Sources for the return-code semantics:
|
||
// - DBL: https://www.spamhaus.org/dbl/
|
||
// - SURBL: https://surbl.org/lists/
|
||
// - URIBL: https://uribl.com/about.shtml
|
||
var DefaultDNSBLZones = []DNSBLZone{
|
||
{
|
||
Zone: "dbl.spamhaus.org",
|
||
Label: "Spamhaus DBL",
|
||
LookupURL: "https://check.spamhaus.org/results/?query=%s",
|
||
RemovalURL: "https://www.spamhaus.org/dbl/removal/",
|
||
Decode: decodeSpamhausDBL,
|
||
},
|
||
{
|
||
Zone: "multi.surbl.org",
|
||
Label: "SURBL multi",
|
||
LookupURL: "https://surbl.org/surbl-analysis?d=%s",
|
||
RemovalURL: "https://surbl.org/surbl-analysis?d=%s",
|
||
Decode: decodeSURBLMulti,
|
||
},
|
||
{
|
||
Zone: "multi.uribl.com",
|
||
Label: "URIBL multi",
|
||
LookupURL: "https://admin.uribl.com/?section=lookup&query=%s",
|
||
RemovalURL: "https://admin.uribl.com/?section=remove",
|
||
Decode: decodeURIBLMulti,
|
||
},
|
||
}
|
||
|
||
func (s *dnsblSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult {
|
||
zones := zonesFromOptions(opts)
|
||
if registered == "" || len(zones) == 0 {
|
||
return []SourceResult{{
|
||
SourceID: s.ID(), SourceName: s.Name(), Enabled: false,
|
||
}}
|
||
}
|
||
|
||
out := make([]SourceResult, len(zones))
|
||
var wg sync.WaitGroup
|
||
for i, z := range zones {
|
||
wg.Add(1)
|
||
go func(i int, z DNSBLZone) {
|
||
defer wg.Done()
|
||
out[i] = s.queryOne(ctx, registered, z)
|
||
}(i, z)
|
||
}
|
||
wg.Wait()
|
||
return out
|
||
}
|
||
|
||
func (s *dnsblSource) queryOne(ctx context.Context, registered string, z DNSBLZone) SourceResult {
|
||
q := registered + "." + z.Zone
|
||
res := SourceResult{
|
||
SourceID: s.ID(),
|
||
SourceName: z.Label,
|
||
Subject: z.Zone,
|
||
Enabled: true,
|
||
LookupURL: formatURL(z.LookupURL, registered),
|
||
RemovalURL: formatURL(z.RemovalURL, registered),
|
||
}
|
||
|
||
addrs, err := net.DefaultResolver.LookupIP(ctx, "ip4", q)
|
||
if err != nil {
|
||
// NXDOMAIN is the standard "not listed" reply.
|
||
if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound {
|
||
return res
|
||
}
|
||
res.Error = err.Error()
|
||
return res
|
||
}
|
||
|
||
res.Listed = true
|
||
res.Severity = SeverityCrit
|
||
seen := map[string]bool{}
|
||
for _, a := range addrs {
|
||
ip := a.To4()
|
||
if ip == nil {
|
||
continue
|
||
}
|
||
code := ip.String()
|
||
if seen[code] {
|
||
continue
|
||
}
|
||
seen[code] = true
|
||
res.Evidence = append(res.Evidence, Evidence{
|
||
Label: "Return code",
|
||
Value: code,
|
||
})
|
||
if z.Decode != nil {
|
||
res.Reasons = append(res.Reasons, z.Decode(ip)...)
|
||
}
|
||
}
|
||
if len(res.Reasons) == 0 {
|
||
res.Reasons = append(res.Reasons, "Listed (no detail decoded)")
|
||
}
|
||
|
||
// TXT lookup is best-effort — operators often embed a pointer URL
|
||
// with the precise reason.
|
||
if txt, terr := net.DefaultResolver.LookupTXT(ctx, q); terr == nil {
|
||
res.Details = mustJSON(map[string]any{"txt": txt, "queried": q})
|
||
} else {
|
||
res.Details = mustJSON(map[string]any{"queried": q})
|
||
}
|
||
return res
|
||
}
|
||
|
||
func (*dnsblSource) Diagnose(res SourceResult) Diagnosis {
|
||
return Diagnosis{
|
||
Severity: SeverityCrit,
|
||
Title: fmt.Sprintf("Listed on %s", res.SourceName),
|
||
Detail: fmt.Sprintf(
|
||
"Reason(s): %s. Senders relaying mail through this domain (or recipients receiving links to it) will see deliveries rejected. Confirm with the lookup link, then follow the operator's removal procedure — automated requests usually take 24–72h to propagate.",
|
||
joinNonEmpty(res.Reasons, "; "),
|
||
),
|
||
LookupURL: res.LookupURL,
|
||
RemovalURL: res.RemovalURL,
|
||
Fix: res.RemovalURL,
|
||
FixIsURL: res.RemovalURL != "",
|
||
}
|
||
}
|
||
|
||
// ---------- helpers (shared with other sources) ----------
|
||
|
||
func formatURL(tmpl, domain string) string {
|
||
if tmpl == "" {
|
||
return ""
|
||
}
|
||
if !strings.Contains(tmpl, "%s") {
|
||
return tmpl
|
||
}
|
||
return fmt.Sprintf(tmpl, domain)
|
||
}
|
||
|
||
func zonesFromOptions(opts sdk.CheckerOptions) []DNSBLZone {
|
||
zones := DefaultDNSBLZones
|
||
|
||
if disabledRaw, ok := sdk.GetOption[string](opts, "disabled_dnsbls"); ok && disabledRaw != "" {
|
||
disabled := splitList(disabledRaw)
|
||
filtered := zones[:0:0]
|
||
for _, z := range zones {
|
||
if slices.Contains(disabled, strings.ToLower(z.Zone)) {
|
||
continue
|
||
}
|
||
filtered = append(filtered, z)
|
||
}
|
||
zones = filtered
|
||
}
|
||
|
||
if extraRaw, ok := sdk.GetOption[string](opts, "extra_dnsbls"); ok && extraRaw != "" {
|
||
for _, e := range splitList(extraRaw) {
|
||
zones = append(zones, DNSBLZone{Zone: e, Label: e})
|
||
}
|
||
}
|
||
return zones
|
||
}
|
||
|
||
func splitList(s string) []string {
|
||
var out []string
|
||
for _, part := range strings.FieldsFunc(s, func(r rune) bool {
|
||
return r == ',' || r == '\n' || r == '\r' || r == ' ' || r == '\t' || r == ';'
|
||
}) {
|
||
if p := strings.TrimSpace(part); p != "" {
|
||
out = append(out, strings.ToLower(p))
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func joinNonEmpty(parts []string, sep string) string {
|
||
if len(parts) == 0 {
|
||
return "listed"
|
||
}
|
||
return strings.Join(parts, sep)
|
||
}
|
||
|
||
func mustJSON(v any) []byte {
|
||
b, err := json.Marshal(v)
|
||
if err != nil {
|
||
panic("checker: mustJSON: " + err.Error())
|
||
}
|
||
return b
|
||
}
|
||
|
||
// ---------- return-code decoders ----------
|
||
|
||
func decodeSpamhausDBL(ip net.IP) []string {
|
||
switch ip.String() {
|
||
case "127.0.1.2":
|
||
return []string{"Spam domain"}
|
||
case "127.0.1.4":
|
||
return []string{"Phishing domain"}
|
||
case "127.0.1.5":
|
||
return []string{"Malware domain"}
|
||
case "127.0.1.6":
|
||
return []string{"Botnet C&C domain"}
|
||
case "127.0.1.102":
|
||
return []string{"Abused legit spam"}
|
||
case "127.0.1.103":
|
||
return []string{"Abused legit spammed redirector"}
|
||
case "127.0.1.104":
|
||
return []string{"Abused legit phish"}
|
||
case "127.0.1.105":
|
||
return []string{"Abused legit malware"}
|
||
case "127.0.1.106":
|
||
return []string{"Abused legit botnet C&C"}
|
||
case "127.0.1.255":
|
||
return []string{"DBL refused the query (resolver blocked, not a domain listing)"}
|
||
}
|
||
return []string{"Listed (code " + ip.String() + ")"}
|
||
}
|
||
|
||
func decodeSURBLMulti(ip net.IP) []string {
|
||
v4 := ip.To4()
|
||
if v4 == nil || v4[0] != 127 {
|
||
return []string{"Listed (" + ip.String() + ")"}
|
||
}
|
||
bits := v4[3]
|
||
var out []string
|
||
if bits&2 != 0 {
|
||
out = append(out, "Listed in SURBL abuse (sa-blacklist)")
|
||
}
|
||
if bits&4 != 0 {
|
||
out = append(out, "Phishing")
|
||
}
|
||
if bits&8 != 0 {
|
||
out = append(out, "Malware")
|
||
}
|
||
if bits&16 != 0 {
|
||
out = append(out, "Cracked / compromised site")
|
||
}
|
||
if bits&32 != 0 {
|
||
out = append(out, "Abuse (general)")
|
||
}
|
||
if bits&64 != 0 {
|
||
out = append(out, "Abused redirector")
|
||
}
|
||
if len(out) == 0 {
|
||
out = append(out, "Listed (code "+ip.String()+")")
|
||
}
|
||
return out
|
||
}
|
||
|
||
func decodeURIBLMulti(ip net.IP) []string {
|
||
v4 := ip.To4()
|
||
if v4 == nil || v4[0] != 127 {
|
||
return []string{"Listed (" + ip.String() + ")"}
|
||
}
|
||
bits := v4[3]
|
||
if bits == 1 {
|
||
return []string{"URIBL: query blocked (resolver on free-use blocklist)"}
|
||
}
|
||
var out []string
|
||
if bits&2 != 0 {
|
||
out = append(out, "URIBL black (active spam source)")
|
||
}
|
||
if bits&4 != 0 {
|
||
out = append(out, "URIBL grey (suspicious)")
|
||
}
|
||
if bits&8 != 0 {
|
||
out = append(out, "URIBL red (newly observed)")
|
||
}
|
||
if len(out) == 0 {
|
||
out = append(out, "Listed (code "+ip.String()+")")
|
||
}
|
||
return out
|
||
}
|