Add DNS filtering with dnsmasq address=/ directives for wildcard blocking

Adds Pi-hole-style DNS filtering using dnsmasq's native address=/ directives,
which block domains and all subdomains. Users subscribe to blocklists by URL
or upload files, with suggested lists from Hagezi, Steven Black, and OISD.
Background runner refreshes lists on a configurable interval (default 24h).
This commit is contained in:
2026-07-12 12:44:19 +00:00
parent 134c01fe5e
commit 518cdbbce5
12 changed files with 2009 additions and 2 deletions

View File

@@ -0,0 +1,115 @@
package dnsfilter
import (
"context"
"log/slog"
"sync"
"time"
)
// Runner manages the background blocklist update goroutine.
type Runner struct {
mu sync.RWMutex
manager *Manager
cancel context.CancelFunc
trigger chan struct{}
running bool
onUpdate func() // called after lists are downloaded and compiled
}
// NewRunner creates a new DNS filter update runner.
func NewRunner(manager *Manager, onUpdate func()) *Runner {
return &Runner{
manager: manager,
trigger: make(chan struct{}, 1),
onUpdate: onUpdate,
}
}
// Start launches the background update goroutine.
// Safe to call multiple times — cancels the previous run first.
func (r *Runner) Start(parentCtx context.Context, intervalHours int) {
r.mu.Lock()
if r.cancel != nil {
r.cancel()
}
ctx, cancel := context.WithCancel(parentCtx)
r.cancel = cancel
r.running = true
r.mu.Unlock()
if intervalHours <= 0 {
intervalHours = 24
}
go r.run(ctx, intervalHours)
}
// Stop cancels the running goroutine.
func (r *Runner) Stop() {
r.mu.Lock()
defer r.mu.Unlock()
if r.cancel != nil {
r.cancel()
r.cancel = nil
}
r.running = false
}
// IsRunning returns whether the runner is active.
func (r *Runner) IsRunning() bool {
r.mu.RLock()
defer r.mu.RUnlock()
return r.running
}
// Trigger requests an immediate update (non-blocking).
func (r *Runner) Trigger() {
select {
case r.trigger <- struct{}{}:
default:
}
}
func (r *Runner) run(ctx context.Context, intervalHours int) {
interval := time.Duration(intervalHours) * time.Hour
slog.Info("dns-filter: runner started", "interval", interval)
// Immediate update on start
r.updateAndCompile(ctx)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
r.mu.Lock()
r.running = false
r.mu.Unlock()
slog.Info("dns-filter: runner stopped")
return
case <-ticker.C:
r.updateAndCompile(ctx)
case <-r.trigger:
r.updateAndCompile(ctx)
}
}
}
func (r *Runner) updateAndCompile(ctx context.Context) {
if err := r.manager.UpdateLists(ctx); err != nil {
slog.Error("dns-filter: update failed", "error", err)
}
if _, err := r.manager.Compile(); err != nil {
slog.Error("dns-filter: compile failed", "error", err)
return
}
if r.onUpdate != nil {
r.onUpdate()
}
}