Перейти к содержимому
SafePass

Развёртывание SafePass в рабочей среде

Руководство рассчитано на один Linux-хост с Docker Engine от root и плагином Docker Compose v2. Caddy завершает публичный TLS и проксирует запросы к API. Порт PostgreSQL на хост не публикуется. Устанавливайте обновления хоста, ограничивайте доступ к сокету Docker администраторами и держите резервные копии вне Docker-хоста.

Требования

  • DNS-имя API с рабочей записью A и, если опубликована, AAAA.
  • Входящие TCP-порты 80 и 443. UDP 443 нужен только для HTTP/3.
  • Docker Engine, Docker Compose v2, OpenSSL, age и age-keygen.
  • Защищённое офлайн-хранилище комплекта секретов развёртывания, закрытого ключа age и копии ключа подписи. Закрытые ключи храните отдельно от архивов PostgreSQL.

Первое развёртывание

В корне репозитория создайте файл несекретных настроек Compose:

cp infra/deployment/env.example .env
chmod 0600 .env

Укажите DNS-имя для клиентов в SAFEPASS_API_DOMAIN и контакт эксплуатации в SAFEPASS_TLS_EMAIL. После регистрации клиентов сохраняйте значения издателя и аудитории API. Файл .env не содержит паролей и закрытых ключей.

Один раз создайте файлы секретов и настройку сервера OPAQUE:

sudo bash scripts/bootstrap-production-secrets.sh

Скрипт собирает образ API и создаёт разные учётные данные администратора PostgreSQL, приложения и миграций, ключ подписи JWT, ключ шифрования сессий и настройку OPAQUE. Если файл секрета уже существует, скрипт завершается с ошибкой. Сохраните весь каталог infra/deployment/secrets в отдельном защищённом менеджере секретов или зашифрованной офлайн-копии. Потеря или замена OpaqueAuth__ServerSetup делает существующие аккаунты OPAQUE недействительными. На настроенном развёртывании повторно запускать bootstrap нельзя.

Compose монтирует секреты в /run/secrets. API читает имена с двойным подчёркиванием через провайдер .NET key-per-file. Скрипт выставляет владельцев файлов для пользователя API без root и пользователя инициализации PostgreSQL по числовым ID закреплённых образов. Для этого нужен Docker от root. Секреты Compose на основе файлов монтируются напрямую, поэтому исходные файлы защищаются правами доступа хоста. Docker их не шифрует.

Проверьте конфигурацию и запустите стек. В командах замените api.example.com своим доменом:

sudo docker compose --env-file .env -f infra/deployment/compose.yaml config --quiet
sudo docker compose --env-file .env -f infra/deployment/compose.yaml up -d --build
sudo docker compose --env-file .env -f infra/deployment/compose.yaml ps
curl --fail --show-error https://api.example.com/health/ready

Caddy автоматически получает и обновляет сертификат и перенаправляет HTTP на HTTPS. Состояние сертификатов хранится в постоянном томе /data. API доступен во внутренних сетях Compose. PostgreSQL находится в отдельной внутренней сети без доступа с хоста или Caddy. Роль администратора базы используется внутри контейнера PostgreSQL. У API ограниченная роль, у миграций отдельная роль.

Пограничная сеть назначает Caddy постоянный частный адрес. API доверяет заголовкам IP клиента и HTTPS только от этого прокси. Проверьте, что подсеть 172.30.0.0/24 не пересекается с локальной сетью или другими сетями Docker. При необходимости меняйте подсеть и оба адреса сервисов вместе.

В клиентах используйте https://${SAFEPASS_API_DOMAIN}. Публичные маршруты состояния выдают только live или ready. В Caddy не включены журналы доступа и запись тел запросов или заголовков авторизации.

Зашифрованные резервные копии

Создайте ключ age на защищённой машине или в офлайн-хранилище. Закрытый ключ храните вне хоста SafePass:

age-keygen -o /secure/safepass-backup-identity.txt
chmod 0600 /secure/safepass-backup-identity.txt
age-keygen -y /secure/safepass-backup-identity.txt

Открытый ключ age1… укажите в SAFEPASS_BACKUP_AGE_RECIPIENT. В SAFEPASS_BACKUP_DIR укажите смонтированный том или каталог, копируемый за пределы хоста. Ключ age используется для шифрования и расшифровки и отделён от ключа подписи Ed25519.

