MAATRIX GAMES / Блог / Discord-бот для интеграции с RCON-командами сервера

Discord-бот для интеграции с RCON-командами сервера

MAATRIX GAMES

Половина времени в модерации уходит не на сами решения, а на переключение между окнами — консоль хостинга, 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 держит соединение постоянно или переподключается на каждую команду?

В примерах выше — переподключение на каждый вызов (connectsendend), это надёжнее для нечастых команд из 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.