check / check (push) Waiting to run
SWWAF_CROWDSEC_LAPI_URL and SWWAF_CROWDSEC_LAPI_KEY name an engine whose decision list, <url>/v1/decisions, is fetched every minute with the key in X-Api-Key, following no redirect, and kept as a blocklist is: used while a fetch fails, and across restarts through reputation.json. Ban decisions on an Ip or a Range end at the fetch time plus their duration. A listed client's request is refused and bans its netblock with the cause crowdsec until the decision ends; bans.json, ban notes and metrics take the cause. Judgement call: fetched every minute, not a setting. Judgement call: a crowdsec ban never lengthens a limit ban. Judgement call: a lifted crowdsec ban is remade while its decision lasts. Model: opus-5-5
653 lines
19 KiB
Go
653 lines
19 KiB
Go
// Package reputation fetches the lists the settings name by URL: the
|
|
// blocklists of SWWAF_BLOCKLIST_URLS, the file of AS:percent lines
|
|
// SWWAF_ASN_LIMIT_PERCENT_URL names, and the decision list of the CrowdSec
|
|
// engine SWWAF_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
|
|
// SWWAF_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 (SWWAF_BLOCKLIST_URLS), and
|
|
// ASNLimitPercentURL the file of AS:percent lines
|
|
// (SWWAF_ASN_LIMIT_PERCENT_URL), "" while it is unset.
|
|
BlocklistURLs []string
|
|
ASNLimitPercentURL string
|
|
// CrowdSecDecisionsURL is the CrowdSec decision list, "" while
|
|
// SWWAF_CROWDSEC_LAPI_URL is unset, fetched with CrowdSecKey
|
|
// (SWWAF_CROWDSEC_LAPI_KEY).
|
|
CrowdSecDecisionsURL string
|
|
CrowdSecKey string
|
|
// Refresh is how long after a list was last fetched or tried it is
|
|
// fetched again (SWWAF_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
|
|
// SWWAF_BLOCKLIST_URLS names them, then SWWAF_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 SWWAF_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 smallwebwaf 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 SWWAF_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
|
|
}
|