diff --git a/.env.bak_20260423_084240 b/.env.bak_20260423_084240
deleted file mode 100644
index 28e09b7..0000000
--- a/.env.bak_20260423_084240
+++ /dev/null
@@ -1,15 +0,0 @@
-PUBLIC_HOST=stend.4mont.ru
-LETSENCRYPT_EMAIL=admin@4mont.ru
-
-POSTGRES_DB=portal
-POSTGRES_USER=portal
-POSTGRES_PASSWORD=change_me
-
-SIGNING_KEY=replace_with_long_random_key
-ADMIN_USERNAME=admin
-ADMIN_PASSWORD=StrongAdminPassword!
-SESSION_IDLE_SECONDS=300
-PREWARM_POOL_SIZE=2
-UNIVERSAL_POOL_SIZE=0
-MAX_ACTIVE_SERVICES_PER_USER=4
-LOG_LEVEL=INFO
diff --git a/.env.bak_before_load_idle_20260424_150032 b/.env.bak_before_load_idle_20260424_150032
deleted file mode 100644
index 9ffac2b..0000000
--- a/.env.bak_before_load_idle_20260424_150032
+++ /dev/null
@@ -1,17 +0,0 @@
-PUBLIC_HOST=stend.4mont.ru
-LETSENCRYPT_EMAIL=admin@4mont.ru
-
-POSTGRES_DB=portal
-POSTGRES_USER=portal
-POSTGRES_PASSWORD=change_me
-
-SIGNING_KEY=9a6d4b053a47ae24078e07587e69f344111652f153ba50eff31603e43c91f89b
-ADMIN_USERNAME=admin
-ADMIN_PASSWORD=StrongAdminPassword!
-SESSION_IDLE_SECONDS=300
-PREWARM_POOL_SIZE=2
-UNIVERSAL_POOL_SIZE=0
-MAX_ACTIVE_SERVICES_PER_USER=4
-LOG_LEVEL=INFO
-WEB_POOL_SIZE=20
-WEB_POOL_BUFFER=2
diff --git a/app/main.py.bak_batchfix_20260424_122119 b/app/main.py.bak_batchfix_20260424_122119
deleted file mode 100644
index 92264eb..0000000
--- a/app/main.py.bak_batchfix_20260424_122119
+++ /dev/null
@@ -1,2563 +0,0 @@
-import datetime as dt
-import enum
-import fcntl
-import json
-import re
-import logging
-import os
-from pathlib import Path
-import secrets
-import threading
-import time
-import uuid
-import contextvars
-from urllib.parse import parse_qs, unquote, urlparse
-from typing import Optional
-
-import docker
-import requests
-from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile, status
-from fastapi.responses import HTMLResponse, JSONResponse, 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,
- DateTime,
- Enum,
- ForeignKey,
- Integer,
- String,
- Text,
- UniqueConstraint,
- create_engine,
- select,
- text,
-)
-from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sessionmaker
-
-
-DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+psycopg2://portal:portal@db:5432/portal")
-COOKIE_NAME = "portal_auth"
-CSRF_COOKIE = "csrf_token"
-COOKIE_MAX_AGE = 8 * 60 * 60
-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()
-LOG_SLOW_REQUEST_MS = int(os.getenv("LOG_SLOW_REQUEST_MS", "2000"))
-GO_USER_LOCK_TIMEOUT_SECONDS = float(os.getenv("GO_USER_LOCK_TIMEOUT_SECONDS", "2.0"))
-GO_POOL_LOCK_TIMEOUT_SECONDS = float(os.getenv("GO_POOL_LOCK_TIMEOUT_SECONDS", "5.0"))
-POOL_DISPATCH_RETRIES = int(os.getenv("POOL_DISPATCH_RETRIES", "4"))
-POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS = float(os.getenv("POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS", "2.0"))
-POOL_DISPATCH_SLEEP_SECONDS = float(os.getenv("POOL_DISPATCH_SLEEP_SECONDS", "0.3"))
-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", "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",
- "image/jpeg": "jpg",
- "image/webp": "webp",
-}
-SERVICE_ICONS_DIR = Path("static/service-icons")
-
-logging.basicConfig(
- level=LOG_LEVEL,
- format="%(asctime)s %(levelname)s %(name)s %(message)s",
-)
-logger = logging.getLogger("portal")
-request_id_ctx = contextvars.ContextVar("request_id", default="-")
-
-
-def _normalize_log_value(value):
- if isinstance(value, (str, int, float, bool)) or value is None:
- return value
- if isinstance(value, dt.datetime):
- return value.isoformat()
- return str(value)
-
-
-def log_event(event: str, level: int = logging.INFO, **fields) -> None:
- payload = {"event": event, "req_id": request_id_ctx.get()}
- for key, value in fields.items():
- payload[key] = _normalize_log_value(value)
- logger.log(level, json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
-
-SIGNING_KEY = os.getenv("SIGNING_KEY", secrets.token_urlsafe(32))
-serializer = URLSafeTimedSerializer(SIGNING_KEY, salt="portal-auth")
-pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
-
-engine = create_engine(DATABASE_URL, pool_pre_ping=True)
-SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
-
-templates = Jinja2Templates(directory="templates")
-app = FastAPI(title="МОНТ - инфрастуктурный полигон")
-app.mount("/static", StaticFiles(directory="static"), name="static")
-
-
-@app.middleware("http")
-async def request_logging_middleware(request: Request, call_next):
- req_id = request.headers.get("X-Request-ID", str(uuid.uuid4())[:8])
- token = request_id_ctx.set(req_id)
- started = time.time()
- client_ip = request.client.host if request.client else "-"
- user_agent = request.headers.get("user-agent", "-")
- try:
- response = await call_next(request)
- except Exception:
- log_event(
- "request_failed",
- level=logging.ERROR,
- method=request.method,
- path=request.url.path,
- client_ip=client_ip,
- user_agent=user_agent,
- )
- request_id_ctx.reset(token)
- raise
- duration_ms = int((time.time() - started) * 1000)
- level = logging.INFO
- if response.status_code >= 500:
- level = logging.ERROR
- elif response.status_code >= 400:
- level = logging.WARNING
- log_event(
- "request",
- level=level,
- method=request.method,
- path=request.url.path,
- query=str(request.url.query or ""),
- status=response.status_code,
- duration_ms=duration_ms,
- client_ip=client_ip,
- user_agent=user_agent,
- )
- if duration_ms >= LOG_SLOW_REQUEST_MS:
- log_event(
- "slow_request",
- level=logging.WARNING,
- method=request.method,
- path=request.url.path,
- duration_ms=duration_ms,
- threshold_ms=LOG_SLOW_REQUEST_MS,
- )
- response.headers["X-Request-ID"] = req_id
- request_id_ctx.reset(token)
- return response
-
-
-class Base(DeclarativeBase):
- pass
-
-
-class ServiceType(str, enum.Enum):
- WEB = "WEB"
- VNC = "VNC"
- RDP = "RDP"
-
-
-class SessionStatus(str, enum.Enum):
- ACTIVE = "ACTIVE"
- EXPIRED = "EXPIRED"
- TERMINATED = "TERMINATED"
- ROTATED = "ROTATED"
-
-
-class User(Base):
- __tablename__ = "users"
-
- id: Mapped[int] = mapped_column(Integer, primary_key=True)
- username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
- password_hash: Mapped[str] = mapped_column(String(255))
- expires_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), index=True)
- active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
- is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
- created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
-
-
-class Service(Base):
- __tablename__ = "services"
-
- id: Mapped[int] = mapped_column(Integer, primary_key=True)
- name: Mapped[str] = mapped_column(String(128))
- slug: Mapped[str] = mapped_column(String(64), unique=True, index=True)
- type: Mapped[ServiceType] = mapped_column(Enum(ServiceType), index=True)
- target: Mapped[str] = mapped_column(Text)
- comment: Mapped[str] = mapped_column(Text, default="")
- icon_path: Mapped[str] = mapped_column(Text, default="")
- active: Mapped[bool] = mapped_column(Boolean, default=True)
- warm_pool_size: Mapped[int] = mapped_column(Integer, default=0)
- created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
-
-
-class Category(Base):
- __tablename__ = "categories"
-
- id: Mapped[int] = mapped_column(Integer, primary_key=True)
- name: Mapped[str] = mapped_column(String(128), unique=True, index=True)
- slug: Mapped[str] = mapped_column(String(64), unique=True, index=True)
- created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
-
-
-class ServiceCategory(Base):
- __tablename__ = "service_categories"
- __table_args__ = (UniqueConstraint("service_id", "category_id", name="uq_service_category"),)
-
- id: Mapped[int] = mapped_column(Integer, primary_key=True)
- service_id: Mapped[int] = mapped_column(ForeignKey("services.id", ondelete="CASCADE"), index=True)
- category_id: Mapped[int] = mapped_column(ForeignKey("categories.id", ondelete="CASCADE"), index=True)
- created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
-
-
-class UserServiceAccess(Base):
- __tablename__ = "user_service_access"
- __table_args__ = (UniqueConstraint("user_id", "service_id", name="uq_user_service"),)
-
- id: Mapped[int] = mapped_column(Integer, primary_key=True)
- user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
- service_id: Mapped[int] = mapped_column(ForeignKey("services.id", ondelete="CASCADE"), index=True)
- granted_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
-
-
-class SessionModel(Base):
- __tablename__ = "sessions"
-
- id: Mapped[str] = mapped_column(String(36), primary_key=True)
- user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
- service_id: Mapped[int] = mapped_column(ForeignKey("services.id", ondelete="CASCADE"), index=True)
- status: Mapped[SessionStatus] = mapped_column(Enum(SessionStatus), default=SessionStatus.ACTIVE, index=True)
- created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc), index=True)
- last_access_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc), index=True)
- container_id: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
-
-
-class AuditLog(Base):
- __tablename__ = "audit_logs"
-
- id: Mapped[int] = mapped_column(Integer, primary_key=True)
- user_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
- action: Mapped[str] = mapped_column(String(128), index=True)
- details: Mapped[str] = mapped_column(Text)
- created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc), index=True)
-
-
-def now_utc() -> dt.datetime:
- return dt.datetime.now(dt.timezone.utc)
-
-
-def session_closed_reason(sess: SessionModel, db: Session) -> str:
- if not sess:
- return "idle"
- if sess.status == SessionStatus.EXPIRED:
- return "idle"
- if sess.status == SessionStatus.ROTATED:
- return "limit"
- if sess.status == SessionStatus.TERMINATED:
- cutoff = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS)
- active_rows = db.scalars(
- select(SessionModel).where(
- SessionModel.user_id == sess.user_id,
- SessionModel.status == SessionStatus.ACTIVE,
- SessionModel.last_access_at >= cutoff,
- )
- ).all()
- active_service_ids = {row.service_id for row in active_rows}
- if len(active_service_ids) >= MAX_ACTIVE_SERVICES_PER_USER and sess.service_id not in active_service_ids:
- return "limit"
- return "idle"
-
-
-def normalize_web_target(url: str) -> str:
- raw = (url or "").strip()
- if not raw:
- return raw
- if raw.startswith(("http://", "https://")):
- return 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"\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:
- raise HTTPException(status_code=400, detail="Empty RDP target")
-
- parsed = urlparse(raw if "://" in raw else f"//{raw}")
- host = parsed.hostname
- if not host:
- raise HTTPException(status_code=400, detail="Invalid RDP target. Use host:port or rdp://user:pass@host:port")
- port = parsed.port or 3389
-
- username = unquote(parsed.username) if parsed.username else ""
- password = unquote(parsed.password) if parsed.password else ""
-
- query = parse_qs(parsed.query or "")
- if not username:
- username = (query.get("u", [""])[0] or query.get("user", [""])[0] or "").strip()
- if not password:
- password = (query.get("p", [""])[0] or query.get("password", [""])[0] or "").strip()
-
- domain = (query.get("domain", [""])[0] or query.get("d", [""])[0] or "").strip()
- security = (query.get("sec", [""])[0] or query.get("security", [""])[0] or "").strip().lower()
- if security and security not in {"nla", "tls", "rdp"}:
- raise HTTPException(status_code=400, detail="Invalid RDP security. Use one of: nla, tls, rdp")
-
- return {
- "host": host,
- "port": str(port),
- "user": username,
- "password": password,
- "domain": domain,
- "security": security,
- }
-
-
-def set_service_categories(db: Session, service_id: int, category_ids: list[int]) -> None:
- normalized = sorted({int(x) for x in (category_ids or [])})
- if normalized:
- existing_ids = set(db.scalars(select(Category.id).where(Category.id.in_(normalized))).all())
- missing = sorted(set(normalized) - existing_ids)
- if missing:
- raise HTTPException(status_code=400, detail=f"Unknown category ids: {missing}")
-
- existing_links = db.scalars(select(ServiceCategory).where(ServiceCategory.service_id == service_id)).all()
- current = {row.category_id: row for row in existing_links}
- wanted = set(normalized)
-
- for cat_id in wanted:
- if cat_id not in current:
- db.add(ServiceCategory(service_id=service_id, category_id=cat_id))
- for cat_id, row in current.items():
- if cat_id not in wanted:
- db.delete(row)
-
-
-def service_uses_universal_pool(service: Service) -> bool:
- return UNIVERSAL_POOL_SIZE > 0 and service.type == ServiceType.RDP
-
-
-def universal_container_name(slot: int) -> str:
- return f"portal-universal-{slot}"
-
-
-def web_pool_container_name(slot: int) -> str:
- return f"portal-webpool-{slot}"
-
-
-def ensure_icons_dir() -> None:
- SERVICE_ICONS_DIR.mkdir(parents=True, exist_ok=True)
-
-
-def remove_icon_file(icon_path: str) -> None:
- if not icon_path or not icon_path.startswith("/static/service-icons/"):
- return
- filename = icon_path.rsplit("/", 1)[-1]
- candidate = SERVICE_ICONS_DIR / filename
- try:
- candidate.unlink(missing_ok=True)
- except Exception:
- logger.exception("icon_delete_failed path=%s", candidate)
-
-
-async def store_service_icon(service: Service, upload: UploadFile) -> str:
- ensure_icons_dir()
- content_type = (upload.content_type or "").lower().strip()
- ext = ICON_UPLOAD_TYPES.get(content_type)
- if not ext:
- raise HTTPException(status_code=400, detail="Unsupported file type. Use PNG/JPG/WEBP")
-
- payload = await upload.read(ICON_UPLOAD_MAX_BYTES + 1)
- if len(payload) > ICON_UPLOAD_MAX_BYTES:
- raise HTTPException(status_code=400, detail="File too large. Max 2MB")
- if not payload:
- raise HTTPException(status_code=400, detail="Empty file")
-
- stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%d_%H%M%S")
- filename = f"svc_{service.id}_{stamp}.{ext}"
- target = SERVICE_ICONS_DIR / filename
- target.write_bytes(payload)
- return f"/static/service-icons/{filename}"
-
-
-def get_db():
- db = SessionLocal()
- try:
- yield db
- finally:
- db.close()
-
-
-def audit(db: Session, action: str, details: str, user_id: Optional[int] = None) -> None:
- db.add(AuditLog(user_id=user_id, action=action, details=details))
- db.commit()
-
-
-def hash_password(password: str) -> str:
- return pwd_context.hash(password)
-
-
-def verify_password(password: str, password_hash: str) -> bool:
- return pwd_context.verify(password, password_hash)
-
-
-def user_is_valid(user: User) -> bool:
- return bool(user.active and user.expires_at > now_utc())
-
-
-def issue_auth_cookie(response: RedirectResponse, user: User) -> None:
- token = serializer.dumps({"user_id": user.id})
- response.set_cookie(
- key=COOKIE_NAME,
- value=token,
- httponly=True,
- secure=True,
- samesite="strict",
- max_age=COOKIE_MAX_AGE,
- path="/",
- )
-
-
-def issue_csrf_cookie(response: RedirectResponse) -> str:
- token = secrets.token_urlsafe(24)
- response.set_cookie(
- key=CSRF_COOKIE,
- value=token,
- httponly=False,
- secure=True,
- samesite="strict",
- max_age=COOKIE_MAX_AGE,
- path="/",
- )
- return token
-
-
-def get_current_user(request: Request, db: Session = Depends(get_db)) -> Optional[User]:
- raw = request.cookies.get(COOKIE_NAME)
- if not raw:
- return None
- try:
- payload = serializer.loads(raw, max_age=COOKIE_MAX_AGE)
- except BadSignature:
- return None
- user = db.get(User, int(payload["user_id"]))
- if not user or not user_is_valid(user):
- return None
- return user
-
-
-def require_user(user: Optional[User] = Depends(get_current_user)) -> User:
- if not user:
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized")
- return user
-
-
-def require_admin(user: User = Depends(require_user)) -> User:
- if not user.is_admin:
- raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin only")
- return user
-
-
-def validate_csrf(request: Request) -> None:
- cookie = request.cookies.get(CSRF_COOKIE)
- form_val = request.headers.get("X-CSRF-Token")
- if request.headers.get("content-type", "").startswith("application/x-www-form-urlencoded"):
- return
- if not cookie or not form_val or cookie != form_val:
- raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="CSRF failed")
-
-
-def has_access(db: Session, user_id: int, service_id: int) -> bool:
- q = select(UserServiceAccess).where(
- UserServiceAccess.user_id == user_id,
- UserServiceAccess.service_id == service_id,
- )
- return db.scalar(q) is not None
-
-
-def docker_client():
- return docker.from_env()
-
-
-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
- d = docker_client()
- image = "portal-universal-runtime:latest"
-
- for i in range(UNIVERSAL_POOL_SIZE, 100):
- name = universal_container_name(i)
- try:
- c = d.containers.get(name)
- c.stop(timeout=5)
- except docker.errors.NotFound:
- break
- except Exception:
- logger.exception("universal_pool_scale_down_failed slot=%s", i)
-
- for i in range(UNIVERSAL_POOL_SIZE):
- name = universal_container_name(i)
- path = f"/u/{i}/"
- router = f"upool-{i}"
- labels = {
- "traefik.enable": "true",
- "traefik.docker.network": "portal_net",
- f"traefik.http.routers.{router}.rule": f"PathPrefix(`{path}`)",
- f"traefik.http.routers.{router}.entrypoints": "websecure",
- f"traefik.http.routers.{router}.tls": "true",
- f"traefik.http.routers.{router}.priority": "9400",
- f"traefik.http.routers.{router}.middlewares": f"{router}-strip",
- f"traefik.http.middlewares.{router}-strip.stripprefix.prefixes": path[:-1],
- f"traefik.http.services.{router}.loadbalancer.server.port": "6080",
- "portal.pool": "1",
- "portal.pool.slot": str(i),
- }
- env = {
- "IDLE_TIMEOUT": str(SESSION_IDLE_SECONDS),
- "ENABLE_HEARTBEAT": "0",
- "SESSION_ID": f"universal-{i}",
- }
- try:
- c = d.containers.get(name)
- if c.status != "running":
- c.start()
- continue
- except docker.errors.NotFound:
- pass
- except Exception:
- logger.exception("universal_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("universal_pool_container_started slot=%s", i)
-
-
-def ensure_web_pool(target_size: Optional[int] = None) -> None:
- desired = max(0, WEB_POOL_SIZE if target_size is None else target_size)
- d = docker_client()
- image = "portal-universal-runtime:latest"
-
- for i in range(desired, 100):
- name = web_pool_container_name(i)
- try:
- c = d.containers.get(name)
- c.stop(timeout=5)
- except docker.errors.NotFound:
- break
- except Exception:
- logger.exception("web_pool_scale_down_failed slot=%s", i)
-
- for i in range(desired):
- name = web_pool_container_name(i)
- path = f"/w/{i}/"
- router = f"wpool-{i}"
- labels = {
- "traefik.enable": "true",
- "traefik.docker.network": "portal_net",
- f"traefik.http.routers.{router}.rule": f"PathPrefix(`{path}`)",
- f"traefik.http.routers.{router}.entrypoints": "websecure",
- f"traefik.http.routers.{router}.tls": "true",
- f"traefik.http.routers.{router}.priority": "9450",
- f"traefik.http.routers.{router}.middlewares": f"{router}-strip",
- f"traefik.http.middlewares.{router}-strip.stripprefix.prefixes": path[:-1],
- f"traefik.http.services.{router}.loadbalancer.server.port": "6080",
- "portal.pool": "1",
- "portal.pool.kind": "web",
- "portal.pool.slot": str(i),
- }
- env = {
- "IDLE_TIMEOUT": str(SESSION_IDLE_SECONDS),
- "ENABLE_HEARTBEAT": "0",
- "SESSION_ID": f"webpool-{i}",
- }
- should_create = False
- try:
- c = d.containers.get(name)
- if c.status != "running":
- 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:
- should_create = True
- except Exception:
- logger.exception("web_pool_check_failed slot=%s", i)
- continue
-
- 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:
- desired = max(0, UNIVERSAL_POOL_SIZE)
- if desired <= 0:
- return {"desired": 0, "running": 0, "total": 0, "health": "down", "names": []}
- d = docker_client()
- names = [universal_container_name(i) for i in range(desired)]
- containers = []
- for name in names:
- try:
- containers.append(d.containers.get(name))
- except Exception:
- continue
- running = sum(1 for c in containers if c.status == "running")
- health = "ok" if running >= min(desired, 1) else "down"
- return {
- "desired": desired,
- "running": running,
- "total": len(containers),
- "names": sorted(c.name for c in containers),
- "health": health,
- }
-
-
-def get_web_pool_status() -> dict:
- desired = max(0, WEB_POOL_SIZE)
- if desired <= 0:
- return {"desired": 0, "running": 0, "total": 0, "health": "down", "names": []}
- d = docker_client()
- names = [web_pool_container_name(i) for i in range(desired)]
- containers = []
- for name in names:
- try:
- containers.append(d.containers.get(name))
- except Exception:
- continue
- running = sum(1 for c in containers if c.status == "running")
- health = "ok" if running >= min(desired, 1) else "down"
- return {
- "desired": desired,
- "running": running,
- "total": len(containers),
- "names": sorted(c.name for c in containers),
- "health": health,
- }
-
-
-def acquire_universal_slot(db: Session) -> int:
- cutoff = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS)
- q = select(SessionModel).where(
- SessionModel.status == SessionStatus.ACTIVE,
- SessionModel.container_id.like("POOLIDX:%"),
- SessionModel.last_access_at >= cutoff,
- )
- active = db.scalars(q).all()
- busy = set()
- for sess in active:
- try:
- busy.add(int((sess.container_id or "").split(":", 1)[1]))
- except Exception:
- continue
- for i in range(max(0, UNIVERSAL_POOL_SIZE)):
- if i not in busy:
- return i
- if active:
- victim = min(active, key=lambda s: s.last_access_at)
- victim.status = SessionStatus.TERMINATED
- db.commit()
- try:
- return int((victim.container_id or "").split(":", 1)[1])
- except Exception:
- pass
- return 0
-
-
-def acquire_web_pool_slot(db: Session) -> int:
- cutoff = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS)
- q = select(SessionModel).where(
- SessionModel.status == SessionStatus.ACTIVE,
- SessionModel.container_id.like("WEBPOOLIDX:%"),
- SessionModel.last_access_at >= cutoff,
- )
- active = db.scalars(q).all()
- busy = set()
- for sess in active:
- try:
- busy.add(int((sess.container_id or "").split(":", 1)[1]))
- except Exception:
- continue
-
- # Keep headroom: when active sessions are close to hot pool capacity,
- # proactively warm up extra slots.
- auto_target = max(WEB_POOL_SIZE, len(active) + max(0, WEB_POOL_BUFFER))
- if auto_target > WEB_POOL_SIZE:
- ensure_web_pool(auto_target)
-
- for i in range(max(0, auto_target)):
- if i not in busy:
- return i
- return 0
-
-
-def dispatch_universal_target(slot: int, service: Service) -> None:
- name = universal_container_name(slot)
- url = ""
- payload = {}
- if service.type == ServiceType.WEB:
- url = f"http://{name}:7000/open"
- payload = {"url": normalize_web_target(service.target)}
- elif service.type == ServiceType.RDP:
- cfg = parse_rdp_target(service.target)
- url = f"http://{name}:7000/rdp"
- payload = {
- "host": cfg["host"],
- "port": cfg["port"],
- "user": cfg["user"],
- "password": cfg["password"],
- "domain": cfg["domain"],
- "security": cfg["security"],
- }
- else:
- raise HTTPException(status_code=400, detail="Universal pool supports WEB/RDP only")
-
- last_exc = None
- for _ in range(max(1, POOL_DISPATCH_RETRIES)):
- try:
- resp = requests.post(url, json=payload, timeout=POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS)
- resp.raise_for_status()
- return
- except Exception as exc:
- last_exc = exc
- time.sleep(max(0.0, POOL_DISPATCH_SLEEP_SECONDS))
- if last_exc:
- raise last_exc
-
-
-def dispatch_web_pool_target(slot: int, service: Service) -> None:
- name = web_pool_container_name(slot)
- target_url = normalize_web_target(service.target)
- url = f"http://{name}:7000/open"
- last_exc = None
- for _ in range(max(1, POOL_DISPATCH_RETRIES)):
- try:
- resp = requests.post(url, json={"url": target_url}, timeout=POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS)
- resp.raise_for_status()
- return
- except Exception as exc:
- last_exc = exc
- time.sleep(max(0.0, POOL_DISPATCH_SLEEP_SECONDS))
- if last_exc:
- raise last_exc
-
-
-def create_runtime_container(service: Service, session_id: str):
- d = docker_client()
- router = session_router_name(session_id)
- path = f"/s/{session_id}/"
- labels = {
- "traefik.enable": "true",
- "traefik.docker.network": "portal_net",
- f"traefik.http.routers.{router}.rule": f"PathPrefix(`{path}`)",
- f"traefik.http.routers.{router}.entrypoints": "websecure",
- f"traefik.http.routers.{router}.tls": "true",
- f"traefik.http.routers.{router}.priority": "10000",
- f"traefik.http.routers.{router}.middlewares": f"{router}-strip",
- f"traefik.http.middlewares.{router}-strip.stripprefix.prefixes": path[:-1],
- f"traefik.http.services.{router}.loadbalancer.server.port": "6080",
- }
-
- env = {
- "SESSION_ID": session_id,
- "IDLE_TIMEOUT": str(SESSION_IDLE_SECONDS),
- "ENABLE_HEARTBEAT": "1",
- "TOUCH_PATH": f"/api/sessions/{session_id}/touch",
- }
- image = "portal-kiosk:latest"
-
- if service.type == ServiceType.WEB:
- env["TARGET_URL"] = service.target
- env["HOME_URL"] = f"https://{PUBLIC_HOST}/"
- elif service.type == ServiceType.RDP:
- image = "portal-rdp-proxy:latest"
- cfg = parse_rdp_target(service.target)
- env["RDP_HOST"] = cfg["host"]
- env["RDP_PORT"] = cfg["port"]
- if cfg["user"]:
- env["RDP_USER"] = cfg["user"]
- if cfg["password"]:
- env["RDP_PASSWORD"] = cfg["password"]
- if cfg["domain"]:
- env["RDP_DOMAIN"] = cfg["domain"]
- if cfg["security"]:
- env["RDP_SECURITY"] = cfg["security"]
- else:
- raise HTTPException(status_code=400, detail="Unsupported service type")
-
- container = d.containers.run(
- image=image,
- name=f"portal-sess-{session_id[:8]}",
- detach=True,
- auto_remove=True,
- network="portal_net",
- labels=labels,
- environment=env,
- )
- logger.info("session_container_started session_id=%s container_id=%s service_type=%s", session_id, container.id, service.type.value)
- return container.id
-
-
-def ensure_warm_pool(service: Service, pool_size: Optional[int] = None) -> None:
- if service_uses_universal_pool(service):
- return
- if pool_size is None:
- pool_size = desired_pool_size(service)
- if pool_size <= 0:
- # Stop stale warm containers for this service when pool is disabled.
- prefix = f"portal-warm-{service.slug}-"
- try:
- d = docker_client()
- for c in d.containers.list(all=True, filters={"name": prefix}):
- if c.name.startswith(prefix):
- c.stop(timeout=5)
- except Exception:
- logger.exception("warm_pool_disable_failed service=%s", service.slug)
- return
- d = docker_client()
- router = f"warm-{service.slug}"
- svc_name = f"warmsvc-{service.slug}"
- path = f"/svc/{service.slug}/"
- image = "portal-kiosk:latest"
- base_env = {
- "IDLE_TIMEOUT": str(SESSION_IDLE_SECONDS),
- "ENABLE_HEARTBEAT": "0",
- "TOUCH_PATH": "",
- }
- if service.type == ServiceType.WEB:
- base_env["UNIVERSAL_WEB"] = "1"
- base_env["START_URL"] = normalize_web_target(service.target)
- base_env["HOME_URL"] = f"https://{PUBLIC_HOST}/"
- elif service.type == ServiceType.RDP:
- image = "portal-rdp-proxy:latest"
- cfg = parse_rdp_target(service.target)
- base_env["RDP_HOST"] = cfg["host"]
- base_env["RDP_PORT"] = cfg["port"]
- if cfg["user"]:
- base_env["RDP_USER"] = cfg["user"]
- if cfg["password"]:
- base_env["RDP_PASSWORD"] = cfg["password"]
- if cfg["domain"]:
- base_env["RDP_DOMAIN"] = cfg["domain"]
- if cfg["security"]:
- base_env["RDP_SECURITY"] = cfg["security"]
- else:
- raise HTTPException(status_code=400, detail="Unsupported service type")
-
- labels = {
- "traefik.enable": "true",
- "traefik.docker.network": "portal_net",
- f"traefik.http.routers.{router}.rule": f"PathPrefix(`{path}`)",
- f"traefik.http.routers.{router}.entrypoints": "websecure",
- f"traefik.http.routers.{router}.tls": "true",
- f"traefik.http.routers.{router}.priority": "9500",
- f"traefik.http.routers.{router}.middlewares": f"{router}-strip",
- f"traefik.http.middlewares.{router}-strip.stripprefix.prefixes": path[:-1],
- f"traefik.http.services.{svc_name}.loadbalancer.server.port": "6080",
- f"traefik.http.routers.{router}.service": svc_name,
- "portal.warm": "1",
- "portal.service.slug": service.slug,
- "portal.service.type": service.type.value,
- }
-
- # Ensure desired cardinality.
- for i in range(pool_size, 50):
- name = f"portal-warm-{service.slug}-{i}"
- try:
- c = d.containers.get(name)
- c.stop(timeout=5)
- except docker.errors.NotFound:
- break
- except Exception:
- logger.exception("warm_pool_scale_down_failed service=%s idx=%s", service.slug, i)
-
- for i in range(pool_size):
- name = f"portal-warm-{service.slug}-{i}"
- try:
- c = d.containers.get(name)
- if c.status != "running":
- c.start()
- continue
- except docker.errors.NotFound:
- pass
- except Exception:
- logger.exception("warm_pool_check_failed service=%s idx=%s", service.slug, i)
- continue
-
- env = dict(base_env)
- env["SESSION_ID"] = f"warm-{service.slug}-{i}"
- d.containers.run(
- image=image,
- name=name,
- detach=True,
- auto_remove=True,
- network="portal_net",
- labels=labels,
- environment=env,
- )
- logger.info("warm_pool_container_started service=%s idx=%s", service.slug, i)
-
-
-def wait_for_session_route(session_id: str, timeout_seconds: int = 6) -> bool:
- target = f"{TRAEFIK_INTERNAL_URL}/s/{session_id}/"
- deadline = time.time() + timeout_seconds
- while time.time() < deadline:
- try:
- resp = requests.get(
- target,
- headers={"Host": PUBLIC_HOST},
- allow_redirects=False,
- timeout=1.5,
- )
- if resp.status_code != 404:
- return True
- except Exception:
- pass
- time.sleep(0.3)
- return False
-
-
-def route_ready(path: str) -> bool:
- bases = [TRAEFIK_INTERNAL_URL]
- if TRAEFIK_INTERNAL_URL.startswith("http://"):
- bases.append("https://" + TRAEFIK_INTERNAL_URL[len("http://"):])
- for base in bases:
- try:
- verify = not base.startswith("https://")
- resp = requests.get(
- f"{base}{path}",
- headers={"Host": PUBLIC_HOST},
- allow_redirects=False,
- timeout=1.5,
- verify=verify,
- )
- if resp.status_code != 404:
- return True
- except Exception:
- continue
- return False
-
-
-def container_running(container_id: Optional[str]) -> bool:
- if not container_id:
- return False
- if (
- container_id.startswith("POOL:")
- or container_id.startswith("POOLIDX:")
- or container_id.startswith("WEBPOOLIDX:")
- ):
- return True
- try:
- c = docker_client().containers.get(container_id)
- return c.status == "running"
- except Exception:
- return False
-
-
-def stop_runtime_container(container_id: Optional[str]) -> None:
- if not container_id:
- return
- try:
- d = docker_client()
- c = d.containers.get(container_id)
- c.stop(timeout=5)
- except Exception:
- 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
- old_status = sess.status
- 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()
- log_event(
- "session_closed",
- level=logging.INFO,
- session_id=sess.id,
- user_id=sess.user_id,
- service_id=sess.service_id,
- container_id=cid,
- old_status=old_status.value if isinstance(old_status, SessionStatus) else str(old_status),
- new_status=new_status.value,
- reason=session_closed_reason(sess, db),
- stop_container=stop_container,
- )
-
-
-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:
- conn.execute(
- text(
- """
- DO $$
- BEGIN
- BEGIN
- ALTER TYPE servicetype ADD VALUE IF NOT EXISTS 'RDP';
- EXCEPTION WHEN undefined_object THEN
- NULL;
- END;
- END $$;
- """
- )
- )
- conn.execute(
- text(
- """
- DO $$
- BEGIN
- BEGIN
- ALTER TYPE sessionstatus ADD VALUE IF NOT EXISTS 'ROTATED';
- EXCEPTION WHEN undefined_object THEN
- NULL;
- END;
- END $$;
- """
- )
- )
-
- with engine.begin() as conn:
- conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS warm_pool_size INTEGER NOT NULL DEFAULT 0"))
- conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS comment TEXT NOT NULL DEFAULT ''"))
- conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS icon_path TEXT NOT NULL DEFAULT ''"))
- conn.execute(
- text(
- """
- CREATE TABLE IF NOT EXISTS categories (
- id SERIAL PRIMARY KEY,
- name VARCHAR(128) NOT NULL UNIQUE,
- slug VARCHAR(64) NOT NULL UNIQUE,
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
- )
- """
- )
- )
- conn.execute(
- text(
- """
- CREATE TABLE IF NOT EXISTS service_categories (
- id SERIAL PRIMARY KEY,
- service_id INT NOT NULL REFERENCES services(id) ON DELETE CASCADE,
- category_id INT NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- UNIQUE (service_id, category_id)
- )
- """
- )
- )
- conn.execute(text("CREATE INDEX IF NOT EXISTS idx_service_categories_service_id ON service_categories(service_id)"))
- conn.execute(text("CREATE INDEX IF NOT EXISTS idx_service_categories_category_id ON service_categories(category_id)"))
- # Handle installs where service type is VARCHAR + CHECK.
- conn.execute(
- text(
- """
- DO $$
- DECLARE c record;
- BEGIN
- FOR c IN
- SELECT conname
- FROM pg_constraint
- WHERE conrelid = 'services'::regclass
- AND contype = 'c'
- AND pg_get_constraintdef(oid) ILIKE '%type%'
- LOOP
- EXECUTE format('ALTER TABLE services DROP CONSTRAINT %I', c.conname);
- END LOOP;
- ALTER TABLE services
- ADD CONSTRAINT services_type_check
- CHECK (type IN ('WEB','VNC','RDP'));
- EXCEPTION WHEN duplicate_object THEN
- NULL;
- END $$;
- """
- )
- )
-
-
-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
-
-
-def get_warm_containers_for_service(service: Service) -> list:
- prefix = f"portal-warm-{service.slug}-"
- try:
- d = docker_client()
- containers = []
- for c in d.containers.list(all=True, filters={"name": prefix}):
- if c.name.startswith(prefix):
- containers.append(c)
- return containers
- except Exception:
- logger.exception("pool_status_failed service=%s", service.slug)
- return []
-
-
-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)
- containers = get_warm_containers_for_service(service)
- running = sum(1 for c in containers if c.status == "running")
- states = [(c.attrs.get("State") or {}).get("Status", c.status) for c in containers]
- has_bad = any(s in {"exited", "dead"} for s in states)
- total = len(containers)
- if running == 0:
- health = "down"
- elif running >= min(desired, 1) and not has_bad:
- health = "ok"
- else:
- health = "degraded"
- return {
- "desired": desired,
- "running": running,
- "total": total,
- "names": sorted(c.name for c in containers),
- "health": health,
- }
-
-
-def get_pool_detailed_status(service: Service) -> dict:
- if service.type == ServiceType.WEB:
- d = docker_client()
- pool = get_web_pool_status()
- details = []
- for i in range(max(0, pool["desired"])):
- name = web_pool_container_name(i)
- try:
- c = d.containers.get(name)
- except Exception:
- continue
- attrs = c.attrs or {}
- state = (attrs.get("State") or {}).get("Status", c.status)
- details.append(
- {
- "name": c.name,
- "status": c.status,
- "state": state,
- "created": attrs.get("Created", ""),
- "image": c.image.tags[0] if c.image.tags else "",
- "labels_ok": True,
- }
- )
- return {
- "service_id": service.id,
- "slug": service.slug,
- "type": service.type.value,
- "desired": pool["desired"],
- "running": pool["running"],
- "total": pool["total"],
- "health": pool["health"],
- "containers": details,
- "updated_at": now_utc().isoformat(),
- }
- if service_uses_universal_pool(service):
- d = docker_client()
- pool = get_universal_pool_status()
- details = []
- for i in range(max(0, UNIVERSAL_POOL_SIZE)):
- name = universal_container_name(i)
- try:
- c = d.containers.get(name)
- except Exception:
- continue
- attrs = c.attrs or {}
- state = (attrs.get("State") or {}).get("Status", c.status)
- details.append(
- {
- "name": c.name,
- "status": c.status,
- "state": state,
- "created": attrs.get("Created", ""),
- "image": c.image.tags[0] if c.image.tags else "",
- "labels_ok": True,
- }
- )
- return {
- "service_id": service.id,
- "slug": service.slug,
- "type": service.type.value,
- "desired": pool["desired"],
- "running": pool["running"],
- "total": pool["total"],
- "health": pool["health"],
- "containers": details,
- "updated_at": now_utc().isoformat(),
- }
- containers = get_warm_containers_for_service(service)
- pool = get_pool_status_for_service(service)
- details = []
- for c in sorted(containers, key=lambda x: x.name):
- attrs = c.attrs or {}
- state = (attrs.get("State") or {}).get("Status", c.status)
- created = attrs.get("Created", "")
- labels = attrs.get("Config", {}).get("Labels", {}) or {}
- labels_ok = (
- labels.get("portal.warm") == "1"
- and labels.get("portal.service.slug") == service.slug
- and labels.get("portal.service.type") == service.type.value
- )
- details.append(
- {
- "name": c.name,
- "status": c.status,
- "state": state,
- "created": created,
- "image": c.image.tags[0] if c.image.tags else "",
- "labels_ok": labels_ok,
- }
- )
- return {
- "service_id": service.id,
- "slug": service.slug,
- "type": service.type.value,
- "desired": pool["desired"],
- "running": pool["running"],
- "total": pool["total"],
- "health": pool["health"],
- "containers": details,
- "updated_at": now_utc().isoformat(),
- }
-
-
-def get_active_sessions_count(db: Session, service_id: int) -> int:
- 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,
- )
- 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]:
- 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 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()
-
-
-class LockTimeoutError(Exception):
- pass
-
-
-def allocator_lock(db: Session, lock_id: int, timeout_seconds: Optional[float] = None, poll_seconds: float = 0.05):
- class _LockCtx:
- def __enter__(self_nonlocal):
- self_nonlocal._acquired = False
- if timeout_seconds is None:
- db.execute(text("SELECT pg_advisory_xact_lock(:lid)"), {"lid": lock_id})
- self_nonlocal._acquired = True
- return self_nonlocal
-
- deadline = time.monotonic() + max(0.0, timeout_seconds)
- while time.monotonic() <= deadline:
- got = db.execute(text("SELECT pg_try_advisory_xact_lock(:lid)"), {"lid": lock_id}).scalar()
- if got:
- self_nonlocal._acquired = True
- return self_nonlocal
- time.sleep(max(0.01, poll_seconds))
- raise LockTimeoutError(f"advisory lock timeout lock_id={lock_id} timeout={timeout_seconds}")
-
- return self_nonlocal
-
- def __exit__(self_nonlocal, exc_type, exc, tb):
- 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:"):
- 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
- if service.type != ServiceType.WEB:
- return
- target_url = normalize_web_target(target_url)
- try:
- d = docker_client()
- containers = d.containers.list(
- filters={
- "label": [
- "portal.warm=1",
- f"portal.service.slug={service.slug}",
- "portal.service.type=WEB",
- ]
- }
- )
- for c in containers:
- try:
- resp = requests.post(
- f"http://{c.name}:7000/open",
- json={"url": target_url},
- timeout=2,
- )
- resp.raise_for_status()
- logger.info("warm_web_open_ok service=%s container=%s url=%s", service.slug, c.name, target_url)
- except Exception:
- logger.exception("warm_web_open_failed service=%s container=%s", service.slug, c.name)
- except Exception:
- logger.exception("warm_web_open_dispatch_failed service=%s", service.slug)
-
-
-def cleanup_loop():
- while True:
- time.sleep(60)
- db = SessionLocal()
- try:
- ensure_universal_pool()
- ensure_web_pool()
- for svc in db.scalars(
- select(Service).where(
- Service.active == True,
- Service.type.in_([ServiceType.WEB, ServiceType.RDP]),
- )
- ).all():
- 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(
- SessionModel.status == SessionStatus.ACTIVE,
- SessionModel.last_access_at < cutoff,
- )
- stale = db.scalars(q).all()
- for sess in stale:
- if sess.container_id and not (
- sess.container_id.startswith("POOL:")
- or sess.container_id.startswith("POOLIDX:")
- or sess.container_id.startswith("WEBPOOLIDX:")
- ):
- stop_runtime_container(sess.container_id)
- sess.status = SessionStatus.EXPIRED
- if stale:
- db.commit()
- except Exception:
- db.rollback()
- logger.exception("cleanup_loop_failed")
- finally:
- db.close()
-
-
-def bootstrap_admin():
- admin_user = os.getenv("ADMIN_USERNAME", "admin")
- admin_password = os.getenv("ADMIN_PASSWORD", "admin123")
- ttl_days = int(os.getenv("ADMIN_TTL_DAYS", "3650"))
-
- db = SessionLocal()
- try:
- existing = db.scalar(select(User).where(User.username == admin_user))
- if not existing:
- db.add(
- User(
- username=admin_user,
- password_hash=hash_password(admin_password),
- active=True,
- is_admin=True,
- expires_at=now_utc() + dt.timedelta(days=ttl_days),
- )
- )
- db.commit()
- finally:
- db.close()
-
-
-@app.on_event("startup")
-def startup_event():
- # Multiple uvicorn workers run startup in parallel. Serialize schema bootstrap
- # to avoid DDL races on first run and during schema extension.
- with open("/tmp/portal-schema.lock", "w") as lock_file:
- fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
- Base.metadata.create_all(bind=engine)
- ensure_schema_compatibility()
- fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
- ensure_icons_dir()
- bootstrap_admin()
- db = SessionLocal()
- try:
- ensure_universal_pool()
- ensure_web_pool()
- for svc in db.scalars(
- select(Service).where(
- Service.active == True,
- Service.type.in_([ServiceType.WEB, ServiceType.RDP]),
- )
- ).all():
- if svc.type == ServiceType.WEB and WEB_POOL_SIZE <= 0:
- ensure_warm_pool(svc)
- finally:
- db.close()
- thread = threading.Thread(target=cleanup_loop, daemon=True)
- thread.start()
-
-
-@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 session_closed == "limit":
- session_notice = (
- f"Сессия была закрыта из-за лимита в {MAX_ACTIVE_SERVICES_PER_USER} сервиса(ов). "
- "Освободите один сервис и попробуйте снова."
- )
- 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(
- "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
-
- services = db.scalars(
- select(Service)
- .join(UserServiceAccess, UserServiceAccess.service_id == Service.id)
- .where(
- UserServiceAccess.user_id == user.id,
- Service.active == True,
- Service.type.in_([ServiceType.WEB, ServiceType.RDP]),
- )
- .order_by(Service.name)
- ).all()
-
- service_categories = {svc.id: [] for svc in services}
- categories = []
- if services:
- service_ids = [svc.id for svc in services]
- rows = db.execute(
- select(ServiceCategory.service_id, Category.id, Category.name, Category.slug)
- .join(Category, Category.id == ServiceCategory.category_id)
- .where(ServiceCategory.service_id.in_(service_ids))
- .order_by(Category.name)
- ).all()
- category_map = {}
- for service_id, category_id, category_name, category_slug in rows:
- service_categories.setdefault(service_id, []).append(
- {
- "id": category_id,
- "name": category_name,
- "slug": category_slug,
- }
- )
- if category_id not in category_map:
- category_map[category_id] = {"id": category_id, "name": category_name, "slug": category_slug}
- categories = sorted(category_map.values(), key=lambda x: x["name"].lower())
-
- selected_category_slug = (request.query_params.get("category") or "").strip().lower()
- if selected_category_slug:
- services = [
- 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",
- {
- "request": request,
- "user": user,
- "services": services,
- "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,
- },
- )
-
-
-@app.get("/admin", response_class=HTMLResponse)
-def admin_page(request: Request, admin: User = Depends(require_admin), db: Session = Depends(get_db)):
- users = db.scalars(select(User).order_by(User.id)).all()
- categories = db.scalars(select(Category).order_by(Category.name)).all()
- services = db.scalars(select(Service).where(Service.type.in_([ServiceType.WEB, ServiceType.RDP])).order_by(Service.id)).all()
- web_services = [s for s in services if s.type == ServiceType.WEB]
- rdp_services = [s for s in services if s.type == ServiceType.RDP]
- service_category_map = {s.id: [] for s in services}
- if services:
- service_rows = db.execute(
- select(ServiceCategory.service_id, ServiceCategory.category_id).where(
- ServiceCategory.service_id.in_([s.id for s in services])
- )
- ).all()
- for service_id, category_id in service_rows:
- service_category_map.setdefault(service_id, []).append(category_id)
- acl_rows = db.scalars(select(UserServiceAccess)).all()
- acl = {}
- for row in acl_rows:
- acl.setdefault(row.user_id, []).append(row.service_id)
- for user_id in acl:
- acl[user_id] = sorted(acl[user_id])
- pool_status = {s.id: get_pool_status_for_service(s) for s in services}
- service_health = {}
- for sid, st in pool_status.items():
- service_health[sid] = {
- "health": st["health"],
- "running": st["running"],
- "desired": st["desired"],
- "active_sessions": get_active_sessions_count(db, sid),
- }
- web_pool = get_web_pool_status()
- web_totals = {
- "services": len(web_services),
- "running": web_pool["running"],
- "desired": web_pool["desired"],
- "active_sessions": sum(service_health[s.id]["active_sessions"] for s in web_services),
- }
- recent_sessions = db.execute(
- text(
- """
- SELECT s.id, u.username, sv.name AS service_name, sv.slug AS service_slug,
- s.status, 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 sv.type IN ('WEB','RDP')
- ORDER BY s.created_at DESC
- LIMIT 200
- """
- )
- ).mappings().all()
- open_stats = db.execute(
- text(
- """
- SELECT u.username, sv.name AS service_name, sv.slug AS service_slug, COUNT(*) AS opens
- FROM sessions s
- JOIN users u ON u.id = s.user_id
- JOIN services sv ON sv.id = s.service_id
- WHERE sv.type IN ('WEB','RDP')
- GROUP BY u.username, sv.name, sv.slug
- ORDER BY opens DESC, u.username ASC
- LIMIT 200
- """
- )
- ).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",
- {
- "request": request,
- "admin": admin,
- "users": users,
- "web_services": web_services,
- "rdp_services": rdp_services,
- "services": services,
- "categories": categories,
- "service_category_map": service_category_map,
- "acl": acl,
- "pool_status": pool_status,
- "service_health": service_health,
- "web_totals": web_totals,
- "web_pool_size": WEB_POOL_SIZE,
- "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,
- },
- )
-
-
-@app.post("/login")
-def login(
- request: Request,
- username: str = Form(...),
- password: str = Form(...),
- csrf_token: str = Form(...),
- db: Session = Depends(get_db),
-):
- cookie_csrf = request.cookies.get(CSRF_COOKIE)
- if not cookie_csrf or csrf_token != cookie_csrf:
- raise HTTPException(status_code=403, detail="CSRF failed")
-
- user = db.scalar(select(User).where(User.username == username))
- if not user or not verify_password(password, user.password_hash):
- 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(
- "login.html",
- {
- "request": request,
- "csrf_token": csrf,
- "login_error": "Доступ к сервису приостоновлен, обратитесь к вашему менеджеру",
- },
- status_code=403,
- )
- response.set_cookie(CSRF_COOKIE, csrf, httponly=False, secure=True, samesite="strict", path="/")
- return response
-
- response = RedirectResponse(url="/", status_code=303)
- issue_auth_cookie(response, user)
- issue_csrf_cookie(response)
- audit(db, "LOGIN", f"login success: {username}", user_id=user.id)
- return response
-
-
-@app.post("/logout")
-def logout(request: Request):
- response = RedirectResponse(url="/", status_code=303)
- response.delete_cookie(COOKIE_NAME, path="/")
- response.delete_cookie(CSRF_COOKIE, path="/")
- return response
-
-
-@app.get("/go/{slug}")
-def go_service(slug: str, user: User = Depends(require_user), db: Session = Depends(get_db)):
- total_started = time.perf_counter()
- phase_ms = {}
-
- def _mark(name: str, started: float) -> None:
- phase_ms[name] = int((time.perf_counter() - started) * 1000)
-
- def _emit(result: str, **extra) -> None:
- payload = {
- "user_id": user.id,
- "service_slug": slug,
- "result": result,
- "total_ms": int((time.perf_counter() - total_started) * 1000),
- }
- payload.update(phase_ms)
- payload.update(extra)
- log_event("go_service_timing", **payload)
-
- log_event("session_open_requested", user_id=user.id, service_slug=slug)
- service = db.scalar(select(Service).where(Service.slug == slug, Service.active == True))
- if not service:
- raise HTTPException(status_code=404, detail="Service not found")
- if service.type == ServiceType.VNC:
- 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")
-
- user_lock_started = time.perf_counter()
- try:
- with allocator_lock(db, 92000 + int(user.id), timeout_seconds=GO_USER_LOCK_TIMEOUT_SECONDS):
- _mark("wait_user_lock_ms", user_lock_started)
-
- t_existing = time.perf_counter()
- existing_user_session = find_active_session_for_user_service(db, user.id, service.id)
- _mark("check_existing_ms", t_existing)
- if existing_user_session:
- _emit("reuse_session", session_id=existing_user_session.id)
- return RedirectResponse(url=session_redirect_url(existing_user_session), status_code=303)
-
- t_limit = time.perf_counter()
- 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}
- _mark("check_limit_ms", t_limit)
- 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:
- t_rotate = time.perf_counter()
- terminate_session_record(db, oldest, SessionStatus.ROTATED, stop_container=True)
- db.commit()
- _mark("rotate_oldest_ms", t_rotate)
- log_event(
- "session_rotated",
- user_id=user.id,
- closed_session_id=oldest.id,
- closed_service_id=oldest.service_id,
- new_service_id=service.id,
- )
- else:
- _emit("max_services_redirect")
- return RedirectResponse(url="/?launch_error=max_services", status_code=303)
-
- if service.type == ServiceType.RDP:
- t_rdp_owner = time.perf_counter()
- active_owner = find_active_session_for_service(db, service.id)
- _mark("check_rdp_owner_ms", t_rdp_owner)
- 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}"
- _emit("rdp_busy", owner=owner_name)
- raise HTTPException(
- status_code=409,
- detail=f"RDP сервис уже занят пользователем {owner_name}. Попробуйте позже.",
- )
- _emit("reuse_rdp_session", session_id=active_owner.id)
- 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:
- try:
- t_pool_lock = time.perf_counter()
- with allocator_lock(db, 91001, timeout_seconds=GO_POOL_LOCK_TIMEOUT_SECONDS):
- _mark("wait_web_pool_lock_ms", t_pool_lock)
- t_ensure = time.perf_counter()
- ensure_web_pool()
- _mark("ensure_web_pool_ms", t_ensure)
-
- t_acquire = time.perf_counter()
- slot = acquire_web_pool_slot(db)
- _mark("acquire_web_slot_ms", t_acquire)
- slot_cid = f"WEBPOOLIDX:{slot}"
-
- t_dispatch = time.perf_counter()
- terminate_active_slot_sessions(db, slot_cid)
- dispatch_web_pool_target(slot, service)
- _mark("dispatch_web_target_ms", t_dispatch)
-
- t_commit = time.perf_counter()
- 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()
- _mark("db_commit_ms", t_commit)
- except LockTimeoutError:
- _emit("web_pool_lock_timeout")
- raise HTTPException(status_code=503, detail="Пул WEB занят. Повторите через несколько секунд.")
- except Exception as exc:
- logger.exception("web_pool_dispatch_failed slug=%s user_id=%s", slug, user.id)
- log_event("session_create_failed", level=logging.ERROR, user_id=user.id, service_slug=slug, mode="web_pool", error=str(exc))
- audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id)
- _emit("web_pool_create_failed", error=str(exc))
- raise HTTPException(status_code=502, detail="WEB runtime failed to switch target")
- log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="web_pool", slot=slot)
- audit(db, "SESSION_CREATE_WEB_POOL", f"service={service.slug} session={session_id} slot={slot}", user_id=user.id)
- _emit("session_created_web_pool", session_id=session_id, slot=slot)
- return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
-
- if service_uses_universal_pool(service):
- try:
- t_pool_lock = time.perf_counter()
- with allocator_lock(db, 91002, timeout_seconds=GO_POOL_LOCK_TIMEOUT_SECONDS):
- _mark("wait_universal_pool_lock_ms", t_pool_lock)
- t_ensure = time.perf_counter()
- ensure_universal_pool()
- _mark("ensure_universal_pool_ms", t_ensure)
-
- t_acquire = time.perf_counter()
- slot = acquire_universal_slot(db)
- _mark("acquire_universal_slot_ms", t_acquire)
- slot_cid = f"POOLIDX:{slot}"
-
- t_dispatch = time.perf_counter()
- terminate_active_slot_sessions(db, slot_cid)
- dispatch_universal_target(slot, service)
- _mark("dispatch_universal_target_ms", t_dispatch)
-
- t_commit = time.perf_counter()
- 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()
- _mark("db_commit_ms", t_commit)
- except LockTimeoutError:
- _emit("universal_pool_lock_timeout")
- raise HTTPException(status_code=503, detail="Пул RDP занят. Повторите через несколько секунд.")
- except Exception as exc:
- logger.exception("universal_pool_dispatch_failed slug=%s user_id=%s", slug, user.id)
- log_event("session_create_failed", level=logging.ERROR, user_id=user.id, service_slug=slug, mode="universal_pool", error=str(exc))
- audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id)
- _emit("universal_pool_create_failed", error=str(exc))
- raise HTTPException(status_code=502, detail="Universal runtime failed to switch target")
- log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="universal_pool", slot=slot)
- audit(db, "SESSION_CREATE_POOL", f"service={service.slug} session={session_id} slot={slot}", user_id=user.id)
- _emit("session_created_universal_pool", session_id=session_id, slot=slot)
- return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
-
- if service.type == ServiceType.WEB and desired_pool_size(service) > 0:
- t_warm = time.perf_counter()
- ensure_warm_pool(service)
- open_warm_web_url(service, service.target)
- _mark("warm_pool_prepare_ms", t_warm)
-
- t_commit = time.perf_counter()
- session_obj = SessionModel(
- id=session_id,
- user_id=user.id,
- service_id=service.id,
- container_id=f"POOL:{service.slug}",
- status=SessionStatus.ACTIVE,
- created_at=now_utc(),
- last_access_at=now_utc(),
- )
- db.add(session_obj)
- db.commit()
- _mark("db_commit_ms", t_commit)
- log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="warm_pool")
- audit(db, "SESSION_CREATE_POOL", f"service={service.slug} session={session_id}", user_id=user.id)
- _emit("session_created_warm_pool", session_id=session_id)
- return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
-
- try:
- t_create = time.perf_counter()
- container_id = create_runtime_container(service, session_id)
- _mark("create_runtime_container_ms", t_create)
- except Exception as exc:
- logger.exception("session_container_create_failed slug=%s user_id=%s", slug, user.id)
- log_event("session_create_failed", level=logging.ERROR, user_id=user.id, service_slug=slug, mode="single_runtime", error=str(exc))
- audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id)
- _emit("single_runtime_create_failed", error=str(exc))
- raise HTTPException(status_code=502, detail="Session runtime failed to start")
-
- t_commit = time.perf_counter()
- session_obj = SessionModel(
- id=session_id,
- user_id=user.id,
- service_id=service.id,
- container_id=container_id,
- status=SessionStatus.ACTIVE,
- created_at=now_utc(),
- last_access_at=now_utc(),
- )
- db.add(session_obj)
- db.commit()
- _mark("db_commit_ms", t_commit)
- log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="single_runtime", container_id=container_id)
-
- audit(db, "SESSION_CREATE", f"service={service.slug} session={session_id}", user_id=user.id)
- t_wait = time.perf_counter()
- ready = wait_for_session_route(session_id)
- _mark("wait_session_route_ms", t_wait)
- log_event("session_route_ready", session_id=session_id, ready=ready)
- _emit("session_created_single_runtime", session_id=session_id, ready=ready)
- return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
- except LockTimeoutError:
- _emit("user_lock_timeout")
- raise HTTPException(status_code=429, detail="Слишком много параллельных запусков. Повторите через несколько секунд.")
-
-
-@app.get("/svc/{slug}/", response_class=HTMLResponse)
-def service_wait_page(slug: str, request: Request, user: User = Depends(require_user), db: Session = Depends(get_db)):
- service = db.scalar(select(Service).where(Service.slug == slug, Service.active == True))
- if not service:
- raise HTTPException(status_code=404, detail="Service not found")
- if not has_access(db, user.id, service.id):
- raise HTTPException(status_code=403, detail="ACL denied")
- return HTMLResponse(
- content="""
-
-
-
-
- Service Starting
-
-
-
-
-
Сервис запускается
-
Проверка...
-
-
-
-
-
-""".strip(),
- status_code=200,
- )
-
-
-@app.get("/s/{session_id}/", response_class=HTMLResponse)
-def session_wait_page(session_id: str, request: Request, 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:
- raise HTTPException(status_code=410, detail="Session is not active")
- service = db.get(Service, sess.service_id)
- service_title = service.name if service else "Сервис"
- redirect_target = session_redirect_url(sess)
- return HTMLResponse(
- content=f"""
-
-
-
-
- {service_title}
-
-
-
-
-
Сессия запускается
-
Проверка...
-
-
{session_id}
-
-
-
-
-""".strip(),
- status_code=200,
- )
-
-
-@app.get("/s/{session_id}/view", response_class=HTMLResponse)
-def session_view_page(session_id: str, request: Request, 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:
- raise HTTPException(status_code=410, detail="Session is not active")
- 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"""
-
-
-
-
- {service.name}
-
-
-
-
-
-
-""".strip()
- )
- return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
-
-
-@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:
- raise HTTPException(status_code=404, detail="Session not found")
- if sess.status != SessionStatus.ACTIVE:
- reason = session_closed_reason(sess, db)
- log_event(
- "session_touch_rejected",
- level=logging.WARNING,
- session_id=session_id,
- user_id=user.id,
- status=sess.status.value,
- reason=reason,
- )
- return JSONResponse(
- status_code=410,
- content={
- "ok": False,
- "reason": reason,
- "status": sess.status.value,
- },
- )
- sess.last_access_at = now_utc()
- db.commit()
- 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:
- log_event(
- "session_close_already_closed",
- session_id=session_id,
- user_id=user.id,
- status=sess.status.value,
- reason=session_closed_reason(sess, db),
- )
- return {"ok": True, "status": sess.status.value}
- terminate_session_record(db, sess, SessionStatus.TERMINATED, stop_container=True)
- db.commit()
- log_event("session_closed_by_user", session_id=session_id, user_id=user.id)
- 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))
- if not service:
- raise HTTPException(status_code=404, detail="Service not found")
- if service.type == ServiceType.VNC:
- 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")
- pool = get_pool_status_for_service(service)
- route_ok = route_ready(f"/svc/{slug}/")
- ready = route_ok and (pool["running"] > 0 if desired_pool_size(service) > 0 else True)
- steps = [
- f"ACL: OK ({user.username})",
- f"Пул: {pool['running']} / {pool['desired']}",
- f"Маршрут /svc/{slug}/: {'OK' if route_ok else 'ожидание'}",
- ]
- return {
- "ready": ready,
- "message": "Готово, открываем..." if ready else "Поднимаем контейнер и маршрут...",
- "steps": steps,
- }
-
-
-@app.get("/api/sessions/{session_id}/status")
-def session_status(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:
- raise HTTPException(status_code=410, detail="Session is not active")
- 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
- steps = [
- f"Контейнер: {'running' if running else 'starting'}",
- f"Маршрут {route_path}: {'OK' if route_ok else 'ожидание'}",
- ]
- payload = {
- "ready": ready,
- "message": "Готово, открываем..." if ready else "Запуск сессии...",
- "steps": steps,
- }
- if pooled_web:
- payload["redirect_url"] = f"/s/{session_id}/view"
- if web_pool_idx is not None:
- 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
-
-
-@app.post("/api/admin/services")
-def create_service(payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
- validate_csrf(request)
- service_type = ServiceType(payload["type"])
- if service_type == ServiceType.VNC:
- raise HTTPException(status_code=400, detail="VNC services are no longer supported")
- target = payload["target"]
- if service_type == ServiceType.WEB:
- target = normalize_web_target(target)
- elif service_type == ServiceType.RDP:
- parse_rdp_target(target)
- service = Service(
- name=payload["name"],
- slug=payload["slug"],
- type=service_type,
- target=target,
- comment=payload.get("comment", ""),
- active=payload.get("active", True),
- warm_pool_size=max(0, int(payload.get("warm_pool_size", 0))),
- )
- db.add(service)
- db.flush()
- set_service_categories(db, service.id, payload.get("category_ids", []))
- db.commit()
- 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}
-
-
-@app.get("/api/admin/services/{service_id}/containers/status")
-def service_containers_status(service_id: int, _: User = Depends(require_admin), db: Session = Depends(get_db)):
- service = db.get(Service, service_id)
- if not service:
- raise HTTPException(status_code=404, detail="Service not found")
- out = get_pool_detailed_status(service)
- out["active_sessions"] = get_active_sessions_count(db, service.id)
- return out
-
-
-@app.post("/api/admin/services/{service_id}/icon")
-async def upload_service_icon(
- service_id: int,
- request: Request,
- file: UploadFile = File(...),
- _: User = Depends(require_admin),
- db: Session = Depends(get_db),
-):
- validate_csrf(request)
- service = db.get(Service, service_id)
- if not service:
- raise HTTPException(status_code=404, detail="Service not found")
- new_path = await store_service_icon(service, file)
- old_path = service.icon_path
- service.icon_path = new_path
- db.commit()
- if old_path and old_path != new_path:
- remove_icon_file(old_path)
- return {"ok": True, "icon_path": new_path}
-
-
-@app.delete("/api/admin/services/{service_id}/icon")
-def delete_service_icon(service_id: int, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
- validate_csrf(request)
- service = db.get(Service, service_id)
- if not service:
- raise HTTPException(status_code=404, detail="Service not found")
- old_path = service.icon_path
- service.icon_path = ""
- db.commit()
- remove_icon_file(old_path)
- return {"ok": True}
-
-
-@app.put("/api/admin/services/{service_id}")
-def edit_service(service_id: int, payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
- validate_csrf(request)
- service = db.get(Service, service_id)
- if not service:
- raise HTTPException(status_code=404, detail="Service not found")
- for key in ["name", "slug", "target", "active", "comment"]:
- if key in payload:
- setattr(service, key, payload[key])
- if "type" in payload:
- service.type = ServiceType(payload["type"])
- if service.type == ServiceType.VNC:
- raise HTTPException(status_code=400, detail="VNC services are no longer supported")
- if service.type == ServiceType.WEB:
- service.target = normalize_web_target(service.target)
- elif service.type == ServiceType.RDP:
- parse_rdp_target(service.target)
- if "warm_pool_size" in payload:
- service.warm_pool_size = max(0, int(payload["warm_pool_size"]))
- if "category_ids" in payload:
- set_service_categories(db, service.id, payload.get("category_ids", []))
- db.commit()
- 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}
-
-
-@app.delete("/api/admin/services/{service_id}")
-def delete_service(service_id: int, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
- validate_csrf(request)
- service = db.get(Service, service_id)
- if not service:
- raise HTTPException(status_code=404, detail="Service not found")
- 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()
- return {"ok": True}
-
-
-@app.post("/api/admin/services/{service_id}/prewarm")
-def prewarm_now(service_id: int, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
- validate_csrf(request)
- service = db.get(Service, service_id)
- if not service:
- raise HTTPException(status_code=404, detail="Service not found")
- if service.type == ServiceType.WEB:
- ensure_web_pool()
- return {"ok": True, "pool": get_web_pool_status()}
- 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)}
-
-
-@app.post("/api/admin/categories")
-def create_category(payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
- validate_csrf(request)
- name = (payload.get("name") or "").strip()
- slug = (payload.get("slug") or "").strip().lower().replace(" ", "-")
- if not name:
- raise HTTPException(status_code=400, detail="Category name is required")
- if not slug:
- raise HTTPException(status_code=400, detail="Category slug is required")
- exists = db.scalar(select(Category).where((Category.name == name) | (Category.slug == slug)))
- if exists:
- raise HTTPException(status_code=409, detail="Category already exists")
- category = Category(name=name, slug=slug)
- db.add(category)
- db.commit()
- return {"id": category.id}
-
-
-@app.delete("/api/admin/categories/{category_id}")
-def delete_category(category_id: int, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
- validate_csrf(request)
- category = db.get(Category, category_id)
- if not category:
- raise HTTPException(status_code=404, detail="Category not found")
- db.delete(category)
- db.commit()
- return {"ok": True}
-
-
-@app.put("/api/admin/web-pool-size")
-def update_web_pool_size(payload: dict, request: Request, _: User = Depends(require_admin)):
- validate_csrf(request)
- global WEB_POOL_SIZE
- value = max(0, int(payload.get("size", WEB_POOL_SIZE)))
- WEB_POOL_SIZE = value
- ensure_web_pool()
- return {"ok": True, "size": WEB_POOL_SIZE, "pool": get_web_pool_status()}
-
-
-@app.post("/api/admin/users")
-def create_user(payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
- validate_csrf(request)
- expires_at = dt.datetime.fromisoformat(payload["expires_at"])
- user = User(
- username=payload["username"],
- password_hash=hash_password(payload["password"]),
- expires_at=expires_at,
- active=payload.get("active", True),
- is_admin=payload.get("is_admin", False),
- )
- db.add(user)
- db.commit()
- return {"id": user.id}
-
-
-@app.put("/api/admin/users/{user_id}")
-def edit_user(user_id: int, payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
- validate_csrf(request)
- user = db.get(User, user_id)
- if not user:
- raise HTTPException(status_code=404, detail="User not found")
- for key in ["username", "active", "is_admin"]:
- if key in payload:
- setattr(user, key, payload[key])
- if "password" in payload and payload["password"]:
- user.password_hash = hash_password(payload["password"])
- if "expires_at" in payload:
- user.expires_at = dt.datetime.fromisoformat(payload["expires_at"])
- db.commit()
- return {"ok": True}
-
-
-@app.delete("/api/admin/users/{user_id}")
-def delete_user(user_id: int, request: Request, admin: User = Depends(require_admin), db: Session = Depends(get_db)):
- validate_csrf(request)
- user = db.get(User, user_id)
- if not user:
- raise HTTPException(status_code=404, detail="User not found")
- if user.id == admin.id:
- raise HTTPException(status_code=400, detail="Cannot delete current admin")
- db.delete(user)
- db.commit()
- return {"ok": True}
-
-
-@app.put("/api/admin/users/{user_id}/acl")
-def set_acl(user_id: int, payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
- validate_csrf(request)
- user = db.get(User, user_id)
- if not user:
- raise HTTPException(status_code=404, detail="User not found")
- service_ids = set(payload.get("service_ids", []))
-
- existing = db.scalars(select(UserServiceAccess).where(UserServiceAccess.user_id == user_id)).all()
- existing_map = {x.service_id: x for x in existing}
-
- for sid in service_ids:
- if sid not in existing_map:
- db.add(UserServiceAccess(user_id=user_id, service_id=sid))
- for sid, row in existing_map.items():
- if sid not in service_ids:
- db.delete(row)
-
- db.commit()
- return {"ok": True}
diff --git a/app/main.py.bak_lockfix_20260424_121438 b/app/main.py.bak_lockfix_20260424_121438
deleted file mode 100644
index 7484e62..0000000
--- a/app/main.py.bak_lockfix_20260424_121438
+++ /dev/null
@@ -1,2565 +0,0 @@
-import datetime as dt
-import enum
-import fcntl
-import json
-import re
-import logging
-import os
-from pathlib import Path
-import secrets
-import threading
-import time
-import uuid
-import contextvars
-from urllib.parse import parse_qs, unquote, urlparse
-from typing import Optional
-
-import docker
-import requests
-from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile, status
-from fastapi.responses import HTMLResponse, JSONResponse, 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,
- DateTime,
- Enum,
- ForeignKey,
- Integer,
- String,
- Text,
- UniqueConstraint,
- create_engine,
- select,
- text,
-)
-from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sessionmaker
-
-
-DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+psycopg2://portal:portal@db:5432/portal")
-COOKIE_NAME = "portal_auth"
-CSRF_COOKIE = "csrf_token"
-COOKIE_MAX_AGE = 8 * 60 * 60
-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()
-LOG_SLOW_REQUEST_MS = int(os.getenv("LOG_SLOW_REQUEST_MS", "2000"))
-GO_USER_LOCK_TIMEOUT_SECONDS = float(os.getenv("GO_USER_LOCK_TIMEOUT_SECONDS", "2.0"))
-GO_POOL_LOCK_TIMEOUT_SECONDS = float(os.getenv("GO_POOL_LOCK_TIMEOUT_SECONDS", "5.0"))
-POOL_DISPATCH_RETRIES = int(os.getenv("POOL_DISPATCH_RETRIES", "4"))
-POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS = float(os.getenv("POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS", "2.0"))
-POOL_DISPATCH_SLEEP_SECONDS = float(os.getenv("POOL_DISPATCH_SLEEP_SECONDS", "0.3"))
-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", "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",
- "image/jpeg": "jpg",
- "image/webp": "webp",
-}
-SERVICE_ICONS_DIR = Path("static/service-icons")
-
-logging.basicConfig(
- level=LOG_LEVEL,
- format="%(asctime)s %(levelname)s %(name)s %(message)s",
-)
-logger = logging.getLogger("portal")
-request_id_ctx = contextvars.ContextVar("request_id", default="-")
-
-
-def _normalize_log_value(value):
- if isinstance(value, (str, int, float, bool)) or value is None:
- return value
- if isinstance(value, dt.datetime):
- return value.isoformat()
- return str(value)
-
-
-def log_event(event: str, level: int = logging.INFO, **fields) -> None:
- payload = {"event": event, "req_id": request_id_ctx.get()}
- for key, value in fields.items():
- payload[key] = _normalize_log_value(value)
- logger.log(level, json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
-
-SIGNING_KEY = os.getenv("SIGNING_KEY", secrets.token_urlsafe(32))
-serializer = URLSafeTimedSerializer(SIGNING_KEY, salt="portal-auth")
-pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
-
-engine = create_engine(DATABASE_URL, pool_pre_ping=True)
-SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
-
-templates = Jinja2Templates(directory="templates")
-app = FastAPI(title="МОНТ - инфрастуктурный полигон")
-app.mount("/static", StaticFiles(directory="static"), name="static")
-
-
-@app.middleware("http")
-async def request_logging_middleware(request: Request, call_next):
- req_id = request.headers.get("X-Request-ID", str(uuid.uuid4())[:8])
- token = request_id_ctx.set(req_id)
- started = time.time()
- client_ip = request.client.host if request.client else "-"
- user_agent = request.headers.get("user-agent", "-")
- try:
- response = await call_next(request)
- except Exception:
- log_event(
- "request_failed",
- level=logging.ERROR,
- method=request.method,
- path=request.url.path,
- client_ip=client_ip,
- user_agent=user_agent,
- )
- request_id_ctx.reset(token)
- raise
- duration_ms = int((time.time() - started) * 1000)
- level = logging.INFO
- if response.status_code >= 500:
- level = logging.ERROR
- elif response.status_code >= 400:
- level = logging.WARNING
- log_event(
- "request",
- level=level,
- method=request.method,
- path=request.url.path,
- query=str(request.url.query or ""),
- status=response.status_code,
- duration_ms=duration_ms,
- client_ip=client_ip,
- user_agent=user_agent,
- )
- if duration_ms >= LOG_SLOW_REQUEST_MS:
- log_event(
- "slow_request",
- level=logging.WARNING,
- method=request.method,
- path=request.url.path,
- duration_ms=duration_ms,
- threshold_ms=LOG_SLOW_REQUEST_MS,
- )
- response.headers["X-Request-ID"] = req_id
- request_id_ctx.reset(token)
- return response
-
-
-class Base(DeclarativeBase):
- pass
-
-
-class ServiceType(str, enum.Enum):
- WEB = "WEB"
- VNC = "VNC"
- RDP = "RDP"
-
-
-class SessionStatus(str, enum.Enum):
- ACTIVE = "ACTIVE"
- EXPIRED = "EXPIRED"
- TERMINATED = "TERMINATED"
- ROTATED = "ROTATED"
-
-
-class User(Base):
- __tablename__ = "users"
-
- id: Mapped[int] = mapped_column(Integer, primary_key=True)
- username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
- password_hash: Mapped[str] = mapped_column(String(255))
- expires_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), index=True)
- active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
- is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
- created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
-
-
-class Service(Base):
- __tablename__ = "services"
-
- id: Mapped[int] = mapped_column(Integer, primary_key=True)
- name: Mapped[str] = mapped_column(String(128))
- slug: Mapped[str] = mapped_column(String(64), unique=True, index=True)
- type: Mapped[ServiceType] = mapped_column(Enum(ServiceType), index=True)
- target: Mapped[str] = mapped_column(Text)
- comment: Mapped[str] = mapped_column(Text, default="")
- icon_path: Mapped[str] = mapped_column(Text, default="")
- active: Mapped[bool] = mapped_column(Boolean, default=True)
- warm_pool_size: Mapped[int] = mapped_column(Integer, default=0)
- created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
-
-
-class Category(Base):
- __tablename__ = "categories"
-
- id: Mapped[int] = mapped_column(Integer, primary_key=True)
- name: Mapped[str] = mapped_column(String(128), unique=True, index=True)
- slug: Mapped[str] = mapped_column(String(64), unique=True, index=True)
- created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
-
-
-class ServiceCategory(Base):
- __tablename__ = "service_categories"
- __table_args__ = (UniqueConstraint("service_id", "category_id", name="uq_service_category"),)
-
- id: Mapped[int] = mapped_column(Integer, primary_key=True)
- service_id: Mapped[int] = mapped_column(ForeignKey("services.id", ondelete="CASCADE"), index=True)
- category_id: Mapped[int] = mapped_column(ForeignKey("categories.id", ondelete="CASCADE"), index=True)
- created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
-
-
-class UserServiceAccess(Base):
- __tablename__ = "user_service_access"
- __table_args__ = (UniqueConstraint("user_id", "service_id", name="uq_user_service"),)
-
- id: Mapped[int] = mapped_column(Integer, primary_key=True)
- user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
- service_id: Mapped[int] = mapped_column(ForeignKey("services.id", ondelete="CASCADE"), index=True)
- granted_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
-
-
-class SessionModel(Base):
- __tablename__ = "sessions"
-
- id: Mapped[str] = mapped_column(String(36), primary_key=True)
- user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
- service_id: Mapped[int] = mapped_column(ForeignKey("services.id", ondelete="CASCADE"), index=True)
- status: Mapped[SessionStatus] = mapped_column(Enum(SessionStatus), default=SessionStatus.ACTIVE, index=True)
- created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc), index=True)
- last_access_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc), index=True)
- container_id: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
-
-
-class AuditLog(Base):
- __tablename__ = "audit_logs"
-
- id: Mapped[int] = mapped_column(Integer, primary_key=True)
- user_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
- action: Mapped[str] = mapped_column(String(128), index=True)
- details: Mapped[str] = mapped_column(Text)
- created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc), index=True)
-
-
-def now_utc() -> dt.datetime:
- return dt.datetime.now(dt.timezone.utc)
-
-
-def session_closed_reason(sess: SessionModel, db: Session) -> str:
- if not sess:
- return "idle"
- if sess.status == SessionStatus.EXPIRED:
- return "idle"
- if sess.status == SessionStatus.ROTATED:
- return "limit"
- if sess.status == SessionStatus.TERMINATED:
- cutoff = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS)
- active_rows = db.scalars(
- select(SessionModel).where(
- SessionModel.user_id == sess.user_id,
- SessionModel.status == SessionStatus.ACTIVE,
- SessionModel.last_access_at >= cutoff,
- )
- ).all()
- active_service_ids = {row.service_id for row in active_rows}
- if len(active_service_ids) >= MAX_ACTIVE_SERVICES_PER_USER and sess.service_id not in active_service_ids:
- return "limit"
- return "idle"
-
-
-def normalize_web_target(url: str) -> str:
- raw = (url or "").strip()
- if not raw:
- return raw
- if raw.startswith(("http://", "https://")):
- return 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"\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:
- raise HTTPException(status_code=400, detail="Empty RDP target")
-
- parsed = urlparse(raw if "://" in raw else f"//{raw}")
- host = parsed.hostname
- if not host:
- raise HTTPException(status_code=400, detail="Invalid RDP target. Use host:port or rdp://user:pass@host:port")
- port = parsed.port or 3389
-
- username = unquote(parsed.username) if parsed.username else ""
- password = unquote(parsed.password) if parsed.password else ""
-
- query = parse_qs(parsed.query or "")
- if not username:
- username = (query.get("u", [""])[0] or query.get("user", [""])[0] or "").strip()
- if not password:
- password = (query.get("p", [""])[0] or query.get("password", [""])[0] or "").strip()
-
- domain = (query.get("domain", [""])[0] or query.get("d", [""])[0] or "").strip()
- security = (query.get("sec", [""])[0] or query.get("security", [""])[0] or "").strip().lower()
- if security and security not in {"nla", "tls", "rdp"}:
- raise HTTPException(status_code=400, detail="Invalid RDP security. Use one of: nla, tls, rdp")
-
- return {
- "host": host,
- "port": str(port),
- "user": username,
- "password": password,
- "domain": domain,
- "security": security,
- }
-
-
-def set_service_categories(db: Session, service_id: int, category_ids: list[int]) -> None:
- normalized = sorted({int(x) for x in (category_ids or [])})
- if normalized:
- existing_ids = set(db.scalars(select(Category.id).where(Category.id.in_(normalized))).all())
- missing = sorted(set(normalized) - existing_ids)
- if missing:
- raise HTTPException(status_code=400, detail=f"Unknown category ids: {missing}")
-
- existing_links = db.scalars(select(ServiceCategory).where(ServiceCategory.service_id == service_id)).all()
- current = {row.category_id: row for row in existing_links}
- wanted = set(normalized)
-
- for cat_id in wanted:
- if cat_id not in current:
- db.add(ServiceCategory(service_id=service_id, category_id=cat_id))
- for cat_id, row in current.items():
- if cat_id not in wanted:
- db.delete(row)
-
-
-def service_uses_universal_pool(service: Service) -> bool:
- return UNIVERSAL_POOL_SIZE > 0 and service.type == ServiceType.RDP
-
-
-def universal_container_name(slot: int) -> str:
- return f"portal-universal-{slot}"
-
-
-def web_pool_container_name(slot: int) -> str:
- return f"portal-webpool-{slot}"
-
-
-def ensure_icons_dir() -> None:
- SERVICE_ICONS_DIR.mkdir(parents=True, exist_ok=True)
-
-
-def remove_icon_file(icon_path: str) -> None:
- if not icon_path or not icon_path.startswith("/static/service-icons/"):
- return
- filename = icon_path.rsplit("/", 1)[-1]
- candidate = SERVICE_ICONS_DIR / filename
- try:
- candidate.unlink(missing_ok=True)
- except Exception:
- logger.exception("icon_delete_failed path=%s", candidate)
-
-
-async def store_service_icon(service: Service, upload: UploadFile) -> str:
- ensure_icons_dir()
- content_type = (upload.content_type or "").lower().strip()
- ext = ICON_UPLOAD_TYPES.get(content_type)
- if not ext:
- raise HTTPException(status_code=400, detail="Unsupported file type. Use PNG/JPG/WEBP")
-
- payload = await upload.read(ICON_UPLOAD_MAX_BYTES + 1)
- if len(payload) > ICON_UPLOAD_MAX_BYTES:
- raise HTTPException(status_code=400, detail="File too large. Max 2MB")
- if not payload:
- raise HTTPException(status_code=400, detail="Empty file")
-
- stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%d_%H%M%S")
- filename = f"svc_{service.id}_{stamp}.{ext}"
- target = SERVICE_ICONS_DIR / filename
- target.write_bytes(payload)
- return f"/static/service-icons/{filename}"
-
-
-def get_db():
- db = SessionLocal()
- try:
- yield db
- finally:
- db.close()
-
-
-def audit(db: Session, action: str, details: str, user_id: Optional[int] = None) -> None:
- db.add(AuditLog(user_id=user_id, action=action, details=details))
- db.commit()
-
-
-def hash_password(password: str) -> str:
- return pwd_context.hash(password)
-
-
-def verify_password(password: str, password_hash: str) -> bool:
- return pwd_context.verify(password, password_hash)
-
-
-def user_is_valid(user: User) -> bool:
- return bool(user.active and user.expires_at > now_utc())
-
-
-def issue_auth_cookie(response: RedirectResponse, user: User) -> None:
- token = serializer.dumps({"user_id": user.id})
- response.set_cookie(
- key=COOKIE_NAME,
- value=token,
- httponly=True,
- secure=True,
- samesite="strict",
- max_age=COOKIE_MAX_AGE,
- path="/",
- )
-
-
-def issue_csrf_cookie(response: RedirectResponse) -> str:
- token = secrets.token_urlsafe(24)
- response.set_cookie(
- key=CSRF_COOKIE,
- value=token,
- httponly=False,
- secure=True,
- samesite="strict",
- max_age=COOKIE_MAX_AGE,
- path="/",
- )
- return token
-
-
-def get_current_user(request: Request, db: Session = Depends(get_db)) -> Optional[User]:
- raw = request.cookies.get(COOKIE_NAME)
- if not raw:
- return None
- try:
- payload = serializer.loads(raw, max_age=COOKIE_MAX_AGE)
- except BadSignature:
- return None
- user = db.get(User, int(payload["user_id"]))
- if not user or not user_is_valid(user):
- return None
- return user
-
-
-def require_user(user: Optional[User] = Depends(get_current_user)) -> User:
- if not user:
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized")
- return user
-
-
-def require_admin(user: User = Depends(require_user)) -> User:
- if not user.is_admin:
- raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin only")
- return user
-
-
-def validate_csrf(request: Request) -> None:
- cookie = request.cookies.get(CSRF_COOKIE)
- form_val = request.headers.get("X-CSRF-Token")
- if request.headers.get("content-type", "").startswith("application/x-www-form-urlencoded"):
- return
- if not cookie or not form_val or cookie != form_val:
- raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="CSRF failed")
-
-
-def has_access(db: Session, user_id: int, service_id: int) -> bool:
- q = select(UserServiceAccess).where(
- UserServiceAccess.user_id == user_id,
- UserServiceAccess.service_id == service_id,
- )
- return db.scalar(q) is not None
-
-
-def docker_client():
- return docker.from_env()
-
-
-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
- d = docker_client()
- image = "portal-universal-runtime:latest"
-
- for i in range(UNIVERSAL_POOL_SIZE, 100):
- name = universal_container_name(i)
- try:
- c = d.containers.get(name)
- c.stop(timeout=5)
- except docker.errors.NotFound:
- break
- except Exception:
- logger.exception("universal_pool_scale_down_failed slot=%s", i)
-
- for i in range(UNIVERSAL_POOL_SIZE):
- name = universal_container_name(i)
- path = f"/u/{i}/"
- router = f"upool-{i}"
- labels = {
- "traefik.enable": "true",
- "traefik.docker.network": "portal_net",
- f"traefik.http.routers.{router}.rule": f"PathPrefix(`{path}`)",
- f"traefik.http.routers.{router}.entrypoints": "websecure",
- f"traefik.http.routers.{router}.tls": "true",
- f"traefik.http.routers.{router}.priority": "9400",
- f"traefik.http.routers.{router}.middlewares": f"{router}-strip",
- f"traefik.http.middlewares.{router}-strip.stripprefix.prefixes": path[:-1],
- f"traefik.http.services.{router}.loadbalancer.server.port": "6080",
- "portal.pool": "1",
- "portal.pool.slot": str(i),
- }
- env = {
- "IDLE_TIMEOUT": str(SESSION_IDLE_SECONDS),
- "ENABLE_HEARTBEAT": "0",
- "SESSION_ID": f"universal-{i}",
- }
- try:
- c = d.containers.get(name)
- if c.status != "running":
- c.start()
- continue
- except docker.errors.NotFound:
- pass
- except Exception:
- logger.exception("universal_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("universal_pool_container_started slot=%s", i)
-
-
-def ensure_web_pool(target_size: Optional[int] = None) -> None:
- desired = max(0, WEB_POOL_SIZE if target_size is None else target_size)
- d = docker_client()
- image = "portal-universal-runtime:latest"
-
- for i in range(desired, 100):
- name = web_pool_container_name(i)
- try:
- c = d.containers.get(name)
- c.stop(timeout=5)
- except docker.errors.NotFound:
- break
- except Exception:
- logger.exception("web_pool_scale_down_failed slot=%s", i)
-
- for i in range(desired):
- name = web_pool_container_name(i)
- path = f"/w/{i}/"
- router = f"wpool-{i}"
- labels = {
- "traefik.enable": "true",
- "traefik.docker.network": "portal_net",
- f"traefik.http.routers.{router}.rule": f"PathPrefix(`{path}`)",
- f"traefik.http.routers.{router}.entrypoints": "websecure",
- f"traefik.http.routers.{router}.tls": "true",
- f"traefik.http.routers.{router}.priority": "9450",
- f"traefik.http.routers.{router}.middlewares": f"{router}-strip",
- f"traefik.http.middlewares.{router}-strip.stripprefix.prefixes": path[:-1],
- f"traefik.http.services.{router}.loadbalancer.server.port": "6080",
- "portal.pool": "1",
- "portal.pool.kind": "web",
- "portal.pool.slot": str(i),
- }
- env = {
- "IDLE_TIMEOUT": str(SESSION_IDLE_SECONDS),
- "ENABLE_HEARTBEAT": "0",
- "SESSION_ID": f"webpool-{i}",
- }
- should_create = False
- try:
- c = d.containers.get(name)
- if c.status != "running":
- 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:
- should_create = True
- except Exception:
- logger.exception("web_pool_check_failed slot=%s", i)
- continue
-
- 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:
- desired = max(0, UNIVERSAL_POOL_SIZE)
- if desired <= 0:
- return {"desired": 0, "running": 0, "total": 0, "health": "down", "names": []}
- d = docker_client()
- names = [universal_container_name(i) for i in range(desired)]
- containers = []
- for name in names:
- try:
- containers.append(d.containers.get(name))
- except Exception:
- continue
- running = sum(1 for c in containers if c.status == "running")
- health = "ok" if running >= min(desired, 1) else "down"
- return {
- "desired": desired,
- "running": running,
- "total": len(containers),
- "names": sorted(c.name for c in containers),
- "health": health,
- }
-
-
-def get_web_pool_status() -> dict:
- desired = max(0, WEB_POOL_SIZE)
- if desired <= 0:
- return {"desired": 0, "running": 0, "total": 0, "health": "down", "names": []}
- d = docker_client()
- names = [web_pool_container_name(i) for i in range(desired)]
- containers = []
- for name in names:
- try:
- containers.append(d.containers.get(name))
- except Exception:
- continue
- running = sum(1 for c in containers if c.status == "running")
- health = "ok" if running >= min(desired, 1) else "down"
- return {
- "desired": desired,
- "running": running,
- "total": len(containers),
- "names": sorted(c.name for c in containers),
- "health": health,
- }
-
-
-def acquire_universal_slot(db: Session) -> int:
- cutoff = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS)
- q = select(SessionModel).where(
- SessionModel.status == SessionStatus.ACTIVE,
- SessionModel.container_id.like("POOLIDX:%"),
- SessionModel.last_access_at >= cutoff,
- )
- active = db.scalars(q).all()
- busy = set()
- for sess in active:
- try:
- busy.add(int((sess.container_id or "").split(":", 1)[1]))
- except Exception:
- continue
- for i in range(max(0, UNIVERSAL_POOL_SIZE)):
- if i not in busy:
- return i
- if active:
- victim = min(active, key=lambda s: s.last_access_at)
- victim.status = SessionStatus.TERMINATED
- db.commit()
- try:
- return int((victim.container_id or "").split(":", 1)[1])
- except Exception:
- pass
- return 0
-
-
-def acquire_web_pool_slot(db: Session) -> int:
- cutoff = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS)
- q = select(SessionModel).where(
- SessionModel.status == SessionStatus.ACTIVE,
- SessionModel.container_id.like("WEBPOOLIDX:%"),
- SessionModel.last_access_at >= cutoff,
- )
- active = db.scalars(q).all()
- busy = set()
- for sess in active:
- try:
- busy.add(int((sess.container_id or "").split(":", 1)[1]))
- except Exception:
- continue
-
- # Keep headroom: when active sessions are close to hot pool capacity,
- # proactively warm up extra slots.
- auto_target = max(WEB_POOL_SIZE, len(active) + max(0, WEB_POOL_BUFFER))
- if auto_target > WEB_POOL_SIZE:
- ensure_web_pool(auto_target)
-
- for i in range(max(0, auto_target)):
- if i not in busy:
- return i
- return 0
-
-
-def dispatch_universal_target(slot: int, service: Service) -> None:
- name = universal_container_name(slot)
- url = ""
- payload = {}
- if service.type == ServiceType.WEB:
- url = f"http://{name}:7000/open"
- payload = {"url": normalize_web_target(service.target)}
- elif service.type == ServiceType.RDP:
- cfg = parse_rdp_target(service.target)
- url = f"http://{name}:7000/rdp"
- payload = {
- "host": cfg["host"],
- "port": cfg["port"],
- "user": cfg["user"],
- "password": cfg["password"],
- "domain": cfg["domain"],
- "security": cfg["security"],
- }
- else:
- raise HTTPException(status_code=400, detail="Universal pool supports WEB/RDP only")
-
- last_exc = None
- for _ in range(max(1, POOL_DISPATCH_RETRIES)):
- try:
- resp = requests.post(url, json=payload, timeout=POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS)
- resp.raise_for_status()
- return
- except Exception as exc:
- last_exc = exc
- time.sleep(max(0.0, POOL_DISPATCH_SLEEP_SECONDS))
- if last_exc:
- raise last_exc
-
-
-def dispatch_web_pool_target(slot: int, service: Service) -> None:
- name = web_pool_container_name(slot)
- target_url = normalize_web_target(service.target)
- url = f"http://{name}:7000/open"
- last_exc = None
- for _ in range(max(1, POOL_DISPATCH_RETRIES)):
- try:
- resp = requests.post(url, json={"url": target_url}, timeout=POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS)
- resp.raise_for_status()
- return
- except Exception as exc:
- last_exc = exc
- time.sleep(max(0.0, POOL_DISPATCH_SLEEP_SECONDS))
- if last_exc:
- raise last_exc
-
-
-def create_runtime_container(service: Service, session_id: str):
- d = docker_client()
- router = session_router_name(session_id)
- path = f"/s/{session_id}/"
- labels = {
- "traefik.enable": "true",
- "traefik.docker.network": "portal_net",
- f"traefik.http.routers.{router}.rule": f"PathPrefix(`{path}`)",
- f"traefik.http.routers.{router}.entrypoints": "websecure",
- f"traefik.http.routers.{router}.tls": "true",
- f"traefik.http.routers.{router}.priority": "10000",
- f"traefik.http.routers.{router}.middlewares": f"{router}-strip",
- f"traefik.http.middlewares.{router}-strip.stripprefix.prefixes": path[:-1],
- f"traefik.http.services.{router}.loadbalancer.server.port": "6080",
- }
-
- env = {
- "SESSION_ID": session_id,
- "IDLE_TIMEOUT": str(SESSION_IDLE_SECONDS),
- "ENABLE_HEARTBEAT": "1",
- "TOUCH_PATH": f"/api/sessions/{session_id}/touch",
- }
- image = "portal-kiosk:latest"
-
- if service.type == ServiceType.WEB:
- env["TARGET_URL"] = service.target
- env["HOME_URL"] = f"https://{PUBLIC_HOST}/"
- elif service.type == ServiceType.RDP:
- image = "portal-rdp-proxy:latest"
- cfg = parse_rdp_target(service.target)
- env["RDP_HOST"] = cfg["host"]
- env["RDP_PORT"] = cfg["port"]
- if cfg["user"]:
- env["RDP_USER"] = cfg["user"]
- if cfg["password"]:
- env["RDP_PASSWORD"] = cfg["password"]
- if cfg["domain"]:
- env["RDP_DOMAIN"] = cfg["domain"]
- if cfg["security"]:
- env["RDP_SECURITY"] = cfg["security"]
- else:
- raise HTTPException(status_code=400, detail="Unsupported service type")
-
- container = d.containers.run(
- image=image,
- name=f"portal-sess-{session_id[:8]}",
- detach=True,
- auto_remove=True,
- network="portal_net",
- labels=labels,
- environment=env,
- )
- logger.info("session_container_started session_id=%s container_id=%s service_type=%s", session_id, container.id, service.type.value)
- return container.id
-
-
-def ensure_warm_pool(service: Service, pool_size: Optional[int] = None) -> None:
- if service_uses_universal_pool(service):
- return
- if pool_size is None:
- pool_size = desired_pool_size(service)
- if pool_size <= 0:
- # Stop stale warm containers for this service when pool is disabled.
- prefix = f"portal-warm-{service.slug}-"
- try:
- d = docker_client()
- for c in d.containers.list(all=True, filters={"name": prefix}):
- if c.name.startswith(prefix):
- c.stop(timeout=5)
- except Exception:
- logger.exception("warm_pool_disable_failed service=%s", service.slug)
- return
- d = docker_client()
- router = f"warm-{service.slug}"
- svc_name = f"warmsvc-{service.slug}"
- path = f"/svc/{service.slug}/"
- image = "portal-kiosk:latest"
- base_env = {
- "IDLE_TIMEOUT": str(SESSION_IDLE_SECONDS),
- "ENABLE_HEARTBEAT": "0",
- "TOUCH_PATH": "",
- }
- if service.type == ServiceType.WEB:
- base_env["UNIVERSAL_WEB"] = "1"
- base_env["START_URL"] = normalize_web_target(service.target)
- base_env["HOME_URL"] = f"https://{PUBLIC_HOST}/"
- elif service.type == ServiceType.RDP:
- image = "portal-rdp-proxy:latest"
- cfg = parse_rdp_target(service.target)
- base_env["RDP_HOST"] = cfg["host"]
- base_env["RDP_PORT"] = cfg["port"]
- if cfg["user"]:
- base_env["RDP_USER"] = cfg["user"]
- if cfg["password"]:
- base_env["RDP_PASSWORD"] = cfg["password"]
- if cfg["domain"]:
- base_env["RDP_DOMAIN"] = cfg["domain"]
- if cfg["security"]:
- base_env["RDP_SECURITY"] = cfg["security"]
- else:
- raise HTTPException(status_code=400, detail="Unsupported service type")
-
- labels = {
- "traefik.enable": "true",
- "traefik.docker.network": "portal_net",
- f"traefik.http.routers.{router}.rule": f"PathPrefix(`{path}`)",
- f"traefik.http.routers.{router}.entrypoints": "websecure",
- f"traefik.http.routers.{router}.tls": "true",
- f"traefik.http.routers.{router}.priority": "9500",
- f"traefik.http.routers.{router}.middlewares": f"{router}-strip",
- f"traefik.http.middlewares.{router}-strip.stripprefix.prefixes": path[:-1],
- f"traefik.http.services.{svc_name}.loadbalancer.server.port": "6080",
- f"traefik.http.routers.{router}.service": svc_name,
- "portal.warm": "1",
- "portal.service.slug": service.slug,
- "portal.service.type": service.type.value,
- }
-
- # Ensure desired cardinality.
- for i in range(pool_size, 50):
- name = f"portal-warm-{service.slug}-{i}"
- try:
- c = d.containers.get(name)
- c.stop(timeout=5)
- except docker.errors.NotFound:
- break
- except Exception:
- logger.exception("warm_pool_scale_down_failed service=%s idx=%s", service.slug, i)
-
- for i in range(pool_size):
- name = f"portal-warm-{service.slug}-{i}"
- try:
- c = d.containers.get(name)
- if c.status != "running":
- c.start()
- continue
- except docker.errors.NotFound:
- pass
- except Exception:
- logger.exception("warm_pool_check_failed service=%s idx=%s", service.slug, i)
- continue
-
- env = dict(base_env)
- env["SESSION_ID"] = f"warm-{service.slug}-{i}"
- d.containers.run(
- image=image,
- name=name,
- detach=True,
- auto_remove=True,
- network="portal_net",
- labels=labels,
- environment=env,
- )
- logger.info("warm_pool_container_started service=%s idx=%s", service.slug, i)
-
-
-def wait_for_session_route(session_id: str, timeout_seconds: int = 6) -> bool:
- target = f"{TRAEFIK_INTERNAL_URL}/s/{session_id}/"
- deadline = time.time() + timeout_seconds
- while time.time() < deadline:
- try:
- resp = requests.get(
- target,
- headers={"Host": PUBLIC_HOST},
- allow_redirects=False,
- timeout=1.5,
- )
- if resp.status_code != 404:
- return True
- except Exception:
- pass
- time.sleep(0.3)
- return False
-
-
-def route_ready(path: str) -> bool:
- bases = [TRAEFIK_INTERNAL_URL]
- if TRAEFIK_INTERNAL_URL.startswith("http://"):
- bases.append("https://" + TRAEFIK_INTERNAL_URL[len("http://"):])
- for base in bases:
- try:
- verify = not base.startswith("https://")
- resp = requests.get(
- f"{base}{path}",
- headers={"Host": PUBLIC_HOST},
- allow_redirects=False,
- timeout=1.5,
- verify=verify,
- )
- if resp.status_code != 404:
- return True
- except Exception:
- continue
- return False
-
-
-def container_running(container_id: Optional[str]) -> bool:
- if not container_id:
- return False
- if (
- container_id.startswith("POOL:")
- or container_id.startswith("POOLIDX:")
- or container_id.startswith("WEBPOOLIDX:")
- ):
- return True
- try:
- c = docker_client().containers.get(container_id)
- return c.status == "running"
- except Exception:
- return False
-
-
-def stop_runtime_container(container_id: Optional[str]) -> None:
- if not container_id:
- return
- try:
- d = docker_client()
- c = d.containers.get(container_id)
- c.stop(timeout=5)
- except Exception:
- 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
- old_status = sess.status
- 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()
- log_event(
- "session_closed",
- level=logging.INFO,
- session_id=sess.id,
- user_id=sess.user_id,
- service_id=sess.service_id,
- container_id=cid,
- old_status=old_status.value if isinstance(old_status, SessionStatus) else str(old_status),
- new_status=new_status.value,
- reason=session_closed_reason(sess, db),
- stop_container=stop_container,
- )
-
-
-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:
- conn.execute(
- text(
- """
- DO $$
- BEGIN
- BEGIN
- ALTER TYPE servicetype ADD VALUE IF NOT EXISTS 'RDP';
- EXCEPTION WHEN undefined_object THEN
- NULL;
- END;
- END $$;
- """
- )
- )
- conn.execute(
- text(
- """
- DO $$
- BEGIN
- BEGIN
- ALTER TYPE sessionstatus ADD VALUE IF NOT EXISTS 'ROTATED';
- EXCEPTION WHEN undefined_object THEN
- NULL;
- END;
- END $$;
- """
- )
- )
-
- with engine.begin() as conn:
- conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS warm_pool_size INTEGER NOT NULL DEFAULT 0"))
- conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS comment TEXT NOT NULL DEFAULT ''"))
- conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS icon_path TEXT NOT NULL DEFAULT ''"))
- conn.execute(
- text(
- """
- CREATE TABLE IF NOT EXISTS categories (
- id SERIAL PRIMARY KEY,
- name VARCHAR(128) NOT NULL UNIQUE,
- slug VARCHAR(64) NOT NULL UNIQUE,
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
- )
- """
- )
- )
- conn.execute(
- text(
- """
- CREATE TABLE IF NOT EXISTS service_categories (
- id SERIAL PRIMARY KEY,
- service_id INT NOT NULL REFERENCES services(id) ON DELETE CASCADE,
- category_id INT NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
- UNIQUE (service_id, category_id)
- )
- """
- )
- )
- conn.execute(text("CREATE INDEX IF NOT EXISTS idx_service_categories_service_id ON service_categories(service_id)"))
- conn.execute(text("CREATE INDEX IF NOT EXISTS idx_service_categories_category_id ON service_categories(category_id)"))
- # Handle installs where service type is VARCHAR + CHECK.
- conn.execute(
- text(
- """
- DO $$
- DECLARE c record;
- BEGIN
- FOR c IN
- SELECT conname
- FROM pg_constraint
- WHERE conrelid = 'services'::regclass
- AND contype = 'c'
- AND pg_get_constraintdef(oid) ILIKE '%type%'
- LOOP
- EXECUTE format('ALTER TABLE services DROP CONSTRAINT %I', c.conname);
- END LOOP;
- ALTER TABLE services
- ADD CONSTRAINT services_type_check
- CHECK (type IN ('WEB','VNC','RDP'));
- EXCEPTION WHEN duplicate_object THEN
- NULL;
- END $$;
- """
- )
- )
-
-
-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
-
-
-def get_warm_containers_for_service(service: Service) -> list:
- prefix = f"portal-warm-{service.slug}-"
- try:
- d = docker_client()
- containers = []
- for c in d.containers.list(all=True, filters={"name": prefix}):
- if c.name.startswith(prefix):
- containers.append(c)
- return containers
- except Exception:
- logger.exception("pool_status_failed service=%s", service.slug)
- return []
-
-
-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)
- containers = get_warm_containers_for_service(service)
- running = sum(1 for c in containers if c.status == "running")
- states = [(c.attrs.get("State") or {}).get("Status", c.status) for c in containers]
- has_bad = any(s in {"exited", "dead"} for s in states)
- total = len(containers)
- if running == 0:
- health = "down"
- elif running >= min(desired, 1) and not has_bad:
- health = "ok"
- else:
- health = "degraded"
- return {
- "desired": desired,
- "running": running,
- "total": total,
- "names": sorted(c.name for c in containers),
- "health": health,
- }
-
-
-def get_pool_detailed_status(service: Service) -> dict:
- if service.type == ServiceType.WEB:
- d = docker_client()
- pool = get_web_pool_status()
- details = []
- for i in range(max(0, pool["desired"])):
- name = web_pool_container_name(i)
- try:
- c = d.containers.get(name)
- except Exception:
- continue
- attrs = c.attrs or {}
- state = (attrs.get("State") or {}).get("Status", c.status)
- details.append(
- {
- "name": c.name,
- "status": c.status,
- "state": state,
- "created": attrs.get("Created", ""),
- "image": c.image.tags[0] if c.image.tags else "",
- "labels_ok": True,
- }
- )
- return {
- "service_id": service.id,
- "slug": service.slug,
- "type": service.type.value,
- "desired": pool["desired"],
- "running": pool["running"],
- "total": pool["total"],
- "health": pool["health"],
- "containers": details,
- "updated_at": now_utc().isoformat(),
- }
- if service_uses_universal_pool(service):
- d = docker_client()
- pool = get_universal_pool_status()
- details = []
- for i in range(max(0, UNIVERSAL_POOL_SIZE)):
- name = universal_container_name(i)
- try:
- c = d.containers.get(name)
- except Exception:
- continue
- attrs = c.attrs or {}
- state = (attrs.get("State") or {}).get("Status", c.status)
- details.append(
- {
- "name": c.name,
- "status": c.status,
- "state": state,
- "created": attrs.get("Created", ""),
- "image": c.image.tags[0] if c.image.tags else "",
- "labels_ok": True,
- }
- )
- return {
- "service_id": service.id,
- "slug": service.slug,
- "type": service.type.value,
- "desired": pool["desired"],
- "running": pool["running"],
- "total": pool["total"],
- "health": pool["health"],
- "containers": details,
- "updated_at": now_utc().isoformat(),
- }
- containers = get_warm_containers_for_service(service)
- pool = get_pool_status_for_service(service)
- details = []
- for c in sorted(containers, key=lambda x: x.name):
- attrs = c.attrs or {}
- state = (attrs.get("State") or {}).get("Status", c.status)
- created = attrs.get("Created", "")
- labels = attrs.get("Config", {}).get("Labels", {}) or {}
- labels_ok = (
- labels.get("portal.warm") == "1"
- and labels.get("portal.service.slug") == service.slug
- and labels.get("portal.service.type") == service.type.value
- )
- details.append(
- {
- "name": c.name,
- "status": c.status,
- "state": state,
- "created": created,
- "image": c.image.tags[0] if c.image.tags else "",
- "labels_ok": labels_ok,
- }
- )
- return {
- "service_id": service.id,
- "slug": service.slug,
- "type": service.type.value,
- "desired": pool["desired"],
- "running": pool["running"],
- "total": pool["total"],
- "health": pool["health"],
- "containers": details,
- "updated_at": now_utc().isoformat(),
- }
-
-
-def get_active_sessions_count(db: Session, service_id: int) -> int:
- 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,
- )
- 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]:
- 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 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()
-
-
-class LockTimeoutError(Exception):
- pass
-
-
-def allocator_lock(db: Session, lock_id: int, timeout_seconds: Optional[float] = None, poll_seconds: float = 0.05):
- class _LockCtx:
- def __enter__(self_nonlocal):
- self_nonlocal._acquired = False
- if timeout_seconds is None:
- db.execute(text("SELECT pg_advisory_xact_lock(:lid)"), {"lid": lock_id})
- self_nonlocal._acquired = True
- return self_nonlocal
-
- deadline = time.monotonic() + max(0.0, timeout_seconds)
- while time.monotonic() <= deadline:
- got = db.execute(text("SELECT pg_try_advisory_lock(:lid)"), {"lid": lock_id}).scalar()
- if got:
- self_nonlocal._acquired = True
- return self_nonlocal
- time.sleep(max(0.01, poll_seconds))
- raise LockTimeoutError(f"advisory lock timeout lock_id={lock_id} timeout={timeout_seconds}")
-
- return self_nonlocal
-
- def __exit__(self_nonlocal, exc_type, exc, tb):
- if getattr(self_nonlocal, "_acquired", False):
- 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:"):
- 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
- if service.type != ServiceType.WEB:
- return
- target_url = normalize_web_target(target_url)
- try:
- d = docker_client()
- containers = d.containers.list(
- filters={
- "label": [
- "portal.warm=1",
- f"portal.service.slug={service.slug}",
- "portal.service.type=WEB",
- ]
- }
- )
- for c in containers:
- try:
- resp = requests.post(
- f"http://{c.name}:7000/open",
- json={"url": target_url},
- timeout=2,
- )
- resp.raise_for_status()
- logger.info("warm_web_open_ok service=%s container=%s url=%s", service.slug, c.name, target_url)
- except Exception:
- logger.exception("warm_web_open_failed service=%s container=%s", service.slug, c.name)
- except Exception:
- logger.exception("warm_web_open_dispatch_failed service=%s", service.slug)
-
-
-def cleanup_loop():
- while True:
- time.sleep(60)
- db = SessionLocal()
- try:
- ensure_universal_pool()
- ensure_web_pool()
- for svc in db.scalars(
- select(Service).where(
- Service.active == True,
- Service.type.in_([ServiceType.WEB, ServiceType.RDP]),
- )
- ).all():
- 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(
- SessionModel.status == SessionStatus.ACTIVE,
- SessionModel.last_access_at < cutoff,
- )
- stale = db.scalars(q).all()
- for sess in stale:
- if sess.container_id and not (
- sess.container_id.startswith("POOL:")
- or sess.container_id.startswith("POOLIDX:")
- or sess.container_id.startswith("WEBPOOLIDX:")
- ):
- stop_runtime_container(sess.container_id)
- sess.status = SessionStatus.EXPIRED
- if stale:
- db.commit()
- except Exception:
- db.rollback()
- logger.exception("cleanup_loop_failed")
- finally:
- db.close()
-
-
-def bootstrap_admin():
- admin_user = os.getenv("ADMIN_USERNAME", "admin")
- admin_password = os.getenv("ADMIN_PASSWORD", "admin123")
- ttl_days = int(os.getenv("ADMIN_TTL_DAYS", "3650"))
-
- db = SessionLocal()
- try:
- existing = db.scalar(select(User).where(User.username == admin_user))
- if not existing:
- db.add(
- User(
- username=admin_user,
- password_hash=hash_password(admin_password),
- active=True,
- is_admin=True,
- expires_at=now_utc() + dt.timedelta(days=ttl_days),
- )
- )
- db.commit()
- finally:
- db.close()
-
-
-@app.on_event("startup")
-def startup_event():
- # Multiple uvicorn workers run startup in parallel. Serialize schema bootstrap
- # to avoid DDL races on first run and during schema extension.
- with open("/tmp/portal-schema.lock", "w") as lock_file:
- fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
- Base.metadata.create_all(bind=engine)
- ensure_schema_compatibility()
- fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
- ensure_icons_dir()
- bootstrap_admin()
- db = SessionLocal()
- try:
- ensure_universal_pool()
- ensure_web_pool()
- for svc in db.scalars(
- select(Service).where(
- Service.active == True,
- Service.type.in_([ServiceType.WEB, ServiceType.RDP]),
- )
- ).all():
- if svc.type == ServiceType.WEB and WEB_POOL_SIZE <= 0:
- ensure_warm_pool(svc)
- finally:
- db.close()
- thread = threading.Thread(target=cleanup_loop, daemon=True)
- thread.start()
-
-
-@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 session_closed == "limit":
- session_notice = (
- f"Сессия была закрыта из-за лимита в {MAX_ACTIVE_SERVICES_PER_USER} сервиса(ов). "
- "Освободите один сервис и попробуйте снова."
- )
- 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(
- "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
-
- services = db.scalars(
- select(Service)
- .join(UserServiceAccess, UserServiceAccess.service_id == Service.id)
- .where(
- UserServiceAccess.user_id == user.id,
- Service.active == True,
- Service.type.in_([ServiceType.WEB, ServiceType.RDP]),
- )
- .order_by(Service.name)
- ).all()
-
- service_categories = {svc.id: [] for svc in services}
- categories = []
- if services:
- service_ids = [svc.id for svc in services]
- rows = db.execute(
- select(ServiceCategory.service_id, Category.id, Category.name, Category.slug)
- .join(Category, Category.id == ServiceCategory.category_id)
- .where(ServiceCategory.service_id.in_(service_ids))
- .order_by(Category.name)
- ).all()
- category_map = {}
- for service_id, category_id, category_name, category_slug in rows:
- service_categories.setdefault(service_id, []).append(
- {
- "id": category_id,
- "name": category_name,
- "slug": category_slug,
- }
- )
- if category_id not in category_map:
- category_map[category_id] = {"id": category_id, "name": category_name, "slug": category_slug}
- categories = sorted(category_map.values(), key=lambda x: x["name"].lower())
-
- selected_category_slug = (request.query_params.get("category") or "").strip().lower()
- if selected_category_slug:
- services = [
- 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",
- {
- "request": request,
- "user": user,
- "services": services,
- "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,
- },
- )
-
-
-@app.get("/admin", response_class=HTMLResponse)
-def admin_page(request: Request, admin: User = Depends(require_admin), db: Session = Depends(get_db)):
- users = db.scalars(select(User).order_by(User.id)).all()
- categories = db.scalars(select(Category).order_by(Category.name)).all()
- services = db.scalars(select(Service).where(Service.type.in_([ServiceType.WEB, ServiceType.RDP])).order_by(Service.id)).all()
- web_services = [s for s in services if s.type == ServiceType.WEB]
- rdp_services = [s for s in services if s.type == ServiceType.RDP]
- service_category_map = {s.id: [] for s in services}
- if services:
- service_rows = db.execute(
- select(ServiceCategory.service_id, ServiceCategory.category_id).where(
- ServiceCategory.service_id.in_([s.id for s in services])
- )
- ).all()
- for service_id, category_id in service_rows:
- service_category_map.setdefault(service_id, []).append(category_id)
- acl_rows = db.scalars(select(UserServiceAccess)).all()
- acl = {}
- for row in acl_rows:
- acl.setdefault(row.user_id, []).append(row.service_id)
- for user_id in acl:
- acl[user_id] = sorted(acl[user_id])
- pool_status = {s.id: get_pool_status_for_service(s) for s in services}
- service_health = {}
- for sid, st in pool_status.items():
- service_health[sid] = {
- "health": st["health"],
- "running": st["running"],
- "desired": st["desired"],
- "active_sessions": get_active_sessions_count(db, sid),
- }
- web_pool = get_web_pool_status()
- web_totals = {
- "services": len(web_services),
- "running": web_pool["running"],
- "desired": web_pool["desired"],
- "active_sessions": sum(service_health[s.id]["active_sessions"] for s in web_services),
- }
- recent_sessions = db.execute(
- text(
- """
- SELECT s.id, u.username, sv.name AS service_name, sv.slug AS service_slug,
- s.status, 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 sv.type IN ('WEB','RDP')
- ORDER BY s.created_at DESC
- LIMIT 200
- """
- )
- ).mappings().all()
- open_stats = db.execute(
- text(
- """
- SELECT u.username, sv.name AS service_name, sv.slug AS service_slug, COUNT(*) AS opens
- FROM sessions s
- JOIN users u ON u.id = s.user_id
- JOIN services sv ON sv.id = s.service_id
- WHERE sv.type IN ('WEB','RDP')
- GROUP BY u.username, sv.name, sv.slug
- ORDER BY opens DESC, u.username ASC
- LIMIT 200
- """
- )
- ).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",
- {
- "request": request,
- "admin": admin,
- "users": users,
- "web_services": web_services,
- "rdp_services": rdp_services,
- "services": services,
- "categories": categories,
- "service_category_map": service_category_map,
- "acl": acl,
- "pool_status": pool_status,
- "service_health": service_health,
- "web_totals": web_totals,
- "web_pool_size": WEB_POOL_SIZE,
- "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,
- },
- )
-
-
-@app.post("/login")
-def login(
- request: Request,
- username: str = Form(...),
- password: str = Form(...),
- csrf_token: str = Form(...),
- db: Session = Depends(get_db),
-):
- cookie_csrf = request.cookies.get(CSRF_COOKIE)
- if not cookie_csrf or csrf_token != cookie_csrf:
- raise HTTPException(status_code=403, detail="CSRF failed")
-
- user = db.scalar(select(User).where(User.username == username))
- if not user or not verify_password(password, user.password_hash):
- 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(
- "login.html",
- {
- "request": request,
- "csrf_token": csrf,
- "login_error": "Доступ к сервису приостоновлен, обратитесь к вашему менеджеру",
- },
- status_code=403,
- )
- response.set_cookie(CSRF_COOKIE, csrf, httponly=False, secure=True, samesite="strict", path="/")
- return response
-
- response = RedirectResponse(url="/", status_code=303)
- issue_auth_cookie(response, user)
- issue_csrf_cookie(response)
- audit(db, "LOGIN", f"login success: {username}", user_id=user.id)
- return response
-
-
-@app.post("/logout")
-def logout(request: Request):
- response = RedirectResponse(url="/", status_code=303)
- response.delete_cookie(COOKIE_NAME, path="/")
- response.delete_cookie(CSRF_COOKIE, path="/")
- return response
-
-
-@app.get("/go/{slug}")
-def go_service(slug: str, user: User = Depends(require_user), db: Session = Depends(get_db)):
- total_started = time.perf_counter()
- phase_ms = {}
-
- def _mark(name: str, started: float) -> None:
- phase_ms[name] = int((time.perf_counter() - started) * 1000)
-
- def _emit(result: str, **extra) -> None:
- payload = {
- "user_id": user.id,
- "service_slug": slug,
- "result": result,
- "total_ms": int((time.perf_counter() - total_started) * 1000),
- }
- payload.update(phase_ms)
- payload.update(extra)
- log_event("go_service_timing", **payload)
-
- log_event("session_open_requested", user_id=user.id, service_slug=slug)
- service = db.scalar(select(Service).where(Service.slug == slug, Service.active == True))
- if not service:
- raise HTTPException(status_code=404, detail="Service not found")
- if service.type == ServiceType.VNC:
- 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")
-
- user_lock_started = time.perf_counter()
- try:
- with allocator_lock(db, 92000 + int(user.id), timeout_seconds=GO_USER_LOCK_TIMEOUT_SECONDS):
- _mark("wait_user_lock_ms", user_lock_started)
-
- t_existing = time.perf_counter()
- existing_user_session = find_active_session_for_user_service(db, user.id, service.id)
- _mark("check_existing_ms", t_existing)
- if existing_user_session:
- _emit("reuse_session", session_id=existing_user_session.id)
- return RedirectResponse(url=session_redirect_url(existing_user_session), status_code=303)
-
- t_limit = time.perf_counter()
- 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}
- _mark("check_limit_ms", t_limit)
- 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:
- t_rotate = time.perf_counter()
- terminate_session_record(db, oldest, SessionStatus.ROTATED, stop_container=True)
- db.commit()
- _mark("rotate_oldest_ms", t_rotate)
- log_event(
- "session_rotated",
- user_id=user.id,
- closed_session_id=oldest.id,
- closed_service_id=oldest.service_id,
- new_service_id=service.id,
- )
- else:
- _emit("max_services_redirect")
- return RedirectResponse(url="/?launch_error=max_services", status_code=303)
-
- if service.type == ServiceType.RDP:
- t_rdp_owner = time.perf_counter()
- active_owner = find_active_session_for_service(db, service.id)
- _mark("check_rdp_owner_ms", t_rdp_owner)
- 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}"
- _emit("rdp_busy", owner=owner_name)
- raise HTTPException(
- status_code=409,
- detail=f"RDP сервис уже занят пользователем {owner_name}. Попробуйте позже.",
- )
- _emit("reuse_rdp_session", session_id=active_owner.id)
- 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:
- try:
- t_pool_lock = time.perf_counter()
- with allocator_lock(db, 91001, timeout_seconds=GO_POOL_LOCK_TIMEOUT_SECONDS):
- _mark("wait_web_pool_lock_ms", t_pool_lock)
- t_ensure = time.perf_counter()
- ensure_web_pool()
- _mark("ensure_web_pool_ms", t_ensure)
-
- t_acquire = time.perf_counter()
- slot = acquire_web_pool_slot(db)
- _mark("acquire_web_slot_ms", t_acquire)
- slot_cid = f"WEBPOOLIDX:{slot}"
-
- t_dispatch = time.perf_counter()
- terminate_active_slot_sessions(db, slot_cid)
- dispatch_web_pool_target(slot, service)
- _mark("dispatch_web_target_ms", t_dispatch)
-
- t_commit = time.perf_counter()
- 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()
- _mark("db_commit_ms", t_commit)
- except LockTimeoutError:
- _emit("web_pool_lock_timeout")
- raise HTTPException(status_code=503, detail="Пул WEB занят. Повторите через несколько секунд.")
- except Exception as exc:
- logger.exception("web_pool_dispatch_failed slug=%s user_id=%s", slug, user.id)
- log_event("session_create_failed", level=logging.ERROR, user_id=user.id, service_slug=slug, mode="web_pool", error=str(exc))
- audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id)
- _emit("web_pool_create_failed", error=str(exc))
- raise HTTPException(status_code=502, detail="WEB runtime failed to switch target")
- log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="web_pool", slot=slot)
- audit(db, "SESSION_CREATE_WEB_POOL", f"service={service.slug} session={session_id} slot={slot}", user_id=user.id)
- _emit("session_created_web_pool", session_id=session_id, slot=slot)
- return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
-
- if service_uses_universal_pool(service):
- try:
- t_pool_lock = time.perf_counter()
- with allocator_lock(db, 91002, timeout_seconds=GO_POOL_LOCK_TIMEOUT_SECONDS):
- _mark("wait_universal_pool_lock_ms", t_pool_lock)
- t_ensure = time.perf_counter()
- ensure_universal_pool()
- _mark("ensure_universal_pool_ms", t_ensure)
-
- t_acquire = time.perf_counter()
- slot = acquire_universal_slot(db)
- _mark("acquire_universal_slot_ms", t_acquire)
- slot_cid = f"POOLIDX:{slot}"
-
- t_dispatch = time.perf_counter()
- terminate_active_slot_sessions(db, slot_cid)
- dispatch_universal_target(slot, service)
- _mark("dispatch_universal_target_ms", t_dispatch)
-
- t_commit = time.perf_counter()
- 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()
- _mark("db_commit_ms", t_commit)
- except LockTimeoutError:
- _emit("universal_pool_lock_timeout")
- raise HTTPException(status_code=503, detail="Пул RDP занят. Повторите через несколько секунд.")
- except Exception as exc:
- logger.exception("universal_pool_dispatch_failed slug=%s user_id=%s", slug, user.id)
- log_event("session_create_failed", level=logging.ERROR, user_id=user.id, service_slug=slug, mode="universal_pool", error=str(exc))
- audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id)
- _emit("universal_pool_create_failed", error=str(exc))
- raise HTTPException(status_code=502, detail="Universal runtime failed to switch target")
- log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="universal_pool", slot=slot)
- audit(db, "SESSION_CREATE_POOL", f"service={service.slug} session={session_id} slot={slot}", user_id=user.id)
- _emit("session_created_universal_pool", session_id=session_id, slot=slot)
- return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
-
- if service.type == ServiceType.WEB and desired_pool_size(service) > 0:
- t_warm = time.perf_counter()
- ensure_warm_pool(service)
- open_warm_web_url(service, service.target)
- _mark("warm_pool_prepare_ms", t_warm)
-
- t_commit = time.perf_counter()
- session_obj = SessionModel(
- id=session_id,
- user_id=user.id,
- service_id=service.id,
- container_id=f"POOL:{service.slug}",
- status=SessionStatus.ACTIVE,
- created_at=now_utc(),
- last_access_at=now_utc(),
- )
- db.add(session_obj)
- db.commit()
- _mark("db_commit_ms", t_commit)
- log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="warm_pool")
- audit(db, "SESSION_CREATE_POOL", f"service={service.slug} session={session_id}", user_id=user.id)
- _emit("session_created_warm_pool", session_id=session_id)
- return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
-
- try:
- t_create = time.perf_counter()
- container_id = create_runtime_container(service, session_id)
- _mark("create_runtime_container_ms", t_create)
- except Exception as exc:
- logger.exception("session_container_create_failed slug=%s user_id=%s", slug, user.id)
- log_event("session_create_failed", level=logging.ERROR, user_id=user.id, service_slug=slug, mode="single_runtime", error=str(exc))
- audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id)
- _emit("single_runtime_create_failed", error=str(exc))
- raise HTTPException(status_code=502, detail="Session runtime failed to start")
-
- t_commit = time.perf_counter()
- session_obj = SessionModel(
- id=session_id,
- user_id=user.id,
- service_id=service.id,
- container_id=container_id,
- status=SessionStatus.ACTIVE,
- created_at=now_utc(),
- last_access_at=now_utc(),
- )
- db.add(session_obj)
- db.commit()
- _mark("db_commit_ms", t_commit)
- log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="single_runtime", container_id=container_id)
-
- audit(db, "SESSION_CREATE", f"service={service.slug} session={session_id}", user_id=user.id)
- t_wait = time.perf_counter()
- ready = wait_for_session_route(session_id)
- _mark("wait_session_route_ms", t_wait)
- log_event("session_route_ready", session_id=session_id, ready=ready)
- _emit("session_created_single_runtime", session_id=session_id, ready=ready)
- return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
- except LockTimeoutError:
- _emit("user_lock_timeout")
- raise HTTPException(status_code=429, detail="Слишком много параллельных запусков. Повторите через несколько секунд.")
-
-
-@app.get("/svc/{slug}/", response_class=HTMLResponse)
-def service_wait_page(slug: str, request: Request, user: User = Depends(require_user), db: Session = Depends(get_db)):
- service = db.scalar(select(Service).where(Service.slug == slug, Service.active == True))
- if not service:
- raise HTTPException(status_code=404, detail="Service not found")
- if not has_access(db, user.id, service.id):
- raise HTTPException(status_code=403, detail="ACL denied")
- return HTMLResponse(
- content="""
-
-
-
-
- Service Starting
-
-
-
-
-
Сервис запускается
-
Проверка...
-
-
-
-
-
-""".strip(),
- status_code=200,
- )
-
-
-@app.get("/s/{session_id}/", response_class=HTMLResponse)
-def session_wait_page(session_id: str, request: Request, 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:
- raise HTTPException(status_code=410, detail="Session is not active")
- service = db.get(Service, sess.service_id)
- service_title = service.name if service else "Сервис"
- redirect_target = session_redirect_url(sess)
- return HTMLResponse(
- content=f"""
-
-
-
-
- {service_title}
-
-
-
-
-
Сессия запускается
-
Проверка...
-
-
{session_id}
-
-
-
-
-""".strip(),
- status_code=200,
- )
-
-
-@app.get("/s/{session_id}/view", response_class=HTMLResponse)
-def session_view_page(session_id: str, request: Request, 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:
- raise HTTPException(status_code=410, detail="Session is not active")
- 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"""
-
-
-
-
- {service.name}
-
-
-
-
-
-
-""".strip()
- )
- return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
-
-
-@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:
- raise HTTPException(status_code=404, detail="Session not found")
- if sess.status != SessionStatus.ACTIVE:
- reason = session_closed_reason(sess, db)
- log_event(
- "session_touch_rejected",
- level=logging.WARNING,
- session_id=session_id,
- user_id=user.id,
- status=sess.status.value,
- reason=reason,
- )
- return JSONResponse(
- status_code=410,
- content={
- "ok": False,
- "reason": reason,
- "status": sess.status.value,
- },
- )
- sess.last_access_at = now_utc()
- db.commit()
- 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:
- log_event(
- "session_close_already_closed",
- session_id=session_id,
- user_id=user.id,
- status=sess.status.value,
- reason=session_closed_reason(sess, db),
- )
- return {"ok": True, "status": sess.status.value}
- terminate_session_record(db, sess, SessionStatus.TERMINATED, stop_container=True)
- db.commit()
- log_event("session_closed_by_user", session_id=session_id, user_id=user.id)
- 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))
- if not service:
- raise HTTPException(status_code=404, detail="Service not found")
- if service.type == ServiceType.VNC:
- 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")
- pool = get_pool_status_for_service(service)
- route_ok = route_ready(f"/svc/{slug}/")
- ready = route_ok and (pool["running"] > 0 if desired_pool_size(service) > 0 else True)
- steps = [
- f"ACL: OK ({user.username})",
- f"Пул: {pool['running']} / {pool['desired']}",
- f"Маршрут /svc/{slug}/: {'OK' if route_ok else 'ожидание'}",
- ]
- return {
- "ready": ready,
- "message": "Готово, открываем..." if ready else "Поднимаем контейнер и маршрут...",
- "steps": steps,
- }
-
-
-@app.get("/api/sessions/{session_id}/status")
-def session_status(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:
- raise HTTPException(status_code=410, detail="Session is not active")
- 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
- steps = [
- f"Контейнер: {'running' if running else 'starting'}",
- f"Маршрут {route_path}: {'OK' if route_ok else 'ожидание'}",
- ]
- payload = {
- "ready": ready,
- "message": "Готово, открываем..." if ready else "Запуск сессии...",
- "steps": steps,
- }
- if pooled_web:
- payload["redirect_url"] = f"/s/{session_id}/view"
- if web_pool_idx is not None:
- 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
-
-
-@app.post("/api/admin/services")
-def create_service(payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
- validate_csrf(request)
- service_type = ServiceType(payload["type"])
- if service_type == ServiceType.VNC:
- raise HTTPException(status_code=400, detail="VNC services are no longer supported")
- target = payload["target"]
- if service_type == ServiceType.WEB:
- target = normalize_web_target(target)
- elif service_type == ServiceType.RDP:
- parse_rdp_target(target)
- service = Service(
- name=payload["name"],
- slug=payload["slug"],
- type=service_type,
- target=target,
- comment=payload.get("comment", ""),
- active=payload.get("active", True),
- warm_pool_size=max(0, int(payload.get("warm_pool_size", 0))),
- )
- db.add(service)
- db.flush()
- set_service_categories(db, service.id, payload.get("category_ids", []))
- db.commit()
- 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}
-
-
-@app.get("/api/admin/services/{service_id}/containers/status")
-def service_containers_status(service_id: int, _: User = Depends(require_admin), db: Session = Depends(get_db)):
- service = db.get(Service, service_id)
- if not service:
- raise HTTPException(status_code=404, detail="Service not found")
- out = get_pool_detailed_status(service)
- out["active_sessions"] = get_active_sessions_count(db, service.id)
- return out
-
-
-@app.post("/api/admin/services/{service_id}/icon")
-async def upload_service_icon(
- service_id: int,
- request: Request,
- file: UploadFile = File(...),
- _: User = Depends(require_admin),
- db: Session = Depends(get_db),
-):
- validate_csrf(request)
- service = db.get(Service, service_id)
- if not service:
- raise HTTPException(status_code=404, detail="Service not found")
- new_path = await store_service_icon(service, file)
- old_path = service.icon_path
- service.icon_path = new_path
- db.commit()
- if old_path and old_path != new_path:
- remove_icon_file(old_path)
- return {"ok": True, "icon_path": new_path}
-
-
-@app.delete("/api/admin/services/{service_id}/icon")
-def delete_service_icon(service_id: int, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
- validate_csrf(request)
- service = db.get(Service, service_id)
- if not service:
- raise HTTPException(status_code=404, detail="Service not found")
- old_path = service.icon_path
- service.icon_path = ""
- db.commit()
- remove_icon_file(old_path)
- return {"ok": True}
-
-
-@app.put("/api/admin/services/{service_id}")
-def edit_service(service_id: int, payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
- validate_csrf(request)
- service = db.get(Service, service_id)
- if not service:
- raise HTTPException(status_code=404, detail="Service not found")
- for key in ["name", "slug", "target", "active", "comment"]:
- if key in payload:
- setattr(service, key, payload[key])
- if "type" in payload:
- service.type = ServiceType(payload["type"])
- if service.type == ServiceType.VNC:
- raise HTTPException(status_code=400, detail="VNC services are no longer supported")
- if service.type == ServiceType.WEB:
- service.target = normalize_web_target(service.target)
- elif service.type == ServiceType.RDP:
- parse_rdp_target(service.target)
- if "warm_pool_size" in payload:
- service.warm_pool_size = max(0, int(payload["warm_pool_size"]))
- if "category_ids" in payload:
- set_service_categories(db, service.id, payload.get("category_ids", []))
- db.commit()
- 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}
-
-
-@app.delete("/api/admin/services/{service_id}")
-def delete_service(service_id: int, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
- validate_csrf(request)
- service = db.get(Service, service_id)
- if not service:
- raise HTTPException(status_code=404, detail="Service not found")
- 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()
- return {"ok": True}
-
-
-@app.post("/api/admin/services/{service_id}/prewarm")
-def prewarm_now(service_id: int, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
- validate_csrf(request)
- service = db.get(Service, service_id)
- if not service:
- raise HTTPException(status_code=404, detail="Service not found")
- if service.type == ServiceType.WEB:
- ensure_web_pool()
- return {"ok": True, "pool": get_web_pool_status()}
- 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)}
-
-
-@app.post("/api/admin/categories")
-def create_category(payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
- validate_csrf(request)
- name = (payload.get("name") or "").strip()
- slug = (payload.get("slug") or "").strip().lower().replace(" ", "-")
- if not name:
- raise HTTPException(status_code=400, detail="Category name is required")
- if not slug:
- raise HTTPException(status_code=400, detail="Category slug is required")
- exists = db.scalar(select(Category).where((Category.name == name) | (Category.slug == slug)))
- if exists:
- raise HTTPException(status_code=409, detail="Category already exists")
- category = Category(name=name, slug=slug)
- db.add(category)
- db.commit()
- return {"id": category.id}
-
-
-@app.delete("/api/admin/categories/{category_id}")
-def delete_category(category_id: int, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
- validate_csrf(request)
- category = db.get(Category, category_id)
- if not category:
- raise HTTPException(status_code=404, detail="Category not found")
- db.delete(category)
- db.commit()
- return {"ok": True}
-
-
-@app.put("/api/admin/web-pool-size")
-def update_web_pool_size(payload: dict, request: Request, _: User = Depends(require_admin)):
- validate_csrf(request)
- global WEB_POOL_SIZE
- value = max(0, int(payload.get("size", WEB_POOL_SIZE)))
- WEB_POOL_SIZE = value
- ensure_web_pool()
- return {"ok": True, "size": WEB_POOL_SIZE, "pool": get_web_pool_status()}
-
-
-@app.post("/api/admin/users")
-def create_user(payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
- validate_csrf(request)
- expires_at = dt.datetime.fromisoformat(payload["expires_at"])
- user = User(
- username=payload["username"],
- password_hash=hash_password(payload["password"]),
- expires_at=expires_at,
- active=payload.get("active", True),
- is_admin=payload.get("is_admin", False),
- )
- db.add(user)
- db.commit()
- return {"id": user.id}
-
-
-@app.put("/api/admin/users/{user_id}")
-def edit_user(user_id: int, payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
- validate_csrf(request)
- user = db.get(User, user_id)
- if not user:
- raise HTTPException(status_code=404, detail="User not found")
- for key in ["username", "active", "is_admin"]:
- if key in payload:
- setattr(user, key, payload[key])
- if "password" in payload and payload["password"]:
- user.password_hash = hash_password(payload["password"])
- if "expires_at" in payload:
- user.expires_at = dt.datetime.fromisoformat(payload["expires_at"])
- db.commit()
- return {"ok": True}
-
-
-@app.delete("/api/admin/users/{user_id}")
-def delete_user(user_id: int, request: Request, admin: User = Depends(require_admin), db: Session = Depends(get_db)):
- validate_csrf(request)
- user = db.get(User, user_id)
- if not user:
- raise HTTPException(status_code=404, detail="User not found")
- if user.id == admin.id:
- raise HTTPException(status_code=400, detail="Cannot delete current admin")
- db.delete(user)
- db.commit()
- return {"ok": True}
-
-
-@app.put("/api/admin/users/{user_id}/acl")
-def set_acl(user_id: int, payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
- validate_csrf(request)
- user = db.get(User, user_id)
- if not user:
- raise HTTPException(status_code=404, detail="User not found")
- service_ids = set(payload.get("service_ids", []))
-
- existing = db.scalars(select(UserServiceAccess).where(UserServiceAccess.user_id == user_id)).all()
- existing_map = {x.service_id: x for x in existing}
-
- for sid in service_ids:
- if sid not in existing_map:
- db.add(UserServiceAccess(user_id=user_id, service_id=sid))
- for sid, row in existing_map.items():
- if sid not in service_ids:
- db.delete(row)
-
- db.commit()
- return {"ok": True}
diff --git a/docker-compose.yml.bak_before_idle_test_20260424_145848 b/docker-compose.yml.bak_before_idle_test_20260424_145848
deleted file mode 100644
index 65e3a8f..0000000
--- a/docker-compose.yml.bak_before_idle_test_20260424_145848
+++ /dev/null
@@ -1,124 +0,0 @@
-services:
- traefik:
- image: traefik:v3.2
- command:
- - --configFile=/etc/traefik/traefik.yml
- ports:
- - "0.0.0.0:8288:80"
- - "0.0.0.0:2288:443"
- volumes:
- - /var/run/docker.sock:/var/run/docker.sock:ro
- - ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
- - ./traefik/dynamic:/etc/traefik/dynamic
- - ./traefik/letsencrypt:/letsencrypt
- networks:
- - portal_net
- restart: unless-stopped
-
- db:
- image: postgres:16
- environment:
- POSTGRES_DB: ${POSTGRES_DB}
- POSTGRES_USER: ${POSTGRES_USER}
- POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
- volumes:
- - pg_data:/var/lib/postgresql/data
- networks:
- - portal_net
- restart: unless-stopped
-
- api:
- build:
- context: ./app
- 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}
- PUBLIC_HOST: ${PUBLIC_HOST}
- ADMIN_USERNAME: ${ADMIN_USERNAME}
- ADMIN_PASSWORD: ${ADMIN_PASSWORD}
- SESSION_IDLE_SECONDS: ${SESSION_IDLE_SECONDS:-300}
- PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2}
- UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0}
- WEB_POOL_SIZE: ${WEB_POOL_SIZE:-20}
- WEB_POOL_BUFFER: ${WEB_POOL_BUFFER:-10}
- MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4}
- LOG_LEVEL: ${LOG_LEVEL:-INFO}
- GO_USER_LOCK_TIMEOUT_SECONDS: 8
- GO_POOL_LOCK_TIMEOUT_SECONDS: 20
- POOL_DISPATCH_RETRIES: 6
- ENABLE_STARTUP_MAINTENANCE: 0
- depends_on:
- - db
- volumes:
- - /var/run/docker.sock:/var/run/docker.sock
- - ./app/static/service-icons:/app/static/service-icons
- labels:
- - traefik.enable=true
- - traefik.docker.network=portal_net
- - traefik.http.routers.portal.rule=Host(`${PUBLIC_HOST}`)
- - traefik.http.routers.portal.entrypoints=websecure
- - traefik.http.routers.portal.tls=true
- - traefik.http.routers.portal.tls.certresolver=letsencrypt
- - traefik.http.routers.portal.priority=1
- - traefik.http.services.portal.loadbalancer.server.port=8000
- - traefik.http.routers.portal.middlewares=secure-headers@file
- networks:
- - portal_net
- restart: unless-stopped
-
-
- maintenance:
- build:
- context: ./app
- command: [python, maintenance_runner.py]
- environment:
- DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
- SIGNING_KEY: ${SIGNING_KEY}
- PUBLIC_HOST: ${PUBLIC_HOST}
- ADMIN_USERNAME: ${ADMIN_USERNAME}
- ADMIN_PASSWORD: ${ADMIN_PASSWORD}
- SESSION_IDLE_SECONDS: ${SESSION_IDLE_SECONDS:-300}
- PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2}
- UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0}
- WEB_POOL_SIZE: ${WEB_POOL_SIZE:-20}
- WEB_POOL_BUFFER: ${WEB_POOL_BUFFER:-10}
- MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4}
- LOG_LEVEL: ${LOG_LEVEL:-INFO}
- GO_USER_LOCK_TIMEOUT_SECONDS: 8
- GO_POOL_LOCK_TIMEOUT_SECONDS: 20
- POOL_DISPATCH_RETRIES: 6
- ENABLE_STARTUP_MAINTENANCE: 0
- depends_on:
- - db
- volumes:
- - /var/run/docker.sock:/var/run/docker.sock
- - ./app/static/service-icons:/app/static/service-icons
- networks:
- - portal_net
- restart: unless-stopped
-
- kiosk-image:
- image: portal-kiosk:latest
- build:
- context: ./kiosk
- profiles: ["build-only"]
-
- rdp-proxy-image:
- image: portal-rdp-proxy:latest
- build:
- context: ./rdp-proxy
- profiles: ["build-only"]
-
- universal-runtime-image:
- image: portal-universal-runtime:latest
- build:
- context: ./universal-runtime
- profiles: ["build-only"]
-
-networks:
- portal_net:
- name: portal_net
-
-volumes:
- pg_data:
diff --git a/docker-compose.yml.bak_before_pool_tune_20260424_140833 b/docker-compose.yml.bak_before_pool_tune_20260424_140833
deleted file mode 100644
index d634688..0000000
--- a/docker-compose.yml.bak_before_pool_tune_20260424_140833
+++ /dev/null
@@ -1,124 +0,0 @@
-services:
- traefik:
- image: traefik:v3.2
- command:
- - --configFile=/etc/traefik/traefik.yml
- ports:
- - "0.0.0.0:8288:80"
- - "0.0.0.0:2288:443"
- volumes:
- - /var/run/docker.sock:/var/run/docker.sock:ro
- - ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
- - ./traefik/dynamic:/etc/traefik/dynamic
- - ./traefik/letsencrypt:/letsencrypt
- networks:
- - portal_net
- restart: unless-stopped
-
- db:
- image: postgres:16
- environment:
- POSTGRES_DB: ${POSTGRES_DB}
- POSTGRES_USER: ${POSTGRES_USER}
- POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
- volumes:
- - pg_data:/var/lib/postgresql/data
- networks:
- - portal_net
- restart: unless-stopped
-
- api:
- build:
- context: ./app
- command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "18"]
- environment:
- DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
- SIGNING_KEY: ${SIGNING_KEY}
- PUBLIC_HOST: ${PUBLIC_HOST}
- ADMIN_USERNAME: ${ADMIN_USERNAME}
- ADMIN_PASSWORD: ${ADMIN_PASSWORD}
- SESSION_IDLE_SECONDS: ${SESSION_IDLE_SECONDS:-300}
- PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2}
- UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0}
- WEB_POOL_SIZE: ${WEB_POOL_SIZE:-20}
- WEB_POOL_BUFFER: ${WEB_POOL_BUFFER:-2}
- MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4}
- LOG_LEVEL: ${LOG_LEVEL:-INFO}
- GO_USER_LOCK_TIMEOUT_SECONDS: 8
- GO_POOL_LOCK_TIMEOUT_SECONDS: 8
- POOL_DISPATCH_RETRIES: 6
- ENABLE_STARTUP_MAINTENANCE: 0
- depends_on:
- - db
- volumes:
- - /var/run/docker.sock:/var/run/docker.sock
- - ./app/static/service-icons:/app/static/service-icons
- labels:
- - traefik.enable=true
- - traefik.docker.network=portal_net
- - traefik.http.routers.portal.rule=Host(`${PUBLIC_HOST}`)
- - traefik.http.routers.portal.entrypoints=websecure
- - traefik.http.routers.portal.tls=true
- - traefik.http.routers.portal.tls.certresolver=letsencrypt
- - traefik.http.routers.portal.priority=1
- - traefik.http.services.portal.loadbalancer.server.port=8000
- - traefik.http.routers.portal.middlewares=secure-headers@file
- networks:
- - portal_net
- restart: unless-stopped
-
-
- maintenance:
- build:
- context: ./app
- command: [python, maintenance_runner.py]
- environment:
- DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
- SIGNING_KEY: ${SIGNING_KEY}
- PUBLIC_HOST: ${PUBLIC_HOST}
- ADMIN_USERNAME: ${ADMIN_USERNAME}
- ADMIN_PASSWORD: ${ADMIN_PASSWORD}
- SESSION_IDLE_SECONDS: ${SESSION_IDLE_SECONDS:-300}
- PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2}
- UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0}
- WEB_POOL_SIZE: ${WEB_POOL_SIZE:-20}
- WEB_POOL_BUFFER: ${WEB_POOL_BUFFER:-2}
- MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4}
- LOG_LEVEL: ${LOG_LEVEL:-INFO}
- GO_USER_LOCK_TIMEOUT_SECONDS: 8
- GO_POOL_LOCK_TIMEOUT_SECONDS: 8
- POOL_DISPATCH_RETRIES: 6
- ENABLE_STARTUP_MAINTENANCE: 0
- depends_on:
- - db
- volumes:
- - /var/run/docker.sock:/var/run/docker.sock
- - ./app/static/service-icons:/app/static/service-icons
- networks:
- - portal_net
- restart: unless-stopped
-
- kiosk-image:
- image: portal-kiosk:latest
- build:
- context: ./kiosk
- profiles: ["build-only"]
-
- rdp-proxy-image:
- image: portal-rdp-proxy:latest
- build:
- context: ./rdp-proxy
- profiles: ["build-only"]
-
- universal-runtime-image:
- image: portal-universal-runtime:latest
- build:
- context: ./universal-runtime
- profiles: ["build-only"]
-
-networks:
- portal_net:
- name: portal_net
-
-volumes:
- pg_data:
diff --git a/docker-compose.yml.bak_before_pool_tune_20260424_140839 b/docker-compose.yml.bak_before_pool_tune_20260424_140839
deleted file mode 100644
index d634688..0000000
--- a/docker-compose.yml.bak_before_pool_tune_20260424_140839
+++ /dev/null
@@ -1,124 +0,0 @@
-services:
- traefik:
- image: traefik:v3.2
- command:
- - --configFile=/etc/traefik/traefik.yml
- ports:
- - "0.0.0.0:8288:80"
- - "0.0.0.0:2288:443"
- volumes:
- - /var/run/docker.sock:/var/run/docker.sock:ro
- - ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
- - ./traefik/dynamic:/etc/traefik/dynamic
- - ./traefik/letsencrypt:/letsencrypt
- networks:
- - portal_net
- restart: unless-stopped
-
- db:
- image: postgres:16
- environment:
- POSTGRES_DB: ${POSTGRES_DB}
- POSTGRES_USER: ${POSTGRES_USER}
- POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
- volumes:
- - pg_data:/var/lib/postgresql/data
- networks:
- - portal_net
- restart: unless-stopped
-
- api:
- build:
- context: ./app
- command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "18"]
- environment:
- DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
- SIGNING_KEY: ${SIGNING_KEY}
- PUBLIC_HOST: ${PUBLIC_HOST}
- ADMIN_USERNAME: ${ADMIN_USERNAME}
- ADMIN_PASSWORD: ${ADMIN_PASSWORD}
- SESSION_IDLE_SECONDS: ${SESSION_IDLE_SECONDS:-300}
- PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2}
- UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0}
- WEB_POOL_SIZE: ${WEB_POOL_SIZE:-20}
- WEB_POOL_BUFFER: ${WEB_POOL_BUFFER:-2}
- MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4}
- LOG_LEVEL: ${LOG_LEVEL:-INFO}
- GO_USER_LOCK_TIMEOUT_SECONDS: 8
- GO_POOL_LOCK_TIMEOUT_SECONDS: 8
- POOL_DISPATCH_RETRIES: 6
- ENABLE_STARTUP_MAINTENANCE: 0
- depends_on:
- - db
- volumes:
- - /var/run/docker.sock:/var/run/docker.sock
- - ./app/static/service-icons:/app/static/service-icons
- labels:
- - traefik.enable=true
- - traefik.docker.network=portal_net
- - traefik.http.routers.portal.rule=Host(`${PUBLIC_HOST}`)
- - traefik.http.routers.portal.entrypoints=websecure
- - traefik.http.routers.portal.tls=true
- - traefik.http.routers.portal.tls.certresolver=letsencrypt
- - traefik.http.routers.portal.priority=1
- - traefik.http.services.portal.loadbalancer.server.port=8000
- - traefik.http.routers.portal.middlewares=secure-headers@file
- networks:
- - portal_net
- restart: unless-stopped
-
-
- maintenance:
- build:
- context: ./app
- command: [python, maintenance_runner.py]
- environment:
- DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
- SIGNING_KEY: ${SIGNING_KEY}
- PUBLIC_HOST: ${PUBLIC_HOST}
- ADMIN_USERNAME: ${ADMIN_USERNAME}
- ADMIN_PASSWORD: ${ADMIN_PASSWORD}
- SESSION_IDLE_SECONDS: ${SESSION_IDLE_SECONDS:-300}
- PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2}
- UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0}
- WEB_POOL_SIZE: ${WEB_POOL_SIZE:-20}
- WEB_POOL_BUFFER: ${WEB_POOL_BUFFER:-2}
- MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4}
- LOG_LEVEL: ${LOG_LEVEL:-INFO}
- GO_USER_LOCK_TIMEOUT_SECONDS: 8
- GO_POOL_LOCK_TIMEOUT_SECONDS: 8
- POOL_DISPATCH_RETRIES: 6
- ENABLE_STARTUP_MAINTENANCE: 0
- depends_on:
- - db
- volumes:
- - /var/run/docker.sock:/var/run/docker.sock
- - ./app/static/service-icons:/app/static/service-icons
- networks:
- - portal_net
- restart: unless-stopped
-
- kiosk-image:
- image: portal-kiosk:latest
- build:
- context: ./kiosk
- profiles: ["build-only"]
-
- rdp-proxy-image:
- image: portal-rdp-proxy:latest
- build:
- context: ./rdp-proxy
- profiles: ["build-only"]
-
- universal-runtime-image:
- image: portal-universal-runtime:latest
- build:
- context: ./universal-runtime
- profiles: ["build-only"]
-
-networks:
- portal_net:
- name: portal_net
-
-volumes:
- pg_data:
diff --git a/docker-compose.yml.bak_before_pool_tune_20260424_140854 b/docker-compose.yml.bak_before_pool_tune_20260424_140854
deleted file mode 100644
index d634688..0000000
--- a/docker-compose.yml.bak_before_pool_tune_20260424_140854
+++ /dev/null
@@ -1,124 +0,0 @@
-services:
- traefik:
- image: traefik:v3.2
- command:
- - --configFile=/etc/traefik/traefik.yml
- ports:
- - "0.0.0.0:8288:80"
- - "0.0.0.0:2288:443"
- volumes:
- - /var/run/docker.sock:/var/run/docker.sock:ro
- - ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
- - ./traefik/dynamic:/etc/traefik/dynamic
- - ./traefik/letsencrypt:/letsencrypt
- networks:
- - portal_net
- restart: unless-stopped
-
- db:
- image: postgres:16
- environment:
- POSTGRES_DB: ${POSTGRES_DB}
- POSTGRES_USER: ${POSTGRES_USER}
- POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
- volumes:
- - pg_data:/var/lib/postgresql/data
- networks:
- - portal_net
- restart: unless-stopped
-
- api:
- build:
- context: ./app
- command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "18"]
- environment:
- DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
- SIGNING_KEY: ${SIGNING_KEY}
- PUBLIC_HOST: ${PUBLIC_HOST}
- ADMIN_USERNAME: ${ADMIN_USERNAME}
- ADMIN_PASSWORD: ${ADMIN_PASSWORD}
- SESSION_IDLE_SECONDS: ${SESSION_IDLE_SECONDS:-300}
- PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2}
- UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0}
- WEB_POOL_SIZE: ${WEB_POOL_SIZE:-20}
- WEB_POOL_BUFFER: ${WEB_POOL_BUFFER:-2}
- MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4}
- LOG_LEVEL: ${LOG_LEVEL:-INFO}
- GO_USER_LOCK_TIMEOUT_SECONDS: 8
- GO_POOL_LOCK_TIMEOUT_SECONDS: 8
- POOL_DISPATCH_RETRIES: 6
- ENABLE_STARTUP_MAINTENANCE: 0
- depends_on:
- - db
- volumes:
- - /var/run/docker.sock:/var/run/docker.sock
- - ./app/static/service-icons:/app/static/service-icons
- labels:
- - traefik.enable=true
- - traefik.docker.network=portal_net
- - traefik.http.routers.portal.rule=Host(`${PUBLIC_HOST}`)
- - traefik.http.routers.portal.entrypoints=websecure
- - traefik.http.routers.portal.tls=true
- - traefik.http.routers.portal.tls.certresolver=letsencrypt
- - traefik.http.routers.portal.priority=1
- - traefik.http.services.portal.loadbalancer.server.port=8000
- - traefik.http.routers.portal.middlewares=secure-headers@file
- networks:
- - portal_net
- restart: unless-stopped
-
-
- maintenance:
- build:
- context: ./app
- command: [python, maintenance_runner.py]
- environment:
- DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
- SIGNING_KEY: ${SIGNING_KEY}
- PUBLIC_HOST: ${PUBLIC_HOST}
- ADMIN_USERNAME: ${ADMIN_USERNAME}
- ADMIN_PASSWORD: ${ADMIN_PASSWORD}
- SESSION_IDLE_SECONDS: ${SESSION_IDLE_SECONDS:-300}
- PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2}
- UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-0}
- WEB_POOL_SIZE: ${WEB_POOL_SIZE:-20}
- WEB_POOL_BUFFER: ${WEB_POOL_BUFFER:-2}
- MAX_ACTIVE_SERVICES_PER_USER: ${MAX_ACTIVE_SERVICES_PER_USER:-4}
- LOG_LEVEL: ${LOG_LEVEL:-INFO}
- GO_USER_LOCK_TIMEOUT_SECONDS: 8
- GO_POOL_LOCK_TIMEOUT_SECONDS: 8
- POOL_DISPATCH_RETRIES: 6
- ENABLE_STARTUP_MAINTENANCE: 0
- depends_on:
- - db
- volumes:
- - /var/run/docker.sock:/var/run/docker.sock
- - ./app/static/service-icons:/app/static/service-icons
- networks:
- - portal_net
- restart: unless-stopped
-
- kiosk-image:
- image: portal-kiosk:latest
- build:
- context: ./kiosk
- profiles: ["build-only"]
-
- rdp-proxy-image:
- image: portal-rdp-proxy:latest
- build:
- context: ./rdp-proxy
- profiles: ["build-only"]
-
- universal-runtime-image:
- image: portal-universal-runtime:latest
- build:
- context: ./universal-runtime
- profiles: ["build-only"]
-
-networks:
- portal_net:
- name: portal_net
-
-volumes:
- pg_data:
diff --git a/docker-compose.yml.bak_webpool_20260423_061134 b/docker-compose.yml.bak_webpool_20260423_061134
deleted file mode 100644
index 2ca5a5b..0000000
--- a/docker-compose.yml.bak_webpool_20260423_061134
+++ /dev/null
@@ -1,87 +0,0 @@
-services:
- traefik:
- image: traefik:v3.2
- command:
- - --configFile=/etc/traefik/traefik.yml
- ports:
- - "0.0.0.0:8288:80"
- - "0.0.0.0:2288:443"
- volumes:
- - /var/run/docker.sock:/var/run/docker.sock:ro
- - ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
- - ./traefik/dynamic:/etc/traefik/dynamic
- - ./traefik/letsencrypt:/letsencrypt
- networks:
- - portal_net
- restart: unless-stopped
-
- db:
- image: postgres:16
- environment:
- POSTGRES_DB: ${POSTGRES_DB}
- POSTGRES_USER: ${POSTGRES_USER}
- POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
- volumes:
- - pg_data:/var/lib/postgresql/data
- networks:
- - portal_net
- restart: unless-stopped
-
- api:
- build:
- context: ./app
- command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "18"]
- environment:
- DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
- SIGNING_KEY: ${SIGNING_KEY}
- PUBLIC_HOST: ${PUBLIC_HOST}
- ADMIN_USERNAME: ${ADMIN_USERNAME}
- ADMIN_PASSWORD: ${ADMIN_PASSWORD}
- 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
- volumes:
- - /var/run/docker.sock:/var/run/docker.sock
- - ./app/static/service-icons:/app/static/service-icons
- labels:
- - traefik.enable=true
- - traefik.docker.network=portal_net
- - traefik.http.routers.portal.rule=Host(`${PUBLIC_HOST}`)
- - traefik.http.routers.portal.entrypoints=websecure
- - traefik.http.routers.portal.tls=true
- - traefik.http.routers.portal.tls.certresolver=letsencrypt
- - traefik.http.routers.portal.priority=1
- - traefik.http.services.portal.loadbalancer.server.port=8000
- - traefik.http.routers.portal.middlewares=secure-headers@file
- networks:
- - portal_net
- restart: unless-stopped
-
- kiosk-image:
- image: portal-kiosk:latest
- build:
- context: ./kiosk
- profiles: ["build-only"]
-
- rdp-proxy-image:
- image: portal-rdp-proxy:latest
- build:
- context: ./rdp-proxy
- profiles: ["build-only"]
-
- universal-runtime-image:
- image: portal-universal-runtime:latest
- build:
- context: ./universal-runtime
- profiles: ["build-only"]
-
-networks:
- portal_net:
- name: portal_net
-
-volumes:
- pg_data:
diff --git a/docker-compose.yml.bak_workers6_20260422_120858 b/docker-compose.yml.bak_workers6_20260422_120858
deleted file mode 100644
index 7b5babd..0000000
--- a/docker-compose.yml.bak_workers6_20260422_120858
+++ /dev/null
@@ -1,87 +0,0 @@
-services:
- traefik:
- image: traefik:v3.2
- command:
- - --configFile=/etc/traefik/traefik.yml
- ports:
- - "0.0.0.0:8288:80"
- - "0.0.0.0:2288:443"
- volumes:
- - /var/run/docker.sock:/var/run/docker.sock:ro
- - ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro
- - ./traefik/dynamic:/etc/traefik/dynamic
- - ./traefik/letsencrypt:/letsencrypt
- networks:
- - portal_net
- restart: unless-stopped
-
- db:
- image: postgres:16
- environment:
- POSTGRES_DB: ${POSTGRES_DB}
- POSTGRES_USER: ${POSTGRES_USER}
- POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
- volumes:
- - pg_data:/var/lib/postgresql/data
- networks:
- - portal_net
- restart: unless-stopped
-
- api:
- build:
- context: ./app
- 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}
- PUBLIC_HOST: ${PUBLIC_HOST}
- ADMIN_USERNAME: ${ADMIN_USERNAME}
- ADMIN_PASSWORD: ${ADMIN_PASSWORD}
- 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
- volumes:
- - /var/run/docker.sock:/var/run/docker.sock
- - ./app/static/service-icons:/app/static/service-icons
- labels:
- - traefik.enable=true
- - traefik.docker.network=portal_net
- - traefik.http.routers.portal.rule=Host(`${PUBLIC_HOST}`)
- - traefik.http.routers.portal.entrypoints=websecure
- - traefik.http.routers.portal.tls=true
- - traefik.http.routers.portal.tls.certresolver=letsencrypt
- - traefik.http.routers.portal.priority=1
- - traefik.http.services.portal.loadbalancer.server.port=8000
- - traefik.http.routers.portal.middlewares=secure-headers@file
- networks:
- - portal_net
- restart: unless-stopped
-
- kiosk-image:
- image: portal-kiosk:latest
- build:
- context: ./kiosk
- profiles: ["build-only"]
-
- rdp-proxy-image:
- image: portal-rdp-proxy:latest
- build:
- context: ./rdp-proxy
- profiles: ["build-only"]
-
- universal-runtime-image:
- image: portal-universal-runtime:latest
- build:
- context: ./universal-runtime
- profiles: ["build-only"]
-
-networks:
- portal_net:
- name: portal_net
-
-volumes:
- pg_data:
diff --git a/docs/CONTEXT_TEST.md b/docs/CONTEXT_TEST.md
deleted file mode 100644
index 19ca98d..0000000
--- a/docs/CONTEXT_TEST.md
+++ /dev/null
@@ -1,58 +0,0 @@
-# CONTEXT_TEST
-
-Обновлено: 2026-04-23 (Europe/Moscow)
-
-## Цель
-Продолжить нагрузочное тестирование маршрута `GET /go/{slug}` и стабилизировать поведение под конкуренцией.
-
-## Что внедрено в API
-
-1. Ограничение ожидания lock-ов:
-- добавлен `LockTimeoutError`;
-- `allocator_lock(...)` теперь поддерживает timeout через `pg_try_advisory_lock`;
-- для user-lock в `go_service`: `GO_USER_LOCK_TIMEOUT_SECONDS` (default `2.0`);
-- для pool-lock: `GO_POOL_LOCK_TIMEOUT_SECONDS` (default `5.0`).
-
-2. Контролируемые ответы вместо долгого зависания:
-- timeout user-lock -> `429`;
-- timeout pool-lock -> `503`.
-
-3. Фазовая телеметрия `go_service`:
-- событие: `go_service_timing`;
-- фиксируются времена фаз (wait lock, check existing/limit, ensure/acquire/dispatch/commit, total).
-
-4. Ограничен dispatch runtime-пула:
-- `POOL_DISPATCH_RETRIES` (default `4`),
-- `POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS` (default `2.0`),
-- `POOL_DISPATCH_SLEEP_SECONDS` (default `0.3`).
-
-## Что исправлено в тестовом контуре
-
-1. В `.env` был пустой `SIGNING_KEY` -> заполнен, `api` перезапущен.
-2. В k6-скрипте включено `noCookiesReset: true`, иначе возникал ложный вал `401`.
-
-## Актуальные контрольные результаты
-
-Контрольный тест (после правок):
-- профиль: `5 VU`, `25s`, single-user;
-- `http_req_failed = 0%`;
-- `open_success = 1138`;
-- `open_rejected = 0`;
-- `p95 http_req_duration = 10.79ms`;
-- по логам `/go/*`: `1138 x 303`, `1 x 503`.
-
-Это подтверждает, что:
-- долгие зависания заменены на быстрые контролируемые ответы;
-- тестовый сценарий больше не искажается cookie-сбросом.
-
-## Следующие шаги
-
-1. Повторить multi-user `load` (30 VU, 5m) на этом же скрипте и зафиксировать:
-- долю `303/429/503`,
-- p95/p99,
-- `go_service_timing` по фазам.
-
-2. При необходимости тонко настроить:
-- `GO_USER_LOCK_TIMEOUT_SECONDS`,
-- `GO_POOL_LOCK_TIMEOUT_SECONDS`,
-- `POOL_DISPATCH_*`.