PowerShell als HTTPServer

PowerShell kann nicht nur als HTTPClient agieren und mit einem Webserver sprechen, sondern über den HTTPListener lässt sich mit PowerShell auch ganz schnell ein kleiner Webserver aufsetzen. Versuchen Sie nun aber nicht mit PowerShell einen IIS nachzubilden. Interessanter ist eher der Ansatz mit PowerShell einen kleinen REST-Server aufzubauen oder bestehende Skripte über einen HTTP-Nebeneingang abzufragen. Oder eben über den Weg über das Netzwerk Daten zu einem anderen PowerShell-Dienst zu senden.

Simpler HTTP Server

Die einfachste Form eines HTTP-Servers mit PowerShell besteht aus ganz wenigen Zeilen. Ein HTTP-Listener startet den Webserver, der ab dem Moment auf Anfragen wartet. Dann wartet das PowerShell-Skript auf einen Request um diesen dann zu verarbeiten. Der HTTPListener ist selbst schon als Thread gebaut, d.h. während das Skript einen Request verarbeitet, kann der nächste Request weiter eingehen und baut eine Queue auf.

Bei Windows 7/2008R2/2012 oder höher müssen Sie erst die Firewall informieren, dass eingehende Verbindungen erlaubt sind.

REM nicht erforderlich wenn man das Skript als Admin startet

netsh http add URLacl URL=http://+:81/ User=domain\User

Hier ein Beispielcode, mit dem ich in dem Beispiel auf Port 81 lausche.

Write-host "Web Listener: Start"

try {
   $listener = New-Object System.Net.HttpListener
   $listener.Prefixes.Add('http://+:81/')  # Must GENAU mit der Angabe in NETSH übereinstimmen 
   $listener.Start()
}
catch {
   write-error "Unable to open listener. Check Admin permission or NETSH Binding"
   exit 1
}

Write-host "Web Listener listening"
$basename = (get-date -Format yyyyMMddHHmmss)
$count = 0

$Host.UI.RawUI.FlushInputBuffer()
[console]::TreatControlCAsInput = $true
write-host "Press any key to end after the next incoming request"
while (!([console]::KeyAvailable)) {
   $count++
   write-host ("Listening on " + $listener.Prefixes )
   $context = $listener.GetContext() # Warte auf eingehende Anfragen
   write-host "------- New Request ($count) arrived ------------"
   $request = $context.Request
   write-host (" URL.AbsoluteUri:" + $request.URL.AbsoluteUri)
   write-host (" HttpMethod     :" + $request.HttpMethod)
   if ($request.HasEntityBody) {
      write-host "Exporting Body"
      # converting streamreader to string
      $rcvStream = [System.IO.StreamReader]::new($context.Request.InputStream).ReadToEnd()
      $rcvStream | out-file -filepath ("request"+$basename+$count+".txt")
   }
   else {
      write-host "No Body"
   }

   write-host "------- Sending OK Response ------------"
   $response = $context.Response
   $response.ContentType = 'text/plain'
   $message = "Anfrage verarbeitet"
   [byte[]] $buffer = [System.Text.Encoding]::UTF8.GetBytes($message)
   $response.ContentLength64 = $buffer.length
   $response.OutputStream.Write($buffer, 0, $buffer.length)
   $response.OutputStream.close()
}
$listener.Stop()

Diese einfache Version hat natürlich ein paar Einschränkungen:

  • Unvollständige Exit-Routing
    Es ist wichtig am Ende den Listener auch wieder zu stoppen. Wenn Sie das Skript mit "CTRL-C einfach abbrechen könnten, dann bleibt der Listener belegt und aktiv, bis die PowerShell geschlossen wird. Daher verhindert das Skript einen CTRL und beendet sich nach einem Tastendruck nach dem nächsten verarbeiteten Request.
  • Sicherheit (SSL/Auth)
    Dieser ganz einfache Code enthält keine Funktion für eine Verschlüsselung per SSL oder Authentifizierung. Wer hier aktiv werden will, sollte vielleicht doch besser eine Applikation im IIS entwickeln. Das Rad muss man ja nicht mehrfach erfinden.
  • Serielle Abarbeitung
    Dieses Beispielcode arbeitet einen Request nach dem anderen ab. Es ist eine reine serielle Abarbeitung und skaliert also nicht gut, wenn viele parallele Clients mit mehreren Threads Daten abrufen. Ein "lang dauernder Prozess" lähmt also andere nachfolgende Prozesse

