feat: один прод-compose со всеми моделями из готовых образов

This commit is contained in:
gglamer 2026-09-28 21:14:38 +00:00
commit a694702d8f
44 changed files with 1126 additions and 2075 deletions

View file

@ -1,8 +1,7 @@
# Скопировать в .env. Файл в .gitignore: ключи в репозиторий не едут.
BIND_HOST=127.0.0.1
# Необязателен: `docker compose up` работает без .env. Скопировать в .env, чтобы
# переопределить порты, пароли или запустить backend нативно. Файл в .gitignore.
POSTGRES_PORT=5432
# Development profile defaults to lct. Before production/offline deployment,
# generate a unique URL-safe value: python -c "import secrets; print(secrets.token_urlsafe(32))"
# Пусто — пароль lct: БД слушает только loopback хоста.
POSTGRES_PASSWORD=
BACKEND_PORT=8000
# Per-backend PostgreSQL connection pool (cluster default budget: 60 total).
@ -11,14 +10,7 @@ DB_POOL_MAX_OVERFLOW=10
# Stable node identity; set a distinct value for each explicit backend node.
BACKEND_NODE_ID=backend-01
FRONTEND_PORT=5173
TLS_PORT=5443
TLS_CERT_DIR=./.local/tls
SIP_PORT=5060
SIPS_PORT=5061
SIP_WS_PORT=8088
RTP_PORT_START=10000
RTP_PORT_END=10099
SIP_TLS_DIR=./.local/sip-tls
# Если пусто, Asterisk создаст стойкие случайные пароли в volume sipdata.
SIP_6001_PASSWORD=
SIP_6002_PASSWORD=
@ -26,25 +18,22 @@ SIP_6003_PASSWORD=
SIP_6101_PASSWORD=
SIP_6102_PASSWORD=
SIP_6103_PASSWORD=
# IP/DNS сервера, который WebRTC-клиенты видят в локальной сети. При запуске
# только на той же машине оставьте 127.0.0.1; RTP-порты должны отображаться 1:1.
SIP_EXTERNAL_MEDIA_ADDRESS=127.0.0.1
DATABASE_URL=postgresql+asyncpg://lct:lct@localhost:5432/lct
# Два локальных OpenAI-совместимых сервера (например, llama-server).
# Два локальных OpenAI-совместимых сервера (например, llama-server) для
# нативного backend. В Compose адреса заданы именами сервисов llm-qwen/llm-vikhr.
# Адреса должны быть loopback: OFFLINE=true не допускает внешний API.
LLM_PROVIDER=local
LLM_BASE_URL=http://127.0.0.1:18080/v1
# Адреса тех же процессов из backend-контейнера Docker Desktop (macOS/Windows).
DOCKER_LLM_BASE_URL=http://host.docker.internal:18080/v1
LLM_API_KEY=
LLM_MODEL_CALLER=Qwen3-1.7B
LLM_CONTROL_BASE_URL=http://127.0.0.1:18081/v1
DOCKER_LLM_CONTROL_BASE_URL=http://host.docker.internal:18081/v1
LLM_MODEL_CONTROL=Vikhr-1B
GRAMMAR_LLM_ENABLED=true
DIALOGUE_MODEL_MODE=dialogue
JUDGE_TEMPERATURE=0
# Потоки llama-server в контейнерах llm-qwen и llm-vikhr.
LLM_THREADS=4
# Голосовой контур
MODELS_DIR=models

View file

@ -9,59 +9,48 @@ UV ?= uv
# cd backend && uv sync --extra dev --extra voice
COMPOSE ?= docker compose
DEMO_PORT ?= 8112
# Веса, без которых `make images` не соберёт backend и llm: COPY в Dockerfile
# упал бы на первом же отсутствующем файле, здесь — список всех сразу понятнее.
IMAGE_WEIGHTS := qwen3-1.7b/Qwen3-1.7B-Q8_0.gguf vikhr-1b/Vikhr-Llama-3.2-1B-Q4_K_M.gguf \
gigaam-v3-onnx/config.json gigaam-v3-onnx/v3_vocab.txt gigaam-v3-onnx/v3_rnnt_encoder.int8.onnx \
gigaam-v3-onnx/v3_rnnt_decoder.int8.onnx gigaam-v3-onnx/v3_rnnt_joint.int8.onnx \
silero-vad/silero_vad.onnx silero-tts/v5_ru.pt \
e5-small/config.json e5-small/model_quantized.onnx e5-small/tokenizer.json
.DEFAULT_GOAL := help
help: ## Список целей
@grep -hE '^[a-z-]+:.*##' $(MAKEFILE_LIST) | sed 's/:.*##/\t/' | expand -t22
dev: ## Поднять стенд: postgres + backend --reload + frontend
$(COMPOSE) up --build
dev: ## Поднять полный стенд из готовых образов: http://127.0.0.1:5173
$(COMPOSE) up
offline: ## Поднять карточки/ДДС из заранее собранных локальных образов, без скачивания
offline: ## То же без обращения к registry: образы уже скачаны или загружены
$(COMPOSE) up --pull never --no-build
tls: ## Поднять полный стенд по HTTPS/WSS с локальным сертификатом
$(COMPOSE) -f docker-compose.yml -f docker-compose.tls.yml up --build
images: ## Собрать 4 образа стенда (docker-bake.hcl); веса — из backend/models
@for f in $(IMAGE_WEIGHTS); do test -s backend/models/$$f \
|| { echo "нет весов backend/models/$$f — make local-models и make models"; exit 2; }; done
docker buildx bake
offline-tls: ## Поднять HTTPS/WSS из уже собранных образов без сети
$(COMPOSE) -f docker-compose.yml -f docker-compose.tls.yml up --pull never --no-build
sip: ## Поднять только локальный Asterisk SIP/VoIP
$(COMPOSE) up -d sip
production: ## Защищённый запуск из исходников: уникальный DB-пароль обязателен
bash scripts/validate_production_env.sh
$(COMPOSE) -f docker-compose.yml -f docker-compose.production.yml -f docker-compose.tls.yml up --build
offline-production: ## Защищённый запуск готовых образов без pull/build; требуется уникальный DB-пароль
bash scripts/validate_production_env.sh
$(COMPOSE) -f docker-compose.yml -f docker-compose.production.yml -f docker-compose.tls.yml up --pull never --no-build
production-config-check: ## Проверить, что production Compose требует и передаёт заданный DB-пароль
bash scripts/check_production_compose.sh
production-postgres-check: ## Проверить межконтейнерную PostgreSQL-аутентификацию на временной БД
bash scripts/test_production_postgres.sh
sip: ## Собрать и поднять локальный Asterisk SIP/VoIP
$(COMPOSE) -f docker-compose.yml -f docker-compose.sip.yml up --build -d sip
offline-sip: ## Поднять SIP из уже собранного образа без сети
$(COMPOSE) -f docker-compose.yml -f docker-compose.sip.yml up --pull never --no-build -d sip
offline-sip: ## Поднять SIP из уже скачанного образа без сети
$(COMPOSE) up --pull never --no-build -d sip
sip-credentials: ## Показать локальные пароли софтфонов 6001–6003 и WebRTC 6101–6103
$(COMPOSE) -f docker-compose.yml -f docker-compose.sip.yml exec -T sip cat /var/lib/lct-sip/credentials.env
$(COMPOSE) exec -T sip cat /var/lib/lct-sip/credentials.env
sip-test: ## Проверить регистрацию, звонок, RTP и задержку (args="--rtp-host-offset 1000")
python3 scripts/smoke_sip.py --port $(or $(SIP_PORT),5060) $(args)
webrtc-test: ## Проверить два браузерных SIP-клиента, аудио и запись Asterisk
python3 scripts/smoke_webrtc_browser.py \
--frontend-url https://127.0.0.1:$(or $(TLS_PORT),5443) \
--frontend-url http://127.0.0.1:$(or $(FRONTEND_PORT),5173) \
--backend-url http://127.0.0.1:$(or $(BACKEND_PORT),8000) \
--database-url postgresql+asyncpg://lct:lct@127.0.0.1:$(or $(POSTGRES_PORT),5432)/lct $(args)
webrtc-test-isolated: ## Полный двухбраузерный WebRTC smoke на отдельной БД, портах и volume
bash scripts/test_webrtc_isolated.sh
down: ## Погасить стенд
$(COMPOSE) down
@ -143,4 +132,4 @@ demo: ## Поднять основной стенд ДДС: база, готов
demo-lite: ## Локальная демонстрация карточки/ДДС без Docker, БД, голоса и внешней сети
cd backend && UV_CACHE_DIR=/tmp/lct-uv-demo-cache OFFLINE=true VOICE_ENABLED=false DEV_AUTH_BYPASS=true DEMO_NO_DB=true $(UV) run --offline --no-sync uvicorn app.main:app --host 127.0.0.1 --port $(DEMO_PORT) --workers 1
.PHONY: help dev offline tls offline-tls production offline-production production-config-check production-postgres-check sip offline-sip sip-credentials sip-test webrtc-test webrtc-test-isolated down back front types users users-docker backup test test-db test-voice typecheck test-llm test-llm-local lesson llm-check latency migrate revision models local-models local-llm local-stt seed repl pregen demo demo-lite
.PHONY: help dev offline images sip offline-sip sip-credentials sip-test webrtc-test down back front types users users-docker backup test test-db test-voice typecheck test-llm test-llm-local lesson llm-check latency migrate revision models local-models local-llm local-stt seed repl pregen demo demo-lite

View file

@ -2,5 +2,12 @@
__pycache__/
*.pyc
.pytest_cache/
.ruff_cache/
models/
tests/
# Образ публикуется в registry: записи звонков, дампы БД и локальные ключи
# разработчика в него попасть не должны.
recordings/
backups/
.env
.env.*

View file

@ -1,4 +1,4 @@
FROM python:3.11-slim
FROM python:3.11-slim AS base
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
@ -12,11 +12,32 @@ RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-co
# Зависимости — из pyproject.toml, а не отдельным списком: ручной список разошёлся
# с проектом, и бэкенд в контейнере упал на первом же новом пакете (num2words).
# Голосовой контур (группа voice: torch, onnx-asr) в образ не входит: под WSL
# модели запускаются нативно — так они и мерялись (docs/LATENCY.md).
# Голосовой контур (группа voice: torch, onnx-asr) в стадию base не входит —
# его вместе с весами добавляет стадия voice ниже.
COPY pyproject.toml ./
RUN uv pip install --system -r pyproject.toml
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# Прод-образ стенда: голосовой контур, сценарии и веса внутри, bind-mount'ов нет.
# Веса и сценарии приходят именованными контекстами сборки (docker-bake.hcl,
# docker-compose.yml): models=backend/models, scenarios=scenarios,
# licenses=deploy/licenses. В Dockerfile ничего не скачивается — хост Silero
# ненадёжен; нет файла — COPY падает с его именем, `make images` проверяет заранее.
# Whisper в образ не входит: единственный проверенный вживую STT — GigaAM.
FROM base AS voice
# torch только CPU-сборка: обычный PyPI тянет CUDA на гигабайты (pyproject).
RUN uv pip install --system --extra-index-url https://download.pytorch.org/whl/cpu \
'torch>=2.2' 'onnx-asr>=0.6' 'scipy>=1.11'
COPY --from=scenarios . /scenarios
# Из GigaAM — только RNNT int8: CTC-вариант стенд не загружает.
COPY --from=models gigaam-v3-onnx/config.json gigaam-v3-onnx/v3_vocab.txt \
gigaam-v3-onnx/v3_rnnt_encoder.int8.onnx gigaam-v3-onnx/v3_rnnt_decoder.int8.onnx \
gigaam-v3-onnx/v3_rnnt_joint.int8.onnx /app/models/gigaam-v3-onnx/
COPY --from=models silero-vad/silero_vad.onnx /app/models/silero-vad/
COPY --from=models silero-tts/v5_ru.pt /app/models/silero-tts/
COPY --from=models e5-small/config.json e5-small/model_quantized.onnx e5-small/tokenizer.json \
/app/models/e5-small/
COPY --from=licenses . /licenses

View file

