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:
351
internal/api/v1/handlers_dnsfilter.go
Normal file
351
internal/api/v1/handlers_dnsfilter.go
Normal 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(),
|
||||
})
|
||||
}
|
||||
550
internal/dnsfilter/manager.go
Normal file
550
internal/dnsfilter/manager.go
Normal 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
|
||||
}
|
||||
89
internal/dnsfilter/parser.go
Normal file
89
internal/dnsfilter/parser.go
Normal 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()
|
||||
}
|
||||
141
internal/dnsfilter/parser_test.go
Normal file
141
internal/dnsfilter/parser_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
115
internal/dnsfilter/runner.go
Normal file
115
internal/dnsfilter/runner.go
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user