first commit

This commit is contained in:
dlinux-host 2024-10-02 18:38:03 -04:00
commit 35e4c48991
4 changed files with 828 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
tokens.json
config.json
package-lock.json
node_modules

380
README.md Normal file
View File

@ -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: <your_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: <your_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.

18
package.json Normal file
View File

@ -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"
}
}

426
user-bot.js Normal file
View File

@ -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);