feat: RDP ACL exclusivity, mobile wall, nav buttons, resolution xrandr
- RDP сервис может быть назначен только одному пользователю в ACL - Мобильная заглушка на dashboard при ширине < 1024px - rdp-proxy: кнопки навигации, спиннер Ожидайте, реконнект - session_wait_page: тёмная тема, CSS спиннер - kiosk/universal-runtime manager.py: xrandr + cvt --newmode для resolution - Dockerfiles: x11-xserver-utils, x11-utils
This commit is contained in:
+59
-15
@@ -43,21 +43,21 @@ DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+psycopg2://portal:portal@db
|
|||||||
COOKIE_NAME = "portal_auth"
|
COOKIE_NAME = "portal_auth"
|
||||||
CSRF_COOKIE = "csrf_token"
|
CSRF_COOKIE = "csrf_token"
|
||||||
COOKIE_MAX_AGE = 8 * 60 * 60
|
COOKIE_MAX_AGE = 8 * 60 * 60
|
||||||
SESSION_IDLE_SECONDS = int(os.getenv("SESSION_IDLE_SECONDS", "300"))
|
SESSION_IDLE_SECONDS = int(os.getenv("SESSION_IDLE_SECONDS", "7200"))
|
||||||
PUBLIC_HOST = os.getenv("PUBLIC_HOST", "stend.4mont.ru")
|
PUBLIC_HOST = os.getenv("PUBLIC_HOST", "stend.4mont.ru")
|
||||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||||
LOG_SLOW_REQUEST_MS = int(os.getenv("LOG_SLOW_REQUEST_MS", "2000"))
|
LOG_SLOW_REQUEST_MS = int(os.getenv("LOG_SLOW_REQUEST_MS", "2000"))
|
||||||
GO_USER_LOCK_TIMEOUT_SECONDS = float(os.getenv("GO_USER_LOCK_TIMEOUT_SECONDS", "8.0"))
|
GO_USER_LOCK_TIMEOUT_SECONDS = float(os.getenv("GO_USER_LOCK_TIMEOUT_SECONDS", "8.0"))
|
||||||
GO_POOL_LOCK_TIMEOUT_SECONDS = float(os.getenv("GO_POOL_LOCK_TIMEOUT_SECONDS", "5.0"))
|
GO_POOL_LOCK_TIMEOUT_SECONDS = float(os.getenv("GO_POOL_LOCK_TIMEOUT_SECONDS", "20.0"))
|
||||||
POOL_DISPATCH_RETRIES = int(os.getenv("POOL_DISPATCH_RETRIES", "4"))
|
POOL_DISPATCH_RETRIES = int(os.getenv("POOL_DISPATCH_RETRIES", "6"))
|
||||||
POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS = float(os.getenv("POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS", "2.0"))
|
POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS = float(os.getenv("POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS", "2.0"))
|
||||||
POOL_DISPATCH_SLEEP_SECONDS = float(os.getenv("POOL_DISPATCH_SLEEP_SECONDS", "0.3"))
|
POOL_DISPATCH_SLEEP_SECONDS = float(os.getenv("POOL_DISPATCH_SLEEP_SECONDS", "0.3"))
|
||||||
TRAEFIK_INTERNAL_URL = os.getenv("TRAEFIK_INTERNAL_URL", "http://traefik")
|
TRAEFIK_INTERNAL_URL = os.getenv("TRAEFIK_INTERNAL_URL", "http://traefik")
|
||||||
PREWARM_POOL_SIZE = int(os.getenv("PREWARM_POOL_SIZE", "0"))
|
PREWARM_POOL_SIZE = int(os.getenv("PREWARM_POOL_SIZE", "2"))
|
||||||
UNIVERSAL_POOL_SIZE = int(os.getenv("UNIVERSAL_POOL_SIZE", "0"))
|
UNIVERSAL_POOL_SIZE = int(os.getenv("UNIVERSAL_POOL_SIZE", "0"))
|
||||||
WEB_POOL_SIZE = int(os.getenv("WEB_POOL_SIZE", "5"))
|
WEB_POOL_SIZE = int(os.getenv("WEB_POOL_SIZE", "20"))
|
||||||
WEB_POOL_BUFFER = int(os.getenv("WEB_POOL_BUFFER", "2"))
|
WEB_POOL_BUFFER = int(os.getenv("WEB_POOL_BUFFER", "2"))
|
||||||
X11VNC_FLAGS = os.getenv("X11VNC_FLAGS", "-wait 5 -defer 5 -ncache 10 -threads")
|
X11VNC_FLAGS = os.getenv("X11VNC_FLAGS", "-wait 5 -defer 5 -threads")
|
||||||
MAX_ACTIVE_SERVICES_PER_USER = int(os.getenv("MAX_ACTIVE_SERVICES_PER_USER", "4"))
|
MAX_ACTIVE_SERVICES_PER_USER = int(os.getenv("MAX_ACTIVE_SERVICES_PER_USER", "4"))
|
||||||
WEB_RESOLUTION_MIN_WIDTH = int(os.getenv("WEB_RESOLUTION_MIN_WIDTH", "1024"))
|
WEB_RESOLUTION_MIN_WIDTH = int(os.getenv("WEB_RESOLUTION_MIN_WIDTH", "1024"))
|
||||||
WEB_RESOLUTION_MIN_HEIGHT = int(os.getenv("WEB_RESOLUTION_MIN_HEIGHT", "720"))
|
WEB_RESOLUTION_MIN_HEIGHT = int(os.getenv("WEB_RESOLUTION_MIN_HEIGHT", "720"))
|
||||||
@@ -1521,7 +1521,7 @@ def cleanup_loop():
|
|||||||
|
|
||||||
def bootstrap_admin():
|
def bootstrap_admin():
|
||||||
admin_user = os.getenv("ADMIN_USERNAME", "admin")
|
admin_user = os.getenv("ADMIN_USERNAME", "admin")
|
||||||
admin_password = os.getenv("ADMIN_PASSWORD", "admin123")
|
admin_password = os.getenv("ADMIN_PASSWORD", "change_me")
|
||||||
ttl_days = int(os.getenv("ADMIN_TTL_DAYS", "3650"))
|
ttl_days = int(os.getenv("ADMIN_TTL_DAYS", "3650"))
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
@@ -1801,6 +1801,19 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi
|
|||||||
),
|
),
|
||||||
{"cutoff": cutoff},
|
{"cutoff": cutoff},
|
||||||
).mappings().all()
|
).mappings().all()
|
||||||
|
rdp_occupied_by: dict[int, int] = {}
|
||||||
|
rdp_occupied_username: dict[int, str] = {}
|
||||||
|
rdp_ids = [s.id for s in rdp_services]
|
||||||
|
if rdp_ids:
|
||||||
|
rdp_acl_rows = db.execute(
|
||||||
|
select(UserServiceAccess.service_id, UserServiceAccess.user_id, User.username)
|
||||||
|
.join(User, User.id == UserServiceAccess.user_id)
|
||||||
|
.where(UserServiceAccess.service_id.in_(rdp_ids))
|
||||||
|
).all()
|
||||||
|
for row in rdp_acl_rows:
|
||||||
|
if row.service_id not in rdp_occupied_by:
|
||||||
|
rdp_occupied_by[row.service_id] = row.user_id
|
||||||
|
rdp_occupied_username[row.service_id] = row.username
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"admin.html",
|
"admin.html",
|
||||||
{
|
{
|
||||||
@@ -1823,6 +1836,8 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi
|
|||||||
"online_sessions": online_sessions,
|
"online_sessions": online_sessions,
|
||||||
"csrf_token": request.cookies.get(CSRF_COOKIE, ""),
|
"csrf_token": request.cookies.get(CSRF_COOKIE, ""),
|
||||||
"max_active_services_per_user": MAX_ACTIVE_SERVICES_PER_USER,
|
"max_active_services_per_user": MAX_ACTIVE_SERVICES_PER_USER,
|
||||||
|
"rdp_occupied_by": rdp_occupied_by,
|
||||||
|
"rdp_occupied_username": rdp_occupied_username,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2205,6 +2220,8 @@ def session_wait_page(session_id: str, request: Request, user: User = Depends(re
|
|||||||
raise HTTPException(status_code=410, detail="Session is not active")
|
raise HTTPException(status_code=410, detail="Session is not active")
|
||||||
service = db.get(Service, sess.service_id)
|
service = db.get(Service, sess.service_id)
|
||||||
service_title = service.name if service else "Сервис"
|
service_title = service.name if service else "Сервис"
|
||||||
|
is_rdp = service and service.type == ServiceType.RDP
|
||||||
|
label = "Ожидайте..." if is_rdp else "Сессия запускается..."
|
||||||
redirect_target = session_redirect_url(sess)
|
redirect_target = session_redirect_url(sess)
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
content=f"""
|
content=f"""
|
||||||
@@ -2214,20 +2231,28 @@ def session_wait_page(session_id: str, request: Request, user: User = Depends(re
|
|||||||
<meta charset='utf-8'>
|
<meta charset='utf-8'>
|
||||||
<title>{service_title}</title>
|
<title>{service_title}</title>
|
||||||
<style>
|
<style>
|
||||||
body {{ font-family: sans-serif; background: #f4f6f8; display: grid; place-items: center; height: 100vh; margin: 0; color:#1b3145; }}
|
*{{box-sizing:border-box}}
|
||||||
.card {{ background: #fff; padding: 1rem 1.2rem; border-radius: 10px; box-shadow: 0 8px 20px rgba(0,0,0,.08); min-width: 340px; }}
|
body{{font-family:sans-serif;background:#0f1720;display:grid;place-items:center;height:100vh;margin:0;color:#dce8f5}}
|
||||||
.title {{ font-weight: 700; margin-bottom: 0.5rem; }}
|
.card{{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12);padding:1.6rem 2rem;border-radius:14px;
|
||||||
.state {{ margin-bottom: 0.6rem; }}
|
box-shadow:0 12px 32px rgba(0,0,0,.4);min-width:320px;max-width:440px;text-align:center}}
|
||||||
ul {{ margin: 0; padding-left: 1.1rem; }}
|
.spinner{{width:48px;height:48px;border:4px solid rgba(220,232,245,.15);border-top-color:#2a8cd6;
|
||||||
li {{ margin: 0.2rem 0; }}
|
border-radius:50%;animation:spin .9s linear infinite;margin:0 auto 1.2rem}}
|
||||||
|
@keyframes spin{{to{{transform:rotate(360deg)}}}}
|
||||||
|
.title{{font-size:1.15rem;font-weight:700;margin-bottom:.5rem;color:#fff}}
|
||||||
|
.state{{font-size:.9rem;color:#a0b8cc;margin-bottom:.8rem;min-height:1.2em}}
|
||||||
|
ul{{margin:0;padding:0;list-style:none;font-size:.82rem;color:#7a99b0;text-align:left}}
|
||||||
|
li::before{{content:"· ";color:#2a8cd6}}
|
||||||
|
li+li{{margin-top:.2rem}}
|
||||||
|
.sid{{display:block;margin-top:1.2rem;font-size:.7rem;color:rgba(160,184,204,.4);word-break:break-all}}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="title">Сессия запускается</div>
|
<div class="spinner"></div>
|
||||||
|
<div class="title">{label}</div>
|
||||||
<div class="state" id="state">Проверка...</div>
|
<div class="state" id="state">Проверка...</div>
|
||||||
<ul id="steps"></ul>
|
<ul id="steps"></ul>
|
||||||
<small>{session_id}</small>
|
<span class="sid">{session_id}</span>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
const sessionId = "{session_id}";
|
const sessionId = "{session_id}";
|
||||||
@@ -2654,6 +2679,25 @@ def set_acl(user_id: int, payload: dict, request: Request, _: User = Depends(req
|
|||||||
existing = db.scalars(select(UserServiceAccess).where(UserServiceAccess.user_id == user_id)).all()
|
existing = db.scalars(select(UserServiceAccess).where(UserServiceAccess.user_id == user_id)).all()
|
||||||
existing_map = {x.service_id: x for x in existing}
|
existing_map = {x.service_id: x for x in existing}
|
||||||
|
|
||||||
|
# Check RDP exclusivity: each RDP service can belong to only one user in ACL
|
||||||
|
all_rdp_ids_in_payload = set()
|
||||||
|
for sid in service_ids:
|
||||||
|
svc = db.get(Service, sid)
|
||||||
|
if svc and svc.type == ServiceType.RDP:
|
||||||
|
all_rdp_ids_in_payload.add(sid)
|
||||||
|
if all_rdp_ids_in_payload:
|
||||||
|
acl_conflicts = db.execute(
|
||||||
|
select(UserServiceAccess.service_id, User.username)
|
||||||
|
.join(User, User.id == UserServiceAccess.user_id)
|
||||||
|
.where(
|
||||||
|
UserServiceAccess.service_id.in_(all_rdp_ids_in_payload),
|
||||||
|
UserServiceAccess.user_id != user_id,
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
if acl_conflicts:
|
||||||
|
blocked = ", ".join(f'"{row.username}"' for row in acl_conflicts)
|
||||||
|
raise HTTPException(status_code=409, detail=f"RDP сервис уже назначен другому пользователю ({blocked}).")
|
||||||
|
|
||||||
for sid in service_ids:
|
for sid in service_ids:
|
||||||
if sid not in existing_map:
|
if sid not in existing_map:
|
||||||
db.add(UserServiceAccess(user_id=user_id, service_id=sid))
|
db.add(UserServiceAccess(user_id=user_id, service_id=sid))
|
||||||
|
|||||||
@@ -189,6 +189,11 @@ button {
|
|||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
margin: 0.6rem 0;
|
margin: 0.6rem 0;
|
||||||
}
|
}
|
||||||
|
.acl-owner {
|
||||||
|
color: #e07b39;
|
||||||
|
font-size: 0.82em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
.tab-row {
|
.tab-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
<div class="list-title">ACL выбранного пользователя</div>
|
<div class="list-title">ACL выбранного пользователя</div>
|
||||||
<div class="acl-grid">
|
<div class="acl-grid">
|
||||||
{% for s in services %}
|
{% for s in services %}
|
||||||
<label><input type="checkbox" class="acl_service" value="{{s.id}}" /> {{s.name}} ({{s.slug}})</label>
|
<label><input type="checkbox" class="acl_service" value="{{s.id}}" data-stype="{{s.type.value}}" /> {{s.name}} ({{s.slug}})<span class="acl-owner"></span></label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<button onclick="saveAclForSelectedUser()">Save ACL</button>
|
<button onclick="saveAclForSelectedUser()">Save ACL</button>
|
||||||
@@ -513,6 +513,8 @@
|
|||||||
const csrf = "{{ csrf_token }}";
|
const csrf = "{{ csrf_token }}";
|
||||||
const aclMap = {{ acl | tojson }};
|
const aclMap = {{ acl | tojson }};
|
||||||
const serviceCategoryMap = {{ service_category_map | tojson }};
|
const serviceCategoryMap = {{ service_category_map | tojson }};
|
||||||
|
const rdpOccupiedBy = {{ rdp_occupied_by | tojson }};
|
||||||
|
const rdpOccupiedUsername = {{ rdp_occupied_username | tojson }};
|
||||||
const placeholderIcon = '/static/service-placeholder.svg';
|
const placeholderIcon = '/static/service-placeholder.svg';
|
||||||
let activeTab = 'users';
|
let activeTab = 'users';
|
||||||
|
|
||||||
@@ -683,7 +685,21 @@
|
|||||||
const userId = parseInt(document.getElementById('u_id').value || '0', 10);
|
const userId = parseInt(document.getElementById('u_id').value || '0', 10);
|
||||||
const allowed = new Set((aclMap[userId] || []));
|
const allowed = new Set((aclMap[userId] || []));
|
||||||
document.querySelectorAll('.acl_service').forEach((box) => {
|
document.querySelectorAll('.acl_service').forEach((box) => {
|
||||||
box.checked = allowed.has(parseInt(box.value, 10));
|
const sid = parseInt(box.value, 10);
|
||||||
|
box.checked = allowed.has(sid);
|
||||||
|
const isRdp = box.dataset.stype === 'RDP';
|
||||||
|
const occupiedBy = rdpOccupiedBy[sid];
|
||||||
|
const currentUserHasIt = allowed.has(sid);
|
||||||
|
const ownerSpan = box.closest('label').querySelector('.acl-owner');
|
||||||
|
if (isRdp && occupiedBy && occupiedBy !== userId && !currentUserHasIt) {
|
||||||
|
box.disabled = true;
|
||||||
|
box.closest('label').style.opacity = '0.45';
|
||||||
|
if (ownerSpan) ownerSpan.textContent = ` (${rdpOccupiedUsername[sid]})`;
|
||||||
|
} else {
|
||||||
|
box.disabled = false;
|
||||||
|
box.closest('label').style.opacity = '';
|
||||||
|
if (ownerSpan) ownerSpan.textContent = '';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,25 @@
|
|||||||
<link rel="alternate icon" type="image/png" href="/static/favicon.png" />
|
<link rel="alternate icon" type="image/png" href="/static/favicon.png" />
|
||||||
</head>
|
</head>
|
||||||
<body class="dashboard-page">
|
<body class="dashboard-page">
|
||||||
|
{% raw %}<style>
|
||||||
|
#mobile-wall{display:none;position:fixed;inset:0;z-index:9999;background:linear-gradient(135deg,#0d1b2a 0%,#1a2e45 60%,#0f2137 100%);flex-direction:column;align-items:center;justify-content:center;padding:2rem;text-align:center;font-family:sans-serif}
|
||||||
|
@media(max-width:1023px){#mobile-wall{display:flex}}
|
||||||
|
.mw-icon{font-size:4rem;margin-bottom:1.2rem;filter:drop-shadow(0 0 18px rgba(42,140,214,.5))}
|
||||||
|
.mw-title{font-size:1.55rem;font-weight:800;color:#fff;margin-bottom:.7rem;letter-spacing:.01em}
|
||||||
|
.mw-sub{font-size:1rem;color:#a0b8cc;max-width:340px;line-height:1.6;margin-bottom:2rem}
|
||||||
|
.mw-badge{display:inline-flex;align-items:center;gap:.55rem;background:rgba(42,140,214,.15);border:1px solid rgba(42,140,214,.4);border-radius:999px;padding:.55rem 1.1rem;color:#6bbfff;font-size:.88rem;font-weight:600;letter-spacing:.02em}
|
||||||
|
.mw-badge svg{width:18px;height:18px;flex-shrink:0}
|
||||||
|
</style>{% endraw %}
|
||||||
|
<div id="mobile-wall">
|
||||||
|
<div class="mw-icon">🖥️</div>
|
||||||
|
<div class="mw-title">Только для компьютера</div>
|
||||||
|
<div class="mw-sub">Инфраструктурный полигон МОНТ оптимизирован для работы на ПК.<br>Пожалуйста, откройте портал с настольного компьютера или ноутбука.</div>
|
||||||
|
<div class="mw-badge">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
|
||||||
|
Минимальная ширина экрана: 1024 px
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<div style="display:flex; align-items:center; gap:0.6rem;">
|
<div style="display:flex; align-items:center; gap:0.6rem;">
|
||||||
<img src="/static/logo.png" alt="MONT" class="header-logo" />
|
<img src="/static/logo.png" alt="MONT" class="header-logo" />
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
websockify \
|
websockify \
|
||||||
python3 \
|
python3 \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
|
x11-xserver-utils \
|
||||||
|
x11-utils \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ set -euo pipefail
|
|||||||
TARGET_URL="${TARGET_URL:-https://example.com}"
|
TARGET_URL="${TARGET_URL:-https://example.com}"
|
||||||
SESSION_ID="${SESSION_ID:-unknown}"
|
SESSION_ID="${SESSION_ID:-unknown}"
|
||||||
IDLE_TIMEOUT="${IDLE_TIMEOUT:-1800}"
|
IDLE_TIMEOUT="${IDLE_TIMEOUT:-1800}"
|
||||||
X11VNC_FLAGS="${X11VNC_FLAGS:--wait 5 -defer 5 -ncache 10 -threads}"
|
X11VNC_FLAGS="${X11VNC_FLAGS:--wait 5 -defer 5 -threads}"
|
||||||
ENABLE_HEARTBEAT="${ENABLE_HEARTBEAT:-1}"
|
ENABLE_HEARTBEAT="${ENABLE_HEARTBEAT:-1}"
|
||||||
TOUCH_PATH="${TOUCH_PATH:-/api/sessions/${SESSION_ID}/touch}"
|
TOUCH_PATH="${TOUCH_PATH:-/api/sessions/${SESSION_ID}/touch}"
|
||||||
UNIVERSAL_WEB="${UNIVERSAL_WEB:-0}"
|
UNIVERSAL_WEB="${UNIVERSAL_WEB:-0}"
|
||||||
|
|||||||
+76
-1
@@ -1,9 +1,18 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
|
||||||
|
DISPLAY = os.environ.get("DISPLAY", ":1")
|
||||||
|
CHROME_WINDOW_SIZE = os.environ.get("CHROME_WINDOW_SIZE", "1920,1080")
|
||||||
|
RESOLUTION_MIN_WIDTH = int(os.environ.get("WEB_RESOLUTION_MIN_WIDTH", "1024"))
|
||||||
|
RESOLUTION_MIN_HEIGHT = int(os.environ.get("WEB_RESOLUTION_MIN_HEIGHT", "720"))
|
||||||
|
RESOLUTION_MAX_WIDTH = int(os.environ.get("WEB_RESOLUTION_MAX_WIDTH", "3840"))
|
||||||
|
RESOLUTION_MAX_HEIGHT = int(os.environ.get("WEB_RESOLUTION_MAX_HEIGHT", "2160"))
|
||||||
|
|
||||||
|
|
||||||
def _json_get(path: str):
|
def _json_get(path: str):
|
||||||
with urllib.request.urlopen(f"http://127.0.0.1:9222{path}", timeout=2) as resp:
|
with urllib.request.urlopen(f"http://127.0.0.1:9222{path}", timeout=2) as resp:
|
||||||
@@ -20,7 +29,6 @@ def chromium_open(url: str) -> None:
|
|||||||
encoded = urllib.parse.quote(url, safe=':/?#[]@!$&\'()*+,;=%')
|
encoded = urllib.parse.quote(url, safe=':/?#[]@!$&\'()*+,;=%')
|
||||||
opened = _json_put(f"/json/new?{encoded}")
|
opened = _json_put(f"/json/new?{encoded}")
|
||||||
opened_id = opened.get("id")
|
opened_id = opened.get("id")
|
||||||
# Keep exactly one active page tab to prevent tab/memory explosion in warm containers.
|
|
||||||
pages = _json_get("/json/list")
|
pages = _json_get("/json/list")
|
||||||
for page in pages:
|
for page in pages:
|
||||||
page_id = page.get("id")
|
page_id = page.get("id")
|
||||||
@@ -31,6 +39,70 @@ def chromium_open(url: str) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_resolution(width, height):
|
||||||
|
if not width or not height:
|
||||||
|
try:
|
||||||
|
default_w, default_h = [int(x) for x in CHROME_WINDOW_SIZE.split(",", 1)]
|
||||||
|
return default_w, default_h
|
||||||
|
except Exception:
|
||||||
|
return 1920, 1080
|
||||||
|
safe_w = max(RESOLUTION_MIN_WIDTH, min(int(width), RESOLUTION_MAX_WIDTH))
|
||||||
|
safe_h = max(RESOLUTION_MIN_HEIGHT, min(int(height), RESOLUTION_MAX_HEIGHT))
|
||||||
|
return safe_w, safe_h
|
||||||
|
|
||||||
|
|
||||||
|
def _xrandr_output_name():
|
||||||
|
try:
|
||||||
|
out = subprocess.run(
|
||||||
|
["xrandr", "-display", DISPLAY],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
).stdout
|
||||||
|
for line in out.splitlines():
|
||||||
|
if " connected" in line:
|
||||||
|
return line.split()[0]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _add_mode_via_cvt(width: int, height: int, output_name: str) -> bool:
|
||||||
|
try:
|
||||||
|
cvt = subprocess.run(
|
||||||
|
["cvt", str(width), str(height)],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
if cvt.returncode != 0:
|
||||||
|
return False
|
||||||
|
modeline_line = next((l for l in cvt.stdout.splitlines() if l.startswith("Modeline")), None)
|
||||||
|
if not modeline_line:
|
||||||
|
return False
|
||||||
|
parts = modeline_line.split()
|
||||||
|
mode_name = parts[1].strip('"')
|
||||||
|
mode_params = parts[2:]
|
||||||
|
subprocess.run(["xrandr", "-display", DISPLAY, "--newmode", mode_name] + mode_params,
|
||||||
|
check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
subprocess.run(["xrandr", "-display", DISPLAY, "--addmode", output_name, mode_name],
|
||||||
|
check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
subprocess.run(["xrandr", "-display", DISPLAY, "--output", output_name, "--mode", mode_name],
|
||||||
|
check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def apply_resolution(width, height) -> tuple:
|
||||||
|
safe_w, safe_h = _sanitize_resolution(width, height)
|
||||||
|
result = subprocess.run(
|
||||||
|
["xrandr", "-display", DISPLAY, "-s", f"{safe_w}x{safe_h}"],
|
||||||
|
check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
output_name = _xrandr_output_name()
|
||||||
|
if output_name:
|
||||||
|
_add_mode_via_cvt(safe_w, safe_h, output_name)
|
||||||
|
return safe_w, safe_h
|
||||||
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
def _json(self, code: int, payload: dict):
|
def _json(self, code: int, payload: dict):
|
||||||
body = json.dumps(payload).encode("utf-8")
|
body = json.dumps(payload).encode("utf-8")
|
||||||
@@ -58,6 +130,9 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if not url.startswith("http://") and not url.startswith("https://"):
|
if not url.startswith("http://") and not url.startswith("https://"):
|
||||||
self._json(400, {"detail": "Invalid URL"})
|
self._json(400, {"detail": "Invalid URL"})
|
||||||
return
|
return
|
||||||
|
width = data.get("width")
|
||||||
|
height = data.get("height")
|
||||||
|
apply_resolution(width, height)
|
||||||
chromium_open(url)
|
chromium_open(url)
|
||||||
print(f"open_ok url={url}", flush=True)
|
print(f"open_ok url={url}", flush=True)
|
||||||
self._json(200, {"ok": True, "url": url})
|
self._json(200, {"ok": True, "url": url})
|
||||||
|
|||||||
+126
-21
@@ -24,53 +24,158 @@ cat > /opt/portal/index.html <<HTML
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>RDP Session</title>
|
<title>RDP Session</title>
|
||||||
<style>html,body,#screen{margin:0;height:100%;background:#111}</style>
|
<style>
|
||||||
|
html,body,#screen{margin:0;height:100%;background:#111}
|
||||||
|
.status{
|
||||||
|
position:fixed;left:12px;top:12px;z-index:50;padding:8px 10px;border-radius:8px;
|
||||||
|
background:rgba(16,22,32,.86);border:1px solid rgba(255,255,255,.18);
|
||||||
|
color:#dce8f5;font:600 13px/1.25 sans-serif;max-width:min(92vw,560px);
|
||||||
|
}
|
||||||
|
.status.error{background:rgba(85,20,20,.9);border-color:rgba(255,130,130,.36);color:#ffe3e3}
|
||||||
|
.status.hidden{display:none}
|
||||||
|
.spinner{display:inline-block;width:12px;height:12px;border:2px solid rgba(220,232,245,.3);
|
||||||
|
border-top-color:#dce8f5;border-radius:50%;animation:spin .8s linear infinite;margin-right:7px;vertical-align:middle}
|
||||||
|
@keyframes spin{to{transform:rotate(360deg)}}
|
||||||
|
.nav-panel{
|
||||||
|
position:fixed;left:16px;top:64px;z-index:99;display:flex;gap:10px;
|
||||||
|
background:linear-gradient(180deg,rgba(15,24,36,.78),rgba(9,14,22,.86));
|
||||||
|
border:1px solid rgba(255,255,255,.22);backdrop-filter:blur(5px);
|
||||||
|
box-shadow:0 10px 28px rgba(0,0,0,.36);padding:9px 10px;border-radius:14px
|
||||||
|
}
|
||||||
|
.nav-btn{
|
||||||
|
border:1px solid rgba(255,255,255,.26);border-radius:999px;padding:9px 14px;cursor:pointer;
|
||||||
|
background:linear-gradient(180deg,#2a8cd6,#1668a6);color:#fff;font:700 13px/1 sans-serif;
|
||||||
|
box-shadow:inset 0 1px 0 rgba(255,255,255,.22),0 5px 12px rgba(10,46,78,.45)
|
||||||
|
}
|
||||||
|
.nav-btn:hover{filter:brightness(1.08)}
|
||||||
|
.nav-btn:active{transform:translateY(1px)}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="screen"></div>
|
<div id="screen"></div>
|
||||||
|
<div id="status" class="status"><span class="spinner"></span>Ожидайте...</div>
|
||||||
|
<div class="nav-panel">
|
||||||
|
<button class="nav-btn" id="btn-back" type="button" title="Назад">←</button>
|
||||||
|
<button class="nav-btn" id="btn-home" type="button" title="Главная">⌂</button>
|
||||||
|
</div>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import RFB from './core/rfb.js';
|
import RFB from './core/rfb.js';
|
||||||
const wsBase = location.pathname.replace(/\/+$/, '');
|
const wsBase = location.pathname.replace(/\/+$/, '');
|
||||||
const wsUrl = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + wsBase + '/websockify';
|
const wsUrl = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + wsBase + '/websockify';
|
||||||
const rfb = new RFB(document.getElementById('screen'), wsUrl);
|
const statusEl = document.getElementById('status');
|
||||||
rfb.viewOnly = false;
|
const XK_ALT_L = 0xffe9;
|
||||||
rfb.scaleViewport = true;
|
const XK_LEFT = 0xff51;
|
||||||
rfb.resizeSession = true;
|
|
||||||
const enableHeartbeat = "${ENABLE_HEARTBEAT}" === "1";
|
let rfb = null;
|
||||||
const SESSION_CLOSED_URL = '/?session_closed=idle';
|
let connected = false;
|
||||||
const CLOSE_PATH = '${TOUCH_PATH}'.replace(/\/touch$/, '/close');
|
let reconnectTimer = null;
|
||||||
function goSessionClosed() {
|
let reconnectAttempts = 0;
|
||||||
try {
|
const MAX_RECONNECT = 12;
|
||||||
if (window.top && window.top !== window) {
|
const DELAYS = [1000,2000,3000,5000,8000];
|
||||||
window.top.location.href = SESSION_CLOSED_URL;
|
let manualDisconnect = false;
|
||||||
return;
|
|
||||||
}
|
function showStatus(text, isError) {
|
||||||
} catch (e) {}
|
const spinner = isError ? '' : '<span class="spinner"></span>';
|
||||||
window.location.href = SESSION_CLOSED_URL;
|
statusEl.innerHTML = spinner + text;
|
||||||
|
statusEl.className = 'status' + (isError ? ' error' : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hideStatus() { statusEl.className = 'status hidden'; }
|
||||||
|
|
||||||
|
function scheduleReconnect(reason) {
|
||||||
|
if (manualDisconnect) return;
|
||||||
|
if (reconnectAttempts >= MAX_RECONNECT) {
|
||||||
|
showStatus('Соединение потеряно. Переподключение не удалось. Откройте сервис заново.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = ++reconnectAttempts;
|
||||||
|
const delay = DELAYS[Math.min(n-1, DELAYS.length-1)];
|
||||||
|
showStatus(\`\${reason} Повтор \${n}/\${MAX_RECONNECT} через \${Math.ceil(delay/1000)} сек.\`, true);
|
||||||
|
reconnectTimer = setTimeout(connect, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (manualDisconnect) return;
|
||||||
|
connected = false;
|
||||||
|
showStatus('Ожидайте...');
|
||||||
|
if (rfb) { try { rfb.disconnect(); } catch(e){} }
|
||||||
|
rfb = new RFB(document.getElementById('screen'), wsUrl);
|
||||||
|
rfb.viewOnly = false;
|
||||||
|
rfb.scaleViewport = true;
|
||||||
|
rfb.resizeSession = true;
|
||||||
|
rfb.addEventListener('connect', () => {
|
||||||
|
connected = true;
|
||||||
|
reconnectAttempts = 0;
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
hideStatus();
|
||||||
|
});
|
||||||
|
rfb.addEventListener('disconnect', () => {
|
||||||
|
connected = false;
|
||||||
|
if (!manualDisconnect) scheduleReconnect('Соединение потеряно.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const enableHeartbeat = "${ENABLE_HEARTBEAT}" === "1";
|
||||||
|
const TOUCH_PATH = '${TOUCH_PATH}';
|
||||||
|
const CLOSE_PATH = TOUCH_PATH.replace(/\/touch$/, '/close');
|
||||||
|
const SESSION_CLOSED_URL = '/?session_closed=idle';
|
||||||
|
|
||||||
|
function goSessionClosed(reason) {
|
||||||
|
const r = reason === 'limit' ? 'limit' : 'idle';
|
||||||
|
try {
|
||||||
|
if (window.top && window.top !== window) { window.top.location.href = '/?session_closed=' + r; return; }
|
||||||
|
} catch(e) {}
|
||||||
|
window.location.href = '/?session_closed=' + r;
|
||||||
|
}
|
||||||
|
|
||||||
async function touch() {
|
async function touch() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('${TOUCH_PATH}', {method:'POST', credentials:'include'});
|
const res = await fetch(TOUCH_PATH, {method:'POST', credentials:'include'});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
goSessionClosed();
|
let reason = 'idle';
|
||||||
|
try { const p = await res.json(); if (p && typeof p.reason === 'string') reason = p.reason; } catch(e) {}
|
||||||
|
goSessionClosed(reason);
|
||||||
}
|
}
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
let closing = false;
|
let closing = false;
|
||||||
async function closeSessionNow() {
|
async function closeSessionNow() {
|
||||||
if (closing) return;
|
if (closing) return;
|
||||||
closing = true;
|
closing = true;
|
||||||
try {
|
manualDisconnect = true;
|
||||||
await fetch(CLOSE_PATH, {method:'POST', credentials:'include', keepalive:true});
|
clearTimeout(reconnectTimer);
|
||||||
} catch (e) {}
|
try { if (rfb) rfb.disconnect(); } catch(e) {}
|
||||||
|
try { await fetch(CLOSE_PATH, {method:'POST', credentials:'include', keepalive:true}); } catch(e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (enableHeartbeat) {
|
if (enableHeartbeat) {
|
||||||
setInterval(touch, 15000);
|
setInterval(touch, 15000);
|
||||||
touch();
|
touch();
|
||||||
window.addEventListener('pagehide', closeSessionNow);
|
window.addEventListener('pagehide', closeSessionNow);
|
||||||
window.addEventListener('beforeunload', closeSessionNow);
|
window.addEventListener('beforeunload', closeSessionNow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chord(mod, key, modCode, keyCode) {
|
||||||
|
if (!rfb) return;
|
||||||
|
rfb.sendKey(mod, modCode, true);
|
||||||
|
rfb.sendKey(key, keyCode, true);
|
||||||
|
rfb.sendKey(key, keyCode, false);
|
||||||
|
rfb.sendKey(mod, modCode, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goHome() {
|
||||||
|
manualDisconnect = true;
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
try { if (window.top && window.top !== window) { window.top.location.href = '/'; return; } } catch(e) {}
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-back').addEventListener('click', () => chord(XK_ALT_L, XK_LEFT, 'AltLeft', 'ArrowLeft'));
|
||||||
|
document.getElementById('btn-home').addEventListener('click', goHome);
|
||||||
document.addEventListener('contextmenu', (e) => e.preventDefault());
|
document.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||||
|
|
||||||
|
connect();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
websockify \
|
websockify \
|
||||||
python3 \
|
python3 \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
|
x11-xserver-utils \
|
||||||
|
x11-utils \
|
||||||
fonts-dejavu-core \
|
fonts-dejavu-core \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
IDLE_TIMEOUT="${IDLE_TIMEOUT:-1800}"
|
IDLE_TIMEOUT="${IDLE_TIMEOUT:-1800}"
|
||||||
X11VNC_FLAGS="${X11VNC_FLAGS:--wait 5 -defer 5 -ncache 10 -threads}"
|
X11VNC_FLAGS="${X11VNC_FLAGS:--wait 5 -defer 5 -threads}"
|
||||||
SCREEN_GEOMETRY="${SCREEN_GEOMETRY:-1920x1080x24}"
|
SCREEN_GEOMETRY="${SCREEN_GEOMETRY:-1920x1080x24}"
|
||||||
CHROME_WINDOW_SIZE="${CHROME_WINDOW_SIZE:-1920,1080}"
|
CHROME_WINDOW_SIZE="${CHROME_WINDOW_SIZE:-1920,1080}"
|
||||||
ENABLE_HEARTBEAT="${ENABLE_HEARTBEAT:-1}"
|
ENABLE_HEARTBEAT="${ENABLE_HEARTBEAT:-1}"
|
||||||
|
|||||||
@@ -68,38 +68,55 @@ def _sanitize_resolution(width: int | None, height: int | None) -> tuple[int, in
|
|||||||
return safe_w, safe_h
|
return safe_w, safe_h
|
||||||
|
|
||||||
|
|
||||||
|
def _xrandr_output_name() -> str | None:
|
||||||
|
try:
|
||||||
|
out = subprocess.run(
|
||||||
|
["xrandr", "-display", DISPLAY],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
).stdout
|
||||||
|
for line in out.splitlines():
|
||||||
|
if " connected" in line:
|
||||||
|
return line.split()[0]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _add_mode_via_cvt(width: int, height: int, output_name: str) -> bool:
|
||||||
|
try:
|
||||||
|
cvt = subprocess.run(
|
||||||
|
["cvt", str(width), str(height)],
|
||||||
|
capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
if cvt.returncode != 0:
|
||||||
|
return False
|
||||||
|
modeline_line = next((l for l in cvt.stdout.splitlines() if l.startswith("Modeline")), None)
|
||||||
|
if not modeline_line:
|
||||||
|
return False
|
||||||
|
parts = modeline_line.split()
|
||||||
|
mode_name = parts[1].strip('"')
|
||||||
|
mode_params = parts[2:]
|
||||||
|
subprocess.run(["xrandr", "-display", DISPLAY, "--newmode", mode_name] + mode_params,
|
||||||
|
check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
subprocess.run(["xrandr", "-display", DISPLAY, "--addmode", output_name, mode_name],
|
||||||
|
check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
subprocess.run(["xrandr", "-display", DISPLAY, "--output", output_name, "--mode", mode_name],
|
||||||
|
check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def apply_resolution(width: int | None, height: int | None) -> tuple[int, int]:
|
def apply_resolution(width: int | None, height: int | None) -> tuple[int, int]:
|
||||||
safe_w, safe_h = _sanitize_resolution(width, height)
|
safe_w, safe_h = _sanitize_resolution(width, height)
|
||||||
# Best effort: Xvfb usually exposes RandR and accepts xrandr -s.
|
result = subprocess.run(
|
||||||
applied = False
|
["xrandr", "-display", DISPLAY, "-s", f"{safe_w}x{safe_h}"],
|
||||||
try:
|
check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
result = subprocess.run( # noqa: S603
|
)
|
||||||
["xrandr", "-display", DISPLAY, "-s", f"{safe_w}x{safe_h}"],
|
if result.returncode != 0:
|
||||||
check=False,
|
output_name = _xrandr_output_name()
|
||||||
stdout=subprocess.DEVNULL,
|
if output_name:
|
||||||
stderr=subprocess.DEVNULL,
|
_add_mode_via_cvt(safe_w, safe_h, output_name)
|
||||||
)
|
|
||||||
applied = result.returncode == 0
|
|
||||||
except Exception:
|
|
||||||
applied = False
|
|
||||||
|
|
||||||
if not applied:
|
|
||||||
# Fallback to default geometry if requested mode is unsupported.
|
|
||||||
try:
|
|
||||||
fallback_w, fallback_h = [int(x) for x in CHROME_WINDOW_SIZE.split(",", 1)]
|
|
||||||
except Exception:
|
|
||||||
fallback_w, fallback_h = 1920, 1080
|
|
||||||
safe_w, safe_h = _sanitize_resolution(fallback_w, fallback_h)
|
|
||||||
try:
|
|
||||||
subprocess.run( # noqa: S603
|
|
||||||
["xrandr", "-display", DISPLAY, "-s", f"{safe_w}x{safe_h}"],
|
|
||||||
check=False,
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
_state["resolution"] = f"{safe_w},{safe_h}"
|
_state["resolution"] = f"{safe_w},{safe_h}"
|
||||||
return safe_w, safe_h
|
return safe_w, safe_h
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user