Node.js でポートを監視し、結果を Slack に投稿する

# 動機

サーバで何かが起こればシステム管理者にメールで通知するのが一般的?かと思いますが、家庭内サーバで起きるイベントは家族で共有したいものもあります。イベントの内容を家庭用Slackチームに投稿できれば便利だと思い立ちました。

まず今回は、特定のホストの特定のポートに接続し、接続できなかったら(何か異常があるとして)Slackに投稿してみます。

# Node.jsを動かすDockerコンテナを作成する
スクリプト類をNode.jsで記述する前提で、公式リポジトリのnodeイメージを利用してDockerコンテナを作成します。npmで監視と投稿に必要なパッケージをインストールし、Slackの [incoming webhook](https://api.slack.com/incoming-webhooks) のURIを環境変数として渡しておきます。

```rb:Dockerfile
FROM node:latest
MAINTAINER flny flny@example.com

RUN npm install node-netcat \
                slack-node \
                request

ENV WEBHOOK_URI https://hooks.slack.com/services/xxxxxx...

CMD /bin/bash
```

node.jsで記述するスクリプトはデータボリュームコンテナ(scripts:)に置いて永続化することにしました。

```yaml:docker-compose.yml
monitor:
  image: monitor
  container_name: monitor
  tty: true
  volumes:
    - /mnt/pub:/mnt/pub
  volumes_from:
    - scripts
  command: "/bin/bash"

scripts:
  image: busybox
  container_name: scripts
  tty: true
  volumes:
    - /mnt/scripts
```

```bash
$ docker build -t monitor <Dockerfileの場所>
$ docker-compose up -d monitor
Creating monitor
```



# ポート監視の結果をファイルに書き出す

- ポートスキャンの結果がNGだったら そのレスポンスをファイルに書き込む
- ファイルの内容を読んでSlackに投稿する

の2つにスクリプトを分けました。

前者のscanPort.jsでは、node-netcatのportscanで var portsで指定したポートをスキャンしています。node-netcatのportscanは 必ずtcp/udp両方スキャンするので、udpでエラーが出たときにはそれを無視するようにしています。[^1]
tcpのスキャンでエラーが出たら、system_port_\<unix時間\>というファイル名でエラーのレスポンス内容を保存します。[^2]


```js:scanPort.js
#!/usr/bin/env node

var host = 'example.com';
var ports = ['22', '53', '445', '8200', '80'];
var udpErrorStr = 'protocol \"udp\" with message';
var msgPath = "/mnt/pub/misc/monitor/"

var scanner = require('node-netcat').portscan();
var fs = require('fs');

var msgFile = msgPath + "system_port_" + (new Date()).getTime();

ports.forEach(function(port) {
  scanner.run(host, port, function(err, res) {
    if(err && err.indexOf(udpErrorStr) == -1) {
      fs.appendFile(msgFile, err + "\n");
    }
  });
});
```

# ファイルの内容を読んで Slackに投稿する

scanPort.jsがファイルに出力したエラーの内容を、sendSlack.jsがSlackに投稿します。

- 特定のディレクトリ(/mnt/pub/misc/monitor/)にある ファイル名が"system_port_" で始まるファイルを列挙する
- ファイルの中身を読んで webhookのデフォルトチャネルに :warning: アイコンで投稿する
- うまくいったら ファイルを投稿済みディレクトリ(/mnt/pub/misc/monitor/done/)に移動する

という流れです。slack-nodeのおかげで投稿部分はシンプルに記述できますが、全体として可読性が今ひとつ...。js力が低いのでもっとがんばる必要がありますね。

```js:sendSlack.js
#!/usr/bin/env node

var msgPath = "/mnt/pub/misc/monitor/"
var donePath = "/mnt/pub/misc/monitor/done/"
var filePrefix = "system_port_"

var SlackNode = require('slack-node');
var slack = new SlackNode();
slack.setWebhook(process.env.WEBHOOK_URI);
var fs = require('fs');

fs.readdir(msgPath, function(err, files) {
  if(err) throw err;
  files.filter(function(file) {
    return fs.statSync(msgPath + file).isFile() &&
             file.startsWith(filePrefix);
  }).forEach(function (file) {
    fs.readFile(msgPath + file, 'utf8', function(err, data) {
      if(err) {
        throw err;
      }else{
        slack.webhook({
          icon_emoji: ":warning:",
          text: data
        }, function(err, res) {
          if(err) {
            throw err;
          }else{
            console.log(res);
            fs.renameSync(msgPath + file, donePath + file);
          }
        })
      }
    })
  })
});
```

# 結果を確認する

コンテナ上でスクリプトを実行します。80番ポートは開けてないのでポートスキャンがエラーになり、投稿されるはずです。

```sh
$ /mnt/scripts/scanPort.js
$ /mnt/scripts/sendSlack.js
{ status: 'ok',
  statusCode: 200,
  headers:
   { 'content-type': 'text/html',
     'transfer-encoding': 'chunked',
(略)
  response: 'ok' }
```

![slack.png](https://qiita-image-store.s3.amazonaws.com/0/87156/12c19015-ca7e-fcb5-25ca-3e6f77e33b8f.png)

うまく投稿できました！
あとはcronか何かでこれらのスクリプトを定期的に呼び出せば完成です。

以上、次回は別の何かを監視対象に加えてみようと思います。

[^1]: つまり、udpは監視してません
[^2]: ファイル名をゆるく一意にしたいだけで、unix時間じゃなくてもいいかもです