feat: RDP slot pool — multi-user RDP with per-account containers
- New RdpSlot model (rdp_slots table): service_id, rdp_username,
rdp_password, container_name
- Each slot gets a dedicated portal-rdpslot-<slug>-<id> container with
Traefik route /rdp/<slot_id>/ and restart_policy=unless-stopped
- go_service: RDP services with slots use pool allocation — finds first
free slot (not occupied by active session), returns 503 if all busy
- session_status + session_view: handle RDPSLOT: container_id prefix
- terminate_session_record: restarts slot container in background on close
- session_redirect_url: RDPSLOT sessions redirect to /s/<id>/view
- startup_event: starts containers for all configured slots on boot
- Admin: POST /api/admin/services/{id}/rdp-slots, DELETE /api/admin/rdp-slots/{id}
- admin.html: slot management UI (list, add, delete); removed ACL exclusivity
- set_acl: removed RDP 1-user exclusivity — RDP services now assignable to many
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+286
-50
@@ -231,6 +231,17 @@ class UserServiceAccess(Base):
|
|||||||
granted_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
|
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):
|
class SessionModel(Base):
|
||||||
__tablename__ = "sessions"
|
__tablename__ = "sessions"
|
||||||
|
|
||||||
@@ -1058,6 +1069,20 @@ def container_running(container_id: Optional[str]) -> bool:
|
|||||||
or container_id.startswith("WEBPOOLIDX:")
|
or container_id.startswith("WEBPOOLIDX:")
|
||||||
):
|
):
|
||||||
return True
|
return True
|
||||||
|
if container_id.startswith("RDPSLOT:"):
|
||||||
|
try:
|
||||||
|
slot_id = int(container_id.split(":", 1)[1])
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
slot = db.get(RdpSlot, slot_id)
|
||||||
|
if not slot or not slot.container_name:
|
||||||
|
return False
|
||||||
|
c = docker_client().containers.get(slot.container_name)
|
||||||
|
return c.status == "running"
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
try:
|
try:
|
||||||
c = docker_client().containers.get(container_id)
|
c = docker_client().containers.get(container_id)
|
||||||
return c.status == "running"
|
return c.status == "running"
|
||||||
@@ -1065,6 +1090,107 @@ def container_running(container_id: Optional[str]) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _rdp_slot_container_name(service_slug: str, slot_id: int) -> str:
|
||||||
|
return f"portal-rdpslot-{service_slug}-{slot_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def start_rdp_slot_container(slot: RdpSlot, service: Service) -> str:
|
||||||
|
d = docker_client()
|
||||||
|
name = _rdp_slot_container_name(service.slug, slot.id)
|
||||||
|
slot_id = slot.id
|
||||||
|
path = f"/rdp/{slot_id}/"
|
||||||
|
router = f"rdpslot-{slot_id}"
|
||||||
|
labels = {
|
||||||
|
"traefik.enable": "true",
|
||||||
|
"traefik.docker.network": "portal_net",
|
||||||
|
f"traefik.http.routers.{router}.rule": f"PathPrefix(`{path}`)",
|
||||||
|
f"traefik.http.routers.{router}.entrypoints": "websecure",
|
||||||
|
f"traefik.http.routers.{router}.tls": "true",
|
||||||
|
f"traefik.http.routers.{router}.priority": "10000",
|
||||||
|
f"traefik.http.routers.{router}.middlewares": f"{router}-strip",
|
||||||
|
f"traefik.http.middlewares.{router}-strip.stripprefix.prefixes": path[:-1],
|
||||||
|
f"traefik.http.services.{router}.loadbalancer.server.port": "6080",
|
||||||
|
"portal.rdpslot": "1",
|
||||||
|
"portal.rdpslot.id": str(slot_id),
|
||||||
|
"portal.service.slug": service.slug,
|
||||||
|
}
|
||||||
|
cfg = parse_rdp_target(service.target)
|
||||||
|
env = {
|
||||||
|
"SESSION_ID": f"rdpslot-{slot_id}",
|
||||||
|
"IDLE_TIMEOUT": "86400",
|
||||||
|
"ENABLE_HEARTBEAT": "0",
|
||||||
|
"RDP_HOST": cfg["host"],
|
||||||
|
"RDP_PORT": cfg["port"],
|
||||||
|
"X11VNC_FLAGS": X11VNC_FLAGS,
|
||||||
|
}
|
||||||
|
if slot.rdp_username:
|
||||||
|
env["RDP_USER"] = slot.rdp_username
|
||||||
|
if slot.rdp_password:
|
||||||
|
env["RDP_PASSWORD"] = slot.rdp_password
|
||||||
|
if cfg.get("domain"):
|
||||||
|
env["RDP_DOMAIN"] = cfg["domain"]
|
||||||
|
if cfg.get("security"):
|
||||||
|
env["RDP_SECURITY"] = cfg["security"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
existing = d.containers.get(name)
|
||||||
|
existing.stop(timeout=5)
|
||||||
|
existing.remove(force=True)
|
||||||
|
except docker.errors.NotFound:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
logger.exception("rdp_slot_container_cleanup_failed slot_id=%s", slot_id)
|
||||||
|
|
||||||
|
container = d.containers.run(
|
||||||
|
"portal-rdp-proxy:latest",
|
||||||
|
name=name,
|
||||||
|
detach=True,
|
||||||
|
restart_policy={"Name": "unless-stopped"},
|
||||||
|
network="portal_net",
|
||||||
|
labels=labels,
|
||||||
|
environment=env,
|
||||||
|
)
|
||||||
|
logger.info("rdp_slot_container_started slot_id=%s name=%s", slot_id, name)
|
||||||
|
return container.name
|
||||||
|
|
||||||
|
|
||||||
|
def stop_rdp_slot_container(container_name: str) -> None:
|
||||||
|
if not container_name:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
d = docker_client()
|
||||||
|
c = d.containers.get(container_name)
|
||||||
|
c.stop(timeout=5)
|
||||||
|
c.remove(force=True)
|
||||||
|
logger.info("rdp_slot_container_stopped name=%s", container_name)
|
||||||
|
except docker.errors.NotFound:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
logger.exception("rdp_slot_container_stop_failed container=%s", container_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _restart_rdp_slot_bg(slot_id: int) -> None:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
slot = db.get(RdpSlot, slot_id)
|
||||||
|
if not slot or not slot.container_name:
|
||||||
|
return
|
||||||
|
service = db.get(Service, slot.service_id)
|
||||||
|
if not service:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
d = docker_client()
|
||||||
|
c = d.containers.get(slot.container_name)
|
||||||
|
c.restart(timeout=10)
|
||||||
|
logger.info("rdp_slot_container_restarted slot_id=%s", slot_id)
|
||||||
|
except docker.errors.NotFound:
|
||||||
|
start_rdp_slot_container(slot, service)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("rdp_slot_container_restart_failed slot_id=%s", slot_id)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def stop_runtime_container(container_id: Optional[str]) -> None:
|
def stop_runtime_container(container_id: Optional[str]) -> None:
|
||||||
if not container_id:
|
if not container_id:
|
||||||
return
|
return
|
||||||
@@ -1087,8 +1213,14 @@ def terminate_session_record(
|
|||||||
return
|
return
|
||||||
old_status = sess.status
|
old_status = sess.status
|
||||||
cid = sess.container_id or ""
|
cid = sess.container_id or ""
|
||||||
if stop_container and cid and not cid.startswith(("POOL:", "POOLIDX:", "WEBPOOLIDX:")):
|
if stop_container and cid and not cid.startswith(("POOL:", "POOLIDX:", "WEBPOOLIDX:", "RDPSLOT:")):
|
||||||
stop_runtime_container(cid)
|
stop_runtime_container(cid)
|
||||||
|
if cid.startswith("RDPSLOT:"):
|
||||||
|
try:
|
||||||
|
slot_id = int(cid.split(":", 1)[1])
|
||||||
|
threading.Thread(target=_restart_rdp_slot_bg, args=(slot_id,), daemon=True).start()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("rdp_slot_restart_schedule_failed cid=%s", cid)
|
||||||
sess.status = new_status
|
sess.status = new_status
|
||||||
sess.last_access_at = now_utc()
|
sess.last_access_at = now_utc()
|
||||||
log_event(
|
log_event(
|
||||||
@@ -1444,7 +1576,7 @@ def terminate_active_slot_sessions(db: Session, container_id: str) -> None:
|
|||||||
|
|
||||||
def session_redirect_url(sess: SessionModel) -> str:
|
def session_redirect_url(sess: SessionModel) -> str:
|
||||||
cid = sess.container_id or ""
|
cid = sess.container_id or ""
|
||||||
if cid.startswith("POOL:") or cid.startswith("POOLIDX:") or cid.startswith("WEBPOOLIDX:"):
|
if cid.startswith("POOL:") or cid.startswith("POOLIDX:") or cid.startswith("WEBPOOLIDX:") or cid.startswith("RDPSLOT:"):
|
||||||
return f"/s/{sess.id}/view"
|
return f"/s/{sess.id}/view"
|
||||||
return f"/s/{sess.id}/"
|
return f"/s/{sess.id}/"
|
||||||
|
|
||||||
@@ -1620,6 +1752,16 @@ def startup_event():
|
|||||||
).all():
|
).all():
|
||||||
if svc.type == ServiceType.WEB and WEB_POOL_SIZE <= 0:
|
if svc.type == ServiceType.WEB and WEB_POOL_SIZE <= 0:
|
||||||
ensure_warm_pool(svc)
|
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:
|
||||||
|
start_rdp_slot_container(slot, svc)
|
||||||
|
slot.container_name = _rdp_slot_container_name(svc.slug, slot.id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("startup_rdp_slot_start_failed slot_id=%s", slot.id)
|
||||||
|
if slots:
|
||||||
|
db.commit()
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -1801,19 +1943,38 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi
|
|||||||
),
|
),
|
||||||
{"cutoff": cutoff},
|
{"cutoff": cutoff},
|
||||||
).mappings().all()
|
).mappings().all()
|
||||||
rdp_occupied_by: dict[int, int] = {}
|
rdp_slots: dict[int, list] = {}
|
||||||
rdp_occupied_username: dict[int, str] = {}
|
cutoff_slot = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS)
|
||||||
rdp_ids = [s.id for s in rdp_services]
|
for svc in rdp_services:
|
||||||
if rdp_ids:
|
slots = db.scalars(select(RdpSlot).where(RdpSlot.service_id == svc.id).order_by(RdpSlot.id)).all()
|
||||||
rdp_acl_rows = db.execute(
|
slot_list = []
|
||||||
select(UserServiceAccess.service_id, UserServiceAccess.user_id, User.username)
|
for slot in slots:
|
||||||
.join(User, User.id == UserServiceAccess.user_id)
|
active_sess = db.scalar(
|
||||||
.where(UserServiceAccess.service_id.in_(rdp_ids))
|
select(SessionModel).where(
|
||||||
).all()
|
SessionModel.container_id == f"RDPSLOT:{slot.id}",
|
||||||
for row in rdp_acl_rows:
|
SessionModel.status == SessionStatus.ACTIVE,
|
||||||
if row.service_id not in rdp_occupied_by:
|
SessionModel.last_access_at >= cutoff_slot,
|
||||||
rdp_occupied_by[row.service_id] = row.user_id
|
)
|
||||||
rdp_occupied_username[row.service_id] = row.username
|
)
|
||||||
|
running = False
|
||||||
|
if slot.container_name:
|
||||||
|
try:
|
||||||
|
c = docker_client().containers.get(slot.container_name)
|
||||||
|
running = c.status == "running"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
occupied_username = None
|
||||||
|
if active_sess:
|
||||||
|
u = db.get(User, active_sess.user_id)
|
||||||
|
occupied_username = u.username if u else f"id={active_sess.user_id}"
|
||||||
|
slot_list.append({
|
||||||
|
"id": slot.id,
|
||||||
|
"rdp_username": slot.rdp_username,
|
||||||
|
"container_name": slot.container_name or "",
|
||||||
|
"running": running,
|
||||||
|
"occupied_username": occupied_username,
|
||||||
|
})
|
||||||
|
rdp_slots[svc.id] = slot_list
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"admin.html",
|
"admin.html",
|
||||||
{
|
{
|
||||||
@@ -1836,8 +1997,7 @@ def admin_page(request: Request, admin: User = Depends(require_admin), db: Sessi
|
|||||||
"online_sessions": online_sessions,
|
"online_sessions": online_sessions,
|
||||||
"csrf_token": request.cookies.get(CSRF_COOKIE, ""),
|
"csrf_token": request.cookies.get(CSRF_COOKIE, ""),
|
||||||
"max_active_services_per_user": MAX_ACTIVE_SERVICES_PER_USER,
|
"max_active_services_per_user": MAX_ACTIVE_SERVICES_PER_USER,
|
||||||
"rdp_occupied_by": rdp_occupied_by,
|
"rdp_slots": rdp_slots,
|
||||||
"rdp_occupied_username": rdp_occupied_username,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1986,20 +2146,61 @@ def go_service(
|
|||||||
return RedirectResponse(url="/?launch_error=max_services", status_code=303)
|
return RedirectResponse(url="/?launch_error=max_services", status_code=303)
|
||||||
|
|
||||||
if service.type == ServiceType.RDP:
|
if service.type == ServiceType.RDP:
|
||||||
t_rdp_owner = time.perf_counter()
|
t_rdp_slots = time.perf_counter()
|
||||||
active_owner = find_active_session_for_service(db, service.id)
|
slots = db.scalars(select(RdpSlot).where(RdpSlot.service_id == service.id)).all()
|
||||||
_mark("check_rdp_owner_ms", t_rdp_owner)
|
_mark("check_rdp_slots_ms", t_rdp_slots)
|
||||||
if active_owner:
|
if slots:
|
||||||
if active_owner.user_id != user.id:
|
session_id = str(uuid.uuid4())
|
||||||
owner = db.get(User, active_owner.user_id)
|
try:
|
||||||
owner_name = owner.username if owner else f"id={active_owner.user_id}"
|
with allocator_lock(db, 91003, timeout_seconds=GO_POOL_LOCK_TIMEOUT_SECONDS):
|
||||||
_emit("rdp_busy", owner=owner_name)
|
cutoff = now_utc() - dt.timedelta(seconds=SESSION_IDLE_SECONDS)
|
||||||
raise HTTPException(
|
busy_slot_ids: set[int] = set()
|
||||||
status_code=409,
|
for row in db.scalars(
|
||||||
detail=f"RDP сервис уже занят пользователем {owner_name}. Попробуйте позже.",
|
select(SessionModel).where(
|
||||||
)
|
SessionModel.status == SessionStatus.ACTIVE,
|
||||||
_emit("reuse_rdp_session", session_id=active_owner.id)
|
SessionModel.last_access_at >= cutoff,
|
||||||
return RedirectResponse(url=session_redirect_url(active_owner), status_code=303)
|
SessionModel.service_id == service.id,
|
||||||
|
SessionModel.container_id.like("RDPSLOT:%"),
|
||||||
|
)
|
||||||
|
).all():
|
||||||
|
try:
|
||||||
|
busy_slot_ids.add(int(row.container_id.split(":", 1)[1]))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
free_slot = next((s for s in slots if s.id not in busy_slot_ids), None)
|
||||||
|
if not free_slot:
|
||||||
|
_emit("rdp_all_slots_busy")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Все слоты этого RDP сервиса заняты. Попробуйте позже.",
|
||||||
|
)
|
||||||
|
session_obj = SessionModel(
|
||||||
|
id=session_id,
|
||||||
|
user_id=user.id,
|
||||||
|
service_id=service.id,
|
||||||
|
container_id=f"RDPSLOT:{free_slot.id}",
|
||||||
|
status=SessionStatus.ACTIVE,
|
||||||
|
created_at=now_utc(),
|
||||||
|
last_access_at=now_utc(),
|
||||||
|
)
|
||||||
|
db.add(session_obj)
|
||||||
|
db.commit()
|
||||||
|
except LockTimeoutError:
|
||||||
|
_emit("rdp_slot_lock_timeout")
|
||||||
|
raise HTTPException(status_code=503, detail="Пул RDP занят. Повторите через несколько секунд.")
|
||||||
|
log_event("session_created", user_id=user.id, service_slug=service.slug, session_id=session_id, mode="rdp_slot", slot_id=free_slot.id)
|
||||||
|
audit(db, "SESSION_CREATE_RDP_SLOT", f"service={service.slug} session={session_id} slot={free_slot.id}", user_id=user.id)
|
||||||
|
_emit("session_created_rdp_slot", session_id=session_id, slot_id=free_slot.id)
|
||||||
|
return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
|
||||||
|
else:
|
||||||
|
# Legacy: no slots configured — exclusive single-session behaviour
|
||||||
|
active_owner = find_active_session_for_service(db, service.id)
|
||||||
|
if active_owner:
|
||||||
|
if active_owner.user_id != user.id:
|
||||||
|
_emit("rdp_busy_legacy")
|
||||||
|
raise HTTPException(status_code=503, detail="RDP сервис занят. Попробуйте позже.")
|
||||||
|
_emit("reuse_rdp_session", session_id=active_owner.id)
|
||||||
|
return RedirectResponse(url=session_redirect_url(active_owner), status_code=303)
|
||||||
|
|
||||||
session_id = str(uuid.uuid4())
|
session_id = str(uuid.uuid4())
|
||||||
if service.type == ServiceType.WEB and WEB_POOL_SIZE > 0:
|
if service.type == ServiceType.WEB and WEB_POOL_SIZE > 0:
|
||||||
@@ -2305,6 +2506,12 @@ def session_view_page(session_id: str, request: Request, user: User = Depends(re
|
|||||||
iframe_src = f"/u/{slot}/?sid={session_id}"
|
iframe_src = f"/u/{slot}/?sid={session_id}"
|
||||||
except Exception:
|
except Exception:
|
||||||
iframe_src = None
|
iframe_src = None
|
||||||
|
elif sess.container_id and sess.container_id.startswith("RDPSLOT:"):
|
||||||
|
try:
|
||||||
|
slot = int(sess.container_id.split(":", 1)[1])
|
||||||
|
iframe_src = f"/rdp/{slot}/?sid={session_id}"
|
||||||
|
except Exception:
|
||||||
|
iframe_src = None
|
||||||
if iframe_src:
|
if iframe_src:
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
content=f"""
|
content=f"""
|
||||||
@@ -2420,10 +2627,18 @@ def session_status(session_id: str, user: User = Depends(require_user), db: Sess
|
|||||||
except Exception:
|
except Exception:
|
||||||
universal_pool_idx = None
|
universal_pool_idx = None
|
||||||
pooled_rdp = bool(sess.container_id and sess.container_id.startswith("POOL:") and service and service.type == ServiceType.RDP)
|
pooled_rdp = bool(sess.container_id and sess.container_id.startswith("POOL:") and service and service.type == ServiceType.RDP)
|
||||||
|
rdp_slot_idx = None
|
||||||
|
if sess.container_id and sess.container_id.startswith("RDPSLOT:"):
|
||||||
|
try:
|
||||||
|
rdp_slot_idx = int(sess.container_id.split(":", 1)[1])
|
||||||
|
except Exception:
|
||||||
|
rdp_slot_idx = None
|
||||||
if pooled_web and service:
|
if pooled_web and service:
|
||||||
route_path = f"/svc/{service.slug}/"
|
route_path = f"/svc/{service.slug}/"
|
||||||
elif pooled_rdp and service:
|
elif pooled_rdp and service:
|
||||||
route_path = f"/svc/{service.slug}/"
|
route_path = f"/svc/{service.slug}/"
|
||||||
|
elif rdp_slot_idx is not None:
|
||||||
|
route_path = f"/rdp/{rdp_slot_idx}/"
|
||||||
else:
|
else:
|
||||||
route_path = f"/s/{session_id}/"
|
route_path = f"/s/{session_id}/"
|
||||||
if web_pool_idx is not None:
|
if web_pool_idx is not None:
|
||||||
@@ -2448,6 +2663,8 @@ def session_status(session_id: str, user: User = Depends(require_user), db: Sess
|
|||||||
payload["redirect_url"] = f"/s/{session_id}/view"
|
payload["redirect_url"] = f"/s/{session_id}/view"
|
||||||
if universal_pool_idx is not None:
|
if universal_pool_idx is not None:
|
||||||
payload["redirect_url"] = f"/s/{session_id}/view"
|
payload["redirect_url"] = f"/s/{session_id}/view"
|
||||||
|
if rdp_slot_idx is not None:
|
||||||
|
payload["redirect_url"] = f"/s/{session_id}/view"
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@@ -2589,6 +2806,44 @@ def prewarm_now(service_id: int, request: Request, _: User = Depends(require_adm
|
|||||||
return {"ok": True, "pool": get_pool_status_for_service(service)}
|
return {"ok": True, "pool": get_pool_status_for_service(service)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/admin/services/{service_id}/rdp-slots")
|
||||||
|
def create_rdp_slot(service_id: int, payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
|
||||||
|
validate_csrf(request)
|
||||||
|
service = db.get(Service, service_id)
|
||||||
|
if not service or service.type != ServiceType.RDP:
|
||||||
|
raise HTTPException(status_code=404, detail="RDP service not found")
|
||||||
|
rdp_username = (payload.get("rdp_username") or "").strip()
|
||||||
|
rdp_password = (payload.get("rdp_password") or "").strip()
|
||||||
|
if not rdp_username:
|
||||||
|
raise HTTPException(status_code=400, detail="rdp_username is required")
|
||||||
|
slot = RdpSlot(service_id=service_id, rdp_username=rdp_username, rdp_password=rdp_password)
|
||||||
|
db.add(slot)
|
||||||
|
db.flush()
|
||||||
|
try:
|
||||||
|
container_name = start_rdp_slot_container(slot, service)
|
||||||
|
slot.container_name = container_name
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("rdp_slot_container_start_failed service_id=%s", service_id)
|
||||||
|
raise HTTPException(status_code=502, detail=f"Контейнер не запустился: {exc}")
|
||||||
|
db.commit()
|
||||||
|
audit(db, "RDP_SLOT_CREATE", f"service={service.slug} slot={slot.id} user={rdp_username}", user_id=None)
|
||||||
|
return {"ok": True, "slot_id": slot.id, "container_name": slot.container_name}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/admin/rdp-slots/{slot_id}")
|
||||||
|
def delete_rdp_slot(slot_id: int, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
|
||||||
|
validate_csrf(request)
|
||||||
|
slot = db.get(RdpSlot, slot_id)
|
||||||
|
if not slot:
|
||||||
|
raise HTTPException(status_code=404, detail="Slot not found")
|
||||||
|
container_name = slot.container_name
|
||||||
|
db.delete(slot)
|
||||||
|
db.commit()
|
||||||
|
if container_name:
|
||||||
|
threading.Thread(target=stop_rdp_slot_container, args=(container_name,), daemon=True).start()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/admin/categories")
|
@app.post("/api/admin/categories")
|
||||||
def create_category(payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
|
def create_category(payload: dict, request: Request, _: User = Depends(require_admin), db: Session = Depends(get_db)):
|
||||||
validate_csrf(request)
|
validate_csrf(request)
|
||||||
@@ -2685,25 +2940,6 @@ 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 = db.scalars(select(UserServiceAccess).where(UserServiceAccess.user_id == user_id)).all()
|
||||||
existing_map = {x.service_id: x for x in existing}
|
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:
|
for sid in service_ids:
|
||||||
if sid not in existing_map:
|
if sid not in existing_map:
|
||||||
db.add(UserServiceAccess(user_id=user_id, service_id=sid))
|
db.add(UserServiceAccess(user_id=user_id, service_id=sid))
|
||||||
|
|||||||
+60
-26
@@ -279,8 +279,6 @@
|
|||||||
<input id="r_slug" placeholder="Системный slug" />
|
<input id="r_slug" placeholder="Системный slug" />
|
||||||
<input id="r_host" placeholder="RDP host (например 192.168.1.60)" />
|
<input id="r_host" placeholder="RDP host (например 192.168.1.60)" />
|
||||||
<input id="r_port" type="number" min="1" max="65535" placeholder="RDP порт, обычно 3389" />
|
<input id="r_port" type="number" min="1" max="65535" placeholder="RDP порт, обычно 3389" />
|
||||||
<input id="r_user" placeholder="Логин (опционально)" />
|
|
||||||
<input id="r_pass" placeholder="Пароль (опционально)" type="password" />
|
|
||||||
<input id="r_domain" placeholder="Домен (опционально)" />
|
<input id="r_domain" placeholder="Домен (опционально)" />
|
||||||
<select id="r_sec">
|
<select id="r_sec">
|
||||||
<option value="">auto</option>
|
<option value="">auto</option>
|
||||||
@@ -342,6 +340,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="rdp_slots_box" style="display:none; margin-top:1rem;">
|
||||||
|
<div class="list-title">RDP пользователи (слоты пула)</div>
|
||||||
|
<div class="field-help">Каждый пользователь — отдельный контейнер. Пользователи портала берут свободный слот.</div>
|
||||||
|
<table class="admin-table" id="rdp_slots_table" style="margin-bottom:.7rem">
|
||||||
|
<thead><tr><th>Логин RDP</th><th>Контейнер</th><th>Статус</th><th>Занят</th><th></th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
<div style="display:flex;gap:.5rem;align-items:flex-end;flex-wrap:wrap;">
|
||||||
|
<input id="new_slot_user" placeholder="Логин RDP" style="max-width:160px" />
|
||||||
|
<input id="new_slot_pass" type="password" placeholder="Пароль RDP" style="max-width:160px" />
|
||||||
|
<button onclick="addRdpSlot()">+ Добавить слот</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
<div class="list-title">Добавить RDP</div>
|
<div class="list-title">Добавить RDP</div>
|
||||||
<div class="field-help">Для большинства кейсов достаточно host + user + password.</div>
|
<div class="field-help">Для большинства кейсов достаточно host + user + password.</div>
|
||||||
@@ -350,8 +362,6 @@
|
|||||||
<input id="new_r_slug" placeholder="Системный slug" />
|
<input id="new_r_slug" placeholder="Системный slug" />
|
||||||
<input id="new_r_host" placeholder="RDP host" />
|
<input id="new_r_host" placeholder="RDP host" />
|
||||||
<input id="new_r_port" type="number" min="1" max="65535" placeholder="RDP порт (3389)" />
|
<input id="new_r_port" type="number" min="1" max="65535" placeholder="RDP порт (3389)" />
|
||||||
<input id="new_r_user" placeholder="Логин (опционально)" />
|
|
||||||
<input id="new_r_pass" placeholder="Пароль (опционально)" type="password" />
|
|
||||||
<input id="new_r_domain" placeholder="Домен (опционально)" />
|
<input id="new_r_domain" placeholder="Домен (опционально)" />
|
||||||
<select id="new_r_sec">
|
<select id="new_r_sec">
|
||||||
<option value="">auto</option>
|
<option value="">auto</option>
|
||||||
@@ -513,8 +523,7 @@
|
|||||||
const csrf = "{{ csrf_token }}";
|
const csrf = "{{ csrf_token }}";
|
||||||
const aclMap = {{ acl | tojson }};
|
const aclMap = {{ acl | tojson }};
|
||||||
const serviceCategoryMap = {{ service_category_map | tojson }};
|
const serviceCategoryMap = {{ service_category_map | tojson }};
|
||||||
const rdpOccupiedBy = {{ rdp_occupied_by | tojson }};
|
const rdpSlotsMap = {{ rdp_slots | tojson }};
|
||||||
const rdpOccupiedUsername = {{ rdp_occupied_username | tojson }};
|
|
||||||
const placeholderIcon = '/static/service-placeholder.svg';
|
const placeholderIcon = '/static/service-placeholder.svg';
|
||||||
let activeTab = 'users';
|
let activeTab = 'users';
|
||||||
|
|
||||||
@@ -687,19 +696,8 @@
|
|||||||
document.querySelectorAll('.acl_service').forEach((box) => {
|
document.querySelectorAll('.acl_service').forEach((box) => {
|
||||||
const sid = parseInt(box.value, 10);
|
const sid = parseInt(box.value, 10);
|
||||||
box.checked = allowed.has(sid);
|
box.checked = allowed.has(sid);
|
||||||
const isRdp = box.dataset.stype === 'RDP';
|
box.disabled = false;
|
||||||
const occupiedBy = rdpOccupiedBy[sid];
|
box.closest('label').style.opacity = '';
|
||||||
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 = '';
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -793,22 +791,58 @@
|
|||||||
function buildRdpTarget(prefix) {
|
function buildRdpTarget(prefix) {
|
||||||
const host = (document.getElementById(`${prefix}_host`)?.value || '').trim();
|
const host = (document.getElementById(`${prefix}_host`)?.value || '').trim();
|
||||||
const port = (document.getElementById(`${prefix}_port`)?.value || '').trim() || '3389';
|
const port = (document.getElementById(`${prefix}_port`)?.value || '').trim() || '3389';
|
||||||
const user = (document.getElementById(`${prefix}_user`)?.value || '').trim();
|
|
||||||
const pass = (document.getElementById(`${prefix}_pass`)?.value || '').trim();
|
|
||||||
const domain = (document.getElementById(`${prefix}_domain`)?.value || '').trim();
|
const domain = (document.getElementById(`${prefix}_domain`)?.value || '').trim();
|
||||||
const sec = (document.getElementById(`${prefix}_sec`)?.value || '').trim();
|
const sec = (document.getElementById(`${prefix}_sec`)?.value || '').trim();
|
||||||
const targetInput = document.getElementById(`${prefix}_target`);
|
const targetInput = document.getElementById(`${prefix}_target`);
|
||||||
if (!host) return (targetInput?.value || '').trim();
|
if (!host) return (targetInput?.value || '').trim();
|
||||||
const creds = user ? `${encodeURIComponent(user)}${pass ? `:${encodeURIComponent(pass)}` : ''}@` : '';
|
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
if (domain) query.set('domain', domain);
|
if (domain) query.set('domain', domain);
|
||||||
if (sec) query.set('sec', sec);
|
if (sec) query.set('sec', sec);
|
||||||
const q = query.toString();
|
const q = query.toString();
|
||||||
const target = `rdp://${creds}${host}:${port}${q ? `?${q}` : ''}`;
|
const target = `rdp://${host}:${port}${q ? `?${q}` : ''}`;
|
||||||
if (targetInput) targetInput.value = target;
|
if (targetInput) targetInput.value = target;
|
||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderRdpSlots(serviceId) {
|
||||||
|
const box = document.getElementById('rdp_slots_box');
|
||||||
|
const tbody = document.querySelector('#rdp_slots_table tbody');
|
||||||
|
const slots = rdpSlotsMap[serviceId] || [];
|
||||||
|
box.style.display = 'block';
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
if (!slots.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" style="color:#888">Нет слотов. Добавьте RDP пользователей ниже.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
slots.forEach(s => {
|
||||||
|
const statusBadge = s.running
|
||||||
|
? '<span style="color:#4caf50">● running</span>'
|
||||||
|
: '<span style="color:#e07b39">● stopped</span>';
|
||||||
|
const occupiedCell = s.occupied_username
|
||||||
|
? `<span style="color:#e07b39">${s.occupied_username}</span>`
|
||||||
|
: '<span style="color:#888">свободен</span>';
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `<td>${s.rdp_username}</td><td style="font-size:.8em;color:#888">${s.container_name||'—'}</td><td>${statusBadge}</td><td>${occupiedCell}</td><td><button onclick="deleteRdpSlot(${s.id})">✕</button></td>`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addRdpSlot() {
|
||||||
|
const serviceId = document.getElementById('r_id').value;
|
||||||
|
if (!serviceId) return alert('Выберите RDP сервис');
|
||||||
|
const rdp_username = document.getElementById('new_slot_user').value.trim();
|
||||||
|
const rdp_password = document.getElementById('new_slot_pass').value.trim();
|
||||||
|
if (!rdp_username) return alert('Введите логин RDP');
|
||||||
|
await api(`/api/admin/services/${serviceId}/rdp-slots`, 'POST', {rdp_username, rdp_password});
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRdpSlot(slotId) {
|
||||||
|
if (!confirm('Удалить RDP слот и остановить контейнер?')) return;
|
||||||
|
await api(`/api/admin/rdp-slots/${slotId}`, 'DELETE', {});
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
function selectRdpService(id, name, slug, target, comment, iconPath, active, pool) {
|
function selectRdpService(id, name, slug, target, comment, iconPath, active, pool) {
|
||||||
const cfg = parseRdpTarget(target);
|
const cfg = parseRdpTarget(target);
|
||||||
document.getElementById('r_id').value = id;
|
document.getElementById('r_id').value = id;
|
||||||
@@ -817,8 +851,6 @@
|
|||||||
document.getElementById('r_target').value = target;
|
document.getElementById('r_target').value = target;
|
||||||
document.getElementById('r_host').value = cfg.host;
|
document.getElementById('r_host').value = cfg.host;
|
||||||
document.getElementById('r_port').value = cfg.port;
|
document.getElementById('r_port').value = cfg.port;
|
||||||
document.getElementById('r_user').value = cfg.user;
|
|
||||||
document.getElementById('r_pass').value = cfg.pass;
|
|
||||||
document.getElementById('r_domain').value = cfg.domain;
|
document.getElementById('r_domain').value = cfg.domain;
|
||||||
document.getElementById('r_sec').value = cfg.sec;
|
document.getElementById('r_sec').value = cfg.sec;
|
||||||
document.getElementById('r_comment').value = comment || '';
|
document.getElementById('r_comment').value = comment || '';
|
||||||
@@ -828,6 +860,7 @@
|
|||||||
document.getElementById('r_icon_preview').src = iconPath || placeholderIcon;
|
document.getElementById('r_icon_preview').src = iconPath || placeholderIcon;
|
||||||
document.getElementById('r_health_box').style.display = 'block';
|
document.getElementById('r_health_box').style.display = 'block';
|
||||||
markSelected('.rdp-item', 'data-service-id', id);
|
markSelected('.rdp-item', 'data-service-id', id);
|
||||||
|
renderRdpSlots(id);
|
||||||
refreshSelectedServiceStatus('rdp');
|
refreshSelectedServiceStatus('rdp');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -865,7 +898,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearRdpForm() {
|
function clearRdpForm() {
|
||||||
['r_id','r_name','r_slug','r_target','r_host','r_port','r_user','r_pass','r_domain','r_comment','r_pool'].forEach(id => document.getElementById(id).value = '');
|
['r_id','r_name','r_slug','r_target','r_host','r_port','r_domain','r_comment','r_pool'].forEach(id => document.getElementById(id).value = '');
|
||||||
|
document.getElementById('rdp_slots_box').style.display = 'none';
|
||||||
document.getElementById('r_sec').value = '';
|
document.getElementById('r_sec').value = '';
|
||||||
document.getElementById('r_active').value = 'true';
|
document.getElementById('r_active').value = 'true';
|
||||||
setCategoryChecks('.r_cat', []);
|
setCategoryChecks('.r_cat', []);
|
||||||
|
|||||||
Reference in New Issue
Block a user