Merge pull request 'Release/v1.2.1' (#26) from release/v1.2.1 into main
Release Binary / Build & Upload Linux Binary (release) Successful in 1m55s

Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
2026-10-11 16:45:18 +00:00
17 changed files with 172 additions and 372 deletions
+1 -1
View File
@@ -66,7 +66,7 @@ Die benötigten Pakete werden vom Installer auf Ubuntu/Debian automatisch instal
```bash
# Release-Archiv herunterladen und entpacken
curl -fL -o adguard-shield-linux-amd64.tar.gz \
https://git.techniverse.net/scriptos/adguard-shield/releases/download/v1.2.0/adguard-shield-linux-amd64.tar.gz
https://git.techniverse.net/scriptos/adguard-shield/releases/download/v1.2.1/adguard-shield-linux-amd64.tar.gz
tar -xzf adguard-shield-linux-amd64.tar.gz
chmod +x ./adguard-shield
```
+5 -7
View File
@@ -107,15 +107,13 @@ GEOIP_MMDB_PATH="" # Manueller DB-Pfad (optional, hat Vorrang)
GEOIP_CACHE_TTL=86400 # GeoIP-Cache in Sekunden (0 = kein Ablauf)
# --- Web-Statusseite (optional) ---
# Schreibgeschützte Statusseite (nur Anzeige, keine Aktionen). Zugriff z.B. per SSH-Tunnel:
# ssh -L 8443:127.0.0.1:8443 root@server -> http://127.0.0.1:8443/
# Schreibgeschützte Statusseite (nur Anzeige, keine Aktionen). Spricht nur HTTP auf
# allen Schnittstellen; HTTPS übernimmt ein Reverse-Proxy (nginx, Caddy, ...).
WEB_ENABLED=false
WEB_LISTEN="127.0.0.1:8443" # host:port; Standard nur lokal (Loopback)
WEB_BASE_PATH="/" # Basispfad hinter Reverse-Proxy, z.B. "/adguard-shield/"
WEB_PORT=8080 # HTTP-Port der Statusseite
WEB_BASE_PATH="/" # Unterordner hinter dem Reverse-Proxy, z.B. "/adguard-shield/"
WEB_USER="admin"
WEB_PASSWORD="" # leer = ohne Anmeldung (nur lokal empfohlen); gesetzt = HTTP Basic Auth
WEB_TLS_CERT="" # Zertifikat (PEM); mit WEB_TLS_KEY zusammen = HTTPS
WEB_TLS_KEY="" # Privater Schlüssel (PEM)
WEB_PASSWORD="" # leer = ohne Anmeldung; gesetzt = HTTP Basic Auth
# --- Erweiterte Einstellungen ---
STATE_DIR="/var/lib/adguard-shield" # SQLite-DB: ${STATE_DIR}/adguard-shield.db
+1 -1
View File
@@ -39,7 +39,7 @@ SystemCallArchitectures=native
# CAP_NET_ADMIN: iptables, ip6tables, ipset
# CAP_NET_RAW: iptables-legacy nutzt einen Raw-Socket
# CAP_NET_BIND_SERVICE: nur für WEB_LISTEN mit einem Port unter 1024
# CAP_NET_BIND_SERVICE: nur für WEB_PORT unter 1024
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE
+1 -1
View File
@@ -389,7 +389,7 @@ Der Daemon läuft als root, die Unit beschränkt die Capabilities aber auf das,
|---|---|
| `CAP_NET_ADMIN` | `iptables`, `ip6tables`, `ipset` (Chain, Regeln, Sets) |
| `CAP_NET_RAW` | `iptables-legacy` öffnet einen Raw-Socket |
| `CAP_NET_BIND_SERVICE` | nur nötig, wenn `WEB_LISTEN` einen Port unter 1024 nutzt |
| `CAP_NET_BIND_SERVICE` | nur nötig, wenn `WEB_PORT` unter 1024 liegt |
Alles andere (u. a. `CAP_DAC_OVERRIDE`, `CAP_SETUID`, `CAP_KILL`) ist entfernt. Zusätzlich gelten `NoNewPrivileges=true`, `RestrictSUIDSGID`, `LockPersonality`, `RestrictRealtime`, `ProtectClock`, `ProtectControlGroups`, `ProtectKernelLogs` und `SystemCallArchitectures=native`. `ProtectKernelTunables` und `ProtectKernelModules` sind bewusst nicht gesetzt, damit iptables/ipset Netfilter-Einstellungen und -Module wie bisher nutzen können.
+2 -2
View File
@@ -268,13 +268,13 @@ ABUSEIPDB_API_KEY="..."
### Service gestartet
```text
AdGuard Shield v1.2.0 wurde auf dns1 gestartet.
AdGuard Shield v1.2.1 wurde auf dns1 gestartet.
```
### Service gestoppt
```text
AdGuard Shield v1.2.0 wurde auf dns1 gestoppt.
AdGuard Shield v1.2.1 wurde auf dns1 gestoppt.
```
### Rate-Limit-Sperre
+6 -8
View File
@@ -37,10 +37,10 @@ Ungültige Werte führen nicht zum Abbruch, damit der Schutz weiterläuft. Statt
| Werte, die nicht negativ sein dürfen | `PROGRESSIVE_BAN_MAX_LEVEL=-1`, `BAN_HISTORY_RETENTION_DAYS=-5`, `BAN_DURATION=-1`, `GEOIP_CACHE_TTL=-1` |
| Ungültige Ports | `BLOCKED_PORTS="53 abc 70000"` (ungültige Einträge werden ignoriert; ist kein Port gültig, gilt `53 443 853`) |
| Ungültige Auswahlwerte | `GEOIP_MODE`, `FIREWALL_MODE`, `LOG_LEVEL`, `NOTIFY_TYPE`, `REPORT_FORMAT`, `REPORT_INTERVAL` |
| Ungültige Web-Adresse/-Pfade | `WEB_LISTEN="8443"`, `WEB_BASE_PATH="/../x"`, nur `WEB_TLS_CERT` ohne `WEB_TLS_KEY` |
| Ungültiger Web-Port/-Pfad | `WEB_PORT=70000`, `WEB_BASE_PATH="/../x"` |
| Listen-URLs ohne `https://` | `EXTERNAL_BLOCKLIST_URLS="http://..."` oder `file://...` (die Quelle wird nicht geladen, siehe [Externe Listen: Transport und Größe](#externe-listen-transport-und-größe)) |
Veraltete Schlüssel der Shell-Version wie `BAN_HISTORY_FILE` werden ignoriert und mit einem eigenen Hinweis gemeldet.
Veraltete Schlüssel wie `BAN_HISTORY_FILE` (Shell-Version) oder `WEB_LISTEN`, `WEB_TLS_CERT` und `WEB_TLS_KEY` (bis v1.2.0) werden ignoriert und mit einem eigenen Hinweis gemeldet.
## Automatische Migration
@@ -580,19 +580,17 @@ Die externe Whitelist und die externe Blocklist fließen direkt in Firewall-Ents
## Web-Statusseite
Optionale, schreibgeschützte Statusseite mit Live-Ansicht. Einrichtung, Absicherung und Reverse-Proxy-Beispiele: [Web-Statusseite](webinterface.md).
Optionale, schreibgeschützte Statusseite mit Live-Ansicht. Der eingebaute Webserver spricht nur HTTP und lauscht auf allen Schnittstellen; HTTPS übernimmt ein Reverse-Proxy. Einrichtung, Absicherung und Reverse-Proxy-Beispiele: [Web-Statusseite](webinterface.md).
| Parameter | Standard | Beschreibung |
|---|---|---|
| `WEB_ENABLED` | `false` | Schreibgeschützte Web-Statusseite aktivieren |
| `WEB_LISTEN` | `127.0.0.1:8443` | Adresse und Port (`host:port`); Standard nur lokal (Loopback), z. B. für einen SSH-Tunnel |
| `WEB_BASE_PATH` | `/` | Basispfad, z. B. `/adguard-shield/` hinter einem Reverse-Proxy |
| `WEB_PORT` | `8080` | HTTP-Port der Statusseite (1–65535) |
| `WEB_BASE_PATH` | `/` | Basispfad, z. B. `/adguard-shield/`, wenn der Reverse-Proxy die Seite in einem Unterordner ausliefert |
| `WEB_USER` | `admin` | Benutzername für HTTP Basic Auth |
| `WEB_PASSWORD` | *(leer)* | Passwort für HTTP Basic Auth; leer = Statusseite ohne Anmeldung |
| `WEB_TLS_CERT` | *(leer)* | TLS-Zertifikat (PEM); zusammen mit `WEB_TLS_KEY` = HTTPS |
| `WEB_TLS_KEY` | *(leer)* | Privater TLS-Schlüssel (PEM) |
> Ohne `WEB_PASSWORD` ist die Seite ohne Anmeldung erreichbar. Bindest du sie nicht an `127.0.0.1`, warnt der Daemon beim Start. Ohne TLS wird ein gesetztes Passwort im Klartext übertragen – außerhalb von Loopback/SSH-Tunnel daher TLS verwenden.
> Der Port ist auf allen Schnittstellen offen. Ohne `WEB_PASSWORD` ist die Seite ohne Anmeldung erreichbar, der Daemon warnt dann beim Start. Den Port per Firewall auf den Reverse-Proxy beschränken und von außen nur über HTTPS zugreifen, sonst läuft ein gesetztes Passwort im Klartext über das Netz.
---
+18 -1
View File
@@ -26,6 +26,23 @@ sudo journalctl -u adguard-shield --no-pager -n 50
---
## Update von v1.2.0 auf v1.2.1
Die Web-Statusseite wird einfacher konfiguriert: Sie spricht nur noch HTTP auf allen Schnittstellen, HTTPS übernimmt ein Reverse-Proxy.
| Bisher (v1.2.0) | Neu (v1.2.1) |
|---|---|
| `WEB_LISTEN="127.0.0.1:8443"` | `WEB_PORT=8080` (lauscht auf allen Schnittstellen) |
| `WEB_TLS_CERT`, `WEB_TLS_KEY` | entfallen, HTTPS macht der Reverse-Proxy |
Der Updater ergänzt `WEB_PORT=8080`. `WEB_LISTEN`, `WEB_TLS_CERT` und `WEB_TLS_KEY` bleiben in der Datei stehen, werden aber ignoriert und beim Start als `WARNUNG` gemeldet; die Zeilen kannst du löschen. Wer die Statusseite bereits nutzt, muss Folgendes anpassen:
- Port: Lief die Seite auf einem anderen Port als 8080, `WEB_PORT` entsprechend setzen und ggf. den Reverse-Proxy auf den neuen Port umstellen.
- Erreichbarkeit: Die Seite ist jetzt auf allen Schnittstellen erreichbar statt nur auf `127.0.0.1`. `WEB_PASSWORD` setzen oder den Port per Firewall auf den Reverse-Proxy beschränken.
- HTTPS: Lief die Seite mit eigenem Zertifikat, einen Reverse-Proxy davorsetzen (Beispiele in [Web-Statusseite](webinterface.md#hinter-einem-reverse-proxy)).
---
## Update von v1.1.x auf v1.2.0
Das Update läuft wie gewohnt über `sudo ./adguard-shield update`. Datenbank, Sperren und Konfiguration werden übernommen. Vorher solltest du trotzdem ein Backup anlegen:
@@ -74,7 +91,7 @@ Du brauchst ein fertiges Linux-Binary. Das kann aus einem Release, aus CI oder a
```bash
curl -fL -o adguard-shield-linux-amd64.tar.gz \
https://git.techniverse.net/scriptos/adguard-shield/releases/download/v1.2.0/adguard-shield-linux-amd64.tar.gz
https://git.techniverse.net/scriptos/adguard-shield/releases/download/v1.2.1/adguard-shield-linux-amd64.tar.gz
tar -xzf adguard-shield-linux-amd64.tar.gz
chmod +x ./adguard-shield
```
+60 -40
View File
@@ -23,23 +23,21 @@ Hell/Dunkel folgt standardmäßig der Systemeinstellung (`prefers-color-scheme`)
## Konfiguration
Der eingebaute Webserver spricht nur HTTP und lauscht auf allen Schnittstellen. Einen eigenen Webserver braucht es dafür nicht. Soll die Seite per HTTPS oder unter einem Hostnamen erreichbar sein, übernimmt das ein Reverse-Proxy (nginx, Caddy, Traefik, ...), wie bei den meisten selbst gehosteten Anwendungen.
| Parameter | Standard | Beschreibung |
|---|---|---|
| `WEB_ENABLED` | `false` | Statusseite aktivieren |
| `WEB_LISTEN` | `127.0.0.1:8443` | Adresse und Port (`host:port`). Standard nur lokal (Loopback). `:8443` oder `0.0.0.0:8443` = alle Schnittstellen |
| `WEB_BASE_PATH` | `/` | Basispfad, z.B. `/adguard-shield/` hinter einem Reverse-Proxy. Wird automatisch auf führenden und abschließenden `/` ergänzt |
| `WEB_PORT` | `8080` | HTTP-Port der Statusseite (1–65535) |
| `WEB_BASE_PATH` | `/` | Basispfad, z.B. `/adguard-shield/`, wenn der Reverse-Proxy die Seite in einem Unterordner ausliefert. Wird automatisch auf führenden und abschließenden `/` ergänzt |
| `WEB_USER` | `admin` | Benutzername für HTTP Basic Auth |
| `WEB_PASSWORD` | *(leer)* | Passwort für HTTP Basic Auth. **Leer = Statusseite ohne Anmeldung** |
| `WEB_TLS_CERT` | *(leer)* | Pfad zum TLS-Zertifikat (PEM). Zusammen mit `WEB_TLS_KEY` = HTTPS |
| `WEB_TLS_KEY` | *(leer)* | Pfad zum privaten Schlüssel (PEM) |
Beispiel:
```bash
WEB_ENABLED=true
WEB_LISTEN="127.0.0.1:8443"
WEB_BASE_PATH="/"
WEB_USER="admin"
WEB_PORT=8080
WEB_PASSWORD='ein-langes-zufaelliges-passwort'
```
@@ -53,46 +51,67 @@ sudo adguard-shield logs --limit 20
Im Log steht beim Start z.B.:
```text
[INFO] [ADGUARD-SHIELDD] Web-Statusseite gestartet: http://127.0.0.1:8443/ (Anmeldung: Basic Auth)
[INFO] [ADGUARD-SHIELDD] Web-Statusseite gestartet: http://[::]:8080/ (Anmeldung: Basic Auth)
```
Die Seite ist dann unter `http://<server-ip>:8080/` erreichbar.
### Prüfungen und Warnungen
| Situation | Verhalten |
|---|---|
| `WEB_LISTEN` ist kein gültiges `host:port` oder der Port liegt nicht in 1–65535 | Warnung, Standard `127.0.0.1:8443` wird verwendet |
| `WEB_PORT` ist keine Zahl von 1–65535 | Warnung, Standard `8080` wird verwendet |
| `WEB_BASE_PATH` enthält andere Zeichen als Buchstaben, Ziffern, `- _ . ~ /` oder `.`/`..`-Segmente | Warnung, Standard `/` wird verwendet |
| Nur `WEB_TLS_CERT` oder nur `WEB_TLS_KEY` gesetzt | Warnung, die Statusseite wird **nicht** gestartet (der Daemon läuft normal weiter) |
| Zertifikat/Schlüssel nicht lesbar oder Port belegt | Fehler im Log, die Statusseite läuft nicht, der Daemon läuft normal weiter |
| `WEB_PASSWORD` leer und `WEB_LISTEN` nicht auf Loopback | Warnung: `Web-Statusseite ohne Anmeldung auf … erreichbar – WEB_PASSWORD setzen oder an 127.0.0.1 binden` |
| `WEB_PASSWORD` gesetzt, kein TLS und `WEB_LISTEN` nicht auf Loopback | Warnung: Basic-Auth-Zugangsdaten werden im Klartext übertragen |
| Port belegt | Fehler im Log, die Statusseite läuft nicht, der Daemon läuft normal weiter |
| `WEB_PASSWORD` leer | Warnung: `Web-Statusseite ohne Anmeldung auf … erreichbar – WEB_PASSWORD setzen oder den Port per Firewall auf den Reverse-Proxy beschränken` |
| `WEB_USER` enthält `:` | Warnung, eine Anmeldung ist so nicht möglich (Basic Auth erlaubt keinen Doppelpunkt im Benutzernamen) |
## Zugriff per SSH-Tunnel (empfohlen)
Mit dem Standard `WEB_LISTEN="127.0.0.1:8443"` ist die Seite nur auf dem Server selbst erreichbar. Vom eigenen Rechner aus:
```bash
ssh -L 8443:127.0.0.1:8443 root@dns-server
```
Danach im Browser `http://127.0.0.1:8443/` öffnen. Der Tunnel verschlüsselt die Verbindung, TLS ist dafür nicht nötig. Auf einem Server, auf dem nur Administratoren per SSH arbeiten, kann `WEB_PASSWORD` in diesem Fall auch leer bleiben.
| `WEB_LISTEN`, `WEB_TLS_CERT` oder `WEB_TLS_KEY` gesetzt (bis v1.2.0) | Hinweis, der Wert wird ignoriert (siehe [Update von v1.2.0 auf v1.2.1](update.md#update-von-v120-auf-v121)) |
## Hinter einem Reverse-Proxy
`WEB_BASE_PATH` muss dem Pfad entsprechen, unter dem der Proxy die Seite ausliefert. Der Proxy reicht den Pfad **unverändert** (mit Präfix) weiter.
Der Reverse-Proxy terminiert HTTPS und reicht die Anfragen per HTTP an `127.0.0.1:<WEB_PORT>` weiter. Damit Port 8080 nicht direkt aus dem Internet erreichbar ist, den Port in der Firewall sperren, wenn der Proxy auf demselben Server läuft (bzw. nur für den Proxy freigeben, wenn er woanders läuft).
### Eigene Subdomain
Beispiel `https://shield.example.com/`, `WEB_BASE_PATH` bleibt `/`.
nginx:
```nginx
server {
listen 443 ssl;
server_name shield.example.com;
# ssl_certificate / ssl_certificate_key wie gewohnt
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
}
}
```
Caddy (holt das Zertifikat automatisch):
```text
shield.example.com {
reverse_proxy 127.0.0.1:8080
}
```
### Unterordner einer bestehenden Domain
Beispiel `https://dns1.example.com/adguard-shield/`, während AdGuard Home unter `https://dns1.example.com/` läuft. Der Proxy reicht den Pfad **unverändert** (mit Präfix) weiter:
```bash
WEB_LISTEN="127.0.0.1:8443"
WEB_PORT=8080
WEB_BASE_PATH="/adguard-shield/"
WEB_PASSWORD='ein-langes-zufaelliges-passwort'
```
nginx:
```nginx
location /adguard-shield/ {
proxy_pass http://127.0.0.1:8443;
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
}
```
@@ -101,26 +120,27 @@ Caddy:
```text
handle /adguard-shield/* {
reverse_proxy 127.0.0.1:8443
reverse_proxy 127.0.0.1:8080
}
```
Hinweise:
Dafür muss der Reverse-Proxy auch vor AdGuard Home stehen: AdGuard Home belegt sonst selbst Port 443 und kann keine Pfade an andere Dienste weiterreichen. AdGuard Home läuft dann auf einem internen Port (z.B. `127.0.0.1:3000`), und der Proxy leitet `/` dorthin weiter.
- Der Proxy sollte HTTPS terminieren, sonst laufen die Basic-Auth-Zugangsdaten im Klartext über das Netz.
### Hinweise
- Von außen nur über HTTPS zugreifen, sonst laufen die Basic-Auth-Zugangsdaten im Klartext über das Netz.
- Die Sperre nach zu vielen Fehlversuchen gilt pro Client-IP. Hinter einem Proxy sieht AdGuard Shield nur die IP des Proxys; `X-Forwarded-For` wird bewusst nicht ausgewertet, weil der Header fälschbar ist. Zehn Fehlversuche irgendeines Nutzers sperren dann für bis zu eine Minute **alle** Anfragen über den Proxy – auch mit gültigen Zugangsdaten, damit das Passwort während der Sperre nicht weiter geraten werden kann.
- Alternativ kann die Anmeldung auch am Proxy erfolgen und `WEB_PASSWORD` leer bleiben, solange `WEB_LISTEN` auf `127.0.0.1` gebunden ist. Ohne Anmeldung prüft AdGuard Shield den `Host`-Header (Schutz vor DNS-Rebinding): Der Proxy darf dann **keinen** fremden Hostnamen weiterreichen, also bei nginx `proxy_set_header Host $host;` weglassen (bzw. `Host 127.0.0.1` setzen), bei Caddy `header_up Host 127.0.0.1`.
- Alternativ kann die Anmeldung auch am Proxy erfolgen und `WEB_PASSWORD` leer bleiben, solange Port 8080 per Firewall nur für den Proxy erreichbar ist. Ohne Anmeldung prüft AdGuard Shield den `Host`-Header (Schutz vor DNS-Rebinding): Der Proxy darf dann **keinen** fremden Hostnamen weiterreichen, also bei nginx `proxy_set_header Host $host;` weglassen (bzw. `Host 127.0.0.1` setzen), bei Caddy `header_up Host 127.0.0.1`.
## Direkt mit HTTPS
## Zugriff per SSH-Tunnel
Ohne Reverse-Proxy und mit gesperrtem Port 8080 geht es auch per SSH-Tunnel vom eigenen Rechner aus:
```bash
WEB_LISTEN="0.0.0.0:8443"
WEB_PASSWORD='ein-langes-zufaelliges-passwort'
WEB_TLS_CERT="/etc/ssl/adguard-shield/cert.pem"
WEB_TLS_KEY="/etc/ssl/adguard-shield/key.pem"
ssh -L 8080:127.0.0.1:8080 root@dns-server
```
Es wird mindestens TLS 1.2 verwendet, zusätzlich wird `Strict-Transport-Security: max-age=31536000` gesendet. Zertifikat und Schlüssel werden beim Start gelesen; der Daemon prüft höchstens einmal pro Minute, ob sich die Dateien geändert haben (Änderungszeit/Größe), und lädt ein erneuertes Zertifikat (z.B. Let's Encrypt) ohne Neustart. Ist das neue Paar unvollständig oder defekt, bleibt das bisherige Zertifikat aktiv und es wird eine Warnung geloggt. Den Port bei Bedarf in der Firewall freigeben, und zwar nur für vertrauenswürdige Netze.
Danach im Browser `http://127.0.0.1:8080/` öffnen. Der Tunnel verschlüsselt die Verbindung.
## Sicherheit
@@ -129,8 +149,8 @@ Es wird mindestens TLS 1.2 verwendet, zusätzlich wird `Strict-Transport-Securit
| Nur Lesen | Ausschließlich `GET`/`HEAD`; andere Methoden werden mit `405` abgelehnt, unbekannte Pfade (nach der Anmeldung) mit `404` |
| Anmeldung | HTTP Basic Auth (Realm `AdGuard Shield`), sobald `WEB_PASSWORD` gesetzt ist. Gilt für alle Pfade, auch Skripte, Styles und die API. Vergleich in konstanter Zeit über SHA-256-Hashes |
| Drosselung | Nach 10 fehlgeschlagenen Anmeldungen pro Client (IPv4-Adresse bzw. IPv6-/64) innerhalb einer Minute werden Anfragen dieses Clients ohne gültige Zugangsdaten bis zum Ende der Minute mit `429` abgewiesen. Zusätzlich gilt ein globales Limit von 100 Fehlversuchen pro Minute für alle Clients zusammen. Gültige Zugangsdaten werden immer angenommen. Fehlversuche werden gedrosselt als `WARN` geloggt (ohne Benutzername oder Passwort) |
| DNS-Rebinding | Ohne `WEB_PASSWORD` werden nur Anfragen beantwortet, deren `Host`-Header eine IP-Adresse, `localhost` oder der Host aus `WEB_LISTEN` ist; sonst `421 Misdirected Request`. Mit Anmeldung entfällt die Prüfung (Reverse-Proxys mit eigenem Hostnamen) |
| HTTPS | Mit `WEB_TLS_CERT`/`WEB_TLS_KEY` wird `Strict-Transport-Security` gesendet |
| DNS-Rebinding | Ohne `WEB_PASSWORD` werden nur Anfragen beantwortet, deren `Host`-Header eine IP-Adresse oder `localhost` ist; sonst `421 Misdirected Request`. Mit Anmeldung entfällt die Prüfung (Reverse-Proxys mit eigenem Hostnamen) |
| HTTPS | Übernimmt der Reverse-Proxy; HSTS bei Bedarf dort setzen |
| Browser-Schutz | Strenge Content-Security-Policy (`default-src 'self'`, keine Inline-Skripte oder -Styles, keine externen Quellen), `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `Cache-Control: no-store` für die API |
| Keine externen Abhängigkeiten | HTML, CSS, JavaScript und Icon sind im Programm eingebettet; es werden keine CDNs oder Web-Fonts geladen |
| XSS-Schutz | Daten (Domains, Client-Namen, Logzeilen) werden im Browser nur als Text eingefügt, nie als HTML |
@@ -142,7 +162,7 @@ Es wird mindestens TLS 1.2 verwendet, zusätzlich wird `Strict-Transport-Securit
Die Seite lädt ihre Daten von `<WEB_BASE_PATH>api/status` (JSON). Die Schnittstelle kann auch direkt abgefragt werden, z.B. für eigene Auswertungen:
```bash
curl -s -u admin:PASSWORT http://127.0.0.1:8443/api/status | jq .tiles
curl -s -u admin:PASSWORT http://127.0.0.1:8080/api/status | jq .tiles
```
| Pfad | Inhalt |
+1 -1
View File
@@ -1,5 +1,5 @@
package appinfo
var Version = "v1.2.0"
var Version = "v1.2.1"
const ProjectURL = "https://git.techniverse.net/scriptos/adguard-shield.git"
+12 -19
View File
@@ -4,7 +4,6 @@ import (
"bufio"
"errors"
"fmt"
"net"
"net/url"
"os"
"path"
@@ -108,12 +107,10 @@ type Config struct {
GeoIPCheckInterval int
WebEnabled bool
WebListen string
WebPort int
WebBasePath string
WebUser string
WebPassword string
WebTLSCert string
WebTLSKey string
}
func Load(path string) (*Config, error) {
@@ -217,22 +214,17 @@ func load(values map[string]string) (*Config, *reader) {
c.GeoIPCacheTTL = int64(r.intMin("GEOIP_CACHE_TTL", 86400, 0))
c.GeoIPCheckInterval = r.intMin("GEOIP_CHECK_INTERVAL", 0, 0)
c.WebEnabled = r.bool("WEB_ENABLED", false)
c.WebListen = r.listen("WEB_LISTEN", DefaultWebListen)
c.WebPort = r.port("WEB_PORT", DefaultWebPort)
c.WebBasePath = r.basePath("WEB_BASE_PATH", "/")
c.WebUser = r.str("WEB_USER", "admin")
c.WebPassword = r.str("WEB_PASSWORD", "")
c.WebTLSCert = strings.TrimSpace(r.str("WEB_TLS_CERT", ""))
c.WebTLSKey = strings.TrimSpace(r.str("WEB_TLS_KEY", ""))
if strings.Contains(c.WebUser, ":") {
r.warnf("WEB_USER=%q enthält einen Doppelpunkt; Basic Auth erlaubt das nicht, eine Anmeldung ist so nicht möglich", c.WebUser)
}
if (c.WebTLSCert == "") != (c.WebTLSKey == "") {
r.warnf("WEB_TLS_CERT und WEB_TLS_KEY müssen gemeinsam gesetzt sein; die Web-Statusseite wird nicht gestartet")
}
return c, r
}
const DefaultWebListen = "127.0.0.1:8443"
const DefaultWebPort = 8080
// Muss zu firewall.normalizeMode passen.
var firewallModes = []string{"host", "classic", "native", "docker-host", "docker", "docker-bridge", "docker-published", "published", "hybrid", "both"}
@@ -240,6 +232,9 @@ var firewallModes = []string{"host", "classic", "native", "docker-host", "docker
// Schlüssel der Shell-Version, die die Go-Version bewusst ignoriert.
var deprecatedKeys = map[string]string{
"BAN_HISTORY_FILE": "BAN_HISTORY_FILE wird ignoriert (History liegt in der SQLite-Datenbank)",
"WEB_LISTEN": "WEB_LISTEN wird ignoriert; die Web-Statusseite lauscht auf allen Schnittstellen, den Port legt WEB_PORT fest",
"WEB_TLS_CERT": "WEB_TLS_CERT wird ignoriert; die Web-Statusseite spricht nur HTTP, HTTPS übernimmt ein Reverse-Proxy",
"WEB_TLS_KEY": "WEB_TLS_KEY wird ignoriert; die Web-Statusseite spricht nur HTTP, HTTPS übernimmt ein Reverse-Proxy",
}
func DefaultPath() string {
@@ -475,21 +470,19 @@ func portNumber(s string) (int, bool) {
return n, true
}
// listen prüft eine Adresse der Form host:port (host darf leer sein).
func (r *reader) listen(k, def string) string {
// port liest einen einzelnen TCP-Port (1-65535).
func (r *reader) port(k string, def int) int {
v, ok := r.lookup(k)
v = strings.TrimSpace(v)
if !ok || v == "" {
return def
}
if _, port, err := net.SplitHostPort(v); err != nil {
r.warnf("%s=%q ist keine gültige Adresse (host:port), verwende Standardwert %s", k, v, def)
return def
} else if _, ok := portNumber(port); !ok {
r.warnf("%s=%q enthält keinen gültigen Port (1-65535), verwende Standardwert %s", k, v, def)
n, ok := portNumber(v)
if !ok {
r.warnf("%s=%q ist kein gültiger Port (1-65535), verwende Standardwert %d", k, v, def)
return def
}
return v
return n
}
// ErrInsecureListURL meldet eine http://-Liste ohne ALLOW_INSECURE_LIST_URLS=true.
+26 -21
View File
@@ -241,14 +241,14 @@ func TestLoadNotifyTLSInsecure(t *testing.T) {
func TestLoadWebDefaults(t *testing.T) {
c := loadString(t, "ADGUARD_URL=\"https://dns.example\"\n")
if c.WebEnabled || c.WebListen != "127.0.0.1:8443" || c.WebBasePath != "/" || c.WebUser != "admin" || c.WebPassword != "" || c.WebTLSCert != "" || c.WebTLSKey != "" {
if c.WebEnabled || c.WebPort != 8080 || c.WebBasePath != "/" || c.WebUser != "admin" || c.WebPassword != "" {
t.Fatalf("unexpected web defaults: %+v", c)
}
keys := map[string]bool{}
for _, k := range KnownKeys() {
keys[k] = true
}
for _, k := range []string{"WEB_ENABLED", "WEB_LISTEN", "WEB_BASE_PATH", "WEB_USER", "WEB_PASSWORD", "WEB_TLS_CERT", "WEB_TLS_KEY"} {
for _, k := range []string{"WEB_ENABLED", "WEB_PORT", "WEB_BASE_PATH", "WEB_USER", "WEB_PASSWORD"} {
if !keys[k] {
t.Fatalf("KnownKeys misses %s", k)
}
@@ -257,14 +257,12 @@ func TestLoadWebDefaults(t *testing.T) {
func TestLoadWebValues(t *testing.T) {
c := loadString(t, `WEB_ENABLED=true
WEB_LISTEN="[::1]:9000"
WEB_PORT=9000
WEB_BASE_PATH="adguard-shield"
WEB_USER="viewer"
WEB_PASSWORD='p#ss word'
WEB_TLS_CERT="/etc/ssl/c.pem"
WEB_TLS_KEY="/etc/ssl/k.pem"
`)
if !c.WebEnabled || c.WebListen != "[::1]:9000" || c.WebBasePath != "/adguard-shield/" || c.WebUser != "viewer" || c.WebPassword != "p#ss word" || c.WebTLSCert != "/etc/ssl/c.pem" || c.WebTLSKey != "/etc/ssl/k.pem" {
if !c.WebEnabled || c.WebPort != 9000 || c.WebBasePath != "/adguard-shield/" || c.WebUser != "viewer" || c.WebPassword != "p#ss word" {
t.Fatalf("unexpected web values: %+v", c)
}
if len(c.Warnings) != 0 {
@@ -272,21 +270,33 @@ WEB_TLS_KEY="/etc/ssl/k.pem"
}
}
func TestLoadWebListenValidation(t *testing.T) {
for _, v := range []string{"8443", "127.0.0.1", "127.0.0.1:", "127.0.0.1:0", "127.0.0.1:70000", "127.0.0.1:http", "::1:8443"} {
c := loadString(t, "WEB_LISTEN=\""+v+"\"\n")
if c.WebListen != DefaultWebListen || !hasWarning(c, "WEB_LISTEN") {
t.Errorf("%q: listen=%q warnings=%v", v, c.WebListen, c.Warnings)
func TestLoadWebPortValidation(t *testing.T) {
for _, v := range []string{"0", "70000", "-1", "http", "8080x", "127.0.0.1:8080"} {
c := loadString(t, "WEB_PORT=\""+v+"\"\n")
if c.WebPort != DefaultWebPort || !hasWarning(c, "WEB_PORT") {
t.Errorf("%q: port=%d warnings=%v", v, c.WebPort, c.Warnings)
}
}
for _, v := range []string{":8443", "0.0.0.0:443", "localhost:8080"} {
c := loadString(t, "WEB_LISTEN=\""+v+"\"\n")
if c.WebListen != v || len(c.Warnings) != 0 {
t.Errorf("%q: listen=%q warnings=%v", v, c.WebListen, c.Warnings)
for v, want := range map[string]int{"1": 1, "443": 443, " 8081 ": 8081, "65535": 65535} {
c := loadString(t, "WEB_PORT=\""+v+"\"\n")
if c.WebPort != want || len(c.Warnings) != 0 {
t.Errorf("%q: port=%d warnings=%v", v, c.WebPort, c.Warnings)
}
}
}
func TestLoadRemovedWebKeys(t *testing.T) {
c := loadString(t, "WEB_LISTEN=\"127.0.0.1:8443\"\nWEB_TLS_CERT=\"/c.pem\"\nWEB_TLS_KEY=\"/k.pem\"\n")
for _, k := range []string{"WEB_LISTEN wird ignoriert", "WEB_TLS_CERT wird ignoriert", "WEB_TLS_KEY wird ignoriert"} {
if !hasWarning(c, k) {
t.Errorf("fehlende Warnung %q: %v", k, c.Warnings)
}
}
if c.WebPort != DefaultWebPort {
t.Fatalf("WEB_LISTEN darf den Port nicht beeinflussen: %d", c.WebPort)
}
}
func TestNormalizeBasePath(t *testing.T) {
ok := map[string]string{
"": "/",
@@ -313,12 +323,7 @@ func TestNormalizeBasePath(t *testing.T) {
}
}
func TestLoadWebTLSPairWarning(t *testing.T) {
for _, content := range []string{"WEB_TLS_CERT=\"/c.pem\"\n", "WEB_TLS_KEY=\"/k.pem\"\n"} {
if c := loadString(t, content); !hasWarning(c, "WEB_TLS_CERT und WEB_TLS_KEY") {
t.Errorf("%q: warnings=%v", content, c.Warnings)
}
}
func TestLoadWebUserColonWarning(t *testing.T) {
if c := loadString(t, "WEB_USER=\"a:b\"\n"); !hasWarning(c, "WEB_USER") {
t.Errorf("Doppelpunkt im Benutzernamen nicht gewarnt: %v", c.Warnings)
}
+1 -8
View File
@@ -210,7 +210,7 @@ func TestParseHistoryLine(t *testing.T) {
func TestStartWebLifecycle(t *testing.T) {
cfg := webTestConfig(t)
cfg.WebEnabled, cfg.WebListen, cfg.WebBasePath = true, "127.0.0.1:0", "/"
cfg.WebEnabled, cfg.WebPort, cfg.WebBasePath = true, 0, "/"
var buf safeBuffer
d, _ := newTestDaemon(t, cfg)
d.Logger = syslog.New(&buf, "INFO")
@@ -227,13 +227,6 @@ func TestStartWebLifecycle(t *testing.T) {
t.Fatalf("nicht geordnet gestoppt: %s", buf.String())
}
buf.Reset()
cfg.WebTLSCert = "/nur/zertifikat.pem"
stop = d.startWeb(context.Background())
stop()
if out := buf.String(); !strings.Contains(out, "[WARN]") || !strings.Contains(out, "nicht gestartet") || strings.Contains(out, "Web-Statusseite gestartet") {
t.Fatalf("unvollständiges TLS-Paar: %s", out)
}
}
type safeBuffer struct {
+6 -8
View File
@@ -613,7 +613,7 @@ SystemCallArchitectures=native
# CAP_NET_ADMIN: iptables, ip6tables, ipset
# CAP_NET_RAW: iptables-legacy nutzt einen Raw-Socket
# CAP_NET_BIND_SERVICE: nur für WEB_LISTEN mit einem Port unter 1024
# CAP_NET_BIND_SERVICE: nur für WEB_PORT unter 1024
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE
@@ -780,15 +780,13 @@ GEOIP_MMDB_PATH="" # Manueller DB-Pfad (optional, hat Vorrang)
GEOIP_CACHE_TTL=86400 # GeoIP-Cache in Sekunden (0 = kein Ablauf)
# --- Web-Statusseite (optional) ---
# Schreibgeschützte Statusseite (nur Anzeige, keine Aktionen). Zugriff z.B. per SSH-Tunnel:
# ssh -L 8443:127.0.0.1:8443 root@server -> http://127.0.0.1:8443/
# Schreibgeschützte Statusseite (nur Anzeige, keine Aktionen). Spricht nur HTTP auf
# allen Schnittstellen; HTTPS übernimmt ein Reverse-Proxy (nginx, Caddy, ...).
WEB_ENABLED=false
WEB_LISTEN="127.0.0.1:8443" # host:port; Standard nur lokal (Loopback)
WEB_BASE_PATH="/" # Basispfad hinter Reverse-Proxy, z.B. "/adguard-shield/"
WEB_PORT=8080 # HTTP-Port der Statusseite
WEB_BASE_PATH="/" # Unterordner hinter dem Reverse-Proxy, z.B. "/adguard-shield/"
WEB_USER="admin"
WEB_PASSWORD="" # leer = ohne Anmeldung (nur lokal empfohlen); gesetzt = HTTP Basic Auth
WEB_TLS_CERT="" # Zertifikat (PEM); mit WEB_TLS_KEY zusammen = HTTPS
WEB_TLS_KEY="" # Privater Schlüssel (PEM)
WEB_PASSWORD="" # leer = ohne Anmeldung; gesetzt = HTTP Basic Auth
# --- Erweiterte Einstellungen ---
STATE_DIR="/var/lib/adguard-shield" # SQLite-DB: ${STATE_DIR}/adguard-shield.db
+1 -120
View File
@@ -2,20 +2,9 @@ package web
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
@@ -167,21 +156,6 @@ func TestHostCheckWithoutAuth(t *testing.T) {
}
}
func TestHSTSOnlyWithTLS(t *testing.T) {
opts := authOpts
s, _, _ := newServer(t, opts)
if h := do(s, req{path: "/", user: "admin", pass: "s3cret-pass"}).Header().Get("Strict-Transport-Security"); h != "" {
t.Fatalf("HSTS ohne TLS: %q", h)
}
opts.TLSCert, opts.TLSKey = "/x/cert.pem", "/x/key.pem"
s, _, _ = newServer(t, opts)
for _, r := range []req{{path: "/", user: "admin", pass: "s3cret-pass"}, {path: "/api/status"}} {
if h := do(s, r).Header().Get("Strict-Transport-Security"); h != "max-age=31536000" {
t.Fatalf("%s: HSTS = %q", r.path, h)
}
}
}
type slowProvider struct {
entered chan struct{}
once sync.Once
@@ -218,7 +192,7 @@ func serveSlow(t *testing.T, p *slowProvider, timeout time.Duration) (*Server, *
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- s.Serve(ctx, ln, nil) }()
go func() { done <- s.Serve(ctx, ln) }()
go func() {
c := &http.Client{Timeout: 10 * time.Second}
if resp, err := c.Get("http://" + ln.Addr().String() + "/api/status"); err == nil {
@@ -274,96 +248,3 @@ func TestShutdownWaitIsBounded(t *testing.T) {
t.Fatal("nach dem Herunterfahren darf kein Provider-Aufruf mehr starten")
}
}
func writeTestCert(t *testing.T, certFile, keyFile, cn string, mtime time.Time) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: cn},
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), DNSNames: []string{cn},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
kb, err := x509.MarshalECPrivateKey(key)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kb}), 0600); err != nil {
t.Fatal(err)
}
for _, f := range []string{certFile, keyFile} {
if err := os.Chtimes(f, mtime, mtime); err != nil {
t.Fatal(err)
}
}
}
func certCN(t *testing.T, c *tls.Certificate) string {
t.Helper()
leaf, err := x509.ParseCertificate(c.Certificate[0])
if err != nil {
t.Fatal(err)
}
return leaf.Subject.CommonName
}
func TestCertReloader(t *testing.T) {
dir := t.TempDir()
certFile, keyFile := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
mtime := time.Now().Add(-time.Hour).Truncate(time.Second)
writeTestCert(t, certFile, keyFile, "eins", mtime)
log := &memLogger{}
r, err := newCertReloader(certFile, keyFile, log)
if err != nil {
t.Fatal(err)
}
now := time.Unix(1_700_000_000, 0)
r.now = func() time.Time { return now }
r.checkedAt = now
get := func() string {
c, err := r.getCertificate(nil)
if err != nil {
t.Fatal(err)
}
return certCN(t, c)
}
if cn := get(); cn != "eins" {
t.Fatalf("CN %q", cn)
}
writeTestCert(t, certFile, keyFile, "zwei", mtime.Add(time.Minute))
now = now.Add(certCheckEvery / 2)
if cn := get(); cn != "eins" {
t.Fatalf("Dateien dürfen höchstens alle %s geprüft werden: %q", certCheckEvery, cn)
}
now = now.Add(certCheckEvery)
if cn := get(); cn != "zwei" {
t.Fatalf("erneuertes Zertifikat nicht geladen: %q", cn)
}
if log.count("INFO Web-Statusseite: TLS-Zertifikat neu geladen") != 1 {
t.Fatalf("Neuladen nicht geloggt: %v", log.lines)
}
if err := os.WriteFile(certFile, []byte("kaputt"), 0600); err != nil {
t.Fatal(err)
}
_ = os.Chtimes(certFile, mtime.Add(2*time.Minute), mtime.Add(2*time.Minute))
now = now.Add(certCheckEvery)
if cn := get(); cn != "zwei" {
t.Fatalf("defektes Zertifikat muss das bisherige behalten: %q", cn)
}
if !strings.Contains(strings.Join(log.lines, "\n"), "nicht neu geladen") {
t.Fatalf("fehlende Warnung: %v", log.lines)
}
_ = os.Remove(keyFile)
now = now.Add(certCheckEvery)
if cn := get(); cn != "zwei" {
t.Fatalf("fehlende Datei muss das bisherige behalten: %q", cn)
}
}
+15 -41
View File
@@ -3,7 +3,6 @@ package web
import (
"context"
"crypto/tls"
"embed"
"encoding/json"
"errors"
@@ -12,6 +11,7 @@ import (
"net"
"net/http"
"net/netip"
"strconv"
"strings"
"sync"
"time"
@@ -26,7 +26,6 @@ const (
errorLogEvery = time.Minute
contentSecPolicy = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'"
staticCacheHeader = "private, max-age=3600"
hstsHeader = "max-age=31536000"
)
//go:embed assets
@@ -39,9 +38,8 @@ var staticFiles = map[string]string{
}
var (
ErrTLSIncomplete = errors.New("WEB_TLS_CERT und WEB_TLS_KEY müssen gemeinsam gesetzt sein")
ErrNoProvider = errors.New("kein Status-Provider")
errStopping = errors.New("Statusseite wird beendet")
ErrNoProvider = errors.New("kein Status-Provider")
errStopping = errors.New("Statusseite wird beendet")
)
type Logger interface {
@@ -57,26 +55,27 @@ func (nopLogger) Warnf(string, ...any) {}
func (nopLogger) Errorf(string, ...any) {}
type Options struct {
// Listen ist host:port; leerer Host = alle Schnittstellen.
Listen string
BasePath string
User string
// Password leer = Statusseite ohne Anmeldung.
Password string
TLSCert string
TLSKey string
Version string
Hostname string
}
// OptionsFromConfig übernimmt die WEB_*-Schlüssel.
// OptionsFromConfig übernimmt die WEB_*-Schlüssel. Die Seite lauscht auf allen
// Schnittstellen; HTTPS übernimmt bei Bedarf ein Reverse-Proxy.
func OptionsFromConfig(c *config.Config) Options {
return Options{
Listen: c.WebListen, BasePath: c.WebBasePath,
Listen: listenAddr(c.WebPort), BasePath: c.WebBasePath,
User: c.WebUser, Password: c.WebPassword,
TLSCert: c.WebTLSCert, TLSKey: c.WebTLSKey,
}
}
func listenAddr(port int) string { return net.JoinHostPort("", strconv.Itoa(port)) }
type Server struct {
opts Options
base string
@@ -108,11 +107,8 @@ func New(opts Options, p Provider, log Logger) (*Server, error) {
if log == nil {
log = nopLogger{}
}
if (opts.TLSCert == "") != (opts.TLSKey == "") {
return nil, ErrTLSIncomplete
}
if opts.Listen == "" {
opts.Listen = config.DefaultWebListen
opts.Listen = listenAddr(config.DefaultWebPort)
}
if _, _, err := net.SplitHostPort(opts.Listen); err != nil {
return nil, fmt.Errorf("ungültige Adresse %q: %w", opts.Listen, err)
@@ -133,8 +129,6 @@ func New(opts Options, p Provider, log Logger) (*Server, error) {
return s, nil
}
func (s *Server) TLS() bool { return s.opts.TLSCert != "" }
func (s *Server) AuthEnabled() bool { return s.auth != nil }
// StartupWarnings beschreibt unsichere, aber zulässige Konfigurationen.
@@ -144,10 +138,7 @@ func (s *Server) StartupWarnings() []string {
return nil
}
if s.auth == nil {
return []string{fmt.Sprintf("Web-Statusseite ohne Anmeldung auf %s erreichbar – WEB_PASSWORD setzen oder an 127.0.0.1 binden", s.opts.Listen)}
}
if !s.TLS() {
return []string{fmt.Sprintf("Web-Statusseite auf %s ohne TLS: Basic-Auth-Zugangsdaten werden im Klartext übertragen – WEB_TLS_CERT/WEB_TLS_KEY setzen, an 127.0.0.1 binden (SSH-Tunnel) oder einen HTTPS-Reverse-Proxy nutzen", s.opts.Listen)}
return []string{fmt.Sprintf("Web-Statusseite ohne Anmeldung auf %s erreichbar – WEB_PASSWORD setzen oder den Port per Firewall auf den Reverse-Proxy beschränken", s.opts.Listen)}
}
return nil
}
@@ -163,22 +154,14 @@ func isLoopbackHost(host string) bool {
// Run lauscht auf Options.Listen, bis ctx endet, und fährt den Server dann
// geordnet herunter.
func (s *Server) Run(ctx context.Context) error {
var tlsCfg *tls.Config
if s.TLS() {
certs, err := newCertReloader(s.opts.TLSCert, s.opts.TLSKey, s.log)
if err != nil {
return fmt.Errorf("TLS-Zertifikat: %w", err)
}
tlsCfg = &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: certs.getCertificate}
}
ln, err := net.Listen("tcp", s.opts.Listen)
if err != nil {
return err
}
return s.Serve(ctx, ln, tlsCfg)
return s.Serve(ctx, ln)
}
func (s *Server) Serve(ctx context.Context, ln net.Listener, tlsCfg *tls.Config) error {
func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
for _, w := range s.StartupWarnings() {
s.log.Warnf("%s", w)
}
@@ -189,12 +172,6 @@ func (s *Server) Serve(ctx context.Context, ln net.Listener, tlsCfg *tls.Config)
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 16 << 10,
TLSConfig: tlsCfg,
}
scheme := "http"
if tlsCfg != nil {
scheme = "https"
ln = tls.NewListener(ln, tlsCfg)
}
errc := make(chan error, 1)
go func() { errc <- srv.Serve(ln) }()
@@ -202,7 +179,7 @@ func (s *Server) Serve(ctx context.Context, ln net.Listener, tlsCfg *tls.Config)
if s.auth != nil {
auth = "Basic Auth"
}
s.log.Infof("Web-Statusseite gestartet: %s://%s%s (Anmeldung: %s)", scheme, ln.Addr(), s.base, auth)
s.log.Infof("Web-Statusseite gestartet: http://%s%s (Anmeldung: %s)", ln.Addr(), s.base, auth)
select {
case err := <-errc:
s.finish()
@@ -264,9 +241,6 @@ func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) {
h.Set("Referrer-Policy", "no-referrer")
h.Set("Cross-Origin-Opener-Policy", "same-origin")
h.Set("Cross-Origin-Resource-Policy", "same-origin")
if s.TLS() {
h.Set("Strict-Transport-Security", hstsHeader)
}
if s.auth == nil && !s.hostAllowed(r.Host) {
http.Error(w, "Unbekannter Host", http.StatusMisdirectedRequest)
return
@@ -308,7 +282,7 @@ func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) {
}
// hostAllowed schützt die Seite ohne Anmeldung vor DNS-Rebinding: Erlaubt
// sind nur IP-Literale, localhost und der Host aus WEB_LISTEN.
// sind nur IP-Literale, localhost und der Host aus Options.Listen.
func (s *Server) hostAllowed(host string) bool {
if host == "" {
return true
+16 -16
View File
@@ -13,6 +13,8 @@ import (
"sync/atomic"
"testing"
"time"
"adguard-shield/internal/config"
)
const maliciousDomain = `<script>alert(1)</script>.evil.example`
@@ -387,24 +389,23 @@ func TestStatusProviderError(t *testing.T) {
}
}
func TestNewRejectsIncompleteTLS(t *testing.T) {
for _, o := range []Options{{TLSCert: "/x/cert.pem"}, {TLSKey: "/x/key.pem"}} {
if _, err := New(o, &countingProvider{}, nil); !errors.Is(err, ErrTLSIncomplete) {
t.Fatalf("%+v: err = %v", o, err)
}
}
if _, err := New(Options{TLSCert: "/x/c", TLSKey: "/x/k"}, &countingProvider{}, nil); err != nil {
t.Fatalf("vollständiges TLS-Paar abgelehnt: %v", err)
}
func TestNewRejectsInvalidOptions(t *testing.T) {
if _, err := New(Options{BasePath: "/../etc"}, &countingProvider{}, nil); err == nil {
t.Fatal("ungültiger Basispfad akzeptiert")
}
if _, err := New(Options{Listen: "8080"}, &countingProvider{}, nil); err == nil {
t.Fatal("ungültige Adresse akzeptiert")
}
s, _, _ := newServer(t, Options{})
if s.opts.Listen != ":8080" {
t.Fatalf("Standardadresse %q", s.opts.Listen)
}
}
func TestRunFailsWithMissingTLSFiles(t *testing.T) {
s, _, _ := newServer(t, Options{Listen: "127.0.0.1:0", TLSCert: "/nonexistent/c.pem", TLSKey: "/nonexistent/k.pem"})
if err := s.Run(context.Background()); err == nil {
t.Fatal("Run ohne lesbares Zertifikat muss fehlschlagen")
func TestOptionsFromConfig(t *testing.T) {
o := OptionsFromConfig(&config.Config{WebPort: 9090, WebBasePath: "/x/", WebUser: "u", WebPassword: "p"})
if o.Listen != ":9090" || o.BasePath != "/x/" || o.User != "u" || o.Password != "p" {
t.Fatalf("%+v", o)
}
}
@@ -418,8 +419,7 @@ func TestStartupWarnings(t *testing.T) {
{Options{Listen: "localhost:8443"}, ""},
{Options{Listen: "0.0.0.0:8443"}, "ohne Anmeldung auf 0.0.0.0:8443"},
{Options{Listen: ":8443"}, "ohne Anmeldung"},
{Options{Listen: "192.0.2.1:8443", Password: "x"}, "Klartext"},
{Options{Listen: "192.0.2.1:8443", Password: "x", TLSCert: "c", TLSKey: "k"}, ""},
{Options{Listen: ":8080", Password: "x"}, ""},
}
for _, c := range cases {
s, _, _ := newServer(t, c.opts)
@@ -439,7 +439,7 @@ func TestServeAndGracefulShutdown(t *testing.T) {
addr := ln.Addr().String()
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- s.Serve(ctx, ln, nil) }()
go func() { done <- s.Serve(ctx, ln) }()
r, _ := http.NewRequest(http.MethodGet, "http://"+addr+"/api/status", nil)
r.SetBasicAuth("admin", "s3cret-pass")
-77
View File
@@ -1,77 +0,0 @@
package web
import (
"crypto/tls"
"fmt"
"os"
"sync"
"time"
)
const certCheckEvery = time.Minute
// certReloader liefert das TLS-Zertifikat und lädt es neu, sobald sich
// Zertifikat- oder Schlüsseldatei ändern (z.B. nach einer Let's-Encrypt-
// Erneuerung). Die Dateien werden höchstens alle certCheckEvery geprüft.
type certReloader struct {
certFile, keyFile string
log Logger
now func() time.Time
mu sync.Mutex
cert *tls.Certificate
stamp string
checkedAt time.Time
}
func newCertReloader(certFile, keyFile string, log Logger) (*certReloader, error) {
c := &certReloader{certFile: certFile, keyFile: keyFile, log: log, now: time.Now}
stamp, err := c.fileStamp()
if err != nil {
return nil, err
}
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, err
}
c.cert, c.stamp, c.checkedAt = &cert, stamp, c.now()
return c, nil
}
func (c *certReloader) fileStamp() (string, error) {
var stamp string
for _, f := range []string{c.certFile, c.keyFile} {
fi, err := os.Stat(f)
if err != nil {
return "", err
}
stamp += fmt.Sprintf("%d/%d;", fi.ModTime().UnixNano(), fi.Size())
}
return stamp, nil
}
func (c *certReloader) getCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
c.mu.Lock()
defer c.mu.Unlock()
now := c.now()
if now.Sub(c.checkedAt) < certCheckEvery {
return c.cert, nil
}
c.checkedAt = now
stamp, err := c.fileStamp()
if err != nil {
c.log.Warnf("Web-Statusseite: TLS-Zertifikat nicht prüfbar, nutze bisheriges: %v", err)
return c.cert, nil
}
if stamp == c.stamp {
return c.cert, nil
}
cert, err := tls.LoadX509KeyPair(c.certFile, c.keyFile)
if err != nil {
c.log.Warnf("Web-Statusseite: TLS-Zertifikat nicht neu geladen, nutze bisheriges: %v", err)
return c.cert, nil
}
c.cert, c.stamp = &cert, stamp
c.log.Infof("Web-Statusseite: TLS-Zertifikat neu geladen")
return c.cert, nil
}