feat(security): GlitchTip forwarder для basic_auth 401 events (Phase 2 PR #426)
Добавляет sidecar glitchtip-auth-forwarder который tails Caddy access log, фильтрует 401 события и шлёт их в GlitchTip через Sentry SDK. - ops/glitchtip-auth-forwarder/forwarder.py: tail + throttle + atomic offset - Caddy JSON schema верифицирован по реальному логу: status верхнеуровневое, request.headers["User-Agent"] — list, client_ip vs remote_ip - Monitor UAs (GlitchTip uptime, SentryUptimeBot) фильтруются как individual events - Storm throttle: >10/60s → один digest event типа basic_auth_storm - docker-compose.prod.yml: caddy_logs volume в caddy + новый service + auth_forwarder_state - docs/runbooks/basic_auth_management.md: runbook с GlitchTip секцией
This commit is contained in:
parent
cc2d148967
commit
71cbdbe238
6 changed files with 413 additions and 42 deletions
|
|
@ -14,6 +14,7 @@ POSTGRES_PASSWORD=changeme
|
||||||
# GlitchTip (errors.gendsgn.ru)
|
# GlitchTip (errors.gendsgn.ru)
|
||||||
GLITCHTIP_DB_PASS=
|
GLITCHTIP_DB_PASS=
|
||||||
GLITCHTIP_SECRET=
|
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=
|
GLITCHTIP_DSN=
|
||||||
NEXT_PUBLIC_GLITCHTIP_DSN=
|
NEXT_PUBLIC_GLITCHTIP_DSN=
|
||||||
|
|
|
||||||
|
|
@ -206,12 +206,33 @@ services:
|
||||||
# main-приложение не страдает.
|
# main-приложение не страдает.
|
||||||
- shared
|
- 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:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
caddy_data:
|
caddy_data:
|
||||||
caddy_config:
|
caddy_config:
|
||||||
caddy_logs:
|
caddy_logs:
|
||||||
|
auth_forwarder_state:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
# Внешняя сеть, создаётся вне compose (см. docs/obsidian-livesync.md).
|
# Внешняя сеть, создаётся вне compose (см. docs/obsidian-livesync.md).
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,78 @@
|
||||||
# Caddy basic_auth — управление пользователями
|
# Basic Auth Management Runbook
|
||||||
|
|
||||||
Snippet: `caddy/users.caddy.snippet`
|
**Установлено**: 2026-05-23 (PR #426, commit `cc2d148`)
|
||||||
Scripts: `scripts/auth/{add,remove,list}_user.sh`
|
**Realm**: `"GenDesign Pilot"`
|
||||||
Git history = audit trail для всех изменений пользователей.
|
**URL**: <https://gendsgn.ru/>
|
||||||
|
|
||||||
## Добавить юзера
|
## Добавить пользователя
|
||||||
|
|
||||||
1. `./scripts/auth/add_user.sh <username> "<comment>"`
|
```bash
|
||||||
2. Скрипт сгенерит 16-char password, забери его в безопасное место
|
./scripts/auth/add_user.sh <username>
|
||||||
3. Commit `caddy/users.caddy.snippet` → PR → merge → GHA deploy → Caddy reload
|
# Вводи пароль интерактивно; скрипт добавляет в caddy/users.caddy.snippet
|
||||||
4. Выдай username/password стейкхолдеру через защищённый канал (Telegram secret chat / Signal)
|
```
|
||||||
|
|
||||||
Шаблон письма: `docs/PILOT_ACCESS.md`
|
После: commit snippet → PR → merge → GHA deploy → Caddy reload (автоматически в deploy.yml).
|
||||||
|
|
||||||
## Удалить юзера
|
## Удалить пользователя
|
||||||
|
|
||||||
1. `./scripts/auth/remove_user.sh <username>`
|
1. `./scripts/auth/remove_user.sh <username>` ASAP
|
||||||
2. Commit → PR → merge → deploy
|
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
|
```bash
|
||||||
./scripts/auth/list_users.sh
|
./scripts/auth/list_users.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## В случае утечки credentials
|
## Credencial rotation
|
||||||
|
|
||||||
1. `./scripts/auth/remove_user.sh <affected_username>` СРАЗУ
|
При компрометации пароля: remove_user + add_user с новым паролем + PR-merge + разослать новый credential.
|
||||||
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. Уведомить остальных стейкхолдеров
|
|
||||||
|
|
||||||
## Аудит логи
|
## Диагностика
|
||||||
|
|
||||||
- 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
|
```bash
|
||||||
# Если snippet изменён вручную на VPS (минуя GHA):
|
# Посмотреть 401-ошибки в live логе
|
||||||
docker compose -f docker-compose.prod.yml exec caddy caddy reload --config /etc/caddy/Caddyfile
|
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
|
||||||
|
|
|
||||||
14
ops/glitchtip-auth-forwarder/Dockerfile
Normal file
14
ops/glitchtip-auth-forwarder/Dockerfile
Normal 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"]
|
||||||
313
ops/glitchtip-auth-forwarder/forwarder.py
Normal file
313
ops/glitchtip-auth-forwarder/forwarder.py
Normal 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()
|
||||||
5
ops/glitchtip-auth-forwarder/pyproject.toml
Normal file
5
ops/glitchtip-auth-forwarder/pyproject.toml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
[project]
|
||||||
|
name = "glitchtip-auth-forwarder"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = ["sentry-sdk>=2.0"]
|
||||||
Loading…
Add table
Reference in a new issue