Networkhorse.psm1


function Add-ScopeLevel
{
<#
.SYNOPSIS
Convert a scope level to account for another call stack level.
 
.DESCRIPTION
For scripts that need to get or set a variable of a specific scope so that it disappears at
the end of a block/function/script, or so that it persists globally, this calculates the
additional call level added by that script.
 
.INPUTS
System.String containing the desired level.
 
.OUTPUTS
System.String containing the calculated level (Global or an integer).
 
.LINK
Stop-ThrowError
 
.LINK
Get-PSCallStack
 
.LINK
about_Scopes
 
.FUNCTIONALITY
PowerShell
 
.EXAMPLE
Add-ScopeLevel Local
 
1
 
.EXAMPLE
Add-ScopeLevel 3
 
4
 
.EXAMPLE
Add-ScopeLevel Global
 
Global
#>


[CmdletBinding()][OutputType([string])] Param(
# The requested scope from the caller of the caller of this script.
# Global, Local, Private, Script, or a positive integer.
[Parameter(Position=0,Mandatory=$true,ValueFromPipeline=$true)][string] $Scope
)
Process
{
    if($Scope -match '\A\d+\z') {return "$(1+[int]$Scope)"}
    switch($Scope)
    {
        Global  {return 'Global'}
        # the module scope seems to implicitly add a level
        Local   {return "1"}
        Private {return "1"}
        Script
        {
            $stack = Get-PSCallStack
            for($i = 2; $i -lt $stack.Length; $i++)
            {
                if($stack[$i].Command -and $stack[$i].FunctionName -like '<ScriptBlock>*') {return "$($i-1)"}
            }
            Stop-ThrowError 'Unable to find Script scope' -Argument Scope
        }
    }
}

}

