initial commit

This commit is contained in:
developer committed 2026-09-27 11:20:34 +00:00
1 parent 5b935301e5
commit 85453852d5
105 files changed
+24846

No files matched your search

+647
View File
@@ -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} }
+535
View File
@@ -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, ".")
}
+737
View File
@@ -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) }