This commit is contained in:
Patrick Asmus
2026-05-11 20:47:25 +02:00
parent 3ae5788b0e
commit 8122b5274a
9 changed files with 753 additions and 8 deletions

7
.env.example Normal file
View 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
View 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
View File

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

View 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

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

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

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