commit fc46d90194e7b5e3b16e9d46eb17bbac57757d43 Author: Ruslan Date: Mon Apr 13 08:35:07 2026 +0000 feat: redesign portal UX and stabilize web session runtime diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..afbad1b --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +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! +PREWARM_POOL_SIZE=2 +UNIVERSAL_POOL_SIZE=5 +LOG_LEVEL=INFO diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..67ac2fe --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.env +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +.pytest_cache/ +.mypy_cache/ +.venv/ +venv/ +.DS_Store +traefik/letsencrypt/acme.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..bdaf60d --- /dev/null +++ b/README.md @@ -0,0 +1,154 @@ +# Portal Stand Access (MVP) + +MVP-портал доступа к стендам в Proxmox через единый вход `https://stend.4mont.ru`. + +## 1. Архитектура + +- `traefik`: входная точка TLS, маршрутизация на API и динамические сессионные контейнеры. +- `api` (FastAPI): auth, ACL, админ API, создание runtime-сессий, cleanup. +- `db` (PostgreSQL): пользователи, сервисы, ACL, сессии, аудит. +- `portal-kiosk` image: Chromium kiosk + noVNC (для WEB target). +- `portal-rdp-proxy` image: xfreerdp + websockify/noVNC proxy до удаленного RDP host:port. +- `portal-universal-runtime` image: универсальный runtime (WEB/RDP), используется общим warm pool `UNIVERSAL_POOL_SIZE`. +- Опциональный prewarm pool: + - глобальный fallback `PREWARM_POOL_SIZE`; + - приоритетный `warm_pool_size` на каждом сервисе в админке. + +Поток: +1. Пользователь логинится на `/`. +2. Dashboard показывает разрешённые сервисы. +3. Клик по `/go/` -> ACL check + проверка `expires_at` + создание записи `sessions` + старт отдельного контейнера. +4. Пользователь редиректится на `/s//`. +5. Traefik отправляет `/s//...` в конкретный runtime контейнер по dynamic labels. +6. Runtime страница шлёт heartbeat в `/api/sessions//touch`. +7. Фоновый cleanup завершает сессии при idle > 30 минут. + +При prewarm: +- создаются warm-контейнеры с маршрутом `/svc//`; +- клик по плитке использует prewarmed runtime без задержки cold start; +- сессии в БД создаются, но контейнеры остаются пулом (без стопа на каждую сессию). +- в `/admin` есть: + - отдельные разделы Users / WEB / RDP (список + форма выбранной записи); + - `pool size` на сервис; + - кнопка `Prewarm now`; + - health `running/desired` по каждому пулу. + - авто-генерация `slug` из названия (поддержка кириллицы -> латиница). + +## 2. Схема БД + +Основные таблицы: +- `users` +- `services` +- `user_service_access` +- `sessions` + +Дополнительно: +- `audit_logs` + +SQL-схема: `scripts/schema.sql`. + +## 3. Безопасность + +- Пароли: `argon2` (`passlib[argon2]`). +- Cookie auth: `HttpOnly`, `Secure`, `SameSite=Strict`. +- CSRF: + - формы (`/login`) через hidden token + cookie; + - admin JSON API через `X-CSRF-Token`. +- Проверки при каждом запросе: + - пользователь `active=true`; + - `expires_at > now()`; + - ACL на `/go/`. +- Аудит: события входа и создания сессий в `audit_logs`. + +## 4. Файлы + +- `docker-compose.yml` +- `traefik/traefik.yml` +- `traefik/dynamic/security.yml` +- `app/main.py` +- `kiosk/Dockerfile`, `kiosk/entrypoint.sh` +- `rdp-proxy/Dockerfile`, `rdp-proxy/entrypoint.sh` + +## 5. Запуск + +```bash +cp .env.example .env +mkdir -p traefik/letsencrypt +touch traefik/letsencrypt/acme.json +chmod 600 traefik/letsencrypt/acme.json +``` + +Собрать runtime образы: + +```bash +docker compose --profile build-only build kiosk-image rdp-proxy-image universal-runtime-image +``` + +Поднять систему: + +```bash +docker compose up -d --build +``` + +Проверка: + +```bash +docker compose ps +docker compose logs -f api traefik +``` + +Дефолтный админ берётся из `.env` (`ADMIN_USERNAME`, `ADMIN_PASSWORD`). + +## 6. Примеры admin API + +1) Создать сервис WEB: + +```bash +curl -k -X POST "https://stend.4mont.ru/api/admin/services" \ + -H "Content-Type: application/json" \ + -H "X-CSRF-Token: " \ + -H "Cookie: portal_auth=; csrf_token=" \ + -d '{"name":"CRM","slug":"crm","type":"WEB","target":"http://192.168.1.10:3000","active":true}' +``` + +2) Создать сервис RDP: + +```bash +curl -k -X POST "https://stend.4mont.ru/api/admin/services" \ + -H "Content-Type: application/json" \ + -H "X-CSRF-Token: " \ + -H "Cookie: portal_auth=; csrf_token=" \ + -d '{"name":"Windows Desktop","slug":"win-rdp1","type":"RDP","target":"192.168.1.60:3389","active":true}' +``` + +Для RDP `target` также поддерживает креды и параметры: + +```text +rdp://user:password@192.168.1.60:3389?domain=AD&sec=nla +``` + +3) Создать пользователя: + +```bash +curl -k -X POST "https://stend.4mont.ru/api/admin/users" \ + -H "Content-Type: application/json" \ + -H "X-CSRF-Token: " \ + -H "Cookie: portal_auth=; csrf_token=" \ + -d '{"username":"user1","password":"Passw0rd!","expires_at":"2026-12-31T23:59:59+00:00","active":true}' +``` + +4) Назначить ACL: + +```bash +curl -k -X PUT "https://stend.4mont.ru/api/admin/users/2/acl" \ + -H "Content-Type: application/json" \ + -H "X-CSRF-Token: " \ + -H "Cookie: portal_auth=; csrf_token=" \ + -d '{"service_ids":[1,2]}' +``` + +## 7. Ограничения MVP + +- Нет отдельной UI-админки (есть admin API). +- TTL неактивности основан на heartbeat runtime-страницы. +- Для production стоит добавить Alembic-миграции, rate limiting и централизованный логинг. diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 0000000..fe57eef --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..450f417 --- /dev/null +++ b/app/main.py @@ -0,0 +1,1613 @@ +import datetime as dt +import enum +import logging +import os +from pathlib import Path +import secrets +import threading +import time +import uuid +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, RedirectResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from itsdangerous import BadSignature, URLSafeTimedSerializer +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", "1800")) +PUBLIC_HOST = os.getenv("PUBLIC_HOST", "stend.4mont.ru") +LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() +TRAEFIK_INTERNAL_URL = os.getenv("TRAEFIK_INTERNAL_URL", "http://traefik") +PREWARM_POOL_SIZE = int(os.getenv("PREWARM_POOL_SIZE", "0")) +UNIVERSAL_POOL_SIZE = int(os.getenv("UNIVERSAL_POOL_SIZE", "5")) +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") + +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]) + started = time.time() + try: + response = await call_next(request) + except Exception: + logger.exception("request_failed req_id=%s method=%s path=%s", req_id, request.method, request.url.path) + raise + duration_ms = int((time.time() - started) * 1000) + logger.info( + "request req_id=%s method=%s path=%s status=%s duration_ms=%s", + req_id, + request.method, + request.url.path, + response.status_code, + duration_ms, + ) + response.headers["X-Request-ID"] = req_id + 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" + + +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 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 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 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 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 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 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 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 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 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(8): + try: + resp = requests.post(url, json=payload, timeout=3) + resp.raise_for_status() + return + except Exception as exc: + last_exc = exc + time.sleep(0.4) + 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:"): + 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 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 $$; + """ + ) + ) + + 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 ''")) + # 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_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_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_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, + ) + return len(db.scalars(q).all()) + + +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() + for svc in db.scalars( + select(Service).where( + Service.active == True, + Service.type.in_([ServiceType.WEB, ServiceType.RDP]), + ) + ).all(): + if not service_uses_universal_pool(svc): + 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:")): + 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(): + Base.metadata.create_all(bind=engine) + ensure_schema_compatibility() + ensure_icons_dir() + bootstrap_admin() + db = SessionLocal() + try: + ensure_universal_pool() + for svc in db.scalars( + select(Service).where( + Service.active == True, + Service.type.in_([ServiceType.WEB, ServiceType.RDP]), + ) + ).all(): + if not service_uses_universal_pool(svc): + 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)): + if not user: + csrf = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24) + response = templates.TemplateResponse("login.html", {"request": request, "csrf_token": csrf}) + 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() + return templates.TemplateResponse( + "dashboard.html", + {"request": request, "user": user, "services": services, "csrf_token": request.cookies.get(CSRF_COOKIE, "")}, + ) + + +@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() + 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] + 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_totals = { + "services": len(web_services), + "running": sum(service_health[s.id]["running"] for s in web_services), + "desired": sum(service_health[s.id]["desired"] for s in web_services), + "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() + return templates.TemplateResponse( + "admin.html", + { + "request": request, + "admin": admin, + "users": users, + "web_services": web_services, + "rdp_services": rdp_services, + "services": services, + "acl": acl, + "pool_status": pool_status, + "service_health": service_health, + "web_totals": web_totals, + "recent_sessions": recent_sessions, + "open_stats": open_stats, + "csrf_token": request.cookies.get(CSRF_COOKIE, ""), + }, + ) + + +@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) or not user_is_valid(user): + raise HTTPException(status_code=401, detail="Invalid credentials or expired user") + + 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)): + 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") + + session_id = str(uuid.uuid4()) + if service_uses_universal_pool(service): + try: + ensure_universal_pool() + slot = acquire_universal_slot(db) + dispatch_universal_target(slot, service) + except Exception as exc: + logger.exception("universal_pool_dispatch_failed slug=%s user_id=%s", slug, user.id) + audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id) + raise HTTPException(status_code=502, detail="Universal runtime failed to switch target") + session_obj = SessionModel( + id=session_id, + user_id=user.id, + service_id=service.id, + container_id=f"POOLIDX:{slot}", + status=SessionStatus.ACTIVE, + created_at=now_utc(), + last_access_at=now_utc(), + ) + db.add(session_obj) + db.commit() + audit(db, "SESSION_CREATE_POOL", f"service={service.slug} session={session_id} slot={slot}", user_id=user.id) + return RedirectResponse(url=f"/u/{slot}/?sid={session_id}", status_code=303) + + if desired_pool_size(service) > 0: + ensure_warm_pool(service) + open_warm_web_url(service, service.target) + 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() + audit(db, "SESSION_CREATE_POOL", f"service={service.slug} session={session_id}", user_id=user.id) + return RedirectResponse(url=f"/s/{session_id}/", status_code=303) + + try: + container_id = create_runtime_container(service, session_id) + except Exception as exc: + logger.exception("session_container_create_failed slug=%s user_id=%s", slug, user.id) + audit(db, "SESSION_CREATE_FAILED", f"slug={slug} err={str(exc)}", user_id=user.id) + raise HTTPException(status_code=502, detail="Session runtime failed to start") + + 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() + + audit(db, "SESSION_CREATE", f"service={service.slug} session={session_id}", user_id=user.id) + ready = wait_for_session_route(session_id) + logger.info("session_route_ready session_id=%s ready=%s", session_id, ready) + return RedirectResponse(url=f"/s/{session_id}/", status_code=303) + + +@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") + redirect_target = f"/s/{session_id}/" + if sess.container_id and sess.container_id.startswith("POOL:"): + redirect_target = f"/s/{session_id}/view" + return HTMLResponse( + content=f""" + + + + + Session Starting + + + +
    +
    Сессия запускается
    +
    Проверка...
    +
      + {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") + if sess.container_id and sess.container_id.startswith("POOL:"): + return HTMLResponse( + content=f""" + + + + + Session {session_id} + + + + + + +""".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 or sess.status != SessionStatus.ACTIVE: + raise HTTPException(status_code=404, detail="Session not found") + sess.last_access_at = now_utc() + db.commit() + return {"ok": True} + + +@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) + route_path = f"/svc/{service.slug}/" if pooled_web and service else f"/s/{session_id}/" + 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" + 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.commit() + ensure_warm_pool(service) + 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"])) + db.commit() + ensure_warm_pool(service) + open_warm_web_url(service, service.target) + 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") + 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_uses_universal_pool(service): + ensure_universal_pool() + return {"ok": True, "pool": get_universal_pool_status()} + ensure_warm_pool(service) + return {"ok": True, "pool": get_pool_status_for_service(service)} + + +@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/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..c7e391d --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.116.1 +uvicorn[standard]==0.35.0 +sqlalchemy==2.0.43 +psycopg2-binary==2.9.10 +python-multipart==0.0.20 +jinja2==3.1.6 +passlib[argon2]==1.7.4 +docker==7.1.0 +itsdangerous==2.2.0 diff --git a/app/static/logo.png b/app/static/logo.png new file mode 100644 index 0000000..170d5f7 Binary files /dev/null and b/app/static/logo.png differ diff --git a/app/static/service-icons/.gitkeep b/app/static/service-icons/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/static/service-icons/svc_4_20260306_134658.png b/app/static/service-icons/svc_4_20260306_134658.png new file mode 100644 index 0000000..7a07065 Binary files /dev/null and b/app/static/service-icons/svc_4_20260306_134658.png differ diff --git a/app/static/service-icons/svc_5_20260306_134448.png b/app/static/service-icons/svc_5_20260306_134448.png new file mode 100644 index 0000000..c9e3568 Binary files /dev/null and b/app/static/service-icons/svc_5_20260306_134448.png differ diff --git a/app/static/service-icons/svc_6_20260306_134739.png b/app/static/service-icons/svc_6_20260306_134739.png new file mode 100644 index 0000000..3852871 Binary files /dev/null and b/app/static/service-icons/svc_6_20260306_134739.png differ diff --git a/app/static/service-icons/svc_7_20260306_143024.png b/app/static/service-icons/svc_7_20260306_143024.png new file mode 100644 index 0000000..a25cb0e Binary files /dev/null and b/app/static/service-icons/svc_7_20260306_143024.png differ diff --git a/app/static/service-placeholder.svg b/app/static/service-placeholder.svg new file mode 100644 index 0000000..9fb8e50 --- /dev/null +++ b/app/static/service-placeholder.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..6386966 --- /dev/null +++ b/app/static/style.css @@ -0,0 +1,330 @@ +:root { + --bg: #f4f6f8; + --fg: #1a2634; + --card: #ffffff; + --accent: #0f5b94; + --line: #d6e1eb; +} +body { + margin: 0; + font-family: "IBM Plex Sans", sans-serif; + color: var(--fg); + background: radial-gradient(circle at top right, #d7e8f7, var(--bg) 45%); +} +.center-box { + min-height: 100vh; + display: grid; + place-content: center; + gap: 1rem; + padding: 1.2rem; +} +.brand-logo { + width: min(440px, 80vw); + height: auto; + justify-self: center; +} +.brand-logo-fullscreen { + width: min(23vw, 360px); + max-height: 14vh; + object-fit: contain; +} +.login-page .panel { + width: min(520px, 92vw); + justify-self: center; +} +.login-title { + text-align: center; + margin: 0; + font-size: clamp(1.1rem, 2.2vw, 1.6rem); +} +.header-logo { + width: 120px; + height: auto; +} +.panel { + background: var(--card); + padding: 1.25rem; + border-radius: 12px; + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.08); + display: grid; + gap: 0.5rem; + min-width: 320px; +} +input, button, textarea { + padding: 0.6rem; + border-radius: 8px; + border: 1px solid #bdd1e2; + font: inherit; +} +textarea { + min-height: 92px; + resize: vertical; +} +button { + background: var(--accent); + color: #fff; + border: none; + cursor: pointer; +} +.btn-link { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.58rem 0.9rem; + border-radius: 8px; + background: #0f5b94; + color: #fff; + text-decoration: none; + font-weight: 600; +} +.btn-link.secondary { + background: #e7eef5; + color: #16344f; +} +.header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding: 1rem 1.25rem; + background: #fff; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; + padding: 1rem; +} +.admin-layout { + display: grid; + grid-template-columns: 1fr; + gap: 1rem; + padding: 1rem; + max-width: 1400px; + margin: 0 auto; +} +.admin-intro { + border: 1px solid var(--line); + border-radius: 10px; + background: #f8fbfe; + padding: 0.8rem 0.9rem; + color: #2b4760; + line-height: 1.4; +} +.summary-strip { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.6rem; + margin: 0.7rem 0 0.8rem; +} +.summary-card { + border: 1px solid var(--line); + border-radius: 10px; + background: #fff; + padding: 0.65rem 0.75rem; +} +.summary-label { + color: #53718c; + font-size: 0.8rem; +} +.summary-value { + margin-top: 0.2rem; + font-weight: 700; + color: #14354f; +} +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.5rem; + margin-bottom: 0.5rem; +} +.labelled-grid { + gap: 0.7rem; +} +.field-col { + display: grid; + gap: 0.32rem; +} +.field-col > span { + font-size: 0.83rem; + color: #44627d; + font-weight: 600; +} +.field-help { + margin: -0.2rem 0 0.4rem; + color: #52708a; + font-size: 0.85rem; +} +.admin-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} +.admin-table th, +.admin-table td { + border-bottom: 1px solid #e2e8ef; + padding: 0.45rem 0.35rem; + text-align: left; + vertical-align: top; +} +.actions { + display: flex; + gap: 0.35rem; +} +.acl-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.35rem; + margin: 0.6rem 0; +} +.tab-row { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} +.tab-btn { + background: #e7eef5; + color: #15344f; +} +.active-tab { + background: #0f5b94; + color: #fff; +} +.split { + display: grid; + grid-template-columns: 320px 1fr; + gap: 1rem; +} +.list-title { + font-weight: 600; + margin-bottom: 0.4rem; +} +.list-box { + border: 1px solid var(--line); + border-radius: 10px; + background: #f8fbfe; + max-height: 460px; + overflow: auto; + padding: 0.3rem; +} +.list-search { + width: 100%; + box-sizing: border-box; + margin-bottom: 0.45rem; + background: #fff; +} +.list-item { + width: 100%; + text-align: left; + margin-bottom: 0.35rem; + background: #fff; + color: #17354f; + border: 1px solid #d6e1eb; +} +.list-item.selected-item { + border-color: #4b8fc4; + box-shadow: inset 0 0 0 1px rgba(75, 143, 196, 0.35); + background: #f1f8ff; +} +.service-row { + display: flex; + align-items: center; + gap: 0.6rem; +} +.service-thumb { + width: 40px; + height: 40px; + border-radius: 8px; + object-fit: cover; + border: 1px solid #d8e3ed; + background: #edf3f9; + flex: 0 0 40px; +} +.service-icon-preview { + width: 80px; + height: 80px; + border-radius: 10px; + object-fit: cover; + border: 1px solid #d8e3ed; + background: #edf3f9; +} +.icon-row { + display: flex; + gap: 0.8rem; + align-items: center; +} +.status-dot { + display: inline-block; + width: 9px; + height: 9px; + border-radius: 999px; + margin-right: 0.35rem; +} +.status-ok { background: #17a35d; } +.status-degraded { background: #f1a312; } +.status-down { background: #d33d3d; } +.health-box, .icon-box { + border: 1px solid #d6e1eb; + background: #f8fbfe; + border-radius: 10px; + padding: 0.8rem; +} +.container-table-wrap { + margin-top: 0.6rem; + max-height: 220px; + overflow: auto; + border: 1px solid #d6e1eb; + border-radius: 8px; + background: #fff; +} +.muted { + color: #4b6178; + font-size: 0.86rem; +} +@media (max-width: 900px) { + .split { + grid-template-columns: 1fr; + } + .brand-logo-fullscreen { + width: min(42vw, 260px); + max-height: 20vh; + } +} +.tile { + display: block; + text-decoration: none; + background: var(--card); + color: inherit; + border-radius: 12px; + padding: 1rem; + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.06); + border: 1px solid transparent; + transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease; +} +.tile:hover { + transform: translateY(-2px); + border-color: #bdd3e6; + box-shadow: 0 14px 26px rgba(15, 64, 103, 0.12); +} +.tile-icon { + width: 56px; + height: 56px; + border-radius: 10px; + object-fit: cover; + border: 1px solid #d8e3ed; + background: #edf3f9; + margin-bottom: 0.5rem; +} +.tile h3 { + margin: 0.1rem 0 0.25rem; +} +.tile p { + margin: 0; + color: #48637c; +} +.tile small { + display: block; + margin-top: 0.45rem; + color: #4b6178; +} diff --git a/app/templates/admin.html b/app/templates/admin.html new file mode 100644 index 0000000..c803654 --- /dev/null +++ b/app/templates/admin.html @@ -0,0 +1,871 @@ + + + + + + Администрирование + + + +
      +
      + +
      МОНТ - инфра полигон | Админ: {{ admin.username }}
      +
      + Главная панель +
      + +
      +
      +
      + Основной режим: WEB. Пользователь выбирает сервис, а портал открывает нужный URL в заранее прогретом браузере. + Поле pool size задаёт, сколько таких прогретых контейнеров держать для конкретного сервиса. +
      +
      +
      +
      + + + + +
      +
      + +
      +

      Пользователи

      +
      +
      +
      Список пользователей
      + +
      + {% for u in users %} + + {% endfor %} +
      +
      + +
      +
      Редактирование пользователя
      +
      + + + + + + +
      +
      + + + +
      + +
      +
      ACL выбранного пользователя
      +
      + {% for s in services %} + + {% endfor %} +
      + +
      + +
      +
      Добавить пользователя
      +
      + + + + + +
      + +
      +
      +
      + + + + + + +
      + + + + diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..48a1fec --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,46 @@ + + + + + + МОНТ - инфра полигон + + + +
      +
      + +
      {{ user.username }}
      +
      +
      + {% if user.is_admin %} + Администрирование + {% endif %} +
      + +
      +
      +
      +
      +
      +
      + Выберите нужный сервис. После клика откроется готовый браузер/сеанс с заранее заданным адресом. +
      +
      +
      + {% for service in services %} + + icon +

      {{ service.name }}

      +

      Открыть сервис

      + {% if service.comment %} + {{ service.comment }} + {% endif %} +
      + {% else %} +
      Нет назначенных сервисов
      + {% endfor %} +
      +
      + + diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..32c9a71 --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,23 @@ + + + + + + МОНТ - инфра полигон + + + +
      + +

      МОНТ - инфра полигон

      +
      + + + + + + +
      +
      + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..262c31b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,85 @@ +services: + traefik: + image: traefik:v3.2 + command: + - --configFile=/etc/traefik/traefik.yml + ports: + - "80:80" + - "443: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 + 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: 1800 + PREWARM_POOL_SIZE: ${PREWARM_POOL_SIZE:-2} + UNIVERSAL_POOL_SIZE: ${UNIVERSAL_POOL_SIZE:-5} + 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/kiosk/Dockerfile b/kiosk/Dockerfile new file mode 100644 index 0000000..36c41d0 --- /dev/null +++ b/kiosk/Dockerfile @@ -0,0 +1,21 @@ +FROM debian:bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + chromium \ + xvfb \ + x11vnc \ + fluxbox \ + novnc \ + websockify \ + python3 \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY entrypoint.sh /entrypoint.sh +COPY manager.py /manager.py +RUN chmod +x /entrypoint.sh + +EXPOSE 6080 +ENTRYPOINT ["/entrypoint.sh"] diff --git a/kiosk/entrypoint.sh b/kiosk/entrypoint.sh new file mode 100755 index 0000000..5b43544 --- /dev/null +++ b/kiosk/entrypoint.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +set -euo pipefail + +TARGET_URL="${TARGET_URL:-https://example.com}" +SESSION_ID="${SESSION_ID:-unknown}" +IDLE_TIMEOUT="${IDLE_TIMEOUT:-1800}" +ENABLE_HEARTBEAT="${ENABLE_HEARTBEAT:-1}" +TOUCH_PATH="${TOUCH_PATH:-/api/sessions/${SESSION_ID}/touch}" +UNIVERSAL_WEB="${UNIVERSAL_WEB:-0}" +START_URL="${START_URL:-about:blank}" +HOME_URL="${HOME_URL:-$TARGET_URL}" +SCREEN_GEOMETRY="${SCREEN_GEOMETRY:-1920x1080x24}" +CHROME_WINDOW_SIZE="${CHROME_WINDOW_SIZE:-1920,1080}" + +if [ "$UNIVERSAL_WEB" = "1" ]; then + HOME_URL="${HOME_URL:-$START_URL}" +fi + +mkdir -p /opt/portal +cp -r /usr/share/novnc/* /opt/portal/ + +cat > /opt/portal/index.html < + + + + + Инфра полигон МОНТ + + + +
      + + + + +HTML + +export DISPLAY=:1 +Xvfb :1 -screen 0 "$SCREEN_GEOMETRY" & +fluxbox >/tmp/fluxbox.log 2>&1 & +sleep 1 + +if [ "$UNIVERSAL_WEB" = "1" ]; then + chromium \ + --no-sandbox \ + --disable-dev-shm-usage \ + --disable-gpu \ + --use-gl=swiftshader \ + --kiosk \ + --remote-debugging-address=0.0.0.0 \ + --remote-debugging-port=9222 \ + --remote-allow-origins=* \ + --disable-translate \ + --disable-features=TranslateUI,ExtensionsToolbarMenu \ + --disable-pinch \ + --overscroll-history-navigation=0 \ + --ignore-certificate-errors \ + --allow-insecure-localhost \ + --allow-running-insecure-content \ + --window-size="$CHROME_WINDOW_SIZE" \ + --no-first-run \ + --no-default-browser-check \ + "$START_URL" \ + >/tmp/chromium.log 2>&1 & + python3 /manager.py >/tmp/manager.log 2>&1 & +else + chromium \ + --no-sandbox \ + --disable-dev-shm-usage \ + --disable-gpu \ + --use-gl=swiftshader \ + --kiosk \ + --app="$TARGET_URL" \ + --disable-translate \ + --disable-features=TranslateUI,ExtensionsToolbarMenu \ + --disable-pinch \ + --overscroll-history-navigation=0 \ + --ignore-certificate-errors \ + --allow-insecure-localhost \ + --allow-running-insecure-content \ + --window-size="$CHROME_WINDOW_SIZE" \ + --no-first-run \ + --no-default-browser-check \ + >/tmp/chromium.log 2>&1 & +fi + +x11vnc -display :1 -rfbport 5900 -forever -shared -nopw -noxdamage >/tmp/x11vnc.log 2>&1 & + +exec websockify --verbose --idle-timeout="$IDLE_TIMEOUT" --web=/opt/portal 6080 localhost:5900 diff --git a/kiosk/manager.py b/kiosk/manager.py new file mode 100644 index 0000000..2439a19 --- /dev/null +++ b/kiosk/manager.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +import json +import urllib.parse +import urllib.request +from http.server import BaseHTTPRequestHandler, HTTPServer + + +def _json_get(path: str): + with urllib.request.urlopen(f"http://127.0.0.1:9222{path}", timeout=2) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def _json_put(path: str): + req = urllib.request.Request(f"http://127.0.0.1:9222{path}", method="PUT") + with urllib.request.urlopen(req, timeout=2) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def chromium_open(url: str) -> None: + encoded = urllib.parse.quote(url, safe=':/?#[]@!$&\'()*+,;=%') + opened = _json_put(f"/json/new?{encoded}") + opened_id = opened.get("id") + # Keep exactly one active page tab to prevent tab/memory explosion in warm containers. + pages = _json_get("/json/list") + for page in pages: + page_id = page.get("id") + if page_id and page_id != opened_id: + try: + _json_put(f"/json/close/{page_id}") + except Exception: + pass + + +class Handler(BaseHTTPRequestHandler): + def _json(self, code: int, payload: dict): + body = json.dumps(payload).encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + if self.path == "/health": + self._json(200, {"ok": True}) + return + self._json(404, {"detail": "Not found"}) + + def do_POST(self): + if self.path != "/open": + self._json(404, {"detail": "Not found"}) + return + try: + length = int(self.headers.get("Content-Length", "0")) + raw = self.rfile.read(length) + data = json.loads(raw.decode("utf-8")) if raw else {} + url = (data.get("url") or "").strip() + if not url.startswith("http://") and not url.startswith("https://"): + self._json(400, {"detail": "Invalid URL"}) + return + chromium_open(url) + print(f"open_ok url={url}", flush=True) + self._json(200, {"ok": True, "url": url}) + except Exception as exc: + print(f"open_fail err={exc}", flush=True) + self._json(500, {"detail": str(exc)}) + + def log_message(self, format, *args): + return + + +if __name__ == "__main__": + server = HTTPServer(("0.0.0.0", 7000), Handler) + server.serve_forever() diff --git a/rdp-proxy/Dockerfile b/rdp-proxy/Dockerfile new file mode 100644 index 0000000..9bb6eb0 --- /dev/null +++ b/rdp-proxy/Dockerfile @@ -0,0 +1,19 @@ +FROM debian:bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + xvfb \ + x11vnc \ + freerdp2-x11 \ + novnc \ + websockify \ + ca-certificates \ + fonts-dejavu-core \ + && rm -rf /var/lib/apt/lists/* + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 6080 +ENTRYPOINT ["/entrypoint.sh"] diff --git a/rdp-proxy/entrypoint.sh b/rdp-proxy/entrypoint.sh new file mode 100644 index 0000000..ae472ff --- /dev/null +++ b/rdp-proxy/entrypoint.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +set -euo pipefail + +RDP_HOST="${RDP_HOST:?RDP_HOST is required}" +RDP_PORT="${RDP_PORT:-3389}" +RDP_USER="${RDP_USER:-}" +RDP_PASSWORD="${RDP_PASSWORD:-}" +RDP_DOMAIN="${RDP_DOMAIN:-}" +RDP_SECURITY="${RDP_SECURITY:-}" +SESSION_ID="${SESSION_ID:-unknown}" +IDLE_TIMEOUT="${IDLE_TIMEOUT:-1800}" +ENABLE_HEARTBEAT="${ENABLE_HEARTBEAT:-1}" +TOUCH_PATH="${TOUCH_PATH:-/api/sessions/${SESSION_ID}/touch}" +SCREEN_GEOMETRY="${SCREEN_GEOMETRY:-1920x1080x24}" +DISPLAY_NUM="${DISPLAY_NUM:-:1}" + +mkdir -p /opt/portal +cp -r /usr/share/novnc/* /opt/portal/ + +cat > /opt/portal/index.html < + + + + + RDP Session + + + +
      + + + +HTML + +export DISPLAY="$DISPLAY_NUM" +Xvfb "$DISPLAY_NUM" -screen 0 "$SCREEN_GEOMETRY" >/tmp/xvfb.log 2>&1 & +sleep 1 + +RDP_ARGS=( + "/v:${RDP_HOST}:${RDP_PORT}" + "/cert:ignore" + "/f" + "/dynamic-resolution" + "/gfx-h264:avc444" + "/network:auto" + "+clipboard" +) + +if [ -n "$RDP_SECURITY" ]; then + RDP_ARGS+=("/sec:${RDP_SECURITY}") +fi + +if [ -n "$RDP_USER" ]; then + RDP_ARGS+=("/u:${RDP_USER}") +fi +if [ -n "$RDP_PASSWORD" ]; then + RDP_ARGS+=("/p:${RDP_PASSWORD}") +fi +if [ -n "$RDP_DOMAIN" ]; then + RDP_ARGS+=("/d:${RDP_DOMAIN}") +fi + +xfreerdp "${RDP_ARGS[@]}" >/tmp/xfreerdp.log 2>&1 & + +x11vnc -display "$DISPLAY_NUM" -rfbport 5900 -forever -shared -nopw -noxdamage >/tmp/x11vnc.log 2>&1 & + +exec websockify --verbose --idle-timeout="$IDLE_TIMEOUT" --web=/opt/portal 6080 localhost:5900 diff --git a/scripts/schema.sql b/scripts/schema.sql new file mode 100644 index 0000000..36cbef7 --- /dev/null +++ b/scripts/schema.sql @@ -0,0 +1,48 @@ +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + username VARCHAR(64) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE, + is_admin BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE services ( + id SERIAL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + slug VARCHAR(64) NOT NULL UNIQUE, + type VARCHAR(8) NOT NULL CHECK (type IN ('WEB', 'VNC', 'RDP')), + target TEXT NOT NULL, + comment TEXT NOT NULL DEFAULT '', + icon_path TEXT NOT NULL DEFAULT '', + active BOOLEAN NOT NULL DEFAULT TRUE, + warm_pool_size INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE user_service_access ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + service_id INT NOT NULL REFERENCES services(id) ON DELETE CASCADE, + granted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (user_id, service_id) +); + +CREATE TABLE sessions ( + id UUID PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + service_id INT NOT NULL REFERENCES services(id) ON DELETE CASCADE, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_access_at TIMESTAMPTZ NOT NULL DEFAULT now(), + container_id VARCHAR(128) +); + +CREATE TABLE audit_logs ( + id BIGSERIAL PRIMARY KEY, + user_id INT, + action VARCHAR(128) NOT NULL, + details TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/traefik/dynamic/security.yml b/traefik/dynamic/security.yml new file mode 100644 index 0000000..1c445d4 --- /dev/null +++ b/traefik/dynamic/security.yml @@ -0,0 +1,11 @@ +http: + middlewares: + secure-headers: + headers: + browserXssFilter: true + contentTypeNosniff: true + frameDeny: true + forceSTSHeader: true + stsIncludeSubdomains: true + stsPreload: true + stsSeconds: 31536000 diff --git a/traefik/traefik.yml b/traefik/traefik.yml new file mode 100644 index 0000000..fd7ef21 --- /dev/null +++ b/traefik/traefik.yml @@ -0,0 +1,30 @@ +entryPoints: + web: + address: ":80" + websecure: + address: ":443" + +providers: + docker: + endpoint: "unix:///var/run/docker.sock" + exposedByDefault: false + file: + directory: /etc/traefik/dynamic + watch: true + +certificatesResolvers: + letsencrypt: + acme: + email: admin@4mont.ru + storage: /letsencrypt/acme.json + tlsChallenge: {} + +api: + dashboard: true + insecure: false + +log: + level: INFO + +accessLog: + format: json diff --git a/universal-runtime/Dockerfile b/universal-runtime/Dockerfile new file mode 100644 index 0000000..9f7df4d --- /dev/null +++ b/universal-runtime/Dockerfile @@ -0,0 +1,23 @@ +FROM debian:bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + chromium \ + xvfb \ + x11vnc \ + fluxbox \ + freerdp2-x11 \ + novnc \ + websockify \ + python3 \ + ca-certificates \ + fonts-dejavu-core \ + && rm -rf /var/lib/apt/lists/* + +COPY entrypoint.sh /entrypoint.sh +COPY manager.py /manager.py +RUN chmod +x /entrypoint.sh + +EXPOSE 6080 +ENTRYPOINT ["/entrypoint.sh"] diff --git a/universal-runtime/entrypoint.sh b/universal-runtime/entrypoint.sh new file mode 100644 index 0000000..6bcd53e --- /dev/null +++ b/universal-runtime/entrypoint.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +set -euo pipefail + +IDLE_TIMEOUT="${IDLE_TIMEOUT:-1800}" +SCREEN_GEOMETRY="${SCREEN_GEOMETRY:-1920x1080x24}" +CHROME_WINDOW_SIZE="${CHROME_WINDOW_SIZE:-1920,1080}" +ENABLE_HEARTBEAT="${ENABLE_HEARTBEAT:-1}" +DISPLAY_NUM="${DISPLAY_NUM:-:1}" + +mkdir -p /opt/portal +cp -r /usr/share/novnc/* /opt/portal/ + +cat > /opt/portal/index.html <<'HTML' + + + + + + Universal Session + + + +
      +
      Подключение к слоту...
      + + + +HTML + +export DISPLAY="$DISPLAY_NUM" +export CHROME_WINDOW_SIZE +Xvfb "$DISPLAY_NUM" -screen 0 "$SCREEN_GEOMETRY" >/tmp/xvfb.log 2>&1 & +fluxbox >/tmp/fluxbox.log 2>&1 & +python3 /manager.py >/tmp/manager.log 2>&1 & +x11vnc -display "$DISPLAY_NUM" -rfbport 5900 -forever -shared -nopw -noxdamage >/tmp/x11vnc.log 2>&1 & + +exec websockify --verbose --idle-timeout="$IDLE_TIMEOUT" --web=/opt/portal 6080 localhost:5900 diff --git a/universal-runtime/manager.py b/universal-runtime/manager.py new file mode 100644 index 0000000..3477d1d --- /dev/null +++ b/universal-runtime/manager.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +import json +import os +import signal +import subprocess +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + +DISPLAY = os.environ.get("DISPLAY", ":1") +CHROME_WINDOW_SIZE = os.environ.get("CHROME_WINDOW_SIZE", "1920,1080") + +_state = { + "proc": None, + "mode": "idle", + "target": "", +} +_lock = threading.Lock() + + +def _stop_current() -> None: + proc = _state.get("proc") + if not proc: + return + try: + os.killpg(os.getpgid(proc.pid), signal.SIGTERM) + proc.wait(timeout=4) + except Exception: + try: + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + except Exception: + pass + finally: + _state["proc"] = None + + +def _start_process(cmd: list[str], mode: str, target: str) -> None: + _stop_current() + logf = open("/tmp/session-app.log", "a", buffering=1) + env = os.environ.copy() + env["DISPLAY"] = DISPLAY + proc = subprocess.Popen( # noqa: S603 + cmd, + stdout=logf, + stderr=subprocess.STDOUT, + env=env, + start_new_session=True, + ) + _state["proc"] = proc + _state["mode"] = mode + _state["target"] = target + + +def open_web(url: str) -> None: + cmd = [ + "chromium", + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + "--use-gl=swiftshader", + "--kiosk", + "--disable-translate", + "--disable-features=TranslateUI,ExtensionsToolbarMenu", + "--disable-pinch", + "--overscroll-history-navigation=0", + "--ignore-certificate-errors", + "--allow-insecure-localhost", + "--allow-running-insecure-content", + f"--window-size={CHROME_WINDOW_SIZE}", + "--no-first-run", + "--no-default-browser-check", + url, + ] + _start_process(cmd, "web", url) + + +def open_rdp(payload: dict) -> None: + host = (payload.get("host") or "").strip() + if not host: + raise ValueError("host is required") + port = str(payload.get("port") or "3389").strip() + user = (payload.get("user") or "").strip() + password = (payload.get("password") or "").strip() + domain = (payload.get("domain") or "").strip() + security = (payload.get("security") or "").strip().lower() + + cmd = [ + "xfreerdp", + f"/v:{host}:{port}", + "/cert:ignore", + "/f", + "/dynamic-resolution", + "/network:auto", + "+clipboard", + ] + if security: + cmd.append(f"/sec:{security}") + if user: + cmd.append(f"/u:{user}") + if password: + cmd.append(f"/p:{password}") + if domain: + cmd.append(f"/d:{domain}") + + safe_target = f"{host}:{port}" + _start_process(cmd, "rdp", safe_target) + + +class Handler(BaseHTTPRequestHandler): + def _read_json(self): + length = int(self.headers.get("Content-Length", "0")) + if length <= 0: + return {} + raw = self.rfile.read(length) + return json.loads(raw.decode("utf-8")) + + def _json(self, code: int, payload: dict): + body = json.dumps(payload).encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + if self.path == "/health": + proc = _state.get("proc") + running = bool(proc and proc.poll() is None) + self._json(200, {"ok": True, "mode": _state.get("mode", "idle"), "running": running, "target": _state.get("target", "")}) + return + self._json(404, {"detail": "Not found"}) + + def do_POST(self): + try: + data = self._read_json() + if self.path == "/open": + url = (data.get("url") or "").strip() + if not (url.startswith("http://") or url.startswith("https://")): + self._json(400, {"detail": "Invalid URL"}) + return + with _lock: + open_web(url) + self._json(200, {"ok": True, "mode": "web", "target": url}) + return + if self.path == "/rdp": + with _lock: + open_rdp(data) + self._json(200, {"ok": True, "mode": "rdp"}) + return + self._json(404, {"detail": "Not found"}) + except Exception as exc: + self._json(500, {"detail": str(exc)}) + + def log_message(self, fmt, *args): + return + + +if __name__ == "__main__": + server = HTTPServer(("0.0.0.0", 7000), Handler) + server.serve_forever()