Ninja PowerShell Blog

Welcome to part 2 of my series of using PowerShell to send Discord webhooks. In this post I will be going over how to send embeds. If you’re just getting started with the process, I recommend reading Part 1 first.

I’ve created a module that makes it easy to work with embeds, store configurations that contain your webhook urls, and make the whole experience seamless by using PowerShell classes. It will get its own post, and if you’re interested check out the Github repo here.

Title and description are strings so those are easy. Color is also a string, but a string that represents a decimal value for the color. For now it is important to understand the value for green is 4289797.

$color='4289797'$title='Greetings from PowerShell!'$description='This is an embed. It looks much nicer than just sending text over!'

3. Now it’s time to create a PSCustomObject that contains those items to add to the array we created earlier.

$title='Greetings with a picture!'$description='This embed should now contain an image'$color='9442302'$embedObject=[PSCustomObject]@{title=$titledescription=$descriptioncolor=$colorthumbnail=$thumbnailObject}

3. Now we’ll create an array, add the embed object, create the payload, and send that over to the webhook url.

Github Repo With Example Code

Summary

Embeds are a little more complicated, but not too hard to work in with these webhooks and PowerShell. There is even more you can do, including adding fields to embeds and sending files. In part 3 I will be go over just how to do that!

Let me know if you have any ideas, questions, or feedback in the comments below!

What's it do?

This post is mostly for the World of Warcraft nerds out there. /raises handThis module allows you to:

Check / Update ElvUI if there is a newer version

Install ElvUI if it is not already installed

Simply check if there is a new version, but do nothing else

Why? Isn't there already a tool for this?

There is a tool provided by the fine folks that created the AddOn, and it works great. I wanted a way to possible automate it (maybe a scheduled task on boot), and a fast way to check/update without having to login/launch anything other than PowerShell (which I happen to already have up most of the time anyway).

How can I install it?

Currently this is only working on Windows, with Mac support coming soon.

You can install the module from the PowerShell Gallery by using (as admin):

The remote code check utilizes Invoke-WebRequest to get the version number. This method can fail when the website gets updated. There is no public API for this, so for now this is the method that works.

The local version check loops through the contents of the ElvUI.toc file and matches on the ‘## Version”’ line. Then some string manipulation is used to grab the version. Error handling is in place to ensure issues are caught if they arise.

What's a Discord Webhook?

A Discord Webhook allows you to send a message to a text channel auto-magically. It essentially provides a URL that is associated with a channel. You can customize the name of the sender, the avatar picture, and of course send over the contents of the mesage. More info on Discord Webhooks here.

Getting Started

The first step will be to create the Webhook in Discord.

1. Navigate to Server Settings on the server you want to create the hook on.

2. Click Webhooks from the options on the left, and then Create Webhook on the right.

3. Fill out the name of the hook, the channel you want it to hook into, and optionally associate an image.

4. Save the Webhook, and get the Webhook Url, as we'll need that later.

The payload is a PSCustomObject, which is great when working in PowerShell, but not-so-great when working with Web APIs, which almost always want something to be formatted as JSON (JavaScript Object Notation).

Download Files With PowerShell Dynamically!

Knowing PowerShell can come in handy when you need to download files. Invoke-WebRequest is the command to get to know when working with web parsing, and obtaining downloads.

I've noticed, however, that different files show up as different content types, and parsing out the file name requires all sorts of voodoo. What if there was a way to use one tool that could utilize the power of PowerShell, and make downloading files a modular experience?

This tool, and blog post, are is inspired by folks asking me for help downloading files via PowerShell. I always appreciate feedback and questions, and this is exactly why!

Prerequisites

Ninja Downloader Overview

Ninja Downloader works by executing the main script (download.ps1), which takes the following parameters:

DownloadName

This is the name of the script you'd like to execute (use 'all' to execute all scripts)

Scripts are located in the .\scripts directory

Argument must omit the .ps1 extension of the script in the .\scripts directory

OutputType

This parameter let's you specify the output type, the default is none.

XML -> Export results as clixml

CSV -> Export results as a CSV file (default)

HTML -> Export results as an HMTL file

All -> Export results as all of the above

Output is exported to the .\output directory

DownloadFolder

This parameter allows you to specify a location to place the downloaded files

Folder will be created if it does not exist

If left empty the folder .\downloads will be used

UnZip

This parameter will look for zip archives after files are downloaded and attempt to extract them

Files extracted to .\downloads\fileName_HHmm-MMddyy\

ListOnly

This parameter (a switch) will give you a list of all possible names to use for DownloadName, as well as the paths to the scripts.

Downloading a File

There are several scripts included by default with tool.

Ccleaner.ps1

Chrome.ps1

FireFox.ps1

Java.ps1

Skype.ps1

template.ps1 (never executed, this is a template for creating your own download script)

So how do we use them, then?

Open PowerShell, and navigate to the root directory of the project/script.

Run the following code:

$downloadResults=.\download.ps1-DownloadNameccleaner

This will give us access to the results in $downloadResults.

You can see that the results we get include the name of the script executed, if it was executed successfully, any errors, and another object inside of this object named FileInfo.

FileInfo contains the file name, path to the file (full), any errors, and if we could verify it exists.

This attempt was successful, and our results echoed that! Let's take a look in the downloads folder just to be sure...

Awesome!

Downloading All Files

What if we wanted to download all the files via every script in the .\scripts folder?

Open PowerShell, and navigate to the root directory.

Run the following code:

$downloadResults=.\download.ps1-DownloadNameall-Verbose

This time we can see some of the output as it happens via the -Verbose switch.

Now let's take a look at $downloadResults:

For good measure, we'll also look at the downloads folder:

Alright! It worked.

Output Types

This script allows you output the results in various ways. All of the results will be time-stamped with the date and time.

CSV

To output results as a CSV, run:

$downloadResults=.\download.ps1-DownloadNameall-OutputTypecsv

After it runs, the results will be in the .\output folder.

HTML

To output results as HTML, run:

$downloadResults=.\download.ps1-DownloadNameall-OutputTypehtml

After it runs, the results will be in the .\output folder.

XML

To output results as XML, run:

$downloadResults=.\download.ps1-DownloadNameall-OutputTypexml

After it runs, the results will be in the .\output folder.

All

To output results in all three formats, run:

$downloadResults=.\download.ps1-DownloadNameall-OutputTypeall

After it runs, the results will be in the .\output folder.

Creating Your Own Download Script

You can create your own script to use with the Ninja Downloader tool. The template provided is a working example of how Firefox is downloaded. The template is located in the .\scripts folder.

Template code:

#Template example (works for Firefox, adjust as needed for your download)#Set this to the URL you'll be navigating to first$navUrl='https://www.mozilla.org/en-US/firefox/all/'#Text to match on, if applicable$matchText='.+windows.+64.+English.+\(US\)'# IMPORTANT: This is the format of the object needed to be returned to the description# Whichever way you get the information, you need to return an object with the following properties:# DownloadName (string, file name)# Content (byte array, file contents)# Success (boolean)# Error (string, any error received)$downloadInfo=[PSCustomObject]@{DownloadName=''Content=''Success=$falseError=''}#Go to first pageTry{$downloadRequest=Invoke-WebRequest-Uri$navURL-MaximumRedirection0-UserAgent[Microsoft.PowerShell.Commands.PSUserAgent]::FireFox-ErrorActionSilentlyContinue}Catch{$errorMessage=$_.Exception.Message$downloadInfo.Error=$errorMessagereturn$downloadInfo}#Look for urls that match$downloadURL=$downloadRequest.Links|Where-Object{$_.Title-Match$matchText}|Select-Object-ExpandPropertyhref#Go to matching URL, look for download file (keeping redirects at 0)try{$downloadRequest=Invoke-WebRequest-Uri$downloadURL-MaximumRedirection0-UserAgent[Microsoft.PowerShell.Commands.PSUserAgent]::FireFox-ErrorActionSilentlyContinue}catch{$errorMessage=$_.Exception.Message$downloadInfo.Error=$errorMessagereturn$downloadInfo}#Get file info$downloadFile=$downloadRequest.Headers.Location#Parse file name, whichever way neededif($downloadRequest.Headers.Location){$downloadInfo.DownloadName=$downloadFile.SubString($downloadFile.LastIndexOf('/')+1).Replace('%20','')}#Switch out the StatusDescription, as applicableSwitch($downloadRequest.StatusDescription){'Found'{$downloadRequest=Invoke-WebRequest-Uri$downloadRequest.Headers.Location-UserAgent[Microsoft.PowerShell.Commands.PSUserAgent]::FireFox}default{$downloadInfo.Error="Status description [$($downloadRequest.StatusDescription)] not handled!"return$downloadInfo}}#Switch out the proper content type for the file downloadSwitch($downloadRequest.BaseResponse.ContentType){'application/x-msdos-program'{$downloadInfo.Content=$downloadRequest.Content$downloadInfo.Success=$truereturn$downloadInfo}Default{$downloadInfo.Error="Content type [$($downloadRequest.BaseResponse.ContentType)] not handled!"return$downloadInfo}}

What matters the most is that you return an object that has the following properties:

#Template example (works for Firefox, adjust as needed for your download)#Set this to the URL you'll be navigating to first$navUrl='http://www.tukui.org/dl.php'# IMPORTANT: This is the format of the object needed to be returned to the description# Whichever way you get the information, you need to return an object with the following properties:# DownloadName (string, file name)# Content (byte array, file contents)# Success (boolean)# Error (string, any error received)$downloadInfo=[PSCustomObject]@{DownloadName=''Content=''Success=$falseError=''}#Go to first pageTry{$downloadRequest=Invoke-WebRequest-Uri$navUrl-UserAgent[Microsoft.PowerShell.Commands.PSUserAgent]::FireFox-ErrorActionSilentlyContinue}Catch{$errorMessage=$_.Exception.Message$downloadInfo.Error=$errorMessagereturn$downloadInfo}#Look for urls that match$downloadURL=($downloadRequest.Links|Where-Object{$_-like'*elv*'-and$_-like'*download*'}).href$downloadInfo.DownloadName=$downloadURL.Substring($downloadURL.LastIndexOf('/')+1)#Go to matching URL, look for download file (keeping redirects at 0)try{$downloadRequest=Invoke-WebRequest-Uri$downloadURL}catch{$errorMessage=$_.Exception.Message$downloadInfo.Error=$errorMessagereturn$downloadInfo}#Switch out the proper content type for the file downloadSwitch($downloadRequest.BaseResponse.ContentType){'application/zip'{$downloadInfo.Content=$downloadRequest.Content$downloadInfo.Success=$truereturn$downloadInfo}Default{$downloadInfo.Error="Content type [$($downloadRequest.BaseResponse.ContentType)] not handled!"return$downloadInfo}}

Configure Your Scripts With a GUI!

Making a GUI in PowerShell is a relatively easy process. GUIs can really come in handy, too! You can use them in scripts to guide execution a specific way, and even ease people into using PowerShell scripts that may otherwise stray away from anything command-line.

One of the coolest uses I've found for GUIs in PowerShell, is using them for script configuration. You run the script, set a parameter to true, and boom you have a GUI open that allows you to change and configure parts of the script the next time it executes.

