Files
wild-central/internal/dnsfilter/parser.go
Paul Payne 518cdbbce5 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).
2026-07-12 12:44:19 +00:00

90 lines
2.2 KiB
Go

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()
}