Discord-бот для интеграции с RCON-командами сервера
Половина времени в модерации уходит не на сами решения, а на переключение между окнами — консоль хостинга, RCON-клиент, снова Discord, чтобы сказать команде «забанил». Если у вас уже крутится бот на сервере, куда логичнее вести RCON прямо оттуда: одна команда /rcon, ответ сервера в том же чате, и никакого прыжка в отдельное окно. Разберём, как собрать такую команду правильно — с проверкой прав, безопасным хранением пароля и логом, который потом можно поднять, если кто-то накосячил.
Содержание
Что понадобится и как устроена связка
RCON (Remote Console) — протокол, которым игровые серверы принимают текстовые команды по отдельному сетевому каналу: логин по паролю, дальше произвольная строка-команда, в ответ — текст. У Source-игр (CS2, Garry's Mod) и Rust это классический Source RCON, у Minecraft — свой RCON-сервер с тем же принципом (enable-rcon=true в server.properties). Разница только в наборе самих команд, транспорт одинаковый.
Для бота на discord.js самый удобный клиент — пакет rcon-client: поддерживает Source RCON-протокол и работает с Rust, CS2, Minecraft и большинством других игр без танцев с бинарным протоколом руками:
npm install discord.js rcon-client dotenv
Если бот ещё не поднят на VPS — начните с базовой установки Discord-бота: токен, автозапуск через pm2/systemd, структура проекта. Здесь исходим из того, что бот уже живёт 24/7, и добавляем к нему одну функцию — RCON-мост. Более широкий обзор возможностей бота (статус онлайна, вебхуки о падении сервера, роли для разных уровней доступа) — в статье про Discord-бота как панель управления. Здесь же фокус узкий: сама команда /rcon, права и безопасность вокруг неё.
Slash-команда /rcon: базовая реализация
Слэш-команды удобнее старых команд на префиксе (!rcon) по двум причинам: Discord сам подсказывает синтаксис в интерфейсе и умеет ограничивать видимость команды по правам на уровне сервера, без ручной проверки ролей в коде для базового случая.
Регистрация команды (deploy-commands.js, запускается один раз при добавлении/обновлении команд):
require('dotenv').config();
const { REST, Routes, SlashCommandBuilder, PermissionFlagsBits } = require('discord.js');
const commands = [
new SlashCommandBuilder()
.setName('rcon')
.setDescription('Выполнить RCON-команду на игровом сервере')
.addStringOption(option =>
option.setName('команда')
.setDescription('Текст команды для консоли сервера')
.setRequired(true))
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.setDMPermission(false)
.toJSON(),
];
const rest = new REST().setToken(process.env.DISCORD_TOKEN);
(async () => {
await rest.put(
Routes.applicationGuildCommands(process.env.CLIENT_ID, process.env.GUILD_ID),
{ body: commands },
);
console.log('Команда /rcon зарегистрирована');
})();
setDefaultMemberPermissions(PermissionFlagsBits.Administrator) — первый рубеж защиты: Discord вообще не покажет команду участникам без прав администратора, они не увидят её даже в автодополнении. Это не замена проверке в коде (владелец сервера может выдать Administrator кому попало), но снимает большинство случайных попаданий не по адресу.
Routes.applicationGuildCommands регистрирует команду только на одном сервере (по GUILD_ID) — появляется мгновенно, удобно на этапе разработки. Для продакшена на несколько серверов используйте Routes.applicationCommands(CLIENT_ID) без GUILD_ID — глобальная регистрация, но обновление кэша Discord может занимать до часа, это нормально.
Нужен игровой сервер?
MAATRIX GAMES — 45+ игр, деплой за минуты, DDoS-защита, автобэкапы. Тарифы от 399 ₽/мес.
Создать серверПроверка прав администратора в коде
Полагаться только на видимость команды в Discord — плохая практика: права на сервере со временем меняются, роли путаются, а разработчик бота должен явно контролировать, кто реально может тронуть RCON. Двойная проверка — на стороне Discord и внутри обработчика — стандартный подход.
const { Events, PermissionFlagsBits } = require('discord.js');
const { Rcon } = require('rcon-client');
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand() || interaction.commandName !== 'rcon') return;
if (!interaction.memberPermissions.has(PermissionFlagsBits.Administrator)) {
return interaction.reply({
content: 'Недостаточно прав для RCON-команд.',
ephemeral: true,
});
}
const allowedIds = (process.env.RCON_ALLOWED_USER_IDS || '').split(',').filter(Boolean);
if (allowedIds.length && !allowedIds.includes(interaction.user.id)) {
return interaction.reply({ content: 'Ваш ID не в списке доверенных.', ephemeral: true });
}
const command = interaction.options.getString('команда');
await interaction.deferReply({ ephemeral: true });
try {
const rcon = await Rcon.connect({
host: process.env.RCON_HOST,
port: Number(process.env.RCON_PORT),
password: process.env.RCON_PASSWORD,
timeout: 5000,
});
const response = await rcon.send(command);
await rcon.end();
await interaction.editReply('```' + (response || 'OK (пустой ответ)') + '```');
logRconCommand(interaction.user, command, response);
} catch (err) {
await interaction.editReply(`Ошибка RCON: ${err.message}`);
logRconCommand(interaction.user, command, `ERROR: ${err.message}`);
}
});
Два уровня проверки здесь неслучайны: setDefaultMemberPermissions фильтрует, кто видит команду, а allowedIds — дополнительный whitelist по Discord ID, полезный, если Administrator на сервере роздан шире, чем круг людей, которым реально можно доверить RCON-пароль. ID, а не имена — ники меняются, ID постоянен, пока аккаунт существует.
ephemeral: true в ответе — сообщение видно только тому, кто вызвал команду, а не всему каналу: результат status со списком SteamID игроков не должен светиться в общем чате.
Безопасное хранение RCON-пароля
Пароль RCON — это полный доступ к консоли сервера: бан всех, снос конфига, вайп до нуля игроков за минуту. Хранить его нужно ровно так же строго, как токен самого бота.
Файл .env рядом с кодом бота, никогда не в репозитории:
DISCORD_TOKEN=токен_бота
CLIENT_ID=id_приложения
GUILD_ID=id_discord_сервера
RCON_HOST=ваш.сервер.ip
RCON_PORT=28016
RCON_PASSWORD=сложный_пароль_16_плюс_символов
RCON_ALLOWED_USER_IDS=123456789012345678,987654321098765432
Добавьте .env в .gitignore до первого коммита, а не после:
echo ".env" >> .gitignore
Если бот запущен через systemd, вместо dotenv можно передать переменные напрямую через юнит:
[Service]
EnvironmentFile=/home/botuser/mybot/.env
ExecStart=/usr/bin/node /home/botuser/mybot/index.js
Дополнительные правила, которые реально стоит соблюдать, а не просто прочитать и забыть:
- Никогда не логируйте сам пароль — команды и ответы в лог, значение
RCON_PASSWORD— нет, даже в debug при ошибке подключения. - Разные пароли для разных серверов, если бот управляет несколькими (см. ниже) — утечка одного не должна ронять весь парк.
- Ограничьте RCON-порт по IP бота через фаервол — подробно про сам протокол и его безопасность в статье про RCON-подключение и команды.
- Ротируйте пароль, если кто-то из списка
RCON_ALLOWED_USER_IDSпокинул команду — учёток персонально под каждого в RCON нет.
Логирование выполненных команд
Без лога RCON-команда через Discord — чёрный ящик: сервер вайпнули, а кто именно нажал /rcon — неизвестно. Минимум — пишем каждую команду в файл построчно (JSON Lines, удобно парсить скриптом), максимум — ещё и дублируем в приватный канал-аудит:
const fs = require('fs');
const path = require('path');
function logRconCommand(user, command, response) {
const entry = {
timestamp: new Date().toISOString(),
userId: user.id,
username: user.tag,
command,
response: String(response).slice(0, 500),
};
fs.appendFileSync(path.join(__dirname, 'rcon-audit.log'), JSON.stringify(entry) + '\n');
}
async function logToChannel(client, user, command, response) {
const channel = await client.channels.fetch(process.env.AUDIT_CHANNEL_ID);
await channel.send({
embeds: [{
color: response.startsWith('ERROR') ? 0xe74c3c : 0x2ecc71,
description: `**${user.tag}** выполнил \`${command}\``,
fields: [{ name: 'Ответ', value: '```' + response.slice(0, 1000) + '```' }],
timestamp: new Date().toISOString(),
}],
});
}
Канал-аудит держите приватным, видимым только тем же администраторам, что и сама команда — так результат виден сразу, без захода на сервер за файлом. А сам файл rcon-audit.log со временем растёт: если команда используется активно, поставьте logrotate, чтобы не съесть диск за полгода.
Опасные команды: подтверждение и лимит частоты
Не все RCON-команды одинаково рискованны. status безопасен, а ban add everyone или рестарт в разгар вайпа — нет. Помечаем такие команды регуляркой и требуем подтверждение кнопкой, а не мгновенное выполнение:
const { ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js');
const DANGEROUS_PATTERNS = [/^ban add/i, /^restart/i, /^shutdown/i, /^wipe/i];
const isDangerous = (cmd) => DANGEROUS_PATTERNS.some(re => re.test(cmd.trim()));
if (isDangerous(command)) {
const row = new ActionRowBuilder().addComponents(
new ButtonBuilder().setCustomId('rcon_confirm').setLabel('Подтвердить').setStyle(ButtonStyle.Danger),
new ButtonBuilder().setCustomId('rcon_cancel').setLabel('Отмена').setStyle(ButtonStyle.Secondary),
);
return interaction.editReply({
content: `Команда \`${command}\` помечена как опасная. Подтвердить?`,
components: [row],
});
}
Само выполнение переносится в отдельный обработчик кнопок (interaction.isButton(), customId === 'rcon_confirm') — код подключения к RCON тот же, что в базовом примере, просто вызывается по нажатию, а не сразу.
Дополнительно стоит поставить простой rate limit на пользователя — не от злоумышленника, а от случайного спама или зацикленного бага:
const rconCooldowns = new Map();
function isRateLimited(userId, limitMs = 3000) {
const last = rconCooldowns.get(userId) || 0;
if (Date.now() - last < limitMs) return true;
rconCooldowns.set(userId, Date.now());
return false;
}
Несколько серверов через одного бота
Если команда держит несколько серверов (например, Rust-кластер из пары инстансов), не плодите ботов — добавьте в команду второй option с выбором сервера через .addChoices(...), а хосты/порты/пароли вынесите в отдельный объект-конфиг:
const SERVERS = {
rust_main: { host: process.env.RUST_MAIN_HOST, port: 28016, password: process.env.RUST_MAIN_PASSWORD },
rust_eu: { host: process.env.RUST_EU_HOST, port: 28016, password: process.env.RUST_EU_PASSWORD },
};
В обработчике достаточно SERVERS[interaction.options.getString('сервер')] вместо жёстко зашитых RCON_HOST/RCON_PASSWORD. Разные пароли на разные серверы плюс, при необходимости, отдельные роли Админ Rust Main / Админ Rust EU — если это разные команды модераторов. Общее про архитектуру доступа разобрано в статье про права доступа и роли администраторов на сервере — принципы применимы и внутри самого бота.
Нужен игровой сервер?
MAATRIX GAMES — 45+ игр, деплой за минуты, DDoS-защита, автобэкапы. Тарифы от 399 ₽/мес.
Создать серверЧастые вопросы
rcon-client держит соединение постоянно или переподключается на каждую команду?
В примерах выше — переподключение на каждый вызов (connect → send → end), это надёжнее для нечастых команд из Discord. Для очень частых вызовов можно держать пул открытых соединений, но для типичной админ-консоли это лишняя сложность.
Что если RCON-сервер не отвечает вовремя?
Параметр timeout: 5000 в Rcon.connect обрывает попытку через 5 секунд и бросает исключение — обработчик это ловит и покажет "Ошибка RCON" вместо бесконечного "бот думает".
Нужно ли валидировать текст команды перед отправкой?
RCON не выполняет произвольный код ОС, только команды самой игры — инъекция в духе SQL-injection физически невозможна. Но стоит ограничить длину строки (setMaxLength(200)) и проверять на чёрный список опасных паттернов, как описано выше.
Можно ли дать доступ к /rcon не через роль Administrator, а точечно конкретным людям?
Да, для этого в примерах добавлен RCON_ALLOWED_USER_IDS — список Discord ID в переменной окружения, проверяемый отдельно от прав Discord.
Стоит ли давать доступ модераторам без полного Administrator?
Лучше не через setDefaultMemberPermissions (он завязан на встроенные права Discord), а отдельной командой с более мягким набором (/modkick, фиксированный шаблон RCON-команды без свободного ввода текста) — безопаснее, чем открывать произвольный /rcon.