mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 14:51:26 +02:00
Feat: add IP-based rate limiting and house balance thresholds
This commit is contained in:
@@ -139,6 +139,7 @@ const SocketHandlers = {
|
||||
socket.on('payment_confirmed', this.onPaymentConfirmed);
|
||||
socket.on('payment_detected', this.onPaymentDetected);
|
||||
socket.on('show_payment_options', this.onShowPaymentOptions);
|
||||
socket.on('balance_critical', this.onBalanceCritical);
|
||||
socket.on('credits_update', this.onCreditsUpdate);
|
||||
socket.on('user_count', this.onUserCount);
|
||||
|
||||
@@ -729,6 +730,90 @@ const SocketHandlers = {
|
||||
}
|
||||
},
|
||||
|
||||
onBalanceCritical: function(data) {
|
||||
console.warn('Balance critical - games halted:', data);
|
||||
|
||||
const message = data?.message || 'Sorry, the house balance is too low to initiate new games. Please try again later.';
|
||||
|
||||
// Show a modal to the user
|
||||
SocketHandlers._showBalanceCriticalModal(message);
|
||||
|
||||
// Also show in chat
|
||||
$('#messages').append($('<li class="error" style="color: #ff6600; font-weight: bold;">').text('⚠️ ' + message));
|
||||
UI.scrollChat();
|
||||
|
||||
SocketHandlers._setBannerStatus('Unavailable', '#ff6600');
|
||||
},
|
||||
|
||||
_showBalanceCriticalModal: function(message) {
|
||||
// Remove any existing modal
|
||||
$('#balanceCriticalModal').remove();
|
||||
|
||||
const $modal = $(`
|
||||
<div id="balanceCriticalModal" style="
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
">
|
||||
<div style="
|
||||
background: linear-gradient(180deg, #220000, #110000);
|
||||
border: 3px solid #ff3300;
|
||||
border-radius: 10px;
|
||||
padding: 30px 40px;
|
||||
max-width: 450px;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
font-family: monospace;
|
||||
box-shadow: 0 0 30px rgba(255, 51, 0, 0.5);
|
||||
">
|
||||
<div style="font-size: 48px; margin-bottom: 15px;">⚠️</div>
|
||||
<h2 style="color: #ff6600; margin: 0 0 20px 0; font-size: 20px;">Service Temporarily Unavailable</h2>
|
||||
<p style="color: #ffcc99; margin: 0 0 25px 0; line-height: 1.6; font-size: 14px;">
|
||||
${message}
|
||||
</p>
|
||||
<button id="balanceCriticalOK" style="
|
||||
background: linear-gradient(180deg, #663300, #331100);
|
||||
border: 2px solid #ff6600;
|
||||
color: #ffcc00;
|
||||
padding: 12px 30px;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$('body').append($modal);
|
||||
|
||||
// Close handlers
|
||||
$modal.on('click', '#balanceCriticalOK', function() {
|
||||
$modal.fadeOut(200, function() { $modal.remove(); });
|
||||
});
|
||||
|
||||
// Also close on clicking outside
|
||||
$modal.on('click', function(e) {
|
||||
if (e.target === $modal[0]) {
|
||||
$modal.fadeOut(200, function() { $modal.remove(); });
|
||||
}
|
||||
});
|
||||
|
||||
// Close on ESC key
|
||||
$(document).one('keydown.balanceCritical', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
$modal.fadeOut(200, function() { $modal.remove(); });
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
_lastDisplayedConfirmation: null,
|
||||
_confirmationTimestamps: {}, // paymentId -> ts
|
||||
onPaymentConfirmed: function(data) {
|
||||
|
||||
@@ -1612,6 +1612,9 @@ async function startServer() {
|
||||
debugManager
|
||||
});
|
||||
|
||||
// Make alertService available to gameModeManager/paymentHandlers for balance checks
|
||||
gameModeManager.alertService = alertService;
|
||||
|
||||
const payoutRetryService = new PayoutRetryService({
|
||||
db: databaseManager,
|
||||
walletService: walletRPCService,
|
||||
|
||||
@@ -47,6 +47,19 @@ class PaymentHandlers {
|
||||
this.io.to(socket.id).emit('payment_error', { error: 'Payment system not available' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if house balance is critically low before proceeding with payment
|
||||
if (this.gameModeManager.alertService) {
|
||||
const balanceCheck = await this.gameModeManager.alertService.checkBalanceForGameStart();
|
||||
if (balanceCheck.halted) {
|
||||
this.io.to(socket.id).emit('balance_critical', {
|
||||
reason: 'low_balance',
|
||||
message: balanceCheck.reason || 'Sorry, the house balance is too low to initiate new games. Please try again later.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { type, packageId } = data;
|
||||
const paymentType = type || data.gameMode || 'single_game';
|
||||
@@ -104,7 +117,19 @@ class PaymentHandlers {
|
||||
|
||||
async createAndShowPaymentRequest(socket, options = {}) {
|
||||
if (!this.gameModeManager) return;
|
||||
|
||||
|
||||
// Check if house balance is critically low before proceeding with payment
|
||||
if (this.gameModeManager.alertService) {
|
||||
const balanceCheck = await this.gameModeManager.alertService.checkBalanceForGameStart();
|
||||
if (balanceCheck.halted) {
|
||||
this.io.to(socket.id).emit('balance_critical', {
|
||||
reason: 'low_balance',
|
||||
message: balanceCheck.reason || 'Sorry, the house balance is too low to initiate new games. Please try again later.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If both direct and credits modes are enabled, let the user choose
|
||||
const bothModesEnabled = this.gameModeManager.directModeEnabled && this.gameModeManager.creditsModeEnabled;
|
||||
const forceShowOptions = options.showOptions === true;
|
||||
|
||||
@@ -43,13 +43,14 @@ class RateLimiter {
|
||||
|
||||
const now = Date.now();
|
||||
const key = `${userId}:${action}`;
|
||||
|
||||
|
||||
// Check user-based limits
|
||||
const userResult = this._checkSingleLimit(key, limit, now);
|
||||
|
||||
// Check IP-based limits if IP provided and it's a connection-type action
|
||||
|
||||
// Check IP-based limits if IP provided
|
||||
// Apply to: connection actions, payment actions, and game actions
|
||||
let ipResult = { allowed: true, remaining: Infinity, retryAfter: 0 };
|
||||
if (ip && action.startsWith('connection:')) {
|
||||
if (ip && this._shouldApplyIpLimit(action)) {
|
||||
const ipKey = `${ip}:${action}`;
|
||||
ipResult = this._checkSingleLimit(ipKey, limit, now, this.ipStorage);
|
||||
}
|
||||
@@ -68,6 +69,23 @@ class RateLimiter {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an action should have IP-based rate limiting applied
|
||||
* @param {string} action - Action type
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_shouldApplyIpLimit(action) {
|
||||
// Actions that should be rate limited by IP (in addition to socket ID)
|
||||
// This prevents abuse when users reconnect to bypass socket-based limits
|
||||
const ipLimitedPrefixes = [
|
||||
'connection:', // Connection attempts
|
||||
'payment:', // Payment creation/operations
|
||||
'game:', // Game start/queue operations
|
||||
'address:' // Address changes
|
||||
];
|
||||
return ipLimitedPrefixes.some(prefix => action.startsWith(prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an action attempt (increment counter)
|
||||
* @param {string} userId - User identifier
|
||||
@@ -77,12 +95,12 @@ class RateLimiter {
|
||||
async recordAttempt(userId, action, ip = null) {
|
||||
const now = Date.now();
|
||||
const key = `${userId}:${action}`;
|
||||
|
||||
|
||||
// Record user attempt
|
||||
this._recordSingleAttempt(key, now);
|
||||
|
||||
// Record IP attempt for connection actions
|
||||
if (ip && action.startsWith('connection:')) {
|
||||
|
||||
// Record IP attempt for actions that should have IP-based limiting
|
||||
if (ip && this._shouldApplyIpLimit(action)) {
|
||||
const ipKey = `${ip}:${action}`;
|
||||
this._recordSingleAttempt(ipKey, now, this.ipStorage);
|
||||
}
|
||||
|
||||
+114
-14
@@ -19,10 +19,17 @@ class AlertService {
|
||||
this.adminEmail = process.env.ADMIN_EMAIL;
|
||||
this.fromEmail = process.env.ALERT_FROM_EMAIL || 'alerts@monerogue.app';
|
||||
|
||||
// Thresholds
|
||||
this.lowBalanceThreshold = parseInt(process.env.LOW_BALANCE_THRESHOLD) || 100000000000; // 0.1 XMR
|
||||
// Balance thresholds (atomic units)
|
||||
// BALANCE_WARN: sends email alert
|
||||
// BALANCE_CRITICAL: sends email AND halts new games
|
||||
this.balanceWarnThreshold = parseInt(process.env.BALANCE_WARN) || parseInt(process.env.LOW_BALANCE_THRESHOLD) || 100000000000; // 0.1 XMR default
|
||||
this.balanceCriticalThreshold = parseInt(process.env.BALANCE_CRITICAL) || 10000000000; // 0.01 XMR default
|
||||
this.highPendingThreshold = parseInt(process.env.HIGH_PENDING_THRESHOLD) || 10;
|
||||
|
||||
// Track critical balance state for game halt
|
||||
this.isBalanceCritical = false;
|
||||
this.lastBalanceCheck = null;
|
||||
|
||||
// Cooldowns (prevent alert spam)
|
||||
this.alertCooldown = parseInt(process.env.ALERT_COOLDOWN_MS) || 3600000; // 1 hour
|
||||
this.lastAlertSent = new Map();
|
||||
@@ -83,7 +90,9 @@ class AlertService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check wallet balance and alert if low
|
||||
* Check wallet balance and alert if low or critical
|
||||
* BALANCE_WARN: sends email alert
|
||||
* BALANCE_CRITICAL: sends email AND sets isBalanceCritical flag (halts new games)
|
||||
*/
|
||||
async checkWalletBalance() {
|
||||
if (!this.walletService?.isHealthy) {
|
||||
@@ -92,23 +101,37 @@ class AlertService {
|
||||
|
||||
try {
|
||||
const balance = await this.walletService.getBalance();
|
||||
const unlockedBalance = balance.unlocked_balance || 0;
|
||||
this.lastBalanceCheck = Date.now();
|
||||
|
||||
if (balance.unlocked_balance < this.lowBalanceThreshold) {
|
||||
const balanceFormatted = (balance.unlocked_balance / 1e12).toFixed(6);
|
||||
const thresholdFormatted = (this.lowBalanceThreshold / 1e12).toFixed(6);
|
||||
const cryptoType = process.env.CRYPTO_TYPE || 'XMR';
|
||||
const cryptoType = process.env.CRYPTO_TYPE || 'XMR';
|
||||
const decimals = cryptoType === 'WOW' ? 11 : 12;
|
||||
const divisor = Math.pow(10, decimals);
|
||||
|
||||
await this.sendAlert('low_balance', {
|
||||
subject: `⚠️ Wallet Balance Low - ${balanceFormatted} ${cryptoType}`,
|
||||
const balanceFormatted = (unlockedBalance / divisor).toFixed(6);
|
||||
|
||||
// Check CRITICAL threshold first (more severe)
|
||||
if (unlockedBalance < this.balanceCriticalThreshold) {
|
||||
// Set critical flag - this will halt new games
|
||||
if (!this.isBalanceCritical) {
|
||||
this.isBalanceCritical = true;
|
||||
console.warn(`🚨 CRITICAL: Wallet balance is critically low (${balanceFormatted} ${cryptoType}). New games are HALTED.`);
|
||||
}
|
||||
|
||||
const thresholdFormatted = (this.balanceCriticalThreshold / divisor).toFixed(6);
|
||||
|
||||
await this.sendAlert('balance_critical', {
|
||||
subject: `🚨 CRITICAL: Wallet Balance Depleted - ${balanceFormatted} ${cryptoType}`,
|
||||
html: `
|
||||
<h2>Low Wallet Balance Warning</h2>
|
||||
<p>The payout wallet balance is running low:</p>
|
||||
<h2 style="color: #dc3545;">🚨 CRITICAL: Wallet Balance Depleted</h2>
|
||||
<p><strong>NEW GAMES HAVE BEEN HALTED.</strong></p>
|
||||
<p>The payout wallet balance is critically low:</p>
|
||||
<ul>
|
||||
<li><strong>Current Balance:</strong> ${balanceFormatted} ${cryptoType}</li>
|
||||
<li><strong>Unlocked:</strong> ${balanceFormatted} ${cryptoType}</li>
|
||||
<li><strong>Threshold:</strong> ${thresholdFormatted} ${cryptoType}</li>
|
||||
<li><strong>Critical Threshold:</strong> ${thresholdFormatted} ${cryptoType}</li>
|
||||
</ul>
|
||||
<p>Please top up the wallet to ensure payouts can continue.</p>
|
||||
<p style="color: #dc3545;"><strong>Users cannot start new paid games until the wallet is topped up.</strong></p>
|
||||
<p>Please add funds immediately to restore service.</p>
|
||||
<hr>
|
||||
<p style="color: #666; font-size: 12px;">
|
||||
Server: ${process.env.NODE_ENV || 'development'}<br>
|
||||
@@ -117,11 +140,88 @@ class AlertService {
|
||||
`
|
||||
});
|
||||
}
|
||||
// Check WARN threshold (less severe)
|
||||
else if (unlockedBalance < this.balanceWarnThreshold) {
|
||||
// Clear critical flag if we were critical but now just warning
|
||||
if (this.isBalanceCritical) {
|
||||
this.isBalanceCritical = false;
|
||||
console.log(`✅ Wallet balance restored above critical threshold. Games can resume.`);
|
||||
}
|
||||
|
||||
const thresholdFormatted = (this.balanceWarnThreshold / divisor).toFixed(6);
|
||||
|
||||
await this.sendAlert('balance_warn', {
|
||||
subject: `⚠️ Wallet Balance Low - ${balanceFormatted} ${cryptoType}`,
|
||||
html: `
|
||||
<h2>⚠️ Low Wallet Balance Warning</h2>
|
||||
<p>The payout wallet balance is running low:</p>
|
||||
<ul>
|
||||
<li><strong>Current Balance:</strong> ${balanceFormatted} ${cryptoType}</li>
|
||||
<li><strong>Warning Threshold:</strong> ${thresholdFormatted} ${cryptoType}</li>
|
||||
<li><strong>Critical Threshold:</strong> ${(this.balanceCriticalThreshold / divisor).toFixed(6)} ${cryptoType}</li>
|
||||
</ul>
|
||||
<p>Please top up the wallet soon to avoid service interruption.</p>
|
||||
<p>If balance drops below the critical threshold, new games will be automatically halted.</p>
|
||||
<hr>
|
||||
<p style="color: #666; font-size: 12px;">
|
||||
Server: ${process.env.NODE_ENV || 'development'}<br>
|
||||
Time: ${new Date().toISOString()}
|
||||
</p>
|
||||
`
|
||||
});
|
||||
} else {
|
||||
// Balance is healthy - clear critical flag if set
|
||||
if (this.isBalanceCritical) {
|
||||
this.isBalanceCritical = false;
|
||||
console.log(`✅ Wallet balance restored above thresholds. Games can resume.`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check wallet balance for alerts:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if new paid games should be halted due to critical balance
|
||||
* @returns {boolean} true if games should be halted
|
||||
*/
|
||||
shouldHaltGames() {
|
||||
return this.isBalanceCritical;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force refresh the balance check (useful when called before game start)
|
||||
* @returns {Object} { halted: boolean, reason?: string }
|
||||
*/
|
||||
async checkBalanceForGameStart() {
|
||||
if (!this.walletService?.isHealthy) {
|
||||
return { halted: true, reason: 'Wallet service is unavailable. Please try again later.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const balance = await this.walletService.getBalance();
|
||||
const unlockedBalance = balance.unlocked_balance || 0;
|
||||
|
||||
if (unlockedBalance < this.balanceCriticalThreshold) {
|
||||
this.isBalanceCritical = true;
|
||||
const cryptoType = process.env.CRYPTO_TYPE || 'XMR';
|
||||
const decimals = cryptoType === 'WOW' ? 11 : 12;
|
||||
const balanceFormatted = (unlockedBalance / Math.pow(10, decimals)).toFixed(4);
|
||||
return {
|
||||
halted: true,
|
||||
reason: `Sorry, the house balance is too low to initiate new games (${balanceFormatted} ${cryptoType}). Please try again later.`
|
||||
};
|
||||
}
|
||||
|
||||
this.isBalanceCritical = false;
|
||||
return { halted: false };
|
||||
} catch (error) {
|
||||
console.error('Failed to check balance for game start:', error.message);
|
||||
// On error, don't halt games - let them proceed
|
||||
return { halted: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for wallet disconnection
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user