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:
@@ -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.'
|
||||
Reference in New Issue
Block a user