Dienstag, 10. Juli 2018

Remote Desktop Connection Manager in der paedML Linux

Ich wurde gerade auf den Remote Desktop Connection Manager von Microsoft aufmerksam gemacht. Es ist eine Verwaltungssoftware für Remotedesktop Verbindungen (RDP) und bietet die Möglichkeit verschiedene RDP Verbindungen nach Gruppen sortiert in einem User Interface darzustellen und Verbindungen per Mausklick zu öffnen. Um diese Nutzen zu können, muss man mit Opsi das Paket rdp-zugriff auf allen Benötigten Clients auszurollen.
Mit dem Manager ist es nun möglich alle Aufgaben, welche lokal am Client per Adminaccout erledigt werden müssen, bequem per Remote Verbindung durchzuführen. Vor allem zur Installation von Programmen ohne Opsi-Pakete kann dies von großem Nutzen sein.

Da die paedML-Linux immer die gleichen Standardcontainer verwendet habe ich daher ein Kleines Skript zusammengestellt, welches die per Schulkonsole angelegte Computerraumstruktur direkt in den Manager importiert. Das Powershell Skript erstellt dann eine Datei "schule.rdg", welche direkt im Manager geöffnet werden kann.
Das Skript verwendet die hier zur Verfügung gestellten Methoden zum Anlegen der Struktur, vielen Dank an den Autor. Zum Ausführen das Skript in die Powershell kopieren und ausführen. Der Dateipfad/Name lässt sich über $pfad anpassen:

