Safari (iPadOS/iOS) blocks SameSite=Strict cookies on the initial top-level navigation when it considers the request cross-site (links from messengers, email, QR codes). The CSRF cookie was therefore never set on first visit, and the subsequent login POST failed with 403 "CSRF failed". Switch the CSRF cookie to SameSite=Lax — this is the OWASP recommended default and matches industry practice. The auth (session) cookie keeps SameSite=Strict, since it is only issued after a successful first-party login POST and needs the stricter binding.
Portal Stand Access (MVP)
MVP-портал доступа к стендам в Proxmox через единый вход https://stend.4mont.ru.
1. Архитектура
traefik: входная точка TLS, маршрутизация на API и динамические сессионные контейнеры.api(FastAPI): auth, ACL, админ API, создание runtime-сессий, cleanup.db(PostgreSQL): пользователи, сервисы, ACL, сессии, аудит.portal-kioskimage: Chromium kiosk + noVNC (для WEB target).portal-rdp-proxyimage: xfreerdp + websockify/noVNC proxy до удаленного RDP host:port.portal-universal-runtimeimage: универсальный runtime (WEB/RDP), используется общим warm poolUNIVERSAL_POOL_SIZE.- Опциональный prewarm pool:
- глобальный fallback
PREWARM_POOL_SIZE; - приоритетный
warm_pool_sizeна каждом сервисе в админке.
- глобальный fallback
Поток:
- Пользователь логинится на
/. - Dashboard показывает разрешённые сервисы.
- Клик по
/go/<slug>-> ACL check + проверкаexpires_at+ создание записиsessions+ старт отдельного контейнера. - Пользователь редиректится на
/s/<session_id>/. - Traefik отправляет
/s/<session_id>/...в конкретный runtime контейнер по dynamic labels. - Runtime страница шлёт heartbeat в
/api/sessions/<session_id>/touch. - Фоновый cleanup завершает сессии при idle > 30 минут.
При prewarm:
- создаются warm-контейнеры с маршрутом
/svc/<slug>/; - клик по плитке использует prewarmed runtime без задержки cold start;
- сессии в БД создаются, но контейнеры остаются пулом (без стопа на каждую сессию).
- в
/adminесть:- отдельные разделы Users / WEB / RDP (список + форма выбранной записи);
pool sizeна сервис;- кнопка
Prewarm now; - health
running/desiredпо каждому пулу. - авто-генерация
slugиз названия (поддержка кириллицы -> латиница).
2. Схема БД
Основные таблицы:
usersservicesuser_service_accesssessions
Дополнительно:
audit_logs
SQL-схема: scripts/schema.sql.
3. Безопасность
- Пароли:
argon2(passlib[argon2]). - Cookie auth:
HttpOnly,Secure,SameSite=Strict. - CSRF:
- формы (
/login) через hidden token + cookie; - admin JSON API через
X-CSRF-Token.
- формы (
- Проверки при каждом запросе:
- пользователь
active=true; expires_at > now();- ACL на
/go/<slug>.
- пользователь
- Аудит: события входа и создания сессий в
audit_logs.
4. Файлы
docker-compose.ymltraefik/traefik.ymltraefik/dynamic/security.ymlapp/main.pykiosk/Dockerfile,kiosk/entrypoint.shrdp-proxy/Dockerfile,rdp-proxy/entrypoint.sh
5. Запуск
cp .env.example .env
mkdir -p traefik/letsencrypt
touch traefik/letsencrypt/acme.json
chmod 600 traefik/letsencrypt/acme.json
Собрать runtime образы:
docker compose --profile build-only build kiosk-image rdp-proxy-image universal-runtime-image
Поднять систему:
docker compose up -d --build
Проверка:
docker compose ps
docker compose logs -f api traefik
Дефолтный админ берётся из .env (ADMIN_USERNAME, ADMIN_PASSWORD).
5.1 Развертывание На Другом Сервере (Gitea)
Пример для чистого Ubuntu-сервера.
- Установить Docker + Compose plugin:
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo $VERSION_CODENAME) stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin git
sudo usermod -aG docker $USER
newgrp docker
- Клонировать проект из Gitea:
git clone http://gitea.ruslan.xyz/ruslan/Stend_mont.git
cd Stend_mont
- Подготовить конфиг:
cp .env.example .env
mkdir -p traefik/letsencrypt
touch traefik/letsencrypt/acme.json
chmod 600 traefik/letsencrypt/acme.json
- Отредактировать
.env:
PUBLIC_HOST-> домен нового стенда (напримерstend.example.com)LETSENCRYPT_EMAIL-> рабочий emailADMIN_USERNAME,ADMIN_PASSWORD-> учетка администратораDATABASE_URLпри необходимости оставить как есть (внутренний docker postgres)
- Собрать и запустить:
docker compose --profile build-only build kiosk-image rdp-proxy-image universal-runtime-image
docker compose up -d --build
- Проверка:
docker compose ps
docker compose logs -f api traefik
Если домен уже смотрит на этот сервер и 80/443 открыты, админка будет доступна по:
https://<PUBLIC_HOST>/admin
5.2 Вариант С Nginx Proxy Manager (NPM)
Если на сервере уже используется NPM, внешний трафик можно вести через него, а Traefik оставить только внутренним роутером проекта.
В этом репозитории уже настроены внутренние порты:
127.0.0.1:2288 -> traefik:443(HTTPS upstream)127.0.0.1:8288 -> traefik:80(HTTP upstream, технический)
Важно: Traefik убирать нельзя, он нужен для динамических маршрутов сессий (/s/*, /svc/*).
Как настроить NPM:
- Создать
Proxy Hostдля домена (напримерstend.example.com):
Forward Hostname / IP:127.0.0.1Forward Port:2288Scheme:https- включить
Websockets Support
-
На вкладке SSL в NPM выпустить/подключить сертификат для домена.
-
В
.envпроекта оставить:
PUBLIC_HOST=stend.example.com
- Перезапустить проект:
docker compose up -d --build
После этого вход в портал и сессии будут работать через NPM, а Traefik останется внутренним компонентом.
6. Примеры admin API
- Создать сервис WEB:
curl -k -X POST "https://stend.4mont.ru/api/admin/services" \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: <csrf_from_cookie>" \
-H "Cookie: portal_auth=<cookie>; csrf_token=<csrf>" \
-d '{"name":"CRM","slug":"crm","type":"WEB","target":"http://192.168.1.10:3000","active":true}'
- Создать сервис RDP:
curl -k -X POST "https://stend.4mont.ru/api/admin/services" \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: <csrf_from_cookie>" \
-H "Cookie: portal_auth=<cookie>; csrf_token=<csrf>" \
-d '{"name":"Windows Desktop","slug":"win-rdp1","type":"RDP","target":"192.168.1.60:3389","active":true}'
Для RDP target также поддерживает креды и параметры:
rdp://user:password@192.168.1.60:3389?domain=AD&sec=nla
- Создать пользователя:
curl -k -X POST "https://stend.4mont.ru/api/admin/users" \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: <csrf_from_cookie>" \
-H "Cookie: portal_auth=<cookie>; csrf_token=<csrf>" \
-d '{"username":"user1","password":"Passw0rd!","expires_at":"2026-12-31T23:59:59+00:00","active":true}'
- Назначить ACL:
curl -k -X PUT "https://stend.4mont.ru/api/admin/users/2/acl" \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: <csrf_from_cookie>" \
-H "Cookie: portal_auth=<cookie>; csrf_token=<csrf>" \
-d '{"service_ids":[1,2]}'
7. Ограничения MVP
- Нет отдельной UI-админки (есть admin API).
- TTL неактивности основан на heartbeat runtime-страницы.
- Для production стоит добавить Alembic-миграции, rate limiting и централизованный логинг.
8. Нагрузочное тестирование (актуально на 2026-04-24)
В проекте подтвержден рабочий плавный профиль нагрузки без резкого burst:
- +1 пользователь в минуту;
- целевой online: 20 пользователей;
- каждый запускает 2 WEB-сервиса (termidesk и vmmanager).
Результат прогона 20x2:
- логин и открытие сервисов: 100% успешных проверок;
- HTTP 5xx в тесте: 0;
- достигнуто 40 ACTIVE WEB-сессий (20 + 20).
Важно для повторения теста:
- Перед прогоном временно увеличьте idle timeout (SESSION_IDLE_SECONDS) и пересоздайте WEB-пул, чтобы слоты получили новый IDLE_TIMEOUT.
- Параллельно собирайте метрики хоста (uptime/free/df/docker stats) в файл.
- После прогона проверяйте согласованность: ACTIVE-сессии в БД vs реально существующие portal-webpool-* контейнеры.
Замеченный риск:
- при агрессивном авторасширении/сжатии WEB-пула возможны ACTIVE-сессии, привязанные к уже удаленным слотам.