Windowsイベントログを標準機能だけでfluentdに突っ込みたい

# やりたいこと

* Windowsサーバにソフトウェアをインストールしないでfluentdにログを渡したい
* ファイルサーバの監査ログをKibanaで気軽に見たい

## ハードウェア構成

* 稼働中のWindowsサーバ7台 (2008R2が3台 + 2012R2が4台) ← あまりいじりたくない
* 余っているLinuxサーバ1台 (Ubuntu Server 16.04 LTS) ← 自由にいじれる

## 方針

* dockerにfluentdとElasticsearch、Kibanaコンテナを立てる
* fluentdの`in_http`プラグインでJSONを受ける
* PowerShellスクリプトでJSONを生成してfluentdに送信

## docker + fluentd + Elasticsearch + Kibana

fluentdの`in_http`でJSONを待ち受けるように設定する。

以下のように`docker-compose.yaml`と`Dockerfile`、`fluent.conf`を配置。
今回はfluentdの`in_http`では9880ポート、Kibanaでは5602ポートを使う。

~~~bash
.
├── docker-compose.yaml
└── fluentd
    ├── Dockerfile
    └── fluent.conf
~~~

~~~:fluentd/Dockercompose
FROM fluent/fluentd

RUN gem install fluent-plugin-elasticsearch
COPY fluent.conf /fluentd/etc/fluent.conf
~~~

~~~apache:fluentd/fluent.conf
<source>
  @type http
  port 9880
  bind 0.0.0.0
  body_size_limit 512m
  keepalive_timeout 10s
</source>
<match **>
  type elasticsearch
  host elasticsearch
  time_key TimeCreated
  port 9200
  logstash_format true
  flatten_hashes true
  flatten_hashes_separator _

  buffer_type file
  buffer_path /tmp/fluentd*.buffer
  buffer_chunk_limit 1024m
#  buffer_queue_limit 256
#  flush_interval 60s
#  retry_wait 5s
</match>
~~~

~~~yaml:docker-compose.yaml
version: '2'

services:
  fluentd:
    build: ./fluentd
    volumes:
      - windowslog:/var/log/windows
      - fluentdata:/var/log/fluent
    environment:
      FLUENTD_CONF: fluent.conf
    ports:
      - "9880:9880"
    restart: always
    depends_on:
      - elasticsearch

  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:5.5.2
    volumes:
      - esdata:/usr/share/elasticsearch/data
      - esconfig:/usr/share/elasticsearch/config
    expose:
      - "9200"
    restart: always
    environment:
      - bootstrap.memory_lock=true
      - xpack.security.enabled=false
      - xpack.monitoring.enabled=false
      - xpack.watcher.enabled=false
      - xpack.graph.enabled=false
      - xpack.ml.enabled=false
      - http.max_content_length=1g
      - thread_pool.index.queue_size=-1
      - thread_pool.bulk.queue_size=-1
      - "ES_JAVA_OPTS=-Xms2048m -Xmx2048m"
    ulimits:
      memlock:
        soft: -1
        hard: -1
    mem_limit: 4g

  kibana:
    image: docker.elastic.co/kibana/kibana:5.5.2
    ports:
      - "5602:5601"
    restart: always
    environment:
      - "ELASTICSEARCH_URL=http://elasticsearch:9200"
      - xpack.graph.enabled=false
      - xpack.security.enabled=false
      - xpack.ml.enabled=false
    depends_on:
      - elasticsearch

volumes:
  windowslog:
    driver: local
  fluentdata:
    driver: local
  esdata:
    driver: local
  esconfig:
    driver: local
~~~

コンテナ起動。

~~~
docker-compose up -d --build
~~~

## Windowsサーバ側

「ソフトウェアをインストールしない」という要件を満たすため、PowerShellの`Get-WinEvent`を使用する。

`Get-WinEvent`はリモートコンピュータのイベントログも取得できるので、Windowsサーバの内1台にだけスクリプトを設置。ただし、`ConvertTo-Json`を使用するので、PowerShell 3.0以降が入ったWindows 2012R2上で実行する。

下記は1日1回タスクスケジューラで実行することを前提としたスクリプトで、前日の1日分のログを収集してfluentdに送信する。

~~~Powershell:export_eventlog.ps1
$hosts = @("SERVER1", "SERVER2", "SERVER3", "SERVER4", "SERVER5", "SERVER6", "SERVER7")
$lognames = @("Application", "Security", "System")

