PowerShell で syslog クライアントを作る

経緯

Windows の情報を Syslog で別サーバ (Syslog サーバ) へ送信したい、と考えました。

メモリ使用率等の他に、イベントビューアで特定のイベントが発生したとき、等のニッチなカスタマイズが必要そうだったので、自作してみることにしました。

成果物

主題は Powershell から Syslog の送信ができる (Syslog クライアント になれる) か、ということですが、分かりやすい指標として「メモリ使用率を1秒おきに送信する」という内容として作成しました。

config.json

{
    "serverIP": "192.0.2.1",
    "threshold": 60
}

差し当たって

  • メモリ使用率の閾値 (この値を越えたら Severity を Information ではなく Warning とする)
  • Syslog サーバ のIP

の2つをパラメータとして要すると判断したので JSON ファイルとして用意。

leibniz5149.ps1

########################################################################################
# leibniz5149                                                                          #
########################################################################################

# Shift-JIS でコード記述

Add-Type -AssemblyName Microsoft.VisualBasic

######################
# 環境設定            #
######################

# ファイル出力時の文字コード設定
$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'

######################
# グローバル変数      #
######################
[String]$appName = 'Leibniz 5149'
[String]$configPath = Join-Path ( Convert-Path . ) 'config.json'
[String]$fileMode = "-a----"
[String]$datetime = Get-Date -UFormat "%Y%m%d_%H%M%S"
[String]$logFilename = 'log-' + $datetime + '.txt'
[String]$logPath = Join-Path ( Convert-Path . ) (Join-Path 'log' $logFilename)
[Int]$count = 514

######################
# 関数               #
######################

##
# Find-ErrorMessage: エラーメッセージ組み立て
#
# @param {Int} $code: エラーコード
# @param {String} $someStr: 一部エラーメッセージで使用する文字列
#
# @return {String} $msg: 出力メッセージ
#
function Find-ErrorMessage([Int]$code, [String]$someStr) {
    $msg = ''
    $errMsgObj = [Hashtable]@{
        # 1x: 設定ファイル系
        11 = '設定ファイル (config.json) が存在しません。'
        # 設定ファイル内のパラメータチェック
        21 = '設定ファイル (config.json) に キー: ########## がありません。'
        22 = '設定ファイル (config.json) の キー: ########## の値が不正な形式です。'
        # 3x: Syslog 系
        31 = 'Syslog サーバへのソケットを開くことに失敗しました。'
        32 = 'セベラリティの値に不正なフラグ文字列がセットされています。'
        33 = 'ファシリティの値に不正なフラグ文字列がセットされています。'

        # 9x: その他、処理中エラー
        99 = '##########'
    }
    $msg = $errMsgObj[$code]
    if ($someStr.Length -gt 0) {
        $msg = $msg.Replace('##########', $someStr)
    }

    return $msg
}
##
# Show-ErrorMessage: エラーメッセージ出力
#
# @param {Int} $code: エラーコード
# @param {Boolean} $exitFlag: exit するかどうかのフラグ
# @param {String} $someStr: 一部エラーメッセージで使用する文字列
# @param {String} $logPath: 出力するログのパス
#
function Show-ErrorMessage([Int]$code, [Boolean]$exitFlag, [String]$someStr, [String]$logPath) {
    $msg = Find-ErrorMessage $code $someStr
    Write-Output('ERROR ' + $code + ': ' + $msg) >> $logPath
    Write-Output `r`n >> $logPath

    if ($exitFlag) {
        exit
    }
}
##
# Assert-ExistFile: ファイル存在チェック
#
# @param {String} $filePath: ファイルパス
#
# @return {Boolean} : ファイルが存在すれば True, そうれなければ False
#
function Assert-ExistFile([String]$filePath) {
    return (Test-Path $filePath)
}

