- diff --git a/app/main.py b/app/main.py index b7753fc..53db54f 100644 --- a/app/main.py +++ b/app/main.py @@ -39,12 +39,12 @@ 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", "1800")) +SESSION_IDLE_SECONDS = int(os.getenv("SESSION_IDLE_SECONDS", "300")) PUBLIC_HOST = os.getenv("PUBLIC_HOST", "stend.4mont.ru") LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() TRAEFIK_INTERNAL_URL = os.getenv("TRAEFIK_INTERNAL_URL", "http://traefik") PREWARM_POOL_SIZE = int(os.getenv("PREWARM_POOL_SIZE", "0")) -UNIVERSAL_POOL_SIZE = int(os.getenv("UNIVERSAL_POOL_SIZE", "5")) +UNIVERSAL_POOL_SIZE = int(os.getenv("UNIVERSAL_POOL_SIZE", "0")) WEB_POOL_SIZE = int(os.getenv("WEB_POOL_SIZE", "5")) WEB_POOL_BUFFER = int(os.getenv("WEB_POOL_BUFFER", "2")) ICON_UPLOAD_MAX_BYTES = 2 * 1024 * 1024 @@ -407,6 +407,21 @@ def session_router_name(session_id: str) -> str: return f"sess-{session_id.replace('-', '')[:16]}" +def _is_pool_name_conflict(exc: Exception) -> bool: + msg = str(exc).lower() + return ("already in use" in msg) or ("marked for removal" in msg) + + +def _remove_container_by_name(d, name: str) -> None: + try: + old = d.containers.get(name) + old.remove(force=True) + except docker.errors.NotFound: + return + except Exception: + logger.exception("pool_container_remove_failed name=%s", name) + + def ensure_universal_pool() -> None: if UNIVERSAL_POOL_SIZE <= 0: return @@ -506,27 +521,48 @@ def ensure_web_pool(target_size: Optional[int] = None) -> None: "ENABLE_HEARTBEAT": "0", "SESSION_ID": f"webpool-{i}", } + should_create = False try: c = d.containers.get(name) if c.status != "running": - c.start() - continue + try: + c.start() + except docker.errors.APIError as exc: + if _is_pool_name_conflict(exc): + logger.warning("web_pool_recreate_needed slot=%s reason=name-conflict", i) + _remove_container_by_name(d, name) + should_create = True + else: + raise + if not should_create: + continue except docker.errors.NotFound: - pass + should_create = True except Exception: logger.exception("web_pool_check_failed slot=%s", i) continue - d.containers.run( - image=image, - name=name, - detach=True, - auto_remove=True, - network="portal_net", - labels=labels, - environment=env, - ) - logger.info("web_pool_container_started slot=%s", i) + for attempt in range(3): + try: + d.containers.run( + image=image, + name=name, + detach=True, + auto_remove=True, + network="portal_net", + labels=labels, + environment=env, + ) + logger.info("web_pool_container_started slot=%s", i) + break + except docker.errors.APIError as exc: + if _is_pool_name_conflict(exc) and attempt < 2: + logger.warning("web_pool_run_conflict_retry slot=%s attempt=%s", i, attempt + 1) + _remove_container_by_name(d, name) + time.sleep(0.25) + continue + logger.exception("web_pool_run_failed slot=%s", i) + break def get_universal_pool_status() -> dict: @@ -983,6 +1019,9 @@ def ensure_schema_compatibility() -> None: def desired_pool_size(service: Service) -> int: if not service.active: return 0 + if service.type == ServiceType.RDP and not service_uses_universal_pool(service): + # RDP runs on-demand per user session; no prewarmed pool. + return 0 if service_uses_universal_pool(service): return UNIVERSAL_POOL_SIZE return service.warm_pool_size if service.warm_pool_size and service.warm_pool_size > 0 else PREWARM_POOL_SIZE @@ -1005,6 +1044,8 @@ def get_warm_containers_for_service(service: Service) -> list: def get_pool_status_for_service(service: Service) -> dict: if service.type == ServiceType.WEB: return get_web_pool_status() + if service.type == ServiceType.RDP and not service_uses_universal_pool(service): + return {"desired": 0, "running": 0, "total": 0, "names": [], "health": "n/a"} if service_uses_universal_pool(service): return get_universal_pool_status() desired = desired_pool_size(service) @@ -1141,6 +1182,27 @@ def get_active_sessions_count(db: Session, service_id: int) -> int: return len(db.scalars(q).all()) +def find_active_session_for_service(db: Session, service_id: int) -> Optional[SessionModel]: + cutoff = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS) + q = ( + select(SessionModel) + .where( + SessionModel.service_id == service_id, + SessionModel.status == SessionStatus.ACTIVE, + SessionModel.last_access_at >= cutoff, + ) + .order_by(SessionModel.created_at.desc()) + ) + return db.scalars(q).first() + + +def session_redirect_url(sess: SessionModel) -> str: + cid = sess.container_id or "" + if cid.startswith("POOL:") or cid.startswith("POOLIDX:") or cid.startswith("WEBPOOLIDX:"): + return f"/s/{sess.id}/view" + return f"/s/{sess.id}/" + + def open_warm_web_url(service: Service, target_url: str) -> None: if service_uses_universal_pool(service): return @@ -1186,9 +1248,7 @@ def cleanup_loop(): Service.type.in_([ServiceType.WEB, ServiceType.RDP]), ) ).all(): - if svc.type == ServiceType.WEB: - continue - if not service_uses_universal_pool(svc): + if svc.type == ServiceType.WEB and WEB_POOL_SIZE <= 0: ensure_warm_pool(svc) cutoff = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS) q = select(SessionModel).where( @@ -1257,9 +1317,7 @@ def startup_event(): Service.type.in_([ServiceType.WEB, ServiceType.RDP]), ) ).all(): - if svc.type == ServiceType.WEB: - continue - if not service_uses_universal_pool(svc): + if svc.type == ServiceType.WEB and WEB_POOL_SIZE <= 0: ensure_warm_pool(svc) finally: db.close() @@ -1269,9 +1327,21 @@ def startup_event(): @app.get("/", response_class=HTMLResponse) def index(request: Request, user: Optional[User] = Depends(get_current_user), db: Session = Depends(get_db)): + session_closed = (request.query_params.get("session_closed") or "").strip().lower() + session_notice = "" + if session_closed == "idle": + session_notice = "Сессия была закрыта из-за простоя. Откройте сервис заново." if not user: csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24) - response = templates.TemplateResponse("login.html", {"request": request, "csrf_token": csrf, "login_error": ""}) + response = templates.TemplateResponse( + "login.html", + { + "request": request, + "csrf_token": csrf, + "login_error": "", + "session_notice": session_notice, + }, + ) response.set_cookie(CSRF_COOKIE, csrf, httponly=False, secure=True, samesite="strict", path="/") return response @@ -1326,6 +1396,7 @@ def index(request: Request, user: Optional[User] = Depends(get_current_user), db "selected_category_slug": selected_category_slug, "service_categories": service_categories, "csrf_token": request.cookies.get(CSRF_COOKIE, ""), + "session_notice": session_notice, }, ) @@ -1473,6 +1544,17 @@ def go_service(slug: str, user: User = Depends(require_user), db: Session = Depe raise HTTPException(status_code=410, detail="VNC services are deprecated") if not has_access(db, user.id, service.id): raise HTTPException(status_code=403, detail="ACL denied") + if service.type == ServiceType.RDP: + active_owner = find_active_session_for_service(db, service.id) + if active_owner: + if active_owner.user_id != user.id: + owner = db.get(User, active_owner.user_id) + owner_name = owner.username if owner else f"id={active_owner.user_id}" + raise HTTPException( + status_code=409, + detail=f"RDP сервис уже занят пользователем {owner_name}. Попробуйте позже.", + ) + return RedirectResponse(url=session_redirect_url(active_owner), status_code=303) session_id = str(uuid.uuid4()) if service.type == ServiceType.WEB and WEB_POOL_SIZE > 0: @@ -1496,7 +1578,7 @@ def go_service(slug: str, user: User = Depends(require_user), db: Session = Depe db.add(session_obj) db.commit() audit(db, "SESSION_CREATE_WEB_POOL", f"service={service.slug} session={session_id} slot={slot}", user_id=user.id) - return RedirectResponse(url=f"/w/{slot}/?sid={session_id}", status_code=303) + return RedirectResponse(url=f"/s/{session_id}/", status_code=303) if service_uses_universal_pool(service): try: @@ -1519,9 +1601,9 @@ def go_service(slug: str, user: User = Depends(require_user), db: Session = Depe db.add(session_obj) db.commit() audit(db, "SESSION_CREATE_POOL", f"service={service.slug} session={session_id} slot={slot}", user_id=user.id) - return RedirectResponse(url=f"/u/{slot}/?sid={session_id}", status_code=303) + return RedirectResponse(url=f"/s/{session_id}/", status_code=303) - if desired_pool_size(service) > 0: + if service.type == ServiceType.WEB and desired_pool_size(service) > 0: ensure_warm_pool(service) open_warm_web_url(service, service.target) session_obj = SessionModel( @@ -1625,16 +1707,16 @@ def session_wait_page(session_id: str, request: Request, user: User = Depends(re raise HTTPException(status_code=404, detail="Session not found") if sess.status != SessionStatus.ACTIVE: raise HTTPException(status_code=410, detail="Session is not active") - redirect_target = f"/s/{session_id}/" - if sess.container_id and sess.container_id.startswith("POOL:"): - redirect_target = f"/s/{session_id}/view" + service = db.get(Service, sess.service_id) + service_title = service.name if service else "Сервис" + redirect_target = session_redirect_url(sess) return HTMLResponse( content=f"""
-
-