#Requires -Version 5.1 #Requires -RunAsAdministrator <# .SYNOPSIS SMTPGraphRelay Installer / Repair / Update .DESCRIPTION Einheitliches Verwaltungswerkzeug für SMTPGraphRelay. Modi: 1 - Neuinstallation 2 - Installation reparieren 3 - Relay aktualisieren 4 - Entra / Exchange RBAC prüfen 5 - Zertifikat erneuern 6 - Health Check ausführen 7 - Deinstallieren WICHTIG: Dieses Skript muss mit Windows PowerShell 5.1 ausgeführt werden. #> [CmdletBinding()] param( [string]$InstallPath = "$env:ProgramFiles\SMTPGraphRelay" ) $ErrorActionPreference = "Stop" [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 $TaskName = "SMTPGraphRelay" $AppDefaultName = "SMTPGraphRelay" $RelayFileName = "SMTPGraphRelay.ps1" $HealthFileName = "Test-SMTPGraphRelay.ps1" $RenewFileName = "Renew-SMTPGraphRelayCertificate.ps1" $ConfigFileName = "config.json" # Zentrales Gitea-Repository / Updatequelle $RepoBaseUrl = "https://me-gitea.maieredv.cloud/MAIEREDV/SMTPGraphRelay" $RemoteVersionUrl = "$RepoBaseUrl/raw/branch/main/version.json" $RemoteArchiveUrl = "$RepoBaseUrl/archive/main.zip" $RemoteRelayUrl = "$RepoBaseUrl/raw/branch/main/SMTPGraphRelay.ps1" # Dateien, die ein Update verändern darf. # config.json, Queue, Logs und sonstige lokale Daten sind absichtlich NICHT enthalten. $ManagedReleaseFiles = @( "SMTPGraphRelay.ps1", "Test-SMTPGraphRelay.ps1", "Renew-SMTPGraphRelayCertificate.ps1", "Setup-SMTPGraphRelay.ps1", "version.json", "README.md" ) function Write-Title { param([string]$Text) Write-Host "" Write-Host "==========================================================" -ForegroundColor Cyan Write-Host " $Text" -ForegroundColor Cyan Write-Host "==========================================================" -ForegroundColor Cyan Write-Host "" } function Write-Ok { param([string]$Text) Write-Host "[OK] $Text" -ForegroundColor Green } function Write-Warn { param([string]$Text) Write-Host "[WARN] $Text" -ForegroundColor Yellow } function Write-Fail { param([string]$Text) Write-Host "[FAIL] $Text" -ForegroundColor Red } function Write-Info { param([string]$Text) Write-Host "[INFO] $Text" -ForegroundColor Cyan } function Read-Default { param([string]$Prompt, [string]$Default) $value = Read-Host "$Prompt [Standard: $Default]" if ([string]::IsNullOrWhiteSpace($value)) { return $Default } return $value } function Confirm-Yes { param([string]$Prompt) $answer = Read-Host "$Prompt [j/N]" return ($answer -match '^(?i)j|ja|y|yes$') } function Assert-WindowsPowerShell51 { if ($PSVersionTable.PSEdition -ne "Desktop" -or $PSVersionTable.PSVersion.Major -ne 5) { Write-Title "FALSCHE POWERSHELL-VERSION" Write-Fail "Dieses Tool muss mit Windows PowerShell 5.1 ausgeführt werden." Write-Host "" Write-Host "Aktuell erkannt:" Write-Host " Edition: $($PSVersionTable.PSEdition)" Write-Host " Version: $($PSVersionTable.PSVersion)" Write-Host "" Write-Host "Bitte starten:" Write-Host " C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" -ForegroundColor Yellow exit 1 } Write-Ok "Windows PowerShell $($PSVersionTable.PSVersion) erkannt." } function Ensure-PackageProvider { try { if (-not (Get-PackageProvider -Name NuGet -ListAvailable -ErrorAction SilentlyContinue)) { Write-Info "NuGet Package Provider wird installiert..." Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force | Out-Null } } catch { Write-Warn "NuGet Provider konnte nicht automatisch vorbereitet werden: $($_.Exception.Message)" } } function Ensure-Modules { param( [switch]$IncludeExchange ) Ensure-PackageProvider $modules = @( "Microsoft.Graph.Authentication", "Microsoft.Graph.Applications" ) if ($IncludeExchange) { $modules += "ExchangeOnlineManagement" } foreach ($module in $modules) { $existing = Get-Module -ListAvailable -Name $module | Sort-Object Version -Descending | Select-Object -First 1 if (-not $existing) { Write-Info "$module fehlt. Installation für AllUsers..." Install-Module $module -Scope AllUsers -Repository PSGallery -Force -AllowClobber $existing = Get-Module -ListAvailable -Name $module | Sort-Object Version -Descending | Select-Object -First 1 } if (-not $existing) { throw "Modul '$module' konnte nicht installiert/gefunden werden." } # Schutz gegen den bereits beobachteten PowerShell-7-Pfad. if ($existing.ModuleBase -notmatch '\\WindowsPowerShell\\Modules\\') { Write-Warn "$module wurde gefunden, aber nicht im Windows-PowerShell-Modulpfad: $($existing.ModuleBase)" Write-Info "Installiere das Modul nochmals explizit aus Windows PowerShell 5.1..." Install-Module $module -Scope AllUsers -Repository PSGallery -Force -AllowClobber } Write-Ok "$module verfügbar." } } function New-RepoStagingArea { Enable-Tls12 $root = Join-Path $env:TEMP ("SMTPGraphRelay-repo-{0}" -f [guid]::NewGuid().ToString("N")) $archive = Join-Path $root "main.zip" $extract = Join-Path $root "extract" New-Item -ItemType Directory -Path $extract -Force | Out-Null Write-Info "Lade aktuellen Stand aus Gitea..." Write-Info "Quelle: $RemoteArchiveUrl" Invoke-WebRequest ` -Uri $RemoteArchiveUrl ` -OutFile $archive ` -UseBasicParsing ` -TimeoutSec 120 ` -ErrorAction Stop if (-not (Test-Path -LiteralPath $archive)) { throw "Gitea-Archiv wurde nicht heruntergeladen." } Expand-Archive ` -LiteralPath $archive ` -DestinationPath $extract ` -Force $releaseRoot = Find-ExtractedReleaseRoot -ExtractPath $extract # Pflichtdateien prüfen. foreach ($required in @("SMTPGraphRelay.ps1", "version.json")) { if (-not (Test-Path -LiteralPath (Join-Path $releaseRoot $required))) { throw "Repository-Archiv ist unvollständig: '$required' fehlt." } } $versionInfo = Get-Content ` -LiteralPath (Join-Path $releaseRoot "version.json") ` -Raw ` -Encoding UTF8 | ConvertFrom-Json if (-not $versionInfo.Version) { throw "version.json aus dem Repository enthält keine Version." } Write-Ok "Repository-Version $($versionInfo.Version) geladen." return [pscustomobject]@{ TempRoot = $root ReleaseRoot = $releaseRoot VersionInfo = $versionInfo } } function Remove-RepoStagingArea { param($Staging) if ($Staging -and $Staging.TempRoot) { Remove-Item -LiteralPath $Staging.TempRoot -Recurse -Force -ErrorAction SilentlyContinue } } function Install-RepoProgramFiles { param( [Parameter(Mandatory)][string]$ReleaseRoot, [Parameter(Mandatory)][string]$TargetPath ) New-Item -ItemType Directory -Path $TargetPath -Force | Out-Null foreach ($name in $ManagedReleaseFiles) { $source = Join-Path $ReleaseRoot $name if (Test-Path -LiteralPath $source) { Copy-Item ` -LiteralPath $source ` -Destination (Join-Path $TargetPath $name) ` -Force Write-Ok "Installiert: $name" } } } function Get-SourceFile { param([Parameter(Mandatory)][string]$Name) $candidate = Join-Path $PSScriptRoot $Name if (Test-Path -LiteralPath $candidate) { return $candidate } return $null } function Get-PackageVersion { $versionFile = Join-Path $PSScriptRoot "version.json" if (-not (Test-Path -LiteralPath $versionFile)) { return $null } try { return Get-Content -LiteralPath $versionFile -Raw -Encoding UTF8 | ConvertFrom-Json } catch { Write-Warn "version.json konnte nicht gelesen werden: $($_.Exception.Message)" return $null } } function Get-InstalledVersion { param([Parameter(Mandatory)][string]$TargetPath) $versionFile = Join-Path $TargetPath "version.json" if (-not (Test-Path -LiteralPath $versionFile)) { return $null } try { return Get-Content -LiteralPath $versionFile -Raw -Encoding UTF8 | ConvertFrom-Json } catch { return $null } } function Copy-ProgramFiles { param( [Parameter(Mandatory)][string]$TargetPath, [switch]$RequireRelay ) New-Item -ItemType Directory -Path $TargetPath -Force | Out-Null $files = @($RelayFileName, $HealthFileName, $RenewFileName, "version.json") foreach ($name in $files) { $source = Get-SourceFile -Name $name if (-not $source) { if ($name -eq $RelayFileName -and $RequireRelay) { throw "Quelldatei '$name' wurde neben dem Installer nicht gefunden." } Write-Warn "Optionale Quelldatei nicht gefunden: $name" continue } $destination = Join-Path $TargetPath $name # Nicht auf sich selbst kopieren. if ([IO.Path]::GetFullPath($source) -ne [IO.Path]::GetFullPath($destination)) { Copy-Item -LiteralPath $source -Destination $destination -Force } Write-Ok "$name bereitgestellt." } # Installer selbst ebenfalls in den Installationsordner legen. try { $selfDest = Join-Path $TargetPath "Setup-SMTPGraphRelay.ps1" if ([IO.Path]::GetFullPath($PSCommandPath) -ne [IO.Path]::GetFullPath($selfDest)) { Copy-Item -LiteralPath $PSCommandPath -Destination $selfDest -Force } } catch {} } function Ensure-Directories { param([Parameter(Mandatory)][string]$TargetPath) foreach ($dir in @( $TargetPath, (Join-Path $TargetPath "queue"), (Join-Path $TargetPath "queue\incoming"), (Join-Path $TargetPath "queue\pending"), (Join-Path $TargetPath "queue\processing"), (Join-Path $TargetPath "failed"), (Join-Path $TargetPath "logs") )) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } Write-Ok "Programm-/Queue-/Log-Verzeichnisse vorhanden." } function Get-RelayConfig { param([Parameter(Mandatory)][string]$TargetPath) $path = Join-Path $TargetPath $ConfigFileName if (-not (Test-Path -LiteralPath $path)) { return $null } try { return Get-Content -LiteralPath $path -Raw -Encoding UTF8 | ConvertFrom-Json } catch { throw "config.json konnte nicht gelesen werden: $($_.Exception.Message)" } } function Write-RelayConfig { param( [Parameter(Mandatory)]$Config, [Parameter(Mandatory)][string]$TargetPath ) $configPath = Join-Path $TargetPath $ConfigFileName $tmp = "$configPath.tmp" $Config | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $tmp -Encoding UTF8 Move-Item -LiteralPath $tmp -Destination $configPath -Force Write-Ok "config.json geschrieben." } function Ensure-FirewallRule { param([Parameter(Mandatory)][int]$Port) $prefix = "SMTPGraphRelay TCP " Get-NetFirewallRule -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like "$prefix*" -and $_.DisplayName -ne "$prefix$Port" } | Remove-NetFirewallRule -ErrorAction SilentlyContinue $ruleName = "$prefix$Port" $existing = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue if (-not $existing) { New-NetFirewallRule ` -DisplayName $ruleName ` -Direction Inbound ` -Action Allow ` -Protocol TCP ` -LocalPort $Port ` -Profile Any | Out-Null Write-Ok "Firewallregel '$ruleName' erstellt." } else { Write-Ok "Firewallregel '$ruleName' vorhanden." } } function Ensure-ScheduledTask { param([Parameter(Mandatory)][string]$TargetPath) $scriptPath = Join-Path $TargetPath $RelayFileName if (-not (Test-Path -LiteralPath $scriptPath)) { throw "Relay-Skript fehlt: $scriptPath" } $psExe = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" $configPath = Join-Path $TargetPath $ConfigFileName $action = New-ScheduledTaskAction ` -Execute $psExe ` -Argument "-NoLogo -NoProfile -ExecutionPolicy Bypass -File `"$scriptPath`" -ConfigPath `"$configPath`"" ` -WorkingDirectory $TargetPath $trigger = New-ScheduledTaskTrigger -AtStartup $principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest $settings = New-ScheduledTaskSettingsSet ` -AllowStartIfOnBatteries ` -DontStopIfGoingOnBatteries ` -StartWhenAvailable ` -RestartCount 5 ` -RestartInterval (New-TimeSpan -Minutes 1) ` -ExecutionTimeLimit ([TimeSpan]::Zero) Register-ScheduledTask ` -TaskName $TaskName ` -Action $action ` -Trigger $trigger ` -Principal $principal ` -Settings $settings ` -Description "Lokaler SMTP Store-and-Forward Relay zu Microsoft 365 via Microsoft Graph" ` -Force | Out-Null Write-Ok "Scheduled Task '$TaskName' eingerichtet." } function Stop-RelayTask { $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue if ($task -and $task.State -eq "Running") { Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue Start-Sleep -Milliseconds 750 } } function Start-RelayTask { $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue if ($task) { Start-ScheduledTask -TaskName $TaskName Start-Sleep -Seconds 2 Write-Ok "Scheduled Task gestartet." } } function Convert-CertToKeyCredential { param([Parameter(Mandatory)][System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate) return @{ Type = "AsymmetricX509Cert" Usage = "Verify" Key = $Certificate.GetRawCertData() DisplayName = "SMTPGraphRelay Certificate" StartDateTime = $Certificate.NotBefore.ToUniversalTime() EndDateTime = $Certificate.NotAfter.ToUniversalTime() } } function New-RelayCertificate { $subject = "CN=SMTPGraphRelay-$env:COMPUTERNAME" return New-SelfSignedCertificate ` -Subject $subject ` -CertStoreLocation "Cert:\LocalMachine\My" ` -KeyAlgorithm RSA ` -KeyLength 2048 ` -HashAlgorithm SHA256 ` -KeyExportPolicy NonExportable ` -KeySpec Signature ` -NotAfter (Get-Date).AddYears(2) } function Connect-RelayGraphAdmin { param([string]$TenantId) Import-Module Microsoft.Graph.Authentication -Force -ErrorAction Stop Import-Module Microsoft.Graph.Applications -Force -ErrorAction Stop Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null if ([string]::IsNullOrWhiteSpace($TenantId)) { Connect-MgGraph -Scopes "Application.ReadWrite.All" -NoWelcome } else { Connect-MgGraph -TenantId $TenantId -Scopes "Application.ReadWrite.All" -NoWelcome } } function Ensure-ExchangeRbac { param( [Parameter(Mandatory)][string]$TenantId, [Parameter(Mandatory)][string]$ClientId, [Parameter(Mandatory)][string]$ServicePrincipalObjectId, [Parameter(Mandatory)][string]$AppName, [Parameter(Mandatory)][string]$SenderMailbox ) Import-Module ExchangeOnlineManagement -Force -ErrorAction Stop Write-Info "Exchange Online Anmeldung erforderlich (Admin)." Connect-ExchangeOnline -ShowBanner:$false -ErrorAction Stop try { $recipient = Get-EXORecipient -Identity $SenderMailbox -ErrorAction Stop Write-Ok "Exchange-Empfänger gefunden: $($recipient.DisplayName)" $exoSp = $null try { $exoSp = Get-ServicePrincipal -Identity $ServicePrincipalObjectId -ErrorAction Stop } catch { Write-Info "Exchange Service Principal wird registriert..." $exoSp = New-ServicePrincipal ` -AppId $ClientId ` -ObjectId $ServicePrincipalObjectId ` -DisplayName $AppName } if (-not $exoSp) { throw "Exchange Service Principal konnte nicht ermittelt/erstellt werden." } $shortId = $ClientId.Substring(0,8) $scopeName = "SMTPGraphRelay-$shortId-Sender" $assignmentName = "SMTPGraphRelay-$shortId-MailSend" $escaped = $SenderMailbox.Replace("'", "''") $filter = "PrimarySmtpAddress -eq '$escaped'" $scope = Get-ManagementScope -Identity $scopeName -ErrorAction SilentlyContinue if ($scope) { Set-ManagementScope -Identity $scopeName -RecipientRestrictionFilter $filter } else { New-ManagementScope -Name $scopeName -RecipientRestrictionFilter $filter | Out-Null } Write-Ok "Exchange Resource Scope: $scopeName -> $SenderMailbox" $assignment = Get-ManagementRoleAssignment -Identity $assignmentName -ErrorAction SilentlyContinue if ($assignment) { Set-ManagementRoleAssignment -Identity $assignmentName -CustomResourceScope $scopeName } else { New-ManagementRoleAssignment ` -Name $assignmentName ` -Role "Application Mail.Send" ` -App $ServicePrincipalObjectId ` -CustomResourceScope $scopeName | Out-Null } Write-Ok "Exchange RBAC 'Application Mail.Send' eingerichtet." $auth = Test-ServicePrincipalAuthorization ` -Identity $ServicePrincipalObjectId ` -Resource $SenderMailbox $mailSend = $auth | Where-Object { $_.RoleName -eq "Application Mail.Send" } | Select-Object -First 1 if (-not $mailSend -or -not $mailSend.InScope) { throw "RBAC-Test für '$SenderMailbox' ist nicht InScope." } Write-Ok "RBAC-Test: $SenderMailbox ist InScope." } finally { Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue } } function Test-NoGlobalMailSend { param( [Parameter(Mandatory)][string]$ServicePrincipalObjectId ) # Microsoft Graph Service Principal $graphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'" -Property "id,appRoles" if (-not $graphSp) { Write-Warn "Microsoft Graph Service Principal konnte nicht geprüft werden." return } $mailSendRole = $graphSp.AppRoles | Where-Object { $_.Value -eq "Mail.Send" -and $_.AllowedMemberTypes -contains "Application" } | Select-Object -First 1 if (-not $mailSendRole) { Write-Warn "Graph AppRole Mail.Send konnte nicht aufgelöst werden." return } $assignments = Get-MgServicePrincipalAppRoleAssignment ` -ServicePrincipalId $ServicePrincipalObjectId ` -All ` -ErrorAction SilentlyContinue $global = $assignments | Where-Object { $_.ResourceId -eq $graphSp.Id -and $_.AppRoleId -eq $mailSendRole.Id } if ($global) { Write-Fail "Die App besitzt zusätzlich globale Microsoft Graph Mail.Send Application Permission." Write-Warn "Diese globale Berechtigung würde Exchange Application RBAC additiv umgehen." } else { Write-Ok "Keine globale Graph Mail.Send Application Permission vorhanden." } } function Enable-Tls12 { try { [Net.ServicePointManager]::SecurityProtocol = ` [Net.ServicePointManager]::SecurityProtocol -bor ` [Net.SecurityProtocolType]::Tls12 } catch {} } function Get-RemoteVersion { Enable-Tls12 $tempFile = Join-Path $env:TEMP ("SMTPGraphRelay-version-{0}.json" -f [guid]::NewGuid().ToString("N")) try { Invoke-WebRequest ` -Uri $RemoteVersionUrl ` -OutFile $tempFile ` -UseBasicParsing ` -TimeoutSec 20 ` -ErrorAction Stop $remote = Get-Content -LiteralPath $tempFile -Raw -Encoding UTF8 | ConvertFrom-Json if (-not $remote.Version) { throw "Remote version.json enthält keine Version." } return $remote } finally { Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue } } function Compare-RelayVersions { param( [Parameter(Mandatory)][string]$Installed, [Parameter(Mandatory)][string]$Remote ) try { $installedVersion = [version]$Installed $remoteVersion = [version]$Remote if ($remoteVersion -gt $installedVersion) { return 1 } if ($remoteVersion -lt $installedVersion) { return -1 } return 0 } catch { throw "Versionsvergleich fehlgeschlagen: installiert='$Installed', remote='$Remote'." } } function Find-ExtractedReleaseRoot { param( [Parameter(Mandatory)][string]$ExtractPath ) # Gitea kann beim Archiv einen zusätzlichen Root-Ordner erzeugen. # Daher suchen wir nach der Kombination aus Relay + version.json statt # einen konkreten Archivordnernamen vorauszusetzen. $relayFiles = Get-ChildItem ` -LiteralPath $ExtractPath ` -Recurse ` -File ` -Filter $RelayFileName ` -ErrorAction SilentlyContinue foreach ($relay in $relayFiles) { $candidate = $relay.Directory.FullName if (Test-Path -LiteralPath (Join-Path $candidate "version.json")) { return $candidate } } throw "Im heruntergeladenen Archiv wurde kein gültiges SMTPGraphRelay-Release gefunden." } function Backup-ManagedFiles { param( [Parameter(Mandatory)][string]$TargetPath ) $backupRoot = Join-Path $TargetPath "backup" $backupPath = Join-Path $backupRoot (Get-Date -Format "yyyyMMdd-HHmmss") New-Item -ItemType Directory -Path $backupPath -Force | Out-Null foreach ($name in $ManagedReleaseFiles) { $source = Join-Path $TargetPath $name if (Test-Path -LiteralPath $source) { Copy-Item -LiteralPath $source -Destination (Join-Path $backupPath $name) -Force } } return $backupPath } function Restore-ManagedFiles { param( [Parameter(Mandatory)][string]$BackupPath, [Parameter(Mandatory)][string]$TargetPath ) foreach ($name in $ManagedReleaseFiles) { $backupFile = Join-Path $BackupPath $name $targetFile = Join-Path $TargetPath $name if (Test-Path -LiteralPath $backupFile) { Copy-Item -LiteralPath $backupFile -Destination $targetFile -Force } } } function Install-ExtractedRelease { param( [Parameter(Mandatory)][string]$ReleaseRoot, [Parameter(Mandatory)][string]$TargetPath ) $required = @( "SMTPGraphRelay.ps1", "version.json" ) foreach ($name in $required) { if (-not (Test-Path -LiteralPath (Join-Path $ReleaseRoot $name))) { throw "Updatepaket ist unvollständig: '$name' fehlt." } } foreach ($name in $ManagedReleaseFiles) { $source = Join-Path $ReleaseRoot $name if (Test-Path -LiteralPath $source) { Copy-Item -LiteralPath $source -Destination (Join-Path $TargetPath $name) -Force Write-Ok "Aktualisiert: $name" } } } function Invoke-PostUpdateHealthCheck { param( [Parameter(Mandatory)][string]$TargetPath ) $health = Join-Path $TargetPath $HealthFileName if (-not (Test-Path -LiteralPath $health)) { Write-Warn "Health Check ist nicht installiert; automatische Nachprüfung entfällt." return 0 } Write-Info "Starte Health Check nach dem Update..." & $health -ConfigPath (Join-Path $TargetPath $ConfigFileName) return $LASTEXITCODE } function Install-New { Write-Title "SMTPGraphRelay - Neuinstallation aus Gitea" if (Test-Path -LiteralPath (Join-Path $InstallPath $ConfigFileName)) { Write-Warn "Es existiert bereits eine config.json unter:" Write-Host " $InstallPath" Write-Warn "Neuinstallation würde eine neue App/Zertifikat erzeugen." if (-not (Confirm-Yes "Trotzdem fortfahren?")) { return } } $staging = $null try { $staging = New-RepoStagingArea Ensure-Modules -IncludeExchange Ensure-Directories -TargetPath $InstallPath # Nur Programmdateien aus Git übernehmen. Install-RepoProgramFiles ` -ReleaseRoot $staging.ReleaseRoot ` -TargetPath $InstallPath Write-Ok "Programmdateien aus Gitea installiert." $appName = Read-Default "Name der Entra App" $AppDefaultName $senderMailbox = Read-Host "M365-Absenderpostfach (z.B. info@firma.de)" while ([string]::IsNullOrWhiteSpace($senderMailbox) -or $senderMailbox -notmatch '^[^@\s]+@[^@\s]+\.[^@\s]+$') { $senderMailbox = Read-Host "Bitte eine gültige Mailadresse eingeben" } $listenAddress = Read-Default "Lokale Listen-IP" "0.0.0.0" $port = [int](Read-Default "SMTP-Port" "2525") $allowedText = Read-Default "Erlaubte Netze, mit Komma getrennt" "127.0.0.1/32,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" $allowedNetworks = @($allowedText -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) Write-Info "Erzeuge Relay-Zertifikat..." $cert = New-RelayCertificate Write-Ok "Zertifikat erstellt: $($cert.Thumbprint)" Write-Info "Microsoft Graph Anmeldung erforderlich (Entra-Admin)." Connect-RelayGraphAdmin try { $tenantId = (Get-MgContext).TenantId Write-Ok "Tenant: $tenantId" $appParams = @{ DisplayName = $appName SignInAudience = "AzureADMyOrg" KeyCredentials = @( (Convert-CertToKeyCredential -Certificate $cert) ) } $app = New-MgApplication -BodyParameter $appParams Write-Ok "App Registration erstellt: $($app.AppId)" $sp = $null for ($i = 0; $i -lt 10 -and -not $sp; $i++) { try { $sp = New-MgServicePrincipal -AppId $app.AppId } catch { Start-Sleep -Seconds 2 } } if (-not $sp) { throw "Entra Service Principal konnte nicht erstellt werden." } Write-Ok "Entra Service Principal erstellt: $($sp.Id)" Test-NoGlobalMailSend -ServicePrincipalObjectId $sp.Id Ensure-ExchangeRbac ` -TenantId $tenantId ` -ClientId $app.AppId ` -ServicePrincipalObjectId $sp.Id ` -AppName $appName ` -SenderMailbox $senderMailbox $config = [ordered]@{ Smtp = [ordered]@{ ListenAddress = $listenAddress Port = $port Hostname = $env:COMPUTERNAME AllowedNetworks = $allowedNetworks MaxMessageSizeMB = 25 ClientTimeoutSeconds = 120 MaxConcurrentClients = 20 } Graph = [ordered]@{ TenantId = $tenantId ClientId = $app.AppId CertificateThumbprint = $cert.Thumbprint SenderMailbox = $senderMailbox ForceSender = $true CertificateWarningDays = 60 CertificateCriticalDays = 14 CertificateCheckHours = 12 } Queue = [ordered]@{ PollSeconds = 10 MaxRetries = 8 RetryMinutes = @(1,5,15,30,60,120,240,480) } Paths = [ordered]@{ Queue = "queue" Failed = "failed" Logs = "logs" } Logging = [ordered]@{ MaxFileSizeMB = 10 RetentionDays = 30 CleanupHours = 12 } Update = [ordered]@{ Repository = $RepoBaseUrl Branch = "main" } } Write-RelayConfig -Config $config -TargetPath $InstallPath Ensure-FirewallRule -Port $port Ensure-ScheduledTask -TargetPath $InstallPath Write-Info "Teste App-only Anmeldung mit Relay-Zertifikat..." Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null Connect-MgGraph ` -TenantId $tenantId ` -ClientId $app.AppId ` -Certificate $cert ` -NoWelcome | Out-Null $ctx = Get-MgContext if (-not $ctx -or $ctx.AuthType -ne "AppOnly") { throw "App-only Anmeldung konnte nicht bestätigt werden." } Write-Ok "App-only Anmeldung funktioniert." Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null Start-RelayTask $health = Join-Path $InstallPath $HealthFileName if (Test-Path -LiteralPath $health) { Write-Info "Starte abschließenden Health Check..." & $health -ConfigPath (Join-Path $InstallPath $ConfigFileName) $healthExit = $LASTEXITCODE if ($healthExit -ge 2) { Write-Warn "Installation abgeschlossen, Health Check meldet Fehler. Bitte Ausgabe prüfen." } } Write-Title "Neuinstallation abgeschlossen" Write-Host "Version: $($staging.VersionInfo.Version)" Write-Host "Installationspfad: $InstallPath" Write-Host "Client ID: $($app.AppId)" Write-Host "Tenant ID: $tenantId" Write-Host "Sender: $senderMailbox" Write-Host "SMTP: $listenAddress`:$port" } finally { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } } finally { Remove-RepoStagingArea -Staging $staging } } function Repair-Installation { Write-Title "SMTPGraphRelay - Repair aus Gitea" $config = Get-RelayConfig -TargetPath $InstallPath if (-not $config) { Write-Fail "Keine config.json gefunden. Repair ist nur für bestehende Installationen gedacht." Write-Info "Bitte Neuinstallation verwenden." return } $staging = $null $backupPath = $null try { $staging = New-RepoStagingArea Ensure-Modules Ensure-Directories -TargetPath $InstallPath $certPath = "Cert:\LocalMachine\My\$($config.Graph.CertificateThumbprint)" $cert = Get-Item -LiteralPath $certPath -ErrorAction SilentlyContinue if (-not $cert) { Write-Fail "Konfiguriertes Zertifikat fehlt: $($config.Graph.CertificateThumbprint)" Write-Warn "Repair erzeugt absichtlich kein neues Credential. Nutze Zertifikat erneuern." } elseif (-not $cert.HasPrivateKey) { Write-Fail "Konfiguriertes Zertifikat besitzt keinen privaten Schlüssel." } else { Write-Ok "Zertifikat vorhanden und besitzt Private Key." } Write-Info "Sichere aktuelle Programmdateien..." $backupPath = Backup-ManagedFiles -TargetPath $InstallPath Write-Ok "Backup: $backupPath" Stop-RelayTask Install-RepoProgramFiles ` -ReleaseRoot $staging.ReleaseRoot ` -TargetPath $InstallPath Ensure-FirewallRule -Port ([int]$config.Smtp.Port) Ensure-ScheduledTask -TargetPath $InstallPath Start-RelayTask $healthExit = Invoke-PostUpdateHealthCheck -TargetPath $InstallPath if ($healthExit -ge 2) { throw "Health Check nach Repair meldet FEHLER (ExitCode $healthExit)." } if ($healthExit -eq 1) { Write-Warn "Repair abgeschlossen, Health Check enthält Warnungen." } else { Write-Ok "Repair Health Check erfolgreich." } Write-Title "Repair abgeschlossen" Write-Host "Installierte Repo-Version: $($staging.VersionInfo.Version)" } catch { Write-Fail "Repair fehlgeschlagen: $($_.Exception.Message)" if ($backupPath -and (Test-Path -LiteralPath $backupPath)) { Write-Warn "Stelle vorherige Programmdateien wieder her..." try { Stop-RelayTask Restore-ManagedFiles -BackupPath $backupPath -TargetPath $InstallPath Ensure-ScheduledTask -TargetPath $InstallPath Start-RelayTask Write-Ok "Rollback nach Repair abgeschlossen." } catch { Write-Fail "Repair-Rollback fehlgeschlagen: $($_.Exception.Message)" } } } finally { Remove-RepoStagingArea -Staging $staging } } function Update-Relay { Write-Title "SMTPGraphRelay - Online Update" $config = Get-RelayConfig -TargetPath $InstallPath if (-not $config) { Write-Fail "Keine bestehende config.json gefunden." Write-Info "Für eine neue Installation bitte 'Neuinstallation' wählen." return } $installedVersionInfo = Get-InstalledVersion -TargetPath $InstallPath $installedVersion = $null if ($installedVersionInfo -and $installedVersionInfo.Version) { $installedVersion = [string]$installedVersionInfo.Version Write-Info "Installierte Version: $installedVersion" } else { Write-Warn "Keine installierte version.json gefunden." $installedVersion = Read-Host "Installierte Version manuell eingeben (z.B. 1.5.0)" if ([string]::IsNullOrWhiteSpace($installedVersion)) { Write-Fail "Ohne lokale Versionsinformation kann kein sicheres Online-Update durchgeführt werden." return } } Write-Info "Prüfe Gitea auf neue Version..." Write-Info "Repository: $RepoBaseUrl" try { $remoteInfo = Get-RemoteVersion } catch { Write-Fail "Remote-Version konnte nicht geladen werden: $($_.Exception.Message)" return } $remoteVersion = [string]$remoteInfo.Version Write-Ok "Remote-Version: $remoteVersion" try { $comparison = Compare-RelayVersions -Installed $installedVersion -Remote $remoteVersion } catch { Write-Fail $_.Exception.Message return } if ($comparison -eq 0) { Write-Ok "SMTPGraphRelay ist bereits aktuell ($installedVersion)." return } if ($comparison -lt 0) { Write-Warn "Die installierte Version ($installedVersion) ist neuer als main ($remoteVersion)." if (-not (Confirm-Yes "Downgrade auf $remoteVersion durchführen?")) { return } } else { Write-Host "" Write-Host "Update verfügbar:" -ForegroundColor Green Write-Host " Installiert: $installedVersion" Write-Host " Neu: $remoteVersion" -ForegroundColor Yellow Write-Host "" if (-not (Confirm-Yes "Update auf $remoteVersion installieren?")) { return } } Enable-Tls12 $updateRoot = Join-Path $env:TEMP ("SMTPGraphRelay-update-{0}" -f [guid]::NewGuid().ToString("N")) $archivePath = Join-Path $updateRoot "main.zip" $extractPath = Join-Path $updateRoot "extract" New-Item -ItemType Directory -Path $updateRoot -Force | Out-Null New-Item -ItemType Directory -Path $extractPath -Force | Out-Null $backupPath = $null $taskWasRunning = $false try { Write-Info "Lade Repository-Archiv..." Invoke-WebRequest ` -Uri $RemoteArchiveUrl ` -OutFile $archivePath ` -UseBasicParsing ` -TimeoutSec 120 ` -ErrorAction Stop if (-not (Test-Path -LiteralPath $archivePath)) { throw "Download des Updatearchivs fehlgeschlagen." } Write-Ok "Archiv heruntergeladen." Expand-Archive ` -LiteralPath $archivePath ` -DestinationPath $extractPath ` -Force $releaseRoot = Find-ExtractedReleaseRoot -ExtractPath $extractPath Write-Ok "Release im Archiv gefunden: $releaseRoot" $downloadedVersionInfo = Get-Content ` -LiteralPath (Join-Path $releaseRoot "version.json") ` -Raw ` -Encoding UTF8 | ConvertFrom-Json if (-not $downloadedVersionInfo.Version) { throw "version.json im Archiv enthält keine Version." } if ([string]$downloadedVersionInfo.Version -ne $remoteVersion) { throw "Versionskonflikt: version.json-URL meldet $remoteVersion, Archiv enthält $($downloadedVersionInfo.Version)." } $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue if ($task -and $task.State -eq "Running") { $taskWasRunning = $true } Write-Info "Erstelle Backup der verwalteten Programmdateien..." $backupPath = Backup-ManagedFiles -TargetPath $InstallPath Write-Ok "Backup: $backupPath" Stop-RelayTask Write-Info "Installiere Release $remoteVersion..." Install-ExtractedRelease ` -ReleaseRoot $releaseRoot ` -TargetPath $InstallPath Ensure-Directories -TargetPath $InstallPath Ensure-FirewallRule -Port ([int]$config.Smtp.Port) Ensure-ScheduledTask -TargetPath $InstallPath Start-RelayTask $healthExit = Invoke-PostUpdateHealthCheck -TargetPath $InstallPath if ($healthExit -ge 2) { throw "Health Check nach Update meldet FEHLER (ExitCode $healthExit)." } if ($healthExit -eq 1) { Write-Warn "Update erfolgreich, Health Check enthält Warnungen." } else { Write-Ok "Health Check nach Update erfolgreich." } Write-Title "Online Update abgeschlossen" Write-Host "Vorher: $installedVersion" Write-Host "Jetzt: $remoteVersion" -ForegroundColor Green Write-Host "Backup: $backupPath" } catch { Write-Host "" Write-Fail "Update fehlgeschlagen: $($_.Exception.Message)" if ($backupPath -and (Test-Path -LiteralPath $backupPath)) { Write-Warn "Automatischer Rollback wird durchgeführt..." try { Stop-RelayTask Restore-ManagedFiles ` -BackupPath $backupPath ` -TargetPath $InstallPath Ensure-ScheduledTask -TargetPath $InstallPath Start-RelayTask Write-Ok "Rollback abgeschlossen." } catch { Write-Fail "Rollback fehlgeschlagen: $($_.Exception.Message)" Write-Warn "Backup liegt unter: $backupPath" } } } finally { Remove-Item -LiteralPath $updateRoot -Recurse -Force -ErrorAction SilentlyContinue } } function Verify-CloudRbac { Write-Title "SMTPGraphRelay - Entra / Exchange RBAC prüfen" $config = Get-RelayConfig -TargetPath $InstallPath if (-not $config) { Write-Fail "config.json nicht gefunden." return } Ensure-Modules -IncludeExchange Write-Info "Microsoft Graph Anmeldung erforderlich (Entra-Admin)." Connect-RelayGraphAdmin -TenantId $config.Graph.TenantId try { $app = Get-MgApplication -Filter "appId eq '$($config.Graph.ClientId)'" -Property "id,appId,displayName,keyCredentials" | Select-Object -First 1 if (-not $app) { Write-Fail "App Registration mit ClientId $($config.Graph.ClientId) nicht gefunden." return } Write-Ok "App Registration gefunden: $($app.DisplayName)" $sp = Get-MgServicePrincipal -Filter "appId eq '$($config.Graph.ClientId)'" -Property "id,appId,displayName" | Select-Object -First 1 if (-not $sp) { Write-Fail "Entra Service Principal nicht gefunden." return } Write-Ok "Entra Service Principal gefunden: $($sp.Id)" Test-NoGlobalMailSend -ServicePrincipalObjectId $sp.Id Import-Module ExchangeOnlineManagement -Force -ErrorAction Stop Write-Info "Exchange Online Anmeldung erforderlich (Admin)." Connect-ExchangeOnline -ShowBanner:$false -ErrorAction Stop try { $auth = Test-ServicePrincipalAuthorization ` -Identity $sp.Id ` -Resource $config.Graph.SenderMailbox $role = $auth | Where-Object { $_.RoleName -eq "Application Mail.Send" } | Select-Object -First 1 if ($role -and $role.InScope) { Write-Ok "Exchange Application Mail.Send: SenderMailbox ist InScope." if ($role.AllowedResourceScope) { Write-Info "AllowedResourceScope: $($role.AllowedResourceScope)" } } else { Write-Fail "Exchange Application Mail.Send fehlt oder SenderMailbox ist nicht InScope." } } finally { Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue } } finally { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } } function Invoke-CertificateRenewal { Write-Title "SMTPGraphRelay - Zertifikat erneuern" $renew = Join-Path $InstallPath $RenewFileName if (-not (Test-Path -LiteralPath $renew)) { Write-Fail "$RenewFileName ist nicht installiert." return } & $renew -ConfigPath (Join-Path $InstallPath $ConfigFileName) } function Invoke-HealthCheck { Write-Title "SMTPGraphRelay - Health Check" $health = Join-Path $InstallPath $HealthFileName if (-not (Test-Path -LiteralPath $health)) { Write-Fail "$HealthFileName ist nicht installiert." return } & $health -ConfigPath (Join-Path $InstallPath $ConfigFileName) } function Uninstall-Relay { Write-Title "SMTPGraphRelay - Deinstallation" $config = Get-RelayConfig -TargetPath $InstallPath Write-Warn "Lokale Deinstallation entfernt Task, Firewallregel und auf Wunsch das lokale Zertifikat." if (-not (Confirm-Yes "Lokale SMTPGraphRelay-Installation wirklich entfernen?")) { return } Stop-RelayTask Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue Write-Ok "Scheduled Task entfernt." Get-NetFirewallRule -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like "SMTPGraphRelay TCP *" } | Remove-NetFirewallRule -ErrorAction SilentlyContinue Write-Ok "SMTPGraphRelay Firewallregeln entfernt." if ($config -and $config.Graph.CertificateThumbprint) { if (Confirm-Yes "Lokales Relay-Zertifikat $($config.Graph.CertificateThumbprint) entfernen?") { Remove-Item -LiteralPath "Cert:\LocalMachine\My\$($config.Graph.CertificateThumbprint)" -Force -ErrorAction SilentlyContinue Write-Ok "Lokales Zertifikat entfernt." } } if ($config -and (Confirm-Yes "Auch Entra-App und Exchange-RBAC-Objekte entfernen?")) { Ensure-Modules -IncludeExchange Write-Warn "Cloud-Cleanup ist destruktiv und betrifft die konfigurierte ClientId:" Write-Host " $($config.Graph.ClientId)" -ForegroundColor Yellow if (Confirm-Yes "Cloud-Cleanup endgültig bestätigen?") { Connect-RelayGraphAdmin -TenantId $config.Graph.TenantId try { $app = Get-MgApplication -Filter "appId eq '$($config.Graph.ClientId)'" -Property "id,appId,displayName" | Select-Object -First 1 $sp = Get-MgServicePrincipal -Filter "appId eq '$($config.Graph.ClientId)'" -Property "id,appId,displayName" | Select-Object -First 1 if ($sp) { Import-Module ExchangeOnlineManagement -Force -ErrorAction Stop Connect-ExchangeOnline -ShowBanner:$false -ErrorAction Stop try { $shortId = $config.Graph.ClientId.Substring(0,8) $scopeName = "SMTPGraphRelay-$shortId-Sender" $assignmentName = "SMTPGraphRelay-$shortId-MailSend" Remove-ManagementRoleAssignment -Identity $assignmentName -Confirm:$false -ErrorAction SilentlyContinue Remove-ManagementScope -Identity $scopeName -Confirm:$false -ErrorAction SilentlyContinue # Exchange Service Principal Referenz löschen, wenn Cmdlet verfügbar. if (Get-Command Remove-ServicePrincipal -ErrorAction SilentlyContinue) { Remove-ServicePrincipal -Identity $sp.Id -Confirm:$false -ErrorAction SilentlyContinue } Write-Ok "Exchange-RBAC-Objekte bereinigt." } finally { Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue } Remove-MgServicePrincipal -ServicePrincipalId $sp.Id -ErrorAction SilentlyContinue Write-Ok "Entra Service Principal entfernt." } if ($app) { Remove-MgApplication -ApplicationId $app.Id -ErrorAction SilentlyContinue Write-Ok "Entra App Registration entfernt." } } finally { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } } } # Programmdateien. Wenn der Installer selbst aus dem Zielordner läuft, kann er # sich nicht zuverlässig selbst löschen. Dann bleiben Setup + Ordner bis nach Ende stehen. $currentInstaller = [IO.Path]::GetFullPath($PSCommandPath) $targetFull = [IO.Path]::GetFullPath($InstallPath) foreach ($item in Get-ChildItem -LiteralPath $InstallPath -Force -ErrorAction SilentlyContinue) { try { if ($item.FullName -eq $currentInstaller) { continue } Remove-Item -LiteralPath $item.FullName -Recurse -Force -ErrorAction Stop } catch { Write-Warn "Konnte nicht entfernen: $($item.FullName)" } } Write-Ok "Lokale Programmdateien entfernt." if ($currentInstaller.StartsWith($targetFull, [StringComparison]::OrdinalIgnoreCase)) { Write-Warn "Der aktuell laufende Installer bleibt übrig. Nach dem Beenden kann '$InstallPath' manuell gelöscht werden." } Write-Title "Deinstallation abgeschlossen" } function Show-Status { Write-Title "SMTPGraphRelay - Status" $configPath = Join-Path $InstallPath $ConfigFileName Write-Host "Installationspfad: $InstallPath" if (Test-Path -LiteralPath $configPath) { Write-Ok "config.json vorhanden." try { $config = Get-RelayConfig -TargetPath $InstallPath Write-Host " Sender: $($config.Graph.SenderMailbox)" Write-Host " SMTP: $($config.Smtp.ListenAddress):$($config.Smtp.Port)" Write-Host " Client: $($config.Graph.ClientId)" } catch {} } else { Write-Warn "Keine config.json vorhanden." } $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue if ($task) { Write-Host "Task State: $($task.State)" } else { Write-Warn "Scheduled Task nicht vorhanden." } } function Show-Menu { Clear-Host Write-Title "SMTPGraphRelay - Bootstrap / Repair / Online Update" Write-Host "Installationspfad: $InstallPath" -ForegroundColor DarkGray $installedVersion = Get-InstalledVersion -TargetPath $InstallPath if ($installedVersion -and $installedVersion.Version) { Write-Host "Installiert: $($installedVersion.Version)" -ForegroundColor DarkGray } Write-Host "Updatequelle: Gitea / main" -ForegroundColor DarkGray Write-Host "" Write-Host " [1] Neuinstallation aus Gitea" Write-Host " [2] Installation aus Gitea reparieren" Write-Host " [3] Nach Online-Updates suchen" Write-Host " [4] Entra / Exchange RBAC prüfen" Write-Host " [5] Zertifikat erneuern" Write-Host " [6] Health Check ausführen" Write-Host " [7] Deinstallieren" Write-Host " [8] Status anzeigen" Write-Host " [0] Beenden" Write-Host "" } Assert-WindowsPowerShell51 while ($true) { Show-Menu $choice = Read-Host "Auswahl" try { switch ($choice) { "1" { Install-New } "2" { Repair-Installation } "3" { Update-Relay } "4" { Verify-CloudRbac } "5" { Invoke-CertificateRenewal } "6" { Invoke-HealthCheck } "7" { Uninstall-Relay } "8" { Show-Status } "0" { break } default { Write-Warn "Ungültige Auswahl." } } } catch { Write-Host "" Write-Fail $_.Exception.Message if ($_.ScriptStackTrace) { Write-Host $_.ScriptStackTrace -ForegroundColor DarkYellow } } if ($choice -ne "0") { Write-Host "" Read-Host "Enter drücken, um zum Menü zurückzukehren" } if ($choice -eq "0") { break } }