##
# Assert-ConfigExistKey: config.json の記述された キーの存在チェック
#
# @param {PSObject} $configData: config.json のオブジェクト
# @param {String} $key: キー名
#
# @return {Boolean} : ライセンスキーのフォーマットであれば True, そうれなければ False
#
function Assert-ConfigExistKey([PSObject]$configData, [String]$key) {
    $configDataHash = @{}
    foreach( $property in $configData.psobject.properties.name ) {
        $configDataHash[$property] = $configData.$property
    }
    if (!$configDataHash.ContainsKey($key)) {
        Show-ErrorMessage 21 $True $key $logPath
    }

    return $true
}
##
# Assert-ConfigServerIP: config.json の記述された値のチェック
#
# @param {String} $val: 値
#
# @return {Boolean} : フォーマットに一致していれば True, そうれなければ False
#
function Assert-ConfigServerIP([String]$val) {
    return $val -match "^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])$"
}
##
# Assert-ConfigThreshold: config.json の記述された値のチェック
#
# @param {Int} $val: 値
#
# @return {Boolean} : フォーマットに一致していれば True, そうれなければ False
#
function Assert-ConfigThreshold([Int]$val) {
    return $val -lt 100 -and $val -gt 0
}


##
# Format-MemorySize: メモリサイズのフォーマット調整
#
# @param {Int32} $SizeInBytes: 値
#
# @return {String} : KB数値の文字列
#
function Format-MemorySize([Int32]$SizeInBytes) {
    $units = "KB", "MB", "GB", "TB"
    $index = 0
    while ($SizeInBytes -gt 1024 -and $index -le $units.length) {
        $SizeInBytes /= 1024
        $index++
        if ($index -ge $units.Length -1) {
            break
        }
    }
    if ($index) {
        return '{0:N2} {1}' -f $SizeInBytes, $units[$index]
    }
    return "$SizeInBytes KB"
}
##
# Get-MemoryUsage: メモリ使用率を算出
#
# @return {String} : メモリ使用率の文字列
#
function Get-MemoryUsage() {
    return Get-CimInstance Win32_OperatingSystem | %{(($_.TotalVisibleMemorySize - $_.FreePhysicalMemory)/$_.TotalVisibleMemorySize)}
}
##
# Get-MemoryEmpty: 空きメモリ使用率を算出
#
# @return {String} : 空きメモリ使用率の文字列
#
function Get-MemoryEmpty() {
    return Get-WMIObject -Class Win32_OperatingSystem | %{ $_.FreePhysicalMemory}
}
##
# Get-MemoryPhysical: 物理メモリ容量を算出
#
# @return {String} : 物理メモリ容量の文字列
#
function Get-MemoryPhysical() {
    return Get-WmiObject -Class Win32_PhysicalMemory | %{ $_.Capacity} | Measure-Object -Sum | %{ ($_.sum / 1024)}
}

##
# Set-SyslogPriority: Syslog のプライオリティをセットする
#
# Note: Priority = Facility * 8 + Severity
#
# @param {String} $facilityFlag: ファシリティのフラグ ((M)onad, harmonic (T)riangle, (F)ormula)
# @param {String} $severityFlag: セベリティのフラグ (EM, AL, CR, ER, WA, NO, IN, DE)
#
# @return {String} : プライオリティの値の文字列
#
function Set-SyslogPriority([String]$facilityFlag, [String]$severityFlag) {
    # severity
    $severityArray = @(
        'EM',
        'AL',
        'CR',
        'ER',
        'WA',
        'NO',
        'IN',
        'DE'
    )
    $severity = $severityArray.indexof($severityFlag)

    if($severity -eq -1) {
        Show-ErrorMessage 32 $True '' $logPath
    }

    # facility
    $facilityOrigin = 0
    switch -Exact ($facilityFlag) {
        'M' {
            $facilityOrigin = 16
        }
        'T' {
            $facilityOrigin = 17
        }
        'F' {
            $facilityOrigin = 18
        }
        default {
            $facilityOrigin = 0
            Show-ErrorMessage 33 $True '' $logPath
        }
    }

    # calc
    $facility = $facilityOrigin * 8
    $priority = $facility + $severity

    return $priority.ToString()
}