function ConvertTo-FileName
{
<#
.SYNOPSIS
Returns a valid and safe filename from a given string.
 
.INPUTS
System.String containing a filename that may contain invalid characters.
 
.OUTPUTS
System.String containing a filename without any invalid characters.
 
.FUNCTIONALITY
Unicode
 
.EXAMPLE
'app*.log' |ConvertTo-FileName
 
app_.log
 
.EXAMPLE
'one|two-<three>' |ConvertTo-FileName -CurrentPlatformOnly
 
one_two-_three_
 
.EXAMPLE
'app-${value}.config' |ConvertTo-FileName Ascii -ExcludeChars '%','$','{','}','`'
 
app-_value_.config
#>


[CmdletBinding()][OutputType([string])] Param(
# Allows limiting the filename to either ASCII or Basic Multilingual Plane characters, if specified.
[ValidateSet('Bmp', 'Ascii')][string] $OutputBlock,
# The character to use to replace a range of invalid characters.
[text.rune] $Replacement = '_'[0],
# Characters to include (overrides exclusions).
[ValidateNotNull()][char[]] $IncludeChars = @(),
# Characters to exclude.
[ValidateNotNull()][char[]] $ExcludeChars = @(),
# Runes to include (overrides excludes).
[ValidateNotNull()][text.rune[]] $IncludeRunes = @(),
# Runes to exclude.
[ValidateNotNull()][text.rune[]] $ExcludeRunes = @(),
# Indicates that only characters invalid for the current platform should be excluded by default,
# otherwise invalid characters from any platform will be excluded by default (unless overridden).
[switch] $CurrentPlatformOnly,
# The string value to sanitize for use as a filename.
[Parameter(Mandatory=$true,ValueFromPipeline=$true)][string] $InputObject
)
Begin
{
    [char[]] $skipchars = $CurrentPlatformOnly ? ([IO.Path]::GetInvalidFileNameChars()) :
        @('"', '*', '/', ':', '<', '>', '?', '\', '|')
    function Copy-Rune
    {
        [CmdletBinding()] Param(
        [Parameter(Position=0,Mandatory=$true)][text.rune] $Rune
        )
        Set-Variable skipping $false -Scope 1
        (Get-Variable value -Scope 1).Value.Append($Rune) |Out-Null
    }
    function Skip-Rune
    {
        $skipping = Get-Variable skipping -Scope 1
        if(!$skipping.Value)
        {
            $skipping.Value = $true
            (Get-Variable value -Scope 1).Value.Append($Replacement) |Out-Null
        }
    }
}
Process
{
    $skipping, $value = $false, (New-Object Text.StringBuilder)
    foreach ($rune in $InputObject.EnumerateRunes())
    {
        if($rune -in $IncludeRunes) {Copy-Rune $rune}
        elseif($rune -in $ExcludeRunes) {Skip-Rune}
        elseif($rune.IsBmp)
        {
            [char] $char = $rune.Value
            if($char -in $IncludeChars) {Copy-Rune $rune}
            elseif($char -in $ExcludeChars) {Skip-Rune}
            elseif($char -in $skipchars -or [char]::IsControl($char)) {Skip-Rune}
            elseif($OutputBlock -eq 'Ascii' -and !$rune.IsAscii) {Skip-Rune}
            else {Copy-Rune $rune}
        }
        elseif($OutputBlock -eq 'Bmp') {Skip-Rune}
        else {Copy-Rune $rune}
    }
    return $value.ToString()
}

}

function Import-Variables
{
<#
.SYNOPSIS
Creates local variables from a data row or dictionary (hashtable).
 
.INPUTS
System.Collections.IDictionary with keys and values to import as variables,
or System.Management.Automation.PSCustomObject with properties to import as variables.
 
.FUNCTIONALITY
PowerShell
 
.EXAMPLE
if($line -match '\AProject\("(?<TypeGuid>[^"]+)"\)') {Import-Variables $Matches}
 
Copies $Matches.TypeGuid to $TypeGuid if a match is found.
 
.EXAMPLE
Import-Csv |ForEach-Object {$_ |Import-Variables; Write-Host "Properties: $Name $Id $Description"}
 
Copies field values into $ProductID, $Name, and $ListPrice.
 
.EXAMPLE
if($env:ComSpec -match '^(?<ComPath>.*?\\)(?<ComExe>[^\\]+$)'){Import-Variables $Matches -Verbose}
 
Sets $ComPath and $ComExe from the regex captures if the regex matches.
 
.EXAMPLE
Invoke-RestMethod https://api.github.com/ |Import-Variables ; Invoke-RestMethod $emojis_url
 
Sets variables from the fields returned by the web service: $current_user_url, $emojis_url, &c.
Then fetches the list of GitHub emojis.
#>


[CmdletBinding()][OutputType([void])] Param(
<#
A hash of string names to any values to set as variables,
or a DataRow or object with properties to set as variables.
Works with DataRows.
#>

[Parameter(Position=0,Mandatory=$true,ValueFromPipeline=$true)][PSObject] $InputObject,
# The type of object members to convert to variables.
[Alias('Type')][Management.Automation.PSMemberTypes] $MemberType = 'Properties',
# The scope of the variables to create.
[string] $Scope = 'Local',
# Indicates that created variables should be hidden from child scopes.
[switch] $Private
)
Begin
{
    $Scope = Add-ScopeLevel $Scope
    $sv = if($Private) {@{Scope=$Scope;Option='Private'}} else {@{Scope=$Scope}}
}
Process
{
    $isDict = $InputObject -is [Collections.IDictionary]
    [string[]]$vars =
        if($isDict) {$InputObject.Keys |Where-Object {$_ -is [string]}}
        else {Get-Member -InputObject $InputObject -MemberType $MemberType |Select-Object -ExpandProperty Name}
    if(!$vars){return}
    Write-Verbose "Importing $($vars.Count) $(if($isDict){'keys'}else{"$MemberType properties"}): $vars"
    foreach($var in $vars) {Set-Variable $var $InputObject.$var @sv}
}

}

function Connect-SshKey
{
<#
.SYNOPSIS
Uses OpenSSH to generate a key and connect it to an ssh server.
 
.EXAMPLE
Connect-SshKey crowpi -UserName pi
#>


[CmdletBinding()] Param(
# The ssh server to connect to.
[Parameter(Position=0,Mandatory=$true)][string] $HostName,
# The remote username to use to connect.
[Alias('AsUserName')][string] $UserName = $env:UserName
)

$pubkeyfile = Join-Path $HOME .ssh id_rsa.pub
if(!(Test-Path $pubkeyfile -Type Leaf) -or !((Get-Item $pubkeyfile).Length))
{
    if(!(Get-Command ssh-keygen -Type Application -ErrorAction Ignore))
    {
        if($IsWindows)
        {
            if(Test-Path "$env:SystemRoot\system32\openssh\ssh-keygen.exe" -Type Leaf)
            {
                Set-Alias ssh-keygen "$env:SystemRoot\system32\openssh\ssh-keygen.exe"
            }
            else
            {
                throw 'Required "ssh-keygen" not found. To install, maybe run "Install-WindowsFeature OpenSSH.Client~~~~0.0.1.0"'
            }
        }
        throw 'Required "ssh-keygen" not found, install it to continue.'
    }
    ssh-keygen
}
Get-Content $pubkeyfile |
    ssh "$UserName@$HostName" 'cat >> .ssh/authorized_keys'

}

function ConvertTo-BasicAuthentication
{
<#
.SYNOPSIS
Produces a basic authentication header string from a credential.
 
.INPUTS
System.Management.Automation.PSCredential to convert to the Authorization HTTP header value.
 
.OUTPUTS
System.String to use as the Authorization HTTP header value.
 
.FUNCTIONALITY
HTTP
 
.LINK
https://tools.ietf.org/html/rfc1945#section-11.1
 
.LINK
http://stackoverflow.com/q/24672760/54323
 
.LINK
https://weblog.west-wind.com/posts/2010/Feb/18/NET-WebRequestPreAuthenticate-not-quite-what-it-sounds-like
 
.LINK
https://powershell.org/forums/topic/pscredential-parameter-help/
 
.EXAMPLE
Invoke-RestMethod https://example.com/api/items -Method Get -Headers @{Authorization=ConvertTo-BasicAuthentication (Get-Credential -Message 'Log in')}
 
Calls a REST method that requires Basic authentication on the first request (with no challenge-response support).
#>


[CmdletBinding()][OutputType([string])] Param(
# Specifies a user account to authenticate an HTTP request that only accepts Basic authentication.
[Parameter(Mandatory=$true,ValueFromPipeline=$true)]
[PSCredential][Management.Automation.Credential()]$Credential
)
Process
{
    return 'Basic ' + [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(
        "$($Credential.UserName):$($Credential.Password |ConvertFrom-SecureString -AsPlainText)"))
}

}

function ConvertTo-MultipartFormData
{
<#
.SYNOPSIS
Creates multipart/form-data to send as a request body.
 
.INPUTS
Any System.Collections.IDictionary type of key-value pairs to encode.
 
.OUTPUTS
System.Byte[] of encoded key-value data.
 
.FUNCTIONALITY
HTTP
 
.LINK
https://docs.microsoft.com/dotnet/api/system.net.http.multipartformdatacontent
 
.LINK
Invoke-WebRequest
 
.LINK
Invoke-RestMethod
 
.LINK
New-Guid
 
.EXAMPLE
@{ title = 'Name'; file = Get-Item avatar.png } |ConvertTo-MultipartFormData |Invoke-WebRequest $url -Method POST
 
Sends two fields, one of which is a file upload.
#>


[CmdletBinding()][OutputType([byte[]])] Param(
<#
The fields to pass, as a Hashtable or other dictionary.
Values of the System.IO.FileInfo type will be read, as for a file upload.
#>

[Parameter(Mandatory=$true,Position=0,ValueFromPipeline=$true)][Collections.IDictionary] $Fields
)
DynamicParam
{
    $boundary = "$(New-Guid)"
    $PSDefaultParameterValues['Invoke-WebRequest:ContentType'] = "multipart/form-data; boundary=$boundary"
    $PSDefaultParameterValues['Invoke-RestMethod:ContentType'] = "multipart/form-data; boundary=$boundary"
}
Begin
{
    $cmdletname = $MyInvocation.MyCommand.Name
    if((Get-Module Microsoft.PowerShell.Utility).Version -ge [version]6.1)
    {
        Write-Warning "Invoke-WebRequest and Invoke-RestMethod appear to natively support multipart/form-data."
    }
    try{[void][Net.Http.StringContent]}catch{Add-Type -AN System.Net.Http |Out-Null}
}
Process
{
    $content = New-Object Net.Http.MultipartFormDataContent $boundary
    foreach($field in $Fields.GetEnumerator())
    {
        if($field.Value -isnot [IO.FileInfo])
        {
            $content.Add([Net.Http.StringContent]$field.Value,$field.Key)
            Write-Verbose "$cmdletname : $($field.Key)=$($field.Value)"
        }
        else
        {
            Write-Verbose "$cmdletname : Adding file $($field.Value.FullName)"
            $content.Add((New-Object Net.Http.StreamContent ($field.Value.OpenRead())),$field.Key,$field.Value.Name)
        }
    }
    $content.Headers.GetEnumerator() |
        ForEach-Object {Write-Verbose "$cmdletname header : $($_.Key)=$($_.Value -join "`n`t")"}
    [Threading.Tasks.Task[byte[]]]$getbody = $content.ReadAsByteArrayAsync()
    $getbody.Wait()
    [byte[]]$body = $getbody.Result
    $content.Dispose()
    Write-Verbose "$cmdletname : Body is $($body.Length) bytes"
    return,$body
}
End
{
    $PSDefaultParameterValues.Remove('Invoke-WebRequest:ContentType')
    $PSDefaultParameterValues.Remove('Invoke-RestMethod:ContentType')
}

}

function Get-ContentSecurityPolicy
{
<#
.SYNOPSIS
Returns the content security policy at from the given URL.
 
.INPUTS
Microsoft.PowerShell.Commands.WebResponseObject from Invoke-WebRequest
or
any object with a Uri or Url property
 
.OUTPUTS
System.Management.Automation.PSCustomObject containing the parsed policy.
 
.FUNCTIONALITY
HTTP
 
.LINK
https://content-security-policy.com/
 
.LINK
Invoke-WebRequest
 
.EXAMPLE
Invoke-WebRequest http://example.org/ |Get-ContentSecurityPolicy
 
default-src : {http://example.org, http://example.net, 'self'}
script-src : {'self'}
img-src : {'self'}
report-uri : {http://example.com/csp}
#>


[CmdletBinding()][OutputType([Management.Automation.PSCustomObject])] Param(
# The URL to get the policy from.
[Parameter(ParameterSetName='Uri',Position=0,Mandatory=$true,ValueFromPipelineByPropertyName=$true)]
[Alias('Url')][Uri] $Uri,
# The output from Invoke-WebRequest to parse the policy from.
[Parameter(ParameterSetName='Response',Mandatory=$true,ValueFromPipeline=$true)]
[Microsoft.PowerShell.Commands.WebResponseObject] $Response
)
Process
{
    if($Uri){$Response = Invoke-WebRequest $Uri -UseBasicParsing}
    if(!$Response.Headers.ContainsKey('Content-Security-Policy')){return}
    $csp = @{}
    $Response.Headers['Content-Security-Policy'] -split '\s*;\s*' |
        ForEach-Object {$directive,$values=$_ -split '\s+'; $csp[$directive]=[string[]]$values}
    return [pscustomobject]$csp
}

}

function Get-Dns
{
<#
.SYNOPSIS
Looks up DNS info, given a hostname or address.
 
.INPUTS
System.String of host names to look up.
 
.OUTPUTS
System.Net.IPHostEntry of host DNS entries, or
System.String of network addresses found.
 
.LINK
https://msdn.microsoft.com/library/ms143998.aspx
 
.EXAMPLE
Get-Dns www.google.com
 
HostName Aliases AddressList
-------- ------- -----------
www.google.com {} {172.217.10.132}
#>


[CmdletBinding()][OutputType([Net.IPHostEntry])][OutputType([string])] Param(
# A host name or address to look up.
[Parameter(Position=0,Mandatory=$true,ValueFromRemainingArguments=$true,ValueFromPipeline=$true)]
[Alias('Address','HostAddress','Name')][string[]] $HostName,
<#
Indicates that only the string versions of addresses belonging to the specified family should be returned.
"Unknown" returns all addresses.
#>

[Net.Sockets.AddressFamily] $OnlyAddresses
)

Process
{
    foreach ($h in $HostName)
    {
        $entry = [Net.Dns]::GetHostEntry($h)
        if(!$PSBoundParameters.ContainsKey('OnlyAddresses')) {$entry}
        elseif($OnlyAddresses -eq [Net.Sockets.AddressFamily]::Unspecified) {$entry.AddressList |ForEach-Object {$_.IPAddressToString}}
        else {$entry.AddressList |Where-Object AddressFamily -eq $OnlyAddresses |ForEach-Object {$_.IPAddressToString}}
    }
}

}

function Get-ServerCertificate
{
<#
.SYNOPSIS
Returns the certificate provided by the requested server.
 
.FUNCTIONALITY
TLS/SSL
 
.EXAMPLE
Get-ServerCertificate webcoder.info
 
Server : webcoder.info
Subject : CN=webcoder.info
Issuer : CN=R11, O=Let's Encrypt, C=US
Issued : 2024-07-06 15:51:57
Expires : 2024-10-04 15:51:56
Thumbprint : 363A8CBAB35E6F3254CBB52FE00D0E0E0B3606BC
Certificate : [Subject]...
Extensions : {[Subject Alternative Name, DNS Name=...
Chain : System.Security.Cryptography.X509Certificates.X509Chain
#>


[CmdletBinding()] Param(
# The server (hostname) to return the TLS/SSL certificate from.
[Parameter(Position=0,Mandatory=$true,ValueFromRemainingArguments=$true)][string[]] $Server
)
Begin
{
    if(!(Get-Command openssl -Type Application -ErrorAction Ignore))
    {
        if($IsWindows)
        {
            if(Test-Path "$env:SystemRoot\openssl.exe" -Type Leaf)
            {
                Set-Alias openssl "$env:SystemRoot\openssl.exe"
            }
            else
            {
                throw 'Required "openssl" is not installed.'
            }
        }
        else
        {
            throw 'Required "openssl" is not installed.'
        }
    }

    filter Get-ServerCertificate
    {
        [CmdletBinding()] Param(
        [Parameter(Position=0,Mandatory=$true,ValueFromPipeline=$true)][string] $Server
        )
        $name = $Server -replace ':\d+\z'
        $serverPort = $Server -like '*:*' ? $Server : "${Server}:443"
        $serialized =  'Q' |openssl s_client -servername $name -connect $serverPort 2>NUL |Out-String
        $cert = New-Object Security.Cryptography.X509Certificates.X509Certificate2 (,[Text.Encoding]::UTF8.GetBytes($serialized))
        $chain = New-Object Security.Cryptography.X509Certificates.X509Chain
        [void]$chain.Build($cert)
        $ext = @{}
        $cert.Extensions |ForEach-Object {$ext.Add($_.Oid.FriendlyName, (New-Object Security.Cryptography.AsnEncodedData $_.Oid, $_.RawData).Format($true).Trim())}
        return [pscustomobject]@{
            Server      = $Server
            Subject     = $cert.Subject
            AltNames    = $ext.ContainsKey('Subject Alternative Name') ? $ext['Subject Alternative Name'] : $null
            Issuer      = $cert.Issuer
            Issued      = $cert.NotBefore
            Expires     = $cert.NotAfter
            Thumbprint  = $cert.Thumbprint
            Certificate = $cert
            Extensions  = $ext
            Chain       = $chain
        }
    }
}
Process
{
    $Server |Get-ServerCertificate
}

}

function Get-SslDetails
{
<#
.SYNOPSIS
Enumerates the SSL protocols that the client is able to successfully use to connect to a server.
 
.INPUTS
System.String of hostname(s) to get SSL support and certificate details for.
 
.OUTPUTS
System.Management.Automation.PSCustomObject with certifcated details and
properties indicating support for SSL protocols with the cypher algorithm used
if supported or false if not supported.
 
.FUNCTIONALITY
HTTP
 
.LINK
https://msdn.microsoft.com/library/system.security.authentication.sslprotocols.aspx
 
.LINK
https://msdn.microsoft.com/library/system.net.security.sslstream.authenticateasclient.aspx
 
.EXAMPLE
Get-SslDetails -ComputerName www.google.com
 
ComputerName : www.google.com
Port : 443
KeyLength : 2048
SignatureAlgorithm : rsa-sha1
CertificateIssuer : Google Inc
CertificateExpires : 06/20/2018 06:22:00
Ssl2 : False
Ssl3 : False
Tls : Aes128
Tls11 : Aes128
Tls12 : Aes128
#>


[CmdletBinding()][OutputType([Management.Automation.PSCustomObject])] Param(
# The name of the remote computer to connect to.
[Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true,ValueFromPipeline=$true)]
[Alias('CN','Hostname')][string]$ComputerName,
# The remote port to connect to.
[Parameter(ValueFromPipelineByPropertyName=$true)][int]$Port = 443
)
#TODO: Add or replace dependency.
Begin {$protocols = Get-EnumValues.ps1 Security.Authentication.SslProtocols |Where-Object Name -notin 'None','Default' |Select-Object -ExpandProperty Name}
Process
{
    $result = [ordered]@{
        ComputerName         = $ComputerName
        Port                 = $Port
        KeyLength            = $null
        SignatureAlgorithm   = $null
        CertificateIssuer    = $null
        CertificateEffective = $null
        CertificateExpires   = $null
        Certificate          = $null
    }
    foreach($protocol in $protocols)
    {
        $socket = New-Object Net.Sockets.Socket Stream,Tcp
        $socket.Connect($ComputerName,$Port)
        try
        {
            $ssl = New-Object Net.Security.SslStream (New-Object Net.Sockets.NetworkStream $socket,$true),$true
            $ssl.AuthenticateAsClient($ComputerName,$null,$protocol,$false)
            if(!$result['Certificate'])
            {
                [Security.Cryptography.X509Certificates.X509Certificate2]$cert = $ssl.RemoteCertificate
                $result['KeyLength'] = $cert.PublicKey.Key.KeySize
                $result['SignatureAlgorithm'] = $cert.SignatureAlgorithm.FriendlyName
                $result['CertificateIssuer'] = $cert.GetNameInfo('SimpleName', $true)
                $result['CertificateEffective'] = $cert.NotBefore
                $result['CertificateExpires'] = $cert.NotAfter
                $result['Certificate'] = $cert
            }
            $result[$protocol] = $ssl.CipherAlgorithm
        }
        catch
        {
            $result[$protocol] = $false
        }
        finally
        {
            if($ssl -is [IDisposable]) {$ssl.Dispose()}
            if($socket -is [IDisposable]) {$socket.Dispose()}
        }
    }
    [pscustomobject]$result
}

}

function Save-PodcastEpisodes
{
<#
.SYNOPSIS
Downloads enclosures from a podcast feed.
 
.LINK
Save-WebRequest
 
.EXAMPLE
Save-PodcastEpisodes https://www.youlooknicetoday.com/rss -UseTitle
 
Downloads podcast episodes to the current directory.
#>


[CmdletBinding()] Param(
# The URL of the podcast feed.
[Parameter(Position=0,Mandatory=$true,ValueFromPipeline=$true)][Alias('Url')][uri] $Uri,
# Episodes before this date will be ignored.
[datetime] $After,
# Episodes after this date will be ignored.
[datetime] $Before,
# Includes only the given number of initial episodes, by publish date.
[int] $First,
# Includes only the given number of most recent episodes, by publish date.
[int] $Last,
# Use episode titles for filenames.
[switch] $UseTitle,
# Downloads the episodes into a folder with the podcast name.
[switch] $CreateFolder
)
Process
{
    $channel = ([xml]((Invoke-WebRequest $Uri).Content)).rss.channel
    $channel |Format-List |Out-String |Write-Verbose
    $activity = "Downloading $($channel.title)"
    [object[]] $episodes = Invoke-RestMethod $Uri
    foreach($episode in $episodes) {$episode |Add-Member published ([datetime]$episode.pubDate)}
    if($PSBoundParameters.ContainsKey('After')) {[object[]] $episodes = $episodes |Where-Object published -gt $After}
    if($PSBoundParameters.ContainsKey('Before')) {[object[]] $episodes = $episodes |Where-Object published -lt $Before}
    if($PSBoundParameters.ContainsKey('First')) {[object[]] $episodes = $episodes |Sort-Object published |Select-Object -First $First}
    if($PSBoundParameters.ContainsKey('Last')) {[object[]] $episodes = $episodes |Sort-Object published |Select-Object -Last $Last}
    #TODO: Add or replace dependency.
    if($CreateFolder) {New-Item ($channel.title |ConvertTo-FileName) -ItemType Directory -EA Ignore |Push-Location}
    $i,$max = 0,($episodes.Count/100)
    foreach($episode in $episodes)
    {
        $episode |Format-List |Out-String |Write-Verbose
        $title = $episode.title |Select-Object -First 1
        Write-Progress $activity $title -curr $episode.pubDate -percent ($i++/$max)
        if(!$episode.PSObject.Properties.Match('enclosure').Count)
        {
            Write-Warning "No enclosure found for '$title', $($episode.pubDate)"
            continue
        }
        if($UseTitle)
        {
            $filename = if($episode.PSObject.Properties.Match('episode')) {$episode.episode + ' '} else {''}
            #TODO: Add or replace dependencies.
            $filename += $title |ConvertTo-FileName
            $filename += Split-Uri.ps1 $episode.enclosure.url -Extension
            Invoke-WebRequest $episode.enclosure.url -OutFile $filename
            (Get-Item $filename).CreationTime = $episode.published
        }
        else
        {
            Save-WebRequest $episode.enclosure.url -CreationTime $episode.published
        }
    }
    if($CreateFolder) {Pop-Location}
    Write-Progress $activity -Completed
}

}

function Save-WebRequest
{
<#
.SYNOPSIS
Downloads a given URL to a file, automatically determining the filename.
 
.INPUTS
Object with System.Uri property named Uri.
 
.FUNCTIONALITY
HTTP
 
.LINK
https://tools.ietf.org/html/rfc2183
 
.LINK
http://test.greenbytes.de/tech/tc2231/
 
.LINK
https://msdn.microsoft.com/library/system.net.mime.contentdisposition.filename.aspx
 
.LINK
https://msdn.microsoft.com/library/system.io.path.getinvalidfilenamechars.aspx
 
.LINK
Invoke-WebRequest
 
.LINK
Invoke-Item
 
.LINK
Move-Item
 
.EXAMPLE
Save-WebRequest https://www.irs.gov/pub/irs-pdf/f1040.pdf -Open
 
Saves f1040.pdf (or else a filename specified in the Content-Disposition header) and opens it.
#>


[CmdletBinding()][OutputType([void])] Param(
# The URL to download.
[Parameter(Position=0,Mandatory=$true,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)]
[Alias('Url','Href','Src')][uri] $Uri,
# The directory to save the file into.
[string] $OutDirectory,
# Sets the creation time on the file to the given value.
[datetime] $CreationTime,
# Sets the creation time on the file to the given value.
[datetime] $LastWriteTime,
# When present, invokes the file after it is downloaded.
[switch] $Open
)
Begin
{
    function Get-FileName
    {
        [CmdletBinding()][OutputType([string])] Param(
        [Parameter(Position=0)][uri] $Uri
        )
        $uriFilename, $suggestion = $null, $null
        $response = Invoke-WebRequest $Uri -Method Head -SkipHttpErrorCheck -MaximumRedirection 0 -AllowInsecureRedirect -EA Ignore
        if([int]::DivRem($response.StatusCode, 100).Item1 -eq 3 -and $response.Headers['Location'].Count -gt 0)
        {
            return Get-FileName (New-Object uri $Uri,($response.Headers['Location'][0]))
        }
        if($response.Headers.ContainsKey('Content-Disposition') -and $response.Headers['Content-Disposition'].Count -gt 0)
        {
            [ContentDisposition] $disposition = $response.Headers['Content-Disposition'][0]
            $suggestion = $disposition.FileName |Split-Path -Leaf
        }
        #TODO: Add or replace dependencies.
        if($suggestion) {return $suggestion |ConvertTo-FileName}
        elseif($null -ne $Uri.Segments -and $Uri.Segments.Count -gt 0) {return $Uri.Segments[-1] |ConvertTo-FileName}
        elseif($Uri.Host) {return '{0}.saved' -f $Uri.Host |ConvertTo-FileName}
        else {return "$Uri.saved" |Split-Path -Leaf |ConvertTo-FileName}
    }
}
Process
{
    $filename = Get-FileName $Uri
    if($OutDirectory) {$filename = Join-Path $OutDirectory $filename}
    $response = Invoke-WebRequest $Uri -OutFile $filename -PassThru
    Write-Information "Saved to '$filename'"
    if($PSBoundParameters.ContainsKey('CreationTime')) {(Get-Item $filename).CreationTime = $CreationTime}
    if($PSBoundParameters.ContainsKey('LastWriteTime')) {(Get-Item $filename).LastWriteTime = $LastWriteTime}
    elseif($response.Headers['Last-Modified'] -is [string[]] -and $response.Headers['Last-Modified'].Count -gt 0)
    {
        (Get-Item $filename).LastWriteTime = [datetimeoffset]::Parse(($response.Headers['Last-Modified'][0])).LocalDateTime
    }
    if($Open) {Invoke-Item $filename}
}

}

function Show-HttpStatus
{
<#
.SYNOPSIS
Displays the HTTP status code info.
 
.FUNCTIONALITY
HTTP
 
.EXAMPLE
Show-HttpStatus ServiceUnavailable -AsCat
 
.EXAMPLE
Show-HttpStatus 200 -AsCat
#>


[CmdletBinding()] Param(
# The HTTP status code to describe.
[Parameter(Position=0,Mandatory=$true)][Net.HttpStatusCode] $Status,
# Render the code as a cat.
[switch] $AsCat
)
Begin
{
    if($AsCat -and !(Get-Command Out-ConsolePicture -ErrorAction Ignore))
    {
        Install-Module OutConsolePicture
    }
}
Process
{
    "$([int]$Status) $Status"
    "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/$([int]$Status)"
    if($AsCat)
    {
        $cat = "https://http.cat/$([int]$Status)"
        Out-ConsolePicture -Url $cat
        $cat
    }
}

}

function Test-HttpSecurity
{
<#
.SYNOPSIS
Scan sites using Mozilla's Observatory.
 
.INPUTS
System.String containing a URL host to check.
 
.OUTPUTS
System.Management.Automation.PSObject containing scan results.
 
.LINK
Invoke-RestMethod
 
.LINK
https://observatory.mozilla.org/
 
.EXAMPLE
Test-HttpSecurity www.example.net -Public
 
end_time : Thu, 22 Dec 2016 00:09:31 GMT
grade : F
hidden : False
likelihood_indicator : MEDIUM
response_headers : @{Accept-Ranges=bytes; Cache-Control=max-age=604800; Content-Encoding=gzip;
                       Content-Length=606; Content-Type=text/html; Date=Thu, 22 Dec 2016 00:09:31 GMT;
                       Etag="359670651+gzip"; Expires=Thu, 29 Dec 2016 00:09:31 GMT; Last-Modified=Fri, 09 Aug
                       2013 23:54:35 GMT; Server=ECS (sjc/4E3B); Vary=Accept-Encoding; X-Cache=HIT;
                       x-ec-custom-error=1}
scan_id : 2899791
score : 0
start_time : Thu, 22 Dec 2016 00:09:29 GMT
state : FINISHED
tests_failed : 6
tests_passed : 6
tests_quantity : 12
results : https://http-observatory.security.mozilla.org/api/v1/getScanResults?scan=2899791
host : www.example.net
 
.EXAMPLE
Test-HttpSecurity www.example.com -IncludeResults
 
end_time : Thu, 22 Dec 2016 16:17:17 GMT
grade : F
hidden : True
likelihood_indicator : MEDIUM
response_headers : @{Accept-Ranges=bytes; Cache-Control=max-age=604800; Content-Encoding=gzip;
                       Content-Length=606; Content-Type=text/html; Date=Thu, 22 Dec 2016 16:17:17 GMT;
                       Etag="359670651+gzip"; Expires=Thu, 29 Dec 2016 16:17:17 GMT; Last-Modified=Fri, 09 Aug
                       2013 23:54:35 GMT; Server=ECS (sjc/4E5C); Vary=Accept-Encoding; X-Cache=HIT;
                       x-ec-custom-error=1}
scan_id : 2903851
score : 0
start_time : Thu, 22 Dec 2016 16:17:16 GMT
state : FINISHED
tests_failed : 6
tests_passed : 6
tests_quantity : 12
results : @{content-security-policy=; contribute=; cookies=; cross-origin-resource-sharing=;
                       public-key-pinning=; redirection=; referrer-policy=; strict-transport-security=;
                       subresource-integrity=; x-content-type-options=; x-frame-options=; x-xss-protection=}
host : www.example.com
#>


[CmdletBinding()][OutputType([psobject])] Param(
# Hostnames to scan, e.g. www.example.org
[Parameter(Position=0,Mandatory=$true,ValueFromPipeline=$true)][string[]]$Hosts,
# Indicates a new scan should be performed, rather than returning a cached one.
[Alias('Rescan')][switch]$Force,
<#
Indicates the scan results may be posted publically.
By default, scans are unlisted.
#>

[switch]$Public,
# Indicates the detailed scan results should be fetched rather than simply providing a URL for them.
[Alias('Details','Results','FetchResults')][switch]$IncludeResults,
# The number of milliseconds to wait between polling the hostnames for scan completion.
[int]$PollingInterval = 1753,
# The address of the Observatory web service.
[Uri]$Endpoint = 'https://http-observatory.security.mozilla.org/api/v1'
)
Process
{
    $scan = @{}
    Write-Progress 'Mozilla Observatory Scan' 'Initiating scans'
    $i,$max = 0,($Hosts.Count/99.99)
    $Hosts |ForEach-Object {
        Write-Progress 'Mozilla Observatory Scan' 'Initiating scans' -CurrentOperation $_ -PercentComplete ($i++/$max)
        $scan.Add($_,(Invoke-RestMethod "$Endpoint/analyze?host=$_" -Body @{hidden=!$Public;rescan=$Force} -Method Post))
    }

    while([string[]]$pending = $scan.Keys |Where-Object {$scan.$_.state -like '*ING' -or
        !(Get-Member state -InputObject $scan.$_ -MemberType Properties)})
    {
        Write-Progress 'Mozilla Observatory Scan' "Waiting $PollingInterval ms" -PercentComplete ($pending.Count/$max)
        Start-Sleep -Milliseconds $PollingInterval
        $pending |ForEach-Object {
            Write-Progress 'Mozilla Observatory Scan' "Checking $_" -PercentComplete ($pending.Count/$max)
            $scan.$_ = Invoke-RestMethod "$Endpoint/analyze?host=$_"
        }
    }
    Write-Progress 'Mozilla Observatory Scan' -Completed

    $scan.Keys |ForEach-Object {
        $results = "$Endpoint/getScanResults?scan=$($scan.$_.scan_id)"
        if($IncludeResults) {$results = Invoke-RestMethod $results}
        Add-Member results $results -InputObject $scan.$_
        Add-Member host $_ -InputObject $scan.$_ -PassThru
    }
}

}

function Trace-WebRequest
{
<#
.SYNOPSIS
Provides details about a retrieving a URI.
 
.NOTES
TODO: Add support for other Invoke-WebRequest parameters.
 
.INPUTS
System.Uri to retrieve.
 
.FUNCTIONALITY
HTTP
 
.EXAMPLE
Trace-WebRequest g.co/p3phelp -SkipHeaders -SkipContent
 
g.co is CN=*.google.com from CN=WR2, O=Google Trust Services, C=US
Valid 05/12/2025 01:42:58 to 08/04/2025 01:42:57
GET https://g.co/p3phelp
HTTP/1.1 302 Found
Following redirect to https://support.google.com/accounts/answer/151657?hl=en
support.google.com is CN=*.google.com from CN=WR2, O=Google Trust Services, C=US
Valid 05/12/2025 01:42:58 to 08/04/2025 01:42:57
GET https://support.google.com/accounts/answer/151657?hl=en
HTTP/1.1 301 MovedPermanently
Following redirect to https://support.google.com/accounts/topic/3382252?hl=en&visit_id=638845176026805186-2907418293&rd=1
GET https://support.google.com/accounts/topic/3382252?hl=en&visit_id=638845176026805186-2907418293&rd=1
HTTP/1.1 301 MovedPermanently
Following redirect to https://support.google.com/accounts/?hl=en&visit_id=638845176026805186-2907418293&rd=2&topic=3382252
GET https://support.google.com/accounts/?hl=en&visit_id=638845176026805186-2907418293&rd=2&topic=3382252
HTTP/1.1 200 OK
#>


[CmdletBinding()] Param(
# The URL to retrieve.
[Parameter(Position=0,Mandatory=$true,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)]
[Alias('Url','Href','Src')][uri] $Uri,
# The HTTP method verb to use.
[Net.Http.HttpMethod] $Method = 'GET',
# A file to log the request to.
[string] $LogFile,
# Indicates headers shouldn't be output.
[switch] $SkipHeaders,
# Indicates content shouldn't be output.
[switch] $SkipContent
)
Begin
{
    $certhost = @{}
    $lock = "$([char]0xD83D)$([char]0xDD12)$([char]0xFE0F)" # :lock:/LOCK
    $outbox_tray = "$([char]0xD83D)$([char]0xDCE4)$([char]0xFE0F)" # :outbox_tray:/OUTBOX TRAY
    $inbox_tray = "$([char]0xD83D)$([char]0xDCE5)$([char]0xFE0F)" # :inbox_tray:/INBOX TRAY
    $information_source = "$([char]0x2139)$([char]0xFE0F)" # :information_source:/INFORMATION SOURCE
    ${timer clock} = "$([char]0x23F2)$([char]0xFE0F)" # TIMER CLOCK

    filter Get-HttpStatusColor
    {
        [CmdletBinding()][OutputType([ConsoleColor])] Param(
        [Parameter(Position=0,Mandatory=$true,ValueFromPipeline=$true)][int] $Code
        )
        switch([int]::DivRem($Code, 100).Item1)
        {
            1 {return [ConsoleColor]::White}
            2 {return [ConsoleColor]::Green}
            3 {return [ConsoleColor]::Blue}
            4 {return [ConsoleColor]::Red}
            5 {return [ConsoleColor]::DarkRed}
        }
    }

    function Trace-Uri
    {
        [CmdletBinding()] Param(
        # The URL to retrieve.
        [Parameter(Position=0,Mandatory=$true,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)]
        [Alias('Url','Href','Src')][uri] $Uri,
        # The HTTP method verb to use.
        [Net.Http.HttpMethod] $Method = 'GET'
        )
        if(!$certhost.Contains($Uri.Host))
        {
            $certinfo = Get-ServerCertificate $Uri.Host
            $certhost[$Uri.Host] = $certinfo
            Write-Information "$lock $($Uri.Host) is $($certinfo.Subject) from $($certinfo.Issuer)" #-fg Magenta
            Write-Information "${timer clock} Valid $($certinfo.Issued) to $($certinfo.Expires)" #-fg DarkMagenta
        }
        $request = New-Object Net.Http.HttpRequestMessage -ArgumentList $Method, $Uri
        $requestLine, $requestRawHeaders = "$Method $Uri", ($request.Headers.ToString())
        Write-Verbose $requestLine
        Write-Verbose $requestRawHeaders
        Write-Information "$outbox_tray $requestLine" #-fg DarkGreen
        #Write-Information $requestRawHeaders #-fg DarkGray
        if($LogFile)
        {@"
###
$Method $Uri
$requestRawHeaders
"@
 |Add-Content $LogFile
        }
        Write-Debug $requestLine
        $StatusCode = 0
        Invoke-WebRequest -Uri $Uri -SkipHttpErrorCheck -MaximumRedirection 0 -AllowInsecureRedirect -EA Ignore |
            Import-Variables
        if(!$StatusCode)
        {
            if($LogFile)
            {@"
###
# Response: $($_.Message)
"@
 |Add-Content $LogFile
            }
            return
        }
        $statusLine, $rawHeaders = ($RawContent -replace '(?s)\r?\n\r?\n.*\z') -split '\r?\n',2
        if($null -eq $rawHeaders) {$rawHeaders = ''}
        Write-Verbose $statusLine
        Write-Verbose $rawHeaders
        Write-Information "$inbox_tray $statusLine" #-fg (Get-HttpStatusColor $StatusCode)
        if(!$SkipHeaders) {Write-Information $rawHeaders} # -fg Gray}
        if(!$SkipContent -and $Content) {Write-Information $Content} # -fg White}
        if($LogFile)
        {@"
###
# Response:
$RawContent
"@
 |Add-Content $LogFile
        }
        if([int]::DivRem($StatusCode, 100).Item1 -eq 3)
        {
            foreach($location in $Headers.Location |ForEach-Object {New-Object Uri $Uri,$_})
            {
                Write-Information "$information_source Following redirect to $location" #-fg DarkBlue
                Trace-Uri $location
            }
        }
    }
}
Process
{
    if(!$Uri.IsAbsoluteUri -and [uri]::IsWellFormedUriString("https://$Uri", 'Absolute'))
    {
        [uri] $Uri = "https://$Uri"
    }
    Trace-Uri -Uri $Uri -Method $Method
}

}
Export-ModuleMember -Function Connect-SshKey,ConvertTo-BasicAuthentication,ConvertTo-MultipartFormData,Get-ContentSecurityPolicy,Get-Dns,Get-ServerCertificate,Get-SslDetails,Save-PodcastEpisodes,Save-WebRequest,Show-HttpStatus,Test-HttpSecurity,Trace-WebRequest