feat(security): закрыть gendsgn.ru basic_auth gate (multi-user, 11 users) (#426)
Pilot demo 28.05.2026: closed gendsgn.ru/* (включая trade-in/) basic_auth gate. /health и /preview/* остаются public через route { } wrapper (Caddy 2.x directive ordering fix).
11 users (admin + user1..user10), bcrypt cost=14, snippet в git (audit trail). Scripts: scripts/auth/{add,remove,list}_user.sh.
Fixup PR #426 (route{} wrap + log rotation + base64 busybox-compat + runbook audit log fix).
This commit is contained in:
parent
99c19f9fbb
commit
cc2d148967
8 changed files with 341 additions and 42 deletions
44
Caddyfile
44
Caddyfile
|
|
@ -2,18 +2,34 @@
|
|||
#
|
||||
# - gendsgn.ru — main production site, auto-TLS via Let's Encrypt.
|
||||
# - 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
|
||||
# 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).
|
||||
#
|
||||
# IMPORTANT: route { } block is required to preserve directive order.
|
||||
# Without route { }, Caddy executes directives in hard-coded default order
|
||||
# (basic_auth runs before handle), making /health and /preview/* exclusions
|
||||
# ineffective. With route { }, handlers are matched top-to-bottom as written.
|
||||
|
||||
gendsgn.ru {
|
||||
encode zstd gzip
|
||||
|
||||
handle /api/* {
|
||||
reverse_proxy backend:8000
|
||||
log {
|
||||
output file /var/log/caddy/gendsgn.ru.log {
|
||||
roll_size 50MiB
|
||||
roll_keep 5
|
||||
roll_keep_for 720h
|
||||
}
|
||||
format json
|
||||
}
|
||||
|
||||
route {
|
||||
# /health и /preview/* — public, без auth, short-circuit.
|
||||
handle /health {
|
||||
reverse_proxy backend:8000
|
||||
}
|
||||
|
|
@ -25,6 +41,9 @@ gendsgn.ru {
|
|||
file_server browse
|
||||
}
|
||||
|
||||
# Auth gate (applies to all routes below within this route block).
|
||||
import caddy/users.caddy.snippet
|
||||
|
||||
# Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker stack,
|
||||
# подключен через gendesign_shared network. Routes ДО универсального handle
|
||||
# потому что Caddy матчит handle-блоки сверху вниз.
|
||||
|
|
@ -45,9 +64,14 @@ gendsgn.ru {
|
|||
reverse_proxy tradein-frontend:3000
|
||||
}
|
||||
|
||||
handle /api/* {
|
||||
reverse_proxy backend:8000
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy frontend:3000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
www.gendsgn.ru {
|
||||
|
|
@ -99,21 +123,27 @@ git.gendsgn.ru {
|
|||
}
|
||||
}
|
||||
|
||||
# Plain HTTP by IP — kept for ssh-tunnel / debugging.
|
||||
# Caddy issues no TLS here (no hostname).
|
||||
# Plain HTTP by IP — closed by same auth gate (prevent bypass via direct IP / SSH tunnel).
|
||||
# Caddy issues no TLS here (no hostname). /health remains public.
|
||||
:80 {
|
||||
encode zstd gzip
|
||||
|
||||
handle /api/* {
|
||||
route {
|
||||
# /health — public, без auth (GHA deploy smoke check, liveness probe).
|
||||
handle /health {
|
||||
reverse_proxy backend:8000
|
||||
}
|
||||
|
||||
handle /health {
|
||||
# Auth gate (same snippet as gendsgn.ru).
|
||||
import caddy/users.caddy.snippet
|
||||
|
||||
handle /api/* {
|
||||
reverse_proxy backend:8000
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy frontend:3000
|
||||
}
|
||||
}
|
||||
}
|
||||
# Test deploy flow 2026-05-15T21:43:32Z
|
||||
|
|
|
|||
22
caddy/users.caddy.snippet
Normal file
22
caddy/users.caddy.snippet
Normal 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)
|
||||
}
|
||||
|
|
@ -185,9 +185,11 @@ services:
|
|||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- ./caddy/users.caddy.snippet:/etc/caddy/caddy/users.caddy.snippet:ro
|
||||
- ./preview:/srv/preview:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
- caddy_logs:/var/log/caddy
|
||||
# backend: service_healthy — backend имеет /health endpoint, готов до Caddy.
|
||||
# frontend: service_started — НЕ service_healthy: даже если frontend healthcheck
|
||||
# дребезжит, Caddy всё равно стартует (можно отдавать 502 для /, но
|
||||
|
|
@ -209,6 +211,7 @@ volumes:
|
|||
redis_data:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
caddy_logs:
|
||||
|
||||
networks:
|
||||
# Внешняя сеть, создаётся вне compose (см. docs/obsidian-livesync.md).
|
||||
|
|
|
|||
11
docs/PILOT_ACCESS.md
Normal file
11
docs/PILOT_ACCESS.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Доступ к GenDesign Pilot
|
||||
|
||||
**URL**: https://gendsgn.ru/
|
||||
**Логин**: {USERNAME}
|
||||
**Пароль**: {PASSWORD}
|
||||
|
||||
При первом заходе браузер запросит данные — введите выше.
|
||||
Credentials кэшируются до закрытия окна браузера.
|
||||
|
||||
Если забыли пароль — напишите claudestars@proton.me, мы выпустим новый.
|
||||
По завершении пилота доступ будет отозван.
|
||||
61
docs/runbooks/basic_auth_management.md
Normal file
61
docs/runbooks/basic_auth_management.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# 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 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
|
||||
```
|
||||
|
||||
Стандартный путь (через PR + GHA deploy) делает reload автоматически.
|
||||
|
||||
## Phase 2 (TODO)
|
||||
|
||||
GlitchTip forwarder sidecar — отдельный PR. Будет парсить `auth_audit.log` и отправлять события в GlitchTip для dashboard + alerting при brute-force.
|
||||
102
scripts/auth/add_user.sh
Normal file
102
scripts/auth/add_user.sh
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
#!/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 '
|
||||
pass=$(cat)
|
||||
raw=$(caddy hash-password --algorithm bcrypt --plaintext "$pass")
|
||||
printf "%s" "$raw" | base64 | tr -d "\n"
|
||||
')"
|
||||
|
||||
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"
|
||||
28
scripts/auth/list_users.sh
Normal file
28
scripts/auth/list_users.sh
Normal 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)"
|
||||
42
scripts/auth/remove_user.sh
Normal file
42
scripts/auth/remove_user.sh
Normal 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"
|
||||
Loading…
Add table
Reference in a new issue