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