$pfad = ".\schule.rdg"
function New-RDCManFile
{
  Param(
    [Parameter(Mandatory = $true)]
    [String]$FilePath,
    
    [Parameter(Mandatory = $true)]
    [String]$Name
  )
  BEGIN
  {
    [string]$template = @' 
<?xml version="1.0" encoding="utf-8"?> 
<RDCMan programVersion="2.7" schemaVersion="3"> 
  <file> 
    <credentialsProfiles /> 
    <properties> 
      <expanded>True</expanded> 
      <name></name> 
    </properties> 
  </file> 
  <connected /> 
  <favorites /> 
  <recentlyUsed /> 
</RDCMan> 
'@ 
    $FilePath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($FilePath)
    if(Test-Path -Path $FilePath)
    {
      Write-Error -Message 'File Already Exists'
    }
    else
    {
      $xml = New-Object -TypeName Xml
      $xml.LoadXml($template)
    }
  }
  PROCESS
  {
    $File = (@($xml.RDCMan.file.properties)[0]).Clone()
    $File.Name = $Name
    
    $xml.RDCMan.file.properties |
    Where-Object -FilterScript {
      $_.Name -eq ''
    } |
    ForEach-Object -Process {
      [void]$xml.RDCMan.file.ReplaceChild($File,$_)
    }
  }
  END
  {
    $xml.Save($FilePath)
  }
}
function New-RDCManGroup
{
  Param(
    [Parameter(Mandatory = $true)]
    [String]$FilePath,
    
    [Parameter(Mandatory = $true)]
    [String]$Name
  )
  BEGIN
  {
    $FilePath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($FilePath)
    if(Test-Path -Path $FilePath)
    {
      $xml = New-Object -TypeName XML
      $xml.Load($FilePath)
    } 
    else
    {
      Write-Error -Exception $_.Exception
      throw $_.Exception
    }
  }
  PROCESS
  {
    $group = $xml.CreateElement('group')
    $grouproperties = $xml.CreateElement('properties')      
    $groupname = $xml.CreateElement('name')
    $groupname.set_InnerXML($Name)     
    $groupexpanded = $xml.CreateElement('expanded')
    $groupexpanded.set_InnerXML('False')      
    [void]$grouproperties.AppendChild($groupname)
    [void]$grouproperties.AppendChild($groupexpanded) 
    [void]$group.AppendChild($grouproperties)
    [void]$xml.RDCMan.file.AppendChild($group)
  }
  END
  {
    $xml.Save($FilePath)
  }
}
function New-RDCManServer
{
  Param(
    [Parameter(Mandatory = $true)]
    [String]$FilePath,   
    [Parameter(Mandatory = $true)]
    [String]$GroupName,
    [Parameter(Mandatory = $true)]
    [String]$Server,
    [Parameter(Mandatory = $true)]
    [String]$DisplayName
  )
  BEGIN
  {
    $FilePath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($FilePath)
    if(Test-Path -Path $FilePath)
    {
      $xml = New-Object -TypeName XML
      $xml.Load($FilePath)
    } 
    else
    {
      Write-Error -Exception $_.Exception
      throw $_.Exception
    }
  }
  PROCESS
  {
    $ServerNode = $xml.CreateElement('server')
    $serverproperties = $xml.CreateElement('properties')
    $servername = $xml.CreateElement('name')
    $servername.set_InnerXML($Server)   
    $serverdisplayname = $xml.CreateElement('displayName')
    $serverdisplayname.set_InnerXML($DisplayName) 
    [void]$serverproperties.AppendChild($servername)
    [void]$serverproperties.AppendChild($serverdisplayname)
    [void]$ServerNode.AppendChild($serverproperties)
    $group = @($xml.RDCMan.file.group) | Where-Object -FilterScript {
      $_.properties.name -eq $groupname
    } 
    [void]$group.AppendChild($ServerNode)
  }
  END
  {
    $xml.Save($FilePath)
  }
}
New-RDCManFile -FilePath $pfad -Name Schule 
New-RDCManGroup -FilePath $pfad -Name "Admin"
New-RDCManServer -FilePath $pfad -DisplayName "AdminVm" -Server "AdminVm" -Group "Admin"
[ADSI]$domain = "LDAP://CN=raeume,CN=groups,OU=schule, DC=paedml-linux,DC=lokal"
$alleGruppen = ($domain.Children.distinguishedName | ForEach-Object {[ADSI]"LDAP://$_"}).cn
foreach($gruppe in $alleGruppen){
    New-RDCManGroup -FilePath $pfad -Name $gruppe
    $pcsInGruppe = (New-Object System.DirectoryServices.DirectoryEntry((New-Object System.DirectoryServices.DirectorySearcher("(&(objectCategory=Group)(name=$($gruppe)))")).FindOne().GetDirectoryEntry().Path)).member | % { (New-Object System.DirectoryServices.DirectoryEntry("LDAP://"+$_)) } | Sort-Object sAMAccountName | SELECT @{name="User Name";expression={$_.Name}},@{name="User sAMAccountName";expression={$_.sAMAccountName}}
        foreach($client in $pcsInGruppe){
            write-host $client.'User Name' "wird zu" $gruppe "hinzugefügt"
            $client = $client.'User Name'
            New-RDCManServer -FilePath $pfad -DisplayName $client -Server $client -Group $gruppe
        }
}
Da die Schulstruktur nun angelegt ist kann die erstellte Datei per Doppelklick im Remote Desktop Connection Manager geöffnet werden. Um mehrere Client-PCs verwalten zu können kann es sinnvoll sein zeitweise das Admin Passwort zu hinterlegen. Damit sind die gewünschten PCs nur noch einen Doppelklick von der Verwaltung entfernt. Klicken Sie Dazu mit der rechten Maustaste auf
"Schule" --> "Properties"



Dort wählen Sie die Logon Credentials, deaktivieren "Inherit..." und können nun das Passwort hinterlegen und mit OK bestätigen. Über "File" --> "save schule.rdg" kann das Passwort (verschlüsselt) auch auf dauer in der Datei hinterlegt werden.


Wenn Sie mit dem Administrieren der Remote-PCs fertig sind kann es sinnvoll sein das Passwort an dieser Stelle wieder zu entfernen, da diese Datei den Zugriff auf alle Client-PCs der Schule ermöglicht.

Montag, 2. Juli 2018

Opsi mit Powershell steuern

