feat(security): GlitchTip forwarder для basic_auth 401 events (Phase 2 PR #426) #430

Merged
lekss361 merged 1 commit from feat/glitchtip-auth-forwarder into main 2026-05-23 08:42:37 +00:00
6 changed files with 413 additions and 42 deletions
Showing only changes of commit 71cbdbe238 - Show all commits

View file

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

View file

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

View file

@ -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**: <https://gendsgn.ru/>
## Добавить юзера
## Добавить пользователя
1. `./scripts/auth/add_user.sh <username> "<comment>"`
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 <username>
# Вводи пароль интерактивно; скрипт добавляет в caddy/users.caddy.snippet
```
Шаблон письма: `docs/PILOT_ACCESS.md`
После: commit snippet → PR → merge → GHA deploy → Caddy reload (автоматически в deploy.yml).
## Удалить юзера
## Удалить пользователя
1. `./scripts/auth/remove_user.sh <username>`
2. Commit → PR → merge → deploy
1. `./scripts/auth/remove_user.sh <username>` 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 <username>` + `./scripts/auth/add_user.sh <username> "<comment>"` в одном PR
## Посмотреть текущих юзеров
## Просмотр текущих пользователей
```bash
./scripts/auth/list_users.sh
```
## В случае утечки credentials
## Credencial rotation
1. `./scripts/auth/remove_user.sh <affected_username>` СРАЗУ
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: <https://errors.gendsgn.ru/> — фильтр `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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
[project]
name = "glitchtip-auth-forwarder"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["sentry-sdk>=2.0"]