commit 1dee538b99f389aaeebeb6b2395a4a9823312fbd Author: MCHost Date: Wed Jul 23 02:52:20 2025 -0400 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..59e32fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +*.json \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b1cf4a5 --- /dev/null +++ b/README.md @@ -0,0 +1,110 @@ +# MyMC Premium Store + +## Overview +MyMC Premium Store is a Discord bot that manages Docker containers for Minecraft servers, automatically adjusting container resources (CPU, memory, and swap) based on users' Discord roles. The bot checks for role changes every 5 minutes and updates container configurations accordingly, supporting default, upgraded, and super-upgraded tiers. + +NOTE: This repo is for educational purposes, and the code is written specifically for our system, however, you may fork it and use it for any need you may have. + +## Features +- **Role-Based Resource Allocation**: Adjusts Docker container resources (CPUs, memory, swap) based on Discord roles (`standard`, `manualUpgrade`, `superUpgrade`). +- **Periodic Checks**: Runs every 5 minutes via a cron job to ensure container settings match user roles. +- **Container Configuration Updates**: Updates PM2 process configurations within containers by copying appropriate `startServer.json` files (`startServer_downgrade.json`, `startServer_upgrade.json`, or `startServer_superUpgrade.json`). +- **Safety Mechanisms**: + - Handles unknown container limits by optionally resetting to default settings. + - Implements command execution timeouts to prevent hanging operations. +- **Logging**: Detailed console logs for container status, role checks, and configuration updates. + +## Prerequisites +- **Node.js**: Version 16 or higher. +- **Docker**: Installed and running, with the bot having access to the Docker socket. +- **Discord Bot Token**: A valid Discord bot token with `Guilds` and `GuildMembers` intents. +- **Environment Variables**: Configured via a `.env` file (see [Configuration](#configuration)). +- **PM2**: Used inside containers for process management. +- **Configuration Files**: `startServer_downgrade.json`, `startServer_upgrade.json`, and `startServer_superUpgrade.json` must exist in the same directory as the bot script. + +## Installation +1. Clone the repository: + ```bash + git clone git@git.ssh.surf:hypermc/mymc-premium.git + cd mymc-premium + ``` +2. Install dependencies: + ```bash + npm install discord.js dockerode cron dotenv + ``` +3. Create a `.env` file in the project root (see [Configuration](#configuration)). +4. Ensure Docker is running and the bot has access to the Docker socket (`/var/run/docker.sock`). +5. Place the required `startServer_*.json` files in the project directory. +6. Start the bot: + ```bash + node index.js + ``` + +## Configuration +Create a `.env` file in the project root with the following variables: + +```env +DISCORD_TOKEN=your_discord_bot_token +GUILD_ID=your_discord_guild_id +ROLE_ID_STANDARD=standard_role_id +ROLE_ID_MANUAL_UPGRADE=manual_upgrade_role_id +ROLE_ID_SUPER_UPGRADE=super_upgrade_role_id +DEFAULT_CPUS=1 +DEFAULT_MEMORY=512 +DEFAULT_SWAP=1024 +UPGRADED_CPUS=2 +UPGRADED_MEMORY=1024 +UPGRADED_SWAP=2048 +SUPER_UPGRADED_CPUS=4 +SUPER_UPGRADED_MEMORY=2048 +SUPER_UPGRADED_SWAP=4096 +RESET_UNKNOWN_TO_DEFAULT=true +EXEC_TIMEOUT=10000 +``` + +- **DISCORD_TOKEN**: Discord bot token. +- **GUILD_ID**: ID of the Discord server (guild) to monitor. +- **ROLE_ID_***: Discord role IDs for each tier. +- **DEFAULT_***: Resource limits for default tier (CPUs, memory in MiB, swap in MiB). +- **UPGRADED_***: Resource limits for upgraded tier. +- **SUPER_UPGRADED_***: Resource limits for super-upgraded tier. +- **RESET_UNKNOWN_TO_DEFAULT**: If `true`, resets containers with unknown limits to default settings. +- **EXEC_TIMEOUT**: Timeout (in milliseconds) for Docker exec commands. + +## Usage +- The bot logs in to Discord and performs an initial container check on startup. +- Every 5 minutes, it checks all running containers with names starting with `mc_` (e.g., `mc_1234567890`, where `1234567890` is the Discord user ID). +- For each container: + - Verifies current resource limits (CPUs, memory, swap). + - Checks the user's Discord roles in the specified guild. + - Updates container resources and configuration files if the role-based tier doesn't match the current settings: + - **Super Upgrade**: Applies if the user has the `superUpgrade` role. + - **Standard/Manual Upgrade**: Applies if the user has `standard` or `manualUpgrade` roles. + - **Default**: Applies if the user has no relevant roles. + - Copies the appropriate `startServer_*.json` file to the container and restarts the PM2 process. +- Logs all actions, including errors, to the console. + +## Container Naming +Containers must be named with the prefix `mc_` followed by the Discord user ID (e.g., `mc_1234567890`). + +## Configuration Files +The bot uses the following JSON files to configure PM2 processes inside containers: +- `startServer_downgrade.json`: For default tier. +- `startServer_upgrade.json`: For standard/manual upgrade tier. +- `startServer_superUpgrade.json`: For super-upgraded tier. + +These files must be present in the bot's working directory and contain valid PM2 configuration for the Minecraft server. + +## Error Handling +- **Docker Errors**: Logs errors during container listing, inspection, or updates. +- **Discord Errors**: Logs errors when fetching guild or member data. +- **Timeout Errors**: Commands exceeding `EXEC_TIMEOUT` are terminated and logged. +- **File Copy Errors**: Logs failures when copying configuration files. +- **PM2 Errors**: Logs errors during PM2 process deletion or startup, but attempts to continue operations where possible. + +## Logging +The bot provides detailed console logs for: +- Container details (name, user ID, current resources). +- Role check results. +- Actions taken (upgrade, downgrade, reset, or no action). +- Errors during any operation. \ No newline at end of file diff --git a/mymc-premium.js b/mymc-premium.js new file mode 100644 index 0000000..33fc990 --- /dev/null +++ b/mymc-premium.js @@ -0,0 +1,264 @@ +const Discord = require('discord.js'); +const Docker = require('dockerode'); +const { CronJob } = require('cron'); +const { execSync } = require('child_process'); +require('dotenv').config(); + +const client = new Discord.Client({ + intents: [ + Discord.GatewayIntentBits.Guilds, + Discord.GatewayIntentBits.GuildMembers + ] +}); + +const docker = new Docker(); // Assumes local Docker socket; adjust if remote + +const DISCORD_TOKEN = process.env.DISCORD_TOKEN; +const GUILD_ID = process.env.GUILD_ID; +const ROLE_IDS = { + standard: process.env.ROLE_ID_STANDARD, + manualUpgrade: process.env.ROLE_ID_MANUAL_UPGRADE, + superUpgrade: process.env.ROLE_ID_SUPER_UPGRADE +}; + +const DEFAULT_CPUS = parseInt(process.env.DEFAULT_CPUS); +const DEFAULT_MEMORY = parseInt(process.env.DEFAULT_MEMORY) * 1024 * 1024; // Convert MiB to bytes +const DEFAULT_SWAP = parseInt(process.env.DEFAULT_SWAP) * 1024 * 1024; // Convert MiB to bytes + +const UPGRADED_CPUS = parseInt(process.env.UPGRADED_CPUS); +const UPGRADED_MEMORY = parseInt(process.env.UPGRADED_MEMORY) * 1024 * 1024; // Convert MiB to bytes +const UPGRADED_SWAP = parseInt(process.env.UPGRADED_SWAP) * 1024 * 1024; // Convert MiB to bytes + +const SUPER_UPGRADED_CPUS = parseInt(process.env.SUPER_UPGRADED_CPUS); +const SUPER_UPGRADED_MEMORY = parseInt(process.env.SUPER_UPGRADED_MEMORY) * 1024 * 1024; // Convert MiB to bytes +const SUPER_UPGRADED_SWAP = parseInt(process.env.SUPER_UPGRADED_SWAP) * 1024 * 1024; // Convert MiB to bytes + +const RESET_UNKNOWN_TO_DEFAULT = process.env.RESET_UNKNOWN_TO_DEFAULT === 'true'; +const EXEC_TIMEOUT = parseInt(process.env.EXEC_TIMEOUT); + +async function execWithTimeout(container, cmd, timeout = EXEC_TIMEOUT) { + try { + const exec = await container.exec({ + Cmd: cmd, + AttachStdout: true, + AttachStderr: true + }); + + const stream = await exec.start(); + let output = ''; + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Command ${cmd.join(' ')} timed out after ${timeout}ms`)), timeout); + }); + + await Promise.race([ + new Promise((resolve, reject) => { + stream.on('data', (chunk) => output += chunk.toString()); + stream.on('end', () => resolve(output)); + stream.on('error', reject); + }), + timeoutPromise + ]); + + return output; + } catch (err) { + throw err; + } +} + +async function updateContainerConfig(container, name, userId, memoryLimit) { + try { + let sourceFile; + if (memoryLimit === SUPER_UPGRADED_MEMORY) { + sourceFile = 'startServer_superUpgrade.json'; + } else if (memoryLimit === UPGRADED_MEMORY) { + sourceFile = 'startServer_upgrade.json'; + } else { + sourceFile = 'startServer_downgrade.json'; + } + console.log(` šŸ”„ Updating container ${name} with ${sourceFile}...`); + + // Check if startServer.json exists + console.log(` šŸ” Checking if startServer.json exists in container ${name}...`); + try { + await execWithTimeout(container, ['test', '-f', '/var/tools/pm2/startServer.json']); + console.log(` āœ… startServer.json exists`); + + // Remove existing startServer.json + console.log(` šŸ—‘ļø Removing existing startServer.json in container ${name}...`); + await execWithTimeout(container, ['rm', '-f', '/var/tools/pm2/startServer.json']); + console.log(` āœ… Removed startServer.json`); + } catch (err) { + console.log(` āš ļø startServer.json does not exist or cannot be removed: ${err.message}`); + } + + // Copy new startServer.json file + console.log(` šŸ“¤ Copying ${sourceFile} to container ${name}...`); + try { + execSync(`docker cp ${sourceFile} ${name}:/var/tools/pm2/startServer.json`, { stdio: 'inherit', timeout: EXEC_TIMEOUT }); + console.log(` āœ… Copied ${sourceFile} to /var/tools/pm2/startServer.json`); + } catch (cpErr) { + console.error(` āŒ Error copying ${sourceFile} to container ${name}: ${cpErr.message}`); + return; + } + + // Delete existing PM2 process + console.log(` šŸ—‘ļø Deleting PM2 process in container ${name}...`); + try { + await execWithTimeout(container, ['su', '-', 'mc', '-c', 'cd /var/tools/pm2 && pm2 delete 0']); + console.log(` āœ… PM2 process deleted`); + } catch (err) { + console.error(` āš ļø Error deleting PM2 process in container ${name}: ${err.message}`); + // Continue to attempt starting the process + } + + // Start new PM2 process + console.log(` ā–¶ļø Starting PM2 process in container ${name}...`); + try { + await execWithTimeout(container, ['su', '-', 'mc', '-c', 'cd /var/tools/pm2 && pm2 start startServer.json']); + console.log(` āœ… PM2 process started with startServer.json`); + } catch (err) { + console.error(` āŒ Error starting PM2 process in container ${name}: ${err.message}`); + return; + } + } catch (err) { + console.error(` āŒ Error updating container ${name}: ${err.message}`); + } +} + +async function checkContainers() { + console.log('\n=== Starting Container Check ===\n'); + + try { + // List running containers + const containers = await docker.listContainers({ all: false }); + + for (const contInfo of containers) { + // Container names start with '/', e.g., '/mc_1234567890' + const name = contInfo.Names[0].slice(1); + if (name.startsWith('mc_')) { + const userId = name.slice(3); // Extract Discord ID + const container = docker.getContainer(contInfo.Id); + const inspect = await container.inspect(); + + const currentCpus = inspect.HostConfig.NanoCpus / 1e9; + const currentMem = inspect.HostConfig.Memory; + const currentSwap = inspect.HostConfig.MemorySwap; + + // Log container details in a structured format + console.log(`šŸ“¦ Container: ${name}`); + console.log(` User ID: ${userId}`); + console.log(` Current Settings:`); + console.log(` CPUs: ${currentCpus}`); + console.log(` Memory: ${currentMem / 1024 / 1024} MiB`); + console.log(` Swap: ${currentSwap / 1024 / 1024} MiB`); + + const isDefault = + currentCpus === DEFAULT_CPUS && + currentMem === DEFAULT_MEMORY && + currentSwap === DEFAULT_SWAP; + + const isUpgraded = + currentCpus === UPGRADED_CPUS && + currentMem === UPGRADED_MEMORY && + currentSwap === UPGRADED_SWAP; + + const isSuperUpgraded = + currentCpus === SUPER_UPGRADED_CPUS && + currentMem === SUPER_UPGRADED_MEMORY && + currentSwap === SUPER_UPGRADED_SWAP; + + // Handle unknown limits + if (!isDefault && !isUpgraded && !isSuperUpgraded) { + console.log(` āš ļø Warning: Unknown limits detected!`); + console.log(` Expected Default: CPUs=${DEFAULT_CPUS}, Memory=${DEFAULT_MEMORY / 1024 / 1024} MiB, Swap=${DEFAULT_SWAP / 1024 / 1024} MiB`); + console.log(` Expected Upgraded: CPUs=${UPGRADED_CPUS}, Memory=${UPGRADED_MEMORY / 1024 / 1024} MiB, Swap=${UPGRADED_SWAP / 1024 / 1024} MiB`); + console.log(` Expected Super Upgraded: CPUs=${SUPER_UPGRADED_CPUS}, Memory=${SUPER_UPGRADED_MEMORY / 1024 / 1024} MiB, Swap=${SUPER_UPGRADED_SWAP / 1024 / 1024} MiB`); + if (RESET_UNKNOWN_TO_DEFAULT) { + console.log(` šŸ”„ Resetting to default settings...`); + await container.update({ + NanoCpus: DEFAULT_CPUS * 1e9, + Memory: DEFAULT_MEMORY, + MemorySwap: DEFAULT_SWAP + }); + await updateContainerConfig(container, name, userId, DEFAULT_MEMORY); + console.log(` āœ… Container reset to default settings.`); + } else { + console.log(` ā­ļø Skipping due to unknown limits.`); + continue; + } + } + + // Fetch guild and check roles + const guild = client.guilds.cache.get(GUILD_ID); + if (!guild) { + console.log(` āŒ Guild ${GUILD_ID} not found.`); + continue; + } + + let hasSuperUpgradeRole = false; + let hasStandardOrManualRole = false; + try { + const member = await guild.members.fetch(userId); + hasSuperUpgradeRole = member.roles.cache.has(ROLE_IDS.superUpgrade); + hasStandardOrManualRole = [ROLE_IDS.standard, ROLE_IDS.manualUpgrade].some(roleId => member.roles.cache.has(roleId)); + console.log(` Role Check: User ${hasSuperUpgradeRole ? 'has superUpgrade role' : hasStandardOrManualRole ? 'has standard or manual upgrade role' : 'has no relevant roles'} (${Object.values(ROLE_IDS).join(' or ')})`); + } catch (err) { + console.log(` āŒ Error fetching member ${userId}: ${err.message}`); + continue; + } + + if (hasSuperUpgradeRole && !isSuperUpgraded) { + // Apply super upgrade + console.log(` šŸ”¼ Applying super upgrade to container...`); + await container.update({ + NanoCpus: SUPER_UPGRADED_CPUS * 1e9, + Memory: SUPER_UPGRADED_MEMORY, + MemorySwap: SUPER_UPGRADED_SWAP + }); + await updateContainerConfig(container, name, userId, SUPER_UPGRADED_MEMORY); + console.log(` āœ… Super Upgraded to: CPUs=${SUPER_UPGRADED_CPUS}, Memory=${SUPER_UPGRADED_MEMORY / 1024 / 1024} MiB, Swap=${SUPER_UPGRADED_SWAP / 1024 / 1024} MiB`); + } else if (!hasSuperUpgradeRole && hasStandardOrManualRole && !isUpgraded) { + // Apply standard upgrade + console.log(` šŸ”¼ Upgrading container...`); + await container.update({ + NanoCpus: UPGRADED_CPUS * 1e9, + Memory: UPGRADED_MEMORY, + MemorySwap: UPGRADED_SWAP + }); + await updateContainerConfig(container, name, userId, UPGRADED_MEMORY); + console.log(` āœ… Upgraded to: CPUs=${UPGRADED_CPUS}, Memory=${UPGRADED_MEMORY / 1024 / 1024} MiB, Swap=${UPGRADED_SWAP / 1024 / 1024} MiB`); + } else if (!hasSuperUpgradeRole && !hasStandardOrManualRole && (isUpgraded || isSuperUpgraded)) { + // Downgrade + console.log(` šŸ”½ Downgrading container...`); + await container.update({ + NanoCpus: DEFAULT_CPUS * 1e9, + Memory: DEFAULT_MEMORY, + MemorySwap: DEFAULT_SWAP + }); + await updateContainerConfig(container, name, userId, DEFAULT_MEMORY); + console.log(` āœ… Downgraded to: CPUs=${DEFAULT_CPUS}, Memory=${DEFAULT_MEMORY / 1024 / 1024} MiB, Swap=${DEFAULT_SWAP / 1024 / 1024} MiB`); + } else { + console.log(` āœ… No action needed. Container settings match role status.`); + } + console.log('----------------------------------------'); + } + } + console.log('\n=== Container Check Completed ===\n'); + } catch (err) { + console.error(`\nāŒ Error in container check: ${err.message}\n`); + } +} + +client.once('ready', () => { + console.log(`āœ… Logged in as ${client.user.tag}. Bot is ready.`); + + // Run initial check on startup + checkContainers(); + + // Cron job every 5 minutes + const job = new CronJob('*/5 * * * *', checkContainers, null, true, 'UTC'); + job.start(); +}); + +client.login(DISCORD_TOKEN); \ No newline at end of file