Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 198 additions & 0 deletions api/realtime.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
const settings = require("../settings.json");
const chalk = require('chalk');
const fetch = require('node-fetch');

module.exports.load = async function(app, db) {
const indexjs = require("../index.js");
const io = indexjs.io;
const statsService = indexjs.statsService;

if (!io || !statsService) {
console.error(chalk.red('[REALTIME] Socket.io or StatsService not initialized'));
return;
}
io.use(async (socket, next) => {
const sessionId = socket.handshake.auth.sessionId;
const userId = socket.handshake.auth.userId;
if (!userId || !sessionId) {
return next(new Error('Authentication required'));
}
const userExists = await db.get(`users-${userId}`);
if (!userExists) {
return next(new Error('Invalid user'));
}

socket.userId = userId;
socket.pteroUserId = userExists;
next();
});
io.on('connection', (socket) => {
console.log(chalk.cyan(`[REALTIME] User ${socket.userId} connected`));
socket.join(`user-${socket.userId}`);
socket.on('subscribe-dashboard', async () => {
socket.join('dashboard');
const allServers = statsService.getAllServersSnapshot();
socket.emit('dashboard-init', allServers);

console.log(chalk.green(`[REALTIME] User ${socket.userId} subscribed to dashboard`));
});
socket.on('subscribe-server', async (data) => {
const { identifier } = data;

if (!identifier) {
socket.emit('error', { message: 'Server identifier required' });
return;
}
const hasAccess = await verifyServerAccess(socket.pteroUserId, identifier);
if (!hasAccess) {
socket.emit('error', { message: 'Access denied' });
return;
}
socket.join(`server-${identifier}`);
const serverData = statsService.getServerStatsSnapshot(identifier);
if (serverData) {
socket.emit('server-init', serverData);
}

console.log(chalk.green(`[REALTIME] User ${socket.userId} subscribed to server ${identifier}`));
});
socket.on('subscribe-console', async (data) => {
const { identifier } = data;

if (!identifier) {
socket.emit('error', { message: 'Server identifier required' });
return;
}
const hasAccess = await verifyServerAccess(socket.pteroUserId, identifier);
if (!hasAccess) {
socket.emit('error', { message: 'Access denied' });
return;
}
const wsUrl = await getServerWebSocketUrl(identifier);
if (wsUrl) {
connectToServerConsole(socket, identifier, wsUrl);
} else {
socket.emit('error', { message: 'Could not connect to server console' });
}
});
socket.on('unsubscribe-dashboard', () => {
socket.leave('dashboard');
console.log(chalk.yellow(`[REALTIME] User ${socket.userId} unsubscribed from dashboard`));
});

socket.on('unsubscribe-server', (data) => {
const { identifier } = data;
socket.leave(`server-${identifier}`);
console.log(chalk.yellow(`[REALTIME] User ${socket.userId} unsubscribed from server ${identifier}`));
});
socket.on('disconnect', () => {
console.log(chalk.yellow(`[REALTIME] User ${socket.userId} disconnected`));
});
socket.on('request-stats-history', async (data) => {
const { identifier, duration } = data;

const hasAccess = await verifyServerAccess(socket.pteroUserId, identifier);
if (!hasAccess) {
socket.emit('error', { message: 'Access denied' });
return;
}

const history = await statsService.getServerHistory(identifier, duration);
socket.emit('stats-history', { identifier, history });
});
});
async function verifyServerAccess(pteroUserId, identifier) {
try {
const response = await fetch(
`${settings.pterodactyl.domain}/api/client/servers/${identifier}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${settings.pterodactyl.account_key}`,
'Accept': 'application/json'
}
}
);

return response.ok;
} catch (error) {
return false;
}
}
async function getServerWebSocketUrl(identifier) {
try {
const response = await fetch(
`${settings.pterodactyl.domain}/api/client/servers/${identifier}/websocket`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${settings.pterodactyl.account_key}`,
'Accept': 'application/json'
}
}
);

if (!response.ok) return null;

const data = await response.json();
return data.data;
} catch (error) {
return null;
}
}
function connectToServerConsole(clientSocket, identifier, wsData) {
const WebSocket = require('ws');
const ws = new WebSocket(wsData.socket);

ws.on('open', () => {
ws.send(JSON.stringify({
event: 'auth',
args: [wsData.token]
}));
ws.send(JSON.stringify({
event: 'send logs',
args: [null]
}));

console.log(chalk.green(`[REALTIME] Console connected for server ${identifier}`));
});

ws.on('message', (data) => {
try {
const message = JSON.parse(data);
if (message.event === 'console output') {
clientSocket.emit('console-output', {
identifier,
output: message.args[0]
});
}
if (message.event === 'status') {
clientSocket.emit('console-status', {
identifier,
status: message.args[0]
});
}
} catch (error) {
console.error(chalk.red('[REALTIME] Error parsing console message:'), error);
}
});

ws.on('error', (error) => {
console.error(chalk.red(`[REALTIME] Console WebSocket error for ${identifier}:`), error);
clientSocket.emit('console-error', {
identifier,
message: 'Console connection error'
});
});
ws.on('close', () => {
console.log(chalk.yellow(`[REALTIME] Console disconnected for server ${identifier}`));
clientSocket.emit('console-disconnected', { identifier });
});
clientSocket.on('disconnect', () => {
ws.close();
});
clientSocket.consoleWs = ws;
}
};
21 changes: 20 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,22 @@ app.use(express.json({
verify: undefined
}));

const listener = app.listen(settings.website.port, function() {
const http = require('http');
const server = http.createServer(app);
const { Server } = require('socket.io');
const io = new Server(server, {
cors: {
origin: settings.api.client.oauth2.link,
credentials: true
}
});

const StatsService = require('./managers/StatsService');
const statsService = new StatsService(settings, db);
module.exports.io = io;
module.exports.statsService = statsService;

const listener = server.listen(settings.website.port, function() {
console.clear();
console.log(chalk.gray(" "));
console.log(chalk.gray(" ") + chalk.bgBlack(" LAPSUS CLIENT IS ONLINE "));
Expand All @@ -128,6 +143,10 @@ const listener = app.listen(settings.website.port, function() {
console.log(chalk.gray(" ") + chalk.blue("[THEME]") + chalk.white(" You're using ") + chalk.underline(settings.defaulttheme) + " theme");
console.log(chalk.gray(" "));
console.log(chalk.gray(" ") + chalk.cyan("[SYSTEM]") + chalk.white(" You can now access the dashboard at ") + chalk.underline(settings.api.client.oauth2.link + "/"));
console.log(chalk.gray(" "));
console.log(chalk.gray(" ") + chalk.magenta("[REALTIME]") + chalk.white(" WebSocket server initialized for live stats"));
statsService.initialize(io);

if (settings.defaulttheme !== 'lapsus' && settings.defaulttheme !== 'lapsusv2' && settings.defaulttheme !== 'pylex') {
console.log(chalk.gray(" "));
console.log(chalk.gray(" ") + chalk.yellow("[WARNING]") + chalk.white(" You're using an unofficial theme. This means you are exposed to vulnerabilities and bugs. Consider using the official theme or a third party theme provided by Lapsus.")); }
Expand Down
150 changes: 150 additions & 0 deletions managers/StatsService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
const fetch = require('node-fetch');
const chalk = require('chalk');

class StatsService {
constructor(settings, db) {
this.settings = settings;
this.db = db;
this.io = null;
this.updateInterval = 5000;
this.serverStats = new Map();
this.intervalId = null;
}

initialize(io) {
this.io = io;
console.log(chalk.green('[STATS] Real-time stats service initialized'));
this.startPolling();
}

startPolling() {
if (this.intervalId) return;
this.intervalId = setInterval(async () => {
await this.pollAllServers();
}, this.updateInterval);
this.pollAllServers();
}

stopPolling() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}

async pollAllServers() {
try {
const allServersResponse = await fetch(
`${this.settings.pterodactyl.domain}/api/application/servers?per_page=100`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.settings.pterodactyl.key}`,
'Accept': 'application/json'
}
}
);

