Release/v1.2.1 #26
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user