Add Quad9 secure DNS blocklist source
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing

Detects domains blocked by Quad9's threat intelligence by comparing
the secure resolver (9.9.9.9) against the unsecured peer (9.9.9.10).
No API key required; enabled by default via the enable_quad9 user option.
This commit is contained in:
nemunaire 2026-05-16 09:33:19 +08:00
commit 219d9353c3
3 changed files with 129 additions and 1 deletions

127
checker/quad9.go Normal file
View file

@ -0,0 +1,127 @@
package checker
import (
"context"
"fmt"
"net"
"sync"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func init() {
Register(&quad9Source{
secure: newDNSResolver("9.9.9.9:53"),
unsecured: newDNSResolver("9.9.9.10:53"),
})
}
type quad9Source struct {
secure *net.Resolver
unsecured *net.Resolver
}
func newDNSResolver(addr string) *net.Resolver {
return &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
d := &net.Dialer{Timeout: 5 * time.Second}
return d.DialContext(ctx, "udp", addr)
},
}
}
func (*quad9Source) ID() string { return "quad9" }
func (*quad9Source) Name() string { return "Quad9 secure DNS" }
func (*quad9Source) Options() SourceOptions {
return SourceOptions{
User: []sdk.CheckerOptionField{
{
Id: "enable_quad9",
Type: "bool",
Label: "Use Quad9 secure DNS check",
Description: "Compare Quad9's secure resolver (9.9.9.9) against its unsecured peer (9.9.9.10). A domain that resolves on the unsecured but returns NXDOMAIN on the secure resolver is blocked by Quad9's threat intelligence.",
Default: true,
},
},
}
}
func (s *quad9Source) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult {
if !sdk.GetBoolOption(opts, "enable_quad9", true) || registered == "" {
return disabledResult(s.ID(), s.Name())
}
res := SourceResult{
SourceID: s.ID(),
SourceName: s.Name(),
Enabled: true,
LookupURL: "https://www.quad9.net/result/?domain=" + registered,
}
var (
secureAddrs, unsecuredAddrs []net.IPAddr
secureErr, unsecuredErr error
wg sync.WaitGroup
)
queryCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
wg.Add(2)
go func() {
defer wg.Done()
secureAddrs, secureErr = s.secure.LookupIPAddr(queryCtx, registered)
}()
go func() {
defer wg.Done()
unsecuredAddrs, unsecuredErr = s.unsecured.LookupIPAddr(queryCtx, registered)
}()
wg.Wait()
// Suppress the unused variable warning — secureAddrs is only used as an
// existence check; the actual IPs are not surfaced.
_ = secureAddrs
// If the unsecured resolver can't resolve the domain either, it simply
// doesn't exist — not a Quad9 block.
if unsecuredErr != nil || len(unsecuredAddrs) == 0 {
if unsecuredErr != nil {
if dnsErr, ok := unsecuredErr.(*net.DNSError); !ok || !dnsErr.IsNotFound {
res.Error = "unsecured resolver: " + unsecuredErr.Error()
}
}
return []SourceResult{res}
}
// Domain resolves on unsecured. Check whether the secure resolver blocked it.
if dnsErr, ok := secureErr.(*net.DNSError); ok && dnsErr.IsNotFound {
res.Reasons = []string{"Blocked by Quad9 threat intelligence"}
res.Evidence = append(res.Evidence, Evidence{
Label: "Secure resolver (9.9.9.9)",
Value: "NXDOMAIN",
})
} else if secureErr != nil {
res.Error = "secure resolver: " + secureErr.Error()
}
return []SourceResult{res}
}
func (*quad9Source) Evaluate(r SourceResult) (bool, string) {
return evidenceEval(r, SeverityCrit)
}
func (*quad9Source) Diagnose(res SourceResult) Diagnosis {
return Diagnosis{
Severity: SeverityCrit,
Title: "Blocked by Quad9 secure DNS resolver",
Detail: fmt.Sprintf(
"This domain is on Quad9's threat intelligence blocklist: the secure resolver (9.9.9.9) returns NXDOMAIN while the unsecured peer (9.9.9.10) resolves normally. Quad9 aggregates feeds from 18+ threat intel partners (abuse.ch, Bambenek, CINS, DShield, etc.). Visitors whose ISP or device uses Quad9 cannot reach this domain. Submit a false-positive report at the Quad9 contact page if you believe the listing is incorrect.",
),
Fix: "https://www.quad9.net/support/contact/",
FixIsURL: true,
LookupURL: res.LookupURL,
}
}

View file

@ -24,7 +24,7 @@ func TestRegisteredSourcesAreSane(t *testing.T) {
}
}
// At least the built-in sources are present.
for _, want := range []string{"dnsbl", "google_safe_browsing", "openphish", "pulsedive", "urlhaus", "virustotal"} {
for _, want := range []string{"dnsbl", "google_safe_browsing", "openphish", "pulsedive", "quad9", "urlhaus", "virustotal"} {
if !seen[want] {
t.Errorf("missing built-in source %q", want)
}