From 58cb8b1035578c4d5f914db4087b997c0db5c32d Mon Sep 17 00:00:00 2001 From: Ruslan Date: Fri, 1 May 2026 10:12:52 +0000 Subject: [PATCH] feat: on-demand RDP - connect xfreerdp only when session opens Replaces always-on xfreerdp with on-demand model (load 12 to under 1 at idle). - rdp-proxy/manager.py: HTTP server port 7001 managing xfreerdp lifecycle - rdp-proxy/entrypoint.sh: starts Xvfb+x11vnc+websockify+manager, no auto-connect - rdp-proxy/Dockerfile: adds python3, copies manager.py, exposes 7001 - runtime.py: connect_rdp_slot and disconnect_rdp_slot via manager HTTP API - terminate_session_record: disconnect instead of container restart - main.py: calls connect_rdp_slot in background thread on session create - maintenance.py: cleanup_loop disconnects on expire, run_maintenance_service includes RDP slot init, maintenance_runner fixed to import maintenance --- app/main.py | 4 +- app/maintenance.py | 20 +++++- app/maintenance_runner.py | 5 +- app/runtime.py | 35 +++++++++- rdp-proxy/Dockerfile | 14 +--- rdp-proxy/entrypoint.sh | 67 ++++--------------- rdp-proxy/manager.py | 131 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 204 insertions(+), 72 deletions(-) create mode 100644 rdp-proxy/manager.py diff --git a/app/main.py b/app/main.py index 14c5eb5..8c4f960 100644 --- a/app/main.py +++ b/app/main.py @@ -1,6 +1,7 @@ import datetime as dt import logging import re +import threading import uuid import time import contextvars @@ -46,7 +47,7 @@ from runtime import ( get_universal_pool_status, get_web_pool_status, LockTimeoutError, open_warm_web_url, _rdp_slot_container_name, route_ready, sanitize_client_resolution, service_uses_universal_pool, session_redirect_url, - start_rdp_slot_container, stop_rdp_slot_container, + connect_rdp_slot, start_rdp_slot_container, stop_rdp_slot_container, stop_runtime_container, terminate_active_slot_sessions, terminate_session_record, wait_for_session_route, ) @@ -579,6 +580,7 @@ def go_service( 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) + threading.Thread(target=connect_rdp_slot, args=(free_slot.id,), daemon=True).start() return RedirectResponse(url=f"/s/{session_id}/", status_code=303) else: # Legacy: no slots configured — exclusive single-session behaviour diff --git a/app/maintenance.py b/app/maintenance.py index 7caee2f..e6358c9 100644 --- a/app/maintenance.py +++ b/app/maintenance.py @@ -15,7 +15,7 @@ from utils import ensure_icons_dir, now_utc from auth import hash_password from runtime import ( _rdp_slot_container_name, - _restart_rdp_slot_bg, + disconnect_rdp_slot, docker_client, ensure_schema_compatibility, ensure_universal_pool, @@ -68,7 +68,7 @@ def cleanup_loop(): if stale: db.commit() for slot_id in rdp_slots_to_restart: - threading.Thread(target=_restart_rdp_slot_bg, args=(slot_id,), daemon=True).start() + threading.Thread(target=disconnect_rdp_slot, args=(slot_id,), daemon=True).start() except Exception: db.rollback() logger.exception("cleanup_loop_failed") @@ -140,6 +140,22 @@ def run_maintenance_service() -> None: ).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() diff --git a/app/maintenance_runner.py b/app/maintenance_runner.py index ced4cf1..8bcfe75 100644 --- a/app/maintenance_runner.py +++ b/app/maintenance_runner.py @@ -1,5 +1,4 @@ -import main - +import maintenance if __name__ == "__main__": - main.run_maintenance_service() + maintenance.run_maintenance_service() diff --git a/app/runtime.py b/app/runtime.py index b66b24c..16b4107 100644 --- a/app/runtime.py +++ b/app/runtime.py @@ -670,6 +670,37 @@ def stop_rdp_slot_container(container_name: str) -> None: logger.exception("rdp_slot_container_stop_failed container=%s", container_name) +def _call_rdp_manager(container_name: str, endpoint: str) -> bool: + url = f"http://{container_name}:7001{endpoint}" + try: + resp = requests.post(url, timeout=10) + logger.info("rdp_manager_%s container=%s status=%s", endpoint.strip('/'), container_name, resp.status_code) + return resp.ok + except Exception: + logger.exception("rdp_manager_call_failed container=%s endpoint=%s", container_name, endpoint) + return False + + +def connect_rdp_slot(slot_id: int) -> None: + db = SessionLocal() + try: + slot = db.get(RdpSlot, slot_id) + if slot and slot.container_name: + _call_rdp_manager(slot.container_name, "/connect") + finally: + db.close() + + +def disconnect_rdp_slot(slot_id: int) -> None: + db = SessionLocal() + try: + slot = db.get(RdpSlot, slot_id) + if slot and slot.container_name: + _call_rdp_manager(slot.container_name, "/disconnect") + finally: + db.close() + + def _restart_rdp_slot_bg(slot_id: int) -> None: db = SessionLocal() try: @@ -719,9 +750,9 @@ def terminate_session_record( 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() + threading.Thread(target=disconnect_rdp_slot, args=(slot_id,), daemon=True).start() except Exception: - logger.exception("rdp_slot_restart_schedule_failed cid=%s", cid) + logger.exception("rdp_slot_disconnect_failed cid=%s", cid) sess.status = new_status sess.last_access_at = now_utc() log_event( diff --git a/rdp-proxy/Dockerfile b/rdp-proxy/Dockerfile index 4c22e10..40d011c 100644 --- a/rdp-proxy/Dockerfile +++ b/rdp-proxy/Dockerfile @@ -2,19 +2,11 @@ FROM debian:bookworm-slim ENV DEBIAN_FRONTEND=noninteractive -RUN apt-get update && apt-get install -y --no-install-recommends \ - xvfb \ - x11vnc \ - freerdp2-x11 \ - novnc \ - websockify \ - xdotool \ - ca-certificates \ - fonts-dejavu-core \ - && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-recommends python3 xvfb x11vnc freerdp2-x11 novnc websockify ca-certificates fonts-dejavu-core && rm -rf /var/lib/apt/lists/* COPY entrypoint.sh /entrypoint.sh +COPY manager.py /manager.py RUN chmod +x /entrypoint.sh -EXPOSE 6080 +EXPOSE 6080 7001 ENTRYPOINT ["/entrypoint.sh"] diff --git a/rdp-proxy/entrypoint.sh b/rdp-proxy/entrypoint.sh index 91fd8c3..2e38397 100644 --- a/rdp-proxy/entrypoint.sh +++ b/rdp-proxy/entrypoint.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -RDP_HOST="${RDP_HOST:?RDP_HOST is required}" +RDP_HOST="${RDP_HOST:-}" RDP_PORT="${RDP_PORT:-3389}" RDP_USER="${RDP_USER:-}" RDP_PASSWORD="${RDP_PASSWORD:-}" @@ -186,69 +186,30 @@ export DISPLAY="$DISPLAY_NUM" DISPLAY_N="${DISPLAY_NUM#:}" 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_PID=$! 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_PID=$! websockify --verbose --idle-timeout="$IDLE_TIMEOUT" --web=/opt/portal 6080 localhost:5900 >/tmp/websockify.log 2>&1 & 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() { - 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 } trap cleanup TERM INT -# Monitor xfreerdp — when it exits (disconnect/logoff) restart the container -wait "$XFREERDP_PID" -echo "xfreerdp exited (code $?), triggering container restart" >> /tmp/xfreerdp.log -kill "$X11VNC_PID" "$WEBSOCKIFY_PID" 2>/dev/null -exit 1 +wait "$WEBSOCKIFY_PID" || true +cleanup diff --git a/rdp-proxy/manager.py b/rdp-proxy/manager.py new file mode 100644 index 0000000..43992a1 --- /dev/null +++ b/rdp-proxy/manager.py @@ -0,0 +1,131 @@ +#!/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", "") + +_lock = threading.Lock() +_proc: subprocess.Popen | None = None +_should_be_connected = False # set True on /connect, False on /disconnect + + +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(): + """Auto-reconnect if xfreerdp crashes while session should be active.""" + 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() + + +threading.Thread(target=_monitor_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 + 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 + 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") + log.info("manager started on :7001") + HTTPServer(("0.0.0.0", 7001), Handler).serve_forever()