5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

DevToxicClubAdvent Calendar 2024

Day 1

Node.jsでSSH接続をやってみた

Last updated at Posted at 2024-11-30

はじめに

お久し振りです。 @Keichan_15 です。

今年は新たに技術好きが集まるOrganizationとしてDevToxicClubを作成し、こちらでAdvent Calendarを執筆することとなりました。

主に私と交流のあるメンバーで構成されていますが、メンバーは随時募集していますので技術がお好きな方で気軽にOrgに属してみたいよ!という方はコメント等にご連絡ください!:relaxed:

さて今年のAdvent Calendar1発目の記事は、Node.jsでSSH接続したお話を書いていこうと思います。
あまりタイトル映えしない内容でいささか恐縮ですが、今回この記事執筆に至った経緯を以下で簡単に…。

経緯

私の過去の記事をご覧になられた方はご存じかと思いますが、年明けに私は興味本位でベアボーンのPCに32GBx2(64GB)のメモリと1TBのNVMe4.0 M.2SSDを搭載したサーバー用ミニPCを購入しました。

該当記事はこちらからでも以下からでもご覧いただけます。

当時大ブームとなっていたPalWorldというゲームのサーバーを運営するために購入を決意。今はほとんど過疎っているのですが…。

そのサーバー運営に関する部分で、私はサーバーの起動・停止、ログ等の監視等を含めDiscordで一元管理したいと思い、discord.jsを使ったBotによる運用を開始するようになっていくのです。
そうです、僕はPythonがあまり分かりませんもので

作戦を立てる

私は基本放任主義な性格がゆえ、今回はサーバーに参加されている方が、私が不在の際でも以下の操作を行えるようにしようと考えました。

  • サーバーの起動
  • サーバーの停止

サーバーの起動は、ミニPC内の指定ディレクトリに格納されているシェルスクリプトを実行することで可能です。
ここについては各ゲームで異なるので、それぞれのゲームサーバーを構築する際に確認が必要です。

今回は私の高尚な思想である "自環境は汚さない" をモットーにDockerコンテナ内でBot用のNode.jsを実行することとします。

環境

ちなみに僕のお家のミニPCを取り巻く環境は以下の通りです。
めちゃくちゃ雑なのは許してほしい時間が無かったんや…

aaa.png

その他補足は以下。

"axios": "^1.6.8"
"discord.js": "^14.14.1"
"dotenv": "^16.4.5"
"fs": "^0.0.1-security"
"openai": "^4.36.0"
"ssh2": "^1.15.0"

実装

まずはDocker関連のファイルから。

Dockerfile
FROM node:latest
WORKDIR /app

面倒だったのでlatestで最低限の記載に収めました。

compose.yml
services:
  app:
    build: .
    volumes:
      - .:/app
    command: node commands/utility/palserver_command.js
    ports:
      - 3001:3000
    tty: true

ここも特段アレンジしている個所はありません。
強いて言えば、もしコンテナを別用途で複数運用する場合はポートの重複使用に気をつけようね。というくらいですかね。

commands/utility/palserver_command.js
import { Client as DiscordClient, GatewayIntentBits  } from "discord.js";
import { exec } from "child_process";
import { REST } from "@discordjs/rest";
import { Routes } from "discord-api-types/v9";
import axios from 'axios';
import { Client as SSHClient} from 'ssh2';
import { readFileSync } from 'fs';
import dotenv from 'dotenv';

dotenv.config()

const client = new DiscordClient({ intents: [
  GatewayIntentBits.Guilds,
  GatewayIntentBits.MessageContent,
  GatewayIntentBits.GuildMessages,
  GatewayIntentBits.GuildPresences,
  GatewayIntentBits.GuildMembers,
] });

const commands = [
  {
    name: 'start',
    description: 'パルワールドのサーバーを起動するコマンドです'
  },
  {
    name: 'shutdown',
    description: 'パルワールドのサーバーをシャットダウンするコマンドです'    
  }
];

const rest = new REST({ version: '10' }).setToken(process.env.TOKEN);

