Merge pull request #1 from shwzss/custom-node-server

Add Node.js server skeleton with auth, persistence, and admin console…
This commit is contained in:
shwzss
2026-09-02 22:11:59 -05:00
committed by GitHub
10 changed files with 379 additions and 2 deletions
+19
View File
@@ -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
+36 -2
View File
@@ -1,2 +1,36 @@
# HeliumRecServer # HeliumRecServer (custom-node-server branch)
A Dedicated Server For 2023-2025 Rec Room in Node.js
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.
+20
View File
@@ -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"
}
}
+50
View File
@@ -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 };
+45
View File
@@ -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 };
+85
View File
@@ -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 });
})();
+16
View File
@@ -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;
+25
View File
@@ -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 };
+64
View File
@@ -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 };
+19
View File
@@ -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));