Создайте отдельный ключ Ed25519 на хосте. Сервис резервирования подписывает каждый архив, поэтому закрытый ключ должен принадлежать root и читаться только им. Храните отдельно защищённую офлайн-копию закрытого ключа. Доверенную офлайн-копию открытого ключа держите с инструкциями восстановления и устанавливайте её на хосты восстановления. Не берите ключ проверки из архива или его каталога. При ротации сохраняйте предыдущие открытые ключи до истечения срока соответствующих копий и выбирайте ключ, которым подписан проверяемый архив.

sudo install -d -o root -g root -m 0700 /etc/safepass
sudo bash -c '
  set -euo pipefail
  umask 077
  test ! -e /etc/safepass/backup-signing-key.pem
  test ! -e /etc/safepass/backup-signing-public.pem
  openssl genpkey -algorithm ED25519 -out /etc/safepass/backup-signing-key.pem
  openssl pkey -in /etc/safepass/backup-signing-key.pem -pubout -out /etc/safepass/backup-signing-public.pem
  chmod 0600 /etc/safepass/backup-signing-key.pem
  chmod 0644 /etc/safepass/backup-signing-public.pem
'

Запишите отпечаток открытого ключа в офлайн-инструкции и сверяйте при переносе:

openssl pkey -pubin -in /etc/safepass/backup-signing-public.pem -outform DER | sha256sum

Отдельная подпись хранится в <archive>.sig. Ed25519 подписывает строку домена SafePass PostgreSQL backup signature v1, байт NUL и SHA-256 зашифрованного архива. Хеш вычисляется потоком. Храните .age и .age.sig вместе. Политика хранения удаляет оба. Подпись подтверждает ключ происхождения архива, а age обеспечивает шифрование и целостность шифротекста.

Разовая копия:

sudo env \
  SAFEPASS_BACKUP_AGE_RECIPIENT='age1replace-with-your-public-key' \
  SAFEPASS_BACKUP_SIGNING_KEY=/etc/safepass/backup-signing-key.pem \
  SAFEPASS_BACKUP_DIR=/mnt/offsite/safepass \
  SAFEPASS_BACKUP_RETENTION_DAYS=35 \
  scripts/backup-postgres.sh

Для ежедневного запуска установите таймер systemd. Если репозиторий расположен в другом месте, замените /opt/safepass в файле службы. В принадлежащем root файле окружения укажите получателя age, путь ключа подписи и каталог копий. Служба импортирует путь к ключу и монтирует его только для чтения в своём пространстве файловой системы:

sudo install -d -o root -g root -m 0700 /etc/safepass
sudo install -o root -g root -m 0600 infra/deployment/backup.env.example /etc/safepass/backup.env
sudoedit /etc/safepass/backup.env
sudo install -o root -g root -m 0644 infra/deployment/systemd/safepass-backup.service /etc/systemd/system/
sudo install -o root -g root -m 0644 infra/deployment/systemd/safepass-backup.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now safepass-backup.timer

Если SAFEPASS_BACKUP_DIR отличается от /var/backups/safepass, добавьте новый путь в ReadWritePaths службы до включения таймера. Если SAFEPASS_BACKUP_SIGNING_KEY отличается от /etc/safepass/backup-signing-key.pem, замените путь в ReadOnlyPaths.

Таймер запускается ежедневно в 03:17 UTC со случайной задержкой до 20 минут. Проверяйте запуски через journalctl -u safepass-backup.service, состояние таймера — через systemctl list-timers safepass-backup.timer.

Копирование за пределы хоста

Для защиты от потери хоста нужна внешняя копия. Та же служба запускает scripts/push-backup-offsite.sh через ExecStartPost: свежий архив .age и подпись .sig копируются на rclone-назначение, проверяются суммы SHA-1, а удалённые архивы старше SAFEPASS_BACKUP_RETENTION_DAYS удаляются. Укажите назначение в /etc/safepass/backup.env:

SAFEPASS_BACKUP_OFFSITE_REMOTE=safepassb2:safepass-backups

Пустое значение пропускает выгрузку и оставляет хост без защиты от его потери. rclone хранит учётные данные в ~/.config/rclone/rclone.conf с правами 0600 и владельцем root. Для Backblaze B2 достаточно ключа приложения в пределах одного бакета с readFiles, writeFiles, listFiles и deleteFiles. Несовпадение суммы переводит службу в состояние ошибки.

