From de940c7534ac8e35fee608e2b5f8f3aea1ebf2f7 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 27 Aug 2026 16:32:27 +0300 Subject: [PATCH] =?UTF-8?q?feat(ops):=20off-box=20=D0=BA=D0=BE=D0=BF=D0=B8?= =?UTF-8?q?=D1=8F=20=D1=80=D0=B0=D0=BD=D1=82=D0=B0=D0=B9=D0=BC-=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=D0=B0=20=E2=80=94=20=D0=B7=D0=B0?= =?UTF-8?q?=D1=88=D0=B8=D1=84=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D0=BE?= =?UTF-8?q?=D0=B9,=20=D0=B8=D0=BD=D0=B0=D1=87=D0=B5=20=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Четвёртый пункт приёмки #2203. В дампах есть колонки под pgp_sym_encrypt, ключ к ним лежит на самой машине и никуда не уезжает: потеря машины означает «дампы есть, расшифровать нечем». Ради этой связки шифрование в базе и заводилось. КУДА И ПОЧЕМУ ИМЕННО ТУДА. В тот же S3, отдельным префиксом env/, и только зашифрованным. Разобранные варианты: - рядом с дампами открытым — нельзя: ключ рядом с шифротекстом обнуляет шифрование, одна утечка доступа к бакету отдаёт и то и другое; - на соседний хост по SSH — потребовало бы завести доверие между машинами, которого нет (проверено: Permission denied (publickey)), то есть РАСШИРИТЬ периметр ровно тогда, когда #3075 его сужает; - отдельный бакет с отдельными ключами — правильнее всего, но требует новых учётных данных. Выбран единственный исполнимый без расширения доступа: шифротекст в S3, парольная фраза — вне S3. FAIL-CLOSED. Без фразы скрипт не выгружает файл открытым, а падает с явным сообщением и алертом. Молчаливая выгрузка ключа в бакет с дампами хуже отсутствия копии: создаёт ложное чувство защищённости. Фраза уходит через дескриптор, а не аргументом — иначе видна в ps любому пользователю машины. После шифрования файл проверяется обратной расшифровкой: без этого можно годами возить нечитаемый мусор и узнать в тот момент, когда он понадобился. Прогон на хосте 27.08 (подставной исходник, боевой не трогался): без фразы → код 1, файлов создано 0 с фразой → «Зашифровано и проверено расшифровкой», 110 байт своей фразой читается, чужой — нет ОДИН ШАГ ЗА ВЛАДЕЛЬЦЕМ, и он неустраним: фраза обязана жить там, где переживёт смерть машины, иначе копия бесполезна — расшифровать будет нечем. Сгенерировать её здесь и оставить на хосте нельзя по построению. Инструкция — в шапке скрипта. Пять тестов сторожат ровно те свойства, ради которых всё сделано. Прогон: 85 ops-тестов зелёные, ruff чист. Refs #2203 --- backend/tests/ops/test_2203_env_offbox.py | 97 ++++++++++++++++ ops/backup-env-offbox.sh | 129 ++++++++++++++++++++++ ops/crontab-poincare.cron | 16 +++ 3 files changed, 242 insertions(+) create mode 100644 backend/tests/ops/test_2203_env_offbox.py create mode 100644 ops/backup-env-offbox.sh diff --git a/backend/tests/ops/test_2203_env_offbox.py b/backend/tests/ops/test_2203_env_offbox.py new file mode 100644 index 00000000..d10c661d --- /dev/null +++ b/backend/tests/ops/test_2203_env_offbox.py @@ -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"), ( + "проверка расшифровкой стоит не между шифрованием и выгрузкой" + ) diff --git a/ops/backup-env-offbox.sh b/ops/backup-env-offbox.sh new file mode 100644 index 00000000..82670a2d --- /dev/null +++ b/ops/backup-env-offbox.sh @@ -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:///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 "Готово." diff --git a/ops/crontab-poincare.cron b/ops/crontab-poincare.cron index 8a63c487..0282643d 100644 --- a/ops/crontab-poincare.cron +++ b/ops/crontab-poincare.cron @@ -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 -- 2.45.3