fix(install): повторный запуск установщика больше не меняет секреты

Найдено на стенде: повторный `install.sh` без `--reconfigure` генерировал новые
MASTER_KEY/SESSION_PEPPER/TOTP_ENCRYPTION_KEY, после чего email-индекс и
TOTP-секреты существующих пользователей перестали расшифровываться (вход
возвращал `auth.invalid_credentials`).

- секреты переиспользуются при любом повторном запуске, а не только с
  `--reconfigure`;
- добавлен явный флаг `--rotate-secrets` с предупреждением о последствиях;
- age-ключ бэкапов сохраняется, старые бэкапы остаются читаемыми;
- защита от потери `.env`: если база есть, а MASTER_KEY нет — установщик
  останавливается с объяснением вместо молчаливой генерации новых ключей;
- `scripts/test-install.sh` сравнивает секреты до и после повторной установки
  и падает при их изменении.
This commit is contained in:
2026-09-19 22:08:47 +03:00
parent 9c001fedfe
commit fb221c91bb
2 changed files with 50 additions and 8 deletions
+25 -4
View File
@@ -79,6 +79,8 @@ INSTANCE_NAME="${INSTANCE_NAME:-glchat}"
MAIN_GUILD_NAME="${MAIN_GUILD_NAME:-Главный сервер}" MAIN_GUILD_NAME="${MAIN_GUILD_NAME:-Главный сервер}"
ADMIN_EMAIL="${ADMIN_EMAIL:-}" ADMIN_EMAIL="${ADMIN_EMAIL:-}"
ENABLE_ADMIN_2FA="${ENABLE_ADMIN_2FA:-0}" ENABLE_ADMIN_2FA="${ENABLE_ADMIN_2FA:-0}"
# Явная ротация секретов: по умолчанию секреты существующего .env сохраняются.
ROTATE_SECRETS="${ROTATE_SECRETS:-0}"
ADMIN_PASSWORD="${ADMIN_PASSWORD:-}" ADMIN_PASSWORD="${ADMIN_PASSWORD:-}"
REGISTRATION_ENABLED="${REGISTRATION_ENABLED:-true}" REGISTRATION_ENABLED="${REGISTRATION_ENABLED:-true}"
TURN_ENABLED="${TURN_ENABLED:-true}" TURN_ENABLED="${TURN_ENABLED:-true}"
@@ -119,6 +121,8 @@ install.sh — установка glchat (AGENT.md 10).
--email <email> email для Let's Encrypt (обязателен, если TLS включён) --email <email> email для Let's Encrypt (обязателен, если TLS включён)
--admin <email> логин инстанс-администратора --admin <email> логин инстанс-администратора
--password <password> пароль инстанс-администратора (иначе — интерактивный ввод) --password <password> пароль инстанс-администратора (иначе — интерактивный ввод)
--rotate-secrets сменить секреты (MASTER_KEY, SESSION_PEPPER и др.):
ломает существующие данные, нужен чистый инстанс
--admin-2fa включить 2FA инстанс-админу и напечатать секрет и --admin-2fa включить 2FA инстанс-админу и напечатать секрет и
резервные коды (по умолчанию включается вручную: резервные коды (по умолчанию включается вручную:
glchat totp-setup --email <admin>) glchat totp-setup --email <admin>)
@@ -188,6 +192,7 @@ parse_args() {
--admin) ADMIN_EMAIL="${2:?}"; shift 2 ;; --admin) ADMIN_EMAIL="${2:?}"; shift 2 ;;
--password) ADMIN_PASSWORD="${2:?}"; shift 2 ;; --password) ADMIN_PASSWORD="${2:?}"; shift 2 ;;
--admin-2fa) ENABLE_ADMIN_2FA=1; shift ;; --admin-2fa) ENABLE_ADMIN_2FA=1; shift ;;
--rotate-secrets) ROTATE_SECRETS=1; shift ;;
--instance-name) INSTANCE_NAME="${2:?}"; shift 2 ;; --instance-name) INSTANCE_NAME="${2:?}"; shift 2 ;;
--main-guild) MAIN_GUILD_NAME="${2:?}"; shift 2 ;; --main-guild) MAIN_GUILD_NAME="${2:?}"; shift 2 ;;
--ssh-port) SSH_PORT="${2:?}"; shift 2 ;; --ssh-port) SSH_PORT="${2:?}"; shift 2 ;;
@@ -569,10 +574,14 @@ generate_secrets() {
local env_existing=0 local env_existing=0
[ -f "$GLCHAT_ENV_FILE" ] && env_existing=1 [ -f "$GLCHAT_ENV_FILE" ] && env_existing=1
# Секреты переиспользуются при любом повторном запуске: MASTER_KEY
# расшифровывает email/PII и TOTP-секреты, SESSION_PEPPER — blind index,
# LIVEKIT_API_SECRET подписывает токены. Их смена делает существующие
# данные нечитаемыми, поэтому она возможна только явным --rotate-secrets.
secret_or_new() { secret_or_new() {
local key="$1" generator="${2:-rand_hex}" local key="$1" generator="${2:-rand_hex}"
local current="" local current=""
if [ "$env_existing" = "1" ] && [ "$RECONFIGURE" = "1" ]; then if [ "$env_existing" = "1" ] && [ "$ROTATE_SECRETS" != "1" ]; then
current="$(env_get "$key" || true)" current="$(env_get "$key" || true)"
fi fi
if [ -n "$current" ]; then if [ -n "$current" ]; then
@@ -582,6 +591,19 @@ generate_secrets() {
fi fi
} }
if [ "$ROTATE_SECRETS" = "1" ] && [ "$env_existing" = "1" ]; then
warn "ВНИМАНИЕ: --rotate-secrets меняет MASTER_KEY/SESSION_PEPPER."
warn "Существующие email/PII, blind index и TOTP-секреты станут нечитаемыми:"
warn "нужен чистый инстанс (сначала сделайте бэкап и удалите базу)."
fi
# Существующая база без секретов в .env — почти всегда потерянный .env:
# молча сгенерировать новые ключи значит сделать данные нечитаемыми.
if [ "$ROTATE_SECRETS" != "1" ] && [ -f "${GLCHAT_DATA_DIR}/db/glchat.db" ] \
&& [ -z "$(env_get MASTER_KEY || true)" ]; then
die "найдена база ${GLCHAT_DATA_DIR}/db/glchat.db, но в ${GLCHAT_ENV_FILE} нет MASTER_KEY: восстановите .env из бэкапа (иначе данные не расшифровать) или запустите с --rotate-secrets на чистой базе"
fi
SESSION_PEPPER="$(secret_or_new SESSION_PEPPER rand_hex)" SESSION_PEPPER="$(secret_or_new SESSION_PEPPER rand_hex)"
MASTER_KEY="$(secret_or_new MASTER_KEY rand_hex)" MASTER_KEY="$(secret_or_new MASTER_KEY rand_hex)"
TOTP_ENCRYPTION_KEY="$(secret_or_new TOTP_ENCRYPTION_KEY rand_hex)" TOTP_ENCRYPTION_KEY="$(secret_or_new TOTP_ENCRYPTION_KEY rand_hex)"
@@ -601,10 +623,9 @@ generate_secrets() {
# без смены владельца приложение не сможет создать базу (AGENT.md 4.3). # без смены владельца приложение не сможет создать базу (AGENT.md 4.3).
set_data_ownership set_data_ownership
if [ "$env_existing" = "1" ] && [ "$RECONFIGURE" = "1" ] && [ -f "${GLCHAT_DATA_DIR}/backups/age-key.txt" ]; then # Ключ шифрования бэкапов тоже сохраняем: иначе старые бэкапы не прочитать.
AGE_KEY_FILE="${GLCHAT_DATA_DIR}/backups/age-key.txt"
else
AGE_KEY_FILE="${GLCHAT_DATA_DIR}/backups/age-key.txt" AGE_KEY_FILE="${GLCHAT_DATA_DIR}/backups/age-key.txt"
if [ ! -f "$AGE_KEY_FILE" ]; then
generate_age_key "$AGE_KEY_FILE" generate_age_key "$AGE_KEY_FILE"
fi fi
chmod 600 "$AGE_KEY_FILE" chmod 600 "$AGE_KEY_FILE"
+24 -3
View File
@@ -61,12 +61,33 @@ timeout 600 docker exec -i "$CONTAINER" bash -lc '
--admin admin@gl.test.local --password "test-password-123" --yes --admin admin@gl.test.local --password "test-password-123" --yes
' </dev/null ' </dev/null
run_install "2/4 install.sh (боевой прогон: TLS и публичные порты не трогаем)" run_install "2/5 install.sh (боевой прогон: TLS и публичные порты не трогаем)"
run_install "3/4 идемпотентность: повторный запуск с --reconfigure" --reconfigure
# Секреты обязаны переживать повторную установку: MASTER_KEY расшифровывает
# PII и TOTP, SESSION_PEPPER — blind index. Их смена ломает данные инстанса.
log "3/5 секреты до повторного запуска"
secrets_before="$(docker exec "$CONTAINER" bash -lc '
grep -E "^(MASTER_KEY|SESSION_PEPPER|TOTP_ENCRYPTION_KEY|WEBHOOK_SECRET|LIVEKIT_API_SECRET|AGE_RECIPIENT)=" /opt/glchat/.env | sort
')"
printf '%s\n' "$secrets_before"
run_install "4/5 идемпотентность: повторный запуск с --reconfigure" --reconfigure
log "5/5 секреты после повторного запуска"
secrets_after="$(docker exec "$CONTAINER" bash -lc '
grep -E "^(MASTER_KEY|SESSION_PEPPER|TOTP_ENCRYPTION_KEY|WEBHOOK_SECRET|LIVEKIT_API_SECRET|AGE_RECIPIENT)=" /opt/glchat/.env | sort
')"
printf '%s\n' "$secrets_after"
if [ "$secrets_before" != "$secrets_after" ]; then
echo "ОШИБКА: повторный запуск установщика изменил секреты (данные станут нечитаемыми)" >&2
diff <(printf '%s\n' "$secrets_before") <(printf '%s\n' "$secrets_after") >&2 || true
exit 1
fi
echo "секреты сохранены при повторной установке: ok"
# Сборка образа внутри DinD невозможна (overlay поверх overlay), поэтому образ # Сборка образа внутри DinD невозможна (overlay поверх overlay), поэтому образ
# собирается и проверяется отдельно на хосте (make docker-build + docker compose). # собирается и проверяется отдельно на хосте (make docker-build + docker compose).
log "4/4 проверки состояния установки" log "проверки состояния установки"
docker exec "$CONTAINER" bash -lc ' docker exec "$CONTAINER" bash -lc '
set -e set -e
echo "--- артефакты ---" echo "--- артефакты ---"