diff --git a/Caddyfile b/Caddyfile index f7d9564b..1dda17ff 100644 --- a/Caddyfile +++ b/Caddyfile @@ -2,18 +2,24 @@ # # - 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). gendsgn.ru { encode zstd gzip - handle /api/* { - reverse_proxy backend:8000 + log { + output file /var/log/caddy/gendsgn.ru.log + format json } + # Liveness probe — public, before auth (GHA deploy smoke check). handle /health { reverse_proxy backend:8000 } @@ -25,6 +31,9 @@ gendsgn.ru { 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, # подключен через gendesign_shared network. Routes ДО универсального handle # потому что Caddy матчит handle-блоки сверху вниз. @@ -45,6 +54,10 @@ gendsgn.ru { reverse_proxy tradein-frontend:3000 } + handle /api/* { + reverse_proxy backend:8000 + } + handle { reverse_proxy frontend:3000 } @@ -99,16 +112,19 @@ 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/* { + 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 } diff --git a/caddy/users.caddy.snippet b/caddy/users.caddy.snippet new file mode 100644 index 00000000..9b37ebb7 --- /dev/null +++ b/caddy/users.caddy.snippet @@ -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 | 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) +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 7434fe4c..37506d2d 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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). diff --git a/docs/PILOT_ACCESS.md b/docs/PILOT_ACCESS.md new file mode 100644 index 00000000..ef8e9b1c --- /dev/null +++ b/docs/PILOT_ACCESS.md @@ -0,0 +1,11 @@ +# Доступ к GenDesign Pilot + +**URL**: https://gendsgn.ru/ +**Логин**: {USERNAME} +**Пароль**: {PASSWORD} + +При первом заходе браузер запросит данные — введите выше. +Credentials кэшируются до закрытия окна браузера. + +Если забыли пароль — напишите claudestars@proton.me, мы выпустим новый. +По завершении пилота доступ будет отозван. diff --git a/docs/runbooks/basic_auth_management.md b/docs/runbooks/basic_auth_management.md new file mode 100644 index 00000000..718a1de2 --- /dev/null +++ b/docs/runbooks/basic_auth_management.md @@ -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 ""` +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 ` +2. Commit → PR → merge → deploy + +Caddy начнёт отклонять запросы этого юзера сразу после reload (через ~1 мин после merge). + +## Сменить пароль юзеру + +1. `./scripts/auth/remove_user.sh ` + `./scripts/auth/add_user.sh ""` в одном PR + +## Посмотреть текущих юзеров + +```bash +./scripts/auth/list_users.sh +``` + +## В случае утечки credentials + +1. `./scripts/auth/remove_user.sh ` СРАЗУ +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. diff --git a/scripts/auth/add_user.sh b/scripts/auth/add_user.sh new file mode 100644 index 00000000..b0584a5f --- /dev/null +++ b/scripts/auth/add_user.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# add_user.sh — добавить пользователя в caddy/users.caddy.snippet +# Usage: ./scripts/auth/add_user.sh "" +# +# - Читает пароль из 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 \"\"" >&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' &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" diff --git a/scripts/auth/list_users.sh b/scripts/auth/list_users.sh new file mode 100644 index 00000000..102d9f1b --- /dev/null +++ b/scripts/auth/list_users.sh @@ -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: username # 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)" diff --git a/scripts/auth/remove_user.sh b/scripts/auth/remove_user.sh new file mode 100644 index 00000000..8787a5aa --- /dev/null +++ b/scripts/auth/remove_user.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# remove_user.sh — удалить пользователя из caddy/users.caddy.snippet +# Usage: ./scripts/auth/remove_user.sh +# +# НЕ коммитит изменения — это задача разработчика. +set -euo pipefail + +SNIPPET="$(dirname "$(dirname "$(realpath "$0")")")/caddy/users.caddy.snippet" + +usage() { + echo "Usage: $0 " >&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"