diff --git a/app/main.py b/app/main.py index 53db54f..563ba99 100644 --- a/app/main.py +++ b/app/main.py @@ -1,6 +1,7 @@ import datetime as dt import enum import fcntl +import re import logging import os from pathlib import Path @@ -18,6 +19,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from itsdangerous import BadSignature, URLSafeTimedSerializer +from markupsafe import Markup, escape from passlib.context import CryptContext from sqlalchemy import ( Boolean, @@ -47,6 +49,7 @@ PREWARM_POOL_SIZE = int(os.getenv("PREWARM_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_BUFFER = int(os.getenv("WEB_POOL_BUFFER", "2")) +MAX_ACTIVE_SERVICES_PER_USER = int(os.getenv("MAX_ACTIVE_SERVICES_PER_USER", "4")) ICON_UPLOAD_MAX_BYTES = 2 * 1024 * 1024 ICON_UPLOAD_TYPES = { "image/png": "png", @@ -202,6 +205,35 @@ def normalize_web_target(url: str) -> str: return f"http://{raw}" +def format_service_comment(raw_text: str) -> Markup: + raw = (raw_text or "").replace("\r\n", "\n").replace("\r", "\n").strip() + if not raw: + return Markup("") + escaped = str(escape(raw)) + # Support pasted/plain markdown-like bold fragments. + escaped = re.sub(r"\*\*(.+?)\*\*", r"\1", escaped, flags=re.DOTALL) + # Allow a small safe subset of pasted HTML tags. + replacements = { + "<b>": "", + "</b>": "", + "<strong>": "", + "</strong>": "", + "<i>": "", + "</i>": "", + "<em>": "", + "</em>": "", + "<u>": "", + "</u>": "", + "<br>": "
", + "<br/>": "
", + "<br />": "
", + } + for src, dst in replacements.items(): + escaped = escaped.replace(src, dst) + escaped = escaped.replace("\n", "
") + return Markup(escaped) + + def parse_rdp_target(target: str) -> dict: raw = (target or "").strip() if not raw: @@ -940,6 +972,22 @@ def stop_runtime_container(container_id: Optional[str]) -> None: logger.exception("session_container_stop_failed container_id=%s", container_id) +def terminate_session_record( + db: Session, + sess: SessionModel, + new_status: SessionStatus = SessionStatus.TERMINATED, + *, + stop_container: bool = True, +) -> None: + if not sess or sess.status != SessionStatus.ACTIVE: + return + cid = sess.container_id or "" + if stop_container and cid and not cid.startswith(("POOL:", "POOLIDX:", "WEBPOOLIDX:")): + stop_runtime_container(cid) + sess.status = new_status + sess.last_access_at = now_utc() + + def ensure_schema_compatibility() -> None: # PostgreSQL requires enum value addition to be committed before usage in constraints. with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn: @@ -1179,7 +1227,13 @@ def get_active_sessions_count(db: Session, service_id: int) -> int: SessionModel.status == SessionStatus.ACTIVE, SessionModel.last_access_at >= cutoff, ) - return len(db.scalars(q).all()) + sessions = db.scalars(q).all() + # Avoid inflated stats when pooled slot sessions were duplicated by race: + # for pooled sessions, occupancy is unique container_id. + pooled = [s for s in sessions if (s.container_id or "").startswith(("WEBPOOLIDX:", "POOLIDX:", "POOL:"))] + direct = [s for s in sessions if s not in pooled] + unique_pooled = len({s.container_id for s in pooled if s.container_id}) + return unique_pooled + len(direct) def find_active_session_for_service(db: Session, service_id: int) -> Optional[SessionModel]: @@ -1196,6 +1250,50 @@ def find_active_session_for_service(db: Session, service_id: int) -> Optional[Se return db.scalars(q).first() +def find_active_session_for_user_service(db: Session, user_id: int, service_id: int) -> Optional[SessionModel]: + cutoff = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS) + q = ( + select(SessionModel) + .where( + SessionModel.user_id == user_id, + 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 allocator_lock(db: Session, lock_id: int): + class _LockCtx: + def __enter__(self_nonlocal): + db.execute(text("SELECT pg_advisory_lock(:lid)"), {"lid": lock_id}) + return self_nonlocal + + def __exit__(self_nonlocal, exc_type, exc, tb): + db.execute(text("SELECT pg_advisory_unlock(:lid)"), {"lid": lock_id}) + return False + + return _LockCtx() + + +def terminate_active_slot_sessions(db: Session, container_id: str) -> None: + if not container_id: + return + db.execute( + text( + """ + UPDATE sessions + SET status = 'TERMINATED' + WHERE container_id = :cid + AND status = 'ACTIVE' + """ + ), + {"cid": container_id}, + ) + + def session_redirect_url(sess: SessionModel) -> str: cid = sess.container_id or "" if cid.startswith("POOL:") or cid.startswith("POOLIDX:") or cid.startswith("WEBPOOLIDX:"): @@ -1328,9 +1426,15 @@ 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() + launch_error = (request.query_params.get("launch_error") or "").strip().lower() session_notice = "" if session_closed == "idle": session_notice = "Сессия была закрыта из-за простоя. Откройте сервис заново." + elif launch_error == "max_services": + session_notice = ( + f"Есть ограничение на {MAX_ACTIVE_SERVICES_PER_USER} сервиса(ов). " + "Освободите один сервис и попробуйте снова." + ) if not user: csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24) response = templates.TemplateResponse( @@ -1385,6 +1489,7 @@ def index(request: Request, user: Optional[User] = Depends(get_current_user), db svc for svc in services if any(cat["slug"] == selected_category_slug for cat in service_categories.get(svc.id, [])) ] + service_comment_html = {svc.id: format_service_comment(svc.comment) for svc in services} return templates.TemplateResponse( "dashboard.html", @@ -1395,6 +1500,7 @@ def index(request: Request, user: Optional[User] = Depends(get_current_user), db "categories": categories, "selected_category_slug": selected_category_slug, "service_categories": service_categories, + "service_comment_html": service_comment_html, "csrf_token": request.cookies.get(CSRF_COOKIE, ""), "session_notice": session_notice, }, @@ -1467,6 +1573,24 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi """ ) ).mappings().all() + cutoff = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS) + online_sessions = db.execute( + text( + """ + SELECT s.id, u.username, sv.name AS service_name, sv.slug AS service_slug, + sv.type AS service_type, s.container_id, s.created_at, s.last_access_at + FROM sessions s + JOIN users u ON u.id = s.user_id + JOIN services sv ON sv.id = s.service_id + WHERE s.status = 'ACTIVE' + AND s.last_access_at >= :cutoff + AND sv.type IN ('WEB','RDP') + ORDER BY s.last_access_at DESC, s.created_at DESC + LIMIT 500 + """ + ), + {"cutoff": cutoff}, + ).mappings().all() return templates.TemplateResponse( "admin.html", { @@ -1486,7 +1610,9 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi "web_pool_buffer": WEB_POOL_BUFFER, "recent_sessions": recent_sessions, "open_stats": open_stats, + "online_sessions": online_sessions, "csrf_token": request.cookies.get(CSRF_COOKIE, ""), + "max_active_services_per_user": MAX_ACTIVE_SERVICES_PER_USER, }, ) @@ -1505,7 +1631,19 @@ def login( user = db.scalar(select(User).where(User.username == username)) if not user or not verify_password(password, user.password_hash): - raise HTTPException(status_code=401, detail="Invalid credentials") + csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24) + response = templates.TemplateResponse( + "login.html", + { + "request": request, + "csrf_token": csrf, + "login_error": "Неверный логин или пароль", + "session_notice": "", + }, + status_code=401, + ) + response.set_cookie(CSRF_COOKIE, csrf, httponly=False, secure=True, samesite="strict", path="/") + return response if not user_is_valid(user): csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24) response = templates.TemplateResponse( @@ -1544,6 +1682,36 @@ 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") + + existing_user_session = find_active_session_for_user_service(db, user.id, service.id) + if existing_user_session: + return RedirectResponse(url=session_redirect_url(existing_user_session), status_code=303) + + cutoff = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS) + active_rows = db.scalars( + select(SessionModel).where( + SessionModel.user_id == user.id, + SessionModel.status == SessionStatus.ACTIVE, + SessionModel.last_access_at >= cutoff, + ) + ).all() + active_rows = sorted(active_rows, key=lambda row: row.created_at) + active_service_ids = {row.service_id for row in active_rows} + if service.id not in active_service_ids and len(active_service_ids) >= MAX_ACTIVE_SERVICES_PER_USER: + oldest = next((row for row in active_rows if row.service_id != service.id), None) + if oldest: + terminate_session_record(db, oldest, SessionStatus.TERMINATED, stop_container=True) + db.commit() + logger.info( + "session_rotated user_id=%s closed_session=%s old_service_id=%s new_service_id=%s", + user.id, + oldest.id, + oldest.service_id, + service.id, + ) + else: + return RedirectResponse(url="/?launch_error=max_services", status_code=303) + if service.type == ServiceType.RDP: active_owner = find_active_session_for_service(db, service.id) if active_owner: @@ -1559,47 +1727,53 @@ def go_service(slug: str, user: User = Depends(require_user), db: Session = Depe session_id = str(uuid.uuid4()) if service.type == ServiceType.WEB and WEB_POOL_SIZE > 0: try: - ensure_web_pool() - slot = acquire_web_pool_slot(db) - dispatch_web_pool_target(slot, service) + with allocator_lock(db, 91001): + ensure_web_pool() + slot = acquire_web_pool_slot(db) + slot_cid = f"WEBPOOLIDX:{slot}" + terminate_active_slot_sessions(db, slot_cid) + dispatch_web_pool_target(slot, service) + session_obj = SessionModel( + id=session_id, + user_id=user.id, + service_id=service.id, + container_id=slot_cid, + status=SessionStatus.ACTIVE, + created_at=now_utc(), + last_access_at=now_utc(), + ) + db.add(session_obj) + db.commit() except Exception as exc: logger.exception("web_pool_dispatch_failed slug=%s user_id=%s", slug, user.id) audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id) raise HTTPException(status_code=502, detail="WEB runtime failed to switch target") - session_obj = SessionModel( - id=session_id, - user_id=user.id, - service_id=service.id, - container_id=f"WEBPOOLIDX:{slot}", - status=SessionStatus.ACTIVE, - created_at=now_utc(), - last_access_at=now_utc(), - ) - 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"/s/{session_id}/", status_code=303) if service_uses_universal_pool(service): try: - ensure_universal_pool() - slot = acquire_universal_slot(db) - dispatch_universal_target(slot, service) + with allocator_lock(db, 91002): + ensure_universal_pool() + slot = acquire_universal_slot(db) + slot_cid = f"POOLIDX:{slot}" + terminate_active_slot_sessions(db, slot_cid) + dispatch_universal_target(slot, service) + session_obj = SessionModel( + id=session_id, + user_id=user.id, + service_id=service.id, + container_id=slot_cid, + status=SessionStatus.ACTIVE, + created_at=now_utc(), + last_access_at=now_utc(), + ) + db.add(session_obj) + db.commit() except Exception as exc: logger.exception("universal_pool_dispatch_failed slug=%s user_id=%s", slug, user.id) audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id) raise HTTPException(status_code=502, detail="Universal runtime failed to switch target") - session_obj = SessionModel( - id=session_id, - user_id=user.id, - service_id=service.id, - container_id=f"POOLIDX:{slot}", - status=SessionStatus.ACTIVE, - created_at=now_utc(), - last_access_at=now_utc(), - ) - 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"/s/{session_id}/", status_code=303) @@ -1817,6 +1991,18 @@ def touch_session(session_id: str, user: User = Depends(require_user), db: Sessi return {"ok": True} +@app.post("/api/sessions/{session_id}/close") +def close_session(session_id: str, user: User = Depends(require_user), db: Session = Depends(get_db)): + sess = db.get(SessionModel, session_id) + if not sess or sess.user_id != user.id: + raise HTTPException(status_code=404, detail="Session not found") + if sess.status != SessionStatus.ACTIVE: + return {"ok": True, "status": sess.status.value} + terminate_session_record(db, sess, SessionStatus.TERMINATED, stop_container=True) + db.commit() + return {"ok": True, "status": "TERMINATED"} + + @app.get("/api/services/{slug}/status") def service_status(slug: str, user: User = Depends(require_user), db: Session = Depends(get_db)): service = db.scalar(select(Service).where(Service.slug == slug, Service.active == True)) diff --git a/app/static/service-icons/svc_2_20260421_113825.png b/app/static/service-icons/svc_2_20260421_113825.png deleted file mode 100644 index 5797d97..0000000 Binary files a/app/static/service-icons/svc_2_20260421_113825.png and /dev/null differ diff --git a/app/static/service-icons/svc_2_20260421_142111.png b/app/static/service-icons/svc_2_20260421_142111.png new file mode 100644 index 0000000..fe10e32 Binary files /dev/null and b/app/static/service-icons/svc_2_20260421_142111.png differ diff --git a/app/static/service-icons/svc_3_20260421_113838.png b/app/static/service-icons/svc_3_20260421_113838.png deleted file mode 100644 index fcd6ab1..0000000 Binary files a/app/static/service-icons/svc_3_20260421_113838.png and /dev/null differ diff --git a/app/static/service-icons/svc_3_20260421_142411.png b/app/static/service-icons/svc_3_20260421_142411.png new file mode 100644 index 0000000..1365533 Binary files /dev/null and b/app/static/service-icons/svc_3_20260421_142411.png differ diff --git a/app/static/service-icons/svc_4_20260421_143520.png b/app/static/service-icons/svc_4_20260421_143520.png new file mode 100644 index 0000000..47d2636 Binary files /dev/null and b/app/static/service-icons/svc_4_20260421_143520.png differ diff --git a/app/static/style.css b/app/static/style.css index 87db4ce..77d566f 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -322,7 +322,8 @@ button { color: #fff; } .tile { - display: block; + display: flex; + flex-direction: column; text-decoration: none; background: var(--card); color: inherit; @@ -368,6 +369,16 @@ button { margin-top: 0.45rem; color: #4b6178; } +.tile-comment { + max-height: 96px; + overflow: auto; + line-height: 1.35; + padding-right: 0.2rem; +} +.tile-comment b, +.tile-comment strong { + font-weight: 700; +} .service-categories { margin-top: 0.7rem; display: flex; diff --git a/app/templates/admin.html b/app/templates/admin.html index efc7124..5f111fc 100644 --- a/app/templates/admin.html +++ b/app/templates/admin.html @@ -411,7 +411,7 @@ diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 6df3d5e..be7f9ff 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -48,7 +48,7 @@

{{ service.name }}

Открыть сервис

{% if service.comment %} - {{ service.comment }} + {{ service_comment_html.get(service.id, '') }} {% endif %} {% if svc_cats %}
diff --git a/docker-compose.yml b/docker-compose.yml index 0b3f0f9..7b5babd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,7 +30,7 @@ services: api: build: context: ./app - command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"] + command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "6"] environment: DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} SIGNING_KEY: ${SIGNING_KEY} @@ -40,6 +40,7 @@ services: SESSION_IDLE_SECONDS: ${SESSION_IDLE_SECONDS:-300} PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2} UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0} + MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4} LOG_LEVEL: ${LOG_LEVEL:-INFO} depends_on: - db diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index c9485f9..9da177e 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -258,6 +258,45 @@ git push https://ruslan%40ipcom.su:utOgbZ09ruslan@git.ruslan.xyz/ruslan/Stend_mo - `404` если сессия не найдена/не принадлежит пользователю; - `410` если сессия найдена, но уже не `ACTIVE`. +## 16) Обновления (2026-04-21, ночь) + +1. Ограничение активных сервисов пользователя: +- Лимит оставлен `MAX_ACTIVE_SERVICES_PER_USER=4`. +- Поведение изменено на FIFO-ротацию: + - при открытии 5-го сервиса автоматически закрывается самый старый активный; + - при открытии 6-го — следующий по старшинству и т.д. +- Жесткий редирект с ошибкой теперь используется только как аварийный fallback. + +2. Время простоя: +- Для обычного простоя подтверждено `SESSION_IDLE_SECONDS=300` (5 минут). +- Значения синхронизированы в `.env`, `docker-compose.yml`, `app/main.py`. + +3. Runtime-навигация в сервисах: +- Кнопки оставлены символьные: + - `←` (назад) + - `⌂` (главная) +- Позиция обновлена: слева вверху, но чуть ниже прежнего: + - `kiosk`: `top:34px` + - `universal-runtime`: `top:64px` (ниже статусного блока) + +4. UI карточек на главной: +- В описании карточки добавлена прокрутка (`max-height` + `overflow:auto`), если текст не влезает. +- Поддержаны переносы строк. +- Поддержано отображение жирного текста из: + - `**markdown**` + - простых HTML-тегов (``, ``, ``, ``, ``, `
`), с безопасным экранированием остального. + +5. Авторизация: +- При неверном логине/пароле теперь отображается явное сообщение на странице входа: + `Неверный логин или пароль` + (вместо немого 401 без человекочитаемого текста). + +6. Производительность API: +- Увеличено число воркеров Uvicorn: + - было: `--workers 4` + - стало: `--workers 6` +- Изменение внесено в `docker-compose.yml`. + 4. WEB pool (устойчивость при пике): - Добавлен recovery на конфликты Docker имен/удаления (`already in use`, `marked for removal`). - Для `ensure_web_pool` добавлены повторные попытки и принудительное удаление конфликтного контейнера перед повтором. diff --git a/kiosk/entrypoint.sh b/kiosk/entrypoint.sh index 37e7131..93ac39f 100755 --- a/kiosk/entrypoint.sh +++ b/kiosk/entrypoint.sh @@ -29,7 +29,7 @@ cat > /opt/portal/index.html < html,body,#screen{margin:0;height:100%;background:#111} .nav-panel{ - position:fixed;left:16px;top:16px;z-index:99;display:flex;gap:10px; + position:fixed;left:16px;top:34px;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 } @@ -44,8 +44,8 @@ cat > /opt/portal/index.html <
diff --git a/universal-runtime/entrypoint.sh b/universal-runtime/entrypoint.sh old mode 100644 new mode 100755 index 9b91913..dff49c8 --- a/universal-runtime/entrypoint.sh +++ b/universal-runtime/entrypoint.sh @@ -39,7 +39,7 @@ cat > /opt/portal/index.html <<'HTML' } .status.hidden{display:none} .nav-panel{ - position:fixed;left:16px;top:16px;z-index:99;display:flex;gap:10px; + 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 } @@ -55,8 +55,8 @@ cat > /opt/portal/index.html <<'HTML'
Подключение к слоту...