@ -57,33 +57,18 @@ def test_websocket_origin_policy(headers, scope, expected):
def test_nginx_proxies_preserve_external_host_for_websocket_origin_validation():
project_root = Path(__file__).resolve().parents[2]
for config in ("nginx.conf.template", "nginx.tls.conf.template"):
text = (project_root / "frontend" / config).read_text(encoding="utf-8")
match = re.search(r"location /ws/ \{(.*?)^ \}", text, re.MULTILINE | re.DOTALL)
assert match is not None, f"{config}: missing WebSocket proxy block"
websocket_location = match.group(1)
assert "proxy_set_header X-Forwarded-Host $http_host;" in websocket_location
tls = (project_root / "frontend" / "nginx.tls.conf.template").read_text(encoding="utf-8")
match = re.search(r"location /ws/ \{(.*?)^ \}", tls, re.MULTILINE | re.DOTALL)
assert match is not None
tls_websocket_location = match.group(1)
assert "proxy_set_header X-Forwarded-Proto https;" in tls_websocket_location
text = (project_root / "frontend" / "nginx.conf.template").read_text(encoding="utf-8")
match = re.search(r"location /ws/ \{(.*?)^ \}", text, re.MULTILINE | re.DOTALL)
assert match is not None, "missing WebSocket proxy block"
assert "proxy_set_header X-Forwarded-Host $http_host;" in match.group(1)
def test_cluster_nginx_pins_all_session_channels_and_session_apis_to_one_hash_key():
def test_nginx_routes_webrtc_phone_to_asterisk():
project_root = Path(__file__).resolve().parents[2]
for config in ("nginx.cluster.conf.template", "nginx.cluster.tls.conf.template"):
text = (project_root / "frontend" / config).read_text(encoding="utf-8")
assert "hash $session_route_key consistent;" in text
assert "server backend:8000" in text and "server backend-b:8000" in text
assert re.search(
r"~\^/ws/\(\?:control\|call\|observe\|station\)/\(\[0-9a-fA-F-\]\{36\}\)",
text,
), f"{config}: all WebSocket channels must extract the same session UUID"
assert re.search(
r"~\^/api/sessions/\(\[0-9a-fA-F-\]\{36\}\)", text
), f"{config}: session REST endpoints must use the same routing key"
assert text.count("proxy_pass http://backend_cluster;") == 2
text = (project_root / "frontend" / "nginx.conf.template").read_text(encoding="utf-8")
match = re.search(r"location = /sip-ws \{(.*?)^ \}", text, re.MULTILINE | re.DOTALL)
assert match is not None, "WebRTC-телефон ходит на same-origin /sip-ws"
assert "proxy_pass http://$sip_backend:8088/ws;" in match.group(1)
@pytest.fixture

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 GigaChat Team
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,70 @@
LLAMA 3.2 COMMUNITY LICENSE AGREEMENT
Llama 3.2 Version Release Date: September 25, 2024
“Agreement” means the terms and conditions for use, reproduction, distribution and modification of the Llama Materials set forth herein.
“Documentation” means the specifications, manuals and documentation accompanying Llama 3.2 distributed by Meta at https://www.llama.com/docs/overview.
“Licensee” or “you” means you, or your employer or any other person or entity (if you are entering into this Agreement on such person or entity’s behalf), of the age required under applicable laws, rules or regulations to provide legal consent and that has legal authority to bind your employer or such other person or entity if you are entering in this Agreement on their behalf.
“Llama 3.2” means the foundational large language models and software and algorithms, including machine-learning model code, trained model weights, inference-enabling code, training-enabling code, fine-tuning enabling code and other elements of the foregoing distributed by Meta at https://www.llama.com/llama-downloads.
“Llama Materials” means, collectively, Meta’s proprietary Llama 3.2 and Documentation (and any portion thereof) made available under this Agreement.
“Meta” or “we” means Meta Platforms Ireland Limited (if you are located in or, if you are an entity, your principal place of business is in the EEA or Switzerland) and Meta Platforms, Inc. (if you are located outside of the EEA or Switzerland).
By clicking “I Accept” below or by using or distributing any portion or element of the Llama Materials, you agree to be bound by this Agreement.
1. License Rights and Redistribution.
a. Grant of Rights. You are granted a non-exclusive, worldwide, non-transferable and royalty-free limited license under Meta’s intellectual property or other rights owned by Meta embodied in the Llama Materials to use, reproduce, distribute, copy, create derivative works of, and make modifications to the Llama Materials.
b. Redistribution and Use.
i. If you distribute or make available the Llama Materials (or any derivative works thereof), or a product or service (including another AI model) that contains any of them, you shall (A) provide a copy of this Agreement with any such Llama Materials; and (B) prominently display “Built with Llama” on a related website, user interface, blogpost, about page, or product documentation. If you use the Llama Materials or any outputs or results of the Llama Materials to create, train, fine tune, or otherwise improve an AI model, which is distributed or made available, you shall also include “Llama” at the beginning of any such AI model name.
ii. If you receive Llama Materials, or any derivative works thereof, from a Licensee as part of an integrated end user product, then Section 2 of this Agreement will not apply to you.
iii. You must retain in all copies of the Llama Materials that you distribute the following attribution notice within a “Notice” text file distributed as a part of such copies: “Llama 3.2 is licensed under the Llama 3.2 Community License, Copyright © Meta Platforms, Inc. All Rights Reserved.”
iv. Your use of the Llama Materials must comply with applicable laws and regulations (including trade compliance laws and regulations) and adhere to the Acceptable Use Policy for the Llama Materials (available at https://www.llama.com/llama3_2/use-policy), which is hereby incorporated by reference into this Agreement.
2. Additional Commercial Terms. If, on the Llama 3.2 version release date, the monthly active users of the products or services made available by or for Licensee, or Licensee’s affiliates, is greater than 700 million monthly active users in the preceding calendar month, you must request a license from Meta, which Meta may grant to you in its sole discretion, and you are not authorized to exercise any of the rights under this Agreement unless or until Meta otherwise expressly grants you such rights.
3. Disclaimer of Warranty. UNLESS REQUIRED BY APPLICABLE LAW, THE LLAMA MATERIALS AND ANY OUTPUT AND RESULTS THEREFROM ARE PROVIDED ON AN “AS IS” BASIS, WITHOUT WARRANTIES OF ANY KIND, AND META DISCLAIMS ALL WARRANTIES OF ANY KIND, BOTH EXPRESS AND IMPLIED, INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. YOU ARE SOLELY RESPONSIBLE FOR DETERMINING THE APPROPRIATENESS OF USING OR REDISTRIBUTING THE LLAMA MATERIALS AND ASSUME ANY RISKS ASSOCIATED WITH YOUR USE OF THE LLAMA MATERIALS AND ANY OUTPUT AND RESULTS.
4. Limitation of Liability. IN NO EVENT WILL META OR ITS AFFILIATES BE LIABLE UNDER ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, TORT, NEGLIGENCE, PRODUCTS LIABILITY, OR OTHERWISE, ARISING OUT OF THIS AGREEMENT, FOR ANY LOST PROFITS OR ANY INDIRECT, SPECIAL, CONSEQUENTIAL, INCIDENTAL, EXEMPLARY OR PUNITIVE DAMAGES, EVEN IF META OR ITS AFFILIATES HAVE BEEN ADVISED OF THE POSSIBILITY OF ANY OF THE FOREGOING.
5. Intellectual Property.
a. No trademark licenses are granted under this Agreement, and in connection with the Llama Materials, neither Meta nor Licensee may use any name or mark owned by or associated with the other or any of its affiliates, except as required for reasonable and customary use in describing and redistributing the Llama Materials or as set forth in this Section 5(a). Meta hereby grants you a license to use “Llama” (the “Mark”) solely as required to comply with the last sentence of Section 1.b.i. You will comply with Meta’s brand guidelines (currently accessible at https://about.meta.com/brand/resources/meta/company-brand/). All goodwill arising out of your use of the Mark will inure to the benefit of Meta.
b. Subject to Meta’s ownership of Llama Materials and derivatives made by or for Meta, with respect to any derivative works and modifications of the Llama Materials that are made by you, as between you and Meta, you are and will be the owner of such derivative works and modifications.
c. If you institute litigation or other proceedings against Meta or any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Llama Materials or Llama 3.2 outputs or results, or any portion of any of the foregoing, constitutes infringement of intellectual property or other rights owned or licensable by you, then any licenses granted to you under this Agreement shall terminate as of the date such litigation or claim is filed or instituted. You will indemnify and hold harmless Meta from and against any claim by any third party arising out of or related to your use or distribution of the Llama Materials.
6. Term and Termination. The term of this Agreement will commence upon your acceptance of this Agreement or access to the Llama Materials and will continue in full force and effect until terminated in accordance with the terms and conditions herein. Meta may terminate this Agreement if you are in breach of any term or condition of this Agreement. Upon termination of this Agreement, you shall delete and cease use of the Llama Materials. Sections 3, 4 and 7 shall survive the termination of this Agreement.
7. Governing Law and Jurisdiction. This Agreement will be governed and construed under the laws of the State of California without regard to choice of law principles, and the UN Convention on Contracts for the International Sale of Goods does not apply to this Agreement. The courts of California shall have exclusive jurisdiction of any dispute arising out of this Agreement.

View file

@ -0,0 +1,52 @@
**Llama 3.2** **Acceptable Use Policy**
Meta is committed to promoting safe and fair use of its tools and features, including Llama 3.2. If you access or use Llama 3.2, you agree to this Acceptable Use Policy (“**Policy**”). The most recent copy of this policy can be found at [https://www.llama.com/llama3_2/use-policy](https://www.llama.com/llama3_2/use-policy).
**Prohibited Uses**
We want everyone to use Llama 3.2 safely and responsibly. You agree you will not use, or allow others to use, Llama 3.2 to:
1. Violate the law or others’ rights, including to:
1. Engage in, promote, generate, contribute to, encourage, plan, incite, or further illegal or unlawful activity or content, such as:
1. Violence or terrorism
2. Exploitation or harm to children, including the solicitation, creation, acquisition, or dissemination of child exploitative content or failure to report Child Sexual Abuse Material
3. Human trafficking, exploitation, and sexual violence
4. The illegal distribution of information or materials to minors, including obscene materials, or failure to employ legally required age-gating in connection with such information or materials.
5. Sexual solicitation
6. Any other criminal activity
1. Engage in, promote, incite, or facilitate the harassment, abuse, threatening, or bullying of individuals or groups of individuals
2. Engage in, promote, incite, or facilitate discrimination or other unlawful or harmful conduct in the provision of employment, employment benefits, credit, housing, other economic benefits, or other essential goods and services
3. Engage in the unauthorized or unlicensed practice of any profession including, but not limited to, financial, legal, medical/health, or related professional practices
4. Collect, process, disclose, generate, or infer private or sensitive information about individuals, including information about individuals’ identity, health, or demographic information, unless you have obtained the right to do so in accordance with applicable law
5. Engage in or facilitate any action or generate any content that infringes, misappropriates, or otherwise violates any third-party rights, including the outputs or results of any products or services using the Llama Materials
6. Create, generate, or facilitate the creation of malicious code, malware, computer viruses or do anything else that could disable, overburden, interfere with or impair the proper working, integrity, operation or appearance of a website or computer system
7. Engage in any action, or facilitate any action, to intentionally circumvent or remove usage restrictions or other safety measures, or to enable functionality disabled by Meta 
2. Engage in, promote, incite, facilitate, or assist in the planning or development of activities that present a risk of death or bodily harm to individuals, including use of Llama 3.2 related to the following:
8. Military, warfare, nuclear industries or applications, espionage, use for materials or activities that are subject to the International Traffic Arms Regulations (ITAR) maintained by the United States Department of State or to the U.S. Biological Weapons Anti-Terrorism Act of 1989 or the Chemical Weapons Convention Implementation Act of 1997
9. Guns and illegal weapons (including weapon development)
10. Illegal drugs and regulated/controlled substances
11. Operation of critical infrastructure, transportation technologies, or heavy machinery
12. Self-harm or harm to others, including suicide, cutting, and eating disorders
13. Any content intended to incite or promote violence, abuse, or any infliction of bodily harm to an individual
3. Intentionally deceive or mislead others, including use of Llama 3.2 related to the following:
14. Generating, promoting, or furthering fraud or the creation or promotion of disinformation
15. Generating, promoting, or furthering defamatory content, including the creation of defamatory statements, images, or other content
16. Generating, promoting, or further distributing spam
17. Impersonating another individual without consent, authorization, or legal right
18. Representing that the use of Llama 3.2 or outputs are human-generated
19. Generating or facilitating false online engagement, including fake reviews and other means of fake online engagement 
4. Fail to appropriately disclose to end users any known dangers of your AI system
5. Interact with third party tools, models, or software designed to generate unlawful content or engage in unlawful or harmful conduct and/or represent that the outputs of such tools, models, or software are associated with Meta or Llama 3.2
With respect to any multimodal models included in Llama 3.2, the rights granted under Section 1(a) of the Llama 3.2 Community License Agreement are not being granted to you if you are an individual domiciled in, or a company with a principal place of business in, the European Union. This restriction does not apply to end users of a product or service that incorporates any such multimodal models.
Please report any violation of this Policy, software “bug,” or other problems that could lead to a violation of this Policy through one of the following means:
* Reporting issues with the model: [https://github.com/meta-llama/llama-models/issues](https://l.workplace.com/l.php?u=https%3A%2F%2Fgithub.com%2Fmeta-llama%2Fllama-models%2Fissues&h=AT0qV8W9BFT6NwihiOHRuKYQM_UnkzN_NmHMy91OT55gkLpgi4kQupHUl0ssR4dQsIQ8n3tfd0vtkobvsEvt1l4Ic6GXI2EeuHV8N08OG2WnbAmm0FL4ObkazC6G_256vN0lN9DsykCvCqGZ)
* Reporting risky content generated by the model: [developers.facebook.com/llama_output_feedback](http://developers.facebook.com/llama_output_feedback)
* Reporting bugs and security concerns: [facebook.com/whitehat/info](http://facebook.com/whitehat/info)
* Reporting violations of the Acceptable Use Policy or unlicensed uses of Llama 3.2: LlamaUseReport@meta.com

19
deploy/licenses/NOTICE.md Normal file
View file

@ -0,0 +1,19 @@
# Веса моделей в образах стенда
Веса не изменялись: в образы скопированы файлы, скачанные из репозиториев авторов.
| Модель | Файлы | Образ | Лицензия | Текст |
|---|---|---|---|---|
| Qwen3-1.7B, GGUF Q8_0 | `Qwen3-1.7B-Q8_0.gguf` | `lct-hack-llm` | Apache 2.0 | `Qwen3-LICENSE.txt` |
| Vikhr-Llama-3.2-1B-Instruct, GGUF Q4_K_M | `Vikhr-Llama-3.2-1B-Q4_K_M.gguf` | `lct-hack-llm` | Llama 3.2 Community License | `Llama-3.2-LICENSE.txt`, `Llama-3.2-USE-POLICY.md` |
| GigaAM v3 RNNT, ONNX int8 | `gigaam-v3-onnx/` | `lct-hack-backend` | MIT | `GigaAM-LICENSE.txt` |
| Silero TTS v5 ru | `silero-tts/v5_ru.pt` | `lct-hack-backend` | CC BY-NC-SA 4.0 | `Silero-TTS-LICENSE.txt` |
| Silero VAD | `silero-vad/silero_vad.onnx` | `lct-hack-backend` | MIT | `Silero-VAD-LICENSE.txt` |
| multilingual-e5-small, ONNX int8 | `e5-small/` | `lct-hack-backend` | MIT | карточка `intfloat/multilingual-e5-small` |
**Built with Llama.** Vikhr-Llama-3.2-1B-Instruct — дообучение Llama 3.2 1B Instruct.
Llama 3.2 is licensed under the Llama 3.2 Community License, Copyright © Meta Platforms, Inc.
All Rights Reserved.
Silero TTS распространяется только для некоммерческого использования (CC BY-NC-SA 4.0):
стенд — учебный тренажёр, коммерческое применение требует отдельной лицензии Silero.

View file

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2024 Alibaba Cloud
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -0,0 +1,437 @@
Attribution-NonCommercial-ShareAlike 4.0 International
=======================================================================
Creative Commons Corporation ("Creative Commons") is not a law firm and
does not provide legal services or legal advice. Distribution of
Creative Commons public licenses does not create a lawyer-client or
other relationship. Creative Commons makes its licenses and related
information available on an "as-is" basis. Creative Commons gives no
warranties regarding its licenses, any material licensed under their
terms and conditions, or any related information. Creative Commons
disclaims all liability for damages resulting from their use to the
fullest extent possible.
Using Creative Commons Public Licenses
Creative Commons public licenses provide a standard set of terms and
conditions that creators and other rights holders may use to share
original works of authorship and other material subject to copyright
and certain other rights specified in the public license below. The
following considerations are for informational purposes only, are not
exhaustive, and do not form part of our licenses.
Considerations for licensors: Our public licenses are
intended for use by those authorized to give the public
permission to use material in ways otherwise restricted by
copyright and certain other rights. Our licenses are
irrevocable. Licensors should read and understand the terms
and conditions of the license they choose before applying it.
Licensors should also secure all rights necessary before
applying our licenses so that the public can reuse the
material as expected. Licensors should clearly mark any
material not subject to the license. This includes other CC-
licensed material, or material used under an exception or
limitation to copyright. More considerations for licensors:
wiki.creativecommons.org/Considerations_for_licensors
Considerations for the public: By using one of our public
licenses, a licensor grants the public permission to use the
licensed material under specified terms and conditions. If
the licensor's permission is not necessary for any reason--for
example, because of any applicable exception or limitation to
copyright--then that use is not regulated by the license. Our
licenses grant only permissions under copyright and certain
other rights that a licensor has authority to grant. Use of
the licensed material may still be restricted for other
reasons, including because others have copyright or other
rights in the material. A licensor may make special requests,
such as asking that all changes be marked or described.
Although not required by our licenses, you are encouraged to
respect those requests where reasonable. More considerations
for the public:
wiki.creativecommons.org/Considerations_for_licensees
=======================================================================
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
Public License
By exercising the Licensed Rights (defined below), You accept and agree
to be bound by the terms and conditions of this Creative Commons
Attribution-NonCommercial-ShareAlike 4.0 International Public License
("Public License"). To the extent this Public License may be
interpreted as a contract, You are granted the Licensed Rights in
consideration of Your acceptance of these terms and conditions, and the
Licensor grants You such rights in consideration of benefits the
Licensor receives from making the Licensed Material available under
these terms and conditions.
Section 1 -- Definitions.
a. Adapted Material means material subject to Copyright and Similar
Rights that is derived from or based upon the Licensed Material
and in which the Licensed Material is translated, altered,
arranged, transformed, or otherwise modified in a manner requiring
permission under the Copyright and Similar Rights held by the
Licensor. For purposes of this Public License, where the Licensed
Material is a musical work, performance, or sound recording,
Adapted Material is always produced where the Licensed Material is
synched in timed relation with a moving image.
b. Adapter's License means the license You apply to Your Copyright
and Similar Rights in Your contributions to Adapted Material in
accordance with the terms and conditions of this Public License.
c. BY-NC-SA Compatible License means a license listed at
creativecommons.org/compatiblelicenses, approved by Creative
Commons as essentially the equivalent of this Public License.
d. Copyright and Similar Rights means copyright and/or similar rights
closely related to copyright including, without limitation,
performance, broadcast, sound recording, and Sui Generis Database
Rights, without regard to how the rights are labeled or
categorized. For purposes of this Public License, the rights
specified in Section 2(b)(1)-(2) are not Copyright and Similar
Rights.
e. Effective Technological Measures means those measures that, in the
absence of proper authority, may not be circumvented under laws
fulfilling obligations under Article 11 of the WIPO Copyright
Treaty adopted on December 20, 1996, and/or similar international
agreements.
f. Exceptions and Limitations means fair use, fair dealing, and/or
any other exception or limitation to Copyright and Similar Rights
that applies to Your use of the Licensed Material.
g. License Elements means the license attributes listed in the name
of a Creative Commons Public License. The License Elements of this
Public License are Attribution, NonCommercial, and ShareAlike.
h. Licensed Material means the artistic or literary work, database,
or other material to which the Licensor applied this Public
License.
i. Licensed Rights means the rights granted to You subject to the
terms and conditions of this Public License, which are limited to
all Copyright and Similar Rights that apply to Your use of the
Licensed Material and that the Licensor has authority to license.
j. Licensor means the individual(s) or entity(ies) granting rights
under this Public License.
k. NonCommercial means not primarily intended for or directed towards
commercial advantage or monetary compensation. For purposes of
this Public License, the exchange of the Licensed Material for
other material subject to Copyright and Similar Rights by digital
file-sharing or similar means is NonCommercial provided there is
no payment of monetary compensation in connection with the
exchange.
l. Share means to provide material to the public by any means or
process that requires permission under the Licensed Rights, such
as reproduction, public display, public performance, distribution,
dissemination, communication, or importation, and to make material
available to the public including in ways that members of the
public may access the material from a place and at a time
individually chosen by them.
m. Sui Generis Database Rights means rights other than copyright
resulting from Directive 96/9/EC of the European Parliament and of
the Council of 11 March 1996 on the legal protection of databases,
as amended and/or succeeded, as well as other essentially
equivalent rights anywhere in the world.
n. You means the individual or entity exercising the Licensed Rights
under this Public License. Your has a corresponding meaning.
Section 2 -- Scope.
a. License grant.
1. Subject to the terms and conditions of this Public License,
the Licensor hereby grants You a worldwide, royalty-free,
non-sublicensable, non-exclusive, irrevocable license to
exercise the Licensed Rights in the Licensed Material to:
a. reproduce and Share the Licensed Material, in whole or
in part, for NonCommercial purposes only; and
b. produce, reproduce, and Share Adapted Material for
NonCommercial purposes only.
2. Exceptions and Limitations. For the avoidance of doubt, where
Exceptions and Limitations apply to Your use, this Public
License does not apply, and You do not need to comply with
its terms and conditions.
3. Term. The term of this Public License is specified in Section
6(a).
4. Media and formats; technical modifications allowed. The
Licensor authorizes You to exercise the Licensed Rights in
all media and formats whether now known or hereafter created,
and to make technical modifications necessary to do so. The
Licensor waives and/or agrees not to assert any right or
authority to forbid You from making technical modifications
necessary to exercise the Licensed Rights, including
technical modifications necessary to circumvent Effective
Technological Measures. For purposes of this Public License,
simply making modifications authorized by this Section 2(a)
(4) never produces Adapted Material.
5. Downstream recipients.
a. Offer from the Licensor -- Licensed Material. Every
recipient of the Licensed Material automatically
receives an offer from the Licensor to exercise the
Licensed Rights under the terms and conditions of this
Public License.
b. Additional offer from the Licensor -- Adapted Material.
Every recipient of Adapted Material from You
automatically receives an offer from the Licensor to
exercise the Licensed Rights in the Adapted Material
under the conditions of the Adapter's License You apply.
c. No downstream restrictions. You may not offer or impose
any additional or different terms or conditions on, or
apply any Effective Technological Measures to, the
Licensed Material if doing so restricts exercise of the
Licensed Rights by any recipient of the Licensed
Material.
6. No endorsement. Nothing in this Public License constitutes or
may be construed as permission to assert or imply that You
are, or that Your use of the Licensed Material is, connected
with, or sponsored, endorsed, or granted official status by,
the Licensor or others designated to receive attribution as
provided in Section 3(a)(1)(A)(i).
b. Other rights.
1. Moral rights, such as the right of integrity, are not
licensed under this Public License, nor are publicity,
privacy, and/or other similar personality rights; however, to
the extent possible, the Licensor waives and/or agrees not to
assert any such rights held by the Licensor to the limited
extent necessary to allow You to exercise the Licensed
Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this
Public License.
3. To the extent possible, the Licensor waives any right to
collect royalties from You for the exercise of the Licensed
Rights, whether directly or through a collecting society
under any voluntary or waivable statutory or compulsory
licensing scheme. In all other cases the Licensor expressly
reserves any right to collect such royalties, including when
the Licensed Material is used other than for NonCommercial
purposes.
Section 3 -- License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the
following conditions.
a. Attribution.
1. If You Share the Licensed Material (including in modified
form), You must:
a. retain the following if it is supplied by the Licensor
with the Licensed Material:
i. identification of the creator(s) of the Licensed
Material and any others designated to receive
attribution, in any reasonable manner requested by
the Licensor (including by pseudonym if
designated);
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of
warranties;
v. a URI or hyperlink to the Licensed Material to the
extent reasonably practicable;
b. indicate if You modified the Licensed Material and
retain an indication of any previous modifications; and
c. indicate the Licensed Material is licensed under this
Public License, and include the text of, or the URI or
hyperlink to, this Public License.
2. You may satisfy the conditions in Section 3(a)(1) in any
reasonable manner based on the medium, means, and context in
which You Share the Licensed Material. For example, it may be
reasonable to satisfy the conditions by providing a URI or
hyperlink to a resource that includes the required
information.
3. If requested by the Licensor, You must remove any of the
information required by Section 3(a)(1)(A) to the extent
reasonably practicable.
b. ShareAlike.
In addition to the conditions in Section 3(a), if You Share
Adapted Material You produce, the following conditions also apply.
1. The Adapter's License You apply must be a Creative Commons
license with the same License Elements, this version or
later, or a BY-NC-SA Compatible License.
2. You must include the text of, or the URI or hyperlink to, the
Adapter's License You apply. You may satisfy this condition
in any reasonable manner based on the medium, means, and
context in which You Share Adapted Material.
3. You may not offer or impose any additional or different terms
or conditions on, or apply any Effective Technological
Measures to, Adapted Material that restrict exercise of the
rights granted under the Adapter's License You apply.
Section 4 -- Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that
apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
to extract, reuse, reproduce, and Share all or a substantial
portion of the contents of the database for NonCommercial purposes
only;
b. if You include all or a substantial portion of the database
contents in a database in which You have Sui Generis Database
Rights, then the database in which You have Sui Generis Database
Rights (but not its individual contents) is Adapted Material,
including for purposes of Section 3(b); and
c. You must comply with the conditions in Section 3(a) if You Share
all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not
replace Your obligations under this Public License where the Licensed
Rights include other Copyright and Similar Rights.
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
c. The disclaimer of warranties and limitation of liability provided
above shall be interpreted in a manner that, to the extent
possible, most closely approximates an absolute disclaimer and
waiver of all liability.
Section 6 -- Term and Termination.
a. This Public License applies for the term of the Copyright and
Similar Rights licensed here. However, if You fail to comply with
this Public License, then Your rights under this Public License
terminate automatically.
b. Where Your right to use the Licensed Material has terminated under
Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided
it is cured within 30 days of Your discovery of the
violation; or
2. upon express reinstatement by the Licensor.
For the avoidance of doubt, this Section 6(b) does not affect any
right the Licensor may have to seek remedies for Your violations
of this Public License.
c. For the avoidance of doubt, the Licensor may also offer the
Licensed Material under separate terms or conditions or stop
distributing the Licensed Material at any time; however, doing so
will not terminate this Public License.
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
License.
Section 7 -- Other Terms and Conditions.
a. The Licensor shall not be bound by any additional or different
terms or conditions communicated by You unless expressly agreed.
b. Any arrangements, understandings, or agreements regarding the
Licensed Material not stated herein are separate from and
independent of the terms and conditions of this Public License.
Section 8 -- Interpretation.
a. For the avoidance of doubt, this Public License does not, and
shall not be interpreted to, reduce, limit, restrict, or impose
conditions on any use of the Licensed Material that could lawfully
be made without permission under this Public License.
b. To the extent possible, if any provision of this Public License is
deemed unenforceable, it shall be automatically reformed to the
minimum extent necessary to make it enforceable. If the provision
cannot be reformed, it shall be severed from this Public License
without affecting the enforceability of the remaining terms and
conditions.
c. No term or condition of this Public License will be waived and no
failure to comply consented to unless expressly agreed to by the
Licensor.
d. Nothing in this Public License constitutes or may be interpreted
as a limitation upon, or waiver of, any privileges and immunities
that apply to the Licensor or You, including from the legal
processes of any jurisdiction or authority.
=======================================================================
Creative Commons is not a party to its public
licenses. Notwithstanding, Creative Commons may elect to apply one of
its public licenses to material it publishes and in those instances
will be considered the “Licensor.” The text of the Creative Commons
public licenses is dedicated to the public domain under the CC0 Public
Domain Dedication. Except for the limited purpose of indicating that
material is shared under a Creative Commons public license or as
otherwise permitted by the Creative Commons policies published at
creativecommons.org/policies, Creative Commons does not authorize the
use of the trademark "Creative Commons" or any other trademark or logo
of Creative Commons without its prior written consent including,
without limitation, in connection with any unauthorized modifications
to any of its public licenses or any other arrangements,
understandings, or agreements concerning use of licensed material. For
the avoidance of doubt, this paragraph does not form part of the
public licenses.
Creative Commons may be contacted at creativecommons.org.

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020-present Silero Team
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

37
deploy/llm/Dockerfile Normal file
View file

@ -0,0 +1,37 @@
# llama-server с весами Qwen3 и Vikhr — один образ на оба сервиса LLM.
#
# GGML_BACKEND_DL + GGML_CPU_ALL_VARIANTS: CPU-бэкенд собран в нескольких
# вариантах (от SSE4.2 до AVX512/AMX), нужный выбирается при запуске. Поэтому
# образ не падает SIGILL на CPU без AVX (стенд на QEMU) и не теряет скорость
# на AVX2/AVX512. Веса приходят именованным контекстом models=backend/models
# (docker-bake.hcl, docker-compose.yml), внутри сборки ничего не скачивается.
FROM ubuntu:24.04 AS build
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential cmake git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Тот же билд, что командный бинарник models/bin/llama-b10934
ARG LLAMA_TAG=b10934
RUN git clone --depth 1 --branch ${LLAMA_TAG} https://github.com/ggml-org/llama.cpp /src
RUN cmake -S /src -B /build \
-DCMAKE_BUILD_TYPE=Release \
-DBUILD_SHARED_LIBS=ON \
-DGGML_NATIVE=OFF \
-DGGML_BACKEND_DL=ON \
-DGGML_CPU_ALL_VARIANTS=ON \
-DLLAMA_CURL=OFF \
-DLLAMA_BUILD_TESTS=OFF -DLLAMA_BUILD_EXAMPLES=OFF \
&& cmake --build /build --target llama-server -j "$(nproc)" \
&& mkdir -p /out \
&& cp /build/bin/llama-server /out/ \
&& find /build -name '*.so*' -exec cp -P {} /out/ \;
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y --no-install-recommends \
libgomp1 curl ca-certificates && rm -rf /var/lib/apt/lists/*
# llama-server ищет libggml-cpu-*.so рядом с собой — всё в одном каталоге.
COPY --from=build /out/ /opt/llama/
ENV LD_LIBRARY_PATH=/opt/llama
COPY --from=models qwen3-1.7b/Qwen3-1.7B-Q8_0.gguf /models/qwen3-1.7b/
COPY --from=models vikhr-1b/Vikhr-Llama-3.2-1B-Q4_K_M.gguf /models/vikhr-1b/
COPY --from=licenses . /licenses
ENTRYPOINT ["/opt/llama/llama-server"]

45
docker-bake.hcl Normal file
View file

@ -0,0 +1,45 @@
# Образы стенда: `docker buildx bake` (или `make images`). Push делает человек:
# `docker buildx bake --push`. Теги совпадают с docker-compose.yml.
# Веса берутся из локального backend/models собирающего — `make local-models`.
group "default" {
targets = ["backend", "frontend", "llm", "sip"]
}
target "_common" {
platforms = ["linux/amd64"]
}
target "backend" {
inherits = ["_common"]
context = "backend"
target = "voice"
contexts = {
models = "backend/models"
scenarios = "scenarios"
licenses = "deploy/licenses"
}
tags = ["registry.gglamer.ru/lct-hack-backend:2026.09.29"]
}
target "frontend" {
inherits = ["_common"]
context = "frontend"
tags = ["registry.gglamer.ru/lct-hack-frontend:2026.09.29"]
}
target "llm" {
inherits = ["_common"]
context = "deploy/llm"
contexts = {
models = "backend/models"
licenses = "deploy/licenses"
}
tags = ["registry.gglamer.ru/lct-hack-llm:2026.09.29"]
}
target "sip" {
inherits = ["_common"]
context = "sip"
tags = ["registry.gglamer.ru/lct-hack-sip:2026.09.29"]
}

View file

@ -1,81 +0,0 @@
# Opt-in two-node routing test/cluster profile. Apply migrations and seed once
# on `backend`; `backend-b` waits for it and joins with its own stable node ID.
# Use with docker-compose.tls.yml for the browser-facing cluster deployment.
services:
backend:
environment:
BACKEND_NODE_ID: backend-a
SECURE_COOKIES: "true"
backend-b:
build: ./backend
image: lct-hack-backend:local
restart: unless-stopped
# Loopback-only endpoint is used by the disposable cross-node acceptance
# smoke; normal users still enter through the TLS Nginx service.
ports: ["127.0.0.1:${BACKEND_B_PORT:-8001}:8000"]
command: >-
sh -c '
if [ -z "$${SESSION_SECRET:-}" ]; then
while [ ! -s /run/lct/session-secret ]; do sleep 0.2; done;
export SESSION_SECRET="$$(cat /run/lct/session-secret)";
fi;
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1'
environment:
DATABASE_URL: postgresql+asyncpg://lct:${POSTGRES_PASSWORD:-lct}@postgres:5432/lct
DB_POOL_SIZE: ${DB_POOL_SIZE:-20}
DB_POOL_MAX_OVERFLOW: ${DB_POOL_MAX_OVERFLOW:-10}
SESSION_SECRET: ${SESSION_SECRET:-}
BACKEND_NODE_ID: backend-b
SECURE_COOKIES: "true"
OFFLINE: "true"
LDAP_ENABLED: ${LDAP_ENABLED:-false}
LDAP_URL: ${LDAP_URL:-}
LDAP_BASE_DN: ${LDAP_BASE_DN:-}
LDAP_BIND_DN: ${LDAP_BIND_DN:-}
LDAP_BIND_PASSWORD: ${LDAP_BIND_PASSWORD:-}
LDAP_USER_FILTER: ${LDAP_USER_FILTER:-'(objectClass=person)'}
LDAP_LOGIN_ATTRIBUTE: ${LDAP_LOGIN_ATTRIBUTE:-sAMAccountName}
LDAP_ROLE_GROUPS: "${LDAP_ROLE_GROUPS:-{}}"
LDAP_SERVICE_GROUPS: "${LDAP_SERVICE_GROUPS:-{}}"
LDAP_CA_CERTS_FILE: ${LDAP_CA_CERTS_FILE:-}
LDAP_CONNECT_TIMEOUT_SECONDS: ${LDAP_CONNECT_TIMEOUT_SECONDS:-5}
VOICE_ENABLED: "false"
RECORD_CALLS: "true"
RECORDINGS_DIR: /recordings
LLM_PROVIDER: local
LLM_BASE_URL: ${DOCKER_LLM_BASE_URL:-http://host.docker.internal:18080/v1}
LLM_MODEL_CALLER: ${LLM_MODEL_CALLER:-Qwen3-1.7B}
LLM_CONTROL_BASE_URL: ${DOCKER_LLM_CONTROL_BASE_URL:-http://host.docker.internal:18081/v1}
LLM_MODEL_CONTROL: ${LLM_MODEL_CONTROL:-Vikhr-1B}
GRAMMAR_LLM_ENABLED: ${GRAMMAR_LLM_ENABLED:-false}
ALLOW_DOCKER_HOST_MODELS: "true"
BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400}
BACKUP_KEEP: ${BACKUP_KEEP:-14}
volumes:
- ./backend:/app
- ./scenarios:/scenarios:ro
- securitydata:/run/lct
- recordings:/recordings
- backups:/app/backups
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
backend:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=2).read()"]
interval: 5s
timeout: 3s
retries: 12
start_period: 10s
frontend:
volumes:
- ./frontend/nginx.cluster.conf.template:/etc/nginx/templates/default.conf.template:ro
- ./frontend/nginx.cluster.tls.conf.template:/etc/nginx/templates-tls/default.conf.template:ro
depends_on:
backend:
condition: service_healthy
backend-b:
condition: service_healthy

View file

@ -1,11 +0,0 @@
# Isolated image tags for the opt-in 20-session / 100-user browser acceptance
# run. Volumes are isolated by the unique Compose project name.
services:
backend:
image: lct-hack-backend:load-test
frontend:
image: lct-hack-frontend:load-test
volumes:
- ./frontend/dist:/usr/share/nginx/html:ro
- ./frontend/nginx.conf.template:/etc/nginx/templates/default.conf.template:ro
- ./frontend/nginx.tls.conf.template:/etc/nginx/templates-tls/default.conf.template:ro

View file

@ -1,94 +0,0 @@
# Test-only fault injection. Each backend reaches PostgreSQL through a local
# TCP proxy so one node's database path can be cut while its HTTP/WS path stays up.
services:
db-proxy-a:
image: lct-hack-backend:load-test
entrypoint: ["python", "-c"]
command:
- |
import asyncio
async def handle(reader, writer):
try:
upstream_reader, upstream_writer = await asyncio.open_connection("postgres", 5432)
except Exception:
writer.close()
return
async def copy(source, target):
try:
while data := await source.read(65536):
target.write(data)
await target.drain()
except Exception:
pass
finally:
target.close()
await asyncio.gather(
copy(reader, upstream_writer), copy(upstream_reader, writer)
)
async def main():
server = await asyncio.start_server(handle, "0.0.0.0", 5432)
async with server:
await server.serve_forever()
asyncio.run(main())
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import socket; s=socket.create_connection(('127.0.0.1',5432),1); s.close()"]
interval: 2s
timeout: 2s
retries: 15
db-proxy-b:
image: lct-hack-backend:load-test
entrypoint: ["python", "-c"]
command:
- |
import asyncio
async def handle(reader, writer):
try:
upstream_reader, upstream_writer = await asyncio.open_connection("postgres", 5432)
except Exception:
writer.close()
return
async def copy(source, target):
try:
while data := await source.read(65536):
target.write(data)
await target.drain()
except Exception:
pass
finally:
target.close()
await asyncio.gather(
copy(reader, upstream_writer), copy(upstream_reader, writer)
)
async def main():
server = await asyncio.start_server(handle, "0.0.0.0", 5432)
async with server:
await server.serve_forever()
asyncio.run(main())
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import socket; s=socket.create_connection(('127.0.0.1',5432),1); s.close()"]
interval: 2s
timeout: 2s
retries: 15
backend:
environment:
DATABASE_URL: postgresql+asyncpg://lct:${POSTGRES_PASSWORD:-lct}@db-proxy-a:5432/lct
depends_on:
db-proxy-a:
condition: service_healthy
backend-b:
environment:
DATABASE_URL: postgresql+asyncpg://lct:${POSTGRES_PASSWORD:-lct}@db-proxy-b:5432/lct
ports:
- "127.0.0.1:${BACKEND_B_PORT:-18001}:8000"
depends_on:
db-proxy-b:
condition: service_healthy

View file

@ -1,19 +0,0 @@
# Production overlay. Keep the default developer profile in docker-compose.yml
# convenient, but refuse to start a network deployment with the known demo DB
# password. Use a URL-unreserved random secret (A-Z, a-z, 0-9, ., _, ~, -).
services:
postgres:
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set a unique URL-safe POSTGRES_PASSWORD in .env}
backend:
environment:
APP_ENV: production
DEMO_NO_DB: "false"
DEV_AUTH_BYPASS: "false"
SECURE_COOKIES: "true"
DATABASE_URL: postgresql+asyncpg://lct:${POSTGRES_PASSWORD:?Set a unique URL-safe POSTGRES_PASSWORD in .env}@postgres:5432/lct
backup:
environment:
DATABASE_URL: postgresql+asyncpg://lct:${POSTGRES_PASSWORD:?Set a unique URL-safe POSTGRES_PASSWORD in .env}@postgres:5432/lct

View file

@ -1,31 +0,0 @@
services:
sip:
build: ./sip
image: lct-hack-sip:local
restart: unless-stopped
environment:
SIP_6001_PASSWORD: ${SIP_6001_PASSWORD:-}
SIP_6002_PASSWORD: ${SIP_6002_PASSWORD:-}
SIP_6003_PASSWORD: ${SIP_6003_PASSWORD:-}
SIP_6101_PASSWORD: ${SIP_6101_PASSWORD:-}
SIP_6102_PASSWORD: ${SIP_6102_PASSWORD:-}
SIP_6103_PASSWORD: ${SIP_6103_PASSWORD:-}
SIP_EXTERNAL_MEDIA_ADDRESS: ${SIP_EXTERNAL_MEDIA_ADDRESS:-127.0.0.1}
SIP_RTP_START: ${SIP_RTP_START:-10000}
SIP_RTP_END: ${SIP_RTP_END:-10099}
ports:
- "${BIND_HOST:-127.0.0.1}:${SIP_PORT:-5060}:5060/udp"
- "${BIND_HOST:-127.0.0.1}:${SIP_PORT:-5060}:5060/tcp"
- "${BIND_HOST:-127.0.0.1}:${SIPS_PORT:-5061}:5061/tcp"
- "${BIND_HOST:-127.0.0.1}:${SIP_WS_PORT:-8088}:8088/tcp"
- "${BIND_HOST:-127.0.0.1}:${RTP_PORT_START:-10000}-${RTP_PORT_END:-10099}:${SIP_RTP_START:-10000}-${SIP_RTP_END:-10099}/udp"
volumes:
- sipdata:/var/lib/lct-sip
- siprecordings:/recordings
- type: bind
source: ${SIP_TLS_DIR:-./.local/sip-tls}
target: /tls
volumes:
sipdata:
siprecordings:

View file

@ -1,15 +0,0 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: lct_test
POSTGRES_PASSWORD: lct_test
POSTGRES_DB: lct_test
# Ephemeral storage only: no bind/named volume, never reuses the dev DB.
ports:
- "127.0.0.1::5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U lct_test -d lct_test"]
interval: 2s
timeout: 2s
retries: 30

View file

@ -1,16 +0,0 @@
services:
backend:
environment:
SECURE_COOKIES: "true"
frontend:
environment:
NGINX_ENVSUBST_TEMPLATE_DIR: /etc/nginx/templates-tls
PUBLIC_TLS_PORT: ${TLS_PORT:-5443}
TLS_BOOTSTRAP: "true"
ports:
- "${BIND_HOST:-127.0.0.1}:${TLS_PORT:-5443}:5443"
volumes:
- type: bind
source: ${TLS_CERT_DIR:-./.local/tls}
target: /etc/nginx/tls

View file

@ -1,14 +0,0 @@
# Unique backend/SIP tags let the smoke build without replacing tags used by
# the long-running project. The frontend runtime image is read-only reused;
# the current production bundle and TLS template are bind-mounted for fidelity.
services:
backend:
image: lct-hack-backend:webrtc-test
frontend:
image: lct-hack-frontend:local
pull_policy: never
volumes:
- ./frontend/dist:/usr/share/nginx/html:ro
- ./frontend/nginx.tls.conf.template:/etc/nginx/templates-tls/default.conf.template:ro
sip:
image: lct-hack-sip:webrtc-test

View file

@ -1,15 +1,20 @@
# Полный стенд на CPU из готовых образов: `docker compose up`, затем
# http://127.0.0.1:5173. Без .env, без сборки; после скачивания образов сеть
# не нужна. Все порты — только на 127.0.0.1.
#
# У своих сервисов рядом с image стоит build: команда собирает те же образы
# локально (`docker compose build` или `docker buildx bake`). Веса и сценарии
# входят в образы именованными контекстами, bind-mount'ов исходников нет.
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: lct
# Local development default only. Use docker-compose.production.yml for
# an isolated deployment; that overlay requires an install-specific secret.
# Пароль по умолчанию: БД не публикуется наружу, только loopback хоста.
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-lct}
POSTGRES_DB: lct
# БД и backend не публикуются в класс: с рабочих мест доступен только
# TLS-терминатор frontend. Loopback-порты нужны для администрирования хоста.
ports: ["127.0.0.1:${POSTGRES_PORT:-5432}:5432"]
volumes: ["pgdata:/var/lib/postgresql/data"]
healthcheck:
@ -19,8 +24,14 @@ services:
retries: 10
backend:
build: ./backend
image: lct-hack-backend:local
image: registry.gglamer.ru/lct-hack-backend:2026.09.29
build: &backend-build
context: ./backend
target: voice
additional_contexts:
models: ./backend/models
scenarios: ./scenarios
licenses: ./deploy/licenses
restart: unless-stopped
# Один воркер принципиально: состояние живой сессии и реестр наблюдателей
# живут в памяти процесса (docs/arch/STACK.md).
@ -37,10 +48,8 @@ services:
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1'
environment:
DATABASE_URL: postgresql+asyncpg://lct:${POSTGRES_PASSWORD:-lct}@postgres:5432/lct
DB_POOL_SIZE: ${DB_POOL_SIZE:-20}
DB_POOL_MAX_OVERFLOW: ${DB_POOL_MAX_OVERFLOW:-10}
SESSION_SECRET: ${SESSION_SECRET:-}
BACKEND_NODE_ID: ${BACKEND_NODE_ID:-backend}
BACKEND_NODE_ID: backend
OFFLINE: "true"
LDAP_ENABLED: ${LDAP_ENABLED:-false}
LDAP_URL: ${LDAP_URL:-}
@ -53,40 +62,45 @@ services:
LDAP_SERVICE_GROUPS: "${LDAP_SERVICE_GROUPS:-{}}"
LDAP_CA_CERTS_FILE: ${LDAP_CA_CERTS_FILE:-}
LDAP_CONNECT_TIMEOUT_SECONDS: ${LDAP_CONNECT_TIMEOUT_SECONDS:-5}
VOICE_ENABLED: "false"
VOICE_ENABLED: "true"
# Единственный STT, проверенный вживую на CPU: ~0,3–0,4 с на 2 с звука.
STT_MODEL: gigaam-v3-rnnt
RECORD_CALLS: "true"
RECORDINGS_DIR: /recordings
LLM_PROVIDER: local
LLM_BASE_URL: ${DOCKER_LLM_BASE_URL:-http://host.docker.internal:18080/v1}
LLM_MODEL_CALLER: ${LLM_MODEL_CALLER:-Qwen3-1.7B}
LLM_CONTROL_BASE_URL: ${DOCKER_LLM_CONTROL_BASE_URL:-http://host.docker.internal:18081/v1}
LLM_MODEL_CONTROL: ${LLM_MODEL_CONTROL:-Vikhr-1B}
GRAMMAR_LLM_ENABLED: ${GRAMMAR_LLM_ENABLED:-false}
# Имена сервисов ниже; ALLOW_DOCKER_HOST_MODELS пускает к ним офлайн-проверку
# адреса (app/dialog/llm.py), внешний URL по-прежнему отвергается.
LLM_BASE_URL: http://llm-qwen:18080/v1
LLM_MODEL_CALLER: Qwen3-1.7B
LLM_CONTROL_BASE_URL: http://llm-vikhr:18081/v1
LLM_MODEL_CONTROL: Vikhr-1B
ALLOW_DOCKER_HOST_MODELS: "true"
GRAMMAR_LLM_ENABLED: "true"
BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400}
BACKUP_KEEP: ${BACKUP_KEEP:-14}
volumes:
- ./backend:/app
- ./scenarios:/scenarios:ro
- securitydata:/run/lct
- recordings:/recordings
- backups:/app/backups
ports: ["127.0.0.1:${BACKEND_PORT:-8000}:8000"]
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
postgres:
condition: service_healthy
llm-qwen:
condition: service_healthy
llm-vikhr:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=2).read()"]
interval: 5s
timeout: 3s
retries: 12
start_period: 10s
retries: 24
# Прогрев STT и TTS на старте занимает десятки секунд на слабом CPU.
start_period: 120s
backup:
build: ./backend
image: lct-hack-backend:local
image: registry.gglamer.ru/lct-hack-backend:2026.09.29
build: *backend-build
restart: unless-stopped
command: python scripts/backup_loop.py
environment:
@ -95,20 +109,19 @@ services:
BACKUP_RETRY_SECONDS: ${BACKUP_RETRY_SECONDS:-300}
BACKUP_KEEP: ${BACKUP_KEEP:-14}
volumes:
- ./backend:/app
- backups:/app/backups
depends_on:
postgres:
condition: service_healthy
frontend:
image: registry.gglamer.ru/lct-hack-frontend:2026.09.29
build: ./frontend
image: lct-hack-frontend:local
restart: unless-stopped
environment:
BACKEND_HOST: backend
BACKEND_PORT: 8000
ports: ["${BIND_HOST:-127.0.0.1}:${FRONTEND_PORT:-5173}:5173"]
ports: ["127.0.0.1:${FRONTEND_PORT:-5173}:5173"]
depends_on:
backend:
condition: service_healthy
@ -118,8 +131,78 @@ services:
timeout: 3s
retries: 3
# Asterisk для софтфонов и встроенного WebRTC-телефона. Браузер ходит на
# same-origin /sip-ws через nginx фронта; SIP и RTP для внешних софтфонов —
# только на loopback.
sip:
image: registry.gglamer.ru/lct-hack-sip:2026.09.29
build: ./sip
restart: unless-stopped
environment:
SIP_6001_PASSWORD: ${SIP_6001_PASSWORD:-}
SIP_6002_PASSWORD: ${SIP_6002_PASSWORD:-}
SIP_6003_PASSWORD: ${SIP_6003_PASSWORD:-}
SIP_6101_PASSWORD: ${SIP_6101_PASSWORD:-}
SIP_6102_PASSWORD: ${SIP_6102_PASSWORD:-}
SIP_6103_PASSWORD: ${SIP_6103_PASSWORD:-}
SIP_EXTERNAL_MEDIA_ADDRESS: 127.0.0.1
SIP_RTP_START: "10000"
SIP_RTP_END: "10099"
ports:
- "127.0.0.1:${SIP_PORT:-5060}:5060/udp"
- "127.0.0.1:${SIP_PORT:-5060}:5060/tcp"
- "127.0.0.1:10000-10099:10000-10099/udp"
volumes:
- sipdata:/var/lib/lct-sip
- siprecordings:/recordings
# Qwen играет звонящего, пишет черновики сценариев и выводы по группе.
llm-qwen:
image: registry.gglamer.ru/lct-hack-llm:2026.09.29
build: &llm-build
context: ./deploy/llm
additional_contexts:
models: ./backend/models
licenses: ./deploy/licenses
restart: unless-stopped
command:
[
"-m", "/models/qwen3-1.7b/Qwen3-1.7B-Q8_0.gguf",
"--alias", "Qwen3-1.7B",
"--host", "0.0.0.0", "--port", "18080",
"--ctx-size", "2048", "--threads", "${LLM_THREADS:-4}", "--parallel", "1",
"--reasoning-budget", "0",
]
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:18080/health"]
interval: 10s
timeout: 3s
retries: 30
start_period: 180s
# Vikhr — рекомендации к разбору и грамматика описания в упражнении 112.
llm-vikhr:
image: registry.gglamer.ru/lct-hack-llm:2026.09.29
build: *llm-build
restart: unless-stopped
command:
[
"-m", "/models/vikhr-1b/Vikhr-Llama-3.2-1B-Q4_K_M.gguf",
"--alias", "Vikhr-1B",
"--host", "0.0.0.0", "--port", "18081",
"--ctx-size", "2048", "--threads", "${LLM_THREADS:-4}", "--parallel", "1",
]
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:18081/health"]
interval: 10s
timeout: 3s
retries: 30
start_period: 180s
volumes:
pgdata:
securitydata:
recordings:
backups:
sipdata:
siprecordings:

View file

@ -9,13 +9,9 @@ RUN npm run build
FROM nginx:1.27-alpine
RUN apk add --no-cache openssl
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
COPY nginx.tls.conf.template /etc/nginx/templates-tls/default.conf.template
COPY docker-entrypoint.d/15-local-certificate.sh /docker-entrypoint.d/15-local-certificate.sh
COPY --from=build /app/dist /usr/share/nginx/html
ENV BACKEND_HOST=backend
ENV BACKEND_PORT=8000
EXPOSE 5173
EXPOSE 5443

View file

@ -1,26 +0,0 @@
#!/bin/sh
set -eu
if [ "${TLS_BOOTSTRAP:-false}" != "true" ]; then
exit 0
fi
certificate_dir=/etc/nginx/tls
certificate_file="$certificate_dir/tls.crt"
private_key_file="$certificate_dir/tls.key"
mkdir -p "$certificate_dir"
if [ -s "$certificate_file" ] && [ -s "$private_key_file" ]; then
exit 0
fi
openssl req -x509 -nodes -newkey rsa:3072 -sha256 -days 365 \
-keyout "$private_key_file" \
-out "$certificate_file" \
-subj "/CN=localhost/O=LCT local training stand" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1" \
-addext "keyUsage=digitalSignature,keyEncipherment" \
-addext "extendedKeyUsage=serverAuth" 2>/dev/null
chmod 600 "$private_key_file"
chmod 644 "$certificate_file"
echo "TLS: создан локальный bootstrap-сертификат $certificate_file" >&2

View file

@ -1,64 +0,0 @@
# The same session UUID must reach its owning SessionHub on every API/WS path.
map $uri $session_route_key {
default $uri;
"~^/ws/(?:control|call|observe|station)/([0-9a-fA-F-]{36})$" $1;
"~^/api/sessions/([0-9a-fA-F-]{36})(?:/|$)" $1;
}
upstream backend_cluster {
zone backend_cluster 64k;
resolver 127.0.0.11 ipv6=off valid=2s;
hash $session_route_key consistent;
server backend:8000 resolve max_fails=1 fail_timeout=5s;
server backend-b:8000 resolve max_fails=1 fail_timeout=5s;
}
server {
listen 5173;
server_name _;
root /usr/share/nginx/html;
gzip on;
gzip_static on;
gzip_vary on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types application/javascript application/json image/svg+xml text/css text/plain;
location /api/ {
proxy_pass http://backend_cluster;
proxy_http_version 1.1;
proxy_next_upstream error timeout http_403 http_500 http_502 http_503 http_504;
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 5s;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ws/ {
proxy_pass http://backend_cluster;
proxy_http_version 1.1;
proxy_next_upstream error timeout http_403 http_502 http_503 http_504;
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 5s;
proxy_connect_timeout 1s;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_read_timeout 75s;
}
location ~* \.(?:css|js|woff2?|png|svg)$ {
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, immutable";
}
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
}

View file

@ -1,96 +0,0 @@
# Keep session channels pinned to the node that owns their SessionHub.
map $uri $session_route_key {
default $uri;
"~^/ws/(?:control|call|observe|station)/([0-9a-fA-F-]{36})$" $1;
"~^/api/sessions/([0-9a-fA-F-]{36})(?:/|$)" $1;
}
upstream backend_cluster {
zone backend_cluster 64k;
resolver 127.0.0.11 ipv6=off valid=2s;
hash $session_route_key consistent;
server backend:8000 resolve max_fails=1 fail_timeout=5s;
server backend-b:8000 resolve max_fails=1 fail_timeout=5s;
}
server {
listen 5173;
server_name _;
return 308 https://$host:${PUBLIC_TLS_PORT}$request_uri;
}
server {
listen 5443 ssl;
http2 on;
server_name _;
root /usr/share/nginx/html;
ssl_certificate /etc/nginx/tls/tls.crt;
ssl_certificate_key /etc/nginx/tls/tls.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:LCT_TLS:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "same-origin" always;
gzip on;
gzip_static on;
gzip_vary on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types application/javascript application/json image/svg+xml text/css text/plain;
location /api/ {
proxy_pass http://backend_cluster;
proxy_http_version 1.1;
proxy_next_upstream error timeout http_403 http_500 http_502 http_503 http_504;
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 5s;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
location /ws/ {
proxy_pass http://backend_cluster;
proxy_http_version 1.1;
proxy_next_upstream error timeout http_403 http_502 http_503 http_504;
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 5s;
proxy_connect_timeout 1s;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 75s;
}
location = /sip-ws {
resolver 127.0.0.11 ipv6=off valid=10s;
set $sip_backend sip;
proxy_pass http://$sip_backend:8088/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location ~* \.(?:css|js|woff2?|png|svg)$ {
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, immutable";
}
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
}

View file

@ -29,6 +29,20 @@ server {
proxy_read_timeout 75s;
}
# SIP over WebSocket для встроенного WebRTC-телефона. Переменная и
# Docker DNS позволяют фронту стартовать даже до sip-сервиса.
location = /sip-ws {
resolver 127.0.0.11 ipv6=off valid=10s;
set $sip_backend sip;
proxy_pass http://$sip_backend:8088/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location ~* \.(?:css|js|woff2?|png|svg)$ {
try_files $uri =404;
expires 1y;

View file

@ -1,76 +0,0 @@
server {
listen 5173;
server_name _;
return 308 https://$host:${PUBLIC_TLS_PORT}$request_uri;
}
server {
listen 5443 ssl;
http2 on;
server_name _;
root /usr/share/nginx/html;
ssl_certificate /etc/nginx/tls/tls.crt;
ssl_certificate_key /etc/nginx/tls/tls.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:LCT_TLS:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "same-origin" always;
gzip on;
gzip_static on;
gzip_vary on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types application/javascript application/json image/svg+xml text/css text/plain;
location /api/ {
proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
location /ws/ {
proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 75s;
}
# SIP over WebSocket для встроенного WebRTC-телефона. Переменная и
# Docker DNS позволяют обычному TLS-стенду стартовать даже до sip-сервиса.
location = /sip-ws {
resolver 127.0.0.11 ipv6=off valid=10s;
set $sip_backend sip;
proxy_pass http://$sip_backend:8088/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location ~* \.(?:css|js|woff2?|png|svg)$ {
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, immutable";
}
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
}

View file

@ -1,27 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
compose=(docker compose -f "$repo_root/docker-compose.yml" \
-f "$repo_root/docker-compose.production.yml" -f "$repo_root/docker-compose.tls.yml")
if env -u POSTGRES_PASSWORD "${compose[@]}" config --quiet >/dev/null 2>&1; then
echo "ОШИБКА: production Compose запустился без POSTGRES_PASSWORD" >&2
exit 1
fi
secret="compose-check-0123456789abcdef0123456789abcdef"
case "$secret" in
*[!A-Za-z0-9._~-]*) echo "ОШИБКА: тестовый пароль не URL-safe" >&2; exit 1 ;;
esac
resolved="$(POSTGRES_PASSWORD="$secret" "${compose[@]}" config --format json 2>/dev/null)"
printf '%s' "$resolved" | jq -e --arg secret "$secret" '
.services.postgres.environment.POSTGRES_PASSWORD == $secret and
.services.backend.environment.DATABASE_URL == ("postgresql+asyncpg://lct:" + $secret + "@postgres:5432/lct") and
.services.backup.environment.DATABASE_URL == ("postgresql+asyncpg://lct:" + $secret + "@postgres:5432/lct") and
.services.backend.environment.APP_ENV == "production" and
.services.backend.environment.DEMO_NO_DB == "false" and
.services.backend.environment.DEV_AUTH_BYPASS == "false" and
.services.backend.environment.SECURE_COOKIES == "true"
' >/dev/null
echo "Production Compose требует уникальный пароль и передаёт его PostgreSQL/backend/backup."

View file

@ -21,8 +21,7 @@ def remove_smoke_recordings(
run = runner or subprocess.run
result = run(
[
"docker", "compose", "-f", "docker-compose.yml", "-f", "docker-compose.sip.yml",
"exec", "-T", "sip", "rm", "-f", "--",
"docker", "compose", "exec", "-T", "sip", "rm", "-f", "--",
*(f"/recordings/{name}" for name in safe_names),
],
cwd=root,
@ -34,8 +33,7 @@ def remove_smoke_recordings(
return False
verification = run(
[
"docker", "compose", "-f", "docker-compose.yml", "-f", "docker-compose.sip.yml",
"exec", "-T", "sip", "find", "/recordings", "-maxdepth", "1",
"docker", "compose", "exec", "-T", "sip", "find", "/recordings", "-maxdepth", "1",
"-type", "f", "-name", "*.wav",
],
cwd=root,

View file

@ -117,8 +117,7 @@ def receive_final(sock: socket.socket, timeout: float = 3.0) -> tuple[str, dict[
def compose_password(user: str) -> str:
result = subprocess.run(
[
"docker", "compose", "-f", "docker-compose.yml", "-f", "docker-compose.sip.yml",
"exec", "-T", "sip", "cat", "/var/lib/lct-sip/credentials.env",
"docker", "compose", "exec", "-T", "sip", "cat", "/var/lib/lct-sip/credentials.env",
],
check=True,
capture_output=True,

View file

@ -59,8 +59,7 @@ async def cleanup_account(database_url: str, login: str) -> None:
def recordings() -> dict[str, int]:
result = subprocess.run(
[
"docker", "compose", "-f", "docker-compose.yml", "-f", "docker-compose.sip.yml",
"exec", "-T", "sip", "find", "/recordings", "-maxdepth", "1", "-type", "f",
"docker", "compose", "exec", "-T", "sip", "find", "/recordings", "-maxdepth", "1", "-type", "f",
"-name", "*.wav", "-printf", "%f %s\\n",
],
cwd=ROOT,
@ -85,7 +84,7 @@ async def configure(page, extension: str, password: str, target: str, timeout_ms
async def run(args: argparse.Namespace) -> int:
frontend = local_url(args.frontend_url, {"https"})
frontend = local_url(args.frontend_url, {"http", "https"})
backend = local_url(args.backend_url, {"http"})
login = f"webrtc-smoke-{secrets.token_hex(5)}"
app_password = secrets.token_urlsafe(24)
@ -219,7 +218,7 @@ async def run(args: argparse.Namespace) -> int:
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--frontend-url", default="https://127.0.0.1:5443")
parser.add_argument("--frontend-url", default="http://127.0.0.1:5173")
parser.add_argument("--backend-url", default="http://127.0.0.1:8000")
parser.add_argument(
"--database-url", default="postgresql+asyncpg://lct:lct@127.0.0.1:5432/lct"

View file

@ -1,760 +0,0 @@
#!/usr/bin/env python3
"""Live smoke: verify a complete DDS exercise across fenced cluster takeover.
Only use with a disposable local Compose cluster and its own PostgreSQL database.
The script creates temporary users/session, stops and restarts one named backend,
and removes the temporary rows on completion.
"""
from __future__ import annotations
import argparse
import asyncio
from contextlib import AsyncExitStack
import json
import os
import secrets
import ssl
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
from uuid import UUID, uuid4
import websockets
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "backend"))
def compose(args: argparse.Namespace, *command: str, check: bool = True) -> str:
env = os.environ.copy()
env.update({
"POSTGRES_PORT": str(args.postgres_port),
"BACKEND_PORT": str(args.backend_port),
"BACKEND_B_PORT": str(args.backend_b_port),
"FRONTEND_PORT": str(args.frontend_port),
"TLS_PORT": str(args.tls_port),
"POSTGRES_PASSWORD": args.postgres_password,
"SESSION_SECRET": args.session_secret,
"TLS_CERT_DIR": args.tls_cert_dir,
})
files = [
"docker-compose.yml", "docker-compose.tls.yml",
"docker-compose.load-test.yml", "docker-compose.cluster.yml",
]
if args.failure_mode == "partition":
files.append("docker-compose.partition-test.yml")
result = subprocess.run(
["docker", "compose", "-p", args.project,
*[part for file in files for part in ("-f", str(ROOT / file))], *command],
cwd=ROOT, env=env, check=False, capture_output=True, text=True,
)
if check and result.returncode:
raise subprocess.CalledProcessError(
result.returncode, result.args, output=result.stdout, stderr=result.stderr
)
return result.stdout
def login(base: str, login_name: str, password: str) -> str:
payload = json.dumps({"login": login_name, "password": password}).encode()
request = urllib.request.Request(
f"{base}/api/auth/login", data=payload, method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(request, timeout=10) as response:
cookie = response.headers.get("Set-Cookie", "").split(";", 1)[0]
if not cookie.startswith("lct_session="):
raise RuntimeError("login did not issue lct_session cookie")
return cookie
async def send_control(ws_base: str, session_id: UUID, cookie: str, payload: dict) -> float:
async with websockets.connect(
f"{ws_base}/ws/control/{session_id}",
additional_headers={"Cookie": cookie},
ssl=ssl._create_unverified_context() if ws_base.startswith("wss://") else None,
open_timeout=10,
ping_interval=None,
) as socket:
await socket.send(json.dumps(payload, ensure_ascii=False))
sent_at = time.monotonic()
await asyncio.sleep(0.15)
return sent_at
async def probe_ws(ws_base: str, path: str, cookie: str) -> dict:
try:
async with websockets.connect(
f"{ws_base}{path}", additional_headers={"Cookie": cookie},
ssl=ssl._create_unverified_context() if ws_base.startswith("wss://") else None,
open_timeout=6, ping_interval=None,
) as socket:
try:
message = await asyncio.wait_for(socket.recv(), timeout=4)
event = json.loads(message) if isinstance(message, str) else "binary"
except TimeoutError:
event = "connected_no_initial_event"
return {"connected": True, "initial_event": event}
except Exception as exc: # report exact endpoint failure in the result
return {"connected": False, "error": f"{type(exc).__name__}: {exc}"}
async def station_command(
ws_base: str, session_id: UUID, cookie: str, payload: dict | None = None,
) -> dict:
"""Read the authoritative station snapshot, optionally issue one command."""
async with websockets.connect(
f"{ws_base}/ws/station/{session_id}", additional_headers={"Cookie": cookie},
ssl=ssl._create_unverified_context() if ws_base.startswith("wss://") else None,
open_timeout=10, ping_interval=None,
) as socket:
async def receive_snapshot() -> dict:
for _ in range(60):
raw = await asyncio.wait_for(socket.recv(), timeout=10)
event = json.loads(raw) if isinstance(raw, str) else {}
if event.get("type") == "error":
raise RuntimeError(f"station rejected smoke: {event.get('message')}")
if event.get("type") == "station.state":
return event["snapshot"]
raise RuntimeError("station did not send its state snapshot")
snapshot = await receive_snapshot()
if payload is None:
return snapshot
await socket.send(json.dumps(payload, ensure_ascii=False))
return await receive_snapshot()
async def replay_station_command(
ws_base: str, session_id: UUID, cookie: str, payload: dict, command_id: str,
attempts: int = 5,
) -> bool:
"""Replay one already committed command and require its durable ACK."""
last_error = None
for attempt in range(attempts):
try:
async with websockets.connect(
f"{ws_base}/ws/station/{session_id}", additional_headers={"Cookie": cookie},
ssl=ssl._create_unverified_context() if ws_base.startswith("wss://") else None,
open_timeout=10, ping_interval=None,
) as socket:
for _ in range(60):
raw = await asyncio.wait_for(socket.recv(), timeout=10)
event = json.loads(raw) if isinstance(raw, str) else {}
if event.get("type") == "error":
raise RuntimeError(f"station reconnect rejected: {event.get('message')}")
if event.get("type") == "station.state":
break
else:
raise RuntimeError("replacement station did not send a state snapshot")
replay = {**payload, "_command_id": command_id}
await socket.send(json.dumps(replay, ensure_ascii=False))
for _ in range(60):
raw = await asyncio.wait_for(socket.recv(), timeout=10)
event = json.loads(raw) if isinstance(raw, str) else {}
if event.get("type") == "error":
raise RuntimeError(f"station replay rejected: {event.get('message')}")
if event.get("type") == "command.ack":
return event.get("command_id") == command_id
raise RuntimeError("replacement owner did not acknowledge replayed command")
except (TimeoutError, OSError, websockets.exceptions.WebSocketException) as exc:
last_error = exc
if attempt + 1 < attempts:
await asyncio.sleep(1)
raise RuntimeError(f"station replay did not reconnect: {last_error}")
def ws_connect(ws_base: str, path: str, cookie: str):
return websockets.connect(
f"{ws_base}{path}", additional_headers={"Cookie": cookie},
ssl=ssl._create_unverified_context() if ws_base.startswith("wss://") else None,
open_timeout=8, ping_interval=None,
)
# Текст `_close_auth_state_unavailable` в backend/app/api/auth.py: 1013 с ним —
# закрытие от устаревшего кэша поколений, а не произвольный 1013.
AUTH_UNAVAILABLE_REASON = "Состояние доступа временно недоступно"
async def wait_auth_fresh(base: str, cookie: str, timeout: float = 10) -> bool:
"""Узел принял cookie: кэш поколений свежий и логин ему известен."""
request = urllib.request.Request(f"{base}/api/auth/me", headers={"Cookie": cookie})
deadline = time.monotonic() + timeout
while True:
try:
with urllib.request.urlopen(request, timeout=2) as response:
if response.status == 200:
return True
except OSError:
pass
if time.monotonic() > deadline:
return False
await asyncio.sleep(0.2)
async def wait_for_fence(socket, timeout: float = 10, ack_id: str | None = None) -> dict:
acked = False
def outcome(**closure) -> dict:
if ack_id is not None:
closure["command_acked"] = acked
return closure
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
message = await asyncio.wait_for(socket.recv(), timeout=deadline - time.monotonic())
except TimeoutError:
return outcome(closed=False, fence_notice=False, reason="timeout")
except Exception as exc:
close = getattr(exc, "rcvd", None) or getattr(exc, "sent", None)
close_code = getattr(close, "code", None) or getattr(exc, "code", None)
return outcome(
closed=True,
fence_notice=close_code == 1012,
close_code=close_code,
reason=getattr(close, "reason", "") if close else str(exc),
)
if isinstance(message, str):
try:
event = json.loads(message)
except json.JSONDecodeError:
continue
if ack_id is not None and event.get("type") == "command.ack" \
and event.get("command_id") == ack_id:
acked = True
if event.get("message") == "Занятие передано другому backend-узлу; переподключитесь.":
return outcome(closed=True, fence_notice=True, close_code=1012,
reason=event.get("message"))
return outcome(closed=False, fence_notice=False, reason="timeout")
async def main(args: argparse.Namespace) -> int:
from sqlalchemy import delete, select
from sqlalchemy.engine import make_url
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.api.auth import hash_password, login_log_marker
from app.db.models import AuditLog, Session, Trainee, User
database_url = (
f"postgresql+asyncpg://lct:{args.postgres_password}@127.0.0.1:"
f"{args.postgres_port}/lct"
)
if make_url(database_url).host not in {"127.0.0.1", "localhost"}:
raise ValueError("failover smoke accepts loopback PostgreSQL only")
engine = create_async_engine(database_url)
factory = async_sessionmaker(engine, expire_on_commit=False)
observer_engine = create_async_engine(database_url, poolclass=NullPool)
run_id = uuid4().hex[:12]
session_id = args.session_id or uuid4()
trainee = Trainee(name=f"Failover smoke {run_id}")
instructor_login = f"fo-i-{run_id}"
trainee_login = f"fo-t-{run_id}"
instructor_password = secrets.token_urlsafe(24)
trainee_password = secrets.token_urlsafe(24)
owner_service = "backend"
owner_stopped = False
fault_service = None
fault_stopped = False
result: dict = {"session_id": str(session_id), "checks": {}}
backend = f"http://127.0.0.1:{args.backend_port}"
result["login_node"] = "backend-a"
ws_base = args.frontend_url.replace("https://", "wss://").replace("http://", "ws://")
instructor_cookie = trainee_cookie = None
try:
async with factory() as db:
db.add(trainee)
await db.flush()
db.add_all([
User(login=instructor_login, full_name="Failover smoke instructor",
password_hash=hash_password(instructor_password), role="instructor",
blocked=False),
User(login=trainee_login, full_name=trainee.name,
password_hash=hash_password(trainee_password), role="trainee",
trainee_id=trainee.id, blocked=False),
])
await db.commit()
users_committed_at = time.monotonic()
instructor_cookie = login(backend, instructor_login, instructor_password)
trainee_cookie = login(backend, trainee_login, trainee_password)
# Учётки созданы прямо в БД, вход — на узле A, а сокет занятия Nginx
# может отдать узлу B до его сверки поколений. Ждать сверку не нужно:
# B проверяет неизвестный логин разовым SELECT (lct-42). Ожидание здесь
# лишило бы доказательности проверку ниже: опрос B сам вызвал бы разовую
# проверку, а за секунду B узнал бы логин сверкой.
control_sent_at = await send_control(ws_base, session_id, instructor_cookie, {
"type": "scenario.start", "scenario_id": args.scenario,
"trainee": trainee.name, "trainee_id": str(trainee.id),
"mode": "training", "exercise": "dds",
})
result["users_commit_to_control_seconds"] = round(control_sent_at - users_committed_at, 4)
if args.expect_initial_owner == "backend-b":
result["checks"]["control_sent_within_first_second"] = (
result["users_commit_to_control_seconds"] < 1
)
# Сверка B идёт раз в секунду в случайной фазе, и время само по себе
# не доказывает, что B не знал логин. Доказательство — запись B о
# разовой проверке именно этого логина.
result["checks"]["backend_b_resolved_login_one_shot"] = (
login_log_marker(instructor_login)
in compose(args, "logs", "--no-color", "backend-b")
)
async def session_row():
async with factory() as db:
return await db.scalar(select(Session).where(Session.id == session_id))
async def wait_for_command_checkpoint(command_id: str, timeout: float = 10) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
# Use a new physical connection, avoiding a pooled observer's
# stale read transaction while the websocket writer commits.
async with observer_engine.connect() as db:
payload = (await db.execute(
select(Session.live_state).where(Session.id == session_id)
)).scalar_one_or_none()
if payload and command_id in payload.get("processed_station_commands", []):
return True
await asyncio.sleep(0.1)
return False
deadline = time.monotonic() + 12
row = None
while time.monotonic() < deadline:
row = await session_row()
if row and row.backend_node_id and row.checkpoint_at and row.live_state:
break
await asyncio.sleep(0.2)
if not row or not row.live_state:
raise RuntimeError("session did not persist an owner and recovery checkpoint")
if row.backend_node_id not in {"backend-a", "backend-b"}:
raise RuntimeError(f"unexpected session owner: {row.backend_node_id}")
owner_service = "backend" if row.backend_node_id == "backend-a" else "backend-b"
old_epoch = row.backend_fencing_epoch
result["initial_owner"] = row.backend_node_id
result["initial_epoch"] = old_epoch
if args.expect_initial_owner and row.backend_node_id != args.expect_initial_owner:
raise RuntimeError(
f"session routed to {row.backend_node_id}, expected {args.expect_initial_owner}"
)
if args.expect_initial_owner == "backend-b":
result["checks"]["login_a_initial_control_b"] = True
# Commit real DDS work before the fault. These actions must survive the
# checkpoint handoff and remain part of the final scored report.
snapshot = await station_command(ws_base, session_id, trainee_cookie)
service = snapshot.get("managed_service") or next(iter(snapshot["services"]), None)
if not service:
raise RuntimeError("the DDS exercise has no managed service")
crew = next(
(item for item in snapshot["crew_options"] if item.startswith(service + " — ")),
None,
)
if not crew:
raise RuntimeError(f"no crew option is available for {service}")
steps_before_fault = []
for status, detail in (
("accepted", "card accepted for processing"),
("crew.select", crew),
("responding", "crew reported departure"),
):
if status == "crew.select":
command = {"type": status, "crew": crew}
else:
command = {
"type": "card.status", "service": service, "status": status,
"comment": f"Основание: доклад по карточке.\nСведения: {detail}; {service} notified.",
}
snapshot = await station_command(ws_base, session_id, trainee_cookie, command)
steps_before_fault.append(status)
result["dds_progress_before_fault"] = steps_before_fault
result["dds_service"] = service
result["dds_crew"] = crew
# Commit a non-replace station command, but close the client socket
# without reading command.ack. The live database read proves the ID
# and business state are committed before the owner fails.
lost_ack_command_id = str(uuid4())
lost_ack_command = {
"type": "card.status", "service": service, "status": "arrived",
"comment": (
"Основание: доклад по карточке.\n"
f"Сведения: прибытие до отказа; {service} notified."
),
}
async with ws_connect(
ws_base, f"/ws/station/{session_id}", trainee_cookie,
) as lost_ack_socket:
for _ in range(60):
raw = await asyncio.wait_for(lost_ack_socket.recv(), timeout=10)
event = json.loads(raw) if isinstance(raw, str) else {}
if event.get("type") == "station.state":
break
if event.get("type") == "error":
raise RuntimeError(f"station rejected lost-ACK setup: {event.get('message')}")
else:
raise RuntimeError("station did not become ready for lost-ACK command")
await lost_ack_socket.send(json.dumps({
**lost_ack_command, "_command_id": lost_ack_command_id,
}, ensure_ascii=False))
# Observe protocol output in the harness, but deliberately do not
# dispatch the ACK to the simulated browser/outbox. This is the
# lost-ack boundary: the command is committed, client state stays
# pending, and the socket is closed before the application consumes
# command.ack.
deadline = time.monotonic() + 12
ack_seen = False
while time.monotonic() < deadline:
raw = await asyncio.wait_for(
lost_ack_socket.recv(), timeout=max(0.1, deadline - time.monotonic())
)
event = json.loads(raw) if isinstance(raw, str) else {}
if event.get("type") == "error":
raise RuntimeError(
f"station command failed before lost-ACK simulation: {event.get('message')}"
)
if event.get("type") == "command.ack":
ack_seen = event.get("command_id") == lost_ack_command_id
break
result["checks"]["lost_ack_command_ack_emitted"] = ack_seen
if not ack_seen:
raise RuntimeError("server did not emit command.ack for the DDS status command")
result["checks"]["lost_ack_command_committed"] = await wait_for_command_checkpoint(
lost_ack_command_id,
)
if not result["checks"]["lost_ack_command_committed"]:
raise RuntimeError("station command ID did not reach the PostgreSQL checkpoint")
# Do not consume any frame after send: the browser's pending
# command remains unacknowledged when this transport is closed.
await lost_ack_socket.close()
result["lost_ack_command_id"] = lost_ack_command_id
result["lost_ack_ack_dispatched_to_browser"] = False
if args.failure_mode == "kill":
fault_service = owner_service
compose(args, "kill", fault_service)
owner_stopped = True
else:
proxy_name = "db-proxy-a" if row.backend_node_id == "backend-a" else "db-proxy-b"
fault_service = proxy_name
async with AsyncExitStack() as channels:
active_sockets = {}
for channel, path, cookie in (
("control", f"/ws/control/{session_id}", instructor_cookie),
("call", f"/ws/call/{session_id}", trainee_cookie),
("observe", f"/ws/observe/{session_id}", instructor_cookie),
("station", f"/ws/station/{session_id}", trainee_cookie),
):
active_sockets[channel] = await channels.enter_async_context(
ws_connect(ws_base, path, cookie)
)
compose(args, "stop", proxy_name)
fault_stopped = True
# Реальная мутация старому владельцу уже без БД: commit не пройдёт,
# ACK не придёт, а id не должен попасть в checkpoint нового
# владельца — прямое доказательство запрета записи после потери БД.
# Отправка сразу, до `compose ps`: через 2 с auth закроет сокет.
boundary_command_id = str(uuid4())
boundary = {"command_id": boundary_command_id, "status": "working"}
try:
await active_sockets["station"].send(json.dumps({
"type": "card.status", "service": service, "status": "working",
"comment": (
"Основание: доклад по карточке.\n"
f"Сведения: начало работ после обрыва БД; {service} notified."
),
"_command_id": boundary_command_id,
}, ensure_ascii=False))
boundary["sent"] = True
except websockets.exceptions.WebSocketException as exc:
boundary["sent"] = False
boundary["error"] = f"{type(exc).__name__}: {exc}"
result["boundary_command_sent_to_old_owner"] = boundary
result["checks"]["boundary_command_sent_after_partition"] = boundary["sent"]
running_services = compose(args, "ps", "--status", "running", "--services").splitlines()
result["checks"]["owner_process_running"] = owner_service in running_services
if owner_service not in running_services:
raise RuntimeError("owner process did not remain running during DB partition")
closures = await asyncio.gather(*(
wait_for_fence(
socket, timeout=args.takeover_timeout,
ack_id=boundary_command_id if channel == "station" else None,
)
for channel, socket in active_sockets.items()
))
result["existing_channel_closures"] = dict(zip(active_sockets, closures))
result["existing_channel_fence_notices"] = {
channel: closure["fence_notice"]
for channel, closure in result["existing_channel_closures"].items()
}
result["checks"].update({
f"existing_{channel}_closed": closure["closed"]
for channel, closure in result["existing_channel_closures"].items()
})
# Без БД старый владелец закрывает каналы fail-closed одним из двух
# путей: auth не может сверить поколения учёток дольше 2 с — 1013,
# supervisor lease замечает потерю владения (цикл 5 с) — 1012.
# Auth обычно успевает первым; оба кода корректны, фронт их не
# различает. Фактический код остаётся в existing_channel_closures.
result["existing_channel_close_codes"] = {
channel: closure.get("close_code")
for channel, closure in result["existing_channel_closures"].items()
}
result["checks"].update({
f"existing_{channel}_closed_fail_closed": (
closure["fence_notice"] or (
closure.get("close_code") == 1013
and closure.get("reason") == AUTH_UNAVAILABLE_REASON
)
)
for channel, closure in result["existing_channel_closures"].items()
})
result["checks"]["boundary_command_not_acked"] = (
result["existing_channel_closures"]["station"].get("command_acked") is False
)
deadline = time.monotonic() + args.takeover_timeout
takeover = None
while time.monotonic() < deadline:
candidate = await session_row()
if candidate and candidate.backend_node_id != row.backend_node_id:
takeover = candidate
break
await asyncio.sleep(0.5)
if takeover is None:
raise RuntimeError("other backend did not take over the expired lease")
result["takeover_owner"] = takeover.backend_node_id
result["takeover_epoch"] = takeover.backend_fencing_epoch
result["takeover_seconds"] = round(args.takeover_timeout - max(0, deadline - time.monotonic()), 2)
result["checks"]["owner_changed"] = takeover.backend_node_id != row.backend_node_id
result["checks"]["fencing_epoch_incremented"] = takeover.backend_fencing_epoch > old_epoch
result["checks"]["checkpoint_restored"] = bool(takeover.live_state and takeover.checkpoint_at)
result["checks"]["lost_ack_command_replayed"] = await replay_station_command(
ws_base, session_id, trainee_cookie, lost_ack_command,
lost_ack_command_id, attempts=args.reconnect_attempts,
)
if args.failure_mode == "partition":
compose(args, "start", fault_service)
fault_stopped = False
await asyncio.sleep(6) # let the surviving old process observe the newer epoch
old_backend_port = args.backend_port if owner_service == "backend" else args.backend_b_port
old_backend = f"http://127.0.0.1:{old_backend_port}"
try:
with urllib.request.urlopen(f"{old_backend}/api/health", timeout=5) as response:
result["checks"]["old_owner_process_healthy"] = response.status == 200
except Exception as exc:
result["checks"]["old_owner_process_healthy"] = False
result["old_owner_health_error"] = f"{type(exc).__name__}: {exc}"
# Auth на старом узле снова свежий: 1013 до accept исключён, и 403
# на рукопожатии ниже остаётся только за fencing занятия (1012).
result["checks"]["old_owner_auth_fresh"] = await wait_auth_fresh(
old_backend, instructor_cookie,
)
stale = await probe_ws(
old_backend.replace("http://", "ws://"),
f"/ws/control/{session_id}", instructor_cookie,
)
result["stale_owner_control"] = stale
result["checks"]["stale_owner_control_rejected"] = (
not stale["connected"] and "HTTP 403" in stale.get("error", "")
)
# БД вернулась к старому владельцу, но его команда после обрыва так
# и не должна появиться в checkpoint: fencing epoch отверг запись.
result["checks"]["boundary_command_absent_from_checkpoint"] = (
not await wait_for_command_checkpoint(boundary_command_id, timeout=1)
)
request = urllib.request.Request(
f"{args.frontend_url}/api/sessions/{session_id}",
headers={"Cookie": instructor_cookie},
)
rest_started = time.monotonic()
rest_deadline = rest_started + args.rest_timeout
rest_attempt = 0
while time.monotonic() < rest_deadline:
rest_attempt += 1
try:
with urllib.request.urlopen(
request,
timeout=min(3.0, max(0.2, rest_deadline - time.monotonic())),
context=ssl._create_unverified_context(),
) as response:
result["checks"]["session_rest"] = response.status == 200
if result["checks"]["session_rest"]:
result.pop("rest_error", None)
break
except urllib.error.HTTPError as exc:
result["rest_error"] = f"HTTP {exc.code}"
except Exception as exc:
result["rest_error"] = f"{type(exc).__name__}: {exc}"
await asyncio.sleep(min(1, max(0, rest_deadline - time.monotonic())))
result["checks"].setdefault("session_rest", False)
result["rest_attempts"] = rest_attempt
result["rest_elapsed_seconds"] = round(time.monotonic() - rest_started, 2)
async def reconnect_probe(path: str, cookie: str) -> dict:
last = {}
for attempt in range(1, args.reconnect_attempts + 1):
last = await probe_ws(ws_base, path, cookie)
if last["connected"]:
last["attempts"] = attempt
return last
await asyncio.sleep(1)
last["attempts"] = args.reconnect_attempts
return last
probes = {
"control": await reconnect_probe(f"/ws/control/{session_id}", instructor_cookie),
"call": await reconnect_probe(f"/ws/call/{session_id}", trainee_cookie),
"observe": await reconnect_probe(f"/ws/observe/{session_id}", instructor_cookie),
"station": await reconnect_probe(f"/ws/station/{session_id}", trainee_cookie),
}
result["websockets"] = probes
result["checks"].update({f"ws_{name}": probe["connected"] for name, probe in probes.items()})
# Reconcile the race status against the recovered checkpoint. If it
# committed before the fault, do not repeat it; otherwise issue it now.
recovered_snapshot = await station_command(ws_base, session_id, trainee_cookie)
recovered_status = recovered_snapshot["statuses"].get(service)
result["boundary_status_after_takeover"] = recovered_status
result["boundary_command_outcome"] = (
"committed before takeover" if recovered_status == "arrived"
else "not in recovered checkpoint; reconciled after takeover"
)
result["checks"]["boundary_command_reconciled"] = recovered_status in {
"responding", "arrived",
}
if args.failure_mode == "partition":
# `working` после обрыва не применён: статус остался на lost-ack `arrived`.
result["checks"]["boundary_command_not_applied"] = recovered_status == "arrived"
result["dds_progress_after_takeover"] = []
if recovered_status == "responding":
recovered_snapshot = await station_command(ws_base, session_id, trainee_cookie, {
"type": "card.status", "service": service, "status": "arrived",
"comment": "Основание: доклад по карточке.\n"
f"Сведения: бригада сообщила о прибытии; {service} notified.",
})
result["dds_progress_after_takeover"].append("arrived")
# Continue the same card after owner recovery and require a stored final
# score, not merely successful handshakes on the replacement owner.
for status, detail in (
("working", "crew started work"),
("completed", "crew completed work"),
):
snapshot = await station_command(ws_base, session_id, trainee_cookie, {
"type": "card.status", "service": service, "status": status,
"comment": f"Основание: доклад по карточке.\nСведения: {detail}; {service} notified.",
})
result["dds_progress_after_takeover"].append(status)
async with websockets.connect(
f"{ws_base}/ws/station/{session_id}",
additional_headers={"Cookie": trainee_cookie},
ssl=ssl._create_unverified_context() if ws_base.startswith("wss://") else None,
open_timeout=10, ping_interval=None,
) as socket:
await socket.send(json.dumps({"type": "station.finish"}))
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
raw = await asyncio.wait_for(socket.recv(), timeout=10)
event = json.loads(raw) if isinstance(raw, str) else {}
if event.get("type") == "error":
raise RuntimeError(f"station finish rejected: {event.get('message')}")
if event.get("type") == "score.ready":
result["checks"]["dds_finished"] = True
break
else:
raise RuntimeError("DDS exercise did not finish after takeover")
report_request = urllib.request.Request(
f"{args.frontend_url}/api/sessions/{session_id}/report",
headers={"Cookie": instructor_cookie},
)
with urllib.request.urlopen(
report_request, timeout=10, context=ssl._create_unverified_context(),
) as response:
report = json.loads(response.read())
result["dds_final_score"] = report.get("score_auto")
card_results = report.get("card_results", [])
if len(card_results) != 1:
raise RuntimeError(f"expected one DDS card result, got {len(card_results)}")
final_card = card_results[0]
result["dds_card_metrics"] = {
metric["key"]: metric["passed"] for metric in final_card["metrics"]
}
required_metric_keys = {
"dds_ack", "dds_decision", "dds_crew", "dds_progress",
"dds_completion", "dds_reply",
}
result["checks"]["dds_business_metrics_passed"] = all(
result["dds_card_metrics"].get(key) is True for key in required_metric_keys
)
from collections import Counter
observed_status_counts = Counter(
action.get("status") for action in final_card["actions"]
if action.get("type") == "card.status" and action.get("service") == service
)
expected_statuses = {"accepted", "responding", "arrived", "working", "completed"}
result["checks"]["dds_statuses_persisted_exactly_once"] = all(
observed_status_counts[status] == 1 for status in expected_statuses
)
result["dds_status_counts"] = dict(observed_status_counts)
result["checks"]["lost_ack_status_recorded_once"] = (
observed_status_counts["arrived"] == 1
)
passed = all(result["checks"].values())
result["pass"] = passed
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if passed else 1
finally:
if instructor_cookie:
try:
await send_control(ws_base, session_id, instructor_cookie, {"type": "session.stop"})
except Exception:
pass
if fault_stopped and fault_service:
compose(args, "start", fault_service, check=False)
if owner_stopped:
compose(args, "start", owner_service, check=False)
async with factory() as db:
row = await db.scalar(select(Session).where(Session.id == session_id))
if row:
await db.execute(delete(Session).where(Session.id == session_id))
await db.execute(delete(AuditLog).where(AuditLog.object_id == str(session_id)))
await db.execute(delete(AuditLog).where(AuditLog.actor.in_([instructor_login, trainee_login])))
await db.execute(delete(User).where(User.login.in_([instructor_login, trainee_login])))
await db.execute(delete(Trainee).where(Trainee.name == trainee.name))
await db.commit()
await engine.dispose()
await observer_engine.dispose()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--project", required=True)
parser.add_argument("--postgres-port", type=int, required=True)
parser.add_argument("--backend-port", type=int, required=True)
parser.add_argument("--backend-b-port", type=int, default=18001)
parser.add_argument("--frontend-port", type=int, required=True)
parser.add_argument("--tls-port", type=int, required=True)
parser.add_argument("--postgres-password", required=True)
parser.add_argument("--session-secret", required=True)
parser.add_argument("--tls-cert-dir", required=True)
parser.add_argument("--frontend-url", required=True)
parser.add_argument("--scenario", default="fire-apartment-l2")
parser.add_argument("--takeover-timeout", type=float, default=30)
parser.add_argument("--reconnect-attempts", type=int, default=5)
parser.add_argument("--rest-timeout", type=float, default=60)
parser.add_argument("--failure-mode", choices=("kill", "partition"), default="kill")
parser.add_argument("--expect-initial-owner", choices=("backend-a", "backend-b"))
parser.add_argument("--session-id", type=UUID)
raise SystemExit(asyncio.run(main(parser.parse_args())))

View file

@ -1,402 +0,0 @@
#!/usr/bin/env python3
"""Cross-process smoke: publish a trainee scenario on backend A, start it on B.
Use only with a disposable local Compose project and its own PostgreSQL. The
smoke creates temporary accounts, group, proposal, and DDS session and removes
their rows in ``finally``. It never writes evidence into the repository.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
from pathlib import Path
import secrets
import ssl
import subprocess
import time
import urllib.error
import urllib.request
from uuid import UUID, uuid4
import websockets
def compose(args: argparse.Namespace, *command: str) -> str:
env = os.environ.copy()
env.update({
"POSTGRES_PORT": str(args.postgres_port),
"BACKEND_PORT": str(args.backend_a_port),
"BACKEND_B_PORT": str(args.backend_b_port),
"FRONTEND_PORT": str(args.frontend_port),
"TLS_PORT": str(args.tls_port),
"POSTGRES_PASSWORD": args.postgres_password,
"SESSION_SECRET": args.session_secret,
"TLS_CERT_DIR": args.tls_cert_dir,
})
files = [
"docker-compose.yml", "docker-compose.tls.yml",
"docker-compose.load-test.yml", "docker-compose.cluster.yml",
]
command_result = subprocess.run(
["docker", "compose", "-p", args.project,
*[part for file in files for part in ("-f", file)], *command],
check=False, capture_output=True, text=True, env=env,
)
if command_result.returncode:
raise RuntimeError(
f"docker compose {' '.join(command)} failed: {command_result.stderr.strip()}"
)
return command_result.stdout
def request_json(base: str, path: str, *, cookie: str | None = None,
payload: dict | None = None) -> dict | list:
headers = {"Content-Type": "application/json"}
if cookie:
headers["Cookie"] = cookie
data = json.dumps(payload, ensure_ascii=False).encode() if payload is not None else None
request = urllib.request.Request(f"{base}{path}", data=data, headers=headers,
method="POST" if data is not None else "GET")
with urllib.request.urlopen(request, timeout=15) as response:
return json.loads(response.read())
def login(base: str, login_name: str, password: str) -> str:
payload = json.dumps({"login": login_name, "password": password}).encode()
request = urllib.request.Request(
f"{base}/api/auth/login", data=payload, method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(request, timeout=15) as response:
cookie = response.headers.get("Set-Cookie", "").split(";", 1)[0]
if not cookie.startswith("lct_session="):
raise RuntimeError(f"{base} did not issue the session cookie")
return cookie
async def main(args: argparse.Namespace) -> int:
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.api.auth import hash_password
from app.db.models import (
AuditLog, Group, Scenario as ScenarioRow, ScenarioSubmission,
Session, Trainee, User,
)
from app.domain import ekp
from app.scenarios import store
db_url = (f"postgresql+asyncpg://lct:{args.postgres_password}@127.0.0.1:"
f"{args.postgres_port}/lct")
engine = create_async_engine(db_url, pool_pre_ping=True)
factory = async_sessionmaker(engine, expire_on_commit=False)
suffix = uuid4().hex[:12]
teacher_login, trainee_login = f"registry-i-{suffix}", f"registry-t-{suffix}"
teacher_password, trainee_password = secrets.token_urlsafe(24), secrets.token_urlsafe(24)
teacher_name, trainee_name = "Registry smoke instructor", f"Registry smoke {suffix}"
group = Group(name=f"registry-smoke-{suffix}", owner_login=teacher_login)
trainee = Trainee(name=trainee_name)
scenario_id: str | None = None
session_id = uuid4()
session_ids: list[UUID] = []
submission_id: UUID | None = None
teacher_cookie = trainee_cookie = None
session_ws_base = None
ssl_context = None
result: dict = {"checks": {}}
a = f"http://127.0.0.1:{args.backend_a_port}"
b = f"http://127.0.0.1:{args.backend_b_port}"
try:
store.load_from_disk(Path(args.scenarios))
source = store.get(args.source_scenario)
if source is None or source.ground_truth.dds is None:
raise RuntimeError(f"invalid source scenario {args.source_scenario}")
trainee.group_id = None
async with factory() as db:
db.add(group)
await db.flush()
trainee.group_id = group.id
db.add(trainee)
await db.flush()
db.add_all([
User(login=teacher_login, full_name=teacher_name,
password_hash=hash_password(teacher_password), role="instructor",
blocked=False),
User(login=trainee_login, full_name=trainee_name,
password_hash=hash_password(trainee_password), role="trainee",
trainee_id=trainee.id, blocked=False),
])
await db.commit()
result["checks"]["backend_a_healthy"] = request_json(a, "/api/health").get("status") == "ok"
result["checks"]["backend_b_healthy"] = request_json(b, "/api/health").get("status") == "ok"
teacher_cookie = login(a, teacher_login, teacher_password)
trainee_cookie = login(a, trainee_login, trainee_password)
incident_group = ekp.incident(source.ground_truth.incident_code).group
created = request_json(a, "/api/scenario-submissions", cookie=trainee_cookie, payload={
"title": f"Межузловая проверка {suffix}", "level": source.level.value,
"kio": {
"caller_name": "Тестовый заявитель",
"caller_contact": "+7 900 000-00-00",
"address": f"Москва, учебная улица, дом {suffix[:3]}",
"description": "На учебном объекте обнаружено задымление и нужна бригада.",
"incident_group": incident_group,
"signs": source.signs,
"incident_type": source.type.value,
"dds": source.ground_truth.dds.value,
"victims_count": 0,
"fire": {"object_kind": "учебное помещение", "fire_nature": "задымление"},
},
})
submission_id = UUID(created["id"])
approved = request_json(a, f"/api/scenario-submissions/{submission_id}/review",
cookie=teacher_cookie,
payload={"decision": "approve", "comment": "Межузловой smoke."})
scenario_id = approved["scenario_id"]
result["scenario_id"] = scenario_id
result["published_on"] = "backend-a"
result["checks"]["submission_approved_on_a"] = approved["status"] == "approved"
# WS start is the first scenario operation on the selected target node.
# With --frontend-url, UUIDs are tried until Nginx consistent-hash routes
# one to B; every candidate is pinned for all session channels.
session_ws_base = args.frontend_url.replace("https://", "wss://").replace(
"http://", "ws://"
) if args.frontend_url else b.replace("http://", "ws://")
ssl_context = ssl._create_unverified_context() if session_ws_base.startswith("wss://") else None
target_backend = "backend-b"
max_route_attempts = 12 if args.frontend_url else 1
session_row = None
for route_attempt in range(max_route_attempts):
candidate_id = uuid4()
session_ids.append(candidate_id)
session_id = candidate_id
ws_url = f"{session_ws_base}/ws/control/{session_id}"
async with websockets.connect(
ws_url, additional_headers={"Cookie": teacher_cookie}, ssl=ssl_context,
open_timeout=15, ping_interval=None,
) as control:
await control.send(json.dumps({
"type": "scenario.start", "scenario_id": scenario_id,
"scenario_ids": [scenario_id], "trainee": trainee_name,
"trainee_id": str(trainee.id), "dds_service": created["kio"]["notify"][0],
"mode": "training", "exercise": "dds",
}, ensure_ascii=False))
async with factory() as db:
deadline = time.monotonic() + 8
session_row = None
while time.monotonic() < deadline:
session_row = await db.scalar(
select(Session).where(Session.id == candidate_id)
)
if session_row and session_row.backend_node_id:
break
await asyncio.sleep(0.1)
if session_row and session_row.backend_node_id == target_backend:
break
if session_row and session_row.backend_node_id:
result.setdefault("nginx_route_attempts", []).append({
"session_id": str(candidate_id),
"owner": session_row.backend_node_id,
})
await control.send(json.dumps({"type": "session.stop"}))
await asyncio.sleep(0.15)
else:
raise RuntimeError("session.start was not persisted through the selected route")
result["checks"]["session_started_on_backend_b"] = bool(
session_row and session_row.backend_node_id == target_backend
and session_row.scenario_id == scenario_id and session_row.live_state
)
if not result["checks"]["session_started_on_backend_b"]:
raise RuntimeError("could not route a fresh session to backend B")
result["session_id"] = str(session_id)
result["route_attempts"] = len(session_ids)
# Prove the user-visible catalog/detail routes while B is still alive.
catalog = request_json(b, "/api/scenarios", cookie=teacher_cookie)
entry = next((item for item in catalog if item["id"] == scenario_id), None)
result["checks"]["visible_in_backend_b_catalog"] = entry is not None
result["checks"]["owned_by_teacher_on_backend_b"] = bool(entry and entry["can_manage"])
detail = request_json(b, f"/api/scenarios/{scenario_id}", cookie=teacher_cookie)
result["checks"]["detail_loaded_from_backend_b"] = (
detail.get("student_card", {}).get("address")
== created["kio"]["address"]
)
if args.failure_mode == "kill-backend-b":
old_epoch = session_row.backend_fencing_epoch
compose(args, "kill", "backend-b")
result["checks"]["backend_b_killed_after_start"] = True
deadline = time.monotonic() + args.takeover_timeout
takeover_row = None
while time.monotonic() < deadline:
async with factory() as db:
takeover_row = await db.scalar(
select(Session).where(Session.id == session_id)
)
if takeover_row and takeover_row.backend_node_id == "backend-a":
break
await asyncio.sleep(0.25)
result["checks"]["takeover_to_backend_a"] = bool(
takeover_row and takeover_row.backend_node_id == "backend-a"
)
result["checks"]["fencing_epoch_incremented"] = bool(
takeover_row and takeover_row.backend_fencing_epoch > old_epoch
)
result["checks"]["checkpoint_restored_after_kill"] = bool(
takeover_row and takeover_row.live_state and takeover_row.checkpoint_at
)
if not all(result["checks"][key] for key in (
"takeover_to_backend_a", "fencing_epoch_incremented",
"checkpoint_restored_after_kill",
)):
raise RuntimeError("backend A did not take over the killed B session")
result["takeover_seconds"] = round(
args.takeover_timeout - max(0, deadline - time.monotonic()), 2
)
station_url = f"{session_ws_base}/ws/station/{session_id}"
station_owner_key = (
"dds_card_received_after_takeover" if args.failure_mode == "kill-backend-b"
else "dds_card_received_on_backend_b"
)
station_snapshot = None
received_card = None
last_station_error = None
for reconnect_attempt in range(8):
try:
async with websockets.connect(
station_url, additional_headers={"Cookie": trainee_cookie}, ssl=ssl_context,
open_timeout=10, ping_interval=None,
) as station:
for _ in range(60):
event = json.loads(await asyncio.wait_for(station.recv(), timeout=10))
if event.get("type") == "error":
raise RuntimeError(
f"station error after route/failover: {event.get('message')}"
)
if event.get("type") == "card.received":
received_card = event["card"]
elif event.get("type") == "station.state":
station_snapshot = event.get("snapshot")
if received_card is not None and station_snapshot is not None:
break
if received_card is None or station_snapshot is None:
raise RuntimeError("station reconnect did not restore card and snapshot")
break
except (TimeoutError, OSError, websockets.exceptions.WebSocketException,
RuntimeError) as exc:
last_station_error = exc
if reconnect_attempt == 7:
raise RuntimeError(f"station did not recover through proxy: {exc}") from exc
await asyncio.sleep(1)
result["checks"][station_owner_key] = received_card is not None
result["checks"]["approved_kio_preserved"] = bool(
received_card
and received_card.get("address") == created["kio"]["address"]
and received_card.get("caller_name") == created["kio"]["caller_name"]
)
if args.failure_mode == "kill-backend-b":
command_id = str(uuid4())
command = {
"type": "card.status", "service": station_snapshot["managed_service"],
"status": "accepted",
"comment": (
"Основание: доклад по карточке.\n"
f"Сведения: карточка повторно принята после takeover; {station_snapshot['managed_service']} notified."
),
"_command_id": command_id,
}
async with websockets.connect(
station_url, additional_headers={"Cookie": trainee_cookie}, ssl=ssl_context,
open_timeout=10, ping_interval=None,
) as station:
await station.send(json.dumps(command, ensure_ascii=False))
acked = False
for _ in range(40):
event = json.loads(await asyncio.wait_for(station.recv(), timeout=10))
if event.get("type") == "error":
raise RuntimeError(f"station command failed after takeover: {event.get('message')}")
if event.get("type") == "command.ack" and event.get("command_id") == command_id:
acked = True
break
result["checks"]["station_command_acked_after_takeover"] = acked
async with factory() as db:
restored = await db.scalar(select(Session).where(Session.id == session_id))
result["checks"]["post_takeover_command_checkpointed"] = bool(
restored and restored.backend_node_id == "backend-a"
and command_id in (restored.live_state or {}).get(
"processed_station_commands", []
)
)
result["pass"] = all(result["checks"].values())
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if result["pass"] else 1
finally:
if args.failure_mode == "kill-backend-b" and args.project:
try:
compose(args, "start", "backend-b")
except Exception:
pass
if teacher_cookie and session_ws_base:
try:
stop_base = (
f"ws://127.0.0.1:{args.backend_a_port}"
if args.failure_mode == "kill-backend-b" else session_ws_base
)
stop_ssl = None if stop_base.startswith("ws://") else ssl_context
async with websockets.connect(
f"{stop_base}/ws/control/{session_id}",
additional_headers={"Cookie": teacher_cookie}, ssl=stop_ssl,
open_timeout=5, ping_interval=None,
) as control:
await control.send(json.dumps({"type": "session.stop"}))
except Exception:
pass
async with factory() as db:
await db.execute(delete(AuditLog).where(
AuditLog.object_id.in_([str(item) for item in session_ids])
))
if submission_id:
await db.execute(delete(ScenarioSubmission).where(
ScenarioSubmission.id == submission_id
))
if session_ids:
await db.execute(delete(Session).where(Session.id.in_(session_ids)))
if scenario_id:
await db.execute(delete(ScenarioRow).where(ScenarioRow.id == scenario_id))
await db.execute(delete(AuditLog).where(
AuditLog.actor.in_([teacher_login, trainee_login])
))
await db.execute(delete(User).where(User.login.in_([teacher_login, trainee_login])))
await db.execute(delete(Trainee).where(Trainee.name == trainee_name))
await db.execute(delete(Group).where(Group.id == group.id))
await db.commit()
await engine.dispose()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--postgres-port", type=int, required=True)
parser.add_argument("--backend-a-port", type=int, required=True)
parser.add_argument("--backend-b-port", type=int, required=True)
parser.add_argument("--postgres-password", required=True)
parser.add_argument("--frontend-url", help="optional TLS Nginx URL; tests consistent-hash routing")
parser.add_argument("--failure-mode", choices=("none", "kill-backend-b"), default="none")
parser.add_argument("--project", help="Compose project, required for --failure-mode kill-backend-b")
parser.add_argument("--tls-cert-dir", default="")
parser.add_argument("--session-secret", default="")
parser.add_argument("--frontend-port", type=int, default=15180)
parser.add_argument("--tls-port", type=int, default=15443)
parser.add_argument("--takeover-timeout", type=float, default=45)
parser.add_argument("--source-scenario", default="t01-1-fire-container")
parser.add_argument("--scenarios", default="scenarios")
parsed = parser.parse_args()
if parsed.failure_mode == "kill-backend-b" and not (
parsed.frontend_url and parsed.project and parsed.session_secret and parsed.tls_cert_dir
):
parser.error("kill-backend-b requires --frontend-url, --project, --session-secret and --tls-cert-dir")
raise SystemExit(asyncio.run(main(parsed)))

View file

@ -2,18 +2,29 @@
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
project="lct-test-$$-$RANDOM$RANDOM"
compose=(docker compose -p "$project" -f "$repo_root/docker-compose.test-db.yml")
container="lct-test-$$-$RANDOM$RANDOM"
cleanup() {
trap - EXIT INT TERM
"${compose[@]}" down --volumes --remove-orphans >/dev/null || true
docker rm --force --volumes "$container" >/dev/null 2>&1 || true
}
trap cleanup EXIT
printf 'Изолированный тестовый Compose project: %s\n' "$project"
"${compose[@]}" up --pull never --detach --wait
port_mapping=$("${compose[@]}" port postgres 5432)
# Временная БД без compose-файла и без тома: dev-база не затрагивается,
# порт на loopback выбирает Docker.
printf 'Изолированная тестовая PostgreSQL: %s\n' "$container"
docker run --detach --rm --name "$container" \
--env POSTGRES_USER=lct_test --env POSTGRES_PASSWORD=lct_test --env POSTGRES_DB=lct_test \
--publish 127.0.0.1::5432 \
--health-cmd 'pg_isready -U lct_test -d lct_test' --health-interval 2s --health-retries 30 \
postgres:16-alpine >/dev/null
for _ in $(seq 60); do
status=$(docker inspect --format '{{.State.Health.Status}}' "$container")
[[ $status == healthy ]] && break
sleep 1
done
[[ $status == healthy ]] || { echo "PostgreSQL не поднялась: $status" >&2; exit 1; }
port_mapping=$(docker port "$container" 5432/tcp | head -n1)
db_port="${port_mapping##*:}"
database_url="postgresql+asyncpg://lct_test:lct_test@127.0.0.1:${db_port}/lct_test"
evidence_dir="${LCT_TEST_EVIDENCE_DIR:-$repo_root/.local/evidence}"

View file

@ -1,35 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
project="lct-production-check-$$"
port=$((20000 + $$ % 40000))
secret="prod-check-$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')"
compose=(docker compose -p "$project" -f "$repo_root/docker-compose.yml" \
-f "$repo_root/docker-compose.production.yml")
cleanup() {
POSTGRES_PASSWORD="$secret" POSTGRES_PORT="$port" \
"${compose[@]}" down --volumes --remove-orphans >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
if env -u POSTGRES_PASSWORD "${compose[@]}" config --quiet >/dev/null 2>&1; then
echo "ОШИБКА: production Compose запустился без POSTGRES_PASSWORD" >&2
exit 1
fi
POSTGRES_PASSWORD="$secret" POSTGRES_PORT="$port" \
"${compose[@]}" up --pull never --detach --wait postgres
network="${project}_default"
docker run --pull never --rm --network "$network" -e "PGPASSWORD=$secret" \
postgres:16-alpine psql -h postgres -U lct -d lct -Atc 'select 1' | rg -x '1' >/dev/null
if docker run --pull never --rm --network "$network" -e PGPASSWORD=wrong \
postgres:16-alpine psql -h postgres -U lct -d lct -Atc 'select 1' >/dev/null 2>&1; then
echo "ОШИБКА: production PostgreSQL принял неверный пароль" >&2
exit 1
fi
echo "Production PostgreSQL принял пароль из backend-сети и отклонил неверный."

View file

@ -100,7 +100,7 @@ async def compose_service(project: str, action: str, service: str) -> None:
async def run(args: argparse.Namespace) -> int:
frontend = local_url(args.frontend_url, {"https"})
frontend = local_url(args.frontend_url, {"http", "https"})
backend = local_url(args.backend_url, {"http"})
if not args.compose_project.strip():
raise ValueError("--compose-project обязателен: recovery test останавливает Nginx")
@ -374,7 +374,7 @@ async def run(args: argparse.Namespace) -> int:
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--frontend-url", default="https://127.0.0.1:5443")
parser.add_argument("--frontend-url", default="http://127.0.0.1:5173")
parser.add_argument("--backend-url", default="http://127.0.0.1:8000")
parser.add_argument("--compose-project", required=True,
help="точное имя изолированного Compose project; тест останавливает его frontend")

View file

@ -1,48 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
project="lct-sip-smoke-$(date +%Y%m%d)-$$-${RANDOM:-0}"
compose=(docker compose -p "$project" -f "$repo_root/docker-compose.yml" -f "$repo_root/docker-compose.sip.yml")
sip_port="${SIP_SMOKE_PORT:-15170}"
sips_port="${SIP_SMOKE_SIPS_PORT:-15171}"
websocket_port="${SIP_SMOKE_WS_PORT:-18098}"
rtp_start="${SIP_SMOKE_RTP_START:-12000}"
rtp_end=$((rtp_start + 99))
rtp_offset=$((rtp_start - 10000))
tls_dir="$(mktemp -d /tmp/lct-sip-smoke-tls-XXXXXX)"
sip_smoke_password="$(openssl rand -hex 24)"
cleanup() {
trap - EXIT INT TERM
"${compose[@]}" down --volumes --remove-orphans >/dev/null || true
case "$tls_dir" in
/tmp/lct-sip-smoke-tls-*) rm -rf -- "$tls_dir" ;;
*) printf 'Refusing to remove unexpected TLS temp path: %s\n' "$tls_dir" >&2 ;;
esac
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
python3 -c 'import socket,sys; ports=[("tcp",int(sys.argv[1])),("udp",int(sys.argv[1])),("tcp",int(sys.argv[2])),("tcp",int(sys.argv[3]))]+[("udp",p) for p in range(int(sys.argv[4]),int(sys.argv[5])+1)]; sockets=[]
try:
for kind,port in ports:
sock=socket.socket(socket.AF_INET,socket.SOCK_STREAM if kind=="tcp" else socket.SOCK_DGRAM)
sock.bind(("127.0.0.1",port)); sockets.append(sock)
except OSError as exc:
raise SystemExit(f"SIP smoke port {port}/{kind} is unavailable: {exc}")
finally:
[sock.close() for sock in sockets]' "$sip_port" "$sips_port" "$websocket_port" "$rtp_start" "$rtp_end"
printf 'Starting isolated SIP smoke project: %s\n' "$project"
SIP_6001_PASSWORD="$sip_smoke_password" \
SIP_PORT="$sip_port" SIPS_PORT="$sips_port" SIP_WS_PORT="$websocket_port" \
RTP_PORT_START="$rtp_start" RTP_PORT_END="$rtp_end" \
SIP_TLS_DIR="$tls_dir" SIP_EXTERNAL_MEDIA_ADDRESS=127.0.0.1 \
"${compose[@]}" up --pull never --no-build --detach --wait --no-deps sip
SIP_PASSWORD="$sip_smoke_password" python3 "$repo_root/scripts/smoke_sip.py" \
--host 127.0.0.1 --port "$sip_port" --rtp-host-offset "$rtp_offset" \
--output "$repo_root/docs/evidence/sip-rtp-2026-09-25.json"

View file

@ -1,83 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
project="lct-webrtc-smoke-$$-${RANDOM:-0}"
compose=(docker compose -p "$project" \
-f "$repo_root/docker-compose.yml" \
-f "$repo_root/docker-compose.tls.yml" \
-f "$repo_root/docker-compose.sip.yml" \
-f "$repo_root/docker-compose.webrtc-test.yml")
temp_root="$(mktemp -d /tmp/lct-webrtc-smoke-XXXXXX)"
# Keep all ports in one checked block; RTP gets its own 100-port block.
base_port=$((30000 + ($$ % 15000)))
export COMPOSE_PROJECT_NAME="$project"
export BIND_HOST=127.0.0.1
export POSTGRES_PORT=$base_port
export BACKEND_PORT=$((base_port + 1))
export FRONTEND_PORT=$((base_port + 2))
export TLS_PORT=$((base_port + 3))
export SIP_PORT=$((base_port + 4))
export SIPS_PORT=$((base_port + 5))
export SIP_WS_PORT=$((base_port + 6))
export RTP_PORT_START=$((base_port + 100))
export RTP_PORT_END=$((base_port + 199))
export SIP_RTP_START="$RTP_PORT_START"
export SIP_RTP_END="$RTP_PORT_END"
export POSTGRES_PASSWORD="$(openssl rand -hex 24)"
export SESSION_SECRET="$(openssl rand -hex 48)"
export TLS_CERT_DIR="$temp_root/frontend-tls"
export SIP_TLS_DIR="$temp_root/sip-tls"
export SIP_EXTERNAL_MEDIA_ADDRESS=127.0.0.1
mkdir -p "$TLS_CERT_DIR" "$SIP_TLS_DIR"
cleanup() {
status=$?
trap - EXIT INT TERM
if ((status != 0)); then
failure_log="/tmp/${project}-compose.log"
"${compose[@]}" logs --no-color --tail 250 >"$failure_log" 2>&1 || true
printf 'Isolated WebRTC diagnostics saved to %s\n' "$failure_log" >&2
fi
"${compose[@]}" down --volumes --remove-orphans >/dev/null || true
case "$temp_root" in
/tmp/lct-webrtc-smoke-*) rm -rf -- "$temp_root" ;;
*) printf 'Refusing to remove unexpected temporary path: %s\n' "$temp_root" >&2 ;;
esac
exit "$status"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
python3 -c 'import socket,sys
ports=[("tcp",int(sys.argv[1])),("tcp",int(sys.argv[2])),("tcp",int(sys.argv[3])),
("tcp",int(sys.argv[4])),("tcp",int(sys.argv[5])),("udp",int(sys.argv[5])),
("tcp",int(sys.argv[6])),("tcp",int(sys.argv[7]))]
ports.extend(("udp",p) for p in range(int(sys.argv[8]),int(sys.argv[9])+1))
sockets=[]
try:
for kind,port in ports:
sock=socket.socket(socket.AF_INET,socket.SOCK_STREAM if kind=="tcp" else socket.SOCK_DGRAM)
sock.bind(("127.0.0.1",port)); sockets.append(sock)
except OSError as exc:
raise SystemExit(f"isolated WebRTC smoke port {port}/{kind} unavailable: {exc}")
finally:
[sock.close() for sock in sockets]' \
"$POSTGRES_PORT" "$BACKEND_PORT" "$FRONTEND_PORT" "$TLS_PORT" "$SIP_PORT" \
"$SIPS_PORT" "$SIP_WS_PORT" "$RTP_PORT_START" "$RTP_PORT_END"
printf 'Starting isolated WebRTC smoke project: %s\n' "$project"
cd "$repo_root"
npm --prefix frontend run build
"${compose[@]}" build backend sip
"${compose[@]}" up --no-build --pull never --detach --wait --wait-timeout 300 \
postgres backend frontend sip
python3 "$repo_root/scripts/smoke_webrtc_browser.py" \
--frontend-url "https://127.0.0.1:$TLS_PORT" \
--backend-url "http://127.0.0.1:$BACKEND_PORT" \
--database-url "postgresql+asyncpg://lct:${POSTGRES_PASSWORD}@127.0.0.1:${POSTGRES_PORT}/lct" \
--timeout "${WEBRTC_TEST_TIMEOUT:-60}" \
--output "$repo_root/docs/evidence/webrtc-browser-isolated-2026-09-26.json"

View file

@ -1,25 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [[ -n "${POSTGRES_PASSWORD+x}" ]]; then
password="$POSTGRES_PASSWORD"
elif [[ -f "$repo_root/.env" ]]; then
matches="$(grep -c '^POSTGRES_PASSWORD=' "$repo_root/.env" || true)"
if [[ "$matches" != "1" ]]; then
echo "Укажите ровно один POSTGRES_PASSWORD в .env или окружении." >&2
exit 2
fi
password="$(sed -n 's/^POSTGRES_PASSWORD=//p' "$repo_root/.env")"
else
echo "Для production запуска скопируйте .env.example в .env и задайте POSTGRES_PASSWORD." >&2
exit 2
fi
if [[ ! "$password" =~ ^[A-Za-z0-9._~-]{32,}$ ]]; then
echo "POSTGRES_PASSWORD должен содержать не менее 32 символов: латиница, цифры, точка, дефис, подчёркивание или тильда. Значение не показано." >&2
exit 2
fi
echo "Production DB secret задан и удовлетворяет формату (само значение не отображается)."

View file

@ -12,10 +12,10 @@ COPY modules.conf /etc/asterisk/modules.conf
COPY rtp.conf.template /opt/lct-sip/rtp.conf.template
COPY entrypoint.sh /usr/local/bin/lct-sip-entrypoint
RUN chmod +x /usr/local/bin/lct-sip-entrypoint \
&& mkdir -p /recordings /var/lib/lct-sip /tls \
&& mkdir -p /recordings /var/lib/lct-sip \
&& chown -R asterisk:asterisk /recordings /var/lib/lct-sip
EXPOSE 5060/udp 5060/tcp 5061/tcp 8088/tcp 10000-10099/udp
EXPOSE 5060/udp 5060/tcp 8088/tcp 10000-10099/udp
HEALTHCHECK --interval=10s --timeout=3s --retries=6 --start-period=15s \
CMD asterisk -rx "core show uptime" >/dev/null 2>&1 || exit 1

View file

@ -2,7 +2,7 @@
set -eu
credentials=/var/lib/lct-sip/credentials.env
mkdir -p /var/lib/lct-sip /recordings /tls
mkdir -p /var/lib/lct-sip /recordings
umask 077
override_6001=${SIP_6001_PASSWORD:-}
@ -54,16 +54,6 @@ envsubst '${SIP_RTP_START} ${SIP_RTP_END}' \
chmod 640 /etc/asterisk/rtp.conf
chown root:asterisk /etc/asterisk/rtp.conf
if [ ! -s /tls/sip.crt ] || [ ! -s /tls/sip.key ]; then
openssl req -x509 -nodes -newkey rsa:3072 -sha256 -days 365 \
-keyout /tls/sip.key -out /tls/sip.crt \
-subj '/CN=localhost/O=LCT local SIP training stand' \
-addext 'subjectAltName=DNS:localhost,IP:127.0.0.1' \
-addext 'extendedKeyUsage=serverAuth' 2>/dev/null
fi
chmod 640 /tls/sip.key
chmod 644 /tls/sip.crt
chown root:asterisk /tls/sip.key /tls/sip.crt
chown -R asterisk:asterisk /recordings /var/lib/lct-sip
exec asterisk -f -U asterisk -G asterisk -vvv

View file

@ -13,18 +13,7 @@ type=transport
protocol=tcp
bind=0.0.0.0:5060
[transport-tls]
type=transport
protocol=tls
bind=0.0.0.0:5061
cert_file=/tls/sip.crt
priv_key_file=/tls/sip.key
method=tlsv1_2
verify_client=no
verify_server=no
allow_reload=yes
; TLS для браузера завершает тот же Nginx, что обслуживает АРМ. До Asterisk
; Браузер ходит через /sip-ws того же Nginx, что обслуживает АРМ. До Asterisk
; SIP signaling идёт WebSocket внутри изолированной Docker-сети.
[transport-ws]
type=transport