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:
parent
8c7a628d17
commit
b89b34c300
8 changed files with 291 additions and 7 deletions
30
Caddyfile
30
Caddyfile
|
|
@ -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
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"
|
- "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
11
docs/PILOT_ACCESS.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Доступ к GenDesign Pilot
|
||||||
|
|
||||||
|
**URL**: https://gendsgn.ru/
|
||||||
|
**Логин**: {USERNAME}
|
||||||
|
**Пароль**: {PASSWORD}
|
||||||
|
|
||||||
|
При первом заходе браузер запросит данные — введите выше.
|
||||||
|
Credentials кэшируются до закрытия окна браузера.
|
||||||
|
|
||||||
|
Если забыли пароль — напишите claudestars@proton.me, мы выпустим новый.
|
||||||
|
По завершении пилота доступ будет отозван.
|
||||||
64
docs/runbooks/basic_auth_management.md
Normal file
64
docs/runbooks/basic_auth_management.md
Normal 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
98
scripts/auth/add_user.sh
Normal 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"
|
||||||
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