<# .SYNOPSIS KDS Install Agent - Einrichtung von Windows-11-Notebooks per Web-/Desktop-Oberflaeche. .PARAMETER Port HTTP-Port (Standard 8090). .PARAMETER Remote Oberflaeche zusaetzlich im Netzwerk bereitstellen (Token-geschuetzt, Firewall-Regel wird angelegt). .PARAMETER NoWindow Kein Desktop-Fenster oeffnen (nur Web-Server). #> param( [int]$Port = 8090, [switch]$Remote, [switch]$NoWindow ) $ErrorActionPreference = 'Stop' $global:KdsRoot = $PSScriptRoot # --- Adminrechte sicherstellen ---------------------------------------------------- $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) if (-not $isAdmin) { $a = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "`"$PSCommandPath`"", '-Port', $Port) if ($Remote) { $a += '-Remote' } if ($NoWindow) { $a += '-NoWindow' } Start-Process powershell.exe -Verb RunAs -ArgumentList $a exit } . (Join-Path $PSScriptRoot 'lib\Functions.ps1') $Host.UI.RawUI.WindowTitle = 'KDS Install Agent' $LogDir = Get-KdsDataDir "logs\$env:COMPUTERNAME" $global:KdsLogFile = Join-Path $LogDir 'agent.log' $Token = -join ((48..57) + (97..122) | Get-Random -Count 24 | ForEach-Object { [char]$_ }) # --- Job-Verwaltung (sequenzielle Queue, damit sich Installer nicht blockieren) --- $Jobs = [ordered]@{} $Queue = New-Object System.Collections.Queue $JobSeq = 0 $JobRunner = { param($Root, $Fn, $Json, $LogFile, $ResultFile) $ErrorActionPreference = 'Stop' $global:KdsRoot = $Root $global:KdsLogFile = $LogFile . (Join-Path $Root 'lib\Functions.ps1') $data = $Json | ConvertFrom-Json try { $r = & $Fn $data if ($null -ne $r) { ConvertTo-Json -InputObject $r -Depth 8 | Set-Content -Path $ResultFile -Encoding UTF8 } Write-Log 'Aufgabe beendet.' 'OK' } catch { Write-Log "ABBRUCH: $($_.Exception.Message)" 'ERROR' throw } } function Add-KdsJob { param([string]$Title, [string]$Fn, $Data) $script:JobSeq++ $id = '{0:D3}' -f $script:JobSeq $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' $entry = [pscustomobject]@{ id = $id; title = $Title; fn = $Fn; state = 'Wartet'; created = (Get-Date).ToString('HH:mm:ss') log = (Join-Path $LogDir "$stamp-$id-$Fn.log"); result = (Join-Path $LogDir "$stamp-$id-$Fn.json") json = (ConvertTo-Json -InputObject $Data -Depth 10 -Compress); job = $null } $script:Jobs[$id] = $entry $script:Queue.Enqueue($id) Write-Log "Aufgabe $id eingereiht: $Title" Update-KdsJobs return $id } function Update-KdsJobs { $running = $false foreach ($e in $script:Jobs.Values) { if ($e.job) { switch ($e.job.State) { 'Running' { $e.state = 'Laeuft'; $running = $true } 'Completed' { $e.state = if ((Test-Path $e.log) -and (Select-String -Path $e.log -Pattern '\[ERROR\]' -Quiet)) { 'Fertig (mit Fehlern)' } else { 'Fertig' } } 'Failed' { $e.state = 'Fehlgeschlagen' } 'Stopped' { $e.state = 'Abgebrochen' } } } } if (-not $running -and $script:Queue.Count -gt 0) { $e = $script:Jobs[$script:Queue.Dequeue()] Set-Content -Path $e.log -Value "=== $($e.title) ($env:COMPUTERNAME, $(Get-Date)) ===" -Encoding UTF8 $e.job = Start-Job -ScriptBlock $JobRunner -ArgumentList $global:KdsRoot, $e.fn, $e.json, $e.log, $e.result $e.state = 'Laeuft' } } function Read-SharedText { # Liest Dateien, die ein Job gerade beschreibt (Get-Content wuerde ETS-Properties anhaengen, die ConvertTo-Json aufblaehen) param([string]$Path) if (-not (Test-Path $Path)) { return '' } $fs = [IO.File]::Open($Path, 'Open', 'Read', 'ReadWrite') try { $sr = New-Object IO.StreamReader($fs, [Text.Encoding]::UTF8); return [string]$sr.ReadToEnd() } finally { $fs.Close() } } function Get-KdsJobView { param($e, [switch]$Full) $o = [ordered]@{ id = $e.id; title = $e.title; state = $e.state; created = $e.created } if ($Full) { $o.log = Read-SharedText $e.log $r = Read-SharedText $e.result # Elemente entpacken, sonst serialisiert PS 5.1 das Array als {value, Count} $o.result = if ($r) { @(($r | ConvertFrom-Json) | ForEach-Object { $_ }) } else { $null } } [pscustomobject]$o } # --- HTTP-Helfer ----------------------------------------------------------------- $Mime = @{ '.html' = 'text/html; charset=utf-8'; '.js' = 'application/javascript; charset=utf-8'; '.css' = 'text/css; charset=utf-8' '.json' = 'application/json; charset=utf-8'; '.png' = 'image/png'; '.ico' = 'image/x-icon'; '.svg' = 'image/svg+xml'; '.xml' = 'application/xml; charset=utf-8' } function Send-Response { param($Ctx, [byte[]]$Bytes, [string]$Type = 'application/json; charset=utf-8', [int]$Code = 200) $r = $Ctx.Response $r.StatusCode = $Code $r.ContentType = $Type $r.Headers['Cache-Control'] = 'no-store' $r.ContentLength64 = $Bytes.Length $r.OutputStream.Write($Bytes, 0, $Bytes.Length) $r.OutputStream.Close() } function Send-Json { param($Ctx, $Obj, [int]$Code = 200) $json = if ($null -eq $Obj) { 'null' } else { ConvertTo-Json -InputObject $Obj -Depth 10 -Compress } Send-Response $Ctx ([Text.Encoding]::UTF8.GetBytes($json)) -Code $Code } function Read-Body { param($Ctx) $sr = New-Object IO.StreamReader($Ctx.Request.InputStream, [Text.Encoding]::UTF8) $txt = $sr.ReadToEnd(); $sr.Close() if ($txt) { return $txt | ConvertFrom-Json } return $null } function Get-JsonFile($rel) { $p = Join-Path $global:KdsRoot $rel if (Test-Path $p) { return [IO.File]::ReadAllText($p, [Text.Encoding]::UTF8) | ConvertFrom-Json } return $null } # --- Routing --------------------------------------------------------------------- function Invoke-Api { param($Ctx, [string]$Method, [string]$Path) $q = $Ctx.Request.QueryString $route = "$Method $Path" switch -Regex ($route) { '^GET /api/status$' { return Send-Json $Ctx (Get-SystemInfo) } '^GET /api/catalog$' { return Send-Json $Ctx (Get-JsonFile 'config\apps.json') } '^GET /api/presets$' { return Send-Json $Ctx ([pscustomobject]@{ shortcuts = @((Get-JsonFile 'config\shortcuts.json') | ForEach-Object { $_ }); timezones = @(Get-TimeZone -ListAvailable | ForEach-Object { @{ id = $_.Id; name = $_.DisplayName } }) }) } '^GET /api/winget/search$' { return Send-Json $Ctx @(Search-Winget -Query $q['q'] -Source $(if ($q['source']) { $q['source'] } else { 'winget' })) } '^GET /api/local-installers$' { return Send-Json $Ctx @(Get-LocalInstallers) } '^POST /api/local-installers$' { $b = Read-Body $Ctx; Set-InstallerMeta $b; return Send-Json $Ctx @{ ok = $true } } # Datei-Upload: Body = Rohdaten, ?kind=installers|icons|rdp&name=Datei.ext '^POST /api/upload$' { $dir, $name = Resolve-UploadTarget $q['kind'] $q['name'] New-Item $dir -ItemType Directory -Force | Out-Null $target = Join-Path $dir $name $fs = [IO.File]::Create($target) try { $Ctx.Request.InputStream.CopyTo($fs, 1MB) } finally { $fs.Close() } Write-Log "Upload: $($q['kind'])\$name ($([math]::Round((Get-Item $target).Length / 1MB, 1)) MB)" return Send-Json $Ctx @{ path = $target.Substring($global:KdsRoot.Length + 1); name = $name } } '^GET /api/files$' { $dir = (Resolve-UploadTarget $q['kind'] 'x.ico')[0] return Send-Json $Ctx @(Get-ChildItem $dir -File -ErrorAction SilentlyContinue | Where-Object Name -notlike '.*' | Where-Object Name -ne 'installers.json' | ForEach-Object { [pscustomobject]@{ name = $_.Name; sizeMB = [math]::Round($_.Length / 1MB, 2) } }) } '^DELETE /api/files$' { $dir, $name = Resolve-UploadTarget $q['kind'] $q['name'] Remove-Item (Join-Path $dir $name) -Force -ErrorAction SilentlyContinue if ($q['kind'] -eq 'installers') { Set-InstallerMeta ([pscustomobject]@{ file = "installers\$name"; remove = $true }) } return Send-Json $Ctx @{ ok = $true } } '^POST /api/apps/install$' { $b = Read-Body $Ctx; return Send-Json $Ctx @{ job = (Add-KdsJob "Programme installieren ($(@($b.apps).Count))" 'Install-Apps' $b) } } '^POST /api/apps/upgrade-all$' { return Send-Json $Ctx @{ job = (Add-KdsJob 'Alle Programme aktualisieren (winget)' 'Update-AllApps' @{}) } } '^POST /api/winget/install$' { return Send-Json $Ctx @{ job = (Add-KdsJob 'winget bereitstellen' 'Install-Winget' @{}) } } '^POST /api/office/xml$' { $b = Read-Body $Ctx; return Send-Response $Ctx ([Text.Encoding]::UTF8.GetBytes((New-OfficeXml $b))) 'application/xml; charset=utf-8' } '^POST /api/office/install$' { $b = Read-Body $Ctx $t = @{ online = 'Office installieren'; download = 'Office-Build auf Stick erstellen'; offline = 'Office offline installieren' }[$b.mode] return Send-Json $Ctx @{ job = (Add-KdsJob $t 'Install-Office' $b) } } '^GET /api/office/configs$' { return Send-Json $Ctx @(Get-ChildItem (Join-Path $global:KdsRoot 'office\source') -Directory -ErrorAction SilentlyContinue | ForEach-Object Name) } '^GET /api/shortcuts$' { return Send-Json $Ctx @(Get-PublicShortcuts) } '^POST /api/shortcuts$' { $b = Read-Body $Ctx; return Send-Json $Ctx @{ file = (New-PublicShortcut $b) } } '^DELETE /api/shortcuts$' { Remove-PublicShortcut $q['file']; return Send-Json $Ctx @{ ok = $true } } '^GET /api/icons$' { return Send-Json $Ctx @(Get-ChildItem (Join-Path $global:KdsRoot 'icons') -Include *.ico, *.exe, *.dll -Recurse -ErrorAction SilentlyContinue | ForEach-Object Name) } '^GET /api/rdpfiles$' { return Send-Json $Ctx @(Get-ChildItem (Join-Path $global:KdsRoot 'rdp') -Filter *.rdp -ErrorAction SilentlyContinue | ForEach-Object Name) } '^POST /api/rdp/preview$' { $b = Read-Body $Ctx; return Send-Response $Ctx ([Text.Encoding]::UTF8.GetBytes((New-RdpContent $b))) 'text/plain; charset=utf-8' } '^POST /api/system$' { $b = Read-Body $Ctx; return Send-Json $Ctx @{ job = (Add-KdsJob 'Systemeinstellungen' 'Set-SystemConfig' $b) } } '^POST /api/security$' { $b = Read-Body $Ctx; return Send-Json $Ctx @{ job = (Add-KdsJob 'Sicherheitseinstellungen' 'Set-SecurityConfig' $b) } } '^POST /api/power$' { $b = Read-Body $Ctx; return Send-Json $Ctx @{ job = (Add-KdsJob 'Energieeinstellungen' 'Set-PowerConfig' $b) } } '^GET /api/power$' { $active = (powercfg /getactivescheme) -join '' return Send-Json $Ctx @{ active = $active; hibernate = [bool](Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Power' -Name HibernateEnabled -ErrorAction SilentlyContinue).HibernateEnabled } } '^GET /api/bloatware$' { return Send-Json $Ctx @(Get-BloatwareInventory) } '^POST /api/bloatware$' { $b = Read-Body $Ctx; return Send-Json $Ctx @{ job = (Add-KdsJob 'Bloatware entfernen' 'Remove-Bloatware' $b) } } '^GET /api/bitlocker$' { return Send-Json $Ctx (Get-BitLockerInfo) } '^POST /api/bitlocker$' { $b = Read-Body $Ctx; return Send-Json $Ctx @{ job = (Add-KdsJob "BitLocker: $($b.bitlocker.action)" 'Set-BitLockerConfig' $b) } } '^POST /api/updates/scan$' { $b = Read-Body $Ctx; return Send-Json $Ctx @{ job = (Add-KdsJob "Update-Suche ($($b.type))" 'Find-WindowsUpdates' $b) } } '^POST /api/updates/install$' { $b = Read-Body $Ctx; return Send-Json $Ctx @{ job = (Add-KdsJob "Updates installieren ($(if($b.all){'alle'}else{@($b.ids).Count}))" 'Install-WindowsUpdates' $b) } } '^POST /api/updates/vendor$' { return Send-Json $Ctx @{ job = (Add-KdsJob 'Hersteller-Treiber-Update' 'Invoke-VendorDriverUpdate' @{}) } } '^POST /api/reboot$' { Restart-Computer -Force; return Send-Json $Ctx @{ ok = $true } } # Blaupausen (Standard-Einrichtungen je Kunde) unter blueprints\.json '^GET /api/blueprints$' { return Send-Json $Ctx @(Get-Blueprints) } '^GET /api/blueprints/(?[^/\\]+)$' { $p = Get-BlueprintPath $Matches.n if (-not (Test-Path $p)) { return Send-Json $Ctx @{ error = 'Blaupause nicht gefunden' } 404 } return Send-Response $Ctx ([IO.File]::ReadAllBytes($p)) } '^POST /api/blueprints/(?[^/\\]+)$' { $sr = New-Object IO.StreamReader($Ctx.Request.InputStream, [Text.Encoding]::UTF8); $txt = $sr.ReadToEnd(); $sr.Close() $null = $txt | ConvertFrom-Json # nur gueltiges JSON speichern $p = Get-BlueprintPath $Matches.n New-Item (Split-Path $p) -ItemType Directory -Force | Out-Null [IO.File]::WriteAllText($p, $txt, (New-Object Text.UTF8Encoding($false))) return Send-Json $Ctx @{ ok = $true } } '^DELETE /api/blueprints/(?[^/\\]+)$' { Remove-Item (Get-BlueprintPath $Matches.n) -ErrorAction SilentlyContinue; return Send-Json $Ctx @{ ok = $true } } '^POST /api/run-profile$' { $b = Read-Body $Ctx; return Send-Json $Ctx @{ job = (Add-KdsJob "Einrichtung: $(@($b.customer, $b.name) -ne '' -join ' / ')" 'Invoke-FullProfile' $b) } } '^GET /api/jobs$' { Update-KdsJobs; return Send-Json $Ctx @($script:Jobs.Values | ForEach-Object { Get-KdsJobView $_ }) } '^GET /api/jobs/(?\d+)$' { Update-KdsJobs $e = $script:Jobs[$Matches.id] if (-not $e) { return Send-Json $Ctx @{ error = 'unbekannt' } 404 } return Send-Json $Ctx (Get-KdsJobView $e -Full) } '^DELETE /api/jobs/(?\d+)$' { $e = $script:Jobs[$Matches.id] if ($e -and $e.job) { Stop-Job $e.job } elseif ($e) { $e.state = 'Abgebrochen'; $script:Queue = New-Object System.Collections.Queue (@($script:Queue.ToArray() | Where-Object { $_ -ne $e.id })) } return Send-Json $Ctx @{ ok = $true } } '^POST /api/shutdown$' { Send-Json $Ctx @{ ok = $true }; $script:Running = $false; return } default { return Send-Json $Ctx @{ error = "Unbekannte Route: $route" } 404 } } } # --- Server starten -------------------------------------------------------------- $listener = New-Object System.Net.HttpListener if ($Remote) { $listener.Prefixes.Add("http://+:$Port/") if (-not (Get-NetFirewallRule -DisplayName "KDS Install Agent $Port" -ErrorAction SilentlyContinue)) { New-NetFirewallRule -DisplayName "KDS Install Agent $Port" -Direction Inbound -Protocol TCP -LocalPort $Port -Action Allow -Profile Domain, Private | Out-Null } } else { $listener.Prefixes.Add("http://localhost:$Port/") $listener.Prefixes.Add("http://127.0.0.1:$Port/") } $listener.Start() $localUrl = "http://localhost:$Port/?token=$Token" Write-Host '' Write-Host ' KDS Install Agent laeuft' -ForegroundColor Cyan Write-Host " Lokal: $localUrl" if ($Remote) { Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' } | ForEach-Object { Write-Host " Netzwerk: http://$($_.IPAddress):$Port/?token=$Token" -ForegroundColor Yellow } } Write-Host " Logs: $LogDir" Write-Host ' Beenden: Button "Beenden" in der Oberflaeche oder dieses Fenster schliessen.' -ForegroundColor DarkGray Write-Host '' if (-not $NoWindow) { $edge = @("${env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe", "$env:ProgramFiles\Microsoft\Edge\Application\msedge.exe") | Where-Object { Test-Path $_ } | Select-Object -First 1 if ($edge) { $profileDir = Join-Path $env:TEMP 'KDS-Agent-Edge' Start-Process $edge -ArgumentList "--app=$localUrl", '--window-size=1400,920', "--user-data-dir=`"$profileDir`"", '--no-first-run', '--no-default-browser-check' } else { Start-Process $localUrl } } $WebRoot = Join-Path $PSScriptRoot 'web' $script:Running = $true while ($script:Running -and $listener.IsListening) { $async = $listener.BeginGetContext($null, $null) while (-not $async.AsyncWaitHandle.WaitOne(1000)) { Update-KdsJobs } $ctx = $listener.EndGetContext($async) $path = [Uri]::UnescapeDataString($ctx.Request.Url.AbsolutePath) try { if ($path.StartsWith('/api/')) { $t = $ctx.Request.Headers['X-Token'] if (-not $t) { $t = $ctx.Request.QueryString['token'] } if ($t -ne $Token) { Send-Json $ctx @{ error = 'Ungueltiges Token' } 401; continue } Invoke-Api $ctx $ctx.Request.HttpMethod $path } else { if ($path -eq '/') { $path = '/index.html' } $file = [IO.Path]::GetFullPath((Join-Path $WebRoot $path.TrimStart('/'))) if ($file.StartsWith($WebRoot) -and (Test-Path $file -PathType Leaf)) { $type = $Mime[[IO.Path]::GetExtension($file)]; if (-not $type) { $type = 'application/octet-stream' } Send-Response $ctx ([IO.File]::ReadAllBytes($file)) $type } else { Send-Response $ctx ([Text.Encoding]::UTF8.GetBytes('Not found')) 'text/plain' 404 } } } catch { Write-Log "API-Fehler $path : $($_.Exception.Message)" 'ERROR' try { Send-Json $ctx @{ error = $_.Exception.Message } 500 } catch { } } } $listener.Stop() Get-Job | Stop-Job -ErrorAction SilentlyContinue Write-Host 'Agent beendet.'