This commit is contained in:
parent
cc2d148967
commit
03dc8e6735
6 changed files with 413 additions and 42 deletions
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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