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

This commit is contained in:
mi
2026-08-14 15:42:45 +03:00
parent e06a77ee1d
commit bbef7a30c9
521 changed files with 2597 additions and 2302 deletions
@@ -0,0 +1,130 @@
# VM2 Processing deployment runbook
This directory is the independent VM2 foundation. It does not deploy VM1 or
`codebase/backend`. All commands below are operator commands; repository
creation does not execute them.
## Production blockers before first start
1. Replace every `.env` placeholder with reviewed non-secret values. Keep
`BITRIX_SYNC_ENABLED=false` until migrations, grants, portal fields, robot
contracts and cutover are signed off.
2. Fill every `*_IMAGE` variable with a reviewed registry digest. Root Compose
rejects missing image references; mutable tags are not production evidence.
3. Install production files as `root:root`; `deploy` must not be in `docker`
and must not be able to write Compose, units, helpers, allow-lists or secret
mappings.
4. Populate separate reviewed active CIDR files from the two `.template`
files. Their committed active versions are intentionally `deny all`.
5. Provision public ACME material under host `/etc/letsencrypt` and the managed
PostgreSQL CA under `/etc/han/ca`. Provision an internal-CA certificate whose
SAN matches the private VM2 name. Permit host port `8443` only from VM1 SG
and, when needed, approved private/VPN ops CIDRs.
6. Create a dedicated VM2 Selectel IAM principal. It may read only names in
`deployment/secrets/config.example.json`. Never reuse the VM1 principal.
7. `REDIS_SAFETY_ACL` is the complete ACL file, not merely a password. It must
expose unauthenticated `PING` only for health and a password-protected
`safety` user limited to required `han:safety:*` keys/commands. The password
in `MESSAGE_SAFETY_REDIS_URL` must match. Start from
`redis/redis-safety.acl.template`, replace
`REPLACE_WITH_LONG_RANDOM_PASSWORD`, and never commit the password.
8. Provision distinct runtime and migration DB credentials.
`MESSAGE_SAFETY_CONFIG_ADMIN_DATABASE_URL` may migrate/activate policy while
`MESSAGE_SAFETY_DATABASE_URL` cannot; `BITRIX_SYNC_MIGRATION_DATABASE_URL`
owns DDL while `BITRIX_SYNC_DATABASE_URL` is the least-privilege runtime
role. Migration credentials are mounted only into the `ops` profile jobs.
9. The setup script leaves UFW egress open for bootstrap. Before production,
constrain egress through Selectel SG/NAT/proxy to the approved PostgreSQL,
S3, Secrets Manager, Bitrix24, DNS/NTP, SigNoz and ClamAV destinations.
Registry/package access exists only during controlled maintenance windows.
## Install
- Bootstrap a fresh Ubuntu 24.04 VM as root with
`deployment/scripts/setup-vm.sh`, supplying `VM1_PRIVATE_CIDRS`, optional
private/VPN `OPS_CIDRS`, and separate Ed25519 public-key files for deploy and
break-glass admin. SSH is publicly reachable but key-only and protected by
fail2ban; the CIDR variables apply only to private port `8443`. The script
installs host packages/firewalls and roles but never starts Compose. Set a
separate admin sudo password; verify deploy login, admin login and admin sudo
in independent sessions before rerunning with `HARDEN_SSH=true`.
Root/deploy/admin key reuse is rejected.
- Checkout an immutable release under `/opt/han-chat/services`.
- Copy `.env.example` to root-owned mode `0600` `.env`.
- Install `secrets_loader.py` and `han-secrets` under
`/usr/local/lib/han-secrets-vm2/`, root-owned and non-writable.
- Install `han-compose` as `/usr/local/sbin/han-vm2-compose`.
- Install `han-secrets-vm2.service` and `han-processing.service` under
`/etc/systemd/system/`.
- Install `han-message-safety-mode` as root-owned `0755` and the sudoers
template as `/etc/sudoers.d/deploy-message-safety-mode` mode `0440`; validate
with `visudo -cf`. Create the dedicated host group `han-message-safety` with
GID `10001`. Before the first Compose validation, create
`/etc/han-chat/message-safety-mode.env` as
`root:han-message-safety 0640` with all three flags `false` (or invoke the
helper's `standard` transition after the fixed launcher is installed).
- Install loader config using the exact `APP_ENV` suffix. With the committed
example (`APP_ENV=production-like`) the path is
`/etc/han/secrets/vm2-production-like.selectel.json` mode `0600`. For
controlled no-provider recovery use an explicit `file`
config pointing to a root-only `0700` directory containing exactly one file
per configured key. Selectel failure never falls back automatically.
## Preflight and startup
Run `deployment/preflight.sh` first. Then, through the approved root units:
1. synchronize secrets; any missing/oversized/invalid secret blocks startup;
2. validate resolved Compose without storing its output;
3. run the two `ops` migration jobs and create/activate the reviewed initial
Message Safety config before starting either runtime;
4. validate nginx config and both certificate chains;
5. start Redis/Collector, ClamAV, application API/workers, then nginx;
6. verify that only nginx publishes `80`, `443`, and private-bound `8443`;
7. verify all non-exact public paths return `404`, HTTP webhook paths return
`426` without redirect/query reflection, wrong methods fail, and wrong
source CIDRs are rejected before upstream;
8. verify private Safety check/task/status and sync status only from approved
callers; verify public `/internal/*` is `404`;
9. canary telemetry with a fake token marker and prove query, form body,
Authorization, DSN, S3 key and object key are absent from logs/traces.
Do not open webhook traffic while `bitrix-sync` is disabled. A disabled or
failed receiver must return retryable `503`/closed routing, never successful
`2xx ignored`.
## Failure policy
- Safety dependency failure is fail-closed: VM1 must not send/promote content.
- Stale/unavailable ClamAV signatures disable file capability only; they never
convert a scan error to allow.
- Redis loss may remove acceleration but PostgreSQL remains authoritative.
- OTEL outage queues within the bounded volume and must not change verdicts.
- Rollback does not downgrade schemas, delete durable tasks/mappings, or run
`docker compose down -v`.
## Emergency MOCK
Only these five sudo commands are allowed:
```text
han-message-safety-mode standard
han-message-safety-mode mock --text-free true --file-free true
han-message-safety-mode mock --text-free true --file-free false
han-message-safety-mode mock --text-free false --file-free true
han-message-safety-mode mock --text-free false --file-free false
```
The helper atomically writes only
`/etc/han-chat/message-safety-mode.env`, recreates only the Safety API, checks
health, and restores the previous mode on failure. MOCK has no timeout: keep a
high-severity alert active until explicit `standard`, then verify normal
text/link/file capabilities and an EICAR canary.
## Known image exceptions
ClamAV images may require UID/path adjustments after validating the exact
digest. Do not weaken `read_only`, capabilities or mounts globally: document
the smallest writable signature/runtime paths and compensate with network and
resource limits. `freshclam` alone receives signature-CDN egress; `clamd`
receives none.
@@ -0,0 +1,917 @@
# Ранбук развёртывания Processing на VM2
Этот каталог — независимая основа VM2. Он не разворачивает VM1 и не
затрагивает `codebase/backend`. Все команды ниже — операторские; создание
репозитория их не выполняет.
## Блокеры production перед первым запуском
1. Замените каждый плейсхолдер в `.env` на проверенные несекретные значения.
Держите `BITRIX_SYNC_ENABLED=false`, пока не подписаны миграции, гранты,
поля портала, контракты роботов и cutover.
(для этого нужно еще образы отправить в conteiner registry, пункт 2)
2. Заполните каждую переменную `*_IMAGE` проверенным digest из registry.
Корневой Compose отклоняет отсутствующие ссылки на образы; изменяемые
теги не являются доказательством для production.
3. Устанавливайте production-файлы от `root:root`; пользователь `deploy` не
должен входить в группу `docker` и не должен иметь возможность писать
Compose, unit-файлы, хелперы, allow-list’ы или маппинги секретов.
(смысл: заходим под админом, sudo -i)
4. Заполните отдельные проверенные активные CIDR-файлы из двух `.template`.
Их закоммиченные активные версии намеренно содержат `deny all`.
(в services/nginx/allowlist прописываем разрешенные адреса - адрес ВМ1 и адрес битрикса)
5. Выпустите публичный ACME-сертификат в host-каталог `/etc/letsencrypt`.
Разместите CA управляемой PostgreSQL в `/etc/han/ca`. Выпустите
сертификат внутренней CA, SAN которого совпадает с приватным именем VM2.
Разрешайте хостовый порт `8443` только из SG VM1 и, при необходимости,
одобренных приватных/VPN-сетей операторов.
(выпуск сертификатов)
6. Создайте отдельный IAM-принципал Selectel для VM2. Он может читать только
имена из `deployment/secrets/config.example.json`. Никогда не
переиспользуйте принципал VM1.
(отдельный проект в селектел, туда отдельного сервисного пользователя с ролью member)
7. `REDIS_SAFETY_ACL` — полный ACL-файл, а не просто пароль. Он должен
открывать неаутентифицированный `PING` только для health и
защищённого паролем пользователя `safety`, ограниченного необходимыми
ключами/командами `han:safety:*`.
(Пароль в `MESSAGE_SAFETY_REDIS_URL` должен совпадать. Используйте `redis/redis-safety.acl.template`, заменив `REPLACE_WITH_LONG_RANDOM_PASSWORD)
8. Выделите отдельные учётные данные БД для runtime и миграций.
`MESSAGE_SAFETY_CONFIG_ADMIN_DATABASE_URL` может мигрировать/активировать
политику, а `MESSAGE_SAFETY_DATABASE_URL` — нет; `BITRIX_SYNC_MIGRATION_DATABASE_URL`
владеет DDL, а `BITRIX_SYNC_DATABASE_URL` — runtime-роль с минимальными
привилегиями. Учётные данные миграций монтируются только в jobs профиля
`ops`.
9. Setup оставляет исходящий трафик UFW открытым на bootstrap-окно. До
production ограничьте egress правилами Selectel SG/NAT/proxy до
утверждённых PostgreSQL, S3, Secrets Manager, Bitrix24, DNS/NTP, SigNoz и
источников ClamAV. Registry/package repositories оставляйте только на
controlled maintenance window.
## Кто что выполняет
- **Локальный компьютер оператора:** создаёт архив релиза и передаёт его на
VM2. Локальные команды ниже показаны для PowerShell.
- **`root` на VM2:** только bootstrap host OS, активация проверенного релиза,
установка root-owned файлов, настройка `.env`, secret mapping, credentials,
TLS/allow-list, миграции и первый старт.
- **`deploy` на VM2:** принимает релиз только в
`/var/lib/han-deploy/incoming`, проверяет статус/логи и запускает уже
установленные fixed systemd operations через точные sudo-правила.
`deploy` не запускает `docker`, не редактирует `/opt/han-chat/services` и не
входит в группу `docker`.
- **`admin` на VM2:** персональная break-glass роль с отдельным SSH-ключом и
отдельным локальным паролем для `sudo`. Не используется для штатного деплоя,
не входит в `docker`/`lxd`; каждый вход и sudo-вызов считается инцидентной
операцией.
## 1. Bootstrap свежей VM2
На локальном компьютере один раз создайте **два разных** ключа. Закрытые части
остаются только у соответствующих операторов и никогда не передаются на VM:
```powershell
ssh-keygen -t ed25519 -a 100 -f C:\Users\MI\.ssh\han_vm2_deploy `
-C "han-vm2-deploy"
ssh-keygen -t ed25519 -a 100 -f C:\Users\MI\.ssh\han_vm2_admin `
-C "han-vm2-break-glass-admin"
```
Для production ключ `admin` должен принадлежать отдельному назначенному
break-glass оператору и храниться отдельно от deploy key. Если команды
выполняет один человек на этапе bootstrap, это всё равно две разные key pairs
с раздельной последующей передачей/ротацией.
Скопируйте setup-скрипт и только публичные части ключей во временный root
каталог:
```powershell
scp -i C:\Users\MI\.ssh\hansel `
.\HAN_chat_specification\codebase\services\deployment\scripts\setup-vm.sh `
root@<VM2_PUBLIC_IP>:/root/setup-vm2.sh
scp -i C:\Users\MI\.ssh\hansel `
C:\Users\MI\.ssh\han_vm2_deploy.pub `
C:\Users\MI\.ssh\han_vm2_admin.pub `
root@<VM2_PUBLIC_IP>:/root/
```
На VM2 в текущей root-сессии задайте приватный CIDR VM1. `/0` скрипт отклоняет:
```sh
install -d -m 0700 -o root -g root /root/bootstrap
install -m 0600 -o root -g root /root/han_vm2_deploy.pub /root/bootstrap/deploy.pub
install -m 0600 -o root -g root /root/han_vm2_admin.pub /root/bootstrap/admin.pub
chmod 0700 /root/setup-vm2.sh
DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
VM1_PRIVATE_CIDRS='<PRIVATE_IP_VM1>/32' \
/root/setup-vm2.sh
```
Скрипт устанавливает Ubuntu-пакеты, Docker Engine + Compose plugin, UFW,
fail2ban, unattended upgrades, swap, sysctl и цепочку `DOCKER-USER`; создаёт
`deploy`, break-glass `admin`, staging и root-owned production-каталог. Скрипт
не запускает Compose/контейнеры. `80/443` и SSH открываются публично; SSH
остаётся key-only и защищён fail2ban. `8443` доступен только на приватном IP
VM2 из `VM1_PRIVATE_CIDRS`. Если оператору нужен прямой доступ к внутреннему
API через приватный маршрут или VPN, дополнительно передайте необязательный
`OPS_CIDRS='<OPS_PRIVATE_OR_VPN_CIDR>'`.
В текущей root-сессии задайте `admin` отдельный сложный sudo-пароль. Он не
разрешает password SSH: пароль нужен только после входа по admin key:
```sh
passwd admin
```
Не закрывая root-сессию, на локальном компьютере проверьте оба входа:
```powershell
ssh -i C:\Users\MI\.ssh\han_vm2_deploy deploy@<VM2_PUBLIC_IP>
ssh -i C:\Users\MI\.ssh\han_vm2_admin admin@<VM2_PUBLIC_IP>
```
В admin-сессии проверьте запрос именно admin-пароля и получение root shell,
после чего сразу выйдите из него:
```sh
sudo -v
sudo -i
id
exit
```
Только после успешной проверки `deploy`, `admin` и `sudo` повторите на VM2
под `root`:
```sh
DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
VM1_PRIVATE_CIDRS='<PRIVATE_IP_VM1>/32' \
HARDEN_SSH=true \
SKIP_APT_UPGRADE=true \
/root/setup-vm2.sh
```
Это добавит `PermitRootLogin no` и `AllowUsers deploy admin`. Ещё раз откройте
обе новые SSH-сессии после reload и только затем закрывайте старую root.
Публичные bootstrap-копии после проверки можно удалить под `admin`:
```sh
sudo rm -f /root/han_vm2_deploy.pub /root/han_vm2_admin.pub
```
## 2. Передача релиза под `deploy`
На локальном компьютере из каталога `HAN_chat_specification`:
```powershell
$Release = "<VERSION_OR_GIT_SHA>"
tar --exclude=services/.env `
--exclude='services/**/__pycache__' `
--exclude='services/**/.pytest_cache' `
--exclude='services/**/.ruff_cache' `
-czf "vm2-services-$Release.tar.gz" -C .\codebase services
Get-FileHash "vm2-services-$Release.tar.gz" -Algorithm SHA256
scp -i C:\Users\MI\.ssh\hansel "vm2-services-$Release.tar.gz" `
deploy@<VM2_PUBLIC_IP>:/var/lib/han-deploy/incoming/
```
Под `deploy` на VM2 вычислите checksum. Значение должно совпасть с локальным:
```sh
RELEASE='<VERSION_OR_GIT_SHA>'
cd /var/lib/han-deploy/incoming
sha256sum "vm2-services-${RELEASE}.tar.gz"
tar -tzf "vm2-services-${RELEASE}.tar.gz"
```
На этом действия `deploy` с файлами заканчиваются. Не распаковывайте релиз
через `sudo` и не копируйте его в production от имени `deploy`.
## 3. Активация и установка файлов под `root`
Под `root` ещё раз сверьте ожидаемый SHA-256 и список архива. Не продолжайте,
если архив содержит абсолютные пути, `..`, symlink/hardlink или лишний проект:
```sh
RELEASE='<VERSION_OR_GIT_SHA>'
EXPECTED_SHA256='<SHA256_С_ЛОКАЛЬНОЙ_МАШИНЫ>'
ARCHIVE="/var/lib/han-deploy/incoming/vm2-services-${RELEASE}.tar.gz"
printf '%s %s\n' "$EXPECTED_SHA256" "$ARCHIVE" | sha256sum --check -
tar -tvzf "$ARCHIVE"
if tar -tzf "$ARCHIVE" | grep -Eq '(^/|(^|/)\.\.(/|$)|^services/\.env$)'; then
echo 'ОШИБКА: архив содержит небезопасный путь или .env' >&2
exit 1
fi
if tar -tzf "$ARCHIVE" | grep -Ev '^services(/|$)' | grep -q .; then
echo 'ОШИБКА: архив содержит файлы вне каталога services' >&2
exit 1
fi
if tar -tvzf "$ARCHIVE" | awk '$1 ~ /^[lh]/ { found=1 } END { exit !found }'; then
echo 'ОШИБКА: архив содержит symlink или hardlink' >&2
exit 1
fi
install -d -m 0755 -o root -g root /opt/han-chat/services
STAGING="$(mktemp -d /opt/han-chat/.vm2-release.XXXXXX)"
tar --extract --gzip --file "$ARCHIVE" \
--directory "$STAGING" --no-same-owner --no-same-permissions
test -f "$STAGING/services/docker-compose.yml"
rsync -a --delete --exclude=.env \
--chown=root:root --chmod=D755,F644 \
"$STAGING/services/" /opt/han-chat/services/
rm -rf -- "$STAGING"
chmod 0755 /opt/han-chat/services/deployment/preflight.sh
```
Повторите setup под `root`: теперь он установит helpers и units из активного
релиза. Приложение всё ещё не запускается:
```sh
DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
VM1_PRIVATE_CIDRS='<PRIVATE_IP_VM1>/32' \
HARDEN_SSH=true \
SKIP_APT_UPGRADE=true \
/root/setup-vm2.sh
```
Скрипт устанавливает:
- `/usr/local/lib/han-secrets-vm2/{secrets_loader.py,han-secrets}`;
- `/usr/local/sbin/han-vm2-compose`;
- `/usr/local/sbin/han-message-safety-mode`;
- `/etc/letsencrypt/renewal-hooks/deploy/han-processing-nginx`;
- `/etc/systemd/system/{han-secrets-vm2,han-processing}.service`;
- `/etc/sudoers.d/{han-vm2-deploy,deploy-message-safety-mode}`;
- группу `han-message-safety` с GID `10001`;
- стандартный `/etc/han-chat/message-safety-mode.env` с правами
`root:han-message-safety 0640`.
## 4. Несекретная конфигурация и Selectel под `root`
`APP_ENV` определяет имя loader config. При значении из `.env.example`
`APP_ENV=production-like` файл обязан называться
`/etc/han/secrets/vm2-production-like.selectel.json`:
```sh
cd /opt/han-chat/services
install -m 0600 -o root -g root .env.example .env
editor .env
install -m 0600 -o root -g root \
deployment/secrets/config.example.json \
/etc/han/secrets/vm2-production-like.selectel.json
editor /etc/han/secrets/vm2-production-like.selectel.json
```
В `.env` заменяются только несекретные плейсхолдеры и image digests. Значения
DSN, token, password, access/secret key туда не записываются. Для Selectel
создайте отдельный VM2 IAM principal с read-only доступом только к remote names
из mapping.
Зашифруйте пароль Selectel service user через systemd credentials, не помещая
его в аргументы или history:
```sh
read -rsp 'Selectel VM2 service-user password: ' SELECTEL_PASSWORD; echo
printf '%s' "$SELECTEL_PASSWORD" | systemd-creds encrypt \
--name=selectel-service-user-password - \
/etc/han/credentials/vm2.selectel-password.cred
unset SELECTEL_PASSWORD
chown root:root /etc/han/credentials/vm2.selectel-password.cred
chmod 0600 /etc/han/credentials/vm2.selectel-password.cred
```
Активные nginx allow-list файлы редактирует только `root`; последней строкой
обязательно остаётся `deny all;`. До cutover Bitrix public allow-list должен
оставаться закрытым:
```sh
editor /opt/han-chat/services/nginx/allowlists/private-caller-allowlist.conf
editor /opt/han-chat/services/nginx/allowlists/bitrix-webhook-allowlist.conf
chown root:root /opt/han-chat/services/nginx/allowlists/*.conf
chmod 0644 /opt/han-chat/services/nginx/allowlists/*.conf
```
Для контролируемого восстановления без провайдера используйте явный `file`
config и root-only каталог `0700` с одним файлом на ключ. При сбое Selectel
автоматический fallback запрещён.
## 5. Сертификат PostgreSQL и первоначальный выпуск public TLS
### CA управляемой PostgreSQL
Скачайте CA-сертификат кластера из панели провайдера и передайте его на VM2 во
временный путь. Под `root` установите сертификат вне каталога релиза:
```sh
install -d -m 0755 -o root -g root /etc/han/ca
install -m 0644 -o root -g root \
/tmp/<PROVIDER_POSTGRESQL_CA_FILE> \
/etc/han/ca/managed-postgresql-ca.pem
openssl x509 -in /etc/han/ca/managed-postgresql-ca.pem \
-noout -subject -issuer -dates
rm -f /tmp/<PROVIDER_POSTGRESQL_CA_FILE>
```
В `.env` должно быть:
```dotenv
PG_CA_HOST_PATH=/etc/han/ca/managed-postgresql-ca.pem
```
Compose монтирует этот файл read-only во все runtime и migration контейнеры как
`/run/config/postgresql-ca.pem`. DB-клиенты создают обязательный TLS context с
проверкой цепочки и имени сервера по этому CA. Не добавляйте libpq-параметры
`sslmode`/`sslrootcert` в SQLAlchemy `postgresql+asyncpg` URL: asyncpg получает
SSL context отдельно, а такие query-параметры могут быть переданы как
неподдерживаемые keyword arguments. DSN в Secrets Manager имеет обычный вид:
```text
postgresql+asyncpg://<USER>:<PASSWORD>@<MANAGED_POSTGRES_HOST>:<PORT>/<DATABASE>
```
### Первоначальный выпуск Let's Encrypt
`PROCESSING_PUBLIC_HOST` должен быть DNS-именем, A-запись которого уже указывает
на публичный IP VM2. Сертификат на IP-адрес этим порядком не выпускается. Порт
`80` должен быть разрешён в cloud firewall/UFW и пока не занят nginx.
Под `root` задайте значения только для текущей shell-сессии и подготовьте
постоянный webroot:
```sh
PUBLIC_HOST='<PROCESSING_PUBLIC_HOST>'
ACME_EMAIL='<ADMIN_EMAIL>'
install -d -m 0755 -o root -g root /var/lib/han-chat/acme
getent ahostsv4 "$PUBLIC_HOST"
ss -lntp | grep -E ':80[[:space:]]' && {
echo 'Порт 80 уже занят; остановите listener перед standalone-проверкой' >&2
exit 1
} || true
```
Сначала проверьте ACME через staging CA. Этот сертификат nginx не использует:
```sh
certbot certonly --standalone --preferred-challenges http \
--staging \
-d "$PUBLIC_HOST" \
--cert-name "${PUBLIC_HOST}-staging" \
--email "$ACME_EMAIL" \
--agree-tos --no-eff-email --non-interactive
certbot delete --cert-name "${PUBLIC_HOST}-staging" --non-interactive
```
После успешного staging-теста выпустите production-сертификат:
```sh
certbot certonly --standalone --preferred-challenges http \
-d "$PUBLIC_HOST" \
--cert-name "$PUBLIC_HOST" \
--email "$ACME_EMAIL" \
--agree-tos --no-eff-email --non-interactive
certbot certificates
test -s "/etc/letsencrypt/live/${PUBLIC_HOST}/fullchain.pem"
test -s "/etc/letsencrypt/live/${PUBLIC_HOST}/privkey.pem"
getent group han-nginx-tls
test -d /var/lib/han-chat/public-tls
install -m 0640 -o root -g han-nginx-tls \
"/etc/letsencrypt/live/${PUBLIC_HOST}/fullchain.pem" \
/var/lib/han-chat/public-tls/fullchain.pem
install -m 0640 -o root -g han-nginx-tls \
"/etc/letsencrypt/live/${PUBLIC_HOST}/privkey.pem" \
/var/lib/han-chat/public-tls/privkey.pem
```
Nginx с primary GID `11001` получает только подготовленные public certificate
и private key из `/var/lib/han-chat/public-tls` с host read-only. Исходный
`/etc/letsencrypt` остаётся доступен только root/Certbot. Не копируйте private
key в каталог релиза и не делайте его world-readable.
## 6. Preflight, миграции и первый запуск под `root`
Сначала синхронизируйте секреты. Затем выполните статический preflight:
```sh
systemctl start han-secrets-vm2.service
/opt/han-chat/services/deployment/preflight.sh
/usr/local/sbin/han-vm2-compose config --quiet
```
До runtime выполните миграции отдельными DB roles и активируйте начальный
Message Safety config:
Перед первым `bitrix-sync-migrate` владелец `han_app` или администратор БД
выдаёт Bitrix migration-role временный read-only доступ к legacy mapping:
```sql
GRANT USAGE ON SCHEMA han_app TO <BITRIX_SYNC_MIGRATION_ROLE>;
GRANT SELECT ON TABLE han_app.entity_external_mapping
TO <BITRIX_SYNC_MIGRATION_ROLE>;
```
```sh
/usr/local/sbin/han-vm2-compose --profile ops run --rm message-safety-migrate
/usr/local/sbin/han-vm2-compose --profile ops run --rm bitrix-sync-migrate
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
--entrypoint message-safety-config message-safety-migrate \
create /app/app/artifacts/seed-config.yaml --version 1 --actor '<OPERATOR>'
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
--entrypoint message-safety-config message-safety-migrate \
activate --version 1 --approved-by '<APPROVER>'
```
После успешного `bitrix-sync-migrate` администратор БД отзывает временные
права. Право `USAGE` отзывайте только если оно не требуется этой роли для
других согласованных операций:
```sql
REVOKE SELECT ON TABLE han_app.entity_external_mapping
FROM <BITRIX_SYNC_MIGRATION_ROLE>;
REVOKE USAGE ON SCHEMA han_app FROM <BITRIX_SYNC_MIGRATION_ROLE>;
```
Первый запуск и enable выполняет `root` только после прохождения gates:
(внутри gate5)
```sh
systemctl enable han-secrets-vm2.service han-processing.service
systemctl start han-processing.service
systemctl --no-pager status han-processing.service
journalctl --no-pager -u han-processing.service
```
Дальнейшие штатные операции может выполнить `deploy`:
```sh
sudo systemctl restart han-secrets-vm2.service
sudo systemctl restart han-processing.service
sudo systemctl --no-pager status han-processing.service
sudo journalctl --no-pager -u han-processing.service
```
Установка/редактирование unit, Compose, `.env`, secret mapping, credential,
TLS, allow-list и запуск migration jobs остаются операциями `root`.
### Gate 1 — секреты материализованы
Под `root` на VM2:
```sh
systemctl restart han-secrets-vm2.service
systemctl is-active han-secrets-vm2.service
journalctl --no-pager -u han-secrets-vm2.service
test -s /run/han-chat/secrets/manifest
cut -d= -f1 /run/han-chat/secrets/manifest | sort
```
Ожидается `active`; журнал не содержит значений секретов; последняя команда
показывает только имена всех ключей из mapping. Не выполняйте `cat` файлов
секретов и не вставляйте реальные значения в terminal history.
### Gate 2 — статический preflight и Compose
Под `root` на VM2:
```sh
cd /opt/han-chat/services
deployment/preflight.sh
/usr/local/sbin/han-vm2-compose config --quiet
/usr/local/sbin/han-vm2-compose config --services
/usr/local/sbin/han-vm2-compose config --images
```
Все команды должны завершиться с кодом `0`. В списке services нет PostgreSQL,
а все production images содержат `@sha256:`. Вывод полного resolved Compose в
файл не сохраняйте.
### Gate 3 — миграции и активный Message Safety config
Команды миграций из предыдущего раздела выполняются под `root`. После них:
```sh
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
message-safety-migrate current
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
bitrix-sync-migrate current
```
Ожидается по одной head revision каждого сервиса. `create --version 1`
выполняется только при первом развёртывании. Для следующего конфига используйте
новый монотонный номер и отдельные значения `--actor`/`--approved-by`; повторно
активировать старую версию нельзя. Alembic downgrade запрещён.
При обновлении ClamAV policy образ Message Safety должен содержать согласованные
seed и schema: seed `max_signature_age_hours=240`, schema maximum `720`
(30 дней). После обновления immutable image digest создайте новую config
version; существующую active version не редактируйте и не активируйте повторно:
```sh
NEXT_VERSION='<СЛЕДУЮЩИЙ_МОНОТОННЫЙ_НОМЕР>'
/usr/local/sbin/han-vm2-compose --profile ops pull \
message-safety-migrate
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
--entrypoint message-safety-config message-safety-migrate \
validate /app/app/artifacts/seed-config.yaml
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
--entrypoint message-safety-config message-safety-migrate \
create /app/app/artifacts/seed-config.yaml \
--version "$NEXT_VERSION" --actor '<OPERATOR>'
/usr/local/sbin/han-vm2-compose --profile ops run --rm \
--entrypoint message-safety-config message-safety-migrate \
activate --version "$NEXT_VERSION" --approved-by '<APPROVER>'
/usr/local/sbin/han-vm2-compose up -d --no-deps --force-recreate \
message-safety-api message-safety-worker
/usr/local/sbin/han-vm2-compose ps \
message-safety-api message-safety-worker clamd freshclam
unset NEXT_VERSION
```
Старый image, schema которого ограничивает поле значением `168`, нельзя
оставлять после активации значения `240`: сначала обновите
`MESSAGE_SAFETY_IMAGE` на новый digest и проверьте `config --quiet`.
### Gate 4 — конфигурация nginx до запуска
После выпуска public TLS в `/etc/letsencrypt` и материализации internal TLS
secrets. В Selectel значения `VM2_INTERNAL_TLS_CERTIFICATE` и
`VM2_INTERNAL_TLS_PRIVATE_KEY` сохраняются как исходный PEM с настоящими
переводами строк, не как повторный base64 и не как строка с литералами `\n`.
После изменения remote secret перезапустите `han-secrets-vm2.service`; preflight
проверит формат PEM и соответствие certificate/key без вывода их содержимого:
```sh
/opt/han-chat/services/deployment/preflight.sh
/usr/local/sbin/han-vm2-compose run --rm --no-deps \
-e MESSAGE_SAFETY_UPSTREAM_HOST=127.0.0.1 \
-e BITRIX_SYNC_UPSTREAM_HOST=127.0.0.1 \
nginx \
nginx -t -c /etc/nginx/nginx.conf
```
Базовый `nginx.conf` подключает обязательный
`/etc/nginx/conf.d/10-vm2.conf`, поэтому команда завершится ошибкой, если
entrypoint не создал конфигурацию из шаблона. Временные значения upstream
нужны только для проверки до первого запуска backend-контейнеров; production
Compose подставляет DNS-имена сервисов. Ожидается `syntax is ok` и `test is
successful`; ошибок `conf.d is not writable` и предупреждения о превышении
open-file limit быть не должно. Ошибка отсутствующего сертификата является
блокером, а не основанием временно убрать TLS.
### Gate 5 — упорядоченный первый запуск
Под `root` на VM2:
```sh
/usr/local/sbin/han-vm2-compose up -d redis-safety otel-collector
/usr/local/sbin/han-vm2-compose up -d freshclam clamd
/usr/local/sbin/han-vm2-compose up -d \
message-safety-api message-safety-worker
/usr/local/sbin/han-vm2-compose up -d \
bitrix-sync bitrix-sync-worker bitrix-sync-reconciliation
/usr/local/sbin/han-vm2-compose up -d nginx
/usr/local/sbin/han-vm2-compose ps
```
`otel-collector` автоматически запускает одноразовый `otel-queue-init`. Он
выставляет владельца persistent queue `10001:10001` и завершается с кодом `0`;
сам Collector стартует только после этого.
Healthcheck nginx использует встроенный `nginx -t`: утверждённый
`nginx-unprivileged` image не содержит `wget`/`curl`. `ExitCode 127` с
сообщением `wget: not found` означает, что на VM2 остался старый Compose.
Если запуск выполнялся со старым релизом и Message Safety уже попал в
permission/restart loop, после активации исправленного релиза под `root`
восстановите контракт файла и пересоздайте затронутые контейнеры:
```sh
getent group 10001 >/dev/null ||
groupadd --system --gid 10001 han-message-safety
test "$(getent group han-message-safety | cut -d: -f3)" = 10001
chown root:han-message-safety /etc/han-chat/message-safety-mode.env
chmod 0640 /etc/han-chat/message-safety-mode.env
install -m 0755 -o root -g root \
/opt/han-chat/services/deployment/han-message-safety-mode \
/usr/local/sbin/han-message-safety-mode
/opt/han-chat/services/deployment/preflight.sh
/usr/local/sbin/han-vm2-compose up -d --force-recreate \
otel-queue-init otel-collector
/usr/local/sbin/han-vm2-compose up -d --force-recreate \
message-safety-api message-safety-worker
/usr/local/sbin/han-vm2-compose ps
```
Не заменяйте это на `chmod 0644/0666`, запуск контейнеров от root или
рекурсивный `chown` Docker volumes. Если после восстановления прав nginx
остаётся в `Restarting`, это отдельная ошибка конфигурации/TLS, а не права
Message Safety; проверьте её без вывода секретов:
```sh
/usr/local/sbin/han-vm2-compose logs --tail 100 nginx otel-collector
```
Дождитесь `healthy` у сервисов с healthcheck. Не продолжайте при
`Restarting`, `unhealthy`, OOM или неожиданном `Exited`. После успешного
первого запуска передайте дальнейший lifecycle systemd:
```sh
systemctl enable han-secrets-vm2.service han-processing.service
systemctl start han-processing.service
systemctl --no-pager status han-processing.service
```
### Gate 6 — host ports и сертификаты
Под `root` на VM2:
```sh
ss -lntp | grep -E ':(80|443|8443|6379|8080|4317|4318)[[:space:]]'
/usr/local/sbin/han-vm2-compose ps --format json | jq .
```
Ожидаются host listeners только nginx: public `80`, `443` и private-bound
`8443` на `PROCESSING_PRIVATE_BIND_ADDRESS`. `6379`, container `8080` и OTLP
`4317/4318` на host отсутствуют.
С доверенной рабочей станции проверьте public chain:
```sh
openssl s_client -connect <PROCESSING_PUBLIC_HOST>:443 \
-servername <PROCESSING_PUBLIC_HOST> \
-verify_hostname <PROCESSING_PUBLIC_HOST> -verify_return_error </dev/null
```
С VM1 или ops host, имеющего private route, проверьте internal chain и SAN:
```sh
openssl s_client -connect <VM2_PRIVATE_IP>:8443 \
-servername <VM2_PRIVATE_DNS_NAME> \
-verify_hostname <VM2_PRIVATE_DNS_NAME> \
-CAfile <INTERNAL_CA_FILE> -verify_return_error </dev/null
```
Обе команды должны завершить certificate verification без ошибки.
Переключите renewal с первоначального `standalone` на webroot, который nginx
обслуживает по `/.well-known/acme-challenge/`. `certbot reconfigure` сам
проверит новый способ через staging CA:
```sh
PUBLIC_HOST='<PROCESSING_PUBLIC_HOST>'
certbot reconfigure \
--cert-name "$PUBLIC_HOST" \
--authenticator webroot \
--webroot-path /var/lib/han-chat/acme
```
Повторный setup после активации релиза устанавливает deploy-hook
`/etc/letsencrypt/renewal-hooks/deploy/han-processing-nginx`: после успешного
обновления он атомарно размещает certificate/key с группой `han-nginx-tls` в
`/var/lib/han-chat/public-tls`, проверяет конфигурацию nginx и отправляет
контейнеру `HUP`.
Проверьте полный цикл и включите штатное расписание Certbot:
```sh
test -x /etc/letsencrypt/renewal-hooks/deploy/han-processing-nginx
certbot renew --dry-run --run-deploy-hooks
systemctl enable --now certbot.timer
systemctl --no-pager status certbot.timer
systemctl list-timers certbot.timer
```
`certbot.timer` проверяет необходимость продления дважды в сутки; сертификат
перевыпускается только при приближении срока. Ошибка dry-run или deploy-hook —
блокер. Итог `Congratulations, all simulated renewals succeeded` означает
успешный dry-run. Старый hook мог при этом дать ложное
`Hook 'deploy-hook' ran with error output`: Compose писал `Killing/Killed`, а
успешный `nginx -t``syntax is ok` в stderr. Исправленный hook показывает
вывод config test только при ненулевом exit code и использует тихий
`docker kill --signal HUP`. Порт `80` после этого остаётся доступен для
HTTP-01 renewal.
### Gate 7 — public routing
С внешней тестовой машины:
```sh
curl -sS -o /dev/null -w '%{http_code}\n' \
http://<PROCESSING_PUBLIC_HOST>/not-a-route
curl -sS -o /dev/null -w '%{http_code}\n' \
https://<PROCESSING_PUBLIC_HOST>/not-a-route
curl -sS -o /dev/null -w '%{http_code}\n' \
http://<PROCESSING_PUBLIC_HOST>/bitrix/sync/webhook/contact
curl -sS -o /dev/null -w '%{http_code}\n' \
https://<PROCESSING_PUBLIC_HOST>/internal/safety/status
curl -sS -o /dev/null -w '%{http_code}\n' -X GET \
https://<PROCESSING_PUBLIC_HOST>/bitrix/sync/webhook/contact
```
Ожидаемые коды по порядку: `308`, `404`, `426`, `404`, `405`. Для HTTPS
используйте только валидный public certificate, без `-k`.
POST к webhook с адреса вне Bitrix allow-list должен получить `403`; если
cloud firewall настроен на drop, допустим timeout. Затем повторите с
разрешённого source IP и заведомо неверным receiver token: upstream должен
ответить `403`, не `2xx`.
### Gate 8 — private API только с VM1/ops
Следующие команды выполняются **на VM1** или approved ops host, не на VM2.
Используйте private DNS/SAN и внутреннюю CA.
Safety status:
```sh
curl --fail --silent --show-error \
--cacert <INTERNAL_CA_FILE> \
https://<VM2_PRIVATE_DNS_NAME>:8443/internal/safety/status
```
Benign text check через тот же private listener:
```sh
SAFETY_TOKEN="$(cat <MESSAGE_SAFETY_SERVICE_TOKEN_FILE_ON_VM1>)"
MESSAGE_ID="$(uuidgen)"
curl --silent --show-error --write-out '\nHTTP %{http_code}\n' --config - <<EOF
url = "https://<VM2_PRIVATE_DNS_NAME>:8443/internal/safety/v2/messages/check"
cacert = "<INTERNAL_CA_FILE>"
request = "POST"
header = "X-Service-Token: ${SAFETY_TOKEN}"
header = "Content-Type: application/json"
data = "{\"message_id\":\"${MESSAGE_ID}\",\"content_kind\":\"text\",\"text\":\"VM2 safety canary\",\"attachment\":null}"
EOF
unset SAFETY_TOKEN MESSAGE_ID
```
В standard mode ожидается `HTTP 200`, `verdict=allow` и непустые
`config_version`/`rules_version`. Проверку `202 → Location → task GET`
выполняйте отдельным file smoke только с реальным versioned quarantine object:
выдуманные S3 key/version/ETag не являются валидным тестом.
Для Bitrix status прочитайте token из уже защищённого secret file VM1 в
переменную и передайте curl config через stdin, чтобы значение не попало в
argv/history:
```sh
BITRIX_TOKEN="$(cat <BITRIX_SYNC_SERVICE_TOKEN_FILE_ON_VM1>)"
curl --silent --show-error --output /tmp/vm2-sync-status.json \
--write-out '%{http_code}\n' --config - <<EOF
url = "https://<VM2_PRIVATE_DNS_NAME>:8443/internal/sync/v1/status"
cacert = "<INTERNAL_CA_FILE>"
header = "Authorization: Bearer ${BITRIX_TOKEN}"
EOF
unset BITRIX_TOKEN
cat /tmp/vm2-sync-status.json
rm -f /tmp/vm2-sync-status.json
```
При `BITRIX_SYNC_ENABLED=false` ожидается закрытая/неготовая синхронизация, а
не ложный успешный full-mode status. С машины вне `VM1_PRIVATE_CIDRS` и
необязательных приватных/VPN-сетей `OPS_CIDRS` подключение к `8443` должно
завершиться timeout/reject.
### Gate 9 — canary на отсутствие секретов в логах и traces
Создайте **фейковый**, не production token marker и отправьте его с
разрешённого тестового source IP:
```sh
CANARY="HAN_VM2_REDACTION_$(date +%s)"
curl -sS -o /dev/null \
-H "Authorization: Bearer ${CANARY}" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode "auth[application_token]=${CANARY}" \
"https://<PROCESSING_PUBLIC_HOST>/bitrix/sync/webhook/contact?token=${CANARY}"
```
На VM2 под `root`:
```sh
CANARY='<ЗНАЧЕНИЕ_CANARY_С_ТЕСТОВОЙ_МАШИНЫ>'
if /usr/local/sbin/han-vm2-compose logs --no-color \
nginx bitrix-sync message-safety-api otel-collector |
grep -F -- "$CANARY"; then
echo 'FAIL: canary попал в логи' >&2
exit 1
fi
unset CANARY
```
В SigNoz выполните поиск этого же marker по logs и span attributes за окно
теста: результат должен быть пустым. Отдельными фейковыми markers повторите
проверку для DSN-подобной строки, S3 key и object key. Реальные secrets для
такой проверки не используйте.
Не открывайте webhook-трафик, пока `bitrix-sync` отключён. Отключённый или
упавший receiver должен возвращать retryable `503`/закрытую маршрутизацию,
никогда успешный `2xx ignored`.
## Политика отказов
- Отказ зависимости Safety — fail-closed: VM1 не должна отправлять/продвигать
контент.
- Устаревшие/недоступные сигнатуры ClamAV отключают только файловую
capability; ошибка сканирования никогда не превращается в allow.
- Потеря Redis может убрать ускорение, но PostgreSQL остаётся источником
истины.
- Сбой OTEL ставит в очередь в пределах ограниченного тома и не должен
менять вердикты.
- Rollback не понижает схемы, не удаляет durable tasks/mappings и не
запускает `docker compose down -v`.
## Аварийный MOCK
Разрешены только эти пять sudo-команд:
```text
han-message-safety-mode standard
han-message-safety-mode mock --text-free true --file-free true
han-message-safety-mode mock --text-free true --file-free false
han-message-safety-mode mock --text-free false --file-free true
han-message-safety-mode mock --text-free false --file-free false
```
Хелпер атомарно пишет только
`/etc/han-chat/message-safety-mode.env`, пересоздаёт только Safety API,
проверяет health и при сбое восстанавливает предыдущий режим. У MOCK нет
таймаута: держите high-severity alert активным до явного `standard`, затем
проверьте нормальные text/link/file capabilities и EICAR-canary.
## Известные исключения по образам
Образы ClamAV могут потребовать корректировок UID/path после валидации
точного digest. Не ослабляйте `read_only`, capabilities или mounts глобально:
задокументируйте минимальные writable пути для сигнатур/runtime и
компенсируйте сетевыми и ресурсными лимитами. Egress к signature-CDN
получает только `freshclam`; `clamd` — нет.
# Gate 9 завершает техническую приёмку VM2, но не означает production cutover сервисов.
Дальнейший порядок:
1. Проверить автозапуск:
```sh
systemctl is-enabled \
docker.service \
han-chat-vm2-docker-firewall.service \
han-secrets-vm2.service \
han-processing.service \
certbot.timer
systemctl is-active \
docker.service \
han-chat-vm2-docker-firewall.service \
han-processing.service \
certbot.timer
/usr/local/sbin/han-vm2-compose ps
```
2. Провести reboot-gate. Только после проверки отдельного входа `admin` и доступа к консоли Selectel:
```sh
systemctl reboot
```
После переподключения повторить команды выше и кратко Gate 68: HTTPS, firewall, private Safety API.
3. Зафиксировать итог релиза:
```sh
/usr/local/sbin/han-vm2-compose config --images
/usr/local/sbin/han-vm2-compose ps
systemctl list-timers certbot.timer
journalctl --no-pager -u han-processing.service -u han-secrets-vm2.service
```
Сохранить версии образов, дату приёмки и результаты gates без значений секретов.
4. Настроить эксплуатационный мониторинг:
- container unhealthy/restart/OOM;
- срок TLS;
- возраст ClamAV signatures;
- OTEL queue/export errors;
- disk/RAM;
- активный MOCK mode;
- недоступность private Safety API.
5. Далее — отдельный controlled cutover Message Safety на VM1: private URL, internal CA, service token, API integration, rollback rehearsal и функциональные проверки.
6. `bitrix-sync` пока оставить:
```dotenv
BITRIX_SYNC_ENABLED=false
BITRIX_SYNC_MODE=disabled
```
Public allow-list — только `deny all;`. Включать Bitrix можно лишь после выполнения gates `module-07`: поля портала, webhooks, migrations, grants, backfill/watermark и rollback rehearsal.
Таким образом, ближайший шаг сейчас — reboot-gate и фиксация приёмки VM2. Затем переход к интеграции VM1, а не немедленное включение Bitrix.
@@ -0,0 +1,3 @@
# Install as root:root 0440 and validate with visudo -cf.
# The root-owned wrapper strictly validates the complete argument list.
deploy ALL=(root) NOPASSWD: /usr/local/sbin/han-message-safety-mode
@@ -0,0 +1,114 @@
#!/bin/sh
# Install as root:root 0755 at /usr/local/sbin/han-message-safety-mode.
set -eu
MODE_FILE=/etc/han-chat/message-safety-mode.env
COMPOSE=/usr/local/sbin/han-vm2-compose
LOCK=/run/lock/han-message-safety-mode.lock
MODE_GROUP=han-message-safety
if [ "$#" -eq 1 ] && [ "$1" = standard ]; then
mock=false
text=false
file=false
elif [ "$#" -eq 5 ] &&
[ "$1" = mock ] &&
[ "$2" = --text-free ] &&
{ [ "$3" = true ] || [ "$3" = false ]; } &&
[ "$4" = --file-free ] &&
{ [ "$5" = true ] || [ "$5" = false ]; }; then
mock=true
text=$3
file=$5
else
echo "usage: han-message-safety-mode standard | mock --text-free true|false --file-free true|false" >&2
exit 64
fi
[ "$(id -u)" -eq 0 ] || {
echo "must run through approved sudo rule" >&2
exit 77
}
[ -x "$COMPOSE" ] || {
echo "fixed compose launcher is unavailable" >&2
exit 69
}
exec 9>"$LOCK"
/usr/bin/flock -n 9 || {
echo "another mode transition is active" >&2
exit 75
}
directory=$(dirname "$MODE_FILE")
/usr/bin/install -d -o root -g root -m 0700 "$directory"
temporary=$(/usr/bin/mktemp "$directory/.message-safety-mode.XXXXXX")
backup=$(/usr/bin/mktemp "$directory/.message-safety-mode.backup.XXXXXX")
cleanup() {
/usr/bin/rm -f "$temporary" "$backup"
}
trap cleanup EXIT HUP INT TERM
if [ -f "$MODE_FILE" ]; then
/usr/bin/cp --preserve=mode,ownership "$MODE_FILE" "$backup"
else
: >"$backup"
/usr/bin/chmod 0600 "$backup"
fi
old_mode=$(/usr/bin/awk -F= '
$1 == "MESSAGE_SAFETY_MOCK_ENABLED" {mock=$2}
$1 == "MESSAGE_SAFETY_MOCK_TEXT_FREE" {text=$2}
$1 == "MESSAGE_SAFETY_MOCK_FILE_FREE" {file=$2}
END {printf "mock=%s,text=%s,file=%s", mock, text, file}
' "$backup")
{
printf 'MESSAGE_SAFETY_MOCK_ENABLED=%s\n' "$mock"
printf 'MESSAGE_SAFETY_MOCK_TEXT_FREE=%s\n' "$text"
printf 'MESSAGE_SAFETY_MOCK_FILE_FREE=%s\n' "$file"
} >"$temporary"
/usr/bin/chown root:"$MODE_GROUP" "$temporary"
/usr/bin/chmod 0640 "$temporary"
/usr/bin/mv -fT "$temporary" "$MODE_FILE"
restart_api() {
"$COMPOSE" config --quiet &&
"$COMPOSE" up -d --no-deps --force-recreate message-safety-api
}
healthy=false
if restart_api; then
attempt=0
while [ "$attempt" -lt 30 ]; do
container=$("$COMPOSE" ps -q message-safety-api)
if [ -n "$container" ]; then
status=$(/usr/bin/docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container")
if [ "$status" = healthy ]; then
healthy=true
break
fi
fi
attempt=$((attempt + 1))
/usr/bin/sleep 2
done
fi
if [ "$healthy" != true ]; then
if [ -s "$backup" ]; then
/usr/bin/cp "$backup" "$temporary"
else
printf '%s\n' \
'MESSAGE_SAFETY_MOCK_ENABLED=false' \
'MESSAGE_SAFETY_MOCK_TEXT_FREE=false' \
'MESSAGE_SAFETY_MOCK_FILE_FREE=false' >"$temporary"
fi
/usr/bin/chown root:"$MODE_GROUP" "$temporary"
/usr/bin/chmod 0640 "$temporary"
/usr/bin/mv -fT "$temporary" "$MODE_FILE"
restart_api || true
/usr/bin/logger -p authpriv.err -t han-message-safety-mode "transition failed; previous policy restored"
exit 1
fi
/usr/bin/logger -p authpriv.notice -t han-message-safety-mode \
"transition succeeded old=$old_mode new=mock=$mock,text=$text,file=$file actor=${SUDO_USER:-root}"
@@ -0,0 +1,31 @@
[Unit]
Description=HAN Processing VM2 root Compose stack
Requires=docker.service han-secrets-vm2.service
After=docker.service han-secrets-vm2.service network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
User=root
Group=root
WorkingDirectory=/opt/han-chat/services
ExecStart=/usr/local/sbin/han-vm2-compose up -d --remove-orphans
ExecReload=/usr/local/sbin/han-vm2-compose up -d --remove-orphans
ExecStop=/usr/local/sbin/han-vm2-compose stop
TimeoutStartSec=300
TimeoutStopSec=120
UMask=0077
NoNewPrivileges=yes
PrivateTmp=yes
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
LimitCORE=0
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,192 @@
#!/bin/sh
set -eu
ROOT=${1:-/opt/han-chat/services}
ENV_FILE=${2:-$ROOT/.env}
MANIFEST=${3:-/run/han-chat/secrets/manifest}
failures=0
fail() {
echo "FAIL: $*" >&2
failures=$((failures + 1))
}
[ "$(id -u)" -eq 0 ] || fail "preflight must inspect production files as root"
[ -f "$ROOT/docker-compose.yml" ] || fail "root docker-compose.yml is missing"
[ -d "$ROOT/message-safety" ] || fail "message-safety artifact directory is missing"
[ -d "$ROOT/bitrix-sync" ] || fail "bitrix-sync artifact directory is missing"
[ -f "$ENV_FILE" ] || fail ".env is missing"
[ -f "$MANIFEST" ] || fail "runtime secret manifest is missing"
[ -f /etc/han-chat/message-safety-mode.env ] ||
fail "root-owned Message Safety mode file is missing; initialize standard mode"
/usr/bin/getent group han-message-safety | /usr/bin/awk -F: '$3 == 10001 {found=1} END {exit !found}' ||
fail "han-message-safety group with GID 10001 is missing"
public_tls_dir=/var/lib/han-chat/public-tls
/usr/bin/getent group han-nginx-tls | /usr/bin/awk -F: '$3 == 11001 {found=1} END {exit !found}' ||
fail "han-nginx-tls group with GID 11001 is missing"
[ -d "$public_tls_dir" ] || fail "public TLS staging directory is missing"
for tls_file in fullchain.pem privkey.pem; do
path="$public_tls_dir/$tls_file"
[ -s "$path" ] || {
fail "public TLS file is missing or empty: $path"
continue
}
[ "$(/usr/bin/stat -c '%U:%G:%a' "$path")" = "root:han-nginx-tls:640" ] ||
fail "public TLS file must be root:han-nginx-tls 0640: $path"
done
if [ -f "$ENV_FILE" ]; then
if /usr/bin/grep -Eq '(^|_)(PASSWORD|SECRET|TOKEN|DATABASE_URL|REDIS_URL|PRIVATE_KEY|ACCESS_KEY)=' "$ENV_FILE"; then
fail ".env contains a secret-shaped key"
fi
if /usr/bin/grep -Eq '=<[^>]+>|change-me|example\.(com|org|net)' "$ENV_FILE"; then
fail ".env still contains placeholders"
fi
bitrix_enabled=$(/usr/bin/awk -F= '$1 == "BITRIX_SYNC_ENABLED" {print $2}' "$ENV_FILE")
bitrix_mode=$(/usr/bin/awk -F= '$1 == "BITRIX_SYNC_MODE" {print $2}' "$ENV_FILE")
case "$bitrix_enabled" in
true|false) ;;
*) fail "BITRIX_SYNC_ENABLED must be exactly true or false" ;;
esac
if { [ "$bitrix_enabled" = true ] && [ "$bitrix_mode" != full ]; } ||
{ [ "$bitrix_enabled" = false ] && [ "$bitrix_mode" != disabled ]; }; then
fail "BITRIX_SYNC_MODE must be full when enabled and disabled otherwise"
fi
otel_tls_insecure=$(/usr/bin/awk -F= \
'$1 == "OTEL_REMOTE_TLS_INSECURE" {print $2}' "$ENV_FILE")
case "$otel_tls_insecure" in
true|false) ;;
*) fail "OTEL_REMOTE_TLS_INSECURE must be exactly true or false" ;;
esac
for image_key in MESSAGE_SAFETY_IMAGE BITRIX_SYNC_IMAGE NGINX_IMAGE REDIS_IMAGE CLAMAV_IMAGE OTEL_COLLECTOR_IMAGE; do
image=$(/usr/bin/awk -F= -v key="$image_key" '$1 == key {print substr($0, index($0, "=") + 1)}' "$ENV_FILE")
echo "$image" | /usr/bin/grep -Eq '@sha256:[0-9a-f]{64}$' ||
fail "$image_key must be pinned by sha256 digest"
done
private_bind=$(/usr/bin/awk -F= '$1 == "PROCESSING_PRIVATE_BIND_ADDRESS" {print $2}' "$ENV_FILE")
case "$private_bind" in
""|0.0.0.0|::|127.*) fail "private 8443 bind address is unsafe" ;;
esac
fi
bitrix_allowlist="$ROOT/nginx/allowlists/bitrix-webhook-allowlist.conf"
private_allowlist="$ROOT/nginx/allowlists/private-caller-allowlist.conf"
for allowlist in "$bitrix_allowlist" "$private_allowlist"; do
[ -f "$allowlist" ] || {
fail "allow-list is missing: $allowlist"
continue
}
[ "$(/usr/bin/tail -n 1 "$allowlist" | /usr/bin/tr -d '[:space:]')" = "denyall;" ] ||
fail "allow-list must end in deny all: $allowlist"
done
if [ "${bitrix_enabled:-}" = true ]; then
/usr/bin/grep -Eq '^[[:space:]]*allow[[:space:]]+[^;]+;' "$bitrix_allowlist" ||
fail "enabled bitrix-sync requires reviewed webhook CIDRs"
else
! /usr/bin/grep -Eq '^[[:space:]]*allow[[:space:]]+[^;]+;' "$bitrix_allowlist" ||
fail "disabled bitrix-sync must keep public webhook allow-list closed"
fi
/usr/bin/grep -Eq '^[[:space:]]*allow[[:space:]]+[^;]+;' "$private_allowlist" ||
fail "private 8443 requires reviewed VM1/ops CIDRs"
required_secrets='
MESSAGE_SAFETY_DATABASE_URL
MESSAGE_SAFETY_CONFIG_ADMIN_DATABASE_URL
MESSAGE_SAFETY_REDIS_URL
MESSAGE_SAFETY_SERVICE_TOKEN
SELECTEL_S3_QUARANTINE_READ_ACCESS_KEY
SELECTEL_S3_QUARANTINE_READ_SECRET_KEY
VM2_INTERNAL_TLS_CERTIFICATE
VM2_INTERNAL_TLS_PRIVATE_KEY
BITRIX_SYNC_DATABASE_URL
BITRIX_SYNC_MIGRATION_DATABASE_URL
BITRIX_SYNC_CRM_REST_WEBHOOK_URL
BITRIX_SYNC_CONTACT_RECEIVER_TOKEN
BITRIX_SYNC_ALERT_RECEIVER_TOKEN
BITRIX_SYNC_SERVICE_TOKEN
REDIS_SAFETY_ACL'
if [ -f "$MANIFEST" ]; then
old_ifs=$IFS
IFS='
'
for name in $required_secrets; do
[ -n "$name" ] || continue
path=$(/usr/bin/awk -F= -v key="$name" '$1 == key {print substr($0, index($0, "=") + 1)}' "$MANIFEST")
[ -n "$path" ] || {
fail "manifest is missing $name"
continue
}
[ -f "$path" ] || fail "secret file is missing for $name"
done
IFS=$old_ifs
internal_cert=$(/usr/bin/awk -F= \
'$1 == "VM2_INTERNAL_TLS_CERTIFICATE" {print substr($0, index($0, "=") + 1)}' \
"$MANIFEST")
internal_key=$(/usr/bin/awk -F= \
'$1 == "VM2_INTERNAL_TLS_PRIVATE_KEY" {print substr($0, index($0, "=") + 1)}' \
"$MANIFEST")
cert_valid=false
key_valid=false
if [ -f "$internal_cert" ] &&
/usr/bin/openssl x509 -in "$internal_cert" -noout >/dev/null 2>&1; then
cert_valid=true
else
fail "internal TLS certificate is not valid PEM"
fi
if [ -f "$internal_key" ] &&
/usr/bin/openssl pkey -in "$internal_key" -passin pass: \
-noout -check >/dev/null 2>&1; then
key_valid=true
else
fail "internal TLS private key is not valid unencrypted PEM"
fi
if [ "$cert_valid" = true ] && [ "$key_valid" = true ]; then
cert_public=$(
/usr/bin/openssl x509 -in "$internal_cert" -pubkey -noout |
/usr/bin/openssl pkey -pubin -outform DER 2>/dev/null |
/usr/bin/sha256sum | /usr/bin/awk '{print $1}'
)
key_public=$(
/usr/bin/openssl pkey -in "$internal_key" -passin pass: -pubout -outform DER 2>/dev/null |
/usr/bin/sha256sum | /usr/bin/awk '{print $1}'
)
[ "$cert_public" = "$key_public" ] ||
fail "internal TLS certificate and private key do not match"
fi
fi
mode_file=/etc/han-chat/message-safety-mode.env
if [ -f "$mode_file" ]; then
[ "$(/usr/bin/stat -c '%U:%G:%a' "$mode_file")" = root:han-message-safety:640 ] ||
fail "Message Safety mode file must be root:han-message-safety 0640"
mode_lines=$(/usr/bin/sort "$mode_file")
case "$mode_lines" in
*MESSAGE_SAFETY_MOCK_ENABLED=*MESSAGE_SAFETY_MOCK_FILE_FREE=*MESSAGE_SAFETY_MOCK_TEXT_FREE=*) ;;
*) fail "Message Safety mode file is incomplete" ;;
esac
fi
for protected in \
"$ROOT/docker-compose.yml" \
"$ROOT/deployment/han-message-safety-mode" \
"$ROOT/deployment/han-processing.service"
do
[ -f "$protected" ] || continue
owner=$(/usr/bin/stat -c '%U:%G' "$protected")
[ "$owner" = root:root ] || fail "$protected must be root:root"
mode=$(/usr/bin/stat -c '%A' "$protected")
case "$mode" in
??????w???|????????w?) fail "$protected is writable by group/other" ;;
esac
done
if [ "$failures" -ne 0 ]; then
echo "preflight: $failures failure(s); deployment remains closed" >&2
exit 1
fi
echo "preflight: static VM2 gates passed; run compose/nginx/TLS probes separately"
@@ -0,0 +1,762 @@
#!/usr/bin/env bash
# Первичная подготовка Ubuntu 24.04 для HAN Chat VM2 Processing.
#
# Скрипт устанавливает host-зависимости, Docker/Compose, создаёт непривилегированную
# роль deploy, настраивает SSH/UFW/fail2ban/DOCKER-USER/swap и устанавливает
# root-owned deployment helpers, если релиз уже размещён в DEPLOY_DIR.
#
# PostgreSQL, S3, Selectel IAM, DNS, TLS, образы, .env и значения секретов
# скрипт не создаёт. Он не запускает Compose и прикладные контейнеры.
#
# Первый запуск на свежей VM выполняется root:
# DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
# ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
# VM1_PRIVATE_CIDRS=10.10.1.5/32 \
# bash deployment/scripts/setup-vm.sh
#
# После задания sudo-пароля admin и проверки обоих входов:
# DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \
# ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \
# VM1_PRIVATE_CIDRS=10.10.1.5/32 \
# HARDEN_SSH=true SKIP_APT_UPGRADE=true \
# bash deployment/scripts/setup-vm.sh
#
# Параметры:
# DEPLOY_USER=deploy
# ADMIN_USER=admin
# DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub
# ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub
# DEPLOY_DIR=/opt/han-chat/services
# INCOMING_DIR=/var/lib/han-deploy/incoming
# SSH_PORT=22
# OPS_CIDRS=10.20.0.0/24 # необязательные приватные/VPN-сети для API 8443
# VM1_PRIVATE_CIDRS=10.10.1.5/32
# TIMEZONE=Europe/Moscow
# SWAP_SIZE_GB=4
# EXTERNAL_IF=ens3
# HARDEN_SSH=false
# LOCK_ACCOUNT_PASSWORDS=true
# RESET_UFW=true
# SKIP_APT_UPGRADE=false
set -Eeuo pipefail
IFS=$'\n\t'
DEPLOY_USER="${DEPLOY_USER:-deploy}"
ADMIN_USER="${ADMIN_USER:-admin}"
DEPLOY_AUTHORIZED_KEY_FILE="${DEPLOY_AUTHORIZED_KEY_FILE:-}"
ADMIN_AUTHORIZED_KEY_FILE="${ADMIN_AUTHORIZED_KEY_FILE:-}"
DEPLOY_DIR="${DEPLOY_DIR:-/opt/han-chat/services}"
INCOMING_DIR="${INCOMING_DIR:-/var/lib/han-deploy/incoming}"
SSH_PORT="${SSH_PORT:-22}"
OPS_CIDRS="${OPS_CIDRS:-}"
VM1_PRIVATE_CIDRS="${VM1_PRIVATE_CIDRS:-}"
TIMEZONE="${TIMEZONE:-Europe/Moscow}"
SWAP_SIZE_GB="${SWAP_SIZE_GB:-4}"
EXTERNAL_IF="${EXTERNAL_IF:-}"
HARDEN_SSH="${HARDEN_SSH:-false}"
LOCK_ACCOUNT_PASSWORDS="${LOCK_ACCOUNT_PASSWORDS:-true}"
RESET_UFW="${RESET_UFW:-true}"
SKIP_APT_UPGRADE="${SKIP_APT_UPGRADE:-false}"
LOG_FILE="${LOG_FILE:-/var/log/han-chat-vm2-setup.log}"
log() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"
}
step() {
log ""
log "==> $*"
}
die() {
log "ОШИБКА: $*"
exit 1
}
on_error() {
local exit_code=$?
log "ОШИБКА: команда завершилась с кодом ${exit_code}, строка ${BASH_LINENO[0]}"
exit "$exit_code"
}
trap on_error ERR
require_root() {
[[ "${EUID:-$(id -u)}" -eq 0 ]] || die "Запустите скрипт от root"
}
is_true_or_false() {
[[ "$1" == "true" || "$1" == "false" ]]
}
validate_cidr_list() {
local label=$1
local value=$2
local item
local octet
local prefix
[[ -n "$value" ]] || die "${label} обязателен и не может быть пустым"
IFS=',' read -ra items <<<"$value"
for item in "${items[@]}"; do
[[ "$item" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}/([0-9]{1,2})$ ]] \
|| die "${label} содержит некорректный IPv4 CIDR: ${item}"
prefix="${item##*/}"
((10#$prefix >= 1 && 10#$prefix <= 32)) \
|| die "${label}: префикс должен быть от /1 до /32: ${item}"
IFS='.' read -ra octets <<<"${item%/*}"
for octet in "${octets[@]}"; do
((10#$octet <= 255)) || die "${label} содержит некорректный IPv4 CIDR: ${item}"
done
done
}
validate_parameters() {
[[ "$DEPLOY_USER" =~ ^[a-z_][a-z0-9_-]*$ ]] || die "Некорректный DEPLOY_USER"
[[ "$DEPLOY_USER" == "deploy" ]] \
|| die "VM2 units/sudoers используют фиксированную роль deploy"
[[ "$ADMIN_USER" == "admin" ]] || die "VM2 break-glass роль должна называться admin"
[[ "$DEPLOY_AUTHORIZED_KEY_FILE" =~ ^/[A-Za-z0-9._/-]+$ ]] \
|| die "DEPLOY_AUTHORIZED_KEY_FILE должен быть безопасным абсолютным путём"
[[ "$ADMIN_AUTHORIZED_KEY_FILE" =~ ^/[A-Za-z0-9._/-]+$ ]] \
|| die "ADMIN_AUTHORIZED_KEY_FILE должен быть безопасным абсолютным путём"
[[ "$DEPLOY_AUTHORIZED_KEY_FILE" != "$ADMIN_AUTHORIZED_KEY_FILE" ]] \
|| die "deploy и admin должны использовать разные public key files"
[[ "$DEPLOY_DIR" =~ ^/[A-Za-z0-9._/-]+$ ]] \
|| die "DEPLOY_DIR должен быть безопасным абсолютным путём"
[[ "$INCOMING_DIR" =~ ^/[A-Za-z0-9._/-]+$ ]] \
|| die "INCOMING_DIR должен быть безопасным абсолютным путём"
[[ "$DEPLOY_DIR" != "$INCOMING_DIR" ]] || die "DEPLOY_DIR и INCOMING_DIR должны различаться"
[[ "$TIMEZONE" =~ ^[A-Za-z0-9_+/-]+$ ]] || die "Некорректный TIMEZONE"
[[ -z "$EXTERNAL_IF" || "$EXTERNAL_IF" =~ ^[A-Za-z0-9_.:-]+$ ]] \
|| die "Некорректный EXTERNAL_IF"
[[ "$SSH_PORT" =~ ^[0-9]+$ ]] || die "SSH_PORT должен быть числом"
((SSH_PORT >= 1 && SSH_PORT <= 65535)) || die "SSH_PORT вне диапазона"
[[ "$SWAP_SIZE_GB" =~ ^[0-9]+$ ]] || die "SWAP_SIZE_GB должен быть целым числом"
is_true_or_false "$HARDEN_SSH" || die "HARDEN_SSH должен быть true или false"
is_true_or_false "$LOCK_ACCOUNT_PASSWORDS" \
|| die "LOCK_ACCOUNT_PASSWORDS должен быть true или false"
if [[ "$HARDEN_SSH" == "true" && "$LOCK_ACCOUNT_PASSWORDS" != "true" ]]; then
die "HARDEN_SSH=true требует LOCK_ACCOUNT_PASSWORDS=true"
fi
is_true_or_false "$RESET_UFW" || die "RESET_UFW должен быть true или false"
is_true_or_false "$SKIP_APT_UPGRADE" || die "SKIP_APT_UPGRADE должен быть true или false"
if [[ -n "$OPS_CIDRS" ]]; then
validate_cidr_list OPS_CIDRS "$OPS_CIDRS"
fi
validate_cidr_list VM1_PRIVATE_CIDRS "$VM1_PRIVATE_CIDRS"
}
check_os() {
step "Проверка операционной системы"
[[ -r /etc/os-release ]] || die "Не найден /etc/os-release"
# shellcheck disable=SC1091
source /etc/os-release
[[ "${ID:-}" == "ubuntu" ]] || die "Поддерживается только Ubuntu"
[[ "${VERSION_ID%%.*}" -ge 24 ]] || die "Требуется Ubuntu 24.04 или новее"
log "Обнаружена ${PRETTY_NAME}"
}
update_system() {
step "Обновление системы и установка host-пакетов"
export DEBIAN_FRONTEND=noninteractive
apt-get update
if [[ "$SKIP_APT_UPGRADE" != "true" ]]; then
apt-get dist-upgrade -y
fi
apt-get install -y --no-install-recommends \
ca-certificates \
certbot \
curl \
fail2ban \
git \
gnupg \
iptables \
jq \
logrotate \
netcat-openbsd \
openssh-client \
openssl \
python3 \
rsync \
sudo \
unattended-upgrades \
ufw \
util-linux
}
configure_time() {
step "Настройка времени"
timedatectl set-timezone "$TIMEZONE"
timedatectl set-ntp true
}
install_authorized_key() {
local user=$1
local source=$2
local target="/home/${user}/.ssh/authorized_keys"
[[ -f "$source" && ! -L "$source" ]] || die "Не найден обычный public key file: ${source}"
[[ "$(wc -l <"$source")" -eq 1 ]] || die "${source} должен содержать ровно один public key"
ssh-keygen -l -f "$source" >/dev/null || die "Некорректный SSH public key: ${source}"
grep -Eq '^ssh-ed25519[[:space:]]+[A-Za-z0-9+/=]+([[:space:]].*)?$' "$source" \
|| die "Для ${user} разрешён только отдельный Ed25519 public key"
install -d -m 0700 -o "$user" -g "$user" "/home/${user}/.ssh"
install -m 0600 -o "$user" -g "$user" "$source" "$target"
}
create_host_roles() {
step "Создание ролей deploy и break-glass admin"
if ! id "$DEPLOY_USER" >/dev/null 2>&1; then
useradd --create-home --shell /bin/bash "$DEPLOY_USER"
log "Создан пользователь ${DEPLOY_USER}"
fi
if ! id "$ADMIN_USER" >/dev/null 2>&1; then
useradd --create-home --shell /bin/bash "$ADMIN_USER"
log "Создан break-glass пользователь ${ADMIN_USER}"
fi
install_authorized_key "$DEPLOY_USER" "$DEPLOY_AUTHORIZED_KEY_FILE"
install_authorized_key "$ADMIN_USER" "$ADMIN_AUTHORIZED_KEY_FILE"
local deploy_key
local admin_key
deploy_key="$(awk '{print $2}' "$DEPLOY_AUTHORIZED_KEY_FILE")"
admin_key="$(awk '{print $2}' "$ADMIN_AUTHORIZED_KEY_FILE")"
[[ "$deploy_key" != "$admin_key" ]] || die "deploy и admin не могут использовать один SSH key"
if [[ -s /root/.ssh/authorized_keys ]] &&
{ grep -Fq "$deploy_key" /root/.ssh/authorized_keys ||
grep -Fq "$admin_key" /root/.ssh/authorized_keys; }; then
die "Ключ deploy/admin совпадает с одним из root authorized_keys"
fi
# Deploy получает только точные sudoers-команды, без широких групп.
local forbidden_group
for forbidden_group in docker sudo lxd adm systemd-journal; do
if getent group "$forbidden_group" >/dev/null &&
id -nG "$DEPLOY_USER" | tr ' ' '\n' | grep -qx "$forbidden_group"; then
gpasswd -d "$DEPLOY_USER" "$forbidden_group"
fi
done
# Admin — персональная break-glass роль: sudo требует отдельный локальный пароль.
usermod -aG sudo "$ADMIN_USER"
for forbidden_group in docker lxd; do
if getent group "$forbidden_group" >/dev/null &&
id -nG "$ADMIN_USER" | tr ' ' '\n' | grep -qx "$forbidden_group"; then
gpasswd -d "$ADMIN_USER" "$forbidden_group"
fi
done
}
configure_account_passwords() {
step "Блокировка root/deploy и проверка break-glass admin"
if [[ "$LOCK_ACCOUNT_PASSWORDS" != "true" ]]; then
log "LOCK_ACCOUNT_PASSWORDS=false: пароли root/deploy не изменены"
else
passwd --lock root
passwd --lock "$DEPLOY_USER"
fi
local admin_password_status
admin_password_status="$(passwd --status "$ADMIN_USER" | awk '{print $2}')"
if [[ "$HARDEN_SSH" == "true" && "$admin_password_status" != "P" ]]; then
die "Перед HARDEN_SSH=true задайте отдельный sudo-пароль: passwd ${ADMIN_USER}"
fi
if [[ "$admin_password_status" != "P" ]]; then
log "ПРЕДУПРЕЖДЕНИЕ: admin пока без sudo-пароля; выполните passwd ${ADMIN_USER}"
fi
}
configure_layout() {
step "Создание каталогов и границ владения"
install -d -m 0755 -o root -g root /opt/han-chat
install -d -m 0755 -o root -g root "$DEPLOY_DIR"
install -d -m 0755 -o root -g root /var/lib/han-deploy
install -d -m 0750 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "$INCOMING_DIR"
install -d -m 0700 -o root -g root \
/etc/han \
/etc/han/secrets \
/etc/han/credentials \
/etc/han-chat
if [[ -f "${DEPLOY_DIR}/.env" ]]; then
chown root:root "${DEPLOY_DIR}/.env"
chmod 0600 "${DEPLOY_DIR}/.env"
fi
}
configure_swap() {
step "Настройка swap"
if ((SWAP_SIZE_GB == 0)); then
log "Создание swap отключено"
return
fi
if ! swapon --show=NAME --noheadings | grep -qx '/swapfile'; then
if [[ ! -f /swapfile ]]; then
fallocate -l "${SWAP_SIZE_GB}G" /swapfile
chmod 0600 /swapfile
mkswap /swapfile
fi
swapon /swapfile
fi
grep -q '^/swapfile ' /etc/fstab \
|| printf '/swapfile none swap sw 0 0\n' >>/etc/fstab
printf 'vm.swappiness = 10\n' >/etc/sysctl.d/99-han-chat-vm2-swappiness.conf
}
configure_sysctl() {
step "Настройка сетевого стека"
cat >/etc/sysctl.d/99-han-chat-vm2-hardening.conf <<'EOF'
net.ipv4.ip_forward = 1
net.ipv4.tcp_syncookies = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.icmp_echo_ignore_broadcasts = 1
net.ipv4.tcp_fin_timeout = 30
EOF
sysctl --system >/dev/null
}
install_docker() {
step "Установка Docker Engine и Compose plugin"
if ! command -v docker >/dev/null 2>&1; then
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
| gpg --dearmor --yes -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
# shellcheck disable=SC1091
source /etc/os-release
printf '%s\n' \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu ${VERSION_CODENAME} stable" \
>/etc/apt/sources.list.d/docker.list
apt-get update
apt-get install -y --no-install-recommends \
docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
fi
install -d -m 0755 /etc/docker
cat >/etc/docker/daemon.json <<'EOF'
{
"live-restore": true,
"log-driver": "json-file",
"log-opts": {
"max-size": "50m",
"max-file": "5"
},
"userland-proxy": false
}
EOF
systemctl enable --now docker
systemctl restart docker
docker compose version >/dev/null || die "Docker Compose plugin не установлен"
log "$(docker --version)"
log "$(docker compose version)"
}
for_each_cidr() {
local value=$1
local callback=$2
local item
[[ -n "$value" ]] || return 0
IFS=',' read -ra items <<<"$value"
for item in "${items[@]}"; do
"$callback" "$item"
done
}
allow_private_api() {
ufw allow from "$1" to any port 8443 proto tcp comment 'HAN VM2 private API'
}
configure_ufw() {
step "Настройка UFW"
if [[ "$RESET_UFW" == "true" ]]; then
ufw --force reset
else
log "ПРЕДУПРЕЖДЕНИЕ: RESET_UFW=false сохраняет ранее созданные UFW allow rules"
fi
ufw default deny incoming
ufw default allow outgoing
ufw allow "${SSH_PORT}/tcp" comment 'HAN VM2 SSH'
for_each_cidr "$OPS_CIDRS" allow_private_api
for_each_cidr "$VM1_PRIVATE_CIDRS" allow_private_api
ufw allow 80/tcp comment 'HAN VM2 public ACME'
ufw allow 443/tcp comment 'HAN VM2 public Bitrix webhooks'
ufw logging medium
ufw --force enable
}
configure_fail2ban() {
step "Настройка fail2ban"
cat >/etc/fail2ban/jail.d/han-chat-vm2.local <<EOF
[DEFAULT]
bantime = 2h
findtime = 10m
maxretry = 5
backend = systemd
banaction = ufw
[sshd]
enabled = true
port = ${SSH_PORT}
maxretry = 3
EOF
systemctl enable --now fail2ban
systemctl restart fail2ban
}
configure_unattended_upgrades() {
step "Автоматические security updates"
cat >/etc/apt/apt.conf.d/51han-chat-vm2-unattended <<'EOF'
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "false";
EOF
dpkg-reconfigure -f noninteractive unattended-upgrades
systemctl enable --now unattended-upgrades
}
configure_docker_firewall() {
step "Фильтрация опубликованных Docker-портов"
cat >/etc/default/han-chat-vm2-docker-firewall <<EOF
EXTERNAL_IF=${EXTERNAL_IF}
OPS_CIDRS=${OPS_CIDRS}
VM1_PRIVATE_CIDRS=${VM1_PRIVATE_CIDRS}
EOF
cat >/usr/local/sbin/han-chat-vm2-docker-firewall <<'FIREWALL'
#!/usr/bin/env bash
set -Eeuo pipefail
# shellcheck disable=SC1091
source /etc/default/han-chat-vm2-docker-firewall
external_if="${EXTERNAL_IF:-}"
if [[ -z "$external_if" ]]; then
external_if="$(ip -4 route show default | awk '{print $5; exit}')"
fi
[[ -n "$external_if" ]] || {
echo "Не удалось определить внешний интерфейс" >&2
exit 1
}
iptables -N HAN-CHAT-VM2 2>/dev/null || true
iptables -F HAN-CHAT-VM2
iptables -A HAN-CHAT-VM2 -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN
iptables -A HAN-CHAT-VM2 -i lo -j RETURN
iptables -A HAN-CHAT-VM2 -i "$external_if" -p tcp \
-m conntrack --ctorigdstport 80 -j RETURN
iptables -A HAN-CHAT-VM2 -i "$external_if" -p tcp \
-m conntrack --ctorigdstport 443 -j RETURN
allow_private_8443() {
local list=$1
local cidr
[[ -n "$list" ]] || return 0
IFS=',' read -ra cidrs <<<"$list"
for cidr in "${cidrs[@]}"; do
iptables -A HAN-CHAT-VM2 -p tcp -s "$cidr" \
-m conntrack --ctorigdstport 8443 -j RETURN
done
}
allow_private_8443 "$OPS_CIDRS"
allow_private_8443 "$VM1_PRIVATE_CIDRS"
iptables -A HAN-CHAT-VM2 -p tcp \
-m conntrack --ctorigdstport 8443 -j DROP
iptables -A HAN-CHAT-VM2 -i "$external_if" -o docker+ -j DROP
iptables -A HAN-CHAT-VM2 -i "$external_if" -o br+ -j DROP
iptables -A HAN-CHAT-VM2 -j RETURN
while iptables -C DOCKER-USER -j HAN-CHAT-VM2 2>/dev/null; do
iptables -D DOCKER-USER -j HAN-CHAT-VM2
done
iptables -I DOCKER-USER 1 -j HAN-CHAT-VM2
FIREWALL
chmod 0750 /usr/local/sbin/han-chat-vm2-docker-firewall
cat >/etc/systemd/system/han-chat-vm2-docker-firewall.service <<'EOF'
[Unit]
Description=HAN Chat VM2 firewall for Docker published ports
After=docker.service network-online.target
Wants=docker.service network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/han-chat-vm2-docker-firewall
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
EOF
install -d -m 0755 /etc/systemd/system/docker.service.d
cat >/etc/systemd/system/docker.service.d/han-chat-vm2-firewall.conf <<'EOF'
[Service]
ExecStartPost=-/usr/local/sbin/han-chat-vm2-docker-firewall
EOF
systemctl daemon-reload
systemctl enable han-chat-vm2-docker-firewall.service
systemctl restart han-chat-vm2-docker-firewall.service
}
configure_ssh() {
step "Настройка SSH"
[[ -s "/home/${DEPLOY_USER}/.ssh/authorized_keys" ]] \
|| die "Нельзя включить key-only SSH без ключа deploy"
[[ -s "/home/${ADMIN_USER}/.ssh/authorized_keys" ]] \
|| die "Нельзя включить SSH hardening без отдельного ключа admin"
cat >/etc/ssh/sshd_config.d/00-han-chat-vm2.conf <<EOF
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
PermitEmptyPasswords no
AllowAgentForwarding no
AllowTcpForwarding no
X11Forwarding no
MaxAuthTries 3
ClientAliveInterval 120
ClientAliveCountMax 2
Port ${SSH_PORT}
EOF
if [[ "$HARDEN_SSH" == "true" ]]; then
cat >>/etc/ssh/sshd_config.d/00-han-chat-vm2.conf <<EOF
PermitRootLogin no
AllowUsers ${DEPLOY_USER} ${ADMIN_USER}
EOF
log "Прямой root SSH отключён; разрешены ${DEPLOY_USER} и break-glass ${ADMIN_USER}"
else
log "Root SSH пока не отключён. Проверьте deploy/admin и повторите с HARDEN_SSH=true"
fi
sshd -t || die "Конфигурация sshd не прошла проверку"
systemctl reload ssh
}
install_deploy_sudoers() {
step "Установка минимальных прав deploy"
cat >/etc/sudoers.d/han-vm2-deploy <<EOF
Cmnd_Alias HAN_VM2_UNITS = \\
/usr/bin/systemctl start han-secrets-vm2.service, \\
/usr/bin/systemctl restart han-secrets-vm2.service, \\
/usr/bin/systemctl start han-processing.service, \\
/usr/bin/systemctl restart han-processing.service, \\
/usr/bin/systemctl stop han-processing.service
Cmnd_Alias HAN_VM2_STATUS = \\
/usr/bin/systemctl --no-pager status han-secrets-vm2.service, \\
/usr/bin/systemctl --no-pager status han-processing.service, \\
/usr/bin/journalctl --no-pager -u han-secrets-vm2.service, \\
/usr/bin/journalctl --no-pager -u han-processing.service
${DEPLOY_USER} ALL=(root) NOPASSWD: HAN_VM2_UNITS, HAN_VM2_STATUS
EOF
chmod 0440 /etc/sudoers.d/han-vm2-deploy
visudo -cf /etc/sudoers.d/han-vm2-deploy >/dev/null \
|| die "Некорректный sudoers для deploy"
}
install_release_helpers_if_possible() {
step "Установка root-owned VM2 helpers и systemd units"
local deployment="${DEPLOY_DIR}/deployment"
local secret_source="${deployment}/secrets"
local safety_sudoers
local tls_group=han-nginx-tls
local tls_gid=11001
local safety_group=han-message-safety
local safety_gid=10001
if [[ ! -f "${DEPLOY_DIR}/docker-compose.yml" ||
! -f "${secret_source}/secrets_loader.py" ||
! -f "${secret_source}/han-secrets" ]]; then
log "Активный релиз ещё не установлен; повторите скрипт после root-активации файлов"
return
fi
if find "$DEPLOY_DIR" -type l -print -quit | grep -q .; then
die "Активный релиз содержит symlink; установка helpers запрещена"
fi
chown -R root:root "$DEPLOY_DIR"
chmod -R go-w "$DEPLOY_DIR"
if getent group "$tls_group" >/dev/null; then
[[ "$(getent group "$tls_group" | cut -d: -f3)" == "$tls_gid" ]] \
|| die "Группа ${tls_group} существует с неожиданным GID"
elif getent group "$tls_gid" >/dev/null; then
die "GID ${tls_gid} уже занят другой группой"
else
groupadd --system --gid "$tls_gid" "$tls_group"
fi
if getent group "$safety_group" >/dev/null; then
[[ "$(getent group "$safety_group" | cut -d: -f3)" == "$safety_gid" ]] \
|| die "Группа ${safety_group} существует с неожиданным GID"
elif getent group "$safety_gid" >/dev/null; then
die "GID ${safety_gid} уже занят другой группой"
else
groupadd --system --gid "$safety_gid" "$safety_group"
fi
install -d -m 0750 -o root -g "$tls_group" /var/lib/han-chat/public-tls
install -d -m 0755 -o root -g root /usr/local/lib/han-secrets-vm2
install -m 0750 -o root -g root \
"${secret_source}/secrets_loader.py" \
/usr/local/lib/han-secrets-vm2/secrets_loader.py
install -m 0750 -o root -g root \
"${secret_source}/han-secrets" \
/usr/local/lib/han-secrets-vm2/han-secrets
install -m 0750 -o root -g root \
"${secret_source}/han-compose" \
/usr/local/sbin/han-vm2-compose
install -m 0755 -o root -g root \
"${deployment}/han-message-safety-mode" \
/usr/local/sbin/han-message-safety-mode
install -d -m 0755 -o root -g root /etc/letsencrypt/renewal-hooks/deploy
install -m 0755 -o root -g root \
"${deployment}/scripts/ssl-renew-deploy-hook.sh" \
/etc/letsencrypt/renewal-hooks/deploy/han-processing-nginx
install -m 0644 -o root -g root \
"${secret_source}/han-secrets-vm2.service" \
/etc/systemd/system/han-secrets-vm2.service
install -m 0644 -o root -g root \
"${deployment}/han-processing.service" \
/etc/systemd/system/han-processing.service
safety_sudoers="$(mktemp)"
sed 's/\r$//' "${deployment}/deploy-message-safety-mode.sudoers" >"$safety_sudoers"
chmod 0440 "$safety_sudoers"
if ! visudo -cf "$safety_sudoers" >/dev/null; then
rm -f "$safety_sudoers"
die "Некорректный исходный sudoers Message Safety mode"
fi
install -m 0440 -o root -g root \
"$safety_sudoers" \
/etc/sudoers.d/deploy-message-safety-mode
rm -f "$safety_sudoers"
visudo -cf /etc/sudoers.d/deploy-message-safety-mode >/dev/null \
|| die "Некорректный sudoers Message Safety mode"
if [[ ! -e /etc/han/secrets/vm2-production-like.selectel.json.example ]]; then
install -m 0600 -o root -g root \
"${secret_source}/config.example.json" \
/etc/han/secrets/vm2-production-like.selectel.json.example
fi
if [[ ! -e /etc/han-chat/message-safety-mode.env ]]; then
cat >/etc/han-chat/message-safety-mode.env <<'EOF'
MESSAGE_SAFETY_MOCK_ENABLED=false
MESSAGE_SAFETY_MOCK_TEXT_FREE=false
MESSAGE_SAFETY_MOCK_FILE_FREE=false
EOF
fi
chown root:"$safety_group" /etc/han-chat/message-safety-mode.env
chmod 0640 /etc/han-chat/message-safety-mode.env
chmod 0755 "${deployment}/preflight.sh"
systemctl daemon-reload
log "Helpers и units установлены, но application units не включены и не запущены"
}
verify() {
step "Проверка host baseline"
local failed=0
local effective_external_if="${EXTERNAL_IF:-}"
if [[ -z "$effective_external_if" ]]; then
effective_external_if="$(ip -4 route show default | awk '{print $5; exit}')"
fi
systemctl is-active --quiet docker \
|| { log "FAIL: Docker не активен"; failed=1; }
systemctl is-active --quiet fail2ban \
|| { log "FAIL: fail2ban не активен"; failed=1; }
ufw status | grep -q 'Status: active' \
|| { log "FAIL: UFW не активен"; failed=1; }
iptables -C DOCKER-USER -j HAN-CHAT-VM2 2>/dev/null \
|| { log "FAIL: HAN-CHAT-VM2 не подключена к DOCKER-USER"; failed=1; }
iptables -C HAN-CHAT-VM2 -i "$effective_external_if" -p tcp \
-m conntrack --ctorigdstport 80 -j RETURN 2>/dev/null \
|| { log "FAIL: DOCKER-USER не разрешает original host port 80"; failed=1; }
iptables -C HAN-CHAT-VM2 -i "$effective_external_if" -p tcp \
-m conntrack --ctorigdstport 443 -j RETURN 2>/dev/null \
|| { log "FAIL: DOCKER-USER не разрешает original host port 443"; failed=1; }
iptables -C HAN-CHAT-VM2 -p tcp \
-m conntrack --ctorigdstport 8443 -j DROP 2>/dev/null \
|| { log "FAIL: DOCKER-USER не закрывает original host port 8443"; failed=1; }
docker compose version >/dev/null \
|| { log "FAIL: Compose plugin недоступен"; failed=1; }
if id -nG "$DEPLOY_USER" | tr ' ' '\n' |
grep -Eq '^(docker|sudo|lxd|adm|systemd-journal)$'; then
log "FAIL: deploy состоит в запрещённой привилегированной группе"
failed=1
fi
id -nG "$ADMIN_USER" | tr ' ' '\n' | grep -qx sudo \
|| { log "FAIL: break-glass admin не состоит в sudo"; failed=1; }
if id -nG "$ADMIN_USER" | tr ' ' '\n' | grep -Eq '^(docker|lxd)$'; then
log "FAIL: admin не должен иметь прямой Docker/LXD доступ"
failed=1
fi
[[ "$(stat -c '%U:%G' "$DEPLOY_DIR")" == "root:root" ]] \
|| { log "FAIL: DEPLOY_DIR не принадлежит root"; failed=1; }
if [[ -f "${DEPLOY_DIR}/docker-compose.yml" ]]; then
[[ "$(getent group han-nginx-tls | cut -d: -f3)" == "11001" ]] \
|| { log "FAIL: группа han-nginx-tls с GID 11001 отсутствует"; failed=1; }
[[ "$(getent group han-message-safety | cut -d: -f3)" == "10001" ]] \
|| { log "FAIL: группа han-message-safety с GID 10001 отсутствует"; failed=1; }
[[ "$(stat -c '%U:%G:%a' /var/lib/han-chat/public-tls)" == \
"root:han-nginx-tls:750" ]] \
|| { log "FAIL: неверные права public TLS staging"; failed=1; }
[[ "$(stat -c '%U:%G:%a' /etc/han-chat/message-safety-mode.env)" == \
"root:han-message-safety:640" ]] \
|| { log "FAIL: неверные права Message Safety mode file"; failed=1; }
fi
[[ "$(stat -c '%U:%G' "$INCOMING_DIR")" == "${DEPLOY_USER}:${DEPLOY_USER}" ]] \
|| { log "FAIL: INCOMING_DIR не принадлежит deploy"; failed=1; }
((failed == 0)) || die "Проверка VM2 baseline не пройдена"
log "VM2 host baseline пройден"
}
summary() {
step "Подготовка VM2 завершена"
cat <<EOF | tee -a "$LOG_FILE"
Роль штатного деплоя: ${DEPLOY_USER}
Break-glass роль: ${ADMIN_USER}
Входящий staging: ${INCOMING_DIR}
Активный root release: ${DEPLOY_DIR}
SSH: public TCP/${SSH_PORT}, key-only, fail2ban
Private API 8443 из: ${VM1_PRIVATE_CIDRS}${OPS_CIDRS:+,${OPS_CIDRS}}
Public ingress: 80,443
Root SSH hardening: ${HARDEN_SSH}
Лог: ${LOG_FILE}
Следующие действия:
1. Задайте sudo-пароль break-glass роли: passwd ${ADMIN_USER}
2. Не закрывая root-сессию, проверьте отдельные SSH-ключи deploy и admin.
3. В сессии admin проверьте sudo -v и sudo -i, затем завершите root shell.
4. Передайте релиз в ${INCOMING_DIR} от имени deploy.
5. Активируйте проверенный релиз в ${DEPLOY_DIR} от root:root.
6. Повторите этот скрипт от root для установки helpers и units.
7. Настройте .env, Selectel credential/config и TLS от root.
8. Выполните deployment/preflight.sh от root.
9. После успешных gates запускайте только утверждённые systemd units.
Deploy не входит в docker group и не изменяет production-файлы.
Скрипт не запускал Compose или прикладные сервисы.
EOF
}
main() {
require_root
install -d -m 0755 "$(dirname "$LOG_FILE")"
touch "$LOG_FILE"
chmod 0600 "$LOG_FILE"
validate_parameters
check_os
update_system
configure_time
create_host_roles
configure_account_passwords
configure_layout
configure_swap
configure_sysctl
install_docker
configure_ufw
configure_fail2ban
configure_unattended_upgrades
configure_docker_firewall
configure_ssh
install_deploy_sudoers
install_release_helpers_if_possible
verify
summary
}
main "$@"
@@ -0,0 +1,37 @@
#!/bin/sh
set -eu
COMPOSE=/usr/local/sbin/han-vm2-compose
TLS_DIR=/var/lib/han-chat/public-tls
TLS_GROUP=han-nginx-tls
lineage=${RENEWED_LINEAGE:?Certbot did not provide RENEWED_LINEAGE}
test -s "$lineage/fullchain.pem"
test -s "$lineage/privkey.pem"
test -d "$TLS_DIR"
getent group "$TLS_GROUP" >/dev/null
staging=$(mktemp -d "${TLS_DIR}/.renew.XXXXXX")
trap 'rm -rf -- "$staging"' EXIT HUP INT TERM
install -m 0640 -o root -g "$TLS_GROUP" \
"$lineage/fullchain.pem" "$staging/fullchain.pem"
install -m 0640 -o root -g "$TLS_GROUP" \
"$lineage/privkey.pem" "$staging/privkey.pem"
mv -f "$staging/fullchain.pem" "$TLS_DIR/fullchain.pem"
mv -f "$staging/privkey.pem" "$TLS_DIR/privkey.pem"
rmdir "$staging"
trap - EXIT HUP INT TERM
container=$("$COMPOSE" ps --status running --quiet nginx)
[ -n "$container" ] || {
echo "HAN VM2 nginx is not running" >&2
exit 1
}
if ! nginx_test_output=$(
"$COMPOSE" exec -T nginx nginx -t -c /etc/nginx/nginx.conf 2>&1
); then
printf '%s\n' "$nginx_test_output" >&2
exit 1
fi
/usr/bin/docker kill --signal HUP "$container" >/dev/null
@@ -0,0 +1,95 @@
{
"version": 1,
"mode": "selectel",
"runtime_dir": "/run/han-chat/secrets",
"http": {
"timeout_seconds": 10,
"retries": 3,
"max_response_bytes": 1048576
},
"selectel": {
"account_id": "<selectel-account-id>",
"username": "han-vm2-secrets-reader",
"project_name": "<selectel-project>",
"region": "<selectel-region>",
"interface": "public",
"password_file": "selectel-service-user-password"
},
"secrets": {
"MESSAGE_SAFETY_DATABASE_URL": {
"remote": "vm2/MESSAGE_SAFETY_DATABASE_URL",
"consumers": ["message-safety-api", "message-safety-worker"],
"max_bytes": 4096
},
"MESSAGE_SAFETY_CONFIG_ADMIN_DATABASE_URL": {
"remote": "vm2/MESSAGE_SAFETY_CONFIG_ADMIN_DATABASE_URL",
"consumers": ["message-safety-migrate", "message-safety-config"],
"max_bytes": 4096
},
"MESSAGE_SAFETY_REDIS_URL": {
"remote": "vm2/MESSAGE_SAFETY_REDIS_URL",
"consumers": ["message-safety-api", "message-safety-worker"],
"max_bytes": 4096
},
"MESSAGE_SAFETY_SERVICE_TOKEN": {
"remote": "vm2/MESSAGE_SAFETY_SERVICE_TOKEN",
"consumers": ["message-safety-api"],
"max_bytes": 1024
},
"SELECTEL_S3_QUARANTINE_READ_ACCESS_KEY": {
"remote": "vm2/SELECTEL_S3_QUARANTINE_READ_ACCESS_KEY",
"consumers": ["message-safety-worker"],
"max_bytes": 1024
},
"SELECTEL_S3_QUARANTINE_READ_SECRET_KEY": {
"remote": "vm2/SELECTEL_S3_QUARANTINE_READ_SECRET_KEY",
"consumers": ["message-safety-worker"],
"max_bytes": 1024
},
"VM2_INTERNAL_TLS_CERTIFICATE": {
"remote": "vm2/VM2_INTERNAL_TLS_CERTIFICATE",
"consumers": ["nginx"],
"max_bytes": 16384
},
"VM2_INTERNAL_TLS_PRIVATE_KEY": {
"remote": "vm2/VM2_INTERNAL_TLS_PRIVATE_KEY",
"consumers": ["nginx"],
"max_bytes": 16384
},
"BITRIX_SYNC_DATABASE_URL": {
"remote": "vm2/BITRIX_SYNC_DATABASE_URL",
"consumers": ["bitrix-sync", "bitrix-sync-worker", "bitrix-sync-reconciliation"],
"max_bytes": 4096
},
"BITRIX_SYNC_MIGRATION_DATABASE_URL": {
"remote": "vm2/BITRIX_SYNC_MIGRATION_DATABASE_URL",
"consumers": ["bitrix-sync-migrate"],
"max_bytes": 4096
},
"BITRIX_SYNC_CRM_REST_WEBHOOK_URL": {
"remote": "vm2/BITRIX_SYNC_CRM_REST_WEBHOOK_URL",
"consumers": ["bitrix-sync", "bitrix-sync-worker", "bitrix-sync-reconciliation"],
"max_bytes": 4096
},
"BITRIX_SYNC_CONTACT_RECEIVER_TOKEN": {
"remote": "vm2/BITRIX_SYNC_CONTACT_RECEIVER_TOKEN",
"consumers": ["bitrix-sync", "bitrix-sync-worker", "bitrix-sync-reconciliation"],
"max_bytes": 1024
},
"BITRIX_SYNC_ALERT_RECEIVER_TOKEN": {
"remote": "vm2/BITRIX_SYNC_ALERT_RECEIVER_TOKEN",
"consumers": ["bitrix-sync", "bitrix-sync-worker", "bitrix-sync-reconciliation"],
"max_bytes": 1024
},
"BITRIX_SYNC_SERVICE_TOKEN": {
"remote": "vm2/BITRIX_SYNC_SERVICE_TOKEN",
"consumers": ["bitrix-sync", "bitrix-sync-worker", "bitrix-sync-reconciliation"],
"max_bytes": 1024
},
"REDIS_SAFETY_ACL": {
"remote": "vm2/REDIS_SAFETY_ACL",
"consumers": ["redis-safety"],
"max_bytes": 4096
}
}
}
@@ -0,0 +1,10 @@
#!/bin/sh
set -eu
DEPLOY_DIR=/opt/han-chat/services
CONFIG_FILE=/opt/han-chat/services/.env
LAUNCHER=/usr/local/lib/han-secrets-vm2/han-secrets
cd "$DEPLOY_DIR"
exec /usr/bin/python3 "$LAUNCHER" run --config "$CONFIG_FILE" -- \
/usr/bin/docker compose --env-file "$CONFIG_FILE" "$@"
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Synchronize VM2 runtime secrets, then execute a command with paths only."""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from secrets_loader import LoaderError, load_json, run
def public_config(path: Path) -> dict[str, str]:
result: dict[str, str] = {}
for number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
line = raw.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
raise LoaderError(f"invalid non-secret config at line {number}")
key, value = line.split("=", 1)
if not key or key in result:
raise LoaderError(f"invalid/duplicate non-secret key at line {number}")
result[key] = value
return result
def selected_config(public: dict[str, str], explicit: Path | None) -> tuple[str, Path]:
source = public.get("SECRETS_SOURCE")
if source not in {"selectel", "file"}:
raise LoaderError("SECRETS_SOURCE must explicitly be selectel or file")
if explicit:
return source, explicit
environment = public.get("APP_ENV", "production")
return source, Path(f"/etc/han/secrets/vm2-{environment}.{source}.json")
def prepare(config_path: Path, source: str, synchronize: bool) -> dict[str, str]:
document = load_json(config_path)
if document.get("mode") != source:
raise LoaderError("loader mode does not match SECRETS_SOURCE")
runtime = Path(str(document.get("runtime_dir", "")))
state_path = runtime / "state.json"
if synchronize:
run(config_path)
descriptor, temporary = tempfile.mkstemp(prefix=".state.", dir=runtime)
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
json.dump(
{"version": 1, "source": source, "loader_config": str(config_path.resolve())},
stream,
separators=(",", ":"),
)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.chmod(temporary, 0o600)
os.replace(temporary, state_path)
if not state_path.is_file():
raise LoaderError("runtime secrets are not synchronized")
state = load_json(state_path)
if state != {
"version": 1,
"source": source,
"loader_config": str(config_path.resolve()),
}:
raise LoaderError("runtime secret state does not match selected configuration")
manifest = runtime / "manifest"
entries: dict[str, str] = {}
for line in manifest.read_text(encoding="utf-8").splitlines():
key, separator, value = line.partition("=")
if not separator or key in entries or not Path(value).is_file():
raise LoaderError("runtime secret manifest is invalid")
entries[key] = value
if set(entries) != set(document.get("secrets", {})):
raise LoaderError("runtime secret manifest does not match configuration")
child = dict(os.environ)
child["HAN_SECRETS_ACTIVE"] = "1"
child["HAN_RUNTIME_SECRET_DIR"] = str(runtime)
child["HAN_RUNTIME_SECRET_MANIFEST"] = str(manifest)
for key, value in entries.items():
child[f"{key}_FILE"] = value
return child
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("action", choices=("sync", "run"))
parser.add_argument("--config", type=Path, default=Path(".env"))
parser.add_argument("--loader-config", type=Path)
arguments, command = parser.parse_known_args()
if command and command[0] == "--":
command.pop(0)
if arguments.action == "run" and not command:
parser.error("run requires a command after --")
try:
source, config = selected_config(public_config(arguments.config), arguments.loader_config)
child = prepare(config, source, arguments.action == "sync")
except (LoaderError, OSError, ValueError, json.JSONDecodeError) as exc:
print(f"han-secrets-vm2: {exc}", file=sys.stderr)
return 1
if arguments.action == "sync":
return 0
if os.name == "nt":
return subprocess.call(command, env=child)
os.execvpe(command[0], command, child)
return 127
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,41 @@
[Unit]
Description=Materialize HAN Processing VM2 service secrets
Wants=network-online.target
After=network-online.target
Before=han-processing.service
[Service]
Type=oneshot
User=root
Group=root
UMask=0077
RuntimeDirectory=han-chat/secrets
RuntimeDirectoryMode=0700
ExecStart=/usr/bin/python3 /usr/local/lib/han-secrets-vm2/han-secrets sync --config /opt/han-chat/services/.env
LoadCredentialEncrypted=selectel-service-user-password:/etc/han/credentials/vm2.selectel-password.cred
RemainAfterExit=yes
StandardOutput=null
StandardError=journal
SyslogIdentifier=han-secrets-vm2
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
LimitCORE=0
SystemCallArchitectures=native
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
CapabilityBoundingSet=
AmbientCapabilities=
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,316 @@
#!/usr/bin/env python3
"""Fail-closed VM2 adaptation of the reviewed HAN Selectel secrets loader."""
from __future__ import annotations
import argparse
import base64
import binascii
import json
import os
import random
import re
import ssl
import stat
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any, Mapping
IDENTITY_URL = "https://cloud.api.selcloud.ru/identity/v3/auth/tokens"
NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")
CONSUMER_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]*$")
RETRYABLE = {408, 425, 429, 500, 502, 503, 504}
MAX_CONFIG = 1_048_576
class LoaderError(Exception):
"""Expected error whose text contains no provider response or secret value."""
def fail(message: str) -> None:
raise LoaderError(message)
def private_file(path: Path, label: str) -> None:
try:
metadata = path.lstat()
except OSError as exc:
fail(f"cannot inspect {label}: {exc.__class__.__name__}")
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
fail(f"{label} must be a regular non-symlink file")
if os.name != "nt" and stat.S_IMODE(metadata.st_mode) & 0o077:
fail(f"{label} must not be accessible by group or other users")
def read_limited(path: Path, limit: int, label: str) -> bytes:
try:
with path.open("rb") as stream:
value = stream.read(limit + 1)
except OSError as exc:
fail(f"cannot read {label}: {exc.__class__.__name__}")
if len(value) > limit:
fail(f"{label} exceeds configured limit")
return value
def object_value(value: Any, label: str) -> dict[str, Any]:
if not isinstance(value, dict):
fail(f"{label} must be an object")
return value
def load_json(path: Path) -> dict[str, Any]:
try:
return object_value(json.loads(read_limited(path, MAX_CONFIG, "configuration")), "configuration")
except (UnicodeDecodeError, json.JSONDecodeError):
fail("configuration is not valid UTF-8 JSON")
def required_string(value: Mapping[str, Any], key: str, label: str) -> str:
result = value.get(key)
if not isinstance(result, str) or not result:
fail(f"{label}.{key} must be a non-empty string")
return result
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str) -> None:
return None
class Client:
def __init__(self, timeout: float, retries: int, maximum: int, ca_file: str | None) -> None:
context = ssl.create_default_context(cafile=ca_file)
self.opener = urllib.request.build_opener(
urllib.request.HTTPSHandler(context=context), NoRedirect()
)
self.timeout, self.retries, self.maximum = timeout, retries, maximum
def request(
self, method: str, url: str, expected: set[int], headers: Mapping[str, str] | None = None, body: bytes | None = None
) -> tuple[Mapping[str, str], bytes]:
parsed = urllib.parse.urlsplit(url)
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password:
fail("provider endpoint must be credential-free HTTPS")
request = urllib.request.Request(url, data=body, headers=dict(headers or {}), method=method)
for attempt in range(self.retries + 1):
try:
with self.opener.open(request, timeout=self.timeout) as response:
if int(response.headers.get("Content-Length", 0)) > self.maximum:
fail("provider response exceeds configured limit")
response_body = response.read(self.maximum + 1)
if len(response_body) > self.maximum:
fail("provider response exceeds configured limit")
if response.status not in expected:
fail(f"provider request failed with HTTP {response.status}")
return response.headers, response_body
except urllib.error.HTTPError as exc:
if exc.code not in RETRYABLE or attempt == self.retries:
fail(f"provider request failed with HTTP {exc.code}")
except (urllib.error.URLError, TimeoutError, OSError):
if attempt == self.retries:
fail("provider request failed after retries")
time.sleep(min(8.0, 0.25 * (2**attempt)) * (0.5 + random.random()))
fail("provider request failed")
def credential(selectel: Mapping[str, Any], environ: Mapping[str, str]) -> str:
configured = Path(required_string(selectel, "password_file", "selectel"))
if not configured.is_absolute():
directory = environ.get("CREDENTIALS_DIRECTORY")
if not directory:
fail("relative password_file requires CREDENTIALS_DIRECTORY")
configured = Path(directory) / configured
private_file(configured, "Selectel credential")
try:
value = read_limited(configured, 16_384, "Selectel credential").decode().rstrip("\r\n")
except UnicodeDecodeError:
fail("Selectel credential is not UTF-8")
if not value or "\n" in value or "\r" in value:
fail("Selectel credential must contain one non-empty line")
return value
def decode_document(raw: bytes, label: str) -> dict[str, Any]:
try:
return object_value(json.loads(raw.decode()), label)
except (UnicodeDecodeError, json.JSONDecodeError):
fail(f"{label} is not valid JSON")
def fetch_values(config: Mapping[str, Any], specs: Mapping[str, Mapping[str, Any]], environ: Mapping[str, str]) -> dict[str, bytes]:
selectel = object_value(config.get("selectel"), "selectel")
http = object_value(config.get("http", {}), "http")
client = Client(
float(http.get("timeout_seconds", 10)),
int(http.get("retries", 3)),
int(http.get("max_response_bytes", MAX_CONFIG)),
selectel.get("ca_file"),
)
account = required_string(selectel, "account_id", "selectel")
auth = {
"auth": {
"identity": {"methods": ["password"], "password": {"user": {
"name": required_string(selectel, "username", "selectel"),
"domain": {"name": account},
"password": credential(selectel, environ),
}}},
"scope": {"project": {
"name": required_string(selectel, "project_name", "selectel"),
"domain": {"name": account},
}},
}
}
headers, body = client.request(
"POST",
str(selectel.get("identity_url", IDENTITY_URL)),
{201},
{"Content-Type": "application/json", "Accept": "application/json"},
json.dumps(auth, separators=(",", ":")).encode(),
)
token = headers.get("X-Subject-Token")
identity = decode_document(body, "identity response").get("token")
if not token or not isinstance(identity, dict) or not isinstance(identity.get("project"), dict):
fail("identity token is missing or not project-scoped")
matches: list[str] = []
for service in identity.get("catalog", []):
if isinstance(service, dict) and service.get("type") == "secrets-manager":
for endpoint in service.get("endpoints", []):
if (
isinstance(endpoint, dict)
and endpoint.get("region") == selectel.get("region")
and endpoint.get("interface") == selectel.get("interface", "public")
and isinstance(endpoint.get("url"), str)
):
matches.append(endpoint["url"].rstrip("/"))
if len(matches) != 1:
fail("service catalog has no unique matching Secrets Manager endpoint")
values: dict[str, bytes] = {}
for name, spec in specs.items():
remote = required_string(spec, "remote", f"secrets.{name}")
_, secret_body = client.request(
"GET",
matches[0] + "/v1/" + urllib.parse.quote(remote, safe=""),
{200},
{"X-Auth-Token": str(token), "Accept": "application/json"},
)
document = decode_document(secret_body, f"secret {name} response")
payload = document.get("version") if isinstance(document.get("version"), dict) else document
encoded = payload.get("value")
try:
value = base64.b64decode(encoded, validate=True)
except (TypeError, ValueError, binascii.Error):
fail(f"secret {name} has invalid encoding")
maximum = int(spec.get("max_bytes", 65_536))
if not value or len(value) > maximum or b"\x00" in value:
fail(f"secret {name} is empty, unsafe, or exceeds its limit")
values[name] = value
return values
def file_values(config: Mapping[str, Any], specs: Mapping[str, Mapping[str, Any]]) -> dict[str, bytes]:
source = Path(required_string(object_value(config.get("file"), "file"), "path", "file"))
if not source.is_absolute():
fail("file.path must be absolute")
try:
metadata = source.lstat()
except OSError as exc:
fail(f"cannot inspect fallback secret directory: {exc.__class__.__name__}")
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
fail("fallback secret directory must be a non-symlink directory")
if os.name != "nt" and stat.S_IMODE(metadata.st_mode) & 0o077:
fail("fallback secret directory must be mode 0700 or stricter")
expected = set(specs)
actual = {entry.name for entry in source.iterdir()}
if actual != expected:
fail("fallback secret directory does not exactly match configured keys")
values: dict[str, bytes] = {}
for name, spec in specs.items():
path = source / name
private_file(path, f"fallback secret {name}")
value = read_limited(path, int(spec.get("max_bytes", 65_536)), f"fallback secret {name}")
if not value or b"\x00" in value:
fail(f"fallback secret {name} is empty or unsafe")
values[name] = value
return values
def materialize(runtime: Path, specs: Mapping[str, Mapping[str, Any]], values: Mapping[str, bytes]) -> list[str]:
runtime.mkdir(parents=True, exist_ok=True, mode=0o700)
if runtime.is_symlink():
fail("runtime directory must not be a symlink")
os.chmod(runtime, 0o700)
paths: dict[str, Path] = {}
for name, value in values.items():
descriptor, temporary = tempfile.mkstemp(prefix=f".{name}.", dir=runtime)
temporary_path = Path(temporary)
with os.fdopen(descriptor, "wb") as stream:
stream.write(value)
stream.flush()
os.fsync(stream.fileno())
os.chmod(temporary_path, 0o444)
destination = runtime / name
os.replace(temporary_path, destination)
paths[name] = destination
consumers = sorted({consumer for spec in specs.values() for consumer in spec["consumers"]})
for consumer in consumers:
lines = [
f'{name}_FILE="{paths[name].resolve()}"\n'
for name, spec in sorted(specs.items())
if consumer in spec["consumers"]
]
destination = runtime / f"{consumer}.env"
destination.write_text("".join(lines), encoding="utf-8")
os.chmod(destination, 0o600)
manifest = runtime / "manifest"
manifest.write_text("".join(f"{name}={path.resolve()}\n" for name, path in sorted(paths.items())), encoding="utf-8")
os.chmod(manifest, 0o600)
return consumers
def run(config_path: Path, environ: Mapping[str, str] | None = None) -> list[str]:
os.umask(0o077)
config = load_json(config_path)
if config.get("version") != 1 or config.get("mode") not in {"selectel", "file"}:
fail("configuration version/mode is invalid")
runtime = Path(required_string(config, "runtime_dir", "configuration"))
if not runtime.is_absolute():
fail("runtime_dir must be absolute")
raw_specs = object_value(config.get("secrets"), "secrets")
specs: dict[str, Mapping[str, Any]] = {}
for name, spec_value in raw_specs.items():
spec = object_value(spec_value, f"secrets.{name}")
consumers = spec.get("consumers")
if (
not NAME_RE.fullmatch(name)
or not isinstance(consumers, list)
or not consumers
or any(not isinstance(item, str) or not CONSUMER_RE.fullmatch(item) for item in consumers)
):
fail("secret name or consumer list is invalid")
specs[name] = spec
environment = os.environ if environ is None else environ
values = fetch_values(config, specs, environment) if config["mode"] == "selectel" else file_values(config, specs)
return materialize(runtime, specs, values)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--config", required=True, type=Path)
args = parser.parse_args()
try:
consumers = run(args.config)
except (LoaderError, OSError, ValueError) as exc:
print(f"secrets-loader: {exc}", file=sys.stderr)
return 1
print(f"secrets-loader: materialized {len(consumers)} VM2 consumer scopes", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())