From 518cdbbce534fe3ee6b4324c2c8a2f0ad73f8fca Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Sun, 12 Jul 2026 12:44:19 +0000 Subject: [PATCH] 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). --- internal/api/v1/handlers_dnsfilter.go | 351 ++++++++++++++ internal/dnsfilter/manager.go | 550 ++++++++++++++++++++++ internal/dnsfilter/parser.go | 89 ++++ internal/dnsfilter/parser_test.go | 141 ++++++ internal/dnsfilter/runner.go | 115 +++++ internal/dnsmasq/config.go | 21 +- internal/dnsmasq/config_test.go | 37 ++ main.go | 1 + web/src/components/DnsFilterComponent.tsx | 501 ++++++++++++++++++++ web/src/hooks/useDnsFilter.ts | 101 ++++ web/src/router/pages/DnsFilterPage.tsx | 10 + web/src/services/api/dnsfilter.ts | 94 ++++ 12 files changed, 2009 insertions(+), 2 deletions(-) create mode 100644 internal/api/v1/handlers_dnsfilter.go create mode 100644 internal/dnsfilter/manager.go create mode 100644 internal/dnsfilter/parser.go create mode 100644 internal/dnsfilter/parser_test.go create mode 100644 internal/dnsfilter/runner.go create mode 100644 web/src/components/DnsFilterComponent.tsx create mode 100644 web/src/hooks/useDnsFilter.ts create mode 100644 web/src/router/pages/DnsFilterPage.tsx create mode 100644 web/src/services/api/dnsfilter.ts diff --git a/internal/api/v1/handlers_dnsfilter.go b/internal/api/v1/handlers_dnsfilter.go new file mode 100644 index 0000000..e2dd2bb --- /dev/null +++ b/internal/api/v1/handlers_dnsfilter.go @@ -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(), + }) +} diff --git a/internal/dnsfilter/manager.go b/internal/dnsfilter/manager.go new file mode 100644 index 0000000..3e4f810 --- /dev/null +++ b/internal/dnsfilter/manager.go @@ -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 is NXDOMAIN" +// (for address=/domain/) or "config 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 is NXDOMAIN" + // and addn-hosts blocks as: "/path/file 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 +} diff --git a/internal/dnsfilter/parser.go b/internal/dnsfilter/parser.go new file mode 100644 index 0000000..172e87a --- /dev/null +++ b/internal/dnsfilter/parser.go @@ -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() +} diff --git a/internal/dnsfilter/parser_test.go b/internal/dnsfilter/parser_test.go new file mode 100644 index 0000000..8d4a3ce --- /dev/null +++ b/internal/dnsfilter/parser_test.go @@ -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) + } + } +} diff --git a/internal/dnsfilter/runner.go b/internal/dnsfilter/runner.go new file mode 100644 index 0000000..4bb947a --- /dev/null +++ b/internal/dnsfilter/runner.go @@ -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() + } +} diff --git a/internal/dnsmasq/config.go b/internal/dnsmasq/config.go index 7b5933b..c6a231d 100644 --- a/internal/dnsmasq/config.go +++ b/internal/dnsmasq/config.go @@ -26,7 +26,8 @@ type DNSEntry struct { // ConfigGenerator handles dnsmasq configuration generation type ConfigGenerator struct { - configPath string + configPath string + filterConfPath string // path to addn-hosts file for DNS filtering } // NewConfigGenerator creates a new dnsmasq config generator @@ -44,6 +45,12 @@ func (g *ConfigGenerator) GetConfigPath() string { 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. // It auto-detects the Wild Central server's IP address for the listen directive. func (g *ConfigGenerator) Generate(cfg *config.State, entries []DNSEntry) string { @@ -94,10 +101,16 @@ log-queries log-dhcp ` - return fmt.Sprintf(template, + result := fmt.Sprintf(template, dnsIP, 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). @@ -270,6 +283,10 @@ log-dhcp } } + if g.filterConfPath != "" { + fmt.Fprintf(&sb, "\n# DNS Filtering\nconf-file=%s\n", g.filterConfPath) + } + return sb.String() } diff --git a/internal/dnsmasq/config_test.go b/internal/dnsmasq/config_test.go index 7da53c8..5ed7938 100644 --- a/internal/dnsmasq/config_test.go +++ b/internal/dnsmasq/config_test.go @@ -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 func TestGenerate_NilConfig(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") diff --git a/main.go b/main.go index 4f55f1b..b8ca161 100644 --- a/main.go +++ b/main.go @@ -123,6 +123,7 @@ func main() { slog.Info("central status broadcaster started") api.StartDDNS(ctx) + api.StartDNSFilter(ctx) router := mux.NewRouter() api.RegisterRoutes(router) diff --git a/web/src/components/DnsFilterComponent.tsx b/web/src/components/DnsFilterComponent.tsx new file mode 100644 index 0000000..86428b6 --- /dev/null +++ b/web/src/components/DnsFilterComponent.tsx @@ -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(null); + const [errorMessage, setErrorMessage] = useState(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(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 ( +
+ {/* Header */} +
+
+ +
+
+

DNS Filter

+

Block ads, trackers, and malware at the DNS level

+
+
+ + {/* Alerts */} + {successMessage && ( + + + + {successMessage} + + + + )} + {errorMessage && ( + + + + {errorMessage} + + + + )} + + {/* Status Card */} + + +
+ Status +
+ {status?.enabled && ( + + )} +
+ + +
+
+
+
+ {status?.enabled && ( + + {/* Query stats */} + {status.queryStats && ( +
+
+

{status.queryStats.totalQueries.toLocaleString()}

+

Queries ({status.queryStats.period})

+
+
+

{status.queryStats.blockedQueries.toLocaleString()}

+

Blocked

+
+
+

{status.queryStats.blockedPercent.toFixed(1)}%

+

Block rate

+
+
+ )} + + {/* List stats */} +
+
+

{status.totalBlocked.toLocaleString()}

+

Domains in blocklist

+
+
+

{status.enabledLists}

+

Active lists

+
+
+

{status.listCount}

+

Total lists

+
+
+

+ {status.lastUpdated ? new Date(status.lastUpdated).toLocaleString() : 'Never'} +

+

Last updated

+
+
+ {config && ( +

+ Lists update every {config.intervalHours || 24} hours +

+ )} +
+ )} +
+ + {/* Blocklists Card */} + + +
+ + + Blocklists + +
+ +
+
+
+ + {/* Add list form */} + {showAddList && ( + +
+

Add blocklist by URL

+
+ + setNewListName(e.target.value)} + className="mt-1" + /> +
+
+ + setNewListUrl(e.target.value)} + className="mt-1" + /> +
+
+ + +
+ + {/* Upload */} +
+

Or upload a file

+
+
+ + setUploadName(e.target.value)} + className="mt-1" + /> +
+
+ + +
+ +
+
+
+
+ )} + + {/* Current lists */} + {lists.length === 0 ? ( +
+ +

No blocklists added

+

Add a blocklist URL or choose from the suggestions below

+
+ ) : ( +
+ {lists.map(list => ( +
+ handleToggleList(list.id, checked)} + /> +
+
+ {list.name} + {list.entryCount > 0 && ( + + {list.entryCount.toLocaleString()} domains + + )} + {list.lastError && ( + Error + )} +
+ {list.url && ( +

{list.url}

+ )} + {!list.url && ( +

Uploaded file

+ )} + {list.lastError && ( +

{list.lastError}

+ )} +
+ +
+ ))} +
+ )} + + {/* Suggested lists */} + {availableSuggested.length > 0 && ( +
+

Suggested lists

+
+ {availableSuggested.map(suggested => ( +
+
+ {suggested.name} + {suggested.url && ( +

{suggested.url}

+ )} +
+
+ {suggested.url && ( + + )} + +
+
+ ))} +
+
+ )} +
+
+ + {/* Custom Entries Card */} + + +
+ Custom Entries + +
+
+ + {showAddCustom && ( + +
+
+
+ + setNewCustomDomain(e.target.value)} + className="mt-1 font-mono" + /> +
+
+ + +
+ + +
+

+ Block adds a domain to the blocklist. Allow overrides blocklists to permit a specific domain. +

+
+
+ )} + + {customEntries.length === 0 ? ( +

+ No custom entries. Use allow entries to override blocklists for specific domains. +

+ ) : ( +
+ {customEntries.map(entry => ( +
+ + {entry.type} + + {entry.domain} + +
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/web/src/hooks/useDnsFilter.ts b/web/src/hooks/useDnsFilter.ts new file mode 100644 index 0000000..b3c2a5a --- /dev/null +++ b/web/src/hooks/useDnsFilter.ts @@ -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, + }; +} diff --git a/web/src/router/pages/DnsFilterPage.tsx b/web/src/router/pages/DnsFilterPage.tsx new file mode 100644 index 0000000..66627dc --- /dev/null +++ b/web/src/router/pages/DnsFilterPage.tsx @@ -0,0 +1,10 @@ +import { ErrorBoundary } from '../../components'; +import { DnsFilterComponent } from '../../components/DnsFilterComponent'; + +export function DnsFilterPage() { + return ( + + + + ); +} diff --git a/web/src/services/api/dnsfilter.ts b/web/src/services/api/dnsfilter.ts new file mode 100644 index 0000000..c49cb51 --- /dev/null +++ b/web/src/services/api/dnsfilter.ts @@ -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('/api/v1/dns-filter/status'), + + getConfig: () => + apiClient.get('/api/v1/dns-filter/config'), + + updateConfig: (data: Partial) => + 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(); + }, +};