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
Четвёртый пункт приёмки #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
129 lines
8.1 KiB
Bash
129 lines
8.1 KiB
Bash
#!/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 "Готово."
|