##
# Set-SyslogMessage: Syslog のメッセージを組み立てる
#
# Note: Priority = Facility * 8 + Severity
#
# @param {Int} $priority: プライオリティの数値
# @param {String} $datetimeString: ログに記録する日時の文字列
# @param {String} $appName: アプリ名
# @param {String} $jsonObj: ログに記録する文字列 (JSON 化する)
#
# @return {String} : プライオリティの値の文字列
#
function Set-SyslogMessage([Int]$priority, [String]$datetimeString, [String]$appName, [Hashtable]$jsonObj) {
    # priority
    $priorityStr = '<' + $priority.ToString() + '>'
    # datetime
    $datetimeStr = ' [' + $datetimeString + ']'
    # appName
    $appNameStr = ' ' + $appName + ':'
    # jsonObj
    $jsonObjStr = ' Msg: ' + (($jsonObj | ConvertTo-Json) -replace "`r?`n", "")

    return $priorityStr + $datetimeStr + $appNameStr + $jsonObjStr
}

##
# Send-SyslogText: Syslog を送信する
#
# @param {String} $serverIP: Syslog サーバ の IP
# @param {String} $message: Syslog のメッセージ
#
# @return {Boolean} : フォーマットに一致していれば True, そうれなければ False
#
function Send-SyslogText([String]$serverIP, [String]$message) {
    # UDP port
    $port = 514

    # アセンブリがロードされていなかったらロードする
    $Lib = "System.Net"
    $AssembliesName = [Appdomain]::CurrentDomain.GetAssemblies() | % {$_.GetName().Name}
    if(-not ($AssembliesName -contains $Lib)) {
        [void][System.Reflection.Assembly]::LoadWithPartialName($Lib)
    }

    # 送信データを作る
    $ByteData = [System.Text.Encoding]::UTF8.GetBytes($message)

    # UDP ソケット作る
    $UDPSocket = $null
    $UDPSocket = New-Object System.Net.Sockets.UdpClient($serverIP, $port)

    if($null -ne $UDPSocket) {
        # 送信
        [void]$UDPSocket.Send($ByteData, $ByteData.Length)

        # ソケット Close
        $UDPSocket.Close()
    }
    else {
        # ソケット Close
        $UDPSocket.Close()
        Show-ErrorMessage 31 $True '' $logPath
    }

    return $True
}

######################
# main process       #
######################

Write-Output '処理を開始します ...' >> $logPath
Write-Output('処理開始日時: ' + $datetime) >> $logPath

# 設定ファイルの存在チェック
if (!(Assert-ExistFile $configPath)) {
    Show-ErrorMessage 11 $True '' $logPath
}

# 設定ファイル読み込み
$configData = Get-Content -Path $configPath -Raw -Encoding UTF8 | ConvertFrom-JSON

# キーの存在チェック
Assert-ConfigExistKey $configData 'serverIP'
Assert-ConfigExistKey $configData 'threshold'

# 値チェック
if (!(Assert-ConfigServerIP $configData.serverIP)) {
    Show-ErrorMessage 22 $True 'serverIP' $logPath
}
if (!(Assert-ConfigThreshold $configData.threshold)) {
    Show-ErrorMessage 22 $True 'threshold' $logPath
}

