pinge-Netzwerk (PowerShell-Funktion)

Die Idee

Ich habe immer wieder die Anforderung, mit ICMP-Echorequests (ping) die Erreichbarkeit von IP-Adressen zu prüfen. Vielleicht kennt ihr das ja aus eurem eigenen Alltag. Sehr beliebt ist diese Aufgabenstellung bei der Suche nach einer freien IP-Adresse.

Mit der PowerShell gibt es natürlich entsprechende Cmdlets, z.B test-connection. Aber diese haben Nachteile:

  • IP-Adressen werden nacheinander angepingt. Sehr viele Adressen aznzupingen benötigt also viel Zeit.
  • Jeder EchoRequest wartet bis zu 1 Sekunde auf einen EchoReply. Sind also sehr viele IP-Adressen nicht erreichbar, dann geht die Laufzeit ins Ungenießbare.

Es muss also eine Alternative her. Den Einstieg zur Optimierung habe ich in dieser kleinen Anweisung gefunden. Ich spreche die Methode Send der Funktion Ping direkt an. Hier kann ich den Timeout-Wert einstellen:

pinge-Netzwerk (PowerShell-Funktion)

Und das ist gerade für lokale Netzwerke mit kurzen Paketlaufzeiten interessant: Statt bei jeder nicht erreichbaren IP-Adresse 1000 Millisekunden zu warten kann ich so den Timeout auf z.B.. 10 Millisekunden heruntersetzen!

Das habe ich in eine PowerShell-Funktion integriert. Diese erwartet beim Aufruf eine Menge von IP-Adressen. Das stellte mich vor eine weitere Herausforderung: Ich möchte z.B. ein ganzes Netzwerksegment alleine durch die Angabe der NetzID anpingen. So wird aus der Eingabe 192.168.1.0/24 die Menge der IP-Adressen 192.168.1.1 bis 192.168.1.254 – und diese Menge soll meine Funktion natürlich selber berechnen… Es war spät und mir kam nur die Idee mit dem Umweg über die binäre Schreibweise von IP-Adressen:

pinge-Netzwerk (PowerShell-Funktion)

Ich konvertierte also eine IP-Adresse von Decimal nach Binary, erhöhe den Binary-Wert um eins und konvertiere das Ergebnis zurück nach Decimal. Das ist dann die FolgeIP. Den Prozess kann ich zwischen einer StartIP und einer EndIP oder von der ersten bist zur letzten IP eines Subnetzes wiederholen. Danach kenne ich alle IP-Adressen.

Von der Idee zum Code

Das sind meine drei kleinen Helfer-Funktionen:

pinge-Netzwerk (PowerShell-Funktion)
pinge-Netzwerk (PowerShell-Funktion)
pinge-Netzwerk (PowerShell-Funktion)

Diese habe ich in eine finale Funktion eingebaut, die ich bequem aufrufen kann.

Optimierung der Laufzeit durch Parallelisierung

Als kleines Extra wollte ich mich aber nicht mit der linearen Abarbeitung der IP-Adressen zufrieden geben. Daher habe ich meine Funktion pinge-Netzwerk mit einer Parallelisierung aufgewertet. Die zu pingenden IP-Adressen werden bei Bedarf durch PowerShell-Jobs in eigenen Unterprozessen angepingt. Die Menge der IPs wird dabei schön aufgeteilt. Das kann man sich so vorstellen:

pinge-Netzwerk (PowerShell-Funktion)

Im blauen PowerShell-Prozess wird die Funktion gestartet. Dabei sollen 254 IP-Adressen angepingt werden. Die Powershell startet 4 Arbeitsprozesse – dieses sind ChildProcess-Objekte unter der eigentlichen PowerShell. Im ProcMon von Sysinternals kann man das gut sehen:

pinge-Netzwerk (PowerShell-Funktion)

Jeder der Arbeitsprozesse erhält eine Teilmenge der zu pingenden IP-Adressen. Alle Arbeitsprozesse arbeiten parallel. Die PowerShell selber arbeitet als Controller und überwacht den Fortschritt. Sind alle Arbeitsprozesse fertig, dann holt sich die PowerShell deren Ergebnisse und stellt sie als Ausgabe zusammen:

pinge-Netzwerk (PowerShell-Funktion)
Das fertige Script

Das fertige Script habe ich zur besseren Strukturierung in einzelne Regionen unterteilt. Damit kann man relativ einfach durch die Anweisungen navigieren:

pinge-Netzwerk (PowerShell-Funktion)