Der Opsi Config Editor ist ein sehr mächtiges Werkzeug. In meiner Schule haben wir allerdings über 300 Clients was dem Editor zu schaffen macht. Das setzen von Produktkonfigurationen für ganze PC-Räume kann daher etwas an den Nerven nagen.
Opsi bietet aber auch eine Web-API für die Steuerung per Skript.
Ich werde hier auf deren Steuerung per Powershell eingehen.

Um einen Befehl an Opsi zu senden verwende ich die "Invoke-RestMethod" Cmdlet.
Dieser muss die Adresse von Opsi, eine Authentifizierung und der Opsi-Aufruf mitgegeben werden.
Invoke-RestMethod -uri $Adresse -headers $Benutzer -method post -body $Aufruf
Die Adresse ist ein String mit der Adresse der API,
$Adresse = 'https://10.1.0.2:4447/rpc'
Für die Authentifizierung kann der Administrator oder der schoolopsiadmin verwendet werden. Das Passwort des letzteren ist auf dem Server in /etc/schoolopsiadmin.secret abgelegt. Leider gibt es meines Wissens keine Möglichkeit das Passwort per Powershell abzufragen, daher liegt das Passwort in meinem Skript zusätzlich in "H:\Skripte\opsiadmin.txt".

$pair = "$('schoolopsiadmin'):$(Get-Content H:\Skripte\opsiadmin.txt)"
$encodedCreds = ([System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($pair)))
$Benutzer = @{Authorization = "Basic $encodedCreds"}
Mit -method post wird angegeben, dass etwas an den Server gesendet wird.
Letztlich folgt der Body. Dieser Parameter ist im JSON Format zu übergeben und kann daher direkt als Powershell-Ojekt erstellt werden und dann zu JSON übersetzt werden. Um Befehle zu testen kann man den interaktiven Modus der Opsi-Api über https://10.1.0.2:4447/interface verwenden. Da die meisten Aufrufe Attribute und Filter erlauben hier eine Vorlage:
$Aufruf = ConvertTo-Json(@{ method = "Methode";params = @(@("optionalerParameter1", "oParam2"), @{FilterAttribut1 = "Wert1", FilterA2 = "Wert2"});id = 1}))
In der Praxis kann dies Um auf dem Client  pc1 das Paket 7zip auf setup zu stellellen:
$Aufruf = ConvertTo-Json( @{ method = "setProductActionRequest"; params = @("7zip", "pc1.paedml-linux.lokal", "setup"); id = 1})
Um die Installierten Pakete auf pc1 von Opsi zu erfragen
$Aufruf = ConvertTo-Json(@{ method = "productOnClient_getObjects";params = @(@(), @{clientId = "pc1.paedml-linux.lokal"});id = 1}))
Natürlich können anstelle von Texten auch Variablen benutzt werden.
Um bei einem Computerraum alle installierten Produkte zu aktualisieren muss eine Abfrage an das Opsi-Depot gestellt werden und die Paketversionen mit denen auf den Clients vergleichen werden. Dies kann für alle Computer aus einer Raumguppe durchgeführt werden. Das fertige Skript könnte z.B. so aussehen:
$computerraum= 'schule-raum1'
$nachInstallationHerunterfahren=$true
$ClientListe=((New-Object System.DirectoryServices.DirectoryEntry((New-Object System.DirectoryServices.DirectorySearcher("(&(objectCategory=Group)(name=$($computerraum)))")).FindOne().GetDirectoryEntry().Path)).member | % { (New-Object System.DirectoryServices.DirectoryEntry("LDAP://"+$_)) } | Sort-Object sAMAccountName | SELECT @{name="User Name";expression={$_.Name}},@{name="User sAMAccountName";expression={$_.sAMAccountName}})."User Name"
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = {$true}
$nichtUpdaten = {"ms_office", "set-registry-keys", "rdp-zugriff", "shutdownwanted", "windomain", "windows10-upgrade", "ms-sql-2012ee"}
#Login Variablen
$urlJSON = 'https://10.1.0.2:4447/rpc'
$user = "schoolopsiadmin"
$pass = Get-Content H:\Skripte\opsiadmin.txt
$pair = "$($user):$($pass)"
$encodedCreds = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($pair))
$basicAuthValue = "Basic $encodedCreds"
$Headers = @{
    Authorization = $basicAuthValue
}
Write-Host "Frage Depot ab"
$Depot = (Invoke-RestMethod -uri $urlJSON -Headers $Headers -method post -body (ConvertTo-Json(@{ method = "productOnDepot_getObjects";params = @();id = 1}))).result
foreach($zielClient in $ClientListe){
    $zielClient = ($zielClient+=".paedml-linux.lokal").ToLower()
    Write-Host "Frage Client" $zielClient "ab"
    $Client = (Invoke-RestMethod -uri $urlJSON -Headers $Headers -method post -body (ConvertTo-Json(@{ method = "productOnClient_getObjects";params = @(@(), @{clientId = $zielClient});id = 1}))).result
    #ProductOnDepot  - $pd
    #ProductOnClient - $pc
    $updateFlag=$false
    foreach($pd in $Depot){
        foreach($pc in $Client){
            if(($pd.productId -eq $pc.productId) -and ($pc.installationStatus -eq "installed") -and ($pc.productType -eq "LocalbootProduct") -and (-not($nichtUpdaten -match $pc.productId)) -and ((-not $pc.productId.StartsWith("opsi"))-or ($pc.productId -eq "opsi-client-agent")-or ($pc.productId -eq "opsi-configed"))){
                Write-Host
                Write-Host "Client:" $zielClient
                Write-Host $pd.productId -ForegroundColor Yellow
                Write-Host "    Client Version: "$pc.productVersion
                Write-Host "    Depot  Version: "$pd.productVersion
                if(($pc.productVersion -eq $pd.productVersion)-and ($pc.packageVersion -eq $pd.packageVersion)){
                    Write-Host "--> kein Update notwendig" -ForegroundColor Green
                }
                else{
                    $updateFlag=$true
                    if((($pc.productVersion -eq $pd.productVersion)) -and ($pc.packageVersion -ne $pd.packageVersion)){Write-Host "--> neues Paket vorhanden, " -ForegroundColor Red}
                    Write-Host "--> neue Version wird auf setup gesetzt. " -ForegroundColor Red
                    $Produkt = $pd.productId
                    #$strAction= (ConvertTo-Json(@{ method = "setProductActionRequest";params = @($Produkt, $zielClient, "setup");id = 1}))
                    $ignoreOutput = Invoke-RestMethod -uri $urlJSON -Headers $Headers -method post -body (ConvertTo-Json(@{ method = "setProductActionRequest";params = @($Produkt, $zielClient, "setup");id = 1}))
                }
            }
        }
    }
    if($updateFlag){
        if($nachInstallationHerunterfahren){
            write-host "Nach Installation" $zielClient "herunterfahren"
            $ignoreOutput = Invoke-RestMethod -uri $urlJSON -Headers $Headers -method post -body (ConvertTo-Json(@{ method = "setProductActionRequest";params = @("shutdownwanted", $zielClient, "once");id = 1}))
        }
        write-host "Update wird gestartet"
        $ignoreOutput = Invoke-RestMethod -uri $urlJSON -Headers $Headers -method post -body (ConvertTo-Json(@{ method = "hostControlSafe_fireEvent";params = @("on_demand", $zielClient);id = 1}))
    }
    else{Write-Host "Für" $zielClient "wurden keine Updates gefunden."}
}
Das Skript sucht zuerst alle Clients des PC-Raumes, welcher unter $computerraum angegeben wird. Nun prüft das Skript jeden Computer ob die Versionen installierter Programme von denen im Depot abweichen. Falls ja, werden die Produkte auf "setup" gesetzt und eine Installation "on_demand" ausgeführt. Ebenso wird "shutdownwanted auf "once" gesetzt, falls dies nicht mit der Variable $nachInstallationHerunterfahren=$false unterbunden wird.
D.h. das Skript arbeitet alle PCs des Raumes ab, startet die Installation und fährt die PCs im Anschluss herunter.