Initial
This commit is contained in:
7
.env.example
Normal file
7
.env.example
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
EXTERNAL_WHITELIST_AUTH_REALM=Protected Area
|
||||||
|
EXTERNAL_WHITELIST_AUTH_USER=admin
|
||||||
|
EXTERNAL_WHITELIST_AUTH_PASSWORD=change-me-long-random-password
|
||||||
|
EXTERNAL_WHITELIST_AUTH_USER_2=user2
|
||||||
|
EXTERNAL_WHITELIST_AUTH_PASSWORD_2=change-me-too-long-random-password
|
||||||
|
EXTERNAL_WHITELIST_AUTH_USER_3=user3
|
||||||
|
EXTERNAL_WHITELIST_AUTH_PASSWORD_3=change-me-also-long-random-password
|
||||||
10
Dockerfile
Normal file
10
Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
FROM python:3.13-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
COPY external_whitelist_auth_gate.py /app/external_whitelist_auth_gate.py
|
||||||
|
|
||||||
|
USER nobody
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["python", "/app/external_whitelist_auth_gate.py"]
|
||||||
217
README.md
217
README.md
@@ -1,28 +1,229 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://techniverse.net">
|
<a href="https://techniverse.net">
|
||||||
<img src="https://assets.techniverse.net/f1/git/graphics/repo-techniverse-logo.png" alt="Techniverse Community" height="70" />
|
<img src="https://assets.techniverse.net/f1/git/graphics/repo-techniverse-logo.png" alt="Techniverse-Community" height="70" />
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1 align="center">Name des Projekts</h1>
|
<h1 align="center">Traefik-External Whitelist Auth Gate</h1>
|
||||||
|
|
||||||
<h4 align="center">
|
<h4 align="center">
|
||||||
Kurzbeschreibung des Projekts/Anwendung, um die es geht
|
Kleiner Traefik-ForwardAuth-Dienst mit externer Whitelist und BasicAuth-Ausweichlösung
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<h6 align="center">
|
<h6 align="center">
|
||||||
<a href="https://www.cleveradmin.de">🏰 Website</a>
|
<a href="https://www.cleveradmin.de">🏰 Webseite</a>
|
||||||
·
|
·
|
||||||
<a href="https://techniverse.net">📰 Community</a>
|
<a href="https://techniverse.net">📰 Gemeinschaft</a>
|
||||||
·
|
·
|
||||||
<a href="https://social.techniverse.net/@donnerwolke">🐘 Mastodon</a>
|
<a href="https://social.techniverse.net/@donnerwolke">🐘 Mastodon</a>
|
||||||
·
|
·
|
||||||
<a href="https://matrix.to/#/#support:techniverse.net">💬 Support</a>
|
<a href="https://matrix.to/#/#support:techniverse.net">💬 Hilfe</a>
|
||||||
</h6>
|
</h6>
|
||||||
<br><br>
|
<br><br>
|
||||||
|
|
||||||
|
|
||||||
CONTENT BEREICH
|
Kleiner Traefik-ForwardAuth-Dienst für dieses Zugriffsmuster:
|
||||||
|
|
||||||
|
- Wenn sich die Client-IP in einer externen Whitelist befindet, wird die Anfrage erlaubt.
|
||||||
|
- Wenn sich die Client-IP nicht in der Whitelist befindet, wird BasicAuth angefordert.
|
||||||
|
- Die externe Whitelist wird alle 5 Minuten aktualisiert und aufgelöst.
|
||||||
|
- Die letzte gültige Whitelist bleibt erhalten, falls die externe Datei oder DNS-Abfragen fehlschlagen.
|
||||||
|
|
||||||
|
Die externe Whitelist kann DNS-Namen, IP-Adressen und CIDR-Bereiche enthalten.
|
||||||
|
|
||||||
|
## Dateien
|
||||||
|
|
||||||
|
- `external_whitelist_auth_gate.py`: der ForwardAuth-Dienst.
|
||||||
|
- `Dockerfile`: Container-Image für den Dienst.
|
||||||
|
- `docker-compose.example.yaml`: minimales Traefik-Compose-Beispiel.
|
||||||
|
- `traefik-dynamic.example.yml`: Traefik-Middleware-Definition.
|
||||||
|
- `protected-router.example.yml`: Beispiel-Router, der die Middleware verwendet.
|
||||||
|
- `.env.example`: Beispiel-Zugangsdaten.
|
||||||
|
- `whitelist.example.txt`: externe Beispiel-Whitelist-Datei.
|
||||||
|
|
||||||
|
## Whitelist-Format
|
||||||
|
|
||||||
|
Veröffentliche eine Klartextdatei, die vom Container erreichbar ist:
|
||||||
|
|
||||||
|
```text
|
||||||
|
# Kommentare sind erlaubt
|
||||||
|
office.example.com
|
||||||
|
vpn.example.com
|
||||||
|
203.0.113.42
|
||||||
|
198.51.100.0/24
|
||||||
|
2001:db8:1234::/48
|
||||||
|
```
|
||||||
|
|
||||||
|
DNS-Einträge werden in A- und AAAA-Records aufgelöst. Einzelne IPv4-Adressen werden zu `/32`, einzelne IPv6-Adressen zu `/128`.
|
||||||
|
|
||||||
|
## Einrichtung
|
||||||
|
|
||||||
|
1. Kopiere diesen Ordner in dein Traefik-Projekt:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/path/to/traefik/external-whitelist-auth-gate
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Füge den Dienst zu deiner Traefik-`docker-compose.yaml` hinzu:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
external-whitelist-auth-gate:
|
||||||
|
build:
|
||||||
|
context: ./external-whitelist-auth-gate
|
||||||
|
image: local/external-whitelist-auth-gate:1.0
|
||||||
|
container_name: external-whitelist-auth-gate
|
||||||
|
restart: unless-stopped
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
environment:
|
||||||
|
WHITELIST_URL: "https://example.com/network-whitelist.txt"
|
||||||
|
REFRESH_INTERVAL_SECONDS: "300"
|
||||||
|
BASIC_AUTH_REALM: "${EXTERNAL_WHITELIST_AUTH_REALM:-Geschützter Bereich}"
|
||||||
|
BASIC_AUTH_USER: "${EXTERNAL_WHITELIST_AUTH_USER:?EXTERNAL_WHITELIST_AUTH_USER in .env setzen}"
|
||||||
|
BASIC_AUTH_PASSWORD: "${EXTERNAL_WHITELIST_AUTH_PASSWORD:?EXTERNAL_WHITELIST_AUTH_PASSWORD in .env setzen}"
|
||||||
|
BASIC_AUTH_USER_2: "${EXTERNAL_WHITELIST_AUTH_USER_2:-}"
|
||||||
|
BASIC_AUTH_PASSWORD_2: "${EXTERNAL_WHITELIST_AUTH_PASSWORD_2:-}"
|
||||||
|
BASIC_AUTH_PASSWORD_SHA256_2: "${EXTERNAL_WHITELIST_AUTH_PASSWORD_SHA256_2:-}"
|
||||||
|
BASIC_AUTH_USER_3: "${EXTERNAL_WHITELIST_AUTH_USER_3:-}"
|
||||||
|
BASIC_AUTH_PASSWORD_3: "${EXTERNAL_WHITELIST_AUTH_PASSWORD_3:-}"
|
||||||
|
BASIC_AUTH_PASSWORD_SHA256_3: "${EXTERNAL_WHITELIST_AUTH_PASSWORD_SHA256_3:-}"
|
||||||
|
CLIENT_IP_STRATEGY: "rightmost"
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:9180:8080"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Füge die Zugangsdaten zu `.env` hinzu:
|
||||||
|
|
||||||
|
```env
|
||||||
|
EXTERNAL_WHITELIST_AUTH_REALM=Geschützter Bereich
|
||||||
|
EXTERNAL_WHITELIST_AUTH_USER=admin
|
||||||
|
EXTERNAL_WHITELIST_AUTH_PASSWORD=bitte-aendern-langes-zufaelliges-passwort
|
||||||
|
EXTERNAL_WHITELIST_AUTH_USER_2=team
|
||||||
|
EXTERNAL_WHITELIST_AUTH_PASSWORD_2=zweites-langes-zufaelliges-passwort
|
||||||
|
EXTERNAL_WHITELIST_AUTH_USER_3=support
|
||||||
|
EXTERNAL_WHITELIST_AUTH_PASSWORD_3=drittes-langes-zufaelliges-passwort
|
||||||
|
```
|
||||||
|
|
||||||
|
Der erste BasicAuth-Benutzer ist Pflicht. Benutzer 2 und 3 sind optional; lasse die zugehörigen `.env`-Variablen leer oder entferne sie, wenn du sie nicht brauchst.
|
||||||
|
|
||||||
|
4. Füge die Traefik-Middleware in einer File-Provider-Konfiguration hinzu, zum Beispiel in `dynamic/external-whitelist-auth.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
http:
|
||||||
|
middlewares:
|
||||||
|
external-whitelist-auth:
|
||||||
|
forwardAuth:
|
||||||
|
address: "http://127.0.0.1:9180/auth"
|
||||||
|
trustForwardHeader: false
|
||||||
|
maxResponseBodySize: 8192
|
||||||
|
authResponseHeaders:
|
||||||
|
- X-Access-Gate-User
|
||||||
|
- X-Access-Gate-Reason
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Hänge die Middleware nur an HTTPS-Router, die geschützt werden sollen:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
middlewares:
|
||||||
|
- external-whitelist-auth@file
|
||||||
|
- deine-bestehenden-header
|
||||||
|
```
|
||||||
|
|
||||||
|
Belasse HTTP-Router als reine Weiterleitungen auf HTTPS. Andernfalls können Benutzer BasicAuth sehen, bevor die Weiterleitung erfolgt.
|
||||||
|
|
||||||
|
6. Starte den Dienst oder baue ihn neu:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build external-whitelist-auth-gate
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Betrieb
|
||||||
|
|
||||||
|
Status prüfen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://127.0.0.1:9180/status
|
||||||
|
```
|
||||||
|
|
||||||
|
Erwartete Antwort:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"last_error":"","last_refresh":1778437250,"network_count":5,"source_hosts":["office.example.com","vpn.example.com"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
Aufgelöste IPs/CIDRs anzeigen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "http://127.0.0.1:9180/status?verbose=1"
|
||||||
|
```
|
||||||
|
|
||||||
|
Konkrete IP gegen die aktuell geladene Whitelist prüfen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "http://127.0.0.1:9180/check?ip=203.0.113.42"
|
||||||
|
```
|
||||||
|
|
||||||
|
Erwartete Antwort, wenn die IP enthalten ist:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"allowlisted":true,"ip":"203.0.113.42","ip_source":"query","matched_networks":["203.0.113.42/32"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
Die von Headern erkannte Client-IP prüfen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -H "X-Forwarded-For: 203.0.113.42" http://127.0.0.1:9180/check
|
||||||
|
```
|
||||||
|
|
||||||
|
Eine nicht in der Whitelist enthaltene IP testen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i -H "X-Forwarded-For: 203.0.113.200" http://127.0.0.1:9180/auth
|
||||||
|
```
|
||||||
|
|
||||||
|
BasicAuth testen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i -u "admin:bitte-aendern-langes-zufaelliges-passwort" -H "X-Forwarded-For: 203.0.113.200" http://127.0.0.1:9180/auth
|
||||||
|
```
|
||||||
|
|
||||||
|
Optionalen zweiten oder dritten BasicAuth-Benutzer testen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i -u "team:zweites-langes-zufaelliges-passwort" -H "X-Forwarded-For: 203.0.113.200" http://127.0.0.1:9180/auth
|
||||||
|
```
|
||||||
|
|
||||||
|
## Umgebungsvariablen
|
||||||
|
|
||||||
|
- `WHITELIST_URL`: URL der externen Whitelist-Textdatei.
|
||||||
|
- `REFRESH_INTERVAL_SECONDS`: Aktualisierungsintervall, Standardwert `300`.
|
||||||
|
- `BASIC_AUTH_REALM`: BasicAuth-Realm des Browsers.
|
||||||
|
- `BASIC_AUTH_USER`: erster BasicAuth-Benutzername.
|
||||||
|
- `BASIC_AUTH_PASSWORD`: Passwort des ersten BasicAuth-Benutzers.
|
||||||
|
- `BASIC_AUTH_PASSWORD_SHA256`: optionale Alternative zu `BASIC_AUTH_PASSWORD`; Hex-SHA-256-Digest des ersten Passworts.
|
||||||
|
- `BASIC_AUTH_USER_2`: optionaler zweiter BasicAuth-Benutzername.
|
||||||
|
- `BASIC_AUTH_PASSWORD_2`: optionales Passwort des zweiten BasicAuth-Benutzers.
|
||||||
|
- `BASIC_AUTH_PASSWORD_SHA256_2`: optionale Alternative zu `BASIC_AUTH_PASSWORD_2`; Hex-SHA-256-Digest des zweiten Passworts.
|
||||||
|
- `BASIC_AUTH_USER_3`: optionaler dritter BasicAuth-Benutzername.
|
||||||
|
- `BASIC_AUTH_PASSWORD_3`: optionales Passwort des dritten BasicAuth-Benutzers.
|
||||||
|
- `BASIC_AUTH_PASSWORD_SHA256_3`: optionale Alternative zu `BASIC_AUTH_PASSWORD_3`; Hex-SHA-256-Digest des dritten Passworts.
|
||||||
|
- `WHITELIST_EXTRA_CIDRS`: optionale, kommagetrennte CIDR/IP-Liste, die zur externen Whitelist hinzugefügt wird.
|
||||||
|
- `CLIENT_IP_STRATEGY`: standardmäßig `rightmost`, alternativ `leftmost`.
|
||||||
|
- `CLIENT_IP_HEADER`: optionaler benutzerdefinierter Client-IP-Header; standardmäßig `X-Forwarded-For`.
|
||||||
|
|
||||||
|
## Hinweise
|
||||||
|
|
||||||
|
Der Dienst ist für Traefiks `forwardAuth`-Middleware ausgelegt. Er gibt `204` zurück, wenn der Zugriff erlaubt ist, und `401` mit `WWW-Authenticate`, wenn BasicAuth erforderlich ist.
|
||||||
|
|
||||||
|
Wenn Traefik mit `network_mode: host` läuft, ist das Binden des Auth-Dienstes an `127.0.0.1:9180` einfach und verhindert, dass er öffentlich erreichbar ist.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<br><br>
|
<br><br>
|
||||||
@@ -34,4 +235,4 @@ CONTENT BEREICH
|
|||||||
<sub>
|
<sub>
|
||||||
© Patrick Asmus · Techniverse Network · <a href="./LICENSE">Lizenz</a>
|
© Patrick Asmus · Techniverse Network · <a href="./LICENSE">Lizenz</a>
|
||||||
</sub>
|
</sub>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
BIN
__pycache__/external_whitelist_auth_gate.cpython-314.pyc
Normal file
BIN
__pycache__/external_whitelist_auth_gate.cpython-314.pyc
Normal file
Binary file not shown.
59
docker-compose.example.yaml
Normal file
59
docker-compose.example.yaml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
name: traefik
|
||||||
|
|
||||||
|
services:
|
||||||
|
traefik:
|
||||||
|
image: traefik:v3.6
|
||||||
|
container_name: traefik
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: host
|
||||||
|
command:
|
||||||
|
- "--entrypoints.web.address=:80"
|
||||||
|
- "--entrypoints.websecure.address=:443"
|
||||||
|
- "--providers.file.directory=/etc/traefik/dynamic"
|
||||||
|
- "--providers.file.watch=true"
|
||||||
|
volumes:
|
||||||
|
- ./dynamic:/etc/traefik/dynamic:ro
|
||||||
|
depends_on:
|
||||||
|
- external-whitelist-auth-gate
|
||||||
|
|
||||||
|
external-whitelist-auth-gate:
|
||||||
|
build:
|
||||||
|
context: ./external-whitelist-auth-gate
|
||||||
|
image: local/external-whitelist-auth-gate:1.0
|
||||||
|
container_name: external-whitelist-auth-gate
|
||||||
|
restart: unless-stopped
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
environment:
|
||||||
|
WHITELIST_URL: "https://example.com/network-whitelist.txt"
|
||||||
|
REFRESH_INTERVAL_SECONDS: "300"
|
||||||
|
BASIC_AUTH_REALM: "${EXTERNAL_WHITELIST_AUTH_REALM:-Protected Area}"
|
||||||
|
BASIC_AUTH_USER: "${EXTERNAL_WHITELIST_AUTH_USER:?set EXTERNAL_WHITELIST_AUTH_USER in .env}"
|
||||||
|
BASIC_AUTH_PASSWORD: "${EXTERNAL_WHITELIST_AUTH_PASSWORD:?set EXTERNAL_WHITELIST_AUTH_PASSWORD in .env}"
|
||||||
|
BASIC_AUTH_USER_2: "${EXTERNAL_WHITELIST_AUTH_USER_2:-}"
|
||||||
|
BASIC_AUTH_PASSWORD_2: "${EXTERNAL_WHITELIST_AUTH_PASSWORD_2:-}"
|
||||||
|
BASIC_AUTH_PASSWORD_SHA256_2: "${EXTERNAL_WHITELIST_AUTH_PASSWORD_SHA256_2:-}"
|
||||||
|
BASIC_AUTH_USER_3: "${EXTERNAL_WHITELIST_AUTH_USER_3:-}"
|
||||||
|
BASIC_AUTH_PASSWORD_3: "${EXTERNAL_WHITELIST_AUTH_PASSWORD_3:-}"
|
||||||
|
BASIC_AUTH_PASSWORD_SHA256_3: "${EXTERNAL_WHITELIST_AUTH_PASSWORD_SHA256_3:-}"
|
||||||
|
CLIENT_IP_STRATEGY: "rightmost"
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:9180:8080"
|
||||||
|
networks:
|
||||||
|
traefik_backend:
|
||||||
|
ipv4_address: 172.23.93.11
|
||||||
|
|
||||||
|
networks:
|
||||||
|
traefik_backend:
|
||||||
|
name: traefik-backend.dockernetwork.local
|
||||||
|
driver: bridge
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: 172.23.93.0/24
|
||||||
|
gateway: 172.23.93.1
|
||||||
411
external_whitelist_auth_gate.py
Normal file
411
external_whitelist_auth_gate.py
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
|
||||||
|
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, LOG_LEVEL, logging.INFO),
|
||||||
|
format="%(asctime)s %(levelname)s %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
WHITELIST_URL = os.getenv(
|
||||||
|
"WHITELIST_URL",
|
||||||
|
"https://example.com/network-whitelist.txt",
|
||||||
|
)
|
||||||
|
REFRESH_INTERVAL_SECONDS = int(os.getenv("REFRESH_INTERVAL_SECONDS", "300"))
|
||||||
|
LISTEN_ADDRESS = os.getenv("LISTEN_ADDRESS", "0.0.0.0")
|
||||||
|
LISTEN_PORT = int(os.getenv("LISTEN_PORT", "8080"))
|
||||||
|
BASIC_AUTH_REALM = os.getenv("BASIC_AUTH_REALM", "Protected Area")
|
||||||
|
CLIENT_IP_STRATEGY = os.getenv("CLIENT_IP_STRATEGY", "rightmost").lower()
|
||||||
|
EXTRA_CIDRS = os.getenv("WHITELIST_EXTRA_CIDRS", "")
|
||||||
|
MAX_BASIC_AUTH_USERS = 3
|
||||||
|
|
||||||
|
|
||||||
|
class WhitelistStore:
|
||||||
|
def __init__(self):
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._networks = []
|
||||||
|
self._source_hosts = []
|
||||||
|
self._resolved_entries = []
|
||||||
|
self._last_refresh = 0
|
||||||
|
self._last_error = ""
|
||||||
|
|
||||||
|
def snapshot(self, include_details=False):
|
||||||
|
with self._lock:
|
||||||
|
data = {
|
||||||
|
"network_count": len(self._networks),
|
||||||
|
"source_hosts": list(self._source_hosts),
|
||||||
|
"last_refresh": self._last_refresh,
|
||||||
|
"last_error": self._last_error,
|
||||||
|
}
|
||||||
|
if include_details:
|
||||||
|
data["networks"] = [str(network) for network in self._networks]
|
||||||
|
data["resolved_entries"] = [
|
||||||
|
{
|
||||||
|
"entry": item["entry"],
|
||||||
|
"networks": list(item["networks"]),
|
||||||
|
}
|
||||||
|
for item in self._resolved_entries
|
||||||
|
]
|
||||||
|
return data
|
||||||
|
|
||||||
|
def contains(self, ip):
|
||||||
|
with self._lock:
|
||||||
|
return any(ip in network for network in self._networks)
|
||||||
|
|
||||||
|
def matches(self, ip):
|
||||||
|
with self._lock:
|
||||||
|
return [str(network) for network in self._networks if ip in network]
|
||||||
|
|
||||||
|
def refresh_forever(self):
|
||||||
|
while True:
|
||||||
|
self.refresh_once()
|
||||||
|
time.sleep(REFRESH_INTERVAL_SECONDS)
|
||||||
|
|
||||||
|
def refresh_once(self):
|
||||||
|
try:
|
||||||
|
entries = self._load_entries()
|
||||||
|
networks = []
|
||||||
|
source_hosts = []
|
||||||
|
resolved_entries = []
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
resolved = resolve_entry(entry)
|
||||||
|
resolved = sorted(set(resolved), key=network_sort_key)
|
||||||
|
resolved_entries.append({
|
||||||
|
"entry": entry,
|
||||||
|
"networks": [str(network) for network in resolved],
|
||||||
|
})
|
||||||
|
if resolved:
|
||||||
|
networks.extend(resolved)
|
||||||
|
source_hosts.append(entry)
|
||||||
|
|
||||||
|
for cidr in parse_extra_cidrs(EXTRA_CIDRS):
|
||||||
|
networks.append(cidr)
|
||||||
|
|
||||||
|
networks = sorted(set(networks), key=network_sort_key)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._networks = networks
|
||||||
|
self._source_hosts = source_hosts
|
||||||
|
self._resolved_entries = resolved_entries
|
||||||
|
self._last_refresh = int(time.time())
|
||||||
|
self._last_error = ""
|
||||||
|
|
||||||
|
logging.info("whitelist refreshed: %s networks from %s source entries", len(networks), len(source_hosts))
|
||||||
|
except Exception as exc:
|
||||||
|
with self._lock:
|
||||||
|
self._last_error = str(exc)
|
||||||
|
logging.exception("whitelist refresh failed; keeping previous whitelist")
|
||||||
|
|
||||||
|
def _load_entries(self):
|
||||||
|
with urllib.request.urlopen(WHITELIST_URL, timeout=20) as response:
|
||||||
|
body = response.read().decode("utf-8")
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
for raw_line in body.splitlines():
|
||||||
|
line = raw_line.split("#", 1)[0].strip()
|
||||||
|
if line:
|
||||||
|
entries.append(line)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def network_sort_key(network):
|
||||||
|
return (network.version, int(network.network_address), network.prefixlen)
|
||||||
|
|
||||||
|
|
||||||
|
def query_flag(query, name):
|
||||||
|
value = query.get(name, [""])[0].lower()
|
||||||
|
return value in ("1", "true", "yes", "on", "full")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_extra_cidrs(value):
|
||||||
|
networks = []
|
||||||
|
for raw_item in value.replace("\n", ",").split(","):
|
||||||
|
item = raw_item.strip()
|
||||||
|
if not item:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
networks.append(ipaddress.ip_network(item, strict=False))
|
||||||
|
except ValueError:
|
||||||
|
logging.warning("ignored invalid extra CIDR: %s", item)
|
||||||
|
return networks
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_entry(entry):
|
||||||
|
try:
|
||||||
|
return [ipaddress.ip_network(entry, strict=False)]
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
networks = []
|
||||||
|
try:
|
||||||
|
answers = socket.getaddrinfo(entry, None, type=socket.SOCK_STREAM)
|
||||||
|
except socket.gaierror as exc:
|
||||||
|
logging.warning("DNS lookup failed for %s: %s", entry, exc)
|
||||||
|
return networks
|
||||||
|
|
||||||
|
for family, _, _, _, sockaddr in answers:
|
||||||
|
ip_text = sockaddr[0]
|
||||||
|
ip = ipaddress.ip_address(ip_text)
|
||||||
|
if family == socket.AF_INET:
|
||||||
|
networks.append(ipaddress.ip_network(f"{ip}/32", strict=False))
|
||||||
|
elif family == socket.AF_INET6:
|
||||||
|
networks.append(ipaddress.ip_network(f"{ip}/128", strict=False))
|
||||||
|
|
||||||
|
return list(set(networks))
|
||||||
|
|
||||||
|
|
||||||
|
def request_client_ip(handler):
|
||||||
|
configured_header = os.getenv("CLIENT_IP_HEADER", "").strip()
|
||||||
|
header_value = handler.headers.get(configured_header) if configured_header else ""
|
||||||
|
header_value = header_value or handler.headers.get("X-Forwarded-For", "")
|
||||||
|
|
||||||
|
candidates = []
|
||||||
|
for part in header_value.split(","):
|
||||||
|
value = part.strip()
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
candidates.append(ipaddress.ip_address(value))
|
||||||
|
except ValueError:
|
||||||
|
logging.warning("ignored invalid forwarded IP: %s", value)
|
||||||
|
|
||||||
|
if candidates:
|
||||||
|
return candidates[0] if CLIENT_IP_STRATEGY == "leftmost" else candidates[-1]
|
||||||
|
|
||||||
|
real_ip = handler.headers.get("X-Real-IP", "").strip()
|
||||||
|
if real_ip:
|
||||||
|
try:
|
||||||
|
return ipaddress.ip_address(real_ip)
|
||||||
|
except ValueError:
|
||||||
|
logging.warning("ignored invalid X-Real-IP: %s", real_ip)
|
||||||
|
|
||||||
|
return ipaddress.ip_address(handler.client_address[0])
|
||||||
|
|
||||||
|
|
||||||
|
def authorization_scheme(header_value):
|
||||||
|
if not header_value:
|
||||||
|
return "none"
|
||||||
|
return header_value.split(None, 1)[0].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def configured_basic_auth_users():
|
||||||
|
users = []
|
||||||
|
|
||||||
|
legacy_user = os.getenv("BASIC_AUTH_USER", "").strip()
|
||||||
|
legacy_password = os.getenv("BASIC_AUTH_PASSWORD", "")
|
||||||
|
legacy_password_sha256 = os.getenv("BASIC_AUTH_PASSWORD_SHA256", "")
|
||||||
|
if legacy_user:
|
||||||
|
users.append({
|
||||||
|
"username": legacy_user,
|
||||||
|
"password": legacy_password,
|
||||||
|
"password_sha256": legacy_password_sha256,
|
||||||
|
})
|
||||||
|
|
||||||
|
for index in range(2, MAX_BASIC_AUTH_USERS + 1):
|
||||||
|
username = os.getenv(f"BASIC_AUTH_USER_{index}", "").strip()
|
||||||
|
password = os.getenv(f"BASIC_AUTH_PASSWORD_{index}", "")
|
||||||
|
password_sha256 = os.getenv(f"BASIC_AUTH_PASSWORD_SHA256_{index}", "")
|
||||||
|
if not username:
|
||||||
|
continue
|
||||||
|
if username == legacy_user:
|
||||||
|
continue
|
||||||
|
users.append({
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
"password_sha256": password_sha256,
|
||||||
|
})
|
||||||
|
|
||||||
|
return users
|
||||||
|
|
||||||
|
|
||||||
|
def request_log_context(handler):
|
||||||
|
return {
|
||||||
|
"host": handler.headers.get("X-Forwarded-Host", ""),
|
||||||
|
"uri": handler.headers.get("X-Forwarded-Uri", ""),
|
||||||
|
"xff": handler.headers.get("X-Forwarded-For", ""),
|
||||||
|
"xreal": handler.headers.get("X-Real-IP", ""),
|
||||||
|
"remote": handler.client_address[0],
|
||||||
|
"auth_scheme": authorization_scheme(handler.headers.get("Authorization", "")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def basic_auth_valid(header_value):
|
||||||
|
users = configured_basic_auth_users()
|
||||||
|
if not users:
|
||||||
|
logging.error("no BasicAuth users are configured")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not header_value.lower().startswith("basic "):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(header_value[6:].strip(), validate=True).decode("utf-8")
|
||||||
|
username, password = decoded.split(":", 1)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
if not user["password"] and not user["password_sha256"]:
|
||||||
|
logging.error("BasicAuth user %s has no password configured", user["username"])
|
||||||
|
continue
|
||||||
|
|
||||||
|
user_ok = secrets.compare_digest(username, user["username"])
|
||||||
|
if user["password_sha256"]:
|
||||||
|
digest = hashlib.sha256(password.encode("utf-8")).hexdigest()
|
||||||
|
password_ok = secrets.compare_digest(digest, user["password_sha256"].lower())
|
||||||
|
else:
|
||||||
|
password_ok = secrets.compare_digest(password, user["password"])
|
||||||
|
|
||||||
|
if user_ok and password_ok:
|
||||||
|
return user["username"]
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
STORE = WhitelistStore()
|
||||||
|
|
||||||
|
|
||||||
|
class AccessGateHandler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "external-whitelist-auth-gate/1.0"
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
self.handle_request()
|
||||||
|
|
||||||
|
def do_HEAD(self):
|
||||||
|
self.handle_request(include_body=False)
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
self.handle_request()
|
||||||
|
|
||||||
|
def handle_request(self, include_body=True):
|
||||||
|
parsed_url = urlparse(self.path)
|
||||||
|
path = parsed_url.path
|
||||||
|
query = parse_qs(parsed_url.query)
|
||||||
|
|
||||||
|
if path.startswith("/healthz"):
|
||||||
|
self.respond(200, {"status": "ok"}, include_body)
|
||||||
|
return
|
||||||
|
|
||||||
|
if path.startswith("/status"):
|
||||||
|
self.respond(200, STORE.snapshot(include_details=query_flag(query, "verbose")), include_body)
|
||||||
|
return
|
||||||
|
|
||||||
|
if path.startswith("/check"):
|
||||||
|
ip_source = "request"
|
||||||
|
ip_text = query.get("ip", [""])[0].strip()
|
||||||
|
try:
|
||||||
|
if ip_text:
|
||||||
|
client_ip = ipaddress.ip_address(ip_text)
|
||||||
|
ip_source = "query"
|
||||||
|
else:
|
||||||
|
client_ip = request_client_ip(self)
|
||||||
|
except ValueError as exc:
|
||||||
|
self.respond(400, {"error": str(exc)}, include_body)
|
||||||
|
return
|
||||||
|
|
||||||
|
matches = STORE.matches(client_ip)
|
||||||
|
self.respond(200, {
|
||||||
|
"allowlisted": bool(matches),
|
||||||
|
"ip": str(client_ip),
|
||||||
|
"ip_source": ip_source,
|
||||||
|
"matched_networks": matches,
|
||||||
|
}, include_body)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not path.startswith("/auth"):
|
||||||
|
self.respond(404, {"error": "not found"}, include_body)
|
||||||
|
return
|
||||||
|
|
||||||
|
client_ip = request_client_ip(self)
|
||||||
|
log_context = request_log_context(self)
|
||||||
|
if STORE.contains(client_ip):
|
||||||
|
logging.info(
|
||||||
|
"allowlisted ip=%s host=%s uri=%s xff=%s xreal=%s remote=%s auth_scheme=%s",
|
||||||
|
client_ip,
|
||||||
|
log_context["host"],
|
||||||
|
log_context["uri"],
|
||||||
|
log_context["xff"],
|
||||||
|
log_context["xreal"],
|
||||||
|
log_context["remote"],
|
||||||
|
log_context["auth_scheme"],
|
||||||
|
)
|
||||||
|
self.send_response(204)
|
||||||
|
self.send_header("X-Access-Gate-Reason", "allowlisted")
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
|
||||||
|
authenticated_user = basic_auth_valid(self.headers.get("Authorization", ""))
|
||||||
|
if authenticated_user:
|
||||||
|
logging.info(
|
||||||
|
"basic-auth ok ip=%s host=%s uri=%s xff=%s xreal=%s remote=%s auth_scheme=%s",
|
||||||
|
client_ip,
|
||||||
|
log_context["host"],
|
||||||
|
log_context["uri"],
|
||||||
|
log_context["xff"],
|
||||||
|
log_context["xreal"],
|
||||||
|
log_context["remote"],
|
||||||
|
log_context["auth_scheme"],
|
||||||
|
)
|
||||||
|
self.send_response(204)
|
||||||
|
self.send_header("X-Access-Gate-Reason", "basic-auth")
|
||||||
|
self.send_header("X-Access-Gate-User", authenticated_user)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
|
||||||
|
logging.info(
|
||||||
|
"auth required ip=%s host=%s uri=%s xff=%s xreal=%s remote=%s auth_scheme=%s",
|
||||||
|
client_ip,
|
||||||
|
log_context["host"],
|
||||||
|
log_context["uri"],
|
||||||
|
log_context["xff"],
|
||||||
|
log_context["xreal"],
|
||||||
|
log_context["remote"],
|
||||||
|
log_context["auth_scheme"],
|
||||||
|
)
|
||||||
|
self.send_response(401)
|
||||||
|
self.send_header("WWW-Authenticate", f'Basic realm="{BASIC_AUTH_REALM}"')
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
self.end_headers()
|
||||||
|
if include_body:
|
||||||
|
self.wfile.write(b"Authentication required\n")
|
||||||
|
|
||||||
|
def respond(self, status, payload, include_body=True):
|
||||||
|
body = json.dumps(payload, sort_keys=True).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
if include_body:
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
logging.debug("%s - %s", self.address_string(), fmt % args)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
STORE.refresh_once()
|
||||||
|
thread = threading.Thread(target=STORE.refresh_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
server = ThreadingHTTPServer((LISTEN_ADDRESS, LISTEN_PORT), AccessGateHandler)
|
||||||
|
logging.info("access gate listening on %s:%s", LISTEN_ADDRESS, LISTEN_PORT)
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
38
protected-router.example.yml
Normal file
38
protected-router.example.yml
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
http:
|
||||||
|
routers:
|
||||||
|
example-web:
|
||||||
|
rule: "Host(`app.example.com`)"
|
||||||
|
entryPoints:
|
||||||
|
- web
|
||||||
|
middlewares:
|
||||||
|
- example-redirect-https
|
||||||
|
service: example-app
|
||||||
|
|
||||||
|
example-websecure:
|
||||||
|
rule: "Host(`app.example.com`)"
|
||||||
|
entryPoints:
|
||||||
|
- websecure
|
||||||
|
tls: {}
|
||||||
|
middlewares:
|
||||||
|
- external-whitelist-auth@file
|
||||||
|
- example-headers
|
||||||
|
service: example-app
|
||||||
|
|
||||||
|
middlewares:
|
||||||
|
example-redirect-https:
|
||||||
|
redirectScheme:
|
||||||
|
scheme: https
|
||||||
|
permanent: true
|
||||||
|
|
||||||
|
example-headers:
|
||||||
|
headers:
|
||||||
|
contentTypeNosniff: true
|
||||||
|
browserXssFilter: true
|
||||||
|
|
||||||
|
services:
|
||||||
|
example-app:
|
||||||
|
loadBalancer:
|
||||||
|
passHostHeader: true
|
||||||
|
servers:
|
||||||
|
- url: "http://10.0.0.10:8080"
|
||||||
11
traefik-dynamic.example.yml
Normal file
11
traefik-dynamic.example.yml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
http:
|
||||||
|
middlewares:
|
||||||
|
external-whitelist-auth:
|
||||||
|
forwardAuth:
|
||||||
|
address: "http://127.0.0.1:9180/auth"
|
||||||
|
trustForwardHeader: false
|
||||||
|
maxResponseBodySize: 8192
|
||||||
|
authResponseHeaders:
|
||||||
|
- X-Access-Gate-User
|
||||||
|
- X-Access-Gate-Reason
|
||||||
8
whitelist.example.txt
Normal file
8
whitelist.example.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# One entry per line.
|
||||||
|
# Entries can be DNS names, single IP addresses, or CIDR ranges.
|
||||||
|
|
||||||
|
office.example.com
|
||||||
|
vpn.example.com
|
||||||
|
203.0.113.42
|
||||||
|
198.51.100.0/24
|
||||||
|
2001:db8:1234::/48
|
||||||
Reference in New Issue
Block a user