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 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"<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:
|
||||
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))
|
||||
|
||||
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;
|
||||
}
|
||||
.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;
|
||||
|
||||
@@ -411,7 +411,7 @@
|
||||
<section id="tab-stats" class="panel admin-tab" style="display:none;">
|
||||
<h3>Статистика открытий</h3>
|
||||
<div class="admin-intro">
|
||||
Здесь видно кто, когда и какой сервис открывал, а также агрегат по количеству запусков.
|
||||
Здесь видно кто, когда и какой сервис открывал, а также кто сейчас онлайн.
|
||||
</div>
|
||||
<div class="split">
|
||||
<div>
|
||||
@@ -473,6 +473,39 @@
|
||||
</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>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
<h3>{{ service.name }}</h3>
|
||||
<p>Открыть сервис</p>
|
||||
{% if service.comment %}
|
||||
<small>{{ service.comment }}</small>
|
||||
<small class="tile-comment">{{ service_comment_html.get(service.id, '') }}</small>
|
||||
{% endif %}
|
||||
{% if svc_cats %}
|
||||
<div class="service-categories">
|
||||
|
||||
Reference in New Issue
Block a user