From f4c2a1661220b3822de6a2a286af026f0fecd84c Mon Sep 17 00:00:00 2001 From: Louis Date: Fri, 9 Oct 2026 15:48:11 +0200 Subject: [PATCH] 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 --- .gitignore | 11 + Agent.ps1 | 302 +++++++++++++++ README.md | 58 +++ Start-Remote.cmd | 4 + Start.cmd | 4 + config/apps.json | 44 +++ config/shortcuts.json | 10 + icons/.gitkeep | 1 + installers/.gitkeep | 1 + lib/Functions.ps1 | 779 +++++++++++++++++++++++++++++++++++++++ packages/winget/.gitkeep | 1 + profiles/.gitkeep | 1 + web/app.js | 411 +++++++++++++++++++++ web/index.html | 350 ++++++++++++++++++ web/style.css | 83 +++++ 15 files changed, 2060 insertions(+) create mode 100644 .gitignore create mode 100644 Agent.ps1 create mode 100644 README.md create mode 100644 Start-Remote.cmd create mode 100644 Start.cmd create mode 100644 config/apps.json create mode 100644 config/shortcuts.json create mode 100644 icons/.gitkeep create mode 100644 installers/.gitkeep create mode 100644 lib/Functions.ps1 create mode 100644 packages/winget/.gitkeep create mode 100644 profiles/.gitkeep create mode 100644 web/app.js create mode 100644 web/index.html create mode 100644 web/style.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb27205 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Laufzeitdaten - nicht einchecken +logs/ +bitlocker-keys/ +office/setup.exe +office/source/ +office/configs/ +installers/* +!installers/.gitkeep +packages/winget/* +!packages/winget/.gitkeep +profiles/*.json diff --git a/Agent.ps1 b/Agent.ps1 new file mode 100644 index 0000000..4186dc9 --- /dev/null +++ b/Agent.ps1 @@ -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/(?[\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/(?[\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/(?[\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/(?\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 = $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.' diff --git a/README.md b/README.md new file mode 100644 index 0000000..b5287c2 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# KDS Install Agent + +Portabler Agent zum automatischen Einrichten von Windows-11-Notebooks – direkt vom USB-Stick, ohne Installation und ohne zusätzliche Abhängigkeiten (nur Bordmittel: PowerShell 5.1, Edge, winget). + +## Start + +| Datei | Zweck | +|---|---| +| `Start.cmd` | Startet den Agent (fordert Adminrechte an) und öffnet die Oberfläche als eigenes Desktop-Fenster (Edge-App-Modus). | +| `Start-Remote.cmd` | Zusätzlich im Netzwerk erreichbar (Firewall-Regel für Domäne/Privat wird angelegt). Die URL mit Token steht im Konsolenfenster. | + +Parameter für `Agent.ps1`: `-Port 8090`, `-Remote`, `-NoWindow` (nur Web-Server). + +Die Web-API ist immer mit einem zufälligen Token geschützt, das bei jedem Start neu erzeugt wird. + +## Funktionen + +- **Programme**: Live-Suche im öffentlichen **winget**-Repository und im **Microsoft Store**, Schnellauswahl aus `config/apps.json`, eigene Installer aus `installers\`. Installation systemweit (`--scope machine`, Fallback auf Standard-Scope). „Alle aktualisieren“ über `winget upgrade --all`. Fehlt winget, wird es automatisch nachinstalliert (offline aus `packages\winget\`, sonst über Microsoft). +- **Office**: Konfigurator für das Office Deployment Tool (M365 Apps Enterprise/Business, LTSC 2024/2021, Home & Business 2024, Visio/Project, Sprachen, Apps abwählen, Kanal, Architektur, MSI-Entfernung, Shared Activation, Makro-Sperre …) mit Live-Vorschau der `configuration.xml`. + - *Online*: direkt vom Microsoft-CDN installieren + - *Build*: Installationsdateien nach `office\source\-` auf den Stick laden + - *Offline*: vom Stick installieren + - `setup.exe` wird bei Bedarf automatisch geladen; generierte XMLs landen in `office\configs\`. +- **Desktop-Icons** für alle Benutzer (öffentlicher Desktop): Web-Links (`.url`) und Programmverknüpfungen (`.lnk`), eigene Icons aus `icons\` (werden nach `C:\ProgramData\KDS-Agent\Icons` kopiert). Vorlagen in `config/shortcuts.json`. +- **System**: Computername, Zeitzone, Dateiendungen anzeigen (auch für neue Benutzer), Consumer-Features/Werbe-Apps aus. +- **Sicherheit**: Defender (Echtzeit/Cloud/PUA), Firewall, SmartScreen, UAC, SMBv1 aus, Autorun aus, LSA-Schutz, Telemetrie, RDP an/aus, lokales Admin-Konto. +- **Energie**: Energieplan, Timeouts für Bildschirm/Standby/Ruhezustand (Netz & Akku), Deckel- und Netzschalteraktion, Ruhezustand, Schnellstart. +- **BitLocker**: Status, Aktivierung mit TPM oder TPM+PIN, XTS-AES 128/256, nur belegten Speicher, Sicherung in Entra ID / AD. Der Wiederherstellungsschlüssel wird **immer** unter `bitlocker-keys\` auf dem Stick gespeichert (Computername + Seriennummer). +- **Updates & Treiber**: Windows- und Treiberupdates über die Windows-Update-API (inkl. Microsoft Update) suchen, auswählen, installieren. Hersteller-Tools: Dell Command Update, Lenovo System Update, HP Image Assistant. +- **Profile**: Gesamte Konfiguration als JSON unter `profiles\` speichern/laden/importieren/exportieren. +- **Einrichtung mit einem Klick**: Auf der Übersicht alle Schritte in sinnvoller Reihenfolge ausführen. +- **Aufgaben**: Alle Aktionen laufen als Hintergrund-Jobs in einer Warteschlange (nacheinander, damit sich Installer nicht blockieren). Live-Protokoll in der Oberfläche; Logs unter `logs\\`. + +## Ordnerstruktur + +``` +Start.cmd / Start-Remote.cmd Starter +Agent.ps1 HTTP-Server, API, Job-Queue +lib\Functions.ps1 Alle Einrichtungsfunktionen +web\ Oberfläche (HTML/CSS/JS) +config\apps.json Programm-Schnellauswahl (anpassbar) +config\shortcuts.json Verknüpfungs-Vorlagen (anpassbar) +installers\ eigene Setups (.exe/.msi) +icons\ eigene Icons (.ico) +packages\winget\ optional: winget-Offlinepakete (*.msixbundle + Abhängigkeiten *.appx) +office\ setup.exe, configs\, source\ (Offline-Builds) +profiles\ gespeicherte Profile +logs\, bitlocker-keys\ werden zur Laufzeit angelegt +``` + +Ist der Stick schreibgeschützt, schreibt der Agent Logs und Schlüssel nach `C:\ProgramData\KDS-Agent\`. + +## Hinweise + +- **Sicherheit**: Profile können Klartext-Kennwörter (lokaler Admin, BitLocker-PIN) enthalten, und `bitlocker-keys\` enthält Wiederherstellungsschlüssel. Den Stick entsprechend schützen. Beide Ordner sind per `.gitignore` von der Versionsverwaltung ausgeschlossen. +- BitLocker benötigt Windows 11 Pro/Enterprise/Education. Unter Home steht nur die Geräteverschlüsselung zur Verfügung. +- Die Store-Installation (`msstore`) braucht Internet und kann bei Konten ohne Store-Zugang fehlschlagen. +- Einige Einstellungen (Computername, LSA-Schutz, SMBv1, BitLocker mit Hardwaretest) werden erst nach einem Neustart wirksam. diff --git a/Start-Remote.cmd b/Start-Remote.cmd new file mode 100644 index 0000000..06f7314 --- /dev/null +++ b/Start-Remote.cmd @@ -0,0 +1,4 @@ +@echo off +REM KDS Install Agent - zusaetzlich im Netzwerk erreichbar (Token-geschuetzt) +cd /d "%~dp0" +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Agent.ps1" -Remote %* diff --git a/Start.cmd b/Start.cmd new file mode 100644 index 0000000..49c2bba --- /dev/null +++ b/Start.cmd @@ -0,0 +1,4 @@ +@echo off +REM KDS Install Agent - Start vom USB-Stick (Desktop-Fenster + lokale Web-Oberflaeche) +cd /d "%~dp0" +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Agent.ps1" %* diff --git a/config/apps.json b/config/apps.json new file mode 100644 index 0000000..50e347e --- /dev/null +++ b/config/apps.json @@ -0,0 +1,44 @@ +{ + "_info": "Katalog fuer die Schnellauswahl. source: winget | msstore | local. Bei local: path relativ zum Stick (installers\\...) und args fuer Silent-Install.", + "apps": [ + { "category": "Browser", "name": "Google Chrome", "id": "Google.Chrome", "source": "winget" }, + { "category": "Browser", "name": "Mozilla Firefox (DE)", "id": "Mozilla.Firefox.de", "source": "winget" }, + { "category": "Browser", "name": "Brave", "id": "Brave.Brave", "source": "winget" }, + + { "category": "Kommunikation", "name": "Microsoft Teams", "id": "Microsoft.Teams", "source": "winget" }, + { "category": "Kommunikation", "name": "Zoom Workplace", "id": "Zoom.Zoom", "source": "winget" }, + { "category": "Kommunikation", "name": "Mozilla Thunderbird (DE)", "id": "Mozilla.Thunderbird.de", "source": "winget" }, + { "category": "Kommunikation", "name": "Webex", "id": "Cisco.Webex", "source": "winget" }, + + { "category": "Office & PDF", "name": "Adobe Acrobat Reader (64-Bit)", "id": "Adobe.Acrobat.Reader.64-bit", "source": "winget" }, + { "category": "Office & PDF", "name": "PDF24 Creator", "id": "geeksoftwareGmbH.PDF24Creator", "source": "winget" }, + { "category": "Office & PDF", "name": "LibreOffice", "id": "TheDocumentFoundation.LibreOffice", "source": "winget" }, + { "category": "Office & PDF", "name": "Microsoft OneDrive", "id": "Microsoft.OneDrive", "source": "winget" }, + + { "category": "Tools", "name": "7-Zip", "id": "7zip.7zip", "source": "winget" }, + { "category": "Tools", "name": "Notepad++", "id": "Notepad++.Notepad++", "source": "winget" }, + { "category": "Tools", "name": "Greenshot", "id": "Greenshot.Greenshot", "source": "winget" }, + { "category": "Tools", "name": "Microsoft PowerToys", "id": "Microsoft.PowerToys", "source": "winget" }, + { "category": "Tools", "name": "KeePassXC", "id": "KeePassXCTeam.KeePassXC", "source": "winget" }, + { "category": "Tools", "name": "Everything", "id": "voidtools.Everything", "source": "winget" }, + + { "category": "Fernwartung", "name": "TeamViewer", "id": "TeamViewer.TeamViewer", "source": "winget" }, + { "category": "Fernwartung", "name": "TeamViewer QuickSupport", "id": "TeamViewer.TeamViewer.QuickSupport", "source": "winget" }, + { "category": "Fernwartung", "name": "AnyDesk", "id": "AnyDesk.AnyDesk", "source": "winget" }, + + { "category": "Medien", "name": "VLC Media Player", "id": "VideoLAN.VLC", "source": "winget" }, + { "category": "Medien", "name": "IrfanView", "id": "IrfanSkiljan.IrfanView", "source": "winget" }, + + { "category": "Laufzeiten", "name": "Visual C++ 2015-2022 x64", "id": "Microsoft.VCRedist.2015+.x64", "source": "winget" }, + { "category": "Laufzeiten", "name": ".NET Desktop Runtime 8", "id": "Microsoft.DotNet.DesktopRuntime.8", "source": "winget" }, + { "category": "Laufzeiten", "name": "Java Runtime (Temurin 21)", "id": "EclipseAdoptium.Temurin.21.JRE", "source": "winget" }, + + { "category": "Microsoft Store", "name": "Unternehmensportal (Intune)", "id": "9WZDNCRFJ3PZ", "source": "msstore" }, + { "category": "Microsoft Store", "name": "WhatsApp", "id": "9NKSQGP7F2NH", "source": "msstore" }, + + { "category": "Entwicklung", "name": "Visual Studio Code", "id": "Microsoft.VisualStudioCode", "source": "winget" }, + { "category": "Entwicklung", "name": "Git", "id": "Git.Git", "source": "winget" }, + { "category": "Entwicklung", "name": "WinSCP", "id": "WinSCP.WinSCP", "source": "winget" }, + { "category": "Entwicklung", "name": "PuTTY", "id": "PuTTY.PuTTY", "source": "winget" } + ] +} diff --git a/config/shortcuts.json b/config/shortcuts.json new file mode 100644 index 0000000..c6d5b3b --- /dev/null +++ b/config/shortcuts.json @@ -0,0 +1,10 @@ +[ + { "name": "Outlook (Web)", "type": "url", "target": "https://outlook.office.com" }, + { "name": "Microsoft 365", "type": "url", "target": "https://www.office.com" }, + { "name": "Fernwartung (Support)", "type": "lnk", "target": "%ProgramFiles%\\TeamViewer\\TeamViewer.exe" }, + { "name": "Word", "type": "lnk", "target": "%ProgramFiles%\\Microsoft Office\\root\\Office16\\WINWORD.EXE" }, + { "name": "Excel", "type": "lnk", "target": "%ProgramFiles%\\Microsoft Office\\root\\Office16\\EXCEL.EXE" }, + { "name": "Outlook", "type": "lnk", "target": "%ProgramFiles%\\Microsoft Office\\root\\Office16\\OUTLOOK.EXE" }, + { "name": "Google Chrome", "type": "lnk", "target": "%ProgramFiles%\\Google\\Chrome\\Application\\chrome.exe" }, + { "name": "Dieser PC", "type": "lnk", "target": "explorer.exe", "args": "::{20D04FE0-3AEA-1069-A2D8-08002B30309D}", "icon": "%SystemRoot%\\System32\\imageres.dll,-109" } +] diff --git a/icons/.gitkeep b/icons/.gitkeep new file mode 100644 index 0000000..d2b13e9 --- /dev/null +++ b/icons/.gitkeep @@ -0,0 +1 @@ +Platzhalter diff --git a/installers/.gitkeep b/installers/.gitkeep new file mode 100644 index 0000000..d2b13e9 --- /dev/null +++ b/installers/.gitkeep @@ -0,0 +1 @@ +Platzhalter diff --git a/lib/Functions.ps1 b/lib/Functions.ps1 new file mode 100644 index 0000000..3805503 --- /dev/null +++ b/lib/Functions.ps1 @@ -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 { " " }) -join "`r`n" + $exclXml = (@($Cfg.exclude) | Where-Object { $_ } | ForEach-Object { " " }) -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 += " `r`n$langXml$(if($exclXml){"`r`n$exclXml"})`r`n " + foreach ($extra in @($Cfg.extraProducts)) { + if ($extra) { $products += " `r`n$langXml`r`n " } + } + + $x = @() + $x += "" + $x += " " + $x += $products + $x += ' ' + $x += " " + $x += " " + $x += " " + $x += " " + if ($Cfg.product -like '*Volume') { $x += " " } + $x += " " + if (Test-True $Cfg.removeMsi) { $x += ' ' } + $x += ' ' + if ($Cfg.company) { $x += " " } + if (Test-True $Cfg.ooxmlDefault) { + $x += ' ' + } + if (Test-True $Cfg.disableVba) { + foreach ($app in 'word', 'excel', 'powerpoint') { + $x += " " + } + } + $x += ' ' + $x += " " + $x += '' + 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 diff --git a/packages/winget/.gitkeep b/packages/winget/.gitkeep new file mode 100644 index 0000000..d2b13e9 --- /dev/null +++ b/packages/winget/.gitkeep @@ -0,0 +1 @@ +Platzhalter diff --git a/profiles/.gitkeep b/profiles/.gitkeep new file mode 100644 index 0000000..587be6b --- /dev/null +++ b/profiles/.gitkeep @@ -0,0 +1 @@ +x diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..bd8165d --- /dev/null +++ b/web/app.js @@ -0,0 +1,411 @@ +'use strict'; +// ---------------------------------------------------------------- Basis +const $ = (s, r = document) => r.querySelector(s); +const $$ = (s, r = document) => [...r.querySelectorAll(s)]; +const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + +const urlToken = new URLSearchParams(location.search).get('token'); +if (urlToken) { sessionStorage.setItem('kdsToken', urlToken); history.replaceState(null, '', '/' + location.hash); } +const TOKEN = sessionStorage.getItem('kdsToken') || ''; + +async function api(method, path, body, raw) { + const r = await fetch(path, { + method, headers: { 'X-Token': TOKEN, 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : (typeof body === 'string' ? body : JSON.stringify(body)) + }); + if (raw) return r.text(); + const data = await r.json().catch(() => null); + if (!r.ok) throw new Error(data?.error || `HTTP ${r.status}`); + return data; +} +const arr = v => v == null ? [] : Array.isArray(v) ? v : [v]; + +function toast(msg, isErr, onClick) { + const d = document.createElement('div'); + d.textContent = msg; if (isErr) d.className = 'err'; + d.onclick = () => { onClick?.(); d.remove(); }; + $('#toast').append(d); setTimeout(() => d.remove(), isErr ? 8000 : 4000); +} +async function guard(fn) { try { return await fn(); } catch (e) { toast(e.message, true); } } + +// ---------------------------------------------------------------- Zustand / Profil +const DEFAULTS = () => ({ + name: '', + apps: [], + office: { + product: 'O365ProPlusRetail', channel: 'Current', arch: '64', languages: ['de-de'], + exclude: ['Groove', 'Lync', 'Bing'], extraProducts: [], pidKey: '', company: '', + updates: true, removeMsi: true, migrateArch: true, sharedComputer: false, disableVba: true, ooxmlDefault: true, showUi: false + }, + officeMode: 'online', + shortcuts: [], + system: { computerName: '', timeZone: '', showExtensions: true, disableConsumer: true }, + security: { defender: true, firewall: true, smartScreen: true, uac: false, disableSmb1: true, disableAutorun: true, lsaProtection: false, telemetryMin: false, rdp: 'keep', localAdminName: '', localAdminPassword: '' }, + power: { plan: 'balanced', monitorAc: 15, monitorDc: 5, sleepAc: 0, sleepDc: 15, hibernateAc: 0, hibernateDc: 60, lidAc: 'nothing', lidDc: 'sleep', buttonAc: 'shutdown', buttonDc: 'shutdown', hibernate: '', fastStartup: 'off' }, + bitlocker: { method: 'XtsAes256', usedSpaceOnly: true, skipHardwareTest: true, usePin: false, pin: '', backupAad: false, backupAd: false }, + steps: ['system', 'security', 'power', 'apps', 'office', 'shortcuts', 'updates'] +}); +let S = DEFAULTS(); +try { const saved = JSON.parse(localStorage.getItem('kdsState') || 'null'); if (saved) S = deepMerge(DEFAULTS(), saved); } catch { } + +function deepMerge(base, add) { + for (const k of Object.keys(add || {})) { + if (add[k] && typeof add[k] === 'object' && !Array.isArray(add[k]) && base[k] && typeof base[k] === 'object') deepMerge(base[k], add[k]); + else base[k] = add[k]; + } + return base; +} +const getPath = p => p.split('.').reduce((o, k) => o?.[k], S); +function setPath(p, v) { const ks = p.split('.'); const last = ks.pop(); ks.reduce((o, k) => o[k] ??= {}, S)[last] = v; } +function persist() { try { localStorage.setItem('kdsState', JSON.stringify(S)); } catch { } renderSummary(); } + +// generische Formularbindung: data-bind="pfad" und data-bind-list="pfad" (Checkbox-Gruppen) +function bindForms() { + $$('[data-bind]').forEach(el => { + el.addEventListener(el.type === 'checkbox' ? 'change' : 'input', () => { + let v = el.type === 'checkbox' ? el.checked : el.value; + if (el.type === 'number') v = el.value === '' ? '' : Number(el.value); + setPath(el.dataset.bind, v); persist(); onChange(el.dataset.bind); + }); + if (el.tagName === 'SELECT') el.addEventListener('change', () => el.dispatchEvent(new Event('input'))); + }); + $$('[data-bind-list]').forEach(box => box.addEventListener('change', () => { + setPath(box.dataset.bindList, $$('input:checked', box).map(i => i.value)); persist(); onChange(box.dataset.bindList); + })); +} +function fillForms() { + $$('[data-bind]').forEach(el => { + const v = getPath(el.dataset.bind); + if (el.type === 'checkbox') el.checked = !!v; else el.value = v ?? ''; + }); + $$('[data-bind-list]').forEach(box => { + const v = arr(getPath(box.dataset.bindList)); + $$('input', box).forEach(i => i.checked = v.includes(i.value)); + }); + $$('#steps input').forEach(i => i.checked = arr(S.steps).includes(i.value)); + $('#profileName').textContent = 'Profil: ' + (S.name || '(ungespeichert)'); + $$('.pinonly').forEach(e => e.classList.toggle('hidden', !S.bitlocker.usePin)); +} +function onChange(path) { + if (path.startsWith('office')) { if (path === 'office.product') { renderOfficeChannels(); renderOfficeExtras(); } updateOfficeXml(); } + if (path === 'bitlocker.usePin') fillForms(); +} +function loadState(obj) { S = deepMerge(DEFAULTS(), obj); persist(); fillForms(); renderAll(); } + +// ---------------------------------------------------------------- Navigation +function showTab(t) { + $$('#nav a').forEach(a => a.classList.toggle('active', a.dataset.tab === t)); + $$('.tab').forEach(s => s.classList.toggle('active', s.id === 'tab-' + t)); + ({ shortcuts: loadShortcuts, bitlocker: loadBitlocker, profiles: loadProfiles, jobs: pollJobs, power: loadPower, office: loadOfficeBuilds })[t]?.(); +} +$('#nav').onclick = e => { const a = e.target.closest('a'); if (a) showTab(a.dataset.tab); }; + +// ---------------------------------------------------------------- Übersicht +async function loadStatus() { + const s = await api('GET', '/api/status'); + const tpm = s.tpm ? (s.tpm.present ? (s.tpm.ready ? 'vorhanden, bereit' : 'vorhanden, nicht bereit') : 'nicht vorhanden') : 'unbekannt'; + const items = [['Computer', s.computerName], ['Hersteller / Modell', `${s.manufacturer} ${s.model}`], ['Seriennummer', s.serial], + ['Windows', `${s.os}
${esc(s.build)}`], ['Prozessor', s.cpu], ['RAM', s.ramGB + ' GB'], + ['Systemlaufwerk', `${s.diskFreeGB} GB frei von ${s.diskSizeGB} GB`], ['TPM', tpm], ['Domäne', s.partOfDomain ? s.domain : 'Arbeitsgruppe'], + ['winget', s.winget ? 'verfügbar' : 'fehlt – wird automatisch installiert'], ['Akku', s.battery || '–'], ['Agent-Pfad', s.root]]; + $('#sysinfo').innerHTML = items.map(([k, v]) => `
${k}
${/<|&/.test(v) && k !== 'Seriennummer' ? v : esc(v)}
`).join(''); +} +function renderSummary() { + const o = S.office, prod = PRODUCTS.find(p => p.id === o.product); + $('#summary').innerHTML = [ + `${arr(S.apps).length} Programme`, `Office: ${esc(prod?.name || o.product)} (${o.arch}-Bit, ${esc(S.officeMode)})`, + `${arr(S.shortcuts).length} Desktop-Icons`, `Energieplan: ${esc(S.power.plan || 'unverändert')}`, + S.system.computerName ? `Neuer Name: ${esc(S.system.computerName)}` : '' + ].filter(Boolean).join(' · '); +} +$('#steps').onchange = () => { S.steps = $$('#steps input:checked').map(i => i.value); persist(); }; +$('#btnRunAll').onclick = () => guard(async () => { + if (!S.steps.length) return toast('Keine Schritte ausgewählt', true); + if (S.steps.includes('bitlocker')) S.bitlocker.action = 'enable'; + if (!confirm(`Einrichtung mit ${S.steps.length} Schritten jetzt starten?`)) return; + const r = await api('POST', '/api/run-profile', S); jobStarted(r.job); +}); + +// ---------------------------------------------------------------- Programme +let catalog = []; +async function loadCatalog() { + catalog = arr((await api('GET', '/api/catalog'))?.apps); + const groups = {}; + catalog.forEach(a => (groups[a.category] ??= []).push(a)); + $('#catalog').innerHTML = Object.entries(groups).map(([c, list]) => `

${esc(c)}

${list.map(a => + ``).join('')}
`).join(''); + syncCatalogChecks(); + const local = arr(await api('GET', '/api/local-installers')); + $('#localFile').innerHTML = local.length ? local.map(f => ``).join('') : ''; +} +$('#catalog').onchange = e => { + const id = e.target.dataset.app; if (!id) return; + const a = catalog.find(x => x.id === id); + if (e.target.checked) addApp({ name: a.name, id: a.id, source: a.source, override: a.override }); + else removeApp(id); +}; +function syncCatalogChecks() { $$('#catalog input').forEach(i => i.checked = S.apps.some(a => a.id === i.dataset.app)); } +function addApp(a) { if (S.apps.some(x => x.id === a.id)) return; S.apps.push(a); persist(); renderSelectedApps(); } +function removeApp(id) { S.apps = S.apps.filter(a => a.id !== id); persist(); renderSelectedApps(); } +function renderSelectedApps() { + $('#appCount').textContent = S.apps.length; + $('#selectedApps').innerHTML = S.apps.length ? S.apps.map(a => `
${esc(a.name)}
+
${esc(a.source)} · ${esc(a.source === 'local' ? a.path : a.id)}
+
`).join('') : '

Nichts ausgewählt.

'; + syncCatalogChecks(); +} +$('#selectedApps').onclick = e => { const id = e.target.dataset.rm; if (id) removeApp(id); }; +async function doSearch() { + const q = $('#appSearch').value.trim(); if (!q) return; + $('#searchResults').innerHTML = '

Suche …

'; + await guard(async () => { + const res = arr(await api('GET', `/api/winget/search?q=${encodeURIComponent(q)}&source=${$('#appSource').value}`)); + $('#searchResults').innerHTML = res.length ? res.map((r, i) => `
${esc(r.name)}
+
${esc(r.id)} · ${esc(r.version)}
`).join('') : '

Keine Treffer.

'; + $('#searchResults').onclick = e => { const i = e.target.dataset.add; if (i != null) { addApp(res[i]); e.target.textContent = '✓'; } }; + }); +} +$('#btnSearch').onclick = doSearch; +$('#appSearch').onkeydown = e => { if (e.key === 'Enter') doSearch(); }; +$('#btnAddLocal').onclick = () => { + const f = $('#localFile').value; if (!f) return; + addApp({ name: f.split('\\').pop(), id: 'local:' + f, source: 'local', path: f, args: $('#localArgs').value }); +}; +$('#btnInstallApps').onclick = () => guard(async () => { + if (!S.apps.length) return toast('Keine Programme ausgewählt', true); + jobStarted((await api('POST', '/api/apps/install', { apps: S.apps })).job); +}); +$('#btnUpgradeAll').onclick = $('#btnWingetUpg').onclick = () => guard(async () => jobStarted((await api('POST', '/api/apps/upgrade-all', {})).job)); + +// ---------------------------------------------------------------- Office +const PRODUCTS = [ + { id: 'O365ProPlusRetail', name: 'Microsoft 365 Apps for enterprise', channels: ['Current', 'MonthlyEnterprise', 'SemiAnnual', 'SemiAnnualPreview', 'BetaChannel'], extras: ['VisioProRetail', 'ProjectProRetail'] }, + { id: 'O365BusinessRetail', name: 'Microsoft 365 Apps for business', channels: ['Current', 'MonthlyEnterprise', 'SemiAnnual'], extras: ['VisioProRetail', 'ProjectProRetail'] }, + { id: 'ProPlus2024Volume', name: 'Office LTSC Professional Plus 2024 (Volumenlizenz)', channels: ['PerpetualVL2024'], extras: ['VisioPro2024Volume', 'ProjectPro2024Volume'] }, + { id: 'Standard2024Volume', name: 'Office LTSC Standard 2024 (Volumenlizenz)', channels: ['PerpetualVL2024'], extras: ['VisioStd2024Volume', 'ProjectStd2024Volume'] }, + { id: 'ProPlus2021Volume', name: 'Office LTSC Professional Plus 2021 (Volumenlizenz)', channels: ['PerpetualVL2021'], extras: ['VisioPro2021Volume', 'ProjectPro2021Volume'] }, + { id: 'HomeBusiness2024Retail', name: 'Office Home & Business 2024', channels: ['Current'], extras: [] }, + { id: 'Home2024Retail', name: 'Office Home 2024', channels: ['Current'], extras: [] } +]; +const CHANNEL_NAMES = { Current: 'Aktueller Kanal', MonthlyEnterprise: 'Monatlicher Enterprise-Kanal', SemiAnnual: 'Halbjährlicher Enterprise-Kanal', SemiAnnualPreview: 'Halbjährlich (Vorschau)', BetaChannel: 'Beta', PerpetualVL2024: 'LTSC 2024', PerpetualVL2021: 'LTSC 2021' }; +const OFFICE_APPS = [['Word', 'Word'], ['Excel', 'Excel'], ['PowerPoint', 'PowerPoint'], ['Outlook', 'Outlook (klassisch)'], ['OutlookForWindows', 'Outlook (neu)'], ['OneNote', 'OneNote'], + ['Access', 'Access'], ['Publisher', 'Publisher'], ['Teams', 'Teams'], ['OneDrive', 'OneDrive'], ['Lync', 'Skype for Business'], ['Groove', 'OneDrive for Business (alt)'], ['Bing', 'Bing-Suche in Chrome']]; + +function renderOffice() { + $('#officeProduct').innerHTML = PRODUCTS.map(p => ``).join(''); + $('#officeApps').innerHTML = OFFICE_APPS.map(([id, n]) => ``).join(''); + $('#officeProduct').value = S.office.product; + renderOfficeChannels(); renderOfficeExtras(); renderOfficeApps(); updateOfficeXml(); +} +function renderOfficeChannels() { + const p = PRODUCTS.find(x => x.id === S.office.product) || PRODUCTS[0]; + $('#officeChannel').innerHTML = p.channels.map(c => ``).join(''); + if (!p.channels.includes(S.office.channel)) S.office.channel = p.channels[0]; + $('#officeChannel').value = S.office.channel; + S.office.extraProducts = arr(S.office.extraProducts).filter(e => p.extras.includes(e)); +} +function renderOfficeExtras() { + const p = PRODUCTS.find(x => x.id === S.office.product) || PRODUCTS[0]; + const label = e => `${e.startsWith('Visio') ? 'Visio' : 'Project'} ${/Std/.test(e) ? 'Standard' : 'Professional'} ${(e.match(/\d{4}/) || [''])[0]}`; + $('#officeExtras').innerHTML = p.extras.length ? p.extras.map(e => ``).join('') : 'keine für dieses Produkt'; +} +function renderOfficeApps() { $$('#officeApps input').forEach(i => i.checked = !arr(S.office.exclude).includes(i.value)); } +$('#officeApps').onchange = () => { S.office.exclude = $$('#officeApps input:not(:checked)').map(i => i.value); persist(); updateOfficeXml(); }; +$('#officeExtras').onchange = () => { S.office.extraProducts = $$('#officeExtras input:checked').map(i => i.value); persist(); updateOfficeXml(); }; +let xmlTimer; +function updateOfficeXml() { + clearTimeout(xmlTimer); + xmlTimer = setTimeout(() => guard(async () => { $('#officeXml').textContent = await api('POST', '/api/office/xml', S.office, true); }), 250); +} +$('#btnXmlDownload').onclick = () => download('configuration.xml', $('#officeXml').textContent, 'application/xml'); +$('#btnXmlCopy').onclick = () => navigator.clipboard.writeText($('#officeXml').textContent).then(() => toast('XML kopiert')); +$('#btnOffice').onclick = () => guard(async () => { + jobStarted((await api('POST', '/api/office/install', { office: S.office, mode: S.officeMode, name: S.name })).job); +}); +async function loadOfficeBuilds() { + const b = arr(await api('GET', '/api/office/configs').catch(() => [])); + $('#officeBuilds').innerHTML = b.length ? 'Vorhandene Builds auf dem Stick: ' + b.map(esc).join(', ') : 'Noch kein Offline-Build auf dem Stick.'; +} + +// ---------------------------------------------------------------- Desktop-Icons +let presets = []; +function scFromForm() { + return { type: $('#scType').value, name: $('#scName').value.trim(), target: $('#scTarget').value.trim(), args: $('#scArgs').value, icon: $('#scIcon').value.trim() }; +} +$('#scType').onchange = () => $$('.lnkonly').forEach(e => e.classList.toggle('hidden', $('#scType').value === 'url')); +$('#btnScCreate').onclick = () => guard(async () => { + const s = scFromForm(); if (!s.name || !s.target) return toast('Name und Ziel angeben', true); + await api('POST', '/api/shortcuts', s); toast('Verknüpfung angelegt'); loadShortcuts(); +}); +$('#btnScPlan').onclick = () => { + const s = scFromForm(); if (!s.name || !s.target) return toast('Name und Ziel angeben', true); + S.shortcuts = arr(S.shortcuts).filter(x => x.name !== s.name).concat(s); persist(); renderPlanned(); +}; +function renderPlanned() { + const l = arr(S.shortcuts); + $('#scPlanned').innerHTML = l.length ? l.map((s, i) => `
${esc(s.name)}
${esc(s.target)}
+
`).join('') : '

Keine geplant.

'; +} +$('#scPlanned').onclick = e => { const i = e.target.dataset.i; if (i != null) { S.shortcuts.splice(i, 1); persist(); renderPlanned(); } }; +$('#btnScApplyAll').onclick = () => guard(async () => { + for (const s of arr(S.shortcuts)) { try { await api('POST', '/api/shortcuts', s); } catch (e) { toast(`${s.name}: ${e.message}`, true); } } + toast('Verknüpfungen angelegt'); loadShortcuts(); +}); +async function loadShortcuts() { + const [cur, icons] = await Promise.all([api('GET', '/api/shortcuts'), api('GET', '/api/icons')]); + $('#scCurrent').innerHTML = arr(cur).length ? arr(cur).map(s => `
${esc(s.name)}
${esc(s.target)}
+
`).join('') : '

Keine Verknüpfungen.

'; + $('#iconList').innerHTML = arr(icons).map(i => ``).join('')); +async function loadPower() { const p = await api('GET', '/api/power').catch(() => null); if (p) $('#powerState').textContent = 'Aktuell: ' + p.active; } + +// ---------------------------------------------------------------- BitLocker +async function loadBitlocker() { + $('#blStatus').textContent = 'Lade …'; + await guard(async () => { + const b = await api('GET', '/api/bitlocker'); + let h = `

${esc(b.edition)} · TPM: ${b.tpmPresent ? (b.tpmReady ? 'bereit' : 'nicht bereit') : 'nicht vorhanden'}

`; + if (!b.available) h += `

${esc(b.message)}

`; + h += arr(b.volumes).map(v => `
${esc(v.mount)} ${esc(v.type)}
+ Status: ${esc(v.status)} · Schutz: ${esc(v.protection)} · ${esc(v.method)}
+ Schutzvorrichtungen: ${esc(arr(v.protectors).join(', ') || '–')} +
`).join(''); + $('#blStatus').innerHTML = h; + }); +} +$('#btnBlRefresh').onclick = loadBitlocker; +$$('[data-bl]').forEach(b => b.onclick = () => guard(async () => { + const action = b.dataset.bl; + if (action === 'enable' && S.bitlocker.usePin && !/^\d{6,20}$/.test(S.bitlocker.pin || '')) return toast('PIN muss 6–20 Ziffern haben', true); + if (action === 'disable' && !confirm('BitLocker wirklich deaktivieren und das Laufwerk entschlüsseln?')) return; + jobStarted((await api('POST', '/api/bitlocker', { bitlocker: { ...S.bitlocker, action } })).job); +})); + +// ---------------------------------------------------------------- Updates +let updates = []; +$$('[data-scan]').forEach(b => b.onclick = () => guard(async () => { + const type = b.dataset.scan; + const r = await api('POST', '/api/updates/scan', { type }); jobStarted(r.job, true); + $('#updList').innerHTML = '

Suche läuft … (kann einige Minuten dauern)

'; + const j = await waitJob(r.job); + updates = arr(j.result); + $('#updCount').textContent = updates.length; + $('#updList').innerHTML = updates.length ? updates.map((u, i) => ``).join('') + : `

${j.state.startsWith('Fertig') ? 'Keine Updates gefunden – System ist aktuell.' : 'Suche fehlgeschlagen, siehe Aufgaben-Protokoll.'}

`; +})); +$('#btnUpdSel').onclick = () => guard(async () => { + const ids = $$('#updList input:checked').map(i => updates[i.dataset.u].id); + if (!ids.length) return toast('Nichts ausgewählt', true); + jobStarted((await api('POST', '/api/updates/install', { ids })).job); +}); +$('#btnUpdAll').onclick = () => guard(async () => jobStarted((await api('POST', '/api/updates/install', { all: true, type: 'all' })).job)); +$('#btnVendor').onclick = () => guard(async () => jobStarted((await api('POST', '/api/updates/vendor', {})).job)); +$('#btnWinget').onclick = () => guard(async () => jobStarted((await api('POST', '/api/winget/install', {})).job)); +$('#btnReboot').onclick = () => { if (confirm('Computer jetzt neu starten?')) guard(() => api('POST', '/api/reboot', {})); }; + +// ---------------------------------------------------------------- Profile +async function loadProfiles() { + const l = arr(await api('GET', '/api/profiles')); + $('#profileList').innerHTML = l.length ? l.map(n => `
${esc(n)}
+
`).join('') : '

Keine Profile gespeichert.

'; +} +$('#profileList').onclick = e => guard(async () => { + const { load, pdel } = e.target.dataset; + if (load) { loadState(await api('GET', '/api/profiles/' + encodeURIComponent(load))); toast(`Profil "${load}" geladen`); } + if (pdel && confirm(`Profil "${pdel}" löschen?`)) { await api('DELETE', '/api/profiles/' + encodeURIComponent(pdel)); loadProfiles(); } +}); +$('#btnProfileSave').onclick = () => guard(async () => { + const n = $('#profileSaveName').value.trim().replace(/[^\w\-. ]/g, '_'); + if (!n) return toast('Namen angeben', true); + S.name = n; persist(); + await api('POST', '/api/profiles/' + encodeURIComponent(n), JSON.stringify(S, null, 2)); + toast(`Profil "${n}" gespeichert`); fillForms(); loadProfiles(); +}); +$('#btnExport').onclick = () => download((S.name || 'profil') + '.json', JSON.stringify(S, null, 2), 'application/json'); +$('#importFile').onchange = async e => { const f = e.target.files[0]; if (f) guard(async () => { loadState(JSON.parse(await f.text())); toast('Profil importiert'); }); e.target.value = ''; }; +$('#btnReset').onclick = () => { if (confirm('Alle Einstellungen auf Standard zurücksetzen?')) loadState(DEFAULTS()); }; + +function download(name, text, type) { + const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([text], { type })); a.download = name; a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 1000); +} + +// ---------------------------------------------------------------- Aufgaben +let selJob = null, jobTimer = null, knownStates = {}; +const stateClass = s => s === 'Laeuft' ? 'run' : s === 'Fertig' ? 'ok' : s.includes('Fehler') ? 'warn' : /Fehlg|Abgeb/.test(s) ? 'err' : ''; +const stateName = s => s === 'Laeuft' ? 'Läuft' : s; +function jobStarted(id, quiet) { + if (!quiet) toast(`Aufgabe ${id} gestartet – zum Protokoll klicken`, false, () => { selJob = id; showTab('jobs'); }); + selJob ??= id; pollJobs(); +} +async function pollJobs() { + clearTimeout(jobTimer); + try { + const jobs = arr(await api('GET', '/api/jobs')).reverse(); + const active = jobs.filter(j => j.state === 'Laeuft' || j.state === 'Wartet').length; + $('#jobBadge').textContent = active; $('#jobBadge').classList.toggle('hidden', !active); + for (const j of jobs) { + if (knownStates[j.id] && knownStates[j.id] !== j.state && !/Laeuft|Wartet/.test(j.state)) + toast(`${j.title}: ${j.state}`, /Fehl|Abgeb/.test(j.state), () => { selJob = j.id; showTab('jobs'); }); + knownStates[j.id] = j.state; + } + $('#jobList').innerHTML = jobs.length ? jobs.map(j => `
+
${esc(j.title)}
#${j.id} · ${j.created}
${esc(stateName(j.state))}
`).join('') + : '

Noch keine Aufgaben.

'; + if (selJob && $('#tab-jobs').classList.contains('active')) { + const j = await api('GET', '/api/jobs/' + selJob); + $('#jobTitle').textContent = `#${j.id} ${j.title} – ${stateName(j.state)}`; + const pre = $('#jobLog'), atBottom = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 30; + pre.innerHTML = esc(j.log || '').replace(/^.*\[(ERROR|WARN|OK)\].*$/gm, (l, lv) => `${l}`); + if (atBottom) pre.scrollTop = pre.scrollHeight; + $('#btnJobStop').classList.toggle('hidden', !/Laeuft|Wartet/.test(j.state)); + } + jobTimer = setTimeout(pollJobs, active ? 1500 : 5000); + } catch { jobTimer = setTimeout(pollJobs, 5000); } +} +$('#jobList').onclick = e => { const it = e.target.closest('[data-j]'); if (it) { selJob = it.dataset.j; pollJobs(); } }; +$('#btnJobStop').onclick = () => { if (selJob && confirm('Aufgabe abbrechen?')) guard(async () => { await api('DELETE', '/api/jobs/' + selJob); pollJobs(); }); }; +async function waitJob(id) { + for (; ;) { + await new Promise(r => setTimeout(r, 2000)); + const j = await api('GET', '/api/jobs/' + id); + if (!/Laeuft|Wartet/.test(j.state)) return j; + } +} + +$('#btnShutdown').onclick = () => { if (confirm('Agent beenden? Laufende Aufgaben werden abgebrochen.')) api('POST', '/api/shutdown', {}).finally(() => { document.body.innerHTML = '

Agent beendet.

Das Fenster kann geschlossen werden.

'; }); }; + +// ---------------------------------------------------------------- Start +function renderAll() { renderOffice(); renderSelectedApps(); renderPlanned(); renderSummary(); } +(async () => { + if (!TOKEN) { document.body.innerHTML = '

Kein Zugriffstoken

Bitte die vollständige URL (mit ?token=…) aus dem Agent-Fenster öffnen.

'; return; } + bindForms(); renderAll(); fillForms(); + await guard(loadStatus); + await guard(loadCatalog); + await guard(async () => { + const p = await api('GET', '/api/presets'); + presets = arr(p.shortcuts); + $('#scPresets').innerHTML = presets.map((s, i) => ``).join(''); + $('#tzSelect').insertAdjacentHTML('beforeend', arr(p.timezones).map(t => ``).join('')); + fillForms(); + }); + if (location.hash) showTab(location.hash.slice(1)); + pollJobs(); +})(); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..8ae0074 --- /dev/null +++ b/web/index.html @@ -0,0 +1,350 @@ + + + + + +KDS Install Agent + + + + + + +
+ +
+

Übersicht

+
+
+

Komplette Einrichtung mit einem Klick

+

Führt die Einstellungen aus allen Tabs in sinnvoller Reihenfolge aus. Vorher in den Tabs konfigurieren oder ein Profil laden.

+
+ + + + + + + + + +
+
+ +
+
+ + +
+

Programme

+
+
+
+

Suche in öffentlichen Bibliotheken

+
+ + + +
+
+
+
+

Schnellauswahl

+
+
+
+

Eigene Installer vom Stick (Ordner installers\)

+
+ + + +
+
+
+
+
+

Ausgewählt 0

+
+ + +
+
+
+
+ + +
+

Microsoft Office

+
+
+

Konfiguration

+ + + + + +

Sprachen

+
+ + + + + + + + + +
+

Apps (abgewählte werden nicht installiert)

+
+

Zusatzprodukte

+
+

Optionen

+
+ + + + + + + +
+
+
+
+

Generierte configuration.xml

+

+        
+ + +
+
+
+

Ausführen

+ +

setup.exe des Office Deployment Tools wird bei Bedarf automatisch von Microsoft geladen und unter office\ abgelegt. Builds liegen in office\source\.

+
+ +
+
+
+
+ + +
+

Desktop-Icons für alle Benutzer

+
+
+
+

Neue Verknüpfung

+ + + + + + +
+ + +
+
+
+

Vorlagen

+
+
+
+
+
+

Im Profil geplant

+
+ +
+
+

Aktuell auf dem öffentlichen Desktop

+
+
+
+
+
+ + +
+

System & Sicherheit

+
+
+

System

+ + +
+ + +
+ +
+
+

Sicherheit

+
+ + + + + + + + +
+ +

Lokales Admin-Konto (optional)

+
+ + +
+

Hinweis: Das Kennwort wird beim Speichern im Profil im Klartext abgelegt.

+ +
+
+
+ + +
+

Energie

+
+
+ + + + + + + + +
Netzbetrieb 🔌Akku 🔋
Bildschirm aus nach (Min., 0 = nie)
Energiesparmodus nach
Ruhezustand nach
Deckel schließen
Netzschalter
+
+ + +
+ +
+
+ + +
+

BitLocker

+
+
+

Status

+
Lade …
+
+
+

Aktivieren

+ +
+ + + + + +
+ +

Der Wiederherstellungsschlüssel wird immer auf dem Stick unter bitlocker-keys\ abgelegt (Computername + Seriennummer).

+
+ + + +
+
+
+
+ + +
+

Updates & Treiber

+
+
+ + + + + + + + + +
+
+
+

Gefundene Updates 0

+

Noch keine Suche durchgeführt.

+
+ + +
+
+
+ + +
+

Profile

+
+
+

Gespeicherte Profile (Ordner profiles\)

+
+
+
+

Aktuelle Konfiguration speichern

+
+

Import / Export

+
+ + + +
+
+
+
+ + +
+

Aufgaben

+
+
+
+

Protokoll

+

+      
+
+
+
+
+ +
+ + + diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..37326f8 --- /dev/null +++ b/web/style.css @@ -0,0 +1,83 @@ +:root { + --bg: #f3f5f8; --card: #fff; --text: #1b2330; --muted: #677284; --line: #e1e5eb; + --accent: #0a64d6; --accent-2: #e8f1fd; --ok: #138a4b; --warn: #b26a00; --err: #c62828; + --side: #121a27; --side-text: #c7d0dd; +} +@media (prefers-color-scheme: dark) { + :root { --bg: #0f141c; --card: #182030; --text: #e4e9f0; --muted: #8d98a8; --line: #2a3446; --accent: #4c95ff; --accent-2: #1d2c45; --side: #0a0e14; } +} +* { box-sizing: border-box; } +html, body { margin: 0; height: 100%; } +body { display: flex; font: 14px/1.45 "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif; background: var(--bg); color: var(--text); } +.side { width: 220px; flex: none; background: var(--side); color: var(--side-text); display: flex; flex-direction: column; } +.brand { padding: 20px 18px; font-size: 18px; font-weight: 700; color: #fff; } +.brand span { font-weight: 400; color: var(--side-text); } +nav { flex: 1; overflow: auto; } +nav a { display: flex; justify-content: space-between; align-items: center; padding: 9px 18px; cursor: pointer; border-left: 3px solid transparent; } +nav a:hover { background: rgba(255,255,255,.05); } +nav a.active { background: rgba(255,255,255,.08); border-left-color: var(--accent); color: #fff; } +.side-foot { padding: 14px 18px; display: grid; gap: 8px; } +main { flex: 1; overflow: auto; padding: 24px 28px 60px; } +h1 { margin: 0 0 18px; font-size: 24px; font-weight: 600; } +h2 { margin: 0 0 12px; font-size: 15px; font-weight: 600; display: flex; align-items: center; gap: 8px; } +h3 { margin: 16px 0 6px; font-size: 13px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .03em; } +.tab { display: none; } .tab.active { display: block; } +.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 18px; margin-bottom: 16px; } +.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: start; } +.cols.jobs { grid-template-columns: 340px 1fr; } +.grid4 { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-bottom: 16px; } +.stat { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; } +.stat .k { font-size: 12px; color: var(--muted); } .stat .v { font-weight: 600; word-break: break-word; } +.sticky { position: sticky; top: 0; } +.muted { color: var(--muted); } .small { font-size: 12px; } +.row { display: flex; gap: 8px; align-items: end; margin-top: 10px; } .row.wrap { flex-wrap: wrap; } .row > input, .row > select { flex: 1; } +.sep { width: 1px; align-self: stretch; background: var(--line); margin: 0 4px; } +label.f { display: block; margin-bottom: 10px; font-size: 12px; color: var(--muted); } +label.f input, label.f select { display: block; width: 100%; margin-top: 3px; } +.row label.f { flex: 1; margin: 0; } +input, select { font: inherit; color: var(--text); background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 7px 9px; min-width: 0; } +input[type=checkbox] { padding: 0; accent-color: var(--accent); width: 16px; height: 16px; } +input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: -1px; } +button, .button { font: inherit; cursor: pointer; border: 1px solid var(--line); background: var(--card); color: var(--text); padding: 7px 14px; border-radius: 6px; display: inline-block; } +button:hover, .button:hover { border-color: var(--accent); } +button.primary { background: var(--accent); border-color: var(--accent); color: #fff; } +button.primary:hover { filter: brightness(1.1); } +button.danger { color: var(--err); } button.danger:hover { border-color: var(--err); } +button.ghost { background: transparent; color: inherit; } +button.small { padding: 2px 8px; font-size: 12px; } +button.big { font-size: 16px; padding: 12px 26px; margin-top: 14px; } +button:disabled { opacity: .5; cursor: default; } +.card > button { margin-top: 12px; margin-right: 6px; } +.checks { display: flex; flex-wrap: wrap; gap: 6px 16px; } .checks.col { flex-direction: column; } +.checks label { display: flex; gap: 7px; align-items: center; cursor: pointer; } +.cat { margin-bottom: 12px; } .cat h3 { margin-top: 0; } +.list { display: flex; flex-direction: column; max-height: 420px; overflow: auto; } +.item { display: flex; align-items: center; gap: 10px; padding: 8px 4px; border-bottom: 1px solid var(--line); } +.item:last-child { border-bottom: 0; } +.item .grow { flex: 1; min-width: 0; } .item .sub { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.item.click { cursor: pointer; } .item.click:hover, .item.sel { background: var(--accent-2); } +.badge { background: var(--accent); color: #fff; border-radius: 10px; padding: 0 7px; font-size: 11px; font-weight: 600; } +.hidden { display: none !important; } +.code { background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 12px; font: 12px/1.5 Consolas, "Cascadia Mono", monospace; white-space: pre-wrap; word-break: break-all; max-height: 460px; overflow: auto; margin: 0; } +.log { min-height: 380px; max-height: calc(100vh - 220px); } +.log .ERROR { color: var(--err); font-weight: 600; } .log .WARN { color: var(--warn); } .log .OK { color: var(--ok); } +.chips { display: flex; flex-wrap: wrap; gap: 6px; } +.chips button { border-radius: 16px; padding: 4px 12px; } +.state { font-size: 11px; padding: 1px 8px; border-radius: 10px; background: var(--line); white-space: nowrap; } +.state.run { background: var(--accent); color: #fff; } .state.ok { background: var(--ok); color: #fff; } +.state.warn { background: var(--warn); color: #fff; } .state.err { background: var(--err); color: #fff; } +.ptable { border-collapse: collapse; margin: 6px 0 14px; } +.ptable td, .ptable th { padding: 5px 10px 5px 0; text-align: left; font-weight: 400; } +.ptable th { color: var(--muted); font-size: 12px; } .ptable input, .ptable select { width: 150px; } +.summary { margin-top: 12px; font-size: 13px; color: var(--muted); } +.vol { padding: 10px; border: 1px solid var(--line); border-radius: 8px; margin-bottom: 8px; } +.bar { height: 6px; background: var(--line); border-radius: 3px; overflow: hidden; margin-top: 6px; } .bar i { display: block; height: 100%; background: var(--ok); } +code { font-family: Consolas, monospace; font-size: 12px; } +#toast { position: fixed; right: 20px; bottom: 20px; display: flex; flex-direction: column; gap: 8px; z-index: 9; } +#toast div { background: var(--side); color: #fff; padding: 10px 16px; border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,.25); cursor: pointer; max-width: 380px; } +#toast div.err { background: var(--err); } +@media (max-width: 900px) { + body { flex-direction: column; } .side { width: auto; } nav { display: flex; overflow-x: auto; } nav a { white-space: nowrap; border-left: 0; border-bottom: 3px solid transparent; } + nav a.active { border-bottom-color: var(--accent); } .side-foot { display: none; } + main { padding: 16px; } .cols, .cols.jobs { grid-template-columns: 1fr; } +}