KDS Install Agent: Grundversion

PowerShell-HTTP-Server mit Web-/Desktop-Oberflaeche: Programme (winget/Store),
Office-ODT-Konfigurator, Desktop-Icons, System/Sicherheit, Energie, BitLocker,
Windows-/Treiber-Updates, Profile und Job-Queue.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-09 15:48:11 +02:00
co-authored by Claude Opus 5.5
commit f4c2a16612
15 changed files with 2060 additions and 0 deletions
+302
View File
@@ -0,0 +1,302 @@
<#
.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'); 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$' {
$dir = Join-Path $global:KdsRoot 'installers'
$files = @(Get-ChildItem $dir -Recurse -Include *.exe, *.msi -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName.Substring($global:KdsRoot.Length + 1) })
return Send-Json $Ctx $files
}
'^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)
}
'^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/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 } }
'^GET /api/profiles$' {
return Send-Json $Ctx @(Get-ChildItem (Join-Path $global:KdsRoot 'profiles') -Filter *.json -ErrorAction SilentlyContinue | ForEach-Object BaseName)
}
'^GET /api/profiles/(?<n>[\w\-\. ]+)$' {
$p = Join-Path $global:KdsRoot "profiles\$($Matches.n).json"
if (-not (Test-Path $p)) { return Send-Json $Ctx @{ error = 'nicht gefunden' } 404 }
return Send-Response $Ctx ([IO.File]::ReadAllBytes($p))
}
'^POST /api/profiles/(?<n>[\w\-\. ]+)$' {
$sr = New-Object IO.StreamReader($Ctx.Request.InputStream, [Text.Encoding]::UTF8); $txt = $sr.ReadToEnd(); $sr.Close()
$dir = Join-Path $global:KdsRoot 'profiles'; New-Item $dir -ItemType Directory -Force | Out-Null
[IO.File]::WriteAllText((Join-Path $dir "$($Matches.n).json"), $txt, (New-Object Text.UTF8Encoding($false)))
return Send-Json $Ctx @{ ok = $true }
}
'^DELETE /api/profiles/(?<n>[\w\-\. ]+)$' { Remove-Item (Join-Path $global:KdsRoot "profiles\$($Matches.n).json") -ErrorAction SilentlyContinue; return Send-Json $Ctx @{ ok = $true } }
'^POST /api/run-profile$' { $b = Read-Body $Ctx; return Send-Json $Ctx @{ job = (Add-KdsJob "Profil ausfuehren: $($b.name)" 'Invoke-FullProfile' $b) } }
'^GET /api/jobs$' { Update-KdsJobs; return Send-Json $Ctx @($script:Jobs.Values | ForEach-Object { Get-KdsJobView $_ }) }
'^GET /api/jobs/(?<id>\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/(?<id>\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 = $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.'