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">
|
||||
<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>
|
||||
</p>
|
||||
|
||||
<h1 align="center">Name des Projekts</h1>
|
||||
<h1 align="center">Traefik-External Whitelist Auth Gate</h1>
|
||||
|
||||
<h4 align="center">
|
||||
Kurzbeschreibung des Projekts/Anwendung, um die es geht
|
||||
Kleiner Traefik-ForwardAuth-Dienst mit externer Whitelist und BasicAuth-Ausweichlösung
|
||||
</h4>
|
||||
|
||||
<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://matrix.to/#/#support:techniverse.net">💬 Support</a>
|
||||
<a href="https://matrix.to/#/#support:techniverse.net">💬 Hilfe</a>
|
||||
</h6>
|
||||
<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>
|
||||
@@ -34,4 +235,4 @@ CONTENT BEREICH
|
||||
<sub>
|
||||
© Patrick Asmus · Techniverse Network · <a href="./LICENSE">Lizenz</a>
|
||||
</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