sudo systemctl start safepass-backup.service
sudo journalctl -u safepass-backup.service -n 5
sudo -i rclone lsf safepassb2:safepass-backups

Мониторинг пропущенных копий

Отдельная ежечасная проверка scripts/check-backup-freshness.sh обнаруживает отсутствие каталога или архивов, превышение SAFEPASS_BACKUP_MAX_AGE_HOURS (по умолчанию 26 часов с запасом на задержку таймера), пустой архив, отсутствие .sig и неверную подпись, если задан SAFEPASS_BACKUP_SIGNING_PUBLIC_KEY. Время берётся из UTC-метки имени файла, поскольку копирование может менять файловые времена. Проверка читает имена, размеры и подписи без расшифровки.

При отказе она пишет строку в журнал и передаёт её как единственный аргумент исполняемому файлу SAFEPASS_BACKUP_ALERT_COMMAND, если он задан. Строка содержит хост, имя файла и возраст. Настройте параметры в /etc/safepass/backup.env и установите юниты:

sudo install -o root -g root -m 0644 infra/deployment/systemd/safepass-backup-check.service /etc/systemd/system/
sudo install -o root -g root -m 0644 infra/deployment/systemd/safepass-backup-check.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now safepass-backup-check.timer
sudo systemctl start safepass-backup-check.service && journalctl -u safepass-backup-check.service -n 5

Служба монтирует /var/backups/safepass и /etc/safepass только для чтения. Для другого каталога или скрипта добавьте путь в ReadOnlyPaths. Код 1 означает отсутствие, устаревание или неполноту копии, код 2 — неверные настройки. Отслеживайте также состояние юнита внешним монитором, чтобы обнаружить недоступность самого хоста.

Проверка восстановления копии

Регулярно запускайте проверку с закрытым ключом age, доступным только администратору, и доверенной копией открытого ключа подписи. Скрипт проверяет отдельную подпись до создания базы и расшифровки. Затем он создаёт временную базу со случайным именем, передаёт расшифрованный поток в pg_restore без записи открытого дампа, проверяет версию миграций и таблицы SafePass, после чего удаляет временную базу. Открытый ключ храните отдельно от копий.

sudo env SAFEPASS_BACKUP_AGE_IDENTITY=/secure/safepass-backup-identity.txt \
  SAFEPASS_BACKUP_SIGNING_PUBLIC_KEY=/etc/safepass/backup-signing-public.pem \
  scripts/verify-postgres-restore.sh /mnt/offsite/safepass/safepass-postgres-YYYYMMDDTHHMMSSZ.dump.age

Проверка сохраняет рабочую базу. Для запуска нужны здоровый PostgreSQL и исходные роли развёртывания.

Учебное аварийное восстановление

Проводите проверку не реже раза в квартал и после изменений резервирования, схемы или управления секретами. Нужно подтвердить работу сервиса на восстановленной базе:

  1. Выберите свежий внешний архив и выполните scripts/check-backup-freshness.sh с доверенным SAFEPASS_BACKUP_SIGNING_PUBLIC_KEY.
  2. Выполните scripts/verify-postgres-restore.sh для подписи, расшифровки, версии схемы и таблиц.
  3. На отдельном хосте или в отдельном проекте восстановите архив по разделу аварийного восстановления. Запустите API с исходным комплектом секретов и убедитесь на тестовом аккаунте, что вход работает и клиент расшифровывает полученные при синхронизации записи.
  4. Запишите дату, имя архива, время восстановления и отклонения. Дополните руководство по результатам.
Дата Среда Результат
2026-10-22 Локальный PostgreSQL 17, API из main, без Docker Тестовый аккаунт с тремя зашифрованными клиентом записями прошёл рабочую цепочку pg_dump → age → подпись Ed25519 и проверку свежести. Подпись сверена до расшифровки. Потоковый pg_restore --exit-on-error --single-transaction восстановил схему версии 7 со всеми 8 обязательными таблицами. Все 9 таблиц совпали по числу строк и хешу содержимого. Вход, синхронизация и расшифровка всех записей исходным ключом сработали. Сдвиг часов на 30 часов вызвал ожидаемый отказ проверки свежести.
2026-11-04 VPS Ubuntu 24.04, Docker от root, PostgreSQL 17.11, Caddy, api.safe-pass.app Свежий архив получен из внешнего Backblaze B2. Подпись проверена доверенным ключом до потоковой расшифровки во временную базу: 9 таблиц, схема 8. Отдельный API ответил {"status":"live"} и {"status":"ready"} во внутренней сети. Временный контейнер и база удалены. Рабочий сервис продолжил работу. В тот же день проверка свежести обнаружила отсутствующий каталог, вернула код 1 и отправила уведомление в Telegram как при прямом запуске, так и из изолированной службы systemd.

