From 419b495020fae77639b52dddaf876a20f7fabd18 Mon Sep 17 00:00:00 2001 From: Ruslan Date: Mon, 27 Apr 2026 18:49:06 +0000 Subject: [PATCH] feat: RDP ACL exclusivity, mobile wall, nav buttons, resolution xrandr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/main.py | 74 ++++++++++++---- app/static/style.css | 5 ++ app/templates/admin.html | 20 ++++- app/templates/dashboard.html | 19 +++++ kiosk/Dockerfile | 2 + kiosk/entrypoint.sh | 2 +- kiosk/manager.py | 77 ++++++++++++++++- rdp-proxy/entrypoint.sh | 147 +++++++++++++++++++++++++++----- universal-runtime/Dockerfile | 2 + universal-runtime/entrypoint.sh | 2 +- universal-runtime/manager.py | 77 ++++++++++------- 11 files changed, 356 insertions(+), 71 deletions(-) diff --git a/app/main.py b/app/main.py index d81894f..b3440de 100644 --- a/app/main.py +++ b/app/main.py @@ -43,21 +43,21 @@ DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+psycopg2://portal:portal@db COOKIE_NAME = "portal_auth" CSRF_COOKIE = "csrf_token" 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") LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() LOG_SLOW_REQUEST_MS = int(os.getenv("LOG_SLOW_REQUEST_MS", "2000")) GO_USER_LOCK_TIMEOUT_SECONDS = float(os.getenv("GO_USER_LOCK_TIMEOUT_SECONDS", "8.0")) -GO_POOL_LOCK_TIMEOUT_SECONDS = float(os.getenv("GO_POOL_LOCK_TIMEOUT_SECONDS", "5.0")) -POOL_DISPATCH_RETRIES = int(os.getenv("POOL_DISPATCH_RETRIES", "4")) +GO_POOL_LOCK_TIMEOUT_SECONDS = float(os.getenv("GO_POOL_LOCK_TIMEOUT_SECONDS", "20.0")) +POOL_DISPATCH_RETRIES = int(os.getenv("POOL_DISPATCH_RETRIES", "6")) POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS = float(os.getenv("POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS", "2.0")) POOL_DISPATCH_SLEEP_SECONDS = float(os.getenv("POOL_DISPATCH_SLEEP_SECONDS", "0.3")) TRAEFIK_INTERNAL_URL = os.getenv("TRAEFIK_INTERNAL_URL", "http://traefik") -PREWARM_POOL_SIZE = int(os.getenv("PREWARM_POOL_SIZE", "0")) +PREWARM_POOL_SIZE = int(os.getenv("PREWARM_POOL_SIZE", "2")) UNIVERSAL_POOL_SIZE = int(os.getenv("UNIVERSAL_POOL_SIZE", "0")) -WEB_POOL_SIZE = int(os.getenv("WEB_POOL_SIZE", "5")) +WEB_POOL_SIZE = int(os.getenv("WEB_POOL_SIZE", "20")) WEB_POOL_BUFFER = int(os.getenv("WEB_POOL_BUFFER", "2")) -X11VNC_FLAGS = os.getenv("X11VNC_FLAGS", "-wait 5 -defer 5 -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")) WEB_RESOLUTION_MIN_WIDTH = int(os.getenv("WEB_RESOLUTION_MIN_WIDTH", "1024")) WEB_RESOLUTION_MIN_HEIGHT = int(os.getenv("WEB_RESOLUTION_MIN_HEIGHT", "720")) @@ -1521,7 +1521,7 @@ def cleanup_loop(): def bootstrap_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")) db = SessionLocal() @@ -1801,6 +1801,19 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi ), {"cutoff": cutoff}, ).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( "admin.html", { @@ -1823,6 +1836,8 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi "online_sessions": online_sessions, "csrf_token": request.cookies.get(CSRF_COOKIE, ""), "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") service = db.get(Service, sess.service_id) 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) return HTMLResponse( content=f""" @@ -2214,20 +2231,28 @@ def session_wait_page(session_id: str, request: Request, user: User = Depends(re {service_title}
-
Сессия запускается
+
+
{label}
Проверка...
- {session_id} + {session_id}
diff --git a/universal-runtime/Dockerfile b/universal-runtime/Dockerfile index 9f7df4d..75eff12 100644 --- a/universal-runtime/Dockerfile +++ b/universal-runtime/Dockerfile @@ -12,6 +12,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ websockify \ python3 \ ca-certificates \ + x11-xserver-utils \ + x11-utils \ fonts-dejavu-core \ && rm -rf /var/lib/apt/lists/* diff --git a/universal-runtime/entrypoint.sh b/universal-runtime/entrypoint.sh index 2b7d7d8..aa28d95 100755 --- a/universal-runtime/entrypoint.sh +++ b/universal-runtime/entrypoint.sh @@ -2,7 +2,7 @@ set -euo pipefail 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}" CHROME_WINDOW_SIZE="${CHROME_WINDOW_SIZE:-1920,1080}" ENABLE_HEARTBEAT="${ENABLE_HEARTBEAT:-1}" diff --git a/universal-runtime/manager.py b/universal-runtime/manager.py index 6d05752..69a6b6c 100644 --- a/universal-runtime/manager.py +++ b/universal-runtime/manager.py @@ -68,38 +68,55 @@ def _sanitize_resolution(width: int | None, height: int | None) -> tuple[int, in 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]: safe_w, safe_h = _sanitize_resolution(width, height) - # Best effort: Xvfb usually exposes RandR and accepts xrandr -s. - applied = False - try: - result = subprocess.run( # noqa: S603 - ["xrandr", "-display", DISPLAY, "-s", f"{safe_w}x{safe_h}"], - check=False, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - 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 - + result = subprocess.run( + ["xrandr", "-display", DISPLAY, "-s", f"{safe_w}x{safe_h}"], + check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + if result.returncode != 0: + output_name = _xrandr_output_name() + if output_name: + _add_mode_via_cvt(safe_w, safe_h, output_name) _state["resolution"] = f"{safe_w},{safe_h}" return safe_w, safe_h