From caf6bf051689b9b6e6cb450341252f1432d1e257 Mon Sep 17 00:00:00 2001 From: mi Date: Wed, 15 Jul 2026 13:18:30 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B8,=20?= =?UTF-8?q?=D0=B2=D1=8B=D1=8F=D0=B2=D0=BB=D0=B5=D0=BD=D0=BD=D1=8B=D0=B5=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=82=D0=B5=D1=81=D1=82=D0=B5=20=D0=B8=D0=BD?= =?UTF-8?q?=D1=84=D1=80=D0=B0=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83?= =?UTF-8?q?=D1=80=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architectory/arch-04-settings-and-content.md | 4 ++ .../backend/api-backend/app/integrations.py | 7 ++- .../tests/contract/test_clients.py | 15 +++++ .../backend/deployment/DEPLOYMENT_GUIDE.ru.md | 5 ++ deploy-steps.md | 60 +++++++++++++++++-- infratest/README.md | 12 +++- infratest/infratest.py | 31 +++++++--- infratest/test_infratest.py | 22 ++++++- 8 files changed, 137 insertions(+), 19 deletions(-) diff --git a/architectory/arch-04-settings-and-content.md b/architectory/arch-04-settings-and-content.md index e07b623..0695046 100644 --- a/architectory/arch-04-settings-and-content.md +++ b/architectory/arch-04-settings-and-content.md @@ -288,6 +288,10 @@ SELECTEL_S3_QUARANTINE_READ_SECRET_KEY=change-me OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 ``` +S3-клиенты используют только virtual-hosted addressing +(`https://.s3.storage.selcloud.ru/`). Это часть контракта +presigned URL и CORS Selectel; path-style адресация не поддерживается приложением. + Все переменные — **только** в `backend/.env`. Отдельного хранилища нет. **Webhook-токены** (публичные callback, не service API): `BITRIX_APPLICATION_TOKEN`, `BITRIX_SYNC_WEBHOOK_TOKEN`. diff --git a/codebase/backend/api-backend/app/integrations.py b/codebase/backend/api-backend/app/integrations.py index 5fec604..cfba639 100644 --- a/codebase/backend/api-backend/app/integrations.py +++ b/codebase/backend/api-backend/app/integrations.py @@ -228,7 +228,12 @@ class S3Client: endpoint_url=str(settings.selectel_s3_endpoint_url), aws_access_key_id=settings.selectel_s3_access_key.get_secret_value(), aws_secret_access_key=settings.selectel_s3_secret_key.get_secret_value(), - config=Config(connect_timeout=3, read_timeout=10, retries={"max_attempts": 2}), + config=Config( + connect_timeout=3, + read_timeout=10, + retries={"max_attempts": 2}, + s3={"addressing_style": "virtual"}, + ), ) async def ready(self) -> bool: diff --git a/codebase/backend/api-backend/tests/contract/test_clients.py b/codebase/backend/api-backend/tests/contract/test_clients.py index 1e28744..b4c4b6b 100644 --- a/codebase/backend/api-backend/tests/contract/test_clients.py +++ b/codebase/backend/api-backend/tests/contract/test_clients.py @@ -1,4 +1,5 @@ import uuid +from urllib.parse import urlsplit import httpx import pytest @@ -6,6 +7,7 @@ import pytest from app.integrations import ( DependencyFailure, OpenLinesClient, + S3Client, SafetyClient, fresh_openlines_payload, ) @@ -39,6 +41,19 @@ def settings() -> Settings: return Settings.model_validate(common) +@pytest.mark.asyncio +async def test_s3_presigned_urls_use_virtual_hosted_addressing() -> None: + s3 = S3Client(settings()) + + put_url = await s3.presign_put("quarantine/users/u/file.pdf", "application/pdf", 600) + get_url = await s3.presign_get("attachments", "dialogs/d/file.pdf") + + assert urlsplit(put_url).netloc == "quarantine.s3.example" + assert urlsplit(put_url).path == "/quarantine/users/u/file.pdf" + assert urlsplit(get_url).netloc == "attachments.s3.example" + assert urlsplit(get_url).path == "/dialogs/d/file.pdf" + + @pytest.mark.asyncio async def test_safety_contract_status_and_service_token() -> None: async def handler(request: httpx.Request) -> httpx.Response: diff --git a/codebase/backend/deployment/DEPLOYMENT_GUIDE.ru.md b/codebase/backend/deployment/DEPLOYMENT_GUIDE.ru.md index 2929272..518e2b6 100644 --- a/codebase/backend/deployment/DEPLOYMENT_GUIDE.ru.md +++ b/codebase/backend/deployment/DEPLOYMENT_GUIDE.ru.md @@ -390,6 +390,11 @@ SELECTEL_S3_QUARANTINE_READ_ACCESS_KEY= SELECTEL_S3_QUARANTINE_READ_SECRET_KEY= ``` +API backend принудительно использует virtual-hosted addressing: +`https://.s3.storage.selcloud.ru/`. Это обязательно для +браузерных presigned PUT и CORS в Selectel; path-style URL для этого сценария не +используйте. DNS и исходящий HTTPS с ВМ должны разрешать поддомены бакетов. + ### 8.7. Bitrix24 До установки локального приложения заполните: diff --git a/deploy-steps.md b/deploy-steps.md index 745f72d..b50b26b 100644 --- a/deploy-steps.md +++ b/deploy-steps.md @@ -1,5 +1,6 @@ # usefull commands - Убрать переносы строк: sed -i 's/\r$//' name-file.sh + - find . -type f \( -name '*.sh' -o -name 'validate-env' \) -exec dos2unix {} + - docker compose stop # мягко останавливает контейнеры (не удаляет) - sudo shutdown -h now # выглючить ВМ - Генерация паролей @@ -55,6 +56,7 @@ chmod +x scripts/validate-env deployment/scripts/*.sh redis/scripts/*.sh nginx/s ## Создаем БД В базе подключаем pgcrypto +Чтобы корректно работал PgBouncer выбираем session pooling ### Копируем СА сертификат @@ -80,6 +82,8 @@ psql "host=master.ef54e3e4-ad3d-4b80-a6af-d63269e0895a.c.dbaas.selcloud.ru \ user=dbAdmin \ sslmode=verify-ca" +(psql "host=master.ef54e3e4-ad3d-4b80-a6af-d63269e0895a.c.dbaas.selcloud.ru port=5432 dbname=han_chat user=dbAdmin sslmode=verify-ca") + ### Заводим пользователей и схемы Пользователей создаем через интерфейс селектела. @@ -94,18 +98,40 @@ psql "host=master.ef54e3e4-ad3d-4b80-a6af-d63269e0895a.c.dbaas.selcloud.ru \ + Запрещаем всем посторонним входить в схему han_app и др. CREATE SCHEMA IF NOT EXISTS han_app; REVOKE ALL ON SCHEMA han_app FROM PUBLIC; +ALTER ROLE CURRENT_USER IN DATABASE han_chat SET search_path TO han_app; +SHOW search_path; --чтобы заработало надо переподключиться (должно быть han_app) CREATE SCHEMA IF NOT EXISTS bitrix_local; REVOKE ALL ON SCHEMA bitrix_local FROM PUBLIC; +ALTER ROLE CURRENT_USER IN DATABASE han_chat SET search_path TO bitrix_local; +SHOW search_path; --чтобы заработало надо переподключиться CREATE SCHEMA IF NOT EXISTS bitrix_sync; REVOKE ALL ON SCHEMA bitrix_sync FROM PUBLIC; +ALTER ROLE CURRENT_USER IN DATABASE han_chat SET search_path TO bitrix_sync; +SHOW search_path; --чтобы заработало надо переподключиться CREATE SCHEMA IF NOT EXISTS message_safety; REVOKE ALL ON SCHEMA message_safety FROM PUBLIC; +ALTER ROLE CURRENT_USER IN DATABASE han_chat SET search_path TO message_safety; +SHOW search_path; --чтобы заработало надо переподключиться CREATE SCHEMA IF NOT EXISTS keycloak; REVOKE ALL ON SCHEMA keycloak FROM PUBLIC; +ALTER ROLE CURRENT_USER IN DATABASE han_chat SET search_path TO keycloak; +SHOW search_path; --чтобы заработало надо переподключиться + +Проверка search_path +SELECT r.rolname, d.datname, s.setconfig +FROM pg_db_role_setting s +JOIN pg_roles r ON r.oid = s.setrole +JOIN pg_database d ON d.oid = s.setdatabase +WHERE r.rolname in ('han_app', + 'bitrix_local_app', + 'bitrix_sync_user', + 'message_safety_app', + 'keycloak_user'); + После реализации bitrix_sync (проверить, вероятно не на все таблицы права нужны): @@ -146,8 +172,32 @@ R: ListBucketMultipartUploads ListMultipartUploadParts -Политика CORS: -Allowed origin: https://chat.example.ru -Methods: POST, PUT, GET, HEAD -Headers: Content-Type, x-amz-* -Expose headers: ETag \ No newline at end of file +Политика CORS (бакет han-chat-quarantine, vHosted обязателен): + Allowed origin: https://chat.han0107.ru + Methods: POST, PUT, GET, HEAD + Headers: * (или явно content-type; wildcard x-amz-* в Selectel не работает) + Expose headers: ETag + +# Тест инфраструктуры + +mkdir -p /opt/han-chat/infratest +cd /opt/han-chat/infratest + +scp -i C:\Users\MI\.ssh\hansel -r "C:\Users\MI\Documents\Assistent\HAN_chat_specification\infratest\*" root@135.106.164.58:/opt/han-chat/infratest + +--настраиваю env на локальной машине и копирую на диск +scp -i C:\Users\MI\.ssh\hansel -r "C:\Users\MI\Documents\job\HAN_new_life\HANapp\Production\.env" root@135.106.164.58:/opt/han-chat/infratest/ + + +Создаем изолированное окружение: +python3 -m venv .venv +. .venv/bin/activate +python3 -m pip install --upgrade pip +python3 -m pip install -r requirements.txt + +Если не работает, то надо DNS переправить на eth0 +resolvectl dns eth0 1.1.1.1 8.8.8.8 +resolvectl domain eth0 '~.' +resolvectl flush-caches +resolvectl query pypi.org + diff --git a/infratest/README.md b/infratest/README.md index c9692a3..cf7932d 100644 --- a/infratest/README.md +++ b/infratest/README.md @@ -149,11 +149,17 @@ INFRATEST_CHECK_MIGRATIONS=true ## CORS -При `INFRATEST_CHECK_CORS=true` quarantine должен отвечать на preflight: +При `INFRATEST_CHECK_CORS=true` quarantine должен отвечать на preflight по +**vHosted** URL (`.`). У Selectel CORS не работает на +path-style адресации, даже если presigned PUT с сервера проходит. +Требования к правилу CORS на `han-chat-quarantine`: + +- бакет с включённой Virtual-Hosted адресацией; - origin — точное значение `PUBLIC_WEB_URL`, без wildcard; -- method — `PUT`; -- headers — `Content-Type`, `x-amz-*`. +- method — `PUT` (можно также `GET`, `HEAD`, `POST`); +- headers — минимум `content-type`; wildcard `x-amz-*` в панели Selectel обычно + **не работает**, надёжнее указать `*` или перечислить заголовки явно. Чтобы временно исключить CORS из диагностики: diff --git a/infratest/infratest.py b/infratest/infratest.py index fe63c03..e3b7f0b 100644 --- a/infratest/infratest.py +++ b/infratest/infratest.py @@ -93,6 +93,15 @@ def sanitize(value: Any) -> str: return text[:1000] +def virtual_hosted_bucket_url(endpoint: str, bucket: str, key: str = "") -> str: + """Собирает vHosted URL бакета для CORS preflight (Selectel не поддерживает CORS на path-style).""" + parsed = urlsplit(endpoint.rstrip("/")) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ConfigError("SELECTEL_S3_ENDPOINT_URL должен быть абсолютным https URL") + path = f"/{key.lstrip('/')}" if key else "/" + return urlunsplit((parsed.scheme, f"{bucket}.{parsed.netloc}", path, "", "")) + + def require(values: dict[str, str], name: str) -> str: value = values.get(name, "").strip() if not value: @@ -288,6 +297,7 @@ class InfraTest: connect_timeout=self.settings.timeout_seconds, read_timeout=self.settings.timeout_seconds, retries={"max_attempts": 2}, + s3={"addressing_style": "virtual"}, ), } if self.settings.s3_region: @@ -742,14 +752,12 @@ class InfraTest: return "GET quarantine разрешён; запись, удаление и другие бакеты запрещены" def _check_cors(self, key: str) -> str: - url = self.api_s3.generate_presigned_url( - "put_object", - Params={ - "Bucket": self.settings.buckets["quarantine"], - "Key": key, - "ContentType": "application/octet-stream", - }, - ExpiresIn=300, + # Selectel обрабатывает CORS только на vHosted URL; path-style presigned URL + # возвращает 405 даже при корректной конфигурации бакета. + url = virtual_hosted_bucket_url( + self.settings.s3_endpoint, + self.settings.buckets["quarantine"], + key, ) response = requests.options( url, @@ -771,7 +779,12 @@ class InfraTest: allow_methods = response.headers.get("Access-Control-Allow-Methods", "") if "PUT" not in allow_methods.upper(): raise AssertionError("CORS не разрешает PUT") - return "CORS разрешает presigned PUT только с PUBLIC_WEB_URL" + allow_headers = response.headers.get("Access-Control-Allow-Headers", "") + if "content-type" not in allow_headers.lower(): + raise AssertionError( + f"CORS не разрешает content-type: {allow_headers!r}" + ) + return "CORS разрешает PUT с PUBLIC_WEB_URL через vHosted URL" def _cleanup(self) -> None: failures: list[str] = [] diff --git a/infratest/test_infratest.py b/infratest/test_infratest.py index 662a5d1..8d3c16e 100644 --- a/infratest/test_infratest.py +++ b/infratest/test_infratest.py @@ -9,7 +9,14 @@ from contextlib import redirect_stdout from pathlib import Path from unittest.mock import patch -from infratest import ConfigError, Reporter, Settings, as_bool, sanitize +from infratest import ( + ConfigError, + Reporter, + Settings, + as_bool, + sanitize, + virtual_hosted_bucket_url, +) def valid_env(ca_path: Path) -> str: @@ -102,6 +109,19 @@ class SettingsTests(unittest.TestCase): Settings.from_env_file(env_file) +class UrlTests(unittest.TestCase): + def test_virtual_hosted_bucket_url(self) -> None: + url = virtual_hosted_bucket_url( + "https://s3.storage.selcloud.ru", + "han-chat-quarantine", + "infratest/key.bin", + ) + self.assertEqual( + url, + "https://han-chat-quarantine.s3.storage.selcloud.ru/infratest/key.bin", + ) + + class SafetyTests(unittest.TestCase): def test_sanitize_redacts_secret_and_url_query(self) -> None: value = (