# Развёртывание на своём сервере (Debian 12 / 13) Сплит-тренер состоит из статических файлов (HTML, CSS, JS) и необязательного API аккаунтов (вход по почте и хранение прогресса на сервере). Без API сервер только отдаёт файлы, ничего не хранит и не ставит куки; с API появляются вход, подтверждение почты, сброс пароля и синхронизация прогресса между устройствами (раздел 4, «Аккаунты и почта»). Обращений к внешним сайтам нет в обоих режимах, кроме отправки писем через ваш SMTP. ## 1. Минимальные требования ### Сервер | | Минимум | Комментарий | |---|---|---| | ОС | Debian 12 (bookworm) или Debian 13 (trixie), amd64 или arm64 | | | CPU / RAM | 1 vCPU, 128 МБ свободной памяти | nginx в простое занимает 5–10 МБ; для контейнера задан лимит 64 МБ | | Диск | порядка 500 МБ с Docker Engine (сам образ занимает несколько десятков МБ) или ~10 МБ с nginx из пакетов | сам сайт меньше 1 МБ | | Сеть | один TCP-порт; по умолчанию `127.0.0.1:8080` | наружу выставляет обратный прокси | | Исходящий доступ | только на время установки (apt или docker pull) | во время работы сайту интернет не нужен | | ПО (вариант A) | Docker Engine 24+ с плагином Compose v2 | рекомендуется официальный репозиторий: docs.docker.com/engine/install/debian | | ПО (вариант B) | nginx 1.22+ из пакетов Debian (в Debian 12 это 1.22, в 13 это 1.26) | ставится скриптом | | Права | root или sudo на время установки | сайт работает без root-прав | | Аккаунты: ПО | вариант A: ничего дополнительного (API в отдельном контейнере); вариант B: python3 3.11+ (в Debian 12 это 3.11, в 13 это 3.13) | только стандартная библиотека Python, `pip` не нужен | | Аккаунты: память | ещё до 256 МБ свободной памяти | каждый вход хеширует пароль (scrypt, около 32 МБ на вход); для контейнера задан лимит 384 МБ | | Аккаунты: почта | доступ к SMTP-серверу (порт 587 или 465) | без SMTP письма печатаются в лог: годится только для проверки | Нужно выбрать один вариант: A или B. Аккаунты необязательны: без API сайт работает как раньше, просто без входа. ### Домен и HTTPS - Для работы сайта HTTPS **не обязателен**: приложение не использует API, требующие защищённого контекста. - Но на публичном адресе HTTPS нужен: его даёт ваш обратный прокси (раздел 5). С аккаунтами это особенно важно: пароли и cookie входа не должны ходить по открытому каналу. Если `PUBLIC_URL` начинается с `https://`, cookie входа получает флаг `Secure`. ### Браузеры пользователей Современные: Chrome и Edge 111+, Firefox 113+, Safari 16.2+ (примерно 2023 год и новее). Для работы нужен включённый JavaScript и `localStorage`. ## 2. Что важно знать про данные - **Без входа (гость)** прогресс и статистика хранятся в браузере пользователя (`localStorage`), на сервер ничего не отправляется. Данные привязаны к адресу сайта (схема + домен + порт): при смене домена гость начинает «с нуля»; смена пути (`/trainer/`) прогресс не затрагивает. - **После входа** прогресс, история упражнений и статистика клавиш копируются на сервер и подтягиваются на других устройствах. Настройки и раскладка Vial остаются на устройстве. - **Что хранит сервер** (файл SQLite, в Docker это том `splitapi-data`): адрес почты, хеш пароля (scrypt, пароль в открытом виде не хранится), хеши токенов входа, подтверждения и сброса (сами токены не хранятся), JSON с прогрессом. Резервное копирование и удаление данных: раздел 4. - Файл раскладки Vial (.vil) читается локально в браузере и никуда не загружается. ## 3. Установка Склонируйте репозиторий на сервер: ```bash git clone https://gittea.khad1997pav.ru/khadzhibekovpa/split-trainer.git cd split-trainer ``` ### Вариант A: Docker (рекомендуется) ```bash docker compose up -d --build curl -i http://127.0.0.1:8080/healthz # должно ответить 200 и "ok" ``` - Порт по умолчанию открыт **только на localhost**. Изменить: файл `.env` рядом с `docker-compose.yml`, например `PORT=9000` и/или `BIND=0.0.0.0` (откроет наружу: тогда закройте порт файрволом для всех, кроме прокси). - Контейнер работает без root-прав, с файловой системой «только чтение», без лишних возможностей ядра и с лимитом памяти. - Если прокси тоже в Docker, уберите блок `ports:` и подключите сервис к общей сети прокси: он слушает `8080` внутри контейнера. ### Вариант B: nginx из пакетов Debian ```bash sudo ./deploy/install-debian.sh curl -i http://127.0.0.1:8080/healthz ``` Скрипт ставит nginx, копирует файлы в `/var/www/split-trainer` и подключает отдельный конфиг `split-trainer.conf`. Чужие сайты и настройки он не трогает. Параметры (переменные окружения): | Переменная | По умолчанию | Назначение | |---|---|---| | `LISTEN` | `127.0.0.1:8080` | адрес и порт (или только порт) | | `DOMAIN` | `_` | `server_name`, можно несколько через пробел | | `WEBROOT` | `/var/www/split-trainer` | куда копировать файлы | | `FORCE` | `0` | `1`: разрешить запуск не на Debian 12/13 | Без прокси, прямо на порту 80: ```bash sudo LISTEN=80 DOMAIN=trainer.example.com ./deploy/install-debian.sh ``` Если порт 80 уже занят «сайтом по умолчанию» nginx и вы не задали `DOMAIN`, будет открываться страница «Welcome to nginx»: укажите домен или отключите сайт по умолчанию (`sudo rm /etc/nginx/sites-enabled/default && sudo systemctl reload nginx`). ### Обновление ```bash git pull docker compose up -d --build # вариант A # или sudo ./deploy/install-debian.sh # вариант B ``` Браузеры подхватят новую версию сами: сайт отдаётся с `Cache-Control: no-cache`, то есть при каждом открытии версия сверяется по ETag. ## 4. Аккаунты и почта Аккаунты необязательны: без API сайт работает как раньше, просто без пункта «Войти». С API появляются регистрация с подтверждением почты, вход, сброс пароля по почте, удаление аккаунта и хранение прогресса на сервере. ### Как включить **Docker (вариант A).** `docker compose up -d --build` поднимает сайт и API вместе. Скопируйте `.env.example` в `.env` и задайте: - `PUBLIC_URL`: внешний адрес сайта, как его видят пользователи (из него строятся ссылки в письмах и проверяется `Origin`); - настройки почты (`SMTP_*`, см. ниже). Если сайт публичный, а SMTP не задан, API **не запустится**: в `docker compose logs splitapi` будет подсказка. Это сделано намеренно: без почты ссылки подтверждения оказались бы в логах. Для проверки можно явно указать `MAIL_BACKEND=console`: письма тогда печатаются в лог (`docker compose logs splitapi`). Если аккаунты не нужны, удалите сервис `splitapi` из `docker-compose.yml`: сайт сам поймёт, что API нет, и не покажет вход. **Без Docker (вариант B).** После `install-debian.sh`: ```bash sudo PUBLIC_URL=https://trainer.example.com \ SMTP_HOST=smtp.example.com SMTP_USER=noreply@example.com SMTP_PASSWORD='...' \ ./deploy/install-api-debian.sh ``` Скрипт ставит python3 (нужен 3.11 или новее: это Debian 12 и 13), копирует код в `/opt/split-trainer/api`, создаёт `/etc/split-trainer/api.env` (права `0600`) и службу `split-trainer-api` с изоляцией systemd (`DynamicUser`, `ProtectSystem=strict`, лимит памяти). База лежит в `/var/lib/split-trainer`. Повторный запуск обновляет код и перезапускает службу, а `api.env` не трогает: его правят вручную и затем `sudo systemctl restart split-trainer-api`. Логи: `journalctl -u split-trainer-api`. ### Как это выглядит для пользователя 1. **Регистрация.** Человек вводит только почту и получает письмо со ссылкой (действует 24 часа, работает один раз). По ссылке он подтверждает адрес **и сам задаёт пароль**: аккаунт создаётся только в этот момент. Поэтому чужая регистрация на ваш адрес ничего не подменит. 2. **Вход.** Почта и пароль. После входа прогресс подтягивается с сервера и объединяется с тем, что уже есть на устройстве. 3. **Сброс пароля.** «Забыли пароль?» → письмо со ссылкой (1 час, один раз). После смены пароля все прежние входы завершаются и приходит уведомление. 4. **Синхронизация.** После каждого упражнения прогресс уходит на сервер. Слияние простое: лучший результат по урокам, объединение истории, для статистики клавиш берётся запись с большим числом нажатий (если два устройства долго работали офлайн, часть нажатий одного из них может не учесться). 5. **Удаление.** В разделе «Аккаунт» есть «Удалить аккаунт» (нужен пароль): удаляются аккаунт, сессии и прогресс на сервере. «Сбросить все данные» в «Статистике» при входе очищает и серверную копию (на других устройствах локальные данные при следующей синхронизации вернутся: очистите и их). ### Переменные окружения | Переменная | По умолчанию | Назначение | |---|---|---| | `PUBLIC_URL` | в compose `http://127.0.0.1:8080` | **обязательна**: внешний адрес сайта (если сайт на подпути, с ним: `https://example.com/trainer`) | | `SMTP_HOST`, `SMTP_PORT` | порт зависит от `SMTP_SECURITY` | почтовый сервер | | `SMTP_SECURITY` | `starttls` | `starttls` (порт 587), `ssl` (465) или `none` (только для локального релея) | | `SMTP_USER`, `SMTP_PASSWORD` | пусто | логин и пароль SMTP; вместо пароля можно `SMTP_PASSWORD_FILE=/путь/к/файлу` (Docker secrets) | | `MAIL_FROM` | `Сплит-тренер ` | от чьего имени уходят письма | | `MAIL_BACKEND` | `smtp`, если задан `SMTP_HOST`; `console` только для localhost | `console` печатает письма в лог: только для проверки | | `ALLOW_REGISTRATION` | `true` | `false` закроет регистрацию (прежние пользователи продолжат входить) | | `SESSION_DAYS` | `30` | сколько дней действует вход на устройстве | | `DB_PATH`, `BIND`, `PORT`, `TRUST_PROXY` | заданы compose и скриптом установки | служебные: менять не нужно | После правки `.env`: `docker compose up -d`. Если сайт открывают не по тому адресу, что указан в `PUBLIC_URL`, запросы получат «Запрос отклонён», а в логе API будет строка с полученным и ожидаемым `Origin`. ### Почта (SMTP) Примеры (подставьте данные вашего провайдера): ``` # STARTTLS, порт 587 SMTP_HOST=smtp.example.com SMTP_PORT=587 SMTP_SECURITY=starttls SMTP_USER=noreply@example.com SMTP_PASSWORD=секрет # SSL, порт 465 SMTP_HOST=smtp.example.com SMTP_PORT=465 SMTP_SECURITY=ssl ``` Чтобы письма не попадали в «Спам», настройте для домена отправителя SPF, DKIM и DMARC. Письма отправляются в фоне: если SMTP недоступен, в логе API будет строка «не удалось отправить письмо» (с типом ошибки, без адреса и текста письма). Очередь рассчитана на 100 писем; при переполнении пользователь получает «сервер занят», а не ложное «проверьте почту». Очередь хранится в памяти: при перезапуске API неотправленные письма теряются. ### Резервное копирование и обслуживание Данные лежат в SQLite (Docker: том `splitapi-data`; без Docker: `/var/lib/split-trainer/splitapi.sqlite3`). Копировать файл базы «на лету» нельзя, нужна встроенная резервная копия. Docker: ```bash docker compose exec -T splitapi python -c "import sqlite3; s=sqlite3.connect('/data/splitapi.sqlite3'); d=sqlite3.connect('/data/backup.sqlite3'); s.backup(d); d.close(); s.close()" docker cp splitapi:/data/backup.sqlite3 ./backup-$(date +%F).sqlite3 docker compose exec -T splitapi rm /data/backup.sqlite3 ``` Без Docker: ```bash sudo python3 -c "import sqlite3; s=sqlite3.connect('/var/lib/split-trainer/splitapi.sqlite3'); d=sqlite3.connect('/root/backup.sqlite3'); s.backup(d)" ``` Удалить пользователя вручную (по просьбе, когда нет доступа к его паролю): в базе включите внешние ключи, иначе его сессии и прогресс останутся: ```bash docker compose exec -T splitapi python -c "import sqlite3; c=sqlite3.connect('/data/splitapi.sqlite3'); c.execute('pragma foreign_keys=on'); c.execute('delete from users where email=?', ('user@example.com',)); c.commit()" ``` ### Безопасность в двух словах - Пароли хранятся только как хеш scrypt; токены входа, подтверждения и сброса хранятся только как SHA-256 и работают один раз. - Ссылки в письмах строятся из `PUBLIC_URL`, а не из заголовка `Host`. Токен лежит во фрагменте адреса (`#…`): он не попадает в логи сервера и заголовок `Referer`. - Ограничения частоты: вход считает только неудачи: по паре «адрес + IP» (5 за 10 минут), по IP (20 за 10 минут) и по всему аккаунту (100 за час, против распределённого подбора). Регистрация и сброс пароля ограничены по IP и по адресу. IPv6-клиенты считаются по сети /64. Успешные входы лимиты не расходуют: класс или семья за одним NAT входят без помех. - Порт API **не публикуется наружу**: к нему ходит только nginx из контейнера сайта. Не публикуйте порт 8000 и не ставьте API за другой прокси: API верит заголовку `X-Real-IP` (`TRUST_PROXY=1`) и только от приватных адресов. - Ваш внешний обратный прокси обязан **добавлять** настоящий адрес клиента в `X-Forwarded-For` (пример в разделе 5: `$proxy_add_x_forwarded_for`). Если он передаёт заголовок клиента без изменений, ограничения частоты можно обойти подменой адреса. - Режим `console` выключен по умолчанию на публичных адресах: ссылки из писем нельзя оставлять в логах. - Две вкладки с разными аккаунтами не смешивают прогресс: сервер сверяет, от чьего имени сохраняются данные, а устаревшая вкладка обновляется. ### Что сознательно не решено (принятые риски) - **Счётчики ограничений хранятся в памяти одного процесса.** После перезапуска API они обнуляются, а запускать несколько процессов нельзя. - **Чужой «шум» мешает владельцу.** Кто-то может засыпать чужой адрес письмами (3 в час на адрес, 5 в час с IP) и этим же лимитом на время лишить владельца письма для сброса пароля. Это цена защиты ящика от рассылки. - **Пароли.** Минимум 8 символов и короткий список плохих паролей; проверки по базам утечек и двухфакторной защиты нет. Поэтому действуют лимиты на подбор. - **Доверие к `X-Real-IP`** от любых приватных адресов, а не только от nginx: на хосте любой локальный процесс может подделать IP (лимиты по почте при этом остаются). Закрыто тем, что API слушает только `127.0.0.1`. - **Данные последнего пользователя остаются на устройстве после выхода**, включая его адрес почты в локальном хранилище браузера. На общем компьютере используйте отдельный профиль браузера. - **Нет выхода со всех устройств и смены пароля внутри сессии:** сброс через почту завершает все входы. - **Пароль SMTP** передаётся переменной окружения и виден через `docker inspect`; вместо этого можно задать `SMTP_PASSWORD_FILE` (Docker secrets). - **Очередь писем в памяти:** при перезапуске API неотправленные письма теряются. ## 5. За обратным прокси От прокси ничего особенного не требуется: без WebSocket, без нестандартных заголовков, все ссылки внутри относительные. Достаточно передать все запросы (в том числе `/api/`) на `127.0.0.1:8080`. ### nginx ```nginx server { listen 443 ssl; server_name trainer.example.com; ssl_certificate /etc/letsencrypt/live/trainer.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/trainer.example.com/privkey.pem; # HTTPS завершается здесь, поэтому HSTS должен ставить именно прокси add_header Strict-Transport-Security "max-age=31536000" always; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } ``` ### Caddy ``` trainer.example.com { reverse_proxy 127.0.0.1:8080 } ``` HTTPS-сертификат Caddy получит сам. ### Traefik (Docker, метки в `docker-compose.override.yml`) ```yaml services: split-trainer: ports: !reset [] networks: [traefik] labels: - traefik.enable=true - traefik.http.routers.trainer.rule=Host(`trainer.example.com`) - traefik.http.routers.trainer.tls.certresolver=letsencrypt - traefik.http.services.trainer.loadbalancer.server.port=8080 networks: traefik: external: true ``` Имена сети и резолвера замените на свои. Тег `!reset` требует Docker Compose 2.24 или новее. ### Под подпутём (`https://example.com/trainer/`) Приложение можно отдавать из подкаталога: все пути в нём относительные, свой конфиг менять не нужно. Прокси должен убрать префикс (завершающий `/` в `proxy_pass` обязателен): ```nginx location = /trainer { return 301 /trainer/; } location /trainer/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; } ``` Для Caddy: ``` example.com { redir /trainer /trainer/ handle_path /trainer/* { reverse_proxy 127.0.0.1:8080 } } ``` ### Настоящий IP клиента в логах Конфиг доверяет заголовку `X-Forwarded-For` только от прокси из частных сетей и localhost (`127.0.0.0/8`, `10/8`, `172.16/12`, `192.168/16`). Если ваш прокси стоит на другом сервере с публичным адресом, добавьте его в `set_real_ip_from` в `deploy/split-trainer.conf` и пересоберите или переустановите. ## 6. Что отдаёт сервер - `GET /healthz` отвечает `200 ok` (для мониторинга и healthcheck сайта), `GET /api/health` отвечает `{"ok": true}` (то же для API). - Запросы `/api/*` nginx передаёт API аккаунтов; ответы API не кешируются (`Cache-Control: no-store`). - Заголовки безопасности: строгий `Content-Security-Policy` (только свои скрипты и стили, никаких внешних адресов), `X-Content-Type-Options`, `Referrer-Policy: no-referrer`, `X-Frame-Options`, `Permissions-Policy`. - Файлы сжимаются gzip, скрытые файлы (`.git`, `.env`) не отдаются, версия nginx в ответах скрыта. ## 7. Если что-то не работает | Симптом | Причина и решение | |---|---| | На сайте нет пункта «Войти» | API не запущен или недоступен: `docker compose ps`, `curl http://127.0.0.1:8080/api/health` (ожидается `{"ok": true}`) | | Письмо не приходит | Проверьте «Спам» и `docker compose logs splitapi`: при ошибке SMTP там строка «не удалось отправить письмо». Без `SMTP_HOST` письма только в логе | | «Ссылка недействительна или устарела» | Ссылка одноразовая и живёт 24 часа (подтверждение) или 1 час (сброс); повторная регистрация или запрос сброса присылает новую, а старая перестаёт работать | | «Запрос отклонён» при входе | `PUBLIC_URL` не совпадает с адресом, по которому открыт сайт (схема, домен, порт): исправьте в `.env` и перезапустите `splitapi` | | «Слишком много попыток» | Сработало ограничение частоты: подождите несколько минут | | 502 / 504 от прокси | Сайт не запущен или слушает другой порт: `docker compose ps`, `curl http://127.0.0.1:8080/healthz` | | Белая страница под `/trainer/` | Нет завершающего `/` в `proxy_pass` или нет редиректа `/trainer` на `/trainer/` (см. выше) | | После обновления видна старая версия | Обновите страницу с Ctrl+F5; если стоит CDN или кеш на прокси, исключите сайт из кеширования | | Прогресс пропал после смены адреса | `localStorage` привязан к адресу сайта (схема + домен + порт); вернуть старый адрес | | `nginx -t` ругается (вариант B) | Покажите вывод `sudo nginx -t`; чаще всего порт занят другим сайтом | | Нужно изменить текст упражнений | Правьте `public/data.js` и обновите сайт командой из раздела «Обновление» |