✨ feat(platform): isolate runtime surfaces

This commit is contained in:
Andrey committed 2026-07-14 22:23:15 +03:00
1 parent eac4aede40
commit dd849fa2d4
38 files changed
+898 -456

No files matched your search

+12 -4
View File
@@ -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 аргумент не нужен.
+17 -5
View File
@@ -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}
}
}
+10 -7
View File
@@ -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
+2 -1
View File
@@ -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"]
+2 -20
View File
@@ -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
+7
View File
@@ -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()
+19
View File
@@ -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()
+4 -289
View File
@@ -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'",
)
+42
View File
@@ -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'",
)
+290
View File
@@ -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"},
}
+15
View File
@@ -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'",
)
+43
View File
@@ -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"])
+2 -27
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
from django.contrib import admin
from django.urls import path
urlpatterns = [
path("admin/", admin.site.urls),
]
+30
View File
@@ -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")),
]
+2 -6
View File
@@ -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
+7
View File
@@ -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()
+7
View File
@@ -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")
+9 -2
View File
@@ -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,
)
+19 -1
View File
@@ -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
+14
View File
@@ -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"))
+4 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+16 -5
View File
@@ -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 -19
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+7 -4
View File
@@ -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())
+20 -3
View File
@@ -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
# ---------------------------------------------------------------------------