gendesign/ops/backup-env-offbox.sh
bot-backend de940c7534
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
feat(ops): off-box копия рантайм-конфига — зашифрованной, иначе никак
Четвёртый пункт приёмки #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
2026-08-27 16:32:27 +03:00

129 lines
8.1 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 "Готово."