5 Commits
v0.9.0 ... main

Author SHA1 Message Date
Patrick Asmus
7bbdb104c4 docs: Dokumentation umfassend überarbeitet 2026-05-01 01:17:05 +02:00
a7f7dbdb71 Merge pull request 'v1.0.0' (#19) from v1.0.0 into main
All checks were successful
Release Binary / Build & Upload Linux Binary (release) Successful in 5m36s
Reviewed-on: #19
2026-04-30 22:16:06 +00:00
Patrick Asmus
4f17f7ff81 feat!: Migration auf Go-Binary
BREAKING CHANGE: Die alte Shell-Version muss vor der Installation der Go-Version deinstalliert werden.
2026-05-01 00:08:57 +02:00
Patrick Asmus
0d1f7db43b feat: Migration auf sqlite3 2026-04-30 15:39:26 +02:00
125329e4ff Merge pull request 'v0.9.0' (#18) from v0.9.0 into main
Reviewed-on: #18
2026-04-28 21:36:28 +00:00
49 changed files with 8867 additions and 9175 deletions

View File

@@ -0,0 +1,46 @@
# AdGuard Shield CI - Pull Request Tests
# Runs on every PR to master: format check, vet, build and tests.
name: PR Tests
on:
pull_request:
branches: [master]
workflow_dispatch:
permissions: read-all
jobs:
test:
name: Format, Vet, Build & Test
runs-on: ubuntu-latest
container:
image: golang:1.26.2-alpine
steps:
- name: Install build dependencies
run: apk add --no-cache git nodejs
- name: Checkout code
uses: actions/checkout@v4
- name: Go module cache
uses: actions/cache@v4
with:
path: /go/pkg/mod
key: go-mod-${{ hashFiles('go.sum') }}
- name: Download dependencies
run: go mod download
- name: Check formatting
run: |
test -z "$(gofmt -l .)"
- name: Go vet
run: go vet ./...
- name: Build Linux binary
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -o /tmp/adguard-shield ./cmd/adguard-shieldd
- name: Run tests
run: go test ./... -v -count=1 -timeout 120s

View File

@@ -0,0 +1,110 @@
# AdGuard Shield CI - Release Binary
# Triggers when a release is published and uploads a Linux amd64 binary asset.
name: Release Binary
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: write
env:
BINARY_NAME: adguard-shield
PACKAGE_NAME: adguard-shield-linux-amd64
jobs:
linux-binary:
name: Build & Upload Linux Binary
runs-on: ubuntu-latest
container:
image: golang:1.26.2-alpine
steps:
- name: Install build dependencies
run: apk add --no-cache git curl jq tar nodejs
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve release tag
id: version
run: |
TAG="${{ github.event.release.tag_name }}"
if [ -z "$TAG" ]; then
TAG="$(git describe --tags --abbrev=0 2>/dev/null || echo '')"
fi
if [ -z "$TAG" ]; then
echo "::error::No release tag found. Create a release or tag first."
exit 1
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
- name: Go module cache
uses: actions/cache@v4
with:
path: /go/pkg/mod
key: go-mod-${{ hashFiles('go.sum') }}
- name: Download dependencies
run: go mod download
- name: Verify before release build
run: |
go vet ./...
go test ./... -count=1 -timeout 120s
- name: Build Linux amd64 binary
run: |
mkdir -p dist
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-trimpath \
-ldflags="-s -w -X adguard-shield/internal/appinfo.Version=${{ steps.version.outputs.tag }}" \
-o "dist/${BINARY_NAME}" \
./cmd/adguard-shieldd
chmod +x "dist/${BINARY_NAME}"
tar -C dist -czf "dist/${PACKAGE_NAME}.tar.gz" "${BINARY_NAME}"
sha256sum "dist/${PACKAGE_NAME}.tar.gz" > "dist/${PACKAGE_NAME}.tar.gz.sha256"
- name: Upload artifacts to Gitea release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_REPOSITORY: ${{ github.repository }}
GITEA_SERVER_URL: ${{ github.server_url }}
TAG: ${{ steps.version.outputs.tag }}
run: |
API="${GITEA_SERVER_URL%/}/api/v1"
REPO="${GITEA_REPOSITORY}"
RELEASE_JSON="$(curl -fsSL \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/repos/${REPO}/releases/tags/${TAG}")"
RELEASE_ID="$(echo "${RELEASE_JSON}" | jq -r '.id')"
if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "null" ]; then
echo "::error::Could not resolve Gitea release id for tag ${TAG}."
exit 1
fi
for file in "dist/${PACKAGE_NAME}.tar.gz" "dist/${PACKAGE_NAME}.tar.gz.sha256"; do
name="$(basename "${file}")"
existing_id="$(curl -fsSL \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/repos/${REPO}/releases/${RELEASE_ID}/assets" \
| jq -r --arg name "${name}" '.[] | select(.name == $name) | .id' \
| head -n 1)"
if [ -n "${existing_id}" ]; then
curl -fsSL -X DELETE \
-H "Authorization: token ${GITEA_TOKEN}" \
"${API}/repos/${REPO}/releases/${RELEASE_ID}/assets/${existing_id}"
fi
curl -fsSL -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${file}" \
"${API}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${name}" \
>/dev/null
done

View File

@@ -0,0 +1,36 @@
# AdGuard Shield CI - Security Scan
# Checks Go dependencies and reachable code for known vulnerabilities.
name: Security Scan
on:
pull_request:
branches: [master]
workflow_dispatch:
permissions: read-all
jobs:
govulncheck:
name: Go Vulnerability Check
runs-on: ubuntu-latest
container:
image: golang:1.26.2-alpine
steps:
- name: Install dependencies
run: apk add --no-cache git nodejs
- name: Checkout code
uses: actions/checkout@v4
- name: Go module cache
uses: actions/cache@v4
with:
path: /go/pkg/mod
key: go-mod-${{ hashFiles('go.sum') }}
- name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: Run govulncheck
run: govulncheck ./...

5
.gitignore vendored
View File

@@ -1 +1,6 @@
.ki-workspace
/adguard-shieldd
/adguard-shieldd.exe
/adguard-shield
/adguard-shield.exe
*.test

333
README.md
View File

@@ -24,137 +24,310 @@
## ✨ Was ist AdGuard Shield?
AdGuard Shield überwacht das Query Log deiner AdGuard-Home-Instanz und erkennt Clients, die eine Domain oder viele zufällige Subdomains in kurzer Zeit übermäßig oft anfragen. Auffällige Clients werden über eine eigene `iptables`/`ip6tables`-Chain auf DNS-relevanten Ports blockiert.
AdGuard Shield ist ein Go-basierter Sicherheitsdaemon, der das Query Log deiner AdGuard-Home-Instanz kontinuierlich überwacht. Er erkennt Clients, die eine Domain oder viele zufällige Subdomains in kurzer Zeit übermäßig oft anfragen, und sperrt sie automatisch über eine eigene `iptables`/`ip6tables`-Chain auf DNS-relevanten Ports.
Das schützt klassische DNS-Anfragen genauso wie DoH, DoT und DoQ, ohne deine bestehenden Firewall-Regeln unnötig anzufassen.
Das Projekt schützt klassische DNS-Anfragen genauso wie DNS-over-HTTPS (DoH), DNS-over-TLS (DoT), DNS-over-QUIC (DoQ) und DNSCrypt, ohne deine bestehenden Firewall-Regeln anzufassen. AdGuard Shield arbeitet nicht direkt am Netzwerkverkehr, sondern wertet das Querylog von AdGuard Home über dessen API aus. Dadurch werden auch verschlüsselte DNS-Protokolle zuverlässig erfasst, solange sie in AdGuard Home sichtbar sind.
Das gesamte Projekt ist als einzelnes, statisch kompiliertes Go-Binary realisiert, das gleichzeitig als Daemon, CLI-Werkzeug, Installer und Report-Generator fungiert. Es ersetzt die frühere Shell-basierte Implementierung mit mehreren Skripten, Cron-Jobs und einem separaten Watchdog.
## 🚀 Highlights
- Automatische Sperren bei Rate-Limit-Verstößen
- Erkennung von Random-Subdomain-Floods, z.B. `abc123.example.com`
- DNS-Flood-Watchlist: sofortiger permanenter Ban + AbuseIPDB-Meldung für definierte Domains
- Progressive Sperren für Wiederholungstäter, ähnlich wie bei fail2ban
- Unterstützung für DNS, DoH, DoT, DoQ und DNSCrypt
- IPv4 und IPv6
- Eigene Firewall-Chain für sauberes Debugging und einfache Entfernung
- Externe Blocklisten und dynamische externe Whitelists
- GeoIP-Länderfilter mit Blocklist- oder Allowlist-Modus
- AbuseIPDB-Reporting für permanent gesperrte IPs
- Benachrichtigungen über Ntfy, Discord, Slack, Gotify oder Generic Webhook
- E-Mail-Reports als HTML oder Text
- Watchdog mit automatischem Health Check und Recovery
| Bereich | Funktionen |
|---|---|
| **Erkennung** | Rate-Limit-Überwachung pro Client und Domain, Random-Subdomain-Flood-Erkennung (z.B. `abc123.example.com`), DNS-Flood-Watchlist für sofortigen Permanent-Ban |
| **Sperren** | Progressive Sperren für Wiederholungstäter (fail2ban-ähnlich), temporäre und permanente Sperren, automatische Freigabe abgelaufener Sperren |
| **Protokolle** | DNS, DoH, DoT, DoQ und DNSCrypt, IPv4 und IPv6 |
| **Firewall** | Eigene Chain mit `ipset`-Sets für performante Sperren, Firewall-Modi für Host, Docker Host Network, Docker Bridge und Hybrid |
| **Listen** | Externe Blocklisten und dynamische externe Whitelists mit automatischer DNS-Auflösung |
| **GeoIP** | Länderbasierte Filterung mit Blocklist- oder Allowlist-Modus über MaxMind GeoLite2 |
| **Meldungen** | AbuseIPDB-Reporting für permanent gesperrte IPs |
| **Benachrichtigungen** | Ntfy, Discord, Slack, Gotify oder Generic Webhook |
| **Reports** | E-Mail-Reports als HTML oder Text mit konfigurierbarem Versandintervall |
| **Betrieb** | systemd-Service mit Restart-Policy, Terminal-Live-Ansicht, Dry-Run-Modus, SQLite-State |
## ✅ Voraussetzungen
- Linux-Server mit AdGuard Home
- Root-Zugriff per `sudo`
- Erreichbare AdGuard Home Web-API, standardmäßig `http://127.0.0.1:3000`
- `curl`, `jq`, `iptables`, `gawk` und `systemd`
| Komponente | Beschreibung |
|---|---|
| **Betriebssystem** | Linux-Server (Debian, Ubuntu oder kompatible Distribution) |
| **AdGuard Home** | Laufende Instanz mit erreichbarer Web-API (Standard: `http://127.0.0.1:3000`) |
| **Root-Zugriff** | Erforderlich für Firewall-Steuerung und Service-Management |
| **Systempakete** | `iptables`, `ip6tables`, `ipset` und `systemd` |
| **Optional** | `msmtp` für E-Mail-Reports, MaxMind-Account für GeoIP-Daten |
Die benötigten Pakete werden vom Installer automatisch installiert.
Die benötigten Pakete werden vom Installer auf Ubuntu/Debian automatisch installiert, sofern `apt-get` verfügbar ist.
> **Hinweis:** Go wird auf dem Server nicht benötigt, wenn du ein fertiges Linux-Binary verwendest. Zum Erzeugen des Binarys brauchst du Go auf dem Build-Rechner oder alternativ Docker/CI/Release-Artefakte.
## ⚡ Schnellstart
### Variante A: Fertiges Release-Binary
```bash
git clone https://git.techniverse.net/scriptos/adguard-shield.git /tmp/adguard-shield
cd /tmp/adguard-shield
# Release-Archiv herunterladen und entpacken
curl -fL -o adguard-shield-linux-amd64.tar.gz \
https://git.techniverse.net/scriptos/adguard-shield/releases/download/v1.0.0/adguard-shield-linux-amd64.tar.gz
tar -xzf adguard-shield-linux-amd64.tar.gz
chmod +x ./adguard-shield
```
# Interaktives Installationsmenü
sudo bash install.sh
### Variante B: Lokal mit Go bauen
# Vor dem produktiven Start testen: loggt nur, sperrt nichts
sudo /opt/adguard-shield/adguard-shield.sh dry-run
```bash
git clone https://git.techniverse.net/scriptos/adguard-shield.git
cd adguard-shield
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o adguard-shield ./cmd/adguard-shieldd
```
### Variante C: Ohne lokales Go per Docker bauen
```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 \
go build -o adguard-shield ./cmd/adguard-shieldd
```
### Installation und erster Start
```bash
# Binary auf dem Server installieren
sudo ./adguard-shield install
# Der Installer fragt am Ende, ob AdGuard Shield direkt gestartet werden soll.
# Konfiguration anpassen (mindestens API-Zugangsdaten und Whitelist)
sudo nano /opt/adguard-shield/adguard-shield.conf
# API-Verbindung testen
sudo /opt/adguard-shield/adguard-shield test
# Dry-Run: loggt Erkennungen, sperrt aber nicht
sudo /opt/adguard-shield/adguard-shield dry-run
# Service starten und prüfen
sudo systemctl start adguard-shield
sudo systemctl status adguard-shield
```
> Beim Installieren wird der systemd-Service für den Autostart registriert. Der Watchdog-Timer wird ebenfalls eingerichtet und prüft den Service regelmäßig.
> Beim Installieren wird der systemd-Service für den Autostart registriert und am Ende nach dem direkten Start gefragt. Die Go-Version nutzt `Restart=on-failure`; einen separaten Watchdog-Timer wie in der alten Shell-Version gibt es nicht mehr.
> **Bestehende Shell-Installation?** Der Go-Installer bricht ab und meldet die gefundenen Script-Artefakte. Die alte Version muss zuerst deinstalliert werden (Konfiguration behalten). Details unter [docs/update.md](docs/update.md).
[![asciicast](https://asciinema.techniverse.net/a/77.svg)](https://asciinema.techniverse.net/a/77)
## 🔧 Wichtigste Befehle
## 🔧 Befehlsübersicht
AdGuard Shield wird über ein einzelnes Binary bedient. Die Grundform lautet:
```bash
sudo /opt/adguard-shield/adguard-shield <befehl>
```
### Installation & Updates
```bash
sudo bash install.sh # Interaktives Menü
sudo bash install.sh install # Direkt installieren
sudo bash install.sh update # Update inkl. Konfigurations-Migration
sudo bash install.sh status # Installationsstatus prüfen
sudo bash /opt/adguard-shield/uninstall.sh
```
| Befehl | Beschreibung |
|---|---|
| `install` | Binary, Konfiguration und systemd-Service installieren |
| `install --skip-deps` | Installation ohne automatische Paketprüfung |
| `install --no-enable` | Installation ohne systemd-Autostart |
| `install --config-source <pfad>` | Bestehende Konfiguration als Vorlage übernehmen |
| `update` | Binary, Service und Konfiguration aktualisieren |
| `install-status` | Installationsstatus anzeigen (Binary, Service, Version) |
| `uninstall` | Vollständige Deinstallation |
| `uninstall --keep-config` | Deinstallation mit Erhalt der Konfiguration |
### Betrieb & Diagnose
### Daemon & Service
```bash
sudo systemctl status adguard-shield
sudo systemctl restart adguard-shield
sudo journalctl -u adguard-shield -f
| Befehl | Beschreibung |
|---|---|
| `run` / `start` | Daemon im Vordergrund starten |
| `dry-run` | Daemon starten, der nur loggt aber nicht sperrt |
| `stop` | Laufenden Daemon über PID-Datei stoppen |
| `test` | API-Verbindung zu AdGuard Home testen |
| `version` | Installierte Version anzeigen |
sudo /opt/adguard-shield/adguard-shield.sh status
sudo /opt/adguard-shield/adguard-shield.sh history
sudo /opt/adguard-shield/adguard-shield.sh test
sudo /opt/adguard-shield/adguard-shield.sh unban 192.0.2.10
sudo /opt/adguard-shield/adguard-shield.sh flush
```
### Status & Monitoring
### Optionale Module
| Befehl | Beschreibung |
|---|---|
| `status` | Aktive Sperren und Konfigurationsübersicht anzeigen |
| `live` / `watch` | Terminal-Live-Ansicht mit Queries, Top-Clients, Sperren und Logs |
| `live --interval 2` | Live-Ansicht mit benutzerdefiniertem Aktualisierungsintervall |
| `live --top 20` | Live-Ansicht mit mehr Top-Einträgen |
| `live --recent 25` | Mehr letzte Queries und Logs anzeigen |
| `live --logs debug` | DEBUG-Logs in der Live-Ansicht einblenden |
| `live --logs off` | Log-Bereich in der Live-Ansicht ausblenden |
| `live --once` | Einmaligen Snapshot ausgeben |
| `history [N]` | Ban-History anzeigen (Standard: 50 Einträge) |
| `logs` | Daemon-Logeinträge anzeigen |
| `logs --level warn --limit 100` | Gefilterte Logs anzeigen |
| `logs-follow` | Logs in Echtzeit verfolgen |
```bash
sudo /opt/adguard-shield/adguard-shield.sh blocklist-status
sudo /opt/adguard-shield/adguard-shield.sh whitelist-status
sudo /opt/adguard-shield/adguard-shield.sh geoip-status
### Sperren & Freigaben
sudo /opt/adguard-shield/report-generator.sh status
sudo /opt/adguard-shield/report-generator.sh send
sudo /opt/adguard-shield/report-generator.sh install
```
| Befehl | Beschreibung |
|---|---|
| `ban <IP>` | IP-Adresse manuell permanent sperren |
| `unban <IP>` | Sperre für eine IP-Adresse aufheben |
| `flush` | Alle aktiven Sperren aufheben |
Die vollständige Befehlsreferenz steht in [docs/befehle.md](docs/befehle.md).
### Progressive Sperren (Offense-Tracking)
| Befehl | Beschreibung |
|---|---|
| `offense-status` | Offense-Zähler und Statistik anzeigen |
| `offense-cleanup` | Abgelaufene Offense-Zähler entfernen |
| `reset-offenses` | Alle Offense-Zähler zurücksetzen |
| `reset-offenses <IP>` | Offense-Zähler für eine bestimmte IP zurücksetzen |
### Firewall
| Befehl | Beschreibung |
|---|---|
| `firewall-create` | Firewall-Chain und ipsets anlegen |
| `firewall-status` | Aktuelle Firewall-Regeln und ipsets anzeigen |
| `firewall-flush` | ipsets leeren (Sperren entfernen, Struktur bleibt) |
| `firewall-remove` | Chain, Regeln und ipsets vollständig entfernen |
| `firewall-save` | Aktuelle iptables-Regeln in Datei sichern |
| `firewall-restore` | Gesicherte Regeln wiederherstellen |
### GeoIP
| Befehl | Beschreibung |
|---|---|
| `geoip-status` | GeoIP-Konfiguration und Status anzeigen |
| `geoip-lookup <IP>` | Land einer IP-Adresse nachschlagen |
| `geoip-sync` | Aktuelle Querylog-Clients einmalig gegen GeoIP prüfen |
| `geoip-flush` | Alle GeoIP-Sperren aufheben |
| `geoip-flush-cache` | GeoIP-Cache leeren |
### Externe Listen
| Befehl | Beschreibung |
|---|---|
| `blocklist-status` | Status der externen Blocklist anzeigen |
| `blocklist-sync` | Externe Blocklist sofort synchronisieren |
| `blocklist-flush` | Alle Sperren aus externer Blocklist aufheben |
| `whitelist-status` | Status der externen Whitelist anzeigen |
| `whitelist-sync` | Externe Whitelist sofort synchronisieren |
| `whitelist-flush` | Aufgelöste externe Whitelist-Einträge entfernen |
### E-Mail-Reports
| Befehl | Beschreibung |
|---|---|
| `report-status` | Report-Konfiguration und Cron-Status anzeigen |
| `report-generate html <datei>` | HTML-Report in Datei schreiben |
| `report-generate txt` | Text-Report auf stdout ausgeben |
| `report-test` | Testmail senden |
| `report-send` | Aktuellen Report erzeugen und per E-Mail versenden |
| `report-install` | Cron-Job für automatischen Versand installieren |
| `report-remove` | Cron-Job entfernen |
Die vollständige Befehlsreferenz mit Beispielen und typischen Betriebsabläufen steht in [docs/befehle.md](docs/befehle.md).
## ⚙️ Konfiguration
Die zentrale Konfiguration liegt nach der Installation hier:
Die zentrale Konfigurationsdatei liegt nach der Installation hier:
```text
/opt/adguard-shield/adguard-shield.conf
```
Wichtige Startpunkte:
Die Datei verwendet ein einfaches Shell-ähnliches Key-Value-Format. Nach Änderungen muss der Service neu gestartet werden:
- `ADGUARD_URL`, `ADGUARD_USER`, `ADGUARD_PASS` für die AdGuard-Home-API
- `RATE_LIMIT_MAX_REQUESTS`, `RATE_LIMIT_WINDOW` und `CHECK_INTERVAL` für die Erkennung
- `BAN_DURATION` und `PROGRESSIVE_BAN_*` für temporäre und progressive Sperren
- `WHITELIST` für vertrauenswürdige Clients wie Router, Management-IPs oder lokale Resolver
- `DNS_FLOOD_WATCHLIST_*` für sofortigen Permanent-Ban bei bekannten Flood-Domains
- `NOTIFY_*`, `REPORT_*`, `GEOIP_*`, `EXTERNAL_BLOCKLIST_*` und `EXTERNAL_WHITELIST_*` für optionale Funktionen
```bash
sudo systemctl restart adguard-shield
```
### Wichtigste Parameter
| Parameter | Standard | Beschreibung |
|---|---|---|
| `ADGUARD_URL` | `https://dns1.domain.com` | URL der AdGuard-Home-API |
| `ADGUARD_USER` | `admin` | API-Benutzername |
| `ADGUARD_PASS` | `changeme` | API-Passwort |
| `RATE_LIMIT_MAX_REQUESTS` | `30` | Maximale Anfragen pro Client/Domain im Zeitfenster |
| `RATE_LIMIT_WINDOW` | `60` | Zeitfenster in Sekunden |
| `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) |
| `DRY_RUN` | `false` | Testmodus: nur loggen, nicht sperren |
### Optionale Module
| Modul | Aktivierung | Beschreibung |
|---|---|---|
| Subdomain-Flood | `SUBDOMAIN_FLOOD_ENABLED=true` | Erkennung von Random-Subdomain-Angriffen |
| DNS-Flood-Watchlist | `DNS_FLOOD_WATCHLIST_ENABLED=true` | Sofortiger Permanent-Ban für definierte Domains |
| Progressive Sperren | `PROGRESSIVE_BAN_ENABLED=true` | Stufenweise längere Sperren für Wiederholungstäter |
| GeoIP-Länderfilter | `GEOIP_ENABLED=true` | Ländersperre per MaxMind-Datenbank |
| Externe Blocklist | `EXTERNAL_BLOCKLIST_ENABLED=true` | IP-Sperren aus externen Listen |
| Externe Whitelist | `EXTERNAL_WHITELIST_ENABLED=true` | Dynamische Whitelist mit DNS-Auflösung |
| Benachrichtigungen | `NOTIFY_ENABLED=true` | Push-Benachrichtigungen bei Sperrereignissen |
| E-Mail-Reports | `REPORT_ENABLED=true` | Periodische Statistik-Reports per E-Mail |
| AbuseIPDB | `ABUSEIPDB_ENABLED=true` | Automatische Meldung permanenter Sperren |
Bei Updates migriert der Installer die bestehende Konfiguration automatisch: vorhandene Werte bleiben erhalten, neue Parameter werden ergänzt und die alte Datei wird als `adguard-shield.conf.old` gesichert.
Mehr Details findest du in [docs/konfiguration.md](docs/konfiguration.md).
Die vollständige Parameterbeschreibung mit Beispielkonfigurationen findest du in [docs/konfiguration.md](docs/konfiguration.md).
## 🧭 Dokumentation
## 🧩 Wie AdGuard Shield arbeitet
| Thema | Link |
|---|---|
| Architektur & Funktionsweise | [docs/architektur.md](docs/architektur.md) |
| Befehle & Nutzung | [docs/befehle.md](docs/befehle.md) |
| Konfiguration | [docs/konfiguration.md](docs/konfiguration.md) |
| Benachrichtigungen | [docs/benachrichtigungen.md](docs/benachrichtigungen.md) |
| E-Mail Report | [docs/report.md](docs/report.md) |
| Updates | [docs/update.md](docs/update.md) |
| Tipps & Troubleshooting | [docs/tipps-und-troubleshooting.md](docs/tipps-und-troubleshooting.md) |
## 🧩 Wie es arbeitet
```text
DNS-Clients
│ DNS, DoH, DoT, DoQ, DNSCrypt
AdGuard Home
│ /control/querylog API
AdGuard Shield Daemon (pollt alle CHECK_INTERVAL Sekunden)
├── Rate-Limit-Prüfung (Client + Domain)
├── Subdomain-Flood-Erkennung (Client + Basisdomain)
├── DNS-Flood-Watchlist-Abgleich
├── Whitelist-Prüfung (statisch + extern)
├── GeoIP-Prüfung (falls aktiviert)
├── Progressive Ban-Berechnung
└── History-Protokollierung
SQLite-Datenbank (active_bans, ban_history, offense_tracking)
ipset + iptables/ip6tables
DNS-relevante Ports (53, 443, 853) werden für gesperrte Clients blockiert
```
1. AdGuard Shield liest regelmäßig das AdGuard-Home-Query-Log über die API.
2. Anfragen werden pro Client, Domain und Protokoll ausgewertet.
3. Überschreitet ein Client die konfigurierten Limits, wird er gegen Whitelist und Sonderregeln geprüft.
4. Die Sperre landet in der eigenen Firewall-Chain `ADGUARD_SHIELD`.
3. Überschreitet ein Client die konfigurierten Limits, wird er gegen Whitelist, GeoIP und Sonderregeln geprüft.
4. Die Sperre landet in der eigenen Firewall-Chain `ADGUARD_SHIELD` und wird in SQLite gespeichert.
5. Ban-History, Logs und optionale Benachrichtigungen dokumentieren das Ereignis.
6. Temporäre Sperren werden automatisch entfernt, permanente Sperren bleiben bis zur manuellen Freigabe aktiv.
7. Bei einem Neustart werden alle aktiven Sperren aus SQLite wieder in die Firewall übertragen.
## 🧭 Dokumentation
| Thema | Link | Beschreibung |
|---|---|---|
| Architektur & Funktionsweise | [docs/architektur.md](docs/architektur.md) | Aufbau, Datenfluss, Firewall-Modell, SQLite-Schema, Hintergrundjobs und Sperrlogik |
| Befehle & Nutzung | [docs/befehle.md](docs/befehle.md) | Vollständige CLI-Referenz mit Beispielen und typischen Betriebsabläufen |
| Konfiguration | [docs/konfiguration.md](docs/konfiguration.md) | Alle Parameter aus `adguard-shield.conf` mit Beispielen und Empfehlungen |
| Docker-Installationen | [docs/docker.md](docs/docker.md) | Firewall-Modi für klassische Installation, Docker Host Network und Docker Bridge |
| Benachrichtigungen | [docs/benachrichtigungen.md](docs/benachrichtigungen.md) | Einrichtung von Ntfy, Discord, Slack, Gotify und Generic Webhooks |
| E-Mail Report | [docs/report.md](docs/report.md) | Report-Inhalte, Mailversand, Cron-Job und manuelle Tests |
| Updates | [docs/update.md](docs/update.md) | Update-Ablauf, Konfigurationsmigration und Migration von der Shell-Version |
| Tipps & Troubleshooting | [docs/tipps-und-troubleshooting.md](docs/tipps-und-troubleshooting.md) | Diagnosewege für API, Firewall, GeoIP, Reports, Listen und falsche Sperren |
## 📜 Lizenz
Dieses Projekt steht unter der [MIT-Lizenz](./LICENSE).
<br><br>
<p align="center">
@@ -165,4 +338,4 @@ Mehr Details findest du in [docs/konfiguration.md](docs/konfiguration.md).
<sub>
Patrick Asmus · Techniverse Network · <a href="./LICENSE">Lizenz</a>
</sub>
</p>
</p>

View File

@@ -1,7 +0,0 @@
[Unit]
Description=AdGuard Shield - Watchdog Health Check
Documentation=https://git.techniverse.net/scriptos/adguard-shield
[Service]
Type=oneshot
ExecStart=/opt/adguard-shield/adguard-shield-watchdog.sh

View File

@@ -1,166 +0,0 @@
#!/bin/bash
###############################################################################
# AdGuard Shield - Watchdog
# Prüft ob der Hauptservice läuft und startet ihn bei Bedarf neu.
# Wird über adguard-shield-watchdog.timer alle 5 Minuten ausgeführt.
#
# Autor: Patrick Asmus
# E-Mail: support@techniverse.net
# Lizenz: MIT
###############################################################################
set -euo pipefail
INSTALL_DIR="/opt/adguard-shield"
CONFIG_FILE="${INSTALL_DIR}/adguard-shield.conf"
SERVICE_NAME="adguard-shield.service"
LOG_FILE="/var/log/adguard-shield.log"
WATCHDOG_STATE_FILE="/var/lib/adguard-shield/watchdog.state"
# ─── Logging ──────────────────────────────────────────────────────────────────
log() {
local level="$1"
shift
local message="$*"
local timestamp
timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
local log_entry="[$timestamp] [WATCHDOG] [$level] $message"
echo "$log_entry" | tee -a "$LOG_FILE"
}
# ─── Benachrichtigung senden ──────────────────────────────────────────────────
send_watchdog_notification() {
local action="$1" # "recovery" oder "failure"
local detail="$2"
# Konfiguration laden für Benachrichtigungs-Einstellungen
if [[ ! -f "$CONFIG_FILE" ]]; then
return
fi
source "$CONFIG_FILE"
if [[ "${NOTIFY_ENABLED:-false}" != "true" ]]; then
return
fi
local my_hostname
my_hostname=$(hostname)
local title message
if [[ "$action" == "recovery" ]]; then
title="🔄 AdGuard Shield Watchdog"
message="🔄 AdGuard Shield Watchdog auf ${my_hostname}
---
Der Service war ausgefallen und wurde automatisch neu gestartet.
${detail}"
elif [[ "$action" == "failure" ]]; then
title="🚨 AdGuard Shield Watchdog"
message="🚨 AdGuard Shield Watchdog auf ${my_hostname}
---
Der Service konnte NICHT automatisch neu gestartet werden!
Manuelles Eingreifen erforderlich.
${detail}"
fi
case "${NOTIFY_TYPE:-}" in
discord)
local json_payload
json_payload=$(jq -nc --arg msg "$message" '{content: $msg}')
curl -s -H "Content-Type: application/json" \
-d "$json_payload" \
"$NOTIFY_WEBHOOK_URL" &>/dev/null || true
;;
slack)
local json_payload
json_payload=$(jq -nc --arg msg "$message" '{text: $msg}')
curl -s -H "Content-Type: application/json" \
-d "$json_payload" \
"$NOTIFY_WEBHOOK_URL" &>/dev/null || true
;;
gotify)
curl -s -X POST "$NOTIFY_WEBHOOK_URL" \
-F "title=${title}" \
-F "message=${message}" \
-F "priority=5" &>/dev/null || true
;;
ntfy)
if [[ -n "${NTFY_TOPIC:-}" ]]; then
local ntfy_url="${NTFY_SERVER_URL:-https://ntfy.sh}"
local auth_args=()
if [[ -n "${NTFY_TOKEN:-}" ]]; then
auth_args=(-H "Authorization: Bearer ${NTFY_TOKEN}")
fi
curl -s \
-H "Title: ${title}" \
-H "Priority: ${NTFY_PRIORITY:-5}" \
-H "Tags: warning,watchdog" \
"${auth_args[@]}" \
-d "$message" \
"${ntfy_url}/${NTFY_TOPIC}" &>/dev/null || true
fi
;;
generic)
local json_payload
json_payload=$(jq -nc --arg msg "$message" --arg act "watchdog_${action}" \
'{message: $msg, action: $act}')
curl -s -H "Content-Type: application/json" \
-d "$json_payload" \
"$NOTIFY_WEBHOOK_URL" &>/dev/null || true
;;
esac
}
# ─── Hauptlogik ──────────────────────────────────────────────────────────────
main() {
# Verzeichnis für State-Datei sicherstellen
mkdir -p "$(dirname "$WATCHDOG_STATE_FILE")"
# Prüfen ob der Service aktiv ist
if systemctl is-active --quiet "$SERVICE_NAME"; then
# Service läuft falls vorher ausgefallen war, Status zurücksetzen
if [[ -f "$WATCHDOG_STATE_FILE" ]]; then
rm -f "$WATCHDOG_STATE_FILE"
fi
exit 0
fi
# Service läuft NICHT Recovery versuchen
log "WARN" "Service $SERVICE_NAME ist nicht aktiv starte Recovery..."
# Zähler für fehlgeschlagene Recovery-Versuche
local fail_count=0
if [[ -f "$WATCHDOG_STATE_FILE" ]]; then
fail_count=$(cat "$WATCHDOG_STATE_FILE" 2>/dev/null || echo "0")
fi
# systemd reset-failed damit StartLimit zurückgesetzt wird
systemctl reset-failed "$SERVICE_NAME" 2>/dev/null || true
# Service starten
if systemctl start "$SERVICE_NAME" 2>/dev/null; then
# Kurz warten und prüfen ob er auch wirklich läuft
sleep 3
if systemctl is-active --quiet "$SERVICE_NAME"; then
log "INFO" "Service $SERVICE_NAME erfolgreich neu gestartet (Watchdog Recovery)"
send_watchdog_notification "recovery" "Versuch: $((fail_count + 1))"
rm -f "$WATCHDOG_STATE_FILE"
exit 0
fi
fi
# Start fehlgeschlagen
fail_count=$((fail_count + 1))
echo "$fail_count" > "$WATCHDOG_STATE_FILE"
log "ERROR" "Service $SERVICE_NAME konnte nicht gestartet werden (Fehlversuch: $fail_count)"
# Bei jedem 3. Fehlversuch eine Benachrichtigung senden (Spam vermeiden)
if [[ $((fail_count % 3)) -eq 1 ]]; then
send_watchdog_notification "failure" "Fehlversuche: $fail_count
Letzter Fehler: $(systemctl status "$SERVICE_NAME" 2>&1 | tail -5)"
fi
exit 1
}
main

View File

@@ -1,11 +0,0 @@
[Unit]
Description=AdGuard Shield - Watchdog Timer
Documentation=https://git.techniverse.net/scriptos/adguard-shield
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
AccuracySec=30s
[Install]
WantedBy=timers.target

View File

@@ -26,6 +26,8 @@ DNS_FLOOD_WATCHLIST="" # Kommagetrennt, z.B. "example.com,evil.org"
BAN_DURATION=3600 # Basis-Sperrdauer in Sekunden
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)
FIREWALL_MODE="host" # host/docker-host, docker-bridge oder hybrid
# --- Whitelist ---
# IPs die niemals gesperrt werden (kommagetrennt)
@@ -34,9 +36,6 @@ 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 # Max. Größe in MB (danach Rotation)
BAN_HISTORY_FILE="/var/log/adguard-shield-bans.log"
BAN_HISTORY_RETENTION_DAYS=0 # 0 = unbegrenzt
# --- Benachrichtigungen ---
NOTIFY_ENABLED=false
@@ -93,14 +92,15 @@ ABUSEIPDB_CATEGORIES="4" # 4 = DDoS Attack (siehe abuseipdb.com/categ
GEOIP_ENABLED=false
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
GEOIP_CHECK_INTERVAL=0 # Legacy: Daemon nutzt den zentralen CHECK_INTERVAL-Poller
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
# --- Erweiterte Einstellungen ---
STATE_DIR="/var/lib/adguard-shield"
STATE_DIR="/var/lib/adguard-shield" # SQLite-DB: ${STATE_DIR}/adguard-shield.db
PID_FILE="/var/run/adguard-shield.pid"
API_QUERY_LIMIT=500 # API-Einträge pro Abfrage (max 5000)
DRY_RUN=false # true = nur loggen, nicht sperren

View File

@@ -1,5 +1,5 @@
[Unit]
Description=AdGuard Shield - DNS Rate-Limit Monitor
Description=AdGuard Shield - Go DNS Rate-Limit Monitor
Documentation=https://git.techniverse.net/scriptos/adguard-shield
After=network.target AdGuardHome.service
Wants=AdGuardHome.service
@@ -8,8 +8,7 @@ StartLimitIntervalSec=300
[Service]
Type=simple
ExecStart=/opt/adguard-shield/adguard-shield.sh start
ExecStop=/opt/adguard-shield/adguard-shield.sh stop
ExecStart=/opt/adguard-shield/adguard-shield -config /opt/adguard-shield/adguard-shield.conf run
ExecReload=/bin/kill -HUP $MAINPID
# Neustart-Verhalten
@@ -18,7 +17,7 @@ RestartSec=30
# Sicherheits-Hardening
ProtectSystem=full
ReadWritePaths=/var/log /var/lib/adguard-shield /var/lib/adguard-shield/external-blocklist /var/run
ReadWritePaths=/var/log /var/lib/adguard-shield /var/run /opt/adguard-shield/geoip
ProtectHome=true
NoNewPrivileges=false
PrivateTmp=true

File diff suppressed because it is too large Load Diff

661
cmd/adguard-shieldd/main.go Normal file
View File

@@ -0,0 +1,661 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
"adguard-shield/internal/appinfo"
"adguard-shield/internal/config"
"adguard-shield/internal/daemon"
"adguard-shield/internal/installer"
"adguard-shield/internal/report"
)
const statusBanLimit = 50
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "FEHLER:", err)
os.Exit(1)
}
}
func run() error {
args := os.Args[1:]
confPath := config.DefaultPath()
if len(args) >= 2 && args[0] == "-config" {
confPath = args[1]
args = args[2:]
}
cmd := "run"
if len(args) > 0 {
cmd = args[0]
args = args[1:]
}
switch cmd {
case "version", "--version", "-v":
fmt.Println(appinfo.Version)
return nil
case "install":
return installCommand(args)
case "update":
return updateCommand(args)
case "uninstall":
return uninstallCommand(args)
case "install-status":
return installStatusCommand(args)
}
cfg, err := config.Load(confPath)
if err != nil {
return err
}
d, err := daemon.New(cfg)
if err != nil {
return err
}
defer d.Close()
ctx, stop := commandContext(d, cmd == "run" || cmd == "start" || cmd == "dry-run")
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
}
defer os.Remove(cfg.PIDFile)
err := d.Run(ctx)
if errors.Is(err, context.Canceled) {
return nil
}
return err
case "stop":
return stopDaemon(cfg.PIDFile)
case "test":
items, err := d.FetchQueryLog(ctx)
if err != nil {
return err
}
fmt.Printf("Verbindung erfolgreich. %d Querylog-Einträge gefunden.\n", len(items))
case "status":
return status(d)
case "live", "watch":
return liveCommand(ctx, d, args)
case "logs":
return logsCommand(d, args)
case "logs-follow":
return logsFollowCommand(ctx, d, args)
case "history":
limit := 50
if len(args) > 0 {
if n, err := strconv.Atoi(args[0]); err == nil && n > 0 {
limit = n
}
}
lines, err := d.Store.RecentHistory(limit)
if err != nil {
return err
}
for _, l := range lines {
fmt.Println(l)
}
case "flush":
bans, err := d.Store.ActiveBans()
if err != nil {
return err
}
for _, b := range bans {
_ = d.UnbanQuiet(ctx, b.IP, "manual-flush")
}
d.NotifyBulkUnban(ctx, "manual-flush", len(bans))
fmt.Printf("%d Sperren aufgehoben\n", len(bans))
case "unban":
if len(args) < 1 {
return fmt.Errorf("Nutzung: adguard-shield unban <IP>")
}
return d.Unban(ctx, args[0], "manual")
case "ban":
if len(args) < 1 {
return fmt.Errorf("Nutzung: adguard-shield ban <IP>")
}
return d.Ban(ctx, args[0], "manual", 0, "-", "manual", "manual", "", true)
case "reset-offenses":
ip := ""
if len(args) > 0 {
ip = args[0]
}
return d.Store.ResetOffense(ip)
case "offense-cleanup":
n, err := d.Store.CleanupOffenses(cfg.ProgressiveBanResetAfter)
if err != nil {
return err
}
fmt.Printf("%d abgelaufene Offense-Zaehler entfernt\n", n)
case "offense-status":
total, err := d.Store.CountOffenses()
if err != nil {
return err
}
expired, err := d.Store.CountExpiredOffenses(cfg.ProgressiveBanResetAfter)
if err != nil {
return err
}
fmt.Println("Offense-Cleanup")
fmt.Printf("Aktiv im Daemon: %v\n", cfg.ProgressiveBanEnabled)
fmt.Printf("Reset nach: %ds\n", cfg.ProgressiveBanResetAfter)
fmt.Printf("Offense-Zaehler gesamt: %d\n", total)
fmt.Printf("Davon abgelaufen: %d\n", expired)
case "geoip-lookup":
if len(args) < 1 {
return fmt.Errorf("Nutzung: adguard-shield geoip-lookup <IP>")
}
if err := d.Geo.Open(ctx); err != nil {
return err
}
cc, err := d.Geo.Lookup(args[0])
if err != nil {
return err
}
fmt.Printf("IP: %s -> Land: %s\n", args[0], empty(cc, "unbekannt"))
case "geoip-sync":
if err := d.Geo.Open(ctx); err != nil {
return err
}
items, err := d.FetchQueryLog(ctx)
if err != nil {
return err
}
events := d.ToEventsForCommand(items)
seen := map[string]bool{}
for _, ev := range events {
if seen[ev] {
continue
}
seen[ev] = true
d.CheckGeoIPForCommand(ctx, ev)
}
fmt.Printf("GeoIP-Sync abgeschlossen: %d Clients geprüft\n", len(seen))
case "geoip-status":
return geoipStatus(d)
case "geoip-flush":
bans, err := d.Store.ActiveBans()
if err != nil {
return err
}
n := 0
for _, b := range bans {
if b.Reason == "geoip" || b.Source == "geoip" {
_ = d.UnbanQuiet(ctx, b.IP, "geoip-flush")
n++
}
}
d.NotifyBulkUnban(ctx, "geoip-flush", n)
fmt.Printf("%d GeoIP-Sperren aufgehoben\n", n)
case "geoip-flush-cache":
n, err := d.Store.ClearGeoIPCache()
if err != nil {
return err
}
fmt.Printf("%d GeoIP-Cache-Einträge entfernt\n", n)
case "blocklist-sync":
return d.SyncBlocklist(ctx)
case "whitelist-sync":
return d.SyncWhitelist(ctx)
case "blocklist-status":
return blocklistStatus(d)
case "whitelist-status":
return whitelistStatus(d)
case "blocklist-flush":
return flushSource(ctx, d, "external-blocklist")
case "whitelist-flush":
return d.Store.ReplaceWhitelist(nil, "external")
case "report-status":
fmt.Print(report.Status(cfg))
case "report-generate":
format := ""
output := ""
if len(args) > 0 {
format = args[0]
}
if len(args) > 1 {
output = args[1]
}
body, err := report.Generate(cfg, d.Store, format)
if err != nil {
return err
}
if output != "" {
return os.WriteFile(output, []byte(body), 0644)
}
fmt.Print(body)
case "report-send":
return report.Send(ctx, cfg, d.Store)
case "report-test":
return report.SendTest(ctx, cfg)
case "report-install":
return report.InstallCron("/opt/adguard-shield/adguard-shield", cfg.Path, cfg)
case "report-remove":
return report.RemoveCron()
case "firewall-create":
return d.FW.Setup(ctx)
case "firewall-remove":
return d.FW.Remove(ctx)
case "firewall-flush":
return d.FW.Flush(ctx)
case "firewall-status":
return firewallStatus(ctx, d)
case "firewall-save":
return d.SaveFirewallRules(ctx)
case "firewall-restore":
return restoreFirewallRules(ctx, d)
default:
usage()
}
return nil
}
func status(d *daemon.Daemon) error {
bans, err := d.Store.ActiveBans()
if err != nil {
return err
}
fmt.Println("AdGuard Shield Daemon Status")
fmt.Printf("Config: %s\n", d.Config.Path)
fmt.Printf("Firewall: %s/%s (Chain: %s)\n", d.Config.FirewallBackend, d.Config.FirewallMode, d.Config.Chain)
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] {
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 history oder direkt in SQLite.\n", len(bans)-limit)
}
return nil
}
func blocklistStatus(d *daemon.Daemon) error {
count, err := d.Store.CountBySource("external-blocklist")
if err != nil {
return err
}
fmt.Println("Externe Blocklist")
fmt.Printf("Aktiv: %v\n", d.Config.ExternalBlocklistEnabled)
fmt.Printf("Intervall: %ds\n", d.Config.ExternalBlocklistInterval)
fmt.Printf("Auto-Unban: %v\n", d.Config.ExternalBlocklistAutoUnban)
fmt.Printf("Cache: %s\n", d.Config.ExternalBlocklistCacheDir)
fmt.Printf("Aktive Sperren: %d\n", count)
for i, u := range d.Config.ExternalBlocklistURLs {
fmt.Printf(" [%d] %s\n", i, u)
}
return nil
}
func whitelistStatus(d *daemon.Daemon) error {
wl, err := d.Store.AllWhitelist()
if err != nil {
return err
}
fmt.Println("Externe Whitelist")
fmt.Printf("Aktiv: %v\n", d.Config.ExternalWhitelistEnabled)
fmt.Printf("Intervall: %ds\n", d.Config.ExternalWhitelistInterval)
fmt.Printf("Cache: %s\n", d.Config.ExternalWhitelistCacheDir)
fmt.Printf("Aufgelöste IPs: %d\n", len(wl))
for i, u := range d.Config.ExternalWhitelistURLs {
fmt.Printf(" [%d] %s\n", i, u)
}
return nil
}
func geoipStatus(d *daemon.Daemon) error {
fmt.Println("GeoIP Status")
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)
fmt.Printf("MMDB: %s\n", empty(d.Config.GeoIPMMDBPath, "<auto/falls License-Key gesetzt>"))
bans, err := d.Store.BansByReason("geoip")
if err != nil {
return err
}
fmt.Printf("Aktive GeoIP-Sperren: %d\n", len(bans))
return nil
}
func flushSource(ctx context.Context, d *daemon.Daemon, source string) error {
bans, err := d.Store.BansBySource(source)
if err != nil {
return err
}
for _, b := range bans {
_ = d.UnbanQuiet(ctx, b.IP, source+"-flush")
}
d.NotifyBulkUnban(ctx, source+"-flush", len(bans))
fmt.Printf("%d Sperren aufgehoben\n", len(bans))
return nil
}
func writePID(path string) error {
if path == "" {
return nil
}
return os.WriteFile(path, []byte(strconv.Itoa(os.Getpid())+"\n"), 0644)
}
func commandContext(d *daemon.Daemon, notifyServiceStop 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())
}
cancel()
case <-done:
}
}()
return ctx, func() {
close(done)
signal.Stop(signals)
cancel()
}
}
func stopDaemon(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("Daemon läuft nicht oder PID-Datei fehlt: %w", err)
}
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil {
return err
}
proc, err := os.FindProcess(pid)
if err != nil {
return err
}
if err := proc.Signal(syscall.SIGTERM); err != nil {
return err
}
fmt.Printf("Daemon gestoppt (PID %d)\n", pid)
return nil
}
func firewallStatus(ctx context.Context, d *daemon.Daemon) error {
fmt.Printf("Firewall Backend: %s\n", d.Config.FirewallBackend)
fmt.Printf("Firewall Modus: %s\n", d.Config.FirewallMode)
fmt.Printf("Chain: %s\n", d.Config.Chain)
for _, cmd := range [][]string{
{"ipset", "list", "adguard_shield_v4"},
{"ipset", "list", "adguard_shield_v6"},
{"iptables", "-n", "-L", d.Config.Chain, "--line-numbers", "-v"},
{"ip6tables", "-n", "-L", d.Config.Chain, "--line-numbers", "-v"},
} {
out, err := exec.CommandContext(ctx, cmd[0], cmd[1:]...).CombinedOutput()
fmt.Printf("\n--- %s ---\n", cmd[0])
if err != nil {
fmt.Printf("%v\n", err)
}
fmt.Print(string(out))
}
return nil
}
func restoreFirewallRules(ctx context.Context, d *daemon.Daemon) error {
files := []struct {
path string
cmd string
}{
{filepath.Join(d.Config.StateDir, "iptables-rules.v4"), "iptables-restore"},
{filepath.Join(d.Config.StateDir, "iptables-rules.v6"), "ip6tables-restore"},
}
for _, f := range files {
data, err := os.ReadFile(f.path)
if err != nil {
continue
}
c := exec.CommandContext(ctx, f.cmd)
stdin, err := c.StdinPipe()
if err != nil {
return err
}
if err := c.Start(); err != nil {
return err
}
_, _ = stdin.Write(data)
_ = stdin.Close()
if err := c.Wait(); err != nil {
return err
}
}
return nil
}
func empty(s, fallback string) string {
if s == "" {
return fallback
}
return s
}
func usage() {
fmt.Println(`AdGuard Shield Daemon
Nutzung:
adguard-shield version
adguard-shield install [--config-source PATH] [--skip-deps]
adguard-shield update [--config-source PATH] [--skip-deps]
adguard-shield uninstall [--keep-config]
adguard-shield install-status
adguard-shield [-config PATH] run|start|stop|dry-run
adguard-shield status|history [N]|test|flush|ban IP|unban IP|reset-offenses [IP]
adguard-shield live [--interval N] [--top N] [--recent N] [--logs LEVEL] [--once]
adguard-shield logs [--level LEVEL] [--limit N]|logs-follow [--level LEVEL]
adguard-shield offense-status|offense-cleanup
adguard-shield geoip-status|geoip-sync|geoip-flush|geoip-flush-cache|geoip-lookup IP
adguard-shield blocklist-status|blocklist-sync|blocklist-flush
adguard-shield whitelist-status|whitelist-sync|whitelist-flush
adguard-shield report-status|report-generate [html|txt] [OUTPUT]|report-send|report-test|report-install|report-remove
adguard-shield firewall-create|firewall-remove|firewall-flush|firewall-status|firewall-save|firewall-restore`)
}
func liveCommand(ctx context.Context, d *daemon.Daemon, args []string) error {
fs := flag.NewFlagSet("live", flag.ContinueOnError)
interval := fs.Int("interval", d.Config.CheckInterval, "Aktualisierungsintervall in Sekunden")
top := fs.Int("top", 10, "Anzahl Top-Einträge")
recent := fs.Int("recent", 12, "Anzahl letzter Queries und Logs")
logLevel := fs.String("logs", "INFO", "Systemlogs ab Level anzeigen: DEBUG, INFO, WARN, ERROR oder off")
once := fs.Bool("once", false, "Nur einen Snapshot anzeigen")
if err := fs.Parse(args); err != nil {
return err
}
err := d.Live(ctx, os.Stdout, daemon.LiveOptions{
Interval: time.Duration(*interval) * time.Second,
Top: *top,
Recent: *recent,
LogLevel: *logLevel,
Once: *once,
})
if errors.Is(err, context.Canceled) {
return nil
}
return err
}
func logsCommand(d *daemon.Daemon, args []string) error {
level, limit, err := parseLogArgs("logs", args, "INFO", 80)
if err != nil {
return err
}
lines := daemon.RecentLogLines(d.Config.LogFile, level, limit)
if len(lines) == 0 {
fmt.Printf("Keine Logeinträge ab Level %s in %s gefunden.\n", strings.ToUpper(level), d.Config.LogFile)
return nil
}
for _, line := range lines {
fmt.Println(line)
}
return nil
}
func logsFollowCommand(ctx context.Context, d *daemon.Daemon, args []string) error {
level, limit, err := parseLogArgs("logs-follow", args, "INFO", 40)
if err != nil {
return err
}
t := time.NewTicker(2 * time.Second)
defer t.Stop()
for {
fmt.Print("\033[H\033[2J")
fmt.Printf("AdGuard Shield Logs | %s | %s ab %s | Strg+C beendet\n", time.Now().Format("2006-01-02 15:04:05"), d.Config.LogFile, strings.ToUpper(level))
fmt.Println(strings.Repeat("=", 92))
lines := daemon.RecentLogLines(d.Config.LogFile, level, limit)
if len(lines) == 0 {
fmt.Println("Keine passenden Logeinträge.")
} else {
for _, line := range lines {
fmt.Println(line)
}
}
select {
case <-ctx.Done():
if errors.Is(ctx.Err(), context.Canceled) {
return nil
}
return ctx.Err()
case <-t.C:
}
}
}
func parseLogArgs(name string, args []string, defaultLevel string, defaultLimit int) (string, int, error) {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
level := fs.String("level", defaultLevel, "Mindestlevel: DEBUG, INFO, WARN, ERROR")
limit := fs.Int("limit", defaultLimit, "Anzahl der letzten Logzeilen")
if err := fs.Parse(args); err != nil {
return "", 0, err
}
rest := fs.Args()
if len(rest) > 0 {
if isLogLevel(rest[0]) {
*level = rest[0]
} else if n, err := strconv.Atoi(rest[0]); err == nil && n > 0 {
*limit = n
}
}
if len(rest) > 1 {
if n, err := strconv.Atoi(rest[1]); err == nil && n > 0 {
*limit = n
}
}
if !isLogLevel(*level) {
return "", 0, fmt.Errorf("ungültiges Log-Level %q (erlaubt: DEBUG, INFO, WARN, ERROR)", *level)
}
if *limit <= 0 {
*limit = defaultLimit
}
return strings.ToUpper(*level), *limit, nil
}
func isLogLevel(s string) bool {
switch strings.ToUpper(strings.TrimSpace(s)) {
case "DEBUG", "INFO", "WARN", "WARNING", "ERROR", "ERR":
return true
default:
return false
}
}
func installCommand(args []string) error {
opts, err := parseInstallFlags("install", args)
if err != nil {
return err
}
err = installer.Install(opts)
if le, ok := installer.IsLegacyError(err); ok {
return fmt.Errorf("%s", installer.FormatLegacyMessage(le, opts.InstallDir))
}
if err != nil {
return err
}
fmt.Println("AdGuard Shield Go-Installation abgeschlossen.")
fmt.Println(installer.PrintStatus(installer.GetStatus(opts.InstallDir)))
return nil
}
func updateCommand(args []string) error {
opts, err := parseInstallFlags("update", args)
if err != nil {
return err
}
err = installer.Update(opts)
if le, ok := installer.IsLegacyError(err); ok {
return fmt.Errorf("%s", installer.FormatLegacyMessage(le, opts.InstallDir))
}
if err != nil {
return err
}
fmt.Println("AdGuard Shield Go-Update abgeschlossen.")
fmt.Println(installer.PrintStatus(installer.GetStatus(opts.InstallDir)))
return nil
}
func uninstallCommand(args []string) error {
fs := flag.NewFlagSet("uninstall", flag.ContinueOnError)
opts := installer.DefaultOptions()
fs.StringVar(&opts.InstallDir, "install-dir", opts.InstallDir, "Installationsverzeichnis")
fs.BoolVar(&opts.KeepConfig, "keep-config", false, "Konfiguration behalten")
if err := fs.Parse(args); err != nil {
return err
}
if err := installer.Uninstall(opts); err != nil {
return err
}
fmt.Println("AdGuard Shield wurde deinstalliert.")
return nil
}
func installStatusCommand(args []string) error {
fs := flag.NewFlagSet("install-status", flag.ContinueOnError)
installDir := installer.DefaultInstallDir
fs.StringVar(&installDir, "install-dir", installDir, "Installationsverzeichnis")
if err := fs.Parse(args); err != nil {
return err
}
fmt.Print(installer.PrintStatus(installer.GetStatus(installDir)))
return nil
}
func parseInstallFlags(name string, args []string) (installer.Options, error) {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
opts := installer.DefaultOptions()
fs.StringVar(&opts.InstallDir, "install-dir", opts.InstallDir, "Installationsverzeichnis")
fs.StringVar(&opts.ConfigSource, "config-source", "", "Konfiguration fuer Neuinstallation uebernehmen")
fs.BoolVar(&opts.SkipDeps, "skip-deps", false, "Paketpruefung ueberspringen")
noEnable := fs.Bool("no-enable", false, "systemd Autostart nicht aktivieren")
if err := fs.Parse(args); err != nil {
return opts, err
}
opts.Enable = !*noEnable
return opts, nil
}

View File

@@ -1,15 +1,82 @@
# Dokumentation
Hier findest du die vollständige Dokumentation zu AdGuard Shield.
Willkommen in der Dokumentation von AdGuard Shield.
## Inhaltsverzeichnis
AdGuard Shield ist ein Go-Daemon, der das Query Log von AdGuard Home auswertet, auffällige DNS-Clients erkennt und diese über eine eigene Firewall-Struktur sperrt. Die Dokumentation ist bewusst ausführlich gehalten: Sie soll nicht nur Befehle auflisten, sondern erklären, was im Hintergrund passiert, welche Werte sinnvoll sind und wie du Fehler sauber eingrenzt.
| Dokument | Beschreibung |
## Schnellnavigation
| Dokument | Wofür es gedacht ist |
|---|---|
| [Architektur & Funktionsweise](architektur.md) | Überblick über den Systemaufbau, Datenfluss und die internen Komponenten |
| [Befehle & Nutzung](befehle.md) | Alle verfügbaren Befehle des Installers, des Hauptskripts und des Watchdogs |
| [Konfiguration](konfiguration.md) | Beschreibung aller Konfigurationsparameter in `adguard-shield.conf` |
| [Webhook-Benachrichtigungen](benachrichtigungen.md) | Einrichtung von Push-Benachrichtigungen über Telegram, Discord, Gotify u.a. |
| [E-Mail Report](report.md) | Konfiguration des automatischen Statistik-Reports per E-Mail |
| [Update-Anleitung](update.md) | Schritt-für-Schritt-Anleitung zum Aktualisieren einer bestehenden Installation |
| [Tipps & Troubleshooting](tipps-und-troubleshooting.md) | Best Practices, häufige Probleme und deren Lösungen |
| [Architektur & Funktionsweise](architektur.md) | Erklärt den Aufbau, den Datenfluss, Firewall-Modell, SQLite-Schema, Hintergrundjobs und Sperrlogik |
| [Befehle & Nutzung](befehle.md) | Vollständige CLI-Referenz mit Beispielen und typischen Betriebsabläufen |
| [Konfiguration](konfiguration.md) | Alle Parameter aus `adguard-shield.conf` mit Beispielen, Empfehlungen und Beispielkonfigurationen |
| [Docker-Installationen](docker.md) | Firewall-Modi für klassische Installation, Docker Host Network und veröffentlichte Docker-Ports |
| [Benachrichtigungen](benachrichtigungen.md) | Einrichtung von Ntfy, Discord, Slack, Gotify und Generic Webhooks mit Beispielinhalten |
| [E-Mail Report](report.md) | Report-Inhalte, Formate, Mailversand, Cron-Job und manuelle Tests |
| [Update-Anleitung](update.md) | Update der Go-Version, Konfigurationsmigration und Migration von alten Shell-Installationen |
| [Tipps & Troubleshooting](tipps-und-troubleshooting.md) | Diagnosewege für API, Firewall, GeoIP, Reports, externe Listen und falsch gesetzte Sperren |
## Das Binary
Die Go-Version bündelt alle Aufgaben in einem einzelnen Binary:
```text
/opt/adguard-shield/adguard-shield
```
Dieses Binary ist gleichzeitig:
- **Daemon** für den produktiven Betrieb (Querylog-Polling, Erkennung, Sperren)
- **CLI** für Status, History, Logs, Firewall, Listen, GeoIP und Reports
- **Installer**, **Updater** und **Uninstaller**
- **Report-Generator** für HTML- und Text-Reports
- **Hintergrundprozess** für externe Whitelist, externe Blocklist, GeoIP und Offense-Cleanup
Die meisten Befehle beginnen daher mit:
```bash
sudo /opt/adguard-shield/adguard-shield <befehl>
```
Für Installation oder Update nutzt du das neue Binary aus dem Repository, Release oder Build-Verzeichnis:
```bash
sudo ./adguard-shield install
sudo ./adguard-shield update
```
## Empfohlener Lesefluss
### Neueinrichtung
1. Lies zuerst [Architektur & Funktionsweise](architektur.md), damit klar ist, was genau gesperrt wird und wie der Datenfluss aussieht.
2. Passe danach [Konfiguration](konfiguration.md) an, besonders API-Zugang, Whitelist und Rate-Limits.
3. Nutze [Befehle & Nutzung](befehle.md) für Installation, Dry-Run und Service-Start.
4. Richte optional [Benachrichtigungen](benachrichtigungen.md), [Reports](report.md), GeoIP oder externe Listen ein.
5. Bei Problemen hilft [Tipps & Troubleshooting](tipps-und-troubleshooting.md).
### Migration von der Shell-Version
Wenn du von der alten Shell-Version kommst, beginne mit [Update-Anleitung](update.md). Dort findest du den empfohlenen Migrationsablauf und Hinweise zu den erkannten Legacy-Artefakten.
### Docker-Setups
Wenn AdGuard Home in Docker läuft, lies [Docker-Installationen](docker.md) zusätzlich zur Grundkonfiguration. Der Firewall-Modus bestimmt, in welcher Chain die Sperren greifen.
## Wichtigster Unterschied zur alten Shell-Version
Die frühere Version bestand aus mehreren Shell-Skripten, Hilfs-Workern, Cron-Jobs und einem separaten Watchdog:
| Alte Shell-Version | Go-Version |
|---|---|
| `adguard-shield.sh` (Hauptskript) | Ein Binary für alles |
| `iptables-helper.sh` | Integriert im Binary |
| `external-blocklist-worker.sh` | Goroutine im Daemon |
| `external-whitelist-worker.sh` | Goroutine im Daemon |
| `geoip-worker.sh` | Goroutine im Daemon |
| `offense-cleanup-worker.sh` | Goroutine im Daemon |
| `report-generator.sh` | Integriert im Binary |
| `unban-expired.sh` | Integriert im Daemon |
| Watchdog-Service + Timer | `Restart=on-failure` in systemd |
| Mehrere Cron-Jobs | Ein optionaler Cron-Job für Reports |

View File

@@ -1,237 +1,478 @@
# Architektur & Funktionsweise
## Überblick
Dieses Dokument erklärt, wie AdGuard Shield intern arbeitet. Es geht dabei nicht nur um die Dateien auf dem System, sondern auch um den Weg einer DNS-Anfrage vom AdGuard-Home-Querylog bis zur Firewall-Sperre und die Logik hinter jeder Erkennungsmethode.
```
┌─────────────────────┐
│ Client Anfragen │
│ (DNS/DoH/DoT/DoQ) │
└──────────┬──────────┘
┌─────────────────────┐ ┌──────────────────────┐
│ AdGuard Home │────▶ │ Query Log (API) │
│ DNS Server │ └──────────┬───────────┘
└─────────────────────┘ │
┌──────────────────────┐
│ adguard-shield.sh │
│ (Monitor Script) │
└──────────┬───────────┘
┌──────────────┼──────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ iptables │ │ Log │ │ Webhook │
│ DROP │ │ Datei │ │ Notify │
└──────────┘ └──────────┘ └──────────┘
## Kurzüberblick
AdGuard Shield besteht in der Go-Version aus einem einzelnen Binary:
```text
/opt/adguard-shield/adguard-shield
```
## Ablauf einer Sperre
Das Binary übernimmt alle Aufgaben, die früher auf mehrere Shell-Skripte verteilt waren:
- Querylog-Polling über die AdGuard-Home-API
- Erkennung von Rate-Limit-Verstößen
- Erkennung von Random-Subdomain-Floods
- DNS-Flood-Watchlist mit sofortigem Permanent-Ban
- Verwaltung aktiver Sperren in SQLite
- Firewall-Steuerung über `ipset`, `iptables` und `ip6tables`
- automatische Freigabe abgelaufener temporärer Sperren
- externe Blocklisten und externe Whitelists
- GeoIP-Länderfilter
- progressive Sperren für Wiederholungstäter
- Benachrichtigungen und AbuseIPDB-Reporting
- E-Mail-Reports
## Datenfluss
```text
Clients
│ DNS, DoH, DoT, DoQ, DNSCrypt
AdGuard Home
│ /control/querylog
AdGuard Shield Go-Daemon
├── Rate-Limit-Prüfung pro Client + Domain
├── Subdomain-Flood-Prüfung pro Client + Basisdomain
├── Watchlist-Prüfung
├── Whitelist-Prüfung (statisch + extern)
├── GeoIP-Prüfung
├── Progressive Ban-Berechnung
├── externe Listen-Abgleich
SQLite State
ipset + iptables/ip6tables
DNS-relevante Ports werden für gesperrte Clients blockiert
```
**Wichtig:** AdGuard Shield analysiert nicht den Netzwerkverkehr direkt. Es liest das Querylog von AdGuard Home. Dadurch erkennt es auch Anfragen über verschlüsselte DNS-Protokolle (DoH, DoT, DoQ, DNSCrypt), solange diese in AdGuard Home sichtbar sind.
## Laufzeit im produktiven Betrieb
Der systemd-Service startet den Daemon so:
```bash
/opt/adguard-shield/adguard-shield -config /opt/adguard-shield/adguard-shield.conf run
```
Beim Start passiert in dieser Reihenfolge:
| Schritt | Aktion | Beschreibung |
|---:|---|---|
| 1 | Konfiguration laden | Liest `adguard-shield.conf` und validiert alle Parameter |
| 2 | SQLite-Datenbank öffnen | Öffnet oder erstellt die Datenbank unter `STATE_DIR` im WAL-Modus |
| 3 | Logdatei öffnen | Initialisiert die Datei unter `LOG_FILE` |
| 4 | Firewall vorbereiten | Erstellt Chain und ipsets, falls nicht vorhanden |
| 5 | GeoIP öffnen | Lädt die MaxMind-Datenbank, falls GeoIP aktiviert ist |
| 6 | Whitelist-Cache laden | Liest aufgelöste externe Whitelist-IPs aus SQLite |
| 7 | GeoIP-Reconcile | Prüft bestehende GeoIP-Sperren gegen aktuelle Konfiguration |
| 8 | Firewall-Reconcile | Überträgt aktive Sperren aus SQLite wieder in die Firewall |
| 9 | Hintergrundjobs starten | Startet Goroutines für externe Listen und Offense-Cleanup |
| 10 | Querylog-Poller starten | Beginnt mit der regelmäßigen Auswertung des Querylogs |
Das Reconcile beim Start ist besonders wichtig: Wenn der Server neu startet oder `iptables`-Regeln verloren gehen (z.B. durch einen Reboot), bleiben die Sperren in SQLite erhalten und werden beim nächsten Start wieder in die Firewall übertragen.
## Querylog-Poller
Der Daemon ruft regelmäßig den AdGuard-Home-Endpunkt ab:
```text
/control/querylog?limit=<API_QUERY_LIMIT>&response_status=all
```
Gesteuert wird das über:
```bash
CHECK_INTERVAL=10 # Abstand zwischen Abfragen in Sekunden
API_QUERY_LIMIT=500 # Maximale Einträge pro API-Abfrage
```
Aus jedem Querylog-Eintrag werden diese Informationen extrahiert:
| Feld | Verwendung |
|---|---|
| Zeitstempel | Bestimmt, ob die Anfrage im aktuellen Zeitfenster liegt |
| Client-IP | Schlüssel für Rate-Limit, Whitelist, GeoIP und Firewall |
| Domain | Schlüssel für Rate-Limit und Subdomain-Flood |
| `client_proto` | Anzeige von DNS, DoH, DoT, DoQ oder DNSCrypt |
Bereits gesehene Querylog-Einträge werden im Speicher dedupliziert. Der Daemon hält nur Ereignisse aus dem relevanten Zeitfenster plus kleinem Puffer vor, sodass der Speicherverbrauch auch bei hohem DNS-Aufkommen stabil bleibt.
## Erkennungsmethoden im Detail
### Rate-Limit-Sperre
1. Client `192.168.1.50` fragt `microsoft.com` 45x in 60 Sekunden an
2. Monitor fragt die AdGuard Home API alle 10 Sekunden ab (`/control/querylog`)
3. Die Anfragen werden pro Client+Domain-Kombination gezählt
4. Monitor erkennt: 45 > 30 (Limit überschritten)
5. Prüfung: Ist der Client auf der Whitelist? → Nein
6. **Progressive Sperren:** Offense-Level wird geprüft/erhöht, Sperrdauer berechnet
7. iptables-Regel wird erstellt: `DROP` für `192.168.1.50` auf allen DNS-Ports
8. State-Datei wird angelegt: `/var/lib/adguard-shield/192.168.1.50.ban`
9. Offense-Datei wird aktualisiert: `/var/lib/adguard-shield/192.168.1.50.offenses`
10. Ban-History Eintrag wird in `/var/log/adguard-shield-bans.log` geschrieben
11. Log-Eintrag + optionale Webhook-Benachrichtigung
12. Nach Ablauf der (progressiven) Sperrdauer: automatische Entsperrung + History-Eintrag
Eine Rate-Limit-Sperre entsteht, wenn ein Client dieselbe Domain innerhalb des konfigurierten Fensters zu oft abfragt.
### Subdomain-Flood-Sperre (Random Subdomain Attack)
**Konfiguration:**
1. Client `10.0.0.99` fragt `abc123.microsoft.com`, `xyz456.microsoft.com`, ... ab
2. Monitor extrahiert die **Basisdomain** (`microsoft.com`) aus jeder Anfrage
3. Pro Client wird gezählt, wie viele **eindeutige Subdomains** einer Basisdomain im Zeitfenster abgefragt wurden
4. Monitor erkennt: 63 eindeutige Subdomains > 50 (Schwellwert überschritten)
5. Prüfung: Ist der Client auf der Whitelist? → Nein
6. Sperre wird ausgeführt mit Domain `*.microsoft.com` und Grund `subdomain-flood`
7. Progressive Sperren greifen auch hier — Wiederholungstäter werden stufenweise länger gesperrt
> **Hinweis:** Die Subdomain-Flood-Erkennung hat ein eigenes Zeitfenster (`SUBDOMAIN_FLOOD_WINDOW`) und einen eigenen Schwellwert (`SUBDOMAIN_FLOOD_MAX_UNIQUE`), unabhängig von den Rate-Limit-Einstellungen.
### DNS-Flood-Watchlist-Sperre
1. Client `10.0.0.42` fragt `microsoft.com` 35x in 60 Sekunden an
2. Monitor erkennt: 35 > 30 (Limit überschritten)
3. Domain `microsoft.com` steht auf der DNS-Flood-Watchlist → **sofortige permanente Sperre**
4. Progressive-Ban-Stufe wird ignoriert — kein stufenweises Hochstufen
5. IP wird an AbuseIPDB gemeldet (falls aktiviert)
6. Permanente Sperre bleibt bis zur manuellen Freigabe aktiv
> **Hinweis:** Die Watchlist greift sowohl bei normalen Rate-Limit-Verstößen als auch bei Subdomain-Flood-Erkennungen. Subdomains werden automatisch erkannt: `foo.microsoft.com` matcht den Watchlist-Eintrag `microsoft.com`.
## iptables Strategie
Das Tool erstellt eine eigene Chain `ADGUARD_SHIELD`:
```
INPUT Chain
├── ... (bestehende Regeln bleiben unberührt)
├── -p tcp --dport 53 → ADGUARD_SHIELD
├── -p udp --dport 53 → ADGUARD_SHIELD
├── -p tcp --dport 443 → ADGUARD_SHIELD
├── -p udp --dport 443 → ADGUARD_SHIELD
├── -p tcp --dport 853 → ADGUARD_SHIELD
├── -p udp --dport 853 → ADGUARD_SHIELD
└── ...
ADGUARD_SHIELD Chain
├── -s 192.168.1.50 → DROP (gesperrter Client)
├── -s 10.0.0.25 → DROP (gesperrter Client)
└── RETURN (alle anderen passieren)
```bash
RATE_LIMIT_MAX_REQUESTS=30 # Maximale Anfragen pro Client und Domain
RATE_LIMIT_WINDOW=60 # Zeitfenster in Sekunden
```
**Vorteile der eigenen Chain:**
- Greift nicht in bestehende Firewall-Regeln ein
- Kann komplett geflusht werden ohne andere Regeln zu beeinflussen
- Einfaches Debugging per `iptables -L ADGUARD_SHIELD`
**Ablauf am Beispiel:**
## State-Management
1. Client `192.168.1.50` fragt `example.com` 45-mal innerhalb von 60 Sekunden ab.
2. Der Poller sieht diese Einträge im Querylog.
3. AdGuard Shield zählt pro Client und Domain.
4. `45 > 30`, also ist das Limit überschritten.
5. Die IP wird gegen statische und externe Whitelists geprüft.
6. Falls die Domain nicht auf der Watchlist steht, entsteht eine normale `rate-limit`-Sperre.
7. Bei aktivem Progressive-Ban wird die aktuelle Offense-Stufe berechnet.
8. Die IP wird in SQLite gespeichert und per Firewall blockiert.
9. History, Log und optionale Benachrichtigung werden geschrieben.
Jede aktive Sperre wird als Datei gespeichert:
### Subdomain-Flood-Erkennung
```
/var/lib/adguard-shield/192.168.1.50.ban
Random-Subdomain-Floods sehen anders aus als normale Wiederholungen. Ein Client fragt nicht eine Domain ständig ab, sondern erzeugt viele verschiedene, oft zufällige Subdomains:
```text
a8f3.example.com
k29x.example.com
z9p1.example.com
m7q2.example.com
```
Inhalt:
```
CLIENT_IP=192.168.1.50
DOMAIN=microsoft.com
COUNT=45
BAN_TIME=2026-03-03 14:30:00
BAN_UNTIL_EPOCH=1741012200
BAN_UNTIL=2026-03-03 15:30:00
BAN_DURATION=3600
OFFENSE_LEVEL=1
IS_PERMANENT=false
REASON=rate-limit
AdGuard Shield extrahiert daraus die Basisdomain `example.com` und zählt pro Client, wie viele **unterschiedliche** Subdomains im Fenster vorkommen. Direkte Anfragen an `example.com` selbst werden bei dieser Erkennung nicht mitgezählt.
**Konfiguration:**
```bash
SUBDOMAIN_FLOOD_ENABLED=true
SUBDOMAIN_FLOOD_MAX_UNIQUE=50 # Maximale eindeutige Subdomains
SUBDOMAIN_FLOOD_WINDOW=60 # Zeitfenster in Sekunden
```
Zusätzlich wird für jede IP ein Offense-Tracker gespeichert:
**Ablauf am Beispiel:**
```
/var/lib/adguard-shield/192.168.1.50.offenses
1. Client `10.0.0.99` fragt 63 verschiedene Subdomains von `example.com` ab.
2. Sobald mehr als `SUBDOMAIN_FLOOD_MAX_UNIQUE` eindeutige Subdomains erkannt werden, wird gesperrt.
3. In der History erscheint die Domain als `*.example.com`.
4. Der Grund lautet `subdomain-flood`, außer die Basisdomain steht auf der DNS-Flood-Watchlist.
**Hinweise:**
- Multi-Part-TLDs wie `.co.uk` werden korrekt als Basisdomain erkannt.
- CDNs und manche Apps erzeugen legitim viele Subdomains. In solchen Fällen den Grenzwert erhöhen oder den Client whitelisten.
### DNS-Flood-Watchlist
Die Watchlist ist für Domains gedacht, bei denen du nicht stufenweise reagieren möchtest. Wenn eine Domain auf der Watchlist steht und gleichzeitig ein Rate-Limit- oder Subdomain-Flood-Verstoß erkannt wird, wird sofort permanent gesperrt.
**Konfiguration:**
```bash
DNS_FLOOD_WATCHLIST_ENABLED=true
DNS_FLOOD_WATCHLIST="microsoft.com,google.com"
```
Inhalt:
```
CLIENT_IP=192.168.1.50
OFFENSE_LEVEL=2
LAST_OFFENSE_EPOCH=1741008600
LAST_OFFENSE=2026-03-03 14:30:00
FIRST_OFFENSE=2026-03-03 12:15:00
**Matching-Logik:**
| Anfrage | Watchlist-Eintrag | Treffer? |
|---|---|---|
| `microsoft.com` | `microsoft.com` | Ja |
| `login.microsoft.com` | `microsoft.com` | Ja |
| `evil-microsoft.com` | `microsoft.com` | Nein |
**Bei einem Treffer:**
- Reason wird `dns-flood-watchlist`
- Sperre ist immer permanent
- Progressive-Ban-Stufen werden für die Dauer ignoriert
- AbuseIPDB-Reporting wird ausgelöst, wenn aktiviert und ein API-Key vorhanden ist
### Progressive Sperren
Progressive Sperren erhöhen die Sperrdauer bei wiederholten Verstößen. Das Verhalten ähnelt fail2ban.
**Konfiguration:**
```bash
BAN_DURATION=3600 # Basis-Sperrdauer: 1 Stunde
PROGRESSIVE_BAN_ENABLED=true
PROGRESSIVE_BAN_MULTIPLIER=2 # Verdopplung pro Stufe
PROGRESSIVE_BAN_MAX_LEVEL=5 # Ab Stufe 5 permanent
PROGRESSIVE_BAN_RESET_AFTER=86400 # Zähler-Reset nach 24h ohne Vergehen
```
Das ermöglicht:
- Persistenz über Script-Neustarts hinweg
- Statusabfragen jederzeit möglich
- Automatisches Aufräumen per Cron-Job
- Progressive Sperrzeiten über mehrere Ban-Zyklen hinweg
**Stufenverlauf mit Standardwerten:**
## Dateistruktur nach Installation
| Vergehen | Stufe | Berechnung | Sperrdauer |
|---:|---:|---|---|
| 1 | 1 | 3600 × 2⁰ | 1 Stunde |
| 2 | 2 | 3600 × 2¹ | 2 Stunden |
| 3 | 3 | 3600 × 2² | 4 Stunden |
| 4 | 4 | 3600 × 2³ | 8 Stunden |
| 5 | 5 | Max-Level erreicht | Permanent |
Der Offense-Zähler wird in SQLite gespeichert. Wenn eine IP länger als `PROGRESSIVE_BAN_RESET_AFTER` (Standard: 24 Stunden) nicht auffällig war, wird der Zähler vom Cleanup-Job entfernt.
**Geltungsbereich:** Progressive Sperren gelten nur für Monitor-Sperren (`rate-limit`, `subdomain-flood`). Watchlist-Treffer sind sofort permanent. GeoIP- und externe Blocklist-Sperren haben eigene Regeln.
## Firewall-Modell
AdGuard Shield nutzt eine eigene Chain und zwei ipsets, um gesperrte IPs effizient zu verwalten:
```text
Chain: ADGUARD_SHIELD
IPv4: adguard_shield_v4
IPv6: adguard_shield_v6
```
### Chain-Einbindung
Die Chain wird je nach `FIREWALL_MODE` in die passende Host-Chain eingehängt:
| Modus | Parent-Chain | Einsatzgebiet |
|---|---|---|
| `host` / `docker-host` | `INPUT` | Klassische Installation oder Docker mit Host-Netzwerk |
| `docker-bridge` | `DOCKER-USER` | Docker mit veröffentlichten Ports (`-p 53:53`) |
| `hybrid` | `INPUT` und `DOCKER-USER` | Gemischte Setups oder Migrationsphasen |
### Regelstruktur im Host-Modus
```text
INPUT
├── tcp/53 → ADGUARD_SHIELD
├── udp/53 → ADGUARD_SHIELD
├── tcp/443 → ADGUARD_SHIELD
├── udp/443 → ADGUARD_SHIELD
├── tcp/853 → ADGUARD_SHIELD
└── udp/853 → ADGUARD_SHIELD
ADGUARD_SHIELD
├── src in adguard_shield_v4 → DROP
└── src in adguard_shield_v6 → DROP
```
Bei Docker Bridge mit veröffentlichten Ports ersetzt `DOCKER-USER` die `INPUT`-Chain. Docker leitet solche Pakete nach DNAT über `FORWARD`; die `INPUT`-Chain sieht sie dort nicht zuverlässig.
### Blockierte Ports
Die Ports werden über `BLOCKED_PORTS` konfiguriert:
```bash
BLOCKED_PORTS="53 443 853"
```
| Port | Protokoll | Zweck |
|---:|---|---|
| 53 | UDP/TCP | Klassisches DNS |
| 443 | TCP | DNS-over-HTTPS (DoH) |
| 853 | TCP/UDP | DNS-over-TLS (DoT) und DNS-over-QUIC (DoQ) |
Die Erkennung basiert auf dem AdGuard-Home-Querylog, die Sperre blockiert aber alle konfigurierten Ports, unabhängig davon, welches Protokoll den Verstoß ausgelöst hat.
### Warum ipset?
- Viele gesperrte IPs erzeugen nicht tausende einzelne `iptables`-Regeln
- IPv4 und IPv6 werden getrennt sauber verwaltet
- Sperren und Freigaben sind performant, auch bei hunderten IPs
- Die eigene Chain bleibt übersichtlich und beeinträchtigt bestehende Regeln nicht
## SQLite-State
Der zentrale Zustand liegt standardmäßig hier:
```text
/var/lib/adguard-shield/adguard-shield.db
```
### Tabellen
| 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 |
| `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 |
Die Datenbank nutzt WAL-Modus (Write-Ahead Logging) und einen Busy-Timeout, damit Daemon und CLI-Befehle gleichzeitig lesen und schreiben können, ohne sich gegenseitig zu blockieren.
### History-Aktionen
| Aktion | Bedeutung |
|---|---|
| `BAN` | Aktive Sperre gesetzt (Firewall-Regel erstellt) |
| `UNBAN` | Sperre aufgehoben (manuell, abgelaufen oder durch Whitelist) |
| `DRY` | Sperre wäre gesetzt worden, wurde aber im Dry-Run nur protokolliert |
## Verzeichnisstruktur
Nach einer Standardinstallation sieht die Struktur so aus:
```text
/opt/adguard-shield/
├── adguard-shield.sh # Haupt-Monitor-Script
├── adguard-shield.conf # Konfiguration (chmod 600)
├── adguard-shield.conf.old # Backup der Konfig nach Update
── adguard-shield-watchdog.sh # Watchdog Health-Check-Script
├── iptables-helper.sh # iptables Verwaltung
├── external-blocklist-worker.sh # Externer Blocklist-Worker
├── external-whitelist-worker.sh # Externer Whitelist-Worker (DNS-Auflösung)
├── geoip-worker.sh # GeoIP-Länderfilter-Worker
├── offense-cleanup-worker.sh # Aufräumen abgelaufener Offense-Zähler (nice 19, idle I/O)
├── unban-expired.sh # Cron-basiertes Entsperren
└── geoip/ # Auto-Download MaxMind GeoLite2 DB (optional)
├── adguard-shield # Go-Binary
├── adguard-shield.conf # Konfiguration, chmod 600
├── adguard-shield.conf.old # Backup nach Konfigurationsmigration
── geoip/ # automatische MaxMind-Downloads
/etc/systemd/system/
── adguard-shield.service # systemd Service (Autostart aktiv)
├── adguard-shield-watchdog.service # systemd Watchdog-Unit (oneshot)
└── adguard-shield-watchdog.timer # systemd Timer (alle 5 Min.)
── adguard-shield.service
/var/lib/adguard-shield/
├── *.ban # State-Dateien aktiver Sperren
├── *.offenses # Offense-Zähler (Progressive Sperren)
├── external-blocklist/ # Cache für externe Blocklisten
├── external-whitelist/ # Cache für externe Whitelisten + aufgelöste IPs
── geoip-cache/ # Cache für GeoIP-Lookups (24h)
├── adguard-shield.db # SQLite State-Datenbank
├── adguard-shield.db-wal # WAL-Datei (im laufenden Betrieb)
├── adguard-shield.db-shm # Shared-Memory-Datei (im laufenden Betrieb)
├── external-blocklist/ # Cache für heruntergeladene Blocklisten
── external-whitelist/ # Cache für heruntergeladene Whitelists
├── iptables-rules.v4 # Gesicherte IPv4-Regeln (nach firewall-save)
└── iptables-rules.v6 # Gesicherte IPv6-Regeln (nach firewall-save)
/var/log/
── adguard-shield.log # Laufzeit-Log
└── adguard-shield-bans.log # Ban-History (alle Sperren/Entsperrungen)
── adguard-shield.log # Daemon-Logdatei
/etc/cron.d/
└── adguard-shield-report # Cron-Job für Reports (optional)
```
## Installer-Architektur
## Hintergrundjobs im Daemon
Der Installer (`install.sh`) bietet ein interaktives Menü und folgende Funktionen:
Es gibt in der Go-Version keine separaten Worker-Skripte mehr. Diese Aufgaben laufen als Goroutines im Daemon:
| Befehl | Beschreibung |
|--------|--------------|
| `install` | Vollständige Neuinstallation (Abhängigkeiten, Dateien, Konfiguration, Service, Watchdog) |
| `update` | Update mit automatischer Konfigurations-Migration, Watchdog-Aktivierung und Service-Neustart |
| `uninstall` | Deinstallation mit optionalem Behalten der Konfiguration |
| `status` | Installationsstatus, Version und Service-Status anzeigen |
| `--help` | Hilfe und Befehlsübersicht |
| Aufgabe | Wann aktiv | Intervall | Zweck |
|---|---|---|---|
| Querylog-Poller | Immer | `CHECK_INTERVAL` | Liest und analysiert AdGuard-Home-Querylogs |
| 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 |
| GeoIP-Lookups | `GEOIP_ENABLED=true` | Mit jedem Poll | Prüft neue öffentliche Client-IPs gegen Länderregeln |
### Konfigurations-Migration beim Update
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.
```
┌─────────────────────────┐ ┌─────────────────────────┐
│ Bestehende Konfig │ │ Neue Konfig (Repo) │
│ (Benutzer-Settings) │ │ (mit neuen Parametern) │
└───────────┬─────────────┘ └───────────┬─────────────┘
│ │
▼ ▼
┌──────────────────────────────────────────┐
│ Konfigurations-Migration │
│ 1. Backup als .conf.old erstellen │
│ 2. Alle Schlüssel vergleichen │
│ 3. Neue Schlüssel zur Konfig ergänzen │
│ 4. Bestehende Werte NICHT ändern │
└──────────────────────┬───────────────────┘
┌──────────────────────────┐
│ Aktualisierte Konfig │
│ (alte Werte + neue Keys) │
└──────────────────────────┘
```
## Whitelist-Logik
## Ban-History
Vor jeder Sperre wird geprüft, ob die IP vertrauenswürdig ist.
Jede Sperre und Entsperrung wird dauerhaft in der Ban-History protokolliert (`/var/log/adguard-shield-bans.log`). Das ermöglicht eine lückenlose Nachvollziehbarkeit, auch nachdem State-Dateien bereits gelöscht wurden.
**Quellen (in Prüfreihenfolge):**
**Format:**
```
ZEITSTEMPEL | AKTION | CLIENT-IP | DOMAIN | ANFRAGEN | SPERRDAUER | GRUND
2026-03-03 14:30:12 | BAN | 192.168.1.50 | microsoft.com | 45 | 3600s | rate-limit
2026-03-03 15:30:12 | UNBAN | 192.168.1.50 | microsoft.com | - | - | expired
2026-03-03 16:10:33 | UNBAN | 10.0.0.25 | telemetry.example.com | - | - | manual
```
1. Statische `WHITELIST` aus der Konfiguration (kommagetrennt)
2. Aufgelöste IPs aus externen Whitelists (gespeichert in SQLite)
**Mögliche Gründe (GRUND-Spalte):**
| Grund | Bedeutung |
|-------|----------|
| `rate-limit` | Automatische Sperre wegen Limit-Überschreitung |
| `subdomain-flood` | Sperre wegen zu vieler eindeutiger Subdomains einer Basisdomain |
| `dns-flood-watchlist` | Sofortige permanente Sperre + AbuseIPDB-Meldung (Domain auf der Watchlist) |
| `dry-run` | Im Dry-Run erkannt (nicht wirklich gesperrt) |
| `dry-run (subdomain-flood)` | Subdomain-Flood im Dry-Run erkannt |
| `dry-run (dns-flood-watchlist)` | Watchlist-Treffer im Dry-Run erkannt |
| `expired` | Automatisch entsperrt nach Ablauf der Sperrdauer |
| `expired-cron` | Entsperrt durch den Cron-Job (`unban-expired.sh`) |
| `manual` | Manuell entsperrt per `unban`-Befehl |
| `manual-flush` | Entsperrt durch `flush`-Befehl (alle Sperren aufgehoben) |
**Verhalten:**
- Eine gewhitelistete IP wird nie gesperrt, unabhängig von der Sperrquelle.
- Dies gilt für automatische Sperren, manuelle Sperren, GeoIP-Sperren und externe Blocklist-Sperren.
- Wenn eine externe Whitelist später eine bereits gesperrte IP enthält, hebt der Daemon diese Sperre automatisch auf.
## GeoIP-Logik
GeoIP arbeitet mit der MaxMind GeoLite2-Datenbank und filtert DNS-Clients nach ihrem geografischen Herkunftsland.
### Private IPs
Wenn `GEOIP_SKIP_PRIVATE=true` gesetzt ist (Standard), werden folgende Adressbereiche übersprungen:
- Private Netze (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
- Loopback (127.0.0.0/8, ::1)
- Link-Local (169.254.0.0/16, fe80::/10)
- CGNAT (100.64.0.0/10)
### Modi
| Modus | Verhalten |
|---|---|
| `blocklist` | Nur die in `GEOIP_COUNTRIES` genannten Länder werden gesperrt. Alle anderen sind erlaubt. |
| `allowlist` | Nur die in `GEOIP_COUNTRIES` genannten Länder sind erlaubt. Alle anderen öffentlichen IPs werden gesperrt. |
Die Ländercodes folgen dem Standard ISO 3166-1 Alpha-2 (siehe [ISO-3166-1-Kodierliste auf Wikipedia](https://de.wikipedia.org/wiki/ISO-3166-1-Kodierliste)).
### GeoIP-Datenquellen (Priorität)
| Priorität | Quelle | Konfiguration |
|---:|---|---|
| 1 | Manueller MMDB-Pfad | `GEOIP_MMDB_PATH="/pfad/zur/GeoLite2-Country.mmdb"` |
| 2 | Automatischer MaxMind-Download | `GEOIP_LICENSE_KEY="dein_key"` |
| 3 | Legacy-Fallback | `geoiplookup` / `geoiplookup6` (Systembefehle) |
**GeoIP-Sperren sind permanent**, werden aber beim Start gegen die aktuelle Konfiguration geprüft. Wenn GeoIP deaktiviert wird, der Modus wechselt oder ein Land nicht mehr blockiert werden müsste, wird die Sperre automatisch aufgehoben.
## AbuseIPDB-Reporting
AbuseIPDB wird nur für **permanente** Monitor-Sperren genutzt:
| Wird gemeldet | Wird nicht gemeldet |
|---|---|
| DNS-Flood-Watchlist-Treffer | Temporäre Rate-Limit-Sperren |
| Progressive-Ban auf Maximalstufe | Manuelle Sperren |
| | GeoIP-Sperren |
| | Externe Blocklist-Sperren |
**Konfiguration:**
**History anzeigen:**
```bash
sudo /opt/adguard-shield/adguard-shield.sh history # letzte 50
sudo /opt/adguard-shield/adguard-shield.sh history 200 # letzte 200
ABUSEIPDB_ENABLED=true
ABUSEIPDB_API_KEY="..."
ABUSEIPDB_CATEGORIES="4" # 4 = DDoS Attack
```
Die Kategorie-Nummern sind auf [abuseipdb.com/categories](https://www.abuseipdb.com/categories) dokumentiert.
## Protokollerkennung
AdGuard Shield liest das Feld `client_proto` aus der AdGuard-Home-API und zeigt das Protokoll in History, Logs und Benachrichtigungen an:
| API-Wert | Anzeige | Beschreibung |
|---|---|---|
| leer oder `dns` | `DNS` | Klassisches DNS über UDP/TCP |
| `doh` | `DoH` | DNS-over-HTTPS |
| `dot` | `DoT` | DNS-over-TLS |
| `doq` | `DoQ` | DNS-over-QUIC |
| `dnscrypt` | `DNSCrypt` | DNSCrypt-Protokoll |
Die Sperre blockiert die konfigurierten Ports unabhängig davon, welches Protokoll den Verstoß ausgelöst hat. So wird verhindert, dass ein gesperrter Client einfach auf ein anderes DNS-Protokoll ausweicht.
## History und Logs
Es gibt zwei unterschiedliche Blickwinkel auf das Geschehen:
| Quelle | Inhalt | Befehl |
|---|---|---|
| `ban_history` in SQLite | Sperren, Freigaben und Dry-Run-Ereignisse | `history [N]` |
| `LOG_FILE` | Daemon-Ereignisse, Worker-Läufe, Warnungen, Fehler | `logs`, `logs-follow` |
| Live-Ansicht | Aktuelle Queries, Top-Clients, Sperren, Systemereignisse | `live` |
**Wichtig:** Query-Inhalte werden nicht dauerhaft in die Logdatei geschrieben. Für aktuelle Queries ist die Live-Ansicht (`live`) gedacht.
### History-Gründe
| Grund | Bedeutung |
|---|---|
| `rate-limit` | Gleiche Domain zu oft angefragt |
| `subdomain-flood` | Zu viele eindeutige Subdomains einer Basisdomain |
| `dns-flood-watchlist` | Watchlist-Treffer mit sofortigem Permanent-Ban |
| `external-blocklist` | Sperre aus externer Blocklist |
| `geoip` | GeoIP-Länderfilter |
| `manual` | Manueller Ban oder Unban |
| `manual-flush` | Freigabe aller Sperren durch `flush` |
| `expired` | Temporäre Sperre ist abgelaufen |
| `external-whitelist` | Freigabe durch externe Whitelist |
## Unterschied zur alten Shell-Architektur
Früher gab es unter anderem:
- `adguard-shield.sh` (Hauptskript)
- `iptables-helper.sh` (Firewall-Management)
- `external-blocklist-worker.sh` (Blocklist-Synchronisation)
- `external-whitelist-worker.sh` (Whitelist-Synchronisation)
- `geoip-worker.sh` (GeoIP-Prüfung)
- `offense-cleanup-worker.sh` (Offense-Bereinigung)
- `report-generator.sh` (Report-Erstellung)
- `unban-expired.sh` (Ablauf temporärer Sperren)
- Watchdog-Service und Watchdog-Timer (Überwachung)
In der Go-Version gibt es diese Skripte nicht mehr. Der systemd-Service nutzt `Restart=on-failure`; die eigentlichen Worker laufen als Goroutines im Daemon. Alte Artefakte werden vom Installer erkannt und müssen vor der Go-Installation entfernt werden, damit nicht zwei Implementierungen parallel dieselbe Firewall und dieselben Dateien verwalten.

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,52 @@
# Webhook-Benachrichtigungen
# Benachrichtigungen
Das Tool kann beim Starten und Stoppen des Services sowie bei Sperren und Entsperrungen Benachrichtigungen an verschiedene Dienste senden.
AdGuard Shield kann Ereignisse an Ntfy, Discord, Slack, Gotify oder einen eigenen Webhook senden. Benachrichtigungen sind optional und werden über `adguard-shield.conf` gesteuert.
## Aktivierung
## Unterstützte Ereignisse
In der Konfiguration (`adguard-shield.conf`):
| Ereignis | Beschreibung |
|---|---|
| Service gestartet | Daemon wurde gestartet |
| Service gestoppt | Daemon wurde gestoppt |
| Automatische Sperre | Rate-Limit- oder Subdomain-Flood-Erkennung |
| Watchlist-Sperre | DNS-Flood-Watchlist-Treffer (permanent) |
| Manuelle Sperre | IP wurde manuell per `ban` gesperrt |
| GeoIP-Sperre | Ländersperre ausgelöst (wenn `GEOIP_NOTIFY=true`) |
| Blocklist-Sperre | Externe Blocklist (wenn `EXTERNAL_BLOCKLIST_NOTIFY=true`) |
| Freigabe | IP wurde entsperrt (manuell, abgelaufen oder durch Whitelist) |
| Bulk-Freigabe | `flush`, `geoip-flush` oder `blocklist-flush` |
## Grundkonfiguration
```bash
NOTIFY_ENABLED=true
NOTIFY_TYPE="<typ>"
NOTIFY_WEBHOOK_URL="<url>"
NOTIFY_TYPE="ntfy" # ntfy, discord, slack, gotify oder generic
```
### Mögliche Typen
| Typ | Protokoll | Beschreibung |
|---|---|---|
| `ntfy` | HTTP POST | Push-Benachrichtigungen über ntfy.sh oder selbst gehostete Instanz |
| `discord` | Webhook | Discord-Kanal-Webhook |
| `slack` | Webhook | Slack Incoming Webhook |
| `gotify` | HTTP POST | Gotify-Server mit App-Token |
| `generic` | HTTP POST (JSON) | Eigener Webhook-Endpunkt |
Nach Änderungen:
```bash
sudo systemctl restart adguard-shield
```
---
## Ntfy
Ntfy ist der einfachste Einstieg, weil kein komplexer Webhook-Body benötigt wird.
### Konfiguration
```bash
NOTIFY_ENABLED=true
NOTIFY_TYPE="ntfy"
@@ -23,199 +56,298 @@ NTFY_TOKEN=""
NTFY_PRIORITY="4"
```
> **Hinweis:** Bei Ntfy wird `NOTIFY_WEBHOOK_URL` nicht benötigt Server-URL und Topic werden separat konfiguriert.
### Eigene Ntfy-Instanz
**Eigene Ntfy-Instanz:**
```bash
NTFY_SERVER_URL="https://ntfy.mein-server.de"
NTFY_SERVER_URL="https://ntfy.example.com"
NTFY_TOPIC="dns-security"
NTFY_TOKEN="tk_mein_geheimer_token"
NTFY_TOKEN="tk_geheimer_token"
```
**Prioritäten:**
| Wert | Bedeutung |
|------|-----------|
| 1 | Minimum |
| 2 | Niedrig |
| 3 | Standard |
| 4 | Hoch |
| 5 | Maximum |
### Prioritäten
**Token erstellen (Self-hosted):**
1. Ntfy Web-UI → Benutzer/Tokens
2. Token kopieren und in `NTFY_TOKEN` eintragen
3. Bei ntfy.sh: Account erstellen → Access Token generieren
| Wert | Bedeutung | Beschreibung |
|---:|---|---|
| `1` | Minimum | Keine Benachrichtigung auf dem Gerät |
| `2` | Niedrig | Leise Benachrichtigung |
| `3` | Standard | Normale Benachrichtigung |
| `4` | Hoch | Benachrichtigung mit Ton |
| `5` | Maximum | Dringende Benachrichtigung |
### Hinweise
- Bei `NOTIFY_TYPE="ntfy"` wird `NOTIFY_WEBHOOK_URL` nicht verwendet.
- Bei privaten Topics oder eigener Instanz ist ein Token empfehlenswert.
- Der Topic-Name sollte nicht öffentlich erratbar sein, um Fremdzugriff zu verhindern.
---
## Discord
### Konfiguration
```bash
NOTIFY_ENABLED=true
NOTIFY_TYPE="discord"
NOTIFY_WEBHOOK_URL="https://discord.com/api/webhooks/xxx/yyy"
```
**Webhook erstellen:**
1. Discord Server → Servereinstellungen → Integrationen → Webhooks
2. Neuer Webhook → URL kopieren
### Webhook erstellen
## Gotify
1. Discord-Server öffnen.
2. Servereinstellungen > Integrationen > Webhooks.
3. Neuen Webhook erstellen.
4. Gewünschten Kanal auswählen.
5. URL kopieren und in `NOTIFY_WEBHOOK_URL` eintragen.
```bash
NOTIFY_ENABLED=true
NOTIFY_TYPE="gotify"
NOTIFY_WEBHOOK_URL="https://gotify.example.com/message?token=xxx"
```
Discord erhält den Inhalt als `content`-Feld im JSON-Body.
**Token erstellen:**
1. Gotify Web-UI → Apps → App erstellen
2. Token kopieren und in die URL einfügen
---
## Slack
### Konfiguration
```bash
NOTIFY_ENABLED=true
NOTIFY_TYPE="slack"
NOTIFY_WEBHOOK_URL="https://hooks.slack.com/services/xxx/yyy/zzz"
```
**Webhook erstellen:**
1. Slack App → Incoming Webhooks aktivieren
2. Webhook-URL kopieren
### Webhook einrichten
## Generic (eigener Endpoint)
1. Slack-App mit Incoming Webhooks erstellen oder vorhandene App verwenden.
2. Webhook für den gewünschten Channel aktivieren.
3. Webhook-URL in die Konfiguration kopieren.
Slack erhält den Inhalt als `text`-Feld im JSON-Body.
---
## Gotify
### Konfiguration
```bash
NOTIFY_ENABLED=true
NOTIFY_TYPE="gotify"
NOTIFY_WEBHOOK_URL="https://gotify.example.com/message?token=xxx"
```
### Token erstellen
1. Gotify-Weboberfläche öffnen.
2. Apps > App erstellen.
3. Token aus der App kopieren und in die URL einsetzen.
Gotify erhält `title`, `message` und `priority` als Formularwerte.
---
## Generic Webhook
Für eigene Automatisierung oder Anbindung an andere Systeme.
### Konfiguration
```bash
NOTIFY_ENABLED=true
NOTIFY_TYPE="generic"
NOTIFY_WEBHOOK_URL="https://your-server.com/webhook"
NOTIFY_WEBHOOK_URL="https://example.com/adguard-shield-webhook"
```
Sendet einen POST mit JSON-Body:
### JSON-Payload
AdGuard Shield sendet einen `POST` mit folgendem JSON-Body:
```json
{
"message": "🚫 AdGuard Shield Ban auf dns1\n---\nIP: 192.168.1.50\nHostname: client.local\nGrund: 45x microsoft.com in 60s via DNS, Rate-Limit\nDauer: 1h 0m\n\nWhois: https://www.whois.com/whois/192.168.1.50\nAbuseIPDB: https://www.abuseipdb.com/check/192.168.1.50",
"action": "ban",
"title": "AdGuard Shield",
"message": "AdGuard Shield Ban auf dns1\n---\nIP: 192.168.1.50\nHostname: client.local\nGrund: 45x example.com in 60s via DNS, Rate-Limit\nDauer: 1h 0m [Stufe 1/5]\n\nAbuseIPDB: https://www.abuseipdb.com/check/192.168.1.50",
"client": "192.168.1.50",
"domain": "microsoft.com"
"action": "ban"
}
```
## Benachrichtigungen und externe Blocklisten
### Mögliche `action`-Werte
Bei Sperren aus der **externen Blocklist** werden Benachrichtigungen separat über `EXTERNAL_BLOCKLIST_NOTIFY` gesteuert — unabhängig von `NOTIFY_ENABLED`.
| Aktion | Bedeutung |
|---|---|
| `ban` | Sperre wurde gesetzt |
| `unban` | Sperre wurde aufgehoben |
| `manual-flush` | Bulk-Freigabe aller Sperren |
| `geoip-flush` | Bulk-Freigabe aller GeoIP-Sperren |
| `external-blocklist-flush` | Bulk-Freigabe aller externen Blocklist-Sperren |
| `service_start` | Service wurde gestartet |
| `service_stop` | Service wurde gestoppt |
| Parameter | Standard | Beschreibung |
|-----------|----------|--------------|
| `EXTERNAL_BLOCKLIST_NOTIFY` | `false` | Benachrichtigungen bei Blocklist-Sperren aktivieren |
---
> **Wichtig:** Bei großen Listen `EXTERNAL_BLOCKLIST_NOTIFY=false` belassen. Beim ersten Sync (oder nach einem `blocklist-flush`) werden alle IPs der Liste auf einmal gesperrt — mit `true` würde das zu einer Nachrichten-Flut im Notification-Channel führen. Nur auf `true` setzen, wenn die Liste sehr klein ist.
## Separate Steuerung für Module
## Beispiel-Nachrichten
### Externe Blocklist
```bash
EXTERNAL_BLOCKLIST_NOTIFY=false
```
**Warum separat?** Eine große Blocklist kann beim ersten Sync hunderte oder tausende IPs sperren. Wenn jede Sperre eine Nachricht erzeugt, wird der Benachrichtigungskanal unbrauchbar.
| Listengröße | Empfehlung |
|---|---|
| Große Listen (>100 IPs) | `EXTERNAL_BLOCKLIST_NOTIFY=false` (Standard) |
| Kleine, kuratierte Listen (<50 IPs) | `EXTERNAL_BLOCKLIST_NOTIFY=true` möglich |
### GeoIP
```bash
GEOIP_NOTIFY=true
```
Wenn GeoIP aktiv ist, aber keine Nachrichten für GeoIP-Sperren gesendet werden sollen:
```bash
GEOIP_NOTIFY=false
```
---
## Bulk-Freigaben
Diese Befehle können viele IPs auf einmal freigeben:
```bash
sudo /opt/adguard-shield/adguard-shield flush
sudo /opt/adguard-shield/adguard-shield geoip-flush
sudo /opt/adguard-shield/adguard-shield blocklist-flush
```
AdGuard Shield sendet dafür **nicht** eine Nachricht pro IP, sondern eine zusammenfassende Meldung mit der Anzahl der freigegebenen Sperren.
---
## AbuseIPDB-Hinweis in Nachrichten
Bei permanenten Monitor-Sperren kann AdGuard Shield zusätzlich an AbuseIPDB melden.
**Voraussetzungen:**
```bash
ABUSEIPDB_ENABLED=true
ABUSEIPDB_API_KEY="..."
```
**Verhalten:**
- Wenn eine AbuseIPDB-Meldung ausgelöst wurde, enthält die Ban-Nachricht einen entsprechenden Hinweis.
- Jede Ban- und Unban-Nachricht enthält einen Link zur AbuseIPDB-Check-Seite der IP.
- AbuseIPDB wird nicht für GeoIP- oder externe Blocklist-Sperren verwendet.
---
## Beispielinhalte
### Service gestartet
**Überschrift:** ✅ AdGuard Shield
> 🟢 AdGuard Shield v0.9.0 wurde auf dns1 gestartet.
```text
AdGuard Shield v1.0.0 wurde auf dns1 gestartet.
```
### Service gestoppt
**Überschrift:** 🚨 🛡️ AdGuard Shield
> 🔴 AdGuard Shield v0.9.0 wurde auf dns1 gestoppt.
```text
AdGuard Shield v1.0.0 wurde auf dns1 gestoppt.
```
### Watchdog — Service wiederhergestellt
**Überschrift:** 🔄 AdGuard Shield Watchdog
### Rate-Limit-Sperre
> 🔄 AdGuard Shield Watchdog auf dns1
> ---
> Der Service war ausgefallen und wurde automatisch neu gestartet.
> Versuch: 1
```text
AdGuard Shield Ban auf dns1
---
IP: 192.0.2.50
Hostname: client.example.com
Grund: 45x example.com in 60s via DNS, Rate-Limit
Dauer: 1h 0m [Stufe 1/5]
### Watchdog — Recovery fehlgeschlagen
**Überschrift:** 🚨 AdGuard Shield Watchdog
AbuseIPDB: https://www.abuseipdb.com/check/192.0.2.50
```
> 🚨 AdGuard Shield Watchdog auf dns1
> ---
> Der Service konnte NICHT automatisch neu gestartet werden!
> Manuelles Eingreifen erforderlich.
> Fehlversuche: 1
> Letzter Fehler: (systemd Statusausgabe)
### Watchlist-Sperre
### Sperre (Ban)
**Überschrift:** 🚨 🛡️ AdGuard Shield
```text
AdGuard Shield Ban auf dns1
IP wurde an AbuseIPDB gemeldet
---
IP: 192.0.2.51
Hostname: unknown
Grund: 75x microsoft.com in 60s via DoH, DNS-Flood-Watchlist
Dauer: PERMANENT
> 🚫 AdGuard Shield Ban auf dns1
> ---
> IP: 95.71.42.116
> Hostname: example-host.provider.net
> Grund: 153x radioportal.techniverse.net in 60s via DNS, Rate-Limit
> Dauer: 1h 0m [Stufe 1/5]
>
> Whois: https://www.whois.com/whois/95.71.42.116
> AbuseIPDB: https://www.abuseipdb.com/check/95.71.42.116
AbuseIPDB: https://www.abuseipdb.com/check/192.0.2.51
```
Bei permanenter Sperre mit aktiviertem AbuseIPDB-Reporting erscheint zusätzlich:
### GeoIP-Sperre
> 🚫 AdGuard Shield Ban auf dns1
> ⚠️ IP wurde an AbuseIPDB gemeldet
> ---
> IP: 95.71.42.116
> Hostname: example-host.provider.net
> Grund: 153x radioportal.techniverse.net in 60s via DNS, Rate-Limit
> Dauer: PERMANENT [Stufe 5/5]
>
> Whois: https://www.whois.com/whois/95.71.42.116
> AbuseIPDB: https://www.abuseipdb.com/check/95.71.42.116
```text
AdGuard Shield GeoIP-Sperre auf dns1
---
IP: 203.0.113.10
Land: BR
Modus: Blocklist
Dauer: PERMANENT
Bei DNS-Flood-Watchlist-Treffer (sofort permanent, ohne Stufe):
AbuseIPDB: https://www.abuseipdb.com/check/203.0.113.10
```
> 🚫 AdGuard Shield Ban auf dns1
> ⚠️ IP wurde an AbuseIPDB gemeldet
> ---
> IP: 95.71.42.116
> Hostname: example-host.provider.net
> Grund: 45x microsoft.com in 60s via DNS, DNS-Flood-Watchlist
> Dauer: PERMANENT
>
> Whois: https://www.whois.com/whois/95.71.42.116
> AbuseIPDB: https://www.abuseipdb.com/check/95.71.42.116
### Freigabe
### Entsperrung (Unban)
**Überschrift:** ✅ AdGuard Shield
```text
AdGuard Shield Freigabe auf dns1
---
IP: 192.0.2.50
Hostname: client.example.com
> ✅ AdGuard Shield Freigabe auf dns1
> ---
> IP: 95.71.42.116
> Hostname: example-host.provider.net
>
> AbuseIPDB: https://www.abuseipdb.com/check/95.71.42.116
AbuseIPDB: https://www.abuseipdb.com/check/192.0.2.50
```
### Externe Blocklist Sperre
**Überschrift:** 🚨 🛡️ AdGuard Shield
### Bulk-Freigabe
> 🚫 AdGuard Shield Ban auf dns1 (Externe Blocklist)
> ---
> IP: 203.0.113.50
> Hostname: bad-actor.example.com
>
> Whois: https://www.whois.com/whois/203.0.113.50
> AbuseIPDB: https://www.abuseipdb.com/check/203.0.113.50
```text
AdGuard Shield Bulk-Freigabe auf dns1
---
Freigegebene IPs: 28
Aktion: Manual-Flush
```
### Externe Blocklist Entsperrung
**Überschrift:** ✅ AdGuard Shield
---
> ✅ AdGuard Shield Freigabe auf dns1 (Externe Blocklist)
> ---
> IP: 203.0.113.50
> Hostname: bad-actor.example.com
>
> AbuseIPDB: https://www.abuseipdb.com/check/203.0.113.50
## Fehlersuche
### Hinweise
Wenn keine Benachrichtigung ankommt:
- Der **Hostname** der IP wird automatisch per Reverse-DNS aufgelöst (`dig`, `host` oder `getent`). Ist kein PTR-Record vorhanden, wird `(unbekannt)` angezeigt.
- Der **Servername** (`dns1` in den Beispielen) wird dynamisch über `$(hostname)` ermittelt und zeigt, auf welchem Server das Ereignis stattfand.
- Die **Überschrift** unterscheidet sich je nach Aktion:
- 🚨 🛡️ bei Sperren und Service-Stopp
- ✅ bei Freigaben und Service-Start
- Bei **permanenten Sperren** mit aktiviertem AbuseIPDB-Reporting wird ein Hinweis eingeblendet, dass die IP an AbuseIPDB gemeldet wurde.
```bash
sudo /opt/adguard-shield/adguard-shield logs --level warn --limit 100
sudo journalctl -u adguard-shield --no-pager -n 100
```
### Checkliste
| Prüfpunkt | Was zu prüfen ist |
|---|---|
| Aktiviert | `NOTIFY_ENABLED=true` gesetzt? |
| Typ | `NOTIFY_TYPE` korrekt geschrieben? |
| Ziel | Webhook-URL oder Ntfy-Topic gesetzt? |
| Token | Token gültig und nicht abgelaufen? |
| Netzwerk | Server kann ausgehende HTTPS-Verbindungen aufbauen? |
| 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.
---
## Datenschutz
Benachrichtigungen können IP-Adressen, Domainnamen und Hostnamen enthalten. Sende sie nur an Dienste, denen du vertraust.
Für öffentliche oder geteilte Kanäle ist eine eigene Ntfy- oder Gotify-Instanz mit privatem Topic oft die bessere Wahl als ein öffentlicher Kanal.

160
docs/docker.md Normal file
View File

@@ -0,0 +1,160 @@
# Docker-Installationen
AdGuard Shield läuft auf dem Host und liest weiterhin das Querylog von AdGuard Home über die API. Der Unterschied zwischen klassischer Installation und Docker-Setup betrifft nur die Stelle, an der die Firewall eine gesperrte Client-IP abfangen muss.
## Modus wählen
Die Wahl des Firewall-Modus hängt davon ab, wie AdGuard Home betrieben wird:
| Installation | Einstellung | Parent-Chain |
|---|---|---|
| AdGuard Home direkt auf dem Host | `FIREWALL_MODE="host"` | `INPUT` |
| Docker mit `network_mode: host` | `FIREWALL_MODE="docker-host"` | `INPUT` |
| Docker Bridge mit veröffentlichten Ports | `FIREWALL_MODE="docker-bridge"` | `DOCKER-USER` |
| Gemischtes Setup oder Migration | `FIREWALL_MODE="hybrid"` | `INPUT` + `DOCKER-USER` |
### Warum verschiedene Modi?
**Host und Docker Host Network:** DNS-Pakete landen direkt in der `INPUT`-Chain des Hosts. Die Firewall-Regeln werden dort eingehängt.
**Docker Bridge mit Port-Publishing:** Docker veröffentlicht Ports über NAT (DNAT). Die Pakete durchlaufen nach dem DNAT die `FORWARD`-Chain, nicht die `INPUT`-Chain. Docker stellt dafür die Chain `DOCKER-USER` bereit, die genau für eigene Admin-Regeln vor Dockers Container-Regeln vorgesehen ist.
**Hybrid:** Hängt Regeln in beide Chains ein. Nützlich bei Migrationen oder wenn unklar ist, welcher Weg die Pakete nehmen.
---
## Konfigurationsbeispiele
### Klassisch oder Docker Host Network
```bash
FIREWALL_MODE="host"
BLOCKED_PORTS="53 443 853"
```
`docker-host` verhält sich technisch identisch zu `host`:
```bash
FIREWALL_MODE="docker-host"
BLOCKED_PORTS="53 443 853"
```
### Docker Bridge mit Port-Publishing
```bash
FIREWALL_MODE="docker-bridge"
BLOCKED_PORTS="53 443 853"
```
### Unklarer Übergangszustand
```bash
FIREWALL_MODE="hybrid"
BLOCKED_PORTS="53 443 853"
```
---
## Regelstruktur nach Modus
### Host / Docker Host Network
```text
INPUT
├── tcp/53 → ADGUARD_SHIELD
├── udp/53 → ADGUARD_SHIELD
├── tcp/443 → ADGUARD_SHIELD
├── udp/443 → ADGUARD_SHIELD
├── tcp/853 → ADGUARD_SHIELD
└── udp/853 → ADGUARD_SHIELD
ADGUARD_SHIELD
├── src in adguard_shield_v4 → DROP
└── src in adguard_shield_v6 → DROP
```
### Docker Bridge
```text
DOCKER-USER
├── tcp/53 → ADGUARD_SHIELD
├── udp/53 → ADGUARD_SHIELD
├── tcp/443 → ADGUARD_SHIELD
├── udp/443 → ADGUARD_SHIELD
├── tcp/853 → ADGUARD_SHIELD
└── udp/853 → ADGUARD_SHIELD
ADGUARD_SHIELD
├── src in adguard_shield_v4 → DROP
└── src in adguard_shield_v6 → DROP
```
### Hybrid
Beide Strukturen gleichzeitig: `INPUT` und `DOCKER-USER` springen in `ADGUARD_SHIELD`.
---
## Wichtige Details
| Thema | Beschreibung |
|---|---|
| **DOCKER-USER Chain** | `docker-bridge` benötigt eine vorhandene IPv4-Chain `DOCKER-USER`. Wenn Docker nicht läuft oder iptables für Docker deaktiviert ist, meldet `firewall-create` einen Fehler. |
| **IPv6 in Docker** | IPv6 über Docker wird nur eingehängt, wenn Docker auch eine `ip6tables`-Chain `DOCKER-USER` angelegt hat. Fehlt sie, wird IPv4 trotzdem geschützt. |
| **Port-Mapping** | In `DOCKER-USER` wird nach Dockers DNAT gematcht. Bei ungewöhnlichen Port-Mappings sollten `BLOCKED_PORTS` die Container-Zielports enthalten (nicht die Host-Ports). |
| **Hybrid-Warnung** | `hybrid` kann mehr Verkehr treffen, weil sowohl Host-Ports als auch Docker-Forwarding geprüft werden. Nur bei Migrationen oder unklaren Setups verwenden. |
| **API-URL** | Die `ADGUARD_URL` muss vom Host aus erreichbar sein. Bei Docker Bridge ist das oft `http://127.0.0.1:<host-port>`. |
---
## Typisches Docker-Bridge-Setup
### docker-compose.yml (AdGuard Home)
```yaml
services:
adguardhome:
image: adguard/adguardhome
ports:
- "53:53/tcp"
- "53:53/udp"
- "443:443/tcp"
- "853:853/tcp"
- "3000:3000/tcp"
volumes:
- ./data:/opt/adguardhome/work
- ./conf:/opt/adguardhome/conf
restart: unless-stopped
```
### adguard-shield.conf
```bash
ADGUARD_URL="http://127.0.0.1:3000"
ADGUARD_USER="admin"
ADGUARD_PASS="geheim"
FIREWALL_MODE="docker-bridge"
BLOCKED_PORTS="53 443 853"
```
---
## Nach einer Änderung prüfen
```bash
sudo systemctl restart adguard-shield
sudo /opt/adguard-shield/adguard-shield firewall-status
sudo /opt/adguard-shield/adguard-shield status
```
## Firewall neu aufbauen
Falls der Modus gewechselt wurde:
```bash
sudo /opt/adguard-shield/adguard-shield firewall-remove
sudo systemctl restart adguard-shield
sudo /opt/adguard-shield/adguard-shield firewall-status
```
Der Daemon erstellt die Firewall-Struktur beim Start automatisch neu und überträgt aktive Sperren aus SQLite.

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,60 @@
# E-Mail Report
AdGuard Shield kann regelmäßig einen Statistik-Report per E-Mail versenden. Der Report enthält eine Übersicht über alle Sperren, die auffälligsten IPs, meistbetroffene Domains und weitere Statistiken.
AdGuard Shield kann Statistik-Reports direkt aus der SQLite-Datenbank erzeugen und per E-Mail versenden. Es gibt in der Go-Version keinen separaten `report-generator.sh` mehr.
## Voraussetzungen
## Was der Report enthält
Der Server muss E-Mails versenden können. Empfohlen wird **msmtp** als leichtgewichtiger SMTP-Client.
Der Report basiert auf der SQLite-Datenbank:
**Anleitung zur Einrichtung von msmtp:**
👉 [Linux: Einfach E-Mails versenden mit msmtp](https://www.cleveradmin.de/blog/2024/12/linux-einfach-emails-versenden-mit-msmtp/)
```text
/var/lib/adguard-shield/adguard-shield.db
```
Alternativ funktioniert auch `sendmail`, `mail` oder ein anderer Befehl, der E-Mails über stdin entgegennimmt.
### Ausgewertete Daten
## Aktivierung
| Bereich | Inhalt |
|---|---|
| Zeitraum | Start- und Enddatum des Berichtszeitraums |
| Sperren | Anzahl der Sperren im Zeitraum |
| Freigaben | Anzahl der Freigaben im Zeitraum |
| Aktive Sperren | Derzeit aktive Sperren zum Zeitpunkt der Report-Erstellung |
| Top-Clients | Die am häufigsten gesperrten Client-IPs |
| Sperrgründe | Aufschlüsselung nach Grund (Rate-Limit, Subdomain-Flood, GeoIP usw.) |
| Sperrquellen | Aufschlüsselung nach Quelle (Monitor, GeoIP, Blocklist, manuell) |
| Letzte Ereignisse | Die letzten 20 Einträge aus der Ban-History |
In der Konfiguration (`adguard-shield.conf`):
---
## Konfiguration
| Parameter | Standard | Beschreibung |
|---|---|---|
| `REPORT_ENABLED` | `false` | Report-Funktion logisch aktivieren |
| `REPORT_INTERVAL` | `weekly` | Versandintervall |
| `REPORT_TIME` | `08:00` | Versandzeit im Format `HH:MM` |
| `REPORT_EMAIL_TO` | `admin@example.com` | Empfängeradresse |
| `REPORT_EMAIL_FROM` | `adguard-shield@example.com` | Absenderadresse |
| `REPORT_FORMAT` | `html` | Report-Format (`html` oder `txt`) |
| `REPORT_MAIL_CMD` | `msmtp` | Mailprogramm für den Versand |
| `REPORT_BUSIEST_DAY_RANGE` | `30` | Kompatibilitätsparameter für den Zeitraum "Aktivster Tag" |
### Versandintervalle
| Intervall | Versandzeitpunkt |
|---|---|
| `daily` | Täglich zur Uhrzeit aus `REPORT_TIME` |
| `weekly` | Montags zur Uhrzeit aus `REPORT_TIME` |
| `biweekly` | Am 1. und 15. des Monats zur Uhrzeit aus `REPORT_TIME` |
| `monthly` | Am 1. des Monats zur Uhrzeit aus `REPORT_TIME` |
### Formate
| Format | Beschreibung | Empfehlung |
|---|---|---|
| `html` | HTML-formatierte E-Mail mit Tabellen und Formatierung | Standard-Mail-Clients |
| `txt` | Reiner Text ohne Formatierung | Einfache Mail-Setups, Log-Ablage |
### Beispielkonfiguration
```bash
REPORT_ENABLED=true
@@ -25,252 +66,221 @@ REPORT_FORMAT="html"
REPORT_MAIL_CMD="msmtp"
```
Anschließend den Cron-Job einrichten:
```bash
sudo /opt/adguard-shield/report-generator.sh install
```
## Konfigurationsparameter
| Parameter | Standard | Beschreibung |
|-----------|----------|--------------|
| `REPORT_ENABLED` | `false` | Report-Funktion aktivieren |
| `REPORT_INTERVAL` | `weekly` | Versandintervall (siehe unten) |
| `REPORT_TIME` | `08:00` | Uhrzeit für den Versand (HH:MM, 24h) |
| `REPORT_EMAIL_TO` | *(leer)* | E-Mail-Empfänger |
| `REPORT_EMAIL_FROM` | `adguard-shield@hostname` | E-Mail-Absender |
| `REPORT_FORMAT` | `html` | Format: `html` oder `txt` |
| `REPORT_MAIL_CMD` | `msmtp` | Mail-Befehl (`msmtp`, `sendmail`, `mail`) |
| `REPORT_BUSIEST_DAY_RANGE` | `30` | Zeitraum in Tagen für „Aktivster Tag“ (0 = Berichtszeitraum) |
### Versandintervalle
| Wert | Beschreibung |
|------|-------------|
| `daily` | Täglich zur konfigurierten Uhrzeit |
| `weekly` | Wöchentlich am Montag |
| `biweekly` | Alle zwei Wochen am Montag |
| `monthly` | Monatlich am 1. des Monats |
## Report-Inhalte
Der Report enthält folgende Statistiken:
### Zeitraum-Schnellübersicht *(immer ganz oben)*
Eine Vergleichstabelle mit Live-Zahlen für vier feste Zeitfenster unabhängig vom konfigurierten `REPORT_INTERVAL`:
| Zeitraum | Sperren | Entsperrungen | Eindeutige IPs | Permanent gebannt |
|----------|---------|---------------|----------------|-------------------|
| Heute *(nur nach 20:00 Uhr)* | … | … | … | … |
| Gestern | … | … | … | … |
| Letzte 7 Tage | … | … | … | … |
| Letzte 14 Tage | … | … | … | … |
| Letzte 30 Tage | … | … | … | … |
Im HTML-Format wird **Gestern** grün hervorgehoben, **Heute** blau (erscheint nur ab 20:00 Uhr).
- **Gestern** umfasst exakt 00:00:00 23:59:59 des gestrigen Tages.
- **Heute** umfasst den laufenden Tag von 00:00:00 bis zum Zeitpunkt der Reportgenerierung und wird nur eingeblendet, wenn der Report nach 20:00 Uhr erstellt wird.
Die übrigen Zeiträume laufen vom Starttag 00:00 Uhr bis zum Zeitpunkt der Reportgenerierung.
> **Hinweis:** Die AbuseIPDB-Meldungen werden in der Schnellübersicht nicht mehr separat ausgewiesen, da sie immer mit einer Permanentsperre korrelieren der Wert „Permanent gebannt" ist daher ausreichend. Die Gesamtanzahl der AbuseIPDB-Reports im Berichtszeitraum ist weiterhin in der allgemeinen Übersicht sichtbar.
### Übersicht (Berichtszeitraum)
- Gesamtzahl der Sperren und Entsperrungen
- Anzahl eindeutiger gesperrter IPs
- Permanente Sperren
- Aktuell aktive Sperren
- AbuseIPDB-Reports
### Angriffsarten
- Rate-Limit Sperren
- Subdomain-Flood Sperren
- Externe Blocklist Sperren
- Aktivster Tag wird über einen konfigurierbaren Zeitraum ermittelt (Standard: letzte 30 Tage, `REPORT_BUSIEST_DAY_RANGE`). Zeigt zusätzlich die Anzahl der Sperren an diesem Tag. Bei `REPORT_BUSIEST_DAY_RANGE=0` wird nur der Berichtszeitraum betrachtet.
### Top 10 Listen
- **Auffälligste IPs** — Die 10 IPs mit den meisten Sperren (mit Balkendiagramm im HTML-Format)
- **Meistbetroffene Domains** — Die 10 am häufigsten betroffenen Domains
### Weitere Details
- **Protokoll-Verteilung** — Aufschlüsselung nach DNS, DoH, DoT, DoQ
- **Letzte 10 Sperren** — Die aktuellsten Sperrereignisse mit Zeitstempel, IP, Domain und Grund
---
## Befehle
```bash
# Report sofort generieren und versenden
sudo /opt/adguard-shield/report-generator.sh send
# Test-E-Mail senden (prüft alle Voraussetzungen + Mailversand)
sudo /opt/adguard-shield/report-generator.sh test
# Report als Datei generieren (auf stdout ausgeben)
sudo /opt/adguard-shield/report-generator.sh generate
# Report im spezifischen Format generieren
sudo /opt/adguard-shield/report-generator.sh generate html > report.html
sudo /opt/adguard-shield/report-generator.sh generate txt > report.txt
# Cron-Job für automatischen Versand einrichten
sudo /opt/adguard-shield/report-generator.sh install
# Cron-Job entfernen
sudo /opt/adguard-shield/report-generator.sh remove
# Report-Konfiguration und Cron-Status anzeigen
sudo /opt/adguard-shield/report-generator.sh status
```
## Report-Intervall ändern
Um das Intervall, die Uhrzeit oder andere Einstellungen zu ändern:
### Konfiguration und Cron-Status anzeigen
```bash
# 1. Konfiguration bearbeiten
sudo nano /opt/adguard-shield/adguard-shield.conf
# → z.B. REPORT_INTERVAL="weekly" auf "daily" ändern
# → z.B. REPORT_TIME="09:00"
# 2. Cron-Job neu einrichten (überschreibt den alten automatisch)
sudo /opt/adguard-shield/report-generator.sh install
sudo /opt/adguard-shield/adguard-shield report-status
```
> **Hinweis:** Der `install`-Befehl überschreibt den bestehenden Cron-Job mit den aktuellen Werten aus der Konfiguration. Ein vorheriges `remove` ist nicht nötig, schadet aber auch nicht.
Alternativ in zwei Schritten:
### HTML-Report in Datei schreiben
```bash
# Alten Cron-Job erst entfernen, dann neu anlegen
sudo /opt/adguard-shield/report-generator.sh remove
sudo nano /opt/adguard-shield/adguard-shield.conf
sudo /opt/adguard-shield/report-generator.sh install
sudo /opt/adguard-shield/adguard-shield report-generate html /tmp/adguard-shield-report.html
```
## Templates
Die Datei kann im Browser geöffnet werden, um das Ergebnis zu prüfen.
Die Report-Templates liegen unter:
```
/opt/adguard-shield/templates/report.html # HTML-Template
/opt/adguard-shield/templates/report.txt # TXT-Template
```
Die Templates verwenden Platzhalter (z.B. `{{TOTAL_BANS}}`, `{{TOP10_IPS_TABLE}}`), die beim Generieren durch die tatsächlichen Werte ersetzt werden. Die Templates können nach Bedarf angepasst werden.
### Verfügbare Platzhalter
| Platzhalter | Beschreibung |
|-------------|-------------|
| `{{REPORT_PERIOD}}` | Berichtszeitraum mit Label |
| `{{REPORT_DATE}}` | Erstellungsdatum des Reports |
| `{{HOSTNAME}}` | Server-Hostname |
| `{{VERSION}}` | AdGuard Shield Version |
| `{{TOTAL_BANS}}` | Gesamtzahl Sperren |
| `{{TOTAL_UNBANS}}` | Gesamtzahl Entsperrungen |
| `{{UNIQUE_IPS}}` | Anzahl eindeutiger IPs |
| `{{PERMANENT_BANS}}` | Permanente Sperren |
| `{{ACTIVE_BANS}}` | Aktuell aktive Sperren |
| `{{ABUSEIPDB_REPORTS}}` | Anzahl AbuseIPDB-Reports |
| `{{RATELIMIT_BANS}}` | Rate-Limit Sperren |
| `{{SUBDOMAIN_FLOOD_BANS}}` | Subdomain-Flood Sperren |
| `{{EXTERNAL_BLOCKLIST_BANS}}` | Externe Blocklist Sperren |
| `{{BUSIEST_DAY}}` | Aktivster Tag (Datum + Anzahl Sperren) |
| `{{BUSIEST_DAY_LABEL}}` | Dynamisches Label für den aktivsten Tag (z.B. „Aktivster Tag (30 Tage)“) |
| `{{TOP10_IPS_TABLE}}` | Top 10 IPs (HTML-Tabelle) |
| `{{TOP10_IPS_TEXT}}` | Top 10 IPs (Text-Tabelle) |
| `{{TOP10_DOMAINS_TABLE}}` | Top 10 Domains (HTML-Tabelle) |
| `{{TOP10_DOMAINS_TEXT}}` | Top 10 Domains (Text-Tabelle) |
| `{{PROTOCOL_TABLE}}` | Protokoll-Verteilung (HTML) |
| `{{PROTOCOL_TEXT}}` | Protokoll-Verteilung (Text) |
| `{{RECENT_BANS_TABLE}}` | Letzte Sperren (HTML) |
| `{{RECENT_BANS_TEXT}}` | Letzte Sperren (Text) |
## Beispiel: Schnellstart
### Text-Report auf stdout ausgeben
```bash
# 1. msmtp installieren und konfigurieren
sudo /opt/adguard-shield/adguard-shield report-generate txt
```
### Testmail senden
```bash
sudo /opt/adguard-shield/adguard-shield report-test
```
Sendet eine einfache Testmail. Erst wenn diese ankommt, lohnt sich die Fehlersuche am eigentlichen Report.
### Aktuellen Report erzeugen und versenden
```bash
sudo /opt/adguard-shield/adguard-shield report-send
```
### Cron-Job installieren
```bash
sudo /opt/adguard-shield/adguard-shield report-install
```
### Cron-Job entfernen
```bash
sudo /opt/adguard-shield/adguard-shield report-remove
```
---
## Mailversand
AdGuard Shield übergibt die fertige Mail an ein lokales Mailprogramm. Der Standard ist:
```bash
REPORT_MAIL_CMD="msmtp"
```
### Einrichtung mit msmtp
```bash
# msmtp installieren
sudo apt install msmtp msmtp-mta
# Anleitung: https://www.cleveradmin.de/blog/2024/12/linux-einfach-emails-versenden-mit-msmtp/
# 2. Report-Konfiguration anpassen
sudo nano /opt/adguard-shield/adguard-shield.conf
# → REPORT_ENABLED=true
# → REPORT_EMAIL_TO="deine@email.de"
# 3. Test-Mail senden (prüft alle Voraussetzungen)
sudo /opt/adguard-shield/report-generator.sh test
# 4. Wenn die Test-Mail angekommen ist: echten Report testen
sudo /opt/adguard-shield/report-generator.sh send
# 5. Automatischen Versand einrichten
sudo /opt/adguard-shield/report-generator.sh install
# 6. Status prüfen
sudo /opt/adguard-shield/report-generator.sh status
# Testmail senden
sudo /opt/adguard-shield/adguard-shield report-test
```
## Test-Mail
### Eigene Mailprogramm-Argumente
Bevor du den automatischen Versand einrichtest, kannst du mit dem `test`-Befehl prüfen, ob alles funktioniert:
Wenn dein Mailprogramm zusätzliche Argumente braucht, können sie in `REPORT_MAIL_CMD` stehen. AdGuard Shield hängt intern `-t` an, damit Empfänger und Header aus der generierten Mail gelesen werden.
```bash
sudo /opt/adguard-shield/report-generator.sh test
REPORT_MAIL_CMD="msmtp --account=default"
```
Der Test prüft Schritt für Schritt:
### Alternativen zu msmtp
1. **E-Mail-Empfänger** — Ist `REPORT_EMAIL_TO` konfiguriert?
2. **E-Mail-Absender** — Zeigt den konfigurierten Absender an
3. **Mail-Befehl** — Ist `msmtp` (oder der konfigurierte Befehl) installiert?
4. **Report-Template** — Existiert das HTML/TXT-Template?
5. **Ban-History** — Gibt es vorhandene Daten?
6. **Test-Versand** — Sendet eine Test-E-Mail und prüft den Exit-Code
| Programm | `REPORT_MAIL_CMD` |
|---|---|
| msmtp | `msmtp` |
| sendmail | `sendmail` |
| ssmtp | `ssmtp` |
| Benutzerdefiniert | Vollständiger Pfad zum Programm |
Die Test-Mail enthält eine Übersicht der aktuellen Konfiguration und bestätigt, dass der Mailversand funktioniert.
---
## Troubleshooting
## Automatischer Versand
### E-Mail wird nicht versendet
### Cron-Job installieren
1. Prüfe ob der Mail-Befehl installiert ist:
```bash
which msmtp
```
```bash
sudo /opt/adguard-shield/adguard-shield report-install
```
2. Teste den Mailversand manuell:
```bash
echo "Test" | msmtp -t deine@email.de
```
Dadurch wird diese Datei geschrieben:
3. Prüfe die msmtp-Konfiguration:
```bash
cat ~/.msmtprc
# oder
cat /etc/msmtprc
```
```text
/etc/cron.d/adguard-shield-report
```
4. Prüfe die Report-Konfiguration:
```bash
sudo /opt/adguard-shield/report-generator.sh status
```
Der Cron-Eintrag ruft das installierte Binary mit der installierten Konfiguration auf:
### Report enthält keine Daten
```text
/opt/adguard-shield/adguard-shield -config /opt/adguard-shield/adguard-shield.conf report-send
```
Der Report basiert auf der Ban-History-Datei (`/var/log/adguard-shield-bans.log`). Wenn keine Sperren im Berichtszeitraum vorhanden sind, zeigt der Report „Keine Daten" an.
### Zeitplan nach Intervall
### Cron-Job wird nicht ausgeführt
| Intervall | Cron-Verhalten |
|---|---|
| `daily` | Täglich zur Uhrzeit aus `REPORT_TIME` |
| `weekly` | Montags zur Uhrzeit aus `REPORT_TIME` |
| `biweekly` | Am 1. und 15. des Monats |
| `monthly` | Am 1. des Monats |
1. Prüfe ob der Cron-Job angelegt wurde:
```bash
cat /etc/cron.d/adguard-shield-report
```
### Cron-Job entfernen
2. Prüfe die Cron-Logs:
```bash
grep adguard-shield /var/log/syslog
# oder
journalctl -u cron
```
```bash
sudo /opt/adguard-shield/adguard-shield report-remove
```
---
## Manuelle Prüfung
### Schritt 1: Status prüfen
```bash
sudo /opt/adguard-shield/adguard-shield report-status
```
### Schritt 2: Report lokal erzeugen
```bash
# HTML-Report zum Ansehen im Browser
sudo /opt/adguard-shield/adguard-shield report-generate html /tmp/adguard-shield-report.html
# Text-Report in der Konsole
sudo /opt/adguard-shield/adguard-shield report-generate txt
```
### Schritt 3: Versand testen
```bash
# Einfache Testmail
sudo /opt/adguard-shield/adguard-shield report-test
# Vollständigen Report senden
sudo /opt/adguard-shield/adguard-shield report-send
```
### Schritt 4: Logs prüfen
```bash
sudo /opt/adguard-shield/adguard-shield logs --level warn --limit 100
sudo journalctl -u cron --no-pager -n 100
```
Je nach Distribution heißt der Cron-Service `cron`, `crond` oder wird über das allgemeine Syslog protokolliert.
---
## Häufige Probleme
### `REPORT_EMAIL_TO ist leer`
Setze einen Empfänger in der Konfiguration:
```bash
REPORT_EMAIL_TO="admin@example.com"
```
### Mailprogramm nicht gefunden
Prüfe, ob das Mailprogramm installiert ist:
```bash
which msmtp
```
Installiere es bei Bedarf:
```bash
sudo apt install msmtp msmtp-mta
```
Oder setze `REPORT_MAIL_CMD` auf dein vorhandenes Mailprogramm.
### Cron läuft, aber keine Mail kommt an
Prüfe die Konfiguration und den Cron-Job:
```bash
sudo /opt/adguard-shield/adguard-shield report-send
sudo cat /etc/cron.d/adguard-shield-report
```
**Checkliste:**
| Prüfpunkt | Beschreibung |
|---|---|
| Empfänger | `REPORT_EMAIL_TO` korrekt gesetzt? |
| Mailprogramm | `REPORT_MAIL_CMD` im Cron-PATH verfügbar? |
| Root-Konfiguration | Mailer für root konfiguriert? (msmtp benötigt `/root/.msmtprc` oder `/etc/msmtprc`) |
| Spam | Spam-Ordner geprüft? |
| SMTP | Ausgehende SMTP-Verbindungen erlaubt? (Port 587/465) |
### Format beim Generieren überschreiben
Du kannst das Format unabhängig von der Konfiguration wählen:
```bash
sudo /opt/adguard-shield/adguard-shield report-generate txt
sudo /opt/adguard-shield/adguard-shield report-generate html /tmp/report.html
```

View File

@@ -1,285 +1,543 @@
# Tipps & Troubleshooting
## Best Practices
Dieses Dokument hilft beim Eingrenzen typischer Probleme im Betrieb. Die Reihenfolge ist bewusst praktisch: erst prüfen, ob der Dienst läuft, dann API, Firewall, Sperren, Listen und optionale Module.
- **Erst immer im Dry-Run testen**, bevor der scharfe Modus aktiviert wird
```bash
sudo /opt/adguard-shield/adguard-shield.sh dry-run
```
- **Whitelist großzügig pflegen**: Eigene IPs, Router, wichtige Server nicht vergessen
- **Sperrdauer anpassen**: Für DDoS-artige Muster ggf. länger sperren
- **Logs regelmäßig prüfen**: Falsche Positive erkennen und Whitelist anpassen
- **Ban-History nutzen**: `history`-Befehl zeigt alle vergangenen Sperren — hilfreich um Muster zu erkennen
- **Log-Level auf DEBUG** setzen wenn etwas nicht funktioniert
## Erste Diagnose
## Häufige Probleme
### API-Verbindung schlägt fehl
Diese fünf Befehle liefern meistens schon genug Hinweise, um ein Problem einzugrenzen:
```bash
sudo /opt/adguard-shield/adguard-shield.sh test
# 1. Läuft der Service?
sudo systemctl status adguard-shield
# 2. Was sagt das Journal?
sudo journalctl -u adguard-shield --no-pager -n 100
# 3. Funktioniert die API?
sudo /opt/adguard-shield/adguard-shield test
# 4. Was ist der aktuelle Zustand?
sudo /opt/adguard-shield/adguard-shield status
# 5. Gibt es Warnungen oder Fehler?
sudo /opt/adguard-shield/adguard-shield logs --level warn --limit 100
```
**Mögliche Ursachen:**
- Falsche URL in `ADGUARD_URL` (Port prüfen!)
- Falsche Zugangsdaten (`ADGUARD_USER` / `ADGUARD_PASS`)
- AdGuard Home läuft nicht
- Firewall blockiert lokale Verbindung
- DNS-Auflösung des Hostnames fehlgeschlagen
- SSL/TLS-Zertifikatfehler (bei HTTPS)
Wenn du aktuelle Queries und den Echtzeit-Zustand sehen willst:
#### Schritt-für-Schritt Diagnose
**1. Base-URL Erreichbarkeit prüfen (ohne Auth):**
```bash
# Vollständige Diagnose mit HTTP-Headern und Verbindungsdetails
curl -ikv https://dns1.domain.com 2>&1
# Nur HTTP-Statuscode prüfen (schnell)
curl -s -o /dev/null -w "%{http_code}\n" -k https://dns1.domain.com
sudo /opt/adguard-shield/adguard-shield live
```
> `-i` zeigt HTTP-Response-Header, `-k` ignoriert SSL-Fehler, `-v` zeigt Verbindungsdetails (DNS, TLS-Handshake, etc.)
---
## Service startet nicht
### Prüfen
**2. DNS-Auflösung testen:**
```bash
# Hostname auflösen
dig +short dns1.domain.com
# Oder mit nslookup
nslookup dns1.domain.com
sudo systemctl status adguard-shield
sudo journalctl -u adguard-shield --no-pager -n 100
```
**3. Port-Erreichbarkeit testen:**
```bash
# TCP-Verbindung zum Port prüfen (z.B. Port 3000)
nc -zv 127.0.0.1 3000
### Typische Ursachen
# Oder mit curl
curl -v telnet://127.0.0.1:3000
```
| Ursache | Lösung |
|---|---|
| Konfigurationsdatei fehlt | `/opt/adguard-shield/adguard-shield.conf` prüfen |
| Falsche Dateirechte | `sudo chmod 600 /opt/adguard-shield/adguard-shield.conf` |
| Binary fehlt oder nicht ausführbar | `ls -l /opt/adguard-shield/adguard-shield` prüfen |
| Systempakete fehlen | `which iptables ip6tables ipset systemctl` prüfen |
| 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 |
**4. API-Endpunkt mit Authentifizierung testen:**
```bash
# Query-Log abfragen (mit Auth + Response-Header)
curl -i -u admin:passwort https://dns1.domain.com/control/querylog?limit=1
# Nur HTTP-Status zurückgeben
curl -s -o /dev/null -w "%{http_code}\n" -u admin:passwort https://dns1.domain.com/control/querylog?limit=1
```
**5. AdGuard Home Status-API prüfen:**
```bash
# Allgemeinen Status abfragen (benötigt keine Auth)
curl -ik https://dns1.domain.com/control/status
```
#### Typische Fehlercodes
| HTTP-Code | Bedeutung | Lösung |
|-----------|-----------|--------|
| `000` | Keine Verbindung | Host nicht erreichbar, DNS-Fehler oder Firewall |
| `200` | Erfolg | Alles in Ordnung ✅ |
| `301/302` | Weiterleitung | URL prüfen — evtl. fehlt `https://` oder Port |
| `401` | Nicht autorisiert | `ADGUARD_USER` / `ADGUARD_PASS` prüfen |
| `403` | Zugriff verweigert | Zugangsdaten oder IP-Beschränkung in AdGuard Home |
| `404` | Nicht gefunden | URL falsch oder AdGuard Home Version zu alt |
| `502/503` | Service nicht verfügbar | AdGuard Home läuft nicht oder wird gerade neu gestartet |
#### curl Exit-Codes
| Exit-Code | Bedeutung |
|-----------|-----------|
| `6` | DNS-Auflösung fehlgeschlagen — Hostname prüfen |
| `7` | Verbindung abgelehnt — Läuft AdGuard Home? Port korrekt? |
| `28` | Timeout — Host nicht erreichbar oder Firewall blockiert |
| `35` | SSL/TLS-Handshake fehlgeschlagen |
| `51` | SSL-Zertifikat: Hostname stimmt nicht überein |
| `60` | SSL-Zertifikat: nicht vertrauenswürdig (selbstsigniert?) |
> **Tipp:** Bei selbstsignierten Zertifikaten `-k` an curl anhängen, um SSL-Fehler zu ignorieren. AdGuard Shield verwendet intern automatisch `-k` bei der API-Kommunikation.
**Lösung:** URL und Zugangsdaten in der Konfiguration anpassen:
```bash
sudo nano /opt/adguard-shield/adguard-shield.conf
sudo systemctl restart adguard-shield
```
### iptables-Fehler: "Permission denied"
Das Script muss als **root** laufen, da iptables Root-Rechte benötigt.
### Nützliche Prüfbefehle
```bash
sudo /opt/adguard-shield/adguard-shield.sh start
```
### Client wird fälschlich gesperrt
1. Client sofort entsperren:
```bash
sudo /opt/adguard-shield/adguard-shield.sh unban 192.168.1.100
```
2. In der Ban-History prüfen, warum gesperrt wurde:
```bash
sudo /opt/adguard-shield/adguard-shield.sh history | grep 192.168.1.100
```
3. Offense-Zähler für die IP zurücksetzen (damit die progressive Sperre wieder bei Stufe 1 beginnt):
```bash
sudo /opt/adguard-shield/adguard-shield.sh reset-offenses 192.168.1.100
```
4. IP zur Whitelist hinzufügen in `adguard-shield.conf`
5. Service neustarten:
```bash
sudo systemctl restart adguard-shield
```
### Client wurde permanent gesperrt (Progressive Sperren)
Wenn eine IP die maximale Stufe der progressiven Sperren erreicht hat, wird sie permanent gesperrt und nicht automatisch aufgehoben.
1. IP entsperren:
```bash
sudo /opt/adguard-shield/adguard-shield.sh unban 192.168.1.100
```
2. Offense-Zähler zurücksetzen:
```bash
sudo /opt/adguard-shield/adguard-shield.sh reset-offenses 192.168.1.100
```
3. Prüfen ob die IP auf die Whitelist gehört, oder die Progressive-Ban-Einstellungen anpassen (`PROGRESSIVE_BAN_MAX_LEVEL` erhöhen oder auf `0` setzen für keine permanenten Sperren)
### Sperren überleben Reboot nicht
Das ist normal — iptables-Regeln sind flüchtig. Der **Service** erstellt die Chain beim Start automatisch neu. Aktive Sperren aus dem State-Verzeichnis werden aber nicht automatisch wiederhergestellt.
**Optionen:**
- `iptables-persistent` installieren (`apt install iptables-persistent`)
- Oder den State beim Boot wiederherstellen lassen (Feature-Idee)
### Zu viele false positives
- `RATE_LIMIT_MAX_REQUESTS` erhöhen (z.B. 50 oder 100)
- `RATE_LIMIT_WINDOW` vergrößern (z.B. 120 Sekunden)
- Windows-Clients fragen manche Domains von Natur aus sehr oft an — Whitelist nutzen
### Subdomain-Flood-Erkennung sperrt legitime Clients
Manche Dienste (z.B. CDNs, Cloud-Dienste, Microsoft 365) nutzen von Natur aus viele verschiedene Subdomains. Falls ein legitimer Client fälschlicherweise durch die Subdomain-Flood-Erkennung gesperrt wird:
1. Client sofort entsperren:
```bash
sudo /opt/adguard-shield/adguard-shield.sh unban <IP>
```
2. Schwellwert erhöhen — z.B. von 50 auf 100 oder 150:
```bash
SUBDOMAIN_FLOOD_MAX_UNIQUE=100
```
3. Zeitfenster vergrößern — z.B. auf 120 Sekunden:
```bash
SUBDOMAIN_FLOOD_WINDOW=120
```
4. Oder die IP zur Whitelist hinzufügen
5. Im Zweifelsfall die Erkennung temporär deaktivieren:
```bash
SUBDOMAIN_FLOOD_ENABLED=false
```
> **Tipp:** Im Dry-Run-Modus (`sudo /opt/adguard-shield/adguard-shield.sh dry-run`) kann man beobachten, welche Clients die Subdomain-Flood-Erkennung auslösen würden, ohne sie wirklich zu sperren.
### Monitor startet nicht (PID-File)
```bash
# Altes PID-File entfernen
sudo rm -f /var/run/adguard-shield.pid
sudo systemctl start adguard-shield
```
### Service ist ausgefallen und startet nicht mehr
Wenn systemd das Restart-Limit erreicht hat (z.B. `"Start request repeated too quickly"`), hilft der **Watchdog** — er prüft alle 5 Minuten ob der Service läuft und startet ihn automatisch neu.
**Watchdog-Status prüfen:**
```bash
# Timer-Status anzeigen
sudo systemctl status adguard-shield-watchdog.timer
# Letzte Watchdog-Ausführungen anzeigen
sudo systemctl list-timers adguard-shield-watchdog.timer
# Watchdog-Logs prüfen
sudo journalctl -u adguard-shield-watchdog.service --no-pager -n 20
```
**Manuelles Recovery (sofort):**
```bash
# systemd-Fehlerzähler zurücksetzen und Service starten
sudo systemctl reset-failed adguard-shield.service
sudo systemctl start adguard-shield.service
```
**Watchdog nachträglich aktivieren:**
```bash
sudo systemctl enable adguard-shield-watchdog.timer
sudo systemctl start adguard-shield-watchdog.timer
```
> **Hinweis:** Der Watchdog sendet automatisch eine Benachrichtigung (falls `NOTIFY_ENABLED=true`), wenn er den Service wiederbeleben muss oder die Recovery fehlschlägt.
## Update durchführen
```bash
# Repository aktualisieren
cd /tmp/adguard-shield
git pull
# Update ausführen (Konfig wird automatisch migriert, Service neu gestartet)
sudo bash install.sh update
```
**Was passiert beim Update:**
- Alle Scripts werden aktualisiert
- Konfiguration wird als `adguard-shield.conf.old` gesichert
- Neue Konfigurationsparameter werden automatisch zur bestehenden Konfig ergänzt
- Bestehende Einstellungen bleiben erhalten
- Service wird per `daemon-reload` neu geladen und automatisch neu gestartet
## Deinstallation
Ab Version 0.6 gibt es einen eigenständigen Uninstaller im Installationsverzeichnis. Die Deinstallation kann daher jederzeit durchgeführt werden, **ohne die originalen Installationsdateien (install.sh) behalten zu müssen**:
```bash
# Empfohlen: direkt aus dem Installationsverzeichnis ausführen
sudo bash /opt/adguard-shield/uninstall.sh
# Alternativ: über den Installer (sofern noch vorhanden)
sudo bash install.sh uninstall
```
Beide Wege sind gleichwertig — `install.sh uninstall` delegiert intern an `/opt/adguard-shield/uninstall.sh`.
Oder manuell:
```bash
sudo systemctl stop adguard-shield
sudo systemctl disable adguard-shield
sudo systemctl stop adguard-shield-watchdog.timer
sudo systemctl disable adguard-shield-watchdog.timer
sudo /opt/adguard-shield/iptables-helper.sh remove
sudo rm -rf /opt/adguard-shield
sudo rm -f /etc/systemd/system/adguard-shield.service
sudo rm -f /etc/systemd/system/adguard-shield-watchdog.service
sudo rm -f /etc/systemd/system/adguard-shield-watchdog.timer
ls -l /opt/adguard-shield/adguard-shield
ls -l /opt/adguard-shield/adguard-shield.conf
which iptables ip6tables ipset systemctl
sudo systemctl daemon-reload
```
## Voraussetzungen
---
Folgende Pakete werden für den Betrieb benötigt und bei der Installation automatisch installiert:
## Verbindung zu AdGuard Home schlägt fehl
| Paket | Zweck |
|-------|-------|
| `curl` | API-Kommunikation mit AdGuard Home |
| `jq` | JSON-Verarbeitung der API-Antworten |
| `iptables` | Firewall-Regeln (IPv4 + IPv6) |
| `gawk` | Textverarbeitung in Scripts |
| `systemd` | Service-Management und Autostart |
### Test
Diese werden bei `sudo bash install.sh install` automatisch geprüft und bei Bedarf über den Paketmanager (`apt`, `dnf`, `yum`, `pacman`) nachinstalliert.
```bash
sudo /opt/adguard-shield/adguard-shield test
```
### Konfiguration prüfen
```bash
ADGUARD_URL="http://127.0.0.1:3000"
ADGUARD_USER="admin"
ADGUARD_PASS="..."
```
### Häufige Fehler und Lösungen
| Symptom | Mögliche Ursache | Lösung |
|---|---|---|
| HTTP 401/403 | Benutzername oder Passwort falsch | Zugangsdaten in der Konfiguration prüfen |
| HTTP 404 | Falsche URL oder falscher Port | URL und Port prüfen, AdGuard-Home-Weboberfläche testen |
| Timeout | Firewall, DNS-Problem oder falsche IP | Netzwerk und Erreichbarkeit prüfen |
| Connection refused | AdGuard Home läuft nicht oder anderer Port | `systemctl status AdGuardHome` prüfen |
| Keine Querylog-Einträge | Querylog deaktiviert oder leer | In AdGuard Home prüfen: Einstellungen > Querylog |
### Direkt testen (unabhängig von AdGuard Shield)
```bash
curl -k -u "admin:passwort" "http://127.0.0.1:3000/control/querylog?limit=1&response_status=all"
```
Passe URL und Zugangsdaten entsprechend an.
---
## Keine Sperren trotz vieler Anfragen
### Prüfen
```bash
sudo /opt/adguard-shield/adguard-shield live --once
sudo /opt/adguard-shield/adguard-shield history 50
sudo /opt/adguard-shield/adguard-shield logs --level debug --limit 100
```
### Mögliche Ursachen und Lösungen
| Ursache | Lösung |
|---|---|
| `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 |
| 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 |
| Querylog enthält die Anfragen nicht | In AdGuard Home prüfen, ob Querylog aktiviert ist |
| `DRY_RUN=true` ist gesetzt | In der Konfiguration auf `false` setzen |
**Wichtig bei Proxies und Forwardern:** Wenn AdGuard Home nur eine einzige interne IP sieht (z.B. die IP eines Routers oder Reverse Proxy), zählt AdGuard Shield auch nur diese IP. In solchen Setups muss die Architektur geprüft oder der Forwarder gewhitelistet werden.
---
## Zu viele Sperren
### Übersicht verschaffen
```bash
sudo /opt/adguard-shield/adguard-shield status
sudo /opt/adguard-shield/adguard-shield history 100
```
### Ursachen und Gegenmaßnahmen
| Ursache | Gegenmaßnahme |
|---|---|
| Legitimer Client fragt häufig dieselbe Domain | Client whitelisten oder `RATE_LIMIT_MAX_REQUESTS` erhöhen |
| Router/Resolver bündelt viele Clients | Router/Resolver in `WHITELIST` aufnehmen |
| CDN/App erzeugt viele Subdomains | `SUBDOMAIN_FLOOD_MAX_UNIQUE` erhöhen |
| Externe Blocklist ist sehr groß | `blocklist-status` prüfen und ggf. Liste anpassen |
| GeoIP Allowlist zu eng | Länder prüfen oder `GEOIP_MODE` wechseln |
### Falsch gesperrte IP freigeben
```bash
# Sperre aufheben
sudo /opt/adguard-shield/adguard-shield unban 192.168.1.100
# Offense-Zähler zurücksetzen (damit progressive Sperren nicht sofort eskalieren)
sudo /opt/adguard-shield/adguard-shield reset-offenses 192.168.1.100
```
### Dauerhaft ausnehmen
```bash
WHITELIST="127.0.0.1,::1,192.168.1.1,192.168.1.100"
```
Danach:
```bash
sudo systemctl restart adguard-shield
```
---
## Firewall prüfen
### Status über AdGuard Shield
```bash
sudo /opt/adguard-shield/adguard-shield firewall-status
```
### Direkte Prüfung mit Systembefehlen
```bash
# ipsets anzeigen
sudo ipset list adguard_shield_v4
sudo ipset list adguard_shield_v6
# iptables-Regeln anzeigen
sudo iptables -n -L ADGUARD_SHIELD --line-numbers -v
sudo ip6tables -n -L ADGUARD_SHIELD --line-numbers -v
# Prüfen, ob Chain in INPUT eingehängt ist
sudo iptables -n -L INPUT --line-numbers -v | grep ADGUARD
# Bei Docker Bridge: DOCKER-USER prüfen
sudo iptables -n -L DOCKER-USER --line-numbers -v | grep ADGUARD
```
### Firewall neu aufbauen
```bash
sudo /opt/adguard-shield/adguard-shield firewall-remove
sudo /opt/adguard-shield/adguard-shield firewall-create
sudo systemctl restart adguard-shield
```
Nach dem Neustart werden aktive Sperren aus SQLite wieder in die ipsets geschrieben.
---
## Sperren bleiben nach Ablauf aktiv
### Prüfen
```bash
sudo /opt/adguard-shield/adguard-shield status
sudo /opt/adguard-shield/adguard-shield history 100
```
Temporäre Sperren werden beim Start und während jedes Pollings auf Ablauf geprüft. Wenn eine Sperre als permanent angezeigt wird, wird sie nicht automatisch freigegeben.
### Permanente Sperren (gewollt)
| Typ | Warum permanent |
|---|---|
| DNS-Flood-Watchlist-Treffer | Sofortiger Permanent-Ban |
| Progressive-Ban auf Maximalstufe | Eskalation durch wiederholte Verstöße |
| Manuelle `ban`-Sperren | Manuell gesetzt, manuell aufheben |
| GeoIP-Sperren | Permanent bis Konfigurationsänderung |
| Externe Blocklist mit `BAN_DURATION=0` | Permanent bis IP aus Liste entfernt |
### Manuell freigeben
```bash
sudo /opt/adguard-shield/adguard-shield unban 192.168.1.100
```
---
## Dry-Run verwenden
Dry-Run ist ideal, um neue Konfigurationen zu prüfen, bevor sie produktiv gehen:
```bash
# Dry-Run starten (Strg+C zum Beenden)
sudo /opt/adguard-shield/adguard-shield dry-run
```
Währenddessen die Ergebnisse prüfen:
```bash
sudo /opt/adguard-shield/adguard-shield history 50
```
Im Dry-Run werden mögliche Sperren als `DRY` protokolliert. Es entstehen keine aktiven Sperren und keine Firewall-Änderungen.
---
## Externe Whitelist
### Status prüfen
```bash
sudo /opt/adguard-shield/adguard-shield whitelist-status
```
### Manuell synchronisieren
```bash
sudo /opt/adguard-shield/adguard-shield whitelist-sync
```
### Typische Probleme
| Problem | Lösung |
|---|---|
| URL nicht erreichbar | URL im Browser oder mit `curl` testen |
| Windows-Zeilenenden oder BOM | Datei in UTF-8 ohne BOM und mit `LF`-Zeilenenden speichern |
| Hostname nicht auflösbar | DNS-Auflösung prüfen, ggf. alternativen Hostnamen verwenden |
| Einträge enthalten Ports oder URLs | Nur IPs, CIDR-Netze und Hostnamen werden unterstützt |
| DNS liefert `0.0.0.0` | AdGuard blockiert den Host; Ausnahme in AdGuard Home einrichten |
### Erwartetes Listenformat
```text
192.168.1.100 # IPv4-Adresse
10.0.0.0/24 # CIDR-Netz
trusted.example.com # Hostname (wird per DNS aufgelöst)
# Kommentare sind erlaubt
```
---
## Externe Blocklist
### Status prüfen
```bash
sudo /opt/adguard-shield/adguard-shield blocklist-status
```
### Manuell synchronisieren
```bash
sudo /opt/adguard-shield/adguard-shield blocklist-sync
```
### Alle Blocklist-Sperren freigeben
```bash
sudo /opt/adguard-shield/adguard-shield blocklist-flush
```
### Zu viele IPs gesperrt?
1. `EXTERNAL_BLOCKLIST_URLS` prüfen: Welche Listen sind konfiguriert?
2. Liste manuell ansehen: Wie viele Einträge enthält sie?
3. Whitelist ergänzen: Eigene IPs sollten dort stehen.
4. `EXTERNAL_BLOCKLIST_NOTIFY=false` belassen, um den Benachrichtigungskanal nicht zu überfluten.
5. `EXTERNAL_BLOCKLIST_AUTO_UNBAN=true` setzen, damit entfernte Einträge automatisch freigegeben werden.
---
## GeoIP
### Status prüfen
```bash
sudo /opt/adguard-shield/adguard-shield geoip-status
```
### Einzelne IP prüfen
```bash
sudo /opt/adguard-shield/adguard-shield geoip-lookup 8.8.8.8
```
### Cache leeren
```bash
sudo /opt/adguard-shield/adguard-shield geoip-flush-cache
```
### Alle GeoIP-Sperren freigeben
```bash
sudo /opt/adguard-shield/adguard-shield geoip-flush
```
### Typische Probleme und Lösungen
| Problem | Lösung |
|---|---|
| Keine Länder erkannt | MaxMind-Key, MMDB-Pfad oder `geoiplookup`-Befehl prüfen |
| Private IPs werden nicht geprüft | `GEOIP_SKIP_PRIVATE=true` ist Standard und korrekt |
| Zu viele Länder gesperrt | `GEOIP_MODE` und `GEOIP_COUNTRIES` prüfen |
| Allowlist sperrt fast alles | Im Allowlist-Modus sind nur genannte Länder erlaubt; alle anderen werden gesperrt |
| Datenbank nicht gefunden | `GEOIP_LICENSE_KEY` oder `GEOIP_MMDB_PATH` setzen |
| Datenbank veraltet | `geoip-flush-cache` und Service neu starten |
### Ländercodes nachschlagen
Die GeoIP-Ländercodes folgen dem Standard ISO 3166-1 Alpha-2. Eine vollständige Liste findest du in der [ISO-3166-1-Kodierliste auf Wikipedia](https://de.wikipedia.org/wiki/ISO-3166-1-Kodierliste).
---
## Reports
### Status prüfen
```bash
sudo /opt/adguard-shield/adguard-shield report-status
```
### Funktionstest
```bash
# Testmail senden
sudo /opt/adguard-shield/adguard-shield report-test
# Text-Report in der Konsole ansehen
sudo /opt/adguard-shield/adguard-shield report-generate txt
```
### Keine Mail kommt an?
| Prüfpunkt | Befehl / Aktion |
|---|---|
| `REPORT_EMAIL_TO` gesetzt? | Konfiguration prüfen |
| `REPORT_MAIL_CMD` vorhanden? | `which msmtp` |
| Mailer für root konfiguriert? | `/root/.msmtprc` oder `/etc/msmtprc` prüfen |
| Cron installiert? | `sudo cat /etc/cron.d/adguard-shield-report` |
| Spam-Ordner geprüft? | E-Mail-Provider prüfen |
| SMTP-Port offen? | Ausgehende Verbindung auf Port 587/465 testen |
### Cron prüfen
```bash
sudo cat /etc/cron.d/adguard-shield-report
sudo /opt/adguard-shield/adguard-shield report-send
```
---
## Benachrichtigungen
### Prüfen
```bash
sudo /opt/adguard-shield/adguard-shield logs --level warn --limit 100
```
### Checkliste
| Prüfpunkt | Beschreibung |
|---|---|
| `NOTIFY_ENABLED=true` | Benachrichtigungen global aktiviert? |
| `NOTIFY_TYPE` | Korrekt geschrieben? (`ntfy`, `discord`, `slack`, `gotify`, `generic`) |
| Webhook-URL | Gesetzt und erreichbar? |
| Ntfy-Topic | Nicht leer? |
| Token | Gültig und nicht abgelaufen? |
| Netzwerk | Ausgehende HTTPS-Verbindungen möglich? |
| Modul-Schalter | `EXTERNAL_BLOCKLIST_NOTIFY` und `GEOIP_NOTIFY` separat prüfen |
Bei `generic` Webhook kannst du testweise einen lokalen HTTP-Empfänger oder einen Request-Inspector (z.B. webhook.site) verwenden, um den gesendeten Payload zu sehen.
---
## SQLite direkt auswerten
Für tiefergehende Analysen kannst du die SQLite-Datenbank direkt abfragen:
### Sperren nach Quelle und Grund
```bash
sudo sqlite3 /var/lib/adguard-shield/adguard-shield.db \
"SELECT source, reason, count(*) FROM active_bans GROUP BY source, reason ORDER BY count(*) DESC;"
```
### Letzte History-Einträge
```bash
sudo sqlite3 /var/lib/adguard-shield/adguard-shield.db \
"SELECT timestamp_text, action, client_ip, domain, reason FROM ban_history ORDER BY id DESC LIMIT 20;"
```
### Offense-Zähler
```bash
sudo sqlite3 /var/lib/adguard-shield/adguard-shield.db \
"SELECT client_ip, offense_level, last_offense FROM offense_tracking ORDER BY offense_level DESC;"
```
### Whitelist-Cache
```bash
sudo sqlite3 /var/lib/adguard-shield/adguard-shield.db \
"SELECT ip, source FROM whitelist_cache ORDER BY ip;"
```
### GeoIP-Cache
```bash
sudo sqlite3 /var/lib/adguard-shield/adguard-shield.db \
"SELECT ip, country_code FROM geoip_cache ORDER BY ip LIMIT 50;"
```
---
## Alte Shell-Artefakte entfernen
Wenn der Installer alte Dateien meldet, zuerst sauber migrieren. Typische alte Dateien:
| Datei | Funktion in der alten Version |
|---|---|
| `adguard-shield.sh` | Hauptskript |
| `iptables-helper.sh` | Firewall-Management |
| `external-blocklist-worker.sh` | Blocklist-Synchronisation |
| `external-whitelist-worker.sh` | Whitelist-Synchronisation |
| `geoip-worker.sh` | GeoIP-Prüfung |
| `offense-cleanup-worker.sh` | Offense-Bereinigung |
| `report-generator.sh` | Report-Erstellung |
| `unban-expired.sh` | Ablauf temporärer Sperren |
| `adguard-shield-watchdog.sh` | Watchdog-Skript |
Die Go-Version ersetzt diese Funktionen durch das eine Binary. Alte Worker sollten nicht parallel laufen.
Details zur Migration stehen in der [Update-Anleitung](update.md).
---
## Service hart zurücksetzen
Wenn der Zustand unklar ist und ein sauberer Neustart nötig ist:
```bash
# Service stoppen
sudo systemctl stop adguard-shield
# Firewall-Struktur entfernen
sudo /opt/adguard-shield/adguard-shield firewall-remove
# Service neu starten (baut Firewall aus SQLite wieder auf)
sudo systemctl start adguard-shield
# Status prüfen
sudo /opt/adguard-shield/adguard-shield status
```
Das entfernt die Firewall-Struktur und lässt den Daemon sie beim Start wieder aus dem SQLite-State aufbauen. Aktive Sperren bleiben in der Datenbank erhalten.
---
## Deinstallation
### Konfiguration behalten
```bash
sudo /opt/adguard-shield/adguard-shield uninstall --keep-config
```
### Alles entfernen
```bash
sudo /opt/adguard-shield/adguard-shield uninstall
```
Ohne `--keep-config` werden Installationsverzeichnis, State-Verzeichnis und Logdatei entfernt.
---
## Zusammenfassung: Wichtigste Diagnosebefehle
| Befehl | Zweck |
|---|---|
| `systemctl status adguard-shield` | Service-Status prüfen |
| `journalctl -u adguard-shield -n 100` | Systemd-Journal ansehen |
| `test` | API-Verbindung prüfen |
| `status` | Aktuellen Zustand und aktive Sperren anzeigen |
| `live` | Echtzeit-Ansicht mit Queries, Sperren und Logs |
| `history 100` | Ban-History anzeigen |
| `logs --level warn --limit 100` | Warnungen und Fehler anzeigen |
| `firewall-status` | Firewall-Regeln und ipsets anzeigen |
| `dry-run` | Konfiguration testen ohne echte Sperren |

View File

@@ -1,77 +1,284 @@
# Update-Anleitung
## Voraussetzungen
AdGuard Shield wird in der Go-Version über das Binary selbst installiert und aktualisiert. Es gibt kein `install.sh` und kein `update`-Shellskript mehr.
- AdGuard Shield ist bereits installiert (`/opt/adguard-shield/`)
- Git ist installiert (`sudo apt install git`)
- Zugriff auf den Server per SSH mit Root-Rechten
## Update durchführen
### 1. Git-Repository aktualisieren
Wechsle in das Verzeichnis, in dem du das Repository geklont hast, und hole die neueste Version:
## Kurzfassung
```bash
cd /pfad/zum/adguard-shield
git pull
# Neues Linux-Binary bereitstellen
chmod +x ./adguard-shield
# Update durchführen
sudo ./adguard-shield update
```
> **Hinweis:** Falls du das Repository z.B. nach `/opt/adguard-shield-repo` geklont hast:
> ```bash
> cd /opt/adguard-shield-repo
> git pull
> ```
Am Ende fragt der Updater, ob AdGuard Shield direkt neu gestartet werden soll.
### 2. Update-Script ausführen
### Nach dem Update prüfen
```bash
sudo bash install.sh update
sudo /opt/adguard-shield/adguard-shield install-status
sudo /opt/adguard-shield/adguard-shield status
sudo journalctl -u adguard-shield --no-pager -n 50
```
Das Update-Script macht automatisch folgendes:
---
1. **Abhängigkeiten prüfen** — Fehlende Pakete werden nachinstalliert
2. **Scripts aktualisieren** — Alle `.sh`-Dateien werden nach `/opt/adguard-shield/` kopiert
3. **Konfigurations-Migration** — Neue Parameter werden automatisch zur bestehenden Konfiguration hinzugefügt, bestehende Einstellungen bleiben **unverändert**
4. **Backup erstellen** — Die alte Konfiguration wird als `adguard-shield.conf.old` gesichert
5. **Service aktualisieren** — Die systemd Service-Datei und Watchdog-Dateien werden aktualisiert und `daemon-reload` ausgeführt
6. **Watchdog aktivieren** — Der Watchdog-Timer wird automatisch aktiviert (falls noch nicht aktiv)
7. **Service neustarten** — Der Service wird automatisch neu gestartet (falls er vorher lief)
## Neues Binary beziehen
### 3. Neue Parameter prüfen (optional)
Du brauchst ein fertiges Linux-Binary. Das kann aus einem Release, aus CI oder aus einem lokalen Build kommen.
Nach dem Update empfiehlt es sich, eventuell neu hinzugefügte Konfigurationsparameter zu prüfen:
### Variante A: Release-Binary herunterladen
```bash
curl -fL -o adguard-shield-linux-amd64.tar.gz \
https://git.techniverse.net/scriptos/adguard-shield/releases/download/v1.0.0/adguard-shield-linux-amd64.tar.gz
tar -xzf adguard-shield-linux-amd64.tar.gz
chmod +x ./adguard-shield
```
### Variante B: Lokal mit Go bauen
```bash
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o adguard-shield ./cmd/adguard-shieldd
```
### Variante C: Per Docker bauen (ohne lokales Go)
```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
```
Auf dem Zielserver muss Go nicht installiert sein, wenn dort nur das fertige Binary ausgeführt wird.
---
## Was `update` macht
Der Update-Befehl nutzt intern dieselbe Routine wie die Installation:
| Schritt | Aktion |
|---:|---|
| 1 | Linux- und Root-Rechte prüfen |
| 2 | Auf alte Shell-Artefakte prüfen |
| 3 | Systemabhängigkeiten prüfen (sofern nicht `--skip-deps`) |
| 4 | Installationsverzeichnis sicherstellen |
| 5 | Neues Binary nach `/opt/adguard-shield/adguard-shield` kopieren |
| 6 | Konfiguration migrieren (vorhandene Werte behalten, neue ergänzen) |
| 7 | systemd-Service neu schreiben |
| 8 | `systemctl daemon-reload` |
| 9 | Autostart aktivieren (sofern nicht `--no-enable`) |
| 10 | Nachfrage: Service direkt neu starten |
---
## Konfigurationsmigration
Vorhandene Werte bleiben erhalten. Neue Parameter werden am Ende der Datei ergänzt. Der Installer überschreibt keine bestehenden Einstellungen.
Wenn eine Migration nötig ist:
| Datei | Inhalt |
|---|---|
| `adguard-shield.conf` | Aktualisierte Konfiguration mit alten + neuen Parametern |
| `adguard-shield.conf.old` | Backup der vorherigen Datei |
### Änderungen prüfen
```bash
sudo diff -u /opt/adguard-shield/adguard-shield.conf.old /opt/adguard-shield/adguard-shield.conf
```
Falls `diff` keine `.old`-Datei findet, war keine Konfigurationsmigration nötig.
### Neue Parameter prüfen
Nach dem Update solltest du die neu ergänzten Parameter überprüfen und bei Bedarf anpassen:
```bash
sudo nano /opt/adguard-shield/adguard-shield.conf
```
Falls etwas nicht stimmt, kann das Backup wiederhergestellt werden:
---
## Update-Optionen
### Update mit Service-Neustart
Wenn der Service nach dem Update direkt laufen soll, bestätige die Nachfrage am Ende mit `j`.
Wenn du vorher manuell prüfen möchtest:
```bash
sudo ./adguard-shield update
sudo /opt/adguard-shield/adguard-shield test
sudo /opt/adguard-shield/adguard-shield dry-run
sudo systemctl restart adguard-shield
```
### Update ohne Paketprüfung
```bash
sudo ./adguard-shield update --skip-deps
```
Sinnvoll, wenn `iptables`, `ip6tables`, `ipset` und `systemctl` bereits vorhanden sind oder die Paketinstallation nicht über `apt-get` laufen soll.
### Update mit expliziter Konfigurationsquelle
```bash
sudo ./adguard-shield update --config-source ./adguard-shield.conf
```
### Update in anderem Installationsverzeichnis
```bash
sudo ./adguard-shield update --install-dir /opt/adguard-shield-test
```
**Hinweis:** Die systemd-Unit heißt weiterhin `adguard-shield.service`. Mehrere parallele produktive Installationen über dieselbe Unit sind nicht vorgesehen.
---
## Migration von der alten Shell-Version
Die Go-Version erkennt alte Shell-Artefakte und bricht ab, wenn sie noch vorhanden sind.
### Typische alte Artefakte
| Datei | Funktion in der alten Version |
|---|---|
| `adguard-shield.sh` | Hauptskript |
| `iptables-helper.sh` | Firewall-Management |
| `external-blocklist-worker.sh` | Blocklist-Synchronisation |
| `external-whitelist-worker.sh` | Whitelist-Synchronisation |
| `geoip-worker.sh` | GeoIP-Prüfung |
| `offense-cleanup-worker.sh` | Offense-Bereinigung |
| `report-generator.sh` | Report-Erstellung |
| `unban-expired.sh` | Ablauf temporärer Sperren |
| `adguard-shield-watchdog.service` | Watchdog-Service |
| `adguard-shield-watchdog.timer` | Watchdog-Timer |
### Warum bricht der Installer ab?
Die alte und die neue Version würden sonst dieselbe Firewall, dieselbe Konfiguration und dieselben Sperren verwalten. Das kann zu schwer nachvollziehbaren Zuständen führen, bei denen zwei Implementierungen sich gegenseitig die Regeln überschreiben.
### Empfohlener Migrationsablauf
```bash
# 1. Konfiguration sichern
sudo cp /opt/adguard-shield/adguard-shield.conf /root/adguard-shield.conf.backup
# 2. Alte Shell-Version mit deren Uninstaller entfernen
# (dabei Konfiguration behalten, falls der alte Uninstaller diese Option anbietet)
# 3. Neues Go-Binary installieren und alte Konfiguration als Quelle nutzen
sudo ./adguard-shield install --config-source /root/adguard-shield.conf.backup
# 4. API-Verbindung prüfen
sudo /opt/adguard-shield/adguard-shield test
# 5. Dry-Run: prüfen, was gesperrt würde
sudo /opt/adguard-shield/adguard-shield dry-run
# 6. Produktiven Service starten
sudo systemctl start adguard-shield
sudo systemctl status adguard-shield
```
**Wichtig:** Wenn der Go-Installer Legacy-Dateien meldet, entferne nur die gemeldeten alten Artefakte der Shell-Version. Keine fremden Firewall-Regeln oder unrelated Dateien löschen.
---
## Nach dem Update prüfen
### Installation
```bash
sudo /opt/adguard-shield/adguard-shield install-status
```
### Service
```bash
sudo systemctl status adguard-shield
sudo journalctl -u adguard-shield --no-pager -n 100
```
### API-Verbindung
```bash
sudo /opt/adguard-shield/adguard-shield test
```
### Laufzeitstatus
```bash
sudo /opt/adguard-shield/adguard-shield status
sudo /opt/adguard-shield/adguard-shield live --once
```
### Firewall
```bash
sudo /opt/adguard-shield/adguard-shield firewall-status
```
---
## Rollback
Ein Rollback besteht aus zwei Teilen: altes Binary wieder bereitstellen und passende Konfiguration verwenden.
### Schritt-für-Schritt
```bash
# 1. Service stoppen
sudo systemctl stop adguard-shield
# 2. Altes Binary wiederherstellen
sudo cp ./adguard-shield-alte-version /opt/adguard-shield/adguard-shield
sudo chmod +x /opt/adguard-shield/adguard-shield
# 3. Service starten
sudo systemctl start adguard-shield
```
### Konfiguration zurücksetzen (optional)
```bash
sudo cp /opt/adguard-shield/adguard-shield.conf.old /opt/adguard-shield/adguard-shield.conf
sudo systemctl restart adguard-shield
```
## Kurzfassung (Copy & Paste)
**Hinweis:** SQLite-Schema-Migrationen sind aktuell sehr konservativ. Trotzdem solltest du vor größeren Updates ein Backup der Datenbank erstellen, wenn dir History und aktive Sperren wichtig sind.
---
## Backup vor größeren Updates
```bash
cd /pfad/zum/adguard-shield
git pull
sudo bash install.sh update
# Service kurz stoppen für konsistentes Backup
sudo systemctl stop adguard-shield
# Konfiguration und Datenbank sichern
sudo cp /opt/adguard-shield/adguard-shield.conf /root/adguard-shield.conf.$(date +%F)
sudo cp /var/lib/adguard-shield/adguard-shield.db /root/adguard-shield.db.$(date +%F)
# Service wieder starten
sudo systemctl start adguard-shield
```
## Versionsprüfung
### WAL-Dateien beachten
Installierte Version anzeigen:
Bei laufendem SQLite mit WAL können zusätzliche Dateien existieren:
```bash
sudo /opt/adguard-shield/adguard-shield.sh status
```
| Datei | Beschreibung |
|---|---|
| `adguard-shield.db` | Hauptdatenbank |
| `adguard-shield.db-wal` | Write-Ahead-Log (enthält noch nicht in die Hauptdatei geschriebene Daten) |
| `adguard-shield.db-shm` | Shared-Memory-Datei |
Oder über den Installer:
```bash
sudo bash install.sh status
```
Am saubersten ist ein kurzer Service-Stop während des Backups. So wird sichergestellt, dass alle WAL-Einträge in die Hauptdatei geschrieben werden.

View File

@@ -1,845 +0,0 @@
#!/bin/bash
###############################################################################
# AdGuard Shield - Externer Blocklist-Worker
# Lädt externe IP-Blocklisten herunter und sperrt/entsperrt IPs automatisch.
# Wird als Hintergrundprozess vom Hauptscript gestartet.
#
# Autor: Patrick Asmus
# E-Mail: support@techniverse.net
# Datum: 2026-03-03
# Lizenz: MIT
###############################################################################
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_FILE="${SCRIPT_DIR}/adguard-shield.conf"
# ─── Konfiguration laden ───────────────────────────────────────────────────────
if [[ ! -f "$CONFIG_FILE" ]]; then
echo "FEHLER: Konfigurationsdatei nicht gefunden: $CONFIG_FILE" >&2
exit 1
fi
# shellcheck source=adguard-shield.conf
source "$CONFIG_FILE"
# ─── Worker PID-File ──────────────────────────────────────────────────────────
WORKER_PID_FILE="/var/run/adguard-blocklist-worker.pid"
# ─── Logging (eigene Funktion, nutzt gleiche Log-Datei) ───────────────────────
declare -A LOG_LEVELS=([DEBUG]=0 [INFO]=1 [WARN]=2 [ERROR]=3)
log() {
local level="$1"
shift
local message="$*"
local configured_level="${LOG_LEVEL:-INFO}"
if [[ ${LOG_LEVELS[$level]:-1} -ge ${LOG_LEVELS[$configured_level]:-1} ]]; then
local timestamp
timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
local log_entry="[$timestamp] [$level] [BLOCKLIST-WORKER] $message"
echo "$log_entry" | tee -a "$LOG_FILE" >&2
fi
}
# ─── Ban-History ─────────────────────────────────────────────────────────────
log_ban_history() {
local action="$1"
local client_ip="$2"
local reason="${3:-external-blocklist}"
local timestamp
timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
if [[ ! -f "$BAN_HISTORY_FILE" ]]; then
echo "# AdGuard Shield - Ban History" > "$BAN_HISTORY_FILE"
echo "# Format: ZEITSTEMPEL | AKTION | CLIENT-IP | DOMAIN | ANFRAGEN | SPERRDAUER | PROTOKOLL | GRUND" >> "$BAN_HISTORY_FILE"
echo "#──────────────────────────────────────────────────────────────────────────────────────────────────" >> "$BAN_HISTORY_FILE"
fi
local duration="permanent"
[[ "$EXTERNAL_BLOCKLIST_BAN_DURATION" -gt 0 ]] && duration="${EXTERNAL_BLOCKLIST_BAN_DURATION}s"
printf "%-19s | %-6s | %-39s | %-30s | %-8s | %-10s | %-10s | %s\n" \
"$timestamp" "$action" "$client_ip" "-" "-" "$duration" "-" "$reason" \
>> "$BAN_HISTORY_FILE"
}
# ─── Verzeichnisse erstellen ──────────────────────────────────────────────────
init_directories() {
mkdir -p "$EXTERNAL_BLOCKLIST_CACHE_DIR"
mkdir -p "$STATE_DIR"
mkdir -p "$(dirname "$LOG_FILE")"
}
# ─── Whitelist Prüfung ───────────────────────────────────────────────────────
is_whitelisted() {
local ip="$1"
IFS=',' read -ra wl_entries <<< "$WHITELIST"
for entry in "${wl_entries[@]}"; do
entry=$(echo "$entry" | xargs) # trim
if [[ "$ip" == "$entry" ]]; then
return 0
fi
done
# Externe Whitelist prüfen (aufgelöste IPs aus dem Whitelist-Worker)
local ext_wl_file="${EXTERNAL_WHITELIST_CACHE_DIR:-/var/lib/adguard-shield/external-whitelist}/resolved_ips.txt"
if [[ -f "$ext_wl_file" ]] && grep -qxF "$ip" "$ext_wl_file" 2>/dev/null; then
return 0
fi
return 1
}
# ─── iptables Chain Setup ────────────────────────────────────────────────────
setup_iptables_chain() {
# IPv4 Chain erstellen falls nicht vorhanden
if ! iptables -n -L "$IPTABLES_CHAIN" &>/dev/null; then
log "INFO" "Erstelle iptables Chain: $IPTABLES_CHAIN (IPv4)"
iptables -N "$IPTABLES_CHAIN"
for port in $BLOCKED_PORTS; do
iptables -I INPUT -p tcp --dport "$port" -j "$IPTABLES_CHAIN"
iptables -I INPUT -p udp --dport "$port" -j "$IPTABLES_CHAIN"
done
fi
# IPv6 Chain erstellen falls nicht vorhanden
if ! ip6tables -n -L "$IPTABLES_CHAIN" &>/dev/null; then
log "INFO" "Erstelle ip6tables Chain: $IPTABLES_CHAIN (IPv6)"
ip6tables -N "$IPTABLES_CHAIN"
for port in $BLOCKED_PORTS; do
ip6tables -I INPUT -p tcp --dport "$port" -j "$IPTABLES_CHAIN"
ip6tables -I INPUT -p udp --dport "$port" -j "$IPTABLES_CHAIN"
done
fi
}
# ─── IP sperren ──────────────────────────────────────────────────────────────
ban_ip() {
local ip="$1"
local state_file="${STATE_DIR}/ext_${ip//[:\/]/_}.ban"
# Bereits gesperrt?
if [[ -f "$state_file" ]]; then
# iptables-Regel prüfen und ggf. nachziehen (z.B. nach Neustart verloren gegangen)
if [[ "$ip" == *:* ]]; then
if ! ip6tables -C "$IPTABLES_CHAIN" -s "$ip" -j DROP 2>/dev/null; then
ip6tables -I "$IPTABLES_CHAIN" -s "$ip" -j DROP 2>/dev/null || true
fi
else
if ! iptables -C "$IPTABLES_CHAIN" -s "$ip" -j DROP 2>/dev/null; then
iptables -I "$IPTABLES_CHAIN" -s "$ip" -j DROP 2>/dev/null || true
fi
fi
log "DEBUG" "IP $ip bereits über externe Blocklist gesperrt"
return 0
fi
# Nicht auch vom Hauptscript gesperrt? (State-Datei ohne ext_ Prefix)
local main_state_file="${STATE_DIR}/${ip//[:\/]/_}.ban"
if [[ -f "$main_state_file" ]]; then
log "DEBUG" "IP $ip bereits vom Rate-Limiter gesperrt - überspringe"
return 0
fi
if [[ "$DRY_RUN" == "true" ]]; then
log "WARN" "[DRY-RUN] WÜRDE sperren (externe Blocklist): $ip"
log_ban_history "DRY" "$ip" "external-blocklist-dry-run"
return 0
fi
log "WARN" "SPERRE IP (externe Blocklist): $ip"
# iptables-Regel setzen
if [[ "$ip" == *:* ]]; then
ip6tables -I "$IPTABLES_CHAIN" -s "$ip" -j DROP 2>/dev/null || true
else
iptables -I "$IPTABLES_CHAIN" -s "$ip" -j DROP 2>/dev/null || true
fi
# State speichern
local ban_until_epoch="0"
local ban_until_display="permanent"
if [[ "$EXTERNAL_BLOCKLIST_BAN_DURATION" -gt 0 ]]; then
ban_until_epoch=$(date -d "+${EXTERNAL_BLOCKLIST_BAN_DURATION} seconds" '+%s' 2>/dev/null \
|| date -v "+${EXTERNAL_BLOCKLIST_BAN_DURATION}S" '+%s')
ban_until_display=$(date -d "@$ban_until_epoch" '+%Y-%m-%d %H:%M:%S' 2>/dev/null \
|| date -r "$ban_until_epoch" '+%Y-%m-%d %H:%M:%S')
fi
cat > "$state_file" << EOF
CLIENT_IP=$ip
DOMAIN=-
COUNT=-
BAN_TIME=$(date '+%Y-%m-%d %H:%M:%S')
BAN_UNTIL_EPOCH=$ban_until_epoch
BAN_UNTIL=$ban_until_display
SOURCE=external-blocklist
EOF
log_ban_history "BAN" "$ip" "external-blocklist"
# Benachrichtigung senden (nur wenn EXTERNAL_BLOCKLIST_NOTIFY=true)
if [[ "$NOTIFY_ENABLED" == "true" && "${EXTERNAL_BLOCKLIST_NOTIFY:-false}" == "true" ]]; then
send_notification "ban" "$ip"
fi
}
# ─── IP entsperren ───────────────────────────────────────────────────────────
unban_ip() {
local ip="$1"
local reason="${2:-external-blocklist-removed}"
local state_file="${STATE_DIR}/ext_${ip//[:\/]/_}.ban"
[[ -f "$state_file" ]] || return 0
log "INFO" "ENTSPERRE IP (externe Blocklist entfernt): $ip"
if [[ "$ip" == *:* ]]; then
ip6tables -D "$IPTABLES_CHAIN" -s "$ip" -j DROP 2>/dev/null || true
else
iptables -D "$IPTABLES_CHAIN" -s "$ip" -j DROP 2>/dev/null || true
fi
rm -f "$state_file"
log_ban_history "UNBAN" "$ip" "$reason"
if [[ "$NOTIFY_ENABLED" == "true" && "${EXTERNAL_BLOCKLIST_NOTIFY:-false}" == "true" ]]; then
send_notification "unban" "$ip"
fi
}
# ─── Hostname-Auflösung ──────────────────────────────────────────────────────
# Versucht den Hostnamen einer IP per Reverse-DNS aufzulösen
resolve_hostname() {
local ip="$1"
local hostname=""
if command -v dig &>/dev/null; then
hostname=$(dig +short -x "$ip" 2>/dev/null | head -1 | sed 's/\.$//')
fi
if [[ -z "$hostname" ]] && command -v host &>/dev/null; then
hostname=$(host "$ip" 2>/dev/null | awk '/domain name pointer/ {print $NF}' | sed 's/\.$//' | head -1)
fi
if [[ -z "$hostname" ]] && command -v getent &>/dev/null; then
hostname=$(getent hosts "$ip" 2>/dev/null | awk '{print $2}' | head -1)
fi
echo "${hostname:-(unbekannt)}"
}
# ─── Benachrichtigung ────────────────────────────────────────────────────────
send_notification() {
local action="$1"
local ip="$2"
# ntfy benötigt keine NOTIFY_WEBHOOK_URL, alle anderen schon
if [[ "${NOTIFY_TYPE:-generic}" != "ntfy" && -z "${NOTIFY_WEBHOOK_URL:-}" ]]; then
return
fi
local title
local message
local my_hostname
my_hostname=$(hostname)
local client_hostname
client_hostname=$(resolve_hostname "$ip")
if [[ "$action" == "ban" ]]; then
title="🚨 🛡️ AdGuard Shield"
message="🚫 AdGuard Shield Ban auf ${my_hostname} (Externe Blocklist)
---
IP: ${ip}
Hostname: ${client_hostname}
Whois: https://www.whois.com/whois/${ip}
AbuseIPDB: https://www.abuseipdb.com/check/${ip}"
else
title="✅ AdGuard Shield"
message="✅ AdGuard Shield Freigabe auf ${my_hostname} (Externe Blocklist)
---
IP: ${ip}
Hostname: ${client_hostname}
AbuseIPDB: https://www.abuseipdb.com/check/${ip}"
fi
case "${NOTIFY_TYPE:-generic}" in
discord)
local json_payload
json_payload=$(jq -nc --arg msg "$message" '{content: $msg}')
curl -s -H "Content-Type: application/json" \
-d "$json_payload" \
"$NOTIFY_WEBHOOK_URL" &>/dev/null &
;;
slack)
local json_payload
json_payload=$(jq -nc --arg msg "$message" '{text: $msg}')
curl -s -H "Content-Type: application/json" \
-d "$json_payload" \
"$NOTIFY_WEBHOOK_URL" &>/dev/null &
;;
gotify)
curl -s -X POST "$NOTIFY_WEBHOOK_URL" \
-F "title=${title}" \
-F "message=${message}" \
-F "priority=5" &>/dev/null &
;;
ntfy)
local ntfy_url="${NTFY_SERVER_URL:-https://ntfy.sh}"
local tags="rotating_light,blocklist"
[[ "$action" != "ban" ]] && tags="white_check_mark,blocklist"
# Ntfy fügt Emojis über Tags hinzu → Titel ohne führende Emojis setzen
local ntfy_title
case "$action" in
ban) ntfy_title="🛡️ AdGuard Shield" ;;
*) ntfy_title="AdGuard Shield" ;;
esac
local -a curl_args=(
-s -X POST "${ntfy_url}/${NTFY_TOPIC}"
-H "Title: ${ntfy_title}"
-H "Priority: ${NTFY_PRIORITY:-3}"
-H "Tags: ${tags}"
-d "${message}"
)
[[ -n "${NTFY_TOKEN:-}" ]] && curl_args+=(-H "Authorization: Bearer ${NTFY_TOKEN}")
curl "${curl_args[@]}" &>/dev/null &
;;
generic)
local json_payload
json_payload=$(jq -nc --arg msg "$message" --arg act "$action" --arg cl "$ip" \
'{message: $msg, action: $act, client: $cl, source: "external-blocklist"}')
curl -s -H "Content-Type: application/json" \
-d "$json_payload" \
"$NOTIFY_WEBHOOK_URL" &>/dev/null &
;;
esac
}
# ─── Externe Blocklist herunterladen ─────────────────────────────────────────
download_blocklist() {
local url="$1"
local index="$2"
local cache_file="${EXTERNAL_BLOCKLIST_CACHE_DIR}/blocklist_${index}.txt"
local etag_file="${EXTERNAL_BLOCKLIST_CACHE_DIR}/blocklist_${index}.etag"
local tmp_file="${EXTERNAL_BLOCKLIST_CACHE_DIR}/blocklist_${index}.tmp"
log "DEBUG" "Prüfe externe Blocklist: $url"
# HTTP-Header für bedingte Anfrage vorbereiten
local -a curl_args=(
-s
-L
--connect-timeout 10
--max-time 30
-o "$tmp_file"
-w "%{http_code}"
)
# ETag für If-None-Match Header nutzen falls vorhanden
if [[ -f "$etag_file" ]]; then
local stored_etag
stored_etag=$(cat "$etag_file")
curl_args+=(-H "If-None-Match: ${stored_etag}")
fi
# Download-Header separat abfragen für ETag
local http_code
http_code=$(curl "${curl_args[@]}" -D "${tmp_file}.headers" "$url" 2>/dev/null) || {
log "WARN" "Fehler beim Download der Blocklist: $url"
rm -f "$tmp_file" "${tmp_file}.headers"
return 1
}
# 304 Not Modified - keine Änderung
if [[ "$http_code" == "304" ]]; then
log "DEBUG" "Blocklist nicht geändert (HTTP 304): $url"
rm -f "$tmp_file" "${tmp_file}.headers"
return 1
fi
# Fehlerhafte HTTP-Codes
if [[ "$http_code" != "200" ]]; then
log "WARN" "Blocklist Download fehlgeschlagen (HTTP $http_code): $url"
rm -f "$tmp_file" "${tmp_file}.headers"
return 1
fi
# Neuen ETag speichern falls vorhanden
if [[ -f "${tmp_file}.headers" ]]; then
local new_etag
new_etag=$(grep -i '^etag:' "${tmp_file}.headers" | head -1 | sed 's/^[^:]*: *//;s/\r$//')
if [[ -n "$new_etag" ]]; then
echo "$new_etag" > "$etag_file"
fi
fi
rm -f "${tmp_file}.headers"
# Prüfen ob sich der Inhalt tatsächlich geändert hat (Fallback für Server ohne ETag)
if [[ -f "$cache_file" ]]; then
if diff -q "$tmp_file" "$cache_file" &>/dev/null; then
log "DEBUG" "Blocklist Inhalt unverändert: $url"
rm -f "$tmp_file"
return 1
fi
fi
# Neue Datei übernehmen
mv "$tmp_file" "$cache_file"
log "INFO" "Blocklist aktualisiert: $url"
return 0
}
# ─── Eintrag-Validierung ─────────────────────────────────────────────────────
# Prüft IPv4-Adresse mit optionalem CIDR (z.B. 1.2.3.4 oder 1.2.3.0/24)
_is_valid_ipv4() {
local ip="$1" addr="$1" prefix=""
if [[ "$ip" == */* ]]; then
addr="${ip%/*}"
prefix="${ip#*/}"
{ [[ "$prefix" =~ ^[0-9]+$ ]] && [[ "$prefix" -le 32 ]]; } || return 1
fi
local IFS='.'
read -ra _octets <<< "$addr"
[[ ${#_octets[@]} -eq 4 ]] || return 1
local o
for o in "${_octets[@]}"; do
[[ "$o" =~ ^[0-9]+$ ]] || return 1
[[ "$o" -le 255 ]] || return 1
done
return 0
}
# Prüft IPv6-Adresse mit optionalem CIDR (z.B. ::1 oder 2001:db8::/32)
# Fängt auch IPv4:Port-Kombinationen ab (z.B. 1.2.3.4:8080)
_is_valid_ipv6() {
local ip="$1" addr="$1"
if [[ "$ip" == */* ]]; then
addr="${ip%/*}"
local prefix="${ip#*/}"
{ [[ "$prefix" =~ ^[0-9]+$ ]] && [[ "$prefix" -le 128 ]]; } || return 1
fi
# IPv4:Port abfangen — enthält Punkt(e) vor dem ersten Doppelpunkt
[[ "$addr" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:[0-9] ]] && return 1
# Muss mindestens einen Doppelpunkt haben und nur gültige Zeichen (Hex, Doppelpunkt, Punkt für IPv4-mapped)
[[ "$addr" == *:* ]] || return 1
[[ "$addr" =~ ^[0-9a-fA-F:\.]+$ ]] || return 1
return 0
}
# Prüft ob ein Hostname syntaktisch plausibel ist
# Akzeptiert: example.com, sub.example.com, example.com. (trailing dot)
# Lehnt ab: einzelne Wörter ohne Punkt, Sonderzeichen, überlange Einträge
_is_valid_hostname() {
local host="$1"
host="${host%.}" # trailing dot (FQDN) entfernen
[[ -z "$host" ]] && return 1
[[ ${#host} -gt 253 ]] && return 1
[[ "$host" =~ ^[a-zA-Z0-9._-]+$ ]] || return 1
[[ "$host" =~ ^[.\-] ]] && return 1 # darf nicht mit . oder - beginnen
[[ "$host" == *.* ]] || return 1 # muss mindestens einen Punkt enthalten
return 0
}
# ─── IPs aus Blocklist-Datei parsen ──────────────────────────────────────────
# Unterstützt IPv4, IPv6, CIDR-Notation und Hostnamen (werden aufgelöst).
# Unterstützt außerdem das Hosts-Datei-Format: "0.0.0.0 hostname" oder "127.0.0.1 hostname".
# Ungültige Einträge (URLs, IP:Port, fehlerhafte IPs, einzelne Wörter usw.) werden
# mit WARN geloggt und übersprungen.
# 0.0.0.0 / :: wird nie importiert (AdGuard-typische Blocking-Antwort).
parse_blocklist_ips() {
local cache_file="$1"
[[ -f "$cache_file" ]] || return
while IFS= read -r line; do
line="${line%$'\r'}" # Windows-Zeilenenden (CRLF) entfernen
line="${line#$'\xef\xbb\xbf'}" # UTF-8 BOM entfernen (erste Zeile)
# Leerzeilen und Kommentarzeilen überspringen
[[ -z "$line" ]] && continue
[[ "$line" =~ ^[[:space:]]*# ]] && continue
# Whitespace trimmen, dann Inline-Kommentare entfernen (# oder ;)
line=$(echo "$line" | xargs)
line=$(echo "$line" | sed 's/[[:space:]]*[#;].*$//' | xargs)
[[ -z "$line" ]] && continue
# ── URLs ablehnen (http://, https://, ftp:// …) ──────────────────────
if [[ "$line" =~ ^[a-zA-Z][a-zA-Z0-9+.-]*:// ]]; then
log "WARN" "Eintrag übersprungen (URL nicht erlaubt): $line"
continue
fi
# ── Hosts-Datei-Format erkennen: "<routing-IP> <ziel>" ───────────────
# z.B. "0.0.0.0 bad.com" oder "127.0.0.1 malware.net"
if [[ "$line" =~ ^[^[:space:]]+[[:space:]]+[^[:space:]] ]]; then
local _first="${line%% *}"
local _rest="${line#* }"
local _second="${_rest%% *}"
if [[ "$_first" == "0.0.0.0" || "$_first" =~ ^127\. ||
"$_first" == "::1" || "$_first" == "::0" ||
"$_first" == "::" ]]; then
log "DEBUG" "Hosts-Format erkannt, extrahiere Ziel: $_second"
line="$_second"
else
log "WARN" "Eintrag übersprungen (Leerzeichen im Eintrag, unbekanntes Format): $line"
continue
fi
fi
# ── Klassifizieren und validieren ─────────────────────────────────────
if [[ "$line" == *:* ]]; then
# ── IPv6 ──────────────────────────────────────────────────────────
if _is_valid_ipv6 "$line"; then
echo "$line"
else
log "WARN" "Eintrag übersprungen (ungültige IPv6-Adresse oder IP:Port): $line"
fi
elif [[ "$line" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(/[0-9]+)?$ ]]; then
# ── IPv4 (nur Ziffern, Punkte und optionaler CIDR-Suffix) ────────
[[ "$line" == "0.0.0.0"* ]] && continue
if _is_valid_ipv4 "$line"; then
echo "$line"
else
log "WARN" "Eintrag übersprungen (ungültige IPv4-Adresse oder ungültiges CIDR): $line"
fi
else
# ── Hostname → DNS-Auflösung ──────────────────────────────────────
if ! _is_valid_hostname "$line"; then
log "WARN" "Eintrag übersprungen (kein gültiger Hostname): $line"
continue
fi
local resolved
resolved=$(getent ahosts "$line" 2>/dev/null | awk '{print $1}' | sort -u) || resolved=""
if [[ -z "$resolved" ]]; then
log "WARN" "Hostname konnte nicht aufgelöst werden: $line"
continue
fi
local resolved_count=0
while IFS= read -r resolved_ip; do
[[ -z "$resolved_ip" ]] && continue
[[ "$resolved_ip" == "0.0.0.0" ]] && continue # AdGuard-Blocking-Antwort
[[ "$resolved_ip" == "::" ]] && continue # IPv6 unspecified
[[ "$resolved_ip" == "::0" ]] && continue
echo "$resolved_ip"
resolved_count=$((resolved_count + 1))
done <<< "$resolved"
if [[ $resolved_count -gt 0 ]]; then
log "DEBUG" "Hostname aufgelöst: $line$resolved_count IP(s)"
else
log "WARN" "Hostname lieferte nur ungültige Adressen (z.B. 0.0.0.0): $line wird übersprungen"
fi
fi
done < "$cache_file"
}
# ─── Aktuelle externe Sperren ermitteln ──────────────────────────────────────
get_currently_banned_external_ips() {
for state_file in "${STATE_DIR}"/ext_*.ban; do
[[ -f "$state_file" ]] || continue
grep '^CLIENT_IP=' "$state_file" | cut -d= -f2
done
}
# ─── Abgelaufene externe Sperren prüfen ─────────────────────────────────────
check_expired_external_bans() {
[[ "$EXTERNAL_BLOCKLIST_BAN_DURATION" -gt 0 ]] || return
local now
now=$(date '+%s')
for state_file in "${STATE_DIR}"/ext_*.ban; do
[[ -f "$state_file" ]] || continue
local ban_until_epoch
ban_until_epoch=$(grep '^BAN_UNTIL_EPOCH=' "$state_file" | cut -d= -f2)
local client_ip
client_ip=$(grep '^CLIENT_IP=' "$state_file" | cut -d= -f2)
if [[ -n "$ban_until_epoch" && "$ban_until_epoch" -gt 0 && "$now" -ge "$ban_until_epoch" ]]; then
unban_ip "$client_ip" "external-blocklist-expired"
fi
done
}
# ─── Blocklisten synchronisieren ─────────────────────────────────────────────
sync_blocklists() {
local any_updated=false
# Alle URLs holen
IFS=',' read -ra urls <<< "$EXTERNAL_BLOCKLIST_URLS"
local index=0
for url in "${urls[@]}"; do
url=$(echo "$url" | xargs) # trim
[[ -z "$url" ]] && continue
if download_blocklist "$url" "$index"; then
any_updated=true
fi
index=$((index + 1))
done
# Alle gewünschten IPs zusammenführen (aus allen Cache-Dateien)
local all_desired_ips_file="${EXTERNAL_BLOCKLIST_CACHE_DIR}/.all_ips.tmp"
> "$all_desired_ips_file"
for cache_file in "${EXTERNAL_BLOCKLIST_CACHE_DIR}"/blocklist_*.txt; do
[[ -f "$cache_file" ]] || continue
parse_blocklist_ips "$cache_file" >> "$all_desired_ips_file"
done
# Duplikate entfernen und sortieren
local unique_ips_file="${EXTERNAL_BLOCKLIST_CACHE_DIR}/.all_ips_unique.tmp"
sort -u "$all_desired_ips_file" > "$unique_ips_file"
local desired_count
desired_count=$(wc -l < "$unique_ips_file" | xargs)
log "DEBUG" "Externe Blockliste enthält $desired_count eindeutige IPs"
# ─── Neue IPs sperren ────────────────────────────────────────────────────
local new_bans=0
while IFS= read -r ip; do
[[ -z "$ip" ]] && continue
# Whitelist prüfen
if is_whitelisted "$ip"; then
log "DEBUG" "IP $ip ist auf der Whitelist - überspringe (externe Blocklist)"
continue
fi
local _state_file_before="${STATE_DIR}/ext_${ip//[:/]/_}.ban"
local _was_new=false
[[ ! -f "$_state_file_before" ]] && _was_new=true
ban_ip "$ip"
[[ "$_was_new" == "true" ]] && new_bans=$((new_bans + 1))
done < "$unique_ips_file"
# ─── Entfernte IPs entsperren ────────────────────────────────────────────
if [[ "$EXTERNAL_BLOCKLIST_AUTO_UNBAN" == "true" ]]; then
local removed_count=0
while IFS= read -r banned_ip; do
[[ -z "$banned_ip" ]] && continue
# Prüfen ob die IP noch in der gewünschten Liste ist
if ! grep -qxF "$banned_ip" "$unique_ips_file" 2>/dev/null; then
unban_ip "$banned_ip" "external-blocklist-removed"
removed_count=$((removed_count + 1))
fi
done < <(get_currently_banned_external_ips)
if [[ $removed_count -gt 0 ]]; then
log "INFO" "$removed_count IPs aus externer Blocklist entfernt und entsperrt"
fi
fi
# Abgelaufene Sperren prüfen (nur bei zeitlich begrenzten Sperren)
check_expired_external_bans
# Aufräumen
rm -f "$all_desired_ips_file" "$unique_ips_file"
if [[ "$new_bans" -gt 0 ]]; then
log "INFO" "$new_bans neue IPs aus externer Blocklist gesperrt"
fi
}
# ─── PID-Management ──────────────────────────────────────────────────────────
write_pid() {
echo $$ > "$WORKER_PID_FILE"
}
cleanup() {
log "INFO" "Externer Blocklist-Worker wird beendet..."
rm -f "$WORKER_PID_FILE"
exit 0
}
check_already_running() {
if [[ -f "$WORKER_PID_FILE" ]]; then
local old_pid
old_pid=$(cat "$WORKER_PID_FILE")
if kill -0 "$old_pid" 2>/dev/null; then
log "DEBUG" "Blocklist-Worker läuft bereits (PID: $old_pid)"
return 1
else
rm -f "$WORKER_PID_FILE"
fi
fi
return 0
}
# ─── Status anzeigen ─────────────────────────────────────────────────────────
show_status() {
echo "═══════════════════════════════════════════════════════════════"
echo " Externer Blocklist-Worker - Status"
echo "═══════════════════════════════════════════════════════════════"
echo ""
if [[ "$EXTERNAL_BLOCKLIST_ENABLED" != "true" ]]; then
echo " ⚠️ Externer Blocklist-Worker ist deaktiviert"
echo " Aktivieren: EXTERNAL_BLOCKLIST_ENABLED=true in $CONFIG_FILE"
echo ""
return
fi
# Worker-Prozess Status
if [[ -f "$WORKER_PID_FILE" ]]; then
local pid
pid=$(cat "$WORKER_PID_FILE")
if kill -0 "$pid" 2>/dev/null; then
echo " ✅ Worker läuft (PID: $pid)"
else
echo " ❌ Worker nicht aktiv (veraltete PID-Datei)"
fi
else
echo " ❌ Worker nicht aktiv"
fi
echo ""
# Konfigurierte URLs
echo " Konfigurierte Blocklisten:"
IFS=',' read -ra urls <<< "$EXTERNAL_BLOCKLIST_URLS"
local index=0
for url in "${urls[@]}"; do
url=$(echo "$url" | xargs)
[[ -z "$url" ]] && continue
local cache_file="${EXTERNAL_BLOCKLIST_CACHE_DIR}/blocklist_${index}.txt"
local ip_count=0
if [[ -f "$cache_file" ]]; then
ip_count=$(grep -cv '^\s*#\|^\s*$' "$cache_file" 2>/dev/null || echo "0")
local last_modified
last_modified=$(date -r "$cache_file" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo "unbekannt")
echo " [$index] $url"
echo " IPs: $ip_count | Zuletzt aktualisiert: $last_modified"
else
echo " [$index] $url (noch nicht heruntergeladen)"
fi
index=$((index + 1))
done
echo ""
# Aktive externe Sperren
local ext_ban_count=0
for state_file in "${STATE_DIR}"/ext_*.ban; do
[[ -f "$state_file" ]] || continue
ext_ban_count=$((ext_ban_count + 1))
done
echo " Aktive Sperren (externe Blocklist): $ext_ban_count"
echo ""
echo " Prüfintervall: ${EXTERNAL_BLOCKLIST_INTERVAL}s"
echo " Auto-Unban: ${EXTERNAL_BLOCKLIST_AUTO_UNBAN}"
if [[ "$EXTERNAL_BLOCKLIST_BAN_DURATION" -gt 0 ]]; then
echo " Sperrdauer: ${EXTERNAL_BLOCKLIST_BAN_DURATION}s"
else
echo " Sperrdauer: permanent (bis aus Liste entfernt)"
fi
echo ""
echo "═══════════════════════════════════════════════════════════════"
}
# ─── Einmalig synchronisieren ────────────────────────────────────────────────
run_once() {
init_directories
setup_iptables_chain
if [[ -z "${EXTERNAL_BLOCKLIST_URLS:-}" ]]; then
log "ERROR" "Keine externen Blocklist-URLs konfiguriert (EXTERNAL_BLOCKLIST_URLS)"
exit 1
fi
log "INFO" "Einmalige Blocklist-Synchronisation..."
sync_blocklists
log "INFO" "Synchronisation abgeschlossen"
}
# ─── Hauptschleife ──────────────────────────────────────────────────────────
main_loop() {
init_directories
setup_iptables_chain
if [[ -z "${EXTERNAL_BLOCKLIST_URLS:-}" ]]; then
log "ERROR" "Keine externen Blocklist-URLs konfiguriert (EXTERNAL_BLOCKLIST_URLS)"
exit 1
fi
log "INFO" "═══════════════════════════════════════════════════════════"
log "INFO" "Externer Blocklist-Worker gestartet"
log "INFO" " URLs: ${EXTERNAL_BLOCKLIST_URLS}"
log "INFO" " Prüfintervall: ${EXTERNAL_BLOCKLIST_INTERVAL}s"
log "INFO" " Auto-Unban: ${EXTERNAL_BLOCKLIST_AUTO_UNBAN}"
log "INFO" "═══════════════════════════════════════════════════════════"
while true; do
sync_blocklists
sleep "$EXTERNAL_BLOCKLIST_INTERVAL"
done
}
# ─── Signal-Handler ──────────────────────────────────────────────────────────
trap cleanup SIGTERM SIGINT SIGHUP
# ─── Kommandozeilen-Argumente ────────────────────────────────────────────────
case "${1:-start}" in
start)
if ! check_already_running; then
exit 0
fi
write_pid
main_loop
;;
stop)
if [[ -f "$WORKER_PID_FILE" ]]; then
kill "$(cat "$WORKER_PID_FILE")" 2>/dev/null || true
rm -f "$WORKER_PID_FILE"
echo "Blocklist-Worker gestoppt"
else
echo "Blocklist-Worker läuft nicht"
fi
;;
sync)
run_once
;;
status)
init_directories
show_status
;;
flush)
init_directories
echo "Entferne alle externen Blocklist-Sperren..."
for state_file in "${STATE_DIR}"/ext_*.ban; do
[[ -f "$state_file" ]] || continue
_ip=$(grep '^CLIENT_IP=' "$state_file" | cut -d= -f2)
unban_ip "$_ip" "manual-flush"
done
echo "Alle externen Blocklist-Sperren aufgehoben"
;;
*)
cat << USAGE
AdGuard Shield - Externer Blocklist-Worker
Nutzung: $0 {start|stop|sync|status|flush}
Befehle:
start Startet den Worker (Dauerbetrieb)
stop Stoppt den Worker
sync Einmalige Synchronisation
status Zeigt Status und konfigurierte Listen
flush Entfernt alle externen Blocklist-Sperren
Konfiguration: $CONFIG_FILE
USAGE
exit 0
;;
esac

View File

@@ -1,532 +0,0 @@
#!/bin/bash
###############################################################################
# AdGuard Shield - Externer Whitelist-Worker
# Lädt externe Whitelist-Dateien herunter, löst Domains zu IPs auf und
# stellt diese dem Hauptscript als dynamische Whitelist zur Verfügung.
# Ideal für DynDNS-Domains mit wechselnden IP-Adressen.
# Wird als Hintergrundprozess vom Hauptscript gestartet.
#
# Autor: Patrick Asmus
# E-Mail: support@techniverse.net
# Datum: 2026-04-04
# Lizenz: MIT
###############################################################################
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_FILE="${SCRIPT_DIR}/adguard-shield.conf"
# ─── Konfiguration laden ───────────────────────────────────────────────────────
if [[ ! -f "$CONFIG_FILE" ]]; then
echo "FEHLER: Konfigurationsdatei nicht gefunden: $CONFIG_FILE" >&2
exit 1
fi
# shellcheck source=adguard-shield.conf
source "$CONFIG_FILE"
# ─── Standardwerte ────────────────────────────────────────────────────────────
EXTERNAL_WHITELIST_CACHE_DIR="${EXTERNAL_WHITELIST_CACHE_DIR:-/var/lib/adguard-shield/external-whitelist}"
EXTERNAL_WHITELIST_RESOLVED_FILE="${EXTERNAL_WHITELIST_CACHE_DIR}/resolved_ips.txt"
# ─── Worker PID-File ──────────────────────────────────────────────────────────
WORKER_PID_FILE="/var/run/adguard-whitelist-worker.pid"
# ─── Logging (eigene Funktion, nutzt gleiche Log-Datei) ───────────────────────
declare -A LOG_LEVELS=([DEBUG]=0 [INFO]=1 [WARN]=2 [ERROR]=3)
log() {
local level="$1"
shift
local message="$*"
local configured_level="${LOG_LEVEL:-INFO}"
if [[ ${LOG_LEVELS[$level]:-1} -ge ${LOG_LEVELS[$configured_level]:-1} ]]; then
local timestamp
timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
local log_entry="[$timestamp] [$level] [WHITELIST-WORKER] $message"
echo "$log_entry" | tee -a "$LOG_FILE" >&2
fi
}
# ─── Verzeichnisse erstellen ──────────────────────────────────────────────────
init_directories() {
mkdir -p "$EXTERNAL_WHITELIST_CACHE_DIR"
mkdir -p "$(dirname "$LOG_FILE")"
}
# ─── Eintrag-Validierung ─────────────────────────────────────────────────────
# Prüft IPv4-Adresse mit optionalem CIDR
_is_valid_ipv4() {
local ip="$1" addr="$1" prefix=""
if [[ "$ip" == */* ]]; then
addr="${ip%/*}"
prefix="${ip#*/}"
{ [[ "$prefix" =~ ^[0-9]+$ ]] && [[ "$prefix" -le 32 ]]; } || return 1
fi
local IFS='.'
read -ra _octets <<< "$addr"
[[ ${#_octets[@]} -eq 4 ]] || return 1
local o
for o in "${_octets[@]}"; do
[[ "$o" =~ ^[0-9]+$ ]] || return 1
[[ "$o" -le 255 ]] || return 1
done
return 0
}
# Prüft IPv6-Adresse mit optionalem CIDR
_is_valid_ipv6() {
local ip="$1" addr="$1"
if [[ "$ip" == */* ]]; then
addr="${ip%/*}"
local prefix="${ip#*/}"
{ [[ "$prefix" =~ ^[0-9]+$ ]] && [[ "$prefix" -le 128 ]]; } || return 1
fi
[[ "$addr" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:[0-9] ]] && return 1
[[ "$addr" == *:* ]] || return 1
[[ "$addr" =~ ^[0-9a-fA-F:\.]+$ ]] || return 1
return 0
}
# Prüft ob ein Hostname syntaktisch plausibel ist
_is_valid_hostname() {
local host="$1"
host="${host%.}" # trailing dot entfernen
[[ -z "$host" ]] && return 1
[[ ${#host} -gt 253 ]] && return 1
[[ "$host" =~ ^[a-zA-Z0-9._-]+$ ]] || return 1
[[ "$host" =~ ^[.\-] ]] && return 1
[[ "$host" == *.* ]] || return 1
return 0
}
# ─── Externe Whitelist herunterladen ─────────────────────────────────────────
download_whitelist() {
local url="$1"
local index="$2"
local cache_file="${EXTERNAL_WHITELIST_CACHE_DIR}/whitelist_${index}.txt"
local etag_file="${EXTERNAL_WHITELIST_CACHE_DIR}/whitelist_${index}.etag"
local tmp_file="${EXTERNAL_WHITELIST_CACHE_DIR}/whitelist_${index}.tmp"
log "DEBUG" "Prüfe externe Whitelist: $url"
local -a curl_args=(
-s
-L
--connect-timeout 10
--max-time 30
-o "$tmp_file"
-w "%{http_code}"
)
if [[ -f "$etag_file" ]]; then
local stored_etag
stored_etag=$(cat "$etag_file")
curl_args+=(-H "If-None-Match: ${stored_etag}")
fi
local http_code
http_code=$(curl "${curl_args[@]}" -D "${tmp_file}.headers" "$url" 2>/dev/null) || {
log "WARN" "Fehler beim Download der Whitelist: $url"
rm -f "$tmp_file" "${tmp_file}.headers"
return 1
}
if [[ "$http_code" == "304" ]]; then
log "DEBUG" "Whitelist nicht geändert (HTTP 304): $url"
rm -f "$tmp_file" "${tmp_file}.headers"
# Auch bei 304 müssen wir DNS neu auflösen (dynamische IPs!)
return 0
fi
if [[ "$http_code" != "200" ]]; then
log "WARN" "Whitelist Download fehlgeschlagen (HTTP $http_code): $url"
rm -f "$tmp_file" "${tmp_file}.headers"
return 1
fi
if [[ -f "${tmp_file}.headers" ]]; then
local new_etag
new_etag=$(grep -i '^etag:' "${tmp_file}.headers" | head -1 | sed 's/^[^:]*: *//;s/\r$//')
if [[ -n "$new_etag" ]]; then
echo "$new_etag" > "$etag_file"
fi
fi
rm -f "${tmp_file}.headers"
if [[ -f "$cache_file" ]]; then
if diff -q "$tmp_file" "$cache_file" &>/dev/null; then
log "DEBUG" "Whitelist Inhalt unverändert: $url"
rm -f "$tmp_file"
return 0
fi
fi
mv "$tmp_file" "$cache_file"
log "INFO" "Whitelist aktualisiert: $url"
return 0
}
# ─── Einträge aus Whitelist-Datei parsen und IPs auflösen ───────────────────
# Gibt pro Zeile eine IP-Adresse aus (aufgelöste Domains + direkte IPs)
parse_whitelist_entries() {
local cache_file="$1"
[[ -f "$cache_file" ]] || return
while IFS= read -r line; do
line="${line%$'\r'}"
line="${line#$'\xef\xbb\xbf'}"
[[ -z "$line" ]] && continue
[[ "$line" =~ ^[[:space:]]*# ]] && continue
line=$(echo "$line" | xargs)
line=$(echo "$line" | sed 's/[[:space:]]*[#;].*$//' | xargs)
[[ -z "$line" ]] && continue
# URLs ablehnen
if [[ "$line" =~ ^[a-zA-Z][a-zA-Z0-9+.-]*:// ]]; then
log "WARN" "Whitelist-Eintrag übersprungen (URL nicht erlaubt): $line"
continue
fi
# Hosts-Datei-Format erkennen
if [[ "$line" =~ ^[^[:space:]]+[[:space:]]+[^[:space:]] ]]; then
local _first="${line%% *}"
local _rest="${line#* }"
local _second="${_rest%% *}"
if [[ "$_first" == "0.0.0.0" || "$_first" =~ ^127\. ||
"$_first" == "::1" || "$_first" == "::0" ||
"$_first" == "::" ]]; then
log "DEBUG" "Whitelist Hosts-Format erkannt, extrahiere: $_second"
line="$_second"
else
log "WARN" "Whitelist-Eintrag übersprungen (unbekanntes Format): $line"
continue
fi
fi
# Klassifizieren und validieren
if [[ "$line" == *:* ]]; then
# IPv6
if _is_valid_ipv6 "$line"; then
echo "$line"
else
log "WARN" "Whitelist-Eintrag übersprungen (ungültige IPv6): $line"
fi
elif [[ "$line" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(/[0-9]+)?$ ]]; then
# IPv4 (nur Ziffern, Punkte und optionaler CIDR-Suffix)
[[ "$line" == "0.0.0.0"* ]] && continue
if _is_valid_ipv4 "$line"; then
echo "$line"
else
log "WARN" "Whitelist-Eintrag übersprungen (ungültige IPv4): $line"
fi
else
# Hostname → DNS-Auflösung (wird bei jedem Durchlauf neu aufgelöst!)
if ! _is_valid_hostname "$line"; then
log "WARN" "Whitelist-Eintrag übersprungen (kein gültiger Hostname): $line"
continue
fi
local resolved
resolved=$(getent ahosts "$line" 2>/dev/null | awk '{print $1}' | sort -u) || resolved=""
if [[ -z "$resolved" ]]; then
log "WARN" "Whitelist-Hostname konnte nicht aufgelöst werden: $line"
continue
fi
local resolved_count=0
while IFS= read -r resolved_ip; do
[[ -z "$resolved_ip" ]] && continue
[[ "$resolved_ip" == "0.0.0.0" ]] && continue
[[ "$resolved_ip" == "::" ]] && continue
[[ "$resolved_ip" == "::0" ]] && continue
echo "$resolved_ip"
resolved_count=$((resolved_count + 1))
done <<< "$resolved"
if [[ $resolved_count -gt 0 ]]; then
log "DEBUG" "Whitelist-Hostname aufgelöst: $line$resolved_count IP(s)"
else
log "WARN" "Whitelist-Hostname lieferte nur ungültige Adressen: $line"
fi
fi
done < "$cache_file"
}
# ─── Whitelisten synchronisieren ─────────────────────────────────────────────
sync_whitelists() {
# Alle URLs herunterladen
IFS=',' read -ra urls <<< "$EXTERNAL_WHITELIST_URLS"
local index=0
for url in "${urls[@]}"; do
url=$(echo "$url" | xargs)
[[ -z "$url" ]] && continue
download_whitelist "$url" "$index" || true
index=$((index + 1))
done
# Alle Einträge aus Cache-Dateien parsen und IPs auflösen
local all_ips_file="${EXTERNAL_WHITELIST_CACHE_DIR}/.all_ips.tmp"
> "$all_ips_file"
for cache_file in "${EXTERNAL_WHITELIST_CACHE_DIR}"/whitelist_*.txt; do
[[ -f "$cache_file" ]] || continue
parse_whitelist_entries "$cache_file" >> "$all_ips_file"
done
# Duplikate entfernen und in die resolved-Datei schreiben
local unique_count
sort -u "$all_ips_file" > "${EXTERNAL_WHITELIST_RESOLVED_FILE}.tmp"
mv "${EXTERNAL_WHITELIST_RESOLVED_FILE}.tmp" "$EXTERNAL_WHITELIST_RESOLVED_FILE"
unique_count=$(wc -l < "$EXTERNAL_WHITELIST_RESOLVED_FILE" | xargs)
rm -f "$all_ips_file"
log "DEBUG" "Externe Whitelist: $unique_count eindeutige IPs aufgelöst"
# Prüfe ob gesperrte IPs jetzt auf der Whitelist stehen und entsperrt werden müssen
check_banned_whitelist_ips
}
# ─── Gesperrte IPs prüfen die jetzt gewhitelistet sind ──────────────────────
# Wenn eine IP nach einer Whitelist-Aktualisierung nun auf der externen
# Whitelist steht, wird sie automatisch entsperrt.
check_banned_whitelist_ips() {
local state_dir="${STATE_DIR:-/var/lib/adguard-shield}"
[[ -d "$state_dir" ]] || return
[[ -f "$EXTERNAL_WHITELIST_RESOLVED_FILE" ]] || return
for state_file in "${state_dir}"/*.ban "${state_dir}"/ext_*.ban; do
[[ -f "$state_file" ]] || continue
local client_ip
client_ip=$(grep '^CLIENT_IP=' "$state_file" | cut -d= -f2)
[[ -z "$client_ip" ]] && continue
if grep -qxF "$client_ip" "$EXTERNAL_WHITELIST_RESOLVED_FILE" 2>/dev/null; then
log "INFO" "Gesperrte IP $client_ip ist jetzt auf externer Whitelist entsperre automatisch"
# iptables-Regel entfernen
if [[ "$client_ip" == *:* ]]; then
ip6tables -D "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true
else
iptables -D "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true
fi
rm -f "$state_file"
# Ban-History Eintrag
if [[ -f "${BAN_HISTORY_FILE:-/var/log/adguard-shield-bans.log}" ]]; then
local timestamp
timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
printf "%-19s | %-6s | %-39s | %-30s | %-8s | %-10s | %-10s | %s\n" \
"$timestamp" "UNBAN" "$client_ip" "-" "-" "-" "-" "external-whitelist" \
>> "${BAN_HISTORY_FILE:-/var/log/adguard-shield-bans.log}"
fi
fi
done
}
# ─── PID-Management ──────────────────────────────────────────────────────────
write_pid() {
echo $$ > "$WORKER_PID_FILE"
}
cleanup() {
log "INFO" "Externer Whitelist-Worker wird beendet..."
rm -f "$WORKER_PID_FILE"
exit 0
}
check_already_running() {
if [[ -f "$WORKER_PID_FILE" ]]; then
local old_pid
old_pid=$(cat "$WORKER_PID_FILE")
if kill -0 "$old_pid" 2>/dev/null; then
log "DEBUG" "Whitelist-Worker läuft bereits (PID: $old_pid)"
return 1
else
rm -f "$WORKER_PID_FILE"
fi
fi
return 0
}
# ─── Status anzeigen ─────────────────────────────────────────────────────────
show_status() {
echo "═══════════════════════════════════════════════════════════════"
echo " Externer Whitelist-Worker - Status"
echo "═══════════════════════════════════════════════════════════════"
echo ""
if [[ "$EXTERNAL_WHITELIST_ENABLED" != "true" ]]; then
echo " ⚠️ Externer Whitelist-Worker ist deaktiviert"
echo " Aktivieren: EXTERNAL_WHITELIST_ENABLED=true in $CONFIG_FILE"
echo ""
return
fi
# Worker-Prozess Status
if [[ -f "$WORKER_PID_FILE" ]]; then
local pid
pid=$(cat "$WORKER_PID_FILE")
if kill -0 "$pid" 2>/dev/null; then
echo " ✅ Worker läuft (PID: $pid)"
else
echo " ❌ Worker nicht aktiv (veraltete PID-Datei)"
fi
else
echo " ❌ Worker nicht aktiv"
fi
echo ""
# Konfigurierte URLs
echo " Konfigurierte Whitelisten:"
IFS=',' read -ra urls <<< "$EXTERNAL_WHITELIST_URLS"
local index=0
for url in "${urls[@]}"; do
url=$(echo "$url" | xargs)
[[ -z "$url" ]] && continue
local cache_file="${EXTERNAL_WHITELIST_CACHE_DIR}/whitelist_${index}.txt"
if [[ -f "$cache_file" ]]; then
local entry_count
entry_count=$(grep -cv '^\s*#\|^\s*$' "$cache_file" 2>/dev/null || echo "0")
local last_modified
last_modified=$(date -r "$cache_file" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo "unbekannt")
echo " [$index] $url"
echo " Einträge: $entry_count | Zuletzt aktualisiert: $last_modified"
else
echo " [$index] $url (noch nicht heruntergeladen)"
fi
index=$((index + 1))
done
echo ""
# Aufgelöste IPs
if [[ -f "$EXTERNAL_WHITELIST_RESOLVED_FILE" ]]; then
local resolved_count
resolved_count=$(wc -l < "$EXTERNAL_WHITELIST_RESOLVED_FILE" | xargs)
local last_resolved
last_resolved=$(date -r "$EXTERNAL_WHITELIST_RESOLVED_FILE" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || echo "unbekannt")
echo " Aufgelöste IPs: $resolved_count"
echo " Letzte Auflösung: $last_resolved"
if [[ "$resolved_count" -gt 0 && "$resolved_count" -le 20 ]]; then
echo ""
echo " Aktuelle IPs:"
while IFS= read -r ip; do
echo "$ip"
done < "$EXTERNAL_WHITELIST_RESOLVED_FILE"
elif [[ "$resolved_count" -gt 20 ]]; then
echo ""
echo " Erste 20 IPs:"
head -20 "$EXTERNAL_WHITELIST_RESOLVED_FILE" | while IFS= read -r ip; do
echo "$ip"
done
echo " ... ($((resolved_count - 20)) weitere)"
fi
else
echo " Aufgelöste IPs: 0 (noch keine Synchronisation durchgeführt)"
fi
echo ""
echo " Prüfintervall: ${EXTERNAL_WHITELIST_INTERVAL}s"
echo ""
echo "═══════════════════════════════════════════════════════════════"
}
# ─── Einmalig synchronisieren ────────────────────────────────────────────────
run_once() {
init_directories
if [[ -z "${EXTERNAL_WHITELIST_URLS:-}" ]]; then
log "ERROR" "Keine externen Whitelist-URLs konfiguriert (EXTERNAL_WHITELIST_URLS)"
exit 1
fi
log "INFO" "Einmalige Whitelist-Synchronisation..."
sync_whitelists
log "INFO" "Whitelist-Synchronisation abgeschlossen"
}
# ─── Hauptschleife ──────────────────────────────────────────────────────────
main_loop() {
init_directories
if [[ -z "${EXTERNAL_WHITELIST_URLS:-}" ]]; then
log "ERROR" "Keine externen Whitelist-URLs konfiguriert (EXTERNAL_WHITELIST_URLS)"
exit 1
fi
log "INFO" "═══════════════════════════════════════════════════════════"
log "INFO" "Externer Whitelist-Worker gestartet"
log "INFO" " URLs: ${EXTERNAL_WHITELIST_URLS}"
log "INFO" " Prüfintervall: ${EXTERNAL_WHITELIST_INTERVAL}s"
log "INFO" "═══════════════════════════════════════════════════════════"
while true; do
sync_whitelists
sleep "$EXTERNAL_WHITELIST_INTERVAL"
done
}
# ─── Signal-Handler ──────────────────────────────────────────────────────────
trap cleanup SIGTERM SIGINT SIGHUP
# ─── Kommandozeilen-Argumente ────────────────────────────────────────────────
case "${1:-start}" in
start)
if ! check_already_running; then
exit 0
fi
write_pid
main_loop
;;
stop)
if [[ -f "$WORKER_PID_FILE" ]]; then
kill "$(cat "$WORKER_PID_FILE")" 2>/dev/null || true
rm -f "$WORKER_PID_FILE"
echo "Whitelist-Worker gestoppt"
else
echo "Whitelist-Worker läuft nicht"
fi
;;
sync)
run_once
;;
status)
init_directories
show_status
;;
flush)
init_directories
echo "Entferne aufgelöste externe Whitelist-IPs..."
rm -f "$EXTERNAL_WHITELIST_RESOLVED_FILE"
echo "Externe Whitelist-IPs entfernt"
;;
*)
cat << USAGE
AdGuard Shield - Externer Whitelist-Worker
Nutzung: $0 {start|stop|sync|status|flush}
Befehle:
start Startet den Worker (Dauerbetrieb)
stop Stoppt den Worker
sync Einmalige Synchronisation (DNS-Auflösung)
status Zeigt Status und aufgelöste IPs
flush Entfernt alle aufgelösten Whitelist-IPs
Konfiguration: $CONFIG_FILE
USAGE
exit 0
;;
esac

View File

@@ -1,932 +0,0 @@
#!/bin/bash
###############################################################################
# AdGuard Shield - GeoIP Worker
# Prüft Client-IPs auf Herkunftsland und sperrt/erlaubt basierend auf Konfig.
# Wird als Hintergrundprozess vom Hauptscript gestartet.
#
# Autor: Patrick Asmus
# E-Mail: support@techniverse.net
# Lizenz: MIT
###############################################################################
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_FILE="${SCRIPT_DIR}/adguard-shield.conf"
# ─── Konfiguration laden ───────────────────────────────────────────────────────
if [[ ! -f "$CONFIG_FILE" ]]; then
echo "FEHLER: Konfigurationsdatei nicht gefunden: $CONFIG_FILE" >&2
exit 1
fi
# shellcheck source=adguard-shield.conf
source "$CONFIG_FILE"
# ─── Worker PID-File ──────────────────────────────────────────────────────────
WORKER_PID_FILE="/var/run/adguard-geoip-worker.pid"
# ─── GeoIP Cache ──────────────────────────────────────────────────────────────
GEOIP_CACHE_DIR="${STATE_DIR}/geoip-cache"
# ─── MaxMind Auto-Download Verzeichnis ────────────────────────────────────────
GEOIP_DB_DIR="${SCRIPT_DIR}/geoip"
GEOIP_AUTO_DB="${GEOIP_DB_DIR}/GeoLite2-Country.mmdb"
GEOIP_DB_UPDATE_INTERVAL=86400 # 24 Stunden (fest)
# ─── Logging (eigene Funktion, nutzt gleiche Log-Datei) ───────────────────────
declare -A LOG_LEVELS=([DEBUG]=0 [INFO]=1 [WARN]=2 [ERROR]=3)
log() {
local level="$1"
shift
local message="$*"
local configured_level="${LOG_LEVEL:-INFO}"
if [[ ${LOG_LEVELS[$level]:-1} -ge ${LOG_LEVELS[$configured_level]:-1} ]]; then
local timestamp
timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
local log_entry="[$timestamp] [$level] [GEOIP-WORKER] $message"
echo "$log_entry" | tee -a "$LOG_FILE" >&2
fi
}
# ─── Ban-History ─────────────────────────────────────────────────────────────
log_ban_history() {
local action="$1"
local client_ip="$2"
local country="${3:-}"
local reason="${4:-geoip}"
local timestamp
timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
if [[ ! -f "$BAN_HISTORY_FILE" ]]; then
echo "# AdGuard Shield - Ban History" > "$BAN_HISTORY_FILE"
echo "# Format: ZEITSTEMPEL | AKTION | CLIENT-IP | DOMAIN | ANFRAGEN | SPERRDAUER | PROTOKOLL | GRUND" >> "$BAN_HISTORY_FILE"
echo "#──────────────────────────────────────────────────────────────────────────────────────────────────" >> "$BAN_HISTORY_FILE"
fi
local duration="permanent"
printf "%-19s | %-6s | %-39s | %-30s | %-8s | %-10s | %-10s | %s\n" \
"$timestamp" "$action" "$client_ip" "Land: ${country:-?}" "-" "$duration" "-" "$reason" \
>> "$BAN_HISTORY_FILE"
}
# ─── Verzeichnisse erstellen ──────────────────────────────────────────────────
init_directories() {
mkdir -p "$GEOIP_CACHE_DIR"
mkdir -p "$GEOIP_DB_DIR"
mkdir -p "$STATE_DIR"
mkdir -p "$(dirname "$LOG_FILE")"
}
# ─── Private IP-Adressen erkennen ────────────────────────────────────────────
is_private_ip() {
local ip="$1"
# IPv6 Loopback und Link-Local
if [[ "$ip" == "::1" || "$ip" == fe80:* || "$ip" == fc00:* || "$ip" == fd00:* ]]; then
return 0
fi
# IPv4 private Bereiche
if [[ "$ip" =~ ^10\. || "$ip" =~ ^172\.(1[6-9]|2[0-9]|3[0-1])\. || "$ip" =~ ^192\.168\. || "$ip" =~ ^127\. || "$ip" == "0.0.0.0" ]]; then
return 0
fi
# IPv4 CGNAT
if [[ "$ip" =~ ^100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\. ]]; then
return 0
fi
return 1
}
# ─── Whitelist Prüfung ───────────────────────────────────────────────────────
is_whitelisted() {
local ip="$1"
IFS=',' read -ra wl_entries <<< "$WHITELIST"
for entry in "${wl_entries[@]}"; do
entry=$(echo "$entry" | xargs) # trim
if [[ "$ip" == "$entry" ]]; then
return 0
fi
done
# Externe Whitelist prüfen
local ext_wl_file="${EXTERNAL_WHITELIST_CACHE_DIR:-/var/lib/adguard-shield/external-whitelist}/resolved_ips.txt"
if [[ -f "$ext_wl_file" ]] && grep -qxF "$ip" "$ext_wl_file" 2>/dev/null; then
return 0
fi
return 1
}
# ─── MaxMind GeoLite2 Auto-Download & Update ─────────────────────────────────
# Lädt die GeoLite2-Country.mmdb herunter, wenn GEOIP_LICENSE_KEY gesetzt ist
# und kein eigener GEOIP_MMDB_PATH angegeben wurde.
# Aktualisiert automatisch alle 24 Stunden.
update_maxmind_db() {
local license_key="${GEOIP_LICENSE_KEY:-}"
# Kein License-Key → nichts zu tun
if [[ -z "$license_key" ]]; then
return 0
fi
# User hat eigenen Pfad gesetzt → kein Auto-Download
if [[ -n "${GEOIP_MMDB_PATH:-}" ]]; then
return 0
fi
# Prüfen ob Update nötig (alle 24h)
if [[ -f "$GEOIP_AUTO_DB" ]]; then
local db_age
db_age=$(( $(date '+%s') - $(stat -c '%Y' "$GEOIP_AUTO_DB" 2>/dev/null || stat -f '%m' "$GEOIP_AUTO_DB" 2>/dev/null || echo "0") ))
if [[ "$db_age" -lt "$GEOIP_DB_UPDATE_INTERVAL" ]]; then
log "DEBUG" "MaxMind DB ist aktuell (Alter: $((db_age / 3600))h, nächstes Update in $(( (GEOIP_DB_UPDATE_INTERVAL - db_age) / 3600 ))h)"
return 0
fi
log "INFO" "MaxMind DB ist älter als 24h starte Update..."
else
log "INFO" "MaxMind DB nicht vorhanden starte Erstdownload..."
fi
# Download-URL zusammenbauen (MaxMind Permalink)
local download_url="https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=${license_key}&suffix=tar.gz"
local tmp_file="${GEOIP_DB_DIR}/GeoLite2-Country.tar.gz"
local tmp_extract="${GEOIP_DB_DIR}/extract_tmp"
# Herunterladen
local http_code
http_code=$(curl -s -o "$tmp_file" -w "%{http_code}" \
--connect-timeout 10 \
--max-time 60 \
"$download_url" 2>/dev/null) || true
if [[ "$http_code" != "200" ]]; then
rm -f "$tmp_file"
case "$http_code" in
401) log "ERROR" "MaxMind Download fehlgeschlagen: Ungültiger License-Key (HTTP 401)" ;;
403) log "ERROR" "MaxMind Download fehlgeschlagen: Zugriff verweigert (HTTP 403) License-Key prüfen" ;;
*) log "ERROR" "MaxMind Download fehlgeschlagen (HTTP ${http_code:-timeout})" ;;
esac
return 1
fi
# Entpacken
rm -rf "$tmp_extract"
mkdir -p "$tmp_extract"
if ! tar -xzf "$tmp_file" -C "$tmp_extract" 2>/dev/null; then
log "ERROR" "MaxMind DB: tar-Archiv konnte nicht entpackt werden"
rm -f "$tmp_file"
rm -rf "$tmp_extract"
return 1
fi
# .mmdb Datei finden und verschieben
local mmdb_file
mmdb_file=$(find "$tmp_extract" -name 'GeoLite2-Country.mmdb' -type f 2>/dev/null | head -1)
if [[ -z "$mmdb_file" || ! -f "$mmdb_file" ]]; then
log "ERROR" "MaxMind DB: GeoLite2-Country.mmdb nicht im Archiv gefunden"
rm -f "$tmp_file"
rm -rf "$tmp_extract"
return 1
fi
mv "$mmdb_file" "$GEOIP_AUTO_DB"
rm -f "$tmp_file"
rm -rf "$tmp_extract"
log "INFO" "MaxMind GeoLite2-Country DB erfolgreich aktualisiert: $GEOIP_AUTO_DB"
return 0
}
# ─── Effektiven MMDB-Pfad ermitteln ──────────────────────────────────────────
# Priorität: GEOIP_MMDB_PATH (User) > Auto-Download > leer (Fallback auf geoiplookup)
resolve_mmdb_path() {
# User hat eigenen Pfad gesetzt
if [[ -n "${GEOIP_MMDB_PATH:-}" && -f "${GEOIP_MMDB_PATH:-}" ]]; then
echo "$GEOIP_MMDB_PATH"
return 0
fi
# Auto-Download DB vorhanden
if [[ -f "$GEOIP_AUTO_DB" ]]; then
echo "$GEOIP_AUTO_DB"
return 0
fi
# Kein MMDB verfügbar
echo ""
return 1
}
# ─── GeoIP Lookup ────────────────────────────────────────────────────────────
# Gibt den ISO 3166-1 Alpha-2 Ländercode zurück (z.B. "DE", "US", "CN")
# Nutzt Cache um wiederholte Lookups zu vermeiden
geoip_lookup() {
local ip="$1"
local cache_file="${GEOIP_CACHE_DIR}/${ip//[:\/]/_}.country"
# Cache prüfen (max 24 Stunden alt)
if [[ -f "$cache_file" ]]; then
local cache_age
cache_age=$(( $(date '+%s') - $(stat -c '%Y' "$cache_file" 2>/dev/null || stat -f '%m' "$cache_file" 2>/dev/null || echo "0") ))
if [[ "$cache_age" -lt 86400 ]]; then
cat "$cache_file"
return 0
fi
fi
local country_code=""
# Effektiven MMDB-Pfad ermitteln (User-Pfad oder Auto-Download)
local effective_mmdb
effective_mmdb=$(resolve_mmdb_path 2>/dev/null) || true
# Methode 1: MaxMind mmdbinspect (bevorzugt, genauer)
if [[ -n "$effective_mmdb" && -f "$effective_mmdb" ]] && command -v mmdbinspect &>/dev/null; then
country_code=$(mmdbinspect -db "$effective_mmdb" -ip "$ip" 2>/dev/null \
| jq -r '.[0].Records[0].Record.country.iso_code // empty' 2>/dev/null || true)
fi
# Methode 2: geoiplookup (GeoIP Legacy)
if [[ -z "$country_code" ]] && command -v geoiplookup &>/dev/null; then
if [[ "$ip" == *:* ]]; then
# IPv6
if command -v geoiplookup6 &>/dev/null; then
country_code=$(geoiplookup6 "$ip" 2>/dev/null \
| grep -oP '(?<=: )[A-Z]{2}(?=,)' | head -1 || true)
fi
else
# IPv4
country_code=$(geoiplookup "$ip" 2>/dev/null \
| grep -oP '(?<=: )[A-Z]{2}(?=,)' | head -1 || true)
fi
fi
# Methode 3: mmdblookup (libmaxminddb)
if [[ -z "$country_code" && -n "$effective_mmdb" && -f "$effective_mmdb" ]] && command -v mmdblookup &>/dev/null; then
country_code=$(mmdblookup --file "$effective_mmdb" --ip "$ip" country iso_code 2>/dev/null \
| grep -oP '"[A-Z]{2}"' | tr -d '"' | head -1 || true)
fi
if [[ -n "$country_code" ]]; then
echo "$country_code" > "$cache_file"
echo "$country_code"
return 0
fi
# Unbekannt nicht cachen (könnte temporärer Fehler sein)
echo ""
return 1
}
# ─── GeoIP Prüfung: Soll eine IP gesperrt werden? ────────────────────────────
# Return 0 = sperren, Return 1 = erlauben
should_block_by_geoip() {
local country_code="$1"
local mode="${GEOIP_MODE:-blocklist}"
local countries="${GEOIP_COUNTRIES:-}"
[[ -z "$country_code" || -z "$countries" ]] && return 1
# Länder-Liste in Array umwandeln
IFS=',' read -ra country_list <<< "$countries"
local found=false
for c in "${country_list[@]}"; do
c=$(echo "$c" | xargs | tr '[:lower:]' '[:upper:]') # trim + uppercase
if [[ "$country_code" == "$c" ]]; then
found=true
break
fi
done
if [[ "$mode" == "blocklist" ]]; then
# Blocklist-Modus: Sperren wenn Land in der Liste
[[ "$found" == "true" ]] && return 0 || return 1
elif [[ "$mode" == "allowlist" ]]; then
# Allowlist-Modus: Sperren wenn Land NICHT in der Liste
[[ "$found" == "true" ]] && return 1 || return 0
fi
return 1
}
# ─── IP via iptables sperren ─────────────────────────────────────────────────
ban_ip_geoip() {
local client_ip="$1"
local country_code="$2"
local mode="${GEOIP_MODE:-blocklist}"
# Prüfen ob bereits gesperrt
local state_file="${STATE_DIR}/${client_ip//[:\/]/_}.ban"
if [[ -f "$state_file" ]]; then
log "DEBUG" "GeoIP: $client_ip ist bereits gesperrt"
return 0
fi
# GeoIP-Sperren sind immer permanent
local ban_until=0
local ban_until_display="PERMANENT"
local reason_text
if [[ "$mode" == "blocklist" ]]; then
reason_text="geoip-blocklist (Land: $country_code)"
else
reason_text="geoip-allowlist (Land: $country_code)"
fi
log "WARN" "GeoIP SPERRE: $client_ip (Land: $country_code, Modus: $mode) PERMANENT"
# iptables Regel setzen
if [[ "$client_ip" == *:* ]]; then
ip6tables -I "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true
else
iptables -I "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true
fi
# State-Datei erstellen
cat > "$state_file" << EOF
CLIENT_IP=$client_ip
DOMAIN=GeoIP:${country_code}
COUNT=-
BAN_TIME=$(date '+%Y-%m-%d %H:%M:%S')
BAN_UNTIL_EPOCH=0
BAN_UNTIL=PERMANENT
BAN_DURATION=0
OFFENSE_LEVEL=0
IS_PERMANENT=true
REASON=geoip
PROTOCOL=-
GEOIP_COUNTRY=$country_code
GEOIP_MODE=$mode
EOF
# Ban-History
log_ban_history "BAN" "$client_ip" "$country_code" "$reason_text"
# Benachrichtigung senden
if [[ "${GEOIP_NOTIFY:-true}" == "true" && "${NOTIFY_ENABLED:-false}" == "true" ]]; then
send_geoip_notification "ban" "$client_ip" "$country_code" "PERMANENT" "$mode"
fi
}
# ─── GeoIP Benachrichtigung ──────────────────────────────────────────────────
send_geoip_notification() {
local action="$1"
local client_ip="$2"
local country_code="$3"
local duration="${4:-PERMANENT}"
local mode="${5:-blocklist}"
local my_hostname
my_hostname=$(hostname)
local title="🌍 🛡️ AdGuard Shield"
local mode_label
[[ "$mode" == "blocklist" ]] && mode_label="Blocklist" || mode_label="Allowlist"
local message="🌍 AdGuard Shield GeoIP-Sperre auf ${my_hostname}
---
IP: ${client_ip}
Land: ${country_code}
Modus: ${mode_label}
Dauer: ${duration}
Whois: https://www.whois.com/whois/${client_ip}
AbuseIPDB: https://www.abuseipdb.com/check/${client_ip}"
case "${NOTIFY_TYPE:-}" in
discord)
local json_payload
json_payload=$(jq -nc --arg msg "$message" '{content: $msg}')
curl -s -H "Content-Type: application/json" \
-d "$json_payload" \
"$NOTIFY_WEBHOOK_URL" &>/dev/null &
;;
slack)
local json_payload
json_payload=$(jq -nc --arg msg "$message" '{text: $msg}')
curl -s -H "Content-Type: application/json" \
-d "$json_payload" \
"$NOTIFY_WEBHOOK_URL" &>/dev/null &
;;
gotify)
local clean_message
clean_message=$(echo "$message" | sed 's/\*\*//g')
curl -s -X POST "$NOTIFY_WEBHOOK_URL" \
-F "title=${title}" \
-F "message=${clean_message}" \
-F "priority=5" &>/dev/null &
;;
ntfy)
local ntfy_url="${NTFY_SERVER_URL:-https://ntfy.sh}"
local -a curl_args=(
-s -X POST
"${ntfy_url}/${NTFY_TOPIC}"
-H "Title: 🛡️ AdGuard Shield GeoIP"
-H "Priority: ${NTFY_PRIORITY:-4}"
-H "Tags: globe_with_meridians,ban"
-d "$message"
)
[[ -n "${NTFY_TOKEN:-}" ]] && curl_args+=(-H "Authorization: Bearer ${NTFY_TOKEN}")
curl "${curl_args[@]}" &>/dev/null &
;;
generic)
local json_payload
json_payload=$(jq -nc --arg msg "$message" --arg cl "$client_ip" --arg cc "$country_code" \
'{message: $msg, action: "geoip-ban", client: $cl, country: $cc}')
curl -s -H "Content-Type: application/json" \
-d "$json_payload" \
"$NOTIFY_WEBHOOK_URL" &>/dev/null &
;;
esac
}
# ─── iptables Chain Setup ────────────────────────────────────────────────────
setup_iptables_chain() {
if ! iptables -n -L "$IPTABLES_CHAIN" &>/dev/null; then
iptables -N "$IPTABLES_CHAIN"
for port in $BLOCKED_PORTS; do
iptables -I INPUT -p tcp --dport "$port" -j "$IPTABLES_CHAIN"
iptables -I INPUT -p udp --dport "$port" -j "$IPTABLES_CHAIN"
done
fi
if ! ip6tables -n -L "$IPTABLES_CHAIN" &>/dev/null; then
ip6tables -N "$IPTABLES_CHAIN"
for port in $BLOCKED_PORTS; do
ip6tables -I INPUT -p tcp --dport "$port" -j "$IPTABLES_CHAIN"
ip6tables -I INPUT -p udp --dport "$port" -j "$IPTABLES_CHAIN"
done
fi
}
# ─── GeoIP-Tools Verfügbarkeit prüfen ────────────────────────────────────────
check_geoip_tools() {
# Effektiven MMDB-Pfad ermitteln
local effective_mmdb
effective_mmdb=$(resolve_mmdb_path 2>/dev/null) || true
# mmdbinspect + MMDB
if [[ -n "$effective_mmdb" && -f "$effective_mmdb" ]]; then
if command -v mmdbinspect &>/dev/null; then
echo "mmdbinspect"
return 0
elif command -v mmdblookup &>/dev/null; then
echo "mmdblookup"
return 0
fi
fi
# geoiplookup (Legacy GeoIP)
if command -v geoiplookup &>/dev/null; then
echo "geoiplookup"
return 0
fi
echo "none"
return 1
}
# ─── Client-IPs aus AdGuard API extrahieren ──────────────────────────────────
get_active_clients() {
local response
response=$(curl -s -u "${ADGUARD_USER}:${ADGUARD_PASS}" \
--connect-timeout 5 \
--max-time 10 \
-k "${ADGUARD_URL}/control/querylog?limit=${API_QUERY_LIMIT:-500}&response_status=all" 2>/dev/null)
if [[ -z "$response" || "$response" == "null" ]]; then
log "ERROR" "Keine Antwort von AdGuard Home API"
return 1
fi
# Eindeutige Client-IPs extrahieren
echo "$response" | jq -r '.data // [] | [.[].client // .[].client_info.ip] | unique | .[]' 2>/dev/null | sort -u
}
# ─── Auto-Unban: GeoIP-Sperren aufheben bei Konfigurationsänderung ────────────
# Prüft alle bestehenden GeoIP-Sperren und hebt sie auf, wenn:
# - Das Land nicht mehr in GEOIP_COUNTRIES steht
# - Der Modus gewechselt wurde (blocklist ↔ allowlist)
# - GeoIP deaktiviert wurde
auto_unban_geoip() {
local unban_count=0
for f in "${STATE_DIR}"/*.ban; do
[[ -f "$f" ]] || continue
local reason
reason=$(grep '^REASON=' "$f" | cut -d= -f2 || true)
[[ "$reason" != "geoip" ]] && continue
local client_ip country_code old_mode
client_ip=$(grep '^CLIENT_IP=' "$f" | cut -d= -f2 || true)
country_code=$(grep '^GEOIP_COUNTRY=' "$f" | cut -d= -f2 || true)
old_mode=$(grep '^GEOIP_MODE=' "$f" | cut -d= -f2 || true)
local should_unban=false
# GeoIP deaktiviert → alle GeoIP-Sperren aufheben
if [[ "${GEOIP_ENABLED:-false}" != "true" ]]; then
should_unban=true
# Modus gewechselt → alle GeoIP-Sperren aufheben und neu prüfen
elif [[ -n "$old_mode" && "$old_mode" != "${GEOIP_MODE:-blocklist}" ]]; then
should_unban=true
# Prüfen ob das Land nach aktueller Konfiguration noch gesperrt sein soll
elif [[ -n "$country_code" ]] && ! should_block_by_geoip "$country_code"; then
should_unban=true
fi
if [[ "$should_unban" == "true" ]]; then
log "INFO" "GeoIP Auto-Unban: $client_ip (Land: ${country_code:-?}, war: ${old_mode:-?})"
# iptables Regel entfernen
if [[ "$client_ip" == *:* ]]; then
ip6tables -D "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true
else
iptables -D "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true
fi
rm -f "$f"
log_ban_history "UNBAN" "$client_ip" "$country_code" "geoip-auto-unban"
unban_count=$((unban_count + 1))
fi
done
if [[ $unban_count -gt 0 ]]; then
log "INFO" "GeoIP Auto-Unban: $unban_count Sperren aufgehoben (Länderliste/Modus geändert)"
fi
}
# ─── Einmaliger GeoIP-Sync ──────────────────────────────────────────────────
sync_geoip() {
# Auto-Unban zuerst: bestehende Sperren prüfen, die nicht mehr zur Config passen
auto_unban_geoip
if [[ "${GEOIP_ENABLED:-false}" != "true" ]]; then
log "INFO" "GeoIP ist deaktiviert"
return 0
fi
# MaxMind DB automatisch herunterladen/aktualisieren (falls License-Key gesetzt)
update_maxmind_db || true
local countries="${GEOIP_COUNTRIES:-}"
if [[ -z "$countries" ]]; then
log "WARN" "GeoIP: Keine Länder konfiguriert (GEOIP_COUNTRIES ist leer)"
return 0
fi
local tool
tool=$(check_geoip_tools) || {
log "ERROR" "GeoIP: Kein GeoIP-Tool verfügbar. Installiere geoip-bin oder mmdbinspect."
return 1
}
log "INFO" "GeoIP-Sync gestartet (Tool: $tool, Modus: ${GEOIP_MODE:-blocklist}, Länder: $countries)"
# Client-IPs aus der API holen
local clients
clients=$(get_active_clients) || {
log "ERROR" "GeoIP: Konnte aktive Clients nicht ermitteln"
return 1
}
local checked=0
local blocked=0
local skipped=0
while IFS= read -r client_ip; do
[[ -z "$client_ip" || "$client_ip" == "null" ]] && continue
# Private IPs überspringen
if [[ "${GEOIP_SKIP_PRIVATE:-true}" == "true" ]] && is_private_ip "$client_ip"; then
log "DEBUG" "GeoIP: Private IP übersprungen: $client_ip"
skipped=$((skipped + 1))
continue
fi
# Whitelist prüfen
if is_whitelisted "$client_ip"; then
log "DEBUG" "GeoIP: Whitelisted IP übersprungen: $client_ip"
skipped=$((skipped + 1))
continue
fi
# Bereits gesperrt?
local state_file="${STATE_DIR}/${client_ip//[:\/]/_}.ban"
if [[ -f "$state_file" ]]; then
skipped=$((skipped + 1))
continue
fi
checked=$((checked + 1))
# GeoIP Lookup
local country_code
country_code=$(geoip_lookup "$client_ip") || true
if [[ -z "$country_code" ]]; then
log "DEBUG" "GeoIP: Kein Ergebnis für $client_ip"
continue
fi
log "DEBUG" "GeoIP: $client_ip$country_code"
# Prüfen ob gesperrt werden soll
if should_block_by_geoip "$country_code"; then
ban_ip_geoip "$client_ip" "$country_code"
blocked=$((blocked + 1))
fi
done <<< "$clients"
log "INFO" "GeoIP-Sync abgeschlossen: $checked geprüft, $blocked gesperrt, $skipped übersprungen"
}
# ─── Worker-Hauptschleife ────────────────────────────────────────────────────
start_worker() {
if [[ "${GEOIP_ENABLED:-false}" != "true" ]]; then
log "DEBUG" "GeoIP-Worker ist deaktiviert"
return 0
fi
# PID schreiben
echo $$ > "$WORKER_PID_FILE"
trap 'rm -f "$WORKER_PID_FILE"; exit 0' SIGTERM SIGINT SIGHUP
local interval="${GEOIP_CHECK_INTERVAL:-0}"
[[ "$interval" -le 0 ]] && interval="${CHECK_INTERVAL:-10}"
log "INFO" "GeoIP-Worker gestartet (PID: $$, Intervall: ${interval}s)"
# Beim Start: MaxMind DB herunterladen/aktualisieren (falls License-Key gesetzt)
update_maxmind_db || true
while true; do
sync_geoip
sleep "$interval"
done
}
# ─── Status anzeigen ─────────────────────────────────────────────────────────
show_status() {
echo "═══════════════════════════════════════════════════════════════"
echo " AdGuard Shield - GeoIP Status"
echo "═══════════════════════════════════════════════════════════════"
echo ""
if [[ "${GEOIP_ENABLED:-false}" != "true" ]]; then
echo " GeoIP ist deaktiviert"
echo ""
return
fi
echo " Modus: ${GEOIP_MODE:-blocklist}"
echo " Länder: ${GEOIP_COUNTRIES:-<keine>}"
echo " Sperrdauer: PERMANENT (Auto-Unban bei Änderung der Länderliste)"
echo " Private IPs überspringen: ${GEOIP_SKIP_PRIVATE:-true}"
echo ""
# MaxMind DB Info
local eff_mmdb
eff_mmdb=$(resolve_mmdb_path)
if [[ -n "${GEOIP_MMDB_PATH:-}" ]]; then
echo " MMDB-Pfad: ${GEOIP_MMDB_PATH} (manuell konfiguriert)"
elif [[ -n "${GEOIP_LICENSE_KEY:-}" ]]; then
echo " MMDB-Pfad: ${GEOIP_AUTO_DB} (Auto-Download)"
if [[ -f "${GEOIP_AUTO_DB}" ]]; then
local db_age db_age_h
db_age=$(( $(date +%s) - $(stat -c %Y "${GEOIP_AUTO_DB}" 2>/dev/null || echo 0) ))
db_age_h=$(( db_age / 3600 ))
echo " DB-Alter: ${db_age_h}h (Update alle 24h)"
else
echo " DB-Status: ⚠️ Noch nicht heruntergeladen"
fi
elif [[ -n "$eff_mmdb" ]]; then
echo " MMDB-Pfad: ${eff_mmdb}"
else
echo " MMDB-Pfad: <nicht konfiguriert> (Fallback auf geoiplookup)"
fi
echo " License-Key: $(if [[ -n "${GEOIP_LICENSE_KEY:-}" ]]; then echo "✅ konfiguriert"; else echo "❌ nicht gesetzt (kein Auto-Download)"; fi)"
echo ""
# GeoIP Tools prüfen
echo " GeoIP Tools:"
local tool
tool=$(check_geoip_tools 2>/dev/null) || tool="none"
case "$tool" in
mmdbinspect) echo " ✅ mmdbinspect mit MaxMind DB" ;;
mmdblookup) echo " ✅ mmdblookup mit MaxMind DB" ;;
geoiplookup) echo " ✅ geoiplookup (Legacy GeoIP)" ;;
none) echo " ❌ Kein GeoIP-Tool gefunden!" ;;
esac
echo ""
# Worker-Status
if [[ -f "$WORKER_PID_FILE" ]]; then
local wpid
wpid=$(cat "$WORKER_PID_FILE")
if kill -0 "$wpid" 2>/dev/null; then
echo " Worker: Läuft (PID: $wpid)"
else
echo " Worker: Abgestürzt (PID: $wpid existiert nicht mehr)"
fi
else
echo " Worker: Nicht gestartet"
fi
echo ""
# GeoIP-Sperren anzeigen
local geoip_bans=0
if [[ -d "$STATE_DIR" ]]; then
for f in "${STATE_DIR}"/*.ban; do
[[ -f "$f" ]] || continue
local reason
reason=$(grep '^REASON=' "$f" | cut -d= -f2 || true)
if [[ "$reason" == "geoip" ]]; then
geoip_bans=$((geoip_bans + 1))
local s_ip s_country s_until
s_ip=$(grep '^CLIENT_IP=' "$f" | cut -d= -f2 || true)
s_country=$(grep '^GEOIP_COUNTRY=' "$f" | cut -d= -f2 || true)
s_until=$(grep '^BAN_UNTIL=' "$f" | cut -d= -f2 || true)
echo " 🌍 $s_ip → Land: ${s_country:-?} (bis: ${s_until:-?})"
fi
done
fi
if [[ $geoip_bans -eq 0 ]]; then
echo " Keine aktiven GeoIP-Sperren"
else
echo ""
echo " Gesamt: $geoip_bans aktive GeoIP-Sperren"
fi
# Cache-Statistik
if [[ -d "$GEOIP_CACHE_DIR" ]]; then
local cache_count
cache_count=$(find "$GEOIP_CACHE_DIR" -name '*.country' -type f 2>/dev/null | wc -l)
echo ""
echo " Cache: $cache_count IP-Lookups zwischengespeichert"
fi
echo ""
echo "═══════════════════════════════════════════════════════════════"
}
# ─── Einzelne IP nachschlagen ────────────────────────────────────────────────
lookup_ip() {
local ip="$1"
local eff_mmdb
eff_mmdb=$(resolve_mmdb_path)
local tool
tool=$(check_geoip_tools 2>/dev/null) || tool="none"
if [[ "$tool" == "none" ]]; then
echo "❌ Kein GeoIP-Tool verfügbar."
echo " Installiere geoip-bin: sudo apt install geoip-bin geoip-database"
echo " Oder mmdbinspect mit MaxMind GeoLite2 DB"
return 1
fi
local country_code
country_code=$(geoip_lookup "$ip") || true
if [[ -z "$country_code" ]]; then
echo "IP: $ip → Land: unbekannt (kein GeoIP-Ergebnis)"
return 1
fi
echo "IP: $ip → Land: $country_code (Tool: $tool)"
[[ -n "$eff_mmdb" ]] && echo " MMDB: $eff_mmdb"
# Prüfen ob diese IP gesperrt werden würde
if [[ "${GEOIP_ENABLED:-false}" == "true" && -n "${GEOIP_COUNTRIES:-}" ]]; then
if should_block_by_geoip "$country_code"; then
echo "→ Würde GESPERRT werden (Modus: ${GEOIP_MODE:-blocklist}, Länder: ${GEOIP_COUNTRIES})"
else
echo "→ Würde ERLAUBT werden (Modus: ${GEOIP_MODE:-blocklist}, Länder: ${GEOIP_COUNTRIES})"
fi
fi
}
# ─── Cache leeren ────────────────────────────────────────────────────────────
flush_cache() {
if [[ -d "$GEOIP_CACHE_DIR" ]]; then
local count
count=$(find "$GEOIP_CACHE_DIR" -name '*.country' -type f 2>/dev/null | wc -l)
rm -f "${GEOIP_CACHE_DIR}"/*.country 2>/dev/null || true
echo "✅ GeoIP-Cache geleert ($count Einträge entfernt)"
log "INFO" "GeoIP-Cache geleert ($count Einträge)"
else
echo " GeoIP-Cache-Verzeichnis existiert nicht"
fi
}
# ─── GeoIP-Sperren aufheben ─────────────────────────────────────────────────
flush_geoip_bans() {
local count=0
if [[ -d "$STATE_DIR" ]]; then
for f in "${STATE_DIR}"/*.ban; do
[[ -f "$f" ]] || continue
local reason
reason=$(grep '^REASON=' "$f" | cut -d= -f2 || true)
if [[ "$reason" == "geoip" ]]; then
local client_ip
client_ip=$(grep '^CLIENT_IP=' "$f" | cut -d= -f2 || true)
# iptables Regel entfernen
if [[ "$client_ip" == *:* ]]; then
ip6tables -D "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true
else
iptables -D "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true
fi
rm -f "$f"
log_ban_history "UNBAN" "$client_ip" "" "geoip-flush"
count=$((count + 1))
fi
done
fi
echo "$count GeoIP-Sperren aufgehoben"
log "INFO" "$count GeoIP-Sperren aufgehoben (flush)"
}
# ─── Hauptprogramm ──────────────────────────────────────────────────────────
case "${1:-help}" in
start)
init_directories
setup_iptables_chain
start_worker
;;
sync)
init_directories
setup_iptables_chain
sync_geoip
;;
status)
init_directories
show_status
;;
lookup)
if [[ -z "${2:-}" ]]; then
echo "Nutzung: $0 lookup <IP-Adresse>" >&2
exit 1
fi
init_directories
lookup_ip "$2"
;;
flush)
init_directories
flush_geoip_bans
;;
flush-cache)
init_directories
flush_cache
;;
stop)
if [[ -f "$WORKER_PID_FILE" ]]; then
local wpid
wpid=$(cat "$WORKER_PID_FILE")
if kill -0 "$wpid" 2>/dev/null; then
kill "$wpid" 2>/dev/null || true
rm -f "$WORKER_PID_FILE"
echo "GeoIP-Worker gestoppt"
else
rm -f "$WORKER_PID_FILE"
echo "GeoIP-Worker war nicht aktiv"
fi
else
echo "GeoIP-Worker läuft nicht"
fi
;;
*)
cat << USAGE
AdGuard Shield - GeoIP Worker
Nutzung: $0 {start|stop|sync|status|lookup|flush|flush-cache}
Befehle:
start Startet den GeoIP-Worker (Hintergrundprozess)
stop Stoppt den GeoIP-Worker
sync Einmalige GeoIP-Prüfung aller aktiven Clients
status Zeigt GeoIP-Status und aktive Sperren
lookup <IP> GeoIP-Lookup für eine einzelne IP
flush Alle GeoIP-Sperren aufheben
flush-cache GeoIP-Lookup-Cache leeren
Konfiguration in: $CONFIG_FILE
GEOIP_ENABLED=true/false
GEOIP_MODE=blocklist/allowlist
GEOIP_COUNTRIES="CN,RU,..."
USAGE
exit 0
;;
esac

24
go.mod Normal file
View File

@@ -0,0 +1,24 @@
module adguard-shield
go 1.22
require (
github.com/oschwald/maxminddb-golang v1.12.0
modernc.org/sqlite v1.29.10
)
require (
github.com/dustin/go-humanize v1.0.1 // 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/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
)

57
go.sum Normal file
View File

@@ -0,0 +1,57 @@
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/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/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=
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/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@@ -1,872 +0,0 @@
#!/bin/bash
###############################################################################
# AdGuard Shield - Installer / Updater / Uninstaller
# Autor: Patrick Asmus
# E-Mail: support@techniverse.net
# Lizenz: MIT
###############################################################################
VERSION="v0.9.0"
set -euo pipefail
INSTALL_DIR="/opt/adguard-shield"
SERVICE_FILE="/etc/systemd/system/adguard-shield.service"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Farben
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
print_header() {
echo ""
echo -e "${BLUE}"
echo " ▄▄▄ ▓█████▄ ▄████ █ ██ ▄▄▄ ██▀███ ▓█████▄ ██████ ██░ ██ ██▓▓█████ ██▓ ▓█████▄ "
echo "▒████▄ ▒██▀ ██▌ ██▒ ▀█▒ ██ ▓██▒▒████▄ ▓██ ▒ ██▒▒██▀ ██▌ ▒██ ▒ ▓██░ ██▒▓██▒▓█ ▀ ▓██▒ ▒██▀ ██▌"
echo "▒██ ▀█▄ ░██ █▌▒██░▄▄▄░▓██ ▒██░▒██ ▀█▄ ▓██ ░▄█ ▒░██ █▌ ░ ▓██▄ ▒██▀▀██░▒██▒▒███ ▒██░ ░██ █▌"
echo "░██▄▄▄▄██ ░▓█▄ ▌░▓█ ██▓▓▓█ ░██░░██▄▄▄▄██ ▒██▀▀█▄ ░▓█▄ ▌ ▒ ██▒░▓█ ░██ ░██░▒▓█ ▄ ▒██░ ░▓█▄ ▌"
echo " ▓█ ▓██▒░▒████▓ ░▒▓███▀▒▒▒█████▓ ▓█ ▓██▒░██▓ ▒██▒░▒████▓ ▒██████▒▒░▓█▒░██▓░██░░▒████▒░██████▒░▒████▓ "
echo " ▒▒ ▓▒█░ ▒▒▓ ▒ ░▒ ▒ ░▒▓▒ ▒ ▒ ▒▒ ▓▒█░░ ▒▓ ░▒▓░ ▒▒▓ ▒ ▒ ▒▓▒ ▒ ░ ▒ ░░▒░▒░▓ ░░ ▒░ ░░ ▒░▓ ░ ▒▒▓ ▒ "
echo " ▒ ▒▒ ░ ░ ▒ ▒ ░ ░ ░░▒░ ░ ░ ▒ ▒▒ ░ ░▒ ░ ▒░ ░ ▒ ▒ ░ ░▒ ░ ░ ▒ ░▒░ ░ ▒ ░ ░ ░ ░░ ░ ▒ ░ ░ ▒ ▒ "
echo " ░ ▒ ░ ░ ░ ░ ░ ░ ░░░ ░ ░ ░ ▒ ░░ ░ ░ ░ ░ ░ ░ ░ ░ ░░ ░ ▒ ░ ░ ░ ░ ░ ░ ░ "
echo " ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ "
echo " ░ ░ ░ "
echo -e "${NC}"
echo -e "${GREEN} Version: ${VERSION}${NC}"
echo -e "${BLUE} Autor: Patrick Asmus${NC}"
echo -e
echo -e "${BLUE} E-Mail: support@techniverse.net${NC}"
echo -e "${BLUE} Web: https://www.patrick-asmus.de${NC}"
echo ""
echo -e "${BLUE}───────────────────────────────────────────────────────────────────────────────────────────────────────────────${NC}"
echo ""
echo -e "${BLUE} Repo: https://git.techniverse.net/scriptos/adguard-shield${NC}"
echo ""
echo -e "${BLUE}═══════════════════════════════════════════════════════════════════════════════════════════════════════════════${NC}"
echo ""
}
# ─── Hilfe-Menü ──────────────────────────────────────────────────────────────
print_help() {
echo -e "${BOLD}Nutzung:${NC} sudo bash $0 [BEFEHL]"
echo ""
echo -e "${BOLD}Verfügbare Befehle:${NC}"
echo ""
echo -e " ${GREEN}install${NC} Vollständige Neuinstallation durchführen"
echo -e " Installiert alle Dateien, fragt die Konfiguration ab,"
echo -e " richtet den systemd Service ein und aktiviert Autostart."
echo ""
echo -e " ${GREEN}update${NC} Update auf die neueste Version"
echo -e " Aktualisiert alle Scripts, führt eine automatische"
echo -e " Konfigurations-Migration durch (neue Parameter werden"
echo -e " hinzugefügt, bestehende Einstellungen bleiben erhalten),"
echo -e " und startet den Service automatisch neu."
echo ""
echo -e " ${GREEN}uninstall${NC} Vollständige Deinstallation"
echo -e " Stoppt den Service, entfernt iptables-Regeln und"
echo -e " löscht alle Dateien (optional Konfiguration behalten)."
echo -e " Delegiert automatisch an den im Installationsverzeichnis"
echo -e " liegenden Uninstaller — kein Behalten der Installationsdateien nötig."
echo -e " Direkt ausführbar: ${CYAN}sudo bash $INSTALL_DIR/uninstall.sh${NC}"
echo ""
echo -e " ${GREEN}status${NC} Installationsstatus anzeigen"
echo -e " Zeigt ob AdGuard Shield installiert ist, welche Version"
echo -e " läuft und ob der Service aktiv ist."
echo ""
echo -e " ${GREEN}--help, -h${NC} Diese Hilfe anzeigen"
echo ""
echo -e "${BOLD}Beispiele:${NC}"
echo -e " ${CYAN}sudo bash install.sh install${NC} # Neuinstallation"
echo -e " ${CYAN}sudo bash install.sh update${NC} # Update durchführen"
echo -e " ${CYAN}sudo bash install.sh uninstall${NC} # Deinstallation"
echo -e " ${CYAN}sudo bash install.sh status${NC} # Status prüfen"
echo ""
echo -e "${BOLD}Service-Befehle:${NC}"
echo -e " ${CYAN}sudo systemctl start adguard-shield${NC} # Service starten"
echo -e " ${CYAN}sudo systemctl stop adguard-shield${NC} # Service stoppen"
echo -e " ${CYAN}sudo systemctl restart adguard-shield${NC} # Service neustarten"
echo -e " ${CYAN}sudo systemctl status adguard-shield${NC} # Service-Status"
echo -e " ${CYAN}sudo journalctl -u adguard-shield -f${NC} # Logs live verfolgen"
echo ""
echo -e "${BOLD}Monitor-Befehle (nach Installation):${NC}"
echo -e " ${CYAN}sudo /opt/adguard-shield/adguard-shield.sh start${NC} # Monitor im Vordergrund starten"
echo -e " ${CYAN}sudo /opt/adguard-shield/adguard-shield.sh stop${NC} # Monitor stoppen"
echo -e " ${CYAN}sudo /opt/adguard-shield/adguard-shield.sh status${NC} # Status & aktive Sperren"
echo -e " ${CYAN}sudo /opt/adguard-shield/adguard-shield.sh history${NC} # Ban-History anzeigen"
echo -e " ${CYAN}sudo /opt/adguard-shield/adguard-shield.sh unban IP${NC} # Einzelne IP entsperren"
echo -e " ${CYAN}sudo /opt/adguard-shield/adguard-shield.sh flush${NC} # Alle Sperren aufheben"
echo -e " ${CYAN}sudo /opt/adguard-shield/adguard-shield.sh test${NC} # API-Verbindung testen"
echo -e " ${CYAN}sudo /opt/adguard-shield/adguard-shield.sh dry-run${NC} # Testmodus (nur loggen)"
echo ""
echo -e "${BOLD}Externe Whitelist-Befehle:${NC}"
echo -e " ${CYAN}sudo /opt/adguard-shield/adguard-shield.sh whitelist-status${NC} # Status der externen Whitelisten"
echo -e " ${CYAN}sudo /opt/adguard-shield/adguard-shield.sh whitelist-sync${NC} # Einmalige Synchronisation"
echo -e " ${CYAN}sudo /opt/adguard-shield/adguard-shield.sh whitelist-flush${NC} # Aufgelöste IPs entfernen"
echo ""
echo -e "${BOLD}iptables-Befehle:${NC}"
echo -e " ${CYAN}sudo /opt/adguard-shield/iptables-helper.sh status${NC} # Firewall-Regeln anzeigen"
echo -e " ${CYAN}sudo /opt/adguard-shield/iptables-helper.sh ban IP${NC} # IP manuell sperren"
echo -e " ${CYAN}sudo /opt/adguard-shield/iptables-helper.sh unban IP${NC} # IP entsperren"
echo -e " ${CYAN}sudo /opt/adguard-shield/iptables-helper.sh flush${NC} # Alle Regeln leeren"
echo -e " ${CYAN}sudo /opt/adguard-shield/iptables-helper.sh create${NC} # Chain erstellen"
echo -e " ${CYAN}sudo /opt/adguard-shield/iptables-helper.sh remove${NC} # Chain komplett entfernen"
echo -e " ${CYAN}sudo /opt/adguard-shield/iptables-helper.sh save${NC} # Regeln speichern"
echo -e " ${CYAN}sudo /opt/adguard-shield/iptables-helper.sh restore${NC} # Regeln wiederherstellen"
echo ""
echo -e "${BOLD}Report-Befehle:${NC}"
echo -e " ${CYAN}sudo /opt/adguard-shield/report-generator.sh status${NC} # Report-Konfiguration anzeigen"
echo -e " ${CYAN}sudo /opt/adguard-shield/report-generator.sh send${NC} # Report sofort senden"
echo -e " ${CYAN}sudo /opt/adguard-shield/report-generator.sh generate${NC} # Report als Datei generieren"
echo -e " ${CYAN}sudo /opt/adguard-shield/report-generator.sh install${NC} # Cron-Job einrichten"
echo -e " ${CYAN}sudo /opt/adguard-shield/report-generator.sh remove${NC} # Cron-Job entfernen"
echo ""
echo -e "${BOLD}Watchdog-Befehle:${NC}"
echo -e " ${CYAN}sudo systemctl status adguard-shield-watchdog.timer${NC} # Watchdog-Status"
echo -e " ${CYAN}sudo systemctl list-timers adguard-shield-watchdog.timer${NC} # Nächste Ausführung"
echo -e " ${CYAN}sudo systemctl enable adguard-shield-watchdog.timer${NC} # Watchdog aktivieren"
echo -e " ${CYAN}sudo systemctl disable adguard-shield-watchdog.timer${NC} # Watchdog deaktivieren"
echo -e " ${CYAN}sudo journalctl -u adguard-shield-watchdog.service${NC} # Watchdog-Logs"
echo ""
echo -e "${BOLD}GeoIP-Befehle:${NC}"
echo -e " ${CYAN}sudo /opt/adguard-shield/adguard-shield.sh geoip-status${NC} # GeoIP-Status anzeigen"
echo -e " ${CYAN}sudo /opt/adguard-shield/adguard-shield.sh geoip-sync${NC} # Einmalige GeoIP-Prüfung"
echo -e " ${CYAN}sudo /opt/adguard-shield/adguard-shield.sh geoip-flush${NC} # Alle GeoIP-Sperren aufheben"
echo -e " ${CYAN}sudo /opt/adguard-shield/adguard-shield.sh geoip-lookup IP${NC} # GeoIP-Lookup einer IP"
echo ""
echo -e "${BOLD}Voraussetzungen:${NC}"
echo " - Linux Server (Debian/Ubuntu empfohlen)"
echo " - Root-Zugriff (sudo)"
echo " - AdGuard Home installiert und erreichbar"
echo " - Pakete: curl, jq, iptables, gawk (werden bei Installation automatisch installiert)"
echo " - GeoIP (optional): geoip-bin + geoip-database oder MaxMind GeoLite2 DB"
echo ""
echo -e "${BOLD}Dokumentation:${NC}"
echo " https://git.techniverse.net/scriptos/adguard-shield"
echo ""
}
# ─── Interaktives Menü ───────────────────────────────────────────────────────
show_menu() {
echo -e "${BOLD}Was möchtest du tun?${NC}"
echo ""
echo -e " ${CYAN}1)${NC} Installation — AdGuard Shield neu installieren"
echo -e " ${CYAN}2)${NC} Update — Auf die neueste Version aktualisieren"
echo -e " ${CYAN}3)${NC} Deinstallation — AdGuard Shield vollständig entfernen"
echo -e " ${CYAN}4)${NC} Status — Installationsstatus anzeigen"
echo -e " ${CYAN}5)${NC} Hilfe — Hilfe & Befehlsübersicht anzeigen"
echo -e " ${CYAN}0)${NC} Beenden"
echo ""
read -rep " Auswahl [0-5]: " choice
echo ""
case "$choice" in
1) do_install ;;
2) do_update ;;
3) do_uninstall ;;
4) do_status ;;
5) print_help ;;
0) echo -e "${GREEN}Auf Wiedersehen!${NC}"; exit 0 ;;
*) echo -e "${RED}Ungültige Auswahl.${NC}"; exit 1 ;;
esac
}
check_root() {
if [[ $EUID -ne 0 ]]; then
echo -e "${RED}Dieses Script muss als root ausgeführt werden!${NC}" >&2
echo "Bitte mit 'sudo $0' ausführen."
exit 1
fi
}
# ─── Abhängigkeiten prüfen und installieren ──────────────────────────────────
check_dependencies() {
echo -e "${YELLOW}Prüfe Abhängigkeiten...${NC}"
local missing_cmds=()
local missing_pkgs=()
# Befehl → Paketname Zuordnung
declare -A cmd_to_pkg=(
[curl]="curl"
[jq]="jq"
[iptables]="iptables"
[ip6tables]="iptables"
[gawk]="gawk"
[systemctl]="systemd"
)
for cmd in curl jq iptables ip6tables gawk systemctl; do
if command -v "$cmd" &>/dev/null; then
echo -e "$cmd"
else
echo -e "$cmd"
missing_cmds+=("$cmd")
local pkg="${cmd_to_pkg[$cmd]}"
# Duplikate vermeiden
if [[ ! " ${missing_pkgs[*]:-} " =~ " ${pkg} " ]]; then
missing_pkgs+=("$pkg")
fi
fi
done
if [[ ${#missing_cmds[@]} -gt 0 ]]; then
echo ""
echo -e "${YELLOW}Installiere fehlende Pakete: ${missing_pkgs[*]}${NC}"
if command -v apt &>/dev/null; then
apt update -qq
apt install -y -qq "${missing_pkgs[@]}"
elif command -v dnf &>/dev/null; then
dnf install -y "${missing_pkgs[@]}"
elif command -v yum &>/dev/null; then
yum install -y "${missing_pkgs[@]}"
elif command -v pacman &>/dev/null; then
pacman -S --noconfirm "${missing_pkgs[@]}"
else
echo -e "${RED}Konnte Paketmanager nicht erkennen. Bitte installiere manuell: ${missing_pkgs[*]}${NC}"
exit 1
fi
echo ""
echo -e "${YELLOW}Prüfe erneut...${NC}"
for cmd in "${missing_cmds[@]}"; do
if command -v "$cmd" &>/dev/null; then
echo -e "$cmd (installiert)"
else
echo -e "$cmd (Installation fehlgeschlagen!)"
echo -e "${RED}FEHLER: $cmd konnte nicht installiert werden. Bitte manuell nachinstallieren.${NC}"
exit 1
fi
done
fi
echo -e " ${GREEN}Alle Abhängigkeiten erfüllt.${NC}"
echo ""
}
install_files() {
echo -e "${YELLOW}Installiere Dateien nach $INSTALL_DIR ...${NC}"
mkdir -p "$INSTALL_DIR"
mkdir -p /var/lib/adguard-shield
mkdir -p /var/log
# Scripts kopieren
cp "$SCRIPT_DIR/adguard-shield.sh" "$INSTALL_DIR/"
cp "$SCRIPT_DIR/iptables-helper.sh" "$INSTALL_DIR/"
cp "$SCRIPT_DIR/unban-expired.sh" "$INSTALL_DIR/"
cp "$SCRIPT_DIR/external-blocklist-worker.sh" "$INSTALL_DIR/"
cp "$SCRIPT_DIR/external-whitelist-worker.sh" "$INSTALL_DIR/"
cp "$SCRIPT_DIR/report-generator.sh" "$INSTALL_DIR/"
cp "$SCRIPT_DIR/adguard-shield-watchdog.sh" "$INSTALL_DIR/"
cp "$SCRIPT_DIR/uninstall.sh" "$INSTALL_DIR/"
cp "$SCRIPT_DIR/geoip-worker.sh" "$INSTALL_DIR/"
cp "$SCRIPT_DIR/offense-cleanup-worker.sh" "$INSTALL_DIR/"
# Templates kopieren
mkdir -p "$INSTALL_DIR/templates"
cp "$SCRIPT_DIR/templates/report.html" "$INSTALL_DIR/templates/"
cp "$SCRIPT_DIR/templates/report.txt" "$INSTALL_DIR/templates/"
# Ausführbar machen
chmod +x "$INSTALL_DIR/adguard-shield.sh"
chmod +x "$INSTALL_DIR/iptables-helper.sh"
chmod +x "$INSTALL_DIR/unban-expired.sh"
chmod +x "$INSTALL_DIR/external-blocklist-worker.sh"
chmod +x "$INSTALL_DIR/external-whitelist-worker.sh"
chmod +x "$INSTALL_DIR/report-generator.sh"
chmod +x "$INSTALL_DIR/adguard-shield-watchdog.sh"
chmod +x "$INSTALL_DIR/uninstall.sh"
chmod +x "$INSTALL_DIR/geoip-worker.sh"
chmod +x "$INSTALL_DIR/offense-cleanup-worker.sh"
echo -e " ✅ Dateien installiert"
echo ""
}
# ─── Konfigurations-Migration ────────────────────────────────────────────────
# Vergleicht die bestehende Konfiguration mit der neuen Version.
# - Bestehende Einstellungen des Benutzers bleiben IMMER erhalten
# - Neue Parameter (die in der alten Konfig fehlen) werden automatisch ergänzt
# - Die alte Konfiguration wird als .conf.old gesichert
migrate_config() {
local existing_conf="$INSTALL_DIR/adguard-shield.conf"
local new_conf="$SCRIPT_DIR/adguard-shield.conf"
local backup_conf="$INSTALL_DIR/adguard-shield.conf.old"
if [[ ! -f "$existing_conf" ]]; then
# Keine bestehende Konfig → einfach kopieren
cp "$new_conf" "$existing_conf"
chmod 600 "$existing_conf"
echo -e " ✅ Konfiguration kopiert (Neuinstallation)"
return 0
fi
echo -e "${YELLOW}Führe Konfigurations-Migration durch...${NC}"
# Backup der aktuellen Konfiguration erstellen
cp "$existing_conf" "$backup_conf"
echo -e " 📦 Backup erstellt: adguard-shield.conf.old"
# Alle Schlüssel aus der bestehenden Konfig extrahieren (nur KEY=... Zeilen)
local existing_keys=()
while IFS= read -r line; do
# Zeilen mit KEY=VALUE extrahieren (keine Kommentare, keine leeren Zeilen)
if [[ "$line" =~ ^[A-Z_][A-Z0-9_]*= ]]; then
local key="${line%%=*}"
existing_keys+=("$key")
fi
done < "$existing_conf"
# Neue Schlüssel finden die in der bestehenden Konfig fehlen
local new_keys_added=0
local current_comment_block=""
while IFS= read -r line; do
# Kommentarblock sammeln (für Kontext bei neuen Keys)
if [[ "$line" =~ ^#.* ]] || [[ -z "$line" ]]; then
current_comment_block+="$line"$'\n'
continue
fi
# KEY=VALUE Zeile prüfen
if [[ "$line" =~ ^[A-Z_][A-Z0-9_]*= ]]; then
local key="${line%%=*}"
local found=false
for existing_key in "${existing_keys[@]}"; do
if [[ "$key" == "$existing_key" ]]; then
found=true
break
fi
done
if [[ "$found" == "false" ]]; then
# Neuer Parameter gefunden → mit Kommentarblock an bestehende Konfig anhängen
if [[ $new_keys_added -eq 0 ]]; then
echo "" >> "$existing_conf"
echo "# ─── Neue Parameter (automatisch bei Update hinzugefügt) ───" >> "$existing_conf"
fi
echo -n "$current_comment_block" >> "$existing_conf"
echo "$line" >> "$existing_conf"
echo -e " Neuer Parameter hinzugefügt: ${GREEN}$key${NC}"
new_keys_added=$((new_keys_added + 1))
fi
fi
current_comment_block=""
done < "$new_conf"
chmod 600 "$existing_conf"
if [[ $new_keys_added -eq 0 ]]; then
echo -e " ✅ Konfiguration ist aktuell — keine neuen Parameter"
else
echo -e "${new_keys_added} neue Parameter zur Konfiguration hinzugefügt"
echo -e " ${YELLOW} Backup der alten Konfig: $backup_conf${NC}"
echo -e " ${YELLOW} Bitte prüfe die neuen Parameter in: $existing_conf${NC}"
fi
echo ""
}
install_service() {
echo -e "${YELLOW}Installiere systemd Service...${NC}"
cp "$SCRIPT_DIR/adguard-shield.service" "$SERVICE_FILE"
cp "$SCRIPT_DIR/adguard-shield-watchdog.service" /etc/systemd/system/adguard-shield-watchdog.service
cp "$SCRIPT_DIR/adguard-shield-watchdog.timer" /etc/systemd/system/adguard-shield-watchdog.timer
systemctl daemon-reload
echo -e " ✅ Service-Dateien installiert (inkl. Watchdog)"
echo ""
# Interaktiv: Autostart beim Booten?
read -rep " Soll AdGuard Shield beim Booten automatisch starten? [J/n]: " autostart
if [[ "${autostart,,}" != "n" ]]; then
systemctl enable adguard-shield.service
systemctl enable adguard-shield-watchdog.timer
echo -e " ✅ Autostart aktiviert (inkl. Watchdog-Timer)"
else
systemctl disable adguard-shield.service 2>/dev/null || true
systemctl disable adguard-shield-watchdog.timer 2>/dev/null || true
echo -e " Autostart nicht aktiviert"
echo -e " ${YELLOW}Später aktivieren mit: sudo systemctl enable adguard-shield${NC}"
fi
echo ""
}
configure() {
echo -e "${YELLOW}Konfiguration:${NC}"
echo ""
local conf="$INSTALL_DIR/adguard-shield.conf"
# AdGuard URL
read -rep " AdGuard Home URL [http://127.0.0.1:3000]: " adguard_url
adguard_url="${adguard_url:-http://127.0.0.1:3000}"
sed -i "s|^ADGUARD_URL=.*|ADGUARD_URL=\"$adguard_url\"|" "$conf"
# Benutzername
read -rep " AdGuard Home Benutzername [admin]: " adguard_user
adguard_user="${adguard_user:-admin}"
sed -i "s|^ADGUARD_USER=.*|ADGUARD_USER=\"$adguard_user\"|" "$conf"
# Passwort
read -resp " AdGuard Home Passwort: " adguard_pass
echo ""
if [[ -n "$adguard_pass" ]]; then
# Einfache Quotes damit $-Zeichen im Passwort nicht expandiert werden
sed -i "s|^ADGUARD_PASS=.*|ADGUARD_PASS='$adguard_pass'|" "$conf"
fi
# Rate Limit
read -rep " Max. Anfragen pro Domain/Client pro Minute [30]: " rate_limit
rate_limit="${rate_limit:-30}"
sed -i "s|^RATE_LIMIT_MAX_REQUESTS=.*|RATE_LIMIT_MAX_REQUESTS=$rate_limit|" "$conf"
# Sperrdauer
read -rep " Sperrdauer in Sekunden [3600]: " ban_duration
ban_duration="${ban_duration:-3600}"
sed -i "s|^BAN_DURATION=.*|BAN_DURATION=$ban_duration|" "$conf"
# Whitelist
read -rep " Whitelist IPs (kommagetrennt) [127.0.0.1,::1]: " whitelist
whitelist="${whitelist:-127.0.0.1,::1}"
sed -i "s|^WHITELIST=.*|WHITELIST=\"$whitelist\"|" "$conf"
echo ""
echo -e " ✅ Konfiguration gespeichert"
echo ""
}
test_connection() {
echo -e "${YELLOW}Teste Verbindung zur AdGuard Home API...${NC}"
source "$INSTALL_DIR/adguard-shield.conf"
# ── Schritt 1: Base-URL Erreichbarkeit prüfen ────────────────────────
echo -e " ${CYAN}1)${NC} Prüfe Erreichbarkeit von ${BOLD}${ADGUARD_URL}${NC} ..."
local base_http_code
local base_curl_exit
base_http_code=$(curl -s -o /dev/null -w "%{http_code}" \
--connect-timeout 5 --max-time 10 \
-k "${ADGUARD_URL}" 2>/dev/null) || base_curl_exit=$?
base_curl_exit=${base_curl_exit:-0}
if [[ "$base_curl_exit" -ne 0 ]]; then
# curl konnte keine Verbindung aufbauen
echo -e " ❌ Base-URL nicht erreichbar! (curl Exit-Code: $base_curl_exit)"
case "$base_curl_exit" in
6) echo -e " ${YELLOW}→ DNS-Auflösung fehlgeschlagen. Hostname prüfen!${NC}" ;;
7) echo -e " ${YELLOW}→ Verbindung abgelehnt. Läuft AdGuard Home? Port korrekt?${NC}" ;;
28) echo -e " ${YELLOW}→ Timeout. Host nicht erreichbar oder Firewall blockiert.${NC}" ;;
35|51|60) echo -e " ${YELLOW}→ SSL/TLS-Fehler. Zertifikat oder HTTPS-Konfiguration prüfen.${NC}" ;;
*) echo -e " ${YELLOW}→ Unbekannter Fehler. Manuell testen: curl -v ${ADGUARD_URL}${NC}" ;;
esac
echo ""
echo -e " ${YELLOW}Troubleshooting:${NC}"
echo -e " curl -ikv ${ADGUARD_URL}"
echo ""
return 1
fi
if [[ "$base_http_code" == "000" ]]; then
echo -e " ❌ Base-URL nicht erreichbar (keine HTTP-Antwort)"
echo -e " ${YELLOW}→ Manuell testen: curl -ikv ${ADGUARD_URL}${NC}"
echo ""
return 1
fi
echo -e " ✅ Base-URL erreichbar (HTTP $base_http_code)"
# ── Schritt 2: API-Endpunkt mit Authentifizierung testen ─────────────
echo -e " ${CYAN}2)${NC} Teste API-Authentifizierung ..."
local api_response
api_response=$(curl -s -o /dev/null -w "%{http_code}" \
-u "${ADGUARD_USER}:${ADGUARD_PASS}" \
--connect-timeout 5 --max-time 10 \
-k "${ADGUARD_URL}/control/querylog?limit=1" 2>/dev/null)
if [[ "$api_response" == "200" ]]; then
echo -e " ✅ API-Authentifizierung erfolgreich! (HTTP $api_response)"
elif [[ "$api_response" == "401" || "$api_response" == "403" ]]; then
echo -e " ❌ Authentifizierung fehlgeschlagen (HTTP $api_response)"
echo -e " ${YELLOW}→ Benutzername oder Passwort falsch!${NC}"
echo -e " ${YELLOW}→ Prüfe ADGUARD_USER und ADGUARD_PASS in: $INSTALL_DIR/adguard-shield.conf${NC}"
else
echo -e " ❌ API-Verbindung fehlgeschlagen (HTTP $api_response)"
echo -e " ${YELLOW}→ Bitte prüfe URL und Zugangsdaten in: $INSTALL_DIR/adguard-shield.conf${NC}"
fi
echo ""
}
print_summary() {
# Service-Status dynamisch ermitteln
local svc_status="gestoppt"
local autostart_status="deaktiviert"
if systemctl is-active adguard-shield &>/dev/null 2>&1; then
svc_status="läuft ✅"
fi
if systemctl is-enabled adguard-shield &>/dev/null 2>&1; then
autostart_status="aktiviert ✅"
fi
echo -e "${GREEN}═══════════════════════════════════════════════════════════════${NC}"
echo -e "${GREEN} AdGuard Shield - Installation abgeschlossen!${NC}"
echo -e "${GREEN}═══════════════════════════════════════════════════════════════${NC}"
echo ""
echo " Installationspfad: $INSTALL_DIR"
echo " Konfiguration: $INSTALL_DIR/adguard-shield.conf"
echo " Service: adguard-shield.service ($svc_status)"
echo " Autostart: $autostart_status"
# Watchdog-Status
local watchdog_status="deaktiviert"
if systemctl is-active adguard-shield-watchdog.timer &>/dev/null 2>&1; then
watchdog_status="aktiv ✅"
elif systemctl is-enabled adguard-shield-watchdog.timer &>/dev/null 2>&1; then
watchdog_status="aktiviert (Timer nicht gestartet)"
fi
echo " Watchdog: $watchdog_status"
echo " Log-Datei: /var/log/adguard-shield.log"
echo ""
echo " Nützliche Befehle:"
echo " ──────────────────"
echo " Konfiguration bearbeiten:"
echo " sudo nano $INSTALL_DIR/adguard-shield.conf"
echo ""
echo " Dry-Run testen (nur loggen, nichts sperren):"
echo " sudo $INSTALL_DIR/adguard-shield.sh dry-run"
echo ""
echo " Service steuern:"
echo " sudo systemctl start|stop|restart adguard-shield"
echo " sudo systemctl status adguard-shield"
echo ""
echo " Logs verfolgen:"
echo " sudo journalctl -u adguard-shield -f"
echo " sudo tail -f /var/log/adguard-shield.log"
echo ""
echo " Weitere Befehle:"
echo " sudo $INSTALL_DIR/iptables-helper.sh status"
echo " sudo $INSTALL_DIR/adguard-shield.sh flush"
echo " sudo $INSTALL_DIR/adguard-shield.sh unban <IP>"
echo ""
echo " E-Mail Report:"
echo " sudo $INSTALL_DIR/report-generator.sh status"
echo " sudo $INSTALL_DIR/report-generator.sh install"
echo " sudo $INSTALL_DIR/report-generator.sh send"
echo ""
echo " Hilfe anzeigen:"
echo " sudo bash install.sh --help"
echo ""
echo " Deinstallieren (auch ohne Installationsdateien):"
echo " sudo bash $INSTALL_DIR/uninstall.sh"
echo ""
}
# ─── Status anzeigen ─────────────────────────────────────────────────────────
do_status() {
check_root
echo -e "${YELLOW}Installationsstatus:${NC}"
echo ""
# Installiert?
if [[ -d "$INSTALL_DIR" ]]; then
echo -e " ✅ AdGuard Shield ist installiert in: $INSTALL_DIR"
# Version aus installiertem Script lesen
if [[ -f "$INSTALL_DIR/adguard-shield.sh" ]]; then
local installed_version
installed_version=$(grep -m1 '^VERSION=' "$INSTALL_DIR/adguard-shield.sh" 2>/dev/null | cut -d'"' -f2)
echo -e " 📌 Installierte Version: ${GREEN}${installed_version:-unbekannt}${NC}"
fi
else
echo -e " ❌ AdGuard Shield ist NICHT installiert"
echo ""
return
fi
# Service-Status
if systemctl is-enabled adguard-shield &>/dev/null 2>&1; then
echo -e " ✅ Autostart: aktiviert"
else
echo -e " ❌ Autostart: deaktiviert"
fi
if systemctl is-active adguard-shield &>/dev/null 2>&1; then
echo -e " ✅ Service: läuft"
else
echo -e " ❌ Service: gestoppt"
fi
# Konfig vorhanden?
if [[ -f "$INSTALL_DIR/adguard-shield.conf" ]]; then
echo -e " ✅ Konfiguration: vorhanden"
else
echo -e " ❌ Konfiguration: fehlt!"
fi
# Watchdog-Status
if systemctl is-active adguard-shield-watchdog.timer &>/dev/null 2>&1; then
echo -e " ✅ Watchdog-Timer: aktiv"
elif systemctl is-enabled adguard-shield-watchdog.timer &>/dev/null 2>&1; then
echo -e " ⚠️ Watchdog-Timer: aktiviert aber nicht gestartet"
else
echo -e " ❌ Watchdog-Timer: nicht installiert/deaktiviert"
fi
echo ""
}
# ─── Installation ────────────────────────────────────────────────────────────
do_install() {
check_root
# Prüfen ob bereits installiert
if [[ -d "$INSTALL_DIR" ]] && [[ -f "$INSTALL_DIR/adguard-shield.sh" ]]; then
echo -e "${YELLOW}AdGuard Shield ist bereits installiert!${NC}"
echo ""
read -rep " Möchtest du stattdessen ein Update durchführen? [j/N]: " do_upd
if [[ "${do_upd,,}" == "j" ]]; then
do_update
return
else
echo -e "${RED}Installation abgebrochen.${NC}"
exit 0
fi
fi
check_dependencies
install_files
# Bei Neuinstallation Konfig kopieren
cp "$SCRIPT_DIR/adguard-shield.conf" "$INSTALL_DIR/"
chmod 600 "$INSTALL_DIR/adguard-shield.conf"
echo -e " ✅ Konfiguration kopiert"
echo ""
configure
install_service
test_connection
# Interaktiv: Service jetzt starten?
echo -e "${YELLOW}Service starten:${NC}"
read -rep " Soll der AdGuard Shield Service jetzt gestartet werden? [J/n]: " start_now
if [[ "${start_now,,}" != "n" ]]; then
systemctl start adguard-shield
systemctl start adguard-shield-watchdog.timer 2>/dev/null || true
echo -e " ✅ Service gestartet (inkl. Watchdog-Timer)"
else
echo -e " Service nicht gestartet"
echo -e " ${YELLOW}Später starten mit: sudo systemctl start adguard-shield${NC}"
fi
echo ""
print_summary
}
# ─── Update ──────────────────────────────────────────────────────────────────
do_update() {
check_root
# Prüfen ob installiert
if [[ ! -d "$INSTALL_DIR" ]] || [[ ! -f "$INSTALL_DIR/adguard-shield.sh" ]]; then
echo -e "${RED}AdGuard Shield ist nicht installiert!${NC}"
echo "Bitte zuerst installieren: sudo bash $0 install"
exit 1
fi
echo -e "${YELLOW}Starte Update von AdGuard Shield...${NC}"
echo ""
check_dependencies
install_files
# Konfigurations-Migration durchführen
migrate_config
# Service-Datei aktualisieren
echo -e "${YELLOW}Aktualisiere systemd Service...${NC}"
cp "$SCRIPT_DIR/adguard-shield.service" "$SERVICE_FILE"
cp "$SCRIPT_DIR/adguard-shield-watchdog.service" /etc/systemd/system/adguard-shield-watchdog.service
cp "$SCRIPT_DIR/adguard-shield-watchdog.timer" /etc/systemd/system/adguard-shield-watchdog.timer
systemctl daemon-reload
echo -e " ✅ Service-Dateien aktualisiert (inkl. Watchdog)"
echo ""
# Interaktiv: Autostart beim Booten?
if systemctl is-enabled adguard-shield &>/dev/null; then
echo -e " Autostart ist bereits aktiviert"
# Watchdog-Timer auch aktivieren falls noch nicht aktiv
if ! systemctl is-enabled adguard-shield-watchdog.timer &>/dev/null 2>&1; then
systemctl enable adguard-shield-watchdog.timer
systemctl start adguard-shield-watchdog.timer
echo -e " ✅ Watchdog-Timer aktiviert"
fi
else
read -rep " Soll AdGuard Shield beim Booten automatisch starten? [J/n]: " autostart
if [[ "${autostart,,}" != "n" ]]; then
systemctl enable adguard-shield.service
systemctl enable adguard-shield-watchdog.timer
systemctl start adguard-shield-watchdog.timer
echo -e " ✅ Autostart aktiviert (inkl. Watchdog-Timer)"
else
echo -e " Autostart bleibt deaktiviert"
fi
fi
echo ""
# Interaktiv: Service neu starten?
local service_was_active=false
if systemctl is-active adguard-shield &>/dev/null; then
service_was_active=true
fi
if [[ "$service_was_active" == "true" ]]; then
read -rep " Soll der Service jetzt neu gestartet werden? [J/n]: " restart_now
if [[ "${restart_now,,}" != "n" ]]; then
systemctl restart adguard-shield
echo -e " ✅ Service wurde neu gestartet"
else
echo -e " Service wurde NICHT neu gestartet"
echo -e " ${YELLOW}Bitte manuell neustarten: sudo systemctl restart adguard-shield${NC}"
fi
else
read -rep " Soll der Service jetzt gestartet werden? [J/n]: " start_now
if [[ "${start_now,,}" != "n" ]]; then
systemctl start adguard-shield
echo -e " ✅ Service gestartet"
else
echo -e " Service nicht gestartet"
echo -e " ${YELLOW}Später starten mit: sudo systemctl start adguard-shield${NC}"
fi
fi
echo ""
echo -e "${GREEN}═══════════════════════════════════════════════════════════════${NC}"
echo -e "${GREEN} AdGuard Shield - Update abgeschlossen!${NC}"
echo -e "${GREEN}═══════════════════════════════════════════════════════════════${NC}"
echo ""
echo " Bitte prüfe bei Bedarf die Konfiguration:"
echo " sudo nano $INSTALL_DIR/adguard-shield.conf"
echo ""
if [[ -f "$INSTALL_DIR/adguard-shield.conf.old" ]]; then
echo " Backup der vorherigen Konfiguration:"
echo " $INSTALL_DIR/adguard-shield.conf.old"
echo ""
fi
}
# ─── Deinstallation ─────────────────────────────────────────────────────────
do_uninstall() {
check_root
# Prüfen ob installiert
if [[ ! -d "$INSTALL_DIR" ]]; then
echo -e "${RED}AdGuard Shield ist nicht installiert!${NC}"
exit 1
fi
# An den im Installationsverzeichnis liegenden Uninstaller delegieren
if [[ -f "$INSTALL_DIR/uninstall.sh" ]]; then
exec bash "$INSTALL_DIR/uninstall.sh"
fi
# Fallback für ältere Installationen ohne uninstall.sh
echo -e "${YELLOW}Deinstalliere AdGuard Shield (Fallback-Modus)...${NC}"
echo ""
read -rep " Wirklich deinstallieren? [j/N]: " confirm
if [[ "${confirm,,}" != "j" ]]; then
echo -e "${GREEN}Deinstallation abgebrochen.${NC}"
exit 0
fi
echo ""
if systemctl is-active adguard-shield &>/dev/null; then
systemctl stop adguard-shield
echo " ✅ Service gestoppt"
fi
if systemctl is-enabled adguard-shield &>/dev/null; then
systemctl disable adguard-shield
echo " ✅ Service deaktiviert"
fi
rm -f "$SERVICE_FILE"
systemctl daemon-reload
echo " ✅ Service-Datei entfernt"
if [[ -f "$INSTALL_DIR/iptables-helper.sh" ]]; then
bash "$INSTALL_DIR/iptables-helper.sh" remove || true
fi
read -rep " Konfiguration und Logs behalten? [j/N]: " keep
if [[ "${keep,,}" == "j" ]]; then
rm -f "$INSTALL_DIR/adguard-shield.sh"
rm -f "$INSTALL_DIR/iptables-helper.sh"
rm -f "$INSTALL_DIR/unban-expired.sh"
rm -f "$INSTALL_DIR/external-blocklist-worker.sh"
rm -f "$INSTALL_DIR/external-whitelist-worker.sh"
rm -f "$INSTALL_DIR/offense-cleanup-worker.sh"
rm -f "$INSTALL_DIR/geoip-worker.sh"
rm -f "$INSTALL_DIR/report-generator.sh"
rm -f "$INSTALL_DIR/adguard-shield-watchdog.sh"
rm -f "$INSTALL_DIR/uninstall.sh"
rm -rf "$INSTALL_DIR/templates"
rm -rf "$INSTALL_DIR/geoip"
echo " ✅ Scripts entfernt (Konfiguration und Logs behalten)"
else
rm -rf "$INSTALL_DIR"
rm -rf /var/lib/adguard-shield
rm -f /var/log/adguard-shield.log*
rm -f /var/log/adguard-shield-bans.log
echo " ✅ Alles entfernt"
fi
echo ""
echo -e "${GREEN}Deinstallation abgeschlossen.${NC}"
}
# ─── Hauptprogramm ──────────────────────────────────────────────────────────
main() {
case "${1:-}" in
install)
print_header
do_install
;;
update)
print_header
do_update
;;
uninstall)
# print_header wird vom delegierten uninstall.sh übernommen
do_uninstall
;;
status)
print_header
do_status
;;
--help|-h)
print_header
print_help
;;
"")
# Kein Argument → interaktives Menü anzeigen
print_header
show_menu
;;
*)
echo -e "${RED}Unbekannter Befehl: $1${NC}"
echo ""
print_help
exit 1
;;
esac
}
main "$@"

View File

@@ -0,0 +1,5 @@
package appinfo
var Version = "v1.0.0"
const ProjectURL = "https://tnvs.de/as"

295
internal/config/config.go Normal file
View File

@@ -0,0 +1,295 @@
package config
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
type Config struct {
Path string
AdGuardURL string
AdGuardUser string
AdGuardPass string
RateLimitMaxRequests int
RateLimitWindow int
CheckInterval int
APIQueryLimit int
SubdomainFloodEnabled bool
SubdomainFloodMaxUnique int
SubdomainFloodWindow int
DNSFloodWatchlistEnabled bool
DNSFloodWatchlist []string
BanDuration int64
Chain string
BlockedPorts []string
FirewallBackend string
FirewallMode string
DryRun bool
Whitelist []string
LogFile string
LogLevel string
StateDir string
PIDFile string
NotifyEnabled bool
NotifyType string
NotifyWebhook string
NTFYServerURL string
NTFYTopic string
NTFYToken string
NTFYPriority string
ReportEnabled bool
ReportInterval string
ReportTime string
ReportEmailTo string
ReportEmailFrom string
ReportFormat string
ReportMailCmd string
ReportBusiestDayRange int
ExternalWhitelistEnabled bool
ExternalWhitelistURLs []string
ExternalWhitelistInterval int
ExternalWhitelistCacheDir string
ExternalBlocklistEnabled bool
ExternalBlocklistURLs []string
ExternalBlocklistInterval int
ExternalBlocklistCacheDir string
ExternalBlocklistDuration int64
ExternalBlocklistAutoUnban bool
ExternalBlocklistNotify bool
ProgressiveBanEnabled bool
ProgressiveBanMultiplier int
ProgressiveBanMaxLevel int
ProgressiveBanResetAfter int64
AbuseIPDBEnabled bool
AbuseIPDBAPIKey string
AbuseIPDBCategories string
GeoIPEnabled bool
GeoIPMode string
GeoIPCountries []string
GeoIPNotify bool
GeoIPSkipPrivate bool
GeoIPLicenseKey string
GeoIPMMDBPath string
GeoIPCacheTTL int64
GeoIPCheckInterval int
}
func Load(path string) (*Config, error) {
values, err := parseFile(path)
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)
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
}
func DefaultPath() string {
if v := os.Getenv("ADGUARD_SHIELD_CONFIG"); v != "" {
return v
}
if _, err := os.Stat("/opt/adguard-shield/adguard-shield.conf"); err == nil {
return "/opt/adguard-shield/adguard-shield.conf"
}
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 parseFile(path string) (map[string]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open config %s: %w", path, err)
}
defer f.Close()
out := map[string]string{}
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
idx := strings.Index(line, "=")
if idx < 1 {
continue
}
key := strings.TrimSpace(line[:idx])
val := stripInlineComment(strings.TrimSpace(line[idx+1:]))
out[key] = unquote(val)
}
return out, sc.Err()
}
func stripInlineComment(s string) string {
inSingle, inDouble := false, false
for i, r := range s {
switch r {
case '\'':
if !inDouble {
inSingle = !inSingle
}
case '"':
if !inSingle {
inDouble = !inDouble
}
case '#':
if !inSingle && !inDouble {
if i == 0 || s[i-1] == ' ' || s[i-1] == '\t' {
return strings.TrimSpace(s[:i])
}
}
}
}
return strings.TrimSpace(s)
}
func unquote(s string) string {
if len(s) >= 2 {
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
return s[1 : len(s)-1]
}
}
return s
}
func stringVal(m map[string]string, k, def string) string {
if v, ok := m[k]; ok {
return v
}
return def
}
func intVal(m map[string]string, k string, def int) int {
v, ok := m[k]
if !ok || strings.TrimSpace(v) == "" {
return def
}
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return def
}
return n
}
func boolVal(m map[string]string, k string, def bool) bool {
v, ok := m[k]
if !ok {
return def
}
switch strings.ToLower(strings.TrimSpace(v)) {
case "true", "1", "yes", "on":
return true
case "false", "0", "no", "off":
return false
default:
return def
}
}
func csv(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func upperCSV(s string) []string {
parts := csv(s)
for i := range parts {
parts[i] = strings.ToUpper(parts[i])
}
return parts
}
func fields(s string) []string {
out := strings.Fields(s)
if len(out) == 0 {
return []string{"53"}
}
return out
}

View File

@@ -0,0 +1,44 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadParsesShellStyleConfig(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "adguard-shield.conf")
err := os.WriteFile(path, []byte(`
ADGUARD_URL="https://dns.example"
ADGUARD_USER="admin"
ADGUARD_PASS='pa#ss'
CHECK_INTERVAL=7
BLOCKED_PORTS="53 443 853"
FIREWALL_BACKEND="ipset"
FIREWALL_MODE="docker-bridge"
GEOIP_ENABLED=true
GEOIP_MODE="allowlist"
GEOIP_COUNTRIES="DE, us"
GEOIP_CACHE_TTL=123
`), 0600)
if err != nil {
t.Fatal(err)
}
c, err := Load(path)
if err != nil {
t.Fatal(err)
}
if c.AdGuardPass != "pa#ss" {
t.Fatalf("quoted # was not preserved: %q", c.AdGuardPass)
}
if c.CheckInterval != 7 || c.FirewallBackend != "ipset" || c.FirewallMode != "docker-bridge" {
t.Fatalf("unexpected config: %+v", c)
}
if got := c.GeoIPCountries; len(got) != 2 || got[0] != "DE" || got[1] != "US" {
t.Fatalf("unexpected countries: %#v", got)
}
if c.GeoIPCacheTTL != 123 {
t.Fatalf("unexpected GeoIP cache ttl: %d", c.GeoIPCacheTTL)
}
}

1221
internal/daemon/daemon.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,365 @@
package daemon
import (
"context"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"adguard-shield/internal/config"
"adguard-shield/internal/db"
"adguard-shield/internal/firewall"
)
func TestParseListEntry(t *testing.T) {
cases := map[string]string{
"1.2.3.4 # comment": "1.2.3.4",
"0.0.0.0 bad.example": "bad.example",
"2001:db8::/32": "2001:db8::/32",
}
for input, want := range cases {
got := parseListEntry(input)
if len(got) != 1 || got[0] != want {
t.Fatalf("%q -> %#v, want %q", input, got, want)
}
}
if got := parseListEntry("http://example.invalid/list"); got != nil {
t.Fatalf("URL should be rejected: %#v", got)
}
}
func TestNotificationFormatting(t *testing.T) {
d := &Daemon{Config: &config.Config{
RateLimitWindow: 60,
SubdomainFloodWindow: 120,
ProgressiveBanMaxLevel: 3,
}}
b := db.Ban{
IP: "203.0.113.7",
Domain: "abb.com",
Count: 110,
Duration: 3600,
OffenseLevel: 1,
Reason: "rate-limit",
Protocol: "dns",
Source: "monitor",
}
if got, want := d.displayBanReason(b), "110x abb.com in 60s via DNS, Rate-Limit"; got != want {
t.Fatalf("reason = %q, want %q", got, want)
}
if got, want := d.displayBanDuration(b), "1h 0m [Stufe 1/3]"; got != want {
t.Fatalf("duration = %q, want %q", got, want)
}
b.Permanent = true
b.Duration = 0
b.OffenseLevel = 3
if got, want := d.displayBanDuration(b), "PERMANENT [Stufe 3/3]"; got != want {
t.Fatalf("permanent duration = %q, want %q", got, want)
}
}
func TestNTFYNotificationTitleDoesNotDuplicateShieldTag(t *testing.T) {
d := &Daemon{Config: &config.Config{
NotifyType: "ntfy",
NTFYServerURL: "https://ntfy.example",
NTFYTopic: "adguard-shield",
NTFYPriority: "4",
}}
req, err := d.notificationRequest(context.Background(), "🛡️ AdGuard Shield", "test", db.Ban{IP: "203.0.113.7", Reason: "rate-limit", Source: "monitor"})
if err != nil {
t.Fatal(err)
}
if req == nil {
t.Fatal("request must be created")
}
if got, want := req.Header.Get("Title"), "🛡️ AdGuard Shield"; got != want {
t.Fatalf("title = %q, want %q", got, want)
}
if got := req.Header.Get("Tags"); strings.Contains(got, "shield") {
t.Fatalf("tags must not duplicate title shield emoji: %q", got)
}
}
func TestNotificationRequestsForWebhookProviders(t *testing.T) {
cases := []struct {
name string
notifyType string
wantType string
wantPayload []string
}{
{
name: "discord",
notifyType: "discord",
wantType: "application/json",
wantPayload: []string{`"content":"title\n\nmessage"`},
},
{
name: "slack",
notifyType: "slack",
wantType: "application/json",
wantPayload: []string{`"text":"title\n\nmessage"`},
},
{
name: "generic",
notifyType: "generic",
wantType: "application/json",
wantPayload: []string{`"action":"unban"`, `"client":"203.0.113.7"`, `"message":"message"`},
},
{
name: "gotify",
notifyType: "gotify",
wantType: "application/x-www-form-urlencoded",
wantPayload: []string{`title=title`, `message=message`, `priority=5`},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
d := &Daemon{Config: &config.Config{
NotifyType: tc.notifyType,
NotifyWebhook: "https://hooks.example/notify",
}}
req, err := d.notificationRequest(context.Background(), "title", "message", db.Ban{IP: "203.0.113.7", Reason: "manual"})
if err != nil {
t.Fatal(err)
}
if req == nil {
t.Fatal("request must be created")
}
if req.Method != http.MethodPost {
t.Fatalf("method = %s, want POST", req.Method)
}
if got := req.Header.Get("Content-Type"); got != tc.wantType {
t.Fatalf("content type = %q, want %q", got, tc.wantType)
}
body, err := io.ReadAll(req.Body)
if err != nil {
t.Fatal(err)
}
payload := string(body)
for _, want := range tc.wantPayload {
if !strings.Contains(payload, want) {
t.Fatalf("payload %q does not contain %q", payload, want)
}
}
})
}
}
func TestServiceNotificationsSendStartAndStopOnce(t *testing.T) {
requests := make(chan string, 4)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
requests <- string(body)
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
d := &Daemon{
Config: &config.Config{NotifyEnabled: true, NotifyType: "generic", NotifyWebhook: srv.URL},
Client: srv.Client(),
}
d.NotifyServiceStart(context.Background())
d.NotifyServiceStart(context.Background())
d.NotifyServiceStop(context.Background())
d.NotifyServiceStop(context.Background())
var payloads []string
for len(payloads) < 2 {
select {
case payload := <-requests:
payloads = append(payloads, payload)
case <-time.After(4 * time.Second):
t.Fatalf("service notifications sent %d payloads, want 2", len(payloads))
}
}
if !strings.Contains(payloads[0], `"action":"service_start"`) || !strings.Contains(payloads[0], "gestartet") {
t.Fatalf("unexpected service start payload: %s", payloads[0])
}
if !strings.Contains(payloads[1], `"action":"service_stop"`) || !strings.Contains(payloads[1], "gestoppt") {
t.Fatalf("unexpected service stop payload: %s", payloads[1])
}
select {
case payload := <-requests:
t.Fatalf("duplicate service notification sent: %s", payload)
case <-time.After(150 * time.Millisecond):
}
}
func TestUnbanSendsNotificationForMonitorBan(t *testing.T) {
requests := make(chan string, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
requests <- string(body)
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
store, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
if err := store.InsertBan(db.Ban{IP: "127.0.0.1", Reason: "rate-limit", Source: "monitor"}); err != nil {
t.Fatal(err)
}
d := &Daemon{
Config: &config.Config{NotifyEnabled: true, NotifyType: "generic", NotifyWebhook: srv.URL},
Store: store,
FW: firewall.New(firewall.OSExecutor{}, "ADGUARD_SHIELD", []string{"53"}, "host", true),
Client: srv.Client(),
}
if err := d.Unban(context.Background(), "127.0.0.1", "manual"); err != nil {
t.Fatal(err)
}
select {
case payload := <-requests:
if !strings.Contains(payload, `"action":"unban"`) || !strings.Contains(payload, "AdGuard Shield Freigabe") {
t.Fatalf("unexpected payload: %s", payload)
}
case <-time.After(4 * time.Second):
t.Fatal("unban notification was not sent")
}
}
func TestUnbanStillSendsExternalBlocklistNotificationWhenBanNotificationsDisabled(t *testing.T) {
requests := make(chan string, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
requests <- string(body)
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
store, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
if err := store.InsertBan(db.Ban{IP: "127.0.0.1", Reason: "external-blocklist", Source: "external-blocklist"}); err != nil {
t.Fatal(err)
}
d := &Daemon{
Config: &config.Config{NotifyEnabled: true, NotifyType: "generic", NotifyWebhook: srv.URL, ExternalBlocklistNotify: false},
Store: store,
FW: firewall.New(firewall.OSExecutor{}, "ADGUARD_SHIELD", []string{"53"}, "host", true),
Client: srv.Client(),
}
if err := d.Unban(context.Background(), "127.0.0.1", "external-blocklist-removed"); err != nil {
t.Fatal(err)
}
select {
case payload := <-requests:
if !strings.Contains(payload, `"action":"unban"`) || !strings.Contains(payload, "AdGuard Shield Freigabe") {
t.Fatalf("unexpected payload: %s", payload)
}
case <-time.After(4 * time.Second):
t.Fatal("external blocklist unban notification was not sent")
}
}
func TestUnbanQuietSkipsIndividualNotificationAndBulkSummarySendsOnce(t *testing.T) {
requests := make(chan string, 2)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
requests <- string(body)
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
store, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
if err := store.InsertBan(db.Ban{IP: "127.0.0.1", Reason: "rate-limit", Source: "monitor"}); err != nil {
t.Fatal(err)
}
d := &Daemon{
Config: &config.Config{NotifyEnabled: true, NotifyType: "generic", NotifyWebhook: srv.URL},
Store: store,
FW: firewall.New(firewall.OSExecutor{}, "ADGUARD_SHIELD", []string{"53"}, "host", true),
Client: srv.Client(),
}
if err := d.UnbanQuiet(context.Background(), "127.0.0.1", "manual-flush"); err != nil {
t.Fatal(err)
}
select {
case payload := <-requests:
t.Fatalf("quiet unban sent individual notification: %s", payload)
case <-time.After(150 * time.Millisecond):
}
d.NotifyBulkUnban(context.Background(), "manual-flush", 1)
select {
case payload := <-requests:
if !strings.Contains(payload, `"action":"manual-flush"`) || !strings.Contains(payload, "Bulk-Freigabe") || !strings.Contains(payload, "Freigegebene IPs: 1") {
t.Fatalf("unexpected payload: %s", payload)
}
case <-time.After(4 * time.Second):
t.Fatal("bulk unban notification was not sent")
}
}
func TestAbuseReportingScope(t *testing.T) {
d := &Daemon{Config: &config.Config{AbuseIPDBEnabled: true, AbuseIPDBAPIKey: "key"}}
if !d.shouldReportAbuseIPDB(db.Ban{Permanent: true, Source: "monitor"}) {
t.Fatal("monitor permanent ban should be reported")
}
if d.shouldReportAbuseIPDB(db.Ban{Permanent: true, Source: "geoip"}) {
t.Fatal("geoip ban must not be reported")
}
if d.shouldReportAbuseIPDB(db.Ban{Permanent: false, Source: "monitor"}) {
t.Fatal("temporary ban must not be reported")
}
d.Config.RateLimitWindow = 60
got := d.abuseIPDBComment(db.Ban{Count: 110, Domain: "abb.com", Reason: "rate-limit"})
want := "DNS flooding on our DNS server: 110x abb.com in 60s. Banned by Adguard Shield 🔗 https://tnvs.de/as"
if got != want {
t.Fatalf("comment = %q, want %q", got, want)
}
}
func TestAbuseIPDBCheckURL(t *testing.T) {
if got := abuseIPDBCheckURL("65.185.189.75"); !strings.Contains(got, "https://www.abuseipdb.com/check/65.185.189.75") {
t.Fatalf("unexpected AbuseIPDB url: %s", got)
}
}
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)
}
}
func TestDryRunDoesNotInsertActiveBan(t *testing.T) {
store, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
d := &Daemon{
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 {
t.Fatal(err)
}
ok, err := store.BanExists("1.2.3.4")
if err != nil {
t.Fatal(err)
}
if ok {
t.Fatal("dry-run must not create an active ban")
}
}

384
internal/daemon/live.go Normal file
View File

@@ -0,0 +1,384 @@
package daemon
import (
"bufio"
"context"
"fmt"
"io"
"os"
"sort"
"strings"
"time"
"adguard-shield/internal/db"
"adguard-shield/internal/syslog"
)
type LiveOptions struct {
Interval time.Duration
Top int
Recent int
LogLevel string
Once bool
}
type liveSnapshot struct {
At time.Time
APIEntries int
Window int
Limit int
Events []queryEvent
TopPairs []liveCount
SubdomainGroups []liveCount
ActiveBans []db.Ban
Offenses int
ExpiredOffenses int
WhitelistCount int
BlocklistBans int
SystemLogs []string
}
type liveCount struct {
Client string
Domain string
Count int
Protocol string
}
func (d *Daemon) Live(ctx context.Context, w io.Writer, opts LiveOptions) error {
if opts.Interval <= 0 {
opts.Interval = time.Duration(d.Config.CheckInterval) * time.Second
}
if opts.Interval <= 0 {
opts.Interval = 2 * time.Second
}
if opts.Top <= 0 {
opts.Top = 10
}
if opts.Recent <= 0 {
opts.Recent = 12
}
if strings.TrimSpace(opts.LogLevel) == "" {
opts.LogLevel = "INFO"
}
for {
snap, err := d.liveSnapshot(ctx, opts)
renderLive(w, d, snap, err, opts)
if opts.Once {
return err
}
timer := time.NewTimer(opts.Interval)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
}
func (d *Daemon) liveSnapshot(ctx context.Context, opts LiveOptions) (liveSnapshot, error) {
snap := liveSnapshot{
At: time.Now(),
Window: d.Config.RateLimitWindow,
Limit: d.Config.RateLimitMaxRequests,
}
items, err := d.FetchQueryLog(ctx)
if err != nil {
return snap, err
}
snap.APIEntries = len(items)
events := dedupeEvents(d.toEvents(items))
sort.Slice(events, func(i, j int) bool { return events[i].At.After(events[j].At) })
if len(events) > opts.Recent {
snap.Events = append([]queryEvent(nil), events[:opts.Recent]...)
} else {
snap.Events = append([]queryEvent(nil), events...)
}
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 {
snap.ActiveBans = bans
}
if n, err := d.Store.CountOffenses(); err == nil {
snap.Offenses = n
}
if n, err := d.Store.CountExpiredOffenses(d.Config.ProgressiveBanResetAfter); err == nil {
snap.ExpiredOffenses = n
}
if wl, err := d.Store.AllWhitelist(); err == nil {
snap.WhitelistCount = len(wl)
}
if n, err := d.Store.CountBySource("external-blocklist"); err == nil {
snap.BlocklistBans = n
}
snap.SystemLogs = RecentLogLines(d.Config.LogFile, opts.LogLevel, opts.Recent)
return snap, nil
}
func dedupeEvents(events []queryEvent) []queryEvent {
seen := map[string]bool{}
out := make([]queryEvent, 0, len(events))
for _, ev := range events {
key := ev.At.Format(time.RFC3339Nano) + "|" + ev.Client + "|" + ev.Domain + "|" + ev.Protocol
if seen[key] {
continue
}
seen[key] = true
out = append(out, ev)
}
return out
}
func topQueryPairs(events []queryEvent, window, limit int) []liveCount {
cut := time.Now().Add(-time.Duration(window) * time.Second)
counts := map[string]*liveCount{}
protos := map[string]map[string]bool{}
for _, ev := range events {
if ev.At.Before(cut) {
continue
}
key := ev.Client + "|" + ev.Domain
if counts[key] == nil {
counts[key] = &liveCount{Client: ev.Client, Domain: ev.Domain}
protos[key] = map[string]bool{}
}
counts[key].Count++
protos[key][formatProtocol(ev.Protocol)] = true
}
out := make([]liveCount, 0, len(counts))
for key, item := range counts {
item.Protocol = strings.Join(sortedKeys(protos[key]), ",")
out = append(out, *item)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Count == out[j].Count {
return out[i].Client+"|"+out[i].Domain < out[j].Client+"|"+out[j].Domain
}
return out[i].Count > out[j].Count
})
if limit > 0 && len(out) > limit {
return out[:limit]
}
return out
}
func topSubdomainGroups(events []queryEvent, window, limit int) []liveCount {
cut := time.Now().Add(-time.Duration(window) * time.Second)
sets := map[string]map[string]bool{}
protos := 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{}
protos[key] = map[string]bool{}
}
sets[key][ev.Domain] = true
protos[key][formatProtocol(ev.Protocol)] = true
}
out := make([]liveCount, 0, len(sets))
for key, set := range sets {
client, domain, _ := strings.Cut(key, "|")
out = append(out, liveCount{
Client: client,
Domain: domain,
Count: len(set),
Protocol: strings.Join(sortedKeys(protos[key]), ","),
})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Count == out[j].Count {
return out[i].Client+"|"+out[i].Domain < out[j].Client+"|"+out[j].Domain
}
return out[i].Count > out[j].Count
})
if limit > 0 && len(out) > limit {
return out[:limit]
}
return out
}
func renderLive(w io.Writer, d *Daemon, snap liveSnapshot, snapErr error, opts LiveOptions) {
fmt.Fprint(w, "\033[H\033[2J")
fmt.Fprintf(w, "AdGuard Shield Live | %s | Strg+C beendet\n", snap.At.Format("2006-01-02 15:04:05"))
fmt.Fprintln(w, strings.Repeat("=", 92))
fmt.Fprintf(w, "Config: %s | API: %s | Log: %s (ab %s)\n", d.Config.Path, d.Config.AdGuardURL, d.Config.LogFile, strings.ToUpper(opts.LogLevel))
if snapErr != nil {
fmt.Fprintf(w, "\nFEHLER: Live-Snapshot konnte nicht geladen werden: %v\n", snapErr)
return
}
fmt.Fprintf(w, "\nWorker und Module\n")
fmt.Fprintf(w, " Query-Poller: alle %ds | API-Eintraege: %d | Zeitfenster: %ds | Limit: %d\n", d.Config.CheckInterval, snap.APIEntries, snap.Window, snap.Limit)
fmt.Fprintf(w, " GeoIP: %s | Modus: %s | Laender: %s\n", enabled(d.Config.GeoIPEnabled), d.Config.GeoIPMode, listOrDash(d.Config.GeoIPCountries))
fmt.Fprintf(w, " Externe Blocklist: %s | Intervall: %ds | URLs: %d | aktive Sperren: %d\n", enabled(d.Config.ExternalBlocklistEnabled), d.Config.ExternalBlocklistInterval, len(d.Config.ExternalBlocklistURLs), snap.BlocklistBans)
fmt.Fprintf(w, " Externe Whitelist: %s | Intervall: %ds | URLs: %d | aufgeloeste IPs: %d\n", enabled(d.Config.ExternalWhitelistEnabled), d.Config.ExternalWhitelistInterval, len(d.Config.ExternalWhitelistURLs), snap.WhitelistCount)
fmt.Fprintf(w, " Offense-Cleanup: %s | Zaehler: %d | davon abgelaufen: %d\n", enabled(d.Config.ProgressiveBanEnabled), snap.Offenses, snap.ExpiredOffenses)
fmt.Fprintf(w, "\nTop Client/Domain im Rate-Limit-Fenster\n")
if len(snap.TopPairs) == 0 {
fmt.Fprintln(w, " Keine Anfragen im aktuellen Zeitfenster.")
} else {
for _, item := range snap.TopPairs {
fmt.Fprintf(w, " %5s %-39s %-34s %s\n", fmt.Sprintf("%d/%d", item.Count, snap.Limit), trim(item.Client, 39), trim(item.Domain, 34), item.Protocol)
}
}
if d.Config.SubdomainFloodEnabled {
fmt.Fprintf(w, "\nSubdomain-Flood-Kandidaten\n")
if len(snap.SubdomainGroups) == 0 {
fmt.Fprintln(w, " Keine Subdomain-Gruppen im aktuellen Zeitfenster.")
} else {
for _, item := range snap.SubdomainGroups {
fmt.Fprintf(w, " %5s %-39s %-34s %s\n", fmt.Sprintf("%d/%d", item.Count, d.Config.SubdomainFloodMaxUnique), trim(item.Client, 39), trim(item.Domain, 34), item.Protocol)
}
}
}
fmt.Fprintf(w, "\nLetzte Queries\n")
if len(snap.Events) == 0 {
fmt.Fprintln(w, " Keine Querylog-Eintraege gefunden.")
} else {
for _, ev := range snap.Events {
fmt.Fprintf(w, " %s %-39s %-8s %s\n", ev.At.Local().Format("15:04:05"), trim(ev.Client, 39), formatProtocol(ev.Protocol), trim(ev.Domain, 44))
}
}
fmt.Fprintf(w, "\nAktive Sperren\n")
if len(snap.ActiveBans) == 0 {
fmt.Fprintln(w, " Keine aktiven Sperren.")
} else {
maxBans := opts.Top
if len(snap.ActiveBans) < maxBans {
maxBans = 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 strings.ToLower(opts.LogLevel) != "off" {
fmt.Fprintf(w, "\nSystemereignisse\n")
if len(snap.SystemLogs) == 0 {
fmt.Fprintln(w, " Keine passenden Logeintraege.")
} else {
for _, line := range snap.SystemLogs {
fmt.Fprintf(w, " %s\n", trim(line, 88))
}
}
}
}
func RecentLogLines(path, minLevel string, limit int) []string {
if strings.EqualFold(strings.TrimSpace(minLevel), "off") || path == "" || limit <= 0 {
return nil
}
f, err := os.Open(path)
if err != nil {
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
}
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])
}
return out
}
func logLineLevel(line string) syslog.Level {
for _, level := range []syslog.Level{syslog.Error, syslog.Warn, syslog.Info, syslog.Debug} {
if strings.Contains(line, "["+syslog.LevelName(level)+"]") {
return level
}
}
return syslog.Info
}
func sortedKeys(m map[string]bool) []string {
keys := make([]string, 0, len(m))
for k := range m {
if k != "" {
keys = append(keys, k)
}
}
sort.Strings(keys)
return keys
}
func formatProtocol(proto string) string {
switch strings.ToLower(strings.TrimSpace(proto)) {
case "doh":
return "DoH"
case "dot":
return "DoT"
case "doq":
return "DoQ"
case "dnscrypt":
return "DNSCrypt"
case "", "dns":
return "DNS"
default:
return proto
}
}
func enabled(ok bool) string {
if ok {
return "aktiv"
}
return "inaktiv"
}
func listOrDash(items []string) string {
if len(items) == 0 {
return "-"
}
return strings.Join(items, ",")
}
func trim(s string, max int) string {
if len(s) <= max {
return s
}
if max <= 1 {
return s[:max]
}
return s[:max-1] + "~"
}
func banUntil(b db.Ban) string {
if b.Permanent || b.BanUntil == 0 {
return "permanent"
}
return time.Unix(b.BanUntil, 0).Format("2006-01-02 15:04:05")
}

408
internal/db/db.go Normal file
View File

@@ -0,0 +1,408 @@
package db
import (
"database/sql"
"fmt"
"time"
_ "modernc.org/sqlite"
)
type Store struct{ DB *sql.DB }
type Ban struct {
IP string
Domain string
Count int
BanUntil int64
Duration int64
OffenseLevel int
Permanent bool
Reason string
Protocol string
Source string
GeoIPCountry string
GeoIPMode string
}
type ReportStats struct {
Since int64
Until int64
TotalBans int
TotalUnbans int
ActiveBans int
TopClients []ReportCount
Reasons []ReportCount
Sources []ReportCount
RecentEvents []string
}
type ReportCount struct {
Name string
Count int
}
func Open(path string) (*Store, error) {
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
if err != nil {
return nil, err
}
s := &Store{DB: db}
if err := s.Init(); err != nil {
db.Close()
return nil, err
}
return s, nil
}
func (s *Store) Close() error { return s.DB.Close() }
func (s *Store) Init() error {
schema := `
PRAGMA journal_mode=WAL;
PRAGMA busy_timeout=5000;
CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY, applied_at TEXT DEFAULT (datetime('now', 'localtime')));
CREATE TABLE IF NOT EXISTS active_bans (
client_ip TEXT PRIMARY KEY, domain TEXT, count INTEGER, ban_time TEXT,
ban_until_epoch INTEGER DEFAULT 0, ban_duration INTEGER DEFAULT 0, offense_level INTEGER DEFAULT 0,
is_permanent INTEGER DEFAULT 0, reason TEXT DEFAULT 'rate-limit', protocol TEXT DEFAULT 'DNS',
source TEXT DEFAULT 'monitor', geoip_country TEXT, geoip_mode TEXT, created_at TEXT DEFAULT (datetime('now', 'localtime')));
CREATE TABLE IF NOT EXISTS offense_tracking (
client_ip TEXT PRIMARY KEY, offense_level INTEGER DEFAULT 0, last_offense_epoch INTEGER,
last_offense TEXT, first_offense TEXT, created_at TEXT DEFAULT (datetime('now', 'localtime')),
updated_at TEXT DEFAULT (datetime('now', 'localtime')));
CREATE TABLE IF NOT EXISTS ban_history (
id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp_epoch INTEGER NOT NULL, timestamp_text TEXT NOT NULL,
action TEXT NOT NULL, client_ip TEXT NOT NULL, domain TEXT, count TEXT, duration TEXT, protocol TEXT, reason TEXT);
CREATE TABLE IF NOT EXISTS whitelist_cache (ip_address TEXT PRIMARY KEY, source TEXT, resolved_at TEXT DEFAULT (datetime('now', 'localtime')));
CREATE TABLE IF NOT EXISTS geoip_cache (ip TEXT PRIMARY KEY, country_code TEXT NOT NULL, looked_up_at_epoch INTEGER NOT NULL, db_mtime INTEGER DEFAULT 0);
CREATE INDEX IF NOT EXISTS idx_bans_until ON active_bans(ban_until_epoch);
CREATE INDEX IF NOT EXISTS idx_bans_source ON active_bans(source);
CREATE INDEX IF NOT EXISTS idx_bans_reason ON active_bans(reason);
CREATE INDEX IF NOT EXISTS idx_history_timestamp ON ban_history(timestamp_epoch);
CREATE INDEX IF NOT EXISTS idx_history_action ON ban_history(action);
CREATE INDEX IF NOT EXISTS idx_history_ip ON ban_history(client_ip);
CREATE INDEX IF NOT EXISTS idx_offenses_last ON offense_tracking(last_offense_epoch);
CREATE INDEX IF NOT EXISTS idx_geoip_cache_age ON geoip_cache(looked_up_at_epoch);
INSERT OR IGNORE INTO schema_version (version) VALUES (1);`
_, err := s.DB.Exec(schema)
return err
}
func (s *Store) BanExists(ip string) (bool, error) {
var one int
err := s.DB.QueryRow(`SELECT 1 FROM active_bans WHERE client_ip=? LIMIT 1`, ip).Scan(&one)
if err == sql.ErrNoRows {
return false, nil
}
return err == nil, err
}
func (s *Store) InsertBan(b Ban) error {
now := time.Now()
perm := 0
if b.Permanent {
perm = 1
}
_, err := s.DB.Exec(`INSERT OR REPLACE INTO 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,
b.Reason, b.Protocol, b.Source, b.GeoIPCountry, b.GeoIPMode)
return err
}
func (s *Store) DeleteBan(ip string) error {
_, err := s.DB.Exec(`DELETE FROM active_bans WHERE client_ip=?`, ip)
return err
}
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()
}
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()
}
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()
}
func (s *Store) CountBySource(source string) (int, error) {
var count int
err := s.DB.QueryRow(`SELECT COUNT(*) FROM active_bans WHERE source=?`, source).Scan(&count)
return count, err
}
func (s *Store) ExpiredBans(now int64) ([]string, error) {
rows, err := s.DB.Query(`SELECT client_ip FROM active_bans WHERE ban_until_epoch > 0 AND is_permanent = 0 AND ban_until_epoch <= ?`, now)
if err != nil {
return nil, err
}
defer rows.Close()
var ips []string
for rows.Next() {
var ip string
if err := rows.Scan(&ip); err != nil {
return nil, err
}
ips = append(ips, ip)
}
return ips, rows.Err()
}
func (s *Store) History(action, ip, domain, count, duration, protocol, reason string) error {
now := time.Now()
_, err := s.DB.Exec(`INSERT INTO ban_history (timestamp_epoch, timestamp_text, action, client_ip, domain, count, duration, protocol, reason)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, now.Unix(), now.Format("2006-01-02 15:04:05"), action, ip, domain, count, duration, protocol, reason)
return err
}
func (s *Store) RecentHistory(limit int) ([]string, error) {
rows, err := s.DB.Query(`SELECT timestamp_text, action, client_ip, COALESCE(domain,''), COALESCE(count,''), COALESCE(duration,''), COALESCE(protocol,''), COALESCE(reason,'')
FROM ban_history ORDER BY id DESC LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var ts, action, ip, domain, count, duration, proto, reason string
if err := rows.Scan(&ts, &action, &ip, &domain, &count, &duration, &proto, &reason); err != nil {
return nil, err
}
out = append(out, fmt.Sprintf("%s | %s | %s | %s | %s | %s | %s | %s", ts, action, ip, domain, count, duration, proto, reason))
}
return out, rows.Err()
}
func (s *Store) WhitelistContains(ip string) (bool, error) {
var one int
err := s.DB.QueryRow(`SELECT 1 FROM whitelist_cache WHERE ip_address=? LIMIT 1`, ip).Scan(&one)
if err == sql.ErrNoRows {
return false, nil
}
return err == nil, err
}
func (s *Store) ReplaceWhitelist(ips []string, source string) error {
tx, err := s.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM whitelist_cache WHERE source=? OR source IS NULL`, source); err != nil {
return err
}
stmt, err := tx.Prepare(`INSERT OR IGNORE INTO whitelist_cache (ip_address, source) VALUES (?, ?)`)
if err != nil {
return err
}
defer stmt.Close()
for _, ip := range ips {
if _, err := stmt.Exec(ip, source); err != nil {
return err
}
}
return tx.Commit()
}
func (s *Store) AllWhitelist() (map[string]bool, error) {
rows, err := s.DB.Query(`SELECT ip_address FROM whitelist_cache`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]bool{}
for rows.Next() {
var ip string
if err := rows.Scan(&ip); err != nil {
return nil, err
}
out[ip] = true
}
return out, rows.Err()
}
func (s *Store) IncrementOffense(ip string, resetAfter int64) (int, error) {
now := time.Now()
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"))
return level, err
}
func (s *Store) ResetOffense(ip string) error {
if ip == "" {
_, err := s.DB.Exec(`DELETE FROM offense_tracking`)
return err
}
_, err := s.DB.Exec(`DELETE FROM offense_tracking WHERE client_ip=?`, ip)
return err
}
func (s *Store) CleanupOffenses(resetAfter int64) (int64, error) {
cutoff := time.Now().Unix() - resetAfter
res, err := s.DB.Exec(`DELETE FROM offense_tracking WHERE last_offense_epoch <= ?`, cutoff)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (s *Store) CountOffenses() (int, error) {
var count int
err := s.DB.QueryRow(`SELECT COUNT(*) FROM offense_tracking`).Scan(&count)
return count, err
}
func (s *Store) CountExpiredOffenses(resetAfter int64) (int, error) {
var count int
cutoff := time.Now().Unix() - resetAfter
err := s.DB.QueryRow(`SELECT COUNT(*) FROM offense_tracking WHERE last_offense_epoch <= ?`, cutoff).Scan(&count)
return count, err
}
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)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]string{}
for rows.Next() {
var ip, cc string
if err := rows.Scan(&ip, &cc); err != nil {
return nil, err
}
out[ip] = cc
}
return out, rows.Err()
}
func (s *Store) UpsertGeoIP(ip, country string, dbMtime int64) error {
_, err := s.DB.Exec(`INSERT OR REPLACE INTO geoip_cache (ip, country_code, looked_up_at_epoch, db_mtime) VALUES (?, ?, ?, ?)`, ip, country, time.Now().Unix(), dbMtime)
return err
}
func (s *Store) ClearGeoIPCache() (int64, error) {
res, err := s.DB.Exec(`DELETE FROM geoip_cache`)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (s *Store) ReportStats(since, until 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 {
return st, err
}
if err := s.DB.QueryRow(`SELECT COUNT(*) FROM ban_history WHERE action='UNBAN' AND timestamp_epoch BETWEEN ? AND ?`, since, until).Scan(&st.TotalUnbans); err != nil {
return st, err
}
if err := s.DB.QueryRow(`SELECT COUNT(*) FROM active_bans`).Scan(&st.ActiveBans); err != nil {
return st, err
}
var err error
st.TopClients, err = s.reportCounts(`SELECT client_ip, COUNT(*) FROM ban_history WHERE action='BAN' AND timestamp_epoch BETWEEN ? AND ? GROUP BY client_ip ORDER BY COUNT(*) DESC, client_ip LIMIT ?`, since, until, limit)
if err != nil {
return st, err
}
st.Reasons, err = s.reportCounts(`SELECT COALESCE(NULLIF(reason,''), 'unknown'), COUNT(*) FROM ban_history WHERE action='BAN' AND timestamp_epoch BETWEEN ? AND ? GROUP BY COALESCE(NULLIF(reason,''), 'unknown') ORDER BY COUNT(*) DESC LIMIT ?`, since, until, limit)
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)
if err != nil {
return st, err
}
st.RecentEvents, err = s.RecentHistory(limit)
return st, err
}
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)
}
if err != nil {
return nil, err
}
defer rows.Close()
var out []ReportCount
for rows.Next() {
var item ReportCount
if err := rows.Scan(&item.Name, &item.Count); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}

31
internal/db/db_test.go Normal file
View File

@@ -0,0 +1,31 @@
package db
import (
"path/filepath"
"testing"
)
func TestStoreBanAndGeoIPCache(t *testing.T) {
s, err := Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
defer s.Close()
if err := s.InsertBan(Ban{IP: "1.2.3.4", Domain: "example.com", Permanent: true, Reason: "geoip", Source: "geoip", GeoIPCountry: "CN"}); err != nil {
t.Fatal(err)
}
ok, err := s.BanExists("1.2.3.4")
if err != nil || !ok {
t.Fatalf("ban not found: %v %v", ok, err)
}
if err := s.UpsertGeoIP("1.2.3.4", "CN", 123); err != nil {
t.Fatal(err)
}
cache, err := s.LoadGeoIPCache(86400, 123)
if err != nil {
t.Fatal(err)
}
if cache["1.2.3.4"] != "CN" {
t.Fatalf("unexpected cache: %#v", cache)
}
}

View File

@@ -0,0 +1,203 @@
package firewall
import (
"context"
"fmt"
"net/netip"
"os/exec"
"strconv"
"strings"
)
type Executor interface {
Run(ctx context.Context, name string, args ...string) error
}
type OSExecutor struct{}
func (OSExecutor) Run(ctx context.Context, name string, args ...string) error {
return exec.CommandContext(ctx, name, args...).Run()
}
type Firewall struct {
Exec Executor
Chain string
Ports []string
Mode string
DryRun bool
Set4 string
Set6 string
}
func New(exec Executor, chain string, ports []string, mode string, dry bool) *Firewall {
return &Firewall{Exec: exec, Chain: chain, Ports: ports, Mode: normalizeMode(mode), DryRun: dry, Set4: "adguard_shield_v4", Set6: "adguard_shield_v6"}
}
func (f *Firewall) Setup(ctx context.Context) error {
if f.DryRun {
return nil
}
if len(f.hooks("iptables")) == 0 {
return fmt.Errorf("unsupported firewall mode %q", f.Mode)
}
_ = f.Exec.Run(ctx, "ipset", "create", f.Set4, "hash:net", "family", "inet", "timeout", "0", "-exist")
_ = f.Exec.Run(ctx, "ipset", "create", f.Set6, "hash:net", "family", "inet6", "timeout", "0", "-exist")
_ = f.Exec.Run(ctx, "iptables", "-N", f.Chain)
_ = f.Exec.Run(ctx, "ip6tables", "-N", f.Chain)
if err := ensureSetDrop(ctx, f.Exec, "iptables", f.Chain, f.Set4); err != nil {
return err
}
if err := ensureSetDrop(ctx, f.Exec, "ip6tables", f.Chain, f.Set6); err != nil {
return err
}
if err := f.ensureHooks(ctx, "iptables"); err != nil {
return err
}
if err := f.ensureHooks(ctx, "ip6tables"); err != nil {
return err
}
return nil
}
func ensureRule(ctx context.Context, ex Executor, bin string, args ...string) bool {
return ex.Run(ctx, bin, args...) == nil
}
func ensureSetDrop(ctx context.Context, ex Executor, bin, chain, set string) error {
check := []string{"-C", chain, "-m", "set", "--match-set", set, "src", "-j", "DROP"}
if ex.Run(ctx, bin, check...) == nil {
return nil
}
return ex.Run(ctx, bin, "-I", chain, "-m", "set", "--match-set", set, "src", "-j", "DROP")
}
type hook struct {
Chain string
OptionalMissing bool
}
func normalizeMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "", "host", "classic", "native", "docker-host":
return "host"
case "docker", "docker-bridge", "docker-published", "published":
return "docker-bridge"
case "hybrid", "both":
return "hybrid"
default:
return strings.ToLower(strings.TrimSpace(mode))
}
}
func (f *Firewall) hooks(bin string) []hook {
docker := hook{Chain: "DOCKER-USER", OptionalMissing: bin == "ip6tables"}
switch f.Mode {
case "host":
return []hook{{Chain: "INPUT"}}
case "docker-bridge":
return []hook{docker}
case "hybrid":
return []hook{{Chain: "INPUT"}, docker}
default:
return nil
}
}
func (f *Firewall) ensureHooks(ctx context.Context, bin string) error {
for _, h := range f.hooks(bin) {
if !chainExists(ctx, f.Exec, bin, h.Chain) {
if h.OptionalMissing {
continue
}
return fmt.Errorf("%s chain %s not found", bin, h.Chain)
}
for _, p := range f.Ports {
for _, proto := range []string{"tcp", "udp"} {
check := []string{"-C", h.Chain, "-p", proto, "--dport", p, "-j", f.Chain}
if ensureRule(ctx, f.Exec, bin, check...) {
continue
}
_ = f.Exec.Run(ctx, bin, "-I", h.Chain, "-p", proto, "--dport", p, "-j", f.Chain)
}
}
}
return nil
}
func chainExists(ctx context.Context, ex Executor, bin, chain string) bool {
return ex.Run(ctx, bin, "-n", "-L", chain) == nil
}
func (f *Firewall) Add(ctx context.Context, ip string, timeout int64) error {
if f.DryRun {
return nil
}
set, err := f.setFor(ip)
if err != nil {
return err
}
args := []string{"add", set, ip, "-exist"}
if timeout > 0 {
args = append(args, "timeout", strconv.FormatInt(timeout, 10))
}
return f.Exec.Run(ctx, "ipset", args...)
}
func (f *Firewall) Del(ctx context.Context, ip string) error {
if f.DryRun {
return nil
}
set, err := f.setFor(ip)
if err != nil {
return err
}
_ = f.Exec.Run(ctx, "ipset", "del", set, ip)
return nil
}
func (f *Firewall) Flush(ctx context.Context) error {
if f.DryRun {
return nil
}
_ = f.Exec.Run(ctx, "ipset", "flush", f.Set4)
_ = f.Exec.Run(ctx, "ipset", "flush", f.Set6)
return nil
}
func (f *Firewall) Remove(ctx context.Context) error {
if f.DryRun {
return nil
}
for _, p := range f.Ports {
for _, proto := range []string{"tcp", "udp"} {
for _, parent := range []string{"INPUT", "DOCKER-USER"} {
_ = 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.Exec.Run(ctx, "iptables", "-F", f.Chain)
_ = f.Exec.Run(ctx, "ip6tables", "-F", f.Chain)
_ = f.Exec.Run(ctx, "iptables", "-X", f.Chain)
_ = f.Exec.Run(ctx, "ip6tables", "-X", f.Chain)
_ = f.Exec.Run(ctx, "ipset", "destroy", f.Set4)
_ = f.Exec.Run(ctx, "ipset", "destroy", f.Set6)
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
}
return f.Set6, nil
}
a, err := netip.ParseAddr(s)
if err != nil {
return "", fmt.Errorf("invalid IP/prefix %q", s)
}
if a.Is4() {
return f.Set4, nil
}
return f.Set6, nil
}

View File

@@ -0,0 +1,142 @@
package firewall
import (
"context"
"strings"
"testing"
)
type fakeExec struct {
calls []string
failChecks bool
missing map[string]bool
}
func (f *fakeExec) Run(_ context.Context, name string, args ...string) error {
call := name + " " + strings.Join(args, " ")
f.calls = append(f.calls, call)
if f.missing != nil && f.missing[call] {
return errFake
}
if f.failChecks && len(args) > 0 && args[0] == "-C" {
return errFake
}
return nil
}
type fakeErr string
func (e fakeErr) Error() string { return string(e) }
var errFake = fakeErr("missing")
func TestFirewallSetupCreatesSetsAndRules(t *testing.T) {
ex := &fakeExec{failChecks: true}
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{
"ipset create adguard_shield_v4 hash:net family inet timeout 0 -exist",
"iptables -I INPUT -p tcp --dport 53 -j ADGUARD_SHIELD",
"iptables -I ADGUARD_SHIELD -m set --match-set adguard_shield_v4 src -j DROP",
"ip6tables -I ADGUARD_SHIELD -m set --match-set adguard_shield_v6 src -j DROP",
} {
if !strings.Contains(joined, want) {
t.Fatalf("missing call %q in:\n%s", want, joined)
}
}
}
func TestFirewallSetupUsesDockerUserForBridgeMode(t *testing.T) {
ex := &fakeExec{failChecks: true}
fw := New(ex, "ADGUARD_SHIELD", []string{"53"}, "docker-bridge", false)
if err := fw.Setup(context.Background()); err != nil {
t.Fatal(err)
}
joined := strings.Join(ex.calls, "\n")
if !strings.Contains(joined, "iptables -I DOCKER-USER -p udp --dport 53 -j ADGUARD_SHIELD") {
t.Fatalf("missing docker hook in:\n%s", joined)
}
if strings.Contains(joined, "iptables -I INPUT -p udp --dport 53 -j ADGUARD_SHIELD") {
t.Fatalf("unexpected INPUT hook in docker-bridge mode:\n%s", joined)
}
}
func TestFirewallSetupHybridUsesInputAndDockerUser(t *testing.T) {
ex := &fakeExec{failChecks: true}
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")
for _, want := range []string{
"iptables -I INPUT -p tcp --dport 53 -j ADGUARD_SHIELD",
"iptables -I DOCKER-USER -p tcp --dport 53 -j ADGUARD_SHIELD",
} {
if !strings.Contains(joined, want) {
t.Fatalf("missing call %q in:\n%s", want, joined)
}
}
}
func TestFirewallSetupRequiresDockerUserForIPv4BridgeMode(t *testing.T) {
ex := &fakeExec{missing: map[string]bool{"iptables -n -L DOCKER-USER": true}}
fw := New(ex, "ADGUARD_SHIELD", []string{"53"}, "docker-bridge", false)
if err := fw.Setup(context.Background()); err == nil || !strings.Contains(err.Error(), "DOCKER-USER") {
t.Fatalf("expected DOCKER-USER error, got %v", err)
}
}
func TestFirewallSetupSkipsMissingIPv6DockerUser(t *testing.T) {
ex := &fakeExec{
failChecks: true,
missing: map[string]bool{"ip6tables -n -L DOCKER-USER": true},
}
fw := New(ex, "ADGUARD_SHIELD", []string{"53"}, "docker-bridge", false)
if err := fw.Setup(context.Background()); err != nil {
t.Fatal(err)
}
joined := strings.Join(ex.calls, "\n")
if strings.Contains(joined, "ip6tables -I DOCKER-USER") {
t.Fatalf("unexpected IPv6 docker hook with missing DOCKER-USER:\n%s", joined)
}
}
func TestFirewallSetupRejectsUnknownMode(t *testing.T) {
fw := New(&fakeExec{}, "ADGUARD_SHIELD", []string{"53"}, "surprise", false)
err := fw.Setup(context.Background())
if err == nil || !strings.Contains(err.Error(), "unsupported firewall mode") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestFirewallAddChoosesFamily(t *testing.T) {
ex := &fakeExec{}
fw := New(ex, "ADGUARD_SHIELD", []string{"53"}, "host", false)
if err := fw.Add(context.Background(), "2001:db8::1", 30); err != nil {
t.Fatal(err)
}
got := strings.Join(ex.calls, "\n")
if !strings.Contains(got, "ipset add adguard_shield_v6 2001:db8::1 -exist timeout 30") {
t.Fatalf("unexpected calls:\n%s", got)
}
}
func TestFirewallRemoveDeletesAllKnownHooks(t *testing.T) {
ex := &fakeExec{}
fw := New(ex, "ADGUARD_SHIELD", []string{"53"}, "host", false)
if err := fw.Remove(context.Background()); err != nil {
t.Fatal(err)
}
joined := strings.Join(ex.calls, "\n")
for _, want := range []string{
"iptables -D INPUT -p tcp --dport 53 -j ADGUARD_SHIELD",
"iptables -D DOCKER-USER -p tcp --dport 53 -j ADGUARD_SHIELD",
} {
if !strings.Contains(joined, want) {
t.Fatalf("missing cleanup call %q in:\n%s", want, joined)
}
}
}

245
internal/geoip/geoip.go Normal file
View File

@@ -0,0 +1,245 @@
package geoip
import (
"archive/tar"
"compress/gzip"
"context"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/oschwald/maxminddb-golang"
)
type Store interface {
LoadGeoIPCache(ttl, dbMtime int64) (map[string]string, error)
UpsertGeoIP(ip, country string, dbMtime int64) error
}
type Resolver struct {
DBPath string
effectivePath string
LicenseKey string
Dir string
TTL int64
Store Store
reader *maxminddb.Reader
cache map[string]string
mtime int64
}
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{}}
}
func (r *Resolver) Open(ctx context.Context) error {
path := r.DBPath
if path == "" && r.LicenseKey != "" {
var err error
path, err = r.ensureAutoDB(ctx)
if err != nil {
return err
}
}
if path == "" {
return nil
}
r.effectivePath = path
st, err := os.Stat(path)
if err != nil {
return err
}
reader, err := maxminddb.Open(path)
if err != nil {
return err
}
r.reader = reader
r.mtime = st.ModTime().Unix()
if r.Store != nil {
if c, err := r.Store.LoadGeoIPCache(r.TTL, r.mtime); err == nil {
r.cache = c
}
}
return nil
}
func (r *Resolver) Close() error {
if r.reader != nil {
return r.reader.Close()
}
return nil
}
func (r *Resolver) Lookup(ip string) (string, error) {
if v, ok := r.cache[ip]; ok {
return v, nil
}
if r.reader == nil {
return r.lookupLegacy(ip)
}
parsed := net.ParseIP(ip)
if parsed == nil {
return "", fmt.Errorf("invalid IP %q", ip)
}
var rec struct {
Country struct {
ISOCode string `maxminddb:"iso_code"`
} `maxminddb:"country"`
RegisteredCountry struct {
ISOCode string `maxminddb:"iso_code"`
} `maxminddb:"registered_country"`
}
if err := r.reader.Lookup(parsed, &rec); err != nil {
return "", err
}
cc := strings.ToUpper(rec.Country.ISOCode)
if cc == "" {
cc = strings.ToUpper(rec.RegisteredCountry.ISOCode)
}
if cc != "" {
r.cache[ip] = cc
if r.Store != nil {
_ = r.Store.UpsertGeoIP(ip, cc, r.mtime)
}
}
return cc, nil
}
func (r *Resolver) lookupLegacy(ip string) (string, error) {
if strings.Contains(ip, ":") {
if cc, err := runGeoIPCommand("geoiplookup6", ip); err == nil && cc != "" {
return cc, nil
}
} else {
if cc, err := runGeoIPCommand("geoiplookup", ip); err == nil && cc != "" {
return cc, nil
}
}
if r.effectivePath != "" {
if cc, err := runGeoIPCommand("mmdblookup", "--file", r.effectivePath, "--ip", ip, "country", "iso_code"); err == nil && cc != "" {
return cc, nil
}
}
return "", fmt.Errorf("no GeoIP result for %s", ip)
}
func runGeoIPCommand(name string, args ...string) (string, error) {
if _, err := exec.LookPath(name); err != nil {
return "", err
}
out, err := exec.Command(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 {
if m != "IP" {
return strings.ToUpper(m), nil
}
}
return "", nil
}
func ShouldBlock(country, mode string, countries []string) bool {
if country == "" || len(countries) == 0 {
return false
}
found := false
country = strings.ToUpper(country)
for _, c := range countries {
if strings.ToUpper(strings.TrimSpace(c)) == country {
found = true
break
}
}
if strings.ToLower(mode) == "allowlist" {
return !found
}
return found
}
func IsPrivateIP(s string) bool {
if p, err := netip.ParsePrefix(s); err == nil {
return isPrivateAddr(p.Addr())
}
a, err := netip.ParseAddr(s)
if err != nil {
return false
}
return isPrivateAddr(a)
}
func isPrivateAddr(a netip.Addr) bool {
return a.IsPrivate() || a.IsLoopback() || a.IsLinkLocalUnicast() || a.IsUnspecified() ||
(a.Is4() && strings.HasPrefix(a.String(), "100.") && isCGNAT(a))
}
func isCGNAT(a netip.Addr) bool {
p := a.As4()
return p[0] == 100 && p[1] >= 64 && p[1] <= 127
}
func (r *Resolver) ensureAutoDB(ctx context.Context) (string, error) {
if err := os.MkdirAll(r.Dir, 0755); err != nil {
return "", err
}
dst := filepath.Join(r.Dir, "GeoLite2-Country.mmdb")
if st, err := os.Stat(dst); err == nil && time.Since(st.ModTime()) < 24*time.Hour {
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
}
resp, err := http.DefaultClient.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)
}
gzr, err := gzip.NewReader(resp.Body)
if err != nil {
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
}
if h.FileInfo().IsDir() || filepath.Base(h.Name) != "GeoLite2-Country.mmdb" {
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 "", fmt.Errorf("GeoLite2-Country.mmdb not found in archive")
}

View File

@@ -0,0 +1,30 @@
package geoip
import "testing"
func TestShouldBlockModes(t *testing.T) {
countries := []string{"CN", "RU"}
if !ShouldBlock("cn", "blocklist", countries) {
t.Fatal("blocklist should block listed country")
}
if ShouldBlock("DE", "blocklist", countries) {
t.Fatal("blocklist should allow unlisted country")
}
if ShouldBlock("CN", "allowlist", countries) {
t.Fatal("allowlist should allow listed country")
}
if !ShouldBlock("DE", "allowlist", countries) {
t.Fatal("allowlist should block unlisted country")
}
}
func TestIsPrivateIP(t *testing.T) {
for _, ip := range []string{"127.0.0.1", "192.168.1.10", "10.1.2.3", "100.64.0.1", "::1", "fd00::1"} {
if !IsPrivateIP(ip) {
t.Fatalf("%s should be private", ip)
}
}
if IsPrivateIP("8.8.8.8") {
t.Fatal("8.8.8.8 should be public")
}
}

View File

@@ -0,0 +1,642 @@
package installer
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
)
const (
DefaultInstallDir = "/opt/adguard-shield"
DefaultStateDir = "/var/lib/adguard-shield"
DefaultLogFile = "/var/log/adguard-shield.log"
ServiceName = "adguard-shield.service"
ServicePath = "/etc/systemd/system/adguard-shield.service"
)
type Options struct {
InstallDir string
ConfigSource string
Enable bool
SkipDeps bool
KeepConfig bool
}
type Status struct {
InstallDir string
BinaryPath string
ConfigPath string
BinaryExists bool
ConfigExists bool
ServiceExists bool
ServiceEnabled bool
ServiceActive bool
Version string
LegacyFindings []string
}
type LegacyError struct {
Findings []string
}
func (e *LegacyError) Error() string {
return "scriptbasierte AdGuard-Shield-Installation gefunden"
}
func DefaultOptions() Options {
return Options{InstallDir: DefaultInstallDir, Enable: true}
}
func Install(opts Options) error {
opts = normalize(opts)
fmt.Println("AdGuard Shield Go-Installation")
fmt.Printf("Installationspfad: %s\n", opts.InstallDir)
fmt.Println("1/8 Pruefe Betriebssystem und root-Rechte ...")
if err := requireLinuxRoot(); err != nil {
return err
}
fmt.Println("2/8 Pruefe auf scriptbasierte Altinstallation ...")
if findings := DetectLegacy(opts.InstallDir); len(findings) > 0 {
return &LegacyError{Findings: findings}
}
if !opts.SkipDeps {
fmt.Println("3/8 Pruefe System-Abhaengigkeiten ...")
if err := ensureDependencies(); err != nil {
return err
}
} else {
fmt.Println("3/8 System-Abhaengigkeiten uebersprungen (--skip-deps)")
}
fmt.Println("4/8 Erstelle Verzeichnisse ...")
if err := os.MkdirAll(opts.InstallDir, 0755); err != nil {
return err
}
if err := os.MkdirAll(DefaultStateDir, 0755); err != nil {
return err
}
if err := os.MkdirAll(filepath.Join(opts.InstallDir, "geoip"), 0755); err != nil {
return err
}
fmt.Println("5/8 Installiere Binary ...")
if err := copySelf(filepath.Join(opts.InstallDir, "adguard-shield")); err != nil {
return err
}
fmt.Println("6/8 Installiere oder migriere Konfiguration ...")
if err := ensureConfig(opts); err != nil {
return err
}
fmt.Println("7/8 Schreibe systemd-Service ...")
if err := writeService(opts.InstallDir); err != nil {
return err
}
fmt.Println("8/8 Aktualisiere systemd ...")
_ = run("systemctl", "daemon-reload")
if opts.Enable {
fmt.Println("Aktiviere Autostart ...")
if err := run("systemctl", "enable", ServiceName); err != nil {
return err
}
}
if askStartService() {
fmt.Println("Starte Service neu ...")
if err := run("systemctl", "restart", ServiceName); err != nil {
return err
}
}
fmt.Println("Installation fertig.")
return nil
}
func Update(opts Options) error {
opts = normalize(opts)
if err := requireLinuxRoot(); err != nil {
return err
}
if findings := DetectLegacy(opts.InstallDir); len(findings) > 0 {
return &LegacyError{Findings: findings}
}
return Install(opts)
}
func Uninstall(opts Options) error {
opts = normalize(opts)
if err := requireLinuxRoot(); err != nil {
return err
}
_ = run("systemctl", "stop", ServiceName)
_ = run("systemctl", "disable", ServiceName)
if _, err := os.Stat(filepath.Join(opts.InstallDir, "adguard-shield")); err == nil {
_ = run(filepath.Join(opts.InstallDir, "adguard-shield"), "-config", filepath.Join(opts.InstallDir, "adguard-shield.conf"), "firewall-remove")
}
_ = os.Remove(ServicePath)
_ = run("systemctl", "daemon-reload")
if opts.KeepConfig {
for _, p := range []string{
filepath.Join(opts.InstallDir, "adguard-shield"),
filepath.Join(opts.InstallDir, "adguard-shield.conf.old"),
} {
_ = os.Remove(p)
}
return nil
}
_ = os.RemoveAll(opts.InstallDir)
_ = os.RemoveAll(DefaultStateDir)
_ = os.Remove(DefaultLogFile)
return nil
}
func GetStatus(installDir string) Status {
if installDir == "" {
installDir = DefaultInstallDir
}
bin := filepath.Join(installDir, "adguard-shield")
conf := filepath.Join(installDir, "adguard-shield.conf")
st := Status{
InstallDir: installDir,
BinaryPath: bin,
ConfigPath: conf,
BinaryExists: fileExists(bin),
ConfigExists: fileExists(conf),
ServiceExists: fileExists(ServicePath),
LegacyFindings: DetectLegacy(installDir),
}
if st.BinaryExists {
if out, err := exec.Command(bin, "version").Output(); err == nil {
st.Version = strings.TrimSpace(string(out))
}
}
st.ServiceEnabled = commandOK("systemctl", "is-enabled", "adguard-shield")
st.ServiceActive = commandOK("systemctl", "is-active", "adguard-shield")
return st
}
func DetectLegacy(installDir string) []string {
if installDir == "" {
installDir = DefaultInstallDir
}
var findings []string
for _, p := range []string{
"adguard-shield.sh",
"iptables-helper.sh",
"db.sh",
"external-blocklist-worker.sh",
"external-whitelist-worker.sh",
"geoip-worker.sh",
"offense-cleanup-worker.sh",
"report-generator.sh",
"unban-expired.sh",
"adguard-shield-watchdog.sh",
} {
full := filepath.Join(installDir, p)
if fileExists(full) {
findings = append(findings, full)
}
}
for _, p := range []string{
"/etc/systemd/system/adguard-shield-watchdog.service",
"/etc/systemd/system/adguard-shield-watchdog.timer",
} {
if fileExists(p) {
findings = append(findings, p)
}
}
if b, err := os.ReadFile(ServicePath); err == nil {
s := string(b)
if strings.Contains(s, ".sh") || strings.Contains(s, "/bin/bash") || strings.Contains(s, "adguard-shield-watchdog") {
findings = append(findings, ServicePath+" verweist auf Shell/Watchdog")
}
}
sort.Strings(findings)
return findings
}
func FormatLegacyMessage(err *LegacyError, installDir string) string {
if installDir == "" {
installDir = DefaultInstallDir
}
var b strings.Builder
b.WriteString("Die scriptbasierte Installation ist noch vorhanden und muss zuerst deinstalliert werden.\n\n")
b.WriteString("Gefunden:\n")
for _, f := range err.Findings {
b.WriteString(" - ")
b.WriteString(f)
b.WriteByte('\n')
}
b.WriteString("\nKonfiguration uebernehmen:\n")
b.WriteString(" 1. Backup behalten: ")
b.WriteString(filepath.Join(installDir, "adguard-shield.conf"))
b.WriteByte('\n')
b.WriteString(" 2. Alte Shell-Version mit deren uninstall.sh entfernen und die Konfiguration behalten.\n")
b.WriteString(" 3. Danach dieses Binary erneut ausfuehren: adguard-shield install\n")
return b.String()
}
func PrintStatus(st Status) string {
var b strings.Builder
b.WriteString("AdGuard Shield Installationsstatus\n")
b.WriteString(fmt.Sprintf("Installationspfad: %s\n", st.InstallDir))
b.WriteString(fmt.Sprintf("Binary: %s\n", yesNo(st.BinaryExists)))
if st.Version != "" {
b.WriteString(fmt.Sprintf("Version: %s\n", st.Version))
}
b.WriteString(fmt.Sprintf("Konfiguration: %s\n", yesNo(st.ConfigExists)))
b.WriteString(fmt.Sprintf("systemd Service: %s\n", yesNo(st.ServiceExists)))
b.WriteString(fmt.Sprintf("Autostart: %s\n", yesNo(st.ServiceEnabled)))
b.WriteString(fmt.Sprintf("Service aktiv: %s\n", yesNo(st.ServiceActive)))
if len(st.LegacyFindings) > 0 {
b.WriteString("\nScriptbasierte Altinstallation/Altartefakte gefunden:\n")
for _, f := range st.LegacyFindings {
b.WriteString(" - ")
b.WriteString(f)
b.WriteByte('\n')
}
}
return b.String()
}
func normalize(opts Options) Options {
if opts.InstallDir == "" {
opts.InstallDir = DefaultInstallDir
}
return opts
}
func askStartService() bool {
fmt.Print("AdGuard Shield jetzt (neu) starten? [j/N] ")
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil && len(line) == 0 {
fmt.Println("Keine Eingabe gelesen, Service wird nicht gestartet.")
return false
}
switch strings.ToLower(strings.TrimSpace(line)) {
case "j", "ja", "y", "yes":
return true
default:
fmt.Println("Service wird nicht gestartet.")
return false
}
}
func requireLinuxRoot() error {
if runtime.GOOS != "linux" {
return fmt.Errorf("Installation ist nur auf Linux-Servern unterstuetzt")
}
if os.Geteuid() != 0 {
return fmt.Errorf("Installation muss als root ausgefuehrt werden")
}
return nil
}
func ensureDependencies() error {
missing := missingCommands("iptables", "ip6tables", "ipset", "systemctl")
if len(missing) == 0 {
fmt.Println(" Alle benoetigten Befehle sind vorhanden.")
return nil
}
fmt.Printf(" Fehlende Befehle: %s\n", strings.Join(missing, ", "))
if _, err := exec.LookPath("apt-get"); err != nil {
return fmt.Errorf("fehlende Abhaengigkeiten (%s), apt-get nicht gefunden", strings.Join(missing, ", "))
}
pkgs := map[string]bool{"iptables": false, "ipset": false, "systemd": false, "ca-certificates": false}
for _, m := range missing {
switch m {
case "iptables", "ip6tables":
pkgs["iptables"] = true
case "ipset":
pkgs["ipset"] = true
case "systemctl":
pkgs["systemd"] = true
}
}
var install []string
for p, needed := range pkgs {
if needed || p == "ca-certificates" {
install = append(install, p)
}
}
sort.Strings(install)
fmt.Printf(" Installiere Pakete via apt-get: %s\n", strings.Join(install, ", "))
fmt.Println(" apt-get update ...")
if err := runStreaming("apt-get", "update"); err != nil {
return err
}
fmt.Println(" apt-get install ...")
args := append([]string{"install", "-y", "-qq"}, install...)
return runStreaming("apt-get", args...)
}
func missingCommands(names ...string) []string {
var missing []string
for _, name := range names {
if _, err := exec.LookPath(name); err != nil {
missing = append(missing, name)
}
}
return missing
}
func copySelf(dst string) error {
src, err := os.Executable()
if err != nil {
return err
}
if sameFile(src, dst) {
return os.Chmod(dst, 0755)
}
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
tmp := dst + ".tmp"
out, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0755)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return err
}
if err := out.Close(); err != nil {
return err
}
if err := os.Chmod(tmp, 0755); err != nil {
return err
}
return os.Rename(tmp, dst)
}
func sameFile(a, b string) bool {
aa, errA := filepath.Abs(a)
bb, errB := filepath.Abs(b)
if errA == nil && errB == nil && aa == bb {
return true
}
ai, errA := os.Stat(a)
bi, errB := os.Stat(b)
return errA == nil && errB == nil && os.SameFile(ai, bi)
}
func ensureConfig(opts Options) error {
target := filepath.Join(opts.InstallDir, "adguard-shield.conf")
defaults := []byte(defaultConfig)
if opts.ConfigSource != "" {
b, err := os.ReadFile(opts.ConfigSource)
if err != nil {
return err
}
defaults = b
}
if !fileExists(target) {
if err := os.WriteFile(target, defaults, 0600); err != nil {
return err
}
return nil
}
current, err := os.ReadFile(target)
if err != nil {
return err
}
merged, changed := mergeConfig(current, []byte(defaultConfig))
if !changed {
return os.Chmod(target, 0600)
}
if err := os.WriteFile(target+".old", current, 0600); err != nil {
return err
}
if err := os.WriteFile(target, merged, 0600); err != nil {
return err
}
return nil
}
func mergeConfig(current, defaults []byte) ([]byte, bool) {
existing := configKeys(current)
var add [][]byte
for _, block := range configBlocks(defaults) {
key := blockKey(block)
if key == "" || existing[key] {
continue
}
add = append(add, block)
}
if len(add) == 0 {
return current, false
}
out := bytes.TrimRight(current, "\r\n")
out = append(out, '\n', '\n')
out = append(out, []byte("# Neue Parameter aus der Go-Version\n")...)
for _, block := range add {
out = append(out, bytes.Trim(block, "\r\n")...)
out = append(out, '\n')
}
return out, true
}
func configKeys(data []byte) map[string]bool {
keys := map[string]bool{}
for _, line := range bytes.Split(data, []byte{'\n'}) {
line = bytes.TrimSpace(line)
if len(line) == 0 || line[0] == '#' {
continue
}
if i := bytes.IndexByte(line, '='); i > 0 {
keys[string(bytes.TrimSpace(line[:i]))] = true
}
}
return keys
}
func configBlocks(data []byte) [][]byte {
lines := bytes.Split(data, []byte{'\n'})
var blocks [][]byte
var comments [][]byte
for _, line := range lines {
trim := bytes.TrimSpace(line)
if len(trim) == 0 || trim[0] == '#' {
comments = append(comments, append([]byte(nil), line...))
continue
}
block := bytes.Join(append(comments, line), []byte{'\n'})
blocks = append(blocks, block)
comments = nil
}
return blocks
}
func blockKey(block []byte) string {
for _, line := range bytes.Split(block, []byte{'\n'}) {
line = bytes.TrimSpace(line)
if len(line) == 0 || line[0] == '#' {
continue
}
if i := bytes.IndexByte(line, '='); i > 0 {
return string(bytes.TrimSpace(line[:i]))
}
}
return ""
}
func writeService(installDir string) error {
service := fmt.Sprintf(`[Unit]
Description=AdGuard Shield - Go DNS Rate-Limit Monitor
After=network.target AdGuardHome.service
Wants=AdGuardHome.service
StartLimitBurst=5
StartLimitIntervalSec=300
[Service]
Type=simple
ExecStart=%s/adguard-shield -config %s/adguard-shield.conf run
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=30
ProtectSystem=full
ReadWritePaths=/var/log /var/lib/adguard-shield /var/run %s/geoip
ProtectHome=true
NoNewPrivileges=false
PrivateTmp=true
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
StandardOutput=journal
StandardError=journal
SyslogIdentifier=adguard-shield
[Install]
WantedBy=multi-user.target
`, installDir, installDir, installDir)
return os.WriteFile(ServicePath, []byte(service), 0644)
}
func run(name string, args ...string) error {
cmd := exec.Command(name, args...)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%s %s: %w\n%s", name, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
}
return nil
}
func runStreaming(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err)
}
return nil
}
func commandOK(name string, args ...string) bool {
return exec.Command(name, args...).Run() == nil
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func yesNo(ok bool) string {
if ok {
return "ja"
}
return "nein"
}
func IsLegacyError(err error) (*LegacyError, bool) {
var le *LegacyError
if errors.As(err, &le) {
return le, true
}
return nil, false
}
const defaultConfig = `# AdGuard Shield Konfiguration
ADGUARD_URL="https://dns1.domain.com"
ADGUARD_USER="admin"
ADGUARD_PASS='changeme'
RATE_LIMIT_MAX_REQUESTS=30
RATE_LIMIT_WINDOW=60
CHECK_INTERVAL=10
API_QUERY_LIMIT=500
SUBDOMAIN_FLOOD_ENABLED=true
SUBDOMAIN_FLOOD_MAX_UNIQUE=50
SUBDOMAIN_FLOOD_WINDOW=60
DNS_FLOOD_WATCHLIST_ENABLED=false
DNS_FLOOD_WATCHLIST=""
BAN_DURATION=3600
IPTABLES_CHAIN="ADGUARD_SHIELD"
BLOCKED_PORTS="53 443 853"
FIREWALL_BACKEND="ipset"
FIREWALL_MODE="host"
DRY_RUN=false
WHITELIST="127.0.0.1,::1"
LOG_FILE="/var/log/adguard-shield.log"
LOG_LEVEL="INFO"
STATE_DIR="/var/lib/adguard-shield"
PID_FILE="/var/run/adguard-shield.pid"
NOTIFY_ENABLED=false
NOTIFY_TYPE="ntfy"
NOTIFY_WEBHOOK_URL=""
NTFY_SERVER_URL="https://ntfy.sh"
NTFY_TOPIC=""
NTFY_TOKEN=""
NTFY_PRIORITY="4"
REPORT_ENABLED=false
REPORT_INTERVAL="weekly"
REPORT_TIME="08:00"
REPORT_EMAIL_TO="admin@example.com"
REPORT_EMAIL_FROM="adguard-shield@example.com"
REPORT_FORMAT="html"
REPORT_MAIL_CMD="msmtp"
REPORT_BUSIEST_DAY_RANGE=30
EXTERNAL_WHITELIST_ENABLED=false
EXTERNAL_WHITELIST_URLS=""
EXTERNAL_WHITELIST_INTERVAL=300
EXTERNAL_WHITELIST_CACHE_DIR="/var/lib/adguard-shield/external-whitelist"
EXTERNAL_BLOCKLIST_ENABLED=false
EXTERNAL_BLOCKLIST_URLS=""
EXTERNAL_BLOCKLIST_INTERVAL=300
EXTERNAL_BLOCKLIST_BAN_DURATION=0
EXTERNAL_BLOCKLIST_AUTO_UNBAN=true
EXTERNAL_BLOCKLIST_NOTIFY=false
EXTERNAL_BLOCKLIST_CACHE_DIR="/var/lib/adguard-shield/external-blocklist"
PROGRESSIVE_BAN_ENABLED=true
PROGRESSIVE_BAN_MULTIPLIER=2
PROGRESSIVE_BAN_MAX_LEVEL=5
PROGRESSIVE_BAN_RESET_AFTER=86400
ABUSEIPDB_ENABLED=false
ABUSEIPDB_API_KEY=""
ABUSEIPDB_CATEGORIES="4"
GEOIP_ENABLED=false
GEOIP_MODE="blocklist"
GEOIP_COUNTRIES=""
GEOIP_CHECK_INTERVAL=0
GEOIP_NOTIFY=true
GEOIP_SKIP_PRIVATE=true
GEOIP_LICENSE_KEY=""
GEOIP_MMDB_PATH=""
GEOIP_CACHE_TTL=86400
`

242
internal/report/report.go Normal file
View File

@@ -0,0 +1,242 @@
package report
import (
"bytes"
"context"
"fmt"
"html"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"adguard-shield/internal/config"
"adguard-shield/internal/db"
)
type Store interface {
ReportStats(since, until int64, limit int) (db.ReportStats, error)
}
const cronPath = "/etc/cron.d/adguard-shield-report"
func Status(c *config.Config) string {
cron := "nicht installiert"
if _, err := os.Stat(cronPath); err == nil {
cron = "installiert (" + cronPath + ")"
}
return fmt.Sprintf(`E-Mail Report
Aktiv: %v
Intervall: %s
Zeit: %s
Empfaenger: %s
Absender: %s
Format: %s
Mail-Befehl: %s
Cron: %s
`, c.ReportEnabled, c.ReportInterval, c.ReportTime, c.ReportEmailTo, c.ReportEmailFrom, c.ReportFormat, c.ReportMailCmd, cron)
}
func Generate(c *config.Config, st Store, format string) (string, error) {
if format == "" {
format = c.ReportFormat
}
since, until := window(c.ReportInterval)
stats, err := st.ReportStats(since, until, 20)
if err != nil {
return "", err
}
if strings.EqualFold(format, "html") {
return renderHTML(c, stats), nil
}
return renderText(c, stats), nil
}
func Send(ctx context.Context, c *config.Config, st Store) error {
body, err := Generate(c, st, c.ReportFormat)
if err != nil {
return err
}
return sendMail(ctx, c, "AdGuard Shield Report", body)
}
func SendTest(ctx context.Context, c *config.Config) error {
body := fmt.Sprintf("AdGuard Shield Test-Mail\n\nHostname: %s\nZeitpunkt: %s\nEmpfaenger: %s\nAbsender: %s\n", hostname(), time.Now().Format("2006-01-02 15:04:05"), c.ReportEmailTo, c.ReportEmailFrom)
if strings.EqualFold(c.ReportFormat, "html") {
body = "<!doctype html><html><body><h1>AdGuard Shield Test-Mail</h1><p>Hostname: " + html.EscapeString(hostname()) + "</p><p>Zeitpunkt: " + html.EscapeString(time.Now().Format("2006-01-02 15:04:05")) + "</p></body></html>"
}
return sendMail(ctx, c, "AdGuard Shield Test-Mail", body)
}
func InstallCron(binary, configPath string, c *config.Config) error {
minute, hour, err := parseReportTime(c.ReportTime)
if err != nil {
return err
}
schedule := cronSchedule(c.ReportInterval, minute, hour)
if binary == "" {
binary = "/opt/adguard-shield/adguard-shield"
}
if configPath == "" {
configPath = "/opt/adguard-shield/adguard-shield.conf"
}
line := fmt.Sprintf("SHELL=/bin/sh\nPATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n%s root %s -config %s report-send\n", schedule, binary, configPath)
return os.WriteFile(cronPath, []byte(line), 0644)
}
func RemoveCron() error {
if err := os.Remove(cronPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func sendMail(ctx context.Context, c *config.Config, subject, body string) error {
if c.ReportEmailTo == "" {
return fmt.Errorf("REPORT_EMAIL_TO ist leer")
}
if c.ReportMailCmd == "" {
return fmt.Errorf("REPORT_MAIL_CMD ist leer")
}
contentType := "text/plain; charset=utf-8"
if strings.EqualFold(c.ReportFormat, "html") {
contentType = "text/html; charset=utf-8"
}
msg := "From: " + c.ReportEmailFrom + "\n" +
"To: " + c.ReportEmailTo + "\n" +
"Subject: " + subject + "\n" +
"Content-Type: " + contentType + "\n\n" + body
parts := strings.Fields(c.ReportMailCmd)
if len(parts) == 0 {
return fmt.Errorf("REPORT_MAIL_CMD ist leer")
}
args := append(parts[1:], "-t")
cmd := exec.CommandContext(ctx, parts[0], args...)
cmd.Stdin = strings.NewReader(msg)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func parseReportTime(value string) (string, string, error) {
parts := strings.Split(value, ":")
if len(parts) != 2 {
return "", "", fmt.Errorf("REPORT_TIME muss HH:MM sein")
}
hour, err := strconv.Atoi(parts[0])
if err != nil || hour < 0 || hour > 23 {
return "", "", fmt.Errorf("REPORT_TIME hat ungueltige Stunde")
}
minute, err := strconv.Atoi(parts[1])
if err != nil || minute < 0 || minute > 59 {
return "", "", fmt.Errorf("REPORT_TIME hat ungueltige Minute")
}
return strconv.Itoa(minute), strconv.Itoa(hour), nil
}
func cronSchedule(interval, minute, hour string) string {
switch strings.ToLower(interval) {
case "daily":
return fmt.Sprintf("%s %s * * *", minute, hour)
case "biweekly":
return fmt.Sprintf("%s %s 1,15 * *", minute, hour)
case "monthly":
return fmt.Sprintf("%s %s 1 * *", minute, hour)
default:
return fmt.Sprintf("%s %s * * 1", minute, hour)
}
}
func window(interval string) (int64, int64) {
now := time.Now()
days := 7
switch strings.ToLower(interval) {
case "daily":
days = 1
case "biweekly":
days = 14
case "monthly":
days = 30
}
return now.AddDate(0, 0, -days).Unix(), now.Unix()
}
func renderText(c *config.Config, st db.ReportStats) string {
var b strings.Builder
b.WriteString("AdGuard Shield Report\n")
b.WriteString("Zeitraum: " + formatTime(st.Since) + " bis " + formatTime(st.Until) + "\n\n")
b.WriteString("Bans: " + strconv.Itoa(st.TotalBans) + "\n")
b.WriteString("Unbans: " + strconv.Itoa(st.TotalUnbans) + "\n")
b.WriteString("Aktive Sperren: " + strconv.Itoa(st.ActiveBans) + "\n\n")
writeCountsText(&b, "Top Clients", st.TopClients)
writeCountsText(&b, "Gruende", st.Reasons)
writeCountsText(&b, "Aktive Quellen", st.Sources)
if len(st.RecentEvents) > 0 {
b.WriteString("Letzte Ereignisse\n")
for _, e := range st.RecentEvents {
b.WriteString("- " + e + "\n")
}
}
_ = c
return b.String()
}
func renderHTML(c *config.Config, st db.ReportStats) string {
var b bytes.Buffer
b.WriteString("<!doctype html><html><head><meta charset=\"utf-8\"><title>AdGuard Shield Report</title>")
b.WriteString("<style>body{font-family:Arial,sans-serif;color:#1f2937}table{border-collapse:collapse;margin:12px 0}td,th{border:1px solid #d1d5db;padding:6px 9px;text-align:left}th{background:#f3f4f6}</style>")
b.WriteString("</head><body>")
b.WriteString("<h1>AdGuard Shield Report</h1>")
b.WriteString("<p>Zeitraum: " + html.EscapeString(formatTime(st.Since)) + " bis " + html.EscapeString(formatTime(st.Until)) + "</p>")
b.WriteString("<ul><li>Bans: " + strconv.Itoa(st.TotalBans) + "</li><li>Unbans: " + strconv.Itoa(st.TotalUnbans) + "</li><li>Aktive Sperren: " + strconv.Itoa(st.ActiveBans) + "</li></ul>")
writeCountsHTML(&b, "Top Clients", st.TopClients)
writeCountsHTML(&b, "Gruende", st.Reasons)
writeCountsHTML(&b, "Aktive Quellen", st.Sources)
if len(st.RecentEvents) > 0 {
b.WriteString("<h2>Letzte Ereignisse</h2><table><tr><th>Ereignis</th></tr>")
for _, e := range st.RecentEvents {
b.WriteString("<tr><td>" + html.EscapeString(e) + "</td></tr>")
}
b.WriteString("</table>")
}
b.WriteString("</body></html>")
_ = c
return b.String()
}
func writeCountsText(b *strings.Builder, title string, rows []db.ReportCount) {
b.WriteString(title + "\n")
if len(rows) == 0 {
b.WriteString("- keine Daten\n\n")
return
}
for _, r := range rows {
b.WriteString("- " + r.Name + ": " + strconv.Itoa(r.Count) + "\n")
}
b.WriteByte('\n')
}
func writeCountsHTML(b *bytes.Buffer, title string, rows []db.ReportCount) {
b.WriteString("<h2>" + html.EscapeString(title) + "</h2><table><tr><th>Name</th><th>Anzahl</th></tr>")
if len(rows) == 0 {
b.WriteString("<tr><td colspan=\"2\">keine Daten</td></tr>")
}
for _, r := range rows {
b.WriteString("<tr><td>" + html.EscapeString(r.Name) + "</td><td>" + strconv.Itoa(r.Count) + "</td></tr>")
}
b.WriteString("</table>")
}
func formatTime(epoch int64) string {
return time.Unix(epoch, 0).Format("2006-01-02 15:04:05")
}
func hostname() string {
name, err := os.Hostname()
if err != nil || name == "" {
return filepath.Base(os.Args[0])
}
return name
}

82
internal/syslog/syslog.go Normal file
View File

@@ -0,0 +1,82 @@
package syslog
import (
"fmt"
"io"
"log"
"strings"
"sync"
)
type Level int
const (
Debug Level = iota
Info
Warn
Error
)
type Logger struct {
mu sync.Mutex
min Level
log *log.Logger
}
func New(w io.Writer, min string) *Logger {
return &Logger{
min: ParseLevel(min, Info),
log: log.New(w, "", log.LstdFlags),
}
}
func ParseLevel(s string, fallback Level) Level {
switch strings.ToUpper(strings.TrimSpace(s)) {
case "DEBUG":
return Debug
case "INFO", "":
return Info
case "WARN", "WARNING":
return Warn
case "ERROR", "ERR":
return Error
default:
return fallback
}
}
func LevelName(l Level) string {
switch l {
case Debug:
return "DEBUG"
case Info:
return "INFO"
case Warn:
return "WARN"
case Error:
return "ERROR"
default:
return "INFO"
}
}
func (l *Logger) Enabled(level Level) bool {
if l == nil {
return false
}
return level >= l.min
}
func (l *Logger) Logf(level Level, format string, args ...any) {
if !l.Enabled(level) {
return
}
l.mu.Lock()
defer l.mu.Unlock()
l.log.Printf("[%s] [ADGUARD-SHIELDD] %s", LevelName(level), fmt.Sprintf(format, args...))
}
func (l *Logger) Debugf(format string, args ...any) { l.Logf(Debug, format, args...) }
func (l *Logger) Infof(format string, args ...any) { l.Logf(Info, format, args...) }
func (l *Logger) Warnf(format string, args ...any) { l.Logf(Warn, format, args...) }
func (l *Logger) Errorf(format string, args ...any) { l.Logf(Error, format, args...) }

View File

@@ -1,234 +0,0 @@
#!/bin/bash
###############################################################################
# AdGuard Shield - iptables Helper
# Verwaltet die Firewall-Regeln für AdGuard Shield
# Kann auch standalone genutzt werden zur Verwaltung der Sperren
###############################################################################
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_FILE="${SCRIPT_DIR}/adguard-shield.conf"
if [[ ! -f "$CONFIG_FILE" ]]; then
echo "FEHLER: Konfigurationsdatei nicht gefunden: $CONFIG_FILE" >&2
exit 1
fi
source "$CONFIG_FILE"
# ─── Chain erstellen ─────────────────────────────────────────────────────────
create_chain() {
echo "Erstelle iptables Chain: $IPTABLES_CHAIN"
# IPv4
if ! iptables -n -L "$IPTABLES_CHAIN" &>/dev/null; then
iptables -N "$IPTABLES_CHAIN"
for port in $BLOCKED_PORTS; do
iptables -I INPUT -p tcp --dport "$port" -j "$IPTABLES_CHAIN"
iptables -I INPUT -p udp --dport "$port" -j "$IPTABLES_CHAIN"
done
echo " ✅ IPv4 Chain erstellt"
else
echo " IPv4 Chain existiert bereits"
fi
# IPv6
if ! ip6tables -n -L "$IPTABLES_CHAIN" &>/dev/null; then
ip6tables -N "$IPTABLES_CHAIN"
for port in $BLOCKED_PORTS; do
ip6tables -I INPUT -p tcp --dport "$port" -j "$IPTABLES_CHAIN"
ip6tables -I INPUT -p udp --dport "$port" -j "$IPTABLES_CHAIN"
done
echo " ✅ IPv6 Chain erstellt"
else
echo " IPv6 Chain existiert bereits"
fi
}
# ─── Chain entfernen ─────────────────────────────────────────────────────────
remove_chain() {
echo "Entferne iptables Chain: $IPTABLES_CHAIN"
# IPv4 - Referenzen entfernen, dann Chain löschen
if iptables -n -L "$IPTABLES_CHAIN" &>/dev/null; then
for port in $BLOCKED_PORTS; do
iptables -D INPUT -p tcp --dport "$port" -j "$IPTABLES_CHAIN" 2>/dev/null || true
iptables -D INPUT -p udp --dport "$port" -j "$IPTABLES_CHAIN" 2>/dev/null || true
done
iptables -F "$IPTABLES_CHAIN" 2>/dev/null || true
iptables -X "$IPTABLES_CHAIN" 2>/dev/null || true
echo " ✅ IPv4 Chain entfernt"
else
echo " IPv4 Chain existiert nicht"
fi
# IPv6
if ip6tables -n -L "$IPTABLES_CHAIN" &>/dev/null; then
for port in $BLOCKED_PORTS; do
ip6tables -D INPUT -p tcp --dport "$port" -j "$IPTABLES_CHAIN" 2>/dev/null || true
ip6tables -D INPUT -p udp --dport "$port" -j "$IPTABLES_CHAIN" 2>/dev/null || true
done
ip6tables -F "$IPTABLES_CHAIN" 2>/dev/null || true
ip6tables -X "$IPTABLES_CHAIN" 2>/dev/null || true
echo " ✅ IPv6 Chain entfernt"
else
echo " IPv6 Chain existiert nicht"
fi
}
# ─── Chain leeren ────────────────────────────────────────────────────────────
flush_chain() {
echo "Leere iptables Chain: $IPTABLES_CHAIN"
iptables -F "$IPTABLES_CHAIN" 2>/dev/null && echo " ✅ IPv4 geleert" || echo " ⚠️ IPv4 Chain nicht gefunden"
ip6tables -F "$IPTABLES_CHAIN" 2>/dev/null && echo " ✅ IPv6 geleert" || echo " ⚠️ IPv6 Chain nicht gefunden"
# State-Dateien auch aufräumen
rm -f "${STATE_DIR}"/*.ban 2>/dev/null || true
echo " ✅ State-Dateien bereinigt"
}
# ─── IP manuell sperren ─────────────────────────────────────────────────────
ban_ip() {
local ip="$1"
echo "Sperre IP: $ip"
if [[ "$ip" == *:* ]]; then
ip6tables -I "$IPTABLES_CHAIN" -s "$ip" -j DROP
echo " ✅ IPv6 Adresse gesperrt"
else
iptables -I "$IPTABLES_CHAIN" -s "$ip" -j DROP
echo " ✅ IPv4 Adresse gesperrt"
fi
}
# ─── IP entsperren ──────────────────────────────────────────────────────────
unban_ip() {
local ip="$1"
echo "Entsperre IP: $ip"
if [[ "$ip" == *:* ]]; then
ip6tables -D "$IPTABLES_CHAIN" -s "$ip" -j DROP 2>/dev/null \
&& echo " ✅ IPv6 Adresse entsperrt" \
|| echo " ⚠️ IPv6 Regel nicht gefunden"
else
iptables -D "$IPTABLES_CHAIN" -s "$ip" -j DROP 2>/dev/null \
&& echo " ✅ IPv4 Adresse entsperrt" \
|| echo " ⚠️ IPv4 Regel nicht gefunden"
fi
# State-Datei entfernen
rm -f "${STATE_DIR}/${ip//[:\/]/_}.ban" 2>/dev/null || true
}
# ─── Status anzeigen ─────────────────────────────────────────────────────────
show_rules() {
echo ""
echo "══════════════════════════════════════════════════════════════════"
echo " iptables Regeln für Chain: $IPTABLES_CHAIN"
echo "══════════════════════════════════════════════════════════════════"
echo ""
echo " --- IPv4 ---"
if iptables -n -L "$IPTABLES_CHAIN" --line-numbers &>/dev/null; then
iptables -n -L "$IPTABLES_CHAIN" --line-numbers -v 2>/dev/null | sed 's/^/ /'
else
echo " Chain existiert nicht"
fi
echo ""
echo " --- IPv6 ---"
if ip6tables -n -L "$IPTABLES_CHAIN" --line-numbers &>/dev/null; then
ip6tables -n -L "$IPTABLES_CHAIN" --line-numbers -v 2>/dev/null | sed 's/^/ /'
else
echo " Chain existiert nicht"
fi
echo ""
echo " --- Aktive Sperren (State) ---"
local count=0
if [[ -d "$STATE_DIR" ]]; then
for f in "${STATE_DIR}"/*.ban; do
[[ -f "$f" ]] || continue
count=$((count + 1))
local ip domain ban_time ban_until
ip=$(grep '^CLIENT_IP=' "$f" | cut -d= -f2)
domain=$(grep '^DOMAIN=' "$f" | cut -d= -f2)
ban_time=$(grep '^BAN_TIME=' "$f" | cut -d= -f2)
ban_until=$(grep '^BAN_UNTIL=' "$f" | cut -d= -f2)
printf " %-20s %-30s seit %-20s bis %s\n" "$ip" "$domain" "$ban_time" "$ban_until"
done
fi
if [[ $count -eq 0 ]]; then
echo " Keine aktiven Sperren"
fi
echo ""
}
# ─── Persistenz (iptables-save/restore kompatibel) ──────────────────────────
save_rules() {
local save_file="${STATE_DIR}/iptables-rules.v4"
local save_file6="${STATE_DIR}/iptables-rules.v6"
iptables-save > "$save_file" 2>/dev/null && echo " ✅ IPv4 Regeln gespeichert: $save_file"
ip6tables-save > "$save_file6" 2>/dev/null && echo " ✅ IPv6 Regeln gespeichert: $save_file6"
}
restore_rules() {
local save_file="${STATE_DIR}/iptables-rules.v4"
local save_file6="${STATE_DIR}/iptables-rules.v6"
[[ -f "$save_file" ]] && iptables-restore < "$save_file" && echo " ✅ IPv4 Regeln wiederhergestellt"
[[ -f "$save_file6" ]] && ip6tables-restore < "$save_file6" && echo " ✅ IPv6 Regeln wiederhergestellt"
}
# ─── Hauptprogramm ──────────────────────────────────────────────────────────
case "${1:-help}" in
create)
create_chain
;;
remove)
remove_chain
;;
flush)
flush_chain
;;
ban)
[[ -z "${2:-}" ]] && { echo "Nutzung: $0 ban <IP>" >&2; exit 1; }
ban_ip "$2"
;;
unban)
[[ -z "${2:-}" ]] && { echo "Nutzung: $0 unban <IP>" >&2; exit 1; }
unban_ip "$2"
;;
status|show)
show_rules
;;
save)
save_rules
;;
restore)
restore_rules
;;
*)
cat << USAGE
iptables Helper für AdGuard Shield
Nutzung: $0 {create|remove|flush|ban|unban|status|save|restore}
Befehle:
create Erstellt die iptables Chain
remove Entfernt die Chain und alle Regeln
flush Leert alle Regeln in der Chain
ban <IP> Sperrt eine IP-Adresse manuell
unban <IP> Entsperrt eine IP-Adresse
status Zeigt alle aktuellen Regeln
save Speichert die aktuellen Regeln
restore Stellt gespeicherte Regeln wieder her
Chain-Name: $IPTABLES_CHAIN
Gesperrte Ports: $BLOCKED_PORTS
USAGE
;;
esac

View File

@@ -1,268 +0,0 @@
#!/bin/bash
###############################################################################
# AdGuard Shield - Offense-Cleanup-Worker
# Räumt abgelaufene Offense-Zähler (progressive Sperren) automatisch auf.
# Entfernt .offenses-Dateien, deren letztes Vergehen länger als
# PROGRESSIVE_BAN_RESET_AFTER zurückliegt.
# Wird als Hintergrundprozess vom Hauptscript gestartet.
#
# Autor: Patrick Asmus
# E-Mail: support@techniverse.net
# Datum: 2026-04-16
# Lizenz: MIT
###############################################################################
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_FILE="${SCRIPT_DIR}/adguard-shield.conf"
# ─── Konfiguration laden ───────────────────────────────────────────────────────
if [[ ! -f "$CONFIG_FILE" ]]; then
echo "FEHLER: Konfigurationsdatei nicht gefunden: $CONFIG_FILE" >&2
exit 1
fi
# shellcheck source=adguard-shield.conf
source "$CONFIG_FILE"
# ─── Niedrigste Priorität setzen (CPU + I/O) ─────────────────────────────────
# Stellt sicher, dass der Worker auch bei manuellem Start nie andere Dienste
# verdrängt. nice 19 = niedrigste CPU-Priorität, ionice idle = nur bei freier I/O.
renice -n 19 $$ >/dev/null 2>&1 || true
ionice -c 3 -p $$ >/dev/null 2>&1 || true
# ─── Worker PID-File ──────────────────────────────────────────────────────────
WORKER_PID_FILE="/var/run/adguard-offense-cleanup-worker.pid"
# ─── Prüfintervall ───────────────────────────────────────────────────────────
# Prüft einmal pro Stunde das ist völlig ausreichend für diese Aufgabe
OFFENSE_CLEANUP_INTERVAL=3600
# ─── Logging (eigene Funktion, nutzt gleiche Log-Datei) ───────────────────────
declare -A LOG_LEVELS=([DEBUG]=0 [INFO]=1 [WARN]=2 [ERROR]=3)
log() {
local level="$1"
shift
local message="$*"
local configured_level="${LOG_LEVEL:-INFO}"
if [[ ${LOG_LEVELS[$level]:-1} -ge ${LOG_LEVELS[$configured_level]:-1} ]]; then
local timestamp
timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
local log_entry="[$timestamp] [$level] [OFFENSE-CLEANUP] $message"
echo "$log_entry" | tee -a "$LOG_FILE" >&2
fi
}
# ─── Hilfsfunktionen ─────────────────────────────────────────────────────────
format_duration() {
local seconds="$1"
if [[ "$seconds" -eq 0 ]]; then
echo "PERMANENT"
return
fi
if [[ "$seconds" -ge 86400 ]]; then
echo "$((seconds / 86400))d $((seconds % 86400 / 3600))h"
elif [[ "$seconds" -ge 3600 ]]; then
echo "$((seconds / 3600))h $((seconds % 3600 / 60))m"
elif [[ "$seconds" -ge 60 ]]; then
echo "$((seconds / 60))m $((seconds % 60))s"
else
echo "${seconds}s"
fi
}
# ─── Verzeichnisse erstellen ──────────────────────────────────────────────────
init_directories() {
mkdir -p "${STATE_DIR}"
mkdir -p "$(dirname "$LOG_FILE")"
}
# ─── Abgelaufene Offense-Zähler aufräumen ────────────────────────────────────
cleanup_expired_offenses() {
local reset_after="${PROGRESSIVE_BAN_RESET_AFTER:-86400}"
local now
now=$(date '+%s')
local cleaned=0
local batch_count=0
for offense_file in "${STATE_DIR}"/*.offenses; do
[[ -f "$offense_file" ]] || continue
local last_offense_epoch client_ip offense_level
last_offense_epoch=$(grep '^LAST_OFFENSE_EPOCH=' "$offense_file" | cut -d= -f2 || true)
client_ip=$(grep '^CLIENT_IP=' "$offense_file" | cut -d= -f2 || true)
offense_level=$(grep '^OFFENSE_LEVEL=' "$offense_file" | cut -d= -f2 || true)
# Kein Zeitstempel vorhanden → überspringen
if [[ -z "$last_offense_epoch" ]]; then
log "DEBUG" "Offense-Datei ohne Zeitstempel übersprungen: $offense_file"
continue
fi
local elapsed=$((now - last_offense_epoch))
if [[ $elapsed -gt $reset_after ]]; then
log "INFO" "Offense-Zähler abgelaufen: $client_ip (Stufe $offense_level, letztes Vergehen vor $(format_duration $elapsed)) → entfernt"
rm -f "$offense_file"
cleaned=$((cleaned + 1))
fi
# Alle 10 Dateien kurz pausieren, um I/O-Bursts zu vermeiden
batch_count=$((batch_count + 1))
if (( batch_count % 10 == 0 )); then
sleep 0.1
fi
done
if [[ $cleaned -gt 0 ]]; then
log "INFO" "Offense-Cleanup: $cleaned abgelaufene Zähler entfernt"
else
log "DEBUG" "Offense-Cleanup: keine abgelaufenen Zähler gefunden"
fi
}
# ─── PID-Management ──────────────────────────────────────────────────────────
write_pid() {
echo $$ > "$WORKER_PID_FILE"
}
cleanup() {
log "INFO" "Offense-Cleanup-Worker wird beendet..."
rm -f "$WORKER_PID_FILE"
exit 0
}
check_already_running() {
if [[ -f "$WORKER_PID_FILE" ]]; then
local old_pid
old_pid=$(cat "$WORKER_PID_FILE")
if kill -0 "$old_pid" 2>/dev/null; then
log "DEBUG" "Offense-Cleanup-Worker läuft bereits (PID: $old_pid)"
return 1
else
rm -f "$WORKER_PID_FILE"
fi
fi
return 0
}
# ─── Status anzeigen ─────────────────────────────────────────────────────────
show_status() {
echo "═══════════════════════════════════════════════════════════════"
echo " Offense-Cleanup-Worker - Status"
echo "═══════════════════════════════════════════════════════════════"
echo ""
if [[ "${PROGRESSIVE_BAN_ENABLED:-false}" != "true" ]]; then
echo " ⚠️ Progressive Sperren sind deaktiviert"
echo " Aktivieren: PROGRESSIVE_BAN_ENABLED=true in $CONFIG_FILE"
echo ""
return
fi
# Worker-Prozess Status
if [[ -f "$WORKER_PID_FILE" ]]; then
local pid
pid=$(cat "$WORKER_PID_FILE")
if kill -0 "$pid" 2>/dev/null; then
echo " 🟢 Worker läuft (PID: $pid)"
else
echo " 🔴 Worker nicht aktiv (veraltete PID-Datei)"
fi
else
echo " 🔴 Worker nicht aktiv"
fi
echo ""
echo " Reset-Zeitraum: $(format_duration "${PROGRESSIVE_BAN_RESET_AFTER:-86400}")"
echo " Prüfintervall: $(format_duration "$OFFENSE_CLEANUP_INTERVAL")"
# Aktuelle Offense-Dateien zählen
local total=0
local expired=0
local now
now=$(date '+%s')
local reset_after="${PROGRESSIVE_BAN_RESET_AFTER:-86400}"
for offense_file in "${STATE_DIR}"/*.offenses; do
[[ -f "$offense_file" ]] || continue
total=$((total + 1))
local last_epoch
last_epoch=$(grep '^LAST_OFFENSE_EPOCH=' "$offense_file" | cut -d= -f2 || true)
if [[ -n "$last_epoch" && $((now - last_epoch)) -gt $reset_after ]]; then
expired=$((expired + 1))
fi
done
echo ""
echo " Offense-Zähler gesamt: $total"
echo " Davon abgelaufen: $expired"
echo ""
echo "═══════════════════════════════════════════════════════════════"
}
# ─── Hauptschleife ──────────────────────────────────────────────────────────
main_loop() {
init_directories
log "INFO" "═══════════════════════════════════════════════════════════"
log "INFO" "Offense-Cleanup-Worker gestartet"
log "INFO" " Reset-Zeitraum: $(format_duration "${PROGRESSIVE_BAN_RESET_AFTER:-86400}")"
log "INFO" " Prüfintervall: $(format_duration "$OFFENSE_CLEANUP_INTERVAL")"
log "INFO" "═══════════════════════════════════════════════════════════"
while true; do
cleanup_expired_offenses
sleep "$OFFENSE_CLEANUP_INTERVAL"
done
}
# ─── Signal-Handler ──────────────────────────────────────────────────────────
trap cleanup SIGTERM SIGINT SIGHUP
# ─── Kommandozeilen-Argumente ────────────────────────────────────────────────
case "${1:-start}" in
start)
if ! check_already_running; then
exit 0
fi
write_pid
main_loop
;;
stop)
if [[ -f "$WORKER_PID_FILE" ]]; then
kill "$(cat "$WORKER_PID_FILE")" 2>/dev/null || true
rm -f "$WORKER_PID_FILE"
echo "Offense-Cleanup-Worker gestoppt"
else
echo "Offense-Cleanup-Worker läuft nicht"
fi
;;
run-once)
init_directories
log "INFO" "Einmaliger Offense-Cleanup..."
cleanup_expired_offenses
log "INFO" "Cleanup abgeschlossen"
;;
status)
init_directories
show_status
;;
*)
cat << USAGE
AdGuard Shield - Offense-Cleanup-Worker
Nutzung: $0 {start|stop|run-once|status}
Befehle:
start Startet den Worker (Dauerbetrieb)
stop Stoppt den Worker
run-once Einmaliger Cleanup-Durchlauf
status Zeigt Status und aktuelle Offense-Zähler
Konfiguration: $CONFIG_FILE
USAGE
;;
esac

File diff suppressed because it is too large Load Diff

View File

@@ -1,370 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AdGuard Shield Report</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background-color: #f0f2f5;
margin: 0;
padding: 0;
color: #1a1a2e;
}
.container {
max-width: 700px;
margin: 30px auto;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
color: #ffffff;
padding: 30px 35px;
text-align: center;
}
.header h1 {
margin: 0 0 6px 0;
font-size: 26px;
font-weight: 700;
letter-spacing: 0.5px;
}
.header .subtitle {
font-size: 14px;
color: #a8b2d1;
margin: 0;
}
.header .period {
display: inline-block;
margin-top: 14px;
padding: 6px 18px;
background: rgba(255,255,255,0.12);
border-radius: 20px;
font-size: 13px;
color: #ccd6f6;
}
.content {
padding: 30px 35px;
}
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 28px;
}
.stat-card {
background: #f8f9fc;
border-radius: 10px;
padding: 18px 20px;
border-left: 4px solid #0f3460;
}
.stat-card.danger {
border-left-color: #e74c3c;
}
.stat-card.warning {
border-left-color: #f39c12;
}
.stat-card.success {
border-left-color: #27ae60;
}
.stat-card.info {
border-left-color: #3498db;
}
.stat-card .stat-value {
font-size: 28px;
font-weight: 700;
color: #1a1a2e;
line-height: 1.2;
}
.stat-card .stat-label {
font-size: 12px;
color: #6c757d;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 4px;
}
h2 {
font-size: 18px;
color: #1a1a2e;
margin: 28px 0 14px 0;
padding-bottom: 8px;
border-bottom: 2px solid #f0f2f5;
}
h2:first-child {
margin-top: 0;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
font-size: 14px;
}
th {
background: #f8f9fc;
color: #1a1a2e;
font-weight: 600;
text-align: left;
padding: 10px 14px;
border-bottom: 2px solid #e8ecf1;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
td {
padding: 10px 14px;
border-bottom: 1px solid #f0f2f5;
color: #495057;
}
tr:last-child td {
border-bottom: none;
}
tr:hover td {
background: #fafbfd;
}
.rank {
display: inline-block;
width: 24px;
height: 24px;
line-height: 24px;
text-align: center;
background: #e8ecf1;
border-radius: 50%;
font-size: 12px;
font-weight: 600;
color: #495057;
}
.rank.top3 {
background: #0f3460;
color: #ffffff;
}
.ip-cell {
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 13px;
color: #1a1a2e;
}
.bar-container {
display: flex;
align-items: center;
gap: 8px;
}
.bar {
height: 8px;
background: linear-gradient(90deg, #0f3460, #3498db);
border-radius: 4px;
min-width: 4px;
}
.bar-value {
font-size: 13px;
font-weight: 600;
color: #1a1a2e;
white-space: nowrap;
}
.protocol-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
background: #e8ecf1;
color: #495057;
margin: 2px;
}
.protocol-badge.dns { background: #dff0d8; color: #3c763d; }
.protocol-badge.doh { background: #d9edf7; color: #31708f; }
.protocol-badge.dot { background: #fcf8e3; color: #8a6d3b; }
.protocol-badge.doq { background: #f2dede; color: #a94442; }
.reason-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.reason-badge.rate-limit { background: #fcf8e3; color: #8a6d3b; }
.reason-badge.subdomain-flood { background: #f2dede; color: #a94442; }
.reason-badge.external { background: #d9edf7; color: #31708f; }
.no-data {
text-align: center;
padding: 30px;
color: #adb5bd;
font-style: italic;
}
.footer {
background: #f8f9fc;
padding: 24px 35px;
text-align: center;
font-size: 12px;
color: #6c757d;
border-top: 1px solid #e8ecf1;
}
.footer a {
color: #0f3460;
text-decoration: none;
font-weight: 600;
}
.footer a:hover {
text-decoration: underline;
}
.footer .links {
margin-top: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.footer .separator {
margin: 0 8px;
color: #ced4da;
}
.version-tag {
display: block;
margin-top: 8px;
font-size: 11px;
color: #adb5bd;
}
.update-notice {
display: inline-block;
margin-top: 10px;
padding: 7px 14px;
background: #fff8e1;
border: 1px solid #ffc107;
border-radius: 8px;
color: #7a5700;
font-size: 12px;
font-weight: 600;
}
.update-notice a {
color: #7a5700;
text-decoration: none;
font-weight: 700;
}
.update-notice a:hover {
text-decoration: underline;
}
.period-today td {
background: #eef4ff;
font-weight: 600;
}
.period-today td:first-child {
color: #0f3460;
}
.period-gestern td {
background: #f0faf3;
font-weight: 600;
}
.period-gestern td:first-child {
color: #27ae60;
}
@media (max-width: 700px) {
table { font-size: 12px; }
th, td { padding: 8px 8px; }
}
</style>
</head>
<body>
<div class="container">
<!-- Header -->
<div class="header">
<h1>🛡️ AdGuard Shield</h1>
<p class="subtitle">Sicherheits-Report</p>
<div class="period">{{REPORT_PERIOD}}</div>
</div>
<!-- Statistik-Übersicht -->
<div class="content">
<!-- Zeitraum-Schnellübersicht -->
<h2>📅 Zeitraum-Schnellübersicht</h2>
{{PERIOD_OVERVIEW_TABLE}}
<!-- Gesamt-Übersicht des Berichtszeitraums -->
<h2>📊 Übersicht</h2>
<div class="stats-grid">
<div class="stat-card danger">
<div class="stat-value">{{TOTAL_BANS}}</div>
<div class="stat-label">Sperren gesamt</div>
</div>
<div class="stat-card success">
<div class="stat-value">{{TOTAL_UNBANS}}</div>
<div class="stat-label">Entsperrungen</div>
</div>
<div class="stat-card warning">
<div class="stat-value">{{UNIQUE_IPS}}</div>
<div class="stat-label">Eindeutige IPs</div>
</div>
<div class="stat-card info">
<div class="stat-value">{{PERMANENT_BANS}}</div>
<div class="stat-label">Permanente Sperren</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ACTIVE_BANS}}</div>
<div class="stat-label">Aktuell aktive Sperren</div>
</div>
<div class="stat-card info">
<div class="stat-value">{{ABUSEIPDB_REPORTS}}</div>
<div class="stat-label">AbuseIPDB Reports</div>
</div>
</div>
<!-- Angriffsarten -->
<h2>⚔️ Angriffsarten</h2>
<div class="stats-grid">
<div class="stat-card warning">
<div class="stat-value">{{RATELIMIT_BANS}}</div>
<div class="stat-label">Rate-Limit Sperren</div>
</div>
<div class="stat-card danger">
<div class="stat-value">{{SUBDOMAIN_FLOOD_BANS}}</div>
<div class="stat-label">Subdomain-Flood Sperren</div>
</div>
<div class="stat-card">
<div class="stat-value">{{EXTERNAL_BLOCKLIST_BANS}}</div>
<div class="stat-label">Externe Blocklist</div>
</div>
<div class="stat-card success">
<div class="stat-value">{{BUSIEST_DAY}}</div>
<div class="stat-label">{{BUSIEST_DAY_LABEL}}</div>
</div>
</div>
<!-- Top 10 IPs -->
<h2>🏴‍☠️ Top 10 Auffälligste IPs</h2>
{{TOP10_IPS_TABLE}}
<!-- Top 10 Domains -->
<h2>🌐 Top 10 Meistbetroffene Domains</h2>
{{TOP10_DOMAINS_TABLE}}
<!-- Protokoll-Verteilung -->
<h2>📡 Protokoll-Verteilung</h2>
{{PROTOCOL_TABLE}}
<!-- Letzte Sperren -->
<h2>🕐 Letzte 10 Sperren</h2>
{{RECENT_BANS_TABLE}}
</div>
<!-- Footer -->
<div class="footer">
<div class="links">
<span>
<a href="https://www.patrick-asmus.de">Patrick-Asmus.de</a>
<span class="separator">|</span>
<a href="https://www.cleveradmin.de">CleverAdmin.de</a>
</span>
<span>
<a href="https://git.techniverse.net/scriptos/adguard-shield.git">AdGuard Shield auf Gitea</a>
<span class="separator">|</span>
<a href="https://git.techniverse.net/scriptos/adguard-shield/src/branch/main/docs">docs</a>
</span>
</div>
<br>
Dieser Report wurde automatisch von <strong>AdGuard Shield</strong> generiert.<br>
Generiert am: {{REPORT_DATE}}
<div class="version-tag">AdGuard Shield {{VERSION}} · {{HOSTNAME}}</div>
{{UPDATE_NOTICE}}
</div>
</div>
</body>
</html>

View File

@@ -1,68 +0,0 @@
═══════════════════════════════════════════════════════════════
🛡️ AdGuard Shield Sicherheits-Report
═══════════════════════════════════════════════════════════════
Zeitraum: {{REPORT_PERIOD}}
Erstellt: {{REPORT_DATE}}
Host: {{HOSTNAME}}
───────────────────────────────────────────────────────────────
<20> ZEITRAUM-SCHNELLÜBERSICHT
───────────────────────────────────────────────────────────────
{{PERIOD_OVERVIEW_TEXT}}
───────────────────────────────────────────────────────────────
📊 ÜBERSICHT (Berichtszeitraum)
───────────────────────────────────────────────────────────────
Sperren gesamt: {{TOTAL_BANS}}
Entsperrungen: {{TOTAL_UNBANS}}
Eindeutige IPs: {{UNIQUE_IPS}}
Permanente Sperren: {{PERMANENT_BANS}}
Aktuell aktive Sperren: {{ACTIVE_BANS}}
AbuseIPDB Reports: {{ABUSEIPDB_REPORTS}}
───────────────────────────────────────────────────────────────
⚔️ ANGRIFFSARTEN
───────────────────────────────────────────────────────────────
Rate-Limit Sperren: {{RATELIMIT_BANS}}
Subdomain-Flood Sperren: {{SUBDOMAIN_FLOOD_BANS}}
Externe Blocklist: {{EXTERNAL_BLOCKLIST_BANS}}
{{BUSIEST_DAY_LABEL}}: {{BUSIEST_DAY}}
───────────────────────────────────────────────────────────────
🏴‍☠️ TOP 10 AUFFÄLLIGSTE IPs
───────────────────────────────────────────────────────────────
{{TOP10_IPS_TEXT}}
───────────────────────────────────────────────────────────────
🌐 TOP 10 MEISTBETROFFENE DOMAINS
───────────────────────────────────────────────────────────────
{{TOP10_DOMAINS_TEXT}}
───────────────────────────────────────────────────────────────
📡 PROTOKOLL-VERTEILUNG
───────────────────────────────────────────────────────────────
{{PROTOCOL_TEXT}}
───────────────────────────────────────────────────────────────
🕐 LETZTE 10 SPERREN
───────────────────────────────────────────────────────────────
{{RECENT_BANS_TEXT}}
{{UPDATE_NOTICE_TXT}}
═══════════════════════════════════════════════════════════════
Dieser Report wurde automatisch von AdGuard Shield generiert.
AdGuard Shield {{VERSION}}
Web: https://www.patrick-asmus.de
Blog: https://www.cleveradmin.de
Repo: https://git.techniverse.net/scriptos/adguard-shield.git
Docs: https://git.techniverse.net/scriptos/adguard-shield/src/branch/main/docs
═══════════════════════════════════════════════════════════════

View File

@@ -1,85 +0,0 @@
#!/bin/bash
###############################################################################
# AdGuard Shield - Cron-basierter Unban-Timer
# Kann als Alternative zum Haupt-Script für das Entsperren genutzt werden.
# Wird z.B. alle 5 Minuten per Cron aufgerufen um abgelaufene Sperren zu prüfen.
#
# Crontab-Eintrag:
# */5 * * * * /opt/adguard-shield/unban-expired.sh
###############################################################################
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_FILE="${SCRIPT_DIR}/adguard-shield.conf"
if [[ ! -f "$CONFIG_FILE" ]]; then
exit 1
fi
source "$CONFIG_FILE"
BAN_HISTORY_FILE="${BAN_HISTORY_FILE:-/var/log/adguard-shield-bans.log}"
LOG_PREFIX="[$(date '+%Y-%m-%d %H:%M:%S')] [UNBAN-TIMER]"
NOW=$(date '+%s')
# History-Eintrag schreiben
log_ban_history() {
local action="$1"
local client_ip="$2"
local domain="${3:-}"
local count="${4:-}"
local reason="${5:-}"
local protocol="${6:-}"
local timestamp
timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
if [[ ! -f "$BAN_HISTORY_FILE" ]]; then
echo "# AdGuard Shield - Ban History" > "$BAN_HISTORY_FILE"
echo "# Format: ZEITSTEMPEL | AKTION | CLIENT-IP | DOMAIN | ANFRAGEN | SPERRDAUER | PROTOKOLL | GRUND" >> "$BAN_HISTORY_FILE"
echo "#────────────────────────────────────────────────────────────────────────────────────────────────" >> "$BAN_HISTORY_FILE"
fi
[[ -z "$protocol" ]] && protocol="-"
printf "%-19s | %-6s | %-39s | %-30s | %-8s | %-10s | %-10s | %s\n" \
"$timestamp" "$action" "$client_ip" "${domain:--}" "${count:--}" "-" "$protocol" "${reason:-expired}" \
>> "$BAN_HISTORY_FILE"
}
unban_count=0
for state_file in "${STATE_DIR}"/*.ban; do
[[ -f "$state_file" ]] || continue
ban_until_epoch=$(grep '^BAN_UNTIL_EPOCH=' "$state_file" | cut -d= -f2)
client_ip=$(grep '^CLIENT_IP=' "$state_file" | cut -d= -f2)
domain=$(grep '^DOMAIN=' "$state_file" | cut -d= -f2)
is_permanent=$(grep '^IS_PERMANENT=' "$state_file" | cut -d= -f2)
protocol=$(grep '^PROTOCOL=' "$state_file" | cut -d= -f2)
# Permanente Sperren nicht automatisch aufheben
if [[ "$is_permanent" == "true" || "$ban_until_epoch" == "0" ]]; then
continue
fi
if [[ -n "$ban_until_epoch" && "$NOW" -ge "$ban_until_epoch" ]]; then
echo "$LOG_PREFIX Entsperre abgelaufene Sperre: $client_ip" >> "$LOG_FILE"
# iptables Regel entfernen
if [[ "$client_ip" == *:* ]]; then
ip6tables -D "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true
else
iptables -D "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true
fi
# Ban-History Eintrag
log_ban_history "UNBAN" "$client_ip" "$domain" "-" "expired-cron" "${protocol:-}"
rm -f "$state_file"
unban_count=$((unban_count + 1))
fi
done
if [[ $unban_count -gt 0 ]]; then
echo "$LOG_PREFIX $unban_count Sperren aufgehoben" >> "$LOG_FILE"
fi

View File

@@ -1,150 +0,0 @@
#!/bin/bash
###############################################################################
# AdGuard Shield - Uninstaller
# Autor: Patrick Asmus
# E-Mail: support@techniverse.net
# Lizenz: MIT
#
# Dieses Script befindet sich im Installationsverzeichnis und kann daher
# ohne die originalen Installationsdateien ausgeführt werden:
# sudo bash /opt/adguard-shield/uninstall.sh
###############################################################################
# INSTALL_DIR ergibt sich aus dem Verzeichnis, in dem dieses Script liegt
INSTALL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SERVICE_FILE="/etc/systemd/system/adguard-shield.service"
WATCHDOG_SERVICE_FILE="/etc/systemd/system/adguard-shield-watchdog.service"
WATCHDOG_TIMER_FILE="/etc/systemd/system/adguard-shield-watchdog.timer"
# Farben
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
print_header() {
echo ""
echo -e "${BLUE}"
echo " ▄▄▄ ▓█████▄ ▄████ █ ██ ▄▄▄ ██▀███ ▓█████▄ ██████ ██░ ██ ██▓▓█████ ██▓ ▓█████▄ "
echo "▒████▄ ▒██▀ ██▌ ██▒ ▀█▒ ██ ▓██▒▒████▄ ▓██ ▒ ██▒▒██▀ ██▌ ▒██ ▒ ▓██░ ██▒▓██▒▓█ ▀ ▓██▒ ▒██▀ ██▌"
echo "▒██ ▀█▄ ░██ █▌▒██░▄▄▄░▓██ ▒██░▒██ ▀█▄ ▓██ ░▄█ ▒░██ █▌ ░ ▓██▄ ▒██▀▀██░▒██▒▒███ ▒██░ ░██ █▌"
echo "░██▄▄▄▄██ ░▓█▄ ▌░▓█ ██▓▓▓█ ░██░░██▄▄▄▄██ ▒██▀▀█▄ ░▓█▄ ▌ ▒ ██▒░▓█ ░██ ░██░▒▓█ ▄ ▒██░ ░▓█▄ ▌"
echo " ▓█ ▓██▒░▒████▓ ░▒▓███▀▒▒▒█████▓ ▓█ ▓██▒░██▓ ▒██▒░▒████▓ ▒██████▒▒░▓█▒░██▓░██░░▒████▒░██████▒░▒████▓ "
echo " ▒▒ ▓▒█░ ▒▒▓ ▒ ░▒ ▒ ░▒▓▒ ▒ ▒ ▒▒ ▓▒█░░ ▒▓ ░▒▓░ ▒▒▓ ▒ ▒ ▒▓▒ ▒ ░ ▒ ░░▒░▒░▓ ░░ ▒░ ░░ ▒░▓ ░ ▒▒▓ ▒ "
echo " ▒ ▒▒ ░ ░ ▒ ▒ ░ ░ ░░▒░ ░ ░ ▒ ▒▒ ░ ░▒ ░ ▒░ ░ ▒ ▒ ░ ░▒ ░ ░ ▒ ░▒░ ░ ▒ ░ ░ ░ ░░ ░ ▒ ░ ░ ▒ ▒ "
echo " ░ ▒ ░ ░ ░ ░ ░ ░ ░░░ ░ ░ ░ ▒ ░░ ░ ░ ░ ░ ░ ░ ░ ░ ░░ ░ ▒ ░ ░ ░ ░ ░ ░ ░ "
echo " ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ "
echo " ░ ░ ░ "
echo -e "${NC}"
echo -e "${GREEN} Uninstaller${NC}"
echo -e "${BLUE} Autor: Patrick Asmus${NC}"
echo -e
echo -e "${BLUE} E-Mail: support@techniverse.net${NC}"
echo -e "${BLUE} Web: https://www.patrick-asmus.de${NC}"
echo ""
echo -e "${BLUE}───────────────────────────────────────────────────────────────────────────────────────────────────────────────${NC}"
echo ""
echo -e "${BLUE} Repo: https://git.techniverse.net/scriptos/adguard-shield${NC}"
echo ""
echo -e "${BLUE}═══════════════════════════════════════════════════════════════════════════════════════════════════════════════${NC}"
echo ""
}
check_root() {
if [[ $EUID -ne 0 ]]; then
echo -e "${RED}Dieses Script muss als root ausgeführt werden!${NC}" >&2
echo "Bitte mit 'sudo $0' ausführen."
exit 1
fi
}
do_uninstall() {
check_root
# Prüfen ob installiert
if [[ ! -d "$INSTALL_DIR" ]]; then
echo -e "${RED}AdGuard Shield ist nicht installiert (Verzeichnis nicht gefunden: $INSTALL_DIR)!${NC}"
exit 1
fi
echo -e "${YELLOW}Deinstalliere AdGuard Shield aus: ${BOLD}$INSTALL_DIR${NC}"
echo ""
# Sicherheitsabfrage
read -rep " Wirklich deinstallieren? [j/N]: " confirm
if [[ "${confirm,,}" != "j" ]]; then
echo -e "${GREEN}Deinstallation abgebrochen.${NC}"
exit 0
fi
echo ""
# Watchdog-Timer stoppen und deaktivieren
if systemctl is-active adguard-shield-watchdog.timer &>/dev/null 2>&1; then
systemctl stop adguard-shield-watchdog.timer
echo " ✅ Watchdog-Timer gestoppt"
fi
if systemctl is-enabled adguard-shield-watchdog.timer &>/dev/null 2>&1; then
systemctl disable adguard-shield-watchdog.timer
echo " ✅ Watchdog-Timer deaktiviert"
fi
# Service stoppen und deaktivieren
if systemctl is-active adguard-shield &>/dev/null; then
systemctl stop adguard-shield
echo " ✅ Service gestoppt"
fi
if systemctl is-enabled adguard-shield &>/dev/null; then
systemctl disable adguard-shield
echo " ✅ Service deaktiviert"
fi
if [[ -f "$SERVICE_FILE" ]]; then
rm -f "$SERVICE_FILE"
echo " ✅ Service-Datei entfernt"
fi
rm -f "$WATCHDOG_SERVICE_FILE" "$WATCHDOG_TIMER_FILE"
if [[ -f "$WATCHDOG_SERVICE_FILE" ]] || [[ -f "$WATCHDOG_TIMER_FILE" ]]; then
echo " ✅ Watchdog-Dateien entfernt"
fi
systemctl daemon-reload
# iptables Chain aufräumen
if [[ -f "$INSTALL_DIR/iptables-helper.sh" ]]; then
bash "$INSTALL_DIR/iptables-helper.sh" remove || true
fi
# Dateien entfernen
read -rep " Konfiguration und Logs behalten? [j/N]: " keep
if [[ "${keep,,}" == "j" ]]; then
rm -f "$INSTALL_DIR/adguard-shield.sh"
rm -f "$INSTALL_DIR/iptables-helper.sh"
rm -f "$INSTALL_DIR/unban-expired.sh"
rm -f "$INSTALL_DIR/external-blocklist-worker.sh"
rm -f "$INSTALL_DIR/external-whitelist-worker.sh"
rm -f "$INSTALL_DIR/offense-cleanup-worker.sh"
rm -f "$INSTALL_DIR/report-generator.sh"
rm -f "$INSTALL_DIR/adguard-shield-watchdog.sh"
rm -f "$INSTALL_DIR/geoip-worker.sh"
rm -f "$INSTALL_DIR/uninstall.sh"
rm -rf "$INSTALL_DIR/templates"
rm -rf "$INSTALL_DIR/geoip"
echo " ✅ Scripts entfernt (Konfiguration und Logs behalten)"
echo ""
echo -e "${YELLOW} Konfiguration verbleibt in: $INSTALL_DIR/adguard-shield.conf${NC}"
echo -e "${YELLOW} Logs verbleiben in: /var/log/adguard-shield*.log${NC}"
else
rm -rf "$INSTALL_DIR"
rm -rf /var/lib/adguard-shield
rm -f /var/log/adguard-shield.log*
rm -f /var/log/adguard-shield-bans.log
echo " ✅ Alles entfernt"
fi
echo ""
echo -e "${GREEN}Deinstallation abgeschlossen.${NC}"
}
print_header
do_uninstall