Compare commits
97 Commits
initial
...
release/v1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de521f73ed | ||
|
|
7bbdb104c4 | ||
| a7f7dbdb71 | |||
|
|
4f17f7ff81 | ||
|
|
0d1f7db43b | ||
| 125329e4ff | |||
|
|
16b2c5950e | ||
|
|
b93689bbaf | ||
| e555db8092 | |||
| b04b8bf87d | |||
| 6b6a77a98c | |||
| ac21922178 | |||
| edd8cd4806 | |||
| 44936e9f20 | |||
| 440694925e | |||
| c97e327f0d | |||
| 12745c3fef | |||
| c2d6f872f5 | |||
| ccdc555246 | |||
| 633331748f | |||
| 2559ed89ea | |||
| df8b18ae08 | |||
| 6f9f7eba8e | |||
| a79586de94 | |||
| a132b2a0f1 | |||
| b42f458d5a | |||
| 70818698d1 | |||
| 83075f2782 | |||
| 0264e1e896 | |||
| 2a1d8ae975 | |||
| df15a587ee | |||
| 0da5d01641 | |||
| 3d60771a1b | |||
| 535be66b55 | |||
| 4d7e053ce7 | |||
| 2e78b9c14e | |||
| 23deae7d81 | |||
| 0af79e7a28 | |||
| 0602fbb596 | |||
| 606a28ed8e | |||
| a27c093d83 | |||
| 77a5ebb144 | |||
| cfd6fa9b70 | |||
| 01a99489ab | |||
| 2200e80f87 | |||
| 6bdeb5bc31 | |||
| 5451c01603 | |||
| 6daaf67f7c | |||
| 0970218f9b | |||
| db128f3076 | |||
| 6f14219445 | |||
| cb31aa48eb | |||
| 1e8b7557e7 | |||
| 4d1870cc85 | |||
| ebcd70ce8b | |||
| ba342dd571 | |||
| ac1af85810 | |||
| 54b6c877e5 | |||
| 8562202aa7 | |||
| 3361b571cf | |||
| 86eeb2b947 | |||
| cf915c5c80 | |||
| cf1e554a28 | |||
| 657fdbaf6b | |||
| a39dc88770 | |||
| 19f72d5be4 | |||
| 007c2b01bc | |||
| fd8388df0b | |||
| db955263ed | |||
| 4b188193f6 | |||
| ae37610ec0 | |||
| f685e7eb3e | |||
| 5f631ba858 | |||
| 6a0d40ec1a | |||
| 00cd42f35f | |||
| 66f817d656 | |||
| 7a1b61a1db | |||
| 5b3172faa2 | |||
| 1dd8a5f606 | |||
| eb8d6fcc26 | |||
| 74a35d16b7 | |||
| f18770b890 | |||
| 86d90634d2 | |||
| efe14fd0cd | |||
| 449b285c12 | |||
| 6ab1fb96e1 | |||
| c055f7f1d7 | |||
| be504eaad9 | |||
| 1d9f5cca18 | |||
| 09b4580f0e | |||
| 1c38ca7bab | |||
| b6e31e4976 | |||
| 4f679cadd4 | |||
| 9d1879ba55 | |||
| 4077cbc838 | |||
| 582e0c1092 | |||
| c44a010466 |
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
46
.gitea/workflows/pr-test.yml
Normal file
46
.gitea/workflows/pr-test.yml
Normal 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
|
||||
110
.gitea/workflows/release.yml
Normal file
110
.gitea/workflows/release.yml
Normal 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
|
||||
36
.gitea/workflows/security-scan.yml
Normal file
36
.gitea/workflows/security-scan.yml
Normal 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 ./...
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
.ki-workspace
|
||||
/adguard-shieldd
|
||||
/adguard-shieldd.exe
|
||||
/adguard-shield
|
||||
/adguard-shield.exe
|
||||
*.test
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"files.eol": "\n",
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
"Rename-Item": true
|
||||
"Rename-Item": true,
|
||||
"ForEach-Object": true,
|
||||
"&": true
|
||||
}
|
||||
}
|
||||
398
README.md
398
README.md
@@ -1,149 +1,341 @@
|
||||
```
|
||||
▄▄▄ ▓█████▄ ▄████ █ ██ ▄▄▄ ██▀███ ▓█████▄ ██████ ██░ ██ ██▓▓█████ ██▓ ▓█████▄
|
||||
▒████▄ ▒██▀ ██▌ ██▒ ▀█▒ ██ ▓██▒▒████▄ ▓██ ▒ ██▒▒██▀ ██▌ ▒██ ▒ ▓██░ ██▒▓██▒▓█ ▀ ▓██▒ ▒██▀ ██▌
|
||||
▒██ ▀█▄ ░██ █▌▒██░▄▄▄░▓██ ▒██░▒██ ▀█▄ ▓██ ░▄█ ▒░██ █▌ ░ ▓██▄ ▒██▀▀██░▒██▒▒███ ▒██░ ░██ █▌
|
||||
░██▄▄▄▄██ ░▓█▄ ▌░▓█ ██▓▓▓█ ░██░░██▄▄▄▄██ ▒██▀▀█▄ ░▓█▄ ▌ ▒ ██▒░▓█ ░██ ░██░▒▓█ ▄ ▒██░ ░▓█▄ ▌
|
||||
▓█ ▓██▒░▒████▓ ░▒▓███▀▒▒▒█████▓ ▓█ ▓██▒░██▓ ▒██▒░▒████▓ ▒██████▒▒░▓█▒░██▓░██░░▒████▒░██████▒░▒████▓
|
||||
▒▒ ▓▒█░ ▒▒▓ ▒ ░▒ ▒ ░▒▓▒ ▒ ▒ ▒▒ ▓▒█░░ ▒▓ ░▒▓░ ▒▒▓ ▒ ▒ ▒▓▒ ▒ ░ ▒ ░░▒░▒░▓ ░░ ▒░ ░░ ▒░▓ ░ ▒▒▓ ▒
|
||||
▒ ▒▒ ░ ░ ▒ ▒ ░ ░ ░░▒░ ░ ░ ▒ ▒▒ ░ ░▒ ░ ▒░ ░ ▒ ▒ ░ ░▒ ░ ░ ▒ ░▒░ ░ ▒ ░ ░ ░ ░░ ░ ▒ ░ ░ ▒ ▒
|
||||
░ ▒ ░ ░ ░ ░ ░ ░ ░░░ ░ ░ ░ ▒ ░░ ░ ░ ░ ░ ░ ░ ░ ░ ░░ ░ ▒ ░ ░ ░ ░ ░ ░ ░
|
||||
░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░
|
||||
░ ░ ░
|
||||
```
|
||||
<p align="center">
|
||||
<a href="https://techniverse.net">
|
||||
<img src="https://assets.techniverse.net/f1/git/graphics/repo-techniverse-logo.png" alt="Techniverse Community" height="70" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
# AdGuard Shield
|
||||
<h1 align="center">AdGuard Shield</h1>
|
||||
|
||||
> **Autor:** Patrick Asmus | **E-Mail:** support@techniverse.net | **Version:** 1.0.0
|
||||
<h4 align="center">
|
||||
Automatischer Schutz für AdGuard Home: erkennt auffällige DNS-Clients, sperrt sie per Firewall und hebt temporäre Sperren selbstständig wieder auf.
|
||||
</h4>
|
||||
|
||||
Automatischer Schutz für deinen AdGuard Home DNS-Server gegen übermäßige Anfragen einzelner Clients. Überwacht die AdGuard Home API, erkennt Rate-Limit-Verstöße und sperrt missbrauchende Clients per iptables — für alle DNS-Protokolle (DNS, DoH, DoT, DoQ).
|
||||
<h6 align="center">
|
||||
<a href="https://www.cleveradmin.de">🏰 Website</a>
|
||||
·
|
||||
<a href="https://techniverse.net">📰 Community</a>
|
||||
·
|
||||
<a href="https://social.techniverse.net/@donnerwolke">🐘 Mastodon</a>
|
||||
·
|
||||
<a href="https://matrix.to/#/#support:techniverse.net">💬 Support</a>
|
||||
</h6>
|
||||
<br><br>
|
||||
|
||||
## Was macht das Tool?
|
||||
|
||||
Wenn ein Client eine bestimmte Domain zu oft anfragt (z.B. >30x pro Minute), wird er automatisch auf Firewall-Ebene für alle DNS-Ports gesperrt. Nach einer konfigurierbaren Zeitspanne wird die Sperre automatisch aufgehoben.
|
||||
## ✨ Was ist AdGuard Shield?
|
||||
|
||||
## Features
|
||||
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.
|
||||
|
||||
- Automatische Erkennung und Sperre bei Rate-Limit-Verstößen
|
||||
- Unterstützt **alle DNS-Protokolle**: DNS (53), DoH (443), DoT (853), DoQ (784/853/8853)
|
||||
- **IPv4 + IPv6**
|
||||
- Eigene iptables Chain — greift nicht in bestehende Regeln ein
|
||||
- Automatisches Entsperren nach konfigurierbarer Dauer
|
||||
- **Externe Blocklisten** — IP-Adressen von externen Textdateien (URLs) laden und automatisch sperren
|
||||
- **Ban-History** — lückenlose Protokollierung aller Sperren/Entsperrungen mit Zeitstempel
|
||||
- Whitelist für vertrauenswürdige IPs
|
||||
- Dry-Run Modus zum gefahrlosen Testen
|
||||
- Benachrichtigungen (Discord, Slack, Gotify, Ntfy)
|
||||
- systemd Service für dauerhaften Betrieb
|
||||
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.
|
||||
|
||||
## Voraussetzungen
|
||||
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.
|
||||
|
||||
- Linux Server mit AdGuard Home (bare metal)
|
||||
- Root-Zugriff (`sudo`)
|
||||
- AdGuard Home Web-API erreichbar (Standard: Port 3000)
|
||||
- Pakete: `curl`, `jq`, `iptables`, `gawk`, `systemd` — werden bei der Installation **automatisch** installiert
|
||||
## 🚀 Highlights
|
||||
|
||||
## Schnellstart
|
||||
| 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
|
||||
|
||||
| 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 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
|
||||
# 1. Repository klonen
|
||||
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
|
||||
```
|
||||
|
||||
# 2. Installer aufrufen (interaktives Menü)
|
||||
sudo bash install.sh
|
||||
### Variante B: Lokal mit Go bauen
|
||||
|
||||
# Oder direkt installieren:
|
||||
sudo bash install.sh install
|
||||
```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
|
||||
```
|
||||
|
||||
# 3. Erst im Dry-Run testen (loggt nur, sperrt nichts)
|
||||
sudo /opt/adguard-shield/adguard-shield.sh dry-run
|
||||
### Variante C: Ohne lokales Go per Docker bauen
|
||||
|
||||
# 4. Wenn alles passt — Service starten
|
||||
```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
|
||||
```
|
||||
|
||||
> **Hinweis:** Bei der Installation werden alle benötigten Abhängigkeiten automatisch installiert und der Service wird für den Autostart beim Booten registriert.
|
||||
> 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.
|
||||
|
||||
## Wichtigste Befehle
|
||||
> **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).
|
||||
|
||||
[](https://asciinema.techniverse.net/a/77)
|
||||
|
||||
## 🔧 Befehlsübersicht
|
||||
|
||||
AdGuard Shield wird über ein einzelnes Binary bedient. Die Grundform lautet:
|
||||
|
||||
```bash
|
||||
# Installer-Menü
|
||||
sudo bash install.sh # Interaktives Menü (Install/Update/Uninstall/Status)
|
||||
sudo bash install.sh --help # Hilfe anzeigen
|
||||
sudo bash install.sh update # Update mit automatischer Konfigurations-Migration
|
||||
sudo bash install.sh status # Installationsstatus prüfen
|
||||
|
||||
# Monitor
|
||||
sudo /opt/adguard-shield/adguard-shield.sh status # Aktive Sperren anzeigen
|
||||
sudo /opt/adguard-shield/adguard-shield.sh history # Ban-History anzeigen
|
||||
sudo /opt/adguard-shield/adguard-shield.sh unban IP # Einzelne IP entsperren
|
||||
sudo /opt/adguard-shield/adguard-shield.sh flush # Alle Sperren aufheben
|
||||
sudo /opt/adguard-shield/adguard-shield.sh test # API-Verbindung testen
|
||||
sudo /opt/adguard-shield/adguard-shield.sh blocklist-status # Externe Blocklisten Status
|
||||
sudo /opt/adguard-shield/adguard-shield.sh blocklist-sync # Blocklisten manuell synchronisieren
|
||||
sudo journalctl -u adguard-shield -f # Logs live verfolgen
|
||||
sudo /opt/adguard-shield/adguard-shield <befehl>
|
||||
```
|
||||
|
||||
## Projektstruktur
|
||||
### Installation & Updates
|
||||
|
||||
```
|
||||
├── adguard-shield.sh # Haupt-Monitor-Script
|
||||
├── adguard-shield.conf # Konfiguration
|
||||
├── adguard-shield.service # systemd Unit
|
||||
├── external-blocklist-worker.sh # Externer Blocklist-Worker
|
||||
├── iptables-helper.sh # Manuelle iptables-Verwaltung
|
||||
├── unban-expired.sh # Cron-basiertes Entsperren
|
||||
├── install.sh # Installer / Updater / Uninstaller
|
||||
├── README.md
|
||||
└── doc/
|
||||
├── architektur.md # Architektur & Funktionsweise
|
||||
├── konfiguration.md # Alle Parameter erklärt + Konfig-Migration
|
||||
├── befehle.md # Vollständige Befehlsreferenz inkl. Installer
|
||||
├── benachrichtigungen.md # Webhook-Setup (Discord, Slack, Gotify, Ntfy)
|
||||
└── tipps-und-troubleshooting.md
|
||||
```
|
||||
| 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 |
|
||||
|
||||
### Daemon & Service
|
||||
|
||||
| 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 |
|
||||
|
||||
### Status & Monitoring
|
||||
|
||||
| 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 |
|
||||
|
||||
### Sperren & Freigaben
|
||||
|
||||
| Befehl | Beschreibung |
|
||||
|---|---|
|
||||
| `ban <IP>` | IP-Adresse manuell permanent sperren |
|
||||
| `unban <IP>` | Sperre für eine IP-Adresse aufheben |
|
||||
| `flush` | Alle aktiven Sperren aufheben |
|
||||
|
||||
### 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 Konfigurationsdatei liegt nach der Installation hier:
|
||||
|
||||
```text
|
||||
/opt/adguard-shield/adguard-shield.conf
|
||||
```
|
||||
|
||||
## Dokumentation
|
||||
Die Datei verwendet ein einfaches Shell-ähnliches Key-Value-Format. Nach Änderungen muss der Service neu gestartet werden:
|
||||
|
||||
| Dokument | Inhalt |
|
||||
|----------|--------|
|
||||
| [Architektur](doc/architektur.md) | Wie das Tool funktioniert, iptables-Strategie, Konfig-Migration |
|
||||
| [Konfiguration](doc/konfiguration.md) | Alle Parameter, Ports, Whitelist-Pflege, automatische Migration |
|
||||
| [Befehle](doc/befehle.md) | Vollständige Befehlsreferenz für Installer, Monitor, iptables-Helper und systemd |
|
||||
| [Benachrichtigungen](doc/benachrichtigungen.md) | Setup für Discord, Slack, Gotify, Ntfy |
|
||||
| [Tipps & Troubleshooting](doc/tipps-und-troubleshooting.md) | Best Practices, häufige Probleme, Deinstallation |
|
||||
```bash
|
||||
sudo systemctl restart adguard-shield
|
||||
```
|
||||
|
||||
## Lizenz
|
||||
### Wichtigste Parameter
|
||||
|
||||
[MIT](LICENSE)
|
||||
| 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
|
||||
|
||||
## 👥 Techniverse Community
|
||||
| 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 |
|
||||
|
||||
Lust auf Austausch rund um Matrix, Selfhosting und andere smarte IT-Lösungen?
|
||||
In der **Techniverse Community** triffst du Gleichgesinnte, kannst Fragen stellen oder einfach nerdigen Talk genießen. 🚀
|
||||
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.
|
||||
|
||||
👉 **[Jetzt der Gruppe auf Matrix beitreten](https://matrix.to/#/#community:techniverse.net)**
|
||||
~ Direkte Raumadresse: `#community:techniverse.net`
|
||||
Die vollständige Parameterbeschreibung mit Beispielkonfigurationen findest du in [docs/konfiguration.md](docs/konfiguration.md).
|
||||
|
||||
👉 **[Für lockere Gespräche abseits der Kernthemen komm in den Talkraum](https://matrix.to/#/#talk:techniverse.net)**
|
||||
~ Direkte Raumadresse: `#talk:techniverse.net`
|
||||
## 🧩 Wie AdGuard Shield arbeitet
|
||||
|
||||
Wir freuen uns, wenn du dabei bist!
|
||||
```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, 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.
|
||||
|
||||
📝 **Blog:** [www.cleveradmin.de](https://www.cleveradmin.de)
|
||||
🌐 **Webseite:** [www.patrick-asmus.de](https://www.patrick-asmus.de)
|
||||
📧 **E-Mail:** [support@techniverse.net](mailto:support@techniverse.net)
|
||||
## 🧭 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">
|
||||
<img src="https://assets.techniverse.net/f1/git/graphics/gray0-catonline.svg" alt="">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://assets.techniverse.net/f1/logos/small/license.png" alt="License" width="15" height="15"> <a href="./LICENSE">License</a> | <img src="https://assets.techniverse.net/f1/logos/small/matrix2.svg" alt="Matrix" width="15" height="15"> <a href="https://matrix.to/#/#community:techniverse.net">Matrix</a> | <img src="https://assets.techniverse.net/f1/logos/small/mastodon2.svg" alt="Mastodon" width="15" height="15"> <a href="https://social.techniverse.net/@donnerwolke">Mastodon</a>
|
||||
<sub>
|
||||
Patrick Asmus · Techniverse Network · <a href="./LICENSE">Lizenz</a>
|
||||
</sub>
|
||||
</p>
|
||||
|
||||
@@ -1,114 +1,106 @@
|
||||
###############################################################################
|
||||
# AdGuard Shield - Konfigurationsdatei
|
||||
# Schutz vor übermäßigen DNS-Anfragen einzelner Clients
|
||||
# Ausführliche Dokumentation: docs/konfiguration.md
|
||||
###############################################################################
|
||||
|
||||
# --- AdGuard Home API Einstellungen ---
|
||||
# URL der AdGuard Home Web-Oberfläche (ohne trailing slash)
|
||||
ADGUARD_URL="https://dns1.techniverse.net"
|
||||
|
||||
# AdGuard Home Zugangsdaten (Web-UI Login)
|
||||
ADGUARD_USER="adg-admin"
|
||||
# --- AdGuard Home API ---
|
||||
ADGUARD_URL="https://dns1.domain.com"
|
||||
ADGUARD_USER="admin"
|
||||
ADGUARD_PASS='changeme'
|
||||
|
||||
# --- Rate-Limit Einstellungen ---
|
||||
# Maximale Anfragen pro Domain pro Client innerhalb des Zeitfensters
|
||||
RATE_LIMIT_MAX_REQUESTS=30
|
||||
# --- Rate-Limit ---
|
||||
RATE_LIMIT_MAX_REQUESTS=30 # Max. Anfragen pro Domain/Client im Zeitfenster
|
||||
RATE_LIMIT_WINDOW=60 # Zeitfenster in Sekunden
|
||||
CHECK_INTERVAL=10 # Prüfintervall in Sekunden
|
||||
|
||||
# Zeitfenster in Sekunden (60 = 1 Minute)
|
||||
RATE_LIMIT_WINDOW=60
|
||||
# --- Subdomain-Flood-Erkennung ---
|
||||
SUBDOMAIN_FLOOD_ENABLED=true
|
||||
SUBDOMAIN_FLOOD_MAX_UNIQUE=50 # Max. eindeutige Subdomains pro Basisdomain/Client
|
||||
SUBDOMAIN_FLOOD_WINDOW=60 # Zeitfenster in Sekunden
|
||||
|
||||
# Wie oft das Script die Logs prüft (in Sekunden)
|
||||
CHECK_INTERVAL=10
|
||||
# --- DNS-Flood-Watchlist ---
|
||||
DNS_FLOOD_WATCHLIST_ENABLED=false
|
||||
DNS_FLOOD_WATCHLIST="" # Kommagetrennt, z.B. "example.com,evil.org"
|
||||
|
||||
# --- Sperr-Einstellungen ---
|
||||
# Wie lange ein Client gesperrt wird (in Sekunden, 3600 = 1 Stunde)
|
||||
BAN_DURATION=3600
|
||||
|
||||
# iptables Chain-Name für die Sperren
|
||||
BAN_DURATION=3600 # Basis-Sperrdauer in Sekunden
|
||||
IPTABLES_CHAIN="ADGUARD_SHIELD"
|
||||
|
||||
# Welche Ports gesperrt werden sollen (DNS, DoT, DoH, DNSv5/QUIC)
|
||||
# Port 53 = DNS (UDP + TCP)
|
||||
# Port 443 = DNS-over-HTTPS (DoH)
|
||||
# Port 853 = DNS-over-TLS (DoT) / DNS-over-QUIC
|
||||
# Port 784 = DNS-over-QUIC (alternativ)
|
||||
# Port 8853 = DNS-over-QUIC (alternativ)
|
||||
BLOCKED_PORTS="53 443 853 784 8853"
|
||||
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 ---
|
||||
# IP-Adressen die NIEMALS gesperrt werden (kommagetrennt)
|
||||
# Lokale Netze und wichtige Server hier eintragen
|
||||
# IPs die niemals gesperrt werden (kommagetrennt)
|
||||
WHITELIST="127.0.0.1,::1"
|
||||
|
||||
# --- Logging ---
|
||||
# Log-Datei Pfad
|
||||
LOG_FILE="/var/log/adguard-shield.log"
|
||||
LOG_LEVEL="INFO" # DEBUG, INFO, WARN, ERROR
|
||||
|
||||
# Log-Level: DEBUG, INFO, WARN, ERROR
|
||||
LOG_LEVEL="INFO"
|
||||
|
||||
# Maximale Größe der Log-Datei in MB (danach wird rotiert)
|
||||
LOG_MAX_SIZE_MB=50
|
||||
|
||||
# Ban-History Datei (protokolliert alle Sperren & Entsperrungen dauerhaft)
|
||||
BAN_HISTORY_FILE="/var/log/adguard-shield-bans.log"
|
||||
|
||||
# --- Benachrichtigungen (optional) ---
|
||||
# Aktiviert Benachrichtigungen bei Sperren
|
||||
# --- Benachrichtigungen ---
|
||||
NOTIFY_ENABLED=false
|
||||
NOTIFY_TYPE="ntfy" # ntfy, discord, slack, gotify, generic
|
||||
NOTIFY_WEBHOOK_URL="" # Webhook-URL (nicht für ntfy)
|
||||
|
||||
# Webhook-URL für Benachrichtigungen (z.B. Discord, Slack, Gotify)
|
||||
# Discord: https://discord.com/api/webhooks/xxx/yyy
|
||||
# Gotify: https://gotify.example.com/message?token=xxx
|
||||
NOTIFY_WEBHOOK_URL=""
|
||||
|
||||
# Benachrichtigungs-Typ: "discord", "slack", "gotify", "ntfy", "generic"
|
||||
NOTIFY_TYPE="generic"
|
||||
|
||||
# --- Ntfy Einstellungen (nur bei NOTIFY_TYPE="ntfy") ---
|
||||
# Server-URL der Ntfy-Instanz (ohne trailing slash)
|
||||
# Ntfy-Einstellungen (nur bei NOTIFY_TYPE="ntfy")
|
||||
NTFY_SERVER_URL="https://ntfy.sh"
|
||||
|
||||
# Topic-Name für die Benachrichtigungen
|
||||
NTFY_TOPIC=""
|
||||
|
||||
# Optionaler Access-Token (leer lassen wenn nicht benötigt)
|
||||
NTFY_TOKEN=""
|
||||
NTFY_PRIORITY="4" # 1=min, 3=default, 5=max
|
||||
|
||||
# Priorität der Ntfy-Nachrichten (1=min, 3=default, 5=max)
|
||||
NTFY_PRIORITY="1"
|
||||
# --- E-Mail Report ---
|
||||
REPORT_ENABLED=false
|
||||
REPORT_INTERVAL="weekly" # daily, weekly, biweekly, monthly
|
||||
REPORT_TIME="08:00"
|
||||
REPORT_EMAIL_TO="admin@example.com"
|
||||
REPORT_EMAIL_FROM="adguard-shield@example.com"
|
||||
REPORT_FORMAT="html" # html, txt
|
||||
REPORT_MAIL_CMD="msmtp"
|
||||
REPORT_BUSIEST_DAY_RANGE=30 # Tage für "Aktivster Tag" (0 = nur Berichtszeitraum)
|
||||
|
||||
# --- Externe Blocklist (optional) ---
|
||||
# Aktiviert den externen Blocklist-Worker
|
||||
# --- Externe Whitelist ---
|
||||
# Externe Whitelist-Dateien mit Domains/IPs; Domains werden per DNS aufgelöst
|
||||
EXTERNAL_WHITELIST_ENABLED=false
|
||||
EXTERNAL_WHITELIST_URLS="" # URL(s) kommagetrennt
|
||||
EXTERNAL_WHITELIST_INTERVAL=300 # Prüfintervall in Sekunden
|
||||
EXTERNAL_WHITELIST_CACHE_DIR="/var/lib/adguard-shield/external-whitelist"
|
||||
|
||||
# --- Externe Blocklist ---
|
||||
EXTERNAL_BLOCKLIST_ENABLED=false
|
||||
|
||||
# URL(s) zu externen Textdateien mit IP-Adressen (eine IP pro Zeile)
|
||||
# Mehrere URLs kommagetrennt angeben
|
||||
# Beispiel: "https://example.com/blocklist.txt,https://other.com/bad-ips.txt"
|
||||
EXTERNAL_BLOCKLIST_URLS=""
|
||||
|
||||
# Wie oft die externe Blocklist geprüft wird (in Sekunden, 300 = 5 Minuten)
|
||||
EXTERNAL_BLOCKLIST_INTERVAL=300
|
||||
|
||||
# Sperrdauer für externe Blocklist-IPs in Sekunden (0 = permanent bis IP aus Liste entfernt)
|
||||
EXTERNAL_BLOCKLIST_BAN_DURATION=0
|
||||
|
||||
# Automatisch IPs entsperren die aus der externen Liste entfernt wurden?
|
||||
EXTERNAL_BLOCKLIST_URLS="" # URL(s) kommagetrennt
|
||||
EXTERNAL_BLOCKLIST_INTERVAL=300 # Prüfintervall in Sekunden
|
||||
EXTERNAL_BLOCKLIST_BAN_DURATION=0 # 0 = permanent bis IP aus Liste entfernt
|
||||
EXTERNAL_BLOCKLIST_AUTO_UNBAN=true
|
||||
|
||||
# Lokaler Cache-Pfad für die heruntergeladene Blocklist
|
||||
EXTERNAL_BLOCKLIST_NOTIFY=false # Bei großen Listen auf false lassen
|
||||
EXTERNAL_BLOCKLIST_CACHE_DIR="/var/lib/adguard-shield/external-blocklist"
|
||||
|
||||
# --- Progressive Sperren (Recidive) ---
|
||||
# Wiederholungstäter werden stufenweise länger gesperrt
|
||||
PROGRESSIVE_BAN_ENABLED=true
|
||||
PROGRESSIVE_BAN_MULTIPLIER=2 # Multiplikator pro Stufe (2 = Verdopplung)
|
||||
PROGRESSIVE_BAN_MAX_LEVEL=5 # Ab dieser Stufe permanent sperren (0 = nie)
|
||||
PROGRESSIVE_BAN_RESET_AFTER=86400 # Zähler-Reset nach X Sekunden ohne Vergehen
|
||||
|
||||
# --- AbuseIPDB Reporting ---
|
||||
# Meldet nur permanent gesperrte IPs an AbuseIPDB
|
||||
ABUSEIPDB_ENABLED=false
|
||||
ABUSEIPDB_API_KEY=""
|
||||
ABUSEIPDB_CATEGORIES="4" # 4 = DDoS Attack (siehe abuseipdb.com/categories)
|
||||
|
||||
# --- GeoIP-basierte Länderfilter ---
|
||||
# Sperrt/erlaubt DNS-Anfragen nach Herkunftsland (lokale DB, keine Online-API)
|
||||
GEOIP_ENABLED=false
|
||||
GEOIP_MODE="blocklist" # blocklist oder allowlist
|
||||
GEOIP_COUNTRIES="" # ISO 3166-1 Alpha-2 Codes, z.B. "CN,RU,KP,IR"
|
||||
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 ---
|
||||
# Pfad zur State-Datei (speichert aktive Sperren)
|
||||
STATE_DIR="/var/lib/adguard-shield"
|
||||
|
||||
# Pfad zum PID-File
|
||||
STATE_DIR="/var/lib/adguard-shield" # SQLite-DB: ${STATE_DIR}/adguard-shield.db
|
||||
PID_FILE="/var/run/adguard-shield.pid"
|
||||
|
||||
# Anzahl der API-Einträge die pro Abfrage geholt werden (max 5000)
|
||||
API_QUERY_LIMIT=500
|
||||
|
||||
# Dry-Run Modus: true = nur loggen, nicht sperren (zum Testen)
|
||||
DRY_RUN=false
|
||||
API_QUERY_LIMIT=500 # API-Einträge pro Abfrage (max 5000)
|
||||
DRY_RUN=false # true = nur loggen, nicht sperren
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
[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
|
||||
StartLimitBurst=5
|
||||
StartLimitIntervalSec=60
|
||||
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
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
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
|
||||
|
||||
@@ -1,744 +0,0 @@
|
||||
#!/bin/bash
|
||||
###############################################################################
|
||||
# AdGuard Shield
|
||||
# Überwacht DNS-Anfragen und sperrt Clients bei Überschreitung des Limits
|
||||
#
|
||||
# Autor: Patrick Asmus
|
||||
# E-Mail: support@techniverse.net
|
||||
# Datum: 2026-03-03
|
||||
# Lizenz: MIT
|
||||
###############################################################################
|
||||
|
||||
VERSION="0.3.0"
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Fehler-Trap: Bei unerwartetem Abbruch Fehlerdetails ausgeben
|
||||
trap 'echo "[$(date "+%Y-%m-%d %H:%M:%S")] [ERROR] Unerwarteter Fehler in Zeile $LINENO (Exit-Code: $?)" >&2' ERR
|
||||
|
||||
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"
|
||||
|
||||
# ─── Abhängigkeiten prüfen ────────────────────────────────────────────────────
|
||||
check_dependencies() {
|
||||
local missing=()
|
||||
for cmd in curl jq iptables ip6tables date; do
|
||||
if ! command -v "$cmd" &>/dev/null; then
|
||||
missing+=("$cmd")
|
||||
fi
|
||||
done
|
||||
if [[ ${#missing[@]} -gt 0 ]]; then
|
||||
log "ERROR" "Fehlende Abhängigkeiten: ${missing[*]}"
|
||||
echo "Bitte installieren: sudo apt install ${missing[*]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Logging ──────────────────────────────────────────────────────────────────
|
||||
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] $message"
|
||||
|
||||
echo "$log_entry" | tee -a "$LOG_FILE"
|
||||
|
||||
# Log-Rotation prüfen
|
||||
if [[ -f "$LOG_FILE" ]]; then
|
||||
local size_kb
|
||||
size_kb=$(du -k "$LOG_FILE" 2>/dev/null | cut -f1)
|
||||
local max_kb=$((LOG_MAX_SIZE_MB * 1024))
|
||||
if [[ ${size_kb:-0} -gt $max_kb ]]; then
|
||||
mv "$LOG_FILE" "${LOG_FILE}.old"
|
||||
log "INFO" "Log-Datei rotiert"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Ban-History ─────────────────────────────────────────────────────────────
|
||||
log_ban_history() {
|
||||
local action="$1"
|
||||
local client_ip="$2"
|
||||
local domain="${3:-}"
|
||||
local count="${4:-}"
|
||||
local reason="${5:-}"
|
||||
local timestamp
|
||||
timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
|
||||
|
||||
# Header schreiben falls Datei neu ist
|
||||
if [[ ! -f "$BAN_HISTORY_FILE" ]]; then
|
||||
echo "# AdGuard Shield - Ban History" > "$BAN_HISTORY_FILE"
|
||||
echo "# Format: ZEITSTEMPEL | AKTION | CLIENT-IP | DOMAIN | ANFRAGEN | SPERRDAUER | GRUND" >> "$BAN_HISTORY_FILE"
|
||||
echo "#───────────────────────────────────────────────────────────────────────────────" >> "$BAN_HISTORY_FILE"
|
||||
fi
|
||||
|
||||
local duration="-"
|
||||
[[ "$action" == "BAN" ]] && duration="${BAN_DURATION}s"
|
||||
|
||||
printf "%-19s | %-6s | %-39s | %-30s | %-8s | %-10s | %s\n" \
|
||||
"$timestamp" "$action" "$client_ip" "${domain:--}" "${count:--}" "$duration" "${reason:-rate-limit}" \
|
||||
>> "$BAN_HISTORY_FILE"
|
||||
}
|
||||
|
||||
# ─── Verzeichnisse erstellen ──────────────────────────────────────────────────
|
||||
init_directories() {
|
||||
mkdir -p "$STATE_DIR"
|
||||
mkdir -p "$(dirname "$LOG_FILE")"
|
||||
mkdir -p "$(dirname "$PID_FILE")"
|
||||
mkdir -p "$(dirname "$BAN_HISTORY_FILE")"
|
||||
}
|
||||
|
||||
# ─── PID-Management ──────────────────────────────────────────────────────────
|
||||
write_pid() {
|
||||
echo $$ > "$PID_FILE"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
log "INFO" "AdGuard Shield wird beendet..."
|
||||
stop_blocklist_worker
|
||||
rm -f "$PID_FILE"
|
||||
exit 0
|
||||
}
|
||||
|
||||
check_already_running() {
|
||||
if [[ -f "$PID_FILE" ]]; then
|
||||
local old_pid
|
||||
old_pid=$(cat "$PID_FILE")
|
||||
if kill -0 "$old_pid" 2>/dev/null; then
|
||||
echo "Monitor läuft bereits (PID: $old_pid). Beende." >&2
|
||||
exit 1
|
||||
else
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── 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
|
||||
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"
|
||||
|
||||
# Chain in INPUT einhängen für alle relevanten Ports
|
||||
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
|
||||
}
|
||||
|
||||
# ─── Client sperren ─────────────────────────────────────────────────────────
|
||||
ban_client() {
|
||||
local client_ip="$1"
|
||||
local domain="$2"
|
||||
local count="$3"
|
||||
local ban_until
|
||||
ban_until=$(date -d "+${BAN_DURATION} seconds" '+%s' 2>/dev/null || date -v "+${BAN_DURATION}S" '+%s')
|
||||
|
||||
# Prüfen ob bereits gesperrt
|
||||
local state_file="${STATE_DIR}/${client_ip//[:\/]/_}.ban"
|
||||
if [[ -f "$state_file" ]]; then
|
||||
log "DEBUG" "Client $client_ip ist bereits gesperrt"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
log "WARN" "[DRY-RUN] WÜRDE sperren: $client_ip (${count}x $domain in ${RATE_LIMIT_WINDOW}s)"
|
||||
log_ban_history "DRY" "$client_ip" "$domain" "$count" "dry-run"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "WARN" "SPERRE Client: $client_ip (${count}x $domain in ${RATE_LIMIT_WINDOW}s) für ${BAN_DURATION}s"
|
||||
|
||||
# IPv4 oder IPv6 erkennen
|
||||
if [[ "$client_ip" == *:* ]]; then
|
||||
# IPv6
|
||||
ip6tables -I "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true
|
||||
else
|
||||
# IPv4
|
||||
iptables -I "$IPTABLES_CHAIN" -s "$client_ip" -j DROP 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# State speichern
|
||||
cat > "$state_file" << EOF
|
||||
CLIENT_IP=$client_ip
|
||||
DOMAIN=$domain
|
||||
COUNT=$count
|
||||
BAN_TIME=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
BAN_UNTIL_EPOCH=$ban_until
|
||||
BAN_UNTIL=$(date -d "@$ban_until" '+%Y-%m-%d %H:%M:%S' 2>/dev/null || date -r "$ban_until" '+%Y-%m-%d %H:%M:%S')
|
||||
EOF
|
||||
|
||||
# Ban-History Eintrag
|
||||
log_ban_history "BAN" "$client_ip" "$domain" "$count" "rate-limit"
|
||||
|
||||
# Benachrichtigung senden
|
||||
if [[ "$NOTIFY_ENABLED" == "true" ]]; then
|
||||
send_notification "ban" "$client_ip" "$domain" "$count"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Client entsperren ──────────────────────────────────────────────────────
|
||||
unban_client() {
|
||||
local client_ip="$1"
|
||||
local reason="${2:-expired}"
|
||||
local state_file="${STATE_DIR}/${client_ip//[:\/]/_}.ban"
|
||||
|
||||
# Domain aus State lesen bevor wir löschen
|
||||
local domain="-"
|
||||
if [[ -f "$state_file" ]]; then
|
||||
domain=$(grep '^DOMAIN=' "$state_file" | cut -d= -f2)
|
||||
fi
|
||||
|
||||
log "INFO" "ENTSPERRE Client: $client_ip ($reason)"
|
||||
|
||||
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
|
||||
log_ban_history "UNBAN" "$client_ip" "$domain" "-" "$reason"
|
||||
|
||||
if [[ "$NOTIFY_ENABLED" == "true" ]]; then
|
||||
send_notification "unban" "$client_ip" "" ""
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Abgelaufene Sperren aufheben ───────────────────────────────────────────
|
||||
check_expired_bans() {
|
||||
local now
|
||||
now=$(date '+%s')
|
||||
|
||||
for state_file in "${STATE_DIR}"/*.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" && "$now" -ge "$ban_until_epoch" ]]; then
|
||||
unban_client "$client_ip" "expired"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# ─── Benachrichtigungen ─────────────────────────────────────────────────────
|
||||
send_notification() {
|
||||
local action="$1"
|
||||
local client_ip="$2"
|
||||
local domain="$3"
|
||||
local count="$4"
|
||||
|
||||
[[ -z "$NOTIFY_WEBHOOK_URL" ]] && return
|
||||
|
||||
local message
|
||||
if [[ "$action" == "ban" ]]; then
|
||||
message="🚫 AdGuard Shield: Client **$client_ip** gesperrt (${count}x $domain in ${RATE_LIMIT_WINDOW}s). Sperre für ${BAN_DURATION}s."
|
||||
else
|
||||
message="✅ AdGuard Shield: Client **$client_ip** wurde entsperrt."
|
||||
fi
|
||||
|
||||
case "$NOTIFY_TYPE" in
|
||||
discord)
|
||||
curl -s -H "Content-Type: application/json" \
|
||||
-d "{\"content\": \"$message\"}" \
|
||||
"$NOTIFY_WEBHOOK_URL" &>/dev/null &
|
||||
;;
|
||||
slack)
|
||||
curl -s -H "Content-Type: application/json" \
|
||||
-d "{\"text\": \"$message\"}" \
|
||||
"$NOTIFY_WEBHOOK_URL" &>/dev/null &
|
||||
;;
|
||||
gotify)
|
||||
curl -s -X POST "$NOTIFY_WEBHOOK_URL" \
|
||||
-F "title=AdGuard Shield" \
|
||||
-F "message=$message" \
|
||||
-F "priority=5" &>/dev/null &
|
||||
;;
|
||||
ntfy)
|
||||
send_ntfy_notification "$action" "$message"
|
||||
;;
|
||||
generic)
|
||||
curl -s -H "Content-Type: application/json" \
|
||||
-d "{\"message\": \"$message\", \"action\": \"$action\", \"client\": \"$client_ip\", \"domain\": \"$domain\"}" \
|
||||
"$NOTIFY_WEBHOOK_URL" &>/dev/null &
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ─── Ntfy Benachrichtigung ───────────────────────────────────────────────────
|
||||
send_ntfy_notification() {
|
||||
local action="$1"
|
||||
local message="$2"
|
||||
|
||||
if [[ -z "${NTFY_TOPIC:-}" ]]; then
|
||||
log "WARN" "Ntfy: Kein Topic konfiguriert (NTFY_TOPIC ist leer)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local ntfy_url="${NTFY_SERVER_URL:-https://ntfy.sh}"
|
||||
local priority="${NTFY_PRIORITY:-4}"
|
||||
local title="AdGuard Shield"
|
||||
local tags
|
||||
|
||||
if [[ "$action" == "ban" ]]; then
|
||||
tags="rotating_light,ban"
|
||||
else
|
||||
tags="white_check_mark,unban"
|
||||
fi
|
||||
|
||||
# Markdown-Formatierung entfernen für Ntfy
|
||||
local clean_message
|
||||
clean_message=$(echo "$message" | sed 's/\*\*//g')
|
||||
|
||||
local -a curl_args=(
|
||||
-s
|
||||
-X POST
|
||||
"${ntfy_url}/${NTFY_TOPIC}"
|
||||
-H "Title: ${title}"
|
||||
-H "Priority: ${priority}"
|
||||
-H "Tags: ${tags}"
|
||||
-d "${clean_message}"
|
||||
)
|
||||
|
||||
# Token hinzufügen falls konfiguriert
|
||||
if [[ -n "${NTFY_TOKEN:-}" ]]; then
|
||||
curl_args+=(-H "Authorization: Bearer ${NTFY_TOKEN}")
|
||||
fi
|
||||
|
||||
curl "${curl_args[@]}" &>/dev/null &
|
||||
}
|
||||
|
||||
# ─── AdGuard Home API abfragen ──────────────────────────────────────────────
|
||||
query_adguard_log() {
|
||||
# Hinweis: Zeitfilterung erfolgt client-seitig in analyze_queries(),
|
||||
# da die AdGuard API keinen "newer_than" Parameter unterstützt.
|
||||
|
||||
local response
|
||||
response=$(curl -s -u "${ADGUARD_USER}:${ADGUARD_PASS}" \
|
||||
--connect-timeout 5 \
|
||||
--max-time 10 \
|
||||
"${ADGUARD_URL}/control/querylog?limit=${API_QUERY_LIMIT}&response_status=all" 2>/dev/null)
|
||||
|
||||
if [[ -z "$response" || "$response" == "null" ]]; then
|
||||
log "ERROR" "Keine Antwort von AdGuard Home API"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Prüfen ob die Antwort gültiges JSON ist
|
||||
if ! echo "$response" | jq . &>/dev/null; then
|
||||
log "ERROR" "Ungültige API-Antwort (kein JSON)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$response"
|
||||
}
|
||||
|
||||
# ─── Anfragen analysieren ───────────────────────────────────────────────────
|
||||
analyze_queries() {
|
||||
local api_response="$1"
|
||||
local now_epoch
|
||||
now_epoch=$(date '+%s')
|
||||
local window_start=$((now_epoch - RATE_LIMIT_WINDOW))
|
||||
|
||||
# Anzahl der API-Einträge loggen
|
||||
local entry_count
|
||||
entry_count=$(echo "$api_response" | jq '.data // [] | length' 2>/dev/null || echo "0")
|
||||
log "INFO" "API-Abfrage: ${entry_count} Einträge erhalten, prüfe Zeitfenster ${RATE_LIMIT_WINDOW}s..."
|
||||
|
||||
# Extrahiere Client-IP + Domain Paare aus dem Zeitfenster
|
||||
# und zähle die Häufigkeit pro (client, domain) Kombination
|
||||
# Unterstützt .question.name (alte API) und .question.host (neue API)
|
||||
# Unterstützt Timestamps mit UTC ("Z") und Zeitzonen-Offset ("+01:00")
|
||||
local violations=""
|
||||
violations=$(echo "$api_response" | jq -r --argjson window_start "$window_start" '
|
||||
# ISO 8601 Timestamp zu Unix-Epoch konvertieren
|
||||
# Unterstützt: "2026-03-03T20:01:48Z", "2026-03-03T20:01:48.123Z",
|
||||
# "2026-03-03T20:01:48+01:00", "2026-03-03T20:01:48.123+01:00"
|
||||
def to_epoch:
|
||||
sub("\\.[0-9]+(?=[+-Z])"; "") |
|
||||
if endswith("Z") then
|
||||
fromdateiso8601
|
||||
elif test("[+-][0-9]{2}:[0-9]{2}$") then
|
||||
# Zeitzonen-Offset per String-Slicing extrahieren (zuverlässiger als Regex)
|
||||
# Letzten 6 Zeichen = "+01:00" bzw. "-05:00"
|
||||
(.[:-6]) as $base |
|
||||
(.[-6:-5]) as $sign |
|
||||
(.[-5:-3] | tonumber) as $h |
|
||||
(.[-2:] | tonumber) as $m |
|
||||
($base + "Z" | fromdateiso8601) +
|
||||
(if $sign == "+" then -1 else 1 end * ($h * 3600 + $m * 60))
|
||||
else
|
||||
fromdateiso8601
|
||||
end;
|
||||
|
||||
.data // [] |
|
||||
[.[] |
|
||||
select(.time != null) |
|
||||
select((.time | to_epoch) >= $window_start) |
|
||||
{
|
||||
client: (.client // .client_info.ip // "unknown"),
|
||||
domain: ((.question.name // .question.host // "unknown") | rtrimstr("."))
|
||||
}
|
||||
] |
|
||||
group_by(.client + "|" + .domain) |
|
||||
map({
|
||||
client: .[0].client,
|
||||
domain: .[0].domain,
|
||||
count: length
|
||||
}) |
|
||||
.[] |
|
||||
select(.count > 0) |
|
||||
"\(.client)|\(.domain)|\(.count)"
|
||||
') || {
|
||||
log "ERROR" "jq Analyse fehlgeschlagen - API-Antwort-Format prüfen (ist AdGuard Home erreichbar?)"
|
||||
return
|
||||
}
|
||||
|
||||
if [[ -z "$violations" ]]; then
|
||||
log "INFO" "Keine Anfragen im Zeitfenster gefunden"
|
||||
return
|
||||
fi
|
||||
|
||||
# Prüfe jede Kombination gegen das Limit
|
||||
while IFS='|' read -r client domain count; do
|
||||
[[ -z "$client" || -z "$domain" || -z "$count" ]] && continue
|
||||
|
||||
log "INFO" "Client: $client, Domain: $domain, Anfragen: $count/$RATE_LIMIT_MAX_REQUESTS"
|
||||
|
||||
if [[ "$count" -gt "$RATE_LIMIT_MAX_REQUESTS" ]]; then
|
||||
if is_whitelisted "$client"; then
|
||||
log "INFO" "Client $client ist auf der Whitelist - keine Sperre (${count}x $domain)"
|
||||
continue
|
||||
fi
|
||||
|
||||
ban_client "$client" "$domain" "$count"
|
||||
fi
|
||||
done <<< "$violations"
|
||||
}
|
||||
|
||||
# ─── Status anzeigen ─────────────────────────────────────────────────────────
|
||||
show_status() {
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo " AdGuard Shield - Status"
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
# Aktive Sperren
|
||||
local ban_count=0
|
||||
if [[ -d "$STATE_DIR" ]]; then
|
||||
for state_file in "${STATE_DIR}"/*.ban; do
|
||||
[[ -f "$state_file" ]] || continue
|
||||
ban_count=$((ban_count + 1))
|
||||
echo " 🚫 Gesperrt:"
|
||||
while IFS='=' read -r key value; do
|
||||
printf " %-20s %s\n" "$key:" "$value"
|
||||
done < "$state_file"
|
||||
echo ""
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ $ban_count -eq 0 ]]; then
|
||||
echo " ✅ Keine aktiven Sperren"
|
||||
else
|
||||
echo " Gesamt: $ban_count aktive Sperren"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# iptables Regeln anzeigen
|
||||
echo " iptables Regeln ($IPTABLES_CHAIN):"
|
||||
if iptables -n -L "$IPTABLES_CHAIN" &>/dev/null; then
|
||||
iptables -n -L "$IPTABLES_CHAIN" --line-numbers 2>/dev/null | sed 's/^/ /'
|
||||
else
|
||||
echo " Chain existiert noch nicht"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
}
|
||||
|
||||
# ─── Ban-History anzeigen ────────────────────────────────────────────────────
|
||||
show_history() {
|
||||
local lines="${1:-50}"
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo " AdGuard Shield - Ban History (letzte $lines Einträge)"
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
if [[ ! -f "$BAN_HISTORY_FILE" ]]; then
|
||||
echo " Noch keine History vorhanden."
|
||||
echo " Datei: $BAN_HISTORY_FILE"
|
||||
echo ""
|
||||
return
|
||||
fi
|
||||
|
||||
# Header zeigen
|
||||
head -3 "$BAN_HISTORY_FILE" | sed 's/^/ /'
|
||||
echo ""
|
||||
|
||||
# Letzte N Einträge (ohne Header-Zeilen)
|
||||
grep -v '^#' "$BAN_HISTORY_FILE" | tail -n "$lines" | sed 's/^/ /'
|
||||
|
||||
echo ""
|
||||
local total
|
||||
total=$(grep -vc '^#' "$BAN_HISTORY_FILE" 2>/dev/null || echo "0")
|
||||
local bans
|
||||
bans=$(grep -c '| BAN ' "$BAN_HISTORY_FILE" 2>/dev/null || echo "0")
|
||||
local unbans
|
||||
unbans=$(grep -c '| UNBAN ' "$BAN_HISTORY_FILE" 2>/dev/null || echo "0")
|
||||
echo " Gesamt: $total Einträge ($bans Sperren, $unbans Entsperrungen)"
|
||||
echo " Datei: $BAN_HISTORY_FILE"
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
}
|
||||
|
||||
# ─── Alle Sperren aufheben ──────────────────────────────────────────────────
|
||||
flush_all_bans() {
|
||||
log "INFO" "Alle Sperren werden aufgehoben..."
|
||||
|
||||
for state_file in "${STATE_DIR}"/*.ban; do
|
||||
[[ -f "$state_file" ]] || continue
|
||||
local client_ip
|
||||
client_ip=$(grep '^CLIENT_IP=' "$state_file" | cut -d= -f2)
|
||||
unban_client "$client_ip" "manual-flush"
|
||||
done
|
||||
|
||||
# Chain leeren
|
||||
iptables -F "$IPTABLES_CHAIN" 2>/dev/null || true
|
||||
ip6tables -F "$IPTABLES_CHAIN" 2>/dev/null || true
|
||||
|
||||
log "INFO" "Alle Sperren aufgehoben"
|
||||
}
|
||||
|
||||
# ─── Externer Blocklist-Worker starten ───────────────────────────────────────
|
||||
start_blocklist_worker() {
|
||||
if [[ "${EXTERNAL_BLOCKLIST_ENABLED:-false}" != "true" ]]; then
|
||||
log "DEBUG" "Externer Blocklist-Worker ist deaktiviert"
|
||||
return
|
||||
fi
|
||||
|
||||
local worker_script="${SCRIPT_DIR}/external-blocklist-worker.sh"
|
||||
if [[ ! -f "$worker_script" ]]; then
|
||||
log "WARN" "Blocklist-Worker Script nicht gefunden: $worker_script"
|
||||
return
|
||||
fi
|
||||
|
||||
log "INFO" "Starte externen Blocklist-Worker im Hintergrund..."
|
||||
bash "$worker_script" start &
|
||||
BLOCKLIST_WORKER_PID=$!
|
||||
log "INFO" "Blocklist-Worker gestartet (PID: $BLOCKLIST_WORKER_PID)"
|
||||
}
|
||||
|
||||
# ─── Externer Blocklist-Worker stoppen ───────────────────────────────────────
|
||||
stop_blocklist_worker() {
|
||||
local worker_pid_file="/var/run/adguard-blocklist-worker.pid"
|
||||
if [[ -f "$worker_pid_file" ]]; then
|
||||
local wpid
|
||||
wpid=$(cat "$worker_pid_file")
|
||||
if kill -0 "$wpid" 2>/dev/null; then
|
||||
log "INFO" "Stoppe Blocklist-Worker (PID: $wpid)..."
|
||||
kill "$wpid" 2>/dev/null || true
|
||||
rm -f "$worker_pid_file"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Hauptschleife ──────────────────────────────────────────────────────────
|
||||
main_loop() {
|
||||
log "INFO" "═══════════════════════════════════════════════════════════"
|
||||
log "INFO" "AdGuard Shield v${VERSION} gestartet"
|
||||
log "INFO" " Limit: ${RATE_LIMIT_MAX_REQUESTS} Anfragen pro ${RATE_LIMIT_WINDOW}s"
|
||||
log "INFO" " Sperrdauer: ${BAN_DURATION}s"
|
||||
log "INFO" " Prüfintervall: ${CHECK_INTERVAL}s"
|
||||
log "INFO" " Dry-Run: ${DRY_RUN}"
|
||||
log "INFO" " Whitelist: ${WHITELIST}"
|
||||
log "INFO" " Externe Blocklist: ${EXTERNAL_BLOCKLIST_ENABLED:-false}"
|
||||
log "INFO" "═══════════════════════════════════════════════════════════"
|
||||
|
||||
# Blocklist-Worker als Hintergrundprozess starten
|
||||
start_blocklist_worker
|
||||
|
||||
while true; do
|
||||
# Abgelaufene Sperren prüfen
|
||||
check_expired_bans
|
||||
|
||||
# API abfragen
|
||||
local api_response
|
||||
if api_response=$(query_adguard_log); then
|
||||
analyze_queries "$api_response"
|
||||
fi
|
||||
|
||||
sleep "$CHECK_INTERVAL"
|
||||
done
|
||||
}
|
||||
|
||||
# ─── Signal-Handler ──────────────────────────────────────────────────────────
|
||||
trap cleanup SIGTERM SIGINT SIGHUP
|
||||
|
||||
# ─── Kommandozeilen-Argumente ────────────────────────────────────────────────
|
||||
case "${1:-start}" in
|
||||
start)
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [INFO] AdGuard Shield v${VERSION} wird gestartet..."
|
||||
check_dependencies
|
||||
check_already_running
|
||||
init_directories
|
||||
write_pid
|
||||
setup_iptables_chain
|
||||
main_loop
|
||||
;;
|
||||
stop)
|
||||
if [[ -f "$PID_FILE" ]]; then
|
||||
kill "$(cat "$PID_FILE")" 2>/dev/null || true
|
||||
rm -f "$PID_FILE"
|
||||
echo "Monitor gestoppt"
|
||||
else
|
||||
echo "Monitor läuft nicht"
|
||||
fi
|
||||
;;
|
||||
blocklist-status)
|
||||
init_directories
|
||||
_worker_script="${SCRIPT_DIR}/external-blocklist-worker.sh"
|
||||
if [[ -f "$_worker_script" ]]; then
|
||||
bash "$_worker_script" status
|
||||
else
|
||||
echo "Blocklist-Worker nicht gefunden"
|
||||
fi
|
||||
;;
|
||||
blocklist-sync)
|
||||
init_directories
|
||||
setup_iptables_chain
|
||||
_worker_script="${SCRIPT_DIR}/external-blocklist-worker.sh"
|
||||
if [[ -f "$_worker_script" ]]; then
|
||||
bash "$_worker_script" sync
|
||||
else
|
||||
echo "Blocklist-Worker nicht gefunden"
|
||||
fi
|
||||
;;
|
||||
blocklist-flush)
|
||||
init_directories
|
||||
_worker_script="${SCRIPT_DIR}/external-blocklist-worker.sh"
|
||||
if [[ -f "$_worker_script" ]]; then
|
||||
bash "$_worker_script" flush
|
||||
else
|
||||
echo "Blocklist-Worker nicht gefunden"
|
||||
fi
|
||||
;;
|
||||
status)
|
||||
init_directories
|
||||
show_status
|
||||
;;
|
||||
flush)
|
||||
init_directories
|
||||
setup_iptables_chain
|
||||
flush_all_bans
|
||||
echo "Alle Sperren aufgehoben"
|
||||
;;
|
||||
unban)
|
||||
if [[ -z "${2:-}" ]]; then
|
||||
echo "Nutzung: $0 unban <IP-Adresse>" >&2
|
||||
exit 1
|
||||
fi
|
||||
init_directories
|
||||
unban_client "$2" "manual"
|
||||
echo "Client $2 entsperrt"
|
||||
;;
|
||||
test)
|
||||
echo "Teste Verbindung zur AdGuard Home API..."
|
||||
check_dependencies
|
||||
init_directories
|
||||
if response=$(query_adguard_log); then
|
||||
entry_count=$(echo "$response" | jq '.data | length' 2>/dev/null || echo "0")
|
||||
echo "✅ Verbindung erfolgreich! $entry_count Log-Einträge gefunden."
|
||||
else
|
||||
echo "❌ Verbindung fehlgeschlagen! Prüfe URL und Zugangsdaten in $CONFIG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
history)
|
||||
init_directories
|
||||
show_history "${2:-50}"
|
||||
;;
|
||||
dry-run)
|
||||
DRY_RUN=true
|
||||
check_dependencies
|
||||
check_already_running
|
||||
init_directories
|
||||
write_pid
|
||||
setup_iptables_chain
|
||||
main_loop
|
||||
;;
|
||||
*)
|
||||
cat << USAGE
|
||||
AdGuard Shield v${VERSION}
|
||||
|
||||
Nutzung: $0 {start|stop|status|history|flush|unban|test|dry-run|blocklist-status|blocklist-sync|blocklist-flush}
|
||||
|
||||
Befehle:
|
||||
start Startet den Monitor (inkl. Blocklist-Worker)
|
||||
stop Stoppt den Monitor
|
||||
status Zeigt aktive Sperren und Regeln
|
||||
history [N] Zeigt die letzten N Ban-Einträge (Standard: 50)
|
||||
flush Hebt alle Sperren auf
|
||||
unban IP Entsperrt eine bestimmte IP-Adresse
|
||||
test Testet die Verbindung zur AdGuard Home API
|
||||
dry-run Startet im Testmodus (keine echten Sperren)
|
||||
blocklist-status Zeigt Status der externen Blocklisten
|
||||
blocklist-sync Einmalige Synchronisation der externen Blocklisten
|
||||
blocklist-flush Entfernt alle Sperren der externen Blocklisten
|
||||
|
||||
Konfiguration: $CONFIG_FILE
|
||||
Log-Datei: $LOG_FILE
|
||||
Ban-History: $BAN_HISTORY_FILE
|
||||
State: $STATE_DIR
|
||||
|
||||
USAGE
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
661
cmd/adguard-shieldd/main.go
Normal file
661
cmd/adguard-shieldd/main.go
Normal 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
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
# Architektur & Funktionsweise
|
||||
|
||||
## Überblick
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ Client Anfragen │
|
||||
│ (DNS/DoH/DoT/DoQ) │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐ ┌──────────────────────┐
|
||||
│ AdGuard Home │────▶│ Query Log (API) │
|
||||
│ DNS Server │ └──────────┬───────────┘
|
||||
└─────────────────────┘ │
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ adguard-shield.sh │
|
||||
│ (Monitor Script) │
|
||||
└──────────┬───────────┘
|
||||
│
|
||||
┌──────────────┼──────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ iptables │ │ Log │ │ Webhook │
|
||||
│ DROP │ │ Datei │ │ Notify │
|
||||
└──────────┘ └──────────┘ └──────────┘
|
||||
```
|
||||
|
||||
## Ablauf einer 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. iptables-Regel wird erstellt: `DROP` für `192.168.1.50` auf allen DNS-Ports
|
||||
7. State-Datei wird angelegt: `/var/lib/adguard-shield/192.168.1.50.ban`
|
||||
8. Ban-History Eintrag wird in `/var/log/adguard-shield-bans.log` geschrieben
|
||||
9. Log-Eintrag + optionale Webhook-Benachrichtigung
|
||||
10. Nach 3600 Sekunden (1 Stunde): automatische Entsperrung + History-Eintrag
|
||||
|
||||
## 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)
|
||||
```
|
||||
|
||||
**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`
|
||||
|
||||
## State-Management
|
||||
|
||||
Jede aktive Sperre wird als Datei gespeichert:
|
||||
|
||||
```
|
||||
/var/lib/adguard-shield/192.168.1.50.ban
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
Das ermöglicht:
|
||||
- Persistenz über Script-Neustarts hinweg
|
||||
- Statusabfragen jederzeit möglich
|
||||
- Automatisches Aufräumen per Cron-Job
|
||||
|
||||
## Dateistruktur nach Installation
|
||||
|
||||
```
|
||||
/opt/adguard-shield/
|
||||
├── adguard-shield.sh # Haupt-Monitor-Script
|
||||
├── adguard-shield.conf # Konfiguration (chmod 600)
|
||||
├── adguard-shield.conf.old # Backup der Konfig nach Update
|
||||
├── iptables-helper.sh # iptables Verwaltung
|
||||
├── external-blocklist-worker.sh # Externer Blocklist-Worker
|
||||
└── unban-expired.sh # Cron-basiertes Entsperren
|
||||
|
||||
/etc/systemd/system/
|
||||
└── adguard-shield.service # systemd Service (Autostart aktiv)
|
||||
|
||||
/var/lib/adguard-shield/
|
||||
├── *.ban # State-Dateien aktiver Sperren
|
||||
└── external-blocklist/ # Cache für externe Blocklisten
|
||||
|
||||
/var/log/
|
||||
├── adguard-shield.log # Laufzeit-Log
|
||||
└── adguard-shield-bans.log # Ban-History (alle Sperren/Entsperrungen)
|
||||
```
|
||||
|
||||
## Installer-Architektur
|
||||
|
||||
Der Installer (`install.sh`) bietet ein interaktives Menü und folgende Funktionen:
|
||||
|
||||
| Befehl | Beschreibung |
|
||||
|--------|--------------|
|
||||
| `install` | Vollständige Neuinstallation (Abhängigkeiten, Dateien, Konfiguration, Service) |
|
||||
| `update` | Update mit automatischer Konfigurations-Migration und Service-Neustart |
|
||||
| `uninstall` | Deinstallation mit optionalem Behalten der Konfiguration |
|
||||
| `status` | Installationsstatus, Version und Service-Status anzeigen |
|
||||
| `--help` | Hilfe und Befehlsübersicht |
|
||||
|
||||
### Konfigurations-Migration beim Update
|
||||
|
||||
```
|
||||
┌─────────────────────────┐ ┌─────────────────────────┐
|
||||
│ 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) │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
## Ban-History
|
||||
|
||||
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.
|
||||
|
||||
**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
|
||||
```
|
||||
|
||||
**Mögliche Gründe (GRUND-Spalte):**
|
||||
| Grund | Bedeutung |
|
||||
|-------|----------|
|
||||
| `rate-limit` | Automatische Sperre wegen Limit-Überschreitung |
|
||||
| `dry-run` | Im Dry-Run erkannt (nicht wirklich gesperrt) |
|
||||
| `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) |
|
||||
|
||||
**History anzeigen:**
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield.sh history # letzte 50
|
||||
sudo /opt/adguard-shield/adguard-shield.sh history 200 # letzte 200
|
||||
```
|
||||
190
doc/befehle.md
190
doc/befehle.md
@@ -1,190 +0,0 @@
|
||||
# Befehle & Nutzung
|
||||
|
||||
## Installer / Updater
|
||||
|
||||
Der Installer bietet ein interaktives Menü wenn er ohne Argumente aufgerufen wird:
|
||||
|
||||
```bash
|
||||
# Interaktives Menü anzeigen
|
||||
sudo bash install.sh
|
||||
|
||||
# Neuinstallation
|
||||
sudo bash install.sh install
|
||||
|
||||
# Update (mit automatischer Konfigurations-Migration)
|
||||
sudo bash install.sh update
|
||||
|
||||
# Deinstallation
|
||||
sudo bash install.sh uninstall
|
||||
|
||||
# Installationsstatus anzeigen
|
||||
sudo bash install.sh status
|
||||
|
||||
# Hilfe anzeigen
|
||||
sudo bash install.sh --help
|
||||
```
|
||||
|
||||
### Update-Verhalten
|
||||
|
||||
Beim Update passiert automatisch:
|
||||
1. Alle Scripts werden aktualisiert
|
||||
2. Die bestehende Konfiguration wird als `adguard-shield.conf.old` gesichert
|
||||
3. Neue Konfigurationsparameter werden automatisch zur bestehenden Konfig hinzugefügt
|
||||
4. Bestehende Einstellungen bleiben **immer** erhalten
|
||||
5. Der systemd Service wird per `daemon-reload` neu geladen
|
||||
6. Der Service wird automatisch neu gestartet (falls er lief)
|
||||
|
||||
### Voraussetzungen
|
||||
|
||||
Folgende Pakete werden bei der Installation automatisch installiert (via `apt`):
|
||||
- `curl` — API-Kommunikation mit AdGuard Home
|
||||
- `jq` — JSON-Verarbeitung der API-Antworten
|
||||
- `iptables` — Firewall-Regeln für IP-Sperren
|
||||
- `gawk` — Textverarbeitung
|
||||
- `systemd` — Service-Management
|
||||
|
||||
## Monitor (Hauptscript)
|
||||
|
||||
```bash
|
||||
# Starten
|
||||
sudo /opt/adguard-shield/adguard-shield.sh start
|
||||
|
||||
# Stoppen
|
||||
sudo /opt/adguard-shield/adguard-shield.sh stop
|
||||
|
||||
# Status + aktive Sperren anzeigen
|
||||
sudo /opt/adguard-shield/adguard-shield.sh status
|
||||
|
||||
# Ban-History anzeigen (letzte 50 Einträge)
|
||||
sudo /opt/adguard-shield/adguard-shield.sh history
|
||||
|
||||
# Ban-History anzeigen (letzte 100 Einträge)
|
||||
sudo /opt/adguard-shield/adguard-shield.sh history 100
|
||||
|
||||
# Alle Sperren aufheben
|
||||
sudo /opt/adguard-shield/adguard-shield.sh flush
|
||||
|
||||
# Einzelne IP entsperren
|
||||
sudo /opt/adguard-shield/adguard-shield.sh unban 192.168.1.100
|
||||
|
||||
# API-Verbindung testen
|
||||
sudo /opt/adguard-shield/adguard-shield.sh test
|
||||
|
||||
# Dry-Run (nur loggen, nichts sperren)
|
||||
sudo /opt/adguard-shield/adguard-shield.sh dry-run
|
||||
|
||||
# Externe Blocklist - Status anzeigen
|
||||
sudo /opt/adguard-shield/adguard-shield.sh blocklist-status
|
||||
|
||||
# Externe Blocklist - Einmalige Synchronisation
|
||||
sudo /opt/adguard-shield/adguard-shield.sh blocklist-sync
|
||||
|
||||
# Externe Blocklist - Alle Sperren der externen Liste aufheben
|
||||
sudo /opt/adguard-shield/adguard-shield.sh blocklist-flush
|
||||
```
|
||||
|
||||
## iptables Helper
|
||||
|
||||
Für die manuelle Verwaltung der Firewall-Regeln:
|
||||
|
||||
```bash
|
||||
# Chain erstellen
|
||||
sudo /opt/adguard-shield/iptables-helper.sh create
|
||||
|
||||
# Alle Regeln anzeigen
|
||||
sudo /opt/adguard-shield/iptables-helper.sh status
|
||||
|
||||
# IP manuell sperren
|
||||
sudo /opt/adguard-shield/iptables-helper.sh ban 192.168.1.100
|
||||
|
||||
# IP entsperren
|
||||
sudo /opt/adguard-shield/iptables-helper.sh unban 192.168.1.100
|
||||
|
||||
# Alle Regeln leeren
|
||||
sudo /opt/adguard-shield/iptables-helper.sh flush
|
||||
|
||||
# Chain komplett entfernen
|
||||
sudo /opt/adguard-shield/iptables-helper.sh remove
|
||||
|
||||
# Regeln speichern / wiederherstellen
|
||||
sudo /opt/adguard-shield/iptables-helper.sh save
|
||||
sudo /opt/adguard-shield/iptables-helper.sh restore
|
||||
```
|
||||
|
||||
## Externer Blocklist-Worker
|
||||
|
||||
Der Worker kann auch standalone gesteuert werden:
|
||||
|
||||
```bash
|
||||
# Worker manuell starten (normalerweise automatisch per Hauptscript)
|
||||
sudo /opt/adguard-shield/external-blocklist-worker.sh start
|
||||
|
||||
# Worker stoppen
|
||||
sudo /opt/adguard-shield/external-blocklist-worker.sh stop
|
||||
|
||||
# Einmalige Synchronisation (z.B. nach Konfigurationsänderung)
|
||||
sudo /opt/adguard-shield/external-blocklist-worker.sh sync
|
||||
|
||||
# Status anzeigen
|
||||
sudo /opt/adguard-shield/external-blocklist-worker.sh status
|
||||
|
||||
# Alle externen Sperren aufheben
|
||||
sudo /opt/adguard-shield/external-blocklist-worker.sh flush
|
||||
```
|
||||
|
||||
## systemd Service
|
||||
|
||||
Der Service wird bei der Installation automatisch für den **Autostart beim Booten** aktiviert.
|
||||
|
||||
```bash
|
||||
# Start / Stop / Restart
|
||||
sudo systemctl start adguard-shield
|
||||
sudo systemctl stop adguard-shield
|
||||
sudo systemctl restart adguard-shield
|
||||
|
||||
# Status
|
||||
sudo systemctl status adguard-shield
|
||||
|
||||
# Autostart aktivieren / deaktivieren
|
||||
sudo systemctl enable adguard-shield
|
||||
sudo systemctl disable adguard-shield
|
||||
```
|
||||
|
||||
> **Hinweis:** Nach einem Update wird der Service automatisch neu gestartet. Ein manueller Neustart ist nicht nötig.
|
||||
|
||||
## Logs
|
||||
|
||||
```bash
|
||||
# systemd Journal
|
||||
sudo journalctl -u adguard-shield -f
|
||||
|
||||
# Log-Datei direkt
|
||||
sudo tail -f /var/log/adguard-shield.log
|
||||
|
||||
# Nur Sperr-Einträge
|
||||
sudo grep "SPERRE" /var/log/adguard-shield.log
|
||||
|
||||
# Nur Entsperr-Einträge
|
||||
sudo grep "ENTSPERRE" /var/log/adguard-shield.log
|
||||
```
|
||||
|
||||
## Cron-basiertes Entsperren
|
||||
|
||||
Als Alternative oder Ergänzung zum Haupt-Monitor:
|
||||
|
||||
```bash
|
||||
# Crontab bearbeiten
|
||||
sudo crontab -e
|
||||
|
||||
# Alle 5 Minuten abgelaufene Sperren prüfen
|
||||
*/5 * * * * /opt/adguard-shield/unban-expired.sh
|
||||
```
|
||||
|
||||
## Hilfe
|
||||
|
||||
Alle verfügbaren Befehle und Optionen des Installers anzeigen:
|
||||
|
||||
```bash
|
||||
sudo bash install.sh --help
|
||||
sudo bash install.sh -h
|
||||
```
|
||||
@@ -1,110 +0,0 @@
|
||||
# Webhook-Benachrichtigungen
|
||||
|
||||
Das Tool kann bei Sperren und Entsperrungen Benachrichtigungen an verschiedene Dienste senden.
|
||||
|
||||
## Aktivierung
|
||||
|
||||
In der Konfiguration (`adguard-shield.conf`):
|
||||
|
||||
```bash
|
||||
NOTIFY_ENABLED=true
|
||||
NOTIFY_TYPE="<typ>"
|
||||
NOTIFY_WEBHOOK_URL="<url>"
|
||||
```
|
||||
|
||||
## Ntfy
|
||||
|
||||
```bash
|
||||
NOTIFY_ENABLED=true
|
||||
NOTIFY_TYPE="ntfy"
|
||||
NTFY_SERVER_URL="https://ntfy.sh"
|
||||
NTFY_TOPIC="adguard-shield"
|
||||
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:**
|
||||
```bash
|
||||
NTFY_SERVER_URL="https://ntfy.mein-server.de"
|
||||
NTFY_TOPIC="dns-security"
|
||||
NTFY_TOKEN="tk_mein_geheimer_token"
|
||||
```
|
||||
|
||||
**Prioritäten:**
|
||||
| Wert | Bedeutung |
|
||||
|------|-----------|
|
||||
| 1 | Minimum |
|
||||
| 2 | Niedrig |
|
||||
| 3 | Standard |
|
||||
| 4 | Hoch |
|
||||
| 5 | Maximum |
|
||||
|
||||
**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
|
||||
|
||||
## Discord
|
||||
|
||||
```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
|
||||
|
||||
## Gotify
|
||||
|
||||
```bash
|
||||
NOTIFY_ENABLED=true
|
||||
NOTIFY_TYPE="gotify"
|
||||
NOTIFY_WEBHOOK_URL="https://gotify.example.com/message?token=xxx"
|
||||
```
|
||||
|
||||
**Token erstellen:**
|
||||
1. Gotify Web-UI → Apps → App erstellen
|
||||
2. Token kopieren und in die URL einfügen
|
||||
|
||||
## Slack
|
||||
|
||||
```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
|
||||
|
||||
## Generic (eigener Endpoint)
|
||||
|
||||
```bash
|
||||
NOTIFY_ENABLED=true
|
||||
NOTIFY_TYPE="generic"
|
||||
NOTIFY_WEBHOOK_URL="https://your-server.com/webhook"
|
||||
```
|
||||
|
||||
Sendet einen POST mit JSON-Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "🚫 AdGuard Shield: Client 192.168.1.50 gesperrt ...",
|
||||
"action": "ban",
|
||||
"client": "192.168.1.50",
|
||||
"domain": "microsoft.com"
|
||||
}
|
||||
```
|
||||
|
||||
## Beispiel-Nachrichten
|
||||
|
||||
**Sperre:**
|
||||
> 🚫 AdGuard Shield: Client **192.168.1.50** gesperrt (45x microsoft.com in 60s). Sperre für 3600s.
|
||||
|
||||
**Entsperrung:**
|
||||
> ✅ AdGuard Shield: Client **192.168.1.50** wurde entsperrt.
|
||||
@@ -1,142 +0,0 @@
|
||||
# Konfiguration
|
||||
|
||||
Die Konfigurationsdatei liegt nach der Installation unter:
|
||||
|
||||
```
|
||||
/opt/adguard-shield/adguard-shield.conf
|
||||
```
|
||||
|
||||
## Automatische Konfigurations-Migration
|
||||
|
||||
Bei einem **Update** (`sudo bash install.sh update`) wird die Konfiguration automatisch migriert:
|
||||
|
||||
1. Die aktuelle Konfiguration wird als **Backup** gespeichert: `adguard-shield.conf.old`
|
||||
2. Neue Parameter (die in der alten Konfig noch nicht existieren) werden **automatisch** zur bestehenden Konfiguration hinzugefügt
|
||||
3. Alle bestehenden Einstellungen bleiben **unverändert** erhalten
|
||||
|
||||
Dadurch muss der Benutzer bei Updates die Konfiguration nicht manuell austauschen oder vergleichen.
|
||||
|
||||
> **Hinweis:** Nach einem Update empfiehlt es sich, die eventuell neu hinzugefügten Parameter zu prüfen und bei Bedarf anzupassen.
|
||||
|
||||
## Alle Parameter
|
||||
|
||||
### AdGuard Home API
|
||||
|
||||
| Parameter | Standard | Beschreibung |
|
||||
|-----------|----------|--------------|
|
||||
| `ADGUARD_URL` | `http://127.0.0.1:3000` | AdGuard Home Web-UI URL |
|
||||
| `ADGUARD_USER` | `admin` | API Benutzername |
|
||||
| `ADGUARD_PASS` | `changeme` | API Passwort |
|
||||
|
||||
### Rate-Limit
|
||||
|
||||
| Parameter | Standard | Beschreibung |
|
||||
|-----------|----------|--------------|
|
||||
| `RATE_LIMIT_MAX_REQUESTS` | `30` | Max. Anfragen pro Domain/Client innerhalb des Zeitfensters |
|
||||
| `RATE_LIMIT_WINDOW` | `60` | Zeitfenster in Sekunden |
|
||||
| `CHECK_INTERVAL` | `10` | Wie oft die Logs geprüft werden (Sekunden) |
|
||||
| `API_QUERY_LIMIT` | `500` | Anzahl API-Einträge pro Abfrage (max 5000) |
|
||||
|
||||
### Sperr-Einstellungen
|
||||
|
||||
| Parameter | Standard | Beschreibung |
|
||||
|-----------|----------|--------------|
|
||||
| `BAN_DURATION` | `3600` | Sperrdauer in Sekunden (3600 = 1 Stunde) |
|
||||
| `IPTABLES_CHAIN` | `ADGUARD_SHIELD` | Name der iptables Chain |
|
||||
| `BLOCKED_PORTS` | `53 443 853 784 8853` | Ports die gesperrt werden |
|
||||
| `WHITELIST` | `127.0.0.1,::1` | IPs die nie gesperrt werden (kommagetrennt) |
|
||||
|
||||
### Logging
|
||||
|
||||
| Parameter | Standard | Beschreibung |
|
||||
|-----------|----------|--------------|
|
||||
| `LOG_FILE` | `/var/log/adguard-shield.log` | Pfad zur Log-Datei |
|
||||
| `LOG_LEVEL` | `INFO` | Log-Level: `DEBUG`, `INFO`, `WARN`, `ERROR` |
|
||||
| `LOG_MAX_SIZE_MB` | `50` | Max. Log-Größe bevor rotiert wird |
|
||||
| `BAN_HISTORY_FILE` | `/var/log/adguard-shield-bans.log` | Datei für die Ban-History (alle Sperren/Entsperrungen) |
|
||||
|
||||
### Benachrichtigungen
|
||||
|
||||
| Parameter | Standard | Beschreibung |
|
||||
|-----------|----------|--------------|
|
||||
| `NOTIFY_ENABLED` | `false` | Webhook-Benachrichtigungen aktivieren |
|
||||
| `NOTIFY_WEBHOOK_URL` | *(leer)* | Webhook-URL |
|
||||
| `NOTIFY_TYPE` | `generic` | Typ: `discord`, `slack`, `gotify`, `generic` |
|
||||
|
||||
### Erweitert
|
||||
|
||||
| Parameter | Standard | Beschreibung |
|
||||
|-----------|----------|--------------|
|
||||
| `STATE_DIR` | `/var/lib/adguard-shield` | Verzeichnis für State-Dateien |
|
||||
| `PID_FILE` | `/var/run/adguard-shield.pid` | PID-Datei |
|
||||
| `DRY_RUN` | `false` | Testmodus — nur loggen, nicht sperren |
|
||||
### Externe Blocklist
|
||||
|
||||
Ermöglicht das Einbinden externer IP-Blocklisten (z.B. gehostete Textdateien mit einer IP pro Zeile). Der Worker läuft als Hintergrundprozess und prüft periodisch auf Änderungen.
|
||||
|
||||
| Parameter | Standard | Beschreibung |
|
||||
|-----------|----------|}--------------|
|
||||
| `EXTERNAL_BLOCKLIST_ENABLED` | `false` | Aktiviert den externen Blocklist-Worker |
|
||||
| `EXTERNAL_BLOCKLIST_URLS` | *(leer)* | URL(s) zu Textdateien mit IPs (kommagetrennt) |
|
||||
| `EXTERNAL_BLOCKLIST_INTERVAL` | `300` | Prüfintervall in Sekunden (300 = 5 Min.) |
|
||||
| `EXTERNAL_BLOCKLIST_BAN_DURATION` | `0` | Sperrdauer in Sekunden (0 = permanent bis IP aus Liste entfernt) |
|
||||
| `EXTERNAL_BLOCKLIST_AUTO_UNBAN` | `true` | IPs automatisch entsperren wenn aus Liste entfernt |
|
||||
| `EXTERNAL_BLOCKLIST_CACHE_DIR` | `/var/lib/adguard-shield/external-blocklist` | Lokaler Cache für heruntergeladene Listen |
|
||||
|
||||
#### Externe Blocklist einrichten
|
||||
|
||||
1. Erstelle eine Textdatei auf einem Webserver mit einer IP pro Zeile:
|
||||
|
||||
```text
|
||||
# Kommentare werden ignoriert
|
||||
192.168.100.50
|
||||
10.0.0.99
|
||||
2001:db8::dead:beef
|
||||
```
|
||||
|
||||
2. Aktiviere die Blocklist in der Konfiguration:
|
||||
|
||||
```bash
|
||||
EXTERNAL_BLOCKLIST_ENABLED=true
|
||||
EXTERNAL_BLOCKLIST_URLS="https://example.com/blocklist.txt"
|
||||
EXTERNAL_BLOCKLIST_INTERVAL=300
|
||||
```
|
||||
|
||||
3. Mehrere Listen können kommagetrennt angegeben werden:
|
||||
|
||||
```bash
|
||||
EXTERNAL_BLOCKLIST_URLS="https://example.com/list1.txt,https://other.com/list2.txt"
|
||||
```
|
||||
|
||||
4. Service neustarten:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart adguard-shield
|
||||
```
|
||||
## Gesperrte Ports im Detail
|
||||
|
||||
Bei einem Rate-Limit-Verstoß werden **alle** DNS-Protokoll-Ports für den Client gesperrt:
|
||||
|
||||
| Port | Protokoll | Beschreibung |
|
||||
|------|-----------|-------------|
|
||||
| 53 | UDP/TCP | Standard DNS |
|
||||
| 443 | TCP | DNS-over-HTTPS (DoH) |
|
||||
| 853 | TCP | DNS-over-TLS (DoT) |
|
||||
| 853 | UDP | DNS-over-QUIC (DoQ) |
|
||||
| 784 | UDP | DNS-over-QUIC (alternativ) |
|
||||
| 8853 | UDP | DNS-over-QUIC (alternativ) |
|
||||
|
||||
## Whitelist richtig pflegen
|
||||
|
||||
Die Whitelist sollte mindestens enthalten:
|
||||
|
||||
- `127.0.0.1` und `::1` (Localhost)
|
||||
- Die IP deines Routers / Gateways
|
||||
- Deine eigenen Management-IPs
|
||||
- Andere vertrauenswürdige DNS-Clients
|
||||
|
||||
Beispiel:
|
||||
|
||||
```
|
||||
WHITELIST="127.0.0.1,::1,192.168.1.1,192.168.1.10,fd00::1"
|
||||
```
|
||||
@@ -1,127 +0,0 @@
|
||||
# Tipps & Troubleshooting
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **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
|
||||
|
||||
## Häufige Probleme
|
||||
|
||||
### API-Verbindung schlägt fehl
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield.sh test
|
||||
```
|
||||
|
||||
**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
|
||||
|
||||
**Lösung:** URL manuell testen:
|
||||
```bash
|
||||
curl -s -u admin:passwort http://127.0.0.1:3000/control/querylog?limit=1
|
||||
```
|
||||
|
||||
### iptables-Fehler: "Permission denied"
|
||||
|
||||
Das Script muss als **root** laufen, da iptables Root-Rechte benötigt.
|
||||
|
||||
```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. IP zur Whitelist hinzufügen in `adguard-shield.conf`
|
||||
3. Service neustarten:
|
||||
```bash
|
||||
sudo systemctl restart adguard-shield
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
### Monitor startet nicht (PID-File)
|
||||
|
||||
```bash
|
||||
# Altes PID-File entfernen
|
||||
sudo rm -f /var/run/adguard-shield.pid
|
||||
sudo systemctl start adguard-shield
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
```bash
|
||||
# Über den Installer (interaktiv mit Menü)
|
||||
sudo bash install.sh uninstall
|
||||
```
|
||||
|
||||
Oder manuell:
|
||||
```bash
|
||||
sudo systemctl stop adguard-shield
|
||||
sudo systemctl disable adguard-shield
|
||||
sudo /opt/adguard-shield/iptables-helper.sh remove
|
||||
sudo rm -rf /opt/adguard-shield
|
||||
sudo rm -f /etc/systemd/system/adguard-shield.service
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
Folgende Pakete werden für den Betrieb benötigt und bei der Installation automatisch installiert:
|
||||
|
||||
| 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 |
|
||||
|
||||
Diese werden bei `sudo bash install.sh install` automatisch geprüft und bei Bedarf über den Paketmanager (`apt`, `dnf`, `yum`, `pacman`) nachinstalliert.
|
||||
82
docs/README.md
Normal file
82
docs/README.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Dokumentation
|
||||
|
||||
Willkommen in der Dokumentation von AdGuard Shield.
|
||||
|
||||
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.
|
||||
|
||||
## Schnellnavigation
|
||||
|
||||
| Dokument | Wofür es gedacht ist |
|
||||
|---|---|
|
||||
| [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 |
|
||||
478
docs/architektur.md
Normal file
478
docs/architektur.md
Normal file
@@ -0,0 +1,478 @@
|
||||
# Architektur & Funktionsweise
|
||||
|
||||
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.
|
||||
|
||||
## Kurzüberblick
|
||||
|
||||
AdGuard Shield besteht in der Go-Version aus einem einzelnen Binary:
|
||||
|
||||
```text
|
||||
/opt/adguard-shield/adguard-shield
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
Eine Rate-Limit-Sperre entsteht, wenn ein Client dieselbe Domain innerhalb des konfigurierten Fensters zu oft abfragt.
|
||||
|
||||
**Konfiguration:**
|
||||
|
||||
```bash
|
||||
RATE_LIMIT_MAX_REQUESTS=30 # Maximale Anfragen pro Client und Domain
|
||||
RATE_LIMIT_WINDOW=60 # Zeitfenster in Sekunden
|
||||
```
|
||||
|
||||
**Ablauf am Beispiel:**
|
||||
|
||||
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.
|
||||
|
||||
### Subdomain-Flood-Erkennung
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
**Ablauf am Beispiel:**
|
||||
|
||||
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"
|
||||
```
|
||||
|
||||
**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
|
||||
```
|
||||
|
||||
**Stufenverlauf mit Standardwerten:**
|
||||
|
||||
| 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 # 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
|
||||
|
||||
/var/lib/adguard-shield/
|
||||
├── 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 # Daemon-Logdatei
|
||||
|
||||
/etc/cron.d/
|
||||
└── adguard-shield-report # Cron-Job für Reports (optional)
|
||||
```
|
||||
|
||||
## Hintergrundjobs im Daemon
|
||||
|
||||
Es gibt in der Go-Version keine separaten Worker-Skripte mehr. Diese Aufgaben laufen als Goroutines im Daemon:
|
||||
|
||||
| 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 |
|
||||
|
||||
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.
|
||||
|
||||
## Whitelist-Logik
|
||||
|
||||
Vor jeder Sperre wird geprüft, ob die IP vertrauenswürdig ist.
|
||||
|
||||
**Quellen (in Prüfreihenfolge):**
|
||||
|
||||
1. Statische `WHITELIST` aus der Konfiguration (kommagetrennt)
|
||||
2. Aufgelöste IPs aus externen Whitelists (gespeichert in SQLite)
|
||||
|
||||
**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:**
|
||||
|
||||
```bash
|
||||
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.
|
||||
906
docs/befehle.md
Normal file
906
docs/befehle.md
Normal file
@@ -0,0 +1,906 @@
|
||||
# Befehle & Nutzung
|
||||
|
||||
AdGuard Shield wird in der Go-Version über ein einzelnes Binary bedient:
|
||||
|
||||
```bash
|
||||
/opt/adguard-shield/adguard-shield
|
||||
```
|
||||
|
||||
Dieses Binary ist Daemon, CLI, Installer, Updater, Uninstaller und Report-Generator. Dadurch gibt es keine getrennten Shell-Skripte mehr.
|
||||
|
||||
## Grundform
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield <befehl>
|
||||
```
|
||||
|
||||
Wenn du eine andere Konfigurationsdatei verwenden möchtest, muss `-config` direkt vor dem Befehl stehen:
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield -config /pfad/zur/adguard-shield.conf status
|
||||
```
|
||||
|
||||
### Standardpfade
|
||||
|
||||
| Datei | Pfad |
|
||||
|---|---|
|
||||
| Binary | `/opt/adguard-shield/adguard-shield` |
|
||||
| Konfiguration | `/opt/adguard-shield/adguard-shield.conf` |
|
||||
| SQLite-Datenbank | `/var/lib/adguard-shield/adguard-shield.db` |
|
||||
| Logdatei | `/var/log/adguard-shield.log` |
|
||||
| PID-Datei | `/var/run/adguard-shield.pid` |
|
||||
|
||||
## Schnellübersicht
|
||||
|
||||
```bash
|
||||
# Version anzeigen
|
||||
/opt/adguard-shield/adguard-shield version
|
||||
|
||||
# Installation und Update
|
||||
sudo ./adguard-shield install
|
||||
sudo ./adguard-shield update
|
||||
sudo ./adguard-shield install-status
|
||||
sudo /opt/adguard-shield/adguard-shield uninstall --keep-config
|
||||
|
||||
# Service-Management über systemd
|
||||
sudo systemctl start adguard-shield
|
||||
sudo systemctl stop adguard-shield
|
||||
sudo systemctl restart adguard-shield
|
||||
sudo systemctl status adguard-shield
|
||||
|
||||
# Diagnose und Monitoring
|
||||
sudo /opt/adguard-shield/adguard-shield test
|
||||
sudo /opt/adguard-shield/adguard-shield status
|
||||
sudo /opt/adguard-shield/adguard-shield live
|
||||
sudo /opt/adguard-shield/adguard-shield history 100
|
||||
sudo /opt/adguard-shield/adguard-shield logs --level warn --limit 100
|
||||
|
||||
# Manuelle Eingriffe
|
||||
sudo /opt/adguard-shield/adguard-shield ban 192.168.1.100
|
||||
sudo /opt/adguard-shield/adguard-shield unban 192.168.1.100
|
||||
sudo /opt/adguard-shield/adguard-shield flush
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
Das installierte Binary landet standardmäßig unter:
|
||||
|
||||
```text
|
||||
/opt/adguard-shield/adguard-shield
|
||||
```
|
||||
|
||||
### Standardinstallation
|
||||
|
||||
```bash
|
||||
chmod +x ./adguard-shield
|
||||
sudo ./adguard-shield install
|
||||
```
|
||||
|
||||
Am Ende fragt der Installer, ob AdGuard Shield direkt gestartet oder neu gestartet werden soll.
|
||||
|
||||
### Installationsoptionen
|
||||
|
||||
| Option | Beschreibung |
|
||||
|---|---|
|
||||
| `--config-source <pfad>` | Bestehende Konfigurationsdatei als Vorlage übernehmen |
|
||||
| `--skip-deps` | Automatische Paketprüfung und -installation überspringen |
|
||||
| `--no-enable` | systemd-Autostart nicht aktivieren |
|
||||
| `--install-dir <pfad>` | Abweichendes Installationsverzeichnis verwenden |
|
||||
|
||||
**Beispiele:**
|
||||
|
||||
```bash
|
||||
# Konfiguration aus anderem Pfad übernehmen
|
||||
sudo ./adguard-shield install --config-source ./adguard-shield.conf
|
||||
|
||||
# Ohne Paketprüfung installieren
|
||||
sudo ./adguard-shield install --skip-deps
|
||||
|
||||
# In anderes Verzeichnis installieren
|
||||
sudo ./adguard-shield install --install-dir /opt/adguard-shield-test
|
||||
```
|
||||
|
||||
### Was der Installer macht
|
||||
|
||||
Der Installer führt diese Schritte automatisch durch:
|
||||
|
||||
| Schritt | Beschreibung |
|
||||
|---:|---|
|
||||
| 1 | Linux- und Root-Prüfung |
|
||||
| 2 | Prüfung auf alte Shell-Artefakte |
|
||||
| 3 | Installation fehlender Abhängigkeiten über `apt-get` (sofern möglich) |
|
||||
| 4 | Anlage von Installations- und State-Verzeichnissen |
|
||||
| 5 | Kopieren des Binarys nach `/opt/adguard-shield/` |
|
||||
| 6 | Anlage oder Migration der Konfiguration |
|
||||
| 7 | Schreiben der systemd-Unit |
|
||||
| 8 | `systemctl daemon-reload` |
|
||||
| 9 | Optional: Autostart aktivieren |
|
||||
| 10 | Nachfrage: Service direkt starten oder neu starten |
|
||||
|
||||
### Benötigte Systembefehle
|
||||
|
||||
| Befehl | Paket (Debian/Ubuntu) | Zweck |
|
||||
|---|---|---|
|
||||
| `iptables` | `iptables` | IPv4-Firewall |
|
||||
| `ip6tables` | `iptables` | IPv6-Firewall |
|
||||
| `ipset` | `ipset` | IP-Set-Verwaltung für performante Sperren |
|
||||
| `systemctl` | `systemd` | Service-Management |
|
||||
|
||||
Auf Debian/Ubuntu installiert der Installer passende Pakete automatisch, sofern `apt-get` verfügbar ist und `--skip-deps` nicht gesetzt wurde.
|
||||
|
||||
---
|
||||
|
||||
## Update
|
||||
|
||||
Ein Update wird immer mit dem **neuen** Binary ausgeführt, nicht mit dem bereits installierten alten Binary:
|
||||
|
||||
```bash
|
||||
chmod +x ./adguard-shield
|
||||
sudo ./adguard-shield update
|
||||
```
|
||||
|
||||
Am Ende fragt der Updater, ob AdGuard Shield direkt neu gestartet werden soll.
|
||||
|
||||
### Update mit expliziter Konfigurationsquelle
|
||||
|
||||
```bash
|
||||
sudo ./adguard-shield update --config-source ./adguard-shield.conf
|
||||
```
|
||||
|
||||
### Was beim Update passiert
|
||||
|
||||
- Die Installation wird wie bei `install` aktualisiert
|
||||
- Vorhandene Konfiguration bleibt erhalten
|
||||
- Neue Konfigurationsparameter werden ergänzt
|
||||
- Bei einer Migration wird `adguard-shield.conf.old` geschrieben
|
||||
- Die systemd-Unit wird neu geschrieben und systemd neu geladen
|
||||
|
||||
Weitere Details stehen in der [Update-Anleitung](update.md).
|
||||
|
||||
---
|
||||
|
||||
## Installationsstatus
|
||||
|
||||
```bash
|
||||
sudo ./adguard-shield install-status
|
||||
```
|
||||
|
||||
Zeigt eine Übersicht mit:
|
||||
|
||||
- Installationspfad und Binary-Status
|
||||
- Installierte Version
|
||||
- Konfiguration vorhanden
|
||||
- systemd-Service vorhanden und Status
|
||||
- Autostart aktiv
|
||||
- Gefundene Legacy-Artefakte
|
||||
|
||||
Für ein anderes Installationsverzeichnis:
|
||||
|
||||
```bash
|
||||
sudo ./adguard-shield install-status --install-dir /opt/adguard-shield-test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deinstallation
|
||||
|
||||
```bash
|
||||
# Vollständige Deinstallation
|
||||
sudo /opt/adguard-shield/adguard-shield uninstall
|
||||
|
||||
# Deinstallation mit Konfigurationserhalt
|
||||
sudo /opt/adguard-shield/adguard-shield uninstall --keep-config
|
||||
```
|
||||
|
||||
**Was bei der Deinstallation passiert:**
|
||||
|
||||
| Schritt | Beschreibung |
|
||||
|---:|---|
|
||||
| 1 | Service stoppen |
|
||||
| 2 | Autostart deaktivieren |
|
||||
| 3 | Shield-Firewall-Struktur entfernen (Chain, ipsets) |
|
||||
| 4 | systemd-Unit löschen |
|
||||
| 5 | systemd neu laden |
|
||||
| 6 | Installationsverzeichnis, State und Log entfernen |
|
||||
|
||||
Mit `--keep-config` bleiben Konfigurationsdaten erhalten. Das ist sinnvoll, wenn du neu installieren oder migrieren möchtest.
|
||||
|
||||
---
|
||||
|
||||
## Alte Shell-Installation
|
||||
|
||||
Die Go-Version darf nicht parallel zur alten Shell-Version laufen. Der Installer bricht ab, wenn er alte Artefakte findet, zum Beispiel:
|
||||
|
||||
```text
|
||||
/opt/adguard-shield/adguard-shield.sh
|
||||
/opt/adguard-shield/iptables-helper.sh
|
||||
/opt/adguard-shield/external-blocklist-worker.sh
|
||||
/opt/adguard-shield/geoip-worker.sh
|
||||
/etc/systemd/system/adguard-shield-watchdog.timer
|
||||
```
|
||||
|
||||
**Empfohlener Ablauf:**
|
||||
|
||||
1. Bestehende `/opt/adguard-shield/adguard-shield.conf` sichern.
|
||||
2. Alte Shell-Version mit deren Uninstaller entfernen und die Konfiguration behalten.
|
||||
3. Go-Binary erneut installieren.
|
||||
4. Konfiguration prüfen.
|
||||
5. Zuerst `dry-run`, dann produktiven Service starten.
|
||||
|
||||
Weitere Details stehen in der [Update-Anleitung](update.md).
|
||||
|
||||
---
|
||||
|
||||
## systemd-Service
|
||||
|
||||
Im produktiven Betrieb sollte AdGuard Shield über systemd laufen:
|
||||
|
||||
```bash
|
||||
sudo systemctl start adguard-shield # Service starten
|
||||
sudo systemctl stop adguard-shield # Service stoppen
|
||||
sudo systemctl restart adguard-shield # Service neu starten
|
||||
sudo systemctl status adguard-shield # Status anzeigen
|
||||
```
|
||||
|
||||
### Autostart
|
||||
|
||||
```bash
|
||||
sudo systemctl enable adguard-shield # Autostart aktivieren
|
||||
sudo systemctl disable adguard-shield # Autostart deaktivieren
|
||||
```
|
||||
|
||||
### Nach manuellen Änderungen an der Unit
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
### Startbefehl der Unit
|
||||
|
||||
Die systemd-Unit startet den Daemon mit:
|
||||
|
||||
```bash
|
||||
/opt/adguard-shield/adguard-shield -config /opt/adguard-shield/adguard-shield.conf run
|
||||
```
|
||||
|
||||
Die Go-Version nutzt `Restart=on-failure` mit `RestartSec=30s`. Einen separaten Watchdog-Service oder Watchdog-Timer gibt es nicht mehr.
|
||||
|
||||
---
|
||||
|
||||
## Daemon direkt starten
|
||||
|
||||
Für Debugging oder Dry-Run kann der Daemon im Vordergrund gestartet werden:
|
||||
|
||||
```bash
|
||||
# Normaler Vordergrundlauf
|
||||
sudo /opt/adguard-shield/adguard-shield run
|
||||
|
||||
# Alias für run
|
||||
sudo /opt/adguard-shield/adguard-shield start
|
||||
|
||||
# Analysieren ohne echte Sperren
|
||||
sudo /opt/adguard-shield/adguard-shield dry-run
|
||||
```
|
||||
|
||||
### Daemon über PID-Datei stoppen
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield stop
|
||||
```
|
||||
|
||||
Für den Alltag gilt: Nutze `systemctl`. Der direkte Vordergrundlauf endet, sobald die Shell beendet wird oder du `Strg+C` drückst.
|
||||
|
||||
---
|
||||
|
||||
## API-Test
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield test
|
||||
```
|
||||
|
||||
Der `test`-Befehl prüft die Verbindung zur AdGuard-Home-API:
|
||||
|
||||
| Prüfung | Was getestet wird |
|
||||
|---|---|
|
||||
| Netzwerk | Ist `ADGUARD_URL` erreichbar? |
|
||||
| TLS | Funktioniert HTTPS/TLS? |
|
||||
| Authentifizierung | Stimmen `ADGUARD_USER` und `ADGUARD_PASS`? |
|
||||
| Querylog | Liefert AdGuard Home Querylog-Daten? |
|
||||
|
||||
**Bei Erfolg:**
|
||||
|
||||
```text
|
||||
Verbindung erfolgreich. 123 Querylog-Einträge gefunden.
|
||||
```
|
||||
|
||||
Wenn der Test fehlschlägt, zuerst die Konfiguration und die AdGuard-Home-Weboberfläche prüfen.
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield status
|
||||
```
|
||||
|
||||
Zeigt eine Übersicht des aktuellen Zustands:
|
||||
|
||||
- Verwendete Konfigurationsdatei
|
||||
- Firewall-Backend, -Modus und Chain
|
||||
- GeoIP-Aktivierung, Modus und Länderliste
|
||||
- Externe Blocklist (aktiv/inaktiv, Anzahl URLs)
|
||||
- Externe Whitelist (aktiv/inaktiv, Anzahl URLs)
|
||||
- Aktive Sperren mit IP, Quelle, Grund und Ablaufzeit
|
||||
|
||||
Bei sehr vielen aktiven Sperren werden nur die ersten 50 angezeigt. Für Details nutze `history` oder frage SQLite direkt ab.
|
||||
|
||||
---
|
||||
|
||||
## Live-Ansicht
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield live
|
||||
```
|
||||
|
||||
Die `live`-Ansicht ist das beste Werkzeug, wenn du verstehen möchtest, was gerade passiert. Sie zeigt in Echtzeit:
|
||||
|
||||
| Bereich | Inhalt |
|
||||
|---|---|
|
||||
| Query-Poller | API-Einträge, Zeitfenster und Rate-Limit-Status |
|
||||
| Top-Kombinationen | Häufigste Client/Domain-Paare |
|
||||
| Subdomain-Flood | Aktuelle Subdomain-Flood-Kandidaten |
|
||||
| Letzte Queries | Die neuesten Querylog-Einträge |
|
||||
| Aktive Sperren | Alle derzeit gesperrten IPs |
|
||||
| Externe Listen | Status von Blocklist und Whitelist |
|
||||
| GeoIP | GeoIP-Konfiguration und Status |
|
||||
| Offense-Cleanup | Progressive-Ban-Status |
|
||||
| Systemereignisse | Aktuelle Logeinträge |
|
||||
|
||||
### Optionen
|
||||
|
||||
| Option | Beschreibung | Beispiel |
|
||||
|---|---|---|
|
||||
| `--interval <sek>` | Aktualisierungsintervall in Sekunden | `live --interval 2` |
|
||||
| `--top <n>` | Anzahl der Top-Einträge | `live --top 20` |
|
||||
| `--recent <n>` | Anzahl letzter Queries und Logs | `live --recent 25` |
|
||||
| `--logs <level>` | Log-Level anzeigen (`debug`, `info`, `warn`, `error`, `off`) | `live --logs debug` |
|
||||
| `--once` | Einmaligen Snapshot ausgeben, nicht fortlaufend | `live --once` |
|
||||
|
||||
### Alias
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield watch
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## History
|
||||
|
||||
```bash
|
||||
# Letzte 50 Einträge (Standard)
|
||||
sudo /opt/adguard-shield/adguard-shield history
|
||||
|
||||
# Letzte 200 Einträge
|
||||
sudo /opt/adguard-shield/adguard-shield history 200
|
||||
```
|
||||
|
||||
Die History kommt aus der SQLite-Tabelle `ban_history`.
|
||||
|
||||
### Ausgabeformat
|
||||
|
||||
```text
|
||||
Zeit | Aktion | Client-IP | Domain | Anzahl | Dauer | Protokoll | Grund
|
||||
```
|
||||
|
||||
### Aktionstypen
|
||||
|
||||
| Aktion | Bedeutung |
|
||||
|---|---|
|
||||
| `BAN` | Echte Sperre gesetzt |
|
||||
| `UNBAN` | Sperre aufgehoben |
|
||||
| `DRY` | Im Dry-Run erkannt, aber nicht gesperrt |
|
||||
|
||||
### Sperrgrü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 durch `flush` |
|
||||
| `expired` | Temporäre Sperre abgelaufen |
|
||||
| `external-whitelist` | Freigabe durch externe Whitelist |
|
||||
| `geoip-flush` | Freigabe aller GeoIP-Sperren |
|
||||
| `external-blocklist-flush` | Freigabe aller Blocklist-Sperren |
|
||||
|
||||
---
|
||||
|
||||
## Logs
|
||||
|
||||
AdGuard Shield schreibt Daemon-Ereignisse in `LOG_FILE`, standardmäßig:
|
||||
|
||||
```text
|
||||
/var/log/adguard-shield.log
|
||||
```
|
||||
|
||||
### CLI-Befehle
|
||||
|
||||
```bash
|
||||
# Letzte INFO/WARN/ERROR-Einträge
|
||||
sudo /opt/adguard-shield/adguard-shield logs
|
||||
|
||||
# Letzte 100 Warnungen und Fehler
|
||||
sudo /opt/adguard-shield/adguard-shield logs --level warn --limit 100
|
||||
|
||||
# Kurzform (Level als Argument)
|
||||
sudo /opt/adguard-shield/adguard-shield logs debug
|
||||
|
||||
# Laufende Ansicht (wie tail -f)
|
||||
sudo /opt/adguard-shield/adguard-shield logs-follow --level info
|
||||
```
|
||||
|
||||
### Erlaubte Log-Level
|
||||
|
||||
| Level | Beschreibung |
|
||||
|---|---|
|
||||
| `DEBUG` | Detaillierte Informationen für Fehlersuche |
|
||||
| `INFO` | Normale Betriebsmeldungen (Start, Sperren, Freigaben) |
|
||||
| `WARN` | Warnungen (z.B. API-Fehler, fehlende Dateien) |
|
||||
| `ERROR` | Fehler, die den Betrieb beeinträchtigen |
|
||||
|
||||
### systemd-Journal
|
||||
|
||||
```bash
|
||||
sudo journalctl -u adguard-shield -f
|
||||
sudo journalctl -u adguard-shield --no-pager -n 100
|
||||
```
|
||||
|
||||
**Hinweis:** Query-Inhalte werden nicht dauerhaft in die Logdatei geschrieben. Für Query-nahe Diagnose ist die `live`-Ansicht gedacht.
|
||||
|
||||
---
|
||||
|
||||
## Manuelle Sperren und Freigaben
|
||||
|
||||
### IP permanent sperren
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield ban 192.168.1.100
|
||||
```
|
||||
|
||||
Legt eine manuelle permanente Sperre an. Die IP wird sofort in die Firewall eingetragen.
|
||||
|
||||
### IP entsperren
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield unban 192.168.1.100
|
||||
```
|
||||
|
||||
Entfernt die IP aus Firewall und Datenbank. Funktioniert für alle Sperrtypen (automatisch, manuell, GeoIP, Blocklist).
|
||||
|
||||
### Alle Sperren aufheben
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield flush
|
||||
```
|
||||
|
||||
Hebt alle aktiven Sperren auf. Bei aktivierten Benachrichtigungen wird eine zusammenfassende Meldung gesendet, nicht eine Nachricht pro IP.
|
||||
|
||||
**Wichtig:** Whitelist-Regeln gelten auch für manuelle Sperren. Eine IP aus `WHITELIST` oder externer Whitelist wird nicht gesperrt.
|
||||
|
||||
---
|
||||
|
||||
## Progressive Sperren und Offenses
|
||||
|
||||
### Offense-Status anzeigen
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield offense-status
|
||||
```
|
||||
|
||||
Zeigt die Gesamtzahl der Offense-Zähler, davon abgelaufene, und die Konfiguration.
|
||||
|
||||
### Abgelaufene Zähler entfernen
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield offense-cleanup
|
||||
```
|
||||
|
||||
### Alle Offense-Zähler zurücksetzen
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield reset-offenses
|
||||
```
|
||||
|
||||
### Zähler für eine IP zurücksetzen
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield reset-offenses 192.168.1.100
|
||||
```
|
||||
|
||||
### Typischer Ablauf nach Fehlkonfiguration
|
||||
|
||||
Wenn ein Client fälschlicherweise eskaliert wurde:
|
||||
|
||||
```bash
|
||||
# Sperre aufheben
|
||||
sudo /opt/adguard-shield/adguard-shield unban 192.168.1.100
|
||||
|
||||
# Offense-Zähler zurücksetzen
|
||||
sudo /opt/adguard-shield/adguard-shield reset-offenses 192.168.1.100
|
||||
|
||||
# IP dauerhaft in Whitelist aufnehmen (in adguard-shield.conf)
|
||||
# WHITELIST="127.0.0.1,::1,192.168.1.100"
|
||||
sudo systemctl restart adguard-shield
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Firewall-Befehle
|
||||
|
||||
### Chain und ipsets anlegen
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield firewall-create
|
||||
```
|
||||
|
||||
### Status anzeigen
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield firewall-status
|
||||
```
|
||||
|
||||
Zeigt die aktuelle Firewall-Struktur: Chain, ipsets und eingehängte Regeln.
|
||||
|
||||
### ipsets leeren
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield firewall-flush
|
||||
```
|
||||
|
||||
Entfernt alle IPs aus den ipsets. Die Firewall-Struktur (Chain, Regeln) bleibt bestehen.
|
||||
|
||||
### Chain und ipsets vollständig entfernen
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield firewall-remove
|
||||
```
|
||||
|
||||
### Firewall-Regeln sichern
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield firewall-save
|
||||
```
|
||||
|
||||
Speichert die aktuellen Regeln nach:
|
||||
|
||||
```text
|
||||
/var/lib/adguard-shield/iptables-rules.v4
|
||||
/var/lib/adguard-shield/iptables-rules.v6
|
||||
```
|
||||
|
||||
### Gesicherte Regeln wiederherstellen
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield firewall-restore
|
||||
```
|
||||
|
||||
**Hinweis:** Normalerweise musst du diese Befehle nicht manuell ausführen. Der Daemon erstellt die Firewall beim Start und schreibt aktive Sperren aus SQLite wieder hinein. Welche Host-Chain genutzt wird, hängt von `FIREWALL_MODE` ab. Details stehen in [Docker-Installationen](docker.md).
|
||||
|
||||
---
|
||||
|
||||
## Externe Whitelist
|
||||
|
||||
### Status anzeigen
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield whitelist-status
|
||||
```
|
||||
|
||||
### Sofort synchronisieren
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield whitelist-sync
|
||||
```
|
||||
|
||||
### Aufgelöste externe Whitelist entfernen
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield whitelist-flush
|
||||
```
|
||||
|
||||
### Hinweise
|
||||
|
||||
- Die externe Whitelist kann IPs, CIDR-Netze und Hostnamen enthalten.
|
||||
- Hostnamen werden per DNS aufgelöst und als IPs in SQLite gespeichert.
|
||||
- Eine gewhitelistete IP wird nicht gesperrt.
|
||||
- Wird eine bereits gesperrte IP später gewhitelistet, wird sie automatisch freigegeben.
|
||||
- Die dauerhafte Synchronisation läuft im Daemon im konfigurierten Intervall.
|
||||
- `whitelist-sync` erzwingt nur einen einzelnen, sofortigen Lauf.
|
||||
|
||||
---
|
||||
|
||||
## Externe Blocklist
|
||||
|
||||
### Status anzeigen
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield blocklist-status
|
||||
```
|
||||
|
||||
### Sofort synchronisieren
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield blocklist-sync
|
||||
```
|
||||
|
||||
### Alle Sperren aus externer Blocklist aufheben
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield blocklist-flush
|
||||
```
|
||||
|
||||
### Hinweise
|
||||
|
||||
- Die externe Blocklist kann IPs, CIDR-Netze und Hostnamen enthalten.
|
||||
- Hostnamen werden per DNS aufgelöst.
|
||||
- IPs aus der Whitelist werden übersprungen.
|
||||
- Bei `EXTERNAL_BLOCKLIST_AUTO_UNBAN=true` hebt der Daemon Blocklist-Sperren automatisch auf, sobald sie nicht mehr in der externen Liste vorkommen.
|
||||
|
||||
---
|
||||
|
||||
## GeoIP
|
||||
|
||||
### Status anzeigen
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield geoip-status
|
||||
```
|
||||
|
||||
### Einzelne IP nachschlagen
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield geoip-lookup 8.8.8.8
|
||||
```
|
||||
|
||||
**Ausgabe:**
|
||||
|
||||
```text
|
||||
IP: 8.8.8.8 -> Land: US
|
||||
```
|
||||
|
||||
### Aktuelle Clients prüfen
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield geoip-sync
|
||||
```
|
||||
|
||||
Liest das aktuelle Querylog und prüft alle darin enthaltenen Client-IPs einmalig gegen die GeoIP-Regeln.
|
||||
|
||||
### Alle GeoIP-Sperren aufheben
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield geoip-flush
|
||||
```
|
||||
|
||||
### Cache leeren
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield geoip-flush-cache
|
||||
```
|
||||
|
||||
### Hinweise
|
||||
|
||||
- GeoIP-Sperren sind permanent, werden aber bei Konfigurationsänderungen automatisch neu bewertet.
|
||||
- Die Ländercodes verwenden ISO 3166-1 Alpha-2 (siehe [ISO-3166-1-Kodierliste auf Wikipedia](https://de.wikipedia.org/wiki/ISO-3166-1-Kodierliste)).
|
||||
|
||||
---
|
||||
|
||||
## Reports
|
||||
|
||||
### Konfiguration und Cron-Status anzeigen
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield report-status
|
||||
```
|
||||
|
||||
### HTML-Report in Datei schreiben
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield report-generate html /tmp/adguard-shield-report.html
|
||||
```
|
||||
|
||||
### Text-Report auf stdout ausgeben
|
||||
|
||||
```bash
|
||||
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 funktioniert, 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
|
||||
```
|
||||
|
||||
Erstellt die Datei `/etc/cron.d/adguard-shield-report` mit dem konfigurierten Intervall und der Versandzeit.
|
||||
|
||||
### Cron-Job entfernen
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield report-remove
|
||||
```
|
||||
|
||||
Details zum Report-System stehen in [E-Mail Report](report.md).
|
||||
|
||||
---
|
||||
|
||||
## Dry-Run
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield dry-run
|
||||
```
|
||||
|
||||
Der Dry-Run ist der sicherste Weg, neue Konfigurationen zu prüfen, bevor sie produktiv gehen.
|
||||
|
||||
### Verhalten im Dry-Run
|
||||
|
||||
| Was passiert | Was nicht passiert |
|
||||
|---|---|
|
||||
| Querylogs werden normal gelesen | Keine aktiven Bans werden angelegt |
|
||||
| Rate-Limit, Subdomain-Flood, Watchlist werden ausgewertet | Keine Firewall-Regeln werden gesetzt |
|
||||
| GeoIP und externe Blocklist werden geprüft | Keine Benachrichtigungen werden gesendet |
|
||||
| Mögliche Sperren werden als `DRY` in die History geschrieben | |
|
||||
|
||||
### Typischer Ablauf nach größeren Änderungen
|
||||
|
||||
```bash
|
||||
# Dry-Run starten (Strg+C zum Beenden)
|
||||
sudo /opt/adguard-shield/adguard-shield dry-run
|
||||
|
||||
# Ergebnisse prüfen
|
||||
sudo /opt/adguard-shield/adguard-shield history 50
|
||||
sudo /opt/adguard-shield/adguard-shield logs --level warn --limit 80
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Version
|
||||
|
||||
```bash
|
||||
/opt/adguard-shield/adguard-shield version
|
||||
```
|
||||
|
||||
Zeigt die installierte Version an. Aliase: `--version`, `-v`.
|
||||
|
||||
---
|
||||
|
||||
## Typische Betriebsabläufe
|
||||
|
||||
### Nach Konfigurationsänderung
|
||||
|
||||
```bash
|
||||
sudo systemctl restart adguard-shield
|
||||
sudo /opt/adguard-shield/adguard-shield status
|
||||
sudo /opt/adguard-shield/adguard-shield logs --level info --limit 80
|
||||
```
|
||||
|
||||
### Falsch gesperrte IP freigeben
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield unban 192.168.1.100
|
||||
sudo /opt/adguard-shield/adguard-shield reset-offenses 192.168.1.100
|
||||
```
|
||||
|
||||
Danach die IP dauerhaft in `WHITELIST` oder eine externe Whitelist aufnehmen.
|
||||
|
||||
### Externe Listen neu laden
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield whitelist-sync
|
||||
sudo /opt/adguard-shield/adguard-shield blocklist-sync
|
||||
sudo /opt/adguard-shield/adguard-shield status
|
||||
```
|
||||
|
||||
### 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 schreibt der Daemon aktive Sperren aus SQLite wieder in die Firewall.
|
||||
|
||||
### Service-Problem eingrenzen
|
||||
|
||||
```bash
|
||||
sudo systemctl status adguard-shield
|
||||
sudo journalctl -u adguard-shield --no-pager -n 100
|
||||
sudo /opt/adguard-shield/adguard-shield test
|
||||
sudo /opt/adguard-shield/adguard-shield logs --level debug --limit 100
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DNS-Abfragen zum Testen
|
||||
|
||||
Die folgenden Befehle sind **ausschließlich für kontrollierte Tests gegen deinen eigenen DNS-Server** gedacht. Ersetze `203.0.113.50` durch deine eigene DNS-Server-IP und `example.com` durch eine Testdomain.
|
||||
|
||||
**Nicht gegen fremde DNS-Server, fremde Dienste oder fremde Infrastruktur verwenden.**
|
||||
|
||||
### Voraussetzungen auf dem Testclient
|
||||
|
||||
| Protokoll | Paket | Installationsbefehl |
|
||||
|---|---|---|
|
||||
| Klassisches DNS | `dnsutils` | `sudo apt install dnsutils` |
|
||||
| DNS-over-HTTPS | `curl` | `sudo apt install curl` |
|
||||
| DNS-over-TLS | `knot-dnsutils` | `sudo apt install knot-dnsutils` |
|
||||
|
||||
### Klassisches DNS: Rate-Limit testen
|
||||
|
||||
Gleiche Domain mehrfach abfragen (40 parallele Anfragen):
|
||||
|
||||
```bash
|
||||
for i in {1..40}; do \
|
||||
dig @203.0.113.50 example.com +short +cookie=$(openssl rand -hex 8) > /dev/null & \
|
||||
done; wait
|
||||
```
|
||||
|
||||
### Klassisches DNS: Subdomain-Flood testen
|
||||
|
||||
Viele zufällige Subdomains abfragen (60 parallele Anfragen):
|
||||
|
||||
```bash
|
||||
for i in {1..60}; do \
|
||||
dig @203.0.113.50 $(openssl rand -hex 6).example.com +short > /dev/null & \
|
||||
done; wait
|
||||
```
|
||||
|
||||
### DNS-over-HTTPS testen
|
||||
|
||||
```bash
|
||||
for i in {1..40}; do \
|
||||
curl -s -H "accept: application/dns-json" \
|
||||
"https://203.0.113.50/dns-query?name=example.com&type=A" > /dev/null & \
|
||||
done; wait
|
||||
```
|
||||
|
||||
Bei selbstsigniertem Zertifikat auf dem eigenen Testserver kann für diesen lokalen Test `-k` ergänzt werden.
|
||||
|
||||
### DNS-over-TLS testen
|
||||
|
||||
```bash
|
||||
for i in {1..40}; do \
|
||||
kdig @203.0.113.50 example.com +tls +short > /dev/null & \
|
||||
done; wait
|
||||
```
|
||||
|
||||
Die Beispielzahlen liegen bewusst nahe an den Standardlimits `RATE_LIMIT_MAX_REQUESTS=30` und `SUBDOMAIN_FLOOD_MAX_UNIQUE=50`.
|
||||
|
||||
---
|
||||
|
||||
## Eingebaute Hilfe
|
||||
|
||||
```bash
|
||||
/opt/adguard-shield/adguard-shield --help
|
||||
```
|
||||
|
||||
Bei unbekannten Befehlen gibt das Binary die Usage-Ausgabe aus.
|
||||
353
docs/benachrichtigungen.md
Normal file
353
docs/benachrichtigungen.md
Normal file
@@ -0,0 +1,353 @@
|
||||
# Benachrichtigungen
|
||||
|
||||
AdGuard Shield kann Ereignisse an Ntfy, Discord, Slack, Gotify oder einen eigenen Webhook senden. Benachrichtigungen sind optional und werden über `adguard-shield.conf` gesteuert.
|
||||
|
||||
## Unterstützte Ereignisse
|
||||
|
||||
| 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="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"
|
||||
NTFY_SERVER_URL="https://ntfy.sh"
|
||||
NTFY_TOPIC="adguard-shield"
|
||||
NTFY_TOKEN=""
|
||||
NTFY_PRIORITY="4"
|
||||
```
|
||||
|
||||
### Eigene Ntfy-Instanz
|
||||
|
||||
```bash
|
||||
NTFY_SERVER_URL="https://ntfy.example.com"
|
||||
NTFY_TOPIC="dns-security"
|
||||
NTFY_TOKEN="tk_geheimer_token"
|
||||
```
|
||||
|
||||
### Prioritäten
|
||||
|
||||
| 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 öffnen.
|
||||
2. Servereinstellungen > Integrationen > Webhooks.
|
||||
3. Neuen Webhook erstellen.
|
||||
4. Gewünschten Kanal auswählen.
|
||||
5. URL kopieren und in `NOTIFY_WEBHOOK_URL` eintragen.
|
||||
|
||||
Discord erhält den Inhalt als `content`-Feld im JSON-Body.
|
||||
|
||||
---
|
||||
|
||||
## Slack
|
||||
|
||||
### Konfiguration
|
||||
|
||||
```bash
|
||||
NOTIFY_ENABLED=true
|
||||
NOTIFY_TYPE="slack"
|
||||
NOTIFY_WEBHOOK_URL="https://hooks.slack.com/services/xxx/yyy/zzz"
|
||||
```
|
||||
|
||||
### Webhook einrichten
|
||||
|
||||
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://example.com/adguard-shield-webhook"
|
||||
```
|
||||
|
||||
### JSON-Payload
|
||||
|
||||
AdGuard Shield sendet einen `POST` mit folgendem JSON-Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"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",
|
||||
"action": "ban"
|
||||
}
|
||||
```
|
||||
|
||||
### Mögliche `action`-Werte
|
||||
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
## Separate Steuerung für Module
|
||||
|
||||
### 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
|
||||
|
||||
```text
|
||||
AdGuard Shield v1.0.0 wurde auf dns1 gestartet.
|
||||
```
|
||||
|
||||
### Service gestoppt
|
||||
|
||||
```text
|
||||
AdGuard Shield v1.0.0 wurde auf dns1 gestoppt.
|
||||
```
|
||||
|
||||
### Rate-Limit-Sperre
|
||||
|
||||
```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]
|
||||
|
||||
AbuseIPDB: https://www.abuseipdb.com/check/192.0.2.50
|
||||
```
|
||||
|
||||
### Watchlist-Sperre
|
||||
|
||||
```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
|
||||
|
||||
AbuseIPDB: https://www.abuseipdb.com/check/192.0.2.51
|
||||
```
|
||||
|
||||
### GeoIP-Sperre
|
||||
|
||||
```text
|
||||
AdGuard Shield GeoIP-Sperre auf dns1
|
||||
---
|
||||
IP: 203.0.113.10
|
||||
Land: BR
|
||||
Modus: Blocklist
|
||||
Dauer: PERMANENT
|
||||
|
||||
AbuseIPDB: https://www.abuseipdb.com/check/203.0.113.10
|
||||
```
|
||||
|
||||
### Freigabe
|
||||
|
||||
```text
|
||||
AdGuard Shield Freigabe auf dns1
|
||||
---
|
||||
IP: 192.0.2.50
|
||||
Hostname: client.example.com
|
||||
|
||||
AbuseIPDB: https://www.abuseipdb.com/check/192.0.2.50
|
||||
```
|
||||
|
||||
### Bulk-Freigabe
|
||||
|
||||
```text
|
||||
AdGuard Shield Bulk-Freigabe auf dns1
|
||||
---
|
||||
Freigegebene IPs: 28
|
||||
Aktion: Manual-Flush
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fehlersuche
|
||||
|
||||
Wenn keine Benachrichtigung ankommt:
|
||||
|
||||
```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
160
docs/docker.md
Normal 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.
|
||||
744
docs/konfiguration.md
Normal file
744
docs/konfiguration.md
Normal file
@@ -0,0 +1,744 @@
|
||||
# Konfiguration
|
||||
|
||||
Die zentrale Konfigurationsdatei liegt nach der Installation hier:
|
||||
|
||||
```text
|
||||
/opt/adguard-shield/adguard-shield.conf
|
||||
```
|
||||
|
||||
Die Datei ist eine einfache Shell-ähnliche Key-Value-Datei. Kommentare beginnen mit `#`. Werte können ohne Anführungszeichen, mit doppelten Anführungszeichen oder mit einfachen Anführungszeichen geschrieben werden.
|
||||
|
||||
Beispiel:
|
||||
|
||||
```bash
|
||||
ADGUARD_URL="https://dns1.example.com"
|
||||
RATE_LIMIT_MAX_REQUESTS=30
|
||||
WHITELIST="127.0.0.1,::1,192.168.1.1"
|
||||
```
|
||||
|
||||
Nach Änderungen muss der Service neu gestartet werden:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart adguard-shield
|
||||
sudo /opt/adguard-shield/adguard-shield status
|
||||
```
|
||||
|
||||
## Automatische Migration
|
||||
|
||||
Beim Installieren oder Aktualisieren wird eine vorhandene Konfiguration nicht überschrieben. Der Installer vergleicht vorhandene Schlüssel mit der aktuellen Standardkonfiguration.
|
||||
|
||||
Wenn neue Parameter fehlen:
|
||||
|
||||
1. Die alte Datei wird als `adguard-shield.conf.old` gesichert.
|
||||
2. Fehlende Schlüssel werden am Ende ergänzt.
|
||||
3. Vorhandene Werte bleiben erhalten.
|
||||
4. Dateirechte werden auf `0600` gesetzt.
|
||||
|
||||
Das ist besonders wichtig beim Umstieg von der Shell-Version auf die Go-Version. Prüfe nach einem Update trotzdem die neu ergänzten Parameter.
|
||||
|
||||
## Empfohlene Startprüfung
|
||||
|
||||
Nach dem Bearbeiten der Konfiguration:
|
||||
|
||||
```bash
|
||||
# API-Verbindung testen
|
||||
sudo /opt/adguard-shield/adguard-shield test
|
||||
|
||||
# Dry-Run: zeigt, was gesperrt würde, ohne die Firewall zu verändern
|
||||
sudo /opt/adguard-shield/adguard-shield dry-run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AdGuard Home API
|
||||
|
||||
| Parameter | Standard | Beschreibung |
|
||||
|---|---|---|
|
||||
| `ADGUARD_URL` | `https://dns1.domain.com` | URL der AdGuard-Home-Weboberfläche/API |
|
||||
| `ADGUARD_USER` | `admin` | Benutzername für die API-Authentifizierung |
|
||||
| `ADGUARD_PASS` | `changeme` | Passwort für die API-Authentifizierung |
|
||||
|
||||
### Beispiel: Lokale Instanz
|
||||
|
||||
```bash
|
||||
ADGUARD_URL="http://127.0.0.1:3000"
|
||||
ADGUARD_USER="admin"
|
||||
ADGUARD_PASS="sehr-geheim"
|
||||
```
|
||||
|
||||
### Beispiel: Entfernte Instanz mit HTTPS
|
||||
|
||||
```bash
|
||||
ADGUARD_URL="https://dns.example.com"
|
||||
ADGUARD_USER="admin"
|
||||
ADGUARD_PASS="geheim"
|
||||
```
|
||||
|
||||
AdGuard Shield ruft intern diesen Endpunkt ab:
|
||||
|
||||
```text
|
||||
/control/querylog?limit=<API_QUERY_LIMIT>&response_status=all
|
||||
```
|
||||
|
||||
**Hinweis:** Der HTTP-Client akzeptiert auch selbstsignierte TLS-Zertifikate. Das erleichtert lokale Setups, ersetzt aber keine saubere Absicherung der AdGuard-Home-Oberfläche.
|
||||
|
||||
---
|
||||
|
||||
## Querylog und Polling
|
||||
|
||||
| Parameter | Standard | Beschreibung |
|
||||
|---|---:|---|
|
||||
| `CHECK_INTERVAL` | `10` | Abstand zwischen Querylog-Abfragen in Sekunden |
|
||||
| `API_QUERY_LIMIT` | `500` | Anzahl der Querylog-Einträge pro API-Abfrage (max. 5000) |
|
||||
|
||||
### Empfehlungen
|
||||
|
||||
| Situation | Empfehlung |
|
||||
|---|---|
|
||||
| Normaler Betrieb | `CHECK_INTERVAL=10` ist ein guter Standard |
|
||||
| Hohes DNS-Aufkommen | `API_QUERY_LIMIT` auf 1000–2000 erhöhen |
|
||||
| `API_QUERY_LIMIT` zu niedrig | Spitzen im Querylog können zwischen zwei Polls verpasst werden |
|
||||
| Sehr kurze Intervalle | Erzeugen mehr API-Last auf AdGuard Home |
|
||||
|
||||
---
|
||||
|
||||
## Rate-Limit
|
||||
|
||||
| Parameter | Standard | Beschreibung |
|
||||
|---|---:|---|
|
||||
| `RATE_LIMIT_MAX_REQUESTS` | `30` | Maximale Anfragen pro Client und Domain im Zeitfenster |
|
||||
| `RATE_LIMIT_WINDOW` | `60` | Zeitfenster in Sekunden |
|
||||
|
||||
Das bedeutet: Wenn ein Client dieselbe Domain mehr als 30-mal innerhalb von 60 Sekunden abfragt, wird er als auffällig erkannt und gesperrt.
|
||||
|
||||
### Empfohlene Startwerte
|
||||
|
||||
| Umgebung | `MAX_REQUESTS` | `WINDOW` | Hinweis |
|
||||
|---|---:|---:|---|
|
||||
| Kleines Heimnetz | `30` | `60` | Standardwerte |
|
||||
| Viele Clients | `60`–`120` | `60` | Höherer Grenzwert für mehr Grundlast |
|
||||
| Aktive Resolver/Forwarder | nach Bedarf | `60` | Zuerst Forwarder whitelisten |
|
||||
|
||||
**Wichtig:** Wenn ein Router, Reverse Proxy oder lokaler DNS-Forwarder stellvertretend für viele Clients fragt, sollte dieser Client in die Whitelist. Sonst sieht AdGuard Shield nur eine sehr aktive IP und sperrt den Forwarder statt der eigentlichen Verursacher.
|
||||
|
||||
---
|
||||
|
||||
## Subdomain-Flood-Erkennung
|
||||
|
||||
| Parameter | Standard | Beschreibung |
|
||||
|---|---:|---|
|
||||
| `SUBDOMAIN_FLOOD_ENABLED` | `true` | Erkennung zufälliger Subdomains aktivieren |
|
||||
| `SUBDOMAIN_FLOOD_MAX_UNIQUE` | `50` | Maximale eindeutige Subdomains pro Client und Basisdomain |
|
||||
| `SUBDOMAIN_FLOOD_WINDOW` | `60` | Zeitfenster in Sekunden |
|
||||
|
||||
Diese Erkennung zielt auf Muster wie:
|
||||
|
||||
```text
|
||||
a1b2.example.com
|
||||
f8x9.example.com
|
||||
zz12.example.com
|
||||
```
|
||||
|
||||
Dabei zählt AdGuard Shield nicht die Gesamtzahl der Anfragen, sondern die Anzahl **unterschiedlicher** Subdomains unter derselben Basisdomain. Direkte Anfragen an `example.com` selbst zählen nicht.
|
||||
|
||||
### Hinweise
|
||||
|
||||
- Multi-Part-TLDs wie `.co.uk` werden korrekt als Basisdomain erkannt.
|
||||
- CDNs und manche Apps nutzen legitim viele Subdomains. Betroffene Clients whitelisten oder Grenzwert erhöhen.
|
||||
|
||||
---
|
||||
|
||||
## DNS-Flood-Watchlist
|
||||
|
||||
| Parameter | Standard | Beschreibung |
|
||||
|---|---|---|
|
||||
| `DNS_FLOOD_WATCHLIST_ENABLED` | `false` | Watchlist aktivieren |
|
||||
| `DNS_FLOOD_WATCHLIST` | leer | Kommagetrennte Domainliste |
|
||||
|
||||
Die Watchlist ist für Domains gedacht, bei denen eine Überschreitung sofort hart behandelt werden soll, ohne progressive Stufen.
|
||||
|
||||
### Beispiel
|
||||
|
||||
```bash
|
||||
DNS_FLOOD_WATCHLIST_ENABLED=true
|
||||
DNS_FLOOD_WATCHLIST="microsoft.com,google.com,apple.com"
|
||||
```
|
||||
|
||||
### Matching-Logik
|
||||
|
||||
Wenn ein Client `login.microsoft.com` über das Rate-Limit bringt, wird sofort permanent gesperrt, weil `login.microsoft.com` zur Watchlist-Domain `microsoft.com` gehört. `evil-microsoft.com` würde dagegen **nicht** matchen.
|
||||
|
||||
### Folgen eines Watchlist-Treffers
|
||||
|
||||
| Aspekt | Verhalten |
|
||||
|---|---|
|
||||
| Grund | `dns-flood-watchlist` |
|
||||
| Sperrdauer | Permanent |
|
||||
| Progressive Sperren | Werden übersprungen |
|
||||
| AbuseIPDB | Wird gemeldet, falls aktiviert |
|
||||
|
||||
---
|
||||
|
||||
## Sperrdauer und Firewall
|
||||
|
||||
| Parameter | Standard | Beschreibung |
|
||||
|---|---|---|
|
||||
| `BAN_DURATION` | `3600` | Basisdauer temporärer Monitor-Sperren in Sekunden (1 Stunde) |
|
||||
| `IPTABLES_CHAIN` | `ADGUARD_SHIELD` | Name der eigenen Firewall-Chain |
|
||||
| `BLOCKED_PORTS` | `53 443 853` | Ports, die für gesperrte Clients blockiert werden (Leerzeichen-getrennt) |
|
||||
| `FIREWALL_BACKEND` | `ipset` | Firewall-Backend (ipset + iptables) |
|
||||
| `FIREWALL_MODE` | `host` | Verkehrsweg der AdGuard-Home-Installation |
|
||||
| `DRY_RUN` | `false` | Konfigurationsweiter Testmodus ohne echte Sperren |
|
||||
|
||||
### Blockierte Ports
|
||||
|
||||
| Port | Zweck |
|
||||
|---:|---|
|
||||
| `53` | Klassisches DNS über UDP/TCP |
|
||||
| `443` | DNS-over-HTTPS (DoH), sofern AdGuard Home darüber erreichbar ist |
|
||||
| `853` | DNS-over-TLS (DoT) und DNS-over-QUIC (DoQ) |
|
||||
|
||||
### Firewall-Modi
|
||||
|
||||
| Modus | Einsatz | Parent-Chain |
|
||||
|---|---|---|
|
||||
| `host` | Klassische AdGuard-Home-Installation direkt auf dem Host | `INPUT` |
|
||||
| `docker-host` | Docker mit `network_mode: host` (Alias von `host`) | `INPUT` |
|
||||
| `docker-bridge` | Docker mit veröffentlichten Ports, z.B. `-p 53:53` | `DOCKER-USER` |
|
||||
| `hybrid` | Schützt Host-Ports und Docker-Forwarding gleichzeitig | `INPUT` + `DOCKER-USER` |
|
||||
|
||||
Details zu den Docker-Modi stehen in [Docker-Installationen](docker.md).
|
||||
|
||||
---
|
||||
|
||||
## Whitelist
|
||||
|
||||
| Parameter | Standard | Beschreibung |
|
||||
|---|---|---|
|
||||
| `WHITELIST` | `127.0.0.1,::1` | IPs, die nie gesperrt werden (kommagetrennt) |
|
||||
|
||||
### Beispiel
|
||||
|
||||
```bash
|
||||
WHITELIST="127.0.0.1,::1,192.168.1.1,192.168.1.10,fd00::1"
|
||||
```
|
||||
|
||||
### Empfohlene Whitelist-Einträge
|
||||
|
||||
| Typ | Beispiel | Grund |
|
||||
|---|---|---|
|
||||
| Localhost | `127.0.0.1`, `::1` | Lokale Anfragen |
|
||||
| Router/Gateway | `192.168.1.1` | Bündelt oft DNS für alle Clients |
|
||||
| Admin-IPs | `192.168.1.10` | Eigene Management-Geräte |
|
||||
| Monitoring | Monitoring-IP | Regelmäßige DNS-Checks |
|
||||
| Interne Resolver | Resolver-IP | Fragt stellvertretend für viele Clients |
|
||||
| VPN-Endpunkte | VPN-IP | Bündeln DNS-Anfragen vieler Nutzer |
|
||||
|
||||
**Wichtig:** Die Whitelist wird vor jeder Sperre geprüft. Das gilt für automatische, manuelle, GeoIP- und externe Blocklist-Sperren.
|
||||
|
||||
---
|
||||
|
||||
## Progressive Sperren
|
||||
|
||||
| Parameter | Standard | Beschreibung |
|
||||
|---|---:|---|
|
||||
| `PROGRESSIVE_BAN_ENABLED` | `true` | Wiederholungstäter stufenweise länger sperren |
|
||||
| `PROGRESSIVE_BAN_MULTIPLIER` | `2` | Multiplikator pro Stufe (2 = Verdopplung) |
|
||||
| `PROGRESSIVE_BAN_MAX_LEVEL` | `5` | Ab dieser Stufe permanent sperren (`0` = nie permanent durch Stufe) |
|
||||
| `PROGRESSIVE_BAN_RESET_AFTER` | `86400` | Offense-Zähler nach so vielen Sekunden ohne neues Vergehen zurücksetzen |
|
||||
|
||||
### Stufenverlauf mit Standardwerten
|
||||
|
||||
| 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 |
|
||||
|
||||
Progressive Sperren gelten für Monitor-Sperren wie `rate-limit` und `subdomain-flood`. Watchlist-Treffer sind sofort permanent. GeoIP und externe Blocklisten haben eigene Regeln.
|
||||
|
||||
### Verwaltungsbefehle
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield offense-status # Zähler anzeigen
|
||||
sudo /opt/adguard-shield/adguard-shield offense-cleanup # Abgelaufene entfernen
|
||||
sudo /opt/adguard-shield/adguard-shield reset-offenses # Alle zurücksetzen
|
||||
sudo /opt/adguard-shield/adguard-shield reset-offenses <IP> # Eine IP zurücksetzen
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Logging
|
||||
|
||||
| Parameter | Standard | Beschreibung |
|
||||
|---|---|---|
|
||||
| `LOG_FILE` | `/var/log/adguard-shield.log` | Datei für Daemon-Ereignisse |
|
||||
| `LOG_LEVEL` | `INFO` | Minimales Log-Level |
|
||||
|
||||
### Verfügbare Log-Level
|
||||
|
||||
| Level | Beschreibung | Empfehlung |
|
||||
|---|---|---|
|
||||
| `DEBUG` | Detaillierte Informationen, z.B. einzelne API-Ergebnisse | Nur kurzzeitig für Fehlersuche |
|
||||
| `INFO` | Normale Betriebsmeldungen (Start, Sperren, Freigaben) | Empfohlen für den produktiven Betrieb |
|
||||
| `WARN` | Warnungen (API-Fehler, fehlende Dateien, Konfigurationsprobleme) | |
|
||||
| `ERROR` | Fehler, die den Betrieb beeinträchtigen | |
|
||||
|
||||
### CLI-Befehle
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield logs --level warn --limit 100
|
||||
sudo /opt/adguard-shield/adguard-shield logs-follow debug
|
||||
sudo /opt/adguard-shield/adguard-shield live
|
||||
```
|
||||
|
||||
**Hinweis:** Query-Inhalte werden nicht dauerhaft ins Log geschrieben. Für Query-nahe Diagnose ist die Live-Ansicht gedacht.
|
||||
|
||||
---
|
||||
|
||||
## State und Runtime
|
||||
|
||||
| Parameter | Standard | Beschreibung |
|
||||
|---|---|---|
|
||||
| `STATE_DIR` | `/var/lib/adguard-shield` | Verzeichnis für SQLite-Datenbank und Caches |
|
||||
| `PID_FILE` | `/var/run/adguard-shield.pid` | PID-Datei für direkten Vordergrundlauf |
|
||||
|
||||
### SQLite-Datei
|
||||
|
||||
```text
|
||||
${STATE_DIR}/adguard-shield.db
|
||||
```
|
||||
|
||||
### Weitere Dateien in STATE_DIR
|
||||
|
||||
| Datei/Verzeichnis | Inhalt |
|
||||
|---|---|
|
||||
| `adguard-shield.db` | Hauptdatenbank (Sperren, History, Offenses, Caches) |
|
||||
| `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-Firewall-Regeln |
|
||||
| `iptables-rules.v6` | Gesicherte IPv6-Firewall-Regeln |
|
||||
|
||||
---
|
||||
|
||||
## Benachrichtigungen
|
||||
|
||||
| Parameter | Standard | Beschreibung |
|
||||
|---|---|---|
|
||||
| `NOTIFY_ENABLED` | `false` | Benachrichtigungen aktivieren |
|
||||
| `NOTIFY_TYPE` | `ntfy` | Benachrichtigungskanal |
|
||||
| `NOTIFY_WEBHOOK_URL` | leer | Webhook-URL (nicht für ntfy) |
|
||||
| `NTFY_SERVER_URL` | `https://ntfy.sh` | Ntfy-Server |
|
||||
| `NTFY_TOPIC` | leer | Ntfy-Topic |
|
||||
| `NTFY_TOKEN` | leer | Optionaler Ntfy-Access-Token |
|
||||
| `NTFY_PRIORITY` | `4` | Ntfy-Priorität (1–5) |
|
||||
|
||||
### Verfügbare Typen
|
||||
|
||||
| Typ | Beschreibung |
|
||||
|---|---|
|
||||
| `ntfy` | Ntfy Push-Benachrichtigungen (öffentlich oder selbst gehostet) |
|
||||
| `discord` | Discord-Webhook |
|
||||
| `slack` | Slack-Webhook |
|
||||
| `gotify` | Gotify-Server |
|
||||
| `generic` | Eigener Webhook-Endpunkt (JSON POST) |
|
||||
|
||||
### Beispiel: Ntfy
|
||||
|
||||
```bash
|
||||
NOTIFY_ENABLED=true
|
||||
NOTIFY_TYPE="ntfy"
|
||||
NTFY_SERVER_URL="https://ntfy.sh"
|
||||
NTFY_TOPIC="mein-adguard-shield"
|
||||
NTFY_PRIORITY="4"
|
||||
```
|
||||
|
||||
### Beispiel: Discord
|
||||
|
||||
```bash
|
||||
NOTIFY_ENABLED=true
|
||||
NOTIFY_TYPE="discord"
|
||||
NOTIFY_WEBHOOK_URL="https://discord.com/api/webhooks/..."
|
||||
```
|
||||
|
||||
Details zu allen Kanälen stehen in [Benachrichtigungen](benachrichtigungen.md).
|
||||
|
||||
---
|
||||
|
||||
## E-Mail-Reports
|
||||
|
||||
| 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 |
|
||||
| `REPORT_MAIL_CMD` | `msmtp` | Mailprogramm für den Versand |
|
||||
| `REPORT_BUSIEST_DAY_RANGE` | `30` | Zeitraum für "Aktivster Tag"; `0` nutzt den Berichtszeitraum |
|
||||
|
||||
### Verfügbare Intervalle
|
||||
|
||||
| Intervall | Versand |
|
||||
|---|---|
|
||||
| `daily` | Täglich zur konfigurierten Uhrzeit |
|
||||
| `weekly` | Montags zur konfigurierten Uhrzeit |
|
||||
| `biweekly` | Montags in ungeraden ISO-Kalenderwochen |
|
||||
| `monthly` | Am 1. des Monats |
|
||||
|
||||
### Verfügbare Formate
|
||||
|
||||
| Format | Beschreibung |
|
||||
|---|---|
|
||||
| `html` | HTML-formatierte E-Mail (empfohlen für Standard-Mail-Clients) |
|
||||
| `txt` | Reiner Text (robuster für einfache Mail-Setups) |
|
||||
|
||||
### Beispiel
|
||||
|
||||
```bash
|
||||
REPORT_ENABLED=true
|
||||
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"
|
||||
```
|
||||
|
||||
### Cron-Job installieren
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield report-install
|
||||
```
|
||||
|
||||
Details stehen in [E-Mail Report](report.md).
|
||||
|
||||
Die Templates werden bei `install`/`update` nach `/opt/adguard-shield/templates` geschrieben. Eigene Templates können alternativ über `ADGUARD_SHIELD_TEMPLATE_DIR` bereitgestellt werden.
|
||||
|
||||
---
|
||||
|
||||
## Externe Whitelist
|
||||
|
||||
| Parameter | Standard | Beschreibung |
|
||||
|---|---|---|
|
||||
| `EXTERNAL_WHITELIST_ENABLED` | `false` | Externe Whitelist aktivieren |
|
||||
| `EXTERNAL_WHITELIST_URLS` | leer | Kommagetrennte URLs zu den Whitelist-Dateien |
|
||||
| `EXTERNAL_WHITELIST_INTERVAL` | `300` | Synchronisationsintervall in Sekunden |
|
||||
| `EXTERNAL_WHITELIST_CACHE_DIR` | `/var/lib/adguard-shield/external-whitelist` | Cache-Verzeichnis |
|
||||
|
||||
### Beispiel
|
||||
|
||||
```bash
|
||||
EXTERNAL_WHITELIST_ENABLED=true
|
||||
EXTERNAL_WHITELIST_URLS="https://example.com/trusted.txt"
|
||||
EXTERNAL_WHITELIST_INTERVAL=300
|
||||
```
|
||||
|
||||
### Listenformat
|
||||
|
||||
```text
|
||||
# Hostnamen werden regelmäßig per DNS aufgelöst
|
||||
mein-router.dyndns.org
|
||||
vpn.example.com
|
||||
|
||||
# IPs und Netze direkt
|
||||
192.168.1.10
|
||||
10.0.0.0/24
|
||||
2001:db8::1
|
||||
```
|
||||
|
||||
### Mehrere Listen
|
||||
|
||||
```bash
|
||||
EXTERNAL_WHITELIST_URLS="https://example.com/a.txt,https://example.net/b.txt"
|
||||
```
|
||||
|
||||
### Verhalten
|
||||
|
||||
- Hostnamen werden per DNS aufgelöst und als IPs in SQLite gespeichert.
|
||||
- Aufgelöste IPs werden bei jedem Sync aktualisiert.
|
||||
- Bereits aktive Sperren werden aufgehoben, wenn die IP in der Whitelist auftaucht.
|
||||
- Kommentare (`#`) und Inline-Kommentare werden unterstützt.
|
||||
|
||||
---
|
||||
|
||||
## Externe Blocklist
|
||||
|
||||
| Parameter | Standard | Beschreibung |
|
||||
|---|---|---|
|
||||
| `EXTERNAL_BLOCKLIST_ENABLED` | `false` | Externe Blocklist aktivieren |
|
||||
| `EXTERNAL_BLOCKLIST_URLS` | leer | Kommagetrennte URLs |
|
||||
| `EXTERNAL_BLOCKLIST_INTERVAL` | `300` | Synchronisationsintervall in Sekunden |
|
||||
| `EXTERNAL_BLOCKLIST_BAN_DURATION` | `0` | Sperrdauer in Sekunden (`0` = permanent bis IP aus Liste entfernt) |
|
||||
| `EXTERNAL_BLOCKLIST_AUTO_UNBAN` | `true` | IPs freigeben, wenn sie nicht mehr in der Liste stehen |
|
||||
| `EXTERNAL_BLOCKLIST_NOTIFY` | `false` | Benachrichtigungen für Blocklist-Sperren senden |
|
||||
| `EXTERNAL_BLOCKLIST_CACHE_DIR` | `/var/lib/adguard-shield/external-blocklist` | Cache-Verzeichnis |
|
||||
|
||||
### Beispiel
|
||||
|
||||
```bash
|
||||
EXTERNAL_BLOCKLIST_ENABLED=true
|
||||
EXTERNAL_BLOCKLIST_URLS="https://example.com/blocklist.txt"
|
||||
EXTERNAL_BLOCKLIST_INTERVAL=300
|
||||
EXTERNAL_BLOCKLIST_BAN_DURATION=0
|
||||
EXTERNAL_BLOCKLIST_AUTO_UNBAN=true
|
||||
EXTERNAL_BLOCKLIST_NOTIFY=false
|
||||
```
|
||||
|
||||
### Unterstützte Listenformate
|
||||
|
||||
| Format | Beispiel |
|
||||
|---|---|
|
||||
| IPv4 | `203.0.113.50` |
|
||||
| IPv4-CIDR | `198.51.100.0/24` |
|
||||
| IPv6 | `2001:db8::1` |
|
||||
| IPv6-CIDR | `2001:db8::/32` |
|
||||
| Hostname | `bad.example.com` |
|
||||
| Hosts-Format | `0.0.0.0 bad.example.com` |
|
||||
| Kommentar | `# Text` |
|
||||
| Inline-Kommentar | `203.0.113.50 # Grund` |
|
||||
|
||||
### Ignorierte Einträge
|
||||
|
||||
- URLs wie `https://...`
|
||||
- IP:Port wie `203.0.113.50:8443`
|
||||
- Hostnamen ohne Punkt oder mit ungültigen Zeichen
|
||||
- Nicht auflösbare Hostnamen
|
||||
- Blocking-Antworten wie `0.0.0.0` oder `::`
|
||||
|
||||
### Hinweise
|
||||
|
||||
- Große Listen können viele Sperren erzeugen. `EXTERNAL_BLOCKLIST_NOTIFY=false` ist deshalb der sichere Standard.
|
||||
- Hostnamen mit mehreren IPs: Alle aufgelösten IPs werden verarbeitet.
|
||||
- IPs aus der Whitelist werden nicht gesperrt.
|
||||
- Bei `EXTERNAL_BLOCKLIST_AUTO_UNBAN=true` werden entfernte Einträge automatisch wieder freigegeben.
|
||||
|
||||
### Dateiformat-Empfehlungen
|
||||
|
||||
- UTF-8 ohne BOM
|
||||
- Unix-Zeilenenden (`LF`)
|
||||
- IP-Listen und Hostname-Listen möglichst getrennt pflegen
|
||||
|
||||
---
|
||||
|
||||
## AbuseIPDB
|
||||
|
||||
| Parameter | Standard | Beschreibung |
|
||||
|---|---|---|
|
||||
| `ABUSEIPDB_ENABLED` | `false` | AbuseIPDB-Reporting aktivieren |
|
||||
| `ABUSEIPDB_API_KEY` | leer | API-Key von abuseipdb.com |
|
||||
| `ABUSEIPDB_CATEGORIES` | `4` | Kategorien, kommagetrennt (siehe [abuseipdb.com/categories](https://www.abuseipdb.com/categories)) |
|
||||
|
||||
### Beispiel
|
||||
|
||||
```bash
|
||||
ABUSEIPDB_ENABLED=true
|
||||
ABUSEIPDB_API_KEY="dein-api-key"
|
||||
ABUSEIPDB_CATEGORIES="4"
|
||||
```
|
||||
|
||||
### Was gemeldet wird
|
||||
|
||||
| Wird gemeldet | Wird nicht gemeldet |
|
||||
|---|---|
|
||||
| Watchlist-Treffer (permanent) | Temporäre Sperren |
|
||||
| Progressive-Ban auf Maximalstufe (permanent) | GeoIP-Sperren |
|
||||
| | Externe Blocklist-Sperren |
|
||||
| | Manuelle Sperren |
|
||||
|
||||
---
|
||||
|
||||
## GeoIP-Länderfilter
|
||||
|
||||
| Parameter | Standard | Beschreibung |
|
||||
|---|---|---|
|
||||
| `GEOIP_ENABLED` | `false` | GeoIP-Filter aktivieren |
|
||||
| `GEOIP_MODE` | `blocklist` | Filtermodus |
|
||||
| `GEOIP_COUNTRIES` | leer | Ländercodes nach ISO 3166-1 Alpha-2 |
|
||||
| `GEOIP_CHECK_INTERVAL` | `0` | Legacy-Parameter (Go-Version nutzt den zentralen Poller) |
|
||||
| `GEOIP_NOTIFY` | `true` | Benachrichtigungen bei GeoIP-Sperren senden |
|
||||
| `GEOIP_SKIP_PRIVATE` | `true` | Private/lokale IPs überspringen |
|
||||
| `GEOIP_LICENSE_KEY` | leer | MaxMind-License-Key für automatischen Download |
|
||||
| `GEOIP_MMDB_PATH` | leer | Manueller Pfad zur MaxMind-MMDB-Datei (hat Vorrang) |
|
||||
| `GEOIP_CACHE_TTL` | `86400` | GeoIP-Cache-Dauer in Sekunden (Standard: 24 Stunden) |
|
||||
|
||||
### Modi
|
||||
|
||||
| Modus | Beschreibung |
|
||||
|---|---|
|
||||
| `blocklist` | Nur die genannten Länder werden gesperrt. Alle anderen sind erlaubt. |
|
||||
| `allowlist` | Nur die genannten Länder sind erlaubt. Alle anderen öffentlichen IPs werden gesperrt. |
|
||||
|
||||
### Ländercodes
|
||||
|
||||
Die Ländercodes folgen dem Standard **ISO 3166-1 Alpha-2**. Eine vollständige Liste aller Ländercodes findest du in der [ISO-3166-1-Kodierliste auf Wikipedia](https://de.wikipedia.org/wiki/ISO-3166-1-Kodierliste).
|
||||
|
||||
Häufig verwendete Codes:
|
||||
|
||||
| Code | Land | | Code | Land |
|
||||
|---|---|---|---|---|
|
||||
| `DE` | Deutschland | | `CN` | China |
|
||||
| `AT` | Österreich | | `RU` | Russland |
|
||||
| `CH` | Schweiz | | `KP` | Nordkorea |
|
||||
| `US` | Vereinigte Staaten | | `IR` | Iran |
|
||||
| `GB` | Vereinigtes Königreich | | `BR` | Brasilien |
|
||||
| `FR` | Frankreich | | `IN` | Indien |
|
||||
| `NL` | Niederlande | | `VN` | Vietnam |
|
||||
|
||||
### Beispiel: Blocklist-Modus
|
||||
|
||||
```bash
|
||||
GEOIP_ENABLED=true
|
||||
GEOIP_MODE="blocklist"
|
||||
GEOIP_COUNTRIES="CN,RU,KP,IR"
|
||||
```
|
||||
|
||||
Damit werden öffentliche DNS-Clients aus China, Russland, Nordkorea und dem Iran gesperrt.
|
||||
|
||||
### Beispiel: Allowlist-Modus
|
||||
|
||||
```bash
|
||||
GEOIP_ENABLED=true
|
||||
GEOIP_MODE="allowlist"
|
||||
GEOIP_COUNTRIES="DE,AT,CH"
|
||||
```
|
||||
|
||||
Damit werden nur Clients aus Deutschland, Österreich und der Schweiz erlaubt. Alle anderen öffentlichen Länder werden gesperrt.
|
||||
|
||||
### Private IPs
|
||||
|
||||
```bash
|
||||
GEOIP_SKIP_PRIVATE=true
|
||||
```
|
||||
|
||||
Damit 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)
|
||||
|
||||
### GeoIP-Datenquellen
|
||||
|
||||
| Priorität | Quelle | Konfiguration |
|
||||
|---:|---|---|
|
||||
| 1 | Manueller MMDB-Pfad | `GEOIP_MMDB_PATH="/usr/share/GeoIP/GeoLite2-Country.mmdb"` |
|
||||
| 2 | Automatischer MaxMind-Download | `GEOIP_LICENSE_KEY="dein_maxmind_license_key"` |
|
||||
| 3 | Legacy-Fallback | `geoiplookup` / `geoiplookup6` Systembefehle |
|
||||
|
||||
### Automatischer MaxMind-Download
|
||||
|
||||
```bash
|
||||
GEOIP_LICENSE_KEY="dein_maxmind_license_key"
|
||||
```
|
||||
|
||||
Die Datenbank wird unter `/opt/adguard-shield/geoip/` gespeichert und nach 24 Stunden automatisch erneuert.
|
||||
|
||||
### GeoIP-Befehle
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield geoip-status # Status anzeigen
|
||||
sudo /opt/adguard-shield/adguard-shield geoip-lookup 8.8.8.8 # IP nachschlagen
|
||||
sudo /opt/adguard-shield/adguard-shield geoip-sync # Clients prüfen
|
||||
sudo /opt/adguard-shield/adguard-shield geoip-flush-cache # Cache leeren
|
||||
sudo /opt/adguard-shield/adguard-shield geoip-flush # Alle GeoIP-Sperren aufheben
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Protokollerkennung
|
||||
|
||||
AdGuard Shield liest das Feld `client_proto` aus der AdGuard-Home-API und zeigt das verwendete DNS-Protokoll an.
|
||||
|
||||
| API-Wert | Anzeige | Bedeutung |
|
||||
|---|---|---|
|
||||
| leer oder `dns` | `DNS` | Klassisches DNS |
|
||||
| `doh` | `DoH` | DNS-over-HTTPS |
|
||||
| `dot` | `DoT` | DNS-over-TLS |
|
||||
| `doq` | `DoQ` | DNS-over-QUIC |
|
||||
| `dnscrypt` | `DNSCrypt` | DNSCrypt-Protokoll |
|
||||
|
||||
Die Sperre blockiert immer alle konfigurierten Ports, unabhängig davon, welches Protokoll den Verstoß ausgelöst hat.
|
||||
|
||||
---
|
||||
|
||||
## Beispielkonfiguration: Heimnetz
|
||||
|
||||
```bash
|
||||
ADGUARD_URL="http://127.0.0.1:3000"
|
||||
ADGUARD_USER="admin"
|
||||
ADGUARD_PASS="geheim"
|
||||
|
||||
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
|
||||
|
||||
BAN_DURATION=3600
|
||||
PROGRESSIVE_BAN_ENABLED=true
|
||||
PROGRESSIVE_BAN_MAX_LEVEL=5
|
||||
|
||||
WHITELIST="127.0.0.1,::1,192.168.1.1,192.168.1.10"
|
||||
|
||||
NOTIFY_ENABLED=true
|
||||
NOTIFY_TYPE="ntfy"
|
||||
NTFY_TOPIC="adguard-shield-home"
|
||||
|
||||
REPORT_ENABLED=false
|
||||
GEOIP_ENABLED=false
|
||||
EXTERNAL_BLOCKLIST_ENABLED=false
|
||||
EXTERNAL_WHITELIST_ENABLED=false
|
||||
```
|
||||
|
||||
## Beispielkonfiguration: Öffentlicher Resolver
|
||||
|
||||
```bash
|
||||
ADGUARD_URL="https://dns.example.com"
|
||||
ADGUARD_USER="admin"
|
||||
ADGUARD_PASS="geheim"
|
||||
|
||||
RATE_LIMIT_MAX_REQUESTS=60
|
||||
RATE_LIMIT_WINDOW=60
|
||||
CHECK_INTERVAL=5
|
||||
API_QUERY_LIMIT=2000
|
||||
|
||||
SUBDOMAIN_FLOOD_ENABLED=true
|
||||
SUBDOMAIN_FLOOD_MAX_UNIQUE=75
|
||||
SUBDOMAIN_FLOOD_WINDOW=60
|
||||
|
||||
DNS_FLOOD_WATCHLIST_ENABLED=true
|
||||
DNS_FLOOD_WATCHLIST="microsoft.com,google.com,apple.com"
|
||||
|
||||
BAN_DURATION=3600
|
||||
PROGRESSIVE_BAN_ENABLED=true
|
||||
PROGRESSIVE_BAN_MULTIPLIER=2
|
||||
PROGRESSIVE_BAN_MAX_LEVEL=5
|
||||
|
||||
GEOIP_ENABLED=true
|
||||
GEOIP_MODE="blocklist"
|
||||
GEOIP_COUNTRIES="CN,RU,KP,IR"
|
||||
GEOIP_LICENSE_KEY="..."
|
||||
|
||||
ABUSEIPDB_ENABLED=true
|
||||
ABUSEIPDB_API_KEY="..."
|
||||
|
||||
NOTIFY_ENABLED=true
|
||||
NOTIFY_TYPE="ntfy"
|
||||
NTFY_TOPIC="adguard-shield-prod"
|
||||
```
|
||||
|
||||
### Vor produktiver Aktivierung
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield test
|
||||
sudo /opt/adguard-shield/adguard-shield dry-run
|
||||
```
|
||||
301
docs/report.md
Normal file
301
docs/report.md
Normal file
@@ -0,0 +1,301 @@
|
||||
# E-Mail Report
|
||||
|
||||
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.
|
||||
|
||||
## Was der Report enthält
|
||||
|
||||
Der Report basiert auf der SQLite-Datenbank:
|
||||
|
||||
```text
|
||||
/var/lib/adguard-shield/adguard-shield.db
|
||||
```
|
||||
|
||||
### Ausgewertete Daten
|
||||
|
||||
| Bereich | Inhalt |
|
||||
|---|---|
|
||||
| Zeitraum | Start- und Enddatum des Berichtszeitraums |
|
||||
| Sperren | Anzahl der Sperren im Zeitraum |
|
||||
| Freigaben | Anzahl der Freigaben im Zeitraum |
|
||||
| Zeitraum-Schnellübersicht | Heute, gestern, letzte 7/14/30 Tage |
|
||||
| Aktive Sperren | Derzeit aktive Sperren zum Zeitpunkt der Report-Erstellung |
|
||||
| Permanente Sperren | Anzahl permanenter Sperren im Zeitraum |
|
||||
| AbuseIPDB | Anzahl erfolgreicher AbuseIPDB-Meldungen im Zeitraum |
|
||||
| Angriffsarten | Rate-Limit, Subdomain-Flood und externe Blocklist |
|
||||
| Top-Clients | Die am häufigsten gesperrten Client-IPs |
|
||||
| Top-Domains | Die am häufigsten betroffenen Domains |
|
||||
| Protokolle | Verteilung nach DNS, DoH, DoT, DoQ usw. |
|
||||
| Letzte Sperren | Die letzten 10 Sperren im Berichtszeitraum |
|
||||
|
||||
### Templates
|
||||
|
||||
Die Standard-Templates liegen im Code unter:
|
||||
|
||||
```text
|
||||
internal/report/templates/report.html
|
||||
internal/report/templates/report.txt
|
||||
```
|
||||
|
||||
Sie werden ins Binary eingebettet und bei `install` sowie `update` nach `/opt/adguard-shield/templates` geschrieben. Zur Laufzeit verwendet AdGuard Shield zuerst externe Templates aus `ADGUARD_SHIELD_TEMPLATE_DIR`, danach `templates` neben der Konfiguration bzw. neben dem Binary und fällt erst zuletzt auf die eingebetteten Standard-Templates zurück.
|
||||
|
||||
---
|
||||
|
||||
## 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` | Zeitraum für "Aktivster Tag"; `0` nutzt den Berichtszeitraum |
|
||||
|
||||
### Versandintervalle
|
||||
|
||||
| Intervall | Versandzeitpunkt |
|
||||
|---|---|
|
||||
| `daily` | Täglich zur Uhrzeit aus `REPORT_TIME` |
|
||||
| `weekly` | Montags zur Uhrzeit aus `REPORT_TIME` |
|
||||
| `biweekly` | Montags in ungeraden ISO-Kalenderwochen 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
|
||||
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"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Befehle
|
||||
|
||||
### Konfiguration und Cron-Status anzeigen
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield report-status
|
||||
```
|
||||
|
||||
### HTML-Report in Datei schreiben
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield report-generate html /tmp/adguard-shield-report.html
|
||||
```
|
||||
|
||||
Die Datei kann im Browser geöffnet werden, um das Ergebnis zu prüfen.
|
||||
|
||||
### Text-Report auf stdout ausgeben
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
# Testmail senden
|
||||
sudo /opt/adguard-shield/adguard-shield report-test
|
||||
```
|
||||
|
||||
### Eigene Mailprogramm-Argumente
|
||||
|
||||
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
|
||||
REPORT_MAIL_CMD="msmtp --account=default"
|
||||
```
|
||||
|
||||
### Alternativen zu msmtp
|
||||
|
||||
| Programm | `REPORT_MAIL_CMD` |
|
||||
|---|---|
|
||||
| msmtp | `msmtp` |
|
||||
| sendmail | `sendmail` |
|
||||
| ssmtp | `ssmtp` |
|
||||
| Benutzerdefiniert | Vollständiger Pfad zum Programm |
|
||||
|
||||
---
|
||||
|
||||
## Automatischer Versand
|
||||
|
||||
### Cron-Job installieren
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield report-install
|
||||
```
|
||||
|
||||
Dadurch wird diese Datei geschrieben:
|
||||
|
||||
```text
|
||||
/etc/cron.d/adguard-shield-report
|
||||
```
|
||||
|
||||
Der Cron-Eintrag ruft das installierte Binary mit der installierten Konfiguration auf:
|
||||
|
||||
```text
|
||||
/opt/adguard-shield/adguard-shield -config /opt/adguard-shield/adguard-shield.conf report-send
|
||||
```
|
||||
|
||||
### Zeitplan nach Intervall
|
||||
|
||||
| Intervall | Cron-Verhalten |
|
||||
|---|---|
|
||||
| `daily` | Täglich zur Uhrzeit aus `REPORT_TIME` |
|
||||
| `weekly` | Montags zur Uhrzeit aus `REPORT_TIME` |
|
||||
| `biweekly` | Montags in ungeraden ISO-Kalenderwochen |
|
||||
| `monthly` | Am 1. des Monats |
|
||||
|
||||
### Cron-Job entfernen
|
||||
|
||||
```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
|
||||
```
|
||||
543
docs/tipps-und-troubleshooting.md
Normal file
543
docs/tipps-und-troubleshooting.md
Normal file
@@ -0,0 +1,543 @@
|
||||
# Tipps & Troubleshooting
|
||||
|
||||
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.
|
||||
|
||||
## Erste Diagnose
|
||||
|
||||
Diese fünf Befehle liefern meistens schon genug Hinweise, um ein Problem einzugrenzen:
|
||||
|
||||
```bash
|
||||
# 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
|
||||
```
|
||||
|
||||
Wenn du aktuelle Queries und den Echtzeit-Zustand sehen willst:
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield live
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Service startet nicht
|
||||
|
||||
### Prüfen
|
||||
|
||||
```bash
|
||||
sudo systemctl status adguard-shield
|
||||
sudo journalctl -u adguard-shield --no-pager -n 100
|
||||
```
|
||||
|
||||
### Typische Ursachen
|
||||
|
||||
| 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 |
|
||||
|
||||
### Nützliche Prüfbefehle
|
||||
|
||||
```bash
|
||||
ls -l /opt/adguard-shield/adguard-shield
|
||||
ls -l /opt/adguard-shield/adguard-shield.conf
|
||||
which iptables ip6tables ipset systemctl
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verbindung zu AdGuard Home schlägt fehl
|
||||
|
||||
### Test
|
||||
|
||||
```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 |
|
||||
284
docs/update.md
Normal file
284
docs/update.md
Normal file
@@ -0,0 +1,284 @@
|
||||
# Update-Anleitung
|
||||
|
||||
AdGuard Shield wird in der Go-Version über das Binary selbst installiert und aktualisiert. Es gibt kein `install.sh` und kein `update`-Shellskript mehr.
|
||||
|
||||
## Kurzfassung
|
||||
|
||||
```bash
|
||||
# Neues Linux-Binary bereitstellen
|
||||
chmod +x ./adguard-shield
|
||||
|
||||
# Update durchführen
|
||||
sudo ./adguard-shield update
|
||||
```
|
||||
|
||||
Am Ende fragt der Updater, ob AdGuard Shield direkt neu gestartet werden soll.
|
||||
|
||||
### Nach dem Update prüfen
|
||||
|
||||
```bash
|
||||
sudo /opt/adguard-shield/adguard-shield install-status
|
||||
sudo /opt/adguard-shield/adguard-shield status
|
||||
sudo journalctl -u adguard-shield --no-pager -n 50
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Neues Binary beziehen
|
||||
|
||||
Du brauchst ein fertiges Linux-Binary. Das kann aus einem Release, aus CI oder aus einem lokalen Build kommen.
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
**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
|
||||
# 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
|
||||
```
|
||||
|
||||
### WAL-Dateien beachten
|
||||
|
||||
Bei laufendem SQLite mit WAL können zusätzliche Dateien existieren:
|
||||
|
||||
| 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 |
|
||||
|
||||
Am saubersten ist ein kurzer Service-Stop während des Backups. So wird sichergestellt, dass alle WAL-Einträge in die Hauptdatei geschrieben werden.
|
||||
@@ -1,640 +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"
|
||||
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 | 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 | %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
|
||||
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
|
||||
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
|
||||
if [[ "$NOTIFY_ENABLED" == "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" ]]; then
|
||||
send_notification "unban" "$ip"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Benachrichtigung ────────────────────────────────────────────────────────
|
||||
send_notification() {
|
||||
local action="$1"
|
||||
local ip="$2"
|
||||
|
||||
[[ -z "${NOTIFY_WEBHOOK_URL:-}" ]] && return
|
||||
|
||||
local message
|
||||
if [[ "$action" == "ban" ]]; then
|
||||
message="🚫 Externe Blocklist: IP **$ip** gesperrt."
|
||||
else
|
||||
message="✅ Externe Blocklist: IP **$ip** entsperrt (aus Liste entfernt)."
|
||||
fi
|
||||
|
||||
case "${NOTIFY_TYPE:-generic}" in
|
||||
discord)
|
||||
curl -s -H "Content-Type: application/json" \
|
||||
-d "{\"content\": \"$message\"}" \
|
||||
"$NOTIFY_WEBHOOK_URL" &>/dev/null &
|
||||
;;
|
||||
slack)
|
||||
curl -s -H "Content-Type: application/json" \
|
||||
-d "{\"text\": \"$message\"}" \
|
||||
"$NOTIFY_WEBHOOK_URL" &>/dev/null &
|
||||
;;
|
||||
gotify)
|
||||
curl -s -X POST "$NOTIFY_WEBHOOK_URL" \
|
||||
-F "title=AdGuard Shield - Externe Blocklist" \
|
||||
-F "message=$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 - Externe Blocklist"
|
||||
-H "Priority: ${NTFY_PRIORITY:-3}"
|
||||
-H "Tags: rotating_light,blocklist"
|
||||
-d "$(echo "$message" | sed 's/\*\*//g')"
|
||||
)
|
||||
[[ -n "${NTFY_TOKEN:-}" ]] && curl_args+=(-H "Authorization: Bearer ${NTFY_TOKEN}")
|
||||
curl "${curl_args[@]}" &>/dev/null &
|
||||
;;
|
||||
generic)
|
||||
curl -s -H "Content-Type: application/json" \
|
||||
-d "{\"message\": \"$message\", \"action\": \"$action\", \"client\": \"$ip\", \"source\": \"external-blocklist\"}" \
|
||||
"$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
|
||||
}
|
||||
|
||||
# ─── IPs aus Blocklist-Datei parsen ──────────────────────────────────────────
|
||||
parse_blocklist_ips() {
|
||||
local cache_file="$1"
|
||||
|
||||
[[ -f "$cache_file" ]] || return
|
||||
|
||||
# Zeilen lesen, Leerzeilen und Kommentare ignorieren, IPs extrahieren
|
||||
while IFS= read -r line; do
|
||||
# Leerzeilen überspringen
|
||||
[[ -z "$line" ]] && continue
|
||||
# Kommentare überspringen (# am Anfang)
|
||||
[[ "$line" =~ ^[[:space:]]*# ]] && continue
|
||||
# Whitespace trimmen
|
||||
line=$(echo "$line" | xargs)
|
||||
# Leere Zeilen nach Trim überspringen
|
||||
[[ -z "$line" ]] && continue
|
||||
# CIDR-Notation oder reine IP ausgeben
|
||||
echo "$line"
|
||||
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
|
||||
|
||||
ban_ip "$ip"
|
||||
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
|
||||
24
go.mod
Normal file
24
go.mod
Normal 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
57
go.sum
Normal 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=
|
||||
726
install.sh
726
install.sh
@@ -1,726 +0,0 @@
|
||||
#!/bin/bash
|
||||
###############################################################################
|
||||
# AdGuard Shield - Installer / Updater / Uninstaller
|
||||
# Autor: Patrick Asmus
|
||||
# E-Mail: support@techniverse.net
|
||||
# Lizenz: MIT
|
||||
###############################################################################
|
||||
|
||||
VERSION="0.3.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 "${BLUE} E-Mail: support@techniverse.net${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 ""
|
||||
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}Monitor-Befehle (nach Installation):${NC}"
|
||||
echo -e " ${CYAN}sudo /opt/adguard-shield/adguard-shield.sh start${NC} # Monitor 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}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}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}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 ""
|
||||
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 -rp " 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/"
|
||||
|
||||
# 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"
|
||||
|
||||
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++))
|
||||
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"
|
||||
systemctl daemon-reload
|
||||
|
||||
echo -e " ✅ Service-Datei installiert"
|
||||
echo ""
|
||||
|
||||
# Interaktiv: Autostart beim Booten?
|
||||
read -rp " Soll AdGuard Shield beim Booten automatisch starten? [J/n]: " autostart
|
||||
if [[ "${autostart,,}" != "n" ]]; then
|
||||
systemctl enable adguard-shield.service
|
||||
echo -e " ✅ Autostart aktiviert"
|
||||
else
|
||||
systemctl disable adguard-shield.service 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 -rp " 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 -rp " AdGuard Home Benutzername [admin]: " adguard_user
|
||||
adguard_user="${adguard_user:-admin}"
|
||||
sed -i "s|^ADGUARD_USER=.*|ADGUARD_USER=\"$adguard_user\"|" "$conf"
|
||||
|
||||
# Passwort
|
||||
read -rsp " 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 -rp " 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 -rp " Sperrdauer in Sekunden [3600]: " ban_duration
|
||||
ban_duration="${ban_duration:-3600}"
|
||||
sed -i "s|^BAN_DURATION=.*|BAN_DURATION=$ban_duration|" "$conf"
|
||||
|
||||
# Whitelist
|
||||
read -rp " 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"
|
||||
|
||||
local response
|
||||
response=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-u "${ADGUARD_USER}:${ADGUARD_PASS}" \
|
||||
--connect-timeout 5 \
|
||||
"${ADGUARD_URL}/control/querylog?limit=1" 2>/dev/null)
|
||||
|
||||
if [[ "$response" == "200" ]]; then
|
||||
echo -e " ✅ Verbindung erfolgreich! (HTTP $response)"
|
||||
else
|
||||
echo -e " ❌ Verbindung fehlgeschlagen (HTTP $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"
|
||||
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 " Hilfe anzeigen:"
|
||||
echo " sudo bash install.sh --help"
|
||||
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
|
||||
|
||||
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 -rp " 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 -rp " Soll der AdGuard Shield 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
|
||||
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"
|
||||
systemctl daemon-reload
|
||||
echo -e " ✅ Service-Datei aktualisiert"
|
||||
echo ""
|
||||
|
||||
# Interaktiv: Autostart beim Booten?
|
||||
if systemctl is-enabled adguard-shield &>/dev/null; then
|
||||
echo -e " ℹ️ Autostart ist bereits aktiviert"
|
||||
else
|
||||
read -rp " Soll AdGuard Shield beim Booten automatisch starten? [J/n]: " autostart
|
||||
if [[ "${autostart,,}" != "n" ]]; then
|
||||
systemctl enable adguard-shield.service
|
||||
echo -e " ✅ Autostart aktiviert"
|
||||
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 -rp " 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 -rp " 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
|
||||
|
||||
echo -e "${YELLOW}Deinstalliere AdGuard Shield...${NC}"
|
||||
echo ""
|
||||
|
||||
# Sicherheitsabfrage
|
||||
read -rp " Wirklich deinstallieren? [j/N]: " confirm
|
||||
if [[ "${confirm,,}" != "j" ]]; then
|
||||
echo -e "${GREEN}Deinstallation abgebrochen.${NC}"
|
||||
exit 0
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 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
|
||||
rm -f "$SERVICE_FILE"
|
||||
systemctl daemon-reload
|
||||
echo " ✅ Service-Datei entfernt"
|
||||
|
||||
# iptables Chain aufräumen
|
||||
if [[ -f "$INSTALL_DIR/iptables-helper.sh" ]]; then
|
||||
bash "$INSTALL_DIR/iptables-helper.sh" remove || true
|
||||
fi
|
||||
|
||||
# Dateien entfernen
|
||||
read -rp " 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"
|
||||
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
|
||||
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 "$@"
|
||||
5
internal/appinfo/appinfo.go
Normal file
5
internal/appinfo/appinfo.go
Normal 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
295
internal/config/config.go
Normal 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
|
||||
}
|
||||
44
internal/config/config_test.go
Normal file
44
internal/config/config_test.go
Normal 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
1221
internal/daemon/daemon.go
Normal file
File diff suppressed because it is too large
Load Diff
365
internal/daemon/daemon_test.go
Normal file
365
internal/daemon/daemon_test.go
Normal 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
384
internal/daemon/live.go
Normal 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")
|
||||
}
|
||||
481
internal/db/db.go
Normal file
481
internal/db/db.go
Normal file
@@ -0,0 +1,481 @@
|
||||
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
|
||||
UniqueIPs int
|
||||
PermanentBans int
|
||||
ActiveBans int
|
||||
AbuseIPDBReports int
|
||||
RateLimitBans int
|
||||
SubdomainFloodBans int
|
||||
ExternalBlocklistBans int
|
||||
BusiestDay string
|
||||
BusiestDayCount int
|
||||
TopClients []ReportCount
|
||||
TopDomains []ReportCount
|
||||
Protocols []ReportCount
|
||||
Reasons []ReportCount
|
||||
Sources []ReportCount
|
||||
RecentBans []ReportEvent
|
||||
RecentEvents []string
|
||||
}
|
||||
|
||||
type ReportCount struct {
|
||||
Name string
|
||||
Count int
|
||||
}
|
||||
|
||||
type ReportEvent struct {
|
||||
Timestamp string
|
||||
Action string
|
||||
IP string
|
||||
Domain string
|
||||
Count string
|
||||
Duration string
|
||||
Protocol string
|
||||
Reason string
|
||||
}
|
||||
|
||||
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, busiestSince int64, limit int) (ReportStats, error) {
|
||||
st := ReportStats{Since: since, Until: until}
|
||||
if err := s.DB.QueryRow(`SELECT COUNT(*) FROM ban_history WHERE action='BAN' AND timestamp_epoch BETWEEN ? AND ?`, since, until).Scan(&st.TotalBans); err != nil {
|
||||
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(DISTINCT client_ip) FROM ban_history WHERE action='BAN' AND timestamp_epoch BETWEEN ? AND ?`, since, until).Scan(&st.UniqueIPs); err != nil {
|
||||
return st, err
|
||||
}
|
||||
if err := s.DB.QueryRow(`SELECT COUNT(*) FROM ban_history WHERE action='BAN' AND timestamp_epoch BETWEEN ? AND ? AND LOWER(COALESCE(duration,'')) LIKE '%permanent%'`, since, until).Scan(&st.PermanentBans); err != nil {
|
||||
return st, err
|
||||
}
|
||||
if err := s.DB.QueryRow(`SELECT COUNT(*) FROM active_bans`).Scan(&st.ActiveBans); err != nil {
|
||||
return st, err
|
||||
}
|
||||
if err := s.DB.QueryRow(`SELECT COUNT(*) FROM ban_history WHERE action='BAN' AND timestamp_epoch BETWEEN ? AND ? AND LOWER(COALESCE(reason,'')) LIKE '%rate%limit%'`, since, until).Scan(&st.RateLimitBans); err != nil {
|
||||
return st, err
|
||||
}
|
||||
if err := s.DB.QueryRow(`SELECT COUNT(*) FROM ban_history WHERE action='BAN' AND timestamp_epoch BETWEEN ? AND ? AND LOWER(COALESCE(reason,'')) LIKE '%subdomain%flood%'`, since, until).Scan(&st.SubdomainFloodBans); err != nil {
|
||||
return st, err
|
||||
}
|
||||
if err := s.DB.QueryRow(`SELECT COUNT(*) FROM ban_history WHERE action='BAN' AND timestamp_epoch BETWEEN ? AND ? AND LOWER(COALESCE(reason,'')) LIKE '%external%blocklist%'`, since, until).Scan(&st.ExternalBlocklistBans); err != nil {
|
||||
return st, err
|
||||
}
|
||||
if busiestSince <= 0 {
|
||||
busiestSince = since
|
||||
}
|
||||
if err := s.DB.QueryRow(`SELECT SUBSTR(timestamp_text, 1, 10), COUNT(*) FROM ban_history WHERE action='BAN' AND timestamp_epoch BETWEEN ? AND ? GROUP BY SUBSTR(timestamp_text, 1, 10) ORDER BY COUNT(*) DESC, SUBSTR(timestamp_text, 1, 10) DESC LIMIT 1`, busiestSince, until).Scan(&st.BusiestDay, &st.BusiestDayCount); err != nil && err != sql.ErrNoRows {
|
||||
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.TopDomains, err = s.reportCounts(`SELECT domain, COUNT(*) FROM ban_history WHERE action='BAN' AND timestamp_epoch BETWEEN ? AND ? AND COALESCE(domain,'') NOT IN ('', '-') GROUP BY domain ORDER BY COUNT(*) DESC, domain LIMIT ?`, since, until, limit)
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
st.Protocols, err = s.reportCounts(`SELECT COALESCE(NULLIF(protocol,''), 'unbekannt'), COUNT(*) FROM ban_history WHERE action='BAN' AND timestamp_epoch BETWEEN ? AND ? GROUP BY COALESCE(NULLIF(protocol,''), 'unbekannt') ORDER BY COUNT(*) DESC 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.RecentBans, err = s.reportRecentBans(since, until, 10)
|
||||
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()
|
||||
}
|
||||
|
||||
func (s *Store) reportRecentBans(since, until int64, limit int) ([]ReportEvent, 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 WHERE action='BAN' AND timestamp_epoch BETWEEN ? AND ? ORDER BY id DESC LIMIT ?`, since, until, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ReportEvent
|
||||
for rows.Next() {
|
||||
var e ReportEvent
|
||||
if err := rows.Scan(&e.Timestamp, &e.Action, &e.IP, &e.Domain, &e.Count, &e.Duration, &e.Protocol, &e.Reason); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
31
internal/db/db_test.go
Normal file
31
internal/db/db_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
203
internal/firewall/firewall.go
Normal file
203
internal/firewall/firewall.go
Normal 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
|
||||
}
|
||||
142
internal/firewall/firewall_test.go
Normal file
142
internal/firewall/firewall_test.go
Normal 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
245
internal/geoip/geoip.go
Normal 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")
|
||||
}
|
||||
30
internal/geoip/geoip_test.go
Normal file
30
internal/geoip/geoip_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
648
internal/installer/installer.go
Normal file
648
internal/installer/installer.go
Normal file
@@ -0,0 +1,648 @@
|
||||
package installer
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"adguard-shield/internal/report"
|
||||
)
|
||||
|
||||
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/9 Pruefe Betriebssystem und root-Rechte ...")
|
||||
if err := requireLinuxRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("2/9 Pruefe auf scriptbasierte Altinstallation ...")
|
||||
if findings := DetectLegacy(opts.InstallDir); len(findings) > 0 {
|
||||
return &LegacyError{Findings: findings}
|
||||
}
|
||||
if !opts.SkipDeps {
|
||||
fmt.Println("3/9 Pruefe System-Abhaengigkeiten ...")
|
||||
if err := ensureDependencies(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
fmt.Println("3/9 System-Abhaengigkeiten uebersprungen (--skip-deps)")
|
||||
}
|
||||
fmt.Println("4/9 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/9 Installiere Binary ...")
|
||||
if err := copySelf(filepath.Join(opts.InstallDir, "adguard-shield")); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("6/9 Installiere Report-Templates ...")
|
||||
if err := report.InstallTemplates(filepath.Join(opts.InstallDir, "templates")); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("7/9 Installiere oder migriere Konfiguration ...")
|
||||
if err := ensureConfig(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("8/9 Schreibe systemd-Service ...")
|
||||
if err := writeService(opts.InstallDir); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("9/9 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
|
||||
`
|
||||
701
internal/report/report.go
Normal file
701
internal/report/report.go
Normal file
@@ -0,0 +1,701 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"adguard-shield/internal/appinfo"
|
||||
"adguard-shield/internal/config"
|
||||
"adguard-shield/internal/db"
|
||||
)
|
||||
|
||||
//go:embed templates/report.html templates/report.txt
|
||||
var embeddedTemplates embed.FS
|
||||
|
||||
type Store interface {
|
||||
ReportStats(since, until, busiestSince 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 + ")"
|
||||
}
|
||||
mailer := "nicht gefunden"
|
||||
if parts := strings.Fields(c.ReportMailCmd); len(parts) > 0 {
|
||||
if p, err := exec.LookPath(parts[0]); err == nil {
|
||||
mailer = "gefunden (" + p + ")"
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf(`AdGuard Shield - Report Status
|
||||
|
||||
Report aktiviert: %v
|
||||
Intervall: %s
|
||||
Uhrzeit: %s
|
||||
Format: %s
|
||||
Empfaenger: %s
|
||||
Absender: %s
|
||||
Mail-Befehl: %s
|
||||
Mail-Befehl Status: %s
|
||||
Aktivster Tag: letzte %d Tage
|
||||
Cron: %s
|
||||
`, c.ReportEnabled, c.ReportInterval, c.ReportTime, c.ReportFormat, empty(c.ReportEmailTo, "nicht konfiguriert"), c.ReportEmailFrom, c.ReportMailCmd, mailer, c.ReportBusiestDayRange, cron)
|
||||
}
|
||||
|
||||
func InstallTemplates(dir string) error {
|
||||
if dir == "" {
|
||||
return fmt.Errorf("Template-Zielverzeichnis ist leer")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range []string{"report.html", "report.txt"} {
|
||||
data, err := embeddedTemplates.ReadFile("templates/" + name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, name), data, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Generate(c *config.Config, st Store, format string) (string, error) {
|
||||
format = normalizeFormat(format, c.ReportFormat)
|
||||
since, until, period := reportWindow(c.ReportInterval, time.Now())
|
||||
busiestSince := busiestWindowStart(c, since)
|
||||
stats, err := st.ReportStats(since, until, busiestSince, 20)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
stats.AbuseIPDBReports = countAbuseReports(c.LogFile, since, until)
|
||||
|
||||
tpl, err := loadTemplate(c, format)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
values, err := templateValues(c, st, stats, period, format)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for key, value := range values {
|
||||
tpl = strings.ReplaceAll(tpl, "{{"+key+"}}", value)
|
||||
}
|
||||
return tpl, nil
|
||||
}
|
||||
|
||||
func Send(ctx context.Context, c *config.Config, st Store) error {
|
||||
format := normalizeFormat(c.ReportFormat, "html")
|
||||
body, err := Generate(c, st, format)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, period := reportWindow(c.ReportInterval, time.Now())
|
||||
return sendMail(ctx, c, "AdGuard Shield "+period+" - "+hostname(), body, format, "AdGuard Shield Report Generator")
|
||||
}
|
||||
|
||||
func SendTest(ctx context.Context, c *config.Config) error {
|
||||
format := normalizeFormat(c.ReportFormat, "html")
|
||||
body := testBody(c, format)
|
||||
return sendMail(ctx, c, "AdGuard Shield Test-Mail - "+hostname(), body, format, "AdGuard Shield Report Generator (Test)")
|
||||
}
|
||||
|
||||
func InstallCron(binary, configPath string, c *config.Config) error {
|
||||
if !c.ReportEnabled {
|
||||
return fmt.Errorf("Report ist deaktiviert (REPORT_ENABLED=false)")
|
||||
}
|
||||
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"
|
||||
}
|
||||
command := shellQuote(binary) + " -config " + shellQuote(configPath) + " report-send"
|
||||
if strings.EqualFold(c.ReportInterval, "biweekly") {
|
||||
command = "[ $(( $(date +\\%V) \\% 2 )) -eq 1 ] && " + command
|
||||
}
|
||||
line := fmt.Sprintf(`# AdGuard Shield - Automatischer Report
|
||||
# Intervall: %s
|
||||
# Uhrzeit: %s
|
||||
SHELL=/bin/sh
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
|
||||
%s root %s >> %s 2>&1
|
||||
`, c.ReportInterval, c.ReportTime, schedule, command, shellQuote(c.LogFile))
|
||||
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, format, xMailer string) error {
|
||||
if strings.TrimSpace(c.ReportEmailTo) == "" {
|
||||
return fmt.Errorf("REPORT_EMAIL_TO ist leer")
|
||||
}
|
||||
parts := strings.Fields(c.ReportMailCmd)
|
||||
if len(parts) == 0 {
|
||||
return fmt.Errorf("REPORT_MAIL_CMD ist leer")
|
||||
}
|
||||
if _, err := exec.LookPath(parts[0]); err != nil {
|
||||
return fmt.Errorf("Mail-Befehl nicht gefunden: %s", parts[0])
|
||||
}
|
||||
contentType := "text/plain; charset=UTF-8"
|
||||
if strings.EqualFold(format, "html") {
|
||||
contentType = "text/html; charset=UTF-8"
|
||||
}
|
||||
msg := strings.Join([]string{
|
||||
"From: " + c.ReportEmailFrom,
|
||||
"To: " + c.ReportEmailTo,
|
||||
"Subject: " + mime.QEncoding.Encode("utf-8", subject),
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: " + contentType,
|
||||
"Content-Transfer-Encoding: 8bit",
|
||||
"X-Mailer: " + xMailer,
|
||||
"",
|
||||
body,
|
||||
}, "\n")
|
||||
args := append(parts[1:], "-t")
|
||||
cmd := exec.CommandContext(ctx, parts[0], args...)
|
||||
cmd.Stdin = strings.NewReader(msg)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func templateValues(c *config.Config, st Store, stats db.ReportStats, period, format string) (map[string]string, error) {
|
||||
updateHTML, updateText := checkForUpdate()
|
||||
busiestDay := "-"
|
||||
if stats.BusiestDay != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02", stats.BusiestDay, time.Local); err == nil {
|
||||
busiestDay = t.Format("02.01.2006") + " (" + strconv.Itoa(stats.BusiestDayCount) + ")"
|
||||
} else {
|
||||
busiestDay = stats.BusiestDay + " (" + strconv.Itoa(stats.BusiestDayCount) + ")"
|
||||
}
|
||||
}
|
||||
busiestLabel := "Aktivster Tag"
|
||||
if c.ReportBusiestDayRange > 0 {
|
||||
busiestLabel = fmt.Sprintf("Aktivster Tag (%d Tage)", c.ReportBusiestDayRange)
|
||||
}
|
||||
|
||||
values := map[string]string{
|
||||
"REPORT_PERIOD": period,
|
||||
"REPORT_DATE": time.Now().Format("02.01.2006 15:04:05"),
|
||||
"HOSTNAME": hostname(),
|
||||
"VERSION": appinfo.Version,
|
||||
"TOTAL_BANS": strconv.Itoa(stats.TotalBans),
|
||||
"TOTAL_UNBANS": strconv.Itoa(stats.TotalUnbans),
|
||||
"UNIQUE_IPS": strconv.Itoa(stats.UniqueIPs),
|
||||
"PERMANENT_BANS": strconv.Itoa(stats.PermanentBans),
|
||||
"ACTIVE_BANS": strconv.Itoa(stats.ActiveBans),
|
||||
"ABUSEIPDB_REPORTS": strconv.Itoa(stats.AbuseIPDBReports),
|
||||
"RATELIMIT_BANS": strconv.Itoa(stats.RateLimitBans),
|
||||
"SUBDOMAIN_FLOOD_BANS": strconv.Itoa(stats.SubdomainFloodBans),
|
||||
"EXTERNAL_BLOCKLIST_BANS": strconv.Itoa(stats.ExternalBlocklistBans),
|
||||
"BUSIEST_DAY": busiestDay,
|
||||
"BUSIEST_DAY_LABEL": busiestLabel,
|
||||
"TOP10_IPS_TABLE": topCountsHTML(stats.TopClients, "IP-Adresse"),
|
||||
"TOP10_DOMAINS_TABLE": topCountsHTML(stats.TopDomains, "Domain"),
|
||||
"PROTOCOL_TABLE": protocolHTML(stats.Protocols),
|
||||
"RECENT_BANS_TABLE": recentBansHTML(stats.RecentBans),
|
||||
"TOP10_IPS_TEXT": topCountsText(stats.TopClients, "IP-Adresse"),
|
||||
"TOP10_DOMAINS_TEXT": topCountsText(stats.TopDomains, "Domain"),
|
||||
"PROTOCOL_TEXT": protocolText(stats.Protocols),
|
||||
"RECENT_BANS_TEXT": recentBansText(stats.RecentBans),
|
||||
"UPDATE_NOTICE": updateHTML,
|
||||
"UPDATE_NOTICE_TXT": updateText,
|
||||
"PERIOD_OVERVIEW_TABLE": "",
|
||||
"PERIOD_OVERVIEW_TEXT": "",
|
||||
}
|
||||
if strings.EqualFold(format, "html") {
|
||||
values["PERIOD_OVERVIEW_TABLE"] = periodOverviewHTML(st)
|
||||
} else {
|
||||
values["PERIOD_OVERVIEW_TEXT"] = periodOverviewText(st)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func topCountsHTML(rows []db.ReportCount, nameHeader string) string {
|
||||
if len(rows) == 0 {
|
||||
return `<div class="no-data">Keine Daten im Berichtszeitraum</div>`
|
||||
}
|
||||
maxCount := rows[0].Count
|
||||
var b strings.Builder
|
||||
b.WriteString("<table><tr><th>#</th><th>" + html.EscapeString(nameHeader) + "</th><th>Sperren</th></tr>")
|
||||
for i, r := range rows {
|
||||
width := 100
|
||||
if maxCount > 0 {
|
||||
width = r.Count * 100 / maxCount
|
||||
}
|
||||
class := ""
|
||||
if i < 3 {
|
||||
class = " top3"
|
||||
}
|
||||
cellClass := ""
|
||||
if strings.Contains(strings.ToLower(nameHeader), "ip") {
|
||||
cellClass = ` class="ip-cell"`
|
||||
}
|
||||
fmt.Fprintf(&b, `<tr><td><span class="rank%s">%d</span></td><td%s>%s</td><td><div class="bar-container"><div class="bar" style="width:%d%%"></div><span class="bar-value">%d</span></div></td></tr>`, class, i+1, cellClass, html.EscapeString(r.Name), width, r.Count)
|
||||
}
|
||||
b.WriteString("</table>")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func protocolHTML(rows []db.ReportCount) string {
|
||||
if len(rows) == 0 {
|
||||
return `<div class="no-data">Keine Daten im Berichtszeitraum</div>`
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("<table><tr><th>Protokoll</th><th>Anzahl Sperren</th></tr>")
|
||||
for _, r := range rows {
|
||||
class := protocolClass(r.Name)
|
||||
fmt.Fprintf(&b, `<tr><td><span class="protocol-badge %s">%s</span></td><td>%d</td></tr>`, class, html.EscapeString(r.Name), r.Count)
|
||||
}
|
||||
b.WriteString("</table>")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func recentBansHTML(rows []db.ReportEvent) string {
|
||||
if len(rows) == 0 {
|
||||
return `<div class="no-data">Keine Sperren im Berichtszeitraum</div>`
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("<table><tr><th>Zeitpunkt</th><th>IP</th><th>Domain</th><th>Grund</th></tr>")
|
||||
for _, e := range rows {
|
||||
reason := fallback(e.Reason, "rate-limit")
|
||||
domain := fallbackDash(e.Domain)
|
||||
fmt.Fprintf(&b, `<tr><td>%s</td><td class="ip-cell">%s</td><td>%s</td><td><span class="reason-badge %s">%s</span></td></tr>`, html.EscapeString(shortTime(e.Timestamp)), html.EscapeString(e.IP), html.EscapeString(domain), reasonClass(reason), html.EscapeString(reason))
|
||||
}
|
||||
b.WriteString("</table>")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func periodOverviewHTML(st Store) string {
|
||||
rows := periodOverviewRows(st)
|
||||
var b strings.Builder
|
||||
b.WriteString("<table><tr><th>Zeitraum</th><th>Sperren</th><th>Entsperrt</th><th>Unique IPs</th><th>Dauerhaft gebannt</th></tr>")
|
||||
for _, r := range rows {
|
||||
class := ""
|
||||
if r.Label == "Heute" {
|
||||
class = ` class="period-today"`
|
||||
} else if r.Label == "Gestern" {
|
||||
class = ` class="period-gestern"`
|
||||
}
|
||||
fmt.Fprintf(&b, `<tr%s><td><strong>%s</strong></td><td>%d</td><td>%d</td><td>%d</td><td>%d</td></tr>`, class, html.EscapeString(r.Label), r.TotalBans, r.TotalUnbans, r.UniqueIPs, r.PermanentBans)
|
||||
}
|
||||
b.WriteString("</table>")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func topCountsText(rows []db.ReportCount, nameHeader string) string {
|
||||
if len(rows) == 0 {
|
||||
return " Keine Daten im Berichtszeitraum"
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, " %-4s %-42s %s\n", "#", nameHeader, "Sperren")
|
||||
fmt.Fprintf(&b, " %-4s %-42s %s\n", "--", strings.Repeat("-", 42), "-------")
|
||||
for i, r := range rows {
|
||||
fmt.Fprintf(&b, " %-4s %-42s %d\n", strconv.Itoa(i+1)+".", r.Name, r.Count)
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
func protocolText(rows []db.ReportCount) string {
|
||||
if len(rows) == 0 {
|
||||
return " Keine Daten im Berichtszeitraum"
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, " %-20s %s\n", "Protokoll", "Anzahl")
|
||||
fmt.Fprintf(&b, " %-20s %s\n", strings.Repeat("-", 20), "------")
|
||||
for _, r := range rows {
|
||||
fmt.Fprintf(&b, " %-20s %d\n", r.Name, r.Count)
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
func recentBansText(rows []db.ReportEvent) string {
|
||||
if len(rows) == 0 {
|
||||
return " Keine Sperren im Berichtszeitraum"
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, " %-17s %-42s %-30s %s\n", "Zeitpunkt", "IP", "Domain", "Grund")
|
||||
fmt.Fprintf(&b, " %-17s %-42s %-30s %s\n", strings.Repeat("-", 17), strings.Repeat("-", 42), strings.Repeat("-", 30), "----------")
|
||||
for _, e := range rows {
|
||||
fmt.Fprintf(&b, " %-17s %-42s %-30s %s\n", shortTime(e.Timestamp), e.IP, fallbackDash(e.Domain), fallback(e.Reason, "rate-limit"))
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
func periodOverviewText(st Store) string {
|
||||
rows := periodOverviewRows(st)
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, " %-15s %-9s %-12s %-14s %-11s\n", "Zeitraum", "Sperren", "Entsperrt", "Unique IPs", "Dauerhaft")
|
||||
fmt.Fprintf(&b, " %-15s %-9s %-12s %-14s %-11s\n", strings.Repeat("-", 15), strings.Repeat("-", 9), strings.Repeat("-", 12), strings.Repeat("-", 14), strings.Repeat("-", 11))
|
||||
for _, r := range rows {
|
||||
fmt.Fprintf(&b, " %-15s %-9d %-12d %-14d %-11d\n", r.Label, r.TotalBans, r.TotalUnbans, r.UniqueIPs, r.PermanentBans)
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
type overviewRow struct {
|
||||
Label string
|
||||
db.ReportStats
|
||||
}
|
||||
|
||||
func periodOverviewRows(st Store) []overviewRow {
|
||||
now := time.Now()
|
||||
today := midnight(now)
|
||||
defs := []struct {
|
||||
label string
|
||||
since time.Time
|
||||
until time.Time
|
||||
}{}
|
||||
if now.Hour() >= 20 {
|
||||
defs = append(defs, struct {
|
||||
label string
|
||||
since time.Time
|
||||
until time.Time
|
||||
}{"Heute", today, now})
|
||||
}
|
||||
defs = append(defs,
|
||||
struct {
|
||||
label string
|
||||
since time.Time
|
||||
until time.Time
|
||||
}{"Gestern", today.AddDate(0, 0, -1), today.Add(-time.Second)},
|
||||
struct {
|
||||
label string
|
||||
since time.Time
|
||||
until time.Time
|
||||
}{"Letzte 7 Tage", today.AddDate(0, 0, -7), now},
|
||||
struct {
|
||||
label string
|
||||
since time.Time
|
||||
until time.Time
|
||||
}{"Letzte 14 Tage", today.AddDate(0, 0, -14), now},
|
||||
struct {
|
||||
label string
|
||||
since time.Time
|
||||
until time.Time
|
||||
}{"Letzte 30 Tage", today.AddDate(0, 0, -30), now},
|
||||
)
|
||||
rows := make([]overviewRow, 0, len(defs))
|
||||
for _, d := range defs {
|
||||
stats, err := st.ReportStats(d.since.Unix(), d.until.Unix(), d.since.Unix(), 0)
|
||||
if err != nil {
|
||||
stats = db.ReportStats{}
|
||||
}
|
||||
rows = append(rows, overviewRow{Label: d.label, ReportStats: stats})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func loadTemplate(c *config.Config, format string) (string, error) {
|
||||
name := "report." + format
|
||||
for _, dir := range templateDirs(c) {
|
||||
data, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err == nil {
|
||||
return string(data), nil
|
||||
}
|
||||
}
|
||||
data, err := fs.ReadFile(embeddedTemplates, "templates/"+name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Report-Template nicht gefunden: %s", name)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func templateDirs(c *config.Config) []string {
|
||||
var dirs []string
|
||||
if v := strings.TrimSpace(os.Getenv("ADGUARD_SHIELD_TEMPLATE_DIR")); v != "" {
|
||||
dirs = append(dirs, v)
|
||||
}
|
||||
if c.Path != "" {
|
||||
dirs = append(dirs, filepath.Join(filepath.Dir(c.Path), "templates"))
|
||||
}
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
dirs = append(dirs, filepath.Join(wd, "templates"))
|
||||
}
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
dirs = append(dirs, filepath.Join(filepath.Dir(exe), "templates"))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
out := dirs[:0]
|
||||
for _, d := range dirs {
|
||||
if d != "" && !seen[d] {
|
||||
seen[d] = true
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
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", "weekly":
|
||||
return fmt.Sprintf("%s %s * * 1", minute, hour)
|
||||
case "monthly":
|
||||
return fmt.Sprintf("%s %s 1 * *", minute, hour)
|
||||
default:
|
||||
return fmt.Sprintf("%s %s * * 1", minute, hour)
|
||||
}
|
||||
}
|
||||
|
||||
func reportWindow(interval string, now time.Time) (int64, int64, string) {
|
||||
today := midnight(now)
|
||||
days, label := 7, "Bericht"
|
||||
switch strings.ToLower(interval) {
|
||||
case "daily":
|
||||
days, label = 1, "Tagesbericht"
|
||||
case "weekly":
|
||||
days, label = 7, "Wochenbericht"
|
||||
case "biweekly":
|
||||
days, label = 14, "Zweiwochenbericht"
|
||||
case "monthly":
|
||||
days, label = 30, "Monatsbericht"
|
||||
}
|
||||
start := today.AddDate(0, 0, -days)
|
||||
end := today.Add(-time.Second)
|
||||
if strings.EqualFold(interval, "daily") {
|
||||
return start.Unix(), end.Unix(), label + ": " + start.Format("02.01.2006")
|
||||
}
|
||||
return start.Unix(), end.Unix(), label + ": " + start.Format("02.01.2006") + " - " + end.Format("02.01.2006")
|
||||
}
|
||||
|
||||
func busiestWindowStart(c *config.Config, fallbackSince int64) int64 {
|
||||
if c.ReportBusiestDayRange <= 0 {
|
||||
return fallbackSince
|
||||
}
|
||||
return midnight(time.Now()).AddDate(0, 0, -c.ReportBusiestDayRange).Unix()
|
||||
}
|
||||
|
||||
func midnight(t time.Time) time.Time {
|
||||
y, m, d := t.Date()
|
||||
return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
|
||||
}
|
||||
|
||||
func countAbuseReports(path string, since, until int64) int {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
defer f.Close()
|
||||
count := 0
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if !strings.Contains(line, "AbuseIPDB:") || !strings.Contains(line, "erfolgreich gemeldet") || len(line) < 21 {
|
||||
continue
|
||||
}
|
||||
ts := strings.TrimPrefix(line[:20], "[")
|
||||
t, err := time.ParseInLocation("2006-01-02 15:04:05", ts, time.Local)
|
||||
if err == nil && t.Unix() >= since && t.Unix() <= until {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func checkForUpdate() (string, string) {
|
||||
if appinfo.Version == "" || appinfo.Version == "unknown" || strings.EqualFold(os.Getenv("ADGUARD_SHIELD_SKIP_UPDATE_CHECK"), "true") {
|
||||
return "", ""
|
||||
}
|
||||
client := http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Get("https://git.techniverse.net/api/v1/repos/scriptos/adguard-shield/releases?limit=1&page=1")
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return "", ""
|
||||
}
|
||||
var releases []struct {
|
||||
TagName string `json:"tag_name"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil || len(releases) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
latest := releases[0].TagName
|
||||
if !versionGreater(latest, appinfo.Version) {
|
||||
return "", ""
|
||||
}
|
||||
htmlNotice := `<div class="update-notice">Update verfuegbar: <strong>` + html.EscapeString(latest) + `</strong> · <a href="https://git.techniverse.net/scriptos/adguard-shield/releases">Jetzt aktualisieren</a></div>`
|
||||
textNotice := " Neue Version verfuegbar: " + latest + "\n Update: https://git.techniverse.net/scriptos/adguard-shield/releases\n"
|
||||
return htmlNotice, textNotice
|
||||
}
|
||||
|
||||
func versionGreater(a, b string) bool {
|
||||
ap := versionParts(a)
|
||||
bp := versionParts(b)
|
||||
max := len(ap)
|
||||
if len(bp) > max {
|
||||
max = len(bp)
|
||||
}
|
||||
for i := 0; i < max; i++ {
|
||||
ai, bi := 0, 0
|
||||
if i < len(ap) {
|
||||
ai = ap[i]
|
||||
}
|
||||
if i < len(bp) {
|
||||
bi = bp[i]
|
||||
}
|
||||
if ai != bi {
|
||||
return ai > bi
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func versionParts(v string) []int {
|
||||
v = strings.TrimPrefix(strings.TrimSpace(v), "v")
|
||||
fields := strings.FieldsFunc(v, func(r rune) bool { return r == '.' || r == '-' || r == '+' })
|
||||
out := make([]int, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
n, err := strconv.Atoi(f)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func testBody(c *config.Config, format string) string {
|
||||
now := time.Now().Format("02.01.2006 15:04:05")
|
||||
host := hostname()
|
||||
if strings.EqualFold(format, "html") {
|
||||
return `<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"></head><body style="font-family:sans-serif;background:#f0f2f5;padding:30px;"><div style="max-width:600px;margin:0 auto;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,0.08);"><div style="background:#0f3460;color:#fff;padding:30px;text-align:center;"><h1 style="margin:0;">AdGuard Shield Test-Mail</h1><p style="margin:6px 0 0;color:#ccd6f6;">E-Mail-Versand funktioniert</p></div><div style="padding:30px;"><table style="width:100%;border-collapse:collapse;"><tr><td>Hostname</td><td><strong>` + html.EscapeString(host) + `</strong></td></tr><tr><td>Zeitpunkt</td><td><strong>` + html.EscapeString(now) + `</strong></td></tr><tr><td>Empfaenger</td><td><strong>` + html.EscapeString(c.ReportEmailTo) + `</strong></td></tr><tr><td>Absender</td><td><strong>` + html.EscapeString(c.ReportEmailFrom) + `</strong></td></tr><tr><td>Mail-Befehl</td><td><strong>` + html.EscapeString(c.ReportMailCmd) + `</strong></td></tr><tr><td>Format</td><td><strong>` + html.EscapeString(format) + `</strong></td></tr></table></div></div></body></html>`
|
||||
}
|
||||
return fmt.Sprintf(`AdGuard Shield - Test-Mail
|
||||
|
||||
E-Mail-Versand funktioniert.
|
||||
|
||||
Hostname: %s
|
||||
Zeitpunkt: %s
|
||||
Empfaenger: %s
|
||||
Absender: %s
|
||||
Mail-Befehl: %s
|
||||
Format: %s
|
||||
`, host, now, c.ReportEmailTo, c.ReportEmailFrom, c.ReportMailCmd, format)
|
||||
}
|
||||
|
||||
func normalizeFormat(value, fallback string) string {
|
||||
format := strings.ToLower(strings.TrimSpace(value))
|
||||
if format == "" {
|
||||
format = strings.ToLower(strings.TrimSpace(fallback))
|
||||
}
|
||||
if format != "txt" {
|
||||
format = "html"
|
||||
}
|
||||
return format
|
||||
}
|
||||
|
||||
func shortTime(value string) string {
|
||||
if len(value) >= 16 {
|
||||
return value[5:16]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func protocolClass(proto string) string {
|
||||
s := strings.ToLower(proto)
|
||||
switch {
|
||||
case strings.HasPrefix(s, "dns"):
|
||||
return "dns"
|
||||
case strings.HasPrefix(s, "doh"):
|
||||
return "doh"
|
||||
case strings.HasPrefix(s, "dot"):
|
||||
return "dot"
|
||||
case strings.HasPrefix(s, "doq"):
|
||||
return "doq"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func reasonClass(reason string) string {
|
||||
s := strings.ToLower(reason)
|
||||
switch {
|
||||
case strings.Contains(s, "subdomain"):
|
||||
return "subdomain-flood"
|
||||
case strings.Contains(s, "external"):
|
||||
return "external"
|
||||
default:
|
||||
return "rate-limit"
|
||||
}
|
||||
}
|
||||
|
||||
func fallback(value, def string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return def
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func fallbackDash(value string) string {
|
||||
if strings.TrimSpace(value) == "" || strings.TrimSpace(value) == "-" {
|
||||
return "-"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func empty(s, fallback string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return fallback
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func shellQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
func hostname() string {
|
||||
name, err := os.Hostname()
|
||||
if err != nil || name == "" {
|
||||
return filepath.Base(os.Args[0])
|
||||
}
|
||||
return name
|
||||
}
|
||||
108
internal/report/report_test.go
Normal file
108
internal/report/report_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"adguard-shield/internal/config"
|
||||
"adguard-shield/internal/db"
|
||||
)
|
||||
|
||||
func TestGenerateUsesTemplatesAndFullStats(t *testing.T) {
|
||||
t.Setenv("ADGUARD_SHIELD_SKIP_UPDATE_CHECK", "true")
|
||||
store, err := db.Open(filepath.Join(t.TempDir(), "report.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
yesterday := time.Now().AddDate(0, 0, -1)
|
||||
events := []struct {
|
||||
ip string
|
||||
domain string
|
||||
duration string
|
||||
proto string
|
||||
reason string
|
||||
}{
|
||||
{"1.2.3.4", "example.com", "3600s", "DNS", "rate-limit"},
|
||||
{"1.2.3.4", "example.com", "permanent", "DoH", "subdomain-flood"},
|
||||
{"5.6.7.8", "block.example", "permanent", "DoT", "external-blocklist"},
|
||||
}
|
||||
for i, e := range events {
|
||||
ts := yesterday.Add(time.Duration(i) * time.Hour)
|
||||
_, err := store.DB.Exec(`INSERT INTO ban_history (timestamp_epoch, timestamp_text, action, client_ip, domain, count, duration, protocol, reason) VALUES (?, ?, 'BAN', ?, ?, '42', ?, ?, ?)`,
|
||||
ts.Unix(), ts.Format("2006-01-02 15:04:05"), e.ip, e.domain, e.duration, e.proto, e.reason)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
ts := yesterday.Add(4 * time.Hour)
|
||||
if _, err := store.DB.Exec(`INSERT INTO ban_history (timestamp_epoch, timestamp_text, action, client_ip, domain, count, duration, protocol, reason) VALUES (?, ?, 'UNBAN', '1.2.3.4', '-', '-', '-', '-', 'manual')`, ts.Unix(), ts.Format("2006-01-02 15:04:05")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.InsertBan(db.Ban{IP: "5.6.7.8", Domain: "block.example", Permanent: true, Reason: "external-blocklist", Protocol: "DoT", Source: "external-blocklist"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
logPath := filepath.Join(t.TempDir(), "shield.log")
|
||||
if err := writeTestLog(logPath, yesterday); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &config.Config{
|
||||
Path: filepath.Join(t.TempDir(), "adguard-shield.conf"),
|
||||
ReportInterval: "weekly",
|
||||
ReportFormat: "html",
|
||||
ReportBusiestDayRange: 30,
|
||||
LogFile: logPath,
|
||||
ReportEmailTo: "admin@example.test",
|
||||
ReportEmailFrom: "shield@example.test",
|
||||
ReportMailCmd: "msmtp",
|
||||
}
|
||||
|
||||
htmlReport, err := Generate(cfg, store, "html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"AdGuard Shield", "example.com", "1.2.3.4", "DoH", "Subdomain-Flood Sperren", "AbuseIPDB Reports"} {
|
||||
if !strings.Contains(htmlReport, want) {
|
||||
t.Fatalf("HTML report missing %q\n%s", want, htmlReport)
|
||||
}
|
||||
}
|
||||
if strings.Contains(htmlReport, "{{") {
|
||||
t.Fatalf("HTML report still contains placeholders")
|
||||
}
|
||||
|
||||
txtReport, err := Generate(cfg, store, "txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"Sperren gesamt: 3", "Entsperrungen: 1", "Permanente Sperren: 2", "block.example"} {
|
||||
if !strings.Contains(txtReport, want) {
|
||||
t.Fatalf("TXT report missing %q\n%s", want, txtReport)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionGreater(t *testing.T) {
|
||||
tests := []struct {
|
||||
a, b string
|
||||
want bool
|
||||
}{
|
||||
{"v1.0.1", "v1.0.0", true},
|
||||
{"v1.0.0", "v1.0.0", false},
|
||||
{"v1.2.0", "v1.10.0", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := versionGreater(tt.a, tt.b); got != tt.want {
|
||||
t.Fatalf("versionGreater(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestLog(path string, when time.Time) error {
|
||||
line := "[" + when.Format("2006-01-02 15:04:05") + "] [INFO] AbuseIPDB: 5.6.7.8 erfolgreich gemeldet\n"
|
||||
return os.WriteFile(path, []byte(line), 0644)
|
||||
}
|
||||
108
internal/report/templates/report.html
Normal file
108
internal/report/templates/report.html
Normal file
@@ -0,0 +1,108 @@
|
||||
<!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 .links { margin-top: 10px; display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
||||
.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; }
|
||||
.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) { .container { margin: 0; border-radius: 0; } .content, .header, .footer { padding-left: 18px; padding-right: 18px; } .stats-grid { grid-template-columns: 1fr; } table { font-size: 12px; } th, td { padding: 8px 8px; } .footer .links { display: block; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>AdGuard Shield</h1>
|
||||
<p class="subtitle">Sicherheits-Report</p>
|
||||
<div class="period">{{REPORT_PERIOD}}</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>Zeitraum-Schnelluebersicht</h2>
|
||||
{{PERIOD_OVERVIEW_TABLE}}
|
||||
<h2>Uebersicht</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>
|
||||
<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>
|
||||
<h2>Top 10 - Auffaelligste IPs</h2>
|
||||
{{TOP10_IPS_TABLE}}
|
||||
<h2>Top 10 - Meistbetroffene Domains</h2>
|
||||
{{TOP10_DOMAINS_TABLE}}
|
||||
<h2>Protokoll-Verteilung</h2>
|
||||
{{PROTOCOL_TABLE}}
|
||||
<h2>Letzte 10 Sperren</h2>
|
||||
{{RECENT_BANS_TABLE}}
|
||||
</div>
|
||||
<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>
|
||||
68
internal/report/templates/report.txt
Normal file
68
internal/report/templates/report.txt
Normal file
@@ -0,0 +1,68 @@
|
||||
================================================================
|
||||
AdGuard Shield - Sicherheits-Report
|
||||
================================================================
|
||||
|
||||
Zeitraum: {{REPORT_PERIOD}}
|
||||
Erstellt: {{REPORT_DATE}}
|
||||
Host: {{HOSTNAME}}
|
||||
|
||||
----------------------------------------------------------------
|
||||
ZEITRAUM-SCHNELLUEBERSICHT
|
||||
----------------------------------------------------------------
|
||||
|
||||
{{PERIOD_OVERVIEW_TEXT}}
|
||||
|
||||
----------------------------------------------------------------
|
||||
UEBERSICHT (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 - AUFFAELLIGSTE 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
|
||||
================================================================
|
||||
82
internal/syslog/syslog.go
Normal file
82
internal/syslog/syslog.go
Normal 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...) }
|
||||
@@ -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
|
||||
@@ -1,75 +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 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 | GRUND" >> "$BAN_HISTORY_FILE"
|
||||
echo "#─────────────────────────────────────────────────────────────────────────────────" >> "$BAN_HISTORY_FILE"
|
||||
fi
|
||||
|
||||
printf "%-19s | %-6s | %-39s | %-30s | %-8s | %-10s | %s\n" \
|
||||
"$timestamp" "$action" "$client_ip" "${domain:--}" "${count:--}" "-" "${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)
|
||||
|
||||
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"
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user