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
|
// ConfigGenerator handles dnsmasq configuration generation
|
||||||
type ConfigGenerator struct {
|
type ConfigGenerator struct {
|
||||||
configPath string
|
configPath string
|
||||||
|
filterConfPath string // path to addn-hosts file for DNS filtering
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConfigGenerator creates a new dnsmasq config generator
|
// NewConfigGenerator creates a new dnsmasq config generator
|
||||||
@@ -44,6 +45,12 @@ func (g *ConfigGenerator) GetConfigPath() string {
|
|||||||
return g.configPath
|
return g.configPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetFilterConfPath sets the path to an additional hosts file for DNS filtering.
|
||||||
|
// When set, the generated config includes an addn-hosts= directive.
|
||||||
|
func (g *ConfigGenerator) SetFilterConfPath(path string) {
|
||||||
|
g.filterConfPath = path
|
||||||
|
}
|
||||||
|
|
||||||
// Generate creates a dnsmasq configuration from registered domain entries.
|
// Generate creates a dnsmasq configuration from registered domain entries.
|
||||||
// It auto-detects the Wild Central server's IP address for the listen directive.
|
// It auto-detects the Wild Central server's IP address for the listen directive.
|
||||||
func (g *ConfigGenerator) Generate(cfg *config.State, entries []DNSEntry) string {
|
func (g *ConfigGenerator) Generate(cfg *config.State, entries []DNSEntry) string {
|
||||||
@@ -94,10 +101,16 @@ log-queries
|
|||||||
log-dhcp
|
log-dhcp
|
||||||
`
|
`
|
||||||
|
|
||||||
return fmt.Sprintf(template,
|
result := fmt.Sprintf(template,
|
||||||
dnsIP,
|
dnsIP,
|
||||||
resolution_section,
|
resolution_section,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if g.filterConfPath != "" {
|
||||||
|
result += fmt.Sprintf("\n# DNS Filtering\nconf-file=%s\n", g.filterConfPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// RestartService reloads the dnsmasq service (SIGHUP — no downtime).
|
// RestartService reloads the dnsmasq service (SIGHUP — no downtime).
|
||||||
@@ -270,6 +283,10 @@ log-dhcp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if g.filterConfPath != "" {
|
||||||
|
fmt.Fprintf(&sb, "\n# DNS Filtering\nconf-file=%s\n", g.filterConfPath)
|
||||||
|
}
|
||||||
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -313,6 +313,43 @@ func TestGenerate_MultipleEntries(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGenerate_ConfFile(t *testing.T) {
|
||||||
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
|
g.SetFilterConfPath("/var/lib/wild-central/dns-filter/hosts.blocked")
|
||||||
|
|
||||||
|
out := g.Generate(nil, nil)
|
||||||
|
|
||||||
|
if !strings.Contains(out, "conf-file=/var/lib/wild-central/dns-filter/hosts.blocked") {
|
||||||
|
t.Errorf("expected addn-hosts directive, got:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "# DNS Filtering") {
|
||||||
|
t.Errorf("expected DNS Filtering comment, got:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerate_NoConfFileWhenEmpty(t *testing.T) {
|
||||||
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
|
// addnHostsPath is empty by default
|
||||||
|
|
||||||
|
out := g.Generate(nil, nil)
|
||||||
|
|
||||||
|
if strings.Contains(out, "addn-hosts") {
|
||||||
|
t.Errorf("should not contain addn-hosts when path is empty, got:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateMainConfig_ConfFile(t *testing.T) {
|
||||||
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
|
g.SetFilterConfPath("/tmp/hosts.blocked")
|
||||||
|
|
||||||
|
cfg := &config.State{}
|
||||||
|
out := g.GenerateMainConfig(cfg)
|
||||||
|
|
||||||
|
if !strings.Contains(out, "conf-file=/tmp/hosts.blocked") {
|
||||||
|
t.Errorf("expected addn-hosts in main config, got:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Test: nil config doesn't panic, uses auto-detect for IP
|
// Test: nil config doesn't panic, uses auto-detect for IP
|
||||||
func TestGenerate_NilConfig(t *testing.T) {
|
func TestGenerate_NilConfig(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
|
|||||||
1
main.go
1
main.go
@@ -123,6 +123,7 @@ func main() {
|
|||||||
slog.Info("central status broadcaster started")
|
slog.Info("central status broadcaster started")
|
||||||
|
|
||||||
api.StartDDNS(ctx)
|
api.StartDDNS(ctx)
|
||||||
|
api.StartDNSFilter(ctx)
|
||||||
|
|
||||||
router := mux.NewRouter()
|
router := mux.NewRouter()
|
||||||
api.RegisterRoutes(router)
|
api.RegisterRoutes(router)
|
||||||
|
|||||||
501
web/src/components/DnsFilterComponent.tsx
Normal file
501
web/src/components/DnsFilterComponent.tsx
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from './ui/card';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Input, Label } from './ui';
|
||||||
|
import { Alert, AlertDescription } from './ui/alert';
|
||||||
|
import { Switch } from './ui/switch';
|
||||||
|
import { Badge } from './ui/badge';
|
||||||
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from './ui/select';
|
||||||
|
import {
|
||||||
|
ShieldBan, Loader2, Plus, Trash2, RotateCw, Upload,
|
||||||
|
CheckCircle, AlertCircle, X, ExternalLink, List,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useDnsFilter } from '../hooks/useDnsFilter';
|
||||||
|
|
||||||
|
export function DnsFilterComponent() {
|
||||||
|
const {
|
||||||
|
status, isLoadingStatus, config,
|
||||||
|
lists, customEntries, suggestedLists,
|
||||||
|
updateConfig, isUpdatingConfig,
|
||||||
|
addList, isAddingList,
|
||||||
|
removeList, toggleList,
|
||||||
|
addCustomEntry, removeCustomEntry,
|
||||||
|
triggerUpdate, isUpdating,
|
||||||
|
uploadList, isUploading,
|
||||||
|
} = useDnsFilter();
|
||||||
|
|
||||||
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Add list form
|
||||||
|
const [newListUrl, setNewListUrl] = useState('');
|
||||||
|
const [newListName, setNewListName] = useState('');
|
||||||
|
const [showAddList, setShowAddList] = useState(false);
|
||||||
|
|
||||||
|
// Upload form
|
||||||
|
const [uploadName, setUploadName] = useState('');
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Custom entry form
|
||||||
|
const [showAddCustom, setShowAddCustom] = useState(false);
|
||||||
|
const [newCustomDomain, setNewCustomDomain] = useState('');
|
||||||
|
const [newCustomType, setNewCustomType] = useState<'allow' | 'block'>('block');
|
||||||
|
|
||||||
|
const showSuccess = (msg: string) => {
|
||||||
|
setSuccessMessage(msg);
|
||||||
|
setErrorMessage(null);
|
||||||
|
setTimeout(() => setSuccessMessage(null), 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showError = (msg: string) => {
|
||||||
|
setErrorMessage(msg);
|
||||||
|
setSuccessMessage(null);
|
||||||
|
setTimeout(() => setErrorMessage(null), 8000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleEnabled = async () => {
|
||||||
|
try {
|
||||||
|
await updateConfig({ enabled: !status?.enabled });
|
||||||
|
showSuccess(status?.enabled ? 'DNS filtering disabled' : 'DNS filtering enabled');
|
||||||
|
} catch (e) {
|
||||||
|
showError(`Failed to update config: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddList = async () => {
|
||||||
|
if (!newListUrl || !newListName) return;
|
||||||
|
try {
|
||||||
|
await addList({ url: newListUrl, name: newListName });
|
||||||
|
setNewListUrl('');
|
||||||
|
setNewListName('');
|
||||||
|
setShowAddList(false);
|
||||||
|
showSuccess(`Added list: ${newListName}`);
|
||||||
|
} catch (e) {
|
||||||
|
showError(`Failed to add list: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddSuggested = async (url: string, name: string) => {
|
||||||
|
try {
|
||||||
|
await addList({ url, name });
|
||||||
|
showSuccess(`Added list: ${name}`);
|
||||||
|
} catch (e) {
|
||||||
|
showError(`Failed to add list: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
const file = fileInputRef.current?.files?.[0];
|
||||||
|
if (!file || !uploadName) return;
|
||||||
|
try {
|
||||||
|
await uploadList({ name: uploadName, file });
|
||||||
|
setUploadName('');
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
showSuccess(`Uploaded list: ${uploadName}`);
|
||||||
|
} catch (e) {
|
||||||
|
showError(`Failed to upload: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveList = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await removeList(id);
|
||||||
|
showSuccess('List removed');
|
||||||
|
} catch (e) {
|
||||||
|
showError(`Failed to remove list: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleList = async (id: string, enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
await toggleList({ id, enabled });
|
||||||
|
} catch (e) {
|
||||||
|
showError(`Failed to toggle list: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddCustom = async () => {
|
||||||
|
if (!newCustomDomain) return;
|
||||||
|
try {
|
||||||
|
await addCustomEntry({ domain: newCustomDomain, type: newCustomType });
|
||||||
|
setNewCustomDomain('');
|
||||||
|
setShowAddCustom(false);
|
||||||
|
showSuccess(`Added ${newCustomType} entry for ${newCustomDomain}`);
|
||||||
|
} catch (e) {
|
||||||
|
showError(`Failed to add entry: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveCustom = async (domain: string) => {
|
||||||
|
try {
|
||||||
|
await removeCustomEntry(domain);
|
||||||
|
showSuccess('Custom entry removed');
|
||||||
|
} catch (e) {
|
||||||
|
showError(`Failed to remove entry: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTriggerUpdate = async () => {
|
||||||
|
try {
|
||||||
|
await triggerUpdate();
|
||||||
|
showSuccess('Update triggered — lists will refresh in the background');
|
||||||
|
} catch (e) {
|
||||||
|
showError(`Failed to trigger update: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter suggested lists to exclude already-added ones
|
||||||
|
const addedIds = new Set(lists.map(l => l.id));
|
||||||
|
const availableSuggested = suggestedLists.filter(s => !addedIds.has(s.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="p-2 bg-primary/10 rounded-lg">
|
||||||
|
<ShieldBan className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold">DNS Filter</h2>
|
||||||
|
<p className="text-muted-foreground">Block ads, trackers, and malware at the DNS level</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alerts */}
|
||||||
|
{successMessage && (
|
||||||
|
<Alert className="border-green-500 bg-green-50 dark:bg-green-900/20">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||||
|
<AlertDescription className="flex items-center justify-between">
|
||||||
|
<span className="text-green-800 dark:text-green-200">{successMessage}</span>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setSuccessMessage(null)}><X className="h-3 w-3" /></Button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{errorMessage && (
|
||||||
|
<Alert variant="error">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription className="flex items-center justify-between">
|
||||||
|
<span>{errorMessage}</span>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setErrorMessage(null)}><X className="h-3 w-3" /></Button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status Card */}
|
||||||
|
<Card className="border-l-4 border-l-blue-500">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-lg">Status</CardTitle>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{status?.enabled && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleTriggerUpdate}
|
||||||
|
disabled={isUpdating}
|
||||||
|
>
|
||||||
|
{isUpdating ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <RotateCw className="h-4 w-4 mr-1" />}
|
||||||
|
Update Now
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label htmlFor="dns-filter-toggle" className="text-sm">
|
||||||
|
{status?.enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="dns-filter-toggle"
|
||||||
|
checked={status?.enabled ?? false}
|
||||||
|
onCheckedChange={handleToggleEnabled}
|
||||||
|
disabled={isUpdatingConfig || isLoadingStatus}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
{status?.enabled && (
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Query stats */}
|
||||||
|
{status.queryStats && (
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="bg-muted rounded-md p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold font-mono">{status.queryStats.totalQueries.toLocaleString()}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Queries ({status.queryStats.period})</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted rounded-md p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold font-mono text-red-500">{status.queryStats.blockedQueries.toLocaleString()}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Blocked</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted rounded-md p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold font-mono text-red-500">{status.queryStats.blockedPercent.toFixed(1)}%</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Block rate</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* List stats */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-muted rounded-md p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold font-mono">{status.totalBlocked.toLocaleString()}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Domains in blocklist</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted rounded-md p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold font-mono">{status.enabledLists}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Active lists</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted rounded-md p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold font-mono">{status.listCount}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Total lists</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted rounded-md p-3 text-center">
|
||||||
|
<p className="text-sm font-mono">
|
||||||
|
{status.lastUpdated ? new Date(status.lastUpdated).toLocaleString() : 'Never'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Last updated</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{config && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Lists update every {config.intervalHours || 24} hours
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Blocklists Card */}
|
||||||
|
<Card className="border-l-4 border-l-green-500">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<List className="h-5 w-5" />
|
||||||
|
Blocklists
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setShowAddList(!showAddList)}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Add List
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Add list form */}
|
||||||
|
{showAddList && (
|
||||||
|
<Card className="p-4 bg-muted/50">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="font-medium text-sm">Add blocklist by URL</h4>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="list-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="list-name"
|
||||||
|
placeholder="e.g. My Custom Blocklist"
|
||||||
|
value={newListName}
|
||||||
|
onChange={e => setNewListName(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="list-url">URL</Label>
|
||||||
|
<Input
|
||||||
|
id="list-url"
|
||||||
|
placeholder="https://example.com/blocklist.txt"
|
||||||
|
value={newListUrl}
|
||||||
|
onChange={e => setNewListUrl(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" onClick={handleAddList} disabled={isAddingList || !newListUrl || !newListName}>
|
||||||
|
{isAddingList ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Plus className="h-4 w-4 mr-1" />}
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setShowAddList(false)}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload */}
|
||||||
|
<div className="border-t pt-3 mt-3">
|
||||||
|
<h4 className="font-medium text-sm mb-2">Or upload a file</h4>
|
||||||
|
<div className="flex gap-2 items-end">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="upload-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="upload-name"
|
||||||
|
placeholder="My uploaded list"
|
||||||
|
value={uploadName}
|
||||||
|
onChange={e => setUploadName(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="upload-file">File</Label>
|
||||||
|
<Input id="upload-file" type="file" ref={fileInputRef} accept=".txt,.hosts,.conf" className="mt-1" />
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={handleUpload} disabled={isUploading || !uploadName}>
|
||||||
|
{isUploading ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Upload className="h-4 w-4 mr-1" />}
|
||||||
|
Upload
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current lists */}
|
||||||
|
{lists.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<ShieldBan className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||||
|
<p className="font-medium">No blocklists added</p>
|
||||||
|
<p className="text-sm mt-1">Add a blocklist URL or choose from the suggestions below</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{lists.map(list => (
|
||||||
|
<div key={list.id} className="flex items-center gap-3 p-3 bg-muted/50 rounded-md">
|
||||||
|
<Switch
|
||||||
|
checked={list.enabled}
|
||||||
|
onCheckedChange={(checked) => handleToggleList(list.id, checked)}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-sm truncate">{list.name}</span>
|
||||||
|
{list.entryCount > 0 && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{list.entryCount.toLocaleString()} domains
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{list.lastError && (
|
||||||
|
<Badge variant="destructive" className="text-xs">Error</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{list.url && (
|
||||||
|
<p className="text-xs text-muted-foreground font-mono truncate mt-0.5">{list.url}</p>
|
||||||
|
)}
|
||||||
|
{!list.url && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">Uploaded file</p>
|
||||||
|
)}
|
||||||
|
{list.lastError && (
|
||||||
|
<p className="text-xs text-red-500 mt-0.5">{list.lastError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => handleRemoveList(list.id)}>
|
||||||
|
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Suggested lists */}
|
||||||
|
{availableSuggested.length > 0 && (
|
||||||
|
<div className="border-t pt-4 mt-4">
|
||||||
|
<h4 className="text-sm font-medium mb-3 text-muted-foreground">Suggested lists</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{availableSuggested.map(suggested => (
|
||||||
|
<div key={suggested.id} className="flex items-center gap-3 p-2 rounded-md hover:bg-muted/50 transition-colors">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-sm font-medium">{suggested.name}</span>
|
||||||
|
{suggested.url && (
|
||||||
|
<p className="text-xs text-muted-foreground font-mono truncate">{suggested.url}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{suggested.url && (
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<a href={suggested.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAddSuggested(suggested.url!, suggested.name)}
|
||||||
|
disabled={isAddingList}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Custom Entries Card */}
|
||||||
|
<Card className="border-l-4 border-l-blue-500">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-lg">Custom Entries</CardTitle>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setShowAddCustom(!showAddCustom)}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Add Entry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{showAddCustom && (
|
||||||
|
<Card className="p-4 bg-muted/50">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex gap-3 items-end">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="custom-domain">Domain</Label>
|
||||||
|
<Input
|
||||||
|
id="custom-domain"
|
||||||
|
placeholder="ads.example.com"
|
||||||
|
value={newCustomDomain}
|
||||||
|
onChange={e => setNewCustomDomain(e.target.value)}
|
||||||
|
className="mt-1 font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-32">
|
||||||
|
<Label htmlFor="custom-type">Type</Label>
|
||||||
|
<Select value={newCustomType} onValueChange={(v) => setNewCustomType(v as 'allow' | 'block')}>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="block">Block</SelectItem>
|
||||||
|
<SelectItem value="allow">Allow</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={handleAddCustom} disabled={!newCustomDomain}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setShowAddCustom(false)}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
<strong>Block</strong> adds a domain to the blocklist. <strong>Allow</strong> overrides blocklists to permit a specific domain.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{customEntries.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
No custom entries. Use allow entries to override blocklists for specific domains.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{customEntries.map(entry => (
|
||||||
|
<div key={entry.domain} className="flex items-center gap-3 p-2 rounded-md hover:bg-muted/50">
|
||||||
|
<Badge variant={entry.type === 'block' ? 'destructive' : 'default'} className="text-xs w-14 justify-center">
|
||||||
|
{entry.type}
|
||||||
|
</Badge>
|
||||||
|
<span className="flex-1 font-mono text-sm">{entry.domain}</span>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => handleRemoveCustom(entry.domain)}>
|
||||||
|
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
web/src/hooks/useDnsFilter.ts
Normal file
101
web/src/hooks/useDnsFilter.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { dnsFilterApi, type CustomEntry } from '../services/api/dnsfilter';
|
||||||
|
|
||||||
|
export function useDnsFilter() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const statusQuery = useQuery({
|
||||||
|
queryKey: ['dns-filter', 'status'],
|
||||||
|
queryFn: () => dnsFilterApi.getStatus(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const configQuery = useQuery({
|
||||||
|
queryKey: ['dns-filter', 'config'],
|
||||||
|
queryFn: () => dnsFilterApi.getConfig(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const listsQuery = useQuery({
|
||||||
|
queryKey: ['dns-filter', 'lists'],
|
||||||
|
queryFn: () => dnsFilterApi.getLists(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const customQuery = useQuery({
|
||||||
|
queryKey: ['dns-filter', 'custom'],
|
||||||
|
queryFn: () => dnsFilterApi.getCustomEntries(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const suggestedQuery = useQuery({
|
||||||
|
queryKey: ['dns-filter', 'suggested'],
|
||||||
|
queryFn: () => dnsFilterApi.getSuggested(),
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidateAll = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dns-filter'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateConfigMutation = useMutation({
|
||||||
|
mutationFn: dnsFilterApi.updateConfig,
|
||||||
|
onSuccess: invalidateAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
const addListMutation = useMutation({
|
||||||
|
mutationFn: (data: { url: string; name: string }) => dnsFilterApi.addList(data),
|
||||||
|
onSuccess: invalidateAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeListMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => dnsFilterApi.removeList(id),
|
||||||
|
onSuccess: invalidateAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleListMutation = useMutation({
|
||||||
|
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => dnsFilterApi.toggleList(id, enabled),
|
||||||
|
onSuccess: invalidateAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
const addCustomMutation = useMutation({
|
||||||
|
mutationFn: (entry: CustomEntry) => dnsFilterApi.setCustomEntry(entry),
|
||||||
|
onSuccess: invalidateAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeCustomMutation = useMutation({
|
||||||
|
mutationFn: (domain: string) => dnsFilterApi.removeCustomEntry(domain),
|
||||||
|
onSuccess: invalidateAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
const triggerUpdateMutation = useMutation({
|
||||||
|
mutationFn: () => dnsFilterApi.triggerUpdate(),
|
||||||
|
onSuccess: () => {
|
||||||
|
// Delay invalidation to give the runner time to download + compile
|
||||||
|
setTimeout(invalidateAll, 3000);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadListMutation = useMutation({
|
||||||
|
mutationFn: ({ name, file }: { name: string; file: File }) => dnsFilterApi.uploadList(name, file),
|
||||||
|
onSuccess: invalidateAll,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: statusQuery.data,
|
||||||
|
isLoadingStatus: statusQuery.isLoading,
|
||||||
|
config: configQuery.data,
|
||||||
|
lists: listsQuery.data?.lists ?? [],
|
||||||
|
customEntries: customQuery.data?.customEntries ?? [],
|
||||||
|
suggestedLists: suggestedQuery.data?.lists ?? [],
|
||||||
|
updateConfig: updateConfigMutation.mutateAsync,
|
||||||
|
isUpdatingConfig: updateConfigMutation.isPending,
|
||||||
|
addList: addListMutation.mutateAsync,
|
||||||
|
isAddingList: addListMutation.isPending,
|
||||||
|
removeList: removeListMutation.mutateAsync,
|
||||||
|
toggleList: toggleListMutation.mutateAsync,
|
||||||
|
addCustomEntry: addCustomMutation.mutateAsync,
|
||||||
|
removeCustomEntry: removeCustomMutation.mutateAsync,
|
||||||
|
triggerUpdate: triggerUpdateMutation.mutateAsync,
|
||||||
|
isUpdating: triggerUpdateMutation.isPending,
|
||||||
|
uploadList: uploadListMutation.mutateAsync,
|
||||||
|
isUploading: uploadListMutation.isPending,
|
||||||
|
refetch: invalidateAll,
|
||||||
|
};
|
||||||
|
}
|
||||||
10
web/src/router/pages/DnsFilterPage.tsx
Normal file
10
web/src/router/pages/DnsFilterPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { ErrorBoundary } from '../../components';
|
||||||
|
import { DnsFilterComponent } from '../../components/DnsFilterComponent';
|
||||||
|
|
||||||
|
export function DnsFilterPage() {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<DnsFilterComponent />
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
web/src/services/api/dnsfilter.ts
Normal file
94
web/src/services/api/dnsfilter.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { apiClient } from './client';
|
||||||
|
import { getApiBaseUrl } from './config';
|
||||||
|
|
||||||
|
export interface QueryStats {
|
||||||
|
totalQueries: number;
|
||||||
|
blockedQueries: number;
|
||||||
|
blockedPercent: number;
|
||||||
|
period: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DNSFilterStats {
|
||||||
|
enabled: boolean;
|
||||||
|
totalBlocked: number;
|
||||||
|
listCount: number;
|
||||||
|
enabledLists: number;
|
||||||
|
lastUpdated: string;
|
||||||
|
queryStats?: QueryStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DNSFilterConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
intervalHours: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Blocklist {
|
||||||
|
id: string;
|
||||||
|
url?: string;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
entryCount: number;
|
||||||
|
lastUpdated: string;
|
||||||
|
lastError?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomEntry {
|
||||||
|
domain: string;
|
||||||
|
type: 'allow' | 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dnsFilterApi = {
|
||||||
|
getStatus: () =>
|
||||||
|
apiClient.get<DNSFilterStats>('/api/v1/dns-filter/status'),
|
||||||
|
|
||||||
|
getConfig: () =>
|
||||||
|
apiClient.get<DNSFilterConfig>('/api/v1/dns-filter/config'),
|
||||||
|
|
||||||
|
updateConfig: (data: Partial<DNSFilterConfig>) =>
|
||||||
|
apiClient.put<{ message: string }>('/api/v1/dns-filter/config', data),
|
||||||
|
|
||||||
|
getLists: () =>
|
||||||
|
apiClient.get<{ lists: Blocklist[] }>('/api/v1/dns-filter/lists'),
|
||||||
|
|
||||||
|
addList: (data: { url: string; name: string }) =>
|
||||||
|
apiClient.post<{ message: string }>('/api/v1/dns-filter/lists', data),
|
||||||
|
|
||||||
|
removeList: (id: string) =>
|
||||||
|
apiClient.delete<{ message: string }>(`/api/v1/dns-filter/lists/${id}`),
|
||||||
|
|
||||||
|
toggleList: (id: string, enabled: boolean) =>
|
||||||
|
apiClient.put<{ message: string }>(`/api/v1/dns-filter/lists/${id}/toggle`, { enabled }),
|
||||||
|
|
||||||
|
getCustomEntries: () =>
|
||||||
|
apiClient.get<{ customEntries: CustomEntry[] }>('/api/v1/dns-filter/custom'),
|
||||||
|
|
||||||
|
setCustomEntry: (entry: CustomEntry) =>
|
||||||
|
apiClient.post<{ message: string }>('/api/v1/dns-filter/custom', entry),
|
||||||
|
|
||||||
|
removeCustomEntry: (domain: string) =>
|
||||||
|
apiClient.delete<{ message: string }>(`/api/v1/dns-filter/custom/${domain}`),
|
||||||
|
|
||||||
|
triggerUpdate: () =>
|
||||||
|
apiClient.post<{ message: string }>('/api/v1/dns-filter/update'),
|
||||||
|
|
||||||
|
getSuggested: () =>
|
||||||
|
apiClient.get<{ lists: Blocklist[] }>('/api/v1/dns-filter/lists/suggested'),
|
||||||
|
|
||||||
|
uploadList: async (name: string, file: File): Promise<{ id: string; message: string }> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('name', name);
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const response = await fetch(`${getApiBaseUrl()}/api/v1/dns-filter/lists/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.error || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user