mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
✨ feat(platform): isolate runtime surfaces
This commit is contained in:
1 parent
eac4aede40
commit
dd849fa2d4
38 files changed
+898
-456
No files matched your search
+12
-4
@@ -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 аргумент не нужен.
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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),
|
||||
}
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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 <no-reply@edevs.tech>")
|
||||
|
||||
# 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
|
||||
@@ -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'",
|
||||
)
|
||||
@@ -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'",
|
||||
)
|
||||
@@ -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 <no-reply@edevs.tech>")
|
||||
|
||||
# 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"},
|
||||
}
|
||||
@@ -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()]
|
||||
@@ -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'",
|
||||
)
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.contrib import admin
|
||||
from django.urls import path
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
]
|
||||
@@ -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")),
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.urls import include, path
|
||||
|
||||
urlpatterns = [
|
||||
path("api/v1/health/", include("hub_platform.health.urls")),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
@@ -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")
|
||||
|
||||
+24
-4
@@ -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
|
||||
|
||||
+65
-8
@@ -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).
|
||||
|
||||
+48
-28
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+33
-9
@@ -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/<invite-token> (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;
|
||||
}
|
||||
}
|
||||
+13
-9
@@ -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=
|
||||
|
||||
+3
-3
@@ -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
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user