diff --git a/Setup-SMTPGraphRelay(4).ps1 b/Setup-SMTPGraphRelay(4).ps1 deleted file mode 100644 index f4e843e..0000000 --- a/Setup-SMTPGraphRelay(4).ps1 +++ /dev/null @@ -1,1931 +0,0 @@ -#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 - MaxRecipients = 50 - MaxMessagesPerConnection = 25 - RequireAuth = $false - AuthMaxFailures = 5 - AllowUnauthenticatedNetworks = @() - AuthUsers = @() - } - 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) - MaxPendingMessages = 5000 - MinFreeDiskSpaceMB = 1024 - } - 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 ConvertTo-PlainText { - param([Parameter(Mandatory)][Security.SecureString]$SecureString) - - $ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureString) - - try { - return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr) - } - finally { - [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) - } -} - -function Invoke-Pbkdf2Sha256 { - param( - [Parameter(Mandatory)][string]$Password, - [Parameter(Mandatory)][byte[]]$Salt, - [Parameter(Mandatory)][int]$Iterations, - [int]$Length = 32 - ) - - if ($Iterations -lt 1) { - throw "Iterations must be greater than zero." - } - - # Auf unterstützten .NET-Framework-Versionen verwenden wir die native, - # schnelle PBKDF2-SHA256-Implementierung. - try { - $derive = New-Object System.Security.Cryptography.Rfc2898DeriveBytes( - $Password, - $Salt, - $Iterations, - [System.Security.Cryptography.HashAlgorithmName]::SHA256 - ) - - try { - return $derive.GetBytes($Length) - } - finally { - $derive.Dispose() - } - } - catch { - # Kompatibilitäts-Fallback für ältere .NET-Framework-Stände. - $hmac = New-Object System.Security.Cryptography.HMACSHA256 - $hmac.Key = [Text.Encoding]::UTF8.GetBytes($Password) - - try { - $hashLength = 32 - $blocks = [Math]::Ceiling($Length / [double]$hashLength) - $output = New-Object byte[] ($blocks * $hashLength) - $offset = 0 - - for ($block = 1; $block -le $blocks; $block++) { - $blockBytes = [BitConverter]::GetBytes([int]$block) - if ([BitConverter]::IsLittleEndian) { - [Array]::Reverse($blockBytes) - } - - $input = New-Object byte[] ($Salt.Length + 4) - [Array]::Copy($Salt, 0, $input, 0, $Salt.Length) - [Array]::Copy($blockBytes, 0, $input, $Salt.Length, 4) - - $u = $hmac.ComputeHash($input) - $t = New-Object byte[] $u.Length - [Array]::Copy($u, $t, $u.Length) - - for ($i = 2; $i -le $Iterations; $i++) { - $u = $hmac.ComputeHash($u) - for ($j = 0; $j -lt $t.Length; $j++) { - $t[$j] = $t[$j] -bxor $u[$j] - } - } - - [Array]::Copy($t, 0, $output, $offset, $t.Length) - $offset += $t.Length - } - - $result = New-Object byte[] $Length - [Array]::Copy($output, 0, $result, 0, $Length) - return $result - } - finally { - $hmac.Dispose() - } - } -} - -function New-SmtpPasswordRecord { - param([Parameter(Mandatory)][Security.SecureString]$Password) - - $plain = ConvertTo-PlainText -SecureString $Password - - try { - $salt = New-Object byte[] 16 - $rng = [Security.Cryptography.RandomNumberGenerator]::Create() - - try { - $rng.GetBytes($salt) - } - finally { - $rng.Dispose() - } - - $iterations = 150000 - $hash = Invoke-Pbkdf2Sha256 ` - -Password $plain ` - -Salt $salt ` - -Iterations $iterations ` - -Length 32 - - return [pscustomobject]@{ - Salt = [Convert]::ToBase64String($salt) - PasswordHash = [Convert]::ToBase64String($hash) - Iterations = $iterations - } - } - finally { - $plain = $null - } -} - -function Ensure-SmtpAuthConfig { - param([Parameter(Mandatory)]$Config) - - if (-not ($Config.Smtp.PSObject.Properties.Name -contains "RequireAuth")) { - $Config.Smtp | Add-Member -NotePropertyName RequireAuth -NotePropertyValue $false - } - - if (-not ($Config.Smtp.PSObject.Properties.Name -contains "AuthMaxFailures")) { - $Config.Smtp | Add-Member -NotePropertyName AuthMaxFailures -NotePropertyValue 5 - } - - if (-not ($Config.Smtp.PSObject.Properties.Name -contains "AllowUnauthenticatedNetworks")) { - $Config.Smtp | Add-Member -NotePropertyName AllowUnauthenticatedNetworks -NotePropertyValue @() - } - - if (-not ($Config.Smtp.PSObject.Properties.Name -contains "AuthUsers")) { - $Config.Smtp | Add-Member -NotePropertyName AuthUsers -NotePropertyValue @() - } - - return $Config -} - -function Save-SmtpAuthConfigAndRestart { - param([Parameter(Mandatory)]$Config) - - Write-RelayConfig -Config $Config -TargetPath $InstallPath - Ensure-ScheduledTask -TargetPath $InstallPath - - Stop-RelayTask - Start-RelayTask -} - -function Manage-SmtpAuth { - $config = Get-RelayConfig -TargetPath $InstallPath - - if (-not $config) { - Write-Fail "config.json nicht gefunden." - return - } - - $config = Ensure-SmtpAuthConfig -Config $config - - while ($true) { - Clear-Host - Write-Title "SMTPGraphRelay - SMTP-AUTH" - - $users = @($config.Smtp.AuthUsers) - $authState = if ([bool]$config.Smtp.RequireAuth) { "ERFORDERLICH" } else { "optional / nicht erforderlich" } - - Write-Host "Status: $authState" - Write-Host "Benutzer: $($users.Count)" - Write-Host "Max. Fehlversuche: $($config.Smtp.AuthMaxFailures)" - Write-Host "Ohne Auth erlaubte Netze: $(@($config.Smtp.AllowUnauthenticatedNetworks) -join ', ')" - Write-Host "" - Write-Host " [1] SMTP-AUTH erforderlich EIN/AUS" - Write-Host " [2] Benutzer hinzufügen" - Write-Host " [3] Benutzer anzeigen" - Write-Host " [4] Passwort ändern" - Write-Host " [5] Benutzer löschen" - Write-Host " [6] Netze ohne Auth verwalten" - Write-Host " [7] Max. Fehlversuche ändern" - Write-Host " [0] Zurück" - Write-Host "" - - $choice = Read-Host "Auswahl" - - switch ($choice) { - "1" { - $config.Smtp.RequireAuth = -not [bool]$config.Smtp.RequireAuth - - if ($config.Smtp.RequireAuth -and @($config.Smtp.AuthUsers).Count -eq 0) { - Write-Warn "AUTH wurde aktiviert, aber es existiert noch kein SMTP-Benutzer." - } - - Save-SmtpAuthConfigAndRestart -Config $config - Write-Ok "SMTP-AUTH Status geändert." - Read-Host "Enter" - } - - "2" { - $username = Read-Host "Benutzername" - - if ([string]::IsNullOrWhiteSpace($username) -or $username -notmatch '^[A-Za-z0-9._@-]{1,128}$') { - Write-Fail "Ungültiger Benutzername." - Read-Host "Enter" - continue - } - - $existing = @($config.Smtp.AuthUsers) | - Where-Object { ([string]$_.Username).Equals($username, [StringComparison]::OrdinalIgnoreCase) } | - Select-Object -First 1 - - if ($existing) { - Write-Fail "Benutzer '$username' existiert bereits." - Read-Host "Enter" - continue - } - - $p1 = Read-Host "Passwort" -AsSecureString - $p2 = Read-Host "Passwort wiederholen" -AsSecureString - - $plain1 = ConvertTo-PlainText -SecureString $p1 - $plain2 = ConvertTo-PlainText -SecureString $p2 - - try { - if ($plain1.Length -lt 8) { - Write-Fail "Passwort muss mindestens 8 Zeichen lang sein." - Read-Host "Enter" - continue - } - - if ($plain1 -cne $plain2) { - Write-Fail "Passwörter stimmen nicht überein." - Read-Host "Enter" - continue - } - } - finally { - $plain1 = $null - $plain2 = $null - } - - $record = New-SmtpPasswordRecord -Password $p1 - - $newUser = [pscustomobject]@{ - Username = $username - Salt = $record.Salt - PasswordHash = $record.PasswordHash - Iterations = $record.Iterations - } - - $config.Smtp.AuthUsers = @($config.Smtp.AuthUsers) + @($newUser) - - Save-SmtpAuthConfigAndRestart -Config $config - Write-Ok "SMTP-Benutzer '$username' angelegt." - Read-Host "Enter" - } - - "3" { - Write-Host "" - - if (@($config.Smtp.AuthUsers).Count -eq 0) { - Write-Warn "Keine SMTP-Benutzer vorhanden." - } - else { - @($config.Smtp.AuthUsers) | - Select-Object Username,Iterations | - Format-Table -AutoSize - } - - Read-Host "Enter" - } - - "4" { - $username = Read-Host "Benutzername" - - $user = @($config.Smtp.AuthUsers) | - Where-Object { ([string]$_.Username).Equals($username, [StringComparison]::OrdinalIgnoreCase) } | - Select-Object -First 1 - - if (-not $user) { - Write-Fail "Benutzer '$username' nicht gefunden." - Read-Host "Enter" - continue - } - - $p1 = Read-Host "Neues Passwort" -AsSecureString - $p2 = Read-Host "Passwort wiederholen" -AsSecureString - - $plain1 = ConvertTo-PlainText -SecureString $p1 - $plain2 = ConvertTo-PlainText -SecureString $p2 - - try { - if ($plain1.Length -lt 8) { - Write-Fail "Passwort muss mindestens 8 Zeichen lang sein." - Read-Host "Enter" - continue - } - - if ($plain1 -cne $plain2) { - Write-Fail "Passwörter stimmen nicht überein." - Read-Host "Enter" - continue - } - } - finally { - $plain1 = $null - $plain2 = $null - } - - $record = New-SmtpPasswordRecord -Password $p1 - $user.Salt = $record.Salt - $user.PasswordHash = $record.PasswordHash - $user.Iterations = $record.Iterations - - Save-SmtpAuthConfigAndRestart -Config $config - Write-Ok "Passwort für '$username' geändert." - Read-Host "Enter" - } - - "5" { - $username = Read-Host "Benutzername" - - $found = @($config.Smtp.AuthUsers) | - Where-Object { ([string]$_.Username).Equals($username, [StringComparison]::OrdinalIgnoreCase) } - - if (-not $found) { - Write-Fail "Benutzer '$username' nicht gefunden." - Read-Host "Enter" - continue - } - - if (Confirm-Yes "Benutzer '$username' wirklich löschen?") { - $config.Smtp.AuthUsers = @( - $config.Smtp.AuthUsers | - Where-Object { -not ([string]$_.Username).Equals($username, [StringComparison]::OrdinalIgnoreCase) } - ) - - Save-SmtpAuthConfigAndRestart -Config $config - Write-Ok "Benutzer '$username' gelöscht." - } - - Read-Host "Enter" - } - - "6" { - $current = @($config.Smtp.AllowUnauthenticatedNetworks) -join "," - $input = Read-Default "Netze/IPs ohne AUTH, Komma getrennt; '-' für keine" $(if ($current) { $current } else { "-" }) - - if ($input -eq "-") { - $config.Smtp.AllowUnauthenticatedNetworks = @() - } - else { - $config.Smtp.AllowUnauthenticatedNetworks = @( - $input -split "," | - ForEach-Object { $_.Trim() } | - Where-Object { $_ } - ) - } - - Save-SmtpAuthConfigAndRestart -Config $config - Write-Ok "Ausnahmen für SMTP-AUTH gespeichert." - Read-Host "Enter" - } - - "7" { - $value = Read-Host "Maximale Fehlversuche pro Verbindung [aktuell: $($config.Smtp.AuthMaxFailures)]" - - if ($value -match '^\d+$' -and [int]$value -ge 1 -and [int]$value -le 100) { - $config.Smtp.AuthMaxFailures = [int]$value - Save-SmtpAuthConfigAndRestart -Config $config - Write-Ok "Maximale Fehlversuche geändert." - } - else { - Write-Fail "Bitte einen Wert zwischen 1 und 100 eingeben." - } - - Read-Host "Enter" - } - - "0" { - return - } - - default { - Write-Warn "Ungültige Auswahl." - Start-Sleep -Seconds 1 - } - } - - # Config nach jeder Änderung neu laden, damit Serialisierung/Arrays exakt - # dem installierten Zustand entsprechen. - $config = Get-RelayConfig -TargetPath $InstallPath - $config = Ensure-SmtpAuthConfig -Config $config - } -} - -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)" - - if ($config.Smtp.PSObject.Properties.Name -contains "RequireAuth") { - $authText = if ([bool]$config.Smtp.RequireAuth) { "erforderlich" } else { "optional / aus" } - $authUsers = if ($config.Smtp.PSObject.Properties.Name -contains "AuthUsers") { @($config.Smtp.AuthUsers).Count } else { 0 } - Write-Host " AUTH: $authText ($authUsers Benutzer)" - } - } 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 " [9] SMTP-AUTH verwalten" - 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 } - "9" { Manage-SmtpAuth } - "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 - } -}