$date = Get-Date
$dateend = $date.AddHours(-$date.Hour).AddMinutes(-$date.Minute).AddSeconds(-$date.Second)
$datestart = $dateend.AddDays(-1)

$datestartutc = [System.TimeZoneInfo]::ConvertTimeToUtc($datestart).ToString("yyyy-MM-ddTHH:mm:ssZ")
$dateendutc = [System.TimeZoneInfo]::ConvertTimeToUtc($dateend).ToString("yyyy-MM-ddTHH:mm:ssZ")
$query = "*[System[TimeCreated[@SystemTime>='$datestartutc' and @SystemTime<='$dateendutc']]]"

foreach($h in $hosts){
  foreach($l in $lognames){
    Get-WinEvent -LogName $l -ComputerName $h -FilterXPath $query | % {
      $evt = $_ | ConvertTo-Json -Depth 5 | ConvertFrom-Json
      $evt.TimeCreated = $evt.TimeCreated.ToString("yyyy-MM-ddTHH:mm:ss.fffzzzz")
      $evt.KeywordsDisplayNames = $evt.KeywordsDisplayNames -join " "
      $evt.Properties = ($evt.Properties | % { $_.Value }) -join ' '
      $evt.MatchedQueryIds = $evt.MatchedQueryIds -join " "
      $evt.Bookmark = $evt.Bookmark -join " "
      $evt.Message = $evt.Message -replace "\p{C}+", " "
      
      Write-Host "$h : $l : $($evt.RecordId)"

      $json = ConvertTo-Json $evt -Compress -Depth 5
      $json = $json -replace "\+", "\u002b"
      $body = [System.Text.Encoding]::UTF8.GetBytes("json=" + $json)
      try{
        $null = Invoke-WebRequest "http://(dockerホストのIP):9880/windows.eventlog" -Method Post -Body $body
      }catch{
        Write-Host $_.Exception.Message -ForegroundColor Red
      }
    }
  }
}
~~~

`ConvertTo-Json`では日付型の値が`"\/Date(1326441600000)\/"`という形式になってしまいfluentdで解釈できないため、二度手間ではあるが`ConvertTo-Json`→`ConvertFrom-Json`として複製した後、`TimeCreate`の値をフォーマット。
その他ネストした配列やオブジェクトはfluentdが上手く解釈できないので適当にくっつけておく。

たまに`Message`中に不正な文字列が残るらしく、fluentdがエラーを返すので`-replace "\p{C}+", " "`でUnicode制御文字を消しておく。

URLエンコードしていないのが原因でした。`$body`は下記のように`UrlEncode`しませう。

~~~PowerShell
Add-Type -AssemblyName System.Web

$body = [System.Text.Encoding]::UTF8.GetBytes("json=" + [System.Web.HttpUtility]::UrlEncode($json))
~~~


`in_http`プラグインの仕様により、`"+"`が無視されてしまうので、`"+"`も`"\u002b"`エンコードする。

PowerShellの文字列をそのままPOSTすると、非ASCII文字が全部`"?"`になるので、UTF-8に変換する。

`Invoke-WebRequest`でfluentdコンテナにJSONをPOSTする。IPはホストのIP、ポートは`docker-compose.yaml`で指定した`9880`ポート。

ある程度まとめて送ってHTTPリクエスト数を減らしたほうが若干高速化するが、下記の理由によりこのスクリプトではイベント1件ずつfluentdに送信している。

* `Get-WinEvent`に律速されるためあまり高速化しない
* JSON文字列に不正な文字列が入っていた場合、全件弾かれてしまう

## Kibana

ブラウザから`http://(dockerホストのIP):5602/`にアクセス。
時刻に`"TimeCreated"`を指定してインデックスパターンを作成。

# 感想

WindowsサーバにRubyなどをインストールしなくても、[fluent-plugin-windows-eventlog](https://github.com/fluent/fluent-plugin-windows-eventlog)相当の機能を実現できた。

懸念点はやはり速度で、だいたい数万件/時間でしかログを吐けない。ファイルサーバのアクセス監査ログだと生成速度に追いつけない。

速度のボトルネックは`Get-WinEvent`なので、他に`wevtutl.exe`を使う方法も検討したが、出力されるXMLに情報が不足している(具体的には`Message`がない)ので断念した。なんとかできないものかなぁ。