feat: redesign portal UX and stabilize web session runtime
This commit is contained in:
13
.env.example
Normal file
13
.env.example
Normal 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
12
.gitignore
vendored
Normal 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
154
README.md
Normal 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
11
app/Dockerfile
Normal 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
1613
app/main.py
Normal file
File diff suppressed because it is too large
Load Diff
9
app/requirements.txt
Normal file
9
app/requirements.txt
Normal 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
BIN
app/static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
0
app/static/service-icons/.gitkeep
Normal file
0
app/static/service-icons/.gitkeep
Normal file
BIN
app/static/service-icons/svc_4_20260306_134658.png
Normal file
BIN
app/static/service-icons/svc_4_20260306_134658.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
BIN
app/static/service-icons/svc_5_20260306_134448.png
Normal file
BIN
app/static/service-icons/svc_5_20260306_134448.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
BIN
app/static/service-icons/svc_6_20260306_134739.png
Normal file
BIN
app/static/service-icons/svc_6_20260306_134739.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
BIN
app/static/service-icons/svc_7_20260306_143024.png
Normal file
BIN
app/static/service-icons/svc_7_20260306_143024.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
7
app/static/service-placeholder.svg
Normal file
7
app/static/service-placeholder.svg
Normal 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
330
app/static/style.css
Normal 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
871
app/templates/admin.html
Normal 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>
|
||||
46
app/templates/dashboard.html
Normal file
46
app/templates/dashboard.html
Normal 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
23
app/templates/login.html
Normal 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
85
docker-compose.yml
Normal 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
21
kiosk/Dockerfile
Normal 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
159
kiosk/entrypoint.sh
Executable 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
74
kiosk/manager.py
Normal 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
19
rdp-proxy/Dockerfile
Normal 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
87
rdp-proxy/entrypoint.sh
Normal 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
48
scripts/schema.sql
Normal 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()
|
||||
);
|
||||
11
traefik/dynamic/security.yml
Normal file
11
traefik/dynamic/security.yml
Normal 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
30
traefik/traefik.yml
Normal 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
|
||||
23
universal-runtime/Dockerfile
Normal file
23
universal-runtime/Dockerfile
Normal 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"]
|
||||
110
universal-runtime/entrypoint.sh
Normal file
110
universal-runtime/entrypoint.sh
Normal 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
|
||||
159
universal-runtime/manager.py
Normal file
159
universal-runtime/manager.py
Normal 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()
|
||||
Reference in New Issue
Block a user