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

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

View File

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