Перенесены секреты из .env в SM

This commit is contained in:
mi
2026-07-30 19:22:48 +03:00
parent 049c45db5c
commit e24ed9d8ef
58 changed files with 3350 additions and 1054 deletions
@@ -262,7 +262,7 @@ han-chat-documents
## 8. Создание файла окружения
На VM:
`.env` содержит только несекретную конфигурацию. На VM:
```sh
cd /opt/han-chat/backend
@@ -272,7 +272,18 @@ chmod 600 .env
nano .env
```
Замените все `change-me` и адреса `example.*`.
Замените несекретные адреса `example.*`. Не добавляйте в `.env` пароли, токены,
ключи, credential-bearing DSN или пути `*_FILE`. Установите отдельно проверенный
launcher `deployment/secrets/han-secrets` из ops-пакета. Его интерфейс:
```sh
deployment/secrets/han-secrets run --config .env -- <command>
```
Launcher читает `SECRETS_SOURCE=file|selectel`, устанавливает
`HAN_SECRETS_ACTIVE=1`, выдаёт значения только дочернему процессу и не печатает
их. Рекомендуемый `HAN_RUNTIME_SECRET_MANIFEST` содержит только пары
`SECRET_KEY=/absolute/protected/path`; файлы имеют mode `0400`/`0600`.
### 8.1. Основные адреса
@@ -295,7 +306,7 @@ KEYCLOAK_AUDIENCE=han-chat-api
### 8.2. PostgreSQL
Укажите фактические host, port, database, пользователей и пароли:
В `.env` укажите только host, port, database и путь к публичному CA:
```dotenv
HAN_PG_HOST=<PG_HOST>
@@ -303,17 +314,13 @@ HAN_PG_PORT=6432
HAN_PG_DATABASE=han_chat
PG_CA_HOST_PATH=/opt/han-chat/backend/secrets/pg/ca.pem
DATABASE_URL=postgresql+asyncpg://han_app:<PASSWORD>@<PG_HOST>:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem
BITRIX_DATABASE_URL=postgresql://bitrix_local_app:<PASSWORD>@<PG_HOST>:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem
BITRIX_SYNC_DATABASE_URL=postgresql://bitrix_sync_user:<PASSWORD>@<PG_HOST>:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem
MESSAGE_SAFETY_DATABASE_URL=postgresql://message_safety_app:<PASSWORD>@<PG_HOST>:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem
KEYCLOAK_DB_URL=jdbc:postgresql://<PG_HOST>:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem
KEYCLOAK_DB_SCHEMA=keycloak
KEYCLOAK_DB_USERNAME=keycloak_user
KEYCLOAK_DB_PASSWORD=<PASSWORD>
```
Все service DSN, включая JDBC и backup DSN, формирует secret backend. Runtime
validator проверяет `verify-full`, `sslrootcert` и запрет `options/currentSchema`
без вывода строк подключения.
Порт `5433` используется с PgBouncer в режиме `session`. Не добавляйте
`options=-csearch_path...` или JDBC-параметр `currentSchema`: они передают
startup parameter `search_path`, который Selectel PgBouncer отклоняет. Для
@@ -323,9 +330,12 @@ Keycloak схема задаётся отдельно через `KEYCLOAK_DB_SC
Если пароль содержит `@`, `:`, `/`, `?`, `#` или `%`, его необходимо
URL-кодировать внутри PostgreSQL URL.
### 8.3. Генерация секретов
### 8.3. Подготовка runtime-секретов
Для обычных токенов используйте:
Генерируйте секреты вне shell history средствами secret manager. Не выполняйте
`export TOKEN=...` и не вставляйте значения в команды. Имена обязательных
runtime-переменных определены в `scripts/validate-env`; парные токены связываются
в secret backend.
```sh
openssl rand -hex 32
@@ -337,7 +347,8 @@ openssl rand -hex 32
python3 -c 'import base64,secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())'
```
Заполните все секреты. Две пары должны совпадать:
Ни один из этих секретов не добавляется в `.env`. Пары проверяются runtime
validator без вывода значений.
```dotenv
BITRIX_LOCAL_APP_INTERNAL_TOKEN=<TOKEN_A>
@@ -361,7 +372,8 @@ KEYCLOAK_ADMIN_PASSWORD=<UNIQUE_ADMIN_PASSWORD>
### 8.4. Redis
Создайте три разных пароля и продублируйте их в URL:
Создайте три разных пароля в secret backend; там же сформируйте Redis URL.
Следующий блок описывает логический контракт и не является содержимым `.env`:
```dotenv
REDIS_API_PASSWORD=<REDIS_API_PASSWORD>
@@ -380,11 +392,10 @@ MESSAGE_SAFETY_REDIS_URL=redis://message_safety:<REDIS_SAFETY_PASSWORD>@redis:63
```dotenv
KEYCLOAK_OTP_MOCK_ENABLED=true
KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true
KEYCLOAK_OTP_MOCK_CODE=<ТЕСТОВЫЙ_КОД_НЕ_КОРОЧЕ_16_СИМВОЛОВ>
```
Этот код будет вводиться пользователем при тестовой авторизации. Не используйте
его как production-механизм доставки OTP.
`KEYCLOAK_OTP_MOCK_CODE` хранится только в secret backend. Не используйте mock
как production-механизм доставки OTP.
Целевой real mode задаёт `modules/module-11-idgtl-sms.md`: Keycloak генерирует и локально проверяет OTP, `sms-service` надёжно записывает заказ/журнал, worker вызывает i-Digital Direct, callback обновляет только delivery journal. Нельзя просто установить `KEYCLOAK_OTP_MOCK_ENABLED=false`.
@@ -397,13 +408,10 @@ SELECTEL_S3_ENDPOINT_URL=https://s3.storage.selcloud.ru
SELECTEL_S3_BUCKET_QUARANTINE=han-chat-quarantine
SELECTEL_S3_BUCKET_ATTACHMENTS=han-chat-attachments
SELECTEL_S3_BUCKET_DOCUMENTS=han-chat-documents
SELECTEL_S3_ACCESS_KEY=<API_ACCESS_KEY>
SELECTEL_S3_SECRET_KEY=<API_SECRET_KEY>
SELECTEL_S3_QUARANTINE_READ_ACCESS_KEY=<SAFETY_READ_ACCESS_KEY>
SELECTEL_S3_QUARANTINE_READ_SECRET_KEY=<SAFETY_READ_SECRET_KEY>
```
Обе пары S3 credentials хранятся только в secret backend.
API backend принудительно использует virtual-hosted addressing:
`https://<bucket>.s3.storage.selcloud.ru/<object-key>`. Это обязательно для
браузерных presigned PUT и CORS в Selectel; path-style URL для этого сценария не
@@ -411,20 +419,17 @@ API backend принудительно использует virtual-hosted addre
### 8.7. Bitrix24
До установки локального приложения заполните:
До установки локального приложения загрузите credentials в secret backend.
В `.env` остаются только несекретные connector/public URL параметры:
```dotenv
BITRIX_CLIENT_ID=<BITRIX_CLIENT_ID>
BITRIX_CLIENT_SECRET=<BITRIX_CLIENT_SECRET>
BITRIX_CONNECTOR_ID=han_mobile_app
BITRIX_OPEN_LINE_ID=8
BITRIX_PUBLIC_BASE_URL=https://chat.example.ru/bitrix
BITRIX_TOKEN_ENCRYPTION_KEY=<URLSAFE_BASE64_KEY>
```
`BITRIX_APPLICATION_TOKEN` можно окончательно задать после создания приложения в
Bitrix24. До этого используйте отдельное случайное значение, проходящее
валидацию окружения.
`BITRIX_APPLICATION_TOKEN` сохраняется в secret backend после создания
приложения и никогда не помещается в `.env`.
### 8.8. TLS и наблюдаемость
@@ -448,8 +453,11 @@ ACME_EMAIL=<ADMIN_EMAIL>
```sh
cd /opt/han-chat/backend
./scripts/validate-env .env
docker compose --env-file .env config --quiet
docker compose --env-file .env config --services
sudo systemctl restart han-secrets@production.service
sudo ./scripts/validate-env .env \
--runtime-manifest /run/han-chat/secrets/manifest
sudo deployment/secrets/han-compose config --quiet
sudo deployment/secrets/han-compose config --services
python3 -m unittest discover -s tests -v
```
@@ -670,7 +678,8 @@ curl -i https://chat.example.ru/internal/safety/v1/messages/check
https://chat.example.ru/
```
Для тестовой авторизации используйте значение `KEYCLOAK_OTP_MOCK_CODE` из `.env`.
Для тестовой авторизации получите mock code утверждённым защищённым способом,
не читая его из `.env` и не помещая в shell history.
## 16. Подключение Bitrix24
@@ -685,7 +694,7 @@ Placement: https://chat.example.ru/bitrix/placement
После установки:
1. Получите и сохраните application token.
2. Запишите его в `BITRIX_APPLICATION_TOKEN` файла `.env`.
2. Сохраните его как `BITRIX_APPLICATION_TOKEN` в secret backend.
3. Пересоздайте сервис:
```sh
@@ -735,7 +744,7 @@ docker compose --env-file .env up -d --force-recreate <SERVICE_NAME>
1. Создайте backup/PITR marker PostgreSQL.
2. Сохраните текущие image digests.
3. Получите новый код.
4. Проверьте `.env`.
4. Проверьте несекретный `.env` и runtime secret set.
5. Пересоберите образы.
6. Примените миграции и seed.
7. Пересоздайте сервисы.
+16 -10
View File
@@ -60,16 +60,20 @@ verify key-based deploy access in a separate SSH session.
## Gate 6 — environment and secrets
```sh
umask 077
cp .env.example .env
chmod 600 .env
# Replace placeholders using a protected editor/secret manager.
# Replace non-secret configuration placeholders only.
./scripts/validate-env .env
docker compose --env-file .env config --quiet
sudo systemctl restart han-secrets@production.service
sudo ./scripts/validate-env .env \
--runtime-manifest /run/han-chat/secrets/manifest
sudo deployment/secrets/han-compose config --quiet
```
- [ ] Token pairs match, PG verifies TLS, public URLs are HTTPS.
- [ ] Mock OTP risk is accepted and all secrets are unique >=128-bit values.
- [ ] `SECRETS_SOURCE=file|selectel`; `.env` contains no secret keys or credential-bearing DSNs.
- [ ] `deployment/secrets/han-secrets` sets `HAN_SECRETS_ACTIVE=1`, does not log values,
and optionally exposes a paths-only `HAN_RUNTIME_SECRET_MANIFEST`.
- [ ] Runtime token pairs match, PG verifies TLS, public URLs are HTTPS.
- [ ] Mock OTP risk is accepted and runtime secrets are unique >=128-bit values.
- [ ] `NOTIFICATIONS_TOKEN_PRODUCER_TEST` is unique and supplied only through secret/env; the `producer_test` source seed stores only its hash.
- [ ] `FRONTEND_DEV_PROXY_ENABLED=false` and Safety/nginx timeout budgets match.
@@ -211,8 +215,7 @@ For local acceptance only, start the redacted debug collector with:
Provider backup/PITR is authoritative. A supplemental verified logical dump:
```sh
PG_BACKUP_DSN='postgresql://...?...sslmode=verify-full&sslrootcert=...' \
deployment/scripts/backup.sh /opt/han-chat/backups
deployment/scripts/backup.sh /opt/han-chat/backups
```
Quarterly, restore PG and S3 into an isolated VPC, deploy the same image digests,
@@ -225,10 +228,13 @@ Only roll back to images compatible with the current schema:
```sh
SCHEMA_BACKWARD_COMPATIBLE_CONFIRMED=true \
deployment/scripts/rollback.sh /secure/path/previous-release.env
ENV_FILE=/secure/path/previous-release.env deployment/scripts/smoke.sh
deployment/scripts/rollback.sh <PREVIOUS_IMMUTABLE_RELEASE>
deployment/scripts/smoke.sh
```
Rollback reuses the current runtime secret set and non-secret config. Do not
create or restore an environment snapshot.
## Real SMS rollout addendum
This runbook remains mock-only until module-11 artifacts exist. An SMS release requires schema/role `sms`, versioned migrations and an active approved `auth_otp` seed, `sms-service`/worker, the exact callback route, paired service tokens, Direct `TOKEN_1`, approved sender/template, separate callback credentials, a reconfirmed callback source IP, and a static worker egress IP.
+17 -10
View File
@@ -63,16 +63,21 @@ sudo deployment/scripts/setup-vm.sh
## Этап 6 — окружение и секреты
```sh
umask 077
cp .env.example .env
chmod 600 .env
# Замените заглушки через защищенный редактор или менеджер секретов.
# Замените только несекретные placeholders.
./scripts/validate-env .env
docker compose --env-file .env config --quiet
sudo systemctl restart han-secrets@production.service
sudo ./scripts/validate-env .env \
--runtime-manifest /run/han-chat/secrets/manifest
sudo deployment/secrets/han-compose config --quiet
```
- [ ] Парные токены совпадают, PostgreSQL проверяет TLS, публичные URL используют HTTPS.
- [ ] Риск mock OTP принят; все секреты уникальны и содержат не менее 128 бит энтропии.
- [ ] `SECRETS_SOURCE=file|selectel`; `.env` не содержит secret keys и DSN с credentials.
- [ ] `deployment/secrets/han-secrets` устанавливает `HAN_SECRETS_ACTIVE=1`,
не пишет значения в лог и при возможности передаёт paths-only manifest
через `HAN_RUNTIME_SECRET_MANIFEST`.
- [ ] Runtime-пары токенов совпадают, PostgreSQL проверяет TLS.
- [ ] Риск mock OTP принят; runtime-секреты уникальны и содержат не менее 128 бит энтропии.
- [ ] `NOTIFICATIONS_TOKEN_PRODUCER_TEST` сгенерирован отдельно, передан только через secret/env; seed `notification_sources.code='producer_test'` содержит только его hash.
- [ ] Установлено `FRONTEND_DEV_PROXY_ENABLED=false`; таймауты Safety и nginx согласованы.
@@ -233,8 +238,7 @@ deployment/scripts/smoke.sh
Основным механизмом являются backup/PITR провайдера. Дополнительный проверенный логический дамп:
```sh
PG_BACKUP_DSN='postgresql://...?...sslmode=verify-full&sslrootcert=...' \
deployment/scripts/backup.sh /opt/han-chat/backups
deployment/scripts/backup.sh /opt/han-chat/backups
```
Ежеквартально восстанавливайте PostgreSQL и S3 в изолированной VPC, развертывайте те же
@@ -248,10 +252,13 @@ smoke-тесты и фиксируйте фактические RPO/RTO. Redis
```sh
SCHEMA_BACKWARD_COMPATIBLE_CONFIRMED=true \
deployment/scripts/rollback.sh /secure/path/previous-release.env
ENV_FILE=/secure/path/previous-release.env deployment/scripts/smoke.sh
deployment/scripts/rollback.sh <PREVIOUS_IMMUTABLE_RELEASE>
deployment/scripts/smoke.sh
```
Откат использует текущие runtime-секреты и текущий несекретный config. Snapshot
старого `.env` не создаётся и не восстанавливается.
## Дополнение: rollout реальной SMS-авторизации
Текущий runbook остаётся mock-only, пока артефакты module-11 не реализованы. Для SMS release обязательны: schema/role `sms`, migrations/seed active approved `auth_otp`, `sms-service`/worker, exact callback route, парные service tokens, Direct `TOKEN_1`, согласованные sender/template, отдельные callback credentials, подтверждённый callback source IP и статический egress IP worker.
@@ -1,83 +1,123 @@
x-no-sms-secrets: &no-sms-secrets
SMS_DATABASE_URL: ""
SMS_SERVICE_TOKEN: ""
KEYCLOAK_SMS_SERVICE_TOKEN: ""
IDGTL_SMS_API_KEY: ""
IDGTL_SMS_CALLBACK_USERNAME: ""
IDGTL_SMS_CALLBACK_PASSWORD: ""
x-api-job-secrets: &api-job-secrets
- api_database_url
- api_redis_url
- api_redis_realtime_url
- message_safety_service_token
- bitrix_local_app_internal_token
- bitrix_api_inbox_token
- keycloak_settings_bridge_token
- selectel_s3_access_key
- selectel_s3_secret_key
- cursor_hmac_secret
x-api-job-environment: &api-job-environment
HAN_SECRET_VARS: >-
DATABASE_URL REDIS_URL REDIS_REALTIME_URL MESSAGE_SAFETY_SERVICE_TOKEN
BITRIX_LOCAL_APP_INTERNAL_TOKEN BITRIX_API_INBOX_TOKEN
KEYCLOAK_SETTINGS_BRIDGE_TOKEN SELECTEL_S3_ACCESS_KEY SELECTEL_S3_SECRET_KEY
CURSOR_HMAC_SECRET
DATABASE_URL_FILE: /run/secrets/api_database_url
REDIS_URL_FILE: /run/secrets/api_redis_url
REDIS_REALTIME_URL_FILE: /run/secrets/api_redis_realtime_url
MESSAGE_SAFETY_SERVICE_TOKEN_FILE: /run/secrets/message_safety_service_token
BITRIX_LOCAL_APP_INTERNAL_TOKEN_FILE: /run/secrets/bitrix_local_app_internal_token
BITRIX_API_INBOX_TOKEN_FILE: /run/secrets/bitrix_api_inbox_token
KEYCLOAK_SETTINGS_BRIDGE_TOKEN_FILE: /run/secrets/keycloak_settings_bridge_token
SELECTEL_S3_ACCESS_KEY_FILE: /run/secrets/selectel_s3_access_key
SELECTEL_S3_SECRET_KEY_FILE: /run/secrets/selectel_s3_secret_key
CURSOR_HMAC_SECRET_FILE: /run/secrets/cursor_hmac_secret
APP_ENV: ${APP_ENV:-production-like}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
KEYCLOAK_PUBLIC_URL: ${KEYCLOAK_PUBLIC_URL}
KEYCLOAK_INTERNAL_URL: ${KEYCLOAK_INTERNAL_URL:-http://keycloak:8080/auth}
KEYCLOAK_REALM: ${KEYCLOAK_REALM:-han-chat}
KEYCLOAK_AUDIENCE: ${KEYCLOAK_AUDIENCE:-han-chat-api}
MESSAGE_SAFETY_URL: ${MESSAGE_SAFETY_URL:-http://message-safety:8080}
MESSAGE_SAFETY_POST_TIMEOUT_SEC: ${MESSAGE_SAFETY_POST_TIMEOUT_SEC:-5}
MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC: ${MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC:-2}
MESSAGE_SAFETY_TASK_POLL_MAX_SEC: ${MESSAGE_SAFETY_TASK_POLL_MAX_SEC:-300}
BITRIX_LOCAL_APP_BASE_URL: ${BITRIX_LOCAL_APP_BASE_URL:-http://bitrix-local-app:8080}
BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC: ${BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC:-20}
SELECTEL_S3_ENDPOINT_URL: ${SELECTEL_S3_ENDPOINT_URL}
SELECTEL_S3_BUCKET_DOCUMENTS: ${SELECTEL_S3_BUCKET_DOCUMENTS}
SELECTEL_S3_BUCKET_ATTACHMENTS: ${SELECTEL_S3_BUCKET_ATTACHMENTS}
SELECTEL_S3_BUCKET_QUARANTINE: ${SELECTEL_S3_BUCKET_QUARANTINE}
TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-127.0.0.1/32}
services:
migrate-api:
image: ${API_BACKEND_IMAGE:-han-chat-api-backend:local}
profiles: ["ops"]
env_file:
- path: ../.env
required: false
environment: *no-sms-secrets
entrypoint: []
environment:
HAN_SECRET_VARS: DATABASE_URL
DATABASE_URL_FILE: /run/secrets/api_database_url
secrets:
- api_database_url
command: ["alembic", "upgrade", "head"]
volumes:
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
networks: [backend, egress]
restart: "no"
security_opt: ["no-new-privileges:true"]
ulimits:
core: {soft: 0, hard: 0}
migrate-bitrix-local:
image: ${BITRIX_LOCAL_APP_IMAGE:-han-chat-bitrix-local-app:local}
profiles: ["ops"]
env_file:
- path: ../.env
required: false
environment: *no-sms-secrets
entrypoint: []
environment:
HAN_SECRET_VARS: BITRIX_DATABASE_URL
BITRIX_DATABASE_URL_FILE: /run/secrets/bitrix_database_url
secrets:
- bitrix_database_url
command: ["alembic", "upgrade", "head"]
volumes:
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
networks: [backend, egress]
restart: "no"
security_opt: ["no-new-privileges:true"]
ulimits:
core: {soft: 0, hard: 0}
migrate-bitrix-sync:
image: ${BITRIX_SYNC_IMAGE:-han-chat-bitrix-sync:local}
profiles: ["ops"]
env_file:
- path: ../.env
required: false
environment: *no-sms-secrets
entrypoint: []
environment:
HAN_SECRET_VARS: BITRIX_SYNC_DATABASE_URL
BITRIX_SYNC_DATABASE_URL_FILE: /run/secrets/bitrix_sync_database_url
secrets:
- bitrix_sync_database_url
command: ["alembic", "upgrade", "head"]
volumes:
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
networks: [backend, egress]
restart: "no"
security_opt: ["no-new-privileges:true"]
ulimits:
core: {soft: 0, hard: 0}
migrate-sms:
image: ${SMS_SERVICE_IMAGE:-han-chat-sms-service:local}
profiles: ["ops"]
environment:
SMS_DATABASE_URL: ${SMS_DATABASE_URL}
SMS_SERVICE_TOKEN: ${SMS_SERVICE_TOKEN}
IDGTL_SMS_BASE_URL: ${IDGTL_SMS_BASE_URL:-https://direct.i-dgtl.ru}
IDGTL_SMS_CALLBACK_PUBLIC_URL: ${IDGTL_SMS_CALLBACK_PUBLIC_URL}
IDGTL_SMS_CALLBACK_USERNAME: ${IDGTL_SMS_CALLBACK_USERNAME}
IDGTL_SMS_CALLBACK_PASSWORD: ${IDGTL_SMS_CALLBACK_PASSWORD}
entrypoint: []
HAN_SECRET_VARS: SMS_DATABASE_URL
SMS_DATABASE_URL_FILE: /run/secrets/sms_database_url
secrets:
- sms_database_url
command: ["alembic", "upgrade", "head"]
volumes:
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
networks: [backend, egress]
restart: "no"
security_opt: ["no-new-privileges:true"]
ulimits:
core: {soft: 0, hard: 0}
seed-settings:
image: ${API_BACKEND_IMAGE:-han-chat-api-backend:local}
profiles: ["ops"]
env_file:
- path: ../.env
required: false
environment: *no-sms-secrets
entrypoint: []
environment: *api-job-environment
secrets: *api-job-secrets
command:
- /bin/sh
- -ec
@@ -91,6 +131,8 @@ services:
networks: [backend, egress]
restart: "no"
security_opt: ["no-new-privileges:true"]
ulimits:
core: {soft: 0, hard: 0}
toolbox:
image: curlimages/curl:8.11.1
+17 -3
View File
@@ -1,10 +1,22 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/../.."
CONFIG_FILE=${CONFIG_FILE:-.env}
SECRETS_LAUNCHER=${SECRETS_LAUNCHER:-deployment/secrets/han-secrets}
if [ -z "${PG_BACKUP_DSN:-}" ]; then
echo "PG_BACKUP_DSN is required (managed PostgreSQL TLS DSN, supplied via secure environment)." >&2
if [ "${HAN_SECRETS_ACTIVE:-0}" != "1" ]; then
[ -x "$SECRETS_LAUNCHER" ] || {
echo "Secret launcher is required: $SECRETS_LAUNCHER" >&2
exit 66
}
exec "$SECRETS_LAUNCHER" run --config "$CONFIG_FILE" -- "$0" "$@"
fi
if [ -z "${PG_BACKUP_DSN_FILE:-}" ] || [ ! -r "$PG_BACKUP_DSN_FILE" ]; then
echo "PG_BACKUP_DSN_FILE is required from the secret launcher." >&2
exit 64
fi
PG_BACKUP_DSN=$(cat "$PG_BACKUP_DSN_FILE")
case "$PG_BACKUP_DSN" in
*sslmode=verify-full*sslrootcert=*) ;;
*) echo "PG_BACKUP_DSN must enforce sslmode=verify-full and sslrootcert." >&2; exit 64 ;;
@@ -16,7 +28,9 @@ mkdir -p "$output_dir"
stamp=$(date -u +%Y%m%dT%H%M%SZ)
archive="$output_dir/han-chat-$stamp.dump"
pg_dump --dbname="$PG_BACKUP_DSN" --format=custom --no-owner --no-privileges --file="$archive"
PGDATABASE="$PG_BACKUP_DSN" pg_dump \
--format=custom --no-owner --no-privileges --file="$archive"
unset PG_BACKUP_DSN
pg_restore --list "$archive" >/dev/null
sha256sum "$archive" > "$archive.sha256"
chmod 600 "$archive" "$archive.sha256"
+24 -10
View File
@@ -1,20 +1,34 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/../.."
CONFIG_FILE=${CONFIG_FILE:-.env}
SECRETS_LAUNCHER=${SECRETS_LAUNCHER:-deployment/secrets/han-secrets}
if [ "${HAN_SECRETS_ACTIVE:-0}" != "1" ]; then
[ -x "$SECRETS_LAUNCHER" ] || {
echo "Secret launcher is required: $SECRETS_LAUNCHER" >&2
exit 66
}
exec "$SECRETS_LAUNCHER" run --config "$CONFIG_FILE" -- "$0" "$@"
fi
if [ "${PITR_MARKER_CONFIRMED:-false}" != "true" ]; then
echo "Refusing migration: create provider PITR marker, then set PITR_MARKER_CONFIRMED=true" >&2
exit 64
fi
./scripts/validate-env "${ENV_FILE:-.env}"
docker compose --env-file "${ENV_FILE:-.env}" config --quiet
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-api alembic current
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-bitrix-local alembic current
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-bitrix-sync alembic current
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-sms alembic current
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-api
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-bitrix-local
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-bitrix-sync
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm migrate-sms
if [ -n "${HAN_RUNTIME_SECRET_MANIFEST:-}" ]; then
./scripts/validate-env "$CONFIG_FILE" --runtime-manifest "$HAN_RUNTIME_SECRET_MANIFEST"
else
./scripts/validate-env "$CONFIG_FILE" --runtime-env
fi
docker compose --env-file "$CONFIG_FILE" config --quiet
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-api alembic current
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-bitrix-local alembic current
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-bitrix-sync alembic current
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-sms alembic current
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-api
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-bitrix-local
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-bitrix-sync
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm migrate-sms
echo "Migrations completed; record revisions in release evidence."
@@ -1,21 +1,34 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/../.."
CONFIG_FILE=${CONFIG_FILE:-.env}
SECRETS_LAUNCHER=${SECRETS_LAUNCHER:-deployment/secrets/han-secrets}
previous_env=${1:-}
if [ -z "$previous_env" ] || [ ! -r "$previous_env" ]; then
echo "Usage: $0 /secure/path/previous-release.env" >&2
previous_release=${1:-}
if [ -z "$previous_release" ]; then
echo "Usage: $0 <previous-immutable-release>" >&2
exit 64
fi
if [ "${HAN_SECRETS_ACTIVE:-0}" != "1" ]; then
[ -x "$SECRETS_LAUNCHER" ] || {
echo "Secret launcher is required: $SECRETS_LAUNCHER" >&2
exit 66
}
exec "$SECRETS_LAUNCHER" run --config "$CONFIG_FILE" -- "$0" "$@"
fi
if [ "${SCHEMA_BACKWARD_COMPATIBLE_CONFIRMED:-false}" != "true" ]; then
echo "Refusing rollback: set SCHEMA_BACKWARD_COMPATIBLE_CONFIRMED=true after migration review." >&2
exit 64
fi
./scripts/validate-env "$previous_env"
docker compose --env-file "$previous_env" config --quiet
docker compose --env-file "$previous_env" up -d --remove-orphans
docker compose --env-file "$previous_env" ps
if [ -n "${HAN_RUNTIME_SECRET_MANIFEST:-}" ]; then
./scripts/validate-env "$CONFIG_FILE" --runtime-manifest "$HAN_RUNTIME_SECRET_MANIFEST"
else
./scripts/validate-env "$CONFIG_FILE" --runtime-env
fi
RELEASE_VERSION="$previous_release" docker compose --env-file "$CONFIG_FILE" config --quiet
RELEASE_VERSION="$previous_release" docker compose --env-file "$CONFIG_FILE" up -d --remove-orphans
docker compose --env-file "$CONFIG_FILE" ps
echo "Application images rolled back without Alembic downgrade."
echo "Run deployment/scripts/smoke.sh with ENV_FILE=$previous_env and verify outbox/inbox idempotency."
echo "Run deployment/scripts/smoke.sh and verify outbox/inbox idempotency."
+13 -2
View File
@@ -1,7 +1,18 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/../.."
CONFIG_FILE=${ENV_FILE:-.env}
SECRETS_LAUNCHER=${SECRETS_LAUNCHER:-deployment/secrets/han-secrets}
./scripts/validate-env "${ENV_FILE:-.env}"
docker compose --env-file "${ENV_FILE:-.env}" --profile ops run --rm seed-settings
if [ "${HAN_SECRETS_ACTIVE:-0}" != "1" ]; then
[ -x "$SECRETS_LAUNCHER" ] || {
echo "Secret launcher is required: $SECRETS_LAUNCHER" >&2
exit 66
}
exec "$SECRETS_LAUNCHER" run --config "$CONFIG_FILE" -- "$0" "$@"
fi
./scripts/validate-env "$CONFIG_FILE" \
--runtime-manifest "$HAN_RUNTIME_SECRET_MANIFEST"
docker compose --env-file "$CONFIG_FILE" --profile ops run --rm seed-settings
echo "Seed and mandatory-settings validation completed."
@@ -435,6 +435,46 @@ configure_application_security() {
fi
}
install_secret_loader_if_possible() {
step "Загрузчик секретов"
local source_dir="${DEPLOY_DIR}/deployment/secrets"
if [[ ! -f "${source_dir}/secrets_loader.py" || ! -f "${source_dir}/han-secrets" ]]; then
log "Проект еще не скопирован: загрузчик секретов будет установлен при повторном запуске"
return
fi
chmod 0750 "${source_dir}/han-secrets" "${source_dir}/han-compose"
install -d -m 0700 -o root -g root \
/etc/han \
/etc/han/secrets \
/etc/han/credentials
install -d -m 0755 -o root -g root \
/usr/local/lib/han-secrets \
/usr/local/share/doc/han-secrets
install -m 0750 -o root -g root \
"${source_dir}/secrets_loader.py" \
/usr/local/lib/han-secrets/secrets_loader.py
install -m 0750 -o root -g root \
"${source_dir}/han-secrets" \
/usr/local/lib/han-secrets/han-secrets
install -m 0750 -o root -g root \
"${source_dir}/han-compose" \
/usr/local/bin/han-compose
install -m 0644 -o root -g root \
"${source_dir}/han-secrets@.service" \
/etc/systemd/system/han-secrets@.service
install -m 0644 -o root -g root \
"${source_dir}/SELECTEL_RUNBOOK.ru.md" \
/usr/local/share/doc/han-secrets/SELECTEL_RUNBOOK.ru.md
if [[ ! -e /etc/han/secrets/production.selectel.json.example ]]; then
install -m 0600 -o root -g root \
"${source_dir}/config.example.json" \
/etc/han/secrets/production.selectel.json.example
fi
systemctl daemon-reload
log "Загрузчик установлен, но не включен: сначала выполните SELECTEL_RUNBOOK.ru.md"
}
install_ssl_timer_if_possible() {
step "Таймер продления TLS"
local renew_script="${DEPLOY_DIR}/deployment/scripts/ssl-renew.sh"
@@ -504,14 +544,18 @@ HSTS max-age: ${HSTS_MAX_AGE_SECONDS}
${DEPLOY_DIR}
3. Поместите CA PostgreSQL:
${DEPLOY_DIR}/secrets/pg/ca.pem
4. Создайте и заполните окружение:
4. Создайте только несекретный config:
cd ${DEPLOY_DIR}
cp .env.example .env
chmod 600 .env
./scripts/validate-env .env
5. Продолжите с Gate 7 в:
5. Настройте Selectel, encrypted bootstrap credential и fallback map:
deployment/secrets/SELECTEL_RUNBOOK.ru.md
6. Выполняйте Compose только через:
sudo deployment/secrets/han-compose <command>
7. Продолжите с Gate 7 в:
deployment/RUNBOOK.ru.md
6. После копирования проекта повторно запустите этот скрипт для установки TLS-таймера.
8. После копирования проекта повторно запустите этот скрипт для установки unit-файлов.
Важно: членство в группе docker начнет действовать после нового входа в систему.
EOF
@@ -538,6 +582,7 @@ main() {
configure_docker_firewall
configure_ssh
configure_application_security
install_secret_loader_if_possible
install_ssl_timer_if_possible
verify
summary
+15 -3
View File
@@ -2,11 +2,23 @@
set -eu
cd "$(dirname "$0")/../.."
ENV_FILE=${ENV_FILE:-.env}
./scripts/validate-env "$ENV_FILE"
CONFIG_FILE=${CONFIG_FILE:-.env}
SECRETS_LAUNCHER=${SECRETS_LAUNCHER:-deployment/secrets/han-secrets}
if [ "${HAN_SECRETS_ACTIVE:-0}" != "1" ]; then
[ -x "$SECRETS_LAUNCHER" ] || {
echo "Secret launcher is required: $SECRETS_LAUNCHER" >&2
exit 66
}
exec "$SECRETS_LAUNCHER" run --config "$CONFIG_FILE" -- "$0" "$@"
fi
if [ -n "${HAN_RUNTIME_SECRET_MANIFEST:-}" ]; then
./scripts/validate-env "$CONFIG_FILE" --runtime-manifest "$HAN_RUNTIME_SECRET_MANIFEST"
else
./scripts/validate-env "$CONFIG_FILE" --runtime-env
fi
env_value() {
python3 - "$ENV_FILE" "$1" <<'PY'
python3 - "$CONFIG_FILE" "$1" <<'PY'
import sys
from pathlib import Path
@@ -1,13 +1,23 @@
#!/bin/sh
set -eu
cd "$(dirname "$0")/../.."
CONFIG_FILE=${CONFIG_FILE:-.env}
SECRETS_LAUNCHER=${SECRETS_LAUNCHER:-deployment/secrets/han-secrets}
if [ "${HAN_SECRETS_ACTIVE:-0}" != "1" ]; then
[ -x "$SECRETS_LAUNCHER" ] || {
echo "Secret launcher is required: $SECRETS_LAUNCHER" >&2
exit 66
}
exec "$SECRETS_LAUNCHER" run --config "$CONFIG_FILE" -- "$0" "$@"
fi
lock=/tmp/han-chat-cert-renew.lock
exec 9>"$lock"
flock -n 9 || { echo '{"event":"tls.renew.skipped","reason":"lock_busy"}'; exit 0; }
compose() {
docker compose --env-file .env "$@"
docker compose --env-file "$CONFIG_FILE" "$@"
}
nginx_container="$(compose ps --status running --quiet nginx)"
@@ -1,5 +1,18 @@
#!/usr/bin/env bash
set -Eeuo pipefail
cd "$(dirname "$0")/../.."
CONFIG_FILE="${CONFIG_FILE:-.env}"
SECRETS_LAUNCHER="${SECRETS_LAUNCHER:-deployment/secrets/han-secrets}"
if [[ "${HAN_SECRETS_ACTIVE:-0}" != 1 ]]; then
[[ -x "$SECRETS_LAUNCHER" ]] || {
echo "Secret launcher is required: $SECRETS_LAUNCHER" >&2
exit 66
}
exec "$SECRETS_LAUNCHER" run --config "$CONFIG_FILE" -- "$0" "$@"
fi
compose() { docker compose --env-file "$CONFIG_FILE" "$@"; }
NETWORK="${OBSERVABILITY_NETWORK:-han-chat-observability}"
COLLECTOR_SERVICE="${COLLECTOR_SERVICE:-otel-collector}"
@@ -8,7 +21,7 @@ errors=0
ok() { printf 'OK %s\n' "$*"; }
fail() { printf 'FAIL %s\n' "$*" >&2; errors=$((errors + 1)); }
collector_id="$(docker compose ps -q "$COLLECTOR_SERVICE" 2>/dev/null || true)"
collector_id="$(compose ps -q "$COLLECTOR_SERVICE" 2>/dev/null || true)"
if [[ -n "$collector_id" ]] &&
[[ "$(docker inspect --format '{{.State.Status}}' "$collector_id")" == running ]]; then
ok "Collector service is running"
@@ -17,7 +30,7 @@ else
fi
for service in sms-service sms-worker; do
if docker compose exec -T "$service" python - <<'PY' >/dev/null 2>&1
if compose exec -T "$service" python - <<'PY' >/dev/null 2>&1
import os
import urllib.request
port = os.environ.get("SMS_METRICS_PORT", "9464") if "worker" in os.environ.get("OTEL_SERVICE_NAME", "") else "8080"
@@ -38,7 +51,7 @@ docker run --rm --network "$NETWORK" \
|| fail "telemetrygen failed"
bad_logs="$(
docker compose logs --since=10m "$COLLECTOR_SERVICE" 2>&1 |
compose logs --since=10m "$COLLECTOR_SERVICE" 2>&1 |
grep -Ei 'queue is full|connection refused|tls:|Unauthenticated|Permanent error' || true
)"
if [[ -z "$bad_logs" ]]; then
@@ -0,0 +1,206 @@
# Selectel Secrets Manager для HAN Chat
## Модель
`han-secrets` читает только `SECRETS_SOURCE=selectel|file` из обычного `.env`,
выбирает соответствующую root-only JSON-карту и вызывает `secrets_loader.py`.
Загрузчик получает project-scoped IAM token, читает объявленные секреты и
создаёт в `/run/han-chat/secrets`:
- отдельные файлы с каноническими именами для Compose secrets;
- узкие service dotenv-файлы для диагностики состава без вывода значений;
- `manifest` вида `NAME=/absolute/path`, используемый валидатором.
Каталог `/run` находится в tmpfs и имеет режим `0700`. Канонические файлы имеют
`0444`: локальные пользователи не могут пройти через root-only каталог, а
не-root UID контейнера может прочитать только явно смонтированный Compose
secret. Значения не передаются через Docker Config.Env, argv, общий `.env` или
логи. После полного root/docker-компромисса runtime-значения извлекаемы — это
ограничение модели, а не гарантия Secret Manager.
Сбой Selectel никогда автоматически не включает file fallback. Уже работающие
контейнеры продолжают использовать текущие значения; новый sync завершается
fail-closed.
## 1. Ресурсы Selectel
1. Создайте отдельный проект `han-chat-secrets-prod`.
2. Создайте сервисного пользователя `han-chat-secrets-reader`.
3. Назначьте ему `member` только в этом проекте. Не выдавайте account scope,
`iam.admin` и доступ к другим production-ресурсам. Если Selectel добавит
отдельную read-only роль Secrets Manager, замените `member` на неё.
4. Ограничьте обращения к `api.selectel.ru` исходящим IP ВМ, если функция
доступна в аккаунте.
5. Включите экспорт audit logs. Контролируйте события `secrets.secret*` и
`secrets.secret_version*`; alert на delete, смену current version вне окна,
массовые чтения и обращения не от штатного пользователя/IP.
6. Выполните canary и убедитесь, что provider audit содержит metadata операции,
но не value, Base64 payload, IAM token или response body.
Secrets Manager принимает project-scoped IAM token в `X-Auth-Token`. Token
живёт до 24 часов, но загрузчик использует его только в памяти одного запуска.
TLS и redirect policy отключать нельзя.
## 2. Каталог секретов
Скопируйте `config.example.json` в
`/etc/han/secrets/production-like.selectel.json` и замените account, username,
project, region и `remote`. Каноническое имя слева обязано совпадать с
Compose/validator; `remote` — неизменяемый ключ в Selectel.
Используйте консервативные provider keys с дефисами, например:
- `han-chat-prod-pg-han-app-dsn`, `han-chat-prod-pg-bitrix-local-dsn`,
`han-chat-prod-pg-bitrix-sync-dsn`, `han-chat-prod-pg-sms-dsn`,
`han-chat-prod-pg-keycloak-password`, `han-chat-prod-pg-backup-dsn`;
- `han-chat-prod-redis-api-password`, `han-chat-prod-redis-safety-password`,
`han-chat-prod-redis-health-password`, а также три credential-bearing URL;
- `han-chat-prod-message-safety-token`, `han-chat-prod-bitrix-internal-token`,
`han-chat-prod-bitrix-forward-token`, `han-chat-prod-bitrix-sync-token`,
`han-chat-prod-keycloak-settings-token`, `han-chat-prod-sms-service-token`;
- Keycloak bootstrap password, OTP HMAC и mock code только для среды, где mock
действительно включён;
- Bitrix client secret, application token и token encryption key;
- пары S3 app access/secret key;
- i-Digital API key и callback username/password.
Одинаковые пары env (`BITRIX_LOCAL_APP_INTERNAL_TOKEN` /
`BITRIX_INTERNAL_API_TOKEN`, `BITRIX_API_FORWARD_TOKEN` /
`BITRIX_API_INBOX_TOKEN`, `SMS_SERVICE_TOKEN` /
`KEYCLOAK_SMS_SERVICE_TOKEN`) должны ссылаться на один `remote`.
`literal: ""` разрешён только для заведомо пустого optional-параметра, например
OTLP auth при self-hosted SigNoz или выключенного CAPTCHA server key. Секреты
не записывайте в JSON. Не заводите planned/unused Message Safety DB, quarantine
S3 и Bitrix sync credentials до появления потребляющего кода.
Загружайте значения в Selectel через скрытый prompt/stdin. Не передавайте value
позиционным аргументом CLI и не включайте `set -x`/`curl -v`.
## 3. Установка на ВМ
Повторный запуск `deployment/scripts/setup-vm.sh` после копирования проекта
устанавливает loader, launcher, systemd template и этот runbook. Вручную:
```sh
sudo install -d -m 0700 /etc/han/secrets /etc/han/credentials
sudo install -d -m 0755 /usr/local/lib/han-secrets
sudo install -m 0750 secrets_loader.py han-secrets /usr/local/lib/han-secrets/
sudo install -m 0750 han-compose /usr/local/bin/han-compose
sudo install -m 0644 han-secrets@.service /etc/systemd/system/
sudo install -m 0600 config.example.json \
/etc/han/secrets/production-like.selectel.json
```
Обычный `/opt/han-chat/backend/.env` содержит только несекретные параметры.
Штатный режим:
```dotenv
SECRETS_SOURCE=selectel
APP_ENV=production-like
```
## 4. Bootstrap credential
Не храните пароль service user в `.env` или JSON. На целевой ВМ:
```sh
sudo systemd-creds encrypt --name=selectel-service-user-password - \
/etc/han/credentials/production.selectel-password.cred
sudo chmod 0600 /etc/han/credentials/production.selectel-password.cred
```
Введите пароль через интерактивный stdin. Unit передаёт расшифрованный файл
через приватный `$CREDENTIALS_DIRECTORY`. Root всё равно может его извлечь;
после инцидента credential и все доступные ему секреты необходимо ротировать.
## 5. Проверка и запуск
Все команды, которым нужны Compose secrets, запускайте от root через wrapper:
```sh
cd /opt/han-chat/backend
sudo ./scripts/validate-env .env
sudo systemctl daemon-reload
sudo systemctl enable han-secrets@production.service
sudo systemctl restart han-secrets@production.service
sudo ./scripts/validate-env .env \
--runtime-manifest /run/han-chat/secrets/manifest
sudo deployment/secrets/han-compose config --quiet
sudo deployment/secrets/han-compose up -d --wait
```
Selectel sync запускается именно unit-файлом: только он предоставляет
расшифрованный bootstrap credential через `$CREDENTIALS_DIRECTORY`.
`han-compose` и ops-скрипты используют уже синхронизированный manifest и
отказываются работать, если `SECRETS_SOURCE`/loader config не совпадают с
runtime state. После смены source, provider version или JSON-карты сначала
выполняйте `systemctl restart han-secrets@production.service`.
Не используйте `docker compose config` без `--quiet`, `docker inspect` для
поиска конфигурации, `env`, `strace`, core dump или debug HTTP proxy. Проверка
приёмки должна подтвердить отсутствие canary value в `docker inspect`, stdout,
json logs, traces и shell history.
На выделенной только под HAN Chat ВМ после canary и проверки file fallback
установите fail-closed ordering:
```sh
sudo install -d -m 0755 /etc/systemd/system/docker.service.d
sudo install -m 0644 \
deployment/secrets/docker-han-secrets.conf.example \
/etc/systemd/system/docker.service.d/han-secrets.conf
sudo systemctl daemon-reload
sudo systemctl restart docker
```
После этого проведите reboot rehearsal: materializer должен завершиться до
autorestart контейнеров. Ошибка Selectel намеренно блокирует Docker. На ВМ с
другими workloads такой глобальный `Requires=` запрещён: нужен отдельный Docker
daemon/VM, иначе fail-closed HAN остановит несвязанные системы.
## 6. Явный file fallback
Подготовьте отдельную карту
`/etc/han/secrets/production-like.file.json`: скопируйте Selectel-карту,
установите `"mode": "file"`, удалите `selectel` и `http`, добавьте:
```json
"file": {
"path": "/etc/han/break-glass/secrets.env",
"max_bytes": 1048576
}
```
`secrets` map остаётся тем же. Для записей с `literal: ""` строка в fallback
не нужна; остальные канонические ключи обязательны. Fallback parser не исполняет
shell: запрещены `export`, substitutions, multiline, неизвестные и дублирующиеся
ключи. Файл — `root:root 0600`.
При инциденте доставьте recovery-файл из защищённой офлайн-копии и только затем
явно измените `.env`:
```dotenv
SECRETS_SOURCE=file
```
Перезапустите `han-secrets@production.service`, затем выполните
validate/recreate через wrappers. После восстановления Selectel верните
`SECRETS_SOURCE=selectel`, снова перезапустите unit, повторите проверки и
удалите recovery-файл.
Не храните его постоянно на ВМ: это вернуло бы исходный риск монолитного `.env`.
## 7. Ротация и rollback
1. Добавьте новую версию секрета, не меняя имя.
2. Для canary при необходимости временно pin числовой `version` в JSON-карте.
3. Выполните sync, validation и smoke без вывода конфигурации.
4. Сделайте версию current, удалите pin, снова sync и пересоздайте только
потребителей.
5. Для rollback активируйте предыдущую provider version; не храните snapshot
старого `.env`.
Selectel не позволяет удалить отдельную версию — только секрет целиком. Старые
значения должны быть отозваны в PostgreSQL/S3/Bitrix/i-Digital после окна
rollback. Все значения из прежнего `.env` считайте раскрытыми и ротируйте после
перехода.
@@ -0,0 +1,190 @@
{
"version": 1,
"mode": "selectel",
"runtime_dir": "/run/han-chat/secrets",
"http": {
"timeout_seconds": 10,
"retries": 3,
"max_response_bytes": 1048576
},
"selectel": {
"account_id": "123456",
"username": "han-secrets-reader",
"project_name": "han-production",
"region": "ru-9",
"interface": "public",
"password_file": "selectel-service-user-password"
},
"secrets": {
"DATABASE_URL": {
"remote": "DATABASE_URL",
"consumers": ["api-backend", "api-migrate"],
"max_bytes": 4096
},
"BITRIX_DATABASE_URL": {
"remote": "BITRIX_DATABASE_URL",
"consumers": ["bitrix-local-app", "bitrix-local-migrate"],
"max_bytes": 4096
},
"BITRIX_SYNC_DATABASE_URL": {
"remote": "BITRIX_SYNC_DATABASE_URL",
"consumers": ["bitrix-sync", "bitrix-sync-migrate"],
"max_bytes": 4096
},
"SMS_DATABASE_URL": {
"remote": "SMS_DATABASE_URL",
"consumers": ["sms-service", "sms-worker", "sms-migrate"],
"max_bytes": 4096
},
"KEYCLOAK_DB_PASSWORD": {
"remote": "KEYCLOAK_DB_PASSWORD",
"consumers": ["keycloak"],
"max_bytes": 1024
},
"KEYCLOAK_ADMIN_PASSWORD": {
"remote": "KEYCLOAK_ADMIN_PASSWORD",
"consumers": ["keycloak"],
"max_bytes": 1024
},
"CURSOR_HMAC_SECRET": {
"remote": "CURSOR_HMAC_SECRET",
"consumers": ["api-backend"],
"max_bytes": 1024
},
"KEYCLOAK_OTP_HMAC_KEY": {
"remote": "KEYCLOAK_OTP_HMAC_KEY",
"consumers": ["keycloak"],
"max_bytes": 1024
},
"KEYCLOAK_OTP_MOCK_CODE": {
"remote": "KEYCLOAK_OTP_MOCK_CODE",
"consumers": ["keycloak"],
"max_bytes": 1024
},
"KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY": {
"remote": "KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY",
"consumers": ["keycloak"],
"max_bytes": 1024
},
"BITRIX_TOKEN_ENCRYPTION_KEY": {
"remote": "BITRIX_TOKEN_ENCRYPTION_KEY",
"consumers": ["api-backend", "bitrix-local-app", "bitrix-sync"],
"max_bytes": 1024
},
"REDIS_API_PASSWORD": {
"remote": "REDIS_API_PASSWORD",
"consumers": ["redis"],
"max_bytes": 1024
},
"REDIS_URL": {
"remote": "REDIS_URL",
"consumers": ["api-backend", "delivery-worker", "cleanup-worker"],
"max_bytes": 4096
},
"REDIS_REALTIME_URL": {
"remote": "REDIS_REALTIME_URL",
"consumers": ["api-backend"],
"max_bytes": 4096
},
"REDIS_SAFETY_PASSWORD": {
"remote": "REDIS_SAFETY_PASSWORD",
"consumers": ["redis"],
"max_bytes": 1024
},
"MESSAGE_SAFETY_REDIS_URL": {
"remote": "MESSAGE_SAFETY_REDIS_URL",
"consumers": ["message-safety", "safety-recovery-worker"],
"max_bytes": 4096
},
"REDIS_HEALTH_PASSWORD": {
"remote": "REDIS_HEALTH_PASSWORD",
"consumers": ["redis", "redis-exporter"],
"max_bytes": 1024
},
"MESSAGE_SAFETY_SERVICE_TOKEN": {
"remote": "MESSAGE_SAFETY_SERVICE_TOKEN",
"consumers": ["api-backend", "message-safety"],
"max_bytes": 1024
},
"BITRIX_LOCAL_APP_INTERNAL_TOKEN": {
"remote": "BITRIX_LOCAL_APP_INTERNAL_TOKEN",
"consumers": ["api-backend", "bitrix-local-app"],
"max_bytes": 1024
},
"BITRIX_INTERNAL_API_TOKEN": {
"remote": "BITRIX_LOCAL_APP_INTERNAL_TOKEN",
"consumers": ["api-backend", "bitrix-local-app"],
"max_bytes": 1024
},
"BITRIX_API_FORWARD_TOKEN": {
"remote": "BITRIX_API_FORWARD_TOKEN",
"consumers": ["api-backend", "bitrix-local-app"],
"max_bytes": 1024
},
"BITRIX_API_INBOX_TOKEN": {
"remote": "BITRIX_API_FORWARD_TOKEN",
"consumers": ["api-backend", "bitrix-local-app"],
"max_bytes": 1024
},
"BITRIX_SYNC_SERVICE_TOKEN": {
"remote": "BITRIX_SYNC_SERVICE_TOKEN",
"consumers": ["api-backend", "bitrix-sync"],
"max_bytes": 1024
},
"KEYCLOAK_SETTINGS_BRIDGE_TOKEN": {
"remote": "KEYCLOAK_SETTINGS_BRIDGE_TOKEN",
"consumers": ["api-backend", "keycloak"],
"max_bytes": 1024
},
"SMS_SERVICE_TOKEN": {
"remote": "SMS_SERVICE_TOKEN",
"consumers": ["sms-service", "sms-worker", "keycloak"],
"max_bytes": 1024
},
"KEYCLOAK_SMS_SERVICE_TOKEN": {
"remote": "SMS_SERVICE_TOKEN",
"consumers": ["sms-service", "sms-worker", "keycloak"],
"max_bytes": 1024
},
"IDGTL_SMS_API_KEY": {
"remote": "IDGTL_SMS_API_KEY",
"consumers": ["sms-service", "sms-worker"],
"max_bytes": 1024
},
"IDGTL_SMS_CALLBACK_USERNAME": {
"remote": "IDGTL_SMS_CALLBACK_USERNAME",
"consumers": ["sms-service", "sms-worker"],
"max_bytes": 1024
},
"IDGTL_SMS_CALLBACK_PASSWORD": {
"remote": "IDGTL_SMS_CALLBACK_PASSWORD",
"consumers": ["sms-service", "sms-worker"],
"max_bytes": 1024
},
"BITRIX_CLIENT_SECRET": {
"remote": "BITRIX_CLIENT_SECRET",
"consumers": ["bitrix-local-app"],
"max_bytes": 1024
},
"BITRIX_APPLICATION_TOKEN": {
"remote": "BITRIX_APPLICATION_TOKEN",
"consumers": ["bitrix-local-app"],
"max_bytes": 1024
},
"SELECTEL_S3_SECRET_KEY": {
"remote": "SELECTEL_S3_SECRET_KEY",
"consumers": ["api-backend"],
"max_bytes": 1024
},
"SELECTEL_S3_ACCESS_KEY": {
"remote": "SELECTEL_S3_ACCESS_KEY",
"consumers": ["api-backend"],
"max_bytes": 1024
},
"OTEL_REMOTE_AUTH_HEADER": {
"literal": "",
"consumers": ["otel-collector"],
"max_bytes": 4096
}
}
}
@@ -0,0 +1,6 @@
[Unit]
# Enable only on a dedicated HAN Chat Docker host after the Selectel canary and
# file fallback have both passed. A failed secret sync intentionally blocks
# Docker startup so containers cannot race an empty /run directory.
Requires=han-secrets@production.service
After=han-secrets@production.service
@@ -0,0 +1,15 @@
#!/bin/sh
set -eu
DEPLOY_DIR=${HAN_DEPLOY_DIR:-/opt/han-chat/backend}
SOURCE_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
[ ! -f "$SOURCE_DIR/../../docker-compose.yml" ] || \
DEPLOY_DIR=$(CDPATH= cd -- "$SOURCE_DIR/../.." && pwd)
cd "$DEPLOY_DIR"
CONFIG_FILE=${CONFIG_FILE:-.env}
LAUNCHER=${HAN_SECRETS_LAUNCHER:-/usr/local/lib/han-secrets/han-secrets}
[ -x "$LAUNCHER" ] || LAUNCHER=deployment/secrets/han-secrets
exec "$LAUNCHER" run --config "$CONFIG_FILE" -- \
docker compose --env-file "$CONFIG_FILE" "$@"
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""Synchronize runtime secrets and execute a command without exporting their values."""
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 load_public_config(path: Path) -> dict[str, str]:
values: dict[str, str] = {}
try:
lines = path.read_text(encoding="utf-8").splitlines()
except OSError as exc:
raise LoaderError(
f"cannot read non-secret config: {exc.strerror or exc.__class__.__name__}"
) from None
for number, raw in enumerate(lines, 1):
line = raw.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
raise LoaderError(f"non-secret config has invalid syntax at line {number}")
key, value = line.split("=", 1)
key = key.strip()
if not key or key in values:
raise LoaderError(f"non-secret config has an invalid key at line {number}")
values[key] = value.strip()
return values
def loader_config_path(
public: dict[str, str],
explicit: Path | None,
environ: dict[str, str],
) -> 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 is not None:
return source, explicit
override = environ.get(f"HAN_SECRETS_{source.upper()}_CONFIG")
if override:
return source, Path(override)
environment = public.get("APP_ENV", "production")
return source, Path(f"/etc/han/secrets/{environment}.{source}.json")
def prepare_environment(
config_path: Path,
source: str,
environ: dict[str, str],
*,
synchronize: bool,
) -> dict[str, str]:
document = load_json(config_path)
if document.get("mode") != source:
raise LoaderError("selected loader configuration mode does not match SECRETS_SOURCE")
runtime = document.get("runtime_dir")
if not isinstance(runtime, str) or not Path(runtime).is_absolute():
raise LoaderError("loader configuration has an invalid runtime_dir")
runtime_dir = Path(runtime)
manifest = runtime_dir / "manifest"
state_path = runtime_dir / "state.json"
consumers: list[str] = []
if synchronize or (source == "file" and not state_path.is_file()):
consumers = run(config_path, environ=environ)
state = {
"version": 1,
"source": source,
"loader_config": str(config_path.resolve()),
}
descriptor, temporary = tempfile.mkstemp(
prefix=".state.", suffix=".tmp", dir=runtime_dir
)
temporary_path = Path(temporary)
try:
os.chmod(temporary_path, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
descriptor = -1
json.dump(state, stream, separators=(",", ":"))
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary_path, state_path)
except BaseException:
if descriptor >= 0:
os.close(descriptor)
temporary_path.unlink(missing_ok=True)
raise
elif not state_path.is_file():
raise LoaderError(
"runtime secrets are not synchronized; restart han-secrets systemd unit"
)
else:
state = load_json(state_path)
if (
state.get("version") != 1
or state.get("source") != source
or state.get("loader_config") != str(config_path.resolve())
):
raise LoaderError(
"runtime secret state does not match selected source/config; synchronize first"
)
if not manifest.is_file():
raise LoaderError("runtime secret manifest was not materialized")
manifest_entries: dict[str, str] = {}
for line in manifest.read_text(encoding="utf-8").splitlines():
key, value_path = line.split("=", 1)
manifest_entries[key] = value_path
specs = document.get("secrets")
if not isinstance(specs, dict) or set(manifest_entries) != set(specs):
raise LoaderError("runtime secret manifest does not match loader configuration")
child = dict(environ)
child["HAN_SECRETS_ACTIVE"] = "1"
child["HAN_RUNTIME_SECRET_DIR"] = str(runtime_dir)
child["HAN_RUNTIME_SECRET_MANIFEST"] = str(manifest)
for key, value_path in manifest_entries.items():
if not Path(value_path).is_file():
raise LoaderError("runtime secret manifest references a missing file")
child[f"{key}_FILE"] = value_path
if synchronize or consumers:
print(
f"han-secrets: synchronized {len(consumers)} service scope(s) from {source}",
file=sys.stderr,
)
return child
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
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(argv)
if command and command[0] == "--":
command.pop(0)
if arguments.action == "run" and not command:
parser.error("run requires a command after --")
if arguments.action == "sync" and command:
parser.error("sync does not accept a command")
environment = dict(os.environ)
try:
public = load_public_config(arguments.config)
source, loader_config = loader_config_path(
public, arguments.loader_config, environment
)
child = prepare_environment(
loader_config,
source,
environment,
synchronize=arguments.action == "sync",
)
except (LoaderError, OSError, ValueError, json.JSONDecodeError) as exc:
message = str(exc) if isinstance(exc, LoaderError) else exc.__class__.__name__
print(f"han-secrets: {message}", 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,42 @@
[Unit]
Description=Materialize HAN service secrets (%i)
Documentation=file:/usr/local/share/doc/han-secrets/SELECTEL_RUNBOOK.ru.md
Wants=network-online.target
After=network-online.target
Before=han-stack@%i.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/han-secrets sync --config /opt/han-chat/backend/.env
LoadCredentialEncrypted=selectel-service-user-password:/etc/han/credentials/%i.selectel-password.cred
RemainAfterExit=yes
StandardOutput=null
StandardError=journal
SyslogIdentifier=han-secrets-%i
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,682 @@
#!/usr/bin/env python3
"""Materialize narrowly scoped service dotenv files from Selectel Secrets Manager."""
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 dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Mapping, NoReturn
DEFAULT_IDENTITY_URL = "https://cloud.api.selcloud.ru/identity/v3/auth/tokens"
MAX_CONFIG_BYTES = 1_048_576
MAX_HTTP_BYTES = 1_048_576
MAX_SECRET_BYTES = 65_536
RETRYABLE_STATUS = frozenset({408, 425, 429, 500, 502, 503, 504})
ENV_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")
SERVICE_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]*$")
DOTENV_LINE_RE = re.compile(r"^([A-Z][A-Z0-9_]*)=(.*)$")
class LoaderError(Exception):
"""An expected, already-redacted loader failure."""
def fail(message: str) -> NoReturn:
raise LoaderError(message)
def _object(value: Any, label: str) -> dict[str, Any]:
if not isinstance(value, dict):
fail(f"{label} must be an object")
return value
def _only_keys(value: Mapping[str, Any], allowed: set[str], label: str) -> None:
unknown = sorted(set(value) - allowed)
if unknown:
fail(f"{label} contains unsupported fields: {', '.join(unknown)}")
def _required_string(value: Mapping[str, Any], key: str, label: str) -> str:
item = value.get(key)
if not isinstance(item, str) or not item:
fail(f"{label}.{key} must be a non-empty string")
return item
def _bounded_int(value: Any, label: str, minimum: int, maximum: int) -> int:
if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum:
fail(f"{label} must be an integer from {minimum} through {maximum}")
return value
def read_limited(path: Path, limit: int, label: str) -> bytes:
try:
with path.open("rb") as stream:
data = stream.read(limit + 1)
except OSError as exc:
fail(f"cannot read {label}: {exc.strerror or exc.__class__.__name__}")
if len(data) > limit:
fail(f"{label} exceeds {limit} bytes")
return data
def require_private_regular_file(path: Path, label: str) -> None:
try:
metadata = path.lstat()
except OSError as exc:
fail(f"cannot inspect {label}: {exc.strerror or 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 file and not a symlink")
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 load_json(path: Path) -> dict[str, Any]:
raw = read_limited(path, MAX_CONFIG_BYTES, "configuration")
try:
document = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
fail("configuration is not valid UTF-8 JSON")
return _object(document, "configuration")
def credential_value(selectel: Mapping[str, Any], environ: Mapping[str, str]) -> str:
methods = sum(key in selectel for key in ("password_file", "password_env"))
if methods != 1:
fail("selectel must set exactly one of password_file or password_env")
if "password_env" in selectel:
variable = _required_string(selectel, "password_env", "selectel")
if not ENV_NAME_RE.fullmatch(variable):
fail("selectel.password_env is not a valid environment variable name")
value = environ.get(variable)
if value is None or not value:
fail(f"credential environment variable {variable} is not set")
return value
configured = Path(_required_string(selectel, "password_file", "selectel"))
if configured.is_absolute():
path = configured
else:
directory = environ.get("CREDENTIALS_DIRECTORY")
if not directory:
fail("relative password_file requires CREDENTIALS_DIRECTORY")
path = Path(directory) / configured
require_private_regular_file(path, "credential")
raw = read_limited(path, 16_384, "credential")
try:
value = raw.decode("utf-8")
except UnicodeDecodeError:
fail("credential is not valid UTF-8")
value = value.removesuffix("\n").removesuffix("\r")
if not value or "\n" in value or "\r" in value or "\x00" in value:
fail("credential must contain exactly one non-empty text line")
return value
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str) -> None:
return None
@dataclass(frozen=True)
class HTTPResult:
status: int
headers: Mapping[str, str]
body: bytes
class HTTPClient:
def __init__(
self,
*,
timeout: float,
retries: int,
max_response_bytes: int,
cafile: str | None = None,
opener: Any | None = None,
sleeper: Callable[[float], None] = time.sleep,
jitter: Callable[[], float] = random.random,
) -> None:
self.timeout = timeout
self.retries = retries
self.max_response_bytes = max_response_bytes
self.sleeper = sleeper
self.jitter = jitter
if opener is None:
try:
context = ssl.create_default_context(cafile=cafile)
except (OSError, ssl.SSLError) as exc:
fail(f"cannot initialize TLS trust store: {exc.__class__.__name__}")
self.opener = urllib.request.build_opener(
urllib.request.HTTPSHandler(context=context), NoRedirect()
)
else:
self.opener = opener
def request(
self,
method: str,
url: str,
*,
headers: Mapping[str, str] | None = None,
body: bytes | None = None,
expected: frozenset[int],
) -> HTTPResult:
parsed = urllib.parse.urlsplit(url)
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password:
fail("provider endpoint must be an HTTPS URL without embedded credentials")
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:
status = int(response.status)
content_length = response.headers.get("Content-Length")
if content_length:
try:
if int(content_length) > self.max_response_bytes:
fail("provider response exceeds configured limit")
except ValueError:
fail("provider returned an invalid Content-Length")
response_body = response.read(self.max_response_bytes + 1)
if len(response_body) > self.max_response_bytes:
fail("provider response exceeds configured limit")
if status not in expected:
fail(f"provider request failed with HTTP {status}")
return HTTPResult(status, response.headers, response_body)
except urllib.error.HTTPError as exc:
status = int(exc.code)
if status not in RETRYABLE_STATUS or attempt >= self.retries:
fail(f"provider request failed with HTTP {status}")
except (urllib.error.URLError, TimeoutError, OSError):
if attempt >= self.retries:
fail("provider request failed after retries")
delay = min(8.0, 0.25 * (2**attempt)) * (0.5 + self.jitter())
self.sleeper(delay)
fail("provider request failed")
def parse_json_response(result: HTTPResult, label: str) -> dict[str, Any]:
try:
return _object(json.loads(result.body.decode("utf-8")), label)
except (UnicodeDecodeError, json.JSONDecodeError):
fail(f"{label} is not valid JSON")
def project_token_and_catalog(
client: HTTPClient, selectel: Mapping[str, Any], password: str
) -> tuple[str, list[Any]]:
identity_url = selectel.get("identity_url", DEFAULT_IDENTITY_URL)
if not isinstance(identity_url, str):
fail("selectel.identity_url must be a string")
account_id = _required_string(selectel, "account_id", "selectel")
username = _required_string(selectel, "username", "selectel")
project_name = _required_string(selectel, "project_name", "selectel")
payload = {
"auth": {
"identity": {
"methods": ["password"],
"password": {
"user": {
"name": username,
"domain": {"name": account_id},
"password": password,
}
},
},
"scope": {
"project": {
"name": project_name,
"domain": {"name": account_id},
}
},
}
}
result = client.request(
"POST",
identity_url,
headers={"Content-Type": "application/json", "Accept": "application/json"},
body=json.dumps(payload, separators=(",", ":")).encode("utf-8"),
expected=frozenset({201}),
)
token = result.headers.get("X-Subject-Token")
if not isinstance(token, str) or not token:
fail("identity response omitted X-Subject-Token")
document = parse_json_response(result, "identity response")
token_data = document.get("token")
if not isinstance(token_data, dict):
fail("identity response omitted token metadata")
project = token_data.get("project")
if not isinstance(project, dict) or not project.get("id"):
fail("identity token is not project-scoped")
catalog = token_data.get("catalog")
if not isinstance(catalog, list):
fail("identity response omitted service catalog")
return token, catalog
def secrets_endpoint(catalog: list[Any], region: str, interface: str) -> str:
matches: list[str] = []
for service in catalog:
if not isinstance(service, dict) or service.get("type") != "secrets-manager":
continue
endpoints = service.get("endpoints")
if not isinstance(endpoints, list):
continue
for endpoint in endpoints:
if (
isinstance(endpoint, dict)
and endpoint.get("region") == region
and endpoint.get("interface") == interface
and isinstance(endpoint.get("url"), str)
):
matches.append(endpoint["url"].rstrip("/"))
if len(matches) != 1:
fail("service catalog did not contain exactly one matching Secrets Manager endpoint")
return matches[0]
def decode_secret(document: Mapping[str, Any], name: str, limit: int) -> bytes:
# GET /v1/{name} returns the current value inside ``version`` while
# GET /v1/{name}/versions/{id} returns a version object directly.
payload: Mapping[str, Any] = document
version = document.get("version")
if isinstance(version, dict):
payload = version
encoded = payload.get("value")
if not isinstance(encoded, str):
fail(f"secret {name} response omitted base64 value")
try:
value = base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError):
fail(f"secret {name} has invalid base64 encoding")
if not value:
fail(f"secret {name} is empty")
if len(value) > limit:
fail(f"secret {name} exceeds its configured limit")
if b"\x00" in value or b"\n" in value or b"\r" in value:
fail(f"secret {name} cannot be represented as a dotenv value")
return value
def fetch_selectel(
config: Mapping[str, Any],
specs: Mapping[str, Mapping[str, Any]],
environ: Mapping[str, str],
client_factory: Callable[..., HTTPClient] = HTTPClient,
) -> dict[str, bytes]:
selectel = _object(config.get("selectel"), "selectel")
_only_keys(
selectel,
{
"account_id",
"username",
"project_name",
"region",
"interface",
"password_file",
"password_env",
"identity_url",
"secrets_url",
"ca_file",
},
"selectel",
)
http = _object(config.get("http", {}), "http")
_only_keys(http, {"timeout_seconds", "retries", "max_response_bytes"}, "http")
timeout = http.get("timeout_seconds", 10)
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not 0.1 <= timeout <= 60:
fail("http.timeout_seconds must be from 0.1 through 60")
retries = _bounded_int(http.get("retries", 3), "http.retries", 0, 8)
response_limit = _bounded_int(
http.get("max_response_bytes", MAX_HTTP_BYTES),
"http.max_response_bytes",
1024,
4 * MAX_HTTP_BYTES,
)
cafile = selectel.get("ca_file")
if cafile is not None and (not isinstance(cafile, str) or not cafile):
fail("selectel.ca_file must be a non-empty string")
client = client_factory(
timeout=float(timeout),
retries=retries,
max_response_bytes=response_limit,
cafile=cafile,
)
password = credential_value(selectel, environ)
token, catalog = project_token_and_catalog(client, selectel, password)
region = _required_string(selectel, "region", "selectel")
interface = selectel.get("interface", "public")
if interface not in {"public", "internal"}:
fail("selectel.interface must be public or internal")
override = selectel.get("secrets_url")
if override is not None and (not isinstance(override, str) or not override):
fail("selectel.secrets_url must be a non-empty string")
base_url = override.rstrip("/") if override else secrets_endpoint(catalog, region, interface)
values: dict[str, bytes] = {}
fetched: dict[tuple[str, int | None], bytes] = {}
for canonical, spec in specs.items():
if "literal" in spec:
values[canonical] = b""
continue
remote = _required_string(spec, "remote", f"secrets.{canonical}")
version = spec.get("version")
version_id: int | None = None
if version is not None:
version_id = _bounded_int(
version, f"secrets.{canonical}.version", 1, 2_147_483_647
)
cache_key = (remote, version_id)
if cache_key not in fetched:
path = f"/v1/{urllib.parse.quote(remote, safe='')}"
if version_id is not None:
path += f"/versions/{version_id}"
try:
result = client.request(
"GET",
base_url + path,
headers={"X-Auth-Token": token, "Accept": "application/json"},
expected=frozenset({200}),
)
document = parse_json_response(result, f"secret {canonical} response")
fetched[cache_key] = decode_secret(
document, canonical, MAX_SECRET_BYTES
)
except LoaderError as exc:
fail(f"cannot load {canonical}: {exc}")
limit = _bounded_int(
spec.get("max_bytes", MAX_SECRET_BYTES),
f"secrets.{canonical}.max_bytes",
1,
MAX_SECRET_BYTES,
)
value = fetched[cache_key]
if len(value) > limit:
fail(f"secret {canonical} exceeds its configured limit")
values[canonical] = value
return values
def parse_dotenv(path: Path, expected: set[str], max_bytes: int) -> dict[str, bytes]:
require_private_regular_file(path, "fallback dotenv")
raw = read_limited(path, max_bytes, "fallback dotenv")
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
fail("fallback dotenv is not valid UTF-8")
values: dict[str, bytes] = {}
for number, line in enumerate(text.splitlines(), 1):
if not line or line.startswith("#"):
continue
match = DOTENV_LINE_RE.fullmatch(line)
if not match:
fail(f"fallback dotenv has invalid syntax at line {number}")
name, encoded_value = match.groups()
if name not in expected:
fail(f"fallback dotenv contains undeclared key {name}")
if name in values:
fail(f"fallback dotenv contains duplicate key {name}")
if encoded_value.startswith('"'):
try:
decoded = json.loads(encoded_value)
except json.JSONDecodeError:
fail(f"fallback dotenv has invalid quoted value at line {number}")
if not isinstance(decoded, str):
fail(f"fallback dotenv has invalid quoted value at line {number}")
value = decoded.encode("utf-8")
elif encoded_value.startswith("'"):
if len(encoded_value) < 2 or not encoded_value.endswith("'"):
fail(f"fallback dotenv has invalid quoted value at line {number}")
value = encoded_value[1:-1].encode("utf-8")
else:
if any(character.isspace() for character in encoded_value) or any(
character in encoded_value for character in ("'", '"', "`", "$", "\\")
):
fail(f"fallback dotenv requires quoting at line {number}")
value = encoded_value.encode("utf-8")
if not value or b"\x00" in value or b"\n" in value or b"\r" in value:
fail(f"fallback dotenv has an empty or unsafe value for {name}")
values[name] = value
missing = sorted(expected - set(values))
if missing:
fail(f"fallback dotenv is missing declared keys: {', '.join(missing)}")
return values
def validate_specs(config: Mapping[str, Any]) -> dict[str, dict[str, Any]]:
raw_specs = _object(config.get("secrets"), "secrets")
if not raw_specs:
fail("secrets must not be empty")
specs: dict[str, dict[str, Any]] = {}
for canonical, raw_spec in raw_specs.items():
if not isinstance(canonical, str) or not ENV_NAME_RE.fullmatch(canonical):
fail("every canonical secret name must be an uppercase environment name")
spec = _object(raw_spec, f"secrets.{canonical}")
_only_keys(
spec,
{"remote", "consumers", "max_bytes", "version", "literal"},
f"secrets.{canonical}",
)
has_remote = "remote" in spec
has_literal = "literal" in spec
if has_remote == has_literal:
fail(f"secrets.{canonical} must set exactly one of remote or literal")
if has_literal and spec["literal"] != "":
fail(f"secrets.{canonical}.literal may only be an empty string")
consumers = spec.get("consumers")
if not isinstance(consumers, list) or not consumers:
fail(f"secrets.{canonical}.consumers must be a non-empty array")
if len(consumers) != len(set(item for item in consumers if isinstance(item, str))):
fail(f"secrets.{canonical}.consumers contains duplicates or invalid values")
for consumer in consumers:
if not isinstance(consumer, str) or not SERVICE_NAME_RE.fullmatch(consumer):
fail(f"secrets.{canonical}.consumers contains an invalid service name")
specs[canonical] = spec
return specs
def dotenv_quote(value: bytes, name: str) -> str:
try:
text = value.decode("utf-8")
except UnicodeDecodeError:
fail(f"secret {name} is not valid UTF-8")
return json.dumps(text, ensure_ascii=False)
def materialize(runtime_dir: Path, specs: Mapping[str, Mapping[str, Any]], values: Mapping[str, bytes]) -> None:
try:
runtime_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
if runtime_dir.is_symlink():
fail("runtime directory must not be a symlink")
os.chmod(runtime_dir, 0o700)
except OSError as exc:
fail(f"cannot prepare runtime directory: {exc.strerror or exc.__class__.__name__}")
consumers = sorted(
{consumer for spec in specs.values() for consumer in spec["consumers"]}
)
staged: list[tuple[Path, Path]] = []
try:
for consumer in consumers:
lines = [
f"{name}={dotenv_quote(values[name], name)}\n"
for name, spec in sorted(specs.items())
if consumer in spec["consumers"]
]
descriptor, temporary = tempfile.mkstemp(
prefix=f".{consumer}.", suffix=".tmp", dir=runtime_dir
)
temporary_path = Path(temporary)
try:
os.chmod(temporary_path, 0o600)
stream = os.fdopen(descriptor, "w", encoding="utf-8", newline="\n")
descriptor = -1
with stream:
stream.writelines(lines)
stream.flush()
os.fsync(stream.fileno())
except BaseException:
if descriptor >= 0:
os.close(descriptor)
raise
staged.append((temporary_path, runtime_dir / f"{consumer}.env"))
for temporary_path, destination in staged:
os.replace(temporary_path, destination)
value_paths: dict[str, Path] = {}
for name, value in sorted(values.items()):
descriptor, temporary = tempfile.mkstemp(
prefix=f".{name}.", suffix=".tmp", dir=runtime_dir
)
temporary_path = Path(temporary)
try:
# Compose implements local secrets as bind mounts. The protected
# 0700 parent prevents host users from traversing to this 0444
# file while allowing a non-root container UID to read its mount.
os.chmod(temporary_path, 0o444)
with os.fdopen(descriptor, "wb") as stream:
descriptor = -1
stream.write(value)
stream.flush()
os.fsync(stream.fileno())
except BaseException:
if descriptor >= 0:
os.close(descriptor)
temporary_path.unlink(missing_ok=True)
raise
destination = runtime_dir / name
os.replace(temporary_path, destination)
value_paths[name] = destination
manifest_lines = [
f"{name}={path.resolve()}\n" for name, path in sorted(value_paths.items())
]
descriptor, temporary = tempfile.mkstemp(
prefix=".manifest.", suffix=".tmp", dir=runtime_dir
)
manifest_path = Path(temporary)
try:
os.chmod(manifest_path, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream:
descriptor = -1
stream.writelines(manifest_lines)
stream.flush()
os.fsync(stream.fileno())
except BaseException:
if descriptor >= 0:
os.close(descriptor)
manifest_path.unlink(missing_ok=True)
raise
os.replace(manifest_path, runtime_dir / "manifest")
if hasattr(os, "O_DIRECTORY"):
directory_fd = os.open(runtime_dir, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
except OSError as exc:
fail(f"cannot atomically materialize service files: {exc.strerror or exc.__class__.__name__}")
finally:
for temporary_path, _ in staged:
try:
temporary_path.unlink(missing_ok=True)
except OSError:
pass
def run(
config_path: Path,
*,
runtime_override: Path | None = None,
environ: Mapping[str, str] | None = None,
client_factory: Callable[..., HTTPClient] = HTTPClient,
) -> list[str]:
os.umask(0o077)
environment = os.environ if environ is None else environ
config = load_json(config_path)
_only_keys(config, {"version", "mode", "runtime_dir", "http", "selectel", "file", "secrets"}, "configuration")
if config.get("version") != 1:
fail("configuration.version must be 1")
mode = config.get("mode")
if mode not in {"selectel", "file"}:
fail("configuration.mode must explicitly be selectel or file")
specs = validate_specs(config)
if runtime_override is None:
configured_runtime = config.get("runtime_dir")
if not isinstance(configured_runtime, str) or not configured_runtime:
fail("configuration.runtime_dir must be a non-empty string")
runtime_dir = Path(configured_runtime)
else:
runtime_dir = runtime_override
if not runtime_dir.is_absolute():
fail("runtime directory must be an absolute path")
if mode == "selectel":
if "file" in config:
fail("file settings are forbidden in selectel mode")
values = fetch_selectel(config, specs, environment, client_factory)
else:
if "selectel" in config or "http" in config:
fail("selectel and http settings are forbidden in file mode")
file_config = _object(config.get("file"), "file")
_only_keys(file_config, {"path", "max_bytes"}, "file")
source = Path(_required_string(file_config, "path", "file"))
if not source.is_absolute():
fail("file.path must be absolute")
max_bytes = _bounded_int(
file_config.get("max_bytes", MAX_CONFIG_BYTES),
"file.max_bytes",
1,
4 * MAX_CONFIG_BYTES,
)
expected = {name for name, spec in specs.items() if "literal" not in spec}
values = parse_dotenv(source, expected, max_bytes)
values.update(
{name: b"" for name, spec in specs.items() if "literal" in spec}
)
for canonical, value in values.items():
limit = _bounded_int(
specs[canonical].get("max_bytes", MAX_SECRET_BYTES),
f"secrets.{canonical}.max_bytes",
1,
MAX_SECRET_BYTES,
)
if len(value) > limit:
fail(f"secret {canonical} exceeds its configured limit")
materialize(runtime_dir, specs, values)
return sorted({consumer for spec in specs.values() for consumer in spec["consumers"]})
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Materialize per-service secret dotenv files")
parser.add_argument("--config", required=True, type=Path)
parser.add_argument("--runtime-dir", type=Path)
arguments = parser.parse_args(argv)
try:
consumers = run(arguments.config, runtime_override=arguments.runtime_dir)
except LoaderError as exc:
print(f"secrets-loader: {exc}", file=sys.stderr)
return 1
print(f"secrets-loader: materialized {len(consumers)} service file(s)", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())