fix: harden daemon and fix ban, whitelist and firewall bugs

This commit is contained in:
Patrick Asmus
2026-10-10 02:56:27 +02:00
parent d3e40e2c13
commit 0d10264a5e
46 changed files with 6336 additions and 1542 deletions
+3 -3
View File
@@ -1,10 +1,10 @@
# AdGuard Shield CI - Pull Request Tests
# Runs on every PR to master: format check, vet, build and tests.
# Runs on every PR to main: format check, vet, build and tests.
name: PR Tests
on:
pull_request:
branches: [master]
branches: [main]
workflow_dispatch:
permissions: read-all
@@ -14,7 +14,7 @@ jobs:
name: Format, Vet, Build & Test
runs-on: ubuntu-latest
container:
image: golang:1.26.2-alpine
image: golang:1.26.9-alpine
steps:
- name: Install build dependencies
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
name: Build & Upload Linux Binary
runs-on: ubuntu-latest
container:
image: golang:1.26.2-alpine
image: golang:1.26.9-alpine
steps:
- name: Install build dependencies
+2 -2
View File
@@ -4,7 +4,7 @@ name: Security Scan
on:
pull_request:
branches: [master]
branches: [main]
workflow_dispatch:
permissions: read-all
@@ -14,7 +14,7 @@ jobs:
name: Go Vulnerability Check
runs-on: ubuntu-latest
container:
image: golang:1.26.2-alpine
image: golang:1.26.9-alpine
steps:
- name: Install dependencies
+1
View File
@@ -4,3 +4,4 @@
/adguard-shield
/adguard-shield.exe
*.test
/dist/
+3 -3
View File
@@ -66,7 +66,7 @@ Die benötigten Pakete werden vom Installer auf Ubuntu/Debian automatisch instal
```bash
# Release-Archiv herunterladen und entpacken
curl -fL -o adguard-shield-linux-amd64.tar.gz \
https://git.techniverse.net/scriptos/adguard-shield/releases/download/v1.1.3/adguard-shield-linux-amd64.tar.gz
https://git.techniverse.net/scriptos/adguard-shield/releases/download/v1.2.0/adguard-shield-linux-amd64.tar.gz
tar -xzf adguard-shield-linux-amd64.tar.gz
chmod +x ./adguard-shield
```
@@ -84,7 +84,7 @@ GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o adguard-shield ./cmd/adguard-s
```bash
git clone https://git.techniverse.net/scriptos/adguard-shield.git
cd adguard-shield
docker run --rm -v "$PWD":/src -w /src -e GOOS=linux -e GOARCH=amd64 -e CGO_ENABLED=0 golang:1.22 \
docker run --rm -v "$PWD":/src -w /src -e GOOS=linux -e GOARCH=amd64 -e CGO_ENABLED=0 golang:1.26.9 \
go build -o adguard-shield ./cmd/adguard-shieldd
```
@@ -257,7 +257,7 @@ sudo systemctl restart adguard-shield
| `CHECK_INTERVAL` | `10` | Abstand zwischen Querylog-Abfragen in Sekunden |
| `BAN_DURATION` | `3600` | Basis-Sperrdauer in Sekunden (1 Stunde) |
| `FIREWALL_MODE` | `host` | `host`, `docker-host`, `docker-bridge` oder `hybrid` |
| `WHITELIST` | `127.0.0.1,::1` | IPs, die nie gesperrt werden (kommagetrennt) |
| `WHITELIST` | `127.0.0.1,::1` | IPs oder CIDR-Netze, die nie gesperrt werden (kommagetrennt) |
| `DRY_RUN` | `false` | Testmodus: nur loggen, nicht sperren |
### Optionale Module
+6 -2
View File
@@ -7,6 +7,7 @@
ADGUARD_URL="https://dns1.domain.com"
ADGUARD_USER="admin"
ADGUARD_PASS='changeme'
ADGUARD_TLS_INSECURE=true # TLS-Zertifikat der AdGuard-API nicht prüfen (selbstsigniert); gilt nur für die AdGuard-API
# --- Rate-Limit ---
RATE_LIMIT_MAX_REQUESTS=30 # Max. Anfragen pro Domain/Client im Zeitfenster
@@ -23,7 +24,7 @@ DNS_FLOOD_WATCHLIST_ENABLED=false
DNS_FLOOD_WATCHLIST="" # Kommagetrennt, z.B. "example.com,evil.org"
# --- Sperr-Einstellungen ---
BAN_DURATION=3600 # Basis-Sperrdauer in Sekunden
BAN_DURATION=3600 # Basis-Sperrdauer in Sekunden (0 = permanent)
IPTABLES_CHAIN="ADGUARD_SHIELD"
BLOCKED_PORTS="53 443 853" # DNS(53), DoH(443), DoT/DoQ(853)
FIREWALL_BACKEND="ipset" # Daemon: ipset + iptables (schneller als Einzelregeln)
@@ -36,11 +37,13 @@ WHITELIST="127.0.0.1,::1"
# --- Logging ---
LOG_FILE="/var/log/adguard-shield.log"
LOG_LEVEL="INFO" # DEBUG, INFO, WARN, ERROR
LOG_MAX_SIZE_MB=50 # Log-Rotation ab dieser Größe nach LOG_FILE.1 (0 = keine Rotation)
# --- Benachrichtigungen ---
NOTIFY_ENABLED=false
NOTIFY_TYPE="ntfy" # ntfy, discord, slack, gotify, generic
NOTIFY_WEBHOOK_URL="" # Webhook-URL (nicht für ntfy)
NOTIFY_TLS_INSECURE=false # true = TLS-Zertifikat des Benachrichtigungsziels nicht prüfen (nur selbstsigniert/intern)
# Ntfy-Einstellungen (nur bei NOTIFY_TYPE="ntfy")
NTFY_SERVER_URL="https://ntfy.sh"
@@ -97,10 +100,11 @@ GEOIP_NOTIFY=true
GEOIP_SKIP_PRIVATE=true # Private IPs ausnehmen
GEOIP_LICENSE_KEY="" # MaxMind GeoLite2 Key (optional, für Auto-Download)
GEOIP_MMDB_PATH="" # Manueller DB-Pfad (optional, hat Vorrang)
GEOIP_CACHE_TTL=86400 # GeoIP-Cache in Sekunden
GEOIP_CACHE_TTL=86400 # GeoIP-Cache in Sekunden (0 = kein Ablauf)
# --- Erweiterte Einstellungen ---
STATE_DIR="/var/lib/adguard-shield" # SQLite-DB: ${STATE_DIR}/adguard-shield.db
PID_FILE="/var/run/adguard-shield.pid"
BAN_HISTORY_RETENTION_DAYS=0 # Ban-History/AbuseIPDB-Meldungen nach X Tagen löschen (0 = unbegrenzt)
API_QUERY_LIMIT=500 # API-Einträge pro Abfrage (max 5000)
DRY_RUN=false # true = nur loggen, nicht sperren
-1
View File
@@ -9,7 +9,6 @@ StartLimitIntervalSec=300
[Service]
Type=simple
ExecStart=/opt/adguard-shield/adguard-shield -config /opt/adguard-shield/adguard-shield.conf run
ExecReload=/bin/kill -HUP $MAINPID
# Neustart-Verhalten
Restart=on-failure
+45 -19
View File
@@ -60,18 +60,25 @@ func run() error {
if err != nil {
return err
}
daemonMode := cmd == "run" || cmd == "start" || cmd == "dry-run"
if cmd == "dry-run" {
// Vor daemon.New setzen, damit auch die Firewall im Dry-Run-Modus arbeitet.
cfg.DryRun = true
}
if !daemonMode {
for _, w := range cfg.Warnings {
fmt.Fprintln(os.Stderr, "WARNUNG:", w)
}
}
d, err := daemon.New(cfg)
if err != nil {
return err
}
defer d.Close()
ctx, stop := commandContext(d, cmd == "run" || cmd == "start" || cmd == "dry-run")
ctx, stop := commandContext(d, daemonMode)
defer stop()
switch cmd {
case "run", "start", "dry-run":
if cmd == "dry-run" {
cfg.DryRun = true
}
if err := writePID(cfg.PIDFile); err != nil {
return err
}
@@ -135,7 +142,7 @@ func run() error {
if len(args) < 1 {
return fmt.Errorf("Nutzung: adguard-shield ban <IP>")
}
return d.Ban(ctx, args[0], "manual", 0, "-", "manual", "manual", "", true)
return d.Ban(ctx, daemon.BanRequest{IP: args[0], Domain: "manual", Protocol: "-", Reason: "manual", Source: "manual", Permanent: true})
case "reset-offenses":
ip := ""
if len(args) > 0 {
@@ -350,6 +357,9 @@ func printIPWhitelistStatus(d *daemon.Daemon, ip string) {
}
hits = append(hits, source)
}
if len(hits) == 0 && d.IsWhitelisted(ip) {
hits = append(hits, "Netzbereich")
}
if len(hits) == 0 {
fmt.Println("Whitelist: nein")
return
@@ -416,7 +426,11 @@ func printIPHistory(d *daemon.Daemon, ip string, limit int) error {
}
func status(d *daemon.Daemon) error {
bans, err := d.Store.ActiveBans()
total, err := d.Store.CountActiveBans()
if err != nil {
return err
}
bans, err := d.Store.RecentActiveBans(statusBanLimit)
if err != nil {
return err
}
@@ -426,17 +440,16 @@ func status(d *daemon.Daemon) error {
fmt.Printf("GeoIP: %v (%s %v)\n", d.Config.GeoIPEnabled, d.Config.GeoIPMode, d.Config.GeoIPCountries)
fmt.Printf("Externe Blocklist: %v (%d URLs)\n", d.Config.ExternalBlocklistEnabled, len(d.Config.ExternalBlocklistURLs))
fmt.Printf("Externe Whitelist: %v (%d URLs)\n", d.Config.ExternalWhitelistEnabled, len(d.Config.ExternalWhitelistURLs))
fmt.Printf("Aktive Sperren: %d\n", len(bans))
limit := min(len(bans), statusBanLimit)
for _, b := range bans[:limit] {
fmt.Printf("Aktive Sperren: %d\n", total)
for _, b := range bans {
until := "permanent"
if !b.Permanent && b.BanUntil > 0 {
until = time.Unix(b.BanUntil, 0).Format("2006-01-02 15:04:05")
}
fmt.Printf(" %s | %s | %s | %s\n", b.IP, b.Source, b.Reason, until)
}
if len(bans) > limit {
fmt.Printf(" ... %d weitere Sperren. Details mit: adguard-shield ip-status <IP> oder history.\n", len(bans)-limit)
if total > len(bans) {
fmt.Printf(" ... %d weitere Sperren. Details mit: adguard-shield ip-status <IP> oder history.\n", total-len(bans))
}
return nil
}
@@ -486,7 +499,11 @@ func geoipStatus(d *daemon.Daemon) error {
fmt.Printf("Aktiv: %v\n", d.Config.GeoIPEnabled)
fmt.Printf("Modus: %s\n", d.Config.GeoIPMode)
fmt.Printf("Länder: %v\n", d.Config.GeoIPCountries)
fmt.Printf("Cache TTL: %ds\n", d.Config.GeoIPCacheTTL)
if d.Config.GeoIPCacheTTL > 0 {
fmt.Printf("Cache TTL: %ds\n", d.Config.GeoIPCacheTTL)
} else {
fmt.Println("Cache TTL: kein Ablauf")
}
fmt.Printf("MMDB: %s\n", empty(d.Config.GeoIPMMDBPath, "<auto/falls License-Key gesetzt>"))
bans, err := d.Store.BansByReason("geoip")
if err != nil {
@@ -516,19 +533,28 @@ func writePID(path string) error {
return os.WriteFile(path, []byte(strconv.Itoa(os.Getpid())+"\n"), 0644)
}
func commandContext(d *daemon.Daemon, notifyServiceStop bool) (context.Context, context.CancelFunc) {
func commandContext(d *daemon.Daemon, daemonMode bool) (context.Context, context.CancelFunc) {
ctx, cancel := context.WithCancel(context.Background())
signals := make(chan os.Signal, 1)
done := make(chan struct{})
signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP)
go func() {
select {
case <-signals:
if notifyServiceStop {
d.NotifyServiceStop(context.Background())
for {
select {
case sig := <-signals:
if sig == syscall.SIGHUP && daemonMode {
if d.Logger != nil {
d.Logger.Warnf("SIGHUP empfangen: Konfigurationsänderungen erfordern einen Neustart (systemctl restart adguard-shield)")
}
continue
}
// Im Daemon-Modus sendet Run die Stopp-Benachrichtigung, nachdem
// die Benachrichtigungs-Warteschlange abgearbeitet wurde.
cancel()
return
case <-done:
return
}
cancel()
case <-done:
}
}()
return ctx, func() {
+10 -4
View File
@@ -94,9 +94,11 @@ Gesteuert wird das über:
```bash
CHECK_INTERVAL=10 # Abstand zwischen Abfragen in Sekunden
API_QUERY_LIMIT=500 # Maximale Einträge pro API-Abfrage
API_QUERY_LIMIT=500 # Einträge pro API-Abfrage (Seitengröße)
```
Ist eine Seite voll, blättert der Daemon über `older_than` weiter zurück, bis bereits ausgewertete Einträge oder der Beginn des Analysefensters erreicht sind (maximal 20 Seiten pro Poll).
Aus jedem Querylog-Eintrag werden diese Informationen extrahiert:
| Feld | Verwendung |
@@ -294,7 +296,7 @@ Der zentrale Zustand liegt standardmäßig hier:
| Tabelle | Inhalt | Beschreibung |
|---|---|---|
| `active_bans` | Aktive Sperren | IP, Grund, Dauer, Quelle, Ablaufzeit, Offense-Level, GeoIP-Metadaten |
| `ban_history` | Dauerhafte Historie | Zeitstempel, Aktion (BAN/UNBAN/DRY), Client-IP, Domain, Protokoll, Grund |
| `ban_history` | Dauerhafte Historie | Zeitstempel, Aktion (BAN/UNBAN/DRY), Client-IP, Domain, Protokoll, Grund; optional begrenzt über `BAN_HISTORY_RETENTION_DAYS` |
| `offense_tracking` | Progressive-Ban-Stufen | Client-IP, aktuelle Offense-Stufe, letzter Verstoß |
| `whitelist_cache` | Externe Whitelist | Aufgelöste IPs aus externen Whitelist-URLs mit Quellzuordnung |
| `geoip_cache` | GeoIP-Ergebnisse | IP, Ländercode, Zeitstempel der Abfrage, DB-Änderungszeitpunkt |
@@ -349,9 +351,11 @@ Es gibt in der Go-Version keine separaten Worker-Skripte mehr. Diese Aufgaben la
| Externe Whitelist | `EXTERNAL_WHITELIST_ENABLED=true` | `EXTERNAL_WHITELIST_INTERVAL` | Lädt Listen, löst Hostnamen auf, aktualisiert Whitelist-Cache |
| Externe Blocklist | `EXTERNAL_BLOCKLIST_ENABLED=true` | `EXTERNAL_BLOCKLIST_INTERVAL` | Lädt Listen, sperrt neue IPs, hebt entfernte IPs optional auf |
| Offense-Cleanup | `PROGRESSIVE_BAN_ENABLED=true` | Stündlich | Entfernt abgelaufene Offense-Zähler |
| Sperren-Ablauf | Immer | Minütlich | Hebt abgelaufene temporäre Sperren auf |
| DB-Bereinigung | `BAN_HISTORY_RETENTION_DAYS>0` oder `GEOIP_CACHE_TTL>0` | Täglich | Löscht alte History-/AbuseIPDB-Einträge und abgelaufene GeoIP-Cache-Einträge |
| GeoIP-Lookups | `GEOIP_ENABLED=true` | Mit jedem Poll | Prüft neue öffentliche Client-IPs gegen Länderregeln |
Externe Whitelist und Blocklist laufen sofort beim Start einmalig und danach im jeweiligen Intervall. Die Sperren-Freigabe abgelaufener Bans wird bei jedem Querylog-Poll mit geprüft.
Externe Whitelist und Blocklist laufen sofort beim Start einmalig und danach im jeweiligen Intervall. Abgelaufene Sperren werden zusätzlich beim Start (Reconcile) freigegeben.
## Whitelist-Logik
@@ -359,7 +363,7 @@ Vor jeder Sperre wird geprüft, ob die IP vertrauenswürdig ist.
**Quellen (in Prüfreihenfolge):**
1. Statische `WHITELIST` aus der Konfiguration (kommagetrennt)
1. Statische `WHITELIST` aus der Konfiguration (kommagetrennt, IPs und CIDR-Netze)
2. Aufgelöste IPs aus externen Whitelists (gespeichert in SQLite)
**Verhalten:**
@@ -447,6 +451,8 @@ Es gibt zwei unterschiedliche Blickwinkel auf das Geschehen:
**Wichtig:** Query-Inhalte werden nicht dauerhaft in die Logdatei geschrieben. Für aktuelle Queries ist die Live-Ansicht (`live`) gedacht.
`LOG_FILE` wird ab `LOG_MAX_SIZE_MB` (Standard `50`) nach `LOG_FILE.1` rotiert; es wird genau ein Backup behalten.
### History-Gründe
| Grund | Bedeutung |
+2
View File
@@ -474,6 +474,8 @@ AdGuard Shield schreibt Daemon-Ereignisse in `LOG_FILE`, standardmäßig:
/var/log/adguard-shield.log
```
Ab `LOG_MAX_SIZE_MB` (Standard `50`) wird die Datei nach `adguard-shield.log.1` rotiert (ein Backup).
### CLI-Befehle
```bash
+16 -2
View File
@@ -77,6 +77,7 @@ NTFY_TOKEN="tk_geheimer_token"
### Hinweise
- Bei `NOTIFY_TYPE="ntfy"` wird `NOTIFY_WEBHOOK_URL` nicht verwendet.
- Selbst gehostete Instanz mit selbstsigniertem Zertifikat: siehe `NOTIFY_TLS_INSECURE` unter [TLS-Zertifikate](#tls-zertifikate).
- Bei privaten Topics oder eigener Instanz ist ein Token empfehlenswert.
- Der Topic-Name sollte nicht öffentlich erratbar sein, um Fremdzugriff zu verhindern.
@@ -198,6 +199,8 @@ EXTERNAL_BLOCKLIST_NOTIFY=false
| Große Listen (>100 IPs) | `EXTERNAL_BLOCKLIST_NOTIFY=false` (Standard) |
| Kleine, kuratierte Listen (<50 IPs) | `EXTERNAL_BLOCKLIST_NOTIFY=true` möglich |
Bringt ein Sync-Lauf mehr als 5 neue Sperren, wird statt Einzelnachrichten eine Zusammenfassung mit Anzahl, Sperrdauer und bis zu 5 Beispiel-IPs gesendet.
### GeoIP
```bash
@@ -250,13 +253,13 @@ ABUSEIPDB_API_KEY="..."
### Service gestartet
```text
AdGuard Shield v1.1.3 wurde auf dns1 gestartet.
AdGuard Shield v1.2.0 wurde auf dns1 gestartet.
```
### Service gestoppt
```text
AdGuard Shield v1.1.3 wurde auf dns1 gestoppt.
AdGuard Shield v1.2.0 wurde auf dns1 gestoppt.
```
### Rate-Limit-Sperre
@@ -339,11 +342,22 @@ sudo journalctl -u adguard-shield --no-pager -n 100
| Ziel | Webhook-URL oder Ntfy-Topic gesetzt? |
| Token | Token gültig und nicht abgelaufen? |
| Netzwerk | Server kann ausgehende HTTPS-Verbindungen aufbauen? |
| Zertifikat | Gültiges TLS-Zertifikat am Ziel? Sonst `NOTIFY_TLS_INSECURE=true` (siehe unten) |
| Firewall | Keine Firewall blockiert ausgehende Verbindungen? |
| Modul-Schalter | `EXTERNAL_BLOCKLIST_NOTIFY` oder `GEOIP_NOTIFY` separat deaktiviert? |
Bei `generic` kannst du testweise einen lokalen HTTP-Empfänger oder einen Request-Inspector verwenden, um den gesendeten Payload zu sehen.
### TLS-Zertifikate
Benachrichtigungen prüfen TLS-Zertifikate standardmäßig. Nutzt ein selbst gehosteter Dienst (ntfy, Gotify, eigener Webhook) ein selbstsigniertes Zertifikat, schlägt der Versand mit einem Zertifikatsfehler im Log fehl. Für solche internen Ziele kann die Prüfung gezielt abgeschaltet werden:
```bash
NOTIFY_TLS_INSECURE=true # Standard: false
```
Die Einstellung gilt nur für Benachrichtigungen (ntfy, Discord, Slack, Gotify, generic). AbuseIPDB und externe Listen prüfen Zertifikate immer.
---
## Datenschutz
+33 -7
View File
@@ -23,6 +23,23 @@ sudo systemctl restart adguard-shield
sudo adguard-shield status
```
`systemctl reload` wird nicht unterstützt: Ein `SIGHUP` beendet den Daemon nicht mehr, lädt die Konfiguration aber auch nicht neu, sondern schreibt nur einen Hinweis ins Log.
## Prüfung beim Laden
Ungültige Werte führen nicht zum Abbruch, damit der Schutz weiterläuft. Stattdessen wird der Standardwert verwendet und eine Warnung ausgegeben (im Daemon-Log bzw. bei CLI-Befehlen als `WARNUNG:` auf stderr). Geprüft wird unter anderem:
| Prüfung | Beispiel |
|---|---|
| Unbekannte Schlüssel (Tippfehler) | `CHEK_INTERVAL=5` |
| Nicht lesbare Zahlen oder Wahrheitswerte | `RATE_LIMIT_WINDOW=abc`, `GEOIP_ENABLED=vielleicht` |
| Werte, die größer als `0` sein müssen | `CHECK_INTERVAL=0`, `RATE_LIMIT_WINDOW=0` |
| Werte, die nicht negativ sein dürfen | `PROGRESSIVE_BAN_MAX_LEVEL=-1`, `BAN_HISTORY_RETENTION_DAYS=-5`, `BAN_DURATION=-1`, `GEOIP_CACHE_TTL=-1` |
| Ungültige Ports | `BLOCKED_PORTS="53 abc 70000"` (ungültige Einträge werden ignoriert; ist kein Port gültig, gilt `53 443 853`) |
| Ungültige Auswahlwerte | `GEOIP_MODE`, `FIREWALL_MODE`, `LOG_LEVEL`, `NOTIFY_TYPE`, `REPORT_FORMAT`, `REPORT_INTERVAL` |
Veraltete Schlüssel der Shell-Version wie `BAN_HISTORY_FILE` werden ignoriert und mit einem eigenen Hinweis gemeldet.
## Automatische Migration
Beim Installieren oder Aktualisieren wird eine vorhandene Konfiguration nicht überschrieben. Der Installer vergleicht vorhandene Schlüssel mit der aktuellen Standardkonfiguration.
@@ -57,6 +74,7 @@ sudo adguard-shield dry-run
| `ADGUARD_URL` | `https://dns1.domain.com` | URL der AdGuard-Home-Weboberfläche/API |
| `ADGUARD_USER` | `admin` | Benutzername für die API-Authentifizierung |
| `ADGUARD_PASS` | `changeme` | Passwort für die API-Authentifizierung |
| `ADGUARD_TLS_INSECURE` | `true` | TLS-Zertifikat der AdGuard-Home-API nicht prüfen (z.B. selbstsigniert) |
### Beispiel: Lokale Instanz
@@ -80,7 +98,7 @@ AdGuard Shield ruft intern diesen Endpunkt ab:
/control/querylog?limit=<API_QUERY_LIMIT>&response_status=all
```
**Hinweis:** Der HTTP-Client akzeptiert auch selbstsignierte TLS-Zertifikate. Das erleichtert lokale Setups, ersetzt aber keine saubere Absicherung der AdGuard-Home-Oberfläche.
**Hinweis:** Mit `ADGUARD_TLS_INSECURE=true` (Standard) akzeptiert AdGuard Shield für die AdGuard-Home-API auch selbstsignierte TLS-Zertifikate. Das erleichtert lokale Setups, ersetzt aber keine saubere Absicherung der AdGuard-Home-Oberfläche. Hat AdGuard Home ein gültiges Zertifikat, sollte `ADGUARD_TLS_INSECURE=false` gesetzt werden. Benachrichtigungen prüfen Zertifikate standardmäßig; nur mit `NOTIFY_TLS_INSECURE=true` werden für das Benachrichtigungsziel auch selbstsignierte Zertifikate akzeptiert. AbuseIPDB, externe Listen und der MaxMind-Download prüfen Zertifikate immer.
---
@@ -89,7 +107,7 @@ AdGuard Shield ruft intern diesen Endpunkt ab:
| Parameter | Standard | Beschreibung |
|---|---:|---|
| `CHECK_INTERVAL` | `10` | Abstand zwischen Querylog-Abfragen in Sekunden |
| `API_QUERY_LIMIT` | `500` | Anzahl der Querylog-Einträge pro API-Abfrage (max. 5000) |
| `API_QUERY_LIMIT` | `500` | Seitengröße der Querylog-Abfrage (max. 5000); ist eine Seite voll, werden ältere Seiten nachgeladen (max. 20 pro Poll) |
### Empfehlungen
@@ -97,7 +115,7 @@ AdGuard Shield ruft intern diesen Endpunkt ab:
|---|---|
| Normaler Betrieb | `CHECK_INTERVAL=10` ist ein guter Standard |
| Hohes DNS-Aufkommen | `API_QUERY_LIMIT` auf 1000–2000 erhöhen |
| `API_QUERY_LIMIT` zu niedrig | Spitzen im Querylog können zwischen zwei Polls verpasst werden |
| `API_QUERY_LIMIT` zu niedrig | Mehr API-Abfragen pro Poll; ab 20 vollen Seiten werden ältere Einträge nicht mehr ausgewertet (Warnung im Log) |
| Sehr kurze Intervalle | Erzeugen mehr API-Last auf AdGuard Home |
---
@@ -183,9 +201,9 @@ Wenn ein Client `login.microsoft.com` über das Rate-Limit bringt, wird sofort p
| Parameter | Standard | Beschreibung |
|---|---|---|
| `BAN_DURATION` | `3600` | Basisdauer temporärer Monitor-Sperren in Sekunden (1 Stunde) |
| `BAN_DURATION` | `3600` | Basisdauer temporärer Monitor-Sperren in Sekunden (1 Stunde, `0` = Monitor-Sperren sind permanent) |
| `IPTABLES_CHAIN` | `ADGUARD_SHIELD` | Name der eigenen Firewall-Chain |
| `BLOCKED_PORTS` | `53 443 853` | Ports, die für gesperrte Clients blockiert werden (Leerzeichen-getrennt) |
| `BLOCKED_PORTS` | `53 443 853` | Ports oder Portbereiche (`a:b`), die für gesperrte Clients blockiert werden (Leerzeichen- oder Komma-getrennt) |
| `FIREWALL_BACKEND` | `ipset` | Firewall-Backend (ipset + iptables) |
| `FIREWALL_MODE` | `host` | Verkehrsweg der AdGuard-Home-Installation |
| `DRY_RUN` | `false` | Konfigurationsweiter Testmodus ohne echte Sperren |
@@ -215,7 +233,7 @@ Details zu den Docker-Modi stehen in [Docker-Installationen](docker.md).
| Parameter | Standard | Beschreibung |
|---|---|---|
| `WHITELIST` | `127.0.0.1,::1` | IPs, die nie gesperrt werden (kommagetrennt) |
| `WHITELIST` | `127.0.0.1,::1` | IPs oder CIDR-Netze (z.B. `192.168.1.0/24`), die nie gesperrt werden (kommagetrennt) |
### Beispiel
@@ -276,6 +294,7 @@ sudo adguard-shield reset-offenses <IP> # Eine IP zurücksetzen
|---|---|---|
| `LOG_FILE` | `/var/log/adguard-shield.log` | Datei für Daemon-Ereignisse |
| `LOG_LEVEL` | `INFO` | Minimales Log-Level |
| `LOG_MAX_SIZE_MB` | `50` | Ab dieser Größe wird `LOG_FILE` nach `LOG_FILE.1` rotiert (ein Backup); `0` = keine Rotation |
### Verfügbare Log-Level
@@ -304,6 +323,9 @@ sudo adguard-shield live
|---|---|---|
| `STATE_DIR` | `/var/lib/adguard-shield` | Verzeichnis für SQLite-Datenbank und Caches |
| `PID_FILE` | `/var/run/adguard-shield.pid` | PID-Datei für direkten Vordergrundlauf |
| `BAN_HISTORY_RETENTION_DAYS` | `0` | Einträge der Ban-History und AbuseIPDB-Meldungen älter als X Tage werden täglich gelöscht; `0` = unbegrenzt aufbewahren |
Abgelaufene GeoIP-Cache-Einträge (älter als `GEOIP_CACHE_TTL`) werden unabhängig davon regelmäßig entfernt.
### SQLite-Datei
@@ -332,6 +354,7 @@ ${STATE_DIR}/adguard-shield.db
| `NOTIFY_ENABLED` | `false` | Benachrichtigungen aktivieren |
| `NOTIFY_TYPE` | `ntfy` | Benachrichtigungskanal |
| `NOTIFY_WEBHOOK_URL` | leer | Webhook-URL (nicht für ntfy) |
| `NOTIFY_TLS_INSECURE` | `false` | TLS-Zertifikat des Benachrichtigungsziels nicht prüfen (nur für selbstsignierte/interne Server) |
| `NTFY_SERVER_URL` | `https://ntfy.sh` | Ntfy-Server |
| `NTFY_TOPIC` | leer | Ntfy-Topic |
| `NTFY_TOKEN` | leer | Optionaler Ntfy-Access-Token |
@@ -463,6 +486,9 @@ EXTERNAL_WHITELIST_URLS="https://example.com/a.txt,https://example.net/b.txt"
- Hostnamen werden per DNS aufgelöst und als IPs in SQLite gespeichert.
- Aufgelöste IPs werden bei jedem Sync aktualisiert.
- Bereits aktive Sperren werden aufgehoben, wenn die IP in der Whitelist auftaucht.
- Zu breite Netze (`/0`, IPv4 kürzer als `/8`, IPv6 kürzer als `/16`, auch als IPv4-mapped IPv6 wie `::ffff:0:0/96`) werden mit Warnung ignoriert.
- Sicherheitsbremse: Würde ein Sync mehr als 50 % der aktiven Sperren (und mehr als 100) aufheben, wird nur die Whitelist übernommen, die Freigabe aber mit einem ERROR-Logeintrag abgebrochen.
- Ist der Whitelist-Worker deaktiviert, lädt der Daemon die Whitelist alle 5 Minuten aus der Datenbank neu (z.B. nach `adguard-shield whitelist-sync` über die CLI).
- Kommentare (`#`) und Inline-Kommentare werden unterstützt.
---
@@ -565,7 +591,7 @@ ABUSEIPDB_CATEGORIES="4"
| `GEOIP_SKIP_PRIVATE` | `true` | Private/lokale IPs überspringen |
| `GEOIP_LICENSE_KEY` | leer | MaxMind-License-Key für automatischen Download |
| `GEOIP_MMDB_PATH` | leer | Manueller Pfad zur MaxMind-MMDB-Datei (hat Vorrang) |
| `GEOIP_CACHE_TTL` | `86400` | GeoIP-Cache-Dauer in Sekunden (Standard: 24 Stunden) |
| `GEOIP_CACHE_TTL` | `86400` | GeoIP-Cache-Dauer in Sekunden (Standard: 24 Stunden, `0` = Einträge laufen nicht ab) |
### Modi
+4 -2
View File
@@ -54,6 +54,8 @@ sudo journalctl -u adguard-shield --no-pager -n 100
| API nicht erreichbar | Erst AdGuard Home starten |
| Alte Shell-Artefakte | Go-Installer meldet Konflikte, alte Version deinstallieren |
| Unit manuell geändert | `sudo systemctl daemon-reload` ausführen |
| Konfiguration geändert, aber ohne Wirkung | `sudo systemctl restart adguard-shield` ausführen; `systemctl reload` lädt die Konfiguration nicht neu |
| Warnungen zur Konfiguration im Log | Tippfehler oder ungültige Werte; betroffene Parameter nutzen den Standardwert (siehe [Konfiguration](konfiguration.md#prüfung-beim-laden)) |
### Nützliche Prüfbefehle
@@ -118,7 +120,7 @@ sudo adguard-shield logs --level debug --limit 100
|---|---|
| `RATE_LIMIT_MAX_REQUESTS` zu hoch | Grenzwert senken oder `live` beobachten |
| `RATE_LIMIT_WINDOW` zu kurz | Zeitfenster verlängern |
| `API_QUERY_LIMIT` zu niedrig | Erhöhen, damit Spitzen nicht verpasst werden |
| `API_QUERY_LIMIT` zu niedrig | Erhöhen, wenn das Log „Limit von 20 Seiten erreicht“ meldet |
| Client steht in `WHITELIST` | Whitelist prüfen |
| Externe Whitelist enthält die IP | `whitelist-status` prüfen |
| Proxy/Forwarder maskiert echte Client-IPs | AdGuard Home sieht nur die Forwarder-IP; Forwarder whitelisten |
@@ -184,7 +186,7 @@ sudo adguard-shield firewall-status
### Direkte Prüfung mit Systembefehlen
```bash
# ipsets anzeigen
# ipsets anzeigen (temporäre Sperren mit timeout, permanente ohne)
sudo ipset list adguard_shield_v4
sudo ipset list adguard_shield_v6
+36 -2
View File
@@ -26,6 +26,40 @@ sudo journalctl -u adguard-shield --no-pager -n 50
---
## Update von v1.1.x auf v1.2.0
Das Update läuft wie gewohnt über `sudo ./adguard-shield update`. Datenbank, Sperren und Konfiguration werden übernommen. Vorher solltest du trotzdem ein Backup anlegen:
```bash
sudo systemctl stop adguard-shield
sudo mkdir -p /root/adguard-shield-backup
sudo cp -a /opt/adguard-shield/adguard-shield /opt/adguard-shield/adguard-shield.conf* /root/adguard-shield-backup/
sudo cp -a /var/lib/adguard-shield/adguard-shield.db* /root/adguard-shield-backup/
```
Was automatisch passiert:
| Bereich | Änderung |
|---|---|
| Datenbank | Schema-Migration auf Version 2 (nur zusätzliche Indizes). v1.1.3 kann die Datenbank weiterhin lesen. |
| Konfiguration | Neue Parameter `ADGUARD_TLS_INSECURE` und `NOTIFY_TLS_INSECURE` werden ergänzt, vorhandene Werte bleiben unverändert. Die bisherige Datei liegt danach als `adguard-shield.conf.old` vor (eine ältere `.old` wird dabei ersetzt). |
| systemd-Unit | Wird neu geschrieben, `ExecReload` entfällt. |
| Firewall | Beim Start werden alle Sperren aus der Datenbank per `ipset restore` übernommen. Permanente Sperren, die durch einen Fehler in älteren Versionen nach `BAN_DURATION` aus dem ipset gefallen waren, sind danach wieder aktiv. |
Was du prüfen solltest:
| Thema | Hinweis |
|---|---|
| TLS-Zertifikate | Benachrichtigungen (ntfy, Gotify, Webhooks) und externe Listen prüfen jetzt das TLS-Zertifikat. Nutzt du einen eigenen Server mit selbstsigniertem Zertifikat, schlagen Benachrichtigungen fehl (`Benachrichtigung fehlgeschlagen` im Log). Abhilfe: gültiges Zertifikat oder `NOTIFY_TLS_INSECURE=true`. Für externe Listen gibt es keine Ausnahme – bei Fehlern bleibt die bisherige Whitelist erhalten bzw. der Cache wird genutzt. Die AdGuard-API ist nicht betroffen (`ADGUARD_TLS_INSECURE=true`). |
| Konfigurations-Warnungen | Ungültige, unbekannte oder veraltete Parameter werden beim Start und bei jedem CLI-Befehl als `WARNUNG` gemeldet (z. B. `BAN_HISTORY_FILE` aus der alten Shell-Version). Ungültige Werte werden durch den Standardwert ersetzt. Veraltete Zeilen kannst du aus der Konfiguration löschen. |
| Logrotation | `LOG_MAX_SIZE_MB` (Standard 50) wird jetzt ausgewertet: Das Log wird nach `adguard-shield.log.1` rotiert. |
| `BLOCKED_PORTS` | Kommas sind jetzt erlaubt, ungültige Ports werden mit Warnung ignoriert. Port-Namen wie `domain` werden nicht mehr akzeptiert – bitte Portnummern verwenden. |
| `systemctl reload` | Wird nicht mehr unterstützt; nach Änderungen an der Konfiguration `systemctl restart adguard-shield` verwenden. |
Rollback: Dienst stoppen, Binary und Datenbank aus dem Backup zurückkopieren (`-wal`/`-shm` der neuen Version vorher löschen), `systemctl daemon-reload`, Dienst starten.
---
## Neues Binary beziehen
Du brauchst ein fertiges Linux-Binary. Das kann aus einem Release, aus CI oder aus einem lokalen Build kommen.
@@ -34,7 +68,7 @@ Du brauchst ein fertiges Linux-Binary. Das kann aus einem Release, aus CI oder a
```bash
curl -fL -o adguard-shield-linux-amd64.tar.gz \
https://git.techniverse.net/scriptos/adguard-shield/releases/download/v1.1.3/adguard-shield-linux-amd64.tar.gz
https://git.techniverse.net/scriptos/adguard-shield/releases/download/v1.2.0/adguard-shield-linux-amd64.tar.gz
tar -xzf adguard-shield-linux-amd64.tar.gz
chmod +x ./adguard-shield
```
@@ -50,7 +84,7 @@ GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o adguard-shield ./cmd/adguard-s
```bash
docker run --rm -v "$PWD":/src -w /src \
-e GOOS=linux -e GOARCH=amd64 -e CGO_ENABLED=0 \
golang:1.22 go build -o adguard-shield ./cmd/adguard-shieldd
golang:1.26.9 go build -o adguard-shield ./cmd/adguard-shieldd
```
Auf dem Zielserver muss Go nicht installiert sein, wenn dort nur das fertige Binary ausgeführt wird.
+12 -14
View File
@@ -1,24 +1,22 @@
module adguard-shield
go 1.22
go 1.26.0
require (
github.com/oschwald/maxminddb-golang v1.12.0
modernc.org/sqlite v1.29.10
github.com/oschwald/maxminddb-golang v1.13.1
golang.org/x/net v0.61.0
modernc.org/sqlite v1.60.1
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/dustin/go-humanize v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.19.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.49.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
golang.org/x/sys v0.49.0 // indirect
modernc.org/gc/v3 v3.2.0 // indirect
modernc.org/libc v1.77.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
)
+48 -43
View File
@@ -1,57 +1,62 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oschwald/maxminddb-golang v1.12.0 h1:9FnTOD0YOhP7DGxGsq4glzpGy5+w7pq50AS6wALUMYs=
github.com/oschwald/maxminddb-golang v1.12.0/go.mod h1:q0Nob5lTCqyQ8WT6FYgS1L7PXKVVbgiymefNwIjPzgY=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.20.0 h1:45Or8mQfbUqJOG9WaxvlFYOAQO0lQ5RvqBcFCXngjxk=
modernc.org/cc/v4 v4.20.0/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.16.0 h1:ofwORa6vx2FMm0916/CkZjpFPSR70VwTjUCe2Eg5BnA=
modernc.org/ccgo/v4 v4.16.0/go.mod h1:dkNyWIjFrVIZ68DTo36vHK+6/ShBn4ysU61So6PIqCI=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.49.3 h1:j2MRCRdwJI2ls/sGbeSk0t2bypOG/uvPZUsGQFDulqg=
modernc.org/libc v1.49.3/go.mod h1:yMZuGkn7pXbKfoT/M35gFJOAEdSKdxL0q64sF7KqCDo=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.29.10 h1:3u93dz83myFnMilBGCOLbr+HjklS6+5rJLx4q86RDAg=
modernc.org/sqlite v1.29.10/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+1 -1
View File
@@ -1,5 +1,5 @@
package appinfo
var Version = "v1.1.3"
var Version = "v1.2.0"
const ProjectURL = "https://git.techniverse.net/scriptos/adguard-shield.git"
+234 -85
View File
@@ -5,16 +5,21 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"unicode"
)
type Config struct {
Path string
// Warnings sammelt Konfigurationsprobleme, die beim Laden durch Standardwerte ersetzt wurden.
Warnings []string
AdGuardURL string
AdGuardUser string
AdGuardPass string
AdGuardURL string
AdGuardUser string
AdGuardPass string
AdGuardTLSInsecure bool
RateLimitMaxRequests int
RateLimitWindow int
@@ -37,10 +42,13 @@ type Config struct {
Whitelist []string
LogFile string
LogLevel string
StateDir string
PIDFile string
LogFile string
LogLevel string
LogMaxSizeMB int
StateDir string
PIDFile string
HistoryRetentionDays int
NotifyEnabled bool
NotifyType string
@@ -49,6 +57,8 @@ type Config struct {
NTFYTopic string
NTFYToken string
NTFYPriority string
// NotifyTLSInsecure deaktiviert die Zertifikatsprüfung nur für Benachrichtigungen.
NotifyTLSInsecure bool
ReportEnabled bool
ReportInterval string
@@ -97,76 +107,107 @@ func Load(path string) (*Config, error) {
if err != nil {
return nil, err
}
c := &Config{Path: path}
c.AdGuardURL = stringVal(values, "ADGUARD_URL", "")
c.AdGuardUser = stringVal(values, "ADGUARD_USER", "")
c.AdGuardPass = stringVal(values, "ADGUARD_PASS", "")
c.RateLimitMaxRequests = intVal(values, "RATE_LIMIT_MAX_REQUESTS", 30)
c.RateLimitWindow = intVal(values, "RATE_LIMIT_WINDOW", 60)
c.CheckInterval = intVal(values, "CHECK_INTERVAL", 10)
c.APIQueryLimit = intVal(values, "API_QUERY_LIMIT", 500)
c.SubdomainFloodEnabled = boolVal(values, "SUBDOMAIN_FLOOD_ENABLED", true)
c.SubdomainFloodMaxUnique = intVal(values, "SUBDOMAIN_FLOOD_MAX_UNIQUE", 50)
c.SubdomainFloodWindow = intVal(values, "SUBDOMAIN_FLOOD_WINDOW", 60)
c.DNSFloodWatchlistEnabled = boolVal(values, "DNS_FLOOD_WATCHLIST_ENABLED", false)
c.DNSFloodWatchlist = csv(values["DNS_FLOOD_WATCHLIST"])
c.BanDuration = int64(intVal(values, "BAN_DURATION", 3600))
c.Chain = stringVal(values, "IPTABLES_CHAIN", "ADGUARD_SHIELD")
c.BlockedPorts = fields(stringVal(values, "BLOCKED_PORTS", "53 443 853"))
c.FirewallBackend = stringVal(values, "FIREWALL_BACKEND", "ipset")
c.FirewallMode = strings.ToLower(strings.TrimSpace(stringVal(values, "FIREWALL_MODE", "host")))
c.DryRun = boolVal(values, "DRY_RUN", false)
c, r := load(values)
c.Path = path
c.Warnings = append(r.warnings, r.unknownKeyWarnings()...)
return c, nil
}
// KnownKeys liefert alle Konfigurationsschlüssel, die Load auswertet.
func KnownKeys() []string {
_, r := load(map[string]string{})
keys := make([]string, 0, len(r.used))
for k := range r.used {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func load(values map[string]string) (*Config, *reader) {
r := &reader{values: values, used: map[string]bool{}}
c := &Config{}
c.AdGuardURL = r.str("ADGUARD_URL", "")
c.AdGuardUser = r.str("ADGUARD_USER", "")
c.AdGuardPass = r.str("ADGUARD_PASS", "")
c.AdGuardTLSInsecure = r.bool("ADGUARD_TLS_INSECURE", true)
c.RateLimitMaxRequests = r.intMin("RATE_LIMIT_MAX_REQUESTS", 30, 1)
c.RateLimitWindow = r.intMin("RATE_LIMIT_WINDOW", 60, 1)
c.CheckInterval = r.intMin("CHECK_INTERVAL", 10, 1)
c.APIQueryLimit = r.intMin("API_QUERY_LIMIT", 500, 1)
c.SubdomainFloodEnabled = r.bool("SUBDOMAIN_FLOOD_ENABLED", true)
c.SubdomainFloodMaxUnique = r.intMin("SUBDOMAIN_FLOOD_MAX_UNIQUE", 50, 1)
c.SubdomainFloodWindow = r.intMin("SUBDOMAIN_FLOOD_WINDOW", 60, 1)
c.DNSFloodWatchlistEnabled = r.bool("DNS_FLOOD_WATCHLIST_ENABLED", false)
c.DNSFloodWatchlist = csv(r.str("DNS_FLOOD_WATCHLIST", ""))
c.BanDuration = int64(r.intMin("BAN_DURATION", 3600, 0))
c.Chain = r.str("IPTABLES_CHAIN", "ADGUARD_SHIELD")
c.BlockedPorts = r.ports("BLOCKED_PORTS", "53 443 853")
c.FirewallBackend = r.str("FIREWALL_BACKEND", "ipset")
c.FirewallMode = r.enum("FIREWALL_MODE", "host", strings.ToLower, firewallModes...)
c.DryRun = r.bool("DRY_RUN", false)
if strings.EqualFold(os.Getenv("DRY_RUN"), "true") || os.Getenv("DRY_RUN") == "1" {
c.DryRun = true
}
c.Whitelist = csv(values["WHITELIST"])
c.LogFile = stringVal(values, "LOG_FILE", "/var/log/adguard-shield.log")
c.LogLevel = stringVal(values, "LOG_LEVEL", "INFO")
c.StateDir = stringVal(values, "STATE_DIR", "/var/lib/adguard-shield")
c.PIDFile = stringVal(values, "PID_FILE", "/var/run/adguard-shield.pid")
c.NotifyEnabled = boolVal(values, "NOTIFY_ENABLED", false)
c.NotifyType = stringVal(values, "NOTIFY_TYPE", "ntfy")
c.NotifyWebhook = stringVal(values, "NOTIFY_WEBHOOK_URL", "")
c.NTFYServerURL = stringVal(values, "NTFY_SERVER_URL", "https://ntfy.sh")
c.NTFYTopic = stringVal(values, "NTFY_TOPIC", "")
c.NTFYToken = stringVal(values, "NTFY_TOKEN", "")
c.NTFYPriority = stringVal(values, "NTFY_PRIORITY", "4")
c.ReportEnabled = boolVal(values, "REPORT_ENABLED", false)
c.ReportInterval = stringVal(values, "REPORT_INTERVAL", "weekly")
c.ReportTime = stringVal(values, "REPORT_TIME", "08:00")
c.ReportEmailTo = stringVal(values, "REPORT_EMAIL_TO", "admin@example.com")
c.ReportEmailFrom = stringVal(values, "REPORT_EMAIL_FROM", "adguard-shield@example.com")
c.ReportFormat = strings.ToLower(stringVal(values, "REPORT_FORMAT", "html"))
c.ReportMailCmd = stringVal(values, "REPORT_MAIL_CMD", "msmtp")
c.ReportBusiestDayRange = intVal(values, "REPORT_BUSIEST_DAY_RANGE", 30)
c.ExternalWhitelistEnabled = boolVal(values, "EXTERNAL_WHITELIST_ENABLED", false)
c.ExternalWhitelistURLs = csv(values["EXTERNAL_WHITELIST_URLS"])
c.ExternalWhitelistInterval = intVal(values, "EXTERNAL_WHITELIST_INTERVAL", 300)
c.ExternalWhitelistCacheDir = stringVal(values, "EXTERNAL_WHITELIST_CACHE_DIR", filepath.Join(c.StateDir, "external-whitelist"))
c.ExternalBlocklistEnabled = boolVal(values, "EXTERNAL_BLOCKLIST_ENABLED", false)
c.ExternalBlocklistURLs = csv(values["EXTERNAL_BLOCKLIST_URLS"])
c.ExternalBlocklistInterval = intVal(values, "EXTERNAL_BLOCKLIST_INTERVAL", 300)
c.ExternalBlocklistCacheDir = stringVal(values, "EXTERNAL_BLOCKLIST_CACHE_DIR", filepath.Join(c.StateDir, "external-blocklist"))
c.ExternalBlocklistDuration = int64(intVal(values, "EXTERNAL_BLOCKLIST_BAN_DURATION", 0))
c.ExternalBlocklistAutoUnban = boolVal(values, "EXTERNAL_BLOCKLIST_AUTO_UNBAN", true)
c.ExternalBlocklistNotify = boolVal(values, "EXTERNAL_BLOCKLIST_NOTIFY", false)
c.ProgressiveBanEnabled = boolVal(values, "PROGRESSIVE_BAN_ENABLED", true)
c.ProgressiveBanMultiplier = intVal(values, "PROGRESSIVE_BAN_MULTIPLIER", 2)
c.ProgressiveBanMaxLevel = intVal(values, "PROGRESSIVE_BAN_MAX_LEVEL", 5)
c.ProgressiveBanResetAfter = int64(intVal(values, "PROGRESSIVE_BAN_RESET_AFTER", 86400))
c.AbuseIPDBEnabled = boolVal(values, "ABUSEIPDB_ENABLED", false)
c.AbuseIPDBAPIKey = stringVal(values, "ABUSEIPDB_API_KEY", "")
c.AbuseIPDBCategories = stringVal(values, "ABUSEIPDB_CATEGORIES", "4")
c.GeoIPEnabled = boolVal(values, "GEOIP_ENABLED", false)
c.GeoIPMode = strings.ToLower(stringVal(values, "GEOIP_MODE", "blocklist"))
c.GeoIPCountries = upperCSV(values["GEOIP_COUNTRIES"])
c.GeoIPNotify = boolVal(values, "GEOIP_NOTIFY", true)
c.GeoIPSkipPrivate = boolVal(values, "GEOIP_SKIP_PRIVATE", true)
c.GeoIPLicenseKey = stringVal(values, "GEOIP_LICENSE_KEY", "")
c.GeoIPMMDBPath = stringVal(values, "GEOIP_MMDB_PATH", "")
c.GeoIPCacheTTL = int64(intVal(values, "GEOIP_CACHE_TTL", 86400))
c.GeoIPCheckInterval = intVal(values, "GEOIP_CHECK_INTERVAL", 0)
return c, nil
c.Whitelist = csv(r.str("WHITELIST", ""))
c.LogFile = r.str("LOG_FILE", "/var/log/adguard-shield.log")
c.LogLevel = r.enum("LOG_LEVEL", "INFO", strings.ToUpper, "DEBUG", "INFO", "WARN", "WARNING", "ERROR", "ERR")
c.LogMaxSizeMB = r.intMin("LOG_MAX_SIZE_MB", 50, 0)
c.StateDir = r.str("STATE_DIR", "/var/lib/adguard-shield")
c.PIDFile = r.str("PID_FILE", "/var/run/adguard-shield.pid")
c.HistoryRetentionDays = r.intMin("BAN_HISTORY_RETENTION_DAYS", 0, 0)
c.NotifyEnabled = r.bool("NOTIFY_ENABLED", false)
c.NotifyType = r.enum("NOTIFY_TYPE", "ntfy", strings.ToLower, "ntfy", "discord", "slack", "generic", "gotify")
c.NotifyWebhook = r.str("NOTIFY_WEBHOOK_URL", "")
c.NTFYServerURL = r.str("NTFY_SERVER_URL", "https://ntfy.sh")
c.NTFYTopic = r.str("NTFY_TOPIC", "")
c.NTFYToken = r.str("NTFY_TOKEN", "")
c.NTFYPriority = r.str("NTFY_PRIORITY", "4")
c.NotifyTLSInsecure = r.bool("NOTIFY_TLS_INSECURE", false)
c.ReportEnabled = r.bool("REPORT_ENABLED", false)
c.ReportInterval = r.enum("REPORT_INTERVAL", "weekly", strings.ToLower, "daily", "weekly", "biweekly", "monthly")
c.ReportTime = r.str("REPORT_TIME", "08:00")
c.ReportEmailTo = r.str("REPORT_EMAIL_TO", "admin@example.com")
c.ReportEmailFrom = r.str("REPORT_EMAIL_FROM", "adguard-shield@example.com")
c.ReportFormat = r.enum("REPORT_FORMAT", "html", strings.ToLower, "html", "txt")
c.ReportMailCmd = r.str("REPORT_MAIL_CMD", "msmtp")
c.ReportBusiestDayRange = r.intMin("REPORT_BUSIEST_DAY_RANGE", 30, 0)
c.ExternalWhitelistEnabled = r.bool("EXTERNAL_WHITELIST_ENABLED", false)
c.ExternalWhitelistURLs = csv(r.str("EXTERNAL_WHITELIST_URLS", ""))
c.ExternalWhitelistInterval = r.intMin("EXTERNAL_WHITELIST_INTERVAL", 300, 1)
c.ExternalWhitelistCacheDir = r.str("EXTERNAL_WHITELIST_CACHE_DIR", filepath.Join(c.StateDir, "external-whitelist"))
c.ExternalBlocklistEnabled = r.bool("EXTERNAL_BLOCKLIST_ENABLED", false)
c.ExternalBlocklistURLs = csv(r.str("EXTERNAL_BLOCKLIST_URLS", ""))
c.ExternalBlocklistInterval = r.intMin("EXTERNAL_BLOCKLIST_INTERVAL", 300, 1)
c.ExternalBlocklistCacheDir = r.str("EXTERNAL_BLOCKLIST_CACHE_DIR", filepath.Join(c.StateDir, "external-blocklist"))
c.ExternalBlocklistDuration = int64(r.intMin("EXTERNAL_BLOCKLIST_BAN_DURATION", 0, 0))
c.ExternalBlocklistAutoUnban = r.bool("EXTERNAL_BLOCKLIST_AUTO_UNBAN", true)
c.ExternalBlocklistNotify = r.bool("EXTERNAL_BLOCKLIST_NOTIFY", false)
c.ProgressiveBanEnabled = r.bool("PROGRESSIVE_BAN_ENABLED", true)
c.ProgressiveBanMultiplier = r.intMin("PROGRESSIVE_BAN_MULTIPLIER", 2, 1)
c.ProgressiveBanMaxLevel = r.intMin("PROGRESSIVE_BAN_MAX_LEVEL", 5, 0)
c.ProgressiveBanResetAfter = int64(r.intMin("PROGRESSIVE_BAN_RESET_AFTER", 86400, 0))
c.AbuseIPDBEnabled = r.bool("ABUSEIPDB_ENABLED", false)
c.AbuseIPDBAPIKey = r.str("ABUSEIPDB_API_KEY", "")
c.AbuseIPDBCategories = r.str("ABUSEIPDB_CATEGORIES", "4")
c.GeoIPEnabled = r.bool("GEOIP_ENABLED", false)
c.GeoIPMode = r.enum("GEOIP_MODE", "blocklist", strings.ToLower, "blocklist", "allowlist")
c.GeoIPCountries = upperCSV(r.str("GEOIP_COUNTRIES", ""))
c.GeoIPNotify = r.bool("GEOIP_NOTIFY", true)
c.GeoIPSkipPrivate = r.bool("GEOIP_SKIP_PRIVATE", true)
c.GeoIPLicenseKey = r.str("GEOIP_LICENSE_KEY", "")
c.GeoIPMMDBPath = r.str("GEOIP_MMDB_PATH", "")
c.GeoIPCacheTTL = int64(r.intMin("GEOIP_CACHE_TTL", 86400, 0))
c.GeoIPCheckInterval = r.intMin("GEOIP_CHECK_INTERVAL", 0, 0)
return c, r
}
// Muss zu firewall.normalizeMode passen.
var firewallModes = []string{"host", "classic", "native", "docker-host", "docker", "docker-bridge", "docker-published", "published", "hybrid", "both"}
// Schlüssel der Shell-Version, die die Go-Version bewusst ignoriert.
var deprecatedKeys = map[string]string{
"BAN_HISTORY_FILE": "BAN_HISTORY_FILE wird ignoriert (History liegt in der SQLite-Datenbank)",
}
func DefaultPath() string {
@@ -179,8 +220,7 @@ func DefaultPath() string {
return filepath.Join(".", "adguard-shield.conf")
}
func (c *Config) DBPath() string { return filepath.Join(c.StateDir, "adguard-shield.db") }
func (c *Config) GeoIPDir(scriptDir string) string { return filepath.Join(scriptDir, "geoip") }
func (c *Config) DBPath() string { return filepath.Join(c.StateDir, "adguard-shield.db") }
func parseFile(path string) (map[string]string, error) {
f, err := os.Open(path)
@@ -238,25 +278,48 @@ func unquote(s string) string {
return s
}
func stringVal(m map[string]string, k, def string) string {
if v, ok := m[k]; ok {
type reader struct {
values map[string]string
used map[string]bool
warnings []string
}
func (r *reader) warnf(format string, args ...any) {
r.warnings = append(r.warnings, fmt.Sprintf(format, args...))
}
func (r *reader) lookup(k string) (string, bool) {
r.used[k] = true
v, ok := r.values[k]
return v, ok
}
func (r *reader) str(k, def string) string {
if v, ok := r.lookup(k); ok {
return v
}
return def
}
func intVal(m map[string]string, k string, def int) int {
v, ok := m[k]
func (r *reader) intMin(k string, def, min int) int {
v, ok := r.lookup(k)
if !ok || strings.TrimSpace(v) == "" {
return def
}
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
r.warnf("%s=%q ist keine gültige Zahl, verwende Standardwert %d", k, v, def)
return def
}
if n < min {
r.warnf("%s=%d ist ungültig (Minimum %d), verwende Standardwert %d", k, n, min, def)
return def
}
return n
}
func boolVal(m map[string]string, k string, def bool) bool {
v, ok := m[k]
func (r *reader) bool(k string, def bool) bool {
v, ok := r.lookup(k)
if !ok {
return def
}
@@ -266,9 +329,45 @@ func boolVal(m map[string]string, k string, def bool) bool {
case "false", "0", "no", "off":
return false
default:
r.warnf("%s=%q ist kein gültiger Wahrheitswert (true/false), verwende Standardwert %v", k, v, def)
return def
}
}
func (r *reader) enum(k, def string, norm func(string) string, allowed ...string) string {
v, ok := r.lookup(k)
v = norm(strings.TrimSpace(v))
if !ok || v == "" {
return def
}
for _, a := range allowed {
if v == a {
return v
}
}
r.warnf("%s=%q ist ungültig (erlaubt: %s), verwende Standardwert %s", k, v, strings.Join(allowed, ", "), def)
return def
}
func (r *reader) unknownKeyWarnings() []string {
var unknown []string
for k := range r.values {
if !r.used[k] {
unknown = append(unknown, k)
}
}
sort.Strings(unknown)
out := make([]string, 0, len(unknown))
for _, k := range unknown {
if msg, ok := deprecatedKeys[k]; ok {
out = append(out, msg)
continue
}
out = append(out, fmt.Sprintf("Unbekannter Konfigurationsschlüssel %s wird ignoriert (Tippfehler?)", k))
}
return out
}
func csv(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
@@ -286,10 +385,60 @@ func upperCSV(s string) []string {
}
return parts
}
func fields(s string) []string {
out := strings.Fields(s)
if len(out) == 0 {
// ports liest eine Liste aus Ports bzw. Portbereichen (a:b), getrennt durch
// Kommas oder Leerzeichen. Ungültige Einträge werden mit Warnung ignoriert; ist
// kein Eintrag gültig, gilt der Standardwert.
func (r *reader) ports(k, def string) []string {
v, ok := r.lookup(k)
if !ok {
return strings.Fields(def)
}
parts := strings.FieldsFunc(v, func(c rune) bool { return c == ',' || unicode.IsSpace(c) })
if len(parts) == 0 {
return []string{"53"}
}
var out, invalid []string
for _, p := range parts {
if n, ok := normalizePort(p); ok {
out = append(out, n)
} else {
invalid = append(invalid, p)
}
}
if len(out) == 0 {
r.warnf("%s=%q enthält keine gültigen Ports, verwende Standardwert %s", k, v, def)
return strings.Fields(def)
}
if len(invalid) > 0 {
r.warnf("%s: ungültige Ports %s werden ignoriert (erlaubt: 1-65535 oder Bereich a:b)", k, strings.Join(invalid, ", "))
}
return out
}
func normalizePort(s string) (string, bool) {
lo, hi, isRange := strings.Cut(s, ":")
a, ok := portNumber(lo)
if !ok {
return "", false
}
if !isRange {
return strconv.Itoa(a), true
}
b, ok := portNumber(hi)
if !ok || b < a {
return "", false
}
return strconv.Itoa(a) + ":" + strconv.Itoa(b), true
}
func portNumber(s string) (int, bool) {
if s == "" || strings.TrimLeft(s, "0123456789") != "" {
return 0, false
}
n, err := strconv.Atoi(s)
if err != nil || n < 1 || n > 65535 {
return 0, false
}
return n, true
}
+196
View File
@@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
@@ -42,3 +43,198 @@ GEOIP_CACHE_TTL=123
t.Fatalf("unexpected GeoIP cache ttl: %d", c.GeoIPCacheTTL)
}
}
func loadString(t *testing.T, content string) *Config {
t.Helper()
path := filepath.Join(t.TempDir(), "adguard-shield.conf")
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
t.Fatal(err)
}
c, err := Load(path)
if err != nil {
t.Fatal(err)
}
return c
}
func hasWarning(c *Config, substr string) bool {
for _, w := range c.Warnings {
if strings.Contains(w, substr) {
return true
}
}
return false
}
func TestLoadDefaultsWithoutWarnings(t *testing.T) {
c := loadString(t, "ADGUARD_URL=\"https://dns.example\"\n")
if len(c.Warnings) != 0 {
t.Fatalf("unexpected warnings: %v", c.Warnings)
}
if !c.AdGuardTLSInsecure {
t.Fatal("ADGUARD_TLS_INSECURE should default to true")
}
if c.HistoryRetentionDays != 0 {
t.Fatalf("BAN_HISTORY_RETENTION_DAYS default: %d", c.HistoryRetentionDays)
}
if c.LogMaxSizeMB != 50 {
t.Fatalf("LOG_MAX_SIZE_MB default: %d", c.LogMaxSizeMB)
}
if c.CheckInterval != 10 || c.FirewallMode != "host" || c.NotifyType != "ntfy" || c.ReportInterval != "weekly" {
t.Fatalf("unexpected defaults: %+v", c)
}
}
func TestLoadNewKeys(t *testing.T) {
c := loadString(t, "ADGUARD_TLS_INSECURE=false\nBAN_HISTORY_RETENTION_DAYS=30\nLOG_MAX_SIZE_MB=0\n")
if c.AdGuardTLSInsecure || c.HistoryRetentionDays != 30 || c.LogMaxSizeMB != 0 {
t.Fatalf("unexpected values: tls=%v retention=%d logmax=%d", c.AdGuardTLSInsecure, c.HistoryRetentionDays, c.LogMaxSizeMB)
}
if len(c.Warnings) != 0 {
t.Fatalf("unexpected warnings: %v", c.Warnings)
}
c = loadString(t, "BAN_HISTORY_RETENTION_DAYS=-1\nLOG_MAX_SIZE_MB=-5\n")
if c.HistoryRetentionDays != 0 || c.LogMaxSizeMB != 50 {
t.Fatalf("negative values not reset: retention=%d logmax=%d", c.HistoryRetentionDays, c.LogMaxSizeMB)
}
if !hasWarning(c, "BAN_HISTORY_RETENTION_DAYS") || !hasWarning(c, "LOG_MAX_SIZE_MB") {
t.Fatalf("missing warnings: %v", c.Warnings)
}
}
func TestLoadWarnsOnUnknownAndDeprecatedKeys(t *testing.T) {
c := loadString(t, "CHEK_INTERVAL=5\nBAN_HISTORY_FILE=\"/var/log/adguard-shield-bans.log\"\n")
if !hasWarning(c, "Unbekannter Konfigurationsschlüssel CHEK_INTERVAL") {
t.Fatalf("missing unknown-key warning: %v", c.Warnings)
}
if !hasWarning(c, "BAN_HISTORY_FILE wird ignoriert") || hasWarning(c, "Unbekannter Konfigurationsschlüssel BAN_HISTORY_FILE") {
t.Fatalf("unexpected deprecated-key warnings: %v", c.Warnings)
}
if c.CheckInterval != 10 {
t.Fatalf("CheckInterval: %d", c.CheckInterval)
}
}
func TestLoadWarnsOnUnparseableValues(t *testing.T) {
c := loadString(t, "RATE_LIMIT_WINDOW=abc\nGEOIP_ENABLED=vielleicht\n")
if c.RateLimitWindow != 60 || c.GeoIPEnabled {
t.Fatalf("defaults not used: window=%d geoip=%v", c.RateLimitWindow, c.GeoIPEnabled)
}
if !hasWarning(c, "RATE_LIMIT_WINDOW") || !hasWarning(c, "GEOIP_ENABLED") {
t.Fatalf("missing warnings: %v", c.Warnings)
}
}
func TestLoadSanitizesNonPositiveValues(t *testing.T) {
c := loadString(t, `CHECK_INTERVAL=0
BAN_DURATION=-10
PROGRESSIVE_BAN_MULTIPLIER=0
GEOIP_CACHE_TTL=-5
PROGRESSIVE_BAN_MAX_LEVEL=-1
GEOIP_CHECK_INTERVAL=-3
EXTERNAL_BLOCKLIST_BAN_DURATION=0
`)
if c.CheckInterval != 10 || c.BanDuration != 3600 || c.ProgressiveBanMultiplier != 2 || c.GeoIPCacheTTL != 86400 {
t.Fatalf("positive values not sanitized: %+v", c)
}
if c.ProgressiveBanMaxLevel != 5 || c.GeoIPCheckInterval != 0 || c.ExternalBlocklistDuration != 0 {
t.Fatalf("non-negative values not sanitized: %+v", c)
}
for _, k := range []string{"CHECK_INTERVAL", "BAN_DURATION", "PROGRESSIVE_BAN_MULTIPLIER", "GEOIP_CACHE_TTL", "PROGRESSIVE_BAN_MAX_LEVEL", "GEOIP_CHECK_INTERVAL"} {
if !hasWarning(c, k) {
t.Fatalf("missing warning for %s: %v", k, c.Warnings)
}
}
if hasWarning(c, "EXTERNAL_BLOCKLIST_BAN_DURATION") {
t.Fatalf("0 is valid for EXTERNAL_BLOCKLIST_BAN_DURATION: %v", c.Warnings)
}
}
func TestLoadValidatesEnums(t *testing.T) {
c := loadString(t, `GEOIP_MODE="denylist"
FIREWALL_MODE="iptables"
LOG_LEVEL="verbose"
NOTIFY_TYPE="telegram"
REPORT_FORMAT="pdf"
REPORT_INTERVAL="hourly"
`)
if c.GeoIPMode != "blocklist" || c.FirewallMode != "host" || c.LogLevel != "INFO" || c.NotifyType != "ntfy" || c.ReportFormat != "html" || c.ReportInterval != "weekly" {
t.Fatalf("invalid enums not reset: %+v", c)
}
if len(c.Warnings) != 6 {
t.Fatalf("expected 6 warnings, got %v", c.Warnings)
}
c = loadString(t, `GEOIP_MODE="Allowlist"
FIREWALL_MODE="Docker-Published"
LOG_LEVEL="warning"
NOTIFY_TYPE="Gotify"
REPORT_FORMAT="TXT"
REPORT_INTERVAL="Monthly"
`)
if c.GeoIPMode != "allowlist" || c.FirewallMode != "docker-published" || c.LogLevel != "WARNING" || c.NotifyType != "gotify" || c.ReportFormat != "txt" || c.ReportInterval != "monthly" {
t.Fatalf("valid enums not accepted: %+v", c)
}
if len(c.Warnings) != 0 {
t.Fatalf("unexpected warnings: %v", c.Warnings)
}
}
func TestKnownKeysContainsNewKeys(t *testing.T) {
keys := map[string]bool{}
for _, k := range KnownKeys() {
keys[k] = true
}
for _, k := range []string{"ADGUARD_TLS_INSECURE", "NOTIFY_TLS_INSECURE", "BAN_HISTORY_RETENTION_DAYS", "LOG_MAX_SIZE_MB", "CHECK_INTERVAL", "GEOIP_CACHE_TTL"} {
if !keys[k] {
t.Fatalf("KnownKeys misses %s", k)
}
}
if keys["BAN_HISTORY_FILE"] {
t.Fatal("deprecated key must not be known")
}
}
func TestLoadZeroMeansPermanentOrNeverExpire(t *testing.T) {
c := loadString(t, "BAN_DURATION=0\nGEOIP_CACHE_TTL=0\n")
if c.BanDuration != 0 || c.GeoIPCacheTTL != 0 {
t.Fatalf("0 must be kept: ban=%d ttl=%d", c.BanDuration, c.GeoIPCacheTTL)
}
if len(c.Warnings) != 0 {
t.Fatalf("unexpected warnings: %v", c.Warnings)
}
}
func TestLoadBlockedPorts(t *testing.T) {
cases := []struct {
in string
want string
warning bool
}{
{"", "53 443 853", false},
{`BLOCKED_PORTS="53,853"`, "53 853", false},
{`BLOCKED_PORTS="53, 443 ,853"`, "53 443 853", false},
{`BLOCKED_PORTS="53 1000:2000"`, "53 1000:2000", false},
{`BLOCKED_PORTS="053"`, "53", false},
{`BLOCKED_PORTS="53 abc 70000 0 2000:1000 1:"`, "53", true},
{`BLOCKED_PORTS="abc,99999"`, "53 443 853", true},
{`BLOCKED_PORTS=""`, "53", false},
}
for _, tc := range cases {
c := loadString(t, tc.in+"\n")
if got := strings.Join(c.BlockedPorts, " "); got != tc.want {
t.Errorf("%s: ports = %q, want %q", tc.in, got, tc.want)
}
if got := hasWarning(c, "BLOCKED_PORTS"); got != tc.warning {
t.Errorf("%s: warning = %v, want %v (%v)", tc.in, got, tc.warning, c.Warnings)
}
}
}
func TestLoadNotifyTLSInsecure(t *testing.T) {
if c := loadString(t, "\n"); c.NotifyTLSInsecure {
t.Fatal("NOTIFY_TLS_INSECURE must default to false")
}
if c := loadString(t, "NOTIFY_TLS_INSECURE=true\n"); !c.NotifyTLSInsecure || len(c.Warnings) != 0 {
t.Fatalf("NOTIFY_TLS_INSECURE=true not applied: %v %v", c.NotifyTLSInsecure, c.Warnings)
}
}
+58
View File
@@ -0,0 +1,58 @@
package daemon
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"adguard-shield/internal/appinfo"
"adguard-shield/internal/db"
)
func (d *Daemon) shouldReportAbuseIPDB(b db.Ban) bool {
return b.Source == "monitor" && b.Permanent && d.Config.AbuseIPDBEnabled && d.Config.AbuseIPDBAPIKey != ""
}
func abuseIPDBCheckURL(ip string) string {
return "https://www.abuseipdb.com/check/" + ip
}
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)
if err := d.Store.RecordAbuseIPDBReport(b.IP); err != nil {
d.warn("AbuseIPDB: Meldung für %s nicht gespeichert: %v", b.IP, err)
}
} 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)
}
+201
View File
@@ -0,0 +1,201 @@
package daemon
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"adguard-shield/internal/db"
"adguard-shield/internal/geoip"
)
// BanRequest beschreibt eine Sperre; Domain, Count usw. dienen Historie und Benachrichtigung.
type BanRequest struct {
IP string
Domain string
Count int
Protocol string
Reason string
Source string
Country string
Permanent bool
}
// Ban sperrt eine IP oder ein Netz. Die Entscheidung (Whitelist, bestehende
// Sperre, Offense-Stufe, Eintrag) ist serialisiert; Benachrichtigung und
// AbuseIPDB-Meldung erfolgen nur für tatsächlich neu eingetragene Sperren.
func (d *Daemon) Ban(ctx context.Context, r BanRequest) error {
ip, ok := normalizeTarget(r.IP)
if !ok || strings.HasSuffix(ip, "/0") {
return fmt.Errorf("ungültige IP-Adresse oder ungültiges Netz: %q", r.IP)
}
r.IP = ip
d.banMu.Lock()
b, inserted, err := d.banLocked(ctx, r)
d.banMu.Unlock()
if err != nil || !inserted {
return err
}
abuseReported := d.shouldReportAbuseIPDB(b)
d.notifyBan(b, abuseReported)
if abuseReported {
d.reportAbuseIPDB(context.Background(), b)
}
return nil
}
func (d *Daemon) banLocked(ctx context.Context, r BanRequest) (db.Ban, bool, error) {
if d.isWhitelistedForBan(r.IP) {
if r.Source == "manual" {
return db.Ban{}, false, fmt.Errorf("%s steht auf der Whitelist und wird nicht gesperrt", r.IP)
}
return db.Ban{}, false, nil
}
exists, err := d.Store.BanExists(r.IP)
if err != nil || exists {
return db.Ban{}, false, err
}
if d.Config.DryRun {
_ = d.Store.History("DRY", r.IP, r.Domain, strconv.Itoa(r.Count), "dry-run", r.Protocol, "dry-run ("+r.Reason+")")
d.warn("[DRY-RUN] Würde sperren: %s (%s, %s)", r.IP, r.Reason, r.Domain)
return db.Ban{}, false, nil
}
permanent := r.Permanent
duration := d.Config.BanDuration
level := 0
if r.Source == "monitor" && d.Config.ProgressiveBanEnabled && !permanent {
level, err = d.Store.IncrementOffense(r.IP, d.Config.ProgressiveBanResetAfter)
if err != nil {
return db.Ban{}, false, err
}
duration = durationForLevel(d.Config.BanDuration, level, d.Config.ProgressiveBanMultiplier)
if d.Config.ProgressiveBanMaxLevel > 0 && level >= d.Config.ProgressiveBanMaxLevel {
permanent = true
}
}
if permanent {
duration = 0
}
banUntil := int64(0)
if !permanent && duration > 0 {
banUntil = time.Now().Unix() + duration
}
b := db.Ban{IP: r.IP, Domain: r.Domain, Count: r.Count, BanUntil: banUntil, Duration: duration, OffenseLevel: level, Permanent: permanent, Reason: r.Reason, Protocol: r.Protocol, Source: r.Source, GeoIPCountry: r.Country, GeoIPMode: d.Config.GeoIPMode}
inserted, err := d.Store.InsertBanIfAbsent(b)
if err != nil || !inserted {
return db.Ban{}, false, err
}
if err := d.FW.Add(ctx, r.IP, duration); err != nil {
d.warn("Firewall Add Warnung: %v", err)
}
_ = d.Store.History("BAN", r.IP, r.Domain, strconv.Itoa(r.Count), formatDuration(duration, permanent), r.Protocol, r.Reason)
d.warn("BAN %s (%s, %s)", r.IP, r.Reason, r.Domain)
return b, true, 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)
}
// unban hebt eine Sperre auf. Firewall, Datenbank und Historie werden unter
// banMu geändert (Aufrufer dürfen banMu nicht halten), die Benachrichtigung
// erfolgt außerhalb. FW.Del läuft auch bei abgebrochenem ctx (SIGTERM), damit
// kein ipset-Eintrag ohne Datenbankzeile zurückbleibt.
func (d *Daemon) unban(ctx context.Context, ip, reason string, notify bool) error {
delCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), firewallDelTimeout)
defer cancel()
d.banMu.Lock()
_ = d.FW.Del(delCtx, ip)
err := d.Store.DeleteBan(ip)
if err == nil {
_ = d.Store.History("UNBAN", ip, "-", "-", "-", "-", reason)
}
d.banMu.Unlock()
if err != nil {
return err
}
if notify {
d.notifyUnban(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 {
if ctx.Err() != nil {
return ctx.Err()
}
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
}
// isWhitelisted prüft nur den Whitelist-Snapshot im Speicher (für Massenprüfungen).
func (d *Daemon) isWhitelisted(ip string) bool {
return d.currentWhitelist().contains(ip)
}
// isWhitelistedForBan ergänzt den Snapshot für einzelne IPs um eine
// Datenbankabfrage, damit z.B. per CLI ("whitelist-sync") eingetragene
// Adressen sofort wirken. Netze werden über den periodischen Reload erfasst.
func (d *Daemon) isWhitelistedForBan(ip string) bool {
if d.isWhitelisted(ip) {
return true
}
a, p, ok := parseTarget(ip)
if !ok || p.IsValid() || d.Store == nil {
return false
}
found, err := d.Store.WhitelistContains(a.String())
if err != nil {
d.warn("Whitelist-Abfrage für %s fehlgeschlagen: %v", ip, err)
}
return found
}
// IsWhitelisted prüft eine IP oder ein Netz gegen statische und externe Whitelist (inkl. CIDR).
func (d *Daemon) IsWhitelisted(ip string) bool {
return d.isWhitelistedForBan(ip)
}
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"
}
+159 -1068
View File
File diff suppressed because it is too large Load Diff
+38 -4
View File
@@ -2,6 +2,7 @@ package daemon
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -335,8 +336,42 @@ func TestBaseDomain(t *testing.T) {
if got := baseDomain("a.b.example.com"); got != "example.com" {
t.Fatalf("unexpected base domain: %s", got)
}
if got := baseDomain("a.b.example.co.uk"); got != "example.co.uk" {
t.Fatalf("unexpected multipart base domain: %s", got)
cases := map[string]string{
"a.b.example.co.uk": "example.co.uk",
"x.y.example.com.au": "example.com.au",
"cdn1.foo.github.io": "foo.github.io",
"abc.cloudfront.net": "abc.cloudfront.net",
"x.abc.cloudfront.net": "abc.cloudfront.net",
"Sub.Example.ORG.": "example.org",
"host.lan": "host.lan",
"a.b.c.example.de": "example.de",
"example.com": "example.com",
"com": "",
"co.uk": "",
"localhost": "",
"": "",
"bad..example.com": "",
"1.0.0.10.in-addr.arpa": "10.in-addr.arpa",
"very.deep.a.example.net": "example.net",
}
for in, want := range cases {
if got := baseDomain(in); got != want {
t.Errorf("baseDomain(%q) = %q, want %q", in, got, want)
}
}
}
func TestSubdomainAnalysisSkipsPrivateSuffixAndBaseDomain(t *testing.T) {
now := time.Now()
var events []queryEvent
for i := 0; i < 20; i++ {
events = append(events, queryEvent{At: now, Client: "203.0.113.9", Domain: fmt.Sprintf("u%d.github.io", i)})
events = append(events, queryEvent{At: now, Client: "203.0.113.9", Domain: fmt.Sprintf("r%d.flood.example", i)})
}
events = append(events, queryEvent{At: now, Client: "203.0.113.9", Domain: "flood.example"})
groups := topSubdomainGroups(events, 60, 10)
if len(groups) != 1 || groups[0].Domain != "flood.example" || groups[0].Count != 20 {
t.Fatalf("unexpected groups: %+v", groups)
}
}
@@ -350,9 +385,8 @@ func TestDryRunDoesNotInsertActiveBan(t *testing.T) {
Config: &config.Config{DryRun: true, BanDuration: 60},
Store: store,
FW: firewall.New(firewall.OSExecutor{}, "ADGUARD_SHIELD", []string{"53"}, "host", true),
wl: map[string]bool{},
}
if err := d.Ban(context.Background(), "1.2.3.4", "example.com", 99, "dns", "rate-limit", "monitor", "", false); err != nil {
if err := d.Ban(context.Background(), BanRequest{IP: "1.2.3.4", Domain: "example.com", Count: 99, Protocol: "dns", Reason: "rate-limit", Source: "monitor"}); err != nil {
t.Fatal(err)
}
ok, err := store.BanExists("1.2.3.4")
+219
View File
@@ -0,0 +1,219 @@
package daemon
import (
"context"
"strings"
"time"
"golang.org/x/net/publicsuffix"
)
type queryEvent struct {
At time.Time
Client string
Domain string
Protocol string
}
func (d *Daemon) analysisCutoff(now time.Time) time.Time {
maxWindow := max(d.Config.RateLimitWindow, d.Config.SubdomainFloodWindow)
return now.Add(-time.Duration(maxWindow+30) * time.Second)
}
func (d *Daemon) pollOnce(ctx context.Context) error {
d.mu.Lock()
seenUntil := d.lastNewest
d.mu.Unlock()
entries, err := d.fetchQueryLogSince(ctx, d.analysisCutoff(time.Now()), seenUntil)
if err != nil {
return err
}
events := d.toEvents(entries)
var geoIPs []string
geoSet := map[string]bool{}
d.mu.Lock()
if d.seen == nil {
d.seen = map[string]time.Time{}
}
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 ev.At.After(d.lastNewest) {
d.lastNewest = ev.At
}
if d.Config.GeoIPEnabled && !geoSet[ev.Client] {
geoSet[ev.Client] = true
geoIPs = append(geoIPs, ev.Client)
}
}
d.pruneLocked()
snapshot := append([]queryEvent(nil), d.events...)
d.mu.Unlock()
d.scheduleGeoIP(ctx, geoIPs)
return d.analyze(ctx, snapshot)
}
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 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() {
cut := d.analysisCutoff(time.Now())
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, BanRequest{IP: parts[0], Domain: parts[1], Count: count, Protocol: protos[key], Reason: reason, Source: "monitor", Permanent: 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"
}
if err := d.Ban(ctx, BanRequest{IP: parts[0], Domain: "*." + parts[1], Count: len(set), Protocol: "dns", Reason: reason, Source: "monitor", Permanent: perm}); err != nil {
d.error("Ban Fehler: %v", err)
}
}
}
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 firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
// baseDomain liefert die registrierbare Domain (eTLD+1) laut Public Suffix
// List. Private Suffixe (github.io, cloudfront.net, ...) zählen dabei als
// öffentlich, damit CDN-Subdomains nicht als Subdomain-Flood gelten.
func baseDomain(domain string) string {
domain = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), ".")
if domain == "" {
return ""
}
base, err := publicsuffix.EffectiveTLDPlusOne(domain)
if err != nil {
return ""
}
return base
}
+26
View File
@@ -0,0 +1,26 @@
package daemon
import (
"context"
"os"
"os/exec"
"path/filepath"
)
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)
}
+158
View File
@@ -0,0 +1,158 @@
package daemon
import (
"context"
"sync"
"time"
"adguard-shield/internal/geoip"
)
func (d *Daemon) CheckGeoIPForCommand(ctx context.Context, ip string) {
d.checkGeoIP(ctx, ip)
}
// scheduleGeoIP merkt Clients für die GeoIP-Prüfung vor. Ein einzelner
// Hintergrundlauf arbeitet die (begrenzte) Warteschlange mit geoIPWorkers ab.
func (d *Daemon) scheduleGeoIP(ctx context.Context, ips []string) {
if len(ips) == 0 {
return
}
d.geoMu.Lock()
if d.geoPending == nil {
d.geoPending = map[string]struct{}{}
}
dropped := 0
for _, ip := range ips {
if _, ok := d.geoPending[ip]; ok {
continue
}
if len(d.geoPending) >= geoIPPendingMax {
dropped++
continue
}
d.geoPending[ip] = struct{}{}
}
start := !d.geoRunning && len(d.geoPending) > 0
if start {
d.geoRunning = true
}
d.geoMu.Unlock()
if dropped > 0 {
d.warn("GeoIP-Warteschlange voll, %d Clients übersprungen", dropped)
}
if start {
go d.runGeoIPChecks(ctx)
}
}
func (d *Daemon) runGeoIPChecks(ctx context.Context) {
for {
d.geoMu.Lock()
if len(d.geoPending) == 0 || ctx.Err() != nil {
d.geoPending = nil
d.geoRunning = false
d.geoMu.Unlock()
return
}
batch := make([]string, 0, len(d.geoPending))
for ip := range d.geoPending {
batch = append(batch, ip)
}
d.geoPending = map[string]struct{}{}
d.geoMu.Unlock()
d.debug("GeoIP-Prüfung: %d Clients", len(batch))
ch := make(chan string)
var wg sync.WaitGroup
for i := 0; i < min(geoIPWorkers, len(batch)); i++ {
wg.Add(1)
go func() {
defer wg.Done()
for ip := range ch {
d.checkGeoIP(ctx, ip)
}
}()
}
feed:
for _, ip := range batch {
select {
case ch <- ip:
case <-ctx.Done():
break feed
}
}
close(ch)
wg.Wait()
}
}
func (d *Daemon) checkGeoIP(ctx context.Context, ip string) {
if d.Geo == nil {
return
}
if d.Config.GeoIPSkipPrivate && geoip.IsPrivateIP(ip) {
return
}
if d.isWhitelisted(ip) {
return
}
if !d.shouldCheckGeoIP(ip, time.Now()) {
return
}
if exists, _ := d.Store.BanExists(ip); exists {
return
}
cc, err := d.Geo.Lookup(ip)
if err != nil || cc == "" {
return
}
if geoip.ShouldBlock(cc, d.Config.GeoIPMode, d.Config.GeoIPCountries) {
if err := d.Ban(ctx, BanRequest{IP: ip, Domain: "GeoIP:" + cc, Protocol: "-", Reason: "geoip", Source: "geoip", Country: cc, Permanent: true}); err != nil {
d.error("GeoIP Ban Fehler: %v", err)
}
}
}
func (d *Daemon) refreshGeoIP(ctx context.Context) error {
swapped, err := d.Geo.Refresh(ctx)
if swapped {
d.info("GeoIP-Datenbank aktualisiert")
}
return err
}
func (d *Daemon) shouldCheckGeoIP(ip string, now time.Time) bool {
interval := d.geoIPCheckInterval()
d.mu.Lock()
defer d.mu.Unlock()
if last, ok := d.geoSeen[ip]; ok && now.Sub(last) < interval {
return false
}
d.geoSeen[ip] = now
d.pruneGeoSeenLocked(now, interval)
return true
}
func (d *Daemon) geoIPCheckInterval() time.Duration {
seconds := 0
if d.Config != nil {
seconds = d.Config.GeoIPCheckInterval
if seconds <= 0 {
seconds = d.Config.CheckInterval
}
}
if seconds <= 0 {
seconds = 1
}
return time.Duration(seconds) * time.Second
}
func (d *Daemon) pruneGeoSeenLocked(now time.Time, interval time.Duration) {
cutoff := now.Add(-2 * interval)
for ip, last := range d.geoSeen {
if last.Before(cutoff) {
delete(d.geoSeen, ip)
}
}
}
+339
View File
@@ -0,0 +1,339 @@
package daemon
import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"os"
"path/filepath"
"strings"
"time"
"adguard-shield/internal/db"
"adguard-shield/internal/firewall"
)
// collectListEntries lädt alle Listen und liefert die normalisierten IPs/Netze
// sowie die Zahl der Quellen, die weder geladen noch aus dem Cache gelesen
// werden konnten.
func (d *Daemon) collectListEntries(ctx context.Context, urls []string, cacheDir, prefix string) (map[string]bool, int) {
out := map[string]bool{}
failed := 0
for i, u := range urls {
lines, err := d.fetchCachedLines(ctx, u, cacheDir, prefix, i)
if err != nil {
d.warn("%s Download Warnung %s: %v", prefix, u, err)
failed++
continue
}
for _, line := range lines {
for _, entry := range parseListEntry(line) {
for _, resolved := range resolveEntry(ctx, entry) {
out[resolved] = true
}
}
}
}
return out, failed
}
func (d *Daemon) SyncWhitelist(ctx context.Context) error {
if err := os.MkdirAll(d.Config.ExternalWhitelistCacheDir, 0755); err != nil {
return err
}
ips, failed := d.collectListEntries(ctx, d.Config.ExternalWhitelistURLs, d.Config.ExternalWhitelistCacheDir, "whitelist")
if failed > 0 {
d.warn("Externe Whitelist: %d von %d Quellen nicht verfügbar, bisherige Einträge bleiben erhalten", failed, len(d.Config.ExternalWhitelistURLs))
return fmt.Errorf("externe Whitelist: %d Quelle(n) nicht verfügbar", failed)
}
var list, broad []string
for _, ip := range sortedKeys(ips) {
if whitelistEntryOK(ip) {
list = append(list, ip)
} else {
broad = append(broad, ip)
}
}
if len(broad) > 0 {
d.warn("Externe Whitelist: %d zu breite Netze werden ignoriert (IPv4 mindestens /%d, IPv6 mindestens /%d): %s", len(broad), whitelistMinBits4, whitelistMinBits6, previewList(broad, 10))
}
if err := d.Store.ReplaceWhitelist(list, "external"); err != nil {
return err
}
if err := d.loadCaches(); err != nil {
return err
}
bans, err := d.Store.ActiveBans()
if err != nil {
return err
}
var lift []string
for _, b := range bans {
if d.isWhitelisted(b.IP) {
lift = append(lift, b.IP)
}
}
if whitelistSweepTooLarge(len(lift), len(bans)) {
d.error("Externe Whitelist: Freigabe von %d der %d aktiven Sperren abgebrochen (Sicherheitsbremse, mehr als %d%%); Whitelist ist aktiv, bitte Listen prüfen", len(lift), len(bans), whitelistSweepMaxPercent)
lift = nil
}
for _, ip := range lift {
if ctx.Err() != nil {
return ctx.Err()
}
_ = d.Unban(ctx, ip, "external-whitelist")
}
d.info("Externe Whitelist synchronisiert: %d IPs/Netze", len(list))
return nil
}
// Sicherheitsbremse: Eine fehlerhafte Whitelist darf nicht schlagartig den
// Großteil aller Sperren aufheben.
const (
whitelistSweepMaxPercent = 50
whitelistSweepMinCount = 100
)
func whitelistSweepTooLarge(lift, active int) bool {
return lift > whitelistSweepMinCount && lift*100 > active*whitelistSweepMaxPercent
}
func (d *Daemon) SyncBlocklist(ctx context.Context) error {
if err := os.MkdirAll(d.Config.ExternalBlocklistCacheDir, 0755); err != nil {
return err
}
desired, failed := d.collectListEntries(ctx, d.Config.ExternalBlocklistURLs, d.Config.ExternalBlocklistCacheDir, "blocklist")
for entry := range desired {
if !blockableTarget(entry) {
d.warn("Externe Blocklist: Eintrag %s wird ignoriert", entry)
delete(desired, entry)
}
}
active, err := d.Store.ActiveBans()
if err != nil {
return err
}
existing := make(map[string]bool, len(active))
for _, b := range active {
existing[canonicalOrRaw(b.IP)] = true
}
dur := d.Config.ExternalBlocklistDuration
perm := dur == 0
banUntil := int64(0)
if !perm {
banUntil = time.Now().Unix() + dur
}
var skippedWL []string
var candidates []db.Ban
for _, ip := range sortedKeys(desired) {
if d.isWhitelisted(ip) {
if strings.Contains(ip, "/") {
skippedWL = append(skippedWL, ip)
}
continue
}
if existing[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
}
candidates = append(candidates, db.Ban{IP: ip, Domain: "-", BanUntil: banUntil, Duration: dur, Permanent: perm, Reason: "external-blocklist", Protocol: "-", Source: "external-blocklist"})
}
if len(skippedWL) > 0 {
d.warn("Externe Blocklist: %d Netze überschneiden die Whitelist und werden nicht gesperrt: %s", len(skippedWL), previewList(skippedWL, 10))
}
var added []db.Ban
var entries []firewall.Entry
for _, b := range candidates {
inserted, err := d.Store.InsertBanIfAbsent(b)
if err != nil {
d.warn("Externe Blocklist: Sperre %s nicht gespeichert: %v", b.IP, err)
continue
}
if inserted {
added = append(added, b)
entries = append(entries, firewall.Entry{IP: b.IP, Timeout: dur})
}
}
if len(entries) > 0 {
if err := d.FW.AddBatch(ctx, entries); err != nil {
d.warn("Firewall Add Warnung (externe Blocklist): %v", err)
}
}
for _, b := range added {
_ = d.Store.History("BAN", b.IP, "-", "-", formatDuration(dur, perm), "-", "external-blocklist")
}
d.notifyBlocklistBans(added)
if d.Config.ExternalBlocklistAutoUnban {
if failed > 0 {
d.warn("Externe Blocklist: %d Quelle(n) nicht verfügbar, Auto-Unban wird übersprungen", failed)
} else {
current, err := d.Store.BansBySource("external-blocklist")
if err != nil {
return err
}
for _, b := range current {
if ctx.Err() != nil {
return ctx.Err()
}
if !desired[canonicalOrRaw(b.IP)] {
_ = d.Unban(ctx, b.IP, "external-blocklist-removed")
}
}
}
}
d.info("Externe Blocklist synchronisiert: %d IPs/Netze, %d neu gesperrt", len(desired), len(added))
return nil
}
// blockableTarget verhindert, dass fehlerhafte Listen 0.0.0.0, Loopback oder
// eine komplette Adressfamilie (/0) sperren.
func blockableTarget(s string) bool {
a, p, ok := parseTarget(s)
if !ok {
return false
}
if p.IsValid() {
return p.Bits() > 0 && !p.Addr().IsLoopback()
}
return !a.IsUnspecified() && !a.IsLoopback()
}
func canonicalOrRaw(s string) string {
if n, ok := normalizeTarget(s); ok {
return n
}
return s
}
func previewList(items []string, n int) string {
if len(items) <= n {
return strings.Join(items, ", ")
}
return strings.Join(items[:n], ", ") + ", ..."
}
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
}
// resolveEntry normalisiert IPs/Netze und löst Hostnamen mit Timeout auf.
func resolveEntry(ctx context.Context, entry string) []string {
if n, ok := normalizeTarget(entry); ok {
return []string{n}
}
lookupCtx, cancel := context.WithTimeout(ctx, hostLookupTimeout)
defer cancel()
addrs, err := net.DefaultResolver.LookupHost(lookupCtx, 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.WithZone("").Unmap().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
}
+59 -32
View File
@@ -1,7 +1,7 @@
package daemon
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
@@ -9,6 +9,7 @@ import (
"sort"
"strings"
"time"
"unicode/utf8"
"adguard-shield/internal/db"
"adguard-shield/internal/syslog"
@@ -31,6 +32,7 @@ type liveSnapshot struct {
TopPairs []liveCount
SubdomainGroups []liveCount
ActiveBans []db.Ban
ActiveBanCount int
Offenses int
ExpiredOffenses int
WhitelistCount int
@@ -99,7 +101,10 @@ func (d *Daemon) liveSnapshot(ctx context.Context, opts LiveOptions) (liveSnapsh
snap.TopPairs = topQueryPairs(events, d.Config.RateLimitWindow, opts.Top)
snap.SubdomainGroups = topSubdomainGroups(events, d.Config.SubdomainFloodWindow, opts.Top)
if bans, err := d.Store.ActiveBans(); err == nil {
if n, err := d.Store.CountActiveBans(); err == nil {
snap.ActiveBanCount = n
}
if bans, err := d.Store.RecentActiveBans(opts.Top); err == nil {
snap.ActiveBans = bans
}
if n, err := d.Store.CountOffenses(); err == nil {
@@ -257,15 +262,12 @@ func renderLive(w io.Writer, d *Daemon, snap liveSnapshot, snapErr error, opts L
if len(snap.ActiveBans) == 0 {
fmt.Fprintln(w, " Keine aktiven Sperren.")
} else {
maxBans := opts.Top
if len(snap.ActiveBans) < maxBans {
maxBans = len(snap.ActiveBans)
}
maxBans := min(opts.Top, len(snap.ActiveBans))
for _, b := range snap.ActiveBans[:maxBans] {
fmt.Fprintf(w, " %-39s %-20s %-18s %s\n", trim(b.IP, 39), trim(b.Source, 20), trim(b.Reason, 18), banUntil(b))
}
if len(snap.ActiveBans) > maxBans {
fmt.Fprintf(w, " ... %d weitere\n", len(snap.ActiveBans)-maxBans)
if snap.ActiveBanCount > maxBans {
fmt.Fprintf(w, " ... %d weitere\n", snap.ActiveBanCount-maxBans)
}
}
@@ -281,6 +283,12 @@ func renderLive(w io.Writer, d *Daemon, snap liveSnapshot, snapErr error, opts L
}
}
const logTailChunk = 64 << 10
var logTailMaxRead int64 = 16 << 20
// RecentLogLines liefert die letzten limit Zeilen ab minLevel. Die Datei wird
// vom Ende her blockweise gelesen (höchstens logTailMaxRead Bytes).
func RecentLogLines(path, minLevel string, limit int) []string {
if strings.EqualFold(strings.TrimSpace(minLevel), "off") || path == "" || limit <= 0 {
return nil
@@ -290,27 +298,42 @@ func RecentLogLines(path, minLevel string, limit int) []string {
return nil
}
defer f.Close()
min := syslog.ParseLevel(minLevel, syslog.Info)
ring := make([]string, limit)
count := 0
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 1024), 1024*1024)
for sc.Scan() {
line := sc.Text()
if logLineLevel(line) < min {
continue
st, err := f.Stat()
if err != nil {
return nil
}
minLvl := syslog.ParseLevel(minLevel, syslog.Info)
var rev []string
var carry []byte
pos := st.Size()
read := int64(0)
for pos > 0 && len(rev) < limit && read < logTailMaxRead {
n := min(int64(logTailChunk), pos)
pos -= n
read += n
buf := make([]byte, n, int(n)+len(carry))
if _, err := f.ReadAt(buf, pos); err != nil && err != io.EOF {
break
}
buf = append(buf, carry...)
lines := bytes.Split(buf, []byte{'\n'})
first := 0
carry = nil
if pos > 0 {
carry = append(carry, lines[0]...)
first = 1
}
for i := len(lines) - 1; i >= first && len(rev) < limit; i-- {
line := strings.TrimRight(string(lines[i]), "\r")
if line == "" || logLineLevel(line) < minLvl {
continue
}
rev = append(rev, line)
}
ring[count%limit] = line
count++
}
n := count
if n > limit {
n = limit
}
out := make([]string, 0, n)
start := count - n
for i := 0; i < n; i++ {
out = append(out, ring[(start+i)%limit])
out := make([]string, len(rev))
for i, line := range rev {
out[len(rev)-1-i] = line
}
return out
}
@@ -366,14 +389,18 @@ func listOrDash(items []string) string {
return strings.Join(items, ",")
}
func trim(s string, max int) string {
if len(s) <= max {
func trim(s string, n int) string {
if utf8.RuneCountInString(s) <= n {
return s
}
if max <= 1 {
return s[:max]
if n <= 0 {
return ""
}
return s[:max-1] + "~"
r := []rune(s)
if n == 1 {
return string(r[:1])
}
return string(r[:n-1]) + "~"
}
func banUntil(b db.Ban) string {
+354
View File
@@ -0,0 +1,354 @@
package daemon
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"strconv"
"strings"
"time"
"adguard-shield/internal/appinfo"
"adguard-shield/internal/db"
)
func (d *Daemon) notifyBlocklistBans(added []db.Ban) {
if len(added) == 0 || !d.Config.NotifyEnabled || !d.Config.ExternalBlocklistNotify {
return
}
if len(added) <= blocklistNotifyMax {
for _, b := range added {
d.notifyBan(b, false)
}
return
}
ips := make([]string, 0, blocklistSummaryIPsMax)
for _, b := range added[:blocklistSummaryIPsMax] {
ips = append(ips, b.IP)
}
first, count := added[0], len(added)
d.enqueueNotification("Blocklist-Zusammenfassung", func(ctx context.Context) {
msg := fmt.Sprintf("🚫 AdGuard Shield Externe Blocklist auf %s\n---\nNeue Sperren: %d\nDauer: %s\nBeispiele: %s, ...",
d.serverHostname(), count, formatNotificationDuration(first.Duration, first.Permanent), strings.Join(ips, ", "))
d.sendNotification(ctx, notifyTitle, msg, db.Ban{Domain: "-", Reason: "external-blocklist", Source: "external-blocklist"})
})
}
func (d *Daemon) notifyBan(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
}
d.enqueueNotification("Ban "+b.IP, func(ctx context.Context) {
d.sendNotification(ctx, notifyTitle, d.banNotificationMessage(ctx, b, abuseReported), b)
})
}
func (d *Daemon) notifyUnban(ip, reason string) {
if !d.Config.NotifyEnabled {
return
}
d.enqueueNotification("Unban "+ip, func(ctx context.Context) {
msg := fmt.Sprintf("✅ AdGuard Shield Freigabe auf %s\n---\nIP: %s\nHostname: %s\n\nAbuseIPDB: %s", d.serverHostname(), ip, lookupPTR(ctx, ip), abuseIPDBCheckURL(ip))
d.sendNotification(ctx, notifyTitle, msg, db.Ban{IP: ip, Reason: reason})
})
}
// NotifyBulkUnban meldet eine Sammel-Freigabe (ctx bleibt aus Kompatibilitätsgründen).
func (d *Daemon) NotifyBulkUnban(_ context.Context, reason string, count int) {
if !d.Config.NotifyEnabled || count <= 0 {
return
}
d.enqueueNotification("Bulk-Freigabe", func(ctx context.Context) {
msg := fmt.Sprintf("✅ AdGuard Shield Bulk-Freigabe auf %s\n---\nFreigegebene IPs: %d\nAktion: %s", d.serverHostname(), count, displayReason(reason))
d.sendNotification(ctx, notifyTitle, 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, notifyTitle, 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) serverHostname() string {
name, err := os.Hostname()
if err != nil || strings.TrimSpace(name) == "" {
return "unbekannt"
}
return strings.TrimSpace(name)
}
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 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
}
client := d.NotifyClient
if client == nil {
client = d.Client
}
resp, err := 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) 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
}
+100
View File
@@ -0,0 +1,100 @@
package daemon
import (
"context"
"sync"
"time"
)
const (
notifyQueueSize = 256
notifyJobTimeout = 30 * time.Second
notifyDrainTimeout = 10 * time.Second
)
type notifyJob func(ctx context.Context)
// notifier arbeitet Benachrichtigungen im Daemon-Betrieb asynchron ab. Ohne
// laufenden Worker (CLI-Befehle) werden Jobs synchron ausgeführt.
type notifier struct {
mu sync.RWMutex
queue chan notifyJob
running bool
stopped bool
done chan struct{}
}
func (d *Daemon) startNotifier() {
n := &d.notify
n.mu.Lock()
defer n.mu.Unlock()
if n.running {
return
}
size := d.notifyQueueSize
if size <= 0 {
size = notifyQueueSize
}
n.queue = make(chan notifyJob, size)
n.done = make(chan struct{})
n.running = true
n.stopped = false
go func(q chan notifyJob, done chan struct{}) {
defer close(done)
for job := range q {
ctx, cancel := context.WithTimeout(context.Background(), notifyJobTimeout)
job(ctx)
cancel()
}
}(n.queue, n.done)
}
// stopNotifier nimmt keine neuen Jobs mehr an und wartet höchstens timeout
// darauf, dass die Warteschlange abgearbeitet ist.
func (d *Daemon) stopNotifier(timeout time.Duration) bool {
n := &d.notify
n.mu.Lock()
if !n.running {
n.mu.Unlock()
return true
}
n.running = false
n.stopped = true
pending := len(n.queue)
close(n.queue)
done := n.done
n.mu.Unlock()
if pending > 0 {
d.info("Warte auf %d ausstehende Benachrichtigungen", pending)
}
select {
case <-done:
return true
case <-time.After(timeout):
d.warn("Benachrichtigungs-Warteschlange nicht vollständig abgearbeitet (Timeout %s)", timeout)
return false
}
}
func (d *Daemon) enqueueNotification(what string, job notifyJob) {
n := &d.notify
n.mu.RLock()
if n.running {
select {
case n.queue <- job:
default:
d.warn("Benachrichtigungs-Warteschlange voll, verwerfe %s", what)
}
n.mu.RUnlock()
return
}
stopped := n.stopped
n.mu.RUnlock()
if stopped {
d.warn("Dienst wird beendet, verwerfe Benachrichtigung %s", what)
return
}
ctx, cancel := context.WithTimeout(context.Background(), notifyJobTimeout)
defer cancel()
job(ctx)
}
+139
View File
@@ -0,0 +1,139 @@
package daemon
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const queryLogMaxPages = 20
type queryLogResponse struct {
Data []queryItem `json:"data"`
Oldest string `json:"oldest"`
}
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"`
}
// FetchQueryLog liest die neueste Seite des AdGuard-Querylogs.
func (d *Daemon) FetchQueryLog(ctx context.Context) ([]queryItem, error) {
page, err := d.fetchQueryLogPage(ctx, "")
if err != nil {
return nil, err
}
return page.Data, nil
}
func (d *Daemon) fetchQueryLogPage(ctx context.Context, olderThan string) (queryLogResponse, error) {
q := url.Values{}
q.Set("limit", strconv.Itoa(d.Config.APIQueryLimit))
q.Set("response_status", "all")
if olderThan != "" {
q.Set("older_than", olderThan)
}
u := strings.TrimRight(d.Config.AdGuardURL, "/") + "/control/querylog?" + q.Encode()
var out queryLogResponse
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return out, err
}
if d.Config.AdGuardUser != "" || d.Config.AdGuardPass != "" {
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(d.Config.AdGuardUser+":"+d.Config.AdGuardPass)))
}
client := d.AdGuardClient
if client == nil {
client = d.Client
}
resp, err := client.Do(req)
if err != nil {
return out, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return out, fmt.Errorf("AdGuard API HTTP %d", resp.StatusCode)
}
err = json.NewDecoder(resp.Body).Decode(&out)
return out, err
}
// fetchQueryLogSince blättert rückwärts durch das Querylog, bis die Seite
// unvollständig ist, cutoff unterschritten wird, bereits im letzten Poll
// verarbeitete Einträge erreicht sind (seenUntil) oder queryLogMaxPages erreicht ist.
func (d *Daemon) fetchQueryLogSince(ctx context.Context, cutoff, seenUntil time.Time) ([]queryItem, error) {
var all []queryItem
olderThan := ""
pages := 0
defer func() {
if pages > 1 {
d.debug("Querylog: %d Seiten mit %d Einträgen geladen", pages, len(all))
}
}()
for page := 0; ; page++ {
if page >= queryLogMaxPages {
if d.shouldWarnQueryLogLimit(time.Now()) {
d.warn("Querylog: Limit von %d Seiten erreicht, ältere Einträge werden nicht ausgewertet (Hinweis höchstens alle %s)", queryLogMaxPages, queryLogLimitWarnEvery)
} else {
d.debug("Querylog: Limit von %d Seiten erreicht", queryLogMaxPages)
}
break
}
res, err := d.fetchQueryLogPage(ctx, olderThan)
if err != nil {
if page == 0 {
return nil, err
}
d.warn("Querylog: Seite %d nicht geladen: %v", page+1, err)
break
}
pages++
all = append(all, res.Data...)
if len(res.Data) == 0 || len(res.Data) < d.Config.APIQueryLimit {
break
}
oldest := res.Oldest
if oldest == "" {
oldest = res.Data[len(res.Data)-1].Time
}
t, err := parseAGHTime(oldest)
if err != nil || oldest == olderThan {
break
}
if t.Before(cutoff) {
break
}
if !seenUntil.IsZero() && !t.After(seenUntil) {
break
}
olderThan = oldest
}
return all, nil
}
// shouldWarnQueryLogLimit begrenzt die Warnung bei Dauerlast (Flood) auf eine
// pro queryLogLimitWarnEvery.
func (d *Daemon) shouldWarnQueryLogLimit(now time.Time) bool {
d.mu.Lock()
defer d.mu.Unlock()
if !d.queryLogLimitWarned.IsZero() && now.Sub(d.queryLogLimitWarned) < queryLogLimitWarnEvery {
return false
}
d.queryLogLimitWarned = now
return true
}
+938
View File
@@ -0,0 +1,938 @@
package daemon
import (
"context"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"adguard-shield/internal/config"
"adguard-shield/internal/db"
"adguard-shield/internal/firewall"
"adguard-shield/internal/geoip"
"adguard-shield/internal/syslog"
)
func newNilGeo(dir string) *geoip.Resolver {
return geoip.New("", "", dir, 0, nil)
}
type fakeExec struct {
mu sync.Mutex
runs [][]string
inputs []string
}
func (f *fakeExec) Run(_ context.Context, name string, args ...string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.runs = append(f.runs, append([]string{name}, args...))
return nil
}
func (f *fakeExec) RunInput(_ context.Context, input string, name string, args ...string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.inputs = append(f.inputs, input)
return nil
}
func (f *fakeExec) Output(context.Context, string, ...string) ([]byte, error) { return nil, nil }
func (f *fakeExec) ipsetAdds() [][]string {
f.mu.Lock()
defer f.mu.Unlock()
var out [][]string
for _, r := range f.runs {
if len(r) > 1 && r[0] == "ipset" && r[1] == "add" {
out = append(out, r)
}
}
return out
}
func (f *fakeExec) restoreInputs() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.inputs...)
}
type notifySink struct {
srv *httptest.Server
count atomic.Int32
mu sync.Mutex
body []string
}
func newNotifySink(t *testing.T) *notifySink {
s := &notifySink{}
s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
s.mu.Lock()
s.body = append(s.body, string(b))
s.mu.Unlock()
s.count.Add(1)
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(s.srv.Close)
return s
}
func (s *notifySink) bodies() []string {
s.mu.Lock()
defer s.mu.Unlock()
return append([]string(nil), s.body...)
}
func newTestDaemon(t *testing.T, cfg *config.Config) (*Daemon, *fakeExec) {
t.Helper()
store, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { store.Close() })
if cfg.BanDuration == 0 {
cfg.BanDuration = 3600
}
ex := &fakeExec{}
d := &Daemon{
Config: cfg,
Store: store,
FW: firewall.New(ex, "ADGUARD_SHIELD", []string{"53"}, "host", false),
Client: http.DefaultClient,
geoSeen: map[string]time.Time{},
}
return d, ex
}
func countHistory(t *testing.T, st *db.Store, ip, action string) int {
t.Helper()
var n int
if err := st.DB.QueryRow(`SELECT COUNT(*) FROM ban_history WHERE client_ip=? AND action=?`, ip, action).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
func TestWhitelistMatchesAddressesAndPrefixes(t *testing.T) {
w, _ := newWhitelist([]string{"10.0.0.0/24", "2001:db8::/48", "192.168.1.1", "::ffff:172.16.0.5", " fd00::1 ", "kein-eintrag", "::ffff:100.64.0.0/112"})
cases := map[string]bool{
"10.0.0.7": true,
"10.0.0.255": true,
"10.0.1.1": false,
"::ffff:10.0.0.9": true,
"2001:db8:0:1::5": true,
"2001:db8:1::1": false,
"2001:DB8::abcd": true,
"172.16.0.5": true,
"::ffff:172.16.0.5": true,
"192.168.1.1": true,
"192.168.1.2": false,
"fd00::1": true,
"fd00:0:0::1": true,
"fe80::1%eth0": false,
"100.64.255.1": true,
"100.65.0.1": false,
"10.0.0.128/25": true,
"10.0.0.0/16": true,
"192.168.0.0/16": true,
"192.168.2.0/24": false,
"203.0.113.0/24": false,
"::ffff:10.0.0.0/120": true,
"2001:db8::/32": true,
"2001:db9::/32": false,
"fd00::/64": true,
"kein-eintrag": false,
"": false,
"300.1.1.1": false,
"10.0.0.0/33": false,
"192.168.1.1/32": true,
"::ffff:192.168.1.1/128": true,
}
for in, want := range cases {
if got := w.contains(in); got != want {
t.Errorf("contains(%q) = %v, want %v", in, got, want)
}
}
if w.size() != 6 {
t.Fatalf("size = %d, want 6 (invalid entry ignored)", w.size())
}
}
func TestNormalizeTarget(t *testing.T) {
cases := map[string]string{
"1.2.3.4": "1.2.3.4",
"::ffff:1.2.3.4": "1.2.3.4",
"2001:0DB8::0001": "2001:db8::1",
"10.1.2.3/8": "10.0.0.0/8",
"::ffff:10.0.0.0/104": "10.0.0.0/8",
"fe80::1%eth0": "fe80::1",
}
for in, want := range cases {
got, ok := normalizeTarget(in)
if !ok || got != want {
t.Errorf("normalizeTarget(%q) = %q,%v want %q", in, got, ok, want)
}
}
for _, bad := range []string{"foo", "", "1.2.3", "1.2.3.4/40", "example.com"} {
if _, ok := normalizeTarget(bad); ok {
t.Errorf("normalizeTarget(%q) must fail", bad)
}
}
}
func TestDaemonWhitelistCombinesStaticAndDBEntriesLazily(t *testing.T) {
d, _ := newTestDaemon(t, &config.Config{Whitelist: []string{"192.0.2.0/28"}})
if err := d.Store.ReplaceWhitelist([]string{"198.51.100.0/24", "203.0.113.9"}, "external"); err != nil {
t.Fatal(err)
}
for ip, want := range map[string]bool{"192.0.2.5": true, "192.0.2.17": false, "198.51.100.77": true, "203.0.113.9": true, "203.0.113.10": false} {
if got := d.IsWhitelisted(ip); got != want {
t.Errorf("IsWhitelisted(%q) = %v, want %v", ip, got, want)
}
}
}
func TestWhitelistConcurrentReadAndReload(t *testing.T) {
d, _ := newTestDaemon(t, &config.Config{Whitelist: []string{"10.0.0.0/8"}})
var wg sync.WaitGroup
stop := make(chan struct{})
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
if !d.isWhitelisted("10.1.2.3") {
t.Error("static whitelist entry lost during reload")
return
}
}
}
}()
}
for i := 0; i < 50; i++ {
if err := d.loadCaches(); err != nil {
t.Fatal(err)
}
}
close(stop)
wg.Wait()
}
func TestBanValidation(t *testing.T) {
d, ex := newTestDaemon(t, &config.Config{Whitelist: []string{"10.0.0.0/24"}})
ctx := context.Background()
for _, bad := range []string{"foo", "", "1.2.3.4/33", "999.1.1.1"} {
if err := d.Ban(ctx, BanRequest{IP: bad, Source: "manual", Reason: "manual"}); err == nil {
t.Errorf("Ban(%q) must fail", bad)
}
}
if n, _ := d.Store.CountActiveBans(); n != 0 {
t.Fatalf("invalid bans must not create rows, got %d", n)
}
err := d.Ban(ctx, BanRequest{IP: "10.0.0.5", Source: "manual", Reason: "manual", Permanent: true})
if err == nil || !strings.Contains(err.Error(), "Whitelist") {
t.Fatalf("manual ban of whitelisted IP must explain itself, got %v", err)
}
if err := d.Ban(ctx, BanRequest{IP: "10.0.0.6", Source: "monitor", Reason: "rate-limit"}); err != nil {
t.Fatalf("automatic ban of whitelisted IP must be silent, got %v", err)
}
if n, _ := d.Store.CountActiveBans(); n != 0 {
t.Fatalf("whitelisted IPs must not be banned, got %d", n)
}
if err := d.Ban(ctx, BanRequest{IP: "::ffff:203.0.113.4", Source: "manual", Reason: "manual", Permanent: true}); err != nil {
t.Fatal(err)
}
b, ok, _ := d.Store.BanByIP("203.0.113.4")
if !ok || !b.Permanent || b.Duration != 0 || b.BanUntil != 0 {
t.Fatalf("manual ban not stored canonically/permanent: %+v ok=%v", b, ok)
}
adds := ex.ipsetAdds()
if len(adds) != 1 || strings.Join(adds[0], " ") != "ipset add adguard_shield_v4 203.0.113.4 -exist" {
t.Fatalf("permanent ban must be added without timeout: %v", adds)
}
}
func TestConcurrentBansCreateExactlyOneBan(t *testing.T) {
sink := newNotifySink(t)
d, ex := newTestDaemon(t, &config.Config{
NotifyEnabled: true, NotifyType: "generic", NotifyWebhook: sink.srv.URL,
ProgressiveBanEnabled: true, ProgressiveBanMultiplier: 2, ProgressiveBanResetAfter: 86400, GeoIPNotify: true,
RateLimitWindow: 60,
})
d.Client = sink.srv.Client()
const n = 40
var wg sync.WaitGroup
errs := make(chan error, n)
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
src := "monitor"
if i%3 == 1 {
src = "geoip"
}
errs <- d.Ban(context.Background(), BanRequest{IP: "203.0.113.50", Domain: "example.com", Count: 100, Protocol: "dns", Reason: "rate-limit", Source: src})
}(i)
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
if got := countHistory(t, d.Store, "203.0.113.50", "BAN"); got != 1 {
t.Fatalf("history BAN rows = %d, want 1", got)
}
if got := len(ex.ipsetAdds()); got != 1 {
t.Fatalf("firewall adds = %d, want 1", got)
}
if got := sink.count.Load(); got != 1 {
t.Fatalf("notifications = %d, want 1", got)
}
o, ok, _ := d.Store.OffenseByIP("203.0.113.50")
if ok && o.Level > 1 {
t.Fatalf("offense incremented %d times", o.Level)
}
}
func TestBanSkipsWhenRowInsertedByOtherProcess(t *testing.T) {
sink := newNotifySink(t)
d, ex := newTestDaemon(t, &config.Config{NotifyEnabled: true, NotifyType: "generic", NotifyWebhook: sink.srv.URL})
d.Client = sink.srv.Client()
if err := d.Store.InsertBan(db.Ban{IP: "203.0.113.60", Source: "manual", Reason: "manual", Permanent: true}); err != nil {
t.Fatal(err)
}
if err := d.Ban(context.Background(), BanRequest{IP: "203.0.113.60", Source: "monitor", Reason: "rate-limit"}); err != nil {
t.Fatal(err)
}
if len(ex.ipsetAdds()) != 0 || sink.count.Load() != 0 || countHistory(t, d.Store, "203.0.113.60", "BAN") != 0 {
t.Fatal("existing ban must not trigger firewall, history or notification")
}
}
func listServer(t *testing.T, body *atomic.Value) *httptest.Server {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
v, _ := body.Load().(string)
if v == "FAIL" {
http.Error(w, "kaputt", http.StatusInternalServerError)
return
}
fmt.Fprint(w, v)
}))
t.Cleanup(srv.Close)
return srv
}
func deadURL(t *testing.T) string {
srv := httptest.NewServer(http.NotFoundHandler())
u := srv.URL + "/list.txt"
srv.Close()
return u
}
func TestSyncWhitelistFailureKeepsOldEntries(t *testing.T) {
var body atomic.Value
body.Store("198.51.100.0/24\n")
srv := listServer(t, &body)
cfg := &config.Config{ExternalWhitelistURLs: []string{srv.URL, deadURL(t)}, ExternalWhitelistCacheDir: t.TempDir()}
d, _ := newTestDaemon(t, cfg)
if err := d.Store.ReplaceWhitelist([]string{"192.0.2.1"}, "external"); err != nil {
t.Fatal(err)
}
if err := d.Store.InsertBan(db.Ban{IP: "198.51.100.20", Source: "monitor", Reason: "rate-limit"}); err != nil {
t.Fatal(err)
}
if err := d.SyncWhitelist(context.Background()); err == nil {
t.Fatal("sync with unavailable source must fail")
}
if !d.IsWhitelisted("192.0.2.1") {
t.Fatal("old whitelist entry must be kept in memory")
}
if ok, _ := d.Store.WhitelistContains("192.0.2.1"); !ok {
t.Fatal("old whitelist entry must be kept in DB")
}
if ok, _ := d.Store.BanExists("198.51.100.20"); !ok {
t.Fatal("failed sync must not unban")
}
cfg.ExternalWhitelistURLs = cfg.ExternalWhitelistURLs[:1]
if err := d.SyncWhitelist(context.Background()); err != nil {
t.Fatal(err)
}
if d.IsWhitelisted("192.0.2.1") || !d.IsWhitelisted("198.51.100.200") {
t.Fatal("successful sync must replace whitelist and support CIDR")
}
if ok, _ := d.Store.BanExists("198.51.100.20"); ok {
t.Fatal("ban inside new whitelisted network must be lifted")
}
}
func TestSyncBlocklistFailureSkipsAutoUnban(t *testing.T) {
var body atomic.Value
body.Store("203.0.113.5\n")
srv := listServer(t, &body)
cfg := &config.Config{
ExternalBlocklistURLs: []string{srv.URL, deadURL(t)},
ExternalBlocklistCacheDir: t.TempDir(),
ExternalBlocklistAutoUnban: true,
}
d, _ := newTestDaemon(t, cfg)
if err := d.Store.InsertBan(db.Ban{IP: "198.51.100.7", Source: "external-blocklist", Reason: "external-blocklist", Permanent: true}); err != nil {
t.Fatal(err)
}
if err := d.SyncBlocklist(context.Background()); err != nil {
t.Fatal(err)
}
if ok, _ := d.Store.BanExists("198.51.100.7"); !ok {
t.Fatal("auto-unban must be skipped while a source is unavailable")
}
if ok, _ := d.Store.BanExists("203.0.113.5"); !ok {
t.Fatal("entries of available sources must still be banned")
}
cfg.ExternalBlocklistURLs = cfg.ExternalBlocklistURLs[:1]
if err := d.SyncBlocklist(context.Background()); err != nil {
t.Fatal(err)
}
if ok, _ := d.Store.BanExists("198.51.100.7"); ok {
t.Fatal("removed entry must be unbanned when all sources are available")
}
}
func TestSyncBlocklistAddsOnlyNewEntriesInOneBatch(t *testing.T) {
var body atomic.Value
body.Store(strings.Join([]string{
"203.0.113.1", "203.0.113.2 # Kommentar", "198.51.100.0/24", "2001:DB8:0::/48",
"10.0.0.0/8", "0.0.0.0", "127.0.0.1", "0.0.0.0/0", "::ffff:203.0.113.3", "",
}, "\n"))
srv := listServer(t, &body)
cfg := &config.Config{
Whitelist: []string{"10.1.2.3"},
ExternalBlocklistURLs: []string{srv.URL},
ExternalBlocklistCacheDir: t.TempDir(),
ExternalBlocklistDuration: 7200,
}
d, ex := newTestDaemon(t, cfg)
if err := d.Store.InsertBan(db.Ban{IP: "203.0.113.2", Source: "monitor", Reason: "rate-limit", BanUntil: time.Now().Unix() + 100, Duration: 100}); err != nil {
t.Fatal(err)
}
if err := d.SyncBlocklist(context.Background()); err != nil {
t.Fatal(err)
}
inputs := ex.restoreInputs()
if len(inputs) != 1 {
t.Fatalf("expected one ipset restore batch, got %d", len(inputs))
}
want := []string{
"add adguard_shield_v4 198.51.100.0/24 timeout 7200",
"add adguard_shield_v6 2001:db8::/48 timeout 7200",
"add adguard_shield_v4 203.0.113.1 timeout 7200",
"add adguard_shield_v4 203.0.113.3 timeout 7200",
}
got := strings.Split(strings.TrimSpace(inputs[0]), "\n")
if strings.Join(got, "|") != strings.Join(want, "|") {
t.Fatalf("batch = %q, want %q", got, want)
}
if len(ex.ipsetAdds()) != 0 {
t.Fatal("blocklist must not use single ipset add calls")
}
for _, ip := range []string{"10.0.0.0/8", "0.0.0.0", "127.0.0.1", "0.0.0.0/0"} {
if ok, _ := d.Store.BanExists(ip); ok {
t.Fatalf("%s must not be banned", ip)
}
}
if b, _, _ := d.Store.BanByIP("203.0.113.2"); b.Source != "monitor" {
t.Fatal("existing ban must not be replaced")
}
if n := countHistory(t, d.Store, "203.0.113.1", "BAN"); n != 1 {
t.Fatalf("history rows = %d", n)
}
if err := d.SyncBlocklist(context.Background()); err != nil {
t.Fatal(err)
}
if got := len(ex.restoreInputs()); got != 1 {
t.Fatalf("second sync must not touch the firewall again, batches = %d", got)
}
if n := countHistory(t, d.Store, "203.0.113.1", "BAN"); n != 1 {
t.Fatalf("second sync must not write history again: %d", n)
}
}
func TestSyncBlocklistNotificationSummary(t *testing.T) {
for _, tc := range []struct {
entries int
want int32
}{{3, 3}, {5, 5}, {7, 1}} {
t.Run(strconv.Itoa(tc.entries), func(t *testing.T) {
var lines []string
for i := 1; i <= tc.entries; i++ {
lines = append(lines, fmt.Sprintf("203.0.113.%d", i))
}
var body atomic.Value
body.Store(strings.Join(lines, "\n"))
srv := listServer(t, &body)
sink := newNotifySink(t)
d, _ := newTestDaemon(t, &config.Config{
NotifyEnabled: true, NotifyType: "generic", NotifyWebhook: sink.srv.URL,
ExternalBlocklistNotify: true, ExternalBlocklistURLs: []string{srv.URL}, ExternalBlocklistCacheDir: t.TempDir(),
})
if err := d.SyncBlocklist(context.Background()); err != nil {
t.Fatal(err)
}
if got := sink.count.Load(); got != tc.want {
t.Fatalf("notifications = %d, want %d", got, tc.want)
}
if tc.want == 1 && !strings.Contains(sink.bodies()[0], "Neue Sperren: 7") {
t.Fatalf("unexpected summary: %s", sink.bodies()[0])
}
})
}
}
func TestReconcileFirewallUsesBatch(t *testing.T) {
d, ex := newTestDaemon(t, &config.Config{})
now := time.Now().Unix()
for i := 0; i < 300; i++ {
if err := d.Store.InsertBan(db.Ban{IP: fmt.Sprintf("10.%d.%d.1", i/250, i%250), Permanent: true, Source: "geoip", Reason: "geoip"}); err != nil {
t.Fatal(err)
}
}
if err := d.Store.InsertBan(db.Ban{IP: "198.51.100.1", BanUntil: now + 500, Duration: 600, Source: "monitor"}); err != nil {
t.Fatal(err)
}
if err := d.reconcileFirewall(context.Background()); err != nil {
t.Fatal(err)
}
inputs := ex.restoreInputs()
if len(inputs) != 1 || len(ex.ipsetAdds()) != 0 {
t.Fatalf("reconcile must use one batch, got %d batches and %d single adds", len(inputs), len(ex.ipsetAdds()))
}
if strings.Count(inputs[0], "\n") != 301 || !strings.Contains(inputs[0], "add adguard_shield_v4 198.51.100.1 timeout ") {
t.Fatalf("unexpected batch content")
}
}
func TestNotificationQueueDropsWhenFullAndDrains(t *testing.T) {
d := &Daemon{Config: &config.Config{}, notifyQueueSize: 2}
var ran atomic.Int32
block := make(chan struct{})
started := make(chan struct{})
d.startNotifier()
d.enqueueNotification("blocker", func(context.Context) {
close(started)
<-block
ran.Add(1)
})
<-started
for i := 0; i < 4; i++ {
d.enqueueNotification("job", func(context.Context) { ran.Add(1) })
}
close(block)
if !d.stopNotifier(2 * time.Second) {
t.Fatal("queue must drain")
}
if got := ran.Load(); got != 3 {
t.Fatalf("executed %d jobs, want 3 (blocker + 2 queued, 2 dropped)", got)
}
d.enqueueNotification("late", func(context.Context) { ran.Add(1) })
if ran.Load() != 3 {
t.Fatal("jobs after shutdown must be dropped")
}
}
func TestNotificationQueueDrainTimeout(t *testing.T) {
d := &Daemon{Config: &config.Config{}}
block := make(chan struct{})
defer close(block)
d.startNotifier()
d.enqueueNotification("hang", func(context.Context) { <-block })
start := time.Now()
if d.stopNotifier(50 * time.Millisecond) {
t.Fatal("hanging job must hit the drain timeout")
}
if time.Since(start) > time.Second {
t.Fatal("drain timeout not honoured")
}
}
func TestNotificationsRunSynchronouslyWithoutWorker(t *testing.T) {
d := &Daemon{Config: &config.Config{}}
ran := false
d.enqueueNotification("cli", func(context.Context) { ran = true })
if !ran {
t.Fatal("without worker the job must run synchronously")
}
}
func TestServiceStopAfterQueueDrain(t *testing.T) {
var mu sync.Mutex
var order []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
mu.Lock()
switch {
case strings.Contains(string(b), "service_stop"):
order = append(order, "stop")
case strings.Contains(string(b), "Freigabe"):
time.Sleep(100 * time.Millisecond)
order = append(order, "unban")
}
mu.Unlock()
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
d := &Daemon{Config: &config.Config{NotifyEnabled: true, NotifyType: "generic", NotifyWebhook: srv.URL}, Client: srv.Client()}
d.startNotifier()
d.notifyUnban("203.0.113.1", "manual")
d.stopNotifier(notifyDrainTimeout)
d.NotifyServiceStop(context.Background())
mu.Lock()
defer mu.Unlock()
if strings.Join(order, ",") != "unban,stop" {
t.Fatalf("order = %v", order)
}
}
type fakeAGH struct {
t *testing.T
times []time.Time
requests atomic.Int32
olders []string
mu sync.Mutex
}
func (f *fakeAGH) format(tm time.Time) string {
return tm.In(time.FixedZone("CEST", 2*3600)).Format(time.RFC3339Nano)
}
func (f *fakeAGH) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.requests.Add(1)
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
older := r.URL.Query().Get("older_than")
f.mu.Lock()
f.olders = append(f.olders, older)
f.mu.Unlock()
start := 0
if older != "" {
ot, err := time.Parse(time.RFC3339Nano, older)
if err != nil {
http.Error(w, "bad older_than", http.StatusBadRequest)
return
}
start = len(f.times)
for i, tm := range f.times {
if tm.Before(ot) {
start = i
break
}
}
}
end := min(start+limit, len(f.times))
var items []string
oldest := ""
for _, tm := range f.times[start:end] {
items = append(items, fmt.Sprintf(`{"time":%q,"client":"203.0.113.1","question":{"name":"x%d.example.com"}}`, f.format(tm), tm.UnixNano()))
oldest = f.format(tm)
}
fmt.Fprintf(w, `{"data":[%s],"oldest":%q}`, strings.Join(items, ","), oldest)
}
func newFakeAGH(t *testing.T, n int, step time.Duration) (*fakeAGH, *Daemon) {
f := &fakeAGH{t: t}
base := time.Now().Truncate(time.Millisecond).Add(123456 * time.Nanosecond)
for i := 0; i < n; i++ {
f.times = append(f.times, base.Add(-time.Duration(i)*step))
}
srv := httptest.NewServer(f)
t.Cleanup(srv.Close)
d := &Daemon{Config: &config.Config{AdGuardURL: srv.URL + "/", APIQueryLimit: 5, RateLimitWindow: 60, SubdomainFloodWindow: 60}, Client: srv.Client()}
return f, d
}
func TestQueryLogPaginationStopsOnShortPage(t *testing.T) {
f, d := newFakeAGH(t, 12, time.Second)
items, err := d.fetchQueryLogSince(context.Background(), time.Now().Add(-time.Hour), time.Time{})
if err != nil {
t.Fatal(err)
}
if len(items) != 12 || f.requests.Load() != 3 {
t.Fatalf("items=%d requests=%d", len(items), f.requests.Load())
}
f.mu.Lock()
defer f.mu.Unlock()
if f.olders[0] != "" || f.olders[1] != f.format(f.times[4]) || f.olders[2] != f.format(f.times[9]) {
t.Fatalf("older_than not round-tripped exactly: %q", f.olders)
}
seen := map[string]bool{}
for _, it := range items {
if seen[it.Time] {
t.Fatalf("duplicate entry %s", it.Time)
}
seen[it.Time] = true
}
}
func TestQueryLogPaginationStopsAtCutoff(t *testing.T) {
f, d := newFakeAGH(t, 100, time.Second)
cutoff := f.times[12]
items, err := d.fetchQueryLogSince(context.Background(), cutoff, time.Time{})
if err != nil {
t.Fatal(err)
}
if f.requests.Load() != 3 || len(items) != 15 {
t.Fatalf("requests=%d items=%d, want 3/15", f.requests.Load(), len(items))
}
}
func TestQueryLogPaginationStopsAtSeenEntries(t *testing.T) {
f, d := newFakeAGH(t, 100, time.Second)
items, err := d.fetchQueryLogSince(context.Background(), time.Now().Add(-time.Hour), f.times[7])
if err != nil {
t.Fatal(err)
}
if f.requests.Load() != 2 || len(items) != 10 {
t.Fatalf("requests=%d items=%d, want 2/10", f.requests.Load(), len(items))
}
}
func TestQueryLogPaginationHardCap(t *testing.T) {
f, d := newFakeAGH(t, 1000, time.Millisecond)
items, err := d.fetchQueryLogSince(context.Background(), time.Now().Add(-time.Hour), time.Time{})
if err != nil {
t.Fatal(err)
}
if f.requests.Load() != queryLogMaxPages || len(items) != queryLogMaxPages*5 {
t.Fatalf("requests=%d items=%d", f.requests.Load(), len(items))
}
}
func TestPollOnceUsesPaginationAndTracksNewest(t *testing.T) {
f, d := newFakeAGH(t, 12, time.Second)
d.Config.RateLimitMaxRequests = 1000
if err := d.pollOnce(context.Background()); err != nil {
t.Fatal(err)
}
if len(d.events) != 12 || !d.lastNewest.Equal(f.times[0]) {
t.Fatalf("events=%d newest=%v", len(d.events), d.lastNewest)
}
before := f.requests.Load()
if err := d.pollOnce(context.Background()); err != nil {
t.Fatal(err)
}
if f.requests.Load()-before != 1 || len(d.events) != 12 {
t.Fatalf("second poll must stop after the first page, requests=%d events=%d", f.requests.Load()-before, len(d.events))
}
}
func TestFetchQueryLogUsesAdGuardClient(t *testing.T) {
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"data":[]}`)
}))
srv.Config.ErrorLog = log.New(io.Discard, "", 0)
srv.StartTLS()
defer srv.Close()
d := &Daemon{
Config: &config.Config{AdGuardURL: srv.URL, APIQueryLimit: 5},
Client: &http.Client{Transport: newTransport(false)},
AdGuardClient: &http.Client{Transport: newTransport(true)},
}
if _, err := d.FetchQueryLog(context.Background()); err != nil {
t.Fatalf("AdGuard client with ADGUARD_TLS_INSECURE must accept self-signed cert: %v", err)
}
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
if _, err := d.Client.Do(req); err == nil {
t.Fatal("general client must verify certificates")
}
d.AdGuardClient = &http.Client{Transport: newTransport(false)}
if _, err := d.FetchQueryLog(context.Background()); err == nil {
t.Fatal("ADGUARD_TLS_INSECURE=false must verify certificates")
}
}
func TestRetentionPrunesHistoryAndGeoIPCache(t *testing.T) {
d, _ := newTestDaemon(t, &config.Config{HistoryRetentionDays: 30, GeoIPCacheTTL: 86400})
now := time.Now()
old := now.Add(-40 * 24 * time.Hour).Unix()
for _, ep := range []int64{old, now.Unix()} {
if _, err := d.Store.DB.Exec(`INSERT INTO ban_history (timestamp_epoch, timestamp_text, action, client_ip) VALUES (?, 'x', 'BAN', '203.0.113.1')`, ep); err != nil {
t.Fatal(err)
}
}
for ip, ep := range map[string]int64{"203.0.113.1": now.Add(-2 * 24 * time.Hour).Unix(), "203.0.113.2": now.Unix()} {
if _, err := d.Store.DB.Exec(`INSERT INTO geoip_cache (ip, country_code, looked_up_at_epoch) VALUES (?, 'DE', ?)`, ip, ep); err != nil {
t.Fatal(err)
}
}
if err := d.runRetention(now); err != nil {
t.Fatal(err)
}
if n := countHistory(t, d.Store, "203.0.113.1", "BAN"); n != 1 {
t.Fatalf("history rows = %d, want 1", n)
}
var geo int
_ = d.Store.DB.QueryRow(`SELECT COUNT(*) FROM geoip_cache`).Scan(&geo)
if geo != 1 {
t.Fatalf("geoip cache rows = %d, want 1", geo)
}
d.Config.HistoryRetentionDays = 0
if _, err := d.Store.DB.Exec(`INSERT INTO ban_history (timestamp_epoch, timestamp_text, action, client_ip) VALUES (?, 'x', 'BAN', '203.0.113.1')`, old); err != nil {
t.Fatal(err)
}
if err := d.runRetention(now); err != nil {
t.Fatal(err)
}
if n := countHistory(t, d.Store, "203.0.113.1", "BAN"); n != 2 {
t.Fatalf("retention 0 must keep history, rows = %d", n)
}
}
func TestRecentLogLinesReadsTailOfLargeFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "shield.log")
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
levels := []string{"INFO", "DEBUG", "WARN", "INFO", "ERROR"}
const total = 60000
for i := 0; i < total; i++ {
fmt.Fprintf(f, "2026/10/10 12:00:00 [%s] [ADGUARD-SHIELDD] Zeile %06d %s\n", levels[i%len(levels)], i, strings.Repeat("ä", i%40))
}
f.Close()
if st, _ := os.Stat(path); st.Size() < 4<<20 {
t.Fatalf("test file too small: %d", st.Size())
}
lines := RecentLogLines(path, "INFO", 7)
if len(lines) != 7 {
t.Fatalf("got %d lines", len(lines))
}
var want []string
for i := total - 1; i >= 0 && len(want) < 7; i-- {
if levels[i%len(levels)] != "DEBUG" {
want = append([]string{fmt.Sprintf("Zeile %06d %s", i, strings.Repeat("ä", i%40))}, want...)
}
}
for i := range want {
if !strings.HasSuffix(lines[i], want[i]) {
t.Fatalf("line %d = %q, want suffix %q", i, lines[i], want[i])
}
}
errs := RecentLogLines(path, "ERROR", 2000)
if len(errs) != 2000 || !strings.Contains(errs[1999], fmt.Sprintf("Zeile %06d", total-1)) {
t.Fatalf("unexpected ERROR tail: %d lines", len(errs))
}
for i := 1; i < len(errs); i++ {
if errs[i-1] >= errs[i] || !strings.Contains(errs[i], "[ERROR]") {
t.Fatalf("ERROR lines out of order or wrong level at %d", i)
}
}
oldMax := logTailMaxRead
logTailMaxRead = 1 << 20
all := RecentLogLines(path, "DEBUG", total+10)
logTailMaxRead = oldMax
if len(all) == 0 || len(all) >= total {
t.Fatalf("read must be capped, got %d lines", len(all))
}
if RecentLogLines(path, "off", 5) != nil || RecentLogLines(filepath.Join(t.TempDir(), "fehlt.log"), "INFO", 5) != nil {
t.Fatal("off/missing must return nil")
}
}
func TestRecentLogLinesSmallFileWithoutTrailingNewline(t *testing.T) {
path := filepath.Join(t.TempDir(), "shield.log")
if err := os.WriteFile(path, []byte("a [INFO] eins\r\n\nb [DEBUG] zwei\nc [WARN] drei"), 0640); err != nil {
t.Fatal(err)
}
got := RecentLogLines(path, "INFO", 10)
if strings.Join(got, "|") != "a [INFO] eins|c [WARN] drei" {
t.Fatalf("got %q", got)
}
}
func TestTrimIsRuneSafe(t *testing.T) {
if got := trim("äöüäöü", 4); got != "äöü~" {
t.Fatalf("got %q", got)
}
if got := trim("abc", 3); got != "abc" {
t.Fatalf("got %q", got)
}
if got := trim("äbc", 1); got != "ä" {
t.Fatalf("got %q", got)
}
if got := trim("abc", 0); got != "" {
t.Fatalf("got %q", got)
}
}
func TestScheduleGeoIPRunsSingleBoundedBatch(t *testing.T) {
d, _ := newTestDaemon(t, &config.Config{GeoIPEnabled: true})
ips := make([]string, geoIPPendingMax+50)
for i := range ips {
ips[i] = fmt.Sprintf("10.%d.%d.%d", i/65536, (i/256)%256, i%256)
}
d.scheduleGeoIP(context.Background(), ips)
deadline := time.Now().Add(5 * time.Second)
for {
d.geoMu.Lock()
running := d.geoRunning
d.geoMu.Unlock()
if !running {
break
}
if time.Now().After(deadline) {
t.Fatal("GeoIP batch did not finish")
}
time.Sleep(5 * time.Millisecond)
}
}
func TestRunWritesConfigWarningsAndRotatesLog(t *testing.T) {
dir := t.TempDir()
logPath := filepath.Join(dir, "shield.log")
if err := os.WriteFile(logPath, []byte(strings.Repeat("x", 2<<20)), 0640); err != nil {
t.Fatal(err)
}
lf, err := syslog.OpenFile(logPath)
if err != nil {
t.Fatal(err)
}
defer lf.Close()
agh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `{"data":[]}`) }))
defer agh.Close()
d, _ := newTestDaemon(t, &config.Config{
Warnings: []string{"TEST_KEY unbekannt"}, LogMaxSizeMB: 1, CheckInterval: 1, APIQueryLimit: 5,
RateLimitWindow: 60, SubdomainFloodWindow: 60, AdGuardURL: agh.URL, Path: filepath.Join(dir, "x.conf"),
})
d.LogFile = lf
d.Logger = syslog.New(lf, "INFO")
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
d.Geo = newNilGeo(dir)
_ = d.Run(ctx)
cur, _ := os.ReadFile(logPath)
if !strings.Contains(string(cur), "[WARN] [ADGUARD-SHIELDD] Konfiguration: TEST_KEY unbekannt") {
t.Fatalf("config warning not logged: %q", cur)
}
if len(cur) > 1<<20 {
t.Fatal("log must have been rotated")
}
if st, err := os.Stat(logPath + ".1"); err != nil || st.Size() != 2<<20 {
t.Fatal("rotated backup missing")
}
}
+364
View File
@@ -0,0 +1,364 @@
package daemon
import (
"context"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"adguard-shield/internal/config"
"adguard-shield/internal/db"
"adguard-shield/internal/firewall"
)
func TestNewWhitelistSkipsBroadPrefixes(t *testing.T) {
w, skipped := newWhitelist([]string{
"::ffff:0:0/96", "::ffff:0.0.0.0/96", "::ffff:0:0/100", "0.0.0.0/0", "::/0", "10.0.0.0/7", "2000::/15",
"10.0.0.0/8", "2001:db8::/16", "::ffff:0:0/80", "192.0.2.1",
})
if len(skipped) != 7 {
t.Fatalf("skipped = %v, want 7 entries", skipped)
}
for ip, want := range map[string]bool{
"8.8.8.8": false,
"1.1.1.1": false,
"::ffff:8.8.8.8": false,
"2a00::1": false,
"10.200.0.1": true,
"2001:db8:ff::1": true,
"192.0.2.1": true,
"203.0.113.0/24": false,
"0.0.0.0/0": true, // überlappt 10.0.0.0/8
"198.51.100.0/24": false,
} {
if got := w.contains(ip); got != want {
t.Errorf("contains(%q) = %v, want %v", ip, got, want)
}
}
for _, e := range []string{"::ffff:0:0/96", "0.0.0.0/0", "::/0", "10.0.0.0/7"} {
if whitelistEntryOK(e) {
t.Errorf("whitelistEntryOK(%q) must be false", e)
}
}
for _, e := range []string{"10.0.0.0/8", "192.0.2.1", "2001:db8::/16"} {
if !whitelistEntryOK(e) {
t.Errorf("whitelistEntryOK(%q) must be true", e)
}
}
}
func TestSyncWhitelistIgnoresBroadEntries(t *testing.T) {
var body atomic.Value
body.Store("::ffff:0:0/96\n0.0.0.0/0\n::/0\n198.51.100.0/24\n")
srv := listServer(t, &body)
cfg := &config.Config{ExternalWhitelistURLs: []string{srv.URL}, ExternalWhitelistCacheDir: t.TempDir()}
d, _ := newTestDaemon(t, cfg)
for _, ip := range []string{"203.0.113.1", "198.51.100.7"} {
if err := d.Store.InsertBan(db.Ban{IP: ip, Source: "monitor", Reason: "rate-limit", Permanent: true}); err != nil {
t.Fatal(err)
}
}
if err := d.SyncWhitelist(context.Background()); err != nil {
t.Fatal(err)
}
if ok, _ := d.Store.BanExists("203.0.113.1"); !ok {
t.Fatal("broad whitelist entries must not unban unrelated IPs")
}
if ok, _ := d.Store.BanExists("198.51.100.7"); ok {
t.Fatal("valid whitelist network must still lift bans")
}
wl, _ := d.Store.AllWhitelist()
if len(wl) != 1 || !wl["198.51.100.0/24"] {
t.Fatalf("broad entries must not be stored: %v", wl)
}
if d.IsWhitelisted("8.8.8.8") {
t.Fatal("8.8.8.8 must not be whitelisted")
}
}
func insertBans(t *testing.T, st *db.Store, prefix string, n int) {
t.Helper()
tx, err := st.DB.Begin()
if err != nil {
t.Fatal(err)
}
for i := 1; i <= n; i++ {
if _, err := tx.Exec(`INSERT INTO active_bans (client_ip, is_permanent, reason, source) VALUES (?, 1, 'rate-limit', 'monitor')`, fmt.Sprintf("%s.%d", prefix, i)); err != nil {
t.Fatal(err)
}
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
}
func TestWhitelistSweepTooLarge(t *testing.T) {
for _, tc := range []struct {
lift, active int
want bool
}{
{100, 100, false},
{101, 150, true},
{101, 202, false},
{101, 201, true},
{12000, 25600, false},
{13000, 25600, true},
{0, 0, false},
} {
if got := whitelistSweepTooLarge(tc.lift, tc.active); got != tc.want {
t.Errorf("whitelistSweepTooLarge(%d, %d) = %v, want %v", tc.lift, tc.active, got, tc.want)
}
}
}
func TestSyncWhitelistSafetyBrake(t *testing.T) {
var body atomic.Value
body.Store("10.0.0.0/16\n")
srv := listServer(t, &body)
cfg := &config.Config{ExternalWhitelistURLs: []string{srv.URL}, ExternalWhitelistCacheDir: t.TempDir()}
d, _ := newTestDaemon(t, cfg)
insertBans(t, d.Store, "10.0.0", 150)
insertBans(t, d.Store, "203.0.113", 10)
if err := d.SyncWhitelist(context.Background()); err != nil {
t.Fatal(err)
}
if n, _ := d.Store.CountActiveBans(); n != 160 {
t.Fatalf("safety brake must keep all bans, got %d", n)
}
if !d.IsWhitelisted("10.0.0.1") {
t.Fatal("whitelist itself must still be applied")
}
insertBans(t, d.Store, "203.0.114", 200)
if err := d.SyncWhitelist(context.Background()); err != nil {
t.Fatal(err)
}
if n, _ := d.Store.CountActiveBans(); n != 210 {
t.Fatalf("sweep below threshold must unban the 150 whitelisted IPs, active=%d", n)
}
}
func TestBanChecksDBWhitelistOnSnapshotMiss(t *testing.T) {
d, ex := newTestDaemon(t, &config.Config{})
if err := d.loadCaches(); err != nil {
t.Fatal(err)
}
// Eintrag aus einem anderen Prozess (CLI whitelist-sync), Snapshot unverändert.
if err := d.Store.ReplaceWhitelist([]string{"203.0.113.9"}, "external"); err != nil {
t.Fatal(err)
}
if d.isWhitelisted("203.0.113.9") {
t.Fatal("snapshot must not know the new entry yet")
}
if err := d.Ban(context.Background(), BanRequest{IP: "::ffff:203.0.113.9", Source: "monitor", Reason: "rate-limit"}); err != nil {
t.Fatal(err)
}
if n, _ := d.Store.CountActiveBans(); n != 0 || len(ex.ipsetAdds()) != 0 {
t.Fatalf("DB-whitelisted IP must not be banned (bans=%d)", n)
}
if !d.IsWhitelisted("203.0.113.9") {
t.Fatal("IsWhitelisted must consult the DB")
}
if err := d.loadCaches(); err != nil {
t.Fatal(err)
}
if !d.isWhitelisted("203.0.113.9") {
t.Fatal("reload must pick up the DB entry")
}
}
func TestBanRejectsZeroPrefix(t *testing.T) {
d, _ := newTestDaemon(t, &config.Config{})
for _, ip := range []string{"0.0.0.0/0", "::/0", "::ffff:0:0/96"} {
if err := d.Ban(context.Background(), BanRequest{IP: ip, Source: "manual", Reason: "manual", Permanent: true}); err == nil {
t.Errorf("Ban(%q) must fail", ip)
}
}
if n, _ := d.Store.CountActiveBans(); n != 0 {
t.Fatalf("no ban expected, got %d", n)
}
}
type ctxExec struct {
mu sync.Mutex
delErrs []error
}
func (c *ctxExec) Run(ctx context.Context, name string, args ...string) error {
if len(args) > 0 && args[0] == "del" {
c.mu.Lock()
c.delErrs = append(c.delErrs, ctx.Err())
c.mu.Unlock()
}
return ctx.Err()
}
func (c *ctxExec) RunInput(ctx context.Context, _ string, _ string, _ ...string) error {
return ctx.Err()
}
func (c *ctxExec) Output(context.Context, string, ...string) ([]byte, error) { return nil, nil }
func TestUnbanDeletesFirewallEntryDespiteCancelledContext(t *testing.T) {
d, _ := newTestDaemon(t, &config.Config{})
ex := &ctxExec{}
d.FW = firewall.New(ex, "ADGUARD_SHIELD", []string{"53"}, "host", false)
if err := d.Store.InsertBan(db.Ban{IP: "203.0.113.1", Source: "manual", Reason: "manual", Permanent: true}); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := d.Unban(ctx, "203.0.113.1", "manual"); err != nil {
t.Fatal(err)
}
if len(ex.delErrs) != 1 || ex.delErrs[0] != nil {
t.Fatalf("ipset del must run with a live context: %v", ex.delErrs)
}
if ok, _ := d.Store.BanExists("203.0.113.1"); ok {
t.Fatal("ban must be deleted")
}
}
func TestSweepsStopOnCancelledContext(t *testing.T) {
d, _ := newTestDaemon(t, &config.Config{GeoIPEnabled: false})
for _, ip := range []string{"203.0.113.1", "203.0.113.2"} {
if err := d.Store.InsertBan(db.Ban{IP: ip, Source: "geoip", Reason: "geoip", Permanent: true}); err != nil {
t.Fatal(err)
}
}
if err := d.Store.InsertBan(db.Ban{IP: "203.0.113.3", Source: "monitor", Reason: "rate-limit", BanUntil: 1, Duration: 60}); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := d.AutoUnbanGeoIP(ctx); err == nil {
t.Fatal("cancelled GeoIP sweep must report ctx error")
}
if err := d.reconcileFirewall(ctx); err == nil {
t.Fatal("cancelled reconcile must report ctx error")
}
if n, _ := d.Store.CountActiveBans(); n != 3 {
t.Fatalf("cancelled sweeps must not unban, active=%d", n)
}
if err := d.AutoUnbanGeoIP(context.Background()); err != nil {
t.Fatal(err)
}
if n, _ := d.Store.CountActiveBans(); n != 1 {
t.Fatalf("GeoIP sweep must unban both GeoIP bans, active=%d", n)
}
}
func TestUnbanWaitsForBanMutex(t *testing.T) {
d, _ := newTestDaemon(t, &config.Config{})
if err := d.Store.InsertBan(db.Ban{IP: "203.0.113.1", Source: "manual", Reason: "manual", Permanent: true}); err != nil {
t.Fatal(err)
}
d.banMu.Lock()
done := make(chan error, 1)
go func() { done <- d.Unban(context.Background(), "203.0.113.1", "manual") }()
select {
case <-done:
d.banMu.Unlock()
t.Fatal("unban must wait for banMu")
case <-time.After(50 * time.Millisecond):
}
if ok, _ := d.Store.BanExists("203.0.113.1"); !ok {
t.Fatal("ban must still exist while banMu is held")
}
d.banMu.Unlock()
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(5 * time.Second):
t.Fatal("unban did not finish")
}
}
func TestBanDurationZeroMeansPermanentMonitorBan(t *testing.T) {
d, ex := newTestDaemon(t, &config.Config{ProgressiveBanEnabled: true, ProgressiveBanMultiplier: 2, ProgressiveBanMaxLevel: 5, ProgressiveBanResetAfter: 86400})
d.Config.BanDuration = 0
if err := d.Ban(context.Background(), BanRequest{IP: "203.0.113.1", Source: "monitor", Reason: "rate-limit"}); err != nil {
t.Fatal(err)
}
b, ok, _ := d.Store.BanByIP("203.0.113.1")
if !ok || b.BanUntil != 0 || b.Duration != 0 {
t.Fatalf("unexpected ban: %+v", b)
}
adds := ex.ipsetAdds()
if len(adds) != 1 || strings.Contains(strings.Join(adds[0], " "), "timeout") {
t.Fatalf("ban without duration must not get a timeout: %v", adds)
}
if expired, _ := d.Store.ExpiredBans(time.Now().Unix() + 1<<30); len(expired) != 0 {
t.Fatalf("ban must never expire: %v", expired)
}
if got := durationForLevel(0, 4, 2); got != 0 {
t.Fatalf("durationForLevel(0) = %d", got)
}
}
func TestRetentionSkipsGeoIPPruningWithZeroTTL(t *testing.T) {
d, _ := newTestDaemon(t, &config.Config{HistoryRetentionDays: 30, GeoIPCacheTTL: 0})
if _, err := d.Store.DB.Exec(`INSERT INTO geoip_cache (ip, country_code, looked_up_at_epoch) VALUES ('203.0.113.1', 'DE', 1)`); err != nil {
t.Fatal(err)
}
if err := d.runRetention(time.Now()); err != nil {
t.Fatal(err)
}
var n int
_ = d.Store.DB.QueryRow(`SELECT COUNT(*) FROM geoip_cache`).Scan(&n)
if n != 1 {
t.Fatalf("GEOIP_CACHE_TTL=0 must keep cache entries, rows=%d", n)
}
}
func TestNotificationsUseNotifyClient(t *testing.T) {
var hits atomic.Int32
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
w.WriteHeader(http.StatusNoContent)
}))
srv.Config.ErrorLog = log.New(io.Discard, "", 0)
srv.StartTLS()
defer srv.Close()
d := &Daemon{
Config: &config.Config{NotifyEnabled: true, NotifyType: "generic", NotifyWebhook: srv.URL},
Client: &http.Client{Transport: newTransport(false)},
NotifyClient: &http.Client{Transport: newTransport(false)},
}
d.notifyUnban("203.0.113.1", "manual")
if hits.Load() != 0 {
t.Fatal("NOTIFY_TLS_INSECURE=false must verify certificates")
}
d.NotifyClient = &http.Client{Transport: newTransport(true)}
d.notifyUnban("203.0.113.1", "manual")
if hits.Load() != 1 {
t.Fatal("NOTIFY_TLS_INSECURE=true must accept self-signed certificates")
}
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
if _, err := d.Client.Do(req); err == nil {
t.Fatal("general client (AbuseIPDB, lists) must verify certificates")
}
}
func TestQueryLogLimitWarningRateLimited(t *testing.T) {
d := &Daemon{}
now := time.Now()
if !d.shouldWarnQueryLogLimit(now) {
t.Fatal("first warning must be logged")
}
if d.shouldWarnQueryLogLimit(now.Add(time.Minute)) {
t.Fatal("repeated warning within 5 minutes must be suppressed")
}
if !d.shouldWarnQueryLogLimit(now.Add(queryLogLimitWarnEvery + time.Second)) {
t.Fatal("warning must be logged again after 5 minutes")
}
}
+143
View File
@@ -0,0 +1,143 @@
package daemon
import (
"net/netip"
"strings"
)
// Kürzere Whitelist-Prefixe würden praktisch eine ganze Adressfamilie freigeben.
const (
whitelistMinBits4 = 8
whitelistMinBits6 = 16
)
// whitelist ist nach dem Aufbau unveränderlich und wird als Ganzes ausgetauscht.
type whitelist struct {
addrs map[netip.Addr]struct{}
prefixes []netip.Prefix
}
// newWhitelist baut die Whitelist auf. Ungültige Einträge werden ignoriert,
// zu breite Netze (siehe whitelistPrefixOK) übersprungen und zurückgegeben,
// damit der Aufrufer warnen kann.
func newWhitelist(entries ...[]string) (*whitelist, []string) {
w := &whitelist{addrs: map[netip.Addr]struct{}{}}
var skipped []string
for _, list := range entries {
for _, e := range list {
a, p, ok := parseTarget(e)
if !ok {
continue
}
if !p.IsValid() {
w.addrs[a] = struct{}{}
continue
}
if !whitelistPrefixOK(p) {
skipped = append(skipped, strings.TrimSpace(e))
continue
}
w.prefixes = append(w.prefixes, p)
}
}
return w, skipped
}
// whitelistPrefixOK lehnt /0 und zu breite Netze ab (IPv4 kürzer als /8,
// IPv6 kürzer als /16; geprüft nach dem Entpacken IPv4-mapped Adressen).
func whitelistPrefixOK(p netip.Prefix) bool {
if p.Addr().Is4() {
return p.Bits() >= whitelistMinBits4
}
return p.Bits() >= whitelistMinBits6
}
// whitelistEntryOK prüft einen Rohwert wie newWhitelist (gültig und nicht zu breit).
func whitelistEntryOK(s string) bool {
_, p, ok := parseTarget(s)
return ok && (!p.IsValid() || whitelistPrefixOK(p))
}
func (w *whitelist) size() int {
if w == nil {
return 0
}
return len(w.addrs) + len(w.prefixes)
}
// contains prüft eine IP oder ein Netz. Ein Netz gilt als gewhitelistet, sobald
// es eine gewhitelistete Adresse oder ein gewhitelistetes Netz überlappt.
func (w *whitelist) contains(s string) bool {
if w == nil {
return false
}
a, p, ok := parseTarget(s)
if !ok {
return false
}
if !p.IsValid() {
if _, ok := w.addrs[a]; ok {
return true
}
for _, wp := range w.prefixes {
if wp.Contains(a) {
return true
}
}
return false
}
for _, wp := range w.prefixes {
if wp.Overlaps(p) {
return true
}
}
for wa := range w.addrs {
if p.Contains(wa) {
return true
}
}
return false
}
// parseTarget normalisiert eine IP (Zone entfernt, IPv4-mapped IPv6 entpackt)
// oder ein Netz (maskiert). IPv4-mapped Netze werden nur ab /96 entpackt, ein
// kürzeres Netz bleibt ein IPv6-Netz. Bei einer Adresse ist der
// Prefix-Rückgabewert ungültig.
func parseTarget(s string) (netip.Addr, netip.Prefix, bool) {
s = strings.TrimSpace(s)
if s == "" {
return netip.Addr{}, netip.Prefix{}, false
}
if strings.Contains(s, "/") {
p, err := netip.ParsePrefix(s)
if err != nil {
return netip.Addr{}, netip.Prefix{}, false
}
a, bits := p.Addr(), p.Bits()
if a.Is4In6() && bits >= 96 {
a, bits = a.Unmap(), bits-96
}
p, err = a.Prefix(bits)
if err != nil {
return netip.Addr{}, netip.Prefix{}, false
}
return netip.Addr{}, p, true
}
a, err := netip.ParseAddr(s)
if err != nil {
return netip.Addr{}, netip.Prefix{}, false
}
return a.WithZone("").Unmap(), netip.Prefix{}, true
}
// normalizeTarget liefert die kanonische Schreibweise einer IP oder eines Netzes.
func normalizeTarget(s string) (string, bool) {
a, p, ok := parseTarget(s)
if !ok {
return "", false
}
if p.IsValid() {
return p.String(), true
}
return a.String(), true
}
+206 -93
View File
@@ -3,6 +3,7 @@ package db
import (
"database/sql"
"fmt"
"math"
"time"
_ "modernc.org/sqlite"
@@ -134,8 +135,66 @@ CREATE INDEX IF NOT EXISTS idx_offenses_last ON offense_tracking(last_offense_ep
CREATE INDEX IF NOT EXISTS idx_geoip_cache_age ON geoip_cache(looked_up_at_epoch);
CREATE INDEX IF NOT EXISTS idx_abuseipdb_timestamp ON abuseipdb_reports(timestamp_epoch);
INSERT OR IGNORE INTO schema_version (version) VALUES (1);`
_, err := s.DB.Exec(schema)
return err
if _, err := s.DB.Exec(schema); err != nil {
return err
}
return s.migrate()
}
type migration struct {
version int
sql string
}
// migrations werden aufsteigend nach Version angewendet; Version 1 ist das
// Basisschema aus Init. Einträge dürfen nachträglich nicht geändert werden.
var migrations = []migration{
{2, `CREATE INDEX IF NOT EXISTS idx_bans_created ON active_bans(created_at);
CREATE INDEX IF NOT EXISTS idx_history_action_timestamp ON ban_history(action, timestamp_epoch);`},
}
func (s *Store) migrate() error {
current, err := s.SchemaVersion()
if err != nil {
return err
}
for _, m := range migrations {
if m.version <= current {
continue
}
if err := s.applyMigration(m); err != nil {
return fmt.Errorf("Migration %d: %w", m.version, err)
}
}
return nil
}
func (s *Store) applyMigration(m migration) error {
tx, err := s.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// Version zuerst eintragen: Das sichert die Schreibsperre, und ein
// parallel startender Prozess überspringt die bereits angewendete Migration.
res, err := tx.Exec(`INSERT OR IGNORE INTO schema_version (version) VALUES (?)`, m.version)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err != nil || n == 0 {
return err
}
if _, err := tx.Exec(m.sql); err != nil {
return err
}
return tx.Commit()
}
// SchemaVersion liefert die höchste angewendete Schema-Version.
func (s *Store) SchemaVersion() (int, error) {
var v int
err := s.DB.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_version`).Scan(&v)
return v, err
}
func (s *Store) BanExists(ip string) (bool, error) {
@@ -147,34 +206,79 @@ func (s *Store) BanExists(ip string) (bool, error) {
return err == nil, err
}
func (s *Store) BanByIP(ip string) (Ban, bool, error) {
row := s.DB.QueryRow(`SELECT client_ip, COALESCE(domain,''), COALESCE(count,0), COALESCE(ban_until_epoch,0),
const banColumns = `client_ip, COALESCE(domain,''), COALESCE(count,0), COALESCE(ban_until_epoch,0),
COALESCE(ban_duration,0), COALESCE(offense_level,0), COALESCE(is_permanent,0), COALESCE(reason,''), COALESCE(protocol,''),
COALESCE(source,''), COALESCE(geoip_country,''), COALESCE(geoip_mode,'') FROM active_bans WHERE client_ip=? LIMIT 1`, ip)
COALESCE(source,''), COALESCE(geoip_country,''), COALESCE(geoip_mode,'')`
type rowScanner interface {
Scan(dest ...any) error
}
func scanBan(r rowScanner) (Ban, error) {
var b Ban
var perm int
if err := row.Scan(&b.IP, &b.Domain, &b.Count, &b.BanUntil, &b.Duration, &b.OffenseLevel, &perm, &b.Reason, &b.Protocol, &b.Source, &b.GeoIPCountry, &b.GeoIPMode); err != nil {
if err == sql.ErrNoRows {
return Ban{}, false, nil
}
return Ban{}, false, err
if err := r.Scan(&b.IP, &b.Domain, &b.Count, &b.BanUntil, &b.Duration, &b.OffenseLevel, &perm, &b.Reason, &b.Protocol, &b.Source, &b.GeoIPCountry, &b.GeoIPMode); err != nil {
return Ban{}, err
}
b.Permanent = perm == 1
return b, nil
}
// queryBans liest Sperren aus active_bans; tail enthält WHERE/ORDER BY.
func (s *Store) queryBans(tail string, args ...any) ([]Ban, error) {
rows, err := s.DB.Query(`SELECT `+banColumns+` FROM active_bans `+tail, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Ban
for rows.Next() {
b, err := scanBan(rows)
if err != nil {
return nil, err
}
out = append(out, b)
}
return out, rows.Err()
}
func (s *Store) BanByIP(ip string) (Ban, bool, error) {
b, err := scanBan(s.DB.QueryRow(`SELECT `+banColumns+` FROM active_bans WHERE client_ip=? LIMIT 1`, ip))
if err == sql.ErrNoRows {
return Ban{}, false, nil
}
if err != nil {
return Ban{}, false, err
}
return b, true, nil
}
func (s *Store) InsertBan(b Ban) error {
now := time.Now()
_, err := s.insertBan(`INSERT OR REPLACE INTO`, ``, b)
return err
}
// InsertBanIfAbsent legt die Sperre nur an, wenn für die IP noch keine aktive
// Sperre existiert. true bedeutet, dass diese Sperre neu eingetragen wurde.
func (s *Store) InsertBanIfAbsent(b Ban) (bool, error) {
res, err := s.insertBan(`INSERT INTO`, ` ON CONFLICT(client_ip) DO NOTHING`, b)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n > 0, err
}
func (s *Store) insertBan(verb, conflict string, b Ban) (sql.Result, error) {
perm := 0
if b.Permanent {
perm = 1
}
_, err := s.DB.Exec(`INSERT OR REPLACE INTO active_bans
return s.DB.Exec(verb+` active_bans
(client_ip, domain, count, ban_time, ban_until_epoch, ban_duration, offense_level, is_permanent, reason, protocol, source, geoip_country, geoip_mode)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
b.IP, b.Domain, b.Count, now.Format("2006-01-02 15:04:05"), b.BanUntil, b.Duration, b.OffenseLevel, perm,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`+conflict,
b.IP, b.Domain, b.Count, time.Now().Format("2006-01-02 15:04:05"), b.BanUntil, b.Duration, b.OffenseLevel, perm,
b.Reason, b.Protocol, b.Source, b.GeoIPCountry, b.GeoIPMode)
return err
}
func (s *Store) DeleteBan(ip string) error {
@@ -183,66 +287,26 @@ func (s *Store) DeleteBan(ip string) error {
}
func (s *Store) ActiveBans() ([]Ban, error) {
rows, err := s.DB.Query(`SELECT client_ip, COALESCE(domain,''), COALESCE(count,0), COALESCE(ban_until_epoch,0),
COALESCE(ban_duration,0), COALESCE(offense_level,0), COALESCE(is_permanent,0), COALESCE(reason,''), COALESCE(protocol,''),
COALESCE(source,''), COALESCE(geoip_country,''), COALESCE(geoip_mode,'') FROM active_bans ORDER BY created_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Ban
for rows.Next() {
var b Ban
var perm int
if err := rows.Scan(&b.IP, &b.Domain, &b.Count, &b.BanUntil, &b.Duration, &b.OffenseLevel, &perm, &b.Reason, &b.Protocol, &b.Source, &b.GeoIPCountry, &b.GeoIPMode); err != nil {
return nil, err
}
b.Permanent = perm == 1
out = append(out, b)
}
return out, rows.Err()
return s.queryBans(`ORDER BY created_at DESC`)
}
// RecentActiveBans liefert die neuesten aktiven Sperren (wie ActiveBans sortiert).
func (s *Store) RecentActiveBans(limit int) ([]Ban, error) {
return s.queryBans(`ORDER BY created_at DESC LIMIT ?`, limit)
}
func (s *Store) CountActiveBans() (int, error) {
var count int
err := s.DB.QueryRow(`SELECT COUNT(*) FROM active_bans`).Scan(&count)
return count, err
}
func (s *Store) BansBySource(source string) ([]Ban, error) {
rows, err := s.DB.Query(`SELECT client_ip, COALESCE(domain,''), COALESCE(count,0), COALESCE(ban_until_epoch,0),
COALESCE(ban_duration,0), COALESCE(offense_level,0), COALESCE(is_permanent,0), COALESCE(reason,''), COALESCE(protocol,''),
COALESCE(source,''), COALESCE(geoip_country,''), COALESCE(geoip_mode,'') FROM active_bans WHERE source=?`, source)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Ban
for rows.Next() {
var b Ban
var perm int
if err := rows.Scan(&b.IP, &b.Domain, &b.Count, &b.BanUntil, &b.Duration, &b.OffenseLevel, &perm, &b.Reason, &b.Protocol, &b.Source, &b.GeoIPCountry, &b.GeoIPMode); err != nil {
return nil, err
}
b.Permanent = perm == 1
out = append(out, b)
}
return out, rows.Err()
return s.queryBans(`WHERE source=?`, source)
}
func (s *Store) BansByReason(reason string) ([]Ban, error) {
rows, err := s.DB.Query(`SELECT client_ip, COALESCE(domain,''), COALESCE(count,0), COALESCE(ban_until_epoch,0),
COALESCE(ban_duration,0), COALESCE(offense_level,0), COALESCE(is_permanent,0), COALESCE(reason,''), COALESCE(protocol,''),
COALESCE(source,''), COALESCE(geoip_country,''), COALESCE(geoip_mode,'') FROM active_bans WHERE reason=?`, reason)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Ban
for rows.Next() {
var b Ban
var perm int
if err := rows.Scan(&b.IP, &b.Domain, &b.Count, &b.BanUntil, &b.Duration, &b.OffenseLevel, &perm, &b.Reason, &b.Protocol, &b.Source, &b.GeoIPCountry, &b.GeoIPMode); err != nil {
return nil, err
}
b.Permanent = perm == 1
out = append(out, b)
}
return out, rows.Err()
return s.queryBans(`WHERE reason=?`, reason)
}
func (s *Store) CountBySource(source string) (int, error) {
@@ -354,21 +418,22 @@ func (s *Store) AllWhitelist() (map[string]bool, error) {
}
func (s *Store) IncrementOffense(ip string, resetAfter int64) (int, error) {
now := time.Now()
return s.incrementOffenseAt(ip, resetAfter, time.Now())
}
// incrementOffenseAt erhöht die Stufe in einer einzigen Anweisung, damit
// parallele Aufrufe keine Erhöhung verlieren. Im SET beziehen sich alle
// Spaltennamen auf die Werte vor dem Update.
func (s *Store) incrementOffenseAt(ip string, resetAfter int64, now time.Time) (int, error) {
ts := now.Format("2006-01-02 15:04:05")
var level int
var last int64
var first string
err := s.DB.QueryRow(`SELECT offense_level, COALESCE(last_offense_epoch,0), COALESCE(first_offense,'') FROM offense_tracking WHERE client_ip=?`, ip).Scan(&level, &last, &first)
if err != nil && err != sql.ErrNoRows {
return 0, err
}
if err == sql.ErrNoRows || (last > 0 && now.Unix()-last > resetAfter) {
level = 0
first = now.Format("2006-01-02 15:04:05")
}
level++
_, err = s.DB.Exec(`INSERT OR REPLACE INTO offense_tracking (client_ip, offense_level, last_offense_epoch, last_offense, first_offense, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`, ip, level, now.Unix(), now.Format("2006-01-02 15:04:05"), first, now.Format("2006-01-02 15:04:05"))
err := s.DB.QueryRow(`INSERT INTO offense_tracking (client_ip, offense_level, last_offense_epoch, last_offense, first_offense, updated_at)
VALUES (?1, 1, ?2, ?3, ?3, ?3)
ON CONFLICT(client_ip) DO UPDATE SET
offense_level = CASE WHEN COALESCE(last_offense_epoch,0) > 0 AND ?2 - last_offense_epoch > ?4 THEN 1 ELSE COALESCE(offense_level,0) + 1 END,
first_offense = CASE WHEN COALESCE(last_offense_epoch,0) > 0 AND ?2 - last_offense_epoch > ?4 THEN ?3 ELSE COALESCE(first_offense,'') END,
last_offense_epoch = ?2, last_offense = ?3, updated_at = ?3
RETURNING offense_level`, ip, now.Unix(), ts, resetAfter).Scan(&level)
return level, err
}
@@ -416,8 +481,13 @@ FROM offense_tracking WHERE client_ip=? LIMIT 1`, ip).Scan(&o.IP, &o.Level, &o.L
return o, true, nil
}
// LoadGeoIPCache liefert die Cache-Einträge der letzten ttl Sekunden (ttl <= 0: alle).
func (s *Store) LoadGeoIPCache(ttl, dbMtime int64) (map[string]string, error) {
rows, err := s.DB.Query(`SELECT ip, country_code FROM geoip_cache WHERE looked_up_at_epoch >= ? AND (db_mtime=? OR db_mtime=0)`, time.Now().Unix()-ttl, dbMtime)
since := int64(math.MinInt64)
if ttl > 0 {
since = time.Now().Unix() - ttl
}
rows, err := s.DB.Query(`SELECT ip, country_code FROM geoip_cache WHERE looked_up_at_epoch >= ? AND (db_mtime=? OR db_mtime=0)`, since, dbMtime)
if err != nil {
return nil, err
}
@@ -458,6 +528,53 @@ func (s *Store) ClearGeoIPCache() (int64, error) {
return res.RowsAffected()
}
// PruneHistory löscht ban_history- und abuseipdb_reports-Einträge, die älter als
// before (Unix-Zeit) sind, und liefert die Gesamtzahl gelöschter Zeilen.
func (s *Store) PruneHistory(before int64) (int64, error) {
tx, err := s.DB.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
var total int64
for _, q := range []string{
`DELETE FROM ban_history WHERE timestamp_epoch < ?`,
`DELETE FROM abuseipdb_reports WHERE timestamp_epoch < ?`,
} {
res, err := tx.Exec(q, before)
if err != nil {
return 0, err
}
n, err := res.RowsAffected()
if err != nil {
return 0, err
}
total += n
}
return total, tx.Commit()
}
// PruneGeoIPCache löscht GeoIP-Cache-Einträge, die vor before nachgeschlagen wurden.
func (s *Store) PruneGeoIPCache(before int64) (int64, error) {
res, err := s.DB.Exec(`DELETE FROM geoip_cache WHERE looked_up_at_epoch < ?`, before)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// PeriodSummary liefert nur TotalBans, TotalUnbans, UniqueIPs und PermanentBans
// (Werte identisch zu ReportStats) mit einer einzigen Abfrage.
func (s *Store) PeriodSummary(since, until int64) (ReportStats, error) {
st := ReportStats{Since: since, Until: until}
err := s.DB.QueryRow(`SELECT COALESCE(SUM(action='BAN'),0), COALESCE(SUM(action='UNBAN'),0),
COUNT(DISTINCT CASE WHEN action='BAN' THEN client_ip END),
COALESCE(SUM(action='BAN' AND LOWER(COALESCE(duration,'')) LIKE '%permanent%'),0)
FROM ban_history WHERE action IN ('BAN','UNBAN') AND timestamp_epoch BETWEEN ? AND ?`, since, until).
Scan(&st.TotalBans, &st.TotalUnbans, &st.UniqueIPs, &st.PermanentBans)
return st, err
}
func (s *Store) ReportStats(since, until, busiestSince int64, limit int) (ReportStats, error) {
st := ReportStats{Since: since, Until: until}
if err := s.DB.QueryRow(`SELECT COUNT(*) FROM ban_history WHERE action='BAN' AND timestamp_epoch BETWEEN ? AND ?`, since, until).Scan(&st.TotalBans); err != nil {
@@ -506,7 +623,7 @@ FROM ban_history WHERE action='BAN' AND timestamp_epoch BETWEEN ? AND ?) GROUP B
if err != nil {
return st, err
}
st.Sources, err = s.reportCounts(`SELECT COALESCE(NULLIF(source,''), 'unknown'), COUNT(*) FROM active_bans GROUP BY COALESCE(NULLIF(source,''), 'unknown') ORDER BY COUNT(*) DESC LIMIT ?`, 0, 0, limit)
st.Sources, err = s.reportCounts(`SELECT COALESCE(NULLIF(source,''), 'unknown'), COUNT(*) FROM active_bans GROUP BY COALESCE(NULLIF(source,''), 'unknown') ORDER BY COUNT(*) DESC LIMIT ?`, limit)
if err != nil {
return st, err
}
@@ -581,14 +698,10 @@ func (s *Store) AbuseIPDBReportStats(since, until int64) (int, int64, error) {
return count, first, nil
}
func (s *Store) reportCounts(query string, since, until int64, limit int) ([]ReportCount, error) {
var rows *sql.Rows
var err error
if since == 0 && until == 0 {
rows, err = s.DB.Query(query, limit)
} else {
rows, err = s.DB.Query(query, since, until, limit)
}
// reportCounts führt eine Abfrage mit den Spalten (Name, Anzahl) aus; die
// Argumente werden unverändert an die Abfrage übergeben.
func (s *Store) reportCounts(query string, args ...any) ([]ReportCount, error) {
rows, err := s.DB.Query(query, args...)
if err != nil {
return nil, err
}
+265
View File
@@ -2,7 +2,10 @@ package db
import (
"path/filepath"
"strconv"
"sync"
"testing"
"time"
)
func TestStoreBanAndGeoIPCache(t *testing.T) {
@@ -30,6 +33,20 @@ func TestStoreBanAndGeoIPCache(t *testing.T) {
}
}
func TestLoadGeoIPCacheZeroTTLLoadsAll(t *testing.T) {
s := openTestStore(t)
if _, err := s.DB.Exec(`INSERT INTO geoip_cache (ip, country_code, looked_up_at_epoch, db_mtime) VALUES ('1.2.3.4', 'CN', 1000, 0)`); err != nil {
t.Fatal(err)
}
if cache, err := s.LoadGeoIPCache(86400, 123); err != nil || len(cache) != 0 {
t.Fatalf("old entry must expire with ttl>0: %v %v", cache, err)
}
cache, err := s.LoadGeoIPCache(0, 123)
if err != nil || cache["1.2.3.4"] != "CN" {
t.Fatalf("ttl=0 must load all entries: %v %v", cache, err)
}
}
func TestStoreIPStatusQueries(t *testing.T) {
s, err := Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
@@ -196,3 +213,251 @@ func TestAbuseIPDBReportStats(t *testing.T) {
t.Fatalf("all: count=%d err=%v", count, err)
}
}
func openTestStore(t *testing.T) *Store {
t.Helper()
s, err := Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.Close() })
return s
}
func TestBanQueriesShareScan(t *testing.T) {
s := openTestStore(t)
for i, b := range []Ban{
{IP: "192.0.2.1", Reason: "rate-limit", Source: "monitor", Permanent: true, GeoIPCountry: "DE"},
{IP: "192.0.2.2", Reason: "geoip", Source: "geoip"},
{IP: "192.0.2.3", Reason: "geoip", Source: "geoip"},
} {
if err := s.InsertBan(b); err != nil {
t.Fatal(err)
}
if _, err := s.DB.Exec(`UPDATE active_bans SET created_at=? WHERE client_ip=?`, "2026-01-01 00:00:0"+strconv.Itoa(i), b.IP); err != nil {
t.Fatal(err)
}
}
all, err := s.ActiveBans()
if err != nil || len(all) != 3 || all[0].IP != "192.0.2.3" || !all[2].Permanent || all[2].GeoIPCountry != "DE" {
t.Fatalf("ActiveBans = %#v, %v", all, err)
}
recent, err := s.RecentActiveBans(2)
if err != nil || len(recent) != 2 || recent[0].IP != "192.0.2.3" || recent[1].IP != "192.0.2.2" {
t.Fatalf("RecentActiveBans = %#v, %v", recent, err)
}
if n, err := s.CountActiveBans(); err != nil || n != 3 {
t.Fatalf("CountActiveBans = %d, %v", n, err)
}
if bans, err := s.BansBySource("geoip"); err != nil || len(bans) != 2 {
t.Fatalf("BansBySource = %#v, %v", bans, err)
}
if bans, err := s.BansByReason("rate-limit"); err != nil || len(bans) != 1 || bans[0].IP != "192.0.2.1" {
t.Fatalf("BansByReason = %#v, %v", bans, err)
}
}
func TestInsertBanIfAbsent(t *testing.T) {
s := openTestStore(t)
ok, err := s.InsertBanIfAbsent(Ban{IP: "192.0.2.1", Reason: "rate-limit", Count: 5})
if err != nil || !ok {
t.Fatalf("first insert: %v %v", ok, err)
}
ok, err = s.InsertBanIfAbsent(Ban{IP: "192.0.2.1", Reason: "geoip", Count: 9})
if err != nil || ok {
t.Fatalf("second insert: %v %v", ok, err)
}
b, _, err := s.BanByIP("192.0.2.1")
if err != nil || b.Reason != "rate-limit" || b.Count != 5 {
t.Fatalf("existing ban overwritten: %#v %v", b, err)
}
// Parallele Aufrufe: genau einer darf die Sperre anlegen.
var wg sync.WaitGroup
var mu sync.Mutex
inserted := 0
for i := 0; i < 16; i++ {
wg.Add(1)
go func() {
defer wg.Done()
ok, err := s.InsertBanIfAbsent(Ban{IP: "192.0.2.2"})
if err != nil {
t.Error(err)
return
}
if ok {
mu.Lock()
inserted++
mu.Unlock()
}
}()
}
wg.Wait()
if inserted != 1 {
t.Fatalf("inserted %d times, want 1", inserted)
}
}
func TestIncrementOffenseLevelsAndReset(t *testing.T) {
s := openTestStore(t)
base := time.Date(2026, 9, 1, 12, 0, 0, 0, time.Local)
step := func(at time.Time, want int) {
t.Helper()
got, err := s.incrementOffenseAt("192.0.2.1", 3600, at)
if err != nil || got != want {
t.Fatalf("level at %s = %d, %v; want %d", at.Format("15:04:05"), got, err, want)
}
}
step(base, 1)
step(base.Add(10*time.Minute), 2)
step(base.Add(70*time.Minute), 3) // genau 3600s Abstand: noch kein Reset
o, _, _ := s.OffenseByIP("192.0.2.1")
if o.First != base.Format("2006-01-02 15:04:05") || o.LastEpoch != base.Add(70*time.Minute).Unix() {
t.Fatalf("unexpected offense: %#v", o)
}
reset := base.Add(70*time.Minute + time.Hour + time.Second)
step(reset, 1)
o, _, _ = s.OffenseByIP("192.0.2.1")
if o.Level != 1 || o.First != reset.Format("2006-01-02 15:04:05") || o.Last != o.First {
t.Fatalf("unexpected offense after reset: %#v", o)
}
step(reset.Add(time.Second), 2)
// Altbestand ohne last_offense_epoch wird nicht zurückgesetzt.
if _, err := s.DB.Exec(`INSERT INTO offense_tracking (client_ip, offense_level, first_offense) VALUES ('192.0.2.9', 4, 'alt')`); err != nil {
t.Fatal(err)
}
if got, err := s.incrementOffenseAt("192.0.2.9", 60, base); err != nil || got != 5 {
t.Fatalf("legacy row level = %d, %v", got, err)
}
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := s.IncrementOffense("192.0.2.50", 86400); err != nil {
t.Error(err)
}
}()
}
wg.Wait()
if o, _, _ := s.OffenseByIP("192.0.2.50"); o.Level != 20 {
t.Fatalf("concurrent increments lost: level=%d", o.Level)
}
}
func TestPruneHistoryAndGeoIPCache(t *testing.T) {
s := openTestStore(t)
for _, ts := range []int64{100, 200, 300} {
if _, err := s.DB.Exec(`INSERT INTO ban_history (timestamp_epoch, timestamp_text, action, client_ip) VALUES (?, 'x', 'BAN', '192.0.2.1')`, ts); err != nil {
t.Fatal(err)
}
if _, err := s.DB.Exec(`INSERT INTO abuseipdb_reports (timestamp_epoch, timestamp_text, client_ip) VALUES (?, 'x', '192.0.2.1')`, ts); err != nil {
t.Fatal(err)
}
if _, err := s.DB.Exec(`INSERT INTO geoip_cache (ip, country_code, looked_up_at_epoch) VALUES (?, 'DE', ?)`, "192.0.2."+strconv.FormatInt(ts, 10), ts); err != nil {
t.Fatal(err)
}
}
n, err := s.PruneHistory(250)
if err != nil || n != 4 {
t.Fatalf("PruneHistory = %d, %v", n, err)
}
var left int
if err := s.DB.QueryRow(`SELECT (SELECT COUNT(*) FROM ban_history) + (SELECT COUNT(*) FROM abuseipdb_reports)`).Scan(&left); err != nil || left != 2 {
t.Fatalf("rows left = %d, %v", left, err)
}
n, err = s.PruneGeoIPCache(200)
if err != nil || n != 1 {
t.Fatalf("PruneGeoIPCache = %d, %v", n, err)
}
if _, ok, _ := s.GeoIPCacheByIP("192.0.2.200"); !ok {
t.Fatal("entry at cutoff must be kept")
}
}
func TestMigrationsUpgradeLegacyDatabase(t *testing.T) {
path := filepath.Join(t.TempDir(), "legacy.db")
s, err := Open(path)
if err != nil {
t.Fatal(err)
}
// Zustand einer älteren Version herstellen: nur Schema-Version 1.
if _, err := s.DB.Exec(`DELETE FROM schema_version WHERE version > 1; DROP INDEX idx_bans_created; DROP INDEX idx_history_action_timestamp;`); err != nil {
t.Fatal(err)
}
s.Close()
want := migrations[len(migrations)-1].version
for i := 0; i < 2; i++ {
s, err = Open(path)
if err != nil {
t.Fatal(err)
}
v, err := s.SchemaVersion()
if err != nil || v != want {
t.Fatalf("schema version = %d, %v; want %d", v, err, want)
}
var rows, idx int
if err := s.DB.QueryRow(`SELECT COUNT(*) FROM schema_version`).Scan(&rows); err != nil {
t.Fatal(err)
}
if err := s.DB.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name IN ('idx_bans_created', 'idx_history_action_timestamp')`).Scan(&idx); err != nil {
t.Fatal(err)
}
if rows != want || idx != 2 {
t.Fatalf("open %d: version rows=%d indexes=%d", i, rows, idx)
}
s.Close()
}
}
func TestPeriodSummaryMatchesReportStats(t *testing.T) {
s := openTestStore(t)
rows := []struct{ action, ip, duration string }{
{"BAN", "192.0.2.1", "600s"},
{"BAN", "192.0.2.1", "PERMANENT"},
{"BAN", "192.0.2.2", "permanent"},
{"UNBAN", "192.0.2.1", "-"},
{"DRY", "192.0.2.3", "permanent"},
{"UNBAN", "192.0.2.4", ""},
}
for i, r := range rows {
if _, err := s.DB.Exec(`INSERT INTO ban_history (timestamp_epoch, timestamp_text, action, client_ip, duration) VALUES (?, '2026-09-15 12:00:00', ?, ?, ?)`, int64(i), r.action, r.ip, r.duration); err != nil {
t.Fatal(err)
}
}
for _, w := range [][2]int64{{0, 100}, {0, 0}, {1, 3}, {200, 300}} {
full, err := s.ReportStats(w[0], w[1], w[0], 10)
if err != nil {
t.Fatal(err)
}
sum, err := s.PeriodSummary(w[0], w[1])
if err != nil {
t.Fatal(err)
}
got := []int{sum.TotalBans, sum.TotalUnbans, sum.UniqueIPs, sum.PermanentBans}
want := []int{full.TotalBans, full.TotalUnbans, full.UniqueIPs, full.PermanentBans}
for i := range want {
if got[i] != want[i] {
t.Fatalf("window %v: summary %v != report stats %v", w, got, want)
}
}
}
sum, _ := s.PeriodSummary(0, 100)
if sum.TotalBans != 3 || sum.TotalUnbans != 2 || sum.UniqueIPs != 2 || sum.PermanentBans != 2 {
t.Fatalf("unexpected summary: %#v", sum)
}
}
func TestReportStatsSourcesWithZeroWindow(t *testing.T) {
s := openTestStore(t)
if err := s.InsertBan(Ban{IP: "192.0.2.1", Source: "monitor"}); err != nil {
t.Fatal(err)
}
st, err := s.ReportStats(0, 0, 0, 10)
if err != nil || len(st.Sources) != 1 || st.Sources[0].Name != "monitor" {
t.Fatalf("sources = %#v, %v", st.Sources, err)
}
}
+300 -15
View File
@@ -2,6 +2,7 @@ package firewall
import (
"context"
"errors"
"fmt"
"net/netip"
"os/exec"
@@ -9,8 +10,14 @@ import (
"strings"
)
const defaultBatchSize = 5000
var hookParents = []string{"INPUT", "DOCKER-USER"}
type Executor interface {
Run(ctx context.Context, name string, args ...string) error
RunInput(ctx context.Context, input string, name string, args ...string) error
Output(ctx context.Context, name string, args ...string) ([]byte, error)
}
type OSExecutor struct{}
@@ -19,6 +26,23 @@ func (OSExecutor) Run(ctx context.Context, name string, args ...string) error {
return exec.CommandContext(ctx, name, args...).Run()
}
func (OSExecutor) RunInput(ctx context.Context, input string, name string, args ...string) error {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Stdin = strings.NewReader(input)
out, err := cmd.CombinedOutput()
if err != nil {
if msg := strings.TrimSpace(string(out)); msg != "" {
return fmt.Errorf("%s: %w: %s", name, err, msg)
}
return fmt.Errorf("%s: %w", name, err)
}
return nil
}
func (OSExecutor) Output(ctx context.Context, name string, args ...string) ([]byte, error) {
return exec.CommandContext(ctx, name, args...).Output()
}
type Firewall struct {
Exec Executor
Chain string
@@ -27,6 +51,13 @@ type Firewall struct {
DryRun bool
Set4 string
Set6 string
batchSize int
}
type Entry struct {
IP string
Timeout int64
}
func New(exec Executor, chain string, ports []string, mode string, dry bool) *Firewall {
@@ -56,6 +87,8 @@ func (f *Firewall) Setup(ctx context.Context) error {
if err := f.ensureHooks(ctx, "ip6tables"); err != nil {
return err
}
f.cleanupHooks(ctx, "iptables", f.keepHook)
f.cleanupHooks(ctx, "ip6tables", f.keepHook)
return nil
}
@@ -132,26 +165,266 @@ func (f *Firewall) Add(ctx context.Context, ip string, timeout int64) error {
if f.DryRun {
return nil
}
set, err := f.setFor(ip)
set, canon, err := f.target(ip)
if err != nil {
return err
}
return f.Exec.Run(ctx, "ipset", addArgs(set, canon, timeout)...)
}
// maxIPSetTimeout ist der groesste Timeout-Wert, den ipset akzeptiert.
const maxIPSetTimeout = 2147483
func clampTimeout(t int64) int64 {
return min(t, maxIPSetTimeout)
}
func addArgs(set, ip string, timeout int64) []string {
args := []string{"add", set, ip, "-exist"}
if timeout > 0 {
args = append(args, "timeout", strconv.FormatInt(timeout, 10))
args = append(args, "timeout", strconv.FormatInt(clampTimeout(timeout), 10))
}
return f.Exec.Run(ctx, "ipset", args...)
return args
}
type batchLine struct {
set, ip string
timeout int64
}
func (l batchLine) restore() string {
line := "add " + l.set + " " + l.ip
if l.timeout > 0 {
line += " timeout " + strconv.FormatInt(clampTimeout(l.timeout), 10)
}
return line
}
// AddBatch fuegt alle gueltigen Eintraege per "ipset restore -exist" in Bloecken
// hinzu. Ungueltige IPs/Prefixe werden uebersprungen. Scheitert ein Block (ipset
// restore bricht bei der ersten fehlerhaften Zeile ab), werden seine Eintraege
// einzeln per "ipset add" gesetzt. Der Fehler nennt ungueltige und fehlgeschlagene
// Eintraege (errors.Join), gueltige Eintraege werden trotzdem gesetzt.
func (f *Firewall) AddBatch(ctx context.Context, entries []Entry) error {
var lines []batchLine
var invalid []string
for _, e := range entries {
set, canon, err := f.target(e.IP)
if err != nil {
invalid = append(invalid, e.IP)
continue
}
lines = append(lines, batchLine{set: set, ip: canon, timeout: e.Timeout})
}
var errs []error
if len(invalid) > 0 {
errs = append(errs, invalidEntriesError(invalid))
}
if !f.DryRun {
size := f.batchSize
if size <= 0 {
size = defaultBatchSize
}
for start := 0; start < len(lines); start += size {
end := min(start+size, len(lines))
var b strings.Builder
for _, l := range lines[start:end] {
b.WriteString(l.restore())
b.WriteByte('\n')
}
if err := f.Exec.RunInput(ctx, b.String(), "ipset", "restore", "-exist"); err != nil {
if ferr := f.addEach(ctx, lines[start:end]); ferr != nil {
errs = append(errs, fmt.Errorf("ipset restore (entries %d-%d): %w; %w", start+1, end, err, ferr))
}
}
}
}
return errors.Join(errs...)
}
// addEach setzt Eintraege einzeln, damit ein fehlerhafter Eintrag die uebrigen
// nicht verhindert.
func (f *Firewall) addEach(ctx context.Context, lines []batchLine) error {
var failed []string
var firstErr error
for i, l := range lines {
if ctx.Err() != nil {
return fmt.Errorf("per-entry fallback aborted after %d of %d entries: %w", i, len(lines), ctx.Err())
}
if err := f.Exec.Run(ctx, "ipset", addArgs(l.set, l.ip, l.timeout)...); err != nil {
failed = append(failed, l.ip)
if firstErr == nil {
firstErr = err
}
}
}
if len(failed) == 0 {
return nil
}
shown := failed
if len(shown) > 10 {
shown = shown[:10]
}
msg := fmt.Sprintf("per-entry fallback: %d of %d entries failed: %q", len(failed), len(lines), shown)
if len(failed) > len(shown) {
msg += " ..."
}
return fmt.Errorf("%s (first error: %w)", msg, firstErr)
}
func invalidEntriesError(invalid []string) error {
shown := invalid
if len(shown) > 10 {
shown = shown[:10]
}
msg := fmt.Sprintf("skipped %d invalid IP/prefix entries: %q", len(invalid), shown)
if len(invalid) > len(shown) {
msg += " ..."
}
return errors.New(msg)
}
// hookRules liefert die Regeln einer Parent-Chain, die nach f.Chain springen,
// jeweils als Argumente ohne "-A <parent>".
func (f *Firewall) hookRules(ctx context.Context, bin, parent string) [][]string {
out, err := f.Exec.Output(ctx, bin, "-S", parent)
if err != nil {
return nil
}
var rules [][]string
for _, line := range strings.Split(string(out), "\n") {
fields := splitRule(strings.TrimSpace(line))
if len(fields) < 2 || fields[0] != "-A" || fields[1] != parent {
continue
}
args := fields[2:]
for i := 0; i+1 < len(args); i++ {
if (args[i] == "-j" || args[i] == "--jump") && args[i+1] == f.Chain {
rules = append(rules, args)
break
}
}
}
return rules
}
// cleanupHooks entfernt Sprungregeln nach f.Chain aus INPUT/DOCKER-USER, die
// keep nicht behalten will (keep == nil entfernt alle).
func (f *Firewall) cleanupHooks(ctx context.Context, bin string, keep func(bin, parent string, args []string) bool) {
for _, parent := range hookParents {
for _, args := range f.hookRules(ctx, bin, parent) {
if keep != nil && keep(bin, parent, args) {
continue
}
_ = f.Exec.Run(ctx, bin, append([]string{"-D", parent}, args...)...)
}
}
}
// ownHook erkennt Regeln, die nur aus den von ensureHooks erzeugten Optionen
// bestehen ("-p <proto> [-m <proto>] --dport <port> -j <chain>"), und liefert
// Protokoll und Port. Regeln mit weiteren Optionen (z.B. -i, -s, Kommentare)
// gelten als manuell angelegt.
func (f *Firewall) ownHook(args []string) (proto, port string, ok bool) {
var target string
for i := 0; i < len(args); i += 2 {
if i+1 >= len(args) {
return "", "", false
}
val := args[i+1]
switch args[i] {
case "-p", "--protocol":
proto = val
case "-m", "--match":
if val != "tcp" && val != "udp" {
return "", "", false
}
case "--dport", "--destination-port":
port = val
case "-j", "--jump":
target = val
default:
return "", "", false
}
}
if target != f.Chain || (proto != "tcp" && proto != "udp") || port == "" {
return "", "", false
}
return proto, port, true
}
// keepHook entscheidet in Setup, welche Sprungregeln bleiben: alle fremd
// aussehenden Regeln und die eigenen Hooks des aktuellen Modus und der
// aktuellen Ports.
func (f *Firewall) keepHook(bin, parent string, args []string) bool {
_, port, ok := f.ownHook(args)
if !ok {
return true
}
return f.wantedHook(bin, parent, port)
}
func (f *Firewall) wantedHook(bin, parent, port string) bool {
parentOK := false
for _, h := range f.hooks(bin) {
if h.Chain == parent {
parentOK = true
break
}
}
if !parentOK {
return false
}
for _, p := range f.Ports {
if p == port {
return true
}
}
return false
}
// splitRule zerlegt eine Zeile aus "iptables -S" und beachtet dabei
// doppelte Anfuehrungszeichen (z.B. bei --comment).
func splitRule(line string) []string {
var fields []string
var cur strings.Builder
inQuote, escaped, has := false, false, false
for _, r := range line {
switch {
case escaped:
cur.WriteRune(r)
escaped = false
case r == '\\' && inQuote:
escaped = true
case r == '"':
inQuote = !inQuote
has = true
case (r == ' ' || r == '\t') && !inQuote:
if has {
fields = append(fields, cur.String())
cur.Reset()
has = false
}
default:
cur.WriteRune(r)
has = true
}
}
if has {
fields = append(fields, cur.String())
}
return fields
}
func (f *Firewall) Del(ctx context.Context, ip string) error {
if f.DryRun {
return nil
}
set, err := f.setFor(ip)
set, canon, err := f.target(ip)
if err != nil {
return err
}
_ = f.Exec.Run(ctx, "ipset", "del", set, ip)
_ = f.Exec.Run(ctx, "ipset", "del", set, canon)
return nil
}
@@ -170,12 +443,14 @@ func (f *Firewall) Remove(ctx context.Context) error {
}
for _, p := range f.Ports {
for _, proto := range []string{"tcp", "udp"} {
for _, parent := range []string{"INPUT", "DOCKER-USER"} {
for _, parent := range hookParents {
_ = f.Exec.Run(ctx, "iptables", "-D", parent, "-p", proto, "--dport", p, "-j", f.Chain)
_ = f.Exec.Run(ctx, "ip6tables", "-D", parent, "-p", proto, "--dport", p, "-j", f.Chain)
}
}
}
f.cleanupHooks(ctx, "iptables", nil)
f.cleanupHooks(ctx, "ip6tables", nil)
_ = f.Exec.Run(ctx, "iptables", "-F", f.Chain)
_ = f.Exec.Run(ctx, "ip6tables", "-F", f.Chain)
_ = f.Exec.Run(ctx, "iptables", "-X", f.Chain)
@@ -185,19 +460,29 @@ func (f *Firewall) Remove(ctx context.Context) error {
return nil
}
func (f *Firewall) setFor(s string) (string, error) {
if p, err := netip.ParsePrefix(s); err == nil {
if p.Addr().Is4() {
return f.Set4, nil
// target prueft eine IP bzw. ein Netz und liefert das passende Set sowie die
// kanonische Schreibweise. Zonen und /0-Netze werden abgelehnt, damit Rohwerte
// (z.B. aus der Datenbank) weder die restore-Eingabe manipulieren noch eine
// ganze Adressfamilie sperren koennen.
func (f *Firewall) target(s string) (string, string, error) {
s = strings.TrimSpace(s)
if strings.Contains(s, "/") {
p, err := netip.ParsePrefix(s)
if err != nil || p.Bits() == 0 || p.Addr().Zone() != "" {
return "", "", fmt.Errorf("invalid IP/prefix %q", s)
}
return f.Set6, nil
p = p.Masked()
if p.Addr().Is4() {
return f.Set4, p.String(), nil
}
return f.Set6, p.String(), nil
}
a, err := netip.ParseAddr(s)
if err != nil {
return "", fmt.Errorf("invalid IP/prefix %q", s)
if err != nil || a.Zone() != "" {
return "", "", fmt.Errorf("invalid IP/prefix %q", s)
}
if a.Is4() {
return f.Set4, nil
return f.Set4, a.String(), nil
}
return f.Set6, nil
return f.Set6, a.String(), nil
}
+364
View File
@@ -2,6 +2,7 @@ package firewall
import (
"context"
"fmt"
"strings"
"testing"
)
@@ -10,6 +11,26 @@ type fakeExec struct {
calls []string
failChecks bool
missing map[string]bool
outputs map[string]string
inputs []string
inputErr error
failAdds map[string]bool
}
func (f *fakeExec) RunInput(_ context.Context, input string, name string, args ...string) error {
f.calls = append(f.calls, name+" "+strings.Join(args, " "))
f.inputs = append(f.inputs, input)
return f.inputErr
}
func (f *fakeExec) Output(_ context.Context, name string, args ...string) ([]byte, error) {
call := name + " " + strings.Join(args, " ")
f.calls = append(f.calls, call)
out, ok := f.outputs[call]
if !ok {
return nil, errFake
}
return []byte(out), nil
}
func (f *fakeExec) Run(_ context.Context, name string, args ...string) error {
@@ -18,6 +39,9 @@ func (f *fakeExec) Run(_ context.Context, name string, args ...string) error {
if f.missing != nil && f.missing[call] {
return errFake
}
if len(args) > 2 && args[0] == "add" && f.failAdds[args[2]] {
return errFake
}
if f.failChecks && len(args) > 0 && args[0] == "-C" {
return errFake
}
@@ -140,3 +164,343 @@ func TestFirewallRemoveDeletesAllKnownHooks(t *testing.T) {
}
}
}
var _ Executor = OSExecutor{}
func TestAddBatchSplitsFamiliesAndTimeouts(t *testing.T) {
ex := &fakeExec{}
fw := New(ex, "ADGUARD_SHIELD", []string{"53"}, "host", false)
err := fw.AddBatch(context.Background(), []Entry{
{IP: "203.0.113.7", Timeout: 60},
{IP: "2001:db8::1"},
{IP: "198.51.100.0/24"},
{IP: "2001:db8:1::/48", Timeout: 5},
})
if err != nil {
t.Fatal(err)
}
if len(ex.inputs) != 1 {
t.Fatalf("expected one restore call, got %d", len(ex.inputs))
}
if ex.calls[0] != "ipset restore -exist" {
t.Fatalf("unexpected call %q", ex.calls[0])
}
want := "add adguard_shield_v4 203.0.113.7 timeout 60\n" +
"add adguard_shield_v6 2001:db8::1\n" +
"add adguard_shield_v4 198.51.100.0/24\n" +
"add adguard_shield_v6 2001:db8:1::/48 timeout 5\n"
if ex.inputs[0] != want {
t.Fatalf("restore input:\n%s\nwant:\n%s", ex.inputs[0], want)
}
}
func TestAddBatchSkipsInvalidEntries(t *testing.T) {
ex := &fakeExec{}
fw := New(ex, "ADGUARD_SHIELD", []string{"53"}, "host", false)
err := fw.AddBatch(context.Background(), []Entry{
{IP: "203.0.113.7"},
{IP: "not-an-ip"},
{IP: "1.2.3.4\nflush adguard_shield_v4"},
{IP: "10.0.0.0/33"},
})
if err == nil || !strings.Contains(err.Error(), "skipped 3 invalid") || !strings.Contains(err.Error(), "not-an-ip") {
t.Fatalf("unexpected error: %v", err)
}
if len(ex.inputs) != 1 || ex.inputs[0] != "add adguard_shield_v4 203.0.113.7\n" {
t.Fatalf("valid entry must still be added, inputs=%q", ex.inputs)
}
}
func TestAddBatchOnlyInvalidRunsNothing(t *testing.T) {
ex := &fakeExec{}
fw := New(ex, "ADGUARD_SHIELD", []string{"53"}, "host", false)
if err := fw.AddBatch(context.Background(), []Entry{{IP: "x"}}); err == nil {
t.Fatal("expected error")
}
if len(ex.calls) != 0 {
t.Fatalf("unexpected calls: %v", ex.calls)
}
if err := fw.AddBatch(context.Background(), nil); err != nil || len(ex.calls) != 0 {
t.Fatalf("empty batch: err=%v calls=%v", err, ex.calls)
}
}
func TestAddBatchDryRun(t *testing.T) {
ex := &fakeExec{}
fw := New(ex, "ADGUARD_SHIELD", []string{"53"}, "host", true)
if err := fw.AddBatch(context.Background(), []Entry{{IP: "203.0.113.7"}}); err != nil {
t.Fatal(err)
}
if len(ex.calls) != 0 {
t.Fatalf("dry-run must not execute: %v", ex.calls)
}
}
func TestAddBatchChunksAndFallsBackPerEntry(t *testing.T) {
ex := &fakeExec{inputErr: errFake}
fw := New(ex, "ADGUARD_SHIELD", []string{"53"}, "host", false)
fw.batchSize = 2
var entries []Entry
for i := 1; i <= 5; i++ {
entries = append(entries, Entry{IP: fmt.Sprintf("192.0.2.%d", i)})
}
if err := fw.AddBatch(context.Background(), entries); err != nil {
t.Fatalf("successful per-entry fallback must not report an error: %v", err)
}
if len(ex.inputs) != 3 {
t.Fatalf("expected 3 chunks, got %d", len(ex.inputs))
}
if strings.Count(ex.inputs[0], "\n") != 2 || strings.Count(ex.inputs[2], "\n") != 1 {
t.Fatalf("unexpected chunking: %q", ex.inputs)
}
joined := strings.Join(ex.calls, "\n")
for i := 1; i <= 5; i++ {
want := fmt.Sprintf("ipset add adguard_shield_v4 192.0.2.%d -exist", i)
if !strings.Contains(joined, want) {
t.Fatalf("missing fallback %q in:\n%s", want, joined)
}
}
}
func TestAddBatchFallbackAppliesGoodEntriesAndReportsBadOnes(t *testing.T) {
ex := &fakeExec{inputErr: errFake, failAdds: map[string]bool{"192.0.2.2": true}}
fw := New(ex, "ADGUARD_SHIELD", []string{"53"}, "host", false)
err := fw.AddBatch(context.Background(), []Entry{
{IP: "192.0.2.1", Timeout: 60},
{IP: "192.0.2.2"},
{IP: "2001:db8::1"},
})
if err == nil || !strings.Contains(err.Error(), "entries 1-3") || !strings.Contains(err.Error(), "1 of 3 entries failed") || !strings.Contains(err.Error(), "192.0.2.2") {
t.Fatalf("unexpected error: %v", err)
}
joined := strings.Join(ex.calls, "\n")
for _, want := range []string{
"ipset add adguard_shield_v4 192.0.2.1 -exist timeout 60",
"ipset add adguard_shield_v6 2001:db8::1 -exist",
} {
if !strings.Contains(joined, want) {
t.Fatalf("good entry %q not applied:\n%s", want, joined)
}
}
}
func TestAddBatchFallbackCapsListedFailures(t *testing.T) {
ex := &fakeExec{inputErr: errFake, failAdds: map[string]bool{}}
fw := New(ex, "ADGUARD_SHIELD", []string{"53"}, "host", false)
var entries []Entry
for i := 1; i <= 15; i++ {
ip := fmt.Sprintf("192.0.2.%d", i)
ex.failAdds[ip] = true
entries = append(entries, Entry{IP: ip})
}
err := fw.AddBatch(context.Background(), entries)
if err == nil || !strings.Contains(err.Error(), "15 of 15") || !strings.Contains(err.Error(), "192.0.2.10") || strings.Contains(err.Error(), "192.0.2.11") || !strings.Contains(err.Error(), "...") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestAddBatchFallbackStopsOnCancel(t *testing.T) {
ex := &fakeExec{inputErr: errFake}
fw := New(ex, "ADGUARD_SHIELD", []string{"53"}, "host", false)
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := fw.AddBatch(ctx, []Entry{{IP: "192.0.2.1"}, {IP: "192.0.2.2"}})
if err == nil || !strings.Contains(err.Error(), "aborted") {
t.Fatalf("unexpected error: %v", err)
}
if strings.Contains(strings.Join(ex.calls, "\n"), "ipset add") {
t.Fatalf("cancelled fallback must not add: %v", ex.calls)
}
}
func TestTimeoutClamped(t *testing.T) {
ex := &fakeExec{}
fw := New(ex, "ADGUARD_SHIELD", []string{"53"}, "host", false)
if err := fw.Add(context.Background(), "192.0.2.1", 1<<40); err != nil {
t.Fatal(err)
}
if err := fw.AddBatch(context.Background(), []Entry{{IP: "192.0.2.2", Timeout: 1 << 40}, {IP: "192.0.2.3", Timeout: maxIPSetTimeout}}); err != nil {
t.Fatal(err)
}
if !strings.Contains(strings.Join(ex.calls, "\n"), "ipset add adguard_shield_v4 192.0.2.1 -exist timeout 2147483") {
t.Fatalf("Add timeout not clamped: %v", ex.calls)
}
want := "add adguard_shield_v4 192.0.2.2 timeout 2147483\nadd adguard_shield_v4 192.0.2.3 timeout 2147483\n"
if len(ex.inputs) != 1 || ex.inputs[0] != want {
t.Fatalf("AddBatch timeout not clamped: %q", ex.inputs)
}
}
func TestTargetRejectsZonesAndZeroPrefixes(t *testing.T) {
ex := &fakeExec{}
fw := New(ex, "ADGUARD_SHIELD", []string{"53"}, "host", false)
bad := []string{"fe80::1%eth0", "fe80::1%eth0\nflush adguard_shield_v6", "fe80::1%a b", "0.0.0.0/0", "::/0", "10.0.0.1/0", "1.2.3.4 5.6.7.8"}
for _, ip := range bad {
if err := fw.Add(context.Background(), ip, 0); err == nil {
t.Errorf("Add(%q) must fail", ip)
}
}
if len(ex.calls) != 0 {
t.Fatalf("invalid entries must not execute anything: %v", ex.calls)
}
err := fw.AddBatch(context.Background(), append(entriesOf(bad), Entry{IP: "192.0.2.1"}))
if err == nil || !strings.Contains(err.Error(), fmt.Sprintf("skipped %d invalid", len(bad))) {
t.Fatalf("unexpected error: %v", err)
}
if len(ex.inputs) != 1 || ex.inputs[0] != "add adguard_shield_v4 192.0.2.1\n" {
t.Fatalf("unexpected restore input: %q", ex.inputs)
}
}
func TestTargetEmitsCanonicalForm(t *testing.T) {
ex := &fakeExec{}
fw := New(ex, "ADGUARD_SHIELD", []string{"53"}, "host", false)
err := fw.AddBatch(context.Background(), []Entry{
{IP: " 10.1.2.3/8 "},
{IP: "2001:0DB8:0000::0001"},
{IP: "2001:db8::1:2/32"},
})
if err != nil {
t.Fatal(err)
}
want := "add adguard_shield_v4 10.0.0.0/8\nadd adguard_shield_v6 2001:db8::1\nadd adguard_shield_v6 2001:db8::/32\n"
if ex.inputs[0] != want {
t.Fatalf("restore input:\n%s\nwant:\n%s", ex.inputs[0], want)
}
if err := fw.Del(context.Background(), "2001:0DB8::0001"); err != nil {
t.Fatal(err)
}
if last := ex.calls[len(ex.calls)-1]; last != "ipset del adguard_shield_v6 2001:db8::1" {
t.Fatalf("Del must use canonical form: %q", last)
}
}
func entriesOf(ips []string) []Entry {
out := make([]Entry, 0, len(ips))
for _, ip := range ips {
out = append(out, Entry{IP: ip})
}
return out
}
func TestAddBatchDefaultChunkSize(t *testing.T) {
ex := &fakeExec{}
fw := New(ex, "ADGUARD_SHIELD", []string{"53"}, "host", false)
entries := make([]Entry, defaultBatchSize+1)
for i := range entries {
entries[i] = Entry{IP: "192.0.2.1"}
}
if err := fw.AddBatch(context.Background(), entries); err != nil {
t.Fatal(err)
}
if len(ex.inputs) != 2 {
t.Fatalf("expected 2 chunks, got %d", len(ex.inputs))
}
}
const inputRules = `-P INPUT ACCEPT
-A INPUT -i lo -j ACCEPT
-A INPUT -p tcp -m tcp --dport 53 -j ADGUARD_SHIELD
-A INPUT -p udp -m udp --dport 53 -j ADGUARD_SHIELD
-A INPUT -p udp -m udp --dport 5353 -j ADGUARD_SHIELD
-A INPUT -s 192.0.2.0/24 -m comment --comment "custom shield hook" -j ADGUARD_SHIELD
-A INPUT -i eth1 -p udp -m udp --dport 5353 -j ADGUARD_SHIELD
-A INPUT -p tcp -m tcp --dport 53 -j ADGUARD_SHIELD_OTHER
-A INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT
`
const dockerRules = `-N DOCKER-USER
-A DOCKER-USER -p tcp -m tcp --dport 53 -j ADGUARD_SHIELD
-A DOCKER-USER -p udp -m udp --dport 53 -j ADGUARD_SHIELD
-A DOCKER-USER -j RETURN
`
func hookOutputs() map[string]string {
return map[string]string{
"iptables -S INPUT": inputRules,
"iptables -S DOCKER-USER": dockerRules,
"ip6tables -S INPUT": "-P INPUT ACCEPT\n-A INPUT -p udp -m udp --dport 853 -j ADGUARD_SHIELD\n",
"ip6tables -S DOCKER-USER": "",
}
}
func TestSetupRemovesStaleHooks(t *testing.T) {
ex := &fakeExec{outputs: hookOutputs()}
fw := New(ex, "ADGUARD_SHIELD", []string{"53"}, "host", false)
if err := fw.Setup(context.Background()); err != nil {
t.Fatal(err)
}
joined := strings.Join(ex.calls, "\n")
for _, want := range []string{
"iptables -D INPUT -p udp -m udp --dport 5353 -j ADGUARD_SHIELD",
"iptables -D DOCKER-USER -p tcp -m tcp --dport 53 -j ADGUARD_SHIELD",
"iptables -D DOCKER-USER -p udp -m udp --dport 53 -j ADGUARD_SHIELD",
"ip6tables -D INPUT -p udp -m udp --dport 853 -j ADGUARD_SHIELD",
} {
if !strings.Contains(joined, want) {
t.Fatalf("missing cleanup %q in:\n%s", want, joined)
}
}
for _, unwanted := range []string{
"iptables -D INPUT -p tcp -m tcp --dport 53 -j ADGUARD_SHIELD",
"iptables -D INPUT -p udp -m udp --dport 53 -j ADGUARD_SHIELD",
"ADGUARD_SHIELD_OTHER",
"-D INPUT -i lo",
"-D DOCKER-USER -j RETURN",
"-D INPUT -s 192.0.2.0/24",
"-D INPUT -i eth1",
} {
if strings.Contains(joined, unwanted) {
t.Fatalf("unexpected call containing %q in:\n%s", unwanted, joined)
}
}
}
func TestSetupHybridKeepsDockerHooks(t *testing.T) {
ex := &fakeExec{outputs: hookOutputs()}
fw := New(ex, "ADGUARD_SHIELD", []string{"53"}, "hybrid", false)
if err := fw.Setup(context.Background()); err != nil {
t.Fatal(err)
}
joined := strings.Join(ex.calls, "\n")
if strings.Contains(joined, "-D DOCKER-USER") {
t.Fatalf("hybrid mode must keep DOCKER-USER hooks:\n%s", joined)
}
if !strings.Contains(joined, "iptables -D INPUT -p udp -m udp --dport 5353 -j ADGUARD_SHIELD") {
t.Fatalf("old port hook not removed:\n%s", joined)
}
}
func TestRemoveDeletesListedHooks(t *testing.T) {
ex := &fakeExec{outputs: hookOutputs()}
fw := New(ex, "ADGUARD_SHIELD", []string{"53"}, "host", false)
if err := fw.Remove(context.Background()); err != nil {
t.Fatal(err)
}
var deletes []string
for _, c := range ex.calls {
if strings.Contains(c, " -D ") && strings.Contains(c, " -m ") {
deletes = append(deletes, c)
}
}
if len(deletes) != 8 {
t.Fatalf("expected 8 listed hook deletions, got %d:\n%s", len(deletes), strings.Join(deletes, "\n"))
}
joined := strings.Join(ex.calls, "\n")
if strings.Contains(joined, "ADGUARD_SHIELD_OTHER") || strings.Contains(joined, "-j ACCEPT") {
t.Fatalf("foreign rule touched:\n%s", joined)
}
for _, want := range []string{"iptables -D INPUT -s 192.0.2.0/24", "iptables -D INPUT -i eth1"} {
if !strings.Contains(joined, want) {
t.Fatalf("Remove must delete every jump to the chain, missing %q:\n%s", want, joined)
}
}
}
func TestSplitRuleHandlesQuotes(t *testing.T) {
got := splitRule(`-A INPUT -m comment --comment "a \"b\" c" -j X`)
want := []string{"-A", "INPUT", "-m", "comment", "--comment", `a "b" c`, "-j", "X"}
if strings.Join(got, "|") != strings.Join(want, "|") {
t.Fatalf("got %q", got)
}
}
+304 -72
View File
@@ -4,11 +4,14 @@ import (
"archive/tar"
"compress/gzip"
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"os/exec"
"path/filepath"
@@ -20,41 +23,90 @@ import (
"github.com/oschwald/maxminddb-golang"
)
const (
defaultDownloadURL = "https://download.maxmind.com/app/geoip_download"
autoDBName = "GeoLite2-Country.mmdb"
autoDBMaxAge = 24 * time.Hour
maxDownloadBytes = 100 << 20
maxDBBytes = 256 << 20
defaultMaxCache = 50000
commandTimeout = 5 * time.Second
)
var countryCodeRe = regexp.MustCompile(`\b[A-Z]{2}\b`)
type Store interface {
LoadGeoIPCache(ttl, dbMtime int64) (map[string]string, error)
UpsertGeoIP(ip, country string, dbMtime int64) error
}
type cacheEntry struct {
country string
at int64
}
type Resolver struct {
DBPath string
effectivePath string
LicenseKey string
Dir string
TTL int64
Store Store
DBPath string
LicenseKey string
Dir string
TTL int64
Store Store
// Test-Hooks; Nullwerte bedeuten Standardverhalten.
downloadURL string
client *http.Client
maxCache int
now func() time.Time
refreshMu sync.Mutex
mu sync.RWMutex
reader *maxminddb.Reader
cache map[string]string
effectivePath string
cache map[string]cacheEntry
gen uint64
mtime int64
modTime time.Time
}
func New(dbPath, licenseKey, dir string, ttl int64, store Store) *Resolver {
return &Resolver{DBPath: dbPath, LicenseKey: licenseKey, Dir: dir, TTL: ttl, Store: store, cache: map[string]string{}}
return &Resolver{DBPath: dbPath, LicenseKey: licenseKey, Dir: dir, TTL: ttl, Store: store, cache: map[string]cacheEntry{}}
}
func newHTTPClient() *http.Client {
return &http.Client{
Timeout: 2 * time.Minute,
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
TLSHandshakeTimeout: 15 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
},
}
}
func (r *Resolver) nowTime() time.Time {
if r.now != nil {
return r.now()
}
return time.Now()
}
// Open laedt die Datenbank. Schlaegt der automatische Download fehl, wird eine
// vorhandene aeltere Datenbank weiter genutzt und der Fehler trotzdem zurueckgegeben.
func (r *Resolver) Open(ctx context.Context) error {
r.refreshMu.Lock()
defer r.refreshMu.Unlock()
path := r.DBPath
var dlErr error
if path == "" && r.LicenseKey != "" {
var err error
path, err = r.ensureAutoDB(ctx)
if err != nil {
return err
path, dlErr = r.ensureAutoDB(ctx)
if path == "" {
return dlErr
}
}
if path == "" {
return nil
}
r.effectivePath = path
st, err := os.Stat(path)
if err != nil {
return err
@@ -63,37 +115,103 @@ func (r *Resolver) Open(ctx context.Context) error {
if err != nil {
return err
}
r.reader = reader
r.mtime = st.ModTime().Unix()
var loaded map[string]string
if r.Store != nil {
if c, err := r.Store.LoadGeoIPCache(r.TTL, r.mtime); err == nil {
r.mu.Lock()
r.cache = c
r.mu.Unlock()
if c, err := r.Store.LoadGeoIPCache(r.TTL, st.ModTime().Unix()); err == nil {
loaded = c
}
}
return nil
r.swapReader(reader, path, st.ModTime())
now := r.nowTime().Unix()
r.mu.Lock()
for ip, cc := range loaded {
r.cache[ip] = cacheEntry{country: cc, at: now}
}
r.mu.Unlock()
return dlErr
}
// Refresh laedt im Auto-Download-Modus eine neue Datenbank, sobald die lokale
// Datei aelter als 24h ist, und tauscht den Reader aus, wenn sich die mtime der
// Datenbankdatei geaendert hat. Der Rueckgabewert meldet einen Austausch.
func (r *Resolver) Refresh(ctx context.Context) (bool, error) {
r.refreshMu.Lock()
defer r.refreshMu.Unlock()
path := r.DBPath
var dlErr error
if path == "" && r.LicenseKey != "" {
path, dlErr = r.ensureAutoDB(ctx)
if path == "" {
return false, dlErr
}
}
if path == "" {
return false, nil
}
st, err := os.Stat(path)
if err != nil {
return false, err
}
r.mu.RLock()
unchanged := r.reader != nil && r.effectivePath == path && r.modTime.Equal(st.ModTime())
r.mu.RUnlock()
if unchanged {
return false, dlErr
}
reader, err := maxminddb.Open(path)
if err != nil {
return false, err
}
r.swapReader(reader, path, st.ModTime())
return true, dlErr
}
func (r *Resolver) swapReader(reader *maxminddb.Reader, path string, modTime time.Time) {
r.mu.Lock()
old := r.reader
r.reader = reader
r.effectivePath = path
r.mtime = modTime.Unix()
r.modTime = modTime
r.cache = map[string]cacheEntry{}
r.gen++
r.mu.Unlock()
if old != nil {
_ = old.Close()
}
}
func (r *Resolver) Close() error {
if r.reader != nil {
return r.reader.Close()
r.mu.Lock()
reader := r.reader
r.reader = nil
r.mu.Unlock()
if reader != nil {
return reader.Close()
}
return nil
}
func (r *Resolver) fresh(e cacheEntry, now int64) bool {
return r.TTL <= 0 || now-e.at < r.TTL
}
func (r *Resolver) Lookup(ip string) (string, error) {
now := r.nowTime().Unix()
r.mu.RLock()
if v, ok := r.cache[ip]; ok {
if e, ok := r.cache[ip]; ok && r.fresh(e, now) {
r.mu.RUnlock()
return v, nil
return e.country, nil
}
r.mu.RUnlock()
gen := r.gen
if r.reader == nil {
return r.lookupLegacy(ip)
path := r.effectivePath
r.mu.RUnlock()
return r.lookupLegacy(ip, path, gen)
}
parsed := net.ParseIP(ip)
if parsed == nil {
r.mu.RUnlock()
return "", fmt.Errorf("invalid IP %q", ip)
}
var rec struct {
@@ -104,48 +222,81 @@ func (r *Resolver) Lookup(ip string) (string, error) {
ISOCode string `maxminddb:"iso_code"`
} `maxminddb:"registered_country"`
}
if err := r.reader.Lookup(parsed, &rec); err != nil {
err := r.reader.Lookup(parsed, &rec)
r.mu.RUnlock()
if err != nil {
return "", err
}
cc := strings.ToUpper(rec.Country.ISOCode)
if cc == "" {
cc = strings.ToUpper(rec.RegisteredCountry.ISOCode)
}
r.storeCache(ip, cc)
r.storeCache(ip, cc, gen)
return cc, nil
}
func (r *Resolver) lookupLegacy(ip string) (string, error) {
func (r *Resolver) lookupLegacy(ip, dbPath string, gen uint64) (string, error) {
if strings.Contains(ip, ":") {
if cc, err := runGeoIPCommand("geoiplookup6", ip); err == nil && cc != "" {
r.storeCache(ip, cc)
r.storeCache(ip, cc, gen)
return cc, nil
}
} else {
if cc, err := runGeoIPCommand("geoiplookup", ip); err == nil && cc != "" {
r.storeCache(ip, cc)
r.storeCache(ip, cc, gen)
return cc, nil
}
}
if r.effectivePath != "" {
if cc, err := runGeoIPCommand("mmdblookup", "--file", r.effectivePath, "--ip", ip, "country", "iso_code"); err == nil && cc != "" {
r.storeCache(ip, cc)
if dbPath != "" {
if cc, err := runGeoIPCommand("mmdblookup", "--file", dbPath, "--ip", ip, "country", "iso_code"); err == nil && cc != "" {
r.storeCache(ip, cc, gen)
return cc, nil
}
}
return "", fmt.Errorf("no GeoIP result for %s", ip)
}
func (r *Resolver) storeCache(ip, country string) {
// storeCache verwirft Ergebnisse, die vor einem Datenbankwechsel (gen) ermittelt wurden.
func (r *Resolver) storeCache(ip, country string, gen uint64) {
country = strings.ToUpper(strings.TrimSpace(country))
if country == "" {
return
}
now := r.nowTime().Unix()
r.mu.Lock()
r.cache[ip] = country
if gen != r.gen {
r.mu.Unlock()
return
}
if r.cache == nil {
r.cache = map[string]cacheEntry{}
}
if _, exists := r.cache[ip]; !exists && len(r.cache) >= r.cacheLimit() {
r.pruneLocked(now)
}
r.cache[ip] = cacheEntry{country: country, at: now}
mtime := r.mtime
r.mu.Unlock()
if r.Store != nil {
_ = r.Store.UpsertGeoIP(ip, country, r.mtime)
_ = r.Store.UpsertGeoIP(ip, country, mtime)
}
}
func (r *Resolver) cacheLimit() int {
if r.maxCache > 0 {
return r.maxCache
}
return defaultMaxCache
}
func (r *Resolver) pruneLocked(now int64) {
for ip, e := range r.cache {
if !r.fresh(e, now) {
delete(r.cache, ip)
}
}
if len(r.cache) >= r.cacheLimit() {
r.cache = map[string]cacheEntry{}
}
}
@@ -153,18 +304,22 @@ func runGeoIPCommand(name string, args ...string) (string, error) {
if _, err := exec.LookPath(name); err != nil {
return "", err
}
out, err := exec.Command(name, args...).CombinedOutput()
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, name, args...).CombinedOutput()
if err != nil {
return "", err
}
re := regexp.MustCompile(`\b[A-Z]{2}\b`)
matches := re.FindAllString(string(out), -1)
for _, m := range matches {
return parseCountryOutput(string(out)), nil
}
func parseCountryOutput(out string) string {
for _, m := range countryCodeRe.FindAllString(out, -1) {
if m != "IP" {
return strings.ToUpper(m), nil
return strings.ToUpper(m)
}
}
return "", nil
return ""
}
func ShouldBlock(country, mode string, countries []string) bool {
@@ -206,58 +361,135 @@ func isCGNAT(a netip.Addr) bool {
return p[0] == 100 && p[1] >= 64 && p[1] <= 127
}
// ensureAutoDB liefert den Pfad der automatisch verwalteten Datenbank. Schlaegt
// ein faelliger Download fehl, wird eine vorhandene alte Datei zusammen mit dem
// Fehler zurueckgegeben. Fehler enthalten nie den License-Key.
func (r *Resolver) ensureAutoDB(ctx context.Context) (string, error) {
if err := os.MkdirAll(r.Dir, 0755); err != nil {
return "", err
return "", r.sanitizeErr(err)
}
dst := filepath.Join(r.Dir, "GeoLite2-Country.mmdb")
if st, err := os.Stat(dst); err == nil && time.Since(st.ModTime()) < 24*time.Hour {
dst := filepath.Join(r.Dir, autoDBName)
st, statErr := os.Stat(dst)
if statErr == nil && r.nowTime().Sub(st.ModTime()) < autoDBMaxAge {
return dst, nil
}
url := "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=" + r.LicenseKey + "&suffix=tar.gz"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
if err := r.downloadDB(ctx, dst); err != nil {
if statErr == nil {
return dst, r.sanitizeErr(err)
}
return "", r.sanitizeErr(err)
}
resp, err := http.DefaultClient.Do(req)
return dst, nil
}
func (r *Resolver) downloadDB(ctx context.Context, dst string) error {
base := r.downloadURL
if base == "" {
base = defaultDownloadURL
}
q := url.Values{}
q.Set("edition_id", "GeoLite2-Country")
q.Set("license_key", r.LicenseKey)
q.Set("suffix", "tar.gz")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"?"+q.Encode(), nil)
if err != nil {
return "", err
return err
}
client := r.client
if client == nil {
client = newHTTPClient()
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("MaxMind download failed: HTTP %d", resp.StatusCode)
return fmt.Errorf("MaxMind download failed: HTTP %d", resp.StatusCode)
}
gzr, err := gzip.NewReader(resp.Body)
gzr, err := gzip.NewReader(io.LimitReader(resp.Body, maxDownloadBytes))
if err != nil {
return "", err
return err
}
defer gzr.Close()
tr := tar.NewReader(gzr)
tmp := dst + ".tmp"
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return "", err
return err
}
if h.FileInfo().IsDir() || filepath.Base(h.Name) != "GeoLite2-Country.mmdb" {
if h.FileInfo().IsDir() || filepath.Base(h.Name) != autoDBName {
continue
}
f, err := os.Create(tmp)
if err != nil {
return "", err
}
_, copyErr := io.Copy(f, tr)
closeErr := f.Close()
if copyErr != nil {
return "", copyErr
}
if closeErr != nil {
return "", closeErr
}
return dst, os.Rename(tmp, dst)
return installDB(tr, dst)
}
return "", fmt.Errorf("GeoLite2-Country.mmdb not found in archive")
return fmt.Errorf("%s not found in archive", autoDBName)
}
// installDB schreibt die Datenbank in eine temporaere Datei, prueft sie mit
// maxminddb und ersetzt erst dann die bestehende Datei.
func installDB(src io.Reader, dst string) (err error) {
f, err := os.CreateTemp(filepath.Dir(dst), autoDBName+".*.tmp")
if err != nil {
return err
}
tmp := f.Name()
defer func() {
if err != nil {
_ = os.Remove(tmp)
}
}()
n, copyErr := io.Copy(f, io.LimitReader(src, maxDBBytes+1))
closeErr := f.Close()
if copyErr != nil {
return copyErr
}
if closeErr != nil {
return closeErr
}
if n > maxDBBytes {
return fmt.Errorf("%s exceeds %d bytes", autoDBName, maxDBBytes)
}
check, err := maxminddb.Open(tmp)
if err != nil {
return fmt.Errorf("downloaded %s is invalid: %w", autoDBName, err)
}
if err := check.Close(); err != nil {
return err
}
if err := os.Chmod(tmp, 0644); err != nil {
return err
}
return os.Rename(tmp, dst)
}
func (r *Resolver) sanitizeErr(err error) error {
if err == nil {
return nil
}
var ue *url.Error
if errors.As(err, &ue) && ue.Err != nil {
err = ue.Err
}
if r.LicenseKey == "" {
return err
}
msg := err.Error()
redacted := redact(msg, r.LicenseKey)
if redacted == msg {
return err
}
return errors.New(redacted)
}
func redact(msg, key string) string {
for _, k := range []string{key, url.QueryEscape(key), url.PathEscape(key)} {
if k != "" {
msg = strings.ReplaceAll(msg, k, "[REDACTED]")
}
}
return msg
}
+380 -4
View File
@@ -1,15 +1,35 @@
package geoip
import "testing"
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
type fakeGeoIPStore struct {
ip string
country string
mtime int64
loaded map[string]string
}
func (s *fakeGeoIPStore) LoadGeoIPCache(ttl, dbMtime int64) (map[string]string, error) {
return map[string]string{}, nil
out := map[string]string{}
for k, v := range s.loaded {
out[k] = v
}
return out, nil
}
func (s *fakeGeoIPStore) UpsertGeoIP(ip, country string, dbMtime int64) error {
@@ -19,6 +39,66 @@ func (s *fakeGeoIPStore) UpsertGeoIP(ip, country string, dbMtime int64) error {
return nil
}
// buildMMDB erzeugt eine minimale IPv4-MaxMind-DB, die jede Adresse auf cc abbildet.
func buildMMDB(cc string) []byte {
var b bytes.Buffer
// Ein Knoten, beide Records zeigen auf Daten-Offset 0 (node_count + 16).
b.Write([]byte{0, 0, 17, 0, 0, 17})
b.Write(make([]byte, 16))
writeStr := func(s string) {
b.WriteByte(0x40 | byte(len(s)))
b.WriteString(s)
}
b.WriteByte(0xE1)
writeStr("country")
b.WriteByte(0xE1)
writeStr("iso_code")
writeStr(cc)
b.WriteString("\xAB\xCD\xEFMaxMind.com")
b.WriteByte(0xE5)
writeStr("node_count")
b.Write([]byte{0xC1, 1})
writeStr("record_size")
b.Write([]byte{0xA1, 24})
writeStr("ip_version")
b.Write([]byte{0xA1, 4})
writeStr("binary_format_major_version")
b.Write([]byte{0xA1, 2})
writeStr("database_type")
writeStr("Test-Country")
return b.Bytes()
}
func buildArchive(t *testing.T, mmdb []byte) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
name := "GeoLite2-Country_20260101/GeoLite2-Country.mmdb"
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0644, Size: int64(len(mmdb)), Typeflag: tar.TypeReg}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write(mmdb); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func writeFixture(t *testing.T, dir, cc string) string {
t.Helper()
path := filepath.Join(dir, "test.mmdb")
if err := os.WriteFile(path, buildMMDB(cc), 0644); err != nil {
t.Fatal(err)
}
return path
}
func TestShouldBlockModes(t *testing.T) {
countries := []string{"CN", "RU"}
if !ShouldBlock("cn", "blocklist", countries) {
@@ -51,12 +131,308 @@ func TestStoreCachePersistsLegacyLookupResult(t *testing.T) {
r := New("", "", "", 86400, store)
r.mtime = 123
r.storeCache("203.0.113.7", "cn")
r.storeCache("203.0.113.7", "cn", r.gen)
if got := r.cache["203.0.113.7"]; got != "CN" {
if got := r.cache["203.0.113.7"].country; got != "CN" {
t.Fatalf("cache = %q, want CN", got)
}
if store.ip != "203.0.113.7" || store.country != "CN" || store.mtime != 123 {
t.Fatalf("store got ip=%q country=%q mtime=%d", store.ip, store.country, store.mtime)
}
}
func TestStoreCacheDropsResultFromOldGeneration(t *testing.T) {
store := &fakeGeoIPStore{}
r := New("", "", "", 86400, store)
gen := r.gen
r.gen++
r.storeCache("203.0.113.7", "CN", gen)
if len(r.cache) != 0 || store.ip != "" {
t.Fatalf("stale generation result was stored: cache=%v store=%q", r.cache, store.ip)
}
}
func TestParseCountryOutput(t *testing.T) {
if got := parseCountryOutput("GeoIP Country Edition: CN, China"); got != "CN" {
t.Fatalf("got %q", got)
}
if got := parseCountryOutput("IP Address not found"); got != "" {
t.Fatalf("got %q", got)
}
}
func TestCacheTTLExpiry(t *testing.T) {
dir := t.TempDir()
path := writeFixture(t, dir, "DE")
now := time.Unix(1_800_000_000, 0)
r := New(path, "", "", 60, nil)
r.now = func() time.Time { return now }
if err := r.Open(context.Background()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = r.Close() })
r.mu.Lock()
r.cache["198.51.100.1"] = cacheEntry{country: "CN", at: now.Unix()}
r.mu.Unlock()
if cc, _ := r.Lookup("198.51.100.1"); cc != "CN" {
t.Fatalf("fresh cache entry not used, got %q", cc)
}
now = now.Add(61 * time.Second)
cc, err := r.Lookup("198.51.100.1")
if err != nil || cc != "DE" {
t.Fatalf("expired entry should be re-resolved from DB, got %q, %v", cc, err)
}
if e := r.cache["198.51.100.1"]; e.country != "DE" || e.at != now.Unix() {
t.Fatalf("cache entry not refreshed: %+v", e)
}
}
func TestOpenLoadsStoreCacheAsFresh(t *testing.T) {
dir := t.TempDir()
path := writeFixture(t, dir, "DE")
store := &fakeGeoIPStore{loaded: map[string]string{"198.51.100.9": "US"}}
r := New(path, "", "", 3600, store)
if err := r.Open(context.Background()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = r.Close() })
if cc, _ := r.Lookup("198.51.100.9"); cc != "US" {
t.Fatalf("loaded cache entry not used, got %q", cc)
}
}
func TestCacheCapPrunesExpiredThenResets(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
r := New("", "", "", 60, nil)
r.maxCache = 3
r.now = func() time.Time { return now }
r.cache["a"] = cacheEntry{country: "DE", at: now.Unix() - 120}
r.cache["b"] = cacheEntry{country: "DE", at: now.Unix()}
r.cache["c"] = cacheEntry{country: "DE", at: now.Unix()}
r.storeCache("d", "FR", r.gen)
if len(r.cache) != 3 {
t.Fatalf("expected expired entry pruned, cache=%v", r.cache)
}
if _, ok := r.cache["a"]; ok {
t.Fatal("expired entry should be removed")
}
r.storeCache("e", "FR", r.gen)
if len(r.cache) != 1 || r.cache["e"].country != "FR" {
t.Fatalf("expected reset map with only new entry, cache=%v", r.cache)
}
r.storeCache("e", "NL", r.gen)
if len(r.cache) != 1 || r.cache["e"].country != "NL" {
t.Fatalf("update of existing key must not reset, cache=%v", r.cache)
}
}
func TestRefreshNoopWhenUnchanged(t *testing.T) {
dir := t.TempDir()
path := writeFixture(t, dir, "DE")
r := New(path, "", "", 3600, nil)
if err := r.Open(context.Background()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = r.Close() })
if _, err := r.Lookup("198.51.100.1"); err != nil {
t.Fatal(err)
}
swapped, err := r.Refresh(context.Background())
if err != nil || swapped {
t.Fatalf("Refresh = %v, %v; want false, nil", swapped, err)
}
if len(r.cache) != 1 {
t.Fatal("cache must be kept when DB is unchanged")
}
}
func TestRefreshSwapsReaderOnMtimeChange(t *testing.T) {
dir := t.TempDir()
path := writeFixture(t, dir, "DE")
r := New(path, "", "", 3600, nil)
if err := r.Open(context.Background()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = r.Close() })
if cc, _ := r.Lookup("198.51.100.1"); cc != "DE" {
t.Fatalf("got %q, want DE", cc)
}
newPath := filepath.Join(dir, "new.mmdb")
if err := os.WriteFile(newPath, buildMMDB("FR"), 0644); err != nil {
t.Fatal(err)
}
want := "FR"
if err := os.Rename(newPath, path); err != nil {
// Windows kann eine gemappte Datei nicht ersetzen; dann nur mtime aendern.
want = "DE"
}
future := time.Now().Add(time.Hour)
if err := os.Chtimes(path, future, future); err != nil {
t.Fatal(err)
}
oldGen := r.gen
swapped, err := r.Refresh(context.Background())
if err != nil || !swapped {
t.Fatalf("Refresh = %v, %v; want true, nil", swapped, err)
}
if len(r.cache) != 0 || r.gen == oldGen || r.mtime != future.Unix() {
t.Fatalf("cache not cleared or state not updated: cache=%v gen=%d mtime=%d", r.cache, r.gen, r.mtime)
}
if cc, _ := r.Lookup("198.51.100.1"); cc != want {
t.Fatalf("after refresh got %q, want %q", cc, want)
}
}
func TestOpenAutoDownloadInstallsValidDB(t *testing.T) {
archive := buildArchive(t, buildMMDB("DE"))
var gotKey string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
gotKey = req.URL.Query().Get("license_key")
_, _ = w.Write(archive)
}))
defer srv.Close()
dir := t.TempDir()
r := New("", "key+with/special", dir, 3600, nil)
r.downloadURL = srv.URL
r.client = srv.Client()
if err := r.Open(context.Background()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = r.Close() })
if gotKey != "key+with/special" {
t.Fatalf("license key not passed correctly: %q", gotKey)
}
if cc, err := r.Lookup("203.0.113.5"); err != nil || cc != "DE" {
t.Fatalf("Lookup = %q, %v", cc, err)
}
leftovers, _ := filepath.Glob(filepath.Join(dir, "*.tmp"))
if len(leftovers) != 0 {
t.Fatalf("temp files left: %v", leftovers)
}
}
func TestAutoDownloadKeepsOldDBWhenInvalid(t *testing.T) {
archive := buildArchive(t, []byte("not a database"))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write(archive)
}))
defer srv.Close()
dir := t.TempDir()
dst := filepath.Join(dir, autoDBName)
if err := os.WriteFile(dst, buildMMDB("DE"), 0644); err != nil {
t.Fatal(err)
}
old := time.Now().Add(-48 * time.Hour)
if err := os.Chtimes(dst, old, old); err != nil {
t.Fatal(err)
}
r := New("", "secret", dir, 3600, nil)
r.downloadURL = srv.URL
r.client = srv.Client()
err := r.Open(context.Background())
t.Cleanup(func() { _ = r.Close() })
if err == nil || !strings.Contains(err.Error(), "invalid") {
t.Fatalf("expected invalid DB error, got %v", err)
}
if cc, lerr := r.Lookup("203.0.113.5"); lerr != nil || cc != "DE" {
t.Fatalf("old DB should stay in use, got %q, %v", cc, lerr)
}
leftovers, _ := filepath.Glob(filepath.Join(dir, "*.tmp"))
if len(leftovers) != 0 {
t.Fatalf("temp files left: %v", leftovers)
}
}
func TestRefreshRedownloadsStaleAutoDB(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows kann gemappte Dateien nicht ersetzen")
}
country := "DE"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write(buildArchive(t, buildMMDB(country)))
}))
defer srv.Close()
dir := t.TempDir()
now := time.Now()
r := New("", "secret", dir, 3600, nil)
r.downloadURL = srv.URL
r.client = srv.Client()
r.now = func() time.Time { return now }
if err := r.Open(context.Background()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = r.Close() })
if swapped, err := r.Refresh(context.Background()); err != nil || swapped {
t.Fatalf("fresh DB must not be refreshed: %v, %v", swapped, err)
}
country = "FR"
now = now.Add(25 * time.Hour)
swapped, err := r.Refresh(context.Background())
if err != nil || !swapped {
t.Fatalf("Refresh = %v, %v; want true, nil", swapped, err)
}
if cc, _ := r.Lookup("203.0.113.5"); cc != "FR" {
t.Fatalf("got %q, want FR", cc)
}
}
type leakyTransport struct{}
func (leakyTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("dial failed for %s (key %s)", req.URL.String(), req.URL.Query().Get("license_key"))
}
func TestDownloadErrorsDoNotLeakLicenseKey(t *testing.T) {
const key = "SuperSecretKey_123"
closed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {}))
closedURL := closed.URL
closed.Close()
failing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
http.Error(w, "nope "+req.URL.String(), http.StatusUnauthorized)
}))
defer failing.Close()
cases := map[string]*Resolver{}
r1 := New("", key, t.TempDir(), 3600, nil)
r1.downloadURL = closedURL
r1.client = &http.Client{Timeout: 5 * time.Second}
cases["connection refused"] = r1
r2 := New("", key, t.TempDir(), 3600, nil)
r2.downloadURL = "https://example.invalid/download"
r2.client = &http.Client{Transport: leakyTransport{}}
cases["transport error"] = r2
r3 := New("", key, t.TempDir(), 3600, nil)
r3.downloadURL = failing.URL
r3.client = failing.Client()
cases["http status"] = r3
for name, r := range cases {
err := r.Open(context.Background())
if err == nil {
t.Fatalf("%s: expected error", name)
}
if strings.Contains(err.Error(), key) {
t.Fatalf("%s: license key leaked: %v", name, err)
}
_, err = r.Refresh(context.Background())
if err == nil || strings.Contains(err.Error(), key) {
t.Fatalf("%s: Refresh error leaked key or missing: %v", name, err)
}
}
}
func TestSanitizeErrRedactsKey(t *testing.T) {
r := New("", "a b&c", "", 0, nil)
err := r.sanitizeErr(errors.New("failed: license_key=a+b%26c and a b&c"))
if strings.Contains(err.Error(), "a b&c") || strings.Contains(err.Error(), "a+b%26c") {
t.Fatalf("key not redacted: %v", err)
}
}
+92 -45
View File
@@ -479,6 +479,9 @@ func ensureConfig(opts Options) error {
}
return nil
}
if opts.ConfigSource != "" {
fmt.Printf(" Hinweis: %s existiert bereits, --config-source %s wird ignoriert (nur fehlende Parameter werden ergaenzt).\n", target, opts.ConfigSource)
}
current, err := os.ReadFile(target)
if err != nil {
return err
@@ -564,8 +567,14 @@ func blockKey(block []byte) string {
}
func writeService(installDir string) error {
service := fmt.Sprintf(`[Unit]
return os.WriteFile(ServicePath, []byte(serviceUnit(installDir)), 0644)
}
// serviceUnit muss fuer /opt/adguard-shield adguard-shield.service im Repository entsprechen (siehe installer_test.go).
func serviceUnit(installDir string) string {
return fmt.Sprintf(`[Unit]
Description=AdGuard Shield - Go DNS Rate-Limit Monitor
Documentation=https://git.techniverse.net/scriptos/adguard-shield
After=network.target AdGuardHome.service
Wants=AdGuardHome.service
StartLimitBurst=5
@@ -573,25 +582,32 @@ StartLimitIntervalSec=300
[Service]
Type=simple
ExecStart=%s/adguard-shield -config %s/adguard-shield.conf run
ExecReload=/bin/kill -HUP $MAINPID
ExecStart=%[1]s/adguard-shield -config %[1]s/adguard-shield.conf run
# Neustart-Verhalten
Restart=on-failure
RestartSec=30
# Sicherheits-Hardening
ProtectSystem=full
ReadWritePaths=/var/log /var/lib/adguard-shield /var/run %s/geoip
ReadWritePaths=/var/log /var/lib/adguard-shield /var/run %[1]s/geoip
ProtectHome=true
NoNewPrivileges=false
PrivateTmp=true
# iptables benötigt CAP_NET_ADMIN + CAP_NET_RAW
# Weitere Capabilities für Dateizugriff, Signale und Prozessverwaltung
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_DAC_OVERRIDE CAP_DAC_READ_SEARCH CAP_FOWNER CAP_KILL CAP_SETUID CAP_SETGID CAP_CHOWN
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=adguard-shield
[Install]
WantedBy=multi-user.target
`, installDir, installDir, installDir)
return os.WriteFile(ServicePath, []byte(service), 0644)
`, installDir)
}
func run(name string, args ...string) error {
@@ -637,84 +653,115 @@ func IsLegacyError(err error) (*LegacyError, bool) {
return nil, false
}
const defaultConfig = `# AdGuard Shield Konfiguration
// defaultConfig muss inhaltlich adguard-shield.conf im Repository entsprechen (siehe installer_test.go).
const defaultConfig = `###############################################################################
# AdGuard Shield - Konfigurationsdatei
# Ausführliche Dokumentation: docs/konfiguration.md
###############################################################################
# --- AdGuard Home API ---
ADGUARD_URL="https://dns1.domain.com"
ADGUARD_USER="admin"
ADGUARD_PASS='changeme'
ADGUARD_TLS_INSECURE=true # TLS-Zertifikat der AdGuard-API nicht prüfen (selbstsigniert); gilt nur für die AdGuard-API
RATE_LIMIT_MAX_REQUESTS=30
RATE_LIMIT_WINDOW=60
CHECK_INTERVAL=10
API_QUERY_LIMIT=500
# --- Rate-Limit ---
RATE_LIMIT_MAX_REQUESTS=30 # Max. Anfragen pro Domain/Client im Zeitfenster
RATE_LIMIT_WINDOW=60 # Zeitfenster in Sekunden
CHECK_INTERVAL=10 # Prüfintervall in Sekunden
# --- Subdomain-Flood-Erkennung ---
SUBDOMAIN_FLOOD_ENABLED=true
SUBDOMAIN_FLOOD_MAX_UNIQUE=50
SUBDOMAIN_FLOOD_WINDOW=60
SUBDOMAIN_FLOOD_MAX_UNIQUE=50 # Max. eindeutige Subdomains pro Basisdomain/Client
SUBDOMAIN_FLOOD_WINDOW=60 # Zeitfenster in Sekunden
# --- DNS-Flood-Watchlist ---
DNS_FLOOD_WATCHLIST_ENABLED=false
DNS_FLOOD_WATCHLIST=""
DNS_FLOOD_WATCHLIST="" # Kommagetrennt, z.B. "example.com,evil.org"
BAN_DURATION=3600
# --- Sperr-Einstellungen ---
BAN_DURATION=3600 # Basis-Sperrdauer in Sekunden (0 = permanent)
IPTABLES_CHAIN="ADGUARD_SHIELD"
BLOCKED_PORTS="53 443 853"
FIREWALL_BACKEND="ipset"
FIREWALL_MODE="host"
DRY_RUN=false
BLOCKED_PORTS="53 443 853" # DNS(53), DoH(443), DoT/DoQ(853)
FIREWALL_BACKEND="ipset" # Daemon: ipset + iptables (schneller als Einzelregeln)
FIREWALL_MODE="host" # host/docker-host, docker-bridge oder hybrid
# --- Whitelist ---
# IPs die niemals gesperrt werden (kommagetrennt)
WHITELIST="127.0.0.1,::1"
# --- Logging ---
LOG_FILE="/var/log/adguard-shield.log"
LOG_LEVEL="INFO"
STATE_DIR="/var/lib/adguard-shield"
PID_FILE="/var/run/adguard-shield.pid"
LOG_LEVEL="INFO" # DEBUG, INFO, WARN, ERROR
LOG_MAX_SIZE_MB=50 # Log-Rotation ab dieser Größe nach LOG_FILE.1 (0 = keine Rotation)
# --- Benachrichtigungen ---
NOTIFY_ENABLED=false
NOTIFY_TYPE="ntfy"
NOTIFY_WEBHOOK_URL=""
NOTIFY_TYPE="ntfy" # ntfy, discord, slack, gotify, generic
NOTIFY_WEBHOOK_URL="" # Webhook-URL (nicht für ntfy)
NOTIFY_TLS_INSECURE=false # true = TLS-Zertifikat des Benachrichtigungsziels nicht prüfen (nur selbstsigniert/intern)
# Ntfy-Einstellungen (nur bei NOTIFY_TYPE="ntfy")
NTFY_SERVER_URL="https://ntfy.sh"
NTFY_TOPIC=""
NTFY_TOKEN=""
NTFY_PRIORITY="4"
NTFY_PRIORITY="4" # 1=min, 3=default, 5=max
# --- E-Mail Report ---
REPORT_ENABLED=false
REPORT_INTERVAL="weekly"
REPORT_INTERVAL="weekly" # daily, weekly, biweekly, monthly
REPORT_TIME="08:00"
REPORT_EMAIL_TO="admin@example.com"
REPORT_EMAIL_FROM="adguard-shield@example.com"
REPORT_FORMAT="html"
REPORT_FORMAT="html" # html, txt
REPORT_MAIL_CMD="msmtp"
REPORT_BUSIEST_DAY_RANGE=30
REPORT_BUSIEST_DAY_RANGE=30 # Tage für "Aktivster Tag" (0 = nur Berichtszeitraum)
# --- Externe Whitelist ---
# Externe Whitelist-Dateien mit Domains/IPs; Domains werden per DNS aufgelöst
EXTERNAL_WHITELIST_ENABLED=false
EXTERNAL_WHITELIST_URLS=""
EXTERNAL_WHITELIST_INTERVAL=300
EXTERNAL_WHITELIST_URLS="" # URL(s) kommagetrennt
EXTERNAL_WHITELIST_INTERVAL=300 # Prüfintervall in Sekunden
EXTERNAL_WHITELIST_CACHE_DIR="/var/lib/adguard-shield/external-whitelist"
# --- Externe Blocklist ---
EXTERNAL_BLOCKLIST_ENABLED=false
EXTERNAL_BLOCKLIST_URLS=""
EXTERNAL_BLOCKLIST_INTERVAL=300
EXTERNAL_BLOCKLIST_BAN_DURATION=0
EXTERNAL_BLOCKLIST_URLS="" # URL(s) kommagetrennt
EXTERNAL_BLOCKLIST_INTERVAL=300 # Prüfintervall in Sekunden
EXTERNAL_BLOCKLIST_BAN_DURATION=0 # 0 = permanent bis IP aus Liste entfernt
EXTERNAL_BLOCKLIST_AUTO_UNBAN=true
EXTERNAL_BLOCKLIST_NOTIFY=false
EXTERNAL_BLOCKLIST_NOTIFY=false # Bei großen Listen auf false lassen
EXTERNAL_BLOCKLIST_CACHE_DIR="/var/lib/adguard-shield/external-blocklist"
# --- Progressive Sperren (Recidive) ---
# Wiederholungstäter werden stufenweise länger gesperrt
PROGRESSIVE_BAN_ENABLED=true
PROGRESSIVE_BAN_MULTIPLIER=2
PROGRESSIVE_BAN_MAX_LEVEL=5
PROGRESSIVE_BAN_RESET_AFTER=86400
PROGRESSIVE_BAN_MULTIPLIER=2 # Multiplikator pro Stufe (2 = Verdopplung)
PROGRESSIVE_BAN_MAX_LEVEL=5 # Ab dieser Stufe permanent sperren (0 = nie)
PROGRESSIVE_BAN_RESET_AFTER=86400 # Zähler-Reset nach X Sekunden ohne Vergehen
# --- AbuseIPDB Reporting ---
# Meldet nur permanent gesperrte IPs an AbuseIPDB
ABUSEIPDB_ENABLED=false
ABUSEIPDB_API_KEY=""
ABUSEIPDB_CATEGORIES="4"
ABUSEIPDB_CATEGORIES="4" # 4 = DDoS Attack (siehe abuseipdb.com/categories)
# --- GeoIP-basierte Länderfilter ---
# Sperrt/erlaubt DNS-Anfragen nach Herkunftsland (lokale DB, keine Online-API)
GEOIP_ENABLED=false
GEOIP_MODE="blocklist"
GEOIP_COUNTRIES=""
GEOIP_CHECK_INTERVAL=0
GEOIP_MODE="blocklist" # blocklist oder allowlist
GEOIP_COUNTRIES="" # ISO 3166-1 Alpha-2 Codes, z.B. "CN,RU,KP,IR"
GEOIP_CHECK_INTERVAL=0 # 0 = nutzt CHECK_INTERVAL; sonst eigenes GeoIP-Prüfintervall
GEOIP_NOTIFY=true
GEOIP_SKIP_PRIVATE=true
GEOIP_LICENSE_KEY=""
GEOIP_MMDB_PATH=""
GEOIP_CACHE_TTL=86400
GEOIP_SKIP_PRIVATE=true # Private IPs ausnehmen
GEOIP_LICENSE_KEY="" # MaxMind GeoLite2 Key (optional, für Auto-Download)
GEOIP_MMDB_PATH="" # Manueller DB-Pfad (optional, hat Vorrang)
GEOIP_CACHE_TTL=86400 # GeoIP-Cache in Sekunden (0 = kein Ablauf)
# --- Erweiterte Einstellungen ---
STATE_DIR="/var/lib/adguard-shield" # SQLite-DB: ${STATE_DIR}/adguard-shield.db
PID_FILE="/var/run/adguard-shield.pid"
BAN_HISTORY_RETENTION_DAYS=0 # Ban-History/AbuseIPDB-Meldungen nach X Tagen löschen (0 = unbegrenzt)
API_QUERY_LIMIT=500 # API-Einträge pro Abfrage (max 5000)
DRY_RUN=false # true = nur loggen, nicht sperren
`
+171
View File
@@ -0,0 +1,171 @@
package installer
import (
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"testing"
"adguard-shield/internal/config"
)
func readRepoFile(t *testing.T, name string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join("..", "..", name))
if err != nil {
t.Fatal(err)
}
return strings.ReplaceAll(string(b), "\r\n", "\n")
}
func sortedKeys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
func TestDefaultConfigMatchesRepoConfig(t *testing.T) {
repo := readRepoFile(t, "adguard-shield.conf")
got := sortedKeys(configKeys([]byte(defaultConfig)))
want := sortedKeys(configKeys([]byte(repo)))
if !reflect.DeepEqual(got, want) {
t.Fatalf("Schluessel weichen ab:\ndefaultConfig: %v\nadguard-shield.conf: %v", got, want)
}
if repo != defaultConfig {
t.Fatal("defaultConfig weicht inhaltlich von adguard-shield.conf ab")
}
}
func TestDefaultConfigKeysAreKnown(t *testing.T) {
known := map[string]bool{}
for _, k := range config.KnownKeys() {
known[k] = true
}
defaults := configKeys([]byte(defaultConfig))
for _, k := range sortedKeys(defaults) {
if !known[k] {
t.Errorf("defaultConfig enthaelt Schluessel %s, den config.Load nicht kennt", k)
}
}
for _, k := range sortedKeys(known) {
if !defaults[k] {
t.Errorf("config.Load kennt Schluessel %s, der in defaultConfig fehlt", k)
}
}
}
func directives(unit string) []string {
var out []string
for _, line := range strings.Split(unit, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
out = append(out, line)
}
return out
}
func TestServiceUnitMatchesRepoService(t *testing.T) {
repo := readRepoFile(t, "adguard-shield.service")
unit := serviceUnit(DefaultInstallDir)
if got, want := directives(unit), directives(repo); !reflect.DeepEqual(got, want) {
t.Fatalf("Direktiven weichen ab:\nserviceUnit: %q\nadguard-shield.service: %q", got, want)
}
if strings.Contains(unit, "ExecReload") {
t.Fatal("ExecReload darf nicht gesetzt sein (SIGHUP beendet den Daemon nicht)")
}
}
func TestServiceUnitUsesInstallDir(t *testing.T) {
unit := serviceUnit("/srv/shield")
for _, want := range []string{
"ExecStart=/srv/shield/adguard-shield -config /srv/shield/adguard-shield.conf run",
"/var/run /srv/shield/geoip",
} {
if !strings.Contains(unit, want) {
t.Fatalf("serviceUnit enthaelt %q nicht:\n%s", want, unit)
}
}
if strings.Contains(unit, "/opt/adguard-shield") {
t.Fatalf("serviceUnit enthaelt noch den Standardpfad:\n%s", unit)
}
}
func TestConfigKeys(t *testing.T) {
data := []byte("# KOMMENTAR=1\n\nA=1\n B = \"x\" # inline\n=ohne\nkein-gleich\r\nC=3\r\n")
got := sortedKeys(configKeys(data))
want := []string{"A", "B", "C"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("configKeys = %v, want %v", got, want)
}
}
func TestConfigBlocksAndBlockKey(t *testing.T) {
data := []byte("# Kopf\n\n# --- A ---\nA=1\nB=2\n# nur Kommentar am Ende\n")
blocks := configBlocks(data)
if len(blocks) != 2 {
t.Fatalf("expected 2 blocks, got %d: %q", len(blocks), blocks)
}
if string(blocks[0]) != "# Kopf\n\n# --- A ---\nA=1" {
t.Fatalf("unexpected first block: %q", blocks[0])
}
if string(blocks[1]) != "B=2" {
t.Fatalf("unexpected second block: %q", blocks[1])
}
if k := blockKey(blocks[0]); k != "A" {
t.Fatalf("blockKey = %q, want A", k)
}
if k := blockKey([]byte("# nur\n# Kommentare\n")); k != "" {
t.Fatalf("blockKey for comment-only block = %q", k)
}
if k := blockKey([]byte(" KEY = wert ")); k != "KEY" {
t.Fatalf("blockKey trims key: %q", k)
}
}
func TestMergeConfigAddsMissingKeys(t *testing.T) {
current := []byte("A=eigener-wert\nC=3\n\n\n")
defaults := []byte("# Kommentar A\nA=1\n# Kommentar B\nB=2\nC=default\n")
merged, changed := mergeConfig(current, defaults)
if !changed {
t.Fatal("expected change")
}
want := "A=eigener-wert\nC=3\n\n# Neue Parameter aus der Go-Version\n# Kommentar B\nB=2\n"
if string(merged) != want {
t.Fatalf("merged =\n%q\nwant\n%q", merged, want)
}
keys := configKeys(merged)
if !keys["A"] || !keys["B"] || !keys["C"] {
t.Fatalf("missing keys after merge: %v", keys)
}
}
func TestMergeConfigNoChange(t *testing.T) {
current := []byte("A=x\nB=y\n")
merged, changed := mergeConfig(current, []byte("A=1\nB=2\n"))
if changed {
t.Fatal("expected no change")
}
if string(merged) != string(current) {
t.Fatalf("current config modified: %q", merged)
}
}
func TestMergeConfigWithDefaultConfigIsIdempotent(t *testing.T) {
merged, changed := mergeConfig([]byte("ADGUARD_URL=\"http://127.0.0.1:3000\"\n"), []byte(defaultConfig))
if !changed {
t.Fatal("expected missing keys to be added")
}
if got, want := sortedKeys(configKeys(merged)), sortedKeys(configKeys([]byte(defaultConfig))); !reflect.DeepEqual(got, want) {
t.Fatalf("merged keys %v, want %v", got, want)
}
if _, changed := mergeConfig(merged, []byte(defaultConfig)); changed {
t.Fatal("second merge must not change anything")
}
}
+42 -18
View File
@@ -9,6 +9,7 @@ import (
"html"
"io/fs"
"mime"
"mime/quotedprintable"
"net/http"
"os"
"os/exec"
@@ -28,6 +29,7 @@ var embeddedTemplates embed.FS
type Store interface {
ReportStats(since, until, busiestSince int64, limit int) (db.ReportStats, error)
AbuseIPDBReportStats(since, until int64) (int, int64, error)
PeriodSummary(since, until int64) (db.ReportStats, error)
}
const topLimit = 10
@@ -100,10 +102,13 @@ func Generate(c *config.Config, st Store, format string) (string, error) {
if err != nil {
return "", err
}
// Ein einziger Durchlauf: Platzhalter in eingesetzten Werten (z. B. Domains)
// werden nicht erneut ersetzt, und das Ergebnis hängt nicht von der Map-Reihenfolge ab.
pairs := make([]string, 0, 2*len(values))
for key, value := range values {
tpl = strings.ReplaceAll(tpl, "{{"+key+"}}", value)
pairs = append(pairs, "{{"+key+"}}", value)
}
return tpl, nil
return strings.NewReplacer(pairs...).Replace(tpl), nil
}
func Send(ctx context.Context, c *config.Config, st Store) error {
@@ -139,7 +144,8 @@ func InstallCron(binary, configPath string, c *config.Config) error {
}
command := shellQuote(binary) + " -config " + shellQuote(configPath) + " report-send"
if strings.EqualFold(c.ReportInterval, "biweekly") {
command = "[ $(( $(date +\\%V) \\% 2 )) -eq 1 ] && " + command
// expr statt $(( )): "08"/"09" wären in der Shell-Arithmetik ungültige Oktalzahlen.
command = "[ $(expr $(date +\\%V) \\% 2) -eq 1 ] && " + command
}
line := fmt.Sprintf(`# AdGuard Shield - Automatischer Report
# Intervall: %s
@@ -170,21 +176,10 @@ func sendMail(ctx context.Context, c *config.Config, subject, body, format, xMai
if _, err := exec.LookPath(parts[0]); err != nil {
return fmt.Errorf("Mail-Befehl nicht gefunden: %s", parts[0])
}
contentType := "text/plain; charset=UTF-8"
if strings.EqualFold(format, "html") {
contentType = "text/html; charset=UTF-8"
msg, err := buildMessage(c.ReportEmailFrom, c.ReportEmailTo, subject, body, format, xMailer)
if err != nil {
return err
}
msg := strings.Join([]string{
"From: " + c.ReportEmailFrom,
"To: " + c.ReportEmailTo,
"Subject: " + mime.QEncoding.Encode("utf-8", subject),
"MIME-Version: 1.0",
"Content-Type: " + contentType,
"Content-Transfer-Encoding: 8bit",
"X-Mailer: " + xMailer,
"",
body,
}, "\n")
args := append(parts[1:], "-t")
cmd := exec.CommandContext(ctx, parts[0], args...)
cmd.Stdin = strings.NewReader(msg)
@@ -193,6 +188,35 @@ func sendMail(ctx context.Context, c *config.Config, subject, body, format, xMai
return cmd.Run()
}
// buildMessage baut die Mail für "sendmail -t". Der Body wird quoted-printable
// kodiert, da generierte Tabellen sonst Zeilen über 998 Zeichen erzeugen (RFC 5322).
// Zeilenenden bleiben LF wie bei den Headern; der MTA wandelt sie in CRLF um.
func buildMessage(from, to, subject, body, format, xMailer string) (string, error) {
contentType := "text/plain; charset=UTF-8"
if strings.EqualFold(format, "html") {
contentType = "text/html; charset=UTF-8"
}
var qp strings.Builder
w := quotedprintable.NewWriter(&qp)
if _, err := w.Write([]byte(body)); err != nil {
return "", err
}
if err := w.Close(); err != nil {
return "", err
}
return strings.Join([]string{
"From: " + from,
"To: " + to,
"Subject: " + mime.QEncoding.Encode("utf-8", subject),
"MIME-Version: 1.0",
"Content-Type: " + contentType,
"Content-Transfer-Encoding: quoted-printable",
"X-Mailer: " + xMailer,
"",
strings.ReplaceAll(qp.String(), "\r\n", "\n"),
}, "\n"), nil
}
func templateValues(c *config.Config, st Store, stats db.ReportStats, period, format string) (map[string]string, error) {
updateHTML, updateText := checkForUpdate()
busiestDay := "-"
@@ -468,7 +492,7 @@ func periodOverviewRows(st Store) []overviewRow {
)
rows := make([]overviewRow, 0, len(defs))
for _, d := range defs {
stats, err := st.ReportStats(d.since.Unix(), d.until.Unix(), d.since.Unix(), 0)
stats, err := st.PeriodSummary(d.since.Unix(), d.until.Unix())
if err != nil {
stats = db.ReportStats{}
}
+63
View File
@@ -1,6 +1,8 @@
package report
import (
"io"
"mime/quotedprintable"
"os"
"path/filepath"
"strconv"
@@ -283,3 +285,64 @@ func TestPeriodOverviewUsesFullDaysUntilYesterday(t *testing.T) {
t.Fatalf("unexpected overview: %#v", byLabel)
}
}
func TestBuildMessageQuotedPrintable(t *testing.T) {
body := "<html><body>" + strings.Repeat(`<tr><td class="ip-cell">192.0.2.1</td><td>Größe = 100 %</td></tr>`, 60) + "</body></html>\nZeile mit Leerzeichen am Ende \nletzte Zeile"
for _, format := range []string{"html", "txt"} {
msg, err := buildMessage("shield@example.test", "admin@example.test", "AdGuard Shield Wochenbericht – Test", body, format, "test")
if err != nil {
t.Fatal(err)
}
header, encoded, ok := strings.Cut(msg, "\n\n")
if !ok {
t.Fatalf("no header/body separator\n%s", msg)
}
if !strings.Contains(header, "\nContent-Transfer-Encoding: quoted-printable\n") || strings.Contains(header, "8bit") {
t.Fatalf("missing quoted-printable header\n%s", header)
}
wantType := "text/plain; charset=UTF-8"
if format == "html" {
wantType = "text/html; charset=UTF-8"
}
if !strings.Contains(header, "Content-Type: "+wantType) {
t.Fatalf("unexpected content type\n%s", header)
}
for i, line := range strings.Split(msg, "\n") {
if len(line) > 998 {
t.Fatalf("line %d has %d chars", i+1, len(line))
}
if strings.HasSuffix(line, "\r") {
t.Fatalf("line %d has CRLF, want LF only", i+1)
}
}
decoded, err := io.ReadAll(quotedprintable.NewReader(strings.NewReader(encoded)))
if err != nil {
t.Fatal(err)
}
if string(decoded) != body {
t.Fatalf("decoded body differs\n got: %q\nwant: %q", decoded, body)
}
}
}
func TestGenerateDoesNotReplacePlaceholdersInValues(t *testing.T) {
t.Setenv("ADGUARD_SHIELD_SKIP_UPDATE_CHECK", "true")
store, err := db.Open(filepath.Join(t.TempDir(), "report.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
ts := midnight(time.Now()).AddDate(0, 0, -1).Add(time.Hour)
if _, err := store.DB.Exec(`INSERT INTO ban_history (timestamp_epoch, timestamp_text, action, client_ip, domain, count, duration, protocol, reason) VALUES (?, ?, 'BAN', '192.0.2.1', '{{HOSTNAME}}.example', '1', '600s', 'dns', 'rate-limit')`,
ts.Unix(), ts.Format("2006-01-02 15:04:05")); err != nil {
t.Fatal(err)
}
cfg := &config.Config{ReportInterval: "weekly", LogFile: filepath.Join(t.TempDir(), "none.log")}
txt, err := Generate(cfg, store, "txt")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(txt, "{{HOSTNAME}}.example") {
t.Fatalf("placeholder inside value was replaced\n%s", txt)
}
}
+100
View File
@@ -0,0 +1,100 @@
package syslog
import (
"os"
"sync"
)
const logFileMode = 0640
// RotatingFile ist ein threadsicherer Append-Writer für die Logdatei. Ist eine
// Maximalgröße gesetzt, wird die Datei beim Überschreiten nach path+".1"
// verschoben (ein altes .1 wird ersetzt) und neu angelegt.
type RotatingFile struct {
mu sync.Mutex
path string
maxBytes int64
f *os.File
size int64
closed bool
}
// OpenFile öffnet path zum Anhängen, zunächst ohne Rotation.
func OpenFile(path string) (*RotatingFile, error) {
r := &RotatingFile{path: path}
if err := r.open(); err != nil {
return nil, err
}
return r, nil
}
// SetMaxBytes aktiviert die Rotation ab n Bytes; n <= 0 deaktiviert sie.
func (r *RotatingFile) SetMaxBytes(n int64) {
r.mu.Lock()
r.maxBytes = n
r.mu.Unlock()
}
func (r *RotatingFile) open() error {
f, err := os.OpenFile(r.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, logFileMode)
if err != nil {
return err
}
r.f = f
r.size = 0
if st, err := f.Stat(); err == nil {
r.size = st.Size()
}
return nil
}
func (r *RotatingFile) Write(p []byte) (int, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.closed {
return 0, os.ErrClosed
}
if r.f == nil {
if err := r.open(); err != nil {
return 0, err
}
}
if r.maxBytes > 0 && r.size > 0 && r.size+int64(len(p)) > r.maxBytes {
r.maybeRotate(int64(len(p)))
if r.f == nil {
return 0, os.ErrClosed
}
}
n, err := r.f.Write(p)
r.size += int64(n)
return n, err
}
// maybeRotate prüft die echte Dateigröße (andere Prozesse oder logrotate
// können die Datei verändert haben) und rotiert nur, wenn sie wirklich zu groß ist.
func (r *RotatingFile) maybeRotate(pending int64) {
if st, err := r.f.Stat(); err == nil {
r.size = st.Size()
if r.size == 0 || r.size+pending <= r.maxBytes {
return
}
}
_ = r.f.Close()
r.f = nil
backup := r.path + ".1"
_ = os.Remove(backup)
_ = os.Rename(r.path, backup)
_ = r.open()
}
func (r *RotatingFile) Close() error {
r.mu.Lock()
defer r.mu.Unlock()
r.closed = true
if r.f == nil {
return nil
}
err := r.f.Close()
r.f = nil
return err
}
+101
View File
@@ -0,0 +1,101 @@
package syslog
import (
"bytes"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
func TestRotatingFileWithoutLimitOnlyAppends(t *testing.T) {
path := filepath.Join(t.TempDir(), "shield.log")
if err := os.WriteFile(path, []byte("alt\n"), 0640); err != nil {
t.Fatal(err)
}
r, err := OpenFile(path)
if err != nil {
t.Fatal(err)
}
for i := 0; i < 100; i++ {
if _, err := r.Write([]byte("0123456789\n")); err != nil {
t.Fatal(err)
}
}
_ = r.Close()
b, _ := os.ReadFile(path)
if !strings.HasPrefix(string(b), "alt\n") || len(b) != 4+100*11 {
t.Fatalf("unexpected content length %d", len(b))
}
if _, err := os.Stat(path + ".1"); !os.IsNotExist(err) {
t.Fatal("without limit no backup must be created")
}
}
func TestRotatingFileRotatesAndReplacesBackup(t *testing.T) {
path := filepath.Join(t.TempDir(), "shield.log")
if err := os.WriteFile(path+".1", []byte("sehr alt\n"), 0640); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, bytes.Repeat([]byte("x"), 90), 0640); err != nil {
t.Fatal(err)
}
r, err := OpenFile(path)
if err != nil {
t.Fatal(err)
}
r.SetMaxBytes(100)
if _, err := r.Write([]byte("0123456789\n")); err != nil {
t.Fatal(err)
}
if _, err := r.Write([]byte("neu\n")); err != nil {
t.Fatal(err)
}
_ = r.Close()
backup, _ := os.ReadFile(path + ".1")
if string(backup) != strings.Repeat("x", 90) {
t.Fatalf("backup = %q", backup)
}
cur, _ := os.ReadFile(path)
if string(cur) != "0123456789\nneu\n" {
t.Fatalf("current = %q", cur)
}
if _, err := r.Write([]byte("x")); err == nil {
t.Fatal("write after close must fail")
}
}
func TestRotatingFileConcurrentWritesKeepLines(t *testing.T) {
path := filepath.Join(t.TempDir(), "shield.log")
r, err := OpenFile(path)
if err != nil {
t.Fatal(err)
}
r.SetMaxBytes(4096)
l := New(r, "INFO")
var wg sync.WaitGroup
for g := 0; g < 8; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 200; i++ {
l.Infof("zeile %d", i)
}
}()
}
wg.Wait()
_ = r.Close()
cur, _ := os.ReadFile(path)
if len(cur) > 4096+200 {
t.Fatalf("current log too large after rotation: %d", len(cur))
}
for _, f := range []string{path, path + ".1"} {
b, _ := os.ReadFile(f)
for _, line := range strings.Split(strings.TrimSuffix(string(b), "\n"), "\n") {
if !strings.Contains(line, "[INFO] [ADGUARD-SHIELDD] zeile ") {
t.Fatalf("broken line in %s: %q", f, line)
}
}
}
}