ruslan cf68bc848f Fix CSRF SameSite=Strict breaking login on iPad/Safari
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.
2026-04-30 17:38:20 +00:00

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-kiosk image: Chromium kiosk + noVNC (для WEB target).
  • portal-rdp-proxy image: xfreerdp + websockify/noVNC proxy до удаленного RDP host:port.
  • portal-universal-runtime image: универсальный runtime (WEB/RDP), используется общим warm pool UNIVERSAL_POOL_SIZE.
  • Опциональный prewarm pool:
    • глобальный fallback PREWARM_POOL_SIZE;
    • приоритетный warm_pool_size на каждом сервисе в админке.

Поток:

  1. Пользователь логинится на /.
  2. Dashboard показывает разрешённые сервисы.
  3. Клик по /go/<slug> -> ACL check + проверка expires_at + создание записи sessions + старт отдельного контейнера.
  4. Пользователь редиректится на /s/<session_id>/.
  5. Traefik отправляет /s/<session_id>/... в конкретный runtime контейнер по dynamic labels.
  6. Runtime страница шлёт heartbeat в /api/sessions/<session_id>/touch.
  7. Фоновый cleanup завершает сессии при idle > 30 минут.

При prewarm:

  • создаются warm-контейнеры с маршрутом /svc/<slug>/;
  • клик по плитке использует prewarmed runtime без задержки cold start;
  • сессии в БД создаются, но контейнеры остаются пулом (без стопа на каждую сессию).
  • в /admin есть:
    • отдельные разделы Users / WEB / RDP (список + форма выбранной записи);
    • pool size на сервис;
    • кнопка Prewarm now;
    • health running/desired по каждому пулу.
    • авто-генерация slug из названия (поддержка кириллицы -> латиница).

2. Схема БД

Основные таблицы:

  • users
  • services
  • user_service_access
  • sessions

Дополнительно:

  • 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.yml
  • traefik/traefik.yml
  • traefik/dynamic/security.yml
  • app/main.py
  • kiosk/Dockerfile, kiosk/entrypoint.sh
  • rdp-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-сервера.

  1. Установить 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
  1. Клонировать проект из Gitea:
git clone http://gitea.ruslan.xyz/ruslan/Stend_mont.git
cd Stend_mont
  1. Подготовить конфиг:
cp .env.example .env
mkdir -p traefik/letsencrypt
touch traefik/letsencrypt/acme.json
chmod 600 traefik/letsencrypt/acme.json
  1. Отредактировать .env:
  • PUBLIC_HOST -> домен нового стенда (например stend.example.com)
  • LETSENCRYPT_EMAIL -> рабочий email
  • ADMIN_USERNAME, ADMIN_PASSWORD -> учетка администратора
  • DATABASE_URL при необходимости оставить как есть (внутренний docker postgres)
  1. Собрать и запустить:
docker compose --profile build-only build kiosk-image rdp-proxy-image universal-runtime-image
docker compose up -d --build
  1. Проверка:
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:

  1. Создать Proxy Host для домена (например stend.example.com):
  • Forward Hostname / IP: 127.0.0.1
  • Forward Port: 2288
  • Scheme: https
  • включить Websockets Support
  1. На вкладке SSL в NPM выпустить/подключить сертификат для домена.

  2. В .env проекта оставить:

  • PUBLIC_HOST=stend.example.com
  1. Перезапустить проект:
docker compose up -d --build

После этого вход в портал и сессии будут работать через NPM, а Traefik останется внутренним компонентом.

6. Примеры admin API

  1. Создать сервис 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}'
  1. Создать сервис 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
  1. Создать пользователя:
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}'
  1. Назначить 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).

Важно для повторения теста:

  1. Перед прогоном временно увеличьте idle timeout (SESSION_IDLE_SECONDS) и пересоздайте WEB-пул, чтобы слоты получили новый IDLE_TIMEOUT.
  2. Параллельно собирайте метрики хоста (uptime/free/df/docker stats) в файл.
  3. После прогона проверяйте согласованность: ACTIVE-сессии в БД vs реально существующие portal-webpool-* контейнеры.

Замеченный риск:

  • при агрессивном авторасширении/сжатии WEB-пула возможны ACTIVE-сессии, привязанные к уже удаленным слотам.
S
Description
No description provided
Readme 11 MiB
Languages
Python 67.5%
HTML 18.8%
CSS 6.6%
Shell 5%
JavaScript 1.7%
Other 0.4%