diff --git a/Makefile b/Makefile index 2e806a5..796268e 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: check build prod-build publish venue venue-update +.PHONY: check build prod-build publish venue venue-update venue-https PROD_DIR ?= prod @@ -23,8 +23,7 @@ prod-build: build venue: bash venue/deploy.sh -venue-update: - bash venue/deploy.sh --build +venue-update venue-https: venue publish: @test -n "$(MSG)" || (echo 'Usage: make publish MSG="commit message"' >&2; exit 1) diff --git a/README.md b/README.md index 40609f9..d2e3d28 100644 --- a/README.md +++ b/README.md @@ -61,20 +61,26 @@ make dev-user По умолчанию создаётся пользователь `dev@example.com` с паролем `dev123456`. Для изменения используйте `DEV_USER_EMAIL` и `DEV_USER_PASSWORD`. -## Автономная площадка (ED-029, этапы 1–2) +## Автономная площадка (ED-029) `venue/` — окружение для проведения игры на площадке без интернета. Локальная разработка описана выше и запускается отдельно. -Из корня workspace запустите: +После подготовки сертификата и локального DNS (инструкция ниже) из корня workspace запустите: ```sh +make prod-build make venue ``` +`make prod-build` заранее готовит артефакты для `prod/` и `venue/`. +Деплой не компилирует Go/Vue и не устанавливает зависимости исходного кода. +Если артефактов нет, запуск завершится с предложением выполнить `make prod-build`. Первый запуск с интернетом сам создаёт настройки и случайные секреты в -`venue/.env`, готовит образы, запускает миграции и создаёт организатора. -Повторные запуски используют готовое окружение без интернета. +`venue/.env`, готовит Docker-образы из готовых файлов, запускает миграции и создаёт организатора. +Каждый `make venue` пересобирает Docker-образ из готовых файлов и принудительно +пересоздаёт контейнеры с HTTPS. При наличии runtime-образов и Alpine запуск +работает без интернета; отсутствующие образы скачиваются при первой подготовке. Email и пароль для входа указаны в `VENUE_USER_EMAIL` и `VENUE_USER_PASSWORD` в `venue/.env`; существующий файл настроек не перезаписывается. Аккаунт организатора при запуске синхронизируется с этими значениями. @@ -82,27 +88,25 @@ Email и пароль для входа указаны в `VENUE_USER_EMAIL` и После изменения кода обновите площадку, пока интернет доступен: ```sh -make venue-update +make prod-build +make venue ``` -Сайт доступен по `http://localhost:8088`; API и материалы обслуживаются тем же -прокси. Для подключения по LAN задайте `VENUE_BIND_IP=0.0.0.0` и -`APP_BASE_URL=http://<постоянный-IP-ноутбука>:8088` в `venue/.env`, затем -повторите `make venue`. Прежние домены и доверенный HTTPS -требуют отдельной настройки DNS и сертификатов на этапе 3. +Сайт доступен по `https://evening-detective.crabs-games.art`; API и материалы +обслуживаются тем же прокси. Для подключения по LAN задайте постоянный IP ноутбука +в `VENUE_BIND_IP` в `venue/.env` и настройте локальный DNS, как описано ниже. -Образы приложения, мигратора и прокси готовятся заранее. Runtime запускается -без сборки и скачивания; приложение, база и RustFS подключены только к -внутренней Docker-сети. Прокси дополнительно подключён к сети публикации порта. +Приложение, база и RustFS подключены только к внутренней Docker-сети. +Прокси дополнительно подключён к сети публикации порта. База и RustFS сохраняются в именованных томах. Миграции Goose выполняются до старта приложения. Для остановки без потери данных: ```sh -docker compose -f venue/docker-compose.yml --env-file venue/.env down +docker compose -f venue/docker-compose.yml -f venue/docker-compose.https.yml --env-file venue/.env down ``` Не используйте `down -v`: это удаляет данные. После изменений кода или миграций -выполните `make venue-update`, пока интернет доступен. Секреты, сборка, +выполните `make venue`, пока интернет доступен. Секреты, сборка, сертификаты и пакеты игры игнорируются Git в `venue/`. `APP_MODE=venue` отключает SMTP, фоновые письма участия/отзывов и внешний webhook @@ -121,8 +125,7 @@ docker compose -f venue/docker-compose.yml --env-file venue/.env down ```sh python3 tests/venue/deploy.py python3 tests/venue/smoke.py -docker compose -f venue/docker-compose.yml --env-file venue/.env down -docker compose -f venue/docker-compose.yml --env-file venue/.env up -d --no-build --pull never +make venue python3 tests/venue/smoke.py --verify ``` @@ -130,6 +133,98 @@ python3 tests/venue/smoke.py --verify Тестовые данные остаются в локальной базе; повторное создание запускайте только когда нужен новый набор. Дождитесь ответа `/api/test/ping` перед проверкой. +### Прежние домены и HTTPS (этап 3) + +Подготовка в репозитории готова; проверка реального Cudy и Android/iPhone без WAN +ещё требуется. `make venue` всегда запускает HTTPS с пересборкой Docker-образа +и перезапуском контейнеров. Для первого запуска: + +1. Пока интернет доступен, подготовьте артефакты через `make prod-build`. + Запуск `make venue` выполните после подготовки сертификата и локального DNS. +2. Выпустите отдельный доверенный сертификат с новым ключом для двух имён: + `evening-detective.crabs-games.art` и `evening-detective-api.crabs-games.art`. + Используйте DNS-01: проверяются TXT-записи `_acme-challenge` в публичном DNS; + публичные A/AAAA-записи production менять не нужно. Не копируйте ключ production. + Например, на машине с установленным Certbot: + + ```sh + certbot certonly --manual --preferred-challenges dns \ + --cert-name evening-detective-venue \ + -d evening-detective.crabs-games.art \ + -d evening-detective-api.crabs-games.art + ``` + + Следуйте подсказкам Certbot для TXT-записей. Выпуск сертификата выполняется + отдельно от приложения; Certbot не входит в runtime площадки. +3. Скопируйте полную цепочку (сертификат сервера и промежуточные сертификаты) в + `venue/certificates/fullchain.pem`, незашифрованный закрытый ключ — в + `venue/certificates/privkey.pem`. Ограничьте доступ к каталогу и ключу владельцем + (режимы 700 и 600); процесс Nginx в контейнере должен иметь доступ на чтение. + Каталог игнорируется Git. Перед игрой проверьте дату окончания сертификата: + + ```sh + openssl x509 -in venue/certificates/fullchain.pem -noout -dates + ``` + +4. На Cudy вручную закрепите IP ноутбука и настройте локальный DNS: оба домена + должны указывать на этот IP. DHCP должен выдавать клиентам адрес локального + DNS. Возможность такой настройки проверяется на конкретной модели и прошивке; + выбор внешнего «Custom DNS» сам по себе не создаёт локальные записи. + Проверьте клиентскую изоляцию, DNS rebind protection и IPv6: клиенты должны + достигать ноутбука, а AAAA-записи не должны уводить их на production. + При отсутствии нужных возможностей прошивки остановите настройку и согласуйте + отдельное решение; скрипты проекта не меняют роутер и не прошивают его. +5. В `venue/.env` задайте `VENUE_BIND_IP=<постоянный-IP-ноутбука>` и разрешите + входящие TCP 80/443 из LAN в межсетевом экране ноутбука. Порты должны быть свободны. + Для HTTPS менять `APP_BASE_URL` в `.env` не нужно: Compose-дополнение задаёт + прежний HTTPS-домен интерфейса для QR-ссылок и материалов. +6. Запустите: + + ```sh + make venue + ``` + +Команда проверяет доверие цепочке через системное хранилище OpenSSL, оба домена, +срок действия и соответствие ключа до обращения к Docker. Ошибка не переключает +работающее окружение на HTTP. Docker-образ приложения пересобирается при каждом +запуске из готовых артефактов, без компиляции Go/Vue. При наличии всех базовых +образов интернет не нужен. Доверие на реальных телефонах проверяется отдельно. + +Интерфейс открывается по `https://evening-detective.crabs-games.art`, API — по +`https://evening-detective-api.crabs-games.art`. Интерфейс использует свой `/api/`; +прежний API-домен также обслуживается локально. HTTP на портах 80 и прежнем +`VENUE_PORT` перенаправляется на HTTPS с сохранением пути и параметров. + +После продления сертификата с доступным интернетом замените оба PEM-файла и +повторите `make venue`: Nginx проверяет конфигурацию и перечитывает сертификат. +Продление не выполняется автоматически. После обновления кода выполните +`make prod-build`, затем `make venue`; контейнеры всегда перезапускаются с HTTPS. + +Для остановки HTTPS-окружения с сохранением данных: + +```sh +docker compose -f venue/docker-compose.yml -f venue/docker-compose.https.yml \ + --env-file venue/.env down +``` + +Проверка после настройки DNS и запуска (первый smoke создаёт тестовые данные): + +```sh +python3 tests/venue/smoke.py +python3 tests/venue/smoke.py --verify +``` + +TLS-проверка в smoke включена; самоподписанный сертификат и обход предупреждений +не подходят для приёмки. На Android и iPhone отключите WAN и мобильные данные, +подключитесь к Wi-Fi площадки и проверьте DNS обоих доменов, отсутствие TLS-ошибок, +вход организатора, материалы и QR-ссылки тестовой игры. Повторите после перезапуска. +Проверьте запрос доступа к камере отдельно: распознавание QR в текущем интерфейсе +также требует поддержки браузером `BarcodeDetector`. + +Этап 3 сохраняет адреса, но не переносит данные production. Production-QR может +вести на игру или билет, которых в локальной базе ещё нет; перенос и возврат +данных относятся к этапам 4–11. До их завершения используйте только тестовые игры. + ## Проверки перед изменениями Сервер: @@ -178,8 +273,9 @@ API через свой прокси (`VITE_API_URL=/.`). Локальные Goo make build ``` -`make prod-build` выполняет ту же общую сборку. `make venue-update` вызывает её -и дополнительно подготавливает Docker-образы для автономного запуска. Команды +`make prod-build` выполняет ту же общую сборку. `make venue` пересобирает +Docker-образ из готовых артефактов и перезапускает площадку с HTTPS. +`make venue-update` и `make venue-https` сохранены как алиасы `make venue`. Команды обновляют только генерируемые артефакты в `prod`; конфигурация `prod` не меняется. ## Окружение и секреты diff --git a/docs/agent/COMPLETED_TASKS.md b/docs/agent/COMPLETED_TASKS.md index 1ea87d0..3a96cb3 100644 --- a/docs/agent/COMPLETED_TASKS.md +++ b/docs/agent/COMPLETED_TASKS.md @@ -2,6 +2,21 @@ Архив задач, переведённых в статус `DONE`. +### ED-030 — Индикатор среды сервиса в интерфейсе + +- Статус: `DONE` +- Репозитории: `evening_detective_server`, `evening_detective_frontend`. +- Связь: дополнение к ED-029; не требует готовности DNS/HTTPS и Cudy. +- Цель: пользователь видит, к какой среде подключён интерфейс: `PROD` или `VENUE`. +- Решение: показывать компактную подпись среды в общем интерфейсе рядом с навигацией; получать среду от подключённого бэкенда по фактическому режиму запуска (`APP_MODE=venue` — площадка, штатный онлайн-режим — production). +- Критерии: подпись видна на компьютере и телефоне; PROD и VENUE различаются текстом, а не только цветом; среда определяется сервером, а не адресом сайта или настройкой сборки фронтенда; при загрузке или ошибке получения среды интерфейс не показывает неподтверждённый PROD/VENUE. +- Ограничения: без переключения серверов, изменения игровых правил, показа секретов, новых зависимостей и изменения папки `prod`; использовать существующую навигацию, API и стандарты обоих репозиториев. +- Проверка: ручной прогон обоих режимов и недоступного API; Go-тесты; frontend type-check, lint, lint:css и build; при изменении protobuf — штатная генерация обоих клиентов. +- Результат: поле `environment` добавлено в существующий публичный GetPermissions API; значение определяется тем же режимом запуска бэкенда, который отключает внешние интеграции площадки. В HeaderMenu добавлены русские подписи «Сервер» (PROD) и «Локальная сеть» (VENUE) через Naive UI; загрузка, неизвестная среда и ошибки не подменяются production. Сведения меню хранятся в общем UI-store; устаревшие ответы не заменяют новые, а права не применяются после смены токена. +- Проверки: штатные `make generate` и `npm run proto:gen`, `gofmt`, `go test ./...`, `npm run type-check`, `npm run lint`, `npm run lint:css`, `npm run build` и `git diff --check` прошли. Табличный Go-тест проверяет оба режима для гостя, пользователя и администратора. Firefox headless с подменой API проверил PROD/VENUE до и после входа, загрузку, серверную и сетевую ошибки, неизвестное значение; подпись находится внутри viewport при ширине 1440 и 390 px. +- Ревью: diff проверен по стандартам обоих репозиториев; зависимости не добавлены, generated-файлы получены штатной генерацией. Сборочные изменения `dist` исключены из diff; папка `prod` и существующие локальные артефакты бэкенда не менялись. +- Ограничение проверки: реальные Android/iPhone и развёртывание обновлённой версии не проверялись; для появления индикатора нужно обновить бэкенд и фронтенд. Миграция БД не требуется. Commit и push не выполнялись. + ### ED-028 — Роль разработчика и общий таскменеджер - Статус: `DONE` diff --git a/docs/agent/TASKS.md b/docs/agent/TASKS.md index 385af8c..d24115c 100644 --- a/docs/agent/TASKS.md +++ b/docs/agent/TASKS.md @@ -25,6 +25,29 @@ ## Активные задачи +### ED-029 — Автономная площадка и панель организатора + +- Статус: `BLOCKED` — подготовка этапа 3 в репозитории завершена; приёмка требует параметров и доступа к Cudy, отдельного доверенного сертификата и проверки Android/iPhone без WAN. +- Репозитории: корневой, `evening_detective_server`, `evening_detective_frontend`. +- Цель: реализовать принятый пользователем план из 15 последовательных этапов: автономное окружение; локальные организаторы; прежние адреса через Cudy; пакет игры; экспорт с блокировкой; локальный импорт; интерфейс передачи; полный билетный и игровой flow; пакет результатов; возврат онлайн; интерфейс возврата; устройства; панель состояния; сбор данных Cudy; Wi-Fi и испытание площадки. +- Критерии: проверяемый результат каждого этапа; цикл «онлайн → ноутбук → проведение → онлайн» через интерфейс с сохранением QR, билетов и связей; повторные импорты без дублей; мониторинг устройств и Wi-Fi; полный прогон без WAN, 100 клиентов и проверка покрытия. +- Ограничения: только тестовые игры до завершения возврата; существующие правила игры и формат `offline` сохраняются; без новых зависимостей и отклонений от стандартов; без персональных списков игроков, CPU/памяти Cudy, записи настроек роутера и автоматического переключения серверов; секреты и пакеты вне Git; commit/push выполняет пользователь. +- Проверка: Go-стандарт и `go test ./...`; PostgreSQL-интеграционные тесты для атомарности; frontend-стандарт, type-check, lint, lint:css и build; штатная генерация protobuf; ручные контрольные точки после этапов 3, 8, 11, 15. +- Прогресс: этапы 1–2 реализованы и проверены в локальном Docker-окружении. Добавлены отдельный Compose, подготовка образов заранее, постоянные тома, миграции перед стартом, healthchecks и прокси. Явный `APP_MODE=venue` отключает SMTP, фоновые письма и внешний webhook; почтовые операции аккаунта возвращают безопасную ошибку до изменения данных. Локальные аккаунты создаются штатным `dev-user.sql`, роли и правила автоматического подтверждения команд сохраняются. Подготовка этапа 3 реализована; его аппаратная приёмка и этапы 4–15 ещё не завершены. +- Дополнение по запросу пользователя: структура `venue` приведена к `prod` (`backend/`, `frontend/dist/`, `migrations/`, `Dockerfile.backend`, `docker-compose.yml`, `deploy.sh`); имена основных Compose-сервисов совпадают. Общая генерация `make build` / `make prod-build` готовит артефакты обеих папок; frontend собирается отдельно с настройками своего окружения. Текущие файлы `prod` при этой работе не изменяются; сборка проверяется с production-выводом в `/tmp`. +- Упрощение запуска: отдельные `init.sh` и `prepare.sh` удалены, всё собрано в `deploy.sh`. Единственная команда запуска — `make venue`: создаёт `.env` только при его отсутствии, при необходимости готовит зависимости/образы и автоматически создаёт организатора. Повторный готовый запуск не собирает и не скачивает образы. `make venue-update` явно пересобирает окружение с интернетом. Аккаунт организатора синхронизируется со значениями `.env` при каждом запуске. Первый запуск, повторный запуск без интернета и обновление проверяются `python3 tests/venue/deploy.py` с подставными командами; реальный готовый запуск проверен через Docker и API-smoke. +- Настройки площадки: по запросу пользователя необязательный `venue/.env.example` удалён; настройки создаются автоматически через `make venue`. Убраны ссылка и проверка примера; `python3 tests/venue/deploy.py` прошёл. Существующая `.env` не изменялась. +- Группировка проверок: `deploy.py` и `smoke.py` собраны в `tests/venue/` с поясняющим README, пути к площадке и команды в документации обновлены; для обычного запуска эти файлы не нужны. `python3 tests/venue/deploy.py` и `python3 tests/venue/smoke.py --verify` прошли; diff проверен. +- Уточнение названия: автономная площадка называется `venue`, чтобы не смешивать её с локальной разработкой. Папка, Make-команды, образ, Docker-проект, режим `APP_MODE=venue` и настройки `VENUE_*` переименованы; существующая `.env` перенесена с сохранением секретов и учётной записи. Служебные имена прежних томов сохранены для сохранности данных; тома подключаются как внешние и создаются автоматически при первом запуске. Go-тесты, тест запуска и API-smoke после переименования прошли; `prod` не изменён. Ручное ревью diff по стандарту Go выполнено. +- Проверка дополнения завершена: `make build PROD_DIR=/tmp/evening-artifacts-check` прошёл, backend и миграции в обоих наборах совпадают; type-check и две frontend-сборки прошли. Проверены Compose config, shell-синтаксис и diff. Local собран и запущен через новый `deploy.sh`; API-smoke и Firefox подтвердили вход, тестовую игру, подтверждённую команду и сохранность материала. Контрольные суммы файлов `prod` до и после работы совпадают. Проверка production-запуска не выполнялась; существующие файлы и конфигурация `prod` не изменены. +- Результат проверки этапов 1–2: ручное ревью diff по Go- и frontend-стандартам выполнено, отклонений нет; `gofmt`, `go test ./...`, frontend `type-check`, `lint`, `lint:css`, сборка и полный `sh venue/prepare.sh` прошли. Compose config и shell-синтаксис проверены. Smoke через API создал тестовую игру, подтверждённую команду и материал; после `down` / `up --no-build --pull never` всё сохранилось. Firefox без окна подтвердил реальный вход, экран игры и команду после перезапуска. Приложение не имеет внешнего маршрута сети. Повторная инициализация не перезаписывает секреты. Инструкции запуска и повторяемый API-smoke находятся в корневом README и `tests/venue/smoke.py`. Проверка телефонов при отключённом WAN ещё не выполнена. +- Внешние данные: запрошены модель/IP Cudy, постоянный IP ноутбука, доступ к проверенной прошивке и возможность DNS-проверки для сертификатов. Проверки телефонов, 100 клиентов и покрытия требуют оборудования. +- Актуальный запуск по последующему запросу пользователя: `make venue` всегда использует HTTPS-дополнение, выполняет `docker compose build --pull=false` из заранее подготовленных артефактов и `up --force-recreate --no-build --pull never`; тома данных сохраняются. `venue-update` и `venue-https` — алиасы единого запуска. Сертификат проверяется до Docker; Go/Vue и npm-зависимости при деплое не собираются. Отсутствующие runtime-образы и базовый Alpine скачиваются, имеющиеся не обновляются автоматически. HTTPS теперь используется smoke-проверкой по умолчанию. README и тесты приведены к единому поведению; `python3 tests/venue/deploy.py`, shell-синтаксис, Python-синтаксис, dry-run всех трёх Make-команд и `git diff --check` прошли; diff вручную проверен. Реальный перезапуск площадки с публичным сертификатом и сборка Docker-образа без WAN не выполнялись; Cudy и проверка телефонов остаются внешней контрольной точкой. +- Уточнение сборки по запросу пользователя: деплой больше не вызывает `make build`, Go/Vue-сборку или установку npm-зависимостей. Артефакты заранее готовятся существующим `make prod-build` в обеих папках; при отсутствии бинарника, Goose, bootstrap SQL, frontend или SQL-миграций запуск завершается до Docker с понятной ошибкой. `make venue-update` только упаковывает готовые файлы в Docker-образ и подготавливает runtime-образы. README обновлён; тесты первого, автономного и update-запуска подтверждают отсутствие make/npm, ошибки отсутствующих артефактов и сохранность настроек. `python3 tests/venue/deploy.py`, `python3 tests/venue/https.py`, `bash -n venue/deploy.sh`, `git diff --check` прошли; diff вручную проверен. Повторная реальная сборка приложения и запуск площадки не выполнялись; код дочерних репозиториев не менялся. +- Подготовка этапа 3: добавлены `make venue-https`, отдельное Compose-дополнение и Nginx-конфигурация для прежних доменов интерфейса/API. HTTP перенаправляется на HTTPS с сохранением пути и параметров; API и материалы доступны через домен интерфейса и прежний API-домен. HTTPS-дополнение задаёт публичные адреса ссылок без изменения `.env`. Сертификат и ключ подключаются только для чтения из игнорируемого каталога; до Docker проверяются доверие полной цепочке, оба домена, срок действия и соответствие ключа. Повторный HTTPS-запуск перечитывает сертификат без пересборки. Обычный HTTP-запуск сохранён; добавлены HTTPS-smoke и инструкции DNS-01, LAN, продления и ручной приёмки. Код Go/Vue, protobuf, схема БД и конфигурация `prod` не менялись; новых зависимостей нет. +- Проверки подготовки этапа 3: `python3 tests/venue/deploy.py`, `python3 tests/venue/https.py --docker`, `bash -n venue/deploy.sh` и `git diff --check` прошли; Python-файлы проверены на синтаксис. Проверены реальный Compose merge, прежние тома и внутренняя сеть backend. Временные Docker-контейнеры с тестовым CA подтвердили проверяемый TLS/SNI, оба домена, redirects, API/материалы, SPA-пути с параметрами, перезапуск и замену сертификата через reload; свои ресурсы тест удалил. Проверки сертификатов покрывают отсутствующие/повреждённые файлы, неверный и зашифрованный ключ, недоверенную/неполную цепочку, отсутствие каждого домена и истечение срока; ошибка возникает до Docker, повторный готовый запуск не скачивает и не собирает образы, `.env` сохраняется. Ручное ревью diff выполнено; стандарты Go/Vue не затронуты. Реальное HTTPS-развёртывание, системное доверие телефонов, Cudy и WAN-off smoke не проверены из-за отсутствия оборудования/сертификата. Commit/push не выполнялись. +- Следующий шаг: получить параметры Cudy/IP ноутбука и возможность DNS-проверки; выпустить отдельный доверенный сертификат, вручную настроить локальный DNS, запустить `make venue-https` и проверить этап 3 на Android/iPhone без WAN, затем последовательно выполнить этапы 4–15. Прежние адреса сами по себе не переносят production-игры и билеты. Общая задача не завершена и не переносится в архив. + ### ED-024 — Общая дизайн-система фронтенда - Статус: `TODO` diff --git a/prod b/prod index 7142d2b..3dcd84c 160000 --- a/prod +++ b/prod @@ -1 +1 @@ -Subproject commit 7142d2bea7411630fad30463780b49b6553a327c +Subproject commit 3dcd84cd68b86ecb9ec819e5c407efb19411413a diff --git a/tests/venue/README.md b/tests/venue/README.md index a319093..4799acd 100644 --- a/tests/venue/README.md +++ b/tests/venue/README.md @@ -2,13 +2,23 @@ Для работы площадки эти файлы не нужны. Обычный запуск: `make venue`. -- `deploy.py` — проверяет первый запуск, запуск без интернета и обновление без Docker. +- `deploy.py` — запускает проверки единого HTTPS-деплоя из `https.py` без Docker: + первый запуск, повторная сборка Docker-образа, принудительный перезапуск, + отсутствие артефактов, сертификаты и сохранность настроек. - `smoke.py` — проверяет работающую площадку через API. Без `--verify` создаёт - тестовые данные; с `--verify` проверяет ранее созданные данные. + тестовые данные; с `--verify` проверяет ранее созданные данные. Всегда + использует HTTPS и прежние домены с проверкой доверия TLS; требует локального DNS. +- `https.py` — проверяет сертификаты и HTTPS-запуск с подставными командами. + `--docker` дополнительно запускает два временных Nginx-контейнера в отдельной + сети, проверяет TLS, маршруты, перенаправления и перезапуск, затем удаляет + свои контейнеры и сеть. Использует локальный `nginx:alpine` и временный тестовый CA; + работающие окружения, production и системное хранилище доверия не меняет. Команды из корня проекта: ```sh python3 tests/venue/deploy.py python3 tests/venue/smoke.py --verify +python3 tests/venue/https.py +python3 tests/venue/https.py --docker ``` diff --git a/tests/venue/deploy.py b/tests/venue/deploy.py index b4d78a0..eb50446 100644 --- a/tests/venue/deploy.py +++ b/tests/venue/deploy.py @@ -1,52 +1,7 @@ -"""Check first, offline and update runs without Docker or production writes.""" +"""Check the unified HTTPS deployment without Docker or production writes.""" from pathlib import Path -import os -import shutil import subprocess -import tempfile +import sys - -source = Path(__file__).resolve().parents[2] / "venue" / "deploy.sh" -for name, cold, update in [("first", True, False), ("offline", False, False), ("update", False, True)]: - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - venue = root / "venue" - venue.mkdir() - shutil.copy(source, venue / "deploy.sh") - log = root / "commands.log" - bin_dir = root / "bin" - bin_dir.mkdir() - for command in ["docker", "make", "npm"]: - stub = bin_dir / command - stub.write_text( - '#!/bin/sh\n' - 'echo "$(basename "$0") $*" >> "$TEST_LOG"\n' - 'if [ "$1 $2" = "image inspect" ] && [ "$TEST_COLD" = 1 ]; then exit 1; fi\n' - ) - stub.chmod(0o755) - if not cold: - (venue / "migrations").mkdir() - (venue / ".env").write_text("EXISTING_SECRET=unchanged\n") - for artifact in ["backend/evening_detective_server", "backend/goose", "backend/dev-user.sql", "frontend/dist/index.html"]: - path = venue / artifact - path.parent.mkdir(parents=True, exist_ok=True) - path.touch() - env = {**os.environ, "PATH": str(bin_dir) + os.pathsep + os.environ["PATH"], "TEST_LOG": str(log), "TEST_COLD": str(int(cold))} - subprocess.run(["bash", str(venue / "deploy.sh"), *(["--build"] if update else [])], env=env, check=True, capture_output=True) - commands = log.read_text() - assert "volume create evening-detective-local_postgres_data" in commands, name - assert "volume create evening-detective-local_rustfs_data" in commands, name - assert "compose up -d --no-build --pull never" in commands, name - assert "compose --profile setup run --rm bootstrap" in commands, name - assert ("make -C .. build" in commands) == (cold or update), name - assert ("compose pull" in commands) == (cold or update), name - assert ("compose build --pull=false" in commands) == (cold or update), name - if cold: - config = dict(line.split("=", 1) for line in (venue / ".env").read_text().splitlines()) - assert len(config["JWT_SECRET"]) == 64 - assert len(config["VENUE_USER_PASSWORD"]) == 16 - assert (venue / ".env").stat().st_mode & 0o777 == 0o600 - else: - assert (venue / ".env").read_text() == "EXISTING_SECRET=unchanged\n", name -print("PASS: automatic first setup, offline reuse, explicit update and preserved secrets") +subprocess.run([sys.executable, str(Path(__file__).with_name("https.py"))], check=True) diff --git a/tests/venue/https.py b/tests/venue/https.py new file mode 100644 index 0000000..2ab56b1 --- /dev/null +++ b/tests/venue/https.py @@ -0,0 +1,239 @@ +"""Check HTTPS preflight; --docker also exercises Nginx in isolated containers.""" + +from pathlib import Path +import http.client +import json +import os +import shutil +import socket +import ssl +import subprocess +import sys +import tempfile +import time +import uuid + + +workspace = Path(__file__).resolve().parents[2] +ui = "evening-detective.crabs-games.art" +api = "evening-detective-api.crabs-games.art" + + +def run(*args, **kwargs): + return subprocess.run(args, check=True, capture_output=True, text=True, **kwargs).stdout.strip() + + +with tempfile.TemporaryDirectory(prefix="venue-https-") as directory: + root = Path(directory) + venue = root / "venue" + venue.mkdir() + shutil.copy(workspace / "venue/deploy.sh", venue / "deploy.sh") + certificates = venue / "certificates" + certificates.mkdir() + for artifact in ["backend/evening_detective_server", "backend/goose", "backend/dev-user.sql", "frontend/dist/index.html"]: + path = venue / artifact + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + (venue / "migrations").mkdir() + (venue / "migrations/001.sql").touch() + original_env = "EXISTING_SECRET=unchanged\n" + (venue / ".env").write_text(original_env) + bin_dir = root / "bin" + bin_dir.mkdir() + log = root / "commands.log" + for command in ["docker", "make", "npm"]: + stub = bin_dir / command + stub.write_text('#!/bin/sh\necho "$(basename "$0") $*" >> "$TEST_LOG"\n' + 'if [ "$1 $2" = "image inspect" ] && [ "$TEST_COLD" = 1 ]; then exit 1; fi\n') + stub.chmod(0o755) + env = {**os.environ, "PATH": str(bin_dir) + os.pathsep + os.environ["PATH"], "TEST_LOG": str(log)} + run("openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "2", + "-addext", "basicConstraints=critical,CA:TRUE", "-addext", "keyUsage=critical,keyCertSign,cRLSign", + "-subj", "/CN=Venue test root", "-keyout", str(root / "ca.key"), "-out", str(root / "ca.pem")) + run("openssl", "req", "-new", "-newkey", "rsa:2048", "-nodes", "-subj", "/CN=Venue intermediate", + "-keyout", str(root / "intermediate.key"), "-out", str(root / "intermediate.csr")) + extension = root / "extensions.cnf" + extension.write_text("basicConstraints=critical,CA:TRUE,pathlen:0\nkeyUsage=critical,keyCertSign,cRLSign\n") + run("openssl", "x509", "-req", "-in", str(root / "intermediate.csr"), "-CA", str(root / "ca.pem"), + "-CAkey", str(root / "ca.key"), "-CAcreateserial", "-days", "2", "-extfile", str(extension), + "-out", str(root / "intermediate.pem")) + run("openssl", "req", "-new", "-newkey", "rsa:2048", "-nodes", "-subj", "/CN=Venue test server", + "-keyout", str(certificates / "privkey.pem"), "-out", str(root / "leaf.csr")) + + def issue(domains, days="1"): + extension.write_text("basicConstraints=CA:FALSE\nextendedKeyUsage=serverAuth\nsubjectAltName=" + + ",".join("DNS:" + domain for domain in domains) + "\n") + if days == "-1": + (root / "index").touch() + (root / "serial").write_text("01\n") + config = root / "ca.cnf" + config.write_text(f"[ca]\ndefault_ca=issuer\n[issuer]\ndatabase={root}/index\n" + f"serial={root}/serial\nnew_certs_dir={root}\ncertificate={root}/intermediate.pem\n" + f"private_key={root}/intermediate.key\ndefault_md=sha256\npolicy=policy\n" + "x509_extensions=server\n[policy]\ncommonName=supplied\n[server]\n" + extension.read_text()) + run("openssl", "ca", "-batch", "-notext", "-config", str(config), "-in", str(root / "leaf.csr"), + "-startdate", "20200101000000Z", "-enddate", "20200102000000Z", "-out", str(root / "leaf.pem")) + else: + run("openssl", "x509", "-req", "-in", str(root / "leaf.csr"), "-CA", str(root / "intermediate.pem"), + "-CAkey", str(root / "intermediate.key"), "-CAcreateserial", "-days", days, "-extfile", str(extension), + "-out", str(root / "leaf.pem")) + (certificates / "fullchain.pem").write_text((root / "leaf.pem").read_text() + (root / "intermediate.pem").read_text()) + + def check(success, trust=True, cold=False, first=False): + log.unlink(missing_ok=True) + test_env = {**env, "SSL_CERT_FILE": str(root / "ca.pem" if trust else root / "untrusted.pem"), + "TEST_COLD": str(int(cold))} + result = subprocess.run(["bash", str(venue / "deploy.sh")], env=test_env, + capture_output=True, text=True) + assert (result.returncode == 0) == success, result.stderr + if first: + config = dict(line.split("=", 1) for line in (venue / ".env").read_text().splitlines()) + assert len(config["JWT_SECRET"]) == 64 and len(config["VENUE_USER_PASSWORD"]) == 16 + assert config["APP_BASE_URL"] == "https://" + ui + assert (venue / ".env").stat().st_mode & 0o777 == 0o600 + else: + assert (venue / ".env").read_text() == original_env + if success: + commands = log.read_text() + prefix = "compose -f docker-compose.yml -f docker-compose.https.yml" + assert prefix + " up -d --force-recreate --no-build --pull never" in commands + assert prefix + " --profile setup run --rm bootstrap" in commands + assert prefix + " exec -T proxy nginx -t" in commands + assert prefix + " exec -T proxy nginx -s reload" in commands + assert prefix + " build --pull=false" in commands + assert commands.index(prefix + " build --pull=false") < commands.index(prefix + " up -d") + assert (prefix + " pull " in commands) == cold + assert ("docker pull alpine:3.22" in commands) == cold + assert "make " not in commands and "npm " not in commands + else: + assert "HTTPS:" in result.stderr + assert not log.exists(), "Invalid certificate reached Docker" + + check(False) # Missing chain. + issue([ui, api]) + check(True) + check(True) # Offline reuse, unchanged settings. + check(False, trust=False) + chain = (certificates / "fullchain.pem").read_text() + (certificates / "fullchain.pem").write_text((root / "leaf.pem").read_text()) + check(False) # Missing intermediate. + (certificates / "fullchain.pem").write_text("invalid certificate") + check(False) + (certificates / "fullchain.pem").write_text(chain) + key = (certificates / "privkey.pem").read_text() + (certificates / "privkey.pem").write_text((root / "ca.key").read_text()) + check(False) # Different valid private key. + (certificates / "privkey.pem").write_text("invalid key") + check(False) + (certificates / "privkey.pem").unlink() + check(False) + (certificates / "privkey.pem").write_text(key) + run("openssl", "pkey", "-in", str(certificates / "privkey.pem"), "-aes-256-cbc", + "-passout", "pass:test-only", "-out", str(root / "encrypted.pem")) + (certificates / "privkey.pem").write_text((root / "encrypted.pem").read_text()) + check(False) + (certificates / "privkey.pem").write_text(key) + issue([ui]) + check(False) # API domain missing. + issue([api]) + check(False) # UI domain missing. + issue([ui, api], "-1") + check(False) # Expired leaf. + issue([ui, api]) + (venue / ".env").unlink() + check(True, cold=True, first=True) + (venue / ".env").write_text(original_env) + for missing in ["backend/evening_detective_server", "backend/goose", "backend/dev-user.sql", "frontend/dist/index.html", "migrations/001.sql"]: + path = venue / missing + path.unlink() + log.unlink(missing_ok=True) + result = subprocess.run(["bash", str(venue / "deploy.sh")], + env={**env, "SSL_CERT_FILE": str(root / "ca.pem")}, capture_output=True, text=True) + assert result.returncode != 0 and "make prod-build" in result.stderr, missing + assert not log.exists(), "Missing artifacts reached Docker" + assert (venue / ".env").read_text() == original_env + path.touch() + print("PASS: HTTPS certificates, first setup, offline Docker rebuild, forced restart, missing artifacts and preserved settings") + + if "--docker" in sys.argv: + compose = ["docker", "compose", "--env-file", "/dev/null", "-f", str(workspace / "venue/docker-compose.yml")] + compose_env = {**os.environ, **dict.fromkeys([ + "POSTGRES_USER", "POSTGRES_PASSWORD", "POSTGRES_DB", "JWT_SECRET", "PARTICIPATION_SECRET", + "S3_USER", "S3_PASSWORD", "VENUE_USER_PASSWORD"], "test-only"), + "APP_BASE_URL": "http://localhost:8088", "VENUE_BIND_IP": "127.0.0.1", "VENUE_PORT": "8088"} + http_config = json.loads(run(*compose, "config", "--format", "json", env=compose_env)) + https = json.loads(run(*compose, "-f", str(workspace / "venue/docker-compose.https.yml"), + "config", "--format", "json", env=compose_env)) + assert http_config["services"]["evening_detective_server"]["environment"]["APP_BASE_URL"] == "http://localhost:8088" + server = https["services"]["evening_detective_server"]["environment"] + assert server["APP_MODE"] == "venue" and server["APP_BASE_URL"] == "https://" + ui + assert server["FILE_PREFIX_DOMAIN"] == server["APP_BASE_URL"] + "/api/files/" + assert {str(p["published"]) for p in https["services"]["proxy"]["ports"]} == {"80", "443", "8088"} + configs = [v for v in https["services"]["proxy"]["volumes"] if v["target"] == "/etc/nginx/conf.d/default.conf"] + assert len(configs) == 1 and configs[0]["source"].endswith("/nginx.https.conf") and configs[0]["read_only"] + assert any(v["target"] == "/etc/nginx/certificates" and v["read_only"] for v in https["services"]["proxy"]["volumes"]) + assert https["networks"]["default"]["internal"] and https["volumes"] == http_config["volumes"] + print("PASS: Compose merge, HTTPS addresses, ports, read-only mounts and preserved data volumes") + suffix = uuid.uuid4().hex[:10] + network = "venue-https-test-" + suffix + upstream = network + "-upstream" + proxy = network + "-proxy" + upstream_config = root / "upstream.conf" + upstream_config.write_text('server { listen 8090; return 200 "api:$request_uri"; }\n' + 'server { listen 8080; return 200 "spa:$request_uri"; }\n') + # Test-only CA is explicit; public HTTPS verification is never disabled. + context = ssl.create_default_context(cafile=str(root / "ca.pem")) + + def request(domain, path, tls=True): + port = int(run("docker", "port", proxy, "443/tcp" if tls else "80/tcp").split(":")[-1]) + connection = http.client.HTTPConnection("127.0.0.1", port, timeout=10) + if tls: + connection.sock = context.wrap_socket(socket.create_connection(("127.0.0.1", port), timeout=10), + server_hostname=domain) + try: + connection.request("GET", path, headers={"Host": domain}) + response = connection.getresponse() + return response.status, response.getheader("Location"), response.read().decode() + finally: + connection.close() + + def serial(): + port = int(run("docker", "port", proxy, "443/tcp").split(":")[-1]) + with context.wrap_socket(socket.create_connection(("127.0.0.1", port), timeout=10), server_hostname=ui) as connection: + return connection.getpeercert()["serialNumber"] + + try: + run("docker", "network", "create", network) + run("docker", "run", "-d", "--name", upstream, "--network", network, + "--network-alias", "evening_detective_server", "--network-alias", "evening_detective_frontend", + "-v", str(upstream_config) + ":/etc/nginx/conf.d/default.conf:ro", "nginx:alpine") + run("docker", "run", "-d", "--name", proxy, "--network", network, + "-p", "127.0.0.1::80", "-p", "127.0.0.1::443", + "-v", str(workspace / "venue/nginx.https.conf") + ":/etc/nginx/conf.d/default.conf:ro", + "-v", str(certificates) + ":/etc/nginx/certificates:ro", "nginx:alpine") + run("docker", "exec", proxy, "nginx", "-t") + for domain in [ui, api]: + assert request(domain, "/api/test/ping?value=1", tls=False)[:2] == (308, "https://" + domain + "/api/test/ping?value=1") + assert request(domain, "/api/test/ping?value=1") == (200, None, "api:/api/test/ping?value=1") + assert request(domain, "/api/files/material.pdf") == (200, None, "api:/api/files/material.pdf") + assert request(ui, "/team-story/1?password=test") == (200, None, "spa:/team-story/1?password=test") + assert request(api, "/docs/") == (200, None, "api:/docs/") + run("docker", "restart", proxy) + run("docker", "exec", proxy, "nginx", "-t") + assert request(ui, "/games") == (200, None, "spa:/games") + previous_serial = serial() + issue([ui, api]) + run("docker", "exec", proxy, "nginx", "-t") + run("docker", "exec", proxy, "nginx", "-s", "reload") + for attempt in range(40): + if serial() != previous_serial: + break + time.sleep(0.05) + else: + raise AssertionError("Nginx did not reload renewed certificate") + assert request(ui, "/games") == (200, None, "spa:/games") + print("PASS: real Nginx TLS/SNI, both domains, redirects, API, materials, SPA paths, restart and certificate renewal") + finally: + for container in [proxy, upstream]: + subprocess.run(["docker", "rm", "-f", container], capture_output=True) + subprocess.run(["docker", "network", "rm", network], capture_output=True) diff --git a/tests/venue/smoke.py b/tests/venue/smoke.py index c426647..36d33f7 100644 --- a/tests/venue/smoke.py +++ b/tests/venue/smoke.py @@ -20,10 +20,7 @@ config = dict( for line in (root / ".env").read_text().splitlines() if line and not line.startswith("#") ) -bind = config.get("VENUE_BIND_IP", "127.0.0.1") -if bind == "0.0.0.0": - bind = "127.0.0.1" -base = f'http://{bind}:{config.get("VENUE_PORT", "8088")}' +base = "https://evening-detective.crabs-games.art" token = "" opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) @@ -57,6 +54,8 @@ def verify(state): try: + with opener.open("https://evening-detective-api.crabs-games.art/api/test/ping", timeout=10) as response: + assert response.status == 200, "HTTPS API domain failed" token = request( "/api/auth/login", { diff --git a/venue/deploy.sh b/venue/deploy.sh index 59f7988..0ebe3a7 100755 --- a/venue/deploy.sh +++ b/venue/deploy.sh @@ -2,12 +2,43 @@ set -euo pipefail cd -- "$(dirname -- "${BASH_SOURCE[0]}")" +if [[ $# -ne 0 ]]; then + echo 'Используйте make venue' >&2 + exit 1 +fi +compose=(docker compose -f docker-compose.yml -f docker-compose.https.yml) + +command -v openssl >/dev/null +certificate=certificates/fullchain.pem +key=certificates/privkey.pem +if [[ ! -s "$certificate" || ! -s "$key" ]]; then + echo 'HTTPS: нужны venue/certificates/fullchain.pem и privkey.pem.' >&2 + exit 1 +fi +if ! openssl x509 -in "$certificate" -checkend 0 -noout >/dev/null 2>&1; then + echo 'HTTPS: сертификат повреждён или истёк.' >&2 + exit 1 +fi +if ! certificate_public=$(openssl x509 -in "$certificate" -pubkey -noout 2>/dev/null) || + ! key_public=$(openssl pkey -in "$key" -pubout -passin pass: 2>/dev/null) || + [[ "$certificate_public" != "$key_public" ]]; then + echo 'HTTPS: ключ повреждён, зашифрован или не соответствует сертификату.' >&2 + exit 1 +fi +for domain in evening-detective.crabs-games.art evening-detective-api.crabs-games.art; do + if ! openssl verify -purpose sslserver -verify_hostname "$domain" \ + -untrusted "$certificate" "$certificate" >/dev/null 2>&1; then + echo "HTTPS: сертификат не доверен, недействителен или не покрывает $domain." >&2 + exit 1 + fi +done + if [[ ! -f .env ]]; then command -v openssl >/dev/null ( umask 077 cat > .env <&2; exit 1 ;; -esac -for image in evening-detective-venue-app:1 postgres:15 rustfs/rustfs:latest byjg/static-httpserver:latest nginx:alpine; do - if ! docker image inspect "$image" >/dev/null 2>&1; then prepare=true; fi -done for artifact in backend/evening_detective_server backend/goose backend/dev-user.sql frontend/dist/index.html; do - if [[ ! -f "$artifact" ]]; then prepare=true; fi + if [[ ! -f "$artifact" ]]; then + echo "Нет venue/$artifact. Сначала выполните make prod-build." >&2 + exit 1 + fi done -if [[ ! -d migrations ]]; then prepare=true; fi -if [[ "$prepare" == true ]]; then - echo 'Подготовка площадки: потребуется интернет.' - if ! docker image inspect my-go-builder >/dev/null 2>&1; then - make -C ../evening_detective_server build-builder - fi - if [[ ! -d ../evening_detective_frontend/node_modules ]]; then - npm --prefix ../evening_detective_frontend ci - fi - make -C .. build - docker compose pull postgres_evening_detective_server rustfs_evening_detective_server evening_detective_frontend proxy - docker pull alpine:3.22 - docker compose build --pull=false +if [[ ! -d migrations ]] || ! compgen -G 'migrations/*.sql' >/dev/null; then + echo 'Нет миграций venue. Сначала выполните make prod-build.' >&2 + exit 1 fi + +for service_image in postgres_evening_detective_server=postgres:15 rustfs_evening_detective_server=rustfs/rustfs:latest evening_detective_frontend=byjg/static-httpserver:latest proxy=nginx:alpine; do + if ! docker image inspect "${service_image#*=}" >/dev/null 2>&1; then + "${compose[@]}" pull "${service_image%%=*}" + fi +done +if ! docker image inspect alpine:3.22 >/dev/null 2>&1; then + docker pull alpine:3.22 +fi +"${compose[@]}" build --pull=false for volume in evening-detective-local_postgres_data evening-detective-local_rustfs_data; do docker volume create "$volume" >/dev/null done -docker compose up -d --no-build --pull never -docker compose --profile setup run --rm bootstrap -docker compose ps -echo 'Площадка запущена. Адрес и данные входа находятся в venue/.env.' +"${compose[@]}" up -d --force-recreate --no-build --pull never +"${compose[@]}" --profile setup run --rm bootstrap +"${compose[@]}" exec -T proxy nginx -t +"${compose[@]}" exec -T proxy nginx -s reload +echo 'Площадка: https://evening-detective.crabs-games.art; данные входа — venue/.env.' +"${compose[@]}" ps diff --git a/venue/docker-compose.https.yml b/venue/docker-compose.https.yml new file mode 100644 index 0000000..443946c --- /dev/null +++ b/venue/docker-compose.https.yml @@ -0,0 +1,13 @@ +services: + evening_detective_server: + environment: + APP_BASE_URL: https://evening-detective.crabs-games.art + FILE_PREFIX_DOMAIN: https://evening-detective.crabs-games.art/api/files/ + + proxy: + volumes: + - ./nginx.https.conf:/etc/nginx/conf.d/default.conf:ro + - ./certificates:/etc/nginx/certificates:ro + ports: + - '${VENUE_BIND_IP:-127.0.0.1}:80:80' + - '${VENUE_BIND_IP:-127.0.0.1}:443:443' diff --git a/venue/nginx.https.conf b/venue/nginx.https.conf new file mode 100644 index 0000000..6416417 --- /dev/null +++ b/venue/nginx.https.conf @@ -0,0 +1,43 @@ +ssl_certificate /etc/nginx/certificates/fullchain.pem; +ssl_certificate_key /etc/nginx/certificates/privkey.pem; +ssl_protocols TLSv1.2 TLSv1.3; + +client_max_body_size 257m; +proxy_set_header Host $host; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-Proto $scheme; +proxy_read_timeout 120s; + +server { + listen 80; + server_name evening-detective.crabs-games.art; + return 308 https://evening-detective.crabs-games.art$request_uri; +} + +server { + listen 80; + server_name evening-detective-api.crabs-games.art; + return 308 https://evening-detective-api.crabs-games.art$request_uri; +} + +server { + listen 443 ssl; + server_name evening-detective.crabs-games.art; + + location /api/ { + proxy_pass http://evening_detective_server:8090; + } + + location / { + proxy_pass http://evening_detective_frontend:8080; + } +} + +server { + listen 443 ssl; + server_name evening-detective-api.crabs-games.art; + + location / { + proxy_pass http://evening_detective_server:8090; + } +}