From 19e7ff4f14f01de7fae5932d21f795c462e0e895 Mon Sep 17 00:00:00 2001 From: jenz Date: Fri, 24 Jul 2026 10:09:52 +0100 Subject: [PATCH] finally updating name and steamID by just reconnecting, no more game relaunching --- README.md | 2 + streaming-agent/gst_agent.py | 24 ++++ streaming-agent/signaling-server.js | 201 ++++++++++++++++++++-------- 3 files changed, 168 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 055c7ed..21f5300 100644 --- a/README.md +++ b/README.md @@ -65,3 +65,5 @@ css-client ## the inbuilt DXVK version 2.3 is replaced with DXVK 3.0 downloaded from https://github.com/doitsujin/dxvk/releases/download/v3.0/dxvk-3.0.tar.gz ## docker cp dxvk-3.0/x64/d3d9.dll 'css-test:/home/ubuntu/Counter-Strike Source/bin/x64/dxvk_d3d9.dll' #probably was like this to copy it in. + +## relies on nosteam_celt plugin for receiving the name correctly. -> https://git.unloze.com/UNLOZE/sm-plugins/src/branch/master/CELT_VOICE diff --git a/streaming-agent/gst_agent.py b/streaming-agent/gst_agent.py index 9209064..b326563 100755 --- a/streaming-agent/gst_agent.py +++ b/streaming-agent/gst_agent.py @@ -173,6 +173,30 @@ def handle_input(event): xdotool('click', button) elif t == 'release_keys': release_all_keys() + elif t == 'connect_to_server': + threading.Thread(target=_connect_to_server_sequence, daemon=True).start() + +# ── Automated menu-connect sequence ────────────────────────────────────────── +# Triggered when the signaling server writes a new password (real visitor IP) +# into GameMenu.res for a new controller. Opens the in-game escape menu, +# moves the mouse to the "UNLOZE | ZombieEscape" button at its fixed absolute +# position (measured empirically at 800x600: x=110, y=348), and clicks it. +# This re-connects the client with the freshly-written password, which the +# server-side connect extension reads to assign the correct name/SteamID — +# all without restarting the CSS process itself. +CONNECT_BUTTON_X = 110 +CONNECT_BUTTON_Y = 348 + +def _connect_to_server_sequence(): + log.info('connect_to_server: opening menu') + xdotool('key', 'Escape') + time.sleep(0.5) # let the menu render + + xdotool('mousemove', '--sync', str(CONNECT_BUTTON_X), str(CONNECT_BUTTON_Y)) + time.sleep(0.2) # let the button highlight/register hover + + xdotool('click', '1') + log.info('connect_to_server: clicked ZombieEscape button') # ── GStreamer Pipeline ──────────────────────────────────────────────────────── class CSSStreamAgent: diff --git a/streaming-agent/signaling-server.js b/streaming-agent/signaling-server.js index db18ed0..462ae36 100644 --- a/streaming-agent/signaling-server.js +++ b/streaming-agent/signaling-server.js @@ -2,7 +2,7 @@ * Signaling Server * * Handles two tracks: video and game audio. - * Also manages controller queue and CSS process restart for name changes. + * Also manages controller queue. * All Cloudflare API calls happen server-side. * * Run: CF_APP_ID=xxx CF_APP_TOKEN=xxx NAME_SALT=xxx CONTAINER_NAME=css-test node signaling-server.js @@ -16,6 +16,7 @@ const WebSocket = require('ws'); const fetch = require('node-fetch'); const path = require('path'); const { spawn, execSync } = require('child_process'); +const dns = require('dns').promises; const PORT = process.env.PORT || 3000; const CF_APP_ID = process.env.CF_APP_ID || ''; @@ -25,9 +26,6 @@ const CONTAINER = process.env.CONTAINER_NAME || 'css-test'; const IDLE_TIMEOUT = 60000; // 60 seconds const NAME_SALT = process.env.NAME_SALT || 'change-this-secret'; -// How long to wait after relaunching CSS before granting control -const CSS_LAUNCH_WAIT = 15000; - if (!CF_APP_ID || !CF_APP_TOKEN) { console.error('[sig] CF_APP_ID and CF_APP_TOKEN must be set'); process.exit(1); @@ -62,8 +60,109 @@ const server = http.createServer(app); const wss = new WebSocket.Server({ server }); app.use(express.json()); +app.use(express.urlencoded({ extended: false })); app.get('/', (req, res) => res.sendFile(path.join(__dirname, '..', 'client.html'))); +// ── Internal-request gate ───────────────────────────────────────────────────── +// Used by both /webclient-name (POST, from the SourceMod plugin) and +// /webclient-current-ip (GET, polled by the SourceMod plugin). Restricted to +// loopback / docker-bridge / private ranges, PLUS this box's own resolved +// public IP. +// +// The public-IP allowance is required because SteamWorks' embedded HTTP +// client (or the Source Engine networking layer beneath it) does NOT route +// outbound requests through true OS loopback the way a normal HTTP library +// would — even when targeting 127.0.0.1, the connection is sourced from +// whatever interface the game server process is bound to for its own +// traffic, which is the public interface. This was confirmed directly from +// logs: requests aimed at 127.0.0.1 arrived showing the box's public IP as +// the remote address. A strict "127.0.0.1 only" check would silently reject +// the SourceMod plugin's own requests. +// +// This is still safe: the TCP handshake proves the sender genuinely owns +// that IP (not spoofable over a real connection), so allowing this one +// specific, DNS-resolved address does not expose the endpoint to the public +// internet — nobody else can complete a TCP handshake claiming to be this +// server's own IP. +const SERVER_HOSTNAME = process.env.SERVER_HOSTNAME || 'ze.unloze.com'; +let resolvedServerIp = null; + +async function refreshServerPublicIp() { + try { + const { address } = await dns.lookup(SERVER_HOSTNAME); + resolvedServerIp = address; + console.log(`[sig] resolved ${SERVER_HOSTNAME} -> ${address}`); + } catch (e) { + console.error(`[sig] failed to resolve ${SERVER_HOSTNAME}:`, e.message); + } +} + +refreshServerPublicIp(); + +function isInternalRequest(req) { + const ip = req.socket.remoteAddress || ''; + // Normalise IPv4-mapped IPv6 addresses (::ffff:127.0.0.1 -> 127.0.0.1) + const normalized = ip.replace('::ffff:', ''); + return normalized === '127.0.0.1' || + normalized === '::1' || + normalized.startsWith('172.') || // docker bridge network range + normalized.startsWith('10.') || // private network range, adjust as needed + (resolvedServerIp && normalized === resolvedServerIp); +} + +// SourceMod plugin -> signaling server: reports the name the connect +// extension generated for the currently-connected webclient player, so the +// browser queue panel and controller-restart-skip logic stay in sync with +// the actual in-game name/SteamID. +app.post('/webclient-name', (req, res) => { + if (!isInternalRequest(req)) { + console.log(`[sig] rejected /webclient-name from non-internal IP: ${req.socket.remoteAddress}`); + return res.status(403).send('forbidden'); + } + + const name = (req.body && req.body.name) ? String(req.body.name).trim() : ''; + if (!name) { + return res.status(400).send('missing name'); + } + + console.log(`[sig] webclient-name received: ${name}`); + lastControllerName = name; + if (currentController) { + currentController.visitorName = name; + } + broadcastQueueState(); + + res.status(200).send('ok'); +}); + +// SourceMod plugin -> signaling server (polled): returns the current +// controller's real IP as plain text, so the plugin can detect when it +// changes and force the already-connected webclient player to reconnect +// with "password ; retry" — reusing the exact same connect-extension +// path already proven correct when a password is supplied manually. +// Returns an empty body when nobody is currently controlling. +// +// This is the ONLY mechanism used to propagate the new controller's IP into +// the game now — there is no more GameMenu.res rewriting, no sed, no +// automated Escape/mouse-move/click sequence. That whole approach was +// removed: GameMenu.res is only parsed once by the engine at menu-build +// time, so rewriting the file on disk while CSS keeps running indefinitely +// (which we do, by design, to avoid restarting the game on every handoff) +// never actually took effect after the very first read — every subsequent +// person's reconnect silently reused whichever password was cached from +// that first parse, which is why everyone ended up with the same generated +// name/SteamID regardless of how many times the file was correctly +// rewritten on disk. +app.get('/webclient-current-ip', (req, res) => { + if (!isInternalRequest(req)) { + console.log(`[sig] rejected /webclient-current-ip from non-internal IP: ${req.socket.remoteAddress}`); + return res.status(403).send('forbidden'); + } + + const ip = currentController ? (currentController.ws._realIp || '') : ''; + res.status(200).send(ip); +}); + // ── Session registry ────────────────────────────────────────────────────────── const agentSessions = new Map(); const browserToAgent = new Map(); @@ -100,77 +199,61 @@ function resetIdleTimer(entry) { }, IDLE_TIMEOUT); } -async function applyController(entry) { +function applyController(entry) { // Defensive: if this exact entry is sitting in the queue (e.g. a race // between request_control calls), pull it out so it's never displayed // as both controller and queued. const qIdx = controlQueue.indexOf(entry); if (qIdx !== -1) controlQueue.splice(qIdx, 1); - containerRestarting = true; - broadcastQueueState(); - - // Same person returning — no restart needed - if (entry.visitorName === lastControllerName) { - console.log(`[sig] same controller returning, skipping restart`); - containerRestarting = false; - currentController = entry; - resetIdleTimer(entry); - broadcastQueueState(); - if (entry.ws.readyState === WebSocket.OPEN) { - entry.ws.send(JSON.stringify({ type: 'control_granted' })); - } - return; - } - - // Different person — update rev.ini and relaunch CSS only + // Granting control is now instant — no CSS restart, no GameMenu.res + // rewrite, no automated click sequence. The SourceMod plugin picks up the + // new controller's IP by polling GET /webclient-current-ip and forces the + // in-game reconnect itself via ClientCommand(client, "password %s; retry"). lastControllerName = entry.visitorName; - console.log(`[sig] applying controller: ${entry.visitorName}`); - - // Update rev.ini - try { - const safeName = entry.visitorName.replace(/'/g, "'\\''"); - execSync(`docker exec ${CONTAINER} sed -i "s/PlayerName=.*/PlayerName=${safeName}/" "/home/ubuntu/Counter-Strike Source/rev.ini"`); - console.log(`[sig] rev.ini updated with name: ${entry.visitorName}`); - } catch (e) { - console.error('[sig] failed to update rev.ini:', e.message); - } - - // Kill CSS and revLoader (leave Xvfb, PulseAudio, gst_agent running) - try { - execSync(`docker exec ${CONTAINER} pkill -f "cstrike_win64.exe" || true`); - execSync(`docker exec ${CONTAINER} pkill -f "revLoader.exe" || true`); - execSync(`docker exec ${CONTAINER} pkill -f "wineserver" || true`); - execSync(`docker exec ${CONTAINER} pkill -f "winedevice.exe" || true`); - console.log('[sig] CSS processes killed'); -} catch (e) { - console.error('[sig] failed to kill CSS:', e.message); -} - - // Wait for Wine to clean up - await new Promise(r => setTimeout(r, 2000)); - - // Relaunch CSS - try { - execSync(`docker exec -d ${CONTAINER} bash -c "DISPLAY=:99 wine '/home/ubuntu/Counter-Strike Source/revLoader.exe'"`); - console.log('[sig] CSS relaunched'); - } catch (e) { - console.error('[sig] failed to relaunch CSS:', e.message); - } - - // Wait for CSS to launch and connect to server - await new Promise(r => setTimeout(r, CSS_LAUNCH_WAIT)); - - containerRestarting = false; currentController = entry; resetIdleTimer(entry); broadcastQueueState(); + console.log(`[sig] applying controller: ${entry.visitorName}`); + if (entry.ws.readyState === WebSocket.OPEN) { entry.ws.send(JSON.stringify({ type: 'control_granted' })); } } +// ── Periodic CSS restart ────────────────────────────────────────────────────── +// Controller handoffs no longer restart the game at all, so CSS now stays +// running indefinitely across many sessions. Long Wine sessions have +// historically accumulated issues (audio/keyboard corruption, memory +// growth), so restart the game process on a fixed schedule regardless of +// who's currently in control. +const CSS_RESTART_INTERVAL = 5 * 60 * 60 * 1000; // 5 hours + +function scheduledCssRestart() { + console.log('[sig] scheduled 5-hour CSS restart starting'); + try { + execSync(`docker exec ${CONTAINER} pkill -f "cstrike_win64.exe" || true`); + execSync(`docker exec ${CONTAINER} pkill -f "revLoader.exe" || true`); + execSync(`docker exec ${CONTAINER} pkill -f "wineserver" || true`); + execSync(`docker exec ${CONTAINER} pkill -f "winedevice.exe" || true`); + console.log('[sig] scheduled restart: CSS processes killed'); + } catch (e) { + console.error('[sig] scheduled restart: failed to kill CSS:', e.message); + } + + setTimeout(() => { + try { + execSync(`docker exec -d ${CONTAINER} bash -c "DISPLAY=:99 wine '/home/ubuntu/Counter-Strike Source/revLoader.exe'"`); + console.log('[sig] scheduled restart: CSS relaunched'); + } catch (e) { + console.error('[sig] scheduled restart: failed to relaunch CSS:', e.message); + } + }, 2000); +} + +setInterval(scheduledCssRestart, CSS_RESTART_INTERVAL); + function releaseControl(entry, reason) { if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; } if (currentController !== entry) return;