gendesign/ops/glitchtip-auth-forwarder/forwarder.py
lekss361 0e294641a1
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 36s
Deploy / build-worker (push) Successful in 36s
Deploy / build-frontend (push) Successful in 42s
Deploy / deploy (push) Successful in 57s
fix(security): log_credentials + auth_audit.log → username из 401 (#436)
2026-05-23 09:59:50 +00:00

365 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Caddy access log -> GlitchTip auth event forwarder.
Tails /var/log/caddy/auth_audit.log (CADDY_LOG_FILE env), фильтрует JSON lines с
status==401, шлёт в GlitchTip через Sentry SDK как warning event с тэгами.
Persistent offset в /state/offset.json — не дублируем при restart.
Throttle: при >10 401 events за 60s — однократный digest event
(чтобы не флудить GlitchTip storm'ом); индивидуальные events во время storm пропускаются.
Реальный Caddy JSON access log (v2) структура:
{
"level": "info",
"ts": 1779524882.734,
"logger": "http.log.access.log0",
"msg": "handled request",
"request": {
"remote_ip": "95.165.147.218",
"remote_port": "50796",
"client_ip": "95.165.147.218",
"proto": "HTTP/2.0",
"method": "GET",
"host": "gendsgn.ru",
"uri": "/",
"headers": {
"User-Agent": ["Mozilla/5.0 ..."],
"Authorization": ["Basic dXNlcjpwYXNz"], # raw — при включённом log_credentials в Caddyfile
...
},
"tls": {...}
},
"user_id": "",
"duration": 0.000134,
"size": 0,
"status": 401,
"resp_headers": {...}
}
"""
import base64
import json
import os
import signal
import sys
import tempfile
import time
from collections import deque
from datetime import datetime, timezone
from pathlib import Path
import sentry_sdk
LOG_FILE = Path(os.getenv("CADDY_LOG_FILE", "/var/log/caddy/gendsgn.ru.log"))
STATE_FILE = Path(os.getenv("STATE_FILE", "/state/offset.json"))
DSN = os.environ["GLITCHTIP_DSN"] # required, fail-fast
ENV = os.getenv("APP_ENV", "production")
POLL_INTERVAL_S = 2
THROTTLE_WINDOW_S = 60
THROTTLE_THRESHOLD = 10 # >10 events за 60s -> digest mode
# Известные боты которые получают 401 от Uptime мониторов —
# логируем их только в digest, не как individual events.
KNOWN_MONITOR_UAS = frozenset(
[
"GlitchTip/",
"SentryUptimeBot/",
]
)
_shutdown = False
# Throttle для unhandled exceptions — не более одного Sentry event за 5 минут,
# чтобы persistent ошибка (напр. PermissionError) не спамила GlitchTip.
_last_exc_sent: float = 0.0
_EXC_THROTTLE_S: float = 300.0
def _signal_handler(signum: int, frame: object) -> None:
global _shutdown
_shutdown = True
def load_offset() -> int:
if STATE_FILE.exists():
try:
data = json.loads(STATE_FILE.read_text())
return int(data.get("offset", 0))
except Exception:
pass
return 0
def save_offset(offset: int) -> None:
"""Atomic write через temp file + rename — избегаем corrupt state при kill."""
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps(
{"offset": offset, "updated_at": datetime.now(timezone.utc).isoformat()}
)
# Пишем в temp рядом с целевым файлом (тот же volume -> атомарный rename)
fd, tmp_path = tempfile.mkstemp(dir=STATE_FILE.parent, prefix=".offset_tmp_")
try:
with os.fdopen(fd, "w") as f:
f.write(payload)
os.replace(tmp_path, STATE_FILE)
except Exception:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
def is_monitor_ua(ua: str) -> bool:
"""Возвращает True если User-Agent принадлежит известному монитору."""
return any(ua.startswith(prefix) for prefix in KNOWN_MONITOR_UAS)
def _extract_attempted_username(entry: dict) -> str | None: # type: ignore[type-arg]
"""
Извлекает username из Authorization header (Basic auth) из Caddy access log.
Требования к Caddyfile:
- global option `log_credentials` (иначе Caddy редактирует Authorization → "REDACTED")
- этот forwarder ожидает auth_audit.log как primary source (CADDY_LOG_FILE env)
Возвращает только username (всё до ':' в base64-decoded auth).
Password нигде не сохраняется, не логируется.
None если: header отсутствует, не Basic, base64 decode fail, нет ':', > 128 chars.
"""
req = entry.get("request") or {}
headers = req.get("headers") or {}
auth_values = headers.get("Authorization") or headers.get("authorization")
if not auth_values:
return None
if isinstance(auth_values, list):
if not auth_values:
return None
auth = auth_values[0]
else:
auth = auth_values
if not isinstance(auth, str) or not auth.lower().startswith("basic "):
return None
try:
decoded = base64.b64decode(auth[6:], validate=True).decode("utf-8", errors="replace")
except Exception:
return None
if ":" not in decoded:
return None
username = decoded.split(":", 1)[0]
# Sanity check: только разумная длина
if not username or len(username) > 128:
return None
return username
def emit_event(entry: dict) -> None: # type: ignore[type-arg]
"""Отправляет одиночный 401 event в GlitchTip."""
req = entry.get("request", {})
path = req.get("uri", "?")
method = req.get("method", "?")
# client_ip — реальный IP (за прокси); remote_ip — подключающийся (docker network)
remote_ip = req.get("client_ip") or req.get("remote_ip", "?")
host = req.get("host", "?")
# headers.User-Agent — список строк по Caddy JSON schema
headers = req.get("headers") or {}
ua_list = headers.get("User-Agent", [])
ua = ua_list[0] if ua_list else "?"
ts = entry.get("ts")
user_id = entry.get("user_id", "")
attempted_username = _extract_attempted_username(entry)
with sentry_sdk.new_scope() as scope:
scope.set_tag("event_type", "basic_auth_failed")
scope.set_tag("http_method", method)
scope.set_tag("remote_ip", remote_ip)
if attempted_username:
scope.set_tag("attempted_username", attempted_username)
scope.set_context(
"caddy_log",
{
"uri": path,
"method": method,
"host": host,
"remote_ip": remote_ip,
"user_agent": ua,
"caddy_ts": ts,
"user_id": user_id or "(none)",
"attempted_username": attempted_username or "(none)",
},
)
message = f"basic_auth 401 — {method} {path} from {remote_ip}"
if attempted_username:
message += f" (tried: {attempted_username})"
sentry_sdk.capture_message(message, level="warning")
def emit_digest(count: int, window_s: int, monitor_count: int) -> None:
"""Отправляет digest event при storm'е."""
with sentry_sdk.new_scope() as scope:
scope.set_tag("event_type", "basic_auth_storm")
sentry_sdk.capture_message(
f"basic_auth storm — {count} failed attempts in {window_s}s"
+ (f" (including {monitor_count} monitor probes)" if monitor_count else ""),
level="error",
)
def is_401_event(entry: dict) -> bool: # type: ignore[type-arg]
"""Проверяет что entry — failed auth (401)."""
return entry.get("status") == 401
def main() -> None:
signal.signal(signal.SIGTERM, _signal_handler)
signal.signal(signal.SIGINT, _signal_handler)
sentry_sdk.init(
dsn=DSN,
environment=ENV,
release=os.getenv("APP_RELEASE", "auth-forwarder-1"),
traces_sample_rate=0.0,
attach_stacktrace=False,
send_default_pii=False,
# Отключаем интеграции которые не нужны тонкому sidecar
default_integrations=False,
)
print(
f"[forwarder] starting; log={LOG_FILE}; state={STATE_FILE}; env={ENV}",
flush=True,
)
# Ожидаем появления лог-файла (Caddy пишет его при первом запросе)
if not LOG_FILE.exists():
print(
f"[forwarder] log file {LOG_FILE} does not exist yet, waiting...",
flush=True,
)
while not LOG_FILE.exists() and not _shutdown:
time.sleep(2)
if _shutdown:
return
offset = load_offset()
file_size = LOG_FILE.stat().st_size
# Если лог был ротирован или обрезан — сбрасываем offset
if offset > file_size:
print(
f"[forwarder] offset={offset} > file_size={file_size}, log rotated, reset to 0",
flush=True,
)
offset = 0
save_offset(offset)
# Sliding window для throttle
recent_timestamps: deque[float] = deque()
monitor_timestamps: deque[float] = deque()
digest_sent = False
print(f"[forwarder] starting tail from offset={offset}", flush=True)
while not _shutdown:
try:
current_size = LOG_FILE.stat().st_size
# Log rotation detection: файл стал меньше чем наш offset
if current_size < offset:
print("[forwarder] log rotation detected, resetting offset to 0", flush=True)
offset = 0
save_offset(offset)
recent_timestamps.clear()
monitor_timestamps.clear()
digest_sent = False
if current_size > offset:
with LOG_FILE.open("rb") as f:
f.seek(offset)
for raw_line in f:
if _shutdown:
break
line = raw_line.decode("utf-8", errors="replace").strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
if not is_401_event(entry):
continue
now = time.monotonic()
# Определяем User-Agent для классификации
req = entry.get("request", {})
headers = req.get("headers") or {}
ua_list = headers.get("User-Agent", [])
ua = ua_list[0] if ua_list else ""
is_monitor = is_monitor_ua(ua)
# Обновляем скользящее окно
recent_timestamps.append(now)
if is_monitor:
monitor_timestamps.append(now)
cutoff = now - THROTTLE_WINDOW_S
while recent_timestamps and recent_timestamps[0] < cutoff:
recent_timestamps.popleft()
while monitor_timestamps and monitor_timestamps[0] < cutoff:
monitor_timestamps.popleft()
window_count = len(recent_timestamps)
if window_count > THROTTLE_THRESHOLD:
# Storm mode: один digest, индивидуальные events пропускаем
if not digest_sent:
emit_digest(
window_count,
THROTTLE_WINDOW_S,
len(monitor_timestamps),
)
digest_sent = True
print(
f"[forwarder] storm digest sent ({window_count} events)",
flush=True,
)
else:
digest_sent = False
# Мониторы (GlitchTip uptime, SentryUptimeBot) получают 401 постоянно —
# не шлём их как individual events, только учитываем в throttle
if not is_monitor:
emit_event(entry)
remote_ip = req.get("client_ip") or req.get("remote_ip", "?")
path = req.get("uri", "?")
print(
f"[forwarder] 401 event sent: {remote_ip} -> {path}",
flush=True,
)
offset = f.tell()
save_offset(offset)
time.sleep(POLL_INTERVAL_S)
except FileNotFoundError:
print("[forwarder] log file disappeared, waiting...", flush=True)
time.sleep(5)
except Exception as exc:
print(f"[forwarder] unhandled error: {exc}", file=sys.stderr, flush=True)
global _last_exc_sent
now_wall = time.time()
if now_wall - _last_exc_sent > _EXC_THROTTLE_S:
try:
sentry_sdk.capture_exception(exc)
except Exception:
pass
_last_exc_sent = now_wall
time.sleep(5)
print("[forwarder] shutting down gracefully", flush=True)
sentry_sdk.flush(timeout=5)
if __name__ == "__main__":
main()