# カウンタが閾値を越えるまでループ
while($i -lt $count) {
    [String]$proceedingDatetime = Get-Date -UFormat "%Y-%m-%d %H:%M:%S +0900"
    $physicalMemory = Get-MemoryPhysical
    $emptyMemory = Get-MemoryEmpty
    $usageMemory = Get-MemoryUsage
    $memory = [Hashtable]@{
        'physicalMemory' = ('{0:N}' -f $physicalMemory) + ' KB';
        'physicalMemoryFormatted' = Format-MemorySize $physicalMemory;
        'emptyMemory' = ('{0:N}' -f $emptyMemory) + ' KB';
        'emptyMemoryFormatted' = Format-MemorySize $emptyMemory;
        'usageMemory' = ([Math]::Round($usageMemory * 100)).ToString() + ' %';
    }
    $priority = 0
    if([Math]::Round($usageMemory * 100) -gt $configData.threshold) {
        $priority = Set-SyslogPriority 'M' 'WA'
    }
    else {
        $priority = Set-SyslogPriority 'M' 'IN'
    }
    $syslogMsg = Set-SyslogMessage $priority $proceedingDatetime $appName $memory
    Send-SyslogText $configData.serverIP $syslogMsg
    Write-Output $syslogMsg >> $logPath
    # 1秒待機
    Start-Sleep -Seconds 1
    # インクリメント
    $i++
}

Write-Output '正常に処理を終了しました。' >> $logPath
Write-Output `r`n >> $logPath

[String]$finishDatetime = Get-Date -UFormat "%Y%m%d_%H%M%S"
Write-Output '処理完了' >> $logPath
Write-Output('処理完了日時: ' + $finishDatetime) >> $logPath
Write-Output `r`n >> $logPath

上述の config.json を読み込んで、

  • 物理メモリ容量 (ほぼ生と GB 単位までフォーマットしたものの2種)
  • 空きメモリ容量 (ほぼ生と GB 単位までフォーマットしたものの2種)
  • メモリ使用率

を JSON 文字列として Syslog で送信する PowerShell スクリプトを作りました。検証のためだけに無限ループで永続的に動かすのも気が引けたので、 514 秒間だけ動くようにしました。

結果

yyyy-mm-dd,hh:ii:ss,16,4, [yyyy-mm-dd hh:ii:ss +0900] Leibniz 5149: Msg: {    "emptyMemoryFormatted":  "5.00 GB",    "physicalMemoryFormatted":  "16.00 GB",    "emptyMemory":  "4,979,616.00 KB",    "physicalMemory":  "16,777,216.00 KB",    "usageMemory":  "70 %"}
yyyy-mm-dd,hh:ii:ss,16,4, [yyyy-mm-dd hh:ii:ss +0900] Leibniz 5149: Msg: {    "emptyMemoryFormatted":  "5.00 GB",    "physicalMemoryFormatted":  "16.00 GB",    "emptyMemory":  "5,160,872.00 KB",    "physicalMemory":  "16,777,216.00 KB",    "usageMemory":  "69 %"}
yyyy-mm-dd,hh:ii:ss,16,4, [yyyy-mm-dd hh:ii:ss +0900] Leibniz 5149: Msg: {    "emptyMemoryFormatted":  "5.00 GB",    "physicalMemoryFormatted":  "16.00 GB",    "emptyMemory":  "5,429,548.00 KB",    "physicalMemory":  "16,777,216.00 KB",    "usageMemory":  "67 %"}
yyyy-mm-dd,hh:ii:ss,16,4, [yyyy-mm-dd hh:ii:ss +0900] Leibniz 5149: Msg: {    "emptyMemoryFormatted":  "6.00 GB",    "physicalMemoryFormatted":  "16.00 GB",    "emptyMemory":  "6,150,420.00 KB",    "physicalMemory":  "16,777,216.00 KB",    "usageMemory":  "63 %"}
yyyy-mm-dd,hh:ii:ss,16,6, [yyyy-mm-dd hh:ii:ss +0900] Leibniz 5149: Msg: {    "emptyMemoryFormatted":  "6.00 GB",    "physicalMemoryFormatted":  "16.00 GB",    "emptyMemory":  "6,677,292.00 KB",    "physicalMemory":  "16,777,216.00 KB",    "usageMemory":  "60 %"}
yyyy-mm-dd,hh:ii:ss,16,6, [yyyy-mm-dd hh:ii:ss +0900] Leibniz 5149: Msg: {    "emptyMemoryFormatted":  "6.00 GB",    "physicalMemoryFormatted":  "16.00 GB",    "emptyMemory":  "6,677,308.00 KB",    "physicalMemory":  "16,777,216.00 KB",    "usageMemory":  "60 %"}
yyyy-mm-dd,hh:ii:ss,16,6, [yyyy-mm-dd hh:ii:ss +0900] Leibniz 5149: Msg: {    "emptyMemoryFormatted":  "6.00 GB",    "physicalMemoryFormatted":  "16.00 GB",    "emptyMemory":  "6,674,940.00 KB",    "physicalMemory":  "16,777,216.00 KB",    "usageMemory":  "60 %"}
yyyy-mm-dd,hh:ii:ss,16,6, [yyyy-mm-dd hh:ii:ss +0900] Leibniz 5149: Msg: {    "emptyMemoryFormatted":  "6.00 GB",    "physicalMemoryFormatted":  "16.00 GB",    "emptyMemory":  "6,683,996.00 KB",    "physicalMemory":  "16,777,216.00 KB",    "usageMemory":  "60 %"}
yyyy-mm-dd,hh:ii:ss,16,6, [yyyy-mm-dd hh:ii:ss +0900] Leibniz 5149: Msg: {    "emptyMemoryFormatted":  "6.00 GB",    "physicalMemoryFormatted":  "16.00 GB",    "emptyMemory":  "6,702,464.00 KB",    "physicalMemory":  "16,777,216.00 KB",    "usageMemory":  "60 %"}

