finally updating name and steamID by just reconnecting, no more game relaunching

This commit is contained in:
jenz 2026-07-24 10:09:52 +01:00
parent 19ee3072a0
commit 19e7ff4f14
3 changed files with 168 additions and 59 deletions

View File

@ -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

View File

@ -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:

View File

@ -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 <ip>; 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;