From 35e4c489919ab126463882a753cf5bdd1d17583d Mon Sep 17 00:00:00 2001 From: dlinux-host Date: Wed, 2 Oct 2024 18:38:03 -0400 Subject: [PATCH] first commit --- .gitignore | 4 + README.md | 380 +++++++++++++++++++++++++++++++++++++++++++++ package.json | 18 +++ user-bot.js | 426 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 828 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package.json create mode 100644 user-bot.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d98581d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +tokens.json +config.json +package-lock.json +node_modules \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a6f881 --- /dev/null +++ b/README.md @@ -0,0 +1,380 @@ +# Discord Container Manager Bot + +A powerful Discord bot built with [discord.js](https://discord.js.org/) that allows users to manage and interact with containerized services directly from Discord. The bot integrates with a MySQL database for user management and communicates with an external API to perform various container operations such as starting, stopping, restarting containers, fetching stats, and executing commands. + +## Table of Contents + +- [Features](#features) +- [Prerequisites](#prerequisites) +- [Installation](#installation) +- [Configuration](#configuration) + - [Config Files](#config-files) + - [Database Setup](#database-setup) +- [Usage](#usage) + - [Available Commands](#available-commands) +- [Contributing](#contributing) +- [License](#license) +- [Acknowledgments](#acknowledgments) + +## Features + +- **Slash Commands:** Interact with containers using intuitive slash commands. +- **Database Integration:** Securely manage user data with MySQL. +- **API Communication:** Fetch and manage tokens automatically, ensuring secure API interactions. +- **Dynamic Command Registration:** Automatically registers and updates commands with Discord. +- **Embed Messages:** Provides rich and informative responses using Discord embeds. +- **Command Execution:** Execute shell commands within containers directly from Discord. + +## Prerequisites + +Before you begin, ensure you have met the following requirements: + +- **Node.js:** Version 16.6.0 or higher. [Download Node.js](https://nodejs.org/) +- **MySQL Database:** A running MySQL server to store user data. +- **Discord Bot:** A Discord application with a bot token. [Create a Discord Bot](https://discord.com/developers/applications) + +## Installation + +1. **Clone the Repository** + + ```bash + git clone https://github.com/yourusername/discord-container-manager.git + cd discord-container-manager + ``` + +2. **Install Dependencies** + + Ensure you have [Node.js](https://nodejs.org/) installed. Then, install the required npm packages: + + ```bash + npm install + ``` + + The project relies on the following main dependencies: + + - `discord.js`: Interact with the Discord API. + - `mysql2`: Connect to the MySQL database. + - `jsonfile`: Read and write JSON files. + - `unirest`: Make HTTP requests. + - `fs`: File system operations (built-in Node.js module). + +## Configuration + +### Config Files + +The bot requires two main configuration files: `config.json` and `tokens.json`. + +1. **config.json** + + This file holds essential configuration details such as Discord tokens, API endpoints, and database credentials. + + ```json + { + "token": "YOUR_DISCORD_BOT_TOKEN", + "clientId": "YOUR_DISCORD_CLIENT_ID", + "SQLHOST": "localhost", + "SQLUSER": "your_mysql_user", + "SQLDATABASE": "your_database", + "SQLPASSWORD": "your_mysql_password", + "endpoint": "https://api.yourservice.com", + "password": "YOUR_API_PASSWORD", + "apiBaseURL": "https://api.yourservice.com" + } + ``` + + - **token:** Your Discord bot token. + - **clientId:** Your Discord application's client ID. + - **SQLHOST:** Hostname for your MySQL server. + - **SQLUSER:** MySQL username. + - **SQLDATABASE:** Name of the MySQL database. + - **SQLPASSWORD:** MySQL user password. + - **endpoint:** API endpoint for token fetching. + - **password:** Password used for API authentication. + - **apiBaseURL:** Base URL for the API interactions. + +2. **tokens.json** + + This file is used to store and manage user-specific API tokens. It's automatically generated and managed by the bot. **Ensure this file is kept secure and is excluded from version control.** + + ```json + {} + ``` + + > **Note:** It's recommended to add `tokens.json` and `config.json` to your `.gitignore` to prevent sensitive information from being pushed to version control. + +### Database Setup + +The bot connects to a MySQL database to manage user data. Ensure your database has a `users` table with the following structure: + +```sql +CREATE TABLE users ( + id INT AUTO_INCREMENT PRIMARY KEY, + uid VARCHAR(255) NOT NULL, + discord_id VARCHAR(255) UNIQUE NOT NULL +); +``` + +- **id:** Primary key. +- **uid:** Unique identifier for the user in the external system. +- **discord_id:** Discord user ID. + +> **Note:** Adjust the table schema as needed based on your requirements. + +## Usage + +After completing the installation and configuration steps, you can start the bot using: + +```bash +node index.js +``` + +Upon successful startup, the bot will register its slash commands with Discord and begin listening for interactions. + +### Available Commands + +The bot offers a variety of slash commands to manage containers and interact with the underlying API. + +#### `/hello` + +**Description:** Say hello via API. + +**Usage:** + +``` +/hello +``` + +**Response:** + +An embed message with a greeting from the API. + +#### `/name` + +**Description:** Get the API username. + +**Usage:** + +``` +/name +``` + +**Response:** + +An embed message displaying the API username. + +#### `/start` + +**Description:** Start the container. + +**Usage:** + +``` +/start +``` + +**Response:** + +An embed message confirming the container has started. + +#### `/stop` + +**Description:** Stop the container. + +**Usage:** + +``` +/stop +``` + +**Response:** + +An embed message confirming the container has stopped. + +#### `/restart` + +**Description:** Restart the container. + +**Usage:** + +``` +/restart +``` + +**Response:** + +An embed message confirming the container has restarted. + +#### `/info` + +**Description:** Get container information. + +**Usage:** + +``` +/info +``` + +**Response:** + +An embed message detailing various information about the container, including name, IP address, memory usage, CPU usage, status, and more. + +#### `/stats` + +**Description:** Get container stats. + +**Usage:** + +``` +/stats +``` + +**Response:** + +An embed message displaying memory and CPU usage statistics of the container. + +#### `/time` + +**Description:** Get container expire time. + +**Usage:** + +``` +/time +``` + +**Response:** + +An embed message showing the expiration date of the container. + +#### `/root-password` + +**Description:** Change the root password. + +**Usage:** + +``` +/root-password +``` + +**Response:** + +An ephemeral embed message revealing the new root password. + +#### `/new-api-key` + +**Description:** Generate a new API key. + +**Usage:** + +``` +/new-api-key +``` + +**Response:** + +An ephemeral embed message providing a new API key. + +#### `/key-expire-time` + +**Description:** Check the API key expiration time. + +**Usage:** + +``` +/key-expire-time +``` + +**Response:** + +An ephemeral embed message showing the expiration date of the API key. + +#### `/x` + +**Description:** Execute a command in the container. + +**Usage:** + +``` +/x command: +``` + +**Options:** + +- **command** (String, Required): The command to execute inside the container. + +**Response:** + +A message containing the standard output and error from the executed command, formatted in markdown code blocks. + +**Examples:** + +- Change directory: + + ``` + /x command: cd /var/www + ``` + +- List files: + + ``` + /x command: ls -la + ``` + +#### `/notify` + +**Description:** Send a notification to Discord. + +**Usage:** + +``` +/notify message: +``` + +**Options:** + +- **message** (String, Required): The message to send as a notification. + +**Response:** + +An ephemeral embed message confirming the notification has been sent. + +## Contributing + +Contributions are welcome! Follow these steps to contribute: + +1. **Fork the Repository** + + Click the [Fork](https://github.com/yourusername/discord-container-manager/fork) button on the repository page. + +2. **Create a New Branch** + + ```bash + git checkout -b feature/YourFeature + ``` + +3. **Make Your Changes** + + Implement your feature or fix the bug. + +4. **Commit Your Changes** + + ```bash + git commit -m "Add your message here" + ``` + +5. **Push to the Branch** + + ```bash + git push origin feature/YourFeature + ``` + +6. **Create a Pull Request** + + Navigate to the original repository and create a pull request from your forked branch. + +## Acknowledgments + +- [discord.js](https://discord.js.org/) - Powerful library for interacting with the Discord API. +- [Unirest](http://unirest.io/) - Lightweight HTTP client. +- [mysql2](https://github.com/sidorares/node-mysql2) - MySQL client for Node.js. +- [jsonfile](https://github.com/jprichardson/node-jsonfile) - Easily read/write JSON files. diff --git a/package.json b/package.json new file mode 100644 index 0000000..f75c7a7 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "installable-bot", + "version": "1.0.0", + "main": "user-bot.js", + "type": "module", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "discord.js": "^14.16.3", + "jsonfile": "^6.1.0", + "mysql2": "^3.11.3", + "unirest": "^0.6.0" + } +} diff --git a/user-bot.js b/user-bot.js new file mode 100644 index 0000000..28c580b --- /dev/null +++ b/user-bot.js @@ -0,0 +1,426 @@ +import { Client, GatewayIntentBits, SlashCommandBuilder, REST, Routes, EmbedBuilder } from 'discord.js'; +import jsonfile from 'jsonfile'; +import unirest from 'unirest'; +import { readFileSync } from 'fs'; +import mysql from 'mysql2'; +const userWorkingDirectories = new Map(); + +let sshSurfID; // Variable to store the user ID from the database + +// Paths to config and tokens files +const tokensFile = './tokens.json'; +const config = JSON.parse(readFileSync('./config.json', 'utf8')); + +// MySQL connection +const connection = mysql.createConnection({ + host: config.SQLHOST, + user: config.SQLUSER, + database: config.SQLDATABASE, + password: config.SQLPASSWORD +}); + +// Initialize Discord client +const client = new Client({ intents: [GatewayIntentBits.Guilds] }); + +// Load tokens from the JSON file +function loadTokens() { + try { + return jsonfile.readFileSync(tokensFile); + } catch (error) { + console.error('Error reading tokens file:', error); + return {}; + } +} + +// Save tokens to the JSON file +function saveTokens(tokens) { + jsonfile.writeFileSync(tokensFile, tokens, { spaces: 2 }); +} + +// Automatically request a new token if it doesn't exist or is invalid +async function fetchAndSaveToken(sshSurfID, interaction) { + return unirest + .post(config.endpoint.toString()) + .headers({ 'Accept': 'application/json', 'Content-Type': 'application/json' }) + .send({ "username": `${sshSurfID}`, "password": config.password.toString() }) + .then((tokenInfo) => { + const tokens = loadTokens(); + tokens[sshSurfID] = tokenInfo.body.token; // Save the new token for sshSurfID + saveTokens(tokens); + return tokenInfo.body.token; + }) + .catch((error) => { + console.error('Error fetching token:', error); + sendSexyEmbed("Error", "An error occurred while fetching your API token.", interaction); + throw error; + }); +} + +// Fetch or retrieve token, if the token is invalid, fetch a new one +async function getToken(sshSurfID, interaction) { + const tokens = loadTokens(); + if (!tokens[sshSurfID]) { + return await fetchAndSaveToken(sshSurfID, interaction); + } + return tokens[sshSurfID]; +} + +// Handle API request +async function makeApiRequest(endpoint, token, interaction, method = 'get', body = null) { + const request = unirest[method](config.apiBaseURL + endpoint) + .headers({ + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'x-ssh-auth': token + }); + + if (body) { + request.send(body); + } + + return request.then(response => { + if (response.error) { + console.error('API Error:', response.error); + sendSexyEmbed("Error", "An error occurred while communicating with the API.", interaction); + throw response.error; + } + return response.body; + }); +} + +// Send sexy embed +function sendSexyEmbed(title, description, interaction, ephemeral = false) { + const embed = new EmbedBuilder() + .setColor("#3498DB") + .setTitle(title) + .setDescription(description) + .setTimestamp() + .setFooter({ + text: `Requested by ${interaction.user.username}`, + iconURL: `${interaction.user.displayAvatarURL()}` + }); + interaction.editReply({ + embeds: [embed], + ephemeral: ephemeral // Set ephemeral flag based on the condition + }); +} + +// Send sexy embed with fields +function sendSexyEmbedWithFields(title, fields, interaction, ephemeral = false) { + const embed = new EmbedBuilder() + .setColor("#3498DB") + .setTitle(title) + .addFields(fields) + .setTimestamp() + .setFooter({ + text: `Requested by ${interaction.user.username}`, + iconURL: `${interaction.user.displayAvatarURL()}` + }); + interaction.editReply({ + embeds: [embed], + ephemeral: ephemeral // Set ephemeral flag based on the condition + }); +} + +// Slash command definitions +const commands = [ + new SlashCommandBuilder().setName('hello').setDescription('Say hello via API'), + new SlashCommandBuilder().setName('name').setDescription('Get the API username'), + new SlashCommandBuilder().setName('start').setDescription('Start the container'), + new SlashCommandBuilder().setName('stop').setDescription('Stop the container'), + new SlashCommandBuilder().setName('restart').setDescription('Restart the container'), + new SlashCommandBuilder().setName('info').setDescription('Get container information'), + new SlashCommandBuilder().setName('stats').setDescription('Get container stats'), + new SlashCommandBuilder().setName('time').setDescription('Get container expire time'), + new SlashCommandBuilder().setName('root-password').setDescription('Change the root password'), + new SlashCommandBuilder().setName('new-api-key').setDescription('Generate a new API key'), + new SlashCommandBuilder().setName('key-expire-time').setDescription('Check the key expire time'), + new SlashCommandBuilder().setName('x').setDescription('Execute a command in the container') + .addStringOption(option => option.setName('command').setDescription('Command to execute').setRequired(true)), + new SlashCommandBuilder().setName('notify').setDescription('Send a notification to Discord') + .addStringOption(option => option.setName('message').setDescription('Message to send').setRequired(true)), +]; + +// Register commands with Discord +const rest = new REST({ version: '10' }).setToken(config.token); + +(async () => { + try { + console.log('Started refreshing application (/) commands.'); + + // Add extra fields to each command + const commandsWithExtras = commands.map((command) => { + const jsonCommand = command.toJSON(); + const extras = { + "integration_types": [0, 1], // 0 for guild, 1 for user + "contexts": [0, 1, 2], // 0 for guild, 1 for app DMs, 2 for GDMs and other DMs + }; + + // Add extras to the command's JSON object + Object.keys(extras).forEach(key => jsonCommand[key] = extras[key]); + + return jsonCommand; + }); + + // Register commands with Discord, making sure all commands are sent in an array + const data = await rest.put( + Routes.applicationCommands(config.clientId), + { body: commandsWithExtras } // Send all commands in one array + ); + + console.log('Successfully reloaded application (/) commands.'); + } catch (error) { + console.error('Error reloading commands:', error); + } +})(); + +// Handle bot interactions +client.on('interactionCreate', async interaction => { + if (!interaction.isCommand()) return; + + // Defer the reply to allow time for the command to run + await interaction.deferReply(); + + // First, we fetch the sshSurfID from the database using interaction.user.id + let sshSurfID = await new Promise((resolve, reject) => { + connection.query( + "SELECT uid FROM users WHERE discord_id = ?", + [interaction.user.id], + (err, results) => { + if (err) { + console.error('Error querying database:', err); + reject(err); + } else if (results.length === 0) { + console.log("User does not exist"); + resolve(null); + } else { + resolve(results[0].uid); + } + } + ); + }); + + if (!sshSurfID) { + return sendSexyEmbed("Error", "User not found in the database.", interaction); + } + + // Once sshSurfID is set, we proceed with token fetching and API requests + const apiToken = await getToken(sshSurfID, interaction); + + try { + switch (interaction.commandName) { + case 'hello': + const helloResponse = await makeApiRequest('/hello', apiToken, interaction); + sendSexyEmbed("Hello", `Message: ${helloResponse.message}`, interaction); + break; + + case 'name': + const nameResponse = await makeApiRequest('/name', apiToken, interaction); + sendSexyEmbedWithFields('Username', [ + { name: 'Username', value: nameResponse.message } + ], interaction); + break; + + case 'start': + const startResponse = await makeApiRequest('/start', apiToken, interaction); + sendSexyEmbedWithFields('Start Server', [ + { name: 'Status', value: 'Success' }, + { name: 'Message', value: startResponse.message } + ], interaction); + break; + + case 'stop': + const stopResponse = await makeApiRequest('/stop', apiToken, interaction); + sendSexyEmbedWithFields('Stop Server', [ + { name: 'Status', value: 'Success' }, + { name: 'Message', value: stopResponse.message } + ], interaction); + break; + + case 'restart': + const restartResponse = await makeApiRequest('/restart', apiToken, interaction); + sendSexyEmbedWithFields('Restart Server', [ + { name: 'Status', value: 'Success' }, + { name: 'Message', value: restartResponse.message } + ], interaction); + break; + + case 'info': + const infoResponse = await makeApiRequest('/info', apiToken, interaction); + + // Extract and fallback data fields from the response + const containerName = infoResponse.data?.name || 'N/A'; + const ipAddress = infoResponse.data?.IPAddress || 'N/A'; + const macAddress = infoResponse.data?.MacAddress || 'N/A'; + const memory = infoResponse.data?.memory || 'N/A'; + const cpus = infoResponse.data?.cpus || 'N/A'; + const restartPolicy = infoResponse.data?.restartPolicy?.Name || 'N/A'; + const restarts = infoResponse.data?.restarts !== undefined ? infoResponse.data.restarts : 'N/A'; + const status = infoResponse.data?.state?.Status || 'Unknown'; + const pid = infoResponse.data?.state?.Pid || 'N/A'; + const startedAt = infoResponse.data?.state?.StartedAt || 'N/A'; + const image = infoResponse.data?.image || 'N/A'; + const createdAt = infoResponse.data?.created || 'N/A'; + + // Format and send the embed + sendSexyEmbedWithFields('Container Info', [ + { name: 'Name', value: containerName }, + { name: 'IP Address', value: ipAddress }, + { name: 'MAC Address', value: macAddress }, + { name: 'Memory', value: memory }, + { name: 'CPUs', value: cpus }, + { name: 'Restart Policy', value: restartPolicy }, + { name: 'Restarts', value: `${restarts}` }, + { name: 'Status', value: status }, + { name: 'PID', value: `${pid}` }, + { name: 'Started At', value: startedAt }, + { name: 'Created At', value: createdAt } + ], interaction); + break; + + case 'stats': + const statsResponse = await makeApiRequest('/stats', apiToken, interaction); + sendSexyEmbedWithFields('Container Stats', [ + { name: 'Memory Usage', value: `${statsResponse.data.memory.raw} (${statsResponse.data.memory.percent})` }, + { name: 'CPU Usage', value: statsResponse.data.cpu } + ], interaction); + break; + + case 'time': + const timeResponse = await makeApiRequest('/time', apiToken, interaction); + sendSexyEmbedWithFields('Container Expire Time', [ + { name: 'Expire Date', value: timeResponse.expireDate } + ], interaction); + break; + + case 'root-password': + const rootPassResponse = await makeApiRequest('/rootpass', apiToken, interaction); + sendSexyEmbedWithFields('Root Password', [ + { name: 'New Root Password', value: rootPassResponse.newRootPass } + ], interaction, true); + break; + + case 'new-api-key': + const newKeyResponse = await makeApiRequest('/new-key', apiToken, interaction); + sendSexyEmbedWithFields('New API Key', [ + { name: 'New API Key', value: newKeyResponse.newAPIKey } + ], interaction, true); + break; + + case 'key-expire-time': + const keyTimeResponse = await makeApiRequest('/key-time', apiToken, interaction); + sendSexyEmbedWithFields('API Key Expire Time', [ + { name: 'Expire Date', value: keyTimeResponse.expireDate } + ], interaction, true); + break; + + + +case 'x': { + + const command = interaction.options.getString('command'); + + // Get the user's current working directory or default to root (/) + let userPWD = userWorkingDirectories.get(sshSurfID) || '/'; + + // Handle 'cd' command logic + if (command.startsWith('cd')) { + let argscmd = command.replace('cd ', '').trim(); + + // Handle 'cd ..' for going up one directory + if (argscmd === '..') { + if (userPWD !== '/') { + // Remove the last part of the current path + const newPWD = userPWD.split('/').slice(0, -1).join('/') || '/'; + userPWD = newPWD; + userWorkingDirectories.set(sshSurfID, newPWD); + await interaction.editReply(`Directory changed to: ${newPWD}`); + } else { + await interaction.editReply(`Already at the root directory: ${userPWD}`); + } + return; + } + + // Handle '~' for home directory + if (argscmd === '~') { + userPWD = '/root'; + userWorkingDirectories.set(sshSurfID, userPWD); + await interaction.editReply(`Directory changed to: ${userPWD}`); + return; + } + + // Handle absolute and relative paths + let newPWD; + if (argscmd.startsWith('/')) { + // Absolute path + newPWD = argscmd; + } else { + // Relative path + newPWD = `${userPWD}/${argscmd}`; + } + + // Normalize the path (remove extra slashes) + newPWD = newPWD.replace(/([^:]\/)\/+/g, "$1"); + + // Check if the user is trying to go back multiple directories (e.g., 'cd ../../') + if (argscmd.includes('../')) { + const numDirsBack = argscmd.split('../').length - 1; + newPWD = RemoveLastDirectoryPartOf(userPWD, numDirsBack); + } + + // Update the working directory + userWorkingDirectories.set(sshSurfID, newPWD); + await interaction.editReply(`Directory changed to: ${newPWD}`); + return; + } + + // If the command is not 'cd', run the command in the current working directory (or default to '/') + const execResponse = await makeApiRequest('/exec', apiToken, interaction, 'post', { + cmd: command, + pwd: userPWD // Use the current directory or default to '/' + }); + + // Format the command output in a markdown code block + let replyMessage = `\`\`\`\n${execResponse.stdout || 'No output'}\n\`\`\``; + + // If there is an error, append the error message in another markdown code block + if (execResponse.stderr && execResponse.stderr.trim()) { + replyMessage += `\n**Error:**\n\`\`\`\n${execResponse.stderr}\n\`\`\``; + } + + // Reply with the formatted message + await interaction.editReply(replyMessage); + break; +} + +// Helper function to remove directories when using '../' +function RemoveLastDirectoryPartOf(the_url, num) { + var the_arr = the_url.split('/'); + the_arr.splice(-num, num); + return the_arr.join('/') || '/'; +} + + + case 'notify': + const message = interaction.options.getString('message'); + const notifyResponse = await makeApiRequest('/notify', apiToken, interaction, 'post', { + message: message + }); + sendSexyEmbedWithFields('Notification', [ + { name: 'Status', value: 'Success' }, + { name: 'Message', value: notifyResponse.message } + ], interaction, true); + break; + + default: + interaction.reply('Command not recognized.'); + break; + } + } catch (error) { + console.error('Command error:', error); + sendSexyEmbed('Error', 'An error occurred while processing your request.', interaction); + } +}); + +// Log in to Discord +client.login(config.token);