Aber das Ziel ist ja nicht gleich einen Apache oder IIS-Wettbewerber zu entwickeln, sondern erst mal einen einfachen Service zu bauen, der per HTTP Daten annehmen und ausliefern kann. "Einfache" Beispiele gibt es hierzu genug.

httplistener RequestObject und GetContext()

Solange Sie noch keinen Request mit "$listener.GetContext()" erwarten, lauscht der Listener zwar schon aber nach wenigen Sekunden kommt dann doch ein  "Verbindung verweigert. Sobald sie aber einmal ein "GetContext()" gemacht haben, dann werden alle folgende Verbindungen angenommen und in einer Warteschlange abgelegt. Die Clients werden solange hingehalten. Dafür zuständig ist der "Timeout Manager. Hier die Defaults:

PS C:> $listener.TimeoutManager

EntityBody            : 00:00:00
DrainEntityBody       : 00:00:00
RequestQueue          : 00:00:00
IdleConnection        : 00:00:00
HeaderWait            : 00:00:00
MinSendBytesPerSecond : 0

Das mit GetContext() erhaltene Objekte hat neben einem User nur zwei Eigenschaften, die ihrerseits wieder Objekte vom Typ "System.Net.HttpListenerRequest" und "System.Net.HttpListenerResponse" sind:

Hier ein Beispiel-Request

PS C:\> $context = $listener.GetContext() 

PS C:\> $context | fl *

Request  : System.Net.HttpListenerRequest
User     :
Response : System.Net.HttpListenerResponse


PS C:\> $context.Request

AcceptTypes            :
UserLanguages          :
Cookies                : {}
ContentEncoding        : System.Text.UTF8Encoding+UTF8EncodingSealed
ContentType            : text/xml; charset=utf-8
IsLocal                : True
IsWebSocketRequest     : False
KeepAlive              : True
QueryString            : {}
RawUrl                 : /autodiscover/autodiscover.xml
UserAgent              : test-autodweiche/1.0
UserHostAddress        : 192.168.178.91:80
UserHostName           : 192.168.178.91
UrlReferrer            :
Url                    : http://192.168.178.91/autodiscover/autodiscover.xml
ProtocolVersion        : 1.1
ClientCertificateError :
RequestTraceIdentifier : 00000000-0000-0000-f500-0080000000fe
ContentLength64        : 354
Headers                : {Content-Length, Content-Type, Host, User-Agent}
HttpMethod             : POST
InputStream            : System.Net.HttpRequestStream
IsAuthenticated        : False
IsSecureConnection     : False
ServiceName            :
TransportContext       : System.Net.HttpListenerRequestContext
HasEntityBody          : True
RemoteEndPoint         : 192.168.178.91:8139
LocalEndPoint          : 192.168.178.91:80

Der "Inputstream" kann übrigens nur einmal gelesen werden. Sie sollten die Ergebnisse daher speicher, wenn Sie diese mehrfach parsen wollen.

Das "Context-Objekt" hat auch eine "Response"-Eigenschaft, die Sie mit Werten füllen sollten, ehe Sie die Verarbeitung mit folgender Zeile beenden.

$context.Response.OutputStream.Close()

Die Variable ist danach immer noch instanziert aber sie können natürlich nicht mehr viel damit anfangen, da kein Client drauf wartet.

Leider gibt meines Wissen leider keinen Weg den Listener zu fragen, wie viele ausstehende Requests derzeit in der Warteschlange sind noch den Listener zu starten und ohne Ergebnis früher zurückkommen zu lassen.

HTTPListener mit BeginGetContext

Den einfachen Beispielen ist alles gemeinsam, dass Sie mit "$listener.GetContext()" endlos auf einen Request warten und dann diesen erst Abarbeiten, ehe Sie den nächsten Request bedienen. Wenn Sie Skript einfach abbrechen, bleibt der Listener weiter aktiv und blockiert den weiteren Start, bis Sie die Powershell-Session schließen und neu starten. Zudem werden alle Requests streng "Sequentiell" abgearbeitet. Das ist natürlich beides nicht optimal aber der Einfachheit geschuldet. Es geht aber auch anders, denn über die Methode "BeginGetContext()" ist eine asynchrone Verarbeitung möglich. Sie müssen dann aber eine "CallBack"-Funktion hinterlegen, die beim Eintreffen eines Requests ausgeführt wird.