If you're on a team, and you don't want people to have to edit your scripts (actually, if you don't want YOU to even have to edit your scripts), this is the way to go.

Prerequisites

I use Write-Host in all my example code for one reason: readability. When you're actually scripting and writing code, you'll want to be sure to use something more versatile like Write-Verbose.Even better, use some logging functions to output to a log file and/or console.

This gives us a parameter we can set later that will allow us to pop up the GUI (or not), and we setup some paths the script can use based on where it is run from. We also set the path to the configuration file we'll be creating and using.

Current folder structure:

Function for generating configuration file

Now let's create a function that will serve two purposes:

Accept input as the configuration file contents to export

Generate a base config if we don't pass any content to export

This is nice for first time script execution, and we can include some defaults that we'll expand upon later

functionInvoke-ConfigurationGeneration{#Begin function Invoke-ConfigurationGeneration[cmdletbinding()]param($configurationOptions)if(!$configurationOptions){#Actions if we don't pass in any options to the function#The OU list will be an array[System.Collections.ArrayList]$ouList=@()#These variables will be used to evaluate last logon dates of users[int]$warnDays=23[int]$disableDays=30#Add some fake OUs for testing purposes$ouList.Add('OU=Marketing,DC=FakeDomain,DC=COM')|Out-Null$ouList.Add('OU=Sales,DC=FakeDomain,DC=COM')|Out-Null#Create a custom object to store things in$configurationOptions=[PSCustomObject]@{WarnDays=$warnDaysDisableDays=$disableDaysOUList=$ouList}#Export the object we created as the current configurationWrite-Host"Exporting generated configuration file to [$configFile]!"$configurationOptions|Export-Clixml-Path$configFile}else{#End actions for no options passed in, being actions for if they areWrite-Host"Exporting passed in options as configuration file to [$configFile]!"$configurationOptions|Export-Clixml-Path$configFile}#End if for options passed into function}#End function Invoke-ConfigurationGeneration

Next we'll add in an if statement that will:

Create a config file if it doesn't exist (and import it)

OR import a config file if it does exist

#Check for config, generate if it doesn't existif(!(Test-Path-Path$configFile)){Write-Host"Configuration file does not exist, creating!"-ForegroundColorGreen-BackgroundColorBlack#Call our function to generate the fileInvoke-ConfigurationGeneration$script:configData=Import-Clixml-Path$configFile}else{#Import file since it exists$script:configData=Import-Clixml-Path$configFile}

We can take a peek in the input folder now to verify if it's there, and look at the contents.

Using the Configuration Data

Now that we have the configuration data available, and imported, we can use it in our script.

First, let's take a look at what the config data looks like after it is imported into PowerShell via Import-Clixml.

We have our data handy, and we can confirm that the OUList is indeed an array (as we'd want it to be), if we pipe $config to Get-Member.

With this data available to us, we can add some logic to our script to use it.

First, we'll add an if statement to the script that will determine if we want to launch the configuration GUI, or actually run through the script logic.

Code:

#Check for config, generate if it doesn't existif(!(Test-Path-Path$configFile)){Write-Host"Configuration file does not exist, creating!"-ForegroundColorGreen-BackgroundColorBlack#Call our function to generate the fileInvoke-ConfigurationGeneration$script:configData=Import-Clixml-Path$configFile}else{#Import file since it exists$script:configData=Import-Clixml-Path$configFile}#Script logicif($configScript){#Begin if to see if $configScript is set to true#If it's true, run this function to launch the GUIInvoke-GUI}else{#Begin if/else for script exeuction (non-config)#Simple example for using the OUList defined in the config fileForEach($ouin$script:configData.OUList){#Begin foreach loop for OU actionsWrite-Host"Performing action on [$ou]!"-ForegroundColorGreen-BackgroundColorBlack}#End foreach loop for OU actions#Create some test users$userList=Invoke-UserDiscovery#Take actions on each user and store results in $processedUsers$processedUsers=$userList|Invoke-UserAction#Create file name for data export$outputFileName=("$outputDir\processedUsers_{0:MMddyy_HHmm}.csv"-f(Get-Date))#Export processed users various data types$processedUsers|Export-Csv-Path$outputFileName-NoTypeInformation$processedUsers|Export-Clixml-Path($outputFileName-replace'csv','xml')Write-Host"File exported to [$outputDir]!"#Take a look at the array$processedUsers|Format-Table}#End if/else for script actions (non-config)

In the code above, I have referenced a few different functions well need for the script to run correctly. They are Invoke-UserDiscovery and Invoke-UserAction.

Here is the code for those functions:

functionInvoke-UserDiscovery{#Begin function Invoke-UserDiscovery[cmdletbinding()]param()#Create empty arrayList object[System.Collections.ArrayList]$userList=@()#Create users and add them to array$testUser2=[PSCustomObject]@{DisplayName='Mike Jones'UserName='jonesm'LastLogon=(Get-Date).AddDays(-35)OU=Get-Random-inputObject$script:configData.OUList}$testUser1=[PSCustomObject]@{DisplayName='John Doe'UserName='doej'LastLogon=(Get-Date).AddDays(-24)OU=Get-Random-inputObject$script:configData.OUList}$testUser3=[PSCustomObject]@{DisplayName='John Doe'UserName='doej'LastLogon=(Get-Date).AddDays(-10)OU=Get-Random-inputObject$script:configData.OUList}$testUser4=[PSCustomObject]@{DisplayName='This WontWork'UserName='wontworkt'LastLogon=$nullOU=Get-Random-inputObject$script:configData.OUList}$testUser5=[PSCustomObject]@{DisplayName='This AlsoWontWork'UserName='alsowontworkt'LastLogon='this many!'OU=Get-Random-inputObject$script:configData.OUList}#Add users to arraylist$userList.Add($testUser1)|Out-Null$userList.Add($testUser2)|Out-Null$userList.Add($testUser3)|Out-Null$userList.Add($testUser4)|Out-Null$userList.Add($testUser5)|Out-Null#Return listReturn$userList}#End function Invoke-UserDiscoveryfunctionInvoke-UserAction{#Begin function Invoke-UserAction[cmdletbinding()]param([Parameter(Mandatory,ValueFromPipeline )]$usersToProcess)Begin{#Begin begin block for Invoke-UserAction#Create array to store results in[System.Collections.ArrayList]$processedArray=@()Write-Host`n"User processing started!"`n-ForegroundColorBlack-BackgroundColorGreen}#End begin block for Invoke-UserActionProcess{#Begin process block for function Invoke-UserActionforeach($userin$usersToProcess){#Begin user foreach loop#Set variables to null so they are not set by the last iteration$lastLogonDays=$null$userAction=$null$notes='N/A'#Some error handling for getting the last logon daysTry{#Set value based on calculation using the LastLogon value of the user$lastLogonDays=((Get-Date)-$user.LastLogon).Days}Catch{#Capture message into variable $errorMessage, and set other variables accordingly$errorMessage=$_.Exception.Message$lastLogonDays=$null$notes=$errorMessageWrite-Host`n"Error while calculating last logon days [$errorMessage]"`n-ForegroundColorRed-BackgroundColorDarkBlue}Write-Host`n"Checking on [$($user.DisplayName)], who last logged on [$lastLogonDays] days ago..."#Switch statement to switch out the value of $lastLogonDaysSwitch($lastLogonDays){#Begin action switch#This expression compares the value of $lastLogondays to the script scoped variable for warning days, set with the configuration data file{$_-lt$script:configData.DisableDays-and$_-ge$script:configData.WarnDays}{#Begin actions for warning$userAction='Warn'Write-Host"Warning, [$($user.DisplayName)] will be disabled in [$($script:configData.DisableDays-$lastLogonDays)] days!"`nBreak}#End actions for warning#This expression compares the value of $lastLogondays to the script scoped variable for disable days, set with the configuration data file{$_-ge$script:configData.DisableDays}{#Begin actions for disable$userAction='Disable'Write-Host"[$($user.DisplayName)] is going to be disabled, and is [$($lastLogonDays-$script:ConfigData.DisableDays)] days past the threshold!"`nBreak}#End actions for disable{$_-eq$null}{#Begin actions for a null value$userAction='Error'Write-Host"Something went wrong, no value specified for last logon days!"`n-ForegroundColorRed-BackgroundColorDarkBlueBreak}#End actions for a null value#Adding a default to catch other valuesdefault{#Begin default actions$userAction='None'Write-Host"$($user.DisplayName) is good to go, they last logged on [$($lastLogonDays)] days ago!"`n}#Begin default actions}#End action switch#Create object to store in array$processedObject=[PSCustomObject]@{DisplayName=$user.DisplayNameUserName=$user.UserNameOU=$user.OULastLogon=$user.LastLogonLastLogonDays=$lastLogonDaysAction=$userActionNotes=$notes}#Add object to array of processed users$processedArray.Add($processedObject)|Out-Null}#End user foreach loop}#End process block for function Invoke-UserActionEnd{#Begin end block for Invoke-UserActionWrite-Host`n"User processing ended!"`n-ForegroundColorBlack-BackgroundColorGreen#Return arrayReturn$processedArray}#End end block for Invoke-UserAction}#End function Invoke-UserAction

Full code so far:

[cmdletbinding()]param([Boolean]$configScript=$false)#Setup paths$scriptPath=Split-Path-parent$MyInvocation.MyCommand.Definition$inputDir="$scriptPath\Input"$outputDir="$scriptPath\Output"$configFile="$inputDir\config.xml"functionInvoke-UserDiscovery{#Begin function Invoke-UserDiscovery[cmdletbinding()]param()#Create empty arrayList object[System.Collections.ArrayList]$userList=@()#Create users and add them to array$testUser2=[PSCustomObject]@{DisplayName='Mike Jones'UserName='jonesm'LastLogon=(Get-Date).AddDays(-35)OU=Get-Random-inputObject$script:configData.OUList}$testUser1=[PSCustomObject]@{DisplayName='John Doe'UserName='doej'LastLogon=(Get-Date).AddDays(-24)OU=Get-Random-inputObject$script:configData.OUList}$testUser3=[PSCustomObject]@{DisplayName='John Doe'UserName='doej'LastLogon=(Get-Date).AddDays(-10)OU=Get-Random-inputObject$script:configData.OUList}$testUser4=[PSCustomObject]@{DisplayName='This WontWork'UserName='wontworkt'LastLogon=$nullOU=Get-Random-inputObject$script:configData.OUList}$testUser5=[PSCustomObject]@{DisplayName='This AlsoWontWork'UserName='alsowontworkt'LastLogon='this many!'OU=Get-Random-inputObject$script:configData.OUList}#Add users to arraylist$userList.Add($testUser1)|Out-Null$userList.Add($testUser2)|Out-Null$userList.Add($testUser3)|Out-Null$userList.Add($testUser4)|Out-Null$userList.Add($testUser5)|Out-Null#Return listReturn$userList}#End function Invoke-UserDiscoveryfunctionInvoke-UserAction{#Begin function Invoke-UserAction[cmdletbinding()]param([Parameter(Mandatory,ValueFromPipeline )]$usersToProcess)Begin{#Begin begin block for Invoke-UserAction#Create array to store results in[System.Collections.ArrayList]$processedArray=@()Write-Host`n"User processing started!"`n-ForegroundColorBlack-BackgroundColorGreen}#End begin block for Invoke-UserActionProcess{#Begin process block for function Invoke-UserActionforeach($userin$usersToProcess){#Begin user foreach loop#Set variables to null so they are not set by the last iteration$lastLogonDays=$null$userAction=$null$notes='N/A'#Some error handling for getting the last logon daysTry{#Set value based on calculation using the LastLogon value of the user$lastLogonDays=((Get-Date)-$user.LastLogon).Days}Catch{#Capture message into variable $errorMessage, and set other variables accordingly$errorMessage=$_.Exception.Message$lastLogonDays=$null$notes=$errorMessageWrite-Host`n"Error while calculating last logon days [$errorMessage]"`n-ForegroundColorRed-BackgroundColorDarkBlue}Write-Host`n"Checking on [$($user.DisplayName)], who last logged on [$lastLogonDays] days ago..."#Switch statement to switch out the value of $lastLogonDaysSwitch($lastLogonDays){#Begin action switch#This expression compares the value of $lastLogondays to the script scoped variable for warning days, set with the configuration data file{$_-lt$script:configData.DisableDays-and$_-ge$script:configData.WarnDays}{#Begin actions for warning$userAction='Warn'Write-Host"Warning, [$($user.DisplayName)] will be disabled in [$($script:configData.DisableDays-$lastLogonDays)] days!"`nBreak}#End actions for warning#This expression compares the value of $lastLogondays to the script scoped variable for disable days, set with the configuration data file{$_-ge$script:configData.DisableDays}{#Begin actions for disable$userAction='Disable'Write-Host"[$($user.DisplayName)] is going to be disabled, and is [$($lastLogonDays-$script:ConfigData.DisableDays)] days past the threshold!"`nBreak}#End actions for disable{$_-eq$null}{#Begin actions for a null value$userAction='Error'Write-Host"Something went wrong, no value specified for last logon days!"`n-ForegroundColorRed-BackgroundColorDarkBlueBreak}#End actions for a null value#Adding a default to catch other valuesdefault{#Begin default actions$userAction='None'Write-Host"$($user.DisplayName) is good to go, they last logged on [$($lastLogonDays)] days ago!"`n}#Begin default actions}#End action switch#Create object to store in array$processedObject=[PSCustomObject]@{DisplayName=$user.DisplayNameUserName=$user.UserNameOU=$user.OULastLogon=$user.LastLogonLastLogonDays=$lastLogonDaysAction=$userActionNotes=$notes}#Add object to array of processed users$processedArray.Add($processedObject)|Out-Null}#End user foreach loop}#End process block for function Invoke-UserActionEnd{#Begin end block for Invoke-UserActionWrite-Host`n"User processing ended!"`n-ForegroundColorBlack-BackgroundColorGreen#Return arrayReturn$processedArray}#End end block for Invoke-UserAction}#End function Invoke-UserActionfunctionInvoke-GUI{#Begin function Invoke-GUI[cmdletbinding()]Param()[void][System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms")[void][System.Reflection.Assembly]::LoadWithPartialName('presentationframework')$inputXML=@"<Window x:Class="psguiconfig.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:local="clr-namespace:psguiconfig" mc:Ignorable="d" Title="Script Configuration" Height="281.26" Width="509.864"> <Grid> <Label x:Name="lblWarningMin" Content="Warning Days" HorizontalAlignment="Left" Height="29" Margin="10,6,0,0" VerticalAlignment="Top" Width="86"/> <TextBox x:Name="txtBoxWarnLow" HorizontalAlignment="Left" Height="20" Margin="96,6,0,0" TextWrapping="Wrap" Text="0" VerticalAlignment="Top" Width="27"/> <Label x:Name="lblDisableMin" Content="Disable Days" HorizontalAlignment="Left" Height="29" Margin="134,6,0,0" VerticalAlignment="Top" Width="86"/> <TextBox x:Name="txtBoxDisableLow" HorizontalAlignment="Left" Height="20" Margin="220,6,0,0" TextWrapping="Wrap" Text="0" VerticalAlignment="Top" Width="27"/> <TextBox x:Name="txtBoxOUList" HorizontalAlignment="Left" Height="153" Margin="10,54,0,0" TextWrapping="Wrap" VerticalAlignment="Top" Width="479" AcceptsReturn="True" ScrollViewer.VerticalScrollBarVisibility="Auto"/> <Label x:Name="lblOUs" Content="OUs To Scan" HorizontalAlignment="Left" Height="26" Margin="10,28,0,0" VerticalAlignment="Top" Width="80"/> <Button x:Name="btnExceptions" Content="Exceptions" HorizontalAlignment="Left" Height="43" Margin="252,6,0,0" VerticalAlignment="Top" Width="237"/> <Button x:Name="btnEdit" Content="Edit" HorizontalAlignment="Left" Height="29" Margin="10,212,0,0" VerticalAlignment="Top" Width="66"/> <Button x:Name="btnSave" Content="Save" HorizontalAlignment="Left" Height="29" Margin="423,212,0,0" VerticalAlignment="Top" Width="66"/> </Grid></Window>"@[xml]$XAML=$inputXML-replace'mc:Ignorable="d"',''-replace"x:N",'N'-replace'^<Win.*','<Window'#Read XAML $reader=(New-ObjectSystem.Xml.XmlNodeReader$xaml)try{$Form=[Windows.Markup.XamlReader]::Load($reader)}catch{Write-Error"Unable to load Windows.Markup.XamlReader. Double-check syntax and ensure .net is installed."}#Create variables to control form elements as objects in PowerShell$xaml.SelectNodes("//*[@Name]")|ForEach-Object{Set-Variable-Name"WPF$($_.Name)"-Value$Form.FindName($_.Name)-ScopeGlobal}#Show form$form.ShowDialog()|Out-Null}#End function Invoke-GUIfunctionInvoke-ConfigurationGeneration{#Begin function Invoke-ConfigurationGeneration[cmdletbinding()]param($configurationOptions)if(!$configurationOptions){#Actions if we don't pass in any options to the function#The OU list will be an array[System.Collections.ArrayList]$ouList=@()#These variables will be used to evaluate last logon dates of users[int]$warnDays=23[int]$disableDays=30#Add some fake OUs for testing purposes$ouList.Add('OU=Marketing,DC=FakeDomain,DC=COM')|Out-Null$ouList.Add('OU=Sales,DC=FakeDomain,DC=COM')|Out-Null#Create a custom object to store things in$configurationOptions=[PSCustomObject]@{WarnDays=$warnDaysDisableDays=$disableDaysOUList=$ouList}#Export the object we created as the current configurationWrite-Host"Exporting generated configuration file to [$configFile]!"$configurationOptions|Export-Clixml-Path$configFile}else{#End actions for no options passed in, being actions for if they areWrite-Host"Exporting passed in options as configuration file to [$configFile]!"$configurationOptions|Export-Clixml-Path$configFile}#End if for options passed into function}#End function Invoke-ConfigurationGeneration#Script logicif($configScript){#Begin if to see if $configScript is set to true#If it's true, run this function to launch the GUIInvoke-GUI}else{#Begin if/else for script exeuction (non-config)#Simple example for using the OUList defined in the config fileForEach($ouin$script:configData.OUList){#Begin foreach loop for OU actionsWrite-Host"Performing action on [$ou]!"-ForegroundColorGreen-BackgroundColorBlack}#End foreach loop for OU actions#Create some test users$userList=Invoke-UserDiscovery#Take actions on each user and store results in $processedUsers$processedUsers=$userList|Invoke-UserAction#Create file name for data export$outputFileName=("$outputDir\processedUsers_{0:MMddyy_HHmm}.csv"-f(Get-Date))#Export processed users various data types$processedUsers|Export-Csv-Path$outputFileName-NoTypeInformation$processedUsers|Export-Clixml-Path($outputFileName-replace'csv','xml')Write-Host"File exported to [$outputDir]!"#Take a look at the array$processedUsers|Format-Table}#End if/else for script actions (non-config)

Overview of what will happen:

If $configScript is set to $false, the else statement will execute

An example of using config data will be displayed for each of the OUs stored

We generate a list of users with some last logon values, and random OUs from the OU list (Invoke-UserDiscovery)

We then go through the users and process them, by piping the user array to Invoke-UserAction

You can see that it performed all the steps above, as we would expect it to.

The output directory now contains two files:

CSV File Contents:

Clixml File Contents:

Adding a Modifiable Configuration

Now onto the ability to modify and save the configuration via the GUI we created!

Normally, I stray away from creating functions within functions...

...but in this case I like the way it creates a clean and understandable layout.

We're going to focus entirely on the function we created earlier, Invoke-GUI.

Here is the new code:

functionInvoke-GUI{#Begin function Invoke-GUI[cmdletbinding()]Param()#We technically don't need these, but they may come in handy later if you want to pop up message boxes, etc[void][System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms")[void][System.Reflection.Assembly]::LoadWithPartialName('presentationframework')#Input XAML here$inputXML=@"<Window x:Class="psguiconfig.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:local="clr-namespace:psguiconfig" mc:Ignorable="d" Title="Script Configuration" Height="281.26" Width="509.864"> <Grid> <Label x:Name="lblWarningMin" Content="Warning Days" HorizontalAlignment="Left" Height="29" Margin="10,6,0,0" VerticalAlignment="Top" Width="86"/> <TextBox x:Name="txtBoxWarnLow" HorizontalAlignment="Left" Height="20" Margin="96,6,0,0" TextWrapping="Wrap" Text="0" VerticalAlignment="Top" Width="27"/> <Label x:Name="lblDisableMin" Content="Disable Days" HorizontalAlignment="Left" Height="29" Margin="134,6,0,0" VerticalAlignment="Top" Width="86"/> <TextBox x:Name="txtBoxDisableLow" HorizontalAlignment="Left" Height="20" Margin="220,6,0,0" TextWrapping="Wrap" Text="0" VerticalAlignment="Top" Width="27"/> <TextBox x:Name="txtBoxOUList" HorizontalAlignment="Left" Height="153" Margin="10,54,0,0" TextWrapping="Wrap" VerticalAlignment="Top" Width="479" AcceptsReturn="True" ScrollViewer.VerticalScrollBarVisibility="Auto"/> <Label x:Name="lblOUs" Content="OUs To Scan" HorizontalAlignment="Left" Height="26" Margin="10,28,0,0" VerticalAlignment="Top" Width="80"/> <Button x:Name="btnExceptions" Content="Exceptions" HorizontalAlignment="Left" Height="43" Margin="252,6,0,0" VerticalAlignment="Top" Width="237"/> <Button x:Name="btnEdit" Content="Edit" HorizontalAlignment="Left" Height="29" Margin="10,212,0,0" VerticalAlignment="Top" Width="66"/> <Button x:Name="btnSave" Content="Save" HorizontalAlignment="Left" Height="29" Margin="423,212,0,0" VerticalAlignment="Top" Width="66"/> </Grid></Window>"@[xml]$XAML=$inputXML-replace'mc:Ignorable="d"',''-replace"x:N",'N'-replace'^<Win.*','<Window'#Read XAML $reader=(New-ObjectSystem.Xml.XmlNodeReader$xaml)try{$Form=[Windows.Markup.XamlReader]::Load($reader)}catch{Write-Error"Unable to load Windows.Markup.XamlReader. Double-check syntax and ensure .net is installed."}#Create variables to control form elements as objects in PowerShell$xaml.SelectNodes("//*[@Name]")|ForEach-Object{Set-Variable-Name"WPF$($_.Name)"-Value$Form.FindName($_.Name)-ScopeGlobal}#Setup the form functionInvoke-FormSetup{#Begin function Invoke-FormSetup#Here we set the default states of the objects that represent the buttons/fields$WPFbtnEdit.IsEnabled=$true$WPFbtnSave.IsEnabled=$false$WPFtxtBoxWarnLow.IsEnabled=$false$WPFtxtBoxDisableLow.IsEnabled=$false$WPFtxtBoxOUList.IsEnabled=$false$WPFbtnExceptions.IsEnabled=$false#We will use the current values we imported from the script scoped variable configData$WPFtxtBoxWarnLow.Text=$script:configData.WarnDays$WPFtxtBoxDisableLow.Text=$script:configData.DisableDays$WPFtxtBoxOUList.Text=$script:configData.OUList|Out-String}#End function Invoke-FormSetupfunctionInvoke-FormSaveData{#Begin function Invoke-FormSaveData#This function will perform the action to save the form data#We setup the variables based on the current values of the form$warnDays=[int]$WPFtxtBoxWarnLow.Text$disableDays=[int]$WPFtxtBoxDisableLow.Text$ouList=($WPFtxtBoxOUList.Text|Out-String).Trim()-split'[\r\n]'|Where-Object{$_-ne''}#This object will contain the current configuration we would like to export$configurationOptions=[PSCustomObject]@{WarnDays=$warnDaysDisableDays=$disableDaysOUList=$ouList}#We then pass the configuration to the function we created earlier that will export the options we pass inInvoke-ConfigurationGeneration-configurationOptions$configurationOptions#Then we re-import the config file after it is exported via the function above$script:configData=Import-Clixml-Path$configFile#Finally we revert the GUI to the original state, which will also reflect the lastest configuration that we just exportedInvoke-FormSetup}#End function Invoke-FormSaveData#Now we perform actions using the functions we created, as well as code that runs when buttons are clicked#Run form setup on launchInvoke-FormSetup#Button actions$WPFbtnEdit.Add_Click{#Begin edit button actions#This will 'open up' the form and allow fields to be edited$WPFbtnExceptions.IsEnabled=$true$WPFbtnSave.IsEnabled=$true$WPFtxtBoxWarnLow.IsEnabled=$true$WPFtxtBoxDisableLow.IsEnabled=$true$WPFtxtBoxOUList.IsEnabled=$true$WPFbtnExceptions.IsEnabled=$true}#End edit button actions$WPFbtnSave.Add_Click{#Begin save button actions#The save button calls the Invoke-FormSaveData functionInvoke-FormSaveData}#End save button actions#Show the form$form.showDialog()|Out-Null}#End function Invoke-GUI

Overview:

We add a function to save the data when the save button is clicked (Invoke-FormSaveData)

We add a function that resets the data on the form and displays the current configuration (Invoke-FormSetup)

We add some code that executes when the Edit and Save buttons are clicked

Notes:

The XAML has also been modified a bit (one line of it), to allow us to use the enter key, as well as to add a scrollbar (if needed).

The line that represents the OU list has had the following appended to it:

Why Control Internet Explorer with PowerShell?

I've covered using Invoke-WebRequest, as well as Invoke-RestMethod for basic and advanced tasks... why would we need anything else? Sometimes you'll try using Invoke-WebRequest, and you swear the data should be there, yet for some reason you just can't seem to parse it out.

The reasons for this can vary. Typically, it is because the page renders something with a script, and you can only gather it with a browser being opened/controlled. If you're having a hard time parsing the data from Invoke-WebRequest, controlling IE can be a time saving solution. This can come in handy if you need to script up something quick, and then work on a long term solution after you know what's possible.

COM Object Instantiation

The first thing we will need to do is instantiate an instance of the Internet Explorer COM object. To do this, we'll use New-Object.

$ieObject=New-Object-ComObject'InternetExplorer.Application'

$ieObject will store the object and contains the properties and methods we can work with. Let's take a peek:

$ieObject|Get-Member

We will be using a few of these properties and methods as we work with controlling IE.

By default, the instance of Internet Explorer is not visible. For automation, that's great!For demoing and testing, not so much!

To set the username, we can change the value property to our username. I have a credential object which contains my username, and I will use that to set it. You can pass along any string value, but I highly recommend using a credential object, or other secure method (more for the password, but it at also omits even showing the raw string for your username in the code).

$userNameBox.value=$myCredentials.UserName

You can see that this is set on the website now!

Password

Now to set the password. To do this we'll use the same logic as we did for the username, but specify the name password.

The password object will take a string for the password (just as the username object did). I highly recommend using a credential object, and then using the GetNetworkCredential method, and then the Password property.

Set the $currentDocument variable to $ieObject.Document as the value is now different since a new page has been loaded.

Parse out information we need by exploring the webpage.

Automate looking for a specific post

It really all depends on what you want to do.

One thing I have done is wrote up a script for a project where I had to rename user IDs. I had a list of old IDs and new IDs, and would iterate through all of them (sometimes 300+), until they were all renamed. All with logging and results output for later review and verification. Once you have the basics down you can really expand into the realm of full automation, and reap the benefits of what PowerShell can do.

!!**Note**!!

After each time you use the Navigate method, or click a button, you'll want to wait for the object's status to not be busy.

You can store it in the same script, or as part of a module. You can invoke this function whenever you need to now, and keep your code looking clean.

It's also good to always refresh your $currentDocument variable after a button click / page load.

Here's some code for an example of when/how to use it:

#Set the URL we want to navigate to$webURL='http://www.tukui.org/forums/bb-login.php'#Create / store object invoked in a variable$ieObject=New-Object-ComObject'InternetExplorer.Application'#By default it will not be visible, and this is likely how you'd want it set in your scripts#But for demo purposes, lets set it to visible$ieObject.Visible=$true#Take a look at the object$ieObject|Get-Member#Navigate to the URL we set earlier$ieObject.Navigate($webURL)#Wait for the page to load$ieObject|Invoke-IEWait#Store current document in variable$currentDocument=$ieObject.Document

functionInvoke-IEWait{#Begin function Invoke-IEWait[cmdletbinding()]Param([Parameter(Mandatory,ValueFromPipeLine )]$ieObject)While($ieObject.Busy){Start-Sleep-Milliseconds10}}#End function Invoke-IEWaitfunctionInvoke-SiteLogon{#Begin function Invoke-SiteLogon[cmdletbinding()]param()#Set the URL we want to navigate to$webURL='http://www.tukui.org/forums/bb-login.php'#Create / store object invoked in a variable$ieObject=New-Object-ComObject'InternetExplorer.Application'#By default it will not be visible, and this is likely how you'd want it set in your scripts#But for demo purposes, let's set it to visible$ieObject.Visible=$true#Navigate to the URL we set earlier$ieObject.Navigate($webURL)#Wait for the page to load$ieObject|Invoke-IEWait#Store current document in a variable$currentDocument=$ieObject.Document#Username field$userNameBox=$currentDocument.IHTMLDocument3_getElementsByTagName('input')|Where-Object{$_.name-eq'user_login'}#Fill out username value$userNameBox.value=$myCredentials.UserName#Password field$passwordBox=$currentDocument.IHTMLDocument3_getElementsByTagName('input')|Where-Object{$_.name-eq'password'}#Fill out password value$passwordBox.value=$myCredentials.GetNetworkCredential().Password#Submit button$submitButton=$currentDocument.IHTMLDocument3_getElementsByTagName('input')|Where-Object{$_.type-eq'submit'}#Invoke click method on submit button$submitButton.click()#Wait for the page to load$ieObject|Invoke-IEWait#Return the object so we can work with it further in the scriptReturn$ieObject}#End function Invoke-SiteLogonfunctionInvoke-IECleanUp{#Begin function Invoke-IECleanUp[cmdletbinding()]param([Parameter(Mandatory,ValueFromPipeLine )]$ieObject)#Store logout URL$logoutURL=$currentDocument.links|Where-Object{$_.outerText-eq'log out'}|Select-Object-ExpandPropertyhref-First1#Use logout URL to logout via the Navigate method$ieObject.Navigate($logoutURL)#Wait for logout$ieObject|Invoke-IEWait#Clean up IE Object$ieObject.Quit()#Release COM Object[void][Runtime.Interopservices.Marshal]::ReleaseComObject($ieObject)}#End function Invoke-IECleanUp#Get credentials$myCredentials=Get-Credential#Login to site$ieObject=Invoke-SiteLogon#Wait in case it is still busy$ieObject|Invoke-IEWait#Set the current document variable $currentDocument=$ieObject.Document#Get all elements that may have text values in a table, and store in a text file$currentDocument.IHTMLDocument3_getElementsByTagName('td')|Select-ObjectOuterText|Out-File'.\siteStuff.txt'#Get all links and select name/href, and store in a text file$currentDocument.links|Select-ObjectouterText,href|Out-File'.\links.txt'#Log out / clean up IE Object now that we're done$ieObject|Invoke-IECleanUp

Continuing With Invoke-RestMethod

In this post, I will be going over some more ways to use Invoke-RestMethod to authenticate to different APIs. I will also go over how to send information to the API, and work with the results we get back.

API Key In Header

Some APIs will require you to authenticate with a key in the header information. I happen to own a LIFX light, and their API uses that method of authentication.

The information here is provided to show you what you can do, how you can build a payload, and then work with the data you are returned. You can do anything you put your mind to, from silly things (the next example), to even using it with another script (that may get weather information), and then flash the light red a few times if there is a weather alert.

For this next example, I will utilize a loop, and our new function, to confuse my girlfriend. Well, with all the testing I've been doing... maybe not anymore ;)

Username/Password in Request URL (PRTG API)

Some APIs authenticate you via including a username/password (hopefully eventually password hash) in the request URL. PRTG has one of those APIs. PRTG is one of my favorite monitoring tools, as not only is it great out of the box, but it also has great synergy with PowerShell.

Let's start by getting the credentials of the account you want to use with the API. This account will be an account that has access to do what you need to do in PRTG. I will use the PRTG admin account, but you'll want to ensure you use one setup just to use with the API.

$prtgCredential=Get-Credential

If you're demoing PRTG, and are using a self-signed cert, you'll need the following code to allow Invoke-RestMethod to work with the self-signed cert. This code will only affect your current session.

Alright, got it! Now we can create a new PS Credential object to store our username, and password hash (instead of our password).

First. we'll convert the hash we created into a secure string. Then, we will create a new PS Credential object using the username we specified in $prtgCredential, and the secure string we just created as the password.

Note: The full URL that is constructed is (I cut out my actual password hash on purpose, you'll see your hash after the = if all went well):

If all went well, you will see the results of the request (if not, you'll see an unauthorized message).

Success!

Sending Data via URL (PRTG API)

The PRTG API accepts data in the URL of the request. The below example will pause a sensor for a specific duration, with a specific message:

#Set the $duration (in minutes)$duration=10#Set $sensorID to the sensor we want to pause$sensorID=45#Set the pause message$message="Paused via PS PRTG"#Construct the $pauseURL$pauseURL="$($prtgInfo.baseURL)/pauseobjectfor.htm?id=$sensorID&pausemsg=$message&duration=$duration"#Use Invoke-RestMethod with the $pauseURL + $credentialAppend#We will always append $credentialAppend as this is how the API accepts authenticationInvoke-RestMethod-Uri($pauseURL+$credentialAppend)

Awesome, it worked!

Mega Example With Concept Code

This last example contains some concept code for a project I'm working on. Feel free to judge and use it as you wish, however I will note now that it is nowhere near finalized. I'm still in the exploration, see what's possible, and try to get it all to work phase.

Requirements for concept code to work:

PRTG Installaction

Folder structure

Module

NinjaLogging.psm1 (included in ZIP file below)

NOTE: I have not fully cleaned up or officially released any of the code yet. That includes the logging module.

Here is my TODO list for the code:

Upload it to GitHub

Clean up/add error handling

Create readme files

Add parameter sets to some of the psprtg.ps1 functions

Fully convert psprtg.ps1 to a module

Add functionality to psprtg.ps1 to include an 'oh crap!' undo feature

This will include exporting a custom object with the device ID and action taken

The first time you do this, it will ask for your PRTG credentials. The module will then export the credential object to the input folder, and then use that exported credential the next time the module is used

Code

psprtg.ps1

#Setup$scriptPath=Split-Path-Parent-Path$MyInvocation.MyCommand.Definition$moduleDir="$scriptPath\modules"$logDir="$scriptPath\logs"$outputDir="$scriptPath\output"$inputDir="$scriptPath\input"$hashFile="$inputDir\prtgCredentialwHash.xml"$hostName='localhost'$modules=('ninjaLogging')$prtgInfo=[PSCustomObject]@{BaseURL="https://$hostName/api"SensorCountURL="https://$hostName/api/table.xml?content=sensors&columns=sensor"}functionInvoke-ModuleImport{#Begin function Invoke-ModuleImport[cmdletbinding()]param($modules)Push-LocationForEach($modulein$modules){if(Get-Module-ListAvailable-Name$module){Import-Module$module}elseif(!(Get-Module-Name$module)){Write-Verbose"Importing module: [$module] from: [$moduleDir\$module]"Import-Module"$moduleDir\$module"}Else{Write-Verbose"Module [$module] already loaded!"}}Pop-Location}#End function Invoke-ModuleImportfunctionInvoke-CredentialCheck{#Begin function Invoke-CredentialCheck[cmdletbinding()]Param()Try{#Begin try for credential path existence#Check if our hashed credential object existsif(Test-Path$hashFile){#Begin if for testing credential path existence #Import credentials from hash fileWrite-LogFile-logPath$logFile-logValue"Importing credentials from [$hashFile]"-Verbose$prtgCredentialwHash=Import-Clixml$hashFile#Add to info hash$prtgInfo|Add-Member-TypeNoteProperty-NameCredentials-Value$prtgCredentialwHash#create credential string for URLs$prtgUser=$prtgCredentialwHash.UserName$prtgPass=$prtgCredentialwHash.GetNetworkCredential().Password$credentialAppend="&username=$prtgUser&passhash=$prtgPass"Return$credentialAppend#If it doesn't exist, attempt to create it}else{Write-Host"We need to get your hashed password to use with the API!"`nWrite-Host"Please enter your PRTG credentials"`n#Store credential in $prtgCredential$prtgCredential=Get-Credential#Get the password hash via ConvertTo-SecureString and the Get-PRTGPasswordHash function.#Note: If you are not using a self-signed certificate (hopefully not, but if it is a demo/test install you may be): use -selfSignedCert:$True$passwordHash=ConvertTo-SecureString"$(Get-PRTGPasswordHash-PRTGCredential$prtgCredential-selfSignedCert:$True)"-AsPlainText-Force#Create new credential object and export it to the input folder#This allows us to use it when the script runs$prtgCredentialwHash=New-ObjectSystem.Management.Automation.PSCredential($($prtgCredential.UserName),$passwordHash)Write-LogFile-logPath$logFile-logValue"Exporting hashed credentials to [$hashFile]."-Verbose#Export to file$prtgCredentialwHash|Export-Clixml$hashFile#create credential string for URLs$prtgUser=$prtgCredentialwHash.UserName$prtgPass=$prtgCredentialwHash.GetNetworkCredential().Password$credentialAppend="&username=$prtgUser&passhash=$prtgPass"Return$credentialAppend}#End if for testing credential path existence}#End try for credential path existenceCatch{$errorMessage=$_.Exception.MessageWrite-LogFileError-logPath$logFile-errorDesc"Error while checking for credentials/importing credentials: [$errorMessage]!"#Resolve the log fileResolve-LogFile-logPath$logFileBreak}}#End function Invoke-CredentialCheckfunctionGet-PRTGPasswordHash{#Begin function Get-PRTGPasswordHash[cmdletbinding()]param([Parameter(Mandatory)][PSCredential]$PRTGCredential,[Parameter()][Boolean]$selfSignedCert=$false)if($selfSignedCert){Try{Add-Type@" using System.Net; using System.Security.Cryptography.X509Certificates; public class TemporarySelfSignedCert : ICertificatePolicy { public TemporarySelfSignedCert() {} public bool CheckValidationResult( ServicePoint sPoint, X509Certificate cert, WebRequest wRequest, int certProb) { return true; } }"@[System.Net.ServicePointManager]::CertificatePolicy=New-ObjectTemporarySelfSignedCert}Catch{$errorMessage=$_.Exception.MessageWrite-LogFileError-logPath$logFile-errorDesc"Error while allowing self signed certs: [$errorMessage]"-ForegroundColorRed-BackgroundColorDarkBlue#Resolve the log fileResolve-LogFile-logPath$logFileBreak}}Try{$getHashURL="$($prtgInfo.BaseURL)/getpasshash.htm?username=$($prtgCredential.userName)&password=$($prtgCredential.GetNetworkCredential().Password)"$getHash=Invoke-RestMethod-Uri$getHashURLReturn$getHash}Catch{$errorMessage=$_.Exception.MessageWrite-LogFileError-logPath$logFile-errorDesc"Error while getting hash: [$errorMessage]"-VerboseBreak}}#End function Get-PRTGPasswordHashfunctionInvoke-DeviceSearch{#Begin function Invoke-DeviceSearch[cmdletbinding()]Param([Parameter(Mandatory)]$findMe)[System.Collections.ArrayList]$foundDeviceIDs=@()ForEach($findin$findMe){Try{$getDeviceURL="$($prtgInfo.BaseURL)/table.json?content=devices&output=json&columns=objid,probe,group,device,host,downsens,partialdownsens,downacksens,upsens,warnsens,pausedsens,unusualsens,undefinedsens"$devices=Invoke-RestMethod-Uri($getDeviceURL+$credentialAppend)$findDeviceWildCard='*'+$find+'*'ForEach($devicein$devices.devices){Switch($device.device){{$_-like$findDeviceWildCard}{$foundDeviceIDs.Add($device.objid)|Out-Null}}}}Catch{$errorMessage=$_.Exception.MessageWrite-LogFileError-logPath$logFile-errorDesc"Unable to get device list: [$errorMessage]"-Verbose}}Return$foundDeviceIDs}#End function Invoke-DeviceSearchfunctionInvoke-SensorModification{#Begin function Invoke-SensorModification[cmdletbinding()]Param([parameter(Mandatory)][string]$action,[parameter()][string]$findDevice,[Parameter()][int]$sensorID,[Parameter()][String]$message,[Parameter()][int]$duration,[Parameter()]$List)$logFile=New-logFile-logPath$logDir-logName'PSPRTG.log'-addDate:$trueif(!$duration){$duration=30}if(!$message){$message="Sensor paused by: [$((Get-ChildItemEnv:\USERNAME).Value)] via PS PRTG."}if($List){Switch($List|Get-Member|Select-Object-ExpandPropertyTypeName-Unique){{$_-eq'System.String'}{if($list-like'*.txt'){Write-Host"File detected"$deviceList=Get-Content$list$foundDeviceIDs=Invoke-DeviceSearch-findMe$deviceList}else{Write-Host'Single string or array detected'$deviceList=$list$foundDeviceIDs=Invoke-DeviceSearch-findMe$deviceList}}{$_-eq'System.Management.Automation.PSCustomObject'}{Write-Host'Custom Object detected'}}}if($findDevice){Try{$foundDeviceIDs=Invoke-DeviceSearch-findMe$findDevice}Catch{$errorMessage=$_.Exception.MessageWrite-LogFileError-logPath$logFile-errorDesc"Unable to get device list: [$errorMessage]"-Verbose#Resolve the log fileResolve-LogFile-logPath$logFileBreak}}Switch($action){#Begin switch for PRTG action{$_-eq'Pause'}{if($findDevice-or$List){ForEach($idin$foundDeviceIDs){$sensorID=$null$sensorID=$id$pauseURL="$($prtgInfo.baseURL)/pauseobjectfor.htm?id=$sensorID&pausemsg=$message&duration=$duration"Write-LogFile-logPath$logFile-Value"Attempting to pause sensor ID: [$sensorID], for duration of [$duration], with message [$message]"-Verbose$pauseAttempt=Invoke-WebRequest-Uri($pauseURL+$credentialAppend)}#Resolve the log fileResolve-LogFile-logPath$logFile}Else{$pauseURL="$($prtgInfo.baseURL)/pauseobjectfor.htm?id=$sensorID&pausemsg=$message&duration=$duration"Write-LogFile-logPath$logFile-Value"Attempting to pause sensor ID: [$sensorID], for duration of [$duration], with message [$message]"-Verbose$pauseAttempt=Invoke-WebRequest-Uri($pauseURL+$credentialAppend)#Resolve the log fileResolve-LogFile-logPath$logFile}}{$_-eq'Resume'}{if($findDevice-or$List){ForEach($idin$foundDeviceIDs){$sensorID=$null$sensorID=$id$resumeURL="$($prtgInfo.baseURL)/pause.htm?id=$sensorID&action=1"Write-LogFile-logPath$logFile-Value"Attempting to resume sensor ID: [$sensorID]."-Verbose$resumeAttempt=Invoke-WebRequest-Uri($resumeURL+$credentialAppend)}#Resolve the log fileResolve-LogFile-logPath$logFile}Else{$resumeURL="$($prtgInfo.baseURL)/pause.htm?id=$sensorID&action=1"Write-LogFile-logPath$logFile-Value"Attempting to resume sensor ID: [$sensorID]."-Verbose$resumeAttempt=Invoke-WebRequest-Uri($resumeURL+$credentialAppend)#Resolve the log fileResolve-LogFile-logPath$logFile}}Default{Write-LogFileError-logPath$logFile-errorDesc"Unable to perform action [$action], as it does not match any valid actions!"-VerboseResolve-LogFile-logPath$logFileBreak}}#End switch for PRTG action}#End function Invoke-SensorModification#Test APITry{#Import modules ForEach($modulein$modules){Invoke-ModuleImport-modules$module}#Create log file$logFile=New-logFile-logPath$logDir-logName'PSPRTG.log'-addDate:$true#Attempt to import/set credentials and get the returned string to append to the request for authentication$credentialAppend=Invoke-CredentialCheck#Attempt to get information as a test to see if the API will workInvoke-RestMethod-uri($prtgInfo.SensorCountURL+$credentialAppend)|Out-Null#If it works, set TestPassed as true$prtgInfo|Add-Member-MemberTypeNoteProperty-NameTestPassed-Value$true}Catch{$errorMessage=$_.Exception.MessageWrite-LogFileError-logPath$logFile-errorDesc"Error while attempting to use API: [$errorMessage]"-Verbose#If it doesn't work, set TestPassed as false and break out$prtgInfo|Add-Member-MemberTypeNoteProperty-NameTestPassed-Value$false#Resolve the log fileResolve-LogFile-logPath$logFileBreak}

NinjaLogging.psm1

Set-StrictMode-VersionLatest$scriptPath=Split-Path-Parent-Path$MyInvocation.MyCommand.DefinitionSwitch($MyInvocation.PSCommandPath){{$_-match'\\\\'}{$scriptName=$_.SubString($_.LastIndexOf('\')+1)}Default{$scriptName=Get-ChildItem$MyInvocation.PSCommandPath|Select-Object-ExpandPropertyBaseName}}if('Count'-in($scriptName.psobject.Members.Name)){$scriptName='ScriptLog'}functionNew-LogFile{<#.SYNOPSIS New-LogFile will create a log file..DESCRIPTION New-LogFile will create a log file. You can specify different paramaters to change the file's name, and where it is stored. By default it will attempt to get the name of the calling function or script via $scriptName = (Get-ChildItem $MyInvocation.PSCommandPath | Select-Object -ExpandProperty BaseName). It will also attempt to get the path via $scriptPath = Split-Path -Parent -Path $MyInvocation.MyCommand.Definition. You can also specify the path and name, as well as if you'd like to append the date in the following format: MM-dd-yy_HHmm. Use the -Verbose parameter to display what is happening to the host..PARAMETERlogPath Alias: Path Type : String Specify the path to the logfile.PARAMETERlogName Alias: Name Type : String Specify the name of the log file. Be sure to include the extension if specifying the name..PARAMETERscriptVersion Type : Double Specify the version of your script being run. If left blank, will default to 0.1.PARAMETERaddDate Type : Boolean Specify if you'd like to add the date to the file name. If you're specifying logName, you can use addDate to append the current date/time in the format: MM-dd-yy_HHmm..NOTES Name: New-LogFile Version: 1.0 Author: Ginger Ninja (Mike Roberts) DateCreated: 5/11/16.LINKhttp://www.gngrninja.com.EXAMPLE $logFile = New-LogFile ----------------------------- gngrNinja> $logFile C:\PowerShell\logs\ScriptLog_05-11-16_1612.log.EXAMPLE $logFile = New-LogFile -Verbose ----------------------------- VERBOSE: No path specified. Using: C:\PowerShell\logs VERBOSE: VERBOSE: No log name specified. Setting log name to: ScriptLog.log and adding date. VERBOSE: VERBOSE: Adding date to log file with an extension! New file name: ScriptLog_05-11-16_1613.log VERBOSE: VERBOSE: Created C:\PowerShell\logs\ScriptLog_05-11-16_1613.log VERBOSE: VERBOSE: File C:\PowerShell\logs\ScriptLog_05-11-16_1613.log created and verified to exist. VERBOSE: VERBOSE: Adding the following information to: C:\PowerShell\logs\ScriptLog_05-11-16_1613.log VERBOSE: VERBOSE: ----------------------------------------------------------------- VERBOSE: Started logging at [05/11/2016 16:13:11] VERBOSE: Script (Version 0.1) executed by: [thegn] on computer: [GINJA10] VERBOSE: ----------------------------------------------------------------- VERBOSE: gngrNinja>.EXAMPLE $logfile = New-LogFile -Name 'testName.log' -path 'c:\temp' -addDate $true -Verbose ----------------------------- VERBOSE: Adding date to log file with an extension! New file name: testName_05-11-16_1615.log VERBOSE: VERBOSE: Created c:\temp\testName_05-11-16_1615.log VERBOSE: VERBOSE: File C:\temp\testName_05-11-16_1615.log created and verified to exist. VERBOSE: VERBOSE: Adding the following information to: C:\temp\testName_05-11-16_1615.log VERBOSE: VERBOSE: ----------------------------------------------------------------- VERBOSE: Started logging at [05/11/2016 16:15:13] VERBOSE: Script (Version 0.1) executed by: [thegn] on computer: [GINJA10] VERBOSE: ----------------------------------------------------------------- VERBOSE:.OUTPUTS Full path to the log file created.#>[cmdletbinding()]param([Parameter(Mandatory=$false,Position=0)][Alias('Path')][string]$logPath,[Parameter(Mandatory=$false,Position=1)][Alias('Name')][string]$logName,[Parameter(Mandatory=$false,Position=2)][double]$scriptVersion=0.1,[Parameter(Mandatory=$false,Position=3)][boolean]$addDate=$false)#Check if file/path are setif(!$logPath){$logPath="$scriptPath\logs"Write-Verbose"No path specified. Using: $logPath"Write-Verbose""}if(!$logName){$logName=$scriptName+'.log'$addDate=$trueWrite-Verbose"No log name specified. Setting log name to: $logName and adding date."Write-Verbose""}#Check if $addDate is $true, take action if soif($addDate){if($logName.Contains('.')){$logName=$logName.SubString(0,$logName.LastIndexOf('.'))+"_{0:MM-dd-yy_HHmm}"-f(Get-Date)+$logName.Substring($logName.LastIndexOf('.'))Write-Verbose"Adding date to log file with an extension! New file name: $logName"Write-Verbose""}else{$logName=$logName+"_{0:MM-dd-yy_HHmm}"-f(Get-Date)Write-Verbose"Adding date to log file. New file name: $logName"Write-Verbose""}}#Variable set up$time=Get-Date$fullPath=$logPath+'\'+$logName$curUser=(Get-ChildItemEnv:\USERNAME).Value$curComp=(Get-ChildItemEnv:\COMPUTERNAME).Value#Checking paths / Creating directory if neededif(!(Test-Path$logPath)){Try{New-Item-Path$logPath-ItemTypeDirectory-ErrorActionStop|Out-NullWrite-Verbose"Folder $logPath created as it did not exist."Write-Verbose""}Catch{$message=$_.Exception.MessageWrite-Output"Could not create folder due to an error. Aborting. (See error details below)"Write-Error$messageBreak}}#Checking to see if a file with the name name exists, renaming it if so.if(Test-Path$fullPath){Try{$renFileName=($fullPath+(Get-Random-Minimum($time.Second)-Maximum999)+'old')Rename-Item$fullPath-NewName($renFileName.Substring($renFileName.LastIndexOf('\')+1))-Force-ErrorActionStop|Out-NullWrite-Verbose"Renamed $fullPath to $($renFileName.Substring($renFileName.LastIndexOf('\')+1))"Write-Verbose""}Catch{$message=$_.Excetion.MessageWrite-Output"Could not rename existing file due to an error. Aborting. (See error details below)"Write-Error$messageBreak}}#File creationTry{New-Item-Path$fullPath-ItemTypeFile-ErrorActionStop|Out-NullWrite-Verbose"Created $fullPath"Write-Verbose""}Catch{$message=$_.Exception.MessageWrite-Output"Could not create directory due to an error. Aborting. (See error details below)"Write-Error$messageBreak}#Get the full path in case of dot sourcing$fullPath=(Get-ChildItem$fullPath).FullNameif(Test-Path$fullPath){$flairLength=("Script (Version $scriptVersion) executed by: [$curUser] on computer: [$curComp]").Length+1Write-Verbose"File $fullPath created and verified to exist."Write-Verbose""Write-Verbose"Adding the following information to: $fullPath"Write-Verbose""Write-Verbose('-'*$flairLength)Write-Verbose"Started logging at [$time]"Write-Verbose"Script (Version $scriptVersion) executed by: [$curUser] on computer: [$curComp]"Write-Verbose('-'*$flairLength)Write-Verbose""Add-Content-Path$fullPath-Value('-'*$flairLength)Add-Content-Path$fullPath-Value"Started logging at [$time]"Add-Content-Path$fullPath-Value"Script (Version $scriptVersion) executed by: [$curUser] on computer: [$curComp]"Add-Content-Path$fullPath-Value('-'*$flairLength)Add-Content-Path$fullPath-Value""Return[string]$fullPath}else{Write-Error"File $fullPath does not exist. Aborting script."Break}}functionWrite-LogFile{<#.SYNOPSIS Write-LogFile will add information to a log file created with New-LogFile..DESCRIPTION Write-LogFile will add information to a log file created with New-LogFile. By default additions to the log file will include a timestamp, unless you specify -addTimeStamp $false. This function accepts values from the pipeline, as demonstrated in an example. Use the -Verbose parameter to display what is being logged to the host..PARAMETERlogPath Alias: Path Type : String Specify the full path to the log file, including the name..PARAMETERlogValue Alias: Value Type : String Specify the value(s) you'd like logged..PARAMETERaddTimeStamp Type : Boolean Defaults to true, set to false if you'd like to omit the timestamp..NOTES Name: Write-LogFile Version: 1.0 Author: Ginger Ninja (Mike Roberts) DateCreated: 5/11/16.LINKhttp://www.gngrninja.com.EXAMPLE For this example we'll assume you use: $logFile = New-LogFile Write-LogFile -logPath $logFile -logValue 'test log value!' ----------------------------- gngrNinja> more $logfile ----------------------------------------------------------------- Started logging at [05/11/2016 16:19:37] Script (Version 0.1) executed by: [thegn] on computer: [GINJA10] ----------------------------------------------------------------- [05-11-16 16:23:24] test log value!.EXAMPLE For this example we'll assume you use: $logFile = New-LogFile Write-LogFile -logPath $logFile -logValue 'test log value!' -Verbose ----------------------------- VERBOSE: Adding [05-11-16 16:25:19] test log value! to C:\PowerShell\logs\ScriptLog_05-11-16_1619.log VERBOSE:.EXAMPLE For this example we'll assume you use: $logFile = New-LogFile Get-Process | Write-LogFile $logFile -Verbose ----------------------------- ... VERBOSE: Adding [05-11-16 16:26:51] System.Diagnostics.Process (wininit) to C:\PowerShell\logs\ScriptLog_05-11-16_1619.log VERBOSE: VERBOSE: Adding [05-11-16 16:26:51] System.Diagnostics.Process (winlogon) to C:\PowerShell\logs\ScriptLog_05-11-16_1619.log VERBOSE: VERBOSE: Adding [05-11-16 16:26:51] System.Diagnostics.Process (WmiPrvSE) to C:\PowerShell\logs\ScriptLog_05-11-16_1619.log VERBOSE: VERBOSE: Adding [05-11-16 16:26:51] System.Diagnostics.Process (WmiPrvSE) to C:\PowerShell\logs\ScriptLog_05-11-16_1619.log VERBOSE: VERBOSE: Adding [05-11-16 16:26:51] System.Diagnostics.Process (WUDFHost) to C:\PowerShell\logs\ScriptLog_05-11-16_1619.log VERBOSE: ....EXAMPLE For this example we'll assume you use: $logFile = New-LogFile Write-LogFile -logPath $logFile -logValue 'test without timestamp' -addTimeStamp $false -Verbose ----------------------------- VERBOSE: Adding test without timestamp to C:\PowerShell\logs\ScriptLog_05-11-16_1631.log VERBOSE:#>[cmdletbinding()]param([Parameter(Mandatory=$true,Position=0)][Alias('Path')][string]$logPath,[Parameter(Mandatory=$true,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true,Position=1)][Alias('Value')][string]$logValue,[Parameter(Mandatory=$false,Position=2)][boolean]$addTimeStamp=$true)Begin{if(!(Test-Path$logPath)){Write-Error"Unable to access $logPath"Break}}Process{ForEach($valuein$logValue){$timeStamp="[{0,0:MM}-{0,0:dd}-{0,0:yy} {0,0:HH}:{0,0:mm}:{0,0:ss}]"-f(Get-Date)if($addTimeStamp){$value="$($timeStamp+''+$value)"}Write-Verbose"Adding $value to $logPath"Write-Verbose""Add-Content-Path$logPath-Value$valueAdd-Content-Path$logPath-Value''}}}functionWrite-LogFileError{<#.SYNOPSIS Write-LogFileError will add information to a log file created with New-LogFile. The information will be prepended with [ERROR]..DESCRIPTION Write-LogFileError will add information to a log file created with New-LogFile. The information will be prepended with [ERROR]. By default additions to the log file will include a timestamp, unless you specify -addTimeStamp $false. This function accepts values from the pipeline, as demonstrated in an example. Use the -Verbose parameter to display what is being logged to the host..PARAMETERlogPath Alias: Path Type : String Specify the full path to the log file, including the name..PARAMETERerrorDesc Alias: Value Type : String Specify the value(s) you'd like logged as errors..PARAMETERaddTimeStamp Type : Boolean Defaults to true, set to false if you'd like to omit the timestamp..PARAMETERexitScript Alias: Exit Type : Boolean This parameter let's you specify $true if you'd like to exit the script after the error is logged. It defaults to $false..NOTES Name: Write-LogFileError Version: 1.0 Author: Ginger Ninja (Mike Roberts) DateCreated: 5/11/16.LINKhttp://www.gngrninja.com.EXAMPLE For this example we'll assume you use: $logFile = New-LogFile Write-LogFileError -logPath $logFile -errorDesc 'test log value error!' ----------------------------- gngrNinja> more $logFile ----------------------------------------------------------------- Started logging at [05/11/2016 16:31:44] Script (Version 0.1) executed by: [thegn] on computer: [GINJA10] ----------------------------------------------------------------- [05-11-16 16:31:51] [ERROR ENCOUNTERED]: test log value error!#>[CmdletBinding()]param([Parameter(Mandatory=$true,Position=0)][Alias('Path')][string]$logPath,[Parameter(Mandatory=$true,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true,Position=1)][string]$errorDesc,[Parameter(Mandatory=$false,Position=2)][boolean]$addTimeStamp=$true,[Parameter(Mandatory=$false,Position=3)][Alias('Exit')][boolean]$exitScript=$false)Begin{if(!(Test-Path$logPath)){Write-Error"Unable to access $logPath"Break}}Process{ForEach($valuein$errorDesc){$timeStamp="[{0,0:MM}-{0,0:dd}-{0,0:yy} {0,0:HH}:{0,0:mm}:{0,0:ss}]"-f(Get-Date)$value="[ERROR ENCOUNTERED]: $value"if($addTimeStamp){$value="$($timeStamp+''+$value)"}Write-Verbose"Adding $value to $logPath"Write-Verbose""Add-Content-Path$logPath-Value$valueAdd-Content-Path$logPath-Value''}}End{if($exitScript){Write-Verbose"Performing log file close command: Resolve-LogFile -logPath $logPath -exitonCompletion $true"Write-Verbose""Resolve-LogFile-logPath$logPath-exitScript$true}}}functionWrite-LogFileWarning{<#.SYNOPSIS Write-LogFileWarning will add information to a log file created with New-LogFile. The information will be prepended with [ERROR]..DESCRIPTION Write-LogFileWarning will add information to a log file created with New-LogFile. The information will be prepended with [ERROR]. By default additions to the log file will include a timestamp, unless you specify -addTimeStamp $false. This function accepts values from the pipeline, as demonstrated in an example. Use the -Verbose parameter to display what is being logged to the host..PARAMETERlogPath Alias: Path Type : String Specify the full path to the log file, including the name..PARAMETERwarningDesc Alias: Value Type : String Specify the value(s) you'd like logged as errors..PARAMETERaddTimeStamp Type : Boolean Defaults to true, set to false if you'd like to omit the timestamp..PARAMETERexitScript Alias: Exit Type : Boolean This parameter let's you specify $true if you'd like to exit the script after the error is logged. It defaults to $false..NOTES Name: Write-LogFileWarning Version: 1.0 Author: Ginger Ninja (Mike Roberts) DateCreated: 5/11/16.LINKhttp://www.gngrninja.com.EXAMPLE For this example we'll assume you use: $logFile = New-LogFile Write-LogFileWarning -logPath $logFile -warningDesc 'test log value warning!' ----------------------------- gngrNinja> more $logFile ----------------------------------------------------------------- Started logging at [05/11/2016 16:38:29] Script (Version 0.1) executed by: [thegn] on computer: [GINJA10] ----------------------------------------------------------------- [05-11-16 16:38:48] [WARNING]: test log value warning!#>[CmdletBinding()]param([Parameter(Mandatory=$true,Position=0)][Alias('Path')][string]$logPath,[Parameter(Mandatory=$true,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true,Position=1)][string]$warningDesc,[Parameter(Mandatory=$false,Position=2)][boolean]$addTimeStamp=$true,[Parameter(Mandatory=$false,Position=3)][Alias('Exit')][boolean]$exitScript=$false)Begin{if(!(Test-Path$logPath)){Write-Error"Unable to access $logPath"Break}}Process{ForEach($valuein$warningDesc){$timeStamp="[{0,0:MM}-{0,0:dd}-{0,0:yy} {0,0:HH}:{0,0:mm}:{0,0:ss}]"-f(Get-Date)$value="[WARNING]: $value"if($addTimeStamp){$value="$($timeStamp+''+$value)"}Write-Verbose"Adding $value to $logPath"Write-Verbose""Add-Content-Path$logPath-Value$valueAdd-Content-Path$logPath-Value''}}End{if($exitScript){Write-Verbose"Performing log file close command: Resolve-LogFile -logPath $logPath -exitonCompletion $true"Write-Verbose""Resolve-LogFile-logPath$logPath-exitScript$true}}}functionResolve-LogFile{<#.SYNOPSIS Resolve-LogFile will resolve a created log file..DESCRIPTION Resolve-LogFile will resolve a created log file. Use the -Verbose parameter to display what is happening to the host..PARAMETERlogPath Alias: Path Type : String Specify the full path, including name, to the log file to be resolved..PARAMETERlogName Alias: Name Type : String Specify the name of the log file. Be sure to include the extension if specifying the name..PARAMETERexitScript Alias: Exit Type : Boolean Specify $true if you'd like to exit the script after the log file is resolved. It defaults to $false..NOTES Name: Resolve-LogFile Version: 1.0 Author: Ginger Ninja (Mike Roberts) DateCreated: 5/11/16.LINKhttp://www.gngrninja.com.EXAMPLE $logFile = New-LogFile ----------------------------- gngrNinja> $logFile C:\PowerShell\logs\ScriptLog_05-11-16_1612.log.EXAMPLE $logFile = New-LogFile Get-Process | Write-LogFile $logFile Resolve-LogFile $logFile ----------------------------- ... [05-11-16 16:43:58] System.Diagnostics.Process (wininit) [05-11-16 16:43:58] System.Diagnostics.Process (winlogon) [05-11-16 16:43:58] System.Diagnostics.Process (WmiPrvSE) [05-11-16 16:43:58] System.Diagnostics.Process (WmiPrvSE) [05-11-16 16:43:58] System.Diagnostics.Process (WUDFHost) --------------------------------------------- Ended logging at [05/11/2016 16:44:01] ---------------------------------------------#>[cmdletbinding()]param([parameter(Mandatory=$true,Position=0)][Alias('Path')][string]$logPath,[Parameter(Mandatory=$false,Position=1)][Alias('Exit')][boolean]$exitScript=$false)$time=Get-Dateif(Test-Path$logPath){$flairLength=("Finished processing at [$time]").Length+1Write-Verbose"Adding the following content to: $logPath"Write-Verbose('-'*$flairLength)Write-Verbose"Ended logging at [$time]"Write-Verbose('-'*$flairLength)Write-Verbose""Add-Content-Path$logPath-Value('-'*$flairLength)Add-Content-Path$logPath-Value"Ended logging at [$time]"Add-Content-Path$logPath-Value('-'*$flairLength)}else{Write-Error"Unable to access $logPath"Break}if($exitScript){Write-Verbose"Exiting on completion specified, exiting..."Exit}}functionOut-LogFile{<#.SYNOPSIS Out-LogFile will create, add to, and resolve a logfile..DESCRIPTION Out-LogFile will create, add to, and resolve a logfile. Value from the pipeline is accepted. Use the -Verbose parameter to display what is happening to the host..PARAMETERlogPath Alias: Path Type : String Specify the path to the logFile you'd like created..PARAMETERlogName Alias: Name Type : String Specify the name of the log file. Be sure to include the extension if specifying the name..PARAMETERlogValue Alias: Value Type : String Specify the value(s) you'd like logged..PARAMETERaddTimeStamp Type : Boolean Defaults to true, set to false if you'd like to omit the timestamp..NOTES Name: Out-LogFile Version: 1.0 Author: Ginger Ninja (Mike Roberts) DateCreated: 5/11/16.LINKhttp://www.gngrninja.com.EXAMPLE $outLog = Get-Process | Out-LogFile -logPath c:\temp -logName 'outlog.log' -Verbose ----------------------------- VERBOSE: Created c:\temp\outlog.log VERBOSE: VERBOSE: File C:\temp\outlog.log created and verified to exist. VERBOSE: VERBOSE: Adding the following information to: C:\temp\outlog.log VERBOSE: VERBOSE: ----------------------------------------------------------------- VERBOSE: Started logging at [05/11/2016 16:58:43] VERBOSE: Script (Version 0.1) executed by: [thegn] on computer: [GINJA10] VERBOSE: ----------------------------------------------------------------- VERBOSE: VERBOSE: Adding [05-11-16 16:58:43] System.Diagnostics.Process (AdobeUpdateService) to C:\temp\outlog.log VERBOSE:.EXAMPLE $outLog = Get-Process | Out-LogFile -Verbose ----------------------------- gngrNinja> more $outLog ... [05-11-16 16:56:02] System.Diagnostics.Process (winlogon) [05-11-16 16:56:02] System.Diagnostics.Process (WmiPrvSE) [05-11-16 16:56:02] System.Diagnostics.Process (WmiPrvSE) [05-11-16 16:56:02] System.Diagnostics.Process (WUDFHost) --------------------------------------------- Ended logging at [05/11/2016 16:56:02] ---------------------------------------------gngrNinja>#>[cmdletbinding()]param([Parameter(Mandatory=$false,Position=0)][Alias('Path')][string]$logPath,[Parameter(Mandatory=$false,Position=1)][Alias('Name')][string]$logName,[Parameter(Mandatory=$true,ValueFromPipeLine=$true,ValueFromPipelineByPropertyName=$true,Position=2)][Alias('Value')][string]$logValue,[Parameter(Mandatory=$false,Position=3)][boolean]$addTimeStamp=$true)Begin{$logFile=New-LogFile-logPath$logPath-logName$logName}Process{ForEach($valuein$logValue){Write-LogFile-logPath$logFile-logValue$value-addTimeStamp$addTimeStamp}}End{Resolve-LogFile$logFileReturn$logFile}}functionSend-LogEmail{[cmdletbinding()]param([Parameter(Mandatory=$true)][string]$To,[Parameter(Mandatory=$true)][string]$Subject,[Parameter(Mandatory=$true)][string]$Body,[Parameter(Mandatory=$true)][string]$emailFrom,[Parameter(Mandatory=$true)][string]$emailUser,[Parameter(Mandatory=$false)][string]$provider='gmail',[Parameter(Mandatory=$true)]$password=(Read-Host"Password?"-AsSecureString))if(!$to){Write-Error"No recipient specified";break}if(!$subject){Write-Error"No subject specified";break}if(!$body){Write-Error"No body specified";break}if(!$emailFrom){$emailFrom='Ninja_PS_Logging@gngrninja.com'}Switch($provider){{$_-eq'gmail'}{$SMTPServer="smtp.gmail.com"$SMTPPort=587}{$_-eq'custom'}{$SMTPServer='Your.SMTP.Server'$SMTPPort='Your.SMTP.Server.Port'}}$gmailCredential=New-ObjectSystem.Management.Automation.PSCredential($emailUser,$password)Send-MailMessage-To$to-From$emailFrom-Body$body-BodyAsHtml:$true-Subject$Subject-SmtpServer$smtpServer-Port$smtpPort-UseSsl-Credential$gmailCredential}

Notes:

I've spent some time on the help with the NinjaLogging.psm1 module

Feel free to browse through and use it as you wish, let me know if you have any problems!

I have not finished the email sending feature

This code will eventually find its way to Github, and be a lot more polished

Invoke-RestMethod

If Invoke-WebRequesthad a brother, it would be Invoke-RestMethod. Well… brother, cousin, sister… you get the idea! They are related. Invoke-RestMethod allows us to send requests to REST web services (Representational State Transfer), and then returns to us a lovely object to use as we need to.

If we get an RSS feed as a response, we’ll get XML as the returned data type. When working with APIs, and using Invoke-RestMethod, you’ll get a custom object back. This is because more times than not we'll get the information back as JSON (JavaScript Object Notation). PowerShell automatically converts that into the custom object, which you can then dive into the properties of.

Here are the different methods we can send:

Default

Delete

Get

Head

Merge

Options

Patch

Post

Put

Trace

Whether we’re working with an RSS feed, or an API of sorts, Invoke-RestMethod allows us to do so with ease. After learning how to use it, it has quickly become one of my favorite commands in PowerShell.

Using Invoke-RestMethod

Let's go into some examples of how to use Invoke-RestMethod. To start out, we will use it to get some information via RSS, and then dive a little deeper into APIs.

RSS Feeds

Invoke-RestMethod can be used to gather information from RSS feeds. For this example, we'll get the feed for http://www.reddit.com/r/powershell. To do that, we simply append .rss to the URL.

Here's the command:

$redditRSS=Invoke-RestMethod'http://www.reddit.com/r/PowerShell.rss'

Let's pipe our variable to Get-Member, and see what's up.

$redditRSS|Get-Member

It looks like we indeed have an XML object, and its associated methods/properties.

Let's see what the data looks like!

$redditRSS

The object we have here has a lot of properties that we can dive into.

For instance, let's say we want the author information for the first element in the array...

$redditRSS[0].author

Under the author property there are more properties that contain the information we're looking for. To access those, we need to drill down to them.

$redditRSS[0].author.name

$redditRSS[0].author.name.uri

This is part of the discovery you'll want to do when you receive an object back after using Invoke-RestMethod. Dig around, check documentation from the website if there is any, and discover all that you can. There is a lot of information returned, and the more you know, the more things you can do!

Let's dig into the content of the first array element.

$redditRSS[0].content

It looks like we want the '#text' property to see the actual returned content.

$redditRSS[0].content.'#text'

The content we received is in HTML. With some parsing we could make it more readable in the console.

Here's one way to clean it up, by stripping out the HTML tags:

$redditRSS[0].content.'#text'|ForEach-Object{$_-replace'<[^>]+>',''}

With this object ($redditRSS[0]), we can also get the title and URL to the actual post.

$redditRSS[0].title

$redditRSS[0].link.href

The link value was buried in the href property under link.

What can we do with this information? Well... whatever you need/want to! That's the awesome part about PowerShell. If you need it to do something, there's a way to do it! We could setup a script that watches for a certain post with a specific title, and then have it email you or send you a text.

You can even make a make-shift post explorer, wherein you can check out the posts and comments of a specific subreddit. I have some example code for doing that as the conclusion to this section.

Code for subreddit exploration:

functionInvoke-RedditBrowsing{#Begin function Invoke-RedditBrowsing[cmdletbinding()]Param([Parameter(Mandatory)][String]$subReddit)#Get the RSS feed for the subreddit requested$redditRSS=Invoke-RestMethod"http://www.reddit.com/r/$subReddit.rss"#This is a hashtable for the text we'll replace in order to make the comments more readable$textToReplace=[hashtable]@{'<[^>]+>'='''&quot;'='"''&#39;'="'"'&#32;'=''}#Make sure there is content before we proceedif($redditRSS){#Begin if for $redditRSS existence#Use the evil Write-Host to display information on what to do nextWrite-Host'Select a [#] from the options below!'`n-ForegroundColorBlack-BackgroundColorGreen#Set $i to 0 so our loop iterates and displays $i correctly$i=0#ForEach $post in the subReddit's feed...ForEach($postin$redditRSS){#Use evil Write-Host to display the current value of $i and the title, then the author's name and post updated dateWrite-Host"[$i] -> $($post.Title)"Write-Host`t"By: [$($post.author.name)], Updated [$($post.updated)]"`n`n#Iterate $i by 1$i++}#Write-Host (new line) to make the next line look prettierWrite-Host`n#Try / Catch to make sure we can convert the input to an integer. If not, we error outTry{#Ask for the post selection so we can proceed[int]$selection=Read-Host'Which [#]? (any invalid option quits)'-ErrorActionSilentlyContinue}Catch{#If we can't make it an int... quit!Write-Host"Invalid option, quitting!"-ForegroundColorRed-BackgroundColorDarkBlueBreak}#Switch statement for $selection. This makes sure it is in bounds for the array of posts.Switch($selection){#Begin Switch for the $selection value#if $i is less than or equal to the total number of values in the array $redditRSS...{$_-le($i-1)}{#Begin option for valid selection#Get the comments RSS feed from the link provided$redditComments=Invoke-RestMethod-Uri"$($redditRSS[$_].link.href).rss"#Write-Host the title we'll be viewing comments forWrite-Host`nWrite-Host"Title: [$($redditRSS[$_].Title)]"-ForegroundColorBlack-BackgroundColorGreen#ForEach comment in the comments feedForEach($commentin$redditComments){#Null out anything set in $commentText$commentText=$null#Set the comment text as the property which contains the actual comment$commentText=$comment.Content.'#text'#Go through each key in the hashtable we created earlierForEach($textin$textToReplace.Keys){#Re-create the $commentText variable while replace the key ($text), with the value ($textToReplace.$text)#For example: it will match '<[^>]+>', then replace it with ''$commentText=$commentText-replace($text,$textToReplace.$text)}#Use Write-Host to write out the author/comment text we cleaned upWrite-Host`n`nWrite-Host"By: [$($comment.author.name)]"-ForegroundColorBlack-BackgroundColorGreenWrite-Host`t"Comment: [$commentText]"}}#End option for valid selection#If the number does not match a value that is valid, quit!Default{Write-Host"Invalid option, quitting!"-ForegroundColorRed-BackgroundColorDarkBlueBreak}}#End Switch for the $selection value#If there is nothing in $redditRSS... quit!}else{Write-Host`n"Unable to get RSS feed for [$subReddit]."-ForegroundColorRed-BackgroundColorDarkBlue}#End if $redditRSS}#End function Invoke-RedditBrowsingInvoke-RedditBrowsing

Now to run it! It will prompt for the subreddit, which can be any subreddit. I will go with PowerShell.

The code will then run, and get the list of posts:

I will choose option [0], which is the first post.

There you have it, one example of what you can do with the data returned via Invoke-RestMethod. The primary motivation for doing something like this, at least for me, is that I like to visualize the data.

Using Write-Host is not suitable for automation, as it will not be seen by anyone. There are better options as well when you want to make specific things verbose, and others specifically for debug / errors. So why use it? Well, when visualizing the data you have available, it really can help certain bits of text stand out.

With a visual representation of what's available, it can lead to even more discovery and experimenting, which can in turn lead to more script ideas and ways to handle the data later.

Invoke-RestMethod and APIs

Invoke-RestMethod allows us to work with REST APIs with relative ease. When working with APIs, the most important thing is to familiarize yourself with their documentation. I'll link to the documentation for the different APIs I'll be using here.

Example 1: No Authentication

For this example we will use Weather Underground's Autocomplete API. This API returns a list of locations that best match the input it receives. It can be used in tandem with their full API to get the weather information.

With the above code executed, $weatherForecast will now contain a plethora of data for us to work with.

$weatherForecast|Get-Member

This is one rich object! For alerts, you can use: $weatherForecast.alerts.

You can do a lot with the information available. I wrote a script that uses it to email a forecast (just the way we want it) to my significant other and myself. I took a parts of the script, and heavily modified them to fit this example. In the following example we will:

Setup the API information needed

Set a limit on use during the script's execution to 10

Declare 3 cities we want the forecast for

Create a PowerShell class (PowerShell version 5 only!)

This class will have a method that formats the forecast in HTML

Lookup the city information

Pass the city information to a function that gathers the API data

Create an object using the class

Loop through the cities, get the information, update the object we created with the class, and export the HTML forecast after using the object's method to create it