Add Node.js server skeleton with auth, persistence, and admin console (custom-node-server branch)
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
@@ -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 });
|
||||
})();
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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));
|
||||
Reference in New Issue
Block a user