feat(security): закрыть gendsgn.ru basic_auth gate (multi-user, 11 users)

Добавить Caddy basic_auth gate на gendsgn.ru и :80 для pilot demo 28.05.2026.
Только /health и /preview/* остаются публичными. Управление пользователями
через scripts/auth/ скрипты + git history как audit trail.
This commit is contained in:
lekss361 2026-05-23 10:55:03 +03:00
parent 8c7a628d17
commit b89b34c300
8 changed files with 291 additions and 7 deletions

View file

@ -2,18 +2,24 @@
# #
# - gendsgn.ru — main production site, auto-TLS via Let's Encrypt. # - gendsgn.ru — main production site, auto-TLS via Let's Encrypt.
# - www.gendsgn.ru — 301 redirect to apex (canonical URL). # - www.gendsgn.ru — 301 redirect to apex (canonical URL).
# - :80 — fallback for raw IP access. Will be removed once everyone uses the domain. # - :80 — fallback for raw IP access. Closed by same auth gate (pilot phase).
# #
# `handle /api/*` (NOT `handle_path`) — we keep the /api prefix because # `handle /api/*` (NOT `handle_path`) — we keep the /api prefix because
# the FastAPI router is mounted at /api/v1/*. # the FastAPI router is mounted at /api/v1/*.
#
# Auth gate: basic_auth for pilot phase (2026-05-23).
# Users managed via caddy/users.caddy.snippet (git history = audit trail).
# Public exclusions: /health (liveness probe), /preview/* (static mockups).
gendsgn.ru { gendsgn.ru {
encode zstd gzip encode zstd gzip
handle /api/* { log {
reverse_proxy backend:8000 output file /var/log/caddy/gendsgn.ru.log
format json
} }
# Liveness probe — public, before auth (GHA deploy smoke check).
handle /health { handle /health {
reverse_proxy backend:8000 reverse_proxy backend:8000
} }
@ -25,6 +31,9 @@ gendsgn.ru {
file_server browse file_server browse
} }
# Auth gate (applies to all routes not matched above).
import caddy/users.caddy.snippet
# Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker stack, # Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker stack,
# подключен через gendesign_shared network. Routes ДО универсального handle # подключен через gendesign_shared network. Routes ДО универсального handle
# потому что Caddy матчит handle-блоки сверху вниз. # потому что Caddy матчит handle-блоки сверху вниз.
@ -45,6 +54,10 @@ gendsgn.ru {
reverse_proxy tradein-frontend:3000 reverse_proxy tradein-frontend:3000
} }
handle /api/* {
reverse_proxy backend:8000
}
handle { handle {
reverse_proxy frontend:3000 reverse_proxy frontend:3000
} }
@ -99,16 +112,19 @@ git.gendsgn.ru {
} }
} }
# Plain HTTP by IP — kept for ssh-tunnel / debugging. # Plain HTTP by IP — closed by same auth gate (prevent bypass via direct IP / SSH tunnel).
# Caddy issues no TLS here (no hostname). # Caddy issues no TLS here (no hostname). /health remains public.
:80 { :80 {
encode zstd gzip encode zstd gzip
handle /api/* { handle /health {
reverse_proxy backend:8000 reverse_proxy backend:8000
} }
handle /health { # Auth gate (same snippet as gendsgn.ru).
import caddy/users.caddy.snippet
handle /api/* {
reverse_proxy backend:8000 reverse_proxy backend:8000
} }

22
caddy/users.caddy.snippet Normal file
View file

@ -0,0 +1,22 @@
# GenDesign Pilot — basic_auth users gate
# Last updated: 2026-05-23
# Add/remove via scripts/auth/add_user.sh / remove_user.sh, then PR.
#
# Format: username bcrypt_hash_base64 # comment
# Hash generation: caddy hash-password --plaintext <pass> | tr -d '\n' | base64 -w 0
# Caddy 2.11+ requires base64-encoded bcrypt hashes in Caddyfile basic_auth.
# Realm is set as argument to basic_auth, not as subdirective.
basic_auth bcrypt "GenDesign Pilot" {
admin JDJhJDE0JHVXRGpmWFBMMmNZd1duQUFJVWFkRi5RVy4xbHBVL3BPaTF6dFBvbUJ5enZvaXQ3bnZ0eGU2 # internal master (qa-tester + health checks, 2026-05-23)
user1 JDJhJDE0JGVHLi5XYUtnUnI5TTUweDBYNGpxQXV5OTNtWFlkYkZQRzJXVzlhSWxRMnhFN25vVDhleE11 # TBD (2026-05-23)
user2 JDJhJDE0JHl1Vk1HL1ZJQlY5R0FJQndtSzRBcU8wQ0kxUjdicXpHRldBR2JLQmkvUlJ6aWp0Z0EvMjYu # TBD (2026-05-23)
user3 JDJhJDE0JGFOeEwwdnFaUjZiWWpkckVkWFdvT2VMVTkubWZrMVBtQlNmcmg4ZS9qU01OYndPVXRzT1Iy # TBD (2026-05-23)
user4 JDJhJDE0JDJWbkY0YWNIRGhCMlFGekhFM2tVSHVxY0JEWm9UOVNqRTBMbGYzbERHcE5sQjF3Znd6QkNh # TBD (2026-05-23)
user5 JDJhJDE0JDAzTEhkd25STjdUTUtUQkdaREdkSU9mdmlVWEI0d1NxTTRkQnhydklDLk5kT3c2MGdOZnA2 # TBD (2026-05-23)
user6 JDJhJDE0JC9vZnZaUU5TVkNUNnRiU3F3QjhYTXVCb2xaSWlzb3U5aUVtY29jSlRyMExOVGdpOHhYc3ky # TBD (2026-05-23)
user7 JDJhJDE0JFlFSDJRV1hsb0kyUmYuMjdMd1FuTHVObC9yQUNNQy9WRGZWMnlUc2wuRUFDOEV6SEJrbkou # TBD (2026-05-23)
user8 JDJhJDE0JFd0alo2VmZYWDlCcHFqellTWC9RSi44WVhQUi5hVFk5djd4emlwcWVvMDExcEU1MVpDL1Yu # TBD (2026-05-23)
user9 JDJhJDE0JHRPZzVEd2ZKaldUWXFSUzI5c3RHTE9NVnp1amRFVkVMTHRFMGF2eUgzbzk2SEJvekdLQ3ZT # TBD (2026-05-23)
user10 JDJhJDE0JEo1QVRUTk1wejgucUI1ZFAzTEE0WWVxQk55c29hTGlHZ243RVhUc2F1aldIc0pSSHVIOUxL # TBD (2026-05-23)
}

View file

@ -185,9 +185,11 @@ services:
- "443:443" - "443:443"
volumes: volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro - ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy/users.caddy.snippet:/etc/caddy/caddy/users.caddy.snippet:ro
- ./preview:/srv/preview:ro - ./preview:/srv/preview:ro
- caddy_data:/data - caddy_data:/data
- caddy_config:/config - caddy_config:/config
- caddy_logs:/var/log/caddy
# backend: service_healthy — backend имеет /health endpoint, готов до Caddy. # backend: service_healthy — backend имеет /health endpoint, готов до Caddy.
# frontend: service_started — НЕ service_healthy: даже если frontend healthcheck # frontend: service_started — НЕ service_healthy: даже если frontend healthcheck
# дребезжит, Caddy всё равно стартует (можно отдавать 502 для /, но # дребезжит, Caddy всё равно стартует (можно отдавать 502 для /, но
@ -209,6 +211,7 @@ volumes:
redis_data: redis_data:
caddy_data: caddy_data:
caddy_config: caddy_config:
caddy_logs:
networks: networks:
# Внешняя сеть, создаётся вне compose (см. docs/obsidian-livesync.md). # Внешняя сеть, создаётся вне compose (см. docs/obsidian-livesync.md).

11
docs/PILOT_ACCESS.md Normal file
View file

@ -0,0 +1,11 @@
# Доступ к GenDesign Pilot
**URL**: https://gendsgn.ru/
**Логин**: {USERNAME}
**Пароль**: {PASSWORD}
При первом заходе браузер запросит данные — введите выше.
Credentials кэшируются до закрытия окна браузера.
Если забыли пароль — напишите claudestars@proton.me, мы выпустим новый.
По завершении пилота доступ будет отозван.

View file

@ -0,0 +1,64 @@
# Caddy basic_auth — управление пользователями
Snippet: `caddy/users.caddy.snippet`
Scripts: `scripts/auth/{add,remove,list}_user.sh`
Git history = audit trail для всех изменений пользователей.
## Добавить юзера
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)
Шаблон письма: `docs/PILOT_ACCESS.md`
## Удалить юзера
1. `./scripts/auth/remove_user.sh <username>`
2. Commit → PR → merge → deploy
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
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. Уведомить остальных стейкхолдеров
## Аудит логи
- Access log: `/var/log/caddy/gendsgn.ru.log` (все запросы, JSON)
- Auth audit: `/var/log/caddy/auth_audit.log` (authentication events, JSON)
```bash
# Просмотр auth событий в реальном времени
docker compose -f docker-compose.prod.yml exec caddy tail -f /var/log/caddy/auth_audit.log | jq
# Посмотреть кто логинился сегодня
docker compose -f docker-compose.prod.yml exec caddy grep "$(date +%Y-%m-%d)" /var/log/caddy/auth_audit.log | jq '.user_id'
```
## Caddy reload после ручной правки на VPS
```bash
# Если snippet изменён вручную на VPS (минуя GHA):
docker compose -f docker-compose.prod.yml exec caddy caddy reload --config /etc/caddy/Caddyfile
```
Стандартный путь (через PR + GHA deploy) делает reload автоматически.
## Phase 2 (TODO)
GlitchTip forwarder sidecar — отдельный PR. Будет парсить `auth_audit.log` и отправлять события в GlitchTip для dashboard + alerting при brute-force.

98
scripts/auth/add_user.sh Normal file
View file

@ -0,0 +1,98 @@
#!/usr/bin/env bash
# add_user.sh — добавить пользователя в caddy/users.caddy.snippet
# Usage: ./scripts/auth/add_user.sh <username> "<comment>"
#
# - Читает пароль из stdin (pipe) или генерирует 16-char random если интерактивен.
# - Bcrypt через `docker run --rm caddy:2 caddy hash-password`.
# - Печатает plain password на stdout ОДИН РАЗ.
# - НЕ коммитит изменения — это задача разработчика.
set -euo pipefail
SNIPPET="$(dirname "$(dirname "$(realpath "$0")")")/caddy/users.caddy.snippet"
usage() {
echo "Usage: $0 <username> \"<comment>\"" >&2
exit 1
}
# ─── args ────────────────────────────────────────────────────────────────────
[[ $# -lt 2 ]] && usage
USERNAME="$1"
COMMENT="$2"
DATE="$(date -u +%Y-%m-%d)"
# Validate username: ^[a-z][a-z0-9-]{2,30}$
if ! [[ "$USERNAME" =~ ^[a-z][a-z0-9-]{2,30}$ ]]; then
echo "ERROR: invalid username '$USERNAME'. Must match ^[a-z][a-z0-9-]{2,30}$" >&2
exit 1
fi
# ─── idempotency guard ────────────────────────────────────────────────────────
if grep -qP "^\s+${USERNAME}\s" "$SNIPPET" 2>/dev/null; then
echo "ERROR: user '$USERNAME' already exists in $SNIPPET. Remove first with remove_user.sh." >&2
exit 1
fi
# ─── password ─────────────────────────────────────────────────────────────────
if [ -t 0 ]; then
# Interactive — generate random password
PLAIN_PASS="$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 16)"
else
# Piped input
read -r PLAIN_PASS
fi
if [[ -z "$PLAIN_PASS" ]]; then
echo "ERROR: empty password" >&2
exit 1
fi
# ─── bcrypt via caddy container ───────────────────────────────────────────────
# Caddy 2.11+ requires base64-encoded bcrypt in Caddyfile basic_auth.
# Password passed via stdin to avoid exposure in process list.
HASH="$(printf '%s' "$PLAIN_PASS" | docker run --rm -i caddy:2 sh -c 'read -r p; caddy hash-password --plaintext "$p" | tr -d "\n" | base64 -w 0')"
if [[ -z "$HASH" ]]; then
echo "ERROR: caddy hash-password returned empty hash" >&2
exit 1
fi
# ─── insert into snippet ─────────────────────────────────────────────────────
# Insert before the closing brace of basic_auth block
if ! grep -q '^}' "$SNIPPET"; then
echo "ERROR: cannot find closing brace in $SNIPPET" >&2
exit 1
fi
# Use a temp file for safe in-place edit
TMP="$(mktemp)"
trap 'rm -f "$TMP"' EXIT
# Insert new user line before the last closing `}` of the basic_auth block
awk -v user="$USERNAME" -v hash="$HASH" -v comment="$COMMENT" -v date="$DATE" '
/^}/ && !done {
printf " %-7s %s # %s (%s)\n", user, hash, comment, date
done=1
}
{ print }
' "$SNIPPET" > "$TMP"
mv "$TMP" "$SNIPPET"
# ─── output ───────────────────────────────────────────────────────────────────
echo "Added user '$USERNAME' to $SNIPPET"
echo ""
echo "Plain password (save now — not stored anywhere):"
echo "$PLAIN_PASS"
echo ""
echo "Next steps:"
echo " 1. Commit caddy/users.caddy.snippet → PR → merge → GHA deploy"
echo " 2. After deploy, Caddy auto-reloads (deploy.yml does caddy reload)"
echo " 3. Send username + password to stakeholder via secure channel"

View file

@ -0,0 +1,28 @@
#!/usr/bin/env bash
# list_users.sh — показать текущих пользователей (имена + comment, без хешей)
# Usage: ./scripts/auth/list_users.sh
set -euo pipefail
SNIPPET="$(dirname "$(dirname "$(realpath "$0")")")/caddy/users.caddy.snippet"
if [[ ! -f "$SNIPPET" ]]; then
echo "ERROR: snippet not found at $SNIPPET" >&2
exit 1
fi
echo "Users in $SNIPPET:"
echo ""
printf "%-12s %s\n" "USERNAME" "COMMENT"
printf "%-12s %s\n" "--------" "-------"
# Extract lines that look like user entries (indented username + base64 hash + optional comment)
# Format: <spaces> username <base64_hash> # comment
grep -P '^\s+[a-z][a-z0-9-]+\s+[A-Za-z0-9+/]+=*' "$SNIPPET" | \
sed -E 's/^\s+([a-z][a-z0-9-]+)\s+\S+\s*(#\s*)?(.*)$/\1\t\3/' | \
while IFS=$'\t' read -r user comment; do
printf "%-12s %s\n" "$user" "$comment"
done
echo ""
TOTAL=$(grep -cP '^\s+[a-z][a-z0-9-]+\s+[A-Za-z0-9+/]+=*' "$SNIPPET" || true)
echo "Total: $TOTAL user(s)"

View file

@ -0,0 +1,42 @@
#!/usr/bin/env bash
# remove_user.sh — удалить пользователя из caddy/users.caddy.snippet
# Usage: ./scripts/auth/remove_user.sh <username>
#
# НЕ коммитит изменения — это задача разработчика.
set -euo pipefail
SNIPPET="$(dirname "$(dirname "$(realpath "$0")")")/caddy/users.caddy.snippet"
usage() {
echo "Usage: $0 <username>" >&2
exit 1
}
[[ $# -lt 1 ]] && usage
USERNAME="$1"
# Validate username format
if ! [[ "$USERNAME" =~ ^[a-z][a-z0-9-]{2,30}$ ]]; then
echo "ERROR: invalid username '$USERNAME'. Must match ^[a-z][a-z0-9-]{2,30}$" >&2
exit 1
fi
# Check user exists
if ! grep -qP "^\s+${USERNAME}\s" "$SNIPPET" 2>/dev/null; then
echo "ERROR: user '$USERNAME' not found in $SNIPPET" >&2
exit 1
fi
# Remove the line with this username (safe in-place via temp file)
TMP="$(mktemp)"
trap 'rm -f "$TMP"' EXIT
grep -vP "^\s+${USERNAME}\s" "$SNIPPET" > "$TMP"
mv "$TMP" "$SNIPPET"
echo "Removed user '$USERNAME' from $SNIPPET"
echo ""
echo "Next steps:"
echo " 1. Commit caddy/users.caddy.snippet → PR → merge → GHA deploy"
echo " 2. After deploy, Caddy auto-reloads — user access revoked"