feat: RDP ACL exclusivity, mobile wall, nav buttons, resolution xrandr
- RDP сервис может быть назначен только одному пользователю в ACL - Мобильная заглушка на dashboard при ширине < 1024px - rdp-proxy: кнопки навигации, спиннер Ожидайте, реконнект - session_wait_page: тёмная тема, CSS спиннер - kiosk/universal-runtime manager.py: xrandr + cvt --newmode для resolution - Dockerfiles: x11-xserver-utils, x11-utils
This commit is contained in:
+59
-15
@@ -43,21 +43,21 @@ DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+psycopg2://portal:portal@db
|
||||
COOKIE_NAME = "portal_auth"
|
||||
CSRF_COOKIE = "csrf_token"
|
||||
COOKIE_MAX_AGE = 8 * 60 * 60
|
||||
SESSION_IDLE_SECONDS = int(os.getenv("SESSION_IDLE_SECONDS", "300"))
|
||||
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", "5.0"))
|
||||
POOL_DISPATCH_RETRIES = int(os.getenv("POOL_DISPATCH_RETRIES", "4"))
|
||||
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", "0"))
|
||||
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", "5"))
|
||||
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 -ncache 10 -threads")
|
||||
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"))
|
||||
@@ -1521,7 +1521,7 @@ def cleanup_loop():
|
||||
|
||||
def bootstrap_admin():
|
||||
admin_user = os.getenv("ADMIN_USERNAME", "admin")
|
||||
admin_password = os.getenv("ADMIN_PASSWORD", "admin123")
|
||||
admin_password = os.getenv("ADMIN_PASSWORD", "change_me")
|
||||
ttl_days = int(os.getenv("ADMIN_TTL_DAYS", "3650"))
|
||||
|
||||
db = SessionLocal()
|
||||
@@ -1801,6 +1801,19 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi
|
||||
),
|
||||
{"cutoff": cutoff},
|
||||
).mappings().all()
|
||||
rdp_occupied_by: dict[int, int] = {}
|
||||
rdp_occupied_username: dict[int, str] = {}
|
||||
rdp_ids = [s.id for s in rdp_services]
|
||||
if rdp_ids:
|
||||
rdp_acl_rows = db.execute(
|
||||
select(UserServiceAccess.service_id, UserServiceAccess.user_id, User.username)
|
||||
.join(User, User.id == UserServiceAccess.user_id)
|
||||
.where(UserServiceAccess.service_id.in_(rdp_ids))
|
||||
).all()
|
||||
for row in rdp_acl_rows:
|
||||
if row.service_id not in rdp_occupied_by:
|
||||
rdp_occupied_by[row.service_id] = row.user_id
|
||||
rdp_occupied_username[row.service_id] = row.username
|
||||
return templates.TemplateResponse(
|
||||
"admin.html",
|
||||
{
|
||||
@@ -1823,6 +1836,8 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi
|
||||
"online_sessions": online_sessions,
|
||||
"csrf_token": request.cookies.get(CSRF_COOKIE, ""),
|
||||
"max_active_services_per_user": MAX_ACTIVE_SERVICES_PER_USER,
|
||||
"rdp_occupied_by": rdp_occupied_by,
|
||||
"rdp_occupied_username": rdp_occupied_username,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2205,6 +2220,8 @@ def session_wait_page(session_id: str, request: Request, user: User = Depends(re
|
||||
raise HTTPException(status_code=410, detail="Session is not active")
|
||||
service = db.get(Service, sess.service_id)
|
||||
service_title = service.name if service else "Сервис"
|
||||
is_rdp = service and service.type == ServiceType.RDP
|
||||
label = "Ожидайте..." if is_rdp else "Сессия запускается..."
|
||||
redirect_target = session_redirect_url(sess)
|
||||
return HTMLResponse(
|
||||
content=f"""
|
||||
@@ -2214,20 +2231,28 @@ def session_wait_page(session_id: str, request: Request, user: User = Depends(re
|
||||
<meta charset='utf-8'>
|
||||
<title>{service_title}</title>
|
||||
<style>
|
||||
body {{ font-family: sans-serif; background: #f4f6f8; display: grid; place-items: center; height: 100vh; margin: 0; color:#1b3145; }}
|
||||
.card {{ background: #fff; padding: 1rem 1.2rem; border-radius: 10px; box-shadow: 0 8px 20px rgba(0,0,0,.08); min-width: 340px; }}
|
||||
.title {{ font-weight: 700; margin-bottom: 0.5rem; }}
|
||||
.state {{ margin-bottom: 0.6rem; }}
|
||||
ul {{ margin: 0; padding-left: 1.1rem; }}
|
||||
li {{ margin: 0.2rem 0; }}
|
||||
*{{box-sizing:border-box}}
|
||||
body{{font-family:sans-serif;background:#0f1720;display:grid;place-items:center;height:100vh;margin:0;color:#dce8f5}}
|
||||
.card{{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12);padding:1.6rem 2rem;border-radius:14px;
|
||||
box-shadow:0 12px 32px rgba(0,0,0,.4);min-width:320px;max-width:440px;text-align:center}}
|
||||
.spinner{{width:48px;height:48px;border:4px solid rgba(220,232,245,.15);border-top-color:#2a8cd6;
|
||||
border-radius:50%;animation:spin .9s linear infinite;margin:0 auto 1.2rem}}
|
||||
@keyframes spin{{to{{transform:rotate(360deg)}}}}
|
||||
.title{{font-size:1.15rem;font-weight:700;margin-bottom:.5rem;color:#fff}}
|
||||
.state{{font-size:.9rem;color:#a0b8cc;margin-bottom:.8rem;min-height:1.2em}}
|
||||
ul{{margin:0;padding:0;list-style:none;font-size:.82rem;color:#7a99b0;text-align:left}}
|
||||
li::before{{content:"· ";color:#2a8cd6}}
|
||||
li+li{{margin-top:.2rem}}
|
||||
.sid{{display:block;margin-top:1.2rem;font-size:.7rem;color:rgba(160,184,204,.4);word-break:break-all}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="title">Сессия запускается</div>
|
||||
<div class="spinner"></div>
|
||||
<div class="title">{label}</div>
|
||||
<div class="state" id="state">Проверка...</div>
|
||||
<ul id="steps"></ul>
|
||||
<small>{session_id}</small>
|
||||
<span class="sid">{session_id}</span>
|
||||
</div>
|
||||
<script>
|
||||
const sessionId = "{session_id}";
|
||||
@@ -2654,6 +2679,25 @@ def set_acl(user_id: int, payload: dict, request: Request, _: User = Depends(req
|
||||
existing = db.scalars(select(UserServiceAccess).where(UserServiceAccess.user_id == user_id)).all()
|
||||
existing_map = {x.service_id: x for x in existing}
|
||||
|
||||
# Check RDP exclusivity: each RDP service can belong to only one user in ACL
|
||||
all_rdp_ids_in_payload = set()
|
||||
for sid in service_ids:
|
||||
svc = db.get(Service, sid)
|
||||
if svc and svc.type == ServiceType.RDP:
|
||||
all_rdp_ids_in_payload.add(sid)
|
||||
if all_rdp_ids_in_payload:
|
||||
acl_conflicts = db.execute(
|
||||
select(UserServiceAccess.service_id, User.username)
|
||||
.join(User, User.id == UserServiceAccess.user_id)
|
||||
.where(
|
||||
UserServiceAccess.service_id.in_(all_rdp_ids_in_payload),
|
||||
UserServiceAccess.user_id != user_id,
|
||||
)
|
||||
).all()
|
||||
if acl_conflicts:
|
||||
blocked = ", ".join(f'"{row.username}"' for row in acl_conflicts)
|
||||
raise HTTPException(status_code=409, detail=f"RDP сервис уже назначен другому пользователю ({blocked}).")
|
||||
|
||||
for sid in service_ids:
|
||||
if sid not in existing_map:
|
||||
db.add(UserServiceAccess(user_id=user_id, service_id=sid))
|
||||
|
||||
@@ -189,6 +189,11 @@ button {
|
||||
gap: 0.35rem;
|
||||
margin: 0.6rem 0;
|
||||
}
|
||||
.acl-owner {
|
||||
color: #e07b39;
|
||||
font-size: 0.82em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tab-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
<div class="list-title">ACL выбранного пользователя</div>
|
||||
<div class="acl-grid">
|
||||
{% for s in services %}
|
||||
<label><input type="checkbox" class="acl_service" value="{{s.id}}" /> {{s.name}} ({{s.slug}})</label>
|
||||
<label><input type="checkbox" class="acl_service" value="{{s.id}}" data-stype="{{s.type.value}}" /> {{s.name}} ({{s.slug}})<span class="acl-owner"></span></label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button onclick="saveAclForSelectedUser()">Save ACL</button>
|
||||
@@ -513,6 +513,8 @@
|
||||
const csrf = "{{ csrf_token }}";
|
||||
const aclMap = {{ acl | tojson }};
|
||||
const serviceCategoryMap = {{ service_category_map | tojson }};
|
||||
const rdpOccupiedBy = {{ rdp_occupied_by | tojson }};
|
||||
const rdpOccupiedUsername = {{ rdp_occupied_username | tojson }};
|
||||
const placeholderIcon = '/static/service-placeholder.svg';
|
||||
let activeTab = 'users';
|
||||
|
||||
@@ -683,7 +685,21 @@
|
||||
const userId = parseInt(document.getElementById('u_id').value || '0', 10);
|
||||
const allowed = new Set((aclMap[userId] || []));
|
||||
document.querySelectorAll('.acl_service').forEach((box) => {
|
||||
box.checked = allowed.has(parseInt(box.value, 10));
|
||||
const sid = parseInt(box.value, 10);
|
||||
box.checked = allowed.has(sid);
|
||||
const isRdp = box.dataset.stype === 'RDP';
|
||||
const occupiedBy = rdpOccupiedBy[sid];
|
||||
const currentUserHasIt = allowed.has(sid);
|
||||
const ownerSpan = box.closest('label').querySelector('.acl-owner');
|
||||
if (isRdp && occupiedBy && occupiedBy !== userId && !currentUserHasIt) {
|
||||
box.disabled = true;
|
||||
box.closest('label').style.opacity = '0.45';
|
||||
if (ownerSpan) ownerSpan.textContent = ` (${rdpOccupiedUsername[sid]})`;
|
||||
} else {
|
||||
box.disabled = false;
|
||||
box.closest('label').style.opacity = '';
|
||||
if (ownerSpan) ownerSpan.textContent = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,25 @@
|
||||
<link rel="alternate icon" type="image/png" href="/static/favicon.png" />
|
||||
</head>
|
||||
<body class="dashboard-page">
|
||||
{% raw %}<style>
|
||||
#mobile-wall{display:none;position:fixed;inset:0;z-index:9999;background:linear-gradient(135deg,#0d1b2a 0%,#1a2e45 60%,#0f2137 100%);flex-direction:column;align-items:center;justify-content:center;padding:2rem;text-align:center;font-family:sans-serif}
|
||||
@media(max-width:1023px){#mobile-wall{display:flex}}
|
||||
.mw-icon{font-size:4rem;margin-bottom:1.2rem;filter:drop-shadow(0 0 18px rgba(42,140,214,.5))}
|
||||
.mw-title{font-size:1.55rem;font-weight:800;color:#fff;margin-bottom:.7rem;letter-spacing:.01em}
|
||||
.mw-sub{font-size:1rem;color:#a0b8cc;max-width:340px;line-height:1.6;margin-bottom:2rem}
|
||||
.mw-badge{display:inline-flex;align-items:center;gap:.55rem;background:rgba(42,140,214,.15);border:1px solid rgba(42,140,214,.4);border-radius:999px;padding:.55rem 1.1rem;color:#6bbfff;font-size:.88rem;font-weight:600;letter-spacing:.02em}
|
||||
.mw-badge svg{width:18px;height:18px;flex-shrink:0}
|
||||
</style>{% endraw %}
|
||||
<div id="mobile-wall">
|
||||
<div class="mw-icon">🖥️</div>
|
||||
<div class="mw-title">Только для компьютера</div>
|
||||
<div class="mw-sub">Инфраструктурный полигон МОНТ оптимизирован для работы на ПК.<br>Пожалуйста, откройте портал с настольного компьютера или ноутбука.</div>
|
||||
<div class="mw-badge">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
|
||||
Минимальная ширина экрана: 1024 px
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header class="header">
|
||||
<div style="display:flex; align-items:center; gap:0.6rem;">
|
||||
<img src="/static/logo.png" alt="MONT" class="header-logo" />
|
||||
|
||||
Reference in New Issue
Block a user