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 @@
Открыть сервис
{% if service.comment %} - {{ service.comment }} + {{ service_comment_html.get(service.id, '') }} {% endif %} {% if svc_cats %}