Im oberen Block sind die drei Hilfsfunktionen integriert. Die Parametervalidierung soll Fehleingaben wie z.B. die IP-Adresse 192.168.1.500 verhindern. In der Region „Bestimmung der IP-Adressen“ wird die Menge der IPs ermittelt. Den eigentlichen Ping habe ich sowohl linear als auch in der Parallel-Variante integriert. Dabei kann die Funktion die Anzahl der Arbeitsprozesse auch selber bestimmen. Abschließend wird die Ausgabe zusammengestellt. Eigentlich recht einfach, oder?

Das hier ist der finale Scriptcode inklusive der Hilfe:


function pinge-Netzwerk {
    <#
    .Notes
       Scriptreihe:      pinge-Netzwerk
       Datum:            2020-05-13
       Version:          V1.04
       Programmierer:    Stephan Walther

    .Synopsis
       Die Funktion sendet optimiert ICMP-Echorequsts (ping) an eine Menge von IP-Adressen.

    .DESCRIPTION
       Mit dieser Funktion kann ein Bereich oder ein Subnetz schnell angepingt werden. Optimierungen 
       wurden durch die Herabsetzung des Timeouts bei Nichterreichbarkeit und durch Parallelisierung
       erreicht.

    .EXAMPLE
       pinge-Netzwerk -Subnetz 192.168.100.0/24

       Die Funktion ermittelt aus der angegebenen SubnetzID die IP-Adressen 192.168.100.1 bis
       192.168.100.254 und pingt sie nacheinander.

    .EXAMPLE
       pinge-Netzwerk -Subnetz 192.168.100.0/27 -parallel -AnzahlProzesse 3

       Die Funktion ermittelt die im Subnetz enthaltenen IP-Adressen und teilt sie auf 3
       Arbeitsprozesse auf. Die 3 Prozesse pingen parallel. Nach Abschluss werden alle
       Adressen sortiert ausgegeben.

    .EXAMPLE
       pinge-Netzwerk -StartIP 192.168.100.1 -EndIP 192.168.100.25

       Die Funktion ermittelt die IP-Adressen zwischen der Start und der End-IP. Anschließend
       werden alle IPs (inklusive Start und Ende) hintereinander angepingt.

    .EXAMPLE
       pinge-Netzwerk -Subnetz 192.168.100.0/27 -timeout 3

       Die Funktion pingt die angegebenen IP-Adressen. Es wird maximal 3 Millisekunden auf
       eine Antwort gewartet. Beim Überschreiten des timeout wird die IP-Adresse als nicht
       erreichbar gewertet.

    .EXAMPLE
       pinge-Netzwerk -StartIP 192.168.100.1 -EndIP 192.168.100.10 -Filter TimedOut

       Die Funktion pingt die angegebenen IPs. Ausgegeben werden aber nur IP-Adressen mit
       fehlgeschlagenem ping.

    .EXAMPLE
       pinge-Netzwerk -Subnetz 192.168.100.0/24 -parallel

       Die angegebenen IP-Adressen werden angepingt. Die Funktion bestimmt dabei selber die 
       Anzahl der Arbeitsprozesse, die parallel im Hintergrund ausgeführt werden.

    .PARAMETER Subnetz
       Der Parameter erwartet eine SubnetzID und eine SubnetzMaske in der CIDR-Notation.
       Z.B. 172.16.0.0/16
       Mit der Verwendung dieses Parameters kann keine StartIP und keine EndIP angegeben 
       werden.

     .PARAMETER StartIP
       Der Parameter erwartet eine IP-Adresse vom Typ v4 ohne Angabe einer SubnetzMaske. 
       Zusätzlich muss der Parameter -EndIP angegeben werden. Die Verwendung von -StartIP
       schließt die Verwendung des Parameters -Subnetz aus.

     .PARAMETER EndIP
       Der Parameter erwartet eine IP-Adresse vom Typ v4 ohne Angabe einer SubnetzMaske. 
       Zusätzlich muss der Parameter -StartIP angegeben werden. Die Verwendung von -EndIP
       schließt die Verwendung des Parameters -Subnetz aus.

     .PARAMETER TimeOut
       Es wird ein Integer aus dem Wertebereich 3, 10, 50, 100, 500, 1000 erwartet. Der
       Standardwert ist 10. Mit dem Parameter wird die Wartezeit auf ein EchoReply in 
       Millisekunden angegeben. Nach Ablauf des TimeOuts wird eine IP-Adresse als nicht
       erreichbar gewertet.

     .PARAMETER parallel
       Mit diesem Schalter kann zwischen der linearen und der parallelen Arbeitsweise 
       umgeschaltet werden. Im Standard werden die IP-Adressen hintereinander ohne
       zusätzliche Arbeitsprozesse angepingt. Das kann bei besonders kleinen IP-Mengen
       schneller sein. Bei größeren Mengen bietet sich die parallele Arbeitsweise an.
       Mit dem Zusatzparameter -AnzahlProzesse kann die Anzahl der Arbeitsprozesse 
       definiert werden. Ohne diesen Zusatzschalter ermittelt -parallel selber eine 
       optimale Anzahl von Arbeitsprozessen.

     .PARAMETER AnzahlProzesse
       Hier kann die Anzahl der Arbeitsprozesse bei der parallelen Abarbeitung
       angegeben werden. Erlaubt sind die Werte zwischen 2 und 10. Mit der Angabe
       dieses Parameters wird -parallel automatisch verwendet.

     .PARAMETER Filter
       Das Ergebnis der ping-Aufrufe wird als Tabelle ausgegeben. Dabei kann ein
       Einzelergebnis "success" oder "timedout" sein. Mit dem Parameter -Filter kann
       man gezielt nach erfolgreichen oder fehlgeschlagenen ping-Ergebnissen suchen.
       Ohne Angabe des Parameters werden alle Ergebnisse ausgegeben.
    
    #>
    param(
        [parameter(Mandatory=$true,ParameterSetName="Subnet")]                [string]    $Subnetz,
        [parameter(Mandatory=$true,ParameterSetName="Range")]                 [ipaddress] $StartIP,
        [parameter(Mandatory=$true,ParameterSetName="Range")]                 [ipaddress] $EndIP,
        [parameter(Mandatory=$false)] [validateSet(3,10,50,100,500,1000)]     [int]       $TimeOut = 10,
        [parameter(Mandatory=$false)]                                         [switch]    $parallel = $false,
        [parameter(Mandatory=$false)] [validaterange(2,10)]                   [int]       $AnzahlProzesse = 0,
        [parameter(Mandatory=$false)] [validateSet('*','Success','TimedOut')] [string]    $Filter = '*'
    )

    #region Hilfsfunktionen
        function tmp_IPDecimal2Binary {
            param($IP)
            
            $Binary = ""
            
            ($IP -split "\.") | ForEach-Object {  
                $BinaryValue = [convert]::ToString($_,2) 
                $OctetInBinary = ("0" * (8 - ($BinaryValue).Length) + $BinaryValue) 
 
                $Binary += $OctetInBinary 
            }

            Return $Binary
        }

        function tmp_IPBinary2Decimal {
            param($Binary) 

            $IPInDecimal = @()
            0..3 | ForEach-Object {            
                $IPInDecimal += [convert]::ToInt32($Binary.Substring(8*$_,8),2)
            }

            $IPInDecimal = $IPInDecimal -join '.'

            Return $IPInDecimal
        }

        function tmp_ping {
            param($IP,$TimeOut) 
                    
            try {
                $ping   = New-Object -TypeName System.Net.NetworkInformation.Ping
                $reply  = $ping.Send($IP,$timeout)	
                $result = $reply.Status
            } 
            catch {
                $result = "error"
            }
        
            $out = New-Object -TypeName PSObject
            $out | Add-Member -MemberType NoteProperty -Name IP     -Value ([ipaddress] $IP)
            $out | Add-Member -MemberType NoteProperty -Name Result -Value $result
            
            Return $out
        }
    #endregion

    #region Validierung der Parameter
        if ($PSBoundParameters.Keys -contains 'Subnetz') {
            if ($Subnetz -notmatch "\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/\d{1,2}") {
                Write-Host "$Subnetz ist kein Subnetz in der CIDR-Notation! Verwende z.B. 192.168.1.0/24" -ForegroundColor Yellow
                break
            } else {
                $SubnetIP,$Mask = $Subnetz -split '/'

                IF ($Mask -gt 32 -or $Mask -le 0) {
                    Write-Host "Die Subnetzmaske muss zwischen 1 und 32 liegen!" -ForegroundColor Yellow
                    break
                }

                try {
                    [ipaddress]::Parse($SubnetIP) | Out-Null                    
                }
                catch {
                    Write-Host "$SubnetIP ist keine SubnetzIP!" -ForegroundColor Yellow
                    break
                }
            }
            Write-verbose "Subnet = $SubnetIP"
            Write-verbose "Mask   = $Mask" 
        } else {
            try {
                $curIP = $StartIP ; [ipaddress]::Parse($curIP) | Out-Null
                $curIP = $EndIP   ; [ipaddress]::Parse($curIP) | Out-Null
            }
            catch {
                Write-Host "$curIP ist keine IP-Adresse!" -ForegroundColor Yellow
                break
            }
        }
    #endregion
       
    #region Bestimmung der IP-Adressen
        if ($PSBoundParameters.Keys -contains 'Subnetz') {
            # Konvertierung der Subnetz-IP von Decimal in Binary
                $SubnetIPInBinary = tmp_IPDecimal2Binary -IP $SubnetIP                
            
            # Anzahl der IPs bestimmen
                $AnzIPs = [convert]::ToInt32('1' * (32 - $Mask),2) - 1
    
            # alle IPs finden
                $IPAdressen = @()
                $CurBin = $SubnetIPInBinary.Substring(0,$Mask) + ("0" * (31-$Mask)) + "1"
                
                1..$AnzIPs | ForEach-Object { 
                    # aktuelle IP in Decimal ermitteln
                        $IPAdressen += tmp_IPBinary2Decimal -Binary $CurBin
        
                    # nächste IP in Binary ermitteln
                        $CurInt = [convert]::ToInt64($CurBin,2) 
                        $NextInt = $CurInt + 1
                        $CurBin = [convert]::ToString($NextInt,2)
                }

        } else {  
            # Sortierung der IPs
                $StartIP,$EndIP = $StartIP,$EndIP | Sort-Object
                
            # Konvertierung der IPs von Decimal in Binary
                $StartIPInBinary = tmp_IPDecimal2Binary -IP $StartIP
                $EndIPInBinary   = tmp_IPDecimal2Binary -IP $EndIP

            # alle IPs finden
                $IPAdressen = @()
                $CurBin = $StartIPInBinary
                
                do {
                    # aktuelle IP in Decimal ermitteln   
                        $IPAdressen += tmp_IPBinary2Decimal -Binary $CurBin                 
        
                    # nächste IP in Binary ermitteln
                        $CurInt = [convert]::ToInt64($CurBin,2) 
                        $NextInt = $CurInt + 1
                        $CurBin = [convert]::ToString($NextInt,2)
                } while ( $CurBin -le $EndIPInBinary )
        }
    #endregion

    #region Vorbereitung von Variablen
        $Ergebnis = @()
        $ZeitStart = Get-Date
    #endregion

    #region automatische Parallelisierung?
        if ($parallel -eq $true -or $AnzahlProzesse -ge 2) {    
            # ggf. Festlegung der Prozessanzahl
                if ($AnzahlProzesse -eq 0) {                    
                    $Divider = [math]::Max($IPAdressen.count,25)                   # Der Teiler darf nicht größer als die Anzahl der IPs sein
                    
                    $AnzahlProzesseInt = [int] ($IPAdressen.count / $Divider)      # optimal: 25 Adressen je Prozess

                    if ($AnzahlProzesseInt -gt 10) { $AnzahlProzesseInt = 10 }     # optimal: max. 10 Prozesse
                    if ($AnzahlProzesseInt -eq 1)  { 
                        $AnzahlProzesseInt = 0                                     # keine Parallelisierung!
                        $parallel = $false
                        Write-Verbose "automatische Parallelisierung lohnt sich nicht ==> Umstellung auf linearen Ping"
                    } else {
                        Write-Verbose "automatische Parallelisierung = $AnzahlProzesseInt Prozesse"
                    }                    
                } else {
                    $AnzahlProzesseInt = $AnzahlProzesse
                }
        } else {
            $AnzahlProzesseInt = 0
        }
    #endregion

    #region Ausführung der Pings (parallel)
        if ($parallel -eq $true -and $AnzahlProzesseInt -ge 2) {
            # Teile die IPs für die Subprozesse auf
                write-verbose "starte $AnzahlProzesse Jobs"
        
                $Submenge = (($IPAdressen.count - 1) / $AnzahlProzesseInt)        
                if ([int] $SubMenge -le $SubMenge) {
                    $SubMenge = [int] $SubMenge + 1
                } else {
                    $SubMenge = [int] $SubMenge
                }
        
            # Variablen
                $jobs = @()
                $erledigt = @()
                
            # PingCode für die Jobs auslesen
                $pingcode = @()
                $pingcode += 'function tmp_ping {'
                $pingcode += (Get-Command -Name tmp_ping).definition 
                $pingcode += '}'
                $pingcode = $pingcode -join "`r`n"
                
            # Jobs starten
                1..$AnzahlProzesseInt | ForEach-Object {  
                    $IPAdressenTeil = $IPAdressen | Where-Object { $erledigt -notcontains $_ } | Get-Random -Count $SubMenge
                    $erledigt += $IPAdressenTeil 
                
                    $jobs += Start-Job -Name "#$_" -ScriptBlock {
                        param( $IPAdressenTeil,$TimeOut,$pingcode )

                        Invoke-Expression -Command $pingcode

                        $IPAdressenTeil | ForEach-Object {
                            tmp_ping -IP $_ -TimeOut $timeout                         
                        }
                    } -ArgumentList $IPAdressenTeil,$TimeOut,$pingcode
                    Write-Verbose -Message "   Job #$_ gestartet"
                }

            # warte auf Ergebnisse
                write-verbose "warte auf Ergebnisse"

                $fertig = $false
                $Ergebnis = @()
                $cnt = 0
                $ttl = (Get-Job).count

                Write-Progress -Activity "pinge parallel mit $AnzahlProzesseInt Prozessen" -PercentComplete 0
                do {                
                    if ((Get-Job | Where-Object {$_.State -eq 'running'}) -eq $null) {$fertig = $true}
                
                    Get-Job | Where-Object {$_.State -ne 'running'} | ForEach-Object {
                        $Ergebnis += Receive-Job -Id $_.id 
                        Write-Verbose "   Job $($_.name) ist fertig"
                        Remove-Job -Id $_.id
                        $cnt += 1
                    }
                    $percent = [math]::min(100,[int]($cnt/$ttl*100))
                    Write-Progress -Activity "pinge parallel mit $AnzahlProzesseInt Prozessen" -PercentComplete $percent
                    Start-Sleep -Seconds 1
                } while (-not $fertig)            
        }
    #endregion

    #region Ausführung der Pings (linear)
        if ($parallel -eq $false -and $AnzahlProzesseInt -eq 0) {
            write-verbose "pinge linear"

            $cnt = 0
            $ttl = $IPAdressen.count

            Write-Progress -Activity 'pinge' -PercentComplete 0
            $IPAdressen | ForEach-Object {
                $Ergebnis += tmp_ping -IP $_ -TimeOut $timeout

                $cnt += 1
                $percent = [math]::min(100,[int]($cnt/$ttl*100))
                Write-Progress -Activity 'pinge' -PercentComplete $percent
            }
        }
        Write-Progress -Activity 'pinge' -Completed
    #endregion

    #region Ausgabe
        # Filter anwenden
            if ($Filter -ne '*') {
                $Ergebnis = $Ergebnis | Where-Object { $_.Result -eq $Filter } 
            }

        # Optimierung und Bereinigung
            $Ergebnis = $Ergebnis | 
                Select-Object -Property @{ n="sort" ; e={$_.ip.address}},IP,Result |
                    Sort-Object -Property sort | 
                        Select-Object -Property IP,Result
                        
        # Informationen
            $ZeitEnde = Get-Date 
            Write-Verbose "Laufzeit = $( [math]::round(($ZeitEnde - $ZeitStart).totalseconds,2) )"

        # Ausgabe
            Return $Ergebnis
    #endregion
}
Mögliche Aufrufe

