From ffee95f88069eec8c9f410c3e96c8d97396f88c3 Mon Sep 17 00:00:00 2001 From: LordHepipud Date: Tue, 6 Nov 2018 17:14:49 +0100 Subject: [PATCH] Initial commit --- README.md | 30 ++ core/config.ps1 | 142 +++++++ core/include/APIResponse.ps1 | 100 +++++ core/include/App.ps1 | 15 + core/include/Checker.ps1 | 108 +++++ core/include/ClientJobs.ps1 | 286 +++++++++++++ core/include/ClientProtocol.ps1 | 225 +++++++++++ core/include/Config.ps1 | 24 ++ core/include/Enums.ps1 | 83 ++++ core/include/Log.ps1 | 128 ++++++ core/include/NetworkProtocol.ps1 | 192 +++++++++ core/include/PidManager.ps1 | 149 +++++++ core/include/ServerProtocol.ps1 | 223 +++++++++++ core/include/Service.ps1 | 183 +++++++++ core/include/System.ps1 | 23 ++ core/include/TCPDaemon.ps1 | 76 ++++ core/include/TCPSocket.ps1 | 161 ++++++++ core/include/Utils.ps1 | 12 + core/include/utils/AdminShell.ps1 | 12 + core/include/utils/AuthHelper.ps1 | 63 +++ core/include/utils/IniParser.ps1 | 75 ++++ core/include/utils/Modules.ps1 | 154 +++++++ core/include/utils/SSL.ps1 | 115 ++++++ core/include/utils/SecureString.ps1 | 29 ++ core/include/utils/WebHelper.ps1 | 296 ++++++++++++++ core/init.ps1 | 94 +++++ core/monitoring.ps1 | 118 ++++++ core/perfcounter.ps1 | 577 +++++++++++++++++++++++++++ core/setup.ps1 | 97 +++++ doc/01-Introduction.md | 19 + doc/02-Installation.md | 59 +++ doc/10-InstallService.md | 49 +++ doc/11-IcingaWeb2Integration.md | 40 ++ icinga-module-windows.psd1 | 78 ++++ icinga-module-windows.psm1 | 286 +++++++++++++ modules/bios.ps1 | 21 + modules/certificates.ps1 | 112 ++++++ modules/cpu.ps1 | 18 + modules/disk.ps1 | 96 +++++ modules/hardware.ps1 | 6 + modules/include/hardware/cpu.ps1 | 19 + modules/include/hardware/disks.ps1 | 70 ++++ modules/include/hardware/memory.ps1 | 59 +++ modules/include/updates/hotfixes.ps1 | 27 ++ modules/include/updates/pending.ps1 | 76 ++++ modules/include/updates/updates.ps1 | 72 ++++ modules/memory.ps1 | 33 ++ modules/network.ps1 | 154 +++++++ modules/ntp.ps1 | 34 ++ modules/process.ps1 | 113 ++++++ modules/services.ps1 | 62 +++ modules/updates.ps1 | 6 + modules/windows.ps1 | 16 + 53 files changed, 5315 insertions(+) create mode 100644 README.md create mode 100644 core/config.ps1 create mode 100644 core/include/APIResponse.ps1 create mode 100644 core/include/App.ps1 create mode 100644 core/include/Checker.ps1 create mode 100644 core/include/ClientJobs.ps1 create mode 100644 core/include/ClientProtocol.ps1 create mode 100644 core/include/Config.ps1 create mode 100644 core/include/Enums.ps1 create mode 100644 core/include/Log.ps1 create mode 100644 core/include/NetworkProtocol.ps1 create mode 100644 core/include/PidManager.ps1 create mode 100644 core/include/ServerProtocol.ps1 create mode 100644 core/include/Service.ps1 create mode 100644 core/include/System.ps1 create mode 100644 core/include/TCPDaemon.ps1 create mode 100644 core/include/TCPSocket.ps1 create mode 100644 core/include/Utils.ps1 create mode 100644 core/include/utils/AdminShell.ps1 create mode 100644 core/include/utils/AuthHelper.ps1 create mode 100644 core/include/utils/IniParser.ps1 create mode 100644 core/include/utils/Modules.ps1 create mode 100644 core/include/utils/SSL.ps1 create mode 100644 core/include/utils/SecureString.ps1 create mode 100644 core/include/utils/WebHelper.ps1 create mode 100644 core/init.ps1 create mode 100644 core/monitoring.ps1 create mode 100644 core/perfcounter.ps1 create mode 100644 core/setup.ps1 create mode 100644 doc/01-Introduction.md create mode 100644 doc/02-Installation.md create mode 100644 doc/10-InstallService.md create mode 100644 doc/11-IcingaWeb2Integration.md create mode 100644 icinga-module-windows.psd1 create mode 100644 icinga-module-windows.psm1 create mode 100644 modules/bios.ps1 create mode 100644 modules/certificates.ps1 create mode 100644 modules/cpu.ps1 create mode 100644 modules/disk.ps1 create mode 100644 modules/hardware.ps1 create mode 100644 modules/include/hardware/cpu.ps1 create mode 100644 modules/include/hardware/disks.ps1 create mode 100644 modules/include/hardware/memory.ps1 create mode 100644 modules/include/updates/hotfixes.ps1 create mode 100644 modules/include/updates/pending.ps1 create mode 100644 modules/include/updates/updates.ps1 create mode 100644 modules/memory.ps1 create mode 100644 modules/network.ps1 create mode 100644 modules/ntp.ps1 create mode 100644 modules/process.ps1 create mode 100644 modules/services.ps1 create mode 100644 modules/updates.ps1 create mode 100644 modules/windows.ps1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e9c4c44 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +Icinga Module for Windows +============== + +## This Module is a Technical-Preview and should **NOT** be used actively in production! + +This repository provides a PowerShell Module to fetch information and data of Windows Hosts, allowing a wide range of integrations + +* Remote Execeution +* Icinga Web 2 integration +* Usage with the Icinga 2 Agent +* ... + +Before you continue, please take a look on the [installation guide](doc/02-Installation.md) + +Documentation +------------- + +Please take a look on the following content to get to know the possibilities of the module including examples on how to use it. + +* [Introduction](doc/01-Introduction.md) +* [Installation Guide](doc/02-Installation.md) + +Contributing +------------ + +The Icinga 2 PowerShell module is an Open Source project and lives from your contributions. No matter whether these are feature requests, issues, translations, documentation or code. + +* Please check whether a related issue alredy exists on our [Issue Tracker](https://github.com/LordHepipud/icinga-module-windows/issues) +* Send a [Pull Request](https://github.com/LordHepipud/icinga-module-windows/pulls) +* The master branche shall never be corrupt! \ No newline at end of file diff --git a/core/config.ps1 b/core/config.ps1 new file mode 100644 index 0000000..0f80ab4 --- /dev/null +++ b/core/config.ps1 @@ -0,0 +1,142 @@ +param( + [string]$AddKey = '', + [Object]$AddValue = '', + [string]$GetConfig = '', + [string]$RemoveConfig = '', + [boolean]$ListConfig = $FALSE, + [boolean]$Reload = $FALSE +); + +function ClassConfig() +{ + param( + [string]$AddKey = '', + [Object]$AddValue = '', + [string]$GetConfig = '', + [string]$RemoveConfig = '', + [boolean]$ListConfig = $FALSE, + [boolean]$Reload = $FALSE + ); + + $instance = New-Object -TypeName PSObject; + + $instance | Add-Member -membertype NoteProperty -name 'ConfigDirectory' -value (Join-Path $Icinga2.App.RootPath -ChildPath 'agent\config'); + $instance | Add-Member -membertype NoteProperty -name 'ConfigFile' -value (Join-Path $instance.ConfigDirectory -ChildPath 'config.conf'); + + $instance | Add-Member -membertype ScriptMethod -name 'Init' -value { + if ($ListConfig) { + return $this.DumpConfig(); + } + + if ($Reload) { + return $this.ReloadConfig(); + } + + if ([string]::IsNullOrEmpty($GetConfig) -eq $FALSE) { + return $this.GetAttribute(); + } + + if ([string]::IsNullOrEmpty($AddKey) -eq $FALSE) { + return $this.SetAttribute(); + } + + if ([string]::IsNullOrEmpty($RemoveConfig) -eq $FALSE) { + return $this.RemoveAttribute(); + } + + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Info, + '{ Invalid or insufficient arguments specified. }' + ); + return 1; + } + + $instance | Add-Member -membertype ScriptMethod -name 'ReloadConfig' -value { + $Icinga2.Config = & (Join-Path $Icinga2.App.RootPath -ChildPath '\core\include\Config.ps1'); + } + + $instance | Add-Member -membertype ScriptMethod -name 'WriteConfig' -value { + If ((Test-Path ($this.ConfigDirectory)) -eq $FALSE) { + $Icinga2.Log.WriteConsole( + $Icinga2.Enums.LogState.Warning, + 'Config Directory is not present. Please run "Icinga-Setup" for the base installation' + ); + return 1; + } + $config = ConvertTo-Json $Icinga2.Config -Depth 100; + [System.IO.File]::WriteAllText($this.ConfigFile, $config); + return 0; + } + + $instance | Add-Member -membertype ScriptMethod -name 'DumpConfig' -value { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Info, + ([string]::Format('Config location: {0}', $this.ConfigFile)) + ); + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Info, + $Icinga2.Config + ); + return 0; + } + + $instance | Add-Member -membertype ScriptMethod -name 'GetAttribute' -value { + return $Icinga2.Config.$GetConfig; + } + + $instance | Add-Member -membertype ScriptMethod -name 'SetAttribute' -value { + $value = $AddValue; + + if ([string]::IsNullOrEmpty($AddValue)) { + $value = $null; + } + + if ([bool]($Icinga2.Config.PSobject.Properties.Name -eq $AddKey) -eq $FALSE) { + $Icinga2.Config | Add-Member -membertype NoteProperty -name $AddKey -value $value; + } else { + $Icinga2.Config.$AddKey = $value; + } + + if ($this.WriteConfig() -eq 0) { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Info, + ([string]::Format('{0} Set config attribute "{1}" to "{2}. {3}', '{', $AddKey, $value, '}')) + ); + return 0; + } + + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Error, + ([string]::Format('{0} Unable to write config file to disk. Failed to update attribute "{1}" to "{2}. {3}', '{', $AddKey, $value, '}')) + ); + return 1; + } + + $instance | Add-Member -membertype ScriptMethod -name 'RemoveAttribute' -value { + if ([bool]($Icinga2.Config.PSobject.Properties.Name -eq $RemoveConfig) -eq $TRUE) { + $Icinga2.Config.PSobject.Members.Remove($RemoveConfig); + if ($this.WriteConfig() -eq 0) { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Info, + ([string]::Format('{0} Successfully removed config attribute "{1}" {2}', '{', $RemoveConfig, '}')) + ); + return 0; + } + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Error, + ([string]::Format('{0} Config attribute "{1}" was removed, but storing the new config file failed. {2}', '{', $RemoveConfig, '}')) + ); + return 1; + } + + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Warning, + ([string]::Format('{0} Unable to remove attribute "{1}". Attribute not found {2}', '{', $RemoveConfig, '}')) + ); + return 1; + } + + return $instance.Init(); +} + +return ClassConfig -AddKey $AddKey -AddValue $AddValue -GetConfig $GetConfig -RemoveConfig $RemoveConfig -ListConfig $ListConfig -Reload $Reload; \ No newline at end of file diff --git a/core/include/APIResponse.ps1 b/core/include/APIResponse.ps1 new file mode 100644 index 0000000..12137f3 --- /dev/null +++ b/core/include/APIResponse.ps1 @@ -0,0 +1,100 @@ +$APIResponse = New-Object -TypeName PSObject; + +$APIResponse | Add-Member -membertype NoteProperty -name 'static' -value $FALSE; +$APIResponse | Add-Member -membertype NoteProperty -name 'statuscode' -value 200; +$APIResponse | Add-Member -membertype NoteProperty -name 'message' -value ''; +$APIResponse | Add-Member -membertype NoteProperty -name 'content' -value $null; +$APIResponse | Add-Member -membertype NoteProperty -name 'authheader' -value ''; + +$APIResponse | Add-Member -membertype ScriptMethod -name 'setContent' -value { + param([object]$content); + + $this.content = $content; +} + +$APIResponse | Add-Member -membertype ScriptMethod -name 'CustomBadRequest' -value { + param([string]$message); + + $this.statuscode = 400; + $this.message = $message; +} + +$APIResponse | Add-Member -membertype ScriptMethod -name 'InternalServerError' -value { + $this.statuscode = 500; + $this.message = 'An internal server error occured while parsing your request.'; +} + +$APIResponse | Add-Member -membertype ScriptMethod -name 'HTTPSRequired' -value { + $this.statuscode = 403; + $this.message = 'This API only supports connections over HTTPS.'; +} + +$APIResponse | Add-Member -membertype ScriptMethod -name 'AuthenticationRequired' -value { + $this.statuscode = 401; + $this.message = 'You require to login in order to access this ressource.'; + $this.authheader = [string]::Format( + 'WWW-Authenticate: Basic realm="Icinga Windows Daemon"{0}', + "`r`n" + ); +} + +$APIResponse | Add-Member -membertype ScriptMethod -name 'CompileMessage' -value { + # If our message is empty, do nothing + if ([string]::IsNullOrEmpty($this.message)) { + return; + } + + # In case we assigned custom content, do not override this content + if ($this.content -ne $null) { + return; + } + + $this.content = @{ + response = $this.statuscode; + message = $this.message; + }; +} + +$APIResponse | Add-Member -membertype ScriptMethod -name 'Compile' -value { + + $this.CompileMessage(); + + [string]$ContentLength = ''; + [string]$HTMLContent = ''; + if ($this.content -ne $null) { + $json = ConvertTo-Json $this.content -Depth 100 -Compress; + $bytes = [System.Text.Encoding]::UTF8.GetBytes($json); + $HTMLContent = [System.Text.Encoding]::UTF8.GetString($bytes); + if ($bytes.Length -gt 0) { + $ContentLength = [string]::Format( + 'Content-Length: {0}{1}', + $bytes.Length, + "`r`n" + ); + } + } + + return -Join( + [string]::Format( + 'HTTP/1.1 {0} {1}{2}', + $this.statuscode, + $Icinga2.Enums.HttpStatusCodes.$this.statuscode, + "`r`n" + ), + [string]::Format( + 'Server: {0}{1}', + (Get-WmiObject Win32_ComputerSystem).Name, + "`r`n" + ), + [string]::Format( + 'Content-Type: application/json{0}', + "`r`n" + ), + $this.authheader, + $ContentLength, + "`r`n", + $HTMLContent + ); +} + +return $APIResponse; \ No newline at end of file diff --git a/core/include/App.ps1 b/core/include/App.ps1 new file mode 100644 index 0000000..8bc5082 --- /dev/null +++ b/core/include/App.ps1 @@ -0,0 +1,15 @@ +# App configuration +$App = @{ + LogSeverity = [PSCustomObject]@{ + PSTypeName = "LogSeverity" + Info = 0 + Warning = 1 + Error = 2 + Exception = 3 + Debug = 4 + }; + RootPath = $_InternalTempVariables.RootPath; + ModuleName = $_InternalTempVariables.ModuleName; +} + +return $App; \ No newline at end of file diff --git a/core/include/Checker.ps1 b/core/include/Checker.ps1 new file mode 100644 index 0000000..a13c6b1 --- /dev/null +++ b/core/include/Checker.ps1 @@ -0,0 +1,108 @@ +$Checker = New-Object -TypeName PSObject; + +$Checker | Add-Member -membertype NoteProperty -name 'os' -value ''; +$Checker | Add-Member -membertype NoteProperty -name 'version' -value ''; +$Checker | Add-Member -membertype NoteProperty -name 'fqdn' -value ''; +$Checker | Add-Member -membertype NoteProperty -name 'bind' -value 'wdt'; +$Checker | Add-Member -membertype NoteProperty -name 'time_offset' -value 0; + +$Checker | Add-Member -membertype ScriptMethod -name 'Start' -value { + + $Icinga2.PidManager.StopProcessByBind($this.bind); + + Start-Sleep 1; + + $Icinga2.PidManager.CreatePidFile($this.bind); + + $WindowsInformations = Get-CimInstance Win32_OperatingSystem; + $this.version = $WindowsInformations.CimInstanceProperties['Version'].Value; + $this.os = $WindowsInformations.CimInstanceProperties['Caption'].Value; + $this.fqdn = [string]::Format( + '{0}.{1}', + (Get-WmiObject Win32_ComputerSystem).DNSHostName, + (Get-WmiObject win32_computersystem).Domain + ); + + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Info, + 'Starting checker component of module.' + ); + + $Icinga2.ClientProtocol.setFQDN($this.fqdn); + $Icinga2.Cache.Checker.ModuleScheduler = @{ }; + + while($true) { + + $StopWatchHandler = [System.Diagnostics.StopWatch]::StartNew() + $this.ScheduleWindowsHello($FALSE); + $this.UpdateModuleTimer(); + $Icinga2.ClientJobs.ParseJobResults(); + + # This part will help us to keep the gap between module execution as low as possible + # We will check how many seconds have been passed while the modules were executed + # This value will then be added to our module timings, ensuring that in general + # they will become executed right on time + $StopWatchHandler.Stop(); + $this.time_offset = [math]::Round($StopWatchHandler.Elapsed.TotalSeconds, 0); + + Start-Sleep -Seconds 1; + } + + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Info, + 'Stopping checker component of module.' + ); +} + +$Checker | Add-Member -membertype ScriptMethod -name 'UpdateModuleTimer' -value { + if ($Icinga2.Cache.Checker.ModuleConfig -eq $null) { + return; + } + + foreach ($module in $Icinga2.Cache.Checker.ModuleConfig.Keys) { + if ($Icinga2.Cache.Checker.ModuleScheduler.ContainsKey($module) -eq $FALSE) { + $Icinga2.Cache.Checker.ModuleScheduler.Add($module, 0); + } else { + $Icinga2.Cache.Checker.ModuleScheduler[$module] += (1 + $this.time_offset); + + if ($Icinga2.Cache.Checker.ModuleScheduler[$module] -ge $Icinga2.Cache.Checker.ModuleConfig[$module]) { + $Icinga2.Cache.Checker.ModuleScheduler[$module] = 0; + $this.ScheduleModuleJob($module); + } + } + } + $this.time_offset = 0; +} + +$Checker | Add-Member -membertype ScriptMethod -name 'ScheduleModuleJob' -value { + param([string]$module); + + $Icinga2.ClientJobs.ScheduleJob($module); +} + +$Checker | Add-Member -membertype ScriptMethod -name 'ScheduleWindowsHello' -value { + param([bool]$force); + $this.WriteLogOutput($Icinga2.ClientJobs.WindowsHello( + $this.os, + $this.fqdn, + $this.version, + $force + )); +} + +$Checker | Add-Member -membertype ScriptMethod -name 'WriteLogOutput' -value { + param($response); + + if ($response -ne $null) { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Debug, + $response + ); + } +} + +$Checker | Add-Member -membertype ScriptMethod -name 'Stop' -value { + $Icinga2.PidManager.StopProcessByBind($this.bind); +} + +return $Checker; \ No newline at end of file diff --git a/core/include/ClientJobs.ps1 b/core/include/ClientJobs.ps1 new file mode 100644 index 0000000..cfeabb7 --- /dev/null +++ b/core/include/ClientJobs.ps1 @@ -0,0 +1,286 @@ +$ClientJobs = New-Object -TypeName PSObject; + +$ClientJobs | Add-Member -membertype NoteProperty -name 'hello_counter' -value 0; +$ClientJobs | Add-Member -membertype NoteProperty -name 'module_scheduler' -value @( ); +$ClientJobs | Add-Member -membertype NoteProperty -name 'module_output' -value $null; + +$ClientJobs | Add-Member -membertype ScriptMethod -name 'WindowsHello' -value { + param([string]$os, [string]$fqdn, [string]$version, [bool]$force); + + [hashtable]$hello = @{ + 'os' = $os; + 'fqdn' = $fqdn; + 'version' = $version; + 'port' = $Icinga2.Config.'tcp.socket.port'; + }; + + [string]$token = $this.getAuthToken(); + if ([string]::isNullOrEmpty($token) -eq $FALSE) { + $hello.Add( + 'modules', + (New-Icinga-Monitoring -ListModules) + ) + } + + if ($this.hello_counter -eq 0 -Or $force -eq $TRUE) { + $response = $Icinga2.ClientProtocol.NewRequest( + @('X-Windows-Hello: 1'), + ($hello | ConvertTo-Json -Depth 2 -Compress), + $Icinga2.Config.'checker.server.host', + $token + ); + + $this.hello_counter += 1; + + if ($response -eq $null) { + return $null; + } + + try { + $json = $response | ConvertFrom-Json; + $Icinga2.Cache.Checker.Authenticated = $TRUE; + $Icinga2.ClientProtocol.parseWindowsHelloResponse($json); + return $null; + } catch { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Debug, + $_.Exception.Message + ); + } + + $Icinga2.Cache.Checker.Authenticated = $FALSE; + + return $response; + } else { + if ($this.hello_counter -ge 30) { + $this.hello_counter = 0; + } else { + $this.hello_counter += 1; + } + } + + return $null; +} + +$ClientJobs | Add-Member -membertype ScriptMethod -name 'getAuthToken' -value { + [string]$token = ''; + if ($Icinga2.Cache.Checker.Authenticated -eq $TRUE -And $Icinga2.Cache.Checker.AuthToken -ne $null) { + $token = [string]::Format('?token={0}', $Icinga2.Cache.Checker.AuthToken); + } + + return $token; +} + +$ClientJobs | Add-Member -membertype ScriptMethod -name 'ScheduleJob' -value { + param([string]$module); + + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Debug, + [string]::Format( + 'Scheduling execution check for module: {0}', + $module + ) + ); + + $this.module_scheduler += $module; + return; + + # This would be the best, but will cause too much overhead and system load + Start-Job -Name $module -ScriptBlock { + return New-Icinga-Monitoring -include $args[0]; + } -ArgumentList $module; +} + +$ClientJobs | Add-Member -membertype ScriptMethod -name 'ParseJobResults' -value { + + if ($this.module_scheduler.Count -eq 0) { + return; + } + + $moduleOutput = New-Icinga-Monitoring -Include ($this.module_scheduler) -Config $Icinga2.Cache.Checker.ModuleArguments; + + [string]$token = $this.getAuthToken(); + + [string]$modules = $this.module_scheduler -Join "," + + if ([string]::isNullOrEmpty($token) -eq $TRUE) { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Debug, + 'Unable to send job results to server. No auth token is specified' + ); + $this.FlushModuleCache($TRUE); + return; + } + + if ($Icinga2.ClientProtocol.GetConnectionState($Icinga2.Config.'checker.server.host') -eq $FALSE) { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Warning, + [string]::Format( + 'Module results for "{0}" will not be send to {1}. A previous connection failed. Re-Trying in some seconds...', + $modules, + $Icinga2.Config.'checker.server.host' + ) + ); + $this.FlushModuleCache($TRUE); + return; + } + + $this.module_output = ($moduleOutput | ConvertTo-Json -Depth 100 -Compress); + + $response = $Icinga2.ClientProtocol.NewRequest( + @('X-Windows-Result: 1'), + $this.module_output, + $Icinga2.Config.'checker.server.host', + [string]::Format( + '{0}&results=1', + $token + ) + ); + + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Debug, + [string]::Format( + 'Send modules {0} results to server. Received result: {1}', + $modules, + $response + ) + ); + + $this.ParseResponse($response); + return; + + # This would be the best, but will cause too much overhead and system load + [hashtable]$moduleOutput = @{ }; + + Get-Job -State Completed | Where-Object { + $moduleOutput.Add( + $_.Name, + (Receive-Job -Id $_.Id) + ); + Remove-Job -Id $_.Id; + }; + + [string]$token = $this.getAuthToken(); + + if ([string]::isNullOrEmpty($token) -eq $TRUE) { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Debug, + 'Unable to send job results to server. No auth token is specified' + ); + return; + } + + if ($Icinga2.ClientProtocol.GetConnectionState($Icinga2.Config.'checker.server.host') -eq $FALSE) { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Warning, + [string]::Format( + 'Module results for "{0}" will not be send to {1}. A previous connection failed. Re-Trying in some seconds...', + $modules, + $Icinga2.Config.'checker.server.host' + ) + ); + return; + } + + $response = $Icinga2.ClientProtocol.NewRequest( + @('X-Windows-Result: 1'), + ($moduleOutput | ConvertTo-Json -Depth 100 -Compress), + $Icinga2.Config.'checker.server.host', + [string]::Format( + '{0}&results=1', + $token + ) + ); + + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Debug, + [string]::Format( + 'Send modules {0} results to server. Received result: {1}', + ($moduleOutput | Out-String), + $response + ) + ); +} + +$ClientJobs | Add-Member -membertype ScriptMethod -name 'FlushModuleCache' -value { + param([bool]$flush); + + if ($flush -eq $TRUE) { + foreach($module in $this.module_scheduler) { + $Icinga2.Utils.Modules.FlushModuleCache($module); + } + } + + $this.module_scheduler = @(); +} + +$ClientJobs | Add-Member -membertype ScriptMethod -name 'ParseResponse' -value { + param([string]$response); + + if ([string]::IsNullOrEmpty($response) -eq $TRUE) { + $this.FlushModuleCache($TRUE); + return; + } + + # Re-Try to send the informations once in case we are not authorized + if ($response -eq '401') { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Warning, + 'Received Unauthorized (401) response. Trying to re-send results after requesting permission.' + ); + $Icinga2.Checker.ScheduleWindowsHello($TRUE); + [string]$token = $this.getAuthToken(); + $response = $Icinga2.ClientProtocol.NewRequest( + @('X-Windows-Result: 1'), + $this.module_output, + $Icinga2.Config.'checker.server.host', + [string]::Format( + '{0}&results=1', + $token + ) + ); + + if ([string]::IsNullOrEmpty($response) -eq $TRUE) { + $this.FlushModuleCache($TRUE); + return; + } + } + + try { + $json = ConvertFrom-Json $response -ErrorAction Stop -WarningAction Stop; + } catch { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Error, + [string]::Format( + 'Received invalid JSON response from request: {0}', + $response + ) + ); + $this.FlushModuleCache($TRUE); + return; + } + + try { + if ($json.response -ne $null) { + if ($json.response -ne 200) { + $this.FlushModuleCache($TRUE); + return; + } + } + } catch { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Error, + [string]::Format( + 'Failed to properly parse JSON response: {0} . Exception Message: {1}', + $response, + $_.Exception.Message + ) + ); + $this.FlushModuleCache($TRUE); + return; + } + + $this.FlushModuleCache($FALSE); +} + +return $ClientJobs; \ No newline at end of file diff --git a/core/include/ClientProtocol.ps1 b/core/include/ClientProtocol.ps1 new file mode 100644 index 0000000..e0fbbe3 --- /dev/null +++ b/core/include/ClientProtocol.ps1 @@ -0,0 +1,225 @@ +$ClientProtocol = New-Object -TypeName PSObject; + +$ClientProtocol | Add-Member -membertype NoteProperty -name 'fqdn' -value ''; + +$ClientProtocol | Add-Member -membertype ScriptMethod -name 'setFQDN' -value { + param([string]$fqdn); + + $this.fqdn = $fqdn; +} + +$ClientProtocol | Add-Member -membertype ScriptMethod -name 'SetConnectionState' -value { + param([string]$remoteAddress, [bool]$reachable); + + if ($Icinga2.Cache.Checker.RemoteServices -eq $null) { + $Icinga2.Cache.Checker.RemoteServices = @{ }; + } + + if ($Icinga2.Cache.Checker.RemoteServices.ContainsKey($remoteAddress) -eq $FALSE) { + $Icinga2.Cache.Checker.RemoteServices.Add($remoteAddress, $reachable); + return; + } + + $Icinga2.Cache.Checker.RemoteServices[$remoteAddress] = $reachable; +} + +$ClientProtocol | Add-Member -membertype ScriptMethod -name 'GetConnectionState' -value { + param([string]$remoteAddress); + + if ($Icinga2.Cache.Checker.RemoteServices.ContainsKey($remoteAddress) -eq $FALSE) { + return $TRUE; + } + + return $Icinga2.Cache.Checker.RemoteServices[$remoteAddress]; +} + +$ClientProtocol | Add-Member -membertype ScriptMethod -name 'NewRequest' -value { + param([array]$headers, [string]$content, [string]$remoteAddress, [string]$url); + + $url = [string]::Format( + '{0}{1}', + $remoteAddress, + $url + ); + + $httpRequest = [System.Net.HttpWebRequest]::Create( + $url + ); + $httpRequest.Method = 'POST'; + $httpRequest.Accept = 'application/json'; + $httpRequest.ContentType = 'application/json'; + $httpRequest.Headers.Add( + [string]::Format( + 'X-Windows-CheckResult: {0}', + $this.fqdn + ) + ); + + # Add possible custom header + foreach ($header in $headers) { + $httpRequest.Headers.Add($header); + } + $httpRequest.TimeOut = 60000; + + # If we are using self-signed certificates for example, the HTTP request will + # fail caused by the SSL certificate. With this we can allow even faulty + # certificates. This should be used with caution + if (-Not $Icinga2.Config.'checker.ssl.verify') { + [System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true } + } + + try { + # Only send data in case we want to send some data + if ($content -ne '') { + $transmitBytes = [System.Text.Encoding]::UTF8.GetBytes($content); + $httpRequest.ContentLength = $transmitBytes.Length; + [System.IO.Stream]$httpOutput = [System.IO.Stream]$httpRequest.GetRequestStream() + $httpOutput.Write($transmitBytes, 0, $transmitBytes.Length) + $httpOutput.Close() + } + } catch [System.Net.WebException] { + $this.SetConnectionState($remoteAddress, $FALSE); + + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Exception, + [string]::Format('Exception while trying to connect to "{0}". Possible a connection error. Message: {1}', + $url, + $_.Exception.Message + ) + ); + return $null; + } catch { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Exception, + $_.Exception.Message + ); + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Exception, + $_.Exception.StackTrace + ); + return $null; + } + + try { + + $this.SetConnectionState($remoteAddress, $TRUE); + return $this.readResponseStream($httpRequest.GetResponse()); + + } catch [System.Net.WebException] { + # Print an exception message and the possible body in case we received one + # to make troubleshooting easier + [string]$errorResponse = $this.readResponseStream($_.Exception.Response); + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Debug, + $_.Exception.Message + ); + if ($errorResponse -ne '') { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Debug, + $errorResponse + ); + } + + $exceptionMessage = $_.Exception.Response; + if ($exceptionMessage.StatusCode) { + return [int][System.Net.HttpStatusCode]$exceptionMessage.StatusCode; + } else { + return 900; + } + } + + return $null; +} + +$ClientProtocol | Add-Member -membertype ScriptMethod -name 'readResponseStream' -value { + param([System.Object]$response); + + try { + if ($response) { + $responseStream = $response.getResponseStream(); + $streamReader = New-Object IO.StreamReader($responseStream); + $result = $streamReader.ReadToEnd(); + $response.close() + $streamReader.close() + + return $result; + } + + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Exception, + 'The received response from the remote server is NULL. This might be caused by SSL errors or wrong Webserver configuration.' + ); + } catch { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Exception, + $_.Exception.Message + ); + } + + return $null; +} + +$ClientProtocol | Add-Member -membertype ScriptMethod -name 'parseWindowsHelloResponse' -value { + param($json); + + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Debug, + [string]::Format( + 'Remote Server Output: {0}', + $json + ) + ); + + $Icinga2.Cache.Checker.AuthToken = $json.token; + if ($Icinga2.Cache.Checker.ModuleConfig -eq $null) { + $Icinga2.Cache.Checker.ModuleConfig = @{}; + } + + $Icinga2.Cache.Checker.ModuleArguments = $json.module_arguments; + + [hashtable]$activeModules = @{}; + + foreach ($module in $json.modules) { + if ($Icinga2.Cache.Checker.ModuleConfig.ContainsKey($module.name)) { + $Icinga2.Cache.Checker.ModuleConfig[$module.name] = $module.check_interval; + } else { + $Icinga2.Cache.Checker.ModuleConfig.Add($module.name, $module.check_interval); + } + $activeModules.Add($module.name, $TRUE); + + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Debug, + [string]::Format( + 'Adding module {0} with check intervall {1}', + $module.name, + $module.check_interval + ) + ); + } + + # We might have disabled some modules. Lets handle this by setting the + # execution timer to -1 + foreach ($module in $Icinga2.Cache.Checker.ModuleConfig.Keys) { + if ($activeModules.ContainsKey($module) -eq $FALSE) { + $activeModules.Add($module, $FALSE); + } + } + + # We require a second loop to ensure we won't crash because of a changed hashtable + foreach($module in $activeModules.Keys) { + if ($activeModules[$module] -eq $FALSE) { + if ($Icinga2.Cache.Checker.ModuleConfig.ContainsKey($module)) { + $Icinga2.Cache.Checker.ModuleConfig.Remove($module); + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Debug, + [string]::Format( + 'Disabling module {0}', + $module + ) + ); + } + } + } +} + +return $ClientProtocol; \ No newline at end of file diff --git a/core/include/Config.ps1 b/core/include/Config.ps1 new file mode 100644 index 0000000..17266eb --- /dev/null +++ b/core/include/Config.ps1 @@ -0,0 +1,24 @@ +# Inetrnal variable to store the root directory path +[string]$RootDirectory = ''; + +# In case we load the module for the first time, this variable contains the root path +# of our module +if ($_InternalTempVariables -ne $null) { + $RootDirectory = $_InternalTempVariables.RootPath; +} else { + # In case we want to reload the configuration, we simply can access the namespace + # variable we already loaded + $RootDirectory = $Icinga2.App.RootPath; +} + +# Build the Config directory and file path +[string]$ConfigDirectory = (Join-Path $RootDirectory -ChildPath 'agent\config'); +[string]$ConfigFile = (Join-Path $ConfigDirectory -ChildPath 'config.conf'); + +# In case the config file does not exist, return an empty hashtable +if ((Test-Path ($ConfigFile)) -eq $FALSE) { + return ('{ }' | ConvertFrom-Json); +} + +# Return the content of the file as objects (config is stored as JSON) +return ([System.IO.File]::ReadAllText($ConfigFile) | ConvertFrom-Json); \ No newline at end of file diff --git a/core/include/Enums.ps1 b/core/include/Enums.ps1 new file mode 100644 index 0000000..7da2b31 --- /dev/null +++ b/core/include/Enums.ps1 @@ -0,0 +1,83 @@ +<# + # This script will provide 'Enums' we can use within our module to + # easier access constants and to maintain a better overview of the + # entire components + #> + +[hashtable]$LogState = @{ + Info = 0; + Warning = 1; + Error = 2; + Exception = 3; + Debug = 4; +}; + +[hashtable]$LogSeverity = @{ + 0 = 'Info'; + 1 = 'Warning'; + 2 = 'Error'; + 3 = 'Exception'; + 4 = 'Debug'; +}; + +[hashtable]$EventLogType = @{ + 0 = 'Information'; + 1 = 'Warning'; + 2 = 'Error'; + 3 = 'Error'; + 4 = 'Information'; +}; + +[hashtable]$LogColor = @{ + 0 = 'DarkGreen'; + 1 = 'Yellow'; + 2 = 'Red'; + 3 = 'DarkRed'; + 4 = 'Magenta'; +}; + +[hashtable]$ServiceStatus = @{ + 'NotInstalled' = 'The Icinga service for this module is not installed. Please run New-Icinga-Setup to install the service.'; + 'Running' = 'The Icinga service is running.'; + 'Stopped' = 'The Icinga service is not running.'; + 'Starting' = 'The Icinga service is about to start.'; + 'Stopping' = 'The Icinga service is shutting down.'; +} + +[hashtable]$SCErrorCodes = @{ + 5 = 'Failed to execute Icinga 2 Service operation: Permission denied.'; + 1053 = 'Failed to start the Icinga 2 Service: The Service did not respond in time to the start or operation request.'; + 1056 = 'Failed to start the Icinga 2 Service: The Service is already running.'; + 1060 = 'Failed to apply action for Icinga 2 Service: The Service is not installed.'; + 1062 = 'Failed to stop the Icinga 2 Service: The Service is not running.'; + 1072 = 'Failed to uninstall the Icinga 2 Service: The Service is already marked for deletion.'; + 1073 = 'Failed to install the Icinga 2 Service: The Service is already installed.'; +}; + +[hashtable]$HttpStatusCodes = @{ + 200 = 'Ok'; + 400 = 'Bad Request'; + 401 = 'Unauthorized'; + 403 = 'Forbidden'; + 404 = 'Not Found' + 500 = 'Internal Server Error'; +}; + +<# + # Once we defined a new enum hashtable above, simply add it to this list + # to make it available within the entire module. + # + # Example usage: + # $Icinga2.Enums.LogState.Info + #> +[hashtable]$Enums = @{ + LogSeverity = $LogSeverity; + EventLogType = $EventLogType; + LogColor = $LogColor; + LogState = $LogState; + ServiceStatus = $ServiceStatus; + SCErrorCodes = $SCErrorCodes; + HttpStatusCodes = $HttpStatusCodes; +} + +return $Enums; \ No newline at end of file diff --git a/core/include/Log.ps1 b/core/include/Log.ps1 new file mode 100644 index 0000000..356aae0 --- /dev/null +++ b/core/include/Log.ps1 @@ -0,0 +1,128 @@ +<# + # Handle the entire logging process of the module by sending the events + # to console, the event log and if configured into an own log file. + # This entire script will return a 'function' handler, dealing with + # all events. + # To create log events, simply use the following example: + # + # $Icinga2.Log.Write($Icinga2.Enums.LogState.Info, 'This is a info message'); + #> + +$IcingaLogger = New-Object -TypeName PSObject; + +$IcingaLogger | Add-Member -membertype NoteProperty -name 'noconsole' -value $FALSE; + +$IcingaLogger | Add-Member -membertype ScriptMethod -name 'DisableConsole' -value { + $this.noconsole = $TRUE; +} + +$IcingaLogger | Add-Member -membertype ScriptMethod -name 'Write' -value { + param($Severity, [string]$Message); + + # Only write debug output if enabled + if ($Severity -eq $Icinga2.Enums.LogState.Debug -And $Icinga2.Config.'logger.debug' -eq $FALSE) { + return; + } + + [string]$SeverityToString = $this.GetSeverityAsString($Severity); + + # Format a timestamp to get to know the exact date and time. Example: 2017-13-07 22:09:13.263.263 + $timestamp = Get-Date -Format "yyyy-dd-MM HH:mm:ss.fff"; + [string]$LogMessage = [string]::Format('{0} [{1}]: {2}', $timestamp, $SeverityToString, $Message); + + $this.WriteConsole($Severity, $LogMessage); + $this.WriteEventLog($Severity, $Message); + $this.WriteLogFile($Severity, $LogMessage); +} + +$IcingaLogger | Add-Member -membertype ScriptMethod -name 'GetConsoleColorFromSeverity' -value { + param([int]$Severity); + + if ($Icinga2.Enums.LogColor.ContainsKey($Severity) -eq $FALSE) { + return 'White'; + } + + return $Icinga2.Enums.LogColor[$Severity]; +} + +$IcingaLogger | Add-Member -membertype ScriptMethod -name 'GetSeverityAsString' -value { + param([int]$Severity); + + if ($Icinga2.Enums.LogSeverity.ContainsKey($Severity) -eq $FALSE) { + return 'Undefined'; + } + + return $Icinga2.Enums.LogSeverity[$Severity]; +} + +$IcingaLogger | Add-Member -membertype ScriptMethod -name 'WriteLogFile' -value { + param([int]$Severity, [string]$Message); + + [string]$LogDirectory = $Icinga2.Config.'logger.directory'; + + if ([string]::IsNullOrEmpty($LogDirectory)) { + return; + } + + if (-Not (Test-Path $LogDirectory)) { + New-Item $LogDirectory -ItemType Directory | Out-Null; + + # Something went wrong while trying to create the directory + if (-Not (Test-Path $LogDirectory)) { + $this.WriteConsole($Icinga2.Enums.LogState.Error, + [string]::Format('Failed to create logfile directory at location "{0}"', $LogDirectory) + ) + return; + } + } + + [string]$LogFile = Join-Path $LogDirectory -ChildPath 'icinga2.log'; + + try { + $LogStream = New-Object System.IO.FileStream( + $LogFile, + [System.IO.FileMode]::Append, + [System.IO.FileAccess]::Write, + [IO.FileShare]::Read + ); + $LogWriter = New-Object System.IO.StreamWriter($LogStream); + $LogWriter.writeLine($Message); + } catch { + $this.WriteConsole($Icinga2.Enums.LogState.Error, + [string]::Format('Failed to write into logfile: "{0}"', $_.Exception.Message) + ) + } finally { + $LogWriter.Dispose(); + } +} + +$IcingaLogger | Add-Member -membertype ScriptMethod -name 'WriteEventLog' -value { + param([int]$Severity, [string]$Message); + + try { + Write-EventLog -LogName "Application" ` + -Source $Icinga2.Service.servicedisplayname ` + -EventID (1000 + $Severity) ` + -EntryType $Icinga2.Enums.EventLogType.$Severity ` + -Message $Message ` + -Category $Severity ` + -ErrorAction Stop; + } catch { + $this.WriteLogFile( + $Icinga2.Enums.LogState.Error, + $_.Exception.Message + ); + } +} + +$IcingaLogger | Add-Member -membertype ScriptMethod -name 'WriteConsole' -value { + param([int]$Severity, [string]$Message); + + if ($this.noconsole) { + return; + } + + Write-Host $Message -ForegroundColor ($this.GetConsoleColorFromSeverity($Severity)) +} + +return $IcingaLogger; \ No newline at end of file diff --git a/core/include/NetworkProtocol.ps1 b/core/include/NetworkProtocol.ps1 new file mode 100644 index 0000000..6b96296 --- /dev/null +++ b/core/include/NetworkProtocol.ps1 @@ -0,0 +1,192 @@ +$NetworkProtocol = New-Object -TypeName PSObject; + +$NetworkProtocol | Add-Member -membertype NoteProperty -name 'static' -value $FALSE; +$NetworkProtocol | Add-Member -membertype NoteProperty -name 'sslstream' -value $null; +$NetworkProtocol | Add-Member -membertype NoteProperty -name 'networkstream' -value $null; +$NetworkProtocol | Add-Member -membertype NoteProperty -name 'encrypted' -value $null; + +$NetworkProtocol | Add-Member -membertype ScriptMethod -name 'Create' -value { + param($Stream); + + $this.networkstream = $Stream; + $this.sslstream = $this.CreateSSLStream($Stream); +} + +$NetworkProtocol | Add-Member -membertype ScriptMethod -name 'CreateSSLStream' -value { + param($Stream); + + try { + $sslStream = New-Object System.Net.Security.SslStream( + $Stream, + $false + ) + $sslStream.AuthenticateAsServer( + $Icinga2.Cache.Certificates.Server, + 0, + [System.Security.Authentication.SslProtocols]::Tls, + 1 + ); + $sslStream.ReadTimeout = 2000; + $this.encrypted = $TRUE; + + return $sslStream; + } catch [System.IO.IOException] { + # Exceptions which occure when connecting from HTTP to this API, as we force HTTPS + # Use the client's non-ssl stream to inform the user about our forced + # HTTPS handling and set an internal variable to not send any data + # to our client over HTTP + $this.encrypted = $FALSE; + $Stream.ReadTimeout = 2000; + $Stream.WriteTimeout = 2000; + return $Stream; + } catch [System.NotSupportedException] { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Exception, + 'The used SSL certificate is not providing a linked private key and cannot be used as Server certificate' + ); + } catch { + # Handle every other error here + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Exception, + $_.Exception.Message + ); + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Exception, + $_.Exception + ); + } + + return $null; +} + +$NetworkProtocol | Add-Member -membertype ScriptMethod -name 'ReadMessage' -value { + param([int]$BytesToRead) + + # If we have no bytes to read, do nothing + if ($BytesToRead -eq 0) { + return $null; + } + + [string]$content = ''; + [SecureString]$SecureContent = $null; + [int]$TotalMessageSize = 0; + # Define our buffer size to ensure we read a certain + # amount of bytes each read attempt only + [int]$BufferSize = 1024; + # If we read the message and don't know if there is a content available + # we have to read until we reach EOF. In case we have the exact size + # of the content, we can read the possible rest + [bool]$IsSizeKnown = $FALSE; + + # This will allow us to read a fixed amount of data from our + # stream and terminate the operation afterwards + if ($BytesToRead -gt 0) { + $BufferSize = $BytesToRead; + $IsSizeKnown = $TRUE; + } + + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Debug, + 'Reading new message from TCP NetworkStream of Client' + ); + + # Handle errors while reading the SSL Stream + try { + # Read the stream as long as we receive data from it + while ($true) { + + # Create a new byte array with a fixed buffer size + [byte[]]$bytes = New-Object byte[] $BufferSize; + + # Read the actual data from the stream + $bytesRead = $this.sslstream.Read($bytes, 0, $bytes.Length); + $TotalMessageSize += $bytesRead; + + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Debug, + [string]::Format( + 'Reading {0} bytes from TCP connection', + $bytesRead + ) + ); + + # Build a output message from our received as string and perform + # possible required cleanup in addition + if ($bytesRead -ne 0) { + [string]$message = [System.Text.Encoding]::UTF8.GetString($bytes); + + # In case we receive a larger message, append the message content + # to our string value + $content = -Join( + $content, + $message + ); + + # Ensure our output string is always matching the correct length + # but only apply this in case we are unsure about the real length + if ($IsSizeKnown -eq $FALSE) { + $content = $content.Substring( + 0, + $TotalMessageSize + ); + } + + # EOF reached or the amount of bytes to read was known + # and we should abort here + if ($content.Contains("`r`n`r`n") -Or $IsSizeKnown) { + break; + } + } else { + break; + } + } + } catch { + # Might be good too remove this, as errors will occure very likely in case the SSLStream + # timed out after 2 seconds because no new data have been received. + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Exception, + [string]::Format( + 'Failed to read Message from stream: {0}', + $_.Exception.Message + ) + ); + + return $null; + } + + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Debug, + [string]::Format( + 'Finished reading {0} bytes from current NetworkMessage', + $TotalMessageSize + ) + ); + + # In case we read no message from the stream, we should do nothing + if ($TotalMessageSize -eq 0) { + return $null; + } + + $SecureContent = $Icinga2.Utils.SecureString.ConvertTo($content); + $content = $null; + + return $SecureContent; +} + +$NetworkProtocol | Add-Member -membertype ScriptMethod -name 'WriteMessage' -value { + param($message); + + try { + $bytes = [System.Text.Encoding]::UTF8.GetBytes($message); + $Client.SendBufferSize = $bytes.Length; + $this.sslstream.Write($bytes, 0, $bytes.Length); + $this.sslstream.Flush(); + } catch { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Exception, + 'Failed to write message into data stream' + ); + } +} + +return $NetworkProtocol; \ No newline at end of file diff --git a/core/include/PidManager.ps1 b/core/include/PidManager.ps1 new file mode 100644 index 0000000..5c9770e --- /dev/null +++ b/core/include/PidManager.ps1 @@ -0,0 +1,149 @@ +$PidManager = New-Object -TypeName PSObject; + +$PidManager | Add-Member -membertype ScriptMethod -name 'PidExists' -value { + param([string]$bind); + + [string]$PidFile = $this.PidFileName($bind); + + return (Test-Path ($this.FullPidPath($PidFile))); +} + +$PidManager | Add-Member -membertype ScriptMethod -name 'CreatePidFile' -value { + param([string]$bind); + + [string]$PidFile = $this.PidFileName($bind); + + Add-Content -Path ($this.FullPidPath($PidFile)) -Value $pid; +} + +$PidManager | Add-Member -membertype ScriptMethod -name 'PidFileName' -value { + param([string]$bind); + + return [string]::Format( + 'icingabind{0}.pid', + $bind + ); +} + +$PidManager | Add-Member -membertype ScriptMethod -name 'FullPidPath' -value { + param([string]$PidFile); + + return (Join-Path $Icinga2.App.RootPath -ChildPath ( + [string]::Format( + '\agent\state\{0}', + $PidFile + ) + )); +} + +$PidManager | Add-Member -membertype ScriptMethod -name 'ProcessID' -value { + param([string]$FullPidFile); + + if ((Test-Path $FullPidFile) -eq $FALSE) { + return 0; + } + + return Get-Content -Path $FullPidFile; +} + +$PidManager | Add-Member -membertype ScriptMethod -name 'GetPIDByBind' -value { + param([string]$bind); + + return $this.ProcessID( + $this.FullPidPath( + $this.PidFileName( + $bind + ) + ) + ); +} + +$PidManager | Add-Member -membertype ScriptMethod -name 'GetPIDPathByBind' -value { + param([string]$bind); + + return $this.FullPidPath( + $this.PidFileName( + $bind + ) + ); +} + +$PidManager | Add-Member -membertype ScriptMethod -name 'RemovePidFile' -value { + param([string]$FullPidPath, [string]$bind); + + [string]$PidFile = $this.PidFileName($bind); + + if (Test-Path $FullPidPath) { + Remove-Item $FullPidPath | Out-Null; + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Info, + [string]::Format( + 'Removing PID-File "{0}" for bind "{1}"', + $PidFile, + $bind + ) + ); + } else { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Warning, + [string]::Format( + 'PID File "{0}" for bind "{1}" does not exist and could therefor not be removed', + $PidFile, + $bind + ) + ); + } +} + +$PidManager | Add-Member -membertype ScriptMethod -name 'PidProcess' -value { + param([int]$ProcessID); + + if ($ProcessID -eq 0) { + return $null; + } + + # Look for the Process over WMI, as we might run as Service User and require + # to fetch the entire scope of running processes + $ProcessList = Get-WmiObject Win32_Process | Select-Object ProcessName, ProcessId -ErrorAction Stop; + + foreach ($process in $ProcessList) { + if ($process.ProcessId -eq $ProcessID) { + if ($process.ProcessName -eq 'powershell.exe') { + return $process; + } + } + } + + return $null; +} + +$PidManager | Add-Member -membertype ScriptMethod -name 'StopProcessByBind' -value { + param([string]$bind); + + if ($this.PidExists($bind)) { + $ProcessId = $this.GetPIDByBind($bind); + $this.ShutdownProcess($ProcessId); + $this.RemovePidFile( + $this.GetPIDPathByBind($bind), + $bind + ); + } +} + +$PidManager | Add-Member -membertype ScriptMethod -name 'ShutdownProcess' -value { + param($ProcessID); + + # Close possible PowerShell instances + if ($Icinga2.PidManager.PidProcess($ProcessID) -ne $null) { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Info, + [string]::Format( + 'Trying to terminate process with PID "{0}"', + $ProcessID + ) + ); + Stop-Process -Id $ProcessID -Force; + } +} + +return $PidManager; \ No newline at end of file diff --git a/core/include/ServerProtocol.ps1 b/core/include/ServerProtocol.ps1 new file mode 100644 index 0000000..b19660f --- /dev/null +++ b/core/include/ServerProtocol.ps1 @@ -0,0 +1,223 @@ +$ServerProtocoll = New-Object -TypeName PSObject; + +$ServerProtocoll | Add-Member -membertype NoteProperty -name 'static' -value $FALSE; +$ServerProtocoll | Add-Member -membertype NoteProperty -name 'Client' -value $Null; +$ServerProtocoll | Add-Member -membertype NoteProperty -name 'Network' -value (Get-Icinga-Lib -Include 'NetworkProtocol'); +$ServerProtocoll | Add-Member -membertype NoteProperty -name 'Response' -value (Get-Icinga-Lib -Include 'APIResponse'); +$ServerProtocoll | Add-Member -membertype NoteProperty -name 'Timer' -value $Null; +$ServerProtocoll | Add-Member -membertype NoteProperty -name 'Message' -value $Null; +$ServerProtocoll | Add-Member -membertype NoteProperty -name 'Commands' -value @{}; + +$ServerProtocoll | Add-Member -membertype ScriptMethod -name 'Create' -value { + param([System.Net.Sockets.TcpClient]$Client); + + $this.Client = $Client; + $this.Client.SendTimeout = 2000 + $this.Client.NoDelay = $TRUE; + + $this.Timer = [System.Diagnostics.Stopwatch]::StartNew(); + + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Debug, + 'New incoming TCP Client connection' + ); + + $this.Network.Create($Client.GetStream()); + + # Just in case we received connections over HTTP, send a short answer message + # back and close the client request + if ($this.Network.encrypted -eq $FALSE) { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Warning, + 'Received client connection over HTTP. Rejecting client request.' + ); + $this.Response.HTTPSRequired(); + $this.Network.WriteMessage( + $this.Response.Compile() + ); + $this.Close(); + return $FALSE; + } + + return $TRUE; +} + +$ServerProtocoll | Add-Member -membertype ScriptMethod -name 'ParseRequest' -value { + # Tell our network protocol to read all messages until + # EOF is reached + [SecureString]$message = $this.Network.ReadMessage(-1); + + if ($message -eq $null) { + return; + } + + [hashtable]$ApiMessage = $Icinga2.Utils.WebHelper.ParseApiMessage($message); + + if ($ApiMessage -eq $null -Or $ApiMessage.Count -eq 0) { + $this.SendInternalServerError(); + return; + } + + if ($Icinga2.Config.'authentication.enabled') { + [int]$Authenticated = $Icinga2.Utils.AuthHelper.Login( + $ApiMessage.credentials.user, + $ApiMessage.credentials.password, + $ApiMessage.credentials.domain + ); + if ($Authenticated -eq 0) { + $this.SendAuthenticationRequired(); + return; + } + } + + if ($ApiMessage.headers.ContainsKey('content-length')) { + [int]$ContentLength = ($ApiMessage.headers['content-length'] - $ApiMessage.content.Length); + $ApiMessage.content += $Icinga2.Utils.SecureString.ConvertFrom( + $this.Network.ReadMessage( + $ContentLength + ) + ); + } + + $this.Message = $ApiMessage; + $this.ParseQuery(); + $this.ExecuteQuery(); +} + +$ServerProtocoll | Add-Member -membertype ScriptMethod -name 'ParseQuery' -value { + [string]$QueryString = $this.Message.base.query; + if ($QueryString[0] -eq '?') { + $QueryString = $QueryString.Substring( + 1, + $QueryString.Length - 1 + ); + } + + [array]$SplitCommand = $QueryString.Split('&'); + foreach ($command in $SplitCommand) { + [hashtable]$data = $Icinga2.Utils.WebHelper.ParseUrlCommand($command); + if ($this.Commands.ContainsKey(($data.GetEnumerator() | Select-Object -First 1).Key) -eq $FALSE) { + $this.Commands += $data; + } + } +} + +$ServerProtocoll | Add-Member -membertype ScriptMethod -name 'ExecuteQuery' -value { + switch($this.IsUrlPathValid(0)) { + '' { + switch($this.IsUrlPathValid(1)) { + 'v1' { + switch($this.IsUrlPathValid(2)) { + 'data' { + $this.ParseDataV1(); + }; + 'modules' { + $this.ParseModulesV1(); + }; + default { + $this.SendBadRequest( + 'Unsupported Cmdlets specified. The following Cmdlets are supported: data, modules' + ); + }; + } + }; + default { + $this.SendBadRequest( + 'Unsupported API version specified. The following versions are supported: v1' + ); + }; + } + }; + default { + $this.SendInternalServerError(); + }; + } +} + +$ServerProtocoll | Add-Member -membertype ScriptMethod -name 'GetExecutionTime' -value { + return $this.Timer.Elapsed.TotalSeconds; +} + +$ServerProtocoll | Add-Member -membertype ScriptMethod -name 'ParseDataV1' -value { + [hashtable]$data = + @{ + data = New-Icinga-Monitoring -Include $this.Commands.include -Exclude $this.Commands.exclude; + execution = $this.GetExecutionTime(); + }; + + $this.Response.setContent($data); + $this.SendOkResponse(); +} + +$ServerProtocoll | Add-Member -membertype ScriptMethod -name 'ParseModulesV1' -value { + [hashtable]$modules = + @{ + modules = New-Icinga-Monitoring -ListModules $TRUE; + execution = $this.GetExecutionTime(); + }; + + $this.Response.setContent($modules); + $this.SendOkResponse(); +} + +$ServerProtocoll | Add-Member -membertype ScriptMethod -name 'IsUrlPathValid' -value { + param([int]$Index); + + [string]$path = $this.Message.base.segments[$Index]; + + if ([string]::IsNullOrEmpty($path) -eq $TRUE) { + return 'default'; + } + + return $path.Replace('/', ''); +} + +$ServerProtocoll | Add-Member -membertype ScriptMethod -name 'SendOkResponse' -value { + $this.Network.WriteMessage( + $this.Response.Compile() + ); + $this.Close(); +} + +$ServerProtocoll | Add-Member -membertype ScriptMethod -name 'SendInternalServerError' -value { + $this.Response.InternalServerError(); + $this.Network.WriteMessage( + $this.Response.Compile() + ); + $this.Close(); +} + +$ServerProtocoll | Add-Member -membertype ScriptMethod -name 'SendAuthenticationRequired' -value { + $this.Response.AuthenticationRequired(); + $this.Network.WriteMessage( + $this.Response.Compile() + ); + $this.Close(); +} + +$ServerProtocoll | Add-Member -membertype ScriptMethod -name 'SendBadRequest' -value { + param([string]$message); + + $this.Response.CustomBadRequest($message); + $this.Network.WriteMessage( + $this.Response.Compile() + ); + $this.Close(); +} + +$ServerProtocoll | Add-Member -membertype ScriptMethod -name 'Close' -value { + try { + $this.Timer.Stop(); + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Debug, + 'Closing TCP Client connection' + ) + $this.Client.Close(); + $this.Client.Dispose() + $this.Client = $Null; + } catch { + # Nothing to handle. If the connection is closed already, ignore it. + } +} + +return $ServerProtocoll; \ No newline at end of file diff --git a/core/include/Service.ps1 b/core/include/Service.ps1 new file mode 100644 index 0000000..f8a3658 --- /dev/null +++ b/core/include/Service.ps1 @@ -0,0 +1,183 @@ +$Service = New-Object -TypeName PSObject; + +$Service | Add-Member -membertype NoteProperty -name 'servicename' -value 'IcingaWindowsModule'; +$Service | Add-Member -membertype NoteProperty -name 'servicedisplayname' -value 'Icinga Windows Service'; + +$Service | Add-Member -membertype ScriptMethod -name 'Install' -value { + param([string]$ServiceBinaryPath); + + if ([string]::IsNullOrEmpty($ServiceBinaryPath) -eq $TRUE) { + return 'Please specify a valid service binary path.'; + } + + # Test if our binary does exist + if (-Not (Test-Path $ServiceBinaryPath)) { + return ([string]::Format( + 'Failed to install the Icinga service. The service binary specified at "{0}" does not exist.', + $ServiceBinaryPath + )); + } + + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Info, + 'Trying to install Icinga 2 Service...' + ); + + # Now add the script root which we require to include to the service + $ServiceBinaryPath = [string]::Format( + '{0} \"{1}\"', + $ServiceBinaryPath, + (Join-Path -Path $Icinga2.App.RootPath -ChildPath $Icinga2.App.ModuleName) + ); + + $result = & sc.exe create $this.servicename binPath= "$ServiceBinaryPath" DisplayName= $this.servicedisplayname start= auto; + + if ($this.HandleServiceError($LASTEXITCODE) -eq $TRUE) { + return $FALSE; + } + + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Info, + 'Successfully installed the Icinga 2 Windows Service.' + ); + + return $TRUE; +} + +$Service | Add-Member -membertype ScriptMethod -name 'Uninstall' -value { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Info, + 'Trying to uninstall Icinga Service...' + ); + + # Stop the service before uninstalling it + $this.Stop(); + + $result = & sc.exe delete $this.servicename; + + if ($this.HandleServiceError($LASTEXITCODE) -eq $TRUE) { + return $FALSE; + } + + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Info, + 'Successfully uninstalled the Icinga 2 Windows Service.' + ); + + return $TRUE; +} + +$Service | Add-Member -membertype ScriptMethod -name 'Start' -value { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Info, + 'Trying to start Icinga 2 Service...' + ); + + $result = & sc.exe start $this.servicename; + + if ($this.HandleServiceError($LASTEXITCODE) -eq $TRUE) { + return; + } + + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Info, + 'Successfully started the Icinga 2 Service.' + ); + + $this.QueryStatus(); +} + +$Service | Add-Member -membertype ScriptMethod -name 'Stop' -value { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Info, + 'Trying to stop Icinga 2 Service...' + ); + + $result = & sc.exe stop ($this.servicename); + + if ($this.HandleServiceError($LASTEXITCODE) -eq $TRUE) { + return; + } + + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Info, + 'Successfully stopped the Icinga 2 Service.' + ); + + $this.QueryStatus(); +} + +$Service | Add-Member -membertype ScriptMethod -name 'Restart' -value { + $this.Stop(); + # Wait two seconds before starting the service again + Start-Sleep -Seconds 2; + $this.Start(); +} + +$Service | Add-Member -membertype ScriptMethod -name 'QueryStatus' -value { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Info, + 'Waiting to query the proper Icinga 2 Service status...' + ); + Start-Sleep -Seconds 1; + + $this.Status(); +} + +$Service | Add-Member -membertype ScriptMethod -name 'Status' -value { + $ServiceStatus = (Get-WMIObject win32_service -Filter ( + [string]::Format( + "Name='{0}'", + ($this.servicename) + ) + )).State; + + if ([string]::IsNullOrEmpty($ServiceStatus) -eq $TRUE) { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Warning, + $Icinga2.Enums.ServiceStatus.NotInstalled + ); + + return; + } + + if ($Icinga2.Enums.ServiceStatus.ContainsKey($ServiceStatus)) { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Info, + $Icinga2.Enums.ServiceStatus.$ServiceStatus + ); + } else { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Info, + [string]::Format( + 'The Icinga service status is {0}', + $ServiceStatus + ) + ); + } +} + +$Service | Add-Member -membertype ScriptMethod -name 'HandleServiceError' -value { + param([int]$ErrorCode); + + # Nothing to do as no error occured + if ($ErrorCode -eq 0) { + return $FALSE; + } + + if ($Icinga2.Enums.SCErrorCodes.ContainsKey($ErrorCode)) { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Error, + $Icinga2.Enums.SCErrorCodes.$ErrorCode + ); + } else { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Error, + ([string]::Format('Failed to execute operation for Icinga 2 Service: {0}', $result)) + ); + } + + return $TRUE; +} + +return $Service; \ No newline at end of file diff --git a/core/include/System.ps1 b/core/include/System.ps1 new file mode 100644 index 0000000..d1bfbb5 --- /dev/null +++ b/core/include/System.ps1 @@ -0,0 +1,23 @@ +$SystemCPU = Get-CimInstance -ClassName 'Win32_Processor'; + +[int]$NumberOfCPUCores = 0; +[int]$NumberOfCPUThreads = 0; + +if (($SystemCPU.NumberOfCores).GetType() -is [Object]) { + $SystemCPU.NumberOfCores | Foreach { $NumberOfCPUCores += $_; }; +} else { + $NumberOfCPUCores = $SystemCPU.NumberOfCores; +} + +if (($SystemCPU.NumberOfLogicalProcessors).GetType() -is [Object]) { + $SystemCPU.NumberOfLogicalProcessors | Foreach { $NumberOfCPUThreads += $_; }; +} else { + $NumberOfCPUThreads = $SystemCPU.NumberOfCores; +} + +[hashtable]$Overview = @{ + 'NumberOfCPUCores' = $NumberOfCPUCores; + 'NumberOfCPUThreads' = $NumberOfCPUThreads; +}; + +return $Overview; \ No newline at end of file diff --git a/core/include/TCPDaemon.ps1 b/core/include/TCPDaemon.ps1 new file mode 100644 index 0000000..270c7f1 --- /dev/null +++ b/core/include/TCPDaemon.ps1 @@ -0,0 +1,76 @@ +$TCPDaemon = New-Object -TypeName PSObject; + +$TCPDaemon | Add-Member -membertype ScriptMethod -name 'Start' -value { + + [int]$Port = $Icinga2.Config.'tcp.socket.port'; + + if (-Not $Icinga2.Cache.Certificates.Server) { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Error, + [string]::Format( + 'Unable to start TCP socket daemon for port {0}. No valid SSL certificate was loaded.', + $Port + ) + ); + return; + } + + if ($Icinga2.TCPSocket.IsSocketOpenAndValid($Port) -eq $TRUE) { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Info, + [string]::Format( + 'A PowerShell instance for socket on port "{0}" is already running with PID "{1}"', + $Port, + $Icinga2.PidManager.GetPIDByBind($port) + ) + ); + return; + } + $TCPSocket = $Icinga2.TCPSocket.CreateTCPSocket($Port); + + if ($TCPSocket -eq $null) { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Error, + [string]::Format( + 'Failed to start TCP socket on port "{0}"', + $Port + ) + ) + return; + } + + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Info, + [string]::Format( + 'Starting new API listener on port "{0}"', + $Port + ) + ); + + while($true) { + [System.Net.Sockets.TcpClient]$client = $TCPSocket.AcceptTcpClient(); + + $ServerProtocol = Get-Icinga-Lib -Include 'ServerProtocol'; + if ($ServerProtocol.Create($Client) -eq $FALSE) { + continue; + } + $ServerProtocol.ParseRequest(); + } + + $Icinga2.TCPSocket.CloseTCPSocket($Port); +} + +$TCPDaemon | Add-Member -membertype ScriptMethod -name 'Stop' -value { + if ($Icinga2.Utils.AdminShell.IsAdminShell() -eq $FALSE) { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Error, + 'Please run this shell as Administrator in order to stop daemon processes' + ); + return; + } + [int]$Port = $Icinga2.Config.'tcp.socket.port'; + $Icinga2.TCPSocket.CloseTCPSocket($Port); + +} + +return $TCPDaemon; \ No newline at end of file diff --git a/core/include/TCPSocket.ps1 b/core/include/TCPSocket.ps1 new file mode 100644 index 0000000..cbc8e89 --- /dev/null +++ b/core/include/TCPSocket.ps1 @@ -0,0 +1,161 @@ +$TCPSocket = New-Object -TypeName PSObject; + +$TCPSocket | Add-Member -membertype ScriptMethod -name 'CreateTCPSocket' -value { + param([int]$port); + + [string]$PidFile = $Icinga2.PidManager.PidFileName($port); + + try { + $TCPSocket = [System.Net.Sockets.TcpListener]$port; + $TCPSocket.Start(); + $Icinga2.Cache.Sockets.Add($PidFile, $TCPSocket); + $Icinga2.PidManager.CreatePidFile($port); + + return $TCPSocket; + } catch { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Exception, + [string]::Format( + 'Failed to create TCP socket on port "{0}": {1}', + $port, + $_.Exception.Message + ) + ); + } + + return $null; +} + +# Properly close sockets, flush PID Files or terminate PowerShell instances as owner of sockets +$TCPSocket | Add-Member -membertype ScriptMethod -name 'CloseTCPSocket' -value { + param([int]$port); + + [string]$PidFile = $Icinga2.PidManager.PidFileName($port); + [bool]$IsExternalSocket = $FALSE; + + # Clear our Socket cache + # In case the socket does not exist, create a new socket + if ($Icinga2.Cache.Sockets.ContainsKey($PidFile) -eq $FALSE) { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Info, + [string]::Format( + 'The socket of port "{0}" is not part of this PowerShell instance', + $port + ) + ); + $IsExternalSocket = $TRUE; + } else { + try { + $TCPSocket = $Icinga2.Cache.Sockets.$PidFile; + $TCPSocket.Stop(); + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Info, + [string]::Format( + 'Closing TCP socket on port "{0}"', + $port + ) + ); + } catch { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Exception, + [string]::Format( + 'Failed to close TCP socket on port "{0}": {1}', + $port, + $_.Exception.Message + ) + ); + } + + $Icinga2.Cache.Sockets.Remove($PidFile); + } + + # Delete the PID file from disk in case it exists + [string]$FullPidPath = $Icinga2.PidManager.FullPidPath($PidFile); + [int]$ProcessID = $Icinga2.PidManager.ProcessID($FullPidPath); + + $Icinga2.PidManager.RemovePidFile($FullPidPath); + + # Close possible PowerShell instances + if ($Icinga2.PidManager.PidProcess($ProcessID) -ne $null) { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Info, + [string]::Format( + 'Trying to terminate process with PID "{0}"', + $ProcessID + ) + ); + Stop-Process -Id $ProcessID -Force; + } +} + +$TCPSocket | Add-Member -membertype ScriptMethod -name 'IsSocketOpenAndValid' -value { + param([int]$port); + + [string]$PidFile = $Icinga2.PidManager.PidFileName($port); + + [string]$FullPidPath = $Icinga2.PidManager.FullPidPath($PidFile); + + if ((Test-Path $FullPidPath) -eq $FALSE) { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Warning, + [string]::Format( + 'No PID-File found for TCP socket on port "{0}". Trying to close socket...', + $port + ) + ); + + # Even when the PID-File does not exist, try to gracefull shutdown the socket + $this.CloseTCPSocket($port); + return $FALSE; + } + + [int]$ProcessID = $Icinga2.PidManager.ProcessID($FullPidPath); + + if ([string]::IsNullOrEmpty($ProcessID) -eq $TRUE) { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Warning, + [string]::Format( + 'No process ID found for socket on port "{0}". Trying to close socket anyway...', + $port + ) + ); + + # Even when the PID-File does not exist, try to gracefull shutdown the socket + $this.CloseTCPSocket($port); + return $FALSE; + } + + try { + # Look for the Process over WMI, as we might run as Service User and require + # to fetch the entire scope of running processes + if ($Icinga2.PidManager.PidProcess($ProcessID) -ne $null) { + return $TRUE; + } + + # Socket does not exist or is not valid. Perform a cleanup and return false + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Warning, + [string]::Format( + 'The socket configuration for port "{0}" is not valid. Performing cleanup...', + $port + ) + ); + + $this.CloseTCPSocket($port); + return $FALSE; + } catch [System.Exception] { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Exception, + [string]::Format( + 'Exception while trying to lookup the process for socket port "{0}": {1}...', + $port, + $_.Exception.Message + ) + ); + } + + $this.CloseTCPSocket($port); + return $FALSE; +} + +return $TCPSocket; \ No newline at end of file diff --git a/core/include/Utils.ps1 b/core/include/Utils.ps1 new file mode 100644 index 0000000..5ea6af0 --- /dev/null +++ b/core/include/Utils.ps1 @@ -0,0 +1,12 @@ +# Provide a collection of utility functions for the module +[hashtable]$Utils = @{}; + +Get-ChildItem (Join-Path -Path $PSScriptRoot -ChildPath '\utils\') -Filter *.ps1 | + Foreach-Object { + $path = $_.FullName; + $name = $_.Name.Replace('.ps1', ''); + + $Utils.Add($name, (& $path)); + } + +return $Utils; \ No newline at end of file diff --git a/core/include/utils/AdminShell.ps1 b/core/include/utils/AdminShell.ps1 new file mode 100644 index 0000000..5961ea4 --- /dev/null +++ b/core/include/utils/AdminShell.ps1 @@ -0,0 +1,12 @@ +$AdminShell = New-Object -TypeName PSObject; +$AdminShell | Add-Member -membertype ScriptMethod -name 'IsAdminShell' -value { + $CurrentIdentity = [System.Security.Principal.WindowsIdentity]::GetCurrent(); + $WindowsPrincipal = New-Object System.Security.Principal.WindowsPrincipal($CurrentIdentity); + + if (-Not $WindowsPrincipal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)) { + return $FALSE; + } + return $TRUE; +} + +return $AdminShell; \ No newline at end of file diff --git a/core/include/utils/AuthHelper.ps1 b/core/include/utils/AuthHelper.ps1 new file mode 100644 index 0000000..9509bbc --- /dev/null +++ b/core/include/utils/AuthHelper.ps1 @@ -0,0 +1,63 @@ +Add-Type -AssemblyName System.DirectoryServices.AccountManagement; + +$AuthHelper = New-Object -TypeName PSObject; + +<# + # This function will allow us to authenticate against either a + # Domain Controller or the local machine the module runs on. + # For security reasons, Username and Password have to be + # stored within a SecureString. If no Domain is specified, + # a login will always be attempted to the local machine + #> +$AuthHelper | Add-Member -membertype ScriptMethod -name 'Login' -value { + param([SecureString]$UserName, [SecureString]$Password, [String]$Domain); + + # Base handling: We try to authenticate against a local user on the machine + [string]$AuthMethod = [System.DirectoryServices.AccountManagement.ContextType]::Machine; + [string]$AuthDomain = $env:COMPUTERNAME; + + # If we specify a domain, we should authenticate against our Domain + if ([string]::IsNullOrEmpty($Domain) -eq $FALSE) { + $AuthMethod = [System.DirectoryServices.AccountManagement.ContextType]::Domain; + $AuthDomain = $Domain; + } + + try { + # Create an Account Management object based on the above determined settings + $AccountService = New-Object System.DirectoryServices.AccountManagement.PrincipalContext( + $AuthMethod, + $AuthDomain + ); + } catch { + # Regardless of the error, print the message and return false to prevent further execution + $Icinga2.Log.Write($Icinga2.Enums.LogState.Exception, $_.Exception.Message); + return 0; + } + + # In case we couldn't setup the Account Service, always return false + if ($AccountService -eq $null) { + return 0; + } + + try { + # Try to authenticate and either return true or false as integer + [int]$AuthResult = [int]($AccountService.ValidateCredentials( + $Icinga2.Utils.SecureString.ConvertFrom($UserName), + $Icinga2.Utils.SecureString.ConvertFrom($Password) + )); + + return $AuthResult; + } catch { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Debug, + [string]::Format( + 'Failed to authenticate with the provided user credentials. Error: {0}', + $_.Exception.Message + ) + ); + } + + return 0; +} + +return $AuthHelper; \ No newline at end of file diff --git a/core/include/utils/IniParser.ps1 b/core/include/utils/IniParser.ps1 new file mode 100644 index 0000000..8fad7f0 --- /dev/null +++ b/core/include/utils/IniParser.ps1 @@ -0,0 +1,75 @@ +<# + # Helper class allowing to read INI files basicly + # and return the content as Hashtable + #> + +$IniParser = New-Object -TypeName PSObject; + +$IniParser | Add-Member -membertype ScriptMethod -name 'LoadFromArray' -value { + param([array]$content, [bool]$CutLastSpace); + + [hashtable]$IniContent = @{}; + [string]$IniKey = ''; + [string]$SubIniKey = ''; + + # First, loop all lines of our NTP config + foreach ($item in $content) { + # At first we require to parse the section argument for the config + if ($item.Contains('[')) { + $IniKey = $item.Replace('[', '').Replace(']', ''); + $IniContent.Add($IniKey, @{ }); + continue; + } + + if ([string]::IsNullOrEmpty($item) -eq $TRUE) { + continue; + } + + # In case our entry does not contain ':', we are not loading a config entry + if ($item.Contains(':') -eq $FALSE) { + $SubIniKey = $item; + $IniContent[$IniKey].Add($SubIniKey, @{ }); + continue; + } + + # Now as we found an config entry point, split the result at first to get + # the key of our config. Afterwards we load the value by removing all + # spaces before the actual value + [array]$ConfigData = $item.Split(':'); + [string]$ConfigKey = $ConfigData[0]; + [string]$ConfigValue = $item.Substring($item.IndexOf(':') + 1, $item.Length - $item.IndexOf(':') - 1); + + # Some INI files (like NTP) add additional details behind the values if they + # are configured by Local or Remote for example. With this we can cut these + # informations out, idependently from our configured OS language + if ($CutLastSpace -eq $TRUE) { + $ConfigValue = $ConfigValue.Substring(0, $ConfigValue.LastIndexOf(' ')); + } + + while ($ConfigValue[0] -eq ' ') { + $ConfigValue = $ConfigValue.Substring(1, $ConfigValue.Length - 1); + } + + # It could happen that within a section keys are being overwritten again + # We should take care of this and update a possible added key with the + # next configured values to receive only the correct configuration as result + # as it is interpreted by the time service + if ([string]::IsNullOrEmpty($SubIniKey) -eq $TRUE) { + if ($IniContent[$IniKey].ContainsKey($ConfigKey) -eq $FALSE) { + $IniContent[$IniKey].Add($ConfigKey, $ConfigValue); + } else { + $IniContent[$IniKey][$ConfigKey] = $ConfigValue; + } + } else { + if ($IniContent[$IniKey][$SubIniKey].ContainsKey($ConfigKey) -eq $FALSE) { + $IniContent[$IniKey][$SubIniKey].Add($ConfigKey, $ConfigValue); + } else { + $IniContent[$IniKey][$SubIniKey][$ConfigKey] = $ConfigValue; + } + } + } + + return $IniContent; +} + +return $IniParser; \ No newline at end of file diff --git a/core/include/utils/Modules.ps1 b/core/include/utils/Modules.ps1 new file mode 100644 index 0000000..dc75cbb --- /dev/null +++ b/core/include/utils/Modules.ps1 @@ -0,0 +1,154 @@ +<# + # Helper class for accessing and handling modules in a + # more easier and managed way + #> + +$Modules = New-Object -TypeName PSObject; + +$Modules | Add-Member -membertype ScriptMethod -name 'LoadIncludes' -value { + param([string]$modulename, $Config); + + $modulename = $modulename.ToLower(); + $modulename = $modulename.Replace('.ps1', ''); + + [string]$ModuleDir = Join-Path ` + -Path $Icinga2.App.RootPath ` + -ChildPath ( + [string]::Format( + '\modules\include\{0}', + $modulename + ) + ) + + [hashtable]$ModuleIndludes = @{}; + + if ( (Test-Path $ModuleDir) -eq $FALSE) { + return $ModuleIndludes; + } + + Get-ChildItem $ModuleDir -Filter *.ps1 | + Foreach-Object { + [string]$name = $_.Name.ToLower().Replace( + '.ps1', + '' + ); + try { + $ModuleIndludes.Add( + $name, + (& $_.FullName -Config $Config) + ); + } catch { + $ModuleIndludes.Add( + $name, + [string]::Format( + 'Failed to execute include "{0}" for module "{1}". Exception: {2}', + $name, + $modulename, + $_.Exception.Message + ) + ); + } + } + + return $ModuleIndludes; +} + +$Modules | Add-Member -membertype ScriptMethod -name 'FlushModuleCache' -value { + param([string]$modulename); + + if ($Icinga2.Cache.Modules.ContainsKey($modulename) -eq $FALSE) { + return; + } + + $Icinga2.Cache.Modules[$modulename] = @{ }; +} + +$Modules | Add-Member -membertype ScriptMethod -name 'AddCacheElement' -value { + param([string]$modulename, [string]$cachename, $value); + + if ($Icinga2.Cache.Modules.ContainsKey($modulename) -eq $FALSE) { + $Icinga2.Cache.Modules.Add($modulename, @{ }); + } + + if ($Icinga2.Cache.Modules[$modulename].ContainsKey($cachename) -eq $FALSE) { + $Icinga2.Cache.Modules[$modulename].Add($cachename, $value); + } else { + $Icinga2.Cache.Modules[$modulename][$cachename] = $value; + } +} + +$Modules | Add-Member -membertype ScriptMethod -name 'GetCacheElement' -value { + param([string]$modulename, [string]$cachename); + + if ($Icinga2.Cache.Modules.ContainsKey($modulename) -eq $FALSE) { + return @{ }; + } + + if ($Icinga2.Cache.Modules[$modulename].ContainsKey($cachename) -eq $FALSE) { + return @{ }; + } + + return $Icinga2.Cache.Modules[$modulename][$cachename]; +} + +$Modules | Add-Member -membertype ScriptMethod -name 'GetHashtableDiff' -value { + param([hashtable]$new, [hashtable]$cache, [array]$addkeys); + + [hashtable]$DiffTable = @{ + FullList = @{ }; + Removed = @( ); + Added = $null; + Modified = @{ }; + } + + if ($cache -eq $null -or $cache.Count -eq 0) { + $DiffTable.FullList = $new; + } else { + # Each additional call will only send the diffs to the server + $int = 0; + foreach ($cachedProcess in $cache.Keys) { + $oldProcess = $cache[$cachedProcess]; + + # In case a service is no longer present on our system, send the process Id + # only so we can delete it from our database + if ($new.ContainsKey($cachedProcess) -eq $FALSE) { + $DiffTable['Removed'] += $oldProcess.ProcessId; + } else { + # If we know about a process, only send the values which have been updated + # since the last check + $newProcess = $new[$cachedProcess]; + + foreach ($entry in $newProcess.Keys) { + $oldValue = $oldProcess[$entry]; + $newValue = $newProcess[$entry]; + + if ($oldValue -ne $newValue) { + if ($DiffTable['Modified'].ContainsKey($cachedProcess) -eq $FALSE) { + $DiffTable['Modified'].Add($cachedProcess, @{ }); + } + $DiffTable['Modified'][$cachedProcess].Add($entry, $newValue); + } + } + + if ($DiffTable['Modified'].ContainsKey($cachedProcess) -eq $TRUE) { + foreach($entry in $addkeys) { + if ($DiffTable['Modified'][$cachedProcess].ContainsKey($entry) -eq $FALSE -and + $newProcess.ContainsKey($entry) -eq $TRUE) { + + $DiffTable['Modified'][$cachedProcess].Add($entry, $newProcess[$entry]); + } + } + } + + $new.Remove($cachedProcess); + } + } + + # All other processes are new and should be added + $DiffTable['Added'] = $new; + } + + return $DiffTable; +} + +return $Modules; \ No newline at end of file diff --git a/core/include/utils/SSL.ps1 b/core/include/utils/SSL.ps1 new file mode 100644 index 0000000..db32805 --- /dev/null +++ b/core/include/utils/SSL.ps1 @@ -0,0 +1,115 @@ + +$SSL = New-Object -TypeName PSObject; +$SSL | Add-Member -membertype ScriptMethod -name 'LoadServerCertificate' -value { + + if ((Get-Icinga-Setup) -eq $FALSE) { + $Icinga2.Log.WriteConsole( + $Icinga2.Enums.LogState.Warning, + 'The module has not been configured yet. Skipping certificate loading' + ); + return; + } + + try { + $CertStore = New-Object System.Security.Cryptography.X509Certificates.X509Store( + $Icinga2.Config.'certstore.name', + $Icinga2.Config.'certstore.location' + ); + $CertStore.Open("ReadOnly"); + + $ServerCertificate = $null; + + [string]$CertName = $Icinga2.Config.'certstore.certificate.name'; + [string]$CertThumbprint = $Icinga2.Config.'certstore.certificate.thumbprint'; + + # Try to discover the certificate based on our FQDN + if ([string]::IsNullOrEmpty($CertName) -eq $TRUE -And [string]::IsNullOrEmpty($CertThumbprint) -eq $TRUE) { + $CertName = [string]::Format( + '{0}.{1}', + (Get-WmiObject Win32_ComputerSystem).DNSHostName, + (Get-WmiObject win32_computersystem).Domain + ); + + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Info, + [string]::Format( + 'Trying to discover certificate for this host with FQDN "{0}"', + $CertName + ) + ); + } + + foreach ($cert in $CertStore.Certificates) { + if ([string]::IsNullOrEmpty($CertThumbprint) -eq $FALSE) { + if ($CertThumbprint.ToLower() -eq $cert.Thumbprint.ToLower()) { + $ServerCertificate = $cert; + break; + } + } + + if ([string]::IsNullOrEmpty($CertName) -eq $FALSE) { + [string]$CNCertName = [string]::Format('CN={0}', $CertName.ToLower()); + if ($CNCertName.ToLower() -eq $cert.Subject.ToLower()) { + $ServerCertificate = $cert; + + try { + $result = Test-Certificate -Cert $cert -ErrorAction SilentlyContinue -WarningAction SilentlyContinue; + if ($result -eq $FALSE) { + continue; + } + } catch { + continue; + } + + break; + } + } + } + + $certificate = $null; + + if ($ServerCertificate -ne $null) { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Debug, + [string]::Format( + 'Using certificate "{0}" with thumbprint "{1}"', + $ServerCertificate.Subject, + $ServerCertificate.Thumbprint + ) + ); + $certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2; + $certificate.Import($ServerCertificate.RawData) + } + + $CertStore.Close(); + + return $certificate; + } catch [System.ComponentModel.Win32Exception] { + # This error occures in case we provide a cert store and location which is not accessable + # from our current user. We have to simply drop everything and close every possible + # connection + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Exception, + 'SSL-Error: Unable to access provided certificate from the user space this module is started with.' + ); + } catch [System.NotSupportedException] { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Exception, + 'The used SSL certificate is not providing a linked private key and cannot be used as Server certificate' + ); + } catch { + # Handle every other error here + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Exception, + $_.Exception.Message + ); + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Exception, + $_.Exception + ); + } + + return $null; +} + +return $SSL; \ No newline at end of file diff --git a/core/include/utils/SecureString.ps1 b/core/include/utils/SecureString.ps1 new file mode 100644 index 0000000..52848e9 --- /dev/null +++ b/core/include/utils/SecureString.ps1 @@ -0,0 +1,29 @@ +<# + # Helper class allowing to easily convert strings into SecureStrings + # and vice-versa + #> + +$SecureString = New-Object -TypeName PSObject; + +$SecureString | Add-Member -membertype ScriptMethod -name 'ConvertTo' -value { + param([string]$string); + + [SecureString]$SecureString = ConvertTo-SecureString -AsPlainText $string -Force; + + return $SecureString; +} + +$SecureString | Add-Member -membertype ScriptMethod -name 'ConvertFrom' -value { + param([SecureString]$SecureString); + + if ($SecureString -eq $null) { + return ''; + } + + [IntPtr]$BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureString) + [string]$String = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR) + + return $String; +} + +return $SecureString; \ No newline at end of file diff --git a/core/include/utils/WebHelper.ps1 b/core/include/utils/WebHelper.ps1 new file mode 100644 index 0000000..dfdd2dd --- /dev/null +++ b/core/include/utils/WebHelper.ps1 @@ -0,0 +1,296 @@ +$WebHelper = New-Object -TypeName PSObject; + +$WebHelper | Add-Member -membertype ScriptMethod -name 'ParseApiMessage' -value { + param([SecureString]$message); + + try { + [hashtable]$HeaderContent = @{}; + + if ($message -eq $null) { + return $HeaderContent; + } + + [string]$HTMLMessage = $Icinga2.Utils.SecureString.ConvertFrom($message); + + [string]$HTMLContent = ''; + if ($HTMLMessage.Contains("`r`n`r`n")) { + [int]$EOFIndex = $HTMLMessage.IndexOf("`r`n`r`n") + 4; + $HTMLContent = $HTMLMessage.Substring( + $EOFIndex, + $HTMLMessage.Length - $EOFIndex + ); + # Remove the content from our message + if ([string]::IsNullOrEmpty($HTMLContent) -eq $FALSE) { + $HTMLMessage = $HTMLMessage.Replace($HTMLContent, ''); + } + } + + [array]$SingleHeaders = $HTMLMessage.Split("`r`n"); + $HTMLMessage = $null; + + # At first read the method, the http call and the protocol + $HeaderContent.Add( + 'base', + $this.ParseMethodAndUrl($SingleHeaders[0]) + ); + # Now add possible content to our hashtable + $HeaderContent.Add( + 'content', + $HTMLContent + ); + # Drop the first entry of the array, as we no longer require it + $SingleHeaders = $SingleHeaders | Select-Object -Skip 1; + + # Read the headers from the array + $HeaderContent.Add( + 'headers', + $this.ParseHeaders($SingleHeaders) + ); + # Flush the array from the memory + $SingleHeaders = $null; + + if ($HeaderContent.headers.ContainsKey('Authorization')) { + $HeaderContent.Add( + 'credentials', + $this.ParseUserCredentials( + $HeaderContent.headers.Authorization + ) + ); + $HeaderContent.headers.Remove('Authorization'); + } + + return $HeaderContent; + } + catch { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Exception, + [string]::Format( + 'Failed to parse HTTP content and headers. Error {0}', + $_.Exception.Message + ) + ); + } + + return $null; +} + +$WebHelper | Add-Member -membertype ScriptMethod -name 'ParseMethodAndUrl' -value { + param([string]$message); + + [array]$Content = $message.Split(' '); + [uri]$UriData = [uri][string]::Format( + 'https://localhost{0}', + $Content[1] + ); + + return @{ + method = $Content[0]; + call = [System.Web.HttpUtility]::UrlDecode( + $Content[1] + ); + protocol = $Content[2]; + segments = $UriData.Segments; + query = [System.Web.HttpUtility]::UrlDecode( + $UriData.Query + ); + } +} + +$WebHelper | Add-Member -membertype ScriptMethod -name 'ParseHeaders' -value { + param([array]$message); + + [hashtable]$Headers = @{}; + + foreach ($header in $message) { + [string]$HeaderName = ''; + [string]$HeaderValue = ''; + # Skip empty array values + if ([string]::IsNullOrEmpty($header) -eq $FALSE) { + if ($header.Contains(':')) { + [array]$Element = $header.Split(':'); + $HeaderName = $Element[0]; + + if ($Element.Count -gt 1) { + for ($i = 1; $i -le ($Element.Count - 1); $i++) { + $HeaderValue = -Join( + $HeaderValue, + $Element[$i], + ':' + ); + } + } + + # Remove the last added ':' at the end of the string + if ($HeaderValue.Length -gt 1) { + $HeaderValue = $HeaderValue.Substring( + 0, + $HeaderValue.Length - 1 + ); + } + + # In case the first letter of our value is a space, remove it + while ($HeaderValue[0] -eq ' ') { + $HeaderValue = $HeaderValue.Substring( + 1, + $HeaderValue.Length - 1 + ); + } + + if ($Headers.ContainsKey($HeaderName) -eq $FALSE) { + # We have to modify the Authorization header value a little more and also + # ensure we store it as secure string within our module + if ($HeaderName -eq 'Authorization') { + [array]$AuthArray = $HeaderValue.Split(' '); + # TODO: Shall we handle each auth type differently? + $Headers.Add( + $HeaderName, + $Icinga2.Utils.SecureString.ConvertTo($AuthArray[1]) + ); + $AuthArray = $null; + } else { + $Headers.Add($HeaderName, $HeaderValue); + } + } + } else { + $Headers.Add($header, $null); + } + } + } + + return $Headers; +} + +$WebHelper | Add-Member -membertype ScriptMethod -name 'ParseUserCredentials' -value { + param([SecureString]$Base64String); + + [hashtable]$Credentials = @{}; + + if ($Base64String -eq $null) { + return $Credentials; + } + + # Convert the Base64 Secure String back to a normal string + [string]$PlainAuth = [System.Text.Encoding]::UTF8.GetString( + [System.Convert]::FromBase64String( + $Icinga2.Utils.SecureString.ConvertFrom($Base64String) + ) + ); + $Base64String = $null; + + # If no ':' is within the string, the credential data is not properly formated + if ($PlainAuth.Contains(':') -eq $FALSE) { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Warning, + 'Received invalid formated credentials as Base64 encoded.' + ); + $PlainAuth = $null; + return $Credentials; + } + + try { + # Build our User Data and Password from the string + [string]$UserData = $PlainAuth.Substring( + 0, + $PlainAuth.IndexOf(':') + ); + $Credentials.Add( + 'password', + $Icinga2.Utils.SecureString.ConvertTo( + $PlainAuth.Substring( + $PlainAuth.IndexOf(':') + 1, + $PlainAuth.Length - $UserData.Length - 1 + ) + ) + ); + + $PlainAuth = $null; + + # Extract a possible domain + if ($UserData.Contains('\')) { + # Split the auth string on the '\' + [array]$AuthData = $UserData.Split('\'); + # First value of the array is the Domain, second is the Username + $Credentials.Add('domain', $AuthData[0]); + $Credentials.Add( + 'user', + $Icinga2.Utils.SecureString.ConvertTo( + $AuthData[1] + ) + ); + $AuthData = $null; + } else { + $Credentials.Add('domain', $null); + $Credentials.Add( + 'user', + $Icinga2.Utils.SecureString.ConvertTo( + $UserData + ) + ); + } + + $UserData = $null; + } catch { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Error, + 'Failed to handle authentication request. An exception occured while processing the request.' + ); + } + + return $Credentials; +} + +$WebHelper | Add-Member -membertype ScriptMethod -name 'ParseUrlCommand' -value { + param([string]$Argument); + + [hashtable]$StructuredCommand = @{}; + # If no = is included, we don't need to handle anything special + if ($Argument.Contains('=') -eq $FALSE) { + $StructuredCommand.Add($Argument, $null); + return $StructuredCommand; + } + + [int]$SeperatorIndex = $Argument.IndexOf('='); + [string]$Command = $Argument.Substring( + 0, + $SeperatorIndex + ); + $Command = $Command.Replace(' ', '').ToLower(); + + [array]$Values = @(); + [string]$Value = $Argument.Substring( + $SeperatorIndex + 1, + $Argument.Length - $SeperatorIndex - 1 + ); + + if ($Value.Contains(',') -eq $TRUE) { + [array]$Entries = $Value.Split(','); + foreach ($entry in $Entries) { + [string]$ParsedValue = $entry; + # Cut spaces before names of inputs + while ($ParsedValue[0] -eq ' ') { + $ParsedValue = $ParsedValue.Substring( + 1, + $ParsedValue.Length - 1 + ); + } + + # Cut spaces after names of inputs + while ($ParsedValue[$ParsedValue.Length - 1] -eq ' ') { + $ParsedValue = $ParsedValue.Substring( + 0, + $ParsedValue.Length - 1 + ); + } + $Values += $ParsedValue.ToLower(); + } + } else { + $Values += $Value.Replace(' ', '').ToLower(); + } + + # Filter out duplicate entries + $Values = $Values | Select-Object -Unique; + $StructuredCommand.Add($Command, $Values); + return $StructuredCommand; +} + +return $WebHelper; \ No newline at end of file diff --git a/core/init.ps1 b/core/init.ps1 new file mode 100644 index 0000000..c5f4842 --- /dev/null +++ b/core/init.ps1 @@ -0,0 +1,94 @@ +# This script will initialse the entire module configuration for easier usage +param ( + [string]$RootDirectory = '', + [string]$ModuleName = '' +); + +# Create an internal 'namespace' for our environment +Set-Variable -Name Icinga2 -Option Constant -Value @{ + Function = @( + 'Get-Icinga-Lib', + 'Get-Icinga-Object', + 'Get-Icinga-Service', + 'Start-Icinga-Service', + 'Stop-Icinga-Service', + 'Restart-Icinga-Service', + 'New-Icinga-Service', + 'Remove-Icinga-Service', + 'New-Icinga-Setup', + 'Get-Icinga-Setup', + 'Start-Icinga-Daemon', + 'Stop-Icinga-Daemon', + 'Start-Icinga-Checker', + 'Stop-Icinga-Checker', + 'Get-Icinga-Command', + 'New-Icinga-Monitoring', + 'Get-Icinga-Counter', + 'Get-Icinga-Config', + 'Set-Icinga-Config', + 'Remove-Icinga-Config', + 'New-Icinga-Config' + ); +} + +# Define temporary variables to store the main current root and module name +# Note: Never use this variables within the module besides inside '\core\includes\' +$_InternalTempVariables = @{ + RootPath = $RootDirectory; + ModuleName = $ModuleName; +} +# End definition of temporary variables + +# Load all PowerShell scripts within our '\core\include\' directory and add the content with the name +# of the script into our namespace +Get-ChildItem (Join-Path -Path $PSScriptRoot -ChildPath '\include\') -Filter *.ps1 | + Foreach-Object { + $path = $_.FullName; + $name = $_.Name.Replace('.ps1', ''); + + # Add variables to a global namespace. Should only be used within the + # same PowerShell instance + try { + $include = (& $path); + } catch { + Write-Host ( + [string]::Format( + 'Failed to execute core module "{0}". Exception: {1}', + $name, + $_.Exception.Message + ) + ); + } + + if ([bool]($include.PSobject.Properties.Name -eq 'static') -eq $FALSE -Or $include.static -eq $TRUE) { + $Icinga2.Add($name, $include); + } + } + +# Flush the internal temp variable cache +$_InternalTempVariables = $null; + +# Load our System.Web helper class +[Reflection.Assembly]::LoadWithPartialName("System.Web") | Out-Null; + +$Icinga2.Add( + 'Cache', + @{ + # This will allow us to dynamicly initialise Performance Counters during + # startup to speed up actual checks later on. Of course counters will be + # cached anyway once they are executed, but will speed up first check + # executions for CPU Performance Counters for example + PerformanceCounter = @{ }; + # Pre-Load the Server SSL Certificate + Certificates = @{ Server = $Icinga2.Utils.SSL.LoadServerCertificate() }; + # Create a instance for storing TCP Sockets (in case we later want to listen in multi-sockets) + Sockets = @{ }; + # Store our checker configuration we receive from the remote endpoint + Checker = @{ }; + # This cache can be used for storing informations of modules to compare send informations + # as well as required data for a later execution of the same module again + Modules = @{ }; + } +); + +return $Icinga2; \ No newline at end of file diff --git a/core/monitoring.ps1 b/core/monitoring.ps1 new file mode 100644 index 0000000..3af9c6f --- /dev/null +++ b/core/monitoring.ps1 @@ -0,0 +1,118 @@ +param( + [array]$Include = @(), + [array]$Exclude = @(), + [boolean]$ListModules = $FALSE, + $Config = $null, + [string]$AgentRoot = '' +) + +function ClassMonitoring() +{ + param( + [array]$Include = @(), + [array]$Exclude = @(), + [boolean]$ListModules = $FALSE, + $Config = $null, + [string]$AgentRoot = '' + ) + + [string]$ModuleDirectory = Join-Path $AgentRoot -ChildPath 'modules'; + [hashtable]$ModuleList = @{}; + [array]$AvailableModules = @(); + $ResultList = New-Object psobject -prop @{}; + + # Let's do a small fix here: We have defined 'include' within the URL, but + # we haven't specified any values. So lets assume we want to load all + # modules + if ($Include.Count -eq 1 -And [string]::IsNullOrEmpty($Include[0])) { + $Include[0] = '*'; + } + + # In case no includes are specified, lets include everything + if ($Include.Count -eq 0) { + $Include += '*'; + } + + <# + # In case no filter is specified, we asume we want to collect everything. + # Lets fetch all PowerShell Scripts within our module Directory + # Will also be used to return a list of installed modules + #> + if (($Include.Count -eq 1 -And $Include[0] -eq '*') -Or $ListModules) { + Get-ChildItem $ModuleDirectory -Filter *.ps1 | + Foreach-Object { + $path = $_.FullName + $name = $_.Name.Replace('.ps1', '').ToLower(); + + $ModuleList.Add($name, $path); + $AvailableModules += $name; + } + + if ($ListModules) { + return $AvailableModules; + } + } else { + # In case we provided a filter, try to locate these modules + foreach ($module in $Include) { + # Just to ensure we skip this argument in case it is provided + if ($module -eq '*') { + continue; + } + $module = $module.ToLower(); + [string]$file = [string]::Format('{0}.ps1', $module); + [string]$path = Join-Path $ModuleDirectory -ChildPath $file; + + if ($ModuleList.ContainsKey($module) -eq $FALSE) { + $ModuleList.Add($module, $path); + } + } + } + + foreach ($module in $Exclude) { + if ($ModuleList.ContainsKey($module)) { + $ModuleList.Remove($module); + } + } + + [System.Diagnostics.Stopwatch]$ModuleTimer = New-Object System.Diagnostics.Stopwatch; + # Now as we have our module list available, lets execute them to fetch informations + foreach ($module in $ModuleList.Keys) { + $ModuleTimer.Start(); + [string]$path = $ModuleList[$module]; + [hashtable]$ModuleResult = @{}; + $moduleConfig = $null; + + if ($Config -ne $null -AND $Config.$module -ne $null) { + $moduleConfig = $Config.$module; + } + + # First test if the specified module is available + if (Test-Path ($path)) { + try { + # If it is, execute the script and return the output + $ModuleResult.Add('output', (&$path -Config $moduleConfig)); + $ModuleResult.Add('response', 200); + $ModuleResult.Add('error', $null); + } catch { + # In case the script we tried to execute runs into a failure, return the exception message as result + $ModuleResult.Add('output', $null); + $ModuleResult.Add('response', 500); + $ModuleResult.Add('error', [string]::Format('Failed to execute module "{0}". Exeception: {1}', $module, $_.Exception.Message)); + } + } else { + # Include the module to our output with a small notify message + $ModuleResult.Add('output', $null); + $ModuleResult.Add('response', 404); + $ModuleResult.Add('error', 'Module not found'); + } + + $ModuleResult.Add('execution', $ModuleTimer.Elapsed.TotalSeconds); + $ModuleTimer.Stop(); + + $ResultList | Add-Member -Name $module -Type NoteProperty -Value $ModuleResult; + } + + return $ResultList; +} + +return ClassMonitoring -Include $Include -Exclude $Exclude -ListModules $ListModules -Config $Config -AgentRoot $AgentRoot; \ No newline at end of file diff --git a/core/perfcounter.ps1 b/core/perfcounter.ps1 new file mode 100644 index 0000000..99839be --- /dev/null +++ b/core/perfcounter.ps1 @@ -0,0 +1,577 @@ +param( + [string]$Counter = '', + [string]$ListCounter = '', + [array]$CounterArray = @(), + [boolean]$ListCategories = $FALSE, + [boolean]$SkipWait = $FALSE, + # These arguments apply to CreateStructuredPerformanceCounterTable + # This is the category name we want to create a structured output + # Example: 'Network Interface' + [string]$CreateStructuredOutputForCategory = '', + # This is the hashtable of Performance Counters, created by + # PerformanceCounterArray + [hashtable]$StructuredCounterInput = @{}, + # This argument is just a helper to replace certain strings within + # a instance name with simply nothing. + # Example: 'HarddiskVolume1' => '1' + [array]$StructuredCounterInstanceCleanup = @() +); + +# This is our internal cache for Performance Counters already loaded +# In case the Icinga Agent is running as daemon, this hashtable is +# already initialised at the beginning. But if we run the Agent +# from the Powershell directly, we will require to build this cache +# within the environment to work properly and to receive valid data +if ($Icinga2.Cache.PerformanceCounter -eq $null) { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Debug, + 'Creating new performance counter cache' + ); + $Icinga2.Cache.PerformanceCounter = @{}; +} + +$Icinga2.Log.Write( + $Icinga2.Enums.LogState.Debug, + [string]::Format( + 'Performance Counter Cache content {0}', + ($Icinga2.Cache.PerformanceCounter | Out-String) + ) +); + +<# + # This function will provide a virtual object, containing an array + # of Performance Counters. The object has the following members: + # Name + # Value + # This will ensure we will not have to worry about looping an array + # of mutltiple instances within a counter handler, because this + # function will deal with everything, returning an hashtable + # containing the parent counter name including the values and + # samples for every single instance + #> +function PerformanceCounterArray() +{ + param( + [string]$FullName = '', + [array]$PerformanceCounters = @() + ); + + $pc_instance = New-Object -TypeName PSObject; + $pc_instance | Add-Member -membertype NoteProperty -name 'FullName' -value $FullName; + $pc_instance | Add-Member -membertype NoteProperty -name 'Counters' -value $PerformanceCounters; + + $pc_instance | Add-Member -membertype ScriptMethod -name 'Name' -value { + return $this.FullName; + } + + $pc_instance | Add-Member -membertype ScriptMethod -name 'Value' -value { + [hashtable]$CounterResults = @{}; + + foreach ($counter in $this.Counters) { + $CounterResults.Add($counter.Name(), $counter.Value()); + } + + return $CounterResults; + } + + return $pc_instance; +} + +<# + # This function will create a custom Performance Counter object with + # already initialised counters, which can be accessed with the + # following members: + # Name + # Value + # Like the PerformanceCounterArray, this will allow to fetch the + # current values of a single counter instance including the name + # of the counter. Within the PerformanceCounterArray function, + # objects created by this function are used. + #> +function PerformanceCounterObject() +{ + param( + [string]$FullName = '', + [string]$Category = '', + [string]$Instance = '', + [string]$Counter = '', + [boolean]$SkipWait = $FALSE + ); + + $pc_instance = New-Object -TypeName PSObject; + $pc_instance | Add-Member -membertype NoteProperty -name 'FullName' -value $FullName; + $pc_instance | Add-Member -membertype NoteProperty -name 'Category' -value $Category; + $pc_instance | Add-Member -membertype NoteProperty -name 'Instance' -value $Instance; + $pc_instance | Add-Member -membertype NoteProperty -name 'Counter' -value $Counter; + $pc_instance | Add-Member -membertype NoteProperty -name 'PerfCounter' -value $Counter; + $pc_instance | Add-Member -membertype NoteProperty -name 'SkipWait' -value $SkipWait; + + $pc_instance | Add-Member -membertype ScriptMethod -name 'Init' -value { + + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Debug, + [string]::Format('Creating new Counter for Category {0} with Instance {1} and Counter {2}. Full Name "{3}"', + $this.Category, + $this.Instance, + $this.Counter, + $this.FullName + ) + ); + + # Create the Performance Counter object we want to access + $this.PerfCounter = New-Object System.Diagnostics.PerformanceCounter; + $this.PerfCounter.CategoryName = $this.Category; + $this.PerfCounter.CounterName = $this.Counter; + + # Only add an instance in case it is defined + if ([string]::IsNullOrEmpty($this.Instance) -eq $FALSE) { + $this.PerfCounter.InstanceName = $this.Instance + } + + # Initialise the counter + try { + $this.PerfCounter.NextValue() | Out-Null; + } catch { + # Nothing to do here, will be handled later + } + + <# + # For some counters we require to wait a small amount of time to receive proper data + # Other counters do not need these informations and we do also not require to wait + # for every counter we use, once the counter is initialised within our environment. + # This will allow us to skip the sleep to speed up loading counters + #> + if ($this.SkipWait -eq $FALSE) { + Start-Sleep -Milliseconds 500; + } + } + + # Return the name of the counter as string + $pc_instance | Add-Member -membertype ScriptMethod -name 'Name' -value { + return $this.FullName; + } + + <# + # Return a hashtable containting the counter value including the + # Sample values for the counter itself. In case we run into an error, + # keep the counter construct but add an error message in addition. + #> + $pc_instance | Add-Member -membertype ScriptMethod -name 'Value' -value { + [hashtable]$CounterData = @{}; + + try { + [string]$CounterType = $this.PerfCounter.CounterType; + $CounterData.Add('value', $this.PerfCounter.NextValue()); + $CounterData.Add('sample', $this.PerfCounter.NextSample()); + $CounterData.Add('help', $this.PerfCounter.CounterHelp); + $CounterData.Add('type', $CounterType); + $CounterData.Add('error', $null); + } catch { + $CounterData = @{}; + $CounterData.Add('value', $null); + $CounterData.Add('sample', $null); + $CounterData.Add('help', $null); + $CounterData.Add('type', $null); + $CounterData.Add('error', $_.Exception.Message); + } + + return $CounterData; + } + + # Initialiste the entire counter and internal handlers + $pc_instance.Init(); + + # Return this custom object + return $pc_instance; +} + +<# + # If some informations are missing, it could happen that + # we are unable to create a Performance Counter. + # In this case we will use this Null Object, containing + # the same member functions but allowing us to maintain + # stability without unwanted exceptions + #> + function PerformanceCounterNullObject() + { + param( + [string]$FullName = '', + [string]$ErrorMessage = '' + ); + + $pc_instance = New-Object -TypeName PSObject; + $pc_instance | Add-Member -membertype NoteProperty -name 'FullName' -value $FullName; + $pc_instance | Add-Member -membertype NoteProperty -name 'ErrorMessage' -value $ErrorMessage; + + $pc_instance | Add-Member -membertype ScriptMethod -name 'Name' -value { + return $this.FullName; + } + + $pc_instance | Add-Member -membertype ScriptMethod -name 'Value' -value { + [hashtable]$ErrorMessage = @{}; + + $ErrorMessage.Add('value', $null); + $ErrorMessage.Add('sample', $null); + $ErrorMessage.Add('help', $null); + $ErrorMessage.Add('type', $null); + $ErrorMessage.Add('error', $this.ErrorMessage); + + return $ErrorMessage; + } + + return $pc_instance; + } + + <# + # This function will make monitoring an entire list of + # Performance counters even more easier. We simply provide + # an array of Performance Counters to this module + # and we will receive a construct-save result of an + # hashtable with all performance counters including + # the corresponding values. In that case the code + # size decreases for larger modules. + # Example: + $counter = Get-Icinga-Counter -CounterArray @( + '\Memory\Available Bytes', + '\Memory\% Committed Bytes In Use' + ); + #> + function CreatePerformanceCounterResult() + { + param( + [array]$CounterArray = @() + ) + + [hashtable]$CounterResult = @{}; + [bool]$RequireSleep = $FALSE; + foreach ($counter in $CounterArray) { + # We want to speed up things with loading, so we will check if a specified + # Counter is already cached within our hashtable. If it is not, we sleep + # at the end of the function the required 500ms and don't have to wait + # NumOfCounters * 500 milliseconds for the first runs. This will speed + # up the general loading of counters and will not require some fancy + # pre-caching / configuration handler + if ($Icinga2.Cache.PerformanceCounter -ne $null) { + if ($Icinga2.Cache.PerformanceCounter.ContainsKey($counter) -eq $FALSE) { + $RequireSleep = $TRUE; + } + } + $obj = CreatePerformanceCounter -Counter $counter -SkipWait $TRUE; + if ($CounterResult.ContainsKey($obj.Name()) -eq $FALSE) { + $CounterResult.Add($obj.Name(), $obj.Value()); + } + } + + # Above we initialse ever single counter and we only require a sleep once + # in case a new, yet unknown counter was added + if ($RequireSleep) { + Start-Sleep -Milliseconds 500; + + # Agreed, this is some sort of code duplication but it wouldn't make + # any sense to create a own function for this. Why are we doing + # this anway? + # Simple: In case we found counters which have yet not been initialised + # we did this above. Now we have waited 500 ms to receive proper + # values from these counters. As the previous generated result + # might have contained counters with 0 results, we will now + # check all counters again to receive the proper values. + # Agreed, might sound like a overhead, but the impact only + # applies to the first call of the module with the counters. + # This 'duplication' however decreased the execution from + # certain modules from 25s to 1s on the first run. Every + # additional run is then beeing executed within 0.x s + # which sounds like a very good performance and solution + $CounterResult = @{}; + foreach ($counter in $CounterArray) { + $obj = CreatePerformanceCounter -Counter $counter -SkipWait $TRUE; + if ($CounterResult.ContainsKey($obj.Name()) -eq $FALSE) { + $CounterResult.Add($obj.Name(), $obj.Value()); + } + } + } + + return $CounterResult; + } + +<# + # This is the main function which is called from this script, constructing our counters + # and loading possible sub-instances from our Performance Counter. + # It will return either an PerformanceCounterObject or PerformanceCounterArray + # which both contain the same members, allowing us to dynamicly use the objects + # without having to worry about exception. + #> +function CreatePerformanceCounter() +{ + param( + [string]$Counter = '', + [boolean]$SkipWait = $FALSE + ); + + # Simply use the counter name, like + # \Paging File(_total)\% Usage + if ([string]::IsNullOrEmpty($Counter) -eq $TRUE) { + return (PerformanceCounterNullObject -FullName $Counter -ErrorMessage 'Failed to initialise counter, as no counter was specified.'); + } + + [array]$CounterArray = $Counter.Split('\'); + [string]$UseCounterCategory = ''; + [string]$UseCounterName = ''; + [string]$UseCounterInstance = ''; + + # If we add the counter as it should be + # \Paging File(_total)\% Usage + # the first array element will be an empty string we can skip + # Otherwise the name was wrong and we should not continue + if (-Not [string]::IsNullOrEmpty($CounterArray[0])) { + return (PerformanceCounterNullObject -FullName $Counter -ErrorMessage ([string]::Format('Failed to deserialize counter "{0}". It seems the leading "\" is missing.', $Counter))); + } + + # In case our Performance Counter is containing instances, we should split + # The content and read the instance and counter category out + if ($CounterArray[1].Contains('(')) { + [array]$TmpCounter = $CounterArray[1].Split('('); + $UseCounterCategory = $TmpCounter[0]; + $UseCounterInstance = $TmpCounter[1].Replace(')', ''); + } else { + # Otherwise we only require the category + $UseCounterCategory = $CounterArray[1]; + } + + # At last get the actual counter containing our values + $UseCounterName = $CounterArray[2]; + + # Now as we know how the counter path is constructed and has been splitted into + # the different values, we need to know how to handle the instances of the counter + + # If we specify a instance with (*) we want the module to automaticly fetch all + # instances for this counter. This will result in an PerformanceCounterArray + # which contains the parent name including counters for all instances that + # have been found + if ($UseCounterInstance -eq '*') { + # In case we already loaded the counters once, return the finished array + if ($Icinga2.Cache.PerformanceCounter.ContainsKey($Counter) -eq $TRUE) { + return (PerformanceCounterArray -FullName $Counter -PerformanceCounters $Icinga2.Cache.PerformanceCounter[$Counter]); + } + + # If we need to build the array, load all instances from the counters and + # create single performance counters and add them to a custom array and + # later to a custom object + try { + [array]$AllCountersIntances = @(); + $CounterInstances = New-Object System.Diagnostics.PerformanceCounterCategory($UseCounterCategory); + foreach ($instance in $CounterInstances.GetInstanceNames()) { + [string]$NewCounterName = $Counter.Replace('*', $instance); + $NewCounter = PerformanceCounterObject -FullName $NewCounterName -Category $UseCounterCategory -Counter $UseCounterName -Instance $instance -SkipWait $SkipWait; + $AllCountersIntances += $NewCounter; + } + } catch { + return (PerformanceCounterNullObject -FullName $Counter -ErrorMessage ([string]::Format('Failed to deserialize instances for counter "{0}". Exception: "{1}".', $Counter, $_.Exception.Message))); + } + + # Add the parent counter including the array of Performance Counters to our + # caching mechanism and return the PerformanceCounterArray object for usage + # within the monitoring modules + $Icinga2.Cache.PerformanceCounter.Add($Counter, $AllCountersIntances); + return (PerformanceCounterArray -FullName $Counter -PerformanceCounters $AllCountersIntances); + } else { + # This part will handle the counters without any instances as well as + # specificly assigned instances, like (_Total) CPU usage. + + # In case we already have the counter within our cache, return the + # cached informations + if ($Icinga2.Cache.PerformanceCounter.ContainsKey($Counter) -eq $TRUE) { + return $Icinga2.Cache.PerformanceCounter[$Counter]; + } + + # If the cache is not present yet, create the Performance Counter object, + # and add it to our cache + $NewCounter = PerformanceCounterObject -FullName $Counter -Category $UseCounterCategory -Counter $UseCounterName -Instance $UseCounterInstance -SkipWait $SkipWait; + $Icinga2.Cache.PerformanceCounter.Add($Counter, $NewCounter); + } + + # This function will always return non-instance counters or + # specificly defined instance counters. Performance Counter Arrays + # are returned within their function. This is just to ensure that the + # function looks finished from developer point of view + return $Icinga2.Cache.PerformanceCounter[$Counter]; +} + +# +# This function will get handy in case we want to fetch Counters +# which have instances which might be helpful to group by their +# instances name. This will apply to Disk and Network Interface +# outputs for example, as it would be helpful to combine all +# counter results for a specific disk / interface in one +# result for easier working with these informations +# +function CreateStructuredPerformanceCounterTable +{ + param( + [string]$CounterCategory = '', + [hashtable]$PerformanceCounterHash = @{}, + [array]$InstanceNameCleanupArray = @() + ) + + # The storage variables we require to store our data + [array]$AvailableInstances = @(); + [hashtable]$StructuredCounterData = @{}; + + # With this little trick we can fetch all instances we have and get their unique name + $CounterInstances = New-Object System.Diagnostics.PerformanceCounterCategory($CounterCategory); + foreach ($instance in $CounterInstances.GetInstanceNames()) { + # For some counters we require to apply a 'cleanup' for the instance name + # Example Disks: Some disks are stored with the name + # 'HarddiskVolume1' + # To be able to map the volume correctly to disks, we require to remove + # 'HarddiskVolume' so only '1' will remain, which allows us to map the + # volume correctly afterwards + [string]$CleanInstanceName = $instance; + foreach ($cleanup in $InstanceNameCleanupArray) { + $CleanInstanceName = $CleanInstanceName.Replace($cleanup, ''); + } + $AvailableInstances += $CleanInstanceName; + } + + # Now let the real magic begin. + + # At first we will loop all instances of our Performance Counters, which means all + # instances we have found above. We build a new hashtable then to list the instances + # by their individual name and all corresponding counters as children + # This allows us a structured output with all data for each instance + foreach ($instance in $AvailableInstances) { + + # First build a hashtable for each instance to add data to later + $StructuredCounterData.Add($instance, @{}); + + # Now we need to loop all return values from our Performance Counters + foreach ($InterfaceCounter in $PerformanceCounterHash.Keys) { + # As we just looped the parent counter (Instance *), we now need to + # loop the actual counters for each instance + foreach ($interface in $PerformanceCounterHash[$InterfaceCounter]) { + # Finally let's loop through all the results which contain the values + # to build our new, structured hashtable + foreach ($entry in $interface.Keys) { + # Match the counters based on our current parent index + # (the instance name we want to add the values as children). + if ($entry.Contains('(' + $instance + ')')) { + # To ensure we don't transmit the entire counter name, + # we only want to include the name of the actual counter. + # There is no need to return + # \Network Interface(Desktopadapter Intel[R] Gigabit CT)\Bytes Received/sec + # the naming + # Bytes Received/sec + # is enough + [array]$TmpOutput = $entry.Split('\'); + [string]$OutputName = $TmpOutput[$TmpOutput.Count - 1]; + + # Now add the actual value to our parent instance with the + # improved value name, including the sample and counter value data + $StructuredCounterData[$instance].Add($OutputName, $interface[$entry]); + } + } + } + } + } + + return $StructuredCounterData; +} + +# +# This function will load all available Categories of Performance Counters +# from the registry and outputs them. This will ensure we can fetch the real +# english names instead of the localiced ones +# +function ListCounterCategories() +{ + $RegistryData = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Perflib\009' ` + -Name 'counter' | Select-Object -ExpandProperty Counter; + [array]$Counters = @(); + + # Now lets loop our registry data and fetch only for counter categories + # Ignore everything else and drop the information + foreach ($counter in $RegistryData) { + # First filter out the ID's of the performance counter + if (-Not ($counter -match "^[\d\.]+$") -And [string]::IsNullOrEmpty($counter) -eq $FALSE) { + # Now check if the value we got is a counter category + if ([System.Diagnostics.PerformanceCounterCategory]::Exists($counter) -eq $TRUE) { + $Counters += $counter; + } + } + } + + return $Counters; +} + +# +# Provide the name of a category to fetch all available counters and +# if there are any instances assigned to it +# +function ListCountersFromCategory() +{ + param ([string]$CounterCategory); + + [hashtable]$counters = @{}; + try { + # At first create our Performance Counter object for the category we specified + $Category = New-Object System.Diagnostics.PerformanceCounterCategory($CounterCategory); + + # Now loop through all keys to find the name of available counters + foreach ($counter in $Category.ReadCategory().Keys) { + [string]$CounterInstanceAddition = ''; + + # As counters might also have instances (like interfaces, disks, paging file), we should + # try to load them as well + foreach ($instance in $Category.ReadCategory()[$counter].Keys) { + # If we do not match this magic string, we have multiple instances we can access + # to get informations for different disks, volumes and interfaces for example + if ($instance -ne 'systemdiagnosticsperfcounterlibsingleinstance') { + # Re-Write the name we return of the counter to something we can use directly + # within our modules to load data from. A returned counter will look like this + # for example: + # \PhysicalDisk(*)\avg. disk bytes/read + [string]$UsableCounterName = [string]::Format('\{0}(*)\{1}', $CounterCategory, $counter); + if ($counters.ContainsKey($UsableCounterName) -eq $TRUE) { + $counters[$UsableCounterName] += $Category.ReadCategory()[$counter][$instance]; + } else { + $counters.Add($UsableCounterName, @( $Category.ReadCategory()[$counter][$instance] )); + } + } else { + # For counters with no instances, we still require to return a re-build Performance Counter + # output, to make later usage in our modules very easy. This can look like this: + # \System\system up time + [string]$UsableCounterName = [string]::Format('\{0}\{1}', $CounterCategory, $counter); + $counters.Add($UsableCounterName, $null); + } + } + }; + } catch { + # In case we run into an error, return an error message + $counters.Add('error', $_.Exception.Message); + } + + return $counters; +} + +if ([string]::IsNullOrEmpty($CreateStructuredOutputForCategory) -eq $FALSE) { + return (CreateStructuredPerformanceCounterTable ` + -CounterCategory $CreateStructuredOutputForCategory ` + -PerformanceCounterHash $StructuredCounterInput ` + -InstanceNameCleanupArray $StructuredCounterInstanceCleanup + ) +} + +if ($ListCategories -eq $TRUE) { + return ListCounterCategories; +} + +if ([string]::IsNullOrEmpty($ListCounter) -eq $FALSE) { + return ListCountersFromCategory -CounterCategory $ListCounter; +} + +# Make things easier by simply proividing an array of Performance Counter +# Names we wish to monitor +if ($CounterArray.Count -ne 0) { + return (CreatePerformanceCounterResult -CounterArray $CounterArray); +} + +return CreatePerformanceCounter -Counter $Counter -SkipWait $SkipWait; \ No newline at end of file diff --git a/core/setup.ps1 b/core/setup.ps1 new file mode 100644 index 0000000..f857875 --- /dev/null +++ b/core/setup.ps1 @@ -0,0 +1,97 @@ +param( + [bool]$IsAgentIntalled = $FALSE +) + +function ClassSetup() +{ + param( + [bool]$IsAgentIntalled = $FALSE + ); + + $instance = New-Object -TypeName PSObject; + + $instance | Add-Member -membertype NoteProperty -name 'BaseDirectory' -value (Join-Path $Icinga2.App.RootPath -ChildPath 'agent'); + + $instance | Add-Member -membertype ScriptMethod -name 'Init' -value { + $IsInstalled = Get-Icinga-Config -Key 'setup.installed'; + + if ($IsAgentIntalled) { + if ($IsInstalled -eq $FALSE -Or $IsInstalled -eq $null) { + return 0; + } + } + + $this.CreateDirectories('config'); + $this.CreateDirectories('state'); + + if ($IsInstalled -eq $FALSE -Or $IsInstalled -eq $null) { + $this.InstallEventLog(); + $this.CreateConfig(); + } + + # At this point for this module, we require to return 1 as 'true' + return 1; + } + + $instance | Add-Member -membertype ScriptMethod -name 'CreateDirectories' -value { + param([string]$directory); + + [string]$path = Join-Path $this.BaseDirectory -ChildPath $directory; + if (-Not (Test-Path $path)) { + New-Item $path -ItemType Directory | Out-Null; + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Info, + ([string]::Format('Creating new directory "{0}"', $path)) + ); + } + } + + $instance | Add-Member -membertype ScriptMethod -name 'InstallEventLog' -value { + try { + New-EventLog -LogName Application -Source ($Icinga2.Service.servicedisplayname) -ErrorAction Stop; + $Icinga2.Log.WriteConsole( + $Icinga2.Enums.LogState.Info, + [string]::Format( + 'Successfully installed EventLog "{0}" for this module', + $Icinga2.Service.servicedisplayname + ) + ); + } catch { + $Icinga2.Log.WriteConsole( + $Icinga2.Enums.LogState.Warning, + [string]::Format( + 'EventLog for "{0}" is already installed.', + $Icinga2.Service.servicedisplayname + ) + ); + } + } + + $instance | Add-Member -membertype ScriptMethod -name 'CreateConfig' -value { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Info, + '### Installing default configuration values ###' + ); + + Set-Icinga-Config -Key 'checker.server.host' -Value 'https://localhost/icingaweb2/windows/checkresult' | Out-Null; + Set-Icinga-Config -Key 'checker.ssl.verify' -Value $TRUE | Out-Null; + Set-Icinga-Config -Key 'tcp.socket.host' -Value 'localhost' | Out-Null; + Set-Icinga-Config -Key 'tcp.socket.port' -Value '5891' | Out-Null; + Set-Icinga-Config -Key 'service.name' -Value 'icinga2winservice' | Out-Null; + Set-Icinga-Config -Key 'service.displayname' -Value 'Icinga 2 Windows Service' | Out-Null; + Set-Icinga-Config -Key 'setup.installed' -Value $TRUE | Out-Null; + Set-Icinga-Config -Key 'certstore.name' -Value 'My' | Out-Null; + Set-Icinga-Config -Key 'certstore.location' -Value 'LocalMachine' | Out-Null; + Set-Icinga-Config -Key 'certstore.certificate.name' -Value '' | Out-Null; + Set-Icinga-Config -Key 'certstore.certificate.thumbprint' -Value '' | Out-Null; + Set-Icinga-Config -Key 'logger.directory' -Value '' | Out-Null; + Set-Icinga-Config -Key 'logger.debug' -Value $FALSE | Out-Null; + Set-Icinga-Config -Key 'authentication.enabled' -Value $FALSE | Out-Null; + Set-Icinga-Config -Key 'authentication.user' -Value '' | Out-Null; + Set-Icinga-Config -Key 'authentication.domain' -Value '' | Out-Null; + } + + return $instance.Init(); +} + +return ClassSetup -IsAgentIntalled $IsAgentIntalled; \ No newline at end of file diff --git a/doc/01-Introduction.md b/doc/01-Introduction.md new file mode 100644 index 0000000..dc28605 --- /dev/null +++ b/doc/01-Introduction.md @@ -0,0 +1,19 @@ +Introduction +============== + +This PowerShell Module will provide a basic framework to retreive data from Windows Hosts. The module will work with the current common Windows Versions, starting from Windows 7 / 2008 R2. Older versions might also work, are however not official supported. + +The module will execute local PowerShell scripts (modules) and return the result as formatted JSON. This result can later be parsed by any software to either do inventory or monitoring tasks. + +The module provides three ways to fetch data: + +* An active Rest-Api +* A passive Checker component +* PowerShell Cmdlets + +The following requirements have to be fullfilled: + +* Windows 7 / Windows 2008 R2 or later +* PowerShell Version 3.x or higher + +If you are ready to get started, take a look on the [installation guide](02-Installation.md). \ No newline at end of file diff --git a/doc/02-Installation.md b/doc/02-Installation.md new file mode 100644 index 0000000..42a0123 --- /dev/null +++ b/doc/02-Installation.md @@ -0,0 +1,59 @@ +Installing the Module +===================================== + +Before you can use this module, you will require to install and configure it. Once done, you are ready to start. + +Install the module +-------------- + +At first we need to obtain folders in which we can install the module. To get a list of available directories, you can use this command:S +```powershell + echo $env:PSModulePath +``` + +***We do recommend to use the Program Files folder (in case it's present) to install the module into, which will make the installation as service easier*** + +To be able to use the module, you will require to have it named **exactly** as the .psm1 and .psd1 files inside the repository. + +To validate if the module is installed properly, you can start a new PowerShell instance and type the following command + +```powershell + Get-Module -ListAvailable -Name icinga-module-windows +``` + +If you receive an output stating that the module is installed, you are fine to continue. + +Configure the module +-------------- + +Once the module is installed, you will want to run the initial setup. Therefor you will simply have to type in the command + +```powershell + Start-Icinga-Setup +``` + +This will create the base configuration of the module including the setup of directories and required files within the **PowerShell Module Directory**. + +Once completed successfully, you are ready to get started with using it. This will include + +* Using it localy with scripts +* Integrate it with the Icinga 2 Agent +* Use it as Remote Execution target +* Integrate it into Icinga Web 2 + +If you wish to provide a Rest-Api of this module, you can run this Module as daemon. It will then listen on the default port **5891** + +```powershell + Start-Icinga-Daemon +``` + +Of course if you wish to actively send data to Icinga Web 2 for example, you can do so by running the Checker component + +```powershell + Start-Icinga-Checker +``` + +For additional setup possibilities, please take a look on the following pages: + +* [Install the module as Windows Service](10-InstallService.md) +* [Integration into Icinga Web 2](11-IcingaWeb2Integration.md) \ No newline at end of file diff --git a/doc/10-InstallService.md b/doc/10-InstallService.md new file mode 100644 index 0000000..4604fff --- /dev/null +++ b/doc/10-InstallService.md @@ -0,0 +1,49 @@ +Run the PowerShell Module as Windows Service +===================================== + +Requirements +-------------- + +As PowerShell Scripts / Modules can not be installed directly as Windows Service, we will require a little assistance here. + +In order to make this work, you will require the Icinga Windows Service which can be downloaded directly from the [GitHub Repository](https://github.com/LordHepipud/icinga-windows-service). + +Install the Service +-------------- + +At first you will require the Service Binary from the [Icinga Windows Service GitHub Repository](https://github.com/LordHepipud/icinga-windows-service) and copy the binary locally to your system. A recommended path would be your Program Files / Program Files (x86) directory. + +Any other custom location is fully supported, has to be however accessable from the Windows Service Environment. + +Once you have found a location, the PowerShell Module will assist you with setting up the service itself. In this documentation we will assume the path you have chosen to copy the binary to is + +``` + C:\Program Files\Icinga Windows Service +``` + +and the binary name is + +``` + icinga-service.exe +``` + +Now lets install the service with the help of the PowerShell Module: + +```powershell + New-Icinga-Service -IcingaServicePath 'C:\Program Files\Icinga Windows Service\icinga-service.exe' +``` + +You can validate if the service has been installed properly by using the Get Service Cmdlet: + +``` + Get-Icinga-Service +``` + +Of course there are more Cmdlets available, making the management of this Icinga Service alot easier, which should be self explaining: + +* Start-Icinga-Service +* Stop-Icinga-Service +* Restart-Icinga-Service +* Remove-Icinga-Service + +**Note:** If you run the PowerShell Module as service, both the Daemon and Checker component will be started. To prevent external access to the Daemon, you should ensure to block port **5891** on this host. \ No newline at end of file diff --git a/doc/11-IcingaWeb2Integration.md b/doc/11-IcingaWeb2Integration.md new file mode 100644 index 0000000..b1eba9e --- /dev/null +++ b/doc/11-IcingaWeb2Integration.md @@ -0,0 +1,40 @@ +Integrating Icinga Web 2 +===================================== + +The PowerShell Module provides the possibility to directly (or indirectly over Proxies) connect to an Icinga Web 2 Api to send informations there. + +Requirements +-------------- + +In order to make this work, you will require the Icinga Web 2 Module from the [GitHub Repository](https://github.com/LordHepipud/icingaweb2-module-windows). + +Configure the Module +-------------- + +Once you installed the [Icinga Web 2 Windows Module](https://github.com/LordHepipud/icingaweb2-module-windows), you will have to tell the PowerShell Module where it should send it's data to. + +The Icinga Web 2 Endpoint for this is + +``` + windows/checkresult +``` + +A full Url example could look like this (which we will use in this documentation): + +``` + https://example.com/icingaweb2/windows/checkresult +``` + +To change configuration elements of the PowerShell Module, there is a Cmdlet available. In order to set the Icinga Web 2 endpoint, you can do it like this: + +```powershell + Set-Icinga-Config -Key 'checker.server.host' -Value 'https://example.com/icingaweb2/windows/checkresult' +``` + +Once sucessfully changed, you will have to restart either the Service or the running PowerShell instance. + +To validate if the configuration change really worked, you can review it with + +```powershell + Get-Icinga-Config -ListConfig +``` \ No newline at end of file diff --git a/icinga-module-windows.psd1 b/icinga-module-windows.psd1 new file mode 100644 index 0000000..13f3166 --- /dev/null +++ b/icinga-module-windows.psd1 @@ -0,0 +1,78 @@ +@{ + +# Script module or binary module file associated with this manifest. +ModuleToProcess = 'icinga-module-windows.psm1' + +# Module version number +ModuleVersion = '0.0.1' + +# ID for this module +GUID = 'fcd7a805-a41b-49f9-afee-9d17a2b76d42' + +# Autor of this Moduls +Author = 'Lord Hepipud' + +# Company +CompanyName = '' + +# Copyright +Copyright = '(c) 2018 Lord Hepipud. Alle Rechte vorbehalten.' + +# Description of this module +Description = 'Icinga 2 Windows Agent Module, which allows to entirely monitor the Windows Host system.' + +# Die für dieses Modul mindestens erforderliche Version des Windows PowerShell-Moduls +PowerShellVersion = '3.0' + +# Aus diesem Modul zu exportierende Funktionen. Um optimale Leistung zu erzielen, verwenden Sie keine Platzhalter und löschen den Eintrag nicht. Verwenden Sie ein leeres Array, wenn keine zu exportierenden Funktionen vorhanden sind. +FunctionsToExport = @( 'Start-Icinga-Checker', 'Stop-Icinga-Checker', 'Get-Icinga-Lib', 'Get-Icinga-Object', 'Get-Icinga-Service', 'Start-Icinga-Service', 'Stop-Icinga-Service', 'Restart-Icinga-Service', 'New-Icinga-Service', 'Remove-Icinga-Service', 'Get-Icinga-Setup', 'New-Icinga-Setup', 'Start-Icinga-Daemon', 'Stop-Icinga-Daemon', 'Icinga-Client', 'Get-Icinga-Command', 'New-Icinga-Monitoring', 'Get-Icinga-Counter', 'Get-Icinga-Config', 'Set-Icinga-Config', 'Remove-Icinga-Config', 'New-Icinga-Config' ) + +# Aus diesem Modul zu exportierende Cmdlets. Um optimale Leistung zu erzielen, verwenden Sie keine Plat'zhalter und löschen den Eintrag nicht. Verwenden Sie ein leeres Array, wenn keine zu exportierenden Cmdlets vorhanden sind. +CmdletsToExport = @() + +# Die aus diesem Modul zu exportierenden Variablen +VariablesToExport = '*' + +# Aus diesem Modul zu exportierende Aliase. Um optimale Leistung zu erzielen, verwenden Sie keine Platzhalter und löschen den Eintrag nicht. Verwenden Sie ein leeres Array, wenn keine zu exportierenden Aliase vorhanden sind. +AliasesToExport = @() + +# Aus diesem Modul zu exportierende DSC-Ressourcen +# DscResourcesToExport = @() + +# Liste aller Module in diesem Modulpaket +# ModuleList = @() + +# Liste aller Dateien in diesem Modulpaket +# FileList = @() + +# Die privaten Daten, die an das in "RootModule/ModuleToProcess" angegebene Modul übergeben werden sollen. Diese können auch eine PSData-Hashtabelle mit zusätzlichen von PowerShell verwendeten Modulmetadaten enthalten. +PrivateData = @{ + + PSData = @{ + + # 'Tags' wurde auf das Modul angewendet und unterstützt die Modulermittlung in Onlinekatalogen. + # Tags = @() + + # Eine URL zur Lizenz für dieses Modul. + # LicenseUri = '' + + # Eine URL zur Hauptwebsite für dieses Projekt. + # ProjectUri = '' + + # Eine URL zu einem Symbol, das das Modul darstellt. + # IconUri = '' + + # 'ReleaseNotes' des Moduls + # ReleaseNotes = '' + + } # Ende der PSData-Hashtabelle + +} # Ende der PrivateData-Hashtabelle + +# HelpInfo-URI dieses Moduls +# HelpInfoURI = '' + +# Standardpräfix für Befehle, die aus diesem Modul exportiert werden. Das Standardpräfix kann mit "Import-Module -Prefix" überschrieben werden. +# DefaultCommandPrefix = '' + +} \ No newline at end of file diff --git a/icinga-module-windows.psm1 b/icinga-module-windows.psm1 new file mode 100644 index 0000000..d99e1ed --- /dev/null +++ b/icinga-module-windows.psm1 @@ -0,0 +1,286 @@ +<# +.Synopsis + Icinga PowerShell Module - Powerfull PowerShell Framework for monitoring Windows Systems +.DESCRIPTION + More Information on https://github.com/LordHepipud/icinga-module-windows +.EXAMPLE + New-Icinga-Setup + .NOTES + +#> + +function New-Icinga-Setup() +{ + [string]$command = Get-Icinga-Command('setup'); + return &$command; +} + +function Get-Icinga-Setup() +{ + [string]$command = Get-Icinga-Command('setup'); + return &$command -IsAgentIntalled $TRUE; +} + +function Get-Icinga-Service() +{ + $Icinga2.Service.Status(); +} + +function Start-Icinga-Service() +{ + $Icinga2.Service.Start(); +} + +function Stop-Icinga-Service() +{ + $Icinga2.Service.Stop(); +} + +function Restart-Icinga-Service() +{ + $Icinga2.Service.Restart(); +} + +function New-Icinga-Service() +{ + [CmdletBinding()] + param( + [string]$IcingaServicePath = '' + ) + $Icinga2.Service.Install($IcingaServicePath); +} + +function Remove-Icinga-Service() +{ + $Icinga2.Service.Uninstall(); +} + +function Start-Icinga-Daemon +{ + [CmdletBinding()] + param( + [Switch]$NoConsole = $FALSE + ); + + if ((Get-Icinga-Setup) -eq $FALSE) { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Warning, + 'The agent seems to be not configured on this system. Please run "New-Icinga-Setup" and try again.' + ); + return; + } + + if ($NoConsole) { + $Icinga2.Log.DisableConsole(); + } + + $Icinga2.TCPDaemon.Start(); +} + +function Stop-Icinga-Daemon() +{ + if ((Get-Icinga-Setup) -eq $FALSE) { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Warning, + 'The agent seems to be not configured on this system. Please run "New-Icinga-Setup" and try again.' + ); + return; + } + + $Icinga2.TCPDaemon.Stop(); +} + +function Start-Icinga-Checker +{ + [CmdletBinding()] + param( + [Switch]$NoConsole = $FALSE + ); + + if ((Get-Icinga-Setup) -eq $FALSE) { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Warning, + 'The agent seems to be not configured on this system. Please run "New-Icinga-Setup" and try again.' + ); + return; + } + + if ($NoConsole) { + $Icinga2.Log.DisableConsole(); + } + + $Icinga2.Checker.Start(); +} + +function Stop-Icinga-Checker +{ + if ((Get-Icinga-Setup) -eq $FALSE) { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Warning, + 'The agent seems to be not configured on this system. Please run "New-Icinga-Setup" and try again.' + ); + return; + } + + $Icinga2.Checker.Stop(); +} + +<# + # This function allows us to easily call core modules by simply + # providing the name of the module we want to load + #> +function Get-Icinga-Command() +{ + [CmdletBinding()] + param( + [string]$command = '' + ); + + [string]$command = [string]::Format('core\{0}.ps1', $command); + + return (Join-Path $PSScriptRoot -ChildPath $command); +} + +<# + # Execute checks based on a filter or execute all of them + #> +function New-Icinga-Monitoring() +{ + param( + [array]$Include = @(), + [array]$Exclude = @(), + [switch]$ListModules = $FALSE, + $Config = $null + ); + + if ((Get-Icinga-Setup) -eq $FALSE) { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Warning, + 'The agent seems to be not configured on this system. Please run "New-Icinga-Setup" and try again.' + ); + return; + } + + [string]$command = Get-Icinga-Command('monitoring'); + return &$command -Include $Include -Exclude $Exclude -ListModules $ListModules -Config $Config -AgentRoot $Icinga2.App.RootPath; +} + +<# + # Retreive Performance Counter from our Windows System + #> +function Get-Icinga-Counter() +{ + param( + # Allows to specify the full path of a counter to fetch data. Example '\Processor(*)\% Processor Time' + [string]$Counter = '', + # Allows to fetch all counters of a specific category, like 'Processor' + [string]$ListCounter = '', + # Provide an array of counters we check in a bulk '\Processor(*)\% Processor Time', '\Processor(*)\% c1 time'" + [array]$CounterArray = @(), + # List all available Performance Counter Categories on a system + [switch]$ListCategories = $FALSE, + # By default counters will wait globally for 500 milliseconds. With this we can skip it. Use with caution! + [switch]$SkipWait = $FALSE, + # These arguments apply to CreateStructuredPerformanceCounterTable + # This is the category name we want to create a structured output + # Example: 'Network Interface' + [string]$CreateStructuredOutputForCategory = '', + # This is the hashtable of Performance Counters, created by + # PerformanceCounterArray + [hashtable]$StructuredCounterInput = @{}, + # This argument is just a helper to replace certain strings within + # a instance name with simply nothing. + # Example: 'HarddiskVolume1' => '1' + [array]$StructuredCounterInstanceCleanup = @() + ); + + if ((Get-Icinga-Setup) -eq $FALSE) { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Warning, + 'The agent seems to be not configured on this system. Please run "New-Icinga-Setup" and try again.' + ); + return; + } + + [string]$command = Get-Icinga-Command('perfcounter'); + return (&$command ` + -Counter $Counter ` + -ListCounter $ListCounter ` + -CounterArray $CounterArray ` + -ListCategories $ListCategories ` + -SkipWait $SkipWait ` + -CreateStructuredOutputForCategory $CreateStructuredOutputForCategory ` + -StructuredCounterInput $StructuredCounterInput ` + -StructuredCounterInstanceCleanup $StructuredCounterInstanceCleanup + ); +} + +<# + # Get a single config key of Icinga 2 or the entire configuration + #> +function Get-Icinga-Config() +{ + param( + [string]$Key = '', + [switch]$ListConfig = $FALSE + ); + + [string]$command = Get-Icinga-Command('config'); + return &$command -GetConfig $Key -ListConfig $ListConfig; +} + +function Set-Icinga-Config() +{ + param( + [string]$Key = '', + [Object]$Value = '' + ); + + [string]$command = Get-Icinga-Command('config'); + return &$command -AddKey $Key -AddValue $Value; +} + +function Remove-Icinga-Config() +{ + param( + [string]$Key = '' + ); + + [string]$command = Get-Icinga-Command('config'); + return &$command -RemoveConfig $Key; +} + +function New-Icinga-Config() +{ + [string]$command = Get-Icinga-Command('config'); + return &$command -Reload $TRUE; +} + +function Get-Icinga-Lib() +{ + param([string]$Include); + + [string]$IncludeFile = (Join-Path $PSScriptRoot -ChildPath ( + [string]::Format( + '\core\include\{0}.ps1', + $Include + ))); + + if (-Not (Test-Path $IncludeFile)) { + return; + } + + return (& $IncludeFile); +} + +function Get-Icinga-Object() +{ + return $Icinga2; +} + +# Initialise base configuration for our module +$Icinga2 = & (Join-Path -Path $PSScriptRoot -ChildPath '\core\init.ps1') ` + -RootDirectory $PSScriptRoot ` + -ModuleName $MyInvocation.MyCommand.Name; + +Export-ModuleMember @Icinga2; \ No newline at end of file diff --git a/modules/bios.ps1 b/modules/bios.ps1 new file mode 100644 index 0000000..11e842c --- /dev/null +++ b/modules/bios.ps1 @@ -0,0 +1,21 @@ + + +function ClassBIOS +{ + # Lets load some bios informations + $BIOSInformation = Get-CimInstance Win32_BIOS; + [hashtable]$BIOSData = @{}; + + foreach ($bios_properties in $BIOSInformation) { + #$bios_datails = @{}; + foreach($bios in $bios_properties.CimInstanceProperties) { + #$bios_datails.Add($bios.Name, $bios.Value); + $BIOSData.Add($bios.Name, $bios.Value); + } + #$BIOSData.Add($bios_datails.DeviceID, $bios_datails); + } + + return $BIOSData; +} + +return ClassBIOS; \ No newline at end of file diff --git a/modules/certificates.ps1 b/modules/certificates.ps1 new file mode 100644 index 0000000..bf6298d --- /dev/null +++ b/modules/certificates.ps1 @@ -0,0 +1,112 @@ +param($Config = $null); + +function ClassCertificates() +{ + param($Config = $null); + + [hashtable]$CertStore = @{}; + [hashtable]$CertLocation = @{}; + [hashtable]$CertCounters = @{}; + + Set-Location 'cert:' | Out-Null; + $certs = Get-ChildItem -Recurse; + + foreach ($cert in $certs) { + if ($cert.LocationName) { + if ($CertStore.ContainsKey($cert.LocationName) -eq $FALSE) { + $CertStore.Add($cert.LocationName, @{}); + } + } + + if ($cert.IssuerName) { + [hashtable]$Certificate = @{}; + $Certificate.Add('Archived', $cert.Archived); + $Certificate.Add('HasPrivateKey', $cert.HasPrivateKey); + $Certificate.Add('IssuerName.Name', $cert.IssuerName.Name); + $Certificate.Add('IssuerName.Oid', $cert.IssuerName.Oid); + $Certificate.Add('NotAfter', $cert.NotAfter); + $Certificate.Add('NotBefore', $cert.NotBefore); + $Certificate.Add('SerialNumber', $cert.SerialNumber); + $Certificate.Add('SubjectName.Name', $cert.SubjectName.Name); + $Certificate.Add('SubjectOid.Oid', $cert.SubjectName.Oid); + $Certificate.Add('SignatureAlgorithm.Value', $cert.SignatureAlgorithm.Value); + $Certificate.Add('SignatureAlgorithm.FriendlyName', $cert.SignatureAlgorithm.FriendlyName); + $Certificate.Add('Thumbprint', $cert.Thumbprint); + $Certificate.Add('Version', $cert.Version); + $Certificate.Add('Issuer', $cert.Issuer); + $Certificate.Add('Subject', $cert.Subject); + $Certificate.Add('PSParentPath', $cert.PSParentPath); + $Certificate.Add('PSChildName', $cert.PSChildName); + $Certificate.Add('DnsNameList', $cert.DnsNameList); + + [string]$cert_store = (GetCertStore -CertPath $cert.PSPath); + [string]$cert_location = (GetCertLocation -CertPath $cert.PSPath); + + $Certificate.Add('CertStore', $cert_store); + $Certificate.Add('CertLocation', $cert_location); + + if ($CertLocation.ContainsKey($cert_location)) { + $CertLocation[$cert_location] += $Certificate; + } else { + $CertLocation.Add($cert_location, @( $Certificate )); + } + } + } + + foreach ($cert_arr in $CertLocation.Keys) { + foreach ($cert in $CertLocation[$cert_arr]) { + [string]$CertFullPathCache = [string]::Format( + '{0}\{1}\{2}', + $cert.CertStore, + $cert.CertLocation, + $cert.Thumbprint + ); + if ($CertCounters.ContainsKey($CertFullPathCache) -eq $FALSE) { + $CertCounters.Add($CertFullPathCache, 1); + } else { + $CertCounters[$CertFullPathCache] += 1; + } + if ($CertStore[$cert.CertStore].ContainsKey($cert.CertLocation)) { + [string]$CertThumbprintKey = $cert.Thumbprint; + if ($CertCounters[$CertFullPathCache] -gt 1) { + $CertThumbprintKey = [string]::Format( + '{0} ({1})', + $CertThumbprintKey, + $CertCounters[$CertFullPathCache] + ); + } + $CertStore[$cert.CertStore][$cert.CertLocation].Add($CertThumbprintKey, $cert); + } else { + $CertStore[$cert.CertStore].Add($cert.CertLocation, @{ $cert.Thumbprint = $cert }); + } + } + } + + return $CertStore +} + +function GetCertStore() +{ + param([string]$CertPath); + + $CertPath = $CertPath.Replace('Microsoft.PowerShell.Security\', ''); + $CertPath = $CertPath.Replace('Certificate::', ''); + + [array]$path = $CertPath.Split('\'); + + return $path[0]; +} + +function GetCertLocation() +{ + param([string]$CertPath); + + $CertPath = $CertPath.Replace('Microsoft.PowerShell.Security\', ''); + $CertPath = $CertPath.Replace('Certificate::', ''); + + [array]$path = $CertPath.Split('\'); + + return $path[1]; +} + +return ClassCertificates -Config $Config; \ No newline at end of file diff --git a/modules/cpu.ps1 b/modules/cpu.ps1 new file mode 100644 index 0000000..36a0f57 --- /dev/null +++ b/modules/cpu.ps1 @@ -0,0 +1,18 @@ +param($Config = $null); + +function ClassCPU +{ + param($Config = $null); + + # This will return a hashtable with every single counter + # We specify within the array + $counter = Get-Icinga-Counter -CounterArray @( + '\Processor(*)\% Processor Time', + '\System\Processor Queue Length', + '\System\Threads' + ); + + return $counter; +} + +return ClassCPU -Config $Config; \ No newline at end of file diff --git a/modules/disk.ps1 b/modules/disk.ps1 new file mode 100644 index 0000000..b8097b7 --- /dev/null +++ b/modules/disk.ps1 @@ -0,0 +1,96 @@ +param($Config = $null); + +function ClassDisk() +{ + param($Config = $null); + # The storage variables we require to store our data + [hashtable]$StructuredDiskData = @{}; + + # This will return a hashtable with every single counter + # we specify within the array. Instead of returning all + # the values in the returned hashtable, we will rebuild + # the result a little to have a improved output which + # is more user friendly and allows us to check for + # certain disks / volumes in details with a simpler + # accessing possibility + $counter = Get-Icinga-Counter -CounterArray @( + '\PhysicalDisk(*)\% Disk Read Time', + '\PhysicalDisk(*)\Current Disk Queue Length', + '\PhysicalDisk(*)\Avg. Disk Bytes/Transfer', + '\PhysicalDisk(*)\Split IO/sec', + '\PhysicalDisk(*)\Disk Reads/sec', + '\PhysicalDisk(*)\Disk Writes/sec', + '\PhysicalDisk(*)\Disk Bytes/sec', + '\PhysicalDisk(*)\Avg. Disk Read Queue Length', + '\PhysicalDisk(*)\Avg. Disk sec/Write', + '\PhysicalDisk(*)\% Disk Time', + '\PhysicalDisk(*)\Avg. Disk sec/Transfer', + '\PhysicalDisk(*)\Avg. Disk Bytes/Write', + '\PhysicalDisk(*)\% Disk Write Time', + '\PhysicalDisk(*)\Avg. Disk Queue Length', + '\PhysicalDisk(*)\Disk Write Bytes/sec', + '\PhysicalDisk(*)\Avg. Disk sec/Read', + '\PhysicalDisk(*)\Disk Read Bytes/sec', + '\PhysicalDisk(*)\Disk Transfers/sec', + '\PhysicalDisk(*)\% Idle Time', + '\PhysicalDisk(*)\Avg. Disk Write Queue Length', + '\PhysicalDisk(*)\Avg. Disk Bytes/Read' + ); + + $logicalCounter = Get-Icinga-Counter -CounterArray @( + '\LogicalDisk(*)\Free Megabytes', + '\LogicalDisk(*)\% Free Space' + ); + + # This function will help us to build a structured output based on + # volumes / disks found within the instances. We will use our + # LogicalDisk as 'index' to assign our performance Counters to. + # In addition we then provide the hashtable of counters we fetched + # above. Last but not least we cleanup the instances name to replace + # 'HarddiskVolume1' for '1' for example, to ensure the mapping of disk + # informations is working as intended + [hashtable]$DiskData = Get-Icinga-Counter ` + -CreateStructuredOutputForCategory 'PhysicalDisk' ` + -StructuredCounterInput $counter; + + foreach ($counters in $logicalCounter.Keys) { + foreach ($counter in $logicalCounter[$counters].Keys) { + [string]$instance = $counter; + if ($instance.Contains('(') -And $instance.Contains(')')) { + [int]$bracketStart = $instance.IndexOf('(') + 1; + [int]$bracketEnd = $instance.IndexOf(')'); + $instance = $instance.Substring($bracketStart, $bracketEnd - $bracketStart); + $instanceArray = $counter.Split('\'); + $counterName = $instanceArray[$instanceArray.Length - 1]; + foreach ($disk in $DiskData.Keys) { + if ($disk.Contains($instance)) { + $DiskData[$disk].Add( + $counterName, + $logicalCounter[$counters][$counter] + ); + } + } + } + } + } + + # Rewrite our output a little to make it more user friendly + # This is unique for disks, as we want to remove the ':' from + # Drive Letters and add back the HarddiskVolume label to volumes + # to prevent having only a numeric table keys. Example: + # '1' => 'HarddiskVolume1' + foreach ($disk in $DiskData.Keys) { + $NewKey = $disk.Replace(':', ''); + if ($NewKey -match "^[\d\.]+$") { + $NewKey = [string]::Format('HarddiskVolume{0}', $NewKey); + } + if ($NewKey[0] -match "^[\d\.]+$") { + $NewKey = $NewKey.Substring(2, $NewKey.Length - 2); + } + $StructuredDiskData.Add($NewKey, $DiskData[$disk]); + } + + return $StructuredDiskData; +} + +return ClassDisk -Config $Config; \ No newline at end of file diff --git a/modules/hardware.ps1 b/modules/hardware.ps1 new file mode 100644 index 0000000..ea47044 --- /dev/null +++ b/modules/hardware.ps1 @@ -0,0 +1,6 @@ +param($Config = $null); + +return $Icinga2.Utils.Modules.LoadIncludes( + $MyInvocation.MyCommand.Name, + $Config +); \ No newline at end of file diff --git a/modules/include/hardware/cpu.ps1 b/modules/include/hardware/cpu.ps1 new file mode 100644 index 0000000..d813c1b --- /dev/null +++ b/modules/include/hardware/cpu.ps1 @@ -0,0 +1,19 @@ +param($Config = $null); +# +# Fetch the CPU Hardware informations +# + +# Lets load some additional CPU informations, besides current performance counters +# It might be useful to get more details about the hardware itself +$CPUInformations = Get-CimInstance Win32_Processor; +[hashtable]$PhysicalCPUData = @{}; + +foreach ($cpu_properties in $CPUInformations) { + $cpu_datails = @{}; + foreach($cpu_core in $cpu_properties.CimInstanceProperties) { + $cpu_datails.Add($cpu_core.Name, $cpu_core.Value); + } + $PhysicalCPUData.Add($cpu_datails.DeviceID, $cpu_datails); +} + +return $PhysicalCPUData; \ No newline at end of file diff --git a/modules/include/hardware/disks.ps1 b/modules/include/hardware/disks.ps1 new file mode 100644 index 0000000..fa0c6c0 --- /dev/null +++ b/modules/include/hardware/disks.ps1 @@ -0,0 +1,70 @@ +param($Config = $null); +# +# Fetch the Disk Hardware informations +# + +# Lets load some additional disk informations, besides current data +# It might be useful to get more details about the hardware itself +$DisksInformations = Get-CimInstance Win32_DiskDrive; + +[hashtable]$PhysicalDiskData = @{}; + +foreach ($disk_properties in $DisksInformations) { + $disk_datails = @{}; + foreach($disk in $disk_properties.CimInstanceProperties) { + $disk_datails.Add($disk.Name, $disk.Value); + } + $disk_datails.Add('DriveReference', @()); + $PhysicalDiskData.Add($disk_datails.DeviceID, $disk_datails); +} + +$DiskPartitionInfo = Get-WmiObject Win32_DiskDriveToDiskPartition; + +[hashtable]$MapDiskPartitionToLogicalDisk = @{}; + +foreach ($item in $DiskPartitionInfo) { + [string]$diskPartition = $item.Dependent.SubString( + $item.Dependent.LastIndexOf('=') + 1, + $item.Dependent.Length - $item.Dependent.LastIndexOf('=') - 1 + ); + $diskPartition = $diskPartition.Replace('"', ''); + + [string]$physicalDrive = $item.Antecedent.SubString( + $item.Antecedent.LastIndexOf('\') + 1, + $item.Antecedent.Length - $item.Antecedent.LastIndexOf('\') - 1 + ) + $physicalDrive = $physicalDrive.Replace('"', ''); + + $MapDiskPartitionToLogicalDisk.Add($diskPartition, $physicalDrive); +} + +$LogicalDiskInfo = Get-WmiObject Win32_LogicalDiskToPartition; + +foreach ($item in $LogicalDiskInfo) { + [string]$driveLetter = $item.Dependent.SubString( + $item.Dependent.LastIndexOf('=') + 1, + $item.Dependent.Length - $item.Dependent.LastIndexOf('=') - 1 + ); + $driveLetter = $driveLetter.Replace('"', ''); + + [string]$diskPartition = $item.Antecedent.SubString( + $item.Antecedent.LastIndexOf('=') + 1, + $item.Antecedent.Length - $item.Antecedent.LastIndexOf('=') - 1 + ) + $diskPartition = $diskPartition.Replace('"', ''); + + if ($MapDiskPartitionToLogicalDisk.ContainsKey($diskPartition)) { + foreach ($disk in $PhysicalDiskData.Keys) { + [string]$DiskId = $disk.SubString( + $disk.LastIndexOf('\') + 1, + $disk.Length - $disk.LastIndexOf('\') - 1 + ); + + if ($DiskId.ToLower() -eq $MapDiskPartitionToLogicalDisk[$diskPartition].ToLower()) { + $PhysicalDiskData[$disk]['DriveReference'] += $driveLetter; + } + } + } +} + +return $PhysicalDiskData; \ No newline at end of file diff --git a/modules/include/hardware/memory.ps1 b/modules/include/hardware/memory.ps1 new file mode 100644 index 0000000..a47825e --- /dev/null +++ b/modules/include/hardware/memory.ps1 @@ -0,0 +1,59 @@ +param($Config = $null); +# +# Fetch the Memory Hardware informations +# + +# Lets load some additional memory informations, besides current performance counters +# It might be useful to get more details about the hardware itself +$MemoryInformations = Get-CimInstance Win32_PhysicalMemory; +$capacity = $MemoryInformations | Measure-Object -Property capacity -Sum; + +# Lets load the details from our RAM modules +[hashtable]$PhysicalMemoryData = @{}; + +$PhysicalMemoryData.Add('Modules', $capacity.Count); + +foreach($memory_object in $MemoryInformations) { + $memory_datails = @{}; + $memory_datails.Add('caption', $memory_object.Caption); + $memory_datails.Add('desc', $memory_object.Description); + $memory_datails.Add('name', $memory_object.Name); + $memory_datails.Add('install_date', $memory_object.InstallDate); + $memory_datails.Add('status', $memory_object.Status); + $memory_datails.Add('creation_class_name', $memory_object.CreationClassName); + $memory_datails.Add('manufacturer', $memory_object.Manufacturer); + $memory_datails.Add('model', $memory_object.Model); + $memory_datails.Add('other_identifiying_info', $memory_object.OtherIdentifyingInfo); + $memory_datails.Add('part_number', $memory_object.PartNumber); + $memory_datails.Add('powered_on', $memory_object.PoweredOn); + $memory_datails.Add('serial_number', $memory_object.SerialNumber); + $memory_datails.Add('sku', $memory_object.SKU); + $memory_datails.Add('tag', $memory_object.Tag); + $memory_datails.Add('version', $memory_object.Version); + $memory_datails.Add('hot_swappable', $memory_object.HotSwappable); + $memory_datails.Add('removable', $memory_object.Removable); + $memory_datails.Add('replaceable', $memory_object.Replaceable); + $memory_datails.Add('form_factor', $memory_object.FormFactor); + $memory_datails.Add('bank_label', $memory_object.BankLabel); + $memory_datails.Add('capacity', $memory_object.Capacity); + $memory_datails.Add('data_width', $memory_object.DataWidth); + $memory_datails.Add('interleave_position', $memory_object.InterleavePosition); + $memory_datails.Add('memory_type', $memory_object.MemoryType); + $memory_datails.Add('position_in_row', $memory_object.PositionInRow); + $memory_datails.Add('speed', $memory_object.Speed); + $memory_datails.Add('total_width', $memory_object.TotalWidth); + $memory_datails.Add('attributes', $memory_object.Attributes); + $memory_datails.Add('configured_clock_speed', $memory_object.ConfiguredClockSpeed); + $memory_datails.Add('configured_voltage', $memory_object.ConfiguredVoltage); + $memory_datails.Add('device_locator', $memory_object.DeviceLocator); + $memory_datails.Add('interleave_data_depth', $memory_object.InterleaveDataDepth); + $memory_datails.Add('max_voltage', $memory_object.MaxVoltage); + $memory_datails.Add('min_voltage', $memory_object.MinVoltage); + $memory_datails.Add('smbios_memory_type', $memory_object.SMBIOSMemoryType); + $memory_datails.Add('type_detail', $memory_object.TypeDetail); + $memory_datails.Add('ps_computer_name', $memory_object.PSComputerName); + + $PhysicalMemoryData.Add($memory_object.Tag, $memory_datails); +} + +return $PhysicalMemoryData; \ No newline at end of file diff --git a/modules/include/updates/hotfixes.ps1 b/modules/include/updates/hotfixes.ps1 new file mode 100644 index 0000000..7dba95a --- /dev/null +++ b/modules/include/updates/hotfixes.ps1 @@ -0,0 +1,27 @@ +param($Config = $null); + +[hashtable]$HotfixInfo = @{}; +[hashtable]$HotfixNameCache = @{}; + +# First fetch all of our hotfixes +$Hotfixes = Get-Hotfix; + +foreach ($property in $Hotfixes) { + [hashtable]$HotfixData = @{}; + foreach ($hotfix in $property.Properties) { + $HotfixData.Add($hotfix.Name, $hotfix.Value); + } + + [string]$name = [string]::Format('{0} [{1}]', $HotfixData.HotFixID, $HotfixData.InstalledOn); + + if ($HotfixNameCache.ContainsKey($name) -eq $FALSE) { + $HotfixNameCache.Add($name, 1); + } else { + $HotfixNameCache[$name] += 1; + $name = [string]::Format('{0} ({1})', $name, $HotfixNameCache[$name]); + } + + $HotfixInfo.Add($name, $HotfixData); +} + +return $HotfixInfo; \ No newline at end of file diff --git a/modules/include/updates/pending.ps1 b/modules/include/updates/pending.ps1 new file mode 100644 index 0000000..e0726fd --- /dev/null +++ b/modules/include/updates/pending.ps1 @@ -0,0 +1,76 @@ +param($Config = $null); + +[hashtable]$PendingUpdates = @{}; +[hashtable]$PendingUpdateNameCache = @{}; +# Fetch all informations about installed updates and add them +$WindowsUpdates = New-Object -ComObject "Microsoft.Update.Session"; +$SearchIndex = $WindowsUpdates.CreateUpdateSearcher(); + +try { + # Get a list of current pending updates which are not yet installed on the system + $Pending = $SearchIndex.Search("IsInstalled=0"); + $PendingUpdates.Add('count', $Pending.Updates.Count); + + foreach ($update in $Pending.Updates) { + [hashtable]$PendingUpdateDetails = @{}; + $PendingUpdateDetails.Add('Title', $update.Title); + $PendingUpdateDetails.Add('Deadline', $update.Deadline); + $PendingUpdateDetails.Add('Description', $update.Description); + $PendingUpdateDetails.Add('IsBeta', $update.IsBeta); + $PendingUpdateDetails.Add('IsDownloaded', $update.IsDownloaded); + $PendingUpdateDetails.Add('IsHidden', $update.IsHidden); + $PendingUpdateDetails.Add('IsInstalled', $update.IsInstalled); + $PendingUpdateDetails.Add('IsMandatory', $update.IsMandatory); + $PendingUpdateDetails.Add('IsUninstallable', $update.IsUninstallable); + $PendingUpdateDetails.Add('Languages', $update.Languages); + $PendingUpdateDetails.Add('LastDeploymentChangeTime', $update.LastDeploymentChangeTime); + $PendingUpdateDetails.Add('MaxDownloadSize', $update.MaxDownloadSize); + $PendingUpdateDetails.Add('MinDownloadSize', $update.MinDownloadSize); + $PendingUpdateDetails.Add('MoreInfoUrls', $update.MoreInfoUrls); + $PendingUpdateDetails.Add('MsrcSeverity', $update.MsrcSeverity); + $PendingUpdateDetails.Add('RecommendedCpuSpeed', $update.RecommendedCpuSpeed); + $PendingUpdateDetails.Add('RecommendedHardDiskSpace', $update.RecommendedHardDiskSpace); + $PendingUpdateDetails.Add('RecommendedMemory', $update.RecommendedMemory); + $PendingUpdateDetails.Add('ReleaseNotes', $update.ReleaseNotes); + $PendingUpdateDetails.Add('SecurityBulletinIDs', $update.SecurityBulletinIDs); + $PendingUpdateDetails.Add('SupersededUpdateIDs', $update.SupersededUpdateIDs); + $PendingUpdateDetails.Add('SupportUrl', $update.SupportUrl); + $PendingUpdateDetails.Add('Type', $update.Type); + $PendingUpdateDetails.Add('UninstallationNotes', $update.UninstallationNotes); + $PendingUpdateDetails.Add('UninstallationBehavior', $update.UninstallationBehavior); + $PendingUpdateDetails.Add('UninstallationSteps', $update.UninstallationSteps); + $PendingUpdateDetails.Add('KBArticleIDs', $update.KBArticleIDs); + $PendingUpdateDetails.Add('DeploymentAction', $update.DeploymentAction); + $PendingUpdateDetails.Add('DownloadPriority', $update.DownloadPriority); + $PendingUpdateDetails.Add('RebootRequired', $update.RebootRequired); + $PendingUpdateDetails.Add('IsPresent', $update.IsPresent); + $PendingUpdateDetails.Add('CveIDs', $update.CveIDs); + $PendingUpdateDetails.Add('BrowseOnly', $update.BrowseOnly); + $PendingUpdateDetails.Add('PerUser', $update.PerUser); + $PendingUpdateDetails.Add('AutoSelection', $update.AutoSelection); + $PendingUpdateDetails.Add('AutoDownload', $update.AutoDownload); + + [string]$name = [string]::Format('{0} [{1}]', $update.Title, $update.LastDeploymentChangeTime); + + if ($PendingUpdateNameCache.ContainsKey($name) -eq $FALSE) { + $PendingUpdateNameCache.Add($name, 1); + } else { + $PendingUpdateNameCache[$name] += 1; + $name = [string]::Format('{0} ({1})', $name, $PendingUpdateNameCache[$name]); + } + + $PendingUpdates.Add($name, $PendingUpdateDetails); + } +} catch { + if ($PendingUpdates.ContainsKey('Count') -eq $FALSE) { + $PendingUpdates.Add('count', 0); + } else { + $PendingUpdates['count'] = 0; + } + $PendingUpdates.Add('error', [string]::Format( + 'Failed to query Windows Update server: {0}', + $_.Exception.Message + )); +} + +return $PendingUpdates; \ No newline at end of file diff --git a/modules/include/updates/updates.ps1 b/modules/include/updates/updates.ps1 new file mode 100644 index 0000000..4432fbf --- /dev/null +++ b/modules/include/updates/updates.ps1 @@ -0,0 +1,72 @@ +param($Config = $null); + +# Fetch all informations about installed updates and add them +$WindowsUpdates = New-Object -ComObject "Microsoft.Update.Session"; +$SearchIndex = $WindowsUpdates.CreateUpdateSearcher(); +[hashtable]$UpdateList = @{}; +[hashtable]$UpdateInstalled = @{}; +[hashtable]$UpdateUninstalled = @{}; +[hashtable]$UpdateOther = @{}; + +# Operation ID's +# 1: Installed +# 2: Uninstalled +# 3: Other + +# At first get a list of our Windows Update history +$Updates = $SearchIndex.QueryHistory(0, $SearchIndex.GetTotalHistoryCount()) | + Select-Object Operation, ResultCode, HResult, Date, Title, Description, ServiceID, SupportUrl; + +foreach ($update in $Updates) { + [string]$UpdateKey = [string]::Format('{0} [{1}|{2}]', $update.Title, $update.Date, $update.HResult); + switch ($update.Operation) { + 1 { + if ($UpdateInstalled.ContainsKey($UpdateKey) -eq $FALSE) { + $UpdateInstalled.Add($UpdateKey, $update); + } else { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Warning, + [string]::Format( + 'Unable to add update "{0}" to update list. The key with content "{1}" is already present', + $UpdateKey, + $update + ) + ); + } + }; + 2 { + if ($UpdateUninstalled.ContainsKey($UpdateKey) -eq $FALSE) { + $UpdateUninstalled.Add($UpdateKey, $update); + } else { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Warning, + [string]::Format( + 'Unable to add update "{0}" to update list. The key with content "{1}" is already present', + $UpdateKey, + $update + ) + ); + } + }; + default { + if ($UpdateOther.ContainsKey($UpdateKey) -eq $FALSE) { + $UpdateOther.Add($UpdateKey, $update); + } else { + $Icinga2.Log.Write( + $Icinga2.Enums.LogState.Warning, + [string]::Format( + 'Unable to add update "{0}" to update list. The key with content "{1}" is already present', + $UpdateKey, + $update + ) + ); + } + }; + } +} + +$UpdateList.Add('installed', $UpdateInstalled); +$UpdateList.Add('uninstalled', $UpdateUninstalled); +$UpdateList.Add('other', $UpdateOther); + +return $UpdateList; \ No newline at end of file diff --git a/modules/memory.ps1 b/modules/memory.ps1 new file mode 100644 index 0000000..0f66764 --- /dev/null +++ b/modules/memory.ps1 @@ -0,0 +1,33 @@ + + +param($Config = $null); + +function ClassMemory +{ + param($Config = $null); + # This will return a hashtable with every single counter + # We specify within the array + $counter = Get-Icinga-Counter -CounterArray @( + '\Memory\Available Bytes', + '\Memory\% Committed Bytes In Use', + '\Memory\Committed Bytes', + '\Memory\Cache Bytes', + '\Memory\Pool Nonpaged Bytes', + '\Memory\Pages/sec', + '\Memory\Page Reads/sec', + '\Memory\Page Writes/sec', + '\Memory\Pages Input/sec', + '\Memory\Pages Output/sec', + '\Paging File(*)\% Usage', + '\Paging File(*)\% Usage Peak' + ); + + # Lets load some additional memory informations, besides current performance counters + $MemoryInformations = Get-CimInstance Win32_PhysicalMemory; + $capacity = $MemoryInformations | Measure-Object -Property capacity -Sum; + $counter.Add('\Memory\Physical Memory Total Bytes', @{ 'value' = $capacity.Sum; 'sample' = @{ }; }); + + return $counter; +} + +return ClassMemory -Config $Config; \ No newline at end of file diff --git a/modules/network.ps1 b/modules/network.ps1 new file mode 100644 index 0000000..b22a470 --- /dev/null +++ b/modules/network.ps1 @@ -0,0 +1,154 @@ +param($Config = $null); + +function ClassNetwork +{ + param($Config = $null); + # The storage variables we require to store our data + [hashtable]$NetworkData = @{}; + [hashtable]$StructuredInterfaceData = @{}; + + $CachedNetwork = $Icinga2.Utils.Modules.GetCacheElement( + $MyInvocation.MyCommand.Name, + 'NetworkData' + ); + + # This will return a hashtable with every single counter + # we specify within the array. Instead of returning all + # the values in the returned hashtable, we will rebuild + # the result a little to have a improved output which + # is more user friendly and allows us to check for + # certain interfaces in detail with a simpler + # accessing possibility + $counter = Get-Icinga-Counter -CounterArray @( + '\Network Interface(*)\Bytes Received/sec', + '\Network Interface(*)\Bytes Sent/sec', + '\Network Interface(*)\Packets Received Unicast/sec', + '\Network Interface(*)\Packets Sent Unicast/sec', + '\Network Interface(*)\Packets Received Non-Unicast/sec', + '\Network Interface(*)\Packets Sent Non-Unicast/sec', + '\Network Interface(*)\Packets Outbound Errors', + '\Network Interface(*)\Packets Sent/sec', + '\Network Interface(*)\TCP RSC Exceptions/sec', + '\Network Interface(*)\Packets Outbound Discarded', + '\Network Interface(*)\TCP RSC Coalesced Packets/sec', + '\Network Interface(*)\Bytes Total/sec', + '\Network Interface(*)\Current Bandwidth', + '\Network Interface(*)\Packets Received Unknown', + '\Network Interface(*)\TCP Active RSC Connections', + '\Network Interface(*)\Offloaded Connections', + '\Network Interface(*)\Packets/sec', + '\Network Interface(*)\Packets Received Errors', + '\Network Interface(*)\Packets Received/sec', + '\Network Interface(*)\Packets Received Discarded', + '\Network Interface(*)\Output Queue Length', + '\Network Interface(*)\TCP RSC Average Packet Size' + ); + + # This function will help us to build a structured output based on + # interfaces found within the instances. We will use our + # Network Interface as 'index' to assign our performance Counters to. + # In addition we then provide the hashtable of counters we fetched + # above. + $StructuredInterfaceData = Get-Icinga-Counter ` + -CreateStructuredOutputForCategory 'Network Interface' ` + -StructuredCounterInput $counter; + + $NetworkData.Add('interfaces', $StructuredInterfaceData); + + # Add additional details to our interfaces, like MAC Address, Interface Index and current connection status + $NetworkAdapter = Get-WMIObject Win32_NetworkAdapter; + + [hashtable]$DuplicateIDCache = @{}; + + foreach ($adapter in $NetworkAdapter) { + # The Performance Counter return values in brackets with [], while WMI returns () + # In addition, WMI uses / within Interface names, while Perf Counter uses _ + # We need to take care about this here + [string]$AdapterName = $adapter.Name.Replace('(', '[').Replace(')', ']'); + [string]$AdapterName = $AdapterName.Replace('/', '_'); + [string]$AdapterName = $AdapterName.Replace('#', '_'); + + # To ensure duplicate interface names will not cause this module + # to crash, we will have to build up a cache and add numeric + # additions. + if ($DuplicateIDCache.ContainsKey($AdapterName) -eq $FALSE) { + $DuplicateIDCache.Add($AdapterName, 0); + } + + # In case we add adapters we have no performance counters for, + # create a new hashtable object for the name + if ($StructuredInterfaceData.ContainsKey($AdapterName) -eq $FALSE) { + $StructuredInterfaceData.Add($AdapterName, @{}); + } else { + # In case the interface does already exist, check if we require + # to rename the interface with a index ID in addition. As we + # have to ensure Performance Counters are added to Physical Adapters + # only, we will focus in these indexes. All other instances will + # receive a follow-up ID. + [int]$ID = $DuplicateIDCache[$AdapterName] + 1; + if ($adapter.PhysicalAdapter -eq $FALSE) { + $ID += 1; + $DuplicateIDCache[$AdapterName] = $ID; + } else { + # Physical Adapters always have unique names, therefor we should + # always use index 1 for these to ensure Performance Counter data + # is added to the correct interfaces + $ID = 1; + } + + # Only add index ID's to interfaces in case we are not equal 1 + if ($ID -ne 1) { + $AdapterName = [string]::Format('{0} ({1})', + $AdapterName, + $ID + ); + $StructuredInterfaceData.Add($AdapterName, @{}); + } + } + + # Add the WMI informations to this interface + $StructuredInterfaceData[$AdapterName].Add('NetConnectionID', $adapter.NetConnectionID); + $StructuredInterfaceData[$AdapterName].Add('InterfaceIndex', $adapter.InterfaceIndex); + $StructuredInterfaceData[$AdapterName].Add('NetConnectionStatus', $adapter.NetConnectionStatus); + $StructuredInterfaceData[$AdapterName].Add('DeviceID', $adapter.DeviceID); + $StructuredInterfaceData[$AdapterName].Add('MACAddress', $adapter.MACAddress); + $StructuredInterfaceData[$AdapterName].Add('ServiceName', $adapter.ServiceName); + $StructuredInterfaceData[$AdapterName].Add('Speed', $adapter.Speed); + $StructuredInterfaceData[$AdapterName].Add('AdapterType', $adapter.AdapterType); + $StructuredInterfaceData[$AdapterName].Add('NetworkAddresses', $adapter.NetworkAddresses); + $StructuredInterfaceData[$AdapterName].Add('Manufacturer', $adapter.Manufacturer); + $StructuredInterfaceData[$AdapterName].Add('PNPDeviceID', $adapter.PNPDeviceID); + $StructuredInterfaceData[$AdapterName].Add('PhysicalAdapter', $adapter.PhysicalAdapter); + } + + # In addition to our general network interface data, it might also + # be helpful to have a look on the configured routing table + $RoutingTable = Get-CimInstance -ClassName Win32_IP4RouteTable + [array]$RoutingData = @(); + + foreach ($tables in $RoutingTable) { + $routes = @{}; + foreach($route in $tables.CimInstanceProperties) { + $routes.Add($route.Name, $route.Value); + } + $RoutingData += $routes; + } + + $NetworkData.Add('routes', $RoutingData); + + $Icinga2.Utils.Modules.AddCacheElement( + $MyInvocation.MyCommand.Name, + 'NetworkData', + $NetworkData + ); + + return $Icinga2.Utils.Modules.GetHashtableDiff( + $NetworkData.Clone(), + $CachedNetwork.Clone() + ); + + # At the end simply return the entire hashtable + return $NetworkData; +} + +return ClassNetwork -Config $Config; \ No newline at end of file diff --git a/modules/ntp.ps1 b/modules/ntp.ps1 new file mode 100644 index 0000000..211773a --- /dev/null +++ b/modules/ntp.ps1 @@ -0,0 +1,34 @@ +param($Config = $null); + +function ClassNTP +{ + param($Config = $null); + [hashtable]$NTPInformations = @{}; + # This will return a hashtable with every single counter + # we specify within the array + $counter = Get-Icinga-Counter -CounterArray @( + '\Windows Time service\clock frequency adjustment', + '\Windows Time service\ntp client time source count', + '\Windows Time service\ntp server outgoing responses', + '\Windows Time service\computed time offset', + '\Windows Time service\ntp roundtrip delay', + '\Windows Time service\ntp server incoming requests' + ); + $NTPInformations.Add('counter', $counter); + + # Load the source from which we receive our NTP config + $NTPInformations.Add('source', (&W32tm /query /source)); + + # Load the NTP config and parse it properly + $NTPInformations.Add( + 'config', + $Icinga2.Utils.IniParser.LoadFromArray( + (&W32tm /query /configuration), + $TRUE + ) + ); + + return $NTPInformations; +} + +return ClassNTP -Config $Config; \ No newline at end of file diff --git a/modules/process.ps1 b/modules/process.ps1 new file mode 100644 index 0000000..15cc71b --- /dev/null +++ b/modules/process.ps1 @@ -0,0 +1,113 @@ +param($Config = $null); + +$CachedProcessList = $Icinga2.Utils.Modules.GetCacheElement( + $MyInvocation.MyCommand.Name, + 'ProcessList' +); + +$ProcessList = Get-WmiObject Win32_Process; +$ProcessPerfList = Get-WmiObject Win32_PerfFormattedData_PerfProc_Process; + +$NumberOfCPUThreads = $Icinga2.System.NumberOfCPUThreads; + +[hashtable]$ProcessReference = @{}; +[hashtable]$Processes = @{}; +[hashtable]$ProcessValues = @{ + FullList = @{ }; + Removed = @( ); + Added = $null; + Modified = @{ }; +} + +foreach ($process in $ProcessList) { + [string]$ProcessKey = [string]::Format( + '{0} [{1}]', + $process.ProcessName, + $process.ProcessId + ); + + [hashtable]$ProcessInfo = @{}; + + $ProcessInfo.Add('Name', $process.Name); + $ProcessInfo.Add('ProcessId', $process.ProcessId); + $ProcessInfo.Add('Priority', $process.Priority); + $ProcessInfo.Add('PageFileUsage', $process.PageFileUsage); + $ProcessInfo.Add('ThreadCount', $process.ThreadCount); + $ProcessInfo.Add('KernelModeTime', $process.KernelModeTime); + $ProcessInfo.Add('UserModeTime', $process.UserModeTime); + $ProcessInfo.Add('WorkingSetSize', $process.WorkingSetSize); + $ProcessInfo.Add('CommandLine', $process.CommandLine); +<# + # These are not required by now + $ProcessInfo.Add('Caption', $process.Caption); + $ProcessInfo.Add('CreationClassName', $process.CreationClassName); + $ProcessInfo.Add('CreationDate', $process.CreationDate); + $ProcessInfo.Add('CSCreationClassName', $process.CSCreationClassName); + $ProcessInfo.Add('CSName', $process.CSName); + $ProcessInfo.Add('Description', $process.Description); + $ProcessInfo.Add('ExecutablePath', $process.ExecutablePath); + $ProcessInfo.Add('ExecutionState', $process.ExecutionState); + $ProcessInfo.Add('Handle', $process.Handle); + $ProcessInfo.Add('HandleCount', $process.HandleCount); + $ProcessInfo.Add('InstallDate', $process.InstallDate); + $ProcessInfo.Add('MaximumWorkingSetSize', $process.MaximumWorkingSetSize); + $ProcessInfo.Add('MinimumWorkingSetSize', $process.MinimumWorkingSetSize); + $ProcessInfo.Add('OSCreationClassName', $process.OSCreationClassName); + $ProcessInfo.Add('OSName', $process.OSName); + $ProcessInfo.Add('OtherOperationCount', $process.OtherOperationCount); + $ProcessInfo.Add('OtherTransferCount', $process.OtherTransferCount); + $ProcessInfo.Add('PageFaults', $process.PageFaults); + $ProcessInfo.Add('ParentProcessId', $process.ParentProcessId); + $ProcessInfo.Add('PeakPageFileUsage', $process.PeakPageFileUsage); + $ProcessInfo.Add('PeakVirtualSize', $process.PeakVirtualSize); + $ProcessInfo.Add('PeakWorkingSetSize', $process.PeakWorkingSetSize); + $ProcessInfo.Add('PrivatePageCount', $process.PrivatePageCount); + $ProcessInfo.Add('QuotaNonPagedPoolUsage', $process.QuotaNonPagedPoolUsage); + $ProcessInfo.Add('QuotaPagedPoolUsage', $process.QuotaPagedPoolUsage); + $ProcessInfo.Add('QuotaPeakNonPagedPoolUsage', $process.QuotaPeakNonPagedPoolUsage); + $ProcessInfo.Add('QuotaPeakPagedPoolUsage', $process.QuotaPeakPagedPoolUsage); + $ProcessInfo.Add('ReadOperationCount', $process.ReadOperationCount); + $ProcessInfo.Add('ReadTransferCount', $process.ReadTransferCount); + $ProcessInfo.Add('SessionId', $process.SessionId); + $ProcessInfo.Add('Status', $process.Status); + $ProcessInfo.Add('TerminationDate', $process.TerminationDate); + $ProcessInfo.Add('VirtualSize', $process.VirtualSize); + $ProcessInfo.Add('WindowsVersion', $process.WindowsVersion); + $ProcessInfo.Add('WriteOperationCount', $process.WriteOperationCount); + $ProcessInfo.Add('WriteTransferCount', $process.WriteTransferCount); +#> + $ProcessReference.Add($process.ProcessId, $ProcessKey); + $Processes.Add($ProcessKey, $ProcessInfo); +} + +foreach ($perfdata in $ProcessPerfList) { + if ($perfdata.Name -eq '_Total') { + continue; + } + if ($ProcessReference.ContainsKey($perfdata.IDProcess)) { + $Processes[$ProcessReference[$perfdata.IDProcess]].Add( + 'WorkingSetPrivate', + $perfdata.WorkingSetPrivate + ); + # Note: In order to get the correct CPU time in % we have to divide the + # Processor Time with the amount of threads installed on our CPU + $Processes[$ProcessReference[$perfdata.IDProcess]].Add( + 'PercentProcessorTime', + [math]::Round(($perfdata.PercentProcessorTime / $NumberOfCPUThreads), 2) + ); + } +} + +$Processes.Add('count', $Processes.count); + +$Icinga2.Utils.Modules.AddCacheElement( + $MyInvocation.MyCommand.Name, + 'ProcessList', + $Processes +); + +return $Icinga2.Utils.Modules.GetHashtableDiff( + $Processes.Clone(), + $CachedProcessList.Clone(), + @('ProcessId') +); \ No newline at end of file diff --git a/modules/services.ps1 b/modules/services.ps1 new file mode 100644 index 0000000..02ea528 --- /dev/null +++ b/modules/services.ps1 @@ -0,0 +1,62 @@ +param($Config = $null); + +function ClassService() +{ + param($Config = $null); + $services = Get-Service; + + [hashtable]$ServiceData = @{}; + + $CachedServiceData = $Icinga2.Utils.Modules.GetCacheElement( + $MyInvocation.MyCommand.Name, + 'ServiceData' + ); + + foreach ($service in $services) { + [hashtable]$ServiceInfo = @{}; + + $ServiceInfo.Add('display_name', $service.DisplayName); + $ServiceInfo.Add('service_name', $service.ServiceName); + $ServiceInfo.Add('can_pause_and_continue', $service.CanPauseAndContinue); + $ServiceInfo.Add('can_shutdown', $service.CanShutdown); + $ServiceInfo.Add('can_stop', $service.CanStop); + $ServiceInfo.Add('service_handle', $service.ServiceHandle); + $ServiceInfo.Add('status', $service.Status); + $ServiceInfo.Add('service_type', $service.ServiceType); + $ServiceInfo.Add('start_type', $service.StartType); + $ServiceInfo.Add('site', $service.Site); + $ServiceInfo.Add('container', $service.Container); + + [array]$DependentServices = $null; + foreach ($dependency in $service.DependentServices) { + if ($DependentServices -eq $null) { $DependentServices = @(); } + $DependentServices += $dependency.Name; + } + $ServiceInfo.Add('dependent_services', $DependentServices); + + [array]$DependentServices = $null; + foreach ($dependency in $service.ServicesDependedOn) { + if ($DependentServices -eq $null) { $DependentServices = @(); } + $DependentServices += $dependency.Name; + } + $ServiceInfo.Add('depends_on', $DependentServices); + + $ServiceData.Add($service.Name, $ServiceInfo); + } + + $Icinga2.Utils.Modules.AddCacheElement( + $MyInvocation.MyCommand.Name, + 'ServiceData', + $ServiceData + ); + + return $Icinga2.Utils.Modules.GetHashtableDiff( + $ServiceData.Clone(), + $CachedServiceData.Clone(), + @('service_name') + ); + + return $ServiceData; +} + +return ClassService -Config $Config; \ No newline at end of file diff --git a/modules/updates.ps1 b/modules/updates.ps1 new file mode 100644 index 0000000..ea47044 --- /dev/null +++ b/modules/updates.ps1 @@ -0,0 +1,6 @@ +param($Config = $null); + +return $Icinga2.Utils.Modules.LoadIncludes( + $MyInvocation.MyCommand.Name, + $Config +); \ No newline at end of file diff --git a/modules/windows.ps1 b/modules/windows.ps1 new file mode 100644 index 0000000..057defe --- /dev/null +++ b/modules/windows.ps1 @@ -0,0 +1,16 @@ +param($Config = $null); + +function ClassWindows +{ + param($Config = $null); + $WindowsInformations = Get-CimInstance Win32_OperatingSystem; + + $windows_datails = @{}; + foreach($cpu_core in $WindowsInformations.CimInstanceProperties) { + $windows_datails.Add($cpu_core.Name, $cpu_core.Value); + } + + return $windows_datails; +} + +return ClassWindows -Config $Config; \ No newline at end of file