Compare commits
132 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 18bb4bfaf4 | |||
| 2a494dfa5e | |||
| 8bf6c44485 | |||
| dff17ad56a | |||
| 0d22003716 | |||
| 986dcf5c84 | |||
| efa1c26e5d | |||
| e5ea23487e | |||
| ad1e781040 | |||
| 4a16813942 | |||
| 202b609b3e | |||
| 56cc2495a6 | |||
| 59bfb66ae4 | |||
| 7918c16a59 | |||
| de49bffc1b | |||
| 7765d666ef | |||
| 46cc29fd4a | |||
| 1c4f351f10 | |||
| 9d2a25af10 | |||
| a10f2c240a | |||
| 823b28983c | |||
| 984f8c324f | |||
| e88e33e7e8 | |||
| 9de7538309 | |||
| df12c54c76 | |||
| 8ab7df12a1 | |||
| dd7288beaf | |||
| 5c06440e4d | |||
| 3d531238d7 | |||
| 4b2618191d | |||
| a4b69b0018 | |||
| 73c7d006c7 | |||
| 1aa9db8e2a | |||
| 4b5b9906a8 | |||
| d65b7a0d35 | |||
| a60279ae3e | |||
| b36b3f6325 | |||
| ba8f3cf753 | |||
| eb05bcac53 | |||
| beb2781123 | |||
| a0b1754ddb | |||
| ce39573618 | |||
| f740420a77 | |||
| 9530f3e957 | |||
| 3e640fbe15 | |||
| eda342cf43 | |||
| e8d1515f89 | |||
| 4f52ae8566 | |||
| 30ce37b906 | |||
| 4268b19a37 | |||
| 6aa40eb5c2 | |||
| dedf4aea77 | |||
| fff7ecdce2 | |||
| 666093f1c6 | |||
| 020793a3e2 | |||
| 55da535f44 | |||
| d7716fa569 | |||
| 116ffba42d | |||
| b9f1e375d3 | |||
| e516cc4aeb | |||
| 52cb1fd3d6 | |||
| 1dc5a0eb34 | |||
| 983065ac9f | |||
| 7e94ddaf8d | |||
| 2edb804660 | |||
| f994674327 | |||
| a44422f43b | |||
| a137729704 | |||
| bbe1e27582 | |||
| 16c06ac166 | |||
| 0b37d5245c | |||
| 535d71709e | |||
| 045b21c514 | |||
| d8f9f4c87f | |||
| d7c3b35502 | |||
| 4dec5a09ce | |||
| 204bb02011 | |||
| dddeb26946 | |||
| bff5ffac1c | |||
| b838c814ba | |||
| 359a0c7636 | |||
| 3d8ccd30b6 | |||
| dc90569631 | |||
| fc3a4c6efb | |||
| ccf7401f71 | |||
| 34972af7c0 | |||
| 96b7dff7cd | |||
| 38dc206f5a | |||
| fb4af8cfe6 | |||
| 4ab49cd10f | |||
| 58cb8b1035 | |||
| 82024a36c4 | |||
| b8dd023233 | |||
| 1c7caec021 | |||
| c8c77048c7 | |||
| 9bd38ed6db | |||
| d57acb416b | |||
| cf68bc848f | |||
| be65be8fdb | |||
| 23c1f6e342 | |||
| d7c956e10b | |||
| 3f20fe5991 | |||
| 154ec35384 | |||
| 8f3617afdd | |||
| beb6828520 | |||
| 4cc19b32d8 | |||
| 2d65d98116 | |||
| 1ab5af28b5 | |||
| bfcf5f565b | |||
| af02c0d059 | |||
| 530d901a45 | |||
| 7a7c6e30e3 | |||
| 6ccba89216 | |||
| a64d49a8c1 | |||
| b06620a793 | |||
| b9d13733c9 | |||
| fa88f7f4e4 | |||
| b951f6c68e | |||
| d0ff949828 | |||
| a4a96c45b0 | |||
| 552898e3e9 | |||
| 6847cbc078 | |||
| 67d361c5c9 | |||
| 56cf2d6830 | |||
| 6f17193312 | |||
| 419b495020 | |||
| 445d025de2 | |||
| d927e1c947 | |||
| bd4350b2e0 | |||
| 40fd9ac64d | |||
| 6871ea6b67 | |||
| ebc5c12a23 |
@@ -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
|
||||
@@ -15,3 +15,4 @@ PROJECT_CONTEXT.md
|
||||
*.bak*
|
||||
*.env.bak*
|
||||
docs/CONTEXT_TEST.md
|
||||
app/static/service-icons/
|
||||
|
||||
@@ -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`
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import secrets
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from itsdangerous import BadSignature, URLSafeTimedSerializer
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from config import COOKIE_MAX_AGE, COOKIE_NAME, CSRF_COOKIE
|
||||
from database import get_db
|
||||
from models import User, UserServiceAccess
|
||||
from utils import now_utc
|
||||
from sqlalchemy import select
|
||||
|
||||
import os
|
||||
|
||||
_SIGNING_KEY = os.getenv("SIGNING_KEY", secrets.token_urlsafe(32))
|
||||
serializer = URLSafeTimedSerializer(_SIGNING_KEY, salt="portal-auth")
|
||||
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
return pwd_context.verify(password, password_hash)
|
||||
|
||||
|
||||
def user_is_valid(user: User) -> bool:
|
||||
return bool(user.active and user.expires_at > now_utc())
|
||||
|
||||
|
||||
def issue_auth_cookie(response: RedirectResponse, user: User) -> None:
|
||||
token = serializer.dumps({"user_id": user.id})
|
||||
response.set_cookie(
|
||||
key=COOKIE_NAME,
|
||||
value=token,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="strict",
|
||||
max_age=COOKIE_MAX_AGE,
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def issue_csrf_cookie(response: RedirectResponse) -> str:
|
||||
token = secrets.token_urlsafe(24)
|
||||
response.set_cookie(
|
||||
key=CSRF_COOKIE,
|
||||
value=token,
|
||||
httponly=False,
|
||||
secure=True,
|
||||
samesite="lax",
|
||||
max_age=COOKIE_MAX_AGE,
|
||||
path="/",
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def get_current_user(request: Request, db: Session = Depends(get_db)) -> Optional[User]:
|
||||
raw = request.cookies.get(COOKIE_NAME)
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
payload = serializer.loads(raw, max_age=COOKIE_MAX_AGE)
|
||||
except BadSignature:
|
||||
return None
|
||||
user = db.get(User, int(payload["user_id"]))
|
||||
if not user or not user_is_valid(user):
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
def require_user(user: Optional[User] = Depends(get_current_user)) -> User:
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized")
|
||||
return user
|
||||
|
||||
|
||||
def require_admin(user: User = Depends(require_user)) -> User:
|
||||
if not user.is_admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin only")
|
||||
return user
|
||||
|
||||
|
||||
def validate_csrf(request: Request) -> None:
|
||||
cookie = request.cookies.get(CSRF_COOKIE)
|
||||
form_val = request.headers.get("X-CSRF-Token")
|
||||
if request.headers.get("content-type", "").startswith("application/x-www-form-urlencoded"):
|
||||
return
|
||||
if not cookie or not form_val or cookie != form_val:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="CSRF failed")
|
||||
|
||||
|
||||
def has_access(db: Session, user_id: int, service_id: int) -> bool:
|
||||
q = select(UserServiceAccess).where(
|
||||
UserServiceAccess.user_id == user_id,
|
||||
UserServiceAccess.service_id == service_id,
|
||||
)
|
||||
return db.scalar(q) is not None
|
||||
@@ -0,0 +1,47 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+psycopg2://portal:portal@db:5432/portal")
|
||||
COOKIE_NAME = "portal_auth"
|
||||
CSRF_COOKIE = "csrf_token"
|
||||
COOKIE_MAX_AGE = 8 * 60 * 60
|
||||
SESSION_IDLE_SECONDS = int(os.getenv("SESSION_IDLE_SECONDS", "7200"))
|
||||
PUBLIC_HOST = os.getenv("PUBLIC_HOST", "stend.4mont.ru")
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
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_POOL_LOCK_TIMEOUT_SECONDS = float(os.getenv("GO_POOL_LOCK_TIMEOUT_SECONDS", "20.0"))
|
||||
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_SLEEP_SECONDS = float(os.getenv("POOL_DISPATCH_SLEEP_SECONDS", "0.3"))
|
||||
TRAEFIK_INTERNAL_URL = os.getenv("TRAEFIK_INTERNAL_URL", "http://traefik")
|
||||
PREWARM_POOL_SIZE = int(os.getenv("PREWARM_POOL_SIZE", "2"))
|
||||
UNIVERSAL_POOL_SIZE = int(os.getenv("UNIVERSAL_POOL_SIZE", "0"))
|
||||
WEB_POOL_SIZE = int(os.getenv("WEB_POOL_SIZE", "20"))
|
||||
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"))
|
||||
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_MAX_WIDTH = int(os.getenv("WEB_RESOLUTION_MAX_WIDTH", "3840"))
|
||||
WEB_RESOLUTION_MAX_HEIGHT = int(os.getenv("WEB_RESOLUTION_MAX_HEIGHT", "2160"))
|
||||
ENABLE_STARTUP_MAINTENANCE = os.getenv("ENABLE_STARTUP_MAINTENANCE", "1") == "1"
|
||||
ICON_UPLOAD_MAX_BYTES = 2 * 1024 * 1024
|
||||
ICON_UPLOAD_TYPES = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/webp": "webp",
|
||||
}
|
||||
SERVICE_ICONS_DIR = Path("static/service-icons")
|
||||
|
||||
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
|
||||
TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "")
|
||||
TELEGRAM_API_URL = os.getenv("TELEGRAM_API_URL", "https://api.telegram.org/bot")
|
||||
|
||||
SMTP_HOST = os.getenv("SMTP_HOST", "mail.hosting.reg.ru")
|
||||
SMTP_PORT = int(os.getenv("SMTP_PORT", "465"))
|
||||
SMTP_USERNAME = os.getenv("SMTP_USERNAME", "")
|
||||
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
|
||||
SMTP_FROM_EMAIL = os.getenv("SMTP_FROM_EMAIL", "stand@4mont.ru")
|
||||
SMTP_FROM_NAME = os.getenv("SMTP_FROM_NAME", "\u0418\u043d\u0444\u0440\u0430\u0441\u0442\u0443\u043a\u0442\u0443\u0440\u043d\u044b\u0439 \u043f\u043e\u043b\u0438\u0433\u043e\u043d MONT")
|
||||
PORTAL_URL = os.getenv("PORTAL_URL", "https://stend.4mont.ru")
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
# SQLite in-memory for tests — no PostgreSQL needed
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///./test.db")
|
||||
os.environ.setdefault("SIGNING_KEY", "test-signing-key-32chars-padding!!")
|
||||
os.environ.setdefault("ADMIN_USERNAME", "admin")
|
||||
os.environ.setdefault("ADMIN_PASSWORD", "testpass123")
|
||||
os.environ.setdefault("PUBLIC_HOST", "http://localhost")
|
||||
os.environ.setdefault("ENABLE_STARTUP_MAINTENANCE", "0")
|
||||
os.environ.setdefault("LOG_LEVEL", "ERROR")
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
# Patch docker before importing app modules
|
||||
_docker_mock = MagicMock()
|
||||
_docker_mock.containers.get.side_effect = Exception("no docker in tests")
|
||||
_docker_mock.containers.list.return_value = []
|
||||
_docker_mock.containers.run.return_value = MagicMock(id="test-container-id", status="running", name="test")
|
||||
|
||||
sys.modules.setdefault("docker", MagicMock())
|
||||
|
||||
with patch("docker.from_env", return_value=_docker_mock):
|
||||
with patch("runtime.ensure_schema_compatibility", lambda: None):
|
||||
from database import Base, get_db
|
||||
import main as app_module
|
||||
|
||||
engine = create_engine(
|
||||
"sqlite:///./test.db",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
TestingSessionLocal = sessionmaker(bind=engine)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# Create admin user
|
||||
from auth import hash_password
|
||||
from models import User
|
||||
with TestingSessionLocal() as db:
|
||||
if not db.query(User).filter(User.username == "admin").first():
|
||||
import datetime as _dt
|
||||
db.add(User(
|
||||
username="admin",
|
||||
password_hash=hash_password("testpass123"),
|
||||
is_admin=True,
|
||||
active=True,
|
||||
expires_at=_dt.datetime(2099, 1, 1, tzinfo=_dt.timezone.utc),
|
||||
))
|
||||
db.commit()
|
||||
|
||||
|
||||
def override_get_db():
|
||||
db = TestingSessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
app_module.app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def client():
|
||||
with patch("runtime.docker_client", return_value=_docker_mock), \
|
||||
patch("runtime.ensure_schema_compatibility", lambda: None):
|
||||
with TestClient(app_module.app, raise_server_exceptions=False, base_url="https://testserver") as c:
|
||||
yield c
|
||||
|
||||
|
||||
def _extract_csrf(client) -> str:
|
||||
"""GET / → берём CSRF из HTML и ставим куку вручную."""
|
||||
import re
|
||||
r = client.get("/", follow_redirects=True)
|
||||
assert r.status_code == 200
|
||||
m = re.search(r'name=["\']csrf_token["\'][^>]*value=["\']([^"\']+)["\']'
|
||||
r'|value=["\']([^"\']+)["\'][^>]*name=["\']csrf_token["\']', r.text)
|
||||
if not m:
|
||||
m = re.search(r'csrf_token["\']?\s*[=:]\s*["\']([^"\']{10,})["\']', r.text)
|
||||
assert m, f"csrf_token not found in HTML: {r.text[:500]}"
|
||||
csrf = m.group(1) or m.group(2)
|
||||
client.cookies.set("portal_csrf", csrf, domain="testserver")
|
||||
return csrf
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def auth_client(client):
|
||||
"""Client with admin session cookie."""
|
||||
csrf = _extract_csrf(client)
|
||||
r = client.post("/login", data={
|
||||
"username": "admin",
|
||||
"password": "testpass123",
|
||||
"csrf_token": csrf,
|
||||
}, follow_redirects=True)
|
||||
assert r.status_code == 200, f"login failed: {r.status_code} {r.text[:300]}"
|
||||
return client
|
||||
@@ -0,0 +1,20 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||
|
||||
from config import DATABASE_URL
|
||||
|
||||
|
||||
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
+1027
-1569
File diff suppressed because one or more lines are too long
@@ -0,0 +1,212 @@
|
||||
import datetime as dt
|
||||
import fcntl
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
import docker
|
||||
from sqlalchemy import select
|
||||
|
||||
from config import ENABLE_STARTUP_MAINTENANCE, SESSION_IDLE_SECONDS, WEB_POOL_SIZE
|
||||
from database import Base, SessionLocal, engine
|
||||
from models import RdpSlot, Service, ServiceType, SessionModel, SessionStatus, User
|
||||
from utils import ensure_icons_dir, now_utc
|
||||
from auth import hash_password
|
||||
from runtime import (
|
||||
_rdp_slot_container_name,
|
||||
disconnect_rdp_slot,
|
||||
docker_client,
|
||||
ensure_schema_compatibility,
|
||||
ensure_universal_pool,
|
||||
ensure_warm_pool,
|
||||
ensure_web_pool,
|
||||
start_rdp_slot_container,
|
||||
stop_runtime_container,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("portal")
|
||||
maintenance_lock_file = None
|
||||
|
||||
|
||||
def cleanup_loop():
|
||||
while True:
|
||||
time.sleep(60)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ensure_universal_pool()
|
||||
ensure_web_pool()
|
||||
for svc in db.scalars(
|
||||
select(Service).where(
|
||||
Service.active == True,
|
||||
Service.type.in_([ServiceType.WEB, ServiceType.RDP]),
|
||||
)
|
||||
).all():
|
||||
if svc.type == ServiceType.WEB and WEB_POOL_SIZE <= 0:
|
||||
ensure_warm_pool(svc)
|
||||
cutoff = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS)
|
||||
q = select(SessionModel).where(
|
||||
SessionModel.status == SessionStatus.ACTIVE,
|
||||
SessionModel.last_access_at < cutoff,
|
||||
)
|
||||
stale = db.scalars(q).all()
|
||||
rdp_slots_to_restart: list[int] = []
|
||||
for sess in stale:
|
||||
cid = sess.container_id or ""
|
||||
if cid.startswith("RDPSLOT:"):
|
||||
try:
|
||||
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(cid)
|
||||
sess.status = SessionStatus.EXPIRED
|
||||
if stale:
|
||||
db.commit()
|
||||
for slot_id in rdp_slots_to_restart:
|
||||
threading.Thread(target=disconnect_rdp_slot, args=(slot_id,), daemon=True).start()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("cleanup_loop_failed")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def bootstrap_admin():
|
||||
admin_user = os.getenv("ADMIN_USERNAME", "admin")
|
||||
admin_password = os.getenv("ADMIN_PASSWORD", "change_me")
|
||||
ttl_days = int(os.getenv("ADMIN_TTL_DAYS", "3650"))
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
existing = db.scalar(select(User).where(User.username == admin_user))
|
||||
if not existing:
|
||||
db.add(
|
||||
User(
|
||||
username=admin_user,
|
||||
password_hash=hash_password(admin_password),
|
||||
active=True,
|
||||
is_admin=True,
|
||||
expires_at=now_utc() + dt.timedelta(days=ttl_days),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def try_acquire_maintenance_leader() -> bool:
|
||||
global maintenance_lock_file
|
||||
if maintenance_lock_file is not None:
|
||||
return True
|
||||
lock_file = open("/tmp/portal-maintenance.lock", "w")
|
||||
try:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
lock_file.close()
|
||||
return False
|
||||
maintenance_lock_file = lock_file
|
||||
return True
|
||||
|
||||
|
||||
def run_maintenance_service() -> None:
|
||||
logger.info("maintenance_service_bootstrap_started")
|
||||
with open("/tmp/portal-schema.lock", "w") as lock_file:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
ensure_schema_compatibility()
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
ensure_icons_dir()
|
||||
bootstrap_admin()
|
||||
|
||||
maintenance_lock = open("/tmp/portal-maintenance.lock", "w")
|
||||
fcntl.flock(maintenance_lock.fileno(), fcntl.LOCK_EX)
|
||||
logger.info("maintenance_service_leader_acquired")
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ensure_universal_pool()
|
||||
ensure_web_pool()
|
||||
for svc in db.scalars(
|
||||
select(Service).where(
|
||||
Service.active == True,
|
||||
Service.type.in_([ServiceType.WEB, ServiceType.RDP]),
|
||||
)
|
||||
).all():
|
||||
if svc.type == ServiceType.WEB and WEB_POOL_SIZE <= 0:
|
||||
ensure_warm_pool(svc)
|
||||
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()
|
||||
|
||||
logger.info("maintenance_service_loop_started")
|
||||
cleanup_loop()
|
||||
|
||||
|
||||
def on_startup() -> None:
|
||||
with open("/tmp/portal-schema.lock", "w") as lock_file:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
ensure_schema_compatibility()
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||
ensure_icons_dir()
|
||||
bootstrap_admin()
|
||||
if not try_acquire_maintenance_leader():
|
||||
logger.info("maintenance_leader_skipped")
|
||||
return
|
||||
|
||||
if ENABLE_STARTUP_MAINTENANCE:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ensure_universal_pool()
|
||||
ensure_web_pool()
|
||||
for svc in db.scalars(
|
||||
select(Service).where(
|
||||
Service.active == True,
|
||||
Service.type.in_([ServiceType.WEB, ServiceType.RDP]),
|
||||
)
|
||||
).all():
|
||||
if svc.type == ServiceType.WEB and WEB_POOL_SIZE <= 0:
|
||||
ensure_warm_pool(svc)
|
||||
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.start()
|
||||
logger.info("maintenance_leader_started")
|
||||
@@ -1,5 +1,4 @@
|
||||
import main
|
||||
|
||||
import maintenance
|
||||
|
||||
if __name__ == "__main__":
|
||||
main.run_maintenance_service()
|
||||
maintenance.run_maintenance_service()
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import datetime as dt
|
||||
import enum
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean, DateTime, Enum, ForeignKey, Integer, String, Text, UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from database import Base
|
||||
|
||||
|
||||
class ServiceType(str, enum.Enum):
|
||||
WEB = "WEB"
|
||||
VNC = "VNC"
|
||||
RDP = "RDP"
|
||||
|
||||
|
||||
class SessionStatus(str, enum.Enum):
|
||||
ACTIVE = "ACTIVE"
|
||||
EXPIRED = "EXPIRED"
|
||||
TERMINATED = "TERMINATED"
|
||||
ROTATED = "ROTATED"
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
expires_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
first_name: Mapped[str] = mapped_column(String(64), default="")
|
||||
last_name: Mapped[str] = mapped_column(String(64), default="")
|
||||
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
|
||||
|
||||
|
||||
class Service(Base):
|
||||
__tablename__ = "services"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128))
|
||||
slug: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
type: Mapped[ServiceType] = mapped_column(Enum(ServiceType), index=True)
|
||||
target: Mapped[str] = mapped_column(Text)
|
||||
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="")
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
warm_pool_size: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
|
||||
|
||||
|
||||
class Category(Base):
|
||||
__tablename__ = "categories"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
slug: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
|
||||
|
||||
|
||||
class ServiceCategory(Base):
|
||||
__tablename__ = "service_categories"
|
||||
__table_args__ = (UniqueConstraint("service_id", "category_id", name="uq_service_category"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
service_id: Mapped[int] = mapped_column(ForeignKey("services.id", ondelete="CASCADE"), index=True)
|
||||
category_id: Mapped[int] = mapped_column(ForeignKey("categories.id", ondelete="CASCADE"), index=True)
|
||||
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
|
||||
|
||||
|
||||
class UserServiceAccess(Base):
|
||||
__tablename__ = "user_service_access"
|
||||
__table_args__ = (UniqueConstraint("user_id", "service_id", name="uq_user_service"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
service_id: Mapped[int] = mapped_column(ForeignKey("services.id", ondelete="CASCADE"), index=True)
|
||||
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):
|
||||
__tablename__ = "sessions"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
service_id: Mapped[int] = mapped_column(ForeignKey("services.id", ondelete="CASCADE"), index=True)
|
||||
status: Mapped[SessionStatus] = mapped_column(Enum(SessionStatus), default=SessionStatus.ACTIVE, index=True)
|
||||
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc), index=True)
|
||||
last_access_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc), index=True)
|
||||
container_id: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
user_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
|
||||
action: Mapped[str] = mapped_column(String(128), index=True)
|
||||
details: Mapped[str] = mapped_column(Text)
|
||||
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc), index=True)
|
||||
|
||||
|
||||
class PendingAccessRequest(Base):
|
||||
__tablename__ = "pending_access_requests"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(12), primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(256))
|
||||
company: Mapped[str] = mapped_column(String(256))
|
||||
email: Mapped[str] = mapped_column(String(256))
|
||||
phone: Mapped[str] = mapped_column(String(64))
|
||||
manager: Mapped[str] = mapped_column(String(256), default="")
|
||||
products_json: Mapped[str] = mapped_column(Text, default="[]")
|
||||
portal_url: Mapped[str] = mapped_column(String(256), default="")
|
||||
status: Mapped[str] = mapped_column(String(16), default="pending")
|
||||
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
|
||||
@@ -7,3 +7,4 @@ jinja2==3.1.6
|
||||
passlib[argon2]==1.7.4
|
||||
docker==7.1.0
|
||||
itsdangerous==2.2.0
|
||||
mistune==3.1.3
|
||||
|
||||
+1163
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 13 KiB |
+21
-7
@@ -1,11 +1,25 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title desc">
|
||||
<title id="title">4MONT favicon</title>
|
||||
<desc id="desc">A compact favicon inspired by the 4MONT logo: a blue geometric 4 and bold black M on a clean rounded square.</desc>
|
||||
<defs>
|
||||
<linearGradient id='g' x1='0' y1='0' x2='1' y2='1'>
|
||||
<stop offset='0%' stop-color='#1e6aa8'/>
|
||||
<stop offset='100%' stop-color='#2f8ec8'/>
|
||||
<linearGradient id="blue" x1="5" y1="8" x2="38" y2="58" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#0C5CAD"/>
|
||||
<stop offset="0.45" stop-color="#004C92"/>
|
||||
<stop offset="1" stop-color="#002F62"/>
|
||||
</linearGradient>
|
||||
<filter id="softShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-color="#001A33" flood-opacity="0.16"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width='64' height='64' rx='14' fill='#eaf3fb'/>
|
||||
<rect x='14' y='14' width='36' height='36' transform='rotate(45 32 32)' fill='url(#g)'/>
|
||||
<rect x='34' y='9' width='14' height='14' transform='rotate(45 41 16)' fill='#b7c0c9'/>
|
||||
|
||||
<rect x="3" y="3" width="58" height="58" rx="14" fill="#FFFFFF"/>
|
||||
<rect x="3.5" y="3.5" width="57" height="57" rx="13.5" fill="none" stroke="#E6EAF0"/>
|
||||
|
||||
<g filter="url(#softShadow)">
|
||||
<!-- Stylized 4 -->
|
||||
<path fill="url(#blue)" d="M7 38.7 27.4 10.2h10.4v28.5h6.3v8.9h-6.3v7.3H27.9v-7.3H7v-8.9Zm20.9 0V25.2L18 38.7h9.9Z"/>
|
||||
|
||||
<!-- Compact M -->
|
||||
<path fill="#050505" d="M39.2 54.9V10.2h9.4l5.7 16.1 5.7-16.1h9.1v44.7h-8.7V30.2l-4.7 13.3h-3.1l-4.8-13.3v24.7h-8.6Z" transform="translate(-5.4 0)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 498 B After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,11 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Disallow: /admin
|
||||
Disallow: /api/
|
||||
Disallow: /go/
|
||||
Disallow: /s/
|
||||
Disallow: /w/
|
||||
Disallow: /u/
|
||||
Disallow: /rdp/
|
||||
|
||||
Sitemap: https://stend.4mont.ru/sitemap.xml
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://stend.4mont.ru/</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
+974
-122
File diff suppressed because it is too large
Load Diff
+149
-35
@@ -6,13 +6,25 @@
|
||||
<title>Администрирование</title>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
||||
<link rel="alternate icon" type="image/png" href="/static/favicon.png" />
|
||||
<link rel="icon" type="image/png" href="/static/favicon.png" />
|
||||
<!-- Yandex.Metrika counter -->
|
||||
<script type="text/javascript">
|
||||
(function(m,e,t,r,i,k,a){
|
||||
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
||||
m[i].l=1*new Date();
|
||||
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
|
||||
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
|
||||
})(window, document,"script","https://mc.yandex.ru/metrika/tag.js?id=109119977", "ym");
|
||||
ym(109119977, "init", {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
|
||||
</script>
|
||||
<noscript><div><img src="https://mc.yandex.ru/watch/109119977" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
|
||||
<!-- /Yandex.Metrika counter -->
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<div style="display:flex; align-items:center; gap:0.6rem;">
|
||||
<img src="/static/logo.png" alt="MONT" class="header-logo" />
|
||||
<div>МОНТ - инфрастуктурный полигон | Админ: {{ admin.username }}</div>
|
||||
<a href="https://4mont.ru"><img src="/static/logo.png?v=2" alt="MONT" class="header-logo" /></a>
|
||||
<div>MONT - инфрастуктурный полигон | Админ: {{ admin.username }}</div>
|
||||
</div>
|
||||
<a href="/" class="btn-link secondary">Главная панель</a>
|
||||
</header>
|
||||
@@ -42,8 +54,8 @@
|
||||
<input class="list-search" id="users_search" placeholder="Поиск пользователя..." oninput="filterList('users_search', '#users_list .user-item')" />
|
||||
<div class="list-box" id="users_list">
|
||||
{% for u in users %}
|
||||
<button class="list-item user-item" data-user-id="{{u.id}}" data-filter="{{u.username|lower}}" onclick='selectUser({{u.id}}, {{u.username|tojson}}, {{u.active|tojson}}, {{u.is_admin|tojson}}, {{u.expires_at.isoformat()|tojson}})'>
|
||||
<div>{{u.username}}</div>
|
||||
<button class="list-item user-item" data-user-id="{{u.id}}" data-filter="{{u.username|lower}}" onclick='selectUser({{u.id}}, {{u.username|tojson}}, {{u.active|tojson}}, {{u.is_admin|tojson}}, {{u.expires_at.isoformat()|tojson}}, {{u.first_name|tojson}}, {{u.last_name|tojson}})'>
|
||||
<div>{{u.username}}{% if u.first_name or u.last_name %} <small style="opacity:.6">— {{ (u.first_name + ' ' + u.last_name)|trim }}</small>{% endif %}</div>
|
||||
<small class="user-days" data-exp="{{u.expires_at.isoformat()}}"></small>
|
||||
</button>
|
||||
{% endfor %}
|
||||
@@ -55,6 +67,8 @@
|
||||
<div class="form-grid">
|
||||
<input id="u_id" type="hidden" />
|
||||
<input id="u_name" placeholder="username" />
|
||||
<input id="u_first_name" placeholder="Имя" />
|
||||
<input id="u_last_name" placeholder="Фамилия" />
|
||||
<input id="u_exp" type="date" required />
|
||||
<input id="u_pwd" placeholder="new password (optional)" type="password" />
|
||||
<select id="u_active"><option value="true">active</option><option value="false">inactive</option></select>
|
||||
@@ -70,7 +84,7 @@
|
||||
<div class="list-title">ACL выбранного пользователя</div>
|
||||
<div class="acl-grid">
|
||||
{% 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 %}
|
||||
</div>
|
||||
<button onclick="saveAclForSelectedUser()">Save ACL</button>
|
||||
@@ -80,6 +94,8 @@
|
||||
<div class="list-title">Добавить пользователя</div>
|
||||
<div class="form-grid">
|
||||
<input id="new_u_name" placeholder="username" />
|
||||
<input id="new_u_first_name" placeholder="Имя" />
|
||||
<input id="new_u_last_name" placeholder="Фамилия" />
|
||||
<input id="new_u_pwd" placeholder="password" type="password" />
|
||||
<input id="new_u_exp" type="date" required />
|
||||
<select id="new_u_active"><option value="true">active</option><option value="false">inactive</option></select>
|
||||
@@ -123,7 +139,7 @@
|
||||
<input class="list-search" id="web_search" placeholder="Поиск WEB сервиса..." oninput="filterList('web_search', '#web_list .web-item')" />
|
||||
<div class="list-box" id="web_list">
|
||||
{% 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" />
|
||||
<div>
|
||||
<div>{{s.name}}</div>
|
||||
@@ -158,6 +174,21 @@
|
||||
<textarea id="w_comment" placeholder="Коротко: что это за сервис"></textarea>
|
||||
</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">
|
||||
<span>Статус</span>
|
||||
<select id="w_active"><option value="true">active</option><option value="false">inactive</option></select>
|
||||
@@ -231,6 +262,9 @@
|
||||
<label class="field-col">
|
||||
<span>Описание для пользователя</span>
|
||||
<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 class="field-col">
|
||||
@@ -257,7 +291,7 @@
|
||||
<input class="list-search" id="rdp_search" placeholder="Поиск RDP сервиса..." oninput="filterList('rdp_search', '#rdp_list .rdp-item')" />
|
||||
<div class="list-box" id="rdp_list">
|
||||
{% 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" />
|
||||
<div>
|
||||
<div>{{s.name}}</div>
|
||||
@@ -277,19 +311,20 @@
|
||||
<input id="r_id" type="hidden" />
|
||||
<input id="r_name" placeholder="Название сервиса (что увидит user)" oninput="autogenSlug('r_name','r_slug')" />
|
||||
<input id="r_slug" placeholder="Системный slug" />
|
||||
<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_user" placeholder="Логин (опционально)" />
|
||||
<input id="r_pass" placeholder="Пароль (опционально)" type="password" />
|
||||
<input id="r_domain" placeholder="Домен (опционально)" />
|
||||
<select id="r_sec">
|
||||
<input id="r_host" placeholder="RDP host (например 192.168.1.60)" oninput="buildRdpTarget('r')" />
|
||||
<input id="r_port" type="number" min="1" max="65535" placeholder="RDP порт, обычно 3389" oninput="buildRdpTarget('r')" />
|
||||
<input id="r_domain" placeholder="Домен (опционально)" oninput="buildRdpTarget('r')" />
|
||||
<select id="r_sec" onchange="buildRdpTarget('r')">
|
||||
<option value="">auto</option>
|
||||
<option value="nla">nla</option>
|
||||
<option value="tls">tls</option>
|
||||
<option value="rdp">rdp</option>
|
||||
</select>
|
||||
<input id="r_target" placeholder="Собранный target (авто)" />
|
||||
<input id="r_target" placeholder="Собранный target (авто)" readonly style="background:rgba(255,255,255,.05);color:#888;cursor:default" />
|
||||
<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="Количество заранее прогретых слотов" />
|
||||
<select id="r_active"><option value="true">active</option><option value="false">inactive</option></select>
|
||||
</div>
|
||||
@@ -342,25 +377,40 @@
|
||||
</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>
|
||||
<div class="list-title">Добавить RDP</div>
|
||||
<div class="field-help">Для большинства кейсов достаточно host + user + password.</div>
|
||||
<div class="form-grid">
|
||||
<input id="new_r_name" placeholder="Название сервиса" oninput="autogenSlug('new_r_name','new_r_slug')" />
|
||||
<input id="new_r_slug" placeholder="Системный slug" />
|
||||
<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_user" placeholder="Логин (опционально)" />
|
||||
<input id="new_r_pass" placeholder="Пароль (опционально)" type="password" />
|
||||
<input id="new_r_domain" placeholder="Домен (опционально)" />
|
||||
<select id="new_r_sec">
|
||||
<input id="new_r_host" placeholder="RDP host" oninput="buildRdpTarget('new_r')" />
|
||||
<input id="new_r_port" type="number" min="1" max="65535" placeholder="RDP порт (3389)" oninput="buildRdpTarget('new_r')" />
|
||||
<input id="new_r_domain" placeholder="Домен (опционально)" oninput="buildRdpTarget('new_r')" />
|
||||
<select id="new_r_sec" onchange="buildRdpTarget('new_r')">
|
||||
<option value="">auto</option>
|
||||
<option value="nla">nla</option>
|
||||
<option value="tls">tls</option>
|
||||
<option value="rdp">rdp</option>
|
||||
</select>
|
||||
<input id="new_r_target" placeholder="Собранный target (авто)" />
|
||||
<input id="new_r_target" placeholder="Собранный target (авто)" readonly style="background:rgba(255,255,255,.05);color:#888;cursor:default" />
|
||||
<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="Количество прогретых слотов" />
|
||||
<select id="new_r_active"><option value="true">active</option><option value="false">inactive</option></select>
|
||||
</div>
|
||||
@@ -390,7 +440,7 @@
|
||||
<div>{{ c.name }}</div>
|
||||
<small>{{ c.slug }}</small>
|
||||
</div>
|
||||
<button onclick="deleteCategory({{ c.id }}, {{ c.name|tojson }})">Delete</button>
|
||||
<button onclick='deleteCategory({{ c.id }}, {{ c.name|tojson }})'>Delete</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="list-item">Категории пока не созданы</div>
|
||||
@@ -513,6 +563,7 @@
|
||||
const csrf = "{{ csrf_token }}";
|
||||
const aclMap = {{ acl | tojson }};
|
||||
const serviceCategoryMap = {{ service_category_map | tojson }};
|
||||
const rdpSlotsMap = {{ rdp_slots | tojson }};
|
||||
const placeholderIcon = '/static/service-placeholder.svg';
|
||||
let activeTab = 'users';
|
||||
|
||||
@@ -622,9 +673,11 @@
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function selectUser(id, username, active, isAdmin, expiresIso) {
|
||||
function selectUser(id, username, active, isAdmin, expiresIso, firstName, lastName) {
|
||||
document.getElementById('u_id').value = id;
|
||||
document.getElementById('u_name').value = username;
|
||||
document.getElementById('u_first_name').value = firstName || '';
|
||||
document.getElementById('u_last_name').value = lastName || '';
|
||||
document.getElementById('u_exp').value = dateFromIso(expiresIso);
|
||||
document.getElementById('u_pwd').value = '';
|
||||
document.getElementById('u_active').value = String(active);
|
||||
@@ -638,6 +691,8 @@
|
||||
if (!expDate) return alert('Выберите дату деактивации');
|
||||
await api('/api/admin/users', 'POST', {
|
||||
username: document.getElementById('new_u_name').value,
|
||||
first_name: document.getElementById('new_u_first_name').value,
|
||||
last_name: document.getElementById('new_u_last_name').value,
|
||||
password: document.getElementById('new_u_pwd').value,
|
||||
expires_at: expiryToApi(expDate),
|
||||
active: document.getElementById('new_u_active').value === 'true',
|
||||
@@ -653,6 +708,8 @@
|
||||
if (!expDate) return alert('Выберите дату деактивации');
|
||||
const payload = {
|
||||
username: document.getElementById('u_name').value,
|
||||
first_name: document.getElementById('u_first_name').value,
|
||||
last_name: document.getElementById('u_last_name').value,
|
||||
expires_at: expiryToApi(expDate),
|
||||
active: document.getElementById('u_active').value === 'true',
|
||||
is_admin: document.getElementById('u_admin').value === 'true',
|
||||
@@ -683,7 +740,10 @@
|
||||
const userId = parseInt(document.getElementById('u_id').value || '0', 10);
|
||||
const allowed = new Set((aclMap[userId] || []));
|
||||
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 +755,15 @@
|
||||
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_name').value = name;
|
||||
document.getElementById('w_slug').value = slug;
|
||||
document.getElementById('w_target').value = target;
|
||||
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_icon_preview').src = iconPath || placeholderIcon;
|
||||
setCategoryChecks('.w_cat', serviceCategoryMap[id] || []);
|
||||
@@ -723,6 +786,9 @@
|
||||
type: 'WEB',
|
||||
target: document.getElementById('new_w_target').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'),
|
||||
active: document.getElementById('new_w_active').value === 'true',
|
||||
});
|
||||
@@ -739,6 +805,9 @@
|
||||
type: 'WEB',
|
||||
target: document.getElementById('w_target').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'),
|
||||
active: document.getElementById('w_active').value === 'true',
|
||||
});
|
||||
@@ -746,7 +815,7 @@
|
||||
}
|
||||
|
||||
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';
|
||||
setCategoryChecks('.w_cat', []);
|
||||
document.getElementById('w_icon_preview').src = placeholderIcon;
|
||||
@@ -777,23 +846,59 @@
|
||||
function buildRdpTarget(prefix) {
|
||||
const host = (document.getElementById(`${prefix}_host`)?.value || '').trim();
|
||||
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 sec = (document.getElementById(`${prefix}_sec`)?.value || '').trim();
|
||||
const targetInput = document.getElementById(`${prefix}_target`);
|
||||
if (!host) return (targetInput?.value || '').trim();
|
||||
const creds = user ? `${encodeURIComponent(user)}${pass ? `:${encodeURIComponent(pass)}` : ''}@` : '';
|
||||
const query = new URLSearchParams();
|
||||
if (domain) query.set('domain', domain);
|
||||
if (sec) query.set('sec', sec);
|
||||
const q = query.toString();
|
||||
const target = `rdp://${creds}${host}:${port}${q ? `?${q}` : ''}`;
|
||||
const target = `rdp://${host}:${port}${q ? `?${q}` : ''}`;
|
||||
if (targetInput) targetInput.value = 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">● running</span>'
|
||||
: '<span style="color:#e07b39">● 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);
|
||||
document.getElementById('r_id').value = id;
|
||||
document.getElementById('r_name').value = name;
|
||||
@@ -801,17 +906,19 @@
|
||||
document.getElementById('r_target').value = target;
|
||||
document.getElementById('r_host').value = cfg.host;
|
||||
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_sec').value = cfg.sec;
|
||||
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_pool').value = pool;
|
||||
setCategoryChecks('.r_cat', serviceCategoryMap[id] || []);
|
||||
document.getElementById('r_icon_preview').src = iconPath || placeholderIcon;
|
||||
document.getElementById('r_health_box').style.display = 'block';
|
||||
markSelected('.rdp-item', 'data-service-id', id);
|
||||
renderRdpSlots(id);
|
||||
refreshSelectedServiceStatus('rdp');
|
||||
}
|
||||
|
||||
@@ -824,6 +931,9 @@
|
||||
type: 'RDP',
|
||||
target,
|
||||
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'),
|
||||
warm_pool_size: parseInt(document.getElementById('new_r_pool').value || '0', 10),
|
||||
active: document.getElementById('new_r_active').value === 'true',
|
||||
@@ -841,6 +951,9 @@
|
||||
type: 'RDP',
|
||||
target,
|
||||
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'),
|
||||
warm_pool_size: parseInt(document.getElementById('r_pool').value || '0', 10),
|
||||
active: document.getElementById('r_active').value === 'true',
|
||||
@@ -849,7 +962,8 @@
|
||||
}
|
||||
|
||||
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_active').value = 'true';
|
||||
setCategoryChecks('.r_cat', []);
|
||||
|
||||
+126
-21
@@ -3,29 +3,66 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>МОНТ - инфрастуктурный полигон</title>
|
||||
<title>MONT - инфрастуктурный полигон</title>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
||||
<link rel="alternate icon" type="image/png" href="/static/favicon.png" />
|
||||
<link rel="icon" type="image/png" href="/static/favicon.png" />
|
||||
<!-- Yandex.Metrika counter -->
|
||||
<script type="text/javascript">
|
||||
(function(m,e,t,r,i,k,a){
|
||||
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
||||
m[i].l=1*new Date();
|
||||
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
|
||||
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
|
||||
})(window, document,"script","https://mc.yandex.ru/metrika/tag.js?id=109119977", "ym");
|
||||
ym(109119977, "init", {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
|
||||
</script>
|
||||
<noscript><div><img src="https://mc.yandex.ru/watch/109119977" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
|
||||
<!-- /Yandex.Metrika counter -->
|
||||
</head>
|
||||
<body class="dashboard-page">
|
||||
<header class="header">
|
||||
<div style="display:flex; align-items:center; gap:0.6rem;">
|
||||
<img src="/static/logo.png" alt="MONT" class="header-logo" />
|
||||
<div>{{ user.username }}</div>
|
||||
{% 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">
|
||||
<a href="https://4mont.ru"><img src="/static/logo.png?v=2" alt="MONT" style="position:absolute;top:1.2rem;left:50%;transform:translateX(-50%);height:clamp(4rem,16vw,6rem);opacity:.9"></a>
|
||||
<div class="mw-icon">🖥️</div>
|
||||
<div class="mw-title">Только для компьютера</div>
|
||||
<div class="mw-sub">Инфраструктурный полигон MONT оптимизирован для работы на ПК.<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 style="display:flex; gap:0.5rem;">
|
||||
<div class="mw-footer"><a href="mailto:ruslan@ipcom.su" style="color:inherit;text-decoration:none">Made by Galyaviev</a></div>
|
||||
</div>
|
||||
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<div class="user-avatar">{{ ((user.first_name[0] if user.first_name else user.username[0]) + (user.last_name[0] if user.last_name else ''))|upper }}</div>
|
||||
<span class="header-username">{{ (user.first_name + ' ' + user.last_name)|trim or user.username }}</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
{% if user.is_admin %}
|
||||
<a href="/admin" class="btn-link secondary">Администрирование</a>
|
||||
<a href="/admin" class="header-btn">Администрирование</a>
|
||||
{% endif %}
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit">Выход</button>
|
||||
<button type="submit" class="header-btn header-btn-logout">Выход</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
<div class="page-logo-wrap">
|
||||
<a href="https://4mont.ru"><img src="/static/logo.png?v=2" alt="MONT" class="page-logo" /></a>
|
||||
</div>
|
||||
<main class="admin-layout">
|
||||
<section class="panel">
|
||||
<div class="admin-intro">Добро пожаловать в инфраструктурную песочницу</div>
|
||||
<div class="admin-intro">Инфраструктурный полигон MONT</div>
|
||||
{% if session_notice %}
|
||||
<div class="session-notice">{{ session_notice }}</div>
|
||||
{% endif %}
|
||||
@@ -55,15 +92,37 @@
|
||||
<section class="grid service-grid">
|
||||
{% for service in services %}
|
||||
{% set svc_cats = service_categories.get(service.id, []) %}
|
||||
<a class="tile" href="/go/{{ service.slug }}">
|
||||
<div class="tile-wrap">
|
||||
<a class="tile-link" href="/go/{{ service.slug }}" aria-label="{{ service.name }}"></a>
|
||||
<div class="tile">
|
||||
<div class="tile-icon-box">
|
||||
<img class="tile-icon" src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
|
||||
</div>
|
||||
<h3>{{ service.name }}</h3>
|
||||
<p>Открыть сервис</p>
|
||||
{% if service.comment %}
|
||||
<small class="tile-comment">{{ service_comment_html.get(service.id, '') }}</small>
|
||||
<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 %}
|
||||
@@ -71,7 +130,8 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="tile">
|
||||
{% if selected_category_slug %}
|
||||
@@ -82,8 +142,20 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
</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:ruslan@ipcom.su">Made by Galyaviev</a></footer>
|
||||
</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>
|
||||
(function () {
|
||||
const username = {{ user.username|tojson }};
|
||||
@@ -107,24 +179,57 @@
|
||||
}
|
||||
|
||||
function currentScreenParams() {
|
||||
const width = clamp(window.innerWidth || document.documentElement.clientWidth || 1280, 320, 7680);
|
||||
const height = clamp(window.innerHeight || document.documentElement.clientHeight || 720, 240, 4320);
|
||||
const screenWidth =
|
||||
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();
|
||||
sp.set('sw', String(width));
|
||||
sp.set('sh', String(height));
|
||||
return sp;
|
||||
}
|
||||
|
||||
const loadingOverlay = document.getElementById('loading-overlay');
|
||||
|
||||
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 {
|
||||
const url = new URL(link.getAttribute('href'), window.location.origin);
|
||||
const url = new URL(href, window.location.origin);
|
||||
const params = currentScreenParams();
|
||||
url.search = params.toString();
|
||||
link.setAttribute('href', url.pathname + '?' + url.searchParams.toString());
|
||||
href = url.pathname + '?' + url.searchParams.toString();
|
||||
} catch (e) {}
|
||||
if (loadingOverlay) loadingOverlay.style.display = 'flex';
|
||||
requestAnimationFrame(function () {
|
||||
requestAnimationFrame(function () {
|
||||
window.location.href = href;
|
||||
});
|
||||
});
|
||||
}, { capture: true });
|
||||
});
|
||||
|
||||
window.addEventListener('pageshow', function (e) {
|
||||
if (loadingOverlay) loadingOverlay.style.display = 'none';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
+498
-15
@@ -1,31 +1,514 @@
|
||||
{% set _scheme = request.headers.get('x-forwarded-proto', request.url.scheme) %}
|
||||
{% set base_url = _scheme + '://' + request.url.netloc + '/' %}
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>МОНТ - инфрастуктурный полигон</title>
|
||||
<title>Инфраструктурный полигон MONT — демо и пилоты российского ПО</title>
|
||||
<meta name="description" content="Инфраструктурный полигон MONT: демонстрация и пилотное тестирование российского ПО для партнёров и заказчиков. Браузерный доступ к рабочим стендам — без установки и настройки." />
|
||||
<meta name="keywords" content="инфраструктурный полигон MONT, пилоты MONT, демо MONT, партнёры MONT, демонстрация MONT, российское ПО демо, отечественное ПО тестирование, демостенд ПО" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<link rel="canonical" href="{{ base_url }}" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="{{ base_url }}" />
|
||||
<meta property="og:title" content="Инфраструктурный полигон MONT — демо и пилоты российского ПО" />
|
||||
<meta property="og:description" content="Демонстрация и тестирование российского ПО для партнёров и заказчиков MONT. Доступ к рабочим стендам прямо в браузере." />
|
||||
<meta property="og:image" content="{{ base_url }}static/logo.png?v=2" />
|
||||
<meta property="og:locale" content="ru_RU" />
|
||||
<meta property="og:site_name" content="Полигон MONT" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="Инфраструктурный полигон MONT" />
|
||||
<meta name="twitter:description" content="Демо и пилоты российского ПО для партнёров и заказчиков MONT." />
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "Инфраструктурный полигон MONT",
|
||||
"url": "{{ base_url }}",
|
||||
"description": "Платформа для демонстрации и пилотного тестирования российского программного обеспечения. Партнеры MONT и их заказчики получают браузерный доступ к рабочим стендам.",
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"name": "MONT",
|
||||
"url": "https://www.mont.ru/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
||||
<link rel="alternate icon" type="image/png" href="/static/favicon.png" />
|
||||
<link rel="icon" type="image/png" href="/static/favicon.png" />
|
||||
<style>
|
||||
body { background: #070f1c; overflow: hidden; height: 100vh; }
|
||||
@media (max-width: 820px) { body { overflow: auto; height: auto; } }
|
||||
</style>
|
||||
<!-- Yandex.Metrika counter -->
|
||||
<script type="text/javascript">
|
||||
(function(m,e,t,r,i,k,a){
|
||||
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
||||
m[i].l=1*new Date();
|
||||
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
|
||||
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
|
||||
})(window, document,"script","https://mc.yandex.ru/metrika/tag.js?id=109119977", "ym");
|
||||
ym(109119977, "init", {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
|
||||
</script>
|
||||
<noscript><div><img src="https://mc.yandex.ru/watch/109119977" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
|
||||
<!-- /Yandex.Metrika counter -->
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-corner-brand">МОНТ - инфрастуктурный полигон</div>
|
||||
<main class="center-box login-page">
|
||||
<section class="login-shell">
|
||||
<img src="/static/logo.png" alt="MONT" class="brand-logo brand-logo-fullscreen" />
|
||||
<h1 class="login-title">Добро пожаловать</h1>
|
||||
{% if session_notice %}<div class="session-notice">{{ session_notice }}</div>{% endif %}
|
||||
{% if login_error %}<div class="auth-error">{{ login_error }}</div>{% endif %}
|
||||
<form method="post" action="/login" class="panel login-panel">
|
||||
<div class="login-wrap">
|
||||
<aside class="login-left">
|
||||
<div class="login-left-glow login-left-glow-top"></div>
|
||||
<div class="login-left-glow login-left-glow-bottom"></div>
|
||||
<div class="login-left-inner">
|
||||
<a href="#" onclick="window.open(location.hostname==='stand.mont.ru'?'https://www.mont.ru':'https://4mont.ru','_blank');return false;"><img src="/static/logo.png?v=2" alt="MONT" class="login-corner-logo" /></a>
|
||||
<h1 class="login-left-title">Инфраструктурный<br>полигон MONT</h1>
|
||||
<p class="login-left-desc">Платформа для демонстрации и пилотного тестирования российского ПО. Партнеры MONT и их заказчики получают браузерный доступ к рабочим стендам с отечественными ОС, платформами виртуализации, СРК и другими решениями — без установки и настройки.</p>
|
||||
<ul class="login-features">
|
||||
<li class="login-feature">
|
||||
<span class="login-feature-icon">🖥</span>
|
||||
<span>Доступ к рабочим столам ОС</span>
|
||||
</li>
|
||||
<li class="login-feature">
|
||||
<span class="login-feature-icon">🌐</span>
|
||||
<span>Веб-интерфейсы сервисов</span>
|
||||
</li>
|
||||
<li class="login-feature">
|
||||
<span class="login-feature-icon">⚡</span>
|
||||
<span>Доступ в один клик</span>
|
||||
</li>
|
||||
<li class="login-feature">
|
||||
<span class="login-feature-icon">🔒</span>
|
||||
<span>Защищённый контур</span>
|
||||
</li>
|
||||
</ul>
|
||||
<button type="button" class="login-distrib-btn" onclick="window.open(location.hostname==='stand.mont.ru'?'https://maps.mont.ru':'https://maps.4mont.ru','_blank')">Продукты нашей дистрибуции</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="login-right">
|
||||
<div class="login-right-inner">
|
||||
<div class="login-form-title">Вход в систему</div>
|
||||
<div class="login-form-subtitle">Инфраструктурный полигон</div>
|
||||
{% if session_notice %}<div class="session-notice lp-session-notice">{{ session_notice }}</div>{% endif %}
|
||||
{% if login_error %}<div class="auth-error lp-auth-error">{{ login_error }}</div>{% endif %}
|
||||
<form method="post" action="/login" class="login-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<div class="login-field">
|
||||
<label>Логин</label>
|
||||
<input type="text" name="username" placeholder="Введите логин" required />
|
||||
<input type="text" name="username" placeholder="Введите логин" required autocomplete="username" />
|
||||
</div>
|
||||
<div class="login-field">
|
||||
<label>Пароль</label>
|
||||
<input type="password" name="password" placeholder="Введите пароль" required />
|
||||
<button type="submit">Войти</button>
|
||||
<input type="password" name="password" placeholder="Введите пароль" required autocomplete="current-password" />
|
||||
</div>
|
||||
<button type="submit" class="login-submit">Войти</button>
|
||||
</form>
|
||||
</section>
|
||||
<button type="button" class="login-request-btn" id="btn-request-access" data-open-access-modal="1">Запросить доступ</button>
|
||||
</div>
|
||||
<footer class="login-footer">
|
||||
<a href="mailto:ruslan@ipcom.su" class="login-footer-link">Made by Galyaviev</a>
|
||||
</footer>
|
||||
</main>
|
||||
<footer class="login-made-by-wrap"><a class="made-by login-made-by" href="mailto:rgalyaviev@mont.com">Made by Galyaviev</a></footer>
|
||||
</div>
|
||||
|
||||
<!-- Request Access Modal -->
|
||||
<div id="access-modal" class="access-modal-overlay" style="display:none" aria-modal="true" role="dialog">
|
||||
<div class="access-modal">
|
||||
<div class="access-modal-header">
|
||||
<div class="access-modal-title">Запрос на доступ</div>
|
||||
|
||||
</div>
|
||||
<div class="access-modal-body">
|
||||
<div class="access-field">
|
||||
<label>Имя и фамилия <span class="req">*</span></label>
|
||||
<input id="am-name" type="text" placeholder="Иван Иванов" />
|
||||
</div>
|
||||
<div class="access-field">
|
||||
<label>Название компании <span class="req">*</span></label>
|
||||
<input id="am-company" type="text" placeholder="ООО Компания" />
|
||||
</div>
|
||||
<div class="access-field">
|
||||
<label>Email <span class="req">*</span></label>
|
||||
<input id="am-email" type="email" placeholder="ivan@company.ru" />
|
||||
</div>
|
||||
<div class="access-field">
|
||||
<label>Телефон <span class="req">*</span></label>
|
||||
<input id="am-phone" type="tel" placeholder="+7 (999) 000-00-00" />
|
||||
</div>
|
||||
<div class="access-field">
|
||||
<label>Ваш менеджер в MONT</label>
|
||||
<input id="am-manager" type="text" placeholder="Если известно — укажите имя" />
|
||||
</div>
|
||||
<div class="access-field">
|
||||
<label>Интересующие продукты</label>
|
||||
<div id="am-products" class="access-products-wrap">
|
||||
<div class="access-products-loading">Загрузка...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="access-consent-field">
|
||||
<label class="access-consent-label">
|
||||
<input type="checkbox" id="am-consent" />
|
||||
<span>Согласен на <a href="/privacy" target="_blank" class="access-consent-link">обработку персональных данных</a></span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="am-error" class="access-modal-error" style="display:none"></div>
|
||||
</div>
|
||||
<div class="access-modal-footer">
|
||||
<button type="button" class="access-btn-cancel" id="am-cancel">Отмена</button>
|
||||
<button type="button" class="access-btn-submit" id="am-submit">Запросить доступ</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const overlay = document.getElementById('access-modal');
|
||||
const btnCancel = document.getElementById('am-cancel');
|
||||
const btnSubmit = document.getElementById('am-submit');
|
||||
const errEl = document.getElementById('am-error');
|
||||
let productsLoaded = false;
|
||||
|
||||
function resetAccessForm() {
|
||||
if (!document.getElementById('am-name')) {
|
||||
document.querySelector('.access-modal-body').innerHTML = `
|
||||
<div class="access-field"><label>Имя и фамилия <span class="req">*</span></label><input id="am-name" type="text" placeholder="Иван Иванов" /></div>
|
||||
<div class="access-field"><label>Название компании <span class="req">*</span></label><input id="am-company" type="text" placeholder="ООО Компания" /></div>
|
||||
<div class="access-field"><label>Email <span class="req">*</span></label><input id="am-email" type="email" placeholder="ivan@company.ru" /></div>
|
||||
<div class="access-field"><label>Телефон <span class="req">*</span></label><input id="am-phone" type="tel" placeholder="+7 (999) 000-00-00" /></div>
|
||||
<div class="access-field"><label>Ваш менеджер в MONT</label><input id="am-manager" type="text" placeholder="Если известно — укажите имя" /></div>
|
||||
<div class="access-field"><label>Интересующие продукты</label><div id="am-products" class="access-products-wrap"><div class="access-products-loading">Загрузка...</div></div></div>
|
||||
<div class="access-consent-field"><label class="access-consent-label"><input type="checkbox" id="am-consent" /><span>Согласен на <a href="/privacy" target="_blank" class="access-consent-link">обработку персональных данных</a></span></label></div>
|
||||
<div id="am-error" class="access-modal-error" style="display:none"></div>`;
|
||||
document.querySelector('.access-modal-footer').innerHTML = `<button type="button" class="access-btn-cancel" id="am-cancel">Отмена</button><button type="button" class="access-btn-submit" id="am-submit">Запросить доступ</button>`;
|
||||
document.getElementById('am-cancel').addEventListener('click', closeModal);
|
||||
document.getElementById('am-submit').addEventListener('click', submitForm);
|
||||
document.querySelectorAll('#am-name,#am-company,#am-email,#am-phone').forEach(function(el){ el.addEventListener('input', function(){ el.classList.remove('am-invalid'); }); });
|
||||
productsLoaded = false;
|
||||
} else {
|
||||
['am-name','am-company','am-email','am-phone','am-manager'].forEach(function(id){ var el=document.getElementById(id); if(el){el.value='';el.classList.remove('am-invalid');} });
|
||||
document.querySelectorAll('#am-products input[type=checkbox]').forEach(function(cb){ cb.checked=false; });
|
||||
var err=document.getElementById('am-error'); if(err) err.style.display='none';
|
||||
var btn=document.getElementById('am-submit'); if(btn){btn.disabled=false;btn.textContent='Запросить доступ';}
|
||||
}
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
resetAccessForm();
|
||||
overlay.style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
if (!productsLoaded) loadProducts();
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
overlay.style.display = 'none';
|
||||
document.body.style.overflow = '';
|
||||
errEl.style.display = 'none';
|
||||
document.querySelectorAll('.am-invalid').forEach(el => el.classList.remove('am-invalid'));
|
||||
}
|
||||
window._closeAccessModal = function() { closeModal(); productsLoaded = false; };
|
||||
|
||||
async function loadProducts() {
|
||||
const wrap = document.getElementById('am-products');
|
||||
try {
|
||||
const res = await fetch('/api/public/services-by-category');
|
||||
const data = await res.json();
|
||||
wrap.innerHTML = '';
|
||||
for (const [cat, svcs] of Object.entries(data)) {
|
||||
const group = document.createElement('div');
|
||||
group.className = 'access-products-group';
|
||||
group.innerHTML = '<div class="access-products-cat">' + cat + '</div>';
|
||||
for (const svc of svcs) {
|
||||
const lbl = document.createElement('label');
|
||||
lbl.className = 'access-product-item';
|
||||
lbl.innerHTML = '<input type="checkbox" value="' + svc.name.replace(/"/g, '"') + '" /><span>' + svc.name + '</span>';
|
||||
group.appendChild(lbl);
|
||||
}
|
||||
wrap.appendChild(group);
|
||||
}
|
||||
productsLoaded = true;
|
||||
} catch(e) {
|
||||
wrap.innerHTML = '<div class="access-products-loading">Не удалось загрузить список</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
const nameEl = document.getElementById('am-name');
|
||||
const companyEl = document.getElementById('am-company');
|
||||
const emailEl = document.getElementById('am-email');
|
||||
const phoneEl = document.getElementById('am-phone');
|
||||
const managerEl = document.getElementById('am-manager');
|
||||
const submitBtn = document.getElementById('am-submit');
|
||||
const errorEl = document.getElementById('am-error');
|
||||
|
||||
const name = nameEl ? nameEl.value.trim() : '';
|
||||
const company = companyEl ? companyEl.value.trim() : '';
|
||||
const email = emailEl ? emailEl.value.trim() : '';
|
||||
const phone = phoneEl ? phoneEl.value.trim() : '';
|
||||
const manager = managerEl ? managerEl.value.trim() : '';
|
||||
const checked = [...document.querySelectorAll('#am-products input[type=checkbox]:checked')];
|
||||
const products = checked.map(c => c.value);
|
||||
|
||||
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const phoneRe = /^[\+\d][\d\s\-\(\)]{6,18}$/;
|
||||
|
||||
const consentEl = document.getElementById('am-consent');
|
||||
const fields = [
|
||||
{ el: nameEl, check: () => !!name, msg: 'Введите имя и фамилию' },
|
||||
{ el: companyEl, check: () => !!company, msg: 'Введите название компании' },
|
||||
{ el: emailEl, check: () => emailRe.test(email), msg: 'Введите корректный email' },
|
||||
{ el: phoneEl, check: () => phoneRe.test(phone), msg: 'Введите корректный номер телефона' },
|
||||
];
|
||||
|
||||
const errors = [];
|
||||
fields.forEach(f => {
|
||||
if (f.el && !f.check()) { f.el.classList.add('am-invalid'); errors.push(f.msg); }
|
||||
else if (f.el) f.el.classList.remove('am-invalid');
|
||||
});
|
||||
if (!consentEl || !consentEl.checked) {
|
||||
errors.push('Необходимо согласие на обработку персональных данных');
|
||||
const cf = document.querySelector('.access-consent-field');
|
||||
if (cf) cf.classList.add('am-invalid-consent');
|
||||
} else {
|
||||
const cf = document.querySelector('.access-consent-field');
|
||||
if (cf) cf.classList.remove('am-invalid-consent');
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
if (errorEl) { errorEl.textContent = errors.join(' • '); errorEl.style.display = 'block'; }
|
||||
return;
|
||||
}
|
||||
|
||||
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Отправка...'; }
|
||||
if (errorEl) errorEl.style.display = 'none';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/request-access', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name, company, email, phone, manager, products}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
throw new Error(d.detail || 'Ошибка отправки');
|
||||
}
|
||||
const body = document.querySelector('#access-modal .access-modal-body');
|
||||
const footer = document.querySelector('#access-modal .access-modal-footer');
|
||||
body.innerHTML = '<div class="am-success-msg">' +
|
||||
'<div class="am-success-icon">✓</div>' +
|
||||
'<div class="am-success-title">Запрос отправлен</div>' +
|
||||
'<div class="am-success-sub">После утверждения доступы придут на электронную почту <strong>' + email + '</strong></div>' +
|
||||
'</div>';
|
||||
footer.innerHTML = '<button type="button" class="access-btn-cancel" onclick="window._closeAccessModal()">Закрыть</button>';
|
||||
} catch(e) {
|
||||
const eb = document.getElementById('am-error');
|
||||
if (eb) { eb.textContent = e.message || 'Ошибка отправки, попробуйте позже'; eb.style.display = 'block'; }
|
||||
const sb = document.getElementById('am-submit');
|
||||
if (sb) { sb.disabled = false; sb.textContent = 'Запросить доступ'; }
|
||||
}
|
||||
}
|
||||
|
||||
// Clear invalid highlight on input
|
||||
document.querySelectorAll('#am-name,#am-company,#am-email,#am-phone').forEach(el => {
|
||||
el.addEventListener('input', () => el.classList.remove('am-invalid'));
|
||||
});
|
||||
|
||||
// Wire up request-access button
|
||||
document.querySelectorAll('.login-request-btn, [data-open-access-modal]').forEach(el => {
|
||||
el.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
openModal();
|
||||
});
|
||||
});
|
||||
|
||||
btnCancel.addEventListener('click', closeModal);
|
||||
btnSubmit.addEventListener('click', submitForm);
|
||||
overlay.addEventListener('click', function(e) {
|
||||
if (e.target === overlay) closeModal();
|
||||
});
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') closeModal();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Contact Ruslan Modal -->
|
||||
<div id="contact-modal" class="access-modal-overlay" style="display:none" aria-modal="true" role="dialog">
|
||||
<div class="access-modal">
|
||||
<div class="access-modal-header">
|
||||
<div class="access-modal-title">Связаться с Русланом</div>
|
||||
|
||||
</div>
|
||||
<div class="access-modal-body" id="cm-body">
|
||||
<div class="access-field">
|
||||
<label>Ваше имя <span class="req">*</span></label>
|
||||
<input id="cm-name" type="text" placeholder="Иван Иванов" />
|
||||
</div>
|
||||
<div class="access-field">
|
||||
<label>Email <span class="req">*</span></label>
|
||||
<input id="cm-email" type="email" placeholder="ivan@company.ru" />
|
||||
</div>
|
||||
<div class="access-field">
|
||||
<label>Телефон <span class="req">*</span></label>
|
||||
<input id="cm-phone" type="tel" placeholder="+7 (999) 000-00-00" />
|
||||
</div>
|
||||
<div class="access-field">
|
||||
<label>Сообщение <span class="req">*</span></label>
|
||||
<textarea id="cm-text" class="access-textarea" placeholder="Ваш вопрос или предложение..." rows="4"></textarea>
|
||||
</div>
|
||||
<div class="access-consent-field">
|
||||
<label class="access-consent-label">
|
||||
<input type="checkbox" id="cm-consent" />
|
||||
<span>Согласен на <a href="/privacy" target="_blank" class="access-consent-link">обработку персональных данных</a></span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="cm-error" class="access-modal-error" style="display:none"></div>
|
||||
</div>
|
||||
<div class="access-modal-footer" id="cm-footer">
|
||||
<button type="button" class="access-btn-cancel" id="cm-cancel">Отмена</button>
|
||||
<button type="button" class="access-btn-submit" id="cm-submit">Отправить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const overlay = document.getElementById('contact-modal');
|
||||
const btnCancel = document.getElementById('cm-cancel');
|
||||
const btnSubmit = document.getElementById('cm-submit');
|
||||
const errEl = document.getElementById('cm-error');
|
||||
|
||||
function resetContactForm() {
|
||||
if (!document.getElementById('cm-name')) {
|
||||
document.getElementById('cm-body').innerHTML = `
|
||||
<div class="access-field"><label>Ваше имя <span class="req">*</span></label><input id="cm-name" type="text" placeholder="Иван Иванов" /></div>
|
||||
<div class="access-field"><label>Email <span class="req">*</span></label><input id="cm-email" type="email" placeholder="ivan@company.ru" /></div>
|
||||
<div class="access-field"><label>Телефон <span class="req">*</span></label><input id="cm-phone" type="tel" placeholder="+7 (999) 000-00-00" /></div>
|
||||
<div class="access-field"><label>Сообщение <span class="req">*</span></label><textarea id="cm-text" class="access-textarea" placeholder="Ваш вопрос или предложение..." rows="4"></textarea></div>
|
||||
<div class="access-consent-field"><label class="access-consent-label"><input type="checkbox" id="cm-consent" /><span>Согласен на <a href="/privacy" target="_blank" class="access-consent-link">обработку персональных данных</a></span></label></div>
|
||||
<div id="cm-error" class="access-modal-error" style="display:none"></div>`;
|
||||
document.getElementById('cm-footer').innerHTML = `<button type="button" class="access-btn-cancel" id="cm-cancel">Отмена</button><button type="button" class="access-btn-submit" id="cm-submit">Отправить</button>`;
|
||||
document.getElementById('cm-cancel').addEventListener('click', closeContact);
|
||||
document.getElementById('cm-submit').addEventListener('click', submitContact);
|
||||
document.querySelectorAll('#cm-name,#cm-email,#cm-phone,#cm-text').forEach(function(el){ el.addEventListener('input', function(){ el.classList.remove('am-invalid'); }); });
|
||||
} else {
|
||||
['cm-name','cm-email','cm-phone','cm-text'].forEach(function(id){ var el=document.getElementById(id); if(el){el.value='';el.classList.remove('am-invalid');} });
|
||||
var err=document.getElementById('cm-error'); if(err) err.style.display='none';
|
||||
var btn=document.getElementById('cm-submit'); if(btn){btn.disabled=false;btn.textContent='Отправить';}
|
||||
}
|
||||
}
|
||||
|
||||
function openContact() {
|
||||
resetContactForm();
|
||||
overlay.style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeContact() {
|
||||
overlay.style.display = 'none';
|
||||
document.body.style.overflow = '';
|
||||
errEl.style.display = 'none';
|
||||
document.querySelectorAll('#contact-modal .am-invalid').forEach(el => el.classList.remove('am-invalid'));
|
||||
}
|
||||
window._closeContactModal = closeContact;
|
||||
|
||||
async function submitContact() {
|
||||
const nameEl = document.getElementById('cm-name');
|
||||
const emailEl = document.getElementById('cm-email');
|
||||
const phoneEl = document.getElementById('cm-phone');
|
||||
const textEl = document.getElementById('cm-text');
|
||||
const submitBtn = document.getElementById('cm-submit');
|
||||
const errorEl = document.getElementById('cm-error');
|
||||
|
||||
const name = nameEl ? nameEl.value.trim() : '';
|
||||
const email = emailEl ? emailEl.value.trim() : '';
|
||||
const phone = phoneEl ? phoneEl.value.trim() : '';
|
||||
const text = textEl ? textEl.value.trim() : '';
|
||||
|
||||
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const phoneRe = /^[\+\d][\d\s\-\(\)]{6,18}$/;
|
||||
|
||||
const consentEl = document.getElementById('cm-consent');
|
||||
const fields = [
|
||||
{ el: nameEl, check: () => !!name, msg: 'Введите имя' },
|
||||
{ el: emailEl, check: () => emailRe.test(email), msg: 'Введите корректный email' },
|
||||
{ el: phoneEl, check: () => phoneRe.test(phone), msg: 'Введите корректный номер телефона' },
|
||||
{ el: textEl, check: () => !!text, msg: 'Введите сообщение' },
|
||||
];
|
||||
|
||||
const errors = [];
|
||||
fields.forEach(f => {
|
||||
if (f.el && !f.check()) { f.el.classList.add('am-invalid'); errors.push(f.msg); }
|
||||
else if (f.el) f.el.classList.remove('am-invalid');
|
||||
});
|
||||
if (!consentEl || !consentEl.checked) {
|
||||
errors.push('Необходимо согласие на обработку персональных данных');
|
||||
const cf = document.querySelector('#contact-modal .access-consent-field');
|
||||
if (cf) cf.classList.add('am-invalid-consent');
|
||||
} else {
|
||||
const cf = document.querySelector('#contact-modal .access-consent-field');
|
||||
if (cf) cf.classList.remove('am-invalid-consent');
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
if (errorEl) { errorEl.textContent = errors.join(' • '); errorEl.style.display = 'block'; }
|
||||
return;
|
||||
}
|
||||
|
||||
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Отправка...'; }
|
||||
if (errorEl) errorEl.style.display = 'none';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name, email, phone, text}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
throw new Error(d.detail || 'Ошибка отправки');
|
||||
}
|
||||
document.getElementById('cm-body').innerHTML =
|
||||
'<div class="am-success-msg">' +
|
||||
'<div class="am-success-icon">✓</div>' +
|
||||
'<div class="am-success-title">Отправлено</div>' +
|
||||
'<div class="am-success-sub">Постараюсь ответить в ближайшее время</div>' +
|
||||
'</div>';
|
||||
document.getElementById('cm-footer').innerHTML =
|
||||
'<button type="button" class="access-btn-cancel" onclick="window._closeContactModal()">Закрыть</button>';
|
||||
} catch(e) {
|
||||
const eb = document.getElementById('cm-error');
|
||||
if (eb) { eb.textContent = e.message || 'Ошибка отправки, попробуйте позже'; eb.style.display = 'block'; }
|
||||
const sb = document.getElementById('cm-submit');
|
||||
if (sb) { sb.disabled = false; sb.textContent = 'Отправить'; }
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('#cm-name,#cm-email,#cm-phone,#cm-text').forEach(el => {
|
||||
el.addEventListener('input', () => el.classList.remove('am-invalid'));
|
||||
});
|
||||
|
||||
document.getElementById('btn-contact-ruslan').addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
openContact();
|
||||
});
|
||||
btnCancel.addEventListener('click', closeContact);
|
||||
btnSubmit.addEventListener('click', submitContact);
|
||||
overlay.addEventListener('click', function(e) { if (e.target === overlay) closeContact(); });
|
||||
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && overlay.style.display !== 'none') closeContact(); });
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Cookie Banner -->
|
||||
<div id="cookie-banner" style="display:none;position:fixed;bottom:0;left:0;right:0;z-index:99999;background:rgba(7,15,28,0.97);border-top:1px solid rgba(255,255,255,0.1);backdrop-filter:blur(8px);padding:14px 24px;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;">
|
||||
<p style="margin:0;font-size:0.85rem;color:#c8d8ea;line-height:1.5;flex:1;min-width:200px;">
|
||||
Мы используем файлы cookie, чтобы сделать работу с сайтом удобнее. Нажмите «Принять», чтобы согласиться с использованием файлов cookie в соответствии с <a href="https://www.mont.ru/ru-ru/confidential" target="_blank" style="color:#5b9bd5;text-decoration:underline;">Политикой конфиденциальности</a>.
|
||||
</p>
|
||||
<button onclick="document.getElementById('cookie-banner').style.display='none';localStorage.setItem('cookie_accepted','1');" style="flex-shrink:0;padding:8px 22px;background:linear-gradient(135deg,#1a5db5,#2d8cf0);color:#fff;border:none;border-radius:8px;font-size:0.88rem;font-weight:600;cursor:pointer;white-space:nowrap;">Принять</button>
|
||||
</div>
|
||||
<script>
|
||||
if (!localStorage.getItem('cookie_accepted')) {
|
||||
document.getElementById('cookie-banner').style.display = 'flex';
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>Политика конфиденциальности — Полигон MONT</title>
|
||||
<meta name="robots" content="noindex"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
<link rel="icon" type="image/png" href="/static/favicon.png"/>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
background: linear-gradient(160deg, #070f1c 0%, #0a1f3a 100%);
|
||||
min-height: 100vh;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
color: #c8d8ea;
|
||||
padding: 40px 20px 60px;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 780px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #5b9bd5;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 32px;
|
||||
opacity: 0.85;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.back-link:hover { opacity: 1; }
|
||||
h1 {
|
||||
font-size: 1.7rem;
|
||||
font-weight: 700;
|
||||
color: #e8f1fb;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 0.9rem;
|
||||
color: #6a8aaa;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: #d0e4f7;
|
||||
margin: 28px 0 10px;
|
||||
}
|
||||
p, li {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.7;
|
||||
color: #a8bdd4;
|
||||
}
|
||||
ul {
|
||||
padding-left: 20px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
li { margin-bottom: 4px; }
|
||||
a { color: #5b9bd5; }
|
||||
.divider {
|
||||
border: none;
|
||||
border-top: 1px solid rgba(255,255,255,0.07);
|
||||
margin: 32px 0;
|
||||
}
|
||||
.contact-box {
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 10px;
|
||||
padding: 20px 24px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
.contact-box p { color: #a8bdd4; }
|
||||
.contact-box strong { color: #d0e4f7; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<a class="back-link" href="/">← Вернуться на главную</a>
|
||||
|
||||
<h1>Политика конфиденциальности</h1>
|
||||
<p class="subtitle">Последнее обновление: 28 мая 2026 г.</p>
|
||||
|
||||
<p>Настоящая Политика конфиденциальности описывает, как ООО «МОНТ» (далее — «Оператор») осуществляет сбор, использование и хранение персональных данных пользователей, оставивших заявку на получение доступа к Инфраструктурному полигону MONT.</p>
|
||||
|
||||
<h2>1. Оператор персональных данных</h2>
|
||||
<p>ООО «МОНТ»<br>
|
||||
Юридический адрес: г. Москва<br>
|
||||
Сайт: <a href="https://www.mont.ru/" target="_blank">www.mont.ru</a><br>
|
||||
Email для обращений по вопросам ПД: <a href="mailto:privacy@mont.ru">privacy@mont.ru</a></p>
|
||||
|
||||
<h2>2. Какие данные мы собираем</h2>
|
||||
<p>При заполнении формы запроса доступа мы получаем:</p>
|
||||
<ul>
|
||||
<li>Имя и фамилия</li>
|
||||
<li>Название компании</li>
|
||||
<li>Адрес электронной почты</li>
|
||||
<li>Номер телефона</li>
|
||||
<li>Имя вашего менеджера в MONT (необязательно)</li>
|
||||
<li>Список интересующих продуктов</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. Цели обработки</h2>
|
||||
<p>Персональные данные обрабатываются исключительно для рассмотрения заявки на доступ к демостендам и последующей связи с вами по вопросам предоставления доступа.</p>
|
||||
|
||||
<h2>4. Правовое основание</h2>
|
||||
<p>Обработка персональных данных осуществляется на основании вашего явного согласия в соответствии со ст. 6, ч. 1, п. 1 и ст. 9 Федерального закона № 152-ФЗ «О персональных данных».</p>
|
||||
|
||||
<h2>5. Передача данных третьим лицам</h2>
|
||||
<p>Персональные данные не хранятся в базе данных портала. После отправки формы данные передаются ответственным сотрудникам MONT по защищённому каналу для обработки заявки. Данные не продаются и не передаются третьим лицам в коммерческих целях.</p>
|
||||
|
||||
<h2>6. Срок хранения</h2>
|
||||
<p>Данные хранятся в течение срока, необходимого для обработки заявки и предоставления доступа, но не более 1 года с момента подачи заявки, если иное не требуется законодательством РФ.</p>
|
||||
|
||||
<h2>7. Ваши права</h2>
|
||||
<p>В соответствии с 152-ФЗ вы вправе:</p>
|
||||
<ul>
|
||||
<li>получить информацию об обработке ваших персональных данных;</li>
|
||||
<li>потребовать уточнения, блокирования или уничтожения ваших данных;</li>
|
||||
<li>отозвать согласие на обработку персональных данных в любой момент.</li>
|
||||
</ul>
|
||||
<p>Для реализации любого из перечисленных прав направьте запрос на <a href="mailto:privacy@mont.ru">privacy@mont.ru</a>.</p>
|
||||
|
||||
<h2>8. Защита данных</h2>
|
||||
<p>Передача данных осуществляется по зашифрованному соединению (HTTPS). Доступ к данным имеют только уполномоченные сотрудники MONT.</p>
|
||||
|
||||
<hr class="divider"/>
|
||||
|
||||
<div class="contact-box">
|
||||
<p><strong>Вопросы по обработке персональных данных:</strong><br>
|
||||
<a href="mailto:privacy@mont.ru">privacy@mont.ru</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,3 @@
|
||||
pytest==8.3.5
|
||||
httpx==0.28.1
|
||||
pytest-asyncio==0.24.0
|
||||
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Smoke-tests: проверяем что все ключевые роуты не падают с NameError/ImportError.
|
||||
Не проверяем бизнес-логику — только что страницы отдают ответ.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
# ── Публичные страницы ──────────────────────────────────────────────────────
|
||||
|
||||
def test_index_anonymous(client):
|
||||
"""Главная без авторизации — либо страница сервисов, либо логин."""
|
||||
r = client.get("/", follow_redirects=True)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_login_form_on_index(client):
|
||||
"""Форма логина рендерится на /."""
|
||||
r = client.get("/", follow_redirects=True)
|
||||
assert r.status_code == 200
|
||||
assert "csrf" in r.text.lower() or "login" in r.text.lower() or "пароль" in r.text.lower()
|
||||
|
||||
|
||||
def _get_csrf(client):
|
||||
from conftest import _extract_csrf
|
||||
return _extract_csrf(client)
|
||||
|
||||
|
||||
def test_login_wrong_password(client):
|
||||
csrf = _get_csrf(client)
|
||||
r = client.post("/login", data={
|
||||
"username": "admin",
|
||||
"password": "wrongpass",
|
||||
"csrf_token": csrf,
|
||||
})
|
||||
assert r.status_code in (200, 401)
|
||||
|
||||
|
||||
def test_login_csrf_fail(client):
|
||||
r = client.post("/login", data={
|
||||
"username": "admin",
|
||||
"password": "testpass123",
|
||||
"csrf_token": "bad-token",
|
||||
})
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_login_no_such_method(client):
|
||||
r = client.get("/login")
|
||||
assert r.status_code in (200, 405) # только документируем поведение
|
||||
|
||||
|
||||
# ── Авторизованные страницы ─────────────────────────────────────────────────
|
||||
|
||||
def test_login_success(auth_client):
|
||||
r = auth_client.get("/", follow_redirects=True)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_admin_page(auth_client):
|
||||
r = auth_client.get("/admin")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_admin_requires_auth(client):
|
||||
from fastapi.testclient import TestClient
|
||||
import main as app_module
|
||||
fresh = TestClient(app_module.app, raise_server_exceptions=False)
|
||||
r = fresh.get("/admin", follow_redirects=False)
|
||||
assert r.status_code in (302, 303, 401, 403)
|
||||
|
||||
|
||||
# ── API роуты ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_api_services_list(auth_client):
|
||||
r = auth_client.get("/api/admin/services")
|
||||
assert r.status_code in (200, 404, 405)
|
||||
|
||||
|
||||
def test_api_users_list(auth_client):
|
||||
r = auth_client.get("/api/admin/users")
|
||||
assert r.status_code in (200, 404, 405)
|
||||
|
||||
|
||||
def test_api_categories(auth_client):
|
||||
r = auth_client.get("/api/admin/categories")
|
||||
assert r.status_code in (200, 404, 405)
|
||||
|
||||
|
||||
# ── Несуществующие роуты ────────────────────────────────────────────────────
|
||||
|
||||
def test_404(client):
|
||||
r = client.get("/this-does-not-exist-xyz")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_session_unknown(auth_client):
|
||||
r = auth_client.get("/s/00000000-0000-0000-0000-000000000000/")
|
||||
assert r.status_code in (200, 302, 303, 404)
|
||||
|
||||
|
||||
def test_go_unknown_slug(auth_client):
|
||||
r = auth_client.get("/go/nonexistent-service-slug", follow_redirects=False)
|
||||
assert r.status_code in (302, 303, 404)
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
import datetime as dt
|
||||
import json
|
||||
import logging
|
||||
import contextvars
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
import mistune
|
||||
from fastapi import HTTPException, UploadFile
|
||||
from markupsafe import Markup
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from config import (
|
||||
ICON_UPLOAD_MAX_BYTES, ICON_UPLOAD_TYPES, MAX_ACTIVE_SERVICES_PER_USER,
|
||||
SERVICE_ICONS_DIR, SESSION_IDLE_SECONDS,
|
||||
)
|
||||
from models import AuditLog, Category, ServiceCategory, SessionModel, SessionStatus
|
||||
|
||||
|
||||
logger = logging.getLogger("portal")
|
||||
request_id_ctx = contextvars.ContextVar("request_id", default="-")
|
||||
|
||||
|
||||
def _normalize_log_value(value):
|
||||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||
return value
|
||||
if isinstance(value, dt.datetime):
|
||||
return value.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
def log_event(event: str, level: int = logging.INFO, **fields) -> None:
|
||||
payload = {"event": event, "req_id": request_id_ctx.get()}
|
||||
for key, value in fields.items():
|
||||
payload[key] = _normalize_log_value(value)
|
||||
logger.log(level, json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
|
||||
|
||||
|
||||
def now_utc() -> dt.datetime:
|
||||
return dt.datetime.now(dt.timezone.utc)
|
||||
|
||||
|
||||
def session_closed_reason(sess: SessionModel, db: Session) -> str:
|
||||
if not sess:
|
||||
return "idle"
|
||||
if sess.status == SessionStatus.EXPIRED:
|
||||
return "idle"
|
||||
if sess.status == SessionStatus.ROTATED:
|
||||
return "limit"
|
||||
if sess.status == SessionStatus.TERMINATED:
|
||||
cutoff = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS)
|
||||
active_rows = db.scalars(
|
||||
select(SessionModel).where(
|
||||
SessionModel.user_id == sess.user_id,
|
||||
SessionModel.status == SessionStatus.ACTIVE,
|
||||
SessionModel.last_access_at >= cutoff,
|
||||
)
|
||||
).all()
|
||||
active_service_ids = {row.service_id for row in active_rows}
|
||||
if len(active_service_ids) >= MAX_ACTIVE_SERVICES_PER_USER and sess.service_id not in active_service_ids:
|
||||
return "limit"
|
||||
return "idle"
|
||||
|
||||
|
||||
def normalize_web_target(url: str) -> str:
|
||||
raw = (url or "").strip()
|
||||
if not raw:
|
||||
return raw
|
||||
if raw.startswith(("http://", "https://")):
|
||||
return raw
|
||||
return f"http://{raw}"
|
||||
|
||||
|
||||
_md = mistune.create_markdown(
|
||||
escape=True,
|
||||
plugins=["strikethrough", "table", "task_lists"],
|
||||
)
|
||||
|
||||
|
||||
def format_service_comment(raw_text: str) -> Markup:
|
||||
raw = (raw_text or "").replace("\r\n", "\n").replace("\r", "\n").strip()
|
||||
if not raw:
|
||||
return Markup("")
|
||||
return Markup(_md(raw))
|
||||
|
||||
|
||||
def parse_rdp_target(target: str) -> dict:
|
||||
raw = (target or "").strip()
|
||||
if not raw:
|
||||
raise HTTPException(status_code=400, detail="Empty RDP target")
|
||||
|
||||
parsed = urlparse(raw if "://" in raw else f"//{raw}")
|
||||
host = parsed.hostname
|
||||
if not host:
|
||||
raise HTTPException(status_code=400, detail="Invalid RDP target. Use host:port or rdp://user:pass@host:port")
|
||||
port = parsed.port or 3389
|
||||
|
||||
username = unquote(parsed.username) if parsed.username else ""
|
||||
password = unquote(parsed.password) if parsed.password else ""
|
||||
|
||||
query = parse_qs(parsed.query or "")
|
||||
if not username:
|
||||
username = (query.get("u", [""])[0] or query.get("user", [""])[0] or "").strip()
|
||||
if not password:
|
||||
password = (query.get("p", [""])[0] or query.get("password", [""])[0] or "").strip()
|
||||
|
||||
domain = (query.get("domain", [""])[0] or query.get("d", [""])[0] or "").strip()
|
||||
security = (query.get("sec", [""])[0] or query.get("security", [""])[0] or "").strip().lower()
|
||||
if security and security not in {"nla", "tls", "rdp"}:
|
||||
raise HTTPException(status_code=400, detail="Invalid RDP security. Use one of: nla, tls, rdp")
|
||||
|
||||
return {
|
||||
"host": host,
|
||||
"port": str(port),
|
||||
"user": username,
|
||||
"password": password,
|
||||
"domain": domain,
|
||||
"security": security,
|
||||
}
|
||||
|
||||
|
||||
def set_service_categories(db: Session, service_id: int, category_ids: list[int]) -> None:
|
||||
normalized = sorted({int(x) for x in (category_ids or [])})
|
||||
if normalized:
|
||||
existing_ids = set(db.scalars(select(Category.id).where(Category.id.in_(normalized))).all())
|
||||
missing = sorted(set(normalized) - existing_ids)
|
||||
if missing:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown category ids: {missing}")
|
||||
|
||||
existing_links = db.scalars(select(ServiceCategory).where(ServiceCategory.service_id == service_id)).all()
|
||||
current = {row.category_id: row for row in existing_links}
|
||||
wanted = set(normalized)
|
||||
|
||||
for cat_id in wanted:
|
||||
if cat_id not in current:
|
||||
db.add(ServiceCategory(service_id=service_id, category_id=cat_id))
|
||||
for cat_id, row in current.items():
|
||||
if cat_id not in wanted:
|
||||
db.delete(row)
|
||||
|
||||
|
||||
def audit(db: Session, action: str, details: str, user_id: Optional[int] = None) -> None:
|
||||
db.add(AuditLog(user_id=user_id, action=action, details=details))
|
||||
db.commit()
|
||||
|
||||
|
||||
def ensure_icons_dir() -> None:
|
||||
SERVICE_ICONS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def remove_icon_file(icon_path: str) -> None:
|
||||
if not icon_path or not icon_path.startswith("/static/service-icons/"):
|
||||
return
|
||||
filename = icon_path.rsplit("/", 1)[-1]
|
||||
candidate = SERVICE_ICONS_DIR / filename
|
||||
try:
|
||||
candidate.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
logger.exception("icon_delete_failed path=%s", candidate)
|
||||
|
||||
|
||||
async def store_service_icon(service, upload: UploadFile) -> str:
|
||||
ensure_icons_dir()
|
||||
content_type = (upload.content_type or "").lower().strip()
|
||||
ext = ICON_UPLOAD_TYPES.get(content_type)
|
||||
if not ext:
|
||||
raise HTTPException(status_code=400, detail="Unsupported file type. Use PNG/JPG/WEBP")
|
||||
|
||||
payload = await upload.read(ICON_UPLOAD_MAX_BYTES + 1)
|
||||
if len(payload) > ICON_UPLOAD_MAX_BYTES:
|
||||
raise HTTPException(status_code=400, detail="File too large. Max 2MB")
|
||||
if not payload:
|
||||
raise HTTPException(status_code=400, detail="Empty file")
|
||||
|
||||
stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"svc_{service.id}_{stamp}.{ext}"
|
||||
target = SERVICE_ICONS_DIR / filename
|
||||
target.write_bytes(payload)
|
||||
return f"/static/service-icons/{filename}"
|
||||
+50
-12
@@ -30,7 +30,7 @@ services:
|
||||
api:
|
||||
build:
|
||||
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:
|
||||
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
SIGNING_KEY: ${SIGNING_KEY}
|
||||
@@ -41,13 +41,32 @@ services:
|
||||
PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2}
|
||||
UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0}
|
||||
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}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
GO_USER_LOCK_TIMEOUT_SECONDS: 8
|
||||
GO_POOL_LOCK_TIMEOUT_SECONDS: 20
|
||||
POOL_DISPATCH_RETRIES: 6
|
||||
ENABLE_STARTUP_MAINTENANCE: 0
|
||||
LOG_SLOW_REQUEST_MS: ${LOG_SLOW_REQUEST_MS:-2000}
|
||||
GO_USER_LOCK_TIMEOUT_SECONDS: ${GO_USER_LOCK_TIMEOUT_SECONDS:-8}
|
||||
GO_POOL_LOCK_TIMEOUT_SECONDS: ${GO_POOL_LOCK_TIMEOUT_SECONDS:-20}
|
||||
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}
|
||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
|
||||
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
|
||||
TELEGRAM_API_URL: ${TELEGRAM_API_URL:-https://api.telegram.org/bot}
|
||||
SMTP_HOST: ${SMTP_HOST:-mail.hosting.reg.ru}
|
||||
SMTP_PORT: ${SMTP_PORT:-465}
|
||||
SMTP_USERNAME: ${SMTP_USERNAME:-}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||
SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-}
|
||||
SMTP_FROM_NAME: ${SMTP_FROM_NAME:-}
|
||||
PORTAL_URL: ${PORTAL_URL:-https://stend.4mont.ru}
|
||||
depends_on:
|
||||
- db
|
||||
volumes:
|
||||
@@ -56,7 +75,7 @@ services:
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=portal_net
|
||||
- traefik.http.routers.portal.rule=Host(`${PUBLIC_HOST}`)
|
||||
- traefik.http.routers.portal.rule=Host(`${PUBLIC_HOST}`) || Host(`stand.mont.ru`)
|
||||
- traefik.http.routers.portal.entrypoints=websecure
|
||||
- traefik.http.routers.portal.tls=true
|
||||
- traefik.http.routers.portal.tls.certresolver=letsencrypt
|
||||
@@ -82,13 +101,32 @@ services:
|
||||
PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2}
|
||||
UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0}
|
||||
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}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
GO_USER_LOCK_TIMEOUT_SECONDS: 8
|
||||
GO_POOL_LOCK_TIMEOUT_SECONDS: 20
|
||||
POOL_DISPATCH_RETRIES: 6
|
||||
ENABLE_STARTUP_MAINTENANCE: 0
|
||||
LOG_SLOW_REQUEST_MS: ${LOG_SLOW_REQUEST_MS:-2000}
|
||||
GO_USER_LOCK_TIMEOUT_SECONDS: ${GO_USER_LOCK_TIMEOUT_SECONDS:-8}
|
||||
GO_POOL_LOCK_TIMEOUT_SECONDS: ${GO_POOL_LOCK_TIMEOUT_SECONDS:-20}
|
||||
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}
|
||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
|
||||
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
|
||||
TELEGRAM_API_URL: ${TELEGRAM_API_URL:-https://api.telegram.org/bot}
|
||||
SMTP_HOST: ${SMTP_HOST:-mail.hosting.reg.ru}
|
||||
SMTP_PORT: ${SMTP_PORT:-465}
|
||||
SMTP_USERNAME: ${SMTP_USERNAME:-}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||
SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-}
|
||||
SMTP_FROM_NAME: ${SMTP_FROM_NAME:-}
|
||||
PORTAL_URL: ${PORTAL_URL:-https://stend.4mont.ru}
|
||||
depends_on:
|
||||
- db
|
||||
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:
|
||||
@@ -11,6 +11,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
websockify \
|
||||
python3 \
|
||||
ca-certificates \
|
||||
x11-xserver-utils \
|
||||
x11-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
|
||||
+18
-2
@@ -4,7 +4,7 @@ set -euo pipefail
|
||||
TARGET_URL="${TARGET_URL:-https://example.com}"
|
||||
SESSION_ID="${SESSION_ID:-unknown}"
|
||||
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}"
|
||||
TOUCH_PATH="${TOUCH_PATH:-/api/sessions/${SESSION_ID}/touch}"
|
||||
UNIVERSAL_WEB="${UNIVERSAL_WEB:-0}"
|
||||
@@ -183,6 +183,22 @@ else
|
||||
>/tmp/chromium.log 2>&1 &
|
||||
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
|
||||
|
||||
+76
-1
@@ -1,9 +1,18 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
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):
|
||||
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=':/?#[]@!$&\'()*+,;=%')
|
||||
opened = _json_put(f"/json/new?{encoded}")
|
||||
opened_id = opened.get("id")
|
||||
# Keep exactly one active page tab to prevent tab/memory explosion in warm containers.
|
||||
pages = _json_get("/json/list")
|
||||
for page in pages:
|
||||
page_id = page.get("id")
|
||||
@@ -31,6 +39,70 @@ def chromium_open(url: str) -> None:
|
||||
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):
|
||||
def _json(self, code: int, payload: dict):
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
@@ -58,6 +130,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if not url.startswith("http://") and not url.startswith("https://"):
|
||||
self._json(400, {"detail": "Invalid URL"})
|
||||
return
|
||||
width = data.get("width")
|
||||
height = data.get("height")
|
||||
apply_resolution(width, height)
|
||||
chromium_open(url)
|
||||
print(f"open_ok url={url}", flush=True)
|
||||
self._json(200, {"ok": True, "url": url})
|
||||
|
||||
@@ -3,17 +3,21 @@ FROM debian:bookworm-slim
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
tini \
|
||||
python3 \
|
||||
xvfb \
|
||||
x11vnc \
|
||||
freerdp2-x11 \
|
||||
novnc \
|
||||
websockify \
|
||||
xdotool \
|
||||
ca-certificates \
|
||||
fonts-dejavu-core \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
COPY manager.py /manager.py
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 6080
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
EXPOSE 6080 7001
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
|
||||
|
||||
+185
-43
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
RDP_HOST="${RDP_HOST:?RDP_HOST is required}"
|
||||
RDP_HOST="${RDP_HOST:-}"
|
||||
RDP_PORT="${RDP_PORT:-3389}"
|
||||
RDP_USER="${RDP_USER:-}"
|
||||
RDP_PASSWORD="${RDP_PASSWORD:-}"
|
||||
@@ -24,88 +24,230 @@ cat > /opt/portal/index.html <<HTML
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<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;
|
||||
cursor:grab;user-select:none;touch-action:none
|
||||
}
|
||||
.nav-panel.dragging{cursor:grabbing;opacity:.85}
|
||||
.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>
|
||||
<body>
|
||||
<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>
|
||||
<button class="nav-btn" id="btn-fs" type="button" title="На весь экран">⛶</button>
|
||||
</div>
|
||||
<script type="module">
|
||||
import RFB from './core/rfb.js';
|
||||
const wsBase = location.pathname.replace(/\/+$/, '');
|
||||
const wsUrl = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + wsBase + '/websockify';
|
||||
const rfb = new RFB(document.getElementById('screen'), wsUrl);
|
||||
const statusEl = document.getElementById('status');
|
||||
const XK_ALT_L = 0xffe9;
|
||||
const XK_LEFT = 0xff51;
|
||||
|
||||
let rfb = null;
|
||||
let connected = false;
|
||||
let reconnectTimer = null;
|
||||
let reconnectAttempts = 0;
|
||||
const MAX_RECONNECT = 12;
|
||||
const DELAYS = [1000,2000,3000,5000,8000];
|
||||
let manualDisconnect = false;
|
||||
|
||||
function showStatus(text, isError) {
|
||||
const spinner = isError ? '' : '<span class="spinner"></span>';
|
||||
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';
|
||||
const CLOSE_PATH = '${TOUCH_PATH}'.replace(/\/touch$/, '/close');
|
||||
function goSessionClosed() {
|
||||
|
||||
function goSessionClosed(reason) {
|
||||
const r = reason === 'limit' ? 'limit' : 'idle';
|
||||
try {
|
||||
if (window.top && window.top !== window) {
|
||||
window.top.location.href = SESSION_CLOSED_URL;
|
||||
return;
|
||||
}
|
||||
} catch (e) {}
|
||||
window.location.href = SESSION_CLOSED_URL;
|
||||
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() {
|
||||
try {
|
||||
const res = await fetch('${TOUCH_PATH}', {method:'POST', credentials:'include'});
|
||||
const res = await fetch(TOUCH_PATH, {method:'POST', credentials:'include'});
|
||||
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) {}
|
||||
}
|
||||
|
||||
let closing = false;
|
||||
async function closeSessionNow() {
|
||||
if (closing) return;
|
||||
closing = true;
|
||||
try {
|
||||
await fetch(CLOSE_PATH, {method:'POST', credentials:'include', keepalive:true});
|
||||
} catch (e) {}
|
||||
manualDisconnect = true;
|
||||
clearTimeout(reconnectTimer);
|
||||
try { if (rfb) rfb.disconnect(); } catch(e) {}
|
||||
try { await fetch(CLOSE_PATH, {method:'POST', credentials:'include', keepalive:true}); } catch(e) {}
|
||||
}
|
||||
|
||||
if (enableHeartbeat) {
|
||||
setInterval(touch, 15000);
|
||||
touch();
|
||||
window.addEventListener('pagehide', 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.getElementById('btn-fs').addEventListener('click', () => {
|
||||
if (!document.fullscreenElement) document.documentElement.requestFullscreen();
|
||||
else document.exitFullscreen();
|
||||
});
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
document.getElementById('btn-fs').textContent = document.fullscreenElement ? '✕' : '⛶';
|
||||
document.getElementById('btn-fs').title = document.fullscreenElement ? 'Выйти из полного экрана' : 'На весь экран';
|
||||
});
|
||||
document.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
|
||||
(function(){
|
||||
const p = document.querySelector('.nav-panel');
|
||||
const SK = 'rdp_nav_pos';
|
||||
try { const s = JSON.parse(localStorage.getItem(SK)); if(s){p.style.left=s.x+'px';p.style.top=s.y+'px';} } catch(e){}
|
||||
let ox, oy, dragged = false;
|
||||
p.addEventListener('pointerdown', e => {
|
||||
if (e.target.closest('button')) return;
|
||||
dragged = false;
|
||||
ox = e.clientX - p.getBoundingClientRect().left;
|
||||
oy = e.clientY - p.getBoundingClientRect().top;
|
||||
p.setPointerCapture(e.pointerId);
|
||||
p.classList.add('dragging');
|
||||
});
|
||||
p.addEventListener('pointermove', e => {
|
||||
if (!p.hasPointerCapture(e.pointerId)) return;
|
||||
dragged = true;
|
||||
const x = Math.max(0, Math.min(window.innerWidth - p.offsetWidth, e.clientX - ox));
|
||||
const y = Math.max(0, Math.min(window.innerHeight - p.offsetHeight, e.clientY - oy));
|
||||
p.style.left = x + 'px';
|
||||
p.style.top = y + 'px';
|
||||
});
|
||||
p.addEventListener('pointerup', () => {
|
||||
p.classList.remove('dragging');
|
||||
if (dragged) try { localStorage.setItem(SK, JSON.stringify({x: parseInt(p.style.left), y: parseInt(p.style.top)})); } catch(e){}
|
||||
});
|
||||
})();
|
||||
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
HTML
|
||||
|
||||
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_PID=$!
|
||||
sleep 1
|
||||
|
||||
RDP_ARGS=(
|
||||
"/v:${RDP_HOST}:${RDP_PORT}"
|
||||
"/cert:ignore"
|
||||
"/f"
|
||||
"/dynamic-resolution"
|
||||
"/gfx-h264:avc444"
|
||||
"/network:auto"
|
||||
"+clipboard"
|
||||
)
|
||||
|
||||
if [ -n "$RDP_SECURITY" ]; then
|
||||
RDP_ARGS+=("/sec:${RDP_SECURITY}")
|
||||
fi
|
||||
|
||||
if [ -n "$RDP_USER" ]; then
|
||||
RDP_ARGS+=("/u:${RDP_USER}")
|
||||
fi
|
||||
if [ -n "$RDP_PASSWORD" ]; then
|
||||
RDP_ARGS+=("/p:${RDP_PASSWORD}")
|
||||
fi
|
||||
if [ -n "$RDP_DOMAIN" ]; then
|
||||
RDP_ARGS+=("/d:${RDP_DOMAIN}")
|
||||
fi
|
||||
|
||||
xfreerdp "${RDP_ARGS[@]}" >/tmp/xfreerdp.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=$!
|
||||
|
||||
python3 /manager.py >/tmp/manager.log 2>&1 &
|
||||
MANAGER_PID=$!
|
||||
|
||||
cleanup() {
|
||||
python3 -c "
|
||||
import urllib.request, sys
|
||||
try:
|
||||
urllib.request.urlopen('http://localhost:7001/disconnect', b'', timeout=3)
|
||||
except Exception as e:
|
||||
sys.stderr.write(str(e) + '\n')
|
||||
" 2>/dev/null || true
|
||||
kill "$X11VNC_PID" "$WEBSOCKIFY_PID" "$MANAGER_PID" "$XVFB_PID" 2>/dev/null || true
|
||||
exit 0
|
||||
}
|
||||
trap cleanup TERM INT
|
||||
|
||||
wait "$WEBSOCKIFY_PID" || true
|
||||
cleanup
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
"""On-demand xfreerdp manager. HTTP on port 7001."""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger("rdp-manager")
|
||||
|
||||
DISPLAY = os.environ.get("DISPLAY", ":1")
|
||||
RDP_HOST = os.environ.get("RDP_HOST", "")
|
||||
RDP_PORT = os.environ.get("RDP_PORT", "3389")
|
||||
RDP_USER = os.environ.get("RDP_USER", "")
|
||||
RDP_PASSWORD = os.environ.get("RDP_PASSWORD", "")
|
||||
RDP_DOMAIN = os.environ.get("RDP_DOMAIN", "")
|
||||
RDP_SECURITY = os.environ.get("RDP_SECURITY", "")
|
||||
|
||||
STATE_FILE = "/tmp/rdp_state.json"
|
||||
|
||||
_lock = threading.Lock()
|
||||
_proc: subprocess.Popen | None = None
|
||||
_should_be_connected = False
|
||||
|
||||
|
||||
def _save_state():
|
||||
try:
|
||||
with open(STATE_FILE, "w") as f:
|
||||
json.dump({"should_be_connected": _should_be_connected}, f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _load_state() -> bool:
|
||||
try:
|
||||
with open(STATE_FILE) as f:
|
||||
return json.load(f).get("should_be_connected", False)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _build_args():
|
||||
args = [
|
||||
"xfreerdp",
|
||||
f"/v:{RDP_HOST}:{RDP_PORT}",
|
||||
"/cert:ignore",
|
||||
"/f",
|
||||
"/dynamic-resolution",
|
||||
"/gfx-h264:avc444",
|
||||
"/network:auto",
|
||||
"+clipboard",
|
||||
]
|
||||
if RDP_SECURITY:
|
||||
args.append(f"/sec:{RDP_SECURITY}")
|
||||
if RDP_USER:
|
||||
args.append(f"/u:{RDP_USER}")
|
||||
if RDP_PASSWORD:
|
||||
args.append(f"/p:{RDP_PASSWORD}")
|
||||
if RDP_DOMAIN:
|
||||
args.append(f"/d:{RDP_DOMAIN}")
|
||||
return args
|
||||
|
||||
|
||||
def _launch():
|
||||
global _proc
|
||||
env = dict(os.environ)
|
||||
env["DISPLAY"] = DISPLAY
|
||||
log_file = open("/tmp/xfreerdp.log", "a")
|
||||
_proc = subprocess.Popen(_build_args(), stdout=log_file, stderr=log_file, env=env)
|
||||
log.info("xfreerdp started pid=%s target=%s:%s", _proc.pid, RDP_HOST, RDP_PORT)
|
||||
return _proc
|
||||
|
||||
|
||||
def _monitor_loop():
|
||||
while True:
|
||||
time.sleep(5)
|
||||
with _lock:
|
||||
if not _should_be_connected:
|
||||
continue
|
||||
if _proc is None or _proc.poll() is not None:
|
||||
log.info("xfreerdp exited unexpectedly, reconnecting in 3s")
|
||||
time.sleep(3)
|
||||
_launch()
|
||||
|
||||
|
||||
def _anti_idle_loop():
|
||||
env = {**os.environ, "DISPLAY": DISPLAY}
|
||||
toggle = False
|
||||
while True:
|
||||
time.sleep(60)
|
||||
with _lock:
|
||||
active = _should_be_connected and _proc is not None and _proc.poll() is None
|
||||
if not active:
|
||||
continue
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["xdotool", "search", "--name", "FreeRDP"],
|
||||
env=env, capture_output=True, timeout=5,
|
||||
)
|
||||
win_id = r.stdout.decode().strip().splitlines()[0] if r.stdout.strip() else ""
|
||||
if win_id:
|
||||
x, y = (5, 80) if toggle else (6, 81)
|
||||
subprocess.run(
|
||||
["xdotool", "mousemove", "--window", win_id, str(x), str(y)],
|
||||
env=env, capture_output=True, timeout=5,
|
||||
)
|
||||
subprocess.run(
|
||||
["xdotool", "click", "--window", win_id, "1"],
|
||||
env=env, capture_output=True, timeout=5,
|
||||
)
|
||||
subprocess.run(
|
||||
["xdotool", "key", "--window", win_id, "--clearmodifiers", "shift"],
|
||||
env=env, capture_output=True, timeout=5,
|
||||
)
|
||||
toggle = not toggle
|
||||
log.debug("anti_idle click window=%s pos=%s,%s", win_id, x, y)
|
||||
else:
|
||||
log.debug("anti_idle: xfreerdp window not found")
|
||||
except Exception as e:
|
||||
log.debug("anti_idle error: %s", e)
|
||||
|
||||
|
||||
threading.Thread(target=_monitor_loop, daemon=True).start()
|
||||
threading.Thread(target=_anti_idle_loop, daemon=True).start()
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt, *args):
|
||||
pass
|
||||
|
||||
def _json(self, code, data):
|
||||
body = json.dumps(data).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/health":
|
||||
with _lock:
|
||||
connected = _proc is not None and _proc.poll() is None
|
||||
pid = _proc.pid if connected else None
|
||||
self._json(200, {
|
||||
"connected": connected,
|
||||
"pid": pid,
|
||||
"target": f"{RDP_HOST}:{RDP_PORT}",
|
||||
"should_be_connected": _should_be_connected,
|
||||
})
|
||||
else:
|
||||
self._json(404, {"error": "not found"})
|
||||
|
||||
def do_POST(self):
|
||||
global _proc, _should_be_connected
|
||||
if self.path == "/connect":
|
||||
with _lock:
|
||||
_should_be_connected = True
|
||||
_save_state()
|
||||
if _proc is not None and _proc.poll() is None:
|
||||
self._json(200, {"ok": True, "pid": _proc.pid, "already": True})
|
||||
return
|
||||
proc = _launch()
|
||||
self._json(200, {"ok": True, "pid": proc.pid})
|
||||
elif self.path == "/disconnect":
|
||||
with _lock:
|
||||
_should_be_connected = False
|
||||
_save_state()
|
||||
if _proc is not None:
|
||||
_proc.terminate()
|
||||
try:
|
||||
_proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
_proc.kill()
|
||||
_proc = None
|
||||
log.info("xfreerdp disconnected")
|
||||
self._json(200, {"ok": True})
|
||||
else:
|
||||
self._json(404, {"error": "not found"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if not RDP_HOST:
|
||||
log.warning("RDP_HOST not set — connect calls will fail")
|
||||
if _load_state():
|
||||
log.info("restoring state: reconnecting xfreerdp")
|
||||
_should_be_connected = True
|
||||
_launch()
|
||||
log.info("manager started on :7001")
|
||||
HTTPServer(("0.0.0.0", 7001), Handler).serve_forever()
|
||||
@@ -1,8 +1,18 @@
|
||||
entryPoints:
|
||||
web:
|
||||
address: ":80"
|
||||
forwardedHeaders:
|
||||
trustedIPs:
|
||||
- "10.0.0.0/8"
|
||||
- "172.16.0.0/12"
|
||||
- "192.168.0.0/16"
|
||||
websecure:
|
||||
address: ":443"
|
||||
forwardedHeaders:
|
||||
trustedIPs:
|
||||
- "10.0.0.0/8"
|
||||
- "172.16.0.0/12"
|
||||
- "192.168.0.0/16"
|
||||
|
||||
providers:
|
||||
docker:
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
LANG=ru_RU.UTF-8 \
|
||||
LC_ALL=ru_RU.UTF-8 \
|
||||
LANGUAGE=ru_RU:ru
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
tini \
|
||||
chromium \
|
||||
xvfb \
|
||||
x11vnc \
|
||||
@@ -12,7 +16,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
websockify \
|
||||
python3 \
|
||||
ca-certificates \
|
||||
x11-xserver-utils \
|
||||
x11-utils \
|
||||
fonts-dejavu-core \
|
||||
python3-cryptography \
|
||||
locales \
|
||||
&& sed -i 's/# ru_RU.UTF-8/ru_RU.UTF-8/' /etc/locale.gen \
|
||||
&& locale-gen \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
@@ -20,4 +30,4 @@ COPY manager.py /manager.py
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 6080
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
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}"
|
||||
CHROME_WINDOW_SIZE="${CHROME_WINDOW_SIZE:-1920,1080}"
|
||||
ENABLE_HEARTBEAT="${ENABLE_HEARTBEAT:-1}"
|
||||
@@ -42,7 +42,8 @@ cat > /opt/portal/index.html <<'HTML'
|
||||
.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
|
||||
box-shadow:0 10px 28px rgba(0,0,0,.36);padding:9px 10px;border-radius:14px;
|
||||
cursor:grab;user-select:none;touch-action:none
|
||||
}
|
||||
.nav-btn{
|
||||
border:1px solid rgba(255,255,255,.26);border-radius:999px;padding:9px 14px;cursor:pointer;letter-spacing:.01em;
|
||||
@@ -50,6 +51,7 @@ cat > /opt/portal/index.html <<'HTML'
|
||||
}
|
||||
.nav-btn:hover{filter:brightness(1.08)}
|
||||
.nav-btn:active{transform:translateY(1px)}
|
||||
.nav-panel.dragging{cursor:grabbing;opacity:.85}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -58,6 +60,7 @@ cat > /opt/portal/index.html <<'HTML'
|
||||
<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>
|
||||
<button class="nav-btn" id="btn-fs" type="button" title="На весь экран">⛶</button>
|
||||
</div>
|
||||
<script type="module">
|
||||
import RFB from './core/rfb.js';
|
||||
@@ -251,8 +254,43 @@ cat > /opt/portal/index.html <<'HTML'
|
||||
|
||||
document.getElementById('btn-back').addEventListener('click', () => chord(XK_ALT_L, XK_LEFT, 'AltLeft', 'ArrowLeft'));
|
||||
document.getElementById('btn-home').addEventListener('click', goHome);
|
||||
document.getElementById('btn-fs').addEventListener('click', () => {
|
||||
if (!document.fullscreenElement) document.documentElement.requestFullscreen();
|
||||
else document.exitFullscreen();
|
||||
});
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
document.getElementById('btn-fs').textContent = document.fullscreenElement ? '✕' : '⛶';
|
||||
document.getElementById('btn-fs').title = document.fullscreenElement ? 'Выйти из полного экрана' : 'На весь экран';
|
||||
});
|
||||
document.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
|
||||
(function(){
|
||||
const p = document.querySelector('.nav-panel');
|
||||
const SK = 'portal_nav_pos';
|
||||
try { const s = JSON.parse(localStorage.getItem(SK)); if(s){p.style.left=s.x+'px';p.style.top=s.y+'px';} } catch(e){}
|
||||
let ox, oy, dragged = false;
|
||||
p.addEventListener('pointerdown', e => {
|
||||
if (e.target.closest('button')) return;
|
||||
dragged = false;
|
||||
ox = e.clientX - p.getBoundingClientRect().left;
|
||||
oy = e.clientY - p.getBoundingClientRect().top;
|
||||
p.setPointerCapture(e.pointerId);
|
||||
p.classList.add('dragging');
|
||||
});
|
||||
p.addEventListener('pointermove', e => {
|
||||
if (!p.hasPointerCapture(e.pointerId)) return;
|
||||
dragged = true;
|
||||
const x = Math.max(0, Math.min(window.innerWidth - p.offsetWidth, e.clientX - ox));
|
||||
const y = Math.max(0, Math.min(window.innerHeight - p.offsetHeight, e.clientY - oy));
|
||||
p.style.left = x + 'px';
|
||||
p.style.top = y + 'px';
|
||||
});
|
||||
p.addEventListener('pointerup', () => {
|
||||
p.classList.remove('dragging');
|
||||
if (dragged) try { localStorage.setItem(SK, JSON.stringify({x: parseInt(p.style.left), y: parseInt(p.style.top)})); } catch(e){}
|
||||
});
|
||||
})();
|
||||
|
||||
connectRfb('Подключение к слоту...');
|
||||
</script>
|
||||
</body>
|
||||
@@ -264,6 +302,22 @@ export CHROME_WINDOW_SIZE
|
||||
Xvfb "$DISPLAY_NUM" -screen 0 "$SCREEN_GEOMETRY" >/tmp/xvfb.log 2>&1 &
|
||||
fluxbox >/tmp/fluxbox.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
|
||||
|
||||
+313
-16
@@ -1,10 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from urllib.parse import urlparse, urlunparse, quote
|
||||
|
||||
DISPLAY = os.environ.get("DISPLAY", ":1")
|
||||
CHROME_WINDOW_SIZE = os.environ.get("CHROME_WINDOW_SIZE", "1920,1080")
|
||||
@@ -18,14 +21,217 @@ _state = {
|
||||
"mode": "idle",
|
||||
"target": "",
|
||||
"resolution": CHROME_WINDOW_SIZE,
|
||||
"profile_dir": None,
|
||||
"extension_dir": None,
|
||||
}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
_AUTOFILL_CONTENT_JS = r"""
|
||||
(function() {
|
||||
const CREDS = __CREDS__;
|
||||
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;
|
||||
el.dispatchEvent(new FocusEvent('focus', { bubbles: 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 InputEvent('input', { bubbles: true, data: v, inputType: 'insertText' }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: v.slice(-1) }));
|
||||
el.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
|
||||
return true;
|
||||
}
|
||||
|
||||
function tryFill() {
|
||||
const p = findPassField();
|
||||
const u = findUserField(p);
|
||||
// Fill login FIRST so password-triggered re-render doesn't clear it
|
||||
if (CREDS.login && u) {
|
||||
if (setNativeValue(u, CREDS.login)) {
|
||||
console.log('[PortalAutofill] user filled');
|
||||
}
|
||||
}
|
||||
if (CREDS.password && p) {
|
||||
if (setNativeValue(p, CREDS.password)) {
|
||||
console.log('[PortalAutofill] password filled');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for SPA re-renders clearing the fields and re-fill continuously
|
||||
let _filling = false;
|
||||
function scheduleFill() {
|
||||
if (_filling) return;
|
||||
_filling = true;
|
||||
requestAnimationFrame(() => { tryFill(); _filling = false; });
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', tryFill);
|
||||
} else {
|
||||
tryFill();
|
||||
}
|
||||
|
||||
// Periodic check for first 30s in case of async SPA resets
|
||||
let _checks = 0;
|
||||
const _interval = setInterval(() => {
|
||||
tryFill();
|
||||
if (++_checks >= 30) clearInterval(_interval);
|
||||
}, 1000);
|
||||
|
||||
const obs = new MutationObserver(scheduleFill);
|
||||
if (document.documentElement) {
|
||||
obs.observe(document.documentElement, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
const resetAndRefill = () => {
|
||||
_checks = 0;
|
||||
setTimeout(tryFill, 150);
|
||||
setTimeout(tryFill, 600);
|
||||
};
|
||||
['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:
|
||||
proc = _state.get("proc")
|
||||
if not proc:
|
||||
return
|
||||
if proc:
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||
proc.wait(timeout=4)
|
||||
@@ -37,6 +243,12 @@ def _stop_current() -> None:
|
||||
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:
|
||||
_stop_current()
|
||||
@@ -62,32 +274,103 @@ def _sanitize_resolution(width: int | None, height: int | None) -> tuple[int, in
|
||||
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 apply_resolution(width: int | None, height: int | None) -> tuple[int, int]:
|
||||
safe_w, safe_h = _sanitize_resolution(width, height)
|
||||
# Best effort: Xvfb usually exposes RandR and accepts xrandr -s.
|
||||
def _xrandr_output_name() -> str | None:
|
||||
try:
|
||||
subprocess.run( # noqa: S603
|
||||
["xrandr", "-display", DISPLAY, "-s", f"{safe_w}x{safe_h}"],
|
||||
check=False,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
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: 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}"
|
||||
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)
|
||||
profile_dir = _create_chrome_profile()
|
||||
extension_dir = _create_autofill_extension(login, password)
|
||||
url_with_creds = url # credentials in URL break SPA fetch; extension handles auth
|
||||
|
||||
# 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 = [
|
||||
"chromium",
|
||||
chromium_bin,
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
@@ -103,9 +386,21 @@ def open_web(url: str, width: int | None = None, height: int | None = None) -> N
|
||||
f"--window-size={safe_w},{safe_h}",
|
||||
"--no-first-run",
|
||||
"--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)
|
||||
_state["profile_dir"] = profile_dir
|
||||
_state["extension_dir"] = extension_dir
|
||||
|
||||
|
||||
def open_rdp(payload: dict) -> None:
|
||||
@@ -183,8 +478,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return
|
||||
width = data.get("width")
|
||||
height = data.get("height")
|
||||
login = (data.get("login") or "").strip()
|
||||
password = (data.get("password") or "").strip()
|
||||
with _lock:
|
||||
open_web(url, width=width, height=height)
|
||||
open_web(url, width=width, height=height, login=login, password=password)
|
||||
self._json(
|
||||
200,
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user