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(),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user