fix: harden daemon and fix ban, whitelist and firewall bugs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,3 +4,4 @@
|
||||
/adguard-shield
|
||||
/adguard-shield.exe
|
||||
*.test
|
||||
/dist/
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 := ¬ifySink{}
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
`
|
||||
|
||||
@@ -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
@@ -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{}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user