Damit steigt der Anspruch an den Code, z:B. wie die konkurrierende Zugriffe auf gleiche Inhalte synchronisieren und sich gegen "Überlastungen" wehren. Zudem sollten Sie den Unterschied zwischen "Multithreading" und "Asynchroner Verarbeitung mittels Callbacks verstehen.

Per Callback kann ich Code hinterlegen, der von einer Funktion aufgerufen wird. Allerdings ist das keine parallele Verarbeitung in einem eigenen Prozess oder Thread. PowerShell wartet einfach den aktuellen Befehl ab, um dann die Callback-Routine durchzuführen. Diese hat aber ihre eigene Laufzeitumgebung, d.h. hat keinen Durchgriff auf ihre Variablen, Sie müssen schon irgendwie eine "Übergabe" organisieren. Alles nicht ganz einfach aber ich habe auch hierfür ein "Sample" bereitgestellt:

powershell/sample-webserverasync.ps1.txt
Einfach in einer PowerShell als Admin aufrufen und per Browser auf http://localhost:8080 gehen. Mit http://localhost:8080/date sehen Sie das Computerdatum und mit http://localhost:8080/quit beenden Sie das Skript. Ein Tastendruck tut es aber auch. Damit sehen Sie, dass die "Hauptschleife" durchaus etwas tun kann

Schön ist es dennoch nicht, denn das Hauptprogramm läuft auch hier in einer Endlosschleife. Besser wäre ein "Idle"-Kommando, um auf den Callback oder einen Timeout zu warten.

Vermeiden Sie aber unbedingt ein "Start-Sleep" einzusetzen, denn der CallBack wird immer erst nach dem Ende das aktuell laufenden Kommandos ausgeführt. Es ist kein Multitasking oder Multithreading

Grenzen von HTTPListener

Die HTTPListener-Klasse ist eine schöne einfache Option per PowerShell eine Schnittstelle anzubieten, mit der ich Aktionen übergeben kann. Sie kommt aber ohne aufwändige Programmierung nicht an einen "richtigen" Webserver heran. Für den Notfall könnten Sie sogar per ASP-Seite einfach ein Skript aufrufen.

Die Eingabe werden erst in einem Webformular abgefragt, welches dann eine ASP-Seite direkt ausführt:

<form action="RunPowershell.asp" method="post">
   <input type="textbox" name="Number"/>
   <input type="submit"/>
</form>

Die dann die RunPowershell.asp mit den Parametern aufruft.

<%
   Dim phoneNumber
   phoneNumber = Request.Form("phoneNumber")
  Server.Execute("c:\psasp\powershell.ps1 -Rufnummer " & Number)
%>

Hier wird dann jedes Mal ein PowerShell-Prozess im Context des Webservers gestartet.

Das ist nicht schnell aber vor allem unsicher.

Da ist mein Ansatz von ReportWeb noch besser, bei dem eine ASP-Seite einen "Job" in Auftrag gibt, der dann von einem Worker mit anderen Rechten ausgeführt wird und sich Gedanken über Throttling u.a. macht. Wobei das dann alles nicht mehr weit von der alten "CGI-Schnittstelle" entfernt ist.

Eine interessante kommerzielle Option ist PowerShell.ASP

Eine andere Option wäre die Nutzung anderer Frameworks, um Powershell oder andere Skripte per WebServer einfach zu starten

Polaris und PSHTML

Natürlich können Sie nun einen Webserver selbst in Powershell schreiben. Aber auch hier gibt es schon fertige Lösungen, die sich um die meisten Dinge alleine kümmern. Exemplarisch können Sie dazu Polaris und PSHTML anschauen.

Stéphane van Gulick - Creating and hosting beautiful websites with PSHTML & Polaris
https://www.youtube.com/watch?v=X6ZtS7rWQ9M

Es gibt noch einige andere dieser "Mini Frameworks"

Weitere Links