diff --git a/.env.example b/.env.example index 11f1019..80d928a 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,17 @@ COMPOSE_PROJECT_NAME=edevs_hub +CUSTOCRM_APP_DOMAIN=app.localhost +CUSTOCRM_PLATFORM_DOMAIN=platform.localhost +CUSTOCRM_ACME_EMAIL=local@example.invalid +CUSTOCRM_ADMIN_PORT=18001 + HUB_ENV=local HUB_DEBUG=true HUB_SECRET_KEY=change-me-only-for-local-development -HUB_ALLOWED_HOSTS=localhost,127.0.0.1,hub.localhost -HUB_CSRF_TRUSTED_ORIGINS=http://localhost,http://localhost:8000,http://localhost:5173,http://localhost:5175 +CUSTOCRM_APP_ALLOWED_HOSTS=localhost,127.0.0.1,app.localhost +CUSTOCRM_APP_CSRF_TRUSTED_ORIGINS=http://localhost,http://app.localhost,http://localhost:8010,http://localhost:5173,http://localhost:5175 +CUSTOCRM_PLATFORM_ALLOWED_HOSTS=localhost,127.0.0.1,platform.localhost +CUSTOCRM_PLATFORM_CSRF_TRUSTED_ORIGINS=http://platform.localhost,http://localhost:8011 HUB_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5175 # Шифрование секретов в БД (Fernet-ключ). В local не обязателен — выводится из @@ -55,9 +62,10 @@ HUB_CALL_TURN_TTL_SECONDS=3600 INTERNAL_UI_PORT=5173 WEB_CHAT_PORT=5175 -BACKEND_PORT=8010 +BACKEND_APP_PORT=8010 +BACKEND_PLATFORM_PORT=8011 -VITE_API_BASE_URL=http://localhost:8010 +VITE_API_BASE_URL=http://app.localhost/api/v1 # Публичный домен для сниппета Web-виджета в рантайме выводится от текущего origin # (ADR-HUB-0028 §10); build-time аргумент не нужен. diff --git a/Caddyfile b/Caddyfile index 42cfb0d..3823a59 100644 --- a/Caddyfile +++ b/Caddyfile @@ -1,17 +1,16 @@ # Caddyfile — единый HTTP/HTTPS public boundary CustoCRM (ADR-HUB-0028 §gateway). # Подставляется в release bundle и монтируется в контейнер gateway. # Caddy: TLS termination + ACME, HTTP->HTTPS redirect, WebSocket upgrade (native). -# Маршрутизация публичных путей делегирована frontend-контейнеру (internal nginx, -# deploy/nginx/frontend.production.conf) — Caddy только терминирует TLS и проксирует -# всё на frontend:80. Coturn не проксируется через Caddy (отдельная boundary, profile calls). +# App-маршрутизация делегирована frontend-контейнеру, platform API идёт в +# отдельный backend. Admin принципиально отсутствует в public gateway. { email {$CUSTOCRM_ACME_EMAIL:} # Caddy admin API не публикуется наружу (default localhost:2019). } -# Домен экземпляра берётся из instance .env (CUSTOCRM_DOMAIN). -{$CUSTOCRM_DOMAIN} { +# Tenant-facing application surface. +{$CUSTOCRM_APP_DOMAIN} { encode gzip zstd reverse_proxy frontend:80 { @@ -21,3 +20,16 @@ header_up X-Forwarded-Proto {scheme} } } + +# Edevs platform surface. На C01 опубликован только health endpoint; API +# управления tenant'ами появится на C06. +{$CUSTOCRM_PLATFORM_DOMAIN} { + encode gzip zstd + + reverse_proxy backend-platform:8000 { + header_up Host {host} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } +} diff --git a/README.md b/README.md index 82eb523..d6791cd 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ Copy-Item .env.example .env The local compose stack contains: -- Django ASGI backend and background worker; +- isolated app, platform, and loopback-only admin Django runtimes; +- background worker; - PostgreSQL; - Redis; - Internal Hub UI; @@ -22,10 +23,12 @@ No production secrets are stored in the repository. Default local URLs: -- Internal Hub UI: `http://localhost:5173` -- Django admin: `http://localhost:8010/admin/` +- App gateway: `http://app.localhost/` +- Platform health: `http://platform.localhost/api/v1/health/live/` +- Django admin (loopback only): `http://127.0.0.1:18001/admin/` +- Internal Hub UI (direct Vite): `http://localhost:5173` - Web Chat: `http://localhost:5175` -- Backend API: `http://localhost:8010/api/v1` +- App API (direct): `http://localhost:8010/api/v1` Local accounts (TOTP disabled). These credentials are fixed — do not change them: @@ -35,7 +38,7 @@ Local accounts (TOTP disabled). These credentials are fixed — do not change th Bootstrap the organization and both accounts: ```powershell -docker compose run --rm backend python manage.py bootstrap_owner --email owner@edevs.tech --password Owner-Local-2026 --name "Иван Петров" +docker compose run --rm backend-app python manage.py bootstrap_owner --email owner@edevs.tech --password Owner-Local-2026 --name "Иван Петров" ``` ## Tests @@ -54,10 +57,10 @@ Individual suites: ```powershell # Backend (pytest + pytest-django) -docker compose run --rm backend pytest +docker compose run --rm backend-app pytest # Frontend unit tests (vitest) -docker compose run --rm internal-ui npm run test +docker compose run --rm frontend npm run test # End-to-end (Playwright, internal-ui) — auto-starts the dev server npx playwright install chromium # one-time diff --git a/apps/backend/Dockerfile.production b/apps/backend/Dockerfile.production index c452353..570c1f2 100644 --- a/apps/backend/Dockerfile.production +++ b/apps/backend/Dockerfile.production @@ -18,6 +18,7 @@ COPY content /app/content # Build-time secret нужен только чтобы settings загрузились в production-режиме; # collectstatic не обращается к БД/Redis. whitenoise раздаёт static в runtime. RUN cd apps/backend && HUB_SECRET_KEY=collectstatic-build HUB_DEBUG=false HUB_ENV=production \ + CUSTOCRM_APP_ALLOWED_HOSTS=collectstatic.invalid \ python manage.py collectstatic --noinput RUN chown -R hub:hub /app @@ -27,4 +28,4 @@ WORKDIR /app/apps/backend USER hub EXPOSE 8000 -CMD ["gunicorn", "hub_backend.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "60"] +CMD ["gunicorn", "hub_backend.wsgi_app:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "60"] diff --git a/apps/backend/hub_backend/asgi.py b/apps/backend/hub_backend/asgi.py index a1fc313..c73e303 100644 --- a/apps/backend/hub_backend/asgi.py +++ b/apps/backend/hub_backend/asgi.py @@ -1,21 +1,3 @@ -import os +"""Backward-compatible app ASGI entrypoint.""" -from django.core.asgi import get_asgi_application - -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hub_backend.settings") - -# HTTP-приложение инициализируется до импорта consumer'ов (django.setup). -django_asgi_app = get_asgi_application() - -from channels.routing import ProtocolTypeRouter, URLRouter # noqa: E402 - -from hub_platform.calls.routing import websocket_urlpatterns # noqa: E402 - -application = ProtocolTypeRouter( - { - "http": django_asgi_app, - # Собственный signaling звонков (SPEC-HUB-0013 §9). Аутентификация — - # первым сообщением по call access token, Django-сессия не нужна. - "websocket": URLRouter(websocket_urlpatterns), - } -) +from hub_backend.asgi_app import application # noqa: F401 diff --git a/apps/backend/hub_backend/asgi_admin.py b/apps/backend/hub_backend/asgi_admin.py new file mode 100644 index 0000000..f820f66 --- /dev/null +++ b/apps/backend/hub_backend/asgi_admin.py @@ -0,0 +1,7 @@ +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hub_backend.settings_admin") + +application = get_asgi_application() diff --git a/apps/backend/hub_backend/asgi_app.py b/apps/backend/hub_backend/asgi_app.py new file mode 100644 index 0000000..1035a43 --- /dev/null +++ b/apps/backend/hub_backend/asgi_app.py @@ -0,0 +1,19 @@ +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hub_backend.settings_app") + +# Django initializes before consumers import models. +django_asgi_app = get_asgi_application() + +from channels.routing import ProtocolTypeRouter, URLRouter # noqa: E402 + +from hub_platform.calls.routing import websocket_urlpatterns # noqa: E402 + +application = ProtocolTypeRouter( + { + "http": django_asgi_app, + "websocket": URLRouter(websocket_urlpatterns), + } +) diff --git a/apps/backend/hub_backend/asgi_platform.py b/apps/backend/hub_backend/asgi_platform.py new file mode 100644 index 0000000..db5ec34 --- /dev/null +++ b/apps/backend/hub_backend/asgi_platform.py @@ -0,0 +1,7 @@ +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hub_backend.settings_platform") + +application = get_asgi_application() diff --git a/apps/backend/hub_backend/settings.py b/apps/backend/hub_backend/settings.py index fd4e8f6..9fc3665 100644 --- a/apps/backend/hub_backend/settings.py +++ b/apps/backend/hub_backend/settings.py @@ -1,291 +1,6 @@ -import os -import sys -from pathlib import Path +"""Backward-compatible default for management commands and local tests. -from django.core.exceptions import ImproperlyConfigured +Runtime services use an explicit surface settings module. +""" -BASE_DIR = Path(__file__).resolve().parent.parent - -INSECURE_SECRET_KEY = "local-development-only" - -# Автоопределение тестового прогона, чтобы manage.py test / pytest работали -# без ручного выставления production-окружения. -TESTING = "test" in sys.argv or "pytest" in sys.modules - - -def env_bool(name: str, default: bool = False) -> bool: - value = os.environ.get(name) - if value is None: - return default - return value.lower() in {"1", "true", "yes", "on"} - - -def env_list(name: str, default: list[str]) -> list[str]: - value = os.environ.get(name) - if value is None: - return default - return [item.strip() for item in value.split(",") if item.strip()] - - -SECRET_KEY = os.environ.get("HUB_SECRET_KEY", INSECURE_SECRET_KEY) -DEBUG = env_bool("HUB_DEBUG") -ALLOWED_HOSTS = env_list("HUB_ALLOWED_HOSTS", ["localhost", "127.0.0.1"]) -CSRF_TRUSTED_ORIGINS = env_list("HUB_CSRF_TRUSTED_ORIGINS", []) - -# Запрещаем запуск в production с дефолтным/пустым ключом подписи. -if not DEBUG and not TESTING and SECRET_KEY in {"", INSECURE_SECRET_KEY}: - raise ImproperlyConfigured("HUB_SECRET_KEY must be set to a strong value when HUB_DEBUG is disabled") - -INSTALLED_APPS = [ - "django.contrib.admin", - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.messages", - "django.contrib.staticfiles", - "rest_framework", - "hub_platform.identity", - "hub_platform.products", - "hub_platform.ai", - "hub_platform.integrations", - "hub_platform.channels", - "hub_platform.conversations", - "hub_platform.orders", - "hub_platform.sales", - "hub_platform.notifications", - "hub_platform.webchat", - "hub_platform.health", - "hub_platform.events", - "hub_platform.support", - "hub_platform.calls", - # django-channels НЕ добавляется в INSTALLED_APPS: его app label «channels» - # конфликтует с доменным hub_platform.channels, а без runserver-оверрайда - # (сервер — uvicorn) библиотеке достаточно CHANNEL_LAYERS. -] - -MIDDLEWARE = [ - "django.middleware.security.SecurityMiddleware", - "whitenoise.middleware.WhiteNoiseMiddleware", - "hub_platform.http.middleware.LocalCorsMiddleware", - "django.contrib.sessions.middleware.SessionMiddleware", - "django.middleware.common.CommonMiddleware", - "django.middleware.csrf.CsrfViewMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", - "django.middleware.clickjacking.XFrameOptionsMiddleware", - "hub_platform.events.middleware.CorrelationIdMiddleware", -] - -ROOT_URLCONF = "hub_backend.urls" - -TEMPLATES = [ - { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [], - "APP_DIRS": True, - "OPTIONS": { - "context_processors": [ - "django.template.context_processors.request", - "django.contrib.auth.context_processors.auth", - "django.contrib.messages.context_processors.messages", - ], - }, - }, -] - -WSGI_APPLICATION = "hub_backend.wsgi.application" -ASGI_APPLICATION = "hub_backend.asgi.application" - -AUTH_USER_MODEL = "identity.HumanUser" - -DATABASES = { - "default": { - "ENGINE": "django.db.backends.postgresql", - "NAME": os.environ.get("POSTGRES_DB", "edevs_hub"), - "USER": os.environ.get("POSTGRES_USER", "edevs_hub"), - "PASSWORD": os.environ.get("POSTGRES_PASSWORD", "edevs_hub"), - "HOST": os.environ.get("POSTGRES_HOST", "postgres"), - "PORT": os.environ.get("POSTGRES_PORT", "5432"), - "CONN_MAX_AGE": 60, - } -} - -CACHES = { - "default": { - "BACKEND": "django.core.cache.backends.redis.RedisCache", - "LOCATION": os.environ.get("REDIS_URL", "redis://redis:6379/0"), - } -} - -# Signaling звонков: Redis только fan-out/presence, source of truth lifecycle — -# PostgreSQL (SPEC-HUB-0013 §9). В тестах — InMemory layer. -CHANNEL_LAYERS = { - "default": { - "BACKEND": "channels.layers.InMemoryChannelLayer", - } - if TESTING - else { - "BACKEND": "channels_redis.core.RedisChannelLayer", - "CONFIG": {"hosts": [os.environ.get("REDIS_URL", "redis://redis:6379/0")]}, - } -} - -AUTH_PASSWORD_VALIDATORS = [ - {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, - {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", "OPTIONS": {"min_length": 10}}, - {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, - {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, - {"NAME": "hub_platform.identity.password_validation.PasswordComplexityValidator"}, -] - -LANGUAGE_CODE = "ru-ru" -TIME_ZONE = "Europe/Moscow" -USE_I18N = True -USE_TZ = True - -# Email (env-driven; console backend is the safe local default until SMTP Edevs is wired in E02). -EMAIL_BACKEND = os.environ.get("EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend") -EMAIL_HOST = os.environ.get("EMAIL_HOST", "") -EMAIL_PORT = int(os.environ.get("EMAIL_PORT", "587")) -EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "") -EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "") -EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS", "true").lower() == "true" -DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "CustoCRM ") - -# Base URL of the internal UI, used to build links inside transactional emails. -INTERNAL_UI_BASE_URL = os.environ.get("INTERNAL_UI_BASE_URL", "http://localhost:5173") - -# Ключ шифрования секретов в БД (Fernet). В production задаётся явно; иначе -# детерминированно выводится из SECRET_KEY (см. hub_platform.identity.crypto). -HUB_FIELD_ENCRYPTION_KEY = os.environ.get("HUB_FIELD_ENCRYPTION_KEY", "") - -# AI-провайдер (chat + embeddings). По умолчанию тестовый адаптер локально; -# в production требуется реальный провайдер (см. hub_platform.ai.provider.factory). -HUB_AI_PROVIDER = os.environ.get("HUB_AI_PROVIDER", "") # "" -> auto ("openrouter" если есть ключ, иначе "test") -HUB_OPENROUTER_API_KEY = os.environ.get("HUB_OPENROUTER_API_KEY", "") -HUB_OPENROUTER_BASE_URL = os.environ.get("HUB_OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1") -HUB_AI_REQUEST_TIMEOUT = float(os.environ.get("HUB_AI_REQUEST_TIMEOUT", "30")) -HUB_AI_MAX_RETRIES = int(os.environ.get("HUB_AI_MAX_RETRIES", "2")) -HUB_AI_GLOBAL_DAILY_COST_LIMIT_MICROS = int(os.environ.get("HUB_AI_GLOBAL_DAILY_COST_LIMIT_MICROS", "0")) # 0 = без лимита -HUB_AI_PRICING: dict = {} # переопределение цен micro-USD/токен по модели -HUB_AI_EMBEDDING_MODEL = os.environ.get("HUB_AI_EMBEDDING_MODEL", "openai/text-embedding-3-small") - -# Long-poll hold-time мессенджеров (сек). Держим малым: единый воркер выполняет -# и inbound-поллинг, и outbox-диспатч в одном потоке — при большом hold-time -# getUpdates/updates блокирует цикл и outbox (приглашения звонков, уведомления, -# ответы AI) уходит с задержкой в размер long-poll на каждое подключение. -HUB_MESSENGER_POLL_TIMEOUT_SECONDS = int(os.environ.get("HUB_MESSENGER_POLL_TIMEOUT_SECONDS", "2")) - -# Password reset link lifetime. UI обещает 30 минут (default_token_generator uses this setting). -PASSWORD_RESET_TIMEOUT = int(os.environ.get("PASSWORD_RESET_TIMEOUT", str(30 * 60))) - -# Транспорт и cookie. По умолчанию безопасно вне DEBUG; локальная разработка и тесты не ломаются. -_secure_default = not DEBUG and not TESTING -SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") -SECURE_CONTENT_TYPE_NOSNIFF = True -SESSION_COOKIE_HTTPONLY = True -SESSION_COOKIE_SAMESITE = os.environ.get("HUB_COOKIE_SAMESITE", "Lax") -CSRF_COOKIE_SAMESITE = SESSION_COOKIE_SAMESITE -SESSION_COOKIE_SECURE = env_bool("HUB_COOKIE_SECURE", _secure_default) -CSRF_COOKIE_SECURE = env_bool("HUB_COOKIE_SECURE", _secure_default) -SECURE_SSL_REDIRECT = env_bool("HUB_SSL_REDIRECT", _secure_default) -SECURE_HSTS_SECONDS = int(os.environ.get("HUB_HSTS_SECONDS", str(60 * 60 * 24 * 365) if _secure_default else "0")) -SECURE_HSTS_INCLUDE_SUBDOMAINS = SECURE_HSTS_SECONDS > 0 -SECURE_HSTS_PRELOAD = SECURE_HSTS_SECONDS > 0 - -STATIC_URL = "static/" -STATIC_ROOT = BASE_DIR / "staticfiles" - -# Файловые вложения знаний (ADR-HUB-0023). Файлы отдаются только через -# download-endpoint (FileResponse), прямого статик-роутинга MEDIA нет. -MEDIA_ROOT = Path(os.environ.get("HUB_MEDIA_ROOT", BASE_DIR / "media")) -MEDIA_URL = "media/" - -# Публичный адрес Hub: абсолютные ссылки, уходящие клиентам (download вложений). -HUB_PUBLIC_BASE_URL = os.environ.get("HUB_PUBLIC_BASE_URL", "http://localhost:8000") - -# P2P calls: opaque invitation lifetime and short-lived signaling/media access. -HUB_CALL_INVITE_TTL_SECONDS = int(os.environ.get("HUB_CALL_INVITE_TTL_SECONDS", str(5 * 60))) -HUB_CALL_ACCESS_TTL_SECONDS = int(os.environ.get("HUB_CALL_ACCESS_TTL_SECONDS", str(60 * 60))) -# Grace period: принятый звонок без установленного соединения закрывается FAILED. -HUB_CALL_CONNECT_GRACE_SECONDS = int(os.environ.get("HUB_CALL_CONNECT_GRACE_SECONDS", str(2 * 60))) -# Grace period восстановления активного звонка после обрыва участника. -HUB_CALL_RECONNECT_GRACE_SECONDS = int(os.environ.get("HUB_CALL_RECONNECT_GRACE_SECONDS", str(60))) -if ( - HUB_CALL_INVITE_TTL_SECONDS <= 0 - or HUB_CALL_ACCESS_TTL_SECONDS <= 0 - or HUB_CALL_CONNECT_GRACE_SECONDS <= 0 - or HUB_CALL_RECONNECT_GRACE_SECONDS <= 0 -): - raise ImproperlyConfigured("HUB call token TTL values must be positive") - -# ICE-серверы для WebRTC (SPEC-HUB-0013 §10): direct-first через STUN, TURN как -# fallback. Формат URL через запятую (stun:host:port / turn:host:3478?transport=udp). -HUB_CALL_STUN_URLS = env_list("HUB_CALL_STUN_URLS", []) -# TURN (Coturn, SPEC-HUB-0013 §11): backend выдаёт краткоживущие REST-credentials -# по общему static-auth-secret. Пусто локально -> только STUN/direct ICE. -HUB_CALL_TURN_URLS = env_list("HUB_CALL_TURN_URLS", []) -HUB_CALL_TURN_SECRET = os.environ.get("HUB_CALL_TURN_SECRET", "") -HUB_CALL_TURN_TTL_SECONDS = int(os.environ.get("HUB_CALL_TURN_TTL_SECONDS", str(60 * 60))) -if HUB_CALL_TURN_TTL_SECONDS <= 0: - raise ImproperlyConfigured("HUB_CALL_TURN_TTL_SECONDS must be positive") -STORAGES = { - "default": { - "BACKEND": "django.core.files.storage.FileSystemStorage", - }, - "staticfiles": { - # В тестах manifest-хранилище требует прогнанного collectstatic, - # поэтому используем обычное хранилище без манифеста. - "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage" - if TESTING - else "whitenoise.storage.CompressedManifestStaticFilesStorage", - }, -} -DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" - -# Лимиты на чувствительные эндпоинты (брутфорс/злоупотребление). В тестах отключены. -_THROTTLE_RATES = { - "login": "10/min", - "password_reset": "5/min", - "totp": "10/min", - "call_invite": "30/min", - # Страница звонка поллит состояние по access token — лимит с запасом. - "call_access": "120/min", -} -if TESTING: - _THROTTLE_RATES = {scope: None for scope in _THROTTLE_RATES} - -REST_FRAMEWORK = { - "DEFAULT_RENDERER_CLASSES": ["rest_framework.renderers.JSONRenderer"], - "DEFAULT_PARSER_CLASSES": ["rest_framework.parsers.JSONParser"], - "DEFAULT_AUTHENTICATION_CLASSES": ["rest_framework.authentication.SessionAuthentication"], - "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], - "EXCEPTION_HANDLER": "hub_platform.api.exceptions.api_exception_handler", - "DEFAULT_THROTTLE_RATES": _THROTTLE_RATES, -} - -CORS_ALLOWED_ORIGINS = env_list( - "HUB_CORS_ALLOWED_ORIGINS", - ["http://localhost:5173", "http://localhost:5174", "http://localhost:5175"], -) - -LOGGING = { - "version": 1, - "disable_existing_loggers": False, - "formatters": { - "structured": { - "format": "%(asctime)s %(levelname)s %(name)s %(message)s correlation_id=%(correlation_id)s" - } - }, - "filters": { - "correlation_id": {"()": "hub_platform.events.logging.CorrelationIdLogFilter"} - }, - "handlers": { - "console": { - "class": "logging.StreamHandler", - "formatter": "structured", - "filters": ["correlation_id"], - } - }, - "root": {"handlers": ["console"], "level": "INFO"}, -} +from hub_backend.settings_app import * # noqa: F403 diff --git a/apps/backend/hub_backend/settings_admin.py b/apps/backend/hub_backend/settings_admin.py new file mode 100644 index 0000000..eeb3450 --- /dev/null +++ b/apps/backend/hub_backend/settings_admin.py @@ -0,0 +1,30 @@ +# ruff: noqa: F403,F405 +import os + +from hub_backend.settings_base import * + +HUB_RUNTIME_SURFACE = "admin" +ROOT_URLCONF = "hub_backend.urls_admin" +ASGI_APPLICATION = "hub_backend.asgi_admin.application" +WSGI_APPLICATION = "hub_backend.wsgi_admin.application" + +# ADR-HUB-0031: admin доступен только через loopback bind и SSH tunnel. +ALLOWED_HOSTS = ["127.0.0.1", "localhost"] +CSRF_TRUSTED_ORIGINS = [] +CORS_ALLOWED_ORIGINS = [] +SESSION_COOKIE_SECURE = env_bool("CUSTOCRM_ADMIN_COOKIE_SECURE", False) +CSRF_COOKIE_SECURE = SESSION_COOKIE_SECURE +SESSION_COOKIE_NAME = os.environ.get("CUSTOCRM_ADMIN_SESSION_COOKIE_NAME", "custocrm_admin_session") +CSRF_COOKIE_NAME = os.environ.get("CUSTOCRM_ADMIN_CSRF_COOKIE_NAME", "custocrm_admin_csrftoken") +SESSION_COOKIE_DOMAIN = None +CSRF_COOKIE_DOMAIN = None +SESSION_COOKIE_PATH = "/" +CSRF_COOKIE_PATH = "/" +SECURE_SSL_REDIRECT = env_bool("CUSTOCRM_ADMIN_SSL_REDIRECT", False) + +HUB_CONTENT_SECURITY_POLICY = os.environ.get( + "CUSTOCRM_ADMIN_CSP", + "default-src 'self'; frame-ancestors 'none'; base-uri 'self'; " + "form-action 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; " + "script-src 'self' 'unsafe-inline'", +) diff --git a/apps/backend/hub_backend/settings_app.py b/apps/backend/hub_backend/settings_app.py new file mode 100644 index 0000000..216ddd0 --- /dev/null +++ b/apps/backend/hub_backend/settings_app.py @@ -0,0 +1,42 @@ +# ruff: noqa: F403,F405 +import os + +from django.core.exceptions import ImproperlyConfigured + +from hub_backend.settings_base import * + +HUB_RUNTIME_SURFACE = "app" +ROOT_URLCONF = "hub_backend.urls_app" +ASGI_APPLICATION = "hub_backend.asgi_app.application" +WSGI_APPLICATION = "hub_backend.wsgi_app.application" + +_app_hosts = os.environ.get("CUSTOCRM_APP_ALLOWED_HOSTS", "") +if not DEBUG and not TESTING and not _app_hosts: + raise ImproperlyConfigured("CUSTOCRM_APP_ALLOWED_HOSTS is required for the app surface") +ALLOWED_HOSTS = env_list( + "CUSTOCRM_APP_ALLOWED_HOSTS", + env_list("HUB_ALLOWED_HOSTS", ["localhost", "127.0.0.1", "app.localhost"]), +) +CSRF_TRUSTED_ORIGINS = env_list( + "CUSTOCRM_APP_CSRF_TRUSTED_ORIGINS", + env_list("HUB_CSRF_TRUSTED_ORIGINS", []), +) + +SESSION_COOKIE_NAME = os.environ.get( + "CUSTOCRM_APP_SESSION_COOKIE_NAME", + "__Host-custocrm-app-session" if SESSION_COOKIE_SECURE else "custocrm_app_session", +) +CSRF_COOKIE_NAME = os.environ.get( + "CUSTOCRM_APP_CSRF_COOKIE_NAME", + "__Host-custocrm-app-csrf" if CSRF_COOKIE_SECURE else "custocrm_app_csrftoken", +) +SESSION_COOKIE_DOMAIN = None +CSRF_COOKIE_DOMAIN = None +SESSION_COOKIE_PATH = "/" +CSRF_COOKIE_PATH = "/" + +HUB_PUBLIC_BASE_URL = os.environ.get("CUSTOCRM_APP_PUBLIC_BASE_URL", HUB_PUBLIC_BASE_URL) +HUB_CONTENT_SECURITY_POLICY = os.environ.get( + "CUSTOCRM_APP_CSP", + "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'", +) diff --git a/apps/backend/hub_backend/settings_base.py b/apps/backend/hub_backend/settings_base.py new file mode 100644 index 0000000..823b1e0 --- /dev/null +++ b/apps/backend/hub_backend/settings_base.py @@ -0,0 +1,290 @@ +import os +import sys +from pathlib import Path + +from django.core.exceptions import ImproperlyConfigured + +from hub_backend.settings_env import env_bool, env_list + +BASE_DIR = Path(__file__).resolve().parent.parent + +INSECURE_SECRET_KEY = "local-development-only" + +# Автоопределение тестового прогона, чтобы manage.py test / pytest работали +# без ручного выставления production-окружения. +TESTING = "test" in sys.argv or "pytest" in sys.modules +SECRET_KEY = os.environ.get("HUB_SECRET_KEY", INSECURE_SECRET_KEY) +DEBUG = env_bool("HUB_DEBUG") +ALLOWED_HOSTS = env_list("HUB_ALLOWED_HOSTS", ["localhost", "127.0.0.1"]) +CSRF_TRUSTED_ORIGINS = env_list("HUB_CSRF_TRUSTED_ORIGINS", []) + +# Запрещаем запуск в production с дефолтным/пустым ключом подписи. +if not DEBUG and not TESTING and SECRET_KEY in {"", INSECURE_SECRET_KEY}: + raise ImproperlyConfigured( + "HUB_SECRET_KEY must be set to a strong value when HUB_DEBUG is disabled" + ) + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "hub_platform.identity", + "hub_platform.products", + "hub_platform.ai", + "hub_platform.integrations", + "hub_platform.channels", + "hub_platform.conversations", + "hub_platform.orders", + "hub_platform.sales", + "hub_platform.notifications", + "hub_platform.webchat", + "hub_platform.health", + "hub_platform.events", + "hub_platform.support", + "hub_platform.calls", + # django-channels НЕ добавляется в INSTALLED_APPS: его app label «channels» + # конфликтует с доменным hub_platform.channels, а без runserver-оверрайда + # (сервер — uvicorn) библиотеке достаточно CHANNEL_LAYERS. +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "hub_platform.http.middleware.ContentSecurityPolicyMiddleware", + "hub_platform.http.middleware.LocalCorsMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "hub_platform.events.middleware.CorrelationIdMiddleware", +] + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +AUTH_USER_MODEL = "identity.HumanUser" + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.environ.get("POSTGRES_DB", "edevs_hub"), + "USER": os.environ.get("POSTGRES_USER", "edevs_hub"), + "PASSWORD": os.environ.get("POSTGRES_PASSWORD", "edevs_hub"), + "HOST": os.environ.get("POSTGRES_HOST", "postgres"), + "PORT": os.environ.get("POSTGRES_PORT", "5432"), + "CONN_MAX_AGE": 60, + } +} + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.redis.RedisCache", + "LOCATION": os.environ.get("REDIS_URL", "redis://redis:6379/0"), + } +} + +# Signaling звонков: Redis только fan-out/presence, source of truth lifecycle — +# PostgreSQL (SPEC-HUB-0013 §9). В тестах — InMemory layer. +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels.layers.InMemoryChannelLayer", + } + if TESTING + else { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": {"hosts": [os.environ.get("REDIS_URL", "redis://redis:6379/0")]}, + } +} + +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + "OPTIONS": {"min_length": 10}, + }, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, + {"NAME": "hub_platform.identity.password_validation.PasswordComplexityValidator"}, +] + +LANGUAGE_CODE = "ru-ru" +TIME_ZONE = "Europe/Moscow" +USE_I18N = True +USE_TZ = True + +# Email (env-driven; console backend is the safe local default until SMTP Edevs is wired in E02). +EMAIL_BACKEND = os.environ.get("EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend") +EMAIL_HOST = os.environ.get("EMAIL_HOST", "") +EMAIL_PORT = int(os.environ.get("EMAIL_PORT", "587")) +EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "") +EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "") +EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS", "true").lower() == "true" +DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "CustoCRM ") + +# Base URL of the internal UI, used to build links inside transactional emails. +INTERNAL_UI_BASE_URL = os.environ.get("INTERNAL_UI_BASE_URL", "http://localhost:5173") + +# Ключ шифрования секретов в БД (Fernet). В production задаётся явно; иначе +# детерминированно выводится из SECRET_KEY (см. hub_platform.identity.crypto). +HUB_FIELD_ENCRYPTION_KEY = os.environ.get("HUB_FIELD_ENCRYPTION_KEY", "") + +# AI-провайдер (chat + embeddings). По умолчанию тестовый адаптер локально; +# в production требуется реальный провайдер (см. hub_platform.ai.provider.factory). +# Empty means auto: OpenRouter when a key exists, otherwise the test provider. +HUB_AI_PROVIDER = os.environ.get("HUB_AI_PROVIDER", "") +HUB_OPENROUTER_API_KEY = os.environ.get("HUB_OPENROUTER_API_KEY", "") +HUB_OPENROUTER_BASE_URL = os.environ.get("HUB_OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1") +HUB_AI_REQUEST_TIMEOUT = float(os.environ.get("HUB_AI_REQUEST_TIMEOUT", "30")) +HUB_AI_MAX_RETRIES = int(os.environ.get("HUB_AI_MAX_RETRIES", "2")) +HUB_AI_GLOBAL_DAILY_COST_LIMIT_MICROS = int( + os.environ.get("HUB_AI_GLOBAL_DAILY_COST_LIMIT_MICROS", "0") +) # 0 = без лимита +HUB_AI_PRICING: dict = {} # переопределение цен micro-USD/токен по модели +HUB_AI_EMBEDDING_MODEL = os.environ.get("HUB_AI_EMBEDDING_MODEL", "openai/text-embedding-3-small") + +# Long-poll hold-time мессенджеров (сек). Держим малым: единый воркер выполняет +# и inbound-поллинг, и outbox-диспатч в одном потоке — при большом hold-time +# getUpdates/updates блокирует цикл и outbox (приглашения звонков, уведомления, +# ответы AI) уходит с задержкой в размер long-poll на каждое подключение. +HUB_MESSENGER_POLL_TIMEOUT_SECONDS = int(os.environ.get("HUB_MESSENGER_POLL_TIMEOUT_SECONDS", "2")) + +# Password reset link lifetime. UI обещает 30 минут (default_token_generator uses this setting). +PASSWORD_RESET_TIMEOUT = int(os.environ.get("PASSWORD_RESET_TIMEOUT", str(30 * 60))) + +# Транспорт и cookie. По умолчанию безопасно вне DEBUG; локальная разработка и тесты не ломаются. +_secure_default = not DEBUG and not TESTING +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +SECURE_CONTENT_TYPE_NOSNIFF = True +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SAMESITE = os.environ.get("HUB_COOKIE_SAMESITE", "Lax") +CSRF_COOKIE_SAMESITE = SESSION_COOKIE_SAMESITE +SESSION_COOKIE_SECURE = env_bool("HUB_COOKIE_SECURE", _secure_default) +CSRF_COOKIE_SECURE = env_bool("HUB_COOKIE_SECURE", _secure_default) +SECURE_SSL_REDIRECT = env_bool("HUB_SSL_REDIRECT", _secure_default) +SECURE_HSTS_SECONDS = int( + os.environ.get("HUB_HSTS_SECONDS", str(60 * 60 * 24 * 365) if _secure_default else "0") +) +SECURE_HSTS_INCLUDE_SUBDOMAINS = SECURE_HSTS_SECONDS > 0 +SECURE_HSTS_PRELOAD = SECURE_HSTS_SECONDS > 0 + +STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "staticfiles" + +# Файловые вложения знаний (ADR-HUB-0023). Файлы отдаются только через +# download-endpoint (FileResponse), прямого статик-роутинга MEDIA нет. +MEDIA_ROOT = Path(os.environ.get("HUB_MEDIA_ROOT", BASE_DIR / "media")) +MEDIA_URL = "media/" + +# Публичный адрес Hub: абсолютные ссылки, уходящие клиентам (download вложений). +HUB_PUBLIC_BASE_URL = os.environ.get("HUB_PUBLIC_BASE_URL", "http://localhost:8000") + +# P2P calls: opaque invitation lifetime and short-lived signaling/media access. +HUB_CALL_INVITE_TTL_SECONDS = int(os.environ.get("HUB_CALL_INVITE_TTL_SECONDS", str(5 * 60))) +HUB_CALL_ACCESS_TTL_SECONDS = int(os.environ.get("HUB_CALL_ACCESS_TTL_SECONDS", str(60 * 60))) +# Grace period: принятый звонок без установленного соединения закрывается FAILED. +HUB_CALL_CONNECT_GRACE_SECONDS = int(os.environ.get("HUB_CALL_CONNECT_GRACE_SECONDS", str(2 * 60))) +# Grace period восстановления активного звонка после обрыва участника. +HUB_CALL_RECONNECT_GRACE_SECONDS = int(os.environ.get("HUB_CALL_RECONNECT_GRACE_SECONDS", str(60))) +if ( + HUB_CALL_INVITE_TTL_SECONDS <= 0 + or HUB_CALL_ACCESS_TTL_SECONDS <= 0 + or HUB_CALL_CONNECT_GRACE_SECONDS <= 0 + or HUB_CALL_RECONNECT_GRACE_SECONDS <= 0 +): + raise ImproperlyConfigured("HUB call token TTL values must be positive") + +# ICE-серверы для WebRTC (SPEC-HUB-0013 §10): direct-first через STUN, TURN как +# fallback. Формат URL через запятую (stun:host:port / turn:host:3478?transport=udp). +HUB_CALL_STUN_URLS = env_list("HUB_CALL_STUN_URLS", []) +# TURN (Coturn, SPEC-HUB-0013 §11): backend выдаёт краткоживущие REST-credentials +# по общему static-auth-secret. Пусто локально -> только STUN/direct ICE. +HUB_CALL_TURN_URLS = env_list("HUB_CALL_TURN_URLS", []) +HUB_CALL_TURN_SECRET = os.environ.get("HUB_CALL_TURN_SECRET", "") +HUB_CALL_TURN_TTL_SECONDS = int(os.environ.get("HUB_CALL_TURN_TTL_SECONDS", str(60 * 60))) +if HUB_CALL_TURN_TTL_SECONDS <= 0: + raise ImproperlyConfigured("HUB_CALL_TURN_TTL_SECONDS must be positive") +STORAGES = { + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + "staticfiles": { + # В тестах manifest-хранилище требует прогнанного collectstatic, + # поэтому используем обычное хранилище без манифеста. + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage" + if TESTING + else "whitenoise.storage.CompressedManifestStaticFilesStorage", + }, +} +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# Лимиты на чувствительные эндпоинты (брутфорс/злоупотребление). В тестах отключены. +_THROTTLE_RATES = { + "login": "10/min", + "password_reset": "5/min", + "totp": "10/min", + "call_invite": "30/min", + # Страница звонка поллит состояние по access token — лимит с запасом. + "call_access": "120/min", +} +if TESTING: + _THROTTLE_RATES = {scope: None for scope in _THROTTLE_RATES} + +REST_FRAMEWORK = { + "DEFAULT_RENDERER_CLASSES": ["rest_framework.renderers.JSONRenderer"], + "DEFAULT_PARSER_CLASSES": ["rest_framework.parsers.JSONParser"], + "DEFAULT_AUTHENTICATION_CLASSES": ["rest_framework.authentication.SessionAuthentication"], + "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], + "EXCEPTION_HANDLER": "hub_platform.api.exceptions.api_exception_handler", + "DEFAULT_THROTTLE_RATES": _THROTTLE_RATES, +} + +CORS_ALLOWED_ORIGINS = env_list( + "HUB_CORS_ALLOWED_ORIGINS", + ["http://localhost:5173", "http://localhost:5174", "http://localhost:5175"], +) + +# Конкретное значение задаёт surface settings. Middleware не добавляет header, +# если policy пуста (например, в узком техническом тесте). +HUB_CONTENT_SECURITY_POLICY = "" + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "structured": { + "format": ( + "%(asctime)s %(levelname)s %(name)s %(message)s " + "correlation_id=%(correlation_id)s" + ) + } + }, + "filters": { + "correlation_id": {"()": "hub_platform.events.logging.CorrelationIdLogFilter"} + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "structured", + "filters": ["correlation_id"], + } + }, + "root": {"handlers": ["console"], "level": "INFO"}, +} diff --git a/apps/backend/hub_backend/settings_env.py b/apps/backend/hub_backend/settings_env.py new file mode 100644 index 0000000..42c3356 --- /dev/null +++ b/apps/backend/hub_backend/settings_env.py @@ -0,0 +1,15 @@ +import os + + +def env_bool(name: str, default: bool = False) -> bool: + value = os.environ.get(name) + if value is None: + return default + return value.lower() in {"1", "true", "yes", "on"} + + +def env_list(name: str, default: list[str]) -> list[str]: + value = os.environ.get(name) + if value is None: + return default + return [item.strip() for item in value.split(",") if item.strip()] diff --git a/apps/backend/hub_backend/settings_platform.py b/apps/backend/hub_backend/settings_platform.py new file mode 100644 index 0000000..a49ec65 --- /dev/null +++ b/apps/backend/hub_backend/settings_platform.py @@ -0,0 +1,45 @@ +# ruff: noqa: F403,F405 +import os + +from django.core.exceptions import ImproperlyConfigured + +from hub_backend.settings_base import * + +HUB_RUNTIME_SURFACE = "platform" +ROOT_URLCONF = "hub_backend.urls_platform" +ASGI_APPLICATION = "hub_backend.asgi_platform.application" +WSGI_APPLICATION = "hub_backend.wsgi_platform.application" + +_platform_hosts = os.environ.get("CUSTOCRM_PLATFORM_ALLOWED_HOSTS", "") +if not DEBUG and not TESTING and not _platform_hosts: + raise ImproperlyConfigured( + "CUSTOCRM_PLATFORM_ALLOWED_HOSTS is required for the platform surface" + ) +ALLOWED_HOSTS = env_list( + "CUSTOCRM_PLATFORM_ALLOWED_HOSTS", + ["localhost", "127.0.0.1", "platform.localhost"], +) +CSRF_TRUSTED_ORIGINS = env_list("CUSTOCRM_PLATFORM_CSRF_TRUSTED_ORIGINS", []) +CORS_ALLOWED_ORIGINS = [] + +SESSION_COOKIE_NAME = os.environ.get( + "CUSTOCRM_PLATFORM_SESSION_COOKIE_NAME", + "__Host-custocrm-platform-session" + if SESSION_COOKIE_SECURE + else "custocrm_platform_session", +) +CSRF_COOKIE_NAME = os.environ.get( + "CUSTOCRM_PLATFORM_CSRF_COOKIE_NAME", + "__Host-custocrm-platform-csrf" + if CSRF_COOKIE_SECURE + else "custocrm_platform_csrftoken", +) +SESSION_COOKIE_DOMAIN = None +CSRF_COOKIE_DOMAIN = None +SESSION_COOKIE_PATH = "/" +CSRF_COOKIE_PATH = "/" + +HUB_CONTENT_SECURITY_POLICY = os.environ.get( + "CUSTOCRM_PLATFORM_CSP", + "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'", +) diff --git a/apps/backend/hub_backend/test_surfaces.py b/apps/backend/hub_backend/test_surfaces.py new file mode 100644 index 0000000..45d68de --- /dev/null +++ b/apps/backend/hub_backend/test_surfaces.py @@ -0,0 +1,43 @@ +from importlib import import_module + +from django.test import SimpleTestCase, override_settings + + +class RuntimeSurfaceRouteTests(SimpleTestCase): + @override_settings(ROOT_URLCONF="hub_backend.urls_app") + def test_app_exposes_tenant_api_but_not_admin(self) -> None: + self.assertEqual(self.client.get("/api/v1/health/live/").status_code, 200) + self.assertNotEqual(self.client.get("/api/v1/auth/session/").status_code, 404) + self.assertEqual(self.client.get("/admin/login/").status_code, 404) + + @override_settings(ROOT_URLCONF="hub_backend.urls_platform") + def test_platform_exposes_only_platform_health(self) -> None: + self.assertEqual(self.client.get("/api/v1/health/live/").status_code, 200) + self.assertEqual(self.client.get("/api/v1/auth/session/").status_code, 404) + self.assertEqual(self.client.get("/chat-widget.js").status_code, 404) + self.assertEqual(self.client.get("/admin/login/").status_code, 404) + + @override_settings(ROOT_URLCONF="hub_backend.urls_admin") + def test_admin_exposes_only_django_admin(self) -> None: + self.assertEqual(self.client.get("/admin/login/").status_code, 200) + self.assertEqual(self.client.get("/api/v1/health/live/").status_code, 404) + self.assertEqual(self.client.get("/api/v1/auth/session/").status_code, 404) + + +class RuntimeSurfaceSettingsTests(SimpleTestCase): + def test_cookie_names_are_isolated_and_host_only(self) -> None: + surfaces = [ + import_module("hub_backend.settings_app"), + import_module("hub_backend.settings_platform"), + import_module("hub_backend.settings_admin"), + ] + + self.assertEqual(len({surface.SESSION_COOKIE_NAME for surface in surfaces}), 3) + self.assertEqual(len({surface.CSRF_COOKIE_NAME for surface in surfaces}), 3) + self.assertTrue(all(surface.SESSION_COOKIE_DOMAIN is None for surface in surfaces)) + self.assertTrue(all(surface.CSRF_COOKIE_DOMAIN is None for surface in surfaces)) + + def test_admin_hosts_are_loopback_only(self) -> None: + admin_settings = import_module("hub_backend.settings_admin") + + self.assertEqual(admin_settings.ALLOWED_HOSTS, ["127.0.0.1", "localhost"]) diff --git a/apps/backend/hub_backend/urls.py b/apps/backend/hub_backend/urls.py index 10f6f54..bdbd032 100644 --- a/apps/backend/hub_backend/urls.py +++ b/apps/backend/hub_backend/urls.py @@ -1,28 +1,3 @@ -from django.contrib import admin -from django.urls import include, path -from rest_framework.schemas import get_schema_view +"""Backward-compatible app URLConf for management commands and tests.""" -from hub_platform.webchat.views import WidgetLoaderView - -urlpatterns = [ - path("admin/", admin.site.urls), - path("chat-widget.js", WidgetLoaderView.as_view(), name="chat-widget-loader"), - path("api/v1/schema/", get_schema_view(title="CustoCRM API", version="0.1.0"), name="openapi-schema"), - path("api/v1/auth/", include("hub_platform.identity.auth_urls")), - path("api/v1/company/", include("hub_platform.identity.company_urls")), - path("api/v1/company/", include("hub_platform.products.urls")), - path("api/v1/employees/", include("hub_platform.identity.employee_urls")), - path("api/v1/access-profiles/", include("hub_platform.identity.access_urls")), - path("api/v1/ai/", include("hub_platform.ai.urls")), - path("api/v1/integrations/", include("hub_platform.integrations.urls")), - path("api/v1/channels/", include("hub_platform.channels.urls")), - path("api/v1/conversations/", include("hub_platform.conversations.urls")), - path("api/v1/orders/", include("hub_platform.orders.urls")), - path("api/v1/sales/", include("hub_platform.sales.urls")), - path("api/v1/product-sales/", include("hub_platform.sales.product_sales_urls")), - path("api/v1/notifications/", include("hub_platform.notifications.urls")), - path("api/v1/webchat/", include("hub_platform.webchat.urls")), - path("api/v1/health/", include("hub_platform.health.urls")), - path("api/v1/support/", include("hub_platform.support.urls")), - path("api/v1/calls/", include("hub_platform.calls.urls")), -] +from hub_backend.urls_app import urlpatterns # noqa: F401 diff --git a/apps/backend/hub_backend/urls_admin.py b/apps/backend/hub_backend/urls_admin.py new file mode 100644 index 0000000..083932c --- /dev/null +++ b/apps/backend/hub_backend/urls_admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin +from django.urls import path + +urlpatterns = [ + path("admin/", admin.site.urls), +] diff --git a/apps/backend/hub_backend/urls_app.py b/apps/backend/hub_backend/urls_app.py new file mode 100644 index 0000000..55c7dc8 --- /dev/null +++ b/apps/backend/hub_backend/urls_app.py @@ -0,0 +1,30 @@ +from django.urls import include, path +from rest_framework.schemas import get_schema_view + +from hub_platform.webchat.views import WidgetLoaderView + +urlpatterns = [ + path("chat-widget.js", WidgetLoaderView.as_view(), name="chat-widget-loader"), + path( + "api/v1/schema/", + get_schema_view(title="CustoCRM API", version="0.1.0"), + name="openapi-schema", + ), + path("api/v1/auth/", include("hub_platform.identity.auth_urls")), + path("api/v1/company/", include("hub_platform.identity.company_urls")), + path("api/v1/company/", include("hub_platform.products.urls")), + path("api/v1/employees/", include("hub_platform.identity.employee_urls")), + path("api/v1/access-profiles/", include("hub_platform.identity.access_urls")), + path("api/v1/ai/", include("hub_platform.ai.urls")), + path("api/v1/integrations/", include("hub_platform.integrations.urls")), + path("api/v1/channels/", include("hub_platform.channels.urls")), + path("api/v1/conversations/", include("hub_platform.conversations.urls")), + path("api/v1/orders/", include("hub_platform.orders.urls")), + path("api/v1/sales/", include("hub_platform.sales.urls")), + path("api/v1/product-sales/", include("hub_platform.sales.product_sales_urls")), + path("api/v1/notifications/", include("hub_platform.notifications.urls")), + path("api/v1/webchat/", include("hub_platform.webchat.urls")), + path("api/v1/health/", include("hub_platform.health.urls")), + path("api/v1/support/", include("hub_platform.support.urls")), + path("api/v1/calls/", include("hub_platform.calls.urls")), +] diff --git a/apps/backend/hub_backend/urls_platform.py b/apps/backend/hub_backend/urls_platform.py new file mode 100644 index 0000000..2294469 --- /dev/null +++ b/apps/backend/hub_backend/urls_platform.py @@ -0,0 +1,5 @@ +from django.urls import include, path + +urlpatterns = [ + path("api/v1/health/", include("hub_platform.health.urls")), +] diff --git a/apps/backend/hub_backend/wsgi.py b/apps/backend/hub_backend/wsgi.py index 4000b8b..9a603c3 100644 --- a/apps/backend/hub_backend/wsgi.py +++ b/apps/backend/hub_backend/wsgi.py @@ -1,7 +1,3 @@ -import os +"""Backward-compatible app WSGI entrypoint.""" -from django.core.wsgi import get_wsgi_application - -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hub_backend.settings") - -application = get_wsgi_application() +from hub_backend.wsgi_app import application # noqa: F401 diff --git a/apps/backend/hub_backend/wsgi_admin.py b/apps/backend/hub_backend/wsgi_admin.py new file mode 100644 index 0000000..1c18a96 --- /dev/null +++ b/apps/backend/hub_backend/wsgi_admin.py @@ -0,0 +1,7 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hub_backend.settings_admin") + +application = get_wsgi_application() diff --git a/apps/backend/hub_backend/wsgi_app.py b/apps/backend/hub_backend/wsgi_app.py new file mode 100644 index 0000000..d8b9bfe --- /dev/null +++ b/apps/backend/hub_backend/wsgi_app.py @@ -0,0 +1,7 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hub_backend.settings_app") + +application = get_wsgi_application() diff --git a/apps/backend/hub_backend/wsgi_platform.py b/apps/backend/hub_backend/wsgi_platform.py new file mode 100644 index 0000000..a3fe509 --- /dev/null +++ b/apps/backend/hub_backend/wsgi_platform.py @@ -0,0 +1,7 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hub_backend.settings_platform") + +application = get_wsgi_application() diff --git a/apps/backend/hub_platform/health/tests.py b/apps/backend/hub_platform/health/tests.py index 77b548b..bd8a698 100644 --- a/apps/backend/hub_platform/health/tests.py +++ b/apps/backend/hub_platform/health/tests.py @@ -7,3 +7,4 @@ class HealthTests(TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["status"], "ok") + self.assertEqual(response.json()["surface"], "app") diff --git a/apps/backend/hub_platform/health/views.py b/apps/backend/hub_platform/health/views.py index c919d8a..0465932 100644 --- a/apps/backend/hub_platform/health/views.py +++ b/apps/backend/hub_platform/health/views.py @@ -1,10 +1,14 @@ +from django.conf import settings from django.core.cache import cache from django.db import connection from django.http import JsonResponse def live(request): - return JsonResponse({"status": "ok", "service": "hub-backend"}) + surface = settings.HUB_RUNTIME_SURFACE + return JsonResponse( + {"status": "ok", "service": f"custocrm-{surface}", "surface": surface} + ) def ready(request): @@ -18,4 +22,7 @@ def ready(request): checks["redis"] = cache.get("healthcheck") == "ok" status_code = 200 if all(checks.values()) else 503 - return JsonResponse({"status": "ok" if status_code == 200 else "degraded", "checks": checks}, status=status_code) + return JsonResponse( + {"status": "ok" if status_code == 200 else "degraded", "checks": checks}, + status=status_code, + ) diff --git a/apps/backend/hub_platform/http/middleware.py b/apps/backend/hub_platform/http/middleware.py index f4cb99a..d65db9c 100644 --- a/apps/backend/hub_platform/http/middleware.py +++ b/apps/backend/hub_platform/http/middleware.py @@ -9,7 +9,11 @@ class LocalCorsMiddleware: self.get_response = get_response def __call__(self, request: HttpRequest) -> HttpResponse: - if request.method == "OPTIONS" and request.headers.get("Origin") in settings.CORS_ALLOWED_ORIGINS: + is_allowed_preflight = ( + request.method == "OPTIONS" + and request.headers.get("Origin") in settings.CORS_ALLOWED_ORIGINS + ) + if is_allowed_preflight: response = HttpResponse(status=204) else: response = self.get_response(request) @@ -21,3 +25,17 @@ class LocalCorsMiddleware: response["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS" response["Vary"] = "Origin" return response + + +class ContentSecurityPolicyMiddleware: + """Apply the CSP selected by the current runtime surface.""" + + def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None: + self.get_response = get_response + + def __call__(self, request: HttpRequest) -> HttpResponse: + response = self.get_response(request) + policy = settings.HUB_CONTENT_SECURITY_POLICY + if policy and not response.has_header("Content-Security-Policy"): + response["Content-Security-Policy"] = policy + return response diff --git a/apps/backend/hub_platform/http/tests.py b/apps/backend/hub_platform/http/tests.py index efae868..6be7651 100644 --- a/apps/backend/hub_platform/http/tests.py +++ b/apps/backend/hub_platform/http/tests.py @@ -19,3 +19,17 @@ class LocalCorsMiddlewareTests(TestCase): self.assertEqual(response.status_code, 204) self.assertEqual(response["Access-Control-Allow-Origin"], "http://localhost:5173") + + +class ContentSecurityPolicyMiddlewareTests(TestCase): + @override_settings(HUB_CONTENT_SECURITY_POLICY="default-src 'none'") + def test_surface_policy_is_applied(self) -> None: + response = self.client.get("/api/v1/health/live/") + + self.assertEqual(response["Content-Security-Policy"], "default-src 'none'") + + @override_settings(HUB_CONTENT_SECURITY_POLICY="") + def test_empty_policy_does_not_add_header(self) -> None: + response = self.client.get("/api/v1/health/live/") + + self.assertFalse(response.has_header("Content-Security-Policy")) diff --git a/apps/backend/hub_platform/identity/tests.py b/apps/backend/hub_platform/identity/tests.py index fdab74b..b5abd96 100644 --- a/apps/backend/hub_platform/identity/tests.py +++ b/apps/backend/hub_platform/identity/tests.py @@ -1,10 +1,11 @@ import json from unittest import mock +from django.conf import settings from django.contrib.auth.tokens import default_token_generator +from django.contrib.sessions.backends.db import SessionStore from django.core import mail from django.test import Client, TestCase, override_settings -from django.contrib.sessions.backends.db import SessionStore from django.utils.encoding import force_bytes from django.utils.http import urlsafe_base64_encode from rest_framework.test import APIClient @@ -142,7 +143,7 @@ class AuthEndpointTests(TestCase): response = self.client.get("/api/v1/auth/session/") self.assertEqual(response.status_code, 200) - self.assertIn("csrftoken", response.cookies) + self.assertIn(settings.CSRF_COOKIE_NAME, response.cookies) def test_login_rejects_invalid_password(self) -> None: response = self.client.post( @@ -397,6 +398,7 @@ class AuthEndpointTests(TestCase): self.assertTrue(verify_response.json()["authenticated"]) +@override_settings(ROOT_URLCONF="hub_backend.urls_admin") class DjangoAdminTests(TestCase): def test_bootstrapped_owner_can_access_django_admin(self) -> None: bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password") diff --git a/compose.dev.yaml b/compose.dev.yaml index 9b5c95a..e0c3d50 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -21,15 +21,15 @@ services: context: . dockerfile: apps/backend/Dockerfile - backend: + backend-app: build: context: . dockerfile: apps/backend/Dockerfile command: > sh -c "python manage.py migrate --noinput && - uvicorn hub_backend.asgi:application --host 0.0.0.0 --port 8000 --reload" + uvicorn hub_backend.asgi_app:application --host 0.0.0.0 --port 8000 --reload" ports: - - "${BACKEND_PORT:-8010}:8000" + - "${BACKEND_APP_PORT:-8010}:8000" volumes: - ./apps/backend:/app/apps/backend depends_on: @@ -40,6 +40,24 @@ services: redis: condition: service_healthy + backend-platform: + build: + context: . + dockerfile: apps/backend/Dockerfile + command: uvicorn hub_backend.asgi_platform:application --host 0.0.0.0 --port 8000 --reload + ports: + - "${BACKEND_PLATFORM_PORT:-8011}:8000" + volumes: + - ./apps/backend:/app/apps/backend + + backend-admin: + build: + context: . + dockerfile: apps/backend/Dockerfile + command: uvicorn hub_backend.asgi_admin:application --host 0.0.0.0 --port 8000 --reload + volumes: + - ./apps/backend:/app/apps/backend + worker: build: context: . @@ -84,7 +102,9 @@ services: volumes: - ./deploy/nginx/local.conf:/etc/nginx/conf.d/default.conf:ro depends_on: - backend: + backend-app: + condition: service_healthy + backend-platform: condition: service_healthy frontend: condition: service_started diff --git a/compose.yaml b/compose.yaml index 34d3265..541830b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -56,13 +56,13 @@ services: redis: condition: service_healthy - backend: + backend-app: image: ${CUSTOCRM_BACKEND_IMAGE:-custocrm-backend:dev} restart: unless-stopped env_file: - ${CUSTOCRM_INSTANCE_DIR:-.}/.env command: > - sh -c "gunicorn hub_backend.asgi:application + sh -c "gunicorn hub_backend.asgi_app:application --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 --workers $${HUB_GUNICORN_WORKERS:-3} @@ -83,13 +83,67 @@ services: "CMD", "python", "-c", - "import os, urllib.request; req = urllib.request.Request('http://127.0.0.1:8000/api/v1/health/live/', headers={'Host': os.environ.get('HUB_HEALTHCHECK_HOST', 'localhost'), 'X-Forwarded-Proto': 'https'}); urllib.request.urlopen(req, timeout=3)", + "import os, urllib.request; req = urllib.request.Request('http://127.0.0.1:8000/api/v1/health/live/', headers={'Host': os.environ.get('CUSTOCRM_APP_HEALTHCHECK_HOST', 'app.localhost'), 'X-Forwarded-Proto': 'https'}); urllib.request.urlopen(req, timeout=3)", ] interval: 10s timeout: 5s retries: 10 start_period: 20s + backend-platform: + image: ${CUSTOCRM_BACKEND_IMAGE:-custocrm-backend:dev} + restart: unless-stopped + env_file: + - ${CUSTOCRM_INSTANCE_DIR:-.}/.env + command: > + sh -c "gunicorn hub_backend.asgi_platform:application + --worker-class uvicorn.workers.UvicornWorker + --bind 0.0.0.0:8000 + --workers $${HUB_GUNICORN_WORKERS:-3} + --timeout $${HUB_GUNICORN_TIMEOUT:-60}" + depends_on: + init: + condition: service_completed_successfully + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "import os, urllib.request; req = urllib.request.Request('http://127.0.0.1:8000/api/v1/health/live/', headers={'Host': os.environ.get('CUSTOCRM_PLATFORM_HEALTHCHECK_HOST', 'platform.localhost'), 'X-Forwarded-Proto': 'https'}); urllib.request.urlopen(req, timeout=3)", + ] + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s + + # Django admin не подключён к gateway. Единственный host bind — loopback; + # удалённый доступ допускается только через SSH tunnel (ADR-HUB-0031). + backend-admin: + image: ${CUSTOCRM_BACKEND_IMAGE:-custocrm-backend:dev} + restart: unless-stopped + env_file: + - ${CUSTOCRM_INSTANCE_DIR:-.}/.env + command: > + sh -c "gunicorn hub_backend.asgi_admin:application + --worker-class uvicorn.workers.UvicornWorker + --bind 0.0.0.0:8000 + --workers $${HUB_GUNICORN_WORKERS:-2} + --timeout $${HUB_GUNICORN_TIMEOUT:-60}" + ports: + - "127.0.0.1:${CUSTOCRM_ADMIN_PORT:-18001}:8000" + depends_on: + init: + condition: service_completed_successfully + postgres: + condition: service_healthy + redis: + condition: service_healthy + worker: image: ${CUSTOCRM_BACKEND_IMAGE:-custocrm-backend:dev} restart: unless-stopped @@ -99,18 +153,18 @@ services: volumes: - ${CUSTOCRM_INSTANCE_DIR:-.}/data/media:/app/apps/backend/media depends_on: - backend: + backend-app: condition: service_healthy # Frontend: один nginx-образ со static-сборкой internal-ui и web-chat, - # внутренняя маршрутизация (/api/, /ws/, /admin/, /static/, /chat-widget.js, + # внутренняя маршрутизация (/api/, /ws/, /chat-widget.js, # /chat/, /calls/) — в deploy/nginx/frontend.production.conf. Не публикует # host-порт: public boundary — gateway (Caddy). frontend: image: ${CUSTOCRM_FRONTEND_IMAGE:-custocrm-frontend:dev} restart: unless-stopped depends_on: - backend: + backend-app: condition: service_healthy # Gateway: единственный HTTP/HTTPS public boundary (ADR-HUB-0028 §gateway). @@ -121,7 +175,8 @@ services: image: ${CUSTOCRM_GATEWAY_IMAGE:-caddy:2.8.4} restart: unless-stopped environment: - CUSTOCRM_DOMAIN: ${CUSTOCRM_DOMAIN:?CUSTOCRM_DOMAIN is required} + CUSTOCRM_APP_DOMAIN: ${CUSTOCRM_APP_DOMAIN:?CUSTOCRM_APP_DOMAIN is required} + CUSTOCRM_PLATFORM_DOMAIN: ${CUSTOCRM_PLATFORM_DOMAIN:?CUSTOCRM_PLATFORM_DOMAIN is required} CUSTOCRM_ACME_EMAIL: ${CUSTOCRM_ACME_EMAIL:?CUSTOCRM_ACME_EMAIL is required} ports: - "${CUSTOCRM_WEB_LISTENING_IP:-0.0.0.0}:80:80" @@ -133,7 +188,9 @@ services: depends_on: frontend: condition: service_started - backend: + backend-app: + condition: service_healthy + backend-platform: condition: service_healthy # Coturn — медиа-relay для TURN fallback (SPEC-HUB-0013 §11). diff --git a/deploy/cli/lib/deploy.sh b/deploy/cli/lib/deploy.sh index 522944c..e1b7a11 100644 --- a/deploy/cli/lib/deploy.sh +++ b/deploy/cli/lib/deploy.sh @@ -21,14 +21,16 @@ cmd_deploy() { run_compose run --rm init || die "deploy: init (migrate) failed" 1 log "deploy: starting application services" - local app_services=(backend worker frontend gateway) + local app_services=(backend-app backend-platform backend-admin worker frontend gateway) if profile_enabled calls; then app_services+=(coturn) fi run_compose up -d "${app_services[@]}" || die "deploy: application start failed" 1 log "deploy: waiting for application health" - _wait_healthy backend 90 || die "deploy: backend did not become healthy" 1 + _wait_healthy backend-app 90 || die "deploy: app backend did not become healthy" 1 + _wait_healthy backend-platform 90 || die "deploy: platform backend did not become healthy" 1 + _wait_running backend-admin 30 || die "deploy: admin backend did not start" 1 _wait_running frontend 30 || die "deploy: frontend did not start" 1 _wait_running gateway 30 || die "deploy: gateway did not start" 1 if profile_enabled calls; then @@ -50,9 +52,15 @@ _deploy_validate() { verify_release_checksums || return 1 validate_release_image_refs || return 1 - local domain - domain="$(env_get "$(instance_env_file)" CUSTOCRM_DOMAIN)" - [[ -n "$domain" ]] || { log_err "CUSTOCRM_DOMAIN not set"; return 1; } + local app_domain platform_domain + app_domain="$(env_get "$(instance_env_file)" CUSTOCRM_APP_DOMAIN)" + platform_domain="$(env_get "$(instance_env_file)" CUSTOCRM_PLATFORM_DOMAIN)" + [[ -n "$app_domain" ]] || { log_err "CUSTOCRM_APP_DOMAIN not set"; return 1; } + [[ -n "$platform_domain" ]] || { log_err "CUSTOCRM_PLATFORM_DOMAIN not set"; return 1; } + [[ "$app_domain" != "$platform_domain" ]] || { + log_err "app and platform domains must be distinct" + return 1 + } if profile_enabled calls; then validate_calls_network_boundary || return 1 @@ -101,20 +109,31 @@ _first_json_service_state() { } _smoke() { - local domain - domain="$(env_get "$(instance_env_file)" CUSTOCRM_DOMAIN)" + local app_domain platform_domain + app_domain="$(env_get "$(instance_env_file)" CUSTOCRM_APP_DOMAIN)" + platform_domain="$(env_get "$(instance_env_file)" CUSTOCRM_PLATFORM_DOMAIN)" - run_compose exec -T backend python - <<'PY' >/dev/null 2>&1 || { + run_compose exec -T backend-app python - <<'PY' >/dev/null 2>&1 || { import os import urllib.error import urllib.request -health_host = os.environ.get("HUB_HEALTHCHECK_HOST") or os.environ["CUSTOCRM_DOMAIN"] -health_request = urllib.request.Request( +app_domain = os.environ["CUSTOCRM_APP_DOMAIN"] +platform_domain = os.environ["CUSTOCRM_PLATFORM_DOMAIN"] +app_health_host = os.environ.get("CUSTOCRM_APP_HEALTHCHECK_HOST") or app_domain +platform_health_host = os.environ.get("CUSTOCRM_PLATFORM_HEALTHCHECK_HOST") or platform_domain +app_health_request = urllib.request.Request( "http://127.0.0.1:8000/api/v1/health/ready/", - headers={"Host": health_host, "X-Forwarded-Proto": "https"}, + headers={"Host": app_health_host, "X-Forwarded-Proto": "https"}, ) -with urllib.request.urlopen(health_request, timeout=5) as response: +with urllib.request.urlopen(app_health_request, timeout=5) as response: + assert response.status == 200 + +platform_health_request = urllib.request.Request( + "http://backend-platform:8000/api/v1/health/ready/", + headers={"Host": platform_health_host, "X-Forwarded-Proto": "https"}, +) +with urllib.request.urlopen(platform_health_request, timeout=5) as response: assert response.status == 200 with urllib.request.urlopen("http://frontend/", timeout=5) as response: @@ -125,28 +144,29 @@ class NoRedirect(urllib.request.HTTPRedirectHandler): return None opener = urllib.request.build_opener(NoRedirect) -domain = os.environ["CUSTOCRM_DOMAIN"] -gateway_request = urllib.request.Request("http://gateway/", headers={"Host": domain}) -try: - opener.open(gateway_request, timeout=5) -except urllib.error.HTTPError as error: - assert error.code in {301, 302, 303, 307, 308} - assert error.headers.get("Location", "").startswith(f"https://{domain}") -else: - raise AssertionError("gateway did not redirect HTTP to HTTPS") +for domain in (app_domain, platform_domain): + gateway_request = urllib.request.Request("http://gateway/", headers={"Host": domain}) + try: + opener.open(gateway_request, timeout=5) + except urllib.error.HTTPError as error: + assert error.code in {301, 302, 303, 307, 308} + assert error.headers.get("Location", "").startswith(f"https://{domain}") + else: + raise AssertionError(f"gateway did not redirect HTTP to HTTPS for {domain}") PY - log_err "smoke: internal backend/frontend/gateway checks failed" + log_err "smoke: internal app/platform/frontend/gateway checks failed" return 1 } - log_ok "smoke: backend, frontend and gateway" + log_ok "smoke: app, platform, frontend and gateway" if command -v curl >/dev/null 2>&1; then - local code - code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 "https://$domain/" 2>/dev/null || true)" - if [[ "$code" =~ ^(200|30[12378])$ ]]; then - log_ok "smoke: public HTTPS endpoint" + local app_code platform_code + app_code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 "https://$app_domain/" 2>/dev/null || true)" + platform_code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 "https://$platform_domain/api/v1/health/live/" 2>/dev/null || true)" + if [[ "$app_code" =~ ^(200|30[12378])$ ]] && [[ "$platform_code" == "200" ]]; then + log_ok "smoke: public app and platform HTTPS endpoints" else - log_warn "smoke: public HTTPS endpoint is not reachable yet (HTTP $code)" + log_warn "smoke: public HTTPS endpoints are not reachable yet (app $app_code, platform $platform_code)" fi fi } diff --git a/deploy/cli/lib/doctor.sh b/deploy/cli/lib/doctor.sh index be0f909..fa853ba 100644 --- a/deploy/cli/lib/doctor.sh +++ b/deploy/cli/lib/doctor.sh @@ -85,12 +85,23 @@ cmd_doctor() { _doctor_report 0 "release image references are invalid" fi - local domain - domain="$(env_get "$(instance_env_file)" CUSTOCRM_DOMAIN)" - if [[ -n "$domain" ]]; then - _doctor_report 1 "CUSTOCRM_DOMAIN set: $domain" + local app_domain platform_domain + app_domain="$(env_get "$(instance_env_file)" CUSTOCRM_APP_DOMAIN)" + platform_domain="$(env_get "$(instance_env_file)" CUSTOCRM_PLATFORM_DOMAIN)" + if [[ -n "$app_domain" ]]; then + _doctor_report 1 "CUSTOCRM_APP_DOMAIN set: $app_domain" else - _doctor_report 0 "CUSTOCRM_DOMAIN not set" + _doctor_report 0 "CUSTOCRM_APP_DOMAIN not set" + fi + if [[ -n "$platform_domain" ]]; then + _doctor_report 1 "CUSTOCRM_PLATFORM_DOMAIN set: $platform_domain" + else + _doctor_report 0 "CUSTOCRM_PLATFORM_DOMAIN not set" + fi + if [[ -n "$app_domain" ]] && [[ "$app_domain" != "$platform_domain" ]]; then + _doctor_report 1 "app and platform domains are distinct" + else + _doctor_report 0 "app and platform domains must be distinct" fi local acme_email diff --git a/deploy/nginx/frontend.production.conf b/deploy/nginx/frontend.production.conf index bfafb2b..11e58e8 100644 --- a/deploy/nginx/frontend.production.conf +++ b/deploy/nginx/frontend.production.conf @@ -11,7 +11,7 @@ server { add_header Referrer-Policy strict-origin-when-cross-origin always; location = /chat-widget.js { - proxy_pass http://backend:8000/chat-widget.js; + proxy_pass http://backend-app:8000/chat-widget.js; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -20,7 +20,7 @@ server { } location /api/ { - proxy_pass http://backend:8000/api/; + proxy_pass http://backend-app:8000/api/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -30,7 +30,7 @@ server { # WebSocket signaling звонков (SPEC-HUB-0013 §9). location /ws/ { - proxy_pass http://backend:8000/ws/; + proxy_pass http://backend-app:8000/ws/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -41,27 +41,17 @@ server { proxy_read_timeout 3600s; } - location /admin/ { - add_header X-Frame-Options SAMEORIGIN always; - - proxy_pass http://backend:8000/admin/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + # Defense in depth: Django admin never crosses the app public boundary. + location = /admin { + return 404; } - location /static/ { - proxy_pass http://backend:8000/static/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + location /admin/ { + return 404; } location /chat/ { + add_header Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors *" always; try_files $uri $uri/ /chat/index.html; } @@ -69,11 +59,13 @@ server { # статический entry call.html из сборки web-chat, token разбирает браузер. # Камера/микрофон разрешены только этой странице. location /calls/ { + add_header Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always; add_header Permissions-Policy "camera=(self), microphone=(self)" always; try_files /chat/call.html =404; } location / { + add_header Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always; add_header X-Frame-Options SAMEORIGIN always; try_files $uri $uri/ /index.html; } diff --git a/deploy/nginx/local.conf b/deploy/nginx/local.conf index a472e7e..e13189a 100644 --- a/deploy/nginx/local.conf +++ b/deploy/nginx/local.conf @@ -1,17 +1,24 @@ server { listen 80; - server_name hub.localhost localhost; + server_name app.localhost localhost; + + location = /admin { + return 404; + } + + location /admin/ { + return 404; + } location /api/ { - proxy_pass http://backend:8000; + proxy_pass http://backend-app:8000; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } - # WebSocket signaling звонков (SPEC-HUB-0013 §9). location /ws/ { - proxy_pass http://backend:8000; + proxy_pass http://backend-app:8000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header Upgrade $http_upgrade; @@ -19,32 +26,49 @@ server { proxy_read_timeout 3600s; } - # Web Chat widget loader (served from the hub domain). location = /chat-widget.js { - proxy_pass http://backend:8000; + proxy_pass http://backend-app:8000; proxy_set_header Host $host; } - # Web Chat panel (iframe content) — Vite base /chat/. location /chat/ { + add_header Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval'; object-src 'none'; base-uri 'self'; frame-ancestors *" always; proxy_pass http://web-chat:5175; proxy_set_header Host $host; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } - # Клиентская страница звонка /calls/ (SPEC-HUB-0013 §7.2): - # отдаётся из web-chat app (entry call.html), token разбирает браузер. location /calls/ { + add_header Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always; + add_header Permissions-Policy "camera=(self), microphone=(self)" always; rewrite ^ /chat/call.html break; proxy_pass http://web-chat:5175; proxy_set_header Host $host; } location / { + add_header Content-Security-Policy "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always; + add_header X-Frame-Options SAMEORIGIN always; proxy_pass http://frontend:5173; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } + +server { + listen 80; + server_name platform.localhost; + + location /api/ { + proxy_pass http://backend-platform:8000; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + return 404; + } +} diff --git a/env.example b/env.example index 4f9d03e..bc0ae1e 100644 --- a/env.example +++ b/env.example @@ -6,8 +6,10 @@ COMPOSE_PROJECT_NAME=custocrm # --- Instance identity / gateway --- -CUSTOCRM_DOMAIN=hub.example.com +CUSTOCRM_APP_DOMAIN=app.example.com +CUSTOCRM_PLATFORM_DOMAIN=platform.example.com CUSTOCRM_ACME_EMAIL=admin@example.com +CUSTOCRM_ADMIN_PORT=18001 # IP web-gateway. Оставьте 0.0.0.0 без profile calls. Для calls укажите # отдельный публичный IP, отличный от HUB_TURN_LISTENING_IP. CUSTOCRM_WEB_LISTENING_IP=0.0.0.0 @@ -21,13 +23,15 @@ HUB_SECRET_KEY=change-me-long-random-secret # Шифрование секретов в БД (Fernet-ключ): Fernet.generate_key(). HUB_FIELD_ENCRYPTION_KEY= -HUB_ALLOWED_HOSTS=hub.example.com -HUB_CSRF_TRUSTED_ORIGINS=https://hub.example.com -HUB_CORS_ALLOWED_ORIGINS=https://hub.example.com -INTERNAL_UI_BASE_URL=https://hub.example.com -# Host header для Docker healthcheck внутри backend-контейнера. -# Должен присутствовать в HUB_ALLOWED_HOSTS. -HUB_HEALTHCHECK_HOST=hub.example.com +CUSTOCRM_APP_ALLOWED_HOSTS=app.example.com +CUSTOCRM_APP_CSRF_TRUSTED_ORIGINS=https://app.example.com +CUSTOCRM_PLATFORM_ALLOWED_HOSTS=platform.example.com +CUSTOCRM_PLATFORM_CSRF_TRUSTED_ORIGINS=https://platform.example.com +HUB_CORS_ALLOWED_ORIGINS=https://app.example.com +INTERNAL_UI_BASE_URL=https://app.example.com +# Host headers для Docker healthchecks должны входить в surface ALLOWED_HOSTS. +CUSTOCRM_APP_HEALTHCHECK_HOST=app.example.com +CUSTOCRM_PLATFORM_HEALTHCHECK_HOST=platform.example.com # Транспортная безопасность (вне HUB_DEBUG включается автоматически). HUB_COOKIE_SECURE=true @@ -56,7 +60,7 @@ HUB_CALL_TURN_URLS= # Общий static-auth-secret между backend и coturn. HUB_CALL_TURN_SECRET= HUB_CALL_TURN_TTL_SECONDS=3600 -HUB_CALL_TURN_REALM=hub.example.com +HUB_CALL_TURN_REALM=app.example.com # Публичный IP coturn listener/relay. ОТДЕЛЬНЫЙ от web IP, чтобы TURN занял 443. HUB_TURN_EXTERNAL_IP= HUB_TURN_LISTENING_IP= diff --git a/scripts/check.ps1 b/scripts/check.ps1 index 8ef3b99..456aff3 100644 --- a/scripts/check.ps1 +++ b/scripts/check.ps1 @@ -1,6 +1,6 @@ $ErrorActionPreference = "Stop" -docker compose run --rm backend pytest -docker compose run --rm internal-ui npm run test -docker compose run --rm internal-ui npm run typecheck +docker compose run --rm backend-app pytest +docker compose run --rm frontend npm run test +docker compose run --rm frontend npm run typecheck docker compose run --rm web-chat npm run typecheck diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index 304b626..35babde 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -89,7 +89,7 @@ if [[ "$1" == "compose" ]]; then svc="${@: -1}" echo "{\"Service\":\"$svc\",\"Health\":\"healthy\"}" exit 0 ;; - *"exec -T backend"*) exit 0 ;; + *"exec -T backend-app"*) exit 0 ;; *"logs"*) exit 0 ;; *) exit 0 ;; esac @@ -144,7 +144,8 @@ def fake_env(tmp_path: Path): def write_env(**overrides) -> Path: lines = { "COMPOSE_PROJECT_NAME": "custocrm_test", - "CUSTOCRM_DOMAIN": "hub.test", + "CUSTOCRM_APP_DOMAIN": "app.test", + "CUSTOCRM_PLATFORM_DOMAIN": "platform.test", "CUSTOCRM_ACME_EMAIL": "admin@test", "HUB_SECRET_KEY": "test-secret-not-default", "HUB_FIELD_ENCRYPTION_KEY": "", @@ -154,8 +155,10 @@ def fake_env(tmp_path: Path): "POSTGRES_HOST": "postgres", "POSTGRES_PORT": "5432", "REDIS_URL": "redis://redis:6379/0", - "HUB_ALLOWED_HOSTS": "hub.test", - "HUB_HEALTHCHECK_HOST": "hub.test", + "CUSTOCRM_APP_ALLOWED_HOSTS": "app.test", + "CUSTOCRM_PLATFORM_ALLOWED_HOSTS": "platform.test", + "CUSTOCRM_APP_HEALTHCHECK_HOST": "app.test", + "CUSTOCRM_PLATFORM_HEALTHCHECK_HOST": "platform.test", } lines.update(overrides) body = "".join(f"{k}={v}\n" for k, v in lines.items()) diff --git a/tests/cli/test_custocrm_cli.py b/tests/cli/test_custocrm_cli.py index c6a80a9..71b596c 100644 --- a/tests/cli/test_custocrm_cli.py +++ b/tests/cli/test_custocrm_cli.py @@ -64,8 +64,11 @@ def test_deploy_success_orders_canonical_workflow(fake_env): idx_pull = _index_of(log, " pull") idx_infra = _index_of(log, " up -d postgres redis") idx_init = _index_of(log, "run --rm init") - idx_app = _index_of(log, " up -d backend worker frontend gateway") - idx_exec = _index_of(log, "exec -T backend") + idx_app = _index_of( + log, + " up -d backend-app backend-platform backend-admin worker frontend gateway", + ) + idx_exec = _index_of(log, "exec -T backend-app") assert idx_pull < idx_infra < idx_init < idx_app < idx_exec, joined assert "seed_hub_initial_data" not in joined # init не запускает Edevs seed @@ -89,7 +92,10 @@ def test_deploy_includes_coturn_when_calls_profile_active(fake_env): r = _run(fake_env, "deploy", "--non-interactive") assert r.returncode == 0, r.stderr joined = "\n".join(_log_lines(fake_env)) - assert "up -d backend worker frontend gateway coturn" in joined + assert ( + "up -d backend-app backend-platform backend-admin worker frontend gateway coturn" + in joined + ) def test_deploy_rejects_shared_web_and_turn_ip(fake_env): @@ -208,6 +214,17 @@ def test_doctor_fails_when_acme_email_missing(fake_env): assert "CUSTOCRM_ACME_EMAIL" in result.stderr +def test_doctor_fails_when_surface_domains_match(fake_env): + fake_env.write_env(CUSTOCRM_PLATFORM_DOMAIN="app.test") + fake_env.install_docker() + fake_env.install_flock(held=False) + + result = _run(fake_env, "doctor") + + assert result.returncode == 1 + assert "must be distinct" in result.stderr + + # --------------------------------------------------------------------------- # status # ---------------------------------------------------------------------------