diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a5a9123 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,51 @@ +name: Release + +on: + push: + branches: + - main + - master + workflow_dispatch: + +permissions: + contents: write + +jobs: + build-release: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build dist + run: make dist + + - name: Package release zip + run: | + short_sha="${GITHUB_SHA::7}" + archive="softwarekatalog-${short_sha}.zip" + + echo "SHORT_SHA=${short_sha}" >> "${GITHUB_ENV}" + echo "ARCHIVE=${archive}" >> "${GITHUB_ENV}" + + rm -rf package + mkdir -p package + cp LICENSE package/LICENSE + cp dist/setup.ps1 package/setup.ps1 + cp -R PolicyDefinitions package/PolicyDefinitions + + (cd package && zip -r "../${archive}" LICENSE setup.ps1 PolicyDefinitions) + + - name: Create or update release + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh release view "${SHORT_SHA}" >/dev/null 2>&1; then + gh release upload "${SHORT_SHA}" "${ARCHIVE}" --clobber + else + gh release create "${SHORT_SHA}" "${ARCHIVE}" \ + --target "${GITHUB_SHA}" \ + --title "${SHORT_SHA}" \ + --notes "Automated build for ${GITHUB_SHA}." + fi diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4a9cb01 --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ +.PHONY: dist clean + +dist: dist/setup.ps1 + +dist/setup.ps1: setup.ps1 scripts/build-dist.sh + ./scripts/build-dist.sh setup.ps1 dist/setup.ps1 + +clean: + rm -rf dist diff --git a/README.md b/README.md index a447299..b1b7493 100644 --- a/README.md +++ b/README.md @@ -48,10 +48,18 @@ The client reads up to 500 catalog entries and 500 required entries. ## Install +Build the ASCII-only distribution wrapper: + +```sh +make dist +``` + +Use `dist/setup.ps1` for deployment. It is ASCII-only and decodes the UTF-8 payload at runtime, avoiding Windows PowerShell 5.1 encoding issues with downloaded scripts. + Run as admin or as computer startup script: ```powershell -powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\setup.ps1 -Mode Install +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\dist\setup.ps1 -Mode Install ``` Generated files are written to: diff --git a/scripts/build-dist.sh b/scripts/build-dist.sh new file mode 100755 index 0000000..caca93a --- /dev/null +++ b/scripts/build-dist.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +src="${1:-setup.ps1}" +out="${2:-dist/setup.ps1}" +out_dir="$(dirname "$out")" + +mkdir -p "$out_dir" + +if base64 --help 2>&1 | grep -q -- '-w'; then + payload="$(base64 -w 0 "$src")" +else + payload="$(base64 "$src" | tr -d '\n')" +fi + +cat > "$out" < GUI / request helper -> named pipe -> daemon task (SYSTEM) -> winget install --scope machine @@ -20,8 +20,13 @@ C:\ProgramData\__Softwarekatalog\ [CmdletBinding()] param( - [ValidateSet('Install','Daemon','UpgradeAll','ApplyPolicy')] - [string]$Mode = 'Install' + [ValidateSet('Install','Daemon','UpgradeAll','ApplyPolicy','ProcessRequest')] + [string]$Mode = 'Install', + [ValidateSet('Install','Uninstall')] + [string]$RequestAction = 'Install', + [string]$AppAlias, + [string]$RequestedBy, + [string]$RequestedBySid ) Set-StrictMode -Version Latest @@ -360,6 +365,77 @@ function Invoke-WithWingetLock { } } +function Test-WingetLockAvailable { + $mutex = New-Object System.Threading.Mutex($false, $WingetMutexName) + $hasLock = $false + + try { + $hasLock = $mutex.WaitOne(0) + return $hasLock + } + finally { + if ($hasLock) { + $mutex.ReleaseMutex() + } + $mutex.Dispose() + } +} + +function ConvertTo-ProcessArgument { + param([Parameter(Mandatory)][string]$Value) + + if ($Value -match '[\s"]') { + return '"{0}"' -f ($Value.Replace('"', '\"')) + } + + return $Value +} + +function Get-WorkerScriptPath { + if (Test-Path -LiteralPath $LocalScriptPath) { + return $LocalScriptPath + } + + if ($PSCommandPath -and (Test-Path -LiteralPath $PSCommandPath)) { + return $PSCommandPath + } + + throw 'Could not determine worker script path.' +} + +function Start-SoftwarekatalogWorker { + param( + [Parameter(Mandatory)][ValidateSet('ApplyPolicy','ProcessRequest')][string]$WorkerMode, + [ValidateSet('Install','Uninstall')][string]$WorkerAction = 'Install', + [string]$WorkerAlias, + [string]$WorkerRequestedBy, + [string]$WorkerRequestedBySid + ) + + $workerScript = Get-WorkerScriptPath + $args = @( + '-NoProfile', + '-ExecutionPolicy', 'Bypass', + '-File', $workerScript, + '-Mode', $WorkerMode + ) + + if ($WorkerMode -eq 'ProcessRequest') { + $args += @('-RequestAction', $WorkerAction, '-AppAlias', $WorkerAlias) + + if (-not [string]::IsNullOrWhiteSpace($WorkerRequestedBy)) { + $args += @('-RequestedBy', $WorkerRequestedBy) + } + + if (-not [string]::IsNullOrWhiteSpace($WorkerRequestedBySid)) { + $args += @('-RequestedBySid', $WorkerRequestedBySid) + } + } + + $argumentLine = (@($args) | ForEach-Object { ConvertTo-ProcessArgument -Value ([string]$_) }) -join ' ' + Start-Process -FilePath $PowerShellExe -ArgumentList $argumentLine -WindowStyle Hidden | Out-Null +} + function Get-WingetPath { $candidates = @() @@ -491,7 +567,7 @@ function Invoke-CatalogDaemon { ) try { - `$client.Connect(30000) + `$client.Connect(60000) `$reader = New-Object System.IO.StreamReader(`$client, `$encoding) `$writer = New-Object System.IO.StreamWriter(`$client, `$encoding) `$writer.AutoFlush = `$true @@ -509,6 +585,9 @@ function Invoke-CatalogDaemon { return `$response } + catch [System.TimeoutException] { + throw 'Der Softwarekatalog-Dienst ist nicht erreichbar oder startet noch. Bitte versuchen Sie es erneut.' + } finally { `$client.Dispose() } @@ -535,10 +614,10 @@ if (-not `$AppAlias) { `$response = Invoke-CatalogDaemon -Command `$Action -Alias `$alias if (`$Action -eq 'Uninstall') { - Write-Host "Deinstallation für '`$alias' abgeschlossen." + Write-Host "Deinstallation für '`$alias' gestartet." } else { - Write-Host "Installation für '`$alias' abgeschlossen." + Write-Host "Installation für '`$alias' gestartet." } if (`$response.Message) { @@ -687,7 +766,7 @@ function Invoke-CatalogDaemon { ) try { - `$client.Connect(30000) + `$client.Connect(60000) `$reader = New-Object System.IO.StreamReader(`$client, `$encoding) `$writer = New-Object System.IO.StreamWriter(`$client, `$encoding) `$writer.AutoFlush = `$true @@ -705,6 +784,9 @@ function Invoke-CatalogDaemon { return `$response } + catch [System.TimeoutException] { + throw 'Der Softwarekatalog-Dienst ist nicht erreichbar oder startet noch. Bitte versuchen Sie es erneut.' + } finally { `$client.Dispose() } @@ -1047,10 +1129,10 @@ function Load-AppList { `$status.Text = "Installation läuft: {0}" -f `$selected.DisplayName `$response = New-AppRequest -Alias `$selected.Alias -Action 'Install' - `$status.Text = if (`$response.Message) { `$response.Message } else { "Installation abgeschlossen: {0}" -f `$selected.DisplayName } + `$status.Text = if (`$response.Message) { `$response.Message } else { "Installation gestartet: {0}" -f `$selected.DisplayName } [System.Windows.Forms.MessageBox]::Show( - ("Installation abgeschlossen:`r`n`r`n{0}`r`nAlias: {1}" -f `$selected.DisplayName, `$selected.Alias), + ("Installation gestartet:`r`n`r`n{0}`r`nAlias: {1}" -f `$selected.DisplayName, `$selected.Alias), `$CatalogTitle, [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Information @@ -1097,10 +1179,10 @@ function Load-AppList { `$status.Text = "Deinstallation läuft: {0}" -f `$selected.DisplayName `$response = New-AppRequest -Alias `$selected.Alias -Action 'Uninstall' - `$status.Text = if (`$response.Message) { `$response.Message } else { "Deinstallation abgeschlossen: {0}" -f `$selected.DisplayName } + `$status.Text = if (`$response.Message) { `$response.Message } else { "Deinstallation gestartet: {0}" -f `$selected.DisplayName } [System.Windows.Forms.MessageBox]::Show( - ("Deinstallation abgeschlossen:`r`n`r`n{0}`r`nAlias: {1}" -f `$selected.DisplayName, `$selected.Alias), + ("Deinstallation gestartet:`r`n`r`n{0}`r`nAlias: {1}" -f `$selected.DisplayName, `$selected.Alias), `$CatalogTitle, [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Information @@ -1740,21 +1822,18 @@ function Invoke-CatalogDaemonCommand { 'applypolicy' { Write-Log "Authenticated policy apply requested by user='$($ClientIdentity.Name)' sid='$($ClientIdentity.Sid)'" - $wingetPath = Ensure-WingetAvailable - if (-not $wingetPath) { - throw 'winget is unavailable.' + + if (-not (Test-WingetLockAvailable)) { + throw 'Der Softwarekatalog-Dienst ist gerade mit einer anderen winget-Aktion beschäftigt. Bitte später erneut versuchen.' } - $result = Invoke-RequiredSoftwarePolicy -WingetPath $wingetPath + Start-SoftwarekatalogWorker -WorkerMode ApplyPolicy return [pscustomobject]@{ - Success = ($result.Failed -eq 0) + Success = $true Command = $command RequestedBy = $ClientIdentity.Name RequestedBySid = $ClientIdentity.Sid - Installed = $result.Installed - Failed = $result.Failed - Skipped = $result.Skipped - Message = "Policy angewendet. Installiert=$($result.Installed), Fehler=$($result.Failed), Übersprungen=$($result.Skipped)." + Message = 'Policyanwendung gestartet.' } } @@ -1781,34 +1860,11 @@ function Invoke-CatalogDaemonCommand { $actionText = if ($command -eq 'uninstall') { 'uninstall' } else { 'install' } Write-Log "Authenticated request: action=$actionText alias='$alias' package='$packageId' user='$($ClientIdentity.Name)' sid='$($ClientIdentity.Sid)'" - $wingetPath = Ensure-WingetAvailable - if (-not $wingetPath) { - throw 'winget is unavailable.' + if (-not (Test-WingetLockAvailable)) { + throw 'Der Softwarekatalog-Dienst ist gerade mit einer anderen winget-Aktion beschäftigt. Bitte später erneut versuchen.' } - $result = Invoke-WithWingetLock -ScriptBlock { - if ($command -eq 'uninstall') { - $exitCode = Invoke-WingetUninstall -WingetPath $wingetPath -PackageId $packageId - } - else { - $exitCode = Invoke-WingetInstall -WingetPath $wingetPath -PackageId $packageId - } - - $snapshotUpdated = $false - if ($exitCode -eq 0) { - $snapshotUpdated = Update-InstalledPackagesSnapshot -WingetPath $wingetPath - } - - [pscustomobject]@{ - ExitCode = $exitCode - SnapshotUpdated = [bool]$snapshotUpdated - } - } - $result = @($result)[-1] - - if ($result.ExitCode -ne 0) { - throw "winget $actionText exited with code $($result.ExitCode) for package '$packageId'." - } + Start-SoftwarekatalogWorker -WorkerMode ProcessRequest -WorkerAction $command -WorkerAlias $alias -WorkerRequestedBy $ClientIdentity.Name -WorkerRequestedBySid $ClientIdentity.Sid $verb = if ($command -eq 'uninstall') { 'Deinstallation' } else { 'Installation' } return [pscustomobject]@{ @@ -1818,9 +1874,7 @@ function Invoke-CatalogDaemonCommand { PackageId = $packageId RequestedBy = $ClientIdentity.Name RequestedBySid = $ClientIdentity.Sid - ExitCode = $result.ExitCode - SnapshotUpdated = $result.SnapshotUpdated - Message = "$verb abgeschlossen: $alias" + Message = "$verb gestartet: $alias" } } @@ -1831,9 +1885,18 @@ function Invoke-CatalogDaemonCommand { throw 'winget is unavailable.' } - $result = Invoke-WithWingetLock -ScriptBlock { - $snapshotUpdated = Update-InstalledPackagesSnapshot -WingetPath $wingetPath - [pscustomobject]@{ SnapshotUpdated = [bool]$snapshotUpdated } + try { + $result = Invoke-WithWingetLock -TimeoutSeconds 2 -ScriptBlock { + $snapshotUpdated = Update-InstalledPackagesSnapshot -WingetPath $wingetPath + [pscustomobject]@{ SnapshotUpdated = [bool]$snapshotUpdated } + } + } + catch { + if ($_.Exception.Message -like 'Timed out waiting for winget lock*') { + throw 'Der Softwarekatalog-Dienst ist gerade mit einer anderen winget-Aktion beschäftigt. Bitte später erneut versuchen.' + } + + throw } $result = @($result)[-1] @@ -1860,13 +1923,10 @@ function Start-CatalogDaemon { Write-Log "Named pipe daemon starting on '\\.\pipe\$PipeName'." try { - $wingetPath = Ensure-WingetAvailable - if ($wingetPath) { - $null = Invoke-RequiredSoftwarePolicy -WingetPath $wingetPath - } + Start-SoftwarekatalogWorker -WorkerMode ApplyPolicy } catch { - Write-Log "Initial required software enforcement failed: $($_.Exception.Message)" 'WARN' + Write-Log "Could not start initial required software enforcement worker: $($_.Exception.Message)" 'WARN' } while ($true) { @@ -1970,6 +2030,67 @@ function Invoke-ApplyPolicyMode { } } +function Invoke-ProcessRequestMode { + Ensure-Directory -Path $BaseDir + Ensure-Directory -Path $LogDir + + if ([string]::IsNullOrWhiteSpace($AppAlias)) { + throw 'AppAlias is required for ProcessRequest mode.' + } + + $alias = $AppAlias.Trim().ToLowerInvariant() + $app = Get-AppCatalogObjectByAlias -Alias $alias + if (-not $app) { + Write-Log "Worker rejected alias '$alias' because it is no longer in catalog policy. RequestedBy='$RequestedBy' Sid='$RequestedBySid'" 'WARN' + return + } + + if ($RequestAction -eq 'Install' -and -not (Test-UserInstallsAllowed)) { + Write-Log "Worker rejected install for alias '$alias' because user installs are disabled. RequestedBy='$RequestedBy' Sid='$RequestedBySid'" 'WARN' + return + } + + if ($RequestAction -eq 'Uninstall' -and -not (Test-UserUninstallAllowed)) { + Write-Log "Worker rejected uninstall for alias '$alias' because user uninstalls are disabled. RequestedBy='$RequestedBy' Sid='$RequestedBySid'" 'WARN' + return + } + + $wingetPath = Ensure-WingetAvailable + if (-not $wingetPath) { + Write-Log "Worker aborting $RequestAction for alias '$alias' because winget is unavailable. RequestedBy='$RequestedBy' Sid='$RequestedBySid'" 'ERROR' + return + } + + Write-Log "Worker starting action=$RequestAction alias='$alias' package='$($app.PackageId)' RequestedBy='$RequestedBy' Sid='$RequestedBySid'" + + $result = Invoke-WithWingetLock -ScriptBlock { + if ($RequestAction -eq 'Uninstall') { + $exitCode = Invoke-WingetUninstall -WingetPath $wingetPath -PackageId ([string]$app.PackageId) + } + else { + $exitCode = Invoke-WingetInstall -WingetPath $wingetPath -PackageId ([string]$app.PackageId) + } + + $snapshotUpdated = $false + if ($exitCode -eq 0) { + $snapshotUpdated = Update-InstalledPackagesSnapshot -WingetPath $wingetPath + } + + [pscustomobject]@{ + ExitCode = $exitCode + SnapshotUpdated = [bool]$snapshotUpdated + } + } + $result = @($result)[-1] + + if ($result.ExitCode -eq 0) { + Write-Log "Worker completed action=$RequestAction alias='$alias' package='$($app.PackageId)' SnapshotUpdated=$($result.SnapshotUpdated) RequestedBy='$RequestedBy' Sid='$RequestedBySid'" + } + else { + Write-Log "Worker failed action=$RequestAction alias='$alias' package='$($app.PackageId)' code=$($result.ExitCode) RequestedBy='$RequestedBy' Sid='$RequestedBySid'" 'ERROR' + } +} + function Install-Service { Ensure-Directory -Path $BaseDir Ensure-Directory -Path $LogDir @@ -2010,6 +2131,7 @@ try { 'Daemon' { Start-CatalogDaemon } 'UpgradeAll' { Invoke-UpgradeAllMode } 'ApplyPolicy' { Invoke-ApplyPolicyMode } + 'ProcessRequest' { Invoke-ProcessRequestMode } default { throw "Unsupported mode '$Mode'." } } }