37 Commits

Author SHA1 Message Date
ruslan 9bd38ed6db Improve autofill extension: better field detection + Basic Auth support
- Split filled flag into userFilled/passFilled for independent tracking
- Add findUserFieldNearPassword() for DOM-relative lookup near password field
- Add isVisible() helper to skip disabled/hidden/offscreen inputs
- Add console.log tracing for debugging
- Add background.js service worker with webRequest.onAuthRequired for Basic Auth
- Add _url_with_credentials() to embed login:pass in URL for HTTP Basic Auth
- Use /usr/lib/chromium/chromium binary directly (bypass Debian wrapper)
- Add --enable-logging=stderr for console.log capture in chromium logs
2026-05-01 04:44:15 +00:00
ruslan d57acb416b Replace Login Data injection with autofill via Chromium extension
The previous approach pre-populated Chromiums Login Data SQLite with
schema version 30 and AES-128-CBC v10 encrypted passwords. Chromium 147
expects schema version 43, fails to migrate (Unable to migrate database
from 30 to 43), and refuses to open Login Data altogether. Result: the
row was written but Chromium never read it, so autofill never worked.

Instead generate a tiny Manifest V3 extension per session in a temp dir
with a content_script that finds username and password fields, sets
their values, and dispatches input/change events. Pass it via
--load-extension and --disable-extensions-except so it is the only
extension loaded.

Benefits:
- Independent of Chromium version and Login Database schema
- Works on SPAs (MutationObserver re-runs on DOM changes)
- Credentials live only in a temp file alongside the profile, removed
  on session end via _stop_current
- No SQLite or cryptography dependency
- Removes the silent failure mode of Login Data migration

Removes _chrome_encrypt_v10, sqlite3, hashlib, urlparse imports.
Adds _create_autofill_extension and tracks extension_dir alongside
profile_dir in _state for cleanup symmetry.
2026-04-30 17:47:10 +00:00
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
ruslan be65be8fdb Fix Chromium autofill timing bug + RDP anti-idle to prevent lock screen
- universal-runtime: set _state[profile_dir] AFTER _start_process so
  _stop_current does not delete the freshly-created profile before
  Chromium reads it. Without this, Login Data was being wiped.
- rdp-proxy: add xdotool dependency and background anti_idle_loop that
  sends Shift to the xfreerdp window every 30s, forwarded over RDP to
  reset the remote idle timer and keep the lock screen from kicking in.
2026-04-30 14:05:04 +00:00
ruslan 23c1f6e342 Chromium: Russian language, autofill passwords from svc_login/svc_password via Login Data 2026-04-30 07:22:31 +00:00
ruslan d7c956e10b rdp-proxy: monitor xfreerdp, auto-restart container on disconnect 2026-04-30 06:53:27 +00:00
ruslan 3f20fe5991 Fix: category delete button broken by double-quote conflict in onclick attr 2026-04-28 21:07:14 +00:00
ruslan 154ec35384 Block mobile devices: show desktop-only page 2026-04-28 20:52:24 +00:00
ruslan 8f3617afdd Remove copy buttons from credentials panels 2026-04-28 13:49:47 +00:00
ruslan beb6828520 Add credentials panel on view page; remove copy buttons from dashboard cards 2026-04-28 13:47:46 +00:00
ruslan 4cc19b32d8 chore: ignore uploaded service icons 2026-04-28 13:32:28 +00:00
ruslan 2d65d98116 fix: link z-index:0, tile z-index:1 pointer-events:none, scroll restored 2026-04-28 13:29:53 +00:00
ruslan 1ab5af28b5 fix: link overlay pattern — credentials never trigger navigation 2026-04-28 13:17:32 +00:00
ruslan bfcf5f565b fix: card height 672px (-30%) 2026-04-28 13:08:29 +00:00
ruslan af02c0d059 fix: card height 960px, credentials always visible, only comment scrolls 2026-04-28 13:02:19 +00:00
ruslan 530d901a45 fix: fixed card height 480px, icon constrained, info-area scrolls 2026-04-28 12:57:21 +00:00
ruslan 7a7c6e30e3 fix: tile-info-area shares space between credentials+comment, scroll restored 2026-04-28 12:48:59 +00:00
ruslan 6ccba89216 fix: equal card height — categories always at bottom, comment clips to fill 2026-04-28 12:33:12 +00:00
ruslan a64d49a8c1 feat: reorder card layout + svc_cred_hint field for credentials note 2026-04-28 12:20:37 +00:00
ruslan b06620a793 fix: move credentials and comment inside card, right below icon 2026-04-28 12:16:02 +00:00
ruslan b9d13733c9 feat: service credentials (login/password) on dashboard cards with copy button 2026-04-28 12:10:40 +00:00
ruslan fa88f7f4e4 feat: full Markdown support in service card comments (mistune) 2026-04-28 11:57:44 +00:00
ruslan b951f6c68e docs: add full deployment guide in Russian 2026-04-28 07:08:05 +00:00
ruslan d0ff949828 fix: remove stale Xvfb lock file on container restart
On restart, /tmp/.X1-lock remains from previous run causing Xvfb to fail
with 'Server is already active for display 1', which then breaks xfreerdp
and x11vnc. Clean up lock and socket before starting Xvfb.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 06:50:56 +00:00
ruslan a4a96c45b0 fix: RDP slot occupancy and cleanup_loop always running
- admin_page: slot shown as occupied based on ACTIVE status only (no time cutoff)
- go_service: busy slots checked by ACTIVE status (no cutoff) — cleanup_loop handles expiry
- startup_event: cleanup_loop starts regardless of ENABLE_STARTUP_MAINTENANCE flag;
  pool/container init guarded by the flag separately
- cleanup_loop: RDPSLOT sessions expire correctly and trigger container restart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 06:48:00 +00:00
ruslan 552898e3e9 fix: cleanup_loop correctly frees and restarts RDP slots on session expiry
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 06:34:04 +00:00
ruslan 6847cbc078 feat: RDP slot pool — multi-user RDP with per-account containers
- New RdpSlot model (rdp_slots table): service_id, rdp_username,
  rdp_password, container_name
- Each slot gets a dedicated portal-rdpslot-<slug>-<id> container with
  Traefik route /rdp/<slot_id>/ and restart_policy=unless-stopped
- go_service: RDP services with slots use pool allocation — finds first
  free slot (not occupied by active session), returns 503 if all busy
- session_status + session_view: handle RDPSLOT: container_id prefix
- terminate_session_record: restarts slot container in background on close
- session_redirect_url: RDPSLOT sessions redirect to /s/<id>/view
- startup_event: starts containers for all configured slots on boot
- Admin: POST /api/admin/services/{id}/rdp-slots, DELETE /api/admin/rdp-slots/{id}
- admin.html: slot management UI (list, add, delete); removed ACL exclusivity
- set_acl: removed RDP 1-user exclusivity — RDP services now assignable to many

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 06:32:02 +00:00
ruslan 67d361c5c9 feat: login page polish — remove welcome text, add spacer, resize corner brand
- login.html: removed 'Добро пожаловать' heading, added 3.5rem spacer below logo
- style.css: .login-corner-brand font-size set to 1.5rem

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 19:31:24 +00:00
ruslan 56cf2d6830 feat: responsive mobile wall with logo and footer
- #mobile-wall uses width:100vw/height:100vh/overflow:hidden to prevent
  text overflow on narrow screens
- All font sizes via clamp(), word-break:break-word on text elements
- MONT logo pinned to top (position:absolute), height clamp(4rem,16vw,6rem)
- Made by Galyaviev footer pinned to bottom

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 19:20:35 +00:00
ruslan 6f17193312 feat: loading overlay on dashboard, RDP pooled session routing fix
- dashboard.html: overlay div moved before <script> so getElementById works;
  double rAF ensures browser paints spinner before navigation
- main.py: pooled_rdp route fix — session_status now returns /svc/<slug>/
  route and redirect_url for POOL: RDP sessions (was always ready instantly)