実行結果のサンプルとして、検証機で実行させて上述のような結果が得られました。

特筆すべきは、途中でメモリ使用量を変化させて閾値未満にすることで 16,4 (local use 0, Warning) から 16,6 (local use 0, Information) へ Severity が変化していること。その意味ではきちんと Syslog の仕様に則ったログメッセージを送信することができている、と言えます。

これで Syslog クライアント としてきちんと動作することが検証できました。

余談

今回のサンプルスクリプトの名前ですが、ご覧の通り Leibniz 5149 としています。

直接的な元ネタはググるとすぐ出てきますが、小惑星の「ライプニッツ」です。

これは以下の過程から採用しました。

  1. Syslog の通信は UDP/514 ポートを使用して行われる。
    • 514 から「古明地こいし」。
      • こいしと言えば西洋哲学。そのため、哲学者関係のネーミングを前提としました。
  2. Powershell の開発中のコードネームが Monad だった。
  3. ライプニッツに因んで命名された小惑星「ライプニッツ」の小惑星番号が偶然 5149 だった。
    • 514 とも繋がる。
    • 検証用のコードなので、あまり複数の因子を持たない小さな概念の方が好ましいと考えました。
      • 「小」惑星であり、かつイトカワやリュウグウのような多くの情報量を持たないだろう、と判断。

以上の要因の複合結果として小惑星「ライプニッツ」。

参考

PowerShell

Syslog クライアント

イベントビューア

(参考) NXLog

『イベントログを生テキストで保存したり、syslog転送出来たらな~』と思う人は多いのか、
外部アプリを噛まして生テキスト化したり、PowerShellでevtxファイルをCSV変換する例は出てくるが、
実用に耐える物は有料アプリが大半で自宅サーバで使える様な代物は皆無な上、
PowerShell変換はもの凄く遅くてリアルタイム性に欠ける課題がある。

DigiLoog » NXLogでWindowsイベントログをsyslog転送してみた

イベントビューア対応に関しては難がありそう……とはいえ、思い付く方法は PowerShell くらいしかないですし……。

メモリ使用量

配列

switch 文

四捨五入

型変換

文字列置換 (正規表現)

数値の書式指定

JSON 文字列へ

命名規則

Syslog

メッセージフォーマット

この記事を書いた人

アルム=バンド

フロントエンド・バックエンド・サーバエンジニア。LAMPやNodeからWP、Gulpを使ってejs,Scss,JSのコーディングまで一通り。たまにRasPiで遊んだり、趣味で開発したり。