日々のオペレーションで、Windows Server のイベントログを目視確認して、見慣れないログがあったらエスカレーションする、という運用を考えてみます。これは、手動でやるとなると面倒な作業です。チェックシート通りにサーバーに RDP して、怪しいログをメモして、次のサーバーに RDP して。繰り返し同じことやって最後にサマリして・・・こういう作業は真っ先に自動化対象に上げられるでしょう。監視ソリューションを導入していて、Windows Server のイベントログもあらかじめ登録してあって、クリティカルなものを自動でメール発報する、といった体制が整っていれば今回の話はまったく必要ないのですが、なかには死活監視だけは導入しているがログ監視ソリューションまでは導入しておらず、手動でイベントログ等をチェックしているといったケースもあると思います。今回はそういう時に作業をある程度自動化するというシチュエーションを想定しています。要件は以下とします。
今回はこのようなライトな要件で、ログを集約および解析するようなソリューションを構築するといった話ではありません(今後試してみたいネタではありますが)
なにはともあれ、Windows なので PowerShell で実装することにします。私の環境は以下ですが、Windows 10であれば問題ありません。
検証済の動作環境
OS | モジュール |
---|---|
Windows 10 Pro 2004 | Windows PowerShell 5.1 (.NET Framework 4.8) |
今回は PowerShell 関数を組み合わせて作りますが、自作 PowerShell 関数は以下のリポジトリで管理しています。
そして、今回紹介するスクリプトに関しては以下のリポジトリで管理しています。
では見ていきましょう!
今回紹介するスクリプトで使う関数はすべて、GitHub リポジトリpowershell-functions
に入っています。今回はこのリポジトリをクローンし、サブフォルダの中にある各種 .ps1 ファイルをドットソースで読み込んで利用する方式を想定しています。
フォルダ構成が以下だとして、
# フォルダ構成
├─powershell-functions
│ └─scripts
└─usecase-winevent
└─scripts # --- ココが $PSScriptRoot になる
├─data
│ └─yyyy-MM-dd
└─log
こんな感じで読み込みます。
Push-Location -Path $PSScriptRoot
$FunctionsDir = "..\..\powershell-functions\scripts"
Get-ChildItem -Path $FunctionsDir | ForEach-Object -Process { .$_.FullName }
これでオリジナル関数がすべて使えるようになりました。あとはメインスクリプトに適宜組み込めばよいでしょう。
メイン関数Backup-Eventlog
です。
### Requires -Version 5.1 ###
<#
.Synopsis
Optimize Windows Eventlog for CSV format
.DESCRIPTION
Optimize Windows Eventlog for CSV Format:
- Divided Timecreated into Date and Time
- Convert from UserId to UserName,
- Compress the Message to One Line
.EXAMPLE
$Params = @{ ComputerName = "localhost"; LogName = "System", "Application"; Level = 1,2,3; Recently = 24 }
Backup-Eventlog @Params
.EXAMPLE
$Params = @{ ComputerName = "localhost"; LogName = "Security"; Level = 0; Recently = 24; EventId = 4625 }
Backup-Eventlog @Params
.EXAMPLE
$Params = @{ LogName = "System", "Application"; Level = 1,2,3; Recently = 24 }
"127.0.0.1" | Backup-Eventlog @Params
.EXAMPLE
$Params = @{ ComputerName = "localhost"; Level = 1,2,3; Recently = 24 }
"System", "Application" | Backup-Eventlog @Params
.NOTES
Author: nekrassov01
#>
Function Backup-Eventlog
{
[OutputType([System.Object])]
[CmdletBinding()]
Param
(
[Parameter(
ValueFromPipeline = $true,
ValueFromPipelineByPropertyName = $true,
Mandatory = $true
)]
[ValidateNotNullOrEmpty()]
[string]$ComputerName = "localhost",
[Parameter(
ValueFromPipeline = $true,
ValueFromPipelineByPropertyName = $true,
Mandatory = $true
)]
[ValidateSet(
"Application",
"System",
"Security"
)]
[string[]]$LogName = @("Application","System"),
[Parameter(
ValueFromPipeline = $true,
ValueFromPipelineByPropertyName = $true,
Mandatory = $true
)]
[ValidateNotNullOrEmpty()]
[int[]]$Level = @(1,2,3),
[Parameter(
ValueFromPipeline = $true,
ValueFromPipelineByPropertyName = $true,
Mandatory = $true
)]
[ValidateNotNullOrEmpty()]
[int32]$Recently = 1,
[Parameter(
ValueFromPipeline = $true,
ValueFromPipelineByPropertyName = $true,
Mandatory = $false
)]
[int32[]]$EventId
)
Begin
{
# 結果を格納する配列を作る
$Result = New-Object -TypeName System.Collections.ArrayList
}
Process
{
# Get-WinEvent に渡す連想配列を定義する
$FilterHashTable = @{
LogName = $LogName
Level = $Level
StartTime = (Get-Date).AddHours(-$Recently)
}
# EventId パラメータが指定されている場合は連想配列に Add する
If($EventId)
{
$FilterHashTable.Add("Id", $EventId)
}
# Get-WinEvent の結果を1つずつ処理する
Get-WinEvent -ComputerName $ComputerName -FilterHashTable $FilterHashTable | ForEach-Object -Process {
# カラムを定義して PSCustomObject を初期化する
$Columns = @("ComputerName","LogName","LevelId","Level","EventId","Date","Time","Source","Keyword","Opcode","Task","User","Sid","Message")
$Obj = New-Object -TypeName PSCustomObject | Select-Object $Columns
# PSCustomObject のプロパティを定義する
$Obj."ComputerName" = If($Null -ne $_.MachineName ){[string]$_.MachineName}
$Obj."LogName" = If($Null -ne $_.LogName ){[string]$_.LogName}
$Obj."LevelId" = If($Null -ne $_.Level ){[string]$_.Level}
$Obj."Level" = If($Null -ne $_.LevelDisplayName ){[string]$_.LevelDisplayName}
$Obj."EventId" = If($Null -ne $_.Id ){[string]$_.Id}
$Obj."Date" = If($Null -ne $_.TimeCreated ){[string]$_.TimeCreated.ToString("yyyy-MM-dd")}
$Obj."Time" = If($Null -ne $_.TimeCreated ){[string]$_.TimeCreated.ToString("HH:mm:ss")}
$Obj."Source" = If($Null -ne $_.ProviderName ){[string]$_.ProviderName}
$Obj."Keyword" = If($Null -ne $_.KeywordsDisplayName){[string]$_.KeywordsDisplayName}
$Obj."Opcode" = If($Null -ne $_.OpcodeDisplayName ){[string]$_.OpcodeDisplayName}
$Obj."Task" = If($Null -ne $_.TaskDisplayName ){[string]$_.TaskDisplayName}
$Obj."User" = If($Null -ne $_.UserId ){Try{[string]$_.UserId.Translate([System.Security.Principal.NTAccount])}Catch{}}
$Obj."Sid" = If($Null -ne $_.UserId ){[string]$_.UserId}
$Obj."Message" = If($Null -ne $_.Message ){[string]$_.Message.Replace("`r`n","`n").Replace("`r","`n").Replace("`n"," ").Replace("`t"," ")}
# 結果格納用の配列にオブジェクトを加算する
$Result += $Obj
}
}
End
{
# オブジェクト配列を返す
return $Result
}
}
Get-WinEvent
コマンドレットをこねくり回して、CSV で出せるように戻り値を加工しただけです。要点は以下です。
要点
これは、SID だけだと誰なのかわからないので、ユーザー名が取得できるなら取得して CSV にマッピングしましょうというものです。以下の部分です。
If($Null -ne $_.UserId){Try{[string]$_.UserId.Translate([System.Security.Principal.NTAccount])}Catch{}}
Translate
メソッドを使って、[System.Security.Principal.NTAccount]
型に変換しています。変換できる場合とできない場合について説明します。
SID からユーザー名に変換できる場合とできない場合
条件 | 可否 |
---|---|
ローカルコンピューターのローカルアカウント | できる |
ローカルコンピューターのドメインアカウント | できる |
リモートコンピューターのローカルアカウント | できない |
リモートコンピューターのドメインアカウント | できる |
すでに削除されたアカウント | できない |
リモートコンピューターのローカルアカウントが対象の場合に SID からユーザー名に変換できない理由は、ローカルコンピューターのローカルアカウント A とリモートコンピューターのローカルアカウント A が別人扱いになるからです。Get-WinEvent
は RPC プロトコルを使ってリモートコンピューターに処理を実行しますが、その際にローカル側の情報でリモート先の SID を解決しようとするため、一致せずに失敗します。これを回避するには、以下のようにGet-WMIObject
コマンドレットを使って取得する方法が考えられます。
Try
{
[string]$_.UserId.Translate([System.Security.Principal.NTAccount]).Value
}
Catch
{
(Get-WmiObject -Class Win32_UserAccount -ComputerName "RemoteComputer").Caption
}
とはいえ、これはあまりお勧めできません。その理由は、第一に、Get-WMIObject
はGet-WinEvent
と同様に RPC プロトコルでリモートコンピューターと通信するのですが、この場合はGet-WinEvent
でリモートコンピューターに対してループ処理を回している中でGet-WMIObject
コマンドレットを使うことになるので、同種の通信が二重で発生してしまい、パフォーマンス面で無駄が多くなるためです。そしてなによりダサいからです。第二に、Win32_UserAccount
クラスにアクセスすると関連する全ユーザーアカウントを列挙しますが、ドメイン環境の場合はすべてのドメインユーザーを列挙するのでめちゃくちゃ時間がかかります。こんな特殊なケースのためだけにコストをかけても仕方がないので、変換できなかった場合はそっとしておくのが無難です。ということで「ユーザー名を取得するが失敗した場合は深追いしない」という仕様になっています。
※ちなみにドメインアカウントの場合は前述の通り、別のコンピューターであっても同一ユーザーとして扱われ、SID も同一となります。このため問題なくTranslate
されます
Message を1行に圧縮しますが、replace
メソッドをチェーンしまくって対応します。イベントログのメッセージにはいろんなメタ文字が入っているので、できる限り想定して無害な文字列に変換します。
If($Null -ne $_.Message){[string]$_.Message.Replace("`r`n","`n").Replace("`r","`n").Replace("`n"," ").Replace("`t"," ")}
流れとしてはこんな感じ。
あとで復元したい場合は、スペースではなく、セミコロンとか無害な文字列にしておくのでもいいかもしれません。
ベースとなるフォルダ構成を準備しますが、フォルダが故意に消されても自動で再現してから処理するようにします。記述を簡単にするための関数を用意しています。
### Requires -Version 5.1 ###
<#
.Synopsis
Build Directory structure
.DESCRIPTION
Build Directory structure
.EXAMPLE
New-FolderConstruction -Path "folder-1", "folder-2" -Root "C:\Work"
.EXAMPLE
"folder-1", "folder-2" | New-FolderConstruction -Root "C:\Work"
.NOTES
Author: nekrassov01
#>
Function New-FolderConstruction
{
[OutputType([System.IO.FileInfo])]
[CmdletBinding()]
Param
(
[Parameter(
ValueFromPipeline = $true,
ValueFromPipelineByPropertyName = $true,
Mandatory = $true
)]
[ValidateNotNullOrEmpty()]
[string[]]$Path,
[Parameter(
Mandatory = $false
)]
[ValidateNotNullOrEmpty()]
[string]$Root = $PSScriptRoot
)
Begin
{
# 結果を格納する配列を作る
$Result = New-Object -TypeName System.Collections.ArrayList
}
Process
{
# 文字列として渡されたフォルダ名を1つずつ処理する
$Path | ForEach-Object -Process {
# 出力を入れるための PSCustomObject を初期化する
$Obj = New-Object -TypeName PSCustomObject | Select-Object -Property "FullName", "Result"
# ルートに指定されたディレクトリと渡されたフォルダ名を結合して絶対パスを作る
$Target = Join-Path -Path $Root -ChildPath $_
$Obj."FullName" = $Target
# フォルダを作成する(すでに存在していてもエラーにしない)
New-Item -Path $Target -ItemType Directory -Force | Out-Null
# 直前のコマンド結果に応じて結果を格納する
If($?)
{
$Obj."Result" = "Success"
}
Else
{
$Obj."Result" = "Error"
}
# 結果格納用の配列にオブジェクトを加算する
$Result += $Obj
}
}
End
{
# オブジェクト配列を返す
return $Result
}
}
こんな感じで使っています。
$Directories = @(
"data"
"data\$((Get-Date).ToString("yyyy-MM-dd"))-Level-1"
"log"
)
$Directories = New-FolderConstruction -Path $Directories -Root $PSScriptRoot
# 後から使うので変数に入れておく
$DataDir = $Directories[0].FullName
$MonthDir = $Directories[1].FullName
$LogDir = $Directories[2].FullName
古い CSV ファイルやログファイルのローテーションも自動化したいので、そのための関数を用意しています。
### Requires -Version 5.1 ###
<#
.Synopsis
Delete Old Folders and Files
.DESCRIPTION
Delete Old Folders and Files:
- Select Property "CreationTime" or "LastWriteTime"
.EXAMPLE
Remove-PastItem -Path "C:\Work\test-1", "C:\Work\test-2" -Day 90
.EXAMPLE
"C:\Work\test-1", "C:\Work\test-2" | Remove-PastItem -Day 90
.EXAMPLE
Remove-PastItem -Path "C:\Work\test-1", "C:\Work\test-2" -Day 90 -Property CreationTime
.EXAMPLE
"C:\Work\test-1", "C:\Work\test-2" | Remove-PastItem -Day 90 -Property CreationTime
.EXAMPLE
Remove-PastItem -Path "C:\Work\test-1", "C:\Work\test-2" -Day 90 -Property LastWriteTime
.EXAMPLE
"C:\Work\test-1", "C:\Work\test-2" | Remove-PastItem -Day 90 -Property LastWriteTime
.NOTES
Author: nekrassov01
#>
Function Remove-PastItem
{
[OutputType([System.Object])]
[CmdletBinding()]
Param
(
[Parameter(
ValueFromPipeline = $true,
ValueFromPipelineByPropertyName = $true,
Mandatory = $true
)]
[ValidateNotNullOrEmpty()]
[string[]]$Path,
[Parameter(
ValueFromPipeline = $true,
ValueFromPipelineByPropertyName = $true,
Mandatory = $true
)]
[ValidateNotNullOrEmpty()]
[int]$Day,
[Parameter(
Mandatory = $false
)]
[ValidateSet(
"CreationTime",
"LastWriteTime"
)]
[string]$Property = "CreationTime"
)
Begin
{
# 結果を格納する配列を作る
$Result = New-Object -TypeName System.Collections.ArrayList
}
Process
{
# 配列で渡されたフォルダを1つずつ処理する
$Path | ForEach-Object -Process {
$Target = Get-ChildItem -Path $_ -Recurse
# 基準にするプロパティを決める
If($Property -eq "LastWriteTime")
{
# 「最終更新日時」を基準にする
$Target = $Target | Where-Object -FilterScript { $_.LastWriteTime -le (Get-Date).AddDays(-$Day) }
}
Else
{
# 「作成日時」を基準にする
$Target = $Target | Where-Object -FilterScript { $_.CreationTime -le (Get-Date).AddDays(-$Day) }
}
$Target | ForEach-Object -Process {
$Obj = New-Object -TypeName PSCustomObject | Select-Object -Property "FullName", "Property", "Result"
$Obj."FullName" = $_.FullName
$Obj."Property" = $Property
# 条件に該当するファイル・フォルダを削除する
$_ | Remove-Item -Force -Recurse #-WhatIf
# 直前のコマンド結果に応じて結果を格納する
If($?)
{
$Obj."Result" = "Success"
}
Else
{
$Obj."Result" = "Error"
}
# 結果格納用の配列にオブジェクトを加算する
$Result += $Obj
}
}
}
End
{
# オブジェクト配列を返す
return $Result
}
}
経過日数を計算する際、CreationTime
とLastWriteTime
のうちどちらのプロパティを基準にするかを指定できます。デフォルト値はCreationTime
です。
Remove-PastItem -Path $DataDir, $LogDir -Day 365 -Property CreationTime
ログに書き出す際は出力行ごとにプレフィックスとして現在時刻が挿入されるようにしたいので、そのための関数を用意しています。
### Requires -Version 5.1 ###
<#
.Synopsis
Display DateTime on the Console Output
.DESCRIPTION
Display DateTime on the Console Output
.EXAMPLE
Out-Log -String "test"
.EXAMPLE
Out-Log "test"
.NOTES
Author: nekrassov01
#>
Function Out-Log
{
[OutputType([System.String])]
[CmdletBinding()]
Param
(
[Parameter(
ValueFromPipeline = $true,
ValueFromPipelineByPropertyName = $true,
Mandatory = $true
)]
[ValidateNotNullOrEmpty()]
[string]$String
)
Process
{
$LogTime = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
Write-Host "[${LogTime}] ${String}"
}
End
{
# 変数はクリアしておく
Clear-Item -Path Variable:LogTime
Clear-Item -Path Variable:String
}
}
これは地味に便利です。個人的にお気に入りで多用しています。主としてログファイルへの書き出しで利用することが多いので、色付け等の機能は実装していません。
PS C:\WINDOWS\system32> Out-Log "処理を実行しました"
[2020-11-15 21:53:59] 処理を実行しました
使っている自作関数を紹介し終わったので、メインスクリプトを見ていきます。3パターン用意しました。
基本となるパターンです。外部ファイル等もなくスクリプト単独で完結させます。出力は1ファイルでまとめて出します。
Push-Location -Path $PSScriptRoot
$FunctionsDir = "..\..\powershell-functions\scripts"
Get-ChildItem -Path $FunctionsDir | ForEach-Object -Process { .$_.FullName }
$Directories = @(
"data"
"data\$((Get-Date).ToString("yyyy-MM-dd"))"
"log"
)
$Directories = New-FolderConstruction -Path $Directories -Root $PSScriptRoot
$DataDir = $Directories[0].FullName
$MonthDir = $Directories[1].FullName
$LogDir = $Directories[2].FullName
Start-Transcript -Path "${LogDir}\$((Get-Date).ToString("yyyyMMddHHmmss")).log" -Force | Out-Null
Remove-PastItem -Path $DataDir, $LogDir -Day 365 -Property CreationTime
$Params = @(
@{
ComputerName = "remotehost1"
LogName = "System", "Application"
Level = 1,2,3
Recently = 24
}
@{
ComputerName = "remotehost1"
LogName = "Security"
Level = 0
Recently = 24
EventId = 4625
}
@{
ComputerName = "remotehost2"
LogName = "System", "Application"
Level = 1,2,3
Recently = 24
}
@{
ComputerName = "remotehost2"
LogName = "Security"
Level = 0
Recently = 24
EventId = 4625
}
)
$Result = @()
$Params | ForEach-Object -Process {
$Ping = Test-Connection -ComputerName $_.ComputerName -Quiet
If($Ping)
{
$Output = Backup-Eventlog @_ -ErrorAction SilentlyContinue
$Output = $Output | Sort-Object -Property LogName, LevelId, Date, Time
$Result += $Output
Out-Log "Done: $($_.ComputerName) - $($_.LogName -join ", ")"
}
Else
{
Out-Log "No Reachable: $($_.ComputerName) - $($_.LogName -join ", ")"
}
}
$Result | Export-Csv -Path "${MonthDir}\eventlog.csv" -Encoding Default -Force -NoTypeInformation
Pop-Location
Stop-Transcript | Out-Null
Get-Variable | Remove-Variable -ErrorAction SilentlyContinue
コメントがない代わりにポイントを説明します。まず、このパターンは必要なパラメータを連想配列(HashTable)で定義しています。
$Params = @(
@{
ComputerName = "remotehost1"
LogName = "System", "Application"
Level = 1,2,3
Recently = 24
}
@{
ComputerName = "remotehost1"
LogName = "Security"
Level = 0
Recently = 24
EventId = 4625
}
@{
ComputerName = "remotehost2"
LogName = "System", "Application"
Level = 1,2,3
Recently = 24
}
@{
ComputerName = "remotehost2"
LogName = "Security"
Level = 0
Recently = 24
EventId = 4625
}
)
定義した連想配列は、Get-WinEvent の持つ -FilterHashTable パラメータにそのまま指定できます。したがって、ループで回すときはこのように記述できます。
$Params | ForEach-Object -Process { Backup-Eventlog @_ }
めちゃくちゃすっきり書けますね。ループで回すときですが、対象ホストに Ping を飛ばして、疎通が取れる場合にだけメイン処理を実行するようにしました。こうすることで、一部のサーバーを撤去したけどパラメータをメンテナンスするのを忘れていた、といったケースでも安全に読み飛ばすことができます。
$Params | ForEach-Object -Process {
# -Quiet スイッチをつけることで戻り値が Boolean 型になる
$Ping = Test-Connection -ComputerName $_.ComputerName -Quiet
If($Ping)
{
Backup-Eventlog @_
Out-Log "Done: $($_.ComputerName) - $($_.LogName -join ", ")"
}
Else
{
Out-Log "No Reachable: $($_.ComputerName) - $($_.LogName -join ", ")"
}
}
あとは、コンソール出力をログとしてキャプチャしたいので、Transcript しておきましょう。「トランスクリプトが開始されました / 停止されました」の文言がログに記録されないように、Out-Null
コマンドレットにパイプして出力を破棄しています。
Start-Transcript -Path "${LogDir}\$((Get-Date).ToString("yyyyMMddHHmmss")).log" -Force | Out-Null
・・・
Stop-Transcript | Out-Null
全体を見るに、自作関数のおかげでだいぶ記述を簡潔にすることができています。
続いて、パラメータを外部ファイルにストアしておくパターンです。パラメータをスクリプトに直書きするのは、ある程度の規模になってくると管理が厳しいと思います。今回は JSON 形式でテキストとして保持しておき、スクリプトで使うときに変換して利用する方式を紹介します。まずは全体像です。
Push-Location -Path $PSScriptRoot
$FunctionsDir = "..\..\powershell-functions\scripts"
Get-ChildItem -Path $FunctionsDir | ForEach-Object -Process { .$_.FullName }
$Directories = @(
"data"
"data\$((Get-Date).ToString("yyyy-MM-dd"))"
"log"
)
$Directories = New-FolderConstruction -Path $Directories -Root $PSScriptRoot
$DataDir = $Directories[0].FullName
$MonthDir = $Directories[1].FullName
$LogDir = $Directories[2].FullName
Start-Transcript -Path "${LogDir}\$((Get-Date).ToString("yyyyMMddHHmmss")).log" -Force | Out-Null
Remove-PastItem -Path $DataDir, $LogDir -Day 365 -Property CreationTime
$Json = Get-Content -Path ".\params.json"
$Params = @()
($Json | ConvertFrom-Json).Params | ForEach-Object -Process {
$Serializer = New-Object -TypeName System.Web.Script.Serialization.JavaScriptSerializer
$Hashtable = $Serializer.Deserialize(($_ | ConvertTo-Json), [System.Collections.Hashtable])
$Params += $Hashtable
}
$Result = @()
$Params | ForEach-Object -Process {
$Ping = Test-Connection -ComputerName $_.ComputerName -Quiet
If($Ping)
{
$Output = Backup-Eventlog @_ -ErrorAction SilentlyContinue
$Output = $Output | Sort-Object -Property LogName, LevelId, Date, Time
$Result += $Output
Out-Log "Done: $($_.ComputerName) - $($_.LogName -join ", ")"
}
Else
{
Out-Log "No Reachable: $($_.ComputerName) - $($_.LogName -join ", ")"
}
}
$Result | Export-Csv -Path "${MonthDir}\eventlog.csv" -Encoding Default -Force -NoTypeInformation
Pop-Location
Stop-Transcript | Out-Null
Get-Variable | Remove-Variable -ErrorAction SilentlyContinue
連想配列の記述が、JSON を変換する記述に差し替わっているだけです。この変換は、いちばんややこしい部分かもしれません。まず JSON の中身です。
# params.json
{
"Params": [
{
"ComputerName": "remotehost1",
"LogName": ["System", "Application"],
"Level": ["1", "2", "3"],
"Recently": "24"
},
{
"ComputerName": "remotehost1",
"LogName": ["Security"],
"Level": ["0"],
"Recently": "24",
"EventId": ["4625"]
},
{
"ComputerName": "remotehost2",
"LogName": ["System", "Application"],
"Level": ["1", "2", "3"],
"Recently": "24"
},
{
"ComputerName": "remotehost2",
"LogName": ["Security"],
"Level": ["0"],
"Recently": "24",
"EventId": ["4625"]
}
]
}
なんの変哲もない JSON です。これを連想配列にするコードは以下です。
# JSON の中身を読み込む
$Json = Get-Content -Path ".\params.json"
# 出力用の配列を初期化する
$Params = @()
# JSON を PSCustomObject に変換して Params の中身をひとつずつ処理する
($Json | ConvertFrom-Json).Params | ForEach-Object -Process {
# JSON をシリアライズ / デシリアライズできる .NET クラスを初期化
$Serializer = New-Object -TypeName System.Web.Script.Serialization.JavaScriptSerializer
# PSCustomObject のひとつを JSON に戻し、型に連想配列を指定してデシリアライズする
$Hashtable = $Serializer.Deserialize(($_ | ConvertTo-Json), [System.Collections.Hashtable])
# 連想配列を出力用の配列に加算する
$Params += $Hashtable
}
コメントを見ていただければわかりますが、面倒です。なぜ面倒なのかというと、以下が成立しないためです。これをやると間違ったオブジェクトができあがってしまいます。
$Json = Get-Content -Path ".\params.json"
$Serializer = New-Object -TypeName System.Web.Script.Serialization.JavaScriptSerializer
$Params = $Serializer.Deserialize($Json, [System.Collections.Hashtable])
JSON が以下のような単純な構造なら問題なく変換できるのですが、今回のような JSON 配列の場合はその要素ごとに変換をかける必要があるようです。
{
"ComputerName": "remotehost-1",
"LogName": ["System", "Application"],
"Level": ["1", "2", "3"],
"Recently": "24"
}
PowerShell は単なるテキストである JSON を直接解釈することができないので、JSON 配列を要素ごとに処理するためには PowerShell が解釈できるなんらかのデータ構造に変換してあげる必要があります。JSON => HashTable が機能しない状況下でいちばん簡単なのは、PSCustomObject を経由して JSON <=> HashTable を変換する方法です。流れを整理すると以下の感じです。
ここまで説明してアレですが、こんなやり方をしなければならないのは、PowerShell のバージョンが 5.1 だからです。6以上の PowerShell では、ConvertFrom-Json
に-AsHashtable
という超絶便利なパラメータが追加されているようです。いつまでも 5.1 使っているとこういうことになるわけですね・・
# PowerShell 6 以上だとこれですむらしい
($Json | ConvertFrom-Json -AsHashtable).Params
最後の事例ですが、これはログの種類ごとに CSV を出し分けしたいといった場合にどうやるかです。ForEach のネストをひとつ深くすることになります。
Push-Location -Path $PSScriptRoot
$FunctionsDir = "..\..\powershell-functions\scripts"
Get-ChildItem -Path $FunctionsDir | ForEach-Object -Process { .$_.FullName }
$Directories = @(
"data"
"data\$((Get-Date).ToString("yyyy-MM-dd"))"
"log"
)
$Directories = New-FolderConstruction -Path $Directories -Root $PSScriptRoot
$DataDir = $Directories[0].FullName
$MonthDir = $Directories[1].FullName
$LogDir = $Directories[2].FullName
Start-Transcript -Path "${LogDir}\$((Get-Date).ToString("yyyyMMddHHmmss")).log" -Force | Out-Null
Remove-PastItem -Path $DataDir, $LogDir -Day 365 -Property CreationTime
$Json = Get-Content -Path ".\params.json"
$Params = @()
($Json | ConvertFrom-Json).Params | ForEach-Object -Process {
$Serializer = New-Object -TypeName System.Web.Script.Serialization.JavaScriptSerializer
$Hashtable = $Serializer.Deserialize(($_ | ConvertTo-Json), [System.Collections.Hashtable])
$Params += $Hashtable
}
$Result = @()
$Params | ForEach-Object -Process {
$ComputerName = $_.ComputerName
$LogName = $_.LogName
$Level = $_.Level
$Recently = $_.Recently
$EventId = $_.EventId
$LogName | ForEach-Object -Process {
$Params = @{
ComputerName = $ComputerName
LogName = $_
Level = $Level
Recently = $Recently
EventId = $EventId
}
$Ping = Test-Connection -ComputerName $ComputerName -Quiet
If($Ping)
{
$Output = Backup-Eventlog @Params -ErrorAction SilentlyContinue
$Output | Export-Csv -Path "${MonthDir}\${ComputerName}.${_}.csv" -Encoding Default -Force -NoTypeInformation
$Result += $Output
Out-Log "Done: ${ComputerName} - ${_}"
}
Else
{
Out-Log "No Reachable: ${ComputerName} - ${_}"
}
}
}
$Result | Export-Csv -Path "${MonthDir}\eventlog.csv" -Encoding Default -Force -NoTypeInformation
Pop-Location
Stop-Transcript | Out-Null
Get-Variable | Remove-Variable -ErrorAction SilentlyContinue
もはや特筆すべきポイントもさほどありませんが、強いて言えば、ネストしたForEach-Object
で親ループの$_.[Property]
を子ループ内で使いたい場合は、親ループの$_.[Property]
を変数に入れておきましょう、ということくらいでしょうか。親ループの予約変数$_
が子ループ内で上書きされてしまうことの対策です。ちなみに、パイプするオブジェクトの親玉がコマンドレットである場合は-PipelineVariable
パラメータが使えるのですが、今回は配列からパイプするパターンなのでそれができず、以下のようになっています。
$Params | ForEach-Object -Process {
$ComputerName = $_.ComputerName
$LogName = $_.LogName
$Level = $_.Level
$Recently = $_.Recently
$EventId = $_.EventId
$LogName | ForEach-Object -Process {
$_Params = @{
ComputerName = $ComputerName
LogName = $_
Level = $Level
Recently = $Recently
EventId = $EventId
}
Backup-Eventlog @_Params
}
}
実際のユースケースでは LogName ごとのファイルも全体のファイルも両方出しておくのが無難な気もします(上記スクリプトはそうなっています)
最後になりますが、注意点です。リモートコンピューターに対してGet-WinEvent
したときに「RPC サーバを使用できません」というエラーが出た場合は、Windows ファイアウォールで通信がブロックされている可能性が高いです。その場合は一度ファイアウォールを無効にしてみて、エラーが発生するかどうかを確認してみましょう。成功する場合は、こちらやこちらを参照して、差分をチェックしながらファイアウォールに穴開けしてみてください。ちなみに私は RPC の動的ポートを開ける必要がありました。