Files
status-check/status-site.js
2025-07-03 02:28:06 -04:00

427 lines
16 KiB
JavaScript

const express = require('express');
const { promisify } = require('util');
const { exec } = require('child_process');
const rateLimit = require('express-rate-limit');
const path = require('path');
const fs = require('fs');
const winston = require('winston');
const app = express();
const port = 3066;
require('dotenv').config();
// Configure Winston logger
const isDebug = process.env.DEBUG === '1';
const isInfo = process.env.INFO === '1' || isDebug;
const logger = winston.createLogger({
level: isDebug ? 'debug' : (isInfo ? 'info' : 'warn'),
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.File({ filename: 'server.log' }),
new winston.transports.Console()
]
});
const execPromise = promisify(exec);
// Custom key generator to support Cloudflare's CF-Connecting-IP header
const getClientIp = (req) => {
return req.headers['cf-connecting-ip'] || req.ip;
};
// Rate limiter configuration: 1 request per second with exponential backoff
const limiter = rateLimit({
windowMs: 1000, // 1 second
max: 10, // 1 request per IP
standardHeaders: true, // Return rate limit info in headers
legacyHeaders: false, // Disable X-RateLimit headers
keyGenerator: getClientIp, // Use CF-Connecting-IP or fallback to req.ip
handler: (req, res) => {
const retryAfter = Math.pow(2, Math.min(req.rateLimit.current - req.rateLimit.limit));
logger.warn('Rate limit exceeded', {
ip: getClientIp(req),
url: req.url,
retryAfter
});
res.setHeader('Retry-After', retryAfter);
res.status(429).json({
error: 'Too many requests, please try again later.',
retryAfter: retryAfter
});
}
});
// Apply rate limiting to API routes and widget
app.use('/java', limiter);
app.use('/bedrock', limiter);
app.use('/widget', limiter);
// Input validation functions
function validateBinaryPath(binaryPath) {
if (isDebug) logger.debug('Validating binary path', { binaryPath });
if (!binaryPath) {
logger.error('Binary path not configured');
throw new Error('Binary path not configured');
}
const resolvedPath = path.resolve(binaryPath);
const allowedPrefixes = [
path.resolve('/home/go/status')
];
if (isDebug) logger.debug('Checking binary path against allowed prefixes', { resolvedPath, allowedPrefixes });
// Check if path exists
try {
fs.accessSync(resolvedPath, fs.constants.X_OK);
} catch (err) {
logger.error('Binary path does not exist or is not executable', { resolvedPath, error: err.message });
throw new Error('Binary path does not exist or is not executable');
}
// Check if path starts with an allowed prefix
if (!allowedPrefixes.some(prefix => resolvedPath.startsWith(prefix))) {
logger.error('Invalid binary path location', { resolvedPath, allowedPrefixes });
throw new Error('Invalid binary path');
}
// Check for shell metacharacters
if (/[|;&$><`]/.test(binaryPath)) {
logger.error('Invalid characters in binary path', { binaryPath });
throw new Error('Invalid characters in binary path');
}
if (isInfo) logger.info('Binary path validated', { resolvedPath });
return resolvedPath;
}
function validateHostname(hostname) {
if (isDebug) logger.debug('Validating hostname', { hostname });
const hostnameRegex = /^(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}|(?:\d{1,3}\.){3}\d{1,3})$/;
if (!hostnameRegex.test(hostname)) {
logger.error('Invalid hostname format', { hostname });
throw new Error('Invalid hostname format');
}
if (/[|;&$><`]/.test(hostname)) {
logger.error('Invalid characters in hostname', { hostname });
throw new Error('Invalid characters in hostname');
}
if (isInfo) logger.info('Hostname validated', { hostname });
return hostname;
}
function validatePort(port) {
if (isDebug) logger.debug('Validating port', { port });
const portNum = parseInt(port, 10);
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
logger.error('Invalid port number', { port });
throw new Error('Port must be a number between 1 and 65535');
}
if (/[^0-9]/.test(port.toString())) {
logger.error('Invalid characters in port', { port });
throw new Error('Invalid characters in port');
}
if (isInfo) logger.info('Port validated', { port });
return portNum.toString();
}
function validateEdition(edition) {
if (isDebug) logger.debug('Validating edition', { edition });
if (!['java', 'bedrock'].includes(edition)) {
logger.error('Invalid edition', { edition });
throw new Error('Edition must be "java" or "bedrock"');
}
if (isInfo) logger.info('Edition validated', { edition });
return edition;
}
async function checkConnectionStatus(hostname, port) {
const requestId = Math.random().toString(36).substring(2);
if (isInfo) logger.info('Starting Java server status check', { requestId, hostname, port });
try {
const validatedBinary = validateBinaryPath(process.env.STATUS_CHECK_PATH);
const validatedHostname = validateHostname(hostname);
const validatedPort = validatePort(port);
const command = `${validatedBinary} -host ${validatedHostname} -port ${validatedPort}`;
if (isDebug) logger.debug('Executing command', { requestId, command });
const { stdout, stderr } = await execPromise(command);
if (stderr) {
logger.error('Command execution error', { requestId, error: stderr });
return { isOnline: false, error: stderr };
}
const data = JSON.parse(stdout);
if (isInfo) logger.info('Java server status check completed', { requestId, isOnline: true });
return { isOnline: true, data };
} catch (error) {
logger.error('Java server status check failed', { requestId, error: error.message });
return { isOnline: false, error: error.message };
}
}
async function checkGeyserStatus(hostname, port) {
const requestId = Math.random().toString(36).substring(2);
if (isInfo) logger.info('Starting Bedrock server status check', { requestId, hostname, port });
try {
const validatedBinary = validateBinaryPath(process.env.GEYSER_STATUS_CHECK_PATH);
const validatedHostname = validateHostname(hostname);
const validatedPort = validatePort(port);
const command = `${validatedBinary} -host ${validatedHostname} -port ${validatedPort}`;
if (isDebug) logger.debug('Executing command', { requestId, command });
const { stdout, stderr } = await execPromise(command);
if (stderr) {
logger.error('Command execution error', { requestId, error: stderr });
return { isOnline: false, error: stderr };
}
const data = JSON.parse(stdout);
if (isInfo) logger.info('Bedrock server status check completed', { requestId, isOnline: true });
return { isOnline: true, data };
} catch (error) {
logger.error('Bedrock server status check failed', { requestId, error: error.message });
return { isOnline: false, error: error.message };
}
}
// Widget template
const widgetTemplate = (edition, host, port) => `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Server Status Widget</title>
<style>
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
background: transparent;
color: #ffffff;
font-size: 14px;
line-height: 1.5;
}
.widget-container {
background: rgba(31, 41, 55, 0.8);
backdrop-filter: blur(8px);
border-radius: 12px;
padding: 16px;
width: 250px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.status-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
background: #6b7280;
}
.online { background: #10b981; }
.offline { background: #ef4444; }
.error { background: #f59e0b; }
h2 {
font-size: 16px;
font-weight: 600;
margin: 0;
color: #60a5fa;
}
p {
margin: 4px 0;
font-size: 13px;
display: none;
}
.label {
color: #93c5fd;
font-weight: 500;
}
.loading-spinner {
border: 3px solid rgba(255, 255, 255, 0.2);
border-top: 3px solid #3b82f6;
border-radius: 50%;
width: 16px;
height: 16px;
animation: spin 1s linear infinite;
margin: 10px auto;
display: none;
}
.visible { display: block; }
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="widget-container">
<div class="status-header">
<div class="status-indicator"></div>
<h2>${host}:${port}</h2>
</div>
<div id="status-content">
<div id="loading-spinner" class="loading-spinner"></div>
<p id="version"><span class="label">Version:</span> <span></span></p>
<p id="players"><span class="label">Players:</span> <span></span></p>
<p id="motd"><span class="label">MOTD:</span> <span></span></p>
<p id="status"><span class="label">Status:</span> <span></span></p>
</div>
</div>
<script>
let isFirstLoad = true;
async function updateStatus() {
const statusIndicator = document.querySelector('.status-indicator');
const loadingSpinner = document.getElementById('loading-spinner');
const versionEl = document.getElementById('version');
const playersEl = document.getElementById('players');
const motdEl = document.getElementById('motd');
const statusEl = document.getElementById('status');
// Show loading state only on first load
if (isFirstLoad) {
loadingSpinner.classList.add('visible');
versionEl.classList.remove('visible');
playersEl.classList.remove('visible');
motdEl.classList.remove('visible');
statusEl.classList.remove('visible');
}
try {
const response = await fetch('/${edition}/${host}/${port}');
if (!response.ok) {
if (response.status === 429) {
throw new Error('RateLimit');
}
throw new Error('Request failed');
}
const result = await response.json();
// Hide loading spinner
loadingSpinner.classList.remove('visible');
if (result.isOnline) {
statusIndicator.classList.remove('offline', 'error');
statusIndicator.classList.add('online');
const data = result.data;
if ('${edition}' === 'java') {
versionEl.querySelector('span:nth-child(2)').textContent = data.version?.name?.clean || 'Unknown';
playersEl.querySelector('span:nth-child(2)').textContent = data.players?.online != null ? \`\${data.players.online}/\${data.players.max}\` : 'Unknown';
motdEl.querySelector('span:nth-child(2)').textContent = data.motd?.clean || 'None';
} else {
versionEl.querySelector('span:nth-child(2)').textContent = data.version || 'Unknown';
playersEl.querySelector('span:nth-child(2)').textContent = data.online_players != null ? \`\${data.online_players}/\${data.max_players}\` : 'Unknown';
motdEl.querySelector('span:nth-child(2)').textContent = data.motd?.clean || 'None';
}
versionEl.classList.add('visible');
playersEl.classList.add('visible');
motdEl.classList.add('visible');
} else {
statusIndicator.classList.remove('online', 'error');
statusIndicator.classList.add('offline');
statusEl.querySelector('span:nth-child(2)').textContent = 'Offline';
statusEl.classList.add('visible');
}
} catch (error) {
loadingSpinner.classList.remove('visible');
statusIndicator.classList.remove('online', 'offline');
statusIndicator.classList.add('error');
statusEl.querySelector('span:nth-child(2)').textContent = error.message === 'RateLimit' ? 'Rate Limited' : 'Error';
statusEl.classList.add('visible');
}
// Update first load flag
isFirstLoad = false;
}
// Initial update
updateStatus();
// Update every 30 seconds
setInterval(updateStatus, 30000);
</script>
</body>
</html>
`;
app.use(express.static('public'));
app.get('/', (req, res) => {
if (isInfo) logger.info('Serving index page', { ip: getClientIp(req) });
res.sendFile(__dirname + '/public/index.html');
});
app.get('/java/:host/:port', async (req, res) => {
const { host, port } = req.params;
if (isInfo) logger.info('Received Java server check request', {
ip: getClientIp(req),
host,
port
});
try {
const result = await checkConnectionStatus(host, port);
res.json(result);
} catch (error) {
logger.error('Java server check request failed', {
ip: getClientIp(req),
error: error.message
});
res.status(500).json({ isOnline: false, error: `Server error: ${error.message}` });
}
});
app.get('/bedrock/:host/:port', async (req, res) => {
const { host, port } = req.params;
if (isInfo) logger.info('Received Bedrock server check request', {
ip: getClientIp(req),
host,
port
});
try {
const result = await checkGeyserStatus(host, port);
if (isDebug) logger.debug('Bedrock check result', { result });
res.json(result);
} catch (error) {
logger.error('Bedrock server check request failed', {
ip: getClientIp(req),
error: error.message
});
res.status(500).json({ isOnline: false, error: `Server error: ${error.message}` });
}
});
app.get('/widget/:edition/:host/:port', (req, res) => {
const { edition, host, port } = req.params;
if (isInfo) logger.info('Received widget request', {
ip: getClientIp(req),
edition,
host,
port
});
try {
validateEdition(edition);
validateHostname(host);
if (host !== 'my-mc.link') {
logger.error('Unauthorized hostname', { host });
throw new Error('Hostname must be my-mc.link');
}
validatePort(port);
res.setHeader('Content-Type', 'text/html');
res.send(widgetTemplate(edition, host, port));
} catch (error) {
logger.error('Widget request failed', {
ip: getClientIp(req),
error: error.message
});
res.status(400).json({ error: `Invalid parameters: ${error.message}` });
}
});
app.listen(port, () => {
console.log(`Server started on port ${port}`);
});