Аварийное восстановление

На чистом хосте используйте исходный .env и точный комплект секретов того же развёртывания. Не создавайте новую настройку OPAQUE. Сначала инициализируйте том PostgreSQL и роли:

sudo docker compose --env-file .env -f infra/deployment/compose.yaml up -d postgres
sudo docker compose --env-file .env -f infra/deployment/compose.yaml exec -T postgres \
  pg_isready -U safepass_admin -d safepass

Перед расшифровкой age и восстановлением проверьте архив доверенным открытым ключом подписи. Сверьте его с офлайн-копией и записанным отпечатком. Скопируйте зашифрованный архив в закрытый временный каталог root и используйте одну и ту же копию для проверки и расшифровки. Убедитесь, что хватает места. Временные файлы не содержат открытых данных базы:

sudo bash <<'SAFEPASS_RESTORE'
set -euo pipefail
umask 077
backup_file=/mnt/offsite/safepass/safepass-postgres-YYYYMMDDTHHMMSSZ.dump.age
snapshot_dir="$(mktemp -d /var/lib/safepass-restore.XXXXXXXXXX)"
trap 'rm -rf -- "$snapshot_dir"' EXIT
snapshot_file="$snapshot_dir/backup.dump.age"
signature_input="$snapshot_dir/signature-input"
cp -- "$backup_file" "$snapshot_file"
printf '%s\0' 'SafePass PostgreSQL backup signature v1' > "$signature_input"
openssl dgst -sha256 -binary "$snapshot_file" >> "$signature_input"
openssl pkeyutl -verify -rawin -pubin -inkey /etc/safepass/backup-signing-public.pem -in "$signature_input" -sigfile "$backup_file.sig"
 
set -o pipefail
age -d -i /secure/safepass-backup-identity.txt \
  "$snapshot_file" \
  | docker compose --env-file .env -f infra/deployment/compose.yaml exec -T postgres \
      pg_restore --username=safepass_admin --dbname=safepass --exit-on-error --single-transaction
SAFEPASS_RESTORE

Запустите приложение и проверьте готовность:

sudo docker compose --env-file .env -f infra/deployment/compose.yaml up -d --build
curl --fail --show-error https://api.example.com/health/ready

При восстановлении на том же хосте остановите API и прокси, сохраните имеющуюся базу под другим именем и восстановите архив в новую пустую safepass. Не удаляйте сохранённую базу до проверки входа, синхронизации и восстановления. Не восстанавливайте дамп поверх рабочих таблиц.

Сайт

safe-pass.app раздаёт статический экспорт Next.js из apps/website. Сборка и публикация:

bash scripts/deploy-site.sh

Скрипт собирает привязку WASM при её отсутствии, запускает next build в контейнере Node и заменяет содержимое infra/deployment/site. Caddy монтирует этот каталог только для чтения как /srv/site. Обновляйте содержимое на месте: bind mount привязан к inode, и пересоздание каталога может оставить контейнер на старой точке монтирования до перезапуска.

Веб-кабинет (необязательно)

Кабинет обращается к API из браузера. По умолчанию это отключено. Для доступа укажите точный origin сайта в SAFEPASS_WEB_ORIGINS в .env, например SAFEPASS_WEB_ORIGINS=https://safepass.example. Несколько значений разделяются запятыми. Требуется HTTPS без пути. HTTP допустим только для localhost, 127.0.0.1 и [::1]. Недействительное значение останавливает запуск API. CORS разрешает только указанные источники без передачи cookies. Токены идут в заголовке Authorization, API не устанавливает cookies.

Обновления и эксплуатация

Проверяйте новые патч-образы PostgreSQL перед изменением SAFEPASS_POSTGRES_IMAGE. Обновление основной версии требует плана миграции. Обновляйте API через docker compose up -d --build и проверяйте состояние и синхронизацию клиента. Сохраняйте том PostgreSQL, том Caddy /data, .env и полный комплект секретов по политике восстановления. Меняйте учётные данные через проверенное обновление ролей базы и секретов приложения. Замена настройки OPAQUE требует миграции аккаунтов на уровне протокола.

Документация