229 lines
6.2 KiB
Go
229 lines
6.2 KiB
Go
package blacklist
|
|
|
|
import (
|
|
"bufio"
|
|
"io"
|
|
"net/netip"
|
|
"strings"
|
|
|
|
"github.com/owen/vibedns/internal/models"
|
|
"github.com/owen/vibedns/internal/validate"
|
|
)
|
|
|
|
// ParsedDomain is one domain extracted from an import.
|
|
type ParsedDomain struct {
|
|
Domain string
|
|
MatchSubdomains bool
|
|
}
|
|
|
|
// ParseOptions tunes bulk import behaviour.
|
|
type ParseOptions struct {
|
|
// DefaultMatchSubdomains sets the match mode for entries that do not carry
|
|
// explicit wildcard syntax.
|
|
DefaultMatchSubdomains bool
|
|
// MaxInvalidSamples caps how many rejected lines are reported back.
|
|
MaxInvalidSamples int
|
|
}
|
|
|
|
// hostsPlaceholders are the addresses a hosts-file blocklist points at. Lines
|
|
// using any other address are still parsed, but these are the common ones.
|
|
var localhostNames = map[string]bool{
|
|
"localhost": true,
|
|
"localhost.localdomain": true,
|
|
"local": true,
|
|
"ip6-localhost": true,
|
|
"ip6-loopback": true,
|
|
"ip6-localnet": true,
|
|
"ip6-mcastprefix": true,
|
|
"ip6-allnodes": true,
|
|
"ip6-allrouters": true,
|
|
"ip6-allhosts": true,
|
|
"broadcasthost": true,
|
|
}
|
|
|
|
// Parse reads a domain list and returns the normalised, de-duplicated domains
|
|
// alongside a summary of what happened to every line.
|
|
//
|
|
// It accepts three shapes, mixed freely in one file:
|
|
//
|
|
// example.com plain list
|
|
// 0.0.0.0 ads.example.com hosts file
|
|
// ||tracker.example.net^ Adblock-style host rule
|
|
//
|
|
// Comments (#, ;, !), blank lines, IP-only lines and localhost entries are
|
|
// ignored. Everything that survives is normalised to lowercase without a
|
|
// trailing dot.
|
|
func Parse(r io.Reader, opts ParseOptions) ([]ParsedDomain, models.ImportSummary) {
|
|
if opts.MaxInvalidSamples <= 0 {
|
|
opts.MaxInvalidSamples = 10
|
|
}
|
|
var summary models.ImportSummary
|
|
// Pre-size for a large list; growth is amortised anyway.
|
|
out := make([]ParsedDomain, 0, 1024)
|
|
seen := make(map[string]struct{}, 1024)
|
|
|
|
sc := bufio.NewScanner(r)
|
|
// Blocklist lines are short, but a stray long line must not abort the scan.
|
|
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
|
|
for sc.Scan() {
|
|
summary.LinesProcessed++
|
|
line := strings.TrimSpace(sc.Text())
|
|
|
|
if line == "" || isComment(line) {
|
|
summary.Ignored++
|
|
continue
|
|
}
|
|
line = stripTrailingComment(line)
|
|
if line == "" {
|
|
summary.Ignored++
|
|
continue
|
|
}
|
|
|
|
candidates, wildcard, ok := extractCandidates(line)
|
|
if !ok {
|
|
summary.Ignored++
|
|
continue
|
|
}
|
|
if len(candidates) == 0 {
|
|
summary.Invalid++
|
|
addSample(&summary, line, opts.MaxInvalidSamples)
|
|
continue
|
|
}
|
|
|
|
anyValid := false
|
|
for _, c := range candidates {
|
|
domain, err := validate.NormaliseDomain(c)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if localhostNames[domain] || !strings.Contains(domain, ".") {
|
|
// Single-label names are hosts-file noise, not blockable domains.
|
|
continue
|
|
}
|
|
if isIPLiteral(domain) {
|
|
continue
|
|
}
|
|
anyValid = true
|
|
if _, dup := seen[domain]; dup {
|
|
summary.Duplicates++
|
|
continue
|
|
}
|
|
seen[domain] = struct{}{}
|
|
out = append(out, ParsedDomain{
|
|
Domain: domain,
|
|
MatchSubdomains: wildcard || opts.DefaultMatchSubdomains,
|
|
})
|
|
summary.Imported++
|
|
}
|
|
if !anyValid {
|
|
summary.Invalid++
|
|
addSample(&summary, line, opts.MaxInvalidSamples)
|
|
}
|
|
}
|
|
if err := sc.Err(); err != nil {
|
|
// A read failure still returns what was parsed so far; the caller
|
|
// decides whether a partial import is acceptable.
|
|
return out, summary
|
|
}
|
|
return out, summary
|
|
}
|
|
|
|
func addSample(s *models.ImportSummary, line string, max int) {
|
|
if len(s.InvalidSamples) >= max {
|
|
return
|
|
}
|
|
if len(line) > 120 {
|
|
line = line[:120] + "..."
|
|
}
|
|
s.InvalidSamples = append(s.InvalidSamples, line)
|
|
}
|
|
|
|
func isComment(line string) bool {
|
|
switch line[0] {
|
|
case '#', ';':
|
|
return true
|
|
case '!':
|
|
// Adblock comment, but "!" never starts a host rule.
|
|
return true
|
|
case '[':
|
|
// Adblock header such as [Adblock Plus 2.0]
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// stripTrailingComment removes an inline comment while leaving the rest.
|
|
func stripTrailingComment(line string) string {
|
|
if i := strings.IndexAny(line, "#;"); i >= 0 {
|
|
line = line[:i]
|
|
}
|
|
return strings.TrimSpace(line)
|
|
}
|
|
|
|
// extractCandidates pulls the domain-shaped tokens out of one line.
|
|
//
|
|
// The bool return reports whether the line should count as "ignored" rather
|
|
// than "invalid" — used for rules this importer deliberately does not support,
|
|
// such as Adblock element-hiding or exception rules.
|
|
func extractCandidates(line string) (domains []string, wildcard bool, supported bool) {
|
|
// Adblock-style rules.
|
|
if strings.HasPrefix(line, "@@") {
|
|
return nil, false, false // exception rule: not a block entry
|
|
}
|
|
if strings.HasPrefix(line, "||") {
|
|
rest := strings.TrimPrefix(line, "||")
|
|
rest = strings.TrimSuffix(rest, "^")
|
|
rest = strings.TrimSuffix(rest, "^$all")
|
|
if i := strings.IndexAny(rest, "/^$*"); i >= 0 {
|
|
// A path or option makes this a URL rule, which DNS cannot express.
|
|
if i == 0 {
|
|
return nil, false, false
|
|
}
|
|
rest = rest[:i]
|
|
}
|
|
return []string{rest}, true, true
|
|
}
|
|
if strings.ContainsAny(line, "/$") && !strings.HasPrefix(line, "0.0.0.0") {
|
|
// dnsmasq address=/example.com/0.0.0.0
|
|
if strings.HasPrefix(line, "address=/") || strings.HasPrefix(line, "server=/") {
|
|
parts := strings.Split(line, "/")
|
|
if len(parts) >= 2 && parts[1] != "" {
|
|
return []string{parts[1]}, true, true
|
|
}
|
|
return nil, false, false
|
|
}
|
|
return nil, false, false
|
|
}
|
|
|
|
fields := strings.Fields(line)
|
|
if len(fields) == 0 {
|
|
return nil, false, false
|
|
}
|
|
|
|
// Hosts-file syntax: the first field is an IP address, the rest are names.
|
|
if isIPLiteral(fields[0]) {
|
|
if len(fields) == 1 {
|
|
return nil, false, false // an address on its own carries no domain
|
|
}
|
|
return fields[1:], false, true
|
|
}
|
|
|
|
// Plain list: one domain per line. Extra fields are treated as noise.
|
|
first := fields[0]
|
|
if strings.HasPrefix(first, "*.") {
|
|
return []string{strings.TrimPrefix(first, "*.")}, true, true
|
|
}
|
|
return []string{first}, false, true
|
|
}
|
|
|
|
func isIPLiteral(s string) bool {
|
|
_, err := netip.ParseAddr(s)
|
|
return err == nil
|
|
}
|
|
|
|
// ParseString is a convenience wrapper for textarea input.
|
|
func ParseString(s string, opts ParseOptions) ([]ParsedDomain, models.ImportSummary) {
|
|
return Parse(strings.NewReader(s), opts)
|
|
}
|