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:
2026-05-01 10:12:52 +00:00
parent 82024a36c4
commit 58cb8b1035
7 changed files with 204 additions and 72 deletions
+3 -11
View File
@@ -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"]
+14 -53
View File
@@ -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
+131
View File
@@ -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()