check / check (push) Failing after 3m27s
Every setting of discriminator and rproxy is read through viper, in place of the os.LookupEnv function Main passed down. NewViper turns AutomaticEnv and AllowEmptyEnv on, so a setting that is set but empty reads as set. A setting may also be given as a file, the _FILE form, which rproxy's settings now have too; rproxy still writes nothing to disk. The settings are renamed. Discriminator's take the prefix SWW_WAF_, rproxy's take SWW_RPROXY_, and the shared SWW_COMMAND keeps SWW_. The Core Rule Set's settings take SWW_WAF_CORE_RULE_SET_ rather than SWW_WAF_WAF_. Every place a name appeared changed: the code, the viper keys, tests, documents, examples, the README's settings table and the image files. Model: deepseek-v4-pro
333 lines
9.9 KiB
Go
333 lines
9.9 KiB
Go
package reputation
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"net/netip"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/hashicorp/golang-lru/v2/simplelru"
|
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
|
)
|
|
|
|
const (
|
|
// maxVerdicts is how many verdicts of the DNSBL zones are kept, and how
|
|
// many scores of AbuseIPDB. Past it, the one fetched longest ago is
|
|
// dropped.
|
|
maxVerdicts = 100000
|
|
// maxQueries is how many queries may be under way at once. Past it, a
|
|
// zone is not asked about a client until the client's next request, so
|
|
// that a swarm of new addresses cannot fill the memory.
|
|
maxQueries = 1000
|
|
// failureDelay is how long a zone is not asked again after a query to
|
|
// it fails, and no client is checked with AbuseIPDB after a check
|
|
// fails, so that a source refusing them is not asked on every request.
|
|
failureDelay = time.Minute
|
|
)
|
|
|
|
var (
|
|
errAsk = errors.New("ask the zone")
|
|
errRefused = errors.New("the zone refused the query")
|
|
errNotListing = errors.New("the answer is outside 127.0.0.0/8")
|
|
)
|
|
|
|
// Verdict is what a zone said about a client, as reputation.json holds
|
|
// it: the zone, the client's address, whether the zone lists it, and when
|
|
// the zone answered.
|
|
type Verdict struct {
|
|
Zone string `json:"zone"`
|
|
Client netip.Addr `json:"client"`
|
|
Listed bool `json:"listed"`
|
|
Fetched time.Time `json:"fetched"`
|
|
}
|
|
|
|
// DNSBLParams are what NewDNSBL needs.
|
|
type DNSBLParams struct {
|
|
// Zones are the DNSBL zones clients are asked about in
|
|
// (SWW_WAF_DNSBL_ZONES).
|
|
Zones []string
|
|
// Resolver is the resolver they are asked through
|
|
// (SWW_WAF_DNSBL_RESOLVER), or, while it is the zero AddrPort, the
|
|
// host's, as /etc/resolv.conf names it.
|
|
Resolver netip.AddrPort
|
|
// CacheTTL is how long a verdict is used after it was fetched
|
|
// (SWW_WAF_REPUTATION_CACHE_TTL), and Timeout how long a query may take
|
|
// (SWW_WAF_REPUTATION_TIMEOUT).
|
|
CacheTTL time.Duration
|
|
Timeout time.Duration
|
|
// Now tells the time, normally time.Now in UTC.
|
|
Now func() time.Time
|
|
// ProcessLog receives each query that fails, and why.
|
|
ProcessLog *slog.Logger
|
|
// Alerts receive a source_failure alert for each query that fails.
|
|
Alerts *alerts.Queue
|
|
}
|
|
|
|
// DNSBL asks the DNSBL zones about clients, in the background, and keeps
|
|
// their verdicts. It is safe for concurrent use.
|
|
type DNSBL struct {
|
|
params DNSBLParams
|
|
resolver *net.Resolver
|
|
|
|
mu sync.Mutex
|
|
// verdicts are by query. Each is added as it is fetched and never moved
|
|
// up, so that the one fetched longest ago is the first dropped.
|
|
verdicts *simplelru.LRU[query, Verdict]
|
|
// asking are the queries under way.
|
|
asking map[query]bool
|
|
// queries and failures count, by zone, the queries made and those that
|
|
// failed, and retryAt is when a zone whose last query failed may be
|
|
// asked again.
|
|
queries map[string]int
|
|
failures map[string]int
|
|
retryAt map[string]time.Time
|
|
}
|
|
|
|
// query is a client's address, to ask a zone about.
|
|
type query struct {
|
|
zone string
|
|
client netip.Addr
|
|
}
|
|
|
|
// NewDNSBL returns a DNSBL with no verdict yet.
|
|
func NewDNSBL(params DNSBLParams) *DNSBL {
|
|
verdicts, err := simplelru.NewLRU[query, Verdict](maxVerdicts, nil)
|
|
if err != nil {
|
|
panic(err) // NewLRU fails only for a size below one
|
|
}
|
|
|
|
resolver := &net.Resolver{}
|
|
if params.Resolver.IsValid() {
|
|
// Dial is used by Go's own resolver alone.
|
|
resolver.PreferGo = true
|
|
resolver.Dial = func(ctx context.Context, network, _ string) (net.Conn, error) {
|
|
var dialer net.Dialer
|
|
|
|
return dialer.DialContext(ctx, network, params.Resolver.String())
|
|
}
|
|
}
|
|
|
|
return &DNSBL{
|
|
params: params,
|
|
resolver: resolver,
|
|
verdicts: verdicts,
|
|
asking: map[query]bool{},
|
|
queries: map[string]int{},
|
|
failures: map[string]int{},
|
|
retryAt: map[string]time.Time{},
|
|
}
|
|
}
|
|
|
|
// Zones returns the zones, in the order SWW_WAF_DNSBL_ZONES names them.
|
|
func (d *DNSBL) Zones() []string {
|
|
return slices.Clone(d.params.Zones)
|
|
}
|
|
|
|
// ListedBy returns the zones whose verdict on addr, a client's address,
|
|
// lists it, in the order SWW_WAF_DNSBL_ZONES names them, each with its key
|
|
// masked, as config.MaskZoneKey masks it, since they go to the request
|
|
// log, the alerts and the metrics. A verdict is used until CacheTTL has
|
|
// passed since it was fetched. Each zone without one is asked about addr
|
|
// in the background, unless a query about addr to it is under way, the
|
|
// zone is left alone after a failure, or maxQueries are under way;
|
|
// ListedBy never waits for a query. ctx is the context of the client's
|
|
// request, and a query goes on after the request ends.
|
|
func (d *DNSBL) ListedBy(ctx context.Context, addr netip.Addr) []string {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
|
|
now := d.params.Now()
|
|
|
|
var listedBy []string
|
|
|
|
for _, zone := range d.params.Zones {
|
|
q := query{zone: zone, client: addr}
|
|
|
|
kept, found := d.verdicts.Peek(q)
|
|
|
|
switch {
|
|
case found && now.Sub(kept.Fetched) < d.params.CacheTTL:
|
|
if kept.Listed {
|
|
listedBy = append(listedBy, config.MaskZoneKey(zone))
|
|
}
|
|
case !d.asking[q] && !now.Before(d.retryAt[zone]) && len(d.asking) < maxQueries:
|
|
d.asking[q] = true
|
|
d.queries[zone]++
|
|
|
|
go d.ask(context.WithoutCancel(ctx), q)
|
|
}
|
|
}
|
|
|
|
return listedBy
|
|
}
|
|
|
|
// Queries returns how many queries were made to zone.
|
|
func (d *DNSBL) Queries(zone string) int {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
|
|
return d.queries[zone]
|
|
}
|
|
|
|
// Failures returns how many queries to zone failed.
|
|
func (d *DNSBL) Failures(zone string) int {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
|
|
return d.failures[zone]
|
|
}
|
|
|
|
// Snapshot returns every verdict still in use, sorted by client, then by
|
|
// zone, as reputation.json lists them.
|
|
func (d *DNSBL) Snapshot() []Verdict {
|
|
d.mu.Lock()
|
|
|
|
now := d.params.Now()
|
|
verdicts := make([]Verdict, 0, d.verdicts.Len())
|
|
|
|
for _, kept := range d.verdicts.Values() {
|
|
if now.Sub(kept.Fetched) < d.params.CacheTTL {
|
|
verdicts = append(verdicts, kept)
|
|
}
|
|
}
|
|
|
|
d.mu.Unlock()
|
|
|
|
slices.SortFunc(verdicts, func(a, b Verdict) int {
|
|
return cmp.Or(a.Client.Compare(b.Client), strings.Compare(a.Zone, b.Zone))
|
|
})
|
|
|
|
return verdicts
|
|
}
|
|
|
|
// Load keeps verdicts, read from reputation.json, in place of those it
|
|
// keeps, but for those of a zone SWW_WAF_DNSBL_ZONES does not name, and,
|
|
// past maxVerdicts, those fetched longest ago. One fetched CacheTTL ago or
|
|
// more is neither used nor written, as for any verdict.
|
|
func (d *DNSBL) Load(verdicts []Verdict) {
|
|
verdicts = slices.Clone(verdicts)
|
|
slices.SortStableFunc(verdicts, func(a, b Verdict) int {
|
|
return a.Fetched.Compare(b.Fetched)
|
|
})
|
|
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
|
|
d.verdicts.Purge()
|
|
|
|
for _, kept := range verdicts {
|
|
if slices.Contains(d.params.Zones, kept.Zone) {
|
|
d.verdicts.Add(query{zone: kept.Zone, client: kept.Client}, kept)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ask asks q's zone about q's client, keeps the verdict, and notes the
|
|
// query as no longer under way. A query that fails gives no verdict: it
|
|
// is counted, logged and raised as a source_failure alert, which show the
|
|
// zone with its key masked, and the zone is not asked again for
|
|
// failureDelay.
|
|
func (d *DNSBL) ask(ctx context.Context, q query) {
|
|
listed, err := d.lookUp(ctx, q)
|
|
now := d.params.Now()
|
|
|
|
d.mu.Lock()
|
|
|
|
delete(d.asking, q)
|
|
|
|
if err == nil {
|
|
d.verdicts.Add(q, Verdict{
|
|
Zone: q.zone, Client: q.client, Listed: listed, Fetched: now,
|
|
})
|
|
} else {
|
|
d.failures[q.zone]++
|
|
d.retryAt[q.zone] = now.Add(failureDelay)
|
|
}
|
|
|
|
d.mu.Unlock()
|
|
|
|
if err != nil {
|
|
const failed = "asking a DNSBL zone failed"
|
|
|
|
shown := config.MaskZoneKey(q.zone)
|
|
|
|
// Raised before it is logged, so that the alert is there once the
|
|
// log line is.
|
|
raiseFailure(d.params.Alerts, failed, shown, err)
|
|
d.params.ProcessLog.Warn(failed, "zone", shown, "error", err.Error())
|
|
}
|
|
}
|
|
|
|
// lookUp asks q's zone about q's client through the resolver, and returns
|
|
// whether the zone lists it, as readAnswer reads the answer. No such name
|
|
// is a client the zone does not list. A query not answered within Timeout
|
|
// fails.
|
|
func (d *DNSBL) lookUp(ctx context.Context, q query) (bool, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, d.params.Timeout)
|
|
defer cancel()
|
|
|
|
answer, err := d.resolver.LookupNetIP(ctx, "ip4", queryName(q.zone, q.client))
|
|
|
|
var dnsErr *net.DNSError
|
|
|
|
switch {
|
|
case err == nil:
|
|
return readAnswer(answer)
|
|
case errors.As(err, &dnsErr) && dnsErr.IsNotFound:
|
|
return false, nil
|
|
case errors.As(err, &dnsErr):
|
|
// The error names the name asked about, which holds the client's
|
|
// address, which is not to be logged: only what went wrong is kept.
|
|
return false, fmt.Errorf("%w: %s", errAsk, dnsErr.Err)
|
|
default:
|
|
return false, fmt.Errorf("%w: %w", errAsk, err)
|
|
}
|
|
}
|
|
|
|
// queryName returns the name a zone is asked about addr by, as RFC 5782
|
|
// builds it: the four numbers of an IPv4 address, or the 32 hex digits of
|
|
// an IPv6 address, in reverse order, each followed by a dot, then the zone
|
|
// and a dot, which makes it a full name, to which the resolver adds no
|
|
// search domain of /etc/resolv.conf.
|
|
func queryName(zone string, addr netip.Addr) string {
|
|
parts := strings.Split(addr.String(), ".")
|
|
if addr.Is6() {
|
|
parts = strings.Split(hex.EncodeToString(addr.AsSlice()), "")
|
|
}
|
|
|
|
slices.Reverse(parts)
|
|
|
|
return strings.Join(parts, ".") + "." + zone + "."
|
|
}
|
|
|
|
// readAnswer reads the addresses a zone answered with. An address in
|
|
// 127.0.0.0/8 lists the client, as RFC 5782 has zones answer, but one in
|
|
// 127.255.255.0/24 is how Spamhaus refuses a query, such as one sent
|
|
// through a public resolver or one past its limit, and is a failure. So is
|
|
// an address outside 127.0.0.0/8, such as a resolver gives that answers
|
|
// even for names that do not exist.
|
|
func readAnswer(answer []netip.Addr) (bool, error) {
|
|
listing := netip.MustParsePrefix("127.0.0.0/8")
|
|
refusal := netip.MustParsePrefix("127.255.255.0/24")
|
|
|
|
for _, addr := range answer {
|
|
switch {
|
|
case refusal.Contains(addr):
|
|
return false, fmt.Errorf("%w: %s", errRefused, addr)
|
|
case !listing.Contains(addr):
|
|
return false, fmt.Errorf("%w: %s", errNotListing, addr)
|
|
}
|
|
}
|
|
|
|
return len(answer) > 0, nil
|
|
}
|