Das sind nun mögliche Aufrufe:

AufrufFunktion
pinge-Netzwerk -Subnetz 192.168.100.0/27 -parallel -AnzahlProzesse 3Die IP-Adressen von 192.168.100.1 bis 192.168.100.30 werden in 3 Arbeitsprozessen gepingt
pinge-Netzwerk -StartIP 192.168.100.1 -EndIP 192.168.100.25 -AnzahlProzesse 6Die IP-Adressen von 192.168.100.1 bis 192.168.100.25 werden in 6 Arbeitsprozessen gepingt.
pinge-Netzwerk -StartIP 192.168.100.1 -EndIP 192.168.100.10Die IP-Adressen von 192.168.100.1 bis 192.168.100.10 werden hintereinander ohne Arbeitsprozesse gepingt.
pinge-Netzwerk -StartIP 192.168.100.1 -EndIP 192.168.100.10 -Filter TimedOutIP-Adressen von 192.168.100.1 bis 192.168.100.10 werden hintereinander ohne Arbeitsprozesse gepingt. Ausgegeben werden nur fehlgeschlagene pings.
pinge-Netzwerk -Subnetz 192.168.100.0/27 -TimeOut 3 -parallel -VerboseDie IP-Adressen von 192.168.100.1 bis 192.168.100.30 werden mit einem maximalen Timeout von 3 Millisekunden gepingt. Die PowerShell ermittelt selbstständig die Anzahl der Arbeitsprozesse. Es werden Zusatzinformationen ausgegeben.
Zusammenfassung

Die Funktion ist fertig. Ich habe sie in mein Standard-Funktionsmodul integriert. So steht sie mir bei jedem PowerShell-Start zur Verfügung. Und hier gibt es noch das zip-Archiv mit dem Script.

Stay tuned!

Kommentar hinterlassen