- docker-compose.yml: parametrise env vars via .env for easier tuning

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 19:07:55 +00:00
ruslan 419b495020 feat: RDP ACL exclusivity, mobile wall, nav buttons, resolution xrandr
- RDP сервис может быть назначен только одному пользователю в ACL
- Мобильная заглушка на dashboard при ширине < 1024px
- rdp-proxy: кнопки навигации, спиннер Ожидайте, реконнект
- session_wait_page: тёмная тема, CSS спиннер
- kiosk/universal-runtime manager.py: xrandr + cvt --newmode для resolution
- Dockerfiles: x11-xserver-utils, x11-utils
2026-04-27 18:49:06 +00:00
ruslan 445d025de2 Add configurable X11VNC_FLAGS env passthrough 2026-04-25 19:07:49 +00:00
ruslan d927e1c947 chore: stop tracking local project context doc 2026-04-25 18:45:44 +00:00
ruslan bd4350b2e0 docs: add ncache-related resolution issue note 2026-04-25 18:44:37 +00:00
ruslan 40fd9ac64d chore: remove obsolete local backup files 2026-04-25 17:28:12 +00:00
ruslan 6871ea6b67 fix: improve web runtime resolution and restore x11vnc ncache 2026-04-25 17:28:04 +00:00
ruslan ebc5c12a23 Merge pull request 'feature/web-dynamic-resolution' (#1) from feature/web-dynamic-resolution into main
Reviewed-on: #1
2026-04-25 08:28:27 +00:00
19 changed files with 2061 additions and 293 deletions
-13
View File
@@ -1,13 +0,0 @@
PUBLIC_HOST=stend.4mont.ru
LETSENCRYPT_EMAIL=admin@4mont.ru
POSTGRES_DB=portal
POSTGRES_USER=portal
POSTGRES_PASSWORD=change_me
SIGNING_KEY=replace_with_long_random_key
ADMIN_USERNAME=admin
ADMIN_PASSWORD=StrongAdminPassword!
PREWARM_POOL_SIZE=2
UNIVERSAL_POOL_SIZE=5
LOG_LEVEL=INFO
+1
View File
@@ -15,3 +15,4 @@ PROJECT_CONTEXT.md
*.bak* *.bak*
*.env.bak* *.env.bak*
docs/CONTEXT_TEST.md docs/CONTEXT_TEST.md
app/static/service-icons/
+601
View File
@@ -0,0 +1,601 @@
# Инструкция по развёртыванию — МОНТ Инфраструктурный Полигон
Версия проекта: **0.6.0**
---
## Содержание
1. [Что это такое](#1-что-это-такое)
2. [Архитектура](#2-архитектура)
3. [Требования к серверу](#3-требования-к-серверу)
4. [Установка зависимостей](#4-установка-зависимостей)
5. [Клонирование репозитория](#5-клонирование-репозитория)
6. [Настройка переменных окружения](#6-настройка-переменных-окружения)
7. [Настройка Traefik](#7-настройка-traefik)
8. [Сборка Docker-образов рантаймов](#8-сборка-docker-образов-рантаймов)
9. [Первый запуск](#9-первый-запуск)
10. [Инициализация базы данных](#10-инициализация-базы-данных)
11. [Проверка работоспособности](#11-проверка-работоспособности)
12. [Настройка через админку](#12-настройка-через-админку)
13. [Настройка RDP-слотов](#13-настройка-rdp-слотов)
14. [Обновление проекта](#14-обновление-проекта)
15. [Описание всех переменных окружения](#15-описание-всех-переменных-окружения)
16. [Частые проблемы и решения](#16-частые-проблемы-и-решения)
---
## 1. Что это такое
Веб-портал для выдачи пользователям браузерного доступа к стендам и сервисам.
Поддерживает три типа сервисов:
| Тип | Описание |
|-----|----------|
| **WEB** | Открывает веб-сайт в браузере Chromium внутри виртуального дисплея (noVNC-стриминг) |
| **VNC** | Подключается по VNC к внешнему хосту |
| **RDP** | Подключается по RDP к внешнему хосту; пул слотов — несколько пользователей одновременно |
Стек: **FastAPI + PostgreSQL + Traefik + Docker**.
---
## 2. Архитектура
```
Интернет
│ HTTPS :443
┌─────────┐
│ Traefik │ — edge-прокси, TLS, маршрутизация по URL
└────┬────┘
│ docker network: portal_net
├──────────────────────────────┐
▼ ▼
┌─────────┐ ┌────────────┐
│ api │ FastAPI/uvicorn │ maintenance│ (отдельный процесс очистки)
└────┬────┘ └────────────┘
├─ /var/run/docker.sock (API управляет контейнерами напрямую)
┌──────────┐
│ db │ PostgreSQL 16
└──────────┘
Динамически создаваемые контейнеры (не в compose):
portal-webpool-N — WEB-сессии (portal-universal-runtime)
portal-universal-N — VNC-сессии (portal-universal-runtime)
portal-rdpslot-SLUG-N — RDP-слоты (portal-rdp-proxy)
```
Traefik читает метки (`labels`) у динамически создаваемых контейнеров и автоматически добавляет маршруты без перезапуска.
---
## 3. Требования к серверу
| Параметр | Минимум | Рекомендовано |
|----------|---------|---------------|
| ОС | Ubuntu 22.04 / Debian 12 | Ubuntu 24.04 |
| CPU | 2 ядра | 4+ ядра |
| RAM | 4 ГБ | 8+ ГБ |
| Диск | 20 ГБ | 40+ ГБ (Docker-образы большие) |
| Docker | 24+ | 29+ |
| Docker Compose | v2.20+ | v2.40+ |
| Внешний IP | Обязателен | — |
| DNS-запись | A-запись домена → IP сервера | — |
| Порты открыты | 80, 443 | — |
> **Важно**: домен и DNS-запись нужны для автоматического получения TLS-сертификата через Let's Encrypt.
> Без домена — использовать самоподписанный сертификат (см. [раздел 7](#7-настройка-traefik)).
---
## 4. Установка зависимостей
```bash
# Обновляем систему
sudo apt-get update && sudo apt-get upgrade -y
# Устанавливаем Docker (официальный способ)
curl -fsSL https://get.docker.com | sh
# Добавляем текущего пользователя в группу docker (без sudo)
sudo usermod -aG docker $USER
newgrp docker
# Проверяем
docker --version # Docker version 29.x.x
docker compose version # Docker Compose version 2.x.x
```
---
## 5. Клонирование репозитория
Все исходники находятся в git-репозитории:
```
https://git.ruslan.xyz/ruslan/Stend_mont
```
```bash
# Рабочий каталог — можно любой, например /opt/stand или ~/docker/stand
mkdir -p ~/docker && cd ~/docker
# Репозиторий приватный — нужны credentials
git clone https://USER:TOKEN@git.ruslan.xyz/ruslan/Stend_mont.git stand
cd stand
```
> Запросите логин и токен у владельца репозитория.
Структура проекта после клонирования:
```
stand/
├── app/ # FastAPI-приложение (Dockerfile + main.py + шаблоны)
├── rdp-proxy/ # Docker-образ RDP-рантайма (xvfb + xfreerdp + noVNC)
├── universal-runtime/ # Docker-образ WEB/VNC-рантайма (Chromium + x11vnc + noVNC)
├── kiosk/ # Docker-образ kiosk-режима (опционально)
├── traefik/ # Конфиг Traefik (traefik.yml + dynamic/)
├── scripts/ # SQL-схема БД
├── docker-compose.yml
└── .env.example # Шаблон переменных окружения
```
---
## 6. Настройка переменных окружения
Скопируйте шаблон и отредактируйте:
```bash
cp .env.example .env
nano .env
```
**Минимальный набор значений, которые нужно поменять:**
```dotenv
# Домен, на котором будет работать портал
PUBLIC_HOST=your-domain.example.com
# Email для Let's Encrypt (уведомления об истечении сертификата)
LETSENCRYPT_EMAIL=admin@example.com
# Пароль PostgreSQL (придумайте сами, не менее 16 символов)
POSTGRES_PASSWORD=supersecretdbpassword
# Секретный ключ для подписи сессий (минимум 32 случайных символа)
# Генерация: python3 -c "import secrets; print(secrets.token_hex(32))"
SIGNING_KEY=your_random_signing_key_here
# Логин и пароль администратора портала
ADMIN_USERNAME=admin
ADMIN_PASSWORD=your_admin_password
```
**Остальные переменные** — описание см. в [разделе 15](#15-описание-всех-переменных-окружения).
---
## 7. Настройка Traefik
### 7.1 Файл `traefik/traefik.yml`
Откройте и проверьте email в секции `certificatesResolvers`:
```bash
nano traefik/traefik.yml
```
```yaml
certificatesResolvers:
letsencrypt:
acme:
email: admin@example.com # ← ваш email
storage: /letsencrypt/acme.json
tlsChallenge: {}
```
Порты по умолчанию: **HTTP :8288**, **HTTPS :2288** (нестандартные, прописаны в `docker-compose.yml`).
Если нужны стандартные 80/443:
```bash
nano docker-compose.yml
# Найдите секцию traefik -> ports и замените:
# - "0.0.0.0:8288:80" → - "80:80"
# - "0.0.0.0:2288:443" → - "443:443"
```
### 7.2 Создание файла хранилища сертификатов
```bash
mkdir -p traefik/letsencrypt
touch traefik/letsencrypt/acme.json
chmod 600 traefik/letsencrypt/acme.json
```
### 7.3 Работа без домена (самоподписанный сертификат)
Если домена нет и нужен только HTTP или самоподписанный HTTPS:
В `traefik/traefik.yml` удалите секцию `certificatesResolvers`.
В `docker-compose.yml` в labels контейнера `api` замените:
```yaml
- traefik.http.routers.portal.entrypoints=websecure
- traefik.http.routers.portal.tls=true
- traefik.http.routers.portal.tls.certresolver=letsencrypt
```
на:
```yaml
- traefik.http.routers.portal.entrypoints=web
```
---
## 8. Сборка Docker-образов рантаймов
Рантаймы — это образы для WEB/VNC/RDP-сессий. Они **не входят** в основной `docker compose up`, их нужно собрать отдельно.
```bash
cd ~/docker/stand
# Образ для WEB и VNC сессий (Chromium + x11vnc + noVNC)
docker build -t portal-universal-runtime:latest ./universal-runtime/
# Образ для RDP сессий (xvfb + xfreerdp + x11vnc + noVNC)
docker build -t portal-rdp-proxy:latest ./rdp-proxy/
# Образ kiosk (опционально, если используется kiosk-режим)
docker build -t portal-kiosk:latest ./kiosk/
```
> Сборка занимает 3–10 минут в зависимости от скорости интернета (скачивается Chromium и другие пакеты).
Проверьте что образы появились:
```bash
docker images | grep portal
# Должно быть:
# portal-universal-runtime latest ...
# portal-rdp-proxy latest ...
# portal-kiosk latest ...
```
---
## 9. Первый запуск
```bash
cd ~/docker/stand
# Запускаем всё (traefik, db, api, maintenance)
docker compose up -d --build
# Смотрим логи запуска
docker compose logs -f api
```
Дождитесь строки:
```
INFO: Application startup complete.
```
Затем откройте браузер: `https://your-domain.example.com`
---
## 10. Инициализация базы данных
База данных инициализируется **автоматически** при первом старте API через SQLAlchemy (ORM создаёт все таблицы).
Если нужно создать таблицы вручную (на случай сбоя):
```bash
# Подключитесь к контейнеру PostgreSQL
docker exec -it stend_mont-db-1 psql -U portal -d portal
# Выполните скрипт схемы
\i /dev/stdin
# вставьте содержимое scripts/schema.sql и нажмите Ctrl+D
```
Или через файл:
```bash
docker exec -i stend_mont-db-1 psql -U portal -d portal < scripts/schema.sql
```
> Таблица `rdp_slots` создаётся автоматически ORM, её нет в `schema.sql` — это нормально.
---
## 11. Проверка работоспособности
```bash
# Статус всех контейнеров
docker compose ps
# Логи отдельных сервисов
docker compose logs api # FastAPI
docker compose logs db # PostgreSQL
docker compose logs traefik # Traefik (маршруты, сертификаты)
docker compose logs maintenance # Фоновая очистка сессий
# Проверка что база доступна
docker exec stend_mont-db-1 pg_isready -U portal
# Проверка API (изнутри сети)
docker exec stend_mont-api-1 curl -s http://localhost:8000/health
```
Ожидаемый вывод `docker compose ps`:
```
NAME STATUS
stend_mont-api-1 Up
stend_mont-db-1 Up (healthy)
stend_mont-maintenance-1 Up
stend_mont-traefik-1 Up
```
---
## 12. Настройка через админку
1. Откройте `https://your-domain.example.com/admin`
2. Войдите с данными `ADMIN_USERNAME` / `ADMIN_PASSWORD` из `.env`
### Создание пользователей
**Пользователи → Добавить пользователя:**
- Логин, пароль, срок действия аккаунта
- После создания — назначить права доступа к сервисам (раздел ACL)
### Создание сервисов
**Сервисы → Добавить сервис:**
| Поле | Описание |
|------|----------|
| Название | Отображаемое название |
| Slug | URL-идентификатор (латиница, цифры, дефис) |
| Тип | WEB / VNC / RDP |
| Адрес | `host:port` для VNC/RDP; URL для WEB |
| Иконка | PNG/SVG, загружается через форму |
| Размер пула | Для WEB: количество прогретых контейнеров (0 = по запросу) |
### Назначение доступа (ACL)
**ACL → выберите пользователя → отметьте сервисы → Сохранить**
---
## 13. Настройка RDP-слотов
RDP-сервисы используют **пул слотов**: каждый слот = отдельный RDP-пользователь + отдельный контейнер.
Пользователи занимают свободные слоты, при их нехватке получают ошибку 503.
### Шаг 1: Создайте RDP-сервис
В админке создайте сервис с типом **RDP**:
- **Адрес**: `hostname_или_ip:3389` (без протокола)
- Домен, безопасность — если нужны
### Шаг 2: Добавьте слоты (RDP-пользователей)
В карточке сервиса появится раздел **«RDP пользователи (слоты пула)»**:
- Введите RDP-логин и пароль пользователя на RDP-сервере
- Нажмите **Добавить**
- Повторите для каждого параллельного пользователя (слота)
### Шаг 3: Запустите контейнеры слотов
```bash
# Перезапустите API с флагом инициализации
cd ~/docker/stand
ENABLE_STARTUP_MAINTENANCE=1 docker compose up -d api maintenance
```
После запуска появятся контейнеры вида `portal-rdpslot-SLUG-N`.
Проверка:
```bash
docker ps | grep rdpslot
```
### Назначение прав на RDP-сервис
В разделе ACL назначьте нужным пользователям доступ к RDP-сервису (обычный чекбокс).
Любой пользователь с доступом может занять любой свободный слот.
---
## 14. Обновление проекта
```bash
cd ~/docker/stand
# Получаем изменения
git pull
# Пересобираем API (если менялся main.py или шаблоны)
docker compose up -d --build api maintenance
# Пересобираем рантаймы (если менялись rdp-proxy/, universal-runtime/, kiosk/)
docker build -t portal-rdp-proxy:latest ./rdp-proxy/
docker build -t portal-universal-runtime:latest ./universal-runtime/
docker build -t portal-kiosk:latest ./kiosk/
# После пересборки рантаймов — перезапустить слот-контейнеры
# Старые запущенные сессии продолжат работать, новые получат новый образ
docker compose down api maintenance
ENABLE_STARTUP_MAINTENANCE=1 docker compose up -d api maintenance
```
> **Важно**: `docker compose up -d api` **без** `--build` НЕ обновит код, если контейнер уже запущен.
> Всегда используйте `--build` после изменений в `app/`.
---
## 15. Описание всех переменных окружения
| Переменная | По умолчанию | Описание |
|------------|-------------|----------|
| `COMPOSE_PROJECT_NAME` | `stend_mont` | Префикс имён контейнеров Docker |
| `PUBLIC_HOST` | — | **Обязательно.** Домен сайта, напр. `stand.example.com` |
| `LETSENCRYPT_EMAIL` | — | Email для Let's Encrypt |
| `POSTGRES_DB` | `portal` | Имя базы данных |
| `POSTGRES_USER` | `portal` | Пользователь PostgreSQL |
| `POSTGRES_PASSWORD` | — | **Обязательно.** Пароль PostgreSQL |
| `SIGNING_KEY` | — | **Обязательно.** Секрет для подписи сессионных токенов (мин. 32 символа) |
| `ADMIN_USERNAME` | `admin` | Логин администратора |
| `ADMIN_PASSWORD` | — | **Обязательно.** Пароль администратора |
| `ADMIN_TTL_DAYS` | `3650` | Срок действия аккаунта admin (дни) |
| `SESSION_IDLE_SECONDS` | `7200` | Тайм-аут сессии по бездействию (секунды). Рекомендуется `300` (5 мин) |
| `UVICORN_WORKERS` | `6` | Количество воркеров uvicorn |
| `WEB_POOL_SIZE` | `20` | Максимальное число WEB-контейнеров в пуле |
| `WEB_POOL_BUFFER` | `2` | Сколько прогретых WEB-контейнеров держать в запасе |
| `PREWARM_POOL_SIZE` | `2` | Размер пула прогрева для VNC |
| `UNIVERSAL_POOL_SIZE` | `0` | Размер универсального пула |
| `MAX_ACTIVE_SERVICES_PER_USER` | `4` | Максимум одновременных сессий на одного пользователя |
| `ENABLE_STARTUP_MAINTENANCE` | `0` | `1` = при старте запустить/переинициализировать все пул-контейнеры |
| `TRAEFIK_INTERNAL_URL` | `http://traefik` | URL Traefik изнутри Docker-сети (не менять без нужды) |
| `LOG_LEVEL` | `INFO` | Уровень логирования: `DEBUG`, `INFO`, `WARNING`, `ERROR` |
| `LOG_SLOW_REQUEST_MS` | `2000` | Запросы дольше этого (мс) логируются как медленные |
| `GO_USER_LOCK_TIMEOUT_SECONDS` | `8` | Тайм-аут блокировки при запуске сессии пользователем |
| `GO_POOL_LOCK_TIMEOUT_SECONDS` | `20` | Тайм-аут блокировки при захвате слота пула |
| `POOL_DISPATCH_RETRIES` | `6` | Число попыток занять слот пула |
| `POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS` | `2.0` | Тайм-аут одного запроса к пул-контейнеру |
| `POOL_DISPATCH_SLEEP_SECONDS` | `0.3` | Пауза между попытками диспетчеризации |
| `X11VNC_FLAGS` | `-wait 5 -defer 5 -threads` | Дополнительные флаги x11vnc |
| `WEB_RESOLUTION_MIN_WIDTH` | `1024` | Минимальная ширина разрешения для WEB-сессий |
| `WEB_RESOLUTION_MIN_HEIGHT` | `720` | Минимальная высота |
| `WEB_RESOLUTION_MAX_WIDTH` | `3840` | Максимальная ширина |
| `WEB_RESOLUTION_MAX_HEIGHT` | `2160` | Максимальная высота |
---
## 16. Частые проблемы и решения
### Сертификат не получается (Let's Encrypt)
**Симптом:** Браузер показывает ошибку TLS, в логах Traefik: `acme: error`.
**Причины и решения:**
1. DNS не указывает на сервер — проверьте A-запись: `nslookup your-domain.example.com`
2. Порт 80 или 443 закрыт фаерволом — откройте: `sudo ufw allow 80 && sudo ufw allow 443`
3. Файл `acme.json` не имеет прав 600: `chmod 600 traefik/letsencrypt/acme.json`
4. Слишком много запросов к LE — подождите час и попробуйте снова
---
### API не стартует (ошибка подключения к БД)
**Симптом:** В логах `api`: `connection refused` или `could not connect to server`.
```bash
# Проверьте что база запущена
docker compose ps db
docker compose logs db
# Убедитесь что переменные совпадают в .env
grep POSTGRES .env
```
Если база не успела подняться — подождите 5–10 секунд и перезапустите API:
```bash
docker compose restart api
```
---
### RDP-сессия не подключается (чёрный экран)
**Симптом:** noVNC открывается, но экран чёрный или появляется ошибка.
```bash
# Найдите имя контейнера слота
docker ps | grep rdpslot
# Смотрите логи внутри контейнера
docker exec portal-rdpslot-SLUG-N cat /tmp/xfreerdp.log
docker exec portal-rdpslot-SLUG-N cat /tmp/xvfb.log
docker exec portal-rdpslot-SLUG-N cat /tmp/x11vnc.log
```
Типичные ошибки:
| Ошибка | Причина | Решение |
|--------|---------|---------|
| `Server is already active for display :1` | Старый lock-файл Xvfb | Обновите `rdp-proxy/entrypoint.sh` (уже исправлено в v0.6.0) |
| `Authentication failure` | Неверный RDP логин/пароль | Проверьте слот в админке |
| `failed to open display :1` | Xvfb не запустился | Перезапустите контейнер слота |
| `Connection refused` к `:3389` | RDP-сервер недоступен | Проверьте `host:port` сервиса |
Перезапуск конкретного слота:
```bash
docker restart portal-rdpslot-SLUG-N
```
---
### Слот показывается свободным, но пользователь всё ещё в сессии
**Симптом:** В админке слот «Свободен», но пользователь зашёл в /view.
Это возникает если сессия осталась в статусе `ACTIVE` без обновления `last_access_at`.
Принудительно истечь сессию через PostgreSQL:
```bash
docker exec -it stend_mont-db-1 psql -U portal -d portal -c \
"UPDATE sessions SET status='EXPIRED' WHERE status='ACTIVE' AND service_id=<ID>;"
```
---
### Контейнеры пула не создаются при старте
**Симптом:** После запуска нет контейнеров `portal-rdpslot-*` или `portal-webpool-*`.
Убедитесь что стартовали с флагом:
```bash
ENABLE_STARTUP_MAINTENANCE=1 docker compose up -d api maintenance
```
Или добавьте в `.env`:
```dotenv
ENABLE_STARTUP_MAINTENANCE=1
```
и перезапустите:
```bash
docker compose up -d api maintenance
```
---
### Полная переустановка (сброс данных)
> **Внимание**: удалятся все пользователи, сессии, сервисы!
```bash
cd ~/docker/stand
docker compose down -v # -v удаляет volume с данными PostgreSQL
docker compose up -d --build
```
---
## Итоговый чеклист первого развёртывания
- [ ] Сервер с Ubuntu 22.04+ и публичным IP
- [ ] DNS A-запись `ваш-домен → IP сервера`
- [ ] Установлен Docker 24+ и Docker Compose v2
- [ ] Репозиторий склонирован
- [ ] Заполнен `.env` (PUBLIC_HOST, POSTGRES_PASSWORD, SIGNING_KEY, ADMIN_PASSWORD)
- [ ] Создан `traefik/letsencrypt/acme.json` с правами 600
- [ ] Собраны образы рантаймов (`portal-rdp-proxy`, `portal-universal-runtime`, `portal-kiosk`)
- [ ] Выполнен `docker compose up -d --build`
- [ ] Открывается `https://ваш-домен/admin` → вход в систему
- [ ] Созданы пользователи и сервисы
- [ ] Для RDP: добавлены слоты, перезапущен API с `ENABLE_STARTUP_MAINTENANCE=1`
+478 -87
View File
@@ -16,6 +16,7 @@ from typing import Optional
import docker import docker
import requests import requests
import mistune
from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, Request, UploadFile, status from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, Request, UploadFile, status
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@@ -43,20 +44,21 @@ DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+psycopg2://portal:portal@db
COOKIE_NAME = "portal_auth" COOKIE_NAME = "portal_auth"
CSRF_COOKIE = "csrf_token" CSRF_COOKIE = "csrf_token"
COOKIE_MAX_AGE = 8 * 60 * 60 COOKIE_MAX_AGE = 8 * 60 * 60
SESSION_IDLE_SECONDS = int(os.getenv("SESSION_IDLE_SECONDS", "300")) SESSION_IDLE_SECONDS = int(os.getenv("SESSION_IDLE_SECONDS", "7200"))
PUBLIC_HOST = os.getenv("PUBLIC_HOST", "stend.4mont.ru") PUBLIC_HOST = os.getenv("PUBLIC_HOST", "stend.4mont.ru")
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
LOG_SLOW_REQUEST_MS = int(os.getenv("LOG_SLOW_REQUEST_MS", "2000")) LOG_SLOW_REQUEST_MS = int(os.getenv("LOG_SLOW_REQUEST_MS", "2000"))
GO_USER_LOCK_TIMEOUT_SECONDS = float(os.getenv("GO_USER_LOCK_TIMEOUT_SECONDS", "8.0")) GO_USER_LOCK_TIMEOUT_SECONDS = float(os.getenv("GO_USER_LOCK_TIMEOUT_SECONDS", "8.0"))
GO_POOL_LOCK_TIMEOUT_SECONDS = float(os.getenv("GO_POOL_LOCK_TIMEOUT_SECONDS", "5.0")) GO_POOL_LOCK_TIMEOUT_SECONDS = float(os.getenv("GO_POOL_LOCK_TIMEOUT_SECONDS", "20.0"))
POOL_DISPATCH_RETRIES = int(os.getenv("POOL_DISPATCH_RETRIES", "4")) POOL_DISPATCH_RETRIES = int(os.getenv("POOL_DISPATCH_RETRIES", "6"))
POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS = float(os.getenv("POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS", "2.0")) POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS = float(os.getenv("POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS", "2.0"))
POOL_DISPATCH_SLEEP_SECONDS = float(os.getenv("POOL_DISPATCH_SLEEP_SECONDS", "0.3")) POOL_DISPATCH_SLEEP_SECONDS = float(os.getenv("POOL_DISPATCH_SLEEP_SECONDS", "0.3"))
TRAEFIK_INTERNAL_URL = os.getenv("TRAEFIK_INTERNAL_URL", "http://traefik") TRAEFIK_INTERNAL_URL = os.getenv("TRAEFIK_INTERNAL_URL", "http://traefik")
PREWARM_POOL_SIZE = int(os.getenv("PREWARM_POOL_SIZE", "0")) PREWARM_POOL_SIZE = int(os.getenv("PREWARM_POOL_SIZE", "2"))
UNIVERSAL_POOL_SIZE = int(os.getenv("UNIVERSAL_POOL_SIZE", "0")) UNIVERSAL_POOL_SIZE = int(os.getenv("UNIVERSAL_POOL_SIZE", "0"))
WEB_POOL_SIZE = int(os.getenv("WEB_POOL_SIZE", "5")) WEB_POOL_SIZE = int(os.getenv("WEB_POOL_SIZE", "20"))
WEB_POOL_BUFFER = int(os.getenv("WEB_POOL_BUFFER", "2")) WEB_POOL_BUFFER = int(os.getenv("WEB_POOL_BUFFER", "2"))
X11VNC_FLAGS = os.getenv("X11VNC_FLAGS", "-wait 5 -defer 5 -threads")
MAX_ACTIVE_SERVICES_PER_USER = int(os.getenv("MAX_ACTIVE_SERVICES_PER_USER", "4")) MAX_ACTIVE_SERVICES_PER_USER = int(os.getenv("MAX_ACTIVE_SERVICES_PER_USER", "4"))
WEB_RESOLUTION_MIN_WIDTH = int(os.getenv("WEB_RESOLUTION_MIN_WIDTH", "1024")) WEB_RESOLUTION_MIN_WIDTH = int(os.getenv("WEB_RESOLUTION_MIN_WIDTH", "1024"))
WEB_RESOLUTION_MIN_HEIGHT = int(os.getenv("WEB_RESOLUTION_MIN_HEIGHT", "720")) WEB_RESOLUTION_MIN_HEIGHT = int(os.getenv("WEB_RESOLUTION_MIN_HEIGHT", "720"))
@@ -157,6 +159,52 @@ async def request_logging_middleware(request: Request, call_next):
return response return response
import re as _re_mob
_MOBILE_UA_RE = _re_mob.compile(
r"(Mobile|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|webOS)",
_re_mob.IGNORECASE,
)
_MOBILE_PAGE = (
"<!doctype html>"
'<html lang="ru">'
"<head>"
'<meta charset="utf-8"/>'
'<meta name="viewport" content="width=device-width,initial-scale=1"/>'
"<title>МОНТ - инфрастуктурный полигон</title>"
"<style>"
"*{box-sizing:border-box;margin:0;padding:0}"
"body{min-height:100dvh;display:flex;flex-direction:column;align-items:center;"
"justify-content:center;background:linear-gradient(160deg,#0a2a4a 0%,#1565a0 60%,#1e88c8 100%);"
"font-family:sans-serif;color:#fff;padding:2rem 1.5rem;text-align:center}"
".logo{width:120px;margin-bottom:2rem}"
"h1{font-size:1.3rem;font-weight:700;margin-bottom:1rem;line-height:1.35}"
"p{font-size:0.95rem;color:rgba(255,255,255,.75);line-height:1.5;max-width:280px}"
".icon{font-size:3.5rem;margin-bottom:1.2rem}"
"</style>"
"</head>"
"<body>"
'<img class="logo" src="/static/logo.png" alt="MONT"/>'
'<div class="icon">&#128421;</div>'
"<h1>Ресурс доступен<br>только с ПК</h1>"
"<p>Пожалуйста, откройте эту страницу на компьютере или ноутбуке.</p>"
"</body>"
"</html>"
)
@app.middleware("http")
async def mobile_block_middleware(request: Request, call_next):
path = request.url.path
if path.startswith("/static/"):
return await call_next(request)
ua = request.headers.get("user-agent", "")
if _MOBILE_UA_RE.search(ua):
from starlette.responses import HTMLResponse as _HR
return _HR(content=_MOBILE_PAGE, status_code=200)
return await call_next(request)
class Base(DeclarativeBase): class Base(DeclarativeBase):
pass pass
@@ -195,6 +243,9 @@ class Service(Base):
type: Mapped[ServiceType] = mapped_column(Enum(ServiceType), index=True) type: Mapped[ServiceType] = mapped_column(Enum(ServiceType), index=True)
target: Mapped[str] = mapped_column(Text) target: Mapped[str] = mapped_column(Text)
comment: Mapped[str] = mapped_column(Text, default="") comment: Mapped[str] = mapped_column(Text, default="")
svc_login: Mapped[str] = mapped_column(String(256), default="")
svc_password: Mapped[str] = mapped_column(String(256), default="")
svc_cred_hint: Mapped[str] = mapped_column(Text, default="")
icon_path: Mapped[str] = mapped_column(Text, default="") icon_path: Mapped[str] = mapped_column(Text, default="")
active: Mapped[bool] = mapped_column(Boolean, default=True) active: Mapped[bool] = mapped_column(Boolean, default=True)
warm_pool_size: Mapped[int] = mapped_column(Integer, default=0) warm_pool_size: Mapped[int] = mapped_column(Integer, default=0)
@@ -230,6 +281,17 @@ class UserServiceAccess(Base):
granted_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc)) granted_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
class RdpSlot(Base):
__tablename__ = "rdp_slots"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
service_id: Mapped[int] = mapped_column(ForeignKey("services.id", ondelete="CASCADE"), index=True)
rdp_username: Mapped[str] = mapped_column(String(128))
rdp_password: Mapped[str] = mapped_column(String(256), default="")
container_name: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
class SessionModel(Base): class SessionModel(Base):
__tablename__ = "sessions" __tablename__ = "sessions"
@@ -287,34 +349,16 @@ def normalize_web_target(url: str) -> str:
return f"http://{raw}" return f"http://{raw}"
_md = mistune.create_markdown(
escape=True,
plugins=["strikethrough", "table", "task_lists"],
)
def format_service_comment(raw_text: str) -> Markup: def format_service_comment(raw_text: str) -> Markup:
raw = (raw_text or "").replace("\r\n", "\n").replace("\r", "\n").strip() raw = (raw_text or "").replace("\r\n", "\n").replace("\r", "\n").strip()
if not raw: if not raw:
return Markup("") return Markup("")
escaped = str(escape(raw)) return Markup(_md(raw))
# Support pasted/plain markdown-like bold fragments.
escaped = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", escaped, flags=re.DOTALL)
# Allow a small safe subset of pasted HTML tags.
replacements = {
"&lt;b&gt;": "<b>",
"&lt;/b&gt;": "</b>",
"&lt;strong&gt;": "<strong>",
"&lt;/strong&gt;": "</strong>",
"&lt;i&gt;": "<i>",
"&lt;/i&gt;": "</i>",
"&lt;em&gt;": "<em>",
"&lt;/em&gt;": "</em>",
"&lt;u&gt;": "<u>",
"&lt;/u&gt;": "</u>",
"&lt;br&gt;": "<br>",
"&lt;br/&gt;": "<br>",
"&lt;br /&gt;": "<br>",
}
for src, dst in replacements.items():
escaped = escaped.replace(src, dst)
escaped = escaped.replace("\n", "<br>")
return Markup(escaped)
def parse_rdp_target(target: str) -> dict: def parse_rdp_target(target: str) -> dict:
raw = (target or "").strip() raw = (target or "").strip()
@@ -463,7 +507,7 @@ def issue_csrf_cookie(response: RedirectResponse) -> str:
value=token, value=token,
httponly=False, httponly=False,
secure=True, secure=True,
samesite="strict", samesite="lax",
max_age=COOKIE_MAX_AGE, max_age=COOKIE_MAX_AGE,
path="/", path="/",
) )
@@ -573,6 +617,7 @@ def ensure_universal_pool() -> None:
"IDLE_TIMEOUT": str(SESSION_IDLE_SECONDS), "IDLE_TIMEOUT": str(SESSION_IDLE_SECONDS),
"ENABLE_HEARTBEAT": "0", "ENABLE_HEARTBEAT": "0",
"SESSION_ID": f"universal-{i}", "SESSION_ID": f"universal-{i}",
"X11VNC_FLAGS": X11VNC_FLAGS,
} }
try: try:
c = d.containers.get(name) c = d.containers.get(name)
@@ -634,6 +679,7 @@ def ensure_web_pool(target_size: Optional[int] = None) -> None:
"IDLE_TIMEOUT": str(SESSION_IDLE_SECONDS), "IDLE_TIMEOUT": str(SESSION_IDLE_SECONDS),
"ENABLE_HEARTBEAT": "0", "ENABLE_HEARTBEAT": "0",
"SESSION_ID": f"webpool-{i}", "SESSION_ID": f"webpool-{i}",
"X11VNC_FLAGS": X11VNC_FLAGS,
} }
should_create = False should_create = False
try: try:
@@ -794,7 +840,7 @@ def dispatch_universal_target(slot: int, service: Service, width: Optional[int]
payload = {} payload = {}
if service.type == ServiceType.WEB: if service.type == ServiceType.WEB:
url = f"http://{name}:7000/open" url = f"http://{name}:7000/open"
payload = {"url": normalize_web_target(service.target)} payload = {"url": normalize_web_target(service.target), "login": service.svc_login or "", "password": service.svc_password or ""}
width, height = sanitize_client_resolution(width, height) width, height = sanitize_client_resolution(width, height)
if width and height: if width and height:
payload["width"] = width payload["width"] = width
@@ -830,7 +876,7 @@ def dispatch_web_pool_target(slot: int, service: Service, width: Optional[int] =
name = web_pool_container_name(slot) name = web_pool_container_name(slot)
target_url = normalize_web_target(service.target) target_url = normalize_web_target(service.target)
url = f"http://{name}:7000/open" url = f"http://{name}:7000/open"
payload = {"url": target_url} payload = {"url": target_url, "login": service.svc_login or "", "password": service.svc_password or ""}
width, height = sanitize_client_resolution(width, height) width, height = sanitize_client_resolution(width, height)
if width and height: if width and height:
payload["width"] = width payload["width"] = width
@@ -869,6 +915,7 @@ def create_runtime_container(service: Service, session_id: str):
"IDLE_TIMEOUT": str(SESSION_IDLE_SECONDS), "IDLE_TIMEOUT": str(SESSION_IDLE_SECONDS),
"ENABLE_HEARTBEAT": "1", "ENABLE_HEARTBEAT": "1",
"TOUCH_PATH": f"/api/sessions/{session_id}/touch", "TOUCH_PATH": f"/api/sessions/{session_id}/touch",
"X11VNC_FLAGS": X11VNC_FLAGS,
} }
image = "portal-kiosk:latest" image = "portal-kiosk:latest"
@@ -929,6 +976,7 @@ def ensure_warm_pool(service: Service, pool_size: Optional[int] = None) -> None:
"IDLE_TIMEOUT": str(SESSION_IDLE_SECONDS), "IDLE_TIMEOUT": str(SESSION_IDLE_SECONDS),
"ENABLE_HEARTBEAT": "0", "ENABLE_HEARTBEAT": "0",
"TOUCH_PATH": "", "TOUCH_PATH": "",
"X11VNC_FLAGS": X11VNC_FLAGS,
} }
if service.type == ServiceType.WEB: if service.type == ServiceType.WEB:
base_env["UNIVERSAL_WEB"] = "1" base_env["UNIVERSAL_WEB"] = "1"
@@ -1053,6 +1101,20 @@ def container_running(container_id: Optional[str]) -> bool:
or container_id.startswith("WEBPOOLIDX:") or container_id.startswith("WEBPOOLIDX:")
): ):
return True return True
if container_id.startswith("RDPSLOT:"):
try:
slot_id = int(container_id.split(":", 1)[1])
db = SessionLocal()
try:
slot = db.get(RdpSlot, slot_id)
if not slot or not slot.container_name:
return False
c = docker_client().containers.get(slot.container_name)
return c.status == "running"
finally:
db.close()
except Exception:
return False
try: try:
c = docker_client().containers.get(container_id) c = docker_client().containers.get(container_id)
return c.status == "running" return c.status == "running"
@@ -1060,6 +1122,107 @@ def container_running(container_id: Optional[str]) -> bool:
return False return False
def _rdp_slot_container_name(service_slug: str, slot_id: int) -> str:
return f"portal-rdpslot-{service_slug}-{slot_id}"
def start_rdp_slot_container(slot: RdpSlot, service: Service) -> str:
d = docker_client()
name = _rdp_slot_container_name(service.slug, slot.id)
slot_id = slot.id
path = f"/rdp/{slot_id}/"
router = f"rdpslot-{slot_id}"
labels = {
"traefik.enable": "true",
"traefik.docker.network": "portal_net",
f"traefik.http.routers.{router}.rule": f"PathPrefix(`{path}`)",
f"traefik.http.routers.{router}.entrypoints": "websecure",
f"traefik.http.routers.{router}.tls": "true",
f"traefik.http.routers.{router}.priority": "10000",
f"traefik.http.routers.{router}.middlewares": f"{router}-strip",
f"traefik.http.middlewares.{router}-strip.stripprefix.prefixes": path[:-1],
f"traefik.http.services.{router}.loadbalancer.server.port": "6080",
"portal.rdpslot": "1",
"portal.rdpslot.id": str(slot_id),
"portal.service.slug": service.slug,
}
cfg = parse_rdp_target(service.target)
env = {
"SESSION_ID": f"rdpslot-{slot_id}",
"IDLE_TIMEOUT": "86400",
"ENABLE_HEARTBEAT": "0",
"RDP_HOST": cfg["host"],
"RDP_PORT": cfg["port"],
"X11VNC_FLAGS": X11VNC_FLAGS,
}
if slot.rdp_username:
env["RDP_USER"] = slot.rdp_username
if slot.rdp_password:
env["RDP_PASSWORD"] = slot.rdp_password
if cfg.get("domain"):
env["RDP_DOMAIN"] = cfg["domain"]
if cfg.get("security"):
env["RDP_SECURITY"] = cfg["security"]
try:
existing = d.containers.get(name)
existing.stop(timeout=5)
existing.remove(force=True)
except docker.errors.NotFound:
pass
except Exception:
logger.exception("rdp_slot_container_cleanup_failed slot_id=%s", slot_id)
container = d.containers.run(
"portal-rdp-proxy:latest",
name=name,
detach=True,
restart_policy={"Name": "unless-stopped"},
network="portal_net",
labels=labels,
environment=env,
)
logger.info("rdp_slot_container_started slot_id=%s name=%s", slot_id, name)
return container.name
def stop_rdp_slot_container(container_name: str) -> None:
if not container_name:
return
try:
d = docker_client()
c = d.containers.get(container_name)
c.stop(timeout=5)
c.remove(force=True)
logger.info("rdp_slot_container_stopped name=%s", container_name)
except docker.errors.NotFound:
pass
except Exception:
logger.exception("rdp_slot_container_stop_failed container=%s", container_name)
def _restart_rdp_slot_bg(slot_id: int) -> None:
db = SessionLocal()
try:
slot = db.get(RdpSlot, slot_id)
if not slot or not slot.container_name:
return
service = db.get(Service, slot.service_id)
if not service:
return
try:
d = docker_client()
c = d.containers.get(slot.container_name)
c.restart(timeout=10)
logger.info("rdp_slot_container_restarted slot_id=%s", slot_id)
except docker.errors.NotFound:
start_rdp_slot_container(slot, service)
except Exception:
logger.exception("rdp_slot_container_restart_failed slot_id=%s", slot_id)
finally:
db.close()
def stop_runtime_container(container_id: Optional[str]) -> None: def stop_runtime_container(container_id: Optional[str]) -> None:
if not container_id: if not container_id:
return return
@@ -1082,8 +1245,14 @@ def terminate_session_record(
return return
old_status = sess.status old_status = sess.status
cid = sess.container_id or "" cid = sess.container_id or ""
if stop_container and cid and not cid.startswith(("POOL:", "POOLIDX:", "WEBPOOLIDX:")): if stop_container and cid and not cid.startswith(("POOL:", "POOLIDX:", "WEBPOOLIDX:", "RDPSLOT:")):
stop_runtime_container(cid) stop_runtime_container(cid)
if cid.startswith("RDPSLOT:"):
try:
slot_id = int(cid.split(":", 1)[1])
threading.Thread(target=_restart_rdp_slot_bg, args=(slot_id,), daemon=True).start()
except Exception:
logger.exception("rdp_slot_restart_schedule_failed cid=%s", cid)
sess.status = new_status sess.status = new_status
sess.last_access_at = now_utc() sess.last_access_at = now_utc()
log_event( log_event(
@@ -1135,6 +1304,9 @@ def ensure_schema_compatibility() -> None:
with engine.begin() as conn: with engine.begin() as conn:
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS warm_pool_size INTEGER NOT NULL DEFAULT 0")) conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS warm_pool_size INTEGER NOT NULL DEFAULT 0"))
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS comment TEXT NOT NULL DEFAULT ''")) conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS comment TEXT NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS svc_login VARCHAR(256) NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS svc_password VARCHAR(256) NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS svc_cred_hint TEXT NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS icon_path TEXT NOT NULL DEFAULT ''")) conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS icon_path TEXT NOT NULL DEFAULT ''"))
conn.execute( conn.execute(
text( text(
@@ -1439,7 +1611,7 @@ def terminate_active_slot_sessions(db: Session, container_id: str) -> None:
def session_redirect_url(sess: SessionModel) -> str: def session_redirect_url(sess: SessionModel) -> str:
cid = sess.container_id or "" cid = sess.container_id or ""
if cid.startswith("POOL:") or cid.startswith("POOLIDX:") or cid.startswith("WEBPOOLIDX:"): if cid.startswith("POOL:") or cid.startswith("POOLIDX:") or cid.startswith("WEBPOOLIDX:") or cid.startswith("RDPSLOT:"):
return f"/s/{sess.id}/view" return f"/s/{sess.id}/view"
return f"/s/{sess.id}/" return f"/s/{sess.id}/"
@@ -1497,16 +1669,25 @@ def cleanup_loop():
SessionModel.last_access_at < cutoff, SessionModel.last_access_at < cutoff,
) )
stale = db.scalars(q).all() stale = db.scalars(q).all()
rdp_slots_to_restart: list[int] = []
for sess in stale: for sess in stale:
if sess.container_id and not ( cid = sess.container_id or ""
sess.container_id.startswith("POOL:") if cid.startswith("RDPSLOT:"):
or sess.container_id.startswith("POOLIDX:") try:
or sess.container_id.startswith("WEBPOOLIDX:") rdp_slots_to_restart.append(int(cid.split(":", 1)[1]))
except Exception:
pass
elif cid and not (
cid.startswith("POOL:")
or cid.startswith("POOLIDX:")
or cid.startswith("WEBPOOLIDX:")
): ):
stop_runtime_container(sess.container_id) stop_runtime_container(cid)
sess.status = SessionStatus.EXPIRED sess.status = SessionStatus.EXPIRED
if stale: if stale:
db.commit() db.commit()
for slot_id in rdp_slots_to_restart:
threading.Thread(target=_restart_rdp_slot_bg, args=(slot_id,), daemon=True).start()
except Exception: except Exception:
db.rollback() db.rollback()
logger.exception("cleanup_loop_failed") logger.exception("cleanup_loop_failed")
@@ -1516,7 +1697,7 @@ def cleanup_loop():
def bootstrap_admin(): def bootstrap_admin():
admin_user = os.getenv("ADMIN_USERNAME", "admin") admin_user = os.getenv("ADMIN_USERNAME", "admin")
admin_password = os.getenv("ADMIN_PASSWORD", "admin123") admin_password = os.getenv("ADMIN_PASSWORD", "change_me")
ttl_days = int(os.getenv("ADMIN_TTL_DAYS", "3650")) ttl_days = int(os.getenv("ADMIN_TTL_DAYS", "3650"))
db = SessionLocal() db = SessionLocal()
@@ -1596,27 +1777,41 @@ def startup_event():
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
ensure_icons_dir() ensure_icons_dir()
bootstrap_admin() bootstrap_admin()
if not ENABLE_STARTUP_MAINTENANCE:
logger.info("startup_maintenance_disabled")
return
if not try_acquire_maintenance_leader(): if not try_acquire_maintenance_leader():
logger.info("maintenance_leader_skipped") logger.info("maintenance_leader_skipped")
return return
db = SessionLocal() if ENABLE_STARTUP_MAINTENANCE:
try: db = SessionLocal()
ensure_universal_pool() try:
ensure_web_pool() ensure_universal_pool()
for svc in db.scalars( ensure_web_pool()
select(Service).where( for svc in db.scalars(
Service.active == True, select(Service).where(
Service.type.in_([ServiceType.WEB, ServiceType.RDP]), Service.active == True,
) Service.type.in_([ServiceType.WEB, ServiceType.RDP]),
).all(): )
if svc.type == ServiceType.WEB and WEB_POOL_SIZE <= 0: ).all():
ensure_warm_pool(svc) if svc.type == ServiceType.WEB and WEB_POOL_SIZE <= 0:
finally: ensure_warm_pool(svc)
db.close() elif svc.type == ServiceType.RDP:
slots = db.scalars(select(RdpSlot).where(RdpSlot.service_id == svc.id)).all()
for slot in slots:
try:
cname = _rdp_slot_container_name(svc.slug, slot.id)
try:
c = docker_client().containers.get(cname)
if c.status != "running":
c.start()
except docker.errors.NotFound:
start_rdp_slot_container(slot, svc)
slot.container_name = cname
except Exception:
logger.exception("startup_rdp_slot_start_failed slot_id=%s", slot.id)
if slots:
db.commit()
finally:
db.close()
thread = threading.Thread(target=cleanup_loop, daemon=True) thread = threading.Thread(target=cleanup_loop, daemon=True)
thread.start() thread.start()
@@ -1651,7 +1846,7 @@ def index(request: Request, user: Optional[User] = Depends(get_current_user), db
"session_notice": session_notice, "session_notice": session_notice,
}, },
) )
response.set_cookie(CSRF_COOKIE, csrf, httponly=False, secure=True, samesite="strict", path="/") response.set_cookie(CSRF_COOKIE, csrf, httponly=False, secure=True, samesite="lax", path="/")
return response return response
services = db.scalars( services = db.scalars(
@@ -1796,6 +1991,36 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi
), ),
{"cutoff": cutoff}, {"cutoff": cutoff},
).mappings().all() ).mappings().all()
rdp_slots: dict[int, list] = {}
for svc in rdp_services:
slots = db.scalars(select(RdpSlot).where(RdpSlot.service_id == svc.id).order_by(RdpSlot.id)).all()
slot_list = []
for slot in slots:
active_sess = db.scalar(
select(SessionModel).where(
SessionModel.container_id == f"RDPSLOT:{slot.id}",
SessionModel.status == SessionStatus.ACTIVE,
)
)
running = False
if slot.container_name:
try:
c = docker_client().containers.get(slot.container_name)
running = c.status == "running"
except Exception:
pass
occupied_username = None
if active_sess:
u = db.get(User, active_sess.user_id)
occupied_username = u.username if u else f"id={active_sess.user_id}"
slot_list.append({
"id": slot.id,
"rdp_username": slot.rdp_username,
"container_name": slot.container_name or "",
"running": running,
"occupied_username": occupied_username,
})
rdp_slots[svc.id] = slot_list
return templates.TemplateResponse( return templates.TemplateResponse(
"admin.html", "admin.html",
{ {
@@ -1818,6 +2043,7 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi
"online_sessions": online_sessions, "online_sessions": online_sessions,
"csrf_token": request.cookies.get(CSRF_COOKIE, ""), "csrf_token": request.cookies.get(CSRF_COOKIE, ""),
"max_active_services_per_user": MAX_ACTIVE_SERVICES_PER_USER, "max_active_services_per_user": MAX_ACTIVE_SERVICES_PER_USER,
"rdp_slots": rdp_slots,
}, },
) )
@@ -1847,7 +2073,7 @@ def login(
}, },
status_code=401, status_code=401,
) )
response.set_cookie(CSRF_COOKIE, csrf, httponly=False, secure=True, samesite="strict", path="/") response.set_cookie(CSRF_COOKIE, csrf, httponly=False, secure=True, samesite="lax", path="/")
return response return response
if not user_is_valid(user): if not user_is_valid(user):
csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24) csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24)
@@ -1860,7 +2086,7 @@ def login(
}, },
status_code=403, status_code=403,
) )
response.set_cookie(CSRF_COOKIE, csrf, httponly=False, secure=True, samesite="strict", path="/") response.set_cookie(CSRF_COOKIE, csrf, httponly=False, secure=True, samesite="lax", path="/")
return response return response
response = RedirectResponse(url="/", status_code=303) response = RedirectResponse(url="/", status_code=303)
@@ -1903,7 +2129,7 @@ def go_service(
payload.update(extra) payload.update(extra)
log_event("go_service_timing", **payload) log_event("go_service_timing", **payload)
log_event("session_open_requested", user_id=user.id, service_slug=slug) log_event("session_open_requested", user_id=user.id, service_slug=slug, sw=sw, sh=sh)
service = db.scalar(select(Service).where(Service.slug == slug, Service.active == True)) service = db.scalar(select(Service).where(Service.slug == slug, Service.active == True))
if not service: if not service:
raise HTTPException(status_code=404, detail="Service not found") raise HTTPException(status_code=404, detail="Service not found")
@@ -1913,6 +2139,15 @@ def go_service(
raise HTTPException(status_code=403, detail="ACL denied") raise HTTPException(status_code=403, detail="ACL denied")
client_width, client_height = sanitize_client_resolution(sw, sh) client_width, client_height = sanitize_client_resolution(sw, sh)
log_event(
"session_open_resolution",
user_id=user.id,
service_slug=slug,
sw=sw,
sh=sh,
client_width=client_width,
client_height=client_height,
)
user_lock_started = time.perf_counter() user_lock_started = time.perf_counter()
try: try:
@@ -1957,20 +2192,59 @@ def go_service(
return RedirectResponse(url="/?launch_error=max_services", status_code=303) return RedirectResponse(url="/?launch_error=max_services", status_code=303)
if service.type == ServiceType.RDP: if service.type == ServiceType.RDP:
t_rdp_owner = time.perf_counter() t_rdp_slots = time.perf_counter()
active_owner = find_active_session_for_service(db, service.id) slots = db.scalars(select(RdpSlot).where(RdpSlot.service_id == service.id)).all()
_mark("check_rdp_owner_ms", t_rdp_owner) _mark("check_rdp_slots_ms", t_rdp_slots)
if active_owner: if slots:
if active_owner.user_id != user.id: session_id = str(uuid.uuid4())
owner = db.get(User, active_owner.user_id) try:
owner_name = owner.username if owner else f"id={active_owner.user_id}" with allocator_lock(db, 91003, timeout_seconds=GO_POOL_LOCK_TIMEOUT_SECONDS):
_emit("rdp_busy", owner=owner_name) busy_slot_ids: set[int] = set()
raise HTTPException( for row in db.scalars(
status_code=409, select(SessionModel).where(
detail=f"RDP сервис уже занят пользователем {owner_name}. Попробуйте позже.", SessionModel.status == SessionStatus.ACTIVE,
) SessionModel.service_id == service.id,
_emit("reuse_rdp_session", session_id=active_owner.id) SessionModel.container_id.like("RDPSLOT:%"),
return RedirectResponse(url=session_redirect_url(active_owner), status_code=303) )
).all():
try:
busy_slot_ids.add(int(row.container_id.split(":", 1)[1]))
except Exception:
pass
free_slot = next((s for s in slots if s.id not in busy_slot_ids), None)
if not free_slot:
_emit("rdp_all_slots_busy")
raise HTTPException(
status_code=503,
detail="Все слоты этого RDP сервиса заняты. Попробуйте позже.",
)
session_obj = SessionModel(
id=session_id,
user_id=user.id,
service_id=service.id,
container_id=f"RDPSLOT:{free_slot.id}",
status=SessionStatus.ACTIVE,
created_at=now_utc(),
last_access_at=now_utc(),
)
db.add(session_obj)
db.commit()
except LockTimeoutError:
_emit("rdp_slot_lock_timeout")
raise HTTPException(status_code=503, detail="Пул RDP занят. Повторите через несколько секунд.")
log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="rdp_slot", slot_id=free_slot.id)
audit(db, "SESSION_CREATE_RDP_SLOT", f"service={service.slug} session={session_id} slot={free_slot.id}", user_id=user.id)
_emit("session_created_rdp_slot", session_id=session_id, slot_id=free_slot.id)
return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
else:
# Legacy: no slots configured — exclusive single-session behaviour
active_owner = find_active_session_for_service(db, service.id)
if active_owner:
if active_owner.user_id != user.id:
_emit("rdp_busy_legacy")
raise HTTPException(status_code=503, detail="RDP сервис занят. Попробуйте позже.")
_emit("reuse_rdp_session", session_id=active_owner.id)
return RedirectResponse(url=session_redirect_url(active_owner), status_code=303)
session_id = str(uuid.uuid4()) session_id = str(uuid.uuid4())
if service.type == ServiceType.WEB and WEB_POOL_SIZE > 0: if service.type == ServiceType.WEB and WEB_POOL_SIZE > 0:
@@ -2191,6 +2465,8 @@ def session_wait_page(session_id: str, request: Request, user: User = Depends(re
raise HTTPException(status_code=410, detail="Session is not active") raise HTTPException(status_code=410, detail="Session is not active")
service = db.get(Service, sess.service_id) service = db.get(Service, sess.service_id)
service_title = service.name if service else "Сервис" service_title = service.name if service else "Сервис"
is_rdp = service and service.type == ServiceType.RDP
label = "Ожидайте..." if is_rdp else "Сессия запускается..."
redirect_target = session_redirect_url(sess) redirect_target = session_redirect_url(sess)
return HTMLResponse( return HTMLResponse(
content=f""" content=f"""
@@ -2200,20 +2476,28 @@ def session_wait_page(session_id: str, request: Request, user: User = Depends(re
<meta charset='utf-8'> <meta charset='utf-8'>
<title>{service_title}</title> <title>{service_title}</title>
<style> <style>
body {{ font-family: sans-serif; background: #f4f6f8; display: grid; place-items: center; height: 100vh; margin: 0; color:#1b3145; }} *{{box-sizing:border-box}}
.card {{ background: #fff; padding: 1rem 1.2rem; border-radius: 10px; box-shadow: 0 8px 20px rgba(0,0,0,.08); min-width: 340px; }} body{{font-family:sans-serif;background:#0f1720;display:grid;place-items:center;height:100vh;margin:0;color:#dce8f5}}
.title {{ font-weight: 700; margin-bottom: 0.5rem; }} .card{{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12);padding:1.6rem 2rem;border-radius:14px;
.state {{ margin-bottom: 0.6rem; }} box-shadow:0 12px 32px rgba(0,0,0,.4);min-width:320px;max-width:440px;text-align:center}}
ul {{ margin: 0; padding-left: 1.1rem; }} .spinner{{width:48px;height:48px;border:4px solid rgba(220,232,245,.15);border-top-color:#2a8cd6;
li {{ margin: 0.2rem 0; }} border-radius:50%;animation:spin .9s linear infinite;margin:0 auto 1.2rem}}
@keyframes spin{{to{{transform:rotate(360deg)}}}}
.title{{font-size:1.15rem;font-weight:700;margin-bottom:.5rem;color:#fff}}
.state{{font-size:.9rem;color:#a0b8cc;margin-bottom:.8rem;min-height:1.2em}}
ul{{margin:0;padding:0;list-style:none;font-size:.82rem;color:#7a99b0;text-align:left}}
li::before{{content:"· ";color:#2a8cd6}}
li+li{{margin-top:.2rem}}
.sid{{display:block;margin-top:1.2rem;font-size:.7rem;color:rgba(160,184,204,.4);word-break:break-all}}
</style> </style>
</head> </head>
<body> <body>
<div class="card"> <div class="card">
<div class="title">Сессия запускается</div> <div class="spinner"></div>
<div class="title">{label}</div>
<div class="state" id="state">Проверка...</div> <div class="state" id="state">Проверка...</div>
<ul id="steps"></ul> <ul id="steps"></ul>
<small>{session_id}</small> <span class="sid">{session_id}</span>
</div> </div>
<script> <script>
const sessionId = "{session_id}"; const sessionId = "{session_id}";
@@ -2266,7 +2550,37 @@ def session_view_page(session_id: str, request: Request, user: User = Depends(re
iframe_src = f"/u/{slot}/?sid={session_id}" iframe_src = f"/u/{slot}/?sid={session_id}"
except Exception: except Exception:
iframe_src = None iframe_src = None
elif sess.container_id and sess.container_id.startswith("RDPSLOT:"):
try:
slot = int(sess.container_id.split(":", 1)[1])
iframe_src = f"/rdp/{slot}/?sid={session_id}"
except Exception:
iframe_src = None
if iframe_src: if iframe_src:
creds_html = ""
if service.type != ServiceType.RDP and (service.svc_login or service.svc_password):
rows = ""
if service.svc_login:
login_esc = service.svc_login.replace('"', '&quot;').replace('<', '&lt;')
rows += f'''<div class="cr-row"><span class="cr-label">Логин</span><span class="cr-val">{login_esc}</span></div>'''
if service.svc_password:
pass_esc = service.svc_password.replace('"', '&quot;').replace('<', '&lt;')
rows += f'''<div class="cr-row"><span class="cr-label">Пароль</span><span class="cr-val cr-masked">{pass_esc}</span></div>'''
if service.svc_cred_hint:
hint_esc = service.svc_cred_hint.replace('<', '&lt;')
rows += f'''<p class="cr-hint">{hint_esc}</p>'''
creds_html = f'''
<div class="creds-panel" id="creds-panel">
<button class="creds-close" id="creds-close" title="Закрыть">✕</button>
{rows}
</div>
<script>
document.getElementById("creds-close").onclick = function() {{
document.getElementById("creds-panel").style.display = "none";
}};
</script>'''
return HTMLResponse( return HTMLResponse(
content=f""" content=f"""
<!doctype html> <!doctype html>
@@ -2276,10 +2590,30 @@ def session_view_page(session_id: str, request: Request, user: User = Depends(re
<title>{service.name}</title> <title>{service.name}</title>
<style> <style>
html,body,iframe {{ margin:0; width:100%; height:100%; border:0; background:#0f1720; }} html,body,iframe {{ margin:0; width:100%; height:100%; border:0; background:#0f1720; }}
.creds-panel{{
position:fixed;right:16px;top:16px;z-index:999;
background:linear-gradient(180deg,rgba(15,24,36,.88),rgba(9,14,22,.94));
border:1px solid rgba(255,255,255,.22);backdrop-filter:blur(6px);
box-shadow:0 10px 28px rgba(0,0,0,.4);padding:10px 12px 11px;border-radius:14px;
min-width:220px;max-width:320px;
}}
.creds-close{{
position:absolute;top:6px;right:8px;background:none;border:none;
color:rgba(255,255,255,.55);font-size:14px;cursor:pointer;line-height:1;padding:2px 4px;
}}
.creds-close:hover{{color:#fff}}
.cr-row{{display:flex;align-items:center;gap:6px;margin-bottom:5px;}}
.cr-label{{font:600 11px/1 sans-serif;text-transform:uppercase;letter-spacing:.04em;
color:rgba(180,210,240,.7);min-width:46px;flex-shrink:0;}}
.cr-val{{font:600 13px/1 monospace;color:#dce8f5;flex:1;overflow:hidden;
text-overflow:ellipsis;white-space:nowrap;}}
.cr-masked{{letter-spacing:.1em;font-size:14px;}}
.cr-hint{{margin:4px 0 0;font:400 11px/1.35 sans-serif;color:rgba(180,210,240,.65);}}
</style> </style>
</head> </head>
<body> <body>
<iframe src="{iframe_src}" allow="clipboard-read; clipboard-write"></iframe> <iframe src="{iframe_src}" allow="clipboard-read; clipboard-write"></iframe>{creds_html}
</body> </body>
</html> </html>
""".strip() """.strip()
@@ -2380,7 +2714,21 @@ def session_status(session_id: str, user: User = Depends(require_user), db: Sess
universal_pool_idx = int(sess.container_id.split(":", 1)[1]) universal_pool_idx = int(sess.container_id.split(":", 1)[1])
except Exception: except Exception:
universal_pool_idx = None universal_pool_idx = None
route_path = f"/svc/{service.slug}/" if pooled_web and service else f"/s/{session_id}/" pooled_rdp = bool(sess.container_id and sess.container_id.startswith("POOL:") and service and service.type == ServiceType.RDP)
rdp_slot_idx = None
if sess.container_id and sess.container_id.startswith("RDPSLOT:"):
try:
rdp_slot_idx = int(sess.container_id.split(":", 1)[1])
except Exception:
rdp_slot_idx = None
if pooled_web and service:
route_path = f"/svc/{service.slug}/"
elif pooled_rdp and service:
route_path = f"/svc/{service.slug}/"
elif rdp_slot_idx is not None:
route_path = f"/rdp/{rdp_slot_idx}/"
else:
route_path = f"/s/{session_id}/"
if web_pool_idx is not None: if web_pool_idx is not None:
route_path = f"/w/{web_pool_idx}/" route_path = f"/w/{web_pool_idx}/"
if universal_pool_idx is not None: if universal_pool_idx is not None:
@@ -2397,12 +2745,14 @@ def session_status(session_id: str, user: User = Depends(require_user), db: Sess
"message": "Готово, открываем..." if ready else "Запуск сессии...", "message": "Готово, открываем..." if ready else "Запуск сессии...",
"steps": steps, "steps": steps,
} }
if pooled_web: if pooled_web or pooled_rdp:
payload["redirect_url"] = f"/s/{session_id}/view" payload["redirect_url"] = f"/s/{session_id}/view"
if web_pool_idx is not None: if web_pool_idx is not None:
payload["redirect_url"] = f"/s/{session_id}/view" payload["redirect_url"] = f"/s/{session_id}/view"
if universal_pool_idx is not None: if universal_pool_idx is not None:
payload["redirect_url"] = f"/s/{session_id}/view" payload["redirect_url"] = f"/s/{session_id}/view"
if rdp_slot_idx is not None:
payload["redirect_url"] = f"/s/{session_id}/view"
return payload return payload
@@ -2423,6 +2773,9 @@ def create_service(payload: dict, request: Request, _: User = Depends(require_ad
type=service_type, type=service_type,
target=target, target=target,
comment=payload.get("comment", ""), comment=payload.get("comment", ""),
svc_login=payload.get("svc_login", ""),
svc_password=payload.get("svc_password", ""),
svc_cred_hint=payload.get("svc_cred_hint", ""),
active=payload.get("active", True), active=payload.get("active", True),
warm_pool_size=max(0, int(payload.get("warm_pool_size", 0))), warm_pool_size=max(0, int(payload.get("warm_pool_size", 0))),
) )
@@ -2487,7 +2840,7 @@ def edit_service(service_id: int, payload: dict, request: Request, _: User = Dep
service = db.get(Service, service_id) service = db.get(Service, service_id)
if not service: if not service:
raise HTTPException(status_code=404, detail="Service not found") raise HTTPException(status_code=404, detail="Service not found")
for key in ["name", "slug", "target", "active", "comment"]: for key in ["name", "slug", "target", "active", "comment", "svc_login", "svc_password", "svc_cred_hint"]:
if key in payload: if key in payload:
setattr(service, key, payload[key]) setattr(service, key, payload[key])
if "type" in payload: if "type" in payload:
@@ -2544,6 +2897,44 @@ def prewarm_now(service_id: int, request: Request, _: User = Depends(require_adm
return {"ok": True, "pool": get_pool_status_for_service(service)} return {"ok": True, "pool": get_pool_status_for_service(service)}
@app.post("/api/admin/services/{service_id}/rdp-slots")
def create_rdp_slot(service_id: int, payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
validate_csrf(request)
service = db.get(Service, service_id)
if not service or service.type != ServiceType.RDP:
raise HTTPException(status_code=404, detail="RDP service not found")
rdp_username = (payload.get("rdp_username") or "").strip()
rdp_password = (payload.get("rdp_password") or "").strip()
if not rdp_username:
raise HTTPException(status_code=400, detail="rdp_username is required")
slot = RdpSlot(service_id=service_id, rdp_username=rdp_username, rdp_password=rdp_password)
db.add(slot)
db.flush()
try:
container_name = start_rdp_slot_container(slot, service)
slot.container_name = container_name
except Exception as exc:
logger.exception("rdp_slot_container_start_failed service_id=%s", service_id)
raise HTTPException(status_code=502, detail=f"Контейнер не запустился: {exc}")
db.commit()
audit(db, "RDP_SLOT_CREATE", f"service={service.slug} slot={slot.id} user={rdp_username}", user_id=None)
return {"ok": True, "slot_id": slot.id, "container_name": slot.container_name}
@app.delete("/api/admin/rdp-slots/{slot_id}")
def delete_rdp_slot(slot_id: int, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
validate_csrf(request)
slot = db.get(RdpSlot, slot_id)
if not slot:
raise HTTPException(status_code=404, detail="Slot not found")
container_name = slot.container_name
db.delete(slot)
db.commit()
if container_name:
threading.Thread(target=stop_rdp_slot_container, args=(container_name,), daemon=True).start()
return {"ok": True}
@app.post("/api/admin/categories") @app.post("/api/admin/categories")
def create_category(payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)): def create_category(payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
validate_csrf(request) validate_csrf(request)
+1
View File
@@ -7,3 +7,4 @@ jinja2==3.1.6
passlib[argon2]==1.7.4 passlib[argon2]==1.7.4
docker==7.1.0 docker==7.1.0
itsdangerous==2.2.0 itsdangerous==2.2.0
mistune==3.1.3
+132 -4
View File
@@ -189,6 +189,11 @@ button {
gap: 0.35rem; gap: 0.35rem;
margin: 0.6rem 0; margin: 0.6rem 0;
} }
.acl-owner {
color: #e07b39;
font-size: 0.82em;
font-weight: 600;
}
.tab-row { .tab-row {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@@ -317,6 +322,8 @@ button {
} }
.service-grid { .service-grid {
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
grid-auto-rows: 672px;
align-items: stretch;
} }
.category-strip { .category-strip {
margin-top: 0.9rem; margin-top: 0.9rem;
@@ -339,10 +346,62 @@ button {
border-color: #0f5b94; border-color: #0f5b94;
color: #fff; color: #fff;
} }
.tile-wrap {
position: relative;
height: 100%;
}
.svc-credentials {
background: linear-gradient(135deg, #edf5fc 0%, #e2eff8 100%);
border: 1px solid #c7d9ea;
border-radius: 8px;
padding: 0.5rem 0.75rem 0.55rem;
margin: 0 0 0.5rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
flex-shrink: 0;
pointer-events: auto;
}
.svc-cred-row {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.svc-cred-label {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .04em;
color: #5a7d9a;
min-width: 3.8rem;
flex-shrink: 0;
}
.svc-cred-value {
font-size: 0.84rem;
font-family: monospace;
color: #1a3a52;
font-weight: 600;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.svc-cred-masked {
letter-spacing: .12em;
font-size: 0.9rem;
}
.tile-link {
position: absolute;
inset: 0;
z-index: 0;
border-radius: 12px;
}
.tile { .tile {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
text-decoration: none;
background: var(--card); background: var(--card);
color: inherit; color: inherit;
border-radius: 12px; border-radius: 12px;
@@ -350,8 +409,15 @@ button {
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.06); box-shadow: 0 8px 22px rgba(0, 0, 0, 0.06);
border: 1px solid transparent; border: 1px solid transparent;
transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease; transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease;
height: 100%;
box-sizing: border-box;
min-height: 0;
overflow: hidden;
position: relative;
z-index: 1;
pointer-events: none;
} }
.tile:hover { .tile-wrap:hover .tile {
transform: translateY(-2px); transform: translateY(-2px);
border-color: #bdd3e6; border-color: #bdd3e6;
box-shadow: 0 14px 26px rgba(15, 64, 103, 0.12); box-shadow: 0 14px 26px rgba(15, 64, 103, 0.12);
@@ -359,6 +425,7 @@ button {
.tile-icon-box { .tile-icon-box {
width: min(100%, 336px); width: min(100%, 336px);
aspect-ratio: 1 / 1; aspect-ratio: 1 / 1;
max-height: 180px;
border-radius: 16px; border-radius: 16px;
border: 1px solid #d8e3ed; border: 1px solid #d8e3ed;
background: #edf3f9; background: #edf3f9;
@@ -366,6 +433,7 @@ button {
place-items: center; place-items: center;
margin: 0 auto 0.8rem; margin: 0 auto 0.8rem;
overflow: hidden; overflow: hidden;
flex-shrink: 0;
} }
.tile-icon { .tile-icon {
width: 88%; width: 88%;
@@ -387,9 +455,18 @@ button {
margin-top: 0.45rem; margin-top: 0.45rem;
color: #4b6178; color: #4b6178;
} }
.tile-info-area {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
pointer-events: auto;
}
.tile-comment { .tile-comment {
display: block; display: block;
max-height: calc(1.35em * 15); flex: 1;
min-height: 0;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
line-height: 1.35; line-height: 1.35;
@@ -400,7 +477,8 @@ button {
font-weight: 700; font-weight: 700;
} }
.service-categories { .service-categories {
margin-top: 0.7rem; margin-top: auto;
padding-top: 0.7rem;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.35rem; gap: 0.35rem;
@@ -541,6 +619,10 @@ button {
border: 1px solid rgba(255, 255, 255, 0.45); border: 1px solid rgba(255, 255, 255, 0.45);
backdrop-filter: blur(3px); backdrop-filter: blur(3px);
} }
.dashboard-page .svc-credentials {
background: rgba(210, 232, 248, 0.55);
border-color: rgba(180, 210, 235, 0.5);
}
.made-by-wrap { .made-by-wrap {
@@ -638,6 +720,10 @@ button {
background: rgba(255, 255, 255, 0.9) !important; background: rgba(255, 255, 255, 0.9) !important;
border: 1px solid rgba(198, 218, 235, 0.9) !important; border: 1px solid rgba(198, 218, 235, 0.9) !important;
} }
.dashboard-page .svc-credentials {
background: rgba(220, 238, 252, 0.9) !important;
border-color: rgba(180, 210, 235, 0.85) !important;
}
.dashboard-page .panel { .dashboard-page .panel {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
@@ -758,6 +844,7 @@ button {
left: 16px; left: 16px;
z-index: 20; z-index: 20;
color: #e8f4ff; color: #e8f4ff;
font-size: 1.5rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.01em; letter-spacing: 0.01em;
text-shadow: 0 2px 8px rgba(9, 44, 72, 0.35); text-shadow: 0 2px 8px rgba(9, 44, 72, 0.35);
@@ -775,3 +862,44 @@ button {
color: rgba(240, 248, 255, 0.95); color: rgba(240, 248, 255, 0.95);
text-shadow: 0 2px 10px rgba(9, 44, 72, 0.45); text-shadow: 0 2px 10px rgba(9, 44, 72, 0.45);
} }
/* Markdown inside service card comments */
.tile-comment p { margin: 0 0 0.4em; }
.tile-comment p:last-child { margin-bottom: 0; }
.tile-comment ul, .tile-comment ol { margin: 0.3em 0 0.4em 1.2em; padding: 0; }
.tile-comment li { margin-bottom: 0.15em; }
.tile-comment h1,.tile-comment h2,.tile-comment h3,
.tile-comment h4,.tile-comment h5,.tile-comment h6 {
font-size: 0.85em; font-weight: 700; margin: 0.4em 0 0.2em;
}
.tile-comment code {
font-family: monospace; font-size: 0.88em;
background: rgba(0,0,0,.06); border-radius: 3px; padding: 0.1em 0.3em;
}
.tile-comment pre {
background: rgba(0,0,0,.06); border-radius: 4px;
padding: 0.4em 0.6em; overflow-x: auto; font-size: 0.82em;
}
.tile-comment pre code { background: none; padding: 0; }
.tile-comment blockquote {
border-left: 3px solid #c7d9e8; margin: 0.3em 0 0.3em 0;
padding-left: 0.6em; color: #5a7a90;
}
.tile-comment a { color: #1668a6; text-decoration: underline; }
.tile-comment table { border-collapse: collapse; font-size: 0.82em; margin: 0.3em 0; }
.tile-comment th, .tile-comment td {
border: 1px solid #c7d9e8; padding: 0.2em 0.5em;
}
.tile-comment th { background: #eaf2fb; font-weight: 700; }
.tile-comment del { text-decoration: line-through; color: #7a9aaf; }
.tile-comment input[type=checkbox] { margin-right: 0.3em; }
.svc-credentials + .tile-comment { margin-top: 0.5rem; }
.svc-cred-hint {
margin: 0.35rem 0 0;
font-size: 0.78rem;
color: #4a7090;
line-height: 1.35;
}
+111 -19
View File
@@ -70,7 +70,7 @@
<div class="list-title">ACL выбранного пользователя</div> <div class="list-title">ACL выбранного пользователя</div>
<div class="acl-grid"> <div class="acl-grid">
{% for s in services %} {% for s in services %}
<label><input type="checkbox" class="acl_service" value="{{s.id}}" /> {{s.name}} ({{s.slug}})</label> <label><input type="checkbox" class="acl_service" value="{{s.id}}" data-stype="{{s.type.value}}" /> {{s.name}} ({{s.slug}})<span class="acl-owner"></span></label>
{% endfor %} {% endfor %}
</div> </div>
<button onclick="saveAclForSelectedUser()">Save ACL</button> <button onclick="saveAclForSelectedUser()">Save ACL</button>
@@ -123,7 +123,7 @@
<input class="list-search" id="web_search" placeholder="Поиск WEB сервиса..." oninput="filterList('web_search', '#web_list .web-item')" /> <input class="list-search" id="web_search" placeholder="Поиск WEB сервиса..." oninput="filterList('web_search', '#web_list .web-item')" />
<div class="list-box" id="web_list"> <div class="list-box" id="web_list">
{% for s in web_services %} {% for s in web_services %}
<button class="list-item service-row web-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" onclick='selectWebService({{s.id}}, {{s.name|tojson}}, {{s.slug|tojson}}, {{s.target|tojson}}, {{s.comment|tojson}}, {{s.icon_path|tojson}}, {{s.active|tojson}})'> <button class="list-item service-row web-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" onclick='selectWebService({{s.id}}, {{s.name|tojson}}, {{s.slug|tojson}}, {{s.target|tojson}}, {{s.comment|tojson}}, {{s.icon_path|tojson}}, {{s.active|tojson}}, {{s.svc_login|tojson}}, {{s.svc_password|tojson}}, {{s.svc_cred_hint|tojson}})'>
<img class="service-thumb" src="{{ s.icon_path or '/static/service-placeholder.svg' }}" alt="icon" /> <img class="service-thumb" src="{{ s.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
<div> <div>
<div>{{s.name}}</div> <div>{{s.name}}</div>
@@ -158,6 +158,21 @@
<textarea id="w_comment" placeholder="Коротко: что это за сервис"></textarea> <textarea id="w_comment" placeholder="Коротко: что это за сервис"></textarea>
</label> </label>
<label class="field-col">
<span>Логин сервиса (показывается на карточке)</span>
<input id="w_svc_login" placeholder="Например: admin" />
</label>
<label class="field-col">
<span>Пароль сервиса (показывается на карточке)</span>
<input id="w_svc_password" placeholder="Пароль для входа в сервис" />
</label>
<label class="field-col">
<span>Подсказка к логину/паролю (необязательно)</span>
<input id="w_svc_cred_hint" placeholder="Например: учётная запись гостя, сбрасывается раз в месяц" />
</label>
<label class="field-col"> <label class="field-col">
<span>Статус</span> <span>Статус</span>
<select id="w_active"><option value="true">active</option><option value="false">inactive</option></select> <select id="w_active"><option value="true">active</option><option value="false">inactive</option></select>
@@ -231,6 +246,9 @@
<label class="field-col"> <label class="field-col">
<span>Описание для пользователя</span> <span>Описание для пользователя</span>
<textarea id="new_w_comment" placeholder="Коротко: что это за сервис"></textarea> <textarea id="new_w_comment" placeholder="Коротко: что это за сервис"></textarea>
<input id="new_w_svc_login" placeholder="Логин сервиса (необязательно)" />
<input id="new_w_svc_password" placeholder="Пароль сервиса (необязательно)" />
<input id="new_w_svc_cred_hint" placeholder="Подсказка к логину/паролю (необязательно)" />
</label> </label>
<label class="field-col"> <label class="field-col">
@@ -257,7 +275,7 @@
<input class="list-search" id="rdp_search" placeholder="Поиск RDP сервиса..." oninput="filterList('rdp_search', '#rdp_list .rdp-item')" /> <input class="list-search" id="rdp_search" placeholder="Поиск RDP сервиса..." oninput="filterList('rdp_search', '#rdp_list .rdp-item')" />
<div class="list-box" id="rdp_list"> <div class="list-box" id="rdp_list">
{% for s in rdp_services %} {% for s in rdp_services %}
<button class="list-item service-row rdp-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" onclick='selectRdpService({{s.id}}, {{s.name|tojson}}, {{s.slug|tojson}}, {{s.target|tojson}}, {{s.comment|tojson}}, {{s.icon_path|tojson}}, {{s.active|tojson}}, {{s.warm_pool_size}})'> <button class="list-item service-row rdp-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" onclick='selectRdpService({{s.id}}, {{s.name|tojson}}, {{s.slug|tojson}}, {{s.target|tojson}}, {{s.comment|tojson}}, {{s.icon_path|tojson}}, {{s.active|tojson}}, {{s.warm_pool_size}}, {{s.svc_login|tojson}}, {{s.svc_password|tojson}}, {{s.svc_cred_hint|tojson}})'>
<img class="service-thumb" src="{{ s.icon_path or '/static/service-placeholder.svg' }}" alt="icon" /> <img class="service-thumb" src="{{ s.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
<div> <div>
<div>{{s.name}}</div> <div>{{s.name}}</div>
@@ -279,8 +297,6 @@
<input id="r_slug" placeholder="Системный slug" /> <input id="r_slug" placeholder="Системный slug" />
<input id="r_host" placeholder="RDP host (например 192.168.1.60)" /> <input id="r_host" placeholder="RDP host (например 192.168.1.60)" />
<input id="r_port" type="number" min="1" max="65535" placeholder="RDP порт, обычно 3389" /> <input id="r_port" type="number" min="1" max="65535" placeholder="RDP порт, обычно 3389" />
<input id="r_user" placeholder="Логин (опционально)" />
<input id="r_pass" placeholder="Пароль (опционально)" type="password" />
<input id="r_domain" placeholder="Домен (опционально)" /> <input id="r_domain" placeholder="Домен (опционально)" />
<select id="r_sec"> <select id="r_sec">
<option value="">auto</option> <option value="">auto</option>
@@ -290,6 +306,9 @@
</select> </select>
<input id="r_target" placeholder="Собранный target (авто)" /> <input id="r_target" placeholder="Собранный target (авто)" />
<textarea id="r_comment" placeholder="Описание для пользователя"></textarea> <textarea id="r_comment" placeholder="Описание для пользователя"></textarea>
<input id="r_svc_login" placeholder="Логин сервиса (показывается на карточке)" />
<input id="r_svc_password" placeholder="Пароль сервиса (показывается на карточке)" />
<input id="r_svc_cred_hint" placeholder="Подсказка к логину/паролю (необязательно)" />
<input id="r_pool" type="number" min="0" placeholder="Количество заранее прогретых слотов" /> <input id="r_pool" type="number" min="0" placeholder="Количество заранее прогретых слотов" />
<select id="r_active"><option value="true">active</option><option value="false">inactive</option></select> <select id="r_active"><option value="true">active</option><option value="false">inactive</option></select>
</div> </div>
@@ -342,6 +361,20 @@
</div> </div>
</div> </div>
<div id="rdp_slots_box" style="display:none; margin-top:1rem;">
<div class="list-title">RDP пользователи (слоты пула)</div>
<div class="field-help">Каждый пользователь — отдельный контейнер. Пользователи портала берут свободный слот.</div>
<table class="admin-table" id="rdp_slots_table" style="margin-bottom:.7rem">
<thead><tr><th>Логин RDP</th><th>Контейнер</th><th>Статус</th><th>Занят</th><th></th></tr></thead>
<tbody></tbody>
</table>
<div style="display:flex;gap:.5rem;align-items:flex-end;flex-wrap:wrap;">
<input id="new_slot_user" placeholder="Логин RDP" style="max-width:160px" />
<input id="new_slot_pass" type="password" placeholder="Пароль RDP" style="max-width:160px" />
<button onclick="addRdpSlot()">+ Добавить слот</button>
</div>
</div>
<hr> <hr>
<div class="list-title">Добавить RDP</div> <div class="list-title">Добавить RDP</div>
<div class="field-help">Для большинства кейсов достаточно host + user + password.</div> <div class="field-help">Для большинства кейсов достаточно host + user + password.</div>
@@ -350,8 +383,6 @@
<input id="new_r_slug" placeholder="Системный slug" /> <input id="new_r_slug" placeholder="Системный slug" />
<input id="new_r_host" placeholder="RDP host" /> <input id="new_r_host" placeholder="RDP host" />
<input id="new_r_port" type="number" min="1" max="65535" placeholder="RDP порт (3389)" /> <input id="new_r_port" type="number" min="1" max="65535" placeholder="RDP порт (3389)" />
<input id="new_r_user" placeholder="Логин (опционально)" />
<input id="new_r_pass" placeholder="Пароль (опционально)" type="password" />
<input id="new_r_domain" placeholder="Домен (опционально)" /> <input id="new_r_domain" placeholder="Домен (опционально)" />
<select id="new_r_sec"> <select id="new_r_sec">
<option value="">auto</option> <option value="">auto</option>
@@ -361,6 +392,9 @@
</select> </select>
<input id="new_r_target" placeholder="Собранный target (авто)" /> <input id="new_r_target" placeholder="Собранный target (авто)" />
<textarea id="new_r_comment" placeholder="Описание для пользователя"></textarea> <textarea id="new_r_comment" placeholder="Описание для пользователя"></textarea>
<input id="new_r_svc_login" placeholder="Логин сервиса (необязательно)" />
<input id="new_r_svc_password" placeholder="Пароль сервиса (необязательно)" />
<input id="new_r_svc_cred_hint" placeholder="Подсказка к логину/паролю (необязательно)" />
<input id="new_r_pool" type="number" min="0" value="1" placeholder="Количество прогретых слотов" /> <input id="new_r_pool" type="number" min="0" value="1" placeholder="Количество прогретых слотов" />
<select id="new_r_active"><option value="true">active</option><option value="false">inactive</option></select> <select id="new_r_active"><option value="true">active</option><option value="false">inactive</option></select>
</div> </div>
@@ -390,7 +424,7 @@
<div>{{ c.name }}</div> <div>{{ c.name }}</div>
<small>{{ c.slug }}</small> <small>{{ c.slug }}</small>
</div> </div>
<button onclick="deleteCategory({{ c.id }}, {{ c.name|tojson }})">Delete</button> <button onclick='deleteCategory({{ c.id }}, {{ c.name|tojson }})'>Delete</button>
</div> </div>
{% else %} {% else %}
<div class="list-item">Категории пока не созданы</div> <div class="list-item">Категории пока не созданы</div>
@@ -513,6 +547,7 @@
const csrf = "{{ csrf_token }}"; const csrf = "{{ csrf_token }}";
const aclMap = {{ acl | tojson }}; const aclMap = {{ acl | tojson }};
const serviceCategoryMap = {{ service_category_map | tojson }}; const serviceCategoryMap = {{ service_category_map | tojson }};
const rdpSlotsMap = {{ rdp_slots | tojson }};
const placeholderIcon = '/static/service-placeholder.svg'; const placeholderIcon = '/static/service-placeholder.svg';
let activeTab = 'users'; let activeTab = 'users';
@@ -683,7 +718,10 @@
const userId = parseInt(document.getElementById('u_id').value || '0', 10); const userId = parseInt(document.getElementById('u_id').value || '0', 10);
const allowed = new Set((aclMap[userId] || [])); const allowed = new Set((aclMap[userId] || []));
document.querySelectorAll('.acl_service').forEach((box) => { document.querySelectorAll('.acl_service').forEach((box) => {
box.checked = allowed.has(parseInt(box.value, 10)); const sid = parseInt(box.value, 10);
box.checked = allowed.has(sid);
box.disabled = false;
box.closest('label').style.opacity = '';
}); });
} }
@@ -695,12 +733,15 @@
location.reload(); location.reload();
} }
function selectWebService(id, name, slug, target, comment, iconPath, active) { function selectWebService(id, name, slug, target, comment, iconPath, active, svcLogin, svcPassword, svcCredHint) {
document.getElementById('w_id').value = id; document.getElementById('w_id').value = id;
document.getElementById('w_name').value = name; document.getElementById('w_name').value = name;
document.getElementById('w_slug').value = slug; document.getElementById('w_slug').value = slug;
document.getElementById('w_target').value = target; document.getElementById('w_target').value = target;
document.getElementById('w_comment').value = comment || ''; document.getElementById('w_comment').value = comment || '';
document.getElementById('w_svc_login').value = svcLogin || '';
document.getElementById('w_svc_password').value = svcPassword || '';
document.getElementById('w_svc_cred_hint').value = svcCredHint || '';
document.getElementById('w_active').value = String(active); document.getElementById('w_active').value = String(active);
document.getElementById('w_icon_preview').src = iconPath || placeholderIcon; document.getElementById('w_icon_preview').src = iconPath || placeholderIcon;
setCategoryChecks('.w_cat', serviceCategoryMap[id] || []); setCategoryChecks('.w_cat', serviceCategoryMap[id] || []);
@@ -723,6 +764,9 @@
type: 'WEB', type: 'WEB',
target: document.getElementById('new_w_target').value, target: document.getElementById('new_w_target').value,
comment: document.getElementById('new_w_comment').value, comment: document.getElementById('new_w_comment').value,
svc_login: document.getElementById('new_w_svc_login').value,
svc_password: document.getElementById('new_w_svc_password').value,
svc_cred_hint: document.getElementById('new_w_svc_cred_hint').value,
category_ids: checkedCategoryIds('.new_w_cat'), category_ids: checkedCategoryIds('.new_w_cat'),
active: document.getElementById('new_w_active').value === 'true', active: document.getElementById('new_w_active').value === 'true',
}); });
@@ -739,6 +783,9 @@
type: 'WEB', type: 'WEB',
target: document.getElementById('w_target').value, target: document.getElementById('w_target').value,
comment: document.getElementById('w_comment').value, comment: document.getElementById('w_comment').value,
svc_login: document.getElementById('w_svc_login').value,
svc_password: document.getElementById('w_svc_password').value,
svc_cred_hint: document.getElementById('w_svc_cred_hint').value,
category_ids: checkedCategoryIds('.w_cat'), category_ids: checkedCategoryIds('.w_cat'),
active: document.getElementById('w_active').value === 'true', active: document.getElementById('w_active').value === 'true',
}); });
@@ -746,7 +793,7 @@
} }
function clearWebForm() { function clearWebForm() {
['w_id','w_name','w_slug','w_target','w_comment'].forEach(id => document.getElementById(id).value = ''); ['w_id','w_name','w_slug','w_target','w_comment','w_svc_login','w_svc_password','w_svc_cred_hint'].forEach(id => document.getElementById(id).value = '');
document.getElementById('w_active').value = 'true'; document.getElementById('w_active').value = 'true';
setCategoryChecks('.w_cat', []); setCategoryChecks('.w_cat', []);
document.getElementById('w_icon_preview').src = placeholderIcon; document.getElementById('w_icon_preview').src = placeholderIcon;
@@ -777,23 +824,59 @@
function buildRdpTarget(prefix) { function buildRdpTarget(prefix) {
const host = (document.getElementById(`${prefix}_host`)?.value || '').trim(); const host = (document.getElementById(`${prefix}_host`)?.value || '').trim();
const port = (document.getElementById(`${prefix}_port`)?.value || '').trim() || '3389'; const port = (document.getElementById(`${prefix}_port`)?.value || '').trim() || '3389';
const user = (document.getElementById(`${prefix}_user`)?.value || '').trim();
const pass = (document.getElementById(`${prefix}_pass`)?.value || '').trim();
const domain = (document.getElementById(`${prefix}_domain`)?.value || '').trim(); const domain = (document.getElementById(`${prefix}_domain`)?.value || '').trim();
const sec = (document.getElementById(`${prefix}_sec`)?.value || '').trim(); const sec = (document.getElementById(`${prefix}_sec`)?.value || '').trim();
const targetInput = document.getElementById(`${prefix}_target`); const targetInput = document.getElementById(`${prefix}_target`);
if (!host) return (targetInput?.value || '').trim(); if (!host) return (targetInput?.value || '').trim();
const creds = user ? `${encodeURIComponent(user)}${pass ? `:${encodeURIComponent(pass)}` : ''}@` : '';
const query = new URLSearchParams(); const query = new URLSearchParams();
if (domain) query.set('domain', domain); if (domain) query.set('domain', domain);
if (sec) query.set('sec', sec); if (sec) query.set('sec', sec);
const q = query.toString(); const q = query.toString();
const target = `rdp://${creds}${host}:${port}${q ? `?${q}` : ''}`; const target = `rdp://${host}:${port}${q ? `?${q}` : ''}`;
if (targetInput) targetInput.value = target; if (targetInput) targetInput.value = target;
return target; return target;
} }
function selectRdpService(id, name, slug, target, comment, iconPath, active, pool) { function renderRdpSlots(serviceId) {
const box = document.getElementById('rdp_slots_box');
const tbody = document.querySelector('#rdp_slots_table tbody');
const slots = rdpSlotsMap[serviceId] || [];
box.style.display = 'block';
tbody.innerHTML = '';
if (!slots.length) {
tbody.innerHTML = '<tr><td colspan="5" style="color:#888">Нет слотов. Добавьте RDP пользователей ниже.</td></tr>';
return;
}
slots.forEach(s => {
const statusBadge = s.running
? '<span style="color:#4caf50">&#9679; running</span>'
: '<span style="color:#e07b39">&#9679; stopped</span>';
const occupiedCell = s.occupied_username
? `<span style="color:#e07b39">${s.occupied_username}</span>`
: '<span style="color:#888">свободен</span>';
const tr = document.createElement('tr');
tr.innerHTML = `<td>${s.rdp_username}</td><td style="font-size:.8em;color:#888">${s.container_name||'—'}</td><td>${statusBadge}</td><td>${occupiedCell}</td><td><button onclick="deleteRdpSlot(${s.id})">✕</button></td>`;
tbody.appendChild(tr);
});
}
async function addRdpSlot() {
const serviceId = document.getElementById('r_id').value;
if (!serviceId) return alert('Выберите RDP сервис');
const rdp_username = document.getElementById('new_slot_user').value.trim();
const rdp_password = document.getElementById('new_slot_pass').value.trim();
if (!rdp_username) return alert('Введите логин RDP');
await api(`/api/admin/services/${serviceId}/rdp-slots`, 'POST', {rdp_username, rdp_password});
location.reload();
}
async function deleteRdpSlot(slotId) {
if (!confirm('Удалить RDP слот и остановить контейнер?')) return;
await api(`/api/admin/rdp-slots/${slotId}`, 'DELETE', {});
location.reload();
}
function selectRdpService(id, name, slug, target, comment, iconPath, active, pool, svcLogin, svcPassword, svcCredHint) {
const cfg = parseRdpTarget(target); const cfg = parseRdpTarget(target);
document.getElementById('r_id').value = id; document.getElementById('r_id').value = id;
document.getElementById('r_name').value = name; document.getElementById('r_name').value = name;
@@ -801,17 +884,19 @@
document.getElementById('r_target').value = target; document.getElementById('r_target').value = target;
document.getElementById('r_host').value = cfg.host; document.getElementById('r_host').value = cfg.host;
document.getElementById('r_port').value = cfg.port; document.getElementById('r_port').value = cfg.port;
document.getElementById('r_user').value = cfg.user;
document.getElementById('r_pass').value = cfg.pass;
document.getElementById('r_domain').value = cfg.domain; document.getElementById('r_domain').value = cfg.domain;
document.getElementById('r_sec').value = cfg.sec; document.getElementById('r_sec').value = cfg.sec;
document.getElementById('r_comment').value = comment || ''; document.getElementById('r_comment').value = comment || '';
document.getElementById('r_svc_login').value = svcLogin || '';
document.getElementById('r_svc_password').value = svcPassword || '';
document.getElementById('r_svc_cred_hint').value = svcCredHint || '';
document.getElementById('r_active').value = String(active); document.getElementById('r_active').value = String(active);
document.getElementById('r_pool').value = pool; document.getElementById('r_pool').value = pool;
setCategoryChecks('.r_cat', serviceCategoryMap[id] || []); setCategoryChecks('.r_cat', serviceCategoryMap[id] || []);
document.getElementById('r_icon_preview').src = iconPath || placeholderIcon; document.getElementById('r_icon_preview').src = iconPath || placeholderIcon;
document.getElementById('r_health_box').style.display = 'block'; document.getElementById('r_health_box').style.display = 'block';
markSelected('.rdp-item', 'data-service-id', id); markSelected('.rdp-item', 'data-service-id', id);
renderRdpSlots(id);
refreshSelectedServiceStatus('rdp'); refreshSelectedServiceStatus('rdp');
} }
@@ -824,6 +909,9 @@
type: 'RDP', type: 'RDP',
target, target,
comment: document.getElementById('new_r_comment').value, comment: document.getElementById('new_r_comment').value,
svc_login: document.getElementById('new_r_svc_login').value,
svc_password: document.getElementById('new_r_svc_password').value,
svc_cred_hint: document.getElementById('new_r_svc_cred_hint').value,
category_ids: checkedCategoryIds('.new_r_cat'), category_ids: checkedCategoryIds('.new_r_cat'),
warm_pool_size: parseInt(document.getElementById('new_r_pool').value || '0', 10), warm_pool_size: parseInt(document.getElementById('new_r_pool').value || '0', 10),
active: document.getElementById('new_r_active').value === 'true', active: document.getElementById('new_r_active').value === 'true',
@@ -841,6 +929,9 @@
type: 'RDP', type: 'RDP',
target, target,
comment: document.getElementById('r_comment').value, comment: document.getElementById('r_comment').value,
svc_login: document.getElementById('r_svc_login').value,
svc_password: document.getElementById('r_svc_password').value,
svc_cred_hint: document.getElementById('r_svc_cred_hint').value,
category_ids: checkedCategoryIds('.r_cat'), category_ids: checkedCategoryIds('.r_cat'),
warm_pool_size: parseInt(document.getElementById('r_pool').value || '0', 10), warm_pool_size: parseInt(document.getElementById('r_pool').value || '0', 10),
active: document.getElementById('r_active').value === 'true', active: document.getElementById('r_active').value === 'true',
@@ -849,7 +940,8 @@
} }
function clearRdpForm() { function clearRdpForm() {
['r_id','r_name','r_slug','r_target','r_host','r_port','r_user','r_pass','r_domain','r_comment','r_pool'].forEach(id => document.getElementById(id).value = ''); ['r_id','r_name','r_slug','r_target','r_host','r_port','r_domain','r_comment','r_pool','r_svc_login','r_svc_password','r_svc_cred_hint'].forEach(id => document.getElementById(id).value = '');
document.getElementById('rdp_slots_box').style.display = 'none';
document.getElementById('r_sec').value = ''; document.getElementById('r_sec').value = '';
document.getElementById('r_active').value = 'true'; document.getElementById('r_active').value = 'true';
setCategoryChecks('.r_cat', []); setCategoryChecks('.r_cat', []);
+111 -21
View File
@@ -9,6 +9,28 @@
<link rel="alternate icon" type="image/png" href="/static/favicon.png" /> <link rel="alternate icon" type="image/png" href="/static/favicon.png" />
</head> </head>
<body class="dashboard-page"> <body class="dashboard-page">
{% raw %}<style>
#mobile-wall{display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:9999;background:linear-gradient(135deg,#0d1b2a 0%,#1a2e45 60%,#0f2137 100%);flex-direction:column;align-items:center;justify-content:center;padding:clamp(1rem,6vw,2.5rem);text-align:center;font-family:sans-serif;box-sizing:border-box;overflow:hidden}
@media(max-width:1023px){#mobile-wall{display:flex}}
.mw-icon{font-size:clamp(2.2rem,12vw,3.5rem);margin-bottom:clamp(.5rem,2vw,1.2rem);filter:drop-shadow(0 0 18px rgba(42,140,214,.5))}
.mw-title{font-size:clamp(1rem,5.5vw,1.5rem);font-weight:800;color:#fff;margin-bottom:.6rem;letter-spacing:.01em;word-break:break-word;width:100%}
.mw-sub{font-size:clamp(.8rem,3.8vw,.95rem);color:#a0b8cc;width:100%;max-width:320px;line-height:1.6;margin-bottom:clamp(.8rem,4vw,2rem);word-break:break-word;overflow-wrap:break-word}
.mw-badge{display:inline-flex;align-items:center;gap:.45rem;background:rgba(42,140,214,.15);border:1px solid rgba(42,140,214,.4);border-radius:999px;padding:.45rem .9rem;color:#6bbfff;font-size:clamp(.7rem,3.2vw,.85rem);font-weight:600;max-width:88vw;flex-wrap:wrap;justify-content:center;word-break:break-word}
.mw-badge svg{width:16px;height:16px;flex-shrink:0}
.mw-footer{position:absolute;bottom:1.2rem;left:0;width:100%;text-align:center;font-size:clamp(.65rem,2.8vw,.78rem);color:rgba(160,184,204,.45);font-family:sans-serif}
</style>{% endraw %}
<div id="mobile-wall">
<img src="/static/logo.png" alt="MONT" style="position:absolute;top:1.2rem;left:50%;transform:translateX(-50%);height:clamp(4rem,16vw,6rem);opacity:.9">
<div class="mw-icon">🖥️</div>
<div class="mw-title">Только для компьютера</div>
<div class="mw-sub">Инфраструктурный полигон МОНТ оптимизирован для работы на ПК.<br>Пожалуйста, откройте портал с настольного компьютера или ноутбука.</div>
<div class="mw-badge">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
Минимальная ширина экрана: 1024 px
</div>
<div class="mw-footer"><a href="mailto:rgalyaviev@mont.com" style="color:inherit;text-decoration:none">Made by Galyaviev</a></div>
</div>
<header class="header"> <header class="header">
<div style="display:flex; align-items:center; gap:0.6rem;"> <div style="display:flex; align-items:center; gap:0.6rem;">
<img src="/static/logo.png" alt="MONT" class="header-logo" /> <img src="/static/logo.png" alt="MONT" class="header-logo" />
@@ -55,23 +77,46 @@
<section class="grid service-grid"> <section class="grid service-grid">
{% for service in services %} {% for service in services %}
{% set svc_cats = service_categories.get(service.id, []) %} {% set svc_cats = service_categories.get(service.id, []) %}
<a class="tile" href="/go/{{ service.slug }}"> <div class="tile-wrap">
<div class="tile-icon-box"> <a class="tile-link" href="/go/{{ service.slug }}" aria-label="{{ service.name }}"></a>
<img class="tile-icon" src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="icon" /> <div class="tile">
</div> <div class="tile-icon-box">
<h3>{{ service.name }}</h3> <img class="tile-icon" src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
<p>Открыть сервис</p>
{% if service.comment %}
<small class="tile-comment">{{ service_comment_html.get(service.id, '') }}</small>
{% endif %}
{% if svc_cats %}
<div class="service-categories">
{% for category in svc_cats %}
<span class="service-cat-badge">{{ category.name }}</span>
{% endfor %}
</div> </div>
{% endif %} <h3>{{ service.name }}</h3>
</a> <div class="tile-info-area">
{% if service.svc_login or service.svc_password %}
<div class="svc-credentials">
{% if service.svc_login %}
<div class="svc-cred-row">
<span class="svc-cred-label">Логин</span>
<span class="svc-cred-value">{{ service.svc_login }}</span>
</div>
{% endif %}
{% if service.svc_password %}
<div class="svc-cred-row">
<span class="svc-cred-label">Пароль</span>
<span class="svc-cred-value svc-cred-masked">{{ service.svc_password }}</span>
</div>
{% endif %}
{% if service.svc_cred_hint %}
<p class="svc-cred-hint">{{ service.svc_cred_hint }}</p>
{% endif %}
</div>
{% endif %}
{% if service.comment %}
<div class="tile-comment">{{ service_comment_html.get(service.id, '') }}</div>
{% endif %}
</div>
{% if svc_cats %}
<div class="service-categories">
{% for category in svc_cats %}
<span class="service-cat-badge">{{ category.name }}</span>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% else %} {% else %}
<div class="tile"> <div class="tile">
{% if selected_category_slug %} {% if selected_category_slug %}
@@ -84,6 +129,18 @@
</section> </section>
<footer class="made-by-wrap"><a class="made-by" href="mailto:rgalyaviev@mont.com">Made by Galyaviev</a></footer> <footer class="made-by-wrap"><a class="made-by" href="mailto:rgalyaviev@mont.com">Made by Galyaviev</a></footer>
</main> </main>
<style>
#loading-overlay{display:none;position:fixed;inset:0;z-index:8888;background:rgba(10,18,28,.88);
backdrop-filter:blur(4px);flex-direction:column;align-items:center;justify-content:center;gap:1.2rem}
#loading-overlay .lo-spinner{width:52px;height:52px;border:4px solid rgba(220,232,245,.15);
border-top-color:#2a8cd6;border-radius:50%;animation:lo-spin .85s linear infinite}
#loading-overlay .lo-text{color:#a0b8cc;font:600 1rem sans-serif}
@keyframes lo-spin{to{transform:rotate(360deg)}}
</style>
<div id="loading-overlay">
<div class="lo-spinner"></div>
<div class="lo-text">Ожидайте...</div>
</div>
<script> <script>
(function () { (function () {
const username = {{ user.username|tojson }}; const username = {{ user.username|tojson }};
@@ -107,24 +164,57 @@
} }
function currentScreenParams() { function currentScreenParams() {
const width = clamp(window.innerWidth || document.documentElement.clientWidth || 1280, 320, 7680); const screenWidth =
const height = clamp(window.innerHeight || document.documentElement.clientHeight || 720, 240, 4320); window.screen && Number.isFinite(window.screen.width) && window.screen.width > 0
? window.screen.width
: null;
const screenHeight =
window.screen && Number.isFinite(window.screen.height) && window.screen.height > 0
? window.screen.height
: null;
const viewportWidth =
(window.visualViewport && window.visualViewport.width) ||
window.innerWidth ||
document.documentElement.clientWidth ||
1280;
const viewportHeight =
(window.visualViewport && window.visualViewport.height) ||
window.innerHeight ||
document.documentElement.clientHeight ||
720;
// Prefer stable screen dimensions; viewport is fallback.
const width = clamp(Math.round(screenWidth || viewportWidth), 320, 7680);
const height = clamp(Math.round(screenHeight || viewportHeight), 240, 4320);
const sp = new URLSearchParams(); const sp = new URLSearchParams();
sp.set('sw', String(width)); sp.set('sw', String(width));
sp.set('sh', String(height)); sp.set('sh', String(height));
return sp; return sp;
} }
const loadingOverlay = document.getElementById('loading-overlay');
document.querySelectorAll('a.tile[href^="/go/"]').forEach(function (link) { document.querySelectorAll('a.tile[href^="/go/"]').forEach(function (link) {
link.addEventListener('click', function () { link.addEventListener('click', function (e) {
e.preventDefault();
let href = link.getAttribute('href');
try { try {
const url = new URL(link.getAttribute('href'), window.location.origin); const url = new URL(href, window.location.origin);
const params = currentScreenParams(); const params = currentScreenParams();
url.search = params.toString(); url.search = params.toString();
link.setAttribute('href', url.pathname + '?' + url.searchParams.toString()); href = url.pathname + '?' + url.searchParams.toString();
} catch (e) {} } catch (e) {}
if (loadingOverlay) loadingOverlay.style.display = 'flex';
requestAnimationFrame(function () {
requestAnimationFrame(function () {
window.location.href = href;
});
});
}, { capture: true }); }, { capture: true });
}); });
window.addEventListener('pageshow', function (e) {
if (loadingOverlay) loadingOverlay.style.display = 'none';
});
})(); })();
</script> </script>
</body> </body>
+2 -2
View File
@@ -13,8 +13,8 @@
<main class="center-box login-page"> <main class="center-box login-page">
<section class="login-shell"> <section class="login-shell">
<img src="/static/logo.png" alt="MONT" class="brand-logo brand-logo-fullscreen" /> <img src="/static/logo.png" alt="MONT" class="brand-logo brand-logo-fullscreen" />
<h1 class="login-title">Добро пожаловать</h1> <div style="height:3.5rem"></div>
{% if session_notice %}<div class="session-notice">{{ session_notice }}</div>{% endif %} {% if session_notice %}<div class="session-notice">{{ session_notice }}</div>{% endif %}
{% if login_error %}<div class="auth-error">{{ login_error }}</div>{% endif %} {% if login_error %}<div class="auth-error">{{ login_error }}</div>{% endif %}
<form method="post" action="/login" class="panel login-panel"> <form method="post" action="/login" class="panel login-panel">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" /> <input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
+29 -11
View File
@@ -30,7 +30,7 @@ services:
api: api:
build: build:
context: ./app context: ./app
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "6"] command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "${UVICORN_WORKERS:-6}"]
environment: environment:
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
SIGNING_KEY: ${SIGNING_KEY} SIGNING_KEY: ${SIGNING_KEY}
@@ -41,13 +41,22 @@ services:
PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2} PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2}
UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0} UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0}
WEB_POOL_SIZE: ${WEB_POOL_SIZE:-20} WEB_POOL_SIZE: ${WEB_POOL_SIZE:-20}
WEB_POOL_BUFFER: ${WEB_POOL_BUFFER:-10} WEB_POOL_BUFFER: ${WEB_POOL_BUFFER:-2}
X11VNC_FLAGS: ${X11VNC_FLAGS:--wait 5 -defer 5 -threads}
MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4} MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4}
LOG_LEVEL: ${LOG_LEVEL:-INFO} LOG_LEVEL: ${LOG_LEVEL:-INFO}
GO_USER_LOCK_TIMEOUT_SECONDS: 8 LOG_SLOW_REQUEST_MS: ${LOG_SLOW_REQUEST_MS:-2000}
GO_POOL_LOCK_TIMEOUT_SECONDS: 20 GO_USER_LOCK_TIMEOUT_SECONDS: ${GO_USER_LOCK_TIMEOUT_SECONDS:-8}
POOL_DISPATCH_RETRIES: 6 GO_POOL_LOCK_TIMEOUT_SECONDS: ${GO_POOL_LOCK_TIMEOUT_SECONDS:-20}
ENABLE_STARTUP_MAINTENANCE: 0 POOL_DISPATCH_RETRIES: ${POOL_DISPATCH_RETRIES:-6}
POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS: ${POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS:-2.0}
POOL_DISPATCH_SLEEP_SECONDS: ${POOL_DISPATCH_SLEEP_SECONDS:-0.3}
TRAEFIK_INTERNAL_URL: ${TRAEFIK_INTERNAL_URL:-http://traefik}
WEB_RESOLUTION_MIN_WIDTH: ${WEB_RESOLUTION_MIN_WIDTH:-1024}
WEB_RESOLUTION_MIN_HEIGHT: ${WEB_RESOLUTION_MIN_HEIGHT:-720}
WEB_RESOLUTION_MAX_WIDTH: ${WEB_RESOLUTION_MAX_WIDTH:-3840}
WEB_RESOLUTION_MAX_HEIGHT: ${WEB_RESOLUTION_MAX_HEIGHT:-2160}
ENABLE_STARTUP_MAINTENANCE: ${ENABLE_STARTUP_MAINTENANCE:-0}
depends_on: depends_on:
- db - db
volumes: volumes:
@@ -82,13 +91,22 @@ services:
PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2} PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2}
UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0} UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0}
WEB_POOL_SIZE: ${WEB_POOL_SIZE:-20} WEB_POOL_SIZE: ${WEB_POOL_SIZE:-20}
WEB_POOL_BUFFER: ${WEB_POOL_BUFFER:-10} WEB_POOL_BUFFER: ${WEB_POOL_BUFFER:-2}
X11VNC_FLAGS: ${X11VNC_FLAGS:--wait 5 -defer 5 -threads}
MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4} MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4}
LOG_LEVEL: ${LOG_LEVEL:-INFO} LOG_LEVEL: ${LOG_LEVEL:-INFO}
GO_USER_LOCK_TIMEOUT_SECONDS: 8 LOG_SLOW_REQUEST_MS: ${LOG_SLOW_REQUEST_MS:-2000}
GO_POOL_LOCK_TIMEOUT_SECONDS: 20 GO_USER_LOCK_TIMEOUT_SECONDS: ${GO_USER_LOCK_TIMEOUT_SECONDS:-8}
POOL_DISPATCH_RETRIES: 6 GO_POOL_LOCK_TIMEOUT_SECONDS: ${GO_POOL_LOCK_TIMEOUT_SECONDS:-20}
ENABLE_STARTUP_MAINTENANCE: 0 POOL_DISPATCH_RETRIES: ${POOL_DISPATCH_RETRIES:-6}
POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS: ${POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS:-2.0}
POOL_DISPATCH_SLEEP_SECONDS: ${POOL_DISPATCH_SLEEP_SECONDS:-0.3}
TRAEFIK_INTERNAL_URL: ${TRAEFIK_INTERNAL_URL:-http://traefik}
WEB_RESOLUTION_MIN_WIDTH: ${WEB_RESOLUTION_MIN_WIDTH:-1024}
WEB_RESOLUTION_MIN_HEIGHT: ${WEB_RESOLUTION_MIN_HEIGHT:-720}
WEB_RESOLUTION_MAX_WIDTH: ${WEB_RESOLUTION_MAX_WIDTH:-3840}
WEB_RESOLUTION_MAX_HEIGHT: ${WEB_RESOLUTION_MAX_HEIGHT:-2160}
ENABLE_STARTUP_MAINTENANCE: ${ENABLE_STARTUP_MAINTENANCE:-0}
depends_on: depends_on:
- db - db
volumes: volumes:
@@ -1,85 +0,0 @@
services:
traefik:
image: traefik:v3.2
command:
- --configFile=/etc/traefik/traefik.yml
ports:
- "127.0.0.1:8288:80"
- "127.0.0.1:2288:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
- ./traefik/dynamic:/etc/traefik/dynamic
- ./traefik/letsencrypt:/letsencrypt
networks:
- portal_net
restart: unless-stopped
db:
image: postgres:16
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pg_data:/var/lib/postgresql/data
networks:
- portal_net
restart: unless-stopped
api:
build:
context: ./app
environment:
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
SIGNING_KEY: ${SIGNING_KEY}
PUBLIC_HOST: ${PUBLIC_HOST}
ADMIN_USERNAME: ${ADMIN_USERNAME}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
SESSION_IDLE_SECONDS: 1800
PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2}
UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-5}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
depends_on:
- db
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./app/static/service-icons:/app/static/service-icons
labels:
- traefik.enable=true
- traefik.docker.network=portal_net
- traefik.http.routers.portal.rule=Host(`${PUBLIC_HOST}`)
- traefik.http.routers.portal.entrypoints=websecure
- traefik.http.routers.portal.tls=true
- traefik.http.routers.portal.tls.certresolver=letsencrypt
- traefik.http.routers.portal.priority=1
- traefik.http.services.portal.loadbalancer.server.port=8000
- traefik.http.routers.portal.middlewares=secure-headers@file
networks:
- portal_net
restart: unless-stopped
kiosk-image:
image: portal-kiosk:latest
build:
context: ./kiosk
profiles: ["build-only"]
rdp-proxy-image:
image: portal-rdp-proxy:latest
build:
context: ./rdp-proxy
profiles: ["build-only"]
universal-runtime-image:
image: portal-universal-runtime:latest
build:
context: ./universal-runtime
profiles: ["build-only"]
networks:
portal_net:
name: portal_net
volumes:
pg_data:
+2
View File
@@ -11,6 +11,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
websockify \ websockify \
python3 \ python3 \
ca-certificates \ ca-certificates \
x11-xserver-utils \
x11-utils \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
+18 -2
View File
@@ -4,7 +4,7 @@ set -euo pipefail
TARGET_URL="${TARGET_URL:-https://example.com}" TARGET_URL="${TARGET_URL:-https://example.com}"
SESSION_ID="${SESSION_ID:-unknown}" SESSION_ID="${SESSION_ID:-unknown}"
IDLE_TIMEOUT="${IDLE_TIMEOUT:-1800}" IDLE_TIMEOUT="${IDLE_TIMEOUT:-1800}"
X11VNC_FLAGS="${X11VNC_FLAGS:--wait 5 -defer 5 -ncache 10 -ncache_cr -threads}" X11VNC_FLAGS="${X11VNC_FLAGS:--wait 5 -defer 5 -threads}"
ENABLE_HEARTBEAT="${ENABLE_HEARTBEAT:-1}" ENABLE_HEARTBEAT="${ENABLE_HEARTBEAT:-1}"
TOUCH_PATH="${TOUCH_PATH:-/api/sessions/${SESSION_ID}/touch}" TOUCH_PATH="${TOUCH_PATH:-/api/sessions/${SESSION_ID}/touch}"
UNIVERSAL_WEB="${UNIVERSAL_WEB:-0}" UNIVERSAL_WEB="${UNIVERSAL_WEB:-0}"
@@ -183,6 +183,22 @@ else
>/tmp/chromium.log 2>&1 & >/tmp/chromium.log 2>&1 &
fi fi
x11vnc -display :1 -rfbport 5900 -forever -shared -nopw -noxdamage $X11VNC_FLAGS >/tmp/x11vnc.log 2>&1 & start_x11vnc_with_retry() {
local display_arg="$1"
local attempt=0
while [ "$attempt" -lt 12 ]; do
x11vnc -display "$display_arg" -rfbport 5900 -forever -shared -nopw -noxdamage $X11VNC_FLAGS >/tmp/x11vnc.log 2>&1 &
local pid=$!
sleep 1
if kill -0 "$pid" 2>/dev/null; then
return 0
fi
attempt=$((attempt + 1))
sleep 0.5
done
return 1
}
start_x11vnc_with_retry ":1" || true
exec websockify --verbose --idle-timeout="$IDLE_TIMEOUT" --web=/opt/portal 6080 localhost:5900 exec websockify --verbose --idle-timeout="$IDLE_TIMEOUT" --web=/opt/portal 6080 localhost:5900
+76 -1
View File
@@ -1,9 +1,18 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json import json
import os
import subprocess
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
DISPLAY = os.environ.get("DISPLAY", ":1")
CHROME_WINDOW_SIZE = os.environ.get("CHROME_WINDOW_SIZE", "1920,1080")
RESOLUTION_MIN_WIDTH = int(os.environ.get("WEB_RESOLUTION_MIN_WIDTH", "1024"))
RESOLUTION_MIN_HEIGHT = int(os.environ.get("WEB_RESOLUTION_MIN_HEIGHT", "720"))
RESOLUTION_MAX_WIDTH = int(os.environ.get("WEB_RESOLUTION_MAX_WIDTH", "3840"))
RESOLUTION_MAX_HEIGHT = int(os.environ.get("WEB_RESOLUTION_MAX_HEIGHT", "2160"))
def _json_get(path: str): def _json_get(path: str):
with urllib.request.urlopen(f"http://127.0.0.1:9222{path}", timeout=2) as resp: with urllib.request.urlopen(f"http://127.0.0.1:9222{path}", timeout=2) as resp:
@@ -20,7 +29,6 @@ def chromium_open(url: str) -> None:
encoded = urllib.parse.quote(url, safe=':/?#[]@!$&\'()*+,;=%') encoded = urllib.parse.quote(url, safe=':/?#[]@!$&\'()*+,;=%')
opened = _json_put(f"/json/new?{encoded}") opened = _json_put(f"/json/new?{encoded}")
opened_id = opened.get("id") opened_id = opened.get("id")
# Keep exactly one active page tab to prevent tab/memory explosion in warm containers.
pages = _json_get("/json/list") pages = _json_get("/json/list")
for page in pages: for page in pages:
page_id = page.get("id") page_id = page.get("id")
@@ -31,6 +39,70 @@ def chromium_open(url: str) -> None:
pass pass
def _sanitize_resolution(width, height):
if not width or not height:
try:
default_w, default_h = [int(x) for x in CHROME_WINDOW_SIZE.split(",", 1)]
return default_w, default_h
except Exception:
return 1920, 1080
safe_w = max(RESOLUTION_MIN_WIDTH, min(int(width), RESOLUTION_MAX_WIDTH))
safe_h = max(RESOLUTION_MIN_HEIGHT, min(int(height), RESOLUTION_MAX_HEIGHT))
return safe_w, safe_h
def _xrandr_output_name():
try:
out = subprocess.run(
["xrandr", "-display", DISPLAY],
capture_output=True, text=True, check=False,
).stdout
for line in out.splitlines():
if " connected" in line:
return line.split()[0]
except Exception:
pass
return None
def _add_mode_via_cvt(width: int, height: int, output_name: str) -> bool:
try:
cvt = subprocess.run(
["cvt", str(width), str(height)],
capture_output=True, text=True, check=False,
)
if cvt.returncode != 0:
return False
modeline_line = next((l for l in cvt.stdout.splitlines() if l.startswith("Modeline")), None)
if not modeline_line:
return False
parts = modeline_line.split()
mode_name = parts[1].strip('"')
mode_params = parts[2:]
subprocess.run(["xrandr", "-display", DISPLAY, "--newmode", mode_name] + mode_params,
check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.run(["xrandr", "-display", DISPLAY, "--addmode", output_name, mode_name],
check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.run(["xrandr", "-display", DISPLAY, "--output", output_name, "--mode", mode_name],
check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return True
except Exception:
return False
def apply_resolution(width, height) -> tuple:
safe_w, safe_h = _sanitize_resolution(width, height)
result = subprocess.run(
["xrandr", "-display", DISPLAY, "-s", f"{safe_w}x{safe_h}"],
check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
if result.returncode != 0:
output_name = _xrandr_output_name()
if output_name:
_add_mode_via_cvt(safe_w, safe_h, output_name)
return safe_w, safe_h
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
def _json(self, code: int, payload: dict): def _json(self, code: int, payload: dict):
body = json.dumps(payload).encode("utf-8") body = json.dumps(payload).encode("utf-8")
@@ -58,6 +130,9 @@ class Handler(BaseHTTPRequestHandler):
if not url.startswith("http://") and not url.startswith("https://"): if not url.startswith("http://") and not url.startswith("https://"):
self._json(400, {"detail": "Invalid URL"}) self._json(400, {"detail": "Invalid URL"})
return return
width = data.get("width")
height = data.get("height")
apply_resolution(width, height)
chromium_open(url) chromium_open(url)
print(f"open_ok url={url}", flush=True) print(f"open_ok url={url}", flush=True)
self._json(200, {"ok": True, "url": url}) self._json(200, {"ok": True, "url": url})
+1
View File
@@ -8,6 +8,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
freerdp2-x11 \ freerdp2-x11 \
novnc \ novnc \
websockify \ websockify \
xdotool \
ca-certificates \ ca-certificates \
fonts-dejavu-core \ fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
+165 -22
View File
@@ -24,59 +24,167 @@ cat > /opt/portal/index.html <<HTML
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>RDP Session</title> <title>RDP Session</title>
<style>html,body,#screen{margin:0;height:100%;background:#111}</style> <style>
html,body,#screen{margin:0;height:100%;background:#111}
.status{
position:fixed;left:12px;top:12px;z-index:50;padding:8px 10px;border-radius:8px;
background:rgba(16,22,32,.86);border:1px solid rgba(255,255,255,.18);
color:#dce8f5;font:600 13px/1.25 sans-serif;max-width:min(92vw,560px);
}
.status.error{background:rgba(85,20,20,.9);border-color:rgba(255,130,130,.36);color:#ffe3e3}
.status.hidden{display:none}
.spinner{display:inline-block;width:12px;height:12px;border:2px solid rgba(220,232,245,.3);
border-top-color:#dce8f5;border-radius:50%;animation:spin .8s linear infinite;margin-right:7px;vertical-align:middle}
@keyframes spin{to{transform:rotate(360deg)}}
.nav-panel{
position:fixed;left:16px;top:64px;z-index:99;display:flex;gap:10px;
background:linear-gradient(180deg,rgba(15,24,36,.78),rgba(9,14,22,.86));
border:1px solid rgba(255,255,255,.22);backdrop-filter:blur(5px);
box-shadow:0 10px 28px rgba(0,0,0,.36);padding:9px 10px;border-radius:14px
}
.nav-btn{
border:1px solid rgba(255,255,255,.26);border-radius:999px;padding:9px 14px;cursor:pointer;
background:linear-gradient(180deg,#2a8cd6,#1668a6);color:#fff;font:700 13px/1 sans-serif;
box-shadow:inset 0 1px 0 rgba(255,255,255,.22),0 5px 12px rgba(10,46,78,.45)
}
.nav-btn:hover{filter:brightness(1.08)}
.nav-btn:active{transform:translateY(1px)}
</style>
</head> </head>
<body> <body>
<div id="screen"></div> <div id="screen"></div>
<div id="status" class="status"><span class="spinner"></span>Ожидайте...</div>
<div class="nav-panel">
<button class="nav-btn" id="btn-back" type="button" title="Назад">←</button>
<button class="nav-btn" id="btn-home" type="button" title="Главная">⌂</button>
</div>
<script type="module"> <script type="module">
import RFB from './core/rfb.js'; import RFB from './core/rfb.js';
const wsBase = location.pathname.replace(/\/+$/, ''); const wsBase = location.pathname.replace(/\/+$/, '');
const wsUrl = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + wsBase + '/websockify'; const wsUrl = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + wsBase + '/websockify';
const rfb = new RFB(document.getElementById('screen'), wsUrl); const statusEl = document.getElementById('status');
rfb.viewOnly = false; const XK_ALT_L = 0xffe9;
rfb.scaleViewport = true; const XK_LEFT = 0xff51;
rfb.resizeSession = true;
const enableHeartbeat = "${ENABLE_HEARTBEAT}" === "1"; let rfb = null;
const SESSION_CLOSED_URL = '/?session_closed=idle'; let connected = false;
const CLOSE_PATH = '${TOUCH_PATH}'.replace(/\/touch$/, '/close'); let reconnectTimer = null;
function goSessionClosed() { let reconnectAttempts = 0;
try { const MAX_RECONNECT = 12;
if (window.top && window.top !== window) { const DELAYS = [1000,2000,3000,5000,8000];
window.top.location.href = SESSION_CLOSED_URL; let manualDisconnect = false;
return;
} function showStatus(text, isError) {
} catch (e) {} const spinner = isError ? '' : '<span class="spinner"></span>';
window.location.href = SESSION_CLOSED_URL; statusEl.innerHTML = spinner + text;
statusEl.className = 'status' + (isError ? ' error' : '');
} }
function hideStatus() { statusEl.className = 'status hidden'; }
function scheduleReconnect(reason) {
if (manualDisconnect) return;
if (reconnectAttempts >= MAX_RECONNECT) {
showStatus('Соединение потеряно. Переподключение не удалось. Откройте сервис заново.', true);
return;
}
const n = ++reconnectAttempts;
const delay = DELAYS[Math.min(n-1, DELAYS.length-1)];
showStatus(\`\${reason} Повтор \${n}/\${MAX_RECONNECT} через \${Math.ceil(delay/1000)} сек.\`, true);
reconnectTimer = setTimeout(connect, delay);
}
function connect() {
if (manualDisconnect) return;
connected = false;
showStatus('Ожидайте...');
if (rfb) { try { rfb.disconnect(); } catch(e){} }
rfb = new RFB(document.getElementById('screen'), wsUrl);
rfb.viewOnly = false;
rfb.scaleViewport = true;
rfb.resizeSession = true;
rfb.addEventListener('connect', () => {
connected = true;
reconnectAttempts = 0;
clearTimeout(reconnectTimer);
showStatus('Устанавливается соединение с рабочим столом...');
setTimeout(hideStatus, 6000);
});
rfb.addEventListener('disconnect', () => {
connected = false;
if (!manualDisconnect) scheduleReconnect('Соединение потеряно.');
});
}
const enableHeartbeat = "${ENABLE_HEARTBEAT}" === "1";
const TOUCH_PATH = '${TOUCH_PATH}';
const CLOSE_PATH = TOUCH_PATH.replace(/\/touch$/, '/close');
const SESSION_CLOSED_URL = '/?session_closed=idle';
function goSessionClosed(reason) {
const r = reason === 'limit' ? 'limit' : 'idle';
try {
if (window.top && window.top !== window) { window.top.location.href = '/?session_closed=' + r; return; }
} catch(e) {}
window.location.href = '/?session_closed=' + r;
}
async function touch() { async function touch() {
try { try {
const res = await fetch('${TOUCH_PATH}', {method:'POST', credentials:'include'}); const res = await fetch(TOUCH_PATH, {method:'POST', credentials:'include'});
if (!res.ok) { if (!res.ok) {
goSessionClosed(); let reason = 'idle';
try { const p = await res.json(); if (p && typeof p.reason === 'string') reason = p.reason; } catch(e) {}
goSessionClosed(reason);
} }
} catch(e) {} } catch(e) {}
} }
let closing = false; let closing = false;
async function closeSessionNow() { async function closeSessionNow() {
if (closing) return; if (closing) return;
closing = true; closing = true;
try { manualDisconnect = true;
await fetch(CLOSE_PATH, {method:'POST', credentials:'include', keepalive:true}); clearTimeout(reconnectTimer);
} catch (e) {} try { if (rfb) rfb.disconnect(); } catch(e) {}
try { await fetch(CLOSE_PATH, {method:'POST', credentials:'include', keepalive:true}); } catch(e) {}
} }
if (enableHeartbeat) { if (enableHeartbeat) {
setInterval(touch, 15000); setInterval(touch, 15000);
touch(); touch();
window.addEventListener('pagehide', closeSessionNow); window.addEventListener('pagehide', closeSessionNow);
window.addEventListener('beforeunload', closeSessionNow); window.addEventListener('beforeunload', closeSessionNow);
} }
function chord(mod, key, modCode, keyCode) {
if (!rfb) return;
rfb.sendKey(mod, modCode, true);
rfb.sendKey(key, keyCode, true);
rfb.sendKey(key, keyCode, false);
rfb.sendKey(mod, modCode, false);
}
function goHome() {
manualDisconnect = true;
clearTimeout(reconnectTimer);
try { if (window.top && window.top !== window) { window.top.location.href = '/'; return; } } catch(e) {}
window.location.href = '/';
}
document.getElementById('btn-back').addEventListener('click', () => chord(XK_ALT_L, XK_LEFT, 'AltLeft', 'ArrowLeft'));
document.getElementById('btn-home').addEventListener('click', goHome);
document.addEventListener('contextmenu', (e) => e.preventDefault()); document.addEventListener('contextmenu', (e) => e.preventDefault());
connect();
</script> </script>
</body> </body>
</html> </html>
HTML HTML
export DISPLAY="$DISPLAY_NUM" export DISPLAY="$DISPLAY_NUM"
DISPLAY_N="${DISPLAY_NUM#:}"
rm -f "/tmp/.X${DISPLAY_N}-lock" "/tmp/.X11-unix/X${DISPLAY_N}" 2>/dev/null || true
Xvfb "$DISPLAY_NUM" -screen 0 "$SCREEN_GEOMETRY" >/tmp/xvfb.log 2>&1 & Xvfb "$DISPLAY_NUM" -screen 0 "$SCREEN_GEOMETRY" >/tmp/xvfb.log 2>&1 &
sleep 1 sleep 1
@@ -105,7 +213,42 @@ if [ -n "$RDP_DOMAIN" ]; then
fi fi
xfreerdp "${RDP_ARGS[@]}" >/tmp/xfreerdp.log 2>&1 & xfreerdp "${RDP_ARGS[@]}" >/tmp/xfreerdp.log 2>&1 &
XFREERDP_PID=$!
x11vnc -display "$DISPLAY_NUM" -rfbport 5900 -forever -shared -nopw -noxdamage >/tmp/x11vnc.log 2>&1 & x11vnc -display "$DISPLAY_NUM" -rfbport 5900 -forever -shared -nopw -noxdamage >/tmp/x11vnc.log 2>&1 &
X11VNC_PID=$!
exec websockify --verbose --idle-timeout="$IDLE_TIMEOUT" --web=/opt/portal 6080 localhost:5900 websockify --verbose --idle-timeout="$IDLE_TIMEOUT" --web=/opt/portal 6080 localhost:5900 >/tmp/websockify.log 2>&1 &
WEBSOCKIFY_PID=$!
# Anti-idle: send Shift key to xfreerdp window every 30s to prevent remote lock screen
anti_idle_loop() {
sleep 5
while true; do
WID=$(DISPLAY="$DISPLAY_NUM" xdotool search --pid "$XFREERDP_PID" 2>/dev/null | head -1)
if [ -n "$WID" ]; then
DISPLAY="$DISPLAY_NUM" xdotool key --window "$WID" shift 2>/dev/null || true
else
DISPLAY="$DISPLAY_NUM" xdotool mousemove --sync 500 300 2>/dev/null || true
sleep 1
DISPLAY="$DISPLAY_NUM" xdotool mousemove --sync 600 400 2>/dev/null || true
fi
sleep 30
done
}
anti_idle_loop &
ANTI_IDLE_PID=$!
# Graceful shutdown on docker stop (SIGTERM) — exit 0 so Docker does NOT auto-restart
cleanup() {
kill "$XFREERDP_PID" "$X11VNC_PID" "$WEBSOCKIFY_PID" "$ANTI_IDLE_PID" 2>/dev/null
exit 0
}
trap cleanup TERM INT
# Monitor xfreerdp — when it exits (disconnect/logoff) restart the container
wait "$XFREERDP_PID"
echo "xfreerdp exited (code $?), triggering container restart" >> /tmp/xfreerdp.log
kill "$X11VNC_PID" "$WEBSOCKIFY_PID" 2>/dev/null
exit 1
+3
View File
@@ -12,7 +12,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
websockify \ websockify \
python3 \ python3 \
ca-certificates \ ca-certificates \
x11-xserver-utils \
x11-utils \
fonts-dejavu-core \ fonts-dejavu-core \
python3-cryptography \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
+18 -2
View File
@@ -2,7 +2,7 @@
set -euo pipefail set -euo pipefail
IDLE_TIMEOUT="${IDLE_TIMEOUT:-1800}" IDLE_TIMEOUT="${IDLE_TIMEOUT:-1800}"
X11VNC_FLAGS="${X11VNC_FLAGS:--wait 5 -defer 5 -ncache 10 -ncache_cr -threads}" X11VNC_FLAGS="${X11VNC_FLAGS:--wait 5 -defer 5 -threads}"
SCREEN_GEOMETRY="${SCREEN_GEOMETRY:-1920x1080x24}" SCREEN_GEOMETRY="${SCREEN_GEOMETRY:-1920x1080x24}"
CHROME_WINDOW_SIZE="${CHROME_WINDOW_SIZE:-1920,1080}" CHROME_WINDOW_SIZE="${CHROME_WINDOW_SIZE:-1920,1080}"
ENABLE_HEARTBEAT="${ENABLE_HEARTBEAT:-1}" ENABLE_HEARTBEAT="${ENABLE_HEARTBEAT:-1}"
@@ -264,6 +264,22 @@ export CHROME_WINDOW_SIZE
Xvfb "$DISPLAY_NUM" -screen 0 "$SCREEN_GEOMETRY" >/tmp/xvfb.log 2>&1 & Xvfb "$DISPLAY_NUM" -screen 0 "$SCREEN_GEOMETRY" >/tmp/xvfb.log 2>&1 &
fluxbox >/tmp/fluxbox.log 2>&1 & fluxbox >/tmp/fluxbox.log 2>&1 &
python3 /manager.py >/tmp/manager.log 2>&1 & python3 /manager.py >/tmp/manager.log 2>&1 &
x11vnc -display "$DISPLAY_NUM" -rfbport 5900 -forever -shared -nopw -noxdamage $X11VNC_FLAGS >/tmp/x11vnc.log 2>&1 & start_x11vnc_with_retry() {
local display_arg="$1"
local attempt=0
while [ "$attempt" -lt 12 ]; do
x11vnc -display "$display_arg" -rfbport 5900 -forever -shared -nopw -noxdamage $X11VNC_FLAGS >/tmp/x11vnc.log 2>&1 &
local pid=$!
sleep 1
if kill -0 "$pid" 2>/dev/null; then
return 0
fi
attempt=$((attempt + 1))
sleep 0.5
done
return 1
}
start_x11vnc_with_retry "$DISPLAY_NUM" || true
exec websockify --verbose --idle-timeout="$IDLE_TIMEOUT" --web=/opt/portal 6080 localhost:5900 exec websockify --verbose --idle-timeout="$IDLE_TIMEOUT" --web=/opt/portal 6080 localhost:5900
+312 -24
View File
@@ -1,10 +1,13 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json import json
import os import os
import shutil
import signal import signal
import subprocess import subprocess
import tempfile
import threading import threading
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, urlunparse, quote
DISPLAY = os.environ.get("DISPLAY", ":1") DISPLAY = os.environ.get("DISPLAY", ":1")
CHROME_WINDOW_SIZE = os.environ.get("CHROME_WINDOW_SIZE", "1920,1080") CHROME_WINDOW_SIZE = os.environ.get("CHROME_WINDOW_SIZE", "1920,1080")
@@ -18,24 +21,223 @@ _state = {
"mode": "idle", "mode": "idle",
"target": "", "target": "",
"resolution": CHROME_WINDOW_SIZE, "resolution": CHROME_WINDOW_SIZE,
"profile_dir": None,
"extension_dir": None,
} }
_lock = threading.Lock() _lock = threading.Lock()
_AUTOFILL_CONTENT_JS = r"""
(function() {
const CREDS = __CREDS__;
let userFilled = false;
let passFilled = false;
console.log('[PortalAutofill] loaded for', location.href);
function isVisible(el) {
if (!el) return false;
if (el.disabled || el.readOnly) return false;
if (el.offsetParent === null && el.type !== 'email') return false;
return true;
}
function findUserFieldByAttrs() {
const candidates = document.querySelectorAll(
'input[type="email"], ' +
'input[autocomplete*="username"], ' +
'input[autocomplete*="email"], ' +
'input[name*="user" i]:not([type="password"]):not([type="hidden"]), ' +
'input[name*="login" i]:not([type="password"]):not([type="hidden"]), ' +
'input[name*="email" i]:not([type="password"]):not([type="hidden"]), ' +
'input[id*="user" i]:not([type="password"]):not([type="hidden"]), ' +
'input[id*="login" i]:not([type="password"]):not([type="hidden"]), ' +
'input[id*="email" i]:not([type="password"]):not([type="hidden"])'
);
for (const el of candidates) {
if (isVisible(el)) return el;
}
return null;
}
function findUserFieldNearPassword(passEl) {
if (!passEl) return null;
// Walk up to find the closest form-like container, then look for
// any text/email input that comes BEFORE the password element.
let container = passEl.closest('form');
if (!container) {
let cur = passEl.parentElement;
while (cur && cur !== document.body) {
if (cur.querySelectorAll('input').length >= 2) { container = cur; break; }
cur = cur.parentElement;
}
}
if (!container) container = document.body;
const inputs = container.querySelectorAll('input');
let candidate = null;
for (const el of inputs) {
if (el === passEl) break;
const t = (el.type || 'text').toLowerCase();
if (t === 'password' || t === 'hidden' || t === 'submit' || t === 'button' ||
t === 'checkbox' || t === 'radio' || t === 'file') continue;
if (!isVisible(el)) continue;
candidate = el;
}
return candidate;
}
function findUserField(passEl) {
return findUserFieldByAttrs() || findUserFieldNearPassword(passEl) ||
Array.from(document.querySelectorAll('input[type="text"], input:not([type])'))
.find(isVisible) || null;
}
function findPassField() {
const list = document.querySelectorAll('input[type="password"]');
for (const el of list) {
if (isVisible(el)) return el;
}
return list[0] || null;
}
function setNativeValue(el, v) {
if (!el) return false;
if (el.value === v) return true;
const proto = Object.getPrototypeOf(el);
const desc = Object.getOwnPropertyDescriptor(proto, 'value') ||
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value');
if (desc && desc.set) desc.set.call(el, v); else el.value = v;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
function tryFill() {
if (userFilled && passFilled) return;
const p = findPassField();
const u = findUserField(p);
if (CREDS.password && p && !passFilled) {
if (setNativeValue(p, CREDS.password)) {
passFilled = true;
console.log('[PortalAutofill] password filled');
}
}
if (CREDS.login && u && !userFilled) {
if (setNativeValue(u, CREDS.login)) {
userFilled = true;
console.log('[PortalAutofill] user filled');
}
}
if (!CREDS.login) userFilled = true;
if (!CREDS.password) passFilled = true;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', tryFill);
} else {
tryFill();
}
const obs = new MutationObserver(() => {
if (!(userFilled && passFilled)) tryFill();
});
if (document.documentElement) {
obs.observe(document.documentElement, { childList: true, subtree: true });
}
const resetAndRefill = () => {
userFilled = !CREDS.login;
passFilled = !CREDS.password;
setTimeout(tryFill, 150);
};
['pushState', 'replaceState'].forEach(fn => {
const orig = history[fn];
history[fn] = function() { const r = orig.apply(this, arguments); resetAndRefill(); return r; };
});
window.addEventListener('popstate', resetAndRefill);
})();
"""
_AUTOFILL_MANIFEST = {
"manifest_version": 3,
"name": "Portal Autofill",
"version": "1.0",
"description": "Auto-fill credentials for portal session",
"background": {"service_worker": "background.js"},
"permissions": ["webRequest"],
"host_permissions": ["<all_urls>"],
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle",
"all_frames": True,
}
],
}
_AUTOFILL_BACKGROUND_JS = r"""
const CREDS = __CREDS__;
if (CREDS.login || CREDS.password) {
chrome.webRequest.onAuthRequired.addListener(
function(details, callback) {
callback({ authCredentials: { username: CREDS.login || '', password: CREDS.password || '' } });
},
{ urls: ['<all_urls>'] },
['asyncBlocking']
);
}
"""
def _create_autofill_extension(login: str, password: str) -> str | None:
if not login and not password:
return None
ext_dir = tempfile.mkdtemp(prefix="chrome-autofill-ext-")
creds_json = json.dumps({"login": login, "password": password})
content_js = _AUTOFILL_CONTENT_JS.replace("__CREDS__", creds_json)
background_js = _AUTOFILL_BACKGROUND_JS.replace("__CREDS__", creds_json)
with open(os.path.join(ext_dir, "manifest.json"), "w") as f:
json.dump(_AUTOFILL_MANIFEST, f)
with open(os.path.join(ext_dir, "content.js"), "w") as f:
f.write(content_js)
with open(os.path.join(ext_dir, "background.js"), "w") as f:
f.write(background_js)
return ext_dir
def _create_chrome_profile() -> str:
profile_dir = tempfile.mkdtemp(prefix="chrome-profile-")
default_dir = os.path.join(profile_dir, "Default")
os.makedirs(default_dir, exist_ok=True)
prefs = {
"intl": {"accept_languages": "ru-RU,ru,en", "selected_languages": "ru-RU,ru"},
"translate": {"enabled": False},
"translate_blocked_languages": ["ru"],
}
with open(os.path.join(default_dir, "Preferences"), "w") as f:
json.dump(prefs, f)
return profile_dir
def _stop_current() -> None: def _stop_current() -> None:
proc = _state.get("proc") proc = _state.get("proc")
if not proc: if proc:
return
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
proc.wait(timeout=4)
except Exception:
try: try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL) os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
proc.wait(timeout=4)
except Exception: except Exception:
pass try:
finally: os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
_state["proc"] = None except Exception:
pass
finally:
_state["proc"] = None
for key in ("profile_dir", "extension_dir"):
path = _state.get(key)
if path and os.path.isdir(path):
shutil.rmtree(path, ignore_errors=True)
_state[key] = None
def _start_process(cmd: list[str], mode: str, target: str) -> None: def _start_process(cmd: list[str], mode: str, target: str) -> None:
@@ -62,32 +264,104 @@ def _sanitize_resolution(width: int | None, height: int | None) -> tuple[int, in
return default_w, default_h return default_w, default_h
except Exception: except Exception:
return 1920, 1080 return 1920, 1080
safe_w = max(RESOLUTION_MIN_WIDTH, min(int(width), RESOLUTION_MAX_WIDTH)) safe_w = max(RESOLUTION_MIN_WIDTH, min(int(width), RESOLUTION_MAX_WIDTH))
safe_h = max(RESOLUTION_MIN_HEIGHT, min(int(height), RESOLUTION_MAX_HEIGHT)) safe_h = max(RESOLUTION_MIN_HEIGHT, min(int(height), RESOLUTION_MAX_HEIGHT))
return safe_w, safe_h return safe_w, safe_h
def apply_resolution(width: int | None, height: int | None) -> tuple[int, int]: def _xrandr_output_name() -> str | None:
safe_w, safe_h = _sanitize_resolution(width, height)
# Best effort: Xvfb usually exposes RandR and accepts xrandr -s.
try: try:
subprocess.run( # noqa: S603 out = subprocess.run(
["xrandr", "-display", DISPLAY, "-s", f"{safe_w}x{safe_h}"], ["xrandr", "-display", DISPLAY],
check=False, capture_output=True, text=True, check=False,
stdout=subprocess.DEVNULL, ).stdout
stderr=subprocess.DEVNULL, for line in out.splitlines():
) if " connected" in line:
return line.split()[0]
except Exception: except Exception:
pass pass
return None
def _add_mode_via_cvt(width: int, height: int, output_name: str) -> bool:
try:
cvt = subprocess.run(
["cvt", str(width), str(height)],
capture_output=True, text=True, check=False,
)
if cvt.returncode != 0:
return False
modeline_line = next(
(l for l in cvt.stdout.splitlines() if l.startswith("Modeline")), None
)
if not modeline_line:
return False
parts = modeline_line.split()
mode_name = parts[1].strip('"')
mode_params = parts[2:]
subprocess.run(
["xrandr", "-display", DISPLAY, "--newmode", mode_name] + mode_params,
check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
subprocess.run(
["xrandr", "-display", DISPLAY, "--addmode", output_name, mode_name],
check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
subprocess.run(
["xrandr", "-display", DISPLAY, "--output", output_name, "--mode", mode_name],
check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
return True
except Exception:
return False
def apply_resolution(width: int | None, height: int | None) -> tuple[int, int]:
safe_w, safe_h = _sanitize_resolution(width, height)
result = subprocess.run(
["xrandr", "-display", DISPLAY, "-s", f"{safe_w}x{safe_h}"],
check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
if result.returncode != 0:
output_name = _xrandr_output_name()
if output_name:
_add_mode_via_cvt(safe_w, safe_h, output_name)
_state["resolution"] = f"{safe_w},{safe_h}" _state["resolution"] = f"{safe_w},{safe_h}"
return safe_w, safe_h return safe_w, safe_h
def open_web(url: str, width: int | None = None, height: int | None = None) -> None: def _url_with_credentials(url: str, login: str, password: str) -> str:
"""Embed login:password into URL so Chromium auto-handles Basic Auth."""
if not login and not password:
return url
parsed = urlparse(url)
netloc = parsed.hostname or ""
if parsed.port:
netloc += f":{parsed.port}"
user_info = quote(login, safe="") + ":" + quote(password, safe="")
return urlunparse(parsed._replace(netloc=f"{user_info}@{netloc}"))
def open_web(
url: str,
width: int | None = None,
height: int | None = None,
login: str = "",
password: str = "",
) -> None:
safe_w, safe_h = apply_resolution(width, height) safe_w, safe_h = apply_resolution(width, height)
profile_dir = _create_chrome_profile()
extension_dir = _create_autofill_extension(login, password)
# Embed credentials in URL for HTTP Basic Auth (no dialog shown)
url_with_creds = _url_with_credentials(url, login, password)
# Use the real Chromium binary directly to avoid the Debian wrapper which
# injects an empty `--load-extension=` from /etc/chromium.d/extensions.
chromium_bin = "/usr/lib/chromium/chromium"
if not os.path.isfile(chromium_bin):
chromium_bin = "chromium"
cmd = [ cmd = [
"chromium", chromium_bin,
"--no-sandbox", "--no-sandbox",
"--disable-dev-shm-usage", "--disable-dev-shm-usage",
"--disable-gpu", "--disable-gpu",
@@ -103,9 +377,21 @@ def open_web(url: str, width: int | None = None, height: int | None = None) -> N
f"--window-size={safe_w},{safe_h}", f"--window-size={safe_w},{safe_h}",
"--no-first-run", "--no-first-run",
"--no-default-browser-check", "--no-default-browser-check",
url, "--enable-logging=stderr",
"--v=0",
"--lang=ru-RU",
"--accept-lang=ru-RU,ru",
"--password-store=basic",
f"--user-data-dir={profile_dir}",
] ]
if extension_dir:
cmd.append(f"--load-extension={extension_dir}")
cmd.append(f"--disable-extensions-except={extension_dir}")
cmd.append(url_with_creds)
_start_process(cmd, "web", url) _start_process(cmd, "web", url)
_state["profile_dir"] = profile_dir
_state["extension_dir"] = extension_dir
def open_rdp(payload: dict) -> None: def open_rdp(payload: dict) -> None:
@@ -183,8 +469,10 @@ class Handler(BaseHTTPRequestHandler):
return return
width = data.get("width") width = data.get("width")
height = data.get("height") height = data.get("height")
login = (data.get("login") or "").strip()
password = (data.get("password") or "").strip()
with _lock: with _lock:
open_web(url, width=width, height=height) open_web(url, width=width, height=height, login=login, password=password)
self._json( self._json(
200, 200,
{ {