merge: refactor/split-main-py into main

This commit is contained in:
2026-05-04 14:46:05 +00:00
20 changed files with 2218 additions and 1776 deletions
View File
+102
View File
@@ -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
+35
View File
@@ -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")
+20
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+212
View File
@@ -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")
+2 -3
View File
@@ -1,5 +1,4 @@
import main import maintenance
if __name__ == "__main__": if __name__ == "__main__":
main.run_maintenance_service() maintenance.run_maintenance_service()
+115
View File
@@ -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
View File
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
View File
@@ -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;
+8 -6
View File
@@ -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
View File
@@ -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}"
+5 -2
View File
@@ -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
View File
@@ -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
+192
View File
@@ -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()
+2 -1
View File
@@ -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"]
+30 -1
View File
@@ -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>
+1 -2
View File
@@ -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.