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
+779
View File
@@ -0,0 +1,779 @@
# KDS Install Agent - Kernfunktionen
# Wird vom Server (Agent.ps1) und von jedem Hintergrund-Job dot-sourced.
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$ProgressPreference = 'SilentlyContinue'
#region Allgemein ----------------------------------------------------------------
function Write-Log {
param([string]$Message, [ValidateSet('INFO', 'OK', 'WARN', 'ERROR')][string]$Level = 'INFO')
$line = '{0} [{1}] {2}' -f (Get-Date -Format 'HH:mm:ss'), $Level, $Message
if ($global:KdsLogFile) {
try { Add-Content -Path $global:KdsLogFile -Value $line -Encoding UTF8 } catch { }
}
Write-Host $line
}
function Get-KdsDataDir {
# Schreibbares Datenverzeichnis: bevorzugt auf dem Stick, sonst ProgramData
param([string]$Name)
$dir = Join-Path $global:KdsRoot $Name
try {
New-Item -ItemType Directory -Path $dir -Force -ErrorAction Stop | Out-Null
$probe = Join-Path $dir '.write-test'
Set-Content -Path $probe -Value 1 -ErrorAction Stop
Remove-Item $probe -Force
return $dir
} catch {
$dir = Join-Path $env:ProgramData "KDS-Agent\$Name"
New-Item -ItemType Directory -Path $dir -Force | Out-Null
return $dir
}
}
function Set-RegValue {
param([string]$Path, [string]$Name, $Value, [string]$Type = 'DWord')
if (-not (Test-Path $Path)) { New-Item -Path $Path -Force | Out-Null }
New-ItemProperty -Path $Path -Name $Name -Value $Value -PropertyType $Type -Force | Out-Null
}
function Test-True($v) { return ($v -eq $true -or $v -eq 'true' -or $v -eq 1) }
function Get-SystemInfo {
$os = Get-CimInstance Win32_OperatingSystem
$cs = Get-CimInstance Win32_ComputerSystem
$bios = Get-CimInstance Win32_BIOS
$cpu = Get-CimInstance Win32_Processor | Select-Object -First 1
$disk = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='$env:SystemDrive'"
$tpm = $null
try { $t = Get-Tpm -ErrorAction Stop; $tpm = @{ present = $t.TpmPresent; ready = $t.TpmReady } } catch { }
$bat = Get-CimInstance Win32_Battery -ErrorAction SilentlyContinue | Select-Object -First 1
[pscustomobject]@{
computerName = $env:COMPUTERNAME
os = $os.Caption
build = "$($os.Version) ($((Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion').DisplayVersion))"
manufacturer = $cs.Manufacturer
model = $cs.Model
serial = $bios.SerialNumber
cpu = $cpu.Name.Trim()
ramGB = [math]::Round($cs.TotalPhysicalMemory / 1GB, 1)
diskFreeGB = [math]::Round($disk.FreeSpace / 1GB, 1)
diskSizeGB = [math]::Round($disk.Size / 1GB, 1)
domain = $cs.Domain
partOfDomain = $cs.PartOfDomain
tpm = $tpm
battery = if ($bat) { "$($bat.EstimatedChargeRemaining) %" } else { $null }
winget = [bool](Get-WingetPath)
user = "$env:USERDOMAIN\$env:USERNAME"
root = $global:KdsRoot
}
}
#endregion
#region winget ------------------------------------------------------------------
function Get-WingetPath {
$c = Get-Command winget.exe -ErrorAction SilentlyContinue
if ($c) { return $c.Source }
$p = Get-ChildItem "$env:ProgramFiles\WindowsApps\Microsoft.DesktopAppInstaller_*_x64__8wekyb3d8bbwe\winget.exe" -ErrorAction SilentlyContinue |
Sort-Object FullName -Descending | Select-Object -First 1
if ($p) { return $p.FullName }
return $null
}
function Install-Winget {
if (Get-WingetPath) { return $true }
Write-Log 'winget nicht gefunden - starte Installation ...' 'WARN'
# 1) Offline-Pakete vom Stick (packages\winget\*.appx / *.msixbundle)
$offline = Join-Path $global:KdsRoot 'packages\winget'
if (Test-Path $offline) {
Get-ChildItem $offline -Filter *.appx | ForEach-Object {
try { Add-AppxPackage -Path $_.FullName -ErrorAction Stop; Write-Log " Abhaengigkeit installiert: $($_.Name)" } catch { }
}
Get-ChildItem $offline -Filter *.msixbundle | ForEach-Object {
try { Add-AppxPackage -Path $_.FullName -ErrorAction Stop; Write-Log " winget installiert (offline): $($_.Name)" 'OK' } catch { Write-Log " $_" 'WARN' }
}
if (Get-WingetPath) { return $true }
}
# 2) Offizieller Weg: Microsoft.WinGet.Client / Repair-WinGetPackageManager
try {
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope AllUsers | Out-Null
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
Install-Module Microsoft.WinGet.Client -Force -Scope AllUsers -AllowClobber
Import-Module Microsoft.WinGet.Client
Repair-WinGetPackageManager -AllUsers -Latest
Write-Log 'winget ueber Repair-WinGetPackageManager installiert' 'OK'
} catch {
Write-Log "Repair-WinGetPackageManager fehlgeschlagen: $_" 'WARN'
# 3) Fallback: direkter Download
try {
$tmp = Join-Path $env:TEMP 'kds-winget'; New-Item $tmp -ItemType Directory -Force | Out-Null
Invoke-WebRequest 'https://aka.ms/Microsoft.VCLibs.x64.14.00.Desktop.appx' -OutFile "$tmp\vclibs.appx" -UseBasicParsing
try { Add-AppxPackage "$tmp\vclibs.appx" } catch { }
Invoke-WebRequest 'https://aka.ms/getwinget' -OutFile "$tmp\winget.msixbundle" -UseBasicParsing
Add-AppxPackage "$tmp\winget.msixbundle"
} catch { Write-Log "winget-Fallback fehlgeschlagen: $_" 'ERROR' }
}
return [bool](Get-WingetPath)
}
function Invoke-Winget {
# Fuehrt winget aus, schreibt bereinigte Ausgabe ins Log, gibt ExitCode zurueck
param([string[]]$Arguments)
$wg = Get-WingetPath
[Console]::OutputEncoding = [Text.Encoding]::UTF8
& $wg @Arguments 2>&1 | ForEach-Object {
$l = ("$_" -split "`r")[-1].TrimEnd()
if ($l -and $l -notmatch '^\s*[-\\|/]\s*$' -and $l -notmatch '[\u2588\u2592]') { Write-Log " $l" }
}
return $LASTEXITCODE
}
function Search-Winget {
param([string]$Query, [string]$Source = 'winget')
$wg = Get-WingetPath
if (-not $wg) { throw 'winget ist nicht installiert (Tab Updates -> winget installieren).' }
if (Get-Module -ListAvailable Microsoft.WinGet.Client) {
try {
Import-Module Microsoft.WinGet.Client -ErrorAction Stop
return @(Find-WinGetPackage -Query $Query -Source $Source -ErrorAction Stop | Select-Object -First 50 |
ForEach-Object { [pscustomobject]@{ name = $_.Name; id = $_.Id; version = $_.Version; source = $Source } })
} catch { }
}
[Console]::OutputEncoding = [Text.Encoding]::UTF8
$out = & $wg search --query $Query --source $Source --accept-source-agreements --disable-interactivity 2>$null
$lines = @($out | ForEach-Object { ("$_" -split "`r")[-1] })
$sep = -1
for ($i = 0; $i -lt $lines.Count; $i++) { if ($lines[$i] -match '^-{5,}') { $sep = $i; break } }
if ($sep -lt 1) { return @() }
# Spaltenpositionen aus der (lokalisierten) Kopfzeile ermitteln
$starts = @([regex]::Matches($lines[$sep - 1], '\S+') | ForEach-Object { $_.Index })
if ($starts.Count -lt 3) { return @() }
$res = foreach ($l in $lines[($sep + 1)..($lines.Count - 1)]) {
if ($l.Length -lt $starts[2]) { continue }
$verEnd = if ($starts.Count -gt 3) { [math]::Min($starts[3], $l.Length) } else { $l.Length }
[pscustomobject]@{
name = $l.Substring(0, $starts[1]).Trim()
id = $l.Substring($starts[1], $starts[2] - $starts[1]).Trim()
version = $l.Substring($starts[2], $verEnd - $starts[2]).Trim()
source = $Source
}
}
return @($res | Where-Object { $_.id } | Select-Object -First 50)
}
#endregion
#region Programme ---------------------------------------------------------------
function Install-Apps {
param($Data)
$apps = @($Data.apps)
if (-not $apps.Count) { Write-Log 'Keine Programme ausgewaehlt.' 'WARN'; return }
$needWinget = @($apps | Where-Object { $_.source -ne 'local' }).Count -gt 0
if ($needWinget -and -not (Install-Winget)) { throw 'winget konnte nicht bereitgestellt werden.' }
if ($needWinget) { Invoke-Winget @('source', 'update') | Out-Null }
$ok = 0; $fail = @()
$n = 0
foreach ($a in $apps) {
$n++
Write-Log "[$n/$($apps.Count)] $($a.name)"
try {
if ($a.source -eq 'local') {
$file = $a.path
if (-not [IO.Path]::IsPathRooted($file)) { $file = Join-Path $global:KdsRoot $file }
if (-not (Test-Path $file)) { throw "Installer nicht gefunden: $file" }
if ($file -like '*.msi') {
$p = Start-Process msiexec.exe -ArgumentList "/i `"$file`" /qn /norestart $($a.args)" -Wait -PassThru
} else {
$p = Start-Process -FilePath $file -ArgumentList $a.args -Wait -PassThru
}
$code = $p.ExitCode
} else {
$src = if ($a.source) { $a.source } else { 'winget' }
$base = @('install', '--id', $a.id, '--exact', '--source', $src, '--silent',
'--accept-package-agreements', '--accept-source-agreements', '--disable-interactivity')
if ($a.override) { $base += @('--override', $a.override) }
$code = Invoke-Winget ($base + @('--scope', 'machine'))
# Kein systemweiter Installer verfuegbar -> ohne Scope erneut
if ($code -eq -1978335216 -or $code -eq -1978335212) {
Write-Log ' Kein Maschinen-Installer, versuche Standard-Scope ...'
$code = Invoke-Winget $base
}
}
# 0 = OK, 3010/1641 = Neustart noetig, -1978335189 = bereits aktuell/installiert
if ($code -in 0, 3010, 1641, -1978335189, -1978335135) { Write-Log " -> OK ($code)" 'OK'; $ok++ }
else { throw "ExitCode $code (0x{0:X8})" -f $code }
} catch {
Write-Log " -> Fehler: $_" 'ERROR'; $fail += $a.name
}
}
Write-Log "Programme: $ok erfolgreich, $($fail.Count) fehlgeschlagen $(if($fail){'(' + ($fail -join ', ') + ')'})" $(if ($fail) { 'WARN' } else { 'OK' })
}
function Update-AllApps {
param($Data)
if (-not (Install-Winget)) { throw 'winget nicht verfuegbar.' }
Write-Log 'Aktualisiere alle Programme ueber winget ...'
$code = Invoke-Winget @('upgrade', '--all', '--silent', '--include-unknown', '--accept-package-agreements', '--accept-source-agreements', '--disable-interactivity')
Write-Log "winget upgrade beendet (ExitCode $code)" 'OK'
}
#endregion
#region Office ------------------------------------------------------------------
function New-OfficeXml {
param($Cfg)
$e = { param($s) [Security.SecurityElement]::Escape([string]$s) }
$langs = @($Cfg.languages | Where-Object { $_ })
if (-not $langs.Count) { $langs = @('MatchOS') }
$langXml = ($langs | ForEach-Object { " <Language ID=`"$(& $e $_)`" />" }) -join "`r`n"
$exclXml = (@($Cfg.exclude) | Where-Object { $_ } | ForEach-Object { " <ExcludeApp ID=`"$(& $e $_)`" />" }) -join "`r`n"
$addAttr = "OfficeClientEdition=`"$($Cfg.arch)`" Channel=`"$(& $e $Cfg.channel)`""
if ($Cfg.sourcePath) { $addAttr += " SourcePath=`"$(& $e $Cfg.sourcePath)`"" }
if (Test-True $Cfg.migrateArch) { $addAttr += ' MigrateArch="TRUE"' }
$products = @()
$pidAttr = if ($Cfg.pidKey) { " PIDKEY=`"$(& $e $Cfg.pidKey)`"" } else { '' }
$products += " <Product ID=`"$(& $e $Cfg.product)`"$pidAttr>`r`n$langXml$(if($exclXml){"`r`n$exclXml"})`r`n </Product>"
foreach ($extra in @($Cfg.extraProducts)) {
if ($extra) { $products += " <Product ID=`"$(& $e $extra)`">`r`n$langXml`r`n </Product>" }
}
$x = @()
$x += "<Configuration ID=`"$([guid]::NewGuid())`">"
$x += " <Add $addAttr>"
$x += $products
$x += ' </Add>'
$x += " <Property Name=`"SharedComputerLicensing`" Value=`"$(if(Test-True $Cfg.sharedComputer){'1'}else{'0'})`" />"
$x += " <Property Name=`"FORCEAPPSHUTDOWN`" Value=`"TRUE`" />"
$x += " <Property Name=`"DeviceBasedLicensing`" Value=`"0`" />"
$x += " <Property Name=`"SCLCacheOverride`" Value=`"0`" />"
if ($Cfg.product -like '*Volume') { $x += " <Property Name=`"AUTOACTIVATE`" Value=`"1`" />" }
$x += " <Updates Enabled=`"$(if(Test-True $Cfg.updates){'TRUE'}else{'FALSE'})`" />"
if (Test-True $Cfg.removeMsi) { $x += ' <RemoveMSI />' }
$x += ' <AppSettings>'
if ($Cfg.company) { $x += " <Setup Name=`"Company`" Value=`"$(& $e $Cfg.company)`" />" }
if (Test-True $Cfg.ooxmlDefault) {
$x += ' <User Key="software\microsoft\office\16.0\common\general" Name="shownfirstrunoptin" Value="1" Type="REG_DWORD" App="office16" Id="L_DisableOptinWizard" />'
}
if (Test-True $Cfg.disableVba) {
foreach ($app in 'word', 'excel', 'powerpoint') {
$x += " <User Key=`"software\policies\microsoft\office\16.0\$app\security`" Name=`"blockcontentexecutionfrominternet`" Value=`"1`" Type=`"REG_DWORD`" App=`"$app`16`" Id=`"L_BlockMacroExecutionFromInternet`" />"
}
}
$x += ' </AppSettings>'
$x += " <Display Level=`"$(if(Test-True $Cfg.showUi){'Full'}else{'None'})`" AcceptEULA=`"TRUE`" />"
$x += '</Configuration>'
return ($x -join "`r`n")
}
function Get-OdtSetup {
$dir = Join-Path $global:KdsRoot 'office'
New-Item $dir -ItemType Directory -Force | Out-Null
$setup = Join-Path $dir 'setup.exe'
if (-not (Test-Path $setup)) {
Write-Log 'setup.exe (Office Deployment Tool) nicht vorhanden - lade von Microsoft CDN ...'
Invoke-WebRequest 'https://officecdn.microsoft.com/pr/wsus/setup.exe' -OutFile $setup -UseBasicParsing
Write-Log 'setup.exe heruntergeladen' 'OK'
}
return $setup
}
function Install-Office {
param($Data)
$cfg = $Data.office
$mode = if ($Data.mode) { $Data.mode } else { 'online' } # online | download | offline
$setup = Get-OdtSetup
$cfgDir = Join-Path $global:KdsRoot 'office\configs'
New-Item $cfgDir -ItemType Directory -Force | Out-Null
if ($mode -in 'download', 'offline') {
$src = Join-Path $global:KdsRoot "office\source\$($cfg.product)-$($cfg.arch)"
New-Item $src -ItemType Directory -Force | Out-Null
$cfg | Add-Member -NotePropertyName sourcePath -NotePropertyValue $src -Force
}
$xml = New-OfficeXml $cfg
$name = if ($Data.name) { $Data.name -replace '[^\w\-]', '_' } else { "$($cfg.product)-$($cfg.arch)" }
$xmlFile = Join-Path $cfgDir "$name.xml"
Set-Content -Path $xmlFile -Value $xml -Encoding UTF8
Write-Log "Konfiguration gespeichert: $xmlFile"
if ($mode -in 'download', 'offline') {
$hasData = Test-Path (Join-Path $cfg.sourcePath 'Office\Data')
if ($mode -eq 'download' -or -not $hasData) {
Write-Log "Lade Office-Installationsdateien nach $($cfg.sourcePath) (kann dauern) ..."
$p = Start-Process $setup -ArgumentList "/download `"$xmlFile`"" -Wait -PassThru -WindowStyle Hidden
if ($p.ExitCode -ne 0) { throw "setup.exe /download ExitCode $($p.ExitCode)" }
Write-Log 'Office-Build auf dem Stick abgeschlossen' 'OK'
}
if ($mode -eq 'download') { return }
}
Write-Log "Installiere $($cfg.product) ($($cfg.arch)-Bit, Kanal $($cfg.channel)) ..."
$p = Start-Process $setup -ArgumentList "/configure `"$xmlFile`"" -Wait -PassThru -WindowStyle Hidden
if ($p.ExitCode -ne 0) { throw "setup.exe /configure ExitCode $($p.ExitCode) - Logs unter $env:TEMP\$env:COMPUTERNAME-*.log" }
Write-Log 'Office installiert' 'OK'
}
#endregion
#region Desktop-Verknuepfungen ---------------------------------------------------
function Get-PublicDesktop { [Environment]::GetFolderPath('CommonDesktopDirectory') }
function Get-PublicShortcuts {
$d = Get-PublicDesktop
$sh = New-Object -ComObject WScript.Shell
@(Get-ChildItem $d -File | Where-Object { $_.Extension -in '.lnk', '.url' } | ForEach-Object {
$target = ''
if ($_.Extension -eq '.lnk') { $target = $sh.CreateShortcut($_.FullName).TargetPath }
else { $m = Select-String -Path $_.FullName -Pattern '^URL=(.*)$' | Select-Object -First 1; if ($m) { $target = $m.Matches[0].Groups[1].Value } }
[pscustomobject]@{ file = $_.Name; name = $_.BaseName; type = $_.Extension.TrimStart('.'); target = $target }
})
}
function Resolve-IconPath {
param([string]$Icon)
if (-not $Icon) { return $null }
$src = $Icon
if (-not [IO.Path]::IsPathRooted($src)) { $src = Join-Path $global:KdsRoot "icons\$Icon" }
if (-not (Test-Path $src)) { return $Icon } # z.B. "C:\Programme\x.exe,0"
# Icons vom Stick lokal ablegen, damit sie nach Abziehen bestehen bleiben
$dst = Join-Path $env:ProgramData 'KDS-Agent\Icons'
New-Item $dst -ItemType Directory -Force | Out-Null
Copy-Item $src $dst -Force
return (Join-Path $dst (Split-Path $src -Leaf))
}
function New-PublicShortcut {
param($S)
$d = Get-PublicDesktop
$safe = ($S.name -replace '[\\/:*?"<>|]', '_').Trim()
if (-not $safe) { throw 'Name fehlt' }
$icon = Resolve-IconPath $S.icon
if ($S.type -eq 'url') {
$file = Join-Path $d "$safe.url"
$c = "[InternetShortcut]`r`nURL=$($S.target)`r`n"
if ($icon) { $c += "IconFile=$icon`r`nIconIndex=0`r`n" }
Set-Content -Path $file -Value $c -Encoding ASCII
} else {
$file = Join-Path $d "$safe.lnk"
$sh = New-Object -ComObject WScript.Shell
$lnk = $sh.CreateShortcut($file)
$lnk.TargetPath = [Environment]::ExpandEnvironmentVariables($S.target)
if ($S.args) { $lnk.Arguments = $S.args }
if ($S.workDir) { $lnk.WorkingDirectory = $S.workDir }
elseif (Test-Path $lnk.TargetPath -PathType Leaf) { $lnk.WorkingDirectory = Split-Path $lnk.TargetPath }
if ($icon) {
$icon = [Environment]::ExpandEnvironmentVariables($icon)
$lnk.IconLocation = if ($icon -match ',-?\d+$') { $icon } else { "$icon,0" }
}
$lnk.Save()
}
Write-Log "Verknuepfung erstellt: $file" 'OK'
return $file
}
function Remove-PublicShortcut {
param([string]$File)
$path = Join-Path (Get-PublicDesktop) (Split-Path $File -Leaf)
if (Test-Path $path) { Remove-Item $path -Force }
}
function Set-DesktopShortcuts {
param($Data)
foreach ($s in @($Data.shortcuts)) {
try { New-PublicShortcut $s | Out-Null } catch { Write-Log "Verknuepfung '$($s.name)': $_" 'ERROR' }
}
}
#endregion
#region System / Sicherheit -----------------------------------------------------
function Set-SystemConfig {
param($Data)
$c = $Data.system
if ($c.timeZone) { Set-TimeZone -Id $c.timeZone; Write-Log "Zeitzone: $($c.timeZone)" 'OK' }
if (Test-True $c.showExtensions) {
# Default-Profil + aktueller Benutzer
Set-RegValue 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' 'HideFileExt' 0
reg load HKU\KdsDefault "$env:SystemDrive\Users\Default\NTUSER.DAT" | Out-Null
reg add 'HKU\KdsDefault\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' /v HideFileExt /t REG_DWORD /d 0 /f | Out-Null
[gc]::Collect(); reg unload HKU\KdsDefault | Out-Null
Write-Log 'Dateiendungen werden angezeigt (inkl. neue Benutzer)' 'OK'
}
if (Test-True $c.disableConsumer) {
Set-RegValue 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\CloudContent' 'DisableWindowsConsumerFeatures' 1
Write-Log 'Consumer-Features / Werbe-Apps deaktiviert' 'OK'
}
if ($c.computerName -and $c.computerName -ne $env:COMPUTERNAME) {
Rename-Computer -NewName $c.computerName -Force
Write-Log "Computer wird nach Neustart umbenannt in '$($c.computerName)'" 'OK'
}
}
function Set-SecurityConfig {
param($Data)
$c = $Data.security
if (Test-True $c.defender) {
try {
Set-MpPreference -DisableRealtimeMonitoring $false -PUAProtection Enabled -MAPSReporting Advanced -SubmitSamplesConsent SendSafeSamples -ErrorAction Stop
Write-Log 'Defender: Echtzeitschutz, PUA-Schutz, Cloud-Schutz aktiv' 'OK'
Update-MpSignature -ErrorAction SilentlyContinue
} catch { Write-Log "Defender: $_ (evtl. Drittanbieter-AV aktiv)" 'WARN' }
}
if (Test-True $c.firewall) {
Set-NetFirewallProfile -Profile Domain, Private, Public -Enabled True
Write-Log 'Firewall fuer alle Profile aktiviert' 'OK'
}
if (Test-True $c.smartScreen) {
Set-RegValue 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System' 'EnableSmartScreen' 1
Set-RegValue 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System' 'ShellSmartScreenLevel' 'Warn' 'String'
Write-Log 'SmartScreen aktiviert' 'OK'
}
if (Test-True $c.disableSmb1) {
try { Disable-WindowsOptionalFeature -Online -FeatureName SMB1Protocol -NoRestart -ErrorAction Stop | Out-Null } catch { }
Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force -ErrorAction SilentlyContinue
Write-Log 'SMBv1 deaktiviert' 'OK'
}
if (Test-True $c.uac) {
$p = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
Set-RegValue $p 'EnableLUA' 1; Set-RegValue $p 'ConsentPromptBehaviorAdmin' 2; Set-RegValue $p 'PromptOnSecureDesktop' 1
Write-Log 'UAC auf hoechste Stufe gesetzt' 'OK'
}
if (Test-True $c.disableAutorun) {
$p = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\Explorer'
Set-RegValue $p 'NoDriveTypeAutoRun' 255; Set-RegValue $p 'NoAutorun' 1
Write-Log 'Autorun/Autoplay deaktiviert' 'OK'
}
if (Test-True $c.lsaProtection) {
Set-RegValue 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' 'RunAsPPL' 1
Write-Log 'LSA-Schutz aktiviert (Neustart noetig)' 'OK'
}
if (Test-True $c.telemetryMin) {
Set-RegValue 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection' 'AllowTelemetry' 1
Write-Log 'Telemetrie auf "Erforderlich" reduziert' 'OK'
}
switch ($c.rdp) {
'enable' {
Set-RegValue 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' 'fDenyTSConnections' 0
Set-RegValue 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' 'UserAuthentication' 1
Enable-NetFirewallRule -DisplayGroup '@FirewallAPI.dll,-28752' -ErrorAction SilentlyContinue
Write-Log 'Remotedesktop aktiviert (mit NLA)' 'OK'
}
'disable' {
Set-RegValue 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' 'fDenyTSConnections' 1
Disable-NetFirewallRule -DisplayGroup '@FirewallAPI.dll,-28752' -ErrorAction SilentlyContinue
Write-Log 'Remotedesktop deaktiviert' 'OK'
}
}
if ($c.localAdminName -and $c.localAdminPassword) {
$pw = ConvertTo-SecureString $c.localAdminPassword -AsPlainText -Force
if (Get-LocalUser -Name $c.localAdminName -ErrorAction SilentlyContinue) {
Set-LocalUser -Name $c.localAdminName -Password $pw
} else {
New-LocalUser -Name $c.localAdminName -Password $pw -PasswordNeverExpires -AccountNeverExpires | Out-Null
}
$grp = (Get-LocalGroup -SID 'S-1-5-32-544').Name
Add-LocalGroupMember -Group $grp -Member $c.localAdminName -ErrorAction SilentlyContinue
Write-Log "Lokaler Admin '$($c.localAdminName)' eingerichtet" 'OK'
}
}
#endregion
#region Energie -----------------------------------------------------------------
function Set-PowerConfig {
param($Data)
$c = $Data.power
$plans = @{ balanced = '381b4222-f694-41f0-9685-ff5bb260df2e'; high = '8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c'; ultimate = 'e9a42b02-d5df-448d-aa00-03f14749eb61' }
if ($c.plan -and $plans[$c.plan]) {
$guid = $plans[$c.plan]
powercfg /setactive $guid 2>$null
if ($LASTEXITCODE -ne 0) {
# Plan ausgeblendet (z.B. Modern Standby) -> duplizieren
$o = powercfg /duplicatescheme $guid 2>$null
if ($o -match '([0-9a-f\-]{36})') { powercfg /setactive $Matches[1] }
}
Write-Log "Energieplan: $($c.plan)" $(if ($LASTEXITCODE -eq 0) { 'OK' } else { 'WARN' })
}
$map = @{ monitorAc = 'monitor-timeout-ac'; monitorDc = 'monitor-timeout-dc'; sleepAc = 'standby-timeout-ac'; sleepDc = 'standby-timeout-dc'; hibernateAc = 'hibernate-timeout-ac'; hibernateDc = 'hibernate-timeout-dc' }
foreach ($k in $map.Keys) {
$v = $c.$k
if ($null -ne $v -and "$v" -ne '') { powercfg /change $map[$k] ([int]$v); Write-Log " $($map[$k]) = $v min" }
}
$actions = @{ nothing = 0; sleep = 1; hibernate = 2; shutdown = 3 }
foreach ($pair in @(@('lidAc', '/setacvalueindex', 'LIDACTION'), @('lidDc', '/setdcvalueindex', 'LIDACTION'),
@('buttonAc', '/setacvalueindex', 'PBUTTONACTION'), @('buttonDc', '/setdcvalueindex', 'PBUTTONACTION'))) {
$v = $c.($pair[0])
if ($v -and $actions.ContainsKey($v)) {
powercfg $pair[1] SCHEME_CURRENT SUB_BUTTONS $pair[2] $actions[$v]
Write-Log " $($pair[2]) $($pair[0]) = $v"
}
}
powercfg /setactive SCHEME_CURRENT
if ($c.hibernate -in 'on', 'off') { powercfg /hibernate $c.hibernate; Write-Log "Ruhezustand: $($c.hibernate)" 'OK' }
if ($c.fastStartup -in 'on', 'off') {
Set-RegValue 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Power' 'HiberbootEnabled' $(if ($c.fastStartup -eq 'on') { 1 } else { 0 })
Write-Log "Schnellstart: $($c.fastStartup)" 'OK'
}
Write-Log 'Energieeinstellungen angewendet' 'OK'
}
#endregion
#region BitLocker ---------------------------------------------------------------
function Get-BitLockerInfo {
$info = [ordered]@{ available = $false; edition = (Get-CimInstance Win32_OperatingSystem).Caption }
try { $t = Get-Tpm -ErrorAction Stop; $info.tpmPresent = $t.TpmPresent; $info.tpmReady = $t.TpmReady } catch { $info.tpmPresent = $false }
if (-not (Get-Command Get-BitLockerVolume -ErrorAction SilentlyContinue)) {
$info.message = 'BitLocker-Cmdlets nicht verfuegbar (Windows Home?)'
return [pscustomobject]$info
}
$info.available = $true
$info.volumes = @(Get-BitLockerVolume | ForEach-Object {
[pscustomobject]@{
mount = $_.MountPoint
type = "$($_.VolumeType)"
status = "$($_.VolumeStatus)"
protection = "$($_.ProtectionStatus)"
percent = $_.EncryptionPercentage
method = "$($_.EncryptionMethod)"
protectors = @($_.KeyProtector | ForEach-Object { "$($_.KeyProtectorType)" })
}
})
return [pscustomobject]$info
}
function Save-RecoveryKey {
param([string]$Mount)
$vol = Get-BitLockerVolume -MountPoint $Mount
$rp = @($vol.KeyProtector | Where-Object KeyProtectorType -eq 'RecoveryPassword')
$dir = Get-KdsDataDir 'bitlocker-keys'
$file = Join-Path $dir ("{0}_{1}_{2}.txt" -f $env:COMPUTERNAME, ($Mount.TrimEnd(':\')), (Get-Date -Format 'yyyyMMdd-HHmm'))
$sn = (Get-CimInstance Win32_BIOS).SerialNumber
$txt = @("BitLocker-Wiederherstellungsschluessel", "Computer: $env:COMPUTERNAME", "Seriennummer: $sn", "Laufwerk: $Mount", "Datum: $(Get-Date)", '')
foreach ($k in $rp) { $txt += "ID: $($k.KeyProtectorId)"; $txt += "Schluessel: $($k.RecoveryPassword)"; $txt += '' }
Set-Content -Path $file -Value $txt -Encoding UTF8
Write-Log "Wiederherstellungsschluessel gespeichert: $file" 'OK'
return $rp
}
function Set-BitLockerConfig {
param($Data)
$c = $Data.bitlocker
$mount = if ($c.mount) { $c.mount } else { $env:SystemDrive }
if (-not (Get-Command Enable-BitLocker -ErrorAction SilentlyContinue)) { throw 'BitLocker ist auf dieser Edition nicht verfuegbar.' }
if ($c.action -eq 'disable') {
Disable-BitLocker -MountPoint $mount | Out-Null
Write-Log "Entschluesselung von $mount gestartet" 'OK'
return
}
if ($c.action -eq 'savekey') { Save-RecoveryKey $mount | Out-Null; return }
if ($c.action -ne 'enable') { return }
$tpm = Get-Tpm
if (-not $tpm.TpmPresent -or -not $tpm.TpmReady) { throw 'TPM nicht vorhanden oder nicht bereit.' }
if ($c.usePin) {
$fve = 'HKLM:\SOFTWARE\Policies\Microsoft\FVE'
Set-RegValue $fve 'UseAdvancedStartup' 1; Set-RegValue $fve 'EnableBDEWithNoTPM' 0
Set-RegValue $fve 'UseTPM' 2; Set-RegValue $fve 'UseTPMPIN' 2; Set-RegValue $fve 'UseTPMKey' 2; Set-RegValue $fve 'UseTPMKeyPIN' 2
if ($c.enhancedPin) { Set-RegValue $fve 'UseEnhancedPin' 1 }
}
$method = if ($c.method) { $c.method } else { 'XtsAes256' }
$vol = Get-BitLockerVolume -MountPoint $mount
if (-not ($vol.KeyProtector | Where-Object KeyProtectorType -eq 'RecoveryPassword')) {
Add-BitLockerKeyProtector -MountPoint $mount -RecoveryPasswordProtector | Out-Null
Write-Log 'Wiederherstellungskennwort-Schutz hinzugefuegt'
}
if ("$($vol.VolumeStatus)" -eq 'FullyDecrypted') {
$p = @{ MountPoint = $mount; EncryptionMethod = $method; ErrorAction = 'Stop' }
if (Test-True $c.usedSpaceOnly) { $p.UsedSpaceOnly = $true }
if (Test-True $c.skipHardwareTest) { $p.SkipHardwareTest = $true }
if ($c.usePin -and $c.pin) {
$p.TpmAndPinProtector = $true; $p.Pin = (ConvertTo-SecureString $c.pin -AsPlainText -Force)
} else { $p.TpmProtector = $true }
Enable-BitLocker @p | Out-Null
Write-Log "BitLocker auf $mount aktiviert ($method)" 'OK'
} else {
# z.B. Geraeteverschluesselung "Warten auf Aktivierung"
Write-Log "Volume-Status: $($vol.VolumeStatus) - ergaenze Schutzvorrichtungen und aktiviere Schutz"
$types = @($vol.KeyProtector | ForEach-Object { "$($_.KeyProtectorType)" })
if ($c.usePin -and $c.pin -and 'TpmPin' -notin $types) {
Add-BitLockerKeyProtector -MountPoint $mount -TpmAndPinProtector -Pin (ConvertTo-SecureString $c.pin -AsPlainText -Force) | Out-Null
} elseif (-not ($types | Where-Object { $_ -like 'Tpm*' })) {
Add-BitLockerKeyProtector -MountPoint $mount -TpmProtector | Out-Null
}
Resume-BitLocker -MountPoint $mount | Out-Null
Write-Log "Schutz auf $mount aktiv" 'OK'
}
$rp = Save-RecoveryKey $mount
foreach ($k in $rp) {
if (Test-True $c.backupAad) {
try { BackupToAAD-BitLockerKeyProtector -MountPoint $mount -KeyProtectorId $k.KeyProtectorId -ErrorAction Stop | Out-Null; Write-Log 'Schluessel in Entra ID gesichert' 'OK' }
catch { Write-Log "Entra-ID-Sicherung fehlgeschlagen: $_" 'WARN' }
}
if (Test-True $c.backupAd) {
try { Backup-BitLockerKeyProtector -MountPoint $mount -KeyProtectorId $k.KeyProtectorId -ErrorAction Stop | Out-Null; Write-Log 'Schluessel im AD gesichert' 'OK' }
catch { Write-Log "AD-Sicherung fehlgeschlagen: $_" 'WARN' }
}
}
if (-not (Test-True $c.skipHardwareTest) -and "$($vol.VolumeStatus)" -eq 'FullyDecrypted') {
Write-Log 'Hardwaretest aktiv: Verschluesselung startet nach dem naechsten Neustart.' 'WARN'
}
}
#endregion
#region Windows Update / Treiber ------------------------------------------------
$script:MUServiceId = '7971f918-a847-4430-9279-4a52d1efe18d'
function New-UpdateSearcher {
$session = New-Object -ComObject Microsoft.Update.Session
$session.ClientApplicationID = 'KDS Install Agent'
$searcher = $session.CreateUpdateSearcher()
try {
# Microsoft Update aktivieren (liefert Treiber + Office/andere MS-Produkte)
$sm = New-Object -ComObject Microsoft.Update.ServiceManager
$sm.AddService2($script:MUServiceId, 7, '') | Out-Null
$searcher.ServerSelection = 3
$searcher.ServiceID = $script:MUServiceId
} catch { Write-Log "Microsoft Update nicht registrierbar, nutze Windows Update: $_" 'WARN' }
return @{ Session = $session; Searcher = $searcher }
}
function Find-WindowsUpdates {
param($Data)
$types = if ($Data.type -eq 'all' -or -not $Data.type) { @('Software', 'Driver') } else { @($Data.type) }
$s = New-UpdateSearcher
$list = @()
foreach ($t in $types) {
Write-Log "Suche nach $(if($t -eq 'Driver'){'Treiber'}else{'Windows'})-Updates ..."
$r = $s.Searcher.Search("IsInstalled=0 and IsHidden=0 and Type='$t'")
foreach ($u in $r.Updates) {
$list += [pscustomobject]@{
id = $u.Identity.UpdateID
title = $u.Title
type = $t
kb = (@($u.KBArticleIDs) -join ',')
sizeMB = [math]::Round($u.MaxDownloadSize / 1MB, 1)
class = if ($t -eq 'Driver') { "$($u.DriverClass)" } else { (@($u.Categories | Select-Object -First 1 | ForEach-Object Name) -join '') }
}
}
Write-Log " $(@($r.Updates).Count) gefunden"
}
return , $list
}
function Install-WindowsUpdates {
param($Data)
$ids = @($Data.ids)
$s = New-UpdateSearcher
Write-Log 'Ermittle Updates ...'
$r = $s.Searcher.Search("IsInstalled=0 and IsHidden=0")
$coll = New-Object -ComObject Microsoft.Update.UpdateColl
foreach ($u in $r.Updates) {
if ($Data.all -or $u.Identity.UpdateID -in $ids) {
if ($Data.type -and $Data.type -ne 'all' -and $u.Type -ne $(if ($Data.type -eq 'Driver') { 2 } else { 1 })) { continue }
if (-not $u.EulaAccepted) { $u.AcceptEula() }
$coll.Add($u) | Out-Null
Write-Log " + $($u.Title)"
}
}
if ($coll.Count -eq 0) { Write-Log 'Keine passenden Updates.' 'OK'; return }
Write-Log "Lade $($coll.Count) Update(s) herunter ..."
$dl = $s.Session.CreateUpdateDownloader(); $dl.Updates = $coll
$dr = $dl.Download()
Write-Log "Download-Ergebnis: $($dr.ResultCode) (2=OK)"
$inst = New-Object -ComObject Microsoft.Update.UpdateColl
foreach ($u in $coll) { if ($u.IsDownloaded) { $inst.Add($u) | Out-Null } }
Write-Log "Installiere $($inst.Count) Update(s) ..."
$installer = $s.Session.CreateUpdateInstaller(); $installer.Updates = $inst
$ir = $installer.Install()
for ($i = 0; $i -lt $inst.Count; $i++) {
$rc = $ir.GetUpdateResult($i).ResultCode
Write-Log (" {0}: {1}" -f $inst.Item($i).Title, @{2 = 'OK'; 3 = 'OK mit Fehlern'; 4 = 'Fehlgeschlagen'; 5 = 'Abgebrochen' }[[int]$rc]) $(if ($rc -eq 2) { 'OK' } else { 'WARN' })
}
if ($ir.RebootRequired) { Write-Log 'NEUSTART ERFORDERLICH' 'WARN' }
return [pscustomobject]@{ rebootRequired = $ir.RebootRequired; result = $ir.ResultCode }
}
function Invoke-VendorDriverUpdate {
param($Data)
$man = (Get-CimInstance Win32_ComputerSystem).Manufacturer
Write-Log "Hersteller: $man"
if (-not (Install-Winget)) { throw 'winget nicht verfuegbar.' }
if ($man -match 'Dell') {
Invoke-Winget @('install', '--id', 'Dell.CommandUpdate', '-e', '--silent', '--accept-package-agreements', '--accept-source-agreements', '--disable-interactivity') | Out-Null
$cli = @("$env:ProgramFiles\Dell\CommandUpdate\dcu-cli.exe", "${env:ProgramFiles(x86)}\Dell\CommandUpdate\dcu-cli.exe") | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $cli) { throw 'dcu-cli.exe nicht gefunden' }
Write-Log 'Dell Command Update: suche und installiere Treiber/BIOS/Firmware ...'
& $cli /applyUpdates -silent -reboot=disable -autoSuspendBitLocker=enable 2>&1 | ForEach-Object { Write-Log " $_" }
Write-Log "dcu-cli ExitCode $LASTEXITCODE (0=OK, 1/5=Neustart noetig, 500=keine Updates)" 'OK'
} elseif ($man -match 'Lenovo') {
Invoke-Winget @('install', '--id', 'Lenovo.SystemUpdate', '-e', '--silent', '--accept-package-agreements', '--accept-source-agreements', '--disable-interactivity') | Out-Null
$tvsu = "${env:ProgramFiles(x86)}\Lenovo\System Update\tvsu.exe"
if (-not (Test-Path $tvsu)) { throw 'tvsu.exe nicht gefunden' }
Write-Log 'Lenovo System Update: installiere Treiber ...'
$p = Start-Process $tvsu -ArgumentList '/CM -search A -action INSTALL -includerebootpackages 1,3,4 -noicons -noreboot -nolicense' -Wait -PassThru
Write-Log "tvsu ExitCode $($p.ExitCode)" 'OK'
} elseif ($man -match 'HP|Hewlett') {
Invoke-Winget @('install', '--id', 'HP.ImageAssistant', '-e', '--silent', '--accept-package-agreements', '--accept-source-agreements', '--disable-interactivity') | Out-Null
$hpia = Get-ChildItem "$env:ProgramFiles\HP\HPIA", "${env:ProgramFiles(x86)}\HP\HPIA", 'C:\SWSetup' -Filter HPImageAssistant.exe -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $hpia) { throw 'HPImageAssistant.exe nicht gefunden' }
$rep = Get-KdsDataDir 'logs\hpia'
Write-Log 'HP Image Assistant: analysiere und installiere ...'
$p = Start-Process $hpia.FullName -ArgumentList "/Operation:Analyze /Action:Install /Category:Drivers,Firmware /Selection:All /Silent /ReportFolder:`"$rep`" /SoftpaqDownloadFolder:`"$env:TEMP\hpia`"" -Wait -PassThru
Write-Log "HPIA ExitCode $($p.ExitCode) (0/256=OK, 3010=Neustart)" 'OK'
} else {
Write-Log 'Kein Hersteller-Tool hinterlegt - bitte Treiber ueber Windows Update installieren.' 'WARN'
}
}
#endregion
#region Gesamtprofil ------------------------------------------------------------
function Invoke-FullProfile {
param($Data)
$steps = @($Data.steps)
Write-Log "Starte Profil '$($Data.name)' mit Schritten: $($steps -join ', ')"
$run = {
param($title, $block)
Write-Log "===== $title ====="
try { & $block } catch { Write-Log "$title fehlgeschlagen: $_" 'ERROR' }
}
if ('system' -in $steps) { & $run 'System' { Set-SystemConfig $Data } }
if ('security' -in $steps) { & $run 'Sicherheit' { Set-SecurityConfig $Data } }
if ('power' -in $steps) { & $run 'Energie' { Set-PowerConfig $Data } }
if ('apps' -in $steps) { & $run 'Programme' { Install-Apps $Data } }
if ('office' -in $steps) { & $run 'Office' { Install-Office ([pscustomobject]@{ office = $Data.office; mode = $Data.officeMode; name = $Data.name }) } }
if ('shortcuts' -in $steps) { & $run 'Desktop-Verknuepfungen' { Set-DesktopShortcuts $Data } }
if ('drivers' -in $steps) { & $run 'Hersteller-Treiber' { Invoke-VendorDriverUpdate $Data } }
if ('updates' -in $steps) { & $run 'Windows Update' { Install-WindowsUpdates ([pscustomobject]@{ all = $true; type = 'all' }) | Out-Null } }
if ('bitlocker' -in $steps) { & $run 'BitLocker' { Set-BitLockerConfig $Data } }
Write-Log 'Profil abgeschlossen.' 'OK'
}
#endregion