be65be8fdb
- universal-runtime: set _state[profile_dir] AFTER _start_process so _stop_current does not delete the freshly-created profile before Chromium reads it. Without this, Login Data was being wiped. - rdp-proxy: add xdotool dependency and background anti_idle_loop that sends Shift to the xfreerdp window every 30s, forwarded over RDP to reset the remote idle timer and keep the lock screen from kicking in.
255 lines
8.9 KiB
Bash
255 lines
8.9 KiB
Bash
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
RDP_HOST="${RDP_HOST:?RDP_HOST is required}"
|
|
RDP_PORT="${RDP_PORT:-3389}"
|
|
RDP_USER="${RDP_USER:-}"
|
|
RDP_PASSWORD="${RDP_PASSWORD:-}"
|
|
RDP_DOMAIN="${RDP_DOMAIN:-}"
|
|
RDP_SECURITY="${RDP_SECURITY:-}"
|
|
SESSION_ID="${SESSION_ID:-unknown}"
|
|
IDLE_TIMEOUT="${IDLE_TIMEOUT:-1800}"
|
|
ENABLE_HEARTBEAT="${ENABLE_HEARTBEAT:-1}"
|
|
TOUCH_PATH="${TOUCH_PATH:-/api/sessions/${SESSION_ID}/touch}"
|
|
SCREEN_GEOMETRY="${SCREEN_GEOMETRY:-1920x1080x24}"
|
|
DISPLAY_NUM="${DISPLAY_NUM:-:1}"
|
|
|
|
mkdir -p /opt/portal
|
|
cp -r /usr/share/novnc/* /opt/portal/
|
|
|
|
cat > /opt/portal/index.html <<HTML
|
|
<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>RDP Session</title>
|
|
<style>
|
|
html,body,#screen{margin:0;height:100%;background:#111}
|
|
.status{
|
|
position:fixed;left:12px;top:12px;z-index:50;padding:8px 10px;border-radius:8px;
|
|
background:rgba(16,22,32,.86);border:1px solid rgba(255,255,255,.18);
|
|
color:#dce8f5;font:600 13px/1.25 sans-serif;max-width:min(92vw,560px);
|
|
}
|
|
.status.error{background:rgba(85,20,20,.9);border-color:rgba(255,130,130,.36);color:#ffe3e3}
|
|
.status.hidden{display:none}
|
|
.spinner{display:inline-block;width:12px;height:12px;border:2px solid rgba(220,232,245,.3);
|
|
border-top-color:#dce8f5;border-radius:50%;animation:spin .8s linear infinite;margin-right:7px;vertical-align:middle}
|
|
@keyframes spin{to{transform:rotate(360deg)}}
|
|
.nav-panel{
|
|
position:fixed;left:16px;top:64px;z-index:99;display:flex;gap:10px;
|
|
background:linear-gradient(180deg,rgba(15,24,36,.78),rgba(9,14,22,.86));
|
|
border:1px solid rgba(255,255,255,.22);backdrop-filter:blur(5px);
|
|
box-shadow:0 10px 28px rgba(0,0,0,.36);padding:9px 10px;border-radius:14px
|
|
}
|
|
.nav-btn{
|
|
border:1px solid rgba(255,255,255,.26);border-radius:999px;padding:9px 14px;cursor:pointer;
|
|
background:linear-gradient(180deg,#2a8cd6,#1668a6);color:#fff;font:700 13px/1 sans-serif;
|
|
box-shadow:inset 0 1px 0 rgba(255,255,255,.22),0 5px 12px rgba(10,46,78,.45)
|
|
}
|
|
.nav-btn:hover{filter:brightness(1.08)}
|
|
.nav-btn:active{transform:translateY(1px)}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="screen"></div>
|
|
<div id="status" class="status"><span class="spinner"></span>Ожидайте...</div>
|
|
<div class="nav-panel">
|
|
<button class="nav-btn" id="btn-back" type="button" title="Назад">←</button>
|
|
<button class="nav-btn" id="btn-home" type="button" title="Главная">⌂</button>
|
|
</div>
|
|
<script type="module">
|
|
import RFB from './core/rfb.js';
|
|
const wsBase = location.pathname.replace(/\/+$/, '');
|
|
const wsUrl = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + wsBase + '/websockify';
|
|
const statusEl = document.getElementById('status');
|
|
const XK_ALT_L = 0xffe9;
|
|
const XK_LEFT = 0xff51;
|
|
|
|
let rfb = null;
|
|
let connected = false;
|
|
let reconnectTimer = null;
|
|
let reconnectAttempts = 0;
|
|
const MAX_RECONNECT = 12;
|
|
const DELAYS = [1000,2000,3000,5000,8000];
|
|
let manualDisconnect = false;
|
|
|
|
function showStatus(text, isError) {
|
|
const spinner = isError ? '' : '<span class="spinner"></span>';
|
|
statusEl.innerHTML = spinner + text;
|
|
statusEl.className = 'status' + (isError ? ' error' : '');
|
|
}
|
|
|
|
function hideStatus() { statusEl.className = 'status hidden'; }
|
|
|
|
function scheduleReconnect(reason) {
|
|
if (manualDisconnect) return;
|
|
if (reconnectAttempts >= MAX_RECONNECT) {
|
|
showStatus('Соединение потеряно. Переподключение не удалось. Откройте сервис заново.', true);
|
|
return;
|
|
}
|
|
const n = ++reconnectAttempts;
|
|
const delay = DELAYS[Math.min(n-1, DELAYS.length-1)];
|
|
showStatus(\`\${reason} Повтор \${n}/\${MAX_RECONNECT} через \${Math.ceil(delay/1000)} сек.\`, true);
|
|
reconnectTimer = setTimeout(connect, delay);
|
|
}
|
|
|
|
function connect() {
|
|
if (manualDisconnect) return;
|
|
connected = false;
|
|
showStatus('Ожидайте...');
|
|
if (rfb) { try { rfb.disconnect(); } catch(e){} }
|
|
rfb = new RFB(document.getElementById('screen'), wsUrl);
|
|
rfb.viewOnly = false;
|
|
rfb.scaleViewport = true;
|
|
rfb.resizeSession = true;
|
|
rfb.addEventListener('connect', () => {
|
|
connected = true;
|
|
reconnectAttempts = 0;
|
|
clearTimeout(reconnectTimer);
|
|
showStatus('Устанавливается соединение с рабочим столом...');
|
|
setTimeout(hideStatus, 6000);
|
|
});
|
|
rfb.addEventListener('disconnect', () => {
|
|
connected = false;
|
|
if (!manualDisconnect) scheduleReconnect('Соединение потеряно.');
|
|
});
|
|
}
|
|
|
|
const enableHeartbeat = "${ENABLE_HEARTBEAT}" === "1";
|
|
const TOUCH_PATH = '${TOUCH_PATH}';
|
|
const CLOSE_PATH = TOUCH_PATH.replace(/\/touch$/, '/close');
|
|
const SESSION_CLOSED_URL = '/?session_closed=idle';
|
|
|
|
function goSessionClosed(reason) {
|
|
const r = reason === 'limit' ? 'limit' : 'idle';
|
|
try {
|
|
if (window.top && window.top !== window) { window.top.location.href = '/?session_closed=' + r; return; }
|
|
} catch(e) {}
|
|
window.location.href = '/?session_closed=' + r;
|
|
}
|
|
|
|
async function touch() {
|
|
try {
|
|
const res = await fetch(TOUCH_PATH, {method:'POST', credentials:'include'});
|
|
if (!res.ok) {
|
|
let reason = 'idle';
|
|
try { const p = await res.json(); if (p && typeof p.reason === 'string') reason = p.reason; } catch(e) {}
|
|
goSessionClosed(reason);
|
|
}
|
|
} catch(e) {}
|
|
}
|
|
|
|
let closing = false;
|
|
async function closeSessionNow() {
|
|
if (closing) return;
|
|
closing = true;
|
|
manualDisconnect = true;
|
|
clearTimeout(reconnectTimer);
|
|
try { if (rfb) rfb.disconnect(); } catch(e) {}
|
|
try { await fetch(CLOSE_PATH, {method:'POST', credentials:'include', keepalive:true}); } catch(e) {}
|
|
}
|
|
|
|
if (enableHeartbeat) {
|
|
setInterval(touch, 15000);
|
|
touch();
|
|
window.addEventListener('pagehide', closeSessionNow);
|
|
window.addEventListener('beforeunload', closeSessionNow);
|
|
}
|
|
|
|
function chord(mod, key, modCode, keyCode) {
|
|
if (!rfb) return;
|
|
rfb.sendKey(mod, modCode, true);
|
|
rfb.sendKey(key, keyCode, true);
|
|
rfb.sendKey(key, keyCode, false);
|
|
rfb.sendKey(mod, modCode, false);
|
|
}
|
|
|
|
function goHome() {
|
|
manualDisconnect = true;
|
|
clearTimeout(reconnectTimer);
|
|
try { if (window.top && window.top !== window) { window.top.location.href = '/'; return; } } catch(e) {}
|
|
window.location.href = '/';
|
|
}
|
|
|
|
document.getElementById('btn-back').addEventListener('click', () => chord(XK_ALT_L, XK_LEFT, 'AltLeft', 'ArrowLeft'));
|
|
document.getElementById('btn-home').addEventListener('click', goHome);
|
|
document.addEventListener('contextmenu', (e) => e.preventDefault());
|
|
|
|
connect();
|
|
</script>
|
|
</body>
|
|
</html>
|
|
HTML
|
|
|
|
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 &
|
|
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=$!
|
|
|
|
|
|
# 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
|
|
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
|