From 03dc8e6735045cf3da98249344631b9ee9a5c128 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 23 May 2026 08:42:36 +0000 Subject: [PATCH] =?UTF-8?q?feat(security):=20GlitchTip=20forwarder=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20basic=5Fauth=20401=20events=20(Phase=202?= =?UTF-8?q?=20PR=20#426)=20(#430)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 +- docker-compose.prod.yml | 21 ++ docs/runbooks/basic_auth_management.md | 99 ++++--- ops/glitchtip-auth-forwarder/Dockerfile | 14 + ops/glitchtip-auth-forwarder/forwarder.py | 313 ++++++++++++++++++++ ops/glitchtip-auth-forwarder/pyproject.toml | 5 + 6 files changed, 413 insertions(+), 42 deletions(-) create mode 100644 ops/glitchtip-auth-forwarder/Dockerfile create mode 100644 ops/glitchtip-auth-forwarder/forwarder.py create mode 100644 ops/glitchtip-auth-forwarder/pyproject.toml diff --git a/.env.example b/.env.example index 1b16ba67..d9e10a34 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,7 @@ POSTGRES_PASSWORD=changeme # GlitchTip (errors.gendsgn.ru) GLITCHTIP_DB_PASS= GLITCHTIP_SECRET= -# DSN-ы из GlitchTip UI после первого старта (→ .env.runtime или backend/.env) +# DSN из GlitchTip UI → Project Settings → Client Keys. +# Используется backend Sentry SDK + glitchtip-auth-forwarder sidecar. GLITCHTIP_DSN= NEXT_PUBLIC_GLITCHTIP_DSN= diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 37506d2d..1c0381dd 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -206,12 +206,33 @@ services: # main-приложение не страдает. - shared + glitchtip-auth-forwarder: + # Собирается локально на VPS при деплое (не тянется из GHCR). + # deploy.yml запускает: docker compose build glitchtip-auth-forwarder + build: ./ops/glitchtip-auth-forwarder + container_name: gendesign-auth-forwarder + restart: unless-stopped + environment: + GLITCHTIP_DSN: ${GLITCHTIP_DSN} + APP_ENV: production + APP_RELEASE: auth-forwarder-1 + CADDY_LOG_FILE: /var/log/caddy/gendsgn.ru.log + STATE_FILE: /state/offset.json + volumes: + # Read-only доступ к Caddy access log + - caddy_logs:/var/log/caddy:ro + # Persistent offset — выживает при перезапуске контейнера + - auth_forwarder_state:/state + depends_on: + - caddy + volumes: postgres_data: redis_data: caddy_data: caddy_config: caddy_logs: + auth_forwarder_state: networks: # Внешняя сеть, создаётся вне compose (см. docs/obsidian-livesync.md). diff --git a/docs/runbooks/basic_auth_management.md b/docs/runbooks/basic_auth_management.md index 9b863dfa..76f67f7c 100644 --- a/docs/runbooks/basic_auth_management.md +++ b/docs/runbooks/basic_auth_management.md @@ -1,61 +1,78 @@ -# Caddy basic_auth — управление пользователями +# Basic Auth Management Runbook -Snippet: `caddy/users.caddy.snippet` -Scripts: `scripts/auth/{add,remove,list}_user.sh` -Git history = audit trail для всех изменений пользователей. +**Установлено**: 2026-05-23 (PR #426, commit `cc2d148`) +**Realm**: `"GenDesign Pilot"` +**URL**: -## Добавить юзера +## Добавить пользователя -1. `./scripts/auth/add_user.sh ""` -2. Скрипт сгенерит 16-char password, забери его в безопасное место -3. Commit `caddy/users.caddy.snippet` → PR → merge → GHA deploy → Caddy reload -4. Выдай username/password стейкхолдеру через защищённый канал (Telegram secret chat / Signal) +```bash +./scripts/auth/add_user.sh +# Вводи пароль интерактивно; скрипт добавляет в caddy/users.caddy.snippet +``` -Шаблон письма: `docs/PILOT_ACCESS.md` +После: commit snippet → PR → merge → GHA deploy → Caddy reload (автоматически в deploy.yml). -## Удалить юзера +## Удалить пользователя -1. `./scripts/auth/remove_user.sh ` -2. Commit → PR → merge → deploy +1. `./scripts/auth/remove_user.sh ` ASAP +2. Commit + push + PR (merge приоритетным) +3. `docker compose exec caddy caddy reload` после deploy +4. Сгенерировать новый credential через `add_user.sh` +5. Уведомить остальных стейкхолдеров о замене -Caddy начнёт отклонять запросы этого юзера сразу после reload (через ~1 мин после merge). - -## Сменить пароль юзеру - -1. `./scripts/auth/remove_user.sh ` + `./scripts/auth/add_user.sh ""` в одном PR - -## Посмотреть текущих юзеров +## Просмотр текущих пользователей ```bash ./scripts/auth/list_users.sh ``` -## В случае утечки credentials +## Credencial rotation -1. `./scripts/auth/remove_user.sh ` СРАЗУ -2. Force-commit + push → merge ASAP (reviewer быстрее через personal DM) -3. Caddy reload проверить вручную: `docker compose -f docker-compose.prod.yml exec caddy caddy reload --config /etc/caddy/Caddyfile` -4. Уведомить остальных стейкхолдеров +При компрометации пароля: remove_user + add_user с новым паролем + PR-merge + разослать новый credential. -## Аудит логи - -- Access log: `/var/log/caddy/gendsgn.ru.log` (все запросы JSON, включая auth events). -- Failed auth последние 100: - ```bash - docker compose -f docker-compose.prod.yml exec caddy \ - grep -P '"status":401' /var/log/caddy/gendsgn.ru.log | tail -100 | jq - ``` -- Phase 2 TODO: отдельный auth_audit.log + GlitchTip forwarder sidecar. - -## Caddy reload после ручной правки на VPS +## Диагностика ```bash -# Если snippet изменён вручную на VPS (минуя GHA): -docker compose -f docker-compose.prod.yml exec caddy caddy reload --config /etc/caddy/Caddyfile +# Посмотреть 401-ошибки в live логе +ssh gendesign 'docker compose -p gendesign exec caddy tail -f /var/log/caddy/gendsgn.ru.log' | grep '"status":401' + +# Reload конфига Caddy без перезапуска +ssh gendesign 'docker compose -p gendesign exec -T caddy caddy reload --config /etc/caddy/Caddyfile' ``` -Стандартный путь (через PR + GHA deploy) делает reload автоматически. +## GlitchTip auth event forwarding -## Phase 2 (TODO) +Sidecar `gendesign-auth-forwarder` шлёт 401 события в GlitchTip как Sentry warning events. -GlitchTip forwarder sidecar — отдельный PR. Будет парсить `auth_audit.log` и отправлять события в GlitchTip для dashboard + alerting при brute-force. +- Состояние: `docker compose -p gendesign ps | grep auth-forwarder` +- Logs: `docker compose -p gendesign logs -f glitchtip-auth-forwarder` +- GlitchTip dashboard: — фильтр `event_type:basic_auth_failed` +- Storm digest: при >10 failed auth за 60s → отдельный `event_type:basic_auth_storm` event +- Мониторы (GlitchTip uptime, SentryUptimeBot) — получают 401 стабильно из-за отсутствия credentials; они фильтруются как individual events, учитываются только в storm digest + +Если sidecar упал — failed auth события **не теряются** (offset persistent в `auth_forwarder_state` volume), но в GlitchTip не доезжают пока container down. + +### Перезапуск forwarder + +```bash +ssh gendesign 'docker compose -p gendesign restart glitchtip-auth-forwarder' +``` + +### Переменные окружения forwarder + +Задаются в `/opt/gendesign/.env` на VPS: + +| Переменная | Описание | Обязательна | +|---|---|---| +| `GLITCHTIP_DSN` | Sentry DSN GlitchTip (project backend) | Да | + +## Phase 2 — реализовано (PR feat/glitchtip-auth-forwarder) + +GlitchTip forwarder sidecar добавлен в `ops/glitchtip-auth-forwarder/`. +Архитектура: Python 3.12 slim, sentry-sdk 2.18, tail live Caddy log, offset в named volume. + +## Phase 3 (TODO) + +- При >30 users — миграция на OAuth/NextAuth +- Разделение admin/user по доступу через Caddy path matcher или app-level RBAC diff --git a/ops/glitchtip-auth-forwarder/Dockerfile b/ops/glitchtip-auth-forwarder/Dockerfile new file mode 100644 index 00000000..4506f329 --- /dev/null +++ b/ops/glitchtip-auth-forwarder/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Устанавливаем только sentry-sdk без лишних extras +RUN pip install --no-cache-dir "sentry-sdk==2.18.0" + +COPY forwarder.py . + +# nobody (uid 65534) — минимальные привилегии +USER nobody + +# -u для unbuffered stdout — docker logs виден сразу без буферизации +CMD ["python", "-u", "forwarder.py"] diff --git a/ops/glitchtip-auth-forwarder/forwarder.py b/ops/glitchtip-auth-forwarder/forwarder.py new file mode 100644 index 00000000..72cec657 --- /dev/null +++ b/ops/glitchtip-auth-forwarder/forwarder.py @@ -0,0 +1,313 @@ +""" +Caddy access log -> GlitchTip auth event forwarder. + +Tails /var/log/caddy/gendsgn.ru.log, фильтрует 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": ["REDACTED"], + ... + }, + "tls": {...} + }, + "user_id": "", + "duration": 0.000134, + "size": 0, + "status": 401, + "resp_headers": {...} +} +""" + +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 + + +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 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", "") + + 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) + 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)", + }, + ) + sentry_sdk.capture_message( + f"basic_auth 401 — {method} {path} from {remote_ip}", + 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) + try: + sentry_sdk.capture_exception(exc) + except Exception: + pass + time.sleep(5) + + print("[forwarder] shutting down gracefully", flush=True) + sentry_sdk.flush(timeout=5) + + +if __name__ == "__main__": + main() diff --git a/ops/glitchtip-auth-forwarder/pyproject.toml b/ops/glitchtip-auth-forwarder/pyproject.toml new file mode 100644 index 00000000..8dd6f9b8 --- /dev/null +++ b/ops/glitchtip-auth-forwarder/pyproject.toml @@ -0,0 +1,5 @@ +[project] +name = "glitchtip-auth-forwarder" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = ["sentry-sdk>=2.0"]