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,351 @@
package v1
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"github.com/gorilla/mux"
"github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/dnsfilter"
)
// DNSFilterStatus returns the current DNS filter status and stats.
func (api *API) DNSFilterStatus(w http.ResponseWriter, r *http.Request) {
state, err := config.LoadState(api.statePath())
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to load state")
return
}
stats := api.dnsFilter.GetStats()
stats.Enabled = state.Cloud.DNSFilter.Enabled
if stats.Enabled {
stats.QueryStats = api.dnsFilter.GetQueryStats()
}
respondJSON(w, http.StatusOK, stats)
}
// DNSFilterGetConfig returns the DNS filter configuration from state.yaml.
func (api *API) DNSFilterGetConfig(w http.ResponseWriter, r *http.Request) {
state, err := config.LoadState(api.statePath())
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to load state")
return
}
respondJSON(w, http.StatusOK, state.Cloud.DNSFilter)
}
// DNSFilterUpdateConfig enables/disables DNS filtering and updates settings.
func (api *API) DNSFilterUpdateConfig(w http.ResponseWriter, r *http.Request) {
var req struct {
Enabled *bool `json:"enabled"`
IntervalHours *int `json:"intervalHours"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
state, err := config.LoadState(api.statePath())
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to load state")
return
}
if req.Enabled != nil {
state.Cloud.DNSFilter.Enabled = *req.Enabled
}
if req.IntervalHours != nil {
state.Cloud.DNSFilter.IntervalHours = *req.IntervalHours
}
if err := config.SaveState(state, api.statePath()); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save state")
return
}
if state.Cloud.DNSFilter.Enabled {
if err := api.dnsFilter.EnsureDataDir(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create data dir: %v", err))
return
}
// Start or restart the runner
interval := state.Cloud.DNSFilter.IntervalHours
if interval <= 0 {
interval = 24
}
api.dnsFilterRunner.Start(api.ctx, interval)
slog.Info("dns-filter: enabled", "interval", interval)
} else {
// Stop the runner and clear addn-hosts
api.dnsFilterRunner.Stop()
api.dnsmasq.SetFilterConfPath("")
slog.Info("dns-filter: disabled")
}
// Reconcile to add/remove addn-hosts from dnsmasq config
go api.reconcileNetworking()
api.broadcastDNSFilterEvent("dns-filter:config", "DNS filter configuration updated")
respondMessage(w, http.StatusOK, "DNS filter configuration updated")
}
// DNSFilterGetLists returns all subscribed blocklists.
func (api *API) DNSFilterGetLists(w http.ResponseWriter, r *http.Request) {
fd, err := api.dnsFilter.LoadFilterData()
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to load filter data")
return
}
respondJSON(w, http.StatusOK, map[string]any{
"lists": fd.Lists,
})
}
// DNSFilterAddList adds a URL-based blocklist subscription.
func (api *API) DNSFilterAddList(w http.ResponseWriter, r *http.Request) {
var req struct {
URL string `json:"url"`
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
if req.URL == "" || req.Name == "" {
respondError(w, http.StatusBadRequest, "URL and name are required")
return
}
bl := dnsfilter.Blocklist{
URL: req.URL,
Name: req.Name,
}
if err := api.dnsFilter.AddList(bl); err != nil {
respondError(w, http.StatusConflict, err.Error())
return
}
// Trigger update if runner is active
if api.dnsFilterRunner.IsRunning() {
api.dnsFilterRunner.Trigger()
}
api.broadcastDNSFilterEvent("dns-filter:list-added", fmt.Sprintf("Added list: %s", req.Name))
respondMessage(w, http.StatusCreated, "List added")
}
// DNSFilterRemoveList removes a blocklist by ID.
func (api *API) DNSFilterRemoveList(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
if id == "" {
respondError(w, http.StatusBadRequest, "List ID is required")
return
}
if err := api.dnsFilter.RemoveList(id); err != nil {
respondError(w, http.StatusNotFound, err.Error())
return
}
// Recompile without the removed list
go func() {
if _, err := api.dnsFilter.Compile(); err != nil {
slog.Warn("dns-filter: recompile after removal failed", "error", err)
return
}
if err := api.dnsmasq.ReloadService(); err != nil {
slog.Warn("dns-filter: reload after removal failed", "error", err)
}
api.broadcastDNSFilterEvent("dns-filter:updated", "DNS filter lists updated")
}()
respondMessage(w, http.StatusOK, "List removed")
}
// DNSFilterToggleList enables or disables a list.
func (api *API) DNSFilterToggleList(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
var req struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
if err := api.dnsFilter.ToggleList(id, req.Enabled); err != nil {
respondError(w, http.StatusNotFound, err.Error())
return
}
// Recompile with updated list status
go func() {
if _, err := api.dnsFilter.Compile(); err != nil {
slog.Warn("dns-filter: recompile after toggle failed", "error", err)
return
}
if err := api.dnsmasq.ReloadService(); err != nil {
slog.Warn("dns-filter: reload after toggle failed", "error", err)
}
api.broadcastDNSFilterEvent("dns-filter:updated", "DNS filter lists updated")
}()
respondMessage(w, http.StatusOK, "List toggled")
}
// DNSFilterGetCustomEntries returns all custom allow/block entries.
func (api *API) DNSFilterGetCustomEntries(w http.ResponseWriter, r *http.Request) {
fd, err := api.dnsFilter.LoadFilterData()
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to load filter data")
return
}
respondJSON(w, http.StatusOK, map[string]any{
"customEntries": fd.CustomEntries,
})
}
// DNSFilterSetCustomEntry adds or updates a custom allow/block entry.
func (api *API) DNSFilterSetCustomEntry(w http.ResponseWriter, r *http.Request) {
var req dnsfilter.CustomEntry
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
if req.Domain == "" {
respondError(w, http.StatusBadRequest, "Domain is required")
return
}
if err := api.dnsFilter.SetCustomEntry(req); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
// Recompile with updated custom entries
go func() {
if _, err := api.dnsFilter.Compile(); err != nil {
slog.Warn("dns-filter: recompile after custom entry failed", "error", err)
return
}
if err := api.dnsmasq.ReloadService(); err != nil {
slog.Warn("dns-filter: reload after custom entry failed", "error", err)
}
api.broadcastDNSFilterEvent("dns-filter:updated", "DNS filter custom entry updated")
}()
respondMessage(w, http.StatusOK, "Custom entry saved")
}
// DNSFilterRemoveCustomEntry removes a custom entry by domain.
func (api *API) DNSFilterRemoveCustomEntry(w http.ResponseWriter, r *http.Request) {
domain := mux.Vars(r)["domain"]
if domain == "" {
respondError(w, http.StatusBadRequest, "Domain is required")
return
}
if err := api.dnsFilter.RemoveCustomEntry(domain); err != nil {
respondError(w, http.StatusNotFound, err.Error())
return
}
// Recompile
go func() {
if _, err := api.dnsFilter.Compile(); err != nil {
slog.Warn("dns-filter: recompile after custom removal failed", "error", err)
return
}
if err := api.dnsmasq.ReloadService(); err != nil {
slog.Warn("dns-filter: reload after custom removal failed", "error", err)
}
api.broadcastDNSFilterEvent("dns-filter:updated", "DNS filter custom entry removed")
}()
respondMessage(w, http.StatusOK, "Custom entry removed")
}
// DNSFilterTriggerUpdate triggers an immediate list update.
func (api *API) DNSFilterTriggerUpdate(w http.ResponseWriter, r *http.Request) {
if !api.dnsFilterRunner.IsRunning() {
respondError(w, http.StatusBadRequest, "DNS filter is not enabled")
return
}
api.dnsFilterRunner.Trigger()
respondJSON(w, http.StatusAccepted, map[string]string{
"message": "Update triggered",
})
}
// DNSFilterUploadList handles file upload for a blocklist.
func (api *API) DNSFilterUploadList(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(50 << 20); err != nil { // 50MB limit
respondError(w, http.StatusBadRequest, "Failed to parse upload")
return
}
name := r.FormValue("name")
if name == "" {
respondError(w, http.StatusBadRequest, "Name is required")
return
}
file, _, err := r.FormFile("file")
if err != nil {
respondError(w, http.StatusBadRequest, "File is required")
return
}
defer file.Close()
content, err := io.ReadAll(io.LimitReader(file, 50<<20))
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to read file")
return
}
id, err := api.dnsFilter.AddUploadedList(name, content)
if err != nil {
respondError(w, http.StatusConflict, err.Error())
return
}
// Recompile with new list
go func() {
if _, err := api.dnsFilter.Compile(); err != nil {
slog.Warn("dns-filter: recompile after upload failed", "error", err)
return
}
if err := api.dnsmasq.ReloadService(); err != nil {
slog.Warn("dns-filter: reload after upload failed", "error", err)
}
api.broadcastDNSFilterEvent("dns-filter:updated", "DNS filter list uploaded")
}()
respondJSON(w, http.StatusCreated, map[string]string{
"id": id,
"message": "List uploaded",
})
}
// DNSFilterGetSuggested returns well-known blocklists for easy adding.
func (api *API) DNSFilterGetSuggested(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{
"lists": dnsfilter.SuggestedLists(),
})
}

View File

@@ -0,0 +1,550 @@
package dnsfilter
import (
"bufio"
"context"
"crypto/sha256"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"github.com/wild-cloud/wild-central/internal/storage"
"gopkg.in/yaml.v3"
)
// Blocklist represents a subscribed blocklist source.
type Blocklist struct {
ID string `yaml:"id" json:"id"`
URL string `yaml:"url,omitempty" json:"url,omitempty"`
Name string `yaml:"name" json:"name"`
Enabled bool `yaml:"enabled" json:"enabled"`
EntryCount int `yaml:"entryCount,omitempty" json:"entryCount,omitempty"`
LastUpdated time.Time `yaml:"lastUpdated,omitempty" json:"lastUpdated,omitzero"`
LastError string `yaml:"lastError,omitempty" json:"lastError,omitempty"`
}
// CustomEntry represents a user-managed domain override.
type CustomEntry struct {
Domain string `yaml:"domain" json:"domain"`
Type string `yaml:"type" json:"type"` // "allow" or "block"
}
// FilterData holds all DNS filter configuration persisted in lists.yaml.
type FilterData struct {
Lists []Blocklist `yaml:"lists" json:"lists"`
CustomEntries []CustomEntry `yaml:"customEntries" json:"customEntries"`
}
// FilterStats holds current filtering statistics.
type FilterStats struct {
Enabled bool `json:"enabled"`
TotalBlocked int `json:"totalBlocked"`
ListCount int `json:"listCount"`
EnabledLists int `json:"enabledLists"`
LastUpdated time.Time `json:"lastUpdated"`
QueryStats *QueryStats `json:"queryStats,omitempty"`
}
// QueryStats holds DNS query statistics from dnsmasq logs.
type QueryStats struct {
TotalQueries int `json:"totalQueries"`
BlockedQueries int `json:"blockedQueries"`
BlockedPercent float64 `json:"blockedPercent"`
Period string `json:"period"` // e.g. "24h"
}
// Manager handles DNS filter list management and compilation.
type Manager struct {
dataDir string // e.g. /var/lib/wild-central/dns-filter
hostsFilePath string // compiled output path (must be readable by dnsmasq)
}
// NewManager creates a new DNS filter manager.
func NewManager(dataDir string) *Manager {
return &Manager{
dataDir: dataDir,
hostsFilePath: filepath.Join(dataDir, "blocked.conf"), // default, override with SetHostsFilePath
}
}
// SetHostsFilePath sets the output path for the compiled hosts file.
// Must be readable by the dnsmasq process (e.g. /etc/dnsmasq.d/).
func (m *Manager) SetHostsFilePath(path string) {
m.hostsFilePath = path
}
func (m *Manager) listsPath() string { return filepath.Join(m.dataDir, "lists.yaml") }
func (m *Manager) cachePath() string { return filepath.Join(m.dataDir, "cache") }
func (m *Manager) lockPath() string { return filepath.Join(m.dataDir, "lists.yaml.lock") }
// HostsFilePath returns the path to the compiled blocked hosts file.
func (m *Manager) HostsFilePath() string { return m.hostsFilePath }
// EnsureDataDir creates the dns-filter directory structure.
func (m *Manager) EnsureDataDir() error {
if err := storage.EnsureDir(m.dataDir, 0755); err != nil {
return err
}
// Also ensure the hosts file output directory exists (may differ from dataDir)
hostsDir := filepath.Dir(m.hostsFilePath)
if hostsDir != m.dataDir {
if err := storage.EnsureDir(hostsDir, 0755); err != nil {
return err
}
}
return storage.EnsureDir(m.cachePath(), 0755)
}
// LoadFilterData reads lists.yaml. Returns empty data if file doesn't exist.
func (m *Manager) LoadFilterData() (*FilterData, error) {
data, err := os.ReadFile(m.listsPath())
if err != nil {
if os.IsNotExist(err) {
return &FilterData{}, nil
}
return nil, fmt.Errorf("reading filter data: %w", err)
}
var fd FilterData
if err := yaml.Unmarshal(data, &fd); err != nil {
return nil, fmt.Errorf("parsing filter data: %w", err)
}
return &fd, nil
}
// SaveFilterData writes lists.yaml atomically with file locking.
func (m *Manager) SaveFilterData(fd *FilterData) error {
return storage.WithLock(m.lockPath(), func() error {
data, err := yaml.Marshal(fd)
if err != nil {
return fmt.Errorf("marshaling filter data: %w", err)
}
return os.WriteFile(m.listsPath(), data, 0644)
})
}
// listID returns a stable ID for a URL-based list.
func listID(url string) string {
h := sha256.Sum256([]byte(url))
return fmt.Sprintf("%x", h[:8])
}
// cacheFile returns the cache file path for a list by ID.
func (m *Manager) cacheFile(id string) string {
return filepath.Join(m.cachePath(), id+".txt")
}
// AddList adds a URL-based blocklist subscription.
func (m *Manager) AddList(bl Blocklist) error {
fd, err := m.LoadFilterData()
if err != nil {
return err
}
if bl.URL != "" {
bl.ID = listID(bl.URL)
}
if bl.ID == "" {
return fmt.Errorf("list must have a URL or ID")
}
// Deduplicate
for _, existing := range fd.Lists {
if existing.ID == bl.ID {
return fmt.Errorf("list already exists: %s", bl.Name)
}
}
bl.Enabled = true
fd.Lists = append(fd.Lists, bl)
return m.SaveFilterData(fd)
}
// AddUploadedList saves an uploaded blocklist file and adds it as a list entry.
func (m *Manager) AddUploadedList(name string, content []byte) (string, error) {
if err := m.EnsureDataDir(); err != nil {
return "", err
}
h := sha256.Sum256(content)
id := fmt.Sprintf("upload-%x", h[:8])
// Write content to cache
if err := os.WriteFile(m.cacheFile(id), content, 0644); err != nil {
return "", fmt.Errorf("writing uploaded list: %w", err)
}
// Count entries
domains, _ := ParseFile(m.cacheFile(id))
bl := Blocklist{
ID: id,
Name: name,
Enabled: true,
EntryCount: len(domains),
LastUpdated: time.Now(),
}
fd, err := m.LoadFilterData()
if err != nil {
return "", err
}
// Deduplicate
for _, existing := range fd.Lists {
if existing.ID == id {
return id, fmt.Errorf("identical list already uploaded")
}
}
fd.Lists = append(fd.Lists, bl)
return id, m.SaveFilterData(fd)
}
// RemoveList removes a list by ID and its cached file.
func (m *Manager) RemoveList(id string) error {
fd, err := m.LoadFilterData()
if err != nil {
return err
}
found := false
filtered := fd.Lists[:0]
for _, bl := range fd.Lists {
if bl.ID == id {
found = true
_ = os.Remove(m.cacheFile(id))
continue
}
filtered = append(filtered, bl)
}
if !found {
return fmt.Errorf("list not found: %s", id)
}
fd.Lists = filtered
return m.SaveFilterData(fd)
}
// ToggleList enables or disables a list by ID.
func (m *Manager) ToggleList(id string, enabled bool) error {
fd, err := m.LoadFilterData()
if err != nil {
return err
}
for i := range fd.Lists {
if fd.Lists[i].ID == id {
fd.Lists[i].Enabled = enabled
return m.SaveFilterData(fd)
}
}
return fmt.Errorf("list not found: %s", id)
}
// SetCustomEntry adds or replaces a custom allow/block entry.
func (m *Manager) SetCustomEntry(entry CustomEntry) error {
if entry.Type != "allow" && entry.Type != "block" {
return fmt.Errorf("type must be 'allow' or 'block'")
}
fd, err := m.LoadFilterData()
if err != nil {
return err
}
// Replace if exists
for i := range fd.CustomEntries {
if fd.CustomEntries[i].Domain == entry.Domain {
fd.CustomEntries[i].Type = entry.Type
return m.SaveFilterData(fd)
}
}
fd.CustomEntries = append(fd.CustomEntries, entry)
return m.SaveFilterData(fd)
}
// RemoveCustomEntry removes a custom entry by domain.
func (m *Manager) RemoveCustomEntry(domain string) error {
fd, err := m.LoadFilterData()
if err != nil {
return err
}
found := false
filtered := fd.CustomEntries[:0]
for _, e := range fd.CustomEntries {
if e.Domain == domain {
found = true
continue
}
filtered = append(filtered, e)
}
if !found {
return fmt.Errorf("custom entry not found: %s", domain)
}
fd.CustomEntries = filtered
return m.SaveFilterData(fd)
}
// UpdateLists downloads all enabled URL-based lists to cache.
func (m *Manager) UpdateLists(ctx context.Context) error {
if err := m.EnsureDataDir(); err != nil {
return err
}
fd, err := m.LoadFilterData()
if err != nil {
return err
}
client := &http.Client{Timeout: 60 * time.Second}
changed := false
for i := range fd.Lists {
bl := &fd.Lists[i]
if !bl.Enabled || bl.URL == "" {
continue
}
slog.Info("dns-filter: downloading list", "name", bl.Name, "url", bl.URL)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, bl.URL, nil)
if err != nil {
bl.LastError = err.Error()
changed = true
continue
}
req.Header.Set("User-Agent", "WildCentral/1.0")
resp, err := client.Do(req)
if err != nil {
bl.LastError = fmt.Sprintf("download failed: %v", err)
changed = true
slog.Warn("dns-filter: download failed", "name", bl.Name, "error", err)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
resp.Body.Close()
bl.LastError = fmt.Sprintf("HTTP %d", resp.StatusCode)
changed = true
slog.Warn("dns-filter: download failed", "name", bl.Name, "status", resp.StatusCode)
continue
}
// Limit download to 50MB
limited := io.LimitReader(resp.Body, 50*1024*1024)
data, err := io.ReadAll(limited)
resp.Body.Close()
if err != nil {
bl.LastError = fmt.Sprintf("read failed: %v", err)
changed = true
continue
}
if err := os.WriteFile(m.cacheFile(bl.ID), data, 0644); err != nil {
bl.LastError = fmt.Sprintf("write failed: %v", err)
changed = true
continue
}
bl.LastUpdated = time.Now()
bl.LastError = ""
changed = true
}
if changed {
_ = m.SaveFilterData(fd)
}
return nil
}
// Compile merges all enabled cached lists, applies custom entries,
// and writes the compiled hosts.blocked file. Returns domain count.
func (m *Manager) Compile() (int, error) {
fd, err := m.LoadFilterData()
if err != nil {
return 0, err
}
blocked := make(map[string]struct{})
// Parse all enabled lists
for i := range fd.Lists {
bl := &fd.Lists[i]
if !bl.Enabled {
continue
}
cachePath := m.cacheFile(bl.ID)
if !storage.FileExists(cachePath) {
continue
}
domains, err := ParseFile(cachePath)
if err != nil {
slog.Warn("dns-filter: failed to parse list", "name", bl.Name, "error", err)
continue
}
for d := range domains {
blocked[d] = struct{}{}
}
bl.EntryCount = len(domains)
}
// Apply custom entries
for _, ce := range fd.CustomEntries {
switch ce.Type {
case "allow":
delete(blocked, ce.Domain)
case "block":
blocked[ce.Domain] = struct{}{}
}
}
// Write hosts.blocked atomically
tmpPath := m.HostsFilePath() + ".tmp"
f, err := os.Create(tmpPath)
if err != nil {
return 0, fmt.Errorf("creating hosts file: %w", err)
}
// Sort for deterministic output
sorted := make([]string, 0, len(blocked))
for d := range blocked {
sorted = append(sorted, d)
}
sort.Strings(sorted)
w := bufio.NewWriter(f)
fmt.Fprintln(w, "# Wild Central DNS Filter — auto-generated, do not edit")
for _, d := range sorted {
fmt.Fprintf(w, "address=/%s/\n", d)
}
if err := w.Flush(); err != nil {
f.Close()
os.Remove(tmpPath)
return 0, fmt.Errorf("writing hosts file: %w", err)
}
f.Close()
if err := os.Rename(tmpPath, m.HostsFilePath()); err != nil {
return 0, fmt.Errorf("renaming hosts file: %w", err)
}
// Update entry counts and save
_ = m.SaveFilterData(fd)
slog.Info("dns-filter: compiled blocklist", "domains", len(sorted))
return len(sorted), nil
}
// GetStats returns current filtering statistics.
func (m *Manager) GetStats() FilterStats {
fd, err := m.LoadFilterData()
if err != nil {
return FilterStats{}
}
var enabled int
var lastUpdated time.Time
for _, bl := range fd.Lists {
if bl.Enabled {
enabled++
}
if bl.LastUpdated.After(lastUpdated) {
lastUpdated = bl.LastUpdated
}
}
// Count lines in hosts.blocked for total
total := 0
if f, err := os.Open(m.HostsFilePath()); err == nil {
scanner := bufio.NewScanner(f)
for scanner.Scan() {
total++
}
_ = scanner.Err()
f.Close()
}
return FilterStats{
TotalBlocked: total,
ListCount: len(fd.Lists),
EnabledLists: enabled,
LastUpdated: lastUpdated,
}
}
// GetQueryStats parses dnsmasq journal logs to count total and blocked queries.
// With address=/ directives, blocked queries log as "config <domain> is NXDOMAIN"
// (for address=/domain/) or "config <domain> is 0.0.0.0".
func (m *Manager) GetQueryStats() *QueryStats {
// Use journalctl to read dnsmasq logs from the last 24 hours.
// Run via sudo since the wildcloud user may not be in the systemd-journal group.
cmd := exec.Command("sudo", "journalctl", "-u", "dnsmasq", "--since", "24 hours ago", "--no-pager", "-o", "cat")
output, err := cmd.Output()
if err != nil {
slog.Debug("dns-filter: failed to read dnsmasq journal", "error", err)
return nil
}
var total, blocked int
scanner := bufio.NewScanner(strings.NewReader(string(output)))
for scanner.Scan() {
line := scanner.Text()
if strings.Contains(line, "query[") {
total++
}
// dnsmasq logs address=/ blocks as: "config <domain> is NXDOMAIN"
// and addn-hosts blocks as: "/path/file <domain> is 0.0.0.0"
if strings.HasPrefix(line, "config ") && strings.Contains(line, " is NXDOMAIN") {
blocked++
}
}
var pct float64
if total > 0 {
pct = float64(blocked) / float64(total) * 100
}
return &QueryStats{
TotalQueries: total,
BlockedQueries: blocked,
BlockedPercent: pct,
Period: "24h",
}
}
// SuggestedLists returns well-known blocklists for easy adding.
// Prefers dnsmasq-format lists which support wildcard subdomain blocking.
func SuggestedLists() []Blocklist {
type entry struct {
url string
name string
}
lists := []entry{
{"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts", "Steven Black - Unified Hosts"},
{"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/dnsmasq/light.txt", "Hagezi - Light"},
{"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/dnsmasq/pro.txt", "Hagezi - Pro"},
{"https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/dnsmasq/ultimate.txt", "Hagezi - Ultimate"},
{"https://big.oisd.nl/dnsmasq2", "OISD - Big"},
{"https://small.oisd.nl/dnsmasq2", "OISD - Small"},
}
result := make([]Blocklist, len(lists))
for i, l := range lists {
result[i] = Blocklist{ID: listID(l.url), URL: l.url, Name: l.name}
}
return result
}

View File

@@ -0,0 +1,89 @@
package dnsfilter
import (
"bufio"
"net"
"os"
"strings"
)
// ParseLine extracts a domain from a single blocklist line.
// Handles hosts format (0.0.0.0 domain), dnsmasq format (address=/domain/...),
// and plain domain lists. Returns empty string and false for unparseable lines.
func ParseLine(line string) (string, bool) {
line = strings.TrimSpace(line)
if line == "" || line[0] == '#' || line[0] == '!' {
return "", false
}
// dnsmasq format: address=/domain/0.0.0.0 or server=/domain/
if strings.HasPrefix(line, "address=/") || strings.HasPrefix(line, "server=/") {
parts := strings.SplitN(line, "/", 4)
if len(parts) >= 3 && parts[1] != "" {
return validateDomain(parts[1])
}
return "", false
}
// Hosts format: 0.0.0.0 domain or 127.0.0.1 domain
fields := strings.Fields(line)
if len(fields) >= 2 {
ip := fields[0]
if ip == "0.0.0.0" || ip == "127.0.0.1" || ip == "::1" || ip == "::0" {
domain := fields[1]
// Strip inline comments
if i := strings.IndexByte(domain, '#'); i >= 0 {
domain = domain[:i]
}
return validateDomain(domain)
}
}
// Plain domain format: one domain per line
if len(fields) == 1 {
return validateDomain(fields[0])
}
return "", false
}
// validateDomain checks that a string looks like a valid domain for blocking.
func validateDomain(d string) (string, bool) {
d = strings.ToLower(strings.TrimSpace(d))
if d == "" || !strings.Contains(d, ".") {
return "", false
}
// Reject known non-domains
switch d {
case "localhost", "localhost.localdomain", "local", "broadcasthost",
"ip6-localhost", "ip6-loopback", "ip6-localnet",
"ip6-mcastprefix", "ip6-allnodes", "ip6-allrouters":
return "", false
}
// Reject IP addresses
if net.ParseIP(d) != nil {
return "", false
}
return d, true
}
// ParseFile reads a cached blocklist file and returns all unique domains.
func ParseFile(path string) (map[string]struct{}, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
domains := make(map[string]struct{})
scanner := bufio.NewScanner(f)
for scanner.Scan() {
if domain, ok := ParseLine(scanner.Text()); ok {
domains[domain] = struct{}{}
}
}
return domains, scanner.Err()
}

View File

@@ -0,0 +1,141 @@
package dnsfilter
import (
"os"
"path/filepath"
"testing"
)
func TestParseLine_HostsFormat(t *testing.T) {
tests := []struct {
line string
want string
wantOK bool
}{
{"0.0.0.0 ads.example.com", "ads.example.com", true},
{"127.0.0.1 tracker.example.com", "tracker.example.com", true},
{"0.0.0.0 ads.example.com # comment", "ads.example.com", true},
{"::1 ads.example.com", "ads.example.com", true},
{"0.0.0.0 localhost", "", false},
{"0.0.0.0 broadcasthost", "", false},
{"0.0.0.0 localhost.localdomain", "", false},
{"127.0.0.1 local", "", false},
}
for _, tt := range tests {
got, ok := ParseLine(tt.line)
if ok != tt.wantOK || got != tt.want {
t.Errorf("ParseLine(%q) = (%q, %v), want (%q, %v)", tt.line, got, ok, tt.want, tt.wantOK)
}
}
}
func TestParseLine_DnsmasqFormat(t *testing.T) {
tests := []struct {
line string
want string
wantOK bool
}{
{"address=/ads.example.com/0.0.0.0", "ads.example.com", true},
{"address=/tracker.example.com/", "tracker.example.com", true},
{"server=/blocked.example.com/", "blocked.example.com", true},
{"address=//", "", false},
}
for _, tt := range tests {
got, ok := ParseLine(tt.line)
if ok != tt.wantOK || got != tt.want {
t.Errorf("ParseLine(%q) = (%q, %v), want (%q, %v)", tt.line, got, ok, tt.want, tt.wantOK)
}
}
}
func TestParseLine_PlainDomain(t *testing.T) {
tests := []struct {
line string
want string
wantOK bool
}{
{"ads.example.com", "ads.example.com", true},
{"TRACKER.Example.COM", "tracker.example.com", true},
{"nodot", "", false},
{"# comment", "", false},
{"", "", false},
{" ", "", false},
{"192.168.1.1", "", false},
}
for _, tt := range tests {
got, ok := ParseLine(tt.line)
if ok != tt.wantOK || got != tt.want {
t.Errorf("ParseLine(%q) = (%q, %v), want (%q, %v)", tt.line, got, ok, tt.want, tt.wantOK)
}
}
}
func TestParseFile(t *testing.T) {
content := `# Steven Black hosts file
0.0.0.0 localhost
0.0.0.0 ads.example.com
0.0.0.0 tracker.example.com
127.0.0.1 more-ads.example.com
# duplicate
0.0.0.0 ads.example.com
`
dir := t.TempDir()
path := filepath.Join(dir, "test.hosts")
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
domains, err := ParseFile(path)
if err != nil {
t.Fatal(err)
}
// Should have 3 unique domains (localhost excluded)
if len(domains) != 3 {
t.Errorf("got %d domains, want 3", len(domains))
}
for _, expected := range []string{"ads.example.com", "tracker.example.com", "more-ads.example.com"} {
if _, ok := domains[expected]; !ok {
t.Errorf("missing expected domain: %s", expected)
}
}
}
func TestParseFile_MixedFormats(t *testing.T) {
content := `# Mixed format file
0.0.0.0 hosts-format.example.com
address=/dnsmasq-format.example.com/0.0.0.0
plain-domain.example.com
! adblock comment
127.0.0.1 another.example.com
`
dir := t.TempDir()
path := filepath.Join(dir, "mixed.txt")
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
domains, err := ParseFile(path)
if err != nil {
t.Fatal(err)
}
if len(domains) != 4 {
t.Errorf("got %d domains, want 4", len(domains))
}
for _, expected := range []string{
"hosts-format.example.com",
"dnsmasq-format.example.com",
"plain-domain.example.com",
"another.example.com",
} {
if _, ok := domains[expected]; !ok {
t.Errorf("missing expected domain: %s", expected)
}
}
}

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()
}
}

View File

@@ -27,6 +27,7 @@ type DNSEntry struct {
// ConfigGenerator handles dnsmasq configuration generation // ConfigGenerator handles dnsmasq configuration generation
type ConfigGenerator struct { type ConfigGenerator struct {
configPath string configPath string
filterConfPath string // path to addn-hosts file for DNS filtering
} }
// NewConfigGenerator creates a new dnsmasq config generator // NewConfigGenerator creates a new dnsmasq config generator
@@ -44,6 +45,12 @@ func (g *ConfigGenerator) GetConfigPath() string {
return g.configPath return g.configPath
} }
// SetFilterConfPath sets the path to an additional hosts file for DNS filtering.
// When set, the generated config includes an addn-hosts= directive.
func (g *ConfigGenerator) SetFilterConfPath(path string) {
g.filterConfPath = path
}
// Generate creates a dnsmasq configuration from registered domain entries. // Generate creates a dnsmasq configuration from registered domain entries.
// It auto-detects the Wild Central server's IP address for the listen directive. // It auto-detects the Wild Central server's IP address for the listen directive.
func (g *ConfigGenerator) Generate(cfg *config.State, entries []DNSEntry) string { func (g *ConfigGenerator) Generate(cfg *config.State, entries []DNSEntry) string {
@@ -94,10 +101,16 @@ log-queries
log-dhcp log-dhcp
` `
return fmt.Sprintf(template, result := fmt.Sprintf(template,
dnsIP, dnsIP,
resolution_section, resolution_section,
) )
if g.filterConfPath != "" {
result += fmt.Sprintf("\n# DNS Filtering\nconf-file=%s\n", g.filterConfPath)
}
return result
} }
// RestartService reloads the dnsmasq service (SIGHUP — no downtime). // RestartService reloads the dnsmasq service (SIGHUP — no downtime).
@@ -270,6 +283,10 @@ log-dhcp
} }
} }
if g.filterConfPath != "" {
fmt.Fprintf(&sb, "\n# DNS Filtering\nconf-file=%s\n", g.filterConfPath)
}
return sb.String() return sb.String()
} }

View File

@@ -313,6 +313,43 @@ func TestGenerate_MultipleEntries(t *testing.T) {
} }
} }
func TestGenerate_ConfFile(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
g.SetFilterConfPath("/var/lib/wild-central/dns-filter/hosts.blocked")
out := g.Generate(nil, nil)
if !strings.Contains(out, "conf-file=/var/lib/wild-central/dns-filter/hosts.blocked") {
t.Errorf("expected addn-hosts directive, got:\n%s", out)
}
if !strings.Contains(out, "# DNS Filtering") {
t.Errorf("expected DNS Filtering comment, got:\n%s", out)
}
}
func TestGenerate_NoConfFileWhenEmpty(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
// addnHostsPath is empty by default
out := g.Generate(nil, nil)
if strings.Contains(out, "addn-hosts") {
t.Errorf("should not contain addn-hosts when path is empty, got:\n%s", out)
}
}
func TestGenerateMainConfig_ConfFile(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
g.SetFilterConfPath("/tmp/hosts.blocked")
cfg := &config.State{}
out := g.GenerateMainConfig(cfg)
if !strings.Contains(out, "conf-file=/tmp/hosts.blocked") {
t.Errorf("expected addn-hosts in main config, got:\n%s", out)
}
}
// Test: nil config doesn't panic, uses auto-detect for IP // Test: nil config doesn't panic, uses auto-detect for IP
func TestGenerate_NilConfig(t *testing.T) { func TestGenerate_NilConfig(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf") g := NewConfigGenerator("/tmp/test-dnsmasq.conf")

View File

@@ -123,6 +123,7 @@ func main() {
slog.Info("central status broadcaster started") slog.Info("central status broadcaster started")
api.StartDDNS(ctx) api.StartDDNS(ctx)
api.StartDNSFilter(ctx)
router := mux.NewRouter() router := mux.NewRouter()
api.RegisterRoutes(router) api.RegisterRoutes(router)

View File

@@ -0,0 +1,501 @@
import { useState, useRef } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from './ui/card';
import { Button } from './ui/button';
import { Input, Label } from './ui';
import { Alert, AlertDescription } from './ui/alert';
import { Switch } from './ui/switch';
import { Badge } from './ui/badge';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from './ui/select';
import {
ShieldBan, Loader2, Plus, Trash2, RotateCw, Upload,
CheckCircle, AlertCircle, X, ExternalLink, List,
} from 'lucide-react';
import { useDnsFilter } from '../hooks/useDnsFilter';
export function DnsFilterComponent() {
const {
status, isLoadingStatus, config,
lists, customEntries, suggestedLists,
updateConfig, isUpdatingConfig,
addList, isAddingList,
removeList, toggleList,
addCustomEntry, removeCustomEntry,
triggerUpdate, isUpdating,
uploadList, isUploading,
} = useDnsFilter();
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Add list form
const [newListUrl, setNewListUrl] = useState('');
const [newListName, setNewListName] = useState('');
const [showAddList, setShowAddList] = useState(false);
// Upload form
const [uploadName, setUploadName] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
// Custom entry form
const [showAddCustom, setShowAddCustom] = useState(false);
const [newCustomDomain, setNewCustomDomain] = useState('');
const [newCustomType, setNewCustomType] = useState<'allow' | 'block'>('block');
const showSuccess = (msg: string) => {
setSuccessMessage(msg);
setErrorMessage(null);
setTimeout(() => setSuccessMessage(null), 5000);
};
const showError = (msg: string) => {
setErrorMessage(msg);
setSuccessMessage(null);
setTimeout(() => setErrorMessage(null), 8000);
};
const handleToggleEnabled = async () => {
try {
await updateConfig({ enabled: !status?.enabled });
showSuccess(status?.enabled ? 'DNS filtering disabled' : 'DNS filtering enabled');
} catch (e) {
showError(`Failed to update config: ${e instanceof Error ? e.message : String(e)}`);
}
};
const handleAddList = async () => {
if (!newListUrl || !newListName) return;
try {
await addList({ url: newListUrl, name: newListName });
setNewListUrl('');
setNewListName('');
setShowAddList(false);
showSuccess(`Added list: ${newListName}`);
} catch (e) {
showError(`Failed to add list: ${e instanceof Error ? e.message : String(e)}`);
}
};
const handleAddSuggested = async (url: string, name: string) => {
try {
await addList({ url, name });
showSuccess(`Added list: ${name}`);
} catch (e) {
showError(`Failed to add list: ${e instanceof Error ? e.message : String(e)}`);
}
};
const handleUpload = async () => {
const file = fileInputRef.current?.files?.[0];
if (!file || !uploadName) return;
try {
await uploadList({ name: uploadName, file });
setUploadName('');
if (fileInputRef.current) fileInputRef.current.value = '';
showSuccess(`Uploaded list: ${uploadName}`);
} catch (e) {
showError(`Failed to upload: ${e instanceof Error ? e.message : String(e)}`);
}
};
const handleRemoveList = async (id: string) => {
try {
await removeList(id);
showSuccess('List removed');
} catch (e) {
showError(`Failed to remove list: ${e instanceof Error ? e.message : String(e)}`);
}
};
const handleToggleList = async (id: string, enabled: boolean) => {
try {
await toggleList({ id, enabled });
} catch (e) {
showError(`Failed to toggle list: ${e instanceof Error ? e.message : String(e)}`);
}
};
const handleAddCustom = async () => {
if (!newCustomDomain) return;
try {
await addCustomEntry({ domain: newCustomDomain, type: newCustomType });
setNewCustomDomain('');
setShowAddCustom(false);
showSuccess(`Added ${newCustomType} entry for ${newCustomDomain}`);
} catch (e) {
showError(`Failed to add entry: ${e instanceof Error ? e.message : String(e)}`);
}
};
const handleRemoveCustom = async (domain: string) => {
try {
await removeCustomEntry(domain);
showSuccess('Custom entry removed');
} catch (e) {
showError(`Failed to remove entry: ${e instanceof Error ? e.message : String(e)}`);
}
};
const handleTriggerUpdate = async () => {
try {
await triggerUpdate();
showSuccess('Update triggered — lists will refresh in the background');
} catch (e) {
showError(`Failed to trigger update: ${e instanceof Error ? e.message : String(e)}`);
}
};
// Filter suggested lists to exclude already-added ones
const addedIds = new Set(lists.map(l => l.id));
const availableSuggested = suggestedLists.filter(s => !addedIds.has(s.id));
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<div className="p-2 bg-primary/10 rounded-lg">
<ShieldBan className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">DNS Filter</h2>
<p className="text-muted-foreground">Block ads, trackers, and malware at the DNS level</p>
</div>
</div>
{/* Alerts */}
{successMessage && (
<Alert className="border-green-500 bg-green-50 dark:bg-green-900/20">
<CheckCircle className="h-4 w-4 text-green-600" />
<AlertDescription className="flex items-center justify-between">
<span className="text-green-800 dark:text-green-200">{successMessage}</span>
<Button variant="ghost" size="sm" onClick={() => setSuccessMessage(null)}><X className="h-3 w-3" /></Button>
</AlertDescription>
</Alert>
)}
{errorMessage && (
<Alert variant="error">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="flex items-center justify-between">
<span>{errorMessage}</span>
<Button variant="ghost" size="sm" onClick={() => setErrorMessage(null)}><X className="h-3 w-3" /></Button>
</AlertDescription>
</Alert>
)}
{/* Status Card */}
<Card className="border-l-4 border-l-blue-500">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Status</CardTitle>
<div className="flex items-center gap-3">
{status?.enabled && (
<Button
variant="outline"
size="sm"
onClick={handleTriggerUpdate}
disabled={isUpdating}
>
{isUpdating ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <RotateCw className="h-4 w-4 mr-1" />}
Update Now
</Button>
)}
<div className="flex items-center gap-2">
<Label htmlFor="dns-filter-toggle" className="text-sm">
{status?.enabled ? 'Enabled' : 'Disabled'}
</Label>
<Switch
id="dns-filter-toggle"
checked={status?.enabled ?? false}
onCheckedChange={handleToggleEnabled}
disabled={isUpdatingConfig || isLoadingStatus}
/>
</div>
</div>
</div>
</CardHeader>
{status?.enabled && (
<CardContent className="space-y-4">
{/* Query stats */}
{status.queryStats && (
<div className="grid grid-cols-3 gap-4">
<div className="bg-muted rounded-md p-3 text-center">
<p className="text-2xl font-bold font-mono">{status.queryStats.totalQueries.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">Queries ({status.queryStats.period})</p>
</div>
<div className="bg-muted rounded-md p-3 text-center">
<p className="text-2xl font-bold font-mono text-red-500">{status.queryStats.blockedQueries.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">Blocked</p>
</div>
<div className="bg-muted rounded-md p-3 text-center">
<p className="text-2xl font-bold font-mono text-red-500">{status.queryStats.blockedPercent.toFixed(1)}%</p>
<p className="text-xs text-muted-foreground">Block rate</p>
</div>
</div>
)}
{/* List stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="bg-muted rounded-md p-3 text-center">
<p className="text-2xl font-bold font-mono">{status.totalBlocked.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">Domains in blocklist</p>
</div>
<div className="bg-muted rounded-md p-3 text-center">
<p className="text-2xl font-bold font-mono">{status.enabledLists}</p>
<p className="text-xs text-muted-foreground">Active lists</p>
</div>
<div className="bg-muted rounded-md p-3 text-center">
<p className="text-2xl font-bold font-mono">{status.listCount}</p>
<p className="text-xs text-muted-foreground">Total lists</p>
</div>
<div className="bg-muted rounded-md p-3 text-center">
<p className="text-sm font-mono">
{status.lastUpdated ? new Date(status.lastUpdated).toLocaleString() : 'Never'}
</p>
<p className="text-xs text-muted-foreground">Last updated</p>
</div>
</div>
{config && (
<p className="text-sm text-muted-foreground">
Lists update every {config.intervalHours || 24} hours
</p>
)}
</CardContent>
)}
</Card>
{/* Blocklists Card */}
<Card className="border-l-4 border-l-green-500">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<List className="h-5 w-5" />
Blocklists
</CardTitle>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setShowAddList(!showAddList)}>
<Plus className="h-4 w-4 mr-1" />
Add List
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Add list form */}
{showAddList && (
<Card className="p-4 bg-muted/50">
<div className="space-y-3">
<h4 className="font-medium text-sm">Add blocklist by URL</h4>
<div>
<Label htmlFor="list-name">Name</Label>
<Input
id="list-name"
placeholder="e.g. My Custom Blocklist"
value={newListName}
onChange={e => setNewListName(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="list-url">URL</Label>
<Input
id="list-url"
placeholder="https://example.com/blocklist.txt"
value={newListUrl}
onChange={e => setNewListUrl(e.target.value)}
className="mt-1"
/>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={handleAddList} disabled={isAddingList || !newListUrl || !newListName}>
{isAddingList ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Plus className="h-4 w-4 mr-1" />}
Add
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowAddList(false)}>Cancel</Button>
</div>
{/* Upload */}
<div className="border-t pt-3 mt-3">
<h4 className="font-medium text-sm mb-2">Or upload a file</h4>
<div className="flex gap-2 items-end">
<div className="flex-1">
<Label htmlFor="upload-name">Name</Label>
<Input
id="upload-name"
placeholder="My uploaded list"
value={uploadName}
onChange={e => setUploadName(e.target.value)}
className="mt-1"
/>
</div>
<div className="flex-1">
<Label htmlFor="upload-file">File</Label>
<Input id="upload-file" type="file" ref={fileInputRef} accept=".txt,.hosts,.conf" className="mt-1" />
</div>
<Button size="sm" onClick={handleUpload} disabled={isUploading || !uploadName}>
{isUploading ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Upload className="h-4 w-4 mr-1" />}
Upload
</Button>
</div>
</div>
</div>
</Card>
)}
{/* Current lists */}
{lists.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<ShieldBan className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p className="font-medium">No blocklists added</p>
<p className="text-sm mt-1">Add a blocklist URL or choose from the suggestions below</p>
</div>
) : (
<div className="space-y-2">
{lists.map(list => (
<div key={list.id} className="flex items-center gap-3 p-3 bg-muted/50 rounded-md">
<Switch
checked={list.enabled}
onCheckedChange={(checked) => handleToggleList(list.id, checked)}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">{list.name}</span>
{list.entryCount > 0 && (
<Badge variant="secondary" className="text-xs">
{list.entryCount.toLocaleString()} domains
</Badge>
)}
{list.lastError && (
<Badge variant="destructive" className="text-xs">Error</Badge>
)}
</div>
{list.url && (
<p className="text-xs text-muted-foreground font-mono truncate mt-0.5">{list.url}</p>
)}
{!list.url && (
<p className="text-xs text-muted-foreground mt-0.5">Uploaded file</p>
)}
{list.lastError && (
<p className="text-xs text-red-500 mt-0.5">{list.lastError}</p>
)}
</div>
<Button variant="ghost" size="sm" onClick={() => handleRemoveList(list.id)}>
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
</Button>
</div>
))}
</div>
)}
{/* Suggested lists */}
{availableSuggested.length > 0 && (
<div className="border-t pt-4 mt-4">
<h4 className="text-sm font-medium mb-3 text-muted-foreground">Suggested lists</h4>
<div className="space-y-2">
{availableSuggested.map(suggested => (
<div key={suggested.id} className="flex items-center gap-3 p-2 rounded-md hover:bg-muted/50 transition-colors">
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">{suggested.name}</span>
{suggested.url && (
<p className="text-xs text-muted-foreground font-mono truncate">{suggested.url}</p>
)}
</div>
<div className="flex gap-1">
{suggested.url && (
<Button variant="ghost" size="sm" asChild>
<a href={suggested.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-3.5 w-3.5" />
</a>
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => handleAddSuggested(suggested.url!, suggested.name)}
disabled={isAddingList}
>
<Plus className="h-3.5 w-3.5 mr-1" />
Add
</Button>
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Custom Entries Card */}
<Card className="border-l-4 border-l-blue-500">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Custom Entries</CardTitle>
<Button variant="outline" size="sm" onClick={() => setShowAddCustom(!showAddCustom)}>
<Plus className="h-4 w-4 mr-1" />
Add Entry
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{showAddCustom && (
<Card className="p-4 bg-muted/50">
<div className="space-y-3">
<div className="flex gap-3 items-end">
<div className="flex-1">
<Label htmlFor="custom-domain">Domain</Label>
<Input
id="custom-domain"
placeholder="ads.example.com"
value={newCustomDomain}
onChange={e => setNewCustomDomain(e.target.value)}
className="mt-1 font-mono"
/>
</div>
<div className="w-32">
<Label htmlFor="custom-type">Type</Label>
<Select value={newCustomType} onValueChange={(v) => setNewCustomType(v as 'allow' | 'block')}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="block">Block</SelectItem>
<SelectItem value="allow">Allow</SelectItem>
</SelectContent>
</Select>
</div>
<Button size="sm" onClick={handleAddCustom} disabled={!newCustomDomain}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowAddCustom(false)}>Cancel</Button>
</div>
<p className="text-xs text-muted-foreground">
<strong>Block</strong> adds a domain to the blocklist. <strong>Allow</strong> overrides blocklists to permit a specific domain.
</p>
</div>
</Card>
)}
{customEntries.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No custom entries. Use allow entries to override blocklists for specific domains.
</p>
) : (
<div className="space-y-1">
{customEntries.map(entry => (
<div key={entry.domain} className="flex items-center gap-3 p-2 rounded-md hover:bg-muted/50">
<Badge variant={entry.type === 'block' ? 'destructive' : 'default'} className="text-xs w-14 justify-center">
{entry.type}
</Badge>
<span className="flex-1 font-mono text-sm">{entry.domain}</span>
<Button variant="ghost" size="sm" onClick={() => handleRemoveCustom(entry.domain)}>
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,101 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { dnsFilterApi, type CustomEntry } from '../services/api/dnsfilter';
export function useDnsFilter() {
const queryClient = useQueryClient();
const statusQuery = useQuery({
queryKey: ['dns-filter', 'status'],
queryFn: () => dnsFilterApi.getStatus(),
});
const configQuery = useQuery({
queryKey: ['dns-filter', 'config'],
queryFn: () => dnsFilterApi.getConfig(),
});
const listsQuery = useQuery({
queryKey: ['dns-filter', 'lists'],
queryFn: () => dnsFilterApi.getLists(),
});
const customQuery = useQuery({
queryKey: ['dns-filter', 'custom'],
queryFn: () => dnsFilterApi.getCustomEntries(),
});
const suggestedQuery = useQuery({
queryKey: ['dns-filter', 'suggested'],
queryFn: () => dnsFilterApi.getSuggested(),
staleTime: Infinity,
});
const invalidateAll = () => {
queryClient.invalidateQueries({ queryKey: ['dns-filter'] });
};
const updateConfigMutation = useMutation({
mutationFn: dnsFilterApi.updateConfig,
onSuccess: invalidateAll,
});
const addListMutation = useMutation({
mutationFn: (data: { url: string; name: string }) => dnsFilterApi.addList(data),
onSuccess: invalidateAll,
});
const removeListMutation = useMutation({
mutationFn: (id: string) => dnsFilterApi.removeList(id),
onSuccess: invalidateAll,
});
const toggleListMutation = useMutation({
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => dnsFilterApi.toggleList(id, enabled),
onSuccess: invalidateAll,
});
const addCustomMutation = useMutation({
mutationFn: (entry: CustomEntry) => dnsFilterApi.setCustomEntry(entry),
onSuccess: invalidateAll,
});
const removeCustomMutation = useMutation({
mutationFn: (domain: string) => dnsFilterApi.removeCustomEntry(domain),
onSuccess: invalidateAll,
});
const triggerUpdateMutation = useMutation({
mutationFn: () => dnsFilterApi.triggerUpdate(),
onSuccess: () => {
// Delay invalidation to give the runner time to download + compile
setTimeout(invalidateAll, 3000);
},
});
const uploadListMutation = useMutation({
mutationFn: ({ name, file }: { name: string; file: File }) => dnsFilterApi.uploadList(name, file),
onSuccess: invalidateAll,
});
return {
status: statusQuery.data,
isLoadingStatus: statusQuery.isLoading,
config: configQuery.data,
lists: listsQuery.data?.lists ?? [],
customEntries: customQuery.data?.customEntries ?? [],
suggestedLists: suggestedQuery.data?.lists ?? [],
updateConfig: updateConfigMutation.mutateAsync,
isUpdatingConfig: updateConfigMutation.isPending,
addList: addListMutation.mutateAsync,
isAddingList: addListMutation.isPending,
removeList: removeListMutation.mutateAsync,
toggleList: toggleListMutation.mutateAsync,
addCustomEntry: addCustomMutation.mutateAsync,
removeCustomEntry: removeCustomMutation.mutateAsync,
triggerUpdate: triggerUpdateMutation.mutateAsync,
isUpdating: triggerUpdateMutation.isPending,
uploadList: uploadListMutation.mutateAsync,
isUploading: uploadListMutation.isPending,
refetch: invalidateAll,
};
}

View File

@@ -0,0 +1,10 @@
import { ErrorBoundary } from '../../components';
import { DnsFilterComponent } from '../../components/DnsFilterComponent';
export function DnsFilterPage() {
return (
<ErrorBoundary>
<DnsFilterComponent />
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,94 @@
import { apiClient } from './client';
import { getApiBaseUrl } from './config';
export interface QueryStats {
totalQueries: number;
blockedQueries: number;
blockedPercent: number;
period: string;
}
export interface DNSFilterStats {
enabled: boolean;
totalBlocked: number;
listCount: number;
enabledLists: number;
lastUpdated: string;
queryStats?: QueryStats;
}
export interface DNSFilterConfig {
enabled: boolean;
intervalHours: number;
}
export interface Blocklist {
id: string;
url?: string;
name: string;
enabled: boolean;
entryCount: number;
lastUpdated: string;
lastError?: string;
}
export interface CustomEntry {
domain: string;
type: 'allow' | 'block';
}
export const dnsFilterApi = {
getStatus: () =>
apiClient.get<DNSFilterStats>('/api/v1/dns-filter/status'),
getConfig: () =>
apiClient.get<DNSFilterConfig>('/api/v1/dns-filter/config'),
updateConfig: (data: Partial<DNSFilterConfig>) =>
apiClient.put<{ message: string }>('/api/v1/dns-filter/config', data),
getLists: () =>
apiClient.get<{ lists: Blocklist[] }>('/api/v1/dns-filter/lists'),
addList: (data: { url: string; name: string }) =>
apiClient.post<{ message: string }>('/api/v1/dns-filter/lists', data),
removeList: (id: string) =>
apiClient.delete<{ message: string }>(`/api/v1/dns-filter/lists/${id}`),
toggleList: (id: string, enabled: boolean) =>
apiClient.put<{ message: string }>(`/api/v1/dns-filter/lists/${id}/toggle`, { enabled }),
getCustomEntries: () =>
apiClient.get<{ customEntries: CustomEntry[] }>('/api/v1/dns-filter/custom'),
setCustomEntry: (entry: CustomEntry) =>
apiClient.post<{ message: string }>('/api/v1/dns-filter/custom', entry),
removeCustomEntry: (domain: string) =>
apiClient.delete<{ message: string }>(`/api/v1/dns-filter/custom/${domain}`),
triggerUpdate: () =>
apiClient.post<{ message: string }>('/api/v1/dns-filter/update'),
getSuggested: () =>
apiClient.get<{ lists: Blocklist[] }>('/api/v1/dns-filter/lists/suggested'),
uploadList: async (name: string, file: File): Promise<{ id: string; message: string }> => {
const formData = new FormData();
formData.append('name', name);
formData.append('file', file);
const response = await fetch(`${getApiBaseUrl()}/api/v1/dns-filter/lists/upload`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `HTTP ${response.status}`);
}
return response.json();
},
};