checker-blacklist/checker/quad9.go
Pierre-Olivier Mercier 219d9353c3
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Add Quad9 secure DNS blocklist source
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.
2026-05-16 11:00:50 +08:00

127 lines
3.8 KiB
Go

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,
}
}