feat: redesign portal UX and stabilize web session runtime

This commit is contained in:
2026-04-13 08:35:07 +00:00
commit fc46d90194
29 changed files with 3915 additions and 0 deletions

13
.env.example Normal file
View File

@@ -0,0 +1,13 @@
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

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
.env
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
.pytest_cache/
.mypy_cache/
.venv/
venv/
.DS_Store
traefik/letsencrypt/acme.json

154
README.md Normal file
View File

@@ -0,0 +1,154 @@
# Portal Stand Access (MVP)
MVP-портал доступа к стендам в Proxmox через единый вход `https://stend.4mont.ru`.
## 1. Архитектура
- `traefik`: входная точка TLS, маршрутизация на API и динамические сессионные контейнеры.
- `api` (FastAPI): auth, ACL, админ API, создание runtime-сессий, cleanup.
- `db` (PostgreSQL): пользователи, сервисы, ACL, сессии, аудит.
- `portal-kiosk` image: Chromium kiosk + noVNC (для WEB target).
- `portal-rdp-proxy` image: xfreerdp + websockify/noVNC proxy до удаленного RDP host:port.
- `portal-universal-runtime` image: универсальный runtime (WEB/RDP), используется общим warm pool `UNIVERSAL_POOL_SIZE`.
- Опциональный prewarm pool:
- глобальный fallback `PREWARM_POOL_SIZE`;
- приоритетный `warm_pool_size` на каждом сервисе в админке.
Поток:
1. Пользователь логинится на `/`.
2. Dashboard показывает разрешённые сервисы.
3. Клик по `/go/<slug>` -> ACL check + проверка `expires_at` + создание записи `sessions` + старт отдельного контейнера.
4. Пользователь редиректится на `/s/<session_id>/`.
5. Traefik отправляет `/s/<session_id>/...` в конкретный runtime контейнер по dynamic labels.
6. Runtime страница шлёт heartbeat в `/api/sessions/<session_id>/touch`.
7. Фоновый cleanup завершает сессии при idle > 30 минут.
При prewarm:
- создаются warm-контейнеры с маршрутом `/svc/<slug>/`;
- клик по плитке использует prewarmed runtime без задержки cold start;
- сессии в БД создаются, но контейнеры остаются пулом (без стопа на каждую сессию).
- в `/admin` есть:
- отдельные разделы Users / WEB / RDP (список + форма выбранной записи);
- `pool size` на сервис;
- кнопка `Prewarm now`;
- health `running/desired` по каждому пулу.
- авто-генерация `slug` из названия (поддержка кириллицы -> латиница).
## 2. Схема БД
Основные таблицы:
- `users`
- `services`
- `user_service_access`
- `sessions`
Дополнительно:
- `audit_logs`
SQL-схема: `scripts/schema.sql`.
## 3. Безопасность
- Пароли: `argon2` (`passlib[argon2]`).
- Cookie auth: `HttpOnly`, `Secure`, `SameSite=Strict`.
- CSRF:
- формы (`/login`) через hidden token + cookie;
- admin JSON API через `X-CSRF-Token`.
- Проверки при каждом запросе:
- пользователь `active=true`;
- `expires_at > now()`;
- ACL на `/go/<slug>`.
- Аудит: события входа и создания сессий в `audit_logs`.
## 4. Файлы
- `docker-compose.yml`
- `traefik/traefik.yml`
- `traefik/dynamic/security.yml`
- `app/main.py`
- `kiosk/Dockerfile`, `kiosk/entrypoint.sh`
- `rdp-proxy/Dockerfile`, `rdp-proxy/entrypoint.sh`
## 5. Запуск
```bash
cp .env.example .env
mkdir -p traefik/letsencrypt
touch traefik/letsencrypt/acme.json
chmod 600 traefik/letsencrypt/acme.json
```
Собрать runtime образы:
```bash
docker compose --profile build-only build kiosk-image rdp-proxy-image universal-runtime-image
```
Поднять систему:
```bash
docker compose up -d --build
```
Проверка:
```bash
docker compose ps
docker compose logs -f api traefik
```
Дефолтный админ берётся из `.env` (`ADMIN_USERNAME`, `ADMIN_PASSWORD`).
## 6. Примеры admin API
1) Создать сервис WEB:
```bash
curl -k -X POST "https://stend.4mont.ru/api/admin/services" \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: <csrf_from_cookie>" \
-H "Cookie: portal_auth=<cookie>; csrf_token=<csrf>" \
-d '{"name":"CRM","slug":"crm","type":"WEB","target":"http://192.168.1.10:3000","active":true}'
```
2) Создать сервис RDP:
```bash
curl -k -X POST "https://stend.4mont.ru/api/admin/services" \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: <csrf_from_cookie>" \
-H "Cookie: portal_auth=<cookie>; csrf_token=<csrf>" \
-d '{"name":"Windows Desktop","slug":"win-rdp1","type":"RDP","target":"192.168.1.60:3389","active":true}'
```
Для RDP `target` также поддерживает креды и параметры:
```text
rdp://user:password@192.168.1.60:3389?domain=AD&sec=nla
```
3) Создать пользователя:
```bash
curl -k -X POST "https://stend.4mont.ru/api/admin/users" \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: <csrf_from_cookie>" \
-H "Cookie: portal_auth=<cookie>; csrf_token=<csrf>" \
-d '{"username":"user1","password":"Passw0rd!","expires_at":"2026-12-31T23:59:59+00:00","active":true}'
```
4) Назначить ACL:
```bash
curl -k -X PUT "https://stend.4mont.ru/api/admin/users/2/acl" \
-H "Content-Type: application/json" \
-H "X-CSRF-Token: <csrf_from_cookie>" \
-H "Cookie: portal_auth=<cookie>; csrf_token=<csrf>" \
-d '{"service_ids":[1,2]}'
```
## 7. Ограничения MVP
- Нет отдельной UI-админки (есть admin API).
- TTL неактивности основан на heartbeat runtime-страницы.
- Для production стоит добавить Alembic-миграции, rate limiting и централизованный логинг.

11
app/Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

1613
app/main.py Normal file

File diff suppressed because it is too large Load Diff

9
app/requirements.txt Normal file
View File

@@ -0,0 +1,9 @@
fastapi==0.116.1
uvicorn[standard]==0.35.0
sqlalchemy==2.0.43
psycopg2-binary==2.9.10
python-multipart==0.0.20
jinja2==3.1.6
passlib[argon2]==1.7.4
docker==7.1.0
itsdangerous==2.2.0

BIN
app/static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none">
<rect width="120" height="120" rx="16" fill="#E8EFF6"/>
<rect x="20" y="28" width="80" height="16" rx="6" fill="#B8CBDC"/>
<rect x="20" y="52" width="80" height="40" rx="8" fill="#CBD9E7"/>
<circle cx="36" cy="72" r="8" fill="#9FB4C8"/>
<rect x="50" y="66" width="38" height="12" rx="6" fill="#9FB4C8"/>
</svg>

After

Width:  |  Height:  |  Size: 421 B

330
app/static/style.css Normal file
View File

