Merge pull request 'feat(ops): off-box копия рантайм-конфига — зашифрованной, иначе никак' (#3146) from feat/2203-env-offbox-copy into main
All checks were successful
Deploy Infra Host / sync-infra-host (push) Successful in 5s
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 39s
Deploy / build-worker (push) Successful in 38s
Deploy / deploy (push) Successful in 1m4s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 12s

This commit is contained in:
lekss361 2026-08-27 14:00:45 +00:00
commit 3297c1b683
3 changed files with 242 additions and 0 deletions

View file

@ -0,0 +1,97 @@
"""Off-box копия рантайм-конфига не должна уезжать в S3 открытой (#2203).
ЗАЧЕМ КОПИЯ. В дампах есть колонки под `pgp_sym_encrypt`. Ключ к ним лежит в
рантайм-конфиге на самой машине. Дампы уезжают в S3 ежедневно, ключ никуда:
потеря машины означает «дампы есть, расшифровать нечем».
ГЛАВНОЕ СВОЙСТВО, КОТОРОЕ ЗДЕСЬ СТОРОЖИТСЯ. Копия кладётся в тот же бакет, что
и дампы другого места без расширения периметра доступа нет (SSH между хостами
отсутствует, проверено). Поэтому она обязана быть зашифрована: ключ рядом с
шифротекстом обнуляет шифрование целиком.
Отсюда fail-closed: без парольной фразы скрипт ПАДАЕТ, а не выгружает файл
открытым. Молчаливая выгрузка ключа в бакет с дампами хуже отсутствия копии
она создаёт ложное чувство защищённости.
Проверено прогоном на хосте 27.08 (подставной исходник, боевой не трогался):
без фразы код 1, файлов создано 0
с фразой «Зашифровано и проверено расшифровкой», 110 байт
своей фразой читается, чужой нет
"""
from __future__ import annotations
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[3]
SCRIPT = REPO_ROOT / "ops" / "backup-env-offbox.sh"
CRON = REPO_ROOT / "ops" / "crontab-poincare.cron"
def _text() -> str:
return SCRIPT.read_text(encoding="utf-8")
def _code() -> str:
"""Только исполняемые строки, без комментариев.
В шапке скрипта лежит инструкция по восстановлению, и в ней тоже есть
`gpg --decrypt`. Проверки порядка обязаны смотреть на код иначе они
сравнивают позиции в документации, а не в программе.
"""
lines = [ln for ln in _text().splitlines() if not ln.lstrip().startswith("#")]
return chr(10).join(lines)
def test_script_exists_and_is_wired_into_cron() -> None:
"""Скрипт без вызова — мёртвый код; страховка от проверки пустоты."""
assert SCRIPT.is_file(), "ops/backup-env-offbox.sh пропал"
assert "backup-env-offbox.sh" in CRON.read_text(encoding="utf-8"), (
"скрипт не вызывается из crontab-poincare.cron — копия делаться не будет"
)
def test_refuses_to_run_without_passphrase() -> None:
"""Без парольной фразы — выход с ошибкой, а не выгрузка открытого файла."""
guard = re.search(
r'if \[\[ -z "\$\{ENV_OFFBOX_PASSPHRASE:-\}" \]\]; then(.+?)\bfi\b', _text(), re.S
)
assert guard, "нет проверки на заданность парольной фразы"
assert "exit 1" in guard.group(1), (
"проверка есть, но скрипт продолжает работу — файл уедет открытым"
)
def test_encryption_happens_before_upload() -> None:
"""Шифрование обязано стоять ДО выгрузки, иначе порядок ничего не гарантирует."""
code = _code()
assert code.index("--symmetric") < code.index("s3 cp"), "выгрузка идёт раньше шифрования"
assert "--cipher-algo AES256" in code
def test_passphrase_never_passed_as_command_line_argument() -> None:
"""Фраза уходит через дескриптор, а не аргументом.
`--passphrase '<фраза>'` в командной строке видна в `ps` любому пользователю
машины на хосте, где крутится прод, это не теоретическая придирка.
"""
code = _code()
assert "--passphrase-fd 3" in code, "фраза не передаётся через дескриптор"
assert not re.search(r'--passphrase\s+"?\$\{?ENV_OFFBOX_PASSPHRASE', code), (
"фраза передаётся аргументом командной строки — видна в ps"
)
def test_encrypted_file_is_verified_by_decrypting_it_back() -> None:
"""Проверка обратным чтением — иначе годами возили бы нечитаемый мусор.
Отказ такой копии обнаруживается ровно в тот момент, когда она понадобилась,
то есть в худший из возможных.
"""
code = _code()
assert "--decrypt" in code, "нет проверки расшифровкой после шифрования"
assert code.index("--symmetric") < code.index("--decrypt") < code.index("s3 cp"), (
"проверка расшифровкой стоит не между шифрованием и выгрузкой"
)

129
ops/backup-env-offbox.sh Normal file
View file

@ -0,0 +1,129 @@
#!/usr/bin/env bash
# Off-box копия рантайм-конфига продукта (#2203, четвёртый пункт приёмки).
#
# ЗАЧЕМ. В дампах есть колонки под `pgp_sym_encrypt`. Ключ к ним лежит в
# рантайм-конфиге на самой машине. Дампы уезжают в S3 ежедневно, ключ — никуда.
# Потеря машины означает: дампы есть, расшифровать нечем. Ради этой связки вся
# сложность с шифрованием в базе и заводилась, и именно она сегодня не работает.
#
# КУДА КЛАДЁМ И ПОЧЕМУ ИМЕННО ТУДА. В тот же S3, но под отдельный префикс
# `env/` и ТОЛЬКО в зашифрованном виде. Разбор вариантов:
#
# - Рядом с дампами в открытом виде — нельзя. Ключ рядом с шифротекстом
# обнуляет шифрование: одна утечка доступа к бакету отдаёт и то и другое.
# - На соседний хост по SSH — потребовало бы завести доверие между машинами,
# которого сейчас нет (проверено: `Permission denied (publickey)`), то есть
# РАСШИРИТЬ периметр доступа ровно тогда, когда #3075 его сужает.
# - Отдельный бакет с отдельными ключами — правильнее всего, но требует новых
# учётных данных, которых у скрипта нет.
#
# Выбран третий по надёжности, но единственный исполнимый без расширения
# доступа: шифротекст в S3, парольная фраза — вне S3, у владельца.
#
# FAIL-CLOSED. Без парольной фразы скрипт НЕ выгружает файл открытым — он
# падает с явным сообщением. Молчаливая выгрузка ключа в тот же бакет, где
# лежат дампы, хуже отсутствия копии: она создаёт ложное чувство защищённости.
#
# ОДИН ШАГ ЗА ВЛАДЕЛЬЦЕМ. Парольная фраза обязана жить там, где переживёт смерть
# машины, иначе копия бесполезна: расшифровать будет нечем. Поэтому её нельзя
# сгенерировать здесь и оставить только на хосте.
#
# 1. Придумать/сгенерировать фразу и положить её в vault
# (`meta/00_credentials.md`) — это отдельная система на другой машине.
# 2. Добавить строкой в /etc/default/gendesign-backup:
# ENV_OFFBOX_PASSPHRASE='<та же фраза>'
#
# Восстановление: скачать .gpg из s3://<bucket>/env/, затем
# gpg --batch --decrypt --passphrase '<фраза>' <файл>.gpg > .env.runtime
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./lib-backup.sh
source "$SCRIPT_DIR/lib-backup.sh"
BACKUP_ENV_FILE="${BACKUP_ENV_FILE:-/etc/default/gendesign-backup}"
SOURCE_FILE="${ENV_OFFBOX_SOURCE:-/opt/gendesign/backend/.env.runtime}"
STAGE_DIR="${ENV_OFFBOX_STAGE:-/opt/gendesign/backups/env}"
KEEP="${ENV_OFFBOX_KEEP:-14}"
log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; }
# shellcheck source=/dev/null
[[ -f "$BACKUP_ENV_FILE" ]] && source "$BACKUP_ENV_FILE"
if [[ ! -f "$SOURCE_FILE" ]]; then
log "ОШИБКА: нечего копировать — $SOURCE_FILE не найден."
notify "🔴 off-box копия рантайм-конфига: исходный файл не найден на $(hostname)"
exit 1
fi
# Fail-closed: см. шапку. Открытым в бакет с дампами — никогда.
if [[ -z "${ENV_OFFBOX_PASSPHRASE:-}" ]]; then
log "ОШИБКА: ENV_OFFBOX_PASSPHRASE не задан в $BACKUP_ENV_FILE."
log "Открытым в тот же бакет, где лежат дампы, выгружать НЕ будем — это"
log "обнулило бы шифрование колонок. Как задать — см. шапку скрипта."
notify "🔴 off-box копия рантайм-конфига НЕ делается: не задана парольная фраза (см. ops/backup-env-offbox.sh)"
exit 1
fi
if [[ -z "${S3_BUCKET:-}" || -z "${S3_ENDPOINT:-}" || -z "${S3_ACCESS_KEY:-}" || -z "${S3_SECRET_KEY:-}" ]]; then
log "ОШИБКА: не заданы S3_* — выгружать некуда."
notify "🔴 off-box копия рантайм-конфига: не заданы S3-параметры на $(hostname)"
exit 1
fi
mkdir -p "$STAGE_DIR"
chmod 700 "$STAGE_DIR"
STAMP="$(date -u +'%Y%m%d_%H%M%S')"
OUT="$STAGE_DIR/env-runtime-$(hostname -s)-${STAMP}.gpg"
# --batch + --passphrase-fd 3: фраза уходит через дескриптор, а не аргументом
# командной строки — иначе она видна в `ps` любому пользователю машины.
if ! gpg --batch --yes --symmetric --cipher-algo AES256 \
--passphrase-fd 3 --output "$OUT" "$SOURCE_FILE" 3<<<"$ENV_OFFBOX_PASSPHRASE"; then
log "ОШИБКА: шифрование не удалось, выгрузки не будет."
rm -f "$OUT"
notify "🔴 off-box копия рантайм-конфига: шифрование не удалось на $(hostname)"
exit 1
fi
chmod 600 "$OUT"
# Проверяем, что получившийся файл действительно расшифровывается ТОЙ ЖЕ фразой.
# Без этой проверки мы бы годами возили в S3 нечитаемый мусор и узнали об этом
# в момент, когда он понадобился.
if ! gpg --batch --decrypt --passphrase-fd 3 --output /dev/null "$OUT" 3<<<"$ENV_OFFBOX_PASSPHRASE" 2>/dev/null; then
log "ОШИБКА: файл зашифрован, но обратно не читается — выгрузки не будет."
rm -f "$OUT"
notify "🔴 off-box копия рантайм-конфига: проверка расшифровкой провалилась на $(hostname)"
exit 1
fi
log "Зашифровано и проверено расшифровкой: $(basename "$OUT") ($(stat -c %s "$OUT") байт)"
log "Выгружаю в s3://${S3_BUCKET}/env/"
# AWS_CA_BUNDLE — та же причина, что в ops/backup.sh: собственный CA-набор
# aws-cli не содержит корня, которым подписан сертификат Selectel.
if ! docker run --rm \
-e AWS_ACCESS_KEY_ID="$S3_ACCESS_KEY" \
-e AWS_SECRET_ACCESS_KEY="$S3_SECRET_KEY" \
-e AWS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt \
-v "$STAGE_DIR":/env:ro \
amazon/aws-cli:latest \
--endpoint-url "$S3_ENDPOINT" \
s3 cp --no-progress "/env/$(basename "$OUT")" "s3://${S3_BUCKET}/env/"; then
log "ОШИБКА: выгрузка в S3 не удалась."
notify "🔴 off-box копия рантайм-конфига: выгрузка в S3 не удалась на $(hostname)"
exit 1
fi
log "Выгрузка ok."
# Локальная ротация. Держим несколько последних: рантайм-конфиг меняется редко,
# и старая копия — единственный способ расшифровать дамп, снятый ДО правки.
mapfile -t OLD < <(ls -1t "$STAGE_DIR"/env-runtime-*.gpg 2>/dev/null | tail -n +$((KEEP + 1)))
if ((${#OLD[@]})); then
printf '%s\n' "${OLD[@]}" | xargs -r rm -f
log "Локально удалено копий сверх ${KEEP}: ${#OLD[@]}"
fi
log "Готово."

View file

@ -62,6 +62,22 @@
# первое выпадает на понедельник) и до ночных бэкапов в 03:30.
0 3 * * 1 bash -c 'f=$(ls -t /opt/gendesign/backups/tradein/tradein-[0-9]*.sql.gz 2>/dev/null | head -1); if [ -n "$f" ]; then bash /opt/gendesign/ops/restore-drill.sh "$f"; else echo "no tradein dump found to drill"; exit 1; fi' >> /opt/gendesign/logs/restore-drill.log 2>&1
# ── Off-box копия рантайм-конфига (#2203) ────────────────────────────────────
# Четвёртый пункт приёмки #2203. В дампах есть колонки под pgp_sym_encrypt;
# ключ к ним лежит на самой машине и никуда не уезжает. Потеря машины =
# дампы есть, расшифровать нечем.
#
# 04:10 — после обоих ночных бэкапов (03:30 и 04:30 идёт tradein, но он берёт
# свой дамп независимо), до обогащения в 05:00. Ежедневно, потому что копия
# должна соответствовать дампу того же дня: конфиг меняют редко, но если
# поменяли, старая копия не расшифрует свежий дамп.
#
# Скрипт fail-closed: без ENV_OFFBOX_PASSPHRASE в /etc/default/gendesign-backup
# он НЕ выгружает файл открытым, а падает с явным сообщением и алертом.
# Открытым в тот же бакет, где лежат дампы, класть нельзя — это обнулило бы
# шифрование колонок (ключ рядом с шифротекстом).
10 4 * * * bash /opt/gendesign/ops/backup-env-offbox.sh >> /opt/gendesign/logs/env-offbox.log 2>&1
# ── Обогащение и геокодирование (ходят в tradein-backend) ───────────────────
0 5 * * * cd /opt/gendesign/tradein-mvp && docker exec tradein-backend python -m scripts.backfill_houses_dadata --limit 100 --priority both >> /opt/gendesign/logs/dadata-backfill.log 2>&1
30 5 * * * cd /opt/gendesign/tradein-mvp && docker exec tradein-backend python -m scripts.geocode_deals_from_houses --limit 50000 >> /opt/gendesign/logs/deals-geocode.log 2>&1