add widgets for my-mc.link servers
This commit is contained in:
249
status-site.js
249
status-site.js
@ -10,8 +10,10 @@ const port = 3066;
|
|||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
// Configure Winston logger
|
// Configure Winston logger
|
||||||
|
const isDebug = process.env.DEBUG === '1';
|
||||||
|
const isInfo = process.env.INFO === '1' || isDebug;
|
||||||
const logger = winston.createLogger({
|
const logger = winston.createLogger({
|
||||||
level: 'debug',
|
level: isDebug ? 'debug' : (isInfo ? 'info' : 'warn'),
|
||||||
format: winston.format.combine(
|
format: winston.format.combine(
|
||||||
winston.format.timestamp(),
|
winston.format.timestamp(),
|
||||||
winston.format.json()
|
winston.format.json()
|
||||||
@ -32,7 +34,7 @@ const getClientIp = (req) => {
|
|||||||
// Rate limiter configuration: 1 request per second with exponential backoff
|
// Rate limiter configuration: 1 request per second with exponential backoff
|
||||||
const limiter = rateLimit({
|
const limiter = rateLimit({
|
||||||
windowMs: 1000, // 1 second
|
windowMs: 1000, // 1 second
|
||||||
max: 1, // 1 request per IP
|
max: 10, // 1 request per IP
|
||||||
standardHeaders: true, // Return rate limit info in headers
|
standardHeaders: true, // Return rate limit info in headers
|
||||||
legacyHeaders: false, // Disable X-RateLimit headers
|
legacyHeaders: false, // Disable X-RateLimit headers
|
||||||
keyGenerator: getClientIp, // Use CF-Connecting-IP or fallback to req.ip
|
keyGenerator: getClientIp, // Use CF-Connecting-IP or fallback to req.ip
|
||||||
@ -51,13 +53,14 @@ const limiter = rateLimit({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply rate limiting to API routes only
|
// Apply rate limiting to API routes and widget
|
||||||
app.use('/java', limiter);
|
app.use('/java', limiter);
|
||||||
app.use('/bedrock', limiter);
|
app.use('/bedrock', limiter);
|
||||||
|
app.use('/widget', limiter);
|
||||||
|
|
||||||
// Input validation functions
|
// Input validation functions
|
||||||
function validateBinaryPath(binaryPath) {
|
function validateBinaryPath(binaryPath) {
|
||||||
logger.debug('Validating binary path', { binaryPath });
|
if (isDebug) logger.debug('Validating binary path', { binaryPath });
|
||||||
if (!binaryPath) {
|
if (!binaryPath) {
|
||||||
logger.error('Binary path not configured');
|
logger.error('Binary path not configured');
|
||||||
throw new Error('Binary path not configured');
|
throw new Error('Binary path not configured');
|
||||||
@ -66,7 +69,7 @@ function validateBinaryPath(binaryPath) {
|
|||||||
const allowedPrefixes = [
|
const allowedPrefixes = [
|
||||||
path.resolve('/home/go/status')
|
path.resolve('/home/go/status')
|
||||||
];
|
];
|
||||||
logger.debug('Checking binary path against allowed prefixes', { resolvedPath, allowedPrefixes });
|
if (isDebug) logger.debug('Checking binary path against allowed prefixes', { resolvedPath, allowedPrefixes });
|
||||||
|
|
||||||
// Check if path exists
|
// Check if path exists
|
||||||
try {
|
try {
|
||||||
@ -87,12 +90,12 @@ function validateBinaryPath(binaryPath) {
|
|||||||
logger.error('Invalid characters in binary path', { binaryPath });
|
logger.error('Invalid characters in binary path', { binaryPath });
|
||||||
throw new Error('Invalid characters in binary path');
|
throw new Error('Invalid characters in binary path');
|
||||||
}
|
}
|
||||||
logger.info('Binary path validated', { resolvedPath });
|
if (isInfo) logger.info('Binary path validated', { resolvedPath });
|
||||||
return resolvedPath;
|
return resolvedPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateHostname(hostname) {
|
function validateHostname(hostname) {
|
||||||
logger.debug('Validating hostname', { 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})$/;
|
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)) {
|
if (!hostnameRegex.test(hostname)) {
|
||||||
logger.error('Invalid hostname format', { hostname });
|
logger.error('Invalid hostname format', { hostname });
|
||||||
@ -102,12 +105,12 @@ function validateHostname(hostname) {
|
|||||||
logger.error('Invalid characters in hostname', { hostname });
|
logger.error('Invalid characters in hostname', { hostname });
|
||||||
throw new Error('Invalid characters in hostname');
|
throw new Error('Invalid characters in hostname');
|
||||||
}
|
}
|
||||||
logger.info('Hostname validated', { hostname });
|
if (isInfo) logger.info('Hostname validated', { hostname });
|
||||||
return hostname;
|
return hostname;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validatePort(port) {
|
function validatePort(port) {
|
||||||
logger.debug('Validating port', { port });
|
if (isDebug) logger.debug('Validating port', { port });
|
||||||
const portNum = parseInt(port, 10);
|
const portNum = parseInt(port, 10);
|
||||||
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
|
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
|
||||||
logger.error('Invalid port number', { port });
|
logger.error('Invalid port number', { port });
|
||||||
@ -117,20 +120,30 @@ function validatePort(port) {
|
|||||||
logger.error('Invalid characters in port', { port });
|
logger.error('Invalid characters in port', { port });
|
||||||
throw new Error('Invalid characters in port');
|
throw new Error('Invalid characters in port');
|
||||||
}
|
}
|
||||||
logger.info('Port validated', { port });
|
if (isInfo) logger.info('Port validated', { port });
|
||||||
return portNum.toString();
|
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) {
|
async function checkConnectionStatus(hostname, port) {
|
||||||
const requestId = Math.random().toString(36).substring(2);
|
const requestId = Math.random().toString(36).substring(2);
|
||||||
logger.info('Starting Java server status check', { requestId, hostname, port });
|
if (isInfo) logger.info('Starting Java server status check', { requestId, hostname, port });
|
||||||
try {
|
try {
|
||||||
const validatedBinary = validateBinaryPath(process.env.STATUS_CHECK_PATH);
|
const validatedBinary = validateBinaryPath(process.env.STATUS_CHECK_PATH);
|
||||||
const validatedHostname = validateHostname(hostname);
|
const validatedHostname = validateHostname(hostname);
|
||||||
const validatedPort = validatePort(port);
|
const validatedPort = validatePort(port);
|
||||||
|
|
||||||
const command = `${validatedBinary} -host ${validatedHostname} -port ${validatedPort}`;
|
const command = `${validatedBinary} -host ${validatedHostname} -port ${validatedPort}`;
|
||||||
logger.debug('Executing command', { requestId, command });
|
if (isDebug) logger.debug('Executing command', { requestId, command });
|
||||||
|
|
||||||
const { stdout, stderr } = await execPromise(command);
|
const { stdout, stderr } = await execPromise(command);
|
||||||
if (stderr) {
|
if (stderr) {
|
||||||
@ -138,7 +151,7 @@ async function checkConnectionStatus(hostname, port) {
|
|||||||
return { isOnline: false, error: stderr };
|
return { isOnline: false, error: stderr };
|
||||||
}
|
}
|
||||||
const data = JSON.parse(stdout);
|
const data = JSON.parse(stdout);
|
||||||
logger.info('Java server status check completed', { requestId, isOnline: true });
|
if (isInfo) logger.info('Java server status check completed', { requestId, isOnline: true });
|
||||||
return { isOnline: true, data };
|
return { isOnline: true, data };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Java server status check failed', { requestId, error: error.message });
|
logger.error('Java server status check failed', { requestId, error: error.message });
|
||||||
@ -148,14 +161,14 @@ async function checkConnectionStatus(hostname, port) {
|
|||||||
|
|
||||||
async function checkGeyserStatus(hostname, port) {
|
async function checkGeyserStatus(hostname, port) {
|
||||||
const requestId = Math.random().toString(36).substring(2);
|
const requestId = Math.random().toString(36).substring(2);
|
||||||
logger.info('Starting Bedrock server status check', { requestId, hostname, port });
|
if (isInfo) logger.info('Starting Bedrock server status check', { requestId, hostname, port });
|
||||||
try {
|
try {
|
||||||
const validatedBinary = validateBinaryPath(process.env.GEYSER_STATUS_CHECK_PATH);
|
const validatedBinary = validateBinaryPath(process.env.GEYSER_STATUS_CHECK_PATH);
|
||||||
const validatedHostname = validateHostname(hostname);
|
const validatedHostname = validateHostname(hostname);
|
||||||
const validatedPort = validatePort(port);
|
const validatedPort = validatePort(port);
|
||||||
|
|
||||||
const command = `${validatedBinary} -host ${validatedHostname} -port ${validatedPort}`;
|
const command = `${validatedBinary} -host ${validatedHostname} -port ${validatedPort}`;
|
||||||
logger.debug('Executing command', { requestId, command });
|
if (isDebug) logger.debug('Executing command', { requestId, command });
|
||||||
|
|
||||||
const { stdout, stderr } = await execPromise(command);
|
const { stdout, stderr } = await execPromise(command);
|
||||||
if (stderr) {
|
if (stderr) {
|
||||||
@ -163,7 +176,7 @@ async function checkGeyserStatus(hostname, port) {
|
|||||||
return { isOnline: false, error: stderr };
|
return { isOnline: false, error: stderr };
|
||||||
}
|
}
|
||||||
const data = JSON.parse(stdout);
|
const data = JSON.parse(stdout);
|
||||||
logger.info('Bedrock server status check completed', { requestId, isOnline: true });
|
if (isInfo) logger.info('Bedrock server status check completed', { requestId, isOnline: true });
|
||||||
return { isOnline: true, data };
|
return { isOnline: true, data };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Bedrock server status check failed', { requestId, error: error.message });
|
logger.error('Bedrock server status check failed', { requestId, error: error.message });
|
||||||
@ -171,16 +184,181 @@ async function checkGeyserStatus(hostname, port) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.use(express.static('public'));
|
||||||
|
|
||||||
app.get('/', (req, res) => {
|
app.get('/', (req, res) => {
|
||||||
logger.info('Serving index page', { ip: getClientIp(req) });
|
if (isInfo) logger.info('Serving index page', { ip: getClientIp(req) });
|
||||||
res.sendFile(__dirname + '/public/index.html');
|
res.sendFile(__dirname + '/public/index.html');
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/java/:host/:port', async (req, res) => {
|
app.get('/java/:host/:port', async (req, res) => {
|
||||||
const { host, port } = req.params;
|
const { host, port } = req.params;
|
||||||
logger.info('Received Java server check request', {
|
if (isInfo) logger.info('Received Java server check request', {
|
||||||
ip: getClientIp(req),
|
ip: getClientIp(req),
|
||||||
host,
|
host,
|
||||||
port
|
port
|
||||||
@ -199,14 +377,14 @@ app.get('/java/:host/:port', async (req, res) => {
|
|||||||
|
|
||||||
app.get('/bedrock/:host/:port', async (req, res) => {
|
app.get('/bedrock/:host/:port', async (req, res) => {
|
||||||
const { host, port } = req.params;
|
const { host, port } = req.params;
|
||||||
logger.info('Received Bedrock server check request', {
|
if (isInfo) logger.info('Received Bedrock server check request', {
|
||||||
ip: getClientIp(req),
|
ip: getClientIp(req),
|
||||||
host,
|
host,
|
||||||
port
|
port
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const result = await checkGeyserStatus(host, port);
|
const result = await checkGeyserStatus(host, port);
|
||||||
logger.debug('Bedrock check result', { result });
|
if (isDebug) logger.debug('Bedrock check result', { result });
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Bedrock server check request failed', {
|
logger.error('Bedrock server check request failed', {
|
||||||
@ -217,6 +395,33 @@ app.get('/bedrock/:host/:port', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(port, () => {
|
app.get('/widget/:edition/:host/:port', (req, res) => {
|
||||||
logger.info(`Server started on port ${port}`);
|
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}`);
|
||||||
});
|
});
|
Reference in New Issue
Block a user