@@ -0,0 +1,330 @@
:root {
--bg: #f4f6f8;
--fg: #1a2634;
--card: #ffffff;
--accent: #0f5b94;
--line: #d6e1eb;
}
body {
margin: 0;
font-family: "IBM Plex Sans", sans-serif;
color: var(--fg);
background: radial-gradient(circle at top right, #d7e8f7, var(--bg) 45%);
}
.center-box {
min-height: 100vh;
display: grid;
place-content: center;
gap: 1rem;
padding: 1.2rem;
}
.brand-logo {
width: min(440px, 80vw);
height: auto;
justify-self: center;
}
.brand-logo-fullscreen {
width: min(23vw, 360px);
max-height: 14vh;
object-fit: contain;
}
.login-page .panel {
width: min(520px, 92vw);
justify-self: center;
}
.login-title {
text-align: center;
margin: 0;
font-size: clamp(1.1rem, 2.2vw, 1.6rem);
}
.header-logo {
width: 120px;
height: auto;
}
.panel {
background: var(--card);
padding: 1.25rem;
border-radius: 12px;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.08);
display: grid;
gap: 0.5rem;
min-width: 320px;
}
input, button, textarea {
padding: 0.6rem;
border-radius: 8px;
border: 1px solid #bdd1e2;
font: inherit;
}
textarea {
min-height: 92px;
resize: vertical;
}
button {
background: var(--accent);
color: #fff;
border: none;
cursor: pointer;
}
.btn-link {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.58rem 0.9rem;
border-radius: 8px;
background: #0f5b94;
color: #fff;
text-decoration: none;
font-weight: 600;
}
.btn-link.secondary {
background: #e7eef5;
color: #16344f;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 1rem 1.25rem;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
padding: 1rem;
}
.admin-layout {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
padding: 1rem;
max-width: 1400px;
margin: 0 auto;
}
.admin-intro {
border: 1px solid var(--line);
border-radius: 10px;
background: #f8fbfe;
padding: 0.8rem 0.9rem;
color: #2b4760;
line-height: 1.4;
}
.summary-strip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.6rem;
margin: 0.7rem 0 0.8rem;
}
.summary-card {
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
padding: 0.65rem 0.75rem;
}
.summary-label {
color: #53718c;
font-size: 0.8rem;
}
.summary-value {
margin-top: 0.2rem;
font-weight: 700;
color: #14354f;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.labelled-grid {
gap: 0.7rem;
}
.field-col {
display: grid;
gap: 0.32rem;
}
.field-col > span {
font-size: 0.83rem;
color: #44627d;
font-weight: 600;
}
.field-help {
margin: -0.2rem 0 0.4rem;
color: #52708a;
font-size: 0.85rem;
}
.admin-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.admin-table th,
.admin-table td {
border-bottom: 1px solid #e2e8ef;
padding: 0.45rem 0.35rem;
text-align: left;
vertical-align: top;
}
.actions {
display: flex;
gap: 0.35rem;
}
.acl-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.35rem;
margin: 0.6rem 0;
}
.tab-row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.tab-btn {
background: #e7eef5;
color: #15344f;
}
.active-tab {
background: #0f5b94;
color: #fff;
}
.split {
display: grid;
grid-template-columns: 320px 1fr;
gap: 1rem;
}
.list-title {
font-weight: 600;
margin-bottom: 0.4rem;
}
.list-box {
border: 1px solid var(--line);
border-radius: 10px;
background: #f8fbfe;
max-height: 460px;
overflow: auto;
padding: 0.3rem;
}
.list-search {
width: 100%;
box-sizing: border-box;
margin-bottom: 0.45rem;
background: #fff;
}
.list-item {
width: 100%;
text-align: left;
margin-bottom: 0.35rem;
background: #fff;
color: #17354f;
border: 1px solid #d6e1eb;
}
.list-item.selected-item {
border-color: #4b8fc4;
box-shadow: inset 0 0 0 1px rgba(75, 143, 196, 0.35);
background: #f1f8ff;
}
.service-row {
display: flex;
align-items: center;
gap: 0.6rem;
}
.service-thumb {
width: 40px;
height: 40px;
border-radius: 8px;
object-fit: cover;
border: 1px solid #d8e3ed;
background: #edf3f9;
flex: 0 0 40px;
}
.service-icon-preview {
width: 80px;
height: 80px;
border-radius: 10px;
object-fit: cover;
border: 1px solid #d8e3ed;
background: #edf3f9;
}
.icon-row {
display: flex;
gap: 0.8rem;
align-items: center;
}
.status-dot {
display: inline-block;
width: 9px;
height: 9px;
border-radius: 999px;
margin-right: 0.35rem;
}
.status-ok { background: #17a35d; }
.status-degraded { background: #f1a312; }
.status-down { background: #d33d3d; }
.health-box, .icon-box {
border: 1px solid #d6e1eb;
background: #f8fbfe;
border-radius: 10px;
padding: 0.8rem;
}
.container-table-wrap {
margin-top: 0.6rem;
max-height: 220px;
overflow: auto;
border: 1px solid #d6e1eb;
border-radius: 8px;
background: #fff;
}
.muted {
color: #4b6178;
font-size: 0.86rem;
}
@media (max-width: 900px) {
.split {
grid-template-columns: 1fr;
}
.brand-logo-fullscreen {
width: min(42vw, 260px);
max-height: 20vh;
}
}
.tile {
display: block;
text-decoration: none;
background: var(--card);
color: inherit;
border-radius: 12px;
padding: 1rem;
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.06);
border: 1px solid transparent;
transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease;
}
.tile:hover {
transform: translateY(-2px);
border-color: #bdd3e6;
box-shadow: 0 14px 26px rgba(15, 64, 103, 0.12);
}
.tile-icon {
width: 56px;
height: 56px;
border-radius: 10px;
object-fit: cover;
border: 1px solid #d8e3ed;
background: #edf3f9;
margin-bottom: 0.5rem;
}
.tile h3 {
margin: 0.1rem 0 0.25rem;
}
.tile p {
margin: 0;
color: #48637c;
}
.tile small {
display: block;
margin-top: 0.45rem;
color: #4b6178;
}

871
app/templates/admin.html Normal file
View File

@@ -0,0 +1,871 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Администрирование</title>
<link rel="stylesheet" href="/static/style.css" />
</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>
</div>
<a href="/" class="btn-link secondary">Главная панель</a>
</header>
<main class="admin-layout">
<section class="panel">
<div class="admin-intro">
Основной режим: <b>WEB</b>. Пользователь выбирает сервис, а портал открывает нужный URL в заранее прогретом браузере.
Поле <b>pool size</b> задаёт, сколько таких прогретых контейнеров держать для конкретного сервиса.
</div>
</section>
<section class="panel">
<div class="tab-row">
<button class="tab-btn" data-tab="users" onclick="showTab('users')">Users</button>
<button class="tab-btn" data-tab="web" onclick="showTab('web')">WEB</button>
<button class="tab-btn" data-tab="rdp" onclick="showTab('rdp')">RDP</button>
<button class="tab-btn" data-tab="stats" onclick="showTab('stats')">Stats</button>
</div>
</section>
<section id="tab-users" class="panel admin-tab">
<h3>Пользователи</h3>
<div class="split">
<div>
<div class="list-title">Список пользователей</div>
<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>
<small class="user-days" data-exp="{{u.expires_at.isoformat()}}"></small>
</button>
{% endfor %}
</div>
</div>
<div>
<div class="list-title">Редактирование пользователя</div>
<div class="form-grid">
<input id="u_id" type="hidden" />
<input id="u_name" placeholder="username" />
<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>
<select id="u_admin"><option value="false">user</option><option value="true">admin</option></select>
</div>
<div class="actions">
<button onclick="saveUser()">Save</button>
<button onclick="deleteUser()">Delete</button>
<button onclick="clearUserForm()">Clear</button>
</div>
<div style="margin-top:1rem;">
<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>
{% endfor %}
</div>
<button onclick="saveAclForSelectedUser()">Save ACL</button>
</div>
<hr>
<div class="list-title">Добавить пользователя</div>
<div class="form-grid">
<input id="new_u_name" placeholder="username" />
<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>
<select id="new_u_admin"><option value="false">user</option><option value="true">admin</option></select>
</div>
<button onclick="createUser()">Add User</button>
</div>
</div>
</section>
<section id="tab-web" class="panel admin-tab" style="display:none;">
<h3>WEB сервисы</h3>
<div class="admin-intro">
<b>Как читать статусы:</b> в строке сервиса <b>прогрето X / Y</b> и <b>занято: N</b> относятся к этому конкретному сервису.
</div>
<div class="summary-strip">
<div class="summary-card">
<div class="summary-label">WEB сервисов</div>
<div class="summary-value">{{ web_totals.services }}</div>
</div>
<div class="summary-card">
<div class="summary-label">Всего прогрето</div>
<div class="summary-value">{{ web_totals.running }} / {{ web_totals.desired }}</div>
</div>
<div class="summary-card">
<div class="summary-label">Сейчас занято</div>
<div class="summary-value">{{ web_totals.active_sessions }}</div>
</div>
</div>
<div class="split">
<div>
<div class="list-title">Список WEB</div>
<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}}, {{s.warm_pool_size}})'>
<img class="service-thumb" src="{{ s.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
<div>
<div>{{s.name}}</div>
<small>
<span class="status-dot status-{{ service_health[s.id].health }}"></span>
{{service_health[s.id].health}} | прогрето: {{service_health[s.id].running}} / {{service_health[s.id].desired}} | занято: {{service_health[s.id].active_sessions}}
</small>
</div>
</button>
{% endfor %}
</div>
</div>
<div>
<div class="list-title">Редактирование WEB</div>
<div class="field-help">Для пользователей показывается только название и описание. Технический тип скрыт.</div>
<div class="form-grid labelled-grid">
<input id="w_id" type="hidden" />
<input id="w_slug" type="hidden" />
<label class="field-col">
<span>Название сервиса</span>
<input id="w_name" placeholder="Например: CRM, Router, Wiki" oninput="autogenSlug('w_name','w_slug')" />
</label>
<label class="field-col">
<span>URL для открытия</span>
<input id="w_target" placeholder="Например: https://crm.company.local" />
</label>
<label class="field-col">
<span>Описание для пользователя</span>
<textarea id="w_comment" placeholder="Коротко: что это за сервис"></textarea>
</label>
<label class="field-col">
<span>Сколько держать прогретых</span>
<input id="w_pool" type="number" min="0" placeholder="Например: 2" />
</label>
<label class="field-col">
<span>Статус</span>
<select id="w_active"><option value="true">active</option><option value="false">inactive</option></select>
</label>
</div>
<div class="actions">
<button onclick="saveWebService()">Save</button>
<button onclick="prewarmNow('w_id')">Prewarm now</button>
<button onclick="deleteService('w_id')">Delete</button>
<button onclick="clearWebForm()">Clear</button>
</div>
<div class="health-box" id="w_health_box" style="display:none; margin-top:1rem;">
<div class="list-title">Container Health</div>
<div id="w_health_summary"></div>
<div class="actions" style="margin-top:0.5rem;">
<button onclick="refreshSelectedServiceStatus('web')">Refresh status</button>
<button id="w_health_toggle" onclick="toggleHealthDetails('w')">Показать детали контейнеров</button>
</div>
<div class="container-table-wrap" id="w_health_table_wrap" style="display:none;">
<table class="admin-table" id="w_health_table">
<thead>
<tr>
<th>name</th>
<th>state</th>
<th>status</th>
<th>image</th>
<th>labels</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<div class="icon-box" style="margin-top:1rem;">
<div class="list-title">Иконка сервиса</div>
<div class="icon-row">
<img id="w_icon_preview" class="service-icon-preview" src="/static/service-placeholder.svg" alt="icon" />
<div>
<input id="w_icon_file" type="file" accept="image/png,image/jpeg,image/webp" />
<div class="actions" style="margin-top:0.4rem;">
<button onclick="uploadServiceIcon('w')">Upload</button>
<button onclick="pasteServiceIcon('w')">Paste</button>
<button onclick="removeServiceIcon('w')">Remove icon</button>
</div>
</div>
</div>
</div>
<hr>
<div class="list-title">Добавить WEB</div>
<div class="field-help">Рекомендуется начинать с pool size = 2-3 для часто используемых сервисов.</div>
<div class="form-grid labelled-grid">
<input id="new_w_slug" type="hidden" />
<label class="field-col">
<span>Название сервиса</span>
<input id="new_w_name" placeholder="Например: CRM, Router, Wiki" oninput="autogenSlug('new_w_name','new_w_slug')" />
</label>
<label class="field-col">
<span>URL для открытия</span>
<input id="new_w_target" placeholder="Например: https://crm.company.local" />
</label>
<label class="field-col">
<span>Описание для пользователя</span>
<textarea id="new_w_comment" placeholder="Коротко: что это за сервис"></textarea>
</label>
<label class="field-col">
<span>Сколько держать прогретых</span>
<input id="new_w_pool" type="number" min="0" value="2" placeholder="Например: 2" />
</label>
<label class="field-col">
<span>Статус</span>
<select id="new_w_active"><option value="true">active</option><option value="false">inactive</option></select>
</label>
</div>
<button onclick="createWebService()">Add WEB</button>
</div>
</div>
</section>
<section id="tab-rdp" class="panel admin-tab" style="display:none;">
<h3>RDP сервисы</h3>
<div class="split">
<div>
<div class="list-title">Список RDP</div>
<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}})'>
<img class="service-thumb" src="{{ s.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
<div>
<div>{{s.name}}</div>
<small>
<span class="status-dot status-{{ service_health[s.id].health }}"></span>
{{service_health[s.id].health}} | {{service_health[s.id].running}} / {{service_health[s.id].desired}} | active: {{service_health[s.id].active_sessions}}
</small>
</div>
</button>
{% endfor %}
</div>
</div>
<div>
<div class="list-title">Редактирование RDP</div>
<div class="field-help">Используйте поля host/port/user. Поле target собирается автоматически.</div>
<div class="form-grid">
<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">
<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 (авто)" />
<textarea id="r_comment" placeholder="Описание для пользователя"></textarea>
<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>
<div class="actions">
<button onclick="saveRdpService()">Save</button>
<button onclick="prewarmNow('r_id')">Prewarm now</button>
<button onclick="deleteService('r_id')">Delete</button>
<button onclick="clearRdpForm()">Clear</button>
</div>
<div class="health-box" id="r_health_box" style="display:none; margin-top:1rem;">
<div class="list-title">Container Health</div>
<div id="r_health_summary"></div>
<div class="actions" style="margin-top:0.5rem;">
<button onclick="refreshSelectedServiceStatus('rdp')">Refresh status</button>
</div>
<div class="container-table-wrap">
<table class="admin-table" id="r_health_table">
<thead>
<tr>
<th>name</th>
<th>state</th>
<th>status</th>
<th>image</th>
<th>labels</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<div class="icon-box" style="margin-top:1rem;">
<div class="list-title">Иконка сервиса</div>
<div class="icon-row">
<img id="r_icon_preview" class="service-icon-preview" src="/static/service-placeholder.svg" alt="icon" />
<div>
<input id="r_icon_file" type="file" accept="image/png,image/jpeg,image/webp" />
<div class="actions" style="margin-top:0.4rem;">
<button onclick="uploadServiceIcon('r')">Upload</button>
<button onclick="pasteServiceIcon('r')">Paste</button>
<button onclick="removeServiceIcon('r')">Remove icon</button>
</div>
</div>
</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">
<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 (авто)" />
<textarea id="new_r_comment" placeholder="Описание для пользователя"></textarea>
<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>
<button onclick="createRdpService()">Add RDP</button>
</div>
</div>
</section>
<section id="tab-stats" class="panel admin-tab" style="display:none;">
<h3>Статистика открытий</h3>
<div class="admin-intro">
Здесь видно кто, когда и какой сервис открывал, а также агрегат по количеству запусков.
</div>
<div class="split">
<div>
<div class="list-title">Топ запусков (user x service)</div>
<div class="container-table-wrap" style="max-height:520px;">
<table class="admin-table">
<thead>
<tr>
<th>user</th>
<th>service</th>
<th>slug</th>
<th>opens</th>
</tr>
</thead>
<tbody>
{% for row in open_stats %}
<tr>
<td>{{ row.username }}</td>
<td>{{ row.service_name }}</td>
<td>{{ row.service_slug }}</td>
<td>{{ row.opens }}</td>
</tr>
{% else %}
<tr><td colspan="4">Нет данных</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div>
<div class="list-title">Последние открытия</div>
<div class="container-table-wrap" style="max-height:520px;">
<table class="admin-table">
<thead>
<tr>
<th>user</th>
<th>service</th>
<th>status</th>
<th>created</th>
<th>last_access</th>
<th>session_id</th>
</tr>
</thead>
<tbody>
{% for row in recent_sessions %}
<tr>
<td>{{ row.username }}</td>
<td>{{ row.service_name }} ({{ row.service_slug }})</td>
<td>{{ row.status }}</td>
<td>{{ row.created_at }}</td>
<td>{{ row.last_access_at }}</td>
<td>{{ row.id }}</td>
</tr>
{% else %}
<tr><td colspan="6">Нет данных</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</section>
</main>
<script>
const csrf = "{{ csrf_token }}";
const aclMap = {{ acl | tojson }};
const placeholderIcon = '/static/service-placeholder.svg';
let activeTab = 'users';
function showTab(tab) {
activeTab = tab;
localStorage.setItem('admin_active_tab', tab);
document.querySelectorAll('.admin-tab').forEach(x => x.style.display = 'none');
document.getElementById(`tab-${tab}`).style.display = 'block';
document.querySelectorAll('.tab-btn').forEach(x => x.classList.remove('active-tab'));
document.querySelector(`.tab-btn[data-tab="${tab}"]`).classList.add('active-tab');
}
function filterList(inputId, itemsSelector) {
const needle = (document.getElementById(inputId)?.value || '').trim().toLowerCase();
document.querySelectorAll(itemsSelector).forEach((item) => {
const hay = (item.dataset.filter || item.textContent || '').toLowerCase();
item.style.display = hay.includes(needle) ? '' : 'none';
});
}
function markSelected(selector, attr, value) {
document.querySelectorAll(selector).forEach((el) => {
el.classList.toggle('selected-item', String(el.getAttribute(attr)) === String(value));
});
}
function slugifyRu(text) {
const map = {
'а':'a','б':'b','в':'v','г':'g','д':'d','е':'e','ё':'e','ж':'zh','з':'z','и':'i','й':'y','к':'k','л':'l','м':'m','н':'n','о':'o','п':'p','р':'r','с':'s','т':'t','у':'u','ф':'f','х':'h','ц':'ts','ч':'ch','ш':'sh','щ':'sch','ъ':'','ы':'y','ь':'','э':'e','ю':'yu','я':'ya'
};
const lower = (text || '').toLowerCase();
let out = '';
for (const ch of lower) out += map[ch] !== undefined ? map[ch] : ch;
return out.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').replace(/-{2,}/g, '-');
}
function autogenSlug(srcId, dstId) {
const src = document.getElementById(srcId);
const dst = document.getElementById(dstId);
if (!dst.value || !dst.dataset.touched) dst.value = slugifyRu(src.value);
}
function dateFromIso(iso) {
return (iso || '').slice(0, 10);
}
function expiryToApi(dateStr) {
if (!dateStr) return '';
return `${dateStr}T23:59:59+00:00`;
}
function daysLeft(expIso) {
const now = new Date();
const exp = new Date(expIso);
const diff = Math.ceil((exp - now) / (1000 * 60 * 60 * 24));
if (Number.isNaN(diff)) return 'дата некорректна';
if (diff < 0) return `просрочен ${Math.abs(diff)} дн`;
return `до деактивации: ${diff} дн`;
}
function renderUserDays() {
document.querySelectorAll('.user-days').forEach(el => {
el.textContent = daysLeft(el.dataset.exp || '');
});
}
async function api(url, method, body) {
const r = await fetch(url, {
method,
credentials: 'include',
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrf},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!r.ok) {
const t = await r.text();
alert(`Request failed: ${r.status} ${t}`);
throw new Error(t);
}
return r.status === 204 ? {} : r.json();
}
async function apiForm(url, formData) {
const r = await fetch(url, {
method: 'POST',
credentials: 'include',
headers: {'X-CSRF-Token': csrf},
body: formData,
});
if (!r.ok) {
const t = await r.text();
alert(`Request failed: ${r.status} ${t}`);
throw new Error(t);
}
return r.json();
}
function selectUser(id, username, active, isAdmin, expiresIso) {
document.getElementById('u_id').value = id;
document.getElementById('u_name').value = username;
document.getElementById('u_exp').value = dateFromIso(expiresIso);
document.getElementById('u_pwd').value = '';
document.getElementById('u_active').value = String(active);
document.getElementById('u_admin').value = String(isAdmin);
markSelected('.user-item', 'data-user-id', id);
syncAclForSelectedUser();
}
async function createUser() {
const expDate = document.getElementById('new_u_exp').value;
if (!expDate) return alert('Выберите дату деактивации');
await api('/api/admin/users', 'POST', {
username: document.getElementById('new_u_name').value,
password: document.getElementById('new_u_pwd').value,
expires_at: expiryToApi(expDate),
active: document.getElementById('new_u_active').value === 'true',
is_admin: document.getElementById('new_u_admin').value === 'true',
});
location.reload();
}
async function saveUser() {
const id = document.getElementById('u_id').value;
if (!id) return alert('Выберите пользователя');
const expDate = document.getElementById('u_exp').value;
if (!expDate) return alert('Выберите дату деактивации');
const payload = {
username: document.getElementById('u_name').value,
expires_at: expiryToApi(expDate),
active: document.getElementById('u_active').value === 'true',
is_admin: document.getElementById('u_admin').value === 'true',
};
const pwd = document.getElementById('u_pwd').value.trim();
if (pwd) payload.password = pwd;
await api(`/api/admin/users/${id}`, 'PUT', payload);
location.reload();
}
async function deleteUser() {
const id = document.getElementById('u_id').value;
if (!id) return alert('Выберите пользователя');
if (!confirm('Удалить пользователя?')) return;
await api(`/api/admin/users/${id}`, 'DELETE', {});
location.reload();
}
function clearUserForm() {
['u_id','u_name','u_exp','u_pwd'].forEach(id => document.getElementById(id).value = '');
document.getElementById('u_active').value = 'true';
document.getElementById('u_admin').value = 'false';
document.querySelectorAll('.acl_service').forEach(x => x.checked = false);
document.querySelectorAll('.user-item').forEach((el) => el.classList.remove('selected-item'));
}
function syncAclForSelectedUser() {
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));
});
}
async function saveAclForSelectedUser() {
const userId = document.getElementById('u_id').value;
if (!userId) return alert('Сначала выберите пользователя');
const serviceIds = [...document.querySelectorAll('.acl_service:checked')].map(x => parseInt(x.value, 10));
await api(`/api/admin/users/${userId}/acl`, 'PUT', {service_ids: serviceIds});
location.reload();
}
function selectWebService(id, name, slug, target, comment, iconPath, active, pool) {
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_active').value = String(active);
document.getElementById('w_pool').value = pool;
document.getElementById('w_icon_preview').src = iconPath || placeholderIcon;
document.getElementById('w_health_box').style.display = 'block';
markSelected('.web-item', 'data-service-id', id);
refreshSelectedServiceStatus('web');
}
async function createWebService() {
const slug = document.getElementById('new_w_slug').value || slugifyRu(document.getElementById('new_w_name').value);
await api('/api/admin/services', 'POST', {
name: document.getElementById('new_w_name').value,
slug,
type: 'WEB',
target: document.getElementById('new_w_target').value,
comment: document.getElementById('new_w_comment').value,
warm_pool_size: parseInt(document.getElementById('new_w_pool').value || '0', 10),
active: document.getElementById('new_w_active').value === 'true',
});
location.reload();
}
async function saveWebService() {
const id = document.getElementById('w_id').value;
if (!id) return alert('Выберите WEB сервис');
const slug = document.getElementById('w_slug').value || slugifyRu(document.getElementById('w_name').value);
await api(`/api/admin/services/${id}`, 'PUT', {
name: document.getElementById('w_name').value,
slug,
type: 'WEB',
target: document.getElementById('w_target').value,
comment: document.getElementById('w_comment').value,
warm_pool_size: parseInt(document.getElementById('w_pool').value || '0', 10),
active: document.getElementById('w_active').value === 'true',
});
location.reload();
}
function clearWebForm() {
['w_id','w_name','w_slug','w_target','w_comment','w_pool'].forEach(id => document.getElementById(id).value = '');
document.getElementById('w_active').value = 'true';
document.getElementById('w_icon_preview').src = placeholderIcon;
document.getElementById('w_health_box').style.display = 'none';
document.querySelectorAll('.web-item').forEach((el) => el.classList.remove('selected-item'));
}
function parseRdpTarget(target) {
const raw = (target || '').trim();
if (!raw) return {host: '', port: '3389', user: '', pass: '', domain: '', sec: ''};
let url;
try {
url = raw.includes('://') ? new URL(raw) : new URL(`rdp://${raw}`);
} catch (e) {
return {host: '', port: '3389', user: '', pass: '', domain: '', sec: ''};
}
const params = url.searchParams;
return {
host: url.hostname || '',
port: url.port || '3389',
user: decodeURIComponent(url.username || params.get('u') || params.get('user') || ''),
pass: decodeURIComponent(url.password || params.get('p') || params.get('password') || ''),
domain: params.get('domain') || params.get('d') || '',
sec: params.get('sec') || params.get('security') || '',
};
}
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}` : ''}`;
if (targetInput) targetInput.value = target;
return target;
}
function selectRdpService(id, name, slug, target, comment, iconPath, active, pool) {
const cfg = parseRdpTarget(target);
document.getElementById('r_id').value = id;
document.getElementById('r_name').value = name;
document.getElementById('r_slug').value = slug;
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_active').value = String(active);
document.getElementById('r_pool').value = pool;
document.getElementById('r_icon_preview').src = iconPath || placeholderIcon;
document.getElementById('r_health_box').style.display = 'block';
markSelected('.rdp-item', 'data-service-id', id);
refreshSelectedServiceStatus('rdp');
}
async function createRdpService() {
const slug = document.getElementById('new_r_slug').value || slugifyRu(document.getElementById('new_r_name').value);
const target = buildRdpTarget('new_r');
await api('/api/admin/services', 'POST', {
name: document.getElementById('new_r_name').value,
slug,
type: 'RDP',
target,
comment: document.getElementById('new_r_comment').value,
warm_pool_size: parseInt(document.getElementById('new_r_pool').value || '0', 10),
active: document.getElementById('new_r_active').value === 'true',
});
location.reload();
}
async function saveRdpService() {
const id = document.getElementById('r_id').value;
if (!id) return alert('Выберите RDP сервис');
const target = buildRdpTarget('r');
await api(`/api/admin/services/${id}`, 'PUT', {
name: document.getElementById('r_name').value,
slug: document.getElementById('r_slug').value,
type: 'RDP',
target,
comment: document.getElementById('r_comment').value,
warm_pool_size: parseInt(document.getElementById('r_pool').value || '0', 10),
active: document.getElementById('r_active').value === 'true',
});
location.reload();
}
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 = '');
document.getElementById('r_sec').value = '';
document.getElementById('r_active').value = 'true';
document.getElementById('r_icon_preview').src = placeholderIcon;
document.getElementById('r_health_box').style.display = 'none';
document.querySelectorAll('.rdp-item').forEach((el) => el.classList.remove('selected-item'));
}
async function deleteService(sourceId) {
const id = document.getElementById(sourceId).value;
if (!id) return alert('Выберите сервис');
if (!confirm('Удалить сервис?')) return;
await api(`/api/admin/services/${id}`, 'DELETE', {});
location.reload();
}
async function prewarmNow(sourceId) {
const id = document.getElementById(sourceId).value;
if (!id) return alert('Выберите сервис');
await api(`/api/admin/services/${id}/prewarm`, 'POST', {});
const kind = activeTab === 'rdp' ? 'rdp' : 'web';
await refreshSelectedServiceStatus(kind);
location.reload();
}
function renderStatusInto(prefix, data) {
const summary = document.getElementById(`${prefix}_health_summary`);
const tableBody = document.querySelector(`#${prefix}_health_table tbody`);
summary.innerHTML = `
<div><span class="status-dot status-${data.health}"></span> health: <b>${data.health}</b></div>
<div>running/desired: <b>${data.running}</b> / <b>${data.desired}</b>, total: ${data.total}</div>
<div>active sessions: <b>${data.active_sessions ?? 0}</b></div>
<div class="muted">updated: ${data.updated_at}</div>
`;
tableBody.innerHTML = '';
(data.containers || []).forEach((c) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${c.name}</td>
<td>${c.state}</td>
<td>${c.status}</td>
<td>${c.image || ''}</td>
<td>${c.labels_ok ? 'ok' : 'mismatch'}</td>
`;
tableBody.appendChild(tr);
});
}
function toggleHealthDetails(prefix) {
const wrap = document.getElementById(`${prefix}_health_table_wrap`);
const btn = document.getElementById(`${prefix}_health_toggle`);
if (!wrap || !btn) return;
const opened = wrap.style.display !== 'none';
wrap.style.display = opened ? 'none' : 'block';
btn.textContent = opened ? 'Показать детали контейнеров' : 'Скрыть детали контейнеров';
}
async function refreshSelectedServiceStatus(kind) {
const kindToPrefix = {web: 'w', rdp: 'r'};
const prefix = kindToPrefix[kind];
if (!prefix) return;
const serviceId = document.getElementById(`${prefix}_id`).value;
if (!serviceId) return;
const data = await api(`/api/admin/services/${serviceId}/containers/status`, 'GET');
renderStatusInto(prefix, data);
}
async function uploadServiceIcon(prefix) {
const serviceId = document.getElementById(`${prefix}_id`).value;
if (!serviceId) return alert('Сначала выберите сервис');
const input = document.getElementById(`${prefix}_icon_file`);
if (!input.files || !input.files[0]) return alert('Выберите файл');
const form = new FormData();
form.append('file', input.files[0]);
const data = await apiForm(`/api/admin/services/${serviceId}/icon`, form);
document.getElementById(`${prefix}_icon_preview`).src = data.icon_path || placeholderIcon;
input.value = '';
location.reload();
}
async function removeServiceIcon(prefix) {
const serviceId = document.getElementById(`${prefix}_id`).value;
if (!serviceId) return alert('Сначала выберите сервис');
await api(`/api/admin/services/${serviceId}/icon`, 'DELETE', {});
document.getElementById(`${prefix}_icon_preview`).src = placeholderIcon;
location.reload();
}
async function pasteServiceIcon(prefix) {
const input = document.getElementById(`${prefix}_icon_file`);
if (!navigator.clipboard || !navigator.clipboard.read) {
return alert('Clipboard API недоступен в этом браузере');
}
try {
const items = await navigator.clipboard.read();
for (const item of items) {
const imageType = item.types.find((t) => t.startsWith('image/'));
if (!imageType) continue;
const blob = await item.getType(imageType);
const ext = imageType.split('/')[1] || 'png';
const file = new File([blob], `clipboard.${ext}`, {type: imageType});
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
await uploadServiceIcon(prefix);
return;
}
alert('В буфере нет изображения');
} catch (e) {
alert(`Не удалось вставить изображение: ${e}`);
}
}
setInterval(() => {
if (activeTab === 'web' && document.getElementById('w_id').value) {
refreshSelectedServiceStatus('web').catch(() => {});
}
if (activeTab === 'rdp' && document.getElementById('r_id').value) {
refreshSelectedServiceStatus('rdp').catch(() => {});
}
}, 5000);
const hashTab = (window.location.hash || '').replace('#', '');
const savedTab = localStorage.getItem('admin_active_tab');
const initialTab = ['users', 'web', 'rdp', 'stats'].includes(hashTab)
? hashTab
: (['users', 'web', 'rdp', 'stats'].includes(savedTab || '') ? savedTab : 'users');
showTab(initialTab);
renderUserDays();
</script>
</body>
</html>

View File

@@ -0,0 +1,46 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>МОНТ - инфра полигон</title>
<link rel="stylesheet" href="/static/style.css" />
</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>{{ user.username }}</div>
</div>
<div style="display:flex; gap:0.5rem;">
{% if user.is_admin %}
<a href="/admin" class="btn-link secondary">Администрирование</a>
{% endif %}
<form method="post" action="/logout">
<button type="submit">Logout</button>
</form>
</div>
</header>
<main class="admin-layout">
<section class="panel">
<div class="admin-intro">
Выберите нужный сервис. После клика откроется готовый браузер/сеанс с заранее заданным адресом.
</div>
</section>
<section class="grid">
{% for service in services %}
<a class="tile" href="/go/{{ service.slug }}">
<img class="tile-icon" src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
<h3>{{ service.name }}</h3>
<p>Открыть сервис</p>
{% if service.comment %}
<small>{{ service.comment }}</small>
{% endif %}
</a>
{% else %}
<div class="tile">Нет назначенных сервисов</div>
{% endfor %}
</section>
</main>
</body>
</html>

23
app/templates/login.html Normal file
View File

@@ -0,0 +1,23 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>МОНТ - инфра полигон</title>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<main class="center-box login-page">
<img src="/static/logo.png" alt="MONT" class="brand-logo brand-logo-fullscreen" />
<h1 class="login-title">МОНТ - инфра полигон</h1>
<form method="post" action="/login" class="panel">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<label>Login</label>
<input type="text" name="username" required />
<label>Password</label>
<input type="password" name="password" required />
<button type="submit">Sign in</button>
</form>
</main>
</body>
</html>

85
docker-compose.yml Normal file
View File

@@ -0,0 +1,85 @@
services:
traefik:
image: traefik:v3.2
command:
- --configFile=/etc/traefik/traefik.yml
ports:
- "80:80"
- "443: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:

21
kiosk/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
chromium \
xvfb \
x11vnc \
fluxbox \
novnc \
websockify \
python3 \
ca-certificates \
&& 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"]

159
kiosk/entrypoint.sh Executable file
View File

@@ -0,0 +1,159 @@
#!/usr/bin/env bash
set -euo pipefail
TARGET_URL="${TARGET_URL:-https://example.com}"
SESSION_ID="${SESSION_ID:-unknown}"
IDLE_TIMEOUT="${IDLE_TIMEOUT:-1800}"
ENABLE_HEARTBEAT="${ENABLE_HEARTBEAT:-1}"
TOUCH_PATH="${TOUCH_PATH:-/api/sessions/${SESSION_ID}/touch}"
UNIVERSAL_WEB="${UNIVERSAL_WEB:-0}"
START_URL="${START_URL:-about:blank}"
HOME_URL="${HOME_URL:-$TARGET_URL}"
SCREEN_GEOMETRY="${SCREEN_GEOMETRY:-1920x1080x24}"
CHROME_WINDOW_SIZE="${CHROME_WINDOW_SIZE:-1920,1080}"
if [ "$UNIVERSAL_WEB" = "1" ]; then
HOME_URL="${HOME_URL:-$START_URL}"
fi
mkdir -p /opt/portal
cp -r /usr/share/novnc/* /opt/portal/
cat > /opt/portal/index.html <<HTML
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Инфра полигон МОНТ</title>
<style>
html,body,#screen{margin:0;height:100%;background:#111}
.nav-panel{
position:fixed;left:14px;top:14px;z-index:99;display:flex;gap:8px;
background:rgba(12,18,26,.88);border:1px solid rgba(255,255,255,.14);
box-shadow:0 8px 22px rgba(0,0,0,.35);padding:8px;border-radius:10px
}
.nav-btn{
border:1px solid rgba(255,255,255,.14);border-radius:8px;padding:8px 12px;cursor:pointer;
background:linear-gradient(180deg,#1a73b3,#0f5b94);color:#fff;font:600 13px/1 sans-serif
}
.nav-btn:hover{filter:brightness(1.08)}
.nav-btn:active{transform:translateY(1px)}
</style>
</head>
<body>
<div id="screen"></div>
<div class="nav-panel">
<button class="nav-btn" id="btn-back" type="button">Назад</button>
<button class="nav-btn" id="btn-forward" type="button">Вперед</button>
<button class="nav-btn" id="btn-home" type="button">Домой</button>
</div>
<script type="module">
import RFB from './core/rfb.js';
const XK_ALT_L = 0xffe9;
const XK_CONTROL_L = 0xffe3;
const XK_LEFT = 0xff51;
const XK_RIGHT = 0xff53;
const XK_ENTER = 0xff0d;
const HOME_URL = ${HOME_URL@Q};
const wsBase = location.pathname.replace(/\/+$/, '');
const wsUrl = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + wsBase + '/websockify';
const rfb = new RFB(document.getElementById('screen'), wsUrl);
rfb.viewOnly = false;
rfb.scaleViewport = true;
rfb.resizeSession = true;
const enableHeartbeat = "${ENABLE_HEARTBEAT}" === "1";
async function touch() {
try {
await fetch('${TOUCH_PATH}', {method:'POST', credentials:'include'});
} catch(e) {}
}
if (enableHeartbeat) {
setInterval(touch, 60000);
touch();
}
function keyTap(keysym, code) {
rfb.sendKey(keysym, code, true);
rfb.sendKey(keysym, code, false);
}
function chord(mod, key, modCode, keyCode) {
rfb.sendKey(mod, modCode, true);
keyTap(key, keyCode);
rfb.sendKey(mod, modCode, false);
}
function typeText(text) {
for (const ch of text) {
const code = ch.codePointAt(0);
keyTap(code, ch);
}
}
function goHome() {
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-forward').addEventListener('click', () => chord(XK_ALT_L, XK_RIGHT, "AltLeft", "ArrowRight"));
document.getElementById('btn-home').addEventListener('click', goHome);
document.addEventListener('contextmenu', (e) => e.preventDefault());
</script>
</body>
</html>
HTML
export DISPLAY=:1
Xvfb :1 -screen 0 "$SCREEN_GEOMETRY" &
fluxbox >/tmp/fluxbox.log 2>&1 &
sleep 1
if [ "$UNIVERSAL_WEB" = "1" ]; then
chromium \
--no-sandbox \
--disable-dev-shm-usage \
--disable-gpu \
--use-gl=swiftshader \
--kiosk \
--remote-debugging-address=0.0.0.0 \
--remote-debugging-port=9222 \
--remote-allow-origins=* \
--disable-translate \
--disable-features=TranslateUI,ExtensionsToolbarMenu \
--disable-pinch \
--overscroll-history-navigation=0 \
--ignore-certificate-errors \
--allow-insecure-localhost \
--allow-running-insecure-content \
--window-size="$CHROME_WINDOW_SIZE" \
--no-first-run \
--no-default-browser-check \
"$START_URL" \
>/tmp/chromium.log 2>&1 &
python3 /manager.py >/tmp/manager.log 2>&1 &
else
chromium \
--no-sandbox \
--disable-dev-shm-usage \
--disable-gpu \
--use-gl=swiftshader \
--kiosk \
--app="$TARGET_URL" \
--disable-translate \
--disable-features=TranslateUI,ExtensionsToolbarMenu \
--disable-pinch \
--overscroll-history-navigation=0 \
--ignore-certificate-errors \
--allow-insecure-localhost \
--allow-running-insecure-content \
--window-size="$CHROME_WINDOW_SIZE" \
--no-first-run \
--no-default-browser-check \
>/tmp/chromium.log 2>&1 &
fi
x11vnc -display :1 -rfbport 5900 -forever -shared -nopw -noxdamage >/tmp/x11vnc.log 2>&1 &
exec websockify --verbose --idle-timeout="$IDLE_TIMEOUT" --web=/opt/portal 6080 localhost:5900

74
kiosk/manager.py Normal file
View File

@@ -0,0 +1,74 @@
#!/usr/bin/env python3
import json
import urllib.parse
import urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer
def _json_get(path: str):
with urllib.request.urlopen(f"http://127.0.0.1:9222{path}", timeout=2) as resp:
return json.loads(resp.read().decode("utf-8"))
def _json_put(path: str):
req = urllib.request.Request(f"http://127.0.0.1:9222{path}", method="PUT")
with urllib.request.urlopen(req, timeout=2) as resp:
return json.loads(resp.read().decode("utf-8"))
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")
if page_id and page_id != opened_id:
try:
_json_put(f"/json/close/{page_id}")
except Exception:
pass
class Handler(BaseHTTPRequestHandler):
def _json(self, code: int, payload: dict):
body = json.dumps(payload).encode("utf-8")
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":
self._json(200, {"ok": True})
return
self._json(404, {"detail": "Not found"})
def do_POST(self):
if self.path != "/open":
self._json(404, {"detail": "Not found"})
return
try:
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length)
data = json.loads(raw.decode("utf-8")) if raw else {}
url = (data.get("url") or "").strip()
if not url.startswith("http://") and not url.startswith("https://"):
self._json(400, {"detail": "Invalid URL"})
return
chromium_open(url)
print(f"open_ok url={url}", flush=True)
self._json(200, {"ok": True, "url": url})
except Exception as exc:
print(f"open_fail err={exc}", flush=True)
self._json(500, {"detail": str(exc)})
def log_message(self, format, *args):
return
if __name__ == "__main__":
server = HTTPServer(("0.0.0.0", 7000), Handler)
server.serve_forever()

19
rdp-proxy/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
xvfb \
x11vnc \
freerdp2-x11 \
novnc \
websockify \
ca-certificates \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 6080
ENTRYPOINT ["/entrypoint.sh"]

87
rdp-proxy/entrypoint.sh Normal file
View File

@@ -0,0 +1,87 @@
#!/usr/bin/env bash
set -euo pipefail
RDP_HOST="${RDP_HOST:?RDP_HOST is required}"
RDP_PORT="${RDP_PORT:-3389}"
RDP_USER="${RDP_USER:-}"
RDP_PASSWORD="${RDP_PASSWORD:-}"
RDP_DOMAIN="${RDP_DOMAIN:-}"
RDP_SECURITY="${RDP_SECURITY:-}"
SESSION_ID="${SESSION_ID:-unknown}"
IDLE_TIMEOUT="${IDLE_TIMEOUT:-1800}"
ENABLE_HEARTBEAT="${ENABLE_HEARTBEAT:-1}"
TOUCH_PATH="${TOUCH_PATH:-/api/sessions/${SESSION_ID}/touch}"
SCREEN_GEOMETRY="${SCREEN_GEOMETRY:-1920x1080x24}"
DISPLAY_NUM="${DISPLAY_NUM:-:1}"
mkdir -p /opt/portal
cp -r /usr/share/novnc/* /opt/portal/
cat > /opt/portal/index.html <<HTML
<!doctype html>
<html>
<head>
<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>
</head>
<body>
<div id="screen"></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);
rfb.viewOnly = false;
rfb.scaleViewport = true;
rfb.resizeSession = true;
const enableHeartbeat = "${ENABLE_HEARTBEAT}" === "1";
async function touch() {
try {
await fetch('${TOUCH_PATH}', {method:'POST', credentials:'include'});
} catch(e) {}
}
if (enableHeartbeat) {
setInterval(touch, 60000);
touch();
}
document.addEventListener('contextmenu', (e) => e.preventDefault());
</script>
</body>
</html>
HTML
export DISPLAY="$DISPLAY_NUM"
Xvfb "$DISPLAY_NUM" -screen 0 "$SCREEN_GEOMETRY" >/tmp/xvfb.log 2>&1 &
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 &
exec websockify --verbose --idle-timeout="$IDLE_TIMEOUT" --web=/opt/portal 6080 localhost:5900

48
scripts/schema.sql Normal file
View File

@@ -0,0 +1,48 @@
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE services (
id SERIAL PRIMARY KEY,
name VARCHAR(128) NOT NULL,
slug VARCHAR(64) NOT NULL UNIQUE,
type VARCHAR(8) NOT NULL CHECK (type IN ('WEB', 'VNC', 'RDP')),
target TEXT NOT NULL,
comment TEXT NOT NULL DEFAULT '',
icon_path TEXT NOT NULL DEFAULT '',
active BOOLEAN NOT NULL DEFAULT TRUE,
warm_pool_size INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE user_service_access (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
service_id INT NOT NULL REFERENCES services(id) ON DELETE CASCADE,
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, service_id)
);
CREATE TABLE sessions (
id UUID PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
service_id INT NOT NULL REFERENCES services(id) ON DELETE CASCADE,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_access_at TIMESTAMPTZ NOT NULL DEFAULT now(),
container_id VARCHAR(128)
);
CREATE TABLE audit_logs (
id BIGSERIAL PRIMARY KEY,
user_id INT,
action VARCHAR(128) NOT NULL,
details TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

View File

@@ -0,0 +1,11 @@
http:
middlewares:
secure-headers:
headers:
browserXssFilter: true
contentTypeNosniff: true
frameDeny: true
forceSTSHeader: true
stsIncludeSubdomains: true
stsPreload: true
stsSeconds: 31536000

30
traefik/traefik.yml Normal file
View File

@@ -0,0 +1,30 @@
entryPoints:
web:
address: ":80"
websecure:
address: ":443"
providers:
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false
file:
directory: /etc/traefik/dynamic
watch: true
certificatesResolvers:
letsencrypt:
acme:
email: admin@4mont.ru
storage: /letsencrypt/acme.json
tlsChallenge: {}
api:
dashboard: true
insecure: false
log:
level: INFO
accessLog:
format: json

View File

@@ -0,0 +1,23 @@
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
chromium \
xvfb \
x11vnc \
fluxbox \
freerdp2-x11 \
novnc \
websockify \
python3 \
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"]

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env bash
set -euo pipefail
IDLE_TIMEOUT="${IDLE_TIMEOUT:-1800}"
SCREEN_GEOMETRY="${SCREEN_GEOMETRY:-1920x1080x24}"
CHROME_WINDOW_SIZE="${CHROME_WINDOW_SIZE:-1920,1080}"
ENABLE_HEARTBEAT="${ENABLE_HEARTBEAT:-1}"
DISPLAY_NUM="${DISPLAY_NUM:-:1}"
mkdir -p /opt/portal
cp -r /usr/share/novnc/* /opt/portal/
cat > /opt/portal/index.html <<'HTML'
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Universal Session</title>
<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}
</style>
</head>
<body>
<div id="screen"></div>
<div id="status" class="status">Подключение к слоту...</div>
<script type="module">
import RFB from './core/rfb.js';
const basePath = location.pathname.replace(/\/+$/, '');
const wsUrl = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + basePath + '/websockify';
const statusEl = document.getElementById('status');
let connected = false;
let connectTimer = null;
function showStatus(text, isError = false) {
statusEl.textContent = text;
statusEl.classList.toggle('error', !!isError);
statusEl.classList.remove('hidden');
}
function hideStatus() {
statusEl.classList.add('hidden');
}
showStatus('Подключение к слоту...');
connectTimer = setTimeout(() => {
if (!connected) {
showStatus('Нет подключения к экрану слота. Откройте сервис заново из дашборда.', true);
}
}, 8000);
const rfb = new RFB(document.getElementById('screen'), wsUrl);
rfb.viewOnly = false;
rfb.scaleViewport = true;
rfb.resizeSession = true;
rfb.addEventListener('connect', () => {
connected = true;
if (connectTimer) clearTimeout(connectTimer);
hideStatus();
});
rfb.addEventListener('disconnect', () => {
connected = false;
showStatus('Соединение со слотом потеряно. Запустите сервис заново.', true);
});
const enableHeartbeat = (new URLSearchParams(location.search).get('hb') ?? '1') !== '0';
const sid = new URLSearchParams(location.search).get('sid');
async function touch() {
if (!sid) return;
try {
await fetch(`/api/sessions/${sid}/touch`, {method:'POST', credentials:'include'});
} catch (e) {}
}
if (enableHeartbeat) {
setInterval(touch, 60000);
touch();
}
document.addEventListener('contextmenu', (e) => e.preventDefault());
</script>
</body>
</html>
HTML
export DISPLAY="$DISPLAY_NUM"
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 >/tmp/x11vnc.log 2>&1 &
exec websockify --verbose --idle-timeout="$IDLE_TIMEOUT" --web=/opt/portal 6080 localhost:5900

View File

@@ -0,0 +1,159 @@
#!/usr/bin/env python3
import json
import os
import signal
import subprocess
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
DISPLAY = os.environ.get("DISPLAY", ":1")
CHROME_WINDOW_SIZE = os.environ.get("CHROME_WINDOW_SIZE", "1920,1080")
_state = {
"proc": None,
"mode": "idle",
"target": "",
}
_lock = threading.Lock()
def _stop_current() -> None:
proc = _state.get("proc")
if not proc:
return
try:
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
proc.wait(timeout=4)
except Exception:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except Exception:
pass
finally:
_state["proc"] = None
def _start_process(cmd: list[str], mode: str, target: str) -> None:
_stop_current()
logf = open("/tmp/session-app.log", "a", buffering=1)
env = os.environ.copy()
env["DISPLAY"] = DISPLAY
proc = subprocess.Popen( # noqa: S603
cmd,
stdout=logf,
stderr=subprocess.STDOUT,
env=env,
start_new_session=True,
)
_state["proc"] = proc
_state["mode"] = mode
_state["target"] = target
def open_web(url: str) -> None:
cmd = [
"chromium",
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--use-gl=swiftshader",
"--kiosk",
"--disable-translate",
"--disable-features=TranslateUI,ExtensionsToolbarMenu",
"--disable-pinch",
"--overscroll-history-navigation=0",
"--ignore-certificate-errors",
"--allow-insecure-localhost",
"--allow-running-insecure-content",
f"--window-size={CHROME_WINDOW_SIZE}",
"--no-first-run",
"--no-default-browser-check",
url,
]
_start_process(cmd, "web", url)
def open_rdp(payload: dict) -> None:
host = (payload.get("host") or "").strip()
if not host:
raise ValueError("host is required")
port = str(payload.get("port") or "3389").strip()
user = (payload.get("user") or "").strip()
password = (payload.get("password") or "").strip()
domain = (payload.get("domain") or "").strip()
security = (payload.get("security") or "").strip().lower()
cmd = [
"xfreerdp",
f"/v:{host}:{port}",
"/cert:ignore",
"/f",
"/dynamic-resolution",
"/network:auto",
"+clipboard",
]
if security:
cmd.append(f"/sec:{security}")
if user:
cmd.append(f"/u:{user}")
if password:
cmd.append(f"/p:{password}")
if domain:
cmd.append(f"/d:{domain}")
safe_target = f"{host}:{port}"
_start_process(cmd, "rdp", safe_target)
class Handler(BaseHTTPRequestHandler):
def _read_json(self):
length = int(self.headers.get("Content-Length", "0"))
if length <= 0:
return {}
raw = self.rfile.read(length)
return json.loads(raw.decode("utf-8"))
def _json(self, code: int, payload: dict):
body = json.dumps(payload).encode("utf-8")
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":
proc = _state.get("proc")
running = bool(proc and proc.poll() is None)
self._json(200, {"ok": True, "mode": _state.get("mode", "idle"), "running": running, "target": _state.get("target", "")})
return
self._json(404, {"detail": "Not found"})
def do_POST(self):
try:
data = self._read_json()
if self.path == "/open":
url = (data.get("url") or "").strip()
if not (url.startswith("http://") or url.startswith("https://")):
self._json(400, {"detail": "Invalid URL"})
return
with _lock:
open_web(url)
self._json(200, {"ok": True, "mode": "web", "target": url})
return
if self.path == "/rdp":
with _lock:
open_rdp(data)
self._json(200, {"ok": True, "mode": "rdp"})
return
self._json(404, {"detail": "Not found"})
except Exception as exc:
self._json(500, {"detail": str(exc)})
def log_message(self, fmt, *args):
return
if __name__ == "__main__":
server = HTTPServer(("0.0.0.0", 7000), Handler)
server.serve_forever()