feat(ops): off-box копия рантайм-конфига — зашифрованной, иначе никак #3146
3 changed files with 242 additions and 0 deletions
97
backend/tests/ops/test_2203_env_offbox.py
Normal file
97
backend/tests/ops/test_2203_env_offbox.py
Normal 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
129
ops/backup-env-offbox.sh
Normal 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 "Готово."
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue