Files
Patrick Asmus 4f17f7ff81 feat!: Migration auf Go-Binary
BREAKING CHANGE: Die alte Shell-Version muss vor der Installation der Go-Version deinstalliert werden.
2026-05-01 00:08:57 +02:00

1222 lines
33 KiB
Go

package daemon
import (
"bytes"
"context"
"crypto/tls"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"adguard-shield/internal/appinfo"
"adguard-shield/internal/config"
"adguard-shield/internal/db"
"adguard-shield/internal/firewall"
"adguard-shield/internal/geoip"
"adguard-shield/internal/syslog"
)
type Daemon struct {
Config *config.Config
Store *db.Store
FW *firewall.Firewall
Geo *geoip.Resolver
Client *http.Client
Logger *syslog.Logger
mu sync.Mutex
seen map[string]time.Time
events []queryEvent
geoSeen map[string]bool
wl map[string]bool
serviceMu sync.Mutex
serviceStartNotified bool
serviceStopNotified bool
}
type queryLogResponse struct {
Data []queryItem `json:"data"`
}
type queryItem struct {
Time string `json:"time"`
Client string `json:"client"`
ClientProto string `json:"client_proto"`
ClientInfo struct {
IP string `json:"ip"`
} `json:"client_info"`
Question struct {
Name string `json:"name"`
Host string `json:"host"`
} `json:"question"`
}
type queryEvent struct {
At time.Time
Client string
Domain string
Protocol string
}
func New(c *config.Config) (*Daemon, error) {
if err := os.MkdirAll(c.StateDir, 0755); err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Dir(c.LogFile), 0755); err != nil {
return nil, err
}
st, err := db.Open(c.DBPath())
if err != nil {
return nil, err
}
logFile, err := os.OpenFile(c.LogFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return nil, err
}
logger := syslog.New(io.MultiWriter(os.Stderr, logFile), c.LogLevel)
fw := firewall.New(firewall.OSExecutor{}, c.Chain, c.BlockedPorts, c.FirewallMode, c.DryRun)
tr := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
d := &Daemon{
Config: c, Store: st, FW: fw, Logger: logger,
Client: &http.Client{Timeout: 20 * time.Second, Transport: tr},
seen: map[string]time.Time{}, geoSeen: map[string]bool{},
}
d.Geo = geoip.New(c.GeoIPMMDBPath, c.GeoIPLicenseKey, filepath.Join(filepath.Dir(c.Path), "geoip"), c.GeoIPCacheTTL, st)
return d, nil
}
func (d *Daemon) Close() {
if d.Geo != nil {
_ = d.Geo.Close()
}
if d.Store != nil {
_ = d.Store.Close()
}
}
func (d *Daemon) Run(ctx context.Context) error {
d.info("AdGuard Shield Go-Daemon gestartet")
d.info("Konfiguration: Limit %d Anfragen/%ds, Polling alle %ds, Dry-Run: %v", d.Config.RateLimitMaxRequests, d.Config.RateLimitWindow, d.Config.CheckInterval, d.Config.DryRun)
d.info("Module: GeoIP=%v, externe Blocklist=%v, externe Whitelist=%v, Progressive-Ban=%v", d.Config.GeoIPEnabled, d.Config.ExternalBlocklistEnabled, d.Config.ExternalWhitelistEnabled, d.Config.ProgressiveBanEnabled)
d.NotifyServiceStart(context.Background())
defer d.NotifyServiceStop(context.Background())
if err := d.FW.Setup(ctx); err != nil {
d.warn("Firewall Setup Warnung: %v", err)
}
if err := d.Geo.Open(ctx); err != nil && d.Config.GeoIPEnabled {
d.warn("GeoIP Warnung: %v", err)
}
if err := d.loadCaches(); err != nil {
return err
}
if err := d.AutoUnbanGeoIP(ctx); err != nil {
d.warn("GeoIP Auto-Unban Warnung: %v", err)
}
if err := d.reconcileFirewall(ctx); err != nil {
d.warn("Firewall Reconcile Warnung: %v", err)
}
d.runJob(ctx, "external-whitelist", d.Config.ExternalWhitelistEnabled, time.Duration(d.Config.ExternalWhitelistInterval)*time.Second, d.SyncWhitelist)
d.runJob(ctx, "external-blocklist", d.Config.ExternalBlocklistEnabled, time.Duration(d.Config.ExternalBlocklistInterval)*time.Second, d.SyncBlocklist)
d.runJob(ctx, "offense-cleanup", d.Config.ProgressiveBanEnabled, time.Hour, func(ctx context.Context) error {
n, err := d.Store.CleanupOffenses(d.Config.ProgressiveBanResetAfter)
if n > 0 {
d.info("Offense-Cleanup: %d abgelaufene Zähler entfernt", n)
}
return err
})
ticker := time.NewTicker(time.Duration(d.Config.CheckInterval) * time.Second)
defer ticker.Stop()
for {
if err := d.pollOnce(ctx); err != nil {
d.error("Poll Fehler: %v", err)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
}
func (d *Daemon) runJob(ctx context.Context, name string, enabled bool, interval time.Duration, fn func(context.Context) error) {
if !enabled || interval <= 0 {
d.debug("Worker %s deaktiviert", name)
return
}
go func() {
d.info("Worker %s gestartet (Intervall: %s)", name, interval)
if err := fn(ctx); err != nil {
d.error("%s Fehler: %v", name, err)
} else {
d.debug("%s Lauf abgeschlossen", name)
}
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
d.info("Worker %s gestoppt", name)
return
case <-t.C:
if err := fn(ctx); err != nil {
d.error("%s Fehler: %v", name, err)
} else {
d.debug("%s Lauf abgeschlossen", name)
}
}
}
}()
}
func (d *Daemon) loadCaches() error {
wl, err := d.Store.AllWhitelist()
if err != nil {
return err
}
d.wl = wl
for _, ip := range d.Config.Whitelist {
d.wl[ip] = true
}
return nil
}
func (d *Daemon) reconcileFirewall(ctx context.Context) error {
now := time.Now().Unix()
expired, err := d.Store.ExpiredBans(now)
if err != nil {
return err
}
for _, ip := range expired {
_ = d.Unban(ctx, ip, "expired")
}
bans, err := d.Store.ActiveBans()
if err != nil {
return err
}
for _, b := range bans {
timeout := int64(0)
if !b.Permanent && b.BanUntil > now {
timeout = b.BanUntil - now
}
_ = d.FW.Add(ctx, b.IP, timeout)
}
return nil
}
func (d *Daemon) pollOnce(ctx context.Context) error {
entries, err := d.FetchQueryLog(ctx)
if err != nil {
return err
}
events := d.toEvents(entries)
d.mu.Lock()
for _, ev := range events {
key := ev.At.Format(time.RFC3339Nano) + "|" + ev.Client + "|" + ev.Domain + "|" + ev.Protocol
if _, ok := d.seen[key]; ok {
continue
}
d.seen[key] = ev.At
d.events = append(d.events, ev)
if d.Config.GeoIPEnabled {
d.debug("GeoIP-Prüfung geplant: %s", ev.Client)
go d.checkGeoIP(context.Background(), ev.Client)
}
}
d.pruneLocked()
snapshot := append([]queryEvent(nil), d.events...)
d.mu.Unlock()
return d.analyze(ctx, snapshot)
}
func (d *Daemon) FetchQueryLog(ctx context.Context) ([]queryItem, error) {
url := strings.TrimRight(d.Config.AdGuardURL, "/") + "/control/querylog?limit=" + strconv.Itoa(d.Config.APIQueryLimit) + "&response_status=all"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if d.Config.AdGuardUser != "" || d.Config.AdGuardPass != "" {
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(d.Config.AdGuardUser+":"+d.Config.AdGuardPass)))
}
resp, err := d.Client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("AdGuard API HTTP %d", resp.StatusCode)
}
var out queryLogResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return out.Data, nil
}
func (d *Daemon) toEvents(items []queryItem) []queryEvent {
var out []queryEvent
for _, it := range items {
t, err := parseAGHTime(it.Time)
if err != nil {
continue
}
client := strings.TrimSpace(it.Client)
if client == "" {
client = strings.TrimSpace(it.ClientInfo.IP)
}
domain := strings.TrimSuffix(strings.ToLower(firstNonEmpty(it.Question.Name, it.Question.Host)), ".")
if client == "" || domain == "" {
continue
}
proto := it.ClientProto
if proto == "" {
proto = "dns"
}
out = append(out, queryEvent{At: t, Client: client, Domain: domain, Protocol: proto})
}
return out
}
func (d *Daemon) ToEventsForCommand(items []queryItem) []string {
seen := map[string]bool{}
var out []string
for _, ev := range d.toEvents(items) {
if seen[ev.Client] {
continue
}
seen[ev.Client] = true
out = append(out, ev.Client)
}
return out
}
func (d *Daemon) CheckGeoIPForCommand(ctx context.Context, ip string) {
d.checkGeoIP(ctx, ip)
}
func parseAGHTime(s string) (time.Time, error) {
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
return t, nil
}
return time.Parse(time.RFC3339, s)
}
func (d *Daemon) pruneLocked() {
maxWindow := max(d.Config.RateLimitWindow, d.Config.SubdomainFloodWindow)
cut := time.Now().Add(-time.Duration(maxWindow+30) * time.Second)
var kept []queryEvent
for _, ev := range d.events {
if ev.At.After(cut) {
kept = append(kept, ev)
}
}
d.events = kept
for k, t := range d.seen {
if t.Before(cut) {
delete(d.seen, k)
}
}
}
func (d *Daemon) analyze(ctx context.Context, events []queryEvent) error {
now := time.Now()
rateCut := now.Add(-time.Duration(d.Config.RateLimitWindow) * time.Second)
counts := map[string]int{}
protos := map[string]string{}
for _, ev := range events {
if ev.At.Before(rateCut) {
continue
}
key := ev.Client + "|" + ev.Domain
counts[key]++
protos[key] = ev.Protocol
}
for key, count := range counts {
if count <= d.Config.RateLimitMaxRequests {
continue
}
parts := strings.SplitN(key, "|", 2)
reason := "rate-limit"
if d.watchlisted(parts[1]) {
reason = "dns-flood-watchlist"
}
perm := reason == "dns-flood-watchlist"
if err := d.Ban(ctx, parts[0], parts[1], count, protos[key], reason, "monitor", "", perm); err != nil {
d.error("Ban Fehler: %v", err)
}
}
if d.Config.SubdomainFloodEnabled {
d.analyzeSubdomains(ctx, events, now)
}
return nil
}
func (d *Daemon) analyzeSubdomains(ctx context.Context, events []queryEvent, now time.Time) {
cut := now.Add(-time.Duration(d.Config.SubdomainFloodWindow) * time.Second)
sets := map[string]map[string]bool{}
for _, ev := range events {
if ev.At.Before(cut) {
continue
}
base := baseDomain(ev.Domain)
if base == "" || base == ev.Domain {
continue
}
key := ev.Client + "|" + base
if sets[key] == nil {
sets[key] = map[string]bool{}
}
sets[key][ev.Domain] = true
}
for key, set := range sets {
if len(set) <= d.Config.SubdomainFloodMaxUnique {
continue
}
parts := strings.SplitN(key, "|", 2)
reason := "subdomain-flood"
perm := d.watchlisted(parts[1])
if perm {
reason = "dns-flood-watchlist"
}
_ = d.Ban(ctx, parts[0], "*."+parts[1], len(set), "dns", reason, "monitor", "", perm)
}
}
func (d *Daemon) checkGeoIP(ctx context.Context, ip string) {
if d.Config.GeoIPSkipPrivate && geoip.IsPrivateIP(ip) {
return
}
d.mu.Lock()
if d.geoSeen[ip] {
d.mu.Unlock()
return
}
d.geoSeen[ip] = true
d.mu.Unlock()
if d.isWhitelisted(ip) {
return
}
exists, _ := d.Store.BanExists(ip)
if exists {
return
}
cc, err := d.Geo.Lookup(ip)
if err != nil || cc == "" {
return
}
if geoip.ShouldBlock(cc, d.Config.GeoIPMode, d.Config.GeoIPCountries) {
_ = d.Ban(ctx, ip, "GeoIP:"+cc, 0, "-", "geoip", "geoip", cc, true)
}
}
func (d *Daemon) Ban(ctx context.Context, ip, domain string, count int, proto, reason, source, country string, permanent bool) error {
if d.isWhitelisted(ip) {
return nil
}
exists, err := d.Store.BanExists(ip)
if err != nil || exists {
return err
}
if d.Config.DryRun {
_ = d.Store.History("DRY", ip, domain, strconv.Itoa(count), "dry-run", proto, "dry-run ("+reason+")")
d.warn("[DRY-RUN] Würde sperren: %s (%s, %s)", ip, reason, domain)
return nil
}
duration := d.Config.BanDuration
level := 0
if source == "monitor" && d.Config.ProgressiveBanEnabled && !permanent {
level, err = d.Store.IncrementOffense(ip, d.Config.ProgressiveBanResetAfter)
if err != nil {
return err
}
duration = durationForLevel(d.Config.BanDuration, level, d.Config.ProgressiveBanMultiplier)
if d.Config.ProgressiveBanMaxLevel > 0 && level >= d.Config.ProgressiveBanMaxLevel {
permanent = true
duration = 0
}
}
banUntil := int64(0)
if !permanent && duration > 0 {
banUntil = time.Now().Unix() + duration
}
if err := d.FW.Add(ctx, ip, duration); err != nil {
d.warn("Firewall Add Warnung: %v", err)
}
b := db.Ban{IP: ip, Domain: domain, Count: count, BanUntil: banUntil, Duration: duration, OffenseLevel: level, Permanent: permanent, Reason: reason, Protocol: proto, Source: source, GeoIPCountry: country, GeoIPMode: d.Config.GeoIPMode}
if err := d.Store.InsertBan(b); err != nil {
return err
}
_ = d.Store.History("BAN", ip, domain, strconv.Itoa(count), formatDuration(duration, permanent), proto, reason)
abuseReported := d.shouldReportAbuseIPDB(b)
d.notifyBan(context.Background(), b, abuseReported)
if abuseReported {
d.reportAbuseIPDB(context.Background(), b)
}
d.warn("BAN %s (%s, %s)", ip, reason, domain)
return nil
}
func (d *Daemon) Unban(ctx context.Context, ip, reason string) error {
return d.unban(ctx, ip, reason, true)
}
func (d *Daemon) UnbanQuiet(ctx context.Context, ip, reason string) error {
return d.unban(ctx, ip, reason, false)
}
func (d *Daemon) unban(ctx context.Context, ip, reason string, notify bool) error {
_ = d.FW.Del(ctx, ip)
if err := d.Store.DeleteBan(ip); err != nil {
return err
}
_ = d.Store.History("UNBAN", ip, "-", "-", "-", "-", reason)
if notify {
d.notifyUnban(context.Background(), ip, reason)
}
d.info("UNBAN %s (%s)", ip, reason)
return nil
}
func (d *Daemon) AutoUnbanGeoIP(ctx context.Context) error {
bans, err := d.Store.BansByReason("geoip")
if err != nil {
return err
}
for _, b := range bans {
shouldUnban := false
if !d.Config.GeoIPEnabled {
shouldUnban = true
} else if b.GeoIPMode != "" && b.GeoIPMode != d.Config.GeoIPMode {
shouldUnban = true
} else if b.GeoIPCountry != "" && !geoip.ShouldBlock(b.GeoIPCountry, d.Config.GeoIPMode, d.Config.GeoIPCountries) {
shouldUnban = true
}
if shouldUnban {
_ = d.Unban(ctx, b.IP, "geoip-auto-unban")
}
}
return nil
}
func (d *Daemon) isWhitelisted(ip string) bool {
if d.wl == nil {
_ = d.loadCaches()
}
if d.wl[ip] {
return true
}
ok, _ := d.Store.WhitelistContains(ip)
return ok
}
func (d *Daemon) watchlisted(domain string) bool {
if !d.Config.DNSFloodWatchlistEnabled {
return false
}
for _, w := range d.Config.DNSFloodWatchlist {
w = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(w)), ".")
if domain == w || strings.HasSuffix(domain, "."+w) {
return true
}
}
return false
}
func (d *Daemon) SyncWhitelist(ctx context.Context) error {
if err := os.MkdirAll(d.Config.ExternalWhitelistCacheDir, 0755); err != nil {
return err
}
ips := map[string]bool{}
for i, u := range d.Config.ExternalWhitelistURLs {
lines, err := d.fetchCachedLines(ctx, u, d.Config.ExternalWhitelistCacheDir, "whitelist", i)
if err != nil {
d.warn("Whitelist Download Warnung %s: %v", u, err)
continue
}
for _, line := range lines {
for _, ip := range parseListEntry(line) {
for _, resolved := range resolveEntry(ip) {
ips[resolved] = true
}
}
}
}
var list []string
for ip := range ips {
list = append(list, ip)
}
sort.Strings(list)
if err := d.Store.ReplaceWhitelist(list, "external"); err != nil {
return err
}
_ = d.loadCaches()
bans, err := d.Store.ActiveBans()
if err != nil {
return err
}
for _, b := range bans {
if d.isWhitelisted(b.IP) {
_ = d.Unban(ctx, b.IP, "external-whitelist")
}
}
d.info("Externe Whitelist synchronisiert: %d IPs", len(list))
return nil
}
func (d *Daemon) SyncBlocklist(ctx context.Context) error {
if err := os.MkdirAll(d.Config.ExternalBlocklistCacheDir, 0755); err != nil {
return err
}
desired := map[string]bool{}
for i, u := range d.Config.ExternalBlocklistURLs {
lines, err := d.fetchCachedLines(ctx, u, d.Config.ExternalBlocklistCacheDir, "blocklist", i)
if err != nil {
d.warn("Blocklist Download Warnung %s: %v", u, err)
continue
}
for _, line := range lines {
for _, entry := range parseListEntry(line) {
for _, resolved := range resolveEntry(entry) {
desired[resolved] = true
}
}
}
}
for ip := range desired {
if d.isWhitelisted(ip) {
continue
}
if d.Config.DryRun {
_ = d.Store.History("DRY", ip, "-", "-", "dry-run", "-", "dry-run (external-blocklist)")
d.warn("[DRY-RUN] Würde externe Blocklist-IP sperren: %s", ip)
continue
}
perm := d.Config.ExternalBlocklistDuration == 0
dur := d.Config.ExternalBlocklistDuration
if dur == 0 {
dur = 0
}
_ = d.FW.Add(ctx, ip, dur)
exists, _ := d.Store.BanExists(ip)
if !exists {
banUntil := int64(0)
if !perm && dur > 0 {
banUntil = time.Now().Unix() + dur
}
b := db.Ban{IP: ip, Domain: "-", BanUntil: banUntil, Duration: dur, Permanent: perm, Reason: "external-blocklist", Protocol: "-", Source: "external-blocklist"}
_ = d.Store.InsertBan(b)
_ = d.Store.History("BAN", ip, "-", "-", formatDuration(dur, perm), "-", "external-blocklist")
d.notifyBan(context.Background(), b, false)
}
}
if d.Config.ExternalBlocklistAutoUnban {
current, err := d.Store.BansBySource("external-blocklist")
if err != nil {
return err
}
for _, b := range current {
if !desired[b.IP] {
_ = d.Unban(ctx, b.IP, "external-blocklist-removed")
}
}
}
d.info("Externe Blocklist synchronisiert: %d IPs/Netze", len(desired))
return nil
}
func (d *Daemon) notifyBan(ctx context.Context, b db.Ban, abuseReported bool) {
if !d.Config.NotifyEnabled {
return
}
if b.Source == "geoip" && !d.Config.GeoIPNotify {
return
}
if b.Source == "external-blocklist" && !d.Config.ExternalBlocklistNotify {
return
}
title := notificationTitle(b)
msg := d.banNotificationMessage(ctx, b, abuseReported)
d.sendNotification(ctx, title, msg, b)
}
func (d *Daemon) notifyUnban(ctx context.Context, ip, reason string) {
if !d.Config.NotifyEnabled {
return
}
host := d.serverHostname()
ptr := lookupPTR(ctx, ip)
msg := fmt.Sprintf("✅ AdGuard Shield Freigabe auf %s\n---\nIP: %s\nHostname: %s\n\nAbuseIPDB: %s", host, ip, ptr, abuseIPDBCheckURL(ip))
d.sendNotification(ctx, "🛡️ AdGuard Shield", msg, db.Ban{IP: ip, Reason: reason})
}
func (d *Daemon) NotifyBulkUnban(ctx context.Context, reason string, count int) {
if !d.Config.NotifyEnabled || count <= 0 {
return
}
msg := fmt.Sprintf("✅ AdGuard Shield Bulk-Freigabe auf %s\n---\nFreigegebene IPs: %d\nAktion: %s", d.serverHostname(), count, displayReason(reason))
d.sendNotification(ctx, "🛡️ AdGuard Shield", msg, db.Ban{Reason: reason})
}
func (d *Daemon) NotifyServiceStart(ctx context.Context) {
d.notifyServiceOnce(ctx, "service_start")
}
func (d *Daemon) NotifyServiceStop(ctx context.Context) {
d.notifyServiceOnce(ctx, "service_stop")
}
func (d *Daemon) notifyServiceOnce(ctx context.Context, action string) {
d.serviceMu.Lock()
switch action {
case "service_start":
if d.serviceStartNotified {
d.serviceMu.Unlock()
return
}
d.serviceStartNotified = true
case "service_stop":
if d.serviceStopNotified {
d.serviceMu.Unlock()
return
}
d.serviceStopNotified = true
}
d.serviceMu.Unlock()
notifyCtx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
d.notifyService(notifyCtx, action)
}
func (d *Daemon) notifyService(ctx context.Context, action string) {
if !d.Config.NotifyEnabled {
return
}
state := "gestartet"
icon := "🟢"
if action == "service_stop" {
state = "gestoppt"
icon = "🔴"
}
msg := fmt.Sprintf("%s AdGuard Shield %s wurde auf %s %s.", icon, appinfo.Version, d.serverHostname(), state)
d.sendNotification(ctx, "🛡️ AdGuard Shield", msg, db.Ban{Reason: action, Source: "service"})
}
func (d *Daemon) banNotificationMessage(ctx context.Context, b db.Ban, abuseReported bool) string {
host := d.serverHostname()
if b.Source == "geoip" {
return fmt.Sprintf("🌍 AdGuard Shield GeoIP-Sperre auf %s\n---\nIP: %s\nLand: %s\nModus: %s\nDauer: %s\n\nAbuseIPDB: %s",
host, b.IP, b.GeoIPCountry, displayGeoIPMode(b.GeoIPMode), formatNotificationDuration(b.Duration, b.Permanent), abuseIPDBCheckURL(b.IP))
}
var lines []string
lines = append(lines, fmt.Sprintf("🚫 AdGuard Shield Ban auf %s", host))
if abuseReported {
lines = append(lines, "⚠️ IP wurde an AbuseIPDB gemeldet")
}
lines = append(lines, "---")
lines = append(lines, "IP: "+b.IP)
lines = append(lines, "Hostname: "+lookupPTR(ctx, b.IP))
lines = append(lines, "Grund: "+d.displayBanReason(b))
lines = append(lines, "Dauer: "+d.displayBanDuration(b))
lines = append(lines, "", "AbuseIPDB: "+abuseIPDBCheckURL(b.IP))
return strings.Join(lines, "\n")
}
func (d *Daemon) displayBanReason(b db.Ban) string {
if b.Count > 0 && strings.TrimSpace(b.Domain) != "" && b.Domain != "-" {
return fmt.Sprintf("%dx %s in %ds via %s, %s", b.Count, b.Domain, d.notificationWindow(b), displayProtocol(b.Protocol), displayReason(b.Reason))
}
return displayReason(b.Reason)
}
func (d *Daemon) displayBanDuration(b db.Ban) string {
out := formatNotificationDuration(b.Duration, b.Permanent)
if b.OffenseLevel > 0 {
if d.Config.ProgressiveBanMaxLevel > 0 {
out += fmt.Sprintf(" [Stufe %d/%d]", b.OffenseLevel, d.Config.ProgressiveBanMaxLevel)
} else {
out += fmt.Sprintf(" [Stufe %d]", b.OffenseLevel)
}
}
return out
}
func (d *Daemon) notificationWindow(b db.Ban) int {
if b.Reason == "subdomain-flood" || strings.HasPrefix(b.Domain, "*.") {
return d.Config.SubdomainFloodWindow
}
return d.Config.RateLimitWindow
}
func (d *Daemon) shouldReportAbuseIPDB(b db.Ban) bool {
return b.Source == "monitor" && b.Permanent && d.Config.AbuseIPDBEnabled && d.Config.AbuseIPDBAPIKey != ""
}
func (d *Daemon) serverHostname() string {
name, err := os.Hostname()
if err != nil || strings.TrimSpace(name) == "" {
return "unbekannt"
}
return strings.TrimSpace(name)
}
func notificationTitle(b db.Ban) string {
return "🛡️ AdGuard Shield"
}
func lookupPTR(ctx context.Context, ip string) string {
if _, err := netip.ParseAddr(ip); err != nil {
return "(unbekannt)"
}
lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
names, err := net.DefaultResolver.LookupAddr(lookupCtx, ip)
if err != nil || len(names) == 0 {
return "(unbekannt)"
}
return strings.TrimSuffix(strings.TrimSpace(names[0]), ".")
}
func abuseIPDBCheckURL(ip string) string {
return "https://www.abuseipdb.com/check/" + ip
}
func displayGeoIPMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "allowlist":
return "Allowlist"
default:
return "Blocklist"
}
}
func displayProtocol(proto string) string {
proto = strings.TrimSpace(proto)
if proto == "" || proto == "-" {
return "DNS"
}
return strings.ToUpper(proto)
}
func displayReason(reason string) string {
switch strings.ToLower(strings.TrimSpace(reason)) {
case "dns-flood-watchlist":
return "DNS-Flood-Watchlist"
case "rate-limit":
return "Rate-Limit"
case "subdomain-flood":
return "Subdomain-Flood"
case "external-blocklist":
return "Externe Blocklist"
case "geoip":
return "GeoIP"
default:
parts := strings.FieldsFunc(reason, func(r rune) bool { return r == '-' || r == '_' })
for i, p := range parts {
if p == "" {
continue
}
parts[i] = strings.ToUpper(p[:1]) + strings.ToLower(p[1:])
}
if len(parts) == 0 {
return "Unbekannt"
}
return strings.Join(parts, "-")
}
}
func formatNotificationDuration(sec int64, perm bool) string {
if perm || sec == 0 {
return "PERMANENT"
}
if sec < 60 {
return strconv.FormatInt(sec, 10) + "s"
}
h := sec / 3600
m := (sec % 3600) / 60
s := sec % 60
if h > 0 {
return fmt.Sprintf("%dh %dm", h, m)
}
if s > 0 {
return fmt.Sprintf("%dm %ds", m, s)
}
return fmt.Sprintf("%dm", m)
}
func (d *Daemon) sendNotification(ctx context.Context, title, msg string, b db.Ban) {
action := notificationAction(b)
req, err := d.notificationRequest(ctx, title, msg, b)
if err != nil {
d.warn("Benachrichtigung nicht vorbereitet (%s/%s): %v", d.Config.NotifyType, action, err)
return
}
if req == nil {
d.warn("Benachrichtigung übersprungen (%s/%s): Ziel nicht konfiguriert oder Typ unbekannt", d.Config.NotifyType, action)
return
}
resp, err := d.Client.Do(req)
if err != nil {
d.warn("Benachrichtigung fehlgeschlagen (%s/%s): %v", d.Config.NotifyType, action, err)
return
}
if resp == nil {
d.warn("Benachrichtigung fehlgeschlagen (%s/%s): keine HTTP-Antwort", d.Config.NotifyType, action)
return
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
d.warn("Benachrichtigung fehlgeschlagen (%s/%s): HTTP %d", d.Config.NotifyType, action, resp.StatusCode)
return
}
d.debug("Benachrichtigung gesendet (%s/%s): HTTP %d", d.Config.NotifyType, action, resp.StatusCode)
}
func (d *Daemon) reportAbuseIPDB(ctx context.Context, b db.Ban) {
if d.Config.AbuseIPDBAPIKey == "" {
d.warn("AbuseIPDB: API-Key nicht konfiguriert")
return
}
form := url.Values{}
form.Set("ip", b.IP)
form.Set("categories", d.Config.AbuseIPDBCategories)
form.Set("comment", d.abuseIPDBComment(b))
go func() {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.abuseipdb.com/api/v2/report", strings.NewReader(form.Encode()))
if err != nil {
return
}
req.Header.Set("Key", d.Config.AbuseIPDBAPIKey)
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := d.Client.Do(req)
if err != nil {
d.error("AbuseIPDB Fehler: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
d.info("AbuseIPDB: %s erfolgreich gemeldet", b.IP)
} else {
d.warn("AbuseIPDB: HTTP %d für %s", resp.StatusCode, b.IP)
}
}()
}
func (d *Daemon) abuseIPDBComment(b db.Ban) string {
return fmt.Sprintf("DNS flooding on our DNS server: %dx %s in %ds. Banned by Adguard Shield 🔗 %s", b.Count, b.Domain, d.notificationWindow(b), appinfo.ProjectURL)
}
func (d *Daemon) notificationRequest(ctx context.Context, title, msg string, b db.Ban) (*http.Request, error) {
action := notificationAction(b)
switch d.Config.NotifyType {
case "ntfy":
if d.Config.NTFYTopic == "" {
return nil, nil
}
url := strings.TrimRight(d.Config.NTFYServerURL, "/") + "/" + d.Config.NTFYTopic
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(msg))
if err != nil {
return nil, err
}
req.Header.Set("Title", title)
req.Header.Set("Priority", d.Config.NTFYPriority)
if d.Config.NTFYToken != "" {
req.Header.Set("Authorization", "Bearer "+d.Config.NTFYToken)
}
return req, nil
case "discord":
return jsonPost(ctx, d.Config.NotifyWebhook, map[string]string{"content": title + "\n\n" + msg})
case "slack":
return jsonPost(ctx, d.Config.NotifyWebhook, map[string]string{"text": title + "\n\n" + msg})
case "generic":
return jsonPost(ctx, d.Config.NotifyWebhook, map[string]string{"title": title, "message": msg, "client": b.IP, "action": action})
case "gotify":
form := url.Values{}
form.Set("title", title)
form.Set("message", msg)
form.Set("priority", "5")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.Config.NotifyWebhook, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return req, nil
default:
return nil, nil
}
}
func notificationAction(b db.Ban) string {
if strings.HasPrefix(b.Reason, "service_") {
return b.Reason
}
if strings.HasSuffix(b.Reason, "-flush") {
return b.Reason
}
if b.Source == "" && b.Domain == "" {
return "unban"
}
return "ban"
}
func jsonPost(ctx context.Context, url string, payload any) (*http.Request, error) {
if url == "" {
return nil, nil
}
b, err := json.Marshal(payload)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
return req, nil
}
func (d *Daemon) fetchLines(ctx context.Context, url string) ([]string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := d.Client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
b, err := io.ReadAll(io.LimitReader(resp.Body, 50<<20))
if err != nil {
return nil, err
}
return strings.Split(string(b), "\n"), nil
}
func (d *Daemon) fetchCachedLines(ctx context.Context, sourceURL, cacheDir, prefix string, index int) ([]string, error) {
cacheFile := filepath.Join(cacheDir, fmt.Sprintf("%s_%d.txt", prefix, index))
etagFile := filepath.Join(cacheDir, fmt.Sprintf("%s_%d.etag", prefix, index))
tmpFile := filepath.Join(cacheDir, fmt.Sprintf("%s_%d.tmp", prefix, index))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
if err != nil {
return nil, err
}
if etag, err := os.ReadFile(etagFile); err == nil {
if value := strings.TrimSpace(string(etag)); value != "" {
req.Header.Set("If-None-Match", value)
}
}
resp, err := d.Client.Do(req)
if err != nil {
if b, readErr := os.ReadFile(cacheFile); readErr == nil {
d.warn("%s Download fehlgeschlagen, nutze Cache: %v", prefix, err)
return strings.Split(string(b), "\n"), nil
}
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotModified {
d.debug("%s Liste unverändert: %s", prefix, sourceURL)
b, err := os.ReadFile(cacheFile)
if err != nil {
return nil, err
}
return strings.Split(string(b), "\n"), nil
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if b, readErr := os.ReadFile(cacheFile); readErr == nil {
d.warn("%s Download HTTP %d, nutze Cache: %s", prefix, resp.StatusCode, sourceURL)
return strings.Split(string(b), "\n"), nil
}
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
b, err := io.ReadAll(io.LimitReader(resp.Body, 50<<20))
if err != nil {
return nil, err
}
if err := os.WriteFile(tmpFile, b, 0644); err != nil {
return nil, err
}
if etag := resp.Header.Get("ETag"); etag != "" {
_ = os.WriteFile(etagFile, []byte(etag+"\n"), 0644)
}
if err := os.Rename(tmpFile, cacheFile); err != nil {
return nil, err
}
d.debug("%s Liste aktualisiert: %s (%d Bytes)", prefix, sourceURL, len(b))
return strings.Split(string(b), "\n"), nil
}
func parseListEntry(line string) []string {
line = strings.TrimSpace(strings.TrimPrefix(line, "\ufeff"))
if line == "" || strings.HasPrefix(line, "#") {
return nil
}
if i := strings.IndexAny(line, "#;"); i >= 0 {
line = strings.TrimSpace(line[:i])
}
parts := strings.Fields(line)
if len(parts) >= 2 && (parts[0] == "0.0.0.0" || strings.HasPrefix(parts[0], "127.") || parts[0] == "::" || parts[0] == "::1") {
line = parts[1]
} else if len(parts) > 1 {
return nil
}
if strings.Contains(line, "://") {
return nil
}
if _, err := netip.ParseAddr(line); err == nil {
return []string{line}
}
if _, err := netip.ParsePrefix(line); err == nil {
return []string{line}
}
if isHostname(line) {
return []string{line}
}
return nil
}
func resolveEntry(entry string) []string {
if _, err := netip.ParseAddr(entry); err == nil {
return []string{entry}
}
if _, err := netip.ParsePrefix(entry); err == nil {
return []string{entry}
}
addrs, err := net.LookupHost(entry)
if err != nil {
return nil
}
var out []string
for _, a := range addrs {
if ip, err := netip.ParseAddr(a); err == nil && !ip.IsUnspecified() {
out = append(out, ip.String())
}
}
return out
}
func isHostname(s string) bool {
if len(s) > 253 || strings.ContainsAny(s, "/:") {
return false
}
for _, p := range strings.Split(s, ".") {
if p == "" || len(p) > 63 {
return false
}
}
return true
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
func baseDomain(domain string) string {
parts := strings.Split(domain, ".")
if len(parts) < 2 {
return ""
}
if len(parts) >= 3 {
lastTwo := parts[len(parts)-2] + "." + parts[len(parts)-1]
if isMultipartPublicSuffix(lastTwo) {
return parts[len(parts)-3] + "." + lastTwo
}
}
return parts[len(parts)-2] + "." + parts[len(parts)-1]
}
func isMultipartPublicSuffix(s string) bool {
first, rest, ok := strings.Cut(s, ".")
if !ok || len(rest) < 2 || len(rest) > 3 {
return false
}
switch first {
case "co", "com", "net", "org", "gov", "edu", "ac", "gv", "ne", "or", "go":
return true
default:
return false
}
}
func (d *Daemon) SaveFirewallRules(ctx context.Context) error {
if err := os.MkdirAll(d.Config.StateDir, 0755); err != nil {
return err
}
if err := saveCommand(ctx, filepath.Join(d.Config.StateDir, "iptables-rules.v4"), "iptables-save"); err != nil {
return err
}
return saveCommand(ctx, filepath.Join(d.Config.StateDir, "iptables-rules.v6"), "ip6tables-save")
}
func saveCommand(ctx context.Context, path, name string) error {
out, err := exec.CommandContext(ctx, name).Output()
if err != nil {
return err
}
return os.WriteFile(path, out, 0644)
}
func durationForLevel(base int64, level, mult int) int64 {
if level <= 1 {
return base
}
if mult < 1 {
mult = 1
}
d := base
for i := 1; i < level; i++ {
d *= int64(mult)
}
return d
}
func formatDuration(sec int64, perm bool) string {
if perm || sec == 0 {
return "permanent"
}
return strconv.FormatInt(sec, 10) + "s"
}
func (d *Daemon) log(format string, args ...any) {
d.info(format, args...)
}
func (d *Daemon) debug(format string, args ...any) {
if d.Logger != nil {
d.Logger.Debugf(format, args...)
}
}
func (d *Daemon) info(format string, args ...any) {
if d.Logger != nil {
d.Logger.Infof(format, args...)
}
}
func (d *Daemon) warn(format string, args ...any) {
if d.Logger != nil {
d.Logger.Warnf(format, args...)
}
}
func (d *Daemon) error(format string, args ...any) {
if d.Logger != nil {
d.Logger.Errorf(format, args...)
}
}