UI/runtime polish, session rotation limit, login errors, docs update
This commit is contained in:
+216
-30
@@ -1,6 +1,7 @@
|
|||||||
import datetime as dt
|
import datetime as dt
|
||||||
import enum
|
import enum
|
||||||
import fcntl
|
import fcntl
|
||||||
|
import re
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -18,6 +19,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from itsdangerous import BadSignature, URLSafeTimedSerializer
|
from itsdangerous import BadSignature, URLSafeTimedSerializer
|
||||||
|
from markupsafe import Markup, escape
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Boolean,
|
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"))
|
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", "5"))
|
||||||
WEB_POOL_BUFFER = int(os.getenv("WEB_POOL_BUFFER", "2"))
|
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_MAX_BYTES = 2 * 1024 * 1024
|
||||||
ICON_UPLOAD_TYPES = {
|
ICON_UPLOAD_TYPES = {
|
||||||
"image/png": "png",
|
"image/png": "png",
|
||||||
@@ -202,6 +205,35 @@ def normalize_web_target(url: str) -> str:
|
|||||||
return f"http://{raw}"
|
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"<strong>\1</strong>", escaped, flags=re.DOTALL)
|
||||||
|
# Allow a small safe subset of pasted HTML tags.
|
||||||
|
replacements = {
|
||||||
|
"<b>": "<b>",
|
||||||
|
"</b>": "</b>",
|
||||||
|
"<strong>": "<strong>",
|
||||||
|
"</strong>": "</strong>",
|
||||||
|
"<i>": "<i>",
|
||||||
|
"</i>": "</i>",
|
||||||
|
"<em>": "<em>",
|
||||||
|
"</em>": "</em>",
|
||||||
|
"<u>": "<u>",
|
||||||
|
"</u>": "</u>",
|
||||||
|
"<br>": "<br>",
|
||||||
|
"<br/>": "<br>",
|
||||||
|
"<br />": "<br>",
|
||||||
|
}
|
||||||
|
for src, dst in replacements.items():
|
||||||
|
escaped = escaped.replace(src, dst)
|
||||||
|
escaped = escaped.replace("\n", "<br>")
|
||||||
|
return Markup(escaped)
|
||||||
|
|
||||||
|
|
||||||
def parse_rdp_target(target: str) -> dict:
|
def parse_rdp_target(target: str) -> dict:
|
||||||
raw = (target or "").strip()
|
raw = (target or "").strip()
|
||||||
if not raw:
|
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)
|
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:
|
def ensure_schema_compatibility() -> None:
|
||||||
# PostgreSQL requires enum value addition to be committed before usage in constraints.
|
# PostgreSQL requires enum value addition to be committed before usage in constraints.
|
||||||
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
|
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.status == SessionStatus.ACTIVE,
|
||||||
SessionModel.last_access_at >= cutoff,
|
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]:
|
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()
|
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:
|
def session_redirect_url(sess: SessionModel) -> str:
|
||||||
cid = sess.container_id or ""
|
cid = sess.container_id or ""
|
||||||
if cid.startswith("POOL:") or cid.startswith("POOLIDX:") or cid.startswith("WEBPOOLIDX:"):
|
if cid.startswith("POOL:") or cid.startswith("POOLIDX:") or cid.startswith("WEBPOOLIDX:"):
|
||||||
@@ -1328,9 +1426,15 @@ def startup_event():
|
|||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def index(request: Request, user: Optional[User] = Depends(get_current_user), db: Session = Depends(get_db)):
|
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_closed = (request.query_params.get("session_closed") or "").strip().lower()
|
||||||
|
launch_error = (request.query_params.get("launch_error") or "").strip().lower()
|
||||||
session_notice = ""
|
session_notice = ""
|
||||||
if session_closed == "idle":
|
if session_closed == "idle":
|
||||||
session_notice = "Сессия была закрыта из-за простоя. Откройте сервис заново."
|
session_notice = "Сессия была закрыта из-за простоя. Откройте сервис заново."
|
||||||
|
elif launch_error == "max_services":
|
||||||
|
session_notice = (
|
||||||
|
f"Есть ограничение на {MAX_ACTIVE_SERVICES_PER_USER} сервиса(ов). "
|
||||||
|
"Освободите один сервис и попробуйте снова."
|
||||||
|
)
|
||||||
if not user:
|
if not user:
|
||||||
csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24)
|
csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24)
|
||||||
response = templates.TemplateResponse(
|
response = templates.TemplateResponse(
|
||||||
@@ -1385,6 +1489,7 @@ def index(request: Request, user: Optional[User] = Depends(get_current_user), db
|
|||||||
svc for svc in services
|
svc for svc in services
|
||||||
if any(cat["slug"] == selected_category_slug for cat in service_categories.get(svc.id, []))
|
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(
|
return templates.TemplateResponse(
|
||||||
"dashboard.html",
|
"dashboard.html",
|
||||||
@@ -1395,6 +1500,7 @@ def index(request: Request, user: Optional[User] = Depends(get_current_user), db
|
|||||||
"categories": categories,
|
"categories": categories,
|
||||||
"selected_category_slug": selected_category_slug,
|
"selected_category_slug": selected_category_slug,
|
||||||
"service_categories": service_categories,
|
"service_categories": service_categories,
|
||||||
|
"service_comment_html": service_comment_html,
|
||||||
"csrf_token": request.cookies.get(CSRF_COOKIE, ""),
|
"csrf_token": request.cookies.get(CSRF_COOKIE, ""),
|
||||||
"session_notice": session_notice,
|
"session_notice": session_notice,
|
||||||
},
|
},
|
||||||
@@ -1467,6 +1573,24 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
).mappings().all()
|
).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(
|
return templates.TemplateResponse(
|
||||||
"admin.html",
|
"admin.html",
|
||||||
{
|
{
|
||||||
@@ -1486,7 +1610,9 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi
|
|||||||
"web_pool_buffer": WEB_POOL_BUFFER,
|
"web_pool_buffer": WEB_POOL_BUFFER,
|
||||||
"recent_sessions": recent_sessions,
|
"recent_sessions": recent_sessions,
|
||||||
"open_stats": open_stats,
|
"open_stats": open_stats,
|
||||||
|
"online_sessions": online_sessions,
|
||||||
"csrf_token": request.cookies.get(CSRF_COOKIE, ""),
|
"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))
|
user = db.scalar(select(User).where(User.username == username))
|
||||||
if not user or not verify_password(password, user.password_hash):
|
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):
|
if not user_is_valid(user):
|
||||||
csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24)
|
csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24)
|
||||||
response = templates.TemplateResponse(
|
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")
|
raise HTTPException(status_code=410, detail="VNC services are deprecated")
|
||||||
if not has_access(db, user.id, service.id):
|
if not has_access(db, user.id, service.id):
|
||||||
raise HTTPException(status_code=403, detail="ACL denied")
|
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:
|
if service.type == ServiceType.RDP:
|
||||||
active_owner = find_active_session_for_service(db, service.id)
|
active_owner = find_active_session_for_service(db, service.id)
|
||||||
if active_owner:
|
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())
|
session_id = str(uuid.uuid4())
|
||||||
if service.type == ServiceType.WEB and WEB_POOL_SIZE > 0:
|
if service.type == ServiceType.WEB and WEB_POOL_SIZE > 0:
|
||||||
try:
|
try:
|
||||||
ensure_web_pool()
|
with allocator_lock(db, 91001):
|
||||||
slot = acquire_web_pool_slot(db)
|
ensure_web_pool()
|
||||||
dispatch_web_pool_target(slot, service)
|
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:
|
except Exception as exc:
|
||||||
logger.exception("web_pool_dispatch_failed slug=%s user_id=%s", slug, user.id)
|
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)
|
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")
|
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)
|
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)
|
return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
|
||||||
|
|
||||||
if service_uses_universal_pool(service):
|
if service_uses_universal_pool(service):
|
||||||
try:
|
try:
|
||||||
ensure_universal_pool()
|
with allocator_lock(db, 91002):
|
||||||
slot = acquire_universal_slot(db)
|
ensure_universal_pool()
|
||||||
dispatch_universal_target(slot, service)
|
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:
|
except Exception as exc:
|
||||||
logger.exception("universal_pool_dispatch_failed slug=%s user_id=%s", slug, user.id)
|
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)
|
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")
|
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)
|
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)
|
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}
|
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")
|
@app.get("/api/services/{slug}/status")
|
||||||
def service_status(slug: str, user: User = Depends(require_user), db: Session = Depends(get_db)):
|
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))
|
service = db.scalar(select(Service).where(Service.slug == slug, Service.active == True))
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 111 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
+12
-1
@@ -322,7 +322,8 @@ button {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
.tile {
|
.tile {
|
||||||
display: block;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
@@ -368,6 +369,16 @@ button {
|
|||||||
margin-top: 0.45rem;
|
margin-top: 0.45rem;
|
||||||
color: #4b6178;
|
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 {
|
.service-categories {
|
||||||
margin-top: 0.7rem;
|
margin-top: 0.7rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -411,7 +411,7 @@
|
|||||||
<section id="tab-stats" class="panel admin-tab" style="display:none;">
|
<section id="tab-stats" class="panel admin-tab" style="display:none;">
|
||||||
<h3>Статистика открытий</h3>
|
<h3>Статистика открытий</h3>
|
||||||
<div class="admin-intro">
|
<div class="admin-intro">
|
||||||
Здесь видно кто, когда и какой сервис открывал, а также агрегат по количеству запусков.
|
Здесь видно кто, когда и какой сервис открывал, а также кто сейчас онлайн.
|
||||||
</div>
|
</div>
|
||||||
<div class="split">
|
<div class="split">
|
||||||
<div>
|
<div>
|
||||||
@@ -473,6 +473,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="margin-top:0.8rem;">
|
||||||
|
<div class="list-title">Сейчас онлайн (активные сессии)</div>
|
||||||
|
<div class="container-table-wrap" style="max-height:420px;">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>user</th>
|
||||||
|
<th>service</th>
|
||||||
|
<th>type</th>
|
||||||
|
<th>container</th>
|
||||||
|
<th>last_access</th>
|
||||||
|
<th>created</th>
|
||||||
|
<th>session_id</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in online_sessions %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ row.username }}</td>
|
||||||
|
<td>{{ row.service_name }} ({{ row.service_slug }})</td>
|
||||||
|
<td>{{ row.service_type }}</td>
|
||||||
|
<td>{{ row.container_id }}</td>
|
||||||
|
<td>{{ row.last_access_at }}</td>
|
||||||
|
<td>{{ row.created_at }}</td>
|
||||||
|
<td>{{ row.id }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="7">Сейчас нет активных сессий</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
<h3>{{ service.name }}</h3>
|
<h3>{{ service.name }}</h3>
|
||||||
<p>Открыть сервис</p>
|
<p>Открыть сервис</p>
|
||||||
{% if service.comment %}
|
{% if service.comment %}
|
||||||
<small>{{ service.comment }}</small>
|
<small class="tile-comment">{{ service_comment_html.get(service.id, '') }}</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if svc_cats %}
|
{% if svc_cats %}
|
||||||
<div class="service-categories">
|
<div class="service-categories">
|
||||||
|
|||||||
+2
-1
@@ -30,7 +30,7 @@ services:
|
|||||||
api:
|
api:
|
||||||
build:
|
build:
|
||||||
context: ./app
|
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:
|
environment:
|
||||||
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
SIGNING_KEY: ${SIGNING_KEY}
|
SIGNING_KEY: ${SIGNING_KEY}
|
||||||
@@ -40,6 +40,7 @@ services:
|
|||||||
SESSION_IDLE_SECONDS: ${SESSION_IDLE_SECONDS:-300}
|
SESSION_IDLE_SECONDS: ${SESSION_IDLE_SECONDS:-300}
|
||||||
PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2}
|
PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2}
|
||||||
UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0}
|
UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0}
|
||||||
|
MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4}
|
||||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
|
|||||||
@@ -258,6 +258,45 @@ git push https://ruslan%40ipcom.su:utOgbZ09ruslan@git.ruslan.xyz/ruslan/Stend_mo
|
|||||||
- `404` если сессия не найдена/не принадлежит пользователю;
|
- `404` если сессия не найдена/не принадлежит пользователю;
|
||||||
- `410` если сессия найдена, но уже не `ACTIVE`.
|
- `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-тегов (`<b>`, `<strong>`, `<i>`, `<em>`, `<u>`, `<br>`), с безопасным экранированием остального.
|
||||||
|
|
||||||
|
5. Авторизация:
|
||||||
|
- При неверном логине/пароле теперь отображается явное сообщение на странице входа:
|
||||||
|
`Неверный логин или пароль`
|
||||||
|
(вместо немого 401 без человекочитаемого текста).
|
||||||
|
|
||||||
|
6. Производительность API:
|
||||||
|
- Увеличено число воркеров Uvicorn:
|
||||||
|
- было: `--workers 4`
|
||||||
|
- стало: `--workers 6`
|
||||||
|
- Изменение внесено в `docker-compose.yml`.
|
||||||
|
|
||||||
4. WEB pool (устойчивость при пике):
|
4. WEB pool (устойчивость при пике):
|
||||||
- Добавлен recovery на конфликты Docker имен/удаления (`already in use`, `marked for removal`).
|
- Добавлен recovery на конфликты Docker имен/удаления (`already in use`, `marked for removal`).
|
||||||
- Для `ensure_web_pool` добавлены повторные попытки и принудительное удаление конфликтного контейнера перед повтором.
|
- Для `ensure_web_pool` добавлены повторные попытки и принудительное удаление конфликтного контейнера перед повтором.
|
||||||
|
|||||||
+15
-4
@@ -29,7 +29,7 @@ cat > /opt/portal/index.html <<HTML
|
|||||||
<style>
|
<style>
|
||||||
html,body,#screen{margin:0;height:100%;background:#111}
|
html,body,#screen{margin:0;height:100%;background:#111}
|
||||||
.nav-panel{
|
.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);
|
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
|
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 <<HTML
|
|||||||
<body>
|
<body>
|
||||||
<div id="screen"></div>
|
<div id="screen"></div>
|
||||||
<div class="nav-panel">
|
<div class="nav-panel">
|
||||||
<button class="nav-btn" id="btn-back" type="button">Назад</button>
|
<button class="nav-btn" id="btn-back" type="button" title="Назад">←</button>
|
||||||
<button class="nav-btn" id="btn-home" type="button">Главная</button>
|
<button class="nav-btn" id="btn-home" type="button" title="Главная">⌂</button>
|
||||||
</div>
|
</div>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import RFB from './core/rfb.js';
|
import RFB from './core/rfb.js';
|
||||||
@@ -60,6 +60,7 @@ cat > /opt/portal/index.html <<HTML
|
|||||||
rfb.resizeSession = true;
|
rfb.resizeSession = true;
|
||||||
const enableHeartbeat = "${ENABLE_HEARTBEAT}" === "1";
|
const enableHeartbeat = "${ENABLE_HEARTBEAT}" === "1";
|
||||||
const SESSION_CLOSED_URL = '/?session_closed=idle';
|
const SESSION_CLOSED_URL = '/?session_closed=idle';
|
||||||
|
const CLOSE_PATH = '${TOUCH_PATH}'.replace(/\/touch$/, '/close');
|
||||||
function goSessionClosed() {
|
function goSessionClosed() {
|
||||||
try {
|
try {
|
||||||
if (window.top && window.top !== window) {
|
if (window.top && window.top !== window) {
|
||||||
@@ -77,9 +78,19 @@ cat > /opt/portal/index.html <<HTML
|
|||||||
}
|
}
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
}
|
}
|
||||||
|
let closing = false;
|
||||||
|
async function closeSessionNow() {
|
||||||
|
if (closing) return;
|
||||||
|
closing = true;
|
||||||
|
try {
|
||||||
|
await fetch(CLOSE_PATH, {method: 'POST', credentials: 'include', keepalive: true});
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
if (enableHeartbeat) {
|
if (enableHeartbeat) {
|
||||||
setInterval(touch, 60000);
|
setInterval(touch, 15000);
|
||||||
touch();
|
touch();
|
||||||
|
window.addEventListener('pagehide', closeSessionNow);
|
||||||
|
window.addEventListener('beforeunload', closeSessionNow);
|
||||||
}
|
}
|
||||||
function keyTap(keysym, code) {
|
function keyTap(keysym, code) {
|
||||||
rfb.sendKey(keysym, code, true);
|
rfb.sendKey(keysym, code, true);
|
||||||
|
|||||||
+12
-1
@@ -38,6 +38,7 @@ cat > /opt/portal/index.html <<HTML
|
|||||||
rfb.resizeSession = true;
|
rfb.resizeSession = true;
|
||||||
const enableHeartbeat = "${ENABLE_HEARTBEAT}" === "1";
|
const enableHeartbeat = "${ENABLE_HEARTBEAT}" === "1";
|
||||||
const SESSION_CLOSED_URL = '/?session_closed=idle';
|
const SESSION_CLOSED_URL = '/?session_closed=idle';
|
||||||
|
const CLOSE_PATH = '${TOUCH_PATH}'.replace(/\/touch$/, '/close');
|
||||||
function goSessionClosed() {
|
function goSessionClosed() {
|
||||||
try {
|
try {
|
||||||
if (window.top && window.top !== window) {
|
if (window.top && window.top !== window) {
|
||||||
@@ -55,9 +56,19 @@ cat > /opt/portal/index.html <<HTML
|
|||||||
}
|
}
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
}
|
}
|
||||||
|
let closing = false;
|
||||||
|
async function closeSessionNow() {
|
||||||
|
if (closing) return;
|
||||||
|
closing = true;
|
||||||
|
try {
|
||||||
|
await fetch(CLOSE_PATH, {method:'POST', credentials:'include', keepalive:true});
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
if (enableHeartbeat) {
|
if (enableHeartbeat) {
|
||||||
setInterval(touch, 60000);
|
setInterval(touch, 15000);
|
||||||
touch();
|
touch();
|
||||||
|
window.addEventListener('pagehide', closeSessionNow);
|
||||||
|
window.addEventListener('beforeunload', closeSessionNow);
|
||||||
}
|
}
|
||||||
document.addEventListener('contextmenu', (e) => e.preventDefault());
|
document.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Regular → Executable
+15
-4
@@ -39,7 +39,7 @@ cat > /opt/portal/index.html <<'HTML'
|
|||||||
}
|
}
|
||||||
.status.hidden{display:none}
|
.status.hidden{display:none}
|
||||||
.nav-panel{
|
.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);
|
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
|
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'
|
|||||||
<div id="screen"></div>
|
<div id="screen"></div>
|
||||||
<div id="status" class="status">Подключение к слоту...</div>
|
<div id="status" class="status">Подключение к слоту...</div>
|
||||||
<div class="nav-panel">
|
<div class="nav-panel">
|
||||||
<button class="nav-btn" id="btn-back" type="button">Назад</button>
|
<button class="nav-btn" id="btn-back" type="button" title="Назад">←</button>
|
||||||
<button class="nav-btn" id="btn-home" type="button">Главная</button>
|
<button class="nav-btn" id="btn-home" type="button" title="Главная">⌂</button>
|
||||||
</div>
|
</div>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import RFB from './core/rfb.js';
|
import RFB from './core/rfb.js';
|
||||||
@@ -102,6 +102,7 @@ cat > /opt/portal/index.html <<'HTML'
|
|||||||
const enableHeartbeat = (new URLSearchParams(location.search).get('hb') ?? '1') !== '0';
|
const enableHeartbeat = (new URLSearchParams(location.search).get('hb') ?? '1') !== '0';
|
||||||
const sid = new URLSearchParams(location.search).get('sid');
|
const sid = new URLSearchParams(location.search).get('sid');
|
||||||
const SESSION_CLOSED_URL = '/?session_closed=idle';
|
const SESSION_CLOSED_URL = '/?session_closed=idle';
|
||||||
|
const CLOSE_PATH = sid ? `/api/sessions/${sid}/close` : '';
|
||||||
function goSessionClosed() {
|
function goSessionClosed() {
|
||||||
try {
|
try {
|
||||||
if (window.top && window.top !== window) {
|
if (window.top && window.top !== window) {
|
||||||
@@ -120,9 +121,19 @@ cat > /opt/portal/index.html <<'HTML'
|
|||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
let closing = false;
|
||||||
|
async function closeSessionNow() {
|
||||||
|
if (!CLOSE_PATH || closing) return;
|
||||||
|
closing = true;
|
||||||
|
try {
|
||||||
|
await fetch(CLOSE_PATH, {method: 'POST', credentials: 'include', keepalive: true});
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
if (enableHeartbeat) {
|
if (enableHeartbeat) {
|
||||||
setInterval(touch, 60000);
|
setInterval(touch, 15000);
|
||||||
touch();
|
touch();
|
||||||
|
window.addEventListener('pagehide', closeSessionNow);
|
||||||
|
window.addEventListener('beforeunload', closeSessionNow);
|
||||||
}
|
}
|
||||||
function keyTap(keysym, code) {
|
function keyTap(keysym, code) {
|
||||||
rfb.sendKey(keysym, code, true);
|
rfb.sendKey(keysym, code, true);
|
||||||
|
|||||||
Reference in New Issue
Block a user