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
This commit is contained in:
+3
-1
@@ -1,6 +1,7 @@
|
|||||||
import datetime as dt
|
import datetime as dt
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
import time
|
import time
|
||||||
import contextvars
|
import contextvars
|
||||||
@@ -46,7 +47,7 @@ from runtime import (
|
|||||||
get_universal_pool_status, get_web_pool_status, LockTimeoutError, open_warm_web_url,
|
get_universal_pool_status, get_web_pool_status, LockTimeoutError, open_warm_web_url,
|
||||||
_rdp_slot_container_name, route_ready, sanitize_client_resolution,
|
_rdp_slot_container_name, route_ready, sanitize_client_resolution,
|
||||||
service_uses_universal_pool, session_redirect_url,
|
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,
|
stop_runtime_container, terminate_active_slot_sessions,
|
||||||
terminate_session_record, wait_for_session_route,
|
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)
|
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)
|
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)
|
_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)
|
return RedirectResponse(url=f"/s/{session_id}/", status_code=303)
|
||||||
else:
|
else:
|
||||||
# Legacy: no slots configured — exclusive single-session behaviour
|
# Legacy: no slots configured — exclusive single-session behaviour
|
||||||
|
|||||||
+18
-2
@@ -15,7 +15,7 @@ from utils import ensure_icons_dir, now_utc
|
|||||||
from auth import hash_password
|
from auth import hash_password
|
||||||
from runtime import (
|
from runtime import (
|
||||||
_rdp_slot_container_name,
|
_rdp_slot_container_name,
|
||||||
_restart_rdp_slot_bg,
|
disconnect_rdp_slot,
|
||||||
docker_client,
|
docker_client,
|
||||||
ensure_schema_compatibility,
|
ensure_schema_compatibility,
|
||||||
ensure_universal_pool,
|
ensure_universal_pool,
|
||||||
@@ -68,7 +68,7 @@ def cleanup_loop():
|
|||||||
if stale:
|
if stale:
|
||||||
db.commit()
|
db.commit()
|
||||||
for slot_id in rdp_slots_to_restart:
|
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:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
logger.exception("cleanup_loop_failed")
|
logger.exception("cleanup_loop_failed")
|
||||||
@@ -140,6 +140,22 @@ def run_maintenance_service() -> None:
|
|||||||
).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:
|
||||||
|
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:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import main
|
import maintenance
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main.run_maintenance_service()
|
maintenance.run_maintenance_service()
|
||||||
|
|||||||
+33
-2
@@ -670,6 +670,37 @@ def stop_rdp_slot_container(container_name: str) -> None:
|
|||||||
logger.exception("rdp_slot_container_stop_failed container=%s", container_name)
|
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:
|
def _restart_rdp_slot_bg(slot_id: int) -> None:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@@ -719,9 +750,9 @@ def terminate_session_record(
|
|||||||
if cid.startswith("RDPSLOT:"):
|
if cid.startswith("RDPSLOT:"):
|
||||||
try:
|
try:
|
||||||
slot_id = int(cid.split(":", 1)[1])
|
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:
|
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.status = new_status
|
||||||
sess.last_access_at = now_utc()
|
sess.last_access_at = now_utc()
|
||||||
log_event(
|
log_event(
|
||||||
|
|||||||
+3
-11
@@ -2,19 +2,11 @@ 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 python3 xvfb x11vnc freerdp2-x11 novnc websockify ca-certificates fonts-dejavu-core && rm -rf /var/lib/apt/lists/*
|
||||||
xvfb \
|
|
||||||
x11vnc \
|
|
||||||
freerdp2-x11 \
|
|
||||||
novnc \
|
|
||||||
websockify \
|
|
||||||
xdotool \
|
|
||||||
ca-certificates \
|
|
||||||
fonts-dejavu-core \
|
|
||||||
&& 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 ["/entrypoint.sh"]
|
||||||
|
|||||||
+14
-53
@@ -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:-}"
|
||||||
@@ -186,69 +186,30 @@ export DISPLAY="$DISPLAY_NUM"
|
|||||||
DISPLAY_N="${DISPLAY_NUM#:}"
|
DISPLAY_N="${DISPLAY_NUM#:}"
|
||||||
rm -f "/tmp/.X${DISPLAY_N}-lock" "/tmp/.X11-unix/X${DISPLAY_N}" 2>/dev/null || true
|
rm -f "/tmp/.X${DISPLAY_N}-lock" "/tmp/.X11-unix/X${DISPLAY_N}" 2>/dev/null || true
|
||||||
Xvfb "$DISPLAY_NUM" -screen 0 "$SCREEN_GEOMETRY" >/tmp/xvfb.log 2>&1 &
|
Xvfb "$DISPLAY_NUM" -screen 0 "$SCREEN_GEOMETRY" >/tmp/xvfb.log 2>&1 &
|
||||||
|
XVFB_PID=$!
|
||||||
sleep 1
|
sleep 1
|
||||||
|
|
||||||
RDP_ARGS=(
|
|
||||||
"/v:${RDP_HOST}:${RDP_PORT}"
|
|
||||||
"/cert:ignore"
|
|
||||||
"/f"
|
|
||||||
"/dynamic-resolution"
|
|
||||||
"/gfx-h264:avc444"
|
|
||||||
"/network:auto"
|
|
||||||
"+clipboard"
|
|
||||||
)
|
|
||||||
|
|
||||||
if [ -n "$RDP_SECURITY" ]; then
|
|
||||||
RDP_ARGS+=("/sec:${RDP_SECURITY}")
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$RDP_USER" ]; then
|
|
||||||
RDP_ARGS+=("/u:${RDP_USER}")
|
|
||||||
fi
|
|
||||||
if [ -n "$RDP_PASSWORD" ]; then
|
|
||||||
RDP_ARGS+=("/p:${RDP_PASSWORD}")
|
|
||||||
fi
|
|
||||||
if [ -n "$RDP_DOMAIN" ]; then
|
|
||||||
RDP_ARGS+=("/d:${RDP_DOMAIN}")
|
|
||||||
fi
|
|
||||||
|
|
||||||
xfreerdp "${RDP_ARGS[@]}" >/tmp/xfreerdp.log 2>&1 &
|
|
||||||
XFREERDP_PID=$!
|
|
||||||
|
|
||||||
x11vnc -display "$DISPLAY_NUM" -rfbport 5900 -forever -shared -nopw -noxdamage >/tmp/x11vnc.log 2>&1 &
|
x11vnc -display "$DISPLAY_NUM" -rfbport 5900 -forever -shared -nopw -noxdamage >/tmp/x11vnc.log 2>&1 &
|
||||||
X11VNC_PID=$!
|
X11VNC_PID=$!
|
||||||
|
|
||||||
websockify --verbose --idle-timeout="$IDLE_TIMEOUT" --web=/opt/portal 6080 localhost:5900 >/tmp/websockify.log 2>&1 &
|
websockify --verbose --idle-timeout="$IDLE_TIMEOUT" --web=/opt/portal 6080 localhost:5900 >/tmp/websockify.log 2>&1 &
|
||||||
WEBSOCKIFY_PID=$!
|
WEBSOCKIFY_PID=$!
|
||||||
|
|
||||||
|
python3 /manager.py >/tmp/manager.log 2>&1 &
|
||||||
|
MANAGER_PID=$!
|
||||||
|
|
||||||
# Anti-idle: send Shift key to xfreerdp window every 30s to prevent remote lock screen
|
|
||||||
anti_idle_loop() {
|
|
||||||
sleep 5
|
|
||||||
while true; do
|
|
||||||
WID=$(DISPLAY="$DISPLAY_NUM" xdotool search --pid "$XFREERDP_PID" 2>/dev/null | head -1)
|
|
||||||
if [ -n "$WID" ]; then
|
|
||||||
DISPLAY="$DISPLAY_NUM" xdotool key --window "$WID" shift 2>/dev/null || true
|
|
||||||
else
|
|
||||||
DISPLAY="$DISPLAY_NUM" xdotool mousemove --sync 500 300 2>/dev/null || true
|
|
||||||
sleep 1
|
|
||||||
DISPLAY="$DISPLAY_NUM" xdotool mousemove --sync 600 400 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
sleep 30
|
|
||||||
done
|
|
||||||
}
|
|
||||||
anti_idle_loop &
|
|
||||||
ANTI_IDLE_PID=$!
|
|
||||||
|
|
||||||
# Graceful shutdown on docker stop (SIGTERM) — exit 0 so Docker does NOT auto-restart
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
kill "$XFREERDP_PID" "$X11VNC_PID" "$WEBSOCKIFY_PID" "$ANTI_IDLE_PID" 2>/dev/null
|
python3 -c "
|
||||||
|
import urllib.request, sys
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen('http://localhost:7001/disconnect', b'', timeout=3)
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write(str(e) + '\n')
|
||||||
|
" 2>/dev/null || true
|
||||||
|
kill "$X11VNC_PID" "$WEBSOCKIFY_PID" "$MANAGER_PID" "$XVFB_PID" 2>/dev/null || true
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
trap cleanup TERM INT
|
trap cleanup TERM INT
|
||||||
|
|
||||||
# Monitor xfreerdp — when it exits (disconnect/logoff) restart the container
|
wait "$WEBSOCKIFY_PID" || true
|
||||||
wait "$XFREERDP_PID"
|
cleanup
|
||||||
echo "xfreerdp exited (code $?), triggering container restart" >> /tmp/xfreerdp.log
|
|
||||||
kill "$X11VNC_PID" "$WEBSOCKIFY_PID" 2>/dev/null
|
|
||||||
exit 1
|
|
||||||
|
|||||||
@@ -0,0 +1,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()
|
||||||
Reference in New Issue
Block a user