From 8213b3d98c79209d1a6b9413287c1e52cf5a0afa Mon Sep 17 00:00:00 2001 From: shwzss <142552560+shwzss@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:10:45 -0500 Subject: [PATCH] Add Node.js server skeleton with auth, persistence, and admin console (custom-node-server branch) --- .env.example | 19 ++++++++++ README.md | 38 ++++++++++++++++++-- package.json | 20 +++++++++++ src/admin.js | 50 ++++++++++++++++++++++++++ src/auth.js | 45 +++++++++++++++++++++++ src/index.js | 85 ++++++++++++++++++++++++++++++++++++++++++++ src/logger.js | 16 +++++++++ src/protocol.js | 25 +++++++++++++ src/storage.js | 64 +++++++++++++++++++++++++++++++++ tools/test-client.js | 19 ++++++++++ 10 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 .env.example create mode 100644 package.json create mode 100644 src/admin.js create mode 100644 src/auth.js create mode 100644 src/index.js create mode 100644 src/logger.js create mode 100644 src/protocol.js create mode 100644 src/storage.js create mode 100644 tools/test-client.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0ae165b --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# .env.example + +# Port the server listens on (client must connect to this) +PORT=5055 +ADMIN_PORT=5056 + +# Logging: error, warn, info, verbose, debug, silly +LOG_LEVEL=info + +# Steam/RecNet +STEAM_WEB_API_KEY= +RECNET_VERIFY_URL= + +# SQLite DB file +SQLITE_FILE=./helium.db + +# Admin basic auth (optional) +ADMIN_USER=admin +ADMIN_PASS=changeme diff --git a/README.md b/README.md index aac3c5e..511ab74 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,36 @@ -# HeliumRecServer -A Dedicated Server For 2023-2025 Rec Room in Node.js +# HeliumRecServer (custom-node-server branch) + +This branch adds a Node.js skeleton for running a custom Rec Room (2023) server replacement that you can adapt to work with the Photon-based client by swapping keys/addresses in the client. + +What I added in this commit: +- WebSocket server skeleton (src/index.js) that accepts query-token authentication and persists minimal user data +- Protocol stub (src/protocol.js) — where Rec Room / Photon packet parsing should be implemented +- Auth helpers (src/auth.js) for Steam/RecNet token verification (placeholders that call configured endpoints) +- SQLite persistence (src/storage.js) +- Admin console (src/admin.js) with basic auth and endpoints to list connections and broadcast messages +- Logger and test client +- package.json with dependencies + +Environment variables (set in .env): +- PORT (default 5055) — WebSocket server port +- ADMIN_PORT (default 5056) — Admin HTTP server port +- LOG_LEVEL — winston log level (info/debug/...) +- STEAM_WEB_API_KEY — Steam Web API key (if you plan to use Steam verification) +- RECNET_VERIFY_URL — RecNet verification endpoint to POST { token } +- SQLITE_FILE — path for the SQLite DB file (default ./helium.db) +- ADMIN_USER / ADMIN_PASS — optional Basic auth for admin console + +How to run: +1. Copy `.env.example` to `.env` and fill in the variables you need. +2. npm install +3. npm start +4. Admin console: http://localhost:5056 (use basic auth if ADMIN_USER/ADMIN_PASS set) + +Notes about Photon & Rec Room: +- The client uses Photon; this skeleton provides a WebSocket entry point you can use for reverse engineering and testing. Photon traffic is binary and has its own framing — you'll need to implement Photon protocol handling inside src/protocol.js (or add a UDP/TCP transport if Photon expects that). +- You said you can replace Photon keys and localhost yourself — good. Put the Photon server host/port to point to this process and replace any required AppId/AppVersion in the client. + +Next steps I can take after you confirm: +- Implement Photon framing and basic handshake to accept a 2023 Rec Room client (requires packet captures or protocol docs). +- Implement Steam/RecNet direct verification flow if you provide endpoints or tokens to test. +- Add more persistence (Redis) and matchmaking logic. diff --git a/package.json b/package.json new file mode 100644 index 0000000..1cb9c39 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "helium-rec-server", + "version": "0.1.0", + "private": true, + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "nodemon src/index.js", + "admin": "node src/admin.js" + }, + "dependencies": { + "ws": "^8.13.0", + "dotenv": "^16.0.0", + "winston": "^3.8.0", + "express": "^4.18.2", + "axios": "^1.4.0", + "sqlite3": "^5.1.6", + "basic-auth": "^2.0.1" + } +} diff --git a/src/admin.js b/src/admin.js new file mode 100644 index 0000000..d30bebb --- /dev/null +++ b/src/admin.js @@ -0,0 +1,50 @@ +const express = require('express'); +const basicAuth = require('basic-auth'); +const logger = require('./logger'); + +let serverInstance; + +function ensureAuth(req, res, next) { + if (!process.env.ADMIN_USER || !process.env.ADMIN_PASS) return next(); + const user = basicAuth(req); + if (!user || user.name !== process.env.ADMIN_USER || user.pass !== process.env.ADMIN_PASS) { + res.set('WWW-Authenticate', 'Basic realm="HeliumRecAdmin"'); + return res.status(401).send('Authentication required'); + } + return next(); +} + +function start({ port = 5056, getConnections }) { + const app = express(); + app.use(express.json()); + + app.get('/health', (req, res) => res.json({ ok: true })); + + app.use(ensureAuth); + + app.get('/connections', (req, res) => { + const conns = []; + const map = getConnections(); + map.forEach((v, k) => { + conns.push({ id: k, user: v.user, connectedAt: v.connectedAt, remote: v.ws?._meta?.remote }); + }); + res.json(conns); + }); + + app.post('/broadcast', (req, res) => { + const { message } = req.body || {}; + if (!message) return res.status(400).json({ error: 'message required' }); + const map = getConnections(); + let sent = 0; + map.forEach((v) => { + try { v.ws.send(typeof message === 'string' ? message : JSON.stringify(message)); sent++; } catch (e) { logger.warn('broadcast failed', e.message); } + }); + res.json({ sent }); + }); + + serverInstance = app.listen(port, () => logger.info(`Admin console listening on http://0.0.0.0:${port}`)); +} + +function stop() { if (serverInstance) serverInstance.close(); } + +module.exports = { start, stop }; diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 0000000..f44f254 --- /dev/null +++ b/src/auth.js @@ -0,0 +1,45 @@ +const axios = require('axios'); +const logger = require('./logger'); + +// These functions are intentionally generic/stubbed. Configure endpoints via env. +// STEAM: require STEAM_WEB_API_KEY to call ISteamUserAuth/AuthenticateUserTicket if you have an auth ticket. +// RECNET: set RECNET_VERIFY_URL to a RecNet endpoint that verifies tokens. + +const STEAM_API_KEY = process.env.STEAM_WEB_API_KEY || ''; +const RECNET_VERIFY_URL = process.env.RECNET_VERIFY_URL || ''; + +async function verifySteamToken(token) { + if (!STEAM_API_KEY) throw new Error('STEAM_WEB_API_KEY not configured'); + if (!token) throw new Error('no token'); + + // Example: call Steam Web API to validate an auth ticket. Replace with proper endpoint as needed. + // This is a placeholder; Steam ticket verification requires the AuthenticateUserTicket endpoint and a ticket from client. + const url = `https://api.steampowered.com/ISteamUserAuth/AuthenticateUserTicket/v1/?key=${STEAM_API_KEY}&ticket=${encodeURIComponent(token)}`; + try { + const res = await axios.get(url); + // Properly interpret res.data based on Steam response shape + logger.debug('Steam verify response', { data: res.data }); + // Map to a normalized user object + return { id: res.data.response?.params?.steamid || null, provider: 'steam', raw: res.data }; + } catch (err) { + logger.warn('Steam token verification failed', { err: err.message }); + throw err; + } +} + +async function verifyRecNetToken(token) { + if (!RECNET_VERIFY_URL) throw new Error('RECNET_VERIFY_URL not configured'); + if (!token) throw new Error('no token'); + + try { + const res = await axios.post(RECNET_VERIFY_URL, { token }); + logger.debug('RecNet verify response', { data: res.data }); + // Map to normalized user + return { id: res.data?.id || res.data?.userId || null, provider: 'recnet', raw: res.data }; + } catch (err) { + logger.warn('RecNet token verification failed', { err: err.message }); + throw err; + } +} + +module.exports = { verifySteamToken, verifyRecNetToken }; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..97bbf2e --- /dev/null +++ b/src/index.js @@ -0,0 +1,85 @@ +const dotenv = require('dotenv'); +dotenv.config(); + +const WebSocket = require('ws'); +const http = require('http'); +const url = require('url'); +const logger = require('./logger'); +const protocol = require('./protocol'); +const auth = require('./auth'); +const storage = require('./storage'); +const admin = require('./admin'); + +const PORT = process.env.PORT || 5055; +const ADMIN_PORT = process.env.ADMIN_PORT || 5056; + +(async () => { + await storage.init(); + + // Start WebSocket server on its own HTTP server so we can inspect req.url + const server = http.createServer(); + const wss = new WebSocket.Server({ noServer: true }); + + const connections = new Map(); // key -> { ws, user } + + server.on('upgrade', async (req, socket, head) => { + // Parse query params for tokens/metadata + const parsed = url.parse(req.url, true); + const { tokenType, token } = parsed.query || {}; + + wss.handleUpgrade(req, socket, head, (ws) => { + // Attach metadata + ws._meta = { remote: req.socket.remoteAddress + ':' + req.socket.remotePort }; + + // Optionally verify token before accepting + (async () => { + let user = null; + if (token && tokenType) { + try { + if (tokenType === 'steam') { + user = await auth.verifySteamToken(token); + } else if (tokenType === 'recnet') { + user = await auth.verifyRecNetToken(token); + } + } catch (err) { + logger.warn('Auth verification failed, closing connection', { err: err.message }); + ws.close(4001, 'auth_failed'); + return; + } + } + + // Store connection and user + const id = `${ws._meta.remote}-${Date.now()}-${Math.random().toString(16).slice(2,8)}`; + connections.set(id, { ws, user, connectedAt: Date.now() }); + + // Persist user if available + if (user && user.id) { + try { await storage.saveOrUpdateUser(user); } catch (err) { logger.error('Failed to persist user', err); } + } + + // Wire events + ws.on('message', (data) => { + logger.debug(`Received ${Buffer.isBuffer(data) ? data.length : data.toString().length} bytes from ${ws._meta.remote}`); + protocol.handlePacket(ws, data, { remote: ws._meta.remote, user }); + }); + + ws.on('close', (code, reason) => { + logger.info(`Connection closed ${ws._meta.remote} code=${code} reason=${reason}`); + // remove from map + connections.forEach((v, k) => { if (v.ws === ws) connections.delete(k); }); + }); + + ws.on('error', (err) => logger.error(`Connection error ${ws._meta.remote}: ${err.stack || err}`)); + + // Send welcome / handshake placeholder + try { ws.send(Buffer.from('HELLO_FROM_HELIUM_SERVER')); } catch (err) { logger.error('Failed to send welcome', err); } + + })(); + }); + }); + + server.listen(PORT, () => logger.info(`HeliumRecServer WebSocket listening on ws://0.0.0.0:${PORT}`)); + + // Start admin console (HTTP) bound to ADMIN_PORT + admin.start({ port: ADMIN_PORT, getConnections: () => connections }); +})(); diff --git a/src/logger.js b/src/logger.js new file mode 100644 index 0000000..6b05a6c --- /dev/null +++ b/src/logger.js @@ -0,0 +1,16 @@ +const { createLogger, format, transports } = require('winston'); + +const logger = createLogger({ + level: process.env.LOG_LEVEL || 'info', + format: format.combine( + format.timestamp(), + format.colorize(), + format.printf(({ timestamp, level, message, ...meta }) => { + const metaStr = Object.keys(meta).length ? JSON.stringify(meta) : ''; + return `${timestamp} ${level}: ${message} ${metaStr}`; + }) + ), + transports: [new transports.Console()] +}); + +module.exports = logger; diff --git a/src/protocol.js b/src/protocol.js new file mode 100644 index 0000000..a324bac --- /dev/null +++ b/src/protocol.js @@ -0,0 +1,25 @@ +// protocol.js +// Minimal, protocol-agnostic handler. Replace parsing below with the real +// Rec Room 2023 packet parsing and responses once protocol details are available. + +const logger = require('./logger'); + +function handlePacket(ws, data, meta) { + logger.debug('protocol.handlePacket called', { length: Buffer.isBuffer(data) ? data.length : data.toString().length, remote: meta.remote, user: meta.user ? meta.user.id : null }); + + if (Buffer.isBuffer(data)) { + logger.silly('packet hex:', data.slice(0, 32).toString('hex')); + } else { + logger.silly('packet string:', data.toString()); + } + + // TODO: Implement Rec Room 2023 protocol (Photon/RecNet flows). For now, echo a small binary message back + try { + const reply = Buffer.from('SERVER_REPLY'); + ws.send(reply); + } catch (err) { + logger.error('Failed to send reply:', err); + } +} + +module.exports = { handlePacket }; diff --git a/src/storage.js b/src/storage.js new file mode 100644 index 0000000..cb9a6a0 --- /dev/null +++ b/src/storage.js @@ -0,0 +1,64 @@ +const sqlite3 = require('sqlite3'); +const { open } = require('sqlite'); +const logger = require('./logger'); + +let db; + +async function init() { + const filename = process.env.SQLITE_FILE || './helium.db'; + db = await open({ filename, driver: sqlite3.Database }); + + // Create tables if not exist + await db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + provider TEXT, + data TEXT, + created_at INTEGER, + updated_at INTEGER + ); + + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id TEXT, + started_at INTEGER, + last_seen INTEGER, + data TEXT + ); + `); + + logger.info('Storage initialized', { filename }); +} + +async function saveOrUpdateUser(user) { + if (!db) throw new Error('db not initialized'); + const now = Date.now(); + const data = JSON.stringify(user.raw || user); + await db.run( + `INSERT INTO users (id, provider, data, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET provider = excluded.provider, data = excluded.data, updated_at = excluded.updated_at;`, + user.id, user.provider, data, now, now + ); +} + +async function getUserById(id) { + if (!db) throw new Error('db not initialized'); + const row = await db.get('SELECT * FROM users WHERE id = ?', id); + if (!row) return null; + return { id: row.id, provider: row.provider, data: JSON.parse(row.data), created_at: row.created_at, updated_at: row.updated_at }; +} + +async function saveSession(session) { + if (!db) throw new Error('db not initialized'); + const now = Date.now(); + const data = JSON.stringify(session.data || {}); + await db.run( + `INSERT INTO sessions (id, user_id, started_at, last_seen, data) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET last_seen = excluded.last_seen, data = excluded.data;`, + session.id, session.user_id || null, session.started_at || now, session.last_seen || now, data + ); +} + +module.exports = { init, saveOrUpdateUser, getUserById, saveSession }; diff --git a/tools/test-client.js b/tools/test-client.js new file mode 100644 index 0000000..713c5e2 --- /dev/null +++ b/tools/test-client.js @@ -0,0 +1,19 @@ +// Simple test client that connects to the WebSocket server and sends a message. +// Use this to verify the skeleton server accepts a connection and replies. +const WebSocket = require('ws'); + +const url = process.argv[2] || 'ws://localhost:5055?tokenType=recnet&token=dummy'; +const ws = new WebSocket(url); + +ws.on('open', () => { + console.log('connected to server', url); + // send a small binary payload (example) + ws.send(Buffer.from([0x01, 0x02, 0x03, 0x04])); +}); + +ws.on('message', (data) => { + console.log('received reply:', data); + ws.close(); +}); + +ws.on('error', (err) => console.error('client error', err));