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

## はじめに
お久し振りです。 @Keichan_15  です。

今年は新たに技術好きが集まるOrganizationとして[DevToxicClub](https://qiita.com/organizations/dev-toxic-club)を作成し、こちらでAdvent Calendarを執筆することとなりました。

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

https://qiita.com/organizations/dev-toxic-club

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

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

該当記事は[こちら](https://qiita.com/Keichan_15/items/f354c665f3058a1e4fe0)からでも以下からでもご覧いただけます。

https://qiita.com/Keichan_15/items/f354c665f3058a1e4fe0

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

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

https://discord.js.org/

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

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

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

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

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

![aaa.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/e1cfe0f3-f6ce-76b7-6c2b-249ae893a321.png)

その他補足は以下。
```json
"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:Dockerfile
FROM node:latest
WORKDIR /app
```

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

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

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

```javascript: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`がメインです。`SSH2`は`Node.js`からSSH接続を実行するためのライブラリです。

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

https://www.npmjs.com/package/ssh2

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

- 指定のDiscordチャンネル内で `/start` と入力すると、BotからミニPC内指定のサーバー起動用ディレクトリのシェルスクリプトを`SSH`経由で実行
- 指定のDiscordチャンネル内で `/shutdown`と入力すると、Botからパルワールドの`REST API`にある[Shutdown URL](https://tech.palworldgame.com/ja/api/rest-api/shutdown)へ`POST`リクエストを実施する

https://tech.palworldgame.com/ja/api/rest-api/shutdown

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/4a5daeca-93ee-e699-c733-4c4aee1d1357.png)

1つ懸念があるとすれば、2つ目の`Shutdown`の`POST`リクエストですね。
サーバーが完全に閉じたタイミングを検知しているのではなく、`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.js`で`SSH`接続をしよう！アモーレ！