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 IsBlockedIP func(ip net.IP) bool // returns true when the IP signals a blocked resolver, not a real listing } // 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, IsBlockedIP: func(ip net.IP) bool { s := ip.String() return s == "127.0.1.255" || s == "127.255.255.254" }, }, { 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, IsBlockedIP: func(ip net.IP) bool { v4 := ip.To4() return v4 != nil && v4[3] == 1 }, }, } 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 } seen := map[string]bool{} blockedCount := 0 for _, a := range addrs { ip := a.To4() if ip == nil { continue } code := ip.String() if seen[code] { continue } seen[code] = true if z.IsBlockedIP != nil && z.IsBlockedIP(ip) { blockedCount++ } res.Evidence = append(res.Evidence, Evidence{ Label: "Return code", Value: code, }) if z.Decode != nil { res.Reasons = append(res.Reasons, z.Decode(ip)...) } } if blockedCount > 0 && blockedCount == len(seen) { // All returned IPs signal a blocked resolver, not a real domain listing. res.BlockedQuery = true return res } res.Listed = true res.Severity = SeverityCrit 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 to 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", "127.255.255.254": 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 }