(async () => {
  try {
    console.log('(/) スラッシュコマンドのリロードを開始します。');

    await rest.put(
      Routes.applicationGuildCommands(process.env.CLIENT_ID, process.env.GUILD_ID),
      { body: commands },
    );

    console.log('(/) スラッシュコマンドのリロードに成功しました。');
  } catch (error) {
    console.error(error);
  }
})();

client.once('ready', () => {
  console.log('Ready!');
});

client.on('interactionCreate', async interaction => {
  if (!interaction.isCommand()) return;

  const { commandName } = interaction;

  const getApiInfo = async () => {
    try {
      const data = JSON.stringify({
        "waittime": 2,
        "message": "2秒後にシャットダウン!"
      });

      if (commandName === 'shutdown') {
        const response = await axios({
          method: 'post',
          url: process.env.SERVER_URL + '/v1/api/shutdown',
          maxBodyLength: Infinity,
          data: data,
          headers: {
            'Content-Type': 'application/json'
          },
          auth: {
            username: process.env.PAL_USERNAME,
            password: process.env.PAL_PASSWORD
          }
        });
        await interaction.reply("シャットダウンが完了しました!");
      } else {
        const ssh = new SSHClient();

        ssh.on('ready', () => {
          ssh.exec(process.env.SERVER_EXEC_COMMAND, (err, stream) => {
            if (err) throw err;

            stream.on('close', (code, signal) => {
              ssh.end();
            }).on('data', (data) => {
              console.log('STDOUT: ' + data);
            }).stderr.on('data', (data) => {
              console.log('STDERR: ' + data);
            });
          });
        }).connect({
          host: process.env.SSH_HOST,
          port: process.env.SSH_PORT,
          username: process.env.SSH_USERNAME,
          privateKey: readFileSync(process.env.SSH_KEY_PATH)
        });
        await interaction.reply("サーバーの起動が完了しました!");
      }
    } catch (error) {
      console.error(error);
    }
  };
  
  getApiInfo();
});

client.login(process.env.TOKEN);

今回はSSH2がメインです。SSH2Node.jsからSSH接続を実行するためのライブラリです。

私の場合、基本的に鍵認証によるSSH接続方式にしているので、鍵認証が使えるというのもSSH2使用を決めた一つの理由とも言えます。

実施している内容はざっくり以下です。

  • 指定のDiscordチャンネル内で /start と入力すると、BotからミニPC内指定のサーバー起動用ディレクトリのシェルスクリプトをSSH経由で実行
  • 指定のDiscordチャンネル内で /shutdownと入力すると、BotからパルワールドのREST APIにあるShutdown URLPOSTリクエストを実施する

一応これでDiscordチャンネルからPalWorldのサーバー操作ができます。

image.png

1つ懸念があるとすれば、2つ目のShutdownPOSTリクエストですね。
サーバーが完全に閉じたタイミングを検知しているのではなく、POST叩いたぜ~って瞬間にレスポンスを受け取ってるみたいなので、シャットダウン後にすぐ起動、はタイミングによってはまずい気がしています。

この辺はsleepかますなりやり方あるとは思うので、いろいろアレンジしてみるのは良さげかもですね~。

ちなみに環境変数ファイルは一部マスクしますが、こんな感じです。
よしなにご自身の環境に書き換えてくださいね。

.env
TOKEN=MTIzMDxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
CLIENT_ID=12xxxxxxxxxxxxxx
GUILD_ID=120xxxxxxxxxxxxxx

SERVER_URL=http://192.168.10.188:8212

PAL_USERNAME=admin
PAL_PASSWORD=yourPassword

SERVER_EXEC_COMMAND=~/Steam/steamapps/common/PalServer/PalServer.sh -port=8242 -rcon > nohup.out 2>&1 &
SSH_HOST=192.168.10.188
SSH_PORT=1072
SSH_USERNAME=ssh_your_user_name
SSH_PASSWORD=ssh_your_user_password
SSH_KEY_PATH=./.ssh/id_rsa

おわりに

いかがでしたでしょうか。

今年1発目の記事を前日に執筆し、突貫工事で仕上げる無能ムーブ。Toxicの名に恥じない行動と自負しております。

明日からは @recodeyo1ko さんが2日連続で執筆くださいます。お楽しみに!

そして皆さん!Node.jsSSH接続をしよう!アモーレ!

5
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?