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
653 lines
19 KiB
Go
653 lines
19 KiB
Go
// Package reputation fetches the lists the settings name by URL: the
|
|
// blocklists of SWW_WAF_BLOCKLIST_URLS, the file of AS:percent lines
|
|
// SWW_WAF_ASN_LIMIT_PERCENT_URL names, and the decision list of the CrowdSec
|
|
// engine SWW_WAF_CROWDSEC_LAPI_URL names. It keeps the last good copy of
|
|
// each, whole, comment lines included, which is used while a fetch fails,
|
|
// and when each was last tried. It also asks the DNSBL zones of
|
|
// SWW_WAF_DNSBL_ZONES about clients, and keeps their verdicts, and checks
|
|
// clients with AbuseIPDB, and keeps their scores and the checks spent
|
|
// today. The state package writes all of these to reputation.json and
|
|
// reads them from it, so that a restart keeps them too.
|
|
package reputation
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/netip"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
|
)
|
|
|
|
const (
|
|
// maxListBytes is the most of a list that is read. A longer one is a
|
|
// failure, so that a wrong URL cannot fill the memory.
|
|
maxListBytes = 16 << 20
|
|
// fetchTimeout bounds one fetch of a list.
|
|
fetchTimeout = time.Minute
|
|
// crowdSecRefresh is how long after the CrowdSec decision list was last
|
|
// fetched or tried it is fetched again: the engine is the operator's
|
|
// own, and makes and ends decisions all the time.
|
|
crowdSecRefresh = time.Minute
|
|
// mappedBits is the length of ::ffff:0.0.0.0/96, the netblock of every
|
|
// IPv4-mapped address.
|
|
mappedBits = 96
|
|
)
|
|
|
|
var (
|
|
errStatus = errors.New("the server answered")
|
|
errTooLong = errors.New("the list is longer than 16 MiB")
|
|
errNotNetblock = errors.New("is not an address or a netblock, such as 192.0.2.0/24")
|
|
errNotASNPercent = errors.New(
|
|
"is not an AS number, : and a percentage, such as AS64496:50")
|
|
errNotDecision = errors.New(
|
|
"does not give an address or a netblock and a duration, such as 4h0m0s")
|
|
)
|
|
|
|
// List is a list as reputation.json holds it: the URL it is fetched from,
|
|
// when it was last tried, the fetch failed or not, and its last good copy:
|
|
// when that was fetched, and its lines, as fetched, comment lines
|
|
// included, both left out while no fetch of it has succeeded.
|
|
type List struct {
|
|
URL string `json:"url"`
|
|
Tried time.Time `json:"tried"`
|
|
Fetched time.Time `json:"fetched,omitzero"`
|
|
Lines []string `json:"lines,omitzero"`
|
|
}
|
|
|
|
// Params are what New needs.
|
|
type Params struct {
|
|
// BlocklistURLs are the blocklists (SWW_WAF_BLOCKLIST_URLS), and
|
|
// ASNLimitPercentURL the file of AS:percent lines
|
|
// (SWW_WAF_ASN_LIMIT_PERCENT_URL), "" while it is unset.
|
|
BlocklistURLs []string
|
|
ASNLimitPercentURL string
|
|
// CrowdSecDecisionsURL is the CrowdSec decision list, "" while
|
|
// SWW_WAF_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey
|
|
// (SWW_WAF_CROWDSEC_LAPI_KEY).
|
|
CrowdSecDecisionsURL string
|
|
CrowdSecKey string
|
|
// Refresh is how long after a list was last fetched or tried it is
|
|
// fetched again (SWW_WAF_BLOCKLIST_REFRESH), but for the CrowdSec decision
|
|
// list, which is fetched again crowdSecRefresh after.
|
|
Refresh time.Duration
|
|
// Now tells the time, normally time.Now in UTC.
|
|
Now func() time.Time
|
|
// ProcessLog receives each fetch of a list, and why one failed.
|
|
ProcessLog *slog.Logger
|
|
// Alerts receive a source_failure alert for each fetch that fails.
|
|
Alerts *alerts.Queue
|
|
}
|
|
|
|
// Lists are the lists Params names, each with its last good copy. They
|
|
// are safe for concurrent use.
|
|
type Lists struct {
|
|
params Params
|
|
httpClient *http.Client
|
|
// crowdSecClient fetches the CrowdSec decision list. It follows no
|
|
// redirect, so that the key goes to the engine alone: a redirect is a
|
|
// failure.
|
|
crowdSecClient *http.Client
|
|
|
|
mu sync.Mutex
|
|
// lists are by URL, one for each URL Params names.
|
|
lists map[string]*list
|
|
}
|
|
|
|
// list is one list: what reputation.json keeps of it, its last try, zero
|
|
// before the first, and its last good copy, what that copy says, and how
|
|
// many fetches of it failed.
|
|
type list struct {
|
|
kept List
|
|
entries entries
|
|
failures int
|
|
}
|
|
|
|
// entries are what the lines of a copy say: for a blocklist, the netblocks
|
|
// it names, with the lengths among them, for the file of AS:percent lines,
|
|
// the percentage it gives each AS number, and for the CrowdSec decision
|
|
// list, the decision on each netblock that ends last, with the lengths
|
|
// among them.
|
|
type entries struct {
|
|
netblocks map[netip.Prefix]bool
|
|
lengths []int
|
|
percents map[string]int64
|
|
decisions map[netip.Prefix]Decision
|
|
}
|
|
|
|
// Decision is a decision of the CrowdSec engine to ban a netblock: when
|
|
// it ends, and the scenario that made it, such as crowdsecurity/ssh-bf.
|
|
type Decision struct {
|
|
Expires time.Time
|
|
Scenario string
|
|
}
|
|
|
|
// New returns the lists, without a copy of any yet.
|
|
func New(params Params) *Lists {
|
|
l := &Lists{
|
|
params: params,
|
|
httpClient: &http.Client{},
|
|
crowdSecClient: &http.Client{
|
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
},
|
|
lists: map[string]*list{},
|
|
}
|
|
|
|
for _, listURL := range l.URLs() {
|
|
l.lists[listURL] = &list{kept: List{URL: listURL}}
|
|
}
|
|
|
|
return l
|
|
}
|
|
|
|
// URLs returns the URL of every list: the blocklists' in the order
|
|
// SWW_WAF_BLOCKLIST_URLS names them, then SWW_WAF_ASN_LIMIT_PERCENT_URL, then
|
|
// the CrowdSec decision list's.
|
|
func (l *Lists) URLs() []string {
|
|
urls := slices.Clone(l.params.BlocklistURLs)
|
|
if l.params.ASNLimitPercentURL != "" {
|
|
urls = append(urls, l.params.ASNLimitPercentURL)
|
|
}
|
|
|
|
if l.params.CrowdSecDecisionsURL != "" {
|
|
urls = append(urls, l.params.CrowdSecDecisionsURL)
|
|
}
|
|
|
|
return urls
|
|
}
|
|
|
|
// ListedBy returns the URLs of the blocklists whose copy lists addr, in
|
|
// the order SWW_WAF_BLOCKLIST_URLS names them.
|
|
func (l *Lists) ListedBy(addr netip.Addr) []string {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
var listedBy []string
|
|
|
|
for _, listURL := range l.params.BlocklistURLs {
|
|
if l.lists[listURL].entries.contain(addr) {
|
|
listedBy = append(listedBy, listURL)
|
|
}
|
|
}
|
|
|
|
return listedBy
|
|
}
|
|
|
|
// ASNLimitPercent returns the percentage the copy of the file of
|
|
// AS:percent lines gives asn, and whether it lists asn.
|
|
func (l *Lists) ASNLimitPercent(asn string) (int64, bool) {
|
|
if l.params.ASNLimitPercentURL == "" {
|
|
return 0, false
|
|
}
|
|
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
percent, listed := l.lists[l.params.ASNLimitPercentURL].entries.percents[asn]
|
|
|
|
return percent, listed
|
|
}
|
|
|
|
// CrowdSecDecision returns the decision of the copy of the CrowdSec
|
|
// decision list on a netblock that holds addr and that ends last, and
|
|
// whether it is still in force at now. A decision that has ended no
|
|
// longer bans, even before the next fetch drops it.
|
|
func (l *Lists) CrowdSecDecision(addr netip.Addr, now time.Time) (Decision, bool) {
|
|
if l.params.CrowdSecDecisionsURL == "" {
|
|
return Decision{}, false
|
|
}
|
|
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
kept := l.lists[l.params.CrowdSecDecisionsURL].entries
|
|
|
|
var last Decision
|
|
|
|
for _, length := range kept.lengths {
|
|
netblock, err := addr.Prefix(length)
|
|
if err != nil {
|
|
continue // an IPv6 netblock's length, past an IPv4 address's 32 bits
|
|
}
|
|
|
|
decision := kept.decisions[netblock]
|
|
if decision.Expires.After(last.Expires) {
|
|
last = decision
|
|
}
|
|
}
|
|
|
|
return last, now.Before(last.Expires)
|
|
}
|
|
|
|
// Fetched returns when the copy in use of the list at listURL was
|
|
// fetched, or zero while there is none.
|
|
func (l *Lists) Fetched(listURL string) time.Time {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
return l.lists[listURL].kept.Fetched
|
|
}
|
|
|
|
// Failures returns how many fetches of the list at listURL failed.
|
|
func (l *Lists) Failures(listURL string) int {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
return l.lists[listURL].failures
|
|
}
|
|
|
|
// Run fetches each list once it is due, as due tells, until ctx is done. A
|
|
// list never tried is fetched at once, and so is one that is due by its
|
|
// last try or copy read from reputation.json.
|
|
func (l *Lists) Run(ctx context.Context) {
|
|
if len(l.lists) == 0 {
|
|
return
|
|
}
|
|
|
|
for ctx.Err() == nil {
|
|
next := l.fetchDue(ctx)
|
|
timer := time.NewTimer(next.Sub(l.params.Now()))
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
case <-timer.C:
|
|
}
|
|
|
|
timer.Stop()
|
|
}
|
|
}
|
|
|
|
// Snapshot returns each list that has been tried, with its copy, if it
|
|
// has one, sorted by URL, as reputation.json lists them.
|
|
func (l *Lists) Snapshot() []List {
|
|
l.mu.Lock()
|
|
|
|
tried := make([]List, 0, len(l.lists))
|
|
|
|
for _, held := range l.lists {
|
|
if !held.kept.Tried.IsZero() {
|
|
tried = append(tried, held.kept)
|
|
}
|
|
}
|
|
|
|
l.mu.Unlock()
|
|
|
|
slices.SortFunc(tried, func(a, b List) int {
|
|
return strings.Compare(a.URL, b.URL)
|
|
})
|
|
|
|
return tried
|
|
}
|
|
|
|
// Load puts lists, read from reputation.json, in place of the last tries
|
|
// and copies held. A list Params does not name is dropped. A copy with a
|
|
// line that parse refuses is an error, and then nothing changes.
|
|
func (l *Lists) Load(lists []List) error {
|
|
found := make(map[string]entries, len(lists))
|
|
|
|
for _, kept := range lists {
|
|
_, named := l.lists[kept.URL]
|
|
if !named || kept.Fetched.IsZero() {
|
|
continue // dropped, or a list tried but never fetched, without a copy
|
|
}
|
|
|
|
read, err := l.parse(kept.URL, kept.Lines, kept.Fetched)
|
|
if err != nil {
|
|
return fmt.Errorf("the copy of %s: %w", kept.URL, err)
|
|
}
|
|
|
|
found[kept.URL] = read
|
|
}
|
|
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
for listURL, held := range l.lists {
|
|
held.kept, held.entries = List{URL: listURL}, entries{}
|
|
}
|
|
|
|
for _, kept := range lists {
|
|
if _, named := l.lists[kept.URL]; named {
|
|
l.lists[kept.URL].kept, l.lists[kept.URL].entries = kept, found[kept.URL]
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// fetchDue fetches each list that is due, one after another, and returns
|
|
// when the next is due. Once ctx has ended, it starts none, since a fetch
|
|
// cut off is noted as a try.
|
|
func (l *Lists) fetchDue(ctx context.Context) time.Time {
|
|
var next time.Time
|
|
|
|
for _, listURL := range l.URLs() {
|
|
due := l.due(listURL)
|
|
if ctx.Err() == nil && !l.params.Now().Before(due) {
|
|
l.fetch(ctx, listURL)
|
|
due = l.due(listURL)
|
|
}
|
|
|
|
if next.IsZero() || due.Before(next) {
|
|
next = due
|
|
}
|
|
}
|
|
|
|
return next
|
|
}
|
|
|
|
// due returns when the list at listURL is to be fetched: Refresh after it
|
|
// was last fetched or tried, the later of the two, or crowdSecRefresh
|
|
// after for the CrowdSec decision list.
|
|
func (l *Lists) due(listURL string) time.Time {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
held := l.lists[listURL]
|
|
|
|
last := held.kept.Fetched
|
|
if held.kept.Tried.After(last) {
|
|
last = held.kept.Tried
|
|
}
|
|
|
|
if listURL == l.params.CrowdSecDecisionsURL {
|
|
return last.Add(crowdSecRefresh)
|
|
}
|
|
|
|
return last.Add(l.params.Refresh)
|
|
}
|
|
|
|
// fetch fetches the list at listURL, and notes the try. A good copy takes
|
|
// the place of the one held. A failure leaves that in use, and is counted,
|
|
// logged and raised as a source_failure alert. A fetch cut off as ctx
|
|
// ends, as sww stops, is no failure, but is still noted as a try,
|
|
// so that a restart waits for it: the server may have had its request.
|
|
func (l *Lists) fetch(ctx context.Context, listURL string) {
|
|
lines, err := l.get(ctx, listURL)
|
|
now := l.params.Now()
|
|
|
|
var found entries
|
|
if err == nil {
|
|
found, err = l.parse(listURL, lines, now)
|
|
}
|
|
|
|
cutOff := err != nil && ctx.Err() != nil
|
|
|
|
l.mu.Lock()
|
|
|
|
held := l.lists[listURL]
|
|
held.kept.Tried = now
|
|
|
|
if err == nil {
|
|
held.kept.Fetched, held.kept.Lines = now, lines
|
|
held.entries = found
|
|
} else if !cutOff {
|
|
held.failures++
|
|
}
|
|
|
|
l.mu.Unlock()
|
|
|
|
if cutOff {
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
const failed = "fetching a list failed"
|
|
|
|
// Raised before it is logged, so that the alert is there once the
|
|
// log line is.
|
|
raiseFailure(l.params.Alerts, failed, listURL, err)
|
|
l.params.ProcessLog.Warn(failed, "url", listURL, "error", err.Error())
|
|
|
|
return
|
|
}
|
|
|
|
l.params.ProcessLog.Info("fetched a list", "url", listURL, "lines", len(lines))
|
|
}
|
|
|
|
// raiseFailure raises a source_failure alert into queue, with reason, and
|
|
// in its detail the source that failed, a list's URL, a zone with its key
|
|
// masked or abuseipdb, and err.
|
|
func raiseFailure(queue *alerts.Queue, reason, source string, err error) {
|
|
queue.Raise(alerts.Alert{
|
|
Event: alerts.EventSourceFailure,
|
|
Reason: reason,
|
|
Detail: map[string]any{"source": source, "error": err.Error()},
|
|
})
|
|
}
|
|
|
|
// get fetches the list at listURL, and returns its lines. The CrowdSec
|
|
// decision list is fetched with CrowdSecKey in the header X-Api-Key, where
|
|
// the engine looks for it, by crowdSecClient, which follows no redirect.
|
|
// An answer other than 200, or a list longer than maxListBytes, is a
|
|
// failure.
|
|
func (l *Lists) get(ctx context.Context, listURL string) ([]string, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, listURL, http.NoBody)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("make the request: %w", err)
|
|
}
|
|
|
|
client := l.httpClient
|
|
|
|
if listURL == l.params.CrowdSecDecisionsURL {
|
|
req.Header.Set("X-Api-Key", l.params.CrowdSecKey)
|
|
client = l.crowdSecClient
|
|
}
|
|
|
|
res, err := client.Do(req)
|
|
if err != nil {
|
|
// Do's error names the URL, which the log line and the alert name
|
|
// already: only what went wrong is kept.
|
|
return nil, fmt.Errorf("fetch the list: %w", errors.Unwrap(err))
|
|
}
|
|
|
|
defer func() {
|
|
_ = res.Body.Close()
|
|
}()
|
|
|
|
if res.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("%w %s", errStatus, res.Status)
|
|
}
|
|
|
|
body, err := io.ReadAll(io.LimitReader(res.Body, maxListBytes+1))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read the list: %w", err)
|
|
}
|
|
|
|
if len(body) > maxListBytes {
|
|
return nil, errTooLong
|
|
}
|
|
|
|
lines := []string{}
|
|
for line := range strings.Lines(string(body)) {
|
|
lines = append(lines, strings.TrimSuffix(line, "\n"))
|
|
}
|
|
|
|
return lines, nil
|
|
}
|
|
|
|
// parse reads the lines of the list at listURL, fetched at fetched: those
|
|
// of a blocklist, of the file of AS:percent lines, or of the CrowdSec
|
|
// decision list. In the first two, anything after a ; or a # on a line is
|
|
// left out, and so is a line left blank. Any other line that does not read
|
|
// is an error naming it by its number.
|
|
func (l *Lists) parse(
|
|
listURL string, lines []string, fetched time.Time,
|
|
) (entries, error) {
|
|
switch listURL {
|
|
case l.params.ASNLimitPercentURL:
|
|
return parsePercents(lines)
|
|
case l.params.CrowdSecDecisionsURL:
|
|
return parseDecisions(lines, fetched)
|
|
default:
|
|
return parseNetblocks(lines)
|
|
}
|
|
}
|
|
|
|
// parseNetblocks reads a blocklist's lines, each an address or a netblock
|
|
// as the settings take them.
|
|
func parseNetblocks(lines []string) (entries, error) {
|
|
found := entries{netblocks: map[netip.Prefix]bool{}}
|
|
|
|
for i, line := range lines {
|
|
text := withoutComment(line)
|
|
if text == "" {
|
|
continue
|
|
}
|
|
|
|
netblock, ok := parseNetblock(text)
|
|
if !ok {
|
|
return entries{}, fmt.Errorf("line %d %w", i+1, errNotNetblock)
|
|
}
|
|
|
|
found.netblocks[netblock] = true
|
|
|
|
if !slices.Contains(found.lengths, netblock.Bits()) {
|
|
found.lengths = append(found.lengths, netblock.Bits())
|
|
}
|
|
}
|
|
|
|
return found, nil
|
|
}
|
|
|
|
// parseNetblock reads text, a line of a blocklist, and reports whether it
|
|
// is an address or a netblock as the settings take them. A client's IPv4
|
|
// address is checked as IPv4, never IPv4-mapped, so an IPv4-mapped line,
|
|
// such as ::ffff:192.0.2.0/120, is read as the IPv4 address or netblock it
|
|
// stands for, 192.0.2.0/24, and a mapped netblock shorter than /96, which
|
|
// stands for none, is refused.
|
|
func parseNetblock(text string) (netip.Prefix, bool) {
|
|
netblock, err := config.ParseNetblock(text)
|
|
if err != nil {
|
|
return netip.Prefix{}, false
|
|
}
|
|
|
|
// The address as written: ParseNetblock's has the bits past the
|
|
// netblock's length cleared, the ::ffff among them below /96.
|
|
written, _, _ := strings.Cut(text, "/")
|
|
if addr, _ := netip.ParseAddr(written); !addr.Is4In6() {
|
|
return netblock, true
|
|
}
|
|
|
|
if netblock.Bits() < mappedBits {
|
|
return netip.Prefix{}, false
|
|
}
|
|
|
|
return netip.PrefixFrom(netblock.Addr().Unmap(), netblock.Bits()-mappedBits), true
|
|
}
|
|
|
|
// parsePercents reads the lines of the file of AS:percent lines, each an
|
|
// AS number, : and a percentage, as SWW_WAF_ASN_LIMIT_PERCENT takes them. An
|
|
// AS number listed more than once gets the lowest of its percentages.
|
|
func parsePercents(lines []string) (entries, error) {
|
|
found := entries{percents: map[string]int64{}}
|
|
|
|
for i, line := range lines {
|
|
text := withoutComment(line)
|
|
if text == "" {
|
|
continue
|
|
}
|
|
|
|
asnText, percentText, _ := strings.Cut(text, ":")
|
|
asn, asnErr := config.ParseASN(asnText)
|
|
|
|
percent, percentErr := config.ParsePercent(percentText)
|
|
if asnErr != nil || percentErr != nil {
|
|
return entries{}, fmt.Errorf("line %d %w", i+1, errNotASNPercent)
|
|
}
|
|
|
|
earlier, listed := found.percents[asn]
|
|
if !listed || percent < earlier {
|
|
found.percents[asn] = percent
|
|
}
|
|
}
|
|
|
|
return found, nil
|
|
}
|
|
|
|
// parseDecisions reads the lines of the CrowdSec decision list fetched at
|
|
// fetched: the engine's answer, a JSON list of its decisions in force,
|
|
// null while it has none. A decision of the type ban whose scope is Ip or
|
|
// Range, as CrowdSec names them, bans its value, an address or a netblock
|
|
// as parseNetblock reads it, until its duration, the time it had left as
|
|
// the engine answered, has passed since fetched. Any other decision, such
|
|
// as one to show a captcha or one on a country, is left out. A decision
|
|
// to ban whose value or duration does not read is an error naming it by
|
|
// its number.
|
|
func parseDecisions(lines []string, fetched time.Time) (entries, error) {
|
|
var answer []struct {
|
|
Duration string `json:"duration"`
|
|
Scenario string `json:"scenario"`
|
|
Scope string `json:"scope"`
|
|
Type string `json:"type"`
|
|
Value string `json:"value"`
|
|
}
|
|
|
|
err := json.Unmarshal([]byte(strings.Join(lines, "\n")), &answer)
|
|
if err != nil {
|
|
return entries{}, fmt.Errorf("read the answer: %w", err)
|
|
}
|
|
|
|
found := entries{decisions: map[netip.Prefix]Decision{}}
|
|
|
|
for i, decision := range answer {
|
|
if decision.Type != "ban" || (decision.Scope != "Ip" && decision.Scope != "Range") {
|
|
continue
|
|
}
|
|
|
|
netblock, ok := parseNetblock(decision.Value)
|
|
|
|
duration, err := time.ParseDuration(decision.Duration)
|
|
if !ok || err != nil {
|
|
return entries{}, fmt.Errorf("decision %d %w", i+1, errNotDecision)
|
|
}
|
|
|
|
expires := fetched.Add(duration)
|
|
if expires.After(found.decisions[netblock].Expires) {
|
|
found.decisions[netblock] = Decision{Expires: expires, Scenario: decision.Scenario}
|
|
}
|
|
|
|
if !slices.Contains(found.lengths, netblock.Bits()) {
|
|
found.lengths = append(found.lengths, netblock.Bits())
|
|
}
|
|
}
|
|
|
|
return found, nil
|
|
}
|
|
|
|
// withoutComment returns line without anything after a ; or a #, and
|
|
// without the spaces around what is left.
|
|
func withoutComment(line string) string {
|
|
text, _, _ := strings.Cut(line, ";")
|
|
text, _, _ = strings.Cut(text, "#")
|
|
|
|
return strings.TrimSpace(text)
|
|
}
|
|
|
|
// contain reports whether the netblocks of a blocklist's copy hold addr:
|
|
// whether addr, cut to one of their lengths, is one of them.
|
|
func (e entries) contain(addr netip.Addr) bool {
|
|
for _, length := range e.lengths {
|
|
netblock, err := addr.Prefix(length)
|
|
if err == nil && e.netblocks[netblock] {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|