Проект разделен на два репозитория
This commit is contained in:
@@ -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 6–8: 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())
|
||||
Reference in New Issue
Block a user