if (!allServersResponse.ok) {
console.error(chalk.red('[STATS] Failed to fetch servers from Pterodactyl'));
return;
}

const serversData = await allServersResponse.json();
const servers = serversData.data || [];

for (const server of servers) {
const stats = await this.getServerStats(server.attributes.identifier);

if (stats) {
const serverData = {
serverId: server.attributes.id,
identifier: server.attributes.identifier,
name: server.attributes.name,
userId: server.attributes.user,
status: stats.current_state || 'offline',
resources: stats.resources || {},
timestamp: Date.now()
};

this.serverStats.set(server.attributes.identifier, serverData);
this.io.to(`server-${server.attributes.identifier}`).emit('server-stats', serverData);
this.io.to('dashboard').emit('server-update', serverData);
}
}
} catch (error) {
console.error(chalk.red('[STATS] Error polling servers:'), error.message);
}
}

async getAllUserKeys() {
try {
const keys = [];
return keys;
} catch (error) {
return [];
}
}

async getUserServers(pteroUserId) {
try {
const response = await fetch(
`${this.settings.pterodactyl.domain}/api/application/users/${pteroUserId}?include=servers`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.settings.pterodactyl.key}`,
'Accept': 'application/json'
}
}
);

if (!response.ok) return null;

const data = await response.json();
return data.attributes?.relationships?.servers?.data || [];
} catch (error) {
console.error(chalk.red('[STATS] Error fetching user servers:'), error.message);
return null;
}
}

async getServerStats(identifier) {
try {
const response = await fetch(
`${this.settings.pterodactyl.domain}/api/client/servers/${identifier}/resources`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.settings.pterodactyl.account_key}`,
'Accept': 'application/json'
}
}
);

if (!response.ok) return null;

const data = await response.json();
return data.attributes || null;
} catch (error) {
return null;
}
}

getServerStatsSnapshot(identifier) {
return this.serverStats.get(identifier);
}

getAllServersSnapshot() {
return Array.from(this.serverStats.values());
}

async getServerHistory(identifier, duration = 3600000) { // 1 hour default
return [this.serverStats.get(identifier)];
}
}

module.exports = StatsService;
Loading