initial commit
This commit is contained in:
1 parent
5b935301e5
commit
85453852d5
105 files changed
+24846
No files matched your search
@@ -0,0 +1,647 @@
|
||||
package blocklist
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/zonefile"
|
||||
)
|
||||
|
||||
// Source is a compiled list plus the bookkeeping the UI shows.
|
||||
type Source struct {
|
||||
Name string
|
||||
Format Format
|
||||
Meta SourceMeta
|
||||
Stats ParseStats
|
||||
// Digest identifies the compiled content, so instances can tell whether
|
||||
// they hold the same rules without shipping the rules around.
|
||||
Digest string
|
||||
}
|
||||
|
||||
// State is the compiled blocklist plus its per-source detail.
|
||||
type State struct {
|
||||
Set *Set
|
||||
Sources []Source
|
||||
// Duration is how long the last full compile took.
|
||||
Duration time.Duration
|
||||
// Errors are per-source failures from the last refresh. A source that
|
||||
// fails keeps its previous content, so the errors are warnings, not
|
||||
// fatal.
|
||||
Errors map[string]string
|
||||
// Digest covers every source, in order.
|
||||
Digest string
|
||||
}
|
||||
|
||||
// Manager owns the blocklist lifecycle: fetching sources, compiling them, and
|
||||
// atomically swapping the compiled Set into the holder.
|
||||
type Manager struct {
|
||||
cfg *config.Store
|
||||
log *slog.Logger
|
||||
hold *Holder
|
||||
http *http.Client
|
||||
stats *StateHolder
|
||||
|
||||
mu sync.Mutex
|
||||
running map[string]bool // in-flight refreshes, by source name
|
||||
|
||||
// trigger lets the cluster ask one instance to refresh (leader election).
|
||||
trigger chan string
|
||||
stop chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// StateHolder is a swappable State for the API.
|
||||
type StateHolder struct{ cur atomic.Pointer[State] }
|
||||
|
||||
// NewStateHolder wraps an initial state.
|
||||
func NewStateHolder(s *State) *StateHolder {
|
||||
h := &StateHolder{}
|
||||
if s != nil {
|
||||
h.cur.Store(s)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// Get returns the current state.
|
||||
func (h *StateHolder) Get() *State { return h.cur.Load() }
|
||||
|
||||
// ManagerOptions configures a Manager.
|
||||
type ManagerOptions struct {
|
||||
// Config is the hot-reloadable config store.
|
||||
Config *config.Store
|
||||
// Logger is the structured logger.
|
||||
Logger *slog.Logger
|
||||
// Client is the HTTP client used for list fetches; nil builds a default.
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
// NewManager builds a Manager.
|
||||
func NewManager(opt ManagerOptions) *Manager {
|
||||
log := opt.Logger
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
client := opt.Client
|
||||
if client == nil {
|
||||
client = &http.Client{
|
||||
Timeout: 120 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 16,
|
||||
MaxIdleConnsPerHost: 4,
|
||||
IdleConnTimeout: 60 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
m := &Manager{
|
||||
cfg: opt.Config,
|
||||
log: log.With("component", "blocklist"),
|
||||
hold: NewHolder(nil),
|
||||
http: client,
|
||||
running: make(map[string]bool),
|
||||
trigger: make(chan string, 16),
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
m.stats = NewStateHolder(&State{Errors: map[string]string{}})
|
||||
return m
|
||||
}
|
||||
|
||||
// Holder returns the compiled-set holder the engine reads.
|
||||
func (m *Manager) Holder() *Holder { return m.hold }
|
||||
|
||||
// State returns the detailed state the API serves.
|
||||
func (m *Manager) State() *State { return m.stats.Get() }
|
||||
|
||||
// RefreshAll compiles every enabled source plus the local zones. The previous
|
||||
// set is kept if every source fails, so a bad deploy never leaves the
|
||||
// resolver with no blocklists at all.
|
||||
func (m *Manager) RefreshAll(ctx context.Context) (State, error) {
|
||||
start := time.Now()
|
||||
cfg := m.cfg.Get()
|
||||
b := NewBuilder()
|
||||
var (
|
||||
sources []Source
|
||||
errs = map[string]string{}
|
||||
)
|
||||
|
||||
// Inline block/allow entries first: they are cheap, and putting them
|
||||
// before the lists means a later list cannot override an operator's
|
||||
// inline decision without a priority bump.
|
||||
if len(cfg.Block.BlockEntries) > 0 {
|
||||
rules, st, err := parseInline(cfg.Block.BlockEntries, ActionBlock, cfg.Block.ResponseTTL)
|
||||
if err == nil {
|
||||
for _, r := range rules {
|
||||
b.Add(r, "inline:block", nil)
|
||||
}
|
||||
b.NoteSource(SourceMeta{
|
||||
Name: "inline:block", Format: string(FormatPlain), Rules: len(rules),
|
||||
Bytes: st.Bytes, UpdatedAt: start, Duration: time.Since(start), Enabled: true,
|
||||
})
|
||||
} else {
|
||||
errs["inline:block"] = err.Error()
|
||||
}
|
||||
}
|
||||
if len(cfg.Block.AllowEntries) > 0 {
|
||||
rules, st, err := parseInline(cfg.Block.AllowEntries, ActionAllow, cfg.Block.ResponseTTL)
|
||||
if err == nil {
|
||||
for _, r := range rules {
|
||||
b.Add(r, "inline:allow", nil)
|
||||
}
|
||||
b.NoteSource(SourceMeta{
|
||||
Name: "inline:allow", Format: string(FormatPlain), Rules: len(rules),
|
||||
Bytes: st.Bytes, UpdatedAt: start, Duration: time.Since(start), Enabled: true,
|
||||
})
|
||||
} else {
|
||||
errs["inline:allow"] = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
// Local RPZ-style zone files.
|
||||
for _, pattern := range cfg.Block.LocalZones {
|
||||
files, err := filepath.Glob(m.cfg.Get().AbsPath(pattern))
|
||||
if err != nil || len(files) == 0 {
|
||||
// A glob that matches nothing is a configuration smell, not a
|
||||
// reason to fail the whole compile.
|
||||
errs[pattern] = "no files matched"
|
||||
continue
|
||||
}
|
||||
sort.Strings(files)
|
||||
rules, st, err := parseRPZFiles(files)
|
||||
if err != nil {
|
||||
errs[pattern] = err.Error()
|
||||
continue
|
||||
}
|
||||
name := "local:" + pattern
|
||||
for _, r := range rules {
|
||||
b.Add(r, name, nil)
|
||||
}
|
||||
b.NoteSource(SourceMeta{
|
||||
Name: name, Format: string(FormatRPZ), Rules: len(rules), Bytes: st.Bytes,
|
||||
UpdatedAt: start, Duration: time.Since(start), Enabled: true, Path: pattern,
|
||||
})
|
||||
}
|
||||
|
||||
// Remote / on-disk lists.
|
||||
for _, l := range cfg.Block.Lists {
|
||||
if !l.Enabled {
|
||||
b.NoteSource(SourceMeta{
|
||||
Name: l.Name, Format: l.Format, Enabled: false, URL: l.URL,
|
||||
Path: cfg.ListPath(l),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if !m.claim(l.Name) {
|
||||
errs[l.Name] = "refresh already in flight"
|
||||
continue
|
||||
}
|
||||
src, err := m.loadOne(ctx, cfg, l)
|
||||
m.release(l.Name)
|
||||
if err != nil {
|
||||
errs[l.Name] = err.Error()
|
||||
// Keep the previous content: record what we know about the
|
||||
// source so the UI can show it as stale rather than missing.
|
||||
if prev, ok := m.previous(l.Name); ok {
|
||||
src = rawSource{
|
||||
Name: prev.Name, Format: prev.Format, Meta: prev.Meta,
|
||||
Stats: prev.Stats, Digest: prev.Digest,
|
||||
}
|
||||
src.Meta.Err = err.Error()
|
||||
src.Meta.Stale = true
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
for _, r := range src.rules {
|
||||
b.Add(r, l.Name, l.Categories)
|
||||
}
|
||||
sources = append(sources, src.toSource())
|
||||
}
|
||||
|
||||
// Carry forward bookkeeping for sources that contributed nothing this
|
||||
// round (disabled, or skipped because a refresh was already in flight), so
|
||||
// the UI keeps showing them. Only the *metadata* is carried: their rules
|
||||
// are deliberately not re-added, because a disabled source must stop
|
||||
// matching, and a source that failed its refresh must fall back to the
|
||||
// copy the manager kept for it (above), not to a silent re-add.
|
||||
for _, prev := range m.previousAll() {
|
||||
if _, ok := b.metaFor(prev.Name); ok {
|
||||
continue
|
||||
}
|
||||
carried := prev
|
||||
carried.Meta.Rules = 0
|
||||
sources = append(sources, carried)
|
||||
}
|
||||
|
||||
set := b.Build()
|
||||
state := State{
|
||||
Set: set,
|
||||
Sources: sources,
|
||||
Duration: time.Since(start),
|
||||
Errors: errs,
|
||||
Digest: set.Digest(),
|
||||
}
|
||||
m.hold.Set(set)
|
||||
m.stats.cur.Store(&state)
|
||||
m.log.Info("blocklist compiled",
|
||||
"rules", set.Len(), "sources", len(sources), "errors", len(errs),
|
||||
"duration", state.Duration)
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (m *Manager) previous(name string) (Source, bool) {
|
||||
st := m.stats.Get()
|
||||
if st == nil {
|
||||
return Source{}, false
|
||||
}
|
||||
for _, s := range st.Sources {
|
||||
if s.Name == name {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
return Source{}, false
|
||||
}
|
||||
|
||||
func (m *Manager) previousAll() []Source {
|
||||
st := m.stats.Get()
|
||||
if st == nil {
|
||||
return nil
|
||||
}
|
||||
return st.Sources
|
||||
}
|
||||
|
||||
func (m *Manager) claim(name string) bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.running[name] {
|
||||
return false
|
||||
}
|
||||
m.running[name] = true
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *Manager) release(name string) {
|
||||
m.mu.Lock()
|
||||
delete(m.running, name)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// loadOne fetches and compiles a single list source.
|
||||
func (m *Manager) loadOne(ctx context.Context, cfg *config.Config, l config.ListSource) (rawSource, error) {
|
||||
start := time.Now()
|
||||
path := cfg.ListPath(l)
|
||||
|
||||
var (
|
||||
data []byte
|
||||
etag = l.ETag
|
||||
lastMod = l.LastModified
|
||||
httpStatus int
|
||||
)
|
||||
|
||||
needFetch := true
|
||||
if path != "" {
|
||||
// Prefer the local copy: it is what a restart without network sees.
|
||||
if fi, err := os.Stat(path); err == nil {
|
||||
if fi.Size() < l.MaxBytes {
|
||||
data, _ = os.ReadFile(path)
|
||||
needFetch = false
|
||||
}
|
||||
}
|
||||
}
|
||||
if l.URL != "" && needFetch {
|
||||
fetched, status, newETag, newLM, err := m.fetch(ctx, l)
|
||||
if err != nil {
|
||||
if len(data) == 0 {
|
||||
return rawSource{}, fmt.Errorf("fetch: %w", err)
|
||||
}
|
||||
// Keep the local copy and report the failure; a stale list beats
|
||||
// no list.
|
||||
httpStatus = status
|
||||
} else {
|
||||
data = fetched
|
||||
httpStatus = status
|
||||
if newETag != "" {
|
||||
etag = newETag
|
||||
}
|
||||
if newLM != "" {
|
||||
lastMod = newLM
|
||||
}
|
||||
if path != "" && !l.InsecureSkipTLSVerify {
|
||||
// Persist for offline restarts. A write failure is not fatal.
|
||||
_ = config.WriteFileAtomic(path, data, 0o644)
|
||||
}
|
||||
}
|
||||
} else if l.URL != "" && !needFetch {
|
||||
// We have a local copy; try a conditional refresh in the background
|
||||
// anyway, but do not fail when it 304s.
|
||||
httpStatus = http.StatusOK
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return rawSource{}, errors.New("no data available")
|
||||
}
|
||||
|
||||
format := Format(l.Format)
|
||||
opt := ParseOptions{
|
||||
Subdomains: true,
|
||||
DefaultTTL: uint32(cfg.Block.ResponseTTL.D()),
|
||||
Categories: l.Categories,
|
||||
}
|
||||
rules, st, perr := Parse(format, data, opt)
|
||||
if perr != nil {
|
||||
return rawSource{}, perr
|
||||
}
|
||||
meta := SourceMeta{
|
||||
Name: l.Name,
|
||||
Format: string(st.Format),
|
||||
Rules: len(rules),
|
||||
Bytes: int64(len(data)),
|
||||
ETag: etag,
|
||||
LastMod: lastMod,
|
||||
UpdatedAt: start,
|
||||
Duration: time.Since(start),
|
||||
Enabled: true,
|
||||
URL: l.URL,
|
||||
Path: path,
|
||||
HTTPStatus: httpStatus,
|
||||
}
|
||||
return rawSource{
|
||||
Name: l.Name,
|
||||
Format: st.Format,
|
||||
Meta: meta,
|
||||
Stats: st,
|
||||
Digest: digestOf(data),
|
||||
rules: rules,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// rawSource is the internal shape before it becomes a Source.
|
||||
type rawSource struct {
|
||||
Name string
|
||||
Format Format
|
||||
Meta SourceMeta
|
||||
Stats ParseStats
|
||||
Digest string
|
||||
rules []Rule
|
||||
}
|
||||
|
||||
func (r rawSource) toSource() Source {
|
||||
return Source{Name: r.Name, Format: r.Format, Meta: r.Meta, Stats: r.Stats, Digest: r.Digest}
|
||||
}
|
||||
|
||||
// fetch performs a conditional GET against a list URL, following mirrors.
|
||||
func (m *Manager) fetch(ctx context.Context, l config.ListSource) (data []byte, status int, etag, lastMod string, err error) {
|
||||
urls := append([]string{l.URL}, l.Mirror...)
|
||||
var lastErr error
|
||||
for _, u := range urls {
|
||||
if u == "" {
|
||||
continue
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
req.Header.Set("User-Agent", "fancy-dns/1.0 (+blocklist)")
|
||||
if l.ETag != "" {
|
||||
req.Header.Set("If-None-Match", l.ETag)
|
||||
}
|
||||
if l.LastModified != "" {
|
||||
req.Header.Set("If-Modified-Since", l.LastModified)
|
||||
}
|
||||
client := m.http
|
||||
if l.InsecureSkipTLSVerify {
|
||||
// Only for self-hosted mirrors with a private CA; the config
|
||||
// field name says what it does.
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
tr.TLSClientConfig = insecureTLS()
|
||||
client = &http.Client{Transport: tr, Timeout: client.Timeout}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
status = resp.StatusCode
|
||||
switch resp.StatusCode {
|
||||
case http.StatusNotModified:
|
||||
resp.Body.Close()
|
||||
return nil, status, l.ETag, l.LastModified, nil
|
||||
case http.StatusOK:
|
||||
etag = resp.Header.Get("ETag")
|
||||
lastMod = resp.Header.Get("Last-Modified")
|
||||
body, rerr := readLimited(resp.Body, l.MaxBytes)
|
||||
resp.Body.Close()
|
||||
if rerr != nil {
|
||||
lastErr = rerr
|
||||
continue
|
||||
}
|
||||
return body, status, etag, lastMod, nil
|
||||
default:
|
||||
io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("%s: unexpected status %s", u, resp.Status)
|
||||
}
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("no usable source")
|
||||
}
|
||||
return nil, 0, "", "", lastErr
|
||||
}
|
||||
|
||||
func readLimited(r io.Reader, max int64) ([]byte, error) {
|
||||
if max <= 0 {
|
||||
max = 512 << 20
|
||||
}
|
||||
buf, err := io.ReadAll(io.LimitReader(r, max+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(buf)) > max {
|
||||
return nil, fmt.Errorf("list exceeds %d bytes", max)
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// Start runs the scheduled refresh loop until ctx is cancelled.
|
||||
func (m *Manager) Start(ctx context.Context) {
|
||||
m.wg.Add(1)
|
||||
go func() {
|
||||
defer m.wg.Done()
|
||||
// Stagger the first pass across sources so a cold cluster does not
|
||||
// fetch 20 lists at the same instant.
|
||||
for _, l := range m.cfg.Get().Block.Lists {
|
||||
if !l.Enabled || l.Refresh <= 0 {
|
||||
continue
|
||||
}
|
||||
jitter := l.Refresh.D() * time.Duration(fraction(l.Name)) / 8
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(jitter):
|
||||
}
|
||||
if m.claim(l.Name) {
|
||||
if _, err := m.RefreshAll(ctx); err != nil {
|
||||
m.log.Warn("initial blocklist refresh failed", "error", err)
|
||||
}
|
||||
m.release(l.Name)
|
||||
}
|
||||
// Only stagger the first pass; after that the loop is regular.
|
||||
break
|
||||
}
|
||||
if _, err := m.RefreshAll(ctx); err != nil {
|
||||
m.log.Warn("initial blocklist refresh failed", "error", err)
|
||||
}
|
||||
m.loop(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
func (m *Manager) loop(ctx context.Context) {
|
||||
// A 5-minute tick is coarse enough to be free and fine enough that a
|
||||
// source's own refresh interval is honoured within 5 minutes.
|
||||
t := time.NewTicker(5 * time.Minute)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case name := <-m.trigger:
|
||||
// A cluster peer asked for a refresh; it is the leader's job.
|
||||
if name != "" && !m.claim(name) {
|
||||
continue
|
||||
}
|
||||
if _, err := m.RefreshAll(ctx); err != nil {
|
||||
m.log.Warn("triggered refresh failed", "source", name, "error", err)
|
||||
}
|
||||
if name != "" {
|
||||
m.release(name)
|
||||
}
|
||||
case <-t.C:
|
||||
m.refreshDue(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// refreshDue refreshes the sources whose interval has elapsed.
|
||||
func (m *Manager) refreshDue(ctx context.Context) {
|
||||
cfg := m.cfg.Get()
|
||||
now := time.Now()
|
||||
var due bool
|
||||
for _, l := range cfg.Block.Lists {
|
||||
if !l.Enabled || l.Refresh <= 0 {
|
||||
continue
|
||||
}
|
||||
src, ok := m.previous(l.Name)
|
||||
if !ok || src.Meta.UpdatedAt.IsZero() {
|
||||
due = true
|
||||
break
|
||||
}
|
||||
if now.Sub(src.Meta.UpdatedAt) >= l.Refresh.D() {
|
||||
due = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !due {
|
||||
return
|
||||
}
|
||||
// Refreshing every due source in one pass compiles once, which is far
|
||||
// cheaper than one compile per source.
|
||||
if _, err := m.RefreshAll(ctx); err != nil {
|
||||
m.log.Warn("scheduled refresh failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshOne refreshes a single named source immediately.
|
||||
func (m *Manager) RefreshOne(ctx context.Context, name string) (State, error) {
|
||||
if !m.claim(name) {
|
||||
return *m.stats.Get(), fmt.Errorf("refresh of %q already in flight", name)
|
||||
}
|
||||
defer m.release(name)
|
||||
return m.RefreshAll(ctx)
|
||||
}
|
||||
|
||||
// Trigger asks the refresh loop to compile now. Safe to call from an API
|
||||
// handler; it does not block.
|
||||
func (m *Manager) Trigger(name string) {
|
||||
select {
|
||||
case m.trigger <- name:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Stop shuts the loop down.
|
||||
func (m *Manager) Stop() {
|
||||
select {
|
||||
case <-m.stop:
|
||||
default:
|
||||
close(m.stop)
|
||||
}
|
||||
m.wg.Wait()
|
||||
}
|
||||
|
||||
// parseInline compiles the config's inline entry lists. The plain-domain
|
||||
// parser yields block rules, so the requested action is stamped on afterwards:
|
||||
// an operator's allowlist must not be compiled as a blocklist, which is the
|
||||
// kind of bug that silently inverts a policy.
|
||||
func parseInline(entries []string, action Action, ttl config.Duration) ([]Rule, ParseStats, error) {
|
||||
var sb strings.Builder
|
||||
for _, e := range entries {
|
||||
sb.WriteString(e)
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
opt := ParseOptions{Subdomains: true, DefaultTTL: uint32(ttl.D())}
|
||||
rules, st, err := Parse(FormatPlain, []byte(sb.String()), opt)
|
||||
if err != nil {
|
||||
return nil, st, err
|
||||
}
|
||||
for i := range rules {
|
||||
if rules[i].Action == ActionBlock {
|
||||
rules[i].Action = action
|
||||
}
|
||||
}
|
||||
return rules, st, nil
|
||||
}
|
||||
|
||||
func parseRPZFiles(files []string) ([]Rule, ParseStats, error) {
|
||||
var sb strings.Builder
|
||||
for _, f := range files {
|
||||
data, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
return nil, ParseStats{}, err
|
||||
}
|
||||
// Prefix each file with its own $ORIGIN so a multi-file policy zone
|
||||
// keeps the per-file origins from its file name.
|
||||
fmt.Fprintf(&sb, "$ORIGIN %s\n", zonefile.OriginFromPath(f))
|
||||
sb.Write(data)
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
opt := ParseOptions{Subdomains: true, DefaultTTL: 60}
|
||||
return Parse(FormatRPZ, []byte(sb.String()), opt)
|
||||
}
|
||||
|
||||
func digestOf(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
// fraction maps a name to a stable 0..1 value, for refresh jitter.
|
||||
func fraction(name string) int {
|
||||
sum := sha256.Sum256([]byte(name))
|
||||
return int(sum[0]) // 0..255
|
||||
}
|
||||
|
||||
// insecureTLS is the transport tweak for sources that need it. It is only
|
||||
// reachable when a list explicitly sets insecure_skip_tls_verify.
|
||||
func insecureTLS() *tls.Config { return &tls.Config{InsecureSkipVerify: true} }
|
||||
@@ -0,0 +1,535 @@
|
||||
package blocklist
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"git.k8s-git-k6n.net/mats/fancy-dns/internal/zonefile"
|
||||
)
|
||||
|
||||
// Format identifies a list's syntax.
|
||||
type Format string
|
||||
|
||||
const (
|
||||
// FormatAuto sniffs the content.
|
||||
FormatAuto Format = "auto"
|
||||
// FormatHosts is "IP domain [domain...]" (also accepts "0 domain").
|
||||
FormatHosts Format = "hosts"
|
||||
// FormatAdblock is the Adblock Plus / EasyList syntax.
|
||||
FormatAdblock Format = "adblock"
|
||||
// FormatPlain is one domain per line.
|
||||
FormatPlain Format = "plain"
|
||||
// FormatRPZ is an RFC 1035 response policy zone.
|
||||
FormatRPZ Format = "rpz"
|
||||
)
|
||||
|
||||
// ParseOptions tunes the parsers.
|
||||
type ParseOptions struct {
|
||||
// Subdomains makes a bare domain also cover its subdomains. Defaults to
|
||||
// true; hosts files rely on it, and precise lists can opt out.
|
||||
Subdomains bool
|
||||
// DefaultTTL is applied to rules that carry none.
|
||||
DefaultTTL uint32
|
||||
// Categories are attached to every rule from this source.
|
||||
Categories []string
|
||||
}
|
||||
|
||||
// ParseStats reports what a parser saw, for the UI and for tests.
|
||||
type ParseStats struct {
|
||||
Rules int `json:"rules"`
|
||||
Comments int `json:"comments"`
|
||||
Skipped int `json:"skipped"`
|
||||
Malformed int `json:"malformed"`
|
||||
Duplicates int `json:"duplicates"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
DurationMS float64 `json:"duration_ms"`
|
||||
Format Format `json:"format"`
|
||||
Sample []string `json:"sample,omitempty"`
|
||||
Notices []string `json:"notices,omitempty"`
|
||||
}
|
||||
|
||||
// DetectFormat sniffs a list's syntax from its first meaningful lines.
|
||||
func DetectFormat(data []byte) Format {
|
||||
sc := bufio.NewScanner(bytes.NewReader(data))
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
sawRecord := false
|
||||
for i := 0; sc.Scan() && i < 200; i++ {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "[") {
|
||||
// "[Adblock Plus 2.0]" is a header, keep looking.
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "$TTL") || strings.HasPrefix(line, "$ORIGIN") ||
|
||||
strings.HasPrefix(line, "$INCLUDE") {
|
||||
return FormatRPZ
|
||||
}
|
||||
if sawRecord {
|
||||
return FormatAdblock
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 && isAddr(fields[0]) {
|
||||
return FormatHosts
|
||||
}
|
||||
if strings.HasPrefix(line, "||") || strings.HasPrefix(line, "|") ||
|
||||
strings.HasPrefix(line, "@@") || strings.Contains(line, "##") ||
|
||||
strings.Contains(line, "#@#") || strings.Contains(line, "#?#") {
|
||||
return FormatAdblock
|
||||
}
|
||||
if len(fields) >= 3 && len(fields[1]) > 1 && allDigitsOrName(fields[1]) {
|
||||
// "name 60 IN A 1.2.3.4" style.
|
||||
return FormatRPZ
|
||||
}
|
||||
sawRecord = true
|
||||
}
|
||||
return FormatPlain
|
||||
}
|
||||
|
||||
func allDigitsOrName(s string) bool {
|
||||
if _, err := strconv.Atoi(s); err == nil {
|
||||
return true
|
||||
}
|
||||
switch strings.ToUpper(s) {
|
||||
case "IN", "CH", "HS":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Parse converts list data into rules using the given format (FormatAuto
|
||||
// sniffs). It never returns partial garbage: on a hard error the rules
|
||||
// accumulated so far are returned alongside it so the caller can decide
|
||||
// whether to keep the last good set or adopt the partial one.
|
||||
func Parse(format Format, data []byte, opt ParseOptions) ([]Rule, ParseStats, error) {
|
||||
if opt.DefaultTTL == 0 {
|
||||
opt.DefaultTTL = 60
|
||||
}
|
||||
if format == "" || format == FormatAuto {
|
||||
format = DetectFormat(data)
|
||||
}
|
||||
stats := ParseStats{Bytes: int64(len(data)), Format: format}
|
||||
var (
|
||||
rules []Rule
|
||||
err error
|
||||
)
|
||||
switch format {
|
||||
case FormatHosts:
|
||||
rules, stats, err = parseHosts(data, opt)
|
||||
case FormatAdblock:
|
||||
rules, stats, err = parseAdblock(data, opt)
|
||||
case FormatPlain:
|
||||
rules, stats, err = parsePlain(data, opt)
|
||||
case FormatRPZ:
|
||||
rules, stats, err = parseRPZ(data, opt)
|
||||
default:
|
||||
return nil, stats, fmt.Errorf("blocklist: unknown format %q", format)
|
||||
}
|
||||
return rules, stats, err
|
||||
}
|
||||
|
||||
func parseHosts(data []byte, opt ParseOptions) ([]Rule, ParseStats, error) {
|
||||
var rules []Rule
|
||||
stats := ParseStats{Bytes: int64(len(data)), Format: FormatHosts}
|
||||
sc := newLineScanner(data)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if trimmed[0] == '#' || trimmed[0] == ';' {
|
||||
stats.Comments++
|
||||
continue
|
||||
}
|
||||
// Strip inline comments.
|
||||
if i := strings.IndexByte(trimmed, '#'); i >= 0 {
|
||||
trimmed = strings.TrimSpace(trimmed[:i])
|
||||
if trimmed == "" {
|
||||
stats.Comments++
|
||||
continue
|
||||
}
|
||||
}
|
||||
fields := strings.Fields(trimmed)
|
||||
if len(fields) < 2 {
|
||||
stats.Malformed++
|
||||
continue
|
||||
}
|
||||
addr := fields[0]
|
||||
if !isAddr(addr) && !strings.EqualFold(addr, "localhost") {
|
||||
// Not an address: this is not a hosts file after all, so treat
|
||||
// the whole line as a domain list.
|
||||
stats.Notices = appendNotice(stats.Notices, "no address in first field, falling back to a plain domain list")
|
||||
return parsePlain(data, opt)
|
||||
}
|
||||
names := fields[1:]
|
||||
if isLoopbackName(names) {
|
||||
// "127.0.0.1 localhost" style bootstrap lines: never block these.
|
||||
stats.Skipped++
|
||||
continue
|
||||
}
|
||||
for _, n := range names {
|
||||
if !validDomain(n) {
|
||||
stats.Malformed++
|
||||
continue
|
||||
}
|
||||
rules = append(rules, hostsRule(n, opt))
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return rules, stats, err
|
||||
}
|
||||
stats.Rules = len(rules)
|
||||
return rules, stats, nil
|
||||
}
|
||||
|
||||
func isLoopbackName(names []string) bool {
|
||||
for _, n := range names {
|
||||
switch strings.ToLower(strings.TrimSuffix(n, ".")) {
|
||||
case "localhost", "localhost.localdomain", "broadcasthost", "ip6-localhost", "ip6-loopback":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hostsRule builds a rule for a hosts-file or plain-list entry.
|
||||
//
|
||||
// A hosts file says "this name is bad" and everyone means the name *and*
|
||||
// anything under it, so the pattern is "**." (apex included) rather than the
|
||||
// strict-subdomain "*.". Getting this wrong is how a filter ends up blocking
|
||||
// ads.example.com but happily resolving 1.ads.example.com.
|
||||
func hostsRule(name string, opt ParseOptions) Rule {
|
||||
pattern := name
|
||||
if opt.Subdomains {
|
||||
pattern = "**." + name
|
||||
}
|
||||
return Rule{Name: pattern, Action: ActionBlock, TTL: opt.DefaultTTL}
|
||||
}
|
||||
|
||||
func parseAdblock(data []byte, opt ParseOptions) ([]Rule, ParseStats, error) {
|
||||
var rules []Rule
|
||||
stats := ParseStats{Bytes: int64(len(data)), Format: FormatAdblock}
|
||||
sc := newLineScanner(data)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if line[0] == '!' || line[0] == '#' {
|
||||
stats.Comments++
|
||||
continue
|
||||
}
|
||||
if line[0] == '[' {
|
||||
stats.Comments++
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "||") {
|
||||
host := hostOfAdblock(line[2:])
|
||||
if !validDomain(host) {
|
||||
stats.Skipped++
|
||||
continue
|
||||
}
|
||||
rules = append(rules, Rule{Name: "**." + host, Action: ActionBlock, TTL: opt.DefaultTTL})
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "@@") {
|
||||
rest := strings.TrimPrefix(line, "@@")
|
||||
host := hostOfAdblock(strings.TrimPrefix(rest, "||"))
|
||||
if !validDomain(host) {
|
||||
stats.Skipped++
|
||||
continue
|
||||
}
|
||||
rules = append(rules, Rule{Name: "**." + host, Action: ActionAllow, Priority: 10, TTL: opt.DefaultTTL})
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "|") {
|
||||
rest := strings.TrimPrefix(line, "|")
|
||||
if strings.HasPrefix(rest, "http://") || strings.HasPrefix(rest, "https://") {
|
||||
host := hostOfURL(rest)
|
||||
if !validDomain(host) {
|
||||
stats.Skipped++
|
||||
continue
|
||||
}
|
||||
// A path rule cannot be expressed in DNS; blocking the host
|
||||
// is the closest faithful approximation, so record it once.
|
||||
rules = append(rules, Rule{Name: "**." + host, Action: ActionBlock, TTL: opt.DefaultTTL})
|
||||
continue
|
||||
}
|
||||
stats.Skipped++
|
||||
continue
|
||||
}
|
||||
if line[0] == '/' && strings.HasSuffix(line, "/") {
|
||||
stats.Skipped++ // regular expressions are not DNS-expressible
|
||||
if len(stats.Notices) < 5 {
|
||||
stats.Notices = appendNotice(stats.Notices, "skipped regular-expression rules")
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Element-hiding rules ("##", "#@#", "#?#", "##+js(") carry no DNS
|
||||
// meaning at all.
|
||||
if strings.Contains(line, "##") || strings.Contains(line, "#@#") || strings.Contains(line, "#?#") {
|
||||
stats.Skipped++
|
||||
continue
|
||||
}
|
||||
// A bare hostname with no adblock syntax at all.
|
||||
if validDomain(line) {
|
||||
rules = append(rules, hostsRule(line, opt))
|
||||
continue
|
||||
}
|
||||
stats.Skipped++
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return rules, stats, err
|
||||
}
|
||||
stats.Rules = len(rules)
|
||||
return rules, stats, nil
|
||||
}
|
||||
|
||||
// hostOfAdblock extracts the domain from the part of an adblock rule between
|
||||
// "||" (or a scheme) and the first separator (^, /, ?, |, $).
|
||||
func hostOfAdblock(s string) string {
|
||||
if i := strings.IndexAny(s, "^/?|$"); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
return strings.Trim(s, ".")
|
||||
}
|
||||
|
||||
func hostOfURL(s string) string {
|
||||
if i := strings.Index(s, "://"); i >= 0 {
|
||||
s = s[i+3:]
|
||||
}
|
||||
if i := strings.IndexAny(s, "/?#"); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
if i := strings.LastIndexByte(s, '@'); i >= 0 {
|
||||
s = s[i+1:]
|
||||
}
|
||||
// Strip credentials and port.
|
||||
if i := strings.LastIndexByte(s, ':'); i >= 0 {
|
||||
if _, err := strconv.Atoi(s[i+1:]); err == nil {
|
||||
s = s[:i]
|
||||
}
|
||||
}
|
||||
return strings.Trim(s, ".")
|
||||
}
|
||||
|
||||
func parsePlain(data []byte, opt ParseOptions) ([]Rule, ParseStats, error) {
|
||||
var rules []Rule
|
||||
stats := ParseStats{Bytes: int64(len(data)), Format: FormatPlain}
|
||||
sc := newLineScanner(data)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if line[0] == '#' || line[0] == ';' {
|
||||
stats.Comments++
|
||||
continue
|
||||
}
|
||||
// Accept "0.0.0.0 domain" and bare "domain" alike so operators do
|
||||
// not have to normalise their sources by hand.
|
||||
if fields := strings.Fields(line); len(fields) > 1 && isAddr(fields[0]) {
|
||||
for _, n := range fields[1:] {
|
||||
if validDomain(n) {
|
||||
rules = append(rules, hostsRule(n, opt))
|
||||
} else {
|
||||
stats.Malformed++
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Support inline allow markers: "!allow example.com".
|
||||
allow := false
|
||||
if strings.HasPrefix(line, "!") {
|
||||
allow = true
|
||||
line = strings.TrimSpace(line[1:])
|
||||
}
|
||||
line = strings.TrimPrefix(line, "||")
|
||||
if !validDomain(line) {
|
||||
stats.Malformed++
|
||||
continue
|
||||
}
|
||||
pattern := line
|
||||
if opt.Subdomains && !strings.HasPrefix(line, "*") {
|
||||
pattern = "**." + line
|
||||
}
|
||||
action := ActionBlock
|
||||
if allow {
|
||||
action = ActionAllow
|
||||
}
|
||||
rules = append(rules, Rule{Name: pattern, Action: action, TTL: opt.DefaultTTL})
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return rules, stats, err
|
||||
}
|
||||
stats.Rules = len(rules)
|
||||
return rules, stats, nil
|
||||
}
|
||||
|
||||
func parseRPZ(data []byte, opt ParseOptions) ([]Rule, ParseStats, error) {
|
||||
var rules []Rule
|
||||
stats := ParseStats{Bytes: int64(len(data)), Format: FormatRPZ}
|
||||
z, err := zonefile.Parse(bytes.NewReader(data), zonefile.Options{DefaultTTL: opt.DefaultTTL})
|
||||
if err != nil {
|
||||
return nil, stats, err
|
||||
}
|
||||
seen := make(map[string]bool, len(z.Records))
|
||||
for _, rec := range z.Records {
|
||||
ttl := rec.TTL
|
||||
if ttl == 0 && opt.DefaultTTL == 0 {
|
||||
ttl = opt.DefaultTTL
|
||||
}
|
||||
rule, ok := rpzRule(rec, ttl, opt)
|
||||
if !ok {
|
||||
stats.Skipped++
|
||||
continue
|
||||
}
|
||||
if seen[rule.Name] {
|
||||
stats.Duplicates++
|
||||
continue
|
||||
}
|
||||
seen[rule.Name] = true
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
stats.Rules = len(rules)
|
||||
return rules, stats, nil
|
||||
}
|
||||
|
||||
// rpzRule maps one policy-zone record onto a rule (RFC draft-ietf-dnsop-rpz).
|
||||
// RPZ encodes the response in the rdata: a CNAME to "." means NXDOMAIN, to
|
||||
// "rpz-drop." means drop, to "rpz-passthru." means resolve normally, and
|
||||
// A/AAAA records return that address verbatim.
|
||||
func rpzRule(rec zonefile.Record, ttl uint32, opt ParseOptions) (Rule, bool) {
|
||||
pattern := rec.Name
|
||||
if !strings.HasPrefix(pattern, "*") && opt.Subdomains {
|
||||
pattern = "*." + pattern
|
||||
}
|
||||
target, action, resp := rpzTarget(rec.RR)
|
||||
if target == "" {
|
||||
return Rule{}, false
|
||||
}
|
||||
return Rule{Name: pattern, Action: action, Response: resp, TTL: ttl}, true
|
||||
}
|
||||
|
||||
// rpzTarget interprets a policy record's rdata. The returned target is only a
|
||||
// "is this actionable" flag; action and resp carry the meaning.
|
||||
func rpzTarget(rr dns.RR) (target string, action Action, resp string) {
|
||||
switch v := rr.(type) {
|
||||
case *dns.CNAME:
|
||||
t := v.Target
|
||||
switch {
|
||||
case t == ".":
|
||||
// The classic RPZ "this name does not exist" trigger.
|
||||
return ".", ActionNXDomain, ""
|
||||
case strings.EqualFold(t, "rpz-drop."):
|
||||
return t, ActionDrop, ""
|
||||
case strings.EqualFold(t, "rpz-passthru."), strings.EqualFold(t, "rpz-passthru."):
|
||||
return t, ActionPassthrough, ""
|
||||
case strings.EqualFold(t, "rpz-tcp-only."):
|
||||
// Force TCP upstream; as a DNS policy this is a no-op for us
|
||||
// since we already retry over TCP on truncation.
|
||||
return t, ActionPassthrough, ""
|
||||
default:
|
||||
return t, ActionCNAME, t
|
||||
}
|
||||
case *dns.A:
|
||||
if v.A == nil {
|
||||
return "", ActionBlock, ""
|
||||
}
|
||||
return v.A.String(), ActionRedirect, v.A.String()
|
||||
case *dns.AAAA:
|
||||
if v.AAAA == nil {
|
||||
return "", ActionBlock, ""
|
||||
}
|
||||
return v.AAAA.String(), ActionRedirect, v.AAAA.String()
|
||||
case *dns.TXT:
|
||||
// Some providers publish policy in TXT records; the payload is the
|
||||
// action name.
|
||||
t := strings.ToLower(strings.Join(v.Txt, ""))
|
||||
switch {
|
||||
case strings.Contains(t, "rpz-drop"):
|
||||
return t, ActionDrop, ""
|
||||
case strings.Contains(t, "rpz-passthru"):
|
||||
return t, ActionPassthrough, ""
|
||||
case strings.Contains(t, "allow"), strings.Contains(t, "whitelist"):
|
||||
return t, ActionAllow, ""
|
||||
default:
|
||||
return "", ActionBlock, ""
|
||||
}
|
||||
default:
|
||||
return "", ActionBlock, ""
|
||||
}
|
||||
}
|
||||
|
||||
// isAddr reports whether s parses as an IP address literal.
|
||||
func isAddr(s string) bool {
|
||||
_, err := netip.ParseAddr(s)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// newLineScanner returns a line scanner sized for the very large list files
|
||||
// real blocklists ship (multi-hundred-megabyte entries are not unheard of).
|
||||
func newLineScanner(data []byte) *bufio.Scanner {
|
||||
sc := bufio.NewScanner(bytes.NewReader(data))
|
||||
sc.Buffer(make([]byte, 0, 128*1024), 4*1024*1024)
|
||||
return sc
|
||||
}
|
||||
|
||||
func appendNotice(notices []string, n string) []string {
|
||||
for _, existing := range notices {
|
||||
if existing == n {
|
||||
return notices
|
||||
}
|
||||
}
|
||||
if len(notices) >= 8 {
|
||||
return notices
|
||||
}
|
||||
return append(notices, n)
|
||||
}
|
||||
|
||||
// validDomain is a cheap sanity check: no scheme, no path, printable, and at
|
||||
// least one dot or a known single-label TLD-ish name.
|
||||
func validDomain(s string) bool {
|
||||
if s == "" || len(s) > 253 {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(s, ".") || strings.HasPrefix(s, "-") {
|
||||
return false
|
||||
}
|
||||
if strings.ContainsAny(s, "/ \t\\\"'<>|?*=") {
|
||||
return false
|
||||
}
|
||||
labels := strings.Split(strings.TrimSuffix(s, "."), ".")
|
||||
if len(labels) < 1 {
|
||||
return false
|
||||
}
|
||||
for _, l := range labels {
|
||||
if l == "" || len(l) > 63 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(l); i++ {
|
||||
c := l[i]
|
||||
ok := c == '-' || c == '_' || c == '*' ||
|
||||
(c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
||||
(c >= '0' && c <= '9')
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if l[0] == '-' || l[len(l)-1] == '-' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// A bare label with no dot is only meaningful for the localhost-ish
|
||||
// names, which callers already filter; reject to avoid blocking a
|
||||
// single-label intranet name by accident.
|
||||
return strings.Contains(s, ".")
|
||||
}
|
||||
@@ -0,0 +1,737 @@
|
||||
// Package blocklist compiles heterogeneous block/allow sources (hosts files,
|
||||
// adblock lists, plain domain lists and RPZ policy zones) into a single
|
||||
// immutable Set that answers "what should I do with this name?" in a handful
|
||||
// of map lookups.
|
||||
//
|
||||
// A Set is never mutated after construction; Manager swaps a whole new Set in
|
||||
// atomically when lists are refreshed, so a query either sees the old rules or
|
||||
// the new ones, never a half-applied mixture.
|
||||
package blocklist
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Action is what to do with a matched name.
|
||||
type Action uint8
|
||||
|
||||
const (
|
||||
// ActionBlock uses the manager's default response (nxdomain/zero/...).
|
||||
ActionBlock Action = iota
|
||||
// ActionAllow whitelists the name: blocklists are skipped entirely.
|
||||
ActionAllow
|
||||
// ActionPassthrough resolves the name upstream but ignores blocklists
|
||||
// (RPZ rpz-passthru).
|
||||
ActionPassthrough
|
||||
// ActionDrop answers nothing at all (RPZ rpz-drop).
|
||||
ActionDrop
|
||||
// ActionNXDomain answers NXDOMAIN regardless of the default response.
|
||||
ActionNXDomain
|
||||
// ActionZero answers the configured sinkhole address.
|
||||
ActionZero
|
||||
// ActionCNAME answers a CNAME to Response.
|
||||
ActionCNAME
|
||||
// ActionRedirect answers A/AAAA for Response as an address literal.
|
||||
ActionRedirect
|
||||
)
|
||||
|
||||
func (a Action) String() string {
|
||||
switch a {
|
||||
case ActionBlock:
|
||||
return "block"
|
||||
case ActionAllow:
|
||||
return "allow"
|
||||
case ActionPassthrough:
|
||||
return "passthrough"
|
||||
case ActionDrop:
|
||||
return "drop"
|
||||
case ActionNXDomain:
|
||||
return "nxdomain"
|
||||
case ActionZero:
|
||||
return "zero"
|
||||
case ActionCNAME:
|
||||
return "cname"
|
||||
case ActionRedirect:
|
||||
return "redirect"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// ParseAction maps configuration and UI strings onto an Action.
|
||||
func ParseAction(s string) (Action, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "", "block":
|
||||
return ActionBlock, true
|
||||
case "allow", "whitelist":
|
||||
return ActionAllow, true
|
||||
case "passthrough", "pass":
|
||||
return ActionPassthrough, true
|
||||
case "drop":
|
||||
return ActionDrop, true
|
||||
case "nxdomain":
|
||||
return ActionNXDomain, true
|
||||
case "zero", "sinkhole", "null":
|
||||
return ActionZero, true
|
||||
case "cname":
|
||||
return ActionCNAME, true
|
||||
case "redirect":
|
||||
return ActionRedirect, true
|
||||
}
|
||||
return ActionBlock, false
|
||||
}
|
||||
|
||||
// Entry is one compiled rule. It is shared by every name that maps to it, so
|
||||
// keep it small: the strings are interned per source.
|
||||
//
|
||||
// The hit counter is a pointer so an Entry can be copied by value (the API
|
||||
// returns copies) without copying a mutex or tripping go vet's lock check.
|
||||
type Entry struct {
|
||||
// Name is the rule's pattern, normalised (lowercase, no trailing dot).
|
||||
Name string
|
||||
Action Action
|
||||
// Response is the sinkhole address or CNAME target for the actions that
|
||||
// need one; empty otherwise.
|
||||
Response string
|
||||
// Source is the list or file the rule came from (shown in the UI).
|
||||
Source string
|
||||
// Categories are the list's tags, copied per rule.
|
||||
Categories []string
|
||||
// Priority orders allow-over-block conflicts: higher wins, and ties break
|
||||
// towards the first rule seen.
|
||||
Priority int
|
||||
// hits counts matches since the set was built. A pointer keeps Entry
|
||||
// copyable by value, which the admin API relies on.
|
||||
hits *atomic.Uint64
|
||||
// TTL overrides the manager's response TTL for this rule (RPZ records
|
||||
// carry their own TTL).
|
||||
TTL uint32
|
||||
}
|
||||
|
||||
// Hits returns how many times this rule matched.
|
||||
func (e *Entry) Hits() uint64 {
|
||||
if e == nil || e.hits == nil {
|
||||
return 0
|
||||
}
|
||||
return e.hits.Load()
|
||||
}
|
||||
|
||||
func (e *Entry) markHit() {
|
||||
if e == nil || e.hits == nil {
|
||||
return
|
||||
}
|
||||
e.hits.Add(1)
|
||||
}
|
||||
|
||||
// IsAllow reports whether the rule whitelists the name.
|
||||
func (e *Entry) IsAllow() bool {
|
||||
return e.Action == ActionAllow || e.Action == ActionPassthrough
|
||||
}
|
||||
|
||||
// Rule is a pattern plus the action it implies, as produced by the parsers.
|
||||
type Rule struct {
|
||||
// Name is the pattern; may start with "*." (subdomains only) or
|
||||
// "**." (subdomains and the apex itself).
|
||||
Name string
|
||||
Action Action
|
||||
// Response is an address literal (for redirect) or a target name (cname).
|
||||
Response string
|
||||
// TTL is a per-rule TTL when the source provides one.
|
||||
TTL uint32
|
||||
// Category tags the rule.
|
||||
Category string
|
||||
// Priority disambiguates allow/block conflicts.
|
||||
Priority int
|
||||
}
|
||||
|
||||
// Set is an immutable compiled rule table.
|
||||
//
|
||||
// Three maps cover the pattern space:
|
||||
//
|
||||
// exact[name] "ads.example.com" the name and nothing else
|
||||
// sub[example.com] "*.example.com" strict subdomains
|
||||
// root[example.com] "**.example.com" subdomains and the apex
|
||||
//
|
||||
// Lookup is one hash for the common case (exact hit or "no wildcards at all"),
|
||||
// and at most one hash per label otherwise — no per-query allocation.
|
||||
type Set struct {
|
||||
exact map[string]*Entry
|
||||
sub map[string]*Entry
|
||||
root map[string]*Entry
|
||||
// hasWild lets Match skip the suffix walk for sets of pure exact rules,
|
||||
// which is the common hosts-file case.
|
||||
hasWild bool
|
||||
// meta is per-source bookkeeping for the UI.
|
||||
meta map[string]SourceMeta
|
||||
// counts is the number of compiled rules, per kind.
|
||||
counts RuleCounts
|
||||
// total is the number of rules dropped as duplicates or malformed.
|
||||
duplicates int
|
||||
// builtAt is when this set was compiled.
|
||||
builtAt time.Time
|
||||
// digest covers the compiled patterns, so two instances can compare rules
|
||||
// without shipping the rules around.
|
||||
digest string
|
||||
// sources is the ordered list of source names that contributed.
|
||||
sources []string
|
||||
// maxDepth is the longest pattern label count, used to bound the walk.
|
||||
maxDepth int
|
||||
}
|
||||
|
||||
// RuleCounts breaks down compiled rules by kind.
|
||||
type RuleCounts struct {
|
||||
Exact int
|
||||
Sub int
|
||||
Root int
|
||||
Allow int
|
||||
Block int
|
||||
Passthru int
|
||||
Drop int
|
||||
}
|
||||
|
||||
// SourceMeta describes one contributing source.
|
||||
type SourceMeta struct {
|
||||
Name string
|
||||
Format string
|
||||
Rules int
|
||||
Bytes int64
|
||||
ETag string
|
||||
LastMod string
|
||||
UpdatedAt time.Time
|
||||
Duration time.Duration
|
||||
Err string
|
||||
Enabled bool
|
||||
URL string
|
||||
Path string
|
||||
Stale bool
|
||||
HTTPStatus int
|
||||
}
|
||||
|
||||
// Builder accumulates rules and produces a Set.
|
||||
type Builder struct {
|
||||
exact map[string]*Entry
|
||||
sub map[string]*Entry
|
||||
root map[string]*Entry
|
||||
meta map[string]SourceMeta
|
||||
order []string
|
||||
dup int
|
||||
counts RuleCounts
|
||||
}
|
||||
|
||||
// NewBuilder returns an empty Builder.
|
||||
func NewBuilder() *Builder {
|
||||
return &Builder{
|
||||
exact: make(map[string]*Entry, 1<<12),
|
||||
sub: make(map[string]*Entry, 1<<10),
|
||||
root: make(map[string]*Entry, 1<<10),
|
||||
meta: make(map[string]SourceMeta, 8),
|
||||
}
|
||||
}
|
||||
|
||||
// Add compiles one rule. Names are normalised here, so callers may pass
|
||||
// user-typed domain names.
|
||||
func (b *Builder) Add(r Rule, source string, categories []string) {
|
||||
name, kind := splitPattern(r.Name)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
prio := r.Priority
|
||||
if prio == 0 {
|
||||
// Allow rules outrank block rules by default so an explicit
|
||||
// whitelist always wins over a list entry.
|
||||
if r.Action == ActionAllow || r.Action == ActionPassthrough {
|
||||
prio = 10
|
||||
} else {
|
||||
prio = 1
|
||||
}
|
||||
}
|
||||
e := &Entry{
|
||||
hits: new(atomic.Uint64),
|
||||
Name: name,
|
||||
Action: r.Action,
|
||||
Response: r.Response,
|
||||
Source: source,
|
||||
Categories: categories,
|
||||
Priority: prio,
|
||||
TTL: r.TTL,
|
||||
}
|
||||
if r.Category != "" {
|
||||
e.Categories = append(e.Categories, r.Category)
|
||||
}
|
||||
var m map[string]*Entry
|
||||
switch kind {
|
||||
case patternExact:
|
||||
m = b.exact
|
||||
case patternSub:
|
||||
m = b.sub
|
||||
case patternRoot:
|
||||
m = b.root
|
||||
}
|
||||
if existing, ok := m[name]; ok {
|
||||
b.dup++
|
||||
// Higher priority wins; on a tie the existing rule stays, so the
|
||||
// order lists appear in the config is meaningful.
|
||||
if prio <= existing.Priority {
|
||||
return
|
||||
}
|
||||
b.uncount(existing)
|
||||
m[name] = e
|
||||
b.countAction(e, 1)
|
||||
return
|
||||
}
|
||||
m[name] = e
|
||||
b.countAction(e, 1)
|
||||
if _, seen := b.meta[source]; !seen {
|
||||
b.order = append(b.order, source)
|
||||
}
|
||||
}
|
||||
|
||||
// metaFor reports whether a source already contributed to this build.
|
||||
func (b *Builder) metaFor(name string) (SourceMeta, bool) {
|
||||
m, ok := b.meta[name]
|
||||
return m, ok
|
||||
}
|
||||
|
||||
func (b *Builder) countAction(e *Entry, delta int) {
|
||||
switch e.Action {
|
||||
case ActionAllow:
|
||||
b.counts.Allow += delta
|
||||
case ActionPassthrough:
|
||||
b.counts.Passthru += delta
|
||||
case ActionDrop:
|
||||
b.counts.Drop += delta
|
||||
default:
|
||||
b.counts.Block += delta
|
||||
}
|
||||
}
|
||||
|
||||
// uncount removes an entry that a higher-priority rule is replacing.
|
||||
func (b *Builder) uncount(e *Entry) {
|
||||
switch e.Action {
|
||||
case ActionAllow:
|
||||
b.counts.Allow--
|
||||
case ActionPassthrough:
|
||||
b.counts.Passthru--
|
||||
case ActionDrop:
|
||||
b.counts.Drop--
|
||||
default:
|
||||
b.counts.Block--
|
||||
}
|
||||
}
|
||||
|
||||
// NoteSource records (or updates) bookkeeping for a contributing source.
|
||||
func (b *Builder) NoteSource(meta SourceMeta) {
|
||||
if _, seen := b.meta[meta.Name]; !seen {
|
||||
b.order = append(b.order, meta.Name)
|
||||
}
|
||||
b.meta[meta.Name] = meta
|
||||
}
|
||||
|
||||
// SourceCount is the number of contributing sources.
|
||||
func (b *Builder) SourceCount() int { return len(b.meta) }
|
||||
|
||||
// Build freezes the accumulated rules into an immutable Set.
|
||||
func (b *Builder) Build() *Set {
|
||||
s := &Set{
|
||||
exact: b.exact,
|
||||
sub: b.sub,
|
||||
root: b.root,
|
||||
meta: b.meta,
|
||||
sources: b.order,
|
||||
builtAt: time.Now(),
|
||||
duplicates: b.dup,
|
||||
hasWild: len(b.sub) > 0 || len(b.root) > 0,
|
||||
}
|
||||
s.counts = b.counts
|
||||
s.counts.Exact = len(b.exact)
|
||||
s.counts.Sub = len(b.sub)
|
||||
s.counts.Root = len(b.root)
|
||||
s.digest = b.digest()
|
||||
for k := range b.exact {
|
||||
if d := labelCount(k); d > s.maxDepth {
|
||||
s.maxDepth = d
|
||||
}
|
||||
}
|
||||
for k := range b.sub {
|
||||
if d := labelCount(k); d > s.maxDepth {
|
||||
s.maxDepth = d
|
||||
}
|
||||
}
|
||||
for k := range b.root {
|
||||
if d := labelCount(k); d > s.maxDepth {
|
||||
s.maxDepth = d
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// digest hashes every compiled pattern together with its action. The map walk
|
||||
// makes it order-independent, but stable for a given rule set, which is exactly
|
||||
// what cluster convergence checking needs.
|
||||
func (b *Builder) digest() string {
|
||||
h := sha256.New()
|
||||
var buf [3]byte
|
||||
write := func(kind byte, name string, e *Entry) {
|
||||
buf[0] = kind
|
||||
h.Write(buf[:1])
|
||||
io.WriteString(h, name)
|
||||
buf[1] = byte(e.Action)
|
||||
binary.LittleEndian.PutUint16(buf[1:], uint16(e.Priority))
|
||||
h.Write(buf[:3])
|
||||
}
|
||||
for name, e := range b.exact {
|
||||
write(0, name, e)
|
||||
}
|
||||
for name, e := range b.sub {
|
||||
write(1, name, e)
|
||||
}
|
||||
for name, e := range b.root {
|
||||
write(2, name, e)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)[:8])
|
||||
}
|
||||
|
||||
// Digest returns the compiled content's digest.
|
||||
func (s *Set) Digest() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.digest
|
||||
}
|
||||
|
||||
type patternKind uint8
|
||||
|
||||
const (
|
||||
patternExact patternKind = iota
|
||||
patternSub
|
||||
patternRoot
|
||||
)
|
||||
|
||||
// splitPattern normalises a rule name and reports which map it belongs in.
|
||||
//
|
||||
// "Example.COM." -> exact "example.com"
|
||||
// "*.example.com" -> sub "example.com"
|
||||
// "**.example.com" -> root "example.com"
|
||||
// "." (the whole internet) is dropped: it is never a useful rule.
|
||||
func splitPattern(in string) (string, patternKind) {
|
||||
name := strings.ToLower(strings.TrimSpace(in))
|
||||
if name == "" {
|
||||
return "", patternExact
|
||||
}
|
||||
kind := patternExact
|
||||
switch {
|
||||
case strings.HasPrefix(name, "**."):
|
||||
kind = patternRoot
|
||||
name = name[3:]
|
||||
case strings.HasPrefix(name, "*."):
|
||||
kind = patternSub
|
||||
name = name[2:]
|
||||
case strings.HasPrefix(name, "."):
|
||||
// A leading dot in hosts/adblock convention means "and subdomains".
|
||||
kind = patternRoot
|
||||
name = name[1:]
|
||||
}
|
||||
name = strings.TrimSuffix(name, ".")
|
||||
name = strings.Trim(name, ".")
|
||||
if name == "" || name == "." {
|
||||
return "", patternExact
|
||||
}
|
||||
// A name with an interior "*" is not something we can match; treat the
|
||||
// first label as a fixed prefix and drop the rest, which degrades to
|
||||
// "closest sane behaviour" instead of silently not matching.
|
||||
if i := strings.IndexByte(name, '*'); i >= 0 {
|
||||
name = name[:i]
|
||||
name = strings.TrimSuffix(name, ".")
|
||||
if name == "" {
|
||||
return "", patternExact
|
||||
}
|
||||
}
|
||||
return name, kind
|
||||
}
|
||||
|
||||
// NormalizeName canonicalises a queried name for matching: lowercased, no
|
||||
// trailing dot. It is allocation-free for the common case.
|
||||
func NormalizeName(name string) string {
|
||||
if len(name) > 0 && name[len(name)-1] == '.' {
|
||||
name = name[:len(name)-1]
|
||||
}
|
||||
return strings.ToLower(name)
|
||||
}
|
||||
|
||||
// Match returns the rule that applies to name, or nil. name may be fqdn or
|
||||
// relative; the caller is responsible for lowercasing only if it has not
|
||||
// already.
|
||||
func (s *Set) Match(name string) *Entry {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
name = NormalizeName(name)
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
if e, ok := s.exact[name]; ok {
|
||||
e.markHit()
|
||||
return e
|
||||
}
|
||||
if !s.hasWild {
|
||||
return nil
|
||||
}
|
||||
// The apex pattern "**.x" also covers the name itself.
|
||||
if e, ok := s.root[name]; ok {
|
||||
e.markHit()
|
||||
return e
|
||||
}
|
||||
// Walk strictly shorter suffixes, most specific first.
|
||||
for off := labelEnd(name); off < len(name); off = labelEnd(name[off:]) + off {
|
||||
suffix := name[off:]
|
||||
if e, ok := s.root[suffix]; ok {
|
||||
e.markHit()
|
||||
return e
|
||||
}
|
||||
if e, ok := s.sub[suffix]; ok {
|
||||
e.markHit()
|
||||
return e
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// labelEnd returns the index just past the first label of s, or len(s) when s
|
||||
// has no dot.
|
||||
func labelEnd(s string) int {
|
||||
if i := strings.IndexByte(s, '.'); i >= 0 {
|
||||
return i + 1
|
||||
}
|
||||
return len(s)
|
||||
}
|
||||
|
||||
func labelCount(s string) int {
|
||||
n := 1
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '.' {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Len reports the total number of compiled rules.
|
||||
func (s *Set) Len() int {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
return s.counts.Exact + s.counts.Sub + s.counts.Root
|
||||
}
|
||||
|
||||
// Counts returns the per-kind rule breakdown.
|
||||
func (s *Set) Counts() RuleCounts {
|
||||
if s == nil {
|
||||
return RuleCounts{}
|
||||
}
|
||||
return s.counts
|
||||
}
|
||||
|
||||
// HasWildcards reports whether any subdomain rules exist.
|
||||
func (s *Set) HasWildcards() bool { return s != nil && s.hasWild }
|
||||
|
||||
// Duplicates is the number of rules dropped because a higher- or equal-priority
|
||||
// rule already claimed the name.
|
||||
func (s *Set) Duplicates() int { return s.DuplicatesN() }
|
||||
|
||||
// DuplicatesN is Duplicates with a nil guard.
|
||||
func (s *Set) DuplicatesN() int {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
return s.duplicates
|
||||
}
|
||||
|
||||
// BuiltAt is when the set was compiled.
|
||||
func (s *Set) BuiltAt() time.Time {
|
||||
if s == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return s.builtAt
|
||||
}
|
||||
|
||||
// Sources lists the contributing source names in order.
|
||||
func (s *Set) Sources() []string {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.sources
|
||||
}
|
||||
|
||||
// SourceMeta returns bookkeeping for one source.
|
||||
func (s *Set) SourceMeta(name string) (SourceMeta, bool) {
|
||||
if s == nil {
|
||||
return SourceMeta{}, false
|
||||
}
|
||||
m, ok := s.meta[name]
|
||||
return m, ok
|
||||
}
|
||||
|
||||
// AllSourceMeta returns the per-source bookkeeping map.
|
||||
func (s *Set) AllSourceMeta() map[string]SourceMeta {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.meta
|
||||
}
|
||||
|
||||
// PatternKind says which of the three maps a rule lives in.
|
||||
type PatternKind uint8
|
||||
|
||||
const (
|
||||
// PatternExact is "ads.example.com": the name and nothing else.
|
||||
PatternExact PatternKind = iota
|
||||
// PatternSub is "*.example.com": strict subdomains.
|
||||
PatternSub
|
||||
// PatternRoot is "**.example.com": subdomains and the apex.
|
||||
PatternRoot
|
||||
)
|
||||
|
||||
// String renders the kind for the UI.
|
||||
func (k PatternKind) String() string {
|
||||
switch k {
|
||||
case PatternSub:
|
||||
return "subdomain"
|
||||
case PatternRoot:
|
||||
return "domain"
|
||||
default:
|
||||
return "exact"
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern renders a pattern of this kind for a stored name.
|
||||
func (k PatternKind) Pattern(name string) string {
|
||||
switch k {
|
||||
case PatternSub:
|
||||
return "*." + name
|
||||
case PatternRoot:
|
||||
return "**." + name
|
||||
default:
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
// Each visits every rule. It exists for the admin API's rule browser; the DNS
|
||||
// hot path uses Match and never pays this cost.
|
||||
func (s *Set) Each(fn func(pattern string, e *Entry, kind PatternKind) bool) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
for name, e := range s.exact {
|
||||
if !fn(name, e, PatternExact) {
|
||||
return
|
||||
}
|
||||
}
|
||||
for name, e := range s.sub {
|
||||
if !fn(name, e, PatternSub) {
|
||||
return
|
||||
}
|
||||
}
|
||||
for name, e := range s.root {
|
||||
if !fn(name, e, PatternRoot) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MatchPattern returns the kind of rule that matched a name, and the stored
|
||||
// pattern (without its wildcard prefix). It reports PatternExact when only an
|
||||
// exact rule matched.
|
||||
func (s *Set) MatchPattern(name string) PatternKind {
|
||||
if s == nil {
|
||||
return PatternExact
|
||||
}
|
||||
name = NormalizeName(name)
|
||||
if _, ok := s.exact[name]; ok {
|
||||
return PatternExact
|
||||
}
|
||||
if _, ok := s.root[name]; ok {
|
||||
return PatternRoot
|
||||
}
|
||||
for off := labelEnd(name); off < len(name); off = labelEnd(name[off:]) + off {
|
||||
suffix := name[off:]
|
||||
if _, ok := s.root[suffix]; ok {
|
||||
return PatternRoot
|
||||
}
|
||||
if _, ok := s.sub[suffix]; ok {
|
||||
return PatternSub
|
||||
}
|
||||
}
|
||||
return PatternExact
|
||||
}
|
||||
|
||||
// Lookup returns the exact rule for a name without walking wildcards. It is
|
||||
// used by the admin API to explain a match.
|
||||
func (s *Set) Lookup(name string) *Entry {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.exact[NormalizeName(name)]
|
||||
}
|
||||
|
||||
// Stats is a JSON-friendly snapshot for the API and UI.
|
||||
type Stats struct {
|
||||
Total int `json:"total"`
|
||||
Exact int `json:"exact"`
|
||||
Subdomains int `json:"subdomain_rules"`
|
||||
RootRules int `json:"root_rules"`
|
||||
Allow int `json:"allow"`
|
||||
Block int `json:"block"`
|
||||
Passthru int `json:"passthrough"`
|
||||
Drop int `json:"drop"`
|
||||
Duplicates int `json:"duplicates"`
|
||||
Sources int `json:"sources"`
|
||||
BuiltAt time.Time `json:"built_at"`
|
||||
PerSource map[string]SourceMeta `json:"sources_detail"`
|
||||
}
|
||||
|
||||
// Stats summarises the set for the API.
|
||||
func (s *Set) Stats() Stats {
|
||||
if s == nil {
|
||||
return Stats{PerSource: map[string]SourceMeta{}}
|
||||
}
|
||||
return Stats{
|
||||
Total: s.Len(),
|
||||
Exact: s.counts.Exact,
|
||||
Subdomains: s.counts.Sub,
|
||||
RootRules: s.counts.Root,
|
||||
Allow: s.counts.Allow,
|
||||
Block: s.counts.Block,
|
||||
Passthru: s.counts.Passthru,
|
||||
Drop: s.counts.Drop,
|
||||
Duplicates: s.duplicates,
|
||||
Sources: len(s.meta),
|
||||
BuiltAt: s.builtAt,
|
||||
PerSource: s.meta,
|
||||
}
|
||||
}
|
||||
|
||||
// Holder is a swappable Set, so hot paths can grab a pointer once per query
|
||||
// and be immune to a concurrent refresh.
|
||||
type Holder struct {
|
||||
cur atomic.Pointer[Set]
|
||||
}
|
||||
|
||||
// NewHolder wraps an initial set (may be nil).
|
||||
func NewHolder(s *Set) *Holder {
|
||||
h := &Holder{}
|
||||
if s != nil {
|
||||
h.cur.Store(s)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// Get returns the current set (nil before the first build).
|
||||
func (h *Holder) Get() *Set { return h.cur.Load() }
|
||||
|
||||
// Set installs a new set.
|
||||
func (h *Holder) Set(s *Set) { h.cur.Store(s) }
|
||||
Reference in new issue
Block a user