merge: refactor/split-main-py into main
This commit is contained in:
+102
@@ -0,0 +1,102 @@
|
|||||||
|
import secrets
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from itsdangerous import BadSignature, URLSafeTimedSerializer
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from config import COOKIE_MAX_AGE, COOKIE_NAME, CSRF_COOKIE
|
||||||
|
from database import get_db
|
||||||
|
from models import User, UserServiceAccess
|
||||||
|
from utils import now_utc
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
_SIGNING_KEY = os.getenv("SIGNING_KEY", secrets.token_urlsafe(32))
|
||||||
|
serializer = URLSafeTimedSerializer(_SIGNING_KEY, salt="portal-auth")
|
||||||
|
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
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="lax",
|
||||||
|
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
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
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", "7200"))
|
||||||
|
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", "8.0"))
|
||||||
|
GO_POOL_LOCK_TIMEOUT_SECONDS = float(os.getenv("GO_POOL_LOCK_TIMEOUT_SECONDS", "20.0"))
|
||||||
|
POOL_DISPATCH_RETRIES = int(os.getenv("POOL_DISPATCH_RETRIES", "6"))
|
||||||
|
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", "2"))
|
||||||
|
UNIVERSAL_POOL_SIZE = int(os.getenv("UNIVERSAL_POOL_SIZE", "0"))
|
||||||
|
WEB_POOL_SIZE = int(os.getenv("WEB_POOL_SIZE", "20"))
|
||||||
|
WEB_POOL_BUFFER = int(os.getenv("WEB_POOL_BUFFER", "2"))
|
||||||
|
X11VNC_FLAGS = os.getenv("X11VNC_FLAGS", "-wait 5 -defer 5 -threads")
|
||||||
|
MAX_ACTIVE_SERVICES_PER_USER = int(os.getenv("MAX_ACTIVE_SERVICES_PER_USER", "4"))
|
||||||
|
WEB_RESOLUTION_MIN_WIDTH = int(os.getenv("WEB_RESOLUTION_MIN_WIDTH", "1024"))
|
||||||
|
WEB_RESOLUTION_MIN_HEIGHT = int(os.getenv("WEB_RESOLUTION_MIN_HEIGHT", "720"))
|
||||||
|
WEB_RESOLUTION_MAX_WIDTH = int(os.getenv("WEB_RESOLUTION_MAX_WIDTH", "3840"))
|
||||||
|
WEB_RESOLUTION_MAX_HEIGHT = int(os.getenv("WEB_RESOLUTION_MAX_HEIGHT", "2160"))
|
||||||
|
ENABLE_STARTUP_MAINTENANCE = os.getenv("ENABLE_STARTUP_MAINTENANCE", "1") == "1"
|
||||||
|
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")
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||||
|
|
||||||
|
from config import DATABASE_URL
|
||||||
|
|
||||||
|
|
||||||
|
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
||||||
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
+51
-1698
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,212 @@
|
|||||||
|
import datetime as dt
|
||||||
|
import fcntl
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import docker
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from config import ENABLE_STARTUP_MAINTENANCE, SESSION_IDLE_SECONDS, WEB_POOL_SIZE
|
||||||
|
from database import Base, SessionLocal, engine
|
||||||
|
from models import RdpSlot, Service, ServiceType, SessionModel, SessionStatus, User
|
||||||
|
from utils import ensure_icons_dir, now_utc
|
||||||
|
from auth import hash_password
|
||||||
|
from runtime import (
|
||||||
|
_rdp_slot_container_name,
|
||||||
|
disconnect_rdp_slot,
|
||||||
|
docker_client,
|
||||||
|
ensure_schema_compatibility,
|
||||||
|
ensure_universal_pool,
|
||||||
|
ensure_warm_pool,
|
||||||
|
ensure_web_pool,
|
||||||
|
start_rdp_slot_container,
|
||||||
|
stop_runtime_container,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("portal")
|
||||||
|
maintenance_lock_file = None
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
rdp_slots_to_restart: list[int] = []
|
||||||
|
for sess in stale:
|
||||||
|
cid = sess.container_id or ""
|
||||||
|
if cid.startswith("RDPSLOT:"):
|
||||||
|
try:
|
||||||
|
rdp_slots_to_restart.append(int(cid.split(":", 1)[1]))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif cid and not (
|
||||||
|
cid.startswith("POOL:")
|
||||||
|
or cid.startswith("POOLIDX:")
|
||||||
|
or cid.startswith("WEBPOOLIDX:")
|
||||||
|
):
|
||||||
|
stop_runtime_container(cid)
|
||||||
|
sess.status = SessionStatus.EXPIRED
|
||||||
|
if stale:
|
||||||
|
db.commit()
|
||||||
|
for slot_id in rdp_slots_to_restart:
|
||||||
|
threading.Thread(target=disconnect_rdp_slot, args=(slot_id,), daemon=True).start()
|
||||||
|
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", "change_me")
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
def try_acquire_maintenance_leader() -> bool:
|
||||||
|
global maintenance_lock_file
|
||||||
|
if maintenance_lock_file is not None:
|
||||||
|
return True
|
||||||
|
lock_file = open("/tmp/portal-maintenance.lock", "w")
|
||||||
|
try:
|
||||||
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except BlockingIOError:
|
||||||
|
lock_file.close()
|
||||||
|
return False
|
||||||
|
maintenance_lock_file = lock_file
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def run_maintenance_service() -> None:
|
||||||
|
logger.info("maintenance_service_bootstrap_started")
|
||||||
|
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()
|
||||||
|
|
||||||
|
maintenance_lock = open("/tmp/portal-maintenance.lock", "w")
|
||||||
|
fcntl.flock(maintenance_lock.fileno(), fcntl.LOCK_EX)
|
||||||
|
logger.info("maintenance_service_leader_acquired")
|
||||||
|
|
||||||
|
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)
|
||||||
|
elif svc.type == ServiceType.RDP:
|
||||||
|
slots = db.scalars(select(RdpSlot).where(RdpSlot.service_id == svc.id)).all()
|
||||||
|
for slot in slots:
|
||||||
|
try:
|
||||||
|
cname = _rdp_slot_container_name(svc.slug, slot.id)
|
||||||
|
try:
|
||||||
|
c = docker_client().containers.get(cname)
|
||||||
|
if c.status != "running":
|
||||||
|
c.start()
|
||||||
|
except docker.errors.NotFound:
|
||||||
|
start_rdp_slot_container(slot, svc)
|
||||||
|
slot.container_name = cname
|
||||||
|
except Exception:
|
||||||
|
logger.exception("startup_rdp_slot_start_failed slot_id=%s", slot.id)
|
||||||
|
if slots:
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
logger.info("maintenance_service_loop_started")
|
||||||
|
cleanup_loop()
|
||||||
|
|
||||||
|
|
||||||
|
def on_startup() -> None:
|
||||||
|
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()
|
||||||
|
if not try_acquire_maintenance_leader():
|
||||||
|
logger.info("maintenance_leader_skipped")
|
||||||
|
return
|
||||||
|
|
||||||
|
if ENABLE_STARTUP_MAINTENANCE:
|
||||||
|
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)
|
||||||
|
elif svc.type == ServiceType.RDP:
|
||||||
|
slots = db.scalars(select(RdpSlot).where(RdpSlot.service_id == svc.id)).all()
|
||||||
|
for slot in slots:
|
||||||
|
try:
|
||||||
|
cname = _rdp_slot_container_name(svc.slug, slot.id)
|
||||||
|
try:
|
||||||
|
c = docker_client().containers.get(cname)
|
||||||
|
if c.status != "running":
|
||||||
|
c.start()
|
||||||
|
except docker.errors.NotFound:
|
||||||
|
start_rdp_slot_container(slot, svc)
|
||||||
|
slot.container_name = cname
|
||||||
|
except Exception:
|
||||||
|
logger.exception("startup_rdp_slot_start_failed slot_id=%s", slot.id)
|
||||||
|
if slots:
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
thread = threading.Thread(target=cleanup_loop, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
logger.info("maintenance_leader_started")
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import main
|
import maintenance
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main.run_maintenance_service()
|
maintenance.run_maintenance_service()
|
||||||
|
|||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
import datetime as dt
|
||||||
|
import enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean, DateTime, Enum, ForeignKey, Integer, String, Text, UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from database import Base
|
||||||
|
|
||||||
|
|
||||||
|
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="")
|
||||||
|
svc_login: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
svc_password: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
svc_cred_hint: 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 RdpSlot(Base):
|
||||||
|
__tablename__ = "rdp_slots"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
service_id: Mapped[int] = mapped_column(ForeignKey("services.id", ondelete="CASCADE"), index=True)
|
||||||
|
rdp_username: Mapped[str] = mapped_column(String(128))
|
||||||
|
rdp_password: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
container_name: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
|
||||||
|
created_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)
|
||||||
+1162
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 610 B |
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 7.4 KiB |
+56
-9
@@ -38,8 +38,19 @@ body {
|
|||||||
font-size: clamp(1.1rem, 2.2vw, 1.6rem);
|
font-size: clamp(1.1rem, 2.2vw, 1.6rem);
|
||||||
}
|
}
|
||||||
.header-logo {
|
.header-logo {
|
||||||
width: 120px;
|
height: 32px;
|
||||||
height: auto;
|
width: auto;
|
||||||
|
}
|
||||||
|
.page-logo-wrap {
|
||||||
|
position: fixed;
|
||||||
|
left: 1.5rem;
|
||||||
|
top: 4.5rem;
|
||||||
|
z-index: 90;
|
||||||
|
}
|
||||||
|
.page-logo {
|
||||||
|
height: clamp(2.5rem, 5vw, 3.5rem);
|
||||||
|
width: auto;
|
||||||
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
.panel {
|
.panel {
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
@@ -86,10 +97,43 @@ button {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 1rem 1.25rem;
|
padding: 0.5rem 1.5rem;
|
||||||
background: #fff;
|
background: rgba(10, 25, 41, 0.88);
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
backdrop-filter: blur(8px);
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
.header-left { display: flex; align-items: center; }
|
||||||
|
.header-right { display: flex; align-items: center; gap: 0.75rem; }
|
||||||
|
.header-username {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 300;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
}
|
||||||
|
.header-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.3rem 0.9rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid rgba(255,255,255,0.18);
|
||||||
|
background: rgba(255,255,255,0.07);
|
||||||
|
color: rgba(255,255,255,0.8);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.header-btn:hover { background: rgba(255,255,255,0.14); }
|
||||||
|
.header-btn-logout {
|
||||||
|
border-color: rgba(255,80,80,0.35);
|
||||||
|
color: rgba(255,160,160,0.9);
|
||||||
|
}
|
||||||
|
.header-btn-logout:hover { background: rgba(255,60,60,0.15); }
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
@@ -613,8 +657,7 @@ button {
|
|||||||
z-index: 4;
|
z-index: 4;
|
||||||
}
|
}
|
||||||
.dashboard-page .panel,
|
.dashboard-page .panel,
|
||||||
.dashboard-page .tile,
|
.dashboard-page .tile {
|
||||||
.dashboard-page .header {
|
|
||||||
background: rgba(255, 255, 255, 0.55);
|
background: rgba(255, 255, 255, 0.55);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.45);
|
border: 1px solid rgba(255, 255, 255, 0.45);
|
||||||
backdrop-filter: blur(3px);
|
backdrop-filter: blur(3px);
|
||||||
@@ -714,12 +757,16 @@ button {
|
|||||||
background: linear-gradient(180deg, #a8d2ee 0%, #d8ecf9 100%) !important;
|
background: linear-gradient(180deg, #a8d2ee 0%, #d8ecf9 100%) !important;
|
||||||
}
|
}
|
||||||
.dashboard-page .panel,
|
.dashboard-page .panel,
|
||||||
.dashboard-page .tile,
|
.dashboard-page .tile {
|
||||||
.dashboard-page .header {
|
|
||||||
backdrop-filter: none !important;
|
backdrop-filter: none !important;
|
||||||
background: rgba(255, 255, 255, 0.9) !important;
|
background: rgba(255, 255, 255, 0.9) !important;
|
||||||
border: 1px solid rgba(198, 218, 235, 0.9) !important;
|
border: 1px solid rgba(198, 218, 235, 0.9) !important;
|
||||||
}
|
}
|
||||||
|
.dashboard-page .header {
|
||||||
|
background: rgba(10, 25, 41, 0.92) !important;
|
||||||
|
backdrop-filter: blur(8px) !important;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.08) !important;
|
||||||
|
}
|
||||||
.dashboard-page .svc-credentials {
|
.dashboard-page .svc-credentials {
|
||||||
background: rgba(220, 238, 252, 0.9) !important;
|
background: rgba(220, 238, 252, 0.9) !important;
|
||||||
border-color: rgba(180, 210, 235, 0.85) !important;
|
border-color: rgba(180, 210, 235, 0.85) !important;
|
||||||
|
|||||||
@@ -32,19 +32,21 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<div style="display:flex; align-items:center; gap:0.6rem;">
|
<div class="header-left">
|
||||||
<img src="/static/logo.png" alt="MONT" class="header-logo" />
|
<span class="header-username">{{ user.username }}</span>
|
||||||
<div>{{ user.username }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex; gap:0.5rem;">
|
<div class="header-right">
|
||||||
{% if user.is_admin %}
|
{% if user.is_admin %}
|
||||||
<a href="/admin" class="btn-link secondary">Администрирование</a>
|
<a href="/admin" class="header-btn">Администрирование</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="post" action="/logout">
|
<form method="post" action="/logout">
|
||||||
<button type="submit">Выход</button>
|
<button type="submit" class="header-btn header-btn-logout">Выход</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<div class="page-logo-wrap">
|
||||||
|
<img src="/static/logo.png" alt="MONT" class="page-logo" />
|
||||||
|
</div>
|
||||||
<main class="admin-layout">
|
<main class="admin-layout">
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="admin-intro">Добро пожаловать в инфраструктурную песочницу</div>
|
<div class="admin-intro">Добро пожаловать в инфраструктурную песочницу</div>
|
||||||
|
|||||||
+181
@@ -0,0 +1,181 @@
|
|||||||
|
import datetime as dt
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import contextvars
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import parse_qs, unquote, urlparse
|
||||||
|
|
||||||
|
import mistune
|
||||||
|
from fastapi import HTTPException, UploadFile
|
||||||
|
from markupsafe import Markup
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
ICON_UPLOAD_MAX_BYTES, ICON_UPLOAD_TYPES, MAX_ACTIVE_SERVICES_PER_USER,
|
||||||
|
SERVICE_ICONS_DIR, SESSION_IDLE_SECONDS,
|
||||||
|
)
|
||||||
|
from models import AuditLog, Category, ServiceCategory, SessionModel, SessionStatus
|
||||||
|
|
||||||
|
|
||||||
|
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=(",", ":")))
|
||||||
|
|
||||||
|
|
||||||
|
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}"
|
||||||
|
|
||||||
|
|
||||||
|
_md = mistune.create_markdown(
|
||||||
|
escape=True,
|
||||||
|
plugins=["strikethrough", "table", "task_lists"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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("")
|
||||||
|
return Markup(_md(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 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 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 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, 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}"
|
||||||
@@ -3,6 +3,8 @@ FROM debian:bookworm-slim
|
|||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
tini \
|
||||||
|
python3 \
|
||||||
xvfb \
|
xvfb \
|
||||||
x11vnc \
|
x11vnc \
|
||||||
freerdp2-x11 \
|
freerdp2-x11 \
|
||||||
@@ -14,7 +16,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
COPY manager.py /manager.py
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
EXPOSE 6080
|
EXPOSE 6080 7001
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
|
||||||
|
|||||||
+44
-54
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
RDP_HOST="${RDP_HOST:?RDP_HOST is required}"
|
RDP_HOST="${RDP_HOST:-}"
|
||||||
RDP_PORT="${RDP_PORT:-3389}"
|
RDP_PORT="${RDP_PORT:-3389}"
|
||||||
RDP_USER="${RDP_USER:-}"
|
RDP_USER="${RDP_USER:-}"
|
||||||
RDP_PASSWORD="${RDP_PASSWORD:-}"
|
RDP_PASSWORD="${RDP_PASSWORD:-}"
|
||||||
@@ -40,8 +40,10 @@ cat > /opt/portal/index.html <<HTML
|
|||||||
position:fixed;left:16px;top:64px;z-index:99;display:flex;gap:10px;
|
position:fixed;left:16px;top:64px;z-index:99;display:flex;gap:10px;
|
||||||
background:linear-gradient(180deg,rgba(15,24,36,.78),rgba(9,14,22,.86));
|
background:linear-gradient(180deg,rgba(15,24,36,.78),rgba(9,14,22,.86));
|
||||||
border:1px solid rgba(255,255,255,.22);backdrop-filter:blur(5px);
|
border:1px solid rgba(255,255,255,.22);backdrop-filter:blur(5px);
|
||||||
box-shadow:0 10px 28px rgba(0,0,0,.36);padding:9px 10px;border-radius:14px
|
box-shadow:0 10px 28px rgba(0,0,0,.36);padding:9px 10px;border-radius:14px;
|
||||||
|
cursor:grab;user-select:none;touch-action:none
|
||||||
}
|
}
|
||||||
|
.nav-panel.dragging{cursor:grabbing;opacity:.85}
|
||||||
.nav-btn{
|
.nav-btn{
|
||||||
border:1px solid rgba(255,255,255,.26);border-radius:999px;padding:9px 14px;cursor:pointer;
|
border:1px solid rgba(255,255,255,.26);border-radius:999px;padding:9px 14px;cursor:pointer;
|
||||||
background:linear-gradient(180deg,#2a8cd6,#1668a6);color:#fff;font:700 13px/1 sans-serif;
|
background:linear-gradient(180deg,#2a8cd6,#1668a6);color:#fff;font:700 13px/1 sans-serif;
|
||||||
@@ -176,6 +178,33 @@ cat > /opt/portal/index.html <<HTML
|
|||||||
document.getElementById('btn-home').addEventListener('click', goHome);
|
document.getElementById('btn-home').addEventListener('click', goHome);
|
||||||
document.addEventListener('contextmenu', (e) => e.preventDefault());
|
document.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||||
|
|
||||||
|
(function(){
|
||||||
|
const p = document.querySelector('.nav-panel');
|
||||||
|
const SK = 'rdp_nav_pos';
|
||||||
|
try { const s = JSON.parse(localStorage.getItem(SK)); if(s){p.style.left=s.x+'px';p.style.top=s.y+'px';} } catch(e){}
|
||||||
|
let ox, oy, dragged = false;
|
||||||
|
p.addEventListener('pointerdown', e => {
|
||||||
|
if (e.target.closest('button')) return;
|
||||||
|
dragged = false;
|
||||||
|
ox = e.clientX - p.getBoundingClientRect().left;
|
||||||
|
oy = e.clientY - p.getBoundingClientRect().top;
|
||||||
|
p.setPointerCapture(e.pointerId);
|
||||||
|
p.classList.add('dragging');
|
||||||
|
});
|
||||||
|
p.addEventListener('pointermove', e => {
|
||||||
|
if (!p.hasPointerCapture(e.pointerId)) return;
|
||||||
|
dragged = true;
|
||||||
|
const x = Math.max(0, Math.min(window.innerWidth - p.offsetWidth, e.clientX - ox));
|
||||||
|
const y = Math.max(0, Math.min(window.innerHeight - p.offsetHeight, e.clientY - oy));
|
||||||
|
p.style.left = x + 'px';
|
||||||
|
p.style.top = y + 'px';
|
||||||
|
});
|
||||||
|
p.addEventListener('pointerup', () => {
|
||||||
|
p.classList.remove('dragging');
|
||||||
|
if (dragged) try { localStorage.setItem(SK, JSON.stringify({x: parseInt(p.style.left), y: parseInt(p.style.top)})); } catch(e){}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
connect();
|
connect();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
@@ -186,69 +215,30 @@ export DISPLAY="$DISPLAY_NUM"
|
|||||||
DISPLAY_N="${DISPLAY_NUM#:}"
|
DISPLAY_N="${DISPLAY_NUM#:}"
|
||||||
rm -f "/tmp/.X${DISPLAY_N}-lock" "/tmp/.X11-unix/X${DISPLAY_N}" 2>/dev/null || true
|
rm -f "/tmp/.X${DISPLAY_N}-lock" "/tmp/.X11-unix/X${DISPLAY_N}" 2>/dev/null || true
|
||||||
Xvfb "$DISPLAY_NUM" -screen 0 "$SCREEN_GEOMETRY" >/tmp/xvfb.log 2>&1 &
|
Xvfb "$DISPLAY_NUM" -screen 0 "$SCREEN_GEOMETRY" >/tmp/xvfb.log 2>&1 &
|
||||||
|
XVFB_PID=$!
|
||||||
sleep 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 &
|
|
||||||
XFREERDP_PID=$!
|
|
||||||
|
|
||||||
x11vnc -display "$DISPLAY_NUM" -rfbport 5900 -forever -shared -nopw -noxdamage >/tmp/x11vnc.log 2>&1 &
|
x11vnc -display "$DISPLAY_NUM" -rfbport 5900 -forever -shared -nopw -noxdamage >/tmp/x11vnc.log 2>&1 &
|
||||||
X11VNC_PID=$!
|
X11VNC_PID=$!
|
||||||
|
|
||||||
websockify --verbose --idle-timeout="$IDLE_TIMEOUT" --web=/opt/portal 6080 localhost:5900 >/tmp/websockify.log 2>&1 &
|
websockify --verbose --idle-timeout="$IDLE_TIMEOUT" --web=/opt/portal 6080 localhost:5900 >/tmp/websockify.log 2>&1 &
|
||||||
WEBSOCKIFY_PID=$!
|
WEBSOCKIFY_PID=$!
|
||||||
|
|
||||||
|
python3 /manager.py >/tmp/manager.log 2>&1 &
|
||||||
|
MANAGER_PID=$!
|
||||||
|
|
||||||
# Anti-idle: send Shift key to xfreerdp window every 30s to prevent remote lock screen
|
|
||||||
anti_idle_loop() {
|
|
||||||
sleep 5
|
|
||||||
while true; do
|
|
||||||
WID=$(DISPLAY="$DISPLAY_NUM" xdotool search --pid "$XFREERDP_PID" 2>/dev/null | head -1)
|
|
||||||
if [ -n "$WID" ]; then
|
|
||||||
DISPLAY="$DISPLAY_NUM" xdotool key --window "$WID" shift 2>/dev/null || true
|
|
||||||
else
|
|
||||||
DISPLAY="$DISPLAY_NUM" xdotool mousemove --sync 500 300 2>/dev/null || true
|
|
||||||
sleep 1
|
|
||||||
DISPLAY="$DISPLAY_NUM" xdotool mousemove --sync 600 400 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
sleep 30
|
|
||||||
done
|
|
||||||
}
|
|
||||||
anti_idle_loop &
|
|
||||||
ANTI_IDLE_PID=$!
|
|
||||||
|
|
||||||
# Graceful shutdown on docker stop (SIGTERM) — exit 0 so Docker does NOT auto-restart
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
kill "$XFREERDP_PID" "$X11VNC_PID" "$WEBSOCKIFY_PID" "$ANTI_IDLE_PID" 2>/dev/null
|
python3 -c "
|
||||||
|
import urllib.request, sys
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen('http://localhost:7001/disconnect', b'', timeout=3)
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write(str(e) + '\n')
|
||||||
|
" 2>/dev/null || true
|
||||||
|
kill "$X11VNC_PID" "$WEBSOCKIFY_PID" "$MANAGER_PID" "$XVFB_PID" 2>/dev/null || true
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
trap cleanup TERM INT
|
trap cleanup TERM INT
|
||||||
|
|
||||||
# Monitor xfreerdp — when it exits (disconnect/logoff) restart the container
|
wait "$WEBSOCKIFY_PID" || true
|
||||||
wait "$XFREERDP_PID"
|
cleanup
|
||||||
echo "xfreerdp exited (code $?), triggering container restart" >> /tmp/xfreerdp.log
|
|
||||||
kill "$X11VNC_PID" "$WEBSOCKIFY_PID" 2>/dev/null
|
|
||||||
exit 1
|
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""On-demand xfreerdp manager. HTTP on port 7001."""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
log = logging.getLogger("rdp-manager")
|
||||||
|
|
||||||
|
DISPLAY = os.environ.get("DISPLAY", ":1")
|
||||||
|
RDP_HOST = os.environ.get("RDP_HOST", "")
|
||||||
|
RDP_PORT = os.environ.get("RDP_PORT", "3389")
|
||||||
|
RDP_USER = os.environ.get("RDP_USER", "")
|
||||||
|
RDP_PASSWORD = os.environ.get("RDP_PASSWORD", "")
|
||||||
|
RDP_DOMAIN = os.environ.get("RDP_DOMAIN", "")
|
||||||
|
RDP_SECURITY = os.environ.get("RDP_SECURITY", "")
|
||||||
|
|
||||||
|
STATE_FILE = "/tmp/rdp_state.json"
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_proc: subprocess.Popen | None = None
|
||||||
|
_should_be_connected = False
|
||||||
|
|
||||||
|
|
||||||
|
def _save_state():
|
||||||
|
try:
|
||||||
|
with open(STATE_FILE, "w") as f:
|
||||||
|
json.dump({"should_be_connected": _should_be_connected}, f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _load_state() -> bool:
|
||||||
|
try:
|
||||||
|
with open(STATE_FILE) as f:
|
||||||
|
return json.load(f).get("should_be_connected", False)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _build_args():
|
||||||
|
args = [
|
||||||
|
"xfreerdp",
|
||||||
|
f"/v:{RDP_HOST}:{RDP_PORT}",
|
||||||
|
"/cert:ignore",
|
||||||
|
"/f",
|
||||||
|
"/dynamic-resolution",
|
||||||
|
"/gfx-h264:avc444",
|
||||||
|
"/network:auto",
|
||||||
|
"+clipboard",
|
||||||
|
]
|
||||||
|
if RDP_SECURITY:
|
||||||
|
args.append(f"/sec:{RDP_SECURITY}")
|
||||||
|
if RDP_USER:
|
||||||
|
args.append(f"/u:{RDP_USER}")
|
||||||
|
if RDP_PASSWORD:
|
||||||
|
args.append(f"/p:{RDP_PASSWORD}")
|
||||||
|
if RDP_DOMAIN:
|
||||||
|
args.append(f"/d:{RDP_DOMAIN}")
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def _launch():
|
||||||
|
global _proc
|
||||||
|
env = dict(os.environ)
|
||||||
|
env["DISPLAY"] = DISPLAY
|
||||||
|
log_file = open("/tmp/xfreerdp.log", "a")
|
||||||
|
_proc = subprocess.Popen(_build_args(), stdout=log_file, stderr=log_file, env=env)
|
||||||
|
log.info("xfreerdp started pid=%s target=%s:%s", _proc.pid, RDP_HOST, RDP_PORT)
|
||||||
|
return _proc
|
||||||
|
|
||||||
|
|
||||||
|
def _monitor_loop():
|
||||||
|
while True:
|
||||||
|
time.sleep(5)
|
||||||
|
with _lock:
|
||||||
|
if not _should_be_connected:
|
||||||
|
continue
|
||||||
|
if _proc is None or _proc.poll() is not None:
|
||||||
|
log.info("xfreerdp exited unexpectedly, reconnecting in 3s")
|
||||||
|
time.sleep(3)
|
||||||
|
_launch()
|
||||||
|
|
||||||
|
|
||||||
|
def _anti_idle_loop():
|
||||||
|
env = {**os.environ, "DISPLAY": DISPLAY}
|
||||||
|
toggle = False
|
||||||
|
while True:
|
||||||
|
time.sleep(60)
|
||||||
|
with _lock:
|
||||||
|
active = _should_be_connected and _proc is not None and _proc.poll() is None
|
||||||
|
if not active:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["xdotool", "search", "--name", "FreeRDP"],
|
||||||
|
env=env, capture_output=True, timeout=5,
|
||||||
|
)
|
||||||
|
win_id = r.stdout.decode().strip().splitlines()[0] if r.stdout.strip() else ""
|
||||||
|
if win_id:
|
||||||
|
x, y = (5, 80) if toggle else (6, 81)
|
||||||
|
subprocess.run(
|
||||||
|
["xdotool", "mousemove", "--window", win_id, str(x), str(y)],
|
||||||
|
env=env, capture_output=True, timeout=5,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["xdotool", "click", "--window", win_id, "1"],
|
||||||
|
env=env, capture_output=True, timeout=5,
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["xdotool", "key", "--window", win_id, "--clearmodifiers", "shift"],
|
||||||
|
env=env, capture_output=True, timeout=5,
|
||||||
|
)
|
||||||
|
toggle = not toggle
|
||||||
|
log.debug("anti_idle click window=%s pos=%s,%s", win_id, x, y)
|
||||||
|
else:
|
||||||
|
log.debug("anti_idle: xfreerdp window not found")
|
||||||
|
except Exception as e:
|
||||||
|
log.debug("anti_idle error: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
threading.Thread(target=_monitor_loop, daemon=True).start()
|
||||||
|
threading.Thread(target=_anti_idle_loop, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _json(self, code, data):
|
||||||
|
body = json.dumps(data).encode()
|
||||||
|
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":
|
||||||
|
with _lock:
|
||||||
|
connected = _proc is not None and _proc.poll() is None
|
||||||
|
pid = _proc.pid if connected else None
|
||||||
|
self._json(200, {
|
||||||
|
"connected": connected,
|
||||||
|
"pid": pid,
|
||||||
|
"target": f"{RDP_HOST}:{RDP_PORT}",
|
||||||
|
"should_be_connected": _should_be_connected,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
self._json(404, {"error": "not found"})
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
global _proc, _should_be_connected
|
||||||
|
if self.path == "/connect":
|
||||||
|
with _lock:
|
||||||
|
_should_be_connected = True
|
||||||
|
_save_state()
|
||||||
|
if _proc is not None and _proc.poll() is None:
|
||||||
|
self._json(200, {"ok": True, "pid": _proc.pid, "already": True})
|
||||||
|
return
|
||||||
|
proc = _launch()
|
||||||
|
self._json(200, {"ok": True, "pid": proc.pid})
|
||||||
|
elif self.path == "/disconnect":
|
||||||
|
with _lock:
|
||||||
|
_should_be_connected = False
|
||||||
|
_save_state()
|
||||||
|
if _proc is not None:
|
||||||
|
_proc.terminate()
|
||||||
|
try:
|
||||||
|
_proc.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
_proc.kill()
|
||||||
|
_proc = None
|
||||||
|
log.info("xfreerdp disconnected")
|
||||||
|
self._json(200, {"ok": True})
|
||||||
|
else:
|
||||||
|
self._json(404, {"error": "not found"})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if not RDP_HOST:
|
||||||
|
log.warning("RDP_HOST not set — connect calls will fail")
|
||||||
|
if _load_state():
|
||||||
|
log.info("restoring state: reconnecting xfreerdp")
|
||||||
|
_should_be_connected = True
|
||||||
|
_launch()
|
||||||
|
log.info("manager started on :7001")
|
||||||
|
HTTPServer(("0.0.0.0", 7001), Handler).serve_forever()
|
||||||
@@ -3,6 +3,7 @@ FROM debian:bookworm-slim
|
|||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
tini \
|
||||||
chromium \
|
chromium \
|
||||||
xvfb \
|
xvfb \
|
||||||
x11vnc \
|
x11vnc \
|
||||||
@@ -23,4 +24,4 @@ COPY manager.py /manager.py
|
|||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
EXPOSE 6080
|
EXPOSE 6080
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ cat > /opt/portal/index.html <<'HTML'
|
|||||||
.nav-panel{
|
.nav-panel{
|
||||||
position:fixed;left:16px;top:64px;z-index:99;display:flex;gap:10px;
|
position:fixed;left:16px;top:64px;z-index:99;display:flex;gap:10px;
|
||||||
background:linear-gradient(180deg,rgba(15,24,36,.78),rgba(9,14,22,.86));border:1px solid rgba(255,255,255,.22);backdrop-filter: blur(5px);
|
background:linear-gradient(180deg,rgba(15,24,36,.78),rgba(9,14,22,.86));border:1px solid rgba(255,255,255,.22);backdrop-filter: blur(5px);
|
||||||
box-shadow:0 10px 28px rgba(0,0,0,.36);padding:9px 10px;border-radius:14px
|
box-shadow:0 10px 28px rgba(0,0,0,.36);padding:9px 10px;border-radius:14px;
|
||||||
|
cursor:grab;user-select:none;touch-action:none
|
||||||
}
|
}
|
||||||
.nav-btn{
|
.nav-btn{
|
||||||
border:1px solid rgba(255,255,255,.26);border-radius:999px;padding:9px 14px;cursor:pointer;letter-spacing:.01em;
|
border:1px solid rgba(255,255,255,.26);border-radius:999px;padding:9px 14px;cursor:pointer;letter-spacing:.01em;
|
||||||
@@ -50,6 +51,7 @@ cat > /opt/portal/index.html <<'HTML'
|
|||||||
}
|
}
|
||||||
.nav-btn:hover{filter:brightness(1.08)}
|
.nav-btn:hover{filter:brightness(1.08)}
|
||||||
.nav-btn:active{transform:translateY(1px)}
|
.nav-btn:active{transform:translateY(1px)}
|
||||||
|
.nav-panel.dragging{cursor:grabbing;opacity:.85}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -253,6 +255,33 @@ cat > /opt/portal/index.html <<'HTML'
|
|||||||
document.getElementById('btn-home').addEventListener('click', goHome);
|
document.getElementById('btn-home').addEventListener('click', goHome);
|
||||||
document.addEventListener('contextmenu', (e) => e.preventDefault());
|
document.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||||
|
|
||||||
|
(function(){
|
||||||
|
const p = document.querySelector('.nav-panel');
|
||||||
|
const SK = 'portal_nav_pos';
|
||||||
|
try { const s = JSON.parse(localStorage.getItem(SK)); if(s){p.style.left=s.x+'px';p.style.top=s.y+'px';} } catch(e){}
|
||||||
|
let ox, oy, dragged = false;
|
||||||
|
p.addEventListener('pointerdown', e => {
|
||||||
|
if (e.target.closest('button')) return;
|
||||||
|
dragged = false;
|
||||||
|
ox = e.clientX - p.getBoundingClientRect().left;
|
||||||
|
oy = e.clientY - p.getBoundingClientRect().top;
|
||||||
|
p.setPointerCapture(e.pointerId);
|
||||||
|
p.classList.add('dragging');
|
||||||
|
});
|
||||||
|
p.addEventListener('pointermove', e => {
|
||||||
|
if (!p.hasPointerCapture(e.pointerId)) return;
|
||||||
|
dragged = true;
|
||||||
|
const x = Math.max(0, Math.min(window.innerWidth - p.offsetWidth, e.clientX - ox));
|
||||||
|
const y = Math.max(0, Math.min(window.innerHeight - p.offsetHeight, e.clientY - oy));
|
||||||
|
p.style.left = x + 'px';
|
||||||
|
p.style.top = y + 'px';
|
||||||
|
});
|
||||||
|
p.addEventListener('pointerup', () => {
|
||||||
|
p.classList.remove('dragging');
|
||||||
|
if (dragged) try { localStorage.setItem(SK, JSON.stringify({x: parseInt(p.style.left), y: parseInt(p.style.top)})); } catch(e){}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
connectRfb('Подключение к слоту...');
|
connectRfb('Подключение к слоту...');
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -352,8 +352,7 @@ def open_web(
|
|||||||
safe_w, safe_h = apply_resolution(width, height)
|
safe_w, safe_h = apply_resolution(width, height)
|
||||||
profile_dir = _create_chrome_profile()
|
profile_dir = _create_chrome_profile()
|
||||||
extension_dir = _create_autofill_extension(login, password)
|
extension_dir = _create_autofill_extension(login, password)
|
||||||
# Embed credentials in URL for HTTP Basic Auth (no dialog shown)
|
url_with_creds = url # credentials in URL break SPA fetch; extension handles auth
|
||||||
url_with_creds = _url_with_credentials(url, login, password)
|
|
||||||
|
|
||||||
# Use the real Chromium binary directly to avoid the Debian wrapper which
|
# Use the real Chromium binary directly to avoid the Debian wrapper which
|
||||||
# injects an empty `--load-extension=` from /etc/chromium.d/extensions.
|
# injects an empty `--load-extension=` from /etc/chromium.d/extensions.
|
||||||
|
|||||||
Reference in New Issue
Block a user