Tune idle timeout, heartbeat redirect, and update project context

This commit is contained in:
2026-04-21 13:31:47 +00:00
parent 9c9b97374c
commit c97cf5308d
11 changed files with 400 additions and 56 deletions
+156 -37
View File
@@ -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"""
<!doctype html>
<html>
<head>
<meta charset='utf-8'>
<title>Session Starting</title>
<title>{service_title}</title>
<style>
body {{ font-family: sans-serif; background: #f4f6f8; display: grid; place-items: center; height: 100vh; margin: 0; color:#1b3145; }}
.card {{ background: #fff; padding: 1rem 1.2rem; border-radius: 10px; box-shadow: 0 8px 20px rgba(0,0,0,.08); min-width: 340px; }}
@@ -1687,20 +1769,35 @@ def session_view_page(session_id: str, request: Request, user: User = Depends(re
service = db.get(Service, sess.service_id)
if not service:
raise HTTPException(status_code=404, detail="Service not found")
iframe_src = None
if sess.container_id and sess.container_id.startswith("POOL:"):
iframe_src = f"/svc/{service.slug}/?sid={session_id}"
elif sess.container_id and sess.container_id.startswith("WEBPOOLIDX:"):
try:
slot = int(sess.container_id.split(":", 1)[1])
iframe_src = f"/w/{slot}/?sid={session_id}"
except Exception:
iframe_src = None
elif sess.container_id and sess.container_id.startswith("POOLIDX:"):
try:
slot = int(sess.container_id.split(":", 1)[1])
iframe_src = f"/u/{slot}/?sid={session_id}"
except Exception:
iframe_src = None
if iframe_src:
return HTMLResponse(
content=f"""
<!doctype html>
<html>
<head>
<meta charset='utf-8'>
<title>Session {session_id}</title>
<title>{service.name}</title>
<style>
html,body,iframe {{ margin:0; width:100%; height:100%; border:0; background:#0f1720; }}
</style>
</head>
<body>
<iframe src="/svc/{service.slug}/?sid={session_id}" allow="clipboard-read; clipboard-write"></iframe>
<iframe src="{iframe_src}" allow="clipboard-read; clipboard-write"></iframe>
</body>
</html>
""".strip()
@@ -1711,8 +1808,10 @@ def session_view_page(session_id: str, request: Request, user: User = Depends(re
@app.post("/api/sessions/{session_id}/touch")
def touch_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 or sess.status != SessionStatus.ACTIVE:
if not sess or sess.user_id != user.id:
raise HTTPException(status_code=404, detail="Session not found")
if sess.status != SessionStatus.ACTIVE:
raise HTTPException(status_code=410, detail="Session expired")
sess.last_access_at = now_utc()
db.commit()
return {"ok": True}
@@ -1752,14 +1851,22 @@ def session_status(session_id: str, user: User = Depends(require_user), db: Sess
service = db.get(Service, sess.service_id)
pooled_web = bool(sess.container_id and sess.container_id.startswith("POOL:") and service and service.type == ServiceType.WEB)
web_pool_idx = None
universal_pool_idx = None
if sess.container_id and sess.container_id.startswith("WEBPOOLIDX:"):
try:
web_pool_idx = int(sess.container_id.split(":", 1)[1])
except Exception:
web_pool_idx = None
if sess.container_id and sess.container_id.startswith("POOLIDX:"):
try:
universal_pool_idx = int(sess.container_id.split(":", 1)[1])
except Exception:
universal_pool_idx = None
route_path = f"/svc/{service.slug}/" if pooled_web and service else f"/s/{session_id}/"
if web_pool_idx is not None:
route_path = f"/w/{web_pool_idx}/"
if universal_pool_idx is not None:
route_path = f"/u/{universal_pool_idx}/"
route_ok = route_ready(route_path)
running = container_running(sess.container_id)
ready = running and route_ok
@@ -1775,7 +1882,9 @@ def session_status(session_id: str, user: User = Depends(require_user), db: Sess
if pooled_web:
payload["redirect_url"] = f"/s/{session_id}/view"
if web_pool_idx is not None:
payload["redirect_url"] = f"/w/{web_pool_idx}/?sid={session_id}"
payload["redirect_url"] = f"/s/{session_id}/view"
if universal_pool_idx is not None:
payload["redirect_url"] = f"/s/{session_id}/view"
return payload
@@ -1803,7 +1912,10 @@ def create_service(payload: dict, request: Request, _: User = Depends(require_ad
db.flush()
set_service_categories(db, service.id, payload.get("category_ids", []))
db.commit()
ensure_warm_pool(service)
if service.type == ServiceType.WEB and WEB_POOL_SIZE <= 0:
ensure_warm_pool(service)
elif service_uses_universal_pool(service):
ensure_universal_pool()
return {"id": service.id}
@@ -1873,8 +1985,12 @@ def edit_service(service_id: int, payload: dict, request: Request, _: User = Dep
if "category_ids" in payload:
set_service_categories(db, service.id, payload.get("category_ids", []))
db.commit()
ensure_warm_pool(service)
open_warm_web_url(service, service.target)
if service.type == ServiceType.WEB:
if WEB_POOL_SIZE <= 0:
ensure_warm_pool(service)
open_warm_web_url(service, service.target)
elif service_uses_universal_pool(service):
ensure_universal_pool()
return {"ok": True}
@@ -1884,7 +2000,8 @@ def delete_service(service_id: int, request: Request, _: User = Depends(require_
service = db.get(Service, service_id)
if not service:
raise HTTPException(status_code=404, detail="Service not found")
ensure_warm_pool(service, 0)
if service.type == ServiceType.WEB and WEB_POOL_SIZE <= 0:
ensure_warm_pool(service, 0)
remove_icon_file(service.icon_path)
db.delete(service)
db.commit()
@@ -1903,6 +2020,8 @@ def prewarm_now(service_id: int, request: Request, _: User = Depends(require_adm
if service_uses_universal_pool(service):
ensure_universal_pool()
return {"ok": True, "pool": get_universal_pool_status()}
if service.type == ServiceType.RDP:
return {"ok": True, "pool": get_pool_status_for_service(service), "message": "RDP запускается on-demand"}
ensure_warm_pool(service)
return {"ok": True, "pool": get_pool_status_for_service(service)}