94 Commits

Author SHA1 Message Date
Patrick Asmus
de521f73ed feat: Mailtemplates und Reportversand wiederherstellen 2026-05-03 13:26:53 +02:00
Patrick Asmus
7bbdb104c4 docs: Dokumentation umfassend überarbeitet 2026-05-01 01:17:05 +02:00
a7f7dbdb71 Merge pull request 'v1.0.0' (#19) from v1.0.0 into main
All checks were successful
Release Binary / Build & Upload Linux Binary (release) Successful in 5m36s
Reviewed-on: #19
2026-04-30 22:16:06 +00:00
Patrick Asmus
4f17f7ff81 feat!: Migration auf Go-Binary
BREAKING CHANGE: Die alte Shell-Version muss vor der Installation der Go-Version deinstalliert werden.
2026-05-01 00:08:57 +02:00
Patrick Asmus
0d1f7db43b feat: Migration auf sqlite3 2026-04-30 15:39:26 +02:00
125329e4ff Merge pull request 'v0.9.0' (#18) from v0.9.0 into main
Reviewed-on: #18
2026-04-28 21:36:28 +00:00
Patrick Asmus
16b2c5950e Release: Version v0.9.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-28 23:34:29 +02:00
Patrick Asmus
b93689bbaf feat: DNS-Flood-Watchlist – sofortiger permanenter Ban für definierte Domains 2026-04-28 23:31:58 +02:00
e555db8092 README.md aktualisiert 2026-04-21 13:45:21 +00:00
b04b8bf87d update Readme.md 2026-04-21 12:03:54 +02:00
6b6a77a98c Merge pull request 'fix: IPv4-Erkennung korrigiert – Hostnamen mit führender Ziffer werden nicht mehr fehlklassifiziert' (#17) from v0.8.2 into main
Reviewed-on: #17
2026-04-19 14:18:40 +00:00
ac21922178 Release: Version v0.8.2 2026-04-19 16:17:56 +02:00
edd8cd4806 fix: IPv4-Erkennung korrigiert – Hostnamen mit führender Ziffer werden nicht mehr fehlklassifiziert 2026-04-19 16:15:10 +02:00
44936e9f20 Merge branch 'main' of https://git.techniverse.net/scriptos/adguard-shield 2026-04-17 13:40:46 +02:00
440694925e Merge pull request 'fix: Offense-Cleanup-Worker mit niedrigster CPU/IO-Priorität ausführen' (#16) from v0.8.1 into main
Reviewed-on: #16
2026-04-16 20:23:48 +00:00
c97e327f0d Merge pull request 'fix: Offense-Cleanup-Worker mit niedrigster CPU/IO-Priorität ausführen' (#16) from v0.8.1 into main
Reviewed-on: #16
2026-04-16 20:23:48 +00:00
12745c3fef Release: Version v0.8.1 2026-04-16 22:22:13 +02:00
c2d6f872f5 Release: Version v0.8.1 2026-04-16 22:22:13 +02:00
ccdc555246 fix: Offense-Cleanup-Worker mit niedrigster CPU/IO-Priorität ausführen 2026-04-16 21:33:47 +02:00
633331748f fix: Offense-Cleanup-Worker mit niedrigster CPU/IO-Priorität ausführen 2026-04-16 21:33:47 +02:00
2559ed89ea Merge pull request 'v0.8.0' (#15) from v0.8.0 into main
Reviewed-on: #15
2026-04-14 19:30:31 +00:00
df8b18ae08 Merge pull request 'v0.8.0' (#15) from v0.8.0 into main
Reviewed-on: #15
2026-04-14 19:30:31 +00:00
6f9f7eba8e Merge tag 'v0.8.0' of https://git.techniverse.net/scriptos/adguard-shield into v0.8.0 2026-04-14 21:24:44 +02:00
a79586de94 Merge tag 'v0.8.0' of https://git.techniverse.net/scriptos/adguard-shield into v0.8.0 2026-04-14 21:24:44 +02:00
a132b2a0f1 Merge pull request 'v0.8.0' (#14) from v0.8.0 into main
Reviewed-on: #14
2026-04-14 19:17:15 +00:00
b42f458d5a Merge pull request 'v0.8.0' (#14) from v0.8.0 into main
Reviewed-on: #14
2026-04-14 19:17:15 +00:00
70818698d1 Release: Version v0.8.0 2026-04-14 21:06:52 +02:00
83075f2782 Release: Version v0.8.0 2026-04-14 21:06:52 +02:00
0264e1e896 feat: Offense-Cleanup-Worker für automatisches Aufräumen abgelaufener Offense-Zähler 2026-04-14 21:01:51 +02:00
2a1d8ae975 feat: Offense-Cleanup-Worker für automatisches Aufräumen abgelaufener Offense-Zähler 2026-04-14 21:01:51 +02:00
df15a587ee update: Konfigurationsdatei aufräumen – Kommentare gekürzt, Verweis auf Doku ergänzt, fehlende Variable EXTERNAL_WHITELIST_CACHE_DIR hinzugefügt 2026-04-14 20:44:06 +02:00
0da5d01641 update: Konfigurationsdatei aufräumen – Kommentare gekürzt, Verweis auf Doku ergänzt, fehlende Variable EXTERNAL_WHITELIST_CACHE_DIR hinzugefügt 2026-04-14 20:44:06 +02:00
3d60771a1b feat: GeoIP-Länderfilter mit MaxMind Auto-Download 2026-04-14 20:30:37 +02:00
535be66b55 feat: GeoIP-Länderfilter mit MaxMind Auto-Download 2026-04-14 20:30:37 +02:00
4d7e053ce7 Merge pull request 'v0.7.1' (#13) from v0.7.1 into main
Reviewed-on: #13
2026-04-12 12:37:09 +00:00
2e78b9c14e Merge pull request 'v0.7.1' (#13) from v0.7.1 into main
Reviewed-on: #13
2026-04-12 12:37:09 +00:00
23deae7d81 Release: Version v0.7.1 2026-04-12 14:35:47 +02:00
0af79e7a28 Release: Version v0.7.1 2026-04-12 14:35:47 +02:00
0602fbb596 docs: README für docs-Verzeichnis hinzugefügt 2026-04-12 14:28:37 +02:00
606a28ed8e docs: README für docs-Verzeichnis hinzugefügt 2026-04-12 14:28:37 +02:00
a27c093d83 docs: Projektstruktur entfernt 2026-04-12 14:19:08 +02:00
77a5ebb144 docs: Projektstruktur entfernt 2026-04-12 14:19:08 +02:00
cfd6fa9b70 feat: Watchdog-Service für automatischen Health-Check und Recovery bei Service-Ausfall 2026-04-12 14:17:59 +02:00
01a99489ab Hier soll in wenigen Worten drin stehen, was geändert wurde, hinzugefügt wurde oder entfernt wurde. Du kannst Formate wie “fix”, “feat”, “update” und co verwenden. 2026-04-12 14:17:59 +02:00
2200e80f87 Merge pull request 'v0.7.0' (#12) from v0.7.0 into main
Reviewed-on: #12
2026-04-04 18:02:27 +00:00
6bdeb5bc31 Release: Version v0.7.0 2026-04-04 19:59:46 +02:00
5451c01603 Externe Whitelist mit DNS-Auflösung für dynamische IPs (DynDNS) 2026-04-04 19:57:07 +02:00
6daaf67f7c Merge pull request 'v0.6.2' (#11) from v0.6.2 into main
Reviewed-on: #11
2026-03-24 10:31:03 +00:00
0970218f9b Release: Version v0.6.2 2026-03-24 11:28:32 +01:00
db128f3076 Ignore .ki-workspace 2026-03-24 11:24:38 +01:00
6f14219445 Report: Aktivster Tag über konfigurierbaren Zeitraum statt Berichtsperiode 2026-03-24 10:50:58 +01:00
cb31aa48eb Merge pull request 'v0.6.1' (#10) from v0.6.1 into main
Reviewed-on: #10
2026-03-13 13:28:01 +00:00
1e8b7557e7 Release v0.6.1 2026-03-13 14:27:41 +01:00
4d1870cc85 Notifications optimiert. 2026-03-13 14:27:07 +01:00
ebcd70ce8b Merge pull request 'v0.6.0' (#9) from v0.6.0 into main
Reviewed-on: #9
2026-03-06 21:15:58 +00:00
ba342dd571 Release v0.6.0 2026-03-06 22:15:16 +01:00
ac1af85810 Performance stark optimiert. 2026-03-06 22:14:30 +01:00
54b6c877e5 Merge pull request 'v0.5.4' (#8) from v0.5.4 into main
Reviewed-on: #8
2026-03-06 20:23:43 +00:00
8562202aa7 Release v0.5.4 2026-03-06 21:23:06 +01:00
3361b571cf Report optimiert + weitere Fehler beseitigt. 2026-03-06 21:21:37 +01:00
86eeb2b947 Template überarbeitet + fix in der Generierung 2026-03-06 19:45:18 +01:00
cf915c5c80 Merge pull request 'v0.5.3' (#7) from v0.5.3 into main
Reviewed-on: #7
2026-03-06 16:38:00 +00:00
cf1e554a28 Das zusätzliche 'v' für Version entfernt. Wird jetzt in den Versionen selbst gesetzt. 2026-03-06 17:34:02 +01:00
657fdbaf6b 'doc' zu 'docs' umbenannt. 2026-03-06 16:07:55 +01:00
a39dc88770 Version in Uninstaller geändert. 2026-03-06 14:47:10 +01:00
19f72d5be4 Merge pull request 'v0.5.2' (#6) from v0.5.2 into main
Reviewed-on: #6
2026-03-06 12:14:05 +00:00
007c2b01bc Release v0.5.2 2026-03-06 13:13:32 +01:00
fd8388df0b Uninstaller ausgelagert. 2026-03-06 13:12:36 +01:00
db955263ed Email-Reports werden jetzt sauber erzeugt. 2026-03-06 13:04:19 +01:00
4b188193f6 Merge pull request 'v0.5.1' (#5) from v0.5.1 into main
Reviewed-on: #5
2026-03-05 22:03:36 +00:00
ae37610ec0 Release v0.5.1 2026-03-05 23:02:59 +01:00
f685e7eb3e Handling mit dem Sync externer Listen verbessert. 2026-03-05 22:59:51 +01:00
5f631ba858 Merge pull request 'v0.5.0' (#4) from v0.5.0 into main
Reviewed-on: #4
2026-03-05 19:13:10 +00:00
6a0d40ec1a Version v0.5.0 release 2026-03-05 20:12:39 +01:00
00cd42f35f Blocklisten mit Hostnames werden nun in IPs aufglöst und können importiert werden. 2026-03-05 20:10:58 +01:00
66f817d656 Änderungen am Email Report + Aufbewahrungsdauer implementiert 2026-03-05 19:37:54 +01:00
7a1b61a1db Doku korrekt formatiert. 2026-03-05 18:32:04 +01:00
5b3172faa2 Email-Report ist jetzt implementiert. 2026-03-05 18:28:46 +01:00
1dd8a5f606 Meldung an AbuseIPDB optimiert. 2026-03-05 17:41:07 +01:00
eb8d6fcc26 Header angepasst (fix). 2026-03-04 22:58:35 +01:00
74a35d16b7 Header angepasst. 2026-03-04 22:47:39 +01:00
f18770b890 Merge pull request 'v0.4.0' (#3) from v0.4.0 into main
Reviewed-on: #3
2026-03-04 21:12:45 +00:00
86d90634d2 "DNS-Abfragen zum Testen" dokumentiert. 2026-03-04 22:11:58 +01:00
efe14fd0cd AbuseIPDB nun implementiert. IPs werden gemeldet, wenn sie permanent geblockt werden. 2026-03-04 22:05:02 +01:00
449b285c12 API wird nach Installation getestet + Verfügbarkein DNS Server kann geprüft werden. 2026-03-04 21:20:40 +01:00
6ab1fb96e1 Doku aktualisiert. 2026-03-04 20:41:26 +01:00
c055f7f1d7 DNS Protokolle werden nun im Log und auch sonst überall angezeigt. 2026-03-04 20:32:39 +01:00
be504eaad9 Neue Funktion: Subomain Flooding wird nun auch erkannt. 2026-03-04 20:17:23 +01:00
1d9f5cca18 Header im Installationsscript aktualisiert. 2026-03-04 19:04:20 +01:00
09b4580f0e Doku zu den Befehlen aktualisiert 2026-03-04 19:00:29 +01:00
1c38ca7bab Merge pull request 'v0.3.1' (#2) from v0.3.1 into main
Reviewed-on: #2
2026-03-03 21:30:54 +00:00
b6e31e4976 notifications 2026-03-03 22:30:06 +01:00
4f679cadd4 feat: Progressive Sperren (Recidive) – Wiederholungstäter werden stufenweise länger gesperrt
- Neues Recidive-System: Sperrdauer steigt exponentiell bei wiederholten Verstößen (wie fail2ban)
- Konfigurierbar: Multiplikator, Max-Stufe mit permanenter Sperre, Zähler-Reset-Zeit
- Offense-Tracking via *.offenses State-Dateien
- Permanente Sperren werden nicht automatisch aufgehoben
- Neuer Befehl: reset-offenses [IP] zum Zurücksetzen der Offense-Zähler
- Status-Anzeige zeigt Stufe, Dauer und Wiederholungstäter-Übersicht
- Benachrichtigungen enthalten Stufe und progressive Sperrdauer
- unban-expired.sh berücksichtigt permanente Sperren
- Dokumentation vollständig aktualisiert (Konfig, Befehle, Architektur, Troubleshooting, README)
2026-03-03 22:16:29 +01:00
9d1879ba55 docs: fix accidental code block wrapping in README 2026-03-03 21:28:31 +01:00
49 changed files with 10331 additions and 3356 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

View File

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

View File

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

View File

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

6
.gitignore vendored Normal file
View File

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

View File

@@ -1,5 +1,8 @@
{
"files.eol": "\n",
"chat.tools.terminal.autoApprove": {
"Rename-Item": true
"Rename-Item": true,
"ForEach-Object": true,
"&": true
}
}

396
README.md
View File

@@ -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>
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).
<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>
## Was macht das Tool?
<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>
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.
## Features
## ✨ Was ist AdGuard Shield?
- 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
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.
## Voraussetzungen
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.
- 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
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.
## Schnellstart
## 🚀 Highlights
| 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.
> **Bestehende Shell-Installation?** Der Go-Installer bricht ab und meldet die gefundenen Script-Artefakte. Die alte Version muss zuerst deinstalliert werden (Konfiguration behalten). Details unter [docs/update.md](docs/update.md).
[![asciicast](https://asciinema.techniverse.net/a/77.svg)](https://asciinema.techniverse.net/a/77)
## Wichtigste Befehle
## 🔧 Befehlsübersicht
AdGuard Shield wird über ein einzelnes Binary bedient. Die Grundform lautet:
```bash
# 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>

View File

@@ -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

View File

@@ -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

View File

@@ -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
View File

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

View File

@@ -1,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
```

View File

@@ -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
```

View File

@@ -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.

View File

@@ -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"
```

View File

@@ -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
View 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
View 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
View 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
View 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
View File

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

744
docs/konfiguration.md Normal file
View 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 10002000 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 (15) |
### 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
View 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
```

View 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
View 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.

View File

@@ -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
View File

@@ -0,0 +1,24 @@
module adguard-shield
go 1.22
require (
github.com/oschwald/maxminddb-golang v1.12.0
modernc.org/sqlite v1.29.10
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.19.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.49.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)

57
go.sum Normal file
View File

@@ -0,0 +1,57 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oschwald/maxminddb-golang v1.12.0 h1:9FnTOD0YOhP7DGxGsq4glzpGy5+w7pq50AS6wALUMYs=
github.com/oschwald/maxminddb-golang v1.12.0/go.mod h1:q0Nob5lTCqyQ8WT6FYgS1L7PXKVVbgiymefNwIjPzgY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.20.0 h1:45Or8mQfbUqJOG9WaxvlFYOAQO0lQ5RvqBcFCXngjxk=
modernc.org/cc/v4 v4.20.0/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.16.0 h1:ofwORa6vx2FMm0916/CkZjpFPSR70VwTjUCe2Eg5BnA=
modernc.org/ccgo/v4 v4.16.0/go.mod h1:dkNyWIjFrVIZ68DTo36vHK+6/ShBn4ysU61So6PIqCI=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.49.3 h1:j2MRCRdwJI2ls/sGbeSk0t2bypOG/uvPZUsGQFDulqg=
modernc.org/libc v1.49.3/go.mod h1:yMZuGkn7pXbKfoT/M35gFJOAEdSKdxL0q64sF7KqCDo=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.29.10 h1:3u93dz83myFnMilBGCOLbr+HjklS6+5rJLx4q86RDAg=
modernc.org/sqlite v1.29.10/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@@ -1,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 "$@"

View File

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

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

@@ -0,0 +1,295 @@
package config
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
type Config struct {
Path string
AdGuardURL string
AdGuardUser string
AdGuardPass string
RateLimitMaxRequests int
RateLimitWindow int
CheckInterval int
APIQueryLimit int
SubdomainFloodEnabled bool
SubdomainFloodMaxUnique int
SubdomainFloodWindow int
DNSFloodWatchlistEnabled bool
DNSFloodWatchlist []string
BanDuration int64
Chain string
BlockedPorts []string
FirewallBackend string
FirewallMode string
DryRun bool
Whitelist []string
LogFile string
LogLevel string
StateDir string
PIDFile string
NotifyEnabled bool
NotifyType string
NotifyWebhook string
NTFYServerURL string
NTFYTopic string
NTFYToken string
NTFYPriority string
ReportEnabled bool
ReportInterval string
ReportTime string
ReportEmailTo string
ReportEmailFrom string
ReportFormat string
ReportMailCmd string
ReportBusiestDayRange int
ExternalWhitelistEnabled bool
ExternalWhitelistURLs []string
ExternalWhitelistInterval int
ExternalWhitelistCacheDir string
ExternalBlocklistEnabled bool
ExternalBlocklistURLs []string
ExternalBlocklistInterval int
ExternalBlocklistCacheDir string
ExternalBlocklistDuration int64
ExternalBlocklistAutoUnban bool
ExternalBlocklistNotify bool
ProgressiveBanEnabled bool
ProgressiveBanMultiplier int
ProgressiveBanMaxLevel int
ProgressiveBanResetAfter int64
AbuseIPDBEnabled bool
AbuseIPDBAPIKey string
AbuseIPDBCategories string
GeoIPEnabled bool
GeoIPMode string
GeoIPCountries []string
GeoIPNotify bool
GeoIPSkipPrivate bool
GeoIPLicenseKey string
GeoIPMMDBPath string
GeoIPCacheTTL int64
GeoIPCheckInterval int
}
func Load(path string) (*Config, error) {
values, err := parseFile(path)
if err != nil {
return nil, err
}
c := &Config{Path: path}
c.AdGuardURL = stringVal(values, "ADGUARD_URL", "")
c.AdGuardUser = stringVal(values, "ADGUARD_USER", "")
c.AdGuardPass = stringVal(values, "ADGUARD_PASS", "")
c.RateLimitMaxRequests = intVal(values, "RATE_LIMIT_MAX_REQUESTS", 30)
c.RateLimitWindow = intVal(values, "RATE_LIMIT_WINDOW", 60)
c.CheckInterval = intVal(values, "CHECK_INTERVAL", 10)
c.APIQueryLimit = intVal(values, "API_QUERY_LIMIT", 500)
c.SubdomainFloodEnabled = boolVal(values, "SUBDOMAIN_FLOOD_ENABLED", true)
c.SubdomainFloodMaxUnique = intVal(values, "SUBDOMAIN_FLOOD_MAX_UNIQUE", 50)
c.SubdomainFloodWindow = intVal(values, "SUBDOMAIN_FLOOD_WINDOW", 60)
c.DNSFloodWatchlistEnabled = boolVal(values, "DNS_FLOOD_WATCHLIST_ENABLED", false)
c.DNSFloodWatchlist = csv(values["DNS_FLOOD_WATCHLIST"])
c.BanDuration = int64(intVal(values, "BAN_DURATION", 3600))
c.Chain = stringVal(values, "IPTABLES_CHAIN", "ADGUARD_SHIELD")
c.BlockedPorts = fields(stringVal(values, "BLOCKED_PORTS", "53 443 853"))
c.FirewallBackend = stringVal(values, "FIREWALL_BACKEND", "ipset")
c.FirewallMode = strings.ToLower(strings.TrimSpace(stringVal(values, "FIREWALL_MODE", "host")))
c.DryRun = boolVal(values, "DRY_RUN", false)
if strings.EqualFold(os.Getenv("DRY_RUN"), "true") || os.Getenv("DRY_RUN") == "1" {
c.DryRun = true
}
c.Whitelist = csv(values["WHITELIST"])
c.LogFile = stringVal(values, "LOG_FILE", "/var/log/adguard-shield.log")
c.LogLevel = stringVal(values, "LOG_LEVEL", "INFO")
c.StateDir = stringVal(values, "STATE_DIR", "/var/lib/adguard-shield")
c.PIDFile = stringVal(values, "PID_FILE", "/var/run/adguard-shield.pid")
c.NotifyEnabled = boolVal(values, "NOTIFY_ENABLED", false)
c.NotifyType = stringVal(values, "NOTIFY_TYPE", "ntfy")
c.NotifyWebhook = stringVal(values, "NOTIFY_WEBHOOK_URL", "")
c.NTFYServerURL = stringVal(values, "NTFY_SERVER_URL", "https://ntfy.sh")
c.NTFYTopic = stringVal(values, "NTFY_TOPIC", "")
c.NTFYToken = stringVal(values, "NTFY_TOKEN", "")
c.NTFYPriority = stringVal(values, "NTFY_PRIORITY", "4")
c.ReportEnabled = boolVal(values, "REPORT_ENABLED", false)
c.ReportInterval = stringVal(values, "REPORT_INTERVAL", "weekly")
c.ReportTime = stringVal(values, "REPORT_TIME", "08:00")
c.ReportEmailTo = stringVal(values, "REPORT_EMAIL_TO", "admin@example.com")
c.ReportEmailFrom = stringVal(values, "REPORT_EMAIL_FROM", "adguard-shield@example.com")
c.ReportFormat = strings.ToLower(stringVal(values, "REPORT_FORMAT", "html"))
c.ReportMailCmd = stringVal(values, "REPORT_MAIL_CMD", "msmtp")
c.ReportBusiestDayRange = intVal(values, "REPORT_BUSIEST_DAY_RANGE", 30)
c.ExternalWhitelistEnabled = boolVal(values, "EXTERNAL_WHITELIST_ENABLED", false)
c.ExternalWhitelistURLs = csv(values["EXTERNAL_WHITELIST_URLS"])
c.ExternalWhitelistInterval = intVal(values, "EXTERNAL_WHITELIST_INTERVAL", 300)
c.ExternalWhitelistCacheDir = stringVal(values, "EXTERNAL_WHITELIST_CACHE_DIR", filepath.Join(c.StateDir, "external-whitelist"))
c.ExternalBlocklistEnabled = boolVal(values, "EXTERNAL_BLOCKLIST_ENABLED", false)
c.ExternalBlocklistURLs = csv(values["EXTERNAL_BLOCKLIST_URLS"])
c.ExternalBlocklistInterval = intVal(values, "EXTERNAL_BLOCKLIST_INTERVAL", 300)
c.ExternalBlocklistCacheDir = stringVal(values, "EXTERNAL_BLOCKLIST_CACHE_DIR", filepath.Join(c.StateDir, "external-blocklist"))
c.ExternalBlocklistDuration = int64(intVal(values, "EXTERNAL_BLOCKLIST_BAN_DURATION", 0))
c.ExternalBlocklistAutoUnban = boolVal(values, "EXTERNAL_BLOCKLIST_AUTO_UNBAN", true)
c.ExternalBlocklistNotify = boolVal(values, "EXTERNAL_BLOCKLIST_NOTIFY", false)
c.ProgressiveBanEnabled = boolVal(values, "PROGRESSIVE_BAN_ENABLED", true)
c.ProgressiveBanMultiplier = intVal(values, "PROGRESSIVE_BAN_MULTIPLIER", 2)
c.ProgressiveBanMaxLevel = intVal(values, "PROGRESSIVE_BAN_MAX_LEVEL", 5)
c.ProgressiveBanResetAfter = int64(intVal(values, "PROGRESSIVE_BAN_RESET_AFTER", 86400))
c.AbuseIPDBEnabled = boolVal(values, "ABUSEIPDB_ENABLED", false)
c.AbuseIPDBAPIKey = stringVal(values, "ABUSEIPDB_API_KEY", "")
c.AbuseIPDBCategories = stringVal(values, "ABUSEIPDB_CATEGORIES", "4")
c.GeoIPEnabled = boolVal(values, "GEOIP_ENABLED", false)
c.GeoIPMode = strings.ToLower(stringVal(values, "GEOIP_MODE", "blocklist"))
c.GeoIPCountries = upperCSV(values["GEOIP_COUNTRIES"])
c.GeoIPNotify = boolVal(values, "GEOIP_NOTIFY", true)
c.GeoIPSkipPrivate = boolVal(values, "GEOIP_SKIP_PRIVATE", true)
c.GeoIPLicenseKey = stringVal(values, "GEOIP_LICENSE_KEY", "")
c.GeoIPMMDBPath = stringVal(values, "GEOIP_MMDB_PATH", "")
c.GeoIPCacheTTL = int64(intVal(values, "GEOIP_CACHE_TTL", 86400))
c.GeoIPCheckInterval = intVal(values, "GEOIP_CHECK_INTERVAL", 0)
return c, nil
}
func DefaultPath() string {
if v := os.Getenv("ADGUARD_SHIELD_CONFIG"); v != "" {
return v
}
if _, err := os.Stat("/opt/adguard-shield/adguard-shield.conf"); err == nil {
return "/opt/adguard-shield/adguard-shield.conf"
}
return filepath.Join(".", "adguard-shield.conf")
}
func (c *Config) DBPath() string { return filepath.Join(c.StateDir, "adguard-shield.db") }
func (c *Config) GeoIPDir(scriptDir string) string { return filepath.Join(scriptDir, "geoip") }
func parseFile(path string) (map[string]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open config %s: %w", path, err)
}
defer f.Close()
out := map[string]string{}
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
idx := strings.Index(line, "=")
if idx < 1 {
continue
}
key := strings.TrimSpace(line[:idx])
val := stripInlineComment(strings.TrimSpace(line[idx+1:]))
out[key] = unquote(val)
}
return out, sc.Err()
}
func stripInlineComment(s string) string {
inSingle, inDouble := false, false
for i, r := range s {
switch r {
case '\'':
if !inDouble {
inSingle = !inSingle
}
case '"':
if !inSingle {
inDouble = !inDouble
}
case '#':
if !inSingle && !inDouble {
if i == 0 || s[i-1] == ' ' || s[i-1] == '\t' {
return strings.TrimSpace(s[:i])
}
}
}
}
return strings.TrimSpace(s)
}
func unquote(s string) string {
if len(s) >= 2 {
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
return s[1 : len(s)-1]
}
}
return s
}
func stringVal(m map[string]string, k, def string) string {
if v, ok := m[k]; ok {
return v
}
return def
}
func intVal(m map[string]string, k string, def int) int {
v, ok := m[k]
if !ok || strings.TrimSpace(v) == "" {
return def
}
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return def
}
return n
}
func boolVal(m map[string]string, k string, def bool) bool {
v, ok := m[k]
if !ok {
return def
}
switch strings.ToLower(strings.TrimSpace(v)) {
case "true", "1", "yes", "on":
return true
case "false", "0", "no", "off":
return false
default:
return def
}
}
func csv(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func upperCSV(s string) []string {
parts := csv(s)
for i := range parts {
parts[i] = strings.ToUpper(parts[i])
}
return parts
}
func fields(s string) []string {
out := strings.Fields(s)
if len(out) == 0 {
return []string{"53"}
}
return out
}

View File

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

1221
internal/daemon/daemon.go Normal file

File diff suppressed because it is too large Load Diff

View File

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

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

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

481
internal/db/db.go Normal file
View 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
View File

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

View File

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

View File

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

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

@@ -0,0 +1,245 @@
package geoip
import (
"archive/tar"
"compress/gzip"
"context"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/oschwald/maxminddb-golang"
)
type Store interface {
LoadGeoIPCache(ttl, dbMtime int64) (map[string]string, error)
UpsertGeoIP(ip, country string, dbMtime int64) error
}
type Resolver struct {
DBPath string
effectivePath string
LicenseKey string
Dir string
TTL int64
Store Store
reader *maxminddb.Reader
cache map[string]string
mtime int64
}
func New(dbPath, licenseKey, dir string, ttl int64, store Store) *Resolver {
return &Resolver{DBPath: dbPath, LicenseKey: licenseKey, Dir: dir, TTL: ttl, Store: store, cache: map[string]string{}}
}
func (r *Resolver) Open(ctx context.Context) error {
path := r.DBPath
if path == "" && r.LicenseKey != "" {
var err error
path, err = r.ensureAutoDB(ctx)
if err != nil {
return err
}
}
if path == "" {
return nil
}
r.effectivePath = path
st, err := os.Stat(path)
if err != nil {
return err
}
reader, err := maxminddb.Open(path)
if err != nil {
return err
}
r.reader = reader
r.mtime = st.ModTime().Unix()
if r.Store != nil {
if c, err := r.Store.LoadGeoIPCache(r.TTL, r.mtime); err == nil {
r.cache = c
}
}
return nil
}
func (r *Resolver) Close() error {
if r.reader != nil {
return r.reader.Close()
}
return nil
}
func (r *Resolver) Lookup(ip string) (string, error) {
if v, ok := r.cache[ip]; ok {
return v, nil
}
if r.reader == nil {
return r.lookupLegacy(ip)
}
parsed := net.ParseIP(ip)
if parsed == nil {
return "", fmt.Errorf("invalid IP %q", ip)
}
var rec struct {
Country struct {
ISOCode string `maxminddb:"iso_code"`
} `maxminddb:"country"`
RegisteredCountry struct {
ISOCode string `maxminddb:"iso_code"`
} `maxminddb:"registered_country"`
}
if err := r.reader.Lookup(parsed, &rec); err != nil {
return "", err
}
cc := strings.ToUpper(rec.Country.ISOCode)
if cc == "" {
cc = strings.ToUpper(rec.RegisteredCountry.ISOCode)
}
if cc != "" {
r.cache[ip] = cc
if r.Store != nil {
_ = r.Store.UpsertGeoIP(ip, cc, r.mtime)
}
}
return cc, nil
}
func (r *Resolver) lookupLegacy(ip string) (string, error) {
if strings.Contains(ip, ":") {
if cc, err := runGeoIPCommand("geoiplookup6", ip); err == nil && cc != "" {
return cc, nil
}
} else {
if cc, err := runGeoIPCommand("geoiplookup", ip); err == nil && cc != "" {
return cc, nil
}
}
if r.effectivePath != "" {
if cc, err := runGeoIPCommand("mmdblookup", "--file", r.effectivePath, "--ip", ip, "country", "iso_code"); err == nil && cc != "" {
return cc, nil
}
}
return "", fmt.Errorf("no GeoIP result for %s", ip)
}
func runGeoIPCommand(name string, args ...string) (string, error) {
if _, err := exec.LookPath(name); err != nil {
return "", err
}
out, err := exec.Command(name, args...).CombinedOutput()
if err != nil {
return "", err
}
re := regexp.MustCompile(`\b[A-Z]{2}\b`)
matches := re.FindAllString(string(out), -1)
for _, m := range matches {
if m != "IP" {
return strings.ToUpper(m), nil
}
}
return "", nil
}
func ShouldBlock(country, mode string, countries []string) bool {
if country == "" || len(countries) == 0 {
return false
}
found := false
country = strings.ToUpper(country)
for _, c := range countries {
if strings.ToUpper(strings.TrimSpace(c)) == country {
found = true
break
}
}
if strings.ToLower(mode) == "allowlist" {
return !found
}
return found
}
func IsPrivateIP(s string) bool {
if p, err := netip.ParsePrefix(s); err == nil {
return isPrivateAddr(p.Addr())
}
a, err := netip.ParseAddr(s)
if err != nil {
return false
}
return isPrivateAddr(a)
}
func isPrivateAddr(a netip.Addr) bool {
return a.IsPrivate() || a.IsLoopback() || a.IsLinkLocalUnicast() || a.IsUnspecified() ||
(a.Is4() && strings.HasPrefix(a.String(), "100.") && isCGNAT(a))
}
func isCGNAT(a netip.Addr) bool {
p := a.As4()
return p[0] == 100 && p[1] >= 64 && p[1] <= 127
}
func (r *Resolver) ensureAutoDB(ctx context.Context) (string, error) {
if err := os.MkdirAll(r.Dir, 0755); err != nil {
return "", err
}
dst := filepath.Join(r.Dir, "GeoLite2-Country.mmdb")
if st, err := os.Stat(dst); err == nil && time.Since(st.ModTime()) < 24*time.Hour {
return dst, nil
}
url := "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=" + r.LicenseKey + "&suffix=tar.gz"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("MaxMind download failed: HTTP %d", resp.StatusCode)
}
gzr, err := gzip.NewReader(resp.Body)
if err != nil {
return "", err
}
defer gzr.Close()
tr := tar.NewReader(gzr)
tmp := dst + ".tmp"
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return "", err
}
if h.FileInfo().IsDir() || filepath.Base(h.Name) != "GeoLite2-Country.mmdb" {
continue
}
f, err := os.Create(tmp)
if err != nil {
return "", err
}
_, copyErr := io.Copy(f, tr)
closeErr := f.Close()
if copyErr != nil {
return "", copyErr
}
if closeErr != nil {
return "", closeErr
}
return dst, os.Rename(tmp, dst)
}
return "", fmt.Errorf("GeoLite2-Country.mmdb not found in archive")
}

View File

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

View File

@@ -0,0 +1,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
View 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
}

View 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)
}

View 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>

View 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
View File

